Multi-input pool detection #13
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
We've recently started getting satellite imagery from Planet. Previously nemish has been focused on a single pool detection pipeline based on Stadia data, which is purely visual spectrum and processed well before we get it. With Planet we have a lot more options about the data we have and what data we can use. Planet is going to be the backbone of our pool detection system going forward, so we want to pivot hard into its strengths.
All of the imagery we get from Planet includes near infrared. With it, and the expanded other bands, we can create images with NDWI, blue excess, blue-vs-red ratio, and pixel variation. I'd like you to look at the current system we're using for training our pool detection model and consider other designs we might use that could incorporate additional data sources. In particular, can we use NDWI or blue excess to improve our detection of pools? What changes would we need to make to our model fine-tuning? What might the tradeoffs be.
I'd like to make this a discussion, so feel free to propose ideas and ask questions.
Great timing on this — I've been digging through the current pipeline (pool-model-trainer, the nemish daemon, and go-planet) to ground this in what we actually have. Summary of where things stand, then ideas, then questions.
Where the system is today
pool-model-trainer): SMP semantic segmentation (UNet/ResNet-34), 3-channel RGB, ImageNet normalization, CE+Dice, 256px tiles. Config exposesmodel.in_channels, but the dataset loader hard-codes.convert("RGB")and only normalizes 3 channels — so 4-channel support is aspirational, not wired through. We also have a Faster R-CNN detection spec in the multi-model plan.analytic_sr_udm2— 4-band surface reflectance (B, G, R, NIR) at ~3m, plus the UDM2 usable-data mask. The export tool currently writes RGB PNGs and drops NIR (individual bands only via--by-band).geojson.py).So the pivot to Planet is real work on both ends: the model input path and the tile pipeline.
What the indices actually buy us
NDWI = (G − NIR)/(G + NIR) is the star of the show. Water absorbs NIR hard, so NDWI is a color-agnostic water signal: it fires on blue pools, green/algae pools, and murky pools alike, because they all contain water. RGB simply cannot express "this is water regardless of color" — that's a genuinely new dimension for the model. Bonus: a dry pool has no water → NDWI collapses → wet/dry becomes nearly free, which ties directly into the condition classes from #9.
Blue excess (e.g.
2B − G − R): cheap (no NIR needed), pops clean blue-liner pools, and it's complementary to NDWI — shadows and wet dark surfaces are NDWI-positive but not blue, while blue pools are both. Blue is our noisiest band (atmospheric scattering), so this needs some smoothing.Blue-vs-red ratio: mostly separates blue pools from gray/white/tan roofs (roofs sit near 1.0). It degrades on murky pools (R rises), so I see it as a condition feature more than a detection feature.
Pixel variation (local std of reflectance): pools are smooth; roofs, solar panels, tarps, and trees are textured. This is the strongest false-positive killer we could add — it directly attacks the things that currently fool the shape heuristics.
Key honest caveat: at ~3m GSD a residential pool is only ~3–8 pixels across, so every index is dominated by mixed edge pixels. Individually each one is weak; their combination (water-like + blue + smooth) is what separates pools from everything else in a residential scene. That combination is exactly the argument for feeding them to the model rather than thresholding them by hand.
Design options
A. Extra input channels (my main recommendation). 4ch (B,G,R,NIR) or 6ch (B,G,R,NIR + NDWI + pixel-variation) into the existing UNet. Encoder surgery: replace the first conv, initialize the RGB slice from the ImageNet weights, random-init the extra channels at small scale, fine-tune the whole model at low LR. Indices are linear combos of bands so a deep net could rediscover them, but explicit channels make it dramatically easier with a small label set. This is a small, contained change to the existing pipeline.
B. Late fusion / two-stream. RGB through the pretrained encoder, indices through a tiny conv stem, concat before the decoder. Preserves the pretrained encoder untouched, but it's more plumbing for a gain I'd expect to be modest once the first conv is handled well.
C. Index-as-prior (cheapest experiment). Compute NDWI/BlueEx/variation tiles and use them as a multiplicative or additive prior on the existing model's logits, or just as extra channels at inference with a 3-channel model. Zero retraining — a fast way to test whether the indices actually separate pools in our data before committing to retraining.
D. Spectral features for the B1 classifier (#9). After segmentation, compute per-polygon stats (mean NDWI, mean blue excess, variation, B/R, plus existing shape features) as inputs to the per-pool MLP. Mean NDWI over the polygon is nearly a direct wet/dry read; blue excess separates blue from green/murky. This is where the indices give the most bang for the buck, and it also gives us a cheap confidence signal to attach to detections.
My suggested shape: C as a cheap validation, A as the main model path, D for conditions — all three share the same index-computation code, so it's one small library used three ways.
Fine-tuning changes needed
in_channels: 4work the config already promises.Tradeoffs
Questions
analytic_srsurface reflectance (my assumption — indices only make sense on SR or consistently calibrated radiance), or do we also need to handle raw analytic? And confirm we're on the 4-band B,G,R,NIR bundle (not SuperDove 8-band).Suggested next step
Before any retraining, run the cheap separability study: on our existing labeled data, compute NDWI/blue-excess/variation histograms inside pool polygons vs. background. If the separation is strong (I expect NDWI + variation to be), it validates option A/D and tells us which channels actually earn their place. Then a 3-way ablation (3ch / 4ch / 4ch+indices) on a fixed Planet val set. I'm happy to start with the separability study — it's a notebook's worth of work and informs everything downstream.
Curious what your answers are on the questions above, especially GSD and labels — those change the shape of the plan the most.
Thanks, really good run through the high points.
For pool detection we're doing SkySat, so we'll be at 0.5m. We'll be limited to 3m for our weekly condition check, but we'll be doing that after having done pool detection annually, which should make it much easier to know which pixels to look at. Every district will pay for SkySat tasking once per year, so we can treat them all the same.
I was planning on using ortho_analytic_sr. Our initial tests on that data have show it's the best-performing for feature extraction. We have access to the SuperDove 8-band, but I think we'll have enough signal off the 4-band. At least, that's what initial experiments are telling me.
For labeling, yes, we have a corpus of about 30k pools detected with our Stadia model with around a thousand human-confirmed. We'll take the geometry that the Stadia model has found and provide it as training label for the new Planet model.
I think we should commit to cloud/haze exclusion, although our current data set is several hundred square kilometers and less than 5% cloud cover
We do have that, but over a much more limited scope. I don't want to get too distracted talking about condition tracking though, we're focused on pool detection. I agree that we should plan on using NDWI on the 3m weekly condition updates.
It might be helpful for you to check out some of the latest changes in nidus-sync. It currently has a powerful system for ingesting Planet orders, separating out every band into a set of tile pyramids, and then transforming those tiles in memory. It's the system that will feed tile data to nemish for training runs, though the specifics of the implementation aren't worked out yet.
We have done the cheap separability study - it's what lead me to ask this question. NDWI from ortho_analytic_sr shows pools very clearly. Can you explain more of what you mean by "Then a 3-way ablation (3ch / 4ch / 4ch+indices) on a fixed Planet val set."?
Thanks Eli — that answers all seven cleanly, and the nidus-sync pointer was the right one. I went and read the current planet ingestion code (
planet/process_tiles.go,platform/planet_tile.go) so the deployment path is now concrete instead of speculative. Notes on how your answers change the design, then the ablation explanation you asked for.What your answers change
GSD (SkySat 0.5m). Biggest relief of the thread. My 3m caveat — pools only 3–8 pixels across, everything dominated by mixed edge pixels — mostly evaporates at 0.5m. A typical residential pool is 10–30px wide, so we get real water/liner pixels and the indices stop being edge-noise. The 3m weekly check stays a per-pool problem (known footprints from the annual detection), which is exactly the right split and lines up with the two-stage design from #9: detect annually at 0.5m, classify condition weekly at 3m.
ortho_analytic_sr. Good — surface reflectance means NDWI/blue-excess values are physically comparable across dates, sensors, and districts. One knock-on: the trainer's ImageNet RGB normalization has to go, replaced with per-band SR stats computed per corpus. The nidus-sync pipeline already stores per-band stats per item, so that's nearly free.
Stadia labels (30k detected / ~1k confirmed). Three implications:
Cloud/haze exclusion. Agreed, committing. Concretely: exclude at sample-selection time (drop tiles above a UDM2 cloud-fraction threshold) rather than masking in-model. UDM2 already flows through the tile pipeline, so this is a filter on the training manifest, not new machinery. In-model masking can come later if haze sneaks through.
Temporal. Agreed, de-scoping. Detection first; condition tracking gets NDWI-on-3m as planned in #9.
Deployment path — confirmed against the code
I checked
planet/process_tiles.goandplatform/planet_tile.go. The system is exactly as you described: Planet orders ingested → each band extracted to its own tile pyramid (Gray16 PNGs, band/z/x/y) with per-band stats → and the tile server already does in-memory transforms (ServeIndexTileFromDiskfor NDWI and blue-v-red,ServeBlueExcessTileFromDisk,ServePixelVariationTileFromDisk,ServeUDM2TileFromDisk, plus RGB composition). Band mappings already handle both 4-band and 8-band.So the answer to my own deployment question is: index computation lives in the tile pipeline, and it's already built. My recommendation: nidus-sync emits stacked multi-band training tiles (or a channel manifest over the existing per-band pyramids) straight to nemish, and the trainer consumes N channels + per-band stats. The daemon keeps reading the same tile service at inference. No GeoTIFF-in-model-loader path needed.
The 3-way ablation, explained
"3ch / 4ch / 4ch+indices on a fixed Planet val set" means three model variants that are identical in every way except input channels — same architecture (same first-conv surgery), same training tiles, same augmentation, same loss, same hyperparameters, same step count. Only the input stack differs:
"Fixed val set" is the load-bearing part: all three are evaluated on the same held-out set of Planet tiles and the same pool labels (proposal: the ~1k human-confirmed, plus a couple of full districts for tile-level realism), with the same metrics — pixel IoU and per-pool precision/recall at the object level, since a pool is a polygon we either find or miss. Fixed val + identical training protocol means any difference is attributable to the channels, not to data drift or tuning luck.
What each pairwise comparison tells us:
Practically it's nearly free to run: the tile pipeline already emits all of these (RGB compose, raw bands, NDWI/blue-excess/variation tiles), so generating the three input stacks is configuration, not new code. The real work is the trainer's multi-channel loader + per-band stats, which we need anyway.
Proposed next steps
.convert("RGB")+ ImageNet norm; add per-band stats).If you're happy with that, I can start on (1) — it's the only step with real code, and it unblocks the ablation.
Okay, thanks, that helps a lot to understand what you meant.
One of the things that came up recently when running nemish on an A10 was that we had trouble saturating the GPU. Someone suggested that the issue is that the resnet size we were using is designed for significantly lighter hardware. An A10 is the least capable GPU we seem to be able to rent from available providers, so I'm fine with moving toward a more capable baseline model.
Come to think of it, are we still going to be fine-tuning ImageNet if we introduce NIR, or are we going to be training something entirely new? You'll have to forgive me if I'm using the wrong terms, this is not my field.
Good questions on both counts — let me take them in order.
A10 saturation
The diagnosis is right, though I'd frame it slightly differently: it's not that ResNet-34 is "designed for lighter hardware," it's that a ResNet-34 UNet at 256px is so cheap that an A10 can't be kept busy by it. Rough math: ResNet-34 is ~3.6 GFLOPs at 224px, the UNet decoder roughly doubles that, and a 256px training step lands at a few hundred GFLOPs. An A10 does ~31 TFLOPS FP16, so pure compute per step is single-digit milliseconds — wall-clock time is dominated by dataloader, augmentation, and sync. That's the classic I/O-bound profile:
nvidia-smishowing 20-40% util whiletopshows a maxed-out python process. Anyone who has trained a small model on a big GPU has met this.So there are two levers, and I'd pull both:
Knock-on: whatever baseline we pick becomes the shared architecture for the 3-way ablation, so this slots in cleanly — the ablation protocol (fixed val set, identical everything except channels) doesn't change, we just run it on the new backbone.
Fine-tuning vs training new — terminology, then substance
Quick terminology fix so we're speaking the same language (no apology needed, it's a fair question): "fine-tuning ImageNet" is shorthand for "start from weights pretrained on ImageNet, then continue training on our data." We never train on ImageNet itself; it's just the starting point — a backbone that already knows edges, textures, and shape features, which transfer surprisingly well to overhead imagery.
Introducing NIR does not mean training from scratch. The standard move is first-conv surgery: the pretrained first conv expects 3 channels (RGB); we widen it to 4 and initialize the new NIR channel's weights by copying the mean of the RGB channels (in non-vegetated scenes NIR correlates most strongly with red, so a copy beats random noise). Everything downstream transfers unchanged, then we fine-tune the whole network on Planet tiles.
Variant worth knowing: instead of widening the first conv, insert a tiny learned stem projection (4ch → 3ch) in front of the frozen pretrained backbone. ImageNet weights stay bit-for-bit intact and the model learns the band mapping. Slightly more parameters, slightly more stable in my experience. Either way: we're still fine-tuning from ImageNet weights; we're not training something new.
One caveat that interacts with your question: I recommended earlier that we drop ImageNet's normalization and use per-band SR stats instead (surface reflectance is physically meaningful across dates/sensors; ImageNet's RGB stats are not). That's a normalization change, not an initialization change — the ImageNet weights still transfer fine; the model just adapts to new input statistics during fine-tuning. If we want to be extra careful, freeze the backbone for the first few epochs, train the head, then unfreeze — smooths the transition when input stats change.
Should we ever train from scratch? Only as an ablation control if we suspect ImageNet priors are fighting us (satellite vs natural-image domain gap). It's cheap to test since the harness exists, but I'd be surprised if it won — with ~30k labeled pools, ImageNet-init fine-tuning is the right default.
If you're happy with this direction — new baseline (my pick: ConvNeXt-B, with input resolution as a co-equal lever) + N-channel loader + per-band stats — I can start on the trainer work, which is the step that unblocks the ablation.
Okay, thanks for the explanation, I have a much better sense of what you're proposing.
Go ahead and start the trainer work.
I'd also appreciate a design for what APIs need to exist in nidus-sync. All of the data lives there, the planet tiles, the pool geometries, everything, so we need a plan for how to get it out onto GPU hardware running nemish.
Trainer work is started — PR #14 is up: #14
What's in it (part 1 — real code, unblocks the ablation):
src/data/dataset.py): 4-band (B,G,R,NIR) TIFFs load via rasterio, raw reflectance preserved (no 8-bit round-trip); 8-bit RGB path unchanged. Band order defaults to R,G,B,NIR ([2,1,0,3]) so the first three channels keep ImageNet semantics.src/data/normalization.py+scripts/compute_band_stats.py): corpus-wide mean/std per channel replaces ImageNet normalization for multi-spectral runs (data.normalization: per_band,data.band_stats_file). ImageNet remains the default — existing RGB configs/checkpoints are untouched.widen_first_convinsrc/models/segmentation.py): SMP reinitializes conv1 randomly whenin_channels != 3with pretrained weights, throwing away ImageNet features. The surgery keeps the RGB kernels byte-identical and seeds the new NIR channel with their mean — the standard approach for multispectral fine-tuning.load_modelreconstructs the exact preprocessing (no train/serve skew).tests/test_multispectral.py), incl. a full 4-band dataset load and surgery weight-preservation check.Verified: 13/13 tests pass; end-to-end 4-band train smoke test (1 epoch, ResNet-34 UNet, per-band norm) runs clean; ruff clean. CI: lint-and-test and container-image pass; type-check fails only on pre-existing
label_studio.py/geojson.pyerrors that also fail on main (not introduced here).Part 2 — nidus-sync API design (
doc/nidus-sync-training-data-api.mdin the PR): how planet tiles + pool geometries get from nidus-sync onto GPU hardware. Summary:GET /train/corpus— what imagery exists (item_type, cloud cover, GSD, geometry) for sample selectionGET /train/stats— cached corpus-wide per-band stats → feedsband_stats_fileGET /train/tile/{item}/{asset}/{z}/{x}/{y}.png?format=multipart|tiff— lossless 16-bit per-band tilesGET /train/mask— pool masks rendered fromfeature_pool.geometry(stadia vs confirmed source, optional dilation)GET /train/udm— UDM2 usable-data mask for cloud/haze exclusionPOST /train/manifest— reproducible, split-aware corpus recipe (train/val), so the 3-way ablation runs on identical dataReuses the vision-worker Bearer auth. Rollout: tile+mask first (unblocks the Stadia-on-SkySat geometry sanity check), then manifest+stats (unblocks the ablation), then corpus+udm.
Open questions are in the doc (trainer auth role, manifest storage, freezing the ~1k confirmed eval manifests). Happy to start on the nidus-sync implementation whenever you want.
PR #14 merged — thank you kindly, neighborino! Final CI went fully green, type-check included. For the record: the mypy failures traced back to the offset-cache change (
7d01fe9) passing half-tile float coords into geometry helpers; I widened the annotations to float|int insrc/geojson.py(2e94d50) instead of truncating (which would have shifted offset-tile geometry by half a tile). That also unblocks main, which had been failing type-check since runs 218+.Next up per the rollout order in the API doc: nidus-sync
/train/tile+/train/maskfirst (unblocks the Stadia-on-SkySat geometry sanity check), then manifest + stats (unblocks the 3-way ablation), then corpus + udm. I'll start on the tile+mask endpoints.nidus-sync rollout step 1 is up: PR #182 — Gleipnir/nidus-sync#182
Implements the first two endpoints from the API design doc (merged in nemish PR #14):
Both reuse the vision-worker Bearer auth and the existing Gray16 tile pyramid — no new deps. go build/vet clean, 11 new tests pass.
Next per the rollout order: POST /train/manifest + GET /train/stats (unblocks the 3-way ablation with a fixed reproducible val split), then /train/corpus + /train/udm.
@ned and @eliribble
Here is a way to make sure we don't loose a handle on empty pools.
Preventing NDWI Shortcut Learning in the Pool Detection Model
The Problem: Your Model Will Cheat
At 0.5m SkySat with 4-band surface reflectance, the NDWI channel is the single most predictive feature in your entire input stack. A filled pool has NDWI values that are physically impossible for any non-water surface in a residential scene. The network will discover this in the first few hundred training steps.
This is a problem because:
If your Stadia training labels are predominantly filled pools (which they almost certainly are — Stadia imagery was likely captured during swimming season), then NDWI > threshold becomes a near-perfect predictor of the label. The model never needs to learn shape, spatial context, blue-excess, pixel variation, or anything else. NDWI alone does the job on the training set.
Then in deployment, an empty pool — same shape, same location, same context, but no water — produces NDWI ≈ 0. The model either misses it entirely or classifies it with low confidence. You won't discover this until you're in production, because your validation set has the same filled-pool bias as your training set.
This is a textbook case of shortcut learning (Geirhos et al., 2020): the model latches onto the simplest predictive feature available and ignores richer, more robust features — because the training distribution never penalizes the shortcut.
The Fix: Class-Conditional NDWI Dropout During Training
What it does
During training, for a randomly selected fraction p of pool-labeled pixels in each batch, set the NDWI channel to zero while leaving all other channels intact and the pool label unchanged.
The model is told: "This pixel is a pool. Figure it out without NDWI this time."
What the model is forced to learn
Without NDWI, a pool must be recognized by the combination of:
These are the same features that identify an empty pool. By forcing the model to use them for filled pools during some fraction of training, you ensure they're learned and available at inference time.
Why class-conditional (not global)
You don't want to drop NDWI everywhere — only for pool pixels.
The conditional approach preserves NDWI's genuine value for water detection while preventing it from becoming a crutch.
What p should be
p = the probability that a given pool pixel loses its NDWI channel in a given epoch.
Recommendation: start at p = 0.2. Calibrate upward if the validation recall on known empty pools isn't satisfactory.
The optimal p approximates the fraction of pools you expect to be empty in deployment at any given time. If seasonal variation means 10–20% of pools are empty, p = 0.2 is the right ballpark.
Implementation (for Ned)
This is a small, self-contained change to the dataset loader in
src/data/dataset.py. It does not touch the model architecture, the loss function, or the training loop.Pseudocode
Key design choices
Apply per-pixel, not per-polygon. Each pool pixel independently gets dropped with probability p. This means that within a single pool polygon, some pixels keep NDWI and some don't — the model learns to use NDWI where available and context where it isn't, within the same object.
Zero-fill, don't mean-fill. Setting NDWI to zero for a pixel that should have positive NDWI is exactly what an empty pool looks like. This is the desired behavior.
Don't modify the mask or label. The supervision signal is unchanged. This is purely an input augmentation.
Only during training. Validation and inference see the full channel stack.
Configuration
Add to the training config:
In the ablation framework
This becomes a fourth variant in the ablation matrix, alongside the existing three (3ch / 4ch / 4ch+indices):
The C vs. D comparison specifically isolates the effect of preventing NDWI shortcut learning. If D has marginally worse performance on filled pools but dramatically better on empty pools, the hypothesis is confirmed and D is the production choice.
How to Validate This (for Eli)
Before training: audit the label distribution
Sample 200–300 pools from the Stadia label set across different climate zones and seasons. Manually classify each as:
If < 5% of labels are empty or partially filled, the shortcut risk is real. If > 15% are empty, the training data already covers the case and the augmentation may be unnecessary.
After training: per-condition evaluation
When reporting ablation results, break out pool-level precision/recall by fill state:
The C vs. D gap on empty-pool recall is the metric that matters. A small degradation in overall IoU (1–2 points) is an acceptable trade for a 40-point improvement in empty-pool recall — because missing empty pools creates a worse user experience than slightly noisier boundaries on filled pools.
Sanity check: NDWI ablation on a few known empty pools
Before full training, run the separability study specifically on empty pools. Take 10–20 known empty pools (manual annotation is fine), compute per-polygon NDWI histograms, and overlay them with the histograms for roofs. If empty-pool NDWI overlaps substantially with roof NDWI, the augmentation is necessary — the model won't distinguish them without shape/context cues. If empty pools are still somewhat NDWI-positive (residual moisture, shadow in the deep end), the problem may be smaller than expected.
Theoretical Foundations (Summary)
This technique is a synthesis of well-established ideas:
CutOut (DeVries & Taylor, 2017) / Random Erasing (Zhong et al., 2020): Randomly masking spatial regions of input forces networks to use surrounding context rather than a single discriminative patch. NDWI channel dropout is the spectral analog.
Spectral band dropping (remote sensing literature): Randomly omitting spectral bands during training to produce models robust to missing or noisy channels. Standard practice in hyperspectral classification.
Shortcut learning (Geirhos et al., 2020, Nature Machine Intelligence): Neural networks are lazy — they learn the simplest predictive feature and ignore others if the training distribution doesn't penalize it. Class-conditional dropout is a targeted intervention to break a specific, identifiable shortcut.
Domain generalization / invariant risk minimization: The principle that a model should not depend on features that are present in training but absent in deployment. An empty pool has no NDWI signal — making NDWI non-essential during training aligns the training distribution with the deployment distribution.
What This Costs
TL;DR
Your model will learn to detect water, not pools, unless you force it to recognize pools when water isn't visibly present. Randomly zero the NDWI channel for 20% of pool pixels during training. This is cheap, theoretically sound, and the ablation will tell you definitively whether it helped. The comparison between C (p=0.0) and D (p=0.2) on empty-pool recall is the single most informative experiment in the ablation after the primary 3ch vs. 4ch comparison.
@benjaminsperry @eliribble
This is a great catch, Benjamin — the shortcut-learning diagnosis is exactly right, and it's the cheapest high-value experiment we could add to the ablation. The C vs. D comparison on empty-pool recall is the right way to measure it. A few thoughts, one substantive design change, and an implementation plan.
Agreed on the problem and the validation structure
Stadia imagery was almost certainly captured in swimming season, so the labels are filled-pool-heavy; with NDWI as an explicit input channel, variant C would learn "water detector" in the first few hundred steps. The audit + per-condition eval you propose is the correct structure, and it dovetails with what we already planned as step (2) of this issue (Stadia-on-SkySat label audit). I'd fold the fill-state classification into that same annotation pass, and add fill-state labels to (a subset of) the ~1k human-confirmed holdout so C vs. D can be scored on empty-pool precision/recall directly — that's the metric that decides production.
Suggested change: per-polygon dropout, not per-pixel
The per-pixel version is weaker than intended, for two reasons:
Deployment reality is that the whole pool is dry, so drop NDWI at the polygon level: pick p fraction of pool polygons and zero NDWI across all of their pixels. That reproduces the empty-pool input distribution exactly — no holes, no checkerboard. We have the instance IDs to do this: labels are Stadia polygons and the
train/maskendpoint already rasterizes per-polygon.Per-pixel isn't worthless though — a patchy NDWI field is roughly what a partially filled pool looks like:
Config:
data.augmentation.ndwi_dropout: {enabled: true, mode: polygon, probability: 0.2, channel_index: <from manifest>}. One correction: don't hardcodechannel_index: 4— derive it from the band manifest. The checkpoint already records band order ([R,G,B,NIR]+ indices appended, so NDWI sits at index 4 today), but manifest-driven is robust to reordering.Two things worth adding to the analysis
Shortcut learning applies to variant B too. The net doesn't need an explicit NDWI channel to cheat — it can synthesize G−NIR in the first conv, so B can silently become a water detector as well, and you can't drop a feature you never named. This is an argument for keeping NDWI explicit (C/D): it turns the shortcut from an unobservable learned feature into a channel we can regularize and measure. If we ever want the same protection for B, the standard hyperspectral practice is random spectral band dropping (drop NIR for a fraction of samples).
Zero must mean zero. Apply the dropout on the raw channel before per-band normalization, so 0 = NDWI 0 = the dry signature, not "normalized mean." Small detail, but it keeps the augmentation semantically meaningful.
Implementation
~25 lines in
src/data/dataset.py__getitem__(after the raw stack, beforenormalizer.apply— the merged N-channel loader already lands there), plus config + checkpoint metadata (we already record normalization/stats/band order). No model, loss, or training-loop changes; cost is negligible; 1–3 IoU points on filled pools is a fine trade per your estimate.I'll fold this in as ablation variant D once the nidus-sync tile+mask endpoints land (PR #182), or as a standalone small nemish PR if you'd rather start the audit in parallel. I can also pre-generate the audit sample: cluster the corpus by NDWI through the tile pipeline to surface likely-empty candidates for fill-state classification, plus the empty-pool-vs-roof NDWI histogram sanity check.