sima-vision 0.0.2__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.
- sima_vision/__init__.py +34 -0
- sima_vision/api.py +210 -0
- sima_vision/assets.py +340 -0
- sima_vision/cli.py +710 -0
- sima_vision/config.py +882 -0
- sima_vision/configs/detect.yaml +206 -0
- sima_vision/configs/fall.yaml +293 -0
- sima_vision/configs/segment.yaml +310 -0
- sima_vision/data/coco_labels.txt +80 -0
- sima_vision/devkit.py +188 -0
- sima_vision/draw.py +286 -0
- sima_vision/masks.py +596 -0
- sima_vision/media.py +658 -0
- sima_vision/neat.py +321 -0
- sima_vision/runloop.py +410 -0
- sima_vision/runtime.py +272 -0
- sima_vision/samples.py +323 -0
- sima_vision/scene.py +166 -0
- sima_vision/setup_commands.py +69 -0
- sima_vision/sinks.py +384 -0
- sima_vision/tasks/__init__.py +23 -0
- sima_vision/tasks/base.py +251 -0
- sima_vision/tasks/detect.py +70 -0
- sima_vision/tasks/fall.py +1160 -0
- sima_vision/tasks/segment.py +756 -0
- sima_vision-0.0.2.dist-info/METADATA +852 -0
- sima_vision-0.0.2.dist-info/RECORD +30 -0
- sima_vision-0.0.2.dist-info/WHEEL +4 -0
- sima_vision-0.0.2.dist-info/entry_points.txt +2 -0
- sima_vision-0.0.2.dist-info/licenses/LICENSE +201 -0
sima_vision/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Live YOLO computer vision on a SiMa Modalix DevKit 3.0.
|
|
2
|
+
|
|
3
|
+
Three applications share one pipeline: object detection, instance segmentation
|
|
4
|
+
with an optional background blur, and fall detection with SMTP alerts. They
|
|
5
|
+
differ only in what they do with a frame once the MLA has finished with it, so
|
|
6
|
+
everything up to that point -- config loading, source geometry, the Neat graph,
|
|
7
|
+
sample decoding, drawing and the sinks -- lives in this package and is written
|
|
8
|
+
once.
|
|
9
|
+
|
|
10
|
+
Run them from the command line::
|
|
11
|
+
|
|
12
|
+
sima-vision detect --source clip.h264 --model yolo26m-det.tar.gz
|
|
13
|
+
sima-vision segment --source clip.h264 --model yolo26m-seg.tar.gz --blur
|
|
14
|
+
sima-vision fall --source rtsp://camera/live --alert-to ops@example.com
|
|
15
|
+
|
|
16
|
+
or from Python, with the same settings under the same names::
|
|
17
|
+
|
|
18
|
+
from sima_vision import run, preview, validate
|
|
19
|
+
|
|
20
|
+
preview("segment", out="blur.png", blur_strength=81) # no board needed
|
|
21
|
+
run("detect", source="clip.h264", model="yolo26m-det.tar.gz", conf=0.5)
|
|
22
|
+
|
|
23
|
+
Everything runs **on the DevKit**, not in the x86 SDK container: ``pyneat`` is
|
|
24
|
+
compiled for aarch64. The imports that need it are deferred, so ``--validate``
|
|
25
|
+
and ``--help`` work anywhere.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
__version__ = "0.0.2"
|
|
31
|
+
|
|
32
|
+
from .api import load, preview, run, validate
|
|
33
|
+
|
|
34
|
+
__all__ = ["__version__", "load", "preview", "run", "validate"]
|
sima_vision/api.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""The Python API: the same three verbs the command line has.
|
|
2
|
+
|
|
3
|
+
Every keyword these accept is derived from the CLI's own flags, so
|
|
4
|
+
``--blur-strength 81`` and ``blur_strength=81`` cannot drift apart -- there is
|
|
5
|
+
one table and it is built from the parser at import time.
|
|
6
|
+
|
|
7
|
+
from sima_vision import run, preview, validate
|
|
8
|
+
|
|
9
|
+
preview("segment", out="blur.png", blur_strength=81)
|
|
10
|
+
validate("detect", conf=0.5)
|
|
11
|
+
run("detect", source="clip.h264", model="yolo26m-det.tar.gz", conf=0.5)
|
|
12
|
+
|
|
13
|
+
``preview`` and ``validate`` need no board. ``run`` needs the DevKit, because
|
|
14
|
+
that is where the MLA is.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
from functools import cache
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .tasks import TASKS
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _task(name: str):
|
|
27
|
+
if name not in TASKS:
|
|
28
|
+
raise ValueError(f"unknown task {name!r}. Choose one of: {', '.join(TASKS)}")
|
|
29
|
+
return TASKS[name]()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _alias_table(task) -> tuple[dict[str, str], set[str]]:
|
|
33
|
+
"""Cached by task class -- the flags cannot change between calls."""
|
|
34
|
+
return _build_alias_table(type(task))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@cache
|
|
38
|
+
def _build_alias_table(task_cls) -> tuple[dict[str, str], set[str]]:
|
|
39
|
+
"""Map Python keyword -> config path, straight off this task's CLI flags.
|
|
40
|
+
|
|
41
|
+
Returns the table and the set of keywords whose boolean has to be flipped:
|
|
42
|
+
``--send`` turns ``alerts.dry_run`` *off*, so ``send=True`` must write
|
|
43
|
+
False. ``--no-save`` is registered as ``save`` for the same reason, in the
|
|
44
|
+
other direction -- nobody wants to write ``no_save=True``.
|
|
45
|
+
"""
|
|
46
|
+
from .cli import add_shared_arguments
|
|
47
|
+
|
|
48
|
+
task = task_cls()
|
|
49
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
50
|
+
add_shared_arguments(parser)
|
|
51
|
+
task.add_arguments(parser.add_argument_group("task"))
|
|
52
|
+
|
|
53
|
+
aliases: dict[str, str] = {}
|
|
54
|
+
inverted: set[str] = set()
|
|
55
|
+
for action in parser._actions:
|
|
56
|
+
if "." not in action.dest:
|
|
57
|
+
continue
|
|
58
|
+
turns_off = getattr(action, "const", None) is False
|
|
59
|
+
for option in action.option_strings:
|
|
60
|
+
negative = option.startswith("--no-")
|
|
61
|
+
name = option.lstrip("-").replace("-", "_")
|
|
62
|
+
if negative:
|
|
63
|
+
name = name[3:] # no_save -> save
|
|
64
|
+
# Two flags claiming one keyword would silently hide a setting --
|
|
65
|
+
# `--video PATH` and `--no-video` both wanted `video` once, and the
|
|
66
|
+
# path became unreachable from Python.
|
|
67
|
+
claimed = aliases.get(name)
|
|
68
|
+
if claimed is not None and claimed != action.dest:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
f"{task.name}: {option} wants the keyword {name!r}, which "
|
|
71
|
+
f"already means {claimed!r}. Rename one of the flags."
|
|
72
|
+
)
|
|
73
|
+
aliases[name] = action.dest
|
|
74
|
+
# `--send` reads as "do send", but it clears a dry_run flag.
|
|
75
|
+
if turns_off and not negative:
|
|
76
|
+
inverted.add(name)
|
|
77
|
+
return aliases, inverted
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def settings_to_overrides(task, settings: dict) -> dict:
|
|
81
|
+
"""Translate Python keywords into the dotted config paths the loader takes.
|
|
82
|
+
|
|
83
|
+
Dotted keys are passed through untouched, so anything the aliases do not
|
|
84
|
+
cover is still reachable::
|
|
85
|
+
|
|
86
|
+
run("detect", **{"runtime.output_buffers": 2})
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
TypeError: On a keyword that is neither an alias nor a dotted path,
|
|
90
|
+
listing the near misses.
|
|
91
|
+
"""
|
|
92
|
+
aliases, inverted = _alias_table(task)
|
|
93
|
+
overrides: dict = {}
|
|
94
|
+
for key, value in settings.items():
|
|
95
|
+
if "." in key:
|
|
96
|
+
overrides[key] = value
|
|
97
|
+
continue
|
|
98
|
+
path = aliases.get(key)
|
|
99
|
+
if path is None:
|
|
100
|
+
near = sorted(name for name in aliases if key in name or name in key)
|
|
101
|
+
hint = f" Did you mean: {', '.join(near[:5])}?" if near else ""
|
|
102
|
+
raise TypeError(f"{task.name}() got an unexpected setting {key!r}.{hint}")
|
|
103
|
+
overrides[path] = not value if key in inverted else value
|
|
104
|
+
return overrides
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load(task: str, config: str | Path | None = None, use_config_file: bool = True,
|
|
108
|
+
**settings):
|
|
109
|
+
"""Resolve a configuration the way the CLI does, and validate it.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
task: ``detect``, ``segment`` or ``fall``.
|
|
113
|
+
config: Path to a config file, or None to look for ``./config.yaml``.
|
|
114
|
+
use_config_file: False ignores any file, like ``--no-config``.
|
|
115
|
+
**settings: Anything the CLI takes, as a keyword.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
A validated config for the task.
|
|
119
|
+
"""
|
|
120
|
+
handle = _task(task)
|
|
121
|
+
return handle.load(
|
|
122
|
+
Path(config) if config else None,
|
|
123
|
+
settings_to_overrides(handle, settings),
|
|
124
|
+
use_file=use_config_file,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def validate(task: str, config: str | Path | None = None, **settings):
|
|
129
|
+
"""Check a configuration without a board. Raises ValueError if it is wrong.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The resolved config, so it can be inspected.
|
|
133
|
+
"""
|
|
134
|
+
return load(task, config, **settings)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run(task: str, config: str | Path | None = None, **settings) -> int:
|
|
138
|
+
"""Run one task to completion. **Needs the DevKit.**
|
|
139
|
+
|
|
140
|
+
A clip or model archive that is missing is downloaded into ``assets/``
|
|
141
|
+
first; see :mod:`sima_vision.assets`. ``validate`` and ``preview`` resolve
|
|
142
|
+
the same paths and never fetch anything.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
task: ``detect``, ``segment`` or ``fall``.
|
|
146
|
+
config: Path to a config file, or None to look for ``./config.yaml``.
|
|
147
|
+
**settings: Anything the CLI takes, as a keyword.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The number of frames processed.
|
|
151
|
+
"""
|
|
152
|
+
import os
|
|
153
|
+
|
|
154
|
+
from .runloop import Stopper
|
|
155
|
+
from .runtime import load_runtime_dependencies
|
|
156
|
+
|
|
157
|
+
handle = _task(task)
|
|
158
|
+
cfg = load(task, config, **settings)
|
|
159
|
+
load_runtime_dependencies()
|
|
160
|
+
# The same two side effects the command line has, so `run(...)` and
|
|
161
|
+
# `sima-vision <task>` really are the same run.
|
|
162
|
+
if cfg.profile:
|
|
163
|
+
os.environ.setdefault("SIMA_GST_ELEMENT_TIMINGS", "1")
|
|
164
|
+
os.environ.setdefault("SIMA_GST_FLOW_DEBUG", "1")
|
|
165
|
+
if cfg.save_enable:
|
|
166
|
+
Path(cfg.save_dir).mkdir(parents=True, exist_ok=True)
|
|
167
|
+
return handle.run(cfg, Stopper())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def preview(task: str = "detect", config: str | Path | None = None,
|
|
171
|
+
out: str | Path = "preview.png", source: str | None = None,
|
|
172
|
+
size: tuple[int, int] = (1280, 720), use_config_file: bool = True,
|
|
173
|
+
**settings) -> Path:
|
|
174
|
+
"""Draw the overlay a config produces, and write it to a PNG. **No board.**
|
|
175
|
+
|
|
176
|
+
Runs no model: the detections are synthetic and exist only to give the
|
|
177
|
+
drawing code something to draw.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
task: ``detect``, ``segment`` or ``fall``.
|
|
181
|
+
config: Path to a config file, or None to look for ``./config.yaml``.
|
|
182
|
+
out: Where to write the PNG.
|
|
183
|
+
source: An image or video to draw on. None paints a synthetic scene.
|
|
184
|
+
size: Synthetic scene size, ignored when ``source`` is readable.
|
|
185
|
+
use_config_file: False ignores any file, like ``--no-config``.
|
|
186
|
+
**settings: Anything the CLI takes, as a keyword.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The path written.
|
|
190
|
+
"""
|
|
191
|
+
from . import runtime
|
|
192
|
+
from .cli import load_drawing_dependencies
|
|
193
|
+
from .scene import build_frame, render
|
|
194
|
+
from .sinks import load_labels
|
|
195
|
+
|
|
196
|
+
load_drawing_dependencies()
|
|
197
|
+
handle = _task(task)
|
|
198
|
+
config_path = Path(config) if config else None
|
|
199
|
+
cfg = handle.load(
|
|
200
|
+
config_path, settings_to_overrides(handle, settings), use_file=use_config_file
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
frame, subjects, _origin, _ = build_frame(source, size)
|
|
204
|
+
annotated = render(handle, cfg, frame, subjects, load_labels(cfg.labels_path))
|
|
205
|
+
out = Path(out)
|
|
206
|
+
if out.parent != Path("."):
|
|
207
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
if not runtime.cv2.imwrite(str(out), annotated):
|
|
209
|
+
raise RuntimeError(f"could not write {out}")
|
|
210
|
+
return out
|
sima_vision/assets.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Where the sample clips and model archives live, and how they get there.
|
|
2
|
+
|
|
3
|
+
There is one ``assets/`` directory for the whole project rather than one inside
|
|
4
|
+
each task folder. A clip is a clip: the same 13 MB of people walking through a
|
|
5
|
+
mall feeds ``detect``, ``segment`` and ``fall``, and the detect archive is
|
|
6
|
+
shared by ``detect`` and ``fall`` outright. Three copies of it said nothing that
|
|
7
|
+
one copy does not.
|
|
8
|
+
|
|
9
|
+
``--source`` and ``--model`` therefore take one of three things:
|
|
10
|
+
|
|
11
|
+
1. a local path -- used as given
|
|
12
|
+
2. an ``http(s)`` URL -- downloaded into ``assets/`` once, then reused
|
|
13
|
+
3. nothing at all -- the task's default, downloaded on first run
|
|
14
|
+
|
|
15
|
+
Case 3 is what makes ``sima-vision detect`` work on its own. The clips are on a
|
|
16
|
+
public GitHub release so they are simply fetched. The model packs are behind a
|
|
17
|
+
`community.sima.ai <https://community.sima.ai>`_ login -- the download URL
|
|
18
|
+
answers a plain GET with a 302 to ``auth.sima.ai`` -- so those go through
|
|
19
|
+
``sima-cli``, which already holds that login, and fall back to printing the
|
|
20
|
+
command when it is not installed.
|
|
21
|
+
|
|
22
|
+
Nothing here runs at config time. ``--validate`` and ``preview`` resolve the
|
|
23
|
+
same paths and never touch the network; only :meth:`Task.run
|
|
24
|
+
<sima_vision.tasks.base.Task.run>` calls :func:`ensure_assets`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import os
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.request
|
|
36
|
+
from dataclasses import dataclass, replace
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
#: Overrides where downloads land. Default ``./assets`` in the working directory.
|
|
40
|
+
ASSETS_ENV = "SIMA_VISION_ASSETS"
|
|
41
|
+
|
|
42
|
+
#: Sample clips, already raw H.264 so they skip the Neat 0.3.0 demuxer bug.
|
|
43
|
+
SAMPLE_RELEASE = "https://github.com/RizwanMunawar/sima-projects/releases/download/0.0.1"
|
|
44
|
+
SAMPLE_VIDEOS = {
|
|
45
|
+
"people-walking-outside-mall.h264": "1920x1080 @ 24 fps, 13 MB. The usual default",
|
|
46
|
+
"people-walking-inside-mall.h264": "1920x1080 @ 30 fps, 1.2 MB. Quicker smoke test",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#: Where the SDK publishes compiled model packs.
|
|
50
|
+
MODEL_BASE = "https://docs.sima.ai/pkg_downloads/SDK2.1.2/models/modalix"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class TaskAssets:
|
|
55
|
+
"""What one task runs on when it is given nothing.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
model_dir: Model pack directory under :data:`MODEL_BASE`.
|
|
59
|
+
model_file: Archive name, which is also its name inside ``assets/models``.
|
|
60
|
+
clip: Sample clip name, a key of :data:`SAMPLE_VIDEOS`.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
model_dir: str
|
|
64
|
+
model_file: str
|
|
65
|
+
clip: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
#: Task name -> its default model and clip. ``detect`` and ``fall`` share a head.
|
|
69
|
+
CATALOGUE: dict[str, TaskAssets] = {
|
|
70
|
+
"detect": TaskAssets(
|
|
71
|
+
"yolo26-detection",
|
|
72
|
+
"yolo26m-det-bf16-mla_tess-b1.tar.gz",
|
|
73
|
+
"people-walking-outside-mall.h264",
|
|
74
|
+
),
|
|
75
|
+
"segment": TaskAssets(
|
|
76
|
+
"yolo26-segmentation",
|
|
77
|
+
"yolo26m-seg-bf16-mla_tess-b1.tar.gz",
|
|
78
|
+
"people-walking-outside-mall.h264",
|
|
79
|
+
),
|
|
80
|
+
"fall": TaskAssets(
|
|
81
|
+
"yolo26-detection",
|
|
82
|
+
"yolo26m-det-bf16-mla_tess-b1.tar.gz",
|
|
83
|
+
"people-walking-inside-mall.h264",
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
# Locations
|
|
90
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def assets_root() -> Path:
|
|
94
|
+
"""The ``assets/`` directory, honouring ``$SIMA_VISION_ASSETS``.
|
|
95
|
+
|
|
96
|
+
Read on every call rather than cached at import, so setting the variable
|
|
97
|
+
from a test -- or between two calls in one process -- takes effect.
|
|
98
|
+
"""
|
|
99
|
+
return Path(os.environ.get(ASSETS_ENV) or "assets")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def videos_dir() -> Path:
|
|
103
|
+
return assets_root() / "videos"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def models_dir() -> Path:
|
|
107
|
+
return assets_root() / "models"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def default_model_path(task: str) -> str:
|
|
111
|
+
"""Where this task's model archive is expected, as a string for the config."""
|
|
112
|
+
return (models_dir() / CATALOGUE[task].model_file).as_posix()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def default_source_uri(task: str) -> str:
|
|
116
|
+
"""Where this task's sample clip is expected, as a string for the config."""
|
|
117
|
+
return (videos_dir() / CATALOGUE[task].clip).as_posix()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def model_url(task: str) -> str:
|
|
121
|
+
entry = CATALOGUE[task]
|
|
122
|
+
return f"{MODEL_BASE}/{entry.model_dir}/{entry.model_file}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def model_command(task: str, into: Path | None = None) -> str:
|
|
126
|
+
"""The one line that downloads the right model pack for a task.
|
|
127
|
+
|
|
128
|
+
``sima-cli download`` needs a community.sima.ai login and writes into the
|
|
129
|
+
working directory, which is why this exists as a printable string as well as
|
|
130
|
+
something :func:`ensure_model` runs: getting the directory wrong is the
|
|
131
|
+
single most common way to end up with a pack the config cannot see.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
task: Which pack to name.
|
|
135
|
+
into: The assets directory to write into, for ``fetch --into``. None
|
|
136
|
+
uses :func:`assets_root`, which is where a run looks.
|
|
137
|
+
"""
|
|
138
|
+
models = ((into / "models") if into is not None else models_dir()).as_posix()
|
|
139
|
+
# A subshell rather than `cd there && ... && cd back`: the working directory
|
|
140
|
+
# you started in is where the rest of the commands expect to be, and one
|
|
141
|
+
# failed step in the middle of that chain would strand you in assets/models.
|
|
142
|
+
return f"mkdir -p {models} && (cd {models} && sima-cli download {model_url(task)})"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_url(value: str) -> bool:
|
|
146
|
+
"""True for something to download. ``rtsp://`` is a stream, not a file."""
|
|
147
|
+
return str(value).startswith(("http://", "https://"))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
# Downloading
|
|
152
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def download(url: str, out: Path) -> bool:
|
|
156
|
+
"""Fetch one file, reporting progress. Returns False on any HTTP failure."""
|
|
157
|
+
if out.exists():
|
|
158
|
+
print(f" have {out} ({out.stat().st_size / 1e6:.1f} MB)")
|
|
159
|
+
return True
|
|
160
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
part = out.with_suffix(out.suffix + ".part")
|
|
162
|
+
# A carriage-return progress line is only readable on a terminal. Piped to a
|
|
163
|
+
# file or a CI log it just repeats the whole line hundreds of times.
|
|
164
|
+
live = sys.stdout.isatty()
|
|
165
|
+
try:
|
|
166
|
+
with urllib.request.urlopen(url, timeout=60) as response: # noqa: S310
|
|
167
|
+
total = int(response.headers.get("Content-Length") or 0)
|
|
168
|
+
done = 0
|
|
169
|
+
with part.open("wb") as handle:
|
|
170
|
+
while chunk := response.read(1 << 16):
|
|
171
|
+
handle.write(chunk)
|
|
172
|
+
done += len(chunk)
|
|
173
|
+
if live and total:
|
|
174
|
+
print(
|
|
175
|
+
f"\r ... {out.name} {done / 1e6:5.1f} / {total / 1e6:.1f} MB",
|
|
176
|
+
end="", flush=True,
|
|
177
|
+
)
|
|
178
|
+
# A server that closes early, or a proxy that truncates, ends the read
|
|
179
|
+
# loop exactly like a finished transfer does. Without this the partial
|
|
180
|
+
# file is renamed into place and every later run reuses it, because the
|
|
181
|
+
# first thing this function does is trust a file that already exists.
|
|
182
|
+
if total and done != total:
|
|
183
|
+
part.unlink(missing_ok=True)
|
|
184
|
+
print(
|
|
185
|
+
f"\r FAIL {out.name}: got {done} of {total} bytes, "
|
|
186
|
+
f"the transfer was cut short",
|
|
187
|
+
file=sys.stderr,
|
|
188
|
+
)
|
|
189
|
+
return False
|
|
190
|
+
print(f"{chr(13) if live else ''} got {out} ({done / 1e6:.1f} MB) ")
|
|
191
|
+
part.replace(out)
|
|
192
|
+
return True
|
|
193
|
+
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
194
|
+
part.unlink(missing_ok=True)
|
|
195
|
+
print(f"\r FAIL {out.name}: {exc}", file=sys.stderr)
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cache_name(url: str) -> str:
|
|
200
|
+
"""A local filename for a URL that cannot collide with another URL's.
|
|
201
|
+
|
|
202
|
+
``--source https://a.example/clip.h264`` and the same name on another host
|
|
203
|
+
are different videos. Keying the cache on the last path segment alone meant
|
|
204
|
+
the second one silently ran the first one's footage, and the download was
|
|
205
|
+
skipped because the file was already there.
|
|
206
|
+
|
|
207
|
+
The digest goes before the extension rather than after, so the suffix still
|
|
208
|
+
says what the file is and `.tar.gz` survives intact::
|
|
209
|
+
|
|
210
|
+
clip.h264 -> clip-1a2b3c4d.h264
|
|
211
|
+
yolo26m-det.tar.gz -> yolo26m-det-1a2b3c4d.tar.gz
|
|
212
|
+
"""
|
|
213
|
+
digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:8]
|
|
214
|
+
name = Path(url.split("?")[0]).name or "download"
|
|
215
|
+
head, dot, tail = name.partition(".")
|
|
216
|
+
return f"{head}-{digest}{dot}{tail}"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def fetch(url: str, out: Path, what: str) -> Path:
|
|
220
|
+
"""Download to ``out``, or raise. The insisting version of :func:`download`."""
|
|
221
|
+
if not download(url, out):
|
|
222
|
+
raise RuntimeError(
|
|
223
|
+
f"could not download the {what} from {url}\n"
|
|
224
|
+
f" wanted: {out}\n"
|
|
225
|
+
"Fetch it by hand and pass the local path instead."
|
|
226
|
+
)
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
231
|
+
# Resolution
|
|
232
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def ensure_source(uri: str, source_type: str = "video") -> str:
|
|
236
|
+
"""Make ``source.uri`` name a file that exists, downloading if it has to.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
uri: The resolved ``source.uri``: a path, an ``http(s)`` URL, or one of
|
|
240
|
+
the sample clip paths the defaults fill in.
|
|
241
|
+
source_type: Only ``video`` reads a file. An RTSP URL or a camera is
|
|
242
|
+
handed back untouched.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
A local path, or ``uri`` unchanged when there is nothing to fetch. A
|
|
246
|
+
path that is simply missing is *also* handed back unchanged, so the
|
|
247
|
+
error comes from :func:`sima_vision.media.check_source_file`, which
|
|
248
|
+
knows how to describe it.
|
|
249
|
+
"""
|
|
250
|
+
if source_type != "video" or not uri:
|
|
251
|
+
return uri
|
|
252
|
+
if is_url(uri):
|
|
253
|
+
return str(fetch(uri, videos_dir() / cache_name(uri), "source video"))
|
|
254
|
+
path = Path(uri)
|
|
255
|
+
if path.exists():
|
|
256
|
+
return uri
|
|
257
|
+
# A default, or a path the user wrote that happens to name a sample clip.
|
|
258
|
+
if path.name in SAMPLE_VIDEOS:
|
|
259
|
+
print(f"source: {uri} is missing, fetching the sample clip", flush=True)
|
|
260
|
+
fetch(f"{SAMPLE_RELEASE}/{path.name}", path, "sample clip")
|
|
261
|
+
return uri
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def ensure_model(path: str, task: str) -> str:
|
|
265
|
+
"""Make ``model.path`` name an archive that exists, downloading if it has to.
|
|
266
|
+
|
|
267
|
+
A URL is fetched directly. Anything already on disk is used as it stands.
|
|
268
|
+
The remaining case is the default -- the task's own archive, not yet
|
|
269
|
+
downloaded -- and that one goes through ``sima-cli``, because a plain GET on
|
|
270
|
+
the pack URL answers with a login redirect rather than a tarball.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
RuntimeError: When the archive is missing and cannot be fetched, with
|
|
274
|
+
the command to run by hand.
|
|
275
|
+
"""
|
|
276
|
+
if is_url(path):
|
|
277
|
+
return str(fetch(path, models_dir() / cache_name(path), "model archive"))
|
|
278
|
+
if not path or Path(path).exists():
|
|
279
|
+
return path
|
|
280
|
+
|
|
281
|
+
entry = CATALOGUE.get(task)
|
|
282
|
+
target = Path(path)
|
|
283
|
+
if entry is None or target.name != entry.model_file:
|
|
284
|
+
# Not something this task knows how to fetch: a name we have no URL for.
|
|
285
|
+
raise RuntimeError(
|
|
286
|
+
f"model archive not found: {path}\n"
|
|
287
|
+
f" launched from: {Path.cwd()}\n"
|
|
288
|
+
"Pass --model with a path or an https URL, or leave it off to use "
|
|
289
|
+
f"the default for {task}."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
url = model_url(task)
|
|
293
|
+
if shutil.which("sima-cli") is None:
|
|
294
|
+
raise RuntimeError(
|
|
295
|
+
f"model archive not found: {path}\n"
|
|
296
|
+
"The model packs need a community.sima.ai login, and `sima-cli` is "
|
|
297
|
+
"not on PATH here,\nso it cannot be fetched for you. Run:\n\n"
|
|
298
|
+
f" sima-cli login\n {model_command(task)}\n\n"
|
|
299
|
+
"Or pass --model with an https URL you can reach."
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
# flush: the child writes straight to the terminal, so an unflushed line
|
|
304
|
+
# here turns up *after* its output and reads as if it came from sima-cli.
|
|
305
|
+
print(f"model: {path} is missing, fetching it with sima-cli", flush=True)
|
|
306
|
+
print(f" {url}", flush=True)
|
|
307
|
+
result = subprocess.run( # noqa: S603
|
|
308
|
+
["sima-cli", "download", url],
|
|
309
|
+
cwd=target.parent,
|
|
310
|
+
check=False,
|
|
311
|
+
# Left alone, sima-cli opens with "a newer version is available, update
|
|
312
|
+
# now? [Y/n]" and waits. Nothing is watching that prompt in the middle
|
|
313
|
+
# of a run, and answering no aborts the download with it. Its own
|
|
314
|
+
# message names this variable as the way off.
|
|
315
|
+
env={**os.environ, "SIMA_CLI_CHECK_FOR_UPDATE": "0"},
|
|
316
|
+
)
|
|
317
|
+
if result.returncode != 0 or not target.exists():
|
|
318
|
+
raise RuntimeError(
|
|
319
|
+
f"`sima-cli download` did not produce {target}\n"
|
|
320
|
+
"It needs a community.sima.ai login. Run `sima-cli login` and try "
|
|
321
|
+
"again, or download\nthe pack by hand and pass --model with its path."
|
|
322
|
+
)
|
|
323
|
+
return path
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def ensure_assets(cfg, task: str):
|
|
327
|
+
"""Resolve ``model.path`` and ``source.uri`` to files that exist.
|
|
328
|
+
|
|
329
|
+
Called once, by :meth:`Task.run <sima_vision.tasks.base.Task.run>`, so that
|
|
330
|
+
everything which does not run inference -- ``--validate``, ``preview``, the
|
|
331
|
+
Python ``validate()`` -- stays offline.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
The config, or a copy of it with the two paths replaced.
|
|
335
|
+
"""
|
|
336
|
+
source = ensure_source(cfg.source_uri, cfg.source_type)
|
|
337
|
+
model = ensure_model(cfg.model_path, task)
|
|
338
|
+
if (source, model) == (cfg.source_uri, cfg.model_path):
|
|
339
|
+
return cfg
|
|
340
|
+
return replace(cfg, source_uri=source, model_path=model)
|