Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
STL Firearm/Knife Screener
|
| 3 |
+
---------------------------
|
| 4 |
+
Upload an STL file. The app rotates a virtual camera fully around the model
|
| 5 |
+
(covering all azimuths, plus dedicated top-down and bottom-up views), renders
|
| 6 |
+
each view as a 2D image, and runs the "Guns-100-11m" YOLOv11 detector
|
| 7 |
+
(wuhp/guns-100-11m) on every rendered frame. Results are aggregated into an
|
| 8 |
+
overall verdict plus an annotated gallery so a detection that only shows up
|
| 9 |
+
from one angle (e.g. the trigger guard only visible from the side) still
|
| 10 |
+
gets caught.
|
| 11 |
+
|
| 12 |
+
Run locally:
|
| 13 |
+
pip install -r requirements.txt
|
| 14 |
+
python app.py
|
| 15 |
+
|
| 16 |
+
Model weights are pulled automatically from the Hugging Face Hub the first
|
| 17 |
+
time the app runs (needs internet access) and cached locally afterwards.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
import tempfile
|
| 22 |
+
|
| 23 |
+
import gradio as gr
|
| 24 |
+
import matplotlib
|
| 25 |
+
matplotlib.use("Agg")
|
| 26 |
+
import matplotlib.pyplot as plt
|
| 27 |
+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
| 28 |
+
from matplotlib.colors import LightSource
|
| 29 |
+
import numpy as np
|
| 30 |
+
import pandas as pd
|
| 31 |
+
import trimesh
|
| 32 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 33 |
+
from huggingface_hub import hf_hub_download
|
| 34 |
+
from ultralytics import YOLO
|
| 35 |
+
|
| 36 |
+
# --------------------------------------------------------------------------
|
| 37 |
+
# Model loading
|
| 38 |
+
# --------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
MODEL_REPO = "wuhp/guns-100-11m"
|
| 41 |
+
MODEL_FILE = "Guns-100-11m.pt"
|
| 42 |
+
CLASS_NAMES = {0: "Gun", 1: "Knife"}
|
| 43 |
+
|
| 44 |
+
_model = None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_model():
|
| 48 |
+
"""Lazily download (once) and cache the YOLO model."""
|
| 49 |
+
global _model
|
| 50 |
+
if _model is None:
|
| 51 |
+
weights_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE)
|
| 52 |
+
_model = YOLO(weights_path)
|
| 53 |
+
return _model
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# --------------------------------------------------------------------------
|
| 57 |
+
# STL -> multi-view rendering
|
| 58 |
+
# --------------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
def build_view_angles(num_azimuths: int, num_elevations: int):
|
| 61 |
+
"""
|
| 62 |
+
Build a list of (azimuth, elevation) pairs that fully cover the sphere
|
| 63 |
+
around the object, including explicit top-down and bottom-up shots.
|
| 64 |
+
"""
|
| 65 |
+
azimuths = np.linspace(0, 360, num_azimuths, endpoint=False)
|
| 66 |
+
|
| 67 |
+
# Elevation ring between (but not including) the poles, e.g. for
|
| 68 |
+
# num_elevations=3 -> [-45, 0, 45]
|
| 69 |
+
if num_elevations > 0:
|
| 70 |
+
elevations = np.linspace(-75, 75, num_elevations)
|
| 71 |
+
else:
|
| 72 |
+
elevations = np.array([0.0])
|
| 73 |
+
|
| 74 |
+
views = []
|
| 75 |
+
for elev in elevations:
|
| 76 |
+
for az in azimuths:
|
| 77 |
+
views.append((float(az), float(elev)))
|
| 78 |
+
|
| 79 |
+
# Dedicated poles: straight down (top view) and straight up (bottom view)
|
| 80 |
+
views.append((0.0, 90.0)) # top
|
| 81 |
+
views.append((0.0, -90.0)) # bottom
|
| 82 |
+
|
| 83 |
+
return views
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def render_view(tris: np.ndarray, center: np.ndarray, scale: float,
|
| 87 |
+
azim: float, elev: float, img_size: int = 640) -> Image.Image:
|
| 88 |
+
"""Render a single (azim, elev) view of the mesh triangles to a PIL image."""
|
| 89 |
+
fig = plt.figure(figsize=(img_size / 100, img_size / 100), dpi=100)
|
| 90 |
+
ax = fig.add_subplot(111, projection="3d")
|
| 91 |
+
ax.set_facecolor("white")
|
| 92 |
+
|
| 93 |
+
ls = LightSource(azdeg=315, altdeg=55)
|
| 94 |
+
facecolors = [(0.62, 0.62, 0.66, 1.0)] * len(tris)
|
| 95 |
+
poly = Poly3DCollection(
|
| 96 |
+
tris,
|
| 97 |
+
facecolors=facecolors,
|
| 98 |
+
edgecolor=(0.15, 0.15, 0.15, 0.4),
|
| 99 |
+
linewidths=0.15,
|
| 100 |
+
shade=True,
|
| 101 |
+
lightsource=ls,
|
| 102 |
+
)
|
| 103 |
+
ax.add_collection3d(poly)
|
| 104 |
+
|
| 105 |
+
ax.set_xlim(center[0] - scale, center[0] + scale)
|
| 106 |
+
ax.set_ylim(center[1] - scale, center[1] + scale)
|
| 107 |
+
ax.set_zlim(center[2] - scale, center[2] + scale)
|
| 108 |
+
try:
|
| 109 |
+
ax.set_box_aspect((1, 1, 1))
|
| 110 |
+
except Exception:
|
| 111 |
+
pass
|
| 112 |
+
ax.set_axis_off()
|
| 113 |
+
ax.view_init(elev=elev, azim=azim)
|
| 114 |
+
fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
| 115 |
+
|
| 116 |
+
fig.canvas.draw()
|
| 117 |
+
buf = np.asarray(fig.canvas.buffer_rgba())
|
| 118 |
+
img = Image.fromarray(buf).convert("RGB")
|
| 119 |
+
plt.close(fig)
|
| 120 |
+
return img
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def load_mesh_triangles(stl_path: str):
|
| 124 |
+
mesh = trimesh.load(stl_path, force="mesh")
|
| 125 |
+
if not isinstance(mesh, trimesh.Trimesh):
|
| 126 |
+
# Scene with multiple geometries -> merge them
|
| 127 |
+
mesh = trimesh.util.concatenate(mesh.dump())
|
| 128 |
+
tris = mesh.vertices[mesh.faces]
|
| 129 |
+
center = mesh.centroid
|
| 130 |
+
scale = float(np.max(mesh.extents)) / 2.0 * 1.05 # small margin
|
| 131 |
+
return tris, center, scale, mesh
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# --------------------------------------------------------------------------
|
| 135 |
+
# Detection + aggregation
|
| 136 |
+
# --------------------------------------------------------------------------
|
| 137 |
+
|
| 138 |
+
def annotate_image(img: Image.Image, boxes, label_prefix=""):
|
| 139 |
+
"""Draw YOLO detection boxes on a copy of the image."""
|
| 140 |
+
out = img.copy()
|
| 141 |
+
draw = ImageDraw.Draw(out)
|
| 142 |
+
try:
|
| 143 |
+
font = ImageFont.load_default()
|
| 144 |
+
except Exception:
|
| 145 |
+
font = None
|
| 146 |
+
|
| 147 |
+
for cls_id, conf, xyxy in boxes:
|
| 148 |
+
x1, y1, x2, y2 = xyxy
|
| 149 |
+
color = (220, 30, 30) if cls_id == 0 else (30, 120, 220)
|
| 150 |
+
draw.rectangle([x1, y1, x2, y2], outline=color, width=3)
|
| 151 |
+
label = f"{CLASS_NAMES.get(cls_id, cls_id)} {conf:.2f}"
|
| 152 |
+
draw.text((x1 + 3, max(0, y1 - 14)), label, fill=color, font=font)
|
| 153 |
+
return out
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def process_stl(stl_file, num_azimuths, num_elevations, conf_threshold, img_size,
|
| 157 |
+
progress=gr.Progress()):
|
| 158 |
+
if stl_file is None:
|
| 159 |
+
return None, "Please upload an STL file.", None
|
| 160 |
+
|
| 161 |
+
progress(0, desc="Loading model...")
|
| 162 |
+
model = get_model()
|
| 163 |
+
|
| 164 |
+
progress(0.05, desc="Loading mesh...")
|
| 165 |
+
tris, center, scale, mesh = load_mesh_triangles(stl_file)
|
| 166 |
+
|
| 167 |
+
views = build_view_angles(int(num_azimuths), int(num_elevations))
|
| 168 |
+
total = len(views)
|
| 169 |
+
|
| 170 |
+
all_detections = [] # rows for the results table
|
| 171 |
+
annotated_images = [] # (image, caption) for the gallery
|
| 172 |
+
max_conf_per_class = {0: 0.0, 1: 0.0}
|
| 173 |
+
any_hit = False
|
| 174 |
+
|
| 175 |
+
for i, (az, el) in enumerate(views):
|
| 176 |
+
progress((0.1 + 0.85 * i / total), desc=f"Rendering + detecting view {i+1}/{total}...")
|
| 177 |
+
frame = render_view(tris, center, scale, az, el, img_size=int(img_size))
|
| 178 |
+
|
| 179 |
+
result = model.predict(source=np.array(frame), conf=float(conf_threshold), verbose=False)[0]
|
| 180 |
+
|
| 181 |
+
boxes = []
|
| 182 |
+
for b in result.boxes:
|
| 183 |
+
cls_id = int(b.cls.item())
|
| 184 |
+
conf = float(b.conf.item())
|
| 185 |
+
xyxy = [float(v) for v in b.xyxy[0].tolist()]
|
| 186 |
+
boxes.append((cls_id, conf, xyxy))
|
| 187 |
+
max_conf_per_class[cls_id] = max(max_conf_per_class[cls_id], conf)
|
| 188 |
+
any_hit = True
|
| 189 |
+
all_detections.append({
|
| 190 |
+
"View": f"az={az:.0f} el={el:.0f}",
|
| 191 |
+
"Class": CLASS_NAMES.get(cls_id, str(cls_id)),
|
| 192 |
+
"Confidence": round(conf, 3),
|
| 193 |
+
})
|
| 194 |
+
|
| 195 |
+
if boxes:
|
| 196 |
+
annotated = annotate_image(frame, boxes)
|
| 197 |
+
caption = f"az={az:.0f}, el={el:.0f} | " + ", ".join(
|
| 198 |
+
f"{CLASS_NAMES.get(c, c)} {p:.2f}" for c, p, _ in boxes
|
| 199 |
+
)
|
| 200 |
+
annotated_images.append((annotated, caption))
|
| 201 |
+
|
| 202 |
+
progress(1.0, desc="Done")
|
| 203 |
+
|
| 204 |
+
# Build verdict
|
| 205 |
+
if any_hit:
|
| 206 |
+
parts = []
|
| 207 |
+
if max_conf_per_class[0] > 0:
|
| 208 |
+
parts.append(f"GUN detected (max confidence {max_conf_per_class[0]:.2f})")
|
| 209 |
+
if max_conf_per_class[1] > 0:
|
| 210 |
+
parts.append(f"KNIFE detected (max confidence {max_conf_per_class[1]:.2f})")
|
| 211 |
+
verdict = "\u26a0\ufe0f WEAPON-LIKE OBJECT DETECTED: " + " | ".join(parts)
|
| 212 |
+
verdict += f"\n\nDetected in {len(annotated_images)} of {total} rendered views."
|
| 213 |
+
else:
|
| 214 |
+
verdict = f"\u2705 No firearm or knife detected across {total} rendered views."
|
| 215 |
+
|
| 216 |
+
verdict += ("\n\nNote: this is a heuristic screen based on rendered silhouettes "
|
| 217 |
+
"of the mesh, not a photo of a real object. Treat results as advisory, "
|
| 218 |
+
"not a certified determination.")
|
| 219 |
+
|
| 220 |
+
if not annotated_images:
|
| 221 |
+
# still show a couple of representative views so the user sees *something*
|
| 222 |
+
sample_idxs = [0, total // 2] if total > 1 else [0]
|
| 223 |
+
for idx in sample_idxs:
|
| 224 |
+
az, el = views[idx]
|
| 225 |
+
frame = render_view(tris, center, scale, az, el, img_size=int(img_size))
|
| 226 |
+
annotated_images.append((frame, f"az={az:.0f}, el={el:.0f} (no detection)"))
|
| 227 |
+
|
| 228 |
+
df = pd.DataFrame(all_detections) if all_detections else pd.DataFrame(
|
| 229 |
+
columns=["View", "Class", "Confidence"])
|
| 230 |
+
|
| 231 |
+
return annotated_images, verdict, df
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# --------------------------------------------------------------------------
|
| 235 |
+
# Gradio UI
|
| 236 |
+
# --------------------------------------------------------------------------
|
| 237 |
+
|
| 238 |
+
with gr.Blocks(title="STL Firearm/Knife Screener") as demo:
|
| 239 |
+
gr.Markdown(
|
| 240 |
+
"# 🔫🔪 STL Firearm / Knife Screener\n"
|
| 241 |
+
"Upload a 3D model (`.stl`). The app spins a virtual camera all the way "
|
| 242 |
+
"around it — including straight-down and straight-up shots — renders each "
|
| 243 |
+
"view, and runs the **Guns-100-11m** YOLOv11 detector on every frame."
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
with gr.Row():
|
| 247 |
+
with gr.Column(scale=1):
|
| 248 |
+
stl_input = gr.File(label="Upload STL file", file_types=[".stl"], type="filepath")
|
| 249 |
+
num_azimuths = gr.Slider(4, 16, value=8, step=1, label="Azimuth steps per elevation ring (more = finer 360° coverage, slower)")
|
| 250 |
+
num_elevations = gr.Slider(1, 5, value=3, step=1, label="Elevation rings between the poles")
|
| 251 |
+
conf_threshold = gr.Slider(0.05, 0.95, value=0.25, step=0.05, label="Detection confidence threshold")
|
| 252 |
+
img_size = gr.Slider(320, 960, value=640, step=32, label="Render resolution (px)")
|
| 253 |
+
run_btn = gr.Button("Run 360° Scan", variant="primary")
|
| 254 |
+
|
| 255 |
+
with gr.Column(scale=2):
|
| 256 |
+
verdict_out = gr.Textbox(label="Verdict", lines=4)
|
| 257 |
+
gallery_out = gr.Gallery(label="Views with detections", columns=3, height=500)
|
| 258 |
+
table_out = gr.Dataframe(label="All detections", wrap=True)
|
| 259 |
+
|
| 260 |
+
run_btn.click(
|
| 261 |
+
fn=process_stl,
|
| 262 |
+
inputs=[stl_input, num_azimuths, num_elevations, conf_threshold, img_size],
|
| 263 |
+
outputs=[gallery_out, verdict_out, table_out],
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
if __name__ == "__main__":
|
| 267 |
+
demo.queue().launch()
|