feat(suggestions): overlay CCIP character matches onto the rail (#114)
SuggestionService.for_image now merges CCIP character matches with the SigLIP head suggestions — they're complementary, not exclusive: CCIP is the identity- specialized signal but needs a detected figure; the heads work whole-image but conflate identity with style. Merged by tag: 'both' when they corroborate (higher score wins), 'ccip' / 'head' otherwise. Cheap when no CCIP vectors exist yet (match_image returns early without a figure vector), so it's a no-op until the agent runs. Suggestion.source is now 'head' | 'ccip' | 'both'. Test: a character with a CCIP reference figure surfaces (source='ccip') on a new image whose figure matches. NEXT: the agent container (real CCIP/detector models, hands-on) that produces the vectors this consumes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...models import ImageRecord, TagSuggestionRejection
|
||||
from ...models.tag import image_tag
|
||||
from .ccip import match_image as ccip_match_image
|
||||
from .heads import score_image
|
||||
|
||||
|
||||
@@ -27,7 +28,7 @@ class Suggestion:
|
||||
display_name: str
|
||||
category: str
|
||||
score: float
|
||||
source: str # 'head' (Camie 'tagger'/'centroid' sources removed in v2)
|
||||
source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
|
||||
creates_new_tag: bool
|
||||
# raw_name = the booru model vocab key behind this suggestion. It's the key
|
||||
# an alias MUST be stored under (resolution looks up the raw key), so the
|
||||
@@ -92,19 +93,39 @@ class SuggestionService:
|
||||
hits = await score_image(
|
||||
self.session, image_id, threshold_override=threshold_override
|
||||
)
|
||||
# CCIP character matches OVERLAY the SigLIP character heads — a
|
||||
# complementary, identity-specialized signal with different failure modes
|
||||
# (CCIP needs a detected figure; heads work whole-image). Merged by tag:
|
||||
# 'both' when they corroborate, taking the higher score.
|
||||
ccip_hits = await ccip_match_image(self.session, image_id)
|
||||
|
||||
merged: dict[tuple[str, int], dict] = {}
|
||||
for h in hits:
|
||||
merged[(h["category"], h["tag_id"])] = {
|
||||
"name": h["name"], "score": h["score"], "source": "head",
|
||||
}
|
||||
for c in ccip_hits:
|
||||
key = ("character", c["tag_id"])
|
||||
ex = merged.get(key)
|
||||
if ex is not None:
|
||||
ex["source"] = "both"
|
||||
ex["score"] = max(ex["score"], c["score"])
|
||||
else:
|
||||
merged[key] = {
|
||||
"name": c["name"], "score": c["score"], "source": "ccip",
|
||||
}
|
||||
|
||||
result = SuggestionList()
|
||||
for h in hits:
|
||||
tag_id = h["tag_id"]
|
||||
for (cat, tag_id), m in merged.items():
|
||||
if tag_id in applied:
|
||||
continue
|
||||
result.by_category.setdefault(h["category"], []).append(
|
||||
result.by_category.setdefault(cat, []).append(
|
||||
Suggestion(
|
||||
canonical_tag_id=tag_id,
|
||||
display_name=h["name"],
|
||||
category=h["category"],
|
||||
score=h["score"],
|
||||
source="head",
|
||||
display_name=m["name"],
|
||||
category=cat,
|
||||
score=m["score"],
|
||||
source=m["source"],
|
||||
creates_new_tag=False,
|
||||
rejected=tag_id in rejected,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user