jevimage 0.1.0__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.
jevimage/__init__.py ADDED
@@ -0,0 +1,119 @@
1
+ """jevimage - a probability-first image classifier you can self-host.
2
+
3
+ One image encode, many typed questions read against it. Nothing is generated: every
4
+ answer is a calibrated probability distribution, so you get a number you can threshold
5
+ instead of a sentence you have to parse.
6
+
7
+ There are two ways to use this, and both are first class.
8
+
9
+ RUN IT YOURSELF - the models execute in your process, nothing leaves the machine:
10
+
11
+ pip install 'jevimage[local]'
12
+
13
+ import jevimage
14
+ jev = jevimage.load() # siglip2-base-224 by default
15
+ a = jev.ask("photo.jpg", {
16
+ "scene": {"type": "choice",
17
+ "criteria": {"indoor": "an indoor scene",
18
+ "outdoor": "an outdoor scene"}},
19
+ })
20
+ a["scene"].choice # 'outdoor'
21
+ a["scene"].probabilities # {'indoor': 0.07, 'outdoor': 0.93}
22
+ a["scene"].confidence # 0.86
23
+
24
+ USE A SERVER - someone else runs the encoder; you need no torch, no weights, no GPU:
25
+
26
+ pip install jevimage # a few hundred KB, stdlib + pillow
27
+
28
+ jev = jevimage.connect("https://jev.example.com", api_key="...")
29
+ a = jev.ask("photo.jpg", {...}) # the same call, the same Answer objects
30
+
31
+ `connect()` is a drop-in for `load()`: same method names, same return shapes. Write
32
+ against one and switch later by changing that line.
33
+
34
+ HOST IT FOR OTHERS:
35
+
36
+ pip install 'jevimage[serve]'
37
+ jev serve --host 0.0.0.0 --port 8000
38
+
39
+ Zero-shot not good enough? Train a head on your own labelled images. The encoder stays
40
+ frozen, so this takes about a second and costs nothing at query time - and it works the
41
+ same locally or through a server:
42
+
43
+ head = jev.train("street", "dataset/") # dataset/bus/*.jpg, dataset/bike/*.jpg
44
+ head.accuracy # held-out, k-fold
45
+ jev.ask("photo.jpg", {"q": {"type": "head", "head": "street"}})
46
+ """
47
+ from __future__ import annotations
48
+
49
+ __version__ = "0.1.0"
50
+
51
+ # Only the light things are imported eagerly. torch and transformers live behind
52
+ # __getattr__ below, so `import jevimage` costs milliseconds and an API-only install
53
+ # never needs them on disk at all.
54
+ from .answers import Answer, confidence, normalise_questions
55
+ from .images import IMAGE_SUFFIXES, Head, as_examples, read_folder, to_image
56
+
57
+ __all__ = ["load", "connect", "register", "available", "Jev", "Head", "Answer",
58
+ "Encoder", "JevError", "to_image", "read_folder", "as_examples",
59
+ "confidence", "IMAGE_SUFFIXES", "DEFAULT", "__version__"]
60
+
61
+ _LAZY = {
62
+ "load": ("local", "load"),
63
+ "Jev": ("local", "Jev"),
64
+ "Encoder": ("encoders", "Encoder"),
65
+ "register": ("encoders", "register"),
66
+ "available": ("encoders", "available"),
67
+ "DEFAULT": ("encoders", "DEFAULT"),
68
+ "JevError": ("client", "JevError"),
69
+ }
70
+
71
+
72
+ def __getattr__(name):
73
+ """Resolve the rest of the API on first use.
74
+
75
+ `load` and `Jev` need torch; the encoder-registry names and `JevError` do not - they
76
+ are here to keep `import jevimage` down to milliseconds. Importing the heavy half
77
+ lazily is what lets `pip install jevimage` be a small HTTP client, while
78
+ `pip install 'jevimage[local]'` is the full framework under the identical name.
79
+ """
80
+ target = _LAZY.get(name)
81
+ if target is None:
82
+ # submodules too: error messages and the docs say jevimage.encoders.unit(),
83
+ # and a plain `import jevimage` should be enough to reach one.
84
+ import importlib.util
85
+ if importlib.util.find_spec(f".{name}", __name__) is None:
86
+ raise AttributeError(f"module 'jevimage' has no attribute {name!r}")
87
+ target = (name, None)
88
+ module, attr = target
89
+ try:
90
+ import importlib
91
+ mod = importlib.import_module(f".{module}", __name__)
92
+ return mod if attr is None else getattr(mod, attr)
93
+ except ImportError as e:
94
+ raise ImportError(
95
+ f"jevimage.{name} runs the encoder in this process, which needs torch and "
96
+ f"transformers: pip install 'jevimage[local]'\n"
97
+ f"To use a server instead - no torch required - call "
98
+ f"jevimage.connect(url).\n(underlying import error: {e})") from e
99
+
100
+
101
+ def __dir__():
102
+ return sorted(__all__)
103
+
104
+
105
+ def connect(url: str, *, api_key: str | None = None, timeout: float = 60.0,
106
+ max_edge: int | None = 384):
107
+ """Talk to a `jev serve` instance. Same methods as `load()`, over HTTP.
108
+
109
+ Needs no torch and downloads no weights, so this is the whole dependency footprint
110
+ of an API-only install.
111
+
112
+ `max_edge` downscales an image before uploading it, because no encoder reads more
113
+ than 384px and on a fresh connection the upload is most of the latency. Pass None
114
+ to send originals.
115
+ The returned object keeps one connection open; close it with `.close()`, or use it
116
+ as a context manager.
117
+ """
118
+ from .client import Remote
119
+ return Remote(url, api_key=api_key, timeout=timeout, max_edge=max_edge)
jevimage/answers.py ADDED
@@ -0,0 +1,125 @@
1
+ """The shapes a request and its answers take, with no dependency on torch.
2
+
3
+ This module is deliberately pure Python. An API-only install - someone who just calls a
4
+ `jev serve` instance over HTTP - gets the same Answer objects as someone running the
5
+ models locally, without installing a deep-learning stack to do it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import numbers
11
+
12
+ __all__ = ["Answer", "check_template", "confidence", "normalise_questions",
13
+ "positive_temperature", "TEMPERATURE_RANGE"]
14
+
15
+ # The usable range. Probabilities are reported to six decimals, so below the floor every
16
+ # distribution is one-hot and above the ceiling every one is uniform to the last digit
17
+ # printed - and past about 1e38 the fp32 division underflows to zero, which made the
18
+ # ANSWER flip to whichever option was listed first. Both ends are the same failure inf
19
+ # is refused for: a well-formed answer carrying no signal.
20
+ TEMPERATURE_RANGE = (1e-6, 1e6)
21
+
22
+
23
+ def positive_temperature(temperature):
24
+ """A negative temperature inverts every softmax - the least similar option wins, and
25
+ the answer still reports high confidence - so it must never reach one.
26
+
27
+ Here rather than in `core`, so the torch-free client can refuse a bad temperature
28
+ with the same three sentences a local `Jev` gives instead of its own wording.
29
+ """
30
+ # numbers.Real, not (int, float): a numpy scalar is a number, and refusing
31
+ # np.float32(0.5) with "must be a number greater than 0, got 0.5" states a falsehood
32
+ # about the value it prints - while np.float64 sailed through for subclassing float.
33
+ if isinstance(temperature, bool) or not isinstance(temperature, numbers.Real):
34
+ raise ValueError(f"temperature must be a number greater than 0, "
35
+ f"got {temperature!r}")
36
+ if not temperature > 0:
37
+ raise ValueError(f"temperature must be greater than 0, got {temperature!r}")
38
+ # inf is "greater than 0" and divides every logit to zero, so every option comes
39
+ # back equally likely and confidence 0 - a well-formed answer carrying no signal,
40
+ # which is worse than an error because nothing downstream can tell.
41
+ if not math.isfinite(temperature):
42
+ raise ValueError(f"temperature must be finite, got {temperature!r}; an infinite "
43
+ "temperature makes every option equally likely")
44
+ lo, hi = TEMPERATURE_RANGE
45
+ if not lo <= temperature <= hi:
46
+ raise ValueError(f"temperature must be between {lo:g} and {hi:g}, got "
47
+ f"{temperature!r}; outside that range every option comes back "
48
+ "equally likely, or exactly one does, whatever the image says")
49
+ return float(temperature)
50
+
51
+
52
+ def check_template(tpl, field: str = "prompt"):
53
+ """A caption template must substitute exactly once.
54
+
55
+ Testing for a literal '{}' is not enough: 'a photo of {x}', '{} and {}' and '{ {}'
56
+ all contain a brace, all passed, and all then blew up inside str.format with a raw
57
+ stdlib error naming neither the field nor the question. A trial substitution answers
58
+ the only question that matters - can this template turn one option into one caption.
59
+ """
60
+ probe = "\x00probe\x00"
61
+ try:
62
+ if isinstance(tpl, str) and tpl.format(probe).count(probe) == 1:
63
+ return tpl
64
+ except (IndexError, KeyError, ValueError):
65
+ pass
66
+ # without exactly one placeholder every option gets the same caption, so the answer
67
+ # is a uniform distribution over identical strings and `choice` is whichever came first
68
+ raise ValueError(f"{field} {tpl!r} has no '{{}}' placeholder, so every option "
69
+ "would get the same caption; write e.g. 'a photo of {}'")
70
+
71
+
72
+ class Answer(dict):
73
+ """One question's answer. A plain dict, so it serialises to JSON unchanged, with
74
+ attribute access because `a.choice` reads better than `a["choice"]`."""
75
+
76
+ def __getattr__(self, k):
77
+ try:
78
+ return self[k]
79
+ except KeyError:
80
+ raise AttributeError(
81
+ f"{self.get('type', 'answer')!r} answers have no {k!r}; "
82
+ f"this one has: {', '.join(self)}") from None
83
+
84
+ def __repr__(self):
85
+ if "choice" in self:
86
+ return (f"<{self['type']} {self['choice']!r} "
87
+ f"p={max(self['probabilities'].values()):.3f}>")
88
+ if "noul" in self:
89
+ return f"<noul {self['noul']:.3f}>"
90
+ if "score" in self:
91
+ return f"<score {self['score']:.2f}/{len(self['legend']) - 1}>"
92
+ return f"<{self.get('type', 'answer')}>"
93
+
94
+
95
+ def confidence(p) -> float:
96
+ """How peaked a distribution is, rescaled so uniform is 0 and certain is 1.
97
+
98
+ Raw max-probability is not comparable across questions: 0.5 is lukewarm over two
99
+ options and emphatic over fifty. (max - 1/K) / (1 - 1/K) removes that dependence,
100
+ so one threshold works for every question you ask.
101
+ """
102
+ k = len(p)
103
+ return (max(p) - 1 / k) / (1 - 1 / k) if k > 1 else 1.0
104
+
105
+
106
+ def normalise_questions(questions) -> dict:
107
+ """A dict keyed by id, or a list that gets q1, q2, ... . Both are common at call
108
+ sites; neither should need a wrapper."""
109
+ if isinstance(questions, dict):
110
+ return questions
111
+ if isinstance(questions, (list, tuple)):
112
+ out = {}
113
+ for i, q in enumerate(questions):
114
+ if not isinstance(q, dict):
115
+ # same sentence core.plan gives for the dict form, so a malformed
116
+ # question reads the same whichever shape it arrived in - and an
117
+ # AttributeError below would escape as a 500 rather than a 422
118
+ raise ValueError(f"a question must be an object, got {type(q).__name__}")
119
+ qid = q.get("id") or f"q{i + 1}"
120
+ if qid in out:
121
+ # the dict would keep only the last: two questions in, one answer out
122
+ raise ValueError(f"two questions share the id {qid!r}; ids must be unique")
123
+ out[qid] = q
124
+ return out
125
+ raise TypeError("questions must be a {id: question} object or a list of questions")
jevimage/batcher.py ADDED
@@ -0,0 +1,212 @@
1
+ """Dynamic batching for the image encoder.
2
+
3
+ One image through the encoder costs 9.7ms; eight together cost 20.6ms - 2.6ms each.
4
+ The GPU is idle between the small matmuls of a single image, so the marginal image in a
5
+ batch is nearly free. Measured on the A10G:
6
+
7
+ batch 1 9.68 ms/img 103 img/s
8
+ batch 8 2.57 ms/img 388 img/s 3.8x
9
+ batch 16 2.05 ms/img 487 img/s 4.7x
10
+
11
+ Requests arriving within a few milliseconds of each other are therefore worth holding
12
+ briefly so they can ride one forward pass. The wait is bounded and small: under load the
13
+ batch fills before the timer, and when traffic is thin a request waits at most
14
+ `max_wait` and still beats the un-batched path on throughput while losing only that.
15
+
16
+ Thread-based on purpose - the server's handlers are sync and uvicorn runs them in its
17
+ threadpool, so this batches whatever that pool is holding without making anything async.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import threading
22
+ import time
23
+
24
+ __all__ = ["Batcher"]
25
+
26
+
27
+ class Batcher:
28
+ """Collect concurrent calls into one call of `fn(list) -> list`."""
29
+
30
+ def __init__(self, fn, max_batch: int = 16, max_wait: float = 0.006,
31
+ name: str = "batch"):
32
+ self.fn = fn
33
+ self.max_batch = max_batch
34
+ self.max_wait = max_wait
35
+ self.name = name
36
+ self._q = [] # [(item, slot)]
37
+ self._ema = 1.0 # recent batch size; drives the adaptive wait
38
+ self._lock = threading.Lock()
39
+ self._wake = threading.Condition(self._lock)
40
+ self._worker = None
41
+ self.stats = {"calls": 0, "batches": 0, "items": 0, "max_seen": 0}
42
+
43
+ def _ensure_worker(self):
44
+ # started lazily so importing this module never spawns a thread, and so a
45
+ # forked uvicorn worker gets its own rather than inheriting a dead one
46
+ if self._worker is None or not self._worker.is_alive():
47
+ self._worker = threading.Thread(target=self._run, daemon=True,
48
+ name=f"batcher-{self.name}")
49
+ self._worker.start()
50
+
51
+ def submit(self, item):
52
+ """Queue one item and block until its result is ready. Raises what fn raised."""
53
+ slot = {"event": threading.Event(), "out": None, "err": None}
54
+ self.stats["calls"] += 1
55
+ with self._wake:
56
+ self._q.append((item, slot))
57
+ self._ensure_worker()
58
+ self._wake.notify()
59
+ slot["event"].wait()
60
+ if slot["err"] is not None:
61
+ raise slot["err"]
62
+ return slot["out"]
63
+
64
+ def _drain(self):
65
+ """Wait for work, then let a batch accumulate for at most max_wait.
66
+
67
+ The wait is ADAPTIVE. Holding a request for 6ms only pays if someone else
68
+ arrives inside that window; on an idle service nobody does, and every request
69
+ pays the delay for nothing - measured as p50 13ms -> 20ms at concurrency 1.
70
+ So the recent batch size decides: while traffic is thin the batch goes straight
71
+ through, and the wait switches on only once batches are actually forming.
72
+ """
73
+ with self._wake:
74
+ while not self._q:
75
+ self._wake.wait(0.5)
76
+ if not self._q:
77
+ return []
78
+ wait = self.max_wait if self._ema >= 1.5 else 0.0
79
+ if wait:
80
+ deadline = time.monotonic() + wait
81
+ while len(self._q) < self.max_batch:
82
+ left = deadline - time.monotonic()
83
+ if left <= 0:
84
+ break
85
+ self._wake.wait(left)
86
+ batch, self._q = self._q[:self.max_batch], self._q[self.max_batch:]
87
+ # a queue that refilled while we worked is itself evidence of load, so count
88
+ # the backlog too - otherwise the EMA can never climb out of "thin"
89
+ self._ema = 0.7 * self._ema + 0.3 * (len(batch) + min(len(self._q), 4))
90
+ return batch
91
+
92
+ def _run(self):
93
+ while True:
94
+ batch = self._drain()
95
+ if not batch:
96
+ continue
97
+ items = [b[0] for b in batch]
98
+ try:
99
+ out = self.fn(items)
100
+ if len(out) != len(items):
101
+ raise RuntimeError(f"batched fn returned {len(out)} for {len(items)}")
102
+ for (_, slot), o in zip(batch, out):
103
+ slot["out"] = o
104
+ except BaseException as e: # one bad batch must not kill the worker
105
+ # Nor may it fail its neighbours. Batching is an internal throughput
106
+ # trick, so one caller's undecodable image must not answer seven other
107
+ # callers with an error about a file they never sent. Re-run the batch
108
+ # one item at a time and route each result - or each failure - to the
109
+ # caller it belongs to. Only on the error path, so it costs nothing
110
+ # when nothing is wrong.
111
+ for item, slot in batch:
112
+ if len(batch) == 1:
113
+ slot["err"] = e
114
+ continue
115
+ try:
116
+ out = self.fn([item])
117
+ if len(out) != 1:
118
+ raise RuntimeError(f"batched fn returned {len(out)} for 1")
119
+ slot["out"] = out[0]
120
+ except BaseException as e2:
121
+ slot["err"] = e2
122
+ finally:
123
+ self.stats["batches"] += 1
124
+ self.stats["items"] += len(batch)
125
+ self.stats["max_seen"] = max(self.stats["max_seen"], len(batch))
126
+ for _, slot in batch:
127
+ slot["event"].set()
128
+
129
+
130
+ def demo() -> None:
131
+ """python3 batcher.py - proves batching, ordering, errors and the wait bound."""
132
+ seen = []
133
+
134
+ def fn(xs):
135
+ seen.append(len(xs))
136
+ time.sleep(0.005)
137
+ return [x * 2 for x in xs]
138
+
139
+ b = Batcher(fn, max_batch=8, max_wait=0.02)
140
+
141
+ # concurrent callers share a pass, and each gets ITS OWN answer back
142
+ out = {}
143
+ def call(i):
144
+ out[i] = b.submit(i)
145
+ ts = [threading.Thread(target=call, args=(i,)) for i in range(8)]
146
+ for t in ts:
147
+ t.start()
148
+ for t in ts:
149
+ t.join()
150
+ assert out == {i: i * 2 for i in range(8)}, out
151
+ assert max(seen) > 1, f"nothing batched: {seen}"
152
+
153
+ # a lone caller on a quiet batcher pays no waiting penalty at all
154
+ quiet = Batcher(lambda xs: xs, max_batch=8, max_wait=0.050)
155
+ quiet.submit(1) # prime the worker
156
+ t0 = time.perf_counter()
157
+ assert quiet.submit(21) == 21
158
+ solo = time.perf_counter() - t0
159
+ assert solo < 0.010, f"idle solo call waited {solo*1000:.0f}ms, should not wait"
160
+
161
+ # an exception reaches every caller in the batch, and the worker survives
162
+ def boom(xs):
163
+ raise ValueError("upstream failed")
164
+ b2 = Batcher(boom, max_batch=4, max_wait=0.01)
165
+ errs = []
166
+ def call2(i):
167
+ try:
168
+ b2.submit(i)
169
+ except ValueError as e:
170
+ errs.append(str(e))
171
+ ts = [threading.Thread(target=call2, args=(i,)) for i in range(4)]
172
+ for t in ts:
173
+ t.start()
174
+ for t in ts:
175
+ t.join()
176
+ assert len(errs) == 4 and all("upstream failed" in e for e in errs), errs
177
+
178
+ # ONE bad item must not fail the callers who rode the same pass. This is the whole
179
+ # point: on a server, one truncated upload used to error every concurrent request.
180
+ def picky(xs):
181
+ if any(x == "bad" for x in xs):
182
+ raise ValueError("cannot decode 'bad'")
183
+ return [x * 2 for x in xs]
184
+ b4 = Batcher(picky, max_batch=8, max_wait=0.02)
185
+ got = {}
186
+ def call4(i, x):
187
+ try:
188
+ got[i] = b4.submit(x)
189
+ except ValueError as e:
190
+ got[i] = f"ERR {e}"
191
+ ts = [threading.Thread(target=call4, args=(i, i)) for i in range(7)]
192
+ ts.append(threading.Thread(target=call4, args=(7, "bad")))
193
+ for t in ts:
194
+ t.start()
195
+ for t in ts:
196
+ t.join()
197
+ assert got[7] == "ERR cannot decode 'bad'", got[7]
198
+ assert all(got[i] == i * 2 for i in range(7)), got
199
+
200
+ # a wrong-length return is an error, not silent misrouting of answers
201
+ b3 = Batcher(lambda xs: xs[:-1], max_batch=4, max_wait=0.01) # always short by one
202
+ try:
203
+ b3.submit(1)
204
+ raise AssertionError("short return was not caught")
205
+ except RuntimeError:
206
+ pass
207
+
208
+ print(f"batcher selftest ok - batches {seen}, solo wait {solo*1000:.0f}ms")
209
+
210
+
211
+ if __name__ == "__main__":
212
+ demo()