ovkit 0.1.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.
ovkit/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """ovkit — a simple Python inference API for OpenVINO.
2
+
3
+ One import, one :class:`Model` class, a callable object, and clean
4
+ :class:`Results` — plus OpenVINO's strengths (AUTO/NPU devices, async, INT8).
5
+
6
+ Example
7
+ -------
8
+ >>> from ovkit import Model
9
+ >>> model = Model("rtdetr_r50") # name -> auto download/convert/cache
10
+ >>> results = model("img.jpg", conf=0.25)
11
+ >>> for r in results:
12
+ ... print(r.boxes.xyxy, r.boxes.conf, r.boxes.cls)
13
+ ... r.save("out.jpg")
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .core.errors import (
19
+ ConversionError,
20
+ DownloadError,
21
+ GatedModelError,
22
+ LicenseError,
23
+ MirrorMissingError,
24
+ ModelNotFoundError,
25
+ OfflineError,
26
+ OVKitError,
27
+ TaskDetectionError,
28
+ )
29
+ from .core.model import Model
30
+ from .core.registry import list_models
31
+ from .core.results import Boxes, Keypoints, Masks, Probs, Results
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ "Model",
37
+ "Results",
38
+ "Boxes",
39
+ "Masks",
40
+ "Keypoints",
41
+ "Probs",
42
+ "list_models",
43
+ "OVKitError",
44
+ "ModelNotFoundError",
45
+ "OfflineError",
46
+ "DownloadError",
47
+ "GatedModelError",
48
+ "MirrorMissingError",
49
+ "ConversionError",
50
+ "TaskDetectionError",
51
+ "LicenseError",
52
+ "__version__",
53
+ ]
ovkit/__main__.py ADDED
@@ -0,0 +1,166 @@
1
+ """``ovkit`` command-line interface: ``run``, ``list``, ``info``, ``download``, ``devices``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .core.convert import to_ir
10
+ from .core.download import fetch
11
+ from .core.errors import OVKitError
12
+ from .core.registry import list_models, resolve
13
+
14
+
15
+ def _cmd_list(_: argparse.Namespace) -> int:
16
+ names = list_models()
17
+ if not names:
18
+ print("No models registered.")
19
+ return 0
20
+ aliases: list[tuple[str, str]] = []
21
+ models: list[tuple[str, str, str]] = []
22
+ for name in names:
23
+ entry = resolve(name)
24
+ if entry is None:
25
+ continue
26
+ if entry.name != name: # capability alias -> its target
27
+ aliases.append((name, entry.name))
28
+ continue
29
+ desc = entry.description or ""
30
+ if len(desc) > 60:
31
+ desc = desc[:57] + "..."
32
+ models.append((name, str(entry.task), desc))
33
+ if aliases:
34
+ print("aliases (capability -> model):")
35
+ for alias, target in aliases:
36
+ print(f" {alias:24s} -> {target}")
37
+ print()
38
+ print(f"models ({len(models)}):")
39
+ for name, task, desc in models:
40
+ print(f" {name:44s} {task:18s} {desc}")
41
+ return 0
42
+
43
+
44
+ def _cmd_info(args: argparse.Namespace) -> int:
45
+ entry = resolve(args.name)
46
+ if entry is None:
47
+ print(f"'{args.name}' is not a registered model.", file=sys.stderr)
48
+ return 1
49
+ print(f"name : {entry.name}")
50
+ print(f"task : {entry.task}")
51
+ if entry.description:
52
+ print(f"description: {entry.description}")
53
+ print(f"license : {entry.license}")
54
+ print(f"source : {entry.src} ({entry.repo or entry.url})")
55
+ print(f"precision : {entry.precision}")
56
+ if entry.filename:
57
+ print(f"filename : {entry.filename}")
58
+ if entry.imgsz:
59
+ print(f"imgsz : {entry.imgsz}")
60
+ return 0
61
+
62
+
63
+ def _cmd_download(args: argparse.Namespace) -> int:
64
+ entry = resolve(args.name)
65
+ if entry is None:
66
+ print(f"'{args.name}' is not a registered model.", file=sys.stderr)
67
+ return 1
68
+ print(f"Fetching {entry.name} from {entry.src}...")
69
+ source = fetch(entry)
70
+ print(f"Downloaded source: {source}")
71
+ if not args.no_convert:
72
+ ir = to_ir(source, entry.name, entry.precision)
73
+ print(f"IR ready: {ir}")
74
+ return 0
75
+
76
+
77
+ def _cmd_devices(_: argparse.Namespace) -> int:
78
+ from .core.backend import available_devices
79
+
80
+ for dev in available_devices():
81
+ print(dev)
82
+ return 0
83
+
84
+
85
+ def _cmd_run(args: argparse.Namespace) -> int:
86
+ """One-shot inference from the shell: ``ovkit run detect img.jpg``."""
87
+ from pathlib import Path
88
+
89
+ from .core.model import Model
90
+
91
+ model = Model(args.model, device=args.device)
92
+ results = model(args.source, conf=args.conf)
93
+ if not isinstance(results, list): # raw (.npy/.wav) input -> tensor dict
94
+ for name, arr in results.items():
95
+ print(f"{name}: shape={tuple(arr.shape)} dtype={arr.dtype}")
96
+ return 0
97
+
98
+ for r in results:
99
+ parts = [f"task={r.task}"]
100
+ if r.text:
101
+ parts.append(f'text="{r.text}"')
102
+ if r.boxes is not None:
103
+ parts.append(f"{len(r.boxes)} boxes")
104
+ for x1, y1, x2, y2, c, cl in r.boxes.data[:20]:
105
+ print(
106
+ f" {r.name_for(int(cl)):16s} {c:.2f} [{int(x1)},{int(y1)},{int(x2)},{int(y2)}]"
107
+ )
108
+ if r.probs is not None:
109
+ top = ", ".join(
110
+ f"{r.name_for(int(i))} {r.probs.data[int(i)]:.2f}" for i in r.probs.top5
111
+ )
112
+ parts.append(f"top-5: {top}")
113
+ if r.masks is not None:
114
+ parts.append(f"masks {tuple(r.masks.data.shape)}")
115
+ if r.keypoints is not None:
116
+ parts.append(f"keypoints {tuple(r.keypoints.data.shape)}")
117
+ print(" | ".join(parts))
118
+
119
+ save = args.save
120
+ if save is None and results and Path(str(args.source)).is_file():
121
+ save = f"{Path(str(args.source)).stem}_out.jpg"
122
+ if save and results:
123
+ results[0].save(save)
124
+ print(f"saved -> {save}")
125
+ return 0
126
+
127
+
128
+ def main(argv: list[str] | None = None) -> int:
129
+ """CLI entry point. Returns a process exit code."""
130
+ parser = argparse.ArgumentParser(prog="ovkit", description="ovkit model utilities")
131
+ parser.add_argument("--version", action="version", version=f"ovkit {__version__}")
132
+ sub = parser.add_subparsers(dest="command", required=True)
133
+
134
+ p_list = sub.add_parser("list", help="list registered models")
135
+ p_list.set_defaults(func=_cmd_list)
136
+
137
+ p_info = sub.add_parser("info", help="show details for a model")
138
+ p_info.add_argument("name")
139
+ p_info.set_defaults(func=_cmd_info)
140
+
141
+ p_dl = sub.add_parser("download", help="download (and convert) a model")
142
+ p_dl.add_argument("name")
143
+ p_dl.add_argument("--no-convert", action="store_true", help="skip IR conversion")
144
+ p_dl.set_defaults(func=_cmd_download)
145
+
146
+ p_dev = sub.add_parser("devices", help="list OpenVINO devices")
147
+ p_dev.set_defaults(func=_cmd_devices)
148
+
149
+ p_run = sub.add_parser("run", help="run a model on an image/folder/video from the shell")
150
+ p_run.add_argument("model", help="alias, registered name, or model path")
151
+ p_run.add_argument("source", help="image / folder / video path (or .npy/.wav)")
152
+ p_run.add_argument("--conf", type=float, default=0.25, help="confidence threshold")
153
+ p_run.add_argument("--device", default="AUTO", help="AUTO | CPU | GPU | NPU")
154
+ p_run.add_argument("--save", metavar="PATH", help="annotated output (default: <src>_out.jpg)")
155
+ p_run.set_defaults(func=_cmd_run)
156
+
157
+ args = parser.parse_args(argv)
158
+ try:
159
+ return args.func(args)
160
+ except OVKitError as exc:
161
+ print(f"error: {exc}", file=sys.stderr)
162
+ return 2
163
+
164
+
165
+ if __name__ == "__main__":
166
+ raise SystemExit(main())
ovkit/core/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Core runtime: backend, model, results, registry, download, convert, tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .model import Model
6
+ from .results import Boxes, Keypoints, Masks, Probs, Results
7
+
8
+ __all__ = ["Model", "Results", "Boxes", "Masks", "Keypoints", "Probs"]
ovkit/core/backend.py ADDED
@@ -0,0 +1,166 @@
1
+ """Thin OpenVINO runtime wrapper: device abstraction, sync + async inference.
2
+
3
+ A :class:`Backend` owns a compiled model for a chosen device and exposes both a
4
+ single-shot :meth:`infer` (synchronous) and a throughput-oriented
5
+ :meth:`infer_batch` built on ``ov.AsyncInferQueue`` for streams/folders/video.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Iterable, Iterator
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+ #: A single OpenVINO Core, shared process-wide (creating many is wasteful).
17
+ _CORE = None
18
+
19
+
20
+ def core() -> Any:
21
+ """Return the shared :class:`openvino.Core`, creating it on first use."""
22
+ global _CORE
23
+ if _CORE is None:
24
+ import openvino as ov
25
+
26
+ _CORE = ov.Core()
27
+ return _CORE
28
+
29
+
30
+ def available_devices() -> list[str]:
31
+ """Return device names visible to OpenVINO (e.g. ``["CPU", "GPU", "NPU"]``)."""
32
+ return list(core().available_devices)
33
+
34
+
35
+ class Backend:
36
+ """A compiled model bound to a device, with sync and async inference.
37
+
38
+ Parameters
39
+ ----------
40
+ model:
41
+ Path to an IR ``.xml`` / ONNX file, or an already-built ``ov.Model``.
42
+ device:
43
+ OpenVINO device string. ``"AUTO"`` (default) lets OpenVINO pick.
44
+ """
45
+
46
+ def __init__(self, model: str | Path | Any, device: str = "AUTO") -> None:
47
+ self.device = device
48
+ c = core()
49
+ src = str(model) if isinstance(model, (str, Path)) else model
50
+ self.compiled = c.compile_model(src, device)
51
+ self.inputs = self.compiled.inputs
52
+ self.outputs = self.compiled.outputs
53
+
54
+ # -- introspection ------------------------------------------------------
55
+
56
+ @property
57
+ def input_shape(self) -> tuple[int, ...]:
58
+ """Partial shape of the first input as a tuple (``-1`` for dynamic)."""
59
+ ps = self.compiled.inputs[0].get_partial_shape()
60
+ dims: list[int] = []
61
+ for d in ps:
62
+ dims.append(int(d.get_length()) if d.is_static else -1)
63
+ return tuple(dims)
64
+
65
+ def _adapt_image_channels(self, arr: np.ndarray) -> np.ndarray:
66
+ """Match a single NCHW image tensor to the model's channel count.
67
+
68
+ Many OMZ models (OCR, some classifiers) take 1-channel grayscale; the
69
+ preprocessor always produces 3 channels, so reconcile the two here —
70
+ every image adapter funnels through :meth:`infer`. Only the 3<->1 image
71
+ case is touched; anything else passes through unchanged.
72
+ """
73
+ shape = self.input_shape
74
+ if arr.ndim != 4 or len(shape) != 4 or shape[1] not in (1, 3):
75
+ return arr
76
+ exp, got = shape[1], arr.shape[1]
77
+ if got == exp:
78
+ return arr
79
+ if exp == 1 and got == 3: # to grayscale (channel-order agnostic)
80
+ return arr.mean(axis=1, keepdims=True).astype(arr.dtype)
81
+ if exp == 3 and got == 1: # broadcast gray -> 3 channels
82
+ return np.repeat(arr, 3, axis=1)
83
+ return arr
84
+
85
+ def output_signatures(self) -> list[tuple[str, tuple[int, ...]]]:
86
+ """Return ``(name, shape)`` for each output (``-1`` for dynamic dims)."""
87
+ sigs: list[tuple[str, tuple[int, ...]]] = []
88
+ for out in self.compiled.outputs:
89
+ ps = out.get_partial_shape()
90
+ shape = tuple(int(d.get_length()) if d.is_static else -1 for d in ps)
91
+ try:
92
+ name = out.get_any_name()
93
+ except RuntimeError:
94
+ name = ""
95
+ sigs.append((name, shape))
96
+ return sigs
97
+
98
+ def rt_info(self, *keys: str) -> str | None:
99
+ """Read a runtime-info value from the underlying model, or ``None``."""
100
+ try:
101
+ model = self.compiled.get_runtime_model()
102
+ info = model.get_rt_info(list(keys))
103
+ return str(info)
104
+ except Exception:
105
+ return None
106
+
107
+ # -- inference ----------------------------------------------------------
108
+
109
+ def infer(self, inputs: np.ndarray | dict[Any, np.ndarray]) -> dict[str, np.ndarray]:
110
+ """Run one synchronous inference and return ``{output_name: ndarray}``."""
111
+ if isinstance(inputs, np.ndarray):
112
+ inputs = self._adapt_image_channels(inputs)
113
+ result = self.compiled(inputs)
114
+ return self._named(result)
115
+
116
+ def infer_batch(
117
+ self,
118
+ feeds: Iterable[np.ndarray | dict[Any, np.ndarray]],
119
+ callback: Callable[[int, dict[str, np.ndarray]], None] | None = None,
120
+ jobs: int = 0,
121
+ ) -> Iterator[dict[str, np.ndarray]]:
122
+ """Run inference over ``feeds`` using an async queue (throughput mode).
123
+
124
+ Yields result dicts in completion order. When ``callback`` is given it
125
+ is invoked as ``callback(index, result)``; otherwise results are
126
+ collected and yielded. ``jobs`` sets the number of in-flight requests
127
+ (``0`` lets OpenVINO choose the optimal number).
128
+ """
129
+ import openvino as ov
130
+
131
+ queue = ov.AsyncInferQueue(self.compiled, jobs)
132
+ collected: dict[int, dict[str, np.ndarray]] = {}
133
+
134
+ def _on_done(request: Any, userdata: int) -> None:
135
+ named = self._named({out: request.get_tensor(out).data for out in self.outputs})
136
+ if callback is not None:
137
+ callback(userdata, named)
138
+ else:
139
+ collected[userdata] = named
140
+
141
+ queue.set_callback(_on_done)
142
+ count = 0
143
+ for i, feed in enumerate(feeds):
144
+ if isinstance(feed, np.ndarray):
145
+ feed = self._adapt_image_channels(feed)
146
+ queue.start_async(feed, userdata=i)
147
+ count += 1
148
+ queue.wait_all()
149
+
150
+ if callback is None:
151
+ for i in range(count):
152
+ if i in collected:
153
+ yield collected[i]
154
+
155
+ def _named(self, result: Any) -> dict[str, np.ndarray]:
156
+ named: dict[str, np.ndarray] = {}
157
+ for idx, out in enumerate(self.compiled.outputs):
158
+ try:
159
+ name = out.get_any_name()
160
+ except RuntimeError:
161
+ name = f"output_{idx}"
162
+ try:
163
+ named[name] = np.asarray(result[out])
164
+ except (KeyError, TypeError):
165
+ named[name] = np.asarray(result[idx])
166
+ return named
@@ -0,0 +1,158 @@
1
+ """Shared constants: cache locations, license policy, well-known class lists."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ # --- cache / environment ---------------------------------------------------
9
+
10
+ #: Environment variable pointing at the ovkit cache root.
11
+ ENV_HOME = "OVKIT_HOME"
12
+ #: Environment variable; when ``"1"`` no network access is attempted.
13
+ ENV_OFFLINE = "OVKIT_OFFLINE"
14
+
15
+
16
+ def cache_root() -> Path:
17
+ """Return the ovkit cache root, honoring ``$OVKIT_HOME``.
18
+
19
+ Defaults to ``~/.cache/ovkit``. The directory is created on demand.
20
+ """
21
+ root = os.environ.get(ENV_HOME)
22
+ base = Path(root).expanduser() if root else Path.home() / ".cache" / "ovkit"
23
+ return base
24
+
25
+
26
+ def is_offline() -> bool:
27
+ """Return ``True`` when offline mode is requested via ``$OVKIT_OFFLINE``."""
28
+ return os.environ.get(ENV_OFFLINE, "").strip() in {"1", "true", "True", "yes"}
29
+
30
+
31
+ # --- license policy --------------------------------------------------------
32
+
33
+ #: SPDX ids accepted for models registered in the manifest. Anything outside
34
+ #: this set (AGPL, non-commercial weights, ...) must not ship with ovkit.
35
+ PERMISSIVE_LICENSES: frozenset[str] = frozenset(
36
+ {
37
+ "apache-2.0",
38
+ "mit",
39
+ "bsd-2-clause",
40
+ "bsd-3-clause",
41
+ "bsd",
42
+ "isc",
43
+ "unlicense",
44
+ "cc0-1.0",
45
+ "mpl-2.0",
46
+ }
47
+ )
48
+
49
+
50
+ def is_permissive(license_id: str | None) -> bool:
51
+ """Return ``True`` if ``license_id`` is a known permissive SPDX id."""
52
+ if not license_id:
53
+ return False
54
+ return license_id.strip().lower() in PERMISSIVE_LICENSES
55
+
56
+
57
+ # --- class name tables -----------------------------------------------------
58
+
59
+ #: 80 COCO class names (detection/segmentation), indexed by class id.
60
+ COCO80: tuple[str, ...] = (
61
+ "person",
62
+ "bicycle",
63
+ "car",
64
+ "motorcycle",
65
+ "airplane",
66
+ "bus",
67
+ "train",
68
+ "truck",
69
+ "boat",
70
+ "traffic light",
71
+ "fire hydrant",
72
+ "stop sign",
73
+ "parking meter",
74
+ "bench",
75
+ "bird",
76
+ "cat",
77
+ "dog",
78
+ "horse",
79
+ "sheep",
80
+ "cow",
81
+ "elephant",
82
+ "bear",
83
+ "zebra",
84
+ "giraffe",
85
+ "backpack",
86
+ "umbrella",
87
+ "handbag",
88
+ "tie",
89
+ "suitcase",
90
+ "frisbee",
91
+ "skis",
92
+ "snowboard",
93
+ "sports ball",
94
+ "kite",
95
+ "baseball bat",
96
+ "baseball glove",
97
+ "skateboard",
98
+ "surfboard",
99
+ "tennis racket",
100
+ "bottle",
101
+ "wine glass",
102
+ "cup",
103
+ "fork",
104
+ "knife",
105
+ "spoon",
106
+ "bowl",
107
+ "banana",
108
+ "apple",
109
+ "sandwich",
110
+ "orange",
111
+ "broccoli",
112
+ "carrot",
113
+ "hot dog",
114
+ "pizza",
115
+ "donut",
116
+ "cake",
117
+ "chair",
118
+ "couch",
119
+ "potted plant",
120
+ "bed",
121
+ "dining table",
122
+ "toilet",
123
+ "tv",
124
+ "laptop",
125
+ "mouse",
126
+ "remote",
127
+ "keyboard",
128
+ "cell phone",
129
+ "microwave",
130
+ "oven",
131
+ "toaster",
132
+ "sink",
133
+ "refrigerator",
134
+ "book",
135
+ "clock",
136
+ "vase",
137
+ "scissors",
138
+ "teddy bear",
139
+ "hair drier",
140
+ "toothbrush",
141
+ )
142
+
143
+ #: Registry of named class tables referenced from the manifest ``classes`` key.
144
+ CLASS_TABLES: dict[str, tuple[str, ...]] = {
145
+ "coco80": COCO80,
146
+ }
147
+
148
+
149
+ def class_names(key: str | None, num_classes: int | None = None) -> dict[int, str]:
150
+ """Resolve a manifest ``classes`` key to an ``{id: name}`` mapping.
151
+
152
+ Falls back to ``class_<i>`` names when ``key`` is unknown. ``num_classes``,
153
+ when given, sizes the fallback table.
154
+ """
155
+ if key and key in CLASS_TABLES:
156
+ return {i: n for i, n in enumerate(CLASS_TABLES[key])}
157
+ n = num_classes if num_classes is not None else 0
158
+ return {i: f"class_{i}" for i in range(n)}
ovkit/core/convert.py ADDED
@@ -0,0 +1,64 @@
1
+ """Convert source models (ONNX / IR) to OpenVINO IR, with a conversion cache.
2
+
3
+ Conversion runs at most once per ``(name, precision)``: the resulting IR is
4
+ written to the model cache and reused on subsequent loads.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from .download import model_cache_dir
12
+ from .errors import ConversionError
13
+
14
+
15
+ def _ir_paths(name: str, precision: str) -> tuple[Path, Path]:
16
+ base = model_cache_dir(name) / "ir" / precision
17
+ return base / "model.xml", base / "model.bin"
18
+
19
+
20
+ def cached_ir(name: str, precision: str) -> Path | None:
21
+ """Return the cached IR ``.xml`` for ``(name, precision)`` if it exists."""
22
+ xml, _ = _ir_paths(name, precision)
23
+ return xml if xml.is_file() else None
24
+
25
+
26
+ def to_ir(source: Path, name: str, precision: str = "fp16") -> Path:
27
+ """Convert ``source`` to OpenVINO IR and return the cached ``.xml`` path.
28
+
29
+ ``source`` may already be IR (``.xml``) — in that case it is passed through
30
+ unchanged. ONNX sources are converted with ``openvino.convert_model`` and
31
+ serialized (compressing weights to fp16 when ``precision == "fp16"``).
32
+ The result is cached so conversion happens only once.
33
+ """
34
+ source = Path(source)
35
+
36
+ # Already IR: load directly, no conversion/caching needed.
37
+ if source.suffix == ".xml":
38
+ return source
39
+
40
+ cached = cached_ir(name, precision)
41
+ if cached is not None:
42
+ return cached
43
+
44
+ try:
45
+ import openvino as ov
46
+ except ImportError as exc: # pragma: no cover - dependency missing
47
+ raise ConversionError("openvino is required to convert models to IR.") from exc
48
+
49
+ if source.suffix not in {".onnx", ".pb", ".pdmodel"} and not source.is_file():
50
+ raise ConversionError(f"Cannot convert '{source}': unsupported or missing source.")
51
+
52
+ xml_path, _ = _ir_paths(name, precision)
53
+ xml_path.parent.mkdir(parents=True, exist_ok=True)
54
+
55
+ try:
56
+ ov_model = ov.convert_model(str(source))
57
+ compress = precision == "fp16"
58
+ ov.save_model(ov_model, str(xml_path), compress_to_fp16=compress)
59
+ except Exception as exc: # pragma: no cover - conversion variety
60
+ raise ConversionError(f"Failed to convert '{source.name}' to IR: {exc}") from exc
61
+
62
+ if not xml_path.is_file(): # pragma: no cover - defensive
63
+ raise ConversionError(f"Conversion produced no IR at {xml_path}.")
64
+ return xml_path