| """Dataset loading and augmentation for Tibetan character training data.
|
|
|
| Loads existing Namsel training data (font-drawn + manually labeled samples)
|
| and provides PyTorch Dataset with CPU-friendly augmentation.
|
|
|
| Training data format: each sample is (label, pixel_0, pixel_1, ..., pixel_1023)
|
| where pixels form a 32x32 grayscale character image.
|
| """
|
|
|
| import glob
|
| import os
|
| import pickle
|
|
|
| import numpy as np
|
| import torch
|
| from torch.utils.data import Dataset, DataLoader
|
| from scipy.ndimage import rotate, shift, zoom, gaussian_filter, map_coordinates
|
|
|
|
|
| def load_all_training_data(data_dir):
|
| """Load all Namsel training datasets from the datasets directory.
|
|
|
| Args:
|
| data_dir: path to namsel_ocr/datasets/
|
|
|
| Returns:
|
| images: ndarray (N, 32, 32) uint8
|
| labels: ndarray (N,) int
|
| """
|
| all_data = []
|
|
|
|
|
| font_draw = os.path.join(data_dir, 'font-draw-samples.txt')
|
| if os.path.exists(font_draw):
|
| data = np.genfromtxt(font_draw, np.uint32, delimiter=',')
|
| all_data.append(data)
|
| print(f" font-draw-samples: {data.shape[0]} samples")
|
|
|
|
|
| tibchar = os.path.join(data_dir, 'tibcharsamples.txt')
|
| if os.path.exists(tibchar):
|
| data = np.genfromtxt(tibchar, np.uint32, delimiter=',')
|
| all_data.append(data)
|
| print(f" tibcharsamples: {data.shape[0]} samples")
|
|
|
|
|
| ui_samples = os.path.join(data_dir, 'ui_samples.csv')
|
| if os.path.exists(ui_samples):
|
| data = np.genfromtxt(ui_samples, np.uint32, delimiter=',')
|
| all_data.append(data)
|
| print(f" ui_samples: {data.shape[0]} samples")
|
|
|
|
|
| norm_npy = os.path.join(data_dir, 'normalized_3216_to_3232_training.npy')
|
| if os.path.exists(norm_npy):
|
| data = np.load(norm_npy)
|
| all_data.append(data)
|
| print(f" normalized_3216_to_3232: {data.shape[0]} samples")
|
|
|
|
|
| symbols = os.path.join(data_dir, 'symbols.txt')
|
| if os.path.exists(symbols):
|
| data = np.genfromtxt(symbols, np.uint32, delimiter=',')
|
| if data.ndim == 1:
|
| data = data.reshape(1, -1)
|
| all_data.append(data)
|
| print(f" symbols: {data.shape[0]} samples")
|
|
|
|
|
| pkl_count = 0
|
| for pkl_file in sorted(glob.glob(os.path.join(data_dir, '*.pkl'))):
|
| try:
|
| with open(pkl_file, 'rb') as f:
|
| data = pickle.load(f)
|
| data = np.array(data)
|
| if data.ndim == 2 and data.shape[1] == 1025:
|
| all_data.append(data)
|
| pkl_count += data.shape[0]
|
| except Exception:
|
| try:
|
| with open(pkl_file, 'rb') as f:
|
| data = pickle.load(f, encoding='latin-1')
|
| data = np.array(data)
|
| if data.ndim == 2 and data.shape[1] == 1025:
|
| all_data.append(data)
|
| pkl_count += data.shape[0]
|
| except Exception:
|
| pass
|
| if pkl_count > 0:
|
| print(f" pkl files: {pkl_count} samples")
|
|
|
| if not all_data:
|
| raise FileNotFoundError(f"No training data found in {data_dir}")
|
|
|
|
|
| combined = np.concatenate(all_data, axis=0)
|
|
|
|
|
| combined = np.unique(combined, axis=0)
|
| print(f" Total after dedup: {combined.shape[0]} samples")
|
|
|
| labels = combined[:, 0].astype(np.int64)
|
| images = combined[:, 1:].astype(np.uint8).reshape(-1, 32, 32)
|
|
|
| return images, labels
|
|
|
|
|
| def build_label_mapping(labels):
|
| """Create contiguous 0-indexed label mapping for PyTorch.
|
|
|
| Args:
|
| labels: array of original Namsel label IDs (non-contiguous integers)
|
|
|
| Returns:
|
| label_to_idx: dict mapping original_label -> contiguous_index
|
| idx_to_label: dict mapping contiguous_index -> original_label
|
| """
|
| unique_labels = sorted(set(labels.tolist()))
|
| label_to_idx = {lbl: idx for idx, lbl in enumerate(unique_labels)}
|
| idx_to_label = {idx: lbl for idx, lbl in enumerate(unique_labels)}
|
| return label_to_idx, idx_to_label
|
|
|
|
|
| def compute_class_weights(labels, label_to_idx, method='inverse_sqrt'):
|
| """Compute per-class weights for imbalanced training data.
|
|
|
| Args:
|
| labels: array of original Namsel label IDs
|
| label_to_idx: mapping from original label to contiguous index
|
| method: 'inverse' (1/count), 'inverse_sqrt' (1/sqrt(count)),
|
| or 'effective' (effective number of samples)
|
|
|
| Returns:
|
| torch.FloatTensor of shape (num_classes,) with per-class weights,
|
| normalized so mean weight = 1.0
|
| """
|
| num_classes = len(label_to_idx)
|
| counts = np.zeros(num_classes, dtype=np.float64)
|
|
|
| for lbl in labels:
|
| idx = label_to_idx[int(lbl)]
|
| counts[idx] += 1
|
|
|
|
|
| counts = np.maximum(counts, 1.0)
|
|
|
| if method == 'inverse':
|
| weights = 1.0 / counts
|
| elif method == 'inverse_sqrt':
|
| weights = 1.0 / np.sqrt(counts)
|
| elif method == 'effective':
|
|
|
| beta = 0.999
|
| weights = (1.0 - beta) / (1.0 - beta ** counts)
|
| else:
|
| raise ValueError(f"Unknown method: {method}")
|
|
|
|
|
| weights = weights / weights.mean()
|
|
|
| return torch.FloatTensor(weights)
|
|
|
|
|
|
|
|
|
| def random_rotation(img, max_angle=5.0):
|
| """Rotate image by a random angle within [-max_angle, max_angle]."""
|
| angle = np.random.uniform(-max_angle, max_angle)
|
|
|
| rotated = rotate(img, angle, reshape=False, order=1, cval=1.0)
|
| return rotated
|
|
|
|
|
| def random_shift(img, max_pixels=2):
|
| """Translate image by random offset in x and y."""
|
| dx = np.random.uniform(-max_pixels, max_pixels)
|
| dy = np.random.uniform(-max_pixels, max_pixels)
|
| shifted = shift(img, [dy, dx], order=1, cval=1.0)
|
| return shifted
|
|
|
|
|
| def random_scale(img, scale_range=(0.9, 1.1)):
|
| """Scale image by a random factor, keeping 32x32 output."""
|
| factor = np.random.uniform(*scale_range)
|
| h, w = img.shape
|
| scaled = zoom(img, factor, order=1, cval=1.0)
|
| sh, sw = scaled.shape
|
|
|
|
|
| result = np.ones((h, w), dtype=img.dtype)
|
| y_off = (sh - h) // 2
|
| x_off = (sw - w) // 2
|
|
|
| if factor >= 1.0:
|
|
|
| result = scaled[y_off:y_off + h, x_off:x_off + w]
|
| else:
|
|
|
| result = np.ones((h, w), dtype=img.dtype)
|
| py = (h - sh) // 2
|
| px = (w - sw) // 2
|
| result[py:py + sh, px:px + sw] = scaled
|
|
|
| return result[:h, :w]
|
|
|
|
|
| def elastic_deformation(img, alpha=3.0, sigma=0.5):
|
| """Apply elastic deformation to simulate print artifacts.
|
|
|
| alpha: deformation intensity
|
| sigma: smoothness of deformation field
|
| """
|
| shape = img.shape
|
| dx = gaussian_filter(np.random.randn(*shape), sigma) * alpha
|
| dy = gaussian_filter(np.random.randn(*shape), sigma) * alpha
|
|
|
| y, x = np.mgrid[0:shape[0], 0:shape[1]]
|
| indices = [np.clip(y + dy, 0, shape[0] - 1),
|
| np.clip(x + dx, 0, shape[1] - 1)]
|
|
|
| return map_coordinates(img, indices, order=1, cval=1.0)
|
|
|
|
|
| def morphological_noise(img, prob=0.3):
|
| """Randomly erode or dilate to simulate ink thickness variation."""
|
| if np.random.random() > prob:
|
| return img
|
|
|
| from scipy.ndimage import binary_erosion, binary_dilation
|
|
|
|
|
| binary = img < 0.5
|
| kernel = np.ones((2, 2), dtype=bool)
|
|
|
| if np.random.random() > 0.5:
|
| result = binary_dilation(binary, structure=kernel)
|
| else:
|
| result = binary_erosion(binary, structure=kernel)
|
|
|
|
|
| out = np.ones_like(img)
|
| out[result] = 0.0
|
| return out
|
|
|
|
|
| def gaussian_noise(img, sigma=0.03):
|
| """Add small Gaussian noise."""
|
| noise = np.random.normal(0, sigma, img.shape)
|
| noisy = np.clip(img + noise, 0.0, 1.0)
|
| return noisy
|
|
|
|
|
| def augment_image(img):
|
| """Apply random augmentations to a 32x32 character image.
|
|
|
| Args:
|
| img: ndarray (32, 32) float in [0, 1] range
|
|
|
| Returns:
|
| Augmented image, same shape and range
|
| """
|
|
|
| if np.random.random() > 0.3:
|
| img = random_rotation(img, max_angle=5.0)
|
|
|
| if np.random.random() > 0.4:
|
| img = random_shift(img, max_pixels=2)
|
|
|
| if np.random.random() > 0.5:
|
| img = random_scale(img, scale_range=(0.9, 1.1))
|
|
|
| if np.random.random() > 0.5:
|
| img = elastic_deformation(img, alpha=2.0, sigma=0.4)
|
|
|
| img = morphological_noise(img, prob=0.2)
|
|
|
| if np.random.random() > 0.6:
|
| img = gaussian_noise(img, sigma=0.02)
|
|
|
| return np.clip(img, 0.0, 1.0).astype(np.float32)
|
|
|
|
|
| class TibetanCharDataset(Dataset):
|
| """PyTorch Dataset for Tibetan character images.
|
|
|
| Loads 32x32 character images with their labels, applies
|
| optional augmentation during training.
|
| """
|
|
|
| def __init__(self, images, labels, label_to_idx, augment=False):
|
| """
|
| Args:
|
| images: ndarray (N, 32, 32) uint8
|
| labels: ndarray (N,) original Namsel label IDs
|
| label_to_idx: dict mapping original label -> contiguous index
|
| augment: whether to apply random augmentation
|
| """
|
| self.images = images
|
| self.labels = labels
|
| self.label_to_idx = label_to_idx
|
| self.augment = augment
|
|
|
| def __len__(self):
|
| return len(self.labels)
|
|
|
| def __getitem__(self, idx):
|
| img = self.images[idx].astype(np.float32)
|
|
|
|
|
| if img.max() > 1.0:
|
| img = img / 255.0
|
|
|
| if self.augment:
|
| img = augment_image(img)
|
|
|
|
|
| tensor = torch.from_numpy(img).unsqueeze(0)
|
|
|
|
|
| label = self.label_to_idx[int(self.labels[idx])]
|
|
|
| return tensor, label
|
|
|
|
|
| def create_data_loaders(data_dir, batch_size=64, val_split=0.15,
|
| augment=True, num_workers=0, pin_memory=False,
|
| seed=42):
|
| """Create train and validation DataLoaders from Namsel training data.
|
|
|
| Args:
|
| data_dir: path to namsel_ocr/datasets/
|
| batch_size: mini-batch size (64 recommended for CPU)
|
| val_split: fraction of data for validation
|
| augment: apply augmentation to training set
|
| num_workers: dataloader workers (0 for Windows, 2+ for Linux/Colab)
|
| pin_memory: True for GPU training (faster CPU->GPU transfer)
|
| seed: random seed for reproducible split
|
|
|
| Returns:
|
| train_loader, val_loader, label_to_idx, idx_to_label, num_classes,
|
| class_weights (FloatTensor)
|
| """
|
| print("Loading training data...")
|
| images, labels = load_all_training_data(data_dir)
|
|
|
| label_to_idx, idx_to_label = build_label_mapping(labels)
|
| num_classes = len(label_to_idx)
|
| print(f" Classes: {num_classes}")
|
|
|
|
|
| class_weights = compute_class_weights(labels, label_to_idx)
|
| min_w, max_w = class_weights.min().item(), class_weights.max().item()
|
| print(f" Class weight range: {min_w:.2f} - {max_w:.2f}")
|
|
|
|
|
| rng = np.random.RandomState(seed)
|
| indices = rng.permutation(len(labels))
|
| val_size = int(len(labels) * val_split)
|
| val_indices = indices[:val_size]
|
| train_indices = indices[val_size:]
|
|
|
| train_dataset = TibetanCharDataset(
|
| images[train_indices], labels[train_indices],
|
| label_to_idx, augment=augment
|
| )
|
| val_dataset = TibetanCharDataset(
|
| images[val_indices], labels[val_indices],
|
| label_to_idx, augment=False
|
| )
|
|
|
| persistent = num_workers > 0
|
| train_loader = DataLoader(
|
| train_dataset, batch_size=batch_size, shuffle=True,
|
| num_workers=num_workers, pin_memory=pin_memory,
|
| persistent_workers=persistent,
|
| )
|
| val_loader = DataLoader(
|
| val_dataset, batch_size=batch_size, shuffle=False,
|
| num_workers=num_workers, pin_memory=pin_memory,
|
| persistent_workers=persistent,
|
| )
|
|
|
| print(f" Train: {len(train_dataset)}, Val: {len(val_dataset)}")
|
| return train_loader, val_loader, label_to_idx, idx_to_label, num_classes, class_weights
|
|
|