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>) => void; + /** After applying best params, parent can re-run structure from L2 (#89). */ + onRequestRerunFromL2?: () => void; } export function LayerMetricsPanel({ @@ -55,6 +64,7 @@ export function LayerMetricsPanel({ disabled, onErrorsChange, onLayerF1Change, + onRequestRerunFromL2, }: LayerMetricsPanelProps) { const gtInputRef = useRef(null); const [gt, setGt] = useState | null>(null); @@ -64,12 +74,15 @@ export function LayerMetricsPanel({ const [frozenPred, setFrozenPred] = useState(null); const [metrics, setMetrics] = useState(null); const [tune, setTune] = useState(null); + const [layerTune, setLayerTune] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [info, setInfo] = useState(null); const [showErrors, setShowErrors] = useState(true); const [tuneStart, setTuneStart] = useState(16); const [tuneStop, setTuneStop] = useState(64); const [tuneStep, setTuneStep] = useState(8); + const [tuneTrials, setTuneTrials] = useState(30); /** Compare target: current structure vs frozen auto pred */ const [predMode, setPredMode] = useState<"current" | "auto">("current"); @@ -243,6 +256,7 @@ export function LayerMetricsPanel({ } setBusy(true); setError(null); + setInfo(null); try { const r = await evaluateTuneParamUpload(file, { gt, @@ -261,6 +275,73 @@ export function LayerMetricsPanel({ } }; + /** Full L3 auto-tune loop (#89): random search → optional apply. */ + const onTuneLayer = async (applyBest: boolean) => { + if (!file || !gt) { + setError("本层调优需要当前图片 + 标注 GT(请先「将编辑框存为标注」)"); + return; + } + setBusy(true); + setError(null); + setInfo(null); + try { + const r = await evaluateTuneLayerUpload(file, { + gt, + layer: "l3", + max_trials: tuneTrials, + max_seconds: 90, + seed: 42, + method: "random", + apply_best: applyBest, + }); + setLayerTune(r); + setInfo( + applyBest + ? `已应用最优参数(loss ${r.baseline_loss.toFixed(3)} → ${r.best_loss.toFixed(3)})。请点结构层「按 L2 重识别下层」。` + : `调优完成:loss ${r.baseline_loss.toFixed(3)} → ${r.best_loss.toFixed(3)}(${r.improved ? "有改进" : "持平"},${r.n_trials} trials)`, + ); + if (applyBest && onRequestRerunFromL2) { + onRequestRerunFromL2(); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const onApplyBest = async () => { + if (!layerTune?.best_params) { + setError("请先完成本层自动调优"); + return; + } + setBusy(true); + setError(null); + try { + await applyLayerParams("l3", layerTune.best_params, { merge: false }); + setInfo("最优参数已写入 core。请「按 L2 框重识别下层」刷新预测。"); + onRequestRerunFromL2?.(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const onResetParams = async () => { + setBusy(true); + setError(null); + try { + await resetLayerParams("l3"); + setInfo("已恢复 L3 默认参数"); + setLayerTune(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + const spark = useMemo(() => { if (!tune?.points?.length) return null; const vals = tune.points.map((p) => p.f1); @@ -410,6 +491,11 @@ export function LayerMetricsPanel({ {error}

) : null} + {info ? ( +

+ {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。 +

+
+ + + + + +
+ {layerTune ? ( +
+

+ 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)} +

+
+ ) : null} +
+

- L3 参数扫描 · min_measure_width -

-

- 使用上方标注 GT(编辑框即可);L1/L2 只算一次 + 单参数扫描 · min_measure_width(#86)