x4d-devkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
x4d_devkit/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """X-4D dataset format SDK."""
2
+
3
+ from x4d_devkit.core import (
4
+ Annotation,
5
+ CalibratedSensor,
6
+ ClipLoader,
7
+ ClipMeta,
8
+ EgoPose,
9
+ Instance,
10
+ Sample,
11
+ SampleData,
12
+ Transform,
13
+ TransformChain,
14
+ generate_token,
15
+ )
16
+ from x4d_devkit.eval import (
17
+ DetectionConfig,
18
+ DetectionEval,
19
+ DetectionResult,
20
+ nusc_detection_config,
21
+ )
22
+ from x4d_devkit.validation import validate_clip, ValidationReport
23
+
24
+ __all__ = [
25
+ "Annotation",
26
+ "CalibratedSensor",
27
+ "ClipLoader",
28
+ "ClipMeta",
29
+ "DetectionConfig",
30
+ "DetectionEval",
31
+ "DetectionResult",
32
+ "EgoPose",
33
+ "Instance",
34
+ "Sample",
35
+ "SampleData",
36
+ "Transform",
37
+ "TransformChain",
38
+ "ValidationReport",
39
+ "generate_token",
40
+ "nusc_detection_config",
41
+ "validate_clip",
42
+ ]
x4d_devkit/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """X-4D DevKit CLI."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def cmd_projects_list(args):
10
+ from x4d_devkit.client import X4DClient
11
+
12
+ with X4DClient(base_url=args.base_url) as client:
13
+ resp = client.projects.list()
14
+ for p in resp["items"]:
15
+ print(f" [{p['id']}] {p['name']} — {p.get('clip_count', '?')} clips")
16
+
17
+
18
+ def cmd_clips_list(args):
19
+ from x4d_devkit.client import X4DClient
20
+
21
+ with X4DClient(base_url=args.base_url) as client:
22
+ resp = client.clips.list(
23
+ project_id=args.project_id,
24
+ page_size=args.page_size,
25
+ lifecycle=args.lifecycle,
26
+ has_archive=args.has_archive,
27
+ )
28
+ print(f"Total: {resp['total']} clips (showing page {resp['page']})")
29
+ for c in resp["items"]:
30
+ status = c.get("lifecycle", "?")
31
+ kf = (
32
+ c.get("statistics", {}).get("keyframe_count", "?")
33
+ if c.get("statistics")
34
+ else "?"
35
+ )
36
+ print(f" {c['clip_id']} [{status}] {kf} keyframes")
37
+
38
+
39
+ def cmd_clips_download(args):
40
+ from x4d_devkit.client import X4DClient
41
+
42
+ with X4DClient(base_url=args.base_url, timeout=600.0) as client:
43
+ # Read clip IDs from file or comma-separated
44
+ if Path(args.clip_ids).exists():
45
+ clip_ids = Path(args.clip_ids).read_text().strip().splitlines()
46
+ else:
47
+ clip_ids = args.clip_ids.split(",")
48
+ clip_ids = [cid.strip() for cid in clip_ids if cid.strip()]
49
+
50
+ print(f"Downloading {len(clip_ids)} clips to {args.output}...")
51
+
52
+ def on_progress(clip_id, i, total):
53
+ print(f" [{i}/{total}] {clip_id} done")
54
+
55
+ paths = client.clips.download_tars(
56
+ clip_ids=clip_ids,
57
+ output_dir=args.output,
58
+ workers=args.workers,
59
+ on_progress=on_progress,
60
+ )
61
+ print(f"Downloaded {len(paths)} clips successfully")
62
+
63
+
64
+ def cmd_validate(args):
65
+ from x4d_devkit.validation import validate_clip
66
+
67
+ target = Path(args.path)
68
+
69
+ if (target / "meta.json").exists():
70
+ # Single clip directory
71
+ report = validate_clip(target)
72
+ print(report)
73
+ sys.exit(0 if report.ok else 1)
74
+ else:
75
+ # Directory of clips
76
+ all_ok = True
77
+ for clip_dir in sorted(target.iterdir()):
78
+ if clip_dir.is_dir() and (clip_dir / "meta.json").exists():
79
+ report = validate_clip(clip_dir)
80
+ print(report)
81
+ if not report.ok:
82
+ all_ok = False
83
+ sys.exit(0 if all_ok else 1)
84
+
85
+
86
+ def main():
87
+ parser = argparse.ArgumentParser(prog="x4d", description="X-4D DevKit CLI")
88
+ parser.add_argument(
89
+ "--base-url", default="http://localhost:8000", help="Platform API URL"
90
+ )
91
+ sub = parser.add_subparsers(dest="command")
92
+
93
+ # projects list
94
+ p_proj = sub.add_parser("projects", help="Project operations")
95
+ p_proj_sub = p_proj.add_subparsers(dest="subcommand")
96
+ p_proj_list = p_proj_sub.add_parser("list", help="List projects")
97
+ p_proj_list.set_defaults(func=cmd_projects_list)
98
+
99
+ # clips list
100
+ p_clips = sub.add_parser("clips", help="Clip operations")
101
+ p_clips_sub = p_clips.add_subparsers(dest="subcommand")
102
+
103
+ p_clips_list = p_clips_sub.add_parser("list", help="List clips")
104
+ p_clips_list.add_argument("--project-id", type=int, required=True)
105
+ p_clips_list.add_argument("--page-size", type=int, default=20)
106
+ p_clips_list.add_argument("--lifecycle", type=str, default=None)
107
+ p_clips_list.add_argument("--has-archive", type=bool, default=None)
108
+ p_clips_list.set_defaults(func=cmd_clips_list)
109
+
110
+ p_clips_dl = p_clips_sub.add_parser("download", help="Download clip tar archives")
111
+ p_clips_dl.add_argument(
112
+ "--clip-ids", required=True, help="Comma-separated IDs or path to file"
113
+ )
114
+ p_clips_dl.add_argument("--output", required=True, help="Output directory")
115
+ p_clips_dl.add_argument("--workers", type=int, default=4)
116
+ p_clips_dl.set_defaults(func=cmd_clips_download)
117
+
118
+ # validate
119
+ p_val = sub.add_parser("validate", help="Validate clip directory")
120
+ p_val.add_argument("path", help="Clip directory or parent directory")
121
+ p_val.set_defaults(func=cmd_validate)
122
+
123
+ args = parser.parse_args()
124
+ if not hasattr(args, "func"):
125
+ parser.print_help()
126
+ sys.exit(1)
127
+ args.func(args)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
@@ -0,0 +1,3 @@
1
+ from x4d_devkit.client.client import X4DClient
2
+
3
+ __all__ = ["X4DClient"]
@@ -0,0 +1,56 @@
1
+ """X-4D platform API client."""
2
+ from __future__ import annotations
3
+
4
+ import httpx
5
+
6
+ from x4d_devkit.client.projects import ProjectsAPI
7
+ from x4d_devkit.client.clips import ClipsAPI
8
+
9
+
10
+ class X4DClient:
11
+ """Client for the X-4D platform REST API.
12
+
13
+ Args:
14
+ base_url: Platform API base URL (e.g. "http://localhost:8000")
15
+ api_key: Optional API key for authentication (future use)
16
+ timeout: Request timeout in seconds
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ base_url: str = "http://localhost:8000",
22
+ api_key: str | None = None,
23
+ timeout: float = 30.0,
24
+ ):
25
+ self._base_url = base_url.rstrip("/")
26
+ self._api_prefix = "/api/v1"
27
+ headers = {}
28
+ if api_key:
29
+ headers["Authorization"] = f"Bearer {api_key}"
30
+ self._http = httpx.Client(
31
+ base_url=self._base_url, headers=headers, timeout=timeout
32
+ )
33
+ self.projects = ProjectsAPI(self)
34
+ self.clips = ClipsAPI(self)
35
+
36
+ def _url(self, path: str) -> str:
37
+ return f"{self._api_prefix}{path}"
38
+
39
+ def get(self, path: str, **kwargs) -> dict:
40
+ resp = self._http.get(self._url(path), **kwargs)
41
+ resp.raise_for_status()
42
+ return resp.json()
43
+
44
+ def post(self, path: str, **kwargs) -> dict:
45
+ resp = self._http.post(self._url(path), **kwargs)
46
+ resp.raise_for_status()
47
+ return resp.json()
48
+
49
+ def close(self):
50
+ self._http.close()
51
+
52
+ def __enter__(self):
53
+ return self
54
+
55
+ def __exit__(self, *args):
56
+ self.close()
@@ -0,0 +1,135 @@
1
+ """Clips API wrapper."""
2
+ from __future__ import annotations
3
+
4
+ import tarfile
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from x4d_devkit.client.client import X4DClient
11
+
12
+
13
+ class ClipsAPI:
14
+ def __init__(self, client: X4DClient):
15
+ self._client = client
16
+
17
+ def list(
18
+ self,
19
+ project_id: int,
20
+ page: int = 1,
21
+ page_size: int = 100,
22
+ lifecycle: str | None = None,
23
+ has_archive: bool | None = None,
24
+ ) -> dict:
25
+ """List clips in a project with optional filters."""
26
+ params: dict = {
27
+ "project_id": project_id,
28
+ "page": page,
29
+ "page_size": page_size,
30
+ }
31
+ if lifecycle:
32
+ params["lifecycle"] = lifecycle
33
+ if has_archive is not None:
34
+ params["has_archive"] = str(has_archive).lower()
35
+ return self._client.get("/clips", params=params)
36
+
37
+ def list_all(self, project_id: int, **kwargs) -> list[dict]:
38
+ """List ALL clips in a project (handles pagination automatically)."""
39
+ all_items = []
40
+ page = 1
41
+ while True:
42
+ resp = self.list(project_id=project_id, page=page, page_size=100, **kwargs)
43
+ all_items.extend(resp["items"])
44
+ if len(all_items) >= resp["total"]:
45
+ break
46
+ page += 1
47
+ return all_items
48
+
49
+ def get(self, clip_id: str) -> dict:
50
+ """Get a single clip by ID."""
51
+ return self._client.get(f"/clips/{clip_id}")
52
+
53
+ def get_archive_status(self, clip_id: str) -> dict:
54
+ """Get archive/tar status for a clip."""
55
+ return self._client.get(f"/clips/{clip_id}/archive")
56
+
57
+ def get_archive_download_url(self, clip_id: str) -> str:
58
+ """Get presigned download URL for the clip's tar archive."""
59
+ resp = self._client.get(f"/clips/{clip_id}/archive/download")
60
+ return resp["url"]
61
+
62
+ def download_tar(
63
+ self, clip_id: str, output_dir: str | Path, extract: bool = True
64
+ ) -> Path:
65
+ """Download and optionally extract a clip's tar archive.
66
+
67
+ Args:
68
+ clip_id: Clip ID to download
69
+ output_dir: Directory to save/extract to
70
+ extract: If True, extract tar and remove the tar file
71
+
72
+ Returns:
73
+ Path to extracted clip directory (if extract=True) or tar file
74
+ """
75
+ output_dir = Path(output_dir)
76
+ output_dir.mkdir(parents=True, exist_ok=True)
77
+
78
+ url = self.get_archive_download_url(clip_id)
79
+ tar_path = output_dir / f"{clip_id}.tar"
80
+
81
+ # Stream download
82
+ with self._client._http.stream("GET", url) as resp:
83
+ resp.raise_for_status()
84
+ with open(tar_path, "wb") as f:
85
+ for chunk in resp.iter_bytes(chunk_size=8 * 1024 * 1024):
86
+ f.write(chunk)
87
+
88
+ if extract:
89
+ clip_dir = output_dir / clip_id
90
+ clip_dir.mkdir(parents=True, exist_ok=True)
91
+ with tarfile.open(tar_path, "r") as tar:
92
+ tar.extractall(path=clip_dir)
93
+ tar_path.unlink()
94
+ return clip_dir
95
+
96
+ return tar_path
97
+
98
+ def download_tars(
99
+ self,
100
+ clip_ids: list[str],
101
+ output_dir: str | Path,
102
+ workers: int = 4,
103
+ extract: bool = True,
104
+ on_progress: callable | None = None,
105
+ ) -> list[Path]:
106
+ """Batch download multiple clip tar archives in parallel.
107
+
108
+ Args:
109
+ clip_ids: List of clip IDs to download
110
+ output_dir: Directory to save/extract to
111
+ workers: Number of parallel download threads
112
+ extract: If True, extract tars and remove tar files
113
+ on_progress: Optional callback(clip_id, index, total) called after each download
114
+
115
+ Returns:
116
+ List of paths to extracted clip directories or tar files
117
+ """
118
+ results = []
119
+
120
+ def _download_one(clip_id: str) -> Path:
121
+ return self.download_tar(clip_id, output_dir, extract=extract)
122
+
123
+ with ThreadPoolExecutor(max_workers=workers) as pool:
124
+ futures = {pool.submit(_download_one, cid): cid for cid in clip_ids}
125
+ for i, future in enumerate(as_completed(futures)):
126
+ clip_id = futures[future]
127
+ try:
128
+ path = future.result()
129
+ results.append(path)
130
+ if on_progress:
131
+ on_progress(clip_id, i + 1, len(clip_ids))
132
+ except Exception as e:
133
+ print(f"Failed to download {clip_id}: {e}")
134
+
135
+ return results
@@ -0,0 +1,25 @@
1
+ """Projects API wrapper."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from x4d_devkit.client.client import X4DClient
8
+
9
+
10
+ class ProjectsAPI:
11
+ def __init__(self, client: X4DClient):
12
+ self._client = client
13
+
14
+ def list(
15
+ self, page: int = 1, page_size: int = 50, search: str | None = None
16
+ ) -> dict:
17
+ """List all projects."""
18
+ params = {"page": page, "page_size": page_size}
19
+ if search:
20
+ params["search"] = search
21
+ return self._client.get("/projects", params=params)
22
+
23
+ def get(self, project_id: int) -> dict:
24
+ """Get a project by ID."""
25
+ return self._client.get(f"/projects/{project_id}")
@@ -0,0 +1 @@
1
+ """Dataset format converters."""