Tremick commited on
Commit
ec02f25
Β·
1 Parent(s): 7012f96

feat: video walk-around -> 3D environment map (SIFT + metric depth scale recovery)

Browse files
Files changed (2) hide show
  1. app.py +302 -93
  2. 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. Core inference helper (shared by Gradio UI and the REST endpoint)
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
- # 3. Gradio function - @spaces.GPU tells ZeroGPU to allocate a GPU here.
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 so it loads right-side up
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
- ply_header = (
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
- ply_filename = "/tmp/output_scene.ply"
120
- with open(ply_filename, "wb") as f:
121
- f.write(ply_header.encode("ascii"))
122
  f.write(vertices.tobytes())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
- return ply_filename
 
 
 
 
125
 
126
 
127
  # =====================================================================
128
- # 4. Gradio UI - uses demo.launch() (NOT gr.mount_gradio_app) so that
129
- # ZeroGPU can properly detect the @spaces.GPU decorated function above.
130
  # =====================================================================
131
- with gr.Blocks(title="Software-ToF 3D Cloud Visualizer") as demo:
132
- gr.Markdown("# πŸ“· Software-ToF Camera Simulation")
133
- gr.Markdown(
134
- "Upload any image to calculate absolute metric depths and generate "
135
- "an interactive 3D Point Cloud visualization."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- submit_btn.click(fn=create_3d_point_cloud, inputs=[input_img], outputs=[output_3d])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
 
151
  # =====================================================================
152
- # 5. Add the REST /get_metric_depth endpoint via Gradio's internal
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
- image_bytes = await file.read()
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
- post_processed = _run_depth_inference(image)
173
- metric_depth_map = (
174
- post_processed[0]["predicted_depth"]
175
- .detach().cpu().numpy().astype(np.float32)
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 - Hugging Face auto-detects the `demo` variable when
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