wuhp commited on
Commit
c1ffd3c
·
verified ·
1 Parent(s): 181b18d

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +267 -0
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()