# 3rd place solution

السلام عليكم، رمضان مبارك جميعا واعاننا الله على صيامه وقيامه
First of all, thanks to the organizers for this challenging competition.
Our solution is based on a
Two-Stage Pipeline
: Object Detection using
YOLO11m
and a
ConvNeXt
classifier to determine the "Right Place" status, supported by Test-Time Augmentation (TTA) and Pseudo Labeling.
1. Challenges:
Domain Shift.
Class Imbalance:
only 5 samples of True "Right Place" class.
2. Stage 1: Object Detection (YOLO11)
We used
YOLO11m
(Medium) as our primary detector.
Color Augmentation
To fix the domain shift , we forced the model to become "Color Invariant" during training by increasing HSV augmentations. Preventing the model from relying on the specific red/white pattern of the Shemagh.
HSV Saturation (
hsv_s
):
Increased to
0.8
.
HSV Value (
hsv_v
):
Increased to
0.6
.
Training Setup
Folds:
Stratified K-Fold (5 Folds).
Image Size:
640.
Epochs:
200 per fold.
3. Stage 2: Classifier (ConvNeXt)
Initially, we tried geometric heuristics (Intersection over Head, Symmetry scores), then we switched to a classifier approach.
Model:
convnext_tiny.fb_in22k_ft_in1k
(via
timm
).
Input:
We passed the images where
Head
or
Shemagh
were detected to the classifier.
Resolution:
224x224.
Pseudo Labeling
Since we had very few "True" samples for the
right_place
class, we:
Trained a model on the labeled data.
Ran inference on the Test Set.
Filtered high confidence "Right Place" predictions.
Added these Pseudo Labels back to the training set.
Retrained ConvNeXt with a weighted loss function to handle the remaining imbalance.
4. Inference Pipeline
Our inference pipeline was designed to maximize recall and precision using ensemble techniques.
Multi-Scale TTA:
[640, 800, 512]
.
Weighted Boxes Fusion (WBF):
Instead of NMS, we used WBF (
IoU=0.60
) to average the coordinates of overlapping boxes from the TTA steps, resulting in tighter bounding boxes.
Class-wise NMS:
To solve the issue of double detections (two boxes for one face), we applied NMS per class, keeping only the box with the highest confidence if overlap > 65%.
!pip install -q ultralytics ensemble_boxes

import os
import pandas as pd
import numpy as np
from ultralytics import YOLO
import yaml
from tqdm.notebook import tqdm
import cv2
import shutil
import glob
from pathlib import Path
from sklearn.model_selection import train_test_split, StratifiedKFold, KFold
from sklearn.metrics import f1_score, accuracy_score
import joblib
from ensemble_boxes import weighted_boxes_fusion
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms, ops
import matplotlib.pyplot as plt
import random
import json
import base64
from io import BytesIO
from IPython.display import HTML, display
import timm
from PIL import Image
Training YOLO using k fold 5
# Global Seed Setup 
def set_all_seeds(seed=42):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    # Ensures deterministic algorithms are used where possible
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

set_all_seeds(42)

# Setup Absolute Paths 
WORK_DIR = Path('/kaggle/input/dal-shemagh-detection-challenge').absolute()
images_path = WORK_DIR / 'images/train' 
all_images = sorted([f.name for f in images_path.glob('*') if f.suffix.lower() in ['.jpg', '.jpeg', '.png']])
k = 5
results = {}

# random_state=42 ensures the folds are always the same
kf = KFold(n_splits=k, shuffle=True, random_state=42)

