feat: 算法升级、整车配准集成与性能分析文档
算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3 适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新 后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动 前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复 文档: 新增5份算法性能分析报告
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""Configuration loading and merging.
|
||||
|
||||
Configs are nested dicts. The single source of truth for defaults is
|
||||
``configs/default.yaml`` (shipped alongside the package); ``DEFAULT_CONFIG``
|
||||
mirrors its structure for in-memory programmatic overrides.
|
||||
|
||||
Public functions:
|
||||
|
||||
- :func:`load_config` — load a YAML file or accept an already-built dict.
|
||||
- :func:`load_profile` — load ``configs/profiles/<name>.yaml`` and merge
|
||||
on top of the defaults.
|
||||
- :func:`deep_merge` — recursive dict merge (override wins).
|
||||
- :func:`apply_dotted` — set a value via ``"sampling.spacing_mm"``.
|
||||
- :func:`resolve_auto` — fill in fields whose value is the literal
|
||||
string ``"auto"`` (currently: ``plane_fit.ransac_inlier_thresh_mm``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
_PKG_ROOT = Path(__file__).resolve().parent
|
||||
_DEFAULT_YAML = _PKG_ROOT.parent / "configs" / "default.yaml"
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"{path}: expected a mapping at the top level")
|
||||
return data
|
||||
|
||||
|
||||
def _load_default() -> dict[str, Any]:
|
||||
if _DEFAULT_YAML.exists():
|
||||
return _load_yaml(_DEFAULT_YAML)
|
||||
# Package may have been installed without configs/. Fall back to a minimal
|
||||
# in-code default so library use without the YAML still works.
|
||||
return _IN_CODE_FALLBACK
|
||||
|
||||
|
||||
# Loaded lazily; recomputed on each call to keep tests deterministic.
|
||||
def default_config() -> dict[str, Any]:
|
||||
"""Return a fresh copy of the default configuration."""
|
||||
return copy.deepcopy(_load_default())
|
||||
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] # populated below for convenience
|
||||
|
||||
|
||||
def load_config(
|
||||
source: str | Path | dict[str, Any] | None,
|
||||
*,
|
||||
profile: str = "auto",
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve the final configuration to use.
|
||||
|
||||
The merge order is::
|
||||
|
||||
default.yaml < profile (if any) < source (path or dict)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source : path, dict, or None
|
||||
If ``None``, only defaults (and the profile) are used.
|
||||
profile : str
|
||||
``"auto"`` / ``"default"`` skip the profile step. Any other name
|
||||
is looked up at ``configs/profiles/<name>.yaml``.
|
||||
"""
|
||||
cfg = default_config()
|
||||
|
||||
if profile not in (None, "auto", "default", "custom"):
|
||||
prof_path = _PKG_ROOT.parent / "configs" / "profiles" / f"{profile}.yaml"
|
||||
if not prof_path.exists():
|
||||
raise FileNotFoundError(f"profile '{profile}' not found at {prof_path}")
|
||||
cfg = deep_merge(cfg, _load_yaml(prof_path))
|
||||
|
||||
if source is None:
|
||||
pass
|
||||
elif isinstance(source, dict):
|
||||
cfg = deep_merge(cfg, source)
|
||||
else:
|
||||
cfg = deep_merge(cfg, _load_yaml(Path(source)))
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
def load_profile(name: str) -> dict[str, Any]:
|
||||
"""Load a profile YAML directly; useful for tests."""
|
||||
path = _PKG_ROOT.parent / "configs" / "profiles" / f"{name}.yaml"
|
||||
return _load_yaml(path)
|
||||
|
||||
|
||||
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursive dict merge. ``override`` wins on leaf collisions."""
|
||||
out = copy.deepcopy(base)
|
||||
for key, val in override.items():
|
||||
if (
|
||||
key in out
|
||||
and isinstance(out[key], dict)
|
||||
and isinstance(val, dict)
|
||||
):
|
||||
out[key] = deep_merge(out[key], val)
|
||||
else:
|
||||
out[key] = copy.deepcopy(val)
|
||||
return out
|
||||
|
||||
|
||||
def apply_dotted(cfg: dict[str, Any], dotted_key: str, value: Any) -> dict[str, Any]:
|
||||
"""Set ``cfg["a"]["b"]["c"] = value`` from ``"a.b.c"``. Returns ``cfg``."""
|
||||
parts = dotted_key.split(".")
|
||||
node = cfg
|
||||
for p in parts[:-1]:
|
||||
node = node.setdefault(p, {})
|
||||
if not isinstance(node, dict):
|
||||
raise TypeError(f"cannot apply '{dotted_key}': '{p}' is not a dict")
|
||||
node[parts[-1]] = value
|
||||
return cfg
|
||||
|
||||
|
||||
def resolve_auto(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Replace literal ``"auto"`` markers with concrete values.
|
||||
|
||||
Currently:
|
||||
|
||||
- ``plane_fit.ransac_inlier_thresh_mm == "auto"`` → ``3 * noise.sigma_mm``
|
||||
(or 0.5 mm if ``noise.sigma_mm == 0``).
|
||||
"""
|
||||
out = copy.deepcopy(cfg)
|
||||
pf = out.get("plane_fit", {})
|
||||
if pf.get("ransac_inlier_thresh_mm", None) == "auto":
|
||||
sigma = float(out.get("noise", {}).get("sigma_mm", 0.0) or 0.0)
|
||||
pf["ransac_inlier_thresh_mm"] = max(3.0 * sigma, 0.5)
|
||||
out["plane_fit"] = pf
|
||||
return out
|
||||
|
||||
|
||||
# Minimal in-code fallback used only when the YAML is missing.
|
||||
_IN_CODE_FALLBACK: dict[str, Any] = {
|
||||
"schema_version": "1.0",
|
||||
"cad": {
|
||||
"linear_deflection_mm": 0.5,
|
||||
"angular_deflection_rad": 0.1,
|
||||
"locating_hole_radius_mm": [4.0, 12.0],
|
||||
"central_hole_radius_min_mm": 20.0,
|
||||
"rectangle_aspect_tol": 0.15,
|
||||
},
|
||||
"sampling": {"method": "poisson_disk", "spacing_mm": 1.0, "num_points": 100000, "seed": 42},
|
||||
"noise": {"enabled": True, "sigma_mm": 0.5, "mode": "isotropic", "seed": 43},
|
||||
"transform": {
|
||||
"enabled": True,
|
||||
"rotation_deg": 3.0,
|
||||
"rotation_axis": "random",
|
||||
"translation_mm": 5.0,
|
||||
"translation_dir": "random",
|
||||
"seed": 44,
|
||||
},
|
||||
"denoise": {"steps": []},
|
||||
"registration": {"method": "kabsch_four_holes", "pre_align": "pca_centroid"},
|
||||
"detection": {
|
||||
"panel_normal_seed": "from_cad",
|
||||
"grid_pixel_mm": 0.5,
|
||||
"panel_min_area_ratio": 0.4,
|
||||
"hole_min_area_mm2": 30.0,
|
||||
"hole_max_area_mm2": 800.0,
|
||||
"circle_fit": {"method": "algebraic_then_lsq", "inlier_residual_mm": 0.5},
|
||||
"select_top_k_by_radius_match": 4,
|
||||
"rectangle_aspect_tol": 0.20,
|
||||
},
|
||||
"roi": {
|
||||
"hole_dilate_mm": 3.0,
|
||||
"central_hole_dilate_mm": 5.0,
|
||||
"edge_buffer_mm": 5.0,
|
||||
"chamfer_normal_dot_min": 0.85,
|
||||
"outlier_z_score": 3.0,
|
||||
},
|
||||
"plane_fit": {
|
||||
"method": "ransac_svd",
|
||||
"ransac_iterations": 200,
|
||||
"ransac_inlier_thresh_mm": "auto",
|
||||
"min_inlier_ratio": 0.6,
|
||||
"enforce_nx_positive": True,
|
||||
},
|
||||
"metrics": {"right_axis_sign": 1, "angle_clamp_deg": 0.0},
|
||||
"artifacts": {
|
||||
"save_intermediate": True,
|
||||
"ply_format": "binary",
|
||||
"image_dpi": 150,
|
||||
"heatmap_colormap": "viridis",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG = copy.deepcopy(_IN_CODE_FALLBACK)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CONFIG",
|
||||
"default_config",
|
||||
"load_config",
|
||||
"load_profile",
|
||||
"deep_merge",
|
||||
"apply_dotted",
|
||||
"resolve_auto",
|
||||
]
|
||||
Reference in New Issue
Block a user