Mingze commited on
Commit
c46c3d9
·
1 Parent(s): 94cdebc

Add YOLO object detection workflow

Browse files
Files changed (4) hide show
  1. app.py +221 -40
  2. detection_utils.py +143 -0
  3. requirements.txt +1 -0
  4. tests/test_detection_utils.py +57 -0
app.py CHANGED
@@ -1,4 +1,4 @@
1
- """Hugging Face Space for semantic segmentation of street-scene images."""
2
 
3
  from __future__ import annotations
4
 
@@ -34,9 +34,17 @@ from segmentation_utils import (
34
  resize_for_output,
35
  write_class_csv,
36
  )
 
 
 
 
 
 
 
37
 
38
- MODEL_ID = "nvidia/segformer-b0-finetuned-cityscapes-1024-1024"
39
- OUTPUT_ROOT = Path("/tmp/street-scene-segmentation")
 
40
  SAMPLE_ROOT = (
41
  "https://raw.githubusercontent.com/"
42
  "LabMingzeChen/HNIVision/main/space/examples"
@@ -49,9 +57,9 @@ def load_model():
49
  """Download once per container, then reuse the processor and model."""
50
  torch.set_num_threads(max(1, min(4, os.cpu_count() or 1)))
51
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
52
- processor = AutoImageProcessor.from_pretrained(MODEL_ID)
53
  model = (
54
- SegformerForSemanticSegmentation.from_pretrained(MODEL_ID)
55
  .to(device)
56
  .eval()
57
  )
@@ -59,6 +67,121 @@ def load_model():
59
  return processor, model, id2label, device
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  @spaces.GPU(duration=90)
63
  def segment_street_scene(
64
  image: Image.Image | None,
@@ -148,16 +271,17 @@ CSS = """
148
  """
149
 
150
 
151
- with gr.Blocks(title="Street Scene Segmentation", theme=gr.themes.Soft(), css=CSS) as demo:
152
  gr.Markdown(
153
  """
154
  <div class="hero">
155
- <h1>🚦 Street Scene Semantic Segmentation</h1>
156
- <p>Turn a street image into a pixel-level map of roads, sidewalks, buildings, vegetation, vehicles, people, and more.</p>
157
- <p class="muted">SegFormer-B0 · 19 Cityscapes classes · browser-based · no API key required</p>
158
  <div class="project-links">
159
  <a class="project-link" href="https://huggingface.co/spaces/Mingze/StreetSceneSegmentation" target="_blank">🤗 Hugging Face Space</a>
160
- <a class="project-link" href="https://huggingface.co/nvidia/segformer-b0-finetuned-cityscapes-1024-1024" target="_blank">🧠 Model card</a>
 
161
  <a class="project-link" href="https://github.com/LabMingzeChen/StreetSceneSegmentation" target="_blank">⭐ GitHub source</a>
162
  </div>
163
  </div>
@@ -179,7 +303,15 @@ with gr.Blocks(title="Street Scene Segmentation", theme=gr.themes.Soft(), css=CS
179
  label="Try the UBC campus street example",
180
  examples_per_page=1,
181
  )
182
- with gr.Accordion("Display settings", open=False):
 
 
 
 
 
 
 
 
183
  opacity_input = gr.Slider(
184
  0.15,
185
  0.85,
@@ -195,47 +327,79 @@ with gr.Blocks(title="Street Scene Segmentation", theme=gr.themes.Soft(), css=CS
195
  label="Minimum class area shown in table (%)",
196
  )
197
  with gr.Row():
198
- run_button = gr.Button("Run segmentation", variant="primary", size="lg")
199
- clear_button = gr.ClearButton(value="Clear", components=[image_input])
200
- status_output = gr.Markdown()
201
 
202
  with gr.Column(scale=7):
203
  with gr.Tabs():
204
- with gr.Tab("Overlay"):
 
 
 
 
 
 
205
  overlay_output = gr.Image(label="Segmentation overlay", height=470)
 
206
  with gr.Tab("Color mask"):
207
  mask_output = gr.Image(label="Cityscapes color mask", height=470)
208
 
209
- table_output = gr.Dataframe(
210
- headers=["Class ID", "Class", "Pixels", "Area share (%)", "Color"],
211
- datatype=["number", "str", "number", "number", "str"],
212
- label="Detected street-scene classes",
213
- interactive=False,
214
- wrap=True,
215
- )
216
- files_output = gr.File(
217
- label="Download results (overlay, color mask, class IDs, and CSV)",
218
- file_count="multiple",
219
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
  gr.Markdown(
222
  """
223
  ## How to use the app
224
 
225
  <div class="guide-grid">
226
- <div class="guide-card"><h3>1 · Choose an image</h3><p>Upload, paste, use a webcam, or select one of the sample street scenes above.</p></div>
227
- <div class="guide-card"><h3>2 · Run segmentation</h3><p>The model assigns one Cityscapes class to every pixel. The first run may take longer while the model loads.</p></div>
228
- <div class="guide-card"><h3>3 · Explore and download</h3><p>Compare the overlay and mask, review area shares, and download reusable PNG and CSV outputs.</p></div>
229
  </div>
230
 
231
  ## What the results mean
232
 
233
- - **Overlay** blends the prediction with the original photograph. White lines mark boundaries between predicted classes.
234
- - **Color mask** shows only the standard Cityscapes colors, making classes easier to compare across images.
235
- - **Area share** is the percentage of image pixels assigned to each class. It describes visual coverage, not physical land area.
236
- - **Class-ID PNG** stores the numeric class at each pixel; the CSV summarizes visible classes and their colors.
 
237
 
238
- | Scene layer | Classes |
239
  |---|---|
240
  | Travel surfaces | road, sidewalk |
241
  | Built environment | building, wall, fence, pole, traffic light, traffic sign |
@@ -245,20 +409,37 @@ with gr.Blocks(title="Street Scene Segmentation", theme=gr.themes.Soft(), css=CS
245
 
246
  ## Classroom and research ideas
247
 
248
- - Compare vegetation and built-environment shares across several streets.
249
- - Discuss why a pixel classifier can confuse terrain, sidewalk, wall, or fence at object boundaries.
250
- - Export the CSV files and create a class-coverage chart for an urban-design exercise.
 
251
  - Compare the same location across seasons, weather conditions, or camera viewpoints.
252
 
253
- > **Important:** predictions are model estimates, not ground truth. The model was trained for road-driving imagery and can be less reliable for unusual viewpoints, severe weather, night scenes, indoor images, or places unlike the training data. Do not use it for safety-critical decisions or to identify individuals.
254
 
 
 
255
  [Read the SegFormer paper](https://arxiv.org/abs/2105.15203) ·
256
  [Explore the Cityscapes dataset](https://www.cityscapes-dataset.com/) ·
257
  [View the source on GitHub](https://github.com/LabMingzeChen/StreetSceneSegmentation)
258
  """
259
  )
260
 
261
- run_button.click(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  fn=segment_street_scene,
263
  inputs=[image_input, opacity_input, min_share_input],
264
  outputs=[
@@ -266,7 +447,7 @@ with gr.Blocks(title="Street Scene Segmentation", theme=gr.themes.Soft(), css=CS
266
  mask_output,
267
  table_output,
268
  files_output,
269
- status_output,
270
  ],
271
  api_name="segment",
272
  )
 
1
+ """Hugging Face Space for street-scene detection and segmentation."""
2
 
3
  from __future__ import annotations
4
 
 
34
  resize_for_output,
35
  write_class_csv,
36
  )
37
+ from detection_utils import (
38
+ build_detection_summary,
39
+ build_detection_table,
40
+ build_street_indicators,
41
+ render_detection,
42
+ write_detection_csv,
43
+ )
44
 
45
+ SEGMENTATION_MODEL_ID = "nvidia/segformer-b0-finetuned-cityscapes-1024-1024"
46
+ DETECTION_MODEL_ID = "yolo26s.pt"
47
+ OUTPUT_ROOT = Path("/tmp/street-scene-vision")
48
  SAMPLE_ROOT = (
49
  "https://raw.githubusercontent.com/"
50
  "LabMingzeChen/HNIVision/main/space/examples"
 
57
  """Download once per container, then reuse the processor and model."""
58
  torch.set_num_threads(max(1, min(4, os.cpu_count() or 1)))
59
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
60
+ processor = AutoImageProcessor.from_pretrained(SEGMENTATION_MODEL_ID)
61
  model = (
62
+ SegformerForSemanticSegmentation.from_pretrained(SEGMENTATION_MODEL_ID)
63
  .to(device)
64
  .eval()
65
  )
 
67
  return processor, model, id2label, device
68
 
69
 
70
+ @lru_cache(maxsize=1)
71
+ def load_detector():
72
+ """Download YOLO26-s once per container and reuse it."""
73
+ from ultralytics import YOLO
74
+
75
+ return YOLO(DETECTION_MODEL_ID)
76
+
77
+
78
+ def _format_detection_indicators(
79
+ detections: list[dict[str, object]],
80
+ ) -> str:
81
+ indicators = build_street_indicators(detections)
82
+ average_confidence = (
83
+ sum(float(item["confidence"]) for item in detections) / len(detections)
84
+ if detections
85
+ else 0.0
86
+ )
87
+ return f"""
88
+ ### Detection-based street indicators
89
+
90
+ | Indicator | Visible count |
91
+ |---|---:|
92
+ | People | {indicators['people']} |
93
+ | Active-mobility objects (`person` + `bicycle`) | {indicators['active_mobility']} |
94
+ | Motor vehicles | {indicators['motor_vehicles']} |
95
+ | All transport objects | {indicators['all_transport']} |
96
+ | All detected objects | {len(detections)} |
97
+
98
+ Average detection confidence: **{average_confidence:.2f}**
99
+
100
+ > Counts describe visible COCO detections in this image. They are not traffic-flow,
101
+ > occupancy, accessibility, or safety measurements.
102
+ """
103
+
104
+
105
+ @spaces.GPU(duration=90)
106
+ def detect_street_objects(
107
+ image: Image.Image | None,
108
+ confidence_threshold: float,
109
+ ):
110
+ """Run YOLO object detection and return visual and tabular outputs."""
111
+ if image is None:
112
+ raise gr.Error("Please upload a street-scene image first.")
113
+
114
+ started_at = time.perf_counter()
115
+ prepared_image = resize_for_output(image)
116
+ device = "cuda" if torch.cuda.is_available() else "cpu"
117
+
118
+ try:
119
+ detector = load_detector()
120
+ predictions = detector.predict(
121
+ source=np.asarray(prepared_image),
122
+ conf=float(confidence_threshold),
123
+ imgsz=1024,
124
+ device=device,
125
+ max_det=100,
126
+ verbose=False,
127
+ )
128
+ prediction = predictions[0]
129
+ names = prediction.names
130
+ detections: list[dict[str, object]] = []
131
+ if prediction.boxes is not None:
132
+ coordinates = prediction.boxes.xyxy.detach().cpu().tolist()
133
+ confidences = prediction.boxes.conf.detach().cpu().tolist()
134
+ class_ids = prediction.boxes.cls.detach().cpu().tolist()
135
+ for coordinates_row, confidence, class_id_value in zip(
136
+ coordinates,
137
+ confidences,
138
+ class_ids,
139
+ ):
140
+ class_id = int(class_id_value)
141
+ detections.append(
142
+ {
143
+ "class_id": class_id,
144
+ "class_name": str(names[class_id]),
145
+ "confidence": float(confidence),
146
+ "x1": float(coordinates_row[0]),
147
+ "y1": float(coordinates_row[1]),
148
+ "x2": float(coordinates_row[2]),
149
+ "y2": float(coordinates_row[3]),
150
+ }
151
+ )
152
+ except Exception as exc:
153
+ raise gr.Error(
154
+ f"Object detection failed: {type(exc).__name__}: {exc}"
155
+ ) from exc
156
+
157
+ overlay = render_detection(prepared_image, detections)
158
+ summary_rows = build_detection_summary(detections)
159
+ detection_rows = build_detection_table(detections)
160
+
161
+ output_dir = OUTPUT_ROOT / uuid.uuid4().hex
162
+ output_dir.mkdir(parents=True, exist_ok=True)
163
+ overlay_path = output_dir / "street_object_detection_overlay.png"
164
+ csv_path = output_dir / "street_object_detections.csv"
165
+ overlay.save(overlay_path)
166
+ write_detection_csv(csv_path, detection_rows)
167
+
168
+ elapsed = time.perf_counter() - started_at
169
+ visible_classes = len(summary_rows)
170
+ status = (
171
+ f"Done · {prepared_image.width}×{prepared_image.height} · "
172
+ f"{len(detections)} objects · {visible_classes} COCO classes · "
173
+ f"{elapsed:.1f}s · device={device}"
174
+ )
175
+ return (
176
+ overlay,
177
+ summary_rows,
178
+ detection_rows,
179
+ [str(overlay_path), str(csv_path)],
180
+ _format_detection_indicators(detections),
181
+ status,
182
+ )
183
+
184
+
185
  @spaces.GPU(duration=90)
186
  def segment_street_scene(
187
  image: Image.Image | None,
 
271
  """
272
 
273
 
274
+ with gr.Blocks(title="Street Scene Vision Toolkit", theme=gr.themes.Soft(), css=CSS) as demo:
275
  gr.Markdown(
276
  """
277
  <div class="hero">
278
+ <h1>🚦 Street Scene Vision Toolkit</h1>
279
+ <p>Detect individual objects with bounding boxes and map every pixel with semantic segmentation.</p>
280
+ <p class="muted">YOLO26-s · COCO 80 objects · SegFormer-B0 · 19 Cityscapes classes · no API key required</p>
281
  <div class="project-links">
282
  <a class="project-link" href="https://huggingface.co/spaces/Mingze/StreetSceneSegmentation" target="_blank">🤗 Hugging Face Space</a>
283
+ <a class="project-link" href="https://docs.ultralytics.com/models/yolo26/" target="_blank">📦 YOLO26</a>
284
+ <a class="project-link" href="https://huggingface.co/nvidia/segformer-b0-finetuned-cityscapes-1024-1024" target="_blank">🎨 SegFormer</a>
285
  <a class="project-link" href="https://github.com/LabMingzeChen/StreetSceneSegmentation" target="_blank">⭐ GitHub source</a>
286
  </div>
287
  </div>
 
303
  label="Try the UBC campus street example",
304
  examples_per_page=1,
305
  )
306
+ with gr.Accordion("Object-detection settings", open=True):
307
+ confidence_input = gr.Slider(
308
+ 0.05,
309
+ 0.90,
310
+ value=0.25,
311
+ step=0.05,
312
+ label="Minimum detection confidence",
313
+ )
314
+ with gr.Accordion("Segmentation settings", open=False):
315
  opacity_input = gr.Slider(
316
  0.15,
317
  0.85,
 
327
  label="Minimum class area shown in table (%)",
328
  )
329
  with gr.Row():
330
+ detection_button = gr.Button("Detect objects", variant="primary", size="lg")
331
+ segmentation_button = gr.Button("Segment pixels", size="lg")
332
+ clear_button = gr.ClearButton(value="Clear image", components=[image_input])
333
 
334
  with gr.Column(scale=7):
335
  with gr.Tabs():
336
+ with gr.Tab("Object detection"):
337
+ detection_output = gr.Image(
338
+ label="YOLO26-s bounding boxes",
339
+ height=470,
340
+ )
341
+ detection_status = gr.Markdown()
342
+ with gr.Tab("Segmentation overlay"):
343
  overlay_output = gr.Image(label="Segmentation overlay", height=470)
344
+ segmentation_status = gr.Markdown()
345
  with gr.Tab("Color mask"):
346
  mask_output = gr.Image(label="Cityscapes color mask", height=470)
347
 
348
+ with gr.Tabs():
349
+ with gr.Tab("Detection results"):
350
+ detection_indicators = gr.Markdown()
351
+ detection_summary = gr.Dataframe(
352
+ headers=["Class", "Count", "Average confidence", "Maximum confidence"],
353
+ datatype=["str", "number", "number", "number"],
354
+ label="Detected object classes",
355
+ interactive=False,
356
+ wrap=True,
357
+ )
358
+ with gr.Accordion("Detailed bounding-box coordinates", open=False):
359
+ detection_table = gr.Dataframe(
360
+ headers=["Object ID", "Class", "Confidence", "x1", "y1", "x2", "y2"],
361
+ datatype=["number", "str", "number", "number", "number", "number", "number"],
362
+ label="Individual detections",
363
+ interactive=False,
364
+ wrap=True,
365
+ )
366
+ detection_files = gr.File(
367
+ label="Download detection overlay and bounding-box CSV",
368
+ file_count="multiple",
369
+ )
370
+
371
+ with gr.Tab("Segmentation results"):
372
+ table_output = gr.Dataframe(
373
+ headers=["Class ID", "Class", "Pixels", "Area share (%)", "Color"],
374
+ datatype=["number", "str", "number", "number", "str"],
375
+ label="Detected street-scene classes",
376
+ interactive=False,
377
+ wrap=True,
378
+ )
379
+ files_output = gr.File(
380
+ label="Download segmentation overlay, color mask, class IDs, and CSV",
381
+ file_count="multiple",
382
+ )
383
 
384
  gr.Markdown(
385
  """
386
  ## How to use the app
387
 
388
  <div class="guide-grid">
389
+ <div class="guide-card"><h3>1 · Choose an image</h3><p>Upload, paste, use a webcam, or select the UBC campus example.</p></div>
390
+ <div class="guide-card"><h3>2 · Choose a method</h3><p>Run YOLO object detection, SegFormer semantic segmentation, or run both methods on the same image.</p></div>
391
+ <div class="guide-card"><h3>3 · Explore and download</h3><p>Compare boxes, overlays, masks, counts, pixel shares, coordinates, and reusable CSV outputs.</p></div>
392
  </div>
393
 
394
  ## What the results mean
395
 
396
+ - **Object detection** finds separate COCO objects, draws bounding boxes, and reports a confidence score for each detection.
397
+ - **Detection indicators** summarize visible people, active-mobility objects, and transport objects. They are transparent image counts, not traffic-flow estimates.
398
+ - **Segmentation overlay** blends the Cityscapes prediction with the original photograph. White lines mark class boundaries.
399
+ - **Color mask and area share** show pixel-level scene composition. Area share describes visual coverage, not physical land area.
400
+ - **Downloadable data** include bounding-box coordinates, class-ID pixels, overlays, masks, and CSV summaries.
401
 
402
+ | Scene layer | Segmentation classes |
403
  |---|---|
404
  | Travel surfaces | road, sidewalk |
405
  | Built environment | building, wall, fence, pole, traffic light, traffic sign |
 
409
 
410
  ## Classroom and research ideas
411
 
412
+ - Compare detected people, bicycles, and motor vehicles across several street images.
413
+ - Compare what bounding boxes reveal with what pixel-level segmentation reveals.
414
+ - Discuss missed objects, false positives, confidence thresholds, and segmentation boundary errors.
415
+ - Export both CSV files and build object-count and class-coverage charts.
416
  - Compare the same location across seasons, weather conditions, or camera viewpoints.
417
 
418
+ > **Important:** predictions are model estimates, not ground truth. COCO detection is limited to its trained object vocabulary, while Cityscapes segmentation is specialized for road-driving imagery. Do not use either output for safety-critical decisions, surveillance, or identifying individuals.
419
 
420
+ [Read the YOLO26 documentation](https://docs.ultralytics.com/models/yolo26/) ·
421
+ [Explore the COCO dataset](https://cocodataset.org/) ·
422
  [Read the SegFormer paper](https://arxiv.org/abs/2105.15203) ·
423
  [Explore the Cityscapes dataset](https://www.cityscapes-dataset.com/) ·
424
  [View the source on GitHub](https://github.com/LabMingzeChen/StreetSceneSegmentation)
425
  """
426
  )
427
 
428
+ detection_button.click(
429
+ fn=detect_street_objects,
430
+ inputs=[image_input, confidence_input],
431
+ outputs=[
432
+ detection_output,
433
+ detection_summary,
434
+ detection_table,
435
+ detection_files,
436
+ detection_indicators,
437
+ detection_status,
438
+ ],
439
+ api_name="detect",
440
+ )
441
+
442
+ segmentation_button.click(
443
  fn=segment_street_scene,
444
  inputs=[image_input, opacity_input, min_share_input],
445
  outputs=[
 
447
  mask_output,
448
  table_output,
449
  files_output,
450
+ segmentation_status,
451
  ],
452
  api_name="segment",
453
  )
detection_utils.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendering and summary helpers for street-scene object detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from collections import defaultdict
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+ from PIL import Image, ImageDraw, ImageFont
11
+
12
+
13
+ STREET_OBJECT_GROUPS: dict[str, set[str]] = {
14
+ "people": {"person"},
15
+ "active_mobility": {"person", "bicycle"},
16
+ "motor_vehicles": {"car", "motorcycle", "bus", "truck", "train"},
17
+ "all_transport": {
18
+ "bicycle",
19
+ "car",
20
+ "motorcycle",
21
+ "bus",
22
+ "truck",
23
+ "train",
24
+ },
25
+ }
26
+
27
+
28
+ def _load_font(size: int) -> ImageFont.ImageFont:
29
+ candidates = (
30
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
31
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
32
+ "/System/Library/Fonts/Helvetica.ttc",
33
+ )
34
+ for candidate in candidates:
35
+ try:
36
+ return ImageFont.truetype(candidate, size)
37
+ except (OSError, IOError):
38
+ continue
39
+ return ImageFont.load_default()
40
+
41
+
42
+ def detection_color(class_id: int) -> tuple[int, int, int]:
43
+ """Return a stable, high-contrast color for a COCO class ID."""
44
+ return (
45
+ int((67 * class_id + 37) % 190 + 40),
46
+ int((97 * class_id + 71) % 190 + 40),
47
+ int((43 * class_id + 113) % 190 + 40),
48
+ )
49
+
50
+
51
+ def render_detection(
52
+ image: Image.Image,
53
+ detections: Iterable[dict[str, object]],
54
+ ) -> Image.Image:
55
+ """Draw labeled bounding boxes on a copy of the input image."""
56
+ rendered = image.convert("RGB").copy()
57
+ draw = ImageDraw.Draw(rendered)
58
+ short_side = min(rendered.size)
59
+ line_width = max(2, round(short_side / 320))
60
+ font = _load_font(max(13, min(24, round(short_side / 55))))
61
+
62
+ for detection in detections:
63
+ class_id = int(detection["class_id"])
64
+ color = detection_color(class_id)
65
+ box = [
66
+ float(detection["x1"]),
67
+ float(detection["y1"]),
68
+ float(detection["x2"]),
69
+ float(detection["y2"]),
70
+ ]
71
+ label = (
72
+ f"{detection['class_name']} "
73
+ f"{float(detection['confidence']):.2f}"
74
+ )
75
+ draw.rectangle(box, outline=color, width=line_width)
76
+ text_box = draw.textbbox((box[0], box[1]), label, font=font)
77
+ text_height = text_box[3] - text_box[1]
78
+ text_width = text_box[2] - text_box[0]
79
+ text_y = max(0.0, box[1] - text_height - 8)
80
+ background = [box[0], text_y, box[0] + text_width + 8, text_y + text_height + 8]
81
+ draw.rectangle(background, fill=color)
82
+ draw.text((box[0] + 4, text_y + 4), label, fill="white", font=font)
83
+
84
+ return rendered
85
+
86
+
87
+ def build_detection_summary(
88
+ detections: Iterable[dict[str, object]],
89
+ ) -> list[list[object]]:
90
+ """Aggregate detection counts and confidence by class."""
91
+ grouped: dict[str, list[float]] = defaultdict(list)
92
+ for detection in detections:
93
+ grouped[str(detection["class_name"])].append(
94
+ float(detection["confidence"])
95
+ )
96
+
97
+ rows = [
98
+ [class_name, len(confidences), round(sum(confidences) / len(confidences), 3), round(max(confidences), 3)]
99
+ for class_name, confidences in grouped.items()
100
+ ]
101
+ rows.sort(key=lambda row: (-int(row[1]), str(row[0])))
102
+ return rows
103
+
104
+
105
+ def build_detection_table(
106
+ detections: Iterable[dict[str, object]],
107
+ ) -> list[list[object]]:
108
+ """Build one exportable row per bounding box."""
109
+ rows: list[list[object]] = []
110
+ for index, detection in enumerate(detections, start=1):
111
+ rows.append(
112
+ [
113
+ index,
114
+ str(detection["class_name"]),
115
+ round(float(detection["confidence"]), 3),
116
+ round(float(detection["x1"]), 1),
117
+ round(float(detection["y1"]), 1),
118
+ round(float(detection["x2"]), 1),
119
+ round(float(detection["y2"]), 1),
120
+ ]
121
+ )
122
+ return rows
123
+
124
+
125
+ def build_street_indicators(
126
+ detections: Iterable[dict[str, object]],
127
+ ) -> dict[str, int]:
128
+ """Derive transparent street-scene counts from visible COCO objects."""
129
+ class_names = [str(detection["class_name"]) for detection in detections]
130
+ return {
131
+ name: sum(class_name in members for class_name in class_names)
132
+ for name, members in STREET_OBJECT_GROUPS.items()
133
+ }
134
+
135
+
136
+ def write_detection_csv(
137
+ path: Path,
138
+ rows: Iterable[Iterable[object]],
139
+ ) -> None:
140
+ with path.open("w", newline="", encoding="utf-8") as handle:
141
+ writer = csv.writer(handle)
142
+ writer.writerow(["object_id", "class", "confidence", "x1", "y1", "x2", "y2"])
143
+ writer.writerows(rows)
requirements.txt CHANGED
@@ -3,3 +3,4 @@ transformers==4.57.6
3
  torch==2.8.0
4
  numpy==2.2.6
5
  Pillow==11.3.0
 
 
3
  torch==2.8.0
4
  numpy==2.2.6
5
  Pillow==11.3.0
6
+ ultralytics==8.4.102
tests/test_detection_utils.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image
2
+
3
+ import detection_utils as utils
4
+
5
+
6
+ DETECTIONS = [
7
+ {
8
+ "class_id": 0,
9
+ "class_name": "person",
10
+ "confidence": 0.9,
11
+ "x1": 1.0,
12
+ "y1": 2.0,
13
+ "x2": 10.0,
14
+ "y2": 20.0,
15
+ },
16
+ {
17
+ "class_id": 2,
18
+ "class_name": "car",
19
+ "confidence": 0.7,
20
+ "x1": 12.0,
21
+ "y1": 4.0,
22
+ "x2": 30.0,
23
+ "y2": 18.0,
24
+ },
25
+ {
26
+ "class_id": 2,
27
+ "class_name": "car",
28
+ "confidence": 0.5,
29
+ "x1": 32.0,
30
+ "y1": 5.0,
31
+ "x2": 45.0,
32
+ "y2": 17.0,
33
+ },
34
+ ]
35
+
36
+
37
+ def test_detection_summary_groups_and_sorts_classes():
38
+ assert utils.build_detection_summary(DETECTIONS) == [
39
+ ["car", 2, 0.6, 0.7],
40
+ ["person", 1, 0.9, 0.9],
41
+ ]
42
+
43
+
44
+ def test_street_indicators_are_transparent_counts():
45
+ assert utils.build_street_indicators(DETECTIONS) == {
46
+ "people": 1,
47
+ "active_mobility": 1,
48
+ "motor_vehicles": 2,
49
+ "all_transport": 2,
50
+ }
51
+
52
+
53
+ def test_detection_overlay_matches_input_size():
54
+ image = Image.new("RGB", (60, 40), "white")
55
+ rendered = utils.render_detection(image, DETECTIONS)
56
+ assert rendered.size == image.size
57
+ assert rendered.getpixel((1, 2)) != (255, 255, 255)