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)
elif path.startswith("/collected-media/"):
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:
self._static(path, static_root)
except KeyError:

View file

@ -7,6 +7,7 @@ import json
import mimetypes
import os
import re
import shutil
import sqlite3
from html.parser import HTMLParser
from dataclasses import replace
@ -402,6 +403,8 @@ class CopyrighterStore:
else self.db_path.parent / "collections" / "images"
)
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 = {
"coverageGoodRate": _bounded_int_env(
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._refresh_existing_submission_file_facts(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._ensure_llm_summaries_for_existing_source_evidence(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_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_google_custom_search(record["id"], query_source_evidence, image_store)
self._ensure_llm_summary(record["id"])
@ -779,6 +786,11 @@ class CopyrighterStore:
[(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:
self.add_audit_event(
actor="system",
@ -1771,6 +1783,13 @@ class CopyrighterStore:
raise ValueError("collected media path points outside image store")
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:
payload = {
"timestamp": _now_label(),
@ -1891,6 +1910,44 @@ class CopyrighterStore:
)
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(
self,
image_store: LocalSubmissionImageStore,
@ -1933,6 +1990,7 @@ class CopyrighterStore:
self._sync_similar_reference_images(submission_id, domain_evidence)
self._increment_knowledge_contribution_counts(submission_id, domain_evidence)
self._rescore_submission(submission_id)
self._sync_face_crops(submission_id, image_store)
return domain_evidence
def _rerun_google_image_search(
@ -1998,11 +2056,21 @@ class CopyrighterStore:
if not self.provider_runtime.face_crop_web_detection_enabled:
return [], 0
signal = HeuristicFacePersonDetector().detect(original_image)
if not signal.face_boxes:
submission = self._get("submissions", submission_id)
stored_crops = submission.get("faceCrops")
if stored_crops is None:
signal = HeuristicFacePersonDetector().detect(original_image)
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
crops = build_face_crop_derivatives(original_image, signal.face_boxes)
crops = build_face_crop_derivatives(original_image, face_boxes)
evidence: list[Evidence] = []
call_count = 0
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 "재활성 사유 메모" 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
finally:
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("file-facts").innerHTML = "";
document.getElementById("similar-strip").innerHTML = "";
document.getElementById("face-crop-strip").innerHTML = "";
document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
@ -1289,7 +1290,16 @@ function renderCaseReview() {
.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);

View file

@ -235,6 +235,7 @@
</figure>
<div class="fact-grid" id="file-facts"></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 class="pane evidence-pane" aria-labelledby="evidence-pane-title">

View file

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