imagejev 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.
imagejev/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """ImageJev: The image-native structured decision engine.
2
+
3
+ Jev: TEXT + DECISION SCHEMA -> structured decision
4
+ ImageJev: IMAGE + DECISION + SCHEMA -> structured decision
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from .backends import Backend, MockBackend, OpenCLIPBackend
9
+ from .core import ImageJev
10
+ from .types import ChoiceResult, NoulResult, ScoreResult
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "ImageJev",
15
+ "Backend",
16
+ "OpenCLIPBackend",
17
+ "MockBackend",
18
+ "ChoiceResult",
19
+ "ScoreResult",
20
+ "NoulResult",
21
+ "__version__",
22
+ ]
imagejev/backends.py ADDED
@@ -0,0 +1,198 @@
1
+ """Vision and Text Encoders for ImageJev.
2
+
3
+ Provides production OpenCLIP backend and lightweight MockBackend for testing.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import io
8
+ import urllib.request
9
+ from pathlib import Path
10
+ from typing import Any, Protocol, Sequence, Union
11
+
12
+ import numpy as np
13
+
14
+
15
+ class Backend(Protocol):
16
+ """Protocol for ImageJev feature encoders."""
17
+
18
+ logit_scale: float
19
+
20
+ def encode_image(self, image: Any) -> np.ndarray:
21
+ """Encode a single image into an L2-normalized 1D embedding vector of shape (D,)."""
22
+ ...
23
+
24
+ def encode_images(self, images: Sequence[Any]) -> np.ndarray:
25
+ """Encode a batch of images into L2-normalized 2D matrix of shape (B, D)."""
26
+ ...
27
+
28
+ def encode_text(self, texts: Sequence[str]) -> np.ndarray:
29
+ """Encode a sequence of text strings into L2-normalized matrix of shape (N, D)."""
30
+ ...
31
+
32
+
33
+ def load_image_to_pil(image: Any) -> Any:
34
+ """Load various image inputs into a standard RGB PIL Image.
35
+
36
+ Supports:
37
+ - PIL.Image.Image objects
38
+ - Local file paths (str or pathlib.Path)
39
+ - Raw image bytes
40
+ - io.BytesIO or file-like streams
41
+ - Remote URLs (http:// or https://) with SSRF & size protections
42
+ """
43
+ import ipaddress
44
+ import urllib.parse
45
+ from PIL import Image
46
+
47
+ # Set decompression bomb threshold to 25 megapixels to prevent memory exhaustion DoS
48
+ Image.MAX_IMAGE_PIXELS = 25_000_000
49
+
50
+ if isinstance(image, (str, Path)):
51
+ path_or_url = str(image).strip()
52
+ if path_or_url.startswith(("http://", "https://")):
53
+ parsed = urllib.parse.urlparse(path_or_url)
54
+ hostname = parsed.hostname
55
+ if not hostname:
56
+ raise ValueError(f"Invalid remote image URL: {path_or_url}")
57
+
58
+ # SSRF Protection: block private and loopback networks
59
+ try:
60
+ ip = ipaddress.ip_address(hostname)
61
+ is_ip = True
62
+ except ValueError:
63
+ is_ip = False
64
+
65
+ if is_ip:
66
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
67
+ raise ValueError(f"Access to private/local network address {hostname} is forbidden.")
68
+ else:
69
+ # String hostname: block known internal / cloud metadata hostnames
70
+ blocked_hosts = {"localhost", "127.0.0.1", "0.0.0.0", "metadata.google.internal"}
71
+ if hostname.lower() in blocked_hosts or hostname.lower().endswith(".internal") or hostname.lower().endswith(".local"):
72
+ raise ValueError(f"Access to hostname {hostname} is forbidden.")
73
+
74
+ req = urllib.request.Request(
75
+ path_or_url,
76
+ headers={"User-Agent": "ImageJev-SDK/0.2.0"}
77
+ )
78
+ # Read in chunks with a hard 10MB limit to prevent memory exhaustion
79
+ max_bytes = 10 * 1024 * 1024
80
+ chunks = []
81
+ total_read = 0
82
+ with urllib.request.urlopen(req, timeout=12) as resp:
83
+ while True:
84
+ chunk = resp.read(64 * 1024)
85
+ if not chunk:
86
+ break
87
+ chunks.append(chunk)
88
+ total_read += len(chunk)
89
+ if total_read > max_bytes:
90
+ raise ValueError(f"Remote image exceeds maximum allowed size of {max_bytes // (1024*1024)}MB.")
91
+ data = b"".join(chunks)
92
+ return Image.open(io.BytesIO(data)).convert("RGB")
93
+
94
+ return Image.open(path_or_url).convert("RGB")
95
+
96
+ if isinstance(image, bytes):
97
+ return Image.open(io.BytesIO(image)).convert("RGB")
98
+
99
+ if hasattr(image, "read") and callable(image.read):
100
+ return Image.open(image).convert("RGB")
101
+
102
+ if hasattr(image, "convert"):
103
+ return image.convert("RGB")
104
+
105
+ raise TypeError(
106
+ f"Unsupported image input type: {type(image)!r}. "
107
+ f"Expected filepath (str/Path), URL (http/https), PIL.Image, or raw bytes."
108
+ )
109
+
110
+
111
+ class OpenCLIPBackend:
112
+ """Production vision & text encoder powered by OpenCLIP.
113
+
114
+ Default backbone: ViT-B-32 trained on LAION-2B (laion2b_s34b_b79k).
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ name: str = "ViT-B-32",
120
+ pretrained: str = "laion2b_s34b_b79k",
121
+ device: str | None = None,
122
+ cache_dir: str | Path | None = None,
123
+ ):
124
+ import open_clip
125
+ import torch
126
+
127
+ self.torch = torch
128
+ self.device = device or (
129
+ "cuda" if torch.cuda.is_available()
130
+ else "mps" if hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
131
+ else "cpu"
132
+ )
133
+ self.model, _, self.pre = open_clip.create_model_and_transforms(
134
+ name,
135
+ pretrained=pretrained,
136
+ device=self.device,
137
+ cache_dir=str(cache_dir) if cache_dir else None,
138
+ )
139
+ self.model.eval()
140
+ self.tok = open_clip.get_tokenizer(name)
141
+ self.logit_scale = float(self.model.logit_scale.exp().detach().cpu().item())
142
+
143
+ def encode_image(self, image: Any) -> np.ndarray:
144
+ pil_img = load_image_to_pil(image)
145
+ with self.torch.inference_mode():
146
+ tensor = self.pre(pil_img).unsqueeze(0).to(self.device)
147
+ v = self.model.encode_image(tensor)[0]
148
+ v = (v / v.norm()).float().cpu().numpy()
149
+ return v
150
+
151
+ def encode_images(self, images: Sequence[Any]) -> np.ndarray:
152
+ pil_images = [load_image_to_pil(img) for img in images]
153
+ with self.torch.inference_mode():
154
+ tensors = self.torch.stack([self.pre(img) for img in pil_images]).to(self.device)
155
+ v = self.model.encode_image(tensors)
156
+ v = (v / v.norm(dim=-1, keepdim=True)).float().cpu().numpy()
157
+ return v
158
+
159
+ def encode_text(self, texts: Sequence[str]) -> np.ndarray:
160
+ with self.torch.inference_mode():
161
+ tokens = self.tok(list(texts)).to(self.device)
162
+ v = self.model.encode_text(tokens)
163
+ v = (v / v.norm(dim=-1, keepdim=True)).float().cpu().numpy()
164
+ return v
165
+
166
+
167
+ class MockBackend:
168
+ """Deterministic hash-based backend for fast, network-free unit tests."""
169
+
170
+ logit_scale: float = 100.0
171
+
172
+ def __init__(self, dim: int = 256, noise: float = 0.0, seed: int = 0):
173
+ import hashlib
174
+
175
+ self.dim = dim
176
+ self.noise = noise
177
+ self.rng = np.random.default_rng(seed)
178
+ self._hashlib = hashlib
179
+
180
+ def _tok(self, w: str) -> np.ndarray:
181
+ seed = int(self._hashlib.md5(w.encode()).hexdigest()[:8], 16)
182
+ return np.random.default_rng(seed).standard_normal(self.dim)
183
+
184
+ def _vec(self, text: str) -> np.ndarray:
185
+ words = text.lower().replace(":", " ").split() or [""]
186
+ v = sum(self._tok(w) for w in words)
187
+ return (v / max(np.linalg.norm(v), 1e-9)).astype(np.float32)
188
+
189
+ def encode_text(self, texts: Sequence[str]) -> np.ndarray:
190
+ return np.stack([self._vec(t) for t in texts]).astype(np.float32)
191
+
192
+ def encode_image(self, image: Any) -> np.ndarray:
193
+ text = image if isinstance(image, str) else str(image)
194
+ v = self._vec(text) + self.noise * self.rng.standard_normal(self.dim) / np.sqrt(self.dim)
195
+ return (v / max(np.linalg.norm(v), 1e-9)).astype(np.float32)
196
+
197
+ def encode_images(self, images: Sequence[Any]) -> np.ndarray:
198
+ return np.stack([self.encode_image(img) for img in images]).astype(np.float32)
imagejev/core.py ADDED
@@ -0,0 +1,468 @@
1
+ """ImageJev: The Image-Native Decision Engine.
2
+
3
+ Jev: TEXT + DECISION SCHEMA -> structured decision
4
+ ImageJev: IMAGE + DECISION + SCHEMA -> structured decision
5
+
6
+ Combines frozen CLIP vision-language embeddings with calibrated closed-schema decision math.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ from typing import Any, Dict, List, Sequence, Union
12
+
13
+ import numpy as np
14
+
15
+ from .backends import Backend, OpenCLIPBackend
16
+ from .types import ChoiceResult, NoulResult, ScoreResult
17
+
18
+ # ---------------------------------------------------------------------------------------- Math Helpers
19
+
20
+
21
+ def _softmax(z: np.ndarray) -> np.ndarray:
22
+ """Compute softmax over a 1D logit vector."""
23
+ e = np.exp(z - np.max(z))
24
+ return e / np.sum(e)
25
+
26
+
27
+ def _softmax_batch(z: np.ndarray) -> np.ndarray:
28
+ """Compute softmax over a 2D logit matrix of shape (B, N)."""
29
+ max_z = np.max(z, axis=-1, keepdims=True)
30
+ e = np.exp(z - max_z)
31
+ return e / np.sum(e, axis=-1, keepdims=True)
32
+
33
+
34
+ def _confidence(p: np.ndarray) -> float:
35
+ """Normalized confidence margin.
36
+
37
+ Returns 1.0 when all probability mass is on a single option,
38
+ and 0.0 for a uniform random distribution across N options.
39
+ Formula: (N * max(p) - 1) / (N - 1)
40
+ """
41
+ n = len(p)
42
+ return 1.0 if n < 2 else float(np.clip((n * np.max(p) - 1.0) / (n - 1.0), 0.0, 1.0))
43
+
44
+
45
+ def _dispersion(probs: Dict[int, float]) -> float:
46
+ """Fraction of a Score schema's probability mass outside the peak level and its immediate neighbors."""
47
+ peak = max(probs, key=probs.get)
48
+ adjacent = {peak, peak - 1, peak + 1} & set(probs)
49
+ return float(np.clip(1.0 - sum(probs[k] for k in adjacent), 0.0, 1.0))
50
+
51
+
52
+ # ---------------------------------------------------------------------------------------- Templates & Warnings
53
+
54
+ _OBJECT_TEMPLATE = "{name}: a photo of a {name}"
55
+
56
+ _DEFAULT_NULL_ANCHOR = "unrelated photo, other random object, or different background"
57
+ _DEFAULT_RELEVANCE_THRESHOLD = 0.20
58
+
59
+ _JUDGMENT_WORDS = {
60
+ "yes", "no", "true", "false", "pass", "fail", "good", "bad",
61
+ "positive", "negative", "acceptable", "unacceptable",
62
+ "suitable", "unsuitable", "approved", "rejected", "valid", "invalid",
63
+ }
64
+
65
+
66
+ # ---------------------------------------------------------------------------------------- Core Engine
67
+
68
+
69
+ class ImageJev:
70
+ """Main ImageJev Decision Engine.
71
+
72
+ Parameters:
73
+ backend: Optional custom backend implementing the Backend protocol.
74
+ If None, initializes an OpenCLIPBackend with the specified model and device.
75
+ model: Vision backbone architecture name (default: "ViT-B-32").
76
+ pretrained: Pretrained weights tag (default: "laion2b_s34b_b79k").
77
+ device: Device string ('cuda', 'mps', 'cpu', or None for auto-detect).
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ backend: Backend | None = None,
83
+ model: str = "ViT-B-32",
84
+ pretrained: str = "laion2b_s34b_b79k",
85
+ device: str | None = None,
86
+ ):
87
+ if backend is not None:
88
+ self.backend = backend
89
+ else:
90
+ self.backend = OpenCLIPBackend(name=model, pretrained=pretrained, device=device)
91
+ self._cache: Dict[str, np.ndarray] = {}
92
+
93
+ def _embed(self, text: str) -> np.ndarray:
94
+ if text not in self._cache:
95
+ self._cache[text] = self.backend.encode_text([text])[0]
96
+ return self._cache[text]
97
+
98
+ def _embed_batch(self, texts: Sequence[str]) -> np.ndarray:
99
+ uncached = [t for t in texts if t not in self._cache]
100
+ if uncached:
101
+ encoded = self.backend.encode_text(uncached)
102
+ for t, v in zip(uncached, encoded):
103
+ self._cache[t] = v
104
+ return np.stack([self._cache[t] for t in texts])
105
+
106
+ def decide(
107
+ self,
108
+ image: Any,
109
+ decision: str,
110
+ schema: dict,
111
+ ) -> Union[ChoiceResult, ScoreResult, NoulResult]:
112
+ """Make a structured decision on a single visual input.
113
+
114
+ Args:
115
+ image: Path (str or Path), URL (http/https), PIL.Image, or raw bytes.
116
+ decision: Plain-language question or framing statement.
117
+ schema: Decision schema dictionary:
118
+ - {"type": "choice", "options": {...}}
119
+ - {"type": "score", "levels": [...]}
120
+ - {"type": "noul", "true": "...", "false": "..."}
121
+
122
+ Returns:
123
+ ChoiceResult, ScoreResult, or NoulResult based on schema type.
124
+ """
125
+ schema_type = schema.get("type")
126
+ if schema_type == "choice":
127
+ return self._choice(image, schema)
128
+ if schema_type == "score":
129
+ return self._score(image, schema)
130
+ if schema_type == "noul":
131
+ return self._noul(image, decision, schema)
132
+ raise ValueError(
133
+ f'schema["type"] must be "choice", "score", or "noul", got {schema_type!r}'
134
+ )
135
+
136
+ def decide_batch(
137
+ self,
138
+ images: Sequence[Any],
139
+ decision: str,
140
+ schema: dict,
141
+ ) -> List[Union[ChoiceResult, ScoreResult, NoulResult]]:
142
+ """Make structured decisions on a batch of visual inputs with high GPU/CPU throughput.
143
+
144
+ Args:
145
+ images: Sequence of images (Paths, URLs, PIL.Images, or bytes).
146
+ decision: Plain-language question or framing statement.
147
+ schema: Decision schema dictionary.
148
+
149
+ Returns:
150
+ List of ChoiceResult, ScoreResult, or NoulResult instances.
151
+ """
152
+ if not images:
153
+ return []
154
+
155
+ schema_type = schema.get("type")
156
+ if schema_type == "choice":
157
+ return self._choice_batch(images, schema)
158
+ if schema_type == "score":
159
+ return self._score_batch(images, schema)
160
+ if schema_type == "noul":
161
+ return self._noul_batch(images, decision, schema)
162
+ raise ValueError(
163
+ f'schema["type"] must be "choice", "score", or "noul", got {schema_type!r}'
164
+ )
165
+
166
+ def decide_multi(
167
+ self,
168
+ image: Any,
169
+ questions: Dict[str, dict],
170
+ ) -> Dict[str, Union[ChoiceResult, ScoreResult, NoulResult]]:
171
+ """Evaluate multiple questions simultaneously on a SINGLE image pass.
172
+
173
+ Encodes the visual input once, then evaluates all question schemas in memory.
174
+
175
+ Args:
176
+ image: Path, URL, PIL.Image, or raw bytes.
177
+ questions: Mapping of question_id -> {"decision": "...", "schema": {...}} or
178
+ {"instructions": "...", "type": "...", ...}
179
+
180
+ Returns:
181
+ Dictionary of question_id -> Result object.
182
+ """
183
+ if not questions:
184
+ return {}
185
+
186
+ img_vec = self.backend.encode_image(image)
187
+ results = {}
188
+
189
+ for qid, qdata in questions.items():
190
+ # Handle both {"decision": "...", "schema": {...}} and flat format
191
+ decision = qdata.get("decision") or qdata.get("instructions") or qdata.get("prompt") or ""
192
+ schema = qdata.get("schema") or qdata
193
+ schema_type = schema.get("type")
194
+
195
+ if schema_type == "choice":
196
+ names, prompts = self._prompts_for_choice(schema.get("options") or schema.get("criteria", {}))
197
+ allow_none = bool(schema.get("allow_none", False))
198
+ thresh = float(schema.get("relevance_threshold", _DEFAULT_RELEVANCE_THRESHOLD))
199
+ null_desc = str(schema.get("null_anchor", _DEFAULT_NULL_ANCHOR))
200
+
201
+ all_prompts = prompts + [null_desc] if allow_none else prompts
202
+ text_vecs = self._embed_batch(all_prompts)
203
+ cos_sims = text_vecs @ img_vec
204
+ in_sims = cos_sims[:len(names)]
205
+ max_rel = float(np.max(in_sims))
206
+ is_rel = bool(max_rel >= thresh)
207
+
208
+ logits = self.backend.logit_scale * cos_sims
209
+ probs = _softmax(logits)
210
+ top_idx = int(np.argmax(probs))
211
+
212
+ if allow_none and (top_idx == len(names) or not is_rel):
213
+ results[qid] = ChoiceResult(
214
+ type="choice",
215
+ choice=None,
216
+ probability=float(probs[len(names)]) if allow_none else float(probs[top_idx]),
217
+ confidence=max_rel,
218
+ is_relevant=False,
219
+ relevance=round(max_rel, 4),
220
+ probabilities={name: float(p) for name, p in zip(names, probs[:len(names)])},
221
+ )
222
+ else:
223
+ opt_probs = probs[:len(names)] / np.sum(probs[:len(names)]) if allow_none else probs
224
+ best_idx = int(np.argmax(opt_probs))
225
+ results[qid] = ChoiceResult(
226
+ type="choice",
227
+ choice=names[best_idx],
228
+ probability=float(opt_probs[best_idx]),
229
+ confidence=_confidence(opt_probs),
230
+ is_relevant=is_rel,
231
+ relevance=round(max_rel, 4),
232
+ probabilities={name: float(p) for name, p in zip(names, opt_probs)},
233
+ )
234
+ elif schema_type == "score":
235
+ levels = list(schema.get("levels") or schema.get("criteria", []))
236
+ if not 2 <= len(levels) <= 10:
237
+ raise ValueError(f"score schema for {qid!r} requires 2-10 levels, got {len(levels)}")
238
+ text_vecs = self._embed_batch(levels)
239
+ logits = self.backend.logit_scale * (text_vecs @ img_vec)
240
+ probs = _softmax(logits)
241
+ peak_idx = int(np.argmax(probs))
242
+ probs_dict = {k: float(x) for k, x in enumerate(probs)}
243
+ results[qid] = ScoreResult(
244
+ type="score",
245
+ score=float(probs @ np.arange(len(probs))),
246
+ probability=float(probs[peak_idx]),
247
+ confidence=_confidence(probs),
248
+ dispersion=_dispersion(probs_dict),
249
+ legend=dict(enumerate(levels)),
250
+ probabilities=probs_dict,
251
+ )
252
+ elif schema_type == "noul":
253
+ true_text, false_text = self._resolve_noul_prompts(decision, schema)
254
+ vecs = self._embed_batch([true_text, false_text])
255
+ logits = self.backend.logit_scale * (vecs @ img_vec)
256
+ prob_true = float(1.0 / (1.0 + np.exp(-(logits[0] - logits[1]))))
257
+ results[qid] = NoulResult(type="noul", probability=prob_true, noul=prob_true)
258
+ else:
259
+ raise ValueError(f'schema["type"] for {qid!r} must be "choice", "score", or "noul", got {schema_type!r}')
260
+
261
+ return results
262
+
263
+ # -------------------------------------------------------------------------------- Choice Primitive
264
+
265
+ def _prompts_for_choice(self, options: Union[dict, list]) -> tuple[list[str], list[str]]:
266
+ if isinstance(options, dict):
267
+ names, descs = list(options.keys()), list(options.values())
268
+ elif isinstance(options, (list, tuple)):
269
+ names, descs = list(options), [None] * len(options)
270
+ else:
271
+ raise TypeError(f"choice schema 'options' must be a dict or list, got {type(options)}")
272
+
273
+ prompts, risky = [], []
274
+ for name, desc in zip(names, descs):
275
+ if desc:
276
+ prompts.append(f"{name}: {desc}")
277
+ else:
278
+ prompts.append(_OBJECT_TEMPLATE.format(name=name))
279
+ if str(name).strip().lower() in _JUDGMENT_WORDS:
280
+ risky.append(name)
281
+
282
+ if risky:
283
+ warnings.warn(
284
+ f"choice option(s) {risky} look like judgment words, not object names, but have no "
285
+ f"description, so they received the generic object-class template "
286
+ f"('{_OBJECT_TEMPLATE.format(name=risky[0])}'). For subjective or quality judgments, "
287
+ f"provide explicit option descriptions in the schema, or use schema={{'type': 'noul'}}.",
288
+ UserWarning,
289
+ stacklevel=3,
290
+ )
291
+ return names, prompts
292
+
293
+ def _choice(self, image: Any, schema: dict) -> ChoiceResult:
294
+ names, prompts = self._prompts_for_choice(schema["options"])
295
+ allow_none = bool(schema.get("allow_none", False))
296
+ thresh = float(schema.get("relevance_threshold", _DEFAULT_RELEVANCE_THRESHOLD))
297
+ null_desc = str(schema.get("null_anchor", _DEFAULT_NULL_ANCHOR))
298
+
299
+ all_prompts = prompts + [null_desc] if allow_none else prompts
300
+ img_vec = self.backend.encode_image(image)
301
+ text_vecs = self._embed_batch(all_prompts)
302
+ cos_sims = text_vecs @ img_vec
303
+ in_sims = cos_sims[:len(names)]
304
+ max_rel = float(np.max(in_sims))
305
+ is_rel = bool(max_rel >= thresh)
306
+
307
+ logits = self.backend.logit_scale * cos_sims
308
+ probs = _softmax(logits)
309
+ top_idx = int(np.argmax(probs))
310
+
311
+ if allow_none and (top_idx == len(names) or not is_rel):
312
+ return ChoiceResult(
313
+ choice=None,
314
+ probability=float(probs[len(names)]),
315
+ confidence=max_rel,
316
+ is_relevant=False,
317
+ relevance=round(max_rel, 4),
318
+ probabilities={name: float(p) for name, p in zip(names, probs[:len(names)])},
319
+ )
320
+
321
+ opt_probs = probs[:len(names)] / np.sum(probs[:len(names)]) if allow_none else probs
322
+ best_idx = int(np.argmax(opt_probs))
323
+
324
+ return ChoiceResult(
325
+ choice=names[best_idx],
326
+ probability=float(opt_probs[best_idx]),
327
+ confidence=_confidence(opt_probs),
328
+ is_relevant=is_rel,
329
+ relevance=round(max_rel, 4),
330
+ probabilities={name: float(p) for name, p in zip(names, opt_probs)},
331
+ )
332
+
333
+ def _choice_batch(self, images: Sequence[Any], schema: dict) -> List[ChoiceResult]:
334
+ names, prompts = self._prompts_for_choice(schema["options"])
335
+ allow_none = bool(schema.get("allow_none", False))
336
+ thresh = float(schema.get("relevance_threshold", _DEFAULT_RELEVANCE_THRESHOLD))
337
+ null_desc = str(schema.get("null_anchor", _DEFAULT_NULL_ANCHOR))
338
+
339
+ all_prompts = prompts + [null_desc] if allow_none else prompts
340
+ img_mat = self.backend.encode_images(images) # (B, D)
341
+ text_mat = self._embed_batch(all_prompts) # (N_all, D)
342
+ cos_mat = img_mat @ text_mat.T # (B, N_all)
343
+ logits = self.backend.logit_scale * cos_mat # (B, N_all)
344
+ probs = _softmax_batch(logits) # (B, N_all)
345
+
346
+ results = []
347
+ for i, p in enumerate(probs):
348
+ in_sims = cos_mat[i, :len(names)]
349
+ max_rel = float(np.max(in_sims))
350
+ is_rel = bool(max_rel >= thresh)
351
+ top_idx = int(np.argmax(p))
352
+
353
+ if allow_none and (top_idx == len(names) or not is_rel):
354
+ results.append(
355
+ ChoiceResult(
356
+ choice=None,
357
+ probability=float(p[len(names)]),
358
+ confidence=max_rel,
359
+ is_relevant=False,
360
+ relevance=round(max_rel, 4),
361
+ probabilities={name: float(val) for name, val in zip(names, p[:len(names)])},
362
+ )
363
+ )
364
+ else:
365
+ opt_probs = p[:len(names)] / np.sum(p[:len(names)]) if allow_none else p
366
+ best_idx = int(np.argmax(opt_probs))
367
+ results.append(
368
+ ChoiceResult(
369
+ choice=names[best_idx],
370
+ probability=float(opt_probs[best_idx]),
371
+ confidence=_confidence(opt_probs),
372
+ is_relevant=is_rel,
373
+ relevance=round(max_rel, 4),
374
+ probabilities={name: float(val) for name, val in zip(names, opt_probs)},
375
+ )
376
+ )
377
+ return results
378
+
379
+ # -------------------------------------------------------------------------------- Score Primitive
380
+
381
+ def _score(self, image: Any, schema: dict) -> ScoreResult:
382
+ levels = list(schema.get("levels", []))
383
+ if not 2 <= len(levels) <= 10:
384
+ raise ValueError(f"score schema requires 2-10 levels, got {len(levels)}")
385
+
386
+ img_vec = self.backend.encode_image(image)
387
+ text_vecs = self._embed_batch(levels)
388
+ logits = self.backend.logit_scale * (text_vecs @ img_vec)
389
+ probs = _softmax(logits)
390
+ peak_idx = int(np.argmax(probs))
391
+ probs_dict = {k: float(x) for k, x in enumerate(probs)}
392
+
393
+ return ScoreResult(
394
+ score=float(probs @ np.arange(len(probs))),
395
+ probability=float(probs[peak_idx]),
396
+ confidence=_confidence(probs),
397
+ dispersion=_dispersion(probs_dict),
398
+ legend=dict(enumerate(levels)),
399
+ probabilities=probs_dict,
400
+ )
401
+
402
+ def _score_batch(self, images: Sequence[Any], schema: dict) -> List[ScoreResult]:
403
+ levels = list(schema.get("levels", []))
404
+ if not 2 <= len(levels) <= 10:
405
+ raise ValueError(f"score schema requires 2-10 levels, got {len(levels)}")
406
+
407
+ img_mat = self.backend.encode_images(images) # (B, D)
408
+ text_mat = self._embed_batch(levels) # (N, D)
409
+ logits = self.backend.logit_scale * (img_mat @ text_mat.T) # (B, N)
410
+ probs = _softmax_batch(logits) # (B, N)
411
+ legend_map = dict(enumerate(levels))
412
+
413
+ results = []
414
+ for p in probs:
415
+ peak_idx = int(np.argmax(p))
416
+ probs_dict = {k: float(x) for k, x in enumerate(p)}
417
+ results.append(
418
+ ScoreResult(
419
+ score=float(p @ np.arange(len(p))),
420
+ probability=float(p[peak_idx]),
421
+ confidence=_confidence(p),
422
+ dispersion=_dispersion(probs_dict),
423
+ legend=legend_map,
424
+ probabilities=probs_dict,
425
+ )
426
+ )
427
+ return results
428
+
429
+ # -------------------------------------------------------------------------------- Noul Primitive
430
+
431
+ def _resolve_noul_prompts(self, decision: str, schema: dict) -> tuple[str, str]:
432
+ true_text = schema.get("true")
433
+ false_text = schema.get("false")
434
+ if not true_text or not false_text:
435
+ true_text = true_text or f"Yes. {decision}"
436
+ false_text = false_text or f"No. {decision}"
437
+ stripped = decision.strip()
438
+ if stripped.endswith("?") or (
439
+ stripped.split()[:1]
440
+ and stripped.split()[0].lower() in (
441
+ "does", "is", "are", "can", "will", "has", "have", "do", "did", "was", "were"
442
+ )
443
+ ):
444
+ warnings.warn(
445
+ f'decision ("{decision}") looks like a question without explicit true/false criteria. '
446
+ f'Using generic Yes./No. wrappers. For optimal contrastive sharpness, '
447
+ f'provide explicit true/false criteria, e.g. {{"true": "a cat is present", "false": "no cats"}}.',
448
+ UserWarning,
449
+ stacklevel=4,
450
+ )
451
+ return true_text, false_text
452
+
453
+ def _noul(self, image: Any, decision: str, schema: dict) -> NoulResult:
454
+ true_text, false_text = self._resolve_noul_prompts(decision, schema)
455
+ img_vec = self.backend.encode_image(image)
456
+ vecs = self._embed_batch([true_text, false_text])
457
+ logits = self.backend.logit_scale * (vecs @ img_vec)
458
+ prob_true = float(1.0 / (1.0 + np.exp(-(logits[0] - logits[1]))))
459
+ return NoulResult(probability=prob_true)
460
+
461
+ def _noul_batch(self, images: Sequence[Any], decision: str, schema: dict) -> List[NoulResult]:
462
+ true_text, false_text = self._resolve_noul_prompts(decision, schema)
463
+ img_mat = self.backend.encode_images(images) # (B, D)
464
+ vecs = self._embed_batch([true_text, false_text]) # (2, D)
465
+ logits = self.backend.logit_scale * (img_mat @ vecs.T) # (B, 2)
466
+ diff = logits[:, 0] - logits[:, 1]
467
+ probs = 1.0 / (1.0 + np.exp(-diff))
468
+ return [NoulResult(probability=float(p)) for p in probs]
imagejev/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561. This package uses inline type annotations.