motion-intelligence 0.2.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.
- motion/__init__.py +10 -0
- motion/batch.py +153 -0
- motion/categories.py +46 -0
- motion/cli.py +503 -0
- motion/config.py +148 -0
- motion/dockerfile.py +110 -0
- motion/predictor.py +62 -0
- motion/profile.py +345 -0
- motion/server.py +138 -0
- motion/weight_upload.py +324 -0
- motion_intelligence-0.2.0.dist-info/METADATA +134 -0
- motion_intelligence-0.2.0.dist-info/RECORD +15 -0
- motion_intelligence-0.2.0.dist-info/WHEEL +5 -0
- motion_intelligence-0.2.0.dist-info/entry_points.txt +2 -0
- motion_intelligence-0.2.0.dist-info/top_level.txt +1 -0
motion/cli.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""The `motion` command (Python).
|
|
2
|
+
|
|
3
|
+
`motion build` — uploads weights to S3 then packages the model into a Docker image.
|
|
4
|
+
`motion deploy` — profile compute (docker stats), push image to ECR, register model.
|
|
5
|
+
|
|
6
|
+
Weight upload flow:
|
|
7
|
+
1. For each weight with a local `path` in motion.yml, call the Motion backend
|
|
8
|
+
for presigned S3 upload URLs (no AWS credentials needed on the developer machine).
|
|
9
|
+
2. Small files: single HTTP PUT. Large files (≥64 MiB): S3 multipart upload
|
|
10
|
+
(init → per-part PUT with fresh URLs → complete).
|
|
11
|
+
3. Write a weights.json manifest into the image with the final S3 URL.
|
|
12
|
+
4. The container fetches weights from S3 at startup using the MotionBatchJobRole
|
|
13
|
+
IAM role — users never touch AWS credentials.
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from .categories import resolve_category_id
|
|
23
|
+
from .config import MotionConfig
|
|
24
|
+
from .dockerfile import generate_dockerfile
|
|
25
|
+
from .profile import resolve_compute_profile
|
|
26
|
+
from .weight_upload import upload_weight_via_backend
|
|
27
|
+
|
|
28
|
+
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
29
|
+
_RUNTIME_FILES = ("__init__.py", "predictor.py", "server.py", "batch.py")
|
|
30
|
+
_DEFAULT_API_URL = "https://adwrzdrdes.us-east-1.awsapprunner.com"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _c(msg, code="36"):
|
|
34
|
+
return f"\033[{code}m{msg}\033[0m"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _api_url():
|
|
38
|
+
return os.environ.get("MOTION_API_URL", _DEFAULT_API_URL).rstrip("/")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_build(args):
|
|
42
|
+
project_dir = os.path.abspath(args.path)
|
|
43
|
+
print(_c(f"▶ motion build ({project_dir})"))
|
|
44
|
+
|
|
45
|
+
# 1. Parse and validate motion.yml
|
|
46
|
+
cfg = MotionConfig.load(project_dir)
|
|
47
|
+
cfg.validate_files()
|
|
48
|
+
cat_label = ""
|
|
49
|
+
if cfg.category_id is not None:
|
|
50
|
+
cat_label = f" category_id={cfg.category_id}"
|
|
51
|
+
elif cfg.category:
|
|
52
|
+
cat_label = f" category={cfg.category}"
|
|
53
|
+
print(f" ✓ {_c('motion.yml')} name={cfg.name} gpu={cfg.build.gpu}{cat_label}")
|
|
54
|
+
print(f" ✓ predictor {cfg.predict}")
|
|
55
|
+
|
|
56
|
+
# 2. Vendor runtime into .motion/runtime/motion/
|
|
57
|
+
motion_dir = os.path.join(project_dir, ".motion")
|
|
58
|
+
if os.path.exists(motion_dir):
|
|
59
|
+
shutil.rmtree(motion_dir)
|
|
60
|
+
staging = os.path.join(motion_dir, "runtime", "motion")
|
|
61
|
+
os.makedirs(staging)
|
|
62
|
+
for fn in _RUNTIME_FILES:
|
|
63
|
+
shutil.copy(os.path.join(_PKG_DIR, fn), os.path.join(staging, fn))
|
|
64
|
+
print(" ✓ vendored runtime → .motion/runtime/motion")
|
|
65
|
+
|
|
66
|
+
# 3. Weights — upload local files to S3, write manifest, exclude from image
|
|
67
|
+
if cfg.weights:
|
|
68
|
+
api_key = os.environ.get("MOTION_API_KEY", "")
|
|
69
|
+
|
|
70
|
+
if not args.dry_run and not api_key:
|
|
71
|
+
print(_c(" ✗ MOTION_API_KEY not set — required to upload weights.", "31"))
|
|
72
|
+
print(" Get your key from the Motion dashboard and run:")
|
|
73
|
+
print(" export MOTION_API_KEY=<your_key>")
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
manifest = []
|
|
77
|
+
for w in cfg.weights:
|
|
78
|
+
local = os.path.join(project_dir, w.get("path", w["dest"]))
|
|
79
|
+
if not args.dry_run and os.path.exists(local):
|
|
80
|
+
# Upload local file → S3 via backend presigned URL
|
|
81
|
+
s3_url = upload_weight_via_backend(
|
|
82
|
+
local,
|
|
83
|
+
cfg.name,
|
|
84
|
+
api_key,
|
|
85
|
+
api_url=_api_url(),
|
|
86
|
+
log_ok=lambda m: _c(m, "32"),
|
|
87
|
+
log_warn=lambda m: _c(m, "33"),
|
|
88
|
+
log_err=lambda m: _c(m, "31"),
|
|
89
|
+
)
|
|
90
|
+
if s3_url is None:
|
|
91
|
+
return 1
|
|
92
|
+
manifest.append({"url": s3_url, "dest": w["dest"]})
|
|
93
|
+
else:
|
|
94
|
+
# No local file — weight already hosted at declared url
|
|
95
|
+
if not args.dry_run:
|
|
96
|
+
print(f" • {w.get('path', w['dest'])} not found locally — using declared url: {w['url']}")
|
|
97
|
+
manifest.append({"url": w["url"], "dest": w["dest"]})
|
|
98
|
+
|
|
99
|
+
# Manifest the container reads at startup to fetch weights
|
|
100
|
+
with open(os.path.join(motion_dir, "weights.json"), "w") as f:
|
|
101
|
+
json.dump(manifest, f, indent=2)
|
|
102
|
+
|
|
103
|
+
# Exclude local weight files from the Docker build context (not baked in)
|
|
104
|
+
ignore_lines = [w["dest"] for w in cfg.weights] + ["__pycache__", "*.pyc"]
|
|
105
|
+
with open(os.path.join(motion_dir, "Dockerfile.dockerignore"), "w") as f:
|
|
106
|
+
f.write("\n".join(ignore_lines) + "\n")
|
|
107
|
+
|
|
108
|
+
names = ", ".join(w["dest"] for w in cfg.weights)
|
|
109
|
+
action = "declared (dry-run)" if args.dry_run else "uploaded"
|
|
110
|
+
print(f" ✓ weights {len(cfg.weights)} {action} — fetched at container startup: {names}")
|
|
111
|
+
|
|
112
|
+
# 4. Generate .motion/Dockerfile
|
|
113
|
+
dockerfile = generate_dockerfile(cfg)
|
|
114
|
+
df_path = os.path.join(motion_dir, "Dockerfile")
|
|
115
|
+
with open(df_path, "w") as f:
|
|
116
|
+
f.write(dockerfile)
|
|
117
|
+
print(" ✓ generated .motion/Dockerfile")
|
|
118
|
+
|
|
119
|
+
if args.dry_run:
|
|
120
|
+
print(_c("\n--- Dockerfile (dry run, not built) ---", "33"))
|
|
121
|
+
print(dockerfile)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
# 5. docker build — always target linux/amd64 so the image runs on AWS Batch
|
|
125
|
+
# (x86_64 instances). BuildKit / Rosetta handles the cross-compilation
|
|
126
|
+
# transparently on Apple Silicon.
|
|
127
|
+
tag = args.tag or cfg.image_tag
|
|
128
|
+
print(_c(f"\n▶ docker build --platform linux/amd64 -t {tag}"))
|
|
129
|
+
proc = subprocess.run([
|
|
130
|
+
"docker", "build",
|
|
131
|
+
"--platform", "linux/amd64",
|
|
132
|
+
"-f", df_path, "-t", tag, project_dir,
|
|
133
|
+
])
|
|
134
|
+
if proc.returncode != 0:
|
|
135
|
+
print(_c("✗ docker build failed", "31"), file=sys.stderr)
|
|
136
|
+
return proc.returncode
|
|
137
|
+
|
|
138
|
+
print(_c(f"\n✓ built image: {tag}", "32"))
|
|
139
|
+
print(f" test locally: docker run --rm -p 8000:8000 {tag}")
|
|
140
|
+
print(" next step: motion deploy (push to ECR + create endpoint)")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_deploy(args):
|
|
145
|
+
import base64
|
|
146
|
+
import requests
|
|
147
|
+
|
|
148
|
+
project_dir = os.path.abspath(args.path)
|
|
149
|
+
print(_c(f"▶ motion deploy ({project_dir})"))
|
|
150
|
+
|
|
151
|
+
# 1. Parse motion.yml
|
|
152
|
+
cfg = MotionConfig.load(project_dir)
|
|
153
|
+
cfg.validate_files()
|
|
154
|
+
cat_label = ""
|
|
155
|
+
if cfg.category_id is not None:
|
|
156
|
+
cat_label = f" category_id={cfg.category_id}"
|
|
157
|
+
elif cfg.category:
|
|
158
|
+
cat_label = f" category={cfg.category}"
|
|
159
|
+
print(f" ✓ {_c('motion.yml')} name={cfg.name} gpu={cfg.build.gpu}{cat_label}")
|
|
160
|
+
|
|
161
|
+
api_key = os.environ.get("MOTION_API_KEY", "")
|
|
162
|
+
if not api_key:
|
|
163
|
+
print(_c(" ✗ MOTION_API_KEY not set.", "31"))
|
|
164
|
+
print(" export MOTION_API_KEY=<your_key>")
|
|
165
|
+
return 1
|
|
166
|
+
|
|
167
|
+
local_tag = args.tag or cfg.image_tag
|
|
168
|
+
|
|
169
|
+
# 2. Confirm image exists locally
|
|
170
|
+
result = subprocess.run(
|
|
171
|
+
["docker", "image", "inspect", local_tag],
|
|
172
|
+
capture_output=True,
|
|
173
|
+
)
|
|
174
|
+
if result.returncode != 0:
|
|
175
|
+
print(_c(f" ✗ image '{local_tag}' not found locally. Run `motion build` first.", "31"))
|
|
176
|
+
return 1
|
|
177
|
+
print(f" ✓ image {local_tag} found")
|
|
178
|
+
|
|
179
|
+
# 3. Profile compute (docker stats) before push — optional
|
|
180
|
+
compute_profile = resolve_compute_profile(
|
|
181
|
+
image=local_tag,
|
|
182
|
+
project_dir=project_dir,
|
|
183
|
+
is_gpu=cfg.build.gpu,
|
|
184
|
+
declared_compute=cfg.compute.as_dict() if cfg.compute else None,
|
|
185
|
+
sample_hint=cfg.sample,
|
|
186
|
+
sample_override=args.sample,
|
|
187
|
+
no_profile=args.no_profile,
|
|
188
|
+
timeout=args.profile_timeout,
|
|
189
|
+
log=lambda m: print(f" ▸ {m}"),
|
|
190
|
+
log_warn=lambda m: print(_c(f" ⚠ {m}", "33")),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# 4. Get ECR push info (tag + login token) from backend — no AWS creds needed
|
|
194
|
+
print(" → requesting ECR push credentials …")
|
|
195
|
+
try:
|
|
196
|
+
r = requests.post(
|
|
197
|
+
f"{_api_url()}/api/v1/motion/deploy-info",
|
|
198
|
+
json={"model_name": cfg.name},
|
|
199
|
+
headers={"X-API-Key": api_key},
|
|
200
|
+
timeout=30,
|
|
201
|
+
)
|
|
202
|
+
r.raise_for_status()
|
|
203
|
+
except Exception as e:
|
|
204
|
+
print(_c(f" ✗ could not get deploy info: {e}", "31"))
|
|
205
|
+
return 1
|
|
206
|
+
|
|
207
|
+
info = r.json()
|
|
208
|
+
ecr_registry: str = info["ecr_registry"]
|
|
209
|
+
ecr_image: str = info["ecr_image"]
|
|
210
|
+
ecr_token: str = info["ecr_token"]
|
|
211
|
+
user_id: int = info["user_id"]
|
|
212
|
+
namespace: str = info["namespace"] # e.g. "jay"
|
|
213
|
+
model_code: str = f"{namespace}/{cfg.name}" # e.g. "jay/pose-estimation"
|
|
214
|
+
model_display: str = f"{namespace}/{_slug_to_title(cfg.name)}"
|
|
215
|
+
print(f" ✓ ECR target {ecr_image}")
|
|
216
|
+
print(f" ✓ model_code {model_code}")
|
|
217
|
+
|
|
218
|
+
# 5. docker login to ECR using the backend-issued token
|
|
219
|
+
decoded = base64.b64decode(ecr_token).decode()
|
|
220
|
+
_, ecr_password = decoded.split(":", 1)
|
|
221
|
+
login = subprocess.run(
|
|
222
|
+
["docker", "login", "--username", "AWS", "--password-stdin", ecr_registry],
|
|
223
|
+
input=ecr_password.encode(),
|
|
224
|
+
capture_output=True,
|
|
225
|
+
)
|
|
226
|
+
if login.returncode != 0:
|
|
227
|
+
print(_c(f" ✗ docker login failed: {login.stderr.decode()}", "31"))
|
|
228
|
+
return 1
|
|
229
|
+
print(" ✓ docker login ECR OK")
|
|
230
|
+
|
|
231
|
+
# 6. Push to ECR — cross-build if local image is arm64
|
|
232
|
+
# On Apple Silicon `docker build --platform linux/amd64` still stores the
|
|
233
|
+
# image locally as arm64 in the default builder. Pushing that directly to
|
|
234
|
+
# ECR would give Batch a CannotPullContainerError on amd64 instances.
|
|
235
|
+
# When we detect arm64, we use `docker buildx build --platform linux/amd64
|
|
236
|
+
# --push` (container-driver buildx) to build a fresh amd64 image and push
|
|
237
|
+
# it straight to ECR — no local load required.
|
|
238
|
+
local_arch = subprocess.run(
|
|
239
|
+
["docker", "image", "inspect", local_tag, "--format", "{{.Architecture}}"],
|
|
240
|
+
capture_output=True, text=True,
|
|
241
|
+
).stdout.strip()
|
|
242
|
+
|
|
243
|
+
df_path = os.path.join(project_dir, ".motion", "Dockerfile")
|
|
244
|
+
need_cross_build = (local_arch == "arm64") and os.path.exists(df_path)
|
|
245
|
+
|
|
246
|
+
if need_cross_build:
|
|
247
|
+
print(f" ⚠ local image is arm64 — cross-building linux/amd64 with buildx")
|
|
248
|
+
# Ensure a container-driver builder exists
|
|
249
|
+
subprocess.run(
|
|
250
|
+
["docker", "buildx", "create", "--name", "cross-builder",
|
|
251
|
+
"--driver", "docker-container", "--use"],
|
|
252
|
+
capture_output=True,
|
|
253
|
+
)
|
|
254
|
+
print(_c(f"\n▶ docker buildx build --platform linux/amd64 --push → {ecr_image}"))
|
|
255
|
+
push = subprocess.run([
|
|
256
|
+
"docker", "buildx", "build",
|
|
257
|
+
"--platform", "linux/amd64",
|
|
258
|
+
"--builder", "cross-builder",
|
|
259
|
+
"-f", df_path,
|
|
260
|
+
"-t", ecr_image,
|
|
261
|
+
"--push",
|
|
262
|
+
project_dir,
|
|
263
|
+
])
|
|
264
|
+
else:
|
|
265
|
+
subprocess.run(["docker", "tag", local_tag, ecr_image], check=True)
|
|
266
|
+
print(_c(f"\n▶ docker push {ecr_image}"))
|
|
267
|
+
push = subprocess.run(["docker", "push", ecr_image])
|
|
268
|
+
|
|
269
|
+
if push.returncode != 0:
|
|
270
|
+
print(_c(" ✗ docker push failed", "31"), file=sys.stderr)
|
|
271
|
+
return push.returncode
|
|
272
|
+
print(_c(f"\n ✓ pushed {ecr_image}", "32"))
|
|
273
|
+
|
|
274
|
+
# 7. Parse predict.py Input fields → params list
|
|
275
|
+
params = _parse_predict_inputs(project_dir, cfg)
|
|
276
|
+
print(f" ✓ parsed {len(params)} input param(s) from {cfg.predict_file}")
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
resolved_category_id = resolve_category_id(
|
|
280
|
+
category_id=cfg.category_id,
|
|
281
|
+
category_code=cfg.category,
|
|
282
|
+
)
|
|
283
|
+
except ValueError as e:
|
|
284
|
+
print(_c(f" ✗ {e}", "31"))
|
|
285
|
+
return 1
|
|
286
|
+
|
|
287
|
+
# 8. Register model in backend DB
|
|
288
|
+
print(" → registering model in Motion platform …")
|
|
289
|
+
register_payload = {
|
|
290
|
+
"model_name": model_display,
|
|
291
|
+
"model_code": model_code,
|
|
292
|
+
"description": cfg.description or "",
|
|
293
|
+
"gpu": compute_profile.get("gpu", cfg.build.gpu),
|
|
294
|
+
"input_type": cfg.input_type,
|
|
295
|
+
"output_type": cfg.output_type,
|
|
296
|
+
"ecr_image": ecr_image,
|
|
297
|
+
"params": params,
|
|
298
|
+
"inference_model_slug": cfg.inference_model_slug,
|
|
299
|
+
"cpu_vcpus": compute_profile.get("cpu_vcpus"),
|
|
300
|
+
"memory_mib": compute_profile.get("memory_mib"),
|
|
301
|
+
"compute_source": compute_profile.get("compute_source"),
|
|
302
|
+
}
|
|
303
|
+
if resolved_category_id is not None:
|
|
304
|
+
register_payload["category_id"] = resolved_category_id
|
|
305
|
+
elif cfg.category:
|
|
306
|
+
register_payload["category_code"] = cfg.category
|
|
307
|
+
if getattr(args, "service_type", None):
|
|
308
|
+
register_payload["service_type"] = args.service_type
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
reg = requests.post(
|
|
312
|
+
f"{_api_url()}/api/v1/motion/models/register",
|
|
313
|
+
json=register_payload,
|
|
314
|
+
headers={"X-API-Key": api_key},
|
|
315
|
+
timeout=30,
|
|
316
|
+
)
|
|
317
|
+
reg.raise_for_status()
|
|
318
|
+
except Exception as e:
|
|
319
|
+
detail = ""
|
|
320
|
+
if hasattr(e, "response") and e.response is not None:
|
|
321
|
+
try:
|
|
322
|
+
detail = e.response.json().get("detail", "")
|
|
323
|
+
except Exception:
|
|
324
|
+
detail = e.response.text[:300]
|
|
325
|
+
msg = f"{e}" + (f" — {detail}" if detail else "")
|
|
326
|
+
print(_c(f" ✗ model registration failed: {msg}", "31"))
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
reg_data = reg.json()
|
|
330
|
+
action = "registered" if reg_data["created"] else "updated"
|
|
331
|
+
print(_c(f"\n✓ model {action}: {model_code} (id={reg_data['model_type_id']})", "32"))
|
|
332
|
+
print(f" ECR image: {reg_data['ecr_image']}")
|
|
333
|
+
print(f" model_code: {reg_data['model_code']}")
|
|
334
|
+
if resolved_category_id is not None:
|
|
335
|
+
print(f" category: id={resolved_category_id}")
|
|
336
|
+
elif cfg.category:
|
|
337
|
+
print(f" category: {cfg.category}")
|
|
338
|
+
if reg_data.get("cpu_vcpus") or reg_data.get("memory_mib"):
|
|
339
|
+
print(
|
|
340
|
+
f" compute: cpu={reg_data.get('cpu_vcpus')} "
|
|
341
|
+
f"mem={reg_data.get('memory_mib')} MiB "
|
|
342
|
+
f"gpu={reg_data.get('gpu')} "
|
|
343
|
+
f"source={reg_data.get('compute_source')}"
|
|
344
|
+
)
|
|
345
|
+
if reg_data.get("service_type"):
|
|
346
|
+
print(f" runs on: {reg_data['service_type']}")
|
|
347
|
+
print(f"\n Submit a job: POST /api/v1/motion/submit model_name={model_code}")
|
|
348
|
+
return 0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _slug_to_title(slug: str) -> str:
|
|
352
|
+
"""Convert 'pose-estimation' → 'Pose Estimation'."""
|
|
353
|
+
return " ".join(w.capitalize() for w in slug.replace("-", " ").replace("_", " ").split())
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _parse_predict_inputs(project_dir: str, cfg: "MotionConfig") -> list:
|
|
357
|
+
"""Parse the predict method signature in predict.py and extract Input() fields.
|
|
358
|
+
|
|
359
|
+
Returns a list of param dicts compatible with RegisterModelRequest.params.
|
|
360
|
+
Each Input() annotation in the predict() method signature becomes one param.
|
|
361
|
+
|
|
362
|
+
Replicate-style:
|
|
363
|
+
def predict(self, video: Path = Input(description="…"), steps: int = Input(default=20)):
|
|
364
|
+
"""
|
|
365
|
+
import ast
|
|
366
|
+
|
|
367
|
+
predict_path = os.path.join(project_dir, cfg.predict_file)
|
|
368
|
+
try:
|
|
369
|
+
with open(predict_path) as f:
|
|
370
|
+
tree = ast.parse(f.read())
|
|
371
|
+
except Exception:
|
|
372
|
+
return []
|
|
373
|
+
|
|
374
|
+
params = []
|
|
375
|
+
for node in ast.walk(tree):
|
|
376
|
+
if not isinstance(node, ast.ClassDef):
|
|
377
|
+
continue
|
|
378
|
+
if node.name != cfg.predict_class:
|
|
379
|
+
continue
|
|
380
|
+
for item in node.body:
|
|
381
|
+
if not isinstance(item, ast.FunctionDef) or item.name != "predict":
|
|
382
|
+
continue
|
|
383
|
+
for i, arg in enumerate(item.args.args):
|
|
384
|
+
if arg.arg == "self":
|
|
385
|
+
continue
|
|
386
|
+
# Determine type annotation
|
|
387
|
+
annotation = item.args.annotations.get(arg.arg) if hasattr(item.args, "annotations") else None
|
|
388
|
+
type_name = ""
|
|
389
|
+
if arg.annotation:
|
|
390
|
+
if isinstance(arg.annotation, ast.Name):
|
|
391
|
+
type_name = arg.annotation.id
|
|
392
|
+
elif isinstance(arg.annotation, ast.Attribute):
|
|
393
|
+
type_name = arg.annotation.attr
|
|
394
|
+
|
|
395
|
+
# Map Python type to field_type
|
|
396
|
+
field_type = _python_type_to_field(type_name)
|
|
397
|
+
|
|
398
|
+
# Parse Input() default for description / default_value / constraints
|
|
399
|
+
default_info = _extract_input_default(item, arg.arg)
|
|
400
|
+
|
|
401
|
+
params.append({
|
|
402
|
+
"param_key": arg.arg,
|
|
403
|
+
"label": arg.arg.replace("_", " ").capitalize(),
|
|
404
|
+
"field_type": field_type,
|
|
405
|
+
"default_value": default_info.get("default"),
|
|
406
|
+
"description": default_info.get("description"),
|
|
407
|
+
"min_value": default_info.get("ge") or default_info.get("min_value"),
|
|
408
|
+
"max_value": default_info.get("le") or default_info.get("max_value"),
|
|
409
|
+
"step": default_info.get("step"),
|
|
410
|
+
"is_required": default_info.get("default") is None,
|
|
411
|
+
"display_order": i,
|
|
412
|
+
})
|
|
413
|
+
return params
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _python_type_to_field(type_name: str) -> str:
|
|
417
|
+
return {
|
|
418
|
+
"int": "number",
|
|
419
|
+
"float": "range",
|
|
420
|
+
"str": "text",
|
|
421
|
+
"bool": "toggle",
|
|
422
|
+
"Path": "text",
|
|
423
|
+
"": "text",
|
|
424
|
+
}.get(type_name, "text")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _extract_input_default(func_node, arg_name: str) -> dict:
|
|
428
|
+
"""Walk defaults in the function AST to find Input(description=…, default=…, ge=…, le=…)."""
|
|
429
|
+
import ast
|
|
430
|
+
|
|
431
|
+
args = func_node.args.args
|
|
432
|
+
# Align defaults (they're right-aligned in args.defaults)
|
|
433
|
+
offset = len(args) - len(func_node.args.defaults)
|
|
434
|
+
for idx, a in enumerate(args):
|
|
435
|
+
if a.arg != arg_name:
|
|
436
|
+
continue
|
|
437
|
+
def_idx = idx - offset
|
|
438
|
+
if def_idx < 0 or def_idx >= len(func_node.args.defaults):
|
|
439
|
+
break
|
|
440
|
+
default_node = func_node.args.defaults[def_idx]
|
|
441
|
+
if not isinstance(default_node, ast.Call):
|
|
442
|
+
break
|
|
443
|
+
info = {}
|
|
444
|
+
for kw in default_node.keywords:
|
|
445
|
+
val = kw.value
|
|
446
|
+
if isinstance(val, ast.Constant):
|
|
447
|
+
info[kw.arg] = val.value
|
|
448
|
+
elif isinstance(val, ast.UnaryOp) and isinstance(val.op, ast.USub):
|
|
449
|
+
if isinstance(val.operand, ast.Constant):
|
|
450
|
+
info[kw.arg] = -val.operand.value
|
|
451
|
+
# Normalise key names
|
|
452
|
+
if "default" in info:
|
|
453
|
+
info["default"] = str(info["default"])
|
|
454
|
+
return info
|
|
455
|
+
return {}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def main(argv=None):
|
|
459
|
+
p = argparse.ArgumentParser(prog="motion", description="Build & deploy models for Motion inference")
|
|
460
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
461
|
+
|
|
462
|
+
b = sub.add_parser("build", help="upload weights to S3 + build model Docker image")
|
|
463
|
+
b.add_argument("path", nargs="?", default=".", help="path to model folder (has motion.yml)")
|
|
464
|
+
b.add_argument("--tag", help="image tag (default motion-<name>:latest)")
|
|
465
|
+
b.add_argument("--dry-run", action="store_true", help="print the Dockerfile, skip upload and build")
|
|
466
|
+
b.set_defaults(func=cmd_build)
|
|
467
|
+
|
|
468
|
+
d = sub.add_parser(
|
|
469
|
+
"deploy",
|
|
470
|
+
help="profile compute, push image to ECR, register model in platform",
|
|
471
|
+
)
|
|
472
|
+
d.add_argument("path", nargs="?", default=".")
|
|
473
|
+
d.add_argument("--tag", help="local image tag to push (default motion-<name>:latest)")
|
|
474
|
+
d.add_argument(
|
|
475
|
+
"--no-profile",
|
|
476
|
+
action="store_true",
|
|
477
|
+
help="skip docker stats profiling; only send gpu flag from motion.yml",
|
|
478
|
+
)
|
|
479
|
+
d.add_argument(
|
|
480
|
+
"--profile-timeout",
|
|
481
|
+
type=int,
|
|
482
|
+
default=120,
|
|
483
|
+
help="max seconds to run the container during profiling (default 120)",
|
|
484
|
+
)
|
|
485
|
+
d.add_argument(
|
|
486
|
+
"--sample",
|
|
487
|
+
help="sample input file for profiling (overrides motion.yml `sample:`)",
|
|
488
|
+
)
|
|
489
|
+
d.add_argument(
|
|
490
|
+
"--service-type",
|
|
491
|
+
choices=["batch", "inference_instance", "dedicated_instance"],
|
|
492
|
+
help="how this model's jobs run: batch (default), inference_instance "
|
|
493
|
+
"(warm host), or dedicated_instance (disabled for now). "
|
|
494
|
+
"Omit to keep the default/existing value.",
|
|
495
|
+
)
|
|
496
|
+
d.set_defaults(func=cmd_deploy)
|
|
497
|
+
|
|
498
|
+
args = p.parse_args(argv)
|
|
499
|
+
return args.func(args)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
if __name__ == "__main__":
|
|
503
|
+
sys.exit(main())
|
motion/config.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Parse and validate motion.yml into a typed config object."""
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import yaml
|
|
8
|
+
except ImportError: # pragma: no cover
|
|
9
|
+
raise SystemExit("PyYAML is required: pip install pyyaml")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class BuildSpec:
|
|
14
|
+
gpu: bool = False
|
|
15
|
+
cuda: str = "11.8"
|
|
16
|
+
python_version: str = "3.10"
|
|
17
|
+
system_packages: List[str] = field(default_factory=list)
|
|
18
|
+
python_packages: List[str] = field(default_factory=list)
|
|
19
|
+
# extra `pip install -f <url>` wheel indexes (e.g. OpenMMLab CPU wheels)
|
|
20
|
+
python_find_links: List[str] = field(default_factory=list)
|
|
21
|
+
# replace the default PyPI index (e.g. https://download.pytorch.org/whl/cpu)
|
|
22
|
+
python_index_url: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ComputeSpec:
|
|
27
|
+
cpu: Optional[int] = None
|
|
28
|
+
memory_mib: Optional[int] = None
|
|
29
|
+
gpu: Optional[bool] = None
|
|
30
|
+
|
|
31
|
+
def as_dict(self) -> dict:
|
|
32
|
+
out = {}
|
|
33
|
+
if self.cpu is not None:
|
|
34
|
+
out["cpu"] = self.cpu
|
|
35
|
+
if self.memory_mib is not None:
|
|
36
|
+
out["memory_mib"] = self.memory_mib
|
|
37
|
+
if self.gpu is not None:
|
|
38
|
+
out["gpu"] = self.gpu
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class MotionConfig:
|
|
44
|
+
name: str
|
|
45
|
+
predict: str # "predict.py:ClassName"
|
|
46
|
+
description: str = ""
|
|
47
|
+
category: str = "" # category_code (mapped to id locally)
|
|
48
|
+
category_id: Optional[int] = None # explicit id; wins over category code
|
|
49
|
+
inference_model: str = "" # inference_model slug; defaults to `name`
|
|
50
|
+
sample: str = "" # sample input used by deploy profiling
|
|
51
|
+
input_type: str = "video" # video | image | text
|
|
52
|
+
output_type: str = "mp4" # mp4 | glb | ...
|
|
53
|
+
build: BuildSpec = field(default_factory=BuildSpec)
|
|
54
|
+
compute: Optional[ComputeSpec] = None
|
|
55
|
+
# large weights pulled at startup (NOT baked into the image). Each item:
|
|
56
|
+
# {"url": "s3://... or https://...", "dest": "relative/path/in/src"}
|
|
57
|
+
weights: List[dict] = field(default_factory=list)
|
|
58
|
+
project_dir: str = "." # absolute dir containing motion.yml
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def predict_file(self) -> str:
|
|
62
|
+
return self.predict.split(":", 1)[0]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def predict_class(self) -> str:
|
|
66
|
+
return self.predict.split(":", 1)[1]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def image_tag(self) -> str:
|
|
70
|
+
return f"motion-{self.name}:latest"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def inference_model_slug(self) -> str:
|
|
74
|
+
return self.inference_model or self.name
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def load(cls, project_dir: str) -> "MotionConfig":
|
|
78
|
+
path = os.path.join(project_dir, "motion.yml")
|
|
79
|
+
if not os.path.exists(path):
|
|
80
|
+
raise FileNotFoundError(f"No motion.yml found in {project_dir}")
|
|
81
|
+
with open(path) as f:
|
|
82
|
+
raw = yaml.safe_load(f) or {}
|
|
83
|
+
|
|
84
|
+
errors = []
|
|
85
|
+
if not raw.get("name"):
|
|
86
|
+
errors.append("`name` is required")
|
|
87
|
+
predict = raw.get("predict")
|
|
88
|
+
if not predict or ":" not in str(predict):
|
|
89
|
+
errors.append("`predict` is required and must be 'file.py:ClassName'")
|
|
90
|
+
weights = []
|
|
91
|
+
for w in (raw.get("weights", []) or []):
|
|
92
|
+
if not w.get("url") or not w.get("dest"):
|
|
93
|
+
errors.append("each `weights` entry needs both `url` and `dest`")
|
|
94
|
+
else:
|
|
95
|
+
# `path` = local source to upload at build time (defaults to dest)
|
|
96
|
+
weights.append({
|
|
97
|
+
"url": str(w["url"]),
|
|
98
|
+
"dest": str(w["dest"]),
|
|
99
|
+
"path": str(w.get("path", w["dest"])),
|
|
100
|
+
})
|
|
101
|
+
if errors:
|
|
102
|
+
raise ValueError("Invalid motion.yml:\n - " + "\n - ".join(errors))
|
|
103
|
+
|
|
104
|
+
b = raw.get("build", {}) or {}
|
|
105
|
+
build = BuildSpec(
|
|
106
|
+
gpu=bool(b.get("gpu", False)),
|
|
107
|
+
cuda=str(b.get("cuda", "11.8")),
|
|
108
|
+
python_version=str(b.get("python_version", "3.10")),
|
|
109
|
+
system_packages=list(b.get("system_packages", []) or []),
|
|
110
|
+
python_packages=list(b.get("python_packages", []) or []),
|
|
111
|
+
python_find_links=list(b.get("python_find_links", []) or []),
|
|
112
|
+
python_index_url=str(b.get("python_index_url", "") or ""),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
compute = None
|
|
116
|
+
c = raw.get("compute")
|
|
117
|
+
if isinstance(c, dict) and c:
|
|
118
|
+
cpu = c.get("cpu") if c.get("cpu") is not None else c.get("cpu_vcpus")
|
|
119
|
+
mem = c.get("memory_mib") if c.get("memory_mib") is not None else c.get("memory")
|
|
120
|
+
gpu = c.get("gpu")
|
|
121
|
+
compute = ComputeSpec(
|
|
122
|
+
cpu=int(cpu) if cpu is not None else None,
|
|
123
|
+
memory_mib=int(mem) if mem is not None else None,
|
|
124
|
+
gpu=bool(gpu) if gpu is not None else None,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return cls(
|
|
128
|
+
name=raw["name"],
|
|
129
|
+
predict=predict,
|
|
130
|
+
description=raw.get("description", ""),
|
|
131
|
+
category=str(raw.get("category", "") or "").strip(),
|
|
132
|
+
category_id=int(raw["category_id"]) if raw.get("category_id") is not None else None,
|
|
133
|
+
inference_model=str(raw.get("inference_model", "") or "").strip(),
|
|
134
|
+
sample=str(raw.get("sample", "") or "").strip(),
|
|
135
|
+
input_type=str(raw.get("input_type", "video") or "video"),
|
|
136
|
+
output_type=str(raw.get("output_type", "mp4") or "mp4"),
|
|
137
|
+
build=build,
|
|
138
|
+
compute=compute,
|
|
139
|
+
weights=weights,
|
|
140
|
+
project_dir=os.path.abspath(project_dir),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def validate_files(self) -> None:
|
|
144
|
+
pf = os.path.join(self.project_dir, self.predict_file)
|
|
145
|
+
if not os.path.exists(pf):
|
|
146
|
+
raise FileNotFoundError(
|
|
147
|
+
f"predict file '{self.predict_file}' not found in {self.project_dir}"
|
|
148
|
+
)
|