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.
Files changed (51) hide show
  1. deva_crnn/__init__.py +1 -0
  2. deva_crnn/augment.py +242 -0
  3. deva_crnn/charset.py +31 -0
  4. deva_crnn/data.py +48 -0
  5. deva_crnn/gate.py +97 -0
  6. deva_crnn/model.py +44 -0
  7. deva_crnn/predict.py +127 -0
  8. deva_crnn/train.py +254 -0
  9. veriscript/__init__.py +9 -0
  10. veriscript/__main__.py +4 -0
  11. veriscript/branding.py +26 -0
  12. veriscript/calibration.py +74 -0
  13. veriscript/cli.py +473 -0
  14. veriscript/core/__init__.py +1 -0
  15. veriscript/core/data.py +1586 -0
  16. veriscript/core/degradation.py +230 -0
  17. veriscript/core/metrics.py +591 -0
  18. veriscript/deva/__init__.py +1 -0
  19. veriscript/deva/reader.py +218 -0
  20. veriscript/document/__init__.py +1 -0
  21. veriscript/document/confusions.py +142 -0
  22. veriscript/document/corrections.py +378 -0
  23. veriscript/document/export.py +388 -0
  24. veriscript/document/layout.py +528 -0
  25. veriscript/document/memory.py +561 -0
  26. veriscript/document/ocr.py +1295 -0
  27. veriscript/document/orientation.py +247 -0
  28. veriscript/document/pipeline.py +367 -0
  29. veriscript/document/reconcile.py +225 -0
  30. veriscript/document/restore.py +222 -0
  31. veriscript/document/router.py +76 -0
  32. veriscript/document/verifier.py +132 -0
  33. veriscript/lexicon.py +105 -0
  34. veriscript/logging_setup.py +44 -0
  35. veriscript/paths.py +13 -0
  36. veriscript/photo/__init__.py +1 -0
  37. veriscript/photo/hybrid.py +736 -0
  38. veriscript/photo/rrdbnet.py +70 -0
  39. veriscript/photo/sr_engine.py +390 -0
  40. veriscript/photo/srvggnet.py +69 -0
  41. veriscript/photo/tv_refinement.py +142 -0
  42. veriscript/photo/upscaler.py +360 -0
  43. veriscript-1.5.0.data/data/calibration/rapidocr_devanagari_v1.json +60 -0
  44. veriscript-1.5.0.data/data/fonts/Mukta-Regular.ttf +0 -0
  45. veriscript-1.5.0.data/data/fonts/OFL.txt +93 -0
  46. veriscript-1.5.0.dist-info/METADATA +342 -0
  47. veriscript-1.5.0.dist-info/RECORD +51 -0
  48. veriscript-1.5.0.dist-info/WHEEL +5 -0
  49. veriscript-1.5.0.dist-info/entry_points.txt +3 -0
  50. veriscript-1.5.0.dist-info/licenses/LICENSE +21 -0
  51. 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
@@ -0,0 +1,4 @@
1
+ from veriscript.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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))