Nduka_Nwagbo commited on
Commit
d22cb2c
·
1 Parent(s): b95c469

feat: annotated video endpoint

Browse files

Adds /detect_video alongside /detect. Detection runs on sampled frames
(2 fps) while boxes are drawn onto every frame, so the returned MP4 plays
back smoothly instead of updating twice a second. H.264 via imageio-ffmpeg
because OpenCV's mp4v output will not play in browsers.

Also reports aggregate compliance: how many analyzed frames contain
violations, and which classes appear in how many frames (counted once per
frame per class, so shares cannot exceed 100%).

Files changed (2) hide show
  1. app.py +184 -30
  2. requirements.txt +4 -0
app.py CHANGED
@@ -1,9 +1,17 @@
1
- """PPE compliance detection API — Gradio app served on Hugging Face Spaces."""
 
 
 
 
 
2
 
3
  import json
4
  import os
 
5
 
 
6
  import gradio as gr
 
7
  from PIL import Image
8
  from ultralytics import YOLO
9
 
@@ -20,6 +28,17 @@ model = YOLO(os.path.join(BASE_DIR, _config["model"]["file"]), task="detect")
20
 
21
  EMPTY_REPORT = {"violations": [], "compliant": [], "workers": 0, "total_detections": 0}
22
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  def detect_ppe(image, conf_threshold=0.25):
25
  """Detect PPE in an image and report compliance.
@@ -37,7 +56,12 @@ def detect_ppe(image, conf_threshold=0.25):
37
 
38
  # result.plot() returns a BGR array (OpenCV convention) — flip to RGB.
39
  annotated = Image.fromarray(result.plot()[..., ::-1])
 
 
40
 
 
 
 
41
  violations, compliant = [], []
42
  workers = 0
43
  for box in result.boxes:
@@ -49,14 +73,12 @@ def detect_ppe(image, conf_threshold=0.25):
49
  compliant.append({"label": label, "confidence": confidence})
50
  elif label == "person":
51
  workers += 1
52
-
53
- report = {
54
  "violations": violations,
55
  "compliant": compliant,
56
  "workers": workers,
57
  "total_detections": len(result.boxes),
58
  }
59
- return annotated, build_summary(report), report
60
 
61
 
62
  def build_summary(report):
@@ -80,6 +102,120 @@ def build_summary(report):
80
  return "\n".join(lines)
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  example_dir = os.path.join(BASE_DIR, "examples")
84
  examples = []
85
  if os.path.isdir(example_dir):
@@ -89,31 +225,49 @@ if os.path.isdir(example_dir):
89
  if f.lower().endswith((".jpg", ".jpeg", ".png"))
90
  ]
91
 
92
- demo = gr.Interface(
93
- fn=detect_ppe,
94
- inputs=[
95
- gr.Image(type="pil", label="Construction Site Image"),
96
- gr.Slider(
97
- minimum=0.1, maximum=0.9, value=0.25, step=0.05,
98
- label="Confidence Threshold",
99
- ),
100
- ],
101
- outputs=[
102
- gr.Image(label="Detection Result"),
103
- gr.Textbox(label="Compliance Summary", lines=10),
104
- gr.JSON(label="Structured Report"),
105
- ],
106
- title="🦺 PPE Compliance Detector",
107
- description=(
108
- "Upload a construction site image to detect hard hats and safety vests. "
109
- "The model identifies PPE violations (missing hard hat or vest) and "
110
- "highlights them with bounding boxes.\n\n"
111
- "**Model:** YOLO11s (ONNX) — 93.2% test mAP50 | "
112
- "**Classes:** hardhat, no-hardhat, vest, no-vest, person"
113
- ),
114
- examples=examples if examples else None,
115
- cache_examples=False,
116
- api_name="detect",
117
  )
118
 
119
- demo.launch(ssr_mode=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PPE compliance detection API — Gradio app served on Hugging Face Spaces.
2
+
3
+ Two endpoints:
4
+ /detect image -> annotated image + text summary + structured report
5
+ /detect_video video -> annotated MP4 + text summary + structured report
6
+ """
7
 
8
  import json
9
  import os
10
+ import tempfile
11
 
12
+ import cv2
13
  import gradio as gr
14
+ import imageio.v2 as imageio
15
  from PIL import Image
16
  from ultralytics import YOLO
17
 
 
28
 
29
  EMPTY_REPORT = {"violations": [], "compliant": [], "workers": 0, "total_detections": 0}
30
 
31
+ # Video limits. Inference on the free CPU tier costs ~2-3 s per frame, so the
32
+ # model runs on a sampled subset of frames while every frame is still written
33
+ # out with the most recent boxes drawn on it — the result plays back smoothly
34
+ # at the source frame rate instead of looking like a slideshow.
35
+ MAX_VIDEO_SECONDS = 15
36
+ SAMPLE_FPS = 2
37
+ MAX_OUTPUT_WIDTH = 1280
38
+
39
+
40
+ # --------------------------------------------------------------------------- image
41
+
42
 
