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 */}
+
+ - 识别曲谱,打开结构层「编辑模式」改准 L2/L3/L4…
+ - 点「将编辑框存为标注」冻结为 GT
+ -
+ 对比「自动框」看改动量,或重识别后对比「当前结果」做调参
+
+
+
+
+ {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 连线高亮