Skip to content

feat: staff-finding pipeline improvements — line following, coord fix, stave QA boxes - #53

Open
kyrieb-ekat wants to merge 27 commits into
mainfrom
kyrie/staff-finding
Open

feat: staff-finding pipeline improvements — line following, coord fix, stave QA boxes#53
kyrieb-ekat wants to merge 27 commits into
mainfrom
kyrie/staff-finding

Conversation

@kyrieb-ekat

@kyrieb-ekat kyrieb-ekat commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Line-following refinement (fit_centerline.py): when a bounding box contains multiple arching stafflines (page curvature), the initial Huber fit averages across them and produces high residuals. A new sliding-window trace walks left-to-right within a ±1.5h band from the box vertical center, isolating one line before refitting with a cubic polynomial. Handles both C-shaped arches and S-shaped parchment waves. Flagged as line_following_applied:deg3/deg2.

  • Page-coordinate offset fix (fit_centerline.py, run_page.py, group_staves.py): FitResult coordinates are crop-local. Adds x_page_offset / y_page_offset fields (populated from YOLO bounding box ulx/uly in run_page.py). Fixes two bugs:

    • Stave grouping was seeing all y-gaps as ~0 px → every line assigned to one stave (mode=96)
    • Diagnostic lines were drawn at crop-local coords on the page canvas → all appeared in the top-left corner
  • Stave bounding boxes (group_staves.py): red rectangles + Sn labels drawn around each stave group in the diagnostic PNG for visual QA.

  • Test fix (test_fit_centerline.py): fixed hardcoded /home/claude sys.path; added test_two_line_box_line_following verifying the line-following trigger and refit quality.

Test plan

  • python -m pytest staff-finding/scripts/script_tests/test_fit_centerline.py staff-finding/scripts/script_tests/test_group_staves.py -v — 15 tests pass
  • Run run_page.py on a real page; confirm grouping diagnostic shows colored centerlines at correct positions, red stave boxes, and a reasonable mode lines/stave (~4–5 for square notation)
  • Check line_following_applied flag appears in fit diagnostics for boxes with high initial residuals

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added multiple staffline detection experiments, including dynamic-programming, Gaussian-process, implicit-neural, and periodicity-based approaches.
    • Added single-page and batch evaluation tools with precision, recall, F1, split, and error metrics.
    • Added staffline interpolation, stave rhythm analysis, page-coordinate outputs, and enhanced diagnostics.
    • Added a training workflow for building and training a staffline detector.
  • Bug Fixes

    • Inference now skips unsupported macOS files and continues after individual prediction failures.
  • Documentation

    • Expanded guidance for evaluation, experiments, implementation status, and future pitch-finding work.
  • Tests

    • Added and updated fixtures, regression tests, and end-to-end results across several manuscript pages.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds staffline tracing experiments, shared evaluation tooling, grouping and interpolation logic, line-fitting refinements, training support, documentation, datasets, generated fixtures, and portability updates to scripts and tests.

Changes

Staff-finding pipeline

Layer / File(s) Summary
Experiment methods and shared outputs
staff-finding/experiments/*
Adds DP, Gaussian-process, implicit-neural, and periodicity-based staffline fitting methods, runners, shared result conversion, and experiment documentation.
Grouping, interpolation, and fitting
staff-finding/scripts/component_filter.py, fit_centerline.py, group_staves.py, interpolate_staves.py
Adds companion component retention, line-following refinement, page-coordinate handling, valley thresholds, rhythm classification, missing-line interpolation, and expanded diagnostics.
Evaluation, inference, and training
staff-finding/scripts/eval_*.py, run_page.py, train_staffline_detector.py, scripts/run_inference.py
Adds single-page and batch evaluation, page-absolute JSOMR output, inference skipping for failures and macOS metadata files, and YOLO dataset construction/training.
Datasets and generated fixtures
data/*, staff-finding/image-sets/*, staff-finding/e2e_tests/*
Adds annotation datasets, model predictions, staffline results, grouping reports, and end-to-end fixture outputs.
Documentation and test maintenance
README.md, staff-finding/dox/*, staff-finding/scripts/script_tests/*
Documents evaluation and implementation status, adds experiment notes, updates interpolation expectations, and replaces hard-coded test import paths.
Repository configuration
.gitignore, staff-finding/README.md, staff-finding/scripts/bgr_adapter.py, yolo_io.py
Updates ignored asset patterns, repository descriptions, and formatting without changing adapter or parser behaviour.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main staff-finding improvements: line following, coordinate fixes, and diagnostic stave boxes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch kyrie/staff-finding

Comment @coderabbitai help to get the list of available commands.

kyrieb-ekat and others added 18 commits July 27, 2026 12:12
fit_centerline.py
- Add x_page_offset / y_page_offset fields to FitResult (default 0.0)
  so crop-local coords can be converted to page-absolute downstream
- Add line-following refinement triggered when initial Huber residual
  mean > 1.0 * scale_unit (multi-line box signal):
    * _trace_line() slides left-to-right within a 1.5h band, emitting
      one median-y per window — naturally follows any curve shape
    * Refit uses cubic polynomial (LINE_FOLLOW_POLY_DEGREE=3) when
      ≥ 8 trace points available, else falls back to quadratic; handles
      both C-shaped page arches and S-shaped parchment waves
    * Only accepted if refit residual improves over initial fit
    * Flagged as line_following_applied:deg3/deg2 in FitResult.flags
- Diagnostic overlay now shows cyan scatter dots for trace points when
  line-following was applied; legend distinguishes from fitted line

group_staves.py
- _y_at_fit_center: add fit.y_page_offset so gap analysis uses
  page-absolute y-positions (fixes all-gaps-near-zero bug that put
  every line in one giant stave)
- _save_grouping_diagnostic drawing loop: add x/y page offsets to
  xs/ys before cv2.polylines (fixes lines-at-origin visualization bug)
- After polylines loop, draw a red bounding rectangle + Sn label
  around each stave group for quick visual QA

run_page.py
- Populate fit_result.x_page_offset / y_page_offset from actual_box
  (ulx, uly) immediately after each fit_centerline call

test_fit_centerline.py
- Fix sys.path (was hardcoded /home/claude)
- Add test_two_line_box_line_following: constructs a ComponentFilterResult
  directly with two-line pixel data, verifies line_following_applied flag
  and tight residual (< 3 px) after the refit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds GentAnt1475 folio 17 (left and right crops) with YOLO inference
outputs (raw + corrected) as the primary test page for the implicit
neural and interpolation experiments documented in
experiments/implicit_neural/NOTES.md.

Moves the Ordo Virtutum page from image-sets/ root into its own
image-sets/ordo_virt_000/ subdirectory, matching the layout convention
used for gent/.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Introduces staff-finding/experiments/ — a family of standalone runner
scripts that plug in after YOLO detection and exercise different
curve-fitting approaches. Each runner produces identical JSOMR JSON and
stave-grouping diagnostic PNGs so results are directly comparable.

shared_utils.py: common helpers (YOLO parsing, ExperimentFitResult
bridge, run_grouping_and_save) shared by every runner.

Runners added:
- dp_tracing/        Dynamic-programming horizontal-emphasis tracer
- gp_centerlines/    Gaussian-process Matern fit on ink-pixel coords
- implicit_neural/   Test-time MLP on pixel brightness (best result
                     on Gent ms: mode=8, see implicit_neural/NOTES.md)
- periodicity/       Autocorrelation comb (limited on per-box crops)
- heatmap_regression/ Planned; NOTES.md stubs the design

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…terpolate_staves

group_staves.py additions
  - Stage 7: gap-based missing-line synthesis with two triggers:
      (A) in-stave gap fill — fires when consecutive detected lines in
          the same stave have a y-gap in [cut_threshold, max_threshold],
          inserts n_missing lines evenly spaced between them
      (B) edge extrapolation — grid-anchored at the bottommost detected
          line, fills empty slots; requires >= mode_n//2 lines detected
          (safety against over-synthesis on sparse/split staves)
  - Adaptive thresholds: cut_threshold (Otsu/median), min_threshold
      (noise floor = 0.5 * scale_unit), max_threshold (mean of
      inter-stave gaps) — all three shown on the gap distribution chart
  - Territory boundaries: interpolated lines are constrained to each
      stave's y-range; top/bottom staves cannot extrapolate outside
      their own detected extent
  - Stage 6c _check_stave_rhythm: classifies each stave as normal /
      under_populated / over_populated by comparing detected intra-stave
      gap count to mode_n-1; also reports gap_cv (spacing consistency)
  - Rhythm gate in Stage 7: staves flagged under/over_populated are
      skipped entirely — their detected assignments are preserved as-is
  - Diagnostic chart: per-bar colouring by rhythm status (red = under,
      purple = over), annotated labels showing observed/expected counts,
      shaded noise and missing-line trigger zones
  - StaveGroupingResult gains min_threshold_px, interpolation_max_gap_px,
      rhythm_anomalies fields; summary print shows stave count + avg

interpolate_staves.py (new)
  Extracted from group_staves Stage 7 to keep grouping and synthesis
  concerns separate. Contains InterpolatedLine dataclass,
  _compute_interpolation_max_gap, _interpolate_between, and the public
  interpolate_missing_lines() API. group_staves.py re-exports
  InterpolatedLine for backward-compatible imports.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
eval_page.py evaluates a single page run against ground-truth
annotations, reporting per-stave line-count accuracy and flagging
rhythm anomalies vs. expected stave structure.

eval_batch.py runs eval_page across an image-set directory and
aggregates results into a summary CSV, enabling cross-method and
cross-manuscript comparison once all five experiment runners are
complete.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
staff-finding/README.md gives an overview of the pipeline stages,
experiment variants, and output formats for new contributors.

.gitignore updated to cover Python cache dirs and experiment output
folders that should not be tracked.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…le test fix

Stage 1 (component_filter.py — already committed): loosen merge thresholds
(y-distance 0.5→1.0h, x-gap 5→10h) and add companion-score-floor retention
so high-scoring non-overlapping fragments are kept alongside the merge winner.
Diagnostics updated with cyan coloring for companions in panels 3–5.

Stage 2 (run_page.py): add centerline_page section to each JSOMR record with
page-absolute x_start/x_end/y_values (crop-local + x_page_offset/y_page_offset).
Old centerline key preserved for backwards compatibility.

Tests: fix stale assertion in test_grouping_with_missing_line_and_interpolation
— interpolation was implemented in 572c025 but the test still expected [].

E2E data:
- 28may_stave-fulldata/: Ordo Virtutum 8-page run (8.6MB, all files)
- 29may/: Gent right/left multi-variant run (JSON/CSV/TXT only, no PNGs)
- pitch_finding_sample/: Gent right, 86 lines, 17 staves — stafflines JSON,
  stave grouping PNGs, summary CSV, stave grouping report for pitch-finding
  collaborator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Iterate on centerline fitting, staff grouping, interpolation, and
component filtering logic across the scripts/ pipeline and the
experimental fitters (DP tracing, GP centerlines, implicit neural,
periodicity), plus add a new staffline detector training script.
Remove the superseded Gent left-crop inference outputs (regenerated
into left-inference/, kept local rather than tracked) and ignore the
new addtl-gt/ ground-truth dataset, which is too large to track in
git.
Relocate IMPLEMENTATION_AUDIT.md and IMPLEMENTATION_NOTES.md next to
the existing ADRs, and add pitch-finding notes and a status doc.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (15)
staff-finding/dox/PITCH_FINDING_NOTES.md-149-151 (1)

149-151: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Typo: "shunted over at they go" → "as they go".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/dox/PITCH_FINDING_NOTES.md` around lines 149 - 151, Correct the
typo in the note by replacing “at they go” with “as they go,” while preserving
the surrounding wording.

Source: Linters/SAST tools

staff-finding/dox/STATUS.md-31-40 (1)

31-40: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Internal contradiction: "all five implemented" vs heatmap_regression being design-doc only.

Line 31 claims all five runners emit JSOMR, and line 34 says all five are implemented, but line 40 says heatmap regression is not yet implemented. Suggest "four of five implemented".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/dox/STATUS.md` around lines 31 - 40, Update the
experiment-runner status wording to say four of five are implemented, while
preserving the heatmap_regression entry as design-doc only and leaving the four
implemented runner descriptions unchanged.
.gitignore-13-14 (1)

13-14: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

New ignore rules cover paths this PR actively tracks. .gitignore now ignores both .claude/ and staff-finding/e2e_tests, yet files under both are added or modified in this same PR. Git keeps already-tracked files tracked, so the rules take no effect today but will silently hide future changes in those paths — pick untrack-or-drop per path.

  • .gitignore#L13-L14: decide whether staff-finding/e2e_tests fixtures are intentional committed test data (drop the rule, or narrow it to the generated run_page*/ subtrees) and add a trailing / if a directory match is intended.
  • .claude/settings.local.json#L16-L28: git rm --cached this file so the .claude/ rule applies, since its absolute /Users/... and /opt/anaconda3/envs/kraken/... paths are machine-specific and unusable by other contributors.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore around lines 13 - 14, Update .gitignore so intentional
staff-finding/e2e_tests fixtures remain tracked, either remove that rule or
narrow it to generated run_page*/ subtrees and use a trailing slash for
directory matching. For .claude/settings.local.json, remove the file from Git’s
index with git rm --cached while retaining the .claude/ ignore rule; no direct
content change is needed there.
staff-finding/dox/STATUS.md-22-24 (1)

22-24: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale: interpolate_staves.py is no longer just a placeholder.

This PR ships interpolate_missing_lines() with Trigger A (in-stave gap fill), Trigger B (territory-bounded edge extrapolation), and a rhythm gate, and group_staves.py calls it when interpolate_missing=True. The accurate statement is that interpolation is implemented but off by default. Line 116 ("Interpolation stub is a no-op") and §1 at lines 49-54 carry the same stale claim. Since this doc is explicitly a handoff note, the mismatch is likely to send the next maintainer down the wrong path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/dox/STATUS.md` around lines 22 - 24, Update the STATUS.md
handoff note to state that interpolation is implemented, including its existing
triggers and rhythm gate, but disabled by default unless group_staves.py
receives interpolate_missing=True. Replace the stale “placeholder,” “stub is a
no-op,” and corresponding §1 claims throughout the document while preserving the
accurate implementation details.
.gitignore-19-22 (1)

19-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

*.pt on line 22 overrides the !models/**/*.pt exception.

Git applies the last matching pattern, so adding *.pt after the negation re-ignores everything under models/, defeating line 21. Put the broad pattern before the negation.

🔧 Proposed fix
 # Downloaded pretrained weights
 yolov8*.pt
-!models/**/*.pt
 *.pt
+!models/**/*.pt
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore around lines 19 - 22, Reorder the .gitignore patterns so the
broad *.pt ignore rule appears before the !models/**/*.pt exception, preserving
the exception for pretrained weights under models/ while ignoring other .pt
files.
staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/Ordo_virtutum-000_stafflines.json-2-27 (1)

2-27: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Regenerate this fixture to include centerline_page
staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/Ordo_virtutum-000_stafflines.json still stops at centerline, while staff-finding/scripts/run_page.py now writes a centerline_page block. Regenerating this fixture would make it a better regression anchor for the page-absolute coordinate path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/Ordo_virtutum-000_stafflines.json`
around lines 2 - 27, Regenerate the fixture represented by the stafflines JSON
so each detected staff line includes the `centerline_page` block produced by
`run_page.py`. Preserve the existing `centerline` data and ensure the
regenerated output reflects page-absolute coordinates for the regression case.
staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/Ordo_virtutum-000_model-predicted.txt-1-1 (1)

1-1: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use class 2 for stafflines here — this TXT and the sibling JSON are already aligned (class 2 / classId: 3); the problem is staff-finding/scripts/eval_page.py defaulting staffline_class to 0, which drops all GT boxes and leaves only the no GT stafflines warning unless --staffline_class 2 is passed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/Ordo_virtutum-000_model-predicted.txt`
at line 1, Update the default staffline class in eval_page.py to 2 so evaluation
uses the class represented by the TXT and sibling JSON; preserve the
--staffline_class override for callers needing a different class and ensure GT
staff boxes are no longer dropped by default.
staff-finding/experiments/README.md-89-113 (1)

89-113: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update documentation to reflect implemented pipeline stages.

The experiment index labels implicit-neural and periodicity as planned, while the notes describe interpolation as stubbed and deferred. The supplied PR context includes implemented runners and interpolation support, so these instructions misrepresent available functionality.

  • staff-finding/experiments/README.md#L89-L113: mark implemented implicit-neural and periodicity methods as implemented and document their runners.
  • staff-finding/experiments/implicit_neural/NOTES.md#L129-L142: replace the “currently stubbed” interpolation statement with the current implementation and usage details.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/README.md` around lines 89 - 113, Update
staff-finding/experiments/README.md lines 89-113 to mark implicit_neural and
periodicity as implemented, and document their available runners. Update
staff-finding/experiments/implicit_neural/NOTES.md lines 129-142 to replace the
stubbed/deferred interpolation description with the current implementation and
usage details.
staff-finding/experiments/dp_tracing/dp_tracer.py-75-89 (1)

75-89: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate fully out-of-bounds trace ranges.

x_start is not upper-clamped and x_end is not lower-clamped. An out-of-range detection can therefore return empty arrays here, then crash the runner when it accesses xs[0]. Clamp both endpoints to the image bounds and explicitly reject x_end < x_start.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/dp_tracing/dp_tracer.py` around lines 75 - 89, The
trace range validation must clamp both horizontal endpoints to image bounds and
reject inverted ranges before constructing results. Update the range handling
around x_start and x_end to lower-clamp x_start, upper-clamp x_end, and
explicitly handle x_end < x_start by returning empty arrays, while preserving
the existing band/column validation behavior.
staff-finding/dox/IMPLEMENTATION_NOTES.md-73-73 (1)

73-73: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Typo: "Downstrem" → "Downstream".

✏️ Proposed fix
-- **Downstrem robustness**: Robust fitting handles local asymmetries; grouping doesn't need to
+- **Downstream robustness**: Robust fitting handles local asymmetries; grouping doesn't need to
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/dox/IMPLEMENTATION_NOTES.md` at line 73, Correct the misspelled
“Downstrem” heading in the implementation notes to “Downstream,” leaving the
rest of the note unchanged.
staff-finding/experiments/periodicity/periodicity_detector.py-224-264 (1)

224-264: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Off-by-one in upper-tooth row bound; unused clamp variables.

r_up_hi subtracts an extra -1 compared to the mirrored lower-tooth formula (page_h - 1 - y_lo - offset_px), excluding the last valid band row from the upper-tooth comb contribution near the page boundary. Also, y_up_lo_c/y_up_hi_c are computed but never used.

🐛 Proposed fix
         # Upper tooth: y_lo+r - offset_px
-        y_up_lo = y_lo - offset_px
-        y_up_hi = y_hi - offset_px
-        y_up_lo_c = max(0, y_up_lo)
-        y_up_hi_c = min(page_h - 1, y_up_hi)
-
         # Which band rows have a valid upper tooth?
         # Band row r maps to page row y_lo + r.
         # Upper tooth page row: y_lo + r - offset_px.
         # Valid when 0 <= y_lo + r - offset_px <= page_h - 1
         #       i.e. offset_px <= r + y_lo  and  r + y_lo - offset_px <= page_h - 1
         r_up_lo = max(0, offset_px - y_lo)  # first band row with valid upper tooth
-        r_up_hi = min(n_band - 1, page_h - 1 - y_lo + offset_px - 1)
+        r_up_hi = min(n_band - 1, page_h - 1 - y_lo + offset_px)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/periodicity/periodicity_detector.py` around lines
224 - 264, Update the upper-tooth bounds in the periodicity comb loop by
removing the extra subtraction from r_up_hi so the final valid band row is
included, matching the lower-tooth boundary logic. Remove the unused y_up_lo_c
and y_up_hi_c clamp variables, while preserving the existing slice and validity
checks.
staff-finding/experiments/shared_utils.py-105-113 (1)

105-113: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

write_jsomr never applies x_page_offset/y_page_offset.

The ExperimentFitResult docstring promises page-absolute coordinates are obtained by adding the offsets to x_start/x_end/y_values, but write_jsomr writes these fields verbatim. Currently harmless because every caller pre-computes page-absolute values and zeroes the offsets, but any future runner that populates crop-local coordinates with non-zero offsets (as the dataclass explicitly supports) will silently produce wrong page-absolute coordinates in the JSOMR output — the same class of bug this PR is fixing elsewhere in the pipeline.

🐛 Proposed fix
             "centerline": {
-                "x_start": fit.x_start,
-                "x_end": fit.x_end,
-                "y_values": [round(float(y), 1) for y in fit.y_values],
+                "x_start": fit.x_start + fit.x_page_offset,
+                "x_end": fit.x_end + fit.x_page_offset,
+                "y_values": [round(float(y) + fit.y_page_offset, 1) for y in fit.y_values],
             },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/shared_utils.py` around lines 105 - 113, Update
write_jsomr when constructing the centerline record to add fit.x_page_offset to
x_start and x_end, and fit.y_page_offset to every value in fit.y_values before
writing JSOMR output. Preserve the existing rounding behavior for the resulting
page-absolute y coordinates and leave unrelated bounding-box fields unchanged.
staff-finding/scripts/fit_centerline.py-238-249 (1)

238-249: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Refinement gate compares residuals over two different populations.

refined_residuals is measured over ~N window medians while abs_residuals is measured over every kept pixel; medians are structurally much closer to any curve, so the guard passes almost unconditionally and line_following_no_improvement is close to unreachable. It also means residual_mean/residual_max on the emitted FitResult (and the summary CSV / JSOMR) silently switch population when refinement fires, so QA can no longer compare them across boxes.

Consider scoring the refined coefficients on the in-band pixels (same set the trace was drawn from) and reporting residuals over that set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/fit_centerline.py` around lines 238 - 249, The
refinement gate in the fit-centerline flow must compare like-for-like residual
populations. In the refinement block, evaluate refined_coeffs against the same
kept in-band pixel coordinates used to compute abs_residuals, use that result
for the improvement check, and retain/report residuals from that pixel set when
refinement is accepted; keep trace_xs_out and trace_ys_out updates unchanged.
staff-finding/scripts/eval_batch.py-146-148 (1)

146-148: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

--staffline-class never overrides the manifest, contrary to its help text.

argparse always supplies a value, so a present manifest column always wins. Either reword the help or default the flag to None and treat a supplied flag as the override.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/eval_batch.py` around lines 146 - 148, Update the
argument handling for staffline_class so --staffline-class defaults to None,
allowing an explicitly supplied flag to override the manifest value while
preserving the manifest value when the flag is omitted. Adjust the conversion
logic around staffline_class and its argparse definition without changing
unrelated behavior.
staff-finding/scripts/group_staves.py-677-682 (1)

677-682: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Stroke colours are BGR triples drawn into an RGB canvas.

Line 677 now converts the page to RGB, but the polyline colours are still built with reversed(color_rgb). imshow(canvas_uint8) (and the RGB2BGR hq write) therefore render every stave line with R and B swapped, so the drawn lines no longer match the stave_colors legend patches. Drop the reversed(...) for both the detected and interpolated strokes.

🎨 Proposed fix (interpolated branch)
-        color_bgr = tuple(int(c * 255) for c in reversed(light_rgb))
+        color_rgb_255 = tuple(int(c * 255) for c in light_rgb)

Also applies to: 748-752

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/group_staves.py` around lines 677 - 682, Update the
detected and interpolated stroke-color construction in the relevant drawing
logic to pass each `color_rgb` directly, removing `reversed(...)`. Keep the RGB
canvas and existing legend colors unchanged so polylines match the
`stave_colors` patches in both branches.
🧹 Nitpick comments (12)
staff-finding/dox/PITCH_FINDING_NOTES.md (1)

103-103: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a language to the fenced block (markdownlint MD040).

text is fine for the ASCII pipeline diagram.

📝 Proposed fix
-```
+```text
 Staff-finding output (JSOMR)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/dox/PITCH_FINDING_NOTES.md` at line 103, Add the text language
identifier to the fenced code block containing the ASCII pipeline diagram in
PITCH_FINDING_NOTES.md, changing the opening fence to use text while preserving
the diagram content.

Source: Linters/SAST tools

staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/stave_grouping_report.txt (1)

247-266: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

19 of 111 fits (~17%) dropped with no_y_position_available.

Worth capturing this rate as an explicit metric in the report header rather than leaving it implicit in the list length — it's the single most useful number for tracking component-filter regressions across runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/stave_grouping_report.txt`
around lines 247 - 266, Update the stave grouping report header to explicitly
include the count and percentage of fits dropped with no_y_position_available,
using the existing unassigned-fit totals so it remains accurate across runs.
.gitignore (1)

24-28: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Repo-wide *.png/*.jpg ignore will also swallow documentation/diagnostic images.

If any README or dox/ assets are images, they'll need explicit ! exceptions. Worth confirming before merge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore around lines 24 - 28, Review the repository for README or dox/
image assets that should remain tracked, then add explicit negation entries in
.gitignore for those paths after the repo-wide *.png, *.jpg, and *.jpeg rules.
Keep unrelated image files ignored.
.claude/settings.local.json (1)

16-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Machine-specific local settings are being committed while .claude/ is simultaneously added to .gitignore.

These entries hard-code one developer's home directory and conda env paths, so they carry no value for other contributors and will drift. Since .gitignore line 15 now ignores .claude/, this file is tracked-but-ignored — future edits will silently stop being staged. Consider git rm --cached for this file so the ignore rule actually takes effect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/settings.local.json around lines 16 - 28, Remove the
machine-specific .claude/settings.local.json from version control using git rm
--cached, while retaining it locally so the existing .claude/ ignore rule
applies. Do not add these user-specific paths or command permissions back to
tracked configuration.
staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/Ordo_virtutum-000_stafflines.json (1)

14555-14560: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Centerlines extrapolate to negative y outside the crop.

line0050 reaches -0.3 and line0090 starts at -3.2, i.e. the quadratic fit is evaluated well past the pixel support it was fitted on (residual_mean 5.3 / 2.2, residual_max ~15). Not a defect in this artifact, but it suggests centerline sampling isn't clamped to the box's valid y-range, which will feed bad rows into the pitch grid. Worth clamping or flagging at fit time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/Ordo_virtutum-000_stafflines.json`
around lines 14555 - 14560, Clamp centerline sampling to the fitted box’s valid
y-range before generating pitch-grid rows, or flag fits that extrapolate beyond
that range. Update the centerline-fit/sampling logic associated with line0050
and line0090, preserving valid in-range samples while preventing negative-y
extrapolated rows from entering the pitch grid.
README.md (1)

337-340: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a language identifier to the manifest CSV fenced code block.

Static analysis flags this fence as missing a language hint.

📝 Proposed fix
-```
+```csv
 page_name,image,gt_txt,gt_source,pred_json,variant
 GentAnt1475_0017_AC_rightcrop,staff-finding/image-sets/gent/right/GentAnt1475_0017_AC_rightcrop.jpg,path/to/gt.txt,corrected_kyrie,path/to/stafflines.json,sauvola_no_bgr
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 337 - 340, Add the csv language identifier to the
fenced manifest example in the README while preserving its contents and
formatting.

Source: Linters/SAST tools

staff-finding/experiments/shared_utils.py (1)

197-254: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider zip(..., strict=True) for defensive length checking.

fit_results and boxes are always built together today, but strict=True would make a future length mismatch fail loudly instead of silently truncating.

♻️ Proposed fix
-    for idx, (fit, box) in enumerate(zip(fit_results, boxes)):
+    for idx, (fit, box) in enumerate(zip(fit_results, boxes, strict=True)):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/shared_utils.py` around lines 197 - 254, Update the
`write_jsomr` call in `run_grouping_and_save` to pair `fit_results` and `boxes`
with `zip(..., strict=True)` before passing them onward, so mismatched lengths
fail loudly instead of being silently truncated. Preserve the existing grouping
and output behavior.

Source: Linters/SAST tools

staff-finding/scripts/train_staffline_detector.py (1)

180-185: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Chain the import failure.

raise SystemExit(...) from None (or from exc) keeps the traceback honest and clears Ruff B904.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/train_staffline_detector.py` around lines 180 - 185,
Update the YOLO import error handling in the try/except block to explicitly
chain the SystemExit exception using an appropriate from clause, such as from
None or the caught ImportError, while preserving the existing installation
guidance.

Source: Linters/SAST tools

staff-finding/scripts/component_filter.py (2)

139-151: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

companion_labels field not documented in the class docstring.

The ComponentFilterResult docstring (Attributes section above) enumerates merged_cluster_labels but doesn't mention the new companion_labels field.

📝 Suggested docstring addition
         merged_cluster_labels: List of connected-component labels that were
             merged into the active cluster (when merge_components=True);
             empty list otherwise.
+        companion_labels: List of connected-component labels retained as
+            companions of the active winner/cluster (score above
+            COMPANION_SCORE_FLOOR, non-overlapping x-range).
     """
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/component_filter.py` around lines 139 - 151, Update the
ComponentFilterResult class docstring’s Attributes section to document the
companion_labels field alongside merged_cluster_labels, describing its purpose
consistently with the field definition.

330-403: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No dedicated test exercises the new companion-retention path.

None of the reviewed test files (c-filter_test.py, test_merge.py, test_merge_step.py) construct a scenario with a discarded "not_top_scoring" candidate scoring above COMPANION_SCORE_FLOOR with a non-overlapping x-range, so the companion feature added here (Lines 334-403) has no direct regression coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/component_filter.py` around lines 330 - 403, Add
focused regression coverage for the companion-retention logic in the code
handling `survivors[1:]`: construct a discarded candidate whose score meets
`COMPANION_SCORE_FLOOR` and whose x-range does not overlap the winner, then
verify it is included in the active coordinates, mask, and companion labels for
both merge and no-merge modes. Also cover the exclusion case for overlapping or
below-floor candidates.
staff-finding/scripts/script_tests/test_merge.py (1)

51-91: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

test_merge.py looks superseded by test_merge_step.py.

Both files define test_fragmented_line_merges/test_two_lines_do_not_merge against the same helper crops, but test_merge_step.py's versions additionally exercise the merge_components parameter and merged_cluster_labels added by this PR, while this file's versions don't. Keeping both risks drift (only one gets updated as the API evolves). Consider retiring this file in favour of test_merge_step.py, or clarifying why both are intentionally kept.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/script_tests/test_merge.py` around lines 51 - 91,
Retire the duplicate tests in test_merge.py and rely on test_merge_step.py,
whose test_fragmented_line_merges and test_two_lines_do_not_merge cases cover
the merge_components and merged_cluster_labels behavior. Remove the superseded
test definitions and any associated standalone execution path, unless there is a
documented distinct purpose that requires preserving them.
staff-finding/scripts/interpolate_staves.py (1)

252-266: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Latent index-misalignment risk between centers and fit_pairs_sorted in Trigger B.

centers is derived by mapping _y_at_center over fit_pairs_sorted and then filtering out None (Lines 253-254), but fit_pairs_sorted itself is never filtered. zip(centers, fit_pairs_sorted) at Line 308 then assumes positional correspondence between the two lists. If any entry in fit_pairs_sorted ever produced None from _y_at_center, the filtering would silently shift indices and pair the wrong center with the wrong fit — a hard-to-detect corruption of the slot-matching logic. Today this appears benign only because group_staves.py seems to gate stave assignment on the same "has y_values" condition, so no None should reach this point in practice — but that's an implicit cross-module invariant. Building (center, pair) tuples together and filtering jointly would remove the fragility.

🛡️ Suggested defensive fix
-        fit_pairs_sorted = sorted(fit_pairs, key=lambda p: _y_at_center(p[1]) or 0.0)
-        centers = [_y_at_center(f) for _, f in fit_pairs_sorted]
-        centers = [c for c in centers if c is not None]
+        fit_pairs_sorted = sorted(fit_pairs, key=lambda p: _y_at_center(p[1]) or 0.0)
+        paired = [(c, p) for p in fit_pairs_sorted if (c := _y_at_center(p[1])) is not None]
+        centers = [c for c, _ in paired]
+        fit_pairs_sorted = [p for _, p in paired]

Also applies to: 299-311

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/interpolate_staves.py` around lines 252 - 266, In the
Trigger B processing around fit_pairs_sorted and the later zip, preserve
center-to-fit alignment by computing each _y_at_center result together with its
corresponding fit pair and filtering out entries whose center is None from both
collections. Use the jointly filtered pairs to derive centers and for subsequent
slot matching, while preserving the existing empty-center handling and gap
estimation behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/stave_grouping_report.txt`:
- Around line 5-8: The Stage 6 line-count tally in group_staves.py must collapse
or discard adjacent staffline assignments whose gaps are below min_threshold
before computing the mode and staves_with_unexpected_count flags. Update the
staff-grouping flow so near-duplicate detections, including clustered six-line
assignments, count as one actual staffline while preserving distinct lines
separated by at least min_threshold.

In
`@staff-finding/e2e_tests/pitch_finding_sample/GentAnt1475_0017_AC_rightcrop_no_bgr/stave_grouping_report.txt`:
- Around line 5-8: Regenerate the stave_grouping_report fixture after fixing the
split logic so it represents a successful grouping result rather than fragmented
staves. Update the e2e validation to assert acceptable stave line counts,
ensuring the regenerated baseline no longer contains the current
unexpected-count flags or 1–2-line fragments.

In `@staff-finding/experiments/gp_centerlines/gp_fitter.py`:
- Around line 128-136: Replace the fabricated mean-y fallback in the GP fitting
function around gpr.fit with an explicit failed-fit result that preserves the
exception details and cannot be interpreted as a normal prediction. Update the
runner’s handling of this result to flag the fit as failed and exclude it from
stave grouping and exported predictions, rather than consuming its centreline
and uncertainty arrays.

In `@staff-finding/scripts/component_filter.py`:
- Around line 334-403: Add a vertical center-distance check to companion
retention in the survivor loop, using the existing merge y-threshold and the
winner/candidate y-center values, and require it alongside the current
non-overlapping x-range checks for both no-merge and merge companions. Preserve
the existing score and merged-winner exclusions.

In `@staff-finding/scripts/eval_batch.py`:
- Around line 85-93: Update the metric filtering in the aggregation loop around
metrics_to_agg to exclude parsed non-finite float values, including
float("nan"), before constructing the NumPy array. Preserve valid numeric values
and the existing skip behavior when no values remain, so mean, standard
deviation, minimum, and maximum are computed only from finite metrics.

In `@staff-finding/scripts/eval_page.py`:
- Around line 59-71: Update _pred_page_y and _pred_page_x to prefer the
serialized centerline_page coordinates when present, without applying
bounding_box offsets to those page-absolute values. Retain the existing
centerline plus bounding_box conversion only for legacy crop-local data when
that contract is guaranteed, so page coordinates are not offset twice.

In `@staff-finding/scripts/group_staves.py`:
- Around line 733-746: Update the interpolated-line construction in
interpolate_staves.py to store page-absolute x coordinates by adding each fit’s
x_page_offset to f.x_start and f.x_end, matching the existing page-absolute
y_values behavior. Preserve integer conversion and ensure the resulting
InterpolatedLine fields, _reindex_stave_lines comparisons, JSON export, and
overlay logic use the corrected coordinates.

In `@staff-finding/scripts/interpolate_staves.py`:
- Around line 106-136: The interpolated-line construction paths must convert
crop-local x coordinates to page-absolute coordinates. Locate both branches that
create InterpolatedLine, including the range using
above_f.x_start/below_f.x_start and the branch using ref_f.x_start, and add the
corresponding x_page_offset before passing x-range values; preserve the existing
y interpolation behavior.

In `@staff-finding/scripts/script_tests/test_run_pageOG.py`:
- Around line 15-17: Update the import setup in test_run_pageOG.py to resolve
staff-finding/scripts/run_pageOG.py from the test file’s __file__ location,
replacing the hard-coded /home/claude/run_page.py path. Ensure the loaded module
targets the OG driver while remaining valid across repository checkouts.

In `@staff-finding/scripts/train_staffline_detector.py`:
- Around line 97-110: Update remap_label_file to discard every label whose class
is not SOURCE_CLASS, and only write retained SOURCE_CLASS entries using
TARGET_CLASS. Preserve the existing handling of blank lines and label
coordinates so the generated single-class dataset contains no class-0 or class-1
boxes.

---

Minor comments:
In @.gitignore:
- Around line 13-14: Update .gitignore so intentional staff-finding/e2e_tests
fixtures remain tracked, either remove that rule or narrow it to generated
run_page*/ subtrees and use a trailing slash for directory matching. For
.claude/settings.local.json, remove the file from Git’s index with git rm
--cached while retaining the .claude/ ignore rule; no direct content change is
needed there.
- Around line 19-22: Reorder the .gitignore patterns so the broad *.pt ignore
rule appears before the !models/**/*.pt exception, preserving the exception for
pretrained weights under models/ while ignoring other .pt files.

In `@staff-finding/dox/IMPLEMENTATION_NOTES.md`:
- Line 73: Correct the misspelled “Downstrem” heading in the implementation
notes to “Downstream,” leaving the rest of the note unchanged.

In `@staff-finding/dox/PITCH_FINDING_NOTES.md`:
- Around line 149-151: Correct the typo in the note by replacing “at they go”
with “as they go,” while preserving the surrounding wording.

In `@staff-finding/dox/STATUS.md`:
- Around line 31-40: Update the experiment-runner status wording to say four of
five are implemented, while preserving the heatmap_regression entry as
design-doc only and leaving the four implemented runner descriptions unchanged.
- Around line 22-24: Update the STATUS.md handoff note to state that
interpolation is implemented, including its existing triggers and rhythm gate,
but disabled by default unless group_staves.py receives
interpolate_missing=True. Replace the stale “placeholder,” “stub is a no-op,”
and corresponding §1 claims throughout the document while preserving the
accurate implementation details.

In
`@staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/Ordo_virtutum-000_model-predicted.txt`:
- Line 1: Update the default staffline class in eval_page.py to 2 so evaluation
uses the class represented by the TXT and sibling JSON; preserve the
--staffline_class override for callers needing a different class and ensure GT
staff boxes are no longer dropped by default.

In
`@staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/Ordo_virtutum-000_stafflines.json`:
- Around line 2-27: Regenerate the fixture represented by the stafflines JSON so
each detected staff line includes the `centerline_page` block produced by
`run_page.py`. Preserve the existing `centerline` data and ensure the
regenerated output reflects page-absolute coordinates for the regression case.

In `@staff-finding/experiments/dp_tracing/dp_tracer.py`:
- Around line 75-89: The trace range validation must clamp both horizontal
endpoints to image bounds and reject inverted ranges before constructing
results. Update the range handling around x_start and x_end to lower-clamp
x_start, upper-clamp x_end, and explicitly handle x_end < x_start by returning
empty arrays, while preserving the existing band/column validation behavior.

In `@staff-finding/experiments/periodicity/periodicity_detector.py`:
- Around line 224-264: Update the upper-tooth bounds in the periodicity comb
loop by removing the extra subtraction from r_up_hi so the final valid band row
is included, matching the lower-tooth boundary logic. Remove the unused
y_up_lo_c and y_up_hi_c clamp variables, while preserving the existing slice and
validity checks.

In `@staff-finding/experiments/README.md`:
- Around line 89-113: Update staff-finding/experiments/README.md lines 89-113 to
mark implicit_neural and periodicity as implemented, and document their
available runners. Update staff-finding/experiments/implicit_neural/NOTES.md
lines 129-142 to replace the stubbed/deferred interpolation description with the
current implementation and usage details.

In `@staff-finding/experiments/shared_utils.py`:
- Around line 105-113: Update write_jsomr when constructing the centerline
record to add fit.x_page_offset to x_start and x_end, and fit.y_page_offset to
every value in fit.y_values before writing JSOMR output. Preserve the existing
rounding behavior for the resulting page-absolute y coordinates and leave
unrelated bounding-box fields unchanged.

In `@staff-finding/scripts/eval_batch.py`:
- Around line 146-148: Update the argument handling for staffline_class so
--staffline-class defaults to None, allowing an explicitly supplied flag to
override the manifest value while preserving the manifest value when the flag is
omitted. Adjust the conversion logic around staffline_class and its argparse
definition without changing unrelated behavior.

In `@staff-finding/scripts/fit_centerline.py`:
- Around line 238-249: The refinement gate in the fit-centerline flow must
compare like-for-like residual populations. In the refinement block, evaluate
refined_coeffs against the same kept in-band pixel coordinates used to compute
abs_residuals, use that result for the improvement check, and retain/report
residuals from that pixel set when refinement is accepted; keep trace_xs_out and
trace_ys_out updates unchanged.

In `@staff-finding/scripts/group_staves.py`:
- Around line 677-682: Update the detected and interpolated stroke-color
construction in the relevant drawing logic to pass each `color_rgb` directly,
removing `reversed(...)`. Keep the RGB canvas and existing legend colors
unchanged so polylines match the `stave_colors` patches in both branches.

---

Nitpick comments:
In @.claude/settings.local.json:
- Around line 16-28: Remove the machine-specific .claude/settings.local.json
from version control using git rm --cached, while retaining it locally so the
existing .claude/ ignore rule applies. Do not add these user-specific paths or
command permissions back to tracked configuration.

In @.gitignore:
- Around line 24-28: Review the repository for README or dox/ image assets that
should remain tracked, then add explicit negation entries in .gitignore for
those paths after the repo-wide *.png, *.jpg, and *.jpeg rules. Keep unrelated
image files ignored.

In `@README.md`:
- Around line 337-340: Add the csv language identifier to the fenced manifest
example in the README while preserving its contents and formatting.

In `@staff-finding/dox/PITCH_FINDING_NOTES.md`:
- Line 103: Add the text language identifier to the fenced code block containing
the ASCII pipeline diagram in PITCH_FINDING_NOTES.md, changing the opening fence
to use text while preserving the diagram content.

