Files
massive-cloud/alg/delivery_v1.2/underframe_deflection/single_beam.py
lipan cec0766e67 feat: 升级算法包 delivery_v1.1 → v1.2,新增任务级 CAD 与测距面板
- 算法包替换为 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++ 依赖
2026-06-11 02:30:04 +00:00

341 lines
11 KiB
Python

"""Direct single-beam deflection measurement.
This module is an independent measurement path for the case where the input
ROI **already isolates one longitudinal beam** (left or right). It does not
use the dual-beam tracking algorithm in :mod:`beams` / :mod:`reference`; that
algorithm expects a car-bottom slab containing both beams and cannot resolve a
single, laterally-continuous beam surface.
Geometry convention (CAD / scan native frame):
x = longitudinal (car length)
y = vertical (height, up)
z = lateral (car width)
Deflection is the vertical (y) departure of the beam underside from a straight
reference line along x. For a nominally straight beam the deflection is ~0.
Method:
1. Crop the cloud to the ROI (axis-aligned bbox).
2. Slice along x.
3. Per slice, estimate the underside as the *dominant lower plane* height
(histogram peak of the lower half), robust to hangers / cables / noise
that hang below the real surface and to the upper flange above it.
4. Fit a robust straight reference line through (x, underside).
5. deflection(x) = underside(x) - reference(x).
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from .exceptions import InsufficientPointsError, InvalidPointCloudError
from .roi import normalize_aabb
# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------
@dataclass
class SingleBeamResult:
"""Result of :func:`measure_single_beam`.
All distances are millimetres. ``deflection_mm`` is the underside height
minus the fitted straight reference, sampled at ``x_mm`` (slice centres).
``selected_*`` is the point of maximum absolute deflection on the kept
(spike-rejected) profile.
"""
side: str
available: bool
x_mm: np.ndarray
underside_height_mm: np.ndarray
reference_height_mm: np.ndarray
deflection_mm: np.ndarray
kept_mask: np.ndarray
selected_x_mm: float | None
selected_signed_mm: float | None
selected_absolute_mm: float | None
max_downward_mm: float | None
max_downward_x_mm: float | None
max_upward_mm: float | None
max_upward_x_mm: float | None
rms_mm: float | None
reference_slope_mm_per_m: float | None
n_slices: int
n_kept: int
roi_point_count: int
quality_flags: list[str] = field(default_factory=list)
report: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return self.report
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def measure_single_beam(
points: np.ndarray,
roi_aabb: dict,
*,
side: str = "beam",
config: dict | None = None,
) -> SingleBeamResult:
"""Measure the vertical deflection of a single isolated beam.
Args:
points: ``(N, 3)`` XYZ array, native frame (x=length, y=height,
z=width), millimetres.
roi_aabb: axis-aligned bbox dict with ``x_min/x_max/y_min/y_max/
z_min/z_max`` (mm, native frame). Should isolate one beam.
side: label carried into the result / report (e.g. ``"left"``).
config: optional overrides; see :data:`DEFAULT_SINGLE_BEAM_CONFIG`.
Returns:
:class:`SingleBeamResult`.
Raises:
InvalidPointCloudError: ``points`` is not ``(N, 3+)``.
InsufficientPointsError: too few points in the ROI to measure.
"""
cfg = dict(DEFAULT_SINGLE_BEAM_CONFIG)
if config:
cfg.update(config)
array = np.asarray(points, dtype=np.float64)
if array.ndim != 2 or array.shape[1] < 3:
raise InvalidPointCloudError(
f"Point cloud must be a 2D (N, 3+) array, got shape {array.shape}."
)
array = array[:, :3]
aabb = normalize_aabb(roi_aabb)
mask = (
(array[:, 0] >= aabb["x_min"]) & (array[:, 0] <= aabb["x_max"])
& (array[:, 1] >= aabb["y_min"]) & (array[:, 1] <= aabb["y_max"])
& (array[:, 2] >= aabb["z_min"]) & (array[:, 2] <= aabb["z_max"])
)
beam = array[mask]
if len(beam) < cfg["min_roi_points"]:
raise InsufficientPointsError(
f"ROI for side '{side}' contains {len(beam)} points; need at least "
f"{cfg['min_roi_points']}."
)
x = beam[:, 0]
x_min, x_max = float(x.min()), float(x.max())
slice_mm = float(cfg["slice_mm"])
edges = np.arange(x_min, x_max + slice_mm, slice_mm)
xs: list[float] = []
under: list[float] = []
support: list[int] = []
for i in range(len(edges) - 1):
s = beam[(x >= edges[i]) & (x < edges[i + 1])]
if len(s) < cfg["min_slice_points"]:
continue
height = _dominant_underside_height(s[:, 1], cfg)
if height is None:
continue
xs.append(float(0.5 * (edges[i] + edges[i + 1])))
under.append(height)
support.append(int(np.sum(np.abs(s[:, 1] - height) <= cfg["support_tol_mm"])))
flags: list[str] = []
if len(xs) < cfg["min_valid_slices"]:
return _empty_result(side, len(beam), flags + ["insufficient_valid_slices"])
x_arr = np.asarray(xs, dtype=np.float64)
under_arr = np.asarray(under, dtype=np.float64)
# Reject residual spikes on the underside profile before / via the fit.
slope, intercept, kept = _robust_line_fit(x_arr, under_arr, cfg)
reference = slope * x_arr + intercept
deflection = under_arr - reference
n_kept = int(kept.sum())
if n_kept < cfg["min_valid_slices"]:
return _empty_result(side, len(beam), flags + ["fit_unstable"])
dev_kept = deflection[kept]
x_kept = x_arr[kept]
down_i = int(np.argmin(dev_kept))
up_i = int(np.argmax(dev_kept))
abs_i = int(np.argmax(np.abs(dev_kept)))
selected_signed = float(dev_kept[abs_i])
rms = float(np.sqrt(np.mean(dev_kept ** 2)))
if int((~kept).sum()) > cfg["spike_warn_count"]:
flags.append("many_underside_spikes")
if np.median(support) < cfg["low_support_threshold"]:
flags.append("low_surface_support")
report = {
"mode": "single_beam",
"side": side,
"available": True,
"roi_aabb": aabb,
"roi_point_count": int(len(beam)),
"n_slices": int(len(xs)),
"n_kept": n_kept,
"reference_slope_mm_per_m": float(slope * 1000.0),
"deflection": {
"selected_x_mm": float(x_kept[abs_i]),
"selected_signed_mm": selected_signed,
"selected_absolute_mm": float(abs(selected_signed)),
"max_downward_mm": float(dev_kept[down_i]),
"max_downward_x_mm": float(x_kept[down_i]),
"max_upward_mm": float(dev_kept[up_i]),
"max_upward_x_mm": float(x_kept[up_i]),
"rms_mm": rms,
},
"quality_flags": flags,
"curve": {
"x_mm": x_arr.tolist(),
"underside_height_mm": under_arr.tolist(),
"reference_height_mm": reference.tolist(),
"deflection_mm": deflection.tolist(),
"kept": kept.tolist(),
},
}
return SingleBeamResult(
side=side,
available=True,
x_mm=x_arr,
underside_height_mm=under_arr,
reference_height_mm=reference,
deflection_mm=deflection,
kept_mask=kept,
selected_x_mm=float(x_kept[abs_i]),
selected_signed_mm=selected_signed,
selected_absolute_mm=float(abs(selected_signed)),
max_downward_mm=float(dev_kept[down_i]),
max_downward_x_mm=float(x_kept[down_i]),
max_upward_mm=float(dev_kept[up_i]),
max_upward_x_mm=float(x_kept[up_i]),
rms_mm=rms,
reference_slope_mm_per_m=float(slope * 1000.0),
n_slices=int(len(xs)),
n_kept=n_kept,
roi_point_count=int(len(beam)),
quality_flags=flags,
report=report,
)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
DEFAULT_SINGLE_BEAM_CONFIG: dict = {
"slice_mm": 200.0,
"min_roi_points": 2000,
"min_slice_points": 30,
"min_valid_slices": 8,
# dominant-plane extraction
"hist_bin_mm": 3.0,
"plane_refine_mm": 8.0,
"support_tol_mm": 3.0,
# robust line fit
"fit_iters": 5,
"fit_tukey_c": 4.685,
"fit_reject_sigma": 4.0,
# warnings: flag only when a large *fraction* of slices are spikes
"spike_warn_count": 40,
"low_support_threshold": 30,
}
def _dominant_underside_height(height: np.ndarray, cfg: dict) -> float | None:
"""Height of the dominant plane in the lower half of a slice.
Robust to (a) hangers / cables / noise that float *below* the real
underside and (b) the upper flange / deck plate *above* it. We look only
at the lower half (<= median), take the densest histogram bin (the real
surface has the most points), then refine with the median of nearby
points.
"""
if len(height) < 10:
return None
lo = float(height.min())
mid = float(np.median(height))
if mid - lo < cfg["hist_bin_mm"]:
return float(mid)
lower = height[height <= mid]
bins = np.arange(lo, mid + cfg["hist_bin_mm"], cfg["hist_bin_mm"])
if len(bins) < 2:
return float(mid)
hist, edges = np.histogram(lower, bins=bins)
if hist.sum() == 0:
return None
k = int(np.argmax(hist))
peak = 0.5 * (edges[k] + edges[k + 1])
near = height[np.abs(height - peak) <= cfg["plane_refine_mm"]]
return float(np.median(near)) if len(near) else float(peak)
def _robust_line_fit(
x: np.ndarray, y: np.ndarray, cfg: dict
) -> tuple[float, float, np.ndarray]:
"""Tukey-reweighted straight-line fit; returns (slope, intercept, keep)."""
w = np.ones_like(y)
coef = np.array([0.0, float(np.median(y))])
for _ in range(int(cfg["fit_iters"])):
A = np.vstack([x, np.ones_like(x)]).T
coef = np.linalg.lstsq(A * w[:, None], y * w, rcond=None)[0]
r = y - (coef[0] * x + coef[1])
scale = float(np.median(np.abs(r - np.median(r))) * 1.4826) + 1e-6
u = np.clip(np.abs(r) / (cfg["fit_tukey_c"] * scale), 0.0, 1.0)
w = (1.0 - u ** 2) ** 2
r = y - (coef[0] * x + coef[1])
med = float(np.median(r))
mad = float(np.median(np.abs(r - med)) * 1.4826) + 1e-6
keep = np.abs(r - med) <= cfg["fit_reject_sigma"] * mad
return float(coef[0]), float(coef[1]), keep
def _empty_result(side: str, roi_count: int, flags: list[str]) -> SingleBeamResult:
empty = np.zeros(0, dtype=np.float64)
return SingleBeamResult(
side=side,
available=False,
x_mm=empty,
underside_height_mm=empty,
reference_height_mm=empty,
deflection_mm=empty,
kept_mask=np.zeros(0, dtype=bool),
selected_x_mm=None,
selected_signed_mm=None,
selected_absolute_mm=None,
max_downward_mm=None,
max_downward_x_mm=None,
max_upward_mm=None,
max_upward_x_mm=None,
rms_mm=None,
reference_slope_mm_per_m=None,
n_slices=0,
n_kept=0,
roi_point_count=roi_count,
quality_flags=flags,
report={
"mode": "single_beam",
"side": side,
"available": False,
"roi_point_count": roi_count,
"quality_flags": flags,
},
)
__all__ = ["SingleBeamResult", "measure_single_beam", "DEFAULT_SINGLE_BEAM_CONFIG"]