{"metadata":{"kaggle":{"accelerator":"gpu","dataSources":[{"datasetId":112509,"sourceId":112509,"sourceType":"competition"}],"isGpuEnabled":true,"isInternetEnabled":true,"language":"python"},"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"pygments_lexer":"ipython3","nbconvert_exporter":"python","version":"3.6.4","file_extension":".py","codemirror_mode":{"name":"ipython","version":3},"name":"python","mimetype":"text/x-python"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"9e73b7bd-8e32-47ce-bd73-324cfa4e08f4","cell_type":"markdown","source":"# CSIRO Image2Biomass: Dual-Stream Inference & Submission\n\nThis notebook implements **Multi-Backbone Super-Ensemble Inference & Submission** combining predictions from both **DINOv3 ViT** and **ConvNeXt-V2 Large** 5-fold models.\n\n### 🏆 Why ViT + ConvNeXt Multi-Backbone Ensembling Wins\n1. **Inductive Complementarity**: ViT uses global self-attention across image patch tokens; ConvNeXt-V2 uses 7×7 depthwise convolutions with Global Response Normalization (GRN). Ensembling both cancels out individual structural errors and was the cornerstone of the top-3 solutions.\n2. **Automatic Architecture Recognition**: Checks checkpoint layer shapes (768 vs 1536 channels) and automatically instantiates the exact corresponding backbone (`vit_base_patch16_dinov3_qkvb` or `convnextv2_large`).\n3. **Dual-Stream 1:1 Square Splitting**: $2000 \\times 1000 \\to$ Left & Right $1000 \\times 1000$ views resized to $512 \\times 512$.\n4. **Cross-View Multi-Head Self-Attention**: Spatial interaction across the seam for both ViT and ConvNeXt.\n5. **Test-Time Augmentation (TTA)**: Evaluates normal views and horizontally flipped mirrored views.\n6. **Soft Physical Calibration & Fringe Expansion**: Calibrates mass conservation and expands dead thatch dynamic range.\n7. **Multi-Output Submissions**: Outputs `submission.csv` (the blended super-ensemble), plus `submission_vit.csv` and `submission_convnext.csv` for fine-grained validation.\n\n### 🚀 How to Use:\n1. **Attach your trained model datasets** via **+ Add Input** (you can attach DINO ViT models, ConvNeXt-V2 models, or both!).\n2. Ensure the competition dataset (`csiro-biomass`) is attached.\n3. Turn on GPU in Notebook Settings (`Accelerator -> GPU T4 or P100`).\n4. **Run All** -> Automatically detects models, runs TTA inference, blends families, and outputs `submission.csv` ready to submit.\n","metadata":{}},{"id":"5257f1f2-e2ef-4630-bcd8-7667baab1de2","cell_type":"code","source":"# 1. Environment Setup & Offline Support\nimport os\nimport sys\nimport glob\nimport random\nimport subprocess\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\nimport cv2\nfrom tqdm.auto import tqdm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms\n\n# Handle timm: works online or from offline .whl if attached\ntry:\n    import timm\nexcept ImportError:\n    wheels = glob.glob('/kaggle/input/**/timm*.whl', recursive=True)\n    if len(wheels) > 0:\n        subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-q', wheels[0]])\n    else:\n        subprocess.check_call(['pip', 'install', '-q', 'timm'])\n    import timm\n\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f'Using device: {DEVICE}')\nif torch.cuda.is_available():\n    print(f'GPU: {torch.cuda.get_device_name(0)}')\nelse:\n    print('\\n' + '!'*60)\n    print('[!] CRITICAL WARNING: Running on CPU! Inference will be 50x slower.')\n    print('    Please turn on GPU in Notebook Settings (right panel -> Accelerator -> GPU T4 or P100).')\n    print('!'*60 + '\\n')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"b4dfd5d0-06b3-41c7-a5c3-89a299c637cd","cell_type":"code","source":"# 2. Configuration & Automatic Path & Checkpoint Discovery\ndef find_data_dir():\n    candidates = [\n        '/kaggle/input/competitions/csiro-biomass',\n        '/kaggle/input/csiro-biomass',\n        '../input/competitions/csiro-biomass',\n        '../input/csiro-biomass',\n        './data',\n        '.'\n    ]\n    for c in candidates:\n        if os.path.exists(os.path.join(c, 'test.csv')):\n            return c\n    if os.path.exists('/kaggle/input'):\n        for root, _, files in os.walk('/kaggle/input'):\n            if 'test.csv' in files:\n                return root\n    return '.'\n\ndef find_model_checkpoints():\n    \"\"\"Finds all uploaded model checkpoint (.pt or .pth) files across /kaggle/input and working directory.\"\"\"\n    found = []\n    if os.path.exists('/kaggle/input'):\n        for root, _, files in os.walk('/kaggle/input'):\n            for f in files:\n                if f.endswith('.pt') or f.endswith('.pth'):\n                    found.append(os.path.join(root, f))\n    \n    for f in sorted(glob.glob('*.pt') + glob.glob('*.pth')):\n        p = os.path.abspath(f)\n        if p not in found:\n            found.append(p)\n            \n    model_files = [f for f in found if 'model' in f.lower() or 'fold' in f.lower()]\n    return sorted(model_files if model_files else found)\n\nclass CFG:\n    DATA_DIR = find_data_dir()\n    TEST_CSV = os.path.join(DATA_DIR, 'test.csv')\n    SAMPLE_SUB_CSV = os.path.join(DATA_DIR, 'sample_submission.csv')\n    TEST_IMG_DIR = os.path.join(DATA_DIR, 'test') if os.path.exists(os.path.join(DATA_DIR, 'test')) else DATA_DIR\n    \n    IMG_SIZE = 512\n    FUSION_DIM = 384\n    NUM_INTERVALS = 7\n    BATCH_SIZE = 8\n    USE_TTA = True\n    \n    # Multi-Backbone Ensemble Blending Weights (Normalized if both present)\n    WEIGHT_VIT = 0.50       # Weight for DINOv3 ViT-Base models\n    WEIGHT_CONVNEXT = 0.50  # Weight for ConvNeXt-V2 Large models\n    \n    TARGET_ORDER = ['Dry_Green_g', 'Dry_Dead_g', 'Dry_Clover_g', 'GDM_g', 'Dry_Total_g']\n    IMAGENET_MEAN = [0.485, 0.456, 0.406]\n    IMAGENET_STD = [0.229, 0.224, 0.225]\n\nprint(f'DATA_DIR: {CFG.DATA_DIR}')\nprint(f'TEST_CSV: {CFG.TEST_CSV} (Exists: {os.path.exists(CFG.TEST_CSV)})')\n\nCHECKPOINTS = find_model_checkpoints()\nprint(f'\\nDiscovered {len(CHECKPOINTS)} Model Checkpoint(s):')\nfor cp in CHECKPOINTS:\n    print(f'  - {cp} ({os.path.getsize(cp) / (1024*1024):.1f} MB)')\n\nif len(CHECKPOINTS) == 0:\n    print('\\n[!] Note: No .pt/.pth checkpoints found yet.')\n    print('    Please upload your trained models as a Kaggle dataset and attach it via \"+ Add Input\".')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"686c61c0-59ec-4573-9d6d-df5c93c1e885","cell_type":"code","source":"# 3. DualStreamBiomassModel Architecture & Smart Architecture Detector\ndef detect_model_architecture(state_dict):\n    \"\"\"\n    Inspects state_dict tensor dimensions and keys to automatically determine\n    whether the model is ConvNeXt-V2 Large or DINOv3 ViT-Base.\n    \"\"\"\n    if isinstance(state_dict, dict) and 'state_dict' in state_dict:\n        if 'backbone' in state_dict:\n            return state_dict['backbone'], state_dict['state_dict']\n        state_dict = state_dict['state_dict']\n        \n    clean_sd = {k.replace('module.', ''): v for k, v in state_dict.items()}\n    \n    # 1. Primary check: cross-view attention projection embed dimension\n    if 'cross_view_attn.in_proj_weight' in clean_sd:\n        dim = clean_sd['cross_view_attn.in_proj_weight'].shape[1]\n        if dim == 1536:\n            return 'convnextv2_large', clean_sd\n        elif dim == 768:\n            return 'vit_base_patch16_dinov3_qkvb', clean_sd\n            \n    # 2. Secondary check: layer name patterns\n    keys = list(clean_sd.keys())\n    if any('stages' in k for k in keys) or any('convnext' in k.lower() for k in keys):\n        return 'convnextv2_large', clean_sd\n        \n    return 'vit_base_patch16_dinov3_qkvb', clean_sd\n\nclass DualStreamBiomassModel(nn.Module):\n    def __init__(self, backbone_name='vit_base_patch16_dinov3_qkvb', num_targets=5, num_intervals=7, fusion_dim=384, dropout=0.3, pretrained=False, **kwargs):\n        super().__init__()\n        self.backbone_name = backbone_name\n        self.backbone = timm.create_model(backbone_name, pretrained=pretrained, num_classes=0)\n        self.backbone_dim = self.backbone.num_features\n        \n        num_heads = 8 if self.backbone_dim % 8 == 0 else 4\n        self.cross_view_attn = nn.MultiheadAttention(embed_dim=self.backbone_dim, num_heads=num_heads, dropout=0.1, batch_first=True)\n        self.attn_norm = nn.LayerNorm(self.backbone_dim)\n        \n        self.fusion_mlp = nn.Sequential(\n            nn.Linear(self.backbone_dim * 2, fusion_dim),\n            nn.LayerNorm(fusion_dim),\n            nn.GELU(),\n            nn.Dropout(dropout)\n        )\n        \n        self.reg_heads = nn.ModuleList([\n            nn.Sequential(\n                nn.Linear(fusion_dim, fusion_dim // 2),\n                nn.LayerNorm(fusion_dim // 2),\n                nn.GELU(),\n                nn.Dropout(dropout * 0.5),\n                nn.Linear(fusion_dim // 2, 64),\n                nn.GELU(),\n                nn.Linear(64, 1)\n            ) for _ in range(num_targets)\n        ])\n        \n        self.cls_heads = nn.ModuleList([\n            nn.Sequential(\n                nn.Linear(fusion_dim, 128),\n                nn.LayerNorm(128),\n                nn.GELU(),\n                nn.Dropout(dropout * 0.5),\n                nn.Linear(128, num_intervals)\n            ) for _ in range(num_targets)\n        ])\n\n    def extract_features(self, x):\n        feats = self.backbone(x)\n        return feats.mean(dim=1) if len(feats.shape) == 3 else feats.mean(dim=[2, 3]) if len(feats.shape) == 4 else feats\n\n    def forward(self, img_left, img_right):\n        feat_l = self.extract_features(img_left)\n        feat_r = self.extract_features(img_right)\n        \n        tokens = torch.stack([feat_l, feat_r], dim=1)\n        attn_out, _ = self.cross_view_attn(tokens, tokens, tokens)\n        tokens = self.attn_norm(tokens + attn_out)\n        \n        fused = self.fusion_mlp(torch.cat([tokens[:, 0], tokens[:, 1]], dim=-1))\n        reg_preds = [F.softplus(head(fused)) for head in self.reg_heads]\n        cls_preds = [head(fused) for head in self.cls_heads]\n        return reg_preds, cls_preds\n","metadata":{},"outputs":[],"execution_count":null},{"id":"ca29a735-9a00-438b-aa43-28d3e87b4238","cell_type":"code","source":"# 4. Dual-Stream Test Dataset & Image Resolver\nclass DualStreamTestDataset(Dataset):\n    def __init__(self, df, img_dir=CFG.TEST_IMG_DIR, img_size=CFG.IMG_SIZE):\n        self.df = df.reset_index(drop=True)\n        self.img_dir = img_dir\n        self.img_size = img_size\n        self.transform = transforms.Compose([\n            transforms.Resize((img_size, img_size)),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=CFG.IMAGENET_MEAN, std=CFG.IMAGENET_STD),\n        ])\n\n    def __len__(self):\n        return len(self.df)\n\n    def _resolve_image_path(self, raw_path):\n        if os.path.exists(raw_path):\n            return raw_path\n        fname = os.path.basename(raw_path)\n        candidates = [\n            os.path.join(CFG.DATA_DIR, raw_path),\n            os.path.join(CFG.DATA_DIR, 'test', fname),\n            os.path.join(CFG.DATA_DIR, 'train', fname),\n            os.path.join(self.img_dir, fname) if self.img_dir else None,\n            os.path.join(self.img_dir, raw_path) if self.img_dir else None,\n            os.path.join('/kaggle/input/competitions/csiro-biomass', raw_path),\n            os.path.join('/kaggle/input/csiro-biomass', raw_path),\n            os.path.join('test', fname),\n            os.path.join('train', fname)\n        ]\n        for c in candidates:\n            if c and os.path.exists(c):\n                return c\n        return raw_path\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        path = self._resolve_image_path(row['image_path'])\n        img = cv2.imread(path)\n        if img is None:\n            img = np.zeros((1000, 2000, 3), dtype=np.uint8)\n        else:\n            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n            \n        h, w, _ = img.shape\n        mid = w // 2\n        left_np = img[:, :mid, :]\n        right_np = img[:, mid:, :]\n        \n        tensor_l = self.transform(Image.fromarray(left_np))\n        tensor_r = self.transform(Image.fromarray(right_np))\n        \n        return {\n            'image_left': tensor_l,\n            'image_right': tensor_r,\n            'sample_id': row.get('sample_id', row.get('clean_id', f'sample_{idx}')),\n        }\n","metadata":{},"outputs":[],"execution_count":null},{"id":"2db117d8-252d-4ded-9fd7-05aad94169a6","cell_type":"code","source":"# 5. Soft Physical Post-Processing & Calibration\ndef soft_physics_postprocess(preds_np):\n    \"\"\"Enforces physical mass relationships and thatch fringe expansion:\"\"\"\n    preds = np.maximum(preds_np.copy(), 0.0)\n    green = preds[:, 0]\n    dead = preds[:, 1]\n    clover = preds[:, 2] * 0.8\n    gdm = preds[:, 3]\n    total = preds[:, 4]\n    \n    # 3rd-Place Dead Thatch Fringe Expansion\n    dead = np.where(dead > 20.0, dead * 1.1, np.where(dead < 10.0, dead * 0.9, dead))\n    \n    # Mass-conservation blends\n    gdm_blended = 0.5 * gdm + 0.5 * (green + clover)\n    total_blended = 0.5 * total + 0.5 * (green + clover + dead)\n    \n    return np.maximum(np.column_stack([green, dead, clover, gdm_blended, total_blended]), 0.0)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"2ff7325c-d961-458e-98b1-63f57e2f36fb","cell_type":"code","source":"# 6. Multi-Backbone Super-Ensemble Inference with TTA\nassert len(CHECKPOINTS) > 0, 'ERROR: No checkpoints found! Please attach your trained models dataset to the notebook.'\n\n# 1. Read Test Data\ntest_df_raw = pd.read_csv(CFG.TEST_CSV)\nif 'target_name' in test_df_raw.columns:\n    test_df_raw['clean_id'] = test_df_raw['sample_id'].astype(str).apply(lambda x: x.split('__')[0])\n    unique_test = test_df_raw[['clean_id', 'image_path']].drop_duplicates(subset=['clean_id']).reset_index(drop=True)\nelse:\n    unique_test = test_df_raw.copy()\n    if 'clean_id' not in unique_test.columns:\n        unique_test['clean_id'] = unique_test['sample_id']\n\nprint(f'Test set: {len(unique_test)} unique pasture images to predict.')\n\n# 2. Create DataLoader\ntest_dataset = DualStreamTestDataset(unique_test, CFG.TEST_IMG_DIR, CFG.IMG_SIZE)\ntest_loader = DataLoader(test_dataset, batch_size=CFG.BATCH_SIZE, shuffle=False, num_workers=0)\n\n# 3. Predict across all discovered model checkpoints, grouped by architecture\nvit_predictions = []\nconvnext_predictions = []\nall_model_info = []\n\nfor cp_idx, cp_path in enumerate(CHECKPOINTS):\n    cp_name = os.path.basename(cp_path)\n    raw_state = torch.load(cp_path, map_location=DEVICE)\n    backbone_name, clean_sd = detect_model_architecture(raw_state)\n    \n    family = 'ConvNeXt' if 'convnext' in backbone_name else 'DINO ViT'\n    print(f'\\n[{cp_idx + 1}/{len(CHECKPOINTS)}] Evaluating: {cp_name} | Family: {family} ({backbone_name})...')\n    all_model_info.append({'path': cp_path, 'name': cp_name, 'backbone': backbone_name, 'family': family})\n    \n    model = DualStreamBiomassModel(backbone_name=backbone_name, pretrained=False).to(DEVICE)\n    model.load_state_dict(clean_sd)\n    model.eval()\n    \n    fold_preds = []\n    with torch.no_grad():\n        for batch in tqdm(test_loader, desc=f'Inference {cp_name}', leave=False):\n            img_l = batch['image_left'].to(DEVICE)\n            img_r = batch['image_right'].to(DEVICE)\n            \n            if CFG.USE_TTA:\n                # Standard view\n                r_std, _ = model(img_l, img_r)\n                # Mirrored horizontal view (flip right view as left, flip left view as right)\n                r_flip, _ = model(torch.flip(img_r, [3]), torch.flip(img_l, [3]))\n                avg_r = [(a + b) * 0.5 for a, b in zip(r_std, r_flip)]\n            else:\n                avg_r, _ = model(img_l, img_r)\n                \n            pred_batch = torch.cat(avg_r, dim=1).cpu().numpy()\n            fold_preds.append(pred_batch)\n            \n    fold_preds_arr = np.concatenate(fold_preds, axis=0)\n    if family == 'ConvNeXt':\n        convnext_predictions.append(fold_preds_arr)\n    else:\n        vit_predictions.append(fold_preds_arr)\n\n# 4. Multi-Backbone Blended Ensembling\nprint(f'\\n======================================================')\nprint(f'Ensemble Summary:')\nprint(f'  - DINO ViT Models:     {len(vit_predictions)}')\nprint(f'  - ConvNeXt-V2 Models:  {len(convnext_predictions)}')\nprint(f'======================================================')\n\nif len(vit_predictions) > 0 and len(convnext_predictions) > 0:\n    vit_raw = np.mean(vit_predictions, axis=0)\n    convnext_raw = np.mean(convnext_predictions, axis=0)\n    \n    # Normalize blend weights\n    w_total = CFG.WEIGHT_VIT + CFG.WEIGHT_CONVNEXT\n    w_v = CFG.WEIGHT_VIT / w_total\n    w_c = CFG.WEIGHT_CONVNEXT / w_total\n    \n    ensemble_raw = w_v * vit_raw + w_c * convnext_raw\n    print(f'✓ Multi-Backbone Blend Applied: {w_v:.0%} ViT + {w_c:.0%} ConvNeXt-V2')\nelif len(vit_predictions) > 0:\n    ensemble_raw = np.mean(vit_predictions, axis=0)\n    print(f'✓ ViT-Only Ensemble Applied ({len(vit_predictions)} models)')\nelse:\n    ensemble_raw = np.mean(convnext_predictions, axis=0)\n    print(f'✓ ConvNeXt-Only Ensemble Applied ({len(convnext_predictions)} models)')\n\nensemble_post = soft_physics_postprocess(ensemble_raw)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"51773be0-5e4d-4988-a76f-3c2d75bf562a","cell_type":"code","source":"# 7. Generate, Verify & Export Submissions\nclean_ids = unique_test['clean_id'].tolist()\n\ndef make_submission(preds_post, filename):\n    pred_dict = {\n        clean_ids[i]: {col: float(preds_post[i, c_idx]) for c_idx, col in enumerate(CFG.TARGET_ORDER)}\n        for i in range(len(clean_ids))\n    }\n    \n    if 'target_name' in test_df_raw.columns:\n        sub_df = test_df_raw.copy()\n        sub_df['target'] = sub_df.apply(\n            lambda row: pred_dict.get(row['clean_id'], {}).get(row['target_name'], 0.0), axis=1\n        )\n        res = sub_df[['sample_id', 'target']].copy()\n    elif os.path.exists(CFG.SAMPLE_SUB_CSV):\n        sub_t = pd.read_csv(CFG.SAMPLE_SUB_CSV)\n        sub_t['clean_id'] = sub_t['sample_id'].astype(str).apply(lambda x: x.split('__')[0])\n        sub_t['target_name'] = sub_t['sample_id'].astype(str).apply(lambda x: x.split('__')[1])\n        sub_t['target'] = sub_t.apply(\n            lambda row: pred_dict.get(row['clean_id'], {}).get(row['target_name'], 0.0), axis=1\n        )\n        res = sub_t[['sample_id', 'target']].copy()\n    else:\n        records = []\n        for cid in clean_ids:\n            for col in CFG.TARGET_ORDER:\n                records.append({'sample_id': f'{cid}__{col}', 'target': pred_dict[cid][col]})\n        res = pd.DataFrame(records)\n        \n    assert res['target'].isna().sum() == 0, f'ERROR: {filename} contains NaN values!'\n    assert len(res) > 0, f'ERROR: {filename} is empty!'\n    res.to_csv(filename, index=False)\n    return res\n\n# Primary blended submission (evaluated by Kaggle)\nfinal_sub = make_submission(ensemble_post, 'submission.csv')\n\n# Individual family auxiliary submissions if both available\nif len(vit_predictions) > 0 and len(convnext_predictions) > 0:\n    make_submission(soft_physics_postprocess(np.mean(vit_predictions, axis=0)), 'submission_vit.csv')\n    make_submission(soft_physics_postprocess(np.mean(convnext_predictions, axis=0)), 'submission_convnextv2.csv')\n    print('Saved auxiliary submissions: submission_vit.csv and submission_convnextv2.csv')\n\nprint('=' * 50)\nprint(f'SUCCESS! Primary submission saved to: submission.csv')\nprint(f'Total predictions: {len(final_sub)}')\nprint('=' * 50)\nprint('\\nTarget Distribution:')\nprint(final_sub['target'].describe())\nprint('\\nFirst 10 Rows:')\nprint(final_sub.head(10))\n","metadata":{},"outputs":[],"execution_count":null}]}