| """ |
| Train sequence CNN: full 8-char ROI image -> "АВ123456". |
| |
| No segmentation -- the model sees the whole strip and outputs 8 characters. |
| Positional constraint baked into loss: positions 0-1 = letters, 2-7 = digits. |
| |
| Input: data/sequences/*.png + data/sequences/labels.json |
| Output: model_sequence.pth |
| |
| Usage: |
| python train_sequence.py |
| python train_sequence.py --epochs 40 --batch-size 64 |
| """ |
|
|
| import argparse |
| import json |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.optim as optim |
| from PIL import Image |
| from torch.utils.data import Dataset, DataLoader, random_split |
|
|
|
|
| |
|
|
| DIGITS = list("0123456789") |
| SERIES_LETTERS = list("АВЕКМНОРСТИЮ") |
| ALL_CHARS = DIGITS + SERIES_LETTERS |
|
|
| CHAR2IDX = {c: i for i, c in enumerate(ALL_CHARS)} |
| IDX2CHAR = {i: c for c, i in CHAR2IDX.items()} |
|
|
| LETTER_IDX = [CHAR2IDX[c] for c in SERIES_LETTERS] |
| DIGIT_IDX = [CHAR2IDX[c] for c in DIGITS] |
|
|
| N_CHARS = 8 |
| SEQ_H = 48 |
| SEQ_W = 256 |
|
|
| DATA_DIR = Path("data/sequences") |
| MODEL_PATH = Path("model_sequence.pth") |
|
|
|
|
| |
|
|
| class SequenceDataset(Dataset): |
| def __init__(self, data_dir: Path): |
| labels_path = data_dir / "labels.json" |
| if not labels_path.exists(): |
| raise FileNotFoundError(f"Labels not found: {labels_path}") |
| raw = json.loads(labels_path.read_text(encoding="utf-8")) |
|
|
| self.samples = [] |
| for fname, seq in raw.items(): |
| p = data_dir / fname |
| if p.exists() and len(seq) == N_CHARS: |
| label = [CHAR2IDX[c] for c in seq if c in CHAR2IDX] |
| if len(label) == N_CHARS: |
| self.samples.append((p, label)) |
|
|
| if not self.samples: |
| raise RuntimeError(f"No valid samples in {data_dir}") |
|
|
| def __len__(self): |
| return len(self.samples) |
|
|
| def __getitem__(self, i): |
| path, label = self.samples[i] |
| img = Image.open(path).convert("L") |
| arr = np.array(img, dtype=np.float32) / 255.0 |
| |
| x = torch.tensor(arr).unsqueeze(0) |
| y = torch.tensor(label, dtype=torch.long) |
| return x, y |
|
|
|
|
| |
|
|
| class SequenceCNN(nn.Module): |
| """ |
| CRNN: CNN backbone -> AdaptiveAvgPool2d((1,24)) -> biGRU -> 8 position heads. |
| |
| 24 horizontal slots (~3 per character) let the GRU learn character boundaries |
| instead of assuming perfectly equal spacing. |
| |
| Input: (B, 1, 48, 256) |
| Output: (B, 8, n_classes) |
| """ |
| RNN_SLOTS = 24 |
|
|
| def __init__(self, n_classes: int = len(ALL_CHARS)): |
| super().__init__() |
| self.features = nn.Sequential( |
| |
| nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), |
| nn.MaxPool2d(2), |
| |
| nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), |
| nn.MaxPool2d(2), |
| |
| nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), |
| nn.MaxPool2d(2), |
| |
| nn.Conv2d(128, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), |
| ) |
| |
| self.pool = nn.AdaptiveAvgPool2d((1, self.RNN_SLOTS)) |
|
|
| |
| |
| self.rnn = nn.GRU(128, 128, num_layers=1, batch_first=True, |
| bidirectional=True) |
|
|
| |
| |
| self.char_slots = [1, 4, 7, 10, 13, 16, 19, 22] |
|
|
| |
| self.heads = nn.ModuleList([ |
| nn.Sequential( |
| nn.Linear(256, 64), nn.ReLU(), nn.Dropout(0.3), |
| nn.Linear(64, n_classes), |
| ) |
| for _ in range(N_CHARS) |
| ]) |
|
|
| def forward(self, x): |
| feat = self.features(x) |
| feat = self.pool(feat).squeeze(2) |
| feat = feat.permute(0, 2, 1) |
| rnn_out, _ = self.rnn(feat) |
| |
| logits = [self.heads[i](rnn_out[:, self.char_slots[i], :]) |
| for i in range(N_CHARS)] |
| return torch.stack(logits, dim=1) |
|
|
|
|
| |
|
|
| def set_inference_mode(model, flag: bool): |
| for module in model.modules(): |
| if hasattr(module, "training"): |
| module.training = not flag |
|
|
|
|
| |
|
|
| def masked_ce_loss(logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor: |
| """ |
| Cross-entropy with positional masking: |
| pos 0-1 -> letters only |
| pos 2-7 -> digits only |
| |
| logits: (B, 8, n_classes) |
| targets: (B, 8) |
| """ |
| n_classes = logits.size(-1) |
| mask = torch.full((N_CHARS, n_classes), float("-inf"), device=logits.device) |
| for pos in range(N_CHARS): |
| for idx in (LETTER_IDX if pos < 2 else DIGIT_IDX): |
| mask[pos, idx] = 0.0 |
|
|
| masked = logits + mask.unsqueeze(0) |
| B = logits.size(0) |
| return nn.functional.cross_entropy( |
| masked.view(B * N_CHARS, n_classes), |
| targets.view(B * N_CHARS), |
| ) |
|
|
|
|
| |
|
|
| def train(epochs: int = 35, batch_size: int = 64, lr: float = 5e-4, |
| val_split: float = 0.05): |
|
|
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| print(f"Device: {device}") |
|
|
| dataset = SequenceDataset(DATA_DIR) |
| print(f"Dataset: {len(dataset)} sequences") |
|
|
| n_val = max(1, int(len(dataset) * val_split)) |
| n_train = len(dataset) - n_val |
| train_ds, val_ds = random_split( |
| dataset, [n_train, n_val], |
| generator=torch.Generator().manual_seed(42)) |
|
|
| train_loader = DataLoader(train_ds, batch_size=batch_size, |
| shuffle=True, num_workers=0, pin_memory=True) |
| val_loader = DataLoader(val_ds, batch_size=batch_size, |
| shuffle=False, num_workers=0) |
|
|
| model = SequenceCNN().to(device) |
| optimizer = optim.Adam(model.parameters(), lr=lr) |
| scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) |
|
|
| best_val_acc = 0.0 |
|
|
| print(f"{'Epoch':>5} {'Loss':>8} {'Train':>7} {'Val':>7} {'Full':>7}") |
| print("-" * 44) |
|
|
| for epoch in range(1, epochs + 1): |
| |
| set_inference_mode(model, False) |
| total_loss = correct_chars = total_chars = 0 |
|
|
| for x, y in train_loader: |
| x, y = x.to(device), y.to(device) |
| optimizer.zero_grad() |
| logits = model(x) |
| loss = masked_ce_loss(logits, y) |
| loss.backward() |
| optimizer.step() |
|
|
| total_loss += loss.item() * x.size(0) |
| preds = logits.argmax(-1) |
| correct_chars += (preds == y).sum().item() |
| total_chars += y.numel() |
|
|
| train_acc = correct_chars / total_chars |
|
|
| |
| set_inference_mode(model, True) |
| v_chars = v_total = v_seqs = v_total_seqs = 0 |
| with torch.no_grad(): |
| for x, y in val_loader: |
| x, y = x.to(device), y.to(device) |
| logits = model(x) |
| preds = logits.argmax(-1) |
| v_chars += (preds == y).sum().item() |
| v_total += y.numel() |
| v_seqs += (preds == y).all(dim=1).sum().item() |
| v_total_seqs += x.size(0) |
|
|
| val_char_acc = v_chars / v_total |
| val_seq_acc = v_seqs / v_total_seqs |
| scheduler.step() |
|
|
| marker = " <- best" if val_char_acc > best_val_acc else "" |
| print(f"{epoch:>5} {total_loss/n_train:>8.4f} " |
| f"{train_acc:>6.1%} {val_char_acc:>6.1%} " |
| f"{val_seq_acc:>6.1%}{marker}") |
|
|
| if val_char_acc > best_val_acc: |
| best_val_acc = val_char_acc |
| torch.save({ |
| "model_state": model.state_dict(), |
| "all_chars": ALL_CHARS, |
| "char2idx": CHAR2IDX, |
| "idx2char": IDX2CHAR, |
| "n_chars": N_CHARS, |
| "seq_h": SEQ_H, |
| "seq_w": SEQ_W, |
| "val_char_acc": val_char_acc, |
| "val_seq_acc": val_seq_acc, |
| }, MODEL_PATH) |
|
|
| print(f"\nBest val char accuracy: {best_val_acc:.1%}") |
| print(f"Model saved: {MODEL_PATH}") |
|
|
|
|
| if __name__ == "__main__": |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--epochs", type=int, default=35) |
| ap.add_argument("--batch-size", type=int, default=64) |
| ap.add_argument("--lr", type=float, default=5e-4) |
| args = ap.parse_args() |
| train(epochs=args.epochs, batch_size=args.batch_size, lr=args.lr) |
|
|