CI / ledger-and-tools (push) Successful in 20s
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>
188 lines
8.9 KiB
Python
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())
|