diff --git a/worker_vision.py b/worker_vision.py index ae0de13..297c131 100644 --- a/worker_vision.py +++ b/worker_vision.py @@ -123,16 +123,59 @@ def _set_of_mark(local_path: str, present: list): return marked, "\n" + "\n".join(parts) + "\n", {f["label"]: f["local_id"] or "unknown" for f in faces} -def _apply_speaker_labels(dialogue: list, label_map: dict) -> list: - """map a set-of-mark face label ("P1") in the speaker field back to its local_id. gemma may also - answer with the local_id directly (legend shows both) — that already matches, so it's left as-is.""" - if not label_map: - return dialogue +_GENDER_SUFFIX = re.compile(r"\s*\((?:m|f|male|female|man|woman|unknown)\)\s*$", re.I) +_ID_SHAPED = re.compile(r"^(?:person|char|face|p)[\s_-]*\d+$", re.I) + + +def _present_keys(present: list) -> dict: + """Every string the dialogue prompt shows for a present character, mapped to its local_id. + + `build_dialogue_prompt` renders each one as `- person_1: brown ponytail, green dress (f)`, and gemma + answers with any part of that line, most often the description. Those answers used to fall through + `normalize_speaker` as free-form NAMES and never matched the registry, losing 28 of 51 speech lines + on job 778297bc. A key shared by two present characters is dropped: it cannot identify either.""" + keys: dict = {} + for c in present: + lid = (c.get("local_id") or "").strip() + if not lid: + continue + name, desc, gender = c.get("name") or "", c.get("desc") or "", c.get("gender") or "" + shown = name or desc or "unknown" + forms = {lid, name, desc, shown} + if gender and gender != "unknown": + forms |= {f"{f} ({gender})" for f in (name, desc, shown) if f} + for f in forms: + k = f.strip().casefold() + if not k or k == "unknown": # the prompt prints "unknown" for a nameless character + continue + keys[k] = lid if keys.get(k, lid) == lid else None # ambiguous key -> unusable + return {k: v for k, v in keys.items() if v} + + +def _apply_speaker_labels(dialogue: list, label_map: dict, present: list | None = None) -> list: + """Map gemma's speaker answer back to a panel-local id. + + Three answer shapes reach here: a set-of-mark face label ("P1"), a local_id, and any string the + prompt showed for a present character. An id-shaped answer that names nobody present is junk and + becomes "unknown" rather than a name claim (invariant 6). A trailing gender marker is stripped, so + "Seonho (m)" can still match the registry name "Seonho" off-panel.""" + keys = _present_keys(present or []) for d in dialogue: s = (d.get("speaker") or "").strip() + if not s: + continue if s in label_map: d["speaker"] = label_map[s] d["speaker_method"] = "som_face" + continue + bare = _GENDER_SUFFIX.sub("", s).strip() + lid = keys.get(s.casefold()) or keys.get(bare.casefold()) + if lid: + d["speaker"] = lid + elif _ID_SHAPED.match(bare): + d["speaker"] = "unknown" # an id for nobody present: never mint a name from it + elif bare != s: + d["speaker"] = bare # off-panel name, gender marker stripped return dialogue @@ -465,7 +508,7 @@ async def dialogue(data: DialogueInput): result.setdefault("entities", []) result.setdefault("named", []) # [{local_id, name}] explicit namings -> bound to character_id by orchestrator result["named"] = _normalize_claims(result["named"], data.panel_id) - _apply_speaker_labels(result["dialogue"], label_map) + _apply_speaker_labels(result["dialogue"], label_map, data.present_characters) resolve_speakers(result["dialogue"], data.present_characters) result["panel_id"] = data.panel_id result.update(_dialogue_envelope([data.panel_id], [] if parse_failed else [result], parse_failed)) @@ -580,7 +623,7 @@ async def dialogue_window(data: DialogueWindowInput): d = by_id.get(pid) if d is None: # missing is unresolved, never manufactured as a silent success continue - _apply_speaker_labels(d.get("dialogue", []), label_maps.get(pid, {})) + _apply_speaker_labels(d.get("dialogue", []), label_maps.get(pid, {}), present_by_id.get(pid, [])) out.append({ "panel_id": pid, "dialogue": resolve_speakers(d.get("dialogue", []), present_by_id.get(pid, [])), @@ -1001,6 +1044,20 @@ if __name__ == "__main__": lbl = _apply_speaker_labels([{"speaker": "P1"}, {"speaker": "Aria"}, {"speaker": "unknown"}], {"P1": "person_3"}) assert lbl[0]["speaker"] == "person_3" and lbl[1]["speaker"] == "Aria" and lbl[2]["speaker"] == "unknown" + # gemma answers with what the prompt SHOWED, not the id: description, name+gender, bare id, junk id. + # Each shape cost real lines on job 778297bc by falling through as a free-form name. + shown = [{"local_id": "person_1", "desc": "brown ponytail, green dress", "gender": "f"}, + {"local_id": "person_2", "name": "Seonho", "gender": "m"}] + got = _apply_speaker_labels([{"speaker": "brown ponytail, green dress (f)"}, {"speaker": "Seonho"}, + {"speaker": "person_2"}, {"speaker": "person_9"}, + {"speaker": "Haeseon (f)"}, {"speaker": "unknown"}], {}, shown) + assert [d["speaker"] for d in got] == ["person_1", "person_2", "person_2", "unknown", + "Haeseon", "unknown"], got + # a description shared by two present characters identifies neither + twins = [{"local_id": "person_1", "desc": "schoolgirl"}, {"local_id": "person_2", "desc": "schoolgirl"}] + assert _apply_speaker_labels([{"speaker": "schoolgirl"}], {}, twins)[0]["speaker"] == "schoolgirl" + # a nameless present character is shown as "unknown"; that must not become an id + assert _apply_speaker_labels([{"speaker": "unknown"}], {}, [{"local_id": "person_1"}])[0]["speaker"] == "unknown" # real detector face -> nearest present char's identity; each present char claimed once; leftover unknown pf = _pair_faces_to_present( [{"bbox": [10, 10, 50, 50]}, {"bbox": [200, 10, 240, 50]}, {"bbox": [400, 10, 440, 50]}],