feat: persist and display detected face crop thumbnails in workbench
This commit is contained in:
parent
646b871b76
commit
1e0f4f8690
7 changed files with 141 additions and 4 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue