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.
- boxes_client/__init__.py +40 -0
- boxes_client/_pb/__init__.py +8 -0
- boxes_client/_pb/aux.py +37 -0
- boxes_client/_pb/pipeline_pb2.py +50 -0
- boxes_client/_pb/pipeline_pb2_grpc.py +100 -0
- boxes_client/_pb_loader.py +18 -0
- boxes_client/box.py +211 -0
- boxes_client/codec.py +113 -0
- boxes_client/conveniences.py +102 -0
- boxes_client/decode_util.py +106 -0
- boxes_client/envelope.py +115 -0
- boxes_client/result.py +146 -0
- boxes_client-0.1.1.dist-info/METADATA +257 -0
- boxes_client-0.1.1.dist-info/RECORD +17 -0
- boxes_client-0.1.1.dist-info/WHEEL +5 -0
- boxes_client-0.1.1.dist-info/licenses/LICENSE +674 -0
- boxes_client-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Best-effort decoding of Envelope data values — the *legacy auto* path.
|
|
2
|
+
|
|
3
|
+
Boxes send heavy payloads as raw ``bytes``. The old client *guessed* the
|
|
4
|
+
encoding; that guessing is kept only as a **legacy fallback** so
|
|
5
|
+
un-migrated boxes keep working. The documented path is the box-declared
|
|
6
|
+
``"encoding"`` keyword decoded by the named codecs in
|
|
7
|
+
:mod:`boxes_client.codec` (see ``CODECS.md``): when a field has a declared
|
|
8
|
+
codec, :mod:`boxes_client.result` uses :func:`codec.decode_with` directly
|
|
9
|
+
and this guessing never runs.
|
|
10
|
+
|
|
11
|
+
Guess chain (legacy): JSON (if it looks like JSON) -> torch (if installed)
|
|
12
|
+
-> numpy (if 4-byte aligned and not an image) -> raw ``bytes``. Nothing here
|
|
13
|
+
raises: the client always returns *something*.
|
|
14
|
+
|
|
15
|
+
.. note::
|
|
16
|
+
The guessing is imperfect: the numpy branch fires on *any*
|
|
17
|
+
4-byte-aligned blob and can return a plausible-looking but wrong float
|
|
18
|
+
array. If you control the box, declare ``"encoding"`` in its response
|
|
19
|
+
config instead of relying on guessing.
|
|
20
|
+
|
|
21
|
+
The codec *bodies* live once in :mod:`boxes_client.codec`; this module
|
|
22
|
+
wraps them with the legacy "try it, or fall through" behaviour.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .codec import _decode_json, _decode_numpy, _decode_torch # noqa: F401 (shared bodies / back-compat)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _try_numpy(buf: bytes):
|
|
29
|
+
try:
|
|
30
|
+
return _decode_numpy(buf)
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
try:
|
|
34
|
+
# legacy extra: the uint8 fallback kept for pre-declaration behaviour
|
|
35
|
+
import numpy as np
|
|
36
|
+
arr = np.frombuffer(buf, dtype=np.uint8)
|
|
37
|
+
if arr.nbytes == 0:
|
|
38
|
+
return None
|
|
39
|
+
return arr
|
|
40
|
+
except Exception:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _try_torch(buf: bytes):
|
|
45
|
+
"""Legacy: only accepts a plain Tensor payload (the original contract);
|
|
46
|
+
dict-of-tensors go through the declared ``torch`` codec instead."""
|
|
47
|
+
try:
|
|
48
|
+
import torch
|
|
49
|
+
except ImportError:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
obj = _decode_torch(buf)
|
|
53
|
+
if isinstance(obj, torch.Tensor):
|
|
54
|
+
return obj
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _try_json(buf: bytes):
|
|
61
|
+
try:
|
|
62
|
+
s = buf.decode("utf-8")
|
|
63
|
+
except Exception:
|
|
64
|
+
return None
|
|
65
|
+
if not s or s[0] not in "{[":
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
return _decode_json(buf)
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def decode_payload(buf):
|
|
74
|
+
"""Return the decoded value of ``buf``.
|
|
75
|
+
|
|
76
|
+
Order (legacy fallback only — boxes are expected to declare
|
|
77
|
+
``"encoding"`` in the response config instead):
|
|
78
|
+
JSON (if it looks like it) -> torch (tapnext / vggt) -> numpy
|
|
79
|
+
(opencv_box ``np_to_bytes``) -> raw ``bytes``.
|
|
80
|
+
Nothing raises: the client always returns *something*.
|
|
81
|
+
"""
|
|
82
|
+
if buf is None:
|
|
83
|
+
return None
|
|
84
|
+
if not isinstance(buf, (bytes, bytearray, memoryview)):
|
|
85
|
+
return buf
|
|
86
|
+
buf = bytes(buf)
|
|
87
|
+
|
|
88
|
+
# JSON first: a JSON document is never a valid torch/numpy buffer.
|
|
89
|
+
# _try_json only fires when the first byte is '{' or '[' — cheap and safe.
|
|
90
|
+
j = _try_json(buf)
|
|
91
|
+
if j is not None:
|
|
92
|
+
return j
|
|
93
|
+
|
|
94
|
+
# torch next — tapnext / vggt use torch.save.
|
|
95
|
+
t = _try_torch(buf)
|
|
96
|
+
if t is not None:
|
|
97
|
+
return t
|
|
98
|
+
|
|
99
|
+
# numpy next — opencv_box uses np_to_bytes; only attempt when the size is
|
|
100
|
+
# at least one element and not clearly a JPEG.
|
|
101
|
+
if len(buf) >= 4 and len(buf) % 4 == 0 and buf[:2] not in (b"\xff\xd8", b"\x89P"):
|
|
102
|
+
arr = _try_numpy(buf)
|
|
103
|
+
if arr is not None:
|
|
104
|
+
return arr
|
|
105
|
+
|
|
106
|
+
return buf
|
boxes_client/envelope.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Envelope builders for the shared ``pipeline.PipelineService`` boxes.
|
|
2
|
+
|
|
3
|
+
Every box in the *envelope family* (tapnext, yolo, vggt, opencv_box, lang_segm,
|
|
4
|
+
...) speaks::
|
|
5
|
+
|
|
6
|
+
service PipelineService {
|
|
7
|
+
rpc <Method>( Envelope ) returns ( Envelope ); # Process, DetectSequence, ...
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
``build()`` turns a flat Python ``data`` dict + a ``config`` dict into an
|
|
11
|
+
``Envelope`` message. It intentionally makes **no assumption about field names
|
|
12
|
+
or payload types** -- ``data={"images": ...}``, ``data={"sentences": ...}``
|
|
13
|
+
or any other keys are all the same shape to the client. Value types pick the
|
|
14
|
+
``Value`` oneof via ``aux.wrap_value``.
|
|
15
|
+
|
|
16
|
+
Value coercion (applied to each data value, and to each list element):
|
|
17
|
+
|
|
18
|
+
bytes / bytearray / memoryview -> bytes binary payload (e.g. an image)
|
|
19
|
+
pathlib.Path -> read file serializes a local file to bytes
|
|
20
|
+
int -> float (proto ``f`` is a float)
|
|
21
|
+
str -> literal string a *text* value, NOT a file path
|
|
22
|
+
list of the above (homogeneous) -> BytesList / StringList / FloatList
|
|
23
|
+
|
|
24
|
+
So: to ship a *file* use ``bytes`` or ``pathlib.Path`` (or :func:`load`); to send a
|
|
25
|
+
*literal string* use ``str``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import pathlib
|
|
30
|
+
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
31
|
+
|
|
32
|
+
from ._pb_loader import get as _get_pb
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load(path: Union[str, "pathlib.PurePath"]) -> bytes:
|
|
36
|
+
"""Read a local file into ``bytes`` so it can be shipped to a (possibly
|
|
37
|
+
remote) box. Equivalent to ``pathlib.Path(path).read_bytes()``."""
|
|
38
|
+
return pathlib.Path(path).read_bytes()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _coerce_item(v: Any) -> Any:
|
|
42
|
+
"""Coerce one scalar into something ``aux.wrap_value`` can wrap."""
|
|
43
|
+
if isinstance(v, pathlib.PurePath):
|
|
44
|
+
return v.read_bytes()
|
|
45
|
+
if isinstance(v, (bytearray, memoryview)):
|
|
46
|
+
return bytes(v)
|
|
47
|
+
if type(v) is int:
|
|
48
|
+
return float(v)
|
|
49
|
+
return v
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _coerce_value(v: Any) -> Any:
|
|
53
|
+
"""Coerce a value or a homogeneous list of values."""
|
|
54
|
+
if isinstance(v, (list, tuple)):
|
|
55
|
+
return [_coerce_item(x) for x in v]
|
|
56
|
+
return _coerce_item(v)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build(data: Optional[Dict[str, Any]] = None,
|
|
60
|
+
config: Optional[Dict[str, Any]] = None):
|
|
61
|
+
"""Build an ``Envelope`` request.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
data:
|
|
66
|
+
``{field_name: value}`` mapped into ``Envelope.data``. Values follow the
|
|
67
|
+
coercion rules in the module docstring.
|
|
68
|
+
config:
|
|
69
|
+
Arbitrary dict serialized to ``Envelope.config_json`` (the box-specific
|
|
70
|
+
control payload; shape depends on the box).
|
|
71
|
+
"""
|
|
72
|
+
pb2, _grpc, aux = _get_pb()
|
|
73
|
+
env = pb2.Envelope(config_json=json.dumps(config or {}))
|
|
74
|
+
for k, v in (data or {}).items():
|
|
75
|
+
env.data[k].CopyFrom(aux.wrap_value(_coerce_value(v)))
|
|
76
|
+
return env
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load_images(images: Union[str, bytes, Sequence]) -> List[bytes]:
|
|
80
|
+
"""Normalize an ``images`` argument into a list of bytes.
|
|
81
|
+
|
|
82
|
+
Used by the image-box conveniences (:mod:`boxes_client.conveniences`), so a
|
|
83
|
+
bare ``str`` here is treated as a **local file path** to serialize (distinct
|
|
84
|
+
from the generic ``run`` contract where ``str`` means a literal string).
|
|
85
|
+
Accepts:
|
|
86
|
+
|
|
87
|
+
* a single path (str) or preencoded bytes
|
|
88
|
+
* a list of paths / preencoded bytes
|
|
89
|
+
* :class:`pathlib.Path` objects
|
|
90
|
+
"""
|
|
91
|
+
if images is None:
|
|
92
|
+
return []
|
|
93
|
+
if isinstance(images, (str, bytes, bytearray, pathlib.PurePath)):
|
|
94
|
+
images = [images]
|
|
95
|
+
out: List[bytes] = []
|
|
96
|
+
for item in images:
|
|
97
|
+
if isinstance(item, (bytes, bytearray, memoryview)):
|
|
98
|
+
out.append(bytes(item))
|
|
99
|
+
elif isinstance(item, (str, pathlib.PurePath)):
|
|
100
|
+
out.append(pathlib.Path(item).read_bytes())
|
|
101
|
+
else:
|
|
102
|
+
raise TypeError(
|
|
103
|
+
f"Unsupported image element: {type(item)!r}. "
|
|
104
|
+
"Pass a path (str/pathlib.Path), pre-encoded bytes, or a list of those."
|
|
105
|
+
)
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def reset_envelope(config_key: str):
|
|
110
|
+
"""Build a config-only ``Process`` request that clears server-side state.
|
|
111
|
+
|
|
112
|
+
``config_key`` names the box's config section (e.g. ``"tapnext"``,
|
|
113
|
+
``"clip"``); the caller decides -- the core has no default and names no box.
|
|
114
|
+
"""
|
|
115
|
+
return build(config={config_key: {"command": "reset"}})
|
boxes_client/result.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Result object returned from ``Box`` calls."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from .codec import decode_with
|
|
8
|
+
from .decode_util import decode_payload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_numeric_array(v):
|
|
12
|
+
try:
|
|
13
|
+
import numpy as np
|
|
14
|
+
except ImportError:
|
|
15
|
+
return False
|
|
16
|
+
return isinstance(v, np.ndarray)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _find_encoding(config):
|
|
20
|
+
"""Locate the box's declared ``"encoding"`` contract key.
|
|
21
|
+
|
|
22
|
+
Scans the parsed config top-level, then each one-level section (first hit
|
|
23
|
+
wins). Returns the declared value — a codec-name ``str`` that applies to
|
|
24
|
+
every ``bytes`` field, or a ``{field_name: codec_name}`` map for mixed
|
|
25
|
+
responses — or ``None`` (undeclared: legacy auto-decode applies).
|
|
26
|
+
``encoding`` is a generic contract keyword, not a box name.
|
|
27
|
+
"""
|
|
28
|
+
if not isinstance(config, dict):
|
|
29
|
+
return None
|
|
30
|
+
if "encoding" in config:
|
|
31
|
+
return config["encoding"]
|
|
32
|
+
for section in config.values():
|
|
33
|
+
if isinstance(section, dict) and "encoding" in section:
|
|
34
|
+
return section["encoding"]
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _codec_for(enc, field_name):
|
|
39
|
+
"""Resolve the declared codec name for one field (or ``None``)."""
|
|
40
|
+
if enc is None:
|
|
41
|
+
return None
|
|
42
|
+
if isinstance(enc, str):
|
|
43
|
+
return enc
|
|
44
|
+
if isinstance(enc, dict):
|
|
45
|
+
return enc.get(field_name)
|
|
46
|
+
# A declared but unrecognized shape (e.g. a list) -> ignore, use legacy.
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _decode_any(buf, codec_name=None):
|
|
51
|
+
"""Decode a single Envelope Value payload.
|
|
52
|
+
|
|
53
|
+
- list of byte payloads: decode each element
|
|
54
|
+
- list of strings/floats: pass through
|
|
55
|
+
- bytes: declared codec if given (never raises), else the legacy guess chain
|
|
56
|
+
- scalars (str/float/int): pass through
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(buf, (list, tuple)):
|
|
59
|
+
items = list(buf)
|
|
60
|
+
if items and all(isinstance(x, (bytes, bytearray, memoryview)) for x in items):
|
|
61
|
+
if codec_name is not None:
|
|
62
|
+
return [decode_with(x, codec_name) for x in items]
|
|
63
|
+
return [decode_payload(x) for x in items]
|
|
64
|
+
return items
|
|
65
|
+
if isinstance(buf, (bytes, bytearray, memoryview)):
|
|
66
|
+
if codec_name is not None:
|
|
67
|
+
return decode_with(buf, codec_name)
|
|
68
|
+
return decode_payload(buf)
|
|
69
|
+
return buf
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Result:
|
|
74
|
+
"""A decoded response from a box.
|
|
75
|
+
|
|
76
|
+
``fields`` maps every Envelope data field to its decoded value: the
|
|
77
|
+
box-declared ``"encoding"`` codec when the box declares one
|
|
78
|
+
(:mod:`boxes_client.codec`), else the legacy auto-decode
|
|
79
|
+
(:mod:`boxes_client.decode_util`). Use ``raw`` for the undecoded Envelope.
|
|
80
|
+
Field values can also be read directly, e.g. ``res.tracks``.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
raw: Any = None
|
|
84
|
+
config: Any = None
|
|
85
|
+
fields: Dict[str, Any] = field(default_factory=dict)
|
|
86
|
+
#: The box's declared payload encoding, exactly as found in the response
|
|
87
|
+
#: config: a codec-name ``str``, a ``{field: codec}`` map, or ``None``
|
|
88
|
+
#: (undeclared → legacy auto-decode). Exposed so callers can see *why*
|
|
89
|
+
#: a field came back decoded or raw.
|
|
90
|
+
encoding: Any = None
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_envelope(cls, envelope):
|
|
94
|
+
config = envelope.config_json
|
|
95
|
+
if config:
|
|
96
|
+
try:
|
|
97
|
+
config = json.loads(config)
|
|
98
|
+
except (ValueError, TypeError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# Declared payload encoding (generic contract keyword, not a box name).
|
|
102
|
+
# ``None`` → behaviour is identical to before the codecs landed.
|
|
103
|
+
enc = _find_encoding(config)
|
|
104
|
+
|
|
105
|
+
fields = {}
|
|
106
|
+
for name, val in envelope.data.items():
|
|
107
|
+
kind = val.WhichOneof("kind") if hasattr(val, "WhichOneof") else None
|
|
108
|
+
if kind == "b":
|
|
109
|
+
buf = val.b
|
|
110
|
+
elif kind == "bb":
|
|
111
|
+
buf = list(val.bb.values)
|
|
112
|
+
elif kind == "ss":
|
|
113
|
+
buf = list(val.ss.values)
|
|
114
|
+
elif kind == "ff":
|
|
115
|
+
buf = list(val.ff.values)
|
|
116
|
+
elif kind == "f":
|
|
117
|
+
buf = val.f
|
|
118
|
+
elif kind == "s":
|
|
119
|
+
buf = val.s
|
|
120
|
+
else:
|
|
121
|
+
buf = val
|
|
122
|
+
fields[name] = _decode_any(buf, codec_name=_codec_for(enc, name))
|
|
123
|
+
return cls(raw=envelope, config=config, fields=fields, encoding=enc)
|
|
124
|
+
|
|
125
|
+
def __getattr__(self, name):
|
|
126
|
+
if name.startswith("_") or name in ("raw", "config", "fields"):
|
|
127
|
+
raise AttributeError(name)
|
|
128
|
+
d = self.__dict__.get("fields") or {}
|
|
129
|
+
if name in d:
|
|
130
|
+
return d[name]
|
|
131
|
+
raise AttributeError(
|
|
132
|
+
f"Result has no field {name!r}; available: {sorted(d)}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def as_dict(self):
|
|
136
|
+
return {
|
|
137
|
+
"config": self.config,
|
|
138
|
+
"fields": {
|
|
139
|
+
k: (v.tolist() if _is_numeric_array(v) else v)
|
|
140
|
+
for k, v in self.fields.items()
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def __repr__(self):
|
|
145
|
+
cfg = self.config if isinstance(self.config, dict) else "<str>"
|
|
146
|
+
return f"<Result fields=[{', '.join(sorted(self.fields))}] config={cfg}>"
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: boxes-client
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Thin Python client to call deployed AI 'boxes' (gRPC services) by IP:port
|
|
5
|
+
Author: Manuel Ferreira
|
|
6
|
+
License-Expression: GPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://github.com/manel-ferreira-01/boxes
|
|
8
|
+
Project-URL: Repository, https://github.com/manel-ferreira-01/boxes
|
|
9
|
+
Project-URL: Issues, https://github.com/manel-ferreira-01/boxes/issues
|
|
10
|
+
Keywords: grpc,grpc-client,inference-serving,ai-fleet,pipeline,zstandard,codec
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: grpcio>=1.50
|
|
25
|
+
Requires-Dist: protobuf>=7.35.1
|
|
26
|
+
Requires-Dist: grpcio-reflection>=1.50
|
|
27
|
+
Requires-Dist: numpy>=1.21
|
|
28
|
+
Requires-Dist: zstandard>=0.18
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
31
|
+
Provides-Extra: torch
|
|
32
|
+
Requires-Dist: torch>=2.0; extra == "torch"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# boxes-client
|
|
36
|
+
|
|
37
|
+
A thin Python client for calling deployed AI **"boxes"** by `IP:port`.
|
|
38
|
+
|
|
39
|
+
A *box* is one of the gRPC services in [`/images/`](../images/) built to the
|
|
40
|
+
shared
|
|
41
|
+
|
|
42
|
+
```protobuf
|
|
43
|
+
service PipelineService {
|
|
44
|
+
rpc Process( Envelope ) returns ( Envelope );
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
interface. Point this client at **any one box** and send an `Envelope`:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from boxes_client import Box
|
|
52
|
+
|
|
53
|
+
b = Box("localhost:8061") # a local box
|
|
54
|
+
# b = Box("10.0.0.5:8061") # ...or a remote one
|
|
55
|
+
|
|
56
|
+
res = b.run(data={"images": ["frame.jpg"]},
|
|
57
|
+
config={"my_box": {"command": "do_thing", "parameters": {}}})
|
|
58
|
+
print(res.fields) # decoded payload, whatever the box returned
|
|
59
|
+
print(res.config) # parsed config_json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Box-specific one-liners live in an *optional* convenience layer, e.g. the
|
|
63
|
+
tapnext tracker:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from boxes_client import trace
|
|
67
|
+
res = trace(b, images=["frame.jpg"], grid_size=30)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
No registry, no central server — the client connects directly to the box, so
|
|
71
|
+
local and remote boxes are the same call. Boxes stay independent, addressable,
|
|
72
|
+
composable units (the *client* does the calling, preserving the distributed
|
|
73
|
+
nature of the fleet).
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install boxes-client
|
|
79
|
+
# optional, to decode tapnext/vggt tensor payloads to numpy:
|
|
80
|
+
pip install "boxes-client[torch]"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
(From a repo checkout, instead: `pip install -e boxes_client`.)
|
|
84
|
+
|
|
85
|
+
`zstandard` is a base dependency (the `zstd_pickle` codec needs it); `torch`
|
|
86
|
+
stays optional. Without the codec's library, declared payloads degrade to raw
|
|
87
|
+
`bytes` with a warning (still usable — e.g.
|
|
88
|
+
`torch.load(BytesIO(res.tracks), weights_only=False)`).
|
|
89
|
+
|
|
90
|
+
The full design of the declared-encoding contract lives in
|
|
91
|
+
[docs/CODECS.md](../docs/CODECS.md).
|
|
92
|
+
|
|
93
|
+
## Core API (box-agnostic) + optional conveniences
|
|
94
|
+
|
|
95
|
+
`Box` is deliberately box-agnostic: it builds and sends an `Envelope` and reads
|
|
96
|
+
a `Result` back, and knows **no box, field, or model**. Per-box sugar lives in a
|
|
97
|
+
separate convenience layer, so adding one never touches the core.
|
|
98
|
+
|
|
99
|
+
### 1. Generic — `Box.run(data, config, method, reset_first)`
|
|
100
|
+
The workhorse. No assumption about field names or payload types:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
b.run(
|
|
104
|
+
data = {"images": [img1, img2]}, # any field names; any types
|
|
105
|
+
config = {"my_box": {"command": "do_thing", "parameters": {...}}},
|
|
106
|
+
method = "Process", # default; e.g. "similarity_check"
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`data` is a dict of `field_name -> value`. Values are coerced per the rules
|
|
111
|
+
below and wrapped into the shared `Value` oneof via the vendored `aux.wrap_value`.
|
|
112
|
+
|
|
113
|
+
### 2. Convenience — `trace(box, images, grid_size=None, reset_first=True)`
|
|
114
|
+
An **optional** one-liner for the *tapnext* box, living in
|
|
115
|
+
`boxes_client.conveniences` (not the core). It reads image files (or accepts
|
|
116
|
+
pre-encoded bytes) and just calls `Box.run()` with the right shape:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
b = Box("localhost:8061")
|
|
120
|
+
res = trace(b, images=["f1.jpg", "f2.jpg", "f3.jpg"], grid_size=30)
|
|
121
|
+
np.save("tracks.npy", res.tracks.numpy())
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The core `Box` knows nothing about tapnext — `trace` *is* the only tapnext
|
|
125
|
+
knowledge, and it's safe to delete without touching the generic client. Add a
|
|
126
|
+
sibling convenience (`segment`, `embed`, `detect`, …) for other boxes the same
|
|
127
|
+
way; never put a box name in `box.py`.
|
|
128
|
+
|
|
129
|
+
### `Box.reset(config_key=None)`
|
|
130
|
+
Sends `{config_key: {"command": "reset"}}` on `Process`. `config_key` is the
|
|
131
|
+
box's section name (or the one from the constructor). Stateful boxes clear
|
|
132
|
+
state; stateless boxes typically ignore it. Pass the box name explicitly, or
|
|
133
|
+
construct with `Box(host, config_key="tapnext")`.
|
|
134
|
+
|
|
135
|
+
### `Box.info()`
|
|
136
|
+
Asks the box (via gRPC reflection) whether it serves `pipeline.PipelineService`.
|
|
137
|
+
Useful to check box reachability and shape before committing to a call.
|
|
138
|
+
|
|
139
|
+
## Value coercion (for `Box.run` / convenience `data` values)
|
|
140
|
+
|
|
141
|
+
| You pass | What's sent |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `bytes` / `bytearray` / `memoryview` | `b` (single bytes) or `bb` (list) — for pre-encoded binary payloads (images, tensors, etc.) |
|
|
144
|
+
| `pathlib.Path` | file bytes (read locally, sent as `b`) |
|
|
145
|
+
| `str` | **literal string** (NOT a file path) — sent as `s` |
|
|
146
|
+
| `int` | coerced to `float` (proto `f` is a float) |
|
|
147
|
+
| list of the above (homogeneous) | corresponding list: `BytesList` / `StringList` / `FloatList` |
|
|
148
|
+
|
|
149
|
+
That's why a *file* has to be passed as `pathlib.Path` (or a `bytes` you loaded
|
|
150
|
+
yourself) — the client serializes it and ships it. A *literal string* is just
|
|
151
|
+
`str` — no confusion.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
import pathlib
|
|
155
|
+
# file:
|
|
156
|
+
b.run(data={"images": [pathlib.Path("frame.jpg")]}, config={...})
|
|
157
|
+
|
|
158
|
+
# preencoded:
|
|
159
|
+
b.run(data={"images": [open("frame.jpg","rb").read()]}, config={...})
|
|
160
|
+
|
|
161
|
+
# literal text (e.g. a hypothetical text-only envelope box):
|
|
162
|
+
b.run(data={"sentences": ["hello", "world"]}, config={...})
|
|
163
|
+
|
|
164
|
+
# list of floats:
|
|
165
|
+
b.run(data={"vals": [1, 2, 3]}, config={...}) # -> FloatList
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Result object
|
|
169
|
+
|
|
170
|
+
`Result` wraps the raw response `Envelope`:
|
|
171
|
+
|
|
172
|
+
- `fields` — dict of `field_name -> decoded value`
|
|
173
|
+
- `.tracks`, `.visibles`, … — direct field access via `__getattr__`
|
|
174
|
+
- `config` — parsed `config_json`
|
|
175
|
+
- `encoding` — the box's **declared** payload encoding (codec-name string,
|
|
176
|
+
`{field: codec}` map, or `None`), so you can see *why* a field is decoded
|
|
177
|
+
or raw
|
|
178
|
+
- `raw` — the undecoded `Envelope` proto
|
|
179
|
+
- `as_dict()` — JSON-friendly version (numpy arrays -> `tolist`)
|
|
180
|
+
|
|
181
|
+
### Decoding: declared first, legacy guess as fallback
|
|
182
|
+
|
|
183
|
+
The authoritative path is the box's **declaration**: the response `config_json`
|
|
184
|
+
carries a generic `"encoding"` key — a codec name for all `bytes` fields, or a
|
|
185
|
+
`{field_name: codec_name}` map:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{ "lang_sam": { "status": "done", "encoding": "zstd_pickle" } }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
``encoding`` scans the parsed config top-level, then each section (first hit
|
|
192
|
+
wins). Named codecs (`[src/boxes_client/codec.py](src/boxes_client/codec.py)`,
|
|
193
|
+
registry `CODECS` / `decode_with`, full design in [docs/CODECS.md](../docs/CODECS.md)):
|
|
194
|
+
|
|
195
|
+
| name | payload | decoded to |
|
|
196
|
+
|---------------|---------------------------------------|-------------------------|
|
|
197
|
+
| `identity` | raw bytes (the default) | `bytes` unchanged |
|
|
198
|
+
| `json` | UTF-8 JSON | `list`/`dict` |
|
|
199
|
+
| `torch` | `torch.save()` tensor / dict | `Tensor` / `dict` |
|
|
200
|
+
| `numpy` | raw float32 buffer | `np.ndarray` |
|
|
201
|
+
| `zstd_pickle` | `zstd.compress(pickle.dumps(obj))` | decoded Python |
|
|
202
|
+
|
|
203
|
+
Unknown names or a missing codec library degrade to **raw bytes + a
|
|
204
|
+
warning** — never an exception.
|
|
205
|
+
|
|
206
|
+
The legacy guess chain
|
|
207
|
+
(`[src/boxes_client/decode_util.py](src/boxes_client/decode_util.py)`):
|
|
208
|
+
**JSON → torch (if installed) → numpy → raw bytes**, kept only for boxes that
|
|
209
|
+
declared nothing. Guessing is approximate (the numpy branch will happily
|
|
210
|
+
reinterpret any 4-byte-aligned blob); boxes are expected to declare.
|
|
211
|
+
|
|
212
|
+
## What's supported
|
|
213
|
+
|
|
214
|
+
| Box | In scope | Notes |
|
|
215
|
+
|-----|----------|-------|
|
|
216
|
+
| tapnext (`Process`) | ✅ | v1 target; `trace(box, ...)` convenience over `Box.run` |
|
|
217
|
+
| vggt, yolo, opencv_box, lang_segm, clip (`Process`) | ✅ envelope shape | call via `Box.run(...)` with the box-specific `config`; extras like yolo `DetectSequence` / opencv `similarity_check` need the box's own proto for `method=` |
|
|
218
|
+
| cotracker, textEmbedding (`Forward`) | ⏸ pending | use `Box.run` after they're migrated to the shared envelope (client needs no changes) |
|
|
219
|
+
|
|
220
|
+
### Method dispatch caveat
|
|
221
|
+
|
|
222
|
+
`Box.run(..., method=...)` resolves to a method on the **client-stub**, which is
|
|
223
|
+
built from the shared `pipeline.proto`. Today the shared proto defines only
|
|
224
|
+
`Process`, so `method=` is currently useful only for `Process`. Boxes that add
|
|
225
|
+
extra `PipelineService` RPCs (e.g. opencv's `similarity_check`, yolo's
|
|
226
|
+
`DetectSequence`/`TrackSequence`/`AllProcessing`) will need either (a) to also
|
|
227
|
+
be migrated to a *single* `Process` with a `command` field (the tapnext pattern),
|
|
228
|
+
or (b) to have their own proto vendored into the client. This is the only
|
|
229
|
+
remaining "per-box" knowledge in the client — everything else (field names,
|
|
230
|
+
payload types, config shape) is fully generic.
|
|
231
|
+
|
|
232
|
+
## Run the tests
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
# in-process fake box (no GPU, no real box needed)
|
|
236
|
+
python boxes_client/tests/fake_box_smoke.py
|
|
237
|
+
|
|
238
|
+
# real tapnext box at BOX_HOST:PORT
|
|
239
|
+
BOX_HOST=localhost:8061 python boxes_client/tests/live_tapnext.py
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Notes / design
|
|
243
|
+
|
|
244
|
+
- **Client-side only.** The box stays a box: independent, restartable,
|
|
245
|
+
composable. Distribution is preserved because the *client* dials the box
|
|
246
|
+
directly, so "local box" and "remote box" are the same call.
|
|
247
|
+
- **Auto-protocol.** `Box.info()` uses gRPC reflection to confirm the box
|
|
248
|
+
serves `pipeline.PipelineService`. If the box does not serve reflection,
|
|
249
|
+
calls still work — `info()` just reports `reflection: False`.
|
|
250
|
+
- **Declared-first decoding.** Boxes declare `"encoding"` in the response
|
|
251
|
+
config; the named codec decodes it. Undeclared payloads fall back to the
|
|
252
|
+
legacy JSON → torch → numpy → raw-bytes guess chain. Nothing raises; you
|
|
253
|
+
always get a `Result` (unknown codec / missing library → raw bytes +
|
|
254
|
+
warning).
|
|
255
|
+
- **Forward boxes deferred.** cotracker / textEmbedding use bespoke
|
|
256
|
+
`Forward` messages; they will work through this client with no changes once
|
|
257
|
+
migrated to the shared envelope (clip is already migrated).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
boxes_client/__init__.py,sha256=wy6l-AHmwOeCDE_EgJGRiQg5YlxrD6Lim9W6DVht7wQ,1624
|
|
2
|
+
boxes_client/_pb_loader.py,sha256=F-n3WaxCZbx_1lSk6YFaPoWc-oSr1MgdGDEehcoPDAM,642
|
|
3
|
+
boxes_client/box.py,sha256=YkgPp0RTeAG1VWdKjpJCanatAFkRLLD8F_5a0gsl9Aw,8259
|
|
4
|
+
boxes_client/codec.py,sha256=FTm6V52yytiIlz5jnQ8dqOwOwSfkyEoyL-cvXhboa_M,4081
|
|
5
|
+
boxes_client/conveniences.py,sha256=KNWqMvdoTc0xwSCmmNwVY5YA5JSjOgG7aENkfaztgYs,3648
|
|
6
|
+
boxes_client/decode_util.py,sha256=C06620qHpmLQsv0PUg5k4q3ZcpB8IZ5AU4FbDPYG9n0,3439
|
|
7
|
+
boxes_client/envelope.py,sha256=buU46Sixg8leKwFhKG4orCrX6slGWxN2mhM9IBJb0v0,4282
|
|
8
|
+
boxes_client/result.py,sha256=jlk6czEJpv9EQODc3n9B881YVDwT8SxiyqNFWsXZs-k,5030
|
|
9
|
+
boxes_client/_pb/__init__.py,sha256=5bIZkZk_hY8DdZmlsQ0oxLTryZ9iPXCUHfbSRa_PpH0,270
|
|
10
|
+
boxes_client/_pb/aux.py,sha256=xlEh38o2CpIoRujHkn1HehIV3ebUeRrHkPqrb3AS7Xg,1263
|
|
11
|
+
boxes_client/_pb/pipeline_pb2.py,sha256=7fdu73F2FXQPhIMiT2K5hdG71-6SaxQOxxkLufLnvxs,2803
|
|
12
|
+
boxes_client/_pb/pipeline_pb2_grpc.py,sha256=LFcIJzebgNSZKwjRPGXdnieWxHWAhOb-ACSUGVGzQfM,3300
|
|
13
|
+
boxes_client-0.1.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
14
|
+
boxes_client-0.1.1.dist-info/METADATA,sha256=A59Qx3tzjffoGm0FlvlJbjiRLBCMjYiSpTbqIeooKQw,10794
|
|
15
|
+
boxes_client-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
+
boxes_client-0.1.1.dist-info/top_level.txt,sha256=Oxf7b8trFFSP25ms2Na4b7O1aHD0rROV5RhO5X6jBlM,13
|
|
17
|
+
boxes_client-0.1.1.dist-info/RECORD,,
|