{
  "id": 679278,
  "title": "2nd Place Solution Vesuvius Challenge – A postprocessing win",
  "url": "/competitions/vesuvius-challenge-surface-detection/writeups/2nd-place-solution-vesuvius-challenge-a-postproc",
  "author_name": "",
  "post_date": "2026-02-28T12:40:12.677Z",
  "votes": 27,
  "comment_count": 9,
  "views": 0,
  "content": "<p>First of all, we want to thank kaggle and the organizers for this interesting and fun competition. A special thank you to <a href=\"https://www.kaggle.com/giorgioangelotti\" target=\"_blank\">@giorgioangelotti</a> and <a href=\"https://www.kaggle.com/seanjohnsonsp\" target=\"_blank\">@seanjohnsonsp</a> for being great hosts. Eventhough there has been negative feedback in some regard, I was impressed and very happy with the constant communication, even if difficult decission had to be made. That is not a given, so thank you alot!</p>\n<h1>Overview:</h1>\n<p>We used nnUNet to train 128³ and 160³ patchsize models and ensembled them with a 40%/60% weighting in favor of the 160³ model. The postprocessing consists of local componentwise interpolation with multiple segmentation masks (different thresholds).</p>\n<h3>Detailed model description:</h3>\n<h5>Single Model Strategy</h5>\n<p>We trained two models on vanilla nnUNet ResEncTrainer with the following settings:</p>\n<p>patch size: 128\nbatch size: 2\nepochs: 1000\nfold: all</p>\n<p>patch size: 160\nbatch size: 2\nepochs: 1000\nfold: all</p>\n<h5>Ensemble Strategy</h5>\n<p>We summed the logits with a weighting of 60% of 160³ model and 40% of 128³ model. This was just visually tuned, since the 160³ model scored higher than the other one.</p>\n<h5>Model history:</h5>\n<p>We started with a 128³ model trained on 80% of the training data. We used this model for tuning the thresholds for the final 128³ model, which seemed to translate well. We finished training the full 160³ model in the last days of the competition and the thresholds from the 128³ model didn't apply to it, so we didnt have time to tune the thresholds for the ensemble. It was mostly done on a visual basis and a few LB submissions.</p>\n<p>For a really long time we tried to come up with a better model by changing the loss function. I invested alot of time to implement the „Skeaw-BorT“-Loss from a paper I found, just to realize that it doesn't work here, since the background can't be split into components. The medialsurface loss also didn't help for the following reason: The resulting model would perform better on topo score but its performance on surface dice would decrease. We had plenty ideas to increase the topo score by postprocessing, so it was way more important to have models, which score as high as possible on surface dice, than having models which were decent at everything.</p>\n<h3>Post-processing:</h3>\n<p>After ensembling the model logits (mirroring+rotation TTA), we created multiple segmentation masks with the following postprocessing structure:</p>\n<ol>\n<li>topo_postprocess:\nConsisting of 3d Hysteresis, 3d Anisotropic Closing, Dust Removel\nThis is the same postprocessing, which can be found in the public workbooks.</li>\n<li>Set the 3 voxels of each volume face to 0 (these are label 2 voxels)</li>\n<li>Dust removel again (size 1000)</li>\n</ol>\n<p>The reason for step 2 and 3 are for special cases, where after removing label 2 it could create artifacts. This was a known issue in this competition, but we didnt have any information on label 2 positions other than the 3 voxel at each volume face.</p>\n<p>In the highest final submission score, we used 4 thesholds for topo_postprocess:</p>\n<p>The base segmentation:\nT_low: 0.2 </p>\n<p>The fine tuning segmentations:\nT_low: 0.5 \nT_low: 0.6\nT_low: 0.7</p>\n<p>After that we performed binary_fill_holes on the base segmentation and cropped the volumes by removing the 3 voxels of each volume face for every segmentation. Why didnt we do this right away? This step was implemented way later and wasnt important for the submitted solution, I will explain it in the history section or a comment later.\nI had a eurika moment, when i realized the tunnel/holes can be quickly located (the existence) by calculating the euler number:</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F7547dea7c615352e60a995c6bf47c619%2Feuler.png?generation=1772280672154738&amp;alt=media\" alt=\"euler\"></p>\n<p>(C=1 (components), H=0 (cavities), T=#Holes/Tunnels)</p>\n<p>Now we performed the following steps:</p>\n<ol>\n<li>Take the base segmentation and split it into volumes with just one component</li>\n<li>Window-check small patches of each volume for tunnel/holes by calculating the euler number</li>\n<li>a) Euler number &gt;= 1 → save component segmentation</li>\n<li>b) Euler number &lt; 1 → Save each patch, if the patches overlap, merge them into a bigger patch. (we were running the window check with overlap 50%)</li>\n<li>For each (merged) patch → Project the component onto a 2d grid by interpolating it on a given number of coords. Perform smoothing, remove „over“-interpolation by doing flood-remove from the edges, thicken it with binary_dilation to 3 voxel and binary_fill_holes.</li>\n<li>Compare the interpolated new component with the old component → if dice and coverage (only TP dice) high enough replace the inner part of the patch of old component with the new one.</li>\n<li>If it dice or coverage score is too low, repeat step 4+ by using the next segmentation with a higher threshold AND for each component (with enough voxels) in the new segmentation of that patch (this way we removed merges of sheets).</li>\n<li>Repeat until score high enough or removed.</li>\n</ol>\n<p>Finally remove_small_objects again and pad the volume back to its original size.</p>\n<p>This is how the different segmentations looked, as you can see, they \"unmerge\" by increasing the threshold:</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Ff07e9686cbee7e392fa206c3eb8a5d5b%2Fbase_seg.png?generation=1772280361155734&amp;alt=media\" alt=\"Base\"></p>\n<p>Base segmentation</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F49560e0f6935e99daab19062cda9f3be%2Ft_low_05.png?generation=1772280378129210&amp;alt=media\" alt=\"0.5\"></p>\n<p>T_low=0.5</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fba9b0ba518f5cbbc3d489a2c0cb24b61%2Ft_low_06.png?generation=1772280393215020&amp;alt=media\" alt=\"0.6\"></p>\n<p>T_low=0.6</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F3aa5537f6f5f4fec8397f6c7dd983b39%2Ft_low_07.png?generation=1772280409601858&amp;alt=media\" alt=\"0.7\"></p>\n<p>T_low=0.7</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fbd88bb633b0be45715b956fd177ad319%2Frefined.png?generation=1772280425203277&amp;alt=media\" alt=\"result\"></p>\n<p>Result</p>\n<h5>Postprocessing history:</h5>\n<p>It is too much right now, to explain how we ended up at the final method. I will probably write a comment/edit to explain it further. But the final method was basically implemented 1 day before the competition ended. Before that we had a very similar global interpolation pipeline (without patches basically). This would have scored 0.631, but it performed worse on public LB and CV for one reason: The label 2 issue. The global interpolation made the risk of a sheet dipping into label 2 territory way higher. I think the local method is superior anyway, but we didnt have any time to tune the hyperparameters and thresholds. And in the end I decided to use two local interpolation methods with different patch sizes.</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F3abdffd13ef58733d9f86a1e64a5f189%2Fscores.png?generation=1772278657270171&amp;alt=media\" alt=\"Scores\"></p>\n<p>But I think this is still an awesome outcome especially since I heavily realied on <a href=\"https://www.kaggle.com/pgeiger\" target=\"_blank\">@pgeiger</a> visualization tool throughout the whole competition. Without this contribution I would have probably given up. It helped me so often to understand what is going wrong/on. So thank you alot for this and a well deserved victory for your team!</p>\n<p>Another special thank you to my team mate <a href=\"https://www.kaggle.com/nguyncdngs\" target=\"_blank\">@nguyncdngs</a> ! It was the first time I teamed up with someone and I didnt know what to expect. It was incredibly helpful and a joy to work with you. Thank you so much!</p>\n<p>Feel free to ask any questions, I'll happily answere them in the comments!</p>\n<p>Edit:</p>\n<p>Here you can find the submission code for the global interpolation postprocessing, which scored the highest for us on private LB:</p>\n<p><a href=\"https://www.kaggle.com/code/mariusheuser/interference-with-global-interpolation?scriptVersionId=300668972\" target=\"_blank\">0.631 global interpolation solution notebook</a></p>\n<p>And here our final submission for the private LB:</p>\n<p><a href=\"https://www.kaggle.com/code/mariusheuser/local-interpolation-interference\" target=\"_blank\">0.622 local interpolation solution notebook</a></p>\n<p>It was heavily optimized by Claude (from ~3 minutes postprocessing per volume down to ~1 minute).\nThe main difference to the described method above:</p>\n<ol>\n<li>It checks the euler number for the whole component at once and completely replaces it if it detects a hole/tunnel.</li>\n<li>After going completely through it once, it does another full postprocessing loop. This can be set higher than once, but doesnt improve it anymore, since it tries the same stuff again and again</li>\n</ol>\n<p>The rest should be the same, if I dont forget anything.</p>\n<p>This is the score of the ensembled models without the interpolation stuff:</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F1eb07ce510d6423c2929fb9018d66ab3%2Fensembled.png?generation=1772295037477998&amp;alt=media\" alt=\"Ensembled models score\"></p>\n<p>Since other teams scored higher with just their base models, I'd be interested if this postprocessing would improve their score even further!</p>\n<h3>How did the postprocessing idea come to life?</h3>\n<p>I came up with the idea to use interpolation to replace errorprone sheet segmentation pretty early on. I thought it is an easy way to score high, given the topo metric. Back then I didn't really understand how the topo metric works. I thought it would be enough to have 0 holes/tunnel (which is true) and the right number of components (wrong, since it is calculated with persistence homology, which means that the location of the components is also important).</p>\n<p>So for each component:\nI extracted the voxel coords, used SVD to get the PCA, project it to (u,v) and height w, create a meshgrid which covers the u,v coords and interpolate over it with scipy.interpolate. The amount of coords used and the grid resolution have to be chosen carefully to limit processing time. In code:</p>\n<pre><code>coords = np.column_stack(np.nonzero(component))\ncoords_mean = coords.mean(axis=0)\nU, S, Vt = np.linalg.svd(coords - coords_mean, full_matrices=False)\ntangent1, tangent2 = Vt[0], Vt[1]\nnormal_guess = Vt[2]\n\nuv_coords = (coords - coords_mean) @ np.column_stack([tangent1, tangent2])\nw_coords = (coords - coords_mean) @ normal_guess\n\nif len(coords) &gt; 5000:\n    indices = np.random.choice(len(coords), 5000, replace=False)\n    uv_coords_sample = uv_coords[indices]\n    w_coords_sample = w_coords[indices]\nelse:\n    uv_coords_sample = uv_coords\n    w_coords_sample = w_coords\n\nu_min, u_max = uv_coords[:,0].min(), uv_coords[:,0].max()\nv_min, v_max = uv_coords[:,1].min(), uv_coords[:,1].max()\nu_padding = (u_max - u_min) * 0.05\nv_padding = (v_max - v_min) * 0.05\n\ngrid_u, grid_v = np.meshgrid(\n    np.linspace(u_min - u_padding, u_max + u_padding, num=grid_resolution),\n    np.linspace(v_min - v_padding, v_max + v_padding, num=grid_resolution),\n    indexing='ij'\n)\n\ntry:\n    w_grid = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='linear')\nexcept:\n    w_grid = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='nearest')\n\nif np.any(np.isnan(w_grid)):\n    mask = np.isnan(w_grid)\n    w_grid_nearest = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='nearest')\n    w_grid[mask] = w_grid_nearest[mask]\n</code></pre>\n<p>This approach has multiple problems:</p>\n<ul>\n<li>The interpolation ignores the boundaries of the original component. It creates a 2d sheet over the whole grid:\n<img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F1ee1e984a434a3ead8685f82f7f41cc2%2Finterpolate.png?generation=1772362966638349&amp;alt=media\" alt=\"interpolation\"></li>\n</ul>\n<p>This could cause the interpolated component to reach into other components or create huge segmentations for small components.\nThis was fixed by using flood-fill from edges (eventhough it is flood-removal in this case). \nIt starts at each edge voxel in the 2d grid and checks if it is background or foreground in the original grid (left picture above).\nIf is foreground -&gt; stop for this voxel. \nIf it is background, remove the voxel from the interpolated grid (right picture above) and check its neighbours with the same logic. \nIn 2d space this removes interpolated background, which isn't inside of the sheet (a hole)!</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Feddc5a844ee11146a4f12df29b5cb301%2Finterpolation2.png?generation=1772363374808586&amp;alt=media\" alt=\"flood-remove\"></p>\n<ul>\n<li>Sheet components which are close to each other might get merged by the interpolation since it doesnt perfectly replace the sheets.\nThis is fixed by overthickening the components. Instead of 3 voxel thick components, it creates 5 voxel thick components. While merging the each components back into the same 3d volume, it checks for overlap and removes each overlapping voxel. This causes the components to only touch anymore. Now we erode each component back to 3 voxel thickness, which will unmerge them again!</li>\n</ul>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fb1eb38c9e82b131115301a10f120ebe5%2Ferode.png?generation=1772364292821624&amp;alt=media\" alt=\"erode\"></p>\n<ul>\n<li>The biggest problem. Components can't be interpolated with a single sheet component if they consist of multiple merged sheets.\nThis was a huge problem and I was stuck for a long time. I tried other postprocessing methods and always went in circles. High logits threshold would cause the sheets to separate, but also worsen the overall segmentation heavily. Low logits threshold would merge the sheets again.\nNow the solution seems easy, we try to take the best from each world (segmentation).\nWe detect merged sheets, by calculating a dice and coverage score for the interpolated component with the original component. If the score is high enough, the component is most likely a single sheet. So we keep this interpolation.\nIf the score is low, it must be multiple merged sheets. So we throw away the interpolation and repeat the interpolation process on the same area with a higher threshold segmentation for each component in that area.\nThis works, since the higher threshold segmentation will always be a part of the lower threshold segmentation.\nHere an example of the workflow with 2 alternative thresholds (0.5 and 0.7):\n<img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F2869ee4294be01582214a5b94f0f5448%2Fmultiple_thresholds.png?generation=1772365202021484&amp;alt=media\" alt=\"workflow\"></li>\n</ul>\n<p>In this example component 3 is multiple sheets merged into one. So another interpolation is started with threshold 0.5, which still detects one component and still scores low. So the process is repeated again. With threshold 0.7 it detects 3 components and can successfully interpolate each of them.</p>\n<p>This approach can cause oversegmentation (multiple components for a single sheet), but it is better than keeping merged components with holes for the given metric.</p>",
  "messages": [
    {
      "id": "3415194",
      "postDate": "02/28/2026 12:13:10",
      "content": "<p>First of all, we want to thank kaggle and the organizers for this interesting and fun competition. A special thank you to <a href=\"https://www.kaggle.com/giorgioangelotti\" target=\"_blank\">@giorgioangelotti</a> and <a href=\"https://www.kaggle.com/seanjohnsonsp\" target=\"_blank\">@seanjohnsonsp</a> for being great hosts. Eventhough there has been negative feedback in some regard, I was impressed and very happy with the constant communication, even if difficult decission had to be made. That is not a given, so thank you alot!</p>\n<h1>Overview:</h1>\n<p>We used nnUNet to train 128³ and 160³ patchsize models and ensembled them with a 40%/60% weighting in favor of the 160³ model. The postprocessing consists of local componentwise interpolation with multiple segmentation masks (different thresholds).</p>\n<h3>Detailed model description:</h3>\n<h5>Single Model Strategy</h5>\n<p>We trained two models on vanilla nnUNet ResEncTrainer with the following settings:</p>\n<p>patch size: 128\nbatch size: 2\nepochs: 1000\nfold: all</p>\n<p>patch size: 160\nbatch size: 2\nepochs: 1000\nfold: all</p>\n<h5>Ensemble Strategy</h5>\n<p>We summed the logits with a weighting of 60% of 160³ model and 40% of 128³ model. This was just visually tuned, since the 160³ model scored higher than the other one.</p>\n<h5>Model history:</h5>\n<p>We started with a 128³ model trained on 80% of the training data. We used this model for tuning the thresholds for the final 128³ model, which seemed to translate well. We finished training the full 160³ model in the last days of the competition and the thresholds from the 128³ model didn't apply to it, so we didnt have time to tune the thresholds for the ensemble. It was mostly done on a visual basis and a few LB submissions.</p>\n<p>For a really long time we tried to come up with a better model by changing the loss function. I invested alot of time to implement the „Skeaw-BorT“-Loss from a paper I found, just to realize that it doesn't work here, since the background can't be split into components. The medialsurface loss also didn't help for the following reason: The resulting model would perform better on topo score but its performance on surface dice would decrease. We had plenty ideas to increase the topo score by postprocessing, so it was way more important to have models, which score as high as possible on surface dice, than having models which were decent at everything.</p>\n<h3>Post-processing:</h3>\n<p>After ensembling the model logits (mirroring+rotation TTA), we created multiple segmentation masks with the following postprocessing structure:</p>\n<ol>\n<li>topo_postprocess:\nConsisting of 3d Hysteresis, 3d Anisotropic Closing, Dust Removel\nThis is the same postprocessing, which can be found in the public workbooks.</li>\n<li>Set the 3 voxels of each volume face to 0 (these are label 2 voxels)</li>\n<li>Dust removel again (size 1000)</li>\n</ol>\n<p>The reason for step 2 and 3 are for special cases, where after removing label 2 it could create artifacts. This was a known issue in this competition, but we didnt have any information on label 2 positions other than the 3 voxel at each volume face.</p>\n<p>In the highest final submission score, we used 4 thesholds for topo_postprocess:</p>\n<p>The base segmentation:\nT_low: 0.2 </p>\n<p>The fine tuning segmentations:\nT_low: 0.5 \nT_low: 0.6\nT_low: 0.7</p>\n<p>After that we performed binary_fill_holes on the base segmentation and cropped the volumes by removing the 3 voxels of each volume face for every segmentation. Why didnt we do this right away? This step was implemented way later and wasnt important for the submitted solution, I will explain it in the history section or a comment later.\nI had a eurika moment, when i realized the tunnel/holes can be quickly located (the existence) by calculating the euler number:</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F7547dea7c615352e60a995c6bf47c619%2Feuler.png?generation=1772280672154738&amp;alt=media\" alt=\"euler\"></p>\n<p>(C=1 (components), H=0 (cavities), T=#Holes/Tunnels)</p>\n<p>Now we performed the following steps:</p>\n<ol>\n<li>Take the base segmentation and split it into volumes with just one component</li>\n<li>Window-check small patches of each volume for tunnel/holes by calculating the euler number</li>\n<li>a) Euler number &gt;= 1 → save component segmentation</li>\n<li>b) Euler number &lt; 1 → Save each patch, if the patches overlap, merge them into a bigger patch. (we were running the window check with overlap 50%)</li>\n<li>For each (merged) patch → Project the component onto a 2d grid by interpolating it on a given number of coords. Perform smoothing, remove „over“-interpolation by doing flood-remove from the edges, thicken it with binary_dilation to 3 voxel and binary_fill_holes.</li>\n<li>Compare the interpolated new component with the old component → if dice and coverage (only TP dice) high enough replace the inner part of the patch of old component with the new one.</li>\n<li>If it dice or coverage score is too low, repeat step 4+ by using the next segmentation with a higher threshold AND for each component (with enough voxels) in the new segmentation of that patch (this way we removed merges of sheets).</li>\n<li>Repeat until score high enough or removed.</li>\n</ol>\n<p>Finally remove_small_objects again and pad the volume back to its original size.</p>\n<p>This is how the different segmentations looked, as you can see, they \"unmerge\" by increasing the threshold:</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Ff07e9686cbee7e392fa206c3eb8a5d5b%2Fbase_seg.png?generation=1772280361155734&amp;alt=media\" alt=\"Base\"></p>\n<p>Base segmentation</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F49560e0f6935e99daab19062cda9f3be%2Ft_low_05.png?generation=1772280378129210&amp;alt=media\" alt=\"0.5\"></p>\n<p>T_low=0.5</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fba9b0ba518f5cbbc3d489a2c0cb24b61%2Ft_low_06.png?generation=1772280393215020&amp;alt=media\" alt=\"0.6\"></p>\n<p>T_low=0.6</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F3aa5537f6f5f4fec8397f6c7dd983b39%2Ft_low_07.png?generation=1772280409601858&amp;alt=media\" alt=\"0.7\"></p>\n<p>T_low=0.7</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fbd88bb633b0be45715b956fd177ad319%2Frefined.png?generation=1772280425203277&amp;alt=media\" alt=\"result\"></p>\n<p>Result</p>\n<h5>Postprocessing history:</h5>\n<p>It is too much right now, to explain how we ended up at the final method. I will probably write a comment/edit to explain it further. But the final method was basically implemented 1 day before the competition ended. Before that we had a very similar global interpolation pipeline (without patches basically). This would have scored 0.631, but it performed worse on public LB and CV for one reason: The label 2 issue. The global interpolation made the risk of a sheet dipping into label 2 territory way higher. I think the local method is superior anyway, but we didnt have any time to tune the hyperparameters and thresholds. And in the end I decided to use two local interpolation methods with different patch sizes.</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F3abdffd13ef58733d9f86a1e64a5f189%2Fscores.png?generation=1772278657270171&amp;alt=media\" alt=\"Scores\"></p>\n<p>But I think this is still an awesome outcome especially since I heavily realied on <a href=\"https://www.kaggle.com/pgeiger\" target=\"_blank\">@pgeiger</a> visualization tool throughout the whole competition. Without this contribution I would have probably given up. It helped me so often to understand what is going wrong/on. So thank you alot for this and a well deserved victory for your team!</p>\n<p>Another special thank you to my team mate <a href=\"https://www.kaggle.com/nguyncdngs\" target=\"_blank\">@nguyncdngs</a> ! It was the first time I teamed up with someone and I didnt know what to expect. It was incredibly helpful and a joy to work with you. Thank you so much!</p>\n<p>Feel free to ask any questions, I'll happily answere them in the comments!</p>\n<p>Edit:</p>\n<p>Here you can find the submission code for the global interpolation postprocessing, which scored the highest for us on private LB:</p>\n<p><a href=\"https://www.kaggle.com/code/mariusheuser/interference-with-global-interpolation?scriptVersionId=300668972\" target=\"_blank\">0.631 global interpolation solution notebook</a></p>\n<p>And here our final submission for the private LB:</p>\n<p><a href=\"https://www.kaggle.com/code/mariusheuser/local-interpolation-interference\" target=\"_blank\">0.622 local interpolation solution notebook</a></p>\n<p>It was heavily optimized by Claude (from ~3 minutes postprocessing per volume down to ~1 minute).\nThe main difference to the described method above:</p>\n<ol>\n<li>It checks the euler number for the whole component at once and completely replaces it if it detects a hole/tunnel.</li>\n<li>After going completely through it once, it does another full postprocessing loop. This can be set higher than once, but doesnt improve it anymore, since it tries the same stuff again and again</li>\n</ol>\n<p>The rest should be the same, if I dont forget anything.</p>\n<p>This is the score of the ensembled models without the interpolation stuff:</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F1eb07ce510d6423c2929fb9018d66ab3%2Fensembled.png?generation=1772295037477998&amp;alt=media\" alt=\"Ensembled models score\"></p>\n<p>Since other teams scored higher with just their base models, I'd be interested if this postprocessing would improve their score even further!</p>\n<h3>How did the postprocessing idea come to life?</h3>\n<p>I came up with the idea to use interpolation to replace errorprone sheet segmentation pretty early on. I thought it is an easy way to score high, given the topo metric. Back then I didn't really understand how the topo metric works. I thought it would be enough to have 0 holes/tunnel (which is true) and the right number of components (wrong, since it is calculated with persistence homology, which means that the location of the components is also important).</p>\n<p>So for each component:\nI extracted the voxel coords, used SVD to get the PCA, project it to (u,v) and height w, create a meshgrid which covers the u,v coords and interpolate over it with scipy.interpolate. The amount of coords used and the grid resolution have to be chosen carefully to limit processing time. In code:</p>\n<pre><code>coords = np.column_stack(np.nonzero(component))\ncoords_mean = coords.mean(axis=0)\nU, S, Vt = np.linalg.svd(coords - coords_mean, full_matrices=False)\ntangent1, tangent2 = Vt[0], Vt[1]\nnormal_guess = Vt[2]\n\nuv_coords = (coords - coords_mean) @ np.column_stack([tangent1, tangent2])\nw_coords = (coords - coords_mean) @ normal_guess\n\nif len(coords) &gt; 5000:\n    indices = np.random.choice(len(coords), 5000, replace=False)\n    uv_coords_sample = uv_coords[indices]\n    w_coords_sample = w_coords[indices]\nelse:\n    uv_coords_sample = uv_coords\n    w_coords_sample = w_coords\n\nu_min, u_max = uv_coords[:,0].min(), uv_coords[:,0].max()\nv_min, v_max = uv_coords[:,1].min(), uv_coords[:,1].max()\nu_padding = (u_max - u_min) * 0.05\nv_padding = (v_max - v_min) * 0.05\n\ngrid_u, grid_v = np.meshgrid(\n    np.linspace(u_min - u_padding, u_max + u_padding, num=grid_resolution),\n    np.linspace(v_min - v_padding, v_max + v_padding, num=grid_resolution),\n    indexing='ij'\n)\n\ntry:\n    w_grid = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='linear')\nexcept:\n    w_grid = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='nearest')\n\nif np.any(np.isnan(w_grid)):\n    mask = np.isnan(w_grid)\n    w_grid_nearest = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='nearest')\n    w_grid[mask] = w_grid_nearest[mask]\n</code></pre>\n<p>This approach has multiple problems:</p>\n<ul>\n<li>The interpolation ignores the boundaries of the original component. It creates a 2d sheet over the whole grid:\n<img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F1ee1e984a434a3ead8685f82f7f41cc2%2Finterpolate.png?generation=1772362966638349&amp;alt=media\" alt=\"interpolation\"></li>\n</ul>\n<p>This could cause the interpolated component to reach into other components or create huge segmentations for small components.\nThis was fixed by using flood-fill from edges (eventhough it is flood-removal in this case). \nIt starts at each edge voxel in the 2d grid and checks if it is background or foreground in the original grid (left picture above).\nIf is foreground -&gt; stop for this voxel. \nIf it is background, remove the voxel from the interpolated grid (right picture above) and check its neighbours with the same logic. \nIn 2d space this removes interpolated background, which isn't inside of the sheet (a hole)!</p>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Feddc5a844ee11146a4f12df29b5cb301%2Finterpolation2.png?generation=1772363374808586&amp;alt=media\" alt=\"flood-remove\"></p>\n<ul>\n<li>Sheet components which are close to each other might get merged by the interpolation since it doesnt perfectly replace the sheets.\nThis is fixed by overthickening the components. Instead of 3 voxel thick components, it creates 5 voxel thick components. While merging the each components back into the same 3d volume, it checks for overlap and removes each overlapping voxel. This causes the components to only touch anymore. Now we erode each component back to 3 voxel thickness, which will unmerge them again!</li>\n</ul>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fb1eb38c9e82b131115301a10f120ebe5%2Ferode.png?generation=1772364292821624&amp;alt=media\" alt=\"erode\"></p>\n<ul>\n<li>The biggest problem. Components can't be interpolated with a single sheet component if they consist of multiple merged sheets.\nThis was a huge problem and I was stuck for a long time. I tried other postprocessing methods and always went in circles. High logits threshold would cause the sheets to separate, but also worsen the overall segmentation heavily. Low logits threshold would merge the sheets again.\nNow the solution seems easy, we try to take the best from each world (segmentation).\nWe detect merged sheets, by calculating a dice and coverage score for the interpolated component with the original component. If the score is high enough, the component is most likely a single sheet. So we keep this interpolation.\nIf the score is low, it must be multiple merged sheets. So we throw away the interpolation and repeat the interpolation process on the same area with a higher threshold segmentation for each component in that area.\nThis works, since the higher threshold segmentation will always be a part of the lower threshold segmentation.\nHere an example of the workflow with 2 alternative thresholds (0.5 and 0.7):\n<img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F2869ee4294be01582214a5b94f0f5448%2Fmultiple_thresholds.png?generation=1772365202021484&amp;alt=media\" alt=\"workflow\"></li>\n</ul>\n<p>In this example component 3 is multiple sheets merged into one. So another interpolation is started with threshold 0.5, which still detects one component and still scores low. So the process is repeated again. With threshold 0.7 it detects 3 components and can successfully interpolate each of them.</p>\n<p>This approach can cause oversegmentation (multiple components for a single sheet), but it is better than keeping merged components with holes for the given metric.</p>",
      "rawMarkdown": "First of all, we want to thank kaggle and the organizers for this interesting and fun competition. A special thank you to @giorgioangelotti and @seanjohnsonsp for being great hosts. Eventhough there has been negative feedback in some regard, I was impressed and very happy with the constant communication, even if difficult decission had to be made. That is not a given, so thank you alot!\n\n# Overview:\n\nWe used nnUNet to train 128³ and 160³ patchsize models and ensembled them with a 40%/60% weighting in favor of the 160³ model. The postprocessing consists of local componentwise interpolation with multiple segmentation masks (different thresholds).\n\n### Detailed model description:\n\n##### Single Model Strategy\n\nWe trained two models on vanilla nnUNet ResEncTrainer with the following settings:\n\npatch size: 128\nbatch size: 2\nepochs: 1000\nfold: all\n\npatch size: 160\nbatch size: 2\nepochs: 1000\nfold: all\n\n##### Ensemble Strategy\n\nWe summed the logits with a weighting of 60% of 160³ model and 40% of 128³ model. This was just visually tuned, since the 160³ model scored higher than the other one.\n\n##### Model history:\n\nWe started with a 128³ model trained on 80% of the training data. We used this model for tuning the thresholds for the final 128³ model, which seemed to translate well. We finished training the full 160³ model in the last days of the competition and the thresholds from the 128³ model didn't apply to it, so we didnt have time to tune the thresholds for the ensemble. It was mostly done on a visual basis and a few LB submissions.\n\nFor a really long time we tried to come up with a better model by changing the loss function. I invested alot of time to implement the „Skeaw-BorT“-Loss from a paper I found, just to realize that it doesn't work here, since the background can't be split into components. The medialsurface loss also didn't help for the following reason: The resulting model would perform better on topo score but its performance on surface dice would decrease. We had plenty ideas to increase the topo score by postprocessing, so it was way more important to have models, which score as high as possible on surface dice, than having models which were decent at everything.\n\n### Post-processing:\n\nAfter ensembling the model logits (mirroring+rotation TTA), we created multiple segmentation masks with the following postprocessing structure:\n\n1. topo_postprocess:\n\tConsisting of 3d Hysteresis, 3d Anisotropic Closing, Dust Removel\n\tThis is the same postprocessing, which can be found in the public workbooks.\n2. Set the 3 voxels of each volume face to 0 (these are label 2 voxels)\n3. Dust removel again (size 1000)\n\nThe reason for step 2 and 3 are for special cases, where after removing label 2 it could create artifacts. This was a known issue in this competition, but we didnt have any information on label 2 positions other than the 3 voxel at each volume face.\n\nIn the highest final submission score, we used 4 thesholds for topo_postprocess:\n\nThe base segmentation:\nT_low: 0.2 \n\nThe fine tuning segmentations:\nT_low: 0.5 \nT_low: 0.6\nT_low: 0.7\n\nAfter that we performed binary_fill_holes on the base segmentation and cropped the volumes by removing the 3 voxels of each volume face for every segmentation. Why didnt we do this right away? This step was implemented way later and wasnt important for the submitted solution, I will explain it in the history section or a comment later.\nI had a eurika moment, when i realized the tunnel/holes can be quickly located (the existence) by calculating the euler number:\n\n![euler](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F7547dea7c615352e60a995c6bf47c619%2Feuler.png?generation=1772280672154738&alt=media)\n\n(C=1 (components), H=0 (cavities), T=#Holes/Tunnels)\n\nNow we performed the following steps:\n1. Take the base segmentation and split it into volumes with just one component\n2. Window-check small patches of each volume for tunnel/holes by calculating the euler number\n3. a) Euler number >= 1 → save component segmentation\n3. b) Euler number < 1 → Save each patch, if the patches overlap, merge them into a bigger patch. (we were running the window check with overlap 50%)\n4. For each (merged) patch → Project the component onto a 2d grid by interpolating it on a given number of coords. Perform smoothing, remove „over“-interpolation by doing flood-remove from the edges, thicken it with binary_dilation to 3 voxel and binary_fill_holes.\n5. Compare the interpolated new component with the old component → if dice and coverage (only TP dice) high enough replace the inner part of the patch of old component with the new one.\n6. If it dice or coverage score is too low, repeat step 4+ by using the next segmentation with a higher threshold AND for each component (with enough voxels) in the new segmentation of that patch (this way we removed merges of sheets).\n7. Repeat until score high enough or removed.\n\nFinally remove_small_objects again and pad the volume back to its original size.\n\nThis is how the different segmentations looked, as you can see, they \"unmerge\" by increasing the threshold:\n\n![Base](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Ff07e9686cbee7e392fa206c3eb8a5d5b%2Fbase_seg.png?generation=1772280361155734&alt=media)\n\nBase segmentation\n\n![0.5](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F49560e0f6935e99daab19062cda9f3be%2Ft_low_05.png?generation=1772280378129210&alt=media)\n\nT_low=0.5\n\n![0.6](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fba9b0ba518f5cbbc3d489a2c0cb24b61%2Ft_low_06.png?generation=1772280393215020&alt=media)\n\nT_low=0.6\n\n![0.7](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F3aa5537f6f5f4fec8397f6c7dd983b39%2Ft_low_07.png?generation=1772280409601858&alt=media)\n\nT_low=0.7\n\n![result](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fbd88bb633b0be45715b956fd177ad319%2Frefined.png?generation=1772280425203277&alt=media)\n\nResult\n\n\n\n##### Postprocessing history:\n\nIt is too much right now, to explain how we ended up at the final method. I will probably write a comment/edit to explain it further. But the final method was basically implemented 1 day before the competition ended. Before that we had a very similar global interpolation pipeline (without patches basically). This would have scored 0.631, but it performed worse on public LB and CV for one reason: The label 2 issue. The global interpolation made the risk of a sheet dipping into label 2 territory way higher. I think the local method is superior anyway, but we didnt have any time to tune the hyperparameters and thresholds. And in the end I decided to use two local interpolation methods with different patch sizes.\n\n![Scores](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F3abdffd13ef58733d9f86a1e64a5f189%2Fscores.png?generation=1772278657270171&alt=media)\n\nBut I think this is still an awesome outcome especially since I heavily realied on @pgeiger visualization tool throughout the whole competition. Without this contribution I would have probably given up. It helped me so often to understand what is going wrong/on. So thank you alot for this and a well deserved victory for your team!\n\nAnother special thank you to my team mate @nguyncdngs ! It was the first time I teamed up with someone and I didnt know what to expect. It was incredibly helpful and a joy to work with you. Thank you so much!\n\nFeel free to ask any questions, I'll happily answere them in the comments!\n\nEdit:\n\nHere you can find the submission code for the global interpolation postprocessing, which scored the highest for us on private LB:\n\n[0.631 global interpolation solution notebook](https://www.kaggle.com/code/mariusheuser/interference-with-global-interpolation?scriptVersionId=300668972)\n\nAnd here our final submission for the private LB:\n\n[0.622 local interpolation solution notebook](https://www.kaggle.com/code/mariusheuser/local-interpolation-interference)\n\nIt was heavily optimized by Claude (from ~3 minutes postprocessing per volume down to ~1 minute).\nThe main difference to the described method above:\n1. It checks the euler number for the whole component at once and completely replaces it if it detects a hole/tunnel.\n2. After going completely through it once, it does another full postprocessing loop. This can be set higher than once, but doesnt improve it anymore, since it tries the same stuff again and again\n\nThe rest should be the same, if I dont forget anything.\n\nThis is the score of the ensembled models without the interpolation stuff:\n\n![Ensembled models score](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F1eb07ce510d6423c2929fb9018d66ab3%2Fensembled.png?generation=1772295037477998&alt=media)\n\nSince other teams scored higher with just their base models, I'd be interested if this postprocessing would improve their score even further!\n\n### How did the postprocessing idea come to life?\n\nI came up with the idea to use interpolation to replace errorprone sheet segmentation pretty early on. I thought it is an easy way to score high, given the topo metric. Back then I didn't really understand how the topo metric works. I thought it would be enough to have 0 holes/tunnel (which is true) and the right number of components (wrong, since it is calculated with persistence homology, which means that the location of the components is also important).\n\nSo for each component:\nI extracted the voxel coords, used SVD to get the PCA, project it to (u,v) and height w, create a meshgrid which covers the u,v coords and interpolate over it with scipy.interpolate. The amount of coords used and the grid resolution have to be chosen carefully to limit processing time. In code:\n```python\ncoords = np.column_stack(np.nonzero(component))\ncoords_mean = coords.mean(axis=0)\nU, S, Vt = np.linalg.svd(coords - coords_mean, full_matrices=False)\ntangent1, tangent2 = Vt[0], Vt[1]\nnormal_guess = Vt[2]\n\nuv_coords = (coords - coords_mean) @ np.column_stack([tangent1, tangent2])\nw_coords = (coords - coords_mean) @ normal_guess\n\nif len(coords) > 5000:\n    indices = np.random.choice(len(coords), 5000, replace=False)\n    uv_coords_sample = uv_coords[indices]\n    w_coords_sample = w_coords[indices]\nelse:\n    uv_coords_sample = uv_coords\n    w_coords_sample = w_coords\n\nu_min, u_max = uv_coords[:,0].min(), uv_coords[:,0].max()\nv_min, v_max = uv_coords[:,1].min(), uv_coords[:,1].max()\nu_padding = (u_max - u_min) * 0.05\nv_padding = (v_max - v_min) * 0.05\n\ngrid_u, grid_v = np.meshgrid(\n    np.linspace(u_min - u_padding, u_max + u_padding, num=grid_resolution),\n    np.linspace(v_min - v_padding, v_max + v_padding, num=grid_resolution),\n    indexing='ij'\n)\n\ntry:\n    w_grid = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='linear')\nexcept:\n    w_grid = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='nearest')\n\nif np.any(np.isnan(w_grid)):\n    mask = np.isnan(w_grid)\n    w_grid_nearest = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='nearest')\n    w_grid[mask] = w_grid_nearest[mask]\n```\n\nThis approach has multiple problems:\n\n- The interpolation ignores the boundaries of the original component. It creates a 2d sheet over the whole grid:\n![interpolation](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F1ee1e984a434a3ead8685f82f7f41cc2%2Finterpolate.png?generation=1772362966638349&alt=media)\n\nThis could cause the interpolated component to reach into other components or create huge segmentations for small components.\nThis was fixed by using flood-fill from edges (eventhough it is flood-removal in this case). \nIt starts at each edge voxel in the 2d grid and checks if it is background or foreground in the original grid (left picture above).\nIf is foreground -> stop for this voxel. \nIf it is background, remove the voxel from the interpolated grid (right picture above) and check its neighbours with the same logic. \nIn 2d space this removes interpolated background, which isn't inside of the sheet (a hole)!\n\n![flood-remove](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Feddc5a844ee11146a4f12df29b5cb301%2Finterpolation2.png?generation=1772363374808586&alt=media)\n\n- Sheet components which are close to each other might get merged by the interpolation since it doesnt perfectly replace the sheets.\nThis is fixed by overthickening the components. Instead of 3 voxel thick components, it creates 5 voxel thick components. While merging the each components back into the same 3d volume, it checks for overlap and removes each overlapping voxel. This causes the components to only touch anymore. Now we erode each component back to 3 voxel thickness, which will unmerge them again!\n\n![erode](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fb1eb38c9e82b131115301a10f120ebe5%2Ferode.png?generation=1772364292821624&alt=media)\n\n- The biggest problem. Components can't be interpolated with a single sheet component if they consist of multiple merged sheets.\nThis was a huge problem and I was stuck for a long time. I tried other postprocessing methods and always went in circles. High logits threshold would cause the sheets to separate, but also worsen the overall segmentation heavily. Low logits threshold would merge the sheets again.\nNow the solution seems easy, we try to take the best from each world (segmentation).\nWe detect merged sheets, by calculating a dice and coverage score for the interpolated component with the original component. If the score is high enough, the component is most likely a single sheet. So we keep this interpolation.\nIf the score is low, it must be multiple merged sheets. So we throw away the interpolation and repeat the interpolation process on the same area with a higher threshold segmentation for each component in that area.\nThis works, since the higher threshold segmentation will always be a part of the lower threshold segmentation.\nHere an example of the workflow with 2 alternative thresholds (0.5 and 0.7):\n![workflow](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F2869ee4294be01582214a5b94f0f5448%2Fmultiple_thresholds.png?generation=1772365202021484&alt=media)\n\nIn this example component 3 is multiple sheets merged into one. So another interpolation is started with threshold 0.5, which still detects one component and still scores low. So the process is repeated again. With threshold 0.7 it detects 3 components and can successfully interpolate each of them.\n\nThis approach can cause oversegmentation (multiple components for a single sheet), but it is better than keeping merged components with holes for the given metric.",
      "votes": null
    },
    {
      "id": "3415316",
      "postDate": "02/28/2026 17:08:26",
      "content": "<p>Congratulations!</p>\n<p>A very elegant solution, quite impressive.</p>\n<p>Thanks for sharing the solution notebook. I would dig in to fully grasp this.</p>",
      "rawMarkdown": "Congratulations!\n\nA very elegant solution, quite impressive.\n\nThanks for sharing the solution notebook. I would dig in to fully grasp this.",
      "votes": null
    },
    {
      "id": "3415467",
      "postDate": "03/01/2026 00:10:57",
      "content": "<p>Coool soln, as expected all the difference lies in the post processing methods.</p>",
      "rawMarkdown": "Coool soln, as expected all the difference lies in the post processing methods.",
      "votes": null
    },
    {
      "id": "3415736",
      "postDate": "03/01/2026 08:19:35",
      "content": "<p>I didn't really understand a lot of post processing steps which you did, how did you find out about these ideas ? did visualization help a lot in doing it ?</p>",
      "rawMarkdown": "I didn't really understand a lot of post processing steps which you did, how did you find out about these ideas ? did visualization help a lot in doing it ?",
      "votes": null
    },
    {
      "id": "3415748",
      "postDate": "03/01/2026 09:02:37",
      "content": "<p>I also noticed, that the postprocessing steps aren't as clear as I thought, while writing them. I will update the explanation today. It is way easier to understand the steps and problems if you see them visualized.\nAnd yes, visualization helped alot, but also just time. I was stuck in this post processing 3-4 times, but after trying different stuff and coming back to it, I finally found a way to solve each problem.</p>",
      "rawMarkdown": "I also noticed, that the postprocessing steps aren't as clear as I thought, while writing them. I will update the explanation today. It is way easier to understand the steps and problems if you see them visualized.\nAnd yes, visualization helped alot, but also just time. I was stuck in this post processing 3-4 times, but after trying different stuff and coming back to it, I finally found a way to solve each problem.",
      "votes": null
    },
    {
      "id": "3415837",
      "postDate": "03/01/2026 12:00:26",
      "content": "<p>I updated the writeup at the end with some more details, if you have any questions. Feel free to ask, I'll happily answere them :)</p>",
      "rawMarkdown": "I updated the writeup at the end with some more details, if you have any questions. Feel free to ask, I'll happily answere them :)",
      "votes": null
    },
    {
      "id": "3415885",
      "postDate": "03/01/2026 15:08:10",
      "content": "<p>Thanks! It's much clearer now! Very nice solution!</p>",
      "rawMarkdown": "Thanks! It's much clearer now! Very nice solution!",
      "votes": null
    },
    {
      "id": "3416162",
      "postDate": "03/02/2026 07:36:44",
      "content": "<p>I wonder if other Kagglers think that this post-processing could help split mergers also on their approaches <a href=\"https://www.kaggle.com/pgeiger\" target=\"_blank\">@pgeiger</a> , <a href=\"https://www.kaggle.com/tom99763\" target=\"_blank\">@tom99763</a> , <a href=\"https://www.kaggle.com/peilwang\" target=\"_blank\">@peilwang</a> , <a href=\"https://www.kaggle.com/ggayoayogg\" target=\"_blank\">@ggayoayogg</a> , <a href=\"https://www.kaggle.com/zy1343930734\" target=\"_blank\">@zy1343930734</a> , <a href=\"https://www.kaggle.com/ren4yu\" target=\"_blank\">@ren4yu</a> </p>",
      "rawMarkdown": "I wonder if other Kagglers think that this post-processing could help split mergers also on their approaches @pgeiger , @tom99763 , @peilwang , @ggayoayogg , @zy1343930734 , @ren4yu",
      "votes": null
    },
    {
      "id": "3417007",
      "postDate": "03/04/2026 10:05:51",
      "content": "<p>Hey, can you share your train/val curves while training? What things let you decide, that this needs to train for 1000 epochs. which is a large number. And yes congrats to the whole team.</p>",
      "rawMarkdown": "Hey, can you share your train/val curves while training? What things let you decide, that this needs to train for 1000 epochs. which is a large number. And yes congrats to the whole team.",
      "votes": null
    },
    {
      "id": "3417030",
      "postDate": "03/04/2026 11:38:35",
      "content": "<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F22951601%2F1ffe30cf6e4ec46e977a03510fd90dcb%2FScreenshot%202026-03-04%20183246.png?generation=1772624113871989&amp;alt=media\" alt=\"\"></p>\n<p>Hi <a href=\"https://www.kaggle.com/arpit1bansal\" target=\"_blank\">@arpit1bansal</a> this is the train/val curves</p>\n<p><code>What things let you decide, that this needs to train for 1000 epochs. which is a large number.</code></p>\n<p>1000 epochs is the default setting of nnU-Net. At first, I used the default setting as a baseline, but the training took too long, so I decided not to increase the number of epochs</p>",
      "rawMarkdown": "![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F22951601%2F1ffe30cf6e4ec46e977a03510fd90dcb%2FScreenshot%202026-03-04%20183246.png?generation=1772624113871989&alt=media)\n\nHi @arpit1bansal this is the train/val curves\n\n`What things let you decide, that this needs to train for 1000 epochs. which is a large number.`\n\n1000 epochs is the default setting of nnU-Net. At first, I used the default setting as a baseline, but the training took too long, so I decided not to increase the number of epochs",
      "votes": null
    }
  ],
  "comments": [
    {
      "id": 3415316,
      "author_name": "vineetkreddy",
      "author_url": "",
      "post_date": "02/28/2026 17:08:26",
      "content": "<p>Congratulations!</p>\n<p>A very elegant solution, quite impressive.</p>\n<p>Thanks for sharing the solution notebook. I would dig in to fully grasp this.</p>",
      "votes": null,
      "replies": []
    },
    {
      "id": 3415467,
      "author_name": "choudharymanas",
      "author_url": "",
      "post_date": "03/01/2026 00:10:57",
      "content": "<p>Coool soln, as expected all the difference lies in the post processing methods.</p>",
      "votes": null,
      "replies": []
    },
    {
      "id": 3415736,
      "author_name": "ppilania1985",
      "author_url": "",
      "post_date": "03/01/2026 08:19:35",
      "content": "<p>I didn't really understand a lot of post processing steps which you did, how did you find out about these ideas ? did visualization help a lot in doing it ?</p>",
      "votes": null,
      "replies": [
        {
          "id": 3415748,
          "author_name": "mariusheuser",
          "author_url": "",
          "post_date": "03/01/2026 09:02:37",
          "content": "<p>I also noticed, that the postprocessing steps aren't as clear as I thought, while writing them. I will update the explanation today. It is way easier to understand the steps and problems if you see them visualized.\nAnd yes, visualization helped alot, but also just time. I was stuck in this post processing 3-4 times, but after trying different stuff and coming back to it, I finally found a way to solve each problem.</p>",
          "votes": null,
          "replies": []
        },
        {
          "id": 3415837,
          "author_name": "mariusheuser",
          "author_url": "",
          "post_date": "03/01/2026 12:00:26",
          "content": "<p>I updated the writeup at the end with some more details, if you have any questions. Feel free to ask, I'll happily answere them :)</p>",
          "votes": null,
          "replies": [
            {
              "id": 3415885,
              "author_name": "giorgioangelotti",
              "author_url": "",
              "post_date": "03/01/2026 15:08:10",
              "content": "<p>Thanks! It's much clearer now! Very nice solution!</p>",
              "votes": null,
              "replies": []
            }
          ]
        }
      ]
    },
    {
      "id": 3416162,
      "author_name": "giorgioangelotti",
      "author_url": "",
      "post_date": "03/02/2026 07:36:44",
      "content": "<p>I wonder if other Kagglers think that this post-processing could help split mergers also on their approaches <a href=\"https://www.kaggle.com/pgeiger\" target=\"_blank\">@pgeiger</a> , <a href=\"https://www.kaggle.com/tom99763\" target=\"_blank\">@tom99763</a> , <a href=\"https://www.kaggle.com/peilwang\" target=\"_blank\">@peilwang</a> , <a href=\"https://www.kaggle.com/ggayoayogg\" target=\"_blank\">@ggayoayogg</a> , <a href=\"https://www.kaggle.com/zy1343930734\" target=\"_blank\">@zy1343930734</a> , <a href=\"https://www.kaggle.com/ren4yu\" target=\"_blank\">@ren4yu</a> </p>",
      "votes": null,
      "replies": []
    },
    {
      "id": 3417007,
      "author_name": "arpit1bansal",
      "author_url": "",
      "post_date": "03/04/2026 10:05:51",
      "content": "<p>Hey, can you share your train/val curves while training? What things let you decide, that this needs to train for 1000 epochs. which is a large number. And yes congrats to the whole team.</p>",
      "votes": null,
      "replies": [
        {
          "id": 3417030,
          "author_name": "nguyncdngs",
          "author_url": "",
          "post_date": "03/04/2026 11:38:35",
          "content": "<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F22951601%2F1ffe30cf6e4ec46e977a03510fd90dcb%2FScreenshot%202026-03-04%20183246.png?generation=1772624113871989&amp;alt=media\" alt=\"\"></p>\n<p>Hi <a href=\"https://www.kaggle.com/arpit1bansal\" target=\"_blank\">@arpit1bansal</a> this is the train/val curves</p>\n<p><code>What things let you decide, that this needs to train for 1000 epochs. which is a large number.</code></p>\n<p>1000 epochs is the default setting of nnU-Net. At first, I used the default setting as a baseline, but the training took too long, so I decided not to increase the number of epochs</p>",
          "votes": null,
          "replies": []
        }
      ]
    }
  ],
  "raw_markdown_by_id": {
    "3415194": "First of all, we want to thank kaggle and the organizers for this interesting and fun competition. A special thank you to @giorgioangelotti and @seanjohnsonsp for being great hosts. Eventhough there has been negative feedback in some regard, I was impressed and very happy with the constant communication, even if difficult decission had to be made. That is not a given, so thank you alot!\n\n# Overview:\n\nWe used nnUNet to train 128³ and 160³ patchsize models and ensembled them with a 40%/60% weighting in favor of the 160³ model. The postprocessing consists of local componentwise interpolation with multiple segmentation masks (different thresholds).\n\n### Detailed model description:\n\n##### Single Model Strategy\n\nWe trained two models on vanilla nnUNet ResEncTrainer with the following settings:\n\npatch size: 128\nbatch size: 2\nepochs: 1000\nfold: all\n\npatch size: 160\nbatch size: 2\nepochs: 1000\nfold: all\n\n##### Ensemble Strategy\n\nWe summed the logits with a weighting of 60% of 160³ model and 40% of 128³ model. This was just visually tuned, since the 160³ model scored higher than the other one.\n\n##### Model history:\n\nWe started with a 128³ model trained on 80% of the training data. We used this model for tuning the thresholds for the final 128³ model, which seemed to translate well. We finished training the full 160³ model in the last days of the competition and the thresholds from the 128³ model didn't apply to it, so we didnt have time to tune the thresholds for the ensemble. It was mostly done on a visual basis and a few LB submissions.\n\nFor a really long time we tried to come up with a better model by changing the loss function. I invested alot of time to implement the „Skeaw-BorT“-Loss from a paper I found, just to realize that it doesn't work here, since the background can't be split into components. The medialsurface loss also didn't help for the following reason: The resulting model would perform better on topo score but its performance on surface dice would decrease. We had plenty ideas to increase the topo score by postprocessing, so it was way more important to have models, which score as high as possible on surface dice, than having models which were decent at everything.\n\n### Post-processing:\n\nAfter ensembling the model logits (mirroring+rotation TTA), we created multiple segmentation masks with the following postprocessing structure:\n\n1. topo_postprocess:\n\tConsisting of 3d Hysteresis, 3d Anisotropic Closing, Dust Removel\n\tThis is the same postprocessing, which can be found in the public workbooks.\n2. Set the 3 voxels of each volume face to 0 (these are label 2 voxels)\n3. Dust removel again (size 1000)\n\nThe reason for step 2 and 3 are for special cases, where after removing label 2 it could create artifacts. This was a known issue in this competition, but we didnt have any information on label 2 positions other than the 3 voxel at each volume face.\n\nIn the highest final submission score, we used 4 thesholds for topo_postprocess:\n\nThe base segmentation:\nT_low: 0.2 \n\nThe fine tuning segmentations:\nT_low: 0.5 \nT_low: 0.6\nT_low: 0.7\n\nAfter that we performed binary_fill_holes on the base segmentation and cropped the volumes by removing the 3 voxels of each volume face for every segmentation. Why didnt we do this right away? This step was implemented way later and wasnt important for the submitted solution, I will explain it in the history section or a comment later.\nI had a eurika moment, when i realized the tunnel/holes can be quickly located (the existence) by calculating the euler number:\n\n![euler](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F7547dea7c615352e60a995c6bf47c619%2Feuler.png?generation=1772280672154738&alt=media)\n\n(C=1 (components), H=0 (cavities), T=#Holes/Tunnels)\n\nNow we performed the following steps:\n1. Take the base segmentation and split it into volumes with just one component\n2. Window-check small patches of each volume for tunnel/holes by calculating the euler number\n3. a) Euler number >= 1 → save component segmentation\n3. b) Euler number < 1 → Save each patch, if the patches overlap, merge them into a bigger patch. (we were running the window check with overlap 50%)\n4. For each (merged) patch → Project the component onto a 2d grid by interpolating it on a given number of coords. Perform smoothing, remove „over“-interpolation by doing flood-remove from the edges, thicken it with binary_dilation to 3 voxel and binary_fill_holes.\n5. Compare the interpolated new component with the old component → if dice and coverage (only TP dice) high enough replace the inner part of the patch of old component with the new one.\n6. If it dice or coverage score is too low, repeat step 4+ by using the next segmentation with a higher threshold AND for each component (with enough voxels) in the new segmentation of that patch (this way we removed merges of sheets).\n7. Repeat until score high enough or removed.\n\nFinally remove_small_objects again and pad the volume back to its original size.\n\nThis is how the different segmentations looked, as you can see, they \"unmerge\" by increasing the threshold:\n\n![Base](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Ff07e9686cbee7e392fa206c3eb8a5d5b%2Fbase_seg.png?generation=1772280361155734&alt=media)\n\nBase segmentation\n\n![0.5](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F49560e0f6935e99daab19062cda9f3be%2Ft_low_05.png?generation=1772280378129210&alt=media)\n\nT_low=0.5\n\n![0.6](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fba9b0ba518f5cbbc3d489a2c0cb24b61%2Ft_low_06.png?generation=1772280393215020&alt=media)\n\nT_low=0.6\n\n![0.7](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F3aa5537f6f5f4fec8397f6c7dd983b39%2Ft_low_07.png?generation=1772280409601858&alt=media)\n\nT_low=0.7\n\n![result](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fbd88bb633b0be45715b956fd177ad319%2Frefined.png?generation=1772280425203277&alt=media)\n\nResult\n\n\n\n##### Postprocessing history:\n\nIt is too much right now, to explain how we ended up at the final method. I will probably write a comment/edit to explain it further. But the final method was basically implemented 1 day before the competition ended. Before that we had a very similar global interpolation pipeline (without patches basically). This would have scored 0.631, but it performed worse on public LB and CV for one reason: The label 2 issue. The global interpolation made the risk of a sheet dipping into label 2 territory way higher. I think the local method is superior anyway, but we didnt have any time to tune the hyperparameters and thresholds. And in the end I decided to use two local interpolation methods with different patch sizes.\n\n![Scores](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F3abdffd13ef58733d9f86a1e64a5f189%2Fscores.png?generation=1772278657270171&alt=media)\n\nBut I think this is still an awesome outcome especially since I heavily realied on @pgeiger visualization tool throughout the whole competition. Without this contribution I would have probably given up. It helped me so often to understand what is going wrong/on. So thank you alot for this and a well deserved victory for your team!\n\nAnother special thank you to my team mate @nguyncdngs ! It was the first time I teamed up with someone and I didnt know what to expect. It was incredibly helpful and a joy to work with you. Thank you so much!\n\nFeel free to ask any questions, I'll happily answere them in the comments!\n\nEdit:\n\nHere you can find the submission code for the global interpolation postprocessing, which scored the highest for us on private LB:\n\n[0.631 global interpolation solution notebook](https://www.kaggle.com/code/mariusheuser/interference-with-global-interpolation?scriptVersionId=300668972)\n\nAnd here our final submission for the private LB:\n\n[0.622 local interpolation solution notebook](https://www.kaggle.com/code/mariusheuser/local-interpolation-interference)\n\nIt was heavily optimized by Claude (from ~3 minutes postprocessing per volume down to ~1 minute).\nThe main difference to the described method above:\n1. It checks the euler number for the whole component at once and completely replaces it if it detects a hole/tunnel.\n2. After going completely through it once, it does another full postprocessing loop. This can be set higher than once, but doesnt improve it anymore, since it tries the same stuff again and again\n\nThe rest should be the same, if I dont forget anything.\n\nThis is the score of the ensembled models without the interpolation stuff:\n\n![Ensembled models score](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F1eb07ce510d6423c2929fb9018d66ab3%2Fensembled.png?generation=1772295037477998&alt=media)\n\nSince other teams scored higher with just their base models, I'd be interested if this postprocessing would improve their score even further!\n\n### How did the postprocessing idea come to life?\n\nI came up with the idea to use interpolation to replace errorprone sheet segmentation pretty early on. I thought it is an easy way to score high, given the topo metric. Back then I didn't really understand how the topo metric works. I thought it would be enough to have 0 holes/tunnel (which is true) and the right number of components (wrong, since it is calculated with persistence homology, which means that the location of the components is also important).\n\nSo for each component:\nI extracted the voxel coords, used SVD to get the PCA, project it to (u,v) and height w, create a meshgrid which covers the u,v coords and interpolate over it with scipy.interpolate. The amount of coords used and the grid resolution have to be chosen carefully to limit processing time. In code:\n```python\ncoords = np.column_stack(np.nonzero(component))\ncoords_mean = coords.mean(axis=0)\nU, S, Vt = np.linalg.svd(coords - coords_mean, full_matrices=False)\ntangent1, tangent2 = Vt[0], Vt[1]\nnormal_guess = Vt[2]\n\nuv_coords = (coords - coords_mean) @ np.column_stack([tangent1, tangent2])\nw_coords = (coords - coords_mean) @ normal_guess\n\nif len(coords) > 5000:\n    indices = np.random.choice(len(coords), 5000, replace=False)\n    uv_coords_sample = uv_coords[indices]\n    w_coords_sample = w_coords[indices]\nelse:\n    uv_coords_sample = uv_coords\n    w_coords_sample = w_coords\n\nu_min, u_max = uv_coords[:,0].min(), uv_coords[:,0].max()\nv_min, v_max = uv_coords[:,1].min(), uv_coords[:,1].max()\nu_padding = (u_max - u_min) * 0.05\nv_padding = (v_max - v_min) * 0.05\n\ngrid_u, grid_v = np.meshgrid(\n    np.linspace(u_min - u_padding, u_max + u_padding, num=grid_resolution),\n    np.linspace(v_min - v_padding, v_max + v_padding, num=grid_resolution),\n    indexing='ij'\n)\n\ntry:\n    w_grid = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='linear')\nexcept:\n    w_grid = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='nearest')\n\nif np.any(np.isnan(w_grid)):\n    mask = np.isnan(w_grid)\n    w_grid_nearest = griddata(uv_coords_sample, w_coords_sample, (grid_u, grid_v), method='nearest')\n    w_grid[mask] = w_grid_nearest[mask]\n```\n\nThis approach has multiple problems:\n\n- The interpolation ignores the boundaries of the original component. It creates a 2d sheet over the whole grid:\n![interpolation](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F1ee1e984a434a3ead8685f82f7f41cc2%2Finterpolate.png?generation=1772362966638349&alt=media)\n\nThis could cause the interpolated component to reach into other components or create huge segmentations for small components.\nThis was fixed by using flood-fill from edges (eventhough it is flood-removal in this case). \nIt starts at each edge voxel in the 2d grid and checks if it is background or foreground in the original grid (left picture above).\nIf is foreground -> stop for this voxel. \nIf it is background, remove the voxel from the interpolated grid (right picture above) and check its neighbours with the same logic. \nIn 2d space this removes interpolated background, which isn't inside of the sheet (a hole)!\n\n![flood-remove](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Feddc5a844ee11146a4f12df29b5cb301%2Finterpolation2.png?generation=1772363374808586&alt=media)\n\n- Sheet components which are close to each other might get merged by the interpolation since it doesnt perfectly replace the sheets.\nThis is fixed by overthickening the components. Instead of 3 voxel thick components, it creates 5 voxel thick components. While merging the each components back into the same 3d volume, it checks for overlap and removes each overlapping voxel. This causes the components to only touch anymore. Now we erode each component back to 3 voxel thickness, which will unmerge them again!\n\n![erode](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2Fb1eb38c9e82b131115301a10f120ebe5%2Ferode.png?generation=1772364292821624&alt=media)\n\n- The biggest problem. Components can't be interpolated with a single sheet component if they consist of multiple merged sheets.\nThis was a huge problem and I was stuck for a long time. I tried other postprocessing methods and always went in circles. High logits threshold would cause the sheets to separate, but also worsen the overall segmentation heavily. Low logits threshold would merge the sheets again.\nNow the solution seems easy, we try to take the best from each world (segmentation).\nWe detect merged sheets, by calculating a dice and coverage score for the interpolated component with the original component. If the score is high enough, the component is most likely a single sheet. So we keep this interpolation.\nIf the score is low, it must be multiple merged sheets. So we throw away the interpolation and repeat the interpolation process on the same area with a higher threshold segmentation for each component in that area.\nThis works, since the higher threshold segmentation will always be a part of the lower threshold segmentation.\nHere an example of the workflow with 2 alternative thresholds (0.5 and 0.7):\n![workflow](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F20325352%2F2869ee4294be01582214a5b94f0f5448%2Fmultiple_thresholds.png?generation=1772365202021484&alt=media)\n\nIn this example component 3 is multiple sheets merged into one. So another interpolation is started with threshold 0.5, which still detects one component and still scores low. So the process is repeated again. With threshold 0.7 it detects 3 components and can successfully interpolate each of them.\n\nThis approach can cause oversegmentation (multiple components for a single sheet), but it is better than keeping merged components with holes for the given metric.",
    "3415316": "Congratulations!\n\nA very elegant solution, quite impressive.\n\nThanks for sharing the solution notebook. I would dig in to fully grasp this.",
    "3415467": "Coool soln, as expected all the difference lies in the post processing methods.",
    "3415736": "I didn't really understand a lot of post processing steps which you did, how did you find out about these ideas ? did visualization help a lot in doing it ?",
    "3415748": "I also noticed, that the postprocessing steps aren't as clear as I thought, while writing them. I will update the explanation today. It is way easier to understand the steps and problems if you see them visualized.\nAnd yes, visualization helped alot, but also just time. I was stuck in this post processing 3-4 times, but after trying different stuff and coming back to it, I finally found a way to solve each problem.",
    "3415837": "I updated the writeup at the end with some more details, if you have any questions. Feel free to ask, I'll happily answere them :)",
    "3415885": "Thanks! It's much clearer now! Very nice solution!",
    "3416162": "I wonder if other Kagglers think that this post-processing could help split mergers also on their approaches @pgeiger , @tom99763 , @peilwang , @ggayoayogg , @zy1343930734 , @ren4yu",
    "3417007": "Hey, can you share your train/val curves while training? What things let you decide, that this needs to train for 1000 epochs. which is a large number. And yes congrats to the whole team.",
    "3417030": "![](https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F22951601%2F1ffe30cf6e4ec46e977a03510fd90dcb%2FScreenshot%202026-03-04%20183246.png?generation=1772624113871989&alt=media)\n\nHi @arpit1bansal this is the train/val curves\n\n`What things let you decide, that this needs to train for 1000 epochs. which is a large number.`\n\n1000 epochs is the default setting of nnU-Net. At first, I used the default setting as a baseline, but the training took too long, so I decided not to increase the number of epochs"
  },
  "source": "meta"
}