In
`@staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/Ordo_virtutum-000_stafflines.json`:
- Around line 14555-14560: Clamp centerline sampling to the fitted box’s valid
y-range before generating pitch-grid rows, or flag fits that extrapolate beyond
that range. Update the centerline-fit/sampling logic associated with line0050
and line0090, preserving valid in-range samples while preventing negative-y
extrapolated rows from entering the pitch grid.

In
`@staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/stave_grouping_report.txt`:
- Around line 247-266: Update the stave grouping report header to explicitly
include the count and percentage of fits dropped with no_y_position_available,
using the existing unassigned-fit totals so it remains accurate across runs.

In `@staff-finding/experiments/shared_utils.py`:
- Around line 197-254: Update the `write_jsomr` call in `run_grouping_and_save`
to pair `fit_results` and `boxes` with `zip(..., strict=True)` before passing
them onward, so mismatched lengths fail loudly instead of being silently
truncated. Preserve the existing grouping and output behavior.

In `@staff-finding/scripts/component_filter.py`:
- Around line 139-151: Update the ComponentFilterResult class docstring’s
Attributes section to document the companion_labels field alongside
merged_cluster_labels, describing its purpose consistently with the field
definition.
- Around line 330-403: Add focused regression coverage for the
companion-retention logic in the code handling `survivors[1:]`: construct a
discarded candidate whose score meets `COMPANION_SCORE_FLOOR` and whose x-range
does not overlap the winner, then verify it is included in the active
coordinates, mask, and companion labels for both merge and no-merge modes. Also
cover the exclusion case for overlapping or below-floor candidates.

In `@staff-finding/scripts/interpolate_staves.py`:
- Around line 252-266: In the Trigger B processing around fit_pairs_sorted and
the later zip, preserve center-to-fit alignment by computing each _y_at_center
result together with its corresponding fit pair and filtering out entries whose
center is None from both collections. Use the jointly filtered pairs to derive
centers and for subsequent slot matching, while preserving the existing
empty-center handling and gap estimation behavior.

In `@staff-finding/scripts/script_tests/test_merge.py`:
- Around line 51-91: Retire the duplicate tests in test_merge.py and rely on
test_merge_step.py, whose test_fragmented_line_merges and
test_two_lines_do_not_merge cases cover the merge_components and
merged_cluster_labels behavior. Remove the superseded test definitions and any
associated standalone execution path, unless there is a documented distinct
purpose that requires preserving them.

In `@staff-finding/scripts/train_staffline_detector.py`:
- Around line 180-185: Update the YOLO import error handling in the try/except
block to explicitly chain the SystemExit exception using an appropriate from
clause, such as from None or the caught ImportError, while preserving the
existing installation guidance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment on lines +5 to +8
Mode lines per stave: 2
Line count distribution: {2: 9, 3: 3, 4: 2, 1: 1, 6: 4, 8: 1, 7: 2, 5: 2}
Cut threshold (px): 17.0
Flags: mode_count_below_typical, staves_with_unexpected_count:2,5,7,8,10,11,12,15,16,22,23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C5 'MIN_GAP_MULTIPLIER|def _determine_cut_threshold|def _find_valley_threshold' staff-finding/scripts/group_staves.py

Repository: DDMAL/mothra

Length of output: 2661


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant thresholding and grouping logic.
sed -n '220,320p' staff-finding/scripts/group_staves.py
printf '\n---\n'
sed -n '430,520p' staff-finding/scripts/group_staves.py
printf '\n---\n'
sed -n '520,700p' staff-finding/scripts/group_staves.py

Repository: DDMAL/mothra

Length of output: 14866


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the fixture report around the cited lines and surrounding diagnostics.
sed -n '1,120p' staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/stave_grouping_report.txt

Repository: DDMAL/mothra

Length of output: 3126


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C4 'duplicate|dedup|count.*stave|line count|unexpected_count|interpolate|normal|valley_threshold|MIN_GAP_MULTIPLIER|cut_threshold' staff-finding/scripts/group_staves.py

Repository: DDMAL/mothra

Length of output: 20012


Collapse near-duplicate stafflines before Stage 6 in staff-finding/scripts/group_staves.py

The 0.0–0.2 px gaps here and the 6-line cluster in Stave 7 are duplicate detections, not distinct stafflines. Stage 6 still counts every raw assignment, so those duplicates pull the page mode down to 2 and trigger staves_with_unexpected_count. Merge or drop gaps below min_threshold before the line-count tally so the mode and rhythm flags reflect actual stafflines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/stave_grouping_report.txt`
around lines 5 - 8, The Stage 6 line-count tally in group_staves.py must
collapse or discard adjacent staffline assignments whose gaps are below
min_threshold before computing the mode and staves_with_unexpected_count flags.
Update the staff-grouping flow so near-duplicate detections, including clustered
six-line assignments, count as one actual staffline while preserving distinct
lines separated by at least min_threshold.

Comment on lines +5 to +8
Mode lines per stave: 8
Line count distribution: {6: 2, 8: 5, 9: 1, 1: 3, 2: 1, 4: 3, 5: 1, 3: 1}
Cut threshold (px): 15.0
Flags: staves_with_unexpected_count:0,5,6,8,9,10,11,13,14,15,16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not retain fragmented grouping as the e2e baseline.

The reported mode is eight lines, but 11 of 17 staves are flagged as unexpected and several contain only 1–2 fits. This fixture records a failed grouping result rather than validating the grouping fix. Regenerate it after correcting the split logic and assert acceptable stave counts in the e2e check.

Also applies to: 150-219

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@staff-finding/e2e_tests/pitch_finding_sample/GentAnt1475_0017_AC_rightcrop_no_bgr/stave_grouping_report.txt`
around lines 5 - 8, Regenerate the stave_grouping_report fixture after fixing
the split logic so it represents a successful grouping result rather than
fragmented staves. Update the e2e validation to assert acceptable stave line
counts, ensuring the regenerated baseline no longer contains the current
unexpected-count flags or 1–2-line fragments.

