babon 0.1.0__tar.gz
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.
- babon-0.1.0/PKG-INFO +78 -0
- babon-0.1.0/README.md +60 -0
- babon-0.1.0/babon/__init__.py +267 -0
- babon-0.1.0/babon/cli.py +147 -0
- babon-0.1.0/babon.egg-info/PKG-INFO +78 -0
- babon-0.1.0/babon.egg-info/SOURCES.txt +10 -0
- babon-0.1.0/babon.egg-info/dependency_links.txt +1 -0
- babon-0.1.0/babon.egg-info/entry_points.txt +2 -0
- babon-0.1.0/babon.egg-info/requires.txt +1 -0
- babon-0.1.0/babon.egg-info/top_level.txt +1 -0
- babon-0.1.0/pyproject.toml +33 -0
- babon-0.1.0/setup.cfg +4 -0
babon-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: babon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Babon kinematics-as-a-service client. Video in, movement data out.
|
|
5
|
+
Author-email: "Babon Innovations B.V." <daan@babon.eu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://babon.eu
|
|
8
|
+
Project-URL: Documentation, https://api.babon.eu/api/v1/docs
|
|
9
|
+
Keywords: biomechanics,kinematics,gait,video-analysis
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: requests>=2.28
|
|
18
|
+
|
|
19
|
+
# babon
|
|
20
|
+
|
|
21
|
+
Kinematics-as-a-service client. Video in, movement data out.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install babon
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from babon import Babon
|
|
29
|
+
|
|
30
|
+
Babon("bk_...").analyze("trial.mp4").save("out.zip")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That's it.
|
|
34
|
+
|
|
35
|
+
## A bit more
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from babon import Babon
|
|
39
|
+
|
|
40
|
+
bb = Babon("bk_...")
|
|
41
|
+
|
|
42
|
+
# get the clinical_report.json dict directly
|
|
43
|
+
report = bb.analyze("trial.mp4").data
|
|
44
|
+
print(report["gait_parameters"]["gait_velocity_m_s"])
|
|
45
|
+
|
|
46
|
+
# batch
|
|
47
|
+
bb.analyze(["a.mp4", "b.mp4", "c.mp4"]).save("./results/")
|
|
48
|
+
|
|
49
|
+
# tag with your own identifier
|
|
50
|
+
bb.analyze("trial.mp4", label="cohort-3-day-7").save("out.zip")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## What's in the bundle
|
|
54
|
+
|
|
55
|
+
`clinical_report.json` (gait params, GRF, symmetry), 14 per-joint angle CSVs (ISB convention), raw rotation matrices, 3D joint positions, `bodymodel.npz`, `grf_waveform.json`, `manifest.json` with SHA-256 per file.
|
|
56
|
+
|
|
57
|
+
## Errors
|
|
58
|
+
|
|
59
|
+
The client raises typed exceptions:
|
|
60
|
+
|
|
61
|
+
- `BabonAuthError` — bad key, wrong environment, DPA not accepted.
|
|
62
|
+
- `BabonQuotaExceeded` — monthly limit, concurrent limit, or rate-limited.
|
|
63
|
+
- `BabonInvalidVideo` — file is not a recognised container.
|
|
64
|
+
- `BabonInvalidLabel` — label looks like a real name (ADR-0002).
|
|
65
|
+
- `BabonRunFailed` — pipeline failed.
|
|
66
|
+
- `BabonTimeout` — `wait()` exceeded its timeout.
|
|
67
|
+
|
|
68
|
+
All carry `.code` (the API error code) and `.request_id` (the support handle).
|
|
69
|
+
|
|
70
|
+
## What we are and are not
|
|
71
|
+
|
|
72
|
+
- Measurement only. Output is kinematics; clinical interpretation is yours.
|
|
73
|
+
- EU-cloud (Scaleway fr-par). Videos never leave the EU.
|
|
74
|
+
- Don't put real patient names in `label` or in your filenames. We reject name-shaped labels server-side.
|
|
75
|
+
|
|
76
|
+
## Contact
|
|
77
|
+
|
|
78
|
+
daan@babon.eu — onboarding, quota, bugs.
|
babon-0.1.0/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# babon
|
|
2
|
+
|
|
3
|
+
Kinematics-as-a-service client. Video in, movement data out.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install babon
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from babon import Babon
|
|
11
|
+
|
|
12
|
+
Babon("bk_...").analyze("trial.mp4").save("out.zip")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That's it.
|
|
16
|
+
|
|
17
|
+
## A bit more
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from babon import Babon
|
|
21
|
+
|
|
22
|
+
bb = Babon("bk_...")
|
|
23
|
+
|
|
24
|
+
# get the clinical_report.json dict directly
|
|
25
|
+
report = bb.analyze("trial.mp4").data
|
|
26
|
+
print(report["gait_parameters"]["gait_velocity_m_s"])
|
|
27
|
+
|
|
28
|
+
# batch
|
|
29
|
+
bb.analyze(["a.mp4", "b.mp4", "c.mp4"]).save("./results/")
|
|
30
|
+
|
|
31
|
+
# tag with your own identifier
|
|
32
|
+
bb.analyze("trial.mp4", label="cohort-3-day-7").save("out.zip")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What's in the bundle
|
|
36
|
+
|
|
37
|
+
`clinical_report.json` (gait params, GRF, symmetry), 14 per-joint angle CSVs (ISB convention), raw rotation matrices, 3D joint positions, `bodymodel.npz`, `grf_waveform.json`, `manifest.json` with SHA-256 per file.
|
|
38
|
+
|
|
39
|
+
## Errors
|
|
40
|
+
|
|
41
|
+
The client raises typed exceptions:
|
|
42
|
+
|
|
43
|
+
- `BabonAuthError` — bad key, wrong environment, DPA not accepted.
|
|
44
|
+
- `BabonQuotaExceeded` — monthly limit, concurrent limit, or rate-limited.
|
|
45
|
+
- `BabonInvalidVideo` — file is not a recognised container.
|
|
46
|
+
- `BabonInvalidLabel` — label looks like a real name (ADR-0002).
|
|
47
|
+
- `BabonRunFailed` — pipeline failed.
|
|
48
|
+
- `BabonTimeout` — `wait()` exceeded its timeout.
|
|
49
|
+
|
|
50
|
+
All carry `.code` (the API error code) and `.request_id` (the support handle).
|
|
51
|
+
|
|
52
|
+
## What we are and are not
|
|
53
|
+
|
|
54
|
+
- Measurement only. Output is kinematics; clinical interpretation is yours.
|
|
55
|
+
- EU-cloud (Scaleway fr-par). Videos never leave the EU.
|
|
56
|
+
- Don't put real patient names in `label` or in your filenames. We reject name-shaped labels server-side.
|
|
57
|
+
|
|
58
|
+
## Contact
|
|
59
|
+
|
|
60
|
+
daan@babon.eu — onboarding, quota, bugs.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Babon — kinematics-as-a-service client for Python.
|
|
2
|
+
|
|
3
|
+
pip install babon
|
|
4
|
+
|
|
5
|
+
from babon import Babon
|
|
6
|
+
Babon("bk_...").analyze("trial.mp4").save("out.zip")
|
|
7
|
+
|
|
8
|
+
That's it.
|
|
9
|
+
|
|
10
|
+
Internally this wraps `api.babon.eu/v1/*` with `requests`. Idempotency,
|
|
11
|
+
polling, error envelope decoding, file handling: all handled. Customer
|
|
12
|
+
sees one class and three methods.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Iterable, Iterator, Optional, Union
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Babon",
|
|
29
|
+
"Run",
|
|
30
|
+
"RunBatch",
|
|
31
|
+
"BabonError",
|
|
32
|
+
"BabonAuthError",
|
|
33
|
+
"BabonQuotaExceeded",
|
|
34
|
+
"BabonInvalidVideo",
|
|
35
|
+
"BabonInvalidLabel",
|
|
36
|
+
"BabonRunFailed",
|
|
37
|
+
"BabonTimeout",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
DEFAULT_BASE_URL = "https://api.babon.eu"
|
|
42
|
+
DEFAULT_POLL_INTERVAL_S = 30
|
|
43
|
+
DEFAULT_WAIT_TIMEOUT_S = 60 * 60 * 6 # 6 hours, covers an overnight batch
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ─────────────────── exceptions ───────────────────
|
|
47
|
+
|
|
48
|
+
class BabonError(Exception):
|
|
49
|
+
"""Base class for everything the client raises."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, *, code: str = "", request_id: str = ""):
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.code = code
|
|
54
|
+
self.request_id = request_id
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BabonAuthError(BabonError): pass
|
|
58
|
+
class BabonQuotaExceeded(BabonError): pass
|
|
59
|
+
class BabonInvalidVideo(BabonError): pass
|
|
60
|
+
class BabonInvalidLabel(BabonError): pass
|
|
61
|
+
class BabonRunFailed(BabonError): pass
|
|
62
|
+
class BabonTimeout(BabonError): pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_CODE_TO_EXC: dict[str, type[BabonError]] = {
|
|
66
|
+
"MISSING_AUTH": BabonAuthError,
|
|
67
|
+
"INVALID_AUTH": BabonAuthError,
|
|
68
|
+
"WRONG_ENVIRONMENT": BabonAuthError,
|
|
69
|
+
"DPA_NOT_ACCEPTED": BabonAuthError,
|
|
70
|
+
"QUOTA_EXCEEDED": BabonQuotaExceeded,
|
|
71
|
+
"CONCURRENT_LIMIT": BabonQuotaExceeded,
|
|
72
|
+
"RATE_LIMITED": BabonQuotaExceeded,
|
|
73
|
+
"INVALID_VIDEO": BabonInvalidVideo,
|
|
74
|
+
"VIDEO_REQUIRED": BabonInvalidVideo,
|
|
75
|
+
"INVALID_LABEL": BabonInvalidLabel,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _raise_for_envelope(response: requests.Response) -> None:
|
|
80
|
+
if response.ok:
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
body = response.json()
|
|
84
|
+
except Exception:
|
|
85
|
+
raise BabonError(f"HTTP {response.status_code}: {response.text[:200]}")
|
|
86
|
+
err = (body or {}).get("error") or {}
|
|
87
|
+
code = err.get("code", "")
|
|
88
|
+
message = err.get("message", f"HTTP {response.status_code}")
|
|
89
|
+
request_id = err.get("request_id", "")
|
|
90
|
+
exc_cls = _CODE_TO_EXC.get(code, BabonError)
|
|
91
|
+
raise exc_cls(message, code=code, request_id=request_id)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _auto_idempotency_key(file_path: Path, key: str) -> str:
|
|
95
|
+
"""Stable hash of (api key + filename + size + first 4 KB of file).
|
|
96
|
+
|
|
97
|
+
First 4 KB is enough to discriminate distinct uploads without
|
|
98
|
+
re-reading the whole file. Same input, same upload → same idem key →
|
|
99
|
+
server replays the prior response.
|
|
100
|
+
"""
|
|
101
|
+
h = hashlib.sha256()
|
|
102
|
+
h.update(key.encode())
|
|
103
|
+
h.update(b"\x00")
|
|
104
|
+
h.update(str(file_path).encode())
|
|
105
|
+
h.update(b"\x00")
|
|
106
|
+
h.update(str(file_path.stat().st_size).encode())
|
|
107
|
+
h.update(b"\x00")
|
|
108
|
+
with open(file_path, "rb") as f:
|
|
109
|
+
h.update(f.read(4096))
|
|
110
|
+
return h.hexdigest()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ─────────────────── public surface ───────────────────
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Run:
|
|
117
|
+
"""A submitted analysis. Returned from `Babon.analyze(...)`.
|
|
118
|
+
|
|
119
|
+
Methods you care about:
|
|
120
|
+
.wait() block until the run terminates
|
|
121
|
+
.save(path) block + download the bundle.zip
|
|
122
|
+
.data block + return clinical_report.json as a dict
|
|
123
|
+
.id, .status introspection
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
id: str
|
|
127
|
+
_client: "Babon"
|
|
128
|
+
_status: str = "queued"
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def status(self) -> str:
|
|
132
|
+
return self._status
|
|
133
|
+
|
|
134
|
+
def refresh(self) -> "Run":
|
|
135
|
+
body = self._client._get(f"/api/v1/runs/{self.id}")
|
|
136
|
+
self._status = body.get("status", "unknown")
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def wait(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
timeout_s: int = DEFAULT_WAIT_TIMEOUT_S,
|
|
143
|
+
poll_every_s: int = DEFAULT_POLL_INTERVAL_S,
|
|
144
|
+
) -> "Run":
|
|
145
|
+
"""Block until the run reaches a terminal state."""
|
|
146
|
+
started = time.monotonic()
|
|
147
|
+
while True:
|
|
148
|
+
self.refresh()
|
|
149
|
+
if self._status == "completed":
|
|
150
|
+
return self
|
|
151
|
+
if self._status == "failed":
|
|
152
|
+
raise BabonRunFailed(f"run {self.id} failed", code="RUN_FAILED")
|
|
153
|
+
if time.monotonic() - started > timeout_s:
|
|
154
|
+
raise BabonTimeout(
|
|
155
|
+
f"run {self.id} did not complete within {timeout_s}s "
|
|
156
|
+
f"(last status: {self._status})",
|
|
157
|
+
code="TIMEOUT",
|
|
158
|
+
)
|
|
159
|
+
time.sleep(poll_every_s)
|
|
160
|
+
|
|
161
|
+
def save(self, path: Union[str, os.PathLike], *, include_preview: bool = False) -> Path:
|
|
162
|
+
"""Wait for completion and write the bundle to `path`."""
|
|
163
|
+
self.wait()
|
|
164
|
+
url = f"/api/v1/runs/{self.id}/bundle.zip"
|
|
165
|
+
if include_preview:
|
|
166
|
+
url += "?include_preview=true"
|
|
167
|
+
out = Path(path)
|
|
168
|
+
out.write_bytes(self._client._get_bytes(url))
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def data(self) -> dict:
|
|
173
|
+
"""Wait for completion and return clinical_report.json as a dict."""
|
|
174
|
+
self.wait()
|
|
175
|
+
body = self._client._get_bytes(f"/api/v1/runs/{self.id}/files/clinical_report.json")
|
|
176
|
+
import json as _json
|
|
177
|
+
return _json.loads(body)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class RunBatch:
|
|
182
|
+
runs: list[Run]
|
|
183
|
+
|
|
184
|
+
def __iter__(self) -> Iterator[Run]:
|
|
185
|
+
return iter(self.runs)
|
|
186
|
+
|
|
187
|
+
def __len__(self) -> int:
|
|
188
|
+
return len(self.runs)
|
|
189
|
+
|
|
190
|
+
def wait(self, **kwargs) -> "RunBatch":
|
|
191
|
+
for run in self.runs:
|
|
192
|
+
run.wait(**kwargs)
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def save(self, directory: Union[str, os.PathLike], *, include_preview: bool = False) -> list[Path]:
|
|
196
|
+
out_dir = Path(directory)
|
|
197
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
paths = []
|
|
199
|
+
for run in self.runs:
|
|
200
|
+
paths.append(run.save(out_dir / f"{run.id}.zip", include_preview=include_preview))
|
|
201
|
+
return paths
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Babon:
|
|
205
|
+
"""Babon client. One key per organisation.
|
|
206
|
+
|
|
207
|
+
Babon("bk_...").analyze("trial.mp4").save("out.zip")
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self, key: str, *, base_url: str = DEFAULT_BASE_URL, timeout_s: int = 600):
|
|
211
|
+
if not key:
|
|
212
|
+
raise BabonAuthError("api key is required", code="MISSING_AUTH")
|
|
213
|
+
self._key = key
|
|
214
|
+
self._base_url = base_url.rstrip("/")
|
|
215
|
+
self._timeout_s = timeout_s
|
|
216
|
+
|
|
217
|
+
# public surface
|
|
218
|
+
def analyze(
|
|
219
|
+
self,
|
|
220
|
+
video: Union[str, os.PathLike, Iterable[Union[str, os.PathLike]]],
|
|
221
|
+
*,
|
|
222
|
+
label: Optional[str] = None,
|
|
223
|
+
) -> Union[Run, RunBatch]:
|
|
224
|
+
"""Submit one video or a list. Returns a Run or a RunBatch.
|
|
225
|
+
|
|
226
|
+
analyze("trial.mp4") -> Run
|
|
227
|
+
analyze(["a.mp4", "b.mp4"]) -> RunBatch
|
|
228
|
+
analyze("trial.mp4", label="cohort-3") -> Run with custom label
|
|
229
|
+
"""
|
|
230
|
+
if isinstance(video, (str, os.PathLike)):
|
|
231
|
+
return self._submit_one(Path(video), label=label)
|
|
232
|
+
return RunBatch([self._submit_one(Path(v), label=label) for v in video])
|
|
233
|
+
|
|
234
|
+
# internals
|
|
235
|
+
def _headers(self) -> dict:
|
|
236
|
+
return {"Authorization": f"Bearer {self._key}"}
|
|
237
|
+
|
|
238
|
+
def _submit_one(self, path: Path, *, label: Optional[str]) -> Run:
|
|
239
|
+
if not path.is_file():
|
|
240
|
+
raise FileNotFoundError(f"video not found: {path}")
|
|
241
|
+
headers = self._headers()
|
|
242
|
+
headers["Idempotency-Key"] = _auto_idempotency_key(path, self._key)
|
|
243
|
+
data: dict = {}
|
|
244
|
+
if label:
|
|
245
|
+
data["label"] = label
|
|
246
|
+
with open(path, "rb") as f:
|
|
247
|
+
files = {"video": (path.name, f, "application/octet-stream")}
|
|
248
|
+
resp = requests.post(
|
|
249
|
+
f"{self._base_url}/api/v1/runs",
|
|
250
|
+
headers=headers,
|
|
251
|
+
data=data,
|
|
252
|
+
files=files,
|
|
253
|
+
timeout=self._timeout_s,
|
|
254
|
+
)
|
|
255
|
+
_raise_for_envelope(resp)
|
|
256
|
+
body = resp.json()
|
|
257
|
+
return Run(id=body["run_id"], _client=self, _status=body.get("status", "queued"))
|
|
258
|
+
|
|
259
|
+
def _get(self, path: str) -> dict:
|
|
260
|
+
resp = requests.get(f"{self._base_url}{path}", headers=self._headers(), timeout=self._timeout_s)
|
|
261
|
+
_raise_for_envelope(resp)
|
|
262
|
+
return resp.json()
|
|
263
|
+
|
|
264
|
+
def _get_bytes(self, path: str) -> bytes:
|
|
265
|
+
resp = requests.get(f"{self._base_url}{path}", headers=self._headers(), timeout=self._timeout_s)
|
|
266
|
+
_raise_for_envelope(resp)
|
|
267
|
+
return resp.content
|
babon-0.1.0/babon/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Babon CLI — `babon <command>`.
|
|
2
|
+
|
|
3
|
+
Installed as a console_scripts entry point, so `pip install babon` puts
|
|
4
|
+
a `babon` binary on the user's PATH. The customer never writes glue
|
|
5
|
+
scripts; they just run:
|
|
6
|
+
|
|
7
|
+
babon analyze trial.mp4
|
|
8
|
+
babon analyze ./videos/
|
|
9
|
+
babon status <run_id>
|
|
10
|
+
babon download <run_id>
|
|
11
|
+
babon resume
|
|
12
|
+
|
|
13
|
+
The key comes from `BABON_KEY` in the environment (or `--key`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from babon import Babon, Run, BabonError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _key(args) -> str:
|
|
27
|
+
key = args.key or os.environ.get("BABON_KEY", "")
|
|
28
|
+
if not key:
|
|
29
|
+
print("set BABON_KEY=bk_... or pass --key", file=sys.stderr)
|
|
30
|
+
sys.exit(2)
|
|
31
|
+
return key
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _collect_videos(target: Path) -> list[Path]:
|
|
35
|
+
if target.is_file():
|
|
36
|
+
return [target]
|
|
37
|
+
if target.is_dir():
|
|
38
|
+
return sorted(p for p in target.glob("*.mp4") if p.is_file())
|
|
39
|
+
print(f"not found: {target}", file=sys.stderr)
|
|
40
|
+
sys.exit(2)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_analyze(args) -> int:
|
|
44
|
+
bb = Babon(_key(args))
|
|
45
|
+
videos = _collect_videos(Path(args.path))
|
|
46
|
+
if not videos:
|
|
47
|
+
print(f"no .mp4 files in {args.path}", file=sys.stderr)
|
|
48
|
+
return 2
|
|
49
|
+
|
|
50
|
+
out_dir = Path(args.out)
|
|
51
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
print(f"submitting {len(videos)} video(s)...", flush=True)
|
|
53
|
+
runs = bb.analyze([str(v) for v in videos], label=args.label)
|
|
54
|
+
if isinstance(runs, Run):
|
|
55
|
+
runs_list = [runs]
|
|
56
|
+
else:
|
|
57
|
+
runs_list = list(runs)
|
|
58
|
+
|
|
59
|
+
for run, video in zip(runs_list, videos):
|
|
60
|
+
print(f" {video.name:30s} -> {run.id}", flush=True)
|
|
61
|
+
|
|
62
|
+
print("waiting for completion, then downloading...", flush=True)
|
|
63
|
+
for run, video in zip(runs_list, videos):
|
|
64
|
+
out_path = out_dir / f"{video.stem}_{run.id}.zip"
|
|
65
|
+
print(f" {run.id}: polling ...", flush=True, end="")
|
|
66
|
+
run.save(str(out_path), include_preview=args.include_preview)
|
|
67
|
+
size_mb = out_path.stat().st_size / 1e6
|
|
68
|
+
print(f" saved {out_path.name} ({size_mb:.1f} MB)", flush=True)
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_status(args) -> int:
|
|
73
|
+
bb = Babon(_key(args))
|
|
74
|
+
body = bb._get(f"/api/v1/runs/{args.run_id}")
|
|
75
|
+
for k in ("run_id", "status", "stage", "progress", "created_at", "last_update"):
|
|
76
|
+
if k in body:
|
|
77
|
+
print(f"{k:14s} {body[k]}")
|
|
78
|
+
if body.get("status") == "failed" and body.get("error"):
|
|
79
|
+
print(f"error {body['error']}")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_download(args) -> int:
|
|
84
|
+
bb = Babon(_key(args))
|
|
85
|
+
out = Path(args.out or f"{args.run_id}.zip")
|
|
86
|
+
run = Run(id=args.run_id, _client=bb)
|
|
87
|
+
run.save(str(out), include_preview=args.include_preview)
|
|
88
|
+
print(f"saved {out} ({out.stat().st_size / 1e6:.1f} MB)")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_resume(args) -> int:
|
|
93
|
+
"""Pick up any non-terminal runs for this tenant and wait+download them.
|
|
94
|
+
|
|
95
|
+
Reads the usage endpoint to find the org id, then walks recent
|
|
96
|
+
submitted runs. For the pilot tier with low volume this is fine; a
|
|
97
|
+
cohort-volume tenant would page through /v1/runs (list endpoint, to
|
|
98
|
+
be added) instead.
|
|
99
|
+
"""
|
|
100
|
+
print("usage endpoint provides totals but not run ids; ask Babon for the list endpoint", file=sys.stderr)
|
|
101
|
+
return 3
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _add_key(p: argparse.ArgumentParser) -> None:
|
|
105
|
+
p.add_argument("--key", help="API key (or set BABON_KEY env)")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main() -> int:
|
|
109
|
+
parser = argparse.ArgumentParser(prog="babon")
|
|
110
|
+
_add_key(parser)
|
|
111
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
112
|
+
|
|
113
|
+
p_analyze = sub.add_parser("analyze", help="submit one video or all .mp4 in a directory, wait, download")
|
|
114
|
+
_add_key(p_analyze)
|
|
115
|
+
p_analyze.add_argument("path", help="path to a .mp4 file or a directory of them")
|
|
116
|
+
p_analyze.add_argument("--out", default="./out", help="output directory (default ./out)")
|
|
117
|
+
p_analyze.add_argument("--label", default=None, help="optional opaque identifier")
|
|
118
|
+
p_analyze.add_argument("--include-preview", action="store_true", help="include rendered preview mp4 in the bundle")
|
|
119
|
+
p_analyze.set_defaults(fn=cmd_analyze)
|
|
120
|
+
|
|
121
|
+
p_status = sub.add_parser("status", help="print lifecycle state for one run")
|
|
122
|
+
_add_key(p_status)
|
|
123
|
+
p_status.add_argument("run_id")
|
|
124
|
+
p_status.set_defaults(fn=cmd_status)
|
|
125
|
+
|
|
126
|
+
p_download = sub.add_parser("download", help="download the bundle for a completed run")
|
|
127
|
+
_add_key(p_download)
|
|
128
|
+
p_download.add_argument("run_id")
|
|
129
|
+
p_download.add_argument("--out", default=None, help="output file (default <run_id>.zip)")
|
|
130
|
+
p_download.add_argument("--include-preview", action="store_true")
|
|
131
|
+
p_download.set_defaults(fn=cmd_download)
|
|
132
|
+
|
|
133
|
+
p_resume = sub.add_parser("resume", help="wait + download any in-flight runs for this tenant")
|
|
134
|
+
_add_key(p_resume)
|
|
135
|
+
p_resume.set_defaults(fn=cmd_resume)
|
|
136
|
+
|
|
137
|
+
args = parser.parse_args()
|
|
138
|
+
try:
|
|
139
|
+
return args.fn(args)
|
|
140
|
+
except BabonError as exc:
|
|
141
|
+
print(f"ERROR: {type(exc).__name__}: {exc} (code={exc.code} req={exc.request_id})",
|
|
142
|
+
file=sys.stderr)
|
|
143
|
+
return 4
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
sys.exit(main())
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: babon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Babon kinematics-as-a-service client. Video in, movement data out.
|
|
5
|
+
Author-email: "Babon Innovations B.V." <daan@babon.eu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://babon.eu
|
|
8
|
+
Project-URL: Documentation, https://api.babon.eu/api/v1/docs
|
|
9
|
+
Keywords: biomechanics,kinematics,gait,video-analysis
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: requests>=2.28
|
|
18
|
+
|
|
19
|
+
# babon
|
|
20
|
+
|
|
21
|
+
Kinematics-as-a-service client. Video in, movement data out.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install babon
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from babon import Babon
|
|
29
|
+
|
|
30
|
+
Babon("bk_...").analyze("trial.mp4").save("out.zip")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That's it.
|
|
34
|
+
|
|
35
|
+
## A bit more
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from babon import Babon
|
|
39
|
+
|
|
40
|
+
bb = Babon("bk_...")
|
|
41
|
+
|
|
42
|
+
# get the clinical_report.json dict directly
|
|
43
|
+
report = bb.analyze("trial.mp4").data
|
|
44
|
+
print(report["gait_parameters"]["gait_velocity_m_s"])
|
|
45
|
+
|
|
46
|
+
# batch
|
|
47
|
+
bb.analyze(["a.mp4", "b.mp4", "c.mp4"]).save("./results/")
|
|
48
|
+
|
|
49
|
+
# tag with your own identifier
|
|
50
|
+
bb.analyze("trial.mp4", label="cohort-3-day-7").save("out.zip")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## What's in the bundle
|
|
54
|
+
|
|
55
|
+
`clinical_report.json` (gait params, GRF, symmetry), 14 per-joint angle CSVs (ISB convention), raw rotation matrices, 3D joint positions, `bodymodel.npz`, `grf_waveform.json`, `manifest.json` with SHA-256 per file.
|
|
56
|
+
|
|
57
|
+
## Errors
|
|
58
|
+
|
|
59
|
+
The client raises typed exceptions:
|
|
60
|
+
|
|
61
|
+
- `BabonAuthError` — bad key, wrong environment, DPA not accepted.
|
|
62
|
+
- `BabonQuotaExceeded` — monthly limit, concurrent limit, or rate-limited.
|
|
63
|
+
- `BabonInvalidVideo` — file is not a recognised container.
|
|
64
|
+
- `BabonInvalidLabel` — label looks like a real name (ADR-0002).
|
|
65
|
+
- `BabonRunFailed` — pipeline failed.
|
|
66
|
+
- `BabonTimeout` — `wait()` exceeded its timeout.
|
|
67
|
+
|
|
68
|
+
All carry `.code` (the API error code) and `.request_id` (the support handle).
|
|
69
|
+
|
|
70
|
+
## What we are and are not
|
|
71
|
+
|
|
72
|
+
- Measurement only. Output is kinematics; clinical interpretation is yours.
|
|
73
|
+
- EU-cloud (Scaleway fr-par). Videos never leave the EU.
|
|
74
|
+
- Don't put real patient names in `label` or in your filenames. We reject name-shaped labels server-side.
|
|
75
|
+
|
|
76
|
+
## Contact
|
|
77
|
+
|
|
78
|
+
daan@babon.eu — onboarding, quota, bugs.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.28
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
babon
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "babon"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Babon kinematics-as-a-service client. Video in, movement data out."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Babon Innovations B.V.", email = "daan@babon.eu" }]
|
|
13
|
+
keywords = ["biomechanics", "kinematics", "gait", "video-analysis"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"requests>=2.28",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://babon.eu"
|
|
27
|
+
Documentation = "https://api.babon.eu/api/v1/docs"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
babon = "babon.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
include = ["babon*"]
|
babon-0.1.0/setup.cfg
ADDED