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.
- tensorplate/__init__.py +71 -0
- tensorplate/client.py +278 -0
- tensorplate/conventions.py +29 -0
- tensorplate/errors.py +87 -0
- tensorplate/postprocess.py +142 -0
- tensorplate/preprocess.py +165 -0
- tensorplate/py.typed +0 -0
- tensorplate/serving.py +291 -0
- tensorplate/tensors.py +250 -0
- tensorplate/vision.py +121 -0
- tensorplate_python-0.1.3.dist-info/METADATA +83 -0
- tensorplate_python-0.1.3.dist-info/RECORD +15 -0
- tensorplate_python-0.1.3.dist-info/WHEEL +5 -0
- tensorplate_python-0.1.3.dist-info/licenses/LICENSE +203 -0
- tensorplate_python-0.1.3.dist-info/top_level.txt +1 -0
tensorplate/__init__.py
ADDED
|
@@ -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))
|