"""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", ]