Spaces:
Running on Zero
Running on Zero
feat: video walk-around -> 3D environment map (SIFT + metric depth scale recovery)
Browse files- app.py +302 -93
- requirements.txt +1 -0
app.py
CHANGED
|
@@ -2,6 +2,7 @@ import spaces # MUST be the very first import for ZeroGPU to work
|
|
| 2 |
|
| 3 |
import io
|
| 4 |
import numpy as np
|
|
|
|
| 5 |
from PIL import Image, UnidentifiedImageError
|
| 6 |
import torch
|
| 7 |
import gradio as gr
|
|
@@ -28,7 +29,7 @@ print(f"Model successfully loaded on target device: {device}")
|
|
| 28 |
|
| 29 |
|
| 30 |
# =====================================================================
|
| 31 |
-
# 2.
|
| 32 |
# =====================================================================
|
| 33 |
def _run_depth_inference(image: Image.Image):
|
| 34 |
"""Returns post-processed depth output list. Caller handles indexing."""
|
|
@@ -41,55 +42,8 @@ def _run_depth_inference(image: Image.Image):
|
|
| 41 |
)
|
| 42 |
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
# This MUST be bound to a Gradio event handler for ZeroGPU to detect it.
|
| 47 |
-
# =====================================================================
|
| 48 |
-
@spaces.GPU(duration=120)
|
| 49 |
-
def create_3d_point_cloud(input_pil_image):
|
| 50 |
-
"""Processes the image, calculates 3D vertices, and writes a binary .ply file."""
|
| 51 |
-
if input_pil_image is None:
|
| 52 |
-
return None
|
| 53 |
-
|
| 54 |
-
# Step A: Downscale large images before inference.
|
| 55 |
-
# DepthPro quality is stable at 512px; this dramatically cuts runtime.
|
| 56 |
-
image = input_pil_image.convert("RGB")
|
| 57 |
-
MAX_DIM = 512
|
| 58 |
-
if max(image.width, image.height) > MAX_DIM:
|
| 59 |
-
image.thumbnail((MAX_DIM, MAX_DIM), Image.LANCZOS)
|
| 60 |
-
|
| 61 |
-
post_processed = _run_depth_inference(image)
|
| 62 |
-
depth_matrix = (
|
| 63 |
-
post_processed[0]["predicted_depth"]
|
| 64 |
-
.detach().cpu().numpy().astype(np.float32)
|
| 65 |
-
)
|
| 66 |
-
|
| 67 |
-
# Step B: Pinhole camera projection (u, v, depth) -> (X, Y, Z)
|
| 68 |
-
height, width = depth_matrix.shape
|
| 69 |
-
color_np = np.array(image, dtype=np.float32) / 255.0
|
| 70 |
-
|
| 71 |
-
focal_length = float(max(width, height))
|
| 72 |
-
cx, cy = width / 2.0, height / 2.0
|
| 73 |
-
u, v = np.meshgrid(np.arange(width, dtype=np.float32),
|
| 74 |
-
np.arange(height, dtype=np.float32))
|
| 75 |
-
|
| 76 |
-
z = depth_matrix.flatten()
|
| 77 |
-
x = ((u.flatten() - cx) * z) / focal_length
|
| 78 |
-
y = ((v.flatten() - cy) * z) / focal_length
|
| 79 |
-
|
| 80 |
-
# Keep objects between 10 cm and 30 m; discard sky / infinite background
|
| 81 |
-
valid_mask = (z > 0.1) & (z < 30.0)
|
| 82 |
-
x, y, z = x[valid_mask], y[valid_mask], z[valid_mask]
|
| 83 |
-
rgb_ints = (color_np.reshape(-1, 3)[valid_mask] * 255).astype(np.uint8)
|
| 84 |
-
|
| 85 |
-
# Step C: Subsample to keep the browser renderer fast (<= 200k points)
|
| 86 |
-
MAX_POINTS = 200_000
|
| 87 |
-
if len(z) > MAX_POINTS:
|
| 88 |
-
idx = np.random.choice(len(z), MAX_POINTS, replace=False)
|
| 89 |
-
x, y, z = x[idx], y[idx], z[idx]
|
| 90 |
-
rgb_ints = rgb_ints[idx]
|
| 91 |
-
|
| 92 |
-
# Step D: Write BINARY PLY via numpy (100x faster than an ASCII for-loop)
|
| 93 |
num_points = len(z)
|
| 94 |
vertex_dtype = np.dtype([
|
| 95 |
('x', '<f4'), ('y', '<f4'), ('z', '<f4'),
|
|
@@ -97,13 +51,13 @@ def create_3d_point_cloud(input_pil_image):
|
|
| 97 |
])
|
| 98 |
vertices = np.empty(num_points, dtype=vertex_dtype)
|
| 99 |
vertices['x'] = x.astype(np.float32)
|
| 100 |
-
vertices['y'] = (-y).astype(np.float32) # invert Y
|
| 101 |
vertices['z'] = z.astype(np.float32)
|
| 102 |
vertices['red'] = rgb_ints[:, 0]
|
| 103 |
vertices['green'] = rgb_ints[:, 1]
|
| 104 |
vertices['blue'] = rgb_ints[:, 2]
|
| 105 |
|
| 106 |
-
|
| 107 |
"ply\n"
|
| 108 |
"format binary_little_endian 1.0\n"
|
| 109 |
f"element vertex {num_points}\n"
|
|
@@ -115,42 +69,306 @@ def create_3d_point_cloud(input_pil_image):
|
|
| 115 |
"property uchar blue\n"
|
| 116 |
"end_header\n"
|
| 117 |
)
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
with open(ply_filename, "wb") as f:
|
| 121 |
-
f.write(ply_header.encode("ascii"))
|
| 122 |
f.write(vertices.tobytes())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
# =====================================================================
|
| 128 |
-
#
|
| 129 |
-
# ZeroGPU can properly detect the @spaces.GPU decorated function above.
|
| 130 |
# =====================================================================
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
)
|
|
|
|
| 137 |
|
| 138 |
-
with gr.Row():
|
| 139 |
-
with gr.Column():
|
| 140 |
-
input_img = gr.Image(type="pil", label="Input Scene Photo")
|
| 141 |
-
submit_btn = gr.Button("Generate ToF 3D Structure", variant="primary")
|
| 142 |
-
with gr.Column():
|
| 143 |
-
output_3d = gr.Model3D(
|
| 144 |
-
label="Interactive 3D Point Cloud Canvas",
|
| 145 |
-
display_mode="point_cloud",
|
| 146 |
-
)
|
| 147 |
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
|
| 151 |
# =====================================================================
|
| 152 |
-
# 5.
|
| 153 |
-
# FastAPI app - this avoids gr.mount_gradio_app which breaks ZeroGPU.
|
| 154 |
# =====================================================================
|
| 155 |
@demo.app.get("/status")
|
| 156 |
def status():
|
|
@@ -161,31 +379,22 @@ def status():
|
|
| 161 |
async def get_metric_depth(file: UploadFile = File(...)):
|
| 162 |
if not (file.content_type or "").startswith("image/"):
|
| 163 |
raise HTTPException(status_code=400, detail="Uploaded file must be an image.")
|
| 164 |
-
|
| 165 |
try:
|
| 166 |
-
|
| 167 |
-
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
| 168 |
except (UnidentifiedImageError, OSError):
|
| 169 |
raise HTTPException(status_code=400, detail="Could not decode image.")
|
| 170 |
-
|
| 171 |
try:
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
)
|
| 177 |
-
buffer = io.BytesIO()
|
| 178 |
-
np.save(buffer, metric_depth_map)
|
| 179 |
-
return Response(content=buffer.getvalue(), media_type="application/octet-stream")
|
| 180 |
-
|
| 181 |
except Exception as e:
|
| 182 |
raise HTTPException(status_code=500, detail=f"Inference error: {str(e)}")
|
| 183 |
|
| 184 |
|
| 185 |
# =====================================================================
|
| 186 |
-
# 6. Launch
|
| 187 |
-
# sdk: gradio is set and launches it. The explicit launch() call
|
| 188 |
-
# here ensures it also works when run locally.
|
| 189 |
# =====================================================================
|
| 190 |
if __name__ == "__main__":
|
| 191 |
-
demo.launch()
|
|
|
|
| 2 |
|
| 3 |
import io
|
| 4 |
import numpy as np
|
| 5 |
+
import cv2
|
| 6 |
from PIL import Image, UnidentifiedImageError
|
| 7 |
import torch
|
| 8 |
import gradio as gr
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
# =====================================================================
|
| 32 |
+
# 2. Shared helpers
|
| 33 |
# =====================================================================
|
| 34 |
def _run_depth_inference(image: Image.Image):
|
| 35 |
"""Returns post-processed depth output list. Caller handles indexing."""
|
|
|
|
| 42 |
)
|
| 43 |
|
| 44 |
|
| 45 |
+
def _write_binary_ply(x, y, z, rgb_ints, path: str) -> str:
|
| 46 |
+
"""Pack arrays into a binary little-endian PLY and write to path."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
num_points = len(z)
|
| 48 |
vertex_dtype = np.dtype([
|
| 49 |
('x', '<f4'), ('y', '<f4'), ('z', '<f4'),
|
|
|
|
| 51 |
])
|
| 52 |
vertices = np.empty(num_points, dtype=vertex_dtype)
|
| 53 |
vertices['x'] = x.astype(np.float32)
|
| 54 |
+
vertices['y'] = (-y).astype(np.float32) # invert Y β right-side up
|
| 55 |
vertices['z'] = z.astype(np.float32)
|
| 56 |
vertices['red'] = rgb_ints[:, 0]
|
| 57 |
vertices['green'] = rgb_ints[:, 1]
|
| 58 |
vertices['blue'] = rgb_ints[:, 2]
|
| 59 |
|
| 60 |
+
header = (
|
| 61 |
"ply\n"
|
| 62 |
"format binary_little_endian 1.0\n"
|
| 63 |
f"element vertex {num_points}\n"
|
|
|
|
| 69 |
"property uchar blue\n"
|
| 70 |
"end_header\n"
|
| 71 |
)
|
| 72 |
+
with open(path, "wb") as f:
|
| 73 |
+
f.write(header.encode("ascii"))
|
|
|
|
|
|
|
| 74 |
f.write(vertices.tobytes())
|
| 75 |
+
return path
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _frame_to_pointcloud(image: Image.Image, depth: np.ndarray, max_pts: int = 50_000):
|
| 79 |
+
"""Project a single frame into (x, y, z, rgb) arrays in camera space."""
|
| 80 |
+
h, w = depth.shape
|
| 81 |
+
color_np = np.array(image, dtype=np.float32) / 255.0
|
| 82 |
+
focal = float(max(w, h))
|
| 83 |
+
cx, cy = w / 2.0, h / 2.0
|
| 84 |
+
|
| 85 |
+
u_g, v_g = np.meshgrid(np.arange(w, dtype=np.float32),
|
| 86 |
+
np.arange(h, dtype=np.float32))
|
| 87 |
+
z = depth.flatten()
|
| 88 |
+
x = ((u_g.flatten() - cx) * z) / focal
|
| 89 |
+
y = ((v_g.flatten() - cy) * z) / focal
|
| 90 |
+
|
| 91 |
+
valid = (z > 0.1) & (z < 30.0)
|
| 92 |
+
x, y, z = x[valid], y[valid], z[valid]
|
| 93 |
+
rgb = (color_np.reshape(-1, 3)[valid] * 255).astype(np.uint8)
|
| 94 |
|
| 95 |
+
if len(z) > max_pts:
|
| 96 |
+
idx = np.random.choice(len(z), max_pts, replace=False)
|
| 97 |
+
x, y, z, rgb = x[idx], y[idx], z[idx], rgb[idx]
|
| 98 |
+
|
| 99 |
+
return x, y, z, rgb
|
| 100 |
|
| 101 |
|
| 102 |
# =====================================================================
|
| 103 |
+
# 3a. Single-image function (original feature)
|
|
|
|
| 104 |
# =====================================================================
|
| 105 |
+
@spaces.GPU(duration=120)
|
| 106 |
+
def create_3d_point_cloud(input_pil_image):
|
| 107 |
+
"""Single photo β coloured 3D point cloud PLY."""
|
| 108 |
+
if input_pil_image is None:
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
image = input_pil_image.convert("RGB")
|
| 112 |
+
if max(image.width, image.height) > 512:
|
| 113 |
+
image.thumbnail((512, 512), Image.LANCZOS)
|
| 114 |
+
|
| 115 |
+
post = _run_depth_inference(image)
|
| 116 |
+
depth = post[0]["predicted_depth"].detach().cpu().numpy().astype(np.float32)
|
| 117 |
+
|
| 118 |
+
x, y, z, rgb = _frame_to_pointcloud(image, depth, max_pts=200_000)
|
| 119 |
+
return _write_binary_ply(x, y, z, rgb, "/tmp/output_scene.ply")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# =====================================================================
|
| 123 |
+
# 3b. Video β environment map (new feature)
|
| 124 |
+
# =====================================================================
|
| 125 |
+
@spaces.GPU(duration=300)
|
| 126 |
+
def create_map_from_video(video_path, frame_stride: int = 10, max_frames: int = 20):
|
| 127 |
+
"""
|
| 128 |
+
Walk-around video β merged 3D environment map.
|
| 129 |
+
|
| 130 |
+
Pipeline
|
| 131 |
+
--------
|
| 132 |
+
1. Extract keyframes (every `frame_stride` video frames, up to `max_frames`).
|
| 133 |
+
2. Downscale each frame to 512 px and run DepthPro β metric depth.
|
| 134 |
+
3. SIFT feature detection + FLANN matching between consecutive frames.
|
| 135 |
+
4. Essential Matrix β R, tΜ (unit translation from recoverPose).
|
| 136 |
+
5. Use metric depth at matched inliers to recover the true translation scale.
|
| 137 |
+
6. Accumulate 4Γ4 camera poses in a world coordinate frame.
|
| 138 |
+
7. Project each frame's pixels into world space and merge point clouds.
|
| 139 |
+
8. Subsample to β€ 500k points and write binary PLY.
|
| 140 |
+
"""
|
| 141 |
+
if video_path is None:
|
| 142 |
+
return None, "No video provided."
|
| 143 |
+
|
| 144 |
+
# ------------------------------------------------------------------
|
| 145 |
+
# Step 1: Extract keyframes
|
| 146 |
+
# ------------------------------------------------------------------
|
| 147 |
+
cap = cv2.VideoCapture(video_path)
|
| 148 |
+
if not cap.isOpened():
|
| 149 |
+
return None, "Could not open video file."
|
| 150 |
+
|
| 151 |
+
frames_pil = []
|
| 152 |
+
total_video_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 153 |
+
frame_indices = range(0, total_video_frames, int(frame_stride))
|
| 154 |
+
|
| 155 |
+
for fi in frame_indices:
|
| 156 |
+
if len(frames_pil) >= int(max_frames):
|
| 157 |
+
break
|
| 158 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, fi)
|
| 159 |
+
ret, bgr = cap.read()
|
| 160 |
+
if not ret:
|
| 161 |
+
break
|
| 162 |
+
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
| 163 |
+
pil = Image.fromarray(rgb)
|
| 164 |
+
if max(pil.width, pil.height) > 512:
|
| 165 |
+
pil.thumbnail((512, 512), Image.LANCZOS)
|
| 166 |
+
frames_pil.append(pil)
|
| 167 |
+
cap.release()
|
| 168 |
+
|
| 169 |
+
n = len(frames_pil)
|
| 170 |
+
if n < 2:
|
| 171 |
+
return None, f"Need at least 2 frames; got {n}."
|
| 172 |
+
|
| 173 |
+
W, H = frames_pil[0].width, frames_pil[0].height
|
| 174 |
+
focal = float(max(W, H))
|
| 175 |
+
cx, cy = W / 2.0, H / 2.0
|
| 176 |
+
K = np.array([[focal, 0, cx], [0, focal, cy], [0, 0, 1]], dtype=np.float64)
|
| 177 |
+
|
| 178 |
+
# ------------------------------------------------------------------
|
| 179 |
+
# Step 2: DepthPro inference on every keyframe
|
| 180 |
+
# ------------------------------------------------------------------
|
| 181 |
+
depth_maps = []
|
| 182 |
+
for pil in frames_pil:
|
| 183 |
+
post = _run_depth_inference(pil)
|
| 184 |
+
d = post[0]["predicted_depth"].detach().cpu().numpy().astype(np.float32)
|
| 185 |
+
depth_maps.append(d)
|
| 186 |
+
|
| 187 |
+
# ------------------------------------------------------------------
|
| 188 |
+
# Step 3: SIFT keypoints for every frame
|
| 189 |
+
# ------------------------------------------------------------------
|
| 190 |
+
sift = cv2.SIFT_create(nfeatures=2000)
|
| 191 |
+
kps_list, desc_list = [], []
|
| 192 |
+
for pil in frames_pil:
|
| 193 |
+
gray = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2GRAY)
|
| 194 |
+
kps, desc = sift.detectAndCompute(gray, None)
|
| 195 |
+
kps_list.append(kps)
|
| 196 |
+
desc_list.append(desc)
|
| 197 |
+
|
| 198 |
+
# ------------------------------------------------------------------
|
| 199 |
+
# Step 4-5: Relative pose estimation with metric-depth scale recovery
|
| 200 |
+
# ------------------------------------------------------------------
|
| 201 |
+
flann = cv2.FlannBasedMatcher({"algorithm": 1, "trees": 5}, {"checks": 50})
|
| 202 |
+
|
| 203 |
+
# poses[i] = 4Γ4 world-to-camera transform for frame i
|
| 204 |
+
poses = [np.eye(4, dtype=np.float64)]
|
| 205 |
+
|
| 206 |
+
for i in range(n - 1):
|
| 207 |
+
d_i = desc_list[i]
|
| 208 |
+
d_ip1 = desc_list[i + 1]
|
| 209 |
+
|
| 210 |
+
# Fall back to identity step if SIFT failed on either frame
|
| 211 |
+
if d_i is None or d_ip1 is None or len(d_i) < 10 or len(d_ip1) < 10:
|
| 212 |
+
poses.append(poses[-1].copy())
|
| 213 |
+
continue
|
| 214 |
+
|
| 215 |
+
try:
|
| 216 |
+
matches = flann.knnMatch(d_i, d_ip1, k=2)
|
| 217 |
+
except cv2.error:
|
| 218 |
+
poses.append(poses[-1].copy())
|
| 219 |
+
continue
|
| 220 |
+
|
| 221 |
+
# Lowe's ratio test
|
| 222 |
+
good = [m for m, nn in matches if m.distance < 0.7 * nn.distance]
|
| 223 |
+
if len(good) < 10:
|
| 224 |
+
poses.append(poses[-1].copy())
|
| 225 |
+
continue
|
| 226 |
+
|
| 227 |
+
pts1 = np.float32([kps_list[i][m.queryIdx].pt for m in good])
|
| 228 |
+
pts2 = np.float32([kps_list[i + 1][m.trainIdx].pt for m in good])
|
| 229 |
+
|
| 230 |
+
E, mask_E = cv2.findEssentialMat(
|
| 231 |
+
pts1, pts2, K, method=cv2.RANSAC, prob=0.999, threshold=1.0
|
| 232 |
+
)
|
| 233 |
+
if E is None:
|
| 234 |
+
poses.append(poses[-1].copy())
|
| 235 |
+
continue
|
| 236 |
+
|
| 237 |
+
_, R, t_unit, mask_pose = cv2.recoverPose(E, pts1, pts2, K, mask=mask_E)
|
| 238 |
+
|
| 239 |
+
# --- Scale recovery via metric depth ---
|
| 240 |
+
# recoverPose gives ||t|| = 1. We use DepthPro depth at inlier
|
| 241 |
+
# correspondences to estimate the true translation magnitude.
|
| 242 |
+
inliers = mask_pose.ravel() == 255
|
| 243 |
+
pts1_in = pts1[inliers]
|
| 244 |
+
pts2_in = pts2[inliers]
|
| 245 |
+
|
| 246 |
+
depth_i = depth_maps[i]
|
| 247 |
+
depth_ip1 = depth_maps[i + 1]
|
| 248 |
+
h_d, w_d = depth_i.shape
|
| 249 |
+
|
| 250 |
+
scales = []
|
| 251 |
+
for p1, p2 in zip(pts1_in, pts2_in):
|
| 252 |
+
u1, v1 = int(np.clip(p1[0], 0, w_d - 1)), int(np.clip(p1[1], 0, h_d - 1))
|
| 253 |
+
u2, v2 = int(np.clip(p2[0], 0, w_d - 1)), int(np.clip(p2[1], 0, h_d - 1))
|
| 254 |
+
|
| 255 |
+
d1 = float(depth_i[v1, u1])
|
| 256 |
+
d2 = float(depth_ip1[v2, u2])
|
| 257 |
+
if not (0.1 < d1 < 30.0 and 0.1 < d2 < 30.0):
|
| 258 |
+
continue
|
| 259 |
+
|
| 260 |
+
# 3-D positions in camera-i and camera-(i+1) coords
|
| 261 |
+
P1 = np.array([(p1[0] - cx) * d1 / focal,
|
| 262 |
+
(p1[1] - cy) * d1 / focal,
|
| 263 |
+
d1])
|
| 264 |
+
P2 = np.array([(p2[0] - cx) * d2 / focal,
|
| 265 |
+
(p2[1] - cy) * d2 / focal,
|
| 266 |
+
d2])
|
| 267 |
+
# Actual translation β P2 - R @ P1
|
| 268 |
+
t_actual = P2 - R @ P1
|
| 269 |
+
scales.append(np.linalg.norm(t_actual))
|
| 270 |
+
|
| 271 |
+
scale = float(np.median(scales)) if scales else 1.0
|
| 272 |
+
t_scaled = t_unit * scale
|
| 273 |
+
|
| 274 |
+
# Build incremental 4Γ4 transform
|
| 275 |
+
T_rel = np.eye(4, dtype=np.float64)
|
| 276 |
+
T_rel[:3, :3] = R
|
| 277 |
+
T_rel[:3, 3] = t_scaled.ravel()
|
| 278 |
+
|
| 279 |
+
poses.append(T_rel @ poses[-1])
|
| 280 |
+
|
| 281 |
+
# ------------------------------------------------------------------
|
| 282 |
+
# Step 6-7: Project each frame β world space and accumulate
|
| 283 |
+
# ------------------------------------------------------------------
|
| 284 |
+
all_pts, all_rgb = [], []
|
| 285 |
+
|
| 286 |
+
for i, (pil, depth, pose) in enumerate(zip(frames_pil, depth_maps, poses)):
|
| 287 |
+
x_c, y_c, z_c, rgb = _frame_to_pointcloud(pil, depth, max_pts=50_000)
|
| 288 |
+
|
| 289 |
+
# Camera β world: pts_world = inv(pose) @ pts_cam_homogeneous
|
| 290 |
+
pts_h = np.vstack([x_c, y_c, z_c, np.ones_like(z_c)]) # 4 Γ N
|
| 291 |
+
pts_w = (np.linalg.inv(pose) @ pts_h)[:3] # 3 Γ N
|
| 292 |
+
|
| 293 |
+
all_pts.append(pts_w.T) # N Γ 3
|
| 294 |
+
all_rgb.append(rgb)
|
| 295 |
+
|
| 296 |
+
XYZ = np.concatenate(all_pts, axis=0)
|
| 297 |
+
RGB = np.concatenate(all_rgb, axis=0)
|
| 298 |
+
|
| 299 |
+
# ------------------------------------------------------------------
|
| 300 |
+
# Step 8: Global subsample β binary PLY
|
| 301 |
+
# ------------------------------------------------------------------
|
| 302 |
+
MAX_TOTAL = 500_000
|
| 303 |
+
if len(XYZ) > MAX_TOTAL:
|
| 304 |
+
idx = np.random.choice(len(XYZ), MAX_TOTAL, replace=False)
|
| 305 |
+
XYZ = XYZ[idx]
|
| 306 |
+
RGB = RGB[idx]
|
| 307 |
+
|
| 308 |
+
ply_path = _write_binary_ply(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2],
|
| 309 |
+
RGB, "/tmp/environment_map.ply")
|
| 310 |
+
msg = (
|
| 311 |
+
f"β
Processed {n} frames Β· "
|
| 312 |
+
f"{len(XYZ):,} points in world map"
|
| 313 |
)
|
| 314 |
+
return ply_path, msg
|
| 315 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
+
# =====================================================================
|
| 318 |
+
# 4. Gradio UI β two tabs
|
| 319 |
+
# =====================================================================
|
| 320 |
+
with gr.Blocks(title="Software-ToF 3D Cloud Visualizer") as demo:
|
| 321 |
+
gr.Markdown("# π· Software-ToF Camera Simulation")
|
| 322 |
+
|
| 323 |
+
with gr.Tab("π· Single Image β 3D Snapshot"):
|
| 324 |
+
gr.Markdown(
|
| 325 |
+
"Upload any photo to get an interactive 3D point cloud "
|
| 326 |
+
"with absolute metric depths from Apple DepthPro."
|
| 327 |
+
)
|
| 328 |
+
with gr.Row():
|
| 329 |
+
with gr.Column():
|
| 330 |
+
input_img = gr.Image(type="pil", label="Input Scene Photo")
|
| 331 |
+
img_btn = gr.Button("Generate ToF 3D Structure", variant="primary")
|
| 332 |
+
with gr.Column():
|
| 333 |
+
output_3d = gr.Model3D(
|
| 334 |
+
label="Interactive 3D Point Cloud",
|
| 335 |
+
display_mode="point_cloud",
|
| 336 |
+
)
|
| 337 |
+
img_btn.click(fn=create_3d_point_cloud,
|
| 338 |
+
inputs=[input_img],
|
| 339 |
+
outputs=[output_3d])
|
| 340 |
+
|
| 341 |
+
with gr.Tab("π₯ Video β Environment Map"):
|
| 342 |
+
gr.Markdown(
|
| 343 |
+
"**Walk around a room while recording, then upload the video.** \n"
|
| 344 |
+
"Frames are matched with SIFT feature correspondences. "
|
| 345 |
+
"DepthPro provides metric depth per frame; the scale of each "
|
| 346 |
+
"relative camera translation is recovered from matched 3-D points, "
|
| 347 |
+
"then all frames are fused into a single world-coordinate map."
|
| 348 |
+
)
|
| 349 |
+
with gr.Row():
|
| 350 |
+
with gr.Column():
|
| 351 |
+
video_input = gr.Video(label="Walk-around Video")
|
| 352 |
+
frame_stride = gr.Slider(5, 30, value=10, step=5,
|
| 353 |
+
label="Frame Stride (sample every N frames)")
|
| 354 |
+
max_frames = gr.Slider(5, 30, value=20, step=5,
|
| 355 |
+
label="Max Frames to Process")
|
| 356 |
+
video_btn = gr.Button("Generate Environment Map", variant="primary")
|
| 357 |
+
with gr.Column():
|
| 358 |
+
map_output = gr.Model3D(
|
| 359 |
+
label="3D Environment Map",
|
| 360 |
+
display_mode="point_cloud",
|
| 361 |
+
)
|
| 362 |
+
status_box = gr.Textbox(label="Status", interactive=False)
|
| 363 |
+
video_btn.click(
|
| 364 |
+
fn=create_map_from_video,
|
| 365 |
+
inputs=[video_input, frame_stride, max_frames],
|
| 366 |
+
outputs=[map_output, status_box],
|
| 367 |
+
)
|
| 368 |
|
| 369 |
|
| 370 |
# =====================================================================
|
| 371 |
+
# 5. REST endpoints via Gradio's internal FastAPI app
|
|
|
|
| 372 |
# =====================================================================
|
| 373 |
@demo.app.get("/status")
|
| 374 |
def status():
|
|
|
|
| 379 |
async def get_metric_depth(file: UploadFile = File(...)):
|
| 380 |
if not (file.content_type or "").startswith("image/"):
|
| 381 |
raise HTTPException(status_code=400, detail="Uploaded file must be an image.")
|
|
|
|
| 382 |
try:
|
| 383 |
+
image = Image.open(io.BytesIO(await file.read())).convert("RGB")
|
|
|
|
| 384 |
except (UnidentifiedImageError, OSError):
|
| 385 |
raise HTTPException(status_code=400, detail="Could not decode image.")
|
|
|
|
| 386 |
try:
|
| 387 |
+
post = _run_depth_inference(image)
|
| 388 |
+
depth = post[0]["predicted_depth"].detach().cpu().numpy().astype(np.float32)
|
| 389 |
+
buf = io.BytesIO()
|
| 390 |
+
np.save(buf, depth)
|
| 391 |
+
return Response(content=buf.getvalue(), media_type="application/octet-stream")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
except Exception as e:
|
| 393 |
raise HTTPException(status_code=500, detail=f"Inference error: {str(e)}")
|
| 394 |
|
| 395 |
|
| 396 |
# =====================================================================
|
| 397 |
+
# 6. Launch
|
|
|
|
|
|
|
| 398 |
# =====================================================================
|
| 399 |
if __name__ == "__main__":
|
| 400 |
+
demo.launch()
|
requirements.txt
CHANGED
|
@@ -8,3 +8,4 @@ gradio
|
|
| 8 |
fastapi
|
| 9 |
uvicorn[standard]
|
| 10 |
python-multipart
|
|
|
|
|
|
| 8 |
fastapi
|
| 9 |
uvicorn[standard]
|
| 10 |
python-multipart
|
| 11 |
+
opencv-python-headless
|