feat: persist and display detected face crop thumbnails in workbench

This commit is contained in:
유창욱 2026-06-12 17:56:09 +09:00
parent 646b871b76
commit 1e0f4f8690
7 changed files with 141 additions and 4 deletions

View file

@ -60,6 +60,8 @@ def build_server(
self._file(store.knowledge_media_path(unquote(path.removeprefix("/knowledge-media/"))), untrusted=True) self._file(store.knowledge_media_path(unquote(path.removeprefix("/knowledge-media/"))), untrusted=True)
elif path.startswith("/collected-media/"): elif path.startswith("/collected-media/"):
self._file(store.collected_media_path(unquote(path.removeprefix("/collected-media/"))), untrusted=True) self._file(store.collected_media_path(unquote(path.removeprefix("/collected-media/"))), untrusted=True)
elif path.startswith("/face-crop-media/"):
self._file(store.face_crop_media_path(unquote(path.removeprefix("/face-crop-media/"))), untrusted=True)
else: else:
self._static(path, static_root) self._static(path, static_root)
except KeyError: except KeyError:

View file

@ -7,6 +7,7 @@ import json
import mimetypes import mimetypes
import os import os
import re import re
import shutil
import sqlite3 import sqlite3
from html.parser import HTMLParser from html.parser import HTMLParser
from dataclasses import replace from dataclasses import replace
@ -402,6 +403,8 @@ class CopyrighterStore:
else self.db_path.parent / "collections" / "images" else self.db_path.parent / "collections" / "images"
) )
self.collection_public_prefix = collection_public_prefix.rstrip("/") self.collection_public_prefix = collection_public_prefix.rstrip("/")
self.face_crop_image_dir = self.db_path.parent / "face-crops"
self.face_crop_public_prefix = "/face-crop-media"
self.coverage_thresholds = { self.coverage_thresholds = {
"coverageGoodRate": _bounded_int_env( "coverageGoodRate": _bounded_int_env(
os.environ.get("COPYRIGHTER_COVERAGE_GOOD_THRESHOLD"), os.environ.get("COPYRIGHTER_COVERAGE_GOOD_THRESHOLD"),
@ -675,6 +678,9 @@ class CopyrighterStore:
self._normalize_saved_google_weak_labels_and_rescore(queue_id=queue_id) self._normalize_saved_google_weak_labels_and_rescore(queue_id=queue_id)
self._refresh_existing_submission_file_facts(image_store, queue_id=queue_id) self._refresh_existing_submission_file_facts(image_store, queue_id=queue_id)
self._refresh_existing_local_face_evidence(image_store, queue_id=queue_id) self._refresh_existing_local_face_evidence(image_store, queue_id=queue_id)
for submission in self._all("submissions", queue_id=queue_id):
if "faceCrops" not in submission:
self._sync_face_crops(str(submission["id"]), image_store)
self._rescore_all_submissions(queue_id=queue_id) self._rescore_all_submissions(queue_id=queue_id)
self._ensure_llm_summaries_for_existing_source_evidence(queue_id=queue_id) self._ensure_llm_summaries_for_existing_source_evidence(queue_id=queue_id)
self._sync_submission_provider_state(queue_id=queue_id) self._sync_submission_provider_state(queue_id=queue_id)
@ -730,6 +736,7 @@ class CopyrighterStore:
] ]
self._sync_similar_reference_images(record["id"], run.evidence) self._sync_similar_reference_images(record["id"], run.evidence)
self._sync_search_result_image_similarity(record["id"], run.evidence, image_store) self._sync_search_result_image_similarity(record["id"], run.evidence, image_store)
self._sync_face_crops(record["id"], image_store)
self._auto_naver_search(record["id"], query_source_evidence, image_store) self._auto_naver_search(record["id"], query_source_evidence, image_store)
self._auto_google_custom_search(record["id"], query_source_evidence, image_store) self._auto_google_custom_search(record["id"], query_source_evidence, image_store)
self._ensure_llm_summary(record["id"]) self._ensure_llm_summary(record["id"])
@ -779,6 +786,11 @@ class CopyrighterStore:
[(submission_id, queue_id) for submission_id in missing_ids], [(submission_id, queue_id) for submission_id in missing_ids],
) )
for submission_id in missing_ids:
crop_dir = self.face_crop_image_dir / submission_id
if crop_dir.exists():
shutil.rmtree(crop_dir, ignore_errors=True)
for submission_id in missing_ids: for submission_id in missing_ids:
self.add_audit_event( self.add_audit_event(
actor="system", actor="system",
@ -1771,6 +1783,13 @@ class CopyrighterStore:
raise ValueError("collected media path points outside image store") raise ValueError("collected media path points outside image store")
return path return path
def face_crop_media_path(self, relative_path: str) -> Path:
root = self.face_crop_image_dir.resolve()
path = (root / relative_path.lstrip("/")).resolve()
if path != root and root not in path.parents:
raise ValueError("face crop media path points outside image store")
return path
def add_audit_event(self, actor: str, event: str, object_id: str, change: str) -> None: def add_audit_event(self, actor: str, event: str, object_id: str, change: str) -> None:
payload = { payload = {
"timestamp": _now_label(), "timestamp": _now_label(),
@ -1891,6 +1910,44 @@ class CopyrighterStore:
) )
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
def _sync_face_crops(
self,
submission_id: str,
image_store: LocalSubmissionImageStore | None,
) -> None:
if image_store is None:
return
try:
original = image_store.image_payload(submission_id)
except Exception:
return
signal = HeuristicFacePersonDetector().detect(original)
crop_dir = self.face_crop_image_dir / submission_id
if crop_dir.exists():
for stale in crop_dir.glob("crop-*.jpg"):
stale.unlink()
face_crops: list[dict[str, Any]] = []
for index, box in enumerate(signal.face_boxes[:3], start=1):
crops = build_face_crop_derivatives(original, [box])
if not crops:
continue
crop_dir.mkdir(parents=True, exist_ok=True)
crop_path = crop_dir / f"crop-{index}.jpg"
crop_path.write_bytes(crops[0].content)
face_crops.append(
{
"index": index,
"url": f"{self.face_crop_public_prefix}/{submission_id}/crop-{index}.jpg",
"box": [int(value) for value in box],
}
)
submission = self._get("submissions", submission_id)
submission["faceCrops"] = face_crops
self._put("submissions", submission_id, submission)
def _refresh_existing_submission_file_facts( def _refresh_existing_submission_file_facts(
self, self,
image_store: LocalSubmissionImageStore, image_store: LocalSubmissionImageStore,
@ -1933,6 +1990,7 @@ class CopyrighterStore:
self._sync_similar_reference_images(submission_id, domain_evidence) self._sync_similar_reference_images(submission_id, domain_evidence)
self._increment_knowledge_contribution_counts(submission_id, domain_evidence) self._increment_knowledge_contribution_counts(submission_id, domain_evidence)
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
self._sync_face_crops(submission_id, image_store)
return domain_evidence return domain_evidence
def _rerun_google_image_search( def _rerun_google_image_search(
@ -1998,11 +2056,21 @@ class CopyrighterStore:
if not self.provider_runtime.face_crop_web_detection_enabled: if not self.provider_runtime.face_crop_web_detection_enabled:
return [], 0 return [], 0
submission = self._get("submissions", submission_id)
stored_crops = submission.get("faceCrops")
if stored_crops is None:
signal = HeuristicFacePersonDetector().detect(original_image) signal = HeuristicFacePersonDetector().detect(original_image)
if not signal.face_boxes: face_boxes: tuple = signal.face_boxes
else:
face_boxes = tuple(
tuple(int(value) for value in item.get("box", []))
for item in stored_crops
if len(item.get("box", [])) == 4
)
if not face_boxes:
return [], 0 return [], 0
crops = build_face_crop_derivatives(original_image, signal.face_boxes) crops = build_face_crop_derivatives(original_image, face_boxes)
evidence: list[Evidence] = [] evidence: list[Evidence] = []
call_count = 0 call_count = 0
for crop_index, crop in enumerate(crops, start=1): for crop_index, crop in enumerate(crops, start=1):

View file

@ -831,3 +831,13 @@ def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
assert "data-reactivate-kb" in script assert "data-reactivate-kb" in script
assert "재활성 사유 메모" in script assert "재활성 사유 메모" in script
assert "data-toggle-kb" not in script assert "data-toggle-kb" not in script
def test_workbench_shows_detected_face_crop_strip():
html = _read(INDEX)
script = _read(APP_JS)
assert 'id="face-crop-strip"' in html
assert "faceCrops" in script
assert "얼굴 영역" in script
assert "동일 인물 판정 아님" in script

View file

@ -1208,3 +1208,44 @@ def test_knowledge_entry_update_deactivate_reactivate_lifecycle(tmp_path: Path):
assert "Knowledge entry reactivated" in event_names assert "Knowledge entry reactivated" in event_names
finally: finally:
server.shutdown() server.shutdown()
class OneFaceBoxDetector:
def detect(self, image):
return FacePersonSignal(face_count=1, person_count=1, face_boxes=((40, 40, 120, 120),))
def test_face_crops_are_persisted_served_and_listed_in_review(tmp_path: Path, monkeypatch):
monkeypatch.setattr(sqlite_store_module, "HeuristicFacePersonDetector", lambda: OneFaceBoxDetector())
static_dir, image_store, store = _png_fixtures(tmp_path)
server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
_start(server)
base = f"http://127.0.0.1:{server.server_port}"
try:
review = _json(base + "/api/submissions/SUB-API1/review")
assert review["faceCrops"], "face crops should be listed for a face submission"
crop = review["faceCrops"][0]
assert crop["index"] == 1
assert crop["box"] == [40, 40, 120, 120]
assert crop["url"].startswith("/face-crop-media/SUB-API1/")
with urlopen(base + crop["url"], timeout=5) as response:
content = response.read()
assert content[:3] == b"\xff\xd8\xff" # JPEG magic bytes
finally:
server.shutdown()
def test_face_crops_are_empty_for_undetectable_images(tmp_path: Path):
# autouse OneFaceDetector는 face_boxes를 반환하지 않고, SVG는 PIL로 크롭할 수 없다.
static_dir, image_store, store = _fixtures(tmp_path)
server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
_start(server)
base = f"http://127.0.0.1:{server.server_port}"
try:
review = _json(base + "/api/submissions/SUB-API1/review")
assert review.get("faceCrops", []) == []
finally:
server.shutdown()

View file

@ -1226,6 +1226,7 @@ function renderNoSelectedCase() {
document.getElementById("floating-case-score").textContent = "대기"; document.getElementById("floating-case-score").textContent = "대기";
document.getElementById("file-facts").innerHTML = ""; document.getElementById("file-facts").innerHTML = "";
document.getElementById("similar-strip").innerHTML = ""; document.getElementById("similar-strip").innerHTML = "";
document.getElementById("face-crop-strip").innerHTML = "";
document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`; document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`; document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`; document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
@ -1289,7 +1290,16 @@ function renderCaseReview() {
.join(""); .join("");
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
.map(
(crop) => `
<div class="similar-item face-crop-item">
<img src="${escapeHtml(crop.url)}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭">
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정 아님</span>
</div>
`,
)
.join("");
document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission); document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission);

View file

@ -235,6 +235,7 @@
</figure> </figure>
<div class="fact-grid" id="file-facts"></div> <div class="fact-grid" id="file-facts"></div>
<div class="similar-strip" id="similar-strip" aria-label="유사 이미지"></div> <div class="similar-strip" id="similar-strip" aria-label="유사 이미지"></div>
<div class="similar-strip face-crop-strip" id="face-crop-strip" aria-label="감지된 얼굴 영역"></div>
</section> </section>
<section class="pane evidence-pane" aria-labelledby="evidence-pane-title"> <section class="pane evidence-pane" aria-labelledby="evidence-pane-title">

View file

@ -2789,3 +2789,8 @@ tbody tr.selected-row,
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.face-crop-strip .face-crop-item img {
border: 1px solid #d4d4d8;
border-radius: 6px;
}