2929
3030import click
3131import numpy as np
32+ import requests
3233import torch
3334import torch .nn .functional as F
3435from bs4 import BeautifulSoup
5152N_CHARS = 6
5253CROP_H = 40
5354CROP_W = 28
55+ PAIR_LEN = 2 # a --pair option names exactly two characters
5456
5557CHROMA_MAX = 30
5658INTENSITY_MAX = 140
57- MIN_GLYPH_HEIGHT = 15 # letter bboxes are ~25–35 tall; noise blobs are ~4–8
59+ TEXT_GRAY_MAX = 200 # pixels darker than this count as text
60+
61+ # Data augmentation probabilities.
62+ PEPPER_PROB = 0.5
63+ SALT_PROB = 0.3
64+ NOISE_FRACTION = 0.01
65+ MIN_GLYPH_HEIGHT = 15 # letter bboxes are ~25-35 tall; noise blobs are ~4-8
5866
5967DEFAULT_LABELS = HERE / "labels.json"
6068DEFAULT_SAMPLES = HERE / "samples"
@@ -96,7 +104,7 @@ def clean(rgb: np.ndarray) -> np.ndarray:
96104
97105def text_bbox (gray : np .ndarray ) -> tuple [int , int ]:
98106 """Return the (left, right) columns spanning all text pixels."""
99- cols = (gray < 200 ).any (axis = 0 )
107+ cols = (gray < TEXT_GRAY_MAX ).any (axis = 0 )
100108 if not cols .any ():
101109 return 0 , gray .shape [1 ]
102110 xs = np .flatnonzero (cols )
@@ -207,9 +215,10 @@ def build_items(labels: dict[str, str], samples_dir: Path) -> list[tuple[Path, i
207215
208216
209217class CharDataset (Dataset ):
210- def __init__ (self , items : list [tuple [Path , int , str ]], augment : bool = False ) -> None :
218+ def __init__ (self , items : list [tuple [Path , int , str ]], * , augment : bool = False , seed : int | None = None ) -> None :
211219 self .items = items
212220 self .augment = augment
221+ self .rng = np .random .default_rng (seed )
213222
214223 def __len__ (self ) -> int :
215224 return len (self .items )
@@ -227,13 +236,13 @@ def __getitem__(self, idx: int) -> tuple[torch.Tensor, int]:
227236
228237 def _augment (self , crop : np .ndarray ) -> np .ndarray :
229238 img = Image .fromarray (crop )
230- angle = random .uniform (- 8 , 8 )
239+ angle = self . rng .uniform (- 8 , 8 )
231240 img = img .rotate (angle , fillcolor = 255 , resample = Image .BILINEAR )
232241 arr = np .array (img )
233- if random . random () < 0.5 :
234- arr [np . random . rand ( * arr .shape ) < 0.01 ] = 0
235- if random . random () < 0.3 :
236- arr [np . random . rand ( * arr .shape ) < 0.01 ] = 255
242+ if self . rng . random () < PEPPER_PROB :
243+ arr [self . rng . random ( arr .shape ) < NOISE_FRACTION ] = 0
244+ if self . rng . random () < SALT_PROB :
245+ arr [self . rng . random ( arr .shape ) < NOISE_FRACTION ] = 255
237246 return arr
238247
239248
@@ -280,13 +289,16 @@ def fit_one(
280289 device : torch .device ,
281290 save_best_to : Path | None ,
282291 log_prefix : str = "" ,
292+ seed : int | None = None ,
283293) -> tuple [TinyCNN , float , int ]:
284294 """
285- Train one model. If save_best_to is set and val_items is non-empty,
286- save the best-by-val-acc checkpoint. Returns (model, best_val_acc, best_epoch).
295+ Train one model.
296+
297+ If save_best_to is set and val_items is non-empty, save the best-by-val-acc
298+ checkpoint. Returns (model, best_val_acc, best_epoch).
287299 """
288300 train_dl = DataLoader (
289- CharDataset (train_items , augment = True ),
301+ CharDataset (train_items , augment = True , seed = seed ),
290302 batch_size = batch_size ,
291303 shuffle = True ,
292304 num_workers = 0 ,
@@ -310,15 +322,15 @@ def fit_one(
310322 model .train ()
311323 train_correct = train_total = train_loss = 0
312324 for x , y in train_dl :
313- x , y = x .to (device ), y .to (device )
314- logits = model (x )
315- loss = F .cross_entropy (logits , y )
325+ xb , yb = x .to (device ), y .to (device )
326+ logits = model (xb )
327+ loss = F .cross_entropy (logits , yb )
316328 opt .zero_grad ()
317329 loss .backward ()
318330 opt .step ()
319- train_loss += loss .item () * x .size (0 )
320- train_correct += (logits .argmax (1 ) == y ).sum ().item ()
321- train_total += x .size (0 )
331+ train_loss += loss .item () * xb .size (0 )
332+ train_correct += (logits .argmax (1 ) == yb ).sum ().item ()
333+ train_total += xb .size (0 )
322334 sched .step ()
323335 val_acc = None
324336 marker = ""
@@ -327,9 +339,9 @@ def fit_one(
327339 v_correct = v_total = 0
328340 with torch .no_grad ():
329341 for x , y in val_dl :
330- x , y = x .to (device ), y .to (device )
331- v_correct += (model (x ).argmax (1 ) == y ).sum ().item ()
332- v_total += x .size (0 )
342+ xb , yb = x .to (device ), y .to (device )
343+ v_correct += (model (xb ).argmax (1 ) == yb ).sum ().item ()
344+ v_total += xb .size (0 )
333345 val_acc = v_correct / v_total
334346 if val_acc > best_val :
335347 best_val = val_acc
@@ -501,8 +513,6 @@ def _fetch_b64(html: bytes) -> str:
501513)
502514def fetch (n : int , start : int , output_dir : Path , delay : float ) -> None :
503515 """Download N captchas from the Assam tenders portal."""
504- import requests
505-
506516 if n < 1 or start < 1 :
507517 raise click .BadParameter ("n and --start must be >= 1" )
508518 output_dir .mkdir (parents = True , exist_ok = True )
@@ -665,7 +675,6 @@ def train(
665675) -> None :
666676 """Train the CNN. Saves the best-by-val checkpoint to --out."""
667677 random .seed (seed )
668- np .random .seed (seed )
669678 torch .manual_seed (seed )
670679 labels = json .loads (labels_path .read_text ())
671680 items = build_items (labels , samples )
@@ -683,6 +692,7 @@ def train(
683692 batch_size = batch_size ,
684693 device = device ,
685694 save_best_to = out ,
695+ seed = seed ,
686696 )
687697 click .echo (f"best val acc { best_val :.3f} at epoch { best_epoch } ; saved to { out } " )
688698
@@ -759,6 +769,7 @@ def xval(
759769 folds : int ,
760770 epochs : int ,
761771 seed : int ,
772+ * ,
762773 report_only : bool ,
763774) -> None :
764775 """K-fold cross-validation; write suspects.txt + suspects.png."""
@@ -774,7 +785,6 @@ def xval(
774785
775786 started = time .monotonic ()
776787 random .seed (seed )
777- np .random .seed (seed )
778788 torch .manual_seed (seed )
779789 fnames = sorted (labels )
780790 random .shuffle (fnames )
@@ -785,7 +795,7 @@ def xval(
785795 predictions : dict [tuple [str , int ], tuple [str , float ]] = {}
786796 for fi , val_fnames in enumerate (fold_groups , 1 ):
787797 val_set = set (val_fnames )
788- train_labels = {f : l for f , l in labels .items () if f not in val_set }
798+ train_labels = {f : lbl for f , lbl in labels .items () if f not in val_set }
789799 train_items = build_items (train_labels , samples )
790800 click .echo (f"Fold { fi } /{ folds } : training on { len (train_items )} chars, val { len (val_fnames )} captchas" )
791801 model , _ , _ = fit_one (
@@ -798,6 +808,7 @@ def xval(
798808 device = device ,
799809 save_best_to = None ,
800810 log_prefix = f" fold { fi } /{ folds } " ,
811+ seed = seed + fi ,
801812 )
802813 model .eval ()
803814 with torch .no_grad ():
@@ -1002,6 +1013,7 @@ def review(
10021013 min_conf : float ,
10031014 max_conf : float ,
10041015 start : int ,
1016+ * ,
10051017 no_image : bool ,
10061018 scale : int ,
10071019 term_rows : int ,
@@ -1013,7 +1025,7 @@ def review(
10131025 pair_chars : set [frozenset [str ]] | None = None
10141026 if pair :
10151027 parts = [p .strip () for p in pair .split ("," )]
1016- if len (parts ) != 2 or any (len (p ) != 1 for p in parts ):
1028+ if len (parts ) != PAIR_LEN or any (len (p ) != 1 for p in parts ):
10171029 raise click .BadParameter (f"--pair must be two characters separated by a comma, e.g. n,h (got { pair !r} )" )
10181030 pair_chars = {frozenset (parts )}
10191031 suspects = parse_suspects (suspects_path )
@@ -1147,6 +1159,7 @@ def verify(
11471159 labels_path : Path ,
11481160 samples : Path ,
11491161 start : int ,
1162+ * ,
11501163 no_image : bool ,
11511164 scale : int ,
11521165 term_rows : int ,
@@ -1270,9 +1283,9 @@ def verify(
12701283 is_flag = True ,
12711284 help = "Also print the model's per-character softmax confidence." ,
12721285)
1273- def predict_cmd (image_path : Path , model : Path , confidence : bool ) -> None :
1286+ def predict_cmd (image_path : Path , model : Path , * , confidence : bool ) -> None :
12741287 """Predict a captcha's text. (Library API: import from predict.py.)."""
1275- from predict import predict , predict_with_confidence # local import; avoids cycle
1288+ from predict import predict , predict_with_confidence # noqa: PLC0415 # local import to avoid cycle
12761289
12771290 if confidence :
12781291 text , conf = predict_with_confidence (image_path , model )
0 commit comments