tensorplate-python 0.1.3__py3-none-any.whl → 0.1.4__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 +4 -2
- tensorplate/client.py +3 -2
- tensorplate/conventions.py +7 -1
- tensorplate/postprocess.py +65 -8
- tensorplate/serving.py +380 -21
- tensorplate/vision.py +3 -1
- {tensorplate_python-0.1.3.dist-info → tensorplate_python-0.1.4.dist-info}/METADATA +1 -1
- tensorplate_python-0.1.4.dist-info/RECORD +15 -0
- tensorplate_python-0.1.3.dist-info/RECORD +0 -15
- {tensorplate_python-0.1.3.dist-info → tensorplate_python-0.1.4.dist-info}/WHEEL +0 -0
- {tensorplate_python-0.1.3.dist-info → tensorplate_python-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {tensorplate_python-0.1.3.dist-info → tensorplate_python-0.1.4.dist-info}/top_level.txt +0 -0
tensorplate/__init__.py
CHANGED
|
@@ -16,7 +16,7 @@ from tensorplate.client import (
|
|
|
16
16
|
canonicalize_serving_url,
|
|
17
17
|
resolve_serving_url,
|
|
18
18
|
)
|
|
19
|
-
from tensorplate.conventions import YOLO_V8_SINGLE_OUTPUT, detections
|
|
19
|
+
from tensorplate.conventions import YOLO26_E2E_DETECTIONS, YOLO_V8_SINGLE_OUTPUT, detections
|
|
20
20
|
from tensorplate.errors import (
|
|
21
21
|
EndpointResolutionError,
|
|
22
22
|
ErrorCode,
|
|
@@ -29,7 +29,7 @@ from tensorplate.errors import (
|
|
|
29
29
|
)
|
|
30
30
|
from tensorplate.postprocess import Detection, decode_detections
|
|
31
31
|
from tensorplate.preprocess import LetterboxTransform, PreprocessConfig, preprocess
|
|
32
|
-
from tensorplate.serving import HealthSnapshot, InferResult, ServingClient, Timing
|
|
32
|
+
from tensorplate.serving import ClientTiming, HealthSnapshot, InferResult, ServingClient, Timing
|
|
33
33
|
from tensorplate.tensors import DType, Layout, TensorInput, TensorOutput
|
|
34
34
|
from tensorplate.vision import VisionClient
|
|
35
35
|
|
|
@@ -40,7 +40,9 @@ except PackageNotFoundError: # pragma: no cover - only hit outside an installed
|
|
|
40
40
|
|
|
41
41
|
__all__ = [
|
|
42
42
|
"LOOPBACK_DEFAULT",
|
|
43
|
+
"YOLO26_E2E_DETECTIONS",
|
|
43
44
|
"YOLO_V8_SINGLE_OUTPUT",
|
|
45
|
+
"ClientTiming",
|
|
44
46
|
"DType",
|
|
45
47
|
"Detection",
|
|
46
48
|
"EndpointResolutionError",
|
tensorplate/client.py
CHANGED
|
@@ -104,8 +104,9 @@ def http_request(
|
|
|
104
104
|
:class:`RequestTimeoutError`.
|
|
105
105
|
"""
|
|
106
106
|
request = urllib.request.Request(url, data=body, method=method)
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
normalized_headers = {key.lower(): value for key, value in (headers or {}).items()}
|
|
108
|
+
request.add_header("Accept", normalized_headers.get("accept", "application/json"))
|
|
109
|
+
if body is not None and "content-type" not in normalized_headers:
|
|
109
110
|
request.add_header("Content-Type", "application/json")
|
|
110
111
|
for key, value in (headers or {}).items():
|
|
111
112
|
request.add_header(key, value)
|
tensorplate/conventions.py
CHANGED
|
@@ -21,9 +21,15 @@ class detections:
|
|
|
21
21
|
classes = "detections.classes"
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
#: Supported built-in detector output contract: a single tensor shaped
|
|
24
|
+
#: Supported built-in YOLOv8-style detector output contract: a single tensor shaped
|
|
25
25
|
#: ``[1, 4 + C, N]`` (YOLOv8-style; 4 box coords + C class scores over N
|
|
26
26
|
#: anchors), or its transpose ``[1, N, 4 + C]`` when the caller declares
|
|
27
27
|
#: it. Other heads (YOLOv5 objectness, exporter-side NMS, masks,
|
|
28
28
|
#: keypoints) remain application-side postprocessing in v0.1.3.
|
|
29
29
|
YOLO_V8_SINGLE_OUTPUT = "yolo_v8_single_output"
|
|
30
|
+
|
|
31
|
+
#: Supported built-in YOLO26 default one-to-one / end-to-end detector output
|
|
32
|
+
#: contract: a single NMS-free tensor shaped ``[1, K, 6]`` with
|
|
33
|
+
#: ``K <= 300`` and columns ``x1, y1, x2, y2, score, class_id`` in
|
|
34
|
+
#: letterboxed model-input pixels.
|
|
35
|
+
YOLO26_E2E_DETECTIONS = "yolo26_e2e_detections"
|
tensorplate/postprocess.py
CHANGED
|
@@ -7,13 +7,14 @@ lazily.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import math
|
|
10
11
|
from collections.abc import Sequence
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from typing import TYPE_CHECKING
|
|
13
14
|
|
|
14
|
-
from tensorplate.conventions import YOLO_V8_SINGLE_OUTPUT
|
|
15
|
+
from tensorplate.conventions import YOLO26_E2E_DETECTIONS, YOLO_V8_SINGLE_OUTPUT
|
|
15
16
|
from tensorplate.errors import ProtocolError
|
|
16
|
-
from tensorplate.tensors import TensorOutput
|
|
17
|
+
from tensorplate.tensors import DType, TensorOutput
|
|
17
18
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
19
20
|
import numpy as np
|
|
@@ -41,16 +42,22 @@ def decode_detections(
|
|
|
41
42
|
transposed: bool = False,
|
|
42
43
|
contract: str = YOLO_V8_SINGLE_OUTPUT,
|
|
43
44
|
) -> list[Detection]:
|
|
44
|
-
"""Decode a
|
|
45
|
+
"""Decode a supported YOLO-style output tensor into source-pixel detections.
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class-aware NMS
|
|
47
|
+
``yolo_v8_single_output`` expects ``[1, 4 + C, N]`` (or
|
|
48
|
+
``[1, N, 4 + C]`` when ``transposed``): 4 center-format box coordinates
|
|
49
|
+
followed by C per-class scores. It applies score thresholding and
|
|
50
|
+
class-aware NMS.
|
|
51
|
+
|
|
52
|
+
``yolo26_e2e_detections`` expects NMS-free ``[1, K, 6]`` with columns
|
|
53
|
+
x1, y1, x2, y2, score, class_id. It applies only score thresholding.
|
|
50
54
|
"""
|
|
55
|
+
if contract == YOLO26_E2E_DETECTIONS:
|
|
56
|
+
return _decode_yolo26_e2e(output, transform, score_threshold=score_threshold, labels=labels)
|
|
51
57
|
if contract != YOLO_V8_SINGLE_OUTPUT:
|
|
52
58
|
raise ValueError(
|
|
53
|
-
f"unsupported output contract {contract!r};
|
|
59
|
+
f"unsupported output contract {contract!r}; supported contracts are "
|
|
60
|
+
f"{YOLO_V8_SINGLE_OUTPUT!r} and {YOLO26_E2E_DETECTIONS!r}"
|
|
54
61
|
)
|
|
55
62
|
import numpy as np
|
|
56
63
|
|
|
@@ -99,6 +106,56 @@ def decode_detections(
|
|
|
99
106
|
return detections
|
|
100
107
|
|
|
101
108
|
|
|
109
|
+
def _decode_yolo26_e2e(
|
|
110
|
+
output: TensorOutput,
|
|
111
|
+
transform: LetterboxTransform,
|
|
112
|
+
*,
|
|
113
|
+
score_threshold: float,
|
|
114
|
+
labels: Sequence[str] | None,
|
|
115
|
+
) -> list[Detection]:
|
|
116
|
+
if output.dtype not in (DType.FLOAT16, DType.FLOAT32):
|
|
117
|
+
raise ProtocolError(
|
|
118
|
+
f"detector output for {YOLO26_E2E_DETECTIONS!r} must be float16 or float32, "
|
|
119
|
+
f"got {output.dtype.value!r}"
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
array = output.to_numpy().astype("float32")
|
|
123
|
+
except (RuntimeError, ValueError) as exc:
|
|
124
|
+
raise ProtocolError(
|
|
125
|
+
f"could not convert detector output for {YOLO26_E2E_DETECTIONS!r} to float32"
|
|
126
|
+
) from exc
|
|
127
|
+
if array.ndim != 3 or array.shape[0] != 1 or array.shape[2] != 6:
|
|
128
|
+
raise ProtocolError(
|
|
129
|
+
f"detector output must be [1, K, 6] for {YOLO26_E2E_DETECTIONS!r}, "
|
|
130
|
+
f"got shape {tuple(array.shape)}"
|
|
131
|
+
)
|
|
132
|
+
if array.shape[1] > 300:
|
|
133
|
+
raise ProtocolError(
|
|
134
|
+
f"detector output has {int(array.shape[1])} rows for {YOLO26_E2E_DETECTIONS!r}; "
|
|
135
|
+
"expected K <= 300"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
detections: list[Detection] = []
|
|
139
|
+
for row in array[0]:
|
|
140
|
+
score = float(row[4])
|
|
141
|
+
# Skip non-finite scores (NaN never compares >= threshold and would
|
|
142
|
+
# otherwise fall through to int(row[5]) and raise on a NaN class id).
|
|
143
|
+
if not math.isfinite(score) or score < score_threshold:
|
|
144
|
+
continue
|
|
145
|
+
class_value = float(row[5])
|
|
146
|
+
# A real YOLO26 head never emits a negative or non-finite class id;
|
|
147
|
+
# drop such rows rather than store a nonsensical Detection.class_id.
|
|
148
|
+
if not math.isfinite(class_value) or class_value < 0:
|
|
149
|
+
continue
|
|
150
|
+
class_id = int(class_value)
|
|
151
|
+
box = transform.map_box_to_source(
|
|
152
|
+
float(row[0]), float(row[1]), float(row[2]), float(row[3])
|
|
153
|
+
)
|
|
154
|
+
label = labels[class_id] if labels is not None and class_id < len(labels) else None
|
|
155
|
+
detections.append(Detection(class_id=class_id, score=score, box=box, label=label))
|
|
156
|
+
return detections
|
|
157
|
+
|
|
158
|
+
|
|
102
159
|
def _looks_like_wrong_layout(array: np.ndarray, transposed: bool) -> bool:
|
|
103
160
|
"""Return true for the common unambiguous YOLO transpose mismatch."""
|
|
104
161
|
rows = int(array.shape[1])
|
tensorplate/serving.py
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import math
|
|
7
|
+
import struct
|
|
8
|
+
import time
|
|
6
9
|
import uuid
|
|
7
10
|
from collections.abc import Sequence
|
|
8
11
|
from dataclasses import dataclass
|
|
12
|
+
from typing import Literal
|
|
9
13
|
|
|
10
14
|
from tensorplate.client import (
|
|
11
15
|
DEFAULT_TIMEOUT_S,
|
|
@@ -19,12 +23,16 @@ from tensorplate.errors import (
|
|
|
19
23
|
ServingError,
|
|
20
24
|
UnsupportedSchemaVersionError,
|
|
21
25
|
)
|
|
22
|
-
from tensorplate.tensors import DType, Layout, TensorInput, TensorOutput
|
|
26
|
+
from tensorplate.tensors import DType, Layout, TensorInput, TensorOutput, itemsize
|
|
23
27
|
|
|
24
28
|
#: Serving envelope schema_version this SDK speaks.
|
|
25
29
|
SCHEMA_VERSION = "0.1"
|
|
26
30
|
|
|
27
31
|
_HEALTH_PATH = "/health"
|
|
32
|
+
_BINARY_CONTENT_TYPE = "application/vnd.tensorplate.infer.binary.v1"
|
|
33
|
+
_BINARY_INFER_MAGIC = b"TPINFER1"
|
|
34
|
+
_BINARY_RESULT_MAGIC = b"TPRESULT1"
|
|
35
|
+
TransportPreference = Literal["auto", "binary", "json"]
|
|
28
36
|
|
|
29
37
|
|
|
30
38
|
@dataclass(frozen=True)
|
|
@@ -36,6 +44,17 @@ class Timing:
|
|
|
36
44
|
total_latency_ns: int | None = None
|
|
37
45
|
|
|
38
46
|
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ClientTiming:
|
|
49
|
+
"""Optional SDK-side timing for request encode, transport, and decode."""
|
|
50
|
+
|
|
51
|
+
encode_ns: int
|
|
52
|
+
http_roundtrip_ns: int
|
|
53
|
+
decode_ns: int
|
|
54
|
+
request_bytes: int
|
|
55
|
+
response_bytes: int
|
|
56
|
+
|
|
57
|
+
|
|
39
58
|
@dataclass(frozen=True)
|
|
40
59
|
class InferResult:
|
|
41
60
|
"""Parsed v0.1 ``success`` response."""
|
|
@@ -44,6 +63,8 @@ class InferResult:
|
|
|
44
63
|
outputs: tuple[TensorOutput, ...]
|
|
45
64
|
correlation_id: str | None = None
|
|
46
65
|
timing: Timing | None = None
|
|
66
|
+
transport: str = "json"
|
|
67
|
+
client_timing: ClientTiming | None = None
|
|
47
68
|
|
|
48
69
|
def output(self, name: str) -> TensorOutput:
|
|
49
70
|
"""Return the output tensor named ``name`` or raise ``KeyError``."""
|
|
@@ -89,7 +110,10 @@ class ServingClient:
|
|
|
89
110
|
config_path: str | None = None,
|
|
90
111
|
timeout: float = DEFAULT_TIMEOUT_S,
|
|
91
112
|
discover: bool = True,
|
|
113
|
+
preferred_transport: TransportPreference = "auto",
|
|
92
114
|
) -> None:
|
|
115
|
+
if preferred_transport not in ("auto", "binary", "json"):
|
|
116
|
+
raise ValueError("preferred_transport must be 'auto', 'binary', or 'json'")
|
|
93
117
|
self._endpoint = resolve_serving_url(
|
|
94
118
|
serving_url,
|
|
95
119
|
profile=profile,
|
|
@@ -98,6 +122,8 @@ class ServingClient:
|
|
|
98
122
|
discover=discover,
|
|
99
123
|
)
|
|
100
124
|
self._timeout = timeout
|
|
125
|
+
self._preferred_transport: TransportPreference = preferred_transport
|
|
126
|
+
self._binary_supported: bool | None = None
|
|
101
127
|
|
|
102
128
|
@property
|
|
103
129
|
def endpoint(self) -> ResolvedEndpoint:
|
|
@@ -129,6 +155,7 @@ class ServingClient:
|
|
|
129
155
|
*,
|
|
130
156
|
deadline_ms: int | None = None,
|
|
131
157
|
correlation_id: str | None = None,
|
|
158
|
+
profile: bool = False,
|
|
132
159
|
) -> InferResult:
|
|
133
160
|
"""Run a single synchronous inference against ``endpoint``.
|
|
134
161
|
|
|
@@ -150,23 +177,118 @@ class ServingClient:
|
|
|
150
177
|
if correlation_id is not None and not correlation_id:
|
|
151
178
|
raise ValueError("correlation_id must be non-empty when provided")
|
|
152
179
|
request_id = str(uuid.uuid4())
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
180
|
+
if self._should_try_binary():
|
|
181
|
+
status_code, raw, timing = self._infer_http(
|
|
182
|
+
"binary",
|
|
183
|
+
request_id,
|
|
184
|
+
endpoint,
|
|
185
|
+
inputs,
|
|
186
|
+
deadline_ms=deadline_ms,
|
|
187
|
+
correlation_id=correlation_id,
|
|
188
|
+
profile=profile,
|
|
189
|
+
)
|
|
190
|
+
if status_code == 415 and _is_binary_unsupported(raw):
|
|
191
|
+
if self._preferred_transport == "binary":
|
|
192
|
+
return _parse_infer_response(
|
|
193
|
+
raw, status_code, self._endpoint.url, "binary", timing
|
|
194
|
+
)
|
|
195
|
+
# In 'auto' mode a 415 'unsupported' is ambiguous: the worker may
|
|
196
|
+
# lack the binary transport, or the request itself may be
|
|
197
|
+
# unsupported (unknown dtype/layout, schema_version, ...) — both
|
|
198
|
+
# surface as 415 + code 'unsupported'. Retry over JSON and only
|
|
199
|
+
# mark binary unsupported when that retry actually succeeds;
|
|
200
|
+
# otherwise the failure was request content, not transport
|
|
201
|
+
# capability, so binary must stay enabled (a transient bad
|
|
202
|
+
# request must not permanently downgrade the client to JSON).
|
|
203
|
+
status_code, raw, timing = self._infer_http(
|
|
204
|
+
"json",
|
|
205
|
+
request_id,
|
|
206
|
+
endpoint,
|
|
207
|
+
inputs,
|
|
208
|
+
deadline_ms=deadline_ms,
|
|
209
|
+
correlation_id=correlation_id,
|
|
210
|
+
profile=profile,
|
|
211
|
+
)
|
|
212
|
+
result = _parse_infer_response(raw, status_code, self._endpoint.url, "json", timing)
|
|
213
|
+
self._binary_supported = False
|
|
214
|
+
return result
|
|
215
|
+
else:
|
|
216
|
+
result = _parse_binary_or_json_response(
|
|
217
|
+
raw, status_code, self._endpoint.url, "binary", timing
|
|
218
|
+
)
|
|
219
|
+
if result.transport == "binary":
|
|
220
|
+
self._binary_supported = True
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
status_code, raw, timing = self._infer_http(
|
|
224
|
+
"json",
|
|
225
|
+
request_id,
|
|
226
|
+
endpoint,
|
|
227
|
+
inputs,
|
|
228
|
+
deadline_ms=deadline_ms,
|
|
229
|
+
correlation_id=correlation_id,
|
|
230
|
+
profile=profile,
|
|
231
|
+
)
|
|
232
|
+
return _parse_infer_response(raw, status_code, self._endpoint.url, "json", timing)
|
|
233
|
+
|
|
234
|
+
def _should_try_binary(self) -> bool:
|
|
235
|
+
if self._preferred_transport == "binary":
|
|
236
|
+
return True
|
|
237
|
+
if self._preferred_transport == "json":
|
|
238
|
+
return False
|
|
239
|
+
return self._binary_supported is not False
|
|
240
|
+
|
|
241
|
+
def _infer_http(
|
|
242
|
+
self,
|
|
243
|
+
transport: Literal["binary", "json"],
|
|
244
|
+
request_id: str,
|
|
245
|
+
endpoint: str,
|
|
246
|
+
inputs: Sequence[TensorInput],
|
|
247
|
+
*,
|
|
248
|
+
deadline_ms: int | None,
|
|
249
|
+
correlation_id: str | None,
|
|
250
|
+
profile: bool,
|
|
251
|
+
) -> tuple[int, bytes, ClientTiming | None]:
|
|
252
|
+
t0 = time.perf_counter_ns()
|
|
253
|
+
if transport == "binary":
|
|
254
|
+
payload = _encode_binary_infer_request(
|
|
255
|
+
request_id,
|
|
256
|
+
endpoint,
|
|
257
|
+
inputs,
|
|
258
|
+
deadline_ms=deadline_ms,
|
|
259
|
+
correlation_id=correlation_id,
|
|
260
|
+
)
|
|
261
|
+
headers = {
|
|
262
|
+
"Content-Type": _BINARY_CONTENT_TYPE,
|
|
263
|
+
"Accept": f"{_BINARY_CONTENT_TYPE}, application/json",
|
|
264
|
+
}
|
|
265
|
+
if correlation_id is not None:
|
|
266
|
+
headers["X-Correlation-Id"] = correlation_id
|
|
267
|
+
else:
|
|
268
|
+
payload, headers = _encode_json_infer_request(
|
|
269
|
+
request_id,
|
|
270
|
+
endpoint,
|
|
271
|
+
inputs,
|
|
272
|
+
deadline_ms=deadline_ms,
|
|
273
|
+
correlation_id=correlation_id,
|
|
274
|
+
)
|
|
275
|
+
t1 = time.perf_counter_ns()
|
|
166
276
|
status_code, raw = http_request(
|
|
167
277
|
"POST", self._endpoint.url, body=payload, headers=headers, timeout=self._timeout
|
|
168
278
|
)
|
|
169
|
-
|
|
279
|
+
t2 = time.perf_counter_ns()
|
|
280
|
+
timing = (
|
|
281
|
+
ClientTiming(
|
|
282
|
+
encode_ns=t1 - t0,
|
|
283
|
+
http_roundtrip_ns=t2 - t1,
|
|
284
|
+
decode_ns=0,
|
|
285
|
+
request_bytes=len(payload),
|
|
286
|
+
response_bytes=len(raw),
|
|
287
|
+
)
|
|
288
|
+
if profile
|
|
289
|
+
else None
|
|
290
|
+
)
|
|
291
|
+
return status_code, raw, timing
|
|
170
292
|
|
|
171
293
|
def health(self) -> HealthSnapshot:
|
|
172
294
|
"""Read ``GET /health`` and parse the worker's readiness snapshot.
|
|
@@ -182,6 +304,72 @@ class ServingClient:
|
|
|
182
304
|
return _parse_health(payload, url)
|
|
183
305
|
|
|
184
306
|
|
|
307
|
+
def _encode_json_infer_request(
|
|
308
|
+
request_id: str,
|
|
309
|
+
endpoint: str,
|
|
310
|
+
inputs: Sequence[TensorInput],
|
|
311
|
+
*,
|
|
312
|
+
deadline_ms: int | None = None,
|
|
313
|
+
correlation_id: str | None = None,
|
|
314
|
+
) -> tuple[bytes, dict[str, str]]:
|
|
315
|
+
request_body: dict[str, object] = {
|
|
316
|
+
"schema_version": SCHEMA_VERSION,
|
|
317
|
+
"request_id": request_id,
|
|
318
|
+
"endpoint": endpoint,
|
|
319
|
+
"inputs": [tensor.to_named_input() for tensor in inputs],
|
|
320
|
+
}
|
|
321
|
+
if deadline_ms is not None:
|
|
322
|
+
request_body["deadline_ms"] = deadline_ms
|
|
323
|
+
headers: dict[str, str] = {}
|
|
324
|
+
if correlation_id is not None:
|
|
325
|
+
request_body["metadata"] = {"correlation_id": correlation_id}
|
|
326
|
+
headers["X-Correlation-Id"] = correlation_id
|
|
327
|
+
return json.dumps(request_body).encode("utf-8"), headers
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _encode_binary_infer_request(
|
|
331
|
+
request_id: str,
|
|
332
|
+
endpoint: str,
|
|
333
|
+
inputs: Sequence[TensorInput],
|
|
334
|
+
*,
|
|
335
|
+
deadline_ms: int | None = None,
|
|
336
|
+
correlation_id: str | None = None,
|
|
337
|
+
) -> bytes:
|
|
338
|
+
payload = bytearray()
|
|
339
|
+
encoded_inputs: list[dict[str, object]] = []
|
|
340
|
+
for tensor in inputs:
|
|
341
|
+
offset = len(payload)
|
|
342
|
+
payload.extend(tensor.data)
|
|
343
|
+
encoded_inputs.append(
|
|
344
|
+
{
|
|
345
|
+
"name": tensor.name,
|
|
346
|
+
"tensor": {
|
|
347
|
+
"dtype": tensor.dtype.value,
|
|
348
|
+
"layout": tensor.layout.value,
|
|
349
|
+
"shape": list(tensor.shape),
|
|
350
|
+
"byte_offset": 0,
|
|
351
|
+
"byte_size": len(tensor.data),
|
|
352
|
+
},
|
|
353
|
+
"payload_offset": offset,
|
|
354
|
+
"payload_size": len(tensor.data),
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
request_body: dict[str, object] = {
|
|
358
|
+
"schema_version": SCHEMA_VERSION,
|
|
359
|
+
"request_id": request_id,
|
|
360
|
+
"endpoint": endpoint,
|
|
361
|
+
"inputs": encoded_inputs,
|
|
362
|
+
}
|
|
363
|
+
if deadline_ms is not None:
|
|
364
|
+
request_body["deadline_ms"] = deadline_ms
|
|
365
|
+
if correlation_id is not None:
|
|
366
|
+
request_body["metadata"] = {"correlation_id": correlation_id}
|
|
367
|
+
metadata = json.dumps(request_body, separators=(",", ":")).encode("utf-8")
|
|
368
|
+
if len(metadata) > 0xFFFFFFFF:
|
|
369
|
+
raise ValueError("binary infer metadata exceeds uint32 length limit")
|
|
370
|
+
return _BINARY_INFER_MAGIC + struct.pack("<I", len(metadata)) + metadata + bytes(payload)
|
|
371
|
+
|
|
372
|
+
|
|
185
373
|
def _decode_json_object(raw: bytes, url: str) -> dict[str, object]:
|
|
186
374
|
try:
|
|
187
375
|
parsed = json.loads(raw)
|
|
@@ -200,7 +388,47 @@ def _require_supported_schema(payload: dict[str, object]) -> None:
|
|
|
200
388
|
)
|
|
201
389
|
|
|
202
390
|
|
|
203
|
-
def
|
|
391
|
+
def _with_decode_timing(
|
|
392
|
+
result: InferResult, timing: ClientTiming | None, started_ns: int
|
|
393
|
+
) -> InferResult:
|
|
394
|
+
if timing is None:
|
|
395
|
+
return result
|
|
396
|
+
return InferResult(
|
|
397
|
+
request_id=result.request_id,
|
|
398
|
+
outputs=result.outputs,
|
|
399
|
+
correlation_id=result.correlation_id,
|
|
400
|
+
timing=result.timing,
|
|
401
|
+
transport=result.transport,
|
|
402
|
+
client_timing=ClientTiming(
|
|
403
|
+
encode_ns=timing.encode_ns,
|
|
404
|
+
http_roundtrip_ns=timing.http_roundtrip_ns,
|
|
405
|
+
decode_ns=time.perf_counter_ns() - started_ns,
|
|
406
|
+
request_bytes=timing.request_bytes,
|
|
407
|
+
response_bytes=timing.response_bytes,
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _parse_binary_or_json_response(
|
|
413
|
+
raw: bytes,
|
|
414
|
+
status_code: int,
|
|
415
|
+
url: str,
|
|
416
|
+
transport: str,
|
|
417
|
+
client_timing: ClientTiming | None = None,
|
|
418
|
+
) -> InferResult:
|
|
419
|
+
if raw.startswith(_BINARY_RESULT_MAGIC):
|
|
420
|
+
return _parse_binary_infer_response(raw, status_code, url, client_timing)
|
|
421
|
+
return _parse_infer_response(raw, status_code, url, transport, client_timing)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _parse_infer_response(
|
|
425
|
+
raw: bytes,
|
|
426
|
+
status_code: int,
|
|
427
|
+
url: str,
|
|
428
|
+
transport: str = "json",
|
|
429
|
+
client_timing: ClientTiming | None = None,
|
|
430
|
+
) -> InferResult:
|
|
431
|
+
decode_started = time.perf_counter_ns()
|
|
204
432
|
payload = _decode_json_object(raw, url)
|
|
205
433
|
_require_supported_schema(payload)
|
|
206
434
|
status = payload.get("status")
|
|
@@ -218,14 +446,145 @@ def _parse_infer_response(raw: bytes, status_code: int, url: str) -> InferResult
|
|
|
218
446
|
if not isinstance(request_id, str):
|
|
219
447
|
raise ProtocolError(f"serving success response from {url!r} is missing 'request_id'")
|
|
220
448
|
correlation_id = payload.get("correlation_id")
|
|
221
|
-
return
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
449
|
+
return _with_decode_timing(
|
|
450
|
+
InferResult(
|
|
451
|
+
request_id=request_id,
|
|
452
|
+
outputs=outputs,
|
|
453
|
+
correlation_id=correlation_id if isinstance(correlation_id, str) else None,
|
|
454
|
+
timing=_parse_timing(payload.get("timing")),
|
|
455
|
+
transport=transport,
|
|
456
|
+
),
|
|
457
|
+
client_timing,
|
|
458
|
+
decode_started,
|
|
226
459
|
)
|
|
227
460
|
|
|
228
461
|
|
|
462
|
+
def _parse_binary_infer_response(
|
|
463
|
+
raw: bytes,
|
|
464
|
+
status_code: int,
|
|
465
|
+
url: str,
|
|
466
|
+
client_timing: ClientTiming | None = None,
|
|
467
|
+
) -> InferResult:
|
|
468
|
+
del status_code
|
|
469
|
+
decode_started = time.perf_counter_ns()
|
|
470
|
+
metadata, payload = _split_binary_envelope(raw, _BINARY_RESULT_MAGIC, url)
|
|
471
|
+
_require_supported_schema(metadata)
|
|
472
|
+
if metadata.get("status") != "success":
|
|
473
|
+
raise ProtocolError(f"binary serving response from {url!r} did not declare success")
|
|
474
|
+
outputs_obj = metadata.get("outputs")
|
|
475
|
+
if not isinstance(outputs_obj, list) or not outputs_obj:
|
|
476
|
+
raise ProtocolError(f"binary serving success response from {url!r} is missing outputs")
|
|
477
|
+
outputs = tuple(_binary_output_from_metadata(item, payload) for item in outputs_obj)
|
|
478
|
+
request_id = metadata.get("request_id")
|
|
479
|
+
if not isinstance(request_id, str):
|
|
480
|
+
raise ProtocolError(f"binary serving success response from {url!r} is missing request_id")
|
|
481
|
+
correlation_id = metadata.get("correlation_id")
|
|
482
|
+
return _with_decode_timing(
|
|
483
|
+
InferResult(
|
|
484
|
+
request_id=request_id,
|
|
485
|
+
outputs=outputs,
|
|
486
|
+
correlation_id=correlation_id if isinstance(correlation_id, str) else None,
|
|
487
|
+
timing=_parse_timing(metadata.get("timing")),
|
|
488
|
+
transport="binary",
|
|
489
|
+
),
|
|
490
|
+
client_timing,
|
|
491
|
+
decode_started,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _split_binary_envelope(raw: bytes, magic: bytes, url: str) -> tuple[dict[str, object], bytes]:
|
|
496
|
+
header_size = len(magic) + 4
|
|
497
|
+
if len(raw) < header_size or not raw.startswith(magic):
|
|
498
|
+
raise ProtocolError(f"binary serving response from {url!r} has invalid magic")
|
|
499
|
+
(metadata_len,) = struct.unpack("<I", raw[len(magic) : header_size])
|
|
500
|
+
metadata_end = header_size + metadata_len
|
|
501
|
+
if len(raw) < metadata_end:
|
|
502
|
+
raise ProtocolError(f"binary serving response from {url!r} has truncated metadata")
|
|
503
|
+
try:
|
|
504
|
+
metadata = json.loads(raw[header_size:metadata_end])
|
|
505
|
+
except ValueError as exc:
|
|
506
|
+
raise ProtocolError(
|
|
507
|
+
f"binary serving response from {url!r} has invalid metadata JSON: {exc}"
|
|
508
|
+
) from exc
|
|
509
|
+
if not isinstance(metadata, dict):
|
|
510
|
+
raise ProtocolError(f"binary serving response from {url!r} metadata is not an object")
|
|
511
|
+
return metadata, raw[metadata_end:]
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _binary_output_from_metadata(obj: object, payload: bytes) -> TensorOutput:
|
|
515
|
+
if not isinstance(obj, dict):
|
|
516
|
+
raise ProtocolError("binary serving output entry is not a JSON object")
|
|
517
|
+
name = obj.get("name")
|
|
518
|
+
if not isinstance(name, str) or not name:
|
|
519
|
+
raise ProtocolError("binary serving output is missing a non-empty 'name'")
|
|
520
|
+
tensor = obj.get("tensor")
|
|
521
|
+
if not isinstance(tensor, dict):
|
|
522
|
+
raise ProtocolError(f"binary serving output {name!r} is missing tensor metadata")
|
|
523
|
+
dtype_value = tensor.get("dtype")
|
|
524
|
+
try:
|
|
525
|
+
dtype = DType(dtype_value)
|
|
526
|
+
except ValueError as exc:
|
|
527
|
+
raise ProtocolError(
|
|
528
|
+
f"binary serving output {name!r} has unknown dtype {dtype_value!r}"
|
|
529
|
+
) from exc
|
|
530
|
+
layout_value = tensor.get("layout", Layout.ROW_MAJOR.value)
|
|
531
|
+
try:
|
|
532
|
+
layout = Layout(layout_value)
|
|
533
|
+
except ValueError as exc:
|
|
534
|
+
raise ProtocolError(
|
|
535
|
+
f"binary serving output {name!r} has unknown layout {layout_value!r}"
|
|
536
|
+
) from exc
|
|
537
|
+
shape_raw = tensor.get("shape")
|
|
538
|
+
if (
|
|
539
|
+
not isinstance(shape_raw, list)
|
|
540
|
+
or not shape_raw
|
|
541
|
+
or not all(
|
|
542
|
+
isinstance(dim, int) and not isinstance(dim, bool) and dim >= 1 for dim in shape_raw
|
|
543
|
+
)
|
|
544
|
+
):
|
|
545
|
+
raise ProtocolError(f"binary serving output {name!r} has an invalid shape")
|
|
546
|
+
shape = tuple(int(dim) for dim in shape_raw)
|
|
547
|
+
expected_size = itemsize(dtype) * math.prod(shape)
|
|
548
|
+
offset = _required_nonnegative_int(obj, "payload_offset", name)
|
|
549
|
+
size = _required_nonnegative_int(obj, "payload_size", name)
|
|
550
|
+
if size < expected_size:
|
|
551
|
+
raise ProtocolError(
|
|
552
|
+
f"binary serving output {name!r} payload_size is {size}, expected {expected_size}"
|
|
553
|
+
)
|
|
554
|
+
end = offset + size
|
|
555
|
+
if len(payload) < end:
|
|
556
|
+
raise ProtocolError(
|
|
557
|
+
f"binary serving output {name!r} payload is {len(payload)} bytes, need {end}"
|
|
558
|
+
)
|
|
559
|
+
semantic_tag = obj.get("semantic_tag")
|
|
560
|
+
return TensorOutput(
|
|
561
|
+
name=name,
|
|
562
|
+
dtype=dtype,
|
|
563
|
+
shape=shape,
|
|
564
|
+
data=payload[offset : offset + expected_size],
|
|
565
|
+
layout=layout,
|
|
566
|
+
semantic_tag=semantic_tag if isinstance(semantic_tag, str) else None,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _required_nonnegative_int(obj: dict[str, object], field: str, name: str) -> int:
|
|
571
|
+
value = obj.get(field)
|
|
572
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
573
|
+
raise ProtocolError(f"binary serving output {name!r} has invalid {field!r}")
|
|
574
|
+
return value
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _is_binary_unsupported(raw: bytes) -> bool:
|
|
578
|
+
try:
|
|
579
|
+
payload = json.loads(raw)
|
|
580
|
+
except (ValueError, UnicodeDecodeError):
|
|
581
|
+
return True
|
|
582
|
+
if not isinstance(payload, dict) or payload.get("status") != "failure":
|
|
583
|
+
return False
|
|
584
|
+
error_obj = payload.get("error")
|
|
585
|
+
return isinstance(error_obj, dict) and error_obj.get("code") == ErrorCode.UNSUPPORTED.value
|
|
586
|
+
|
|
587
|
+
|
|
229
588
|
def _serving_error(payload: dict[str, object]) -> ServingError:
|
|
230
589
|
request_id = payload.get("request_id")
|
|
231
590
|
request_id = request_id if isinstance(request_id, str) else None
|
tensorplate/vision.py
CHANGED
|
@@ -12,7 +12,7 @@ from tensorplate.conventions import YOLO_V8_SINGLE_OUTPUT, detections
|
|
|
12
12
|
from tensorplate.errors import ProtocolError
|
|
13
13
|
from tensorplate.postprocess import Detection, decode_detections
|
|
14
14
|
from tensorplate.preprocess import PreprocessConfig, preprocess
|
|
15
|
-
from tensorplate.serving import InferResult, ServingClient
|
|
15
|
+
from tensorplate.serving import InferResult, ServingClient, TransportPreference
|
|
16
16
|
from tensorplate.tensors import TensorOutput
|
|
17
17
|
|
|
18
18
|
if TYPE_CHECKING:
|
|
@@ -39,6 +39,7 @@ class VisionClient:
|
|
|
39
39
|
config_path: str | None = None,
|
|
40
40
|
timeout: float = DEFAULT_TIMEOUT_S,
|
|
41
41
|
discover: bool = True,
|
|
42
|
+
preferred_transport: TransportPreference = "auto",
|
|
42
43
|
client: ServingClient | None = None,
|
|
43
44
|
) -> None:
|
|
44
45
|
self._serving = client or ServingClient(
|
|
@@ -47,6 +48,7 @@ class VisionClient:
|
|
|
47
48
|
config_path=config_path,
|
|
48
49
|
timeout=timeout,
|
|
49
50
|
discover=discover,
|
|
51
|
+
preferred_transport=preferred_transport,
|
|
50
52
|
)
|
|
51
53
|
|
|
52
54
|
@property
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
tensorplate/__init__.py,sha256=GnFr1z9YVQirlbudOJzmYlpPWmBnFLLKL24_SyOBvsk,2047
|
|
2
|
+
tensorplate/client.py,sha256=hVoVAaqXUT8ZcP6HDbhk1OtqL_4uS5R9E4LgPDz4n_M,10673
|
|
3
|
+
tensorplate/conventions.py,sha256=6b2WBWHAGNxuPninMqNVRpe6pGz2k8FCI1SbLe-r33c,1414
|
|
4
|
+
tensorplate/errors.py,sha256=oZSYgG1T8TtLaw4jFwW806KzfDzke_5GB6Omlkvx8Uw,2718
|
|
5
|
+
tensorplate/postprocess.py,sha256=nKcPf5LeIOZIn-IlEKhQOOV3Qc6YUMAYk5uiTThco2s,7553
|
|
6
|
+
tensorplate/preprocess.py,sha256=ItUWijW3jKzel3ESLhnjg5ag6BoNXQhytpa_2o08fKk,6019
|
|
7
|
+
tensorplate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
tensorplate/serving.py,sha256=pq3mvLsgqf2DyFcbdi-zZM0JZglJdd_O_kyd3TIzhtw,23560
|
|
9
|
+
tensorplate/tensors.py,sha256=KvI3zYkAfNuquBKb9xDT-Mt4W8HudMECt02aHbMKm6Y,8939
|
|
10
|
+
tensorplate/vision.py,sha256=d4p-Gm5X4fhF4QH0nI0w66kS74pEZZywS_teUAr9c0A,4664
|
|
11
|
+
tensorplate_python-0.1.4.dist-info/licenses/LICENSE,sha256=ZxVIDHiEBfOHLu3QJTeSSO8fYRNUlInHP9ESK3DZmEE,11389
|
|
12
|
+
tensorplate_python-0.1.4.dist-info/METADATA,sha256=nMEYm4P5hklxrVXqD5VyeZeGI0nVYkU7iQbuId3m6sg,2905
|
|
13
|
+
tensorplate_python-0.1.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
tensorplate_python-0.1.4.dist-info/top_level.txt,sha256=FFVk-zeOOH6pSd9Hd9gzDD7WSJpbZ23RazVSCssIXnM,12
|
|
15
|
+
tensorplate_python-0.1.4.dist-info/RECORD,,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
tensorplate/__init__.py,sha256=cPJYqsMBR-PjWtfqN1N1yA2alUrVv32RwaSSTPQ1UUs,1961
|
|
2
|
-
tensorplate/client.py,sha256=SwgFZYnxXO1Ff3ruBSIAdCMzbyReGixlXOvjYK6Oj5M,10506
|
|
3
|
-
tensorplate/conventions.py,sha256=rysRZVfesQ6vRLrlTxiWbRBK4zbazvv-9njmBVKdJJo,1109
|
|
4
|
-
tensorplate/errors.py,sha256=oZSYgG1T8TtLaw4jFwW806KzfDzke_5GB6Omlkvx8Uw,2718
|
|
5
|
-
tensorplate/postprocess.py,sha256=GusFErKCJdUERoDUDvdrPbMkt0wtEFkXL5R3_c5O_JA,5186
|
|
6
|
-
tensorplate/preprocess.py,sha256=ItUWijW3jKzel3ESLhnjg5ag6BoNXQhytpa_2o08fKk,6019
|
|
7
|
-
tensorplate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
tensorplate/serving.py,sha256=d4OYe4JdcPFJWTwTzbi3xgbftFxgfSEHDiubc7C_eAc,10287
|
|
9
|
-
tensorplate/tensors.py,sha256=KvI3zYkAfNuquBKb9xDT-Mt4W8HudMECt02aHbMKm6Y,8939
|
|
10
|
-
tensorplate/vision.py,sha256=KwbVfPqtqdbIGSzEKqbkif9-KZj6U0zUZ81MrQDPPhw,4531
|
|
11
|
-
tensorplate_python-0.1.3.dist-info/licenses/LICENSE,sha256=ZxVIDHiEBfOHLu3QJTeSSO8fYRNUlInHP9ESK3DZmEE,11389
|
|
12
|
-
tensorplate_python-0.1.3.dist-info/METADATA,sha256=vS3F-CXLjd5BCrwYKRsUkzbzkyZZtNPp9d3Oy62JX3c,2905
|
|
13
|
-
tensorplate_python-0.1.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
-
tensorplate_python-0.1.3.dist-info/top_level.txt,sha256=FFVk-zeOOH6pSd9Hd9gzDD7WSJpbZ23RazVSCssIXnM,12
|
|
15
|
-
tensorplate_python-0.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|