wormocr 1.0.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 (48) hide show
  1. wormocr/__init__.py +18 -0
  2. wormocr/__main__.py +4 -0
  3. wormocr/api.py +229 -0
  4. wormocr/benchmark/__init__.py +5 -0
  5. wormocr/benchmark/report.py +403 -0
  6. wormocr/checkpoints.py +55 -0
  7. wormocr/cli.py +308 -0
  8. wormocr/config.py +220 -0
  9. wormocr/data/__init__.py +5 -0
  10. wormocr/data/dataset.py +110 -0
  11. wormocr/data/fonts.py +173 -0
  12. wormocr/data/handwriting.py +304 -0
  13. wormocr/data/lexicon.py +105 -0
  14. wormocr/data/lexicon_medical.py +48 -0
  15. wormocr/data/mixed.py +211 -0
  16. wormocr/data/synthetic.py +445 -0
  17. wormocr/detection/__init__.py +5 -0
  18. wormocr/detection/lines.py +382 -0
  19. wormocr/evaluation/__init__.py +5 -0
  20. wormocr/evaluation/errors.py +145 -0
  21. wormocr/evaluation/metrics.py +105 -0
  22. wormocr/evaluation/robustness.py +43 -0
  23. wormocr/exceptions.py +25 -0
  24. wormocr/export/__init__.py +5 -0
  25. wormocr/export/onnx_export.py +75 -0
  26. wormocr/hub.py +157 -0
  27. wormocr/inference/__init__.py +5 -0
  28. wormocr/inference/decode.py +113 -0
  29. wormocr/inference/engine.py +122 -0
  30. wormocr/models/__init__.py +5 -0
  31. wormocr/models/crnn.py +236 -0
  32. wormocr/preprocessing.py +135 -0
  33. wormocr/py.typed +0 -0
  34. wormocr/runtime.py +56 -0
  35. wormocr/training/__init__.py +5 -0
  36. wormocr/training/loop.py +645 -0
  37. wormocr/vocabulary.py +69 -0
  38. wormocr/web/__init__.py +5 -0
  39. wormocr/web/server.py +505 -0
  40. wormocr/web/static/app.js +514 -0
  41. wormocr/web/static/index.html +141 -0
  42. wormocr/web/static/style.css +379 -0
  43. wormocr-1.0.0.dist-info/METADATA +377 -0
  44. wormocr-1.0.0.dist-info/RECORD +48 -0
  45. wormocr-1.0.0.dist-info/WHEEL +5 -0
  46. wormocr-1.0.0.dist-info/entry_points.txt +2 -0
  47. wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. wormocr-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,48 @@
