captix 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.
captix/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """captix: Python port of the R captcha package."""
2
+ from __future__ import annotations
3
+
4
+ from .captcha import Captcha, read_captcha
5
+ from .annotate import annotate
6
+ from .dataset import (
7
+ CaptchaDataset,
8
+ DEFAULT_VOCAB,
9
+ transform_image,
10
+ encode_label,
11
+ decode_indices,
12
+ pad_collate,
13
+ )
14
+ from .model import CRNN
15
+ from .fit import fit_model, FitHistory
16
+ from .decrypt import decrypt
17
+ from .load import save_model, load_model, available_models, download_model
18
+ from .data import download_dataset, available_datasets
19
+ from .metrics import sequence_accuracy
20
+ from .download import (
21
+ DownloadError,
22
+ available_sources,
23
+ download_captchas,
24
+ )
25
+ from .hub import (
26
+ DEFAULT_REPO_ID,
27
+ from_pretrained,
28
+ push_to_hub,
29
+ save_pretrained,
30
+ )
31
+
32
+ __all__ = [
33
+ "Captcha",
34
+ "read_captcha",
35
+ "annotate",
36
+ "CaptchaDataset",
37
+ "DEFAULT_VOCAB",
38
+ "transform_image",
39
+ "encode_label",
40
+ "decode_indices",
41
+ "pad_collate",
42
+ "CRNN",
43
+ "fit_model",
44
+ "FitHistory",
45
+ "decrypt",
46
+ "save_model",
47
+ "load_model",
48
+ "available_models",
49
+ "download_model",
50
+ "download_dataset",
51
+ "available_datasets",
52
+ "sequence_accuracy",
53
+ "download_captchas",
54
+ "available_sources",
55
+ "DownloadError",
56
+ "from_pretrained",
57
+ "save_pretrained",
58
+ "push_to_hub",
59
+ "DEFAULT_REPO_ID",
60
+ ]
61
+
62
+ __version__ = "1.0.0"
captix/annotate.py ADDED
@@ -0,0 +1,70 @@
1
+ """Manual / batch annotation of captcha files."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ from pathlib import Path
6
+ from typing import Callable, List, Optional, Sequence, Union
7
+
8
+ PathLike = Union[str, Path]
9
+
10
+
11
+ def _annotate_one(
12
+ file: Path,
13
+ label: str,
14
+ out_dir: Optional[Path],
15
+ rm_old: bool,
16
+ ) -> Path:
17
+ target_dir = out_dir if out_dir is not None else file.parent
18
+ target_dir.mkdir(parents=True, exist_ok=True)
19
+ new_name = f"{file.stem}_{label}{file.suffix}"
20
+ new_path = target_dir / new_name
21
+ if rm_old and out_dir is None:
22
+ file.rename(new_path)
23
+ else:
24
+ shutil.copy2(file, new_path)
25
+ if rm_old:
26
+ file.unlink()
27
+ return new_path
28
+
29
+
30
+ def annotate(
31
+ files: Union[PathLike, Sequence[PathLike]],
32
+ labels: Optional[Sequence[str]] = None,
33
+ path: Optional[PathLike] = None,
34
+ rm_old: bool = False,
35
+ prompt: Optional[Callable[[Path], str]] = None,
36
+ ) -> List[Path]:
37
+ """Annotate captcha files by appending `_<label>` to the filename.
38
+
39
+ Parameters
40
+ ----------
41
+ files: file path or list of file paths.
42
+ labels: list of labels matching `files`. If None, `prompt` is called
43
+ for each file to obtain the label interactively.
44
+ path: optional output directory. Defaults to the file's directory.
45
+ rm_old: if True, remove the original file after annotation.
46
+ prompt: callable taking the file Path and returning a label string.
47
+ Defaults to interactive `input()`.
48
+ """
49
+ if isinstance(files, (str, Path)):
50
+ file_list = [Path(files)]
51
+ else:
52
+ file_list = [Path(f) for f in files]
53
+
54
+ out_dir = Path(path) if path is not None else None
55
+
56
+ if labels is None:
57
+ if prompt is None:
58
+ def prompt(f: Path) -> str: # type: ignore[misc]
59
+ return input(f"Label for {f.name}: ").strip()
60
+ labels = [prompt(f) for f in file_list]
61
+ else:
62
+ if len(labels) != len(file_list):
63
+ raise ValueError(
64
+ f"labels length ({len(labels)}) must match files length ({len(file_list)})"
65
+ )
66
+
67
+ return [
68
+ _annotate_one(f, lab, out_dir, rm_old)
69
+ for f, lab in zip(file_list, labels)
70
+ ]
captix/captcha.py ADDED
@@ -0,0 +1,111 @@
1
+ """Captcha container class and read_captcha function."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Iterable, List, Optional, Sequence, Union
8
+
9
+ from PIL import Image
10
+
11
+ PathLike = Union[str, Path]
12
+
13
+ _LABEL_RE = re.compile(r"(?<=_)[0-9a-zA-Z]+")
14
+
15
+
16
+ def _extract_label(path: Path) -> Optional[str]:
17
+ stem = path.stem
18
+ m = _LABEL_RE.search(stem)
19
+ return m.group(0) if m else None
20
+
21
+
22
+ @dataclass
23
+ class Captcha:
24
+ """Container for one or more captcha images.
25
+
26
+ Mirrors the R `captcha` S3 object: a list with `img`, `lab`, `path`.
27
+ """
28
+
29
+ images: List[Image.Image] = field(default_factory=list)
30
+ labels: List[Optional[str]] = field(default_factory=list)
31
+ paths: List[Path] = field(default_factory=list)
32
+
33
+ def __len__(self) -> int:
34
+ return len(self.images)
35
+
36
+ def __getitem__(self, idx) -> "Captcha":
37
+ if isinstance(idx, int):
38
+ return Captcha(
39
+ images=[self.images[idx]],
40
+ labels=[self.labels[idx]],
41
+ paths=[self.paths[idx]],
42
+ )
43
+ if isinstance(idx, slice):
44
+ return Captcha(
45
+ images=self.images[idx],
46
+ labels=self.labels[idx],
47
+ paths=self.paths[idx],
48
+ )
49
+ if isinstance(idx, (list, tuple)):
50
+ return Captcha(
51
+ images=[self.images[i] for i in idx],
52
+ labels=[self.labels[i] for i in idx],
53
+ paths=[self.paths[i] for i in idx],
54
+ )
55
+ raise TypeError(f"Unsupported index type: {type(idx)}")
56
+
57
+ def __repr__(self) -> str:
58
+ return f"Captcha(n={len(self)})"
59
+
60
+ def plot(self, cols: int = 4, max_rows: int = 25):
61
+ """Display captcha images in a grid using matplotlib."""
62
+ import matplotlib.pyplot as plt
63
+
64
+ n = min(len(self), cols * max_rows)
65
+ rows = (n + cols - 1) // cols
66
+ fig, axes = plt.subplots(rows, cols, figsize=(cols * 2, rows * 1.2))
67
+ if rows == 1 and cols == 1:
68
+ axes = [[axes]]
69
+ elif rows == 1:
70
+ axes = [axes]
71
+ elif cols == 1:
72
+ axes = [[a] for a in axes]
73
+ for i in range(rows * cols):
74
+ r, c = divmod(i, cols)
75
+ ax = axes[r][c]
76
+ ax.axis("off")
77
+ if i < n:
78
+ ax.imshow(self.images[i])
79
+ if self.labels[i]:
80
+ ax.set_title(self.labels[i], fontsize=8)
81
+ fig.tight_layout()
82
+ return fig
83
+
84
+
85
+ def read_captcha(
86
+ files: Union[PathLike, Sequence[PathLike]],
87
+ lab_in_path: bool = False,
88
+ ) -> Captcha:
89
+ """Read one or more captcha image files into a Captcha object.
90
+
91
+ Parameters
92
+ ----------
93
+ files: path or sequence of paths
94
+ lab_in_path: extract label from filename (after the first underscore).
95
+ """
96
+ if isinstance(files, (str, Path)):
97
+ paths: List[Path] = [Path(files)]
98
+ else:
99
+ paths = [Path(f) for f in files]
100
+
101
+ images: List[Image.Image] = []
102
+ labels: List[Optional[str]] = []
103
+ for p in paths:
104
+ if not p.exists():
105
+ raise FileNotFoundError(p)
106
+ img = Image.open(p)
107
+ img.load()
108
+ images.append(img)
109
+ labels.append(_extract_label(p) if lab_in_path else None)
110
+
111
+ return Captcha(images=images, labels=labels, paths=paths)
captix/data.py ADDED
@@ -0,0 +1,65 @@
1
+ """Download and unzip the labeled captcha datasets from the R package releases."""
2
+ from __future__ import annotations
3
+
4
+ import zipfile
5
+ from pathlib import Path
6
+ from typing import List, Union
7
+
8
+ PathLike = Union[str, Path]
9
+
10
+ DATASET_NAMES: List[str] = [
11
+ "cadesp", "esaj", "jucesp", "rfb", "sei",
12
+ "tjmg", "tjpe", "tjrs", "trf5", "trt",
13
+ ]
14
+
15
+ DATASET_REPO_BASE = "https://github.com/decryptr/captcha/releases/download/data"
16
+
17
+
18
+ def available_datasets() -> List[str]:
19
+ """List of dataset names published in the R captcha release."""
20
+ return list(DATASET_NAMES)
21
+
22
+
23
+ def download_dataset(
24
+ name: str,
25
+ dest_dir: PathLike,
26
+ extract: bool = True,
27
+ force: bool = False,
28
+ ) -> Path:
29
+ """Download (and optionally unzip) one of the R captcha datasets.
30
+
31
+ Returns the path to the extracted directory (or the .zip if `extract=False`).
32
+ """
33
+ import requests
34
+ from tqdm import tqdm
35
+
36
+ if name not in DATASET_NAMES:
37
+ raise ValueError(f"Unknown dataset {name!r}. Choose one of {DATASET_NAMES}.")
38
+ dest_dir = Path(dest_dir)
39
+ dest_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ zip_path = dest_dir / f"{name}.zip"
42
+ out_dir = dest_dir / name
43
+
44
+ if not zip_path.exists() or force:
45
+ url = f"{DATASET_REPO_BASE}/{name}.zip"
46
+ with requests.get(url, stream=True, timeout=300) as r:
47
+ r.raise_for_status()
48
+ total = int(r.headers.get("content-length", 0))
49
+ with open(zip_path, "wb") as f, tqdm(
50
+ total=total, unit="B", unit_scale=True, desc=name
51
+ ) as bar:
52
+ for chunk in r.iter_content(chunk_size=1 << 20):
53
+ if chunk:
54
+ f.write(chunk)
55
+ bar.update(len(chunk))
56
+
57
+ if not extract:
58
+ return zip_path
59
+
60
+ if not out_dir.exists() or force:
61
+ out_dir.mkdir(parents=True, exist_ok=True)
62
+ with zipfile.ZipFile(zip_path) as zf:
63
+ zf.extractall(out_dir)
64
+
65
+ return out_dir
captix/dataset.py ADDED
@@ -0,0 +1,207 @@
1
+ """Dataset, transforms and label encoding for captix.
2
+
3
+ The model uses CTC loss, so labels are encoded as integer sequences with a
4
+ reserved blank index 0. The default vocabulary is the full alphanumeric set
5
+ ``0-9a-zA-Z`` (62 classes), giving 63 logits including blank.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import string
11
+ from pathlib import Path
12
+ from typing import List, Optional, Sequence, Tuple, Union
13
+
14
+ import numpy as np
15
+ import torch
16
+ from PIL import Image
17
+ from torch.utils.data import Dataset
18
+ from torchvision import transforms as T
19
+
20
+ PathLike = Union[str, Path]
21
+
22
+ # Default vocabulary: digits + lowercase + uppercase
23
+ DEFAULT_VOCAB: List[str] = list(string.digits + string.ascii_lowercase + string.ascii_uppercase)
24
+
25
+ # Index 0 is reserved as the CTC blank
26
+ BLANK_INDEX = 0
27
+
28
+ _LABEL_RE = re.compile(r"(?<=_)[0-9a-zA-Z]+")
29
+
30
+
31
+ def _build_char_to_idx(vocab: Sequence[str]) -> dict:
32
+ return {ch: i + 1 for i, ch in enumerate(vocab)} # +1 to skip blank
33
+
34
+
35
+ def encode_label(label: str, vocab: Sequence[str] = DEFAULT_VOCAB) -> torch.Tensor:
36
+ """Encode a string label into a 1D LongTensor of vocab indices (no blanks)."""
37
+ c2i = _build_char_to_idx(vocab)
38
+ try:
39
+ return torch.tensor([c2i[c] for c in label], dtype=torch.long)
40
+ except KeyError as e:
41
+ raise ValueError(f"Character {e!s} not in vocabulary") from None
42
+
43
+
44
+ def decode_indices(
45
+ indices: Sequence[int],
46
+ vocab: Sequence[str] = DEFAULT_VOCAB,
47
+ collapse: bool = True,
48
+ ) -> str:
49
+ """Decode a CTC index sequence to a string.
50
+
51
+ If `collapse` is True, applies CTC collapse (remove repeats then blanks).
52
+ """
53
+ out_chars: List[str] = []
54
+ prev = -1
55
+ for i in indices:
56
+ i = int(i)
57
+ if collapse:
58
+ if i == prev:
59
+ continue
60
+ prev = i
61
+ if i == BLANK_INDEX:
62
+ continue
63
+ out_chars.append(vocab[i - 1])
64
+ return "".join(out_chars)
65
+
66
+
67
+ def _flatten_transparency(img: Image.Image) -> Image.Image:
68
+ """If ``img`` has an alpha channel, composite it onto a solid background
69
+ whose luminance contrasts with the average color of the visible pixels,
70
+ then return an RGB image. Otherwise return ``img.convert('RGB')``.
71
+
72
+ PIL's default ``convert('RGB')`` drops alpha by pasting onto black — so a
73
+ captcha drawn as black glyphs on a transparent PNG (TJPE, some PJe
74
+ installs) collapses to an all-black image and the model sees nothing.
75
+ Picking the background as the tonal inverse of the mean visible color
76
+ recovers the contrast regardless of whether the glyphs are dark or light.
77
+ """
78
+ has_alpha = img.mode in ("RGBA", "LA") or (
79
+ img.mode == "P" and "transparency" in img.info
80
+ )
81
+ if not has_alpha:
82
+ return img.convert("RGB")
83
+ rgba = img.convert("RGBA")
84
+ arr = np.asarray(rgba)
85
+ visible = arr[..., 3] > 0
86
+ if visible.any():
87
+ mean_lum = float(arr[..., :3][visible].mean())
88
+ else:
89
+ mean_lum = 0.0
90
+ bg_value = 255 if mean_lum < 128 else 0
91
+ bg = Image.new("RGB", rgba.size, (bg_value, bg_value, bg_value))
92
+ bg.paste(rgba, mask=rgba.split()[-1])
93
+ return bg
94
+
95
+
96
+ def transform_image(
97
+ files: Union[PathLike, Sequence[PathLike], Image.Image, Sequence[Image.Image]],
98
+ height: int = 32,
99
+ max_width: Optional[int] = None,
100
+ ) -> torch.Tensor:
101
+ """Load and resize image(s) to a fixed height, preserving aspect ratio.
102
+
103
+ Returns a 4D tensor (B, 3, H, W). When passing a batch with different widths,
104
+ images are padded to the maximum width in the batch (or `max_width` if given).
105
+ """
106
+ if isinstance(files, (str, Path, Image.Image)):
107
+ files_list = [files]
108
+ else:
109
+ files_list = list(files)
110
+
111
+ to_tensor = T.ToTensor()
112
+ tensors: List[torch.Tensor] = []
113
+ for f in files_list:
114
+ if isinstance(f, Image.Image):
115
+ img = _flatten_transparency(f)
116
+ else:
117
+ img = _flatten_transparency(Image.open(f))
118
+ w, h = img.size
119
+ new_w = max(1, int(round(w * (height / h))))
120
+ img = img.resize((new_w, height), Image.BILINEAR)
121
+ tensors.append(to_tensor(img))
122
+
123
+ target_w = max_width if max_width is not None else max(t.shape[-1] for t in tensors)
124
+ padded = []
125
+ for t in tensors:
126
+ if t.shape[-1] < target_w:
127
+ pad = torch.zeros(3, height, target_w - t.shape[-1])
128
+ t = torch.cat([t, pad], dim=-1)
129
+ elif t.shape[-1] > target_w:
130
+ t = t[..., :target_w]
131
+ padded.append(t)
132
+ return torch.stack(padded, dim=0)
133
+
134
+
135
+ def _list_files(root: Path) -> List[Path]:
136
+ exts = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".webp"}
137
+ return sorted(p for p in root.rglob("*") if p.is_file() and p.suffix.lower() in exts)
138
+
139
+
140
+ def _label_from_path(p: Path) -> Optional[str]:
141
+ m = _LABEL_RE.search(p.stem)
142
+ return m.group(0) if m else None
143
+
144
+
145
+ class CaptchaDataset(Dataset):
146
+ """Dataset of labeled captcha images.
147
+
148
+ Files must follow the pattern ``<id>_<label>.<ext>``. Labels can have
149
+ different lengths (CTC handles variable-length sequences).
150
+ """
151
+
152
+ def __init__(
153
+ self,
154
+ root: PathLike,
155
+ vocab: Sequence[str] = DEFAULT_VOCAB,
156
+ height: int = 32,
157
+ case_sensitive: bool = True,
158
+ ):
159
+ self.root = Path(root)
160
+ if not self.root.is_dir():
161
+ raise NotADirectoryError(self.root)
162
+ self.height = height
163
+ self.vocab = list(vocab)
164
+ self.case_sensitive = case_sensitive
165
+ self.files: List[Path] = []
166
+ self.labels: List[str] = []
167
+ for f in _list_files(self.root):
168
+ lab = _label_from_path(f)
169
+ if lab is None:
170
+ continue
171
+ if not case_sensitive:
172
+ lab = lab.lower()
173
+ self.files.append(f)
174
+ self.labels.append(lab)
175
+ if not self.files:
176
+ raise ValueError(f"No labeled captcha files found in {self.root}")
177
+
178
+ def __len__(self) -> int:
179
+ return len(self.files)
180
+
181
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
182
+ img_t = transform_image(self.files[idx], height=self.height)[0] # (3, H, W)
183
+ target = encode_label(self.labels[idx], self.vocab)
184
+ return img_t, target
185
+
186
+
187
+ def pad_collate(
188
+ batch: Sequence[Tuple[torch.Tensor, torch.Tensor]],
189
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
190
+ """Collate function: pads images to the max width and stacks targets.
191
+
192
+ Returns
193
+ -------
194
+ images: (B, 3, H, W_max)
195
+ targets: 1D tensor concatenating all label sequences
196
+ target_lengths: (B,) lengths for CTC loss
197
+ """
198
+ images = [b[0] for b in batch]
199
+ targets = [b[1] for b in batch]
200
+ H = images[0].shape[1]
201
+ W_max = max(im.shape[-1] for im in images)
202
+ out = torch.zeros(len(images), 3, H, W_max)
203
+ for i, im in enumerate(images):
204
+ out[i, :, :, : im.shape[-1]] = im
205
+ target_lengths = torch.tensor([len(t) for t in targets], dtype=torch.long)
206
+ targets_cat = torch.cat(targets) if targets else torch.zeros(0, dtype=torch.long)
207
+ return out, targets_cat, target_lengths