magboola commited on
Commit
c495d1a
Β·
verified Β·
1 Parent(s): e529923

deploy sam3-zerogpu

Browse files
Files changed (2) hide show
  1. README.md +11 -2
  2. app.py +266 -80
README.md CHANGED
@@ -38,6 +38,14 @@ Sapiens pose" section below).
38
  stats (mean / p95 / max magnitude px, dominant direction deg, direction
39
  consistency)
40
 
 
 
 
 
 
 
 
 
41
  The backend client lives in
42
  `cadayn-backend/app/services/training/sam3p1_client.py`. Both halves are
43
  versioned independently; the contract below documents what they must agree
@@ -100,9 +108,10 @@ it is not a substitute for ordering.
100
  - `SAPIENS_HF_SPACE_URL` β€” `api_pose_image` + `api_pose_video_frames`
101
  (point at this same Space β€” the bundled endpoints share the container)
102
  - `NEUFLOW_HF_SPACE_URL` β€” `api_optical_flow` (same Space; Phase 2)
 
103
 
104
- All four env vars can point at the same Space URL β€” the bundled Space
105
- registers all six Gradio routes.
106
 
107
  ## Bundled Sapiens pose
108
 
 
38
  stats (mean / p95 / max magnitude px, dominant direction deg, direction
39
  consistency)
40
 
41
+ **Bundled Video Depth Anything (Phase 4 β€” same cap reasoning):**
42
+
43
+ - `api_video_depth` β€” video URL + anchor timestamps + optional per-frame
44
+ bboxes; returns RELATIVE depth in [0, 1] (never metric), per-bbox
45
+ p10/p50/p90 percentiles, and a near→far ordering of the bboxes per
46
+ frame. Set `is_relative: true` flag is always present in the response
47
+ so downstream code never promises metric distances.
48
+
49
  The backend client lives in
50
  `cadayn-backend/app/services/training/sam3p1_client.py`. Both halves are
51
  versioned independently; the contract below documents what they must agree
 
108
  - `SAPIENS_HF_SPACE_URL` β€” `api_pose_image` + `api_pose_video_frames`
109
  (point at this same Space β€” the bundled endpoints share the container)
110
  - `NEUFLOW_HF_SPACE_URL` β€” `api_optical_flow` (same Space; Phase 2)
111
+ - `VDA_HF_SPACE_URL` β€” `api_video_depth` (same Space; Phase 4)
112
 
113
+ All five env vars can point at the same Space URL β€” the bundled Space
114
+ registers all seven Gradio routes.
115
 
116
  ## Bundled Sapiens pose
117
 
app.py CHANGED
@@ -119,12 +119,10 @@ def _get_video_predictor() -> tuple[str, Any]:
119
  print("[GPU worker] Falling back to HF Transformers Sam3VideoModel...")
120
  from transformers import Sam3VideoModel, Sam3VideoProcessor
121
 
122
- _video_hf_processor = Sam3VideoProcessor.from_pretrained(
123
- "facebook/sam3", token=HF_TOKEN
 
124
  )
125
- _video_hf_model = Sam3VideoModel.from_pretrained(
126
- "facebook/sam3", token=HF_TOKEN, torch_dtype=torch.bfloat16
127
- ).to("cuda")
128
  print("[GPU worker] HF Sam3VideoModel loaded")
129
  return ("hf_sam3", (_video_hf_model, _video_hf_processor))
130
 
@@ -147,9 +145,7 @@ def _get_image_model_and_processor() -> tuple[Any, Any]:
147
  _image_model = model
148
  _image_processor = Sam3Processor(_image_model)
149
  dtype_sample = next(model.parameters()).dtype
150
- print(
151
- f"[GPU worker] SAM 3 image model ready (first-param dtype={dtype_sample})"
152
- )
153
  return _image_model, _image_processor
154
 
155
 
@@ -331,28 +327,16 @@ def _run_tracking_gpu(video_path: str, query: str) -> dict[str, Any]:
331
  video_storage_device="cpu",
332
  dtype=torch.bfloat16,
333
  )
334
- inference_session = processor.add_text_prompt(
335
- inference_session=inference_session, text=query
336
- )
337
  outputs_per_frame: dict[int, dict[str, Any]] = {}
