File size: 7,075 Bytes
2adb41a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | # Master Spatial Synchronization Integration Test between Vision (2.2) and Physics (2.3) (tc.v1 SOTA).
import pytest
import numpy as np
import pandas as pd
from PIL import Image
from ml.core.schema import (
ChainageWindow,
TrackSegment,
SegmentDecision,
CalibratedSignal,
SignalType,
DefectClass,
DecisionType,
)
from ml.core.chainage import ChainageResampler
from ml.features.en13848 import EN13848PhysicsCalculator
from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector
from ml.models.vision.anomaly import PatchCoreAnomalyDetector
from ml.models.vision.detector import YOLOv8DefectDetector
from ml.fusion.rules import PersistenceRuleFusion
from ml.inference.pipeline import EndToEndInferencePipeline
from ml.scripts.generate_trc_telemetry import generate_trc_telemetry_csv
def create_metallic_surface_frame(has_anomaly: bool = False) -> np.ndarray:
"""Creates a 224x224 synthetic steel surface image matching NEU-DET characteristics."""
arr = np.random.normal(120, 8, (224, 224, 3)).astype(np.uint8)
if has_anomaly:
arr[90:130, 80:140] = np.random.normal(30, 5, (40, 60, 3)).astype(np.uint8)
return arr
def test_vision_physics_spatial_sync(monkeypatch):
"""
Test that a 100m run with an injected visual anomaly and a 5mm twist at 50m
lands in the EXACT SAME TrackSegment bin and triggers both models with exact calibration.
"""
# 1. Setup: 100m run @ 20 m/s (5.0 seconds total @ 100 Hz = 501 samples)
sample_rate_hz = 100.0
speed_mps = 20.0
total_time_s = 5.0
timestamps = np.linspace(0, total_time_s, int(total_time_s * sample_rate_hz) + 1)
n_samples = len(timestamps)
# Base track roll
roll_arr = np.zeros(n_samples)
# Inject 5.0mm physical cant step at exactly 50m (idx = 250)
# Cant = G * sin(roll) -> roll = arcsin(5.0 / 1676.0)
start_idx = 250
roll_arr[start_idx:] = np.arcsin(5.0 / 1676.0)
telemetry_df = pd.DataFrame({
"timestamp": timestamps,
"speed_mps": np.full(n_samples, speed_mps),
"roll_rad": roll_arr,
"lat_accel_g": np.random.normal(0, 0.01, n_samples),
"vert_accel_g": np.random.normal(1.0, 0.01, n_samples),
"gauge_mm": np.full(n_samples, 1676.0),
})
# Prepare 5 frames tagged at distances [0, 25, 50, 75, 100]m. Frame at 50m has visual defect
frames = [
(0.0, create_metallic_surface_frame(has_anomaly=False)),
(25.0, create_metallic_surface_frame(has_anomaly=False)),
(50.0, create_metallic_surface_frame(has_anomaly=True)),
(75.0, create_metallic_surface_frame(has_anomaly=False)),
(100.0, create_metallic_surface_frame(has_anomaly=False)),
]
# 2. Resample to Chainage and Segment into 2.0m physical segments
resampler = ChainageResampler(bin_size_m=0.25)
resampled_data = resampler.process(telemetry_df, frames, segment_length_m=2.0)
# 3. Extract the Segment at 50m
target_segments = [s for s in resampled_data.segments if s.chainage_start_m == 50.0]
assert len(target_segments) == 1, "Target segment at 50.0m not found!"
target_segment = target_segments[0]
assert target_segment.chainage_start_m == 50.0
assert target_segment.chainage_end_m == 52.0
assert len(target_segment.frames) >= 1
# 4. Run Physics over continuous resampled stream with full 3m multi-chord context
physics_calc = EN13848PhysicsCalculator(nominal_gauge_mm=1676.0)
all_features = physics_calc.compute(resampled_data.resampled_telemetry, step_m=0.25)
# Slice features for target segment (50.0m to 52.0m)
grid_m = resampled_data.grid_chainage_m
seg_mask = (grid_m >= 50.0) & (grid_m <= 52.0)
seg_features = {
k: (v[seg_mask] if isinstance(v, np.ndarray) else v)
for k, v in all_features.items()
}
detector = EN13848PhysicsThresholdDetector(twist_limit_mm=4.0)
phys_signals = detector.evaluate_features(seg_features)
# 5. Run Vision (Mocked for deterministic novelty test)
patchcore = PatchCoreAnomalyDetector()
def mock_patch_predict(frame: np.ndarray):
return [
CalibratedSignal(
stream_name="patchcore_anomaly",
raw_score=15.8,
calibrated_prob=0.82,
predicted_class=DefectClass.VISUAL_ANOMALY,
is_anomaly=True,
signal_type=SignalType.VISUAL_NOVEL,
threshold=0.50,
bbox=(80.0, 90.0, 140.0, 130.0),
explanation="PatchCore surface anomaly detected",
)
]
monkeypatch.setattr(patchcore, "predict", mock_patch_predict)
vis_signals = patchcore.predict(target_segment.frames[0])
# 6. THE ASSERTIONS (The Spatial & Confidence Sync Proof)
# Proof 1: Both models identified the fault
assert any(s.fired for s in phys_signals), "Physics failed to detect the 5mm twist!"
assert any(s.fired for s in vis_signals), "PatchCore failed to detect the visual anomaly!"
# Proof 2: Both models output the EXACT SAME signal type mapping
phys_sig = [s for s in phys_signals if s.fired][0]
vis_sig = [s for s in vis_signals if s.fired][0]
assert phys_sig.signal_type == SignalType.GEOMETRY_KNOWN
assert vis_sig.signal_type == SignalType.VISUAL_NOVEL
# Proof 3: Both are perfectly calibrated to the [0.0, 1.0] contract
# 5mm twist on a 4mm limit = 5 / (2*4) = 0.625
assert abs(phys_sig.value - 0.625) < 0.01, f"Physics calibration math error: {phys_sig.value}"
assert vis_sig.value > 0.50, "PatchCore sigmoid calibration failed to cross threshold!"
def test_spatial_sync_telemetry_and_vision_binning():
"""
Test that asynchronous 100Hz IMU and 15 FPS Vision streams are correctly
resampled and aligned to the exact same uniform 0.25m distance bins.
"""
resampler = ChainageResampler(step_size_m=0.25)
speed_mps = 20.0
duration_s = 5.0
imu_ts = np.linspace(0, duration_s, 500)
imu_speeds = np.full(500, speed_mps)
roll_rad = np.sin(imu_ts * 2 * np.pi * 0.5) * 0.005
grid_m, resampled = resampler.resample_telemetry_batch(
timestamps_s=imu_ts,
speeds_mps=imu_speeds,
sensor_streams={"roll_rad": roll_rad},
)
diffs = np.diff(grid_m)
assert np.allclose(diffs, 0.25, atol=1e-3)
assert grid_m[0] == 0.0
assert np.isclose(grid_m[-1], 100.0, atol=0.5)
assert len(grid_m) == 401
def test_clean_track_false_positive_suppression(tmp_path):
"""
Test that synthetic TRC telemetry without defects produces zero false alarms.
"""
csv_path = tmp_path / "clean_trc.csv"
generate_trc_telemetry_csv(
output_path=str(csv_path),
length_m=200.0,
defect_mm=0.0,
)
df = pd.read_csv(csv_path)
detector = EN13848PhysicsThresholdDetector()
signals = detector.predict(df)
fired_signals = [s for s in signals if s.fired]
assert len(fired_signals) == 0, f"Expected 0 false alarms on clean track, got {len(fired_signals)}"
|