Spaces:
Running on Zero
Running on Zero
deploy sam3-zerogpu
Browse files
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
|
| 105 |
-
registers all
|
| 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 |
-
|
|
|
|
| 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)
|