338
- for model_outputs in model.propagate_in_video_iterator(
339
- inference_session=inference_session
340
- ):
341
  processed = processor.postprocess_outputs(inference_session, model_outputs)
342
  outputs_per_frame[model_outputs.frame_idx] = processed
343
 
344
- visible_indices = [
345
- fi
346
- for fi, payload in outputs_per_frame.items()
347
- if (payload.get("object_ids") or [])
348
- ]
349
  visible_indices.sort()
350
 
351
- fps_est = (
352
- len(frames) / _video_duration_s(video_path)
353
- if _video_duration_s(video_path) > 0
354
- else SAMPLE_FPS
355
- )
356
  tracked_segments: list[dict[str, float]] = []
357
  if visible_indices:
358
  current_start = visible_indices[0]
@@ -445,9 +429,7 @@ def _download_image_bytes(url: str, *, timeout_s: float = 30.0) -> bytes:
445
  for chunk in response.iter_bytes(chunk_size=256 * 1024):
446
  buf.write(chunk)
447
  if buf.tell() > MAX_IMAGE_BYTES:
448
- raise ValueError(
449
- f"Image exceeds {MAX_IMAGE_BYTES // (1024 * 1024)} MB limit"
450
- )
451
  return buf.getvalue()
452
 
453
 
@@ -525,9 +507,7 @@ def _masks_to_rle(output: Any) -> list[dict[str, Any] | None]:
525
  masks = candidate
526
  break
527
  if masks is None:
528
- print(
529
- f"[GPU worker] mask extraction: no mask-like key in output (got {sorted(output.keys())})"
530
- )
531
  if masks is None:
532
  return []
533
 
@@ -585,9 +565,7 @@ def _binary_mask_to_rle(mask: np.ndarray) -> dict[str, Any]:
585
  prev = 1 - prev
586
  return {
587
  "size": [int(h), int(w)],
588
- "counts": base64.b64encode(
589
- ",".join(str(r) for r in runs).encode("ascii")
590
- ).decode("ascii"),
591
  "format": "uncompressed_rle_b64",
592
  }
593
 
