veriscript 1.5.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.
- deva_crnn/__init__.py +1 -0
- deva_crnn/augment.py +242 -0
- deva_crnn/charset.py +31 -0
- deva_crnn/data.py +48 -0
- deva_crnn/gate.py +97 -0
- deva_crnn/model.py +44 -0
- deva_crnn/predict.py +127 -0
- deva_crnn/train.py +254 -0
- veriscript/__init__.py +9 -0
- veriscript/__main__.py +4 -0
- veriscript/branding.py +26 -0
- veriscript/calibration.py +74 -0
- veriscript/cli.py +473 -0
- veriscript/core/__init__.py +1 -0
- veriscript/core/data.py +1586 -0
- veriscript/core/degradation.py +230 -0
- veriscript/core/metrics.py +591 -0
- veriscript/deva/__init__.py +1 -0
- veriscript/deva/reader.py +218 -0
- veriscript/document/__init__.py +1 -0
- veriscript/document/confusions.py +142 -0
- veriscript/document/corrections.py +378 -0
- veriscript/document/export.py +388 -0
- veriscript/document/layout.py +528 -0
- veriscript/document/memory.py +561 -0
- veriscript/document/ocr.py +1295 -0
- veriscript/document/orientation.py +247 -0
- veriscript/document/pipeline.py +367 -0
- veriscript/document/reconcile.py +225 -0
- veriscript/document/restore.py +222 -0
- veriscript/document/router.py +76 -0
- veriscript/document/verifier.py +132 -0
- veriscript/lexicon.py +105 -0
- veriscript/logging_setup.py +44 -0
- veriscript/paths.py +13 -0
- veriscript/photo/__init__.py +1 -0
- veriscript/photo/hybrid.py +736 -0
- veriscript/photo/rrdbnet.py +70 -0
- veriscript/photo/sr_engine.py +390 -0
- veriscript/photo/srvggnet.py +69 -0
- veriscript/photo/tv_refinement.py +142 -0
- veriscript/photo/upscaler.py +360 -0
- veriscript-1.5.0.data/data/calibration/rapidocr_devanagari_v1.json +60 -0
- veriscript-1.5.0.data/data/fonts/Mukta-Regular.ttf +0 -0
- veriscript-1.5.0.data/data/fonts/OFL.txt +93 -0
- veriscript-1.5.0.dist-info/METADATA +342 -0
- veriscript-1.5.0.dist-info/RECORD +51 -0
- veriscript-1.5.0.dist-info/WHEEL +5 -0
- veriscript-1.5.0.dist-info/entry_points.txt +3 -0
- veriscript-1.5.0.dist-info/licenses/LICENSE +21 -0
- veriscript-1.5.0.dist-info/top_level.txt +2 -0
deva_crnn/train.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
train.py — CRNN+CTC trainer for Devanagari lines.
|
|
3
|
+
|
|
4
|
+
Runs locally (CPU, tiny data) and on Vertex AI (T4, prebuilt PyTorch
|
|
5
|
+
container). Data is an npz produced by `deva_crnn.data.export_npz` or by
|
|
6
|
+
`scripts/export_training_data.py`.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python -m deva_crnn.train --data data.npz --out out/deva_crnn \
|
|
10
|
+
--epochs 30 --batch 64 --lr 3e-4
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from typing import Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import torch
|
|
23
|
+
import torch.nn as nn
|
|
24
|
+
from torch.utils.data import DataLoader, Dataset
|
|
25
|
+
|
|
26
|
+
from .charset import build_charset, decode, encode
|
|
27
|
+
from .data import IN_H, IN_W, load_npz
|
|
28
|
+
from .model import CRNN
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_data_path(path: str) -> str:
|
|
32
|
+
"""`pkg://data/x.npz` resolves inside the installed package (Vertex jobs
|
|
33
|
+
ship the npz inside the wheel; local runs use plain paths)."""
|
|
34
|
+
if path.startswith("pkg://"):
|
|
35
|
+
import importlib.resources as ir
|
|
36
|
+
return str(ir.files("deva_crnn").joinpath(path[len("pkg://"):]))
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LineDataset(Dataset):
|
|
41
|
+
def __init__(self, images: np.ndarray, texts: List[str], charset: List[str]):
|
|
42
|
+
self.images = images
|
|
43
|
+
self.texts = texts
|
|
44
|
+
self.charset = charset
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return len(self.texts)
|
|
48
|
+
|
|
49
|
+
def __getitem__(self, i: int):
|
|
50
|
+
img = torch.from_numpy(self.images[i].astype(np.float32) / 255.0)
|
|
51
|
+
img = (img - 0.5) / 0.5
|
|
52
|
+
target = torch.tensor(encode(self.texts[i], self.charset),
|
|
53
|
+
dtype=torch.long)
|
|
54
|
+
return img.unsqueeze(0), target, len(target)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _collate(batch):
|
|
58
|
+
imgs = torch.stack([b[0] for b in batch])
|
|
59
|
+
targets = torch.cat([b[1] for b in batch])
|
|
60
|
+
lens = torch.tensor([b[2] for b in batch], dtype=torch.long)
|
|
61
|
+
return imgs, targets, lens
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def evaluate(model: CRNN, loader: DataLoader, charset: List[str],
|
|
65
|
+
device: torch.device, max_batches: int = 0) -> Dict:
|
|
66
|
+
model.eval()
|
|
67
|
+
exact = total = 0
|
|
68
|
+
with torch.no_grad():
|
|
69
|
+
for i, (imgs, _targets, _lens) in enumerate(loader):
|
|
70
|
+
if max_batches and i >= max_batches:
|
|
71
|
+
break
|
|
72
|
+
logits = model(imgs.to(device)) # T, B, C
|
|
73
|
+
preds = logits.argmax(-1).permute(1, 0) # B, T
|
|
74
|
+
for b, p in enumerate(preds):
|
|
75
|
+
hyp = decode(p.tolist(), charset)
|
|
76
|
+
gt = loader.dataset.texts[total]
|
|
77
|
+
exact += int(hyp == gt)
|
|
78
|
+
total += 1
|
|
79
|
+
return {"lines": total, "exact_match": (exact / total) if total else 0.0}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _upload_if_gcs(local_path: str, out_dir: str) -> None:
|
|
83
|
+
"""Checkpoint to GCS from a Vertex job (best-effort, two backends)."""
|
|
84
|
+
if not out_dir.startswith("gs://"):
|
|
85
|
+
return
|
|
86
|
+
bucket, _, prefix = out_dir[len("gs://"):].partition("/")
|
|
87
|
+
blob = f"{prefix}/{os.path.basename(local_path)}".lstrip("/")
|
|
88
|
+
try:
|
|
89
|
+
from google.cloud import storage
|
|
90
|
+
storage.Client().bucket(bucket).blob(blob).upload_from_filename(local_path)
|
|
91
|
+
return
|
|
92
|
+
except Exception: # noqa: BLE001
|
|
93
|
+
pass
|
|
94
|
+
import subprocess
|
|
95
|
+
try:
|
|
96
|
+
subprocess.run(["gcloud", "storage", "cp", local_path,
|
|
97
|
+
f"gs://{bucket}/{blob}"], check=True,
|
|
98
|
+
capture_output=True, timeout=300)
|
|
99
|
+
except Exception as e: # noqa: BLE001
|
|
100
|
+
print(f"WARN: could not upload {local_path} to GCS: {e}", flush=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve_out_dirs(out_dir: str) -> Tuple[str, Optional[str]]:
|
|
104
|
+
"""(local_dir, remote_prefix) for a trainer output path.
|
|
105
|
+
|
|
106
|
+
`gs://bucket/prefix` keeps the per-epoch files in a local temp dir and
|
|
107
|
+
uploads each one via `_upload_if_gcs` (the Vertex/GCE pattern); a plain
|
|
108
|
+
path is used as-is with no upload. Before this, `gs://` outputs were
|
|
109
|
+
written to a literal `gs:/...` directory and the upload fallback copied a
|
|
110
|
+
non-existent GCS object to itself - checkpoints never left the VM.
|
|
111
|
+
"""
|
|
112
|
+
if out_dir.startswith("gs://"):
|
|
113
|
+
return os.path.join(tempfile.gettempdir(), "deva_crnn_out"), out_dir
|
|
114
|
+
return out_dir, None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def train(data_path: str, out_dir: str, epochs: int = 30, batch: int = 64,
|
|
118
|
+
lr: float = 1e-3, val_split: float = 0.05, seed: int = 1,
|
|
119
|
+
max_hours: float = 3.0, init: Optional[str] = None,
|
|
120
|
+
workers: int = -1, in_h: int = IN_H, in_w: int = IN_W,
|
|
121
|
+
hidden: int = 256, lr_schedule: str = "none",
|
|
122
|
+
save_best: bool = False) -> Dict:
|
|
123
|
+
images, texts = load_npz(resolve_data_path(data_path))
|
|
124
|
+
if images.shape[1] != in_h or images.shape[2] != in_w:
|
|
125
|
+
raise SystemExit(
|
|
126
|
+
f"data shape {images.shape[1]}x{images.shape[2]} != --in-h "
|
|
127
|
+
f"{in_h} x --in-w {in_w}; re-export the npz with --height "
|
|
128
|
+
f"{in_h} --width {in_w}")
|
|
129
|
+
charset = build_charset(texts)
|
|
130
|
+
rng = np.random.default_rng(seed)
|
|
131
|
+
order = rng.permutation(len(texts))
|
|
132
|
+
n_val = max(1, int(len(texts) * val_split))
|
|
133
|
+
val_idx, train_idx = order[:n_val], order[n_val:]
|
|
134
|
+
ds_tr = LineDataset(images[train_idx], [texts[i] for i in train_idx], charset)
|
|
135
|
+
ds_va = LineDataset(images[val_idx], [texts[i] for i in val_idx], charset)
|
|
136
|
+
# Worker processes cost ~3 s per epoch on Windows regardless of dataset
|
|
137
|
+
# size; only worth it for real datasets. `workers=0` avoids torch's shared
|
|
138
|
+
# file mapping, which fails on memory-constrained Windows boxes.
|
|
139
|
+
if workers < 0:
|
|
140
|
+
workers = 0 if len(ds_tr) < 512 else 2
|
|
141
|
+
dl_tr = DataLoader(ds_tr, batch_size=batch, shuffle=True, collate_fn=_collate,
|
|
142
|
+
num_workers=workers,
|
|
143
|
+
drop_last=len(ds_tr) >= batch)
|
|
144
|
+
dl_va = DataLoader(ds_va, batch_size=batch, shuffle=False,
|
|
145
|
+
collate_fn=_collate)
|
|
146
|
+
|
|
147
|
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
148
|
+
model = CRNN(n_classes=len(charset), hidden=hidden,
|
|
149
|
+
in_h=in_h).to(device)
|
|
150
|
+
if init:
|
|
151
|
+
ck = torch.load(init, map_location=device, weights_only=False)
|
|
152
|
+
if list(ck["charset"]) != list(charset):
|
|
153
|
+
raise SystemExit(
|
|
154
|
+
f"--init charset mismatch ({len(ck['charset'])} vs "
|
|
155
|
+
f"{len(charset)}): rebuild the data with --match-charset")
|
|
156
|
+
model.load_state_dict(ck["model"])
|
|
157
|
+
print(f"initialized from {init} (epoch {ck.get('epoch')})", flush=True)
|
|
158
|
+
opt = torch.optim.AdamW(model.parameters(), lr=lr)
|
|
159
|
+
if lr_schedule == "cosine":
|
|
160
|
+
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=max(1, epochs))
|
|
161
|
+
else:
|
|
162
|
+
sched = None
|
|
163
|
+
ctc = nn.CTCLoss(blank=0, zero_infinity=True)
|
|
164
|
+
|
|
165
|
+
local_dir, remote = resolve_out_dirs(out_dir)
|
|
166
|
+
os.makedirs(local_dir, exist_ok=True)
|
|
167
|
+
history: List[Dict] = []
|
|
168
|
+
best: Optional[float] = None
|
|
169
|
+
t0 = time.time()
|
|
170
|
+
step = 0
|
|
171
|
+
for epoch in range(epochs):
|
|
172
|
+
model.train()
|
|
173
|
+
losses = []
|
|
174
|
+
for imgs, targets, lens in dl_tr:
|
|
175
|
+
logits = model(imgs.to(device)) # T, B, C
|
|
176
|
+
T = logits.shape[0]
|
|
177
|
+
input_lens = torch.full((imgs.shape[0],), T, dtype=torch.long)
|
|
178
|
+
loss = ctc(logits, targets, input_lens, lens)
|
|
179
|
+
if not torch.isfinite(loss):
|
|
180
|
+
opt.zero_grad(set_to_none=True)
|
|
181
|
+
continue # never let one bad batch poison the weights
|
|
182
|
+
opt.zero_grad()
|
|
183
|
+
loss.backward()
|
|
184
|
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
|
|
185
|
+
opt.step()
|
|
186
|
+
losses.append(float(loss.item()))
|
|
187
|
+
step += 1
|
|
188
|
+
val = evaluate(model, dl_va, charset, device)
|
|
189
|
+
rec = {"epoch": epoch + 1,
|
|
190
|
+
"loss": float(np.mean(losses)) if losses else None, **val,
|
|
191
|
+
"seconds": round(time.time() - t0, 1)}
|
|
192
|
+
history.append(rec)
|
|
193
|
+
print(f"epoch {epoch + 1}/{epochs} loss {rec['loss']:.3f} "
|
|
194
|
+
f"val_exact {val['exact_match']:.3f} ({rec['seconds']}s)",
|
|
195
|
+
flush=True)
|
|
196
|
+
ckpt_path = os.path.join(local_dir, "ckpt.pt")
|
|
197
|
+
payload = {"model": model.state_dict(), "charset": charset,
|
|
198
|
+
"in_h": in_h, "in_w": in_w, "epoch": epoch + 1}
|
|
199
|
+
torch.save(payload, ckpt_path)
|
|
200
|
+
best_path = os.path.join(local_dir, "ckpt_best.pt")
|
|
201
|
+
if save_best and (best is None or val["exact_match"] > best):
|
|
202
|
+
best = val["exact_match"]
|
|
203
|
+
torch.save(payload, best_path)
|
|
204
|
+
print(f" new best val_exact {best:.3f} -> ckpt_best.pt", flush=True)
|
|
205
|
+
if sched is not None:
|
|
206
|
+
sched.step()
|
|
207
|
+
metrics_path = os.path.join(local_dir, "metrics.json")
|
|
208
|
+
with open(metrics_path, "w") as f:
|
|
209
|
+
json.dump({"history": history, "charset_size": len(charset),
|
|
210
|
+
"lines": len(texts), "device": str(device)}, f, indent=2)
|
|
211
|
+
if remote:
|
|
212
|
+
_upload_if_gcs(ckpt_path, remote)
|
|
213
|
+
if save_best:
|
|
214
|
+
_upload_if_gcs(best_path, remote)
|
|
215
|
+
_upload_if_gcs(metrics_path, remote)
|
|
216
|
+
if (time.time() - t0) / 3600.0 > max_hours:
|
|
217
|
+
print("time budget reached, stopping", flush=True)
|
|
218
|
+
break
|
|
219
|
+
return {"history": history, "out_dir": out_dir}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main():
|
|
223
|
+
ap = argparse.ArgumentParser(description="CRNN+CTC Devanagari line trainer")
|
|
224
|
+
ap.add_argument("--data", required=True)
|
|
225
|
+
ap.add_argument("--out", default="out/deva_crnn")
|
|
226
|
+
ap.add_argument("--epochs", type=int, default=30)
|
|
227
|
+
ap.add_argument("--batch", type=int, default=64)
|
|
228
|
+
ap.add_argument("--lr", type=float, default=1e-3)
|
|
229
|
+
ap.add_argument("--seed", type=int, default=1)
|
|
230
|
+
ap.add_argument("--max-hours", type=float, default=3.0)
|
|
231
|
+
ap.add_argument("--init", default=None,
|
|
232
|
+
help="checkpoint to warm-start from (charset must match)")
|
|
233
|
+
ap.add_argument("--workers", type=int, default=-1,
|
|
234
|
+
help="DataLoader workers (-1 auto; 0 avoids shared memory)")
|
|
235
|
+
ap.add_argument("--in-h", type=int, default=IN_H,
|
|
236
|
+
help="input height the npz was exported at (32 or 48)")
|
|
237
|
+
ap.add_argument("--in-w", type=int, default=IN_W,
|
|
238
|
+
help="input width the npz was exported at (256 or 512)")
|
|
239
|
+
ap.add_argument("--hidden", type=int, default=256,
|
|
240
|
+
help="BiLSTM hidden size (256 or 384)")
|
|
241
|
+
ap.add_argument("--lr-schedule", choices=("none", "cosine"), default="none",
|
|
242
|
+
help="cosine decays lr to ~0 over the run (fine-tunes)")
|
|
243
|
+
ap.add_argument("--save-best", action="store_true",
|
|
244
|
+
help="also write ckpt_best.pt on val_exact improvements")
|
|
245
|
+
args = ap.parse_args()
|
|
246
|
+
train(args.data, args.out, epochs=args.epochs, batch=args.batch,
|
|
247
|
+
lr=args.lr, seed=args.seed, max_hours=args.max_hours,
|
|
248
|
+
init=args.init, workers=args.workers, in_h=args.in_h,
|
|
249
|
+
in_w=args.in_w, hidden=args.hidden,
|
|
250
|
+
lr_schedule=args.lr_schedule, save_best=args.save_best)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
main()
|
veriscript/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""VeriScript — offline document restoration (Devanagari first)."""
|
|
2
|
+
from importlib.metadata import PackageNotFoundError, version as _package_version
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
__version__ = _package_version("veriscript")
|
|
6
|
+
except PackageNotFoundError: # source checkout without an installed distribution
|
|
7
|
+
__version__ = "0+unknown"
|
|
8
|
+
|
|
9
|
+
__all__ = ["__version__"]
|
veriscript/__main__.py
ADDED
veriscript/branding.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
branding.py — product name and environment-variable compatibility.
|
|
3
|
+
|
|
4
|
+
The product is **VeriScript**. Current deployments read `VERISCRIPT_*`
|
|
5
|
+
environment variables; the former name's `VECTOVECTO_*` variables keep
|
|
6
|
+
working as a fallback, so existing shells, Docker commands and hosted
|
|
7
|
+
deployments do not need to change.
|
|
8
|
+
|
|
9
|
+
No heavy imports; safe for the shipped path.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
NAME = "VeriScript"
|
|
16
|
+
ENV_PREFIX = "VERISCRIPT_"
|
|
17
|
+
LEGACY_NAME = "VectoVecto"
|
|
18
|
+
LEGACY_ENV_PREFIX = "VECTOVECTO_"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def env(name: str, default=None):
|
|
22
|
+
"""`VERISCRIPT_<name>` when set, else `VECTOVECTO_<name>`, else default."""
|
|
23
|
+
value = os.environ.get(ENV_PREFIX + name)
|
|
24
|
+
if value is None:
|
|
25
|
+
value = os.environ.get(LEGACY_ENV_PREFIX + name)
|
|
26
|
+
return default if value is None else value
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
calibration.py — confidence calibration maps (loaded by document_ocr).
|
|
3
|
+
|
|
4
|
+
The fitted file lives in `calibration/` (source checkout) or the wheel
|
|
5
|
+
data-files location `<sys.prefix>/calibration/`; it is produced by
|
|
6
|
+
fit_calibration.py on a *development* set (never a frozen eval set). Only
|
|
7
|
+
monotone (isotonic) maps are shipped: temperature scaling was measured to be
|
|
8
|
+
structurally unsuitable for the Devanagari letterpress domain (saturated
|
|
9
|
+
probabilities; BCE optimum inverted the ranking, T<0).
|
|
10
|
+
|
|
11
|
+
No heavy imports; safe for the shipped path.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Dict, Optional
|
|
19
|
+
|
|
20
|
+
from veriscript.paths import ROOT
|
|
21
|
+
|
|
22
|
+
BASE_DIR = ROOT
|
|
23
|
+
CALIBRATION_NAME = "rapidocr_devanagari_v1.json"
|
|
24
|
+
DEFAULT_CALIBRATION = os.path.join(BASE_DIR, "calibration", CALIBRATION_NAME)
|
|
25
|
+
|
|
26
|
+
_CACHE: Dict[str, Dict] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_calibration(path: Optional[str] = None) -> Optional[str]:
|
|
30
|
+
"""First existing calibration file, or None.
|
|
31
|
+
|
|
32
|
+
`path` (when given) is exact; otherwise the repo copy and the wheel
|
|
33
|
+
data-files location are probed in order.
|
|
34
|
+
"""
|
|
35
|
+
if path:
|
|
36
|
+
return os.path.abspath(path) if os.path.isfile(path) else None
|
|
37
|
+
for cand in (DEFAULT_CALIBRATION,
|
|
38
|
+
os.path.join(sys.prefix, "calibration", CALIBRATION_NAME)):
|
|
39
|
+
if os.path.isfile(cand):
|
|
40
|
+
return os.path.abspath(cand)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_calibration(path: Optional[str] = None) -> Optional[Dict]:
|
|
45
|
+
"""Load and cache a calibration JSON; None if missing or malformed."""
|
|
46
|
+
resolved = resolve_calibration(path)
|
|
47
|
+
if resolved is None:
|
|
48
|
+
return None
|
|
49
|
+
if resolved in _CACHE:
|
|
50
|
+
return _CACHE[resolved]
|
|
51
|
+
try:
|
|
52
|
+
with open(resolved, encoding="utf-8") as f:
|
|
53
|
+
data = json.load(f)
|
|
54
|
+
if "isotonic" not in data:
|
|
55
|
+
return None
|
|
56
|
+
except Exception: # noqa: BLE001
|
|
57
|
+
return None
|
|
58
|
+
_CACHE[resolved] = data
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def apply_isotonic(conf_pct: float, mapping: Dict) -> float:
|
|
63
|
+
"""Map a 0-100 confidence through the piecewise-constant isotonic table."""
|
|
64
|
+
xs, ys = mapping.get("x", []), mapping.get("y", [])
|
|
65
|
+
if not xs or not ys:
|
|
66
|
+
return conf_pct
|
|
67
|
+
x = conf_pct / 100.0
|
|
68
|
+
y = ys[0]
|
|
69
|
+
for xi, yi in zip(xs, ys):
|
|
70
|
+
if x >= xi:
|
|
71
|
+
y = yi
|
|
72
|
+
else:
|
|
73
|
+
break
|
|
74
|
+
return max(0.0, min(100.0, y * 100.0))
|