tensorplate-python 0.1.3__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,71 @@
1
+ """TensorPlate Python SDK.
2
+
3
+ Client-side SDK for calling already-deployed TensorPlate detection and
4
+ vision serving models over the v0.1 ``/infer`` HTTP envelope. The public
5
+ surface is re-exported here; see the individual module docstrings for
6
+ details.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ from tensorplate.client import (
14
+ LOOPBACK_DEFAULT,
15
+ ResolvedEndpoint,
16
+ canonicalize_serving_url,
17
+ resolve_serving_url,
18
+ )
19
+ from tensorplate.conventions import YOLO_V8_SINGLE_OUTPUT, detections
20
+ from tensorplate.errors import (
21
+ EndpointResolutionError,
22
+ ErrorCode,
23
+ ProtocolError,
24
+ RequestTimeoutError,
25
+ ServingError,
26
+ TensorPlateError,
27
+ TransportError,
28
+ UnsupportedSchemaVersionError,
29
+ )
30
+ from tensorplate.postprocess import Detection, decode_detections
31
+ from tensorplate.preprocess import LetterboxTransform, PreprocessConfig, preprocess
32
+ from tensorplate.serving import HealthSnapshot, InferResult, ServingClient, Timing
33
+ from tensorplate.tensors import DType, Layout, TensorInput, TensorOutput
34
+ from tensorplate.vision import VisionClient
35
+
36
+ try:
37
+ __version__ = version("tensorplate-python")
38
+ except PackageNotFoundError: # pragma: no cover - only hit outside an installed package
39
+ __version__ = "0.0.0+unknown"
40
+
41
+ __all__ = [
42
+ "LOOPBACK_DEFAULT",
43
+ "YOLO_V8_SINGLE_OUTPUT",
44
+ "DType",
45
+ "Detection",
46
+ "EndpointResolutionError",
47
+ "ErrorCode",
48
+ "HealthSnapshot",
49
+ "InferResult",
50
+ "Layout",
51
+ "LetterboxTransform",
52
+ "PreprocessConfig",
53
+ "ProtocolError",
54
+ "RequestTimeoutError",
55
+ "ResolvedEndpoint",
56
+ "ServingClient",
57
+ "ServingError",
58
+ "TensorInput",
59
+ "TensorOutput",
60
+ "TensorPlateError",
61
+ "Timing",
62
+ "TransportError",
63
+ "UnsupportedSchemaVersionError",
64
+ "VisionClient",
65
+ "__version__",
66
+ "canonicalize_serving_url",
67
+ "decode_detections",
68
+ "detections",
69
+ "preprocess",
70
+ "resolve_serving_url",
71
+ ]
tensorplate/client.py ADDED
@@ -0,0 +1,278 @@
1
+ """Serving endpoint resolution and the low-level HTTP transport.
2
+
3
+ Resolution mirrors ``tensorplate infer`` so the SDK reaches the same
4
+ worker the CLI would: an explicit URL wins, then the active CLI profile's
5
+ ``serving_url``, then a read-only agent-status discovery, then the
6
+ loopback default. URL canonicalization matches the CLI's exactly. The
7
+ HTTP transport is hand-rolled over the standard library so the core SDK
8
+ has no third-party dependency.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import socket
16
+ import urllib.error
17
+ import urllib.request
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from tensorplate.errors import (
22
+ EndpointResolutionError,
23
+ RequestTimeoutError,
24
+ TransportError,
25
+ )
26
+
27
+ #: Loopback serving endpoint used when nothing else resolves. Mirrors the
28
+ #: CLI's hard-coded v0.1 default.
29
+ LOOPBACK_DEFAULT = "http://127.0.0.1:18080"
30
+
31
+ #: Default local agent control socket. Mirrors the CLI/packaging default.
32
+ DEFAULT_AGENT_SOCKET = "/var/run/tensorplate/agent.sock"
33
+
34
+ #: Environment variable naming the CLI config file. Mirrors the CLI's
35
+ #: discovery: explicit path, then this variable, then built-in defaults —
36
+ #: no arbitrary filesystem search.
37
+ CLI_CONFIG_ENV = "TENSORPLATE_CLI_CONFIG"
38
+
39
+ #: Default request timeout in seconds.
40
+ DEFAULT_TIMEOUT_S = 30.0
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ResolvedEndpoint:
45
+ """A canonicalized serving endpoint and how it was resolved."""
46
+
47
+ url: str
48
+ host: str
49
+ port: int
50
+ path: str
51
+ source: str
52
+
53
+ @property
54
+ def origin(self) -> str:
55
+ """The ``http://host:port`` origin, without the inference path."""
56
+ return f"http://{self.host}:{self.port}"
57
+
58
+
59
+ def canonicalize_serving_url(value: str, source: str) -> ResolvedEndpoint:
60
+ """Canonicalize a serving URL exactly as ``tensorplate infer`` does.
61
+
62
+ A bare ``http://host:port`` gets the ``/infer`` path appended; a full
63
+ URL with a path keeps that path. Only ``http://`` is accepted (v0.1
64
+ serving is loopback HTTP).
65
+ """
66
+ if not value.startswith("http://"):
67
+ raise EndpointResolutionError(
68
+ f"serving url {value!r} must start with 'http://' (v0.1 serving is loopback http)"
69
+ )
70
+ rest = value[len("http://") :]
71
+ authority, separator, tail = rest.partition("/")
72
+ raw_path = (tail or "infer") if separator else "infer"
73
+ if ":" in authority:
74
+ host, _, port_text = authority.rpartition(":")
75
+ try:
76
+ port = int(port_text)
77
+ except ValueError:
78
+ raise EndpointResolutionError(f"serving url {value!r} has a non-numeric port") from None
79
+ else:
80
+ host, port = authority, 80
81
+ if not host:
82
+ raise EndpointResolutionError(f"serving url {value!r} has an empty host")
83
+ if not 0 <= port <= 65535:
84
+ raise EndpointResolutionError(f"serving url {value!r} has an out-of-range port: {port}")
85
+ path = raw_path if raw_path.startswith("/") else f"/{raw_path}"
86
+ return ResolvedEndpoint(
87
+ url=f"http://{host}:{port}{path}", host=host, port=port, path=path, source=source
88
+ )
89
+
90
+
91
+ def http_request(
92
+ method: str,
93
+ url: str,
94
+ *,
95
+ body: bytes | None = None,
96
+ headers: dict[str, str] | None = None,
97
+ timeout: float = DEFAULT_TIMEOUT_S,
98
+ ) -> tuple[int, bytes]:
99
+ """Perform a single HTTP request, returning ``(status_code, body)``.
100
+
101
+ Non-2xx responses are returned (not raised) so callers can read a
102
+ typed failure envelope or a ``/health`` degraded body. Only genuine
103
+ transport failures raise :class:`TransportError` /
104
+ :class:`RequestTimeoutError`.
105
+ """
106
+ request = urllib.request.Request(url, data=body, method=method)
107
+ request.add_header("Accept", "application/json")
108
+ if body is not None:
109
+ request.add_header("Content-Type", "application/json")
110
+ for key, value in (headers or {}).items():
111
+ request.add_header(key, value)
112
+ try:
113
+ with urllib.request.urlopen(request, timeout=timeout) as response:
114
+ data: bytes = response.read()
115
+ return int(response.status), data
116
+ except urllib.error.HTTPError as exc:
117
+ return int(exc.code), exc.read()
118
+ except TimeoutError as exc:
119
+ raise RequestTimeoutError(f"request to {url!r} timed out after {timeout}s") from exc
120
+ except urllib.error.URLError as exc:
121
+ if isinstance(exc.reason, TimeoutError):
122
+ raise RequestTimeoutError(f"request to {url!r} timed out after {timeout}s") from exc
123
+ raise TransportError(f"failed to reach {url!r}: {exc.reason}") from exc
124
+ except OSError as exc:
125
+ raise TransportError(f"failed to reach {url!r}: {exc}") from exc
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class _AgentTransport:
130
+ kind: str # "unix" | "tcp"
131
+ socket_path: str | None = None
132
+ host: str | None = None
133
+ port: int | None = None
134
+
135
+
136
+ def resolve_serving_url(
137
+ explicit: str | None = None,
138
+ *,
139
+ profile: str | None = None,
140
+ config_path: str | None = None,
141
+ timeout: float = DEFAULT_TIMEOUT_S,
142
+ discover: bool = True,
143
+ ) -> ResolvedEndpoint:
144
+ """Resolve the serving endpoint with CLI-parity precedence.
145
+
146
+ Order: explicit URL, then the chosen CLI profile's ``serving_url``,
147
+ then read-only agent-status discovery, then the loopback default. When
148
+ ``discover`` is false the agent tier is skipped. Discovery is
149
+ best-effort: an unreachable agent falls through to the loopback
150
+ default rather than raising.
151
+ """
152
+ if explicit is not None:
153
+ return canonicalize_serving_url(explicit, "explicit")
154
+ config = _load_cli_config(config_path)
155
+ serving_url, transport = _select_profile(config, profile)
156
+ if serving_url is not None:
157
+ return canonicalize_serving_url(serving_url, "profile")
158
+ if discover and transport is not None:
159
+ discovered = _discover_via_agent(transport, timeout)
160
+ if discovered is not None:
161
+ return canonicalize_serving_url(discovered, "agent-discovered")
162
+ return canonicalize_serving_url(LOOPBACK_DEFAULT, "loopback")
163
+
164
+
165
+ def _load_cli_config(config_path: str | None) -> dict[str, object]:
166
+ path: Path | None = None
167
+ if config_path is not None:
168
+ path = Path(config_path)
169
+ else:
170
+ env_path = os.environ.get(CLI_CONFIG_ENV)
171
+ if env_path:
172
+ path = Path(env_path)
173
+ if path is None:
174
+ return {}
175
+ try:
176
+ text = path.read_text(encoding="utf-8")
177
+ except OSError as exc:
178
+ raise EndpointResolutionError(f"failed to read CLI config {str(path)!r}: {exc}") from exc
179
+ try:
180
+ parsed = json.loads(text)
181
+ except ValueError as exc:
182
+ raise EndpointResolutionError(f"CLI config {str(path)!r} is not valid JSON: {exc}") from exc
183
+ if not isinstance(parsed, dict):
184
+ raise EndpointResolutionError(f"CLI config {str(path)!r} must be a JSON object")
185
+ return parsed
186
+
187
+
188
+ def _select_profile(
189
+ config: dict[str, object], profile: str | None
190
+ ) -> tuple[str | None, _AgentTransport | None]:
191
+ profiles_obj = config.get("profiles")
192
+ profiles = profiles_obj if isinstance(profiles_obj, dict) else {}
193
+ default_obj = config.get("default_profile")
194
+ default_name = default_obj if isinstance(default_obj, str) else None
195
+ name = profile or default_name or "local"
196
+ spec = profiles.get(name)
197
+ if not isinstance(spec, dict):
198
+ if name == "local":
199
+ return None, _AgentTransport(kind="unix", socket_path=DEFAULT_AGENT_SOCKET)
200
+ raise EndpointResolutionError(f"profile {name!r} is not declared in the CLI config")
201
+ serving_obj = spec.get("serving_url")
202
+ serving_url = serving_obj if isinstance(serving_obj, str) else None
203
+ return serving_url, _transport_for_spec(spec)
204
+
205
+
206
+ def _transport_for_spec(spec: dict[str, object]) -> _AgentTransport | None:
207
+ mode = spec.get("mode")
208
+ if mode == "url":
209
+ agent_url = spec.get("agent_url")
210
+ if not isinstance(agent_url, str) or ":" not in agent_url:
211
+ return None
212
+ host, _, port_text = agent_url.rpartition(":")
213
+ try:
214
+ port = int(port_text)
215
+ except ValueError:
216
+ return None
217
+ if not host:
218
+ return None
219
+ return _AgentTransport(kind="tcp", host=host, port=port)
220
+ if mode in (None, "local"):
221
+ socket_obj = spec.get("socket_path")
222
+ socket_path = socket_obj if isinstance(socket_obj, str) else DEFAULT_AGENT_SOCKET
223
+ return _AgentTransport(kind="unix", socket_path=socket_path)
224
+ # Reserved modes (ssh_tunnel/overlay/relay): the SDK does not attempt
225
+ # discovery against them, mirroring the CLI's "unsupported" stance.
226
+ return None
227
+
228
+
229
+ def _discover_via_agent(transport: _AgentTransport, timeout: float) -> str | None:
230
+ """Query the agent's read-only ``status`` op for the active serving URL.
231
+
232
+ Best-effort: any transport, decode, or shape problem returns ``None``
233
+ so resolution falls through to the loopback default.
234
+ """
235
+ try:
236
+ raw = _agent_status_roundtrip(transport, timeout)
237
+ except OSError:
238
+ return None
239
+ try:
240
+ parsed = json.loads(raw)
241
+ except (ValueError, UnicodeDecodeError):
242
+ return None
243
+ if not isinstance(parsed, dict) or parsed.get("status") != "ok":
244
+ return None
245
+ agent_status = parsed.get("agent_status")
246
+ if not isinstance(agent_status, dict):
247
+ return None
248
+ active = agent_status.get("active")
249
+ if not isinstance(active, dict):
250
+ return None
251
+ serving_url = active.get("serving_url")
252
+ return serving_url if isinstance(serving_url, str) and serving_url else None
253
+
254
+
255
+ def _agent_status_roundtrip(transport: _AgentTransport, timeout: float) -> bytes:
256
+ request = json.dumps({"schema_version": "0.1", "op": "status"}).encode("utf-8") + b"\n"
257
+ if transport.kind == "unix":
258
+ path = transport.socket_path
259
+ if path is None or not hasattr(socket, "AF_UNIX"):
260
+ raise OSError("unix-socket agent transport is unavailable")
261
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
262
+ sock.settimeout(timeout)
263
+ sock.connect(path)
264
+ else:
265
+ if transport.host is None or transport.port is None:
266
+ raise OSError("tcp agent transport is missing host/port")
267
+ sock = socket.create_connection((transport.host, transport.port), timeout=timeout)
268
+ chunks: list[bytes] = []
269
+ with sock:
270
+ sock.sendall(request)
271
+ while True:
272
+ chunk = sock.recv(65536)
273
+ if not chunk:
274
+ break
275
+ chunks.append(chunk)
276
+ if b"\n" in chunk:
277
+ break
278
+ return b"".join(chunks)
@@ -0,0 +1,29 @@
1
+ """Detector output-decoding conventions and supported output contracts.
2
+
3
+ These names and identifiers are pure constants (no numpy) so the core
4
+ package imports without the optional vision dependencies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class detections:
11
+ """Semantic-tag names for opportunistic detector-output decode.
12
+
13
+ A serving worker that tags its output tensors with these names lets
14
+ the SDK select the boxes/scores/classes tensors without the caller
15
+ supplying explicit output-name mapping. Tag emission is best-effort;
16
+ explicit mapping remains the primary, documented path.
17
+ """
18
+
19
+ boxes = "detections.boxes"
20
+ scores = "detections.scores"
21
+ classes = "detections.classes"
22
+
23
+
24
+ #: Supported built-in detector output contract: a single tensor shaped
25
+ #: ``[1, 4 + C, N]`` (YOLOv8-style; 4 box coords + C class scores over N
26
+ #: anchors), or its transpose ``[1, N, 4 + C]`` when the caller declares
27
+ #: it. Other heads (YOLOv5 objectness, exporter-side NMS, masks,
28
+ #: keypoints) remain application-side postprocessing in v0.1.3.
29
+ YOLO_V8_SINGLE_OUTPUT = "yolo_v8_single_output"
tensorplate/errors.py ADDED
@@ -0,0 +1,87 @@
1
+ """Typed exceptions and error codes raised by the TensorPlate SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class ErrorCode(str, Enum):
9
+ """Stable serving error codes shared with the runtime, agent, and CLI.
10
+
11
+ Mirrors the ``code`` enum in the v0.1 serving and error schemas. The
12
+ string values are the wire form; the numeric C++ enum is not part of
13
+ the protocol.
14
+ """
15
+
16
+ CONFIG_INVALID = "config_invalid"
17
+ LOAD_FAILED = "load_failed"
18
+ NOT_READY = "not_ready"
19
+ SHAPE_MISMATCH = "shape_mismatch"
20
+ UNSUPPORTED = "unsupported"
21
+ OOM_ERROR = "oom_error"
22
+ TIMEOUT = "timeout"
23
+ INFERENCE_FAILED = "inference_failed"
24
+ INTERNAL = "internal"
25
+
26
+
27
+ class TensorPlateError(Exception):
28
+ """Base class for every error raised by the TensorPlate SDK.
29
+
30
+ Transport, protocol, and serving-failure subtypes derive from this
31
+ base so callers can catch the entire SDK error surface with a single
32
+ ``except`` clause.
33
+ """
34
+
35
+
36
+ class EndpointResolutionError(TensorPlateError):
37
+ """Raised when the serving endpoint cannot be resolved or canonicalized."""
38
+
39
+
40
+ class TransportError(TensorPlateError):
41
+ """Raised when the serving endpoint cannot be reached or the HTTP exchange fails."""
42
+
43
+
44
+ class RequestTimeoutError(TransportError):
45
+ """Raised when a request to the serving endpoint exceeds its timeout."""
46
+
47
+
48
+ class ProtocolError(TensorPlateError):
49
+ """Raised when a response is not valid JSON or violates the serving envelope."""
50
+
51
+
52
+ class UnsupportedSchemaVersionError(ProtocolError):
53
+ """Raised when a response declares a ``schema_version`` the SDK does not support."""
54
+
55
+ def __init__(self, received: str | None, supported: str) -> None:
56
+ self.received = received
57
+ self.supported = supported
58
+ got = received if received is not None else "<missing>"
59
+ super().__init__(
60
+ f"unsupported serving schema_version {got!r}; this SDK supports {supported!r}"
61
+ )
62
+
63
+
64
+ class ServingError(TensorPlateError):
65
+ """Raised when the serving worker returns a typed ``failure`` envelope.
66
+
67
+ Carries the wire ``code``, human-readable ``message``, optional
68
+ ``context``, and the ``request_id`` the worker echoed so failures can
69
+ be correlated with serving logs.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ code: ErrorCode,
75
+ message: str,
76
+ *,
77
+ context: str | None = None,
78
+ request_id: str | None = None,
79
+ ) -> None:
80
+ self.code = code
81
+ self.message = message
82
+ self.context = context
83
+ self.request_id = request_id
84
+ detail = f"[{code.value}] {message}"
85
+ if context:
86
+ detail = f"{detail} (context: {context})"
87
+ super().__init__(detail)
@@ -0,0 +1,142 @@
1
+ """YOLO-style detector output decoding, NMS, and the ``Detection`` type.
2
+
3
+ ``Detection`` and the module's imports are numpy-free so the core package
4
+ imports without the vision extras; the decode/NMS routines import numpy
5
+ lazily.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ from tensorplate.conventions import YOLO_V8_SINGLE_OUTPUT
15
+ from tensorplate.errors import ProtocolError
16
+ from tensorplate.tensors import TensorOutput
17
+
18
+ if TYPE_CHECKING:
19
+ import numpy as np
20
+
21
+ from tensorplate.preprocess import LetterboxTransform
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Detection:
26
+ """A single object detection in source-image pixel space."""
27
+
28
+ class_id: int
29
+ score: float
30
+ box: tuple[float, float, float, float] # (x1, y1, x2, y2)
31
+ label: str | None = None
32
+
33
+
34
+ def decode_detections(
35
+ output: TensorOutput,
36
+ transform: LetterboxTransform,
37
+ *,
38
+ score_threshold: float = 0.25,
39
+ nms_threshold: float = 0.45,
40
+ labels: Sequence[str] | None = None,
41
+ transposed: bool = False,
42
+ contract: str = YOLO_V8_SINGLE_OUTPUT,
43
+ ) -> list[Detection]:
44
+ """Decode a YOLOv8-style single-output tensor into source-pixel detections.
45
+
46
+ Expects ``[1, 4 + C, N]`` (or ``[1, N, 4 + C]`` when ``transposed``): 4
47
+ box coordinates (center-x, center-y, width, height in model-input
48
+ pixels) followed by C per-class scores. Applies score thresholding and
49
+ class-aware NMS, then maps boxes back to source pixels via ``transform``.
50
+ """
51
+ if contract != YOLO_V8_SINGLE_OUTPUT:
52
+ raise ValueError(
53
+ f"unsupported output contract {contract!r}; only {YOLO_V8_SINGLE_OUTPUT!r} is built in"
54
+ )
55
+ import numpy as np
56
+
57
+ array = output.to_numpy().astype("float32")
58
+ if array.ndim != 3 or array.shape[0] != 1:
59
+ raise ProtocolError(
60
+ f"detector output must be [1, *, *] for {contract!r}, got shape {tuple(array.shape)}"
61
+ )
62
+ if _looks_like_wrong_layout(array, transposed):
63
+ expected = "[1, N, 4 + C]" if transposed else "[1, 4 + C, N]"
64
+ hint = "remove transposed=True" if transposed else "pass transposed=True"
65
+ raise ProtocolError(
66
+ f"detector output shape {tuple(array.shape)} does not match declared layout "
67
+ f"{expected}; {hint} if the tensor uses the opposite layout"
68
+ )
69
+ grid = array[0].T if transposed else array[0]
70
+ if grid.shape[0] < 5:
71
+ raise ProtocolError(
72
+ f"detector output has {int(grid.shape[0])} channels; need 4 box + >=1 class score "
73
+ "(is the layout transposed? pass transposed=True)"
74
+ )
75
+
76
+ boxes = grid[:4, :]
77
+ class_scores = grid[4:, :]
78
+ scores = class_scores.max(axis=0)
79
+ class_ids = class_scores.argmax(axis=0)
80
+ keep = scores >= score_threshold
81
+ if not bool(keep.any()):
82
+ return []
83
+
84
+ cx, cy, w, h = boxes[0, keep], boxes[1, keep], boxes[2, keep], boxes[3, keep]
85
+ xyxy = np.stack([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], axis=1)
86
+ kept_scores = scores[keep]
87
+ kept_classes = class_ids[keep]
88
+
89
+ detections: list[Detection] = []
90
+ for idx in _class_aware_nms(xyxy, kept_scores, kept_classes, nms_threshold):
91
+ box = transform.map_box_to_source(
92
+ float(xyxy[idx, 0]), float(xyxy[idx, 1]), float(xyxy[idx, 2]), float(xyxy[idx, 3])
93
+ )
94
+ class_id = int(kept_classes[idx])
95
+ label = labels[class_id] if labels is not None and 0 <= class_id < len(labels) else None
96
+ detections.append(
97
+ Detection(class_id=class_id, score=float(kept_scores[idx]), box=box, label=label)
98
+ )
99
+ return detections
100
+
101
+
102
+ def _looks_like_wrong_layout(array: np.ndarray, transposed: bool) -> bool:
103
+ """Return true for the common unambiguous YOLO transpose mismatch."""
104
+ rows = int(array.shape[1])
105
+ cols = int(array.shape[2])
106
+ if transposed:
107
+ return cols > rows and rows >= 5
108
+ return rows > cols and cols >= 5
109
+
110
+
111
+ def _class_aware_nms(
112
+ boxes: np.ndarray, scores: np.ndarray, class_ids: np.ndarray, threshold: float
113
+ ) -> list[int]:
114
+ import numpy as np
115
+
116
+ selected: list[int] = []
117
+ for cls in np.unique(class_ids):
118
+ members = np.nonzero(class_ids == cls)[0]
119
+ order = members[np.argsort(scores[members])[::-1]]
120
+ while order.size > 0:
121
+ best = int(order[0])
122
+ selected.append(best)
123
+ if order.size == 1:
124
+ break
125
+ rest = order[1:]
126
+ order = rest[_iou(boxes[best], boxes[rest]) <= threshold]
127
+ return selected
128
+
129
+
130
+ def _iou(box: np.ndarray, others: np.ndarray) -> np.ndarray:
131
+ import numpy as np
132
+
133
+ x1 = np.maximum(box[0], others[:, 0])
134
+ y1 = np.maximum(box[1], others[:, 1])
135
+ x2 = np.minimum(box[2], others[:, 2])
136
+ y2 = np.minimum(box[3], others[:, 3])
137
+ inter = np.clip(x2 - x1, 0.0, None) * np.clip(y2 - y1, 0.0, None)
138
+ area_box = max(0.0, float(box[2] - box[0])) * max(0.0, float(box[3] - box[1]))
139
+ area_others = np.clip(others[:, 2] - others[:, 0], 0.0, None) * np.clip(
140
+ others[:, 3] - others[:, 1], 0.0, None
141
+ )
142
+ return np.asarray(inter / np.maximum(area_box + area_others - inter, 1e-9))