198df02d2a
* test(ml): add an embedding-space fingerprint tool (#1084) requirements.txt is pinned exactly so rebuilds produce byte-identical embeddings, but nothing verified that. The API tests stub FacePipeline, so a decode/resize/kernel change could move every stored cluster without failing anything. This prints a hash per stage — decode, resize, cvtColor, YuNet detect, FaceNet forward pass — so the old and new image can be diffed on the same host before any base-image or dependency bump lands. Used it to answer the open question in #1084: Debian/Python 3.12 and Wolfi/Python 3.14 produce identical hashes at every stage, so a base swap would not invalidate stored clusters. The input is generated rather than a fixture, and the hashes are deliberately not compared across architectures — OpenCV and onnxruntime dispatch different SIMD kernels on x86 and aarch64, so this answers "did this change move the numbers", not "is every platform identical". * test(ml): fingerprint the production path, not a parallel one External review found the first cut was largely theatre. The documented command could not run: tools/ is in .dockerignore and the Dockerfile copies only app/, so the script is never inside the image. It has to be mounted — which is what I actually did when producing the numbers, while documenting something else. Three stages were fingerprinting the wrong thing: - The detector recorded "none" plus a return status, because synthetic input has no face to find. It would have stayed green through any change to YuNet or its kernels. Now the ONNX graph is driven directly, so all twelve output heads always produce numbers, and the reported thresholds are the service's (0.6/0.3) rather than FaceDetectorYN's 0.9 default. - The embedding used a hand-rolled tensor, bypassing everything that actually places a face in the embedding space: umeyama + warpAffine, BGR->RGB, per-image standardization, layout, and the L2 normalization the backend's cosine similarity depends on. It now calls _align and _embed directly. Private, deliberately — reimplementing the maths here would drift from pipeline.py and fingerprint a path nothing runs. - Decode exercised PNG, but the worker only ever receives the preview rendition, which imageProcessor.js writes as JPEG. Now a fixed JPEG, embedded as bytes so the input cannot depend on the encoder version being held still. Verified SOI/EOI-clean; the first attempt at this produced "Corrupt JPEG data: 22 extraneous bytes". Sensitivity checked rather than assumed: a one-pixel landmark nudge moves align_warp and embed and leaves decode and the detector heads alone, which is exactly the dependency structure expected. Debian/Python 3.12 vs Wolfi/Python 3.14 remain identical across all sixteen stages, so the #1084 parity conclusion still holds under the stronger check. * test(ml): measure the image's own pipeline, and the detector OpenCV runs Two more from external review, both of which let matching hashes mean less than they claimed. The documented bind mount put the checkout's app/ ahead of the image's /app/app, so comparing two images built from different revisions would have executed the same pipeline source twice and reported a match no matter how the images differed. /app now wins whenever it exists, so the tool measures the image under test however it is invoked, and the loaded path is printed as _app_source so that is auditable rather than assumed. The detector was fingerprinted through onnxruntime, but production runs cv2.FaceDetectorYN — OpenCV's own preprocessing, DNN engine and NMS/landmark decode, none of which ORT touches. An OpenCV upgrade could therefore move real landmarks, and with them alignment and embeddings, while every detector hash held still. It now runs the OpenCV path too, with the score threshold at the floor so synthetic input still yields candidates (594 here) instead of the empty result the production 0.6 gives on an image with no face. The ORT pass is kept alongside it to separate a model change from an OpenCV change. Parity across debian/3.12 and wolfi/3.14 still holds across all 19 stages, and a one-pixel landmark nudge still moves align_warp and embed and nothing else. * test(ml): cover the orchestration and progressive decode too Round three of external review found two more ways the hashes could match while production moved. The isolated stages never fed the detector's output into alignment — _align got fixed landmarks — so INPUT_LONG_EDGE resizing and the row -> landmark scaling in _one_face were invisible. process() now runs end to end on the fixture, with the pipeline's own detector threshold dropped so a faceless frame still yields rows to carry through (26 faces here). A first attempt at that still missed the resize: the embedded fixture is 48px, so `long_edge > INPUT_LONG_EDGE` never fired and changing 1920 to 960 moved nothing. It now runs a second pass with the threshold lowered under the fixture, which executes the same downscale and inverse landmark scaling without carrying a 1920px image in the source. Verified sensitive: moving that bound 32 -> 24 changes both the face count and the embedding. The fixture was also a baseline JPEG, while generatePreview writes progressive (imageProcessor.js:236/480/617) — a different path through libjpeg. Swapped for a progressive fixture, SOF2 confirmed present and SOF0 absent. 24 stages now. Debian/3.12 and Wolfi/3.14 remain identical across all of them. * test(ml): close three more false-negative paths in the fingerprint Round four of external review. All three let hashes match while production moved. INPUT_LONG_EDGE was used but never printed. The fixture is too small to trip the resize in either image, and the forced pass overrides the value in both, so a production change from 1920 to 960 moved no hash at all. It is now emitted alongside the other thresholds, where a reviewer sees it in the diff. The fixture was square, so a width/height swap in setInputSize or the resize produced identical dimensions and identical hashes. It is now 64x48. The forced-downscale pass hashed only an embedding, which is derived from separately scaled landmarks — a regression in the inverse scaling of row[0:4] would have shown up nowhere, because the normal pass runs at scale 1. That bbox is now hashed too; a wrong one is what breaks avatar crops and area calculations. Changing the fixture to 64x48 also broke the forced pass: at the old bound of 32 the downscaled frame is 32x24 and YuNet returns nothing, so the stage pinned nothing. The NO-DETECTIONS-STAGE-VACUOUS marker added last round caught it immediately rather than printing a reassuring hash of an empty result. Bound moved to 48, which still triggers the resize and still yields rows. 25 stages, no vacuous markers. Debian/3.12 and Wolfi/3.14 identical across all of them. * test(ml): hash every detection, not just the first Round five of external review. Both end-to-end passes hashed only candidate 0, so a change that moved candidates 1..n — or merely reordered them — matched as long as the count and the first candidate held. With the threshold at the floor those passes return 24 and 27 candidates, so that was most of the evidence being thrown away. Both now stack every returned face, in order, via a shared _hash_all. Stacking preserves order, so a reshuffle is caught too. Verified against the exact case: reversing candidates 1..n while leaving the count and candidate 0 untouched now moves process_embedding and process_bbox. Before this it moved nothing. * test(ml): hash every persisted field, and emit the model version Round six of external review, plus the adjacent gaps it implied. Two findings: MODEL_VERSION was never emitted, and _hash_all discarded score. Both matter to the backend rather than to the numbers — a model_version change makes faceClustering.js:190 refuse to compare new faces against existing people, forcing a rescan, and det_score decides via meetsQualityFloor (faceClustering.js:96-100) whether a face joins clustering at all. Either could change while every hash held still. Rather than fix only the two named, I checked what faceProcessor.js actually stores per face (:157-167) and covered all of it: bbox, score, yaw, pitch, blur, embedding. yaw/pitch/blur were heading for the same finding next round. One hash per field, so a diff says which thing moved rather than only that something did. model_version is emitted as a compatibility key alongside the thresholds, not hashed — it is a string, and its job is to be read. Verified: scaling score alone by 0.999 now moves process_score and nothing else. 33 stages, no vacuous markers, debian/3.12 and wolfi/3.14 still identical. * test(ml): split verdict from diagnostic, and stop masking the threshold Round seven of external review. The ORT detector hashes were being read as part of the compatibility verdict, but production never runs YuNet through onnxruntime. An ORT change touching a YuNet operator would have moved them while real behaviour was untouched, and the docstring said any difference means re-scan — so the tool could have ordered a full-gallery rescan for nothing. They are now diag_-prefixed, and the docstring states which keys carry a verdict, which are diagnostic, and which are metadata a reviewer has to read rather than diff. setScoreThreshold(1e-6) also overwrote the detector's real threshold before anything recorded it, and _thresholds.det_score only echoes config. If FacePipeline ever stopped applying DET_SCORE_THRESHOLD — falling back to OpenCV's 0.9 default — production would detect a different face set while every hash matched. The constructed value is now read first and emitted as _effective_det_score; simulating the regression makes it read 0.9 instead of 0.6. MAX_FACES is emitted for the same reason INPUT_LONG_EDGE is: the fixture never reaches the pipeline.py:138 slice, so 64 -> 128 would move no hash while real group photos persisted a different face set. 21 verdict keys, 12 diagnostic, no vacuous markers, debian/3.12 and wolfi/3.14 still identical across both sets. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
302 lines
15 KiB
Python
302 lines
15 KiB
Python
"""
|
|
Embedding-space fingerprint (#1084): did anything shift under our feet?
|
|
|
|
`requirements.txt` is pinned exactly so that rebuilds produce byte-identical
|
|
embeddings — a decode, warp, standardization or kernel that changes behaviour
|
|
would move every stored cluster silently. That guarantee is only as strong as
|
|
our ability to check it, and "the tests pass" does not check it: the API tests
|
|
stub `FacePipeline`, so nothing compares numbers across two builds.
|
|
|
|
This prints a hash per stage. Run it against the OLD image and the NEW one, on
|
|
the SAME machine, and diff the output.
|
|
|
|
Reading the diff:
|
|
|
|
* Keys with NO prefix are the compatibility verdict. If every one matches,
|
|
stored clusters stay valid. If any differs, existing installs need a
|
|
re-scan, and the change has to be a deliberate, announced decision rather
|
|
than a side effect of a base-image or dependency bump.
|
|
* `diag_*` keys are DIAGNOSTIC ONLY and carry no verdict — they exercise a
|
|
path production does not run, and exist to localise the cause when a real
|
|
key moves. A `diag_*` change on its own is not a reason to re-scan.
|
|
* `_`-prefixed keys are metadata: versions, thresholds and the compatibility
|
|
key. They are printed rather than hashed because the fixture cannot reach
|
|
them, so a reviewer has to read them. A change to `_thresholds` or
|
|
`_effective_det_score` matters even when every hash matches.
|
|
|
|
`tools/` is in .dockerignore and never ships inside the image, so mount just
|
|
this file — NOT the whole `ml/` tree. Mounting `ml/` would put the checkout's
|
|
`app/` package ahead of the image's own `/app/app`, so both containers would
|
|
run the same pipeline source and a code difference between two images would
|
|
vanish from the comparison:
|
|
|
|
docker run --rm -v "$PWD/ml/tools:/tools:ro" --entrypoint python \
|
|
<image> /tools/fingerprint_pipeline.py
|
|
|
|
Compare on one host only. The hashes are NOT portable across architectures —
|
|
OpenCV and onnxruntime both dispatch to different SIMD kernels on x86 and
|
|
aarch64, so an amd64 result will not match an arm64 one even when nothing has
|
|
changed. This answers "did this change move the numbers", not "is every
|
|
platform identical".
|
|
|
|
Every stage runs the PRODUCTION code path. Reimplementing the maths here would
|
|
drift from `app/pipeline.py` and quietly start fingerprinting the wrong thing,
|
|
so `_align` and `_embed` are called directly even though they are private —
|
|
pinning internal numerics is the entire point.
|
|
"""
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import onnxruntime as ort
|
|
|
|
# Import the app package the IMAGE ships, not the checkout's. The whole point
|
|
# is to compare two built images; sourcing pipeline.py from a bind mount would
|
|
# make both runs execute identical code and hide exactly the differences this
|
|
# is meant to surface. /app is the runtime WORKDIR; the parent-of-tools
|
|
# fallback only exists so the tool still runs from a plain source checkout.
|
|
_IMAGE_APP_ROOT = "/app"
|
|
if os.path.isdir(os.path.join(_IMAGE_APP_ROOT, "app")):
|
|
sys.path.insert(0, _IMAGE_APP_ROOT)
|
|
else:
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
|
|
|
from app import config # noqa: E402
|
|
from app.pipeline import FacePipeline # noqa: E402
|
|
|
|
# A fixed 64x48 PROGRESSIVE JPEG, embedded as bytes rather than encoded at
|
|
# runtime. Production input is always the preview rendition, which the backend
|
|
# writes with `progressive: true` (imageProcessor.js:236/480/617) — progressive
|
|
# and baseline take different paths through libjpeg, so a baseline fixture
|
|
# would miss a change that moves every real photo. Non-square on purpose: a
|
|
# square fixture cannot tell a width/height swap from correct behaviour.
|
|
# Embedded rather than encoded here because calling cv2.imencode would make
|
|
# the *input* depend on the very encoder version we are trying to hold still.
|
|
FIXED_JPEG = base64.b64decode(
|
|
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsK"
|
|
"CwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQU"
|
|
"FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wgARCAAwAEADASIA"
|
|
"AhEBAxEB/8QAGQABAQEBAQEAAAAAAAAAAAAABQYEAwcI/8QAGQEBAAMBAQAAAAAAAAAAAAAABgMF"
|
|
"BwIE/9oADAMBAAIQAxAAAAH5t3tb+ZRkGUFduKgygqt5LnW88qWhb2UCOLjb2t6q3GQZQV3ElyrP"
|
|
"OM091OgygHzUbe1vVXA25mMawSU1W8ymW//EABkQAAMBAQEAAAAAAAAAAAAAAAACBAEDBf/aAAgB"
|
|
"AQABBQJJxJxJxJxJxJyqcScScScScScScScqnEnEnEnEnEnEnEnKpzOGLiTiTiTiTiTiTlfDFz0K"
|
|
"9r1JxJxJxJxJzOGLnvV7XqTiTiTiTiTmcMXPQr2vaZxJz//EACERAAEDAgcBAAAAAAAAAAAAAAIA"
|
|
"AQMEBRETISMxUeHw/9oACAEDAQE/AYiURKIlSFtsoiURKIlWXqG0UrSHqT8N34oiURK5XqG0Q5h6"
|
|
"k/Dd+J6+avPPnfF3+wZf/8QAIREAAQQBAwUAAAAAAAAAAAAABAABAgMFBjGyESIlYoL/2gAIAQIB"
|
|
"AT8BGKQxSGKWoCvL3fPFkMUhikMUsxKV+auZtu3iyGKQxSx0pXy6Nss3bGrK2xj68WX/xAAfEAAB"
|
|
"AwQDAQAAAAAAAAAAAAABAgMhABEgQRMwohD/2gAIAQEABj8CyR0I6EVcwBvNJMAXmuNuGR6yuYA3"
|
|
"SW24ZHrK5gDdcbcMj1SPn//EAB0QAAMAAwEBAQEAAAAAAAAAAAABYREhUcHwcYH/2gAIAQEAAT8h"
|
|
"iRJkSJE4u+ESJEiRIkTi74RIkSJEiROLvg440sttJIiRJkSJExHpM20ktG8jH4265PlEiRIkRxxp"
|
|
"ZbaSRlMYcba25PlEiRIkxxxpZbaSRvIx/W65Plzd8In/2gAMAwEAAgADAAAAEFoe/HV6rKViYv/E"
|
|
"ACARAQABAgYDAAAAAAAAAAAAAAERIUEAECCBobExUcH/2gAIAQMBAT8QzV53vQqtNVXqp4F23glQ"
|
|
"clmr7XVfBd2JUMde6CWAsFjdlVx//8QAHBEAAwEAAwEBAAAAAAAAAAAAAAEhMRFR4UHB/9oACAEC"
|
|
"AQE/EMKYUwp9IeFMKYUZlwlPr9zCmFEJ/Ca+vSf6XrZ//8QAHBABAQEAAgMBAAAAAAAAAAAAATEA"
|
|
"EcEQIUGB/9oACAEBAAE/EJ9dPI9NPJ9NPwan008n008ns8n4PTyeT2eT6aeT6+GmUUOgByqsA0+u"
|
|
"nkemnk+uniIKGgAlVgGZXY8IT6D4Gfp98GTyeTyeTxlFDoAcqrAN7F2XhCeAfBT9PvgyezyfXTyP"
|
|
"TMoodADlVYBmV2PCCfQfAz9Pvg8Op5//2Q=="
|
|
)
|
|
|
|
|
|
def _hash(array):
|
|
return hashlib.sha256(np.ascontiguousarray(array, dtype=np.float32).tobytes()).hexdigest()[:16]
|
|
|
|
|
|
# Every field of a face the backend actually stores (faceProcessor.js:157-167).
|
|
# Hashing only the embedding would approve a change that leaves the vector
|
|
# alone but moves something the backend clusters on: det_score and bbox size
|
|
# gate meetsQualityFloor (faceClustering.js:96-100), so a face can silently
|
|
# drop in or out of clustering with an identical embedding.
|
|
_PERSISTED_FIELDS = ("bbox", "score", "yaw", "pitch", "blur", "embedding")
|
|
|
|
|
|
def _hash_all(faces):
|
|
"""Hash every returned face, in order, one hash per persisted field.
|
|
|
|
Every face, not just the first: the forced-low threshold yields dozens of
|
|
candidates, and hashing only candidate 0 would report a match while a
|
|
numerical change moved candidates 1..n, or while their ORDER changed —
|
|
both of which alter what production stores. Stacking preserves order, so
|
|
a reshuffle shows up too.
|
|
|
|
One hash per field rather than one combined hash, so a diff says which
|
|
thing moved instead of only that something did.
|
|
"""
|
|
if not faces:
|
|
# Not fatal, but it means the stage pinned nothing — say so loudly
|
|
# rather than printing a reassuring hash of an empty result.
|
|
return {f: "NO-DETECTIONS-STAGE-VACUOUS" for f in _PERSISTED_FIELDS}
|
|
return {
|
|
field: _hash(np.stack([np.atleast_1d(np.asarray(f[field], dtype=np.float32)) for f in faces]))
|
|
for field in _PERSISTED_FIELDS
|
|
}
|
|
|
|
|
|
def _deterministic_bgr(size):
|
|
"""A fixed BGR image. Arithmetic, so it cannot drift with any codec."""
|
|
y, x = np.mgrid[0:size, 0:size]
|
|
return np.stack([(x * 5) % 256, (y * 5) % 256, ((x + y) * 3) % 256], axis=-1).astype(np.uint8)
|
|
|
|
|
|
def main():
|
|
import app.pipeline
|
|
|
|
out = {
|
|
"_versions": {
|
|
"python": sys.version.split()[0],
|
|
"numpy": np.__version__,
|
|
"cv2": cv2.__version__,
|
|
"onnxruntime": ort.__version__,
|
|
},
|
|
# Which pipeline.py actually ran. If this says /tools/.. or a checkout
|
|
# path instead of /app, the mount is shadowing the image's own code and
|
|
# the comparison is not measuring what it claims to.
|
|
"_app_source": app.pipeline.__file__,
|
|
}
|
|
|
|
# 1. Decode. JPEG specifically: the worker only ever sees preview renditions.
|
|
decoded = cv2.imdecode(np.frombuffer(FIXED_JPEG, np.uint8), cv2.IMREAD_COLOR)
|
|
out["decode_jpeg"] = _hash(decoded)
|
|
|
|
det_path = os.path.join(config.MODEL_DIR, config.DETECTOR_FILENAME)
|
|
det_scene = _deterministic_bgr(320)
|
|
|
|
# 2a. Detector through OpenCV, which is what production actually uses.
|
|
# cv2.FaceDetectorYN runs its own preprocessing, its own DNN engine and
|
|
# its own NMS/landmark decode — none of which onnxruntime exercises, so
|
|
# an OpenCV upgrade could move real landmarks (and therefore alignment
|
|
# and embeddings) while an ORT-only check stayed identical.
|
|
#
|
|
# The threshold is dropped to the floor ON PURPOSE. Synthetic input has
|
|
# no face, so at the production 0.6 this returns nothing and pins
|
|
# nothing; near-zero forces candidates through the same code path and
|
|
# makes the stage produce numbers. The service's real thresholds are
|
|
# recorded below so a change to them is still visible in the diff.
|
|
yn = cv2.FaceDetectorYN.create(
|
|
model=det_path,
|
|
config="",
|
|
input_size=(320, 320),
|
|
score_threshold=1e-6,
|
|
nms_threshold=config.NMS_THRESHOLD,
|
|
top_k=config.TOP_K,
|
|
)
|
|
yn.setInputSize((320, 320))
|
|
retval, faces = yn.detect(det_scene)
|
|
out["detect_cv2_retval"] = int(retval)
|
|
out["detect_cv2_faces"] = "none" if faces is None else _hash(faces)
|
|
out["detect_cv2_count"] = 0 if faces is None else int(faces.shape[0])
|
|
|
|
# 2b. The same model as a raw ONNX forward pass — DIAGNOSTIC ONLY, hence
|
|
# the diag_ prefix. Production never runs YuNet through onnxruntime, so
|
|
# an ORT change touching a YuNet operator moves these while real
|
|
# behaviour is untouched. Treating that as "rescan needed" would cost a
|
|
# full-gallery rescan for nothing. They earn their place by splitting
|
|
# the cause when 2a does move: both together means the model changed,
|
|
# 2a alone means OpenCV's DNN path did.
|
|
det_sess = ort.InferenceSession(det_path, providers=["CPUExecutionProvider"])
|
|
det_in = det_sess.get_inputs()[0]
|
|
_, _, h, w = det_in.shape
|
|
det_tensor = (
|
|
_deterministic_bgr(max(h, w))[:h, :w].astype(np.float32).transpose(2, 0, 1)[None, ...]
|
|
)
|
|
det_outs = det_sess.run(None, {det_in.name: det_tensor})
|
|
for meta, arr in zip(det_sess.get_outputs(), det_outs):
|
|
out[f"diag_ort_{meta.name}"] = _hash(arr)
|
|
|
|
# 3. Alignment and embedding, through the production pipeline. This is what
|
|
# actually decides where a face lands in the embedding space: umeyama +
|
|
# warpAffine, then BGR->RGB, per-image standardization, layout and the
|
|
# L2 normalization the backend's cosine similarity depends on.
|
|
pipeline = FacePipeline()
|
|
# Read what the detector was ACTUALLY constructed with, before the override
|
|
# below replaces it. _thresholds.det_score only echoes config; if
|
|
# FacePipeline ever stopped applying it — falling back to OpenCV's 0.9
|
|
# default, say — production would detect a different face set while every
|
|
# hash here still matched, because the override erases the evidence.
|
|
out["_effective_det_score"] = round(float(pipeline._detector.getScoreThreshold()), 6)
|
|
# Emitted, not just used: the fixture is too small to trip the resize in
|
|
# either image, and the forced pass below overrides the value in both, so
|
|
# a production change from 1920 to 960 would move no hash at all. Printing
|
|
# it puts the change in the diff where a reviewer will see it.
|
|
out["_thresholds"] = {
|
|
"det_score": config.DET_SCORE_THRESHOLD,
|
|
"nms": config.NMS_THRESHOLD,
|
|
"input_long_edge": config.INPUT_LONG_EDGE,
|
|
"top_k": config.TOP_K,
|
|
# Not a hash — a compatibility key. /faces stamps it on every result
|
|
# and faceClustering.js:190 refuses to compare a face against a person
|
|
# carrying a different one, so a change here forces a full rescan even
|
|
# when every number below is identical.
|
|
"model_version": config.MODEL_VERSION,
|
|
# Emitted because the fixture never reaches it: pipeline.py:138 slices
|
|
# to MAX_FACES, so raising 64 -> 128 changes nothing here while real
|
|
# group photos would persist a different face set.
|
|
"max_faces": config.MAX_FACES,
|
|
}
|
|
|
|
scene = _deterministic_bgr(256)
|
|
# Fixed landmark set (eyes, nose, mouth corners) in the canonical order the
|
|
# pipeline expects, chosen to give the warp a non-trivial rotation+scale.
|
|
landmarks = np.array(
|
|
[[96.0, 104.0], [160.0, 100.0], [128.0, 140.0], [104.0, 176.0], [154.0, 174.0]],
|
|
dtype=np.float32,
|
|
)
|
|
aligned = pipeline._align(scene, landmarks)
|
|
out["align_warp"] = _hash(aligned)
|
|
|
|
embedding = pipeline._embed(aligned)
|
|
out["embed"] = _hash(embedding)
|
|
out["embed_l2"] = round(float(np.linalg.norm(embedding)), 6)
|
|
|
|
# 4. End to end, through process(). The isolated stages above cannot see the
|
|
# orchestration between them: INPUT_LONG_EDGE resizing, and the row ->
|
|
# landmark scaling in _one_face that turns detector output into the
|
|
# coordinates _align consumes. A change there moves newly generated
|
|
# embeddings relative to stored clusters while every stage hash above
|
|
# holds still, because _align is handed fixed landmarks rather than the
|
|
# detector's.
|
|
#
|
|
# Same threshold trick as 2a, applied to the pipeline's own detector so
|
|
# a faceless synthetic frame still produces rows to carry through the
|
|
# real code path.
|
|
pipeline._detector.setScoreThreshold(1e-6)
|
|
detected = pipeline.process(FIXED_JPEG)
|
|
out["process_face_count"] = len(detected)
|
|
for field, digest in _hash_all(detected).items():
|
|
out[f"process_{field}"] = digest
|
|
|
|
# 5. The same path again with the downscale branch forced. process() only
|
|
# resizes when the long edge exceeds INPUT_LONG_EDGE (1920), and no
|
|
# fixture small enough to embed here ever will — so without this the
|
|
# resize and its inverse landmark scaling are never executed, and a
|
|
# change to either would leave every hash above untouched. Lowering the
|
|
# threshold under the fixture is cheaper than carrying a 1920px image in
|
|
# the source, and exercises the identical code.
|
|
_real_long_edge = config.INPUT_LONG_EDGE
|
|
try:
|
|
# 48 < the fixture's 64px long edge, so the resize fires; small
|
|
# enough to matter, large enough that YuNet still returns rows.
|
|
# At 32 the downscaled frame (32x24) yields nothing and the stage
|
|
# silently pins nothing, which is what the VACUOUS marker caught.
|
|
config.INPUT_LONG_EDGE = 48
|
|
downscaled = pipeline.process(FIXED_JPEG)
|
|
finally:
|
|
config.INPUT_LONG_EDGE = _real_long_edge
|
|
out["process_downscaled_count"] = len(downscaled)
|
|
# Bboxes as well as embeddings. In the pass above the scale is 1, so
|
|
# `row[0:4]` never goes through inverse scaling and a regression there
|
|
# stays hidden; the embeddings would not catch it either, because those
|
|
# are derived from separately scaled landmarks. A wrong bbox is what
|
|
# breaks avatar crops and area calculations.
|
|
for field, digest in _hash_all(downscaled).items():
|
|
out[f"process_downscaled_{field}"] = digest
|
|
|
|
print(json.dumps(out, indent=2, sort_keys=True))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|