diff --git a/desktop/src/components/LayerMetricsPanel.tsx b/desktop/src/components/LayerMetricsPanel.tsx index 28c99cb..f4724f9 100644 --- a/desktop/src/components/LayerMetricsPanel.tsx +++ b/desktop/src/components/LayerMetricsPanel.tsx @@ -1,10 +1,16 @@ /** * Layered accuracy metrics panel (#86). - * Load GT → compare with current structure/score → F1 badges + error overlay toggle. + * + * GT sources: + * 1. Import Score/layer JSON file + * 2. **Use current edit-layer boxes as annotation GT** (no external tool) + * + * Then compare against auto recognition (or latest re-run) and run L3 param sweep. */ import { useMemo, useRef, useState } from "react"; import { evaluateCompare, evaluateTuneParamUpload } from "../lib/api"; +import { cloneStructure, structureToEvalGt } from "../lib/structureGt"; import type { LayerMetric, MetricErrorBox, @@ -32,7 +38,10 @@ function fmt(n: number | undefined): string { export interface LayerMetricsPanelProps { file: File | null; score: Score | null; + /** Current structure (includes user edits if any). */ structure: StructureDebug | null | undefined; + /** Last auto-recognition structure (before edits), for pred baseline. */ + autoStructure?: StructureDebug | null; disabled?: boolean; onErrorsChange?: (errors: MetricErrorBox[] | null) => void; onLayerF1Change?: (map: Partial>) => void; @@ -42,6 +51,7 @@ export function LayerMetricsPanel({ file, score, structure, + autoStructure = null, disabled, onErrorsChange, onLayerF1Change, @@ -49,6 +59,9 @@ export function LayerMetricsPanel({ const gtInputRef = useRef(null); const [gt, setGt] = useState | null>(null); const [gtName, setGtName] = useState(null); + const [gtSource, setGtSource] = useState<"file" | "edit" | null>(null); + /** Frozen auto structure at the moment GT was saved from edits (optional pred). */ + const [frozenPred, setFrozenPred] = useState(null); const [metrics, setMetrics] = useState(null); const [tune, setTune] = useState(null); const [busy, setBusy] = useState(false); @@ -57,6 +70,8 @@ export function LayerMetricsPanel({ const [tuneStart, setTuneStart] = useState(16); const [tuneStop, setTuneStop] = useState(64); const [tuneStep, setTuneStep] = useState(8); + /** Compare target: current structure vs frozen auto pred */ + const [predMode, setPredMode] = useState<"current" | "auto">("current"); const layerList = useMemo(() => { if (!metrics) return [] as LayerMetric[]; @@ -68,28 +83,129 @@ export function LayerMetricsPanel({ return [...ordered, ...rest]; }, [metrics]); + const editBoxCount = structure?.items?.length ?? 0; + + const applyGt = ( + data: Record, + name: string, + source: "file" | "edit", + predSnap: StructureDebug | null, + ) => { + setGt(data); + setGtName(name); + setGtSource(source); + setFrozenPred(predSnap); + setMetrics(null); + setTune(null); + onErrorsChange?.(null); + }; + const onLoadGt = async (f: File) => { setError(null); try { const text = await f.text(); const data = JSON.parse(text) as Record; - setGt(data); - setGtName(f.name); - setMetrics(null); - setTune(null); - onErrorsChange?.(null); + applyGt(data, f.name, "file", cloneStructure(autoStructure ?? structure)); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }; + /** Save current edit boxes as geometry GT (annotation). */ + const onSaveEditAsGt = () => { + setError(null); + if (!structure?.items?.length) { + setError("没有可标注的结构框。请先识别,再在编辑模式中调整各层框。"); + return; + } + const label = file?.name + ? `edit-gt:${file.name}` + : `edit-gt:${new Date().toISOString().slice(0, 19)}`; + const data = structureToEvalGt(structure, score, { label }); + // Pred baseline = auto recognition (if any), else previous structure snapshot + const predSnap = + cloneStructure(autoStructure) || cloneStructure(structure); + applyGt(data, label, "edit", predSnap); + setPredMode(autoStructure ? "auto" : "current"); + }; + + /** Update GT from latest edits without clearing metrics preference. */ + const onRefreshGtFromEdit = () => { + if (!structure?.items?.length) { + setError("当前没有结构框"); + return; + } + const label = gtName?.startsWith("edit-gt:") + ? gtName + : file?.name + ? `edit-gt:${file.name}` + : "edit-gt:session"; + const data = structureToEvalGt(structure, score, { label }); + setGt(data); + setGtName(label); + setGtSource("edit"); + setError(null); + setMetrics(null); + }; + + const onExportGt = () => { + if (!gt) return; + const blob = new Blob([JSON.stringify(gt, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${(gtName || "annotation-gt").replace(/[^\w.-]+/g, "_")}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const resolvePred = (): { + structure: StructureDebug | null; + score: Score | null; + } => { + if (predMode === "auto" && frozenPred) { + return { structure: frozenPred, score: null }; + } + if (predMode === "auto" && autoStructure) { + return { structure: autoStructure, score: null }; + } + return { + structure: structure ?? null, + score, + }; + }; + + const pushErrors = (m: SampleMetrics, show: boolean) => { + const f1map: Partial> = {}; + for (const L of ["L1", "L2", "L3", "L4", "L5"] as StructureLayerId[]) { + if (m.layers[L]) f1map[L] = m.layers[L].f1; + } + onLayerF1Change?.(f1map); + if (!show) { + onErrorsChange?.(null); + return; + } + const errs: MetricErrorBox[] = []; + for (const lm of Object.values(m.layers)) { + for (const e of lm.errors || []) { + if (e.kind === "fp" || e.kind === "fn" || e.kind === "tp") { + errs.push(e); + } + } + } + onErrorsChange?.(errs); + }; + const onCompare = async () => { if (!gt) { - setError("请先导入 GT JSON"); + setError("请先「将编辑框存为标注」或导入 GT JSON"); return; } - if (!score && !structure) { - setError("请先完成识别(需要 score 或 structure)"); + const pred = resolvePred(); + if (!pred.score && !pred.structure?.items?.length) { + setError("没有可对比的预测结果(识别结果或自动框)"); return; } setBusy(true); @@ -98,25 +214,12 @@ export function LayerMetricsPanel({ const m = await evaluateCompare({ sample_id: gtName || file?.name || "sample", gt, - score, - structure: structure ?? null, + score: pred.score, + structure: pred.structure, include_errors: true, }); setMetrics(m); - const f1map: Partial> = {}; - for (const L of ["L1", "L2", "L3", "L4", "L5"] as StructureLayerId[]) { - if (m.layers[L]) f1map[L] = m.layers[L].f1; - } - onLayerF1Change?.(f1map); - const errs: MetricErrorBox[] = []; - for (const lm of Object.values(m.layers)) { - for (const e of lm.errors || []) { - if (e.kind === "fp" || e.kind === "fn" || e.kind === "tp") { - errs.push(e); - } - } - } - onErrorsChange?.(showErrors ? errs : null); + pushErrors(m, showErrors); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -126,21 +229,16 @@ export function LayerMetricsPanel({ const onToggleErrors = (on: boolean) => { setShowErrors(on); - if (!on) { + if (!metrics) { onErrorsChange?.(null); return; } - if (!metrics) return; - const errs: MetricErrorBox[] = []; - for (const lm of Object.values(metrics.layers)) { - for (const e of lm.errors || []) errs.push(e); - } - onErrorsChange?.(errs); + pushErrors(metrics, on); }; const onTune = async () => { if (!file || !gt) { - setError("参数扫描需要当前图片 + GT"); + setError("参数扫描需要当前图片 + 标注 GT(可用编辑框生成)"); return; } setBusy(true); @@ -187,12 +285,41 @@ export function LayerMetricsPanel({ 分层精度评测 · #86

- 导入 Score/分层 GT → 与当前识别结果对比 · 支持 L3 参数扫描 + 无标注工具时:识别 → 编辑各层框 →{" "} + 存为标注 GT → 对比 / 扫参

+ {/* Workflow hint */} +
    +
  1. 识别曲谱,打开结构层「编辑模式」改准 L2/L3/L4…
  2. +
  3. 点「将编辑框存为标注」冻结为 GT
  4. +
  5. + 对比「自动框」看改动量,或重识别后对比「当前结果」做调参 +
  6. +
+
+ + {gtSource === "edit" ? ( + + ) : null} gtInputRef.current?.click()} className="rounded-md border border-white/15 px-2.5 py-1 text-[11px] text-slate-200 hover:bg-white/5 disabled:opacity-40" > - 导入 GT JSON + 导入 GT 文件 + {gt ? ( + + ) : null} +
+ + {gtName ? ( +

+ 标注 GT: {gtName} + {gtSource === "edit" ? " · 来自编辑框" : " · 来自文件"} + {(() => { + const measures = ( + gt?.layers as { L3?: { measures?: unknown[] } } | undefined + )?.L3?.measures; + return Array.isArray(measures) ? ` · L3×${measures.length}` : ""; + })()} +

+ ) : ( +

+ 尚未设置标注(编辑改框后点「将编辑框存为标注」) +

+ )} + +
+ 对比对象 +
- - {gtName ? ( -

- GT: {gtName} -

- ) : ( -

- 未加载 GT(可用 samples/eval/gt/*.json) -

- )} +

+ {predMode === "auto" + ? "用标注 GT 衡量「自动识别」有多准(编辑改动量)" + : "用标注 GT 衡量「当前结果」(可先按某层重识别后再比)"} +

{error ? (

@@ -307,11 +471,13 @@ export function LayerMetricsPanel({ ) : null} - {/* L3 param sweep */}

L3 参数扫描 · min_measure_width

+

+ 使用上方标注 GT(编辑框即可);L1/L2 只算一次 +

- ) : ( -

- 需图片 + GT;缓存 L1/L2,仅重跑 L3 -

- )} + ) : null}
); diff --git a/desktop/src/lib/structureGt.ts b/desktop/src/lib/structureGt.ts new file mode 100644 index 0000000..7881fdb --- /dev/null +++ b/desktop/src/lib/structureGt.ts @@ -0,0 +1,168 @@ +/** + * Build evaluation GT from structure-layer boxes (#86). + * Users edit L1–L5 in the UI; those boxes become geometry ground truth + * without a separate annotation tool. + */ + +import type { Score, StructureBox, StructureDebug } from "./types"; + +function boxOf(it: StructureBox): { + x1: number; + y1: number; + x2: number; + y2: number; +} | null { + const b = it.box; + if ( + b == null || + b.x1 == null || + b.y1 == null || + b.x2 == null || + b.y2 == null + ) { + return null; + } + return { x1: b.x1, y1: b.y1, x2: b.x2, y2: b.y2 }; +} + +/** + * Convert StructureDebug (+ optional Score) into GT JSON accepted by + * ``/v1/evaluation/compare`` and param tuner. + */ +export function structureToEvalGt( + structure: StructureDebug, + score?: Score | null, + opts?: { label?: string }, +): Record { + const items = structure.items ?? []; + const l1 = items.filter((it) => it.layer === "L1"); + const l2 = items.filter((it) => it.layer === "L2"); + const l3 = items.filter((it) => it.layer === "L3"); + const l4 = items.filter((it) => it.layer === "L4"); + + const regions = l1 + .map((it) => { + const box = boxOf(it); + if (!box) return null; + const role = + it.kind === "title" || + it.kind === "score" || + it.kind === "meta" || + it.kind === "key_time" + ? it.kind + : it.label?.includes("title") + ? "title" + : it.label?.includes("score") || it.kind === "region" + ? "score" + : it.kind || "region"; + return { role, kind: role, box, label: it.label }; + }) + .filter(Boolean); + + const systems = l2 + .map((it, i) => { + const box = boxOf(it); + if (!box) return null; + return { box, label: it.label || `sys${i}`, kind: "system" }; + }) + .filter(Boolean); + + const measures = l3 + .map((it, i) => { + const box = boxOf(it); + if (!box) return null; + return { box, label: it.label || `m${i + 1}`, kind: "measure" }; + }) + .filter(Boolean); + + const notes = l4 + .map((it, i) => { + const box = boxOf(it); + if (!box) return null; + const kind = + it.kind === "chord" || it.kind === "lyric" ? it.kind : "pitch"; + return { + box, + kind, + label: it.label || `n${i}`, + pitch: it.pitch ?? undefined, + }; + }) + .filter(Boolean); + + const barlines = (structure.barlines ?? []) + .map((b) => Number(b.x)) + .filter((x) => Number.isFinite(x)); + + // Pitch sequence from L5 boxes if present, else from score + const l5 = items.filter((it) => it.layer === "L5" && it.pitch); + let pitch_sequence: string[] = []; + if (l5.length) { + pitch_sequence = l5 + .map((it) => String(it.pitch)) + .filter((p) => p && p !== "null"); + } else if (score?.parts?.[0]?.measures) { + for (const m of score.parts[0].measures) { + for (const n of m.notes || []) { + if (n.is_rest || !n.pitch) continue; + let tag = String(n.pitch); + if (n.accidental === "sharp") tag += "#"; + if (n.accidental === "flat") tag += "b"; + pitch_sequence.push(tag); + } + } + } + + const measure_count = + measures.length || + score?.parts?.[0]?.measures?.length || + Number(structure.summary?.n_measures) || + 0; + + const system_count = + systems.length || Number(structure.summary?.n_systems) || undefined; + + const gt: Record = { + schema_version: "0.1", + title: score?.title || opts?.label || "annotation-from-edit", + key: score?.key || "C", + time_signature: score?.time_signature || "4/4", + parts: score?.parts + ? JSON.parse(JSON.stringify(score.parts)) + : [ + { + id: "P1", + name: "melody", + measures: Array.from({ length: Math.max(measure_count, 1) }, (_, i) => ({ + number: i + 1, + notes: [], + })), + }, + ], + extra: { + eval: { + pitch_sequence, + measure_count, + ...(system_count != null ? { system_count } : {}), + source: "ui_structure_edit", + label: opts?.label || null, + }, + }, + layers: { + L1: { regions }, + L2: { systems }, + L3: { measures, barlines }, + L4: { notes }, + }, + }; + + return gt; +} + +/** Snapshot structure JSON (deep clone) for pred/GT freeze. */ +export function cloneStructure( + structure: StructureDebug | null | undefined, +): StructureDebug | null { + if (!structure) return null; + return JSON.parse(JSON.stringify(structure)) as StructureDebug; +} diff --git a/desktop/src/pages/RecognizePage.tsx b/desktop/src/pages/RecognizePage.tsx index 323d644..c4f1914 100644 --- a/desktop/src/pages/RecognizePage.tsx +++ b/desktop/src/pages/RecognizePage.tsx @@ -1454,6 +1454,7 @@ export function RecognizePage() { file={file} score={score} structure={structureDraft ?? result?.structure} + autoStructure={result?.structure ?? null} disabled={loading || structureRerunning} onErrorsChange={setMetricErrors} onLayerF1Change={setLayerF1} diff --git a/docs/layer-metrics.md b/docs/layer-metrics.md index 03ac032..254c0c0 100644 --- a/docs/layer-metrics.md +++ b/docs/layer-metrics.md @@ -55,24 +55,29 @@ core\.venv\Scripts\python.exe scripts\eval-layers.py --run --engine mock --out r ## Desktop(#86) -- 侧栏 **分层精度评测**:导入 GT → 对比当前识别 → F1 徽章 / P·R·F1 表 -- 结构层按钮显示 F1 色标(绿≥0.8 / 黄≥0.5 / 红) -- 原稿 **误差叠图**:绿 TP / 红 FP / 黄 FN -- **L3 min_measure_width** 参数扫描 + 折线,标最优值 +- 侧栏 **分层精度评测** + - **将编辑框存为标注**:无外部标注工具时,在结构层编辑模式改准 L1–L5 后一键冻结为几何 GT + - 对比对象:**自动框**(存标注时的识别)或 **当前结果**(重识别后) + - 导出/导入 GT;L3 `min_measure_width` 扫描 +- 结构层 F1 色标;原稿误差叠图(绿 TP / 红 FP / 黄 FN) + +### 无标注工具时的调参流程 + +1. 识别(structure 管线) +2. 结构「编辑模式」改准目标层(尤其 L3) +3. **将编辑框存为标注** +4. 对比「自动框」看自动识别误差;或重识别后对比「当前结果」 +5. **L3 参数扫描** 用同一 GT 找最优阈值 ## 模块 ```text -core/app/evaluation/ - types.py metrics.py gt_loader.py extract.py compare.py batch.py param_tuner.py -core/app/api/v1/evaluation.py -scripts/eval-layers.py +core/app/evaluation/ … +desktop/src/lib/structureGt.ts # 编辑框 → GT JSON desktop/src/components/LayerMetricsPanel.tsx ``` ## 后续 -- 更多可扫参数(投影阈值、旋律带比例) -- 误差传导 L3→L4 连线高亮 -- 批量评测结果直接在 UI 导入/对比基线 +- 更多可扫参数;误差传导 L3→L4 连线高亮