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