| import os |
| os.environ.setdefault("OPENCV_IO_ENABLE_OPENEXR", "1") |
|
|
| import spaces |
| import time |
| import uuid |
| import tempfile |
| from pathlib import Path |
|
|
| import torch |
| import numpy as np |
| import cv2 |
| import gradio as gr |
| from scipy.ndimage import binary_dilation, uniform_filter, uniform_filter1d |
| from PIL import Image |
| import matplotlib |
| import trimesh |
| import trimesh.visual |
| import utils3d |
| from huggingface_hub import hf_hub_download |
|
|
| from moge.model.v2 import MoGeModel |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| REPO_ID = "hcarrion/DermDepth" |
| CKPT_DEPTH = "DermDepth_Synth_SKINL2_WoundsDB_DDI.pt" |
| CKPT_NORMAL = "DermDepth_Synth_Normals.pt" |
|
|
|
|
| def load_dermdepth(filename: str) -> MoGeModel: |
| path = hf_hub_download(repo_id=REPO_ID, filename=filename, repo_type="model") |
| ckpt = torch.load(path, map_location="cpu", weights_only=True) |
| model = MoGeModel(**ckpt["model_config"]) |
| model.load_state_dict(ckpt["model"], strict=False) |
| return model.to("cuda").eval() |
|
|
|
|
| print(f"Loading metric-depth model ({CKPT_DEPTH}) ...") |
| model_depth = load_dermdepth(CKPT_DEPTH) |
| print(f"Loading normal model ({CKPT_NORMAL}) ...") |
| model_normal = load_dermdepth(CKPT_NORMAL) |
| print("Models loaded.") |
|
|
| |
| |
| |
| |
| |
| RESOLUTION_TOKENS = { |
| "Draft (1200 tokens)": 1200, |
| "Balanced (2000 tokens)": 2000, |
| "High (2800 tokens)": 2800, |
| "Ultra (3600 tokens - max)": 3600, |
| } |
| DEFAULT_RESOLUTION = "Ultra (3600 tokens - max)" |
| DEPTH_CMAP = "Spectral" |
| |
| |
| MESH_TARGET_VERTS = 220_000 |
|
|
|
|
| |
| |
| |
| def colorize_depth(depth: np.ndarray, mask=None, cmap: str = DEPTH_CMAP): |
| """Colorize depth; also report the disparity range used for the mapping. |
| |
| The colormap is applied to *normalized disparity* (1/depth) -- which is why the |
| colorbar's depth ticks are deliberately non-uniformly spaced. |
| """ |
| if mask is None: |
| depth = np.where(depth > 0, depth, np.nan) |
| else: |
| depth = np.where((depth > 0) & mask, depth, np.nan) |
| disp = 1 / depth |
| min_disp, max_disp = np.nanquantile(disp, 0.001), np.nanquantile(disp, 0.99) |
| norm = (disp - min_disp) / (max_disp - min_disp) |
| colored = np.nan_to_num(matplotlib.colormaps[cmap](1.0 - norm)[..., :3], nan=0.0) |
| colored = np.ascontiguousarray((colored.clip(0, 1) * 255).astype(np.uint8)) |
| return colored, float(min_disp), float(max_disp) |
|
|
|
|
| def render_depth_with_colorbar(colored: np.ndarray, min_disp: float, max_disp: float) -> np.ndarray: |
| """Attach a metric colorbar (cm) to the colorized depth map. |
| |
| Colour is cmap(1 - t) for normalized disparity t, so the bar runs `Spectral_r` |
| over t and each tick is labelled with its true depth 1/(min_disp + t*(max_disp-min_disp)). |
| """ |
| from matplotlib.figure import Figure |
| from matplotlib.backends.backend_agg import FigureCanvasAgg |
| from matplotlib.cm import ScalarMappable |
| from matplotlib.colors import Normalize |
|
|
| h, w = colored.shape[:2] |
| if not np.isfinite([min_disp, max_disp]).all() or max_disp <= min_disp: |
| return colored |
|
|
| dpi = 100 |
| fig = Figure(figsize=(w / dpi * 1.22, h / dpi), dpi=dpi, facecolor="white") |
| FigureCanvasAgg(fig) |
|
|
| ax = fig.add_axes([0.0, 0.0, 0.80, 1.0]) |
| ax.imshow(colored) |
| ax.axis("off") |
|
|
| cax = fig.add_axes([0.83, 0.06, 0.035, 0.88]) |
| sm = ScalarMappable(cmap=f"{DEPTH_CMAP}_r", norm=Normalize(vmin=0.0, vmax=1.0)) |
| cb = fig.colorbar(sm, cax=cax) |
|
|
| ticks = np.linspace(0.0, 1.0, 6) |
| depths_cm = [100.0 / (min_disp + t * (max_disp - min_disp)) for t in ticks] |
| fmt = "{:.2f}" if max(depths_cm) < 10 else "{:.1f}" |
| cb.set_ticks(ticks) |
| cb.set_ticklabels([fmt.format(d) for d in depths_cm]) |
| cb.set_label("Metric depth (cm)", fontsize=11) |
| cb.ax.tick_params(labelsize=9) |
| |
| |
| |
| |
| cb.ax.text(0.5, 1.015, "near", transform=cb.ax.transAxes, ha="center", va="bottom", fontsize=8) |
| cb.ax.text(0.5, -0.015, "far", transform=cb.ax.transAxes, ha="center", va="top", fontsize=8) |
|
|
| fig.canvas.draw() |
| return np.ascontiguousarray(np.asarray(fig.canvas.buffer_rgba())[..., :3]) |
|
|
|
|
| def colorize_normal(normal: np.ndarray, mask=None) -> np.ndarray: |
| if mask is not None: |
| normal = np.where(mask[..., None], normal, 0) |
| normal = normal * [0.5, -0.5, -0.5] + 0.5 |
| return (normal.clip(0, 1) * 255).astype(np.uint8) |
|
|
|
|
| |
| |
| |
| @spaces.GPU(duration=60) |
| def predict( |
| image: np.ndarray, |
| resolution_level: str = DEFAULT_RESOLUTION, |
| apply_mask: bool = True, |
| remove_edges: bool = True, |
| max_size: int = 800, |
| ): |
| """Reconstruct metric 3D geometry from a single dermatological photograph.""" |
| if image is None: |
| return None, None, None, "Please provide an input image.", None, None, None, [], None |
|
|
| t0 = time.perf_counter() |
|
|
| larger_size = max(image.shape[:2]) |
| if larger_size > max_size: |
| scale = max_size / larger_size |
| image = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=cv2.INTER_AREA) |
|
|
| height, width = image.shape[:2] |
| num_tokens = RESOLUTION_TOKENS.get(resolution_level, 3600) |
|
|
| image_tensor = torch.tensor(image, dtype=torch.float32, device="cuda").permute(2, 0, 1) / 255 |
|
|
| |
| out_d = model_depth.infer(image_tensor, num_tokens=num_tokens, apply_mask=apply_mask, use_fp16=True) |
| out_d = {k: v.cpu().numpy() for k, v in out_d.items()} |
| points, depth, mask = out_d["points"], out_d["depth"], out_d["mask"] |
| normal_geom = out_d.get("normal", None) |
|
|
| |
| out_n = model_normal.infer(image_tensor, num_tokens=num_tokens, apply_mask=apply_mask, use_fp16=True) |
| out_n = {k: v.cpu().numpy() for k, v in out_n.items()} |
| normal_display = out_n.get("normal", None) |
| mask_n = out_n.get("mask", mask) |
|
|
| mask_cleaned = mask & ~utils3d.np.depth_map_edge(depth, rtol=0.04) if remove_edges else mask |
|
|
| depth_colored, min_disp, max_disp = colorize_depth(depth, mask=mask_cleaned) |
| depth_vis = render_depth_with_colorbar(depth_colored, min_disp, max_disp) |
| normal_vis = ( |
| colorize_normal(normal_display, mask=mask_n) if normal_display is not None else np.zeros_like(image) |
| ) |
|
|
| |
| |
| |
| |
| |
| stride = int(max(1, np.ceil(np.sqrt((height * width) / MESH_TARGET_VERTS)))) |
| pts_m = points[::stride, ::stride] |
| img_m = image[::stride, ::stride] |
| msk_m = mask_cleaned[::stride, ::stride] |
| nrm_m = normal_geom[::stride, ::stride] if normal_geom is not None else None |
| hm, wm = pts_m.shape[:2] |
|
|
| if nrm_m is None: |
| faces, vertices, vertex_colors, vertex_uvs = utils3d.np.build_mesh_from_map( |
| pts_m, img_m.astype(np.float32) / 255, utils3d.np.uv_map(hm, wm), |
| mask=msk_m, tri=True, |
| ) |
| vertex_normals = None |
| else: |
| faces, vertices, vertex_colors, vertex_uvs, vertex_normals = utils3d.np.build_mesh_from_map( |
| pts_m, img_m.astype(np.float32) / 255, utils3d.np.uv_map(hm, wm), |
| nrm_m, mask=msk_m, tri=True, |
| ) |
|
|
| vertices = vertices * np.array([1, -1, -1], dtype=np.float32) |
| vertex_uvs = vertex_uvs * np.array([1, -1], dtype=np.float32) + np.array([0, 1], dtype=np.float32) |
| if vertex_normals is not None: |
| vertex_normals = vertex_normals * np.array([1, -1, -1], dtype=np.float32) |
|
|
| tmpdir = Path(tempfile.gettempdir(), "dermdepth") |
| tmpdir.mkdir(exist_ok=True) |
| mesh_path = str(tmpdir / f"mesh_{uuid.uuid4().hex}.glb") |
| trimesh.Trimesh( |
| vertices=vertices, faces=faces, |
| visual=trimesh.visual.texture.TextureVisuals( |
| uv=vertex_uvs, |
| material=trimesh.visual.material.PBRMaterial( |
| baseColorTexture=Image.fromarray(image), metallicFactor=0.5, roughnessFactor=1.0, |
| ), |
| ), |
| vertex_normals=vertex_normals, process=False, |
| ).export(mesh_path) |
|
|
| fov_x, fov_y = np.rad2deg(utils3d.np.intrinsics_to_fov(out_d["intrinsics"])) |
| elapsed = time.perf_counter() - t0 |
|
|
| |
| valid = np.isfinite(depth) & (depth > 0) & mask_cleaned |
| if valid.any(): |
| d = depth[valid] |
| d_min, d_max, d_med = float(d.min()), float(d.max()), float(np.median(d)) |
| scale_txt = ( |
| f"| **Working distance** (median depth) | **{d_med * 100:.1f} cm** |\n" |
| f"| Depth range across the surface | {d_min * 100:.1f} – {d_max * 100:.1f} cm |\n" |
| f"| Depth spread (1st–99th pct) | {(np.quantile(d, 0.99) - np.quantile(d, 0.01)) * 1000:.1f} mm |\n" |
| ) |
| else: |
| scale_txt = "| Metric depth | no valid depth values |\n" |
|
|
| info_text = ( |
| "### Metric readout\n| | |\n|---|---|\n" |
| f"{scale_txt}" |
| f"| Field of view | {fov_x:.1f}° × {fov_y:.1f}° |\n" |
| f"| Inference resolution | {num_tokens} ViT tokens |\n" |
| f"| Input size | {width} × {height} px |\n" |
| f"| Mesh | {(hm*wm)//1000}k verts (stride {stride}) · measured at full res |\n" |
| f"| Time (both models) | {elapsed:.2f} s |\n\n" |
| f"<sub>Depth & 3D from `{CKPT_DEPTH}` (best model) · normals from `{CKPT_NORMAL}`.</sub>" |
| ) |
|
|
| |
| return mesh_path, depth_vis, normal_vis, info_text, points, image, image, [], image |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| def _xyz(points, smooth=3): |
| """Point map -> X/Y/Z float64 with non-finite as NaN, lightly denoised. |
| |
| infer(apply_mask=True) sets background points to +inf; left as inf the finite |
| differences below produce inf/NaN area elements that silently poison sums. |
| |
| The light box filter matters: area and volume are built from first differences, |
| which are biased strictly UPWARD by per-pixel depth noise (a noisy plane has more |
| apparent area than a flat one). Smoothing is applied NaN-aware so the foreground |
| border does not bleed in background values. |
| """ |
| p = np.asarray(points, dtype=np.float64).copy() |
| p[~np.isfinite(p)] = np.nan |
| valid = np.isfinite(p).all(axis=2) |
| if smooth and smooth > 1 and valid.any(): |
| w = uniform_filter(valid.astype(np.float64), size=smooth, mode="nearest") |
| out = np.empty_like(p) |
| for k in range(3): |
| ch = np.where(valid, p[..., k], 0.0) |
| s = uniform_filter(ch, size=smooth, mode="nearest") |
| out[..., k] = np.where(w > 1e-9, s / np.maximum(w, 1e-9), np.nan) |
| out[~valid] = np.nan |
| p = out |
| return p[..., 0], p[..., 1], p[..., 2] |
|
|
|
|
| def _area_elements(X, Y, Z): |
| """Return (surface_elem, proj_elem). |
| |
| surface_elem = |dP/dx x dP/dy| -> true 3D surface area per pixel (m^2) |
| proj_elem = |nz| -> that patch's footprint projected on the XY plane |
| |
| Volume between a surface and a reference is a column integral along Z, so its |
| per-pixel weight is the PROJECTED element, not the surface element. Weighting a |
| height by the surface element overestimates by 1/cos(theta) per pixel (+50% on a |
| hemisphere). nz is the z-component of the same cross product. |
| """ |
| def _fd(A, axis): |
| d = np.full_like(A, np.nan) |
| if axis == 1: |
| d[:, :-1] = A[:, 1:] - A[:, :-1] |
| else: |
| d[:-1, :] = A[1:, :] - A[:-1, :] |
| return d |
|
|
| dXdx, dYdx, dZdx = _fd(X, 1), _fd(Y, 1), _fd(Z, 1) |
| dXdy, dYdy, dZdy = _fd(X, 0), _fd(Y, 0), _fd(Z, 0) |
| nx = dYdx * dZdy - dZdx * dYdy |
| ny = dZdx * dXdy - dXdx * dZdy |
| nz = dXdx * dYdy - dYdx * dXdy |
| return np.sqrt(nx ** 2 + ny ** 2 + nz ** 2), np.abs(nz) |
|
|
|
|
| def _fit_reference(X, Y, Z, ring): |
| """Least-squares reference surface through a ring of surrounding skin. |
| |
| A PLANE is the wrong model for healthy skin on a limb: a plane fitted around a |
| patch of a 4 cm-radius forearm fabricates ~1900 mm^3 of "raised" volume where the |
| truth is zero. We fit a quadric, which absorbs limb curvature, and fall back to a |
| plane (then a constant) when the ring is too small to support it. |
| |
| Returns (surface_fn, rms_residual_m, model_name). |
| """ |
| rx, ry, rz = X[ring], Y[ring], Z[ring] |
| n = rx.size |
| x0, y0 = float(rx.mean()), float(ry.mean()) |
| dx, dy = rx - x0, ry - y0 |
|
|
| designs = [ |
| ("quadric", np.column_stack([dx ** 2, dx * dy, dy ** 2, dx, dy, np.ones_like(dx)]), 12), |
| ("plane", np.column_stack([dx, dy, np.ones_like(dx)]), 4), |
| ] |
| for name, A, need in designs: |
| if n < need: |
| continue |
| try: |
| coef, *_ = np.linalg.lstsq(A, rz, rcond=None) |
| except np.linalg.LinAlgError: |
| continue |
| if not np.isfinite(coef).all(): |
| continue |
| if name == "quadric": |
| fn = lambda Xq, Yq, c=coef: (c[0] * (Xq - x0) ** 2 + c[1] * (Xq - x0) * (Yq - y0) |
| + c[2] * (Yq - y0) ** 2 + c[3] * (Xq - x0) |
| + c[4] * (Yq - y0) + c[5]) |
| else: |
| fn = lambda Xq, Yq, c=coef: c[0] * (Xq - x0) + c[1] * (Yq - y0) + c[2] |
| rms = float(np.sqrt(np.mean((rz - fn(rx, ry)) ** 2))) |
| return fn, rms, name |
|
|
| zc = float(np.median(rz)) |
| return (lambda Xq, Yq, z=zc: np.full_like(Xq, z)), float(np.std(rz)), "constant" |
|
|
|
|
| def compute_region_measurements(points, mask, ring_iters=None): |
| """3D area / raised & cavity volume / extent for a painted region.""" |
| X, Y, Z = _xyz(points) |
| finite = np.isfinite(X) & np.isfinite(Y) & np.isfinite(Z) & (Z > 0) |
| surf_elem, proj_elem = _area_elements(X, Y, Z) |
| usable = finite & np.isfinite(surf_elem) & np.isfinite(proj_elem) |
| region = np.asarray(mask, bool) & usable |
| n = int(region.sum()) |
| if n < 25: |
| return {"error": "Painted region is too small, or lands on background with no valid geometry."} |
|
|
| |
| |
| |
| |
| if ring_iters is None: |
| ring_iters = int(max(3, round(0.15 * np.sqrt(n / np.pi)))) |
| ring = binary_dilation(mask, iterations=ring_iters) & ~np.asarray(mask, bool) & finite |
| if ring.sum() < 6: |
| ring = binary_dilation(mask, iterations=ring_iters + 4) & ~np.asarray(mask, bool) & finite |
| if ring.sum() < 3: |
| return {"error": "No healthy skin found around the painted region to use as a reference."} |
|
|
| ref_fn, rms, model = _fit_reference(X, Y, Z, ring) |
| ref_z = ref_fn(X[region], Y[region]) |
| elevation = ref_z - Z[region] |
| pe, se = proj_elem[region], surf_elem[region] |
|
|
| |
| |
| |
| |
| |
| |
| try: |
| A = np.column_stack([X[region], Y[region], np.ones_like(ref_z)]) |
| cf, *_ = np.linalg.lstsq(A, ref_z, rcond=None) |
| sag = float(np.max(np.abs(ref_z - A @ cf))) |
| except np.linalg.LinAlgError: |
| sag = float("nan") |
|
|
| raised = float(np.sum(np.maximum(elevation, 0.0) * pe)) |
| cavity = float(np.sum(np.maximum(-elevation, 0.0) * pe)) |
| area = float(np.sum(se)) |
|
|
| pts = np.column_stack([X[region], Y[region], Z[region]]) |
| pts_c = pts - pts.mean(axis=0) |
| try: |
| Vt = np.linalg.svd(pts_c, full_matrices=False)[2] |
| extent = float(np.ptp(pts_c @ Vt[0])) |
| minor = float(np.ptp(pts_c @ Vt[1])) |
| except np.linalg.LinAlgError: |
| extent = minor = float("nan") |
|
|
| max_raise_mm = max(float(np.nanmax(elevation)), 0.0) * 1e3 |
| max_depth_mm = max(float(-np.nanmin(elevation)), 0.0) * 1e3 |
| sag_mm = sag * 1e3 |
| relief_mm = max(max_raise_mm, max_depth_mm) |
|
|
| |
| |
| |
| warn = bool(np.isfinite(sag_mm) and sag_mm > 0.35 and sag_mm > 0.5 * relief_mm) |
|
|
| return { |
| "n_px": n, |
| "area_mm2": area * 1e6, |
| "raised_mm3": raised * 1e9, |
| "cavity_mm3": cavity * 1e9, |
| "max_raise_mm": max_raise_mm, |
| "max_depth_mm": max_depth_mm, |
| "extent_mm": extent * 1e3, |
| "minor_mm": minor * 1e3, |
| "ref_model": model, |
| "ref_rms_mm": rms * 1e3, |
| "ref_sag_mm": sag_mm, |
| "curvature_warning": warn, |
| } |
|
|
|
|
| def surface_arc_length(points, x1, y1, x2, y2, smooth=9): |
| """Arc length of the surface profile along the A->B image ray. |
| |
| Not a geodesic: it follows the straight line in IMAGE space, so it can exceed the |
| true shortest path over the surface. |
| """ |
| n = int(max(abs(x2 - x1), abs(y2 - y1))) + 1 |
| if n < 2: |
| return None |
| xs = np.linspace(x1, x2, n).round().astype(int) |
| ys = np.linspace(y1, y2, n).round().astype(int) |
| track = np.asarray(points, dtype=np.float64)[ys, xs] |
| good = np.isfinite(track).all(axis=1) |
| idx = np.flatnonzero(good) |
| |
| |
| |
| if idx.size < 2 or good.mean() < 0.6 or (idx.size > 1 and np.diff(idx).max() > 2): |
| return None |
| track = track[idx] |
| if len(track) > smooth > 1: |
| |
| |
| |
| t = np.linspace(0.0, 1.0, len(track))[:, None] |
| base = track[0] + (track[-1] - track[0]) * t |
| resid = uniform_filter1d(track - base, size=smooth, axis=0, mode="nearest") |
| resid[0] = 0.0 |
| resid[-1] = 0.0 |
| track = base + resid |
| return float(np.linalg.norm(np.diff(track, axis=0), axis=1).sum()) |
|
|
|
|
| |
| |
| |
| MEASURE_HINT = ("Click **two points** on the image to measure the estimated metric distance " |
| "between them.") |
|
|
|
|
| def _fmt_metric(metres: float) -> str: |
| mm = metres * 1000.0 |
| if mm < 10: |
| return f"{mm:.2f} mm" |
| if mm < 1000: |
| return f"{mm:.1f} mm ({mm / 10:.2f} cm)" |
| return f"{mm / 1000:.3f} m" |
|
|
|
|
| def _draw_marker(img, x, y, letter): |
| cv2.circle(img, (x, y), 9, (255, 255, 255), -1) |
| cv2.circle(img, (x, y), 9, (20, 20, 20), 2) |
| cv2.putText(img, letter, (x + 13, y - 9), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 4) |
| cv2.putText(img, letter, (x + 13, y - 9), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (20, 20, 20), 1) |
|
|
|
|
| def on_measure_click(points, base_img, clicks, evt: gr.SelectData): |
| """Two clicks -> straight-line distance through metric 3D space.""" |
| if points is None or base_img is None: |
| return None, "Run a reconstruction first, then click two points.", [] |
|
|
| h, w = points.shape[:2] |
| x = int(np.clip(evt.index[0], 0, w - 1)) |
| y = int(np.clip(evt.index[1], 0, h - 1)) |
|
|
| clicks = list(clicks or []) |
| if len(clicks) >= 2: |
| clicks = [] |
| clicks.append((x, y)) |
|
|
| img = np.ascontiguousarray(base_img.copy()) |
| for i, (cx, cy) in enumerate(clicks): |
| _draw_marker(img, cx, cy, "AB"[i]) |
|
|
| if len(clicks) == 1: |
| return img, "**A** set — now click point **B**.", clicks |
|
|
| (x1, y1), (x2, y2) = clicks |
| pa, pb = points[y1, x1], points[y2, x2] |
| if not (np.isfinite(pa).all() and np.isfinite(pb).all()): |
| return ( |
| img, |
| "⚠️ One of those points has no valid geometry (it's masked background). " |
| "Click on the skin surface itself.", |
| clicks, |
| ) |
|
|
| cv2.line(img, (x1, y1), (x2, y2), (255, 255, 255), 4) |
| cv2.line(img, (x1, y1), (x2, y2), (20, 20, 20), 1) |
| for i, (cx, cy) in enumerate(clicks): |
| _draw_marker(img, cx, cy, "AB"[i]) |
|
|
| chord = float(np.linalg.norm(pa - pb)) |
| ddepth = abs(float(pa[2] - pb[2])) |
| arc = surface_arc_length(points, x1, y1, x2, y2) |
|
|
| rows = [f"| Straight-line (chord) | **{_fmt_metric(chord)}** |"] |
| if arc is not None: |
| arc = max(arc, chord) |
| extra = (arc / chord - 1.0) * 100.0 if chord > 0 else 0.0 |
| rows.append(f"| Along the A\u2192B ray (surface profile) | {_fmt_metric(arc)} (+{extra:.1f}% over chord) |") |
| else: |
| rows.append("| Along the A\u2192B ray (surface profile) | n/a \u2014 the path crosses background |") |
| rows += [ |
| f"| Depth difference (\u0394z) | {_fmt_metric(ddepth)} |", |
| f"| A (x, y, z) | {pa[0]*100:.2f}, {pa[1]*100:.2f}, {pa[2]*100:.2f} cm |", |
| f"| B (x, y, z) | {pb[0]*100:.2f}, {pb[1]*100:.2f}, {pb[2]*100:.2f} cm |", |
| ] |
| readout = ( |
| f"### \U0001F4CF A \u2194 B \u2248 **{_fmt_metric(chord)}** (straight line)\n" |
| "| | |\n|---|---|\n" + "\n".join(rows) + "\n\n" |
| "<sub>Estimated from the predicted metric 3D point map \u2014 not pixels, and **not ground truth**. " |
| "The surface profile follows the straight A\u2192B line *in the image*; it is **not** a geodesic " |
| "(the shortest path over the surface), which can be shorter where it routes around raised tissue. " |
| "It is lightly smoothed so per-pixel depth noise does not inflate it. The chord is the more reliable " |
| "of the two. Click again to start a new measurement.</sub>" |
| ) |
| return img, readout, clicks |
|
|
|
|
| def on_volume_click(editor_value, points): |
| """Measure a painted region: 3D area, raised/cavity volume, width.""" |
| if points is None: |
| return "Run a reconstruction first, then paint over a region." |
| if not isinstance(editor_value, dict): |
| return "Paint over the region you want to measure, then press **Measure region**." |
|
|
| layers = editor_value.get("layers") or [] |
| mask = None |
| for layer in layers: |
| arr = np.asarray(layer) |
| if arr.ndim != 3 or arr.shape[2] < 4: |
| continue |
| m = arr[..., 3] > 127 |
| mask = m if mask is None else (mask | m) |
| if mask is None or not mask.any(): |
| return ("Nothing painted yet — use the brush to paint over the lesion or wound, " |
| "then press **Measure region**.") |
|
|
| if mask.shape != points.shape[:2]: |
| |
| |
| return ("The painted image no longer matches the reconstruction \u2014 press " |
| "**Reconstruct in 3D** again before measuring.") |
|
|
| r = compute_region_measurements(points, mask) |
| if "error" in r: |
| return f"⚠️ {r['error']}" |
|
|
| raised, cavity = r["raised_mm3"], r["cavity_mm3"] |
| hi, lo = max(raised, cavity), min(raised, cavity) |
| |
| |
| if hi < 2.0 * max(lo, 1e-9) or hi < 0.5: |
| headline = "### \U0001F4D0 No clear net relief in this region" |
| else: |
| headline = (f"### \U0001F4D0 Estimated {'cavity' if cavity > raised else 'raised'} volume " |
| f"(vs. a fitted reference) \u2248 **{hi:,.1f} mm\u00b3**") |
|
|
| warn = "" |
| if r["curvature_warning"]: |
| warn = ("> \u26A0\ufe0f **The surrounding skin is curved, not flat here.** The reference surface bows " |
| f"{r['ref_sag_mm']:.2f} mm across your region \u2014 comparable to the relief being measured \u2014 so " |
| "these volumes may be mostly body curvature rather than lesion. Paint a smaller region, or one on " |
| "flatter skin.\n\n") |
|
|
| return ( |
| f"{headline}\n\n{warn}" |
| "| | |\n|---|---|\n" |
| f"| Raised volume (toward camera) | {raised:,.1f} mm\u00b3 |\n" |
| f"| Cavity volume (below surround) | {cavity:,.1f} mm\u00b3 |\n" |
| f"| Max elevation above surround | {r['max_raise_mm']:.2f} mm |\n" |
| f"| Max depth below surround | {r['max_depth_mm']:.2f} mm |\n" |
| f"| 3D surface area *of painted region* | {r['area_mm2']:,.1f} mm\u00b2 ({r['area_mm2']/100:,.2f} cm\u00b2) |\n" |
| f"| Longest extent *of painted region* | {r['extent_mm']:.1f} mm (minor axis {r['minor_mm']:.1f} mm) |\n" |
| f"| Reference surface | {r['ref_model']} \u00b7 fit residual {r['ref_rms_mm']:.3f} mm \u00b7 bows {r['ref_sag_mm']:.2f} mm |\n" |
| f"| Region size | {r['n_px']:,} px |\n\n" |
| "<sub>**These are estimates against a surface least-squares fitted to a ring of skin just outside your " |
| "painted border \u2014 not measurements.** Heights are weighted by each pixel's projected area, so the " |
| "volume is a true column integral. **Raised** and **cavity** are separate because a nodular lesion " |
| "protrudes toward the lens while an ulcer recedes from it. Volume is insensitive to how generously you " |
| "paint (flat skin adds \u2248 zero), but **area and longest-extent describe the region you painted, not the " |
| "lesion** \u2014 so painting past the border inflates both. Treat these as comparative (same site, same " |
| "distance, over time), never absolute. Not ground truth and not a clinical measurement.</sub>" |
| ) |
|
|
|
|
| def reset_measure(base_img): |
| return base_img, MEASURE_HINT, [] |
|
|
|
|
| |
| |
| |
| EX = "examples" |
| |
| |
| |
| |
| WOUNDSDB_EXAMPLE = f"{EX}/woundsdb_case45_leg_venous_ulcer.png" |
| EXAMPLES = [ |
| [WOUNDSDB_EXAMPLE], |
| [f"{EX}/woundsdb_case33_hand_wound.png"], |
| [f"{EX}/woundsdb_case42_foot_wound.png"], |
| [f"{EX}/dsynth_sample000275_fitz1-2_dark_lesion_light_skin.png"], |
| [f"{EX}/dsynth_sample001300_fitz3-4_small_dark_lesion.png"], |
| [f"{EX}/dsynth_sample001925_fitz5-6_multiple_lesions.png"], |
| ] |
|
|
| CSS = """ |
| #col-container { max-width: 1280px; margin: 0 auto; } |
| .disclaimer { border-left: 3px solid #e11d48; padding-left: 12px; } |
| """ |
|
|
| with gr.Blocks(title="DermDepth") as demo: |
| points_state = gr.State(None) |
| base_state = gr.State(None) |
| clicks_state = gr.State([]) |
|
|
| with gr.Column(elem_id="col-container"): |
| gr.Markdown( |
| """ |
| # 🩺 DermDepth — Monocular Metric-Scale 3D for Dermatology |
| |
| Dermatology is largely a **measurement** problem: clinicians screen and monitor lesions |
| and wounds by tracking size, border, elevation and texture over time. Those properties are |
| inherently 3D — yet point-of-care imaging is almost always a single 2D photo. |
| |
| **DermDepth** recovers **metric-scale** 3D from *one* ordinary photograph — no depth sensor, |
| no second view, no ruler in frame. A 2.1M-parameter scale-and-normal head sits on a frozen |
| [MoGe-2](https://huggingface.co/Ruicheng/moge-2-vitl-normal) backbone, trained progressively |
| on **D-Synth** (synthetic renders with pixel-perfect depth, normals and intrinsics) and then |
| on real clinical data. On the paper's held-out benchmarks it cuts metric scale error from |
| **16.1× to 1.15×** on SKINL2 and from **81× to 1.95×** on DDI, and reduces Fitzpatrick |
| skin-tone scale disparity from **10.90 to 1.02**. Those are benchmark figures — accuracy on |
| your own photograph, from an unfamiliar camera or distance, may be substantially worse. |
| |
| Reconstruct an image, then use **📏 Measure distance** to click two points and read the |
| estimated metric distance between them. |
| """ |
| ) |
| gr.Markdown( |
| "⚠️ **Research demonstration only — not a medical device.** These outputs are not " |
| "diagnostic and must not inform clinical decisions. Every distance, area and volume shown is a " |
| "**model estimate from a single photograph**, not a measurement — treat them as comparative, " |
| "never absolute. Predictions on out-of-distribution images can fail silently.", |
| elem_classes="disclaimer", |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(scale=4): |
| input_image = gr.Image( |
| type="numpy", image_mode="RGB", label="Skin image", |
| height=340, value=WOUNDSDB_EXAMPLE, |
| ) |
| run_btn = gr.Button("Reconstruct in 3D", variant="primary", size="lg") |
| with gr.Accordion("Advanced settings", open=False): |
| resolution_level = gr.Dropdown( |
| choices=list(RESOLUTION_TOKENS.keys()), value=DEFAULT_RESOLUTION, |
| label="Inference resolution", |
| info="ViT token budget. The model's usable range is 1200–3600; Ultra is its true maximum.", |
| ) |
| max_size_input = gr.Number( |
| value=800, label="Max input size (px)", precision=0, minimum=256, maximum=2048, |
| info="Longest side before inference. Drives mesh density: 800 ≈ 37 MB GLB, " |
| "1024 ≈ 60 MB (finer, slower to load).", |
| ) |
| apply_mask_cb = gr.Checkbox(value=True, label="Apply predicted foreground mask") |
| remove_edges_cb = gr.Checkbox(value=True, label="Remove occlusion edges from mesh") |
| info_output = gr.Markdown() |
|
|
| with gr.Column(scale=6): |
| with gr.Tabs(): |
| with gr.Tab("🧊 3D reconstruction"): |
| mesh_output = gr.Model3D( |
| label="Drag to rotate · scroll to zoom", display_mode="solid", |
| clear_color=[0.07, 0.09, 0.12, 1.0], height=620, zoom_speed=1.2, |
| ) |
| with gr.Tab("🌈 Metric depth"): |
| depth_output = gr.Image( |
| type="numpy", label="Metric depth, with scale in cm", |
| format="png", interactive=False, height=620, |
| ) |
| with gr.Tab("🧭 Surface normals"): |
| normal_output = gr.Image( |
| type="numpy", label="Surface normals — from the normal-head checkpoint", |
| format="png", interactive=False, height=620, |
| ) |
| with gr.Tab("📏 Measure distance"): |
| measure_image = gr.Image( |
| type="numpy", label="Click two points", format="png", |
| interactive=False, height=560, |
| ) |
| measure_out = gr.Markdown(MEASURE_HINT) |
| reset_btn = gr.Button("Clear measurement", size="sm") |
| with gr.Tab("📐 Measure volume"): |
| volume_editor = gr.ImageEditor( |
| type="numpy", label="Paint over the lesion / wound", |
| brush=gr.Brush(colors=["#00e5ff"], color_mode="fixed", default_size=28), |
| eraser=gr.Eraser(default_size=28), |
| layers=False, height=520, interactive=True, |
| transforms=(), sources=(), |
| ) |
| volume_btn = gr.Button("Measure region", variant="primary") |
| volume_out = gr.Markdown( |
| "Paint over the lesion or wound — keeping the **border on healthy skin** — " |
| "then press **Measure region**." |
| ) |
|
|
| examples_ui = gr.Examples( |
| examples=EXAMPLES, |
| inputs=[input_image], |
| cache_examples=False, |
| label="Examples — first three are real clinical photos (WoundsDB, held-out cases); last three are synthetic renders (D-Synth)", |
| ) |
|
|
| gr.Markdown( |
| """ |
| --- |
| ### How it works |
| |
| | Output | Checkpoint | Why | |
| |---|---|---| |
| | Metric depth + 3D mesh | `DermDepth_Synth_SKINL2_WoundsDB_DDI.pt` | The paper's best model — D-Synth → SKINL2 + WoundsDB → DDI pseudo-GT for metric scale. | |
| | Surface normals | `DermDepth_Synth_Normals.pt` | Normal-head model trained on D-Synth, whose rendered normals are the only clean normal supervision (real ToF/plenoptic normals are noisy). | |
| |
| **📏 Measure distance** reports the estimated chord and along-the-surface arc between two |
| points; **📐 Measure volume** estimates 3D area and raised/cavity volume for a painted region. |
| Both read through the predicted metric point map, so they are estimates in millimetres |
| rather than pixel counts — not ground truth. |
| |
| ### Example credits |
| |
| The **first three** are real clinical photographs from **WoundsDB** (Chronic Wounds |
| Multimodal Image Database, Silesian University of Technology), used under |
| [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) at their native 320×240 — the |
| resolution the paper evaluates WoundsDB at. They are held-out cases (the paper splits |
| WoundsDB by case: 1–30 train, 31+ test): `case_45` (leg), `case_33` (hand), `case_42` (foot). |
| |
| > Kręcichwost, M., Czajkowska, J., Wijata, A., Juszczyk, J., Pyciński, B., Biesok, M., |
| > Rudzki, M., Majewski, J., Kostecki, J., & Pietka, E. (2021). Chronic wounds multimodal |
| > image database. *Computerized Medical Imaging and Graphics*, 88, 101844. |
| > [doi:10.1016/j.compmedimag.2020.101844](https://doi.org/10.1016/j.compmedimag.2020.101844) |
| |
| The **last three** are **synthetic renders** from |
| [D-Synth](https://huggingface.co/datasets/hcarrion/D-Synth) (Carrión & Norouzi), |
| [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/) — one per Fitzpatrick group |
| (I–II, III–IV, V–VI). They are renders, not patient photographs, and imply no diagnosis. |
| |
| No DDI imagery is bundled: Stanford's Research Use Agreement prohibits redistributing any |
| portion of that dataset. |
| |
| ### Links |
| |
| 📄 [Paper (MICCAI 2026)](https://arxiv.org/abs/2607.13010) · |
| 🤗 [Model](https://huggingface.co/hcarrion/DermDepth) · |
| 📊 [D-Synth dataset](https://huggingface.co/datasets/hcarrion/D-Synth) · |
| 💻 [Code](https://github.com/hectorcarrion/dermdepth) |
| """ |
| ) |
|
|
| run_btn.click( |
| fn=predict, |
| inputs=[input_image, resolution_level, apply_mask_cb, remove_edges_cb, max_size_input], |
| outputs=[mesh_output, depth_output, normal_output, info_output, |
| points_state, base_state, measure_image, clicks_state, volume_editor], |
| ) |
| examples_ui.load_input_event.then( |
| fn=predict, |
| inputs=[input_image, resolution_level, apply_mask_cb, remove_edges_cb, max_size_input], |
| outputs=[mesh_output, depth_output, normal_output, info_output, |
| points_state, base_state, measure_image, clicks_state, volume_editor], |
| ) |
| volume_btn.click(fn=on_volume_click, inputs=[volume_editor, points_state], outputs=[volume_out]) |
| measure_image.select( |
| fn=on_measure_click, |
| inputs=[points_state, base_state, clicks_state], |
| outputs=[measure_image, measure_out, clicks_state], |
| ) |
| reset_btn.click(fn=reset_measure, inputs=[base_state], outputs=[measure_image, measure_out, clicks_state]) |
|
|
| if __name__ == "__main__": |
| |
| demo.launch(mcp_server=True, theme=gr.themes.Default(primary_hue="teal"), css=CSS) |
|
|