eho69 commited on
Commit
d3e4c8a
·
verified ·
1 Parent(s): 6d5af5c

mesh building

Browse files
geometric_validator.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import cv2
3
+ import numpy as np
4
+ from typing import List, Tuple, Optional, Dict
5
+ from dataclasses import dataclass
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ import threading
8
+ import warnings
9
+
10
+ # Lazy import for RANSAC (avoids startup delay)
11
+ _ransac_lock = threading.Lock()
12
+ _RANSACRegressor = None
13
+
14
+ def _get_ransac():
15
+ global _RANSACRegressor
16
+ if _RANSACRegressor is None:
17
+ with _ransac_lock:
18
+ if _RANSACRegressor is None:
19
+ from sklearn.linear_model import RANSACRegressor
20
+ _RANSACRegressor = RANSACRegressor
21
+ return _RANSACRegressor
22
+
23
+ warnings.filterwarnings('ignore')
24
+
25
+
26
+ @dataclass
27
+ class PhysicalDimensions:
28
+ """Physical dimensions of saddles in millimeters"""
29
+ height_min: float = 55.0
30
+ height_max: float = 95.0
31
+ width_min: float = 40.0
32
+ width_max: float = 75.0
33
+ spacing_min: float = 150.0
34
+ spacing_max: float = 250.0
35
+ spacing_avg: float = 200.0
36
+ aspect_ratio_min: float = 1.3
37
+ aspect_ratio_max: float = 1.5
38
+
39
+ def validate_dimensions(self, height_px: float, width_px: float,
40
+ spacing_px: float, px_to_mm: float) -> Tuple[bool, str]:
41
+ height_mm = height_px * px_to_mm
42
+ width_mm = width_px * px_to_mm
43
+ spacing_mm = spacing_px * px_to_mm
44
+
45
+ if not (self.height_min <= height_mm <= self.height_max):
46
+ return False, f"Height {height_mm:.1f}mm out of range"
47
+ if not (self.width_min <= width_mm <= self.width_max):
48
+ return False, f"Width {width_mm:.1f}mm out of range"
49
+ if spacing_px > 0 and not (self.spacing_min <= spacing_mm <= self.spacing_max):
50
+ return False, f"Spacing {spacing_mm:.1f}mm out of range"
51
+
52
+ aspect_ratio = height_px / width_px if width_px > 0 else 0
53
+ if not (self.aspect_ratio_min <= aspect_ratio <= self.aspect_ratio_max):
54
+ return False, f"Aspect ratio {aspect_ratio:.2f} invalid"
55
+ return True, "Dimensions valid"
56
+
57
+
58
+ class PixelToMMCalibrator:
59
+ """Camera calibration for pixel-to-mm conversion"""
60
+
61
+ def __init__(self, physical_dims: PhysicalDimensions):
62
+ self.physical_dims = physical_dims
63
+ self.px_to_mm_ratio: Optional[float] = None
64
+ self.mm_to_px_ratio: Optional[float] = None
65
+ self.calibrated = False
66
+
67
+ def calibrate_from_saddles(self, saddles: List) -> Tuple[bool, str]:
68
+ """Calibrate using spacing between two most confident saddles"""
69
+ if len(saddles) < 2:
70
+ return False, "Need at least 2 saddles"
71
+
72
+ sorted_saddles = sorted(saddles, key=lambda s: s.confidence, reverse=True)
73
+ s1, s2 = sorted_saddles[0], sorted_saddles[1]
74
+
75
+ spacing_px = np.linalg.norm(np.array(s1.center) - np.array(s2.center))
76
+ if spacing_px < 10:
77
+ return False, f"Spacing too small: {spacing_px:.1f}px"
78
+
79
+ spacing_mm = self.physical_dims.spacing_avg
80
+ self.mm_to_px_ratio = spacing_px / spacing_mm
81
+ self.px_to_mm_ratio = spacing_mm / spacing_px
82
+
83
+ avg_height_mm = (self.physical_dims.height_min + self.physical_dims.height_max) / 2
84
+ implied_height_px = avg_height_mm * self.mm_to_px_ratio
85
+
86
+ if implied_height_px < 10 or implied_height_px > 500:
87
+ return False, f"Scale anomaly: height {implied_height_px:.1f}px"
88
+
89
+ self.calibrated = True
90
+ return True, "Calibration successful"
91
+
92
+ def px_to_mm(self, pixels: float) -> float:
93
+ return pixels * self.px_to_mm_ratio if self.calibrated else 0.0
94
+
95
+ def mm_to_px(self, millimeters: float) -> float:
96
+ return millimeters * self.mm_to_px_ratio if self.calibrated else 0.0
97
+
98
+
99
+ class RANSACLineDetector:
100
+ """Use RANSAC to robustly fit a line through saddle centers - FAST"""
101
+
102
+ def __init__(self, residual_threshold: float = 10.0):
103
+ self.residual_threshold = residual_threshold
104
+ self.line_params: Optional[Tuple[float, float]] = None
105
+ self.inliers: Optional[np.ndarray] = None
106
+
107
+ def fit_line(self, points: np.ndarray) -> Tuple[bool, str]:
108
+ if len(points) < 2:
109
+ return False, "Need at least 2 points"
110
+
111
+ try:
112
+ X = points[:, 0].reshape(-1, 1)
113
+ y = points[:, 1]
114
+
115
+ RANSACRegressor = _get_ransac() # Lazy import
116
+ ransac = RANSACRegressor(residual_threshold=self.residual_threshold,
117
+ random_state=42, max_trials=50) # Reduced trials for speed
118
+ ransac.fit(X, y)
119
+
120
+ self.line_params = (ransac.estimator_.coef_[0], ransac.estimator_.intercept_)
121
+ self.inliers = ransac.inlier_mask_
122
+
123
+ outlier_count = len(points) - np.sum(self.inliers)
124
+ if outlier_count > 0:
125
+ return False, f"RANSAC: {outlier_count} outliers"
126
+ return True, "All points are inliers"
127
+ except Exception as e:
128
+ return False, f"RANSAC failed: {e}"
129
+
130
+ def get_distance_from_line(self, point: Tuple[float, float]) -> float:
131
+ if self.line_params is None:
132
+ return 999.0
133
+ slope, intercept = self.line_params
134
+ x, y = point
135
+ return abs(slope * x - y + intercept) / np.sqrt(slope**2 + 1)
136
+
137
+
138
+ class SaddleMeshProjector:
139
+ """Project a 1x4 grid (mesh) onto the image"""
140
+
141
+ def __init__(self, physical_dims: PhysicalDimensions, calibrator: PixelToMMCalibrator):
142
+ self.physical_dims = physical_dims
143
+ self.calibrator = calibrator
144
+ self.mesh_centers: Optional[List[Tuple[int, int]]] = None
145
+ self.mesh_bboxes: Optional[List[Tuple[int, int, int, int]]] = None
146
+
147
+ def create_mesh_from_detections(self, saddles: List) -> Tuple[bool, str]:
148
+ if len(saddles) < 2 or not self.calibrator.calibrated:
149
+ return False, "Need 2+ saddles and calibration"
150
+
151
+ centers = np.array([s.center for s in saddles])
152
+ spacing_px = self.calibrator.mm_to_px(self.physical_dims.spacing_avg)
153
+
154
+ x_coords = centers[:, 0]
155
+ start_point = centers[np.argmin(x_coords)]
156
+ end_point = centers[np.argmax(x_coords)]
157
+
158
+ direction = end_point - start_point
159
+ direction_norm = np.linalg.norm(direction)
160
+ if direction_norm < 1:
161
+ return False, "Start and end too close"
162
+
163
+ direction_unit = direction / direction_norm
164
+
165
+ self.mesh_centers = []
166
+ for i in range(4):
167
+ point = start_point + direction_unit * (i * spacing_px)
168
+ self.mesh_centers.append((int(point[0]), int(point[1])))
169
+
170
+ height_mm = (self.physical_dims.height_min + self.physical_dims.height_max) / 2
171
+ width_mm = (self.physical_dims.width_min + self.physical_dims.width_max) / 2
172
+ height_px = int(self.calibrator.mm_to_px(height_mm))
173
+ width_px = int(self.calibrator.mm_to_px(width_mm))
174
+
175
+ self.mesh_bboxes = []
176
+ for cx, cy in self.mesh_centers:
177
+ self.mesh_bboxes.append((cx - width_px // 2, cy - height_px // 2, width_px, height_px))
178
+
179
+ return True, "Mesh created"
180
+
181
+ def force_crop_at_mesh(self, image: np.ndarray, mesh_id: int) -> Optional[np.ndarray]:
182
+ if self.mesh_bboxes is None or mesh_id >= len(self.mesh_bboxes):
183
+ return None
184
+
185
+ x, y, w, h = self.mesh_bboxes[mesh_id]
186
+ h_img, w_img = image.shape[:2]
187
+
188
+ x = max(0, min(x, w_img - 1))
189
+ y = max(0, min(y, h_img - 1))
190
+ x_end = max(x + 1, min(x + w, w_img))
191
+ y_end = max(y + 1, min(y + h, h_img))
192
+
193
+ return image[y:y_end, x:x_end].copy()
194
+
195
+
196
+ class OptimizedGeometricValidator:
197
+ """
198
+ Optimized Geometric Constraint Validator
199
+
200
+ Validates:
201
+ 1. RANSAC collinearity
202
+ 2. Physical dimensions (55-95mm × 40-75mm)
203
+ 3. Semicircular surface + middle arc (via SaddleStructureValidator)
204
+ 4. Mesh-based inference
205
+ 5. Automatic calibration
206
+ """
207
+
208
+ def __init__(self, expected_count: int = 4):
209
+ self.expected_count = expected_count
210
+ self.physical_dims = PhysicalDimensions()
211
+ self.calibrator = PixelToMMCalibrator(self.physical_dims)
212
+ self.ransac = RANSACLineDetector(residual_threshold=15.0)
213
+ self.mesh_projector = SaddleMeshProjector(self.physical_dims, self.calibrator)
214
+ self.reference_positions = None
215
+ self.reference_distances = {}
216
+
217
+ # Optional structure validator
218
+ self.structure_validator = None
219
+ try:
220
+ from saddle_structure_validator import SaddleStructureValidator
221
+ self.structure_validator = SaddleStructureValidator(
222
+ arc_center_tolerance=0.05,
223
+ min_arc_length_ratio=0.6,
224
+ min_structure_confidence=0.65
225
+ )
226
+ except ImportError:
227
+ pass
228
+
229
+ def set_reference_positions(self, saddles: List) -> bool:
230
+ if len(saddles) != self.expected_count:
231
+ return False
232
+
233
+ success, _ = self.calibrator.calibrate_from_saddles(saddles)
234
+ if not success:
235
+ return False
236
+
237
+ self.mesh_projector.create_mesh_from_detections(saddles)
238
+
239
+ centers = [s.center for s in saddles]
240
+ centroid = (int(np.mean([c[0] for c in centers])), int(np.mean([c[1] for c in centers])))
241
+ self.reference_positions = [(c[0] - centroid[0], c[1] - centroid[1]) for c in centers]
242
+
243
+ self.reference_distances = {}
244
+ for i in range(len(saddles)):
245
+ for j in range(i+1, len(saddles)):
246
+ dist = np.linalg.norm(np.array(saddles[i].center) - np.array(saddles[j].center))
247
+ self.reference_distances[(i, j)] = {'px': dist, 'mm': self.calibrator.px_to_mm(dist)}
248
+
249
+ return True
250
+
251
+ def validate_and_refine(self, saddles: List, image: np.ndarray) -> Tuple[List, Dict]:
252
+ """
253
+ PRODUCTION-LEVEL validation with parallel processing for instant results.
254
+ Structure validation, RANSAC, and mesh creation run concurrently.
255
+ """
256
+ report = {'initial_count': len(saddles), 'warnings': [], 'inferred_count': 0}
257
+
258
+ # Early calibration (fast - ~1ms)
259
+ if not self.calibrator.calibrated and len(saddles) >= 2:
260
+ self.calibrator.calibrate_from_saddles(saddles)
261
+
262
+ # PARALLEL: Structure validation + RANSAC + Mesh creation
263
+ with ThreadPoolExecutor(max_workers=3) as executor:
264
+ futures = {}
265
+
266
+ # Task 1: Parallel structure validation for each saddle
267
+ if self.structure_validator and saddles:
268
+ def validate_structure(saddle):
269
+ if hasattr(saddle, 'crop') and saddle.crop is not None and saddle.crop.size > 0:
270
+ return saddle, self.structure_validator.validate_structure(saddle.crop)
271
+ return saddle, None
272
+
273
+ for saddle in saddles:
274
+ futures[executor.submit(validate_structure, saddle)] = 'structure'
275
+
276
+ # Task 2: RANSAC line fitting (runs parallel)
277
+ if len(saddles) >= 2:
278
+ centers = np.array([s.center for s in saddles])
279
+ futures[executor.submit(self.ransac.fit_line, centers)] = 'ransac'
280
+
281
+ # Task 3: Mesh projection (runs parallel)
282
+ if len(saddles) >= 2 and self.calibrator.calibrated:
283
+ futures[executor.submit(self.mesh_projector.create_mesh_from_detections, saddles)] = 'mesh'
284
+
285
+ # Collect results as they complete (non-blocking)
286
+ for future in as_completed(futures):
287
+ task_type = futures[future]
288
+ try:
289
+ result = future.result()
290
+ if task_type == 'structure' and result[1] is not None:
291
+ saddle, structure = result
292
+ if hasattr(saddle, '__dict__'):
293
+ saddle.structure = structure
294
+ except Exception:
295
+ pass # Continue on failure - don't block
296
+
297
+ # Refine bboxes for aspect ratio (fast - in-place)
298
+ refined_saddles = self._refine_bboxes(saddles, image)
299
+
300
+ # Infer missing saddles using mesh
301
+ if len(refined_saddles) < self.expected_count and self.mesh_projector.mesh_bboxes:
302
+ refined_saddles = self._infer_missing(refined_saddles, image, report)
303
+
304
+ refined_saddles.sort(key=lambda s: s.id)
305
+ report['final_count'] = len(refined_saddles)
306
+ return refined_saddles, report
307
+
308
+ def _refine_bboxes(self, saddles: List, image: np.ndarray) -> List:
309
+ """Refine bounding boxes to match physical aspect ratio"""
310
+ for saddle in saddles:
311
+ x, y, w, h = saddle.bbox
312
+ aspect_ratio = h / w if w > 0 else 0
313
+
314
+ if not (self.physical_dims.aspect_ratio_min <= aspect_ratio <= self.physical_dims.aspect_ratio_max):
315
+ target_aspect = (self.physical_dims.aspect_ratio_min + self.physical_dims.aspect_ratio_max) / 2
316
+ cx, cy = saddle.center
317
+
318
+ if self.calibrator.calibrated:
319
+ avg_height = (self.physical_dims.height_min + self.physical_dims.height_max) / 2
320
+ avg_width = (self.physical_dims.width_min + self.physical_dims.width_max) / 2
321
+ new_h = int(self.calibrator.mm_to_px(avg_height))
322
+ new_w = int(self.calibrator.mm_to_px(avg_width))
323
+ else:
324
+ new_h, new_w = h, int(h / target_aspect)
325
+
326
+ new_x = max(0, min(cx - new_w // 2, image.shape[1] - new_w))
327
+ new_y = max(0, min(cy - new_h // 2, image.shape[0] - new_h))
328
+
329
+ saddle.bbox = (new_x, new_y, new_w, new_h)
330
+ saddle.crop = image[new_y:new_y+new_h, new_x:new_x+new_w].copy()
331
+ saddle.area = new_w * new_h
332
+
333
+ return saddles
334
+
335
+ def _infer_missing(self, saddles: List, image: np.ndarray, report: Dict) -> List:
336
+ """Infer missing saddles using mesh projection"""
337
+ from inspector_engine import SaddleROI # Import here to avoid circular
338
+
339
+ detected_ids = {s.id for s in saddles}
340
+
341
+ for expected_id in range(self.expected_count):
342
+ if expected_id not in detected_ids:
343
+ crop = self.mesh_projector.force_crop_at_mesh(image, expected_id)
344
+ if crop is not None and crop.size > 0:
345
+ cx, cy = self.mesh_projector.mesh_centers[expected_id]
346
+ x, y, w, h = self.mesh_projector.mesh_bboxes[expected_id]
347
+
348
+ inferred = SaddleROI(
349
+ id=expected_id, bbox=(x, y, w, h), crop=crop,
350
+ center=(cx, cy), area=w * h, angle=0.0, confidence=0.0
351
+ )
352
+
353
+ if self.structure_validator:
354
+ inferred.structure = self.structure_validator.validate_structure(crop)
355
+
356
+ saddles.append(inferred)
357
+ report['inferred_count'] += 1
358
+
359
+ return saddles
360
+
361
+ def validate_geometry(self, saddles: List, tolerance: float = 0.10) -> Tuple[bool, str]:
362
+ """Validate geometry with RANSAC and physical constraints"""
363
+ if len(saddles) != self.expected_count:
364
+ return False, f"Expected {self.expected_count}, got {len(saddles)}"
365
+
366
+ # RANSAC check
367
+ centers = np.array([s.center for s in saddles])
368
+ success, msg = self.ransac.fit_line(centers)
369
+ if not success:
370
+ return False, msg
371
+
372
+ for i, saddle in enumerate(saddles):
373
+ if self.ransac.inliers is not None and not self.ransac.inliers[i]:
374
+ return False, f"S{saddle.id} is RANSAC outlier"
375
+ if self.ransac.get_distance_from_line(saddle.center) > 20.0:
376
+ return False, f"S{saddle.id} too far from line"
377
+
378
+ # Physical dimensions check
379
+ if self.calibrator.calibrated:
380
+ for saddle in saddles:
381
+ x, y, w, h = saddle.bbox
382
+ height_mm = self.calibrator.px_to_mm(h)
383
+ width_mm = self.calibrator.px_to_mm(w)
384
+
385
+ if not (self.physical_dims.height_min <= height_mm <= self.physical_dims.height_max):
386
+ return False, f"S{saddle.id} height {height_mm:.1f}mm invalid"
387
+ if not (self.physical_dims.width_min <= width_mm <= self.physical_dims.width_max):
388
+ return False, f"S{saddle.id} width {width_mm:.1f}mm invalid"
389
+
390
+ # Spacing check
391
+ sorted_saddles = sorted(saddles, key=lambda s: s.id)
392
+ for i in range(len(sorted_saddles) - 1):
393
+ s0, s1 = sorted_saddles[i], sorted_saddles[i + 1]
394
+ dist_px = np.linalg.norm(np.array(s1.center) - np.array(s0.center))
395
+
396
+ if self.calibrator.calibrated:
397
+ dist_mm = self.calibrator.px_to_mm(dist_px)
398
+ if not (self.physical_dims.spacing_min <= dist_mm <= self.physical_dims.spacing_max):
399
+ return False, f"Spacing S{s0.id}-S{s1.id}: {dist_mm:.1f}mm invalid"
400
+
401
+ return True, "All geometry checks passed"
inspector_engine.py CHANGED
The diff for this file is too large to render. See raw diff
 
saddle_structure_validator.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Enhanced Saddle Structure Validator
3
+ ====================================
4
+
5
+ Validates saddles based on physical structure:
6
+ 1. Semicircular top surface
7
+ 2. Vertical arc dividing the semicircle exactly in the middle
8
+ """
9
+
10
+ import cv2
11
+ import numpy as np
12
+ from typing import Tuple, Optional
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass
17
+ class SaddleStructure:
18
+ """Detected saddle structure components"""
19
+ has_semicircle: bool
20
+ semicircle_center: Optional[Tuple[int, int]]
21
+ semicircle_radius: Optional[int]
22
+ has_middle_arc: bool
23
+ arc_center_x: Optional[int]
24
+ arc_alignment_score: float
25
+ structure_confidence: float
26
+
27
+ def is_valid_saddle(self, min_confidence: float = 0.7) -> bool:
28
+ return (self.has_semicircle and
29
+ self.has_middle_arc and
30
+ self.structure_confidence >= min_confidence)
31
+
32
+
33
+ class SaddleStructureValidator:
34
+ """
35
+ Validates saddle structure using geometric constraints:
36
+ 1. Semicircular Surface Detection (Hough Circle + contour fallback)
37
+ 2. Middle Arc Detection (Sobel + Hough Lines + intensity profile)
38
+ 3. Alignment Validation (arc-semicircle center ±5% tolerance)
39
+ """
40
+
41
+ def __init__(self,
42
+ arc_center_tolerance: float = 0.05,
43
+ min_arc_length_ratio: float = 0.6,
44
+ min_structure_confidence: float = 0.7):
45
+ self.arc_center_tolerance = arc_center_tolerance
46
+ self.min_arc_length_ratio = min_arc_length_ratio
47
+ self.min_structure_confidence = min_structure_confidence
48
+
49
+ def validate_structure(self, crop: np.ndarray) -> SaddleStructure:
50
+ """Main validation function - returns SaddleStructure"""
51
+ if crop is None or crop.size == 0:
52
+ return self._invalid_structure()
53
+
54
+ h, w = crop.shape[:2]
55
+ if h < 20 or w < 20:
56
+ return self._invalid_structure()
57
+
58
+ gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) if len(crop.shape) == 3 else crop.copy()
59
+
60
+ # Step 1: Detect semicircular surface
61
+ semi_detected, semi_center, semi_radius = self._detect_semicircular_surface(gray)
62
+
63
+ # Step 2: Detect middle arc
64
+ arc_detected, arc_center_x, arc_angle, arc_length = self._detect_middle_arc(gray)
65
+
66
+ # Step 3: Validate alignment
67
+ alignment_score = 0.0
68
+ if semi_detected and arc_detected and semi_center is not None:
69
+ expected_center_x = semi_center[0]
70
+ center_deviation = abs(arc_center_x - expected_center_x)
71
+ max_deviation = w * self.arc_center_tolerance
72
+
73
+ alignment_score = max(0.0, 1.0 - (center_deviation / max_deviation)) if center_deviation <= max_deviation else 0.0
74
+
75
+ if arc_length / h < self.min_arc_length_ratio:
76
+ alignment_score *= 0.5
77
+ if arc_angle is not None and abs(90 - abs(arc_angle)) > 15:
78
+ alignment_score *= 0.5
79
+
80
+ # Step 4: Calculate confidence
81
+ if semi_detected and arc_detected:
82
+ structure_confidence = alignment_score
83
+ elif semi_detected:
84
+ structure_confidence = 0.3
85
+ elif arc_detected:
86
+ structure_confidence = 0.2
87
+ else:
88
+ structure_confidence = 0.0
89
+
90
+ return SaddleStructure(
91
+ has_semicircle=semi_detected,
92
+ semicircle_center=semi_center,
93
+ semicircle_radius=semi_radius,
94
+ has_middle_arc=arc_detected,
95
+ arc_center_x=arc_center_x,
96
+ arc_alignment_score=alignment_score,
97
+ structure_confidence=structure_confidence
98
+ )
99
+
100
+ def _detect_semicircular_surface(self, gray: np.ndarray) -> Tuple[bool, Optional[Tuple[int, int]], Optional[int]]:
101
+ """Detect semicircular surface on top portion of saddle"""
102
+ h, w = gray.shape
103
+ upper_region = gray[:int(h * 0.6), :]
104
+
105
+ blurred = cv2.GaussianBlur(upper_region, (5, 5), 1.5)
106
+ edges = cv2.Canny(blurred, 30, 100)
107
+
108
+ circles = cv2.HoughCircles(
109
+ edges, cv2.HOUGH_GRADIENT, dp=1.0, minDist=w // 2,
110
+ param1=50, param2=30, minRadius=int(w * 0.3), maxRadius=int(w * 0.8)
111
+ )
112
+
113
+ if circles is None or len(circles[0]) == 0:
114
+ return self._detect_semicircle_from_contours(upper_region, w, h)
115
+
116
+ circles = np.round(circles[0, :]).astype(int)
117
+ cx, cy, r = sorted(circles, key=lambda c: c[2], reverse=True)[0]
118
+
119
+ if abs(cx - w // 2) > w * 0.2 or cy > h * 0.5 or r < w * 0.3 or r > w * 0.8:
120
+ return False, None, None
121
+
122
+ return True, (cx, cy), r
123
+
124
+ def _detect_semicircle_from_contours(self, upper_region: np.ndarray, full_w: int, full_h: int):
125
+ """Fallback: Detect semicircle using contours"""
126
+ contours, _ = cv2.findContours(cv2.Canny(upper_region, 30, 100), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
127
+ if not contours:
128
+ return False, None, None
129
+
130
+ largest = max(contours, key=cv2.contourArea)
131
+ if len(largest) < 5:
132
+ return False, None, None
133
+
134
+ (cx, cy), radius = cv2.minEnclosingCircle(largest)
135
+ cx, cy, radius = int(cx), int(cy), int(radius)
136
+
137
+ if abs(cx - full_w // 2) > full_w * 0.2:
138
+ return False, None, None
139
+ return True, (cx, cy), radius
140
+
141
+ def _detect_middle_arc(self, gray: np.ndarray) -> Tuple[bool, Optional[int], Optional[float], float]:
142
+ """Detect vertical arc dividing the saddle in the middle"""
143
+ h, w = gray.shape
144
+
145
+ # Method 1: Vertical edge detection
146
+ arc_x, arc_length = self._detect_arc_from_edges(gray)
147
+ if arc_x is not None:
148
+ return True, arc_x, 90.0, arc_length
149
+
150
+ # Method 2: Hough Line detection
151
+ arc_detected, arc_x, angle, length = self._detect_arc_from_lines(gray)
152
+ if arc_detected:
153
+ return True, arc_x, angle, length
154
+
155
+ # Method 3: Intensity profile
156
+ arc_x = self._detect_arc_from_intensity(gray)
157
+ if arc_x is not None:
158
+ return True, arc_x, 90.0, h * 0.8
159
+
160
+ return False, None, None, 0.0
161
+
162
+ def _detect_arc_from_edges(self, gray: np.ndarray) -> Tuple[Optional[int], float]:
163
+ """Detect arc using vertical edge detection (Sobel X)"""
164
+ h, w = gray.shape
165
+ sobelx = np.abs(cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3))
166
+
167
+ center_start, center_end = int(w * 0.3), int(w * 0.7)
168
+ center_region = sobelx[:, center_start:center_end]
169
+ vertical_sums = np.sum(center_region, axis=0)
170
+
171
+ if len(vertical_sums) == 0:
172
+ return None, 0.0
173
+
174
+ peak_idx = np.argmax(vertical_sums)
175
+ if vertical_sums[peak_idx] < np.mean(vertical_sums) + np.std(vertical_sums):
176
+ return None, 0.0
177
+
178
+ arc_x = center_start + peak_idx
179
+ column = sobelx[:, arc_x]
180
+ arc_length = float(np.sum(column > np.percentile(column, 70)))
181
+ return arc_x, arc_length
182
+
183
+ def _detect_arc_from_lines(self, gray: np.ndarray) -> Tuple[bool, Optional[int], Optional[float], float]:
184
+ """Detect arc using Hough Line Transform"""
185
+ h, w = gray.shape
186
+ edges = cv2.Canny(gray, 50, 150)
187
+
188
+ lines = cv2.HoughLinesP(edges, rho=1, theta=np.pi/180, threshold=int(h * 0.3),
189
+ minLineLength=int(h * 0.4), maxLineGap=int(h * 0.2))
190
+
191
+ if lines is None:
192
+ return False, None, None, 0.0
193
+
194
+ vertical_lines = []
195
+ for line in lines:
196
+ x1, y1, x2, y2 = line[0]
197
+ angle = 90.0 if x2 == x1 else abs(np.degrees(np.arctan2(y2 - y1, x2 - x1)))
198
+
199
+ if abs(angle - 90) < 15:
200
+ line_center_x = (x1 + x2) / 2
201
+ if abs(line_center_x - w / 2) < w * 0.3:
202
+ length = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)
203
+ vertical_lines.append((line_center_x, angle, length))
204
+
205
+ if not vertical_lines:
206
+ return False, None, None, 0.0
207
+
208
+ best = max(vertical_lines, key=lambda x: x[2])
209
+ return True, int(best[0]), best[1], best[2]
210
+
211
+ def _detect_arc_from_intensity(self, gray: np.ndarray) -> Optional[int]:
212
+ """Detect arc using intensity profile (dark line in middle)"""
213
+ h, w = gray.shape
214
+ center_start, center_end = int(w * 0.3), int(w * 0.7)
215
+ center_region = gray[:, center_start:center_end]
216
+ column_means = np.mean(center_region, axis=0)
217
+
218
+ if len(column_means) == 0:
219
+ return None
220
+
221
+ darkest_idx = np.argmin(column_means)
222
+ darkest_value = column_means[darkest_idx]
223
+
224
+ if 0 < darkest_idx < len(column_means) - 1:
225
+ avg_neighbor = (column_means[darkest_idx - 1] + column_means[darkest_idx + 1]) / 2
226
+ if darkest_value < avg_neighbor * 0.9:
227
+ return center_start + darkest_idx
228
+ return None
229
+
230
+ def _invalid_structure(self) -> SaddleStructure:
231
+ return SaddleStructure(False, None, None, False, None, 0.0, 0.0)