Files
jungfrauandClaude Fable 5.1 ddaa5a99e6
CI / ledger-and-tools (push) Successful in 20s
dedup study: suppressing nested duplicate outlines on the unseen gold frames
Containment (intersection over the smaller area) between human-drawn crystal pairs never
exceeds 0.42, while the model-drawn duplicates sit at 0.96-1.0, so a rule at 0.9 cannot
lose a real pair. On 231 unseen frames, v4: precision 0.831 -> 0.876, recall 0.881 -> 0.869,
F1 0.855 -> 0.873, 46 outlines removed, 8 true positives lost where the less confident of
two outlines was the one matching the label.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 22:26:18 +02:00

188 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Does suppressing nested duplicate outlines help, and does it ever cost a real second crystal?
python tools/dedup_study.py yolo26n-seg-v4-noflip-mask1-2026-10-02 [more versions] [--thresholds 0.5,0.7,0.9]
The models draw the same object twice (one outline inside the other) because the end-to-end
head has no NMS and IoU between a small and a large outline is low. Containment is not:
``intersection / smaller area``. The rule under study keeps, per class, the most confident
instance and drops any other instance of that class whose containment with a kept one is at
least the threshold. Two separate crystals have low containment and both stay.
For every model and threshold the script reports, on the gold frames no model trained on:
overall precision / recall / F1, the F1 of crystal, loop and face, how many instances the rule
dropped, and the crystal recall on the gold frames that hold two crystals or more (the frames
where a wrong rule would show). It also prints the containment between gold crystal pairs:
if human-drawn pairs never reach the threshold, the rule cannot lose one by construction.
Predictions are cached in runs/_dedup_<version>.json; results go to runs/dedup_study.json.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Sequence, Tuple
import cv2
import numpy as np
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
sys.path.insert(0, str(ROOT))
from src.eval.metrics import ClassCount, aggregate, class_report, evaluate_image # noqa: E402
from src.eval.yolo_labels import PolygonInstance, read_label_file # noqa: E402
NAMES = ["loop_all", "pin", "crystal", "loop_face", "ice", "needle"]
GRID = 512
def raster(points_norm: Sequence[Tuple[float, float]], size: int = GRID) -> np.ndarray:
m = np.zeros((size, size), dtype=np.uint8)
if len(points_norm) >= 3:
pts = np.array([(x * size, y * size) for x, y in points_norm], dtype=np.int32).reshape(-1, 1, 2)
cv2.fillPoly(m, [pts], 1)
return m
def containment(a: np.ndarray, b: np.ndarray) -> float:
"""Intersection over the smaller area: 1 when one outline lies inside the other."""
small = min(int(a.sum()), int(b.sum()))
return float(np.logical_and(a, b).sum()) / small if small else 0.0
def suppress(insts: List[dict], thr: float, classes: set | None) -> Tuple[List[dict], int]:
"""Keep the most confident instance of a class; drop another of the same class contained in a
kept one by ``thr`` or more. ``classes`` = class ids the rule applies to (None = all)."""
kept: List[dict] = []
masks: Dict[int, np.ndarray] = {}
dropped = 0
for inst in sorted(insts, key=lambda i: -i["conf"]):
if classes is not None and inst["cls"] not in classes:
kept.append(inst)
continue
m = raster(inst["points"])
dup = False
for k in kept:
if k["cls"] != inst["cls"] or (classes is not None and k["cls"] not in classes):
continue
if containment(m, masks[id(k)]) >= thr:
dup = True
break
if dup:
dropped += 1
continue
masks[id(inst)] = m
kept.append(inst)
return kept, dropped
def predict_all(version: str, stems: List[str], img_dir: Path, conf: float = 0.25) -> Dict[str, List[dict]]:
cache = ROOT / "runs" / f"_dedup_{version}.json"
if cache.exists():
return json.loads(cache.read_text())
from ultralytics import YOLO
model = YOLO(str(ROOT / "models" / "registry" / version / "weights.pt"))
out: Dict[str, List[dict]] = {}
for stem in stems:
img = next((img_dir / f"{stem}{e}" for e in (".jpg", ".jpeg", ".png") if (img_dir / f"{stem}{e}").exists()), None)
if img is None:
continue
res = model.predict(str(img), conf=conf, verbose=False)[0]
insts = []
if res.masks is not None:
h, w = res.orig_shape
for poly, cls, c in zip(res.masks.xy, res.boxes.cls.tolist(), res.boxes.conf.tolist()):
if len(poly) >= 3:
insts.append({"cls": int(cls), "conf": float(c), "points": [(float(x) / w, float(y) / h) for x, y in poly]})
out[stem] = insts
cache.write_text(json.dumps(out))
return out
def to_instances(insts: List[dict]) -> List[PolygonInstance]:
return [PolygonInstance(i["cls"], [tuple(p) for p in i["points"]]) for i in insts]
def unseen_stems(gold_labels: Path) -> Tuple[List[str], set]:
cj = json.loads((ROOT / "data" / "gold" / "unseen_core.json").read_text())
seen = set(cj.get("seen", []))
trained = set()
for m in (ROOT / "data" / "datasets").glob("*/manifest.csv"):
with open(m, encoding="utf-8") as fh:
next(fh, None)
for line in fh:
key = line.split(",")[0].strip()
if key.startswith("tpi_"):
trained.add(key)
groups = ROOT / "data" / "gold" / "groups.json"
excluded = set(json.loads(groups.read_text()).get("excluded", {})) if groups.exists() else set()
key_of = lambda s: "tpi_" + re.search(r"tpi_(\d+)", s).group(1)
stems = sorted(p.stem for p in gold_labels.glob("*.txt") if p.stem not in excluded)
return [s for s in stems if s not in seen and key_of(s) not in trained], set(cj["core"])
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("versions", nargs="+")
ap.add_argument("--thresholds", default="0.5,0.7,0.9")
args = ap.parse_args(argv)
thresholds = [float(t) for t in args.thresholds.split(",")]
gold_labels, img_dir = ROOT / "data" / "gold" / "labels", ROOT / "data" / "gold" / "images"
unseen, core = unseen_stems(gold_labels)
gold = {s: read_label_file(gold_labels / f"{s}.txt") for s in unseen}
crystal = NAMES.index("crystal")
multi = [s for s in unseen if sum(1 for i in gold[s] if i.class_id == crystal) >= 2]
# how close do human-drawn crystal pairs come to the rule?
pair_c = []
for s in multi:
cs = [raster(i.points) for i in gold[s] if i.class_id == crystal]
for a in range(len(cs)):
for b in range(a + 1, len(cs)):
pair_c.append(containment(cs[a], cs[b]))
print(f"unseen gold frames {len(unseen)} (core {sum(1 for s in unseen if s in core)}), with two crystals or more: {len(multi)} "
f"({sum(1 for s in multi for i in gold[s] if i.class_id == crystal)} crystals, {len(pair_c)} pairs)")
if pair_c:
pc = sorted(pair_c)
print(f"containment between gold crystal pairs: median {pc[len(pc) // 2]:.2f}, max {pc[-1]:.2f}, "
f"pairs at or above 0.5 / 0.7 / 0.9: {sum(c >= 0.5 for c in pc)} / {sum(c >= 0.7 for c in pc)} / {sum(c >= 0.9 for c in pc)}")
results = {"unseen_frames": len(unseen), "multi_crystal_frames": len(multi), "gold_pair_containment": pair_c, "models": {}}
variants = [("none", None, None)] + [(f"crystal>={t:g}", t, {crystal}) for t in thresholds] + [(f"all>={t:g}", t, None) for t in thresholds]
for version in args.versions:
preds = predict_all(version, unseen, img_dir)
rows = {}
print(f"\n== {version}")
print(f"{'rule':14} {'P':>6} {'R':>6} {'F1':>6} {'crystal':>7} {'loop':>6} {'face':>6} {'pin':>6} {'dropped':>7} {'crystal R on multi-crystal frames':>34}")
for name, thr, classes in variants:
per_image, dropped, multi_counts = [], 0, []
for s in unseen:
insts = preds.get(s, [])
if thr is not None:
insts, d = suppress(insts, thr, classes)
dropped += d
ev = evaluate_image(gold[s], to_instances(insts))
per_image.append(ev)
if s in multi:
multi_counts.append(ev.get(crystal, ClassCount()))
total = aggregate(per_image)
overall = ClassCount()
for cc in total.values():
overall.tp += cc.tp; overall.fp += cc.fp; overall.fn += cc.fn
o = class_report(overall)
per = {NAMES[c]: class_report(cc) for c, cc in total.items() if 0 <= c < len(NAMES)}
mt = sum(c.tp for c in multi_counts); mf = sum(c.fn for c in multi_counts)
rows[name] = {"precision": o["precision"], "recall": o["recall"], "f1": o["f1"], "per_class_f1": {k: v["f1"] for k, v in per.items()},
"fp": overall.fp, "dropped": dropped, "multi_crystal": {"tp": mt, "fn": mf}}
f = lambda k: per.get(k, {}).get("f1", 0.0)
print(f"{name:14} {o['precision']:6.3f} {o['recall']:6.3f} {o['f1']:6.3f} {f('crystal'):7.3f} {f('loop_all'):6.3f} {f('loop_face'):6.3f} {f('pin'):6.3f} {dropped:7d} {mt:3d} / {mt + mf:3d}")
results["models"][version] = rows
(ROOT / "runs" / "dedup_study.json").write_text(json.dumps(results, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())