Nduka_Nwagbo commited on
Commit ·
d22cb2c
1
Parent(s): b95c469
feat: annotated video endpoint
Browse filesAdds /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%).
- app.py +184 -30
- 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 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|