diff --git a/configs/tune/default_params.yaml b/configs/tune/default_params.yaml
new file mode 100644
index 0000000..7d4eb97
--- /dev/null
+++ b/configs/tune/default_params.yaml
@@ -0,0 +1,15 @@
+# Default layer parameters for structure pipeline + auto-tune (#89)
+schema: enpu-tune-params-v0.1
+
+l3:
+ min_measure_width: 24.0
+ min_gap_floor: 18.0
+ min_gap_ratio: 0.03
+ enable_cross_line: true
+ soft_gap_enabled: true
+ open_trailing_enabled: true
+ dedup_gap_factor: 0.5
+
+l4:
+ min_area: 18
+ max_aspect: 3.5
diff --git a/configs/tune/space_l3.yaml b/configs/tune/space_l3.yaml
new file mode 100644
index 0000000..5b8639d
--- /dev/null
+++ b/configs/tune/space_l3.yaml
@@ -0,0 +1,35 @@
+# L3 parameter search space (#89)
+schema: enpu-tune-space-v0.1
+layer: l3
+
+params:
+ min_measure_width:
+ type: float
+ low: 12.0
+ high: 72.0
+ min_gap_floor:
+ type: float
+ low: 10.0
+ high: 36.0
+ min_gap_ratio:
+ type: float
+ low: 0.015
+ high: 0.06
+ enable_cross_line:
+ type: bool
+ soft_gap_enabled:
+ type: bool
+ open_trailing_enabled:
+ type: bool
+ dedup_gap_factor:
+ type: float
+ low: 0.35
+ high: 0.75
+
+# Objective weights for layer loss
+objective:
+ w_iou: 1.0
+ w_cnt: 0.5
+ w_fn: 0.35
+ w_fp: 0.25
+ iou_threshold: 0.5
diff --git a/configs/tune/space_l4.yaml b/configs/tune/space_l4.yaml
new file mode 100644
index 0000000..fa0f8ce
--- /dev/null
+++ b/configs/tune/space_l4.yaml
@@ -0,0 +1,20 @@
+# L4 parameter search space (#89) — secondary; L3 closed-loop first
+schema: enpu-tune-space-v0.1
+layer: l4
+
+params:
+ min_area:
+ type: int
+ low: 8
+ high: 48
+ max_aspect:
+ type: float
+ low: 2.0
+ high: 5.0
+
+objective:
+ w_iou: 1.0
+ w_cnt: 0.4
+ w_fn: 0.4
+ w_fp: 0.3
+ iou_threshold: 0.4
diff --git a/core/app/api/v1/evaluation.py b/core/app/api/v1/evaluation.py
index 3cd60eb..4d2c544 100644
--- a/core/app/api/v1/evaluation.py
+++ b/core/app/api/v1/evaluation.py
@@ -25,9 +25,13 @@
CompareRequest,
CompareResponse,
SampleMetricsOut,
+ TuneLayerRequest,
+ TuneLayerResponse,
TuneParamRequest,
TuneParamResponse,
)
+from app.tuning.params import reset_layer_params, set_layer_params, snapshot_all_params
+from app.tuning.search import tune_layer
router = APIRouter(prefix="/evaluation", tags=["evaluation"])
@@ -269,3 +273,120 @@ def baseline_get(name: str) -> dict:
if not path.is_file():
raise HTTPException(status_code=404, detail=f"baseline not found: {safe}")
return load_baseline(path)
+
+
+@router.post(
+ "/tune-layer",
+ response_model=TuneLayerResponse,
+ summary="Single-layer auto-tune loop (#89): random/grid search on L3 or L4",
+)
+def tune_layer_json(body: TuneLayerRequest) -> TuneLayerResponse:
+ """JSON body with base64 image + GT. Prefer multipart for large images."""
+ if not body.image_base64:
+ raise HTTPException(
+ status_code=400,
+ detail="image_base64 required (or use /tune-layer/upload)",
+ )
+ try:
+ image_bgr = decode_image_bytes(_decode_b64_image(body.image_base64))
+ except ImageDecodeError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ gt = load_ground_truth_from_dict(body.gt)
+ try:
+ result = tune_layer(
+ image_bgr,
+ gt=gt,
+ layer=body.layer, # type: ignore[arg-type]
+ max_trials=body.max_trials,
+ max_seconds=body.max_seconds,
+ seed=body.seed,
+ method=body.method, # type: ignore[arg-type]
+ apply_best=body.apply_best,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return TuneLayerResponse(result=result.as_dict())
+
+
+@router.post(
+ "/tune-layer/upload",
+ response_model=TuneLayerResponse,
+ summary="Single-layer auto-tune with multipart image + GT (#89)",
+)
+async def tune_layer_upload(
+ file: UploadFile = File(...),
+ gt_json: str = Form(...),
+ layer: str = Form("l3"),
+ max_trials: int = Form(40),
+ max_seconds: float = Form(120.0),
+ seed: int = Form(42),
+ method: str = Form("random"),
+ apply_best: bool = Form(False),
+) -> TuneLayerResponse:
+ import json
+
+ data = await file.read()
+ try:
+ image_bgr = decode_image_bytes(data)
+ except ImageDecodeError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ try:
+ gt_raw = json.loads(gt_json)
+ except json.JSONDecodeError as exc:
+ raise HTTPException(status_code=400, detail=f"gt_json invalid: {exc}") from exc
+ gt = load_ground_truth_from_dict(gt_raw)
+ try:
+ result = tune_layer(
+ image_bgr,
+ gt=gt,
+ layer=layer, # type: ignore[arg-type]
+ max_trials=max_trials,
+ max_seconds=max_seconds,
+ seed=seed,
+ method=method, # type: ignore[arg-type]
+ apply_best=apply_best,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return TuneLayerResponse(result=result.as_dict())
+
+
+@router.post(
+ "/params/apply",
+ summary="Apply layer params to runtime store (#89)",
+)
+def params_apply(
+ layer: str = Form("l3"),
+ params_json: str = Form(...),
+ merge: bool = Form(True),
+) -> dict:
+ import json
+
+ try:
+ params = json.loads(params_json)
+ except json.JSONDecodeError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if not isinstance(params, dict):
+ raise HTTPException(status_code=400, detail="params must be object")
+ try:
+ effective = set_layer_params(layer, params, merge=merge)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return {"ok": True, "layer": layer.lower(), "params": effective}
+
+
+@router.post(
+ "/params/reset",
+ summary="Reset runtime layer params to YAML defaults (#89)",
+)
+def params_reset(layer: str | None = Form(default=None)) -> dict:
+ reset_layer_params(layer if layer else None)
+ return {"ok": True, "params": snapshot_all_params()}
+
+
+@router.get(
+ "/params",
+ summary="Snapshot effective layer params (#89)",
+)
+def params_get() -> dict:
+ return snapshot_all_params()
diff --git a/core/app/pipeline/structure/l3_measures.py b/core/app/pipeline/structure/l3_measures.py
index c3fb335..9bd5967 100644
--- a/core/app/pipeline/structure/l3_measures.py
+++ b/core/app/pipeline/structure/l3_measures.py
@@ -31,8 +31,9 @@ def segment_measures_on_systems(
image_bgr: np.ndarray,
systems: list[StaffSystem],
*,
- min_measure_width: float = 24.0,
- enable_cross_line: bool = True,
+ min_measure_width: float | None = None,
+ enable_cross_line: bool | None = None,
+ params: dict | None = None,
) -> tuple[list[StaffSystem], list[str]]:
"""For each system, detect vertical barlines and split into measures.
@@ -41,7 +42,19 @@ def segment_measures_on_systems(
**#84**: barlines are sought primarily in the estimated melody band;
soft gap cut + explicit ``measure_source`` when bars are insufficient.
+
+ **#89**: thresholds from ``params`` / runtime L3 store when not passed.
"""
+ from app.tuning.params import L3Params, get_l3_params
+
+ base = get_l3_params()
+ if params:
+ base = L3Params.from_dict({**base.to_dict(), **params})
+ if min_measure_width is not None:
+ base.min_measure_width = float(min_measure_width)
+ if enable_cross_line is not None:
+ base.enable_cross_line = bool(enable_cross_line)
+
warnings: list[str] = []
if image_bgr is None or image_bgr.size == 0:
return systems, ["L3: empty image"]
@@ -52,12 +65,12 @@ def segment_measures_on_systems(
sys2, wsys = _segment_one_system(
image_bgr,
sys,
- min_measure_width=min_measure_width,
+ p=base,
)
out.append(sys2)
warnings.extend(wsys)
- if enable_cross_line and len(out) >= 2:
+ if base.enable_cross_line and len(out) >= 2:
out, w_cross = _merge_cross_line_opens(out)
warnings.extend(w_cross)
@@ -68,8 +81,14 @@ def _segment_one_system(
image_bgr: np.ndarray,
sys: StaffSystem,
*,
- min_measure_width: float,
+ p: "object",
) -> tuple[StaffSystem, list[str]]:
+ from app.tuning.params import L3Params
+
+ if not isinstance(p, L3Params):
+ p = L3Params.from_dict(dict(p) if p else None) # type: ignore[arg-type]
+
+ min_measure_width = float(p.min_measure_width)
warnings: list[str] = []
y0, y1 = sys.rect.y1, sys.rect.y2
x_lo, x_hi = sys.rect.x1, sys.rect.x2
@@ -89,7 +108,8 @@ def _segment_one_system(
f"(system y=[{y0:.0f},{y1:.0f}])"
)
- min_gap = max(18.0, sys.rect.width * 0.03)
+ min_gap = max(float(p.min_gap_floor), sys.rect.width * float(p.min_gap_ratio))
+ dedup = min_measure_width * float(p.dedup_gap_factor)
# Graphic bars inside melody band first
xs_mel = detect_barline_xs(
@@ -106,10 +126,10 @@ def _segment_one_system(
melody_mode=False,
)
- xs = _merge_unique_xs(xs_mel + xs_full, min_gap=min_measure_width * 0.5)
+ xs = _merge_unique_xs(xs_mel + xs_full, min_gap=dedup)
# Keep xs inside system x range with margin
xs = [x for x in xs if x_lo + 8 < x < x_hi - 8]
- xs = _dedup_xs(sorted(xs), min_gap=min_measure_width * 0.5)
+ xs = _dedup_xs(sorted(xs), min_gap=dedup)
measures: list[MeasureLayout] = []
source = SRC_L3_BARLINE
@@ -124,7 +144,7 @@ def _segment_one_system(
)
# #84: not enough bars → soft gap cut inside melody band
- if len(measures) < 2:
+ if len(measures) < 2 and p.soft_gap_enabled:
soft_xs = gap_soft_bar_xs(
image_bgr,
y_range=(my0, my1),
@@ -208,7 +228,12 @@ def _segment_one_system(
)
# Open trailing only when ink exists after last bar (not empty margin) (#84 / #66)
- if measures and source == SRC_L3_BARLINE and len(xs) >= 2:
+ if (
+ p.open_trailing_enabled
+ and measures
+ and source == SRC_L3_BARLINE
+ and len(xs) >= 2
+ ):
last_bar = xs[-1]
if x_hi - last_bar > min_measure_width * 1.2 and _has_ink_in_band(
image_bgr,
diff --git a/core/app/pipeline/structure/l4_notes.py b/core/app/pipeline/structure/l4_notes.py
index be5d6ca..e1b3328 100644
--- a/core/app/pipeline/structure/l4_notes.py
+++ b/core/app/pipeline/structure/l4_notes.py
@@ -24,10 +24,26 @@ def detect_note_candidates(
image_bgr: np.ndarray,
systems: list[StaffSystem],
*,
- min_area: int = 18,
- max_aspect: float = 3.5,
+ min_area: int | None = None,
+ max_aspect: float | None = None,
+ params: dict | None = None,
) -> tuple[list[StaffSystem], list[str]]:
- """Fill each measure with pitch note ROIs (+ optional chord/lyric slots)."""
+ """Fill each measure with pitch note ROIs (+ optional chord/lyric slots).
+
+ **#89**: thresholds from runtime L4 params when not passed explicitly.
+ """
+ from app.tuning.params import L4Params, get_l4_params
+
+ p = get_l4_params()
+ if params:
+ p = L4Params.from_dict({**p.to_dict(), **params})
+ if min_area is not None:
+ p.min_area = int(min_area)
+ if max_aspect is not None:
+ p.max_aspect = float(max_aspect)
+ min_area = int(p.min_area)
+ max_aspect = float(p.max_aspect)
+
warnings: list[str] = []
if image_bgr is None or image_bgr.size == 0:
return systems, ["L4: empty image"]
diff --git a/core/app/schemas/evaluation.py b/core/app/schemas/evaluation.py
index 3b924c9..55d25ea 100644
--- a/core/app/schemas/evaluation.py
+++ b/core/app/schemas/evaluation.py
@@ -99,3 +99,24 @@ class BaselineDiffRequest(BaseModel):
class BaselineDiffResponse(BaseModel):
diff: dict[str, Any]
+
+
+class TuneLayerRequest(BaseModel):
+ """Full single-layer auto-tune loop (#89)."""
+
+ sample_id: str = "tune"
+ gt: dict[str, Any]
+ image_base64: str | None = None
+ layer: str = Field(default="l3", description="l3 | l4")
+ max_trials: int = Field(default=40, ge=1, le=500)
+ max_seconds: float = Field(default=120.0, ge=1.0, le=3600.0)
+ seed: int = 42
+ method: str = Field(default="random", description="random | grid")
+ apply_best: bool = Field(
+ default=False,
+ description="If true, write best params into runtime store for next recognize",
+ )
+
+
+class TuneLayerResponse(BaseModel):
+ result: dict[str, Any]
diff --git a/core/app/tuning/__init__.py b/core/app/tuning/__init__.py
new file mode 100644
index 0000000..816b2fc
--- /dev/null
+++ b/core/app/tuning/__init__.py
@@ -0,0 +1,15 @@
+"""Single-layer parameter auto-tune loop (#89)."""
+
+from app.tuning.layer_objective import layer_loss, match_boxes
+from app.tuning.params import L3Params, L4Params, get_layer_params, set_layer_params
+from app.tuning.search import tune_layer
+
+__all__ = [
+ "L3Params",
+ "L4Params",
+ "get_layer_params",
+ "set_layer_params",
+ "layer_loss",
+ "match_boxes",
+ "tune_layer",
+]
diff --git a/core/app/tuning/layer_objective.py b/core/app/tuning/layer_objective.py
new file mode 100644
index 0000000..79f57d8
--- /dev/null
+++ b/core/app/tuning/layer_objective.py
@@ -0,0 +1,145 @@
+"""Box matching and single-layer loss (#89)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+from app.evaluation.metrics import box_match_metrics, iou
+from app.evaluation.types import Box, LayerMetric
+
+
+@dataclass
+class MatchResult:
+ tp: int
+ fp: int
+ fn: int
+ mean_iou: float
+ pairs: list[tuple[int, int, float]] = field(default_factory=list)
+ precision: float = 0.0
+ recall: float = 0.0
+ f1: float = 0.0
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "tp": self.tp,
+ "fp": self.fp,
+ "fn": self.fn,
+ "mean_iou": round(self.mean_iou, 4),
+ "precision": round(self.precision, 4),
+ "recall": round(self.recall, 4),
+ "f1": round(self.f1, 4),
+ "n_pairs": len(self.pairs),
+ }
+
+
+def match_boxes(
+ pred: list[Box],
+ gt: list[Box],
+ *,
+ iou_threshold: float = 0.5,
+) -> MatchResult:
+ """Greedy one-to-one IoU matching (same as eval metrics)."""
+ lm = box_match_metrics(gt, pred, layer="match", iou_threshold=iou_threshold)
+ pairs: list[tuple[int, int, float]] = []
+ # Reconstruct pairs from errors TP entries
+ used_g: set[int] = set()
+ used_p: set[int] = set()
+ for e in lm.errors:
+ if e.kind != "tp" or e.partner is None or e.iou is None:
+ continue
+ # find indices
+ gi = next((i for i, g in enumerate(gt) if g is e.partner or _box_eq(g, e.partner)), -1)
+ pi = next((i for i, p in enumerate(pred) if p is e.box or _box_eq(p, e.box)), -1)
+ if gi >= 0 and pi >= 0 and gi not in used_g and pi not in used_p:
+ used_g.add(gi)
+ used_p.add(pi)
+ pairs.append((pi, gi, e.iou))
+ return MatchResult(
+ tp=lm.tp,
+ fp=lm.fp,
+ fn=lm.fn,
+ mean_iou=lm.mean_iou,
+ pairs=pairs,
+ precision=lm.precision,
+ recall=lm.recall,
+ f1=lm.f1,
+ )
+
+
+def _box_eq(a: Box, b: Box) -> bool:
+ return (
+ abs(a.x1 - b.x1) < 1e-6
+ and abs(a.y1 - b.y1) < 1e-6
+ and abs(a.x2 - b.x2) < 1e-6
+ and abs(a.y2 - b.y2) < 1e-6
+ )
+
+
+@dataclass
+class LayerLoss:
+ loss: float
+ score: float
+ match: MatchResult
+ components: dict[str, float] = field(default_factory=dict)
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "loss": round(self.loss, 6),
+ "score": round(self.score, 6),
+ "match": self.match.as_dict(),
+ "components": {k: round(v, 6) for k, v in self.components.items()},
+ }
+
+
+def layer_loss(
+ pred: list[Box],
+ gt: list[Box],
+ *,
+ w_iou: float = 1.0,
+ w_cnt: float = 0.5,
+ w_fn: float = 0.35,
+ w_fp: float = 0.25,
+ iou_threshold: float = 0.5,
+) -> LayerLoss:
+ """
+ loss = w_iou*(1-mean_iou) + w_cnt*norm_count + w_fn*fn_rate + w_fp*fp_rate
+
+ Only uses current-layer pred vs GT (no downstream pitch).
+ """
+ m = match_boxes(pred, gt, iou_threshold=iou_threshold)
+ n_gt = max(len(gt), 1)
+ n_pred = len(pred)
+ # count error normalized by gt size (cap at 1)
+ cnt_err = min(1.0, abs(n_pred - len(gt)) / n_gt)
+ fn_rate = m.fn / n_gt
+ fp_rate = m.fp / max(n_pred, 1) if n_pred else (0.0 if len(gt) == 0 else 1.0)
+ mean_iou = m.mean_iou if m.tp > 0 else 0.0
+
+ c_iou = w_iou * (1.0 - mean_iou)
+ c_cnt = w_cnt * cnt_err
+ c_fn = w_fn * fn_rate
+ c_fp = w_fp * fp_rate
+ loss = c_iou + c_cnt + c_fn + c_fp
+ # Bound for score display
+ score = max(0.0, 1.0 - loss)
+ return LayerLoss(
+ loss=loss,
+ score=score,
+ match=m,
+ components={
+ "iou_term": c_iou,
+ "cnt_term": c_cnt,
+ "fn_term": c_fn,
+ "fp_term": c_fp,
+ "mean_iou": mean_iou,
+ "cnt_err": cnt_err,
+ "fn_rate": fn_rate,
+ "fp_rate": fp_rate,
+ },
+ )
+
+
+def metric_to_boxes_from_layer_metric(lm: LayerMetric) -> None:
+ """Placeholder — boxes come from extract, not metrics."""
+ return None
diff --git a/core/app/tuning/params.py b/core/app/tuning/params.py
new file mode 100644
index 0000000..488d0e3
--- /dev/null
+++ b/core/app/tuning/params.py
@@ -0,0 +1,123 @@
+"""Layer parameter dataclasses + runtime store (#89)."""
+
+from __future__ import annotations
+
+import copy
+from dataclasses import asdict, dataclass, fields
+from pathlib import Path
+from typing import Any
+
+# core/app/tuning -> parents[3] = repo root EnPu
+_REPO_ROOT = Path(__file__).resolve().parents[3]
+_DEFAULT_YAML = _REPO_ROOT / "configs" / "tune" / "default_params.yaml"
+
+# Runtime overrides applied after load (session / apply best)
+_RUNTIME: dict[str, dict[str, Any]] = {}
+
+
+@dataclass
+class L3Params:
+ min_measure_width: float = 24.0
+ min_gap_floor: float = 18.0
+ min_gap_ratio: float = 0.03
+ enable_cross_line: bool = True
+ soft_gap_enabled: bool = True
+ open_trailing_enabled: bool = True
+ dedup_gap_factor: float = 0.5
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, d: dict[str, Any] | None) -> L3Params:
+ if not d:
+ return cls()
+ known = {f.name for f in fields(cls)}
+ kwargs = {k: d[k] for k in known if k in d}
+ return cls(**kwargs)
+
+
+@dataclass
+class L4Params:
+ min_area: int = 18
+ max_aspect: float = 3.5
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, d: dict[str, Any] | None) -> L4Params:
+ if not d:
+ return cls()
+ known = {f.name for f in fields(cls)}
+ kwargs = {k: d[k] for k in known if k in d}
+ if "min_area" in kwargs:
+ kwargs["min_area"] = int(kwargs["min_area"])
+ return cls(**kwargs)
+
+
+def _load_yaml(path: Path) -> dict[str, Any]:
+ if not path.is_file():
+ return {}
+ try:
+ import yaml # type: ignore
+
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ # Minimal fallback without PyYAML: only support empty / skip
+ return {}
+
+
+def load_default_params_file() -> dict[str, Any]:
+ return _load_yaml(_DEFAULT_YAML)
+
+
+def get_layer_params(layer: str) -> dict[str, Any]:
+ """Merged defaults + runtime overrides for a layer (``l3`` / ``l4``)."""
+ key = layer.lower()
+ if key not in ("l3", "l4"):
+ # allow "L3" already lowercased; reject others later when setting
+ pass
+ defaults = load_default_params_file()
+ base = dict(defaults.get(key) or {})
+ base.update(_RUNTIME.get(key) or {})
+ return base
+
+
+def get_l3_params() -> L3Params:
+ return L3Params.from_dict(get_layer_params("l3"))
+
+
+def get_l4_params() -> L4Params:
+ return L4Params.from_dict(get_layer_params("l4"))
+
+
+def set_layer_params(layer: str, params: dict[str, Any], *, merge: bool = True) -> dict[str, Any]:
+ """Apply runtime params for a layer. Returns the effective dict."""
+ key = layer.lower()
+ if key not in ("l3", "l4"):
+ raise ValueError(f"unsupported layer: {layer}")
+ if merge:
+ cur = get_layer_params(key)
+ cur.update(params)
+ _RUNTIME[key] = cur
+ else:
+ _RUNTIME[key] = dict(params)
+ return get_layer_params(key)
+
+
+def reset_layer_params(layer: str | None = None) -> None:
+ """Clear runtime overrides (restore YAML defaults)."""
+ if layer is None:
+ _RUNTIME.clear()
+ else:
+ _RUNTIME.pop(layer.lower(), None)
+
+
+def snapshot_all_params() -> dict[str, Any]:
+ return {
+ "l3": get_l3_params().to_dict(),
+ "l4": get_l4_params().to_dict(),
+ "runtime_overrides": copy.deepcopy(_RUNTIME),
+ }
diff --git a/core/app/tuning/search.py b/core/app/tuning/search.py
new file mode 100644
index 0000000..43c17cf
--- /dev/null
+++ b/core/app/tuning/search.py
@@ -0,0 +1,415 @@
+"""Random / grid search for single-layer params (#89)."""
+
+from __future__ import annotations
+
+import json
+import random
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal
+
+import numpy as np
+
+from app.evaluation.extract import boxes_from_page_layout
+from app.evaluation.types import Box
+from app.pipeline.structure.ir import PageLayout, StaffSystem
+from app.pipeline.structure.l1_page import detect_page_regions
+from app.pipeline.structure.l2_systems import detect_staff_systems
+from app.pipeline.structure.l3_measures import segment_measures_on_systems
+from app.pipeline.structure.l4_notes import detect_note_candidates
+from app.tuning.layer_objective import layer_loss
+from app.tuning.params import (
+ get_layer_params,
+ load_default_params_file,
+ set_layer_params,
+)
+
+_REPO = Path(__file__).resolve().parents[3]
+
+
+@dataclass
+class TrialRecord:
+ trial: int
+ params: dict[str, Any]
+ loss: float
+ score: float
+ mean_iou: float
+ tp: int
+ fp: int
+ fn: int
+ elapsed_ms: float
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "trial": self.trial,
+ "params": self.params,
+ "loss": round(self.loss, 6),
+ "score": round(self.score, 6),
+ "mean_iou": round(self.mean_iou, 4),
+ "tp": self.tp,
+ "fp": self.fp,
+ "fn": self.fn,
+ "elapsed_ms": round(self.elapsed_ms, 2),
+ }
+
+
+@dataclass
+class TuneLayerResult:
+ layer: str
+ best_params: dict[str, Any]
+ best_loss: float
+ best_score: float
+ baseline_loss: float
+ baseline_score: float
+ improved: bool
+ trials: list[TrialRecord]
+ n_trials: int
+ seed: int
+ elapsed_sec: float
+ warnings: list[str] = field(default_factory=list)
+ objective: dict[str, float] = field(default_factory=dict)
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "layer": self.layer,
+ "best_params": self.best_params,
+ "best_loss": round(self.best_loss, 6),
+ "best_score": round(self.best_score, 6),
+ "baseline_loss": round(self.baseline_loss, 6),
+ "baseline_score": round(self.baseline_score, 6),
+ "improved": self.improved,
+ "n_trials": self.n_trials,
+ "seed": self.seed,
+ "elapsed_sec": round(self.elapsed_sec, 3),
+ "warnings": self.warnings,
+ "objective": self.objective,
+ "trials": [t.as_dict() for t in self.trials],
+ }
+
+
+def _load_space(layer: str) -> dict[str, Any]:
+ path = _REPO / "configs" / "tune" / f"space_{layer}.yaml"
+ try:
+ import yaml
+
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) if path.is_file() else {}
+ except Exception:
+ data = {}
+ return data if isinstance(data, dict) else {}
+
+
+def _sample_params(
+ space: dict[str, Any],
+ rng: random.Random,
+ *,
+ fixed: dict[str, Any],
+) -> dict[str, Any]:
+ """Draw one candidate from space (random search)."""
+ params_spec = space.get("params") or {}
+ out = dict(fixed)
+ for key, spec in params_spec.items():
+ if not isinstance(spec, dict):
+ continue
+ t = str(spec.get("type") or "float")
+ if t == "bool":
+ out[key] = bool(rng.choice([False, True]))
+ elif t == "int":
+ lo = int(spec.get("low", 0))
+ hi = int(spec.get("high", lo))
+ out[key] = int(rng.randint(lo, hi))
+ else:
+ lo = float(spec.get("low", 0.0))
+ hi = float(spec.get("high", lo))
+ out[key] = float(rng.uniform(lo, hi))
+ return out
+
+
+def _clone_systems(systems: list[StaffSystem]) -> list[StaffSystem]:
+ out: list[StaffSystem] = []
+ for s in systems:
+ out.append(
+ StaffSystem(
+ index=s.index,
+ rect=s.rect,
+ measures=list(s.measures),
+ barline_xs=list(s.barline_xs),
+ confidence=s.confidence,
+ extra=dict(s.extra or {}),
+ )
+ )
+ return out
+
+
+def _build_upstream(
+ image_bgr: np.ndarray,
+ *,
+ layer: str,
+) -> tuple[PageLayout, list[str]]:
+ """Cache L1/L2 (and L3 if tuning L4)."""
+ warnings: list[str] = []
+ h, w = image_bgr.shape[:2]
+ regions, w1 = detect_page_regions(image_bgr)
+ warnings.extend(w1)
+ from app.pipeline.structure.ir import Rect, RegionRole
+
+ score = next((r for r in regions if r.role == RegionRole.score), None)
+ score_rect = score.rect if score else Rect(0, 0, float(w), float(h))
+ systems, w2 = detect_staff_systems(image_bgr, score_rect)
+ warnings.extend(w2)
+ if layer == "l4":
+ systems, w3 = segment_measures_on_systems(image_bgr, systems)
+ warnings.extend(w3)
+ layout = PageLayout(
+ width=w,
+ height=h,
+ regions=regions,
+ systems=systems,
+ warnings=list(warnings),
+ )
+ return layout, warnings
+
+
+def _pred_boxes_for_layer(
+ image_bgr: np.ndarray,
+ base: PageLayout,
+ *,
+ layer: str,
+ params: dict[str, Any],
+) -> list[Box]:
+ systems = _clone_systems(base.systems)
+ if layer == "l3":
+ # Clear measures; re-segment
+ systems = [
+ StaffSystem(
+ index=s.index,
+ rect=s.rect,
+ measures=[],
+ barline_xs=[],
+ confidence=s.confidence,
+ extra=dict(s.extra or {}),
+ )
+ for s in systems
+ ]
+ systems, _ = segment_measures_on_systems(
+ image_bgr, systems, params=params
+ )
+ layout = PageLayout(
+ width=base.width,
+ height=base.height,
+ regions=list(base.regions),
+ systems=systems,
+ )
+ return boxes_from_page_layout(layout).get("L3")
+
+ if layer == "l4":
+ # Keep L3 measures from cache; re-run L4 only
+ systems, _ = detect_note_candidates(image_bgr, systems, params=params)
+ layout = PageLayout(
+ width=base.width,
+ height=base.height,
+ regions=list(base.regions),
+ systems=systems,
+ )
+ return [
+ b
+ for b in boxes_from_page_layout(layout).get("L4")
+ if (b.kind or "pitch") == "pitch"
+ ]
+
+ raise ValueError(f"unsupported layer: {layer}")
+
+
+def gt_boxes_for_layer(gt: dict[str, Any], layer: str) -> list[Box]:
+ """Extract GT boxes for L3/L4 from normalized GT dict."""
+ geom = gt.get("geometry") or {}
+ key = "L3" if layer == "l3" else "L4"
+ boxes = list(geom.get(key) or [])
+ if key == "L4":
+ boxes = [b for b in boxes if (b.kind or "pitch") == "pitch"]
+ return boxes
+
+
+def tune_layer(
+ image_bgr: np.ndarray,
+ *,
+ gt: dict[str, Any],
+ layer: Literal["l3", "l4"] = "l3",
+ max_trials: int = 40,
+ max_seconds: float = 120.0,
+ seed: int = 42,
+ method: Literal["random", "grid"] = "random",
+ apply_best: bool = False,
+ log_path: Path | str | None = None,
+) -> TuneLayerResult:
+ """
+ Search layer params to minimize layer_loss(pred, gt).
+
+ Upstream layout is cached once. GT is never modified.
+ """
+ layer = layer.lower() # type: ignore[assignment]
+ if layer not in ("l3", "l4"):
+ raise ValueError("layer must be l3 or l4")
+
+ started = time.perf_counter()
+ warnings: list[str] = []
+ space = _load_space(layer)
+ obj = space.get("objective") or {}
+ w_iou = float(obj.get("w_iou", 1.0))
+ w_cnt = float(obj.get("w_cnt", 0.5))
+ w_fn = float(obj.get("w_fn", 0.35))
+ w_fp = float(obj.get("w_fp", 0.25))
+ iou_thr = float(obj.get("iou_threshold", 0.5))
+
+ gt_boxes = gt_boxes_for_layer(gt, layer)
+ if not gt_boxes:
+ warnings.append(
+ f"tune_layer: no geometry GT for {layer.upper()} — "
+ "loss uses empty-GT matching (prefer edit-as-GT)"
+ )
+
+ base_layout, w0 = _build_upstream(image_bgr, layer=layer)
+ warnings.extend(w0[:12])
+
+ fixed = get_layer_params(layer)
+ rng = random.Random(seed)
+
+ def eval_params(params: dict[str, Any]) -> tuple[float, float, Any, float]:
+ t0 = time.perf_counter()
+ pred = _pred_boxes_for_layer(
+ image_bgr, base_layout, layer=layer, params=params
+ )
+ loss_obj = layer_loss(
+ pred,
+ gt_boxes,
+ w_iou=w_iou,
+ w_cnt=w_cnt,
+ w_fn=w_fn,
+ w_fp=w_fp,
+ iou_threshold=iou_thr,
+ )
+ return loss_obj.loss, loss_obj.score, loss_obj, (time.perf_counter() - t0) * 1000
+
+ # Baseline with current params
+ base_loss, base_score, base_obj, base_ms = eval_params(fixed)
+ trials: list[TrialRecord] = [
+ TrialRecord(
+ trial=0,
+ params=dict(fixed),
+ loss=base_loss,
+ score=base_score,
+ mean_iou=base_obj.match.mean_iou,
+ tp=base_obj.match.tp,
+ fp=base_obj.match.fp,
+ fn=base_obj.match.fn,
+ elapsed_ms=base_ms,
+ )
+ ]
+
+ best_params = dict(fixed)
+ best_loss = base_loss
+ best_score = base_score
+
+ # Candidate generation
+ candidates: list[dict[str, Any]] = []
+ if method == "grid" and layer == "l3":
+ # Small grid on min_measure_width only for MVP determinism
+ for w in np.linspace(16, 64, num=min(max_trials, 13)):
+ p = dict(fixed)
+ p["min_measure_width"] = float(round(w, 2))
+ candidates.append(p)
+ else:
+ for _ in range(max(0, max_trials - 1)):
+ candidates.append(_sample_params(space, rng, fixed=fixed))
+
+ for i, cand in enumerate(candidates, start=1):
+ if time.perf_counter() - started > max_seconds:
+ warnings.append(
+ f"tune_layer: stopped by max_seconds={max_seconds} at trial {i}"
+ )
+ break
+ if i >= max_trials:
+ break
+ loss, score, obj_r, ms = eval_params(cand)
+ trials.append(
+ TrialRecord(
+ trial=i,
+ params=dict(cand),
+ loss=loss,
+ score=score,
+ mean_iou=obj_r.match.mean_iou,
+ tp=obj_r.match.tp,
+ fp=obj_r.match.fp,
+ fn=obj_r.match.fn,
+ elapsed_ms=ms,
+ )
+ )
+ if loss < best_loss - 1e-12:
+ best_loss = loss
+ best_score = score
+ best_params = dict(cand)
+
+ if apply_best:
+ set_layer_params(layer, best_params, merge=False)
+ warnings.append(f"tune_layer: applied best params to runtime {layer}")
+
+ result = TuneLayerResult(
+ layer=layer,
+ best_params=best_params,
+ best_loss=best_loss,
+ best_score=best_score,
+ baseline_loss=base_loss,
+ baseline_score=base_score,
+ improved=best_loss <= base_loss + 1e-12,
+ trials=trials,
+ n_trials=len(trials),
+ seed=seed,
+ elapsed_sec=time.perf_counter() - started,
+ warnings=warnings,
+ objective={
+ "w_iou": w_iou,
+ "w_cnt": w_cnt,
+ "w_fn": w_fn,
+ "w_fp": w_fp,
+ "iou_threshold": iou_thr,
+ },
+ )
+
+ if log_path:
+ path = Path(log_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", encoding="utf-8") as f:
+ for t in trials:
+ f.write(json.dumps(t.as_dict(), ensure_ascii=False) + "\n")
+ summary_path = path.with_suffix(".summary.json")
+ summary_path.write_text(
+ json.dumps(result.as_dict(), ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+
+ return result
+
+
+def write_best_params_yaml(
+ best: dict[str, Any],
+ *,
+ layer: str,
+ path: Path | str,
+) -> None:
+ """Write best params merged into default file structure."""
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ defaults = load_default_params_file()
+ defaults[layer.lower()] = {
+ **(defaults.get(layer.lower()) or {}),
+ **best,
+ }
+ try:
+ import yaml
+
+ path.write_text(
+ yaml.safe_dump(defaults, allow_unicode=True, sort_keys=False),
+ encoding="utf-8",
+ )
+ except Exception:
+ path.write_text(json.dumps(defaults, ensure_ascii=False, indent=2), encoding="utf-8")
diff --git a/core/requirements-ci.txt b/core/requirements-ci.txt
index 42d6332..1dc009e 100644
--- a/core/requirements-ci.txt
+++ b/core/requirements-ci.txt
@@ -12,5 +12,6 @@ numpy>=1.24,<3
opencv-python-headless>=4.8,<5
setuptools>=65
music21>=9.1,<10
+PyYAML>=6.0,<7
httpx>=0.27,<1
pytest>=8.0,<9
diff --git a/core/requirements.txt b/core/requirements.txt
index 65990ba..8a78571 100644
--- a/core/requirements.txt
+++ b/core/requirements.txt
@@ -22,6 +22,9 @@ paddleocr>=2.7,<3
# Export (MusicXML / MIDI) — issue #11
music21>=9.1,<10
+# Layer param YAML (#89)
+PyYAML>=6.0,<7
+
# tests
httpx>=0.27,<1
pytest>=8.0,<9
diff --git a/core/tests/test_tuning_layer.py b/core/tests/test_tuning_layer.py
new file mode 100644
index 0000000..28891b6
--- /dev/null
+++ b/core/tests/test_tuning_layer.py
@@ -0,0 +1,118 @@
+"""Tests for single-layer tune loop (#89)."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from app.evaluation.types import Box
+from app.tuning.layer_objective import layer_loss, match_boxes
+from app.tuning.params import (
+ L3Params,
+ get_l3_params,
+ reset_layer_params,
+ set_layer_params,
+)
+from app.tuning.search import tune_layer
+
+
+def test_match_boxes_basic() -> None:
+ gt = [Box(0, 0, 10, 10), Box(20, 0, 30, 10)]
+ pred = [Box(1, 1, 9, 9), Box(50, 0, 60, 10)]
+ m = match_boxes(pred, gt, iou_threshold=0.3)
+ assert m.tp == 1
+ assert m.fp == 1
+ assert m.fn == 1
+
+
+def test_layer_loss_perfect() -> None:
+ boxes = [Box(0, 0, 10, 10), Box(20, 0, 30, 10)]
+ loss = layer_loss(boxes, boxes)
+ assert loss.loss < 0.05
+ assert loss.score > 0.9
+ assert loss.match.tp == 2
+
+
+def test_set_get_l3_params() -> None:
+ reset_layer_params()
+ set_layer_params("l3", {"min_measure_width": 33.0}, merge=True)
+ p = get_l3_params()
+ assert p.min_measure_width == pytest.approx(33.0)
+ reset_layer_params("l3")
+ p2 = get_l3_params()
+ assert p2.min_measure_width == pytest.approx(24.0)
+
+
+def _synthetic_staff() -> np.ndarray:
+ img = np.full((120, 400, 3), 255, dtype=np.uint8)
+ img[40:90, 20:380] = 245
+ for x in (60, 150, 240, 330):
+ img[45:85, x : x + 2] = 0
+ for x in (90, 180, 270):
+ img[55:75, x : x + 10] = 0
+ return img
+
+
+def test_tune_layer_l3_reproducible() -> None:
+ reset_layer_params()
+ img = _synthetic_staff()
+ # GT = three measure boxes roughly between bars
+ gt_boxes = [
+ Box(60, 40, 150, 90, kind="measure"),
+ Box(150, 40, 240, 90, kind="measure"),
+ Box(240, 40, 330, 90, kind="measure"),
+ ]
+ gt = {
+ "pitch_sequence": [],
+ "measure_count": 3,
+ "geometry": {"L3": gt_boxes},
+ "barline_xs": [60.0, 150.0, 240.0, 330.0],
+ }
+ r1 = tune_layer(
+ img,
+ gt=gt,
+ layer="l3",
+ max_trials=12,
+ max_seconds=30,
+ seed=7,
+ method="random",
+ apply_best=False,
+ )
+ r2 = tune_layer(
+ img,
+ gt=gt,
+ layer="l3",
+ max_trials=12,
+ max_seconds=30,
+ seed=7,
+ method="random",
+ apply_best=False,
+ )
+ assert r1.n_trials == r2.n_trials
+ assert r1.best_loss == pytest.approx(r2.best_loss, rel=1e-6, abs=1e-6)
+ assert r1.best_params["min_measure_width"] == pytest.approx(
+ r2.best_params["min_measure_width"], rel=1e-6, abs=1e-6
+ )
+ # GT never written into params
+ assert "geometry" not in r1.best_params
+ assert r1.improved is True
+ assert r1.best_loss <= r1.baseline_loss + 1e-9
+
+
+def test_l3_params_affect_segmentation() -> None:
+ """Smoke: changing min_measure_width can change measure count."""
+ from app.pipeline.structure.ir import Rect, StaffSystem
+ from app.pipeline.structure.l3_measures import segment_measures_on_systems
+
+ img = _synthetic_staff()
+ systems = [StaffSystem(index=0, rect=Rect(20, 35, 380, 95), confidence=0.8)]
+ s1, _ = segment_measures_on_systems(
+ img, systems, params={"min_measure_width": 16.0}
+ )
+ s2, _ = segment_measures_on_systems(
+ img,
+ [StaffSystem(index=0, rect=Rect(20, 35, 380, 95), confidence=0.8)],
+ params={"min_measure_width": 200.0},
+ )
+ # Huge min width should collapse / whole-line fewer splits
+ assert len(s1[0].measures) >= len(s2[0].measures)
diff --git a/desktop/src/components/LayerMetricsPanel.tsx b/desktop/src/components/LayerMetricsPanel.tsx
index f4724f9..d6bfad0 100644
--- a/desktop/src/components/LayerMetricsPanel.tsx
+++ b/desktop/src/components/LayerMetricsPanel.tsx
@@ -9,7 +9,13 @@
*/
import { useMemo, useRef, useState } from "react";
-import { evaluateCompare, evaluateTuneParamUpload } from "../lib/api";
+import {
+ applyLayerParams,
+ evaluateCompare,
+ evaluateTuneLayerUpload,
+ evaluateTuneParamUpload,
+ resetLayerParams,
+} from "../lib/api";
import { cloneStructure, structureToEvalGt } from "../lib/structureGt";
import type {
LayerMetric,
@@ -17,6 +23,7 @@ import type {
SampleMetrics,
Score,
StructureDebug,
+ TuneLayerResult,
TuneParamResult,
} from "../lib/types";
import type { StructureLayerId } from "./StructureLayerPanel";
@@ -45,6 +52,8 @@ export interface LayerMetricsPanelProps {
disabled?: boolean;
onErrorsChange?: (errors: MetricErrorBox[] | null) => void;
onLayerF1Change?: (map: Partial
+ {info} +
+ ) : null} {layerList.length > 0 ? ( <> @@ -471,12 +557,78 @@ export function LayerMetricsPanel({ > ) : null} + {/* #89 full layer auto-tune */} ++ 本层自动调优 · L3(#89) +
++ 用标注 GT 最小化 layer loss;只搜 L3 参数。上游 L1/L2 缓存,不改 GT。 +
++ baseline loss={layerTune.baseline_loss.toFixed(4)} → best= + {layerTune.best_loss.toFixed(4)} + {layerTune.improved ? " · 已改进" : " · 持平"} ·{" "} + {layerTune.n_trials} trials / {layerTune.elapsed_sec}s +
++ best: {JSON.stringify(layerTune.best_params)} +
+- L3 参数扫描 · min_measure_width -
-- 使用上方标注 GT(编辑框即可);L1/L2 只算一次 + 单参数扫描 · min_measure_width(#86)