- 算法包替换为 delivery_v1.2(coupler_panel 新增 autotune/layer_select,underframe_deflection 新增 single_beam 管线),删除 delivery_v1.1 - manifests/adapters 的 PYTHONPATH 与版本说明同步更新到 v1.2 - 新增任务级 CAD 文件支持(task_cad_files 表、上传/读取路由、TaskManager 全选与加载显示) - 新增 train-measurement 自定义前端面板(YAML 编辑器 + 可视化 ROI 配置 + 3D 取点) - PointCloudViewer 支持 PLY 叠加层渲染与 ROI 拾取 - Dockerfile 改用 Miniforge 安装 pythonocc-core,修复 libgomp/libstdc++ 依赖
210 lines
6.5 KiB
Python
210 lines
6.5 KiB
Python
"""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",
|
|
]
|