Spaces:
Sleeping
Sleeping
| """ | |
| Gradio app: object-to-object distance estimation using SAM3 + Depth Anything 3. | |
| Deploy on Hugging Face Spaces: | |
| 1. Create a new Space -> SDK: Gradio -> hardware: GPU recommended (SAM3 + DA3 | |
| both run on CPU but are slow; a T4 or better is a big speedup). | |
| 2. Upload this file as app.py, plus requirements.txt (below) and README.md. | |
| 3. facebook/sam3 is gated: accept the license at | |
| https://huggingface.co/facebook/sam3, then add a `HF_TOKEN` secret to | |
| your Space (Settings -> Repository secrets) with a token that has access. | |
| """ | |
| import os | |
| import shutil | |
| import tempfile | |
| import time | |
| import numpy as np | |
| import torch | |
| import cv2 | |
| from PIL import Image, ExifTags | |
| import gradio as gr | |
| from transformers import Sam3Processor, Sam3Model | |
| from depth_anything_3.api import DepthAnything3 | |
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" | |
| # Half precision on CUDA gives a large SAM3 speed/memory win with negligible | |
| # accuracy impact for this pipeline (mask thresholds are coarse-grained); | |
| # CPU stays fp32 since there's no benefit there. Depth Anything 3 already | |
| # does its own internal mixed-precision autocast (see DepthAnything3.forward | |
| # in depth_anything_3/api.py), so it doesn't need this treatment here. | |
| SAM3_DTYPE = torch.float16 if DEVICE == "cuda" else torch.float32 | |
| HF_TOKEN = os.environ.get("HF_TOKEN") # set as a Space secret if sam3 is gated for you | |
| ARUCO_DICT = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50) | |
| # -------------------------------------------------------------------------- | |
| # Lazy, cached model loading β Spaces reload this module per-worker, so we | |
| # only want to pay the load cost once, not on every button click. | |
| # -------------------------------------------------------------------------- | |
| _segmenter = None | |
| _depther = None | |
| def get_segmenter(): | |
| global _segmenter | |
| if _segmenter is None: | |
| model = Sam3Model.from_pretrained( | |
| "facebook/sam3", token=HF_TOKEN, torch_dtype=SAM3_DTYPE | |
| ).to(DEVICE) | |
| processor = Sam3Processor.from_pretrained("facebook/sam3", token=HF_TOKEN) | |
| _segmenter = (model, processor) | |
| return _segmenter | |
| def get_depther(model_id: str): | |
| global _depther | |
| if _depther is None or _depther[0] != model_id: | |
| model = DepthAnything3.from_pretrained(model_id).to(DEVICE) | |
| _depther = (model_id, model) | |
| return _depther[1] | |
| # -------------------------------------------------------------------------- | |
| # Pipeline stages (same logic as the standalone script) | |
| # -------------------------------------------------------------------------- | |
| def segment_batch(image: Image.Image, text_prompts: list, score_threshold: float = 0.5) -> list: | |
| """Segments every text prompt against the same primary image in a single | |
| batched SAM3 forward pass, instead of one full forward pass (image | |
| encoder included) per object as before. The image is simply repeated | |
| across the batch dimension so `Sam3Processor`/`Sam3Model` treat it as | |
| `len(text_prompts)` independent (image, text) pairs, one call instead of | |
| N sequential Python-level calls. | |
| Returns a list of (mask_or_None, error_message_or_None) tuples, one per | |
| entry in `text_prompts`, in the same order. | |
| """ | |
| model, processor = get_segmenter() | |
| images = [image] * len(text_prompts) | |
| # dtype must match the (possibly fp16) model weights, or the forward | |
| # pass will fail with a dtype-mismatch error on `pixel_values`. | |
| inputs = processor(images=images, text=text_prompts, return_tensors="pt").to(DEVICE, dtype=SAM3_DTYPE) | |
| with torch.inference_mode(): | |
| outputs = model(**inputs) | |
| results = processor.post_process_instance_segmentation( | |
| outputs, | |
| threshold=score_threshold, | |
| mask_threshold=0.5, | |
| target_sizes=inputs.get("original_sizes").tolist(), | |
| ) | |
| per_prompt = [] | |
| for text_prompt, result in zip(text_prompts, results): | |
| masks = result["masks"] | |
| scores = result["scores"] | |
| if len(masks) == 0: | |
| per_prompt.append((None, | |
| f"No object found matching '{text_prompt}' above the confidence " | |
| f"threshold ({score_threshold}). Try a more specific or different phrase.")) | |
| continue | |
| best_idx = int(torch.argmax(scores)) | |
| mask = masks[best_idx] | |
| if hasattr(mask, "cpu"): | |
| mask = mask.cpu().numpy() | |
| per_prompt.append((np.asarray(mask).astype(bool), None)) | |
| return per_prompt | |
| def erode_mask(mask: np.ndarray, pixels: int = 3) -> np.ndarray: | |
| if pixels <= 0: | |
| return mask | |
| # cv2.erode is substantially faster than scipy.ndimage.binary_erosion for | |
| # simple binary structuring-element erosion on 2D masks. A 3x3 cross | |
| # kernel matches scipy's default 4-connected structuring element. | |
| kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3)) | |
| eroded = cv2.erode(mask.astype(np.uint8), kernel, iterations=pixels).astype(bool) | |
| return eroded if eroded.sum() > 20 else mask | |
| def robust_object_depth(depth, conf, mask, conf_percentile=40.0, mad_k=3.0): | |
| d, c = depth[mask], conf[mask] | |
| if d.size == 0: | |
| raise gr.Error("Mask is empty after erosion β object may be too small in this image.") | |
| conf_cut = np.percentile(c, conf_percentile) | |
| d_kept = d[c >= conf_cut] | |
| if d_kept.size < 5: | |
| d_kept = d | |
| med = np.median(d_kept) | |
| mad = np.median(np.abs(d_kept - med)) + 1e-8 | |
| inliers = d_kept[np.abs(d_kept - med) <= mad_k * 1.4826 * mad] | |
| if inliers.size == 0: | |
| inliers = d_kept | |
| return float(np.median(inliers)), float(np.std(inliers)), int(inliers.size) | |
| def backproject_to_3d(mask, robust_depth, intrinsics): | |
| ys, xs = np.nonzero(mask) | |
| cy, cx = np.median(ys), np.median(xs) | |
| fx, fy = intrinsics[0, 0], intrinsics[1, 1] | |
| px, py = intrinsics[0, 2], intrinsics[1, 2] | |
| z = robust_depth | |
| x = (cx - px) * z / fx | |
| y = (cy - py) * z / fy | |
| return np.array([x, y, z], dtype=np.float64) | |
| OBJECT_COLORS = [ | |
| (242, 183, 5), # amber | |
| (13, 148, 136), # teal | |
| (76, 29, 149), # violet | |
| (224, 102, 90), # coral | |
| (59, 130, 246), # blue | |
| (236, 72, 153), # pink | |
| ] | |
| def mask_overlay(image: Image.Image, masks: list) -> Image.Image: | |
| """Tints each object's mask a distinct color (cycling through | |
| OBJECT_COLORS if there are more objects than colors) for a quick | |
| visual sanity-check of what got segmented.""" | |
| # np.array(image).astype(np.float32) already allocates a fresh array, so | |
| # no extra .copy() is needed before mutating it in place. | |
| overlay = np.array(image).astype(np.float32) | |
| for i, mask in enumerate(masks): | |
| color = np.array(OBJECT_COLORS[i % len(OBJECT_COLORS)]) | |
| overlay[mask] = overlay[mask] * 0.4 + color * 0.6 | |
| return Image.fromarray(overlay.astype(np.uint8)) | |
| # -------------------------------------------------------------------------- | |
| # Addition 1: image-quality gate. | |
| # A blurry/motion-blurred view doesn't just give bad depth for itself β in | |
| # multi-view mode it can quietly drag down DA3's joint pose/depth solve for | |
| # every other view too. Flag it before it reaches the models. | |
| # -------------------------------------------------------------------------- | |
| def check_blur(image: Image.Image, threshold: float = 100.0): | |
| """Returns (sharpness_score, is_blurry). Variance of the Laplacian β | |
| lower means blurrier. Threshold is scene-dependent; 100 is a reasonable | |
| default for well-lit photos but tune it if you get false positives.""" | |
| gray = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2GRAY) | |
| score = float(cv2.Laplacian(gray, cv2.CV_64F).var()) | |
| return score, score < threshold | |
| # -------------------------------------------------------------------------- | |
| # Addition 2: EXIF-derived intrinsics as an alternative to DA3's estimated | |
| # ones. If you know the real camera, this removes a whole source of error | |
| # that no amount of downstream robust-statistics fixes. | |
| # -------------------------------------------------------------------------- | |
| def intrinsics_from_exif(image: Image.Image): | |
| """Approximates fx, fy in pixels from the 35mm-equivalent focal length | |
| tag, assuming a 36mm-wide full-frame-equivalent sensor. Returns a 3x3 | |
| intrinsics matrix, or None if the tag isn't present. This is an | |
| approximation, not a substitute for real calibration, but it's | |
| typically closer than a single-image network estimate.""" | |
| try: | |
| exif = image.getexif() | |
| tag_map = {ExifTags.TAGS.get(k, k): v for k, v in exif.items()} | |
| focal_35mm = tag_map.get("FocalLengthIn35mmFilm") | |
| if not focal_35mm: | |
| return None | |
| w, h = image.size | |
| fx = (float(focal_35mm) / 36.0) * w | |
| fy = fx # assume square pixels | |
| cx, cy = w / 2.0, h / 2.0 | |
| return np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float64) | |
| except Exception: | |
| return None | |
| def default_intrinsics(image: Image.Image, assumed_focal_35mm: float = 26.0) -> np.ndarray: | |
| """Last-resort fallback intrinsics for when neither DA3 nor the image's | |
| EXIF data provide any β e.g. screenshots, re-compressed/re-saved | |
| photos, or images from sources that strip metadata. Assumes a ~26mm | |
| 35mm-equivalent focal length (typical of smartphone main cameras) and a | |
| 36mm-wide full-frame-equivalent sensor, same approximation as | |
| `intrinsics_from_exif`. This is a coarse guess, not a calibration β | |
| resulting distances should be treated as rough estimates rather than | |
| precise measurements, but it lets the pipeline still produce a result | |
| instead of hard-failing.""" | |
| w, h = image.size | |
| fx = (assumed_focal_35mm / 36.0) * w | |
| fy = fx # assume square pixels | |
| cx, cy = w / 2.0, h / 2.0 | |
| return np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float64) | |
| # -------------------------------------------------------------------------- | |
| # Addition 3: automatic scale calibration via an ArUco marker of known | |
| # physical size, instead of requiring the user to type in a measured | |
| # reference length by hand. | |
| # | |
| # Print a DICT_4X4_50 marker at a known side length (e.g. 5cm) and place it | |
| # flat in the scene. If found, this replaces the manual scale-calibration | |
| # inputs entirely. | |
| # -------------------------------------------------------------------------- | |
| def detect_aruco_scale(image: Image.Image, depth: np.ndarray, intrinsics: np.ndarray, | |
| marker_real_size_m: float): | |
| if not marker_real_size_m or marker_real_size_m <= 0: | |
| return None, None | |
| gray = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2GRAY) | |
| detector = cv2.aruco.ArucoDetector(ARUCO_DICT, cv2.aruco.DetectorParameters()) | |
| corners, ids, _ = detector.detectMarkers(gray) | |
| if ids is None or len(corners) == 0: | |
| return None, None | |
| quad = corners[0][0] # 4x2 pixel corners of the first detected marker | |
| h, w = depth.shape | |
| points_3d = [] | |
| for (px, py) in quad: | |
| xi, yi = int(np.clip(px, 0, w - 1)), int(np.clip(py, 0, h - 1)) | |
| z = float(depth[yi, xi]) | |
| fx, fy = intrinsics[0, 0], intrinsics[1, 1] | |
| cx, cy = intrinsics[0, 2], intrinsics[1, 2] | |
| x = (px - cx) * z / fx | |
| y = (py - cy) * z / fy | |
| points_3d.append(np.array([x, y, z])) | |
| side_lengths = [np.linalg.norm(points_3d[i] - points_3d[(i + 1) % 4]) for i in range(4)] | |
| measured_size = float(np.median(side_lengths)) | |
| if measured_size <= 0: | |
| return None, None | |
| scale_correction = marker_real_size_m / measured_size | |
| return scale_correction, measured_size | |
| # -------------------------------------------------------------------------- | |
| # Main entry point called by the Gradio UI | |
| # -------------------------------------------------------------------------- | |
| def parse_object_list(objects_text: str) -> list: | |
| names = [n.strip() for n in objects_text.split(",")] | |
| names = [n for n in names if n] | |
| return names | |
| def run_pipeline(files, objects_text, depth_model_id, erosion_px, | |
| use_exif_intrinsics, aruco_marker_size, | |
| true_reference_length, measured_reference_length): | |
| if not files: | |
| raise gr.Error("Please upload at least one image (the primary view).") | |
| object_names = parse_object_list(objects_text or "") | |
| if len(object_names) < 2: | |
| raise gr.Error("Please list at least two objects, comma-separated, " | |
| "e.g. 'the red chair, the wooden table, the floor lamp'.") | |
| if len(object_names) > len(OBJECT_COLORS): | |
| gr.Warning(f"{len(object_names)} objects requested; overlay colors will repeat " | |
| f"after the first {len(OBJECT_COLORS)}.") | |
| image_paths = [f.name if hasattr(f, "name") else f for f in files] | |
| if len(image_paths) > 5: | |
| gr.Warning(f"{len(image_paths)} images provided; accuracy gains from extra views " | |
| f"typically plateau well before this many.") | |
| images = [Image.open(p).convert("RGB") for p in image_paths] | |
| # Addition 1: quality gate β warn (don't silently fail) on blurry views, | |
| # since a bad extra view can drag down the joint depth solve for all views. | |
| for i, img in enumerate(images): | |
| score, is_blurry = check_blur(img) | |
| if is_blurry: | |
| label = "primary image" if i == 0 else f"extra view {i}" | |
| gr.Warning(f"{label} looks blurry (sharpness score {score:.0f}). " | |
| f"This can reduce accuracy β consider retaking it.") | |
| primary_image = images[0] | |
| # Segment every object in a single batched SAM3 forward pass (one image, | |
| # N text prompts) instead of one full forward pass per object. A bad | |
| # prompt for one object shouldn't discard valid results for the others, | |
| # so failures are collected and reported rather than raised immediately. | |
| masks, valid_names, seg_warnings = [], [], [] | |
| for name, (mask, err) in zip(object_names, segment_batch(primary_image, object_names)): | |
| if err is not None: | |
| seg_warnings.append(f"'{name}': {err}") | |
| continue | |
| masks.append(erode_mask(mask, erosion_px)) | |
| valid_names.append(name) | |
| for w in seg_warnings: | |
| gr.Warning(f"Skipped {w}") | |
| if len(valid_names) < 2: | |
| raise gr.Error("Fewer than two objects could be segmented β see warnings above for details.") | |
| depther = get_depther(depth_model_id) | |
| # DA3 only populates conf/extrinsics/intrinsics when an export step runs | |
| # (see https://github.com/ByteDance-Seed/Depth-Anything-3/issues/38) -- | |
| # without export_dir/export_format they come back as None. mini_npz is | |
| # a lightweight raw-array export (no mesh/GLB generation), so this adds | |
| # negligible overhead just to force those fields to populate. | |
| # NOTE: DA3's export_to_mini_npz is decorated with @async_call, which just | |
| # fires a background Thread and returns immediately -- there's no handle to | |
| # join(), so inference() can return before the file is actually written. | |
| # Deleting the export dir right away (e.g. via a `with | |
| # tempfile.TemporaryDirectory()` block) races that thread and makes it crash | |
| # with a FileNotFoundError when it tries to write into a directory that no | |
| # longer exists. The intrinsics/conf fields on `prediction` itself are | |
| # already populated synchronously by this point, so we don't need the file | |
| # on disk -- we just give the background thread a brief grace period before | |
| # cleanup so it doesn't blow up in the Space logs. | |
| tmp_export_dir = tempfile.mkdtemp(prefix="da3_export_") | |
| try: | |
| prediction = depther.inference(images, export_dir=tmp_export_dir, export_format="mini_npz") | |
| mini_npz_path = os.path.join(tmp_export_dir, "exports", "mini_npz", "results.npz") | |
| for _ in range(50): # wait up to ~5s for the async export to land | |
| if os.path.isfile(mini_npz_path): | |
| break | |
| time.sleep(0.1) | |
| exported_files = os.listdir(tmp_export_dir) if os.path.isdir(tmp_export_dir) else [] | |
| finally: | |
| shutil.rmtree(tmp_export_dir, ignore_errors=True) | |
| # Diagnostics: these print to the Space's container logs (visible in the | |
| # "Logs" tab), not to the UI. If conf/intrinsics are still None after | |
| # requesting an export, this tells us whether the export step ran at all. | |
| print(f"[diag] exported files in tmp dir: {exported_files}") | |
| print(f"[diag] prediction attrs: {[a for a in dir(prediction) if not a.startswith('_')]}") | |
| print(f"[diag] depth is None: {prediction.depth is None}; " | |
| f"conf is None: {prediction.conf is None}; " | |
| f"intrinsics is None: {prediction.intrinsics is None}") | |
| def _to_numpy(t): | |
| return t.cpu().numpy() if hasattr(t, "cpu") else np.asarray(t) | |
| depth = np.squeeze(_to_numpy(prediction.depth[0])).astype(np.float32) | |
| # conf is a robustness aid, not a hard requirement -- degrade gracefully | |
| # to uniform confidence (median+MAD outlier rejection still applies) | |
| # rather than crashing if this build of DA3 doesn't return it. | |
| if prediction.conf is not None: | |
| conf = np.squeeze(_to_numpy(prediction.conf[0])).astype(np.float32) | |
| else: | |
| gr.Warning("Depth confidence map was unavailable from this DA3 build β " | |
| "falling back to uniform confidence (outlier rejection still applies).") | |
| conf = np.ones_like(depth) | |
| # DA3 internally resizes images before its forward pass (see the | |
| # "Processed Images" log line), so depth/conf come back at that internal | |
| # resolution -- not the original image's. Masks (from SAM3, via | |
| # segment_batch's target_sizes=original_sizes) and intrinsics are both in | |
| # original-image pixel space, so upsample depth/conf to match before | |
| # indexing with the masks below (otherwise mask/depth shapes mismatch and | |
| # boolean indexing raises IndexError). | |
| target_w, target_h = primary_image.size | |
| if depth.shape[:2] != (target_h, target_w): | |
| depth = cv2.resize(depth, (target_w, target_h), interpolation=cv2.INTER_LINEAR) | |
| conf = cv2.resize(conf, (target_w, target_h), interpolation=cv2.INTER_LINEAR) | |
| # Addition 2: prefer EXIF-derived intrinsics over DA3's estimated ones | |
| # when available and requested β a known camera beats a network guess. | |
| # Intrinsics are required for 3D back-projection; if DA3 didn't return | |
| # them, EXIF is tried next, and if that's also unavailable we fall back | |
| # to a rough default assumption (see default_intrinsics()) rather than | |
| # failing the whole request. | |
| intrinsics = prediction.intrinsics[0] if prediction.intrinsics is not None else None | |
| intrinsics_source = "DA3 (estimated)" | |
| if use_exif_intrinsics or intrinsics is None: | |
| exif_intrinsics = intrinsics_from_exif(primary_image) | |
| if exif_intrinsics is not None: | |
| intrinsics = exif_intrinsics | |
| intrinsics_source = "EXIF (35mm-equivalent focal length)" | |
| elif intrinsics is None: | |
| # Neither DA3 nor EXIF gave us intrinsics (e.g. a screenshot or a | |
| # re-compressed image with stripped metadata). Rather than hard- | |
| # failing the whole pipeline, degrade gracefully to a rough | |
| # default focal-length assumption so the user still gets a | |
| # result, just flagged as approximate. | |
| intrinsics = default_intrinsics(primary_image) | |
| intrinsics_source = "default estimate (~26mm-equivalent focal length assumption)" | |
| gr.Warning( | |
| "Camera intrinsics were unavailable from both DA3 and the image's EXIF data " | |
| "(e.g. no EXIF on this image). Falling back to a default focal-length " | |
| "assumption (~26mm-equivalent, typical smartphone camera) β treat the " | |
| "resulting distances as rough estimates rather than precise measurements. " | |
| "For better accuracy, provide a photo with intact EXIF metadata (avoid " | |
| "re-saving/re-compressing it, which often strips EXIF)." | |
| ) | |
| else: | |
| gr.Warning("No usable focal-length EXIF tag found on the primary image β " | |
| "falling back to DA3's estimated intrinsics.") | |
| # Addition 3: automatic scale calibration via ArUco marker, falling back | |
| # to manual true/measured length entry if no marker is found. | |
| scale_correction = 1.0 | |
| scale_source = "none (raw metric depth)" | |
| aruco_scale, aruco_measured = detect_aruco_scale(primary_image, depth, intrinsics, aruco_marker_size) | |
| if aruco_scale is not None: | |
| scale_correction = aruco_scale | |
| scale_source = f"ArUco marker (measured {aruco_measured:.4f} m, expected {aruco_marker_size:.4f} m)" | |
| elif true_reference_length and measured_reference_length and measured_reference_length > 0: | |
| scale_correction = float(true_reference_length) / float(measured_reference_length) | |
| scale_source = "manual reference length" | |
| elif aruco_marker_size: | |
| gr.Warning("ArUco marker size was set but no marker was detected in the primary image β " | |
| "check it's a DICT_4X4_50 marker, flat, and clearly visible.") | |
| # Per-object depth, uncertainty, and 3D point β independent of object count. | |
| points, stds, depths, pixel_counts = [], [], [], [] | |
| for mask in masks: | |
| d, std, n = robust_object_depth(depth, conf, mask) | |
| p = backproject_to_3d(mask, d, intrinsics) * scale_correction | |
| points.append(p) | |
| stds.append(std * scale_correction) | |
| depths.append(d * scale_correction) | |
| pixel_counts.append(n) | |
| n_obj = len(valid_names) | |
| dist_matrix = np.zeros((n_obj, n_obj)) | |
| unc_matrix = np.zeros((n_obj, n_obj)) | |
| for i in range(n_obj): | |
| for j in range(n_obj): | |
| if i == j: | |
| continue | |
| dist_matrix[i, j] = np.linalg.norm(points[i] - points[j]) | |
| unc_matrix[i, j] = np.sqrt(stds[i]**2 + stds[j]**2) | |
| overlay_img = mask_overlay(primary_image, masks) | |
| # Per-object table | |
| per_object_rows = "\n".join( | |
| f"| {name} | {depths[i]:.3f} m | {pixel_counts[i]} |" | |
| for i, name in enumerate(valid_names) | |
| ) | |
| # Pairwise distance matrix table (upper triangle to avoid repeating each pair twice) | |
| header = "| |" + "".join(f" {n} |" for n in valid_names) | |
| sep = "|---|" + "---|" * n_obj | |
| rows = [] | |
| for i in range(n_obj): | |
| cells = [] | |
| for j in range(n_obj): | |
| if j <= i: | |
| cells.append(" β |") | |
| else: | |
| cells.append(f" {dist_matrix[i, j]:.3f} Β± {unc_matrix[i, j]:.3f} m |") | |
| rows.append(f"| **{valid_names[i]}** |" + "".join(cells)) | |
| matrix_table = "\n".join([header, sep] + rows) | |
| summary = ( | |
| f"### Per-object depth\n" | |
| f"| Object | Depth | Pixels used |\n" | |
| f"|---|---|---|\n" | |
| f"{per_object_rows}\n\n" | |
| f"### Pairwise distances\n" | |
| f"{matrix_table}\n\n" | |
| f"Views used: {len(images)} " | |
| f"({'multi-view' if len(images) > 1 else 'single-view β add more views for better accuracy'})\n\n" | |
| f"Camera intrinsics: {intrinsics_source}\n\n" | |
| f"Scale calibration: {scale_source}" | |
| + (f" (Γ{scale_correction:.4f})" if scale_correction != 1.0 else "") | |
| ) | |
| return overlay_img, summary | |
| # -------------------------------------------------------------------------- | |
| # UI | |
| # -------------------------------------------------------------------------- | |
| with gr.Blocks(title="Object Distance Estimator β SAM3 + Depth Anything 3") as demo: | |
| gr.Markdown( | |
| "# Object Distance Estimator\n" | |
| "Segment two or more objects with text prompts (SAM3), estimate metric depth " | |
| "(Depth Anything 3), and compute the real-world pairwise distances between them.\n\n" | |
| "**Tip:** upload the primary photo plus 1β4 extra photos of the *same scene* " | |
| "from different angles for meaningfully better accuracy." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| files = gr.File( | |
| label="Images (first = primary view, rest = optional extra views)", | |
| file_count="multiple", | |
| file_types=["image"], | |
| ) | |
| objects_text = gr.Textbox( | |
| label="Objects (comma-separated, 2 or more)", | |
| placeholder="e.g. the red chair, the wooden table, the floor lamp", | |
| ) | |
| with gr.Accordion("Advanced settings", open=False): | |
| depth_model_id = gr.Dropdown( | |
| label="Depth model (must be a metric checkpoint for real distances)", | |
| choices=[ | |
| "depth-anything/da3metric-large", | |
| "depth-anything/DA3NESTED-GIANT-LARGE-1.1", | |
| ], | |
| value="depth-anything/da3metric-large", | |
| ) | |
| erosion_px = gr.Slider(label="Mask erosion (pixels)", minimum=0, maximum=10, value=3, step=1) | |
| gr.Markdown("**Camera intrinsics**") | |
| use_exif_intrinsics = gr.Checkbox( | |
| label="Prefer EXIF focal length over DA3's estimated intrinsics (if available)", | |
| value=True, | |
| ) | |
| gr.Markdown( | |
| "**Scale calibration** β pick one: place a printed ArUco `DICT_4X4_50` " | |
| "marker of known size in the scene (automatic), or enter a known length manually." | |
| ) | |
| aruco_marker_size = gr.Number(label="ArUco marker side length (m)", value=None) | |
| true_reference_length = gr.Number(label="Manual: true length (m)", value=None) | |
| measured_reference_length = gr.Number(label="Manual: measured length from this pipeline (m)", value=None) | |
| run_btn = gr.Button("Estimate distances", variant="primary") | |
| with gr.Column(scale=1): | |
| overlay_out = gr.Image(label="Mask overlay (each object gets a distinct color)") | |
| result_out = gr.Markdown() | |
| run_btn.click( | |
| fn=run_pipeline, | |
| inputs=[files, objects_text, depth_model_id, erosion_px, | |
| use_exif_intrinsics, aruco_marker_size, | |
| true_reference_length, measured_reference_length], | |
| outputs=[overlay_out, result_out], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() |