Skip to content

Commit 5a3da39

Browse files
anselm67claude
andcommitted
scripts: add predicted-box tolerance diagnostic (pre-merge)
One-off diagnostic (top-level scripts/, run not imported, no src/ changes): feeds staffer's predicted stave crops to noter and measures the transcription-similarity drop vs ground-truth crops, bucketed by vertical page position. Reports per-bin base/pred similarity + detection miss-rate, plus the matched-box error (Δtop/Δbot) that specifies the noter box-jitter augmentation for the end-to-end merge. Self-contained: reuses NoterModule.predict, NoterDataset._load_image/_load_sequence, sequence_edit_distance, and a local copy of staffer predict's active-stave grouping; loads configs inline via dataclass field-filtering (no cli import). ruff + mypy clean, CPU smoke-tested. Also document scripts/ in the CLAUDE.md repo layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 827526c commit 5a3da39

2 files changed

Lines changed: 287 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,15 @@ src/
120120
utils/ # Walker async pool, json_query, misc helpers
121121
verovio/ # Verovio wrapper, SVG scraper (LayoutExtractor), binaries
122122
tests/ # pytest, mirrors src/ structure
123+
scripts/ # one-off diagnostics (run, not imported; outside the package)
123124
```
124125

126+
`scripts/` holds standalone tools run via `uv run python scripts/<name>.py` — they
127+
import from `src/` but are not part of the installable package (not type-checked
128+
under `mypy src tests`, may use private APIs). e.g. `eval_predicted_boxes.py`:
129+
feeds staffer's predicted stave crops to noter to measure the transcription drop
130+
vs ground-truth crops (the pre-merge tolerance diagnostic).
131+
125132
---
126133

127134
## Pipeline overview

scripts/eval_predicted_boxes.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
#!/usr/bin/env python
2+
"""One-off PRE-MERGE DIAGNOSTIC — not wired into any CLI, no src/ changes.
3+
4+
Measures how much noter's transcription degrades when fed staffer's PREDICTED
5+
stave crops instead of ground-truth crops, bucketed by vertical page position.
6+
7+
It answers the question behind the end-to-end merge: noter is trained on
8+
geometrically perfect GT crops; at merge time it sees the detector's jittering
9+
boxes. This quantifies (a) the per-bin token-similarity drop and (b) the
10+
matched-box error distribution (Δtop/Δbottom px) + detection miss rate — i.e.
11+
the SPEC for the noter box-jitter augmentation.
12+
13+
Both models run inference only. Everything is imported/reused from src/; the
14+
single copied block is staffer predict's active-stave grouping (see below).
15+
16+
Run from /home/anselm/projects/Music (use --device cpu to avoid contending with
17+
a running training on the GPU):
18+
19+
uv run python scripts/eval_predicted_boxes.py \
20+
--noter enhanced3 --staffer stave-primary-grid \
21+
--csv System2.csv --pages 300 --device cuda
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import argparse
27+
import random
28+
from collections import defaultdict
29+
from dataclasses import fields
30+
from pathlib import Path
31+
32+
import torch
33+
from scipy.optimize import linear_sum_assignment
34+
from torchvision.io import decode_image
35+
from torchvision.transforms import v2
36+
from tqdm import tqdm
37+
38+
from noter import NoterConfig, NoterDataset, NoterModule, Vocab
39+
from pdmx import PDMX, Box
40+
from staffer import StafferConfig, StafferModule
41+
from utils import sequence_edit_distance, strip_eos
42+
43+
# Mirror StafferDataset.transform (the normalize constants are dataset stats).
44+
_NORM_MEAN, _NORM_STD = 0.9563435316085815, 0.16557540870879858
45+
46+
NUM_BINS = 16
47+
48+
49+
def make_staffer_transform(cfg: StafferConfig) -> v2.Transform:
50+
return v2.Compose(
51+
[
52+
v2.Grayscale(),
53+
v2.Resize(
54+
cfg.image_shape,
55+
interpolation=cfg.interpolation,
56+
antialias=cfg.antialias,
57+
),
58+
v2.ToDtype(torch.float, scale=True),
59+
v2.Normalize(mean=[_NORM_MEAN], std=[_NORM_STD]),
60+
]
61+
)
62+
63+
64+
@torch.no_grad()
65+
def staffer_active_boxes(
66+
model: StafferModule, img: torch.Tensor
67+
) -> list[tuple[float, float, float, float]]:
68+
"""Predicted stave boxes as normalised (left, top, right, bottom), top→bottom.
69+
70+
Copied from `staffer predict` (cli/staffer.py): active queries are
71+
non-contiguous, so the boundary cumsum runs over active staves sorted
72+
top-to-bottom; each stave inherits (left, right) from its grouped system.
73+
"""
74+
stave_tb, stave_logits, boundary_logits, sys_lr, _sys_logits = (
75+
t.squeeze(0) for t in model.forward(img)
76+
)
77+
stave_logit = stave_logits.squeeze(-1)
78+
active = (stave_logit > 0.0).nonzero(as_tuple=True)[0]
79+
if active.numel() == 0:
80+
return []
81+
active = active[stave_tb[active, 0].argsort()] # top-to-bottom
82+
boundary = (boundary_logits.squeeze(-1)[active] > 0.0).long()
83+
group = (boundary.cumsum(0) - 1).clamp(0, sys_lr.shape[0] - 1)
84+
boxes = []
85+
for i, q in enumerate(active.tolist()):
86+
left, right = sys_lr[group[i]].tolist()
87+
top, bot = stave_tb[q].tolist()
88+
boxes.append((left, top, right, bot))
89+
return boxes
90+
91+
92+
def similarity(
93+
gt_seq: torch.Tensor, pred_tokens: torch.Tensor, max_chords: int
94+
) -> float:
95+
"""1 - normalised edit distance, matching cli/noter.run_eval."""
96+
gt_content = strip_eos(gt_seq[1:], Vocab.EOS)
97+
pred_content = strip_eos(pred_tokens.cpu(), Vocab.EOS)
98+
edit = sequence_edit_distance(gt_content, pred_content, Vocab.PAD)
99+
max_cost = max(len(gt_content), len(pred_content)) * max_chords
100+
return 1.0 - edit / max_cost if max_cost > 0 else 1.0
101+
102+
103+
def main() -> None:
104+
ap = argparse.ArgumentParser(description=__doc__)
105+
ap.add_argument("--noter", default="enhanced3")
106+
ap.add_argument("--staffer", default="stave-primary-grid")
107+
ap.add_argument("--home", type=Path, default=Path("/home/anselm/datasets/PDMX"))
108+
ap.add_argument("--csv", default="System2.csv")
109+
ap.add_argument("--pages", type=int, default=300, help="random pages to evaluate")
110+
ap.add_argument("--limit", type=int, default=-1, help="PDMX rows to load (-1=all)")
111+
ap.add_argument("--device", default="cuda")
112+
ap.add_argument("--seed", type=int, default=0)
113+
args = ap.parse_args()
114+
115+
random.seed(args.seed)
116+
torch.manual_seed(args.seed)
117+
device = torch.device(
118+
args.device if (args.device != "cuda" or torch.cuda.is_available()) else "cpu"
119+
)
120+
121+
pdmx = PDMX(args.home, args.csv, -1, args.limit)
122+
123+
# noter
124+
n_ckpt = Path("checkpoints") / "noter" / args.noter / "last.ckpt"
125+
n_hp = torch.load(n_ckpt, weights_only=False)["hyper_parameters"]
126+
n_keep = {f.name for f in fields(NoterConfig)}
127+
n_cfg = NoterConfig(**{k: v for k, v in n_hp.items() if k in n_keep})
128+
dataset = NoterDataset(n_cfg, pdmx)
129+
noter = (
130+
NoterModule.load_from_checkpoint(
131+
n_ckpt, config=n_cfg, weights_only=False, map_location=device
132+
)
133+
.to(device)
134+
.eval()
135+
)
136+
137+
# staffer
138+
s_ckpt = Path("checkpoints") / "staffer" / args.staffer / "last.ckpt"
139+
s_hp = torch.load(s_ckpt, weights_only=False)["hyper_parameters"]
140+
s_keep = {f.name for f in fields(StafferConfig)}
141+
s_cfg = StafferConfig(**{k: v for k, v in s_hp.items() if k in s_keep})
142+
staffer = (
143+
StafferModule.load_from_checkpoint(
144+
s_ckpt, config=s_cfg, weights_only=False, map_location=device
145+
)
146+
.to(device)
147+
.eval()
148+
)
149+
s_transform = make_staffer_transform(s_cfg)
150+
151+
page_h, page_w = n_cfg.page_shape # (966, 680)
152+
bin_size = page_h / NUM_BINS
153+
154+
# Group GT staves (noter items) by page image.
155+
page_to_idx: dict[str, list[int]] = defaultdict(list)
156+
for idx, item in enumerate(dataset.items):
157+
page_to_idx[str(item[1])].append(idx)
158+
pages = list(page_to_idx)
159+
random.shuffle(pages)
160+
pages = pages[: args.pages]
161+
162+
# Per-bin accumulators.
163+
base: list[list[float]] = [[] for _ in range(NUM_BINS)] # GT-box similarity
164+
pred: list[list[float]] = [[] for _ in range(NUM_BINS)] # predicted-box sim
165+
dtop: list[list[float]] = [[] for _ in range(NUM_BINS)] # |pred_top-gt_top| px
166+
dbot: list[list[float]] = [[] for _ in range(NUM_BINS)] # |pred_bot-gt_bot| px
167+
n_gt = n_missed = n_extra = 0
168+
missed = [0 for _ in range(NUM_BINS)] # detection misses per bin
169+
170+
for png in tqdm(pages, desc="pages"):
171+
idxs = page_to_idx[png]
172+
# --- staffer inference on the page ---
173+
try:
174+
page_img = s_transform(decode_image(png)).unsqueeze(0).to(device)
175+
except Exception:
176+
continue
177+
pred_boxes = staffer_active_boxes(staffer, page_img)
178+
pred_cy = [((t + b) / 2.0) * page_h for (_l, t, _r, b) in pred_boxes]
179+
180+
# GT staves on this page: (idx, center_y_px, height_px)
181+
gts = []
182+
for idx in idxs:
183+
box = dataset.items[idx][2]
184+
gts.append((idx, (box.top + box.bottom) / 2.0, box.height))
185+
n_gt += len(gts)
186+
187+
# --- match GT <-> predicted by center-y (1-D optimal + threshold) ---
188+
match = {} # gt position -> pred position
189+
if pred_boxes and gts:
190+
cost = torch.tensor(
191+
[[abs(cy - pcy) for pcy in pred_cy] for (_i, cy, _h) in gts]
192+
)
193+
rows, cols = linear_sum_assignment(cost.numpy())
194+
for r, c in zip(rows, cols):
195+
thresh = 0.5 * gts[r][2] # half the GT staff height
196+
if cost[r, c].item() <= thresh:
197+
match[r] = c
198+
# match values are distinct preds (1-1 assignment), so unmatched preds
199+
# = total preds - matched. Misses (unmatched GTs) are counted per-GT below.
200+
n_extra += len(pred_boxes) - len(match)
201+
202+
# --- score each GT stave: GT-box baseline vs predicted-box ---
203+
for gpos, (idx, cy, _h) in enumerate(gts):
204+
mxl, png_path, gt_box, spine, fb, lb = dataset.items[idx]
205+
b = min(int(cy / bin_size), NUM_BINS - 1)
206+
207+
res = dataset._load_image(mxl, png_path, gt_box)
208+
seq = dataset._load_sequence(mxl, spine, fb, lb)
209+
if res is None or seq is None:
210+
continue
211+
img0, w0 = res
212+
p0 = noter.predict(
213+
img0.unsqueeze(0).to(device), torch.tensor([w0]).to(device)
214+
)
215+
base[b].append(similarity(seq, p0[0], n_cfg.max_chords))
216+
217+
if gpos not in match: # detection miss → whole staff lost
218+
pred[b].append(0.0)
219+
missed[b] += 1
220+
n_missed += 1
221+
continue
222+
pl, pt, pr, pb = pred_boxes[match[gpos]]
223+
pbox = Box(
224+
(int(pl * page_w), int(pt * page_h)),
225+
(int(pr * page_w), int(pb * page_h)),
226+
)
227+
dtop[b].append(abs(pt * page_h - gt_box.top))
228+
dbot[b].append(abs(pb * page_h - gt_box.bottom))
229+
res2 = dataset._load_image(mxl, png_path, pbox)
230+
if res2 is None:
231+
pred[b].append(0.0)
232+
continue
233+
img1, w1 = res2
234+
p1 = noter.predict(
235+
img1.unsqueeze(0).to(device), torch.tensor([w1]).to(device)
236+
)
237+
pred[b].append(similarity(seq, p1[0], n_cfg.max_chords))
238+
239+
# --- report ---
240+
def avg(xs: list[float]) -> float:
241+
return sum(xs) / len(xs) if xs else float("nan")
242+
243+
print(f"\nPredicted-box tolerance — noter={args.noter} staffer={args.staffer}")
244+
print(f"{len(pages)} pages, {n_gt} GT staves, page_shape={n_cfg.page_shape}")
245+
miss_pct = 100 * n_missed / max(n_gt, 1)
246+
print(
247+
f"detection: matched {n_gt - n_missed}/{n_gt} "
248+
f"(miss {n_missed}, {miss_pct:.1f}%) · extra preds {n_extra}\n"
249+
)
250+
print(
251+
f" {'bin':>3} {'y-range':>10} {'n':>5} {'base':>7} {'pred':>7} "
252+
f"{'Δsim':>7} {'miss%':>6} {'Δtop':>6} {'Δbot':>6}"
253+
)
254+
tot_b, tot_p = [], []
255+
for bb in range(NUM_BINS):
256+
nb = len(base[bb])
257+
if nb == 0:
258+
continue
259+
tot_b += base[bb]
260+
tot_p += pred[bb]
261+
print(
262+
f" {bb:>3} {int(bb * bin_size):>4}-{int((bb + 1) * bin_size):<5} {nb:>5} "
263+
f"{avg(base[bb]):>7.3f} {avg(pred[bb]):>7.3f} "
264+
f"{avg(base[bb]) - avg(pred[bb]):>7.3f} {100 * missed[bb] / nb:>5.1f}% "
265+
f"{avg(dtop[bb]):>5.1f} {avg(dbot[bb]):>5.1f}"
266+
)
267+
print(
268+
f"\n TOTAL base={avg(tot_b):.4f} pred={avg(tot_p):.4f} "
269+
f"Δ={avg(tot_b) - avg(tot_p):.4f}"
270+
)
271+
all_top = [v for b in dtop for v in b]
272+
all_bot = [v for b in dbot for v in b]
273+
print(
274+
f" matched-box error (jitter spec): "
275+
f"Δtop {avg(all_top):.2f}px Δbot {avg(all_bot):.2f}px"
276+
)
277+
278+
279+
if __name__ == "__main__":
280+
main()

0 commit comments

Comments
 (0)