VietCat commited on
Commit
f489532
·
1 Parent(s): f98ab8d

Implement tiling strategy for high-resolution object detection

Browse files

- Add _create_tiles() to split image into overlapping tiles (20% overlap)
- Add _select_standard_size() to choose nearest standard size (640/960/1024)
- Add _resize_to_standard() for letterbox preprocessing of tiles
- Add _merge_detections() to deduplicate detections from overlapping regions using NMS
- Refactor detect() method to process each tile separately then merge results
- Transform bounding boxes from tile space back to original image space
- Ensures maximum input resolution while maintaining accuracy

Files changed (1) hide show
  1. model.py +245 -130
model.py CHANGED
@@ -5,6 +5,7 @@ import yaml
5
  from huggingface_hub import hf_hub_download
6
  import os
7
  import torch
 
8
 
9
  class TrafficSignDetector:
10
  def __init__(self, config_path):
@@ -106,17 +107,87 @@ class TrafficSignDetector:
106
 
107
  print("="*80 + "\n")
108
 
109
- def _ensure_square(self, image, target_size=640):
110
  """
111
- Adjust image to square while maintaining aspect ratio.
112
- - If image is smaller: pad to target_size x target_size
113
- - If image is larger: resize down to target_size x target_size
114
- Letterbox padding is added to preserve aspect ratio.
115
  :param image: input image (numpy array)
116
- :param target_size: target size (default 640x640)
117
- :return: square image (target_size x target_size)
118
  """
