Wall3 commited on
Commit
19ca2e1
Β·
verified Β·
1 Parent(s): 2f08058

Upload 5 files

Browse files
Files changed (6) hide show
  1. .gitattributes +1 -0
  2. README.md +237 -0
  3. dataset.py +339 -0
  4. model.py +290 -0
  5. preview_all_types.png +3 -0
  6. weekend_best.weights.h5 +3 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ preview_all_types.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,3 +1,240 @@
1
  ---
2
  license: mit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: mit
3
+ language:
4
+ - en
5
+ tags:
6
+ - captcha
7
+ - ocr
8
+ - crnn
9
+ - ctc
10
+ - image-to-text
11
+ - tensorflow
12
+ - keras
13
+ task_categories:
14
+ - image-to-text
15
+ datasets:
16
+ - ayoubkirouane/captcha
17
+ - ThangaTharun/captchaimages
18
+ - yuxi5/text-captcha-data-clean
19
+ - AvinashRicky/CaptchaOCR-500K
20
+ - cybertruck32489/captcha_90k
21
+ - yusuf802/captcha_dataset
22
+ - lumasik/captcha-25k
23
  ---
24
+
25
+ # πŸ” CRNN-CTC Captcha Solver
26
+
27
+ ### Convolutional Recurrent Neural Network Β· Real-Data Fine-Tuned
28
+
29
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
30
+ [![Python 3.10+](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
31
+ [![TensorFlow 2.21](https://img.shields.io/badge/TensorFlow-2.21-orange.svg)](https://www.tensorflow.org/)
32
+
33
+ Alphanumeric CAPTCHA recognition using a deep **CRNN + CTC** architecture trained on
34
+ **275,000 real-world labeled CAPTCHAs** from 7 HuggingFace datasets, then refined over a
35
+ full weekend of augmented training on an NVIDIA A100. Achieves **90.08% whole-CAPTCHA
36
+ sequence accuracy** on the held-out real test set.
37
+
38
+ ---
39
+
40
+ ## πŸ“‹ Model Details
41
+
42
+ | Property | Value |
43
+ |---|---|
44
+ | **Task** | Alphanumeric CAPTCHA Recognition (OCR) |
45
+ | **Architecture** | CRNN β€” 6-block CNN + 2Γ— Bidirectional LSTM + Dense |
46
+ | **Input** | RGB image `(64 Γ— 200 Γ— 3)`, float32 `[0, 1]` |
47
+ | **Output** | Character sequence, length 1–8 |
48
+ | **Vocabulary** | `0-9`, `a-z`, `A-Z` β€” 62 characters + CTC blank |
49
+ | **Loss** | Connectionist Temporal Classification (CTC) |
50
+ | **Parameters** | 10,049,535 (~38.3 MB) |
51
+ | **Framework** | TensorFlow 2.21 / Keras 3 |
52
+ | **Training hardware** | NVIDIA A100 80 GB |
53
+
54
+ ---
55
+
56
+ ## πŸ“Š Performance
57
+
58
+ ### Overall (held-out real test set, n = 5,502)
59
+
60
+ | Metric | Score |
61
+ |---|---|
62
+ | **Sequence accuracy** (whole CAPTCHA correct) | **90.08 %** |
63
+ | **Character accuracy** | **96.14 %** |
64
+ | CTC loss | 1.10 |
65
+
66
+ ### Per-dataset breakdown
67
+
68
+ | Dataset | n (test) | Char acc | Seq acc |
69
+ |---|---|---|---|
70
+ | cybertruck32489/captcha_90k | 1,224 | 99.9 % | **99.7 %** |
71
+ | ayoubkirouane/captcha | 192 | 99.9 % | **99.5 %** |
72
+ | yuxi5/text-captcha-data-clean | 1,171 | 93.6 % | 90.9 % |
73
+ | AvinashRicky/CaptchaOCR-500K | 1,215 | 97.8 % | 88.7 % |
74
+ | lumasik/captcha-25k | 502 | 96.0 % | 86.5 % |
75
+ | yusuf802/captcha_dataset | 1,196 | 92.5 % | 80.8 % |
76
+ | ThangaTharun/captchaimages | 2 | 100.0 % | 100.0 % |
77
+
78
+ **Total params: 10,049,535 β€” Model size: 38.3 MB**
79
+
80
+ ---
81
+
82
+ ## πŸ“¦ Training Datasets
83
+
84
+ | Dataset | Label field | Images used | Notes |
85
+ |---|---|---|---|
86
+ | [ayoubkirouane/captcha](https://huggingface.co/datasets/ayoubkirouane/captcha) | `solution` | 10,000 | clean, parquet |
87
+ | [ThangaTharun/captchaimages](https://huggingface.co/datasets/ThangaTharun/captchaimages) | `output` | 100 | small, noisy |
88
+ | [yuxi5/text-captcha-data-clean](https://huggingface.co/datasets/yuxi5/text-captcha-data-clean) | `label` | 60,000 | capped at 60k |
89
+ | [AvinashRicky/CaptchaOCR-500K](https://huggingface.co/datasets/AvinashRicky/CaptchaOCR-500K) | `text` | 60,000 | capped at 60k |
90
+ | [cybertruck32489/captcha_90k](https://huggingface.co/datasets/cybertruck32489/captcha_90k) | `solve` | 60,000 | capped at 60k |
91
+ | [yusuf802/captcha_dataset](https://huggingface.co/datasets/yusuf802/captcha_dataset) | `label` | 60,000 | capped at 60k |
92
+ | [lumasik/captcha-25k](https://huggingface.co/datasets/lumasik/captcha-25k) | `label` | 25,000 | all images used |
93
+ | **Synthetic (17 generator types)** | generated | mixed in at 30% | custom Python generators |
94
+
95
+ **Total real images preprocessed:** 275,100 β†’ 264,096 train / 5,502 val / 5,502 test
96
+
97
+ All images were resized to **64 Γ— 200** with aspect-preserving letterboxing (pad with
98
+ median border color).
99
+
100
+ ---
101
+
102
+ ## βš™οΈ Training Details
103
+
104
+ ### Two-stage process
105
+
106
+ **Stage 1 β€” Synthetic pre-training**
107
+ Training from scratch on 17 procedural CAPTCHA types (custom Python generators) covering
108
+ different fonts, noise levels, distortions, and color palettes.
109
+
110
+ **Stage 2 β€” Real-data fine-tuning + augmented weekend run**
111
+
112
+ - Initialized from the synthetic checkpoint.
113
+ - Fine-tuned on 264k real images for 40 epochs (LR 1e-3 β†’ 1e-5 cosine).
114
+ - Extended with a full weekend run (386 epochs, ~60h on A100) combining:
115
+ - **30% synthetic** mixed into each batch for robustness.
116
+ - **GPU augmentation:** `RandomRotation(Β±2Β°)`, `RandomTranslation`, `RandomZoom(6%)`, `RandomContrast(25%)`, brightness jitter Β±0.08, Gaussian noise Οƒ=0.03.
117
+ - **Weak-source oversampling:** datasets below 90% test seq were oversampled up to Γ—2 proportionally.
118
+
119
+ ### Hyperparameters
120
+
121
+ | Parameter | Value |
122
+ |---|---|
123
+ | Optimizer | Adam |
124
+ | Peak LR | 5 Γ— 10⁻⁴ |
125
+ | Min LR | 1 Γ— 10⁻⁢ |
126
+ | LR schedule | Warmup + Cosine Decay |
127
+ | Batch size | 128 |
128
+ | Dropout | 0.25 |
129
+ | Gradient clip norm | 5.0 |
130
+ | Max sequence length | 8 characters |
131
+
132
+ ---
133
+
134
+ ## πŸš€ Usage
135
+
136
+ ### Install dependencies
137
+
138
+ ```bash
139
+ pip install tensorflow pillow numpy
140
+ ```
141
+
142
+ ### Predict from an image
143
+
144
+ ```python
145
+ import numpy as np
146
+ from PIL import Image
147
+ import tensorflow as tf
148
+
149
+ # ── helpers ──────────────────────────────────────────────────────────────────
150
+ CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
151
+ IDX_TO_CHAR = {i: c for i, c in enumerate(CHARS)}
152
+ BLANK_IDX = 62
153
+ IMG_H, IMG_W = 64, 200
154
+
155
+ def preprocess(pil_img: Image.Image) -> np.ndarray:
156
+ """Aspect-preserving resize to 64Γ—200, pad with median border color."""
157
+ img = pil_img.convert("RGB")
158
+ w, h = img.size
159
+ new_w = int(round(w * IMG_H / h))
160
+ img = img.resize((new_w, IMG_H), Image.LANCZOS)
161
+ if new_w >= IMG_W:
162
+ img = img.resize((IMG_W, IMG_H), Image.LANCZOS)
163
+ return np.array(img, dtype=np.float32) / 255.0
164
+ canvas = np.array(img, dtype=np.float32)
165
+ border_color = np.median(
166
+ np.concatenate([canvas[0], canvas[-1],
167
+ canvas[:, 0], canvas[:, -1]], axis=0), axis=0
168
+ )
169
+ pad_l = (IMG_W - new_w) // 2
170
+ result = np.full((IMG_H, IMG_W, 3), border_color, dtype=np.float32)
171
+ result[:, pad_l:pad_l + new_w] = canvas
172
+ return result / 255.0
173
+
174
+ def decode_ctc(logits: np.ndarray) -> str:
175
+ """Greedy CTC decode β€” collapse repeats, remove blanks."""
176
+ indices = np.argmax(logits, axis=-1) # (T,)
177
+ chars, prev = [], -1
178
+ for idx in indices:
179
+ if idx != prev and idx != BLANK_IDX:
180
+ chars.append(IDX_TO_CHAR.get(int(idx), ""))
181
+ prev = idx
182
+ return "".join(chars)
183
+
184
+ # ── load model ───────────────────────────────────────────────────────────────
185
+ # Clone the repo or download the weights file, then:
186
+ from model import build_crnn_model # from this repository
187
+
188
+ model = build_crnn_model()
189
+ model.load_weights("weekend_best.weights.h5")
190
+
191
+ # ── run inference ─────────────────────────────────────────────────────────────
192
+ img = Image.open("captcha.png")
193
+ tensor = preprocess(img)[np.newaxis] # (1, 64, 200, 3)
194
+ logits = model(tensor, training=False).numpy() # (1, 50, 63)
195
+ print("Prediction:", decode_ctc(logits[0]))
196
+ ```
197
+
198
+ ### Batch inference
199
+
200
+ ```python
201
+ images = [preprocess(Image.open(p)) for p in image_paths]
202
+ batch = np.stack(images) # (N, 64, 200, 3)
203
+ logits = model(batch, training=False).numpy() # (N, 50, 63)
204
+ preds = [decode_ctc(l) for l in logits]
205
+ ```
206
+
207
+ ---
208
+
209
+ ## πŸ–ΌοΈ Preprocessing
210
+
211
+ Input images go through **aspect-preserving letterboxing**:
212
+
213
+ 1. Resize height to 64 px, keeping aspect ratio.
214
+ 2. If the resulting width β‰₯ 200 px, squeeze to 200 px.
215
+ 3. Otherwise, pad left and right with the **median border color** of the image to reach 200 px.
216
+ 4. Normalize to `float32 [0, 1]`.
217
+
218
+ This ensures the character shapes are never stretched, which is critical for
219
+ distinguishing similar characters (e.g. `O` / `0`, `l` / `1`).
220
+
221
+ ---
222
+
223
+ ## βš–οΈ License
224
+
225
+ This model is released under the **MIT License**. You are free to use, copy, modify,
226
+ and distribute it for any purpose, including commercial use, with attribution.
227
+
228
+ ---
229
+
230
+ ## πŸ“Ž Citation
231
+
232
+ If you use this model in your work, please cite:
233
+
234
+ ```
235
+ CRNN-CTC Captcha Solver (2026)
236
+ Trained on 275k real-world CAPTCHAs from 7 HuggingFace datasets.
237
+ Architecture: 6-block CNN + 2Γ— BiLSTM + CTC, 10M parameters, 38 MB.
238
+ Sequence accuracy: 90.08% on held-out real test set.
239
+ License: MIT
240
+ ```
dataset.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dataset.py
3
+ ~~~~~~~~~~
4
+ Infinite on-the-fly TensorFlow dataset pipeline for CAPTCHA training.
5
+ No images are ever saved to disk β€” every sample is generated fresh each epoch.
6
+
7
+ Image size: 200 Γ— 64 px (width Γ— height)
8
+ Why 200 wide?
9
+ CTC needs time_steps >= 2 * max_label_len - 1.
10
+ After 2Γ— stride-2 pooling on the width axis: 200 / 4 = 50 time steps.
11
+ For a 7-char label that's 50 vs the minimum 13 β€” comfortable margin.
12
+ 128px would give only 32 steps (still valid but tight for longer labels).
13
+
14
+ Character set: 62 chars (digits + lowercase + uppercase)
15
+ Indices 0-61 β†’ '0'-'9', 'a'-'z', 'A'-'Z'
16
+ Index 62 β†’ CTC blank token
17
+
18
+ Usage:
19
+ from dataset import make_dataset, make_combined_dataset, CHARS, NUM_CLASSES
20
+
21
+ # One dataset per type (17 separate, fully on-the-fly)
22
+ ds_type1 = make_dataset(captcha_type=1, batch_size=32)
23
+ for images, labels, label_lengths in ds_type1.take(10):
24
+ ... # images: (B, 64, 200, 3) float32 labels: (B, pad) int32
25
+
26
+ # All 17 types mixed together
27
+ ds_all = make_combined_dataset(batch_size=32)
28
+
29
+ # Keras model.fit() helper (returns dict y)
30
+ ds_keras = make_dataset(1, batch_size=32, keras_format=True)
31
+ model.fit(ds_keras, steps_per_epoch=500, epochs=50)
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import numpy as np
37
+ import tensorflow as tf
38
+ from PIL import Image as PILImage
39
+
40
+ from captcha_generators import generate, generate_random, TYPES
41
+
42
+ # ── Character set ─────────────────────────────────────────────────────────────
43
+
44
+ CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
45
+ CHAR_TO_IDX = {c: i for i, c in enumerate(CHARS)}
46
+ IDX_TO_CHAR = {i: c for i, c in enumerate(CHARS)}
47
+ BLANK_IDX = len(CHARS) # 62
48
+ NUM_CLASSES = len(CHARS) + 1 # 63 (62 chars + 1 blank)
49
+ MAX_LABEL_LEN = 8 # max chars any generator produces
50
+
51
+ # ── Image dimensions ──────────────────────────────────────────────────────────
52
+
53
+ IMG_W = 200 # width β†’ determines CTC time steps after CNN
54
+ IMG_H = 64 # height
55
+ IMG_C = 3 # RGB channels
56
+
57
+ # ── Encoding helpers ──────────────────────────────────────────────────────────
58
+
59
+ def encode_label(text: str) -> np.ndarray:
60
+ """
61
+ Map each character in *text* to its integer index.
62
+ Unknown characters are silently skipped.
63
+ Returns a 1-D int32 numpy array.
64
+ """
65
+ return np.array([CHAR_TO_IDX[c] for c in text if c in CHAR_TO_IDX],
66
+ dtype=np.int32)
67
+
68
+
69
+ def decode_label(indices) -> str:
70
+ """
71
+ Map a sequence of integer indices back to a string.
72
+ CTC blank tokens (index 62) and padding (-1) are stripped.
73
+ """
74
+ return "".join(
75
+ IDX_TO_CHAR[int(i)]
76
+ for i in indices
77
+ if int(i) not in (-1, BLANK_IDX)
78
+ )
79
+
80
+
81
+ def preprocess_image(pil_img) -> np.ndarray:
82
+ """
83
+ Resize a PIL image to (IMG_H, IMG_W) **preserving aspect ratio** and
84
+ pad the remaining width with the estimated background colour.
85
+ Returns a float32 array in [0, 1] of shape (IMG_H, IMG_W, IMG_C).
86
+
87
+ Real CAPTCHAs vary widely in aspect ratio (100Γ—40 … 280Γ—54). Stretching
88
+ them all to a fixed box distorts the glyphs; instead we scale to the target
89
+ height, fit the width, and pad so characters keep their natural shape.
90
+ """
91
+ pil_img = pil_img.convert("RGB")
92
+ w, h = pil_img.size
93
+
94
+ # Scale so height == IMG_H, keep aspect ratio
95
+ new_w = max(1, int(round(w * (IMG_H / h))))
96
+ pil_img = pil_img.resize((new_w, IMG_H), PILImage.LANCZOS)
97
+ arr = np.array(pil_img, dtype=np.float32) / 255.0 # (IMG_H, new_w, 3)
98
+
99
+ if new_w == IMG_W:
100
+ return arr
101
+ if new_w > IMG_W:
102
+ # Too wide after height-scaling: squeeze width to fit (rare)
103
+ pil_img = pil_img.resize((IMG_W, IMG_H), PILImage.LANCZOS)
104
+ return np.array(pil_img, dtype=np.float32) / 255.0
105
+
106
+ # Pad width to IMG_W, centered, using the median border colour as background
107
+ border = np.concatenate([arr[0, :, :], arr[-1, :, :],
108
+ arr[:, 0, :], arr[:, -1, :]], axis=0)
109
+ bg = np.median(border, axis=0) # (3,)
110
+ canvas = np.ones((IMG_H, IMG_W, IMG_C), dtype=np.float32) * bg
111
+ left = (IMG_W - new_w) // 2
112
+ canvas[:, left:left + new_w, :] = arr
113
+ return canvas
114
+
115
+
116
+
117
+ # ── Core Python generators ────────────────────────────────────────────────────
118
+
119
+ def _single_type_generator(captcha_type: int):
120
+ """
121
+ Infinite Python generator for one CAPTCHA type.
122
+ Yields (image_array, label_indices, label_length) tuples.
123
+ """
124
+ while True:
125
+ pil_img, label = generate(captcha_type)
126
+ image = preprocess_image(pil_img)
127
+ indices = encode_label(label)
128
+ yield image, indices, np.int32(len(indices))
129
+
130
+
131
+ def _combined_generator():
132
+ """
133
+ Infinite Python generator that cycles through all 17 types randomly.
134
+ Yields (image_array, label_indices, label_length, type_id) tuples.
135
+ """
136
+ import random
137
+ while True:
138
+ pil_img, label, t = generate_random()
139
+ image = preprocess_image(pil_img)
140
+ indices = encode_label(label)
141
+ yield image, indices, np.int32(len(indices)), np.int32(t)
142
+
143
+
144
+ # ── tf.data.Dataset factories ─────────────────────────────────────────────────
145
+
146
+ def make_dataset(
147
+ captcha_type: int,
148
+ batch_size: int = 32,
149
+ shuffle_buffer: int = 256,
150
+ prefetch: int = tf.data.AUTOTUNE,
151
+ keras_format: bool = False,
152
+ ) -> tf.data.Dataset:
153
+ """
154
+ Build an infinite on-the-fly tf.data.Dataset for a single CAPTCHA type.
155
+
156
+ Args:
157
+ captcha_type: Integer 1-17.
158
+ batch_size: Samples per batch.
159
+ shuffle_buffer: Size of the shuffle buffer (0 = no shuffle).
160
+ prefetch: Number of batches to prefetch (AUTOTUNE recommended).
161
+ keras_format: If True, each batch is (images, y_dict) where
162
+ y_dict = {'labels': ..., 'label_lengths': ...}.
163
+ Use this with model.fit().
164
+
165
+ Returns:
166
+ Batched tf.data.Dataset.
167
+ Each batch (keras_format=False):
168
+ images : (B, 64, 200, 3) float32
169
+ labels : (B, pad_len) int32 (padded with -1)
170
+ label_lengths : (B,) int32
171
+ """
172
+ if captcha_type not in TYPES:
173
+ raise ValueError(f"captcha_type must be 1-17, got {captcha_type!r}")
174
+
175
+ output_sig = (
176
+ tf.TensorSpec(shape=(IMG_H, IMG_W, IMG_C), dtype=tf.float32),
177
+ tf.TensorSpec(shape=(None,), dtype=tf.int32),
178
+ tf.TensorSpec(shape=(), dtype=tf.int32),
179
+ )
180
+
181
+ ds = tf.data.Dataset.from_generator(
182
+ lambda: _single_type_generator(captcha_type),
183
+ output_signature=output_sig,
184
+ )
185
+
186
+ if shuffle_buffer > 0:
187
+ ds = ds.shuffle(buffer_size=shuffle_buffer, reshuffle_each_iteration=True)
188
+
189
+ ds = ds.padded_batch(
190
+ batch_size,
191
+ padded_shapes=((IMG_H, IMG_W, IMG_C), (None,), ()),
192
+ padding_values=(
193
+ tf.constant(0.0, tf.float32),
194
+ tf.constant(-1, tf.int32), # -1 = padding sentinel for labels
195
+ tf.constant(0, tf.int32),
196
+ ),
197
+ drop_remainder=False,
198
+ )
199
+
200
+ if keras_format:
201
+ ds = ds.map(
202
+ lambda imgs, lbl, llen: (
203
+ imgs,
204
+ {"labels": lbl, "label_lengths": llen},
205
+ ),
206
+ num_parallel_calls=tf.data.AUTOTUNE,
207
+ )
208
+
209
+ return ds.prefetch(prefetch)
210
+
211
+
212
+ def make_combined_dataset(
213
+ batch_size: int = 32,
214
+ shuffle_buffer: int = 256,
215
+ prefetch: int = tf.data.AUTOTUNE,
216
+ include_type_id: bool = False,
217
+ keras_format: bool = False,
218
+ ) -> tf.data.Dataset:
219
+ """
220
+ Build an infinite dataset that mixes all 17 CAPTCHA types randomly.
221
+
222
+ Args:
223
+ include_type_id: If True, each batch includes the captcha_type integer
224
+ (useful for multi-task learning or analysis).
225
+ keras_format: Same as make_dataset().
226
+
227
+ Returns:
228
+ Batched tf.data.Dataset.
229
+ Each batch (include_type_id=False, keras_format=False):
230
+ images : (B, 64, 200, 3) float32
231
+ labels : (B, pad_len) int32
232
+ label_lengths : (B,) int32
233
+ """
234
+ output_sig = (
235
+ tf.TensorSpec(shape=(IMG_H, IMG_W, IMG_C), dtype=tf.float32),
236
+ tf.TensorSpec(shape=(None,), dtype=tf.int32),
237
+ tf.TensorSpec(shape=(), dtype=tf.int32),
238
+ tf.TensorSpec(shape=(), dtype=tf.int32), # type_id
239
+ )
240
+
241
+ ds = tf.data.Dataset.from_generator(
242
+ _combined_generator,
243
+ output_signature=output_sig,
244
+ )
245
+
246
+ if shuffle_buffer > 0:
247
+ ds = ds.shuffle(buffer_size=shuffle_buffer, reshuffle_each_iteration=True)
248
+
249
+ if include_type_id:
250
+ ds = ds.padded_batch(
251
+ batch_size,
252
+ padded_shapes=((IMG_H, IMG_W, IMG_C), (None,), (), ()),
253
+ padding_values=(
254
+ tf.constant(0.0, tf.float32),
255
+ tf.constant(-1, tf.int32),
256
+ tf.constant(0, tf.int32),
257
+ tf.constant(0, tf.int32),
258
+ ),
259
+ drop_remainder=False,
260
+ )
261
+ else:
262
+ # Drop the type_id column for simplicity
263
+ ds = ds.map(lambda img, lbl, llen, _t: (img, lbl, llen),
264
+ num_parallel_calls=tf.data.AUTOTUNE)
265
+ ds = ds.padded_batch(
266
+ batch_size,
267
+ padded_shapes=((IMG_H, IMG_W, IMG_C), (None,), ()),
268
+ padding_values=(
269
+ tf.constant(0.0, tf.float32),
270
+ tf.constant(-1, tf.int32),
271
+ tf.constant(0, tf.int32),
272
+ ),
273
+ drop_remainder=False,
274
+ )
275
+
276
+ if keras_format:
277
+ ds = ds.map(
278
+ lambda imgs, lbl, llen: (
279
+ imgs,
280
+ {"labels": lbl, "label_lengths": llen},
281
+ ),
282
+ num_parallel_calls=tf.data.AUTOTUNE,
283
+ )
284
+
285
+ return ds.prefetch(prefetch)
286
+
287
+
288
+ def make_all_datasets(
289
+ batch_size: int = 32,
290
+ shuffle_buffer: int = 256,
291
+ keras_format: bool = False,
292
+ ) -> dict[int, tf.data.Dataset]:
293
+ """
294
+ Convenience function β€” returns a dict of 17 separate tf.data.Datasets,
295
+ one per CAPTCHA type.
296
+
297
+ Returns:
298
+ {1: ds_type1, 2: ds_type2, ..., 17: ds_type17}
299
+ """
300
+ return {
301
+ t: make_dataset(t, batch_size=batch_size,
302
+ shuffle_buffer=shuffle_buffer,
303
+ keras_format=keras_format)
304
+ for t in TYPES
305
+ }
306
+
307
+
308
+ # ── Quick sanity check ────────────────────────────────────────────────────────
309
+
310
+ def verify_pipeline(captcha_type: int = 1, n_batches: int = 3, batch_size: int = 4) -> None:
311
+ """
312
+ Print shape and label info for a few batches. Use to confirm the pipeline
313
+ is working before kicking off a full training run.
314
+
315
+ Example:
316
+ from dataset import verify_pipeline
317
+ verify_pipeline(captcha_type=3)
318
+ """
319
+ print(f"\n── Verifying pipeline for type {captcha_type} ──")
320
+ print(f" IMG_W={IMG_W} IMG_H={IMG_H} NUM_CLASSES={NUM_CLASSES} BLANK={BLANK_IDX}\n")
321
+
322
+ ds = make_dataset(captcha_type, batch_size=batch_size, shuffle_buffer=16)
323
+ for batch_idx, (images, labels, lengths) in enumerate(ds.take(n_batches)):
324
+ print(f" Batch {batch_idx + 1}:")
325
+ print(f" images shape : {images.shape} dtype={images.dtype}"
326
+ f" min={images.numpy().min():.3f} max={images.numpy().max():.3f}")
327
+ print(f" labels shape : {labels.shape} dtype={labels.dtype}")
328
+ print(f" label_lengths : {lengths.numpy().tolist()}")
329
+ for i in range(len(lengths)):
330
+ raw = labels[i].numpy()
331
+ length = int(lengths[i])
332
+ text = decode_label(raw[:length])
333
+ print(f" sample {i}: encoded={raw[:length].tolist()} decoded='{text}'")
334
+ print()
335
+
336
+
337
+ if __name__ == "__main__":
338
+ for t in TYPES:
339
+ verify_pipeline(captcha_type=t, n_batches=1, batch_size=2)
model.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ model.py
3
+ ~~~~~~~~
4
+ CRNN (Convolutional Recurrent Neural Network) for CAPTCHA text recognition.
5
+
6
+ Architecture β€” Shi et al. 2016, "An End-to-End Trainable Neural Network
7
+ for Image-based Sequence Recognition" (modernised with BatchNorm + Dropout):
8
+
9
+ Input (B, H=64, W=200, C=3)
10
+ ──────────────────────────────────────────────────────────────
11
+ CNN 6 conv blocks β†’ (B, 1, 50, 512)
12
+ Reshape squeeze height β†’ (B, T=50, 512)
13
+ BiLSTMΓ—2 bidirectional context β†’ (B, T=50, 512)
14
+ Dense per-step projection β†’ (B, T=50, 63) ← logits
15
+ ──────────────────────────────────────────────────────────────
16
+ CTC loss (training) tf.nn.ctc_loss
17
+ CTC decode(inference) greedy or beam search
18
+
19
+ Why CRNN + CTC?
20
+ β€’ CNN β€” learns local visual features (stroke curves, serifs, noise patterns)
21
+ β€’ Width axis maps naturally to the time axis CTC needs
22
+ β€’ BiLSTM β€” captures left ↔ right context across characters
23
+ β€’ CTC β€” no need to pre-segment characters; handles variable-length text
24
+
25
+ CNN width reduction detail:
26
+ Blocks 1-2: MaxPool(2Γ—2) β†’ width halved twice: 200 β†’ 100 β†’ 50
27
+ Blocks 3-6: MaxPool(2Γ—1) β†’ width unchanged at 50, height halved to 1
28
+ After CNN: T = 50 time steps (β‰₯ 2Γ—max_label_len βˆ’ 1 = 15, safe margin)
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import keras
34
+ import tensorflow as tf
35
+ import numpy as np
36
+
37
+ from dataset import NUM_CLASSES, BLANK_IDX, IMG_H, IMG_W, IMG_C, decode_label
38
+
39
+ # ── CNN building block ────────────────────────────────────────────────────────
40
+
41
+ def _conv_block(
42
+ x: keras.KerasTensor,
43
+ filters: int,
44
+ pool: tuple[int, int],
45
+ double_conv: bool = False,
46
+ name: str = "",
47
+ ) -> keras.KerasTensor:
48
+ """Conv β†’ BN β†’ ReLU (optionally repeated) β†’ MaxPool."""
49
+ x = keras.layers.Conv2D(
50
+ filters, (3, 3), padding="same", use_bias=False, name=f"{name}_conv1"
51
+ )(x)
52
+ x = keras.layers.BatchNormalization(name=f"{name}_bn1")(x)
53
+ x = keras.layers.Activation("relu", name=f"{name}_relu1")(x)
54
+
55
+ if double_conv:
56
+ x = keras.layers.Conv2D(
57
+ filters, (3, 3), padding="same", use_bias=False, name=f"{name}_conv2"
58
+ )(x)
59
+ x = keras.layers.BatchNormalization(name=f"{name}_bn2")(x)
60
+ x = keras.layers.Activation("relu", name=f"{name}_relu2")(x)
61
+
62
+ x = keras.layers.MaxPooling2D(pool_size=pool, strides=pool, name=f"{name}_pool")(x)
63
+ return x
64
+
65
+
66
+ # ── Model builder ─────────────────────────────────────────────────────────────
67
+
68
+ def build_crnn_model(
69
+ img_h: int = IMG_H,
70
+ img_w: int = IMG_W,
71
+ img_c: int = IMG_C,
72
+ num_classes: int = NUM_CLASSES,
73
+ rnn_units: int = 256,
74
+ num_rnn_layers: int = 2,
75
+ rnn_type: str = "lstm", # 'lstm' or 'gru'
76
+ dropout: float = 0.25,
77
+ ) -> keras.Model:
78
+ """
79
+ Build and return the CRNN model.
80
+
81
+ Args:
82
+ img_h, img_w, img_c : Input image dimensions (height, width, channels).
83
+ num_classes : Total classes including CTC blank (63).
84
+ rnn_units : Hidden units per direction in each BiRNN layer.
85
+ num_rnn_layers : Number of stacked BiRNN layers (1 or 2).
86
+ rnn_type : 'lstm' (default, slightly better) or 'gru' (faster).
87
+ dropout : Dropout rate applied after each RNN layer.
88
+
89
+ Returns:
90
+ keras.Model inputs=(B, H, W, C) outputs=(B, T, num_classes)
91
+ The output is RAW LOGITS β€” no softmax, no activation.
92
+ Pass directly to ctc_loss() during training.
93
+ Pass through tf.nn.softmax then ctc_decode() during inference.
94
+ """
95
+ inputs = keras.Input(shape=(img_h, img_w, img_c), name="image")
96
+
97
+ # ── CNN backbone ───────────────────────────────────────────────────────────
98
+ # Block 1: H 64β†’32, W 200β†’100, channels 3β†’64
99
+ x = _conv_block(inputs, 64, pool=(2, 2), name="block1")
100
+ # Block 2: H 32β†’16, W 100β†’50, channels 64β†’128
101
+ x = _conv_block(x, 128, pool=(2, 2), name="block2")
102
+ # Block 3: H 16β†’8, W 50 (unchanged), double conv, channels 128β†’256
103
+ x = _conv_block(x, 256, pool=(2, 1), double_conv=True, name="block3")
104
+ # Block 4: H 8β†’4, W 50 (unchanged), channels 256β†’512
105
+ x = _conv_block(x, 512, pool=(2, 1), name="block4")
106
+ # Block 5: H 4β†’2, W 50 (unchanged), channels 512β†’512
107
+ x = _conv_block(x, 512, pool=(2, 1), name="block5")
108
+ # Block 6: H 2β†’1, W 50 (unchanged), channels 512β†’512
109
+ x = _conv_block(x, 512, pool=(2, 1), name="block6")
110
+ # x shape: (B, 1, 50, 512)
111
+
112
+ # ── Height squeeze β†’ sequence ──────────────────────────────────────────────
113
+ # Remove the height=1 axis β†’ (B, 50, 512)
114
+ x = keras.layers.Lambda(lambda t: tf.squeeze(t, axis=1), name="squeeze")(x)
115
+
116
+ # ── Bidirectional RNN ──────────────────────────────────────────────────────
117
+ RNNCell = keras.layers.LSTM if rnn_type.lower() == "lstm" else keras.layers.GRU
118
+
119
+ for i in range(num_rnn_layers):
120
+ return_seq = True # always True β€” we need per-timestep output
121
+ x = keras.layers.Bidirectional(
122
+ RNNCell(rnn_units, return_sequences=return_seq,
123
+ dropout=dropout, name=f"rnn{i + 1}"),
124
+ merge_mode="concat",
125
+ name=f"birnn{i + 1}",
126
+ )(x)
127
+ # x shape: (B, 50, rnn_units*2)
128
+
129
+ # ── Output projection ──────────────────────────────────────────────────────
130
+ # Raw logits β€” shape (B, T=50, num_classes=63)
131
+ # DO NOT apply softmax here; tf.nn.ctc_loss expects unnormalised log-probs.
132
+ logits = keras.layers.Dense(num_classes, name="logits")(x)
133
+
134
+ model = keras.Model(inputs=inputs, outputs=logits, name="CRNN_CTC")
135
+ return model
136
+
137
+
138
+ # ── CTC loss ──────────────────────────────────────────────────────────────────
139
+
140
+ def ctc_loss(
141
+ logits: tf.Tensor,
142
+ labels: tf.Tensor,
143
+ label_lengths: tf.Tensor,
144
+ logit_lengths: tf.Tensor | None = None,
145
+ ) -> tf.Tensor:
146
+ """
147
+ Compute mean CTC loss over a batch.
148
+
149
+ Args:
150
+ logits : (B, T, C) raw logits from the model.
151
+ labels : (B, pad_len) int32 padded label indices (-1 = padding).
152
+ label_lengths : (B,) int32 true length of each label sequence.
153
+ logit_lengths : (B,) int32 length of each logit sequence.
154
+ Defaults to T (full width) for every sample.
155
+
156
+ Returns:
157
+ Scalar tensor β€” mean CTC loss over the batch.
158
+ """
159
+ batch_size = tf.shape(logits)[0]
160
+ time_steps = tf.shape(logits)[1]
161
+
162
+ if logit_lengths is None:
163
+ logit_lengths = tf.fill([batch_size], time_steps)
164
+
165
+ # tf.nn.ctc_loss expects labels without padding tokens.
166
+ # We pass the dense padded tensor + label_lengths; TF handles the masking.
167
+ loss = tf.nn.ctc_loss(
168
+ labels=tf.cast(labels, tf.int32),
169
+ logits=logits,
170
+ label_length=tf.cast(label_lengths, tf.int32),
171
+ logit_length=tf.cast(logit_lengths, tf.int32),
172
+ logits_time_major=False, # logits is (B, T, C)
173
+ blank_index=BLANK_IDX, # 62 = last class
174
+ )
175
+ return tf.reduce_mean(loss)
176
+
177
+
178
+ # ── CTC decode ────────────────────────────────────────────────────────────────
179
+
180
+ def ctc_decode(
181
+ logits: tf.Tensor,
182
+ logit_lengths: tf.Tensor | None = None,
183
+ method: str = "greedy",
184
+ beam_width: int = 5,
185
+ ) -> list[str]:
186
+ """
187
+ Decode model logits into text strings.
188
+
189
+ Args:
190
+ logits : (B, T, C) raw logits (no softmax needed β€” applied here).
191
+ logit_lengths : (B,) int32. Defaults to T for all samples.
192
+ method : 'greedy' (fast) or 'beam' (slightly more accurate).
193
+ beam_width : Beam width when method='beam'.
194
+
195
+ Returns:
196
+ List of decoded text strings, length = B.
197
+ """
198
+ batch_size = tf.shape(logits)[0]
199
+ time_steps = tf.shape(logits)[1]
200
+
201
+ if logit_lengths is None:
202
+ logit_lengths = tf.fill([batch_size], time_steps)
203
+
204
+ # Apply softmax and convert to log-probs for the CTC decoder
205
+ log_probs = tf.nn.log_softmax(logits, axis=-1)
206
+
207
+ # TF CTC decoders expect (T, B, C) β€” time-major
208
+ log_probs_tm = tf.transpose(log_probs, perm=[1, 0, 2])
209
+
210
+ seq_len = tf.cast(logit_lengths, tf.int32)
211
+
212
+ if method == "beam":
213
+ decoded, _ = tf.nn.ctc_beam_search_decoder(
214
+ log_probs_tm,
215
+ sequence_length=seq_len,
216
+ beam_width=beam_width,
217
+ top_paths=1,
218
+ )
219
+ sparse = decoded[0]
220
+ else:
221
+ decoded, _ = tf.nn.ctc_greedy_decoder(
222
+ log_probs_tm,
223
+ sequence_length=seq_len,
224
+ merge_repeated=True,
225
+ )
226
+ sparse = decoded[0]
227
+
228
+ # Convert SparseTensor to dense padded with -1
229
+ dense = tf.sparse.to_dense(sparse, default_value=-1).numpy()
230
+
231
+ return [decode_label(row) for row in dense]
232
+
233
+
234
+ # ── Accuracy metrics ──────────────���───────────────────────────────────────────
235
+
236
+ def character_accuracy(preds: list[str], targets: list[str]) -> float:
237
+ """
238
+ Character-level accuracy: fraction of correctly predicted characters
239
+ across all samples (aligned by position, length-padded).
240
+ """
241
+ total = correct = 0
242
+ for pred, tgt in zip(preds, targets):
243
+ for p, t in zip(pred.ljust(len(tgt)), tgt):
244
+ total += 1
245
+ if p == t:
246
+ correct += 1
247
+ return correct / total if total else 0.0
248
+
249
+
250
+ def sequence_accuracy(preds: list[str], targets: list[str]) -> float:
251
+ """
252
+ Sequence-level accuracy: fraction of samples where the entire
253
+ predicted string exactly matches the ground truth.
254
+ """
255
+ if not preds:
256
+ return 0.0
257
+ return sum(p == t for p, t in zip(preds, targets)) / len(preds)
258
+
259
+
260
+ # ── Summary ───────────────────────────────────────────────────────────────────
261
+
262
+ def model_summary(model: keras.Model) -> None:
263
+ """Print a clean model summary with parameter count."""
264
+ model.summary(line_length=80)
265
+ total = model.count_params()
266
+ trainable = sum(
267
+ int(tf.reduce_prod(v.shape)) for v in model.trainable_variables
268
+ )
269
+ print(f"\n Total params : {total:,}")
270
+ print(f" Trainable params : {trainable:,}")
271
+ print(f" Input shape : {model.input_shape}")
272
+ print(f" Output shape : {model.output_shape} (B, T, num_classes)")
273
+ print(f" T (time steps) : {model.output_shape[1]}")
274
+ print(f" num_classes : {model.output_shape[2]} "
275
+ f"(chars 0-{NUM_CLASSES-2} + blank={BLANK_IDX})\n")
276
+
277
+
278
+ # ── Quick build test ──────────────────────────────────────────────────────────
279
+
280
+ if __name__ == "__main__":
281
+ print("Building CRNN model...")
282
+ m = build_crnn_model()
283
+ model_summary(m)
284
+
285
+ # Forward pass smoke test
286
+ dummy = tf.zeros((2, IMG_H, IMG_W, IMG_C))
287
+ out = m(dummy, training=False)
288
+ print(f"Smoke test β€” input {dummy.shape} β†’ output {out.shape}")
289
+ assert out.shape == (2, 50, NUM_CLASSES), f"Unexpected output shape: {out.shape}"
290
+ print("All checks passed.")
preview_all_types.png ADDED

Git LFS Details

  • SHA256: 2c2c231ee6eea1ca89b9a7d5fbcb3b5840357d627e310841c831805e07e43887
  • Pointer size: 131 Bytes
  • Size of remote file: 141 kB
weekend_best.weights.h5 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fcc00334e74b4b1ae5ad2b3bc0b29283056149e743cae8f7d528cc60e9d31f34
3
+ size 40301680