havan2605 commited on
Commit
8160537
Β·
verified Β·
1 Parent(s): 81fa2d7

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +443 -0
app.py ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio app: object-to-object distance estimation using SAM3 + Depth Anything 3.
3
+
4
+ Deploy on Hugging Face Spaces:
5
+ 1. Create a new Space -> SDK: Gradio -> hardware: GPU recommended (SAM3 + DA3
6
+ both run on CPU but are slow; a T4 or better is a big speedup).
7
+ 2. Upload this file as app.py, plus requirements.txt (below) and README.md.
8
+ 3. facebook/sam3 is gated: accept the license at
9
+ https://huggingface.co/facebook/sam3, then add a `HF_TOKEN` secret to
10
+ your Space (Settings -> Repository secrets) with a token that has access.
11
+ """
12
+
13
+ import os
14
+ import numpy as np
15
+ import torch
16
+ import cv2
17
+ from PIL import Image, ExifTags
18
+ from scipy import ndimage
19
+ import gradio as gr
20
+
21
+ from transformers import Sam3Processor, Sam3Model
22
+ from depth_anything_3.api import DepthAnything3
23
+
24
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
25
+ HF_TOKEN = os.environ.get("HF_TOKEN") # set as a Space secret if sam3 is gated for you
26
+ ARUCO_DICT = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50)
27
+
28
+ # --------------------------------------------------------------------------
29
+ # Lazy, cached model loading β€” Spaces reload this module per-worker, so we
30
+ # only want to pay the load cost once, not on every button click.
31
+ # --------------------------------------------------------------------------
32
+
33
+ _segmenter = None
34
+ _depther = None
35
+
36
+
37
+ def get_segmenter():
38
+ global _segmenter
39
+ if _segmenter is None:
40
+ model = Sam3Model.from_pretrained("facebook/sam3", token=HF_TOKEN).to(DEVICE)
41
+ processor = Sam3Processor.from_pretrained("facebook/sam3", token=HF_TOKEN)
42
+ _segmenter = (model, processor)
43
+ return _segmenter
44
+
45
+
46
+ def get_depther(model_id: str):
47
+ global _depther
48
+ if _depther is None or _depther[0] != model_id:
49
+ model = DepthAnything3.from_pretrained(model_id).to(DEVICE)
50
+ _depther = (model_id, model)
51
+ return _depther[1]
52
+
53
+
54
+ # --------------------------------------------------------------------------
55
+ # Pipeline stages (same logic as the standalone script)
56
+ # --------------------------------------------------------------------------
57
+
58
+ def segment(image: Image.Image, text_prompt: str, score_threshold: float = 0.5) -> np.ndarray:
59
+ model, processor = get_segmenter()
60
+ inputs = processor(images=image, text=text_prompt, return_tensors="pt").to(DEVICE)
61
+ with torch.no_grad():
62
+ outputs = model(**inputs)
63
+
64
+ masks = processor.post_process_masks(outputs.pred_masks.cpu(), [image.size[::-1]])[0]
65
+ scores = outputs.pred_scores.cpu() if hasattr(outputs, "pred_scores") else None
66
+
67
+ if masks.shape[0] == 0:
68
+ raise gr.Error(f"No object found matching '{text_prompt}'. Try a more specific or different phrase.")
69
+
70
+ if scores is not None and scores.numel() > 0:
71
+ best_idx = int(torch.argmax(scores))
72
+ if float(scores[best_idx]) < score_threshold:
73
+ raise gr.Error(
74
+ f"Best match for '{text_prompt}' only scored {float(scores[best_idx]):.2f} "
75
+ f"(threshold {score_threshold}). Try rephrasing."
76
+ )
77
+ else:
78
+ best_idx = 0
79
+
80
+ return masks[best_idx].squeeze().numpy().astype(bool)
81
+
82
+
83
+ def erode_mask(mask: np.ndarray, pixels: int = 3) -> np.ndarray:
84
+ if pixels <= 0:
85
+ return mask
86
+ eroded = ndimage.binary_erosion(mask, iterations=pixels)
87
+ return eroded if eroded.sum() > 20 else mask
88
+
89
+
90
+ def robust_object_depth(depth, conf, mask, conf_percentile=40.0, mad_k=3.0):
91
+ d, c = depth[mask], conf[mask]
92
+ if d.size == 0:
93
+ raise gr.Error("Mask is empty after erosion β€” object may be too small in this image.")
94
+
95
+ conf_cut = np.percentile(c, conf_percentile)
96
+ d_kept = d[c >= conf_cut]
97
+ if d_kept.size < 5:
98
+ d_kept = d
99
+
100
+ med = np.median(d_kept)
101
+ mad = np.median(np.abs(d_kept - med)) + 1e-8
102
+ inliers = d_kept[np.abs(d_kept - med) <= mad_k * 1.4826 * mad]
103
+ if inliers.size == 0:
104
+ inliers = d_kept
105
+
106
+ return float(np.median(inliers)), float(np.std(inliers)), int(inliers.size)
107
+
108
+
109
+ def backproject_to_3d(mask, robust_depth, intrinsics):
110
+ ys, xs = np.nonzero(mask)
111
+ cy, cx = np.median(ys), np.median(xs)
112
+ fx, fy = intrinsics[0, 0], intrinsics[1, 1]
113
+ px, py = intrinsics[0, 2], intrinsics[1, 2]
114
+ z = robust_depth
115
+ x = (cx - px) * z / fx
116
+ y = (cy - py) * z / fy
117
+ return np.array([x, y, z], dtype=np.float64)
118
+
119
+
120
+ OBJECT_COLORS = [
121
+ (242, 183, 5), # amber
122
+ (13, 148, 136), # teal
123
+ (76, 29, 149), # violet
124
+ (224, 102, 90), # coral
125
+ (59, 130, 246), # blue
126
+ (236, 72, 153), # pink
127
+ ]
128
+
129
+
130
+ def mask_overlay(image: Image.Image, masks: list) -> Image.Image:
131
+ """Tints each object's mask a distinct color (cycling through
132
+ OBJECT_COLORS if there are more objects than colors) for a quick
133
+ visual sanity-check of what got segmented."""
134
+ arr = np.array(image).astype(np.float32)
135
+ overlay = arr.copy()
136
+ for i, mask in enumerate(masks):
137
+ color = np.array(OBJECT_COLORS[i % len(OBJECT_COLORS)])
138
+ overlay[mask] = overlay[mask] * 0.4 + color * 0.6
139
+ return Image.fromarray(overlay.astype(np.uint8))
140
+
141
+
142
+ # --------------------------------------------------------------------------
143
+ # Addition 1: image-quality gate.
144
+ # A blurry/motion-blurred view doesn't just give bad depth for itself β€” in
145
+ # multi-view mode it can quietly drag down DA3's joint pose/depth solve for
146
+ # every other view too. Flag it before it reaches the models.
147
+ # --------------------------------------------------------------------------
148
+
149
+ def check_blur(image: Image.Image, threshold: float = 100.0):
150
+ """Returns (sharpness_score, is_blurry). Variance of the Laplacian β€”
151
+ lower means blurrier. Threshold is scene-dependent; 100 is a reasonable
152
+ default for well-lit photos but tune it if you get false positives."""
153
+ gray = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2GRAY)
154
+ score = float(cv2.Laplacian(gray, cv2.CV_64F).var())
155
+ return score, score < threshold
156
+
157
+
158
+ # --------------------------------------------------------------------------
159
+ # Addition 2: EXIF-derived intrinsics as an alternative to DA3's estimated
160
+ # ones. If you know the real camera, this removes a whole source of error
161
+ # that no amount of downstream robust-statistics fixes.
162
+ # --------------------------------------------------------------------------
163
+
164
+ def intrinsics_from_exif(image: Image.Image):
165
+ """Approximates fx, fy in pixels from the 35mm-equivalent focal length
166
+ tag, assuming a 36mm-wide full-frame-equivalent sensor. Returns a 3x3
167
+ intrinsics matrix, or None if the tag isn't present. This is an
168
+ approximation, not a substitute for real calibration, but it's
169
+ typically closer than a single-image network estimate."""
170
+ try:
171
+ exif = image.getexif()
172
+ tag_map = {ExifTags.TAGS.get(k, k): v for k, v in exif.items()}
173
+ focal_35mm = tag_map.get("FocalLengthIn35mmFilm")
174
+ if not focal_35mm:
175
+ return None
176
+ w, h = image.size
177
+ fx = (float(focal_35mm) / 36.0) * w
178
+ fy = fx # assume square pixels
179
+ cx, cy = w / 2.0, h / 2.0
180
+ return np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float64)
181
+ except Exception:
182
+ return None
183
+
184
+
185
+ # --------------------------------------------------------------------------
186
+ # Addition 3: automatic scale calibration via an ArUco marker of known
187
+ # physical size, instead of requiring the user to type in a measured
188
+ # reference length by hand.
189
+ #
190
+ # Print a DICT_4X4_50 marker at a known side length (e.g. 5cm) and place it
191
+ # flat in the scene. If found, this replaces the manual scale-calibration
192
+ # inputs entirely.
193
+ # --------------------------------------------------------------------------
194
+
195
+ def detect_aruco_scale(image: Image.Image, depth: np.ndarray, intrinsics: np.ndarray,
196
+ marker_real_size_m: float):
197
+ if not marker_real_size_m or marker_real_size_m <= 0:
198
+ return None, None
199
+
200
+ gray = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2GRAY)
201
+ detector = cv2.aruco.ArucoDetector(ARUCO_DICT, cv2.aruco.DetectorParameters())
202
+ corners, ids, _ = detector.detectMarkers(gray)
203
+ if ids is None or len(corners) == 0:
204
+ return None, None
205
+
206
+ quad = corners[0][0] # 4x2 pixel corners of the first detected marker
207
+ h, w = depth.shape
208
+ points_3d = []
209
+ for (px, py) in quad:
210
+ xi, yi = int(np.clip(px, 0, w - 1)), int(np.clip(py, 0, h - 1))
211
+ z = float(depth[yi, xi])
212
+ fx, fy = intrinsics[0, 0], intrinsics[1, 1]
213
+ cx, cy = intrinsics[0, 2], intrinsics[1, 2]
214
+ x = (px - cx) * z / fx
215
+ y = (py - cy) * z / fy
216
+ points_3d.append(np.array([x, y, z]))
217
+
218
+ side_lengths = [np.linalg.norm(points_3d[i] - points_3d[(i + 1) % 4]) for i in range(4)]
219
+ measured_size = float(np.median(side_lengths))
220
+ if measured_size <= 0:
221
+ return None, None
222
+
223
+ scale_correction = marker_real_size_m / measured_size
224
+ return scale_correction, measured_size
225
+
226
+
227
+ # --------------------------------------------------------------------------
228
+ # Main entry point called by the Gradio UI
229
+ # --------------------------------------------------------------------------
230
+
231
+ def parse_object_list(objects_text: str) -> list:
232
+ names = [n.strip() for n in objects_text.split(",")]
233
+ names = [n for n in names if n]
234
+ return names
235
+
236
+
237
+ def run_pipeline(files, objects_text, depth_model_id, erosion_px,
238
+ use_exif_intrinsics, aruco_marker_size,
239
+ true_reference_length, measured_reference_length):
240
+ if not files:
241
+ raise gr.Error("Please upload at least one image (the primary view).")
242
+
243
+ object_names = parse_object_list(objects_text or "")
244
+ if len(object_names) < 2:
245
+ raise gr.Error("Please list at least two objects, comma-separated, "
246
+ "e.g. 'the red chair, the wooden table, the floor lamp'.")
247
+ if len(object_names) > len(OBJECT_COLORS):
248
+ gr.Warning(f"{len(object_names)} objects requested; overlay colors will repeat "
249
+ f"after the first {len(OBJECT_COLORS)}.")
250
+
251
+ image_paths = [f.name if hasattr(f, "name") else f for f in files]
252
+ if len(image_paths) > 5:
253
+ gr.Warning(f"{len(image_paths)} images provided; accuracy gains from extra views "
254
+ f"typically plateau well before this many.")
255
+
256
+ images = [Image.open(p).convert("RGB") for p in image_paths]
257
+
258
+ # Addition 1: quality gate β€” warn (don't silently fail) on blurry views,
259
+ # since a bad extra view can drag down the joint depth solve for all views.
260
+ for i, img in enumerate(images):
261
+ score, is_blurry = check_blur(img)
262
+ if is_blurry:
263
+ label = "primary image" if i == 0 else f"extra view {i}"
264
+ gr.Warning(f"{label} looks blurry (sharpness score {score:.0f}). "
265
+ f"This can reduce accuracy β€” consider retaking it.")
266
+
267
+ primary_image = images[0]
268
+
269
+ # Segment each object independently. A bad prompt for one object
270
+ # shouldn't discard valid results for the others, so failures are
271
+ # collected and reported rather than raised immediately.
272
+ masks, valid_names, seg_warnings = [], [], []
273
+ for name in object_names:
274
+ try:
275
+ mask = erode_mask(segment(primary_image, name), erosion_px)
276
+ masks.append(mask)
277
+ valid_names.append(name)
278
+ except gr.Error as e:
279
+ seg_warnings.append(f"'{name}': {e}")
280
+
281
+ for w in seg_warnings:
282
+ gr.Warning(f"Skipped {w}")
283
+
284
+ if len(valid_names) < 2:
285
+ raise gr.Error("Fewer than two objects could be segmented β€” see warnings above for details.")
286
+
287
+ depther = get_depther(depth_model_id)
288
+ prediction = depther.inference(images)
289
+ depth, conf = prediction.depth[0], prediction.conf[0]
290
+
291
+ # Addition 2: prefer EXIF-derived intrinsics over DA3's estimated ones
292
+ # when available and requested β€” a known camera beats a network guess.
293
+ intrinsics = prediction.intrinsics[0]
294
+ intrinsics_source = "DA3 (estimated)"
295
+ if use_exif_intrinsics:
296
+ exif_intrinsics = intrinsics_from_exif(primary_image)
297
+ if exif_intrinsics is not None:
298
+ intrinsics = exif_intrinsics
299
+ intrinsics_source = "EXIF (35mm-equivalent focal length)"
300
+ else:
301
+ gr.Warning("No usable focal-length EXIF tag found on the primary image β€” "
302
+ "falling back to DA3's estimated intrinsics.")
303
+
304
+ # Addition 3: automatic scale calibration via ArUco marker, falling back
305
+ # to manual true/measured length entry if no marker is found.
306
+ scale_correction = 1.0
307
+ scale_source = "none (raw metric depth)"
308
+ aruco_scale, aruco_measured = detect_aruco_scale(primary_image, depth, intrinsics, aruco_marker_size)
309
+ if aruco_scale is not None:
310
+ scale_correction = aruco_scale
311
+ scale_source = f"ArUco marker (measured {aruco_measured:.4f} m, expected {aruco_marker_size:.4f} m)"
312
+ elif true_reference_length and measured_reference_length and measured_reference_length > 0:
313
+ scale_correction = float(true_reference_length) / float(measured_reference_length)
314
+ scale_source = "manual reference length"
315
+ elif aruco_marker_size:
316
+ gr.Warning("ArUco marker size was set but no marker was detected in the primary image β€” "
317
+ "check it's a DICT_4X4_50 marker, flat, and clearly visible.")
318
+
319
+ # Per-object depth, uncertainty, and 3D point β€” independent of object count.
320
+ points, stds, depths, pixel_counts = [], [], [], []
321
+ for mask in masks:
322
+ d, std, n = robust_object_depth(depth, conf, mask)
323
+ p = backproject_to_3d(mask, d, intrinsics) * scale_correction
324
+ points.append(p)
325
+ stds.append(std * scale_correction)
326
+ depths.append(d * scale_correction)
327
+ pixel_counts.append(n)
328
+
329
+ n_obj = len(valid_names)
330
+ dist_matrix = np.zeros((n_obj, n_obj))
331
+ unc_matrix = np.zeros((n_obj, n_obj))
332
+ for i in range(n_obj):
333
+ for j in range(n_obj):
334
+ if i == j:
335
+ continue
336
+ dist_matrix[i, j] = np.linalg.norm(points[i] - points[j])
337
+ unc_matrix[i, j] = np.sqrt(stds[i]**2 + stds[j]**2)
338
+
339
+ overlay_img = mask_overlay(primary_image, masks)
340
+
341
+ # Per-object table
342
+ per_object_rows = "\n".join(
343
+ f"| {name} | {depths[i]:.3f} m | {pixel_counts[i]} |"
344
+ for i, name in enumerate(valid_names)
345
+ )
346
+
347
+ # Pairwise distance matrix table (upper triangle to avoid repeating each pair twice)
348
+ header = "| |" + "".join(f" {n} |" for n in valid_names)
349
+ sep = "|---|" + "---|" * n_obj
350
+ rows = []
351
+ for i in range(n_obj):
352
+ cells = []
353
+ for j in range(n_obj):
354
+ if j <= i:
355
+ cells.append(" β€” |")
356
+ else:
357
+ cells.append(f" {dist_matrix[i, j]:.3f} Β± {unc_matrix[i, j]:.3f} m |")
358
+ rows.append(f"| **{valid_names[i]}** |" + "".join(cells))
359
+ matrix_table = "\n".join([header, sep] + rows)
360
+
361
+ summary = (
362
+ f"### Per-object depth\n"
363
+ f"| Object | Depth | Pixels used |\n"
364
+ f"|---|---|---|\n"
365
+ f"{per_object_rows}\n\n"
366
+ f"### Pairwise distances\n"
367
+ f"{matrix_table}\n\n"
368
+ f"Views used: {len(images)} "
369
+ f"({'multi-view' if len(images) > 1 else 'single-view β€” add more views for better accuracy'})\n\n"
370
+ f"Camera intrinsics: {intrinsics_source}\n\n"
371
+ f"Scale calibration: {scale_source}"
372
+ + (f" (Γ—{scale_correction:.4f})" if scale_correction != 1.0 else "")
373
+ )
374
+
375
+ return overlay_img, summary
376
+
377
+
378
+ # --------------------------------------------------------------------------
379
+ # UI
380
+ # --------------------------------------------------------------------------
381
+
382
+ with gr.Blocks(title="Object Distance Estimator β€” SAM3 + Depth Anything 3") as demo:
383
+ gr.Markdown(
384
+ "# Object Distance Estimator\n"
385
+ "Segment two or more objects with text prompts (SAM3), estimate metric depth "
386
+ "(Depth Anything 3), and compute the real-world pairwise distances between them.\n\n"
387
+ "**Tip:** upload the primary photo plus 1–4 extra photos of the *same scene* "
388
+ "from different angles for meaningfully better accuracy."
389
+ )
390
+
391
+ with gr.Row():
392
+ with gr.Column(scale=1):
393
+ files = gr.File(
394
+ label="Images (first = primary view, rest = optional extra views)",
395
+ file_count="multiple",
396
+ file_types=["image"],
397
+ )
398
+ objects_text = gr.Textbox(
399
+ label="Objects (comma-separated, 2 or more)",
400
+ placeholder="e.g. the red chair, the wooden table, the floor lamp",
401
+ )
402
+
403
+ with gr.Accordion("Advanced settings", open=False):
404
+ depth_model_id = gr.Dropdown(
405
+ label="Depth model (must be a metric checkpoint for real distances)",
406
+ choices=[
407
+ "depth-anything/da3metric-large",
408
+ "depth-anything/da3nested-giant-large",
409
+ ],
410
+ value="depth-anything/da3metric-large",
411
+ )
412
+ erosion_px = gr.Slider(label="Mask erosion (pixels)", minimum=0, maximum=10, value=3, step=1)
413
+
414
+ gr.Markdown("**Camera intrinsics**")
415
+ use_exif_intrinsics = gr.Checkbox(
416
+ label="Prefer EXIF focal length over DA3's estimated intrinsics (if available)",
417
+ value=True,
418
+ )
419
+
420
+ gr.Markdown(
421
+ "**Scale calibration** β€” pick one: place a printed ArUco `DICT_4X4_50` "
422
+ "marker of known size in the scene (automatic), or enter a known length manually."
423
+ )
424
+ aruco_marker_size = gr.Number(label="ArUco marker side length (m)", value=None)
425
+ true_reference_length = gr.Number(label="Manual: true length (m)", value=None)
426
+ measured_reference_length = gr.Number(label="Manual: measured length from this pipeline (m)", value=None)
427
+
428
+ run_btn = gr.Button("Estimate distances", variant="primary")
429
+
430
+ with gr.Column(scale=1):
431
+ overlay_out = gr.Image(label="Mask overlay (each object gets a distinct color)")
432
+ result_out = gr.Markdown()
433
+
434
+ run_btn.click(
435
+ fn=run_pipeline,
436
+ inputs=[files, objects_text, depth_model_id, erosion_px,
437
+ use_exif_intrinsics, aruco_marker_size,
438
+ true_reference_length, measured_reference_length],
439
+ outputs=[overlay_out, result_out],
440
+ )
441
+
442
+ if __name__ == "__main__":
443
+ demo.launch()