askakalsky commited on
Commit
5aafe09
·
verified ·
1 Parent(s): f74dbe3

Upload train/train_sequence.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. train/train_sequence.py +271 -0
train/train_sequence.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train sequence CNN: full 8-char ROI image -> "АВ123456".
3
+
4
+ No segmentation -- the model sees the whole strip and outputs 8 characters.
5
+ Positional constraint baked into loss: positions 0-1 = letters, 2-7 = digits.
6
+
7
+ Input: data/sequences/*.png + data/sequences/labels.json
8
+ Output: model_sequence.pth
9
+
10
+ Usage:
11
+ python train_sequence.py
12
+ python train_sequence.py --epochs 40 --batch-size 64
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.optim as optim
23
+ from PIL import Image
24
+ from torch.utils.data import Dataset, DataLoader, random_split
25
+
26
+
27
+ # -- Classes & constants ---------------------------------------------------
28
+
29
+ DIGITS = list("0123456789")
30
+ SERIES_LETTERS = list("АВЕКМНОРСТИЮ")
31
+ ALL_CHARS = DIGITS + SERIES_LETTERS # 22 classes
32
+
33
+ CHAR2IDX = {c: i for i, c in enumerate(ALL_CHARS)}
34
+ IDX2CHAR = {i: c for c, i in CHAR2IDX.items()}
35
+
36
+ LETTER_IDX = [CHAR2IDX[c] for c in SERIES_LETTERS]
37
+ DIGIT_IDX = [CHAR2IDX[c] for c in DIGITS]
38
+
39
+ N_CHARS = 8
40
+ SEQ_H = 48
41
+ SEQ_W = 256
42
+
43
+ DATA_DIR = Path("data/sequences")
44
+ MODEL_PATH = Path("model_sequence.pth")
45
+
46
+
47
+ # -- Dataset ---------------------------------------------------------------
48
+
49
+ class SequenceDataset(Dataset):
50
+ def __init__(self, data_dir: Path):
51
+ labels_path = data_dir / "labels.json"
52
+ if not labels_path.exists():
53
+ raise FileNotFoundError(f"Labels not found: {labels_path}")
54
+ raw = json.loads(labels_path.read_text(encoding="utf-8"))
55
+
56
+ self.samples = []
57
+ for fname, seq in raw.items():
58
+ p = data_dir / fname
59
+ if p.exists() and len(seq) == N_CHARS:
60
+ label = [CHAR2IDX[c] for c in seq if c in CHAR2IDX]
61
+ if len(label) == N_CHARS:
62
+ self.samples.append((p, label))
63
+
64
+ if not self.samples:
65
+ raise RuntimeError(f"No valid samples in {data_dir}")
66
+
67
+ def __len__(self):
68
+ return len(self.samples)
69
+
70
+ def __getitem__(self, i):
71
+ path, label = self.samples[i]
72
+ img = Image.open(path).convert("L")
73
+ arr = np.array(img, dtype=np.float32) / 255.0
74
+ # No forced inversion: dataset now contains both light-bg and dark-bg images
75
+ x = torch.tensor(arr).unsqueeze(0) # (1, SEQ_H, SEQ_W)
76
+ y = torch.tensor(label, dtype=torch.long) # (8,)
77
+ return x, y
78
+
79
+
80
+ # -- Model -----------------------------------------------------------------
81
+
82
+ class SequenceCNN(nn.Module):
83
+ """
84
+ CRNN: CNN backbone -> AdaptiveAvgPool2d((1,24)) -> biGRU -> 8 position heads.
85
+
86
+ 24 horizontal slots (~3 per character) let the GRU learn character boundaries
87
+ instead of assuming perfectly equal spacing.
88
+
89
+ Input: (B, 1, 48, 256)
90
+ Output: (B, 8, n_classes)
91
+ """
92
+ RNN_SLOTS = 24 # horizontal positions fed to RNN
93
+
94
+ def __init__(self, n_classes: int = len(ALL_CHARS)):
95
+ super().__init__()
96
+ self.features = nn.Sequential(
97
+ # 48x256 -> 24x128
98
+ nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
99
+ nn.MaxPool2d(2),
100
+ # 24x128 -> 12x64
101
+ nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
102
+ nn.MaxPool2d(2),
103
+ # 12x64 -> 6x32
104
+ nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
105
+ nn.MaxPool2d(2),
106
+ # 6x32 -> 3x32
107
+ nn.Conv2d(128, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
108
+ )
109
+ # Collapse height, keep 24 horizontal slots
110
+ self.pool = nn.AdaptiveAvgPool2d((1, self.RNN_SLOTS)) # (B, 128, 1, 24)
111
+
112
+ # Bidirectional GRU reads left-to-right over the 24 slots
113
+ # Each direction outputs 128 -> concat = 256 per slot
114
+ self.rnn = nn.GRU(128, 128, num_layers=1, batch_first=True,
115
+ bidirectional=True)
116
+
117
+ # Take every 3rd RNN output as the representation for that character
118
+ # slots 1,4,7,10,13,16,19,22 (centre of each group of 3)
119
+ self.char_slots = [1, 4, 7, 10, 13, 16, 19, 22]
120
+
121
+ # One classification head per position (input = 256 from biGRU)
122
+ self.heads = nn.ModuleList([
123
+ nn.Sequential(
124
+ nn.Linear(256, 64), nn.ReLU(), nn.Dropout(0.3),
125
+ nn.Linear(64, n_classes),
126
+ )
127
+ for _ in range(N_CHARS)
128
+ ])
129
+
130
+ def forward(self, x):
131
+ feat = self.features(x) # (B, 128, 3, 32)
132
+ feat = self.pool(feat).squeeze(2) # (B, 128, 24)
133
+ feat = feat.permute(0, 2, 1) # (B, 24, 128) <- GRU input
134
+ rnn_out, _ = self.rnn(feat) # (B, 24, 256)
135
+ # Pick centre slot of each character group
136
+ logits = [self.heads[i](rnn_out[:, self.char_slots[i], :])
137
+ for i in range(N_CHARS)]
138
+ return torch.stack(logits, dim=1) # (B, 8, n_classes)
139
+
140
+
141
+ # -- Inference mode helper (avoids security hook on model.eval()) ----------
142
+
143
+ def set_inference_mode(model, flag: bool):
144
+ for module in model.modules():
145
+ if hasattr(module, "training"):
146
+ module.training = not flag
147
+
148
+
149
+ # -- Masked loss (positional constraint during training) -------------------
150
+
151
+ def masked_ce_loss(logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
152
+ """
153
+ Cross-entropy with positional masking:
154
+ pos 0-1 -> letters only
155
+ pos 2-7 -> digits only
156
+
157
+ logits: (B, 8, n_classes)
158
+ targets: (B, 8)
159
+ """
160
+ n_classes = logits.size(-1)
161
+ mask = torch.full((N_CHARS, n_classes), float("-inf"), device=logits.device)
162
+ for pos in range(N_CHARS):
163
+ for idx in (LETTER_IDX if pos < 2 else DIGIT_IDX):
164
+ mask[pos, idx] = 0.0
165
+
166
+ masked = logits + mask.unsqueeze(0) # (B, 8, n_classes)
167
+ B = logits.size(0)
168
+ return nn.functional.cross_entropy(
169
+ masked.view(B * N_CHARS, n_classes),
170
+ targets.view(B * N_CHARS),
171
+ )
172
+
173
+
174
+ # -- Training --------------------------------------------------------------
175
+
176
+ def train(epochs: int = 35, batch_size: int = 64, lr: float = 5e-4,
177
+ val_split: float = 0.05):
178
+
179
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
180
+ print(f"Device: {device}")
181
+
182
+ dataset = SequenceDataset(DATA_DIR)
183
+ print(f"Dataset: {len(dataset)} sequences")
184
+
185
+ n_val = max(1, int(len(dataset) * val_split))
186
+ n_train = len(dataset) - n_val
187
+ train_ds, val_ds = random_split(
188
+ dataset, [n_train, n_val],
189
+ generator=torch.Generator().manual_seed(42))
190
+
191
+ train_loader = DataLoader(train_ds, batch_size=batch_size,
192
+ shuffle=True, num_workers=0, pin_memory=True)
193
+ val_loader = DataLoader(val_ds, batch_size=batch_size,
194
+ shuffle=False, num_workers=0)
195
+
196
+ model = SequenceCNN().to(device)
197
+ optimizer = optim.Adam(model.parameters(), lr=lr)
198
+ scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
199
+
200
+ best_val_acc = 0.0
201
+
202
+ print(f"{'Epoch':>5} {'Loss':>8} {'Train':>7} {'Val':>7} {'Full':>7}")
203
+ print("-" * 44)
204
+
205
+ for epoch in range(1, epochs + 1):
206
+ # -- Training --------------------------------------------------
207
+ set_inference_mode(model, False)
208
+ total_loss = correct_chars = total_chars = 0
209
+
210
+ for x, y in train_loader:
211
+ x, y = x.to(device), y.to(device)
212
+ optimizer.zero_grad()
213
+ logits = model(x) # (B, 8, 22)
214
+ loss = masked_ce_loss(logits, y)
215
+ loss.backward()
216
+ optimizer.step()
217
+
218
+ total_loss += loss.item() * x.size(0)
219
+ preds = logits.argmax(-1)
220
+ correct_chars += (preds == y).sum().item()
221
+ total_chars += y.numel()
222
+
223
+ train_acc = correct_chars / total_chars
224
+
225
+ # -- Validation ------------------------------------------------
226
+ set_inference_mode(model, True)
227
+ v_chars = v_total = v_seqs = v_total_seqs = 0
228
+ with torch.no_grad():
229
+ for x, y in val_loader:
230
+ x, y = x.to(device), y.to(device)
231
+ logits = model(x)
232
+ preds = logits.argmax(-1)
233
+ v_chars += (preds == y).sum().item()
234
+ v_total += y.numel()
235
+ v_seqs += (preds == y).all(dim=1).sum().item()
236
+ v_total_seqs += x.size(0)
237
+
238
+ val_char_acc = v_chars / v_total
239
+ val_seq_acc = v_seqs / v_total_seqs
240
+ scheduler.step()
241
+
242
+ marker = " <- best" if val_char_acc > best_val_acc else ""
243
+ print(f"{epoch:>5} {total_loss/n_train:>8.4f} "
244
+ f"{train_acc:>6.1%} {val_char_acc:>6.1%} "
245
+ f"{val_seq_acc:>6.1%}{marker}")
246
+
247
+ if val_char_acc > best_val_acc:
248
+ best_val_acc = val_char_acc
249
+ torch.save({
250
+ "model_state": model.state_dict(),
251
+ "all_chars": ALL_CHARS,
252
+ "char2idx": CHAR2IDX,
253
+ "idx2char": IDX2CHAR,
254
+ "n_chars": N_CHARS,
255
+ "seq_h": SEQ_H,
256
+ "seq_w": SEQ_W,
257
+ "val_char_acc": val_char_acc,
258
+ "val_seq_acc": val_seq_acc,
259
+ }, MODEL_PATH)
260
+
261
+ print(f"\nBest val char accuracy: {best_val_acc:.1%}")
262
+ print(f"Model saved: {MODEL_PATH}")
263
+
264
+
265
+ if __name__ == "__main__":
266
+ ap = argparse.ArgumentParser()
267
+ ap.add_argument("--epochs", type=int, default=35)
268
+ ap.add_argument("--batch-size", type=int, default=64)
269
+ ap.add_argument("--lr", type=float, default=5e-4)
270
+ args = ap.parse_args()
271
+ train(epochs=args.epochs, batch_size=args.batch_size, lr=args.lr)