inferencekit-cli 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.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Decode video clips and score them frame by frame, inside the video pipeline's own Python.
|
|
2
|
+
|
|
3
|
+
Usage: python video_metrics.py CONFIG.json
|
|
4
|
+
Prints exactly one line ``INFERKIT_RESULT {...}`` on stdout; the full results go to the file named by
|
|
5
|
+
``out`` in the config; logs go to stderr. Standard library at import time; the work needs PyAV and
|
|
6
|
+
numpy; LPIPS also needs torch and the ``lpips`` package (PSNR, SSIM and MAD are numpy only). A video
|
|
7
|
+
pipeline's venv has all of these; the inferencekit harness has none of them, which is why this runs as a worker (like
|
|
8
|
+
engines/vllm_worker.py) and never imports inferencekit.
|
|
9
|
+
|
|
10
|
+
Config: {"task": "probe" | "compare", "out": path,
|
|
11
|
+
"files": [{"key", "path"}] probe: what decoding the file really gives
|
|
12
|
+
"pairs": [{"key", "a", "b"}] compare: a = the reference side, b = the other
|
|
13
|
+
"metrics": ["psnr", "ssim", "lpips", "mad"], "device": "auto" | "cpu" | "cuda",
|
|
14
|
+
"frame_stride": 1, "psnr_cap_db": 100.0, "lpips_net": "alex", "ssim_backend": "auto" | "numpy" | "torch"}
|
|
15
|
+
|
|
16
|
+
probe, per file (decoded, not read from the container header):
|
|
17
|
+
width/height of the decoded frames, the decoded frame count, fps from the decoded timestamps
|
|
18
|
+
((n - 1) / (t_last - t_first)), audio samples decoded / sample rate; the header's claims beside them.
|
|
19
|
+
compare, per pair, per frame (frames decoded in step, never all held in memory):
|
|
20
|
+
psnr 10 log10(255^2 / MSE) over the RGB24 pixels, capped at psnr_cap_db (identical frames)
|
|
21
|
+
ssim SSIM of the BT.601 luma, 11x11 Gaussian window sigma 1.5, K1 0.01, K2 0.03, valid region
|
|
22
|
+
(Wang et al. 2004; skimage structural_similarity with gaussian_weights=True,
|
|
23
|
+
use_sample_covariance=False and data_range=255 computes the same)
|
|
24
|
+
lpips LPIPS v0.1 (Zhang et al. 2018) with the given backbone, RGB scaled to [-1, 1]
|
|
25
|
+
mad mean absolute difference of the RGB24 pixels (0-255 levels)
|
|
26
|
+
Frames are compared in order up to the shorter clip; both counts are reported. Frames of different
|
|
27
|
+
sizes are not compared (the pair gets an error).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import math
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
# Run by path, this file's directory comes first on sys.path; its neighbours (metrics.py, client.py...)
|
|
37
|
+
# must not shadow a top-level module that torch or PyAV imports.
|
|
38
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
39
|
+
sys.path[:] = [p for p in sys.path if os.path.abspath(p or os.curdir) != _HERE]
|
|
40
|
+
|
|
41
|
+
MARK = "INFERKIT_RESULT "
|
|
42
|
+
METRICS = ("psnr", "ssim", "lpips", "mad")
|
|
43
|
+
LUMA = (0.299, 0.587, 0.114) # BT.601
|
|
44
|
+
SSIM_SIGMA, SSIM_WIN, SSIM_K1, SSIM_K2 = 1.5, 11, 0.01, 0.03
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def log(msg):
|
|
48
|
+
print(msg, file=sys.stderr, flush=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --------------------------------------------------------------------------- probe
|
|
52
|
+
|
|
53
|
+
def probe(path):
|
|
54
|
+
"""What decoding ``path`` gives, beside what its header claims."""
|
|
55
|
+
import av
|
|
56
|
+
|
|
57
|
+
out = {"decoder": "pyav", "video": None, "audio": None, "header": {}}
|
|
58
|
+
with av.open(path) as c:
|
|
59
|
+
out["header"]["format"] = c.format.name
|
|
60
|
+
out["header"]["duration_s"] = c.duration / 1e6 if c.duration else None
|
|
61
|
+
if c.streams.video:
|
|
62
|
+
vs = c.streams.video[0]
|
|
63
|
+
out["header"]["video"] = {"codec": vs.codec_context.name, "width": vs.codec_context.width,
|
|
64
|
+
"height": vs.codec_context.height, "frames": vs.frames or None,
|
|
65
|
+
"fps": float(vs.average_rate) if vs.average_rate else None}
|
|
66
|
+
sizes, times, n = set(), [], 0
|
|
67
|
+
for f in c.decode(vs):
|
|
68
|
+
n += 1
|
|
69
|
+
sizes.add((f.width, f.height))
|
|
70
|
+
if f.time is not None:
|
|
71
|
+
times.append(float(f.time))
|
|
72
|
+
times.sort()
|
|
73
|
+
fps = (len(times) - 1) / (times[-1] - times[0]) if len(times) >= 2 and times[-1] > times[0] else None
|
|
74
|
+
w, h = (sorted(sizes)[0] if sizes else (None, None))
|
|
75
|
+
out["video"] = {"frames": n, "width": w, "height": h, "sizes": sorted(sizes), "fps": fps,
|
|
76
|
+
"first_s": times[0] if times else None, "last_s": times[-1] if times else None,
|
|
77
|
+
"duration_s": n / fps if fps else None}
|
|
78
|
+
with av.open(path) as c:
|
|
79
|
+
if c.streams.audio:
|
|
80
|
+
st = c.streams.audio[0]
|
|
81
|
+
out["header"]["audio"] = {"codec": st.codec_context.name, "sample_rate": st.codec_context.sample_rate,
|
|
82
|
+
"channels": st.codec_context.channels if hasattr(st.codec_context, "channels")
|
|
83
|
+
else None,
|
|
84
|
+
"duration_s": float(st.duration * st.time_base) if st.duration else None}
|
|
85
|
+
samples, rate = 0, None
|
|
86
|
+
for f in c.decode(st):
|
|
87
|
+
samples += f.samples
|
|
88
|
+
rate = rate or f.sample_rate
|
|
89
|
+
out["audio"] = {"samples": samples, "sample_rate": rate,
|
|
90
|
+
"duration_s": samples / rate if rate else None}
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------------- compare
|
|
95
|
+
|
|
96
|
+
class Scorer:
|
|
97
|
+
"""Per-frame metrics. PSNR and MAD run in numpy. SSIM runs in numpy, or in torch (same float64 maths) when
|
|
98
|
+
torch is loaded anyway for LPIPS and the device is a GPU (``ssim_backend`` "auto"), or when asked ("torch"):
|
|
99
|
+
at 1920x1088 numpy SSIM takes ~0.7 s a frame on one CPU core, which adds up over a clip set."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, metrics, device, psnr_cap_db, lpips_net, ssim_backend="auto"):
|
|
102
|
+
import numpy as np
|
|
103
|
+
|
|
104
|
+
self.np = np
|
|
105
|
+
self.metrics = [m for m in metrics if m in METRICS]
|
|
106
|
+
self.cap = float(psnr_cap_db)
|
|
107
|
+
g = np.arange(SSIM_WIN, dtype=np.float64) - (SSIM_WIN - 1) / 2
|
|
108
|
+
g = np.exp(-(g * g) / (2 * SSIM_SIGMA ** 2))
|
|
109
|
+
self.window = g / g.sum()
|
|
110
|
+
self.torch = self.dev = self.lpips = self.lpips_error = None
|
|
111
|
+
self.ssim_backend = "numpy"
|
|
112
|
+
if "lpips" in self.metrics or ("ssim" in self.metrics and ssim_backend == "torch"):
|
|
113
|
+
try:
|
|
114
|
+
import torch
|
|
115
|
+
|
|
116
|
+
self.torch = torch
|
|
117
|
+
if device == "auto":
|
|
118
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
119
|
+
self.dev = torch.device(device)
|
|
120
|
+
except ImportError as e:
|
|
121
|
+
if ssim_backend == "torch":
|
|
122
|
+
raise
|
|
123
|
+
self.lpips_error = f"torch is not installed ({e})"
|
|
124
|
+
if "ssim" in self.metrics and self.torch is not None and (
|
|
125
|
+
ssim_backend == "torch" or (ssim_backend == "auto" and self.dev.type == "cuda")):
|
|
126
|
+
self.ssim_backend = "torch"
|
|
127
|
+
self.twin = self.torch.tensor(self.window, dtype=self.torch.float64, device=self.dev)
|
|
128
|
+
if "lpips" in self.metrics and self.torch is not None:
|
|
129
|
+
try:
|
|
130
|
+
import lpips
|
|
131
|
+
|
|
132
|
+
self.lpips = lpips.LPIPS(net=lpips_net, verbose=False).to(self.dev).eval()
|
|
133
|
+
except Exception as e: # no package, or the backbone weights cannot be fetched
|
|
134
|
+
self.lpips_error = f"{type(e).__name__}: {e}"[:500]
|
|
135
|
+
if self.lpips_error:
|
|
136
|
+
log(f"LPIPS unavailable: {self.lpips_error}")
|
|
137
|
+
self.device = str(self.dev) if self.lpips is not None or self.ssim_backend == "torch" else "cpu"
|
|
138
|
+
|
|
139
|
+
def _blur(self, x):
|
|
140
|
+
"""Separable Gaussian filter over the valid region (no padding): a sum of shifted slices."""
|
|
141
|
+
k, n = self.window, SSIM_WIN
|
|
142
|
+
h, w = x.shape
|
|
143
|
+
rows = sum(k[i] * x[i:h - n + 1 + i, :] for i in range(n))
|
|
144
|
+
return sum(k[j] * rows[:, j:w - n + 1 + j] for j in range(n))
|
|
145
|
+
|
|
146
|
+
def _blur_torch(self, x):
|
|
147
|
+
f = self.torch.nn.functional
|
|
148
|
+
return f.conv2d(f.conv2d(x, self.twin.view(1, 1, -1, 1)), self.twin.view(1, 1, 1, -1))
|
|
149
|
+
|
|
150
|
+
def ssim(self, a, b):
|
|
151
|
+
if min(a.shape[0], a.shape[1]) < SSIM_WIN:
|
|
152
|
+
return None
|
|
153
|
+
c1, c2 = (SSIM_K1 * 255) ** 2, (SSIM_K2 * 255) ** 2
|
|
154
|
+
if self.ssim_backend == "torch":
|
|
155
|
+
torch = self.torch
|
|
156
|
+
luma = torch.tensor(LUMA, dtype=torch.float64, device=self.dev)
|
|
157
|
+
ya = (torch.from_numpy(a).to(self.dev, torch.float64) @ luma)[None, None]
|
|
158
|
+
yb = (torch.from_numpy(b).to(self.dev, torch.float64) @ luma)[None, None]
|
|
159
|
+
blur = self._blur_torch
|
|
160
|
+
else:
|
|
161
|
+
np = self.np
|
|
162
|
+
luma = np.asarray(LUMA, dtype=np.float64)
|
|
163
|
+
ya, yb = a.astype(np.float64) @ luma, b.astype(np.float64) @ luma
|
|
164
|
+
blur = self._blur
|
|
165
|
+
mu_a, mu_b = blur(ya), blur(yb)
|
|
166
|
+
va = blur(ya * ya) - mu_a * mu_a
|
|
167
|
+
vb = blur(yb * yb) - mu_b * mu_b
|
|
168
|
+
cov = blur(ya * yb) - mu_a * mu_b
|
|
169
|
+
s = ((2 * mu_a * mu_b + c1) * (2 * cov + c2)) / ((mu_a * mu_a + mu_b * mu_b + c1) * (va + vb + c2))
|
|
170
|
+
return float(s.mean())
|
|
171
|
+
|
|
172
|
+
def lpips_of(self, a, b):
|
|
173
|
+
torch = self.torch
|
|
174
|
+
|
|
175
|
+
def t(x):
|
|
176
|
+
return torch.from_numpy(x).to(self.dev).permute(2, 0, 1)[None].float() / 127.5 - 1.0
|
|
177
|
+
|
|
178
|
+
with torch.no_grad():
|
|
179
|
+
return float(self.lpips(t(a), t(b)).item())
|
|
180
|
+
|
|
181
|
+
def frame(self, a, b):
|
|
182
|
+
np = self.np
|
|
183
|
+
d = a.astype(np.float64) - b.astype(np.float64)
|
|
184
|
+
mse = float(np.mean(d * d))
|
|
185
|
+
row = {"identical": mse == 0.0}
|
|
186
|
+
if "psnr" in self.metrics:
|
|
187
|
+
row["psnr"] = self.cap if mse == 0.0 else min(self.cap, 10.0 * math.log10(255.0 ** 2 / mse))
|
|
188
|
+
if "mad" in self.metrics:
|
|
189
|
+
row["mad"] = float(np.mean(np.abs(d)))
|
|
190
|
+
if "ssim" in self.metrics:
|
|
191
|
+
row["ssim"] = 1.0 if mse == 0.0 else self.ssim(a, b)
|
|
192
|
+
if "lpips" in self.metrics and self.lpips is not None:
|
|
193
|
+
row["lpips"] = 0.0 if mse == 0.0 else self.lpips_of(a, b)
|
|
194
|
+
return row
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _decoder(path):
|
|
198
|
+
import av
|
|
199
|
+
|
|
200
|
+
c = av.open(path)
|
|
201
|
+
s = c.streams.video[0]
|
|
202
|
+
s.thread_type = "AUTO"
|
|
203
|
+
return c, c.decode(s)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def compare(scorer, pa, pb, stride):
|
|
207
|
+
out = {"frames_a": 0, "frames_b": 0, "frames_compared": 0, "size_a": None, "size_b": None,
|
|
208
|
+
"identical_frames": 0, "per_frame": {m: [] for m in scorer.metrics}}
|
|
209
|
+
ca, fa = _decoder(pa)
|
|
210
|
+
cb, fb = _decoder(pb)
|
|
211
|
+
try:
|
|
212
|
+
i = 0
|
|
213
|
+
while True:
|
|
214
|
+
a = next(fa, None)
|
|
215
|
+
b = next(fb, None)
|
|
216
|
+
if a is not None:
|
|
217
|
+
out["frames_a"] += 1
|
|
218
|
+
out["size_a"] = out["size_a"] or [a.width, a.height]
|
|
219
|
+
if b is not None:
|
|
220
|
+
out["frames_b"] += 1
|
|
221
|
+
out["size_b"] = out["size_b"] or [b.width, b.height]
|
|
222
|
+
if a is None or b is None:
|
|
223
|
+
if a is None and b is None:
|
|
224
|
+
break
|
|
225
|
+
continue # count the rest of the longer clip
|
|
226
|
+
if (a.width, a.height) != (b.width, b.height):
|
|
227
|
+
out["error"] = f"frame size differs: {a.width}x{a.height} vs {b.width}x{b.height} (frame {i})"
|
|
228
|
+
break
|
|
229
|
+
if i % stride == 0:
|
|
230
|
+
row = scorer.frame(a.to_ndarray(format="rgb24"), b.to_ndarray(format="rgb24"))
|
|
231
|
+
out["frames_compared"] += 1
|
|
232
|
+
out["identical_frames"] += 1 if row.pop("identical") else 0
|
|
233
|
+
for m, v in row.items():
|
|
234
|
+
out["per_frame"][m].append(v)
|
|
235
|
+
i += 1
|
|
236
|
+
finally:
|
|
237
|
+
ca.close()
|
|
238
|
+
cb.close()
|
|
239
|
+
out["mean"] = {m: (sum(v for v in vs if v is not None) / len([v for v in vs if v is not None])
|
|
240
|
+
if any(v is not None for v in vs) else None) for m, vs in out["per_frame"].items()}
|
|
241
|
+
out["frame_count_differs"] = out["frames_a"] != out["frames_b"]
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# --------------------------------------------------------------------------- main
|
|
246
|
+
|
|
247
|
+
def versions():
|
|
248
|
+
v = {"python": sys.version.split()[0]}
|
|
249
|
+
for name in ("av", "numpy", "torch", "lpips"):
|
|
250
|
+
mod = sys.modules.get(name)
|
|
251
|
+
if mod is not None:
|
|
252
|
+
v[name] = getattr(mod, "__version__", None)
|
|
253
|
+
return v
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main(argv):
|
|
257
|
+
if len(argv) != 2:
|
|
258
|
+
print(__doc__, file=sys.stderr)
|
|
259
|
+
return 2
|
|
260
|
+
with open(argv[1]) as f:
|
|
261
|
+
cfg = json.load(f)
|
|
262
|
+
t0 = time.time()
|
|
263
|
+
items, extra = [], {}
|
|
264
|
+
try:
|
|
265
|
+
import av # noqa: F401 (both tasks need PyAV and numpy: a missing one fails the run, not each clip)
|
|
266
|
+
import numpy # noqa: F401
|
|
267
|
+
|
|
268
|
+
if cfg["task"] == "probe":
|
|
269
|
+
for it in cfg.get("files", []):
|
|
270
|
+
try:
|
|
271
|
+
items.append({"key": it["key"], "ok": True, **probe(it["path"])})
|
|
272
|
+
except Exception as e:
|
|
273
|
+
items.append({"key": it["key"], "ok": False, "error": f"{type(e).__name__}: {e}"[:500]})
|
|
274
|
+
elif cfg["task"] == "compare":
|
|
275
|
+
scorer = Scorer(cfg.get("metrics") or ["psnr", "ssim", "lpips"], cfg.get("device") or "auto",
|
|
276
|
+
cfg.get("psnr_cap_db", 100.0), cfg.get("lpips_net") or "alex",
|
|
277
|
+
cfg.get("ssim_backend") or "auto")
|
|
278
|
+
stride = max(1, int(cfg.get("frame_stride") or 1))
|
|
279
|
+
extra = {"device": scorer.device, "metrics": scorer.metrics, "lpips_error": scorer.lpips_error,
|
|
280
|
+
"ssim_backend": scorer.ssim_backend, "frame_stride": stride, "psnr_cap_db": scorer.cap}
|
|
281
|
+
for it in cfg.get("pairs", []):
|
|
282
|
+
try:
|
|
283
|
+
r = compare(scorer, it["a"], it["b"], stride)
|
|
284
|
+
items.append({"key": it["key"], "ok": "error" not in r, **r})
|
|
285
|
+
except Exception as e:
|
|
286
|
+
items.append({"key": it["key"], "ok": False, "error": f"{type(e).__name__}: {e}"[:500]})
|
|
287
|
+
else:
|
|
288
|
+
raise ValueError(f"unknown task {cfg['task']!r}")
|
|
289
|
+
except ImportError as e: # the venv lacks PyAV / numpy / torch: say which, so the caller can point elsewhere
|
|
290
|
+
print(MARK + json.dumps({"ok": False, "error": f"missing module: {e}", "versions": versions()}), flush=True)
|
|
291
|
+
return 1
|
|
292
|
+
except Exception as e:
|
|
293
|
+
print(MARK + json.dumps({"ok": False, "error": f"{type(e).__name__}: {e}"[:500], "versions": versions()}),
|
|
294
|
+
flush=True)
|
|
295
|
+
return 1
|
|
296
|
+
with open(cfg["out"], "w") as f:
|
|
297
|
+
json.dump({"items": items, "versions": versions(), **extra}, f)
|
|
298
|
+
print(MARK + json.dumps({"ok": True, "n": len(items), "seconds": round(time.time() - t0, 3),
|
|
299
|
+
"versions": versions(), **extra}), flush=True)
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
raise SystemExit(main(sys.argv))
|
inferencekit/registry.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""The operation registry: the single source of truth for what inferencekit can do.
|
|
2
|
+
|
|
3
|
+
Each operation is a plain Python function decorated with :func:`operation`. The
|
|
4
|
+
decorator records its name (the MCP tool name), its CLI path (``("nsys", "analyze")``
|
|
5
|
+
becomes ``inferencekit nsys analyze``), a one-line summary and an argument list. The
|
|
6
|
+
CLI (``cli.py``) and the MCP server (``mcp_server.py``) are generated from this
|
|
7
|
+
registry, so the two interfaces cannot drift apart.
|
|
8
|
+
|
|
9
|
+
Handlers are called as ``handler(call, **validated_args)`` and return a JSON-able
|
|
10
|
+
dict (usually an evidence record). ``call`` carries what the evidence layer needs
|
|
11
|
+
to write provenance: which operation ran, with which arguments, and when.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import datetime as _dt
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
from inferencekit.canonical import digest
|
|
22
|
+
|
|
23
|
+
# JSON-schema type names used for arguments.
|
|
24
|
+
_TYPES = {"string", "integer", "number", "boolean", "array"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OpError(Exception):
|
|
28
|
+
"""A user-facing failure (bad input, missing tool). Printed without a traceback."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Arg:
|
|
33
|
+
"""One argument of an operation.
|
|
34
|
+
|
|
35
|
+
``positional`` only affects the CLI (``inferencekit compare A B`` instead of flags).
|
|
36
|
+
``sensitive`` marks command lines and similar free text: such values are used,
|
|
37
|
+
but only their digest is ever written to an evidence record.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
type: str
|
|
42
|
+
help: str
|
|
43
|
+
required: bool = False
|
|
44
|
+
default: Any = None
|
|
45
|
+
choices: tuple | None = None
|
|
46
|
+
positional: bool = False
|
|
47
|
+
items: str = "string" # element type when type == "array"
|
|
48
|
+
sensitive: bool = False
|
|
49
|
+
|
|
50
|
+
def __post_init__(self):
|
|
51
|
+
if self.type not in _TYPES:
|
|
52
|
+
raise ValueError(f"arg {self.name}: unknown type {self.type}")
|
|
53
|
+
|
|
54
|
+
def json_schema(self) -> dict:
|
|
55
|
+
if self.type == "array":
|
|
56
|
+
schema: dict = {"type": "array", "items": {"type": self.items}}
|
|
57
|
+
if self.choices:
|
|
58
|
+
schema["items"]["enum"] = list(self.choices)
|
|
59
|
+
else:
|
|
60
|
+
schema = {"type": self.type}
|
|
61
|
+
if self.choices:
|
|
62
|
+
schema["enum"] = list(self.choices)
|
|
63
|
+
schema["description"] = self.help
|
|
64
|
+
if self.default is not None:
|
|
65
|
+
schema["default"] = self.default
|
|
66
|
+
return schema
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Operation:
|
|
71
|
+
name: str
|
|
72
|
+
cli: tuple[str, ...]
|
|
73
|
+
summary: str
|
|
74
|
+
args: tuple[Arg, ...]
|
|
75
|
+
handler: Callable[..., dict]
|
|
76
|
+
passthrough: bool = False # True: takes a command after "--" as the "argv" argument
|
|
77
|
+
|
|
78
|
+
def input_schema(self) -> dict:
|
|
79
|
+
"""JSON Schema for the MCP tool's input (same names and defaults as the CLI)."""
|
|
80
|
+
return {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"properties": {a.name: a.json_schema() for a in self.args},
|
|
83
|
+
"required": [a.name for a in self.args if a.required],
|
|
84
|
+
"additionalProperties": False,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def validate(self, raw: dict | None) -> dict:
|
|
88
|
+
"""Check types, fill defaults, reject unknown names. Same rules for CLI and MCP."""
|
|
89
|
+
raw = dict(raw or {})
|
|
90
|
+
known = {a.name for a in self.args}
|
|
91
|
+
unknown = sorted(set(raw) - known)
|
|
92
|
+
if unknown:
|
|
93
|
+
raise OpError(f"{self.name}: unknown argument(s) {unknown}; expected {sorted(known)}")
|
|
94
|
+
out = {}
|
|
95
|
+
for a in self.args:
|
|
96
|
+
value = raw.get(a.name)
|
|
97
|
+
if value is None:
|
|
98
|
+
if a.required:
|
|
99
|
+
raise OpError(f"{self.name}: missing required argument '{a.name}'")
|
|
100
|
+
value = copy.deepcopy(a.default)
|
|
101
|
+
else:
|
|
102
|
+
value = _coerce(self.name, a, value)
|
|
103
|
+
if a.choices and value is not None:
|
|
104
|
+
for v in value if isinstance(value, list) else [value]:
|
|
105
|
+
if v not in a.choices:
|
|
106
|
+
raise OpError(f"{self.name}: {a.name}={v!r} is not one of {list(a.choices)}")
|
|
107
|
+
out[a.name] = value
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _coerce(op: str, a: Arg, value: Any) -> Any:
|
|
112
|
+
"""Convert CLI strings (and lenient MCP inputs) to the declared type."""
|
|
113
|
+
try:
|
|
114
|
+
if a.type == "integer":
|
|
115
|
+
if isinstance(value, bool) or (isinstance(value, float) and not value.is_integer()):
|
|
116
|
+
raise ValueError
|
|
117
|
+
return int(value)
|
|
118
|
+
if a.type == "number":
|
|
119
|
+
if isinstance(value, bool):
|
|
120
|
+
raise ValueError
|
|
121
|
+
return float(value)
|
|
122
|
+
if a.type == "boolean":
|
|
123
|
+
if isinstance(value, bool):
|
|
124
|
+
return value
|
|
125
|
+
if str(value).lower() in ("1", "true", "yes", "on"):
|
|
126
|
+
return True
|
|
127
|
+
if str(value).lower() in ("0", "false", "no", "off"):
|
|
128
|
+
return False
|
|
129
|
+
raise ValueError
|
|
130
|
+
if a.type == "array":
|
|
131
|
+
if isinstance(value, (str, int, float)):
|
|
132
|
+
value = [value]
|
|
133
|
+
sub = Arg(a.name, a.items, a.help)
|
|
134
|
+
return [_coerce(op, sub, v) for v in value]
|
|
135
|
+
return str(value)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
raise OpError(f"{op}: argument '{a.name}' expects {a.type}, got {value!r}") from None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class Call:
|
|
142
|
+
"""One invocation of an operation; handed to the handler for provenance."""
|
|
143
|
+
|
|
144
|
+
op: Operation
|
|
145
|
+
args: dict
|
|
146
|
+
started: str = field(default_factory=lambda: utc_now())
|
|
147
|
+
|
|
148
|
+
def argv_digest(self) -> str:
|
|
149
|
+
# Digest of the full invocation (operation path + every argument, including
|
|
150
|
+
# sensitive ones). Lets you check "was this the same command?" without storing it.
|
|
151
|
+
return digest(["inferencekit", *self.op.cli, self.args])
|
|
152
|
+
|
|
153
|
+
def request(self) -> dict:
|
|
154
|
+
"""The invocation with sensitive values replaced by digests (safe to store)."""
|
|
155
|
+
safe = {}
|
|
156
|
+
for a in self.op.args:
|
|
157
|
+
v = self.args.get(a.name)
|
|
158
|
+
if a.sensitive and v not in (None, [], ""):
|
|
159
|
+
v = {"digest": digest(v)}
|
|
160
|
+
safe[a.name] = v
|
|
161
|
+
return {"op": self.op.name, "cli": "inferencekit " + " ".join(self.op.cli), "args": safe}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def utc_now() -> str:
|
|
165
|
+
return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
REGISTRY: dict[str, Operation] = {}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def operation(name: str, cli: tuple[str, ...], summary: str, args: list[Arg] | tuple[Arg, ...] = (),
|
|
172
|
+
passthrough: bool = False):
|
|
173
|
+
"""Decorator: register ``fn`` as operation ``name``."""
|
|
174
|
+
|
|
175
|
+
def register(fn: Callable[..., dict]) -> Callable[..., dict]:
|
|
176
|
+
if name in REGISTRY:
|
|
177
|
+
raise ValueError(f"operation {name} registered twice")
|
|
178
|
+
all_args = tuple(args)
|
|
179
|
+
if passthrough:
|
|
180
|
+
all_args += (Arg("argv", "array", "Command to run (after '--' on the CLI).", required=True,
|
|
181
|
+
sensitive=True),)
|
|
182
|
+
REGISTRY[name] = Operation(name, tuple(cli), summary, all_args, fn, passthrough)
|
|
183
|
+
return fn
|
|
184
|
+
|
|
185
|
+
return register
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_all() -> dict[str, Operation]:
|
|
189
|
+
"""Import every module in inferencekit.ops so their decorators run."""
|
|
190
|
+
import inferencekit.ops # noqa: F401 (the package __init__ imports each op module)
|
|
191
|
+
|
|
192
|
+
return REGISTRY
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get(name: str) -> Operation:
|
|
196
|
+
load_all()
|
|
197
|
+
if name not in REGISTRY:
|
|
198
|
+
raise OpError(f"unknown operation '{name}'; known: {sorted(REGISTRY)}")
|
|
199
|
+
return REGISTRY[name]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def call(name: str, raw_args: dict | None = None) -> dict:
|
|
203
|
+
"""Validate arguments and run an operation. Both front ends end up here."""
|
|
204
|
+
op = get(name)
|
|
205
|
+
args = op.validate(raw_args)
|
|
206
|
+
return op.handler(Call(op, args), **args)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""The review page: every engine's outputs side by side, per input bucket, in one offline HTML file.
|
|
2
|
+
|
|
3
|
+
``rundir`` reads ``runs/<run-id>/``; ``diff`` compares texts (first divergence, word
|
|
4
|
+
diffs, word errors); ``page`` renders the HTML. The ``review build`` operation
|
|
5
|
+
(``inferencekit.ops.review``) ties them together.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Text comparison for the review page: first divergence, word diffs and word-error alignment.
|
|
2
|
+
|
|
3
|
+
Three questions, three functions:
|
|
4
|
+
* ``first_token_divergence``: where do two greedy generations stop agreeing, in tokens?
|
|
5
|
+
* ``word_diff``: which words of a candidate text differ from the reference text, for
|
|
6
|
+
highlighting (inserted, deleted and replaced runs of words, whitespace preserved)?
|
|
7
|
+
* ``align_words``: which words of a hypothesis transcript are substitutions,
|
|
8
|
+
insertions or deletions against a ground truth, and the word error rate.
|
|
9
|
+
|
|
10
|
+
Everything is plain Python (``difflib`` plus a small Levenshtein table), so the page can
|
|
11
|
+
be built anywhere without extra dependencies.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
import re
|
|
18
|
+
import unicodedata
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
_WORD_SPACE = re.compile(r"(\S+)(\s*)")
|
|
22
|
+
_LEAD_SPACE = re.compile(r"\s*")
|
|
23
|
+
# Exact Levenshtein alignment up to this many table cells; above it, difflib anchors the
|
|
24
|
+
# alignment on long matching runs and the table only fills the gaps between them.
|
|
25
|
+
EXACT_CELLS = 250_000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def first_token_divergence(a: list | None, b: list | None) -> int | None:
|
|
29
|
+
"""Index of the first differing token id; None if the sequences are identical (length included)."""
|
|
30
|
+
a, b = list(a or []), list(b or [])
|
|
31
|
+
for i, (x, y) in enumerate(zip(a, b)):
|
|
32
|
+
if x != y:
|
|
33
|
+
return i
|
|
34
|
+
return None if len(a) == len(b) else min(len(a), len(b))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def words(text: str) -> tuple[str, list[tuple[str, str]]]:
|
|
38
|
+
"""(leading whitespace, [(word, whitespace after it)]): the text, losslessly, as words."""
|
|
39
|
+
text = text or ""
|
|
40
|
+
lead = _LEAD_SPACE.match(text).group(0)
|
|
41
|
+
return lead, _WORD_SPACE.findall(text[len(lead):])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class TextDiff:
|
|
46
|
+
"""A candidate text as segments against a reference text.
|
|
47
|
+
|
|
48
|
+
``segments`` holds ``(tag, reference_text, candidate_text)`` with tag one of
|
|
49
|
+
``equal``, ``replace``, ``delete`` (only in the reference) or ``insert`` (only in the
|
|
50
|
+
candidate). Joining every candidate_text gives back the candidate exactly.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
lead: str
|
|
54
|
+
segments: list[tuple[str, str, str]] = field(default_factory=list)
|
|
55
|
+
first_word: int | None = None # index of the first differing word in the candidate; None = identical
|
|
56
|
+
ref_words: int = 0
|
|
57
|
+
cand_words: int = 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def identical(self) -> bool:
|
|
61
|
+
return self.first_word is None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def word_diff(reference: str, candidate: str) -> TextDiff:
|
|
65
|
+
"""Word-level diff of ``candidate`` against ``reference`` (whitespace kept for display)."""
|
|
66
|
+
_, ref = words(reference)
|
|
67
|
+
lead, cand = words(candidate)
|
|
68
|
+
sm = difflib.SequenceMatcher(a=[w for w, _ in ref], b=[w for w, _ in cand], autojunk=False)
|
|
69
|
+
out = TextDiff(lead=lead, ref_words=len(ref), cand_words=len(cand))
|
|
70
|
+
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
|
71
|
+
r = "".join(w + s for w, s in ref[i1:i2])
|
|
72
|
+
c = "".join(w + s for w, s in cand[j1:j2])
|
|
73
|
+
if tag != "equal" and out.first_word is None:
|
|
74
|
+
out.first_word = j1
|
|
75
|
+
out.segments.append((tag, r, c))
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- word errors
|
|
80
|
+
|
|
81
|
+
def normalize_word(word: str) -> str:
|
|
82
|
+
"""Lowercase, Unicode NFKC, punctuation removed: the usual WER normalisation for one word."""
|
|
83
|
+
w = unicodedata.normalize("NFKC", word).lower()
|
|
84
|
+
return "".join(ch for ch in w if not unicodedata.category(ch).startswith("P"))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class Alignment:
|
|
89
|
+
"""Word-level alignment of a hypothesis against a ground truth.
|
|
90
|
+
|
|
91
|
+
``ops`` holds ``(op, reference_word, hypothesis_word)`` with op one of ``ok``, ``sub``,
|
|
92
|
+
``del`` (a ground-truth word the hypothesis missed) or ``ins`` (an extra hypothesis
|
|
93
|
+
word). Words are shown in their original spelling; matching uses ``normalize_word``.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
ops: list[tuple[str, str | None, str | None]]
|
|
97
|
+
ref_len: int
|
|
98
|
+
exact: bool # True: minimal edit distance; False: anchored on long matches (very long texts)
|
|
99
|
+
|
|
100
|
+
def count(self, op: str) -> int:
|
|
101
|
+
return sum(1 for o, _, _ in self.ops if o == op)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def wer(self) -> float | None:
|
|
105
|
+
if self.ref_len == 0:
|
|
106
|
+
return None
|
|
107
|
+
return (self.count("sub") + self.count("del") + self.count("ins")) / self.ref_len
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _tokens(text: str) -> list[tuple[str, str]]:
|
|
111
|
+
"""(normalised, original) for each word that is not pure punctuation."""
|
|
112
|
+
out = []
|
|
113
|
+
for w in (text or "").split():
|
|
114
|
+
n = normalize_word(w)
|
|
115
|
+
if n:
|
|
116
|
+
out.append((n, w))
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _levenshtein(ref: list[tuple[str, str]], hyp: list[tuple[str, str]]) -> list[tuple[str, str | None, str | None]]:
|
|
121
|
+
"""Minimal-edit alignment (substitution, insertion, deletion all cost 1), by dynamic programming."""
|
|
122
|
+
n, m = len(ref), len(hyp)
|
|
123
|
+
# dist[i][j]: edits to turn ref[:i] into hyp[:j].
|
|
124
|
+
dist = [[0] * (m + 1) for _ in range(n + 1)]
|
|
125
|
+
for i in range(n + 1):
|
|
126
|
+
dist[i][0] = i
|
|
127
|
+
for j in range(m + 1):
|
|
128
|
+
dist[0][j] = j
|
|
129
|
+
for i in range(1, n + 1):
|
|
130
|
+
ri, row, prev = ref[i - 1][0], dist[i], dist[i - 1]
|
|
131
|
+
for j in range(1, m + 1):
|
|
132
|
+
cost = 0 if ri == hyp[j - 1][0] else 1
|
|
133
|
+
row[j] = min(prev[j - 1] + cost, prev[j] + 1, row[j - 1] + 1)
|
|
134
|
+
ops = []
|
|
135
|
+
i, j = n, m
|
|
136
|
+
while i > 0 or j > 0: # walk back from the corner; prefer match/substitution, then deletion
|
|
137
|
+
if i > 0 and j > 0 and dist[i][j] == dist[i - 1][j - 1] + (0 if ref[i - 1][0] == hyp[j - 1][0] else 1):
|
|
138
|
+
ops.append(("ok" if ref[i - 1][0] == hyp[j - 1][0] else "sub", ref[i - 1][1], hyp[j - 1][1]))
|
|
139
|
+
i, j = i - 1, j - 1
|
|
140
|
+
elif i > 0 and dist[i][j] == dist[i - 1][j] + 1:
|
|
141
|
+
ops.append(("del", ref[i - 1][1], None))
|
|
142
|
+
i -= 1
|
|
143
|
+
else:
|
|
144
|
+
ops.append(("ins", None, hyp[j - 1][1]))
|
|
145
|
+
j -= 1
|
|
146
|
+
ops.reverse()
|
|
147
|
+
return ops
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def align_words(reference: str, hypothesis: str, exact_cells: int = EXACT_CELLS) -> Alignment:
|
|
151
|
+
"""Align a hypothesis transcript to the ground truth: substitutions, insertions, deletions."""
|
|
152
|
+
ref, hyp = _tokens(reference), _tokens(hypothesis)
|
|
153
|
+
if (len(ref) + 1) * (len(hyp) + 1) <= exact_cells:
|
|
154
|
+
return Alignment(_levenshtein(ref, hyp), len(ref), exact=True)
|
|
155
|
+
# Long transcripts (minutes of audio): anchor on matching runs, align each gap exactly.
|
|
156
|
+
sm = difflib.SequenceMatcher(a=[r for r, _ in ref], b=[h for h, _ in hyp], autojunk=False)
|
|
157
|
+
ops: list[tuple[str, str | None, str | None]] = []
|
|
158
|
+
i = j = 0
|
|
159
|
+
for block in sm.get_matching_blocks():
|
|
160
|
+
ops += _levenshtein(ref[i:block.a], hyp[j:block.b])
|
|
161
|
+
ops += [("ok", ref[block.a + k][1], hyp[block.b + k][1]) for k in range(block.size)]
|
|
162
|
+
i, j = block.a + block.size, block.b + block.size
|
|
163
|
+
return Alignment(ops, len(ref), exact=False)
|