Ship each crop's embedding so a resolver NONE can mint

Two identity fixes, neither yet run on a GPU.

has_face, the extras gate: fixing the bbox coordinate space made extras worse,
because crops finally landed on their subjects and a background extra bound to
the lead at 1.00. /vision stamps has_face per character using face_detect and
_pair_faces_to_present, so containment and its margin match the speaker path.
Identity skips has_face is False before it crops or embeds. Fails open on a
missing or raising detector, and gates on `is False` so an older vision blob
behaves as before.

The NONE mint: /identity/resolve now writes each crop's embedding to the crop's
key with a .npy suffix and returns emb_uri. The orchestrator cannot compute an
embedding of its own (siglip and gemma cannot both be resident), which is why it
was clearing a deliberate NONE instead of minting an anonymous character.

Also: audit_registry.py, and the tmux respawn-window trap, which
leaves a bare shell rather than re-running the window command.

decisions/identity-bbox.md#face-gates-enrollment
decisions/identity-bbox.md#none-mints-an-anonymous-character

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 19:07:56 +04:00
parent 7f25dc43e4
commit 491dad1c67
9 changed files with 476 additions and 109 deletions
+16 -2
View File
@@ -200,6 +200,13 @@ async def resolve(data: IdentityInput):
assignments, backfill, new_chars, shortlists = [], [], [], []
for ch in data.vision_characters:
# no detected face inside the box -> a background extra, a figure on a poster, or scenery
# gemma called a person. Embedding it pollutes the registry and, once the boxes were pixels,
# bound an extra to the lead at confidence 1.00. Abstain instead. `/vision` stamps this and
# fails open, so a panel it could not gate arrives with has_face=True on every character
# (decisions/identity-bbox.md#face-gates-enrollment). Absent key = an older vision blob.
if ch.get("has_face") is False:
continue
crop = _crop_bbox(img, ch["bbox"])
if crop.size == 0: # degenerate/out-of-bounds bbox -> nothing to embed, skip
continue
@@ -207,10 +214,17 @@ async def resolve(data: IdentityInput):
# shortlist for gemma's decider, from the roster as it stood before this crop's own outcome.
sl = shortlist(emb, known, data.k, ch.get("gender"))
crop_uri = f"s3://manga/{data.manga_id}/characters/_crops/{data.panel_id}_{ch['local_id']}.png"
# the embedding ships with the crop. gemma's decider can answer "none of these", and the
# orchestrator has to mint a character from that crop — which needs an embedding it cannot
# compute (siglip is resident here, gemma is resident there, and the mutex forbids both).
# Uploading it now is what removes the third siglip pass
# (`decisions/identity-bbox.md#none-mints-an-anonymous-character`).
key = f"{data.manga_id}/characters/_crops/{data.panel_id}_{ch['local_id']}"
crop_uri, emb_uri = f"s3://manga/{key}.png", f"s3://manga/{key}.npy"
cp = f"{SHM}/cc_{uuid.uuid4().hex[:8]}.png"; cv2.imwrite(cp, crop)
transport.put(cp, crop_uri); os.remove(cp)
shortlists.append({"local_id": ch["local_id"], "crop_uri": crop_uri,
_save_npy(emb, emb_uri)
shortlists.append({"local_id": ch["local_id"], "crop_uri": crop_uri, "emb_uri": emb_uri,
"candidates": [{"character_id": c["character_id"], "name": c.get("name"),
"gender": c.get("gender"), "species": c.get("species"),
"appearance": c.get("description"), "cosine": c["cosine"],