feat(ml): CCIP character matches ground to the matched figure region (#133 step 2)
match_image now tracks WHICH query figure produced the winning cosine per
character (argmax over the per-figure best-reference sim) and attaches its bbox as
grounding {bbox,kind:'figure',detector}. SuggestionService carries it: a CCIP-only
character hit grounds to its figure; a 'both' hit keeps the head's localized crop
if it had one, else falls back to the CCIP figure — so corroborated characters
stay grounded. Test: a character match carries the matched figure's bbox+kind.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -161,16 +161,22 @@ async def match_image(
|
|||||||
if threshold is None:
|
if threshold is None:
|
||||||
threshold = await _settings_threshold(session)
|
threshold = await _settings_threshold(session)
|
||||||
|
|
||||||
qvecs = (
|
# Keep each figure region's bbox alongside its vector so a match can point at
|
||||||
|
# the figure that matched (#1206 grounding), not just the score.
|
||||||
|
fig_rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(ImageRegion.ccip_embedding).where(
|
select(
|
||||||
|
ImageRegion.ccip_embedding,
|
||||||
|
ImageRegion.rx, ImageRegion.ry, ImageRegion.rw, ImageRegion.rh,
|
||||||
|
ImageRegion.kind, ImageRegion.detector_version,
|
||||||
|
).where(
|
||||||
ImageRegion.image_record_id == image_id,
|
ImageRegion.image_record_id == image_id,
|
||||||
ImageRegion.kind.in_(_FIGURE_KINDS),
|
ImageRegion.kind.in_(_FIGURE_KINDS),
|
||||||
ImageRegion.ccip_embedding.is_not(None),
|
ImageRegion.ccip_embedding.is_not(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).all()
|
||||||
if not qvecs:
|
if not fig_rows:
|
||||||
return []
|
return []
|
||||||
refs = await character_references(session)
|
refs = await character_references(session)
|
||||||
if not refs:
|
if not refs:
|
||||||
@@ -186,13 +192,21 @@ async def match_image(
|
|||||||
)
|
)
|
||||||
names = await _tag_names(session, [t for t in refs if t not in applied])
|
names = await _tag_names(session, [t for t in refs if t not in applied])
|
||||||
|
|
||||||
|
qvecs = [r[0] for r in fig_rows]
|
||||||
|
fig_meta = [
|
||||||
|
{"bbox": [rx, ry, rw, rh], "kind": kind, "detector": detector}
|
||||||
|
for _v, rx, ry, rw, rh, kind, detector in fig_rows
|
||||||
|
]
|
||||||
Q = _l2norm(np.vstack([np.asarray(v, dtype=np.float32) for v in qvecs]), np)
|
Q = _l2norm(np.vstack([np.asarray(v, dtype=np.float32) for v in qvecs]), np)
|
||||||
out = []
|
out = []
|
||||||
for tag_id, vecs in refs.items():
|
for tag_id, vecs in refs.items():
|
||||||
if tag_id in applied:
|
if tag_id in applied:
|
||||||
continue
|
continue
|
||||||
R = _l2norm(np.vstack([np.asarray(v, dtype=np.float32) for v in vecs]), np)
|
R = _l2norm(np.vstack([np.asarray(v, dtype=np.float32) for v in vecs]), np)
|
||||||
best = float((Q @ R.T).max()) # best (query figure, reference) cosine
|
sims = Q @ R.T # (n_query_figures, n_references)
|
||||||
|
per_figure = sims.max(axis=1) # best reference cosine per figure
|
||||||
|
best_figure = int(per_figure.argmax())
|
||||||
|
best = float(per_figure[best_figure])
|
||||||
if best >= threshold:
|
if best >= threshold:
|
||||||
out.append({
|
out.append({
|
||||||
"tag_id": tag_id,
|
"tag_id": tag_id,
|
||||||
@@ -200,6 +214,8 @@ async def match_image(
|
|||||||
"category": "character",
|
"category": "character",
|
||||||
"score": round(best, 4),
|
"score": round(best, 4),
|
||||||
"source": "ccip",
|
"source": "ccip",
|
||||||
|
# the figure region that matched → grounds the character tag.
|
||||||
|
"grounding": fig_meta[best_figure],
|
||||||
})
|
})
|
||||||
out.sort(key=lambda d: d["score"], reverse=True)
|
out.sort(key=lambda d: d["score"], reverse=True)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -116,9 +116,13 @@ class SuggestionService:
|
|||||||
if ex is not None:
|
if ex is not None:
|
||||||
ex["source"] = "both"
|
ex["source"] = "both"
|
||||||
ex["score"] = max(ex["score"], c["score"])
|
ex["score"] = max(ex["score"], c["score"])
|
||||||
|
# Keep the head's localized crop if it had one; else fall back to
|
||||||
|
# the CCIP figure so a corroborated character still grounds (#1206).
|
||||||
|
ex["grounding"] = ex.get("grounding") or c.get("grounding")
|
||||||
else:
|
else:
|
||||||
merged[key] = {
|
merged[key] = {
|
||||||
"name": c["name"], "score": c["score"], "source": "ccip",
|
"name": c["name"], "score": c["score"], "source": "ccip",
|
||||||
|
"grounding": c.get("grounding"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result = SuggestionList()
|
result = SuggestionList()
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ async def test_matches_same_character_across_images(db):
|
|||||||
m = next(x for x in matches if x["tag_id"] == raven.id)
|
m = next(x for x in matches if x["tag_id"] == raven.id)
|
||||||
assert m["source"] == "ccip" and m["category"] == "character"
|
assert m["source"] == "ccip" and m["category"] == "character"
|
||||||
assert m["score"] > 0.9
|
assert m["score"] > 0.9
|
||||||
|
# #1206: the match grounds to the figure region that matched (hover → the
|
||||||
|
# figure box lights up), so a character suggestion is localized too.
|
||||||
|
assert m["grounding"]["bbox"] == pytest.approx([0.0, 0.0, 1.0, 1.0])
|
||||||
|
assert m["grounding"]["kind"] == "figure"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user