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.
- wormocr/__init__.py +18 -0
- wormocr/__main__.py +4 -0
- wormocr/api.py +229 -0
- wormocr/benchmark/__init__.py +5 -0
- wormocr/benchmark/report.py +403 -0
- wormocr/checkpoints.py +55 -0
- wormocr/cli.py +308 -0
- wormocr/config.py +220 -0
- wormocr/data/__init__.py +5 -0
- wormocr/data/dataset.py +110 -0
- wormocr/data/fonts.py +173 -0
- wormocr/data/handwriting.py +304 -0
- wormocr/data/lexicon.py +105 -0
- wormocr/data/lexicon_medical.py +48 -0
- wormocr/data/mixed.py +211 -0
- wormocr/data/synthetic.py +445 -0
- wormocr/detection/__init__.py +5 -0
- wormocr/detection/lines.py +382 -0
- wormocr/evaluation/__init__.py +5 -0
- wormocr/evaluation/errors.py +145 -0
- wormocr/evaluation/metrics.py +105 -0
- wormocr/evaluation/robustness.py +43 -0
- wormocr/exceptions.py +25 -0
- wormocr/export/__init__.py +5 -0
- wormocr/export/onnx_export.py +75 -0
- wormocr/hub.py +157 -0
- wormocr/inference/__init__.py +5 -0
- wormocr/inference/decode.py +113 -0
- wormocr/inference/engine.py +122 -0
- wormocr/models/__init__.py +5 -0
- wormocr/models/crnn.py +236 -0
- wormocr/preprocessing.py +135 -0
- wormocr/py.typed +0 -0
- wormocr/runtime.py +56 -0
- wormocr/training/__init__.py +5 -0
- wormocr/training/loop.py +645 -0
- wormocr/vocabulary.py +69 -0
- wormocr/web/__init__.py +5 -0
- wormocr/web/server.py +505 -0
- wormocr/web/static/app.js +514 -0
- wormocr/web/static/index.html +141 -0
- wormocr/web/static/style.css +379 -0
- wormocr-1.0.0.dist-info/METADATA +377 -0
- wormocr-1.0.0.dist-info/RECORD +48 -0
- wormocr-1.0.0.dist-info/WHEEL +5 -0
- wormocr-1.0.0.dist-info/entry_points.txt +2 -0
- wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|