119
  height, width = image.shape[:2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  max_dim = max(width, height)
121
 
122
  # Scale to fit target while maintaining aspect ratio
@@ -127,18 +198,23 @@ class TrafficSignDetector:
127
  new_height = int(height * scale)
128
 
129
  # Resize image
130
- resized = cv2.resize(image, (new_width, new_height), interpolation=cv2.INTER_LINEAR)
131
 
132
- # Create canvas and place resized image
133
  canvas = np.full((target_size, target_size, 3), (114, 114, 114), dtype=np.uint8)
134
  pad_x = (target_size - new_width) // 2
135
  pad_y = (target_size - new_height) // 2
136
  canvas[pad_y:pad_y + new_height, pad_x:pad_x + new_width] = resized
137
 
138
- print(f"Original: {image.shape} → Scale: {scale:.3f} → Resized: {resized.shape} → Final: {canvas.shape}")
139
-
140
  return canvas, scale, pad_x, pad_y
141
 
 
 
 
 
 
 
 
142
  def _preprocess(self, image):
143
  """
144
  Preprocess image: keep uint8 format as YOLO expects.
@@ -149,9 +225,67 @@ class TrafficSignDetector:
149
  print(f"Image format: {image.dtype}, Min: {image.min()}, Max: {image.max()}, Mean: {image.mean():.1f}")
150
  return image
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  def detect(self, image, confidence_threshold=None):
153
  """
154
- Perform inference on the image and draw bounding boxes.
 
 
155
  :param image: numpy array of the image
156
  :param confidence_threshold: optional override for confidence threshold
157
  :return: tuple of (image with drawn bounding boxes, preprocessed image for visualization)
@@ -161,143 +295,124 @@ class TrafficSignDetector:
161
  confidence_threshold = self.conf_threshold
162
  else:
163
  confidence_threshold = float(confidence_threshold)
 
164
  print(f"\n{'='*80}")
165
- print(f"DETECTION PIPELINE START")
166
  print(f"{'='*80}")
167
  print(f"[STEP 1] INPUT IMAGE")
168
  print(f" - Shape: {image.shape}")
169
  print(f" - dtype: {image.dtype}")
170
  print(f" - Range: [{image.min()}, {image.max()}]")
171
- print(f" - Mean: {image.mean():.2f}, Std: {image.std():.2f}")
172
 
173
- # Store original image for drawing (uint8)
174
  original_image = image.copy()
 
175
 
176
- # Apply letterbox preprocessing to ensure 640x640 matching training size
177
- # Returns both processed image and transformation info
178
- print(f"\n[STEP 2] LETTERBOX PREPROCESSING")
179
- image, scale, pad_x, pad_y = self._ensure_square(image, target_size=640)
180
- print(f" - Letterboxed shape: {image.shape}")
181
- print(f" - Scale factor: {scale:.3f}")
182
- print(f" - Padding X: {pad_x}, Y: {pad_y}")
183
-
184
- # Warning if scale is too small (objects might be too small to detect)
185
- if scale < 0.5:
186
- print(f" ⚠️ WARNING: Scale factor < 0.5 - objects may be too small!")
187
- print(f" Original size: {original_image.shape[:2]} → Resized: {int(original_image.shape[1]*scale)}x{int(original_image.shape[0]*scale)}")
188
-
189
- # Normalize pixel values for inference
190
- print(f"\n[STEP 3] IMAGE NORMALIZATION")
191
- image = self._preprocess(image)
192
-
193
- # Store preprocessed image for visualization (convert back to 0-255 for display)
194
- preprocessed_display = (image * 255).astype(np.uint8) if image.max() <= 1.0 else image.copy()
195
-
196
- # Use imgsz=640 to match training size
197
- # Use iou_threshold for NMS (Non-Maximum Suppression) to remove overlapping boxes
198
- print(f"\n[STEP 4] MODEL INFERENCE")
199
- print(f" - Input shape to model: {image.shape}")
200
- print(f" - Confidence threshold: {confidence_threshold}")
201
- print(f" - IOU threshold: 0.55")
202
 
203
- # Run with conf=0.0 to get raw predictions (before filtering)
204
- results_raw = self.model(image, conf=0.0, imgsz=640, iou=0.55)
205
- raw_box_count = len(results_raw[0].boxes) if results_raw else 0
206
- print(f" - Raw detections (conf=0.0): {raw_box_count}")
207
 
208
- if results_raw and len(results_raw[0].boxes) > 0:
209
- all_raw_confs = [float(box.conf[0]) for box in results_raw[0].boxes]
 
210
 
211
- # Get top 5 with class names
212
- boxes_with_conf = [(float(box.conf[0]), int(box.cls[0].cpu().numpy())) for box in results_raw[0].boxes]
213
- top_5 = sorted(boxes_with_conf, key=lambda x: x[0], reverse=True)[:5]
214
- top_5_str = [f"{c:.6f} ({self.classes[cls]})" for c, cls in top_5]
215
 
216
- print(f" - Top 5 raw confidences: {top_5_str}")
217
- print(f" - Confidence stats: min={min(all_raw_confs):.6f}, max={max(all_raw_confs):.6f}, mean={np.mean(all_raw_confs):.6f}")
218
- print(f" - Confidences > 0.01: {sum(1 for c in all_raw_confs if c > 0.01)}")
219
- print(f" - Confidences > 0.001: {sum(1 for c in all_raw_confs if c > 0.001)}")
220
- print(f" - Confidences > 0.0001: {sum(1 for c in all_raw_confs if c > 0.0001)}")
221
-
222
- # Now run with actual threshold
223
- results = self.model(image, conf=confidence_threshold, imgsz=640, iou=0.55)
224
- print(f" - Filtered detections (conf={confidence_threshold}): {len(results)}")
225
-
226
- # Get original dimensions for coordinate transformation
227
- orig_h, orig_w = original_image.shape[:2]
228
-
229
- print(f"\n[STEP 5] DETECTION RESULTS")
230
- for result in results:
231
- boxes = result.boxes
232
- print(f" - Total boxes after NMS (confidence >= {self.conf_threshold}): {len(boxes)}")
233
 
234
- # Debug: print all raw predictions before NMS
235
- if hasattr(result, 'boxes') and len(result.boxes) == 0:
236
- print(f" - Note: Model raw output available but filtered by NMS/confidence")
237
- if hasattr(result, 'probs'):
238
- print(f" - Raw predictions present: {result.probs}")
239
 
240
- # Debug: print summary
241
- if len(boxes) > 0:
242
- confidences = [float(box.conf[0]) for box in boxes]
243
- print(f" - Confidence range: {min(confidences):.4f} - {max(confidences):.4f}")
244
- print(f" - Mean confidence: {np.mean(confidences):.4f}")
245
- else:
246
- print(f" - No detections above threshold {self.conf_threshold}")
247
- print(f" - Model may not have detected any objects in this image")
248
 
249
- for box in boxes:
250
- # Get bounding box coordinates from letterboxed image
251
- x1, y1, x2, y2 = box.xyxy[0].cpu().numpy().astype(int)
 
252
 
253
- # Convert coordinates back to original image space
254
- x1 = max(0, int((x1 - pad_x) / scale))
255
- y1 = max(0, int((y1 - pad_y) / scale))
256
- x2 = min(orig_w, int((x2 - pad_x) / scale))
257
- y2 = min(orig_h, int((y2 - pad_y) / scale))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
- conf = box.conf[0].cpu().numpy()
260
- cls = int(box.cls[0].cpu().numpy())
261
- print(f"Detected: {self.classes[cls]} with conf {conf:.4f} at ({x1},{y1})-({x2},{y2})")
262
-
263
- # Only draw if confidence meets threshold
264
- if conf >= confidence_threshold:
265
- # Draw bounding box on original image
266
- cv2.rectangle(original_image, (x1, y1), (x2, y2), self.box_color, self.thickness)
267
-
268
- # Draw label
269
- label = f"{self.classes[cls]}: {conf:.2f}"
270
- cv2.putText(original_image, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, self.text_color, 2)
271
-
272
  print(f"\n{'='*80}")
273
  print(f"DETECTION PIPELINE COMPLETE")
274
- print(f"{'='*80}")
 
 
 
275
 
276
- # Analysis and recommendations
277
- print(f"\n📋 ANALYSIS & RECOMMENDATIONS:")
278
-
279
- # Check for raw detections issue
280
- if raw_box_count > 0 and max([float(box.conf[0]) for box in results_raw[0].boxes]) < 0.01:
281
- print(f" ⚠️ MODEL CONFIDENCE ISSUE:")
282
- print(f" - Model detects {raw_box_count} objects but all with confidence < 0.01")
283
- print(f" - This indicates the model may not be well-trained for this domain")
284
- print(f" - Possible causes:")
285
- print(f" a) Model trained on different dataset/resolution")
286
- print(f" b) Model underfitted (needs more training epochs)")
287
- print(f" c) Training data does not match inference data")
288
- print(f" d) Model weights not properly saved")
289
- print(f" - Solutions:")
290
- print(f" 1) Retrain model with proper hyperparameters (100+ epochs)")
291
- print(f" 2) Use augmentation during training")
292
- print(f" 3) Check training/validation accuracy was good")
293
- print(f" 4) Ensure training data matches inference image types")
294
- print(f" - Try lowering the confidence threshold slider to see detections")
295
-
296
- if scale < 0.5:
297
- print(f"\n ⚠️ SCALING ISSUE:")
298
- print(f" - Objects too small after resizing (scale={scale:.2f})")
299
- print(f" - Current: {original_image.shape} → {image.shape}")
300
- print(f" - Solutions: use larger imgsz (1024/1280) or smaller input images")
301
-
302
- print()
303
  return original_image, preprocessed_display
 
5
  from huggingface_hub import hf_hub_download
6
  import os
7
  import torch
8
+ from collections import defaultdict
9
 
10
  class TrafficSignDetector:
11
  def __init__(self, config_path):
 
107
 
108
  print("="*80 + "\n")
109
 
110
+ def _create_tiles(self, image, overlap_ratio=0.2):
111
  """
112
+ Cắt ảnh thành các tiles vuông với overlap.
 
 
 
113
  :param image: input image (numpy array)
114
+ :param overlap_ratio: tỉ lệ overlap giữa các tiles (0.2 = 20%)
115
+ :return: list of (tile_image, tile_coords) - tile_coords = (y1, x1, y2, x2) trong ảnh gốc
116
  """
117
  height, width = image.shape[:2]
118
+ min_dim = min(height, width)
119
+
120
+ print(f"\n[TILING] Image: {width}x{height}, Min dimension: {min_dim}")
121
+
122
+ # Xác định stride (bước nhảy) dựa trên overlap
123
+ # Nếu overlap = 20%, thì stride = 80% của tile_size
124
+ stride = int(min_dim * (1 - overlap_ratio))
125
+
126
+ tiles = []
127
+ tile_size = min_dim
128
+
129
+ # Tạo grid tiles
130
+ y = 0
131
+ while y < height:
132
+ y_end = min(y + tile_size, height)
133
+ # Nếu đây là tiles cuối cùng, đảm bảo nó có đủ kích thước
134
+ if y_end - y < tile_size and y > 0:
135
+ y = height - tile_size
136
+ y_end = height
137
+
138
+ x = 0
139
+ while x < width:
140
+ x_end = min(x + tile_size, width)
141
+ # Nếu đây là tiles cuối cùng, đảm bảo nó có đủ kích thước
142
+ if x_end - x < tile_size and x > 0:
143
+ x = width - tile_size
144
+ x_end = width
145
+
146
+ # Extract tile
147
+ tile = image[y:y_end, x:x_end]
148
+ tiles.append({
149
+ 'image': tile,
150
+ 'y_min': y,
151
+ 'x_min': x,
152
+ 'y_max': y_end,
153
+ 'x_max': x_end
154
+ })
155
+
156
+ x += stride
157
+ if x >= width:
158
+ break
159
+
160
+ y += stride
161
+ if y >= height:
162
+ break
163
+
164
+ print(f" - Tile size: {tile_size}x{tile_size}")
165
+ print(f" - Stride: {stride} (overlap: {overlap_ratio*100:.0f}%)")
166
+ print(f" - Số tiles: {len(tiles)}")
167
+
168
+ return tiles
169
+
170
+ def _select_standard_size(self, tile_size):
171
+ """
172
+ Chọn kích thước chuẩn gần nhất cho tile.
173
+ :param tile_size: kích thước hiện tại
174
+ :return: kích thước chuẩn (640, 960, hoặc 1024)
175
+ """
176
+ standard_sizes = [640, 960, 1024]
177
+ # Chọn size nhỏ nhất mà >= tile_size
178
+ for size in standard_sizes:
179
+ if size >= tile_size:
180
+ return size
181
+ return 1024 # Fallback to largest
182
+
183
+ def _resize_to_standard(self, tile, target_size=640):
184
+ """
185
+ Resize tile về size chuẩn với letterbox padding.
186
+ :param tile: tile image
187
+ :param target_size: target size (640, 960, hoặc 1024)
188
+ :return: (resized_image, scale, pad_x, pad_y)
189
+ """
190
+ height, width = tile.shape[:2]
191
  max_dim = max(width, height)
192
 
193
  # Scale to fit target while maintaining aspect ratio
 
198
  new_height = int(height * scale)
199
 
200
  # Resize image
201
+ resized = cv2.resize(tile, (new_width, new_height), interpolation=cv2.INTER_LINEAR)
202
 
203
+ # Create canvas and place resized image (letterbox)
204
  canvas = np.full((target_size, target_size, 3), (114, 114, 114), dtype=np.uint8)
205
  pad_x = (target_size - new_width) // 2
206
  pad_y = (target_size - new_height) // 2
207
  canvas[pad_y:pad_y + new_height, pad_x:pad_x + new_width] = resized
208
 
 
 
209
  return canvas, scale, pad_x, pad_y
210
 
211
+ def _ensure_square(self, image, target_size=640):
212
+ """
213
+ Adjust image to square while maintaining aspect ratio.
214
+ Deprecated: use _resize_to_standard instead.
215
+ """
216
+ return self._resize_to_standard(image, target_size)
217
+
218
  def _preprocess(self, image):
219
  """
220
  Preprocess image: keep uint8 format as YOLO expects.
 
225
  print(f"Image format: {image.dtype}, Min: {image.min()}, Max: {image.max()}, Mean: {image.mean():.1f}")
226
  return image
227
 
228
+ def _merge_detections(self, all_detections, overlap_threshold=0.5):
229
+ """
230
+ Merge detections từ nhiều tiles, loại bỏ duplicates.
231
+ Sử dụng NMS để gộp detections từ overlapping regions.
232
+
233
+ :param all_detections: list of {
234
+ 'x1': int, 'y1': int, 'x2': int, 'y2': int,
235
+ 'conf': float, 'cls': int
236
+ }
237
+ :param overlap_threshold: IOU threshold cho NMS
238
+ :return: merged_detections
239
+ """
240
+ if not all_detections:
241
+ return []
242
+
243
+ # Sort by confidence (descending)
244
+ all_detections = sorted(all_detections, key=lambda x: x['conf'], reverse=True)
245
+
246
+ merged = []
247
+ used = [False] * len(all_detections)
248
+
249
+ for i, det in enumerate(all_detections):
250
+ if used[i]:
251
+ continue
252
+
253
+ # Add this detection
254
+ merged.append(det)
255
+ used[i] = True
256
+
257
+ # Mark overlapping detections as used
258
+ for j in range(i + 1, len(all_detections)):
259
+ if used[j]:
260
+ continue
261
+
262
+ # Calculate IOU
263
+ x1_inter = max(det['x1'], all_detections[j]['x1'])
264
+ y1_inter = max(det['y1'], all_detections[j]['y1'])
265
+ x2_inter = min(det['x2'], all_detections[j]['x2'])
266
+ y2_inter = min(det['y2'], all_detections[j]['y2'])
267
+
268
+ if x2_inter < x1_inter or y2_inter < y1_inter:
269
+ continue # No intersection
270
+
271
+ inter_area = (x2_inter - x1_inter) * (y2_inter - y1_inter)
272
+ det_area = (det['x2'] - det['x1']) * (det['y2'] - det['y1'])
273
+ other_area = (all_detections[j]['x2'] - all_detections[j]['x1']) * (all_detections[j]['y2'] - all_detections[j]['y1'])
274
+ union_area = det_area + other_area - inter_area
275
+
276
+ iou = inter_area / union_area if union_area > 0 else 0
277
+
278
+ # Mark as duplicate if IOU > threshold
279
+ if iou > overlap_threshold:
280
+ used[j] = True
281
+
282
+ return merged
283
+
284
  def detect(self, image, confidence_threshold=None):
285
  """
286
+ Perform inference on the image using tiling strategy.
287
+ Cắt ảnh thành tiles, inference từng tile, sau đó merge kết quả.
288
+
289
  :param image: numpy array of the image
290
  :param confidence_threshold: optional override for confidence threshold
291
  :return: tuple of (image with drawn bounding boxes, preprocessed image for visualization)
 
295
  confidence_threshold = self.conf_threshold
296
  else:
297
  confidence_threshold = float(confidence_threshold)
298
+
299
  print(f"\n{'='*80}")
300
+ print(f"DETECTION PIPELINE START (TILING STRATEGY)")
301
  print(f"{'='*80}")
302
  print(f"[STEP 1] INPUT IMAGE")
303
  print(f" - Shape: {image.shape}")
304
  print(f" - dtype: {image.dtype}")
305
  print(f" - Range: [{image.min()}, {image.max()}]")
 
306
 
307
+ # Store original image for drawing
308
  original_image = image.copy()
309
+ orig_h, orig_w = original_image.shape[:2]
310
 
311
+ # STEP 2: Tạo tiles
312
+ print(f"\n[STEP 2] TILING")
313
+ tiles = self._create_tiles(original_image, overlap_ratio=0.2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
+ # STEP 3: Xử từng tile
316
+ print(f"\n[STEP 3] PROCESSING TILES")
317
+ all_detections = []
 
318
 
319
+ for tile_idx, tile_info in enumerate(tiles):
320
+ print(f"\n [TILE {tile_idx + 1}/{len(tiles)}]")
321
+ print(f" Position in original: ({tile_info['x_min']}, {tile_info['y_min']}) → ({tile_info['x_max']}, {tile_info['y_max']})")
322
 
323
+ tile = tile_info['image']
324
+ tile_h, tile_w = tile.shape[:2]
 
 
325
 
326
+ # Chọn kích thước chuẩn
327
+ standard_size = self._select_standard_size(max(tile_w, tile_h))
328
+ print(f" Tile size: {tile_w}x{tile_h} Standard size: {standard_size}x{standard_size}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
 
330
+ # Resize tile
331
+ resized_tile, scale, pad_x, pad_y = self._resize_to_standard(tile, target_size=standard_size)
 
 
 
332
 
333
+ # Inference
334
+ results = self.model(resized_tile, conf=0.0, imgsz=standard_size, iou=0.55)
 
 
 
 
 
 
335
 
336
+ # Process results
337
+ for result in results:
338
+ boxes = result.boxes
339
+ print(f" Detections in this tile: {len(boxes)}")
340
 
341
+ for box in boxes:
342
+ # Get coordinates in resized tile space
343
+ x1, y1, x2, y2 = box.xyxy[0].cpu().numpy().astype(int)
344
+
345
+ # Transform back to original tile space
346
+ x1 = int((x1 - pad_x) / scale)
347
+ y1 = int((y1 - pad_y) / scale)
348
+ x2 = int((x2 - pad_x) / scale)
349
+ y2 = int((y2 - pad_y) / scale)
350
+
351
+ # Clamp to tile bounds
352
+ x1 = max(0, min(x1, tile_w))
353
+ y1 = max(0, min(y1, tile_h))
354
+ x2 = max(0, min(x2, tile_w))
355
+ y2 = max(0, min(y2, tile_h))
356
+
357
+ # Transform to original image space
358
+ x1_orig = x1 + tile_info['x_min']
359
+ y1_orig = y1 + tile_info['y_min']
360
+ x2_orig = x2 + tile_info['x_min']
361
+ y2_orig = y2 + tile_info['y_min']
362
+
363
+ # Clamp to original image bounds
364
+ x1_orig = max(0, min(x1_orig, orig_w))
365
+ y1_orig = max(0, min(y1_orig, orig_h))
366
+ x2_orig = max(0, min(x2_orig, orig_w))
367
+ y2_orig = max(0, min(y2_orig, orig_h))
368
+
369
+ conf = float(box.conf[0].cpu().numpy())
370
+ cls = int(box.cls[0].cpu().numpy())
371
+
372
+ all_detections.append({
373
+ 'x1': x1_orig,
374
+ 'y1': y1_orig,
375
+ 'x2': x2_orig,
376
+ 'y2': y2_orig,
377
+ 'conf': conf,
378
+ 'cls': cls
379
+ })
380
+
381
+ # STEP 4: Merge detections
382
+ print(f"\n[STEP 4] MERGING DETECTIONS")
383
+ print(f" - Raw detections from all tiles: {len(all_detections)}")
384
+
385
+ merged_detections = self._merge_detections(all_detections, overlap_threshold=0.5)
386
+ print(f" - After deduplication: {len(merged_detections)}")
387
+
388
+ # STEP 5: Filter by confidence threshold
389
+ print(f"\n[STEP 5] FILTERING & DRAWING")
390
+ print(f" - Confidence threshold: {confidence_threshold}")
391
+
392
+ drawn_count = 0
393
+ for det in merged_detections:
394
+ if det['conf'] >= confidence_threshold:
395
+ x1, y1, x2, y2 = det['x1'], det['y1'], det['x2'], det['y2']
396
+ cls = det['cls']
397
+ conf = det['conf']
398
 
399
+ # Draw bounding box
400
+ cv2.rectangle(original_image, (x1, y1), (x2, y2), self.box_color, self.thickness)
401
+
402
+ # Draw label
403
+ label = f"{self.classes[cls]}: {conf:.2f}"
404
+ cv2.putText(original_image, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, self.text_color, 2)
405
+
406
+ drawn_count += 1
407
+ print(f" ✓ {self.classes[cls]}: conf={conf:.4f} at ({x1},{y1})-({x2},{y2})")
408
+
409
+ print(f"\n - Drawn: {drawn_count}/{len(merged_detections)}")
410
+
 
411
  print(f"\n{'='*80}")
412
  print(f"DETECTION PIPELINE COMPLETE")
413
+ print(f"{'='*80}\n")
414
+
415
+ # Create preprocessed visualization (first tile for reference)
416
+ preprocessed_display = tiles[0]['image'].copy() if tiles else original_image.copy()
417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  return original_image, preprocessed_display