"""
Font Classification using CNN - Detailed Parameter Explanations
==============================================================

This script implements a Convolutional Neural Network (CNN) to classify 
alphabetic characters (A-Z) from font images with comprehensive parameter explanations.
"""

# =============================================================================
# 1. IMPORT REQUIRED LIBRARIES
# =============================================================================

import tensorflow as tf
import numpy as np
from tensorflow import keras
from keras import Sequential
from keras.layers import Conv2D, MaxPooling2D, Dense, Dropout, Flatten
from keras.preprocessing import image

# Library Explanations:
# - tensorflow: Main deep learning framework
# - numpy: For numerical operations and array manipulations
# - keras: High-level neural network API (part of TensorFlow)
# - Sequential: Model type that allows stacking layers linearly
# - Conv2D: 2D convolution layer for feature extraction
# - MaxPooling2D: Pooling layer for dimension reduction
# - Dense: Fully connected layer for classification
# - Dropout: Regularization layer to prevent overfitting
# - Flatten: Converts 2D feature maps to 1D vector
# - image: Utilities for image loading and preprocessing

# =============================================================================
# 2. LOAD AND PREPARE DATASET
# =============================================================================

train_ds, val_ds = keras.utils.image_dataset_from_directory(
    "E:/Maroon/College/S9/DL/Fonts",  # directory: Path to dataset folder
                                       # PURPOSE: Location of image dataset with subdirectories as classes
                                       # WHEN TO CHANGE: Update path to your actual dataset location
                                       # STRUCTURE: Each subdirectory (A/, B/, C/, etc.) becomes a class
    
    color_mode="grayscale",           # color_mode: Image color format
                                      # PURPOSE: Determines number of color channels
                                      # OPTIONS: "grayscale" (1 channel), "rgb" (3 channels), "rgba" (4 channels)
                                      # WHEN TO CHANGE: Use "rgb" for colored images, "grayscale" for B&W
    
    subset="both",                    # subset: Which data splits to return
                                      # PURPOSE: Specifies training/validation split output
                                      # OPTIONS: "training", "validation", "both"
                                      # WHEN TO CHANGE: Use specific subset if you want only one split
    
    image_size=(28, 28),              # image_size: Target image dimensions (height, width)
                                      # PURPOSE: Resizes all images to this size
                                      # IMPACT: Affects model input shape and computational requirements
                                      # WHEN TO CHANGE: Larger (64x64, 224x224) for more detail, smaller for speed
    
    validation_split=0.2,             # validation_split: Fraction for validation set
                                      # PURPOSE: Percentage of data reserved for validation (20% here)
                                      # RANGE: 0.0 to 1.0 (typically 0.1 to 0.3)
                                      # WHEN TO CHANGE: 0.1-0.15 for large datasets, 0.2-0.3 for smaller datasets
    
    seed=1337                         # seed: Random seed for reproducible splits
                                      # PURPOSE: Ensures same train/val split across runs
                                      # WHEN TO CHANGE: Different values for different splits, remove for random
)

# =============================================================================
# 3. DATA NORMALIZATION
# =============================================================================

train_ds = train_ds.map(lambda x, y: (tf.cast(x, tf.float32)/255.0, y))
val_ds = val_ds.map(lambda x, y: (tf.cast(x, tf.float32)/255.0, y))

# Parameter Explanations:
# 
# .map() function:
# - PURPOSE: Applies transformation to each batch in the dataset
# - lambda x, y: Anonymous function where x=images, y=labels
# 
# tf.cast(x, tf.float32):
# - PURPOSE: Converts image data type from uint8 to float32
# - WHEN TO CHANGE: Use tf.float16 for memory savings, tf.float64 for precision
# - WHY NEEDED: Neural networks work better with floating-point numbers
# 
# /255.0:
# - PURPOSE: Normalizes pixel values from [0,255] to [0,1]
# - WHY CRITICAL: Prevents gradient explosion, helps faster convergence
# - ALTERNATIVES: /127.5 - 1 for [-1,1] range, custom normalization for specific needs

# =============================================================================
# 4. BUILD CNN MODEL ARCHITECTURE
# =============================================================================

