import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import Dataset, DataLoader from torch.optim.lr_scheduler import StepLR import os import h5py from tqdm import tqdm import numpy as np import cv2 from pdf2image import convert_from_path from crnn_model import CRNN from torchvision import transforms DATA_FILE = "data/real_line_dataset.h5" MODEL_OUTPUT_DIR = "models/crnn_final/" MODEL_SAVE_NAME = "crnn_real_data_model.pth" VALIDATION_PDF = "sample_documents/books/Applied-Machine-Learning-and-AI-for-Engineers.pdf" VALIDATION_PAGE = 2 EPOCHS, BATCH_SIZE, LEARNING_RATE = 50, 16, 0.0001 DEVICE = torch.device("mps" if torch.backends.mps.is_available() else "cpu") IMAGE_HEIGHT = 32 POPPLER_PATH = None class RealLineDataset(Dataset): def __init__(self, h5_path, char_list, transform=None): self.h5_path, self.transform = h5_path, transform self.char_to_int = {char: i + 1 for i, char in enumerate(char_list)} self.int_to_char = {i + 1: char for i, char in enumerate(char_list)} with h5py.File(self.h5_path, 'r') as hf: self.num_samples = len(hf['labels']) def __len__(self): return self.num_samples def __getitem__(self, idx): try: with h5py.File(self.h5_path, 'r') as hf: img_encoded = hf['image_data'][idx] label_str = hf['labels'][idx].decode('utf-8') image = cv2.imdecode(np.frombuffer(img_encoded, np.uint8), cv2.IMREAD_GRAYSCALE) if image is None or image.shape[1] == 0 or image.shape[0] == 0: raise ValueError(f"Image at index {idx} is corrupted or has a zero dimension.") image = cv2.bitwise_not(image) h, w = image.shape scale_factor = IMAGE_HEIGHT / h new_w = int(w * scale_factor) if new_w <= 0: raise ValueError(f"Calculated new width is non-positive for image at index {idx}.") resized_image = cv2.resize(image, (new_w, IMAGE_HEIGHT), interpolation=cv2.INTER_AREA) label = torch.tensor([self.char_to_int.get(char, 0) for char in label_str]) if self.transform: resized_image = self.transform(resized_image) return resized_image, label except Exception as e: print(f"\nWARNING: Corrupted data at index {idx}. Skipping. Error: {e}\n") return self.__getitem__(0) def collate_fn(batch): images, labels = zip(*batch) image_widths = [img.shape[2] for img in images] max_width = max(image_widths) padded_images = [] for img in images: padding = (0, max_width - img.shape[2], 0, 0) padded_images.append(torch.nn.functional.pad(img, padding, "constant", 0)) images_tensor = torch.stack(padded_images, 0) labels_concat = torch.cat(labels, 0) label_lengths = torch.tensor([len(lab) for lab in labels]) return images_tensor, labels_concat, label_lengths def decode_ctc_output(preds, int_to_char): texts = [] preds_idx = preds.argmax(2).cpu().numpy() for seq in preds_idx: decoded, last = [], 0 for char_idx in seq: if char_idx != last: if char_idx != 0: decoded.append(char_idx) last = char_idx texts.append("".join([int_to_char.get(c, '') for c in decoded])) return texts def validate_on_real_page(model, int_to_char, pdf_path, page_num): print("\n--- Running Real-World Validation ---") try: pil_image = convert_from_path(pdf_path, first_page=page_num, last_page=page_num, poppler_path=POPPLER_PATH)[0] image_data = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) except Exception as e: print(f"Validation failed: Could not convert PDF page. Error: {e}"); return gray = cv2.cvtColor(image_data, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) kernel = np.ones((1, 40), np.uint8) connected = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(connected, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) line_crops = [] bounding_boxes = [cv2.boundingRect(c) for c in contours] contours = [c for _, c in sorted(zip(bounding_boxes, contours), key=lambda b: b[0][1])] for contour in contours: x, y, w, h = cv2.boundingRect(contour) if w > 15 and h > 8 and h > 0: line_crops.append(binary[max(0, y - 2):y + h + 2, max(0, x - 2):x + w + 2]) full_text = [] model.eval() with torch.no_grad(): for line_image in line_crops: inverted_image = cv2.bitwise_not(line_image) h, w = inverted_image.shape scale = IMAGE_HEIGHT / h resized = cv2.resize(inverted_image, (int(w * scale), IMAGE_HEIGHT)) tensor = transforms.ToTensor()(resized).unsqueeze(0).to(DEVICE) preds = model(tensor) decoded_text = decode_ctc_output(preds, int_to_char) full_text.append(decoded_text[0]) print("--- Validation Page OCR Result ---") print("\n".join(full_text)) print("----------------------------------\n") def main(): if not os.path.exists(MODEL_OUTPUT_DIR): os.makedirs(MODEL_OUTPUT_DIR) with h5py.File(DATA_FILE, 'r') as hf: char_list = [c.decode('utf-8') for c in hf['char_list'][:]] dataset = RealLineDataset(DATA_FILE, char_list, transform=transforms.ToTensor()) loader = DataLoader(dataset, batch_size=BATCH_SIZE, collate_fn=collate_fn, shuffle=True, num_workers=2) model = CRNN(num_chars=len(char_list)).to(DEVICE) criterion = nn.CTCLoss(blank=0, zero_infinity=True) optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE) scheduler = StepLR(optimizer, step_size=10, gamma=0.5) best_train_loss = float('inf') for epoch in range(EPOCHS): model.train() total_train_loss = 0.0 for images, labels, label_lengths in tqdm(loader, desc=f"Epoch {epoch + 1}/{EPOCHS} [Train]"): images, batch_size = images.to(DEVICE), images.size(0) preds_raw = model(images) preds_for_loss = preds_raw.permute(1, 0, 2) cnn_output_width = preds_for_loss.size(0) input_lengths = torch.full(size=(batch_size,), fill_value=cnn_output_width, dtype=torch.long) optimizer.zero_grad() loss = criterion(preds_for_loss.log_softmax(2).cpu(), labels.cpu(), input_lengths.cpu(), label_lengths.cpu()) if not (torch.isinf(loss) or torch.isnan(loss)): loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5) optimizer.step() total_train_loss += loss.item() avg_train_loss = total_train_loss / len(loader) scheduler.step() print( f"Epoch {epoch + 1}/{EPOCHS} | Train Loss: {avg_train_loss:.4f} | LR: {optimizer.param_groups[0]['lr']:.6f}") validate_on_real_page(model, dataset.int_to_char, VALIDATION_PDF, VALIDATION_PAGE) if avg_train_loss < best_train_loss: best_train_loss = avg_train_loss torch.save(model.state_dict(), os.path.join(MODEL_OUTPUT_DIR, MODEL_SAVE_NAME)) print(f"Train loss improved. Saved model to '{MODEL_SAVE_NAME}'") if __name__ == "__main__": main()