Skip to content

Commit c1db04b

Browse files
committed
chore: Fix ruff lints
1 parent 6275166 commit c1db04b

3 files changed

Lines changed: 58 additions & 41 deletions

File tree

captcha/manage.py

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
import click
3131
import numpy as np
32+
import requests
3233
import torch
3334
import torch.nn.functional as F
3435
from bs4 import BeautifulSoup
@@ -51,10 +52,17 @@
5152
N_CHARS = 6
5253
CROP_H = 40
5354
CROP_W = 28
55+
PAIR_LEN = 2 # a --pair option names exactly two characters
5456

5557
CHROMA_MAX = 30
5658
INTENSITY_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

5967
DEFAULT_LABELS = HERE / "labels.json"
6068
DEFAULT_SAMPLES = HERE / "samples"
@@ -96,7 +104,7 @@ def clean(rgb: np.ndarray) -> np.ndarray:
96104

97105
def 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

209217
class 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
)
502514
def 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)

captcha/predict.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
import binascii
2323
import io
2424
import sys
25+
from functools import cache
2526
from pathlib import Path
26-
from typing import Union
2727

2828
import numpy as np
2929
import torch
@@ -38,27 +38,27 @@
3838
)
3939
from PIL import Image
4040

41-
ImageInput = Union[str, bytes, Path, Image.Image, np.ndarray]
41+
ImageInput = str | bytes | Path | Image.Image | np.ndarray
4242
DEFAULT_MODEL_PATH = Path(__file__).parent / "captcha_cnn.pt"
4343

44-
_model: TinyCNN | None = None
45-
_device: torch.device | None = None
44+
RGB_NDIM = 3 # height, width, channel axes
45+
RGB_CHANNELS = 3
46+
MAX_PATH_LEN = 260 # paths longer than this can't be filesystem paths, so treat as base64
4647

4748

49+
@cache
4850
def _get_model(model_path: Path = DEFAULT_MODEL_PATH) -> tuple[TinyCNN, torch.device]:
49-
global _model, _device
50-
if _model is None:
51-
_device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
52-
_model = TinyCNN()
53-
_model.load_state_dict(torch.load(model_path, map_location=_device))
54-
_model.to(_device).eval()
55-
return _model, _device # type: ignore[return-value]
51+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
52+
model = TinyCNN()
53+
model.load_state_dict(torch.load(model_path, map_location=device))
54+
model.to(device).eval()
55+
return model, device
5656

5757

5858
def _to_rgb(image: ImageInput) -> np.ndarray:
5959
"""Coerce any supported input into an HxWx3 uint8 array composited over white."""
6060
if isinstance(image, np.ndarray):
61-
if image.ndim == 3 and image.shape[2] == 3 and image.dtype == np.uint8:
61+
if image.ndim == RGB_NDIM and image.shape[2] == RGB_CHANNELS and image.dtype == np.uint8:
6262
return image
6363
pil = Image.fromarray(image)
6464
elif isinstance(image, Image.Image):
@@ -79,7 +79,7 @@ def _to_rgb(image: ImageInput) -> np.ndarray:
7979

8080
def _looks_like_path(s: str) -> bool:
8181
# Treat anything short with a known image suffix or an existing file as a path.
82-
if len(s) < 260 and (s.endswith((".png", ".jpg", ".jpeg"))):
82+
if len(s) < MAX_PATH_LEN and (s.endswith((".png", ".jpg", ".jpeg"))):
8383
return True
8484
return Path(s).exists()
8585

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@ ignore = [
1212
"ANN", "C901", "COM812", "D203", "D212", "D415", "EM", "ISC001", "PERF203", "PLR091", "Q000",
1313
"D1",
1414
"T201", # print
15+
"TRY003", # raise
1516
]
1617

1718
[tool.ruff.lint.flake8-builtins]
1819
builtins-ignorelist = ["copyright"]
1920

21+
[tool.ruff.lint.pep8-naming]
22+
extend-ignore-names = ["F"] # torch.nn.functional as F
23+
2024
[tool.ruff.lint.flake8-unused-arguments]
2125
ignore-variadic-names = true

0 commit comments

Comments
 (0)