Files
lipan 29f1d45d66 feat: 算法升级、整车配准集成与性能分析文档
算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3
适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新
后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动
前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复
文档: 新增5份算法性能分析报告
2026-06-25 08:22:20 +00:00

404 lines
14 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
# Inspection-report fields (theory = 0; error = measured value).
item_name: str = "纵向梁到直线的垂直偏差(挠度)"
nominal_mm: float = 0.0
tolerance_mm: float | None = None
measured_mm: float | None = None
error_mm: float | None = None
verdict: str | None = None # "合格" / "不合格"
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 | None = None,
tolerance_mm: float | None = None,
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: ``"left"`` / ``"right"`` label. If None, inferred from the ROI's
lateral (z) centre: looking along +x, ``z > 0`` is left,
``z < 0`` is right.
tolerance_mm: pass/fail tolerance. If None, defaults by side
(:data:`DEFAULT_TOLERANCE_MM`: left 7 mm, right 12 mm).
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)
# Looking along +x: z>0 is left, z<0 is right (per project convention).
if side is None:
z_center = 0.5 * (aabb["z_min"] + aabb["z_max"])
side = "left" if z_center >= 0 else "right"
if tolerance_mm is None:
tolerance_mm = DEFAULT_TOLERANCE_MM.get(side)
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"], tolerance_mm)
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"], tolerance_mm)
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")
# Inspection verdict. Theory (nominal) = 0, so error == measured value.
measured_mm = float(abs(selected_signed))
error_mm = measured_mm # |measured - 0|
verdict = None
if tolerance_mm is not None:
verdict = "合格" if error_mm <= float(tolerance_mm) else "不合格"
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),
"inspection": {
"item_name": "纵向梁到直线的垂直偏差(挠度)",
"nominal_mm": 0.0,
"tolerance_mm": None if tolerance_mm is None else float(tolerance_mm),
"measured_mm": measured_mm,
"error_mm": error_mm,
"verdict": verdict,
},
"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)),
tolerance_mm=None if tolerance_mm is None else float(tolerance_mm),
measured_mm=measured_mm,
error_mm=error_mm,
verdict=verdict,
quality_flags=flags,
report=report,
)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
# Pass/fail tolerance by side (mm). Looking along +x: z>0 left, z<0 right.
DEFAULT_TOLERANCE_MM: dict = {
"left": 7.0,
"right": 12.0,
}
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], tolerance_mm: float | None = None
) -> 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,
tolerance_mm=None if tolerance_mm is None else float(tolerance_mm),
measured_mm=None,
error_mm=None,
verdict=None,
quality_flags=flags,
report={
"mode": "single_beam",
"side": side,
"available": False,
"roi_point_count": roi_count,
"inspection": {
"item_name": "纵向梁到直线的垂直偏差(挠度)",
"nominal_mm": 0.0,
"tolerance_mm": None if tolerance_mm is None else float(tolerance_mm),
"measured_mm": None,
"error_mm": None,
"verdict": None,
},
"quality_flags": flags,
},
)
__all__ = [
"SingleBeamResult",
"measure_single_beam",
"DEFAULT_SINGLE_BEAM_CONFIG",
"DEFAULT_TOLERANCE_MM",
]