Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions configs/tune/default_params.yaml
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions configs/tune/space_l3.yaml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions configs/tune/space_l4.yaml
Original file line number Diff line number Diff line change
@@ -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
121 changes: 121 additions & 0 deletions core/app/api/v1/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down Expand Up @@ -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()
45 changes: 35 additions & 10 deletions core/app/pipeline/structure/l3_measures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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"]
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 19 additions & 3 deletions core/app/pipeline/structure/l4_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
21 changes: 21 additions & 0 deletions core/app/schemas/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
15 changes: 15 additions & 0 deletions core/app/tuning/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading
Loading