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,165 @@
1
+ """Client-side image preprocessing for detector models.
2
+
3
+ Decodes an image (path, bytes, or HWC uint8 ndarray), letterboxes it to
4
+ the model's input size, normalizes, orders channels, and lays it out as an
5
+ NCHW float32 input tensor. A :class:`LetterboxTransform` records the scale
6
+ and padding so detections map back to source-image pixels.
7
+
8
+ numpy and Pillow (the ``tensorplate-python[vision]`` extra) are imported
9
+ lazily so the core package stays importable without them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ from tensorplate.tensors import DType, Layout, TensorInput
20
+
21
+ if TYPE_CHECKING:
22
+ import numpy as np
23
+
24
+
25
+ def _clamp(value: float, low: float, high: float) -> float:
26
+ return max(low, min(high, value))
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class LetterboxTransform:
31
+ """Maps model-input pixel coordinates back to source-image pixels."""
32
+
33
+ src_height: int
34
+ src_width: int
35
+ scale_x: float
36
+ scale_y: float
37
+ pad_x: float
38
+ pad_y: float
39
+ input_height: int
40
+ input_width: int
41
+
42
+ def map_box_to_source(
43
+ self, x1: float, y1: float, x2: float, y2: float
44
+ ) -> tuple[float, float, float, float]:
45
+ """Map a box from model-input pixels to source pixels (clamped to bounds)."""
46
+ return (
47
+ _clamp((x1 - self.pad_x) / self.scale_x, 0.0, float(self.src_width)),
48
+ _clamp((y1 - self.pad_y) / self.scale_y, 0.0, float(self.src_height)),
49
+ _clamp((x2 - self.pad_x) / self.scale_x, 0.0, float(self.src_width)),
50
+ _clamp((y2 - self.pad_y) / self.scale_y, 0.0, float(self.src_height)),
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class PreprocessConfig:
56
+ """Preprocessing options. Defaults target YOLO-style NCHW float32 input."""
57
+
58
+ input_size: tuple[int, int] = (640, 640) # (height, width)
59
+ letterbox: bool = True
60
+ channel_order: str = "rgb" # "rgb" or "bgr"
61
+ channels_first: bool = True # NCHW when True, else NHWC
62
+ scale: float = 1.0 / 255.0
63
+ mean: tuple[float, ...] | None = None
64
+ std: tuple[float, ...] | None = None
65
+ dtype: DType = DType.FLOAT32
66
+ pad_value: int = 114
67
+ input_name: str = "images"
68
+
69
+
70
+ def preprocess(
71
+ image: str | bytes | Path | np.ndarray,
72
+ config: PreprocessConfig | None = None,
73
+ ) -> tuple[TensorInput, LetterboxTransform]:
74
+ """Preprocess ``image`` into a model input tensor and a back-mapping transform.
75
+
76
+ ``image`` may be a filesystem path, encoded image bytes, or an HWC
77
+ uint8 ndarray (assumed RGB). Decoding path/bytes requires Pillow.
78
+ """
79
+ import numpy as np
80
+
81
+ cfg = config if config is not None else PreprocessConfig()
82
+ if cfg.channel_order not in ("rgb", "bgr"):
83
+ raise ValueError(f"channel_order must be 'rgb' or 'bgr', got {cfg.channel_order!r}")
84
+
85
+ source = _decode_to_hwc_rgb_uint8(image)
86
+ input_height, input_width = cfg.input_size
87
+ resized, scale_x, scale_y, pad_x, pad_y = _resize(source, cfg)
88
+ transform = LetterboxTransform(
89
+ src_height=int(source.shape[0]),
90
+ src_width=int(source.shape[1]),
91
+ scale_x=scale_x,
92
+ scale_y=scale_y,
93
+ pad_x=pad_x,
94
+ pad_y=pad_y,
95
+ input_height=input_height,
96
+ input_width=input_width,
97
+ )
98
+
99
+ array = resized.astype("float32")
100
+ if cfg.channel_order == "bgr":
101
+ array = array[:, :, ::-1]
102
+ array = array * cfg.scale
103
+ if cfg.mean is not None:
104
+ array = array - np.asarray(cfg.mean, dtype="float32")
105
+ if cfg.std is not None:
106
+ array = array / np.asarray(cfg.std, dtype="float32")
107
+ if cfg.channels_first:
108
+ array = np.transpose(array, (2, 0, 1))
109
+ array = array[np.newaxis, ...]
110
+
111
+ tensor = TensorInput.from_numpy(cfg.input_name, array, dtype=cfg.dtype, layout=Layout.ROW_MAJOR)
112
+ return tensor, transform
113
+
114
+
115
+ def _decode_to_hwc_rgb_uint8(image: str | bytes | Path | np.ndarray) -> np.ndarray:
116
+ if isinstance(image, (str, Path)):
117
+ return _decode_pil(image)
118
+ if isinstance(image, (bytes, bytearray)):
119
+ return _decode_pil(io.BytesIO(bytes(image)))
120
+ import numpy as np
121
+
122
+ array = np.asarray(image)
123
+ if array.ndim != 3 or array.shape[2] != 3:
124
+ raise ValueError(
125
+ f"ndarray image must be HWC with 3 channels, got shape {tuple(array.shape)}"
126
+ )
127
+ if array.dtype != np.uint8:
128
+ raise ValueError(f"ndarray image must have dtype uint8, got {array.dtype}")
129
+ return np.ascontiguousarray(array)
130
+
131
+
132
+ def _decode_pil(source: str | Path | io.BytesIO) -> np.ndarray:
133
+ try:
134
+ from PIL import Image
135
+ except ModuleNotFoundError as exc: # pragma: no cover - exercised only without Pillow
136
+ raise RuntimeError(
137
+ "image decoding requires Pillow; install `tensorplate-python[vision]`"
138
+ ) from exc
139
+ import numpy as np
140
+
141
+ with Image.open(source) as handle:
142
+ return np.asarray(handle.convert("RGB"))
143
+
144
+
145
+ def _resize(
146
+ source: np.ndarray, cfg: PreprocessConfig
147
+ ) -> tuple[np.ndarray, float, float, float, float]:
148
+ import numpy as np
149
+ from PIL import Image
150
+
151
+ src_height, src_width = int(source.shape[0]), int(source.shape[1])
152
+ input_height, input_width = cfg.input_size
153
+ pil = Image.fromarray(source)
154
+ if cfg.letterbox:
155
+ scale = min(input_width / src_width, input_height / src_height)
156
+ new_width = max(1, round(src_width * scale))
157
+ new_height = max(1, round(src_height * scale))
158
+ resized = np.asarray(pil.resize((new_width, new_height), Image.Resampling.BILINEAR))
159
+ canvas = np.full((input_height, input_width, 3), cfg.pad_value, dtype="uint8")
160
+ pad_x = (input_width - new_width) // 2
161
+ pad_y = (input_height - new_height) // 2
162
+ canvas[pad_y : pad_y + new_height, pad_x : pad_x + new_width] = resized
163
+ return canvas, float(scale), float(scale), float(pad_x), float(pad_y)
164
+ resized = np.asarray(pil.resize((input_width, input_height), Image.Resampling.BILINEAR))
165
+ return resized, input_width / src_width, input_height / src_height, 0.0, 0.0
tensorplate/py.typed ADDED
File without changes
tensorplate/serving.py ADDED
@@ -0,0 +1,291 @@
1
+ """Synchronous client for the TensorPlate v0.1 serving HTTP envelope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass
9
+
10
+ from tensorplate.client import (
11
+ DEFAULT_TIMEOUT_S,
12
+ ResolvedEndpoint,
13
+ http_request,
14
+ resolve_serving_url,
15
+ )
16
+ from tensorplate.errors import (
17
+ ErrorCode,
18
+ ProtocolError,
19
+ ServingError,
20
+ UnsupportedSchemaVersionError,
21
+ )
22
+ from tensorplate.tensors import DType, Layout, TensorInput, TensorOutput
23
+
24
+ #: Serving envelope schema_version this SDK speaks.
25
+ SCHEMA_VERSION = "0.1"
26
+
27
+ _HEALTH_PATH = "/health"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Timing:
32
+ """Optional per-request timing reported by the worker."""
33
+
34
+ queue_latency_ns: int | None = None
35
+ execution_latency_ns: int | None = None
36
+ total_latency_ns: int | None = None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class InferResult:
41
+ """Parsed v0.1 ``success`` response."""
42
+
43
+ request_id: str
44
+ outputs: tuple[TensorOutput, ...]
45
+ correlation_id: str | None = None
46
+ timing: Timing | None = None
47
+
48
+ def output(self, name: str) -> TensorOutput:
49
+ """Return the output tensor named ``name`` or raise ``KeyError``."""
50
+ for tensor in self.outputs:
51
+ if tensor.name == name:
52
+ return tensor
53
+ raise KeyError(f"no output named {name!r}")
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class HealthSnapshot:
58
+ """Parsed ``GET /health`` response."""
59
+
60
+ state: str
61
+ endpoint: str
62
+ backend: str
63
+ active_model_id: str | None = None
64
+ last_error_code: ErrorCode | None = None
65
+ last_error_message: str | None = None
66
+ queue_depth: int | None = None
67
+ in_flight: int | None = None
68
+
69
+ @property
70
+ def is_ready(self) -> bool:
71
+ """True only for the ``ready`` state (``degraded`` is not ready)."""
72
+ return self.state == "ready"
73
+
74
+
75
+ class ServingClient:
76
+ """Synchronous client for the v0.1 ``/infer`` serving HTTP envelope.
77
+
78
+ The endpoint is resolved once at construction with the same precedence
79
+ as ``tensorplate infer`` (explicit URL, then CLI profile, then
80
+ read-only agent discovery, then the loopback default). The client is
81
+ model-class-neutral so higher-level helpers can build on it.
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ serving_url: str | None = None,
87
+ *,
88
+ profile: str | None = None,
89
+ config_path: str | None = None,
90
+ timeout: float = DEFAULT_TIMEOUT_S,
91
+ discover: bool = True,
92
+ ) -> None:
93
+ self._endpoint = resolve_serving_url(
94
+ serving_url,
95
+ profile=profile,
96
+ config_path=config_path,
97
+ timeout=timeout,
98
+ discover=discover,
99
+ )
100
+ self._timeout = timeout
101
+
102
+ @property
103
+ def endpoint(self) -> ResolvedEndpoint:
104
+ """The resolved serving endpoint and how it was resolved."""
105
+ return self._endpoint
106
+
107
+ @staticmethod
108
+ def tensor_input(
109
+ name: str,
110
+ data: bytes,
111
+ dtype: DType | str,
112
+ shape: Sequence[int],
113
+ *,
114
+ layout: Layout | str = Layout.ROW_MAJOR,
115
+ ) -> TensorInput:
116
+ """Build a :class:`TensorInput` from raw bytes and metadata."""
117
+ return TensorInput(
118
+ name=name,
119
+ dtype=DType(dtype),
120
+ shape=tuple(shape),
121
+ data=data,
122
+ layout=Layout(layout),
123
+ )
124
+
125
+ def infer(
126
+ self,
127
+ endpoint: str,
128
+ inputs: Sequence[TensorInput],
129
+ *,
130
+ deadline_ms: int | None = None,
131
+ correlation_id: str | None = None,
132
+ ) -> InferResult:
133
+ """Run a single synchronous inference against ``endpoint``.
134
+
135
+ Raises :class:`~tensorplate.errors.ServingError` for a typed
136
+ ``failure`` envelope, :class:`~tensorplate.errors.TransportError`
137
+ / :class:`~tensorplate.errors.RequestTimeoutError` for transport
138
+ failures, and :class:`~tensorplate.errors.ProtocolError` /
139
+ :class:`~tensorplate.errors.UnsupportedSchemaVersionError` for a
140
+ malformed or unsupported response.
141
+ """
142
+ if not endpoint:
143
+ raise ValueError("infer endpoint must be non-empty")
144
+ if not inputs:
145
+ raise ValueError("infer requires at least one input tensor")
146
+ if deadline_ms is not None and (
147
+ not isinstance(deadline_ms, int) or isinstance(deadline_ms, bool) or deadline_ms < 1
148
+ ):
149
+ raise ValueError("deadline_ms must be an integer greater than or equal to 1")
150
+ if correlation_id is not None and not correlation_id:
151
+ raise ValueError("correlation_id must be non-empty when provided")
152
+ request_id = str(uuid.uuid4())
153
+ request_body: dict[str, object] = {
154
+ "schema_version": SCHEMA_VERSION,
155
+ "request_id": request_id,
156
+ "endpoint": endpoint,
157
+ "inputs": [tensor.to_named_input() for tensor in inputs],
158
+ }
159
+ if deadline_ms is not None:
160
+ request_body["deadline_ms"] = deadline_ms
161
+ headers: dict[str, str] = {}
162
+ if correlation_id is not None:
163
+ request_body["metadata"] = {"correlation_id": correlation_id}
164
+ headers["X-Correlation-Id"] = correlation_id
165
+ payload = json.dumps(request_body).encode("utf-8")
166
+ status_code, raw = http_request(
167
+ "POST", self._endpoint.url, body=payload, headers=headers, timeout=self._timeout
168
+ )
169
+ return _parse_infer_response(raw, status_code, self._endpoint.url)
170
+
171
+ def health(self) -> HealthSnapshot:
172
+ """Read ``GET /health`` and parse the worker's readiness snapshot.
173
+
174
+ The body is parsed regardless of the HTTP status (``ready`` and
175
+ ``degraded`` return 200; the rest return 503), so inspect
176
+ :attr:`HealthSnapshot.state`, not just reachability.
177
+ """
178
+ url = f"{self._endpoint.origin}{_HEALTH_PATH}"
179
+ _status_code, raw = http_request("GET", url, timeout=self._timeout)
180
+ payload = _decode_json_object(raw, url)
181
+ _require_supported_schema(payload)
182
+ return _parse_health(payload, url)
183
+
184
+
185
+ def _decode_json_object(raw: bytes, url: str) -> dict[str, object]:
186
+ try:
187
+ parsed = json.loads(raw)
188
+ except ValueError as exc:
189
+ raise ProtocolError(f"serving response from {url!r} is not valid JSON: {exc}") from exc
190
+ if not isinstance(parsed, dict):
191
+ raise ProtocolError(f"serving response from {url!r} is not a JSON object")
192
+ return parsed
193
+
194
+
195
+ def _require_supported_schema(payload: dict[str, object]) -> None:
196
+ version = payload.get("schema_version")
197
+ if version != SCHEMA_VERSION:
198
+ raise UnsupportedSchemaVersionError(
199
+ version if isinstance(version, str) else None, SCHEMA_VERSION
200
+ )
201
+
202
+
203
+ def _parse_infer_response(raw: bytes, status_code: int, url: str) -> InferResult:
204
+ payload = _decode_json_object(raw, url)
205
+ _require_supported_schema(payload)
206
+ status = payload.get("status")
207
+ if status == "failure":
208
+ raise _serving_error(payload)
209
+ if status != "success":
210
+ raise ProtocolError(
211
+ f"serving response from {url!r} has unexpected status {status!r} (HTTP {status_code})"
212
+ )
213
+ outputs_obj = payload.get("outputs")
214
+ if not isinstance(outputs_obj, list) or not outputs_obj:
215
+ raise ProtocolError(f"serving success response from {url!r} is missing non-empty 'outputs'")
216
+ outputs = tuple(TensorOutput.from_named_output(item) for item in outputs_obj)
217
+ request_id = payload.get("request_id")
218
+ if not isinstance(request_id, str):
219
+ raise ProtocolError(f"serving success response from {url!r} is missing 'request_id'")
220
+ correlation_id = payload.get("correlation_id")
221
+ return InferResult(
222
+ request_id=request_id,
223
+ outputs=outputs,
224
+ correlation_id=correlation_id if isinstance(correlation_id, str) else None,
225
+ timing=_parse_timing(payload.get("timing")),
226
+ )
227
+
228
+
229
+ def _serving_error(payload: dict[str, object]) -> ServingError:
230
+ request_id = payload.get("request_id")
231
+ request_id = request_id if isinstance(request_id, str) else None
232
+ error_obj = payload.get("error")
233
+ if not isinstance(error_obj, dict):
234
+ return ServingError(
235
+ ErrorCode.INTERNAL,
236
+ "serving worker returned a failure without a typed error",
237
+ request_id=request_id,
238
+ )
239
+ try:
240
+ code = ErrorCode(error_obj.get("code"))
241
+ except ValueError:
242
+ code = ErrorCode.INTERNAL
243
+ message_obj = error_obj.get("message")
244
+ context_obj = error_obj.get("context")
245
+ return ServingError(
246
+ code,
247
+ message_obj if isinstance(message_obj, str) else "serving worker returned an error",
248
+ context=context_obj if isinstance(context_obj, str) else None,
249
+ request_id=request_id,
250
+ )
251
+
252
+
253
+ def _parse_health(payload: dict[str, object], url: str) -> HealthSnapshot:
254
+ state = payload.get("state")
255
+ if not isinstance(state, str):
256
+ raise ProtocolError(f"/health response from {url!r} is missing 'state'")
257
+ endpoint = payload.get("endpoint")
258
+ backend = payload.get("backend")
259
+ code_obj = payload.get("last_error_code")
260
+ try:
261
+ last_error_code = ErrorCode(code_obj) if code_obj is not None else None
262
+ except ValueError:
263
+ last_error_code = None
264
+ return HealthSnapshot(
265
+ state=state,
266
+ endpoint=endpoint if isinstance(endpoint, str) else "",
267
+ backend=backend if isinstance(backend, str) else "",
268
+ active_model_id=_optional_str(payload.get("active_model_id")),
269
+ last_error_code=last_error_code,
270
+ last_error_message=_optional_str(payload.get("last_error_message")),
271
+ queue_depth=_optional_int(payload.get("queue_depth")),
272
+ in_flight=_optional_int(payload.get("in_flight")),
273
+ )
274
+
275
+
276
+ def _parse_timing(obj: object) -> Timing | None:
277
+ if not isinstance(obj, dict):
278
+ return None
279
+ return Timing(
280
+ queue_latency_ns=_optional_int(obj.get("queue_latency_ns")),
281
+ execution_latency_ns=_optional_int(obj.get("execution_latency_ns")),
282
+ total_latency_ns=_optional_int(obj.get("total_latency_ns")),
283
+ )
284
+
285
+
286
+ def _optional_int(value: object) -> int | None:
287
+ return value if isinstance(value, int) else None
288
+
289
+
290
+ def _optional_str(value: object) -> str | None:
291
+ return value if isinstance(value, str) else None