Skip to content

Commit 5c2adef

Browse files
fix: handle local uploads in SAM2 video backend (#923)
1 parent ea630da commit 5c2adef

2 files changed

Lines changed: 58 additions & 33 deletions

File tree

label_studio_ml/examples/segment_anything_video_interactive/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ project's control tag.
8888
Cloud-backed projects put the raw provider URI in task data (`s3://bucket/key.mp4`,
8989
`gs://…`, `azure-blob://…`). Those can't be streamed — only LS can resolve them,
9090
via `/tasks/<id>/presign/` — so the backend downloads them through the SDK and
91-
decodes locally. HTTP(S) assets, LS-hosted or not, are still range-streamed.
91+
decodes locally. External HTTP(S) assets are still range-streamed. LS-hosted
92+
HTTP(S) uploads are downloaded once by default; set
93+
`LABEL_STUDIO_STREAM_LS_UPLOADS=true` only for deployments where direct range
94+
streaming is reliable.
9295

9396
## Running locally (no Docker)
9497

label_studio_ml/examples/segment_anything_video_interactive/model.py

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@
8282
)
8383
TRACK_PROGRESS_MAX_BATCH = int(os.getenv("TRACK_PROGRESS_MAX_BATCH", "32"))
8484

85+
# Label Studio's authenticated upload endpoints are not always reliable as
86+
# ffmpeg streaming inputs: local dev / nginx modes can return partial bodies,
87+
# and prewarm bursts can trigger 429s. Default to one authenticated download for
88+
# LS-hosted uploads, while still allowing explicit streaming for deployments
89+
# where range requests are known to work well.
90+
STREAM_LS_UPLOADS = os.getenv("LABEL_STUDIO_STREAM_LS_UPLOADS", "").lower() in {"1", "true", "yes", "on"}
91+
8592
# Stop-tracking thresholds (see _run_tracking).
8693
# SAM2's mask decoder emits a per-frame object_score_logits; convention is
8794
# `> 0` = object present, `<= 0` = occluded/absent. We debounce across a few
@@ -727,18 +734,18 @@ def _predict_single_frame(self, task_id, context, video, frame,
727734
def _ls_host_token(self) -> Tuple[Optional[str], Optional[str]]:
728735
"""Return (ls_host, ls_token) for authenticated LS asset fetches.
729736
730-
Host resolution is env first, then the cache populated from `/setup`.
731-
Token resolution is cache first, then env. Either may be None; callers
732-
pass them into `self.get_local_path(ls_host=..., ls_access_token=...)`
733-
which lets the SDK skip its `http://localhost:8000` default fallback.
734-
735-
LS's own `/setup` payload can carry `http://localhost:<port>` when
736-
the LS side doesn't have `HOSTNAME` configured — that's useless to a
737-
remote ML backend. The host remains env-var-first so operators can
738-
override LS's guess via `.env` / `docker-compose`. The token is
739-
cache-first: `/setup` sends the ML-backend access token that LS itself
740-
wants us to use, while env tokens are often stale or a frontend refresh
741-
JWT that this LS deployment may not accept for API/file downloads.
737+
Host/token resolution is env first, then the cache populated from
738+
`/setup`. Either may be None; callers pass them into
739+
`self.get_local_path(ls_host=..., ls_access_token=...)` which lets the
740+
SDK skip its `http://localhost:8000` default fallback.
741+
742+
LS's own `/setup` payload can carry `http://localhost:<port>` when the
743+
LS side doesn't have `HOSTNAME` configured — that's useless to a remote
744+
ML backend. Keep both host and token env-var-first so an operator can
745+
fix credentials through the process environment / `docker-compose` and
746+
restart the backend; otherwise an old `/setup` access token can shadow a
747+
freshly configured `LABEL_STUDIO_API_KEY` and cause 401s on uploaded
748+
files.
742749
"""
743750
env_host = (os.getenv("LABEL_STUDIO_URL") or os.getenv("LABEL_STUDIO_HOST") or "").rstrip("/")
744751
env_token = (
@@ -749,7 +756,7 @@ def _ls_host_token(self) -> Tuple[Optional[str], Optional[str]]:
749756
cached_host = (LS_CONTEXT.get("url") or "").rstrip("/")
750757
cached_token = LS_CONTEXT.get("token") or ""
751758
host = env_host or cached_host
752-
token = cached_token or env_token
759+
token = env_token or cached_token
753760
return (host or None, token or None)
754761

755762
def _download_valid_video(self, raw_url: str, task_id: str) -> str:
@@ -784,11 +791,13 @@ def _download_valid_video(self, raw_url: str, task_id: str) -> str:
784791
def _resolve_video_source(self, raw_url: str, task_id: str):
785792
"""Resolve a task video URL to a streamable source + auth headers.
786793
787-
* Cloud storage URLs (don't look like LS's own host) → stream directly,
788-
no headers.
789-
* LS-hosted URLs, whether absolute or relative → attach the LS API
790-
token; LS guards the /data/upload/* path and returns 401 without it.
791-
* No LS url / key configured → fall back to local download.
794+
* External HTTP(S) URLs (don't look like LS's own host) → stream
795+
directly, no headers.
796+
* LS-hosted upload URLs / paths → authenticated local download by
797+
default; direct ffmpeg streaming can be enabled with
798+
`LABEL_STUDIO_STREAM_LS_UPLOADS=true` when the LS deployment supports
799+
reliable range requests.
800+
* Cloud-storage URIs → authenticated local download through the LS SDK.
792801
793802
Resolution order for the LS hostname:
794803
1. `LABEL_STUDIO_URL` env var (explicit operator override)
@@ -806,12 +815,16 @@ def _auth_headers(target_url: str):
806815
return ls_auth_headers(ls_url, api_key, target_url=target_url)
807816

808817
if raw_url.startswith("http://") or raw_url.startswith("https://"):
809-
# Absolute URL — attach auth iff its host matches the known LS host
810-
# (LS returns 404, not 401, to an unauthenticated /data or /upload
811-
# fetch, which is how ffprobe streaming fails). Never attach to any
812-
# other host: task data can carry external/presigned cloud URLs and
813-
# we must not leak the LS token to them.
818+
# Absolute URL — attach auth iff its host matches the known LS host.
819+
# Never attach to any other host: task data can carry external /
820+
# presigned cloud URLs and we must not leak the LS token to them.
814821
attach = should_attach_ls_auth(raw_url, ls_url, bool(api_key))
822+
if attach and not STREAM_LS_UPLOADS:
823+
logger.info(
824+
"LS-hosted video: downloading once instead of streaming (%s)",
825+
raw_url,
826+
)
827+
return self._download_valid_video(raw_url, task_id), None
815828
return raw_url, _auth_headers(raw_url) if attach else None
816829

817830
if raw_url.startswith(CLOUD_URI_SCHEMES):
@@ -822,17 +835,20 @@ def _auth_headers(target_url: str):
822835
# it through `/tasks/<id>/presign/` and downloads the result.
823836
return self._download_valid_video(raw_url, task_id), None
824837

825-
if ls_url and api_key:
838+
if (
839+
ls_url
840+
and api_key
841+
and STREAM_LS_UPLOADS
842+
and raw_url.startswith(("/data/", "/upload", "data/", "upload"))
843+
):
826844
full_url = f"{ls_url}{raw_url}" if raw_url.startswith("/") else f"{ls_url}/{raw_url}"
827845
return full_url, _auth_headers(full_url)
828846

829-
logger.warning("streaming not available (no LABEL_STUDIO_URL), falling back to download")
830-
local_path = self.get_local_path(
831-
raw_url,
832-
task_id=task_id,
833-
ls_host=ls_url_opt,
834-
ls_access_token=ls_token_for_sdk(ls_url_opt, api_key_opt),
835-
)
847+
if not ls_url:
848+
logger.warning("streaming not available (no LABEL_STUDIO_URL), falling back to download")
849+
else:
850+
logger.info("video source requires Label Studio resolution; downloading once")
851+
local_path = self._download_valid_video(raw_url, task_id)
836852
return local_path, None
837853

838854
def _mask_response(self, mask, w, h, from_name, to_name):
@@ -937,6 +953,12 @@ def _run_tracking(self, session, video, prompts, start_frame, end_frame,
937953
# as propagate_in_video walks through them — the wait for
938954
# "encode all frames upfront" becomes "encode frame 0".
939955
#
956+
# MPS is the exception: SAM2's async JPEG loader can produce
957+
# float64 CPU tensors and then move them to MPS before the
958+
# subsequent `.float()` cast. MPS doesn't support float64, so
959+
# load frames synchronously there; SAM2's sync path preallocates
960+
# a float32 tensor and avoids the dtype hop.
961+
#
940962
# offload_video_to_cpu=True keeps the loaded frames on CPU so
941963
# only the propagation thread touches the GPU. Without this
942964
# the async loader pushes frames to the device with
@@ -946,7 +968,7 @@ def _run_tracking(self, session, video, prompts, start_frame, end_frame,
946968
# mask "jumping" onto a different object.
947969
inference_state = video_predictor.init_state(
948970
video_path=frame_dir,
949-
async_loading_frames=True,
971+
async_loading_frames=(DEVICE != "mps"),
950972
offload_video_to_cpu=True,
951973
)
952974
video_predictor.reset_state(inference_state)

0 commit comments

Comments
 (0)