boxes-client 0.1.1__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,40 @@
1
+ """boxes_client -- a thin client for calling deployed AI "boxes" by IP:port.
2
+
3
+ A *box* is one of the gRPC services in ``/images/`` built to the shared
4
+ ``pipeline.PipelineService.Process(Envelope) -> Envelope`` interface. Point
5
+ this client at any one by address and send an ``Envelope``::
6
+
7
+ from boxes_client import Box
8
+ b = Box("localhost:8061") # local box ("10.0.0.5:8061" for remote)
9
+
10
+ # any box -- generic, box-agnostic (data + config dicts)
11
+ res = b.run(data={"sentences": ["hello", "world"]},
12
+ config={"my_box": {"command": "do_thing"}})
13
+
14
+ The core (``Box`` / ``Result`` / envelope builders) knows **no box**. There is
15
+ also an *optional convenience layer* for specific boxes -- e.g. the tapnext
16
+ point-tracking one-liner -- kept separate on purpose::
17
+
18
+ from boxes_client import trace # optional, tapnext-only
19
+ res = trace(b, images=["frame.jpg"], grid_size=30)
20
+
21
+ No registry, no central server: the client connects directly to the box. Local
22
+ and remote boxes are the same call -- just change the address.
23
+ """
24
+
25
+ import os as _os
26
+ import sys as _sys
27
+
28
+ # Vendored generated proto modules import each other and ``aux`` as top-level
29
+ # names; make that directory reachable before they are imported.
30
+ _PB_DIR = _os.path.join(_os.path.dirname(__file__), "_pb")
31
+ if _os.path.isdir(_PB_DIR) and _PB_DIR not in _sys.path:
32
+ _sys.path.append(_PB_DIR)
33
+
34
+ from .box import Box
35
+ from .result import Result
36
+ from .envelope import load
37
+ from .conveniences import trace # optional, convenience layer (not core)
38
+
39
+ __all__ = ["Box", "Result", "load", "trace"]
40
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ import os as _os
2
+ import sys as _sys
3
+
4
+ # The generated modules import `pipeline_pb2`, `aux` as top-level modules,
5
+ # so this directory has to be on sys.path before they are imported.
6
+ _HERE = _os.path.dirname(__file__)
7
+ if _HERE not in _sys.path:
8
+ _sys.path.append(_HERE)
@@ -0,0 +1,37 @@
1
+ import pipeline_pb2
2
+
3
+ def wrap_value(obj):
4
+ """Wrap a Python object into a pipeline.Value"""
5
+ if isinstance(obj, float):
6
+ return pipeline_pb2.Value(f=obj)
7
+ elif isinstance(obj, str):
8
+ return pipeline_pb2.Value(s=obj)
9
+ elif isinstance(obj, bytes):
10
+ return pipeline_pb2.Value(b=obj)
11
+
12
+ elif isinstance(obj, list):
13
+ if all(isinstance(v, float) for v in obj):
14
+ return pipeline_pb2.Value(ff=pipeline_pb2.FloatList(values=obj))
15
+ elif all(isinstance(v, str) for v in obj):
16
+ return pipeline_pb2.Value(ss=pipeline_pb2.StringList(values=obj))
17
+ elif all(isinstance(v, (bytes, bytearray)) for v in obj):
18
+ return pipeline_pb2.Value(bb=pipeline_pb2.BytesList(values=obj))
19
+ raise TypeError(f"Cannot wrap object of type {type(obj)}: {obj}")
20
+
21
+
22
+ def unwrap_value(val: pipeline_pb2.Value):
23
+ """Unwrap a pipeline.Value into a plain Python object"""
24
+ kind = val.WhichOneof("kind")
25
+ if kind == "f":
26
+ return val.f
27
+ if kind == "s":
28
+ return val.s
29
+ if kind == "b":
30
+ return val.b
31
+ if kind == "ff":
32
+ return list(val.ff.values)
33
+ if kind == "ss":
34
+ return list(val.ss.values)
35
+ if kind == "bb":
36
+ return list(val.bb.values)
37
+ return None
@@ -0,0 +1,50 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: pipeline.proto
5
+ # Protobuf Python Version: 7.35.1
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 7,
15
+ 35,
16
+ 1,
17
+ '',
18
+ 'pipeline.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+
26
+
27
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0epipeline.proto\x12\x08pipeline\"\x1b\n\tFloatList\x12\x0e\n\x06values\x18\x01 \x03(\x02\"\x1c\n\nStringList\x12\x0e\n\x06values\x18\x01 \x03(\t\"\x1b\n\tBytesList\x12\x0e\n\x06values\x18\x01 \x03(\x0c\"\xa0\x01\n\x05Value\x12\x0b\n\x01\x62\x18\x01 \x01(\x0cH\x00\x12\x0b\n\x01s\x18\x02 \x01(\tH\x00\x12\x0b\n\x01\x66\x18\x05 \x01(\x02H\x00\x12!\n\x02\x62\x62\x18\x06 \x01(\x0b\x32\x13.pipeline.BytesListH\x00\x12\"\n\x02ss\x18\x07 \x01(\x0b\x32\x14.pipeline.StringListH\x00\x12!\n\x02\x66\x66\x18\n \x01(\x0b\x32\x13.pipeline.FloatListH\x00\x42\x06\n\x04kind\"\x89\x01\n\x08\x45nvelope\x12\x13\n\x0b\x63onfig_json\x18\x01 \x01(\t\x12*\n\x04\x64\x61ta\x18\x02 \x03(\x0b\x32\x1c.pipeline.Envelope.DataEntry\x1a<\n\tDataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1e\n\x05value\x18\x02 \x01(\x0b\x32\x0f.pipeline.Value:\x02\x38\x01\x32\x44\n\x0fPipelineService\x12\x31\n\x07Process\x12\x12.pipeline.Envelope\x1a\x12.pipeline.Envelopeb\x06proto3')
28
+
29
+ _globals = globals()
30
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'pipeline_pb2', _globals)
32
+ if not _descriptor._USE_C_DESCRIPTORS:
33
+ DESCRIPTOR._loaded_options = None
34
+ _globals['_ENVELOPE_DATAENTRY']._loaded_options = None
35
+ _globals['_ENVELOPE_DATAENTRY']._serialized_options = b'8\001'
36
+ _globals['_FLOATLIST']._serialized_start=28
37
+ _globals['_FLOATLIST']._serialized_end=55
38
+ _globals['_STRINGLIST']._serialized_start=57
39
+ _globals['_STRINGLIST']._serialized_end=85
40
+ _globals['_BYTESLIST']._serialized_start=87
41
+ _globals['_BYTESLIST']._serialized_end=114
42
+ _globals['_VALUE']._serialized_start=117
43
+ _globals['_VALUE']._serialized_end=277
44
+ _globals['_ENVELOPE']._serialized_start=280
45
+ _globals['_ENVELOPE']._serialized_end=417
46
+ _globals['_ENVELOPE_DATAENTRY']._serialized_start=357
47
+ _globals['_ENVELOPE_DATAENTRY']._serialized_end=417
48
+ _globals['_PIPELINESERVICE']._serialized_start=419
49
+ _globals['_PIPELINESERVICE']._serialized_end=487
50
+ # @@protoc_insertion_point(module_scope)
@@ -0,0 +1,100 @@
1
+ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
2
+ """Client and server classes corresponding to protobuf-defined services."""
3
+ import grpc
4
+ import warnings
5
+
6
+ import pipeline_pb2 as pipeline__pb2
7
+
8
+ GRPC_GENERATED_VERSION = '1.83.0'
9
+ GRPC_VERSION = grpc.__version__
10
+ _version_not_supported = False
11
+
12
+ try:
13
+ from grpc._utilities import first_version_is_lower
14
+ _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
15
+ except ImportError:
16
+ _version_not_supported = True
17
+
18
+ if _version_not_supported:
19
+ raise RuntimeError(
20
+ f'The grpc package installed is at version {GRPC_VERSION},'
21
+ + ' but the generated code in pipeline_pb2_grpc.py depends on'
22
+ + f' grpcio>={GRPC_GENERATED_VERSION}.'
23
+ + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
24
+ + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
25
+ )
26
+
27
+
28
+ class PipelineServiceStub:
29
+ """Universal service interface
30
+ """
31
+
32
+ def __init__(self, channel):
33
+ """Constructor.
34
+
35
+ Args:
36
+ channel: A grpc.Channel.
37
+ """
38
+ self.Process = channel.unary_unary(
39
+ '/pipeline.PipelineService/Process',
40
+ request_serializer=pipeline__pb2.Envelope.SerializeToString,
41
+ response_deserializer=pipeline__pb2.Envelope.FromString,
42
+ _registered_method=True)
43
+
44
+
45
+ class PipelineServiceServicer:
46
+ """Universal service interface
47
+ """
48
+
49
+ def Process(self, request, context):
50
+ """Missing associated documentation comment in .proto file."""
51
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
52
+ context.set_details('Method not implemented!')
53
+ raise NotImplementedError('Method not implemented!')
54
+
55
+
56
+ def add_PipelineServiceServicer_to_server(servicer, server):
57
+ rpc_method_handlers = {
58
+ 'Process': grpc.unary_unary_rpc_method_handler(
59
+ servicer.Process,
60
+ request_deserializer=pipeline__pb2.Envelope.FromString,
61
+ response_serializer=pipeline__pb2.Envelope.SerializeToString,
62
+ ),
63
+ }
64
+ generic_handler = grpc.method_handlers_generic_handler(
65
+ 'pipeline.PipelineService', rpc_method_handlers)
66
+ server.add_generic_rpc_handlers((generic_handler,))
67
+ server.add_registered_method_handlers('pipeline.PipelineService', rpc_method_handlers)
68
+
69
+
70
+ # This class is part of an EXPERIMENTAL API.
71
+ class PipelineService:
72
+ """Universal service interface
73
+ """
74
+
75
+ @staticmethod
76
+ def Process(request,
77
+ target,
78
+ options=(),
79
+ channel_credentials=None,
80
+ call_credentials=None,
81
+ insecure=False,
82
+ compression=None,
83
+ wait_for_ready=None,
84
+ timeout=None,
85
+ metadata=None):
86
+ return grpc.experimental.unary_unary(
87
+ request,
88
+ target,
89
+ '/pipeline.PipelineService/Process',
90
+ pipeline__pb2.Envelope.SerializeToString,
91
+ pipeline__pb2.Envelope.FromString,
92
+ options,
93
+ channel_credentials,
94
+ insecure,
95
+ call_credentials,
96
+ compression,
97
+ wait_for_ready,
98
+ timeout,
99
+ metadata,
100
+ _registered_method=True)
@@ -0,0 +1,18 @@
1
+ """Loading of the vendored generated proto modules.
2
+
3
+ ``boxes_client._pb.__init__`` puts its own directory on ``sys.path`` so the
4
+ generated modules (which import each other and ``aux`` as top-level) resolve.
5
+ Importing the package first guarantees that, then we grab the top-level modules.
6
+ """
7
+
8
+ import importlib
9
+
10
+
11
+ def get():
12
+ """Return ``(pipeline_pb2, pipeline_pb2_grpc, aux)``."""
13
+ import boxes_client._pb # noqa: F401 -> runs __init__, fixes sys.path
14
+
15
+ pb2 = importlib.import_module("pipeline_pb2")
16
+ pb2_grpc = importlib.import_module("pipeline_pb2_grpc")
17
+ aux = importlib.import_module("aux")
18
+ return pb2, pb2_grpc, aux
boxes_client/box.py ADDED
@@ -0,0 +1,211 @@
1
+ """``Box`` -- a thin client for one deployed box (a box = a gRPC AI service).
2
+
3
+ Give it an address (``host:port``) and it can send an ``Envelope`` and get a
4
+ decoded ``Result`` back via the shared ``pipeline.PipelineService`` interface.
5
+
6
+ Local and remote boxes are identical: ``Box("localhost:8061")`` vs
7
+ ``Box("10.0.0.5:8061")``. No registry, no central server -- the client dials
8
+ the box directly (boxes are push-style servers), which preserves the
9
+ distributed nature of the fleet.
10
+
11
+ The core stays **box-agnostic**: it knows how to build and send an ``Envelope``
12
+ and read a ``Result`` back, but it knows no box, field, or model. Box-specific
13
+ conveniences (one per box, e.g. a point-tracking helper) live in
14
+ :mod:`boxes_client.conveniences`, are built purely on top of :meth:`Box.run`,
15
+ and are never imported by the core.
16
+
17
+ Core call surface
18
+ -----------------
19
+ :meth:`Box.run` -- the generic workhorse: ``run(data=..., config=..., method="Process")``.
20
+ No assumption about field names or payload types.
21
+ :meth:`Box.reset` -- clear server-side state (``{"<box>": {"command": "reset"}}``).
22
+ :meth:`Box.call` -- send an already-built ``Envelope`` via ``Process``.
23
+ :meth:`Box.info` -- reachability + gRPC-reflection self-description probe.
24
+ """
25
+
26
+
27
+ from typing import Any, Dict, List, Optional, Union
28
+
29
+ import grpc
30
+
31
+ from ._pb_loader import get as _get_pb
32
+ from . import envelope as _env
33
+ from .result import Result
34
+
35
+ _SERVICE = "pipeline.PipelineService"
36
+ _DEFAULT_PORT = 8061
37
+
38
+
39
+ class Box:
40
+ """A client for a single box.
41
+
42
+ Parameters
43
+ ----------
44
+ address:
45
+ ``"host:port"`` or just ``"host"`` (defaults to port 8061).
46
+ port:
47
+ Optional explicit port (alternative to the ``":"`` in the address).
48
+ timeout:
49
+ Per-RPC timeout in seconds (default 600).
50
+ config_key:
51
+ Optional name of the box's config section. Needed only by
52
+ :meth:`reset` (and ``run(reset_first=True)``) so it knows which section
53
+ to reset. The generic :meth:`run` does not need it -- you pass the full
54
+ ``config`` dict yourself. Defaults to ``None``: the core assumes no box.
55
+ Pass e.g. ``config_key="tapnext"`` when you will reset a stateful box.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ address: str,
61
+ port: Optional[int] = None,
62
+ timeout: float = 600,
63
+ config_key: Optional[str] = None,
64
+ ):
65
+ self.host, self.port = _split_address(address, port)
66
+ self.timeout = timeout
67
+ self.config_key = config_key
68
+
69
+ self._channel = grpc.insecure_channel(
70
+ f"{self.host}:{self.port}",
71
+ options=[
72
+ ("grpc.max_send_message_length", -1),
73
+ ("grpc.max_receive_message_length", -1),
74
+ ],
75
+ )
76
+ pb2, pb2_grpc, _aux = _get_pb()
77
+ self._stub = pb2_grpc.PipelineServiceStub(self._channel)
78
+
79
+ # ------------------------------------------------------------------ utils
80
+ def close(self):
81
+ try:
82
+ self._channel.close()
83
+ except Exception:
84
+ pass
85
+
86
+ def __enter__(self):
87
+ return self
88
+
89
+ def __exit__(self, *exc):
90
+ self.close()
91
+
92
+ # ------------------------------------------------------------- discover
93
+ def info(self, timeout: float = 10) -> Dict[str, Any]:
94
+ """Ask the box (via gRPC reflection) what it exposes.
95
+
96
+ Returns ``{"service", "methods", "reflection", "reachable"}``.
97
+ ``reflection`` is ``False`` if the box does not serve reflection (the
98
+ client still works; it just cannot self-describe).
99
+ """
100
+ out = {"service": _SERVICE, "methods": [], "reflection": False,
101
+ "reachable": False}
102
+ try:
103
+ grpc.channel_ready_future(self._channel).result(timeout=timeout)
104
+ out["reachable"] = True
105
+ except Exception:
106
+ return out
107
+
108
+ try:
109
+ from grpc_reflection.v1alpha import (
110
+ reflection_pb2,
111
+ reflection_pb2_grpc,
112
+ )
113
+ except ImportError:
114
+ return out
115
+
116
+ stub = reflection_pb2_grpc.ServerReflectionStub(self._channel)
117
+ try:
118
+ # Bidirectional-streaming RPC: one request -> (take the first) response.
119
+ def _reqs():
120
+ yield reflection_pb2.ServerReflectionRequest(list_services="*")
121
+ resp = next(stub.ServerReflectionInfo(iter(_reqs()), timeout=timeout))
122
+ services = [e.name for e in resp.list_services_response.service]
123
+ if _SERVICE in services:
124
+ out["reflection"] = True
125
+ out["methods"] = ["Process"]
126
+ else:
127
+ out["methods"] = services
128
+ except Exception:
129
+ out["reflection"] = False
130
+ return out
131
+
132
+ # ---------------------------------------------------------------- internal
133
+ def _send(self, envelope, method: str) -> Result:
134
+ """Low-level: send ``envelope`` via the named ``method`` on the box."""
135
+ fn = getattr(self._stub, method, None)
136
+ if not callable(fn):
137
+ known = sorted(n for n in dir(self._stub) if not n.startswith("_"))
138
+ raise AttributeError(
139
+ f"Box has no RPC method {method!r}. "
140
+ f"This stub defines: {known} (only 'Process' is guaranteed; "
141
+ f"other methods depend on the box's own .proto)."
142
+ )
143
+ resp = fn(envelope, timeout=self.timeout)
144
+ return Result.from_envelope(resp)
145
+
146
+ # ------------------------------------------------------------------ calls
147
+ def run(
148
+ self,
149
+ data: Optional[Dict[str, Any]] = None,
150
+ config: Optional[Dict[str, Any]] = None,
151
+ method: str = "Process",
152
+ reset_first: bool = False,
153
+ ) -> Result:
154
+ """Generic entry point.
155
+
156
+ Parameters
157
+ ----------
158
+ data:
159
+ Mapping of ``field_name -> value`` written into ``Envelope.data``.
160
+ Values follow :mod:`boxes_client.envelope` coercion rules:
161
+ ``bytes`` / ``pathlib.Path`` -> bytes; ``str`` -> literal string;
162
+ ``int`` -> float; lists of those -> ``BytesList`` / ``StringList``
163
+ / ``FloatList``.
164
+ config:
165
+ The box-specific control payload (dict) serialized to
166
+ ``Envelope.config_json``. Shape depends on the box.
167
+ method:
168
+ RPC name to invoke (default ``"Process"``). Boxes may expose extra
169
+ methods (e.g. opencv's ``similarity_check``, yolo's
170
+ ``DetectSequence`` / ``TrackSequence`` / ``AllProcessing``).
171
+ reset_first:
172
+ If ``True``, send a tapnext-style reset (config-only
173
+ ``{config_key: {"command": "reset"}}``) on ``Process`` first.
174
+ Useful for stateful boxes; ignored by stateless ones.
175
+ """
176
+ if reset_first:
177
+ self.reset()
178
+ return self._send(_env.build(data, config), method)
179
+
180
+ def reset(self, config_key: Optional[str] = None) -> Result:
181
+ """Best-effort clear of server-side state.
182
+
183
+ Sends a config-only ``Process`` call with ``{key: {"command": "reset"}}``
184
+ where ``key`` is ``config_key`` (if given) or the box's ``config_key``
185
+ from the constructor. Boxes treat it as a hard state reset; boxes that
186
+ don't recognize the command typically ignore it.
187
+ """
188
+ key = config_key or self.config_key
189
+ if not key:
190
+ raise ValueError(
191
+ "Box.reset(): no config_key given. Construct the Box with "
192
+ "config_key=<box> or call Box.reset(config_key=<box>).")
193
+ return self._send(_env.reset_envelope(key), "Process")
194
+
195
+ # Back-compat alias: low-level "I already built the Envelope" call.
196
+ def call(self, envelope: Any) -> Result:
197
+ return self._send(envelope, "Process")
198
+
199
+
200
+ def _split_address(address: str, port: Optional[int]) -> tuple:
201
+ address = (address or "").strip()
202
+ if ":" not in address:
203
+ return (address or "localhost"), (int(port) if port is not None else _DEFAULT_PORT)
204
+ host, _, maybe_port = address.rpartition(":")
205
+ if not host:
206
+ host = "localhost"
207
+ if maybe_port.isdigit():
208
+ return host, int(maybe_port)
209
+ if port is not None:
210
+ return host, int(port)
211
+ return host, _DEFAULT_PORT
boxes_client/codec.py ADDED
@@ -0,0 +1,113 @@
1
+ """Named payload codecs: the *declared* decoding path (see ``CODECS.md``).
2
+
3
+ The client is smart about *shape* and dumb about *content*: a box says how
4
+ its payload is encoded by adding an ``"encoding"`` field to its response
5
+ ``config_json`` (a codec name for every ``bytes`` field, or a
6
+ ``{field_name: codec_name}`` map for mixed responses). The core only knows the
7
+ generic ``encoding`` keyword and this registry of codecs — **never a box
8
+ name**.
9
+
10
+ Codec vocabulary (all pure ``bytes -> object``):
11
+
12
+ ================== =========================================================
13
+ name behavior
14
+ ================== =========================================================
15
+ ``identity`` raw bytes, unchanged (the default)
16
+ ``json`` UTF-8 JSON document -> ``list``/``dict``/scalar
17
+ ``torch`` ``torch.save`` bytes -> the unpickled object
18
+ (Tensor or dict of Tensors); needs ``torch``
19
+ ``numpy`` raw float32 buffer -> ``np.ndarray``
20
+ ``zstd_pickle`` ``zstd.compress(pickle.dumps(obj))`` -> ``obj``;
21
+ needs ``zstandard``
22
+ ================== =========================================================
23
+
24
+ A codec whose library is missing (``torch`` / ``zstandard``) degrades to
25
+ ``identity`` (raw bytes) plus a warning — **never raises**: the client always
26
+ returns *something* usable.
27
+ """
28
+
29
+ import io
30
+ import json
31
+ import pickle
32
+ import warnings
33
+
34
+ import numpy as np
35
+
36
+ __all__ = ["CODECS", "decode_with"]
37
+
38
+
39
+ def _decode_identity(buf: bytes) -> bytes:
40
+ return buf
41
+
42
+
43
+ def _decode_json(buf: bytes):
44
+ """Strict: declared ``json`` fields must parse (or we degrade in
45
+ ``decode_with``); no "does it look like JSON?" sniffing."""
46
+ s = buf.decode("utf-8")
47
+ return json.loads(s)
48
+
49
+
50
+ def _decode_torch(buf: bytes):
51
+ """``torch.save`` blob -> whatever object was saved (Tensor or dict).
52
+
53
+ Propagates (rather than hides) decode failures so ``decode_with`` can
54
+ degrade with a warning instead of silently guessing."""
55
+ import torch # noqa: W061 -- ImportError here means the lib is missing
56
+
57
+ return torch.load(io.BytesIO(buf), weights_only=False, map_location="cpu")
58
+
59
+
60
+ def _decode_numpy(buf: bytes) -> "np.ndarray":
61
+ """Raw numeric buffer (float32, per the opencv_box ``np_to_bytes``
62
+ contract)."""
63
+ return np.frombuffer(buf, dtype=np.float32)
64
+
65
+
66
+ def _decode_zstd_pickle(buf: bytes):
67
+ """``zstandard`` compress + ``pickle`` -> the decoded Python object
68
+ (normally a list). The lang_segm -> folder_wd cross-box contract."""
69
+ import zstandard # noqa: W061 -- ImportError here means the lib is missing
70
+
71
+ return pickle.loads(zstandard.ZstdDecompressor().decompress(buf))
72
+
73
+
74
+ #: The named registry: generic codec name -> pure ``bytes -> object`` function.
75
+ #: No box name appears here or below; boxes *select* a codec by declaring it.
76
+ CODECS = {
77
+ "identity": _decode_identity,
78
+ "json": _decode_json,
79
+ "torch": _decode_torch,
80
+ "numpy": _decode_numpy,
81
+ "zstd_pickle": _decode_zstd_pickle,
82
+ }
83
+
84
+
85
+ def decode_with(payload, name):
86
+ """Apply a *named* codec to ``payload`` (raw ``bytes``/``bytearray``).
87
+
88
+ - ``name`` is ``None``/``"identity"`` -> raw bytes, unchanged (the
89
+ agnostic default).
90
+ - unknown name, or a codec whose library is missing, or a payload the
91
+ codec cannot parse -> a warning is emitted and the **raw bytes** are
92
+ returned. ``decode_with`` never raises.
93
+ """
94
+ if name is None or name == "identity":
95
+ return bytes(payload)
96
+ fn = CODECS.get(name)
97
+ if fn is None:
98
+ warnings.warn(
99
+ f"codec {name!r} is not a known codec (known: {sorted(CODECS)}); "
100
+ "returning raw bytes",
101
+ stacklevel=2,
102
+ )
103
+ return bytes(payload)
104
+ try:
105
+ return fn(payload)
106
+ except Exception as e:
107
+ kind = "library is not installed" if isinstance(e, ImportError) else "decode failed"
108
+ warnings.warn(
109
+ f"codec {name!r}: {kind} ({type(e).__name__}: {e}); "
110
+ "returning raw bytes",
111
+ stacklevel=2,
112
+ )
113
+ return bytes(payload)
@@ -0,0 +1,102 @@
1
+ """Box-specific *conveniences* -- NOT part of the agnostic core.
2
+
3
+ The core of ``boxes_client`` (``box.py`` / ``envelope.py`` / ``result.py`` /
4
+ ``decode_util.py``) is deliberately box-agnostic: it only knows *how* to build
5
+ an ``Envelope`` and send it over the shared ``PipelineService`` interface, and
6
+ *how* to read a ``Result`` back. It knows **no box name, field name, or model**.
7
+
8
+ This module is the opposite, on purpose: it encodes knowledge about a *specific*
9
+ box and only composes the generic ``Box.run`` / ``Box.reset`` primitives. The
10
+ tapnext point-tracking helper :func:`trace` lives here.
11
+
12
+ If you add a convenience for another box (``segment``, ``embed``, ``detect``,
13
+ ...), put it here or in a sibling ``conveniences/<box>.py`` -- **never** in
14
+ ``box.py`` -- so the core stays agnostic and each box's sugar is isolated,
15
+ visible, and easy to delete.
16
+ """
17
+
18
+ from typing import Any, List, Sequence, Union
19
+ import pathlib
20
+
21
+ __all__ = ["trace", "load_images"]
22
+
23
+
24
+ def load_images(images: Union[str, bytes, "pathlib.PurePath", Sequence]) -> List[bytes]:
25
+ """Normalize an ``images`` argument into a list of ``bytes``.
26
+
27
+ ``trace`` is the *image-box* convenience, so a bare ``str`` here is treated
28
+ as a **local file path** to serialize (distinct from the generic ``run``
29
+ contract where ``str`` means a literal string). Accepts:
30
+
31
+ * a single path (``str``/``pathlib.Path``) or pre-encoded ``bytes``
32
+ * a list of any of the above
33
+ """
34
+ if images is None:
35
+ return []
36
+ if isinstance(images, (str, bytes, bytearray, pathlib.PurePath)):
37
+ images = [images]
38
+ out: List[bytes] = []
39
+ for item in images:
40
+ if isinstance(item, (bytes, bytearray, memoryview)):
41
+ out.append(bytes(item))
42
+ elif isinstance(item, (str, pathlib.PurePath)):
43
+ out.append(pathlib.Path(item).read_bytes())
44
+ else:
45
+ raise TypeError(
46
+ f"Unsupported image element: {type(item)!r}. "
47
+ "Pass a path (str/pathlib.Path), pre-encoded bytes, or a list of those."
48
+ )
49
+ return out
50
+
51
+
52
+ def trace(
53
+ box,
54
+ images: Union[str, bytes, "pathlib.PurePath", Sequence],
55
+ *,
56
+ grid_size: int = None,
57
+ reset_first: bool = True,
58
+ config_key: str = "tapnext",
59
+ **params: Any,
60
+ ):
61
+ """Point-tracking convenience for the **tapnext** box.
62
+
63
+ Built purely on the generic core -- it is equivalent to::
64
+
65
+ box.run(data={"images": [bytes, ...]},
66
+ config={config_key: {"command": "track", "parameters": {...}}},
67
+ method="Process")
68
+
69
+ with an optional preceding ``box.reset(config_key)`` (tapnext *accumulates*
70
+ tracks across sequential requests, so a reset keeps a one-shot clean).
71
+
72
+ Parameters
73
+ ----------
74
+ box:
75
+ A :class:`boxes_client.Box` pointed at a tapnext box.
76
+ images:
77
+ A single local path / pre-encoded bytes, or a list of either.
78
+ grid_size:
79
+ TAPNext grid size (added to ``parameters``).
80
+ reset_first:
81
+ Send ``reset`` first (default ``True``).
82
+ config_key:
83
+ The box's config section name (default ``"tapnext"``).
84
+ **params:
85
+ Extra ``parameters`` (e.g. ``threshold=...``).
86
+ """
87
+ images_bytes = load_images(images)
88
+ if not images_bytes:
89
+ raise ValueError("trace(): no images provided")
90
+
91
+ parameters: Any = dict(params or {})
92
+ if grid_size is not None:
93
+ parameters["grid_size"] = int(grid_size)
94
+
95
+ if reset_first:
96
+ box.reset(config_key)
97
+
98
+ return box.run(
99
+ data={"images": images_bytes},
100
+ config={config_key: {"command": "track", "parameters": parameters}},
101
+ method="Process",
102
+ )