src/mcss/data/download.py
"""Safe, resumable download helpers for official Replica and Hypersim releases."""
from __future__ import annotations
import io
import os
import re
import shutil
import tarfile
import zipfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from string import ascii_lowercase
from typing import BinaryIO
import requests
REPLICA_RELEASE_URL = "https://github.com/facebookresearch/Replica-Dataset/releases/download/v1.0"
HYPERSIM_SCENE_URL = (
"https://docs-assets.developer.apple.com/ml-research/datasets/hypersim/v1/scenes"
)
_SCENE_NAME = re.compile(r"^ai_\d{3}_\d{3}$")
@dataclass(frozen=True)
class DownloadItem:
url: str
destination: Path
@dataclass(frozen=True)
class DownloadPlan:
dataset: str
root: Path
items: tuple[DownloadItem, ...]
@dataclass(frozen=True)
class DownloadResult:
item: DownloadItem
bytes_written: int
reused: bool
def replica_part_urls() -> tuple[str, ...]:
"""Return the 17 official v1.0 Replica archive part URLs, aa through aq."""
return tuple(
f"{REPLICA_RELEASE_URL}/replica_v1_0.tar.gz.parta{suffix}"
for suffix in ascii_lowercase[:17]
)
def build_replica_plan(root: str | Path) -> DownloadPlan:
root_path = Path(root)
archive_root = root_path / "archives"
items = tuple(DownloadItem(url, archive_root / Path(url).name) for url in replica_part_urls())
return DownloadPlan("replica", root_path, items)
def hypersim_scene_url(scene: str) -> str:
if not _SCENE_NAME.fullmatch(scene):
raise ValueError("scene must match ai_DDD_DDD, for example ai_001_001")
return f"{HYPERSIM_SCENE_URL}/{scene}.zip"
def build_hypersim_plan(root: str | Path, scenes: list[str] | tuple[str, ...]) -> DownloadPlan:
root_path = Path(root)
if not scenes:
raise ValueError("at least one Hypersim scene is required")
items = tuple(
DownloadItem(hypersim_scene_url(scene), root_path / "archives" / f"{scene}.zip")
for scene in scenes
)
return DownloadPlan("hypersim", root_path, items)
def safe_member_destination(root: str | Path, member_name: str) -> Path:
"""Map an archive member to a descendant path or reject traversal and drive paths."""
normalized = member_name.replace("\\", "/")
pure_path = PurePosixPath(normalized)
if (
not normalized
or pure_path.is_absolute()
or any(part in {"", ".", ".."} for part in pure_path.parts)
or re.match(r"^[A-Za-z]:", normalized) is not None
):
raise ValueError(f"unsafe archive member path: {member_name!r}")
base = Path(root).resolve()
destination = (base / Path(*pure_path.parts)).resolve()
try:
destination.relative_to(base)
except ValueError as error:
raise ValueError(f"unsafe archive member path: {member_name!r}") from error
return destination
def execute_plan(
plan: DownloadPlan,
*,
dry_run: bool = False,
session: requests.Session | None = None,
timeout_seconds: float = 60.0,
) -> list[DownloadResult]:
"""Download all plan items with `.part` resume files and atomic final renames."""
if dry_run:
return [DownloadResult(item, 0, False) for item in plan.items]
active_session = session or requests.Session()
owns_session = session is None
try:
return [
download_file(item, session=active_session, timeout_seconds=timeout_seconds)
for item in plan.items
]
finally:
if owns_session:
active_session.close()
def download_file(
item: DownloadItem,
*,
session: requests.Session,
timeout_seconds: float = 60.0,
chunk_size: int = 1024 * 1024,
) -> DownloadResult:
"""Download one URL safely, continuing an existing partial file when supported."""
if chunk_size < 1:
raise ValueError("chunk_size must be positive")
destination = item.destination
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
expected_size = _content_length(session, item.url, timeout_seconds)
if expected_size is None or destination.stat().st_size == expected_size:
return DownloadResult(item, 0, True)
destination.unlink()
partial = destination.with_name(destination.name + ".part")
offset = partial.stat().st_size if partial.exists() else 0
headers = {"Range": f"bytes={offset}-"} if offset else {}
response = session.get(item.url, headers=headers, stream=True, timeout=timeout_seconds)
try:
response.raise_for_status()
append = offset > 0 and response.status_code == 206
if offset > 0 and not append:
offset = 0
mode = "ab" if append else "wb"
with partial.open(mode) as handle:
for chunk in response.iter_content(chunk_size=chunk_size):
if chunk:
handle.write(chunk)
expected_size = _expected_response_size(response, offset)
finally:
response.close()
actual_size = partial.stat().st_size
if expected_size is not None and actual_size != expected_size:
raise OSError(
f"incomplete download for {item.url}: expected {expected_size} bytes, got {actual_size}"
)
os.replace(partial, destination)
return DownloadResult(item, actual_size - offset, False)
def extract_replica_parts(
plan: DownloadPlan,
destination: str | Path,
*,
remove_parts: bool = False,
) -> None:
"""Stream-extract Replica parts without materializing a second merged tarball."""
if plan.dataset != "replica":
raise ValueError("extract_replica_parts requires a Replica plan")
part_paths = [item.destination for item in plan.items]
missing = [path for path in part_paths if not path.is_file()]
if missing:
raise FileNotFoundError(f"Replica archive parts are missing: {missing[:3]}")
output_root = Path(destination)
output_root.mkdir(parents=True, exist_ok=True)
with (
_ConcatenatedReader(part_paths) as stream,
tarfile.open(fileobj=stream, mode="r|gz") as archive,
):
for member in archive:
target = safe_member_destination(output_root, member.name)
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
elif member.isfile():
target.parent.mkdir(parents=True, exist_ok=True)
source = archive.extractfile(member)
if source is None:
raise OSError(f"cannot read archive member {member.name}")
with source, target.open("wb") as destination_handle:
shutil.copyfileobj(source, destination_handle, length=1024 * 1024)
else:
raise ValueError(f"refusing non-file Replica archive member: {member.name}")
if remove_parts:
for part in part_paths:
part.unlink()
def download_hypersim_members(
scene: str,
destination: str | Path,
*,
camera: str = "cam_00",
frame_ids: set[int] | None = None,
include_position: bool = False,
overwrite: bool = False,
session: requests.Session | None = None,
timeout_seconds: float = 60.0,
) -> list[Path]:
"""Extract a practical RGB/depth/normal subset directly from an official scene ZIP.
ZIP central-directory access uses HTTP byte ranges, so the full 1-20 GB scene archive is not
written locally. Camera trajectories are always retained; image modalities can be restricted
to a set of frame ids for smoke and pilot experiments.
"""
if not re.fullmatch(r"cam_\d{2}", camera):
raise ValueError("camera must match cam_DD")
root = Path(destination)
active_session = session or requests.Session()
owns_session = session is None
try:
with HTTPRangeReader(hypersim_scene_url(scene), active_session, timeout_seconds) as reader:
with zipfile.ZipFile(reader) as archive:
selected: list[tuple[zipfile.ZipInfo, str]] = []
for info in archive.infolist():
relative_name = hypersim_member_relative_name(scene, info.filename)
if _select_hypersim_member(
relative_name,
camera=camera,
frame_ids=frame_ids,
include_position=include_position,
):
selected.append((info, relative_name))
written: list[Path] = []
for info, relative_name in selected:
target = safe_member_destination(root / scene, relative_name)
if target.exists() and not overwrite:
written.append(target)
continue
target.parent.mkdir(parents=True, exist_ok=True)
partial = target.with_name(target.name + ".part")
with archive.open(info) as source, partial.open("wb") as destination_handle:
shutil.copyfileobj(source, destination_handle, length=1024 * 1024)
os.replace(partial, target)
written.append(target)
return written
finally:
if owns_session:
active_session.close()
class HTTPRangeReader(io.RawIOBase):
"""Seekable read-only stream backed by HTTP Range requests for `zipfile.ZipFile`."""
def __init__(self, url: str, session: requests.Session, timeout_seconds: float) -> None:
super().__init__()
self.url = url
self.session = session
self.timeout_seconds = timeout_seconds
response = self.session.head(url, allow_redirects=True, timeout=timeout_seconds)
try:
response.raise_for_status()
content_length = response.headers.get("Content-Length")
if content_length is None:
raise OSError(f"server did not provide content length for {url}")
self.size = int(content_length)
finally:
response.close()
self.offset = 0
def readable(self) -> bool:
return True
def seekable(self) -> bool:
return True
def tell(self) -> int:
return self.offset
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
if whence == io.SEEK_SET:
new_offset = offset
elif whence == io.SEEK_CUR:
new_offset = self.offset + offset
elif whence == io.SEEK_END:
new_offset = self.size + offset
else:
raise ValueError(f"unsupported seek mode {whence}")
self.offset = min(max(new_offset, 0), self.size)
return self.offset
def read(self, size: int = -1) -> bytes:
if size == 0 or self.offset >= self.size:
return b""
if size < 0:
size = self.size - self.offset
end = min(self.offset + size, self.size) - 1
response = self.session.get(
self.url,
headers={"Range": f"bytes={self.offset}-{end}"},
timeout=self.timeout_seconds,
)
try:
response.raise_for_status()
if response.status_code != 206:
raise OSError(f"server ignored Range request for {self.url}")
data = response.content
finally:
response.close()
if len(data) > size:
data = data[:size]
self.offset += len(data)
return data
class _ConcatenatedReader(io.RawIOBase):
"""Read several archive chunks as one file without a temporary merged copy."""
def __init__(self, paths: list[Path]) -> None:
self.paths = paths
self._index = 0
self._handle: BinaryIO | None = None
def __enter__(self) -> _ConcatenatedReader:
return self
def __exit__(self, *_: object) -> None:
self.close()
def readable(self) -> bool:
return True
def read(self, size: int = -1) -> bytes:
chunks: list[bytes] = []
remaining = size
while self._index < len(self.paths) and (remaining != 0):
if self._handle is None:
self._handle = self.paths[self._index].open("rb")
chunk = self._handle.read(remaining)
if chunk:
chunks.append(chunk)
if remaining > 0:
remaining -= len(chunk)
if remaining == 0:
break
if not chunk:
self._handle.close()
self._handle = None
self._index += 1
return b"".join(chunks)
def close(self) -> None:
if self._handle is not None:
self._handle.close()
self._handle = None
super().close()
def hypersim_member_relative_name(scene: str, member_name: str) -> str:
"""Remove the official scene-root prefix while rejecting cross-scene members."""
if not _SCENE_NAME.fullmatch(scene):
raise ValueError("scene must match ai_DDD_DDD")
normalized = member_name.replace("\\", "/")
prefix = f"{scene}/"
if normalized.startswith(prefix):
return normalized[len(prefix) :]
first_part = normalized.split("/", 1)[0]
if _SCENE_NAME.fullmatch(first_part):
raise ValueError(f"archive member belongs to a different scene: {member_name!r}")
return normalized
def _select_hypersim_member(
name: str, *, camera: str, frame_ids: set[int] | None, include_position: bool
) -> bool:
if name in {"_detail/metadata_scene.csv", "_detail/metadata_cameras.csv"}:
return True
if name.startswith(f"_detail/{camera}/") and name.endswith(".hdf5"):
return True
preview_prefix = f"images/scene_{camera}_final_preview/"
geometry_prefix = f"images/scene_{camera}_geometry_hdf5/"
if not (name.startswith(preview_prefix) or name.startswith(geometry_prefix)):
return False
frame_match = re.search(r"frame\.(\d{4})\.", name)
if frame_match is None:
return False
if frame_ids is not None and int(frame_match.group(1)) not in frame_ids:
return False
return (
name.endswith(".color.jpg")
or name.endswith(".depth_meters.hdf5")
or name.endswith(".normal_world.hdf5")
or (include_position and name.endswith(".position.hdf5"))
)
def _content_length(session: requests.Session, url: str, timeout_seconds: float) -> int | None:
response = session.head(url, allow_redirects=True, timeout=timeout_seconds)
try:
response.raise_for_status()
value = response.headers.get("Content-Length")
return None if value is None else int(value)
finally:
response.close()
def _expected_response_size(response: requests.Response, offset: int) -> int | None:
content_range = response.headers.get("Content-Range")
if content_range:
match = re.fullmatch(r"bytes \d+-\d+/(\d+)", content_range)
if match:
return int(match.group(1))
content_length = response.headers.get("Content-Length")
if content_length is None:
return None
return offset + int(content_length)