namsel_BUDA_CNN / dataset.py
trabten's picture
Initial model upload
7e6c7fa verified
Raw
History Blame Contribute Delete
13 kB
"""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 = []
# 1. font-draw-samples.txt (primary training 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")
# 2. tibcharsamples.txt
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")
# 3. ui_samples.csv (manually labeled via UI)
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")
# 4. normalized_3216_to_3232_training.npy
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")
# 5. symbols.txt
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")
# 6. All .pkl files (character-specific additions)
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}")
# Combine all datasets
combined = np.concatenate(all_data, axis=0)
# Deduplicate
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
# Avoid division by zero for empty classes
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':
# Effective number of samples (Cui et al. 2019)
beta = 0.999
weights = (1.0 - beta) / (1.0 - beta ** counts)
else:
raise ValueError(f"Unknown method: {method}")
# Normalize so mean weight = 1.0
weights = weights / weights.mean()
return torch.FloatTensor(weights)
# --- Augmentation functions (numpy/scipy, no torchvision dependency) ---
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)
# Use order=1 (bilinear) and fill with background (1.0 for white bg)
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
# Crop or pad to original size
result = np.ones((h, w), dtype=img.dtype)
y_off = (sh - h) // 2
x_off = (sw - w) // 2
if factor >= 1.0:
# Crop center
result = scaled[y_off:y_off + h, x_off:x_off + w]
else:
# Pad with background
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
# Work with binary mask (foreground = values < 0.5)
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)
# Convert back to float image
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
"""
# Each augmentation applied with some probability
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)
# Normalize to [0, 1]
if img.max() > 1.0:
img = img / 255.0
if self.augment:
img = augment_image(img)
# Convert to tensor: (1, 32, 32) with channel dim
tensor = torch.from_numpy(img).unsqueeze(0)
# Map original label to contiguous index
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}")
# Compute class weights for imbalanced data
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}")
# Stratified-ish split: shuffle then split
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