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 +22 -0
- imagejev/backends.py +198 -0
- imagejev/core.py +468 -0
- imagejev/py.typed +1 -0
- imagejev/rate_limiter.py +158 -0
- imagejev/server.py +417 -0
- imagejev/server_prod.py +15 -0
- imagejev/types.py +80 -0
- imagejev-0.1.0.dist-info/METADATA +253 -0
- imagejev-0.1.0.dist-info/RECORD +14 -0
- imagejev-0.1.0.dist-info/WHEEL +5 -0
- imagejev-0.1.0.dist-info/entry_points.txt +2 -0
- imagejev-0.1.0.dist-info/licenses/LICENSE +21 -0
- imagejev-0.1.0.dist-info/top_level.txt +1 -0
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.
|