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:
403
alg/delivery_v1.2.1/underframe_deflection/single_beam.py
Normal file
403
alg/delivery_v1.2.1/underframe_deflection/single_beam.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user