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.
@@ -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)