# K-Fold Training Loop 
for fold, (train_idx, val_idx) in enumerate(kf.split(all_images)):
    # after some tests using the same random seed, 3ed fold usally gives the hieghst validation score. 
    if fold != 2: 
        continue

    train_list = [str(images_path / all_images[i]) for i in train_idx]
    val_list = [str(images_path / all_images[i]) for i in val_idx]

    with open(f"train_f{fold}.txt", "w") as f: f.write("\n".join(train_list))
    with open(f"val_f{fold}.txt", "w") as f: f.write("\n".join(val_list))

    yaml_path = os.path.abspath(f"fold_{fold}.yaml")
    with open(yaml_path, "w") as f:
        yaml.dump({
            'path': str(WORK_DIR),
            'train': os.path.abspath(f"train_f{fold}.txt"),
            'val': os.path.abspath(f"val_f{fold}.txt"),
            'nc': 2, 
            'names': ['Face', 'Shemagh']
        }, f)

    # Initialize and Train
    torch.cuda.empty_cache()
    model = YOLO('yolo11m.pt') 

    model.train(
        data=yaml_path,
        epochs=200,
        imgsz=640,          
        batch=16,              
        device=1,
        seed=42,
        workers=4,              
        deterministic=True,    

        hsv_h=0.015,   
        hsv_s=0.8,     
        hsv_v=0.6,     
        scale=0.8,

        mosaic=1.0,    
        mixup=0.1,     
        copy_paste=0.3,
        close_mosaic=50, 
        erasing=0.3,

        optimizer='SGD',
        lr0=0.001,
        project='Shemagh_KFold_BuiltIn3',
        name=f'fold_{fold}'

    )



    stats = model.val()
    if stats:
        results[fold] = stats.results_dict
    with open("kfold_results_backup.json", "w") as f:
        json.dump(results, f)
Use YOLO and heuristics To generate pseudo labels
# CONFIGURATION
MODEL_PATH = '/kaggle/working/runs/detect/Shemagh_KFold_BuiltIn3/fold_2/weights/best.pt'
TEST_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'

# Inference Hyperparameters
TTA_SCALES = [640, 800, 512]
CONF_THRES = 0.40   # Lowered to capture all possible detections
IOU_WBF = 0.50      # Aggressive merging for multi-scale consensus
W_SYM = 0.8
W_DX = 0.2
FINAL_THRESH = 0.45 

# Pseudo Labeling Thresholds
SURE_CORRECT_THRESH = 0.85   
SURE_INCORRECT_THRESH = 0.15 

# HYBRID UTILITY FUNCTIONS
def xyxy_to_xywh(box):
    x1, y1, x2, y2 = box
    return [(x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1]

def clean_duplicate_shemaghs(shemaghs, overlap_threshold=0.50):
    if not shemaghs: return []
    cleaned = []
    sorted_s = sorted(shemaghs, key=lambda x: x[2] * x[3], reverse=True)
    for s_big in sorted_s:
        keep = True
        bx, by, bw, bh = s_big
        for s_checked in cleaned:
            cx, cy, cw, ch = s_checked
            ix1, iy1 = max(bx-bw/2, cx-cw/2), max(by-bh/2, cy-ch/2)
            ix2, iy2 = min(bx+bw/2, cx+cw/2), min(by+bh/2, cy+ch/2)
            inter = max(0, ix2-ix1) * max(0, iy2-iy1)
            if inter / (bw * bh) > overlap_threshold:
                keep = False
                break
        if keep: cleaned.append(s_big)
    return cleaned

def calculate_hybrid_features(head, shemagh):
    hx, hy, hw, hh = head
    sx, sy, sw, sh = shemagh
    ix1, iy1 = max(hx-hw/2, sx-sw/2), max(hy-hh/2, sy-sh/2)
    ix2, iy2 = min(hx+hw/2, sx+sw/2), min(hy+hh/2, sy+sh/2)
    inter = max(0, ix2-ix1) * max(0, iy2-iy1)
    ioh = inter / (hw * hh) if (hw * hh) > 0 else 0.0
    half_area = (hw * hh) / 2
    l_inter = max(0, min(hx, sx+sw/2) - max(hx-hw/2, sx-sw/2)) * max(0, min(hy+hh/2, sy+sh/2) - max(hy-hh/2, sy-sh/2))
    r_inter = max(0, min(hx+hw/2, sx+sw/2) - max(hx, sx-sw/2)) * max(0, min(hy+hh/2, sy+sh/2) - max(hy-hh/2, sy-sh/2))
    symmetry = abs((l_inter / half_area) - (r_inter / half_area)) if half_area > 0 else 1.0
    h_ymin, s_ymin = hy - hh/2, sy - sh/2
    return {
        'ioh': ioh, 'symmetry': symmetry, 'dx_norm': abs(sx - hx) / hw,
        'starts_at_top': s_ymin < (hy - hh/4), 
        'is_above': s_ymin < h_ymin,
        'head_flat': hh < (hw * 0.9)
    }


# INFERENCE LOOP
test_files = sorted([f for f in os.listdir(TEST_IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])
model = YOLO(MODEL_PATH)
all_results = []

print(f"Running inference on {len(test_files)} images...")

for filename in tqdm(test_files):
    img_path = os.path.join(TEST_IMG_DIR, filename)
    img_orig = cv2.imread(img_path)
    img_orig = cv2.cvtColor(img_orig, cv2.COLOR_BGR2RGB)
    h_img, w_img, _ = img_orig.shape

    # TTA Detection
    b_list, s_list, l_list, raw_viz_boxes = [], [], [], []
    for scale in TTA_SCALES:
        res = model.predict(img_path, imgsz=scale, conf=CONF_THRES, augment=True, verbose=False)[0]
        if len(res.boxes) > 0:
            bn, cn, cl = res.boxes.xyxyn.cpu().tolist(), res.boxes.conf.cpu().tolist(), res.boxes.cls.cpu().tolist()
            b_list.append(bn); s_list.append(cn); l_list.append(cl)
            for b, s, l in zip(bn, cn, cl):
                raw_viz_boxes.append({'box': b, 'cls': int(l)})

    # Weighted Boxes Fusion
    if b_list:
        boxes, scores, labels = weighted_boxes_fusion(b_list, s_list, l_list, weights=None, iou_thr=IOU_WBF, skip_box_thr=CONF_THRES)
    else:
        boxes, scores, labels = [], [], []

    # Apply Logic
    raw_heads = [xyxy_to_xywh(b) for b, l in zip(boxes, labels) if int(l) == 0]
    raw_shemaghs = [xyxy_to_xywh(b) for b, l in zip(boxes, labels) if int(l) == 1]
    cleaned_shemaghs = clean_duplicate_shemaghs(raw_shemaghs)

    is_correct = 0
    max_img_score = -1.0 # Default if no valid pair found

    if raw_heads and cleaned_shemaghs:
        for h in raw_heads:
            for s in cleaned_shemaghs:
                f = calculate_hybrid_features(h, s)
                if not f['is_above']: continue
                if f['head_flat'] and f['ioh'] < 0.80: continue

                score = f['ioh'] - (W_SYM * f['symmetry']) - (W_DX * f['dx_norm'])
                if f['starts_at_top']: score += 0.05
                if score > max_img_score: max_img_score = score

        if max_img_score > FINAL_THRESH:
            is_correct = 1

    # Store Data
    pred_parts = [f"{int(l)} {s:.4f} {xyxy_to_xywh(b)[0]:.4f} {xyxy_to_xywh(b)[1]:.4f} {xyxy_to_xywh(b)[2]:.4f} {xyxy_to_xywh(b)[3]:.4f}" 
                  for b, s, l in zip(boxes, scores, labels)]
    pred_str = " ".join(pred_parts) if pred_parts else "-"

    all_results.append({
        "filename": filename,
        "right_place": is_correct,
        "prediction_string": pred_str,
        "max_img_score": max_img_score
    })

# EXPORTING DATA
df_all = pd.DataFrame(all_results)


certain_labels = []
for idx, row in df_all.iterrows():
    if row['max_img_score'] >= SURE_CORRECT_THRESH:
        certain_labels.append({"filename": row['filename'], "label": 1})
    elif row['max_img_score'] <= SURE_INCORRECT_THRESH:
        certain_labels.append({"filename": row['filename'], "label": 0})

df_pseudo = pd.DataFrame(certain_labels)

# Filter for Label 1 
df_correct = df_pseudo[df_pseudo['label'] == 1].copy()

# Save to a new file
output_csv = 'correct_images_only.csv'
df_correct.to_csv(output_csv, index=False)


print(f"Filtered {len(df_correct)} correct images out of {len(df)} total pseudo labels.")
print(f"Saved to: {output_csv}")
Training ConvNext for 'right_place' using Pseudo labeling
Fun fact: we got 0.848 private score using only heuristics.
# CONFIGURATION
ORIGINAL_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/train'
ORIGINAL_CSV = '/kaggle/input/dal-shemagh-detection-challenge/train_labels.csv'
PSEUDO_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'
PSEUDO_CSV = '/kaggle/working/correct_images_only.csv'
MODEL_NAME = 'convnext_tiny.fb_in22k_ft_in1k'
NUM_CLASSES = 2
IMG_SIZE = 224
BATCH_SIZE = 16
EPOCHS = 15
LEARNING_RATE = 5e-5
N_FOLDS = 5
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
OUTPUT_DIR = 'ConvNext_KFold_Models'

os.makedirs(OUTPUT_DIR, exist_ok=True)

# DATASET CLASS
class ShemaghDataset(Dataset):
    def __init__(self, df, transform=None):
        self.df = df
        self.transform = transform

    def __len__(self):
        return len(self.df)

    def __getitem__(self, idx):
        row = self.df.iloc[idx]
        img_path = row['path']
        label = int(row['right_place'])

        # Read Image using OpenCV 
        image = cv2.imread(img_path)
        if image is None:
            # Fallback (black image) to prevent crash
            image = np.zeros((IMG_SIZE, IMG_SIZE, 3), dtype=np.uint8)

        # Convert BGR -> RGB -> PIL Image 
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        image = Image.fromarray(image)

        if self.transform:
            image = self.transform(image)

        return image, torch.tensor(label, dtype=torch.long)


# AUGMENTATIONS
def get_transforms(data='train'):
    if data == 'train':
        return transforms.Compose([
            transforms.Resize((IMG_SIZE, IMG_SIZE)),
            transforms.RandomHorizontalFlip(p=0.5),
            transforms.RandomRotation(degrees=15),
            transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.02),
            transforms.ToTensor(),
            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
        ])
    else:
        return transforms.Compose([
            transforms.Resize((IMG_SIZE, IMG_SIZE)),
            transforms.ToTensor(),
            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
        ])

# 4. TRAINING ENGINE
def train_one_epoch(model, loader, optimizer, criterion):
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0

    for images, labels in tqdm(loader, desc="Training", leave=False):
        images, labels = images.to(DEVICE), labels.to(DEVICE)

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

        running_loss += loss.item()
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()

    return running_loss / len(loader), correct / total

def validate_one_epoch(model, loader, criterion):
    model.eval()
    running_loss = 0.0
    all_preds = []
    all_labels = []

    with torch.no_grad():
        for images, labels in tqdm(loader, desc="Validating", leave=False):
            images, labels = images.to(DEVICE), labels.to(DEVICE)

            outputs = model(images)
            loss = criterion(outputs, labels)

            running_loss += loss.item()
            _, predicted = torch.max(outputs.data, 1)

            all_preds.extend(predicted.cpu().numpy())
            all_labels.extend(labels.cpu().numpy())

    acc = accuracy_score(all_labels, all_preds)
    f1 = f1_score(all_labels, all_preds, zero_division=0)

    return running_loss / len(loader), acc, f1

# PIPELINE 
if __name__ == '__main__':
    print(f"--- Training ConvNeXt on {DEVICE} ---")

    # 1. Load Data
    df_orig = pd.read_csv(ORIGINAL_CSV)
    df_orig['path'] = df_orig['filename'].apply(lambda x: os.path.join(ORIGINAL_IMG_DIR, x))

    df_pseudo = pd.read_csv(PSEUDO_CSV)
    # rename from 'label' to 'right_place', thank you Omar 
    df_pseudo = df_pseudo.rename(columns={'label': 'right_place'})
    df_pseudo['path'] = df_pseudo['filename'].apply(lambda x: os.path.join(PSEUDO_IMG_DIR, x))

    # Merge
    df_final = pd.concat([df_orig, df_pseudo], axis=0).reset_index(drop=True)

    # Class Weighting Calculation
    counts = df_final['right_place'].value_counts()
    print(f"Class Distribution: {counts.to_dict()}")

    n_class0 = counts.get(0, 1)
    n_class1 = counts.get(1, 1)
    # Weight Class 1 higher to force model to pay attention to it
    pos_weight = n_class0 / n_class1
    class_weights = torch.tensor([1.0, float(pos_weight)]).to(DEVICE)
    print(f"Using Class Weights: [1.0, {pos_weight:.2f}]")

    # 2. Stratified K-Fold
    skf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=42)

    for fold, (train_idx, val_idx) in enumerate(skf.split(df_final, df_final['right_place'])):
        if fold > 0: 
            break

        train_df = df_final.iloc[train_idx].reset_index(drop=True)
        val_df = df_final.iloc[val_idx].reset_index(drop=True)

        # Loaders (Workers=0 for Windows Safety)
        train_ds = ShemaghDataset(train_df, transform=get_transforms('train'))
        val_ds = ShemaghDataset(val_df, transform=get_transforms('val'))

        train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)
        val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)

        # Model Setup
        model = timm.create_model(MODEL_NAME, pretrained=True, num_classes=NUM_CLASSES)
        model = model.to(DEVICE)

        optimizer = optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=1e-2)
        criterion = nn.CrossEntropyLoss(weight=class_weights)

        best_val_f1 = 0.0

        for epoch in range(EPOCHS):
            train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion)
            val_loss, val_acc, val_f1 = validate_one_epoch(model, val_loader, criterion)

            print(f"Epoch {epoch+1:02d} | Train Loss: {train_loss:.4f} | Val F1: {val_f1:.4f}")

            if val_f1 > best_val_f1:
                best_val_f1 = val_f1
                save_path = os.path.join(OUTPUT_DIR, f"convnext_fold{fold}_best.pth")
                torch.save(model.state_dict(), save_path)

        print(f"Fold {fold+1} Finished. Best F1: {best_val_f1:.4f}")

        # Cleanup
        del model, optimizer
        torch.cuda.empty_cache()

    print(f"\nTraining Complete. Models saved to {OUTPUT_DIR}")
Generate Submission
# CONFIGURATION

YOLO_MODEL_PATH = '/kaggle/working/runs/detect/Shemagh_KFold_BuiltIn3/fold_2/weights/best.pt' 
# after some experiments, the first fold gives the hieghts validation score. 
CLS_MODEL_PATH = '/kaggle/working/ConvNext_KFold_Models/convnext_fold0_best.pth' 
TEST_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'

# Inference Settings
TTA_SCALES = [640, 800, 512]
CONF_THRES = 0.05 
IOU_WBF = 0.60  
NMS_THRES = 0.65 

# Classifier Settings
CLS_MODEL_NAME = 'convnext_tiny.fb_in22k_ft_in1k'
IMG_SIZE = 224
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')


# UTILITY FUNCTIONS
def xyxy_to_xywh(box):
    """Converts [x1, y1, x2, y2] to [x_center, y_center, w, h]."""
    x1, y1, x2, y2 = box
    w = x2 - x1
    h = y2 - y1
    x = (x1 + x2) / 2
    y = (y1 + y2) / 2
    return [x, y, w, h]

def get_inference_transforms():
    """Exact same transforms used in Validation."""
    return transforms.Compose([
        transforms.Resize((IMG_SIZE, IMG_SIZE)),
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
    ])

def apply_nms(boxes, scores, labels, iou_threshold=0.65):
    """
    Applies Non-Maximum Suppression per class.
    If overlap > threshold, keeps only the one with higher confidence.
    """
    if len(boxes) == 0:
        return [], [], []

    # Convert to tensor
    boxes_t = torch.tensor(boxes, dtype=torch.float32)
    scores_t = torch.tensor(scores, dtype=torch.float32)
    labels_t = torch.tensor(labels, dtype=torch.int64)

    keep_indices = []

    # Apply NMS for each class independently
    unique_labels = labels_t.unique()
    for lbl in unique_labels:
        mask = (labels_t == lbl)
        class_boxes = boxes_t[mask]
        class_scores = scores_t[mask]
        original_indices = torch.where(mask)[0]

        # torchvision NMS returns indices to keep
        nms_keep = ops.nms(class_boxes, class_scores, iou_threshold)

        # Map back to original indices
        keep_indices.extend(original_indices[nms_keep].tolist())

    keep_indices = sorted(keep_indices)

    return [boxes[i] for i in keep_indices], \
           [scores[i] for i in keep_indices], \
           [labels[i] for i in keep_indices]

# LOAD MODELS
print(f"Loading YOLO: {YOLO_MODEL_PATH}")
yolo_model = YOLO(YOLO_MODEL_PATH)

print(f"Loading ConvNeXt: {CLS_MODEL_PATH}")
cls_model = timm.create_model(CLS_MODEL_NAME, pretrained=False, num_classes=2)
# Load weights
state_dict = torch.load(CLS_MODEL_PATH, map_location=DEVICE)
cls_model.load_state_dict(state_dict)
cls_model.to(DEVICE)
cls_model.eval()

cls_transforms = get_inference_transforms()


# FINAL INFERENCE PIPELINE
print("\nStarting Final Inference...")

test_files = sorted([f for f in os.listdir(TEST_IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])
submission_rows = []

for filename in tqdm(test_files, desc="Processing Images"):
    img_path = os.path.join(TEST_IMG_DIR, filename)

    # YOLO TTA 
    boxes_list = []
    scores_list = []
    labels_list = []

    for scale in TTA_SCALES:
        results = yolo_model.predict(img_path, imgsz=scale, conf=CONF_THRES, augment=True, verbose=False)[0]

        if len(results.boxes) > 0:
            boxes_list.append(results.boxes.xyxyn.cpu().tolist())
            scores_list.append(results.boxes.conf.cpu().tolist())
            labels_list.append(results.boxes.cls.cpu().tolist())

    # WBF 
    if len(boxes_list) > 0:
        boxes, scores, labels = weighted_boxes_fusion(
            boxes_list, scores_list, labels_list, 
            weights=None, iou_thr=IOU_WBF, skip_box_thr=CONF_THRES
        )
    else:
        boxes, scores, labels = [], [], []

    # NMS  
    boxes, scores, labels = apply_nms(boxes, scores, labels, iou_threshold=NMS_THRES)

    # Prepare Logic & Prediction String 
    has_head = False
    has_shemagh = False
    pred_parts = []

    for b, s, l in zip(boxes, scores, labels):
        xywh = xyxy_to_xywh(b)
        x, y, w, h = xywh

        pred_parts.append(f"{int(l)} {s:.4f} {x:.4f} {y:.4f} {w:.4f} {h:.4f}")

        if int(l) == 0: has_head = True
        elif int(l) == 1: has_shemagh = True

    prediction_string = " ".join(pred_parts) if pred_parts else "-"

    # Classifier Logic 
    is_correct = 0

    if has_head or has_shemagh: # YOLO might not see all faces and shemaghs in the image
        # Load and Preprocess Image for Classifier
        # Read with OpenCV to match training pipeline
        img_cv = cv2.imread(img_path)
        img_cv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)
        img_pil = Image.fromarray(img_cv)

        input_tensor = cls_transforms(img_pil).unsqueeze(0).to(DEVICE)

        with torch.no_grad():
            outputs = cls_model(input_tensor)
            # Get class with highest score (0 or 1)
            probs = torch.softmax(outputs, dim=1)
            pred_cls = torch.argmax(probs, dim=1).item()

            # Assuming Class 1 is "Right Place"
            is_correct = pred_cls

    submission_rows.append({
        "filename": filename,
        "right_place": is_correct,
        "prediction_string": prediction_string
    })

# 5. SAVE SUBMISSION
df_sub = pd.DataFrame(submission_rows)
df_sub.to_csv("submission.csv", index=False)

print("Submission saved to submission.csv")
print(f"Total Positive Predictions: {df_sub['right_place'].sum()}")
Scores may vary due to the difference in GPU because training was done in a local GPU
T4: private score 0.862 | public score 0.904.
P100: private score 0.863 | public score 0.878
RTX 5070: private score 0.860 | public score 0.884