diff --git a/src/rights_filter/server/http_app.py b/src/rights_filter/server/http_app.py index 9a435ef..3cad9fd 100644 --- a/src/rights_filter/server/http_app.py +++ b/src/rights_filter/server/http_app.py @@ -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: diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index e8612bc..e5762c8 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -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): diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index d271744..558d297 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -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 diff --git a/tests/rights_filter/server/test_http_app.py b/tests/rights_filter/server/test_http_app.py index c39c924..b296aa8 100644 --- a/tests/rights_filter/server/test_http_app.py +++ b/tests/rights_filter/server/test_http_app.py @@ -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() diff --git a/web/operator-gui/app.js b/web/operator-gui/app.js index 1992eb8..9925cf9 100644 --- a/web/operator-gui/app.js +++ b/web/operator-gui/app.js @@ -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 = `