model = Sequential([
    Conv2D(32,                        # filters: Number of feature detectors (kernels)
                                      # PURPOSE: How many different features to detect
                                      # WHEN TO INCREASE: More complex datasets (64, 128, 256)
                                      # WHEN TO DECREASE: Simpler datasets or less computation (16, 8)
           
           (5, 5),                    # kernel_size: Size of convolution window (height, width)
                                      # PURPOSE: Defines the receptive field size
                                      # OPTIONS: (3,3) for fine details, (5,5) for broader features, (7,7) for large patterns
                                      # WHEN TO CHANGE: Smaller for detailed features, larger for global patterns
           
           activation="relu",         # activation: Non-linear activation function
                                      # PURPOSE: Introduces non-linearity for complex pattern learning
                                      # OPTIONS: "relu" (most common), "tanh", "sigmoid", "leaky_relu"
                                      # WHEN TO CHANGE: "leaky_relu" for dying ReLU, "tanh" for [-1,1] outputs
           
           padding="same",            # padding: Controls output size after convolution
                                      # PURPOSE: Determines spatial dimension preservation
                                      # OPTIONS: "same" (keeps size), "valid" (reduces size)
                                      # WHEN TO USE: "same" to preserve dimensions, "valid" to reduce them
           
           input_shape=(28, 28, 1)),  # input_shape: Input tensor dimensions (height, width, channels)
                                      # PURPOSE: Defines the expected input format
                                      # WHEN TO CHANGE: Must match your image dimensions and channels
    
    MaxPooling2D(padding="same"),     # MaxPooling2D: Downsampling layer
                                      # pool_size: Default (2,2) - size of pooling window
                                      # PURPOSE: Reduces spatial dimensions while retaining features
                                      # WHEN TO CHANGE: (3,3) for aggressive downsampling, (1,1) for minimal
                                      # padding: Same options as Conv2D
    
    Conv2D(64,                        # filters: Increased to 64 for more complex feature detection
                                      # PURPOSE: Detects combinations of basic features from previous layer
           (5, 5), 
           activation="relu", 
           padding="same"),
    
    MaxPooling2D(padding="same"),     # Second pooling layer for further dimension reduction
    
    Flatten(),                        # Flatten: Converts 2D feature maps to 1D vector
                                      # PURPOSE: Prepares data for dense layers
                                      # NO PARAMETERS: Automatically handles reshaping
    
    Dense(1024,                       # units: Number of neurons in dense layer
                                      # PURPOSE: Learning capacity of the layer
                                      # WHEN TO INCREASE: More complex patterns (2048, 4096)
                                      # WHEN TO DECREASE: Simpler datasets or prevent overfitting (512, 256)
          
          activation="relu"),         # activation: Activation function for dense layer
                                      # PURPOSE: Non-linearity for complex pattern learning
                                      # TYPICAL: "relu" for hidden layers
    
    Dropout(0.2),                     # rate: Fraction of neurons to randomly disable
                                      # PURPOSE: Prevents overfitting during training
                                      # RANGE: 0.0 to 0.8 (typically 0.2 to 0.5)
                                      # WHEN TO INCREASE: If overfitting (0.3, 0.4, 0.5)
                                      # WHEN TO DECREASE: If underfitting (0.1, 0.15)
    
    Dense(26,                         # units: 26 for output classes (A-Z letters)
                                      # PURPOSE: One neuron per class for classification
                                      # WHEN TO CHANGE: Match number of classes in your dataset
          
          activation="sigmoid")       # activation: Output layer activation
                                      # PURPOSE: Produces probability-like outputs
                                      # OPTIONS: "sigmoid" for multi-label, "softmax" for multi-class
                                      # NOTE: "softmax" might be more appropriate for single-class prediction
])

# =============================================================================
# 5. COMPILE AND TRAIN THE MODEL
# =============================================================================

model.compile(
    optimizer="adam",                 # optimizer: Weight update algorithm
                                      # PURPOSE: How the model learns from errors
                                      # OPTIONS: "adam" (adaptive), "sgd" (basic), "rmsprop" (RNN-friendly)
                                      # WHEN TO CHANGE: "sgd" with momentum for fine-tuning
                                      # CUSTOM: keras.optimizers.Adam(learning_rate=0.001) for custom LR
    
    loss="sparse_categorical_crossentropy",  # loss: Error measurement function
                                             # PURPOSE: Quantifies prediction errors
                                             # WHEN TO USE: Multi-class with integer labels (0,1,2,...)
                                             # ALTERNATIVES: "categorical_crossentropy" for one-hot, 
                                             #               "binary_crossentropy" for binary classification
    
    metrics=["accuracy"]              # metrics: Additional monitoring metrics
                                      # PURPOSE: Track performance during training
                                      # OPTIONS: ["accuracy"], ["precision"], ["recall"], ["f1_score"]
                                      # WHEN TO ADD: Use precision/recall for imbalanced datasets
)

model.fit(
    train_ds,                         # x: Training data
                                      # PURPOSE: Data used to update model weights
                                      # FORMAT: tf.data.Dataset with (images, labels)
    
    epochs=20,                        # epochs: Number of complete training passes
                                      # PURPOSE: How many times to see entire dataset
                                      # WHEN TO INCREASE: If loss still decreasing (30, 50, 100)
                                      # WHEN TO DECREASE: If overfitting early (10, 15)
                                      # MONITORING: Watch validation loss to avoid overfitting
    
    validation_data=val_ds            # validation_data: Data for performance monitoring
                                      # PURPOSE: Evaluates model during training without affecting weights
                                      # WHY IMPORTANT: Detects overfitting, monitors generalization
                                      # WHEN TO SKIP: Only for very small datasets (not recommended)
)

