A flag in the sky came out weighted as sky, and no feather setting fixed it. The scene model's logits are `[1, 150, 80, 80]`, so one cell is eight input pixels; at the 1600px proxy the letterbox scale is 0.4 and **one cell is 20 proxy pixels**, which `rasterise`'s bilinear then spreads across one more either side. A flag is a handful of cells whose softmax is dominated by the sky around it. The information was never in the grid, so nothing downstream of the grid can recover it. Tiling is the answer for an instance and is not available here: a category has no bounding box to tile over — sky is wherever the sky is. But the photograph is at full proxy resolution even though the weights are not, and it knows exactly where the flag is. So the model says *what*, and the pixels say *which of them*, which is the division of labour arm C already draws between the instance model and the watershed. ## Seeds, and why the erosion radius is not a guess Threshold the weights high, take `signed_distance`, and keep what is more than 1.5 cells inside. One cell *is* the model's resolution and the bilinear spreads it across one more, so the band either side of the boundary is smear rather than evidence. Deriving the radius from `Scene::cell_pixels` rather than picking a pixel count means it stays right if the proxy edge or the export changes. The mirror of that set is a confident *exterior*, free from the same field. ## Dropping small modes is the step that makes it work Four k-means modes per side, not one Gaussian: sky is blue at the zenith, white where the cloud is and pale at the horizon, and one blob over all three rejects two of them. Then modes holding under 3% of a side are discarded, and without that step the whole thing fails on the case it was built for. A small flag deep in the sky has both a high weight and a large distance from the boundary, so it lands in the interior sample and teaches the model its own colour. It cannot be excluded geometrically. It can be excluded by share. Luminance is weighted at a quarter against chrominance for the same reason the watershed's gradient is. Sky's variance is dominated by luminance, so at equal weight the distribution is a long bright streak that a mid-grey flag sits comfortably inside. A flag is separated by chrominance; a cloud is separated by luminance alone. Not zero, or a dark bird against a bright sky survives. ## Two tests, because either alone is wrong Absolute — is this colour plausible under the category, as a chi-square on the Mahalanobis distance. Comparative — is it likelier inside than outside. A pixel must pass both. The absolute test is what catches the flag, whose colour is far from *both* sides and which the comparative test alone would leave at even odds. The comparative test is what stops the absolute one needing a constant tuned per category. ## What this cannot do, written down rather than left to be discovered An intruder large enough to hold its own mode is kept. By share, a flag over a fifth of the sky and a cloud bank over a fifth of the sky are the same object, and colour does not separate them either — a white cloud is as far from blue sky in chrominance as many intruders are. So `min_cluster` is not a threshold with a correct value waiting to be found; it is the trade-off itself, set where a photographic intruder falls. Both ends are pinned by tests — `a_flag_in_the_sky_is_removed` and `an_intruder_larger_than_min_cluster_survives` — so that moving the number reads as moving the trade-off rather than as fixing a bug. The case left open is a large unrecognised object in a clean category, which wants the boundary snapped to watershed basins and is a different mechanism. ## Safe to apply without a control It is subtractive: the output is the input times a factor in `0..=1`. The worst failure available to it is losing part of a real sky, never gaining a region, so a blue car below the horizon that was never in the mask cannot be pulled into it. And a factor in `0..=1` cannot raise a sum, so `scene.rs`'s partition still holds when every category is refined independently — the weight taken off the flag lands in the unlisted remainder, which is where a flag belongs, ADE20K having no class for one. Every path without the evidence to judge returns the weights untouched and says which path it took. A refinement that silently did nothing is indistinguishable from the feature being off, and an empty seed set fitted to a distribution would reject every pixel. The signature is deliberately unchanged: categories are addressed by name, not by index, so a sharper mask cannot create the stale-index hazard the signature exists to guard against. The example writes `<prefix>-<category>-refined.ppm` beside the coarse one, never instead of it — whether this is an improvement is a comparative judgement and one image cannot answer it. Verified: fmt clean, clippy -D warnings clean, 57 dr-segment tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DarkRoom
A cross-platform, non-destructive RAW photo editor for Linux and Android.
Status: 0.9.0, and no longer a spike. A library opens, culls, develops and exports on both platforms, across eight tagged releases. What is not built is written down rather than merely absent — see docs/outstanding.md for the requirements that have no implementation and why, and docs/technical-debt.md for the compromises that were chosen.
Documentation
| Document | Contents |
|---|---|
| CONTRIBUTING.md | How to land a first change without reading the rest |
| requirements.md | What the software must do — 179 numbered requirements |
| architecture.md | How it is built — crates, GPU pipeline, data model, sync |
| technical-debt.md | Compromises taken deliberately, each with the condition that retires it |
| outstanding.md | What is not built, and whether that is a decision or a gap |
| code-health.md | What a contribution costs, per seam, measured |
| traceability.md | Generated: which requirement is claimed by which file |
| faces.md | Face detection and identity — the models, the licence problem, and what S14 measured |
Building
Desktop:
cargo run -p darkroom-desktop
Android (containerised toolchain, see docker/android):
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
Git LFS is required for the model weights, and the toolchain pins itself. CONTRIBUTING.md has the details and the four commands CI will run against what you send.
Current state
Working. A catalog over a local folder, a Nextcloud account, or a folder a
sync client keeps in virtual-files mode — where a placeholder is treated as the
photograph rather than as a one-byte file. A virtualised library grid with a
capture-time timeline, ratings, labels, keywords, collections and a trash that
survives a crash mid-operation. Card ingest. Face detection and identity, with
the index syncing between devices. A develop pipeline of fifteen declared
operations fused into a single compute dispatch, plus the neighbourhood
operations that cannot be — clarity, texture, capture sharpening, noise
reduction, lens correction, spectral film simulation. Crop, straighten, spot
removal, gradient and subject-segmentation masks, named presets, and a
generated panel that no operation in ui/ is allowed to name. Export to JPEG,
PNG and 8- or 16-bit TIFF with resize and output sharpening.
The zero-copy display path works on desktop. The compute pass writes a texture that Slint composites directly, which is what ARCH §6.1 requires; the readback it forbids costs 96% of frame time at 4K, and
cargo run -p dr-gpu --example bench --features readback
still reproduces that measurement. The one exception is the Android develop view, which reads the frame back through the CPU because zero-copy there needs wgpu's Vulkan swapchain, and that tears a portrait window on a tablet whose panel is mounted landscape. It is debt, not a revision of the rule: the reasoning, the on-device measurements that forced it, and the three separate things any one of which would remove it are in technical-debt.md TD-1.
Not built. Plugins, compare and survey culling, focus peaking, burst grouping, AI denoise, tiled and progressive rendering, and most of the Android platform integration beyond running. The performance targets in §4.1 are unverified rather than unmet — the per-commit benchmark suite §8 requires does not exist, so nothing fails a build on a regression. docs/outstanding.md is the list, with the reasoning.
Licence
GPL-3.0-or-later.