"""Seat-vs-PIP-knuckle width validation over a folder of hand photos. For each image: run the real pipeline up through canonical rotation, then measure two bands on the chosen finger -- * SEAT : current anatomical band [PIP - seg , PIP] (median) * KNUCKLE : PIP-centered band PIP +/- K_SEG*seg (median + p90) -- map each to a ring size, and write * an annotated overlay PNG so the seat vs knuckle lines can be eyeballed * a row in results.csv No ground truth is required: this is a visual + tabular comparison to confirm the knuckle band detects the wider PIP joint on knuckle-dominant fingers. Usage: python script/validate_knuckle.py [input_dir] [--finger index] [--limit N] """ import sys import csv import argparse from pathlib import Path import numpy as np import cv2 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from src.finger_segmentation import segment_hand, isolate_finger from src.geometry import ( estimate_finger_axis, localize_ring_zone_from_landmarks, calculate_angle_from_vertical, rotate_image_precise, rotate_axis_data, transform_points_rotation, ) from src.edge_refinement import refine_edges_sobel from src.card_detection import compute_scale_factor from src.ring_size import recommend_ring_size from measure_finger import _sam_card_detect # --- locked method parameters (per agreed recommendation) --- K_SEG = 0.4 # knuckle band half-height in units of |DIP-PIP| SLOPE, INTERCEPT = 0.809353, 0.251506 # src/calibration.json (mask/classic) def _cal_mm(raw_px, px_per_cm): raw_cm = raw_px / px_per_cm return (SLOPE * raw_cm + INTERCEPT) * 10.0 def _size(cal_mm): rs = recommend_ring_size(cal_mm / 10.0) return rs["best_match"] if rs else None def _band_widths_px(image, axis_data, zone, px_per_cm, landmarks, mask): """Return (median_px, p90_px, n) of per-row mask widths over a band.""" m = refine_edges_sobel( image=image, axis_data=axis_data, zone_data=zone, scale_px_per_cm=px_per_cm, finger_landmarks=landmarks, finger_mask=mask, mask_mode="mask_only", ) ed = m["edge_data"] vr = ed["valid_rows"] w = (ed["right_edges"][vr] - ed["left_edges"][vr]) if w.size == 0: return None, None, 0 # same MAD trim the production median uses, so seat matches the real number med = np.median(w) mad = np.median(np.abs(w - med)) keep = np.abs(w - med) <= 3 * mad if mad > 0 else np.ones_like(w, bool) w = w[keep] return float(np.median(w)), float(np.percentile(w, 90)), int(w.size) def _draw(img, landmarks, seat_c, seat_w, knk_c, knk_w, texts): """Draw both width lines + landmarks on a copy, crop tight, return it.""" vis = img.copy() t = max(2, img.shape[1] // 500) pts = [seat_c, knk_c] + [tuple(p) for p in landmarks] def hline(c, half, color): cx, cy = float(c[0]), float(c[1]) a = (int(cx - half), int(cy)); b = (int(cx + half), int(cy)) cv2.line(vis, a, b, color, t) cv2.circle(vis, a, t + 1, color, -1) cv2.circle(vis, b, t + 1, color, -1) pts.extend([a, b]) hline(seat_c, seat_w / 2, (0, 200, 0)) # green = seat hline(knk_c, knk_w / 2, (0, 80, 255)) # orange = knuckle for name, p in zip(["MCP", "PIP", "DIP", "TIP"], landmarks): cv2.circle(vis, (int(p[0]), int(p[1])), t + 2, (255, 255, 0), -1) xs = [p[0] for p in pts]; ys = [p[1] for p in pts] pad = int(img.shape[1] * 0.12) x0 = max(0, int(min(xs)) - pad); x1 = min(img.shape[1], int(max(xs)) + pad) y0 = max(0, int(min(ys)) - pad); y1 = min(img.shape[0], int(max(ys)) + pad) crop = vis[y0:y1, x0:x1].copy() fs = crop.shape[1] / 700.0 for i, txt in enumerate(texts): y = int(30 * fs) + int(i * 34 * fs) cv2.putText(crop, txt, (int(10 * fs), y), cv2.FONT_HERSHEY_SIMPLEX, fs, (0, 0, 0), max(3, int(4 * fs)), cv2.LINE_AA) cv2.putText(crop, txt, (int(10 * fs), y), cv2.FONT_HERSHEY_SIMPLEX, fs, (255, 255, 255), max(1, int(2 * fs)), cv2.LINE_AA) return crop def process(path, finger, out_dir): image = cv2.imread(str(path)) if image is None: return {"name": path.stem, "fail_reason": "imread_failed"} hand = segment_hand(image, finger=finger) if hand is None: return {"name": path.stem, "fail_reason": "hand_not_detected"} img_can = hand.get("canonical_image", image) raw_mask = hand.get("mask") card = _sam_card_detect(img_can, hand, False, None) if card is None: return {"name": path.stem, "fail_reason": "card_not_detected"} px_per_cm, _ = compute_scale_factor(card["corners"]) h, w = img_can.shape[:2] fd = isolate_finger(hand, finger=finger, image_shape=(h, w)) if fd is None or fd.get("landmarks") is None: return {"name": path.stem, "fail_reason": "finger_isolation_failed"} axis = estimate_finger_axis(fd["landmarks"]) angle = calculate_angle_from_vertical(axis["direction"]) R = None if abs(angle) >= 0.0: img_can, R = rotate_image_precise(img_can, angle, (w / 2.0, h / 2.0)) axis = rotate_axis_data(axis, R) lm = transform_points_rotation(fd["landmarks"], R) raw_mask = cv2.warpAffine(raw_mask, R, (w, h), flags=cv2.INTER_NEAREST) else: lm = fd["landmarks"] mcp, pip, dip, tip = lm seg = float(np.linalg.norm(dip - pip)) direction = (pip - mcp) / np.linalg.norm(pip - mcp) seat = localize_ring_zone_from_landmarks(lm, axis, zone_type="anatomical") half = K_SEG * seg knuckle = { "start_point": (pip - direction * half).astype(np.float32), "end_point": (pip + direction * half).astype(np.float32), "center_point": pip.astype(np.float32), "length": float(2 * half), "localization_method": "pip_knuckle", } seat_med, _, seat_n = _band_widths_px(img_can, axis, seat, px_per_cm, lm, raw_mask) knk_med, knk_p90, knk_n = _band_widths_px(img_can, axis, knuckle, px_per_cm, lm, raw_mask) if seat_med is None or knk_med is None: return {"name": path.stem, "fail_reason": "no_valid_rows"} seat_cal = _cal_mm(seat_med, px_per_cm) knk_cal = _cal_mm(knk_med, px_per_cm) knk_p90_cal = _cal_mm(knk_p90, px_per_cm) row = { "name": path.stem, "finger": finger, "scale_px_per_cm": round(px_per_cm, 1), "seat_cal_mm": round(seat_cal, 2), "seat_size": _size(seat_cal), "knuckle_med_cal_mm": round(knk_cal, 2), "knuckle_size": _size(knk_cal), "knuckle_p90_cal_mm": round(knk_p90_cal, 2), "knuckle_p90_size": _size(knk_p90_cal), "gap_mm": round(knk_cal - seat_cal, 2), "size_up": int((_size(knk_cal) or 0) > (_size(seat_cal) or 0)), "fail_reason": "", } texts = [ f"seat {seat_cal:.1f}mm size {row['seat_size']}", f"knuckle {knk_cal:.1f}mm size {row['knuckle_size']} (gap {row['gap_mm']:+.1f}mm)", ] crop = _draw(img_can, lm, seat["center_point"], seat_med, pip, knk_med, texts) cv2.imwrite(str(out_dir / f"{path.stem}__cmp.png"), crop) return row def main(): ap = argparse.ArgumentParser() ap.add_argument("input_dir", nargs="?", default="input/kol_high_quality") ap.add_argument("--finger", default="index") ap.add_argument("--limit", type=int, default=0) args = ap.parse_args() in_dir = Path(args.input_dir) out_dir = Path("output/knuckle_validation") out_dir.mkdir(parents=True, exist_ok=True) exts = {".jpg", ".jpeg", ".png"} images = sorted(p for p in in_dir.iterdir() if p.suffix.lower() in exts) if args.limit: images = images[:args.limit] fields = ["name", "finger", "scale_px_per_cm", "seat_cal_mm", "seat_size", "knuckle_med_cal_mm", "knuckle_size", "knuckle_p90_cal_mm", "knuckle_p90_size", "gap_mm", "size_up", "fail_reason"] rows = [] for i, p in enumerate(images, 1): try: r = process(p, args.finger, out_dir) except Exception as e: r = {"name": p.stem, "fail_reason": f"error:{type(e).__name__}:{e}"} rows.append({k: r.get(k, "") for k in fields}) print(f"[{i}/{len(images)}] {p.stem}: " + (r["fail_reason"] if r.get("fail_reason") else f"seat={r['seat_size']} knuckle={r['knuckle_size']} gap={r['gap_mm']:+.1f}mm"), flush=True) csv_path = out_dir / "results.csv" with open(csv_path, "w", newline="") as f: wcsv = csv.DictWriter(f, fieldnames=fields) wcsv.writeheader() wcsv.writerows(rows) ok = [r for r in rows if not r["fail_reason"]] up = [r for r in ok if r["size_up"]] print("\n==== summary ====") print(f"processed {len(rows)}, ok {len(ok)}, failed {len(rows) - len(ok)}") if ok: gaps = [float(r["gap_mm"]) for r in ok] print(f"knuckle wider in {sum(g > 0 for g in gaps)}/{len(ok)} images; " f"mean gap {np.mean(gaps):+.2f}mm, max {max(gaps):+.2f}mm") print(f"knuckle bumps the recommended size up in {len(up)}/{len(ok)} images") print(f"overlays + results.csv -> {out_dir}") if __name__ == "__main__": main()