# Additional useful parameters (not used but available):
# batch_size: Number of samples per weight update (default from dataset)
# callbacks: [EarlyStopping, ModelCheckpoint, ReduceLROnPlateau] for automation
# verbose: 0 (silent), 1 (progress bar), 2 (one line per epoch)

# =============================================================================
# 6. LOAD TEST IMAGE FOR PREDICTION
# =============================================================================

img = image.load_img(
    "E:/Maroon/College/S9/DL/Fonts/test1.png",  # path: File path to test image
                                                # PURPOSE: Location of image to classify
                                                # WHEN TO CHANGE: Test different images
                                                # FORMATS: PNG, JPG, JPEG, BMP, GIF supported
    
    target_size=(28, 28),             # target_size: Resize dimensions (height, width)
                                      # PURPOSE: Match model's expected input size
                                      # MUST MATCH: Model's input_shape requirements
                                      # WHEN TO CHANGE: Only if model input size changes
    
    color_mode="grayscale"            # color_mode: Color channel specification
                                      # PURPOSE: Match model's expected channels
                                      # OPTIONS: "grayscale" (1), "rgb" (3), "rgba" (4)
                                      # MUST MATCH: Model's input channel requirements
)

img_array = np.expand_dims(
    image.img_to_array(img)/255.0,    # array: Preprocessed image array
                                      # img_to_array(): PIL image → numpy array (28,28,1)
                                      # /255.0: Normalize to [0,1] (CRITICAL: match training preprocessing)
    
    axis=0                            # axis: Dimension to expand
                                      # PURPOSE: Add batch dimension for model input
                                      # RESULT: (28,28,1) → (1,28,28,1)
                                      # WHY NEEDED: Model expects batched input even for single image
                                      # OPTIONS: axis=0 (beginning), axis=-1 (end)
)

# =============================================================================
# 7. MAKE PREDICTION
# =============================================================================

pred = model.predict(
    img_array                         # x: Input data for prediction
                                      # PURPOSE: Preprocessed image ready for classification
                                      # EXPECTED SHAPE: (batch_size, height, width, channels)
                                      # DATA TYPE: Must be float32 and normalized
)

# Additional predict() parameters (not used but available):
# batch_size: Control memory usage for large datasets (32, 64, 128)
# verbose: 0 (silent), 1 (progress bar), 2 (one line per batch)
# steps: Number of batches (for generator inputs)

pred_class = np.argmax(
    pred,                             # a: Prediction array with probability scores
                                      # PURPOSE: Array of class probabilities
                                      # SHAPE: (1, 26) - 1 sample, 26 class probabilities
    
    axis=1                            # axis: Dimension for finding maximum
                                      # PURPOSE: Find class with highest probability
                                      # OPTIONS: axis=0 (across samples), axis=1 (across classes)
                                      # RESULT: Index of most confident prediction
)[0]                                  # [0]: Extract scalar from array
                                      # PURPOSE: Get single prediction from batch
                                      # WHY NEEDED: argmax returns shape (1,), we want scalar

# =============================================================================
# 8. DISPLAY PREDICTION RESULT
# =============================================================================

class_values = [chr(i) for i in range(65, 91)]  # Create A-Z mapping
# chr(): Converts ASCII code to character
# range(65, 91): ASCII codes for A(65) through Z(90)
# WHEN TO CHANGE: range(97, 123) for lowercase a-z
# ALTERNATIVES: 
#   - import string; class_values = list(string.ascii_uppercase)
#   - Manual list: ['A', 'B', 'C', ..., 'Z']

print("Predicted values:", class_values[pred_class])
# print() parameters (not used but available):
# sep: Separator between arguments (default ' ')
# end: String at end (default '\n')
# file: Output destination (default stdout)

# =============================================================================
# SUMMARY OF KEY PARAMETER TUNING GUIDELINES
# =============================================================================

"""
COMMON PARAMETER ADJUSTMENTS:

1. OVERFITTING (High training accuracy, low validation accuracy):
   - Increase dropout rate (0.3, 0.4, 0.5)
   - Reduce model complexity (fewer filters, smaller dense layers)
   - Add more data augmentation
   - Reduce epochs

2. UNDERFITTING (Low training and validation accuracy):
   - Increase model complexity (more filters, larger dense layers)
   - Decrease dropout rate (0.1, 0.15)
   - Increase epochs
   - Adjust learning rate

3. SLOW TRAINING:
   - Increase batch_size (64, 128)
   - Use smaller image_size (16x16 instead of 28x28)
   - Reduce model complexity

4. MEMORY ISSUES:
   - Decrease batch_size (16, 8)
   - Use tf.float16 instead of tf.float32
   - Reduce image_size
   - Reduce model complexity

5. POOR ACCURACY:
   - Try different optimizers (sgd, rmsprop)
   - Adjust learning rate
   - Use data augmentation
   - Ensure proper data normalization
"""
