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 +62 -0
- captix/annotate.py +70 -0
- captix/captcha.py +111 -0
- captix/data.py +65 -0
- captix/dataset.py +207 -0
- captix/decrypt.py +231 -0
- captix/download/__init__.py +26 -0
- captix/download/_base.py +153 -0
- captix/download/_sources.py +248 -0
- captix/download/cli.py +92 -0
- captix/fit.py +193 -0
- captix/hub.py +207 -0
- captix/load.py +93 -0
- captix/metrics.py +14 -0
- captix/model.py +111 -0
- captix-1.0.0.dist-info/METADATA +261 -0
- captix-1.0.0.dist-info/RECORD +20 -0
- captix-1.0.0.dist-info/WHEEL +4 -0
- captix-1.0.0.dist-info/entry_points.txt +2 -0
- captix-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|