From e3a423433e650a01a3940d3bdcb841c8d0a53b10 Mon Sep 17 00:00:00 2001 From: crosstyan Date: Mon, 23 Mar 2026 09:35:54 +0000 Subject: [PATCH] feat(zed): add DuckDB segment timestamp indexer Add a new mcap_video_bounds helper binary plus a zed_segment_time_index.py CLI that builds and queries an embedded DuckDB index for bundled ZED segment recordings. The index stores segment folders, MCAP paths, video time bounds, durations, camera labels, and dataset metadata, and reuses the existing recursive multi-camera segment discovery logic so nested kindergarten layouts are indexed correctly. Infer a dataset default timezone from folder names versus MCAP timestamps, and make point queries precision-aware so second-level folder timestamps like 2026-03-18T12-00-23 resolve to the matching segment instead of missing due to subsecond start offsets. Verification: - uv add 'duckdb>=1.0' - cmake --build build --target mcap_video_bounds - uv run python -m unittest tests.test_zed_segment_time_index - uv run python scripts/zed_segment_time_index.py build /workspaces/data/kindergarten --jobs 8 - uv run python scripts/zed_segment_time_index.py query /workspaces/data/kindergarten --at 2026-03-18T12-00-23 --- CMakeLists.txt | 27 ++ docs/zed_segment_time_index.md | 97 ++++ pyproject.toml | 1 + scripts/zed_segment_time_index.py | 658 +++++++++++++++++++++++++++ src/tools/mcap_video_bounds.cpp | 219 +++++++++ tests/test_zed_segment_time_index.py | 139 ++++++ uv.lock | 44 ++ 7 files changed, 1185 insertions(+) create mode 100644 docs/zed_segment_time_index.md create mode 100644 scripts/zed_segment_time_index.py create mode 100644 src/tools/mcap_video_bounds.cpp create mode 100644 tests/test_zed_segment_time_index.py diff --git a/CMakeLists.txt b/CMakeLists.txt index b38eecb..16d6fe9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -365,7 +365,34 @@ set_target_properties(mcap_replay_tester PROPERTIES OUTPUT_NAME "mcap_replay_tester" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin") +add_executable(mcap_video_bounds src/tools/mcap_video_bounds.cpp) +target_include_directories(mcap_video_bounds + PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/include" + "${CMAKE_CURRENT_BINARY_DIR}") +target_link_libraries(mcap_video_bounds + PRIVATE + CLI11::CLI11 + cvmmap_streamer_foxglove_proto + cvmmap_streamer_mcap_runtime + mcap::mcap + PkgConfig::ZSTD + PkgConfig::LZ4) +if (TARGET spdlog::spdlog) + target_link_libraries(mcap_video_bounds PRIVATE spdlog::spdlog) +elseif (TARGET spdlog) + target_link_libraries(mcap_video_bounds PRIVATE spdlog) +endif() +target_link_libraries(mcap_video_bounds PRIVATE cvmmap_streamer_protobuf) +if (TARGET PkgConfig::PROTOBUF_PKG) + target_link_libraries(mcap_video_bounds PRIVATE PkgConfig::PROTOBUF_PKG) +endif() +set_target_properties(mcap_video_bounds PROPERTIES + OUTPUT_NAME "mcap_video_bounds" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin") + set(CVMMAP_STREAMER_INSTALL_TARGETS cvmmap_streamer) +list(APPEND CVMMAP_STREAMER_INSTALL_TARGETS mcap_video_bounds) if (CVMMAP_HAS_ZED_SDK) add_library( diff --git a/docs/zed_segment_time_index.md b/docs/zed_segment_time_index.md new file mode 100644 index 0000000..22b5744 --- /dev/null +++ b/docs/zed_segment_time_index.md @@ -0,0 +1,97 @@ +# ZED Segment Time Index + +`scripts/zed_segment_time_index.py` builds and queries an embedded DuckDB index for bundled ZED segment folders. + +Default artifact name: + +```text +/segment_time_index.duckdb +``` + +Primary commands: + +```bash +uv run python scripts/zed_segment_time_index.py build +uv run python scripts/zed_segment_time_index.py query --at 2026-03-18T12-00-23 +uv run python scripts/zed_segment_time_index.py query --start 2026-03-18T12-00-23 --end 2026-03-18T12-00-30 +``` + +## Data Source Rules + +- Segment discovery is recursive and follows the same multi-camera layout assumptions as the batch ZED tooling. +- A directory is considered a valid segment when it contains at least two unique `*_zedN.svo` or `*_zedN.svo2` files and no duplicate camera labels. +- Timing is sourced from the segment MCAP, not from the SVO/SVO2 files. +- A valid segment is skipped when it has no `.mcap` file or more than one `.mcap` file in the segment directory. + +## MCAP Bounds Extraction + +`build/bin/mcap_video_bounds` scans `foxglove.CompressedVideo` messages in one MCAP and emits: + +- `start_ns` +- `end_ns` +- `duration_ns` +- `video_message_count` +- `start_iso_utc` +- `end_iso_utc` + +The helper prefers the protobuf `CompressedVideo.timestamp` field and falls back to MCAP `logTime` when that field is zero. + +## DuckDB Layout + +The database contains two tables: `meta` and `segments`. + +### `meta` + +Key-value metadata for the index: + +- `schema_version`: current schema version, currently `1` +- `dataset_root`: absolute dataset root used when the index was built +- `built_at_utc`: build timestamp in UTC +- `default_timezone`: inferred dataset wall-clock timezone used when querying with `--timezone dataset` + +### `segments` + +One row per indexed segment. + +| Column | Type | Meaning | +|---|---|---| +| `segment_dir` | `VARCHAR` | Absolute path to the segment directory | +| `relative_segment_dir` | `VARCHAR` | Path relative to the dataset root | +| `group_path` | `VARCHAR` | Parent path of the segment within the dataset | +| `activity` | `VARCHAR` | First path component under the dataset root | +| `segment_name` | `VARCHAR` | Segment directory basename | +| `mcap_path` | `VARCHAR` | Absolute MCAP path used for timing | +| `start_ns` | `BIGINT` | Earliest video timestamp in nanoseconds since Unix epoch | +| `end_ns` | `BIGINT` | Latest video timestamp in nanoseconds since Unix epoch | +| `duration_ns` | `BIGINT` | `end_ns - start_ns` | +| `start_iso_utc` | `VARCHAR` | UTC rendering of `start_ns` | +| `end_iso_utc` | `VARCHAR` | UTC rendering of `end_ns` | +| `camera_count` | `INTEGER` | Number of discovered camera inputs in the segment directory | +| `camera_labels` | `VARCHAR` | Comma-separated camera labels, for example `zed1,zed2,zed3,zed4` | +| `video_message_count` | `BIGINT` | Number of `foxglove.CompressedVideo` messages observed in the MCAP | +| `index_source` | `VARCHAR` | Current extractor label, currently `mcap_video_bounds` | + +Indexes are created on `start_ns` and `end_ns`. + +## Query Semantics + +- `--at` performs an overlap lookup, not just an exact nanosecond equality check. +- Query precision follows the precision supplied by the user. +- A second-precision value like `2026-03-18T12-00-23` is treated as the whole second `[12:00:23.000, 12:00:23.999999999]`. +- Integer epochs are widened similarly by their apparent unit: + - 10 digits or fewer: seconds + - 11-13 digits: milliseconds + - 14-16 digits: microseconds + - 17+ digits: nanoseconds +- `--start/--end` returns every segment whose `[start_ns, end_ns]` overlaps the requested interval. + +## Timezone Behavior + +- Query default is `--timezone dataset`. +- `dataset` resolves to the `default_timezone` stored in `meta`. +- If inference is unavailable, the script falls back to `local`. +- Explicit values are also accepted: + - `local` + - `UTC` + - fixed offsets such as `UTC+08:00` + - IANA zone names such as `Asia/Shanghai` diff --git a/pyproject.toml b/pyproject.toml index ab858a2..88ab608 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.0.0" requires-python = ">=3.10" dependencies = [ "click>=8.1", + "duckdb>=1.0", "numpy>=2.2", "opencv-python-headless>=4.11", "progress-table>=3.2", diff --git a/scripts/zed_segment_time_index.py b/scripts/zed_segment_time_index.py new file mode 100644 index 0000000..3e1ab32 --- /dev/null +++ b/scripts/zed_segment_time_index.py @@ -0,0 +1,658 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import concurrent.futures +import datetime as dt +import json +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +import click +import duckdb + + +SCRIPT_PATH = Path(__file__).resolve() +REPO_ROOT = SCRIPT_PATH.parents[1] +DEFAULT_INDEX_NAME = "segment_time_index.duckdb" +INDEX_SCHEMA_VERSION = "1" +SEGMENT_FILE_PATTERN = re.compile(r".*_zed([0-9]+)\.svo2?$", re.IGNORECASE) +FOLDER_TIMESTAMP_PATTERN = re.compile( + r"^(?P\d{4}-\d{2}-\d{2})[T ](?P\d{2})-(?P\d{2})-(?P\d{2})(?P\.\d+)?(?PZ|[+-]\d{2}:\d{2})?$" +) + + +@dataclass(slots=True, frozen=True) +class SegmentScan: + segment_dir: Path + matched_files: int + camera_labels: tuple[str, ...] + is_valid: bool + reason: str | None = None + + +@dataclass(slots=True, frozen=True) +class BoundsRow: + segment_dir: Path + relative_segment_dir: str + group_path: str + activity: str + segment_name: str + mcap_path: Path + start_ns: int + end_ns: int + duration_ns: int + start_iso_utc: str + end_iso_utc: str + camera_count: int + camera_labels: str + video_message_count: int + index_source: str + + +def sorted_camera_labels(labels: set[str]) -> tuple[str, ...]: + return tuple(sorted(labels, key=lambda label: int(label[3:]))) + + +def scan_segment_dir(segment_dir: Path) -> SegmentScan: + if not segment_dir.is_dir(): + return SegmentScan( + segment_dir=segment_dir, + matched_files=0, + camera_labels=(), + is_valid=False, + reason=f"segment directory does not exist: {segment_dir}", + ) + + matched_by_camera: dict[str, list[Path]] = {} + for child in segment_dir.iterdir(): + if not child.is_file(): + continue + match = SEGMENT_FILE_PATTERN.fullmatch(child.name) + if match is None: + continue + label = f"zed{int(match.group(1))}" + matched_by_camera.setdefault(label, []).append(child) + + matched_files = sum(len(paths) for paths in matched_by_camera.values()) + camera_labels = sorted_camera_labels(set(matched_by_camera)) + duplicate_cameras = [label for label, paths in sorted(matched_by_camera.items()) if len(paths) > 1] + + if duplicate_cameras: + return SegmentScan( + segment_dir=segment_dir, + matched_files=matched_files, + camera_labels=camera_labels, + is_valid=False, + reason=f"duplicate camera inputs under {segment_dir}: {', '.join(duplicate_cameras)}", + ) + if len(camera_labels) < 2: + return SegmentScan( + segment_dir=segment_dir, + matched_files=matched_files, + camera_labels=camera_labels, + is_valid=False, + reason=f"expected at least 2 camera inputs under {segment_dir}, found {len(camera_labels)}", + ) + + return SegmentScan( + segment_dir=segment_dir, + matched_files=matched_files, + camera_labels=camera_labels, + is_valid=True, + ) + + +def discover_segment_dirs(root: Path, recursive: bool) -> tuple[list[SegmentScan], list[SegmentScan]]: + if not root.is_dir(): + raise click.ClickException(f"input directory does not exist: {root}") + + candidate_dirs = {root.resolve()} + iterator = root.rglob("*") if recursive else root.iterdir() + for path in iterator: + if path.is_dir(): + candidate_dirs.add(path.resolve()) + + valid_scans: list[SegmentScan] = [] + ignored_partial_scans: list[SegmentScan] = [] + for segment_dir in sorted(candidate_dirs): + scan = scan_segment_dir(segment_dir) + if scan.is_valid: + valid_scans.append(scan) + elif scan.matched_files > 0: + ignored_partial_scans.append(scan) + + if not valid_scans: + raise click.ClickException(f"no multi-camera segments found under {root}") + + return valid_scans, ignored_partial_scans + + +def locate_binary(name: str, override: Path | None) -> Path: + if override is not None: + candidate = override.expanduser().resolve() + if not candidate.is_file(): + raise click.ClickException(f"binary not found: {candidate}") + return candidate + + candidates = ( + REPO_ROOT / "build" / "bin" / name, + REPO_ROOT / "build" / name, + ) + for candidate in candidates: + if candidate.is_file(): + return candidate + raise click.ClickException(f"could not find {name} under {REPO_ROOT / 'build'}") + + +def default_index_path(dataset_root: Path) -> Path: + return dataset_root / DEFAULT_INDEX_NAME + + +def find_unique_mcap(segment_dir: Path) -> Path | None: + matches = sorted(path for path in segment_dir.iterdir() if path.is_file() and path.suffix.lower() == ".mcap") + if len(matches) == 1: + return matches[0] + return None + + +def format_ns_iso(ns: int, tzinfo: dt.tzinfo) -> str: + seconds, nanos = divmod(ns, 1_000_000_000) + stamp = dt.datetime.fromtimestamp(seconds, tz=dt.timezone.utc).astimezone(tzinfo) + offset = stamp.strftime("%z") + offset = f"{offset[:3]}:{offset[3:]}" if offset else "" + return f"{stamp.strftime('%Y-%m-%dT%H:%M:%S')}.{nanos:09d}{offset}" + + +def format_ns_utc(ns: int) -> str: + return format_ns_iso(ns, dt.timezone.utc).replace("+00:00", "Z") + + +def resolve_timezone(name: str) -> dt.tzinfo: + if name == "local": + local = dt.datetime.now().astimezone().tzinfo + if local is None: + raise click.ClickException("could not resolve local timezone") + return local + if name == "UTC": + return dt.timezone.utc + if name.startswith("UTC") and len(name) == len("UTC+00:00"): + try: + sign = 1 if name[3] == "+" else -1 + hours = int(name[4:6]) + minutes = int(name[7:9]) + except ValueError as exc: + raise click.ClickException(f"invalid fixed UTC offset '{name}'") from exc + return dt.timezone(sign * dt.timedelta(hours=hours, minutes=minutes)) + try: + return ZoneInfo(name) + except Exception as exc: # pragma: no cover - defensive wrapper around system tzdb + raise click.ClickException(f"unknown timezone '{name}': {exc}") from exc + + +def normalize_timestamp_text(value: str) -> str: + match = FOLDER_TIMESTAMP_PATTERN.fullmatch(value) + if match is None: + return value + parts = match.groupdict() + fraction = parts["fraction"] or "" + timezone_text = parts["timezone"] or "" + return f"{parts['date']}T{parts['hour']}:{parts['minute']}:{parts['second']}{fraction}{timezone_text}" + + +def parse_folder_name_naive(value: str) -> dt.datetime | None: + normalized = normalize_timestamp_text(value) + try: + parsed = dt.datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is not None: + return None + return parsed + + +def datetime_to_ns(value: dt.datetime) -> int: + utc_value = value.astimezone(dt.timezone.utc) + return int(utc_value.timestamp()) * 1_000_000_000 + utc_value.microsecond * 1_000 + + +def parse_timestamp_to_ns(value: str, timezone_name: str) -> int: + stripped = value.strip() + if not stripped: + raise click.ClickException("timestamp value is empty") + + digit_text = stripped.lstrip("+-") + if digit_text.isdigit(): + raw_value = int(stripped) + digits = len(digit_text) + if digits <= 10: + return raw_value * 1_000_000_000 + if digits <= 13: + return raw_value * 1_000_000 + if digits <= 16: + return raw_value * 1_000 + return raw_value + + normalized = normalize_timestamp_text(stripped) + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + try: + parsed = dt.datetime.fromisoformat(normalized) + except ValueError as exc: + raise click.ClickException(f"invalid timestamp '{value}': {exc}") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=resolve_timezone(timezone_name)) + return datetime_to_ns(parsed) + + +def parse_timestamp_window(value: str, timezone_name: str) -> tuple[int, int]: + stripped = value.strip() + if not stripped: + raise click.ClickException("timestamp value is empty") + + digit_text = stripped.lstrip("+-") + if digit_text.isdigit(): + base_ns = parse_timestamp_to_ns(stripped, timezone_name) + digits = len(digit_text) + if digits <= 10: + precision_ns = 1_000_000_000 + elif digits <= 13: + precision_ns = 1_000_000 + elif digits <= 16: + precision_ns = 1_000 + else: + precision_ns = 1 + return base_ns, base_ns + precision_ns - 1 + + normalized = normalize_timestamp_text(stripped) + base_ns = parse_timestamp_to_ns(stripped, timezone_name) + fraction_match = re.search(r"\.(\d+)", normalized) + if fraction_match is None: + precision_ns = 1_000_000_000 + else: + digits = min(len(fraction_match.group(1)), 9) + precision_ns = 10 ** (9 - digits) + return base_ns, base_ns + precision_ns - 1 + + +def probe_mcap_bounds(bounds_bin: Path, mcap_path: Path) -> dict[str, Any]: + result = subprocess.run( + [str(bounds_bin), str(mcap_path), "--json"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + stderr = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + raise RuntimeError(f"{mcap_path}: {stderr}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{mcap_path}: failed to parse helper JSON: {exc}") from exc + + +def build_row(dataset_root: Path, scan: SegmentScan, bounds_bin: Path) -> BoundsRow | None: + mcap_path = find_unique_mcap(scan.segment_dir) + if mcap_path is None: + return None + + bounds = probe_mcap_bounds(bounds_bin, mcap_path) + relative_segment_dir = scan.segment_dir.relative_to(dataset_root).as_posix() + parent = Path(relative_segment_dir).parent + group_path = "" if str(parent) == "." else parent.as_posix() + parts = Path(relative_segment_dir).parts + activity = parts[0] if parts else scan.segment_dir.name + + start_ns = int(bounds["start_ns"]) + end_ns = int(bounds["end_ns"]) + return BoundsRow( + segment_dir=scan.segment_dir, + relative_segment_dir=relative_segment_dir, + group_path=group_path, + activity=activity, + segment_name=scan.segment_dir.name, + mcap_path=mcap_path, + start_ns=start_ns, + end_ns=end_ns, + duration_ns=max(0, end_ns - start_ns), + start_iso_utc=str(bounds["start_iso_utc"]), + end_iso_utc=str(bounds["end_iso_utc"]), + camera_count=len(scan.camera_labels), + camera_labels=",".join(scan.camera_labels), + video_message_count=int(bounds["video_message_count"]), + index_source="mcap_video_bounds", + ) + + +def init_db(conn: duckdb.DuckDBPyConnection) -> None: + conn.execute( + """ + CREATE TABLE meta ( + key VARCHAR PRIMARY KEY, + value VARCHAR NOT NULL + ); + """ + ) + conn.execute( + """ + CREATE TABLE segments ( + segment_dir VARCHAR PRIMARY KEY, + relative_segment_dir VARCHAR NOT NULL, + group_path VARCHAR NOT NULL, + activity VARCHAR NOT NULL, + segment_name VARCHAR NOT NULL, + mcap_path VARCHAR NOT NULL, + start_ns BIGINT NOT NULL, + end_ns BIGINT NOT NULL, + duration_ns BIGINT NOT NULL, + start_iso_utc VARCHAR NOT NULL, + end_iso_utc VARCHAR NOT NULL, + camera_count INTEGER NOT NULL, + camera_labels VARCHAR NOT NULL, + video_message_count BIGINT NOT NULL, + index_source VARCHAR NOT NULL + ); + """ + ) + conn.execute("CREATE INDEX segments_start_ns_idx ON segments(start_ns);") + conn.execute("CREATE INDEX segments_end_ns_idx ON segments(end_ns);") + + +def write_index(index_path: Path, dataset_root: Path, rows: list[BoundsRow]) -> None: + index_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(prefix=f"{index_path.name}.", suffix=".tmp", dir=index_path.parent, delete=False) as handle: + temp_path = Path(handle.name) + temp_path.unlink(missing_ok=True) + + inferred_timezone = infer_dataset_timezone(rows) + + try: + conn = duckdb.connect(str(temp_path)) + try: + init_db(conn) + conn.executemany( + "INSERT INTO meta (key, value) VALUES (?, ?)", + [ + ("schema_version", INDEX_SCHEMA_VERSION), + ("dataset_root", str(dataset_root)), + ("built_at_utc", dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")), + ("default_timezone", inferred_timezone), + ], + ) + conn.executemany( + """ + INSERT INTO segments ( + segment_dir, + relative_segment_dir, + group_path, + activity, + segment_name, + mcap_path, + start_ns, + end_ns, + duration_ns, + start_iso_utc, + end_iso_utc, + camera_count, + camera_labels, + video_message_count, + index_source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + str(row.segment_dir), + row.relative_segment_dir, + row.group_path, + row.activity, + row.segment_name, + str(row.mcap_path), + row.start_ns, + row.end_ns, + row.duration_ns, + row.start_iso_utc, + row.end_iso_utc, + row.camera_count, + row.camera_labels, + row.video_message_count, + row.index_source, + ) + for row in rows + ], + ) + finally: + conn.close() + temp_path.replace(index_path) + except Exception: + temp_path.unlink(missing_ok=True) + raise + + +def infer_dataset_timezone(rows: list[BoundsRow]) -> str: + offset_counts: dict[int, int] = {} + for row in rows: + folder_time = parse_folder_name_naive(row.segment_name) + if folder_time is None: + continue + actual_utc = dt.datetime.fromtimestamp(row.start_ns / 1_000_000_000, tz=dt.timezone.utc).replace(tzinfo=None) + offset_minutes = round((folder_time - actual_utc).total_seconds() / 60.0) + offset_counts[offset_minutes] = offset_counts.get(offset_minutes, 0) + 1 + + if not offset_counts: + return "local" + + minutes = max(offset_counts.items(), key=lambda item: item[1])[0] + if minutes == 0: + return "UTC" + + sign = "+" if minutes >= 0 else "-" + absolute_minutes = abs(minutes) + hours, mins = divmod(absolute_minutes, 60) + return f"UTC{sign}{hours:02d}:{mins:02d}" + + +def require_query_window(at: str | None, start: str | None, end: str | None, timezone_name: str) -> tuple[int, int]: + if at is not None and (start is not None or end is not None): + raise click.ClickException("use either --at or --start/--end, not both") + if at is not None: + return parse_timestamp_window(at, timezone_name) + if start is None or end is None: + raise click.ClickException("provide --at or both --start and --end") + start_ns = parse_timestamp_to_ns(start, timezone_name) + end_ns = parse_timestamp_to_ns(end, timezone_name) + if start_ns > end_ns: + raise click.ClickException("query start must be before or equal to query end") + return start_ns, end_ns + + +def load_meta(conn: duckdb.DuckDBPyConnection) -> dict[str, str]: + rows = conn.execute("SELECT key, value FROM meta").fetchall() + return {str(key): str(value) for key, value in rows} + + +def format_duration(duration_ns: int) -> str: + return f"{duration_ns / 1_000_000_000:.3f}s" + + +@click.group() +def cli() -> None: + """Build and query a DuckDB index of bundled ZED segment timestamps.""" + + +@cli.command() +@click.argument("dataset_root", type=click.Path(path_type=Path, file_okay=False)) +@click.option("--index", "index_path", type=click.Path(path_type=Path, dir_okay=False)) +@click.option("--recursive/--no-recursive", default=True, show_default=True) +@click.option("--jobs", type=click.IntRange(min=1), default=min(8, os.cpu_count() or 1), show_default=True) +@click.option("--bounds-bin", type=click.Path(path_type=Path, dir_okay=False)) +def build(dataset_root: Path, index_path: Path | None, recursive: bool, jobs: int, bounds_bin: Path | None) -> None: + """Build or replace the embedded DuckDB time index for DATASET_ROOT.""" + + dataset_root = dataset_root.expanduser().resolve() + index_path = (index_path or default_index_path(dataset_root)).expanduser().resolve() + bounds_binary = locate_binary("mcap_video_bounds", bounds_bin) + + valid_scans, ignored_partial_scans = discover_segment_dirs(dataset_root, recursive) + click.echo( + f"discovered {len(valid_scans)} valid segment directories under {dataset_root}", + err=True, + ) + if ignored_partial_scans: + click.echo(f"ignored {len(ignored_partial_scans)} partial segment directories", err=True) + + rows: list[BoundsRow] = [] + skipped_missing_mcap: list[Path] = [] + errors: list[str] = [] + + with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor: + future_to_scan: dict[concurrent.futures.Future[BoundsRow | None], SegmentScan] = { + executor.submit(build_row, dataset_root, scan, bounds_binary): scan for scan in valid_scans + } + for future in concurrent.futures.as_completed(future_to_scan): + scan = future_to_scan[future] + try: + row = future.result() + except Exception as exc: + errors.append(f"{scan.segment_dir}: {exc}") + continue + if row is None: + skipped_missing_mcap.append(scan.segment_dir) + continue + rows.append(row) + + rows.sort(key=lambda row: (row.start_ns, row.segment_dir.as_posix())) + + if skipped_missing_mcap: + click.echo(f"skipped {len(skipped_missing_mcap)} segments with missing or ambiguous MCAP files", err=True) + if errors: + for error in errors: + click.echo(f"error: {error}", err=True) + raise click.ClickException(f"failed to probe {len(errors)} segment(s)") + if not rows: + raise click.ClickException("no indexable MCAP segments were found") + + write_index(index_path, dataset_root, rows) + click.echo( + f"wrote {len(rows)} segments to {index_path} (skipped_missing_mcap={len(skipped_missing_mcap)})", + err=True, + ) + + +@cli.command() +@click.argument("dataset_root", type=click.Path(path_type=Path, file_okay=False)) +@click.option("--index", "index_path", type=click.Path(path_type=Path, dir_okay=False)) +@click.option("--at") +@click.option("--start") +@click.option("--end") +@click.option("--json", "as_json", is_flag=True) +@click.option("--timezone", "timezone_name", default="dataset", show_default=True) +def query( + dataset_root: Path, + index_path: Path | None, + at: str | None, + start: str | None, + end: str | None, + as_json: bool, + timezone_name: str, +) -> None: + """Query the embedded time index for matching segment folders.""" + + dataset_root = dataset_root.expanduser().resolve() + index_path = (index_path or default_index_path(dataset_root)).expanduser().resolve() + if not index_path.is_file(): + raise click.ClickException(f"index not found: {index_path}") + + conn = duckdb.connect(str(index_path), read_only=True) + try: + meta = load_meta(conn) + indexed_root = Path(meta.get("dataset_root", "")).expanduser().resolve() + if indexed_root != dataset_root: + raise click.ClickException( + f"index root mismatch: index was built for {indexed_root}, not {dataset_root}" + ) + effective_timezone_name = meta.get("default_timezone", "local") if timezone_name == "dataset" else timezone_name + query_start_ns, query_end_ns = require_query_window(at, start, end, effective_timezone_name) + display_timezone = resolve_timezone(effective_timezone_name) + + result_rows = conn.execute( + """ + SELECT + segment_dir, + relative_segment_dir, + group_path, + activity, + segment_name, + mcap_path, + start_ns, + end_ns, + duration_ns, + start_iso_utc, + end_iso_utc, + camera_count, + camera_labels, + video_message_count, + index_source + FROM segments + WHERE start_ns <= ? AND end_ns >= ? + ORDER BY start_ns, segment_dir + """, + [query_end_ns, query_start_ns], + ).fetchall() + finally: + conn.close() + + payload = [ + { + "segment_dir": row[0], + "relative_segment_dir": row[1], + "group_path": row[2], + "activity": row[3], + "segment_name": row[4], + "mcap_path": row[5], + "start_ns": row[6], + "end_ns": row[7], + "duration_ns": row[8], + "start_iso_utc": row[9], + "end_iso_utc": row[10], + "camera_count": row[11], + "camera_labels": row[12].split(",") if row[12] else [], + "video_message_count": row[13], + "index_source": row[14], + "start_display": format_ns_iso(row[6], display_timezone), + "end_display": format_ns_iso(row[7], display_timezone), + } + for row in result_rows + ] + + if as_json: + click.echo(json.dumps(payload, indent=2, ensure_ascii=False)) + return + + if not payload: + click.echo("no matching segments") + return + + click.echo(f"matched {len(payload)} segment(s)") + for row in payload: + click.echo( + " | ".join( + ( + row["start_display"], + row["end_display"], + format_duration(int(row["duration_ns"])), + row["segment_dir"], + row["mcap_path"], + ) + ) + ) + + +if __name__ == "__main__": + cli() diff --git a/src/tools/mcap_video_bounds.cpp b/src/tools/mcap_video_bounds.cpp new file mode 100644 index 0000000..516a174 --- /dev/null +++ b/src/tools/mcap_video_bounds.cpp @@ -0,0 +1,219 @@ +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +enum class ToolExitCode : int { + Success = 0, + UsageError = 2, + OpenError = 3, + SchemaError = 4, + ParseError = 5, + EmptyError = 6, +}; + +struct Config { + std::string input_path{}; + bool json{false}; +}; + +struct BoundsSummary { + std::uint64_t start_ns{std::numeric_limits::max()}; + std::uint64_t end_ns{0}; + std::uint64_t message_count{0}; +}; + +[[nodiscard]] +constexpr int exit_code(const ToolExitCode code) { + return static_cast(code); +} + +[[nodiscard]] +std::uint64_t proto_timestamp_ns(const google::protobuf::Timestamp ×tamp) { + return static_cast(timestamp.seconds()) * 1000000000ull + static_cast(timestamp.nanos()); +} + +[[nodiscard]] +std::string json_escape(const std::string &input) { + std::ostringstream output; + for (const unsigned char ch : input) { + switch (ch) { + case '\\': + output << "\\\\"; + break; + case '"': + output << "\\\""; + break; + case '\b': + output << "\\b"; + break; + case '\f': + output << "\\f"; + break; + case '\n': + output << "\\n"; + break; + case '\r': + output << "\\r"; + break; + case '\t': + output << "\\t"; + break; + default: + if (ch < 0x20) { + output << "\\u" << std::hex << std::setw(4) << std::setfill('0') << static_cast(ch) << std::dec; + } else { + output << static_cast(ch); + } + break; + } + } + return output.str(); +} + +[[nodiscard]] +std::string format_iso_utc(const std::uint64_t timestamp_ns) { + const auto seconds = static_cast(timestamp_ns / 1000000000ull); + const auto nanos = timestamp_ns % 1000000000ull; + std::tm tm{}; +#if defined(_WIN32) + gmtime_s(&tm, &seconds); +#else + gmtime_r(&seconds, &tm); +#endif + std::ostringstream output; + output << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S") << '.' << std::setw(9) << std::setfill('0') << nanos << 'Z'; + return output.str(); +} + +[[nodiscard]] +bool is_video_message(const auto &view) { + if (view.channel == nullptr || view.schema == nullptr) { + return false; + } + return view.schema->encoding == "protobuf" && + view.schema->name == "foxglove.CompressedVideo" && + view.channel->messageEncoding == "protobuf"; +} + +[[nodiscard]] +BoundsSummary collect_bounds(const Config &config, ToolExitCode &error_code) { + mcap::McapReader reader{}; + const auto open_status = reader.open(config.input_path); + if (!open_status.ok()) { + spdlog::error("failed to open MCAP file '{}': {}", config.input_path, open_status.message); + error_code = ToolExitCode::OpenError; + return {}; + } + + BoundsSummary summary{}; + auto messages = reader.readMessages(); + for (auto it = messages.begin(); it != messages.end(); ++it) { + if (it->channel == nullptr) { + spdlog::error("MCAP message missing channel metadata"); + reader.close(); + error_code = ToolExitCode::SchemaError; + return {}; + } + if (it->schema == nullptr) { + continue; + } + if (!is_video_message(*it)) { + continue; + } + + foxglove::CompressedVideo message{}; + if (!message.ParseFromArray(it->message.data, static_cast(it->message.dataSize))) { + spdlog::error("failed to parse foxglove.CompressedVideo payload from '{}'", config.input_path); + reader.close(); + error_code = ToolExitCode::ParseError; + return {}; + } + + auto timestamp_ns = proto_timestamp_ns(message.timestamp()); + if (timestamp_ns == 0) { + timestamp_ns = it->message.logTime; + } + + summary.start_ns = std::min(summary.start_ns, timestamp_ns); + summary.end_ns = std::max(summary.end_ns, timestamp_ns); + summary.message_count += 1; + } + + reader.close(); + + if (summary.message_count == 0) { + spdlog::error("no foxglove.CompressedVideo messages found in '{}'", config.input_path); + error_code = ToolExitCode::EmptyError; + return {}; + } + + error_code = ToolExitCode::Success; + return summary; +} + +void print_json(const Config &config, const BoundsSummary &summary) { + std::cout + << '{' + << "\"input_path\":\"" << json_escape(config.input_path) << "\"," + << "\"start_ns\":" << summary.start_ns << ',' + << "\"end_ns\":" << summary.end_ns << ',' + << "\"duration_ns\":" << (summary.end_ns - summary.start_ns) << ',' + << "\"video_message_count\":" << summary.message_count << ',' + << "\"start_iso_utc\":\"" << format_iso_utc(summary.start_ns) << "\"," + << "\"end_iso_utc\":\"" << format_iso_utc(summary.end_ns) << "\"" + << "}\n"; +} + +void print_text(const Config &config, const BoundsSummary &summary) { + std::cout + << config.input_path << '\t' + << summary.start_ns << '\t' + << summary.end_ns << '\t' + << summary.message_count << '\t' + << format_iso_utc(summary.start_ns) << '\t' + << format_iso_utc(summary.end_ns) + << '\n'; +} + +} // namespace + +int main(int argc, char **argv) { + Config config{}; + CLI::App app{"mcap_video_bounds - emit bundled video timestamp bounds from an MCAP"}; + app.add_option("input", config.input_path, "Input MCAP path")->required(); + app.add_flag("--json", config.json, "Emit a JSON object instead of tab-separated text"); + + try { + app.parse(argc, argv); + } catch (const CLI::ParseError &e) { + return app.exit(e); + } + + auto error_code = ToolExitCode::Success; + const auto summary = collect_bounds(config, error_code); + if (error_code != ToolExitCode::Success) { + return exit_code(error_code); + } + + if (config.json) { + print_json(config, summary); + } else { + print_text(config, summary); + } + + return exit_code(ToolExitCode::Success); +} diff --git a/tests/test_zed_segment_time_index.py b/tests/test_zed_segment_time_index.py new file mode 100644 index 0000000..3abc710 --- /dev/null +++ b/tests/test_zed_segment_time_index.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import datetime as dt +import tempfile +import unittest +from pathlib import Path + +import duckdb + +from scripts.zed_segment_time_index import ( + BoundsRow, + format_ns_iso, + infer_dataset_timezone, + parse_timestamp_to_ns, + parse_timestamp_window, + require_query_window, + scan_segment_dir, + write_index, +) + + +class TimestampParseTests(unittest.TestCase): + def test_parse_folder_style_timestamp(self) -> None: + actual = parse_timestamp_to_ns("2026-03-18T12-00-23", "UTC") + expected = parse_timestamp_to_ns("2026-03-18T12:00:23+00:00", "UTC") + self.assertEqual(actual, expected) + + def test_parse_integer_epoch_milliseconds(self) -> None: + self.assertEqual(parse_timestamp_to_ns("1710000000123", "UTC"), 1710000000123 * 1_000_000) + + def test_parse_timestamp_window_for_second_precision_text(self) -> None: + start_ns, end_ns = parse_timestamp_window("2026-03-18T12-00-23", "UTC") + self.assertEqual(end_ns - start_ns, 999_999_999) + + def test_require_query_window_rejects_mixed_modes(self) -> None: + with self.assertRaises(Exception): + require_query_window("1", "2", "3", "UTC") + + def test_format_ns_iso_utc(self) -> None: + rendered = format_ns_iso(1_710_000_000_123_000_000, dt.timezone.utc) + self.assertTrue(rendered.startswith("2024-03-09T16:00:00.123000000")) + + +class SegmentDiscoveryTests(unittest.TestCase): + def test_scan_segment_dir_accepts_multicamera_dir(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + segment_dir = Path(tmp) + for label in ("zed1", "zed2", "zed3", "zed4"): + (segment_dir / f"2026-03-18T12-00-23_{label}.svo2").write_bytes(b"") + scan = scan_segment_dir(segment_dir) + self.assertTrue(scan.is_valid) + self.assertEqual(scan.camera_labels, ("zed1", "zed2", "zed3", "zed4")) + + def test_scan_segment_dir_rejects_partial_dir(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + segment_dir = Path(tmp) + (segment_dir / "2026-03-18T12-00-23_zed1.svo2").write_bytes(b"") + scan = scan_segment_dir(segment_dir) + self.assertFalse(scan.is_valid) + + +class DuckDbIndexTests(unittest.TestCase): + def test_infer_dataset_timezone_from_folder_names(self) -> None: + row = BoundsRow( + segment_dir=Path("/tmp/bar/2026-03-18T11-59-41"), + relative_segment_dir="bar/2026-03-18T11-59-41", + group_path="bar", + activity="bar", + segment_name="2026-03-18T11-59-41", + mcap_path=Path("/tmp/bar/2026-03-18T11-59-41/2026-03-18T11-59-41.mcap"), + start_ns=1_773_806_381_201_081_000, + end_ns=1_773_806_392_268_226_000, + duration_ns=11_067_145_000, + start_iso_utc="2026-03-18T03:59:41.201081000Z", + end_iso_utc="2026-03-18T03:59:52.268226000Z", + camera_count=4, + camera_labels="zed1,zed2,zed3,zed4", + video_message_count=1330, + index_source="mcap_video_bounds", + ) + self.assertEqual(infer_dataset_timezone([row]), "UTC+08:00") + + def test_write_index_and_query_overlap(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "dataset" + root.mkdir() + index_path = root / "segment_time_index.duckdb" + + rows = [ + BoundsRow( + segment_dir=root / "bar" / "2026-03-18T12-00-23", + relative_segment_dir="bar/2026-03-18T12-00-23", + group_path="bar", + activity="bar", + segment_name="2026-03-18T12-00-23", + mcap_path=root / "bar" / "2026-03-18T12-00-23" / "2026-03-18T12-00-23.mcap", + start_ns=100, + end_ns=200, + duration_ns=100, + start_iso_utc="1970-01-01T00:00:00.000000100Z", + end_iso_utc="1970-01-01T00:00:00.000000200Z", + camera_count=4, + camera_labels="zed1,zed2,zed3,zed4", + video_message_count=1330, + index_source="mcap_video_bounds", + ), + BoundsRow( + segment_dir=root / "run" / "2026-03-18T12-01-00", + relative_segment_dir="run/2026-03-18T12-01-00", + group_path="run", + activity="run", + segment_name="2026-03-18T12-01-00", + mcap_path=root / "run" / "2026-03-18T12-01-00" / "2026-03-18T12-01-00.mcap", + start_ns=250, + end_ns=400, + duration_ns=150, + start_iso_utc="1970-01-01T00:00:00.000000250Z", + end_iso_utc="1970-01-01T00:00:00.000000400Z", + camera_count=4, + camera_labels="zed1,zed2,zed3,zed4", + video_message_count=1400, + index_source="mcap_video_bounds", + ), + ] + write_index(index_path, root, rows) + + conn = duckdb.connect(str(index_path), read_only=True) + try: + matches = conn.execute( + "SELECT relative_segment_dir FROM segments WHERE start_ns <= ? AND end_ns >= ? ORDER BY start_ns", + [300, 180], + ).fetchall() + self.assertEqual(matches, [("bar/2026-03-18T12-00-23",), ("run/2026-03-18T12-01-00",)]) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index c56a7f8..fef1689 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,7 @@ version = "0.0.0" source = { virtual = "." } dependencies = [ { name = "click" }, + { name = "duckdb" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "opencv-python-headless" }, @@ -44,6 +45,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1" }, + { name = "duckdb", specifier = ">=1.0" }, { name = "numpy", specifier = ">=2.2" }, { name = "opencv-python-headless", specifier = ">=4.11" }, { name = "progress-table", specifier = ">=3.2" }, @@ -51,6 +53,48 @@ requires-dist = [ { name = "zstandard", specifier = ">=0.23" }, ] +[[package]] +name = "duckdb" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/11/e05a7eb73a373d523e45d83c261025e02bc31ebf868e6282c30c4d02cc59/duckdb-1.5.0.tar.gz", hash = "sha256:f974b61b1c375888ee62bc3125c60ac11c4e45e4457dd1bb31a8f8d3cf277edd", size = 17981141, upload-time = "2026-03-09T12:50:26.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/8fa129bbd604d0e91aa9a0a407e7d2acc559b6024c3f887868fd7a13871d/duckdb-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:47fbb1c053a627a91fa71ec883951561317f14a82df891c00dcace435e8fea78", size = 30012348, upload-time = "2026-03-09T12:48:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/31/db320641a262a897755e634d16838c98d5ca7dc91f4e096e104e244a3a01/duckdb-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b546a30a6ac020165a86ab3abac553255a6e8244d5437d17859a6aa338611aa", size = 15940515, upload-time = "2026-03-09T12:48:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/0b/45/5725684794fbabf54d8dbae5247685799a6bf8e1e930ebff3a76a726772c/duckdb-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:122396041c0acb78e66d7dc7d36c55f03f67fe6ad012155c132d82739722e381", size = 14193724, upload-time = "2026-03-09T12:48:44.105Z" }, + { url = "https://files.pythonhosted.org/packages/27/68/f110c66b43e27191d7e53d3587e118568b73d66f23cb9bd6c7e0a560fd6d/duckdb-1.5.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a2cd73d50ea2c2bf618a4b7d22fe7c4115a1c9083d35654a0d5d421620ed999", size = 19218777, upload-time = "2026-03-09T12:48:46.399Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9d/46affc9257377cbc865e494650312a7a08a56e85aa8d702eb297bec430b7/duckdb-1.5.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63a8ea3b060a881c90d1c1b9454abed3daf95b6160c39bbb9506fee3a9711730", size = 21311205, upload-time = "2026-03-09T12:48:48.895Z" }, + { url = "https://files.pythonhosted.org/packages/3b/34/dac03ab7340989cda258655387959c88342ea3b44949751391267bcbc830/duckdb-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:238d576ae1dda441f8c79ed1370c5ccf863e4a5d59ca2563f9c96cd26b2188ac", size = 13043217, upload-time = "2026-03-09T12:48:51.262Z" }, + { url = "https://files.pythonhosted.org/packages/01/0c/0282b10a1c96810606b916b8d58a03f2131bd3ede14d2851f58b0b860e7c/duckdb-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3298bd17cf0bb5f342fb51a4edc9aadacae882feb2b04161a03eb93271c70c86", size = 30014615, upload-time = "2026-03-09T12:48:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/71/e8/cbbc920078a794f24f63017fc55c9cbdb17d6fb94d3973f479b2d9f2983d/duckdb-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:13f94c49ca389731c439524248e05007fb1a86cd26f1e38f706abc261069cd41", size = 15940493, upload-time = "2026-03-09T12:48:57.85Z" }, + { url = "https://files.pythonhosted.org/packages/31/b6/6cae794d5856259b0060f79d5db71c7fdba043950eaa6a9d72b0bad16095/duckdb-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab9d597b1e8668466f1c164d0ea07eaf0ebb516950f5a2e794b0f52c81ff3b16", size = 14194663, upload-time = "2026-03-09T12:49:00.416Z" }, + { url = "https://files.pythonhosted.org/packages/82/07/aba3887658b93a36ce702dd00ca6a6422de3d14c7ee3a4b4c03ea20a99c0/duckdb-1.5.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a43f8289b11c0b50d13f96ab03210489d37652f3fd7911dc8eab04d61b049da2", size = 19220501, upload-time = "2026-03-09T12:49:03.431Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a2/723e6df48754e468fa50d7878eb860906c975eafe317c4134a8482ca220e/duckdb-1.5.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f514e796a116c5de070e99974e42d0b8c2e6c303386790e58408c481150d417", size = 21316142, upload-time = "2026-03-09T12:49:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/03/af/4dcbdf8f2349ed0b054c254ec59bc362ce6ddf603af35f770124c0984686/duckdb-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:cf503ba2c753d97c76beb111e74572fef8803265b974af2dca67bba1de4176d2", size = 13043445, upload-time = "2026-03-09T12:49:08.892Z" }, + { url = "https://files.pythonhosted.org/packages/60/5e/1bb7e75a63bf3dc49bc5a2cd27a65ffeef151f52a32db980983516f2d9f6/duckdb-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:a1156e91e4e47f0e7d9c9404e559a1d71b372cd61790a407d65eb26948ae8298", size = 13883145, upload-time = "2026-03-09T12:49:11.566Z" }, + { url = "https://files.pythonhosted.org/packages/43/73/120e673e48ae25aaf689044c25ef51b0ea1d088563c9a2532612aea18e0a/duckdb-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9ea988d1d5c8737720d1b2852fd70e4d9e83b1601b8896a1d6d31df5e6afc7dd", size = 30057869, upload-time = "2026-03-09T12:49:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/21/e9/61143471958d36d3f3e764cb4cd43330be208ddbff1c78d3310b9ee67fe8/duckdb-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb786d5472afc16cc3c7355eb2007172538311d6f0cc6f6a0859e84a60220375", size = 15963092, upload-time = "2026-03-09T12:49:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/4f/71/76e37c9a599ad89dd944e6cbb3e6a8ad196944a421758e83adea507637b6/duckdb-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dc92b238f4122800a7592e99134124cc9048c50f766c37a0778dd2637f5cbe59", size = 14220562, upload-time = "2026-03-09T12:49:23.518Z" }, + { url = "https://files.pythonhosted.org/packages/db/b8/de1831656d5d13173e27c79c7259c8b9a7bdc314fdc8920604838ea4c46d/duckdb-1.5.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b74cb205c21d3696d8f8b88adca401e1063d6e6f57c1c4f56a243610b086e30", size = 19245329, upload-time = "2026-03-09T12:49:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8d/33d349a3bcbd3e9b7b4e904c19d5b97f058c4c20791b89a8d6323bb93dce/duckdb-1.5.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e56c19ffd1ffe3642fa89639e71e2e00ab0cf107b62fe16e88030acaebcbde6", size = 21348041, upload-time = "2026-03-09T12:49:30.283Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ec/591a4cad582fae04bc8f8b4a435eceaaaf3838cf0ca771daae16a3c2995b/duckdb-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:86525e565ec0c43420106fd34ba2c739a54c01814d476c7fed3007c9ed6efd86", size = 13053781, upload-time = "2026-03-09T12:49:33.574Z" }, + { url = "https://files.pythonhosted.org/packages/db/62/42e0a13f9919173bec121c0ff702406e1cdd91d8084c3e0b3412508c3891/duckdb-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:5faeebc178c986a7bfa68868a023001137a95a1110bf09b7356442a4eae0f7e7", size = 13862906, upload-time = "2026-03-09T12:49:36.598Z" }, + { url = "https://files.pythonhosted.org/packages/35/5d/af5501221f42e4e3662c047ecec4dcd0761229fceeba3c67ad4d9d8741df/duckdb-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11dd05b827846c87f0ae2f67b9ae1d60985882a7c08ce855379e4a08d5be0e1d", size = 30057396, upload-time = "2026-03-09T12:49:39.95Z" }, + { url = "https://files.pythonhosted.org/packages/43/bd/a278d73fedbd3783bf9aedb09cad4171fe8e55bd522952a84f6849522eb6/duckdb-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8d9c91b7c280ab6811f59deff554b845706c20baa28c4e8f80a95690b252b", size = 15962700, upload-time = "2026-03-09T12:49:43.504Z" }, + { url = "https://files.pythonhosted.org/packages/76/fc/c916e928606946209c20fb50898dabf120241fb528a244e2bd8cde1bd9e2/duckdb-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee4dabe03ed810d64d93927e0fd18cd137060b81ee75dcaeaaff32cbc816656", size = 14220272, upload-time = "2026-03-09T12:49:46.867Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/1390e69db922423b2e111e32ed342b3e8fad0a31c144db70681ea1ba4d56/duckdb-1.5.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409ed1184b363ddea239609c5926f5148ee412b8d9e5ffa617718d755d942f6", size = 19244401, upload-time = "2026-03-09T12:49:49.865Z" }, + { url = "https://files.pythonhosted.org/packages/54/13/b58d718415cde993823a54952ea511d2612302f1d2bc220549d0cef752a4/duckdb-1.5.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1df8c4f9c853a45f3ec1e79ed7fe1957a203e5ec893bbbb853e727eb93e0090f", size = 21345827, upload-time = "2026-03-09T12:49:52.977Z" }, + { url = "https://files.pythonhosted.org/packages/e0/96/4460429651e371eb5ff745a4790e7fa0509c7a58c71fc4f0f893404c9646/duckdb-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a3d3dfa2d8bc74008ce3ad9564761ae23505a9e4282f6a36df29bd87249620b", size = 13053101, upload-time = "2026-03-09T12:49:56.134Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/6d5b805113214b830fa3c267bb3383fb8febaa30760d0162ef59aadb110a/duckdb-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:2deebcbafd9d39c04f31ec968f4dd7cee832c021e10d96b32ab0752453e247c8", size = 13865071, upload-time = "2026-03-09T12:49:59.282Z" }, + { url = "https://files.pythonhosted.org/packages/66/9f/dd806d4e8ecd99006eb240068f34e1054533da1857ad06ac726305cd102d/duckdb-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d4b618de670cd2271dd7b3397508c7b3c62d8ea70c592c755643211a6f9154fa", size = 30065704, upload-time = "2026-03-09T12:50:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/79/c2/7b7b8a5c65d5535c88a513e267b5e6d7a55ab3e9b67e4ddd474454653268/duckdb-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:065ae50cb185bac4b904287df72e6b4801b3bee2ad85679576dd712b8ba07021", size = 15964883, upload-time = "2026-03-09T12:50:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/23/c5/9a52a2cdb228b8d8d191a603254364d929274d9cc7d285beada8f7daa712/duckdb-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6be5e48e287a24d98306ce9dd55093c3b105a8fbd8a2e7a45e13df34bf081985", size = 14221498, upload-time = "2026-03-09T12:50:10.567Z" }, + { url = "https://files.pythonhosted.org/packages/b8/68/646045cb97982702a8a143dc2e45f3bdcb79fbe2d559a98d74b8c160e5e2/duckdb-1.5.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5ee41a0bf793882f02192ce105b9a113c3e8c505a27c7ef9437d7b756317113", size = 19249787, upload-time = "2026-03-09T12:50:13.524Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/5abf0c7f38febb3b4a231c784223fceccfd3f2bfd957699d786f46e41ce6/duckdb-1.5.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8e42aaf3cd217417c5dc9ff522dc3939d18b25a6fe5f846348277e831e6f59c", size = 21351583, upload-time = "2026-03-09T12:50:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/93/a4/a90f2901cc0a1ce7ca4f0564b8492b9dbfe048a6395b27933d46ae9be473/duckdb-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:11ae50aaeda2145b50294ee0247e4f11fb9448b3cc3d2aea1cfc456637dfb977", size = 13575130, upload-time = "2026-03-09T12:50:19.716Z" }, + { url = "https://files.pythonhosted.org/packages/64/aa/f14dd5e241ec80d9f9d82196ca65e0c53badfc8a7a619d5497c5626657ad/duckdb-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:d6d2858c734d1a7e7a1b6e9b8403b3fce26dfefb4e0a2479c420fba6cd36db36", size = 14341879, upload-time = "2026-03-09T12:50:22.347Z" }, +] + [[package]] name = "numpy" version = "2.2.6"