1
+ """Prescription-style vocabulary for synthetic handwriting.
2
+
3
+ Generic medicine names (common international nonproprietary names), dosage
4
+ forms, and standard prescription abbreviations. No patient data.
5
+ """
6
+
7
+ GENERICS = """
8
+ paracetamol acetaminophen ibuprofen aspirin diclofenac naproxen tramadol morphine codeine
9
+ amoxicillin ampicillin azithromycin clarithromycin doxycycline ciprofloxacin levofloxacin
10
+ metronidazole cefixime ceftriaxone cefuroxime cephalexin clindamycin nitrofurantoin
11
+ cotrimoxazole fluconazole clotrimazole ketoconazole acyclovir oseltamivir albendazole
12
+ ivermectin omeprazole esomeprazole pantoprazole rabeprazole ranitidine famotidine domperidone
13
+ ondansetron metoclopramide loperamide lactulose bisacodyl simethicone metformin glimepiride
14
+ gliclazide sitagliptin insulin amlodipine atenolol metoprolol bisoprolol propranolol losartan
15
+ telmisartan olmesartan valsartan enalapril ramipril lisinopril hydrochlorothiazide furosemide
16
+ spironolactone atorvastatin rosuvastatin simvastatin clopidogrel warfarin heparin
17
+ levothyroxine prednisolone prednisone dexamethasone hydrocortisone methylprednisolone
18
+ salbutamol montelukast budesonide fluticasone cetirizine levocetirizine loratadine
19
+ fexofenadine chlorpheniramine diphenhydramine amitriptyline sertraline fluoxetine
20
+ escitalopram alprazolam clonazepam diazepam lorazepam zolpidem gabapentin pregabalin
21
+ carbamazepine levetiracetam phenytoin valproate haloperidol olanzapine risperidone
22
+ quetiapine donepezil folic acid ferrous sulfate calcium carbonate vitamin cholecalciferol
23
+ cyanocobalamin thiamine pyridoxine zinc magnesium potassium chloride sodium bicarbonate
24
+ oral rehydration salts dextrose saline mupirocin fusidic betamethasone tamsulosin
25
+ finasteride sildenafil misoprostol oxytocin progesterone estradiol tranexamic baclofen
26
+ methocarbamol colchicine allopurinol febuxostat hydroxychloroquine methotrexate
27
+ """.split()
28
+
29
+ FORMS = [
30
+ "Tab", "Tab.", "Cap", "Cap.", "Syr", "Syr.", "Inj", "Inj.", "Susp", "Oint", "Cream",
31
+ "Drops", "Gel", "Inhaler", "Sachet", "Lotion", "Neb", "IV", "IM", "SC",
32
+ ]
33
+
34
+ FREQUENCIES = [
35
+ "OD", "BD", "BID", "TDS", "TID", "QID", "QDS", "HS", "SOS", "PRN", "STAT",
36
+ "1-0-1", "1-1-1", "0-0-1", "1-0-0", "0-1-0", "1/2-0-1/2", "a.c.", "p.c.", "h.s.",
37
+ "once daily", "twice daily", "at night", "after food", "before food", "as needed",
38
+ ]
39
+
40
+ DURATIONS = ["x 3 days", "x 5 days", "x 7 days", "x 10 days", "x 2 wks", "x 1 month", "for 5 days", "1 week"]
41
+
42
+ UNITS = ["mg", "mg", "mg", "mcg", "g", "ml", "mL", "IU", "%"]
43
+
44
+ NOTES = [
45
+ "Rx", "Dx", "Hx", "c/o", "k/c/o", "BP", "Pulse", "Temp", "SpO2", "Wt", "Ht", "RBS", "FBS",
46
+ "CBC", "LFT", "RFT", "ECG", "X-ray", "USG", "Review after", "Follow up", "Adv", "Plenty of fluids",
47
+ "Avoid oily food", "Rest", "Fever", "Cough", "Cold", "Headache", "Pain abdomen", "Vomiting",
48
+ ]
wormocr/data/mixed.py ADDED
@@ -0,0 +1,211 @@
1
+ """Weighted mixtures of synthetic printed, synthetic handwritten, and real lines.
2
+
3
+ `data.sources` in a config lists the parts of the mixture:
4
+
5
+ sources:
6
+ - {kind: synthetic, weight: 0.35}
7
+ - {kind: handwriting, weight: 0.35}
8
+ - {kind: labels, weight: 0.2, labels: path/train.tsv}
9
+
10
+ Which source item i comes from is a pure function of (seed, i), so a run is
11
+ reproducible regardless of worker count. Real lines get light, random
12
+ augmentation; synthetic items keep their own deterministic augmentation.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import bisect
18
+ import random
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ from PIL import Image, ImageFilter
23
+ from torch.utils.data import ConcatDataset, Dataset, Subset
24
+
25
+ from wormocr.data.dataset import LabelDataset, OnlineSynthDataset, _read_labels
26
+ from wormocr.data.handwriting import handwriting_fonts, render_handwriting, sample_hw_text
27
+ from wormocr.data.synthetic import _jpeg, _noise, fit_label_width
28
+ from wormocr.exceptions import ConfigError, DataError
29
+ from wormocr.preprocessing import prepare_line
30
+ from wormocr.vocabulary import Vocabulary
31
+
32
+
33
+ def _finish(image: Image.Image, text: str, config: dict) -> dict:
34
+ model_cfg = config["model"]
35
+ data_cfg = config["data"]
36
+ image = fit_label_width(
37
+ image,
38
+ text,
39
+ int(model_cfg["image_height"]),
40
+ list(model_cfg["channels"]),
41
+ int(data_cfg["max_width"]),
42
+ int(model_cfg.get("width_stride", 2)),
43
+ )
44
+ tensor, pad_value = prepare_line(
45
+ image,
46
+ height=int(model_cfg["image_height"]),
47
+ align=2,
48
+ max_width=max(int(data_cfg["max_width"]), image.width),
49
+ min_width=8,
50
+ )
51
+ return {"image": tensor, "text": text, "pad_value": pad_value, "width": int(tensor.shape[-1])}
52
+
53
+
54
+ class SynthHandwritingDataset(Dataset):
55
+ def __init__(self, config: dict, samples: int, seed: int, augment: bool, font_dir: str = "") -> None:
56
+ self.config = config
57
+ self.samples = int(samples)
58
+ self.seed = int(seed)
59
+ self.augment = bool(augment)
60
+ self.fonts = handwriting_fonts(font_dir or config["data"].get("handwriting_font_dir") or "")
61
+ self.vocabulary = Vocabulary((config.get("vocab") or {}).get("charset"))
62
+
63
+ def __len__(self) -> int:
64
+ return self.samples
65
+
66
+ def __getitem__(self, index: int) -> dict:
67
+ seed = self.seed + int(index) * 10007
68
+ rng = random.Random(seed)
69
+ np_rng = np.random.default_rng(seed)
70
+ text = sample_hw_text(rng, int(self.config["data"]["max_chars"]))
71
+ self.vocabulary.encode(text)
72
+ image = render_handwriting(text, rng, np_rng, self.fonts, augment=self.augment)
73
+ return _finish(image, text, self.config)
74
+
75
+
76
+ def _augment_real(image: Image.Image, rng: random.Random) -> Image.Image:
77
+ """Mild changes only: real crops already carry real noise and their labels must stay true."""
78
+ image = image.convert("L")
79
+ if rng.random() < 0.3:
80
+ angle = rng.uniform(-2.0, 2.0)
81
+ fill = int(np.median(np.asarray(image)))
82
+ image = image.rotate(angle, resample=Image.Resampling.BILINEAR, expand=True, fillcolor=fill)
83
+ if rng.random() < 0.3:
84
+ shear = rng.uniform(-0.15, 0.15)
85
+ fill = int(np.median(np.asarray(image)))
86
+ image = image.transform(
87
+ image.size, Image.Transform.AFFINE, (1, shear, -shear * image.height / 2, 0, 1, 0),
88
+ resample=Image.Resampling.BILINEAR, fillcolor=fill,
89
+ )
90
+ if rng.random() < 0.3:
91
+ scale = rng.uniform(0.85, 1.15)
92
+ image = image.resize((max(8, int(image.width * scale)), image.height), Image.Resampling.BILINEAR)
93
+ if rng.random() < 0.25:
94
+ array = np.asarray(image).astype(np.float32)
95
+ mean = array.mean()
96
+ array = (array - mean) * rng.uniform(0.7, 1.2) + mean + rng.uniform(-20, 20)
97
+ image = Image.fromarray(np.clip(array, 0, 255).astype(np.uint8), mode="L")
98
+ if rng.random() < 0.15:
99
+ image = image.filter(ImageFilter.GaussianBlur(radius=rng.uniform(0.3, 0.9)))
100
+ if rng.random() < 0.15:
101
+ image = _noise(image, np.random.default_rng(rng.getrandbits(32)), rng.uniform(2, 8))
102
+ if rng.random() < 0.15:
103
+ image = _jpeg(image, rng.randint(40, 85))
104
+ return image
105
+
106
+
107
+ class RealLinesDataset(Dataset):
108
+ """Prepared line crops (`relative<TAB>text`), sized for CTC like synthetic lines."""
109
+
110
+ def __init__(self, labels: str | Path, config: dict, augment: bool, image_root: str | Path | None = None) -> None:
111
+ self.labels = Path(labels)
112
+ self.root = Path(image_root) if image_root else self.labels.parent
113
+ self.config = config
114
+ self.augment = bool(augment)
115
+ vocabulary = Vocabulary((config.get("vocab") or {}).get("charset"))
116
+ self.records = []
117
+ for relative, text in _read_labels(self.labels):
118
+ try:
119
+ vocabulary.encode(text)
120
+ except ValueError:
121
+ continue
122
+ if text.strip():
123
+ self.records.append((relative, text))
124
+ if not self.records:
125
+ raise DataError(f"no usable labels in {self.labels}")
126
+
127
+ def __len__(self) -> int:
128
+ return len(self.records)
129
+
130
+ def __getitem__(self, index: int) -> dict:
131
+ relative, text = self.records[int(index)]
132
+ with Image.open(self.root / relative) as handle:
133
+ image = handle.convert("L")
134
+ if self.augment:
135
+ image = _augment_real(image, random.Random())
136
+ item = _finish(image, text, self.config)
137
+ item["path"] = str(self.root / relative)
138
+ return item
139
+
140
+
141
+ class MixedDataset(Dataset):
142
+ def __init__(self, parts: list[tuple[Dataset, float]], samples: int, seed: int) -> None:
143
+ if not parts:
144
+ raise ConfigError("data.sources is empty")
145
+ weights = [float(weight) for _dataset, weight in parts]
146
+ if any(weight <= 0 for weight in weights):
147
+ raise ConfigError("every data.sources weight must be positive")
148
+ total = sum(weights)
149
+ self.datasets = [dataset for dataset, _weight in parts]
150
+ self.cumulative = []
151
+ running = 0.0
152
+ for weight in weights:
153
+ running += weight / total
154
+ self.cumulative.append(running)
155
+ self.samples = int(samples)
156
+ self.seed = int(seed)
157
+
158
+ def __len__(self) -> int:
159
+ return self.samples
160
+
161
+ def __getitem__(self, index: int) -> dict:
162
+ rng = random.Random(self.seed * 1_000_003 + int(index))
163
+ choice = min(bisect.bisect_left(self.cumulative, rng.random()), len(self.datasets) - 1)
164
+ dataset = self.datasets[choice]
165
+ return dataset[rng.randrange(len(dataset))]
166
+
167
+
168
+ def _resolve(path: str, base: Path) -> Path:
169
+ candidate = Path(path)
170
+ return candidate if candidate.is_absolute() else base / candidate
171
+
172
+
173
+ def build_source(spec: dict, config: dict, train: bool, base: Path, seed: int, samples: int) -> Dataset:
174
+ kind = spec.get("kind")
175
+ augment = bool(spec.get("augment", True)) and train
176
+ if kind == "synthetic":
177
+ return OnlineSynthDataset(config, samples, seed, augment)
178
+ if kind == "handwriting":
179
+ return SynthHandwritingDataset(config, samples, seed + 7, augment, spec.get("font_dir") or "")
180
+ if kind == "labels":
181
+ key = "labels" if train else "val_labels"
182
+ if not spec.get(key):
183
+ raise ConfigError(f"data.sources entry {spec.get('name') or kind} needs '{key}'")
184
+ root = spec.get("image_root")
185
+ return RealLinesDataset(_resolve(spec[key], base), config, augment, _resolve(root, base) if root else None)
186
+ raise ConfigError(f"unknown data.sources kind {kind!r}; use synthetic, handwriting, or labels")
187
+
188
+
189
+ def build_mixed(config: dict, base: Path) -> tuple[Dataset, Dataset | None]:
190
+ data = config["data"]
191
+ seed = int(config["seed"])
192
+ train_samples = int(data["train_samples"])
193
+ parts = [
194
+ (build_source(spec, config, True, base, seed + 31 * index, train_samples), float(spec.get("weight", 1.0)))
195
+ for index, spec in enumerate(data["sources"])
196
+ ]
197
+ train = MixedDataset(parts, train_samples, seed)
198
+ per_source = int(data.get("val_per_source") or 0)
199
+ if per_source <= 0:
200
+ return train, None
201
+ val_seed = int(data.get("val_seed") or seed + 1_000_003)
202
+ val_parts: list[Dataset] = []
203
+ for index, spec in enumerate(data["sources"]):
204
+ if spec.get("kind") == "labels" and not spec.get("val_labels"):
205
+ continue
206
+ source = build_source(spec, config, False, base, val_seed + 31 * index, per_source)
207
+ if len(source) > per_source:
208
+ stride = len(source) / per_source
209
+ source = Subset(source, [int(position * stride) for position in range(per_source)])
210
+ val_parts.append(source)
211
+ return train, ConcatDataset(val_parts) if val_parts else None