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
7 changes: 6 additions & 1 deletion core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ ENPU_RECOGNIZE_ENGINE=mock pytest -q
| `ENPU_MAX_UPLOAD_BYTES` | `20971520` | 最大上传 |
| `ENPU_CORS_ORIGINS` | `*` | CORS |
| `ENPU_PIPELINE_MODE` | `legacy` | `legacy`:整页 OCR→parse;`structure`:L1–L5 结构优先(#58) |
| `ENPU_STRUCTURE_L1L3_ENGINE` | `rule` | `rule` \| `learned`(#104;learned 需 torch + 权重) |
| `ENPU_L1L3_WEIGHTS` | | `best.pt` / `layout_net.pt` 路径 |
| `ENPU_L1L3_DEVICE` | `cpu` | `cpu` / `cuda` |
| `ENPU_L1L3_FALLBACK` | `rule` | 失败时回退 rule 或 `none` |

## 流水线说明

Expand Down Expand Up @@ -135,7 +139,8 @@ python scripts/export_layout_gt.py --project path\to\song.enpu.json --out sample
```

模块:`app/layout_gt/`(export + validate)。
规范:[l1-l3-data-spec.md](../docs/train/l1-l3-data-spec.md) · 模型方案:[l1-l3-model-design.md](../docs/train/l1-l3-model-design.md)(#94)。
规范:[l1-l3-data-spec.md](../docs/train/l1-l3-data-spec.md) · 模型方案:[l1-l3-model-design.md](../docs/train/l1-l3-model-design.md)(#94)。
推理接入:[core-inference.md](../docs/train/core-inference.md)(#104:`structure/learned/`)。

## Sidecar 打包(可选,Issue #8)

Expand Down
8 changes: 8 additions & 0 deletions core/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ class Settings(BaseSettings):
# legacy = OCR-first (current default); structure = L1–L5 geometry-first (#58)
pipeline_mode: str = "legacy"

# structure L1–L3 engine (#104): rule (OpenCV) | learned (train weights)
structure_l1l3_engine: str = "rule"
# path to layout_net.pt / best.pt (required when engine=learned)
l1l3_weights: str = ""
l1l3_device: str = "cpu"
# on load/infer failure: rule | none (raise)
l1l3_fallback: str = "rule"

# OCR / preprocess
ocr_lang: str = "ch"
ocr_use_angle_cls: bool = True
Expand Down
12 changes: 12 additions & 0 deletions core/app/pipeline/structure/learned/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Learned L1–L3 layout inference for structure pipeline (#104).

Does **not** import the train/ app. Weights format: ``enpu_layout_net_v0``
or train ``best.pt`` / ``last.pt`` (state_dict + cfg).
"""

from app.pipeline.structure.learned.infer_l1l3 import (
LearnedL1L3Error,
run_learned_l2_l3,
)

__all__ = ["LearnedL1L3Error", "run_learned_l2_l3"]
96 changes: 96 additions & 0 deletions core/app/pipeline/structure/learned/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Convert learned systems + splits → PageLayout skeleton (#104)."""

from __future__ import annotations

from typing import Any

from app.pipeline.structure.ir import (
PageLayout,
PageRegion,
Rect,
RegionRole,
SplitLine,
StaffSystem,
)
from app.pipeline.structure.splits import normalize_splits, splits_to_measures


def systems_splits_to_page_layout(
*,
width: int,
height: int,
system_boxes: list[dict[str, float]],
splits_per_system: list[list[float]],
score_region: Rect | None = None,
title_box: Rect | None = None,
key_time_box: Rect | None = None,
warnings: list[str] | None = None,
engine_meta: dict[str, Any] | None = None,
min_gap: float = 6.0,
min_measure_width: float = 4.0,
) -> PageLayout:
"""Build PageLayout with L1 regions + L2 systems + L3 splits/measures."""
regions: list[PageRegion] = []
if title_box is not None:
regions.append(
PageRegion(role=RegionRole.title, rect=title_box, confidence=0.6)
)
if key_time_box is not None:
regions.append(
PageRegion(role=RegionRole.key_time, rect=key_time_box, confidence=0.55)
)
if score_region is None:
score_region = Rect(0, 0, float(width), float(height))
regions.append(
PageRegion(role=RegionRole.score, rect=score_region, confidence=0.75)
)

systems: list[StaffSystem] = []
for i, box in enumerate(system_boxes):
rect = Rect(
float(box["x1"]),
float(box["y1"]),
float(box["x2"]),
float(box["y2"]),
)
raw_xs = splits_per_system[i] if i < len(splits_per_system) else []
raw_splits = [
SplitLine(x=float(x), split_id=f"s{i}-{j}", source="detect")
for j, x in enumerate(raw_xs)
]
splits = normalize_splits(
raw_splits,
x_left=rect.x1,
x_right=rect.x2,
min_gap=min_gap,
default_source="detect",
)
measures = splits_to_measures(
x_left=rect.x1,
x_right=rect.x2,
y_top=rect.y1,
y_bot=rect.y2,
splits=splits,
min_measure_width=min_measure_width,
measure_source="l3_split",
)
systems.append(
StaffSystem(
index=i,
rect=rect,
measures=measures,
barline_xs=[s.x for s in splits],
splits=splits,
confidence=0.7,
extra={"source": "learned_l1l3", "engine": "learned"},
)
)

return PageLayout(
width=width,
height=height,
regions=regions,
systems=systems,
warnings=list(warnings or []),
debug={"l1l3_engine": "learned", **(engine_meta or {})},
)
175 changes: 175 additions & 0 deletions core/app/pipeline/structure/learned/infer_l1l3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Run learned L2+L3 on a BGR image → PageLayout skeleton (#104).

L1 uses optional rule regions or full-page score ROI (MVP: hybrid).
"""

from __future__ import annotations

import logging
from typing import Any

import numpy as np

from app.config import Settings
from app.pipeline.structure.ir import PageLayout, PageRegion, Rect, RegionRole
from app.pipeline.structure.learned.adapter import systems_splits_to_page_layout
from app.pipeline.structure.learned.loader import (
WeightsLoadError,
get_cached_layout_model,
)
from app.pipeline.structure.learned.postprocess import (
bgr_to_model_tensor,
l2_heat_to_system_boxes,
l3_heat_to_split_xs,
)

logger = logging.getLogger(__name__)


class LearnedL1L3Error(Exception):
"""Inference failure (caller may fallback to rule)."""


def run_learned_l2_l3(
image_bgr: np.ndarray,
*,
settings: Settings,
l1_regions: list[PageRegion] | None = None,
) -> PageLayout:
"""Infer L2 systems + L3 splits; attach L1 regions if provided."""
if image_bgr is None or image_bgr.size == 0:
raise LearnedL1L3Error("empty image")

weights = (settings.l1l3_weights or "").strip()
if not weights:
raise LearnedL1L3Error(
"ENPU_L1L3_WEIGHTS is empty; set path to train export "
"(layout_net.pt / best.pt)"
)

device = (settings.l1l3_device or "cpu").strip() or "cpu"
try:
model, wmeta = get_cached_layout_model(weights, device)
except WeightsLoadError as e:
raise LearnedL1L3Error(str(e)) from e

try:
import torch
except ImportError as e:
raise LearnedL1L3Error("torch not installed") from e

h, w = image_bgr.shape[:2]
cfg = model.cfg
page_h, page_w = cfg.page_h, cfg.page_w
row_h, row_w = cfg.row_h, cfg.row_w

page_t = bgr_to_model_tensor(image_bgr, out_h=page_h, out_w=page_w).to(device)
warnings: list[str] = [
"l1l3_engine=learned (#104)",
f"l1l3_weights={wmeta.get('path')}",
f"l1l3_format={wmeta.get('format')}",
]

with torch.no_grad():
out = model(page=page_t)
if "l2_logits" not in out:
raise LearnedL1L3Error("model has no L2 head / tasks")
l2_prob = torch.sigmoid(out["l2_logits"])[0].detach().cpu().numpy()

system_boxes = l2_heat_to_system_boxes(
l2_prob,
orig_h=h,
orig_w=w,
page_h=page_h,
page_w=page_w,
)
if not system_boxes:
raise LearnedL1L3Error("L2 produced no systems")

# L3 per system crop
splits_per: list[list[float]] = [[] for _ in system_boxes]
row_tensors: list = []
row_indices: list[int] = []
crop_meta: list[dict[str, float]] = []

for i, box in enumerate(system_boxes):
x1 = int(max(0, box["x1"]))
y1 = int(max(0, box["y1"]))
x2 = int(min(w, box["x2"]))
y2 = int(min(h, box["y2"]))
if x2 <= x1 + 2 or y2 <= y1 + 2:
continue
pad_x = int(0.02 * (x2 - x1))
pad_y = int(0.05 * (y2 - y1))
cx1 = max(0, x1 - pad_x)
cy1 = max(0, y1 - pad_y)
cx2 = min(w, x2 + pad_x)
cy2 = min(h, y2 + pad_y)
crop = image_bgr[cy1:cy2, cx1:cx2]
row_tensors.append(bgr_to_model_tensor(crop, out_h=row_h, out_w=row_w)[0])
row_indices.append(i)
crop_meta.append(
{
"x_left": float(cx1),
"x_right": float(cx2),
"y1": float(cy1),
"y2": float(cy2),
}
)

if row_tensors and "l3" in cfg.tasks:
rows_batch = torch.stack(row_tensors, dim=0).to(device)
with torch.no_grad():
l3_out = model(rows=rows_batch)
l3_logits = l3_out.get("l3_logits")
if l3_logits is not None:
probs = torch.sigmoid(l3_logits).detach().cpu().numpy()
for j, meta in enumerate(crop_meta):
xs = l3_heat_to_split_xs(
probs[j],
x_left=meta["x_left"],
x_right=meta["x_right"],
)
splits_per[row_indices[j]] = xs
else:
warnings.append("L3 skipped (no crops or task disabled)")

# L1 regions
score_rect = None
title_box = None
key_time_box = None
if l1_regions:
for r in l1_regions:
if r.role == RegionRole.score:
score_rect = r.rect
elif r.role == RegionRole.title:
title_box = r.rect
elif r.role == RegionRole.key_time:
key_time_box = r.rect
if score_rect is None:
# score band covering all systems
y1 = min(b["y1"] for b in system_boxes)
y2 = max(b["y2"] for b in system_boxes)
score_rect = Rect(0, max(0.0, y1 - 0.02 * h), float(w), min(float(h), y2 + 0.02 * h))

warnings.append(
f"L2: learned {len(system_boxes)} system(s); "
f"L3 splits total={sum(len(s) for s in splits_per)}"
)

layout = systems_splits_to_page_layout(
width=w,
height=h,
system_boxes=system_boxes,
splits_per_system=splits_per,
score_region=score_rect,
title_box=title_box,
key_time_box=key_time_box,
warnings=warnings,
engine_meta={
"weights": wmeta,
"n_systems": len(system_boxes),
"n_splits": sum(len(s) for s in splits_per),
},
)
return layout
Loading
Loading