算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3 适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新 后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动 前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复 文档: 新增5份算法性能分析报告
404 lines
14 KiB
Python
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",
|
|
]
|