Comment on lines +128 to +136
try:
gpr.fit(obs_x, obs_y)
except Exception as exc:
n = x_query_end - x_query_start + 1
return (
list(np.full(n, float(np.mean(obs_y)))),
list(np.full(n, float("inf"))),
{"error": str(exc)},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not substitute a fabricated centreline on GP failure.

This fallback returns a flat mean-y fit, and the runner treats it as a normal result. A numerical or configuration failure therefore silently pollutes stave grouping and exported predictions. Return an explicit failed-fit result and have the runner flag and exclude it from grouping.

🧰 Tools
🪛 Ruff (0.15.21)

[warning] 130-130: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/gp_centerlines/gp_fitter.py` around lines 128 -
136, Replace the fabricated mean-y fallback in the GP fitting function around
gpr.fit with an explicit failed-fit result that preserves the exception details
and cannot be interpreted as a normal prediction. Update the runner’s handling
of this result to flag the fit as failed and exclude it from stave grouping and
exported predictions, rather than consuming its centreline and uncertainty
arrays.

Comment on lines +334 to +403
# --- Companion retention ---
# Scan "not_top_scoring" losers. Keep any that:
# (a) score >= COMPANION_SCORE_FLOOR, and
# (b) have non-overlapping x-range with the active winner
# (horizontal continuation, not a parallel duplicate / adjacent line).
# Companion pixels are added to both the no-merge and merge coord/mask sets
# so the downstream fit sees the full staffline extent.

no_merge_win_x_start = winner_stats["x"]
no_merge_win_x_end = winner_stats["x"] + winner_stats["w"]

if merged_scored:
m_stats_top = merged_scored[0][3] # (cluster, score, sub_scores, stats)
merge_win_x_start = m_stats_top["x"]
merge_win_x_end = m_stats_top["x"] + m_stats_top["w"]
else:
merge_win_x_start = no_merge_win_x_start
merge_win_x_end = no_merge_win_x_end

merged_winner_set = set(int(lbl) for lbl in merged_winner_labels)

no_merge_companion_labels: list[int] = []
no_merge_companion_coords: list[tuple[int, int]] = []
no_merge_companion_mask = np.zeros_like(labels, dtype=bool)

merge_companion_labels: list[int] = []
merge_companion_coords: list[tuple[int, int]] = []
merge_companion_mask = np.zeros_like(labels, dtype=bool)

for s in survivors[1:]:
s_label, s_score, _s_sub, s_stats = s
if s_score < COMPANION_SCORE_FLOOR:
continue
comp_x = s_stats["x"]
comp_w = s_stats["w"]
comp_mask = labels == s_label
c_ys, c_xs = np.where(comp_mask)
comp_pixels = list(zip(c_xs.tolist(), c_ys.tolist()))

# No-merge companion: non-overlapping with no-merge winner bbox.
no_overlap_no_merge = (comp_x + comp_w <= no_merge_win_x_start) or (
comp_x >= no_merge_win_x_end
)
if no_overlap_no_merge:
no_merge_companion_labels.append(int(s_label))
no_merge_companion_mask |= comp_mask
no_merge_companion_coords.extend(comp_pixels)

# Merge companion: must not already be in merged winner; non-overlapping
# with merged winner's combined bbox.
if int(s_label) not in merged_winner_set:
no_overlap_merge = (comp_x + comp_w <= merge_win_x_start) or (
comp_x >= merge_win_x_end
)
if no_overlap_merge:
merge_companion_labels.append(int(s_label))
merge_companion_mask |= comp_mask
merge_companion_coords.extend(comp_pixels)

# --- Choose active outputs based on the mode ---
if merge_components:
active_coords = merged_coords
active_mask = merged_mask
active_coords = merged_coords + merge_companion_coords
active_mask = merged_mask | merge_companion_mask
active_flags = merge_flags
active_companion_labels = merge_companion_labels
else:
active_coords = no_merge_coords
active_mask = no_merge_mask
active_coords = no_merge_coords + no_merge_companion_coords
active_mask = no_merge_mask | no_merge_companion_mask
active_flags = no_merge_flags
active_companion_labels = no_merge_companion_labels

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Companion retention has no vertical-proximity check, unlike merge clustering.

_compute_merge_groups() requires both y-center proximity and x-gap to merge two components (Lines 592-604). The new companion-retention logic (Lines 363-391) only checks that a candidate's x-range doesn't overlap the winner's — there's no check that the candidate is vertically close to the winner. The docstring/comment (Lines 335-341) claims this constraint rules out "a parallel duplicate or adjacent staffline fragment," but non-overlapping x-range alone doesn't guarantee that: a fragment of a genuinely different (adjacent) staffline that happens to sit outside the winner's x-range could score above COMPANION_SCORE_FLOOR (0.25) and get folded into coords/mask, contaminating the pixel set that fit_centerline consumes.

Given the same file already documents the page-arch/adjacent-staffline risk extensively for the merge thresholds, consider mirroring that same y-center distance guard here.

🛡️ Suggested guard using the existing y-threshold
+    y_threshold = MERGE_Y_CENTER_DISTANCE_MULTIPLIER * scale_unit
+    winner_y_center = winner_stats["y"] + winner_stats["h"] / 2.0
+
     for s in survivors[1:]:
         s_label, s_score, _s_sub, s_stats = s
         if s_score < COMPANION_SCORE_FLOOR:
             continue
+        comp_y_center = s_stats["y"] + s_stats["h"] / 2.0
+        if abs(comp_y_center - winner_y_center) > y_threshold:
+            continue
         comp_x = s_stats["x"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# --- Companion retention ---
# Scan "not_top_scoring" losers. Keep any that:
# (a) score >= COMPANION_SCORE_FLOOR, and
# (b) have non-overlapping x-range with the active winner
# (horizontal continuation, not a parallel duplicate / adjacent line).
# Companion pixels are added to both the no-merge and merge coord/mask sets
# so the downstream fit sees the full staffline extent.
no_merge_win_x_start = winner_stats["x"]
no_merge_win_x_end = winner_stats["x"] + winner_stats["w"]
if merged_scored:
m_stats_top = merged_scored[0][3] # (cluster, score, sub_scores, stats)
merge_win_x_start = m_stats_top["x"]
merge_win_x_end = m_stats_top["x"] + m_stats_top["w"]
else:
merge_win_x_start = no_merge_win_x_start
merge_win_x_end = no_merge_win_x_end
merged_winner_set = set(int(lbl) for lbl in merged_winner_labels)
no_merge_companion_labels: list[int] = []
no_merge_companion_coords: list[tuple[int, int]] = []
no_merge_companion_mask = np.zeros_like(labels, dtype=bool)
merge_companion_labels: list[int] = []
merge_companion_coords: list[tuple[int, int]] = []
merge_companion_mask = np.zeros_like(labels, dtype=bool)
for s in survivors[1:]:
s_label, s_score, _s_sub, s_stats = s
if s_score < COMPANION_SCORE_FLOOR:
continue
comp_x = s_stats["x"]
comp_w = s_stats["w"]
comp_mask = labels == s_label
c_ys, c_xs = np.where(comp_mask)
comp_pixels = list(zip(c_xs.tolist(), c_ys.tolist()))
# No-merge companion: non-overlapping with no-merge winner bbox.
no_overlap_no_merge = (comp_x + comp_w <= no_merge_win_x_start) or (
comp_x >= no_merge_win_x_end
)
if no_overlap_no_merge:
no_merge_companion_labels.append(int(s_label))
no_merge_companion_mask |= comp_mask
no_merge_companion_coords.extend(comp_pixels)
# Merge companion: must not already be in merged winner; non-overlapping
# with merged winner's combined bbox.
if int(s_label) not in merged_winner_set:
no_overlap_merge = (comp_x + comp_w <= merge_win_x_start) or (
comp_x >= merge_win_x_end
)
if no_overlap_merge:
merge_companion_labels.append(int(s_label))
merge_companion_mask |= comp_mask
merge_companion_coords.extend(comp_pixels)
# --- Choose active outputs based on the mode ---
if merge_components:
active_coords = merged_coords
active_mask = merged_mask
active_coords = merged_coords + merge_companion_coords
active_mask = merged_mask | merge_companion_mask
active_flags = merge_flags
active_companion_labels = merge_companion_labels
else:
active_coords = no_merge_coords
active_mask = no_merge_mask
active_coords = no_merge_coords + no_merge_companion_coords
active_mask = no_merge_mask | no_merge_companion_mask
active_flags = no_merge_flags
active_companion_labels = no_merge_companion_labels
# --- Companion retention ---
# Scan "not_top_scoring" losers. Keep any that:
# (a) score >= COMPANION_SCORE_FLOOR, and
# (b) have non-overlapping x-range with the active winner
# (horizontal continuation, not a parallel duplicate / adjacent line).
# Companion pixels are added to both the no-merge and merge coord/mask sets
# so the downstream fit sees the full staffline extent.
no_merge_win_x_start = winner_stats["x"]
no_merge_win_x_end = winner_stats["x"] + winner_stats["w"]
if merged_scored:
m_stats_top = merged_scored[0][3] # (cluster, score, sub_scores, stats)
merge_win_x_start = m_stats_top["x"]
merge_win_x_end = m_stats_top["x"] + m_stats_top["w"]
else:
merge_win_x_start = no_merge_win_x_start
merge_win_x_end = no_merge_win_x_end
merged_winner_set = set(int(lbl) for lbl in merged_winner_labels)
no_merge_companion_labels: list[int] = []
no_merge_companion_coords: list[tuple[int, int]] = []
no_merge_companion_mask = np.zeros_like(labels, dtype=bool)
merge_companion_labels: list[int] = []
merge_companion_coords: list[tuple[int, int]] = []
merge_companion_mask = np.zeros_like(labels, dtype=bool)
y_threshold = MERGE_Y_CENTER_DISTANCE_MULTIPLIER * scale_unit
winner_y_center = winner_stats["y"] + winner_stats["h"] / 2.0
for s in survivors[1:]:
s_label, s_score, _s_sub, s_stats = s
if s_score < COMPANION_SCORE_FLOOR:
continue
comp_y_center = s_stats["y"] + s_stats["h"] / 2.0
if abs(comp_y_center - winner_y_center) > y_threshold:
continue
comp_x = s_stats["x"]
comp_w = s_stats["w"]
comp_mask = labels == s_label
c_ys, c_xs = np.where(comp_mask)
comp_pixels = list(zip(c_xs.tolist(), c_ys.tolist()))
# No-merge companion: non-overlapping with no-merge winner bbox.
no_overlap_no_merge = (comp_x + comp_w <= no_merge_win_x_start) or (
comp_x >= no_merge_win_x_end
)
if no_overlap_no_merge:
no_merge_companion_labels.append(int(s_label))
no_merge_companion_mask |= comp_mask
no_merge_companion_coords.extend(comp_pixels)
# Merge companion: must not already be in merged winner; non-overlapping
# with merged winner's combined bbox.
if int(s_label) not in merged_winner_set:
no_overlap_merge = (comp_x + comp_w <= merge_win_x_start) or (
comp_x >= merge_win_x_end
)
if no_overlap_merge:
merge_companion_labels.append(int(s_label))
merge_companion_mask |= comp_mask
merge_companion_coords.extend(comp_pixels)
# --- Choose active outputs based on the mode ---
if merge_components:
active_coords = merged_coords + merge_companion_coords
active_mask = merged_mask | merge_companion_mask
active_flags = merge_flags
active_companion_labels = merge_companion_labels
else:
active_coords = no_merge_coords + no_merge_companion_coords
active_mask = no_merge_mask | no_merge_companion_mask
active_flags = no_merge_flags
active_companion_labels = no_merge_companion_labels
🧰 Tools
🪛 Ruff (0.15.21)

[warning] 371-371: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/component_filter.py` around lines 334 - 403, Add a
vertical center-distance check to companion retention in the survivor loop,
using the existing merge y-threshold and the winner/candidate y-center values,
and require it alongside the current non-overlapping x-range checks for both
no-merge and merge companions. Preserve the existing score and merged-winner
exclusions.

Comment on lines +85 to +93
for m in metrics_to_agg:
vals = [float(r[m]) for r in rows if r[m] not in ("", "nan")]
if not vals:
continue
a = np.array(vals)
print(
f" {m:<20s} {a.mean():.4f} ± {a.std():.4f}"
f" [min {a.min():.4f} max {a.max():.4f}]"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

NaN metrics are not filtered out, so aggregates collapse to NaN.

evaluate() returns real floats, and split_ratio / mean_y_mae_px can be float("nan"). float("nan") not in ("", "nan") is True, so a single page without matches poisons the mean/std/min/max for that whole variant.

🐛 Proposed fix
-            vals = [float(r[m]) for r in rows if r[m] not in ("", "nan")]
+            vals = [
+                v
+                for v in (
+                    float(r[m]) for r in rows if r[m] not in ("", None, "nan")
+                )
+                if not np.isnan(v)
+            ]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for m in metrics_to_agg:
vals = [float(r[m]) for r in rows if r[m] not in ("", "nan")]
if not vals:
continue
a = np.array(vals)
print(
f" {m:<20s} {a.mean():.4f} ± {a.std():.4f}"
f" [min {a.min():.4f} max {a.max():.4f}]"
)
for m in metrics_to_agg:
vals = [
v
for v in (
float(r[m]) for r in rows if r[m] not in ("", None, "nan")
)
if not np.isnan(v)
]
if not vals:
continue
a = np.array(vals)
print(
f" {m:<20s} {a.mean():.4f} ± {a.std():.4f}"
f" [min {a.min():.4f} max {a.max():.4f}]"
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/eval_batch.py` around lines 85 - 93, Update the metric
filtering in the aggregation loop around metrics_to_agg to exclude parsed
non-finite float values, including float("nan"), before constructing the NumPy
array. Preserve valid numeric values and the existing skip behavior when no
values remain, so mean, standard deviation, minimum, and maximum are computed
only from finite metrics.

Comment on lines +59 to +71
def _pred_page_y(item: dict) -> np.ndarray:
"""Page-absolute y-values for a JSOMR staffline item."""
return (
np.array(item["centerline"]["y_values"], dtype=float)
+ item["bounding_box"]["uly"]
)


def _pred_page_x(item: dict) -> np.ndarray:
"""Page-absolute x positions corresponding to each y_value."""
x_start = item["centerline"]["x_start"]
n = len(item["centerline"]["y_values"])
return np.arange(x_start, x_start + n) + item["bounding_box"]["ulx"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Which writers emit centerline / centerline_page, and what do they put in bounding_box?
rg -nP -C4 '"centerline(_page)?"\s*:' --type=py
rg -nP -C6 'ExperimentFitResult\(' staff-finding/experiments --type=py | head -80
fd -t f 'shared_utils.py' staff-finding --exec rg -nP -C5 '"bounding_box"|json\.dump' {}

Repository: DDMAL/mothra

Length of output: 150


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files of interest =="
git ls-files 'staff-finding/**' | rg '(^staff-finding/scripts/eval_page.py$|^staff-finding/.*run_page.py$|^staff-finding/.*shared_utils.py$|^staff-finding/experiments/)'

echo
echo "== outline eval_page.py =="
ast-grep outline staff-finding/scripts/eval_page.py --view expanded || true

echo
echo "== relevant slices in eval_page.py =="
sed -n '1,220p' staff-finding/scripts/eval_page.py

echo
echo "== search for centerline_page and ExperimentFitResult =="
rg -n 'centerline_page|ExperimentFitResult|bounding_box|y_page_offset|x_page_offset' staff-finding -S

Repository: DDMAL/mothra

Length of output: 16099


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files:\n'
git ls-files 'staff-finding/**' | rg 'eval_page.py|run_page.py|shared_utils.py|experiments/.+\.py'

printf '\n== eval_page.py around lines 1-120 ==\n'
sed -n '1,120p' staff-finding/scripts/eval_page.py

printf '\n== run_page.py references ==\n'
rg -n 'centerline_page|bounding_box|y_page_offset|x_page_offset|page-absolute|x_start|y_values' staff-finding/scripts/run_page.py

printf '\n== experiment writer references ==\n'
rg -n 'ExperimentFitResult|bounding_box|centerline|y_page_offset|x_page_offset' staff-finding/experiments -S

Repository: DDMAL/mothra

Length of output: 12397


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== shared_utils.py relevant slices =="
sed -n '1,280p' staff-finding/experiments/shared_utils.py

echo
echo "== gp_centerlines runner slice =="
sed -n '130,210p' staff-finding/experiments/gp_centerlines/run_gp_page.py

echo
echo "== dp_tracing runner slice =="
sed -n '80,120p' staff-finding/experiments/dp_tracing/run_dp_page.py

echo
echo "== periodicity runner slice =="
sed -n '160,190p' staff-finding/experiments/periodicity/run_periodicity_page.py

echo
echo "== implicit_neural runner slice =="
sed -n '160,190p' staff-finding/experiments/implicit_neural/run_implicit_neural_page.py

Repository: DDMAL/mothra

Length of output: 15569


Teach the evaluator which coordinate space it’s reading

centerline + bounding_box only works for crop-local fits. The experiment writers serialize page-absolute y_values into the same shape, so bounding_box.uly gets added twice and the page metrics drift. Prefer centerline_page when it exists, and treat the legacy fields as crop-local only when the writer guarantees that contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/eval_page.py` around lines 59 - 71, Update _pred_page_y
and _pred_page_x to prefer the serialized centerline_page coordinates when
present, without applying bounding_box offsets to those page-absolute values.
Retain the existing centerline plus bounding_box conversion only for legacy
crop-local data when that contract is guaranteed, so page coordinates are not
offset twice.

Comment on lines +733 to +746
# --- Overlay interpolated lines (dashed, same stave color but lighter) ---
for interp in result.interpolated_lines:
if interp.stave_id not in stave_colors:
continue
if not interp.y_values:
continue

xs = np.arange(interp.x_start, interp.x_end + 1)
ys = np.array(interp.y_values)
mask = (xs >= 0) & (xs < canvas_w) & (ys >= 0) & (ys < canvas_h)
xs = xs[mask].astype(np.int32)
ys = ys[mask].astype(np.int32)
if len(xs) < 2:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Interpolated overlays mix crop-local x with page-absolute y.

interp.y_values are page-absolute (built as ref_f.y_page_offset + y in interpolate_staves.py), but interp.x_start/x_end are copied straight from FitResult.x_start/x_end, which are crop-local — no x_page_offset is ever applied, here or upstream. On any page whose crops don't start at x=0 the dashed lines are drawn at the wrong x, and the xs < canvas_w mask silently drops them once the offset is large.

The clean fix belongs upstream in interpolate_staves.py: store x_start/x_end as int(f.x_page_offset + f.x_start) / int(f.x_page_offset + f.x_end) so the InterpolatedLine docstring ("page-absolute") actually holds, which also makes the _reindex_stave_lines y-comparison and any JSON export consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/group_staves.py` around lines 733 - 746, Update the
interpolated-line construction in interpolate_staves.py to store page-absolute x
coordinates by adding each fit’s x_page_offset to f.x_start and f.x_end,
matching the existing page-absolute y_values behavior. Preserve integer
conversion and ensure the resulting InterpolatedLine fields,
_reindex_stave_lines comparisons, JSON export, and overlay logic use the
corrected coordinates.

Comment on lines +106 to +136
def _interpolate_between(
above_fit: FitResult,
below_fit: FitResult,
x_start: int,
x_end: int,
y_target: float,
) -> list[float]:
"""Synthesise y-values for a missing line between two detected neighbours.

At each x, reads the y-value from the line above and below (clamping to
their endpoints if x falls outside their range), then linearly interpolates
to y_target based on its fractional position between the two neighbours.

Returns page-absolute y-values (y_page_offset already included).
"""

def y_at_x(fit: FitResult, x: int) -> float:
idx = x - fit.x_start
idx = max(0, min(idx, len(fit.y_values) - 1))
return fit.y_page_offset + fit.y_values[idx]

ys: list[float] = []
for x in range(x_start, x_end + 1):
ya = y_at_x(above_fit, x)
yb = y_at_x(below_fit, x)
if yb > ya:
t = max(0.0, min(1.0, (y_target - ya) / (yb - ya)))
ys.append(ya + t * (yb - ya))
else:
ys.append((ya + yb) / 2.0) # degenerate: lines crossed or identical
return ys

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether FitResult.x_start/x_end are crop-local or page-absolute,
# and how x_page_offset is intended to be applied downstream.
rg -n -B2 -A15 'class FitResult' staff-finding/scripts/fit_centerline.py
rg -n 'x_page_offset|x_start\s*=|x_end\s*=' staff-finding/scripts/fit_centerline.py staff-finding/scripts/run_page.py staff-finding/scripts/group_staves.py

Repository: DDMAL/mothra

Length of output: 2418


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the interpolation logic around the cited lines.
sed -n '240,360p' staff-finding/scripts/interpolate_staves.py | cat -n

# Also inspect the FitResult definition and any helper that uses x_page_offset nearby.
sed -n '78,130p' staff-finding/scripts/fit_centerline.py | cat -n

Repository: DDMAL/mothra

Length of output: 8567


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the InterpolatedLine definition and downstream consumers.
sed -n '1,120p' staff-finding/scripts/interpolate_staves.py | cat -n
rg -n 'InterpolatedLine|x_page_offset|x_start|x_end' staff-finding/scripts -g '!*.pyc'

Repository: DDMAL/mothra

Length of output: 15842


Add x_page_offset when emitting InterpolatedLine x-ranges

FitResult.x_start/x_end are crop-local, but InterpolatedLine is treated as page-absolute. Both branches here pass raw crop-local x values into InterpolatedLine (min(above_f.x_start, below_f.x_start) / ref_f.x_start), so interpolated lines from crops with different x_page_offsets will be shifted. Add the offset before constructing the synthesised line.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/interpolate_staves.py` around lines 106 - 136, The
interpolated-line construction paths must convert crop-local x coordinates to
page-absolute coordinates. Locate both branches that create InterpolatedLine,
including the range using above_f.x_start/below_f.x_start and the branch using
ref_f.x_start, and add the corresponding x_page_offset before passing x-range
values; preserve the existing y interpolation behavior.

Comment on lines +15 to +17
spec = importlib.util.spec_from_file_location(
"run_page_partial", "/home/claude/run_page.py"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Load run_pageOG.py through a repository-relative path.

Line 16 hard-codes a local path to run_page.py; this makes the test fail on other checkouts and means test_run_pageOG.py does not exercise the OG driver. Resolve staff-finding/scripts/run_pageOG.py relative to __file__ instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/script_tests/test_run_pageOG.py` around lines 15 - 17,
Update the import setup in test_run_pageOG.py to resolve
staff-finding/scripts/run_pageOG.py from the test file’s __file__ location,
replacing the hard-coded /home/claude/run_page.py path. Ensure the loaded module
targets the OG driver while remaining valid across repository checkouts.

Comment on lines +97 to +110
def remap_label_file(src: Path, dst: Path) -> None:
"""Copy a YOLO .txt label, remapping SOURCE_CLASS → TARGET_CLASS."""
lines: list[str] = []
with src.open() as fh:
for raw in fh:
parts = raw.strip().split()
if not parts:
continue
cls = int(parts[0])
if cls == SOURCE_CLASS:
cls = TARGET_CLASS
lines.append(" ".join([str(cls)] + parts[1:]))
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text("\n".join(lines) + "\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Non-staffline classes are copied into a single-class dataset.

Only class 2 is remapped; lines with any other class id are written through unchanged. Since the source TXTs come from the multi-class model (run_inference.py maps ids 0/1/2), the produced labels contain original class-0 boxes now indistinguishable from stafflines, plus class-1 boxes that violate the nc: 1 declared in the YAML. Filter to SOURCE_CLASS instead of remapping in place.

🐛 Proposed fix
             cls = int(parts[0])
-            if cls == SOURCE_CLASS:
-                cls = TARGET_CLASS
-            lines.append(" ".join([str(cls)] + parts[1:]))
+            if cls != SOURCE_CLASS:
+                continue  # drop non-staffline annotations
+            lines.append(" ".join([str(TARGET_CLASS), *parts[1:]]))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def remap_label_file(src: Path, dst: Path) -> None:
"""Copy a YOLO .txt label, remapping SOURCE_CLASS → TARGET_CLASS."""
lines: list[str] = []
with src.open() as fh:
for raw in fh:
parts = raw.strip().split()
if not parts:
continue
cls = int(parts[0])
if cls == SOURCE_CLASS:
cls = TARGET_CLASS
lines.append(" ".join([str(cls)] + parts[1:]))
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text("\n".join(lines) + "\n")
def remap_label_file(src: Path, dst: Path) -> None:
"""Copy a YOLO .txt label, remapping SOURCE_CLASS → TARGET_CLASS."""
lines: list[str] = []
with src.open() as fh:
for raw in fh:
parts = raw.strip().split()
if not parts:
continue
cls = int(parts[0])
if cls != SOURCE_CLASS:
continue # drop non-staffline annotations
lines.append(" ".join([str(TARGET_CLASS), *parts[1:]]))
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text("\n".join(lines) + "\n")
🧰 Tools
🪛 Ruff (0.15.21)

[warning] 108-108: Consider [str(cls), *parts[1:]] instead of concatenation

Replace with [str(cls), *parts[1:]]

(RUF005)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/train_staffline_detector.py` around lines 97 - 110,
Update remap_label_file to discard every label whose class is not SOURCE_CLASS,
and only write retained SOURCE_CLASS entries using TARGET_CLASS. Preserve the
existing handling of blank lines and label coordinates so the generated
single-class dataset contains no class-0 or class-1 boxes.

kyrieb-ekat and others added 9 commits July 27, 2026 12:48
…R export, and eval

interpolate_staves.py never applied x_page_offset when constructing
InterpolatedLine x-ranges (Trigger A and Trigger B), despite the dataclass
docstring promising page-absolute coordinates - lines from crops with
different offsets landed at the wrong x. Also fixes a latent index
misalignment between centers and fit_pairs_sorted if any center were
ever None.

shared_utils.py write_jsomr had the same defect: it wrote fit.x_start,
x_end, and y_values verbatim without the fit's x_page_offset/y_page_offset,
currently masked because every caller pre-zeros the offsets, but a latent
bug for any future runner using non-zero offsets.

eval_page.py's page-y/page-x helpers added bounding_box offsets to values
that are sometimes already page-absolute (the new centerline_page block
from run_page.py), double-counting the offset. Now prefers centerline_page
when present and falls back to the legacy crop-local conversion otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ilter

component_filter.py's companion-retention logic only checked that a
candidate's x-range didn't overlap the winner's, unlike the merge-clustering
path which also requires y-center proximity. A non-overlapping fragment of
an adjacent (different) staffline could score above COMPANION_SCORE_FLOOR
and get folded into the winner's pixel set, contaminating the fit. Mirrors
the existing merge y-threshold guard. Also documents companion_labels in
the result docstring and adds strict= to the coordinate zips.

train_staffline_detector.py's remap_label_file only remapped SOURCE_CLASS
labels and passed every other class through unchanged, so class-0/class-1
boxes from the multi-class source labels ended up in the single-class
(nc: 1) dataset, indistinguishable from real stafflines. Now filters to
SOURCE_CLASS only. Also chains the ultralytics ImportError for a cleaner
traceback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_print_aggregate_summary filtered out the string "nan" but not the actual
float("nan") values that evaluate() can return (e.g. split_ratio,
mean_y_mae_px), so a single page without matches poisoned the mean/std/
min/max for its whole variant.

--staffline-class defaulted to 0, so argparse always supplied a value and
a present manifest column always won regardless of the flag, contradicting
its own help text ("overrides manifest column if set"). Now defaults to
None so an explicitly-passed flag can actually override the manifest.
Also aligns the default staffline class with the "staves" YOLO class (2)
used elsewhere in the pipeline, rather than 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gp_fit() returned a flat mean-y fallback (with inf std) whenever
GaussianProcessRegressor.fit() raised, and the runner treated that as a
normal fit result - a numerical or configuration failure would silently
pollute stave grouping and exported predictions. gp_fit() now returns
empty y_pred/y_std with meta["error"] set on failure (both the no-coords
case and the fit-exception case), and run_gp_page.py checks for that error
and excludes the box from grouping instead of building a fit from it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…by-one

group_staves.py's stave-grouping diagnostic draws onto an RGB canvas
(matplotlib imshow + a later RGB2BGR conversion before cv2.imwrite), but
the stroke colors were built via reversed(color_rgb), swapping R and B so
every drawn line mismatched its legend patch. Drops the reversal for both
the detected and interpolated strokes.

dp_tracer.py's trace-range clamping only lower-clamped x_start and only
upper-clamped x_end, so a fully out-of-bounds range produced empty xs/ys
arrays that crash the runner on xs[0]. Now clamps both endpoints and
returns a single-point degenerate result for an inverted range instead of
an empty one.

periodicity_detector.py's upper-tooth band-row bound (r_up_hi) subtracted
an extra 1 versus the mirrored lower-tooth formula, excluding the last
valid row near the page boundary from the comb cost. Also removes the
now-unused y_up_lo/y_up_hi clamp variables.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ation

The refinement-acceptance gate compared refined_residuals (computed over
~N sliding-window medians) against abs_residuals (computed over every kept
pixel). Window medians are structurally much closer to any curve than raw
pixels, so the gate accepted refinement almost unconditionally, and
residual_mean/residual_max silently switched population whenever
refinement fired - making them incomparable across boxes in the summary
CSV / JSOMR output.

_trace_line now also returns the boolean mask of every raw pixel that fell
in-band during the trace. The gate scores both the original and refined
coefficients against that same in-band pixel set, and reports residuals
from that set when refinement is accepted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every script_tests file hardcoded sys.path.insert(0, "/home/claude"),
breaking on any other checkout. test_run_pageOG.py had it worse: it loaded
"/home/claude/run_page.py" via importlib regardless, so it never actually
exercised run_pageOG.py despite its name. All six files now resolve the
scripts directory relative to __file__; test_run_pageOG.py loads
run_pageOG.py specifically. Verified all six run standalone (mothrav8 env).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
*.pt was added after !models/**/*.pt, so the later broad pattern won and
re-ignored everything under models/, defeating the intended exception.
Reordered so the negation is last.

staff-finding/e2e_tests was still listed as ignored despite this PR
intentionally committing e2e regression fixtures under it (81 tracked
files) - dropped the now-contradictory rule. Also removed a duplicate
.claude/ entry introduced while merging gitignore changes from main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
STATUS.md, experiments/README.md, and implicit_neural/NOTES.md still
described interpolate_staves.py as a stub/placeholder and implicit_neural/
periodicity as "planned" - all three are implemented in this PR (the
former is just off by default pending validation). Updated the status
claims, the known-issues and key-files tables, and added run examples for
the two runners so the docs match what actually ships.

Also fixes two typos ("Downstrem", "at they go") and adds language hints
to three fenced code blocks flagged by markdownlint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kyrieb-ekat
kyrieb-ekat force-pushed the kyrie/staff-finding branch from 59ac380 to fa7c333 Compare July 27, 2026 23:38
@kyrieb-ekat

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
staff-finding/scripts/component_filter.py (1)

280-299: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

discarded / score_breakdown["kept"] no longer match what's actually kept once companions are retained.

All non-winning survivors are unconditionally recorded as discarded (reason "not_top_scoring") and score_breakdown[...]["kept"] is only ever True for winner_label — but the companion-retention pass below (lines 337-401) can fold some of these same survivors into the active coords/mask. Downstream consumers of result.discarded (e.g. run_page.py's n_discarded summary column) will over-count truly-discarded components whenever companions are retained, since companion labels appear in both discarded and companion_labels/coords simultaneously.

Consider excluding companion labels from discarded, or clearly documenting that discarded/kept reflect only the no-merge single-winner view and may not match the active output when companions are present.

Also applies to: 422-425

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/component_filter.py` around lines 280 - 299, Update the
survivor bookkeeping so retained companions are not reported as discarded. In
the companion-retention flow, use the established companion-label collection to
exclude those labels from discarded entries and mark their score_breakdown
entries as kept alongside winner_label. Ensure result.discarded and
score_breakdown reflect the final active coords/mask output rather than the
intermediate single-winner view.
🧹 Nitpick comments (9)
staff-finding/scripts/group_staves.py (1)

530-573: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace Unicode minus signs in the docstring/comments.

Ruff flags (U+2212) on lines 532, 543, 550, 551 and 573 (RUF002/RUF003); use plain - so the lint run stays clean.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/group_staves.py` around lines 530 - 573, Replace every
Unicode minus sign (U+2212) in the docstring and comments surrounding the stave
classification and intra_count calculation with the ASCII hyphen-minus (-),
including references to detected_line_count, mode_n, and the intra_count
assignment in the affected function. Preserve the wording and logic otherwise.

Source: Linters/SAST tools

staff-finding/experiments/gp_centerlines/gp_fitter.py (1)

145-148: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the redundant int() casts.

len() already returns int; Ruff flags both (RUF046).

♻️ Proposed cleanup
-        "n_obs_cols": int(len(unique_xs)),
-        "n_obs_pixels": int(len(coords)),
+        "n_obs_cols": len(unique_xs),
+        "n_obs_pixels": len(coords),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/gp_centerlines/gp_fitter.py` around lines 145 -
148, Remove the redundant int() wrappers from the len(unique_xs) and len(coords)
values in the result mapping, while preserving the existing keys and values.

Source: Linters/SAST tools

staff-finding/experiments/gp_centerlines/run_gp_page.py (2)

197-199: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Append the uncertainty flag rather than replacing the list.

fit.flags = [...] discards anything set during construction. Harmless today, but it will silently drop flags the moment one is added upstream.

♻️ Proposed change
-        fit.flags = [f"mean_uncertainty_px:{mean_std:.2f}"]
+        fit.flags = [*fit.flags, f"mean_uncertainty_px:{mean_std:.2f}"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/gp_centerlines/run_gp_page.py` around lines 197 -
199, Update the uncertainty handling near fit.flags so the mean_uncertainty_px
flag is appended to the existing flags collection rather than replacing it.
Preserve all flags initialized during fit construction while retaining the
current mean_std calculation and formatting.

134-139: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unify the box convention and silence the unused unpacking.

The empty_crop branch records the unpadded box while every other branch records actual_box, so boxes mixes two conventions in the exported JSOMR. lrx_a/lry_a are also unused (RUF059).

♻️ Proposed cleanup
-        ulx_a, uly_a, lrx_a, lry_a = actual_box
+        ulx_a, uly_a = actual_box[0], actual_box[1]
 
         if crop.size == 0:
             fit_results.append(ExperimentFitResult(flags=["empty_crop"]))
-            boxes.append((ulx, uly, lrx, lry))
+            boxes.append(actual_box)
             continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/gp_centerlines/run_gp_page.py` around lines 134 -
139, Update the empty-crop branch in the surrounding box-processing flow to
append actual_box’s coordinates, matching the convention used by non-empty
crops. Remove the unused lrx_a and lry_a unpacking while retaining the
coordinates required by the branch.

Source: Linters/SAST tools

staff-finding/scripts/script_tests/test_fit_centerline.py (1)

30-32: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Hardcoded /tmp/...png diagnostic paths (4 occurrences).

Flagged by both Ruff (S108) and ast-grep as CWE-377; not exploitable here since it's local test-debug output, but it's not portable (Windows has no /tmp) and risks collisions under parallel test execution.

♻️ Proposed fix — use `tempfile`
+import tempfile
+
+_TMP_DIR = Path(tempfile.gettempdir())
+
 def test_clean_line_fit():
     ...
-        filter_result, scale_unit=4.0, crop=crop, save_path=Path("/tmp/fit_clean.png")
+        filter_result, scale_unit=4.0, crop=crop, save_path=_TMP_DIR / "fit_clean.png"

Apply the same pattern to the other three save_path=Path("/tmp/...") call sites (lines 54, 85, 135).

Also applies to: 53-55, 84-86, 131-136

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/script_tests/test_fit_centerline.py` around lines 30 -
32, Replace all four hardcoded save_path values in test_fit_centerline.py,
including the fit_centerline call and the other highlighted call sites, with
paths created through tempfile so they are portable and unique during parallel
tests. Preserve the existing diagnostic filenames or output behavior as
appropriate while ensuring each temporary path is safely managed.

Source: Linters/SAST tools

staff-finding/experiments/implicit_neural/implicit_neural_fitter.py (1)

178-190: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Full-page tensor rebuilt on every call — redundant per-line cost.

gray_tensor is a full (1,1,H,W) float32 conversion of the entire page, recomputed on each implicit_neural_fit() call. Per implicit_neural/NOTES.md in this PR, this runs ~86 times per page (once per staffline), so the same page-sized conversion is redone dozens of times even though each call only samples a narrow y-band. Consider accepting an optional pre-converted gray_tensor parameter (built once by the caller) or memoizing it, since the underlying image doesn't change between calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/implicit_neural/implicit_neural_fitter.py` around
lines 178 - 190, The implicit_neural_fit flow rebuilds the full-page gray_tensor
on every staffline call. Add an optional preconverted gray_tensor parameter to
implicit_neural_fit, reuse it when supplied, and keep the existing conversion as
a fallback when it is absent; update the page-level caller to construct the
tensor once and pass it to each call.
staff-finding/scripts/component_filter.py (2)

337-401: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No dedicated test coverage for companion retention.

This is a substantial new behaviour (score-floor + y-proximity + x-non-overlap gating that folds extra components into coords/mask/companion_labels), but the reviewed test files (test_merge.py, test_merge_step.py) only exercise merge-cluster behaviour, not companion retention specifically. Consider adding a test that constructs a crop with a plausible companion fragment (passing the score floor, within the y-threshold, non-overlapping x-range) and asserts it appears in companion_labels and contributes to coords.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/component_filter.py` around lines 337 - 401, The
companion-retention logic in the survivor-processing loop lacks dedicated
coverage. Add a focused test using a crop containing a companion fragment that
passes the score floor, y-proximity, and x non-overlap checks, then assert the
fragment’s label is included in companion_labels and its pixels are included in
the resulting coords, covering the relevant merge/no-merge path exposed by the
existing tests.

301-304: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Missing strict=True on this zip(), unlike the newer companion zips.

no_merge_coords = list(zip(xs.tolist(), ys.tolist())) doesn't use strict=True, while the merge/companion zips added in this same diff (lines 335, 380) do. xs/ys are always same-length here (both from np.where on one mask), so this is purely a consistency nit, but adding strict=True costs nothing and matches the pattern established elsewhere in this function.

♻️ Proposed fix
-    no_merge_coords = list(zip(xs.tolist(), ys.tolist()))
+    no_merge_coords = list(zip(xs.tolist(), ys.tolist(), strict=True))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/component_filter.py` around lines 301 - 304, Update the
no_merge_coords construction in the no-merge mask setup to call zip with
strict=True, matching the companion zip calls elsewhere in the function while
preserving the existing coordinate output.

Source: Linters/SAST tools

staff-finding/experiments/dp_tracing/run_dp_page.py (1)

76-111: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unused loop variable idx.

idx from enumerate(stafflines) is never used inside the loop body (flagged by Ruff B007). Rename to _idx or drop enumerate.

♻️ Proposed fix
-    for idx, det in enumerate(stafflines):
+    for det in stafflines:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/dp_tracing/run_dp_page.py` around lines 76 - 111,
Remove the unused idx variable in the stafflines iteration by replacing
enumerate(stafflines) with direct iteration over stafflines, or rename it to
_idx if enumeration is required; preserve the existing loop body behavior.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@staff-finding/experiments/periodicity/periodicity_detector.py`:
- Around line 198-220: Extract the full-page smoothing and darkness calculation
from periodicity_trace into a reusable compute_dark_field helper. In
run_periodicity_page.py, call compute_dark_field once before the staffline
iteration and pass the resulting dark field through each periodicity_trace call
via its dark parameter; update periodicity_trace to reuse the supplied field
while preserving existing behavior when no field is provided.

In `@staff-finding/scripts/group_staves.py`:
- Around line 149-152: Update the `group_staves` docstring for
`interpolation_max_gap` to describe the actual default resolution through
`_compute_interpolation_max_gap(all_gaps, cut_threshold)` when gaps are present,
while noting the `cut_threshold * INTERPOLATION_GAP_MULTIPLIER` fallback only
applies when no gaps exist.

---

Outside diff comments:
In `@staff-finding/scripts/component_filter.py`:
- Around line 280-299: Update the survivor bookkeeping so retained companions
are not reported as discarded. In the companion-retention flow, use the
established companion-label collection to exclude those labels from discarded
entries and mark their score_breakdown entries as kept alongside winner_label.
Ensure result.discarded and score_breakdown reflect the final active coords/mask
output rather than the intermediate single-winner view.

---

Nitpick comments:
In `@staff-finding/experiments/dp_tracing/run_dp_page.py`:
- Around line 76-111: Remove the unused idx variable in the stafflines iteration
by replacing enumerate(stafflines) with direct iteration over stafflines, or
rename it to _idx if enumeration is required; preserve the existing loop body
behavior.

In `@staff-finding/experiments/gp_centerlines/gp_fitter.py`:
- Around line 145-148: Remove the redundant int() wrappers from the
len(unique_xs) and len(coords) values in the result mapping, while preserving
the existing keys and values.

In `@staff-finding/experiments/gp_centerlines/run_gp_page.py`:
- Around line 197-199: Update the uncertainty handling near fit.flags so the
mean_uncertainty_px flag is appended to the existing flags collection rather
than replacing it. Preserve all flags initialized during fit construction while
retaining the current mean_std calculation and formatting.
- Around line 134-139: Update the empty-crop branch in the surrounding
box-processing flow to append actual_box’s coordinates, matching the convention
used by non-empty crops. Remove the unused lrx_a and lry_a unpacking while
retaining the coordinates required by the branch.

In `@staff-finding/experiments/implicit_neural/implicit_neural_fitter.py`:
- Around line 178-190: The implicit_neural_fit flow rebuilds the full-page
gray_tensor on every staffline call. Add an optional preconverted gray_tensor
parameter to implicit_neural_fit, reuse it when supplied, and keep the existing
conversion as a fallback when it is absent; update the page-level caller to
construct the tensor once and pass it to each call.

In `@staff-finding/scripts/component_filter.py`:
- Around line 337-401: The companion-retention logic in the survivor-processing
loop lacks dedicated coverage. Add a focused test using a crop containing a
companion fragment that passes the score floor, y-proximity, and x non-overlap
checks, then assert the fragment’s label is included in companion_labels and its
pixels are included in the resulting coords, covering the relevant
merge/no-merge path exposed by the existing tests.
- Around line 301-304: Update the no_merge_coords construction in the no-merge
mask setup to call zip with strict=True, matching the companion zip calls
elsewhere in the function while preserving the existing coordinate output.

In `@staff-finding/scripts/group_staves.py`:
- Around line 530-573: Replace every Unicode minus sign (U+2212) in the
docstring and comments surrounding the stave classification and intra_count
calculation with the ASCII hyphen-minus (-), including references to
detected_line_count, mode_n, and the intra_count assignment in the affected
function. Preserve the wording and logic otherwise.

In `@staff-finding/scripts/script_tests/test_fit_centerline.py`:
- Around line 30-32: Replace all four hardcoded save_path values in
test_fit_centerline.py, including the fit_centerline call and the other
highlighted call sites, with paths created through tempfile so they are portable
and unique during parallel tests. Preserve the existing diagnostic filenames or
output behavior as appropriate while ensuring each temporary path is safely
managed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d8acfa8-8952-4919-8260-427551e0d090

📥 Commits

Reviewing files that changed from the base of the PR and between 59ac380 and fa7c333.

⛔ Files ignored due to path filters (54)
  • data/First_round_of_corrections-zips/F-PnLatin15181_169v_bigger_annotations.zip is excluded by !**/*.zip
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/Ordo_virtutum-000_predicted.jpg is excluded by !**/*.jpg
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/Ordo_virtutum-000_stave_grouping.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0000.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0000_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0001.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0001_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0002.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0002_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0003.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0003_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0004.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0004_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0005.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0005_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0100.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0100_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0102.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0102_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0104.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0104_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0107.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0107_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0108.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0108_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0109.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0109_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0110.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/box_0110_fit.png is excluded by !**/*.png
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/summary.csv is excluded by !**/*.csv
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-001/Ordo_virtutum-001_predicted.jpg is excluded by !**/*.jpg
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-002/Ordo_virtutum-002_predicted.jpg is excluded by !**/*.jpg
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-003/Ordo_virtutum-003_predicted.jpg is excluded by !**/*.jpg
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-004/Ordo_virtutum-004_predicted.jpg is excluded by !**/*.jpg
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-005/Ordo_virtutum-005_predicted.jpg is excluded by !**/*.jpg
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-006/Ordo_virtutum-006_predicted.jpg is excluded by !**/*.jpg
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-007/Ordo_virtutum-007_predicted.jpg is excluded by !**/*.jpg
  • staff-finding/e2e_tests/29may/Gent15_17_left/run_page/GentAnt1475_0017_leftcrop_no_bgr/summary.csv is excluded by !**/*.csv
  • staff-finding/e2e_tests/29may/Gent15_17_right/eval_batch_results.csv is excluded by !**/*.csv
  • staff-finding/e2e_tests/29may/Gent15_17_right/eval_manifest.csv is excluded by !**/*.csv
  • staff-finding/e2e_tests/29may/Gent15_17_right/eval_results.csv is excluded by !**/*.csv
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/GentAnt1475_0017_AC_rightcrop_no_bgr/summary.csv is excluded by !**/*.csv
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/otsu_no_bgr/summary.csv is excluded by !**/*.csv
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/sauvola_no_bgr/GentAnt1475_0017_AC_rightcrop_no_bgr/summary.csv is excluded by !**/*.csv
  • staff-finding/e2e_tests/pitch_finding_sample/GentAnt1475_0017_AC_rightcrop_no_bgr/GentAnt1475_0017_AC_rightcrop_stave_grouping.png is excluded by !**/*.png
  • staff-finding/e2e_tests/pitch_finding_sample/GentAnt1475_0017_AC_rightcrop_no_bgr/GentAnt1475_0017_AC_rightcrop_stave_grouping_hq.png is excluded by !**/*.png
  • staff-finding/e2e_tests/pitch_finding_sample/GentAnt1475_0017_AC_rightcrop_no_bgr/summary.csv is excluded by !**/*.csv
  • staff-finding/image-sets/gent/Gent15_17_left_screenshot.png is excluded by !**/*.png
  • staff-finding/image-sets/gent/Gent15_17_right_screenshot.png is excluded by !**/*.png
  • staff-finding/image-sets/gent/left/GentAnt1475_0017_leftcrop.jpg is excluded by !**/*.jpg
  • staff-finding/image-sets/gent/right/GentAnt1475_0017_AC_rightcrop.jpg is excluded by !**/*.jpg
  • staff-finding/image-sets/gent/right/inference/GentAnt1475_0017_AC_rightcrop/GentAnt1475_0017_AC_rightcrop_predicted.jpg is excluded by !**/*.jpg
  • staff-finding/scripts/__pycache__/bgr_adapter.cpython-312.pyc is excluded by !**/*.pyc
  • staff-finding/scripts/__pycache__/yolo_io.cpython-312.pyc is excluded by !**/*.pyc
📒 Files selected for processing (88)
  • .gitignore
  • README.md
  • data/First_round_of_corrections-zips/CH-Fco Ms.2 108v_annotations/CH-Fco Ms.2 108v.json
  • data/First_round_of_corrections-zips/CH-Fco Ms.2 108v_annotations/CH-Fco Ms.2 108v.txt
  • data/fast_pairs/F-PnLatin15181_169v_biggerCORRECTED.json
  • data/fast_pairs/F-PnLatin15181_169v_biggerCORRECTED.txt
  • scripts/run_inference.py
  • staff-finding/README.md
  • staff-finding/dox/IMPLEMENTATION_AUDIT.md
  • staff-finding/dox/IMPLEMENTATION_NOTES.md
  • staff-finding/dox/PITCH_FINDING_NOTES.md
  • staff-finding/dox/STATUS.md
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/Ordo_virtutum-000.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/Ordo_virtutum-000_model-predicted.txt
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/Ordo_virtutum-000_stafflines.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/stave_grouping_report.txt
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-001/Ordo_virtutum-001.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-002/Ordo_virtutum-002.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-003/Ordo_virtutum-003.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-004/Ordo_virtutum-004.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-005/Ordo_virtutum-005.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-006/Ordo_virtutum-006.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-007/Ordo_virtutum-007.json
  • staff-finding/e2e_tests/28may_stave-fulldata/all_predictions.json
  • staff-finding/e2e_tests/29may/Gent15_17_left/Gent15_17_left.json
  • staff-finding/e2e_tests/29may/Gent15_17_left/run_page/GentAnt1475_0017_leftcrop_no_bgr/GentAnt1475_0017_leftcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_left/run_page/GentAnt1475_0017_leftcrop_no_bgr/stave_grouping_report.txt
  • staff-finding/e2e_tests/29may/Gent15_17_right/Gent15_17_right_corrected.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/Gent15_17_right_corrected.txt
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/GentAnt1475_0017_AC_rightcrop_no_bgr/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/GentAnt1475_0017_AC_rightcrop_no_bgr/stave_grouping_report.txt
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/dp_tracing/GentAnt1475_0017_AC_rightcrop_dp/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/gp_centerlines/GentAnt1475_0017_AC_rightcrop_gp/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/gp_centerlines_shape_metrics/GentAnt1475_0017_AC_rightcrop_gp/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/implicit_neural/GentAnt1475_0017_AC_rightcrop_implicit_neural/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/implicit_neural_v2_green/GentAnt1475_0017_AC_rightcrop_implicit_neural/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/implicit_neural_v3_interp/GentAnt1475_0017_AC_rightcrop_implicit_neural/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/implicit_neural_v4_periodicity/GentAnt1475_0017_AC_rightcrop_implicit_neural/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/otsu_no_bgr/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/otsu_no_bgr/stave_grouping_report.txt
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/periodicity/GentAnt1475_0017_AC_rightcrop_periodicity/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/periodicity_v2/GentAnt1475_0017_AC_rightcrop_periodicity/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/periodicity_v3/GentAnt1475_0017_AC_rightcrop_periodicity/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/sauvola_no_bgr/GentAnt1475_0017_AC_rightcrop_no_bgr/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/sauvola_no_bgr/GentAnt1475_0017_AC_rightcrop_no_bgr/stave_grouping_report.txt
  • staff-finding/e2e_tests/pitch_finding_sample/GentAnt1475_0017_AC_rightcrop_no_bgr/GentAnt1475_0017_AC_rightcrop_stafflines.json
  • staff-finding/e2e_tests/pitch_finding_sample/GentAnt1475_0017_AC_rightcrop_no_bgr/stave_grouping_report.txt
  • staff-finding/experiments/README.md
  • staff-finding/experiments/dp_tracing/dp_tracer.py
  • staff-finding/experiments/dp_tracing/run_dp_page.py
  • staff-finding/experiments/gp_centerlines/gp_fitter.py
  • staff-finding/experiments/gp_centerlines/run_gp_page.py
  • staff-finding/experiments/heatmap_regression/NOTES.md
  • staff-finding/experiments/implicit_neural/NOTES.md
  • staff-finding/experiments/implicit_neural/implicit_neural_fitter.py
  • staff-finding/experiments/implicit_neural/run_implicit_neural_page.py
  • staff-finding/experiments/periodicity/NOTES.md
  • staff-finding/experiments/periodicity/periodicity_detector.py
  • staff-finding/experiments/periodicity/run_periodicity_page.py
  • staff-finding/experiments/shared_utils.py
  • staff-finding/image-sets/gent/all_predictions.json
  • staff-finding/image-sets/gent/right/Gent15_17_right.json
  • staff-finding/image-sets/gent/right/inference/GentAnt1475_0017_AC_rightcrop/GentAnt1475_0017_AC_rightcrop.json
  • staff-finding/image-sets/gent/right/inference/all_predictions.json
  • staff-finding/image-sets/gent/right/inference/corrected/GentAnt1475_0017_AC_rightcrop.json
  • staff-finding/image-sets/gent/right/inference/corrected/GentAnt1475_0017_AC_rightcrop.txt
  • staff-finding/image-sets/ordo_virt_000/Ordo_virtutum-000.json
  • staff-finding/image-sets/ordo_virt_000/Ordo_virtutum-000.txt
  • staff-finding/scripts/bgr_adapter.py
  • staff-finding/scripts/component_filter.py
  • staff-finding/scripts/eval_batch.py
  • staff-finding/scripts/eval_page.py
  • staff-finding/scripts/fit_centerline.py
  • staff-finding/scripts/group_staves.py
  • staff-finding/scripts/interpolate_staves.py
  • staff-finding/scripts/run_page.py
  • staff-finding/scripts/run_pageOG.py
  • staff-finding/scripts/script_tests/c-filter_test.py
  • staff-finding/scripts/script_tests/test_fit_centerline.py
  • staff-finding/scripts/script_tests/test_group_staves.py
  • staff-finding/scripts/script_tests/test_merge.py
  • staff-finding/scripts/script_tests/test_merge_step.py
  • staff-finding/scripts/script_tests/test_run_page.py
  • staff-finding/scripts/script_tests/test_run_pageOG.py
  • staff-finding/scripts/script_tests/test_sauvola.py
  • staff-finding/scripts/test_group_staves.py
  • staff-finding/scripts/train_staffline_detector.py
  • staff-finding/scripts/yolo_io.py
🚧 Files skipped from review as they are similar to previous changes (33)
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/Ordo_virtutum-000_model-predicted.txt
  • staff-finding/image-sets/ordo_virt_000/Ordo_virtutum-000.txt
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/sauvola_no_bgr/GentAnt1475_0017_AC_rightcrop_no_bgr/stave_grouping_report.txt
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/run_page_results/Ordo_virtutum-000_no_bgr/stave_grouping_report.txt
  • staff-finding/image-sets/gent/right/inference/GentAnt1475_0017_AC_rightcrop/GentAnt1475_0017_AC_rightcrop.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-005/Ordo_virtutum-005.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-004/Ordo_virtutum-004.json
  • README.md
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/otsu_no_bgr/stave_grouping_report.txt
  • staff-finding/scripts/script_tests/test_merge.py
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-001/Ordo_virtutum-001.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-007/Ordo_virtutum-007.json
  • .gitignore
  • staff-finding/image-sets/gent/right/inference/corrected/GentAnt1475_0017_AC_rightcrop.json
  • staff-finding/image-sets/gent/right/Gent15_17_right.json
  • staff-finding/e2e_tests/28may_stave-fulldata/all_predictions.json
  • staff-finding/e2e_tests/29may/Gent15_17_right/run_page/GentAnt1475_0017_AC_rightcrop_no_bgr/stave_grouping_report.txt
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-000/Ordo_virtutum-000.json
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-003/Ordo_virtutum-003.json
  • staff-finding/scripts/script_tests/test_sauvola.py
  • staff-finding/image-sets/gent/right/inference/all_predictions.json
  • staff-finding/scripts/script_tests/test_run_pageOG.py
  • staff-finding/e2e_tests/28may_stave-fulldata/Ordo_virtutum-006/Ordo_virtutum-006.json
  • staff-finding/scripts/test_group_staves.py
  • staff-finding/experiments/shared_utils.py
  • staff-finding/dox/IMPLEMENTATION_NOTES.md
  • staff-finding/scripts/bgr_adapter.py
  • staff-finding/scripts/script_tests/test_group_staves.py
  • staff-finding/scripts/script_tests/c-filter_test.py
  • data/fast_pairs/F-PnLatin15181_169v_biggerCORRECTED.txt
  • staff-finding/scripts/run_page.py
  • staff-finding/scripts/fit_centerline.py
  • staff-finding/scripts/run_pageOG.py

Comment on lines +198 to +220
# Optional Gaussian pre-blur along rows (axis=0) to suppress noise.
sigma = blur_sigma_multiplier * scale_unit
if sigma > 0.5:
smoothed = gaussian_filter1d(gray.astype(np.float32), sigma=sigma, axis=0)
else:
smoothed = gray.astype(np.float32)

# dark[y, x] = (255 - pixel) / 255 for the full page, so dark ink = high value.
# We keep the full page here so that comb teeth can reach rows outside the band.
dark = (255.0 - smoothed) / 255.0 # shape: (page_h, page_w)

# -----------------------------------------------------------------
# Build comb cost for the search band.
# cost[r, c] = darkness(y_lo+r, x_start+c)
# + teeth_weight * darkness(y_lo+r - h_est, x_start+c) [if in bounds]
# + teeth_weight * darkness(y_lo+r + h_est, x_start+c) [if in bounds]
# + … for further teeth
# Then normalised to [0, 1] and inverted so low cost = dark = good.
# -----------------------------------------------------------------

# Centre slice: band rows only, columns x_start..x_end.
# shape: (n_band, n_cols)
band_dark = dark[y_lo : y_hi + 1, x_start : x_end + 1]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Full-page Gaussian blur + darkness field recomputed on every staffline call.

smoothed/dark depend only on gray and scale_unit (via blur_sigma_multiplier), both invariant across the whole page — yet run_periodicity_page.py's per-staffline loop calls periodicity_trace() once per detection, redoing this full-page Gaussian blur and normalization every time. For pages with dozens of stafflines this multiplies an expensive full-page operation dozens of times for no benefit.

♻️ Proposed fix — hoist the blur/dark computation out of the per-box call
+def compute_dark_field(
+    gray: np.ndarray, scale_unit: float, blur_sigma_multiplier: float = BLUR_SIGMA_MULTIPLIER
+) -> np.ndarray:
+    """Precompute the page-wide darkness field once per page."""
+    sigma = blur_sigma_multiplier * scale_unit
+    if sigma > 0.5:
+        smoothed = gaussian_filter1d(gray.astype(np.float32), sigma=sigma, axis=0)
+    else:
+        smoothed = gray.astype(np.float32)
+    return (255.0 - smoothed) / 255.0
+
+
 def periodicity_trace(
     gray: np.ndarray,
     y_hint: float,
     x_start: int,
     x_end: int,
     scale_unit: float,
     h_est: float | None = None,
     band_half_multiplier: float = BAND_HALF_MULTIPLIER,
     max_step_px: int = MAX_STEP_PX,
     blur_sigma_multiplier: float = BLUR_SIGMA_MULTIPLIER,
     n_teeth: int = N_TEETH,
     teeth_weight: float = TEETH_WEIGHT,
+    dark: np.ndarray | None = None,
 ) -> tuple[np.ndarray, np.ndarray]:
     ...
-    sigma = blur_sigma_multiplier * scale_unit
-    if sigma > 0.5:
-        smoothed = gaussian_filter1d(gray.astype(np.float32), sigma=sigma, axis=0)
-    else:
-        smoothed = gray.astype(np.float32)
-    dark = (255.0 - smoothed) / 255.0
+    if dark is None:
+        dark = compute_dark_field(gray, scale_unit, blur_sigma_multiplier)

Then in run_periodicity_page.py, compute dark_field = compute_dark_field(gray, scale_unit, blur_sigma_multiplier) once before the for idx, det in enumerate(stafflines): loop and pass dark=dark_field into each periodicity_trace(...) call.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Optional Gaussian pre-blur along rows (axis=0) to suppress noise.
sigma = blur_sigma_multiplier * scale_unit
if sigma > 0.5:
smoothed = gaussian_filter1d(gray.astype(np.float32), sigma=sigma, axis=0)
else:
smoothed = gray.astype(np.float32)
# dark[y, x] = (255 - pixel) / 255 for the full page, so dark ink = high value.
# We keep the full page here so that comb teeth can reach rows outside the band.
dark = (255.0 - smoothed) / 255.0 # shape: (page_h, page_w)
# -----------------------------------------------------------------
# Build comb cost for the search band.
# cost[r, c] = darkness(y_lo+r, x_start+c)
# + teeth_weight * darkness(y_lo+r - h_est, x_start+c) [if in bounds]
# + teeth_weight * darkness(y_lo+r + h_est, x_start+c) [if in bounds]
# + … for further teeth
# Then normalised to [0, 1] and inverted so low cost = dark = good.
# -----------------------------------------------------------------
# Centre slice: band rows only, columns x_start..x_end.
# shape: (n_band, n_cols)
band_dark = dark[y_lo : y_hi + 1, x_start : x_end + 1]
# Optional Gaussian pre-blur along rows (axis=0) to suppress noise.
if dark is None:
dark = compute_dark_field(gray, scale_unit, blur_sigma_multiplier)
# dark[y, x] = (255 - pixel) / 255 for the full page, so dark ink = high value.
# We keep the full page here so that comb teeth can reach rows outside the band.
# -----------------------------------------------------------------
# Build comb cost for the search band.
# cost[r, c] = darkness(y_lo+r, x_start+c)
# + teeth_weight * darkness(y_lo+r - h_est, x_start+c) [if in bounds]
# + teeth_weight * darkness(y_lo+r + h_est, x_start+c) [if in bounds]
# + … for further teeth
# Then normalised to [0, 1] and inverted so low cost = dark = good.
# -----------------------------------------------------------------
# Centre slice: band rows only, columns x_start..x_end.
# shape: (n_band, n_cols)
band_dark = dark[y_lo : y_hi + 1, x_start : x_end + 1]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/experiments/periodicity/periodicity_detector.py` around lines
198 - 220, Extract the full-page smoothing and darkness calculation from
periodicity_trace into a reusable compute_dark_field helper. In
run_periodicity_page.py, call compute_dark_field once before the staffline
iteration and pass the resulting dark field through each periodicity_trace call
via its dark parameter; update periodicity_trace to reuse the supplied field
while preserving existing behavior when no field is provided.

Comment on lines +149 to +152
interpolation_max_gap: Upper gap bound (pixels) for the
interpolation trigger. Gaps above this are treated as
inter-stave spacing and are not filled. Defaults to
cut_threshold * INTERPOLATION_GAP_MULTIPLIER when None.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Docstring default for interpolation_max_gap doesn't match behaviour.

group_staves always passes all_gaps=gaps, so interpolate_missing_lines resolves the bound via _compute_interpolation_max_gap(all_gaps, cut_threshold); the cut_threshold * INTERPOLATION_GAP_MULTIPLIER path only applies when there are no gaps at all.

📝 Proposed doc fix
         interpolation_max_gap: Upper gap bound (pixels) for the
             interpolation trigger.  Gaps above this are treated as
-            inter-stave spacing and are not filled.  Defaults to
-            cut_threshold * INTERPOLATION_GAP_MULTIPLIER when None.
+            inter-stave spacing and are not filled.  When None it is
+            derived adaptively from the page gap distribution (see
+            interpolate_staves._compute_interpolation_max_gap), falling
+            back to cut_threshold * INTERPOLATION_GAP_MULTIPLIER when no
+            gaps are available.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
interpolation_max_gap: Upper gap bound (pixels) for the
interpolation trigger. Gaps above this are treated as
inter-stave spacing and are not filled. Defaults to
cut_threshold * INTERPOLATION_GAP_MULTIPLIER when None.
interpolation_max_gap: Upper gap bound (pixels) for the
interpolation trigger. Gaps above this are treated as
inter-stave spacing and are not filled. When None it is
derived adaptively from the page gap distribution (see
interpolate_staves._compute_interpolation_max_gap), falling
back to cut_threshold * INTERPOLATION_GAP_MULTIPLIER when no
gaps are available.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@staff-finding/scripts/group_staves.py` around lines 149 - 152, Update the
`group_staves` docstring for `interpolation_max_gap` to describe the actual
default resolution through `_compute_interpolation_max_gap(all_gaps,
cut_threshold)` when gaps are present, while noting the `cut_threshold *
INTERPOLATION_GAP_MULTIPLIER` fallback only applies when no gaps exist.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant