{
  "id": 674138,
  "title": "3rd place solution",
  "url": "/competitions/dal-shemagh-detection-challenge/discussion/674138",
  "author_name": "hassan algamdi",
  "post_date": "2026-02-18T22:09:02.459000",
  "votes": 4,
  "comment_count": 3,
  "views": 0,
  "content": "<p>السلام عليكم، رمضان مبارك جميعا واعاننا الله على صيامه وقيامه</p>\n<p>First of all, thanks to the organizers for this challenging competition. </p>\n<p>Our solution is based on a <strong>Two-Stage Pipeline</strong>: Object Detection using <strong>YOLO11m</strong> and a <strong>ConvNeXt</strong> classifier to determine the \"Right Place\" status, supported by Test-Time Augmentation (TTA) and Pseudo Labeling.</p>\n<h2>1. Challenges:</h2>\n<ul>\n<li><strong>Domain Shift.</strong> </li>\n<li><strong>Class Imbalance:</strong> only 5 samples of True \"Right Place\" class.</li>\n</ul>\n<h2>2. Stage 1: Object Detection (YOLO11)</h2>\n<p>We used <strong>YOLO11m</strong> (Medium) as our primary detector.</p>\n<h3>Color Augmentation</h3>\n<p>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.</p>\n<ul>\n<li><strong>HSV Saturation (<code>hsv_s</code>):</strong> Increased to <code>0.8</code>.</li>\n<li><strong>HSV Value (<code>hsv_v</code>):</strong> Increased to <code>0.6</code>.</li>\n</ul>\n<h3>Training Setup</h3>\n<ul>\n<li><strong>Folds:</strong> Stratified K-Fold (5 Folds).</li>\n<li><strong>Image Size:</strong> 640.</li>\n<li><strong>Epochs:</strong> 200 per fold.</li>\n</ul>\n<h2>3. Stage 2: Classifier (ConvNeXt)</h2>\n<p>Initially, we tried geometric heuristics (Intersection over Head, Symmetry scores), then we switched to a classifier approach.</p>\n<ul>\n<li><strong>Model:</strong> <code>convnext_tiny.fb_in22k_ft_in1k</code> (via <code>timm</code>).</li>\n<li><strong>Input:</strong> We passed the images where <code>Head</code> or <code>Shemagh</code> were detected to the classifier.</li>\n<li><strong>Resolution:</strong> 224x224.</li>\n</ul>\n<h3>Pseudo Labeling</h3>\n<p>Since we had very few \"True\" samples for the <code>right_place</code> class, we:</p>\n<ol>\n<li>Trained a model on the labeled data.</li>\n<li>Ran inference on the Test Set.</li>\n<li>Filtered high confidence \"Right Place\" predictions.</li>\n<li>Added these Pseudo Labels back to the training set.</li>\n<li>Retrained ConvNeXt with a weighted loss function to handle the remaining imbalance.</li>\n</ol>\n<h2>4. Inference Pipeline</h2>\n<p>Our inference pipeline was designed to maximize recall and precision using ensemble techniques.</p>\n<ol>\n<li><strong>Multi-Scale TTA:</strong> <code>[640, 800, 512]</code>.</li>\n<li><strong>Weighted Boxes Fusion (WBF):</strong> Instead of NMS, we used WBF (<code>IoU=0.60</code>) to average the coordinates of overlapping boxes from the TTA steps, resulting in tighter bounding boxes.</li>\n<li><strong>Class-wise NMS:</strong> 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 &gt; 65%.</li>\n</ol>\n<pre><code>!pip install -q ultralytics ensemble_boxes\n\nimport os\nimport pandas as pd\nimport numpy as np\nfrom ultralytics import YOLO\nimport yaml\nfrom tqdm.notebook import tqdm\nimport cv2\nimport shutil\nimport glob\nfrom pathlib import Path\nfrom sklearn.model_selection import train_test_split, StratifiedKFold, KFold\nfrom sklearn.metrics import f1_score, accuracy_score\nimport joblib\nfrom ensemble_boxes import weighted_boxes_fusion\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms, ops\nimport matplotlib.pyplot as plt\nimport random\nimport json\nimport base64\nfrom io import BytesIO\nfrom IPython.display import HTML, display\nimport timm\nfrom PIL import Image\n</code></pre>\n<p>Training YOLO using k fold 5</p>\n<pre><code># Global Seed Setup \ndef set_all_seeds(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    # Ensures deterministic algorithms are used where possible\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n\nset_all_seeds(42)\n\n# Setup Absolute Paths \nWORK_DIR = Path('/kaggle/input/dal-shemagh-detection-challenge').absolute()\nimages_path = WORK_DIR / 'images/train' \nall_images = sorted([f.name for f in images_path.glob('*') if f.suffix.lower() in ['.jpg', '.jpeg', '.png']])\nk = 5\nresults = {}\n\n# random_state=42 ensures the folds are always the same\nkf = KFold(n_splits=k, shuffle=True, random_state=42)\n\n# K-Fold Training Loop \nfor fold, (train_idx, val_idx) in enumerate(kf.split(all_images)):\n    # after some tests using the same random seed, 3ed fold usally gives the hieghst validation score. \n    if fold != 2: \n        continue\n\n    train_list = [str(images_path / all_images[i]) for i in train_idx]\n    val_list = [str(images_path / all_images[i]) for i in val_idx]\n\n    with open(f\"train_f{fold}.txt\", \"w\") as f: f.write(\"\\n\".join(train_list))\n    with open(f\"val_f{fold}.txt\", \"w\") as f: f.write(\"\\n\".join(val_list))\n\n    yaml_path = os.path.abspath(f\"fold_{fold}.yaml\")\n    with open(yaml_path, \"w\") as f:\n        yaml.dump({\n            'path': str(WORK_DIR),\n            'train': os.path.abspath(f\"train_f{fold}.txt\"),\n            'val': os.path.abspath(f\"val_f{fold}.txt\"),\n            'nc': 2, \n            'names': ['Face', 'Shemagh']\n        }, f)\n\n    # Initialize and Train\n    torch.cuda.empty_cache()\n    model = YOLO('yolo11m.pt') \n\n    model.train(\n        data=yaml_path,\n        epochs=200,\n        imgsz=640,          \n        batch=16,              \n        device=1,\n        seed=42,\n        workers=4,              \n        deterministic=True,    \n\n        hsv_h=0.015,   \n        hsv_s=0.8,     \n        hsv_v=0.6,     \n        scale=0.8,\n\n        mosaic=1.0,    \n        mixup=0.1,     \n        copy_paste=0.3,\n        close_mosaic=50, \n        erasing=0.3,\n\n        optimizer='SGD',\n        lr0=0.001,\n        project='Shemagh_KFold_BuiltIn3',\n        name=f'fold_{fold}'\n\n    )\n\n\n\n    stats = model.val()\n    if stats:\n        results[fold] = stats.results_dict\n    with open(\"kfold_results_backup.json\", \"w\") as f:\n        json.dump(results, f)\n</code></pre>\n<p>Use YOLO and heuristics To generate pseudo labels</p>\n<pre><code># CONFIGURATION\nMODEL_PATH = '/kaggle/working/runs/detect/Shemagh_KFold_BuiltIn3/fold_2/weights/best.pt'\nTEST_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\n\n# Inference Hyperparameters\nTTA_SCALES = [640, 800, 512]\nCONF_THRES = 0.40   # Lowered to capture all possible detections\nIOU_WBF = 0.50      # Aggressive merging for multi-scale consensus\nW_SYM = 0.8\nW_DX = 0.2\nFINAL_THRESH = 0.45 \n\n# Pseudo Labeling Thresholds\nSURE_CORRECT_THRESH = 0.85   \nSURE_INCORRECT_THRESH = 0.15 \n\n# HYBRID UTILITY FUNCTIONS\ndef xyxy_to_xywh(box):\n    x1, y1, x2, y2 = box\n    return [(x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1]\n\ndef clean_duplicate_shemaghs(shemaghs, overlap_threshold=0.50):\n    if not shemaghs: return []\n    cleaned = []\n    sorted_s = sorted(shemaghs, key=lambda x: x[2] * x[3], reverse=True)\n    for s_big in sorted_s:\n        keep = True\n        bx, by, bw, bh = s_big\n        for s_checked in cleaned:\n            cx, cy, cw, ch = s_checked\n            ix1, iy1 = max(bx-bw/2, cx-cw/2), max(by-bh/2, cy-ch/2)\n            ix2, iy2 = min(bx+bw/2, cx+cw/2), min(by+bh/2, cy+ch/2)\n            inter = max(0, ix2-ix1) * max(0, iy2-iy1)\n            if inter / (bw * bh) &gt; overlap_threshold:\n                keep = False\n                break\n        if keep: cleaned.append(s_big)\n    return cleaned\n\ndef calculate_hybrid_features(head, shemagh):\n    hx, hy, hw, hh = head\n    sx, sy, sw, sh = shemagh\n    ix1, iy1 = max(hx-hw/2, sx-sw/2), max(hy-hh/2, sy-sh/2)\n    ix2, iy2 = min(hx+hw/2, sx+sw/2), min(hy+hh/2, sy+sh/2)\n    inter = max(0, ix2-ix1) * max(0, iy2-iy1)\n    ioh = inter / (hw * hh) if (hw * hh) &gt; 0 else 0.0\n    half_area = (hw * hh) / 2\n    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))\n    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))\n    symmetry = abs((l_inter / half_area) - (r_inter / half_area)) if half_area &gt; 0 else 1.0\n    h_ymin, s_ymin = hy - hh/2, sy - sh/2\n    return {\n        'ioh': ioh, 'symmetry': symmetry, 'dx_norm': abs(sx - hx) / hw,\n        'starts_at_top': s_ymin &lt; (hy - hh/4), \n        'is_above': s_ymin &lt; h_ymin,\n        'head_flat': hh &lt; (hw * 0.9)\n    }\n\n\n# INFERENCE LOOP\ntest_files = sorted([f for f in os.listdir(TEST_IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])\nmodel = YOLO(MODEL_PATH)\nall_results = []\n\nprint(f\"Running inference on {len(test_files)} images...\")\n\nfor filename in tqdm(test_files):\n    img_path = os.path.join(TEST_IMG_DIR, filename)\n    img_orig = cv2.imread(img_path)\n    img_orig = cv2.cvtColor(img_orig, cv2.COLOR_BGR2RGB)\n    h_img, w_img, _ = img_orig.shape\n\n    # TTA Detection\n    b_list, s_list, l_list, raw_viz_boxes = [], [], [], []\n    for scale in TTA_SCALES:\n        res = model.predict(img_path, imgsz=scale, conf=CONF_THRES, augment=True, verbose=False)[0]\n        if len(res.boxes) &gt; 0:\n            bn, cn, cl = res.boxes.xyxyn.cpu().tolist(), res.boxes.conf.cpu().tolist(), res.boxes.cls.cpu().tolist()\n            b_list.append(bn); s_list.append(cn); l_list.append(cl)\n            for b, s, l in zip(bn, cn, cl):\n                raw_viz_boxes.append({'box': b, 'cls': int(l)})\n\n    # Weighted Boxes Fusion\n    if b_list:\n        boxes, scores, labels = weighted_boxes_fusion(b_list, s_list, l_list, weights=None, iou_thr=IOU_WBF, skip_box_thr=CONF_THRES)\n    else:\n        boxes, scores, labels = [], [], []\n\n    # Apply Logic\n    raw_heads = [xyxy_to_xywh(b) for b, l in zip(boxes, labels) if int(l) == 0]\n    raw_shemaghs = [xyxy_to_xywh(b) for b, l in zip(boxes, labels) if int(l) == 1]\n    cleaned_shemaghs = clean_duplicate_shemaghs(raw_shemaghs)\n\n    is_correct = 0\n    max_img_score = -1.0 # Default if no valid pair found\n\n    if raw_heads and cleaned_shemaghs:\n        for h in raw_heads:\n            for s in cleaned_shemaghs:\n                f = calculate_hybrid_features(h, s)\n                if not f['is_above']: continue\n                if f['head_flat'] and f['ioh'] &lt; 0.80: continue\n\n                score = f['ioh'] - (W_SYM * f['symmetry']) - (W_DX * f['dx_norm'])\n                if f['starts_at_top']: score += 0.05\n                if score &gt; max_img_score: max_img_score = score\n\n        if max_img_score &gt; FINAL_THRESH:\n            is_correct = 1\n\n    # Store Data\n    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}\" \n                  for b, s, l in zip(boxes, scores, labels)]\n    pred_str = \" \".join(pred_parts) if pred_parts else \"-\"\n\n    all_results.append({\n        \"filename\": filename,\n        \"right_place\": is_correct,\n        \"prediction_string\": pred_str,\n        \"max_img_score\": max_img_score\n    })\n\n# EXPORTING DATA\ndf_all = pd.DataFrame(all_results)\n\n\ncertain_labels = []\nfor idx, row in df_all.iterrows():\n    if row['max_img_score'] &gt;= SURE_CORRECT_THRESH:\n        certain_labels.append({\"filename\": row['filename'], \"label\": 1})\n    elif row['max_img_score'] &lt;= SURE_INCORRECT_THRESH:\n        certain_labels.append({\"filename\": row['filename'], \"label\": 0})\n\ndf_pseudo = pd.DataFrame(certain_labels)\n\n# Filter for Label 1 \ndf_correct = df_pseudo[df_pseudo['label'] == 1].copy()\n\n# Save to a new file\noutput_csv = 'correct_images_only.csv'\ndf_correct.to_csv(output_csv, index=False)\n\n\nprint(f\"Filtered {len(df_correct)} correct images out of {len(df)} total pseudo labels.\")\nprint(f\"Saved to: {output_csv}\")\n</code></pre>\n<p>Training ConvNext for 'right_place' using Pseudo labeling</p>\n<p><strong>Fun fact: we got 0.848 private score using only heuristics.</strong></p>\n<pre><code># CONFIGURATION\nORIGINAL_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/train'\nORIGINAL_CSV = '/kaggle/input/dal-shemagh-detection-challenge/train_labels.csv'\nPSEUDO_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\nPSEUDO_CSV = '/kaggle/working/correct_images_only.csv'\nMODEL_NAME = 'convnext_tiny.fb_in22k_ft_in1k'\nNUM_CLASSES = 2\nIMG_SIZE = 224\nBATCH_SIZE = 16\nEPOCHS = 15\nLEARNING_RATE = 5e-5\nN_FOLDS = 5\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nOUTPUT_DIR = 'ConvNext_KFold_Models'\n\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\n# DATASET CLASS\nclass ShemaghDataset(Dataset):\n    def __init__(self, df, transform=None):\n        self.df = df\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        img_path = row['path']\n        label = int(row['right_place'])\n\n        # Read Image using OpenCV \n        image = cv2.imread(img_path)\n        if image is None:\n            # Fallback (black image) to prevent crash\n            image = np.zeros((IMG_SIZE, IMG_SIZE, 3), dtype=np.uint8)\n\n        # Convert BGR -&gt; RGB -&gt; PIL Image \n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        image = Image.fromarray(image)\n\n        if self.transform:\n            image = self.transform(image)\n\n        return image, torch.tensor(label, dtype=torch.long)\n\n\n# AUGMENTATIONS\ndef get_transforms(data='train'):\n    if data == 'train':\n        return transforms.Compose([\n            transforms.Resize((IMG_SIZE, IMG_SIZE)),\n            transforms.RandomHorizontalFlip(p=0.5),\n            transforms.RandomRotation(degrees=15),\n            transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.02),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n    else:\n        return transforms.Compose([\n            transforms.Resize((IMG_SIZE, IMG_SIZE)),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n\n# 4. TRAINING ENGINE\ndef train_one_epoch(model, loader, optimizer, criterion):\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n\n    for images, labels in tqdm(loader, desc=\"Training\", leave=False):\n        images, labels = images.to(DEVICE), labels.to(DEVICE)\n\n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n\n        running_loss += loss.item()\n        _, predicted = torch.max(outputs.data, 1)\n        total += labels.size(0)\n        correct += (predicted == labels).sum().item()\n\n    return running_loss / len(loader), correct / total\n\ndef validate_one_epoch(model, loader, criterion):\n    model.eval()\n    running_loss = 0.0\n    all_preds = []\n    all_labels = []\n\n    with torch.no_grad():\n        for images, labels in tqdm(loader, desc=\"Validating\", leave=False):\n            images, labels = images.to(DEVICE), labels.to(DEVICE)\n\n            outputs = model(images)\n            loss = criterion(outputs, labels)\n\n            running_loss += loss.item()\n            _, predicted = torch.max(outputs.data, 1)\n\n            all_preds.extend(predicted.cpu().numpy())\n            all_labels.extend(labels.cpu().numpy())\n\n    acc = accuracy_score(all_labels, all_preds)\n    f1 = f1_score(all_labels, all_preds, zero_division=0)\n\n    return running_loss / len(loader), acc, f1\n\n# PIPELINE \nif __name__ == '__main__':\n    print(f\"--- Training ConvNeXt on {DEVICE} ---\")\n\n    # 1. Load Data\n    df_orig = pd.read_csv(ORIGINAL_CSV)\n    df_orig['path'] = df_orig['filename'].apply(lambda x: os.path.join(ORIGINAL_IMG_DIR, x))\n\n    df_pseudo = pd.read_csv(PSEUDO_CSV)\n    # rename from 'label' to 'right_place', thank you Omar \n    df_pseudo = df_pseudo.rename(columns={'label': 'right_place'})\n    df_pseudo['path'] = df_pseudo['filename'].apply(lambda x: os.path.join(PSEUDO_IMG_DIR, x))\n\n    # Merge\n    df_final = pd.concat([df_orig, df_pseudo], axis=0).reset_index(drop=True)\n\n    # Class Weighting Calculation\n    counts = df_final['right_place'].value_counts()\n    print(f\"Class Distribution: {counts.to_dict()}\")\n\n    n_class0 = counts.get(0, 1)\n    n_class1 = counts.get(1, 1)\n    # Weight Class 1 higher to force model to pay attention to it\n    pos_weight = n_class0 / n_class1\n    class_weights = torch.tensor([1.0, float(pos_weight)]).to(DEVICE)\n    print(f\"Using Class Weights: [1.0, {pos_weight:.2f}]\")\n\n    # 2. Stratified K-Fold\n    skf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=42)\n\n    for fold, (train_idx, val_idx) in enumerate(skf.split(df_final, df_final['right_place'])):\n        if fold &gt; 0: \n            break\n\n        train_df = df_final.iloc[train_idx].reset_index(drop=True)\n        val_df = df_final.iloc[val_idx].reset_index(drop=True)\n\n        # Loaders (Workers=0 for Windows Safety)\n        train_ds = ShemaghDataset(train_df, transform=get_transforms('train'))\n        val_ds = ShemaghDataset(val_df, transform=get_transforms('val'))\n\n        train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)\n        val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)\n\n        # Model Setup\n        model = timm.create_model(MODEL_NAME, pretrained=True, num_classes=NUM_CLASSES)\n        model = model.to(DEVICE)\n\n        optimizer = optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=1e-2)\n        criterion = nn.CrossEntropyLoss(weight=class_weights)\n\n        best_val_f1 = 0.0\n\n        for epoch in range(EPOCHS):\n            train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion)\n            val_loss, val_acc, val_f1 = validate_one_epoch(model, val_loader, criterion)\n\n            print(f\"Epoch {epoch+1:02d} | Train Loss: {train_loss:.4f} | Val F1: {val_f1:.4f}\")\n\n            if val_f1 &gt; best_val_f1:\n                best_val_f1 = val_f1\n                save_path = os.path.join(OUTPUT_DIR, f\"convnext_fold{fold}_best.pth\")\n                torch.save(model.state_dict(), save_path)\n\n        print(f\"Fold {fold+1} Finished. Best F1: {best_val_f1:.4f}\")\n\n        # Cleanup\n        del model, optimizer\n        torch.cuda.empty_cache()\n\n    print(f\"\\nTraining Complete. Models saved to {OUTPUT_DIR}\")\n</code></pre>\n<p>Generate Submission</p>\n<pre><code># CONFIGURATION\n\nYOLO_MODEL_PATH = '/kaggle/working/runs/detect/Shemagh_KFold_BuiltIn3/fold_2/weights/best.pt' \n# after some experiments, the first fold gives the hieghts validation score. \nCLS_MODEL_PATH = '/kaggle/working/ConvNext_KFold_Models/convnext_fold0_best.pth' \nTEST_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\n\n# Inference Settings\nTTA_SCALES = [640, 800, 512]\nCONF_THRES = 0.05 \nIOU_WBF = 0.60  \nNMS_THRES = 0.65 \n\n# Classifier Settings\nCLS_MODEL_NAME = 'convnext_tiny.fb_in22k_ft_in1k'\nIMG_SIZE = 224\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n\n\n# UTILITY FUNCTIONS\ndef xyxy_to_xywh(box):\n    \"\"\"Converts [x1, y1, x2, y2] to [x_center, y_center, w, h].\"\"\"\n    x1, y1, x2, y2 = box\n    w = x2 - x1\n    h = y2 - y1\n    x = (x1 + x2) / 2\n    y = (y1 + y2) / 2\n    return [x, y, w, h]\n\ndef get_inference_transforms():\n    \"\"\"Exact same transforms used in Validation.\"\"\"\n    return transforms.Compose([\n        transforms.Resize((IMG_SIZE, IMG_SIZE)),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n    ])\n\ndef apply_nms(boxes, scores, labels, iou_threshold=0.65):\n    \"\"\"\n    Applies Non-Maximum Suppression per class.\n    If overlap &gt; threshold, keeps only the one with higher confidence.\n    \"\"\"\n    if len(boxes) == 0:\n        return [], [], []\n\n    # Convert to tensor\n    boxes_t = torch.tensor(boxes, dtype=torch.float32)\n    scores_t = torch.tensor(scores, dtype=torch.float32)\n    labels_t = torch.tensor(labels, dtype=torch.int64)\n\n    keep_indices = []\n\n    # Apply NMS for each class independently\n    unique_labels = labels_t.unique()\n    for lbl in unique_labels:\n        mask = (labels_t == lbl)\n        class_boxes = boxes_t[mask]\n        class_scores = scores_t[mask]\n        original_indices = torch.where(mask)[0]\n\n        # torchvision NMS returns indices to keep\n        nms_keep = ops.nms(class_boxes, class_scores, iou_threshold)\n\n        # Map back to original indices\n        keep_indices.extend(original_indices[nms_keep].tolist())\n\n    keep_indices = sorted(keep_indices)\n\n    return [boxes[i] for i in keep_indices], \\\n           [scores[i] for i in keep_indices], \\\n           [labels[i] for i in keep_indices]\n\n# LOAD MODELS\nprint(f\"Loading YOLO: {YOLO_MODEL_PATH}\")\nyolo_model = YOLO(YOLO_MODEL_PATH)\n\nprint(f\"Loading ConvNeXt: {CLS_MODEL_PATH}\")\ncls_model = timm.create_model(CLS_MODEL_NAME, pretrained=False, num_classes=2)\n# Load weights\nstate_dict = torch.load(CLS_MODEL_PATH, map_location=DEVICE)\ncls_model.load_state_dict(state_dict)\ncls_model.to(DEVICE)\ncls_model.eval()\n\ncls_transforms = get_inference_transforms()\n\n\n# FINAL INFERENCE PIPELINE\nprint(\"\\nStarting Final Inference...\")\n\ntest_files = sorted([f for f in os.listdir(TEST_IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])\nsubmission_rows = []\n\nfor filename in tqdm(test_files, desc=\"Processing Images\"):\n    img_path = os.path.join(TEST_IMG_DIR, filename)\n\n    # YOLO TTA \n    boxes_list = []\n    scores_list = []\n    labels_list = []\n\n    for scale in TTA_SCALES:\n        results = yolo_model.predict(img_path, imgsz=scale, conf=CONF_THRES, augment=True, verbose=False)[0]\n\n        if len(results.boxes) &gt; 0:\n            boxes_list.append(results.boxes.xyxyn.cpu().tolist())\n            scores_list.append(results.boxes.conf.cpu().tolist())\n            labels_list.append(results.boxes.cls.cpu().tolist())\n\n    # WBF \n    if len(boxes_list) &gt; 0:\n        boxes, scores, labels = weighted_boxes_fusion(\n            boxes_list, scores_list, labels_list, \n            weights=None, iou_thr=IOU_WBF, skip_box_thr=CONF_THRES\n        )\n    else:\n        boxes, scores, labels = [], [], []\n\n    # NMS  \n    boxes, scores, labels = apply_nms(boxes, scores, labels, iou_threshold=NMS_THRES)\n\n    # Prepare Logic &amp; Prediction String \n    has_head = False\n    has_shemagh = False\n    pred_parts = []\n\n    for b, s, l in zip(boxes, scores, labels):\n        xywh = xyxy_to_xywh(b)\n        x, y, w, h = xywh\n\n        pred_parts.append(f\"{int(l)} {s:.4f} {x:.4f} {y:.4f} {w:.4f} {h:.4f}\")\n\n        if int(l) == 0: has_head = True\n        elif int(l) == 1: has_shemagh = True\n\n    prediction_string = \" \".join(pred_parts) if pred_parts else \"-\"\n\n    # Classifier Logic \n    is_correct = 0\n\n    if has_head or has_shemagh: # YOLO might not see all faces and shemaghs in the image\n        # Load and Preprocess Image for Classifier\n        # Read with OpenCV to match training pipeline\n        img_cv = cv2.imread(img_path)\n        img_cv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)\n        img_pil = Image.fromarray(img_cv)\n\n        input_tensor = cls_transforms(img_pil).unsqueeze(0).to(DEVICE)\n\n        with torch.no_grad():\n            outputs = cls_model(input_tensor)\n            # Get class with highest score (0 or 1)\n            probs = torch.softmax(outputs, dim=1)\n            pred_cls = torch.argmax(probs, dim=1).item()\n\n            # Assuming Class 1 is \"Right Place\"\n            is_correct = pred_cls\n\n    submission_rows.append({\n        \"filename\": filename,\n        \"right_place\": is_correct,\n        \"prediction_string\": prediction_string\n    })\n\n# 5. SAVE SUBMISSION\ndf_sub = pd.DataFrame(submission_rows)\ndf_sub.to_csv(\"submission.csv\", index=False)\n\nprint(\"Submission saved to submission.csv\")\nprint(f\"Total Positive Predictions: {df_sub['right_place'].sum()}\")\n</code></pre>\n<h3>Scores may vary due to the difference in GPU because training was done in a local GPU</h3>\n<ul>\n<li>T4: private score 0.862 | public score 0.904. </li>\n<li>P100: private score 0.863 | public score 0.878 </li>\n<li>RTX 5070: private score 0.860 | public score 0.884</li>\n</ul>",
  "messages": [
    {
      "id": 3407675,
      "postDate": "2026-02-18T22:09:02.460Z",
      "content": "<p>السلام عليكم، رمضان مبارك جميعا واعاننا الله على صيامه وقيامه</p>\n<p>First of all, thanks to the organizers for this challenging competition. </p>\n<p>Our solution is based on a <strong>Two-Stage Pipeline</strong>: Object Detection using <strong>YOLO11m</strong> and a <strong>ConvNeXt</strong> classifier to determine the \"Right Place\" status, supported by Test-Time Augmentation (TTA) and Pseudo Labeling.</p>\n<h2>1. Challenges:</h2>\n<ul>\n<li><strong>Domain Shift.</strong> </li>\n<li><strong>Class Imbalance:</strong> only 5 samples of True \"Right Place\" class.</li>\n</ul>\n<h2>2. Stage 1: Object Detection (YOLO11)</h2>\n<p>We used <strong>YOLO11m</strong> (Medium) as our primary detector.</p>\n<h3>Color Augmentation</h3>\n<p>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.</p>\n<ul>\n<li><strong>HSV Saturation (<code>hsv_s</code>):</strong> Increased to <code>0.8</code>.</li>\n<li><strong>HSV Value (<code>hsv_v</code>):</strong> Increased to <code>0.6</code>.</li>\n</ul>\n<h3>Training Setup</h3>\n<ul>\n<li><strong>Folds:</strong> Stratified K-Fold (5 Folds).</li>\n<li><strong>Image Size:</strong> 640.</li>\n<li><strong>Epochs:</strong> 200 per fold.</li>\n</ul>\n<h2>3. Stage 2: Classifier (ConvNeXt)</h2>\n<p>Initially, we tried geometric heuristics (Intersection over Head, Symmetry scores), then we switched to a classifier approach.</p>\n<ul>\n<li><strong>Model:</strong> <code>convnext_tiny.fb_in22k_ft_in1k</code> (via <code>timm</code>).</li>\n<li><strong>Input:</strong> We passed the images where <code>Head</code> or <code>Shemagh</code> were detected to the classifier.</li>\n<li><strong>Resolution:</strong> 224x224.</li>\n</ul>\n<h3>Pseudo Labeling</h3>\n<p>Since we had very few \"True\" samples for the <code>right_place</code> class, we:</p>\n<ol>\n<li>Trained a model on the labeled data.</li>\n<li>Ran inference on the Test Set.</li>\n<li>Filtered high confidence \"Right Place\" predictions.</li>\n<li>Added these Pseudo Labels back to the training set.</li>\n<li>Retrained ConvNeXt with a weighted loss function to handle the remaining imbalance.</li>\n</ol>\n<h2>4. Inference Pipeline</h2>\n<p>Our inference pipeline was designed to maximize recall and precision using ensemble techniques.</p>\n<ol>\n<li><strong>Multi-Scale TTA:</strong> <code>[640, 800, 512]</code>.</li>\n<li><strong>Weighted Boxes Fusion (WBF):</strong> Instead of NMS, we used WBF (<code>IoU=0.60</code>) to average the coordinates of overlapping boxes from the TTA steps, resulting in tighter bounding boxes.</li>\n<li><strong>Class-wise NMS:</strong> 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 &gt; 65%.</li>\n</ol>\n<pre><code>!pip install -q ultralytics ensemble_boxes\n\nimport os\nimport pandas as pd\nimport numpy as np\nfrom ultralytics import YOLO\nimport yaml\nfrom tqdm.notebook import tqdm\nimport cv2\nimport shutil\nimport glob\nfrom pathlib import Path\nfrom sklearn.model_selection import train_test_split, StratifiedKFold, KFold\nfrom sklearn.metrics import f1_score, accuracy_score\nimport joblib\nfrom ensemble_boxes import weighted_boxes_fusion\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms, ops\nimport matplotlib.pyplot as plt\nimport random\nimport json\nimport base64\nfrom io import BytesIO\nfrom IPython.display import HTML, display\nimport timm\nfrom PIL import Image\n</code></pre>\n<p>Training YOLO using k fold 5</p>\n<pre><code># Global Seed Setup \ndef set_all_seeds(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    # Ensures deterministic algorithms are used where possible\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n\nset_all_seeds(42)\n\n# Setup Absolute Paths \nWORK_DIR = Path('/kaggle/input/dal-shemagh-detection-challenge').absolute()\nimages_path = WORK_DIR / 'images/train' \nall_images = sorted([f.name for f in images_path.glob('*') if f.suffix.lower() in ['.jpg', '.jpeg', '.png']])\nk = 5\nresults = {}\n\n# random_state=42 ensures the folds are always the same\nkf = KFold(n_splits=k, shuffle=True, random_state=42)\n\n# K-Fold Training Loop \nfor fold, (train_idx, val_idx) in enumerate(kf.split(all_images)):\n    # after some tests using the same random seed, 3ed fold usally gives the hieghst validation score. \n    if fold != 2: \n        continue\n\n    train_list = [str(images_path / all_images[i]) for i in train_idx]\n    val_list = [str(images_path / all_images[i]) for i in val_idx]\n\n    with open(f\"train_f{fold}.txt\", \"w\") as f: f.write(\"\\n\".join(train_list))\n    with open(f\"val_f{fold}.txt\", \"w\") as f: f.write(\"\\n\".join(val_list))\n\n    yaml_path = os.path.abspath(f\"fold_{fold}.yaml\")\n    with open(yaml_path, \"w\") as f:\n        yaml.dump({\n            'path': str(WORK_DIR),\n            'train': os.path.abspath(f\"train_f{fold}.txt\"),\n            'val': os.path.abspath(f\"val_f{fold}.txt\"),\n            'nc': 2, \n            'names': ['Face', 'Shemagh']\n        }, f)\n\n    # Initialize and Train\n    torch.cuda.empty_cache()\n    model = YOLO('yolo11m.pt') \n\n    model.train(\n        data=yaml_path,\n        epochs=200,\n        imgsz=640,          \n        batch=16,              \n        device=1,\n        seed=42,\n        workers=4,              \n        deterministic=True,    \n\n        hsv_h=0.015,   \n        hsv_s=0.8,     \n        hsv_v=0.6,     \n        scale=0.8,\n\n        mosaic=1.0,    \n        mixup=0.1,     \n        copy_paste=0.3,\n        close_mosaic=50, \n        erasing=0.3,\n\n        optimizer='SGD',\n        lr0=0.001,\n        project='Shemagh_KFold_BuiltIn3',\n        name=f'fold_{fold}'\n\n    )\n\n\n\n    stats = model.val()\n    if stats:\n        results[fold] = stats.results_dict\n    with open(\"kfold_results_backup.json\", \"w\") as f:\n        json.dump(results, f)\n</code></pre>\n<p>Use YOLO and heuristics To generate pseudo labels</p>\n<pre><code># CONFIGURATION\nMODEL_PATH = '/kaggle/working/runs/detect/Shemagh_KFold_BuiltIn3/fold_2/weights/best.pt'\nTEST_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\n\n# Inference Hyperparameters\nTTA_SCALES = [640, 800, 512]\nCONF_THRES = 0.40   # Lowered to capture all possible detections\nIOU_WBF = 0.50      # Aggressive merging for multi-scale consensus\nW_SYM = 0.8\nW_DX = 0.2\nFINAL_THRESH = 0.45 \n\n# Pseudo Labeling Thresholds\nSURE_CORRECT_THRESH = 0.85   \nSURE_INCORRECT_THRESH = 0.15 \n\n# HYBRID UTILITY FUNCTIONS\ndef xyxy_to_xywh(box):\n    x1, y1, x2, y2 = box\n    return [(x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1]\n\ndef clean_duplicate_shemaghs(shemaghs, overlap_threshold=0.50):\n    if not shemaghs: return []\n    cleaned = []\n    sorted_s = sorted(shemaghs, key=lambda x: x[2] * x[3], reverse=True)\n    for s_big in sorted_s:\n        keep = True\n        bx, by, bw, bh = s_big\n        for s_checked in cleaned:\n            cx, cy, cw, ch = s_checked\n            ix1, iy1 = max(bx-bw/2, cx-cw/2), max(by-bh/2, cy-ch/2)\n            ix2, iy2 = min(bx+bw/2, cx+cw/2), min(by+bh/2, cy+ch/2)\n            inter = max(0, ix2-ix1) * max(0, iy2-iy1)\n            if inter / (bw * bh) &gt; overlap_threshold:\n                keep = False\n                break\n        if keep: cleaned.append(s_big)\n    return cleaned\n\ndef calculate_hybrid_features(head, shemagh):\n    hx, hy, hw, hh = head\n    sx, sy, sw, sh = shemagh\n    ix1, iy1 = max(hx-hw/2, sx-sw/2), max(hy-hh/2, sy-sh/2)\n    ix2, iy2 = min(hx+hw/2, sx+sw/2), min(hy+hh/2, sy+sh/2)\n    inter = max(0, ix2-ix1) * max(0, iy2-iy1)\n    ioh = inter / (hw * hh) if (hw * hh) &gt; 0 else 0.0\n    half_area = (hw * hh) / 2\n    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))\n    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))\n    symmetry = abs((l_inter / half_area) - (r_inter / half_area)) if half_area &gt; 0 else 1.0\n    h_ymin, s_ymin = hy - hh/2, sy - sh/2\n    return {\n        'ioh': ioh, 'symmetry': symmetry, 'dx_norm': abs(sx - hx) / hw,\n        'starts_at_top': s_ymin &lt; (hy - hh/4), \n        'is_above': s_ymin &lt; h_ymin,\n        'head_flat': hh &lt; (hw * 0.9)\n    }\n\n\n# INFERENCE LOOP\ntest_files = sorted([f for f in os.listdir(TEST_IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])\nmodel = YOLO(MODEL_PATH)\nall_results = []\n\nprint(f\"Running inference on {len(test_files)} images...\")\n\nfor filename in tqdm(test_files):\n    img_path = os.path.join(TEST_IMG_DIR, filename)\n    img_orig = cv2.imread(img_path)\n    img_orig = cv2.cvtColor(img_orig, cv2.COLOR_BGR2RGB)\n    h_img, w_img, _ = img_orig.shape\n\n    # TTA Detection\n    b_list, s_list, l_list, raw_viz_boxes = [], [], [], []\n    for scale in TTA_SCALES:\n        res = model.predict(img_path, imgsz=scale, conf=CONF_THRES, augment=True, verbose=False)[0]\n        if len(res.boxes) &gt; 0:\n            bn, cn, cl = res.boxes.xyxyn.cpu().tolist(), res.boxes.conf.cpu().tolist(), res.boxes.cls.cpu().tolist()\n            b_list.append(bn); s_list.append(cn); l_list.append(cl)\n            for b, s, l in zip(bn, cn, cl):\n                raw_viz_boxes.append({'box': b, 'cls': int(l)})\n\n    # Weighted Boxes Fusion\n    if b_list:\n        boxes, scores, labels = weighted_boxes_fusion(b_list, s_list, l_list, weights=None, iou_thr=IOU_WBF, skip_box_thr=CONF_THRES)\n    else:\n        boxes, scores, labels = [], [], []\n\n    # Apply Logic\n    raw_heads = [xyxy_to_xywh(b) for b, l in zip(boxes, labels) if int(l) == 0]\n    raw_shemaghs = [xyxy_to_xywh(b) for b, l in zip(boxes, labels) if int(l) == 1]\n    cleaned_shemaghs = clean_duplicate_shemaghs(raw_shemaghs)\n\n    is_correct = 0\n    max_img_score = -1.0 # Default if no valid pair found\n\n    if raw_heads and cleaned_shemaghs:\n        for h in raw_heads:\n            for s in cleaned_shemaghs:\n                f = calculate_hybrid_features(h, s)\n                if not f['is_above']: continue\n                if f['head_flat'] and f['ioh'] &lt; 0.80: continue\n\n                score = f['ioh'] - (W_SYM * f['symmetry']) - (W_DX * f['dx_norm'])\n                if f['starts_at_top']: score += 0.05\n                if score &gt; max_img_score: max_img_score = score\n\n        if max_img_score &gt; FINAL_THRESH:\n            is_correct = 1\n\n    # Store Data\n    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}\" \n                  for b, s, l in zip(boxes, scores, labels)]\n    pred_str = \" \".join(pred_parts) if pred_parts else \"-\"\n\n    all_results.append({\n        \"filename\": filename,\n        \"right_place\": is_correct,\n        \"prediction_string\": pred_str,\n        \"max_img_score\": max_img_score\n    })\n\n# EXPORTING DATA\ndf_all = pd.DataFrame(all_results)\n\n\ncertain_labels = []\nfor idx, row in df_all.iterrows():\n    if row['max_img_score'] &gt;= SURE_CORRECT_THRESH:\n        certain_labels.append({\"filename\": row['filename'], \"label\": 1})\n    elif row['max_img_score'] &lt;= SURE_INCORRECT_THRESH:\n        certain_labels.append({\"filename\": row['filename'], \"label\": 0})\n\ndf_pseudo = pd.DataFrame(certain_labels)\n\n# Filter for Label 1 \ndf_correct = df_pseudo[df_pseudo['label'] == 1].copy()\n\n# Save to a new file\noutput_csv = 'correct_images_only.csv'\ndf_correct.to_csv(output_csv, index=False)\n\n\nprint(f\"Filtered {len(df_correct)} correct images out of {len(df)} total pseudo labels.\")\nprint(f\"Saved to: {output_csv}\")\n</code></pre>\n<p>Training ConvNext for 'right_place' using Pseudo labeling</p>\n<p><strong>Fun fact: we got 0.848 private score using only heuristics.</strong></p>\n<pre><code># CONFIGURATION\nORIGINAL_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/train'\nORIGINAL_CSV = '/kaggle/input/dal-shemagh-detection-challenge/train_labels.csv'\nPSEUDO_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\nPSEUDO_CSV = '/kaggle/working/correct_images_only.csv'\nMODEL_NAME = 'convnext_tiny.fb_in22k_ft_in1k'\nNUM_CLASSES = 2\nIMG_SIZE = 224\nBATCH_SIZE = 16\nEPOCHS = 15\nLEARNING_RATE = 5e-5\nN_FOLDS = 5\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nOUTPUT_DIR = 'ConvNext_KFold_Models'\n\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\n# DATASET CLASS\nclass ShemaghDataset(Dataset):\n    def __init__(self, df, transform=None):\n        self.df = df\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        img_path = row['path']\n        label = int(row['right_place'])\n\n        # Read Image using OpenCV \n        image = cv2.imread(img_path)\n        if image is None:\n            # Fallback (black image) to prevent crash\n            image = np.zeros((IMG_SIZE, IMG_SIZE, 3), dtype=np.uint8)\n\n        # Convert BGR -&gt; RGB -&gt; PIL Image \n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        image = Image.fromarray(image)\n\n        if self.transform:\n            image = self.transform(image)\n\n        return image, torch.tensor(label, dtype=torch.long)\n\n\n# AUGMENTATIONS\ndef get_transforms(data='train'):\n    if data == 'train':\n        return transforms.Compose([\n            transforms.Resize((IMG_SIZE, IMG_SIZE)),\n            transforms.RandomHorizontalFlip(p=0.5),\n            transforms.RandomRotation(degrees=15),\n            transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.02),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n    else:\n        return transforms.Compose([\n            transforms.Resize((IMG_SIZE, IMG_SIZE)),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n\n# 4. TRAINING ENGINE\ndef train_one_epoch(model, loader, optimizer, criterion):\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n\n    for images, labels in tqdm(loader, desc=\"Training\", leave=False):\n        images, labels = images.to(DEVICE), labels.to(DEVICE)\n\n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n\n        running_loss += loss.item()\n        _, predicted = torch.max(outputs.data, 1)\n        total += labels.size(0)\n        correct += (predicted == labels).sum().item()\n\n    return running_loss / len(loader), correct / total\n\ndef validate_one_epoch(model, loader, criterion):\n    model.eval()\n    running_loss = 0.0\n    all_preds = []\n    all_labels = []\n\n    with torch.no_grad():\n        for images, labels in tqdm(loader, desc=\"Validating\", leave=False):\n            images, labels = images.to(DEVICE), labels.to(DEVICE)\n\n            outputs = model(images)\n            loss = criterion(outputs, labels)\n\n            running_loss += loss.item()\n            _, predicted = torch.max(outputs.data, 1)\n\n            all_preds.extend(predicted.cpu().numpy())\n            all_labels.extend(labels.cpu().numpy())\n\n    acc = accuracy_score(all_labels, all_preds)\n    f1 = f1_score(all_labels, all_preds, zero_division=0)\n\n    return running_loss / len(loader), acc, f1\n\n# PIPELINE \nif __name__ == '__main__':\n    print(f\"--- Training ConvNeXt on {DEVICE} ---\")\n\n    # 1. Load Data\n    df_orig = pd.read_csv(ORIGINAL_CSV)\n    df_orig['path'] = df_orig['filename'].apply(lambda x: os.path.join(ORIGINAL_IMG_DIR, x))\n\n    df_pseudo = pd.read_csv(PSEUDO_CSV)\n    # rename from 'label' to 'right_place', thank you Omar \n    df_pseudo = df_pseudo.rename(columns={'label': 'right_place'})\n    df_pseudo['path'] = df_pseudo['filename'].apply(lambda x: os.path.join(PSEUDO_IMG_DIR, x))\n\n    # Merge\n    df_final = pd.concat([df_orig, df_pseudo], axis=0).reset_index(drop=True)\n\n    # Class Weighting Calculation\n    counts = df_final['right_place'].value_counts()\n    print(f\"Class Distribution: {counts.to_dict()}\")\n\n    n_class0 = counts.get(0, 1)\n    n_class1 = counts.get(1, 1)\n    # Weight Class 1 higher to force model to pay attention to it\n    pos_weight = n_class0 / n_class1\n    class_weights = torch.tensor([1.0, float(pos_weight)]).to(DEVICE)\n    print(f\"Using Class Weights: [1.0, {pos_weight:.2f}]\")\n\n    # 2. Stratified K-Fold\n    skf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=42)\n\n    for fold, (train_idx, val_idx) in enumerate(skf.split(df_final, df_final['right_place'])):\n        if fold &gt; 0: \n            break\n\n        train_df = df_final.iloc[train_idx].reset_index(drop=True)\n        val_df = df_final.iloc[val_idx].reset_index(drop=True)\n\n        # Loaders (Workers=0 for Windows Safety)\n        train_ds = ShemaghDataset(train_df, transform=get_transforms('train'))\n        val_ds = ShemaghDataset(val_df, transform=get_transforms('val'))\n\n        train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)\n        val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)\n\n        # Model Setup\n        model = timm.create_model(MODEL_NAME, pretrained=True, num_classes=NUM_CLASSES)\n        model = model.to(DEVICE)\n\n        optimizer = optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=1e-2)\n        criterion = nn.CrossEntropyLoss(weight=class_weights)\n\n        best_val_f1 = 0.0\n\n        for epoch in range(EPOCHS):\n            train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion)\n            val_loss, val_acc, val_f1 = validate_one_epoch(model, val_loader, criterion)\n\n            print(f\"Epoch {epoch+1:02d} | Train Loss: {train_loss:.4f} | Val F1: {val_f1:.4f}\")\n\n            if val_f1 &gt; best_val_f1:\n                best_val_f1 = val_f1\n                save_path = os.path.join(OUTPUT_DIR, f\"convnext_fold{fold}_best.pth\")\n                torch.save(model.state_dict(), save_path)\n\n        print(f\"Fold {fold+1} Finished. Best F1: {best_val_f1:.4f}\")\n\n        # Cleanup\n        del model, optimizer\n        torch.cuda.empty_cache()\n\n    print(f\"\\nTraining Complete. Models saved to {OUTPUT_DIR}\")\n</code></pre>\n<p>Generate Submission</p>\n<pre><code># CONFIGURATION\n\nYOLO_MODEL_PATH = '/kaggle/working/runs/detect/Shemagh_KFold_BuiltIn3/fold_2/weights/best.pt' \n# after some experiments, the first fold gives the hieghts validation score. \nCLS_MODEL_PATH = '/kaggle/working/ConvNext_KFold_Models/convnext_fold0_best.pth' \nTEST_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\n\n# Inference Settings\nTTA_SCALES = [640, 800, 512]\nCONF_THRES = 0.05 \nIOU_WBF = 0.60  \nNMS_THRES = 0.65 \n\n# Classifier Settings\nCLS_MODEL_NAME = 'convnext_tiny.fb_in22k_ft_in1k'\nIMG_SIZE = 224\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n\n\n# UTILITY FUNCTIONS\ndef xyxy_to_xywh(box):\n    \"\"\"Converts [x1, y1, x2, y2] to [x_center, y_center, w, h].\"\"\"\n    x1, y1, x2, y2 = box\n    w = x2 - x1\n    h = y2 - y1\n    x = (x1 + x2) / 2\n    y = (y1 + y2) / 2\n    return [x, y, w, h]\n\ndef get_inference_transforms():\n    \"\"\"Exact same transforms used in Validation.\"\"\"\n    return transforms.Compose([\n        transforms.Resize((IMG_SIZE, IMG_SIZE)),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n    ])\n\ndef apply_nms(boxes, scores, labels, iou_threshold=0.65):\n    \"\"\"\n    Applies Non-Maximum Suppression per class.\n    If overlap &gt; threshold, keeps only the one with higher confidence.\n    \"\"\"\n    if len(boxes) == 0:\n        return [], [], []\n\n    # Convert to tensor\n    boxes_t = torch.tensor(boxes, dtype=torch.float32)\n    scores_t = torch.tensor(scores, dtype=torch.float32)\n    labels_t = torch.tensor(labels, dtype=torch.int64)\n\n    keep_indices = []\n\n    # Apply NMS for each class independently\n    unique_labels = labels_t.unique()\n    for lbl in unique_labels:\n        mask = (labels_t == lbl)\n        class_boxes = boxes_t[mask]\n        class_scores = scores_t[mask]\n        original_indices = torch.where(mask)[0]\n\n        # torchvision NMS returns indices to keep\n        nms_keep = ops.nms(class_boxes, class_scores, iou_threshold)\n\n        # Map back to original indices\n        keep_indices.extend(original_indices[nms_keep].tolist())\n\n    keep_indices = sorted(keep_indices)\n\n    return [boxes[i] for i in keep_indices], \\\n           [scores[i] for i in keep_indices], \\\n           [labels[i] for i in keep_indices]\n\n# LOAD MODELS\nprint(f\"Loading YOLO: {YOLO_MODEL_PATH}\")\nyolo_model = YOLO(YOLO_MODEL_PATH)\n\nprint(f\"Loading ConvNeXt: {CLS_MODEL_PATH}\")\ncls_model = timm.create_model(CLS_MODEL_NAME, pretrained=False, num_classes=2)\n# Load weights\nstate_dict = torch.load(CLS_MODEL_PATH, map_location=DEVICE)\ncls_model.load_state_dict(state_dict)\ncls_model.to(DEVICE)\ncls_model.eval()\n\ncls_transforms = get_inference_transforms()\n\n\n# FINAL INFERENCE PIPELINE\nprint(\"\\nStarting Final Inference...\")\n\ntest_files = sorted([f for f in os.listdir(TEST_IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])\nsubmission_rows = []\n\nfor filename in tqdm(test_files, desc=\"Processing Images\"):\n    img_path = os.path.join(TEST_IMG_DIR, filename)\n\n    # YOLO TTA \n    boxes_list = []\n    scores_list = []\n    labels_list = []\n\n    for scale in TTA_SCALES:\n        results = yolo_model.predict(img_path, imgsz=scale, conf=CONF_THRES, augment=True, verbose=False)[0]\n\n        if len(results.boxes) &gt; 0:\n            boxes_list.append(results.boxes.xyxyn.cpu().tolist())\n            scores_list.append(results.boxes.conf.cpu().tolist())\n            labels_list.append(results.boxes.cls.cpu().tolist())\n\n    # WBF \n    if len(boxes_list) &gt; 0:\n        boxes, scores, labels = weighted_boxes_fusion(\n            boxes_list, scores_list, labels_list, \n            weights=None, iou_thr=IOU_WBF, skip_box_thr=CONF_THRES\n        )\n    else:\n        boxes, scores, labels = [], [], []\n\n    # NMS  \n    boxes, scores, labels = apply_nms(boxes, scores, labels, iou_threshold=NMS_THRES)\n\n    # Prepare Logic &amp; Prediction String \n    has_head = False\n    has_shemagh = False\n    pred_parts = []\n\n    for b, s, l in zip(boxes, scores, labels):\n        xywh = xyxy_to_xywh(b)\n        x, y, w, h = xywh\n\n        pred_parts.append(f\"{int(l)} {s:.4f} {x:.4f} {y:.4f} {w:.4f} {h:.4f}\")\n\n        if int(l) == 0: has_head = True\n        elif int(l) == 1: has_shemagh = True\n\n    prediction_string = \" \".join(pred_parts) if pred_parts else \"-\"\n\n    # Classifier Logic \n    is_correct = 0\n\n    if has_head or has_shemagh: # YOLO might not see all faces and shemaghs in the image\n        # Load and Preprocess Image for Classifier\n        # Read with OpenCV to match training pipeline\n        img_cv = cv2.imread(img_path)\n        img_cv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)\n        img_pil = Image.fromarray(img_cv)\n\n        input_tensor = cls_transforms(img_pil).unsqueeze(0).to(DEVICE)\n\n        with torch.no_grad():\n            outputs = cls_model(input_tensor)\n            # Get class with highest score (0 or 1)\n            probs = torch.softmax(outputs, dim=1)\n            pred_cls = torch.argmax(probs, dim=1).item()\n\n            # Assuming Class 1 is \"Right Place\"\n            is_correct = pred_cls\n\n    submission_rows.append({\n        \"filename\": filename,\n        \"right_place\": is_correct,\n        \"prediction_string\": prediction_string\n    })\n\n# 5. SAVE SUBMISSION\ndf_sub = pd.DataFrame(submission_rows)\ndf_sub.to_csv(\"submission.csv\", index=False)\n\nprint(\"Submission saved to submission.csv\")\nprint(f\"Total Positive Predictions: {df_sub['right_place'].sum()}\")\n</code></pre>\n<h3>Scores may vary due to the difference in GPU because training was done in a local GPU</h3>\n<ul>\n<li>T4: private score 0.862 | public score 0.904. </li>\n<li>P100: private score 0.863 | public score 0.878 </li>\n<li>RTX 5070: private score 0.860 | public score 0.884</li>\n</ul>",
      "rawMarkdown": "السلام عليكم، رمضان مبارك جميعا واعاننا الله على صيامه وقيامه\n\nFirst of all, thanks to the organizers for this challenging competition. \n\nOur 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.\n\n## 1. Challenges: \n* **Domain Shift.** \n* **Class Imbalance:** only 5 samples of True \"Right Place\" class.\n\n## 2. Stage 1: Object Detection (YOLO11)\nWe used **YOLO11m** (Medium) as our primary detector.\n\n### Color Augmentation\nTo 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.\n* **HSV Saturation (`hsv_s`):** Increased to `0.8`.\n* **HSV Value (`hsv_v`):** Increased to `0.6`.\n\n### Training Setup\n* **Folds:** Stratified K-Fold (5 Folds).\n* **Image Size:** 640.\n* **Epochs:** 200 per fold.\n\n## 3. Stage 2: Classifier (ConvNeXt)\nInitially, we tried geometric heuristics (Intersection over Head, Symmetry scores), then we switched to a classifier approach.\n\n* **Model:** `convnext_tiny.fb_in22k_ft_in1k` (via `timm`).\n* **Input:** We passed the images where `Head` or `Shemagh` were detected to the classifier.\n* **Resolution:** 224x224.\n\n### Pseudo Labeling\nSince we had very few \"True\" samples for the `right_place` class, we:\n1.  Trained a model on the labeled data.\n2.  Ran inference on the Test Set.\n3.  Filtered high confidence \"Right Place\" predictions.\n4.  Added these Pseudo Labels back to the training set.\n5.  Retrained ConvNeXt with a weighted loss function to handle the remaining imbalance.\n\n## 4. Inference Pipeline \nOur inference pipeline was designed to maximize recall and precision using ensemble techniques.\n\n1.  **Multi-Scale TTA:** `[640, 800, 512]`.\n2.  **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.\n3.  **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%.\n\n\n```python\n!pip install -q ultralytics ensemble_boxes\n\nimport os\nimport pandas as pd\nimport numpy as np\nfrom ultralytics import YOLO\nimport yaml\nfrom tqdm.notebook import tqdm\nimport cv2\nimport shutil\nimport glob\nfrom pathlib import Path\nfrom sklearn.model_selection import train_test_split, StratifiedKFold, KFold\nfrom sklearn.metrics import f1_score, accuracy_score\nimport joblib\nfrom ensemble_boxes import weighted_boxes_fusion\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms, ops\nimport matplotlib.pyplot as plt\nimport random\nimport json\nimport base64\nfrom io import BytesIO\nfrom IPython.display import HTML, display\nimport timm\nfrom PIL import Image\n```\n\nTraining YOLO using k fold 5\n\n```python\n# Global Seed Setup \ndef set_all_seeds(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    # Ensures deterministic algorithms are used where possible\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n\nset_all_seeds(42)\n\n# Setup Absolute Paths \nWORK_DIR = Path('/kaggle/input/dal-shemagh-detection-challenge').absolute()\nimages_path = WORK_DIR / 'images/train' \nall_images = sorted([f.name for f in images_path.glob('*') if f.suffix.lower() in ['.jpg', '.jpeg', '.png']])\nk = 5\nresults = {}\n\n# random_state=42 ensures the folds are always the same\nkf = KFold(n_splits=k, shuffle=True, random_state=42)\n\n# K-Fold Training Loop \nfor fold, (train_idx, val_idx) in enumerate(kf.split(all_images)):\n    # after some tests using the same random seed, 3ed fold usally gives the hieghst validation score. \n    if fold != 2: \n        continue\n    \n    train_list = [str(images_path / all_images[i]) for i in train_idx]\n    val_list = [str(images_path / all_images[i]) for i in val_idx]\n    \n    with open(f\"train_f{fold}.txt\", \"w\") as f: f.write(\"\\n\".join(train_list))\n    with open(f\"val_f{fold}.txt\", \"w\") as f: f.write(\"\\n\".join(val_list))\n\n    yaml_path = os.path.abspath(f\"fold_{fold}.yaml\")\n    with open(yaml_path, \"w\") as f:\n        yaml.dump({\n            'path': str(WORK_DIR),\n            'train': os.path.abspath(f\"train_f{fold}.txt\"),\n            'val': os.path.abspath(f\"val_f{fold}.txt\"),\n            'nc': 2, \n            'names': ['Face', 'Shemagh']\n        }, f)\n\n    # Initialize and Train\n    torch.cuda.empty_cache()\n    model = YOLO('yolo11m.pt') \n    \n    model.train(\n        data=yaml_path,\n        epochs=200,\n        imgsz=640,          \n        batch=16,              \n        device=1,\n        seed=42,\n        workers=4,              \n        deterministic=True,    \n\n        hsv_h=0.015,   \n        hsv_s=0.8,     \n        hsv_v=0.6,     \n        scale=0.8,\n\n        mosaic=1.0,    \n        mixup=0.1,     \n        copy_paste=0.3,\n        close_mosaic=50, \n        erasing=0.3,\n        \n        optimizer='SGD',\n        lr0=0.001,\n        project='Shemagh_KFold_BuiltIn3',\n        name=f'fold_{fold}'\n\n    )\n    \n\n    \n    stats = model.val()\n    if stats:\n        results[fold] = stats.results_dict\n    with open(\"kfold_results_backup.json\", \"w\") as f:\n        json.dump(results, f)\n```\n\nUse YOLO and heuristics To generate pseudo labels\n```python\n# CONFIGURATION\nMODEL_PATH = '/kaggle/working/runs/detect/Shemagh_KFold_BuiltIn3/fold_2/weights/best.pt'\nTEST_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\n\n# Inference Hyperparameters\nTTA_SCALES = [640, 800, 512]\nCONF_THRES = 0.40   # Lowered to capture all possible detections\nIOU_WBF = 0.50      # Aggressive merging for multi-scale consensus\nW_SYM = 0.8\nW_DX = 0.2\nFINAL_THRESH = 0.45 \n\n# Pseudo Labeling Thresholds\nSURE_CORRECT_THRESH = 0.85   \nSURE_INCORRECT_THRESH = 0.15 \n\n# HYBRID UTILITY FUNCTIONS\ndef xyxy_to_xywh(box):\n    x1, y1, x2, y2 = box\n    return [(x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1]\n\ndef clean_duplicate_shemaghs(shemaghs, overlap_threshold=0.50):\n    if not shemaghs: return []\n    cleaned = []\n    sorted_s = sorted(shemaghs, key=lambda x: x[2] * x[3], reverse=True)\n    for s_big in sorted_s:\n        keep = True\n        bx, by, bw, bh = s_big\n        for s_checked in cleaned:\n            cx, cy, cw, ch = s_checked\n            ix1, iy1 = max(bx-bw/2, cx-cw/2), max(by-bh/2, cy-ch/2)\n            ix2, iy2 = min(bx+bw/2, cx+cw/2), min(by+bh/2, cy+ch/2)\n            inter = max(0, ix2-ix1) * max(0, iy2-iy1)\n            if inter / (bw * bh) > overlap_threshold:\n                keep = False\n                break\n        if keep: cleaned.append(s_big)\n    return cleaned\n\ndef calculate_hybrid_features(head, shemagh):\n    hx, hy, hw, hh = head\n    sx, sy, sw, sh = shemagh\n    ix1, iy1 = max(hx-hw/2, sx-sw/2), max(hy-hh/2, sy-sh/2)\n    ix2, iy2 = min(hx+hw/2, sx+sw/2), min(hy+hh/2, sy+sh/2)\n    inter = max(0, ix2-ix1) * max(0, iy2-iy1)\n    ioh = inter / (hw * hh) if (hw * hh) > 0 else 0.0\n    half_area = (hw * hh) / 2\n    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))\n    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))\n    symmetry = abs((l_inter / half_area) - (r_inter / half_area)) if half_area > 0 else 1.0\n    h_ymin, s_ymin = hy - hh/2, sy - sh/2\n    return {\n        'ioh': ioh, 'symmetry': symmetry, 'dx_norm': abs(sx - hx) / hw,\n        'starts_at_top': s_ymin < (hy - hh/4), \n        'is_above': s_ymin < h_ymin,\n        'head_flat': hh < (hw * 0.9)\n    }\n\n\n# INFERENCE LOOP\ntest_files = sorted([f for f in os.listdir(TEST_IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])\nmodel = YOLO(MODEL_PATH)\nall_results = []\n\nprint(f\"Running inference on {len(test_files)} images...\")\n\nfor filename in tqdm(test_files):\n    img_path = os.path.join(TEST_IMG_DIR, filename)\n    img_orig = cv2.imread(img_path)\n    img_orig = cv2.cvtColor(img_orig, cv2.COLOR_BGR2RGB)\n    h_img, w_img, _ = img_orig.shape\n\n    # TTA Detection\n    b_list, s_list, l_list, raw_viz_boxes = [], [], [], []\n    for scale in TTA_SCALES:\n        res = model.predict(img_path, imgsz=scale, conf=CONF_THRES, augment=True, verbose=False)[0]\n        if len(res.boxes) > 0:\n            bn, cn, cl = res.boxes.xyxyn.cpu().tolist(), res.boxes.conf.cpu().tolist(), res.boxes.cls.cpu().tolist()\n            b_list.append(bn); s_list.append(cn); l_list.append(cl)\n            for b, s, l in zip(bn, cn, cl):\n                raw_viz_boxes.append({'box': b, 'cls': int(l)})\n\n    # Weighted Boxes Fusion\n    if b_list:\n        boxes, scores, labels = weighted_boxes_fusion(b_list, s_list, l_list, weights=None, iou_thr=IOU_WBF, skip_box_thr=CONF_THRES)\n    else:\n        boxes, scores, labels = [], [], []\n\n    # Apply Logic\n    raw_heads = [xyxy_to_xywh(b) for b, l in zip(boxes, labels) if int(l) == 0]\n    raw_shemaghs = [xyxy_to_xywh(b) for b, l in zip(boxes, labels) if int(l) == 1]\n    cleaned_shemaghs = clean_duplicate_shemaghs(raw_shemaghs)\n    \n    is_correct = 0\n    max_img_score = -1.0 # Default if no valid pair found\n    \n    if raw_heads and cleaned_shemaghs:\n        for h in raw_heads:\n            for s in cleaned_shemaghs:\n                f = calculate_hybrid_features(h, s)\n                if not f['is_above']: continue\n                if f['head_flat'] and f['ioh'] < 0.80: continue\n                \n                score = f['ioh'] - (W_SYM * f['symmetry']) - (W_DX * f['dx_norm'])\n                if f['starts_at_top']: score += 0.05\n                if score > max_img_score: max_img_score = score\n        \n        if max_img_score > FINAL_THRESH:\n            is_correct = 1\n\n    # Store Data\n    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}\" \n                  for b, s, l in zip(boxes, scores, labels)]\n    pred_str = \" \".join(pred_parts) if pred_parts else \"-\"\n    \n    all_results.append({\n        \"filename\": filename,\n        \"right_place\": is_correct,\n        \"prediction_string\": pred_str,\n        \"max_img_score\": max_img_score\n    })\n\n# EXPORTING DATA\ndf_all = pd.DataFrame(all_results)\n\n\ncertain_labels = []\nfor idx, row in df_all.iterrows():\n    if row['max_img_score'] >= SURE_CORRECT_THRESH:\n        certain_labels.append({\"filename\": row['filename'], \"label\": 1})\n    elif row['max_img_score'] <= SURE_INCORRECT_THRESH:\n        certain_labels.append({\"filename\": row['filename'], \"label\": 0})\n\ndf_pseudo = pd.DataFrame(certain_labels)\n\n# Filter for Label 1 \ndf_correct = df_pseudo[df_pseudo['label'] == 1].copy()\n\n# Save to a new file\noutput_csv = 'correct_images_only.csv'\ndf_correct.to_csv(output_csv, index=False)\n\n\nprint(f\"Filtered {len(df_correct)} correct images out of {len(df)} total pseudo labels.\")\nprint(f\"Saved to: {output_csv}\")\n```\n\nTraining ConvNext for 'right_place' using Pseudo labeling\n\n**Fun fact: we got 0.848 private score using only heuristics.**\n```python\n# CONFIGURATION\nORIGINAL_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/train'\nORIGINAL_CSV = '/kaggle/input/dal-shemagh-detection-challenge/train_labels.csv'\nPSEUDO_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\nPSEUDO_CSV = '/kaggle/working/correct_images_only.csv'\nMODEL_NAME = 'convnext_tiny.fb_in22k_ft_in1k'\nNUM_CLASSES = 2\nIMG_SIZE = 224\nBATCH_SIZE = 16\nEPOCHS = 15\nLEARNING_RATE = 5e-5\nN_FOLDS = 5\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nOUTPUT_DIR = 'ConvNext_KFold_Models'\n\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\n# DATASET CLASS\nclass ShemaghDataset(Dataset):\n    def __init__(self, df, transform=None):\n        self.df = df\n        self.transform = transform\n        \n    def __len__(self):\n        return len(self.df)\n    \n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        img_path = row['path']\n        label = int(row['right_place'])\n        \n        # Read Image using OpenCV \n        image = cv2.imread(img_path)\n        if image is None:\n            # Fallback (black image) to prevent crash\n            image = np.zeros((IMG_SIZE, IMG_SIZE, 3), dtype=np.uint8)\n            \n        # Convert BGR -> RGB -> PIL Image \n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        image = Image.fromarray(image)\n        \n        if self.transform:\n            image = self.transform(image)\n            \n        return image, torch.tensor(label, dtype=torch.long)\n\n\n# AUGMENTATIONS\ndef get_transforms(data='train'):\n    if data == 'train':\n        return transforms.Compose([\n            transforms.Resize((IMG_SIZE, IMG_SIZE)),\n            transforms.RandomHorizontalFlip(p=0.5),\n            transforms.RandomRotation(degrees=15),\n            transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.02),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n    else:\n        return transforms.Compose([\n            transforms.Resize((IMG_SIZE, IMG_SIZE)),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n\n# 4. TRAINING ENGINE\ndef train_one_epoch(model, loader, optimizer, criterion):\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n    \n    for images, labels in tqdm(loader, desc=\"Training\", leave=False):\n        images, labels = images.to(DEVICE), labels.to(DEVICE)\n        \n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        \n        running_loss += loss.item()\n        _, predicted = torch.max(outputs.data, 1)\n        total += labels.size(0)\n        correct += (predicted == labels).sum().item()\n        \n    return running_loss / len(loader), correct / total\n\ndef validate_one_epoch(model, loader, criterion):\n    model.eval()\n    running_loss = 0.0\n    all_preds = []\n    all_labels = []\n    \n    with torch.no_grad():\n        for images, labels in tqdm(loader, desc=\"Validating\", leave=False):\n            images, labels = images.to(DEVICE), labels.to(DEVICE)\n            \n            outputs = model(images)\n            loss = criterion(outputs, labels)\n            \n            running_loss += loss.item()\n            _, predicted = torch.max(outputs.data, 1)\n            \n            all_preds.extend(predicted.cpu().numpy())\n            all_labels.extend(labels.cpu().numpy())\n            \n    acc = accuracy_score(all_labels, all_preds)\n    f1 = f1_score(all_labels, all_preds, zero_division=0)\n    \n    return running_loss / len(loader), acc, f1\n\n# PIPELINE \nif __name__ == '__main__':\n    print(f\"--- Training ConvNeXt on {DEVICE} ---\")\n    \n    # 1. Load Data\n    df_orig = pd.read_csv(ORIGINAL_CSV)\n    df_orig['path'] = df_orig['filename'].apply(lambda x: os.path.join(ORIGINAL_IMG_DIR, x))\n    \n    df_pseudo = pd.read_csv(PSEUDO_CSV)\n    # rename from 'label' to 'right_place', thank you Omar \n    df_pseudo = df_pseudo.rename(columns={'label': 'right_place'})\n    df_pseudo['path'] = df_pseudo['filename'].apply(lambda x: os.path.join(PSEUDO_IMG_DIR, x))\n    \n    # Merge\n    df_final = pd.concat([df_orig, df_pseudo], axis=0).reset_index(drop=True)\n    \n    # Class Weighting Calculation\n    counts = df_final['right_place'].value_counts()\n    print(f\"Class Distribution: {counts.to_dict()}\")\n    \n    n_class0 = counts.get(0, 1)\n    n_class1 = counts.get(1, 1)\n    # Weight Class 1 higher to force model to pay attention to it\n    pos_weight = n_class0 / n_class1\n    class_weights = torch.tensor([1.0, float(pos_weight)]).to(DEVICE)\n    print(f\"Using Class Weights: [1.0, {pos_weight:.2f}]\")\n\n    # 2. Stratified K-Fold\n    skf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=42)\n    \n    for fold, (train_idx, val_idx) in enumerate(skf.split(df_final, df_final['right_place'])):\n        if fold > 0: \n            break\n        \n        train_df = df_final.iloc[train_idx].reset_index(drop=True)\n        val_df = df_final.iloc[val_idx].reset_index(drop=True)\n        \n        # Loaders (Workers=0 for Windows Safety)\n        train_ds = ShemaghDataset(train_df, transform=get_transforms('train'))\n        val_ds = ShemaghDataset(val_df, transform=get_transforms('val'))\n        \n        train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)\n        val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)\n        \n        # Model Setup\n        model = timm.create_model(MODEL_NAME, pretrained=True, num_classes=NUM_CLASSES)\n        model = model.to(DEVICE)\n        \n        optimizer = optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=1e-2)\n        criterion = nn.CrossEntropyLoss(weight=class_weights)\n        \n        best_val_f1 = 0.0\n        \n        for epoch in range(EPOCHS):\n            train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion)\n            val_loss, val_acc, val_f1 = validate_one_epoch(model, val_loader, criterion)\n            \n            print(f\"Epoch {epoch+1:02d} | Train Loss: {train_loss:.4f} | Val F1: {val_f1:.4f}\")\n            \n            if val_f1 > best_val_f1:\n                best_val_f1 = val_f1\n                save_path = os.path.join(OUTPUT_DIR, f\"convnext_fold{fold}_best.pth\")\n                torch.save(model.state_dict(), save_path)\n                \n        print(f\"Fold {fold+1} Finished. Best F1: {best_val_f1:.4f}\")\n        \n        # Cleanup\n        del model, optimizer\n        torch.cuda.empty_cache()\n\n    print(f\"\\nTraining Complete. Models saved to {OUTPUT_DIR}\")\n```\n\nGenerate Submission\n```python\n# CONFIGURATION\n\nYOLO_MODEL_PATH = '/kaggle/working/runs/detect/Shemagh_KFold_BuiltIn3/fold_2/weights/best.pt' \n# after some experiments, the first fold gives the hieghts validation score. \nCLS_MODEL_PATH = '/kaggle/working/ConvNext_KFold_Models/convnext_fold0_best.pth' \nTEST_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\n\n# Inference Settings\nTTA_SCALES = [640, 800, 512]\nCONF_THRES = 0.05 \nIOU_WBF = 0.60  \nNMS_THRES = 0.65 \n\n# Classifier Settings\nCLS_MODEL_NAME = 'convnext_tiny.fb_in22k_ft_in1k'\nIMG_SIZE = 224\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n\n\n# UTILITY FUNCTIONS\ndef xyxy_to_xywh(box):\n    \"\"\"Converts [x1, y1, x2, y2] to [x_center, y_center, w, h].\"\"\"\n    x1, y1, x2, y2 = box\n    w = x2 - x1\n    h = y2 - y1\n    x = (x1 + x2) / 2\n    y = (y1 + y2) / 2\n    return [x, y, w, h]\n\ndef get_inference_transforms():\n    \"\"\"Exact same transforms used in Validation.\"\"\"\n    return transforms.Compose([\n        transforms.Resize((IMG_SIZE, IMG_SIZE)),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n    ])\n\ndef apply_nms(boxes, scores, labels, iou_threshold=0.65):\n    \"\"\"\n    Applies Non-Maximum Suppression per class.\n    If overlap > threshold, keeps only the one with higher confidence.\n    \"\"\"\n    if len(boxes) == 0:\n        return [], [], []\n\n    # Convert to tensor\n    boxes_t = torch.tensor(boxes, dtype=torch.float32)\n    scores_t = torch.tensor(scores, dtype=torch.float32)\n    labels_t = torch.tensor(labels, dtype=torch.int64)\n\n    keep_indices = []\n    \n    # Apply NMS for each class independently\n    unique_labels = labels_t.unique()\n    for lbl in unique_labels:\n        mask = (labels_t == lbl)\n        class_boxes = boxes_t[mask]\n        class_scores = scores_t[mask]\n        original_indices = torch.where(mask)[0]\n        \n        # torchvision NMS returns indices to keep\n        nms_keep = ops.nms(class_boxes, class_scores, iou_threshold)\n        \n        # Map back to original indices\n        keep_indices.extend(original_indices[nms_keep].tolist())\n\n    keep_indices = sorted(keep_indices)\n    \n    return [boxes[i] for i in keep_indices], \\\n           [scores[i] for i in keep_indices], \\\n           [labels[i] for i in keep_indices]\n\n# LOAD MODELS\nprint(f\"Loading YOLO: {YOLO_MODEL_PATH}\")\nyolo_model = YOLO(YOLO_MODEL_PATH)\n\nprint(f\"Loading ConvNeXt: {CLS_MODEL_PATH}\")\ncls_model = timm.create_model(CLS_MODEL_NAME, pretrained=False, num_classes=2)\n# Load weights\nstate_dict = torch.load(CLS_MODEL_PATH, map_location=DEVICE)\ncls_model.load_state_dict(state_dict)\ncls_model.to(DEVICE)\ncls_model.eval()\n\ncls_transforms = get_inference_transforms()\n\n\n# FINAL INFERENCE PIPELINE\nprint(\"\\nStarting Final Inference...\")\n\ntest_files = sorted([f for f in os.listdir(TEST_IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])\nsubmission_rows = []\n\nfor filename in tqdm(test_files, desc=\"Processing Images\"):\n    img_path = os.path.join(TEST_IMG_DIR, filename)\n    \n    # YOLO TTA \n    boxes_list = []\n    scores_list = []\n    labels_list = []\n    \n    for scale in TTA_SCALES:\n        results = yolo_model.predict(img_path, imgsz=scale, conf=CONF_THRES, augment=True, verbose=False)[0]\n        \n        if len(results.boxes) > 0:\n            boxes_list.append(results.boxes.xyxyn.cpu().tolist())\n            scores_list.append(results.boxes.conf.cpu().tolist())\n            labels_list.append(results.boxes.cls.cpu().tolist())\n\n    # WBF \n    if len(boxes_list) > 0:\n        boxes, scores, labels = weighted_boxes_fusion(\n            boxes_list, scores_list, labels_list, \n            weights=None, iou_thr=IOU_WBF, skip_box_thr=CONF_THRES\n        )\n    else:\n        boxes, scores, labels = [], [], []\n\n    # NMS  \n    boxes, scores, labels = apply_nms(boxes, scores, labels, iou_threshold=NMS_THRES)\n\n    # Prepare Logic & Prediction String \n    has_head = False\n    has_shemagh = False\n    pred_parts = []\n    \n    for b, s, l in zip(boxes, scores, labels):\n        xywh = xyxy_to_xywh(b)\n        x, y, w, h = xywh\n        \n        pred_parts.append(f\"{int(l)} {s:.4f} {x:.4f} {y:.4f} {w:.4f} {h:.4f}\")\n        \n        if int(l) == 0: has_head = True\n        elif int(l) == 1: has_shemagh = True\n            \n    prediction_string = \" \".join(pred_parts) if pred_parts else \"-\"\n\n    # Classifier Logic \n    is_correct = 0\n    \n    if has_head or has_shemagh: # YOLO might not see all faces and shemaghs in the image\n        # Load and Preprocess Image for Classifier\n        # Read with OpenCV to match training pipeline\n        img_cv = cv2.imread(img_path)\n        img_cv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)\n        img_pil = Image.fromarray(img_cv)\n        \n        input_tensor = cls_transforms(img_pil).unsqueeze(0).to(DEVICE)\n        \n        with torch.no_grad():\n            outputs = cls_model(input_tensor)\n            # Get class with highest score (0 or 1)\n            probs = torch.softmax(outputs, dim=1)\n            pred_cls = torch.argmax(probs, dim=1).item()\n            \n            # Assuming Class 1 is \"Right Place\"\n            is_correct = pred_cls\n\n    submission_rows.append({\n        \"filename\": filename,\n        \"right_place\": is_correct,\n        \"prediction_string\": prediction_string\n    })\n\n# 5. SAVE SUBMISSION\ndf_sub = pd.DataFrame(submission_rows)\ndf_sub.to_csv(\"submission.csv\", index=False)\n\nprint(\"Submission saved to submission.csv\")\nprint(f\"Total Positive Predictions: {df_sub['right_place'].sum()}\")\n```\n\n### Scores may vary due to the difference in GPU because training was done in a local GPU\n* T4: private score 0.862 | public score 0.904. \n* P100: private score 0.863 | public score 0.878 \n* RTX 5070: private score 0.860 | public score 0.884",
      "votes": 4
    },
    {
      "id": 3408079,
      "postDate": "2026-02-19T18:47:51.850Z",
      "content": "<p>86% with a single model is huge work!!, gl in next challanges </p>",
      "rawMarkdown": "86% with a single model is huge work!!, gl in next challanges "
    },
    {
      "id": 3407686,
      "postDate": "2026-02-18T22:48:08.557Z",
      "content": "<p>Goated writeup, thanks for sharing! 86% private with a single model is insane ngl</p>",
      "rawMarkdown": "Goated writeup, thanks for sharing! 86% private with a single model is insane ngl"
    },
    {
      "id": 3407680,
      "postDate": "2026-02-18T22:16:48.263Z",
      "rawMarkdown": "",
      "isDeleted": true
    }
  ],
  "comments": [
    {
      "id": 3408079,
      "author_name": "Not a Cheater",
      "author_url": "",
      "post_date": "2026-02-19T18:47:51.850000",
      "content": "<p>86% with a single model is huge work!!, gl in next challanges </p>",
      "votes": 0,
      "replies": []
    },
    {
      "id": 3407686,
      "author_name": "amjad alqasemi",
      "author_url": "",
      "post_date": "2026-02-18T22:48:08.557000",
      "content": "<p>Goated writeup, thanks for sharing! 86% private with a single model is insane ngl</p>",
      "votes": 0,
      "replies": []
    },
    {
      "id": 3407680,
      "author_name": "",
      "author_url": "",
      "post_date": "2026-02-18T22:16:48.263000",
      "content": "",
      "votes": 0,
      "replies": []
    }
  ],
  "raw_markdown_by_id": {
    "3407675": "السلام عليكم، رمضان مبارك جميعا واعاننا الله على صيامه وقيامه\n\nFirst of all, thanks to the organizers for this challenging competition. \n\nOur 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.\n\n## 1. Challenges: \n* **Domain Shift.** \n* **Class Imbalance:** only 5 samples of True \"Right Place\" class.\n\n## 2. Stage 1: Object Detection (YOLO11)\nWe used **YOLO11m** (Medium) as our primary detector.\n\n### Color Augmentation\nTo 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.\n* **HSV Saturation (`hsv_s`):** Increased to `0.8`.\n* **HSV Value (`hsv_v`):** Increased to `0.6`.\n\n### Training Setup\n* **Folds:** Stratified K-Fold (5 Folds).\n* **Image Size:** 640.\n* **Epochs:** 200 per fold.\n\n## 3. Stage 2: Classifier (ConvNeXt)\nInitially, we tried geometric heuristics (Intersection over Head, Symmetry scores), then we switched to a classifier approach.\n\n* **Model:** `convnext_tiny.fb_in22k_ft_in1k` (via `timm`).\n* **Input:** We passed the images where `Head` or `Shemagh` were detected to the classifier.\n* **Resolution:** 224x224.\n\n### Pseudo Labeling\nSince we had very few \"True\" samples for the `right_place` class, we:\n1.  Trained a model on the labeled data.\n2.  Ran inference on the Test Set.\n3.  Filtered high confidence \"Right Place\" predictions.\n4.  Added these Pseudo Labels back to the training set.\n5.  Retrained ConvNeXt with a weighted loss function to handle the remaining imbalance.\n\n## 4. Inference Pipeline \nOur inference pipeline was designed to maximize recall and precision using ensemble techniques.\n\n1.  **Multi-Scale TTA:** `[640, 800, 512]`.\n2.  **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.\n3.  **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%.\n\n\n```python\n!pip install -q ultralytics ensemble_boxes\n\nimport os\nimport pandas as pd\nimport numpy as np\nfrom ultralytics import YOLO\nimport yaml\nfrom tqdm.notebook import tqdm\nimport cv2\nimport shutil\nimport glob\nfrom pathlib import Path\nfrom sklearn.model_selection import train_test_split, StratifiedKFold, KFold\nfrom sklearn.metrics import f1_score, accuracy_score\nimport joblib\nfrom ensemble_boxes import weighted_boxes_fusion\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms, ops\nimport matplotlib.pyplot as plt\nimport random\nimport json\nimport base64\nfrom io import BytesIO\nfrom IPython.display import HTML, display\nimport timm\nfrom PIL import Image\n```\n\nTraining YOLO using k fold 5\n\n```python\n# Global Seed Setup \ndef set_all_seeds(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    # Ensures deterministic algorithms are used where possible\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n\nset_all_seeds(42)\n\n# Setup Absolute Paths \nWORK_DIR = Path('/kaggle/input/dal-shemagh-detection-challenge').absolute()\nimages_path = WORK_DIR / 'images/train' \nall_images = sorted([f.name for f in images_path.glob('*') if f.suffix.lower() in ['.jpg', '.jpeg', '.png']])\nk = 5\nresults = {}\n\n# random_state=42 ensures the folds are always the same\nkf = KFold(n_splits=k, shuffle=True, random_state=42)\n\n# K-Fold Training Loop \nfor fold, (train_idx, val_idx) in enumerate(kf.split(all_images)):\n    # after some tests using the same random seed, 3ed fold usally gives the hieghst validation score. \n    if fold != 2: \n        continue\n    \n    train_list = [str(images_path / all_images[i]) for i in train_idx]\n    val_list = [str(images_path / all_images[i]) for i in val_idx]\n    \n    with open(f\"train_f{fold}.txt\", \"w\") as f: f.write(\"\\n\".join(train_list))\n    with open(f\"val_f{fold}.txt\", \"w\") as f: f.write(\"\\n\".join(val_list))\n\n    yaml_path = os.path.abspath(f\"fold_{fold}.yaml\")\n    with open(yaml_path, \"w\") as f:\n        yaml.dump({\n            'path': str(WORK_DIR),\n            'train': os.path.abspath(f\"train_f{fold}.txt\"),\n            'val': os.path.abspath(f\"val_f{fold}.txt\"),\n            'nc': 2, \n            'names': ['Face', 'Shemagh']\n        }, f)\n\n    # Initialize and Train\n    torch.cuda.empty_cache()\n    model = YOLO('yolo11m.pt') \n    \n    model.train(\n        data=yaml_path,\n        epochs=200,\n        imgsz=640,          \n        batch=16,              \n        device=1,\n        seed=42,\n        workers=4,              \n        deterministic=True,    \n\n        hsv_h=0.015,   \n        hsv_s=0.8,     \n        hsv_v=0.6,     \n        scale=0.8,\n\n        mosaic=1.0,    \n        mixup=0.1,     \n        copy_paste=0.3,\n        close_mosaic=50, \n        erasing=0.3,\n        \n        optimizer='SGD',\n        lr0=0.001,\n        project='Shemagh_KFold_BuiltIn3',\n        name=f'fold_{fold}'\n\n    )\n    \n\n    \n    stats = model.val()\n    if stats:\n        results[fold] = stats.results_dict\n    with open(\"kfold_results_backup.json\", \"w\") as f:\n        json.dump(results, f)\n```\n\nUse YOLO and heuristics To generate pseudo labels\n```python\n# CONFIGURATION\nMODEL_PATH = '/kaggle/working/runs/detect/Shemagh_KFold_BuiltIn3/fold_2/weights/best.pt'\nTEST_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\n\n# Inference Hyperparameters\nTTA_SCALES = [640, 800, 512]\nCONF_THRES = 0.40   # Lowered to capture all possible detections\nIOU_WBF = 0.50      # Aggressive merging for multi-scale consensus\nW_SYM = 0.8\nW_DX = 0.2\nFINAL_THRESH = 0.45 \n\n# Pseudo Labeling Thresholds\nSURE_CORRECT_THRESH = 0.85   \nSURE_INCORRECT_THRESH = 0.15 \n\n# HYBRID UTILITY FUNCTIONS\ndef xyxy_to_xywh(box):\n    x1, y1, x2, y2 = box\n    return [(x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1]\n\ndef clean_duplicate_shemaghs(shemaghs, overlap_threshold=0.50):\n    if not shemaghs: return []\n    cleaned = []\n    sorted_s = sorted(shemaghs, key=lambda x: x[2] * x[3], reverse=True)\n    for s_big in sorted_s:\n        keep = True\n        bx, by, bw, bh = s_big\n        for s_checked in cleaned:\n            cx, cy, cw, ch = s_checked\n            ix1, iy1 = max(bx-bw/2, cx-cw/2), max(by-bh/2, cy-ch/2)\n            ix2, iy2 = min(bx+bw/2, cx+cw/2), min(by+bh/2, cy+ch/2)\n            inter = max(0, ix2-ix1) * max(0, iy2-iy1)\n            if inter / (bw * bh) > overlap_threshold:\n                keep = False\n                break\n        if keep: cleaned.append(s_big)\n    return cleaned\n\ndef calculate_hybrid_features(head, shemagh):\n    hx, hy, hw, hh = head\n    sx, sy, sw, sh = shemagh\n    ix1, iy1 = max(hx-hw/2, sx-sw/2), max(hy-hh/2, sy-sh/2)\n    ix2, iy2 = min(hx+hw/2, sx+sw/2), min(hy+hh/2, sy+sh/2)\n    inter = max(0, ix2-ix1) * max(0, iy2-iy1)\n    ioh = inter / (hw * hh) if (hw * hh) > 0 else 0.0\n    half_area = (hw * hh) / 2\n    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))\n    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))\n    symmetry = abs((l_inter / half_area) - (r_inter / half_area)) if half_area > 0 else 1.0\n    h_ymin, s_ymin = hy - hh/2, sy - sh/2\n    return {\n        'ioh': ioh, 'symmetry': symmetry, 'dx_norm': abs(sx - hx) / hw,\n        'starts_at_top': s_ymin < (hy - hh/4), \n        'is_above': s_ymin < h_ymin,\n        'head_flat': hh < (hw * 0.9)\n    }\n\n\n# INFERENCE LOOP\ntest_files = sorted([f for f in os.listdir(TEST_IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])\nmodel = YOLO(MODEL_PATH)\nall_results = []\n\nprint(f\"Running inference on {len(test_files)} images...\")\n\nfor filename in tqdm(test_files):\n    img_path = os.path.join(TEST_IMG_DIR, filename)\n    img_orig = cv2.imread(img_path)\n    img_orig = cv2.cvtColor(img_orig, cv2.COLOR_BGR2RGB)\n    h_img, w_img, _ = img_orig.shape\n\n    # TTA Detection\n    b_list, s_list, l_list, raw_viz_boxes = [], [], [], []\n    for scale in TTA_SCALES:\n        res = model.predict(img_path, imgsz=scale, conf=CONF_THRES, augment=True, verbose=False)[0]\n        if len(res.boxes) > 0:\n            bn, cn, cl = res.boxes.xyxyn.cpu().tolist(), res.boxes.conf.cpu().tolist(), res.boxes.cls.cpu().tolist()\n            b_list.append(bn); s_list.append(cn); l_list.append(cl)\n            for b, s, l in zip(bn, cn, cl):\n                raw_viz_boxes.append({'box': b, 'cls': int(l)})\n\n    # Weighted Boxes Fusion\n    if b_list:\n        boxes, scores, labels = weighted_boxes_fusion(b_list, s_list, l_list, weights=None, iou_thr=IOU_WBF, skip_box_thr=CONF_THRES)\n    else:\n        boxes, scores, labels = [], [], []\n\n    # Apply Logic\n    raw_heads = [xyxy_to_xywh(b) for b, l in zip(boxes, labels) if int(l) == 0]\n    raw_shemaghs = [xyxy_to_xywh(b) for b, l in zip(boxes, labels) if int(l) == 1]\n    cleaned_shemaghs = clean_duplicate_shemaghs(raw_shemaghs)\n    \n    is_correct = 0\n    max_img_score = -1.0 # Default if no valid pair found\n    \n    if raw_heads and cleaned_shemaghs:\n        for h in raw_heads:\n            for s in cleaned_shemaghs:\n                f = calculate_hybrid_features(h, s)\n                if not f['is_above']: continue\n                if f['head_flat'] and f['ioh'] < 0.80: continue\n                \n                score = f['ioh'] - (W_SYM * f['symmetry']) - (W_DX * f['dx_norm'])\n                if f['starts_at_top']: score += 0.05\n                if score > max_img_score: max_img_score = score\n        \n        if max_img_score > FINAL_THRESH:\n            is_correct = 1\n\n    # Store Data\n    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}\" \n                  for b, s, l in zip(boxes, scores, labels)]\n    pred_str = \" \".join(pred_parts) if pred_parts else \"-\"\n    \n    all_results.append({\n        \"filename\": filename,\n        \"right_place\": is_correct,\n        \"prediction_string\": pred_str,\n        \"max_img_score\": max_img_score\n    })\n\n# EXPORTING DATA\ndf_all = pd.DataFrame(all_results)\n\n\ncertain_labels = []\nfor idx, row in df_all.iterrows():\n    if row['max_img_score'] >= SURE_CORRECT_THRESH:\n        certain_labels.append({\"filename\": row['filename'], \"label\": 1})\n    elif row['max_img_score'] <= SURE_INCORRECT_THRESH:\n        certain_labels.append({\"filename\": row['filename'], \"label\": 0})\n\ndf_pseudo = pd.DataFrame(certain_labels)\n\n# Filter for Label 1 \ndf_correct = df_pseudo[df_pseudo['label'] == 1].copy()\n\n# Save to a new file\noutput_csv = 'correct_images_only.csv'\ndf_correct.to_csv(output_csv, index=False)\n\n\nprint(f\"Filtered {len(df_correct)} correct images out of {len(df)} total pseudo labels.\")\nprint(f\"Saved to: {output_csv}\")\n```\n\nTraining ConvNext for 'right_place' using Pseudo labeling\n\n**Fun fact: we got 0.848 private score using only heuristics.**\n```python\n# CONFIGURATION\nORIGINAL_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/train'\nORIGINAL_CSV = '/kaggle/input/dal-shemagh-detection-challenge/train_labels.csv'\nPSEUDO_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\nPSEUDO_CSV = '/kaggle/working/correct_images_only.csv'\nMODEL_NAME = 'convnext_tiny.fb_in22k_ft_in1k'\nNUM_CLASSES = 2\nIMG_SIZE = 224\nBATCH_SIZE = 16\nEPOCHS = 15\nLEARNING_RATE = 5e-5\nN_FOLDS = 5\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nOUTPUT_DIR = 'ConvNext_KFold_Models'\n\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\n# DATASET CLASS\nclass ShemaghDataset(Dataset):\n    def __init__(self, df, transform=None):\n        self.df = df\n        self.transform = transform\n        \n    def __len__(self):\n        return len(self.df)\n    \n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        img_path = row['path']\n        label = int(row['right_place'])\n        \n        # Read Image using OpenCV \n        image = cv2.imread(img_path)\n        if image is None:\n            # Fallback (black image) to prevent crash\n            image = np.zeros((IMG_SIZE, IMG_SIZE, 3), dtype=np.uint8)\n            \n        # Convert BGR -> RGB -> PIL Image \n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        image = Image.fromarray(image)\n        \n        if self.transform:\n            image = self.transform(image)\n            \n        return image, torch.tensor(label, dtype=torch.long)\n\n\n# AUGMENTATIONS\ndef get_transforms(data='train'):\n    if data == 'train':\n        return transforms.Compose([\n            transforms.Resize((IMG_SIZE, IMG_SIZE)),\n            transforms.RandomHorizontalFlip(p=0.5),\n            transforms.RandomRotation(degrees=15),\n            transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.02),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n    else:\n        return transforms.Compose([\n            transforms.Resize((IMG_SIZE, IMG_SIZE)),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n\n# 4. TRAINING ENGINE\ndef train_one_epoch(model, loader, optimizer, criterion):\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n    \n    for images, labels in tqdm(loader, desc=\"Training\", leave=False):\n        images, labels = images.to(DEVICE), labels.to(DEVICE)\n        \n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        \n        running_loss += loss.item()\n        _, predicted = torch.max(outputs.data, 1)\n        total += labels.size(0)\n        correct += (predicted == labels).sum().item()\n        \n    return running_loss / len(loader), correct / total\n\ndef validate_one_epoch(model, loader, criterion):\n    model.eval()\n    running_loss = 0.0\n    all_preds = []\n    all_labels = []\n    \n    with torch.no_grad():\n        for images, labels in tqdm(loader, desc=\"Validating\", leave=False):\n            images, labels = images.to(DEVICE), labels.to(DEVICE)\n            \n            outputs = model(images)\n            loss = criterion(outputs, labels)\n            \n            running_loss += loss.item()\n            _, predicted = torch.max(outputs.data, 1)\n            \n            all_preds.extend(predicted.cpu().numpy())\n            all_labels.extend(labels.cpu().numpy())\n            \n    acc = accuracy_score(all_labels, all_preds)\n    f1 = f1_score(all_labels, all_preds, zero_division=0)\n    \n    return running_loss / len(loader), acc, f1\n\n# PIPELINE \nif __name__ == '__main__':\n    print(f\"--- Training ConvNeXt on {DEVICE} ---\")\n    \n    # 1. Load Data\n    df_orig = pd.read_csv(ORIGINAL_CSV)\n    df_orig['path'] = df_orig['filename'].apply(lambda x: os.path.join(ORIGINAL_IMG_DIR, x))\n    \n    df_pseudo = pd.read_csv(PSEUDO_CSV)\n    # rename from 'label' to 'right_place', thank you Omar \n    df_pseudo = df_pseudo.rename(columns={'label': 'right_place'})\n    df_pseudo['path'] = df_pseudo['filename'].apply(lambda x: os.path.join(PSEUDO_IMG_DIR, x))\n    \n    # Merge\n    df_final = pd.concat([df_orig, df_pseudo], axis=0).reset_index(drop=True)\n    \n    # Class Weighting Calculation\n    counts = df_final['right_place'].value_counts()\n    print(f\"Class Distribution: {counts.to_dict()}\")\n    \n    n_class0 = counts.get(0, 1)\n    n_class1 = counts.get(1, 1)\n    # Weight Class 1 higher to force model to pay attention to it\n    pos_weight = n_class0 / n_class1\n    class_weights = torch.tensor([1.0, float(pos_weight)]).to(DEVICE)\n    print(f\"Using Class Weights: [1.0, {pos_weight:.2f}]\")\n\n    # 2. Stratified K-Fold\n    skf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=42)\n    \n    for fold, (train_idx, val_idx) in enumerate(skf.split(df_final, df_final['right_place'])):\n        if fold > 0: \n            break\n        \n        train_df = df_final.iloc[train_idx].reset_index(drop=True)\n        val_df = df_final.iloc[val_idx].reset_index(drop=True)\n        \n        # Loaders (Workers=0 for Windows Safety)\n        train_ds = ShemaghDataset(train_df, transform=get_transforms('train'))\n        val_ds = ShemaghDataset(val_df, transform=get_transforms('val'))\n        \n        train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)\n        val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)\n        \n        # Model Setup\n        model = timm.create_model(MODEL_NAME, pretrained=True, num_classes=NUM_CLASSES)\n        model = model.to(DEVICE)\n        \n        optimizer = optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=1e-2)\n        criterion = nn.CrossEntropyLoss(weight=class_weights)\n        \n        best_val_f1 = 0.0\n        \n        for epoch in range(EPOCHS):\n            train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion)\n            val_loss, val_acc, val_f1 = validate_one_epoch(model, val_loader, criterion)\n            \n            print(f\"Epoch {epoch+1:02d} | Train Loss: {train_loss:.4f} | Val F1: {val_f1:.4f}\")\n            \n            if val_f1 > best_val_f1:\n                best_val_f1 = val_f1\n                save_path = os.path.join(OUTPUT_DIR, f\"convnext_fold{fold}_best.pth\")\n                torch.save(model.state_dict(), save_path)\n                \n        print(f\"Fold {fold+1} Finished. Best F1: {best_val_f1:.4f}\")\n        \n        # Cleanup\n        del model, optimizer\n        torch.cuda.empty_cache()\n\n    print(f\"\\nTraining Complete. Models saved to {OUTPUT_DIR}\")\n```\n\nGenerate Submission\n```python\n# CONFIGURATION\n\nYOLO_MODEL_PATH = '/kaggle/working/runs/detect/Shemagh_KFold_BuiltIn3/fold_2/weights/best.pt' \n# after some experiments, the first fold gives the hieghts validation score. \nCLS_MODEL_PATH = '/kaggle/working/ConvNext_KFold_Models/convnext_fold0_best.pth' \nTEST_IMG_DIR = '/kaggle/input/dal-shemagh-detection-challenge/images/test'\n\n# Inference Settings\nTTA_SCALES = [640, 800, 512]\nCONF_THRES = 0.05 \nIOU_WBF = 0.60  \nNMS_THRES = 0.65 \n\n# Classifier Settings\nCLS_MODEL_NAME = 'convnext_tiny.fb_in22k_ft_in1k'\nIMG_SIZE = 224\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n\n\n# UTILITY FUNCTIONS\ndef xyxy_to_xywh(box):\n    \"\"\"Converts [x1, y1, x2, y2] to [x_center, y_center, w, h].\"\"\"\n    x1, y1, x2, y2 = box\n    w = x2 - x1\n    h = y2 - y1\n    x = (x1 + x2) / 2\n    y = (y1 + y2) / 2\n    return [x, y, w, h]\n\ndef get_inference_transforms():\n    \"\"\"Exact same transforms used in Validation.\"\"\"\n    return transforms.Compose([\n        transforms.Resize((IMG_SIZE, IMG_SIZE)),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n    ])\n\ndef apply_nms(boxes, scores, labels, iou_threshold=0.65):\n    \"\"\"\n    Applies Non-Maximum Suppression per class.\n    If overlap > threshold, keeps only the one with higher confidence.\n    \"\"\"\n    if len(boxes) == 0:\n        return [], [], []\n\n    # Convert to tensor\n    boxes_t = torch.tensor(boxes, dtype=torch.float32)\n    scores_t = torch.tensor(scores, dtype=torch.float32)\n    labels_t = torch.tensor(labels, dtype=torch.int64)\n\n    keep_indices = []\n    \n    # Apply NMS for each class independently\n    unique_labels = labels_t.unique()\n    for lbl in unique_labels:\n        mask = (labels_t == lbl)\n        class_boxes = boxes_t[mask]\n        class_scores = scores_t[mask]\n        original_indices = torch.where(mask)[0]\n        \n        # torchvision NMS returns indices to keep\n        nms_keep = ops.nms(class_boxes, class_scores, iou_threshold)\n        \n        # Map back to original indices\n        keep_indices.extend(original_indices[nms_keep].tolist())\n\n    keep_indices = sorted(keep_indices)\n    \n    return [boxes[i] for i in keep_indices], \\\n           [scores[i] for i in keep_indices], \\\n           [labels[i] for i in keep_indices]\n\n# LOAD MODELS\nprint(f\"Loading YOLO: {YOLO_MODEL_PATH}\")\nyolo_model = YOLO(YOLO_MODEL_PATH)\n\nprint(f\"Loading ConvNeXt: {CLS_MODEL_PATH}\")\ncls_model = timm.create_model(CLS_MODEL_NAME, pretrained=False, num_classes=2)\n# Load weights\nstate_dict = torch.load(CLS_MODEL_PATH, map_location=DEVICE)\ncls_model.load_state_dict(state_dict)\ncls_model.to(DEVICE)\ncls_model.eval()\n\ncls_transforms = get_inference_transforms()\n\n\n# FINAL INFERENCE PIPELINE\nprint(\"\\nStarting Final Inference...\")\n\ntest_files = sorted([f for f in os.listdir(TEST_IMG_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])\nsubmission_rows = []\n\nfor filename in tqdm(test_files, desc=\"Processing Images\"):\n    img_path = os.path.join(TEST_IMG_DIR, filename)\n    \n    # YOLO TTA \n    boxes_list = []\n    scores_list = []\n    labels_list = []\n    \n    for scale in TTA_SCALES:\n        results = yolo_model.predict(img_path, imgsz=scale, conf=CONF_THRES, augment=True, verbose=False)[0]\n        \n        if len(results.boxes) > 0:\n            boxes_list.append(results.boxes.xyxyn.cpu().tolist())\n            scores_list.append(results.boxes.conf.cpu().tolist())\n            labels_list.append(results.boxes.cls.cpu().tolist())\n\n    # WBF \n    if len(boxes_list) > 0:\n        boxes, scores, labels = weighted_boxes_fusion(\n            boxes_list, scores_list, labels_list, \n            weights=None, iou_thr=IOU_WBF, skip_box_thr=CONF_THRES\n        )\n    else:\n        boxes, scores, labels = [], [], []\n\n    # NMS  \n    boxes, scores, labels = apply_nms(boxes, scores, labels, iou_threshold=NMS_THRES)\n\n    # Prepare Logic & Prediction String \n    has_head = False\n    has_shemagh = False\n    pred_parts = []\n    \n    for b, s, l in zip(boxes, scores, labels):\n        xywh = xyxy_to_xywh(b)\n        x, y, w, h = xywh\n        \n        pred_parts.append(f\"{int(l)} {s:.4f} {x:.4f} {y:.4f} {w:.4f} {h:.4f}\")\n        \n        if int(l) == 0: has_head = True\n        elif int(l) == 1: has_shemagh = True\n            \n    prediction_string = \" \".join(pred_parts) if pred_parts else \"-\"\n\n    # Classifier Logic \n    is_correct = 0\n    \n    if has_head or has_shemagh: # YOLO might not see all faces and shemaghs in the image\n        # Load and Preprocess Image for Classifier\n        # Read with OpenCV to match training pipeline\n        img_cv = cv2.imread(img_path)\n        img_cv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)\n        img_pil = Image.fromarray(img_cv)\n        \n        input_tensor = cls_transforms(img_pil).unsqueeze(0).to(DEVICE)\n        \n        with torch.no_grad():\n            outputs = cls_model(input_tensor)\n            # Get class with highest score (0 or 1)\n            probs = torch.softmax(outputs, dim=1)\n            pred_cls = torch.argmax(probs, dim=1).item()\n            \n            # Assuming Class 1 is \"Right Place\"\n            is_correct = pred_cls\n\n    submission_rows.append({\n        \"filename\": filename,\n        \"right_place\": is_correct,\n        \"prediction_string\": prediction_string\n    })\n\n# 5. SAVE SUBMISSION\ndf_sub = pd.DataFrame(submission_rows)\ndf_sub.to_csv(\"submission.csv\", index=False)\n\nprint(\"Submission saved to submission.csv\")\nprint(f\"Total Positive Predictions: {df_sub['right_place'].sum()}\")\n```\n\n### Scores may vary due to the difference in GPU because training was done in a local GPU\n* T4: private score 0.862 | public score 0.904. \n* P100: private score 0.863 | public score 0.878 \n* RTX 5070: private score 0.860 | public score 0.884",
    "3408079": "86% with a single model is huge work!!, gl in next challanges ",
    "3407686": "Goated writeup, thanks for sharing! 86% private with a single model is insane ngl",
    "3407680": ""
  }
}