@@ -769,9 +747,7 @@ def _bbox_to_rle(
769
 
770
  return {
771
  "size": [int(img_h), int(img_w)],
772
- "counts": base64.b64encode(
773
- ",".join(str(r) for r in runs).encode("ascii")
774
- ).decode("ascii"),
775
  "format": "bbox_rle_b64",
776
  }
777
 
@@ -946,9 +922,7 @@ SAPIENS_MAX_PERSONS_PER_FRAME = 12
946
  SAPIENS_INPUT_HEIGHT = 1024
947
  SAPIENS_INPUT_WIDTH = 768
948
 
949
- SAPIENS_KEYPOINT_NAMES_PATH = os.path.join(
950
- os.path.dirname(__file__), "goliath_keypoints.txt"
951
- )
952
 
953
  # Worker-local model cache β€” separate from the SAM caches above so the
954
  # two model families don't compete for the same global slot.
@@ -963,9 +937,7 @@ def _sapiens_load_keypoint_names() -> list[str]:
963
  return _sapiens_worker_keypoint_names
964
  if os.path.exists(SAPIENS_KEYPOINT_NAMES_PATH):
965
  with open(SAPIENS_KEYPOINT_NAMES_PATH) as f:
966
- names = [
967
- line.strip() for line in f if line.strip() and not line.startswith("#")
968
- ]
969
  if names:
970
  _sapiens_worker_keypoint_names = names
971
  return _sapiens_worker_keypoint_names
@@ -1073,19 +1045,12 @@ def _sapiens_preprocess(image: np.ndarray) -> tuple[Any, tuple[int, int]]:
1073
  from PIL import Image
1074
 
1075
  orig_h, orig_w = image.shape[:2]
1076
- pil = Image.fromarray(image).resize(
1077
- (SAPIENS_INPUT_WIDTH, SAPIENS_INPUT_HEIGHT), Image.BILINEAR
1078
- )
1079
  arr = np.array(pil).astype(np.float32) / 255.0
1080
  mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
1081
  std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
1082
  arr = (arr - mean) / std
1083
- tensor = (
1084
- torch.from_numpy(arr)
1085
- .permute(2, 0, 1)
1086
- .unsqueeze(0)
1087
- .to("cuda", dtype=torch.float32)
1088
- )
1089
  return tensor, (orig_h, orig_w)
1090
 
1091
 
@@ -1215,9 +1180,7 @@ def _sapiens_extract_video_frames(
1215
  subprocess.run(cmd, check=True, capture_output=True, timeout=30)
1216
  frame_bgr = cv2.imread(out_path)
1217
  if frame_bgr is not None:
1218
- extracted_ff.append(
1219
- (ts, cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB))
1220
- )
1221
  except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
1222
  print(f"[sapiens] ffmpeg seek failed for ts={ts}: {exc}")
1223
  continue
@@ -1264,10 +1227,7 @@ def _sapiens_track_persons_across_frames(
1264
  # ByteTrack drops rejected detections from its output, so output
1265
  # length ≀ input length, and order isn't preserved. Per-input-row
1266
  # bbox-equality matching is the only safe way to assign IDs.
1267
- if (
1268
- getattr(tracked, "tracker_id", None) is not None
1269
- and getattr(tracked, "xyxy", None) is not None
1270
- ):
1271
  for out_idx in range(len(tracked.tracker_id)):
1272
  tid = tracked.tracker_id[out_idx]
1273
  if tid is None:
@@ -1276,10 +1236,7 @@ def _sapiens_track_persons_across_frames(
1276
  for p in frame_persons:
1277
  if "id" in p:
1278
  continue # already matched to a tracker_id
1279
- if (
1280
- abs(p["bbox"]["x1"] - float(out_x1)) < 1.0
1281
- and abs(p["bbox"]["y1"] - float(out_y1)) < 1.0
1282
- ):
1283
  p["id"] = f"p_{int(tid)}"
1284
  break
1285
 
@@ -1312,9 +1269,7 @@ def api_pose_image(
1312
  image_array = _sapiens_decode_image_np(raw)
1313
  h, w = image_array.shape[:2]
1314
 
1315
- person_bboxes = _sapiens_detect_persons(
1316
- image_array, detector_conf=detector_conf
1317
- )
1318
  if not person_bboxes:
1319
  return {
1320
  "ok": True,
@@ -1328,9 +1283,7 @@ def api_pose_image(
1328
  }
1329
 
1330
  pose_model = _sapiens_load_pose_model()
1331
- persons = _sapiens_infer_pose_for_persons(
1332
- pose_model, image_array, person_bboxes, confidence_threshold
1333
- )
1334
  for j, p in enumerate(persons):
1335
  p["id"] = f"p_{j}"
1336
 
@@ -1410,14 +1363,10 @@ def api_pose_video_frames(
1410
 
1411
  for ts, image_array in frames:
1412
  h, w = image_array.shape[:2]
1413
- person_bboxes = _sapiens_detect_persons(
1414
- image_array, detector_conf=detector_conf
1415
- )
1416
  if len(person_bboxes) >= SAPIENS_MAX_PERSONS_PER_FRAME:
1417
  any_capped = True
1418
- persons = _sapiens_infer_pose_for_persons(
1419
- pose_model, image_array, person_bboxes, confidence_threshold
1420
- )
1421
  per_frame_persons.append(persons)
1422
  per_frame_meta.append({"timestamp_s": ts, "width": w, "height": h})
1423
 
@@ -1536,9 +1485,7 @@ def _neuflow_preprocess(image: np.ndarray, max_dim: int) -> tuple[Any, tuple[int
1536
 
1537
  # cv2 default is BGR β€” match the reference infer_hf.py exactly (no
1538
  # RGB conversion, no /255 scaling, no mean/std).
1539
- tensor = (
1540
- torch.from_numpy(image_small).permute(2, 0, 1).unsqueeze(0).half().to("cuda")
1541
- )
1542
  return tensor, (new_h, new_w)
1543
 
1544
 
@@ -1583,9 +1530,7 @@ def _neuflow_aggregate_flow(
1583
  # Dominant direction: angle of the mean flow vector
1584
  mean_fx = float(np.mean(fx_orig))
1585
  mean_fy = float(np.mean(fy_orig))
1586
- dominant_dir_deg = float(
1587
- (np.degrees(np.arctan2(-mean_fy, mean_fx)) + 360.0) % 360.0
1588
- )
1589
 
1590
  # Direction consistency: |mean(unit_vectors)|
1591
  eps = 1e-6
@@ -1723,6 +1668,227 @@ def api_optical_flow(
1723
  # ═════════════════════════════════════════════════════════════════════════════
1724
 
1725
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1726
  # ═════════════════════════════════════════════════════════════════════════════
1727
  # Gradio UI
1728
  # ═════════════════════════════════════════════════════════════════════════════
@@ -1853,6 +2019,26 @@ with gr.Blocks(title="SAM 3 / SAM 3.1 for Cadayn") as demo:
1853
  api_name="api_optical_flow",
1854
  )
1855
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1856
 
1857
  if __name__ == "__main__":
1858
  demo.launch(server_name="0.0.0.0", server_port=7860)
 
119
  print("[GPU worker] Falling back to HF Transformers Sam3VideoModel...")
120
  from transformers import Sam3VideoModel, Sam3VideoProcessor
121
 
122
+ _video_hf_processor = Sam3VideoProcessor.from_pretrained("facebook/sam3", token=HF_TOKEN)
123
+ _video_hf_model = Sam3VideoModel.from_pretrained("facebook/sam3", token=HF_TOKEN, torch_dtype=torch.bfloat16).to(
124
+ "cuda"
125
  )
 
 
 
126
  print("[GPU worker] HF Sam3VideoModel loaded")
127
  return ("hf_sam3", (_video_hf_model, _video_hf_processor))
128
 
 
145
  _image_model = model
146
  _image_processor = Sam3Processor(_image_model)
147
  dtype_sample = next(model.parameters()).dtype
148
+ print(f"[GPU worker] SAM 3 image model ready (first-param dtype={dtype_sample})")
 
 
149
  return _image_model, _image_processor
150
 
151
 
 
327
  video_storage_device="cpu",
328
  dtype=torch.bfloat16,
329
  )
330
+ inference_session = processor.add_text_prompt(inference_session=inference_session, text=query)
 
 
331
  outputs_per_frame: dict[int, dict[str, Any]] = {}
332
+ for model_outputs in model.propagate_in_video_iterator(inference_session=inference_session):
 
 
333
  processed = processor.postprocess_outputs(inference_session, model_outputs)
334
  outputs_per_frame[model_outputs.frame_idx] = processed
335
 
336
+ visible_indices = [fi for fi, payload in outputs_per_frame.items() if (payload.get("object_ids") or [])]
 
 
 
 
337
  visible_indices.sort()
338
 
339
+ fps_est = len(frames) / _video_duration_s(video_path) if _video_duration_s(video_path) > 0 else SAMPLE_FPS
 
 
 
 
340
  tracked_segments: list[dict[str, float]] = []
341
  if visible_indices:
342
  current_start = visible_indices[0]
 
429
  for chunk in response.iter_bytes(chunk_size=256 * 1024):
430
  buf.write(chunk)
431
  if buf.tell() > MAX_IMAGE_BYTES:
432
+ raise ValueError(f"Image exceeds {MAX_IMAGE_BYTES // (1024 * 1024)} MB limit")
 
 
433
  return buf.getvalue()
434
 
435
 
 
507
  masks = candidate
508
  break
509
  if masks is None:
510
+ print(f"[GPU worker] mask extraction: no mask-like key in output (got {sorted(output.keys())})")
 
 
511
  if masks is None:
512
  return []
513
 
 
565
  prev = 1 - prev
566
  return {
567
  "size": [int(h), int(w)],
568
+ "counts": base64.b64encode(",".join(str(r) for r in runs).encode("ascii")).decode("ascii"),
 
 
569
  "format": "uncompressed_rle_b64",
570
  }
571
 
 
747
 
748
  return {
749
  "size": [int(img_h), int(img_w)],
750
+ "counts": base64.b64encode(",".join(str(r) for r in runs).encode("ascii")).decode("ascii"),
 
 
751
  "format": "bbox_rle_b64",
752
  }
753
 
 
922
  SAPIENS_INPUT_HEIGHT = 1024
923
  SAPIENS_INPUT_WIDTH = 768
924
 
925
+ SAPIENS_KEYPOINT_NAMES_PATH = os.path.join(os.path.dirname(__file__), "goliath_keypoints.txt")
 
 
926
 
927
  # Worker-local model cache β€” separate from the SAM caches above so the
928
  # two model families don't compete for the same global slot.
 
937
  return _sapiens_worker_keypoint_names
938
  if os.path.exists(SAPIENS_KEYPOINT_NAMES_PATH):
939
  with open(SAPIENS_KEYPOINT_NAMES_PATH) as f:
940
+ names = [line.strip() for line in f if line.strip() and not line.startswith("#")]
 
 
941
  if names:
942
  _sapiens_worker_keypoint_names = names
943
  return _sapiens_worker_keypoint_names
 
1045
  from PIL import Image
1046
 
1047
  orig_h, orig_w = image.shape[:2]
1048
+ pil = Image.fromarray(image).resize((SAPIENS_INPUT_WIDTH, SAPIENS_INPUT_HEIGHT), Image.BILINEAR)
 
 
1049
  arr = np.array(pil).astype(np.float32) / 255.0
1050
  mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
1051
  std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
1052
  arr = (arr - mean) / std
1053
+ tensor = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to("cuda", dtype=torch.float32)
 
 
 
 
 
1054
  return tensor, (orig_h, orig_w)
1055
 
1056
 
 
1180
  subprocess.run(cmd, check=True, capture_output=True, timeout=30)
1181
  frame_bgr = cv2.imread(out_path)
1182
  if frame_bgr is not None:
1183
+ extracted_ff.append((ts, cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)))
 
 
1184
  except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
1185
  print(f"[sapiens] ffmpeg seek failed for ts={ts}: {exc}")
1186
  continue
 
1227
  # ByteTrack drops rejected detections from its output, so output
1228
  # length ≀ input length, and order isn't preserved. Per-input-row
1229
  # bbox-equality matching is the only safe way to assign IDs.
1230
+ if getattr(tracked, "tracker_id", None) is not None and getattr(tracked, "xyxy", None) is not None:
 
 
 
1231
  for out_idx in range(len(tracked.tracker_id)):
1232
  tid = tracked.tracker_id[out_idx]
1233
  if tid is None:
 
1236
  for p in frame_persons:
1237
  if "id" in p:
1238
  continue # already matched to a tracker_id
1239
+ if abs(p["bbox"]["x1"] - float(out_x1)) < 1.0 and abs(p["bbox"]["y1"] - float(out_y1)) < 1.0:
 
 
 
1240
  p["id"] = f"p_{int(tid)}"
1241
  break
1242
 
 
1269
  image_array = _sapiens_decode_image_np(raw)
1270
  h, w = image_array.shape[:2]
1271
 
1272
+ person_bboxes = _sapiens_detect_persons(image_array, detector_conf=detector_conf)
 
 
1273
  if not person_bboxes:
1274
  return {
1275
  "ok": True,
 
1283
  }
1284
 
1285
  pose_model = _sapiens_load_pose_model()
1286
+ persons = _sapiens_infer_pose_for_persons(pose_model, image_array, person_bboxes, confidence_threshold)
 
 
1287
  for j, p in enumerate(persons):
1288
  p["id"] = f"p_{j}"
1289
 
 
1363
 
1364
  for ts, image_array in frames:
1365
  h, w = image_array.shape[:2]
1366
+ person_bboxes = _sapiens_detect_persons(image_array, detector_conf=detector_conf)
 
 
1367
  if len(person_bboxes) >= SAPIENS_MAX_PERSONS_PER_FRAME:
1368
  any_capped = True
1369
+ persons = _sapiens_infer_pose_for_persons(pose_model, image_array, person_bboxes, confidence_threshold)
 
 
1370
  per_frame_persons.append(persons)
1371
  per_frame_meta.append({"timestamp_s": ts, "width": w, "height": h})
1372
 
 
1485
 
1486
  # cv2 default is BGR β€” match the reference infer_hf.py exactly (no
1487
  # RGB conversion, no /255 scaling, no mean/std).
1488
+ tensor = torch.from_numpy(image_small).permute(2, 0, 1).unsqueeze(0).half().to("cuda")
 
 
1489
  return tensor, (new_h, new_w)
1490
 
1491
 
 
1530
  # Dominant direction: angle of the mean flow vector
1531
  mean_fx = float(np.mean(fx_orig))
1532
  mean_fy = float(np.mean(fy_orig))
1533
+ dominant_dir_deg = float((np.degrees(np.arctan2(-mean_fy, mean_fx)) + 360.0) % 360.0)
 
 
1534
 
1535
  # Direction consistency: |mean(unit_vectors)|
1536
  eps = 1e-6
 
1668
  # ═════════════════════════════════════════════════════════════════════════════
1669
 
1670
 
1671
+ # ═════════════════════════════════════════════════════════════════════════════
1672
+ # β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
1673
+ # BUNDLED β€” VIDEO DEPTH ANYTHING SMALL (Phase 4 spatial-tools)
1674
+ #
1675
+ # VDA Small (CVPR 2025 Highlight) β€” temporally-consistent monocular
1676
+ # depth on long video. Bundled into this Space alongside SAM, Sapiens,
1677
+ # and NeuFlow because of the 10 ZeroGPU per-user cap. Returns RELATIVE
1678
+ # depth in [0, 1]; never claim metric distances downstream.
1679
+ #
1680
+ # Endpoint added:
1681
+ # * api_video_depth(video_url, frame_timestamps_s, bboxes_per_frame,
1682
+ # downsample_to) β€” for each anchor timestamp,
1683
+ # returns depth_range + foreground_threshold + (optional) per-bbox
1684
+ # depth p10/p50/p90 + relative_ordering of bboxes near→far.
1685
+ # β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
1686
+ # ═════════════════════════════════════════════════════════════════════════════
1687
+
1688
+ VDA_REPO = "depth-anything/Video-Depth-Anything-Small"
1689
+ VDA_GPU_DURATION_S = 300
1690
+ VDA_MAX_TIMESTAMPS = 32
1691
+ VDA_DEFAULT_DOWNSAMPLE = 518 # VDA's native 518Γ—518 inference resolution
1692
+ VDA_FOREGROUND_PERCENTILE = 40 # depth at this percentile = "foreground threshold"
1693
+
1694
+ _vda_worker_model: Any = None
1695
+
1696
+
1697
+ def _vda_load_model() -> Any:
1698
+ """Lazy-load Video Depth Anything Small from the HF hub.
1699
+
1700
+ The depth-anything package isn't on PyPI as a single install but the
1701
+ HF hub copy uses transformers AutoModelForDepthEstimation under the
1702
+ hood. We try the transformers path first (zero extra install on top
1703
+ of the existing requirements) and fall back to the depth_anything
1704
+ package if it's vendored in.
1705
+ """
1706
+ global _vda_worker_model
1707
+ if _vda_worker_model is not None:
1708
+ return _vda_worker_model
1709
+
1710
+ print(f"[vda] Loading {VDA_REPO} into GPU memory...")
1711
+ try:
1712
+ from transformers import AutoImageProcessor, AutoModelForDepthEstimation
1713
+
1714
+ processor = AutoImageProcessor.from_pretrained(VDA_REPO, token=HF_TOKEN)
1715
+ model = AutoModelForDepthEstimation.from_pretrained(VDA_REPO, token=HF_TOKEN).to("cuda")
1716
+ model.eval()
1717
+ _vda_worker_model = (model, processor)
1718
+ print("[vda] Model ready on CUDA (transformers path)")
1719
+ return _vda_worker_model
1720
+ except Exception as exc:
1721
+ print(f"[vda] transformers path failed ({exc}); cannot fall back without vendored package")
1722
+ raise
1723
+
1724
+
1725
+ def _vda_infer_frame(model_pair: Any, image: np.ndarray, downsample_to: int) -> np.ndarray:
1726
+ """Run VDA on a single frame; return depth map in original-image dims."""
1727
+ model, processor = model_pair
1728
+
1729
+ h, w = image.shape[:2]
1730
+ scale = downsample_to / max(h, w)
1731
+ if scale < 1.0:
1732
+ new_h, new_w = int(h * scale), int(w * scale)
1733
+ image_small = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
1734
+ else:
1735
+ image_small = image
1736
+
1737
+ inputs = processor(images=image_small, return_tensors="pt").to("cuda")
1738
+ with torch.no_grad():
1739
+ outputs = model(**inputs)
1740
+ depth = outputs.predicted_depth # (B, H', W')
1741
+ depth_cpu = depth.detach().float().cpu().numpy()
1742
+ if depth_cpu.ndim == 3:
1743
+ depth_cpu = depth_cpu[0]
1744
+
1745
+ # Resize back to original resolution
1746
+ depth_full = cv2.resize(depth_cpu, (w, h), interpolation=cv2.INTER_LINEAR)
1747
+ # Normalise to [0, 1] within this frame (RELATIVE depth)
1748
+ d_min = float(np.min(depth_full))
1749
+ d_max = float(np.max(depth_full))
1750
+ if d_max - d_min > 1e-6:
1751
+ depth_full = (depth_full - d_min) / (d_max - d_min)
1752
+ return depth_full
1753
+
1754
+
1755
+ def _vda_aggregate_frame(
1756
+ depth_map: np.ndarray,
1757
+ timestamp_s: float,
1758
+ bboxes_for_frame: list[dict[str, Any]],
1759
+ ) -> dict[str, Any]:
1760
+ """Reduce a depth map + per-frame bboxes to the schema the backend expects."""
1761
+ h, w = depth_map.shape
1762
+
1763
+ objects: list[dict[str, Any]] = []
1764
+ for entry in bboxes_for_frame:
1765
+ bbox = entry.get("bbox") or {}
1766
+ if not isinstance(bbox, dict):
1767
+ continue
1768
+ x1 = max(0, min(w, int(bbox.get("x1", 0))))
1769
+ y1 = max(0, min(h, int(bbox.get("y1", 0))))
1770
+ x2 = max(0, min(w, int(bbox.get("x2", 0))))
1771
+ y2 = max(0, min(h, int(bbox.get("y2", 0))))
1772
+ if x2 <= x1 or y2 <= y1:
1773
+ continue
1774
+ crop = depth_map[y1:y2, x1:x2]
1775
+ if crop.size == 0:
1776
+ continue
1777
+ p10 = float(np.percentile(crop, 10))
1778
+ p50 = float(np.percentile(crop, 50))
1779
+ p90 = float(np.percentile(crop, 90))
1780
+ objects.append(
1781
+ {
1782
+ "bbox_id": str(entry.get("bbox_id", f"bbox_{len(objects)}")),
1783
+ "p10": p10,
1784
+ "p50": p50,
1785
+ "p90": p90,
1786
+ "spans_foreground_and_background": (p90 - p10) > 0.4,
1787
+ }
1788
+ )
1789
+
1790
+ # Order objects near→far by p50
1791
+ relative_ordering = [o["bbox_id"] for o in sorted(objects, key=lambda o: o["p50"])]
1792
+
1793
+ return {
1794
+ "timestamp_s": timestamp_s,
1795
+ "depth_range": {"min": float(np.min(depth_map)), "max": float(np.max(depth_map))},
1796
+ "foreground_threshold": float(np.percentile(depth_map, VDA_FOREGROUND_PERCENTILE)),
1797
+ "objects": objects,
1798
+ "relative_ordering": relative_ordering,
1799
+ }
1800
+
1801
+
1802
+ @spaces.GPU(duration=VDA_GPU_DURATION_S)
1803
+ def api_video_depth(
1804
+ video_url: str,
1805
+ frame_timestamps_s: list[float],
1806
+ bboxes_per_frame: list[dict[str, Any]] | None = None,
1807
+ downsample_to: int = VDA_DEFAULT_DOWNSAMPLE,
1808
+ ) -> dict[str, Any]:
1809
+ """Estimate per-frame relative depth + optional per-bbox depth percentiles.
1810
+
1811
+ Bundled into sam3-zerogpu β€” see banner above.
1812
+ """
1813
+ started = time.monotonic()
1814
+ if not video_url:
1815
+ return {"ok": False, "error": "video_url is required", "elapsed_s": 0.0}
1816
+ if not isinstance(frame_timestamps_s, list) or not frame_timestamps_s:
1817
+ return {
1818
+ "ok": False,
1819
+ "error": "frame_timestamps_s must be a non-empty list",
1820
+ "elapsed_s": 0.0,
1821
+ }
1822
+ if len(frame_timestamps_s) > VDA_MAX_TIMESTAMPS:
1823
+ return {
1824
+ "ok": False,
1825
+ "error": f"timestamp batch exceeds {VDA_MAX_TIMESTAMPS}",
1826
+ "elapsed_s": 0.0,
1827
+ }
1828
+
1829
+ try:
1830
+ video_path = _download_video(video_url, timeout=60.0)
1831
+ try:
1832
+ frames = _sapiens_extract_video_frames(video_path, frame_timestamps_s)
1833
+ finally:
1834
+ try:
1835
+ os.unlink(video_path)
1836
+ except OSError:
1837
+ pass
1838
+
1839
+ if not frames:
1840
+ return {
1841
+ "ok": False,
1842
+ "frames": [],
1843
+ "error": "frame extraction returned no frames",
1844
+ "elapsed_s": round(time.monotonic() - started, 3),
1845
+ }
1846
+
1847
+ # Group bboxes by their nominal timestamp_s; use a small tolerance
1848
+ # match because float timestamps may not exact-equal what came back
1849
+ # from ffmpeg.
1850
+ bboxes_by_ts: dict[float, list[dict[str, Any]]] = {}
1851
+ for entry in bboxes_per_frame or []:
1852
+ if not isinstance(entry, dict):
1853
+ continue
1854
+ ts = float(entry.get("timestamp_s", 0.0))
1855
+ bboxes_by_ts.setdefault(round(ts, 3), []).append(entry)
1856
+
1857
+ model_pair = _vda_load_model()
1858
+ per_frame_out: list[dict[str, Any]] = []
1859
+ for ts, image in frames:
1860
+ depth_map = _vda_infer_frame(model_pair, image, downsample_to)
1861
+ bboxes_for_frame = bboxes_by_ts.get(round(ts, 3), [])
1862
+ per_frame_out.append(_vda_aggregate_frame(depth_map, ts, bboxes_for_frame))
1863
+
1864
+ return {
1865
+ "ok": True,
1866
+ "frames": per_frame_out,
1867
+ "model": VDA_REPO,
1868
+ "is_relative": True,
1869
+ "camera_motion_hint": None, # TODO: derive from depth-flow between frames
1870
+ "error": None,
1871
+ "elapsed_s": round(time.monotonic() - started, 3),
1872
+ }
1873
+ except Exception as exc:
1874
+ traceback.print_exc()
1875
+ return {
1876
+ "ok": False,
1877
+ "frames": [],
1878
+ "error": f"{type(exc).__name__}: {exc}",
1879
+ "elapsed_s": round(time.monotonic() - started, 3),
1880
+ }
1881
+ finally:
1882
+ gc.collect()
1883
+
1884
+
1885
+ # ═════════════════════════════════════════════════════════════════════════════
1886
+ # β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
1887
+ # END OF BUNDLED VDA BLOCK
1888
+ # β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
1889
+ # ═════════════════════════════════════════════════════════════════════════════
1890
+
1891
+
1892
  # ═════════════════════════════════════════════════════════════════════════════
1893
  # Gradio UI
1894
  # ═════════════════════════════════════════════════════════════════════════════
 
2019
  api_name="api_optical_flow",
2020
  )
2021
 
2022
+ # ── BUNDLED VDA depth endpoint (see banner mid-file) ──────────────────
2023
+ with gr.Row(visible=False):
2024
+ api_depth_video_url = gr.Textbox()
2025
+ api_depth_timestamps = gr.JSON(value=[])
2026
+ api_depth_bboxes = gr.JSON(value=[])
2027
+ api_depth_downsample = gr.Number(value=VDA_DEFAULT_DOWNSAMPLE)
2028
+ api_depth_output = gr.JSON()
2029
+
2030
+ api_depth_video_url.change(
2031
+ fn=api_video_depth,
2032
+ inputs=[
2033
+ api_depth_video_url,
2034
+ api_depth_timestamps,
2035
+ api_depth_bboxes,
2036
+ api_depth_downsample,
2037
+ ],
2038
+ outputs=api_depth_output,
2039
+ api_name="api_video_depth",
2040
+ )
2041
+
2042
 
2043
  if __name__ == "__main__":
2044
  demo.launch(server_name="0.0.0.0", server_port=7860)