this is my code: import os
import math
import random
import torch
import torch.nn as nn
import torch.optim as optim
import torchvision.transforms as transforms
from torchvision.models import resnet50
from torch.utils.data import Dataset, DataLoader
import json
from pathlib import Path
import cv2
import numpy as np
from tqdm import tqdm
from IPython.display import clear_output
import optuna
## Set Up
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# Set a random seed for torch
seed = 13
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)  # If using multiple GPUs
np.random.seed(seed)
random.seed(seed)

# Additional configurations for deterministic behavior on CUDA devices
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False

data_path = '/workspaces/local_dev/Images/Training'
validation_path = '/workspaces/local_dev/Images/validation_set'
model_name = 'rotnet_resnet50v2'

# Training Parameters
num_samples = 100 # Number of samples used for training. If None then all images
num_val_samples = 25  # Number of samples used for validation. If None then all images
input_shape = (224, 224) # Tuple defining the input image shape (height, width)

# Optuna Optimization Parameters
n_trial = 100 # Number of Optuna Trials
learning_rate_options = (1e-5, 1e-1) # Learning rate for the optimizer
batch_size_options = [8, 16] # Batch size for training
num_epochs_options = (50,50) #  Number of epochs to train the model for
weight_decay_options = (1e-6, 0.1)
dropout_rate_options = (0.001, 0.5)

# Define Defs
# Error Function
def angle_difference(x, y):
    """
    Calculate minimum difference between two angles.
    """
    return 180 - abs(abs(x - y) - 180)

def angle_error(y_true, y_pred):
    """
    Calculate the mean difference between the true angles
    and the predicted angles.
    """
    diff = angle_difference(y_true, y_pred)
    return torch.mean(torch.abs(diff.float()))

# Rotation Related
def rotate(image, angle):
    """
    Rotates an OpenCV 2 / NumPy image about its center by the given angle
    (in degrees).
    """
    center = (image.shape[1] / 2, image.shape[0] / 2)
    rot_mat = cv2.getRotationMatrix2D(center, angle, 1.0)
    return cv2.warpAffine(image, rot_mat, (image.shape[1], image.shape[0]), flags=cv2.INTER_LINEAR)

def largest_rotated_rect(w, h, angle):
    """
    Given a rectangle of size wxh that has been rotated by 'angle' (in
    radians), computes the width and height of the largest possible
    axis-aligned rectangle within the rotated rectangle.

    Original JS code by 'Andri' and Magnus Hoff from Stack Overflow

    Converted to Python by Aaron Snoswell

    Source: http://stackoverflow.com/questions/16702966/rotate-image-and-crop-out-black-borders
    """

    quadrant = int(math.floor(angle / (math.pi / 2))) & 3
    sign_alpha = angle if ((quadrant & 1) == 0) else math.pi - angle
    alpha = (sign_alpha % math.pi + math.pi) % math.pi

    bb_w = w * math.cos(alpha) + h * math.sin(alpha)
    bb_h = w * math.sin(alpha) + h * math.cos(alpha)

    gamma = math.atan2(bb_w, bb_w) if (w < h) else math.atan2(bb_w, bb_w)

    delta = math.pi - alpha - gamma

    length = h if (w < h) else w

    d = length * math.cos(alpha)
    a = d * math.sin(alpha) / math.sin(delta)

    y = a * math.cos(gamma)
    x = y * math.tan(gamma)

    return (
        bb_w - 2 * x,
        bb_h - 2 * y
    )


def crop_around_center(image, width, height):
    """
    Given a NumPy / OpenCV 2 image, crops it to the given width and height,
    around it's centre point

    Source: http://stackoverflow.com/questions/16702966/rotate-image-and-crop-out-black-borders
    """

    image_size = (image.shape[1], image.shape[0])
    image_center = (int(image_size[0] * 0.5), int(image_size[1] * 0.5))

    if(width > image_size[0]):
        width = image_size[0]

    if(height > image_size[1]):
        height = image_size[1]

    x1 = int(image_center[0] - width * 0.5)
    x2 = int(image_center[0] + width * 0.5)
    y1 = int(image_center[1] - height * 0.5)
    y2 = int(image_center[1] + height * 0.5)

    return image[y1:y2, x1:x2]


def crop_largest_rectangle(image, angle, height, width):
    """
    Crop around the center the largest possible rectangle
    found with largest_rotated_rect.
    """
    return crop_around_center(
        image,
        *largest_rotated_rect(
            width,
            height,
            math.radians(angle)
        )
    )

def generate_rotated_image(image, angle, size=None, crop_center=False,
                           crop_largest_rect=False):
    """
    Generate a valid rotated image for the RotNetDataGenerator. If the
    image is rectangular, the crop_center option should be used to make
    it square. To crop out the black borders after rotation, use the
    crop_largest_rect option. To resize the final image, use the size
    option.
    """
    height, width = image.shape[:2]
    if crop_center:
        if width < height:
            height = width
        else:
            width = height

    image = rotate(image, angle)

    if crop_largest_rect:
        image = crop_largest_rectangle(image, angle, height, width)

    if size:
        image = cv2.resize(image, size)

    return image

def get_filenames(path, num_samples=None):
    image_paths = []
    
    # Recursively traverse the directory structure
    for root, _, filenames in os.walk(path):
        for filename in filenames:
            image_paths.append(os.path.join(root, filename))

    # Use all available images if num_samples is not provided
    if num_samples is None:
        train_filenames = image_paths
    else:
        # Randomly select num_samples images
        train_filenames = random.sample(image_paths, min(num_samples, len(image_paths)))

    # Shuffle the train_filenames list
    random.shuffle(train_filenames)

    return train_filenames

# Dataset class for RotNet data
class RotNetDataset(Dataset):
    def __init__(self, filenames, num_samples, input_shape, transform=None):
        self.filenames = filenames
        self.num_samples = num_samples
        self.input_shape = input_shape
        self.transform = transform

    def __len__(self):
        return min(self.num_samples, len(self.filenames))

    def __getitem__(self, idx):
        image = cv2.imread(self.filenames[idx])
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

        rotation_angle = random.randint(0, 359)

        rotated_image = generate_rotated_image(
            image,
            rotation_angle,
            size=self.input_shape,
            crop_center=True,
            crop_largest_rect=True
        )

        sample = {'image': rotated_image, 'angle': rotation_angle}

        if self.transform:
            sample['image'] = self.transform(sample['image'])

        return sample

# Define transforms
transform = transforms.Compose([
    transforms.ToPILImage(),
    transforms.Resize(input_shape),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

## Run

## Run
# Model definition
num_classes = 360 # The degrees of a circle
class RotNetModel(nn.Module):
    def __init__(self, num_classes):
        super(RotNetModel, self).__init__()
        self.resnet = resnet50(weights=True)
        self.resnet.fc = nn.Linear(2048, num_classes)

    def forward(self, x):
        return self.resnet(x)
    
# Initialize the model
model = RotNetModel(num_classes=num_classes).to(device)

# Create output folder if it doesn't exist
output_folder = 'models'
Path(output_folder).mkdir(parents=True, exist_ok=True)
# Training loop           
def train_model(model, dataloader, criterion, optimizer, start_epoch=0, num_epochs=50, patience=10):
    model.train()
    best_loss = float('inf')
    epochs_without_improvement = 0  # Count how many epochs without improvement

    for epoch in range(start_epoch, num_epochs):
        running_loss = 0.0
        for i, data in enumerate(dataloader, 0):
            inputs, labels = data['image'].to(device), data['angle'].to(device)

            optimizer.zero_grad()
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()

            running_loss += loss.item()

            # Log training loss to TensorBoard
            global_step = epoch * len(dataloader) + i

        epoch_loss = running_loss / len(dataloader)
        print(f'Epoch [{epoch + 1}/{num_epochs}] Loss: {epoch_loss:.4f}')

        if epoch_loss < best_loss:
            best_loss = epoch_loss
            epochs_without_improvement = 0  # Reset counter
            torch.save(model.state_dict(), os.path.join(output_folder, model_name + '.pth'))
        else:
            epochs_without_improvement += 1
        
        if epochs_without_improvement >= patience:
            print(f'Early stopping after {patience} epochs without improvement.')
            break
    
    return model  # Return the trained model

            
# Define the objective function for Optuna
def objective(trial):
    learning_rate = trial.suggest_float('learning_rate', *learning_rate_options, log=True)
    weight_decay = trial.suggest_float('weight_decay', *weight_decay_options, log=True)
    batch_size = trial.suggest_categorical('batch_size', batch_size_options)
    num_epochs = trial.suggest_int('num_epochs', *num_epochs_options)
    dropout_rate = trial.suggest_float('dropout_rate', *dropout_rate_options, log=True)
    optimizer_choice = trial.suggest_categorical('optimizer', ['SGD', 'Adam', 'RMSprop'])

    # Define the model
    model = RotNetModel(num_classes=num_classes).to(device)
    
    # Define the optimizer with suggested learning rate
    if optimizer_choice == 'SGD':
        optimizer = optim.SGD(model.parameters(), lr=learning_rate, momentum=0.9, weight_decay=weight_decay)
    elif optimizer_choice == 'Adam':
        optimizer = optim.Adam(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
    elif optimizer_choice == 'RMSprop':
        optimizer = optim.RMSprop(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
    
    # Initialize data loaders
    train_dataset = RotNetDataset(train_filenames, num_samples, input_shape, transform=transform)
    train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=8)
    
    # Train the model
    train_model(model, train_dataloader, criterion, optimizer, num_epochs=num_epochs)
    
    # Calculate the angle error on the validation set
    model.eval()
    validation_Data = RotNetDataset(validation_filenames, num_samples, input_shape, transform=transform)
    validation_dataloader = DataLoader(validation_Data, batch_size=batch_size, shuffle=False, num_workers=4)
    
    total_angle_error = 0.0
    total_samples = 0
    
    with torch.no_grad():
        for data in validation_dataloader:
            inputs, labels = data['image'].to(device), data['angle'].to(device)
            outputs = model(inputs)
            predicted_angles = outputs.argmax(dim=1)
            angle_errors = angle_error(labels, predicted_angles)
            
            total_angle_error += angle_errors.item() * len(labels)
            total_samples += len(labels)
    
    # Calculate the mean angle error for the validation set
    mean_angle_error = total_angle_error / total_samples
    
    # Clear the output after each iteration
    clear_output(wait=True)
    
    # Return the mean angle error to Optuna (minimize it)
    return mean_angle_error

if __name__ == '__main__':
    # Initialize the model
    model = RotNetModel(num_classes=num_classes).to(device)

    # Loss function and optimizer
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

    # Create output folder if it doesn't exist
    output_folder = 'models'
    Path(output_folder).mkdir(parents=True, exist_ok=True)

    # Training loop using Optuna
    study = optuna.create_study(direction='minimize')
    study.optimize(objective, n_trials=n_trial)
    print('Number of finished trials:', len(study.trials))
    print('Best trial:')
    trial = study.best_trial
    
    print('Value:', trial.value)
    print('Params:')
    best_params = {}
    for key, value in trial.params.items():
        print(f'    {key}: {value}')
        best_params[key] = value  # Store best hyperparameters
    
    # Save the best hyperparameters to a file
    best_params_path = os.path.join(output_folder, 'best_hyperparameters.json')
    with open(best_params_path, 'w') as f:
        json.dump(best_params, f)
saved_file_path = os.path.join(output_folder, model_name + '.pth')
if os.path.exists(saved_file_path):
    os.remove(saved_file_path)
    print(f"File {saved_file_path} deleted.")
else:
    print(f"File {saved_file_path} not found.")