43
  def detect_ppe(image, conf_threshold=0.25):
44
  """Detect PPE in an image and report compliance.
 
56
 
57
  # result.plot() returns a BGR array (OpenCV convention) — flip to RGB.
58
  annotated = Image.fromarray(result.plot()[..., ::-1])
59
+ report = summarize(result)
60
+ return annotated, build_summary(report), report
61
 
62
+
63
+ def summarize(result):
64
+ """Turn one Ultralytics result into our structured report."""
65
  violations, compliant = [], []
66
  workers = 0
67
  for box in result.boxes:
 
73
  compliant.append({"label": label, "confidence": confidence})
74
  elif label == "person":
75
  workers += 1
76
+ return {
 
77
  "violations": violations,
78
  "compliant": compliant,
79
  "workers": workers,
80
  "total_detections": len(result.boxes),
81
  }
 
82
 
83
 
84
  def build_summary(report):
 
102
  return "\n".join(lines)
103
 
104
 
105
+ # --------------------------------------------------------------------------- video
106
+
107
+
108
+ def detect_ppe_video(video_path, conf_threshold=0.25):
109
+ """Annotate a short video and report aggregate compliance.
110
+
111
+ Returns the annotated MP4 path, a text summary, and a structured report.
112
+ """
113
+ empty = {
114
+ "frames_analyzed": 0, "frames_with_violations": 0, "violation_counts": {},
115
+ "compliant_counts": {}, "peak_workers": 0, "duration_seconds": 0.0,
116
+ }
117
+ if not video_path:
118
+ return None, "No video provided.", empty
119
+
120
+ capture = cv2.VideoCapture(video_path)
121
+ if not capture.isOpened():
122
+ return None, "Could not read that video file.", empty
123
+
124
+ fps = capture.get(cv2.CAP_PROP_FPS) or 25.0
125
+ frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
126
+ duration = frame_count / fps if fps else 0.0
127
+
128
+ if duration > MAX_VIDEO_SECONDS + 0.5:
129
+ capture.release()
130
+ return (None,
131
+ f"Video is {duration:.0f}s — the maximum is {MAX_VIDEO_SECONDS} seconds.",
132
+ empty)
133
+
134
+ stride = max(1, round(fps / SAMPLE_FPS))
135
+ out_path = os.path.join(tempfile.mkdtemp(), "ppe_annotated.mp4")
136
+ # libx264 so the result plays in browsers; OpenCV's default mp4v does not.
137
+ writer = imageio.get_writer(out_path, fps=fps, codec="libx264",
138
+ quality=7, macro_block_size=None)
139
+
140
+ analyzed = 0
141
+ frames_with_violations = 0
142
+ violation_counts, compliant_counts = {}, {}
143
+ peak_workers = 0
144
+ last_result = None
145
+ index = 0
146
+
147
+ try:
148
+ while True:
149
+ ok, frame = capture.read()
150
+ if not ok:
151
+ break
152
+
153
+ if frame.shape[1] > MAX_OUTPUT_WIDTH:
154
+ scale = MAX_OUTPUT_WIDTH / frame.shape[1]
155
+ frame = cv2.resize(frame, (MAX_OUTPUT_WIDTH, int(frame.shape[0] * scale)))
156
+
157
+ if index % stride == 0:
158
+ # cv2 gives BGR, which is exactly what Ultralytics expects from
159
+ # an ndarray — no conversion here (unlike the PIL image path).
160
+ last_result = model.predict(frame, imgsz=640, conf=conf_threshold,
161
+ verbose=False)[0]
162
+ report = summarize(last_result)
163
+ analyzed += 1
164
+ if report["violations"]:
165
+ frames_with_violations += 1
166
+ # Count each label once per frame, not once per box: these are
167
+ # reported as "seen in N of M frames", so a frame containing
168
+ # three hard hats must not push the share above 100%.
169
+ for label in {v["label"] for v in report["violations"]}:
170
+ violation_counts[label] = violation_counts.get(label, 0) + 1
171
+ for label in {c["label"] for c in report["compliant"]}:
172
+ compliant_counts[label] = compliant_counts.get(label, 0) + 1
173
+ peak_workers = max(peak_workers, report["workers"])
174
+
175
+ # Draw the most recent detections onto every frame so playback is
176
+ # smooth rather than only updating twice a second.
177
+ annotated = last_result.plot(img=frame) if last_result is not None else frame
178
+ writer.append_data(annotated[..., ::-1]) # BGR -> RGB for the encoder
179
+ index += 1
180
+ finally:
181
+ capture.release()
182
+ writer.close()
183
+
184
+ report = {
185
+ "frames_analyzed": analyzed,
186
+ "frames_with_violations": frames_with_violations,
187
+ "violation_counts": violation_counts,
188
+ "compliant_counts": compliant_counts,
189
+ "peak_workers": peak_workers,
190
+ "duration_seconds": round(duration, 2),
191
+ }
192
+ return out_path, build_video_summary(report), report
193
+
194
+
195
+ def build_video_summary(report):
196
+ lines = []
197
+ total = report["frames_analyzed"]
198
+ flagged = report["frames_with_violations"]
199
+ if flagged:
200
+ lines.append(f"⚠️ VIOLATIONS DETECTED in {flagged} of {total} analyzed frames")
201
+ lines.append("")
202
+ for label, count in sorted(report["violation_counts"].items(), key=lambda kv: -kv[1]):
203
+ lines.append(f" ❌ {label} — seen in {count} frames")
204
+ else:
205
+ lines.append(f"✅ No PPE violations detected across {total} analyzed frames.")
206
+ lines.append("")
207
+ if report["compliant_counts"]:
208
+ lines.append("PPE compliant items:")
209
+ for label, count in sorted(report["compliant_counts"].items(), key=lambda kv: -kv[1]):
210
+ lines.append(f" ✅ {label} — seen in {count} frames")
211
+ lines.append("")
212
+ lines.append(f"Peak workers in frame: {report['peak_workers']}")
213
+ lines.append(f"Clip length: {report['duration_seconds']:.1f}s")
214
+ return "\n".join(lines)
215
+
216
+
217
+ # --------------------------------------------------------------------------- ui
218
+
219
  example_dir = os.path.join(BASE_DIR, "examples")
220
  examples = []
221
  if os.path.isdir(example_dir):
 
225
  if f.lower().endswith((".jpg", ".jpeg", ".png"))
226
  ]
227
 
228
+ DESCRIPTION = (
229
+ "Detects hard hats and high-visibility vests on construction sites and flags "
230
+ "missing equipment as violations.\n\n"
231
+ "**Model:** YOLO11s (ONNX) · **Classes:** hardhat, no-hardhat, vest, no-vest, person"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  )
233
 
234
+ with gr.Blocks(title="PPE Compliance Detector") as demo:
235
+ gr.Markdown("# 🦺 PPE Compliance Detector")
236
+ gr.Markdown(DESCRIPTION)
237
+
238
+ with gr.Tab("Image"):
239
+ with gr.Row():
240
+ with gr.Column():
241
+ image_in = gr.Image(type="pil", label="Construction Site Image")
242
+ image_conf = gr.Slider(0.1, 0.9, value=0.25, step=0.05,
243
+ label="Confidence Threshold")
244
+ image_btn = gr.Button("Detect PPE", variant="primary")
245
+ with gr.Column():
246
+ image_out = gr.Image(label="Detection Result")
247
+ image_text = gr.Textbox(label="Compliance Summary", lines=10)
248
+ image_json = gr.JSON(label="Structured Report")
249
+ if examples:
250
+ gr.Examples(examples=examples, inputs=image_in)
251
+ image_btn.click(detect_ppe, [image_in, image_conf],
252
+ [image_out, image_text, image_json], api_name="detect")
253
+
254
+ with gr.Tab("Video"):
255
+ gr.Markdown(
256
+ f"Upload a clip up to **{MAX_VIDEO_SECONDS} seconds**. Detection runs at "
257
+ f"{SAMPLE_FPS} fps and the boxes are drawn onto every frame, so the result "
258
+ "plays back smoothly. Processing takes roughly a minute on the free CPU tier."
259
+ )
260
+ with gr.Row():
261
+ with gr.Column():
262
+ video_in = gr.Video(label="Construction Site Video")
263
+ video_conf = gr.Slider(0.1, 0.9, value=0.25, step=0.05,
264
+ label="Confidence Threshold")
265
+ video_btn = gr.Button("Detect PPE in Video", variant="primary")
266
+ with gr.Column():
267
+ video_out = gr.Video(label="Annotated Result")
268
+ video_text = gr.Textbox(label="Compliance Summary", lines=10)
269
+ video_json = gr.JSON(label="Structured Report")
270
+ video_btn.click(detect_ppe_video, [video_in, video_conf],
271
+ [video_out, video_text, video_json], api_name="detect_video")
272
+
273
+ demo.queue(max_size=8).launch(ssr_mode=False)
requirements.txt CHANGED
@@ -4,3 +4,7 @@ onnxruntime>=1.20.1
4
  opencv-python-headless>=4.10.0
5
  Pillow>=11.1.0
6
  numpy<2.0
 
 
 
 
 
4
  opencv-python-headless>=4.10.0
5
  Pillow>=11.1.0
6
  numpy<2.0
7
+ # H.264 encoding for the annotated video output — OpenCV's bundled mp4v codec
8
+ # produces files browsers will not play.
9
+ imageio[ffmpeg]>=2.34.0
10
+ imageio-ffmpeg>=0.5.1