"""슬롯 판정 — 등록 레이아웃 + 검사 씬 → 슬롯별 present / missing / wrong_tool + 슬롯밖 foreign(이물). 3단계 (클래스 무관, Track A 탐지기 클래스에 의존하지 않음): 1) 점유(occupancy): 슬롯 모서리 배경색 대비 전경 픽셀 비율 → 낮으면 missing (고전 CV) 2) 정체(identity): 전경 FFA 임베딩과 등록 지문의 코사인 유사도 → 낮으면 wrong_tool (DINOv2). tier3(기본): 등록 크롭을 여러 각도로 회전한 임베딩 뱅크와 max-sim → 회전으로 흔들린 정상 공구 회수(present→wrong_tool 오판 0.07→0.023, 씬 오경보 0.806→0.514, wrong_tool recall 유지) 3) 이물(foreign): 등록 슬롯 박스 '바깥' 자유공간에서 보드 배경 대비 전경 덩어리 → 이물 후보 (슬롯 점유와 동일한 상대임계 median+MAD_K·MAD·denoise 엔진 재사용) 임계값(occ_thr, id_thr)은 dev에서 슬롯 상태 balanced accuracy를, foreign_min_area는 dev에서 이물 F1을 (씬 오경보 게이트 하에) 최대화하도록 탐색. 씬 pass/fail은 슬롯만/슬롯+이물 2종을 같이 보고해 이물탐지 on/off 트레이드오프(침입탐지↑ ↔ 정상 씬 오경보↑)를 정량화한다. 실행: python -m src.layout --tune-eval """ from __future__ import annotations import argparse import json from pathlib import Path import numpy as np from PIL import Image from scipy import ndimage from src.enroll import DinoEmbedder, cos ROOT = Path(__file__).resolve().parents[1] BENCH = ROOT / "data/synthbench" POOL = BENCH / "pool" INSET = 16 # 슬롯 테두리(그려진 윤곽선) 회피 여백 COLOR_THR = 38.0 # 배경색 대비 전경 판정 색거리 def _slot_region(scene: np.ndarray, bbox): x, y, w, h = bbox H, W = scene.shape[:2] x1, y1 = max(0, x + INSET), max(0, y + INSET) x2, y2 = min(W, x + w - INSET), min(H, y + h - INSET) return scene[y1:y2, x1:x2], (x1, y1, x2, y2) MAD_K = 3.0 # tier1a 상대임계 민감도 (median + k·MAD) TAU_CD = 3.0 # tier1b Horprasert 색성분(CD) 임계 (bg MAD 배수) TAU_A = 3.0 # tier1b Horprasert 밝기성분(BD) 임계 (어두워지는 방향) EDGE_K = 5.0 # 점유 엣지 보강: 얇은/저조도 금속(색은 비슷해도 윤곽 강함)을 색거리 OR 엣지에너지로 회수 FOREIGN_MIN_AREA = 0.0005 # 이물 최소 전경면적(씬 대비) — 0.0005가 마지노선(더 낮추면 잡음덩어리 통과 P붕괴, dev 튜닝) FOREIGN_DILATE = 10 # 슬롯 박스 제외 시 팽창(테두리 그림자·정렬오차 여유) FOREIGN_MED = 5 # 이물 스캔 median 커널: 배경 speckle 억제(이물 ≥40px는 생존) FOREIGN_GAUSS = 3.5 # 이물 스캔 gaussian σ: 가산잡음까지 평균 → 배경 MAD 급감, 오탐 P≈1.0 달성 FOREIGN_K = 3.5 # 이물 상대임계 배수(정밀 우선; 낮추면 recall↑ precision↓ — 나쁜 거래) def _mad(x): med = float(np.median(x)) return med, float(np.median(np.abs(x - med))) * 1.4826 + 1e-6 def _fg_mask(reg: np.ndarray, bg: np.ndarray, mode: str) -> np.ndarray: """모드별 전경 픽셀 마스크(연결성분 필터 전). raw=고정 색거리 임계(밝기보정 없음) / tier0=밝기비례 색거리+고정임계(빈칸오점유↑, 폐기) / mad=색거리+median+k·MAD 상대임계 / horprasert=배경색축 분해(BD 밝기·CD 색)로 '색 다름 OR 더 어두움' 판정(저조도 회수).""" regf = reg.astype(np.float32) if mode == "raw": return np.linalg.norm(regf - bg, axis=2) > COLOR_THR if mode == "mad": dist = np.linalg.norm(regf - bg, axis=2) med, mad = _mad(dist.reshape(-1)) return dist > med + MAD_K * mad if mode == "horprasert": E = bg.astype(np.float32) ee = float(np.dot(E, E)) + 1e-6 alpha = (regf @ E) / ee # BD: 배경색 축 밝기 성분 (1=배경과 동일 밝기) cd = np.linalg.norm(regf - alpha[..., None] * E, axis=2) # CD: 축에 수직인 색 성분 a_med, a_mad = _mad(alpha.reshape(-1)) c_med, c_mad = _mad(cd.reshape(-1)) fg_color = cd > c_med + TAU_CD * c_mad # 배경과 색이 다름 fg_dark = alpha < a_med - TAU_A * a_mad # 배경과 색은 비슷하나 더 어두움(저조도 공구) return fg_color | fg_dark # tier0 (기본): 밝기 비례 스케일링 + 고정 색거리 임계 scale = min(4.0, 128.0 / max(float(bg.mean()), 8.0)) dist = np.linalg.norm(regf - bg, axis=2) * scale return dist > COLOR_THR def occupancy(scene: np.ndarray, bbox, mode: str = "raw", denoise: bool = False, edge: bool = False): """슬롯 4모서리에서 배경색을 추정하고, 내부 전경 픽셀 비율과 마스크를 반환. denoise=True면 median filter로 노이즈 억제 후 판정(노이즈 조건 회복). edge=True면 색거리 마스크에 엣지에너지(Sobel) 마스크를 OR — 얇은/저조도 금속 회수.""" reg, _ = _slot_region(scene, bbox) if reg.size == 0: return 0.0, np.zeros((1, 1), bool) if denoise: reg = ndimage.median_filter(reg, size=(3, 3, 1)) # edge 보존 경량 denoise h, w = reg.shape[:2] cs = max(4, min(h, w) // 5) corners = np.concatenate([ reg[:cs, :cs].reshape(-1, 3), reg[:cs, -cs:].reshape(-1, 3), reg[-cs:, :cs].reshape(-1, 3), reg[-cs:, -cs:].reshape(-1, 3)], 0) bg = np.median(corners, 0) mask = _fg_mask(reg, bg, mode) if edge: # 색이 배경과 비슷해도 윤곽이 강한 얇은 금속(가위·나사)을 엣지로 회수 gray = reg.astype(np.float32).mean(2) gm = np.hypot(ndimage.sobel(gray, axis=1), ndimage.sobel(gray, axis=0)) gmed, gmad = _mad(gm.reshape(-1)) mask = mask | (gm > gmed + EDGE_K * gmad) # 연결 성분 필터: 배경 무늬(pegboard 구멍·나뭇결)는 작은 조각 → 제거, 공구=큰 덩어리만 유지 lbl, n = ndimage.label(mask) if n: areas = ndimage.sum(mask, lbl, range(1, n + 1)) min_area = 0.004 * mask.size keep_ids = {i for i, a in enumerate(areas, 1) if a >= min_area} mask = np.isin(lbl, list(keep_ids)) if keep_ids else np.zeros_like(mask) return float(mask.mean()), mask def _iou(a, b) -> float: ax, ay, aw, ah = a bx, by, bw, bh = b ix = max(0, min(ax + aw, bx + bw) - max(ax, bx)) iy = max(0, min(ay + ah, by + bh) - max(ay, by)) inter = ix * iy return inter / max(1, aw * ah + bw * bh - inter) def _foreign_scan(scene: np.ndarray, slot_boxes, denoise: bool, min_area_frac: float = FOREIGN_MIN_AREA) -> list: """등록 슬롯 박스 '바깥'(자유공간)에서 보드 배경 대비 전경 덩어리를 찾아 이물 후보 bbox 목록 반환. 슬롯 점유와 동일한 상대임계(median+MAD_K·MAD)·denoise 엔진 재사용, 클래스 무관.""" H, W = scene.shape[:2] img = scene.astype(np.float32) if denoise: # median(speckle 억제)+gaussian(가산잡음 평균) → 거친/저조도 배경에서도 이물이 드러남 img = ndimage.median_filter(img, size=(FOREIGN_MED, FOREIGN_MED, 1)) img = ndimage.gaussian_filter(img, sigma=(FOREIGN_GAUSS, FOREIGN_GAUSS, 0)) free = np.ones((H, W), bool) # 슬롯(팽창) 밖 = 자유공간 for x, y, w, h in slot_boxes: free[max(0, y - FOREIGN_DILATE):y + h + FOREIGN_DILATE, max(0, x - FOREIGN_DILATE):x + w + FOREIGN_DILATE] = False if free.sum() < 100: return [] bg = np.median(img[free], axis=0) # 보드 배경색(이물은 소수 → median≈보드) dist = np.linalg.norm(img - bg, axis=2) med, mad = _mad(dist[free]) fg = ndimage.binary_opening((dist > med + FOREIGN_K * mad) & free, iterations=1) # 흩뿌린 잡음 제거 lbl, n = ndimage.label(fg) if not n: return [] areas = ndimage.sum(fg, lbl, range(1, n + 1)) slices = ndimage.find_objects(lbl) min_area = min_area_frac * H * W out = [] for i, sl in enumerate(slices, 1): if sl is None or areas[i - 1] < min_area: continue ys, xs = sl out.append([xs.start, ys.start, xs.stop - xs.start, ys.stop - ys.start]) return out TTA_ANGLES = (-20.0, -10.0, 0.0, 10.0, 20.0) # tier3 회전 증강 각도(합성 paste의 ±20° 회전 범위를 덮음) class Judge: def __init__(self, embedder: DinoEmbedder, mode: str = "mad", denoise: bool = True, tier3: bool = True, edge: bool = False): self.emb = embedder self.mode = mode # 프로덕션 확정: mad + denoise (전 조건 실패모드 없음) self.denoise = denoise # tier3 확정: 회전증강 뱅크 max-sim → present→wrong_tool 0.07→0.023, 씬오경보 0.806→0.514 (recall 유지) self.tier3 = tier3 # True=등록 크롭 회전증강 뱅크로 max-sim(identity 재검증) self.edge = edge # True=점유에 엣지에너지 보강(얇은/저조도 금속 회수, A/B 중) self._reg_cache: dict[str, list] = {} def _registered_bank(self, rel_path: str) -> list: """등록 크롭 임베딩 뱅크. tier3면 여러 각도 회전본, 아니면 원본 1개. 회전은 RGBA째 돌려 알파(전경 마스크)도 함께 회전 → FFA 전경 정합 유지.""" if rel_path not in self._reg_cache: rgba = Image.open(POOL / rel_path).convert("RGBA") angles = TTA_ANGLES if self.tier3 else (0.0,) bank = [] for ang in angles: r = rgba.rotate(ang, expand=True, resample=Image.BICUBIC) if ang else rgba bank.append(self.emb.embed_ffa(r.convert("RGB"), np.asarray(r.split()[-1]))) self._reg_cache[rel_path] = bank return self._reg_cache[rel_path] def judge_scene(self, scene_path: Path, layout: dict, occ_thr: float, id_thr: float, foreign_min_area: float = FOREIGN_MIN_AREA, do_foreign: bool = True): scene = np.asarray(Image.open(scene_path).convert("RGB")) out = [] for s in layout["slots"]: occ, mask = occupancy(scene, s["bbox"], self.mode, self.denoise, self.edge) if occ < occ_thr: out.append({"slot_id": s["slot_id"], "state": "missing", "occ": round(occ, 3), "sim": None}) continue reg_region, _ = _slot_region(scene, s["bbox"]) e = self.emb.embed_ffa(Image.fromarray(reg_region), mask) sim = max(cos(e, r) for r in self._registered_bank(s["registered"])) # tier3=회전뱅크 max-sim state = "present" if sim >= id_thr else "wrong_tool" out.append({"slot_id": s["slot_id"], "state": state, "occ": round(occ, 3), "sim": round(sim, 3)}) # do_foreign=False → 슬롯 임계 튜닝 중엔 무거운 이물 스캔 생략(속도) foreign = _foreign_scan(scene, [s["bbox"] for s in layout["slots"]], self.denoise, foreign_min_area) \ if do_foreign else [] return {"slots": out, "foreign": foreign} def _load(split: str): man = json.loads((BENCH / "manifest.json").read_text())["scenes"] layouts = {p.stem.replace("layout_", ""): json.loads(p.read_text()) for p in (BENCH / "layouts").glob("layout_*.json")} rows = [] for m in man: if m["split"] != split: continue gt = json.loads((BENCH / "gt" / f"{m['scene_id']}.json").read_text()) rows.append((m["scene_id"], layouts[m["layout_id"]], {s["slot_id"]: s["state"] for s in gt["slots"]}, [f["obj_bbox"] for f in gt.get("foreign_objects", [])])) return rows def _scene_metrics(sc: dict) -> dict: """씬 단위 {tp,fp,fn,tn}(fail=positive) → pass/fail 정확도·불량탐지율·오경보율.""" n = sum(sc.values()) return { "pass_fail_acc": round((sc["tp"] + sc["tn"]) / max(1, n), 3), "fail_recall": round(sc["tp"] / max(1, sc["tp"] + sc["fn"]), 3), # 불량 공구함을 불량으로 잡는 비율 "false_alarm": round(sc["fp"] / max(1, sc["fp"] + sc["tn"]), 3), # 정상을 불량이라 오경보하는 비율 "counts": sc, } IOU_MATCH = 0.15 # 이물 예측 bbox ↔ GT bbox 매칭 임계 (작은 이물이라 관대하게) def _score(judge: Judge, rows, occ_thr, id_thr, foreign_min_area: float = FOREIGN_MIN_AREA, do_foreign: bool = True) -> dict: """슬롯 상태 + 이물 예측을 GT와 대조해 지표 산출. 씬 pass/fail은 슬롯만(scene_slotonly) / 슬롯+이물(scene) 2종을 함께 내 트레이드오프를 드러낸다.""" states = ["present", "missing", "wrong_tool"] conf = {a: {b: 0 for b in states} for a in states} # conf[gt][pred] sc_slot = {"tp": 0, "fp": 0, "fn": 0, "tn": 0} # 씬 fail 판정 — 슬롯만(이물 제외) sc_full = {"tp": 0, "fp": 0, "fn": 0, "tn": 0} # 씬 fail 판정 — 슬롯+이물 cond_pm = {"normal": [0, 0], "dark": [0, 0], "noisy": [0, 0]} # 조건별 [present총, present→missing 오판] f_tp = f_fp = f_fn = 0 # 이물 객체 단위(bbox 매칭) fs = {"tp": 0, "fp": 0, "fn": 0, "tn": 0} # 이물 씬 단위(이물 존재 유무) for sid, layout, gt, gt_foreign in rows: cond = sid.rsplit("_", 1)[-1] res = judge.judge_scene(BENCH / "scenes" / f"{sid}.jpg", layout, occ_thr, id_thr, foreign_min_area, do_foreign) pred = {p["slot_id"]: p["state"] for p in res["slots"]} pred_foreign = res["foreign"] for slot_id, g in gt.items(): conf[g][pred[slot_id]] += 1 if g == "present" and cond in cond_pm: cond_pm[cond][0] += 1 cond_pm[cond][1] += pred[slot_id] == "missing" # 이물 객체 매칭 (greedy IoU) matched = set() for pb in pred_foreign: hit = next((j for j, gb in enumerate(gt_foreign) if j not in matched and _iou(pb, gb) >= IOU_MATCH), None) if hit is None: f_fp += 1 else: matched.add(hit) f_tp += 1 f_fn += len(gt_foreign) - len(matched) g_has_f, p_has_f = len(gt_foreign) > 0, len(pred_foreign) > 0 fs["tp" if g_has_f and p_has_f else "fn" if g_has_f else "fp" if p_has_f else "tn"] += 1 # 씬 pass/fail — 슬롯만 / 슬롯+이물 gt_slot_fail = any(v != "present" for v in gt.values()) pred_slot_fail = any(v != "present" for v in pred.values()) sc_slot["tp" if gt_slot_fail and pred_slot_fail else "fn" if gt_slot_fail else "fp" if pred_slot_fail else "tn"] += 1 gt_full_fail, pred_full_fail = gt_slot_fail or g_has_f, pred_slot_fail or p_has_f sc_full["tp" if gt_full_fail and pred_full_fail else "fn" if gt_full_fail else "fp" if pred_full_fail else "tn"] += 1 total = sum(conf[a][b] for a in states for b in states) acc = sum(conf[s][s] for s in states) / max(1, total) per = {} for s in states: tp = conf[s][s] fn = sum(conf[s][b] for b in states) - tp fp = sum(conf[a][s] for a in states) - tp p = tp / max(1, tp + fp) r = tp / max(1, tp + fn) per[s] = {"P": round(p, 3), "R": round(r, 3), "F1": round(2 * p * r / max(1e-9, p + r), 3), "n": tp + fn} bal = float(np.mean([per[s]["R"] for s in states])) fp_p = f_tp / max(1, f_tp + f_fp) fp_r = f_tp / max(1, f_tp + f_fn) foreign = { "obj_P": round(fp_p, 3), "obj_R": round(fp_r, 3), "obj_F1": round(2 * fp_p * fp_r / max(1e-9, fp_p + fp_r), 3), "n_obj": f_tp + f_fn, "scene_R": round(fs["tp"] / max(1, fs["tp"] + fs["fn"]), 3), # 이물 있는 씬을 잡는 비율 "scene_FA": round(fs["fp"] / max(1, fs["fp"] + fs["tn"]), 3), # 이물 없는데 있다 오경보 "counts": {"obj_tp": f_tp, "obj_fp": f_fp, "obj_fn": f_fn, "scene": fs}, } cond_err = {c: round(v[1] / max(1, v[0]), 3) for c, v in cond_pm.items()} # 조건별 present→missing 오판율 return {"slot_acc": round(acc, 3), "balanced_acc": round(bal, 3), "per_state": per, "confusion": conf, "scene": _scene_metrics(sc_full), "scene_slotonly": _scene_metrics(sc_slot), "foreign": foreign, "cond_pm_err": cond_err} def _tune(judge, dev): best = None for occ_thr in [0.015, 0.03, 0.05, 0.08, 0.12]: for id_thr in [0.35, 0.42, 0.50, 0.58, 0.66]: r = _score(judge, dev, occ_thr, id_thr, do_foreign=False) # 슬롯 임계 탐색엔 이물 불필요 if best is None or r["balanced_acc"] > best[2]: best = (occ_thr, id_thr, r["balanced_acc"]) return best def _tune_foreign(judge, dev, occ_thr, id_thr, fa_gate=0.15): """이물 min_area를 dev에서 탐색: 이물 씬 오경보(scene_FA) ≤ fa_gate 게이트 하에 이물 F1 최대화. 게이트 통과 후보가 없으면 오경보 최소를 택한다(트레이드오프 게이트 방식).""" cands = [] for ma in [0.0005, 0.0008, 0.0012, 0.002]: # 0.0003↓는 잡음덩어리 통과로 P붕괴 → 제외 f = _score(judge, dev, occ_thr, id_thr, ma)["foreign"] cands.append((ma, f["obj_F1"], f["scene_FA"])) gated = [c for c in cands if c[2] <= fa_gate] return (max(gated, key=lambda c: c[1]) if gated else min(cands, key=lambda c: c[2]))[0] def _report(label, occ_thr, id_thr, fma, res): ce, sc, ss, fo = res["cond_pm_err"], res["scene"], res["scene_slotonly"], res["foreign"] pw = res["confusion"]["present"] p2w = pw["wrong_tool"] / max(1, sum(pw.values())) # present→wrong_tool 오판율(씬 오경보 주범) print(f"[{label:11}] thr=({occ_thr},{id_thr}) fmin_area={fma} slot_acc={res['slot_acc']} balanced={res['balanced_acc']}") print(f" present→missing: normal={ce['normal']} dark={ce['dark']} noisy={ce['noisy']} | present→wrong_tool={round(p2w, 3)}") print(f" missing F1={res['per_state']['missing']['F1']} wrong_tool P={res['per_state']['wrong_tool']['P']} R={res['per_state']['wrong_tool']['R']} F1={res['per_state']['wrong_tool']['F1']}") print(f" 이물 객체: P={fo['obj_P']} R={fo['obj_R']} F1={fo['obj_F1']} (n={fo['n_obj']}) | 씬탐지R={fo['scene_R']} 씬오경보={fo['scene_FA']}") print(f" 씬 불량탐지 슬롯만={ss['fail_recall']}→+이물={sc['fail_recall']} | 씬 오경보 슬롯만={ss['false_alarm']}→+이물={sc['false_alarm']}\n") def main(): ap = argparse.ArgumentParser() ap.add_argument("--tune-eval", action="store_true") ap.add_argument("--ab", action="store_true", help="occupancy 모드 A/B 비교 (tier0/mad/horprasert)") ap.add_argument("--ab-tier3", action="store_true", help="identity 재검증 A/B (tier2 단일 vs tier3 회전뱅크)") ap.add_argument("--ab-edge", action="store_true", help="점유 엣지보강 A/B (색거리 vs 색거리+엣지)") ap.add_argument("--modes", default="tier0,mad,horprasert") ap.add_argument("--model", default="facebook/dinov2-small") a = ap.parse_args() emb = DinoEmbedder(a.model) dev, test = _load("dev"), _load("test") print(f"dev {len(dev)} scenes / test {len(test)} scenes | model {a.model}\n") if a.ab: # (label, judge kwargs) configs = [(m, {"mode": m}) for m in a.modes.split(",")] elif a.ab_tier3: configs = [("tier2", {"tier3": False}), ("tier3", {"tier3": True})] elif a.ab_edge: configs = [("occ_color", {"edge": False}), ("occ_edge", {"edge": True})] # 점유 엣지보강 A/B else: configs = [("prod", {})] # 프로덕션 = mad + tier3 (Judge 기본값) results = {} for label, kw in configs: judge = Judge(emb, **kw) occ_thr, id_thr, _ = _tune(judge, dev) fma = _tune_foreign(judge, dev, occ_thr, id_thr) res = _score(judge, test, occ_thr, id_thr, fma) results[label] = {"occ_thr": occ_thr, "id_thr": id_thr, "foreign_min_area": fma, "test": res} _report(label, occ_thr, id_thr, fma, res) (ROOT / "outputs").mkdir(exist_ok=True) out = "synthbench_ab_tier3.json" if a.ab_tier3 else "synthbench_ab.json" (ROOT / "outputs" / out).write_text(json.dumps( {"model": a.model, "results": results}, ensure_ascii=False, indent=1)) print(f"저장: outputs/{out}") if __name__ == "__main__": main()