Merge origin/main

This commit is contained in:
Brandon Thomas 2026-10-09 08:05:59 -04:00
commit 9eb8151984
92 changed files with 12925 additions and 160 deletions

331
Cargo.lock generated
View File

@ -209,7 +209,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
dependencies = [
"equator",
"equator 0.4.2",
]
[[package]]
@ -967,6 +967,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "coe-rs"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e8f1e641542c07631228b1e0dc04b69ae3c1d58ef65d5691a439711d805c698"
[[package]]
name = "color"
version = "0.3.3"
@ -1256,6 +1262,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dyn-stack"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e53799688f5632f364f8fb387488dd05db9fe45db7011be066fc20e7027f8b"
dependencies = [
"bytemuck",
"reborrow",
]
[[package]]
name = "dyn-stack"
version = "0.13.2"
@ -1537,13 +1553,33 @@ version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
[[package]]
name = "equator"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3b0a88aa91d0ad2b9684e4479aed31a17d3f9051bdbbc634bd2c01bc5a5eee8"
dependencies = [
"equator-macro 0.1.9",
]
[[package]]
name = "equator"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
dependencies = [
"equator-macro",
"equator-macro 0.4.2",
]
[[package]]
name = "equator-macro"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d08acb9849f7fb4401564f251be5a526829183a3645a90197dea8e786cf3ae"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@ -1608,6 +1644,42 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "faer-core"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da0f6262445261106407bdfb548df4f8f1c89bcc3cff1bb6d08e7f525b575a"
dependencies = [
"bytemuck",
"coe-rs",
"dyn-stack 0.10.0",
"equator 0.1.10",
"faer-entity",
"gemm 0.17.1",
"matrixcompare-core",
"num-complex",
"num-traits",
"paste",
"pulp 0.18.22",
"reborrow",
"seq-macro",
]
[[package]]
name = "faer-entity"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fb12f36da3162b3fefc0c7343d905d5bd2c2bf444a7f63ceeaa2bc003311d3"
dependencies = [
"bytemuck",
"coe-rs",
"libm",
"num-complex",
"num-traits",
"pulp 0.18.22",
"reborrow",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@ -1783,13 +1855,33 @@ dependencies = [
"slab",
]
[[package]]
name = "gemm"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ab24cc62135b40090e31a76a9b2766a501979f3070fa27f689c27ec04377d32"
dependencies = [
"dyn-stack 0.10.0",
"gemm-c32 0.17.1",
"gemm-c64 0.17.1",
"gemm-common 0.17.1",
"gemm-f16 0.17.1",
"gemm-f32 0.17.1",
"gemm-f64 0.17.1",
"num-complex",
"num-traits",
"paste",
"raw-cpuid 10.7.0",
"seq-macro",
]
[[package]]
name = "gemm"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-c32 0.18.2",
"gemm-c64 0.18.2",
"gemm-common 0.18.2",
@ -1799,7 +1891,7 @@ dependencies = [
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"seq-macro",
]
@ -1809,7 +1901,7 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-c32 0.19.0",
"gemm-c64 0.19.0",
"gemm-common 0.19.0",
@ -1819,7 +1911,22 @@ dependencies = [
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"seq-macro",
]
[[package]]
name = "gemm-c32"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9c030d0b983d1e34a546b86e08f600c11696fde16199f971cd46c12e67512c0"
dependencies = [
"dyn-stack 0.10.0",
"gemm-common 0.17.1",
"num-complex",
"num-traits",
"paste",
"raw-cpuid 10.7.0",
"seq-macro",
]
@ -1829,12 +1936,12 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-common 0.18.2",
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"seq-macro",
]
@ -1844,12 +1951,27 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-common 0.19.0",
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"seq-macro",
]
[[package]]
name = "gemm-c64"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbb5f2e79fefb9693d18e1066a557b4546cd334b226beadc68b11a8f9431852a"
dependencies = [
"dyn-stack 0.10.0",
"gemm-common 0.17.1",
"num-complex",
"num-traits",
"paste",
"raw-cpuid 10.7.0",
"seq-macro",
]
@ -1859,12 +1981,12 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-common 0.18.2",
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"seq-macro",
]
@ -1874,15 +1996,34 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-common 0.19.0",
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"seq-macro",
]
[[package]]
name = "gemm-common"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2e7ea062c987abcd8db95db917b4ffb4ecdfd0668471d8dc54734fdff2354e8"
dependencies = [
"bytemuck",
"dyn-stack 0.10.0",
"half",
"num-complex",
"num-traits",
"once_cell",
"paste",
"pulp 0.18.22",
"raw-cpuid 10.7.0",
"seq-macro",
"sysctl 0.5.5",
]
[[package]]
name = "gemm-common"
version = "0.18.2"
@ -1890,7 +2031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3"
dependencies = [
"bytemuck",
"dyn-stack",
"dyn-stack 0.13.2",
"half",
"libm",
"num-complex",
@ -1898,10 +2039,10 @@ dependencies = [
"once_cell",
"paste",
"pulp 0.21.5",
"raw-cpuid",
"raw-cpuid 11.6.0",
"rayon",
"seq-macro",
"sysctl",
"sysctl 0.6.0",
]
[[package]]
@ -1911,7 +2052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e"
dependencies = [
"bytemuck",
"dyn-stack",
"dyn-stack 0.13.2",
"half",
"libm",
"num-complex",
@ -1919,10 +2060,27 @@ dependencies = [
"once_cell",
"paste",
"pulp 0.22.3",
"raw-cpuid",
"raw-cpuid 11.6.0",
"rayon",
"seq-macro",
"sysctl",
"sysctl 0.6.0",
]
[[package]]
name = "gemm-f16"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca4c06b9b11952071d317604acb332e924e817bd891bec8dfb494168c7cedd4"
dependencies = [
"dyn-stack 0.10.0",
"gemm-common 0.17.1",
"gemm-f32 0.17.1",
"half",
"num-complex",
"num-traits",
"paste",
"raw-cpuid 10.7.0",
"seq-macro",
]
[[package]]
@ -1931,14 +2089,14 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-common 0.18.2",
"gemm-f32 0.18.2",
"half",
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"rayon",
"seq-macro",
]
@ -1949,30 +2107,45 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-common 0.19.0",
"gemm-f32 0.19.0",
"half",
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"rayon",
"seq-macro",
]
[[package]]
name = "gemm-f32"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9a69f51aaefbd9cf12d18faf273d3e982d9d711f60775645ed5c8047b4ae113"
dependencies = [
"dyn-stack 0.10.0",
"gemm-common 0.17.1",
"num-complex",
"num-traits",
"paste",
"raw-cpuid 10.7.0",
"seq-macro",
]
[[package]]
name = "gemm-f32"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-common 0.18.2",
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"seq-macro",
]
@ -1982,12 +2155,27 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-common 0.19.0",
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"seq-macro",
]
[[package]]
name = "gemm-f64"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa397a48544fadf0b81ec8741e5c0fba0043008113f71f2034def1935645d2b0"
dependencies = [
"dyn-stack 0.10.0",
"gemm-common 0.17.1",
"num-complex",
"num-traits",
"paste",
"raw-cpuid 10.7.0",
"seq-macro",
]
@ -1997,12 +2185,12 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-common 0.18.2",
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"seq-macro",
]
@ -2012,12 +2200,12 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a"
dependencies = [
"dyn-stack",
"dyn-stack 0.13.2",
"gemm-common 0.19.0",
"num-complex",
"num-traits",
"paste",
"raw-cpuid",
"raw-cpuid 11.6.0",
"seq-macro",
]
@ -2869,6 +3057,38 @@ dependencies = [
"serde",
]
[[package]]
name = "lightcraft-denoise"
version = "0.4.0"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
"lightcraft-raster",
"miniz_oxide 0.8.9",
"proptest",
"rayon",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.21",
"web-time",
]
[[package]]
name = "lightcraft-denoise-core"
version = "0.4.0"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
"proptest",
"rayon",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.21",
"web-time",
]
[[package]]
name = "lightcraft-develop"
version = "0.4.0"
@ -2891,6 +3111,8 @@ dependencies = [
"lightcraft-catalog",
"lightcraft-codecs",
"lightcraft-color",
"lightcraft-denoise",
"lightcraft-denoise-core",
"lightcraft-develop",
"lightcraft-faces",
"lightcraft-fetch",
@ -2954,6 +3176,7 @@ version = "0.4.0"
dependencies = [
"bytemuck",
"lightcraft-color",
"lightcraft-denoise",
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-pipeline",
@ -3118,6 +3341,7 @@ dependencies = [
"lightcraft-catalog",
"lightcraft-codecs",
"lightcraft-color",
"lightcraft-denoise",
"lightcraft-develop",
"lightcraft-engine",
"lightcraft-faces",
@ -3211,6 +3435,12 @@ dependencies = [
"libc",
]
[[package]]
name = "matrixcompare-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0bdabb30db18805d5290b3da7ceaccbddba795620b86c02145d688e04900a73"
[[package]]
name = "matrixmultiply"
version = "0.3.11"
@ -4353,6 +4583,18 @@ dependencies = [
"unarray",
]
[[package]]
name = "pulp"
version = "0.18.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0a01a0dc67cf4558d279f0c25b0962bd08fc6dec0137699eae304103e882fe6"
dependencies = [
"bytemuck",
"libm",
"num-complex",
"reborrow",
]
[[package]]
name = "pulp"
version = "0.21.5"
@ -4379,7 +4621,7 @@ dependencies = [
"num-complex",
"paste",
"pulp-wasm-simd-flag",
"raw-cpuid",
"raw-cpuid 11.6.0",
"reborrow",
"version_check",
]
@ -4580,6 +4822,15 @@ dependencies = [
"rgb",
]
[[package]]
name = "raw-cpuid"
version = "10.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c297679cb867470fa8c9f67dbba74a78d78e3e98d7cf2b08d6d71540f797332"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@ -5313,6 +5564,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "sysctl"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7dddc5f0fee506baf8b9fdb989e242f17e4b11c61dfbb0635b705217199eea"
dependencies = [
"bitflags 2.13.2",
"byteorder",
"enum-as-inner",
"libc",
"thiserror 1.0.69",
"walkdir",
]
[[package]]
name = "sysctl"
version = "0.6.0"

View File

@ -11,6 +11,8 @@ repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
lightcraft-denoise = { path = "crates/denoise", default-features = false }
lightcraft-geom = { path = "crates/geom" }
lightcraft-faces = { path = "crates/faces" }
lightcraft-sysmem = { path = "crates/sysmem" }

9
NOTICE
View File

@ -27,6 +27,15 @@ the facebook/sam3 checkpoint only when they choose to (in the app, or by hand),
License (https://github.com/facebookresearch/sam3/blob/main/LICENSE), a separate, non-OSI
licence. See docs/ai-masks.md.
AI denoise weights are not part of LightCraft and are never distributed with it.
The default build offers user-supplied models only. The optional rawnind-model
feature offers a separately downloaded RawNIND UtNet2 Bayer checkpoint under
GPL-3.0 (https://www.gnu.org/licenses/gpl-3.0.html), after the user accepts its terms.
Commercial use is subject to those terms, including applicable source, licence and
notice obligations on redistribution. Enabling that offer in releases remains a
maintainer decision. The ONNX reader and CPU/GPU executor are our Rust implementation;
no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included.
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ

View File

@ -25,7 +25,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
@ -40,7 +40,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); Bayer AI denoise needs separately installed weights; X-Trans/linear RGB, model policy and quality parity remain open |
## Where we're going
@ -59,9 +59,9 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
(references stay in the local `plan/`), then tune against the numbers.
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
to be set up, docs/ai-masks.md); Subject / Sky / People and Enhance are still open.
to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open.
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
and accessibility.
@ -86,7 +86,7 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise, Raw Details, Super Resolution ⬜) |
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) |
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |

View File

@ -25,7 +25,9 @@ serde_json = { workspace = true }
dhat = { workspace = true, optional = true }
[features]
default = []
default = ["denoise"]
denoise = ["lightcraft-engine/denoise"]
rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"]
# Heap profiling: `cargo run --release -p lightcraft-cli --features dhat-heap -- snapshot …` counts
# every allocation (live/peak bytes in `library.memory` / `ui.inspect`) and writes dhat-heap.json
# (open it in DHAT's viewer: allocation sites by bytes at the peak). Slows allocation-heavy work.

View File

@ -391,7 +391,7 @@ fn merge(args: &[String]) -> Result<(), String> {
}
i += 1;
}
let mut s = Session::new().with_fs();
let mut s = Session::new().with_fs().with_default_denoise_models();
let paths = expand_paths(&files);
let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
let mut ids: Vec<u64> = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
@ -652,7 +652,7 @@ fn render(args: &[String]) -> Result<(), String> {
}
let input = input.ok_or("render: missing input file")?;
let output = output.ok_or("render: missing -o OUTPUT")?;
let mut s = Session::new().with_fs();
let mut s = Session::new().with_fs().with_default_denoise_models();
let abs = expand_paths(std::slice::from_ref(&input));
let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?;
let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?;
@ -732,12 +732,12 @@ fn snapshot(args: &[String]) -> Result<(), String> {
let t0 = Instant::now();
let mut session = match &library {
Some(dir) => {
let mut s = Session::new().with_fs().with_default_face_models();
let mut s = Session::new().with_fs().with_default_denoise_models().with_default_face_models();
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
s
}
None if files.is_empty() => Session::with_demo().with_fs().with_default_face_models(),
None => Session::new().with_fs().with_default_face_models(),
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_default_face_models(),
None => Session::new().with_fs().with_default_denoise_models().with_default_face_models(),
};
// Apply compute policy before imports or the first scripted query can discover an adapter.
session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;

View File

@ -205,6 +205,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
open_picker(originals.clone(), backend.clone(), ctx.clone());
Vec::new() // files arrive asynchronously and are imported on a later frame
})),
pick_denoise_model: None,
// Preset files: browser pickers are asynchronous; not wired on the web yet.
pick_preset_files: None,
pick_tracklog: None,

View File

@ -10,7 +10,9 @@ default-run = "lightcraft"
workspace = true
[features]
default = []
default = ["denoise"]
denoise = ["lightcraft-engine/denoise"]
rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"]
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
# it (HEVC patents); build with `--features heif` to include it.
heif = ["lightcraft-codecs/heif"]

View File

@ -125,7 +125,7 @@ fn gpu_crash_notice(what: &str) -> String {
}
fn config_dir() -> Option<std::path::PathBuf> {
lightcraft_engine::camera_profiles::config_dir()
lightcraft_engine::config::config_dir()
}
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
@ -387,6 +387,13 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_denoise_model: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().into_owned()])
.unwrap_or_default()
})),
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
@ -468,15 +475,15 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
fn open_library_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
if in_memory {
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None);
}
let unopened = || Session::new().with_fs().with_system_clock();
let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock();
let Some(dir) = dir else {
log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)");
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
};
let t0 = std::time::Instant::now();
let mut s = Session::new().with_fs().with_system_clock();
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
match s.open_library(&dir, seed_demo) {
Ok(r) => {
let (replayed, torn) = (r.replayed, r.torn_bytes);

View File

@ -0,0 +1,26 @@
[package]
name = "lightcraft-denoise-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "LightCraft denoise model metadata, Bayer tiling and cache formats (L1; no inference backend)"
[lints]
workspace = true
[features]
default = []
rawnind-model = []
[dependencies]
lightcraft-raster = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
miniz_oxide = { workspace = true }
rayon = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }

View File

@ -0,0 +1,7 @@
# lightcraft-denoise-core
L1 model metadata, bounded archive/cache formats and Bayer packing/tiling helpers.
No weights or inference backend. See [the user workflow](../../docs/denoise.md).
`lightcraft-denoise` re-exports these types for CPU/GPU users; the engine uses this
crate directly when inference is disabled. The default download catalog is empty.
Run `cargo test -p lightcraft-denoise-core`; optional catalog checks use `--features rawnind-model`.

View File

@ -0,0 +1,279 @@
//! Taking one file out of a `.zip` (a `.dtmodel` is one), the little of the format a model download needs: stored and
//! deflated entries, found by name, checked against their CRC-32 and a size limit, written next to where they are
//! going and moved into place. It is a stranger's file, so every length and offset in it is checked, a name is only
//! ever compared (never used as a path), and zip64, encrypted and multi-disk archives are refused.
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
/// How far from the end the end-of-central-directory record can start (a 22-byte record and a 64 KiB comment).
const EOCD_SEARCH: u64 = 22 + 65535;
/// Largest central directory we read.
const MAX_DIRECTORY: u64 = 1 << 20;
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum ArchiveError {
#[error("the archive could not be read: {0}")]
Io(String),
#[error("not a usable archive: {0}")]
Format(String),
#[error("the archive has no file called `{0}`")]
Missing(String),
}
impl From<std::io::Error> for ArchiveError {
fn from(e: std::io::Error) -> Self {
ArchiveError::Io(e.to_string())
}
}
fn format<T>(why: &str) -> Result<T, ArchiveError> {
Err(ArchiveError::Format(why.into()))
}
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
}
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
/// CRC-32 (IEEE) of `bytes`.
fn crc32(bytes: &[u8]) -> u32 {
let mut table = [0u32; 256];
for (i, t) in table.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
}
*t = c;
}
!bytes.iter().fold(!0u32, |c, &b| table[((c ^ u32::from(b)) & 0xff) as usize] ^ (c >> 8))
}
struct Entry {
method: u16,
crc: u32,
compressed: u64,
size: u64,
local_header: u64,
}
/// Find the entry called `name` (exactly, or as the last part of a longer name: `folder/name`) in the central directory.
fn find(f: &mut (impl Read + Seek), name: &str) -> Result<Entry, ArchiveError> {
let len = f.seek(SeekFrom::End(0))?;
let tail_len = len.min(EOCD_SEARCH);
f.seek(SeekFrom::Start(len - tail_len))?;
let mut tail = vec![0u8; tail_len as usize];
f.read_exact(&mut tail)?;
let eocd = (0..tail.len().saturating_sub(21)).rev().find(|&i| u32_at(&tail, i) == Some(0x0605_4b50));
let Some(e) = eocd else { return format("this is not a zip file") };
let (entries, size, offset) = (u16_at(&tail, e + 10), u32_at(&tail, e + 12), u32_at(&tail, e + 16));
let (Some(entries), Some(size), Some(offset)) = (entries, size, offset) else { return format("the end of the archive is cut short") };
if entries == 0xffff || size == u32::MAX || offset == u32::MAX {
return format("zip64 archives are not supported");
}
if u64::from(size) > MAX_DIRECTORY || u64::from(offset) + u64::from(size) > len {
return format("the list of files is not where it says");
}
f.seek(SeekFrom::Start(u64::from(offset)))?;
let mut dir = vec![0u8; size as usize];
f.read_exact(&mut dir)?;
let mut at = 0usize;
for _ in 0..entries {
if u32_at(&dir, at) != Some(0x0201_4b50) {
return format("the list of files is damaged");
}
let get = |o: usize| at.checked_add(o);
let (flags, method, crc) = (u16_at(&dir, at + 8), u16_at(&dir, at + 10), u32_at(&dir, at + 16));
let (compressed, uncompressed) = (u32_at(&dir, at + 20), u32_at(&dir, at + 24));
let (n, x, c) = (u16_at(&dir, at + 28), u16_at(&dir, at + 30), u16_at(&dir, at + 32));
let local = u32_at(&dir, at + 42);
let (Some(flags), Some(method), Some(crc), Some(compressed), Some(uncompressed), Some(n), Some(x), Some(c), Some(local), Some(start)) =
(flags, method, crc, compressed, uncompressed, n, x, c, local, get(46))
else {
return format("the list of files is cut short");
};
let Some(entry_name) = dir.get(start..start + usize::from(n)) else { return format("a file name is cut short") };
let matches = entry_name == name.as_bytes() || entry_name.strip_suffix(name.as_bytes()).is_some_and(|p| p.ends_with(b"/"));
if matches {
if flags & 1 != 0 {
return format("encrypted files are not supported");
}
if compressed == u32::MAX || uncompressed == u32::MAX {
return format("zip64 files are not supported");
}
return Ok(Entry { method, crc, compressed: u64::from(compressed), size: u64::from(uncompressed), local_header: u64::from(local) });
}
at = start + usize::from(n) + usize::from(x) + usize::from(c);
}
Err(ArchiveError::Missing(name.into()))
}
/// Take the file called `name` out of the archive at `archive` and write it to `dest` (through a temporary file next
/// to it); `max_bytes` bounds its size. Returns the number of bytes written.
pub fn extract(archive: &Path, name: &str, dest: &Path, max_bytes: u64) -> Result<u64, ArchiveError> {
let mut f = std::io::BufReader::new(std::fs::File::open(archive)?);
let e = find(&mut f, name)?;
if e.size > max_bytes || e.compressed > max_bytes {
return format("the file in the archive is larger than allowed");
}
f.seek(SeekFrom::Start(e.local_header))?;
let mut head = [0u8; 30];
f.read_exact(&mut head)?;
if u32_at(&head, 0) != Some(0x0403_4b50) {
return format("the file's header is not where it says");
}
let skip = u64::from(u16_at(&head, 26).unwrap_or(0)) + u64::from(u16_at(&head, 28).unwrap_or(0));
f.seek_relative(i64::try_from(skip).map_err(|_| ArchiveError::Format("a header is too long".into()))?)?;
let len = usize::try_from(e.compressed)
.ok()
.filter(|&n| n <= 256 * 1024 * 1024)
.ok_or_else(|| ArchiveError::Format("the compressed model is too large".into()))?;
if e.size > 256 * 1024 * 1024 {
return format("the extracted model is too large");
}
let mut packed = Vec::new();
packed.try_reserve_exact(len).map_err(|_| ArchiveError::Format("not enough memory for the model archive".into()))?;
packed.resize(len, 0u8);
f.read_exact(&mut packed)?;
let data = match e.method {
0 => packed,
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(&packed, max_bytes.min(256 * 1024 * 1024) as usize)
.map_err(|err| ArchiveError::Format(format!("{err:?}")))?,
m => return format(&format!("compression method {m} is not supported")),
};
if data.len() as u64 != e.size || crc32(&data) != e.crc {
return format("the file in the archive is damaged");
}
let tmp = dest.with_extension("part");
let written = (|| -> std::io::Result<()> {
let mut out = std::fs::File::create(&tmp)?;
out.write_all(&data)?;
out.flush()?;
drop(out);
std::fs::rename(&tmp, dest)
})();
if let Err(err) = written {
let _ = std::fs::remove_file(&tmp);
return Err(err.into());
}
Ok(data.len() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
/// A zip with the given (name, bytes, deflate) entries, written by hand.
pub(crate) fn make_zip(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
let mut out = Vec::new();
let mut dir = Vec::new();
for (name, data, deflate) in files {
let packed = if *deflate { miniz_oxide::deflate::compress_to_vec(data, 6) } else { data.to_vec() };
let offset = out.len() as u32;
let method: u16 = if *deflate { 8 } else { 0 };
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
out.extend_from_slice(&[20, 0, 0, 0]);
out.extend_from_slice(&method.to_le_bytes());
out.extend_from_slice(&[0, 0, 0, 0]);
out.extend_from_slice(&crc32(data).to_le_bytes());
out.extend_from_slice(&(packed.len() as u32).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&[0, 0]);
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(&packed);
dir.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
dir.extend_from_slice(&[20, 0, 20, 0, 0, 0]);
dir.extend_from_slice(&method.to_le_bytes());
dir.extend_from_slice(&[0, 0, 0, 0]);
dir.extend_from_slice(&crc32(data).to_le_bytes());
dir.extend_from_slice(&(packed.len() as u32).to_le_bytes());
dir.extend_from_slice(&(data.len() as u32).to_le_bytes());
dir.extend_from_slice(&(name.len() as u16).to_le_bytes());
dir.extend_from_slice(&[0; 12]);
dir.extend_from_slice(&offset.to_le_bytes());
dir.extend_from_slice(name.as_bytes());
}
let cd_offset = out.len() as u32;
out.extend_from_slice(&dir);
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&[0, 0, 0, 0]);
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
out.extend_from_slice(&(dir.len() as u32).to_le_bytes());
out.extend_from_slice(&cd_offset.to_le_bytes());
out.extend_from_slice(&[0, 0]);
out
}
fn temp(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("lc-denoise-zip-{name}-{}", std::process::id()))
}
#[test]
fn a_stored_and_a_deflated_file_come_out_whole() {
let big: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
let zip = make_zip(&[("m/config.json", b"{}", false), ("m/model.onnx", &big, true), ("m/other.onnx", b"zz", true)]);
let (a, out) = (temp("a.zip"), temp("a.onnx"));
std::fs::write(&a, &zip).unwrap();
assert_eq!(extract(&a, "model.onnx", &out, 1 << 20).unwrap(), big.len() as u64);
assert_eq!(std::fs::read(&out).unwrap(), big);
assert_eq!(extract(&a, "m/config.json", &out, 1 << 20).unwrap(), 2);
assert_eq!(std::fs::read(&out).unwrap(), b"{}");
// a name must match whole path parts: `del.onnx` is not `model.onnx`
assert!(matches!(extract(&a, "del.onnx", &out, 1 << 20), Err(ArchiveError::Missing(_))));
assert!(matches!(extract(&a, "nothing", &out, 1 << 20), Err(ArchiveError::Missing(_))));
// the size limit applies
assert!(matches!(extract(&a, "model.onnx", &out, 1000), Err(ArchiveError::Format(_))));
for p in [a, out] {
let _ = std::fs::remove_file(p);
}
}
/// Opt-in: `LC_DENOISE_DTMODEL=<rawdenoise-nind.dtmodel> cargo test -p lightcraft-denoise -- --ignored archive`
#[test]
#[ignore = "needs the real download: set LC_DENOISE_DTMODEL"]
fn the_real_download_gives_the_model_it_should() {
let Some(path) = std::env::var_os("LC_DENOISE_DTMODEL") else { return };
let known = crate::known::find("rawnind-bayer").unwrap();
let (d, entry) = (known.download.unwrap(), known.entry.unwrap());
assert_eq!(crate::hash::sha256_file(Path::new(&path)).unwrap(), d.sha256);
let out = temp("real.onnx");
let n = extract(Path::new(&path), entry, &out, crate::manifest::MAX_MODEL_BYTES).unwrap();
assert_eq!(Some(n), known.manifest.size_bytes);
assert_eq!(Some(crate::hash::sha256_file(&out).unwrap()), known.manifest.sha256);
let _ = std::fs::remove_file(out);
}
#[test]
fn damaged_and_hostile_archives_are_errors_not_panics() {
let zip = make_zip(&[("m/model.onnx", &vec![9u8; 5000], true)]);
let (a, out) = (temp("b.zip"), temp("b.onnx"));
// cut anywhere
for n in (0..zip.len()).step_by(3) {
std::fs::write(&a, &zip[..n]).unwrap();
assert!(extract(&a, "model.onnx", &out, 1 << 20).is_err(), "cut at {n}");
}
// one byte changed anywhere: an error, or (when it falls in an unused field) the right file, never a panic
for at in 0..zip.len() {
let mut bad = zip.clone();
bad[at] ^= 0x5a;
std::fs::write(&a, &bad).unwrap();
if extract(&a, "model.onnx", &out, 1 << 20).is_ok() {
assert_eq!(std::fs::read(&out).unwrap(), vec![9u8; 5000], "a changed byte at {at} gave another file");
}
}
// not a zip at all, and an empty file
for junk in [Vec::new(), b"PK".to_vec(), vec![0u8; 100_000]] {
std::fs::write(&a, junk).unwrap();
assert!(extract(&a, "model.onnx", &out, 1 << 20).is_err());
}
assert!(extract(&temp("missing.zip"), "model.onnx", &out, 1 << 20).is_err());
for p in [a, out] {
let _ = std::fs::remove_file(p);
}
}
}

View File

@ -0,0 +1,191 @@
//! Bayer geometry: which way a mosaic is laid out, and how a patch of it is packed into the four planes
//! (red, green on the red row, green on the blue row, blue) a denoise model takes.
//!
//! Every Bayer layout is the same 2 × 2 cell shifted by one pixel at most, so a layout is just where its
//! first red sample is. The cell grid starts one pixel before the picture when that red sample is in
//! column or row 1, and the picture is read with its edge mirrored (without repeating the edge sample, so
//! the colour of a mirrored sample is the colour that belongs at that place).
/// Where the first red sample of a Bayer mosaic sits, `(dx, dy)` in `{0, 1}²`: RGGB is `(0, 0)`, GRBG `(1, 0)`,
/// GBRG `(0, 1)`, BGGR `(1, 1)`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Layout {
pub dx: usize,
pub dy: usize,
}
impl Layout {
/// The layout of a 2 × 2 colour pattern, row-major, colours 0 = red, 1 = green, 2 = blue; `None` when
/// it is not a Bayer cell (one red, one blue, the greens on the other diagonal).
pub fn from_cell(cell: [u8; 4]) -> Option<Layout> {
let at = |c: u8| cell.iter().position(|&v| v == c);
let red = at(0)?;
let blue = at(2)?;
let greens = cell.iter().filter(|&&v| v == 1).count();
// red and blue sit on one diagonal: indices 0+3 or 1+2
if greens != 2 || red + blue != 3 {
return None;
}
Some(Layout { dx: red % 2, dy: red / 2 })
}
/// The cell grid's origin: `-1` along an axis whose first red sample is at 1, so that cell 0 starts on a red column or row.
fn origin(self) -> (isize, isize) {
(isize::try_from(self.dx).unwrap_or(isize::MAX).saturating_neg(), isize::try_from(self.dy).unwrap_or(isize::MAX).saturating_neg())
}
/// Cells across and down covering a `width × height` mosaic.
pub fn cells(self, width: usize, height: usize) -> (usize, usize) {
let cells = |n: usize, phase: usize| n / 2 + (n % 2 + phase.min(1)).div_ceil(2);
(cells(width, self.dx), cells(height, self.dy))
}
/// The mosaic position of cell `(cx, cy)`'s top-left sample (may lie outside the picture).
pub fn position(self, cx: isize, cy: isize) -> (isize, isize) {
let (ox, oy) = self.origin();
(ox.saturating_add(cx.saturating_mul(2)), oy.saturating_add(cy.saturating_mul(2)))
}
}
/// `i` mirrored into `0..n` (ping-pong, edge not repeated), so `-1` is `1` and `n` is `n - 2`. `n == 0` gives 0.
pub fn reflect(i: isize, n: usize) -> usize {
if n <= 1 {
return 0;
}
if let Some(period) = isize::try_from(n).ok().and_then(|n| (n - 1).checked_mul(2)) {
let m = i.rem_euclid(period);
return (if m < n as isize { m } else { period - m }) as usize;
}
let period = 2 * (n as i128 - 1);
let m = (i as i128).rem_euclid(period);
// The reflected value is in 0..n, so this conversion always fits usize.
(if m < n as i128 { m } else { period - m }) as usize
}
/// Pack the `tile × tile` cells whose top-left cell is `(cx0, cy0)` into `out` as four planes `[R, G1, G2, B]`
/// (`4 · tile²` values, NCHW). G1 is the green beside the red sample, G2 the green under it. Samples outside
/// the picture are mirrored. Returns `false` (and writes nothing) when the sizes do not fit.
pub fn pack_tile(mosaic: &[f32], width: usize, height: usize, layout: Layout, cx0: isize, cy0: isize, tile: usize, out: &mut [f32]) -> bool {
let Some(plane) = tile.checked_mul(tile) else { return false };
let Some(values) = plane.checked_mul(4) else { return false };
if width == 0
|| height == 0
|| tile == 0
|| layout.dx > 1
|| layout.dy > 1
|| width.checked_mul(height) != Some(mosaic.len())
|| out.len() != values
{
return false;
}
let Ok(last) = isize::try_from(tile - 1) else { return false };
let (ox, oy) = layout.origin();
for (start, origin) in [(cx0, ox), (cy0, oy)] {
if start.checked_mul(2).and_then(|v| v.checked_add(origin)).is_none()
|| start.checked_add(last).and_then(|v| v.checked_mul(2)).and_then(|v| v.checked_add(origin)).and_then(|v| v.checked_add(1)).is_none()
{
return false;
}
}
// the four samples of a cell, as (dx, dy) from its top-left sample
const SUB: [(isize, isize); 4] = [(0, 0), (1, 0), (0, 1), (1, 1)];
for (p, &(sx, sy)) in SUB.iter().enumerate() {
let Some(dst) = out.get_mut(p * plane..(p + 1) * plane) else { return false };
for (j, row) in dst.chunks_exact_mut(tile).enumerate() {
let (_, y) = layout.position(cx0, cy0 + j as isize);
let ry = reflect(y + sy, height);
let Some(src) = mosaic.get(ry * width..(ry + 1) * width) else { return false };
for (i, v) in row.iter_mut().enumerate() {
let (x, _) = layout.position(cx0 + i as isize, cy0);
*v = src.get(reflect(x + sx, width)).copied().unwrap_or(0.0);
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_sizes_and_positions_never_panic_or_write_output() {
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
let mut out = [7.0; 4];
for (w, h, tile, x) in [(usize::MAX, 2, 1, 0), (1, 1, usize::MAX, 0), (1, 1, 0, 0), (1, 1, 1, isize::MAX), (1, 1, 1, isize::MIN)] {
assert!(!pack_tile(&[0.5], w, h, layout, x, 0, tile, &mut out));
assert_eq!(out, [7.0; 4]);
}
for n in [1, 2, isize::MAX as usize, usize::MAX] {
for i in [isize::MIN, -1, 0, isize::MAX] {
assert!(reflect(i, n) < n);
}
}
assert_eq!(layout.cells(usize::MAX, usize::MAX), (usize::MAX / 2 + 1, usize::MAX / 2 + 1));
}
#[test]
fn layouts_from_cells() {
assert_eq!(Layout::from_cell([0, 1, 1, 2]), Some(Layout { dx: 0, dy: 0 }));
assert_eq!(Layout::from_cell([1, 0, 2, 1]), Some(Layout { dx: 1, dy: 0 }));
assert_eq!(Layout::from_cell([1, 2, 0, 1]), Some(Layout { dx: 0, dy: 1 }));
assert_eq!(Layout::from_cell([2, 1, 1, 0]), Some(Layout { dx: 1, dy: 1 }));
// not Bayer: two reds, a missing blue, greens on the wrong diagonal
assert_eq!(Layout::from_cell([0, 0, 1, 2]), None);
assert_eq!(Layout::from_cell([0, 1, 2, 1]), None);
assert_eq!(Layout::from_cell([1, 1, 1, 1]), None);
}
#[test]
fn reflection_keeps_the_colour_of_a_site() {
assert_eq!(reflect(-1, 10), 1);
assert_eq!(reflect(-2, 10), 2);
assert_eq!(reflect(10, 10), 8);
assert_eq!(reflect(11, 10), 7);
assert_eq!(reflect(25, 10), 7);
assert_eq!(reflect(5, 0), 0);
assert_eq!(reflect(5, 1), 0);
for i in -40..60 {
assert_eq!(reflect(i, 11) % 2, (i.rem_euclid(2)) as usize, "parity at {i}");
}
}
#[test]
fn packing_puts_each_colour_in_its_plane_for_every_layout() {
// a mosaic whose sample value says which colour it is: R = 10, G = 20, B = 30
for cell in [[0u8, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]] {
let layout = Layout::from_cell(cell).unwrap();
let (w, h) = (10usize, 8usize);
let mosaic: Vec<f32> = (0..w * h).map(|i| [10.0, 20.0, 30.0][cell[(i / w % 2) * 2 + i % w % 2] as usize]).collect();
let (nx, ny) = layout.cells(w, h);
let tile = nx.max(ny);
let mut out = vec![0.0; 4 * tile * tile];
assert!(pack_tile(&mosaic, w, h, layout, 0, 0, tile, &mut out));
let plane = tile * tile;
for (p, want) in [10.0, 20.0, 20.0, 30.0].into_iter().enumerate() {
assert!(out[p * plane..(p + 1) * plane].iter().all(|&v| v == want), "layout {cell:?} plane {p}");
}
}
}
#[test]
fn g1_is_beside_the_red_sample_and_g2_under_it() {
// RGGB with a distinct value at every site
let (w, h) = (4, 4);
let mosaic: Vec<f32> = (0..16).map(|i| i as f32).collect();
let mut out = vec![0.0; 4 * 4];
assert!(pack_tile(&mosaic, w, h, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
// cell (0,0): R = site (0,0)=0, G1 = site (1,0)=1, G2 = site (0,1)=4, B = site (1,1)=5
assert_eq!([out[0], out[4], out[8], out[12]], [0.0, 1.0, 4.0, 5.0]);
// cell (1,0): sites (2,0)=2, (3,0)=3, (2,1)=6, (3,1)=7
assert_eq!([out[1], out[5], out[9], out[13]], [2.0, 3.0, 6.0, 7.0]);
}
#[test]
fn packing_rejects_wrong_sizes() {
let mut out = vec![0.0; 16];
assert!(!pack_tile(&[0.0; 15], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
assert!(!pack_tile(&[0.0; 16], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 3, &mut out));
assert!(!pack_tile(&[], 0, 0, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
}
}

View File

@ -0,0 +1,46 @@
//! SHA-256 of a model file, streamed (a model can be hundreds of megabytes).
use std::io::{self, Read};
use std::path::Path;
use sha2::{Digest, Sha256};
/// Lowercase hex SHA-256 of everything `r` yields.
pub fn sha256_reader<R: Read>(mut r: R) -> io::Result<String> {
let mut h = Sha256::new();
let mut buf = vec![0u8; 1 << 20];
loop {
let n = r.read(&mut buf)?;
if n == 0 {
break;
}
h.update(buf.get(..n).unwrap_or(&[]));
}
Ok(hex(&h.finalize()))
}
/// Lowercase hex SHA-256 of a file.
pub fn sha256_file(path: &Path) -> io::Result<String> {
sha256_reader(io::BufReader::new(std::fs::File::open(path)?))
}
/// Lowercase hex SHA-256 of `bytes`.
pub fn sha256_hex(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_digests() {
assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
assert_eq!(sha256_reader(&b"abc"[..]).unwrap(), sha256_hex(b"abc"));
}
}

View File

@ -0,0 +1,128 @@
//! The denoise models LightCraft knows about: what each one is, who may use it, and where to fetch it.
//!
//! None is part of LightCraft. A model with an address here can be downloaded when the user asks and has accepted
//! its terms; anything else is brought by the user as an `.onnx` file with a `denoise-model.json` beside it.
#[cfg(any(feature = "rawnind-model", test))]
use crate::licence::{Commercial, Licence};
use crate::manifest::DenoiserManifest;
#[cfg(any(feature = "rawnind-model", test))]
use crate::manifest::{Domain, Gain};
/// A known model, and how to get it.
pub struct Known {
pub manifest: DenoiserManifest,
/// What to fetch (an archive or the `.onnx` itself); `None` when the user must bring the file.
pub download: Option<Download>,
/// The `.onnx` inside the downloaded archive (`None`: the download is the `.onnx`).
pub entry: Option<&'static str>,
}
/// RawNIND's UtNet2 for Bayer sensors, as published for darktable (`rawdenoise-nind.dtmodel`, a zip with a Bayer and a
/// linear model). Its weights are GPL-3.0; the training pictures are CC BY 4.0 and CC0.
#[cfg(any(feature = "rawnind-model", test))]
fn rawnind_bayer() -> Known {
let manifest = DenoiserManifest {
id: "rawnind-bayer".into(),
name: "RawNIND UtNet2 (Bayer)".into(),
version: "1.0".into(),
licence: Licence {
name: "GPL-3.0".into(),
commercial: Commercial::Yes,
url: Some("https://www.gnu.org/licenses/gpl-3.0.html".into()),
notice: "The weights are published under the GNU General Public License v3. LightCraft does not include them: they \
are downloaded to your computer at your request and used by it only. If you pass the model file on, the \
GPL applies to that copy. Commercial use is subject to the GPL terms, including applicable source, licence and notice obligations on redistribution."
.into(),
},
source: Some("https://github.com/darktable-org/darktable-ai/tree/master/models/rawdenoise-nind".into()),
sha256: Some("da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901".into()),
size_bytes: Some(31_056_425),
provenance: "Trained on RawNIND, real photographs of the same scenes taken noisy and clean (images CC BY 4.0 and CC0, \
published on Wikimedia Commons and by the University of Louvain). Paper: arXiv 2501.08924."
.into(),
domain: Domain::BayerToRgb,
tile: 512,
overlap: 64,
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
};
let download = Download {
id: manifest.id.clone(),
url: "https://github.com/darktable-org/darktable-ai/releases/download/release-5.6.0/rawdenoise-nind.dtmodel".into(),
file_name: "rawdenoise-nind.dtmodel".into(),
size_bytes: 57_700_134,
sha256: "d71b5f1e727c85a359e6f74dca9e2016c9d8fc3e2f7ac3e9b347d80ceca969af".into(),
};
Known { manifest, download: Some(download), entry: Some("model_bayer.onnx") }
}
/// Every model LightCraft knows.
pub fn all() -> Vec<Known> {
#[cfg(feature = "rawnind-model")]
{
vec![rawnind_bayer()]
}
#[cfg(not(feature = "rawnind-model"))]
{
Vec::new()
}
}
/// The known model with this id.
pub fn find(id: &str) -> Option<Known> {
all().into_iter().find(|k| k.manifest.id == id)
}
/// The known model whose `.onnx` has this SHA-256.
pub fn by_sha256(sha256: &str) -> Option<DenoiserManifest> {
all().into_iter().map(|k| k.manifest).find(|m| m.sha256.as_deref() == Some(sha256))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::validate;
#[test]
fn known_models_are_valid_unique_and_downloaded_over_https() {
let all = all();
assert_eq!(all.is_empty(), !cfg!(feature = "rawnind-model"));
validate(&rawnind_bayer().manifest).unwrap();
for (i, k) in all.iter().enumerate() {
validate(&k.manifest).unwrap();
assert!(all.iter().skip(i + 1).all(|o| o.manifest.id != k.manifest.id), "unique ids");
assert!(!k.manifest.licence.name.is_empty() && !k.manifest.licence.notice.is_empty(), "terms are shown before use");
if let Some(d) = &k.download {
assert_eq!(d.id, k.manifest.id);
assert!(d.url.starts_with("https://") && d.sha256.len() == 64 && d.size_bytes > 0);
// a name that cannot leave the staging folder
assert!(!d.file_name.contains(['/', '\\']) && !d.file_name.starts_with('.'));
}
// a model that may not be used commercially is never offered for download
if k.manifest.licence.commercial == Commercial::No {
assert!(k.download.is_none(), "{}", k.manifest.id);
}
}
assert_eq!(find("rawnind-bayer").is_some(), cfg!(feature = "rawnind-model"));
assert!(find("nothing").is_none());
assert_eq!(
by_sha256("da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901").map(|m| m.id),
if cfg!(feature = "rawnind-model") { Some("rawnind-bayer".into()) } else { None }
);
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Download {
pub id: String,
pub url: String,
/// The name the file is kept under while it waits for the user to accept its terms.
pub file_name: String,
pub size_bytes: u64,
pub sha256: String,
}
pub fn host(url: &str) -> &str {
url.strip_prefix("https://").and_then(|r| r.split('/').next()).unwrap_or("")
}

View File

@ -0,0 +1,16 @@
//! Model metadata, bounded archive/cache formats and Bayer tiling, independent of inference.
//! Used by featureless engine and web builds; no neural-network or matrix backend is linked.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod archive;
pub mod bayer;
pub mod hash;
pub mod known;
pub mod licence;
pub mod manifest;
pub mod product;
pub mod run;
pub mod tiles;
pub use manifest::{DenoiserManifest, Domain, Gain, ManifestError};
pub use run::{Control, Error, Params, TileRunner, denoise_bayer};

View File

@ -0,0 +1,34 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Commercial {
Yes,
/// Research or personal use only. Never bundled or redistributed by LightCraft.
No,
/// The licence or the training data is unclear: the user is told so before installing.
Unknown,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Licence {
/// SPDX id or a short name ("MIT", "Apache-2.0", "InsightFace non-commercial research").
pub name: String,
pub commercial: Commercial,
pub url: Option<String>,
/// Plain-language terms shown before the model is enabled.
pub notice: String,
}
impl Default for Licence {
fn default() -> Self {
Self { name: String::new(), commercial: Commercial::Unknown, url: None, notice: String::new() }
}
}
pub fn valid_id(id: &str) -> bool {
let mut bytes = id.bytes();
let first_ok = bytes.next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
first_ok && id.len() <= 64 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
}

View File

@ -0,0 +1,217 @@
//! A denoise model's manifest: what the model takes and gives, how big its tiles are and who may use it, as
//! plain data.
//!
//! It comes from a `denoise-model.json` next to the `.onnx` file or from the user's `catalog.json`. Both are
//! untrusted: [`validate`] must pass before a manifest is used, and [`parse`] applies it after reading JSON of
//! bounded size. The licence, id and address rules are the face models' (`crate::licence`).
use crate::licence::{Licence, valid_id};
use serde::{Deserialize, Serialize};
/// Largest `denoise-model.json` we read.
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
/// Largest model file we accept (bytes).
pub const MAX_MODEL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
/// What the model takes and what it gives.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Domain {
/// The sensor's Bayer mosaic (black subtracted, white at 1, not white balanced) packed as four planes
/// `[R, G1, G2, B]` of its 2 × 2 cells, forced to RGGB; gives camera RGB at the mosaic's resolution, so
/// the picture is denoised and demosaiced in one step and must not be demosaiced again.
BayerToRgb,
}
/// How the model's output scale is brought back to the input's.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Gain {
/// The output already has the input's scale.
None,
/// The output has a scale of its own, `nominal` times the input's (a million, for one model) and a little
/// different from tile to tile: each tile is scaled so that its channel means equal the input's. A tile too
/// dark to measure, or whose measured scale is further than `maxDeviation` (a fraction, 0.05 = 5 %) from
/// `nominal`, is scaled as if it were within that limit.
MatchMean { nominal: f32, max_deviation: f32 },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DenoiserManifest {
/// Lowercase letters, digits, `.` `_` `-`.
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub licence: Licence,
/// Where the weights come from (an `https://` page or file), for the user's information.
#[serde(default)]
pub source: Option<String>,
/// Lowercase hex SHA-256 of the `.onnx` file, when known.
#[serde(default)]
pub sha256: Option<String>,
#[serde(default)]
pub size_bytes: Option<u64>,
/// What the model was trained on, in plain words; says "undisclosed" when it is.
#[serde(default)]
pub provenance: String,
pub domain: Domain,
/// Side of the square the model runs on, in packed cells (the file's fixed input size).
pub tile: u32,
/// Cells neighbouring tiles share; the picture is blended across them.
pub overlap: u32,
pub gain: Gain,
}
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum ManifestError {
#[error("`{0}` is not valid: {1}")]
Field(&'static str, String),
#[error("not a denoise-model manifest: {0}")]
Parse(String),
#[error("a manifest may be at most {MAX_MANIFEST_BYTES} bytes")]
TooLarge,
}
fn bad<T>(field: &'static str, why: impl Into<String>) -> Result<T, ManifestError> {
Err(ManifestError::Field(field, why.into()))
}
fn text(field: &'static str, s: &str, min: usize, max: usize) -> Result<(), ManifestError> {
let n = s.chars().count();
if n < min || n > max {
return bad(field, format!("must be {min} to {max} characters"));
}
if s.chars().any(|c| c.is_control() && c != '\n') {
return bad(field, "must not contain control characters");
}
Ok(())
}
fn url(field: &'static str, u: &Option<String>) -> Result<(), ManifestError> {
match u {
None => Ok(()),
Some(u) if u.len() <= 500 && u.starts_with("https://") && !u.chars().any(|c| c.is_control() || c.is_whitespace()) => Ok(()),
Some(_) => bad(field, "must be an https:// address of at most 500 characters"),
}
}
/// Check a manifest from outside before trusting it.
pub fn validate(m: &DenoiserManifest) -> Result<(), ManifestError> {
if !valid_id(&m.id) {
return bad("id", "1 to 64 lowercase letters, digits, `.`, `_` or `-`, starting with a letter or digit");
}
text("name", &m.name, 1, 80)?;
text("version", &m.version, 1, 40)?;
text("provenance", &m.provenance, 0, 1000)?;
text("licence.name", &m.licence.name, 0, 80)?;
text("licence.notice", &m.licence.notice, 0, 2000)?;
url("licence.url", &m.licence.url)?;
url("source", &m.source)?;
if let Some(h) = &m.sha256
&& !(h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
{
return bad("sha256", "must be 64 lowercase hex digits");
}
if let Some(n) = m.size_bytes
&& (n == 0 || n > MAX_MODEL_BYTES)
{
return bad("sizeBytes", format!("must be between 1 and {MAX_MODEL_BYTES}"));
}
// a U-Net with four poolings wants a multiple of 16; nothing smaller than 64 or larger than 2048 is a tile
if !(64..=2048).contains(&m.tile) || !m.tile.is_multiple_of(16) {
return bad("tile", "must be a multiple of 16 from 64 to 2048");
}
// tiles must overlap by less than half, or one tile's blend ramps would run into its other neighbour's
if !m.overlap.is_multiple_of(2) || m.overlap < 16 || m.overlap.saturating_mul(2) >= m.tile {
return bad("overlap", "must be even, at least 16 and less than half the tile");
}
if let Gain::MatchMean { nominal, max_deviation } = m.gain {
if !(nominal.is_finite() && (1e-6..=1e12).contains(&nominal)) {
return bad("gain.nominal", "must be between 0.000001 and 1000000000000");
}
if !(max_deviation.is_finite() && (0.0..=0.5).contains(&max_deviation)) {
return bad("gain.maxDeviation", "must be between 0 and 0.5");
}
}
Ok(())
}
/// Read and validate a `denoise-model.json`.
pub fn parse(bytes: &[u8]) -> Result<DenoiserManifest, ManifestError> {
if bytes.len() > MAX_MANIFEST_BYTES {
return Err(ManifestError::TooLarge);
}
let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
let m: DenoiserManifest = serde_json::from_slice(bytes).map_err(|e| ManifestError::Parse(e.to_string()))?;
validate(&m)?;
Ok(m)
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) fn sample() -> DenoiserManifest {
DenoiserManifest {
id: "nind-bayer".into(),
name: "Bayer denoiser".into(),
version: "1".into(),
licence: Licence::default(),
source: Some("https://example.org/model.onnx".into()),
sha256: None,
size_bytes: Some(31_000_000),
provenance: "trained on real raw noise pairs".into(),
domain: Domain::BayerToRgb,
tile: 512,
overlap: 64,
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
}
}
#[test]
fn a_good_manifest_round_trips() {
let m = sample();
let json = serde_json::to_vec(&m).unwrap();
assert_eq!(parse(&json).unwrap(), m);
}
#[test]
fn bad_fields_are_named() {
let cases: Vec<(&str, Box<dyn Fn(&mut DenoiserManifest)>)> = vec![
("id", Box::new(|m| m.id = "../evil".into())),
("id", Box::new(|m| m.id = String::new())),
("name", Box::new(|m| m.name = "x\u{7}".into())),
("source", Box::new(|m| m.source = Some("http://insecure".into()))),
("sha256", Box::new(|m| m.sha256 = Some("XYZ".into()))),
("sizeBytes", Box::new(|m| m.size_bytes = Some(0))),
("tile", Box::new(|m| m.tile = 500)),
("tile", Box::new(|m| m.tile = 4096)),
("overlap", Box::new(|m| m.overlap = 300)),
("overlap", Box::new(|m| m.overlap = 63)),
("gain.maxDeviation", Box::new(|m| m.gain = Gain::MatchMean { nominal: 1.0, max_deviation: f32::NAN })),
("gain.maxDeviation", Box::new(|m| m.gain = Gain::MatchMean { nominal: 1.0, max_deviation: 2.0 })),
("gain.nominal", Box::new(|m| m.gain = Gain::MatchMean { nominal: 0.0, max_deviation: 0.1 })),
("gain.nominal", Box::new(|m| m.gain = Gain::MatchMean { nominal: f32::INFINITY, max_deviation: 0.1 })),
];
for (field, edit) in cases {
let mut m = sample();
edit(&mut m);
match validate(&m) {
Err(ManifestError::Field(f, _)) => assert_eq!(f, field),
other => panic!("{field}: {other:?}"),
}
}
}
#[test]
fn parse_is_bounded_and_tolerant() {
assert_eq!(parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]), Err(ManifestError::TooLarge));
assert!(matches!(parse(b"{ nope"), Err(ManifestError::Parse(_))));
assert!(matches!(parse(b""), Err(ManifestError::Parse(_))));
let mut with_bom = vec![0xEF, 0xBB, 0xBF];
with_bom.extend(serde_json::to_vec(&sample()).unwrap());
assert!(parse(&with_bom).is_ok());
}
}

View File

@ -0,0 +1,616 @@
//! The denoised picture as cached data, and the blend the Amount slider controls.
//!
//! A product is camera RGB at the sensor's resolution (what the model gave, before the raw's own opcodes and
//! crop), kept as half floats in strips of rows, each strip byte-shuffled and deflated with its own checksum.
//! It is derived data: any problem reading it (missing, truncated, corrupt, made for another photo or model) is
//! just a cache miss, never a panic, and the picture can be made again.
//!
//! File: `LCDN`, version, header length, header JSON (`width`, `height`, `strip`, `key`), then for each strip its
//! compressed length and CRC-32, then the strips' bytes. `key` says what the product was made from (the photo's
//! content, the model, this code's version, the settings it depends on); a file with another key is stale.
use std::io::{Read, Write};
use std::path::Path;
use lightcraft_raster::Rgb32f;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
const MAGIC: &[u8; 4] = b"LCDN";
const VERSION: u8 = 1;
const MAX_HEADER: usize = 4096;
const MAX_SIDE: usize = 1 << 17;
/// Most pixels a product may have (the denoiser's own limit, [`crate::run::MAX_PIXELS`]).
const MAX_PIXELS: usize = crate::run::MAX_PIXELS;
/// Rows per strip: about a megapixel of three planes at 24 MP widths.
const STRIP_ROWS: usize = 128;
/// Bumped when the meaning of a product changes (included in keys by the caller).
pub const ALGORITHM: u32 = 1;
#[derive(Debug, thiserror::Error)]
pub enum ProductError {
#[error("the denoised picture could not be read or written: {0}")]
Io(String),
#[error("not a usable denoised picture: {0}")]
Format(String),
/// A product made from something else (another photo state, model or version).
#[error("the denoised picture is out of date")]
Stale,
}
impl From<std::io::Error> for ProductError {
fn from(e: std::io::Error) -> Self {
ProductError::Io(e.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Header {
pub width: usize,
pub height: usize,
pub strip: usize,
pub key: String,
}
fn allocate<T: Clone>(n: usize, value: T) -> Result<Vec<T>, ProductError> {
let mut v = Vec::new();
v.try_reserve_exact(n).map_err(|_| ProductError::Format("not enough memory for the denoised picture".into()))?;
v.resize(n, value);
Ok(v)
}
/// `f32` as IEEE half-float bits, rounded to nearest even; beyond the range it saturates, NaN becomes 0.
pub fn to_f16(v: f32) -> u16 {
if !v.is_finite() {
return if v.is_nan() {
0
} else if v > 0.0 {
0x7bff
} else {
0xfbff
};
}
let b = v.to_bits();
let sign = ((b >> 16) & 0x8000) as u16;
let exp = ((b >> 23) & 0xff) as i32 - 127 + 15;
let man = b & 0x7f_ffff;
if exp >= 31 {
return sign | 0x7bff;
}
if exp <= 0 {
if exp < -10 {
return sign;
}
let man = man | 0x80_0000;
let shift = (14 - exp) as u32;
let (half, rem, halfway) = (man >> shift, man & ((1u32 << shift) - 1), 1u32 << (shift - 1));
let mut h = half as u16;
if rem > halfway || (rem == halfway && h & 1 == 1) {
h += 1;
}
return sign | h;
}
let mut h = (((exp as u32) << 10) | (man >> 13)) as u16;
let rem = man & 0x1fff;
if rem > 0x1000 || (rem == 0x1000 && h & 1 == 1) {
h += 1;
}
if h >= 0x7c00 {
h = 0x7bff;
}
sign | h
}
/// Half-float bits as `f32`.
pub fn from_f16(h: u16) -> f32 {
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
let exp = i32::from((h >> 10) & 0x1f);
let man = f32::from(h & 0x3ff);
match exp {
0 => sign * man * 2f32.powi(-24),
31 => sign * 65504.0,
_ => sign * (1.0 + man / 1024.0) * 2f32.powi(exp - 15),
}
}
fn crc(bytes: &[u8]) -> u32 {
// CRC-32 (IEEE), bitwise: strips are small and this runs once per strip
let mut table = [0u32; 256];
for (i, t) in table.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
}
*t = c;
}
!bytes.iter().fold(!0u32, |c, &b| table[((c ^ u32::from(b)) & 0xff) as usize] ^ (c >> 8))
}
/// One strip as bytes: for each plane (R, G, B) the low bytes of its half floats, then the high bytes.
fn encode_strip(img: &Rgb32f, y0: usize, y1: usize) -> Result<Vec<u8>, ProductError> {
let n = (y1 - y0) * img.width;
let mut raw = allocate(6 * n, 0u8)?;
let rows = img.data.get(y0 * img.width..y1 * img.width).unwrap_or(&[]);
for (i, px) in rows.iter().enumerate() {
for (c, &v) in px.iter().enumerate() {
let h = to_f16(v);
if let Some(lo) = raw.get_mut(2 * c * n + i) {
*lo = h as u8;
}
if let Some(hi) = raw.get_mut((2 * c + 1) * n + i) {
*hi = (h >> 8) as u8;
}
}
}
Ok(miniz_oxide::deflate::compress_to_vec(&raw, 3))
}
/// Write `img` to `path` (through a temporary file next to it, so a reader never sees half a product).
pub fn write(path: &Path, img: &Rgb32f, key: &str) -> Result<(), ProductError> {
let pixels = img.width.checked_mul(img.height).filter(|&n| n <= MAX_PIXELS);
if img.width == 0 || img.height == 0 || img.width > MAX_SIDE || img.height > MAX_SIDE || pixels != Some(img.data.len()) {
return Err(ProductError::Format("the picture has no usable size".into()));
}
let strips: Vec<(usize, usize)> = (0..img.height).step_by(STRIP_ROWS).map(|y| (y, (y + STRIP_ROWS).min(img.height))).collect();
let packed: Vec<Vec<u8>> = strips.par_iter().map(|&(a, b)| encode_strip(img, a, b)).collect::<Result<_, _>>()?;
let header = serde_json::to_vec(&Header { width: img.width, height: img.height, strip: STRIP_ROWS, key: key.to_string() })
.map_err(|e| ProductError::Format(e.to_string()))?;
let tmp = path.with_extension("tmp");
let result = (|| -> Result<(), ProductError> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut f = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
f.write_all(MAGIC)?;
f.write_all(&[VERSION, 0, 0, 0])?;
f.write_all(&(header.len() as u32).to_le_bytes())?;
f.write_all(&header)?;
for p in &packed {
f.write_all(&(p.len() as u32).to_le_bytes())?;
f.write_all(&crc(p).to_le_bytes())?;
}
for p in &packed {
f.write_all(p)?;
}
f.flush()?;
drop(f);
std::fs::rename(&tmp, path)?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
/// The parsed start of a product file: header, each strip's (compressed length, CRC-32), and the file length the
/// start says the whole file has.
struct Start {
header: Header,
table: Vec<(usize, u32)>,
expected_len: u64,
}
fn read_start(r: &mut impl Read) -> Result<Start, ProductError> {
let mut fixed = [0u8; 12];
r.read_exact(&mut fixed)?;
if fixed.get(0..4) != Some(&MAGIC[..]) || fixed.get(4) != Some(&VERSION) {
return Err(ProductError::Format("not a denoised picture of this version".into()));
}
let len = fixed.get(8..12).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes).unwrap_or(u32::MAX) as usize;
if len > MAX_HEADER {
return Err(ProductError::Format("the header is too large".into()));
}
let mut hb = allocate(len, 0u8)?;
r.read_exact(&mut hb)?;
let h: Header = serde_json::from_slice(&hb).map_err(|e| ProductError::Format(e.to_string()))?;
let pixels = h.width.saturating_mul(h.height);
if h.width == 0 || h.height == 0 || h.width > MAX_SIDE || h.height > MAX_SIDE || pixels > MAX_PIXELS || h.strip != STRIP_ROWS {
return Err(ProductError::Format("the size is not usable".into()));
}
let n = h.height.div_ceil(h.strip);
let mut table = Vec::with_capacity(n);
let mut total = 12 + len as u64 + 8 * n as u64;
for _ in 0..n {
let mut e = [0u8; 8];
r.read_exact(&mut e)?;
let l = u32::from_le_bytes([e[0], e[1], e[2], e[3]]) as usize;
let c = u32::from_le_bytes([e[4], e[5], e[6], e[7]]);
// a strip never compresses to much more than its raw size
if l > 12 * h.strip * h.width + 4096 {
return Err(ProductError::Format("a strip is larger than it can be".into()));
}
total += l as u64;
table.push((l, c));
}
Ok(Start { header: h, table, expected_len: total })
}
/// The header of the product at `path`, when it is one.
pub fn read_header_at(path: &Path) -> Result<Header, ProductError> {
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
Ok(read_start(&mut f)?.header)
}
/// Whether `path` holds a complete-looking product made from `key` (the header and the file's length are checked;
/// strips are checked when read).
pub fn is_current(path: &Path, key: &str) -> bool {
let check = || -> Result<bool, ProductError> {
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
let s = read_start(&mut f)?;
Ok(s.header.key == key && std::fs::metadata(path)?.len() == s.expected_len)
};
check().unwrap_or(false)
}
/// A rectangle of a product, in the product's pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Window {
pub x: usize,
pub y: usize,
pub width: usize,
pub height: usize,
}
/// Half floats as `f32`, for all 65536 bit patterns (a table is much faster than computing each).
fn f16_table() -> &'static [f32] {
static TABLE: std::sync::OnceLock<Vec<f32>> = std::sync::OnceLock::new();
TABLE.get_or_init(|| (0..=u16::MAX).map(from_f16).collect())
}
/// Strips decoded together (they are inflated in parallel, then folded in order).
const GROUP: usize = 8;
/// One strip, checked and inflated: for each plane the low bytes of its half floats, then the high bytes.
fn decode_strip(index: usize, packed: &[u8], expect_crc: u32, n: usize) -> Result<Vec<u8>, ProductError> {
if crc(packed) != expect_crc {
return Err(ProductError::Format(format!("strip {index} is damaged")));
}
let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(packed, 6 * n).map_err(|e| ProductError::Format(format!("strip {index}: {e:?}")))?;
if raw.len() != 6 * n {
return Err(ProductError::Format(format!("strip {index} has the wrong size")));
}
Ok(raw)
}
/// Read the product at `path`, made from `key`, with every `factor × factor` block of pixels averaged into one
/// (`factor` 1 = full size). The result is camera RGB.
///
/// With a `window`, only that rectangle is read and the blocks start at its corner; only whole blocks are made, so
/// the result is `window.width / factor × window.height / factor` (the same blocks a binned development of the same
/// crop makes). Without one the whole product is read and blocks at the right and bottom edges may be smaller.
///
/// With `clip` (white is 1), a block in which any sample of a colour is at or above it takes that colour's
/// maximum instead of the mean, so a clipped highlight stays clipped for highlight reconstruction.
pub fn read_window(path: &Path, key: &str, window: Option<Window>, factor: usize, clip: Option<f32>) -> Result<Rgb32f, ProductError> {
let factor = factor.clamp(1, 1 << 12);
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
let Start { header: h, table, expected_len } = read_start(&mut f)?;
if h.key != key {
return Err(ProductError::Stale);
}
if std::fs::metadata(path)?.len() != expected_len {
return Err(ProductError::Format("the file is not the length its header says".into()));
}
let w = window.unwrap_or(Window { x: 0, y: 0, width: h.width, height: h.height });
let fits = |a: usize, b: usize, limit: usize| a.checked_add(b).is_some_and(|e| e <= limit);
if w.width == 0 || w.height == 0 || !fits(w.x, w.width, h.width) || !fits(w.y, w.height, h.height) {
return Err(ProductError::Format("the window does not lie inside the picture".into()));
}
// whole blocks for a window; partial ones at the edges when the whole picture is read
let whole = window.is_none();
let (ow, oh) = if whole { (w.width.div_ceil(factor), w.height.div_ceil(factor)) } else { (w.width / factor, w.height / factor) };
if ow == 0 || oh == 0 {
return Err(ProductError::Format("the window is smaller than one block".into()));
}
// the picture rows and columns the blocks cover
let (x_end, y_end) = if whole { (w.x + w.width, w.y + w.height) } else { (w.x + ow * factor, w.y + oh * factor) };
let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? };
let lut = f16_table();
let value = |raw: &[u8], c: usize, n: usize, i: usize| -> f32 {
let (lo, hi) = (raw.get(2 * c * n + i).copied().unwrap_or(0), raw.get((2 * c + 1) * n + i).copied().unwrap_or(0));
lut.get(usize::from(lo) | usize::from(hi) << 8).copied().unwrap_or(0.0)
};
// which strips are needed, and where each starts in the file (after the header and the strip table)
let wanted = |s: usize| s * h.strip < y_end && (s + 1) * h.strip > w.y;
let (mut sum, mut peak) = (allocate(ow, [0f32; 3])?, allocate(ow, [f32::MIN; 3])?);
let (mut rows_in, mut oy) = (0usize, 0usize);
let strips: Vec<usize> = (0..table.len()).filter(|&s| wanted(s)).collect();
// skip the strips before the first one wanted
let skipped: usize = table.iter().take(strips.first().copied().unwrap_or(0)).map(|e| e.0).sum();
f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?;
for group in strips.chunks(GROUP) {
let mut packed = Vec::with_capacity(group.len());
for &s in group {
let Some(&(len, _)) = table.get(s) else { return Err(ProductError::Format("a strip is missing".into())) };
let mut p = allocate(len, 0u8)?;
f.read_exact(&mut p)?;
packed.push(p);
}
let decoded: Vec<Result<Vec<u8>, ProductError>> = group
.par_iter()
.zip(packed.par_iter())
.map(|(&s, p)| {
let rows = h.height.saturating_sub(s * h.strip).min(h.strip);
decode_strip(s, p, table.get(s).map_or(0, |e| e.1), rows * h.width)
})
.collect();
for (&s, raw) in group.iter().zip(decoded) {
let raw = raw?;
let y0 = s * h.strip;
let rows = h.height.saturating_sub(y0).min(h.strip);
let n = rows * h.width;
for r in 0..rows {
let y = y0 + r;
if y < w.y || y >= y_end {
continue;
}
let base = r * h.width;
for (ox, (sm, pk)) in sum.iter_mut().zip(peak.iter_mut()).enumerate() {
let x0 = w.x + ox * factor;
let x1 = (x0 + factor).min(x_end);
for x in x0..x1 {
for c in 0..3 {
let v = value(&raw, c, n, base + x);
if let (Some(s_), Some(p_)) = (sm.get_mut(c), pk.get_mut(c)) {
*s_ += v;
*p_ = p_.max(v);
}
}
}
}
rows_in += 1;
if rows_in == factor || y + 1 == y_end {
for (ox, (sm, pk)) in sum.iter_mut().zip(peak.iter_mut()).enumerate() {
// the block's size at the right and bottom edges may be smaller
let bw = (x_end - (w.x + ox * factor)).min(factor);
let k = 1.0 / (rows_in * bw).max(1) as f32;
if let Some(o) = out.data.get_mut(oy * ow + ox) {
for c in 0..3 {
let (s_, p_) = (sm.get(c).copied().unwrap_or(0.0), pk.get(c).copied().unwrap_or(0.0));
if let Some(slot) = o.get_mut(c) {
*slot = if factor > 1 && clip.is_some_and(|t| p_ >= t) { p_ } else { s_ * k };
}
}
}
*sm = [0.0; 3];
*pk = [f32::MIN; 3];
}
rows_in = 0;
oy += 1;
}
}
}
}
Ok(out)
}
/// [`read_window`] of the whole product: every `factor × factor` block averaged into one.
pub fn read(path: &Path, key: &str, factor: usize) -> Result<Rgb32f, ProductError> {
read_window(path, key, None, factor, None)
}
/// `base + (denoised − base) · amount` for every pixel (`amount` 0 to 1), in place in `base`. The two pictures must
/// be the same size; if they are not, `base` is left as it was and `false` is returned.
pub fn blend(base: &mut Rgb32f, denoised: &Rgb32f, amount: f32) -> bool {
if base.width != denoised.width || base.height != denoised.height {
return false;
}
let a = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
base.data.par_iter_mut().zip(denoised.data.par_iter()).for_each(|(b, d)| {
for c in 0..3 {
b[c] += (d[c] - b[c]) * a;
}
});
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oversized_product_header_is_rejected_before_allocating_pixels() {
let p = temp("oversized-header");
let header = serde_json::to_vec(&Header { width: 10001, height: 10000, strip: STRIP_ROWS, key: "k".into() }).unwrap();
let mut bytes = Vec::from(MAGIC.as_slice());
bytes.extend_from_slice(&[VERSION, 0, 0, 0]);
bytes.extend_from_slice(&(header.len() as u32).to_le_bytes());
bytes.extend_from_slice(&header);
std::fs::write(&p, bytes).unwrap();
assert!(read_header_at(&p).is_err());
let _ = std::fs::remove_file(p);
}
fn temp(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
}
fn picture(w: usize, h: usize) -> Rgb32f {
Rgb32f {
width: w,
height: h,
data: (0..w * h).map(|i| [(i % w) as f32 / w as f32, (i / w) as f32 / h as f32, 0.001 + 0.5 * ((i % 7) as f32 / 7.0)]).collect(),
}
}
#[test]
fn half_floats_round_trip_closely() {
for v in [0.0f32, 1.0, -1.0, 0.5, 0.333_333, 1e-4, 6.1e-5, 3e-6, 123.456, 65000.0, -0.002] {
let back = from_f16(to_f16(v));
assert!((back - v).abs() <= v.abs() * 0.001 + 1e-7, "{v} -> {back}");
}
assert_eq!(from_f16(to_f16(1e10)), 65504.0);
assert_eq!(to_f16(f32::NAN), 0);
assert_eq!(from_f16(to_f16(f32::NEG_INFINITY)), -65504.0);
}
#[test]
fn a_product_survives_a_round_trip_at_full_and_reduced_size() {
let (w, h) = (301, 517);
let img = picture(w, h);
let p = temp("round");
write(&p, &img, "k1").unwrap();
assert!(is_current(&p, "k1") && !is_current(&p, "k2"));
assert_eq!(read_header_at(&p).unwrap().width, w);
let full = read(&p, "k1", 1).unwrap();
assert_eq!((full.width, full.height), (w, h));
for (a, b) in full.data.iter().zip(&img.data) {
for c in 0..3 {
assert!((a[c] - b[c]).abs() < 0.002, "{a:?} vs {b:?}");
}
}
// averaged into blocks of 4: the sizes round up, and the mean of the picture is kept
let small = read(&p, "k1", 4).unwrap();
assert_eq!((small.width, small.height), (w.div_ceil(4), h.div_ceil(4)));
let mean = |i: &Rgb32f| i.data.iter().map(|p| f64::from(p[1])).sum::<f64>() / i.data.len() as f64;
assert!((mean(&small) - mean(&img)).abs() < 0.01);
// a factor that does not divide the strip height is the same average
let odd = read(&p, "k1", 5).unwrap();
assert_eq!((odd.width, odd.height), (w.div_ceil(5), h.div_ceil(5)));
assert!((mean(&odd) - mean(&img)).abs() < 0.01);
assert!(matches!(read(&p, "other", 1), Err(ProductError::Stale)));
std::fs::remove_file(&p).unwrap();
}
/// What binning a window of `img` by `k` means, written the slow obvious way.
fn blocks(img: &Rgb32f, win: Window, k: usize, clip: Option<f32>) -> Rgb32f {
let (ow, oh) = (win.width / k, win.height / k);
let mut out = Rgb32f { width: ow, height: oh, data: vec![[0.0; 3]; ow * oh] };
for by in 0..oh {
for bx in 0..ow {
for c in 0..3 {
let vals: Vec<f32> = (0..k * k).map(|i| img.data[(win.y + by * k + i / k) * img.width + win.x + bx * k + i % k][c]).collect();
let max = vals.iter().copied().fold(f32::MIN, f32::max);
out.data[by * ow + bx][c] =
if k > 1 && clip.is_some_and(|t| max >= t) { max } else { vals.iter().sum::<f32>() / vals.len() as f32 };
}
}
}
out
}
#[test]
fn a_window_is_read_as_the_blocks_of_that_crop() {
let (w, h) = (301, 517);
let mut img = picture(w, h);
// a clipped sample in the middle of a block
img.data[(130 + 2) * w + 61 + 1] = [1.0, 0.4, 0.4];
let p = temp("window");
write(&p, &img, "k1").unwrap();
let near = |a: &Rgb32f, b: &Rgb32f| {
assert_eq!((a.width, a.height), (b.width, b.height));
for (x, y) in a.data.iter().zip(&b.data) {
for c in 0..3 {
assert!((x[c] - y[c]).abs() < 0.003, "{x:?} vs {y:?}");
}
}
};
// windows at odd places, across strip boundaries (128 rows), with factors that do and do not divide them
for (win, k) in [
(Window { x: 3, y: 5, width: 200, height: 300 }, 1),
(Window { x: 3, y: 5, width: 200, height: 300 }, 4),
(Window { x: 61, y: 130, width: 121, height: 250 }, 5),
(Window { x: 0, y: 0, width: w, height: h }, 2),
(Window { x: 1, y: 380, width: 90, height: 137 }, 3),
] {
let got = read_window(&p, "k1", Some(win), k, None).unwrap();
near(&got, &blocks(&img, win, k, None));
}
// with a clip level the block with the clipped sample keeps its highest value, the others stay means
let win = Window { x: 61, y: 130, width: 40, height: 20 };
let clipped = read_window(&p, "k1", Some(win), 4, Some(0.99)).unwrap();
near(&clipped, &blocks(&img, win, 4, Some(0.99)));
assert!(clipped.data[0][0] > 0.99, "the clipped block stays clipped: {:?}", clipped.data[0]);
let plain = read_window(&p, "k1", Some(win), 4, None).unwrap();
assert!(plain.data[0][0] < 0.99, "an average falls below the clip level");
// windows that do not fit, or are smaller than a block, are errors
for bad in [
Window { x: 0, y: 0, width: 0, height: 5 },
Window { x: 290, y: 0, width: 20, height: 5 },
Window { x: 0, y: 510, width: 5, height: 10 },
Window { x: usize::MAX, y: 0, width: 2, height: 2 },
Window { x: 0, y: 0, width: 3, height: 3 },
] {
assert!(read_window(&p, "k1", Some(bad), 4, None).is_err(), "{bad:?}");
}
std::fs::remove_file(&p).unwrap();
}
#[test]
fn a_smooth_picture_compresses_well() {
let img = Rgb32f {
width: 512,
height: 512,
data: (0..512 * 512)
.map(|i| {
let v = 0.2 + 0.0001 * (i % 512) as f32;
[v, v * 0.8, v * 0.6]
})
.collect(),
};
let p = temp("size");
write(&p, &img, "k").unwrap();
let bytes = std::fs::metadata(&p).unwrap().len() as usize;
assert!(bytes < 512 * 512 * 6 / 2, "{bytes} bytes for a smooth picture");
std::fs::remove_file(&p).unwrap();
}
#[test]
fn damaged_files_are_errors_not_panics() {
let img = picture(100, 300);
let p = temp("damage");
write(&p, &img, "k").unwrap();
let good = std::fs::read(&p).unwrap();
// truncated at every length
for cut in (0..good.len()).step_by(37) {
std::fs::write(&p, &good[..cut]).unwrap();
assert!(read(&p, "k", 1).is_err(), "cut at {cut}");
assert!(!is_current(&p, "k"));
}
// a flipped byte anywhere is caught by a checksum, the header parse, or the key
for at in (0..good.len()).step_by(53) {
let mut bad = good.clone();
bad[at] ^= 0xff;
std::fs::write(&p, &bad).unwrap();
let _ = read(&p, "k", 1); // must not panic; most are errors
}
// a header that claims an absurd size
let mut huge = b"LCDN\x01\0\0\0".to_vec();
let hdr = br#"{"width":4000000000,"height":4000000000,"strip":128,"key":"k"}"#;
huge.extend((hdr.len() as u32).to_le_bytes());
huge.extend(hdr);
std::fs::write(&p, &huge).unwrap();
assert!(matches!(read(&p, "k", 1), Err(ProductError::Format(_))));
std::fs::write(&p, b"").unwrap();
assert!(read(&p, "k", 1).is_err());
assert!(read(&temp("missing"), "k", 1).is_err());
std::fs::remove_file(&p).unwrap();
}
#[test]
fn writing_rejects_unusable_pictures() {
let p = temp("bad");
assert!(write(&p, &Rgb32f { width: 0, height: 0, data: vec![] }, "k").is_err());
assert!(write(&p, &Rgb32f { width: 3, height: 3, data: vec![[0.0; 3]; 8] }, "k").is_err());
assert!(!p.exists());
}
#[test]
fn blend_mixes_by_amount_and_rejects_other_sizes() {
let mut base = Rgb32f { width: 2, height: 1, data: vec![[0.0; 3], [1.0; 3]] };
let dn = Rgb32f { width: 2, height: 1, data: vec![[1.0; 3], [0.0; 3]] };
assert!(blend(&mut base, &dn, 0.25));
assert_eq!(base.data, vec![[0.25; 3], [0.75; 3]]);
assert!(blend(&mut base, &dn, f32::NAN));
assert_eq!(base.data, vec![[0.25; 3], [0.75; 3]]);
assert!(!blend(&mut base, &Rgb32f { width: 1, height: 1, data: vec![[0.0; 3]] }, 1.0));
assert!(blend(&mut base, &dn, 1.0));
assert_eq!(base.data, dn.data);
}
#[test]
fn crc_matches_the_standard_check_value() {
assert_eq!(crc(b"123456789"), 0xcbf4_3926);
}
}

View File

@ -0,0 +1,589 @@
//! Denoising a whole Bayer mosaic with a tile model: cut it into overlapping tiles, run the model on each, bring
//! each tile's scale back to the input's, keep clipped highlights as they were, and blend the tiles together.
//!
//! The model itself is a [`TileRunner`], so everything around it (packing, tiling, scale matching, highlight
//! protection, blending, cancelling, progress) is tested without one.
use std::sync::atomic::{AtomicBool, Ordering};
use lightcraft_raster::Rgb32f;
use rayon::prelude::*;
use crate::bayer::{Layout, pack_tile};
use crate::manifest::Gain;
use crate::tiles::{starts, weight};
/// Most pixels one picture may have (guards the allocations that follow from its size).
pub const MAX_PIXELS: usize = 100_000_000;
/// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from.
const MIN_SIGNAL: f32 = 0.003;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{0}")]
Input(String),
#[error("the model gave {0}")]
Model(String),
#[error("the model could not run: {0}")]
Runtime(String),
#[error("the model could not be loaded: {0}")]
Load(String),
#[error("cancelled")]
Cancelled,
}
fn allocate<T: Clone>(n: usize, value: T) -> Result<Vec<T>, Error> {
let mut v = Vec::new();
v.try_reserve_exact(n).map_err(|_| Error::Input("not enough memory for denoise".into()))?;
v.resize(n, value);
Ok(v)
}
/// Something that runs the model on one tile.
pub trait TileRunner: Sync {
/// `input` is the tile as four planes `[R, G1, G2, B]` of `tile × tile` cells (NCHW, batch 1); the answer is
/// camera RGB, three planes of `2·tile × 2·tile` pixels (CHW), in the model's own scale.
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error>;
}
pub struct Params {
/// Side of the model's square, in cells.
pub tile: usize,
/// Cells neighbouring tiles share.
pub overlap: usize,
pub gain: Gain,
/// White is 1; samples at or above this (minus a little) are clipped and keep their value.
pub clip: Option<f32>,
/// Tiles run at once (each model call may itself be single-threaded).
pub parallel: usize,
}
#[derive(Default)]
pub struct Control<'a> {
pub cancel: Option<&'a AtomicBool>,
/// `(tiles done, tiles in all)`.
pub progress: Option<&'a (dyn Fn(usize, usize) + Sync)>,
}
/// The tile grid of a picture: `(starts across, starts down)`.
pub fn grid(width: usize, height: usize, layout: Layout, tile: usize, overlap: usize) -> (Vec<usize>, Vec<usize>) {
let (nx, ny) = layout.cells(width, height);
(starts(nx, tile, overlap), starts(ny, tile, overlap))
}
/// How many model calls [`denoise_bayer`] makes for a picture.
pub fn tile_count(width: usize, height: usize, layout: Layout, tile: usize, overlap: usize) -> usize {
let (sx, sy) = grid(width, height, layout, tile, overlap);
sx.len().saturating_mul(sy.len())
}
/// Denoise (and demosaic) the normalised mosaic `mosaic` of `width × height` samples, laid out as `layout`.
/// Gives camera RGB at the mosaic's resolution.
pub fn denoise_bayer(
mosaic: &[f32],
width: usize,
height: usize,
layout: Layout,
runner: &dyn TileRunner,
p: &Params,
ctl: &Control<'_>,
) -> Result<Rgb32f, Error> {
let pixels = width
.checked_mul(height)
.filter(|&n| n > 0 && n <= MAX_PIXELS)
.ok_or_else(|| Error::Input("the picture has no size, or is too large".into()))?;
if mosaic.len() != pixels {
return Err(Error::Input("the mosaic does not have width × height samples".into()));
}
if !(16..=2048).contains(&p.tile) || p.overlap >= p.tile.div_ceil(2) || layout.dx > 1 || layout.dy > 1 {
return Err(Error::Input("the tile size and overlap do not fit".into()));
}
if let Gain::MatchMean { nominal, max_deviation } = p.gain
&& !(nominal.is_finite() && (1e-6..=1e12).contains(&nominal) && max_deviation.is_finite() && (0.0..=0.5).contains(&max_deviation))
{
return Err(Error::Input("the model gain is not finite or within its supported range".into()));
}
if p.clip.is_some_and(|v| !v.is_finite() || v <= 0.0) {
return Err(Error::Input("the highlight clip must be finite and positive".into()));
}
let (nx, ny) = layout.cells(width, height);
let (sx, sy) = (starts(nx, p.tile, p.overlap), starts(ny, p.tile, p.overlap));
let (cols, rows) = (sx.len(), sy.len());
if cols == 0 || rows == 0 {
return Err(Error::Input("the picture needs too many tiles".into()));
}
let tiles: Vec<(usize, usize, bool, bool, bool, bool)> = sy
.iter()
.enumerate()
.flat_map(|(j, &y0)| sx.iter().enumerate().map(move |(i, &x0)| (x0, y0, i > 0, i + 1 < cols, j > 0, j + 1 < rows)))
.collect();
let total = tiles.len();
let mut acc = allocate(pixels, [0f32; 3])?;
let mut wsum = allocate(nx * ny, 0f32)?;
let group = p.parallel.clamp(1, 4);
let mut done = 0;
// where the time goes, printed under `LIGHTCRAFT_PROFILE`
let (mut t_wait, mut t_total) = (std::time::Duration::ZERO, std::time::Duration::ZERO);
let began = web_time::Instant::now();
// One group of tiles at a time: pack, run the model, check and bring each to the input's scale, in parallel.
// Blending (sequential: the tiles add into one picture, in tile order so the result does not depend on timing) of
// the previous group runs while the next group is on the model, so neither the card nor the cores wait for it.
type Tile = (usize, usize, bool, bool, bool, bool);
let compute = |chunk: &[Tile]| -> Vec<Result<Vec<f32>, Error>> {
chunk
.par_iter()
.map(|&(x0, y0, bx, ax, by, ay)| {
let mut input = allocate(4 * p.tile * p.tile, 0f32)?;
if !pack_tile(mosaic, width, height, layout, x0 as isize, y0 as isize, p.tile, &mut input) {
return Err(Error::Input("a tile could not be packed".into()));
}
let mut out = runner.run(&input)?;
let side = 2 * p.tile;
if out.len() != 3 * side * side {
return Err(Error::Model(format!("{} numbers for a tile, not the {} expected", out.len(), 3 * side * side)));
}
if out.iter().any(|v| !v.is_finite()) {
return Err(Error::Model("numbers that are not finite".into()));
}
finish_tile(&input, &mut out, p, (bx, ax, by, ay));
Ok(out)
})
.collect()
};
let mut blend = |chunk: &[Tile], outs: Vec<Result<Vec<f32>, Error>>| -> Result<(), Error> {
for (&(x0, y0, bx, ax, by, ay), r) in chunk.iter().zip(outs) {
blend_tile(&r?, &mut acc, &mut wsum, (width, height), (nx, ny), layout, (x0, y0), p, (bx, ax, by, ay));
done += 1;
if let Some(f) = ctl.progress {
f(done, total);
}
}
Ok(())
};
let mut previous: Option<(&[Tile], Vec<Result<Vec<f32>, Error>>)> = None;
for chunk in tiles.chunks(group) {
if ctl.cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
return Err(Error::Cancelled);
}
let (outs, blended) = rayon::join(
|| compute(chunk),
|| match previous.take() {
Some((c, o)) => {
let started = web_time::Instant::now();
let r = blend(c, o);
(r, started.elapsed())
}
None => (Ok(()), std::time::Duration::ZERO),
},
);
blended.0?;
t_wait += blended.1;
previous = Some((chunk, outs));
}
if let Some((c, o)) = previous.take() {
let started = web_time::Instant::now();
blend(c, o)?;
t_wait += started.elapsed();
}
t_total += began.elapsed();
let started = web_time::Instant::now();
// normalise by the weights
let (ox, oy) = (layout.position(0, 0).0, layout.position(0, 0).1);
acc.par_chunks_mut(width).enumerate().for_each(|(y, row)| {
let cy = ((y as isize - oy) / 2) as usize;
for (x, px) in row.iter_mut().enumerate() {
let cx = ((x as isize - ox) / 2) as usize;
let w = wsum.get(cy * nx + cx).copied().unwrap_or(0.0);
if w > 0.0 {
*px = px.map(|v| v / w);
}
}
});
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
eprintln!(
"[denoise] {total} tiles, {} at once: tiles {:.0} ms (blending, {:.0} ms of it, overlaps the model), normalise {:.0} ms",
group,
t_total.as_secs_f64() * 1e3,
t_wait.as_secs_f64() * 1e3,
started.elapsed().as_secs_f64() * 1e3
);
}
Ok(Rgb32f { width, height, data: acc })
}
/// Bring a tile's output to the input's scale and keep clipped highlights as they were (in place).
fn finish_tile(input: &[f32], out: &mut [f32], p: &Params, edges: (bool, bool, bool, bool)) {
let t = p.tile;
let side = 2 * t;
if let Gain::MatchMean { nominal, max_deviation } = p.gain {
// the part of the tile whose scale is measured: away from the edges that blend into a neighbour
let m = p.overlap / 2;
let (i0, i1) = (if edges.0 { m } else { 0 }, t - if edges.1 { m } else { 0 });
let (j0, j1) = (if edges.2 { m } else { 0 }, t - if edges.3 { m } else { 0 });
let cells = ((i1 - i0) * (j1 - j0)) as f64;
let plane_sum = |pl: usize| -> f64 {
(j0..j1)
.map(|j| input.get(pl * t * t + j * t + i0..pl * t * t + j * t + i1).map_or(0.0, |r| r.iter().map(|&v| f64::from(v)).sum::<f64>()))
.sum()
};
let in_mean = [plane_sum(0) / cells, (plane_sum(1) + plane_sum(2)) / (2.0 * cells), plane_sum(3) / cells];
for (c, &mean_in) in in_mean.iter().enumerate() {
let sum_out: f64 = (2 * j0..2 * j1)
.map(|y| {
out.get(c * side * side + y * side + 2 * i0..c * side * side + y * side + 2 * i1)
.map_or(0.0, |r| r.iter().map(|&v| f64::from(v)).sum::<f64>())
})
.sum();
let mean_out = sum_out / (4.0 * cells);
let measured = if mean_in as f32 >= MIN_SIGNAL && mean_out > 0.0 { (mean_out / mean_in) as f32 } else { nominal };
let g = measured.clamp(nominal * (1.0 - max_deviation), nominal * (1.0 + max_deviation));
if g.is_finite() && g > 0.0 {
let inv = 1.0 / g;
if let Some(plane) = out.get_mut(c * side * side..(c + 1) * side * side) {
plane.iter_mut().for_each(|v| *v *= inv);
}
}
}
}
if let Some(clip) = p.clip {
protect_clipped(input, out, t, clip);
}
}
/// Where the mosaic is at (or within a cell of) white, the output becomes the plain cell values again, so a clipped
/// area stays exactly as clipped as the camera recorded it (highlight recovery later depends on that).
fn protect_clipped(input: &[f32], out: &mut [f32], t: usize, clip: f32) {
let side = 2 * t;
let plane = t * t;
let (Some(r), Some(g1), Some(g2), Some(b)) =
(input.get(0..plane), input.get(plane..2 * plane), input.get(2 * plane..3 * plane), input.get(3 * plane..4 * plane))
else {
return;
};
let mut m: Vec<f32> = (0..plane).map(|i| r[i].max(g1[i]).max(g2[i]).max(b[i])).collect();
if !m.iter().any(|&v| v >= clip) {
return;
}
// 3×3 maximum over cells (separable)
let dilate = |src: &[f32], horizontal: bool| -> Vec<f32> {
(0..plane)
.map(|i| {
let (x, y) = (i % t, i / t);
let mut best = src[i];
for d in [-1isize, 1] {
let (nx, ny) = if horizontal { (x as isize + d, y as isize) } else { (x as isize, y as isize + d) };
if nx >= 0 && ny >= 0 && (nx as usize) < t && (ny as usize) < t {
best = best.max(src[ny as usize * t + nx as usize]);
}
}
best
})
.collect()
};
m = dilate(&dilate(&m, true), false);
let lo = clip - 0.03;
for j in 0..t {
for i in 0..t {
let k = j * t + i;
let s = ((m[k] - lo) / (clip - lo)).clamp(0.0, 1.0);
if s <= 0.0 {
continue;
}
let s = s * s * (3.0 - 2.0 * s);
let plain = [r[k], 0.5 * (g1[k] + g2[k]), b[k]];
for (c, &pv) in plain.iter().enumerate() {
for (sy, sx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
if let Some(v) = out.get_mut(c * side * side + (2 * j + sy) * side + 2 * i + sx) {
*v += (pv - *v) * s;
}
}
}
}
}
}
/// Add a finished tile to the picture with its blend weights.
#[allow(clippy::too_many_arguments)]
fn blend_tile(
out: &[f32],
acc: &mut [[f32; 3]],
wsum: &mut [f32],
size: (usize, usize),
cells: (usize, usize),
layout: Layout,
at: (usize, usize),
p: &Params,
edges: (bool, bool, bool, bool),
) {
let (width, height) = size;
let (nx, ny) = cells;
let (t, side) = (p.tile, 2 * p.tile);
let fade = p.overlap;
let wx: Vec<f32> = (0..t).map(|i| weight(i, t, fade, edges.0, edges.1)).collect();
let wy: Vec<f32> = (0..t).map(|j| weight(j, t, fade, edges.2, edges.3)).collect();
for j in 0..t {
let cy = at.1 + j;
for i in 0..t {
let cx = at.0 + i;
// cells past the picture's edge (a picture smaller than one tile is padded) belong to no pixel
if cx >= nx || cy >= ny {
continue;
}
let (Some(&a), Some(&b)) = (wx.get(i), wy.get(j)) else { continue };
let w = a * b;
if let Some(s) = wsum.get_mut(cy * nx + cx) {
*s += w;
}
let (px0, py0) = layout.position(cx as isize, cy as isize);
for (sy, sx) in [(0isize, 0isize), (0, 1), (1, 0), (1, 1)] {
let (x, y) = (px0 + sx, py0 + sy);
if x < 0 || y < 0 || x as usize >= width || y as usize >= height {
continue;
}
let o = (2 * j + sy as usize) * side + 2 * i + sx as usize;
let (Some(&r), Some(&g), Some(&bl)) = (out.get(o), out.get(side * side + o), out.get(2 * side * side + o)) else { continue };
if let Some(px) = acc.get_mut(y as usize * width + x as usize) {
px[0] += w * r;
px[1] += w * g;
px[2] += w * bl;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A stand-in model: the cell's samples as an RGB picture at twice the resolution (a plain 2 × 2 "demosaic"),
/// times `scale`, so the output has a scale of its own like the real model's.
struct Mock {
tile: usize,
scale: f32,
}
impl TileRunner for Mock {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
let (t, side) = (self.tile, 2 * self.tile);
let plane = t * t;
let mut out = vec![0.0; 3 * side * side];
for j in 0..t {
for i in 0..t {
let k = j * t + i;
let rgb = [input[k], 0.5 * (input[plane + k] + input[2 * plane + k]), input[3 * plane + k]];
for (c, v) in rgb.iter().enumerate() {
for (sy, sx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
out[c * side * side + (2 * j + sy) * side + 2 * i + sx] = v * self.scale;
}
}
}
}
Ok(out)
}
}
fn params(tile: usize, overlap: usize, gain: Gain) -> Params {
Params { tile, overlap, gain, clip: None, parallel: 2 }
}
#[test]
fn overflowing_tiles_and_nan_gain_are_errors_before_the_model_runs() {
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
let model = Mock { tile: 16, scale: 1.0 };
for (tile, overlap, gain) in [
(usize::MAX, 0, Gain::None),
(16, usize::MAX, Gain::None),
(16, 0, Gain::MatchMean { nominal: 1.0, max_deviation: f32::NAN }),
(16, 0, Gain::MatchMean { nominal: f32::INFINITY, max_deviation: 0.1 }),
] {
assert!(matches!(denoise_bayer(&[0.5], 1, 1, layout, &model, &params(tile, overlap, gain), &Control::default()), Err(Error::Input(_))));
}
}
/// A smooth picture whose colour at a sample is `base[colour] · (1 + shading)`.
fn mosaic(w: usize, h: usize, cell: [u8; 4]) -> Vec<f32> {
(0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let c = cell[(y % 2) * 2 + x % 2] as usize;
[0.5, 0.3, 0.2][c] * (0.6 + 0.4 * ((x as f32 / 23.0).sin() * (y as f32 / 31.0).cos()))
})
.collect()
}
fn reference(m: &[f32], w: usize, h: usize, layout: Layout) -> Rgb32f {
// what the mock gives for the whole picture in one tile
let (nx, ny) = layout.cells(w, h);
let t = nx.max(ny).div_ceil(16) * 16;
denoise_bayer(m, w, h, layout, &Mock { tile: t, scale: 1.0 }, &params(t, 16, Gain::None), &Control::default()).unwrap()
}
#[test]
fn tiles_blend_to_the_same_picture_as_one_tile_for_every_layout() {
for cell in [[0u8, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]] {
let layout = Layout::from_cell(cell).unwrap();
let (w, h) = (150usize, 110usize);
let m = mosaic(w, h, cell);
let want = reference(&m, w, h, layout);
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, &params(32, 8, Gain::None), &Control::default()).unwrap();
assert_eq!((got.width, got.height), (w, h));
for (a, b) in got.data.iter().zip(&want.data) {
for c in 0..3 {
assert!((a[c] - b[c]).abs() < 1e-5, "layout {cell:?}: {a:?} vs {b:?}");
}
}
}
}
#[test]
fn the_picture_does_not_depend_on_how_many_tiles_run_at_once_or_how_long_each_takes() {
// tiles that finish in a different order every time: blending still adds them in tile order
struct Jittery(Mock);
impl TileRunner for Jittery {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
let wait = (input.iter().take(64).sum::<f32>() * 1000.0) as u64 % 7;
std::thread::sleep(std::time::Duration::from_millis(wait));
self.0.run(input)
}
}
let layout = Layout { dx: 0, dy: 1 };
let (w, h) = (150usize, 110usize);
let m = mosaic(w, h, [1, 0, 2, 1]);
let run = |parallel: usize| {
let mut p = params(32, 8, Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 });
p.parallel = parallel;
denoise_bayer(&m, w, h, layout, &Jittery(Mock { tile: 32, scale: 1.01e6 }), &p, &Control::default()).unwrap()
};
let one = run(1);
for parallel in [2, 3, 8] {
assert_eq!(run(parallel).data, one.data, "{parallel} at once");
}
}
#[test]
fn a_failing_tile_or_a_cancel_ends_the_picture_without_a_panic() {
struct Fails(Mock, std::sync::atomic::AtomicUsize);
impl TileRunner for Fails {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
if self.1.fetch_add(1, Ordering::Relaxed) == 4 {
return Err(Error::Runtime("boom".into()));
}
self.0.run(input)
}
}
let layout = Layout { dx: 0, dy: 0 };
let (w, h) = (150usize, 110usize);
let m = mosaic(w, h, [0, 1, 1, 2]);
let r = denoise_bayer(
&m,
w,
h,
layout,
&Fails(Mock { tile: 32, scale: 1.0 }, Default::default()),
&params(32, 8, Gain::None),
&Control::default(),
);
assert!(matches!(r, Err(Error::Runtime(_))), "{r:?}");
let stop = AtomicBool::new(true);
let ctl = Control { cancel: Some(&stop), progress: None };
let r = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, &params(32, 8, Gain::None), &ctl);
assert!(matches!(r, Err(Error::Cancelled)));
}
#[test]
fn a_scale_of_its_own_is_taken_out_tile_by_tile() {
let layout = Layout { dx: 0, dy: 0 };
let (w, h) = (150usize, 110usize);
let m = mosaic(w, h, [0, 1, 1, 2]);
let want = reference(&m, w, h, layout);
let gain = Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 };
// the scale differs a little from nominal; each tile's own scale is measured and removed
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.02e6 }, &params(32, 8, gain), &Control::default()).unwrap();
let mean = |img: &Rgb32f, c: usize| img.data.iter().map(|p| f64::from(p[c])).sum::<f64>() / img.data.len() as f64;
for c in 0..3 {
let (a, b) = (mean(&got, c), mean(&want, c));
assert!((a - b).abs() / b < 0.01, "channel {c}: {a} vs {b}");
}
// a scale far from nominal is clamped rather than trusted
let wild = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 3.0e6 }, &params(32, 8, gain), &Control::default()).unwrap();
assert!(mean(&wild, 1) > 2.0 * mean(&want, 1));
}
#[test]
fn a_picture_smaller_than_a_tile_is_padded_by_mirroring() {
let layout = Layout { dx: 1, dy: 1 };
let (w, h) = (20usize, 14usize);
let m = mosaic(w, h, [2, 1, 1, 0]);
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 64, scale: 1.0 }, &params(64, 16, Gain::None), &Control::default()).unwrap();
assert_eq!((got.width, got.height), (w, h));
assert!(got.data.iter().all(|p| p.iter().all(|v| v.is_finite() && *v > 0.0)));
}
#[test]
fn clipped_highlights_stay_clipped() {
// a model that darkens everything by 10 % would pull a clipped plateau below white
struct Dim(Mock);
impl TileRunner for Dim {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
Ok(self.0.run(input)?.into_iter().map(|v| v * 0.9).collect())
}
}
let layout = Layout { dx: 0, dy: 0 };
let (w, h) = (96usize, 96usize);
let m: Vec<f32> = (0..w * h).map(|i| if i % w > 40 && i % w < 70 { 1.0 } else { 0.4 }).collect();
let mut p = params(32, 8, Gain::None);
p.clip = Some(0.99);
let got = denoise_bayer(&m, w, h, layout, &Dim(Mock { tile: 32, scale: 1.0 }), &p, &Control::default()).unwrap();
let mid = got.data[48 * w + 55];
assert!(mid.iter().all(|&v| v > 0.995), "plateau {mid:?}");
let dark = got.data[48 * w + 10];
assert!(dark.iter().all(|&v| (v - 0.36).abs() < 0.01), "unclipped area still goes through the model: {dark:?}");
}
#[test]
fn cancelling_and_progress_work() {
let layout = Layout { dx: 0, dy: 0 };
let (w, h) = (150usize, 110usize);
let m = mosaic(w, h, [0, 1, 1, 2]);
let cancel = AtomicBool::new(true);
let r = denoise_bayer(
&m,
w,
h,
layout,
&Mock { tile: 32, scale: 1.0 },
&params(32, 8, Gain::None),
&Control { cancel: Some(&cancel), progress: None },
);
assert!(matches!(r, Err(Error::Cancelled)));
let seen = std::sync::Mutex::new(Vec::new());
let f = |d: usize, n: usize| seen.lock().unwrap().push((d, n));
denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, &params(32, 8, Gain::None), &Control { cancel: None, progress: Some(&f) })
.unwrap();
let seen = seen.into_inner().unwrap();
let total = tile_count(w, h, layout, 32, 8);
assert!(total > 1);
assert_eq!(seen.last(), Some(&(total, total)));
assert!(seen.windows(2).all(|p| p[1].0 == p[0].0 + 1));
}
#[test]
fn hostile_inputs_are_errors() {
let layout = Layout { dx: 0, dy: 0 };
let mock = Mock { tile: 32, scale: 1.0 };
let p = params(32, 8, Gain::None);
assert!(matches!(denoise_bayer(&[], 0, 0, layout, &mock, &p, &Control::default()), Err(Error::Input(_))));
assert!(matches!(denoise_bayer(&[0.0; 10], 4, 4, layout, &mock, &p, &Control::default()), Err(Error::Input(_))));
assert!(matches!(denoise_bayer(&[0.0; 16], 4, 4, layout, &mock, &params(32, 16, Gain::None), &Control::default()), Err(Error::Input(_))));
struct Bad(Vec<f32>);
impl TileRunner for Bad {
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
Ok(self.0.clone())
}
}
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_))));
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_))));
}
}

View File

@ -0,0 +1,100 @@
//! Cutting a picture into overlapping tiles and putting the results back together.
//!
//! A model runs on a fixed square, so a picture is covered by tiles spaced evenly along each axis, each sharing
//! at least `overlap` cells with its neighbours. Each tile's influence fades towards its own edges (except at the
//! picture's edge), and the result is the sum of tile × weight divided by the sum of weights, so any set of
//! overlapping tiles blends consistently and a seam between two tiles of slightly different scale is a smooth ramp.
/// Where the tiles start along one axis of `n` cells: `[0]` when the axis fits one tile (the tile is then padded by
/// mirroring), else evenly spaced starts, the first at 0 and the last at `n − tile`, each tile sharing at least
/// `overlap` cells with the next.
pub fn starts(n: usize, tile: usize, overlap: usize) -> Vec<usize> {
if n <= tile || tile == 0 {
return vec![0];
}
let step = tile.saturating_sub(overlap).max(1);
// tiles needed so that start k·spacing with spacing ≤ step reaches n − tile
let Some(k) = (n - tile).div_ceil(step).checked_add(1).filter(|&k| k <= 1_000_000) else { return Vec::new() };
let last = n - tile;
(0..k).map(|i| if k == 1 { 0 } else { ((i as u128 * last as u128 + (k - 1) as u128 / 2) / (k - 1) as u128) as usize }).collect()
}
/// Weight of the cell at offset `p` (0-based, inside a tile of `tile` cells) along one axis: 1 in the middle, easing
/// down to a sliver over `fade` cells at an edge that has a neighbour, 1 right up to an edge that is the picture's.
pub fn weight(p: usize, tile: usize, fade: usize, has_before: bool, has_after: bool) -> f32 {
let fade = fade.max(1) as f32;
let ramp = |d: usize| {
let t = ((d as f32 + 0.5) / fade).min(1.0);
// smoothstep, never exactly 0 so that a cell is always covered
(t * t * (3.0 - 2.0 * t)).max(1e-3)
};
let before = if has_before { ramp(p) } else { 1.0 };
let after = if has_after { ramp(tile.saturating_sub(1).saturating_sub(p)) } else { 1.0 };
before.min(after)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_axes_have_bounded_tile_counts_and_checked_spacing() {
assert!(starts(usize::MAX, 1, 0).is_empty());
assert!(starts(usize::MAX, 2, usize::MAX).is_empty());
let s = starts(usize::MAX, usize::MAX / 2, 0);
assert_eq!(s.first(), Some(&0));
assert_eq!(s.last(), Some(&(usize::MAX - usize::MAX / 2)));
assert!(s.windows(2).all(|s| s[0] < s[1]));
}
#[test]
fn one_tile_when_it_fits() {
assert_eq!(starts(100, 512, 64), vec![0]);
assert_eq!(starts(512, 512, 64), vec![0]);
assert_eq!(starts(0, 512, 64), vec![0]);
assert_eq!(starts(10, 0, 0), vec![0]);
}
#[test]
fn tiles_cover_the_axis_and_overlap_enough() {
for n in [513usize, 600, 900, 1000, 1024, 1500, 3024, 3600, 5000, 12345] {
let s = starts(n, 512, 64);
assert_eq!(s[0], 0, "n={n}");
assert_eq!(*s.last().unwrap(), n - 512, "n={n}");
for w in s.windows(2) {
assert!(w[1] > w[0], "starts increase, n={n}");
assert!(w[0] + 512 >= w[1] + 64, "at least 64 shared cells between {} and {}, n={n}", w[0], w[1]);
}
}
}
#[test]
fn weights_fade_only_towards_neighbours() {
let (t, f) = (512, 64);
// a lone tile: full weight everywhere
assert!((0..t).all(|p| weight(p, t, f, false, false) == 1.0));
// the first tile fades on its far side only
assert_eq!(weight(0, t, f, false, true), 1.0);
assert!(weight(t - 1, t, f, false, true) < 0.01);
// the last tile fades on its near side only
assert!(weight(0, t, f, true, false) < 0.01);
assert_eq!(weight(t - 1, t, f, true, false), 1.0);
// a middle tile is 1 in the middle and everywhere positive
assert_eq!(weight(t / 2, t, f, true, true), 1.0);
assert!((0..t).all(|p| weight(p, t, f, true, true) > 0.0));
}
#[test]
fn two_overlapping_tiles_blend_to_a_partition_of_unity() {
// at every cell of the shared region the normalised weights are in [0, 1] and sum to 1
let (t, f) = (512usize, 64usize);
let s = starts(900, t, 64);
assert_eq!(s.len(), 2);
for cell in s[1]..s[0] + t {
let a = weight(cell - s[0], t, f, false, true);
let b = weight(cell - s[1], t, f, true, false);
let (na, nb) = (a / (a + b), b / (a + b));
assert!((na + nb - 1.0).abs() < 1e-6 && na >= 0.0 && nb >= 0.0);
}
}
}

32
crates/denoise/Cargo.toml Normal file
View File

@ -0,0 +1,32 @@
[package]
name = "lightcraft-denoise"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "LightCraft AI denoise (L1): denoise-model manifests, Bayer packing, tiling and blending, the cache file, and pure-Rust CPU inference"
[lints]
workspace = true
[features]
default = []
runtime = ["dep:faer"]
# Product-policy switch: absent by default; users may install their own model.
rawnind-model = ["lightcraft-denoise-core/rawnind-model"]
[dependencies]
lightcraft-denoise-core = { workspace = true }
# Safe matrix views over the pure-Rust GEMM backend; no BLAS, cc/build scripts
# or inner thread pool. Its dependencies include architecture-specific assembly.
faer = { package = "faer-core", version = "=0.17.1", default-features = false, features = ["std"], optional = true }
lightcraft-raster = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
miniz_oxide = { workspace = true }
rayon = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }

18
crates/denoise/README.md Normal file
View File

@ -0,0 +1,18 @@
# lightcraft-denoise
L1 pure-Rust AI RAW denoise primitives and CPU inference. No weights are bundled.
See [user workflow and limitations](../../docs/denoise.md).
`lightcraft-denoise-core` provides metadata, Bayer helpers and cache formats,
re-exported here. `manifest` and `known` validate opt-in models; `onnx` reads a bounded operator subset
into `net`; `cpu` executes it and `reference` supplies scalar checks. `bayer`, `tiles`
and `run` pack sensor data and blend overlapping tiles while preserving clipped
highlights. `product` reads/writes the disposable `.lcdn` cache. `runtime` supplies
the installation self-test. Synthetic networks are generated in Rust for tests.
Run `cargo test -p lightcraft-denoise --features runtime`. The CPU backend is optional;
featureless and web builds keep the model metadata and cache format without faer/GEMM.
The `rawnind-model` feature enables the otherwise empty download catalog and requires
an explicit release-policy decision. Real-model tests are ignored by default and
require an explicitly supplied local model. The GPU twin is `lightcraft-gpu::nn`;
model management, background jobs and export integration live in `lightcraft-engine`.

386
crates/denoise/src/cpu.rs Normal file
View File

@ -0,0 +1,386 @@
//! Single-thread CPU execution of the checked denoise network.
//!
//! Convolutions lower at most 256 spatial positions at a time, then use faer's safe views over pure-Rust GEMM.
//! No inner thread pool: `run::denoise_bayer` already runs tiles on the application's Rayon pool. Tensor lifetimes
//! are planned once; buffers and im2col storage are recycled between calls, independently for concurrent callers.
use crate::net::{Net, NetError, Op};
use crate::run::{Error, TileRunner};
use faer::{Parallelism, mat};
use std::sync::{Arc, Mutex};
const BLOCK: usize = 256;
/// Cap all tensor buffers and convolution scratch for one tile together (512 MiB).
const MAX_VALUES: usize = 128 * 1024 * 1024;
const MAX_TILE: usize = 2048;
const MAX_IDLE: usize = 1;
type Result<T> = std::result::Result<T, NetError>;
fn bad(why: &str) -> NetError {
NetError::Invalid(why.into())
}
fn count(c: usize, side: usize) -> Result<usize> {
c.checked_mul(side)
.and_then(|n| n.checked_mul(side))
.filter(|&n| n > 0 && n <= MAX_VALUES)
.ok_or_else(|| bad("a tensor has no size or is too large"))
}
fn zeros(n: usize) -> Result<Vec<f32>> {
let mut v = Vec::new();
v.try_reserve_exact(n).map_err(|_| bad("not enough memory for a denoise tile"))?;
v.resize(n, 0.0);
Ok(v)
}
fn sources(op: &Op) -> (usize, Option<usize>) {
match op {
Op::Conv(c) => (c.src, c.src2),
Op::ConvT2(c) => (c.src, None),
Op::MaxPool2 { src } | Op::DepthToSpace2 { src } => (*src, None),
}
}
#[derive(Clone, Copy)]
struct Tensor {
slot: usize,
side: usize,
channels: usize,
}
struct Plan {
net: Net,
tensors: Vec<Tensor>,
sizes: Vec<usize>,
lower: usize,
product: usize,
up_weights: Vec<Option<Vec<f32>>>,
}
struct Workspace {
buffers: Vec<Vec<f32>>,
lower: Vec<f32>,
product: Vec<f32>,
}
/// A checked Net on square CHW tiles. Clones share immutable weights and the idle workspace cache.
#[derive(Clone)]
pub struct NetRunner {
plan: Arc<Plan>,
idle: Arc<Mutex<Vec<Workspace>>>,
}
impl NetRunner {
pub fn new(net: &Net, tile: usize) -> Result<Self> {
if tile == 0
|| tile > MAX_TILE
|| !net.fits_tile(tile)
|| net.in_channels != 4
|| net.out_channels() != 3
|| net.shape(0).is_none_or(|s| s.channels != net.in_channels)
{
return Err(bad("the tile or Bayer-to-RGB contract does not fit the network"));
}
let mut last = vec![0; net.ops().len() + 1];
for (i, op) in net.ops().iter().enumerate() {
let (a, b) = sources(op);
for id in std::iter::once(a).chain(b) {
*last.get_mut(id).ok_or_else(|| bad("missing tensor"))? = i + 1;
}
}
let mut tensors = vec![Tensor { slot: 0, side: tile, channels: net.in_channels }];
let mut sizes = vec![count(net.in_channels, tile)?];
let mut busy = vec![true];
let (mut lower, mut product) = (0, 0);
let mut up_weights = Vec::new();
for (i, op) in net.ops().iter().enumerate() {
let (src, src2) = sources(op);
let a = *tensors.get(src).ok_or_else(|| bad("missing tensor"))?;
let (channels, side) = match op {
Op::Conv(c) => {
if let Some(s) = src2
&& tensors.get(s).is_none_or(|b| b.side != a.side)
{
return Err(bad("a join has different sizes"));
}
(c.cout, a.side)
}
Op::ConvT2(c) => (c.cout, a.side.checked_mul(2).ok_or_else(|| bad("size overflow"))?),
Op::MaxPool2 { .. } => {
if a.side < 2 || !a.side.is_multiple_of(2) {
return Err(bad("pooling does not divide the tile"));
}
(a.channels, a.side / 2)
}
Op::DepthToSpace2 { .. } => (a.channels / 4, a.side.checked_mul(2).ok_or_else(|| bad("size overflow"))?),
};
let n = count(channels, side)?;
let slot = busy
.iter()
.zip(&sizes)
.enumerate()
.filter(|(_, (used, _))| !**used)
.min_by_key(|(_, (_, size))| (n.saturating_sub(**size), **size))
.map_or(busy.len(), |(i, _)| i);
if slot == sizes.len() {
sizes.push(n);
busy.push(true);
} else {
let sz = sizes.get_mut(slot).ok_or_else(|| bad("missing buffer"))?;
*sz = (*sz).max(n);
*busy.get_mut(slot).ok_or_else(|| bad("missing buffer"))? = true;
}
tensors.push(Tensor { slot, side, channels });
// Release sources only after the output has its own buffer (also handles reading the same tensor twice).
for id in std::iter::once(src).chain(src2) {
if last.get(id) == Some(&(i + 1)) {
let t = tensors.get(id).ok_or_else(|| bad("missing tensor"))?;
*busy.get_mut(t.slot).ok_or_else(|| bad("missing buffer"))? = false;
}
}
let (k, m, positions) = match op {
Op::Conv(c) => (c.cin * c.k * c.k, c.cout, a.side * a.side),
Op::ConvT2(c) => (c.cin, c.cout * 4, a.side * a.side),
_ => (0, 0, 0),
};
lower = lower.max(k.saturating_mul(BLOCK.min(positions)));
product = product.max(m.saturating_mul(BLOCK.min(positions)));
let packed = if let Op::ConvT2(c) = op {
let mut w = zeros(c.weight.len())?;
// [cin][cout][dy][dx] -> [cout*4][cin], so an up-convolution is one GEMM and a shuffle.
for (ci, row) in c.weight.chunks_exact(c.cout * 4).enumerate() {
for (co, &v) in row.iter().enumerate() {
*w.get_mut(co * c.cin + ci).ok_or_else(|| bad("invalid transposed weights"))? = v;
}
}
Some(w)
} else {
None
};
up_weights.push(packed);
}
let total = sizes
.iter()
.try_fold(lower.saturating_add(product), |n, &s| n.checked_add(s))
.filter(|&n| n <= MAX_VALUES)
.ok_or_else(|| bad("the tile needs too much working memory"))?;
let _ = total;
Ok(Self { plan: Arc::new(Plan { net: net.clone(), tensors, sizes, lower, product, up_weights }), idle: Arc::new(Mutex::new(Vec::new())) })
}
/// Immutable data for the GPU runner, without re-reading the model.
pub fn net(&self) -> &Net {
&self.plan.net
}
pub fn bytes_per_tile(&self) -> usize {
(self.plan.sizes.iter().sum::<usize>() + self.plan.lower + self.plan.product) * 4
}
fn workspace(&self) -> Result<Workspace> {
if let Some(w) = self.idle.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() {
return Ok(w);
}
Ok(Workspace {
buffers: self.plan.sizes.iter().map(|&n| zeros(n)).collect::<Result<_>>()?,
lower: zeros(self.plan.lower)?,
product: zeros(self.plan.product)?,
})
}
fn execute(&self, input: &[f32], w: &mut Workspace) -> Result<Vec<f32>> {
let first = self.plan.tensors.first().ok_or_else(|| bad("no input"))?;
let want = count(first.channels, first.side)?;
w.buffers.first_mut().and_then(|b| b.get_mut(..want)).ok_or_else(|| bad("missing input buffer"))?.copy_from_slice(input);
let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
for (i, op) in self.plan.net.ops().iter().enumerate() {
let started = web_time::Instant::now();
let out = *self.plan.tensors.get(i + 1).ok_or_else(|| bad("missing output tensor"))?;
let (before, rest) = w.buffers.split_at_mut_checked(out.slot).ok_or_else(|| bad("missing output buffer"))?;
let (dest, after) = rest.split_first_mut().ok_or_else(|| bad("missing output buffer"))?;
let get = |id: usize| -> Result<(&[f32], Tensor)> {
let t = *self.plan.tensors.get(id).ok_or_else(|| bad("missing source tensor"))?;
let data = if t.slot < out.slot {
before.get(t.slot)
} else {
after.get(t.slot.checked_sub(out.slot + 1).ok_or_else(|| bad("aliased buffers"))?)
}
.and_then(|b| b.get(..count(t.channels, t.side).ok()?))
.ok_or_else(|| bad("missing source buffer"))?;
Ok((data, t))
};
let (src, src2) = sources(op);
let (a, shape) = get(src)?;
let b = src2.map(get).transpose()?.map(|v| v.0);
let dest = dest.get_mut(..count(out.channels, out.side)?).ok_or_else(|| bad("short output buffer"))?;
match op {
Op::Conv(c) => conv(c, a, b, shape.side, dest, &mut w.lower, &mut w.product)?,
Op::ConvT2(c) => up(
c,
a,
shape.side,
dest,
self.plan.up_weights.get(i).and_then(Option::as_deref).ok_or_else(|| bad("missing up weights"))?,
&mut w.lower,
&mut w.product,
)?,
Op::MaxPool2 { .. } => pool(a, shape.side, dest)?,
Op::DepthToSpace2 { .. } => shuffle(a, shape.side, dest)?,
}
if dest.iter().any(|v| !v.is_finite()) {
return Err(bad("the model produced non-finite values"));
}
if profile {
eprintln!("[denoise-cpu] layer {i}: {}x{}x{} {:.2} ms", out.channels, out.side, out.side, started.elapsed().as_secs_f64() * 1e3);
}
}
let t = self.plan.tensors.last().ok_or_else(|| bad("no output"))?;
let data = w.buffers.get(t.slot).and_then(|b| b.get(..count(t.channels, t.side).ok()?)).ok_or_else(|| bad("missing result"))?;
let mut out = zeros(data.len())?;
out.copy_from_slice(data);
Ok(out)
}
}
impl TileRunner for NetRunner {
fn run(&self, input: &[f32]) -> std::result::Result<Vec<f32>, Error> {
let first = self.plan.tensors.first().ok_or_else(|| Error::Runtime("missing input shape".into()))?;
let want = count(first.channels, first.side).map_err(|e| Error::Runtime(e.to_string()))?;
if input.len() != want || input.iter().any(|v| !v.is_finite()) {
return Err(Error::Input("the tile has the wrong size or non-finite values".into()));
}
let mut w = self.workspace().map_err(|e| Error::Runtime(e.to_string()))?;
let result = self.execute(input, &mut w).map_err(|e| Error::Runtime(e.to_string()));
let mut idle = self.idle.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if idle.len() < MAX_IDLE {
idle.push(w);
}
result
}
}
fn activate(x: f32, bias: f32, alpha: Option<f32>) -> f32 {
let x = x + bias;
if x < 0.0 { x * alpha.unwrap_or(1.0) } else { x }
}
/// All views are over exact, checked slices. The matrix library's assertions therefore never see file-derived sizes.
fn multiply(w: &[f32], x: &[f32], y: &mut [f32], m: usize, k: usize, n: usize) -> Result<()> {
if m.checked_mul(k) != Some(w.len()) || k.checked_mul(n) != Some(x.len()) || m.checked_mul(n) != Some(y.len()) || m == 0 || k == 0 || n == 0 {
return Err(bad("invalid matrix dimensions"));
}
faer::mul::matmul(
mat::from_row_major_slice_mut::<f32>(y, m, n),
mat::from_row_major_slice::<f32>(w, m, k),
mat::from_row_major_slice::<f32>(x, k, n),
None,
1.0,
Parallelism::None,
);
Ok(())
}
fn lower(a: &[f32], b: Option<&[f32]>, side: usize, k: usize, start: usize, n: usize, dest: &mut [f32]) -> Result<()> {
let pad = k / 2;
let pixels = side * side;
// Whole row segments are copied, so bounds checks and edge branches stay out of the multiply loop.
for (ci, plane) in a.chunks_exact(pixels).chain(b.into_iter().flat_map(|b| b.chunks_exact(pixels))).enumerate() {
for ky in 0..k {
for kx in 0..k {
let row = (ci * k * k + ky * k + kx) * n;
let out = dest.get_mut(row..row + n).ok_or_else(|| bad("short im2col buffer"))?;
let mut at = 0;
while at < n {
let p = start + at;
let (y, x) = (p / side, p % side);
let len = (side - x).min(n - at);
if let Some(yy) = y.checked_add(ky).and_then(|v| v.checked_sub(pad)).filter(|&v| v < side) {
let lo = x.max(pad.saturating_sub(kx));
let hi = (x + len).min((side + pad).saturating_sub(kx));
if lo < hi {
let from = yy * side + lo + kx - pad;
let to = at + lo - x;
out.get_mut(at..to).ok_or_else(|| bad("short im2col padding"))?.fill(0.0);
out.get_mut(to..to + hi - lo)
.ok_or_else(|| bad("short im2col row"))?
.copy_from_slice(plane.get(from..from + hi - lo).ok_or_else(|| bad("short source row"))?);
out.get_mut(to + hi - lo..at + len).ok_or_else(|| bad("short im2col padding"))?.fill(0.0);
} else {
out.get_mut(at..at + len).ok_or_else(|| bad("short im2col row"))?.fill(0.0);
}
} else {
out.get_mut(at..at + len).ok_or_else(|| bad("short im2col row"))?.fill(0.0);
}
at += len;
}
}
}
}
Ok(())
}
fn conv(c: &crate::net::Conv, a: &[f32], b: Option<&[f32]>, side: usize, dest: &mut [f32], scratch: &mut [f32], product: &mut [f32]) -> Result<()> {
let pixels = side * side;
let k = c.cin * c.k * c.k;
for start in (0..pixels).step_by(BLOCK) {
let n = BLOCK.min(pixels - start);
let x = scratch.get_mut(..k * n).ok_or_else(|| bad("short im2col storage"))?;
lower(a, b, side, c.k, start, n, x)?;
let y = product.get_mut(..c.cout * n).ok_or_else(|| bad("short product storage"))?;
multiply(&c.weight, x, y, c.cout, k, n)?;
for ((row, &bias), out) in y.chunks_exact(n).zip(&c.bias).zip(dest.chunks_exact_mut(pixels)) {
let to = out.get_mut(start..start + n).ok_or_else(|| bad("short convolution output"))?;
for (d, &v) in to.iter_mut().zip(row) {
*d = activate(v, bias, c.leaky);
}
}
}
Ok(())
}
fn up(c: &crate::net::ConvT2, a: &[f32], side: usize, dest: &mut [f32], weights: &[f32], scratch: &mut [f32], product: &mut [f32]) -> Result<()> {
let pixels = side * side;
let out_side = 2 * side;
for start in (0..pixels).step_by(BLOCK) {
let n = BLOCK.min(pixels - start);
let x = scratch.get_mut(..c.cin * n).ok_or_else(|| bad("short up input storage"))?;
for (to, from) in x.chunks_exact_mut(n).zip(a.chunks_exact(pixels)) {
to.copy_from_slice(from.get(start..start + n).ok_or_else(|| bad("short up input"))?);
}
let y = product.get_mut(..c.cout * 4 * n).ok_or_else(|| bad("short up product storage"))?;
multiply(weights, x, y, c.cout * 4, c.cin, n)?;
for (co, (&bias, out)) in c.bias.iter().zip(dest.chunks_exact_mut(4 * pixels)).enumerate() {
for d in 0..4 {
let row = y.get((4 * co + d) * n..(4 * co + d + 1) * n).ok_or_else(|| bad("short up product"))?;
for (j, &v) in row.iter().enumerate() {
let p = start + j;
let at = (2 * (p / side) + d / 2) * out_side + 2 * (p % side) + d % 2;
*out.get_mut(at).ok_or_else(|| bad("short up output"))? = activate(v, bias, c.leaky);
}
}
}
}
Ok(())
}
fn pool(a: &[f32], side: usize, dest: &mut [f32]) -> Result<()> {
let s = side / 2;
for (input, out) in a.chunks_exact(side * side).zip(dest.chunks_exact_mut(s * s)) {
for (y, row) in out.chunks_exact_mut(s).enumerate() {
let lo = input.get(2 * y * side..(2 * y + 1) * side).ok_or_else(|| bad("short pool input"))?;
let hi = input.get((2 * y + 1) * side..(2 * y + 2) * side).ok_or_else(|| bad("short pool input"))?;
for ((d, lo), hi) in row.iter_mut().zip(lo.as_chunks::<2>().0).zip(hi.as_chunks::<2>().0) {
let [a, b] = *lo;
let [c, e] = *hi;
*d = a.max(b).max(c).max(e);
}
}
}
Ok(())
}
fn shuffle(a: &[f32], side: usize, dest: &mut [f32]) -> Result<()> {
let pixels = side * side;
for (input, out) in a.chunks_exact(4 * pixels).zip(dest.chunks_exact_mut(4 * pixels)) {
for d in 0..4 {
let plane = input.get(d * pixels..(d + 1) * pixels).ok_or_else(|| bad("short shuffle input"))?;
for (y, row) in plane.chunks_exact(side).enumerate() {
let at = (2 * y + d / 2) * 2 * side;
let to = out.get_mut(at..at + 2 * side).ok_or_else(|| bad("short shuffle output"))?;
for (to, &v) in to.as_chunks_mut::<2>().0.iter_mut().zip(row) {
if let Some(t) = to.get_mut(d % 2) {
*t = v;
}
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,151 @@
use super::*;
use crate::{
net::{Conv, ConvT2},
reference, synthetic,
};
fn random(n: usize, seed: u32) -> Vec<f32> {
let mut s = seed | 1;
(0..n)
.map(|_| {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
(s >> 8) as f32 / (1u32 << 23) as f32 - 1.0
})
.collect()
}
fn net(tile: usize, ch: u64, depth: usize, seed: u64) -> Net {
let p = std::env::temp_dir().join(format!("lc-purecpu-{}-{tile}-{ch}-{depth}-{seed}.onnx", std::process::id()));
std::fs::write(&p, synthetic::unet_onnx(tile as u64, ch, depth, seed)).unwrap();
let n = crate::onnx::read(&p).unwrap();
std::fs::remove_file(p).unwrap();
n
}
fn agree(a: &[f32], b: &[f32]) {
assert_eq!(a.len(), b.len());
assert!(a.iter().chain(b).all(|v| v.is_finite()));
let scale = b.iter().fold(1e-6f32, |m, v| m.max(v.abs()));
let error = a.iter().zip(b).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
assert!(error <= 1e-3, "relative maximum error {error:e}");
}
#[test]
fn all_operators_match_scalar_on_random_nets_and_odd_and_tiny_shapes() {
for (t, ch, d) in [(1, 3, 0), (3, 5, 0), (5, 7, 0), (6, 3, 1), (20, 5, 2), (36, 9, 2), (64, 8, 3)] {
for seed in [1, 73] {
let n = net(t, ch, d, seed);
let r = NetRunner::new(&n, t).unwrap();
let x = random(4 * t * t, seed as u32);
let want = reference::run(&n, t, &x).unwrap();
agree(&r.run(&x).unwrap(), &want);
assert_eq!(r.run(&x).unwrap(), r.run(&x).unwrap());
}
}
}
#[test]
fn malformed_values_fail_before_execution_and_workspace_sizes_are_bounded() {
let mut n = net(4, 3, 1, 11);
for t in [0, 1, 3, usize::MAX, 4096] {
assert!(NetRunner::new(&n, t).is_err());
}
for ch in [0, 3, usize::MAX] {
n.in_channels = ch;
assert!(NetRunner::new(&n, 4).is_err());
}
let n = Net::new(
4,
vec![
Op::Conv(Conv { src: 0, src2: None, cin: 4, cout: 4096, k: 1, weight: vec![0.1; 4 * 4096], bias: vec![0.0; 4096], leaky: None }),
Op::Conv(Conv { src: 1, src2: None, cin: 4096, cout: 12, k: 1, weight: vec![0.1; 4096 * 12], bias: vec![0.0; 12], leaky: None }),
Op::DepthToSpace2 { src: 2 },
],
)
.unwrap();
assert!(NetRunner::new(&n, 2048).is_err());
let ops = (0..100).map(|i| Op::MaxPool2 { src: i }).collect();
let n = Net::new(4, ops).unwrap();
assert!(NetRunner::new(&n, 16).is_err());
let c = Conv { src: 0, src2: None, cin: 4, cout: 12, k: 1, weight: vec![0.25; 48], bias: vec![0.0; 12], leaky: None };
for mode in 0..6 {
let mut bad = c.clone();
match mode {
0 => bad.src = usize::MAX,
1 => bad.weight.pop().map(|_| ()).unwrap(),
2 => bad.bias[0] = f32::NAN,
3 => bad.leaky = Some(f32::INFINITY),
4 => bad.cout = usize::MAX,
_ => bad.k = usize::MAX,
}
assert!(Net::new(4, vec![Op::Conv(bad), Op::DepthToSpace2 { src: 1 }]).is_err());
}
}
#[test]
fn bad_tiles_and_arithmetic_overflow_are_errors_and_do_not_poison_reuse() {
let c = Conv { src: 0, src2: None, cin: 4, cout: 12, k: 1, weight: vec![f32::MAX; 48], bias: vec![0.0; 12], leaky: None };
let n = Net::new(4, vec![Op::Conv(c), Op::DepthToSpace2 { src: 1 }]).unwrap();
let r = NetRunner::new(&n, 1).unwrap();
assert!(r.run(&[]).is_err());
assert!(r.run(&[f32::NAN; 4]).is_err());
assert!(r.run(&[f32::INFINITY; 4]).is_err());
assert_eq!(r.idle.lock().unwrap().len(), 0, "invalid input never allocates a workspace");
assert!(r.run(&[2.0; 4]).is_err());
assert_eq!(r.run(&[0.0; 4]).unwrap(), vec![0.0; 12]);
// Matrix shape checks are the boundary in front of the dependency's asserting constructors.
assert!(multiply(&[], &[], &mut [], usize::MAX, 2, 2).is_err());
}
#[test]
fn shared_runner_serves_concurrent_tiles_without_cross_contamination() {
let n = net(12, 5, 1, 97);
let r = NetRunner::new(&n, 12).unwrap();
let inputs: Vec<_> = (0..8).map(|s| random(4 * 12 * 12, s)).collect();
let answers: Vec<_> = inputs.iter().map(|x| reference::run(&n, 12, x).unwrap()).collect();
std::thread::scope(|scope| {
let mut handles = Vec::new();
for (x, want) in inputs.iter().zip(&answers) {
let r = &r;
handles.push(scope.spawn(move || {
for _ in 0..3 {
agree(&r.run(x).unwrap(), want);
}
}));
}
for h in handles {
h.join().unwrap();
}
});
}
#[test]
fn join_may_read_the_same_tensor_twice_and_leaky_up_convolution_is_supported() {
let c = ConvT2 { src: 0, cin: 4, cout: 3, weight: random(48, 2), bias: vec![0.1, -0.1, 0.0], leaky: Some(0.13) };
let n = Net::new(
4,
vec![
Op::ConvT2(c),
Op::Conv(Conv { src: 1, src2: Some(1), cin: 6, cout: 3, k: 3, weight: random(162, 3), bias: vec![0.0; 3], leaky: Some(0.0) }),
],
)
.unwrap();
let x = random(4 * 3 * 3, 7);
agree(&NetRunner::new(&n, 3).unwrap().run(&x).unwrap(), &reference::run(&n, 3, &x).unwrap());
}
#[test]
fn reused_im2col_clears_all_edge_padding_between_images() {
for t in [1, 3, 5, 36] {
let n = net(t, 5, 0, 121);
let r = NetRunner::new(&n, t).unwrap();
for x in [vec![1.0; 4 * t * t], vec![0.0; 4 * t * t], random(4 * t * t, 9)] {
agree(&r.run(&x).unwrap(), &reference::run(&n, t, &x).unwrap());
}
}
}
#[test]
#[ignore = "needs LC_DENOISE_MODEL; one tile with LIGHTCRAFT_PROFILE=1"]
fn profile_one_real_tile() {
let model = std::env::var_os("LC_DENOISE_MODEL").expect("set LC_DENOISE_MODEL");
let m = crate::known::find("rawnind-bayer").unwrap().manifest;
let r = crate::runtime::CpuRunner::load(std::path::Path::new(&model), &m).unwrap();
println!("workspace {:.1} MB", r.bytes_per_tile() as f64 / 1e6);
r.run(&crate::runtime::check_tile(512)).unwrap();
}

37
crates/denoise/src/lib.rs Normal file
View File

@ -0,0 +1,37 @@
//! AI denoise for LightCraft, as a step between the sensor and the demosaic that the rest of the develop
//! pipeline never sees: the raw file stays untouched, the denoised picture is cached data that can always be
//! made again, and the amount is an ordinary develop setting.
//!
//! - [`manifest`]: what a denoise model takes and gives, and who may use it (untrusted JSON, validated).
//! - [`known`] and [`archive`]: the models LightCraft can fetch, and taking the model file out of the `.zip` it comes in.
//! - [`bayer`]: Bayer layouts and the packing of a mosaic into the four planes a model takes.
//! - [`tiles`] and [`run`]: cutting a picture into overlapping tiles for a fixed-size model, bringing each tile's
//! output scale back to the input's, keeping clipped highlights as they were, and blending the tiles together.
//! The model itself is a [`run::TileRunner`], so all of this is tested without one.
//! - [`product`]: the cache file the denoised picture is kept in (half floats, compressed in strips) and the
//! blend with the plain demosaic that the Amount slider controls.
//! - [`cpu`] and [`runtime`]: a single-thread pure-Rust CPU runner and its installation self-test.
//! - [`net`] and [`onnx`]: checked plain network data and bounded ONNX decoding, shared by CPU and GPU.
//!
//! Everything here treats model files, manifests and cache files as hostile input: sizes are capped, numbers must
//! be finite, and a malformed file is an error, never a panic.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(feature = "runtime")]
pub mod cpu;
pub mod net;
pub mod onnx;
mod onnx_proto;
pub mod reference;
#[cfg(feature = "runtime")]
pub mod runtime;
#[doc(hidden)]
pub mod synthetic;
pub use lightcraft_denoise_core::*;
pub mod synthetic_proto;

345
crates/denoise/src/net.rs Normal file
View File

@ -0,0 +1,345 @@
//! A denoise network as plain data: the handful of layer kinds a U-Net denoiser is made of, with their weights.
//!
//! [`onnx`](crate::onnx) reads the file into this description, shared by the pure-Rust CPU and GPU runners.
//! Unsupported operators, groups, dilation or activations are reported as errors when loading the model.
//!
//! The description is checked ([`Net::new`]) because it comes from a stranger's file: channel counts must chain,
//! weights must have the sizes the shapes need and be finite, and the depth must stay small.
/// Most layers a network may have.
pub const MAX_OPS: usize = 512;
/// Most channels a layer may have.
pub const MAX_CHANNELS: usize = 4096;
/// Most weights (numbers) a network may have in all.
pub const MAX_WEIGHTS: usize = 128 * 1024 * 1024;
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum NetError {
/// The model uses something this denoise network description cannot express.
#[error("not supported by the denoise runners: {0}")]
Unsupported(String),
/// The model is not a well-formed network.
#[error("not a valid network: {0}")]
Invalid(String),
}
fn invalid<T>(why: impl Into<String>) -> Result<T, NetError> {
Err(NetError::Invalid(why.into()))
}
/// A tensor is named by its number: 0 is the network's input, and layer `i` makes tensor `i + 1`.
pub type TensorId = usize;
/// A convolution with stride 1 and "same" padding (`k` is 1 or 3), over one tensor or two joined along the channels
/// (`src` first), then a bias and optionally a leaky ReLU.
#[derive(Clone, Debug, PartialEq)]
pub struct Conv {
pub src: TensorId,
/// A second source, whose channels follow `src`'s (a skip connection's concatenation).
pub src2: Option<TensorId>,
pub cin: usize,
pub cout: usize,
pub k: usize,
/// `[cout][cin][k][k]`, as ONNX has it.
pub weight: Vec<f32>,
pub bias: Vec<f32>,
/// `Some(alpha)`: a leaky ReLU with this slope follows.
pub leaky: Option<f32>,
}
/// A transposed convolution with a 2 × 2 kernel and stride 2: every input pixel becomes a 2 × 2 block.
#[derive(Clone, Debug, PartialEq)]
pub struct ConvT2 {
pub src: TensorId,
pub cin: usize,
pub cout: usize,
/// `[cin][cout][2][2]`, as ONNX has it.
pub weight: Vec<f32>,
pub bias: Vec<f32>,
pub leaky: Option<f32>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Op {
Conv(Conv),
ConvT2(ConvT2),
/// 2 × 2 maximum, stride 2.
MaxPool2 {
src: TensorId,
},
/// Depth-to-space with block size 2 in "CRD" order: output channel `c` at `(2y + dy, 2x + dx)` is input channel
/// `4c + 2dy + dx` at `(y, x)`.
DepthToSpace2 {
src: TensorId,
},
}
/// What a tensor looks like: its channels, and its resolution as a power of two below the input's (a negative
/// level is above it).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Shape {
pub channels: usize,
pub level: i32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Net {
pub in_channels: usize,
ops: Vec<Op>,
shapes: Vec<Shape>,
}
impl Net {
/// Check `ops` (layer `i` makes tensor `i + 1`) over an input of `in_channels` and describe every tensor.
pub fn new(in_channels: usize, ops: Vec<Op>) -> Result<Net, NetError> {
if in_channels == 0 || in_channels > MAX_CHANNELS {
return invalid("the input has no channels or too many");
}
if ops.is_empty() || ops.len() > MAX_OPS {
return invalid("the network has no layers or too many");
}
let mut shapes = vec![Shape { channels: in_channels, level: 0 }];
let mut weights = 0usize;
for (i, op) in ops.iter().enumerate() {
let shape_of = |t: TensorId| {
shapes
.get(t)
.copied()
.filter(|_| t <= i)
.ok_or_else(|| NetError::Invalid(format!("layer {i} reads tensor {t}, which does not exist yet")))
};
let out = match op {
Op::Conv(c) => {
let a = shape_of(c.src)?;
let (cin, level) = match c.src2 {
Some(s2) => {
let b = shape_of(s2)?;
if b.level != a.level {
return invalid(format!("layer {i} joins tensors of different resolutions"));
}
(a.channels + b.channels, a.level)
}
None => (a.channels, a.level),
};
if c.cin != cin || c.cout == 0 || c.cout > MAX_CHANNELS || !(c.k == 1 || c.k == 3) {
return invalid(format!(
"layer {i}: a convolution of {} to {} channels with a {}-wide kernel does not fit",
c.cin, c.cout, c.k
));
}
check_weights(
i,
&c.weight,
c.cout.checked_mul(cin).and_then(|n| n.checked_mul(c.k * c.k)),
&c.bias,
c.cout,
c.leaky,
&mut weights,
)?;
Shape { channels: c.cout, level }
}
Op::ConvT2(c) => {
let a = shape_of(c.src)?;
if c.cin != a.channels || c.cout == 0 || c.cout > MAX_CHANNELS {
return invalid(format!("layer {i}: a transposed convolution of {} to {} channels does not fit", c.cin, c.cout));
}
check_weights(i, &c.weight, c.cin.checked_mul(c.cout).and_then(|n| n.checked_mul(4)), &c.bias, c.cout, c.leaky, &mut weights)?;
Shape { channels: c.cout, level: a.level - 1 }
}
Op::MaxPool2 { src } => {
let a = shape_of(*src)?;
Shape { channels: a.channels, level: a.level + 1 }
}
Op::DepthToSpace2 { src } => {
let a = shape_of(*src)?;
if !a.channels.is_multiple_of(4) {
return invalid(format!("layer {i}: depth-to-space needs a multiple of 4 channels"));
}
Shape { channels: a.channels / 4, level: a.level - 1 }
}
};
shapes.push(out);
}
Ok(Net { in_channels, ops, shapes })
}
/// The layers: layer `i` makes tensor `i + 1`.
pub fn ops(&self) -> &[Op] {
&self.ops
}
/// The shape of tensor `t` (0 is the input).
pub fn shape(&self, t: TensorId) -> Option<Shape> {
self.shapes.get(t).copied()
}
/// The tensor the network gives (the last layer's).
pub fn output(&self) -> TensorId {
self.ops.len()
}
pub fn out_channels(&self) -> usize {
self.shapes.last().map_or(0, |s| s.channels)
}
/// The deepest resolution level: a tile must be a multiple of `2^this` cells wide.
pub fn depth(&self) -> u32 {
self.shapes.iter().map(|s| s.level.max(0) as u32).max().unwrap_or(0)
}
/// Can the network run on a `tile × tile` input and give `[out_channels, 2·tile, 2·tile]`? (Pooling must divide
/// the tile, and the output must be one level above the input.)
pub fn fits_tile(&self, tile: usize) -> bool {
let Some(multiple) = 1usize.checked_shl(self.depth()) else { return false };
tile > 0 && tile.is_multiple_of(multiple) && self.shapes.last().is_some_and(|s| s.level == -1)
}
/// Multiply-adds for one tile, for estimating time.
pub fn macs(&self, tile: usize) -> u64 {
let pixels = |level: i32| -> u64 {
let side = if level >= 0 {
tile.checked_shr(level as u32).unwrap_or(0)
} else {
tile.saturating_mul(1usize.checked_shl(level.unsigned_abs()).unwrap_or(usize::MAX))
};
(side as u64).saturating_mul(side as u64)
};
self.ops
.iter()
.enumerate()
.map(|(i, op)| {
let out = self.shapes.get(i + 1).map_or(0, |s| pixels(s.level));
match op {
Op::Conv(c) => out.saturating_mul((c.cin * c.cout * c.k * c.k) as u64),
// every input pixel makes a 2 × 2 block: as many multiply-adds as the 2 × 2-wide kernel has weights
Op::ConvT2(c) => pixels(self.shapes.get(c.src).map_or(0, |s| s.level)).saturating_mul((c.cin * c.cout * 4) as u64),
_ => 0,
}
})
.fold(0u64, u64::saturating_add)
}
}
fn check_weights(
layer: usize,
weight: &[f32],
want: Option<usize>,
bias: &[f32],
cout: usize,
leaky: Option<f32>,
total: &mut usize,
) -> Result<(), NetError> {
if want != Some(weight.len()) || bias.len() != cout {
return invalid(format!("layer {layer}: the weights are not the size its shape needs"));
}
*total = total.saturating_add(weight.len()).saturating_add(bias.len());
if *total > MAX_WEIGHTS {
return invalid("the network has too many weights");
}
if weight.iter().chain(bias).any(|v| !v.is_finite()) || leaky.is_some_and(|a| !a.is_finite()) {
return invalid(format!("layer {layer}: a weight is not a finite number"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn conv(src: TensorId, src2: Option<TensorId>, cin: usize, cout: usize, k: usize) -> Op {
Op::Conv(Conv { src, src2, cin, cout, k, weight: vec![0.1; cout * cin * k * k], bias: vec![0.0; cout], leaky: Some(0.2) })
}
fn convt(src: TensorId, cin: usize, cout: usize) -> Op {
Op::ConvT2(ConvT2 { src, cin, cout, weight: vec![0.1; cin * cout * 4], bias: vec![0.0; cout], leaky: None })
}
/// A two-level network of the shape the denoisers have: 4 → 8 channels, pool, 16 channels, up, join, 12, and the
/// 1 × 1 convolution to the colours.
fn unet() -> Vec<Op> {
vec![
conv(0, None, 4, 8, 3), // 1: 8 ch, level 0
Op::MaxPool2 { src: 1 }, // 2: level 1
conv(2, None, 8, 16, 3), // 3: 16 ch, level 1
convt(3, 16, 8), // 4: 8 ch, level 0
conv(4, Some(1), 16, 8, 3), // 5: join [up, skip]
conv(5, None, 8, 12, 1), // 6: 12 ch
Op::DepthToSpace2 { src: 6 }, // 7: 3 ch, level -1
]
}
#[test]
fn deep_networks_and_huge_cost_queries_never_overflow_or_truncate_depth() {
let n = Net::new(4, unet()).unwrap();
assert_eq!(n.macs(usize::MAX), u64::MAX);
assert_eq!(n.macs(0), 0);
let mut ops = Vec::new();
for i in 0..21 {
ops.push(Op::MaxPool2 { src: i });
}
for i in 21..42 {
ops.push(convt(i, 4, 4));
}
ops.push(conv(42, None, 4, 12, 1));
ops.push(Op::DepthToSpace2 { src: 43 });
let n = Net::new(4, ops).unwrap();
assert!(!n.fits_tile(1 << 20));
assert!(n.fits_tile(1 << 21));
let _ = n.macs(16);
}
#[test]
fn a_u_net_is_described_with_its_shapes_and_costs() {
let net = Net::new(4, unet()).unwrap();
assert_eq!(net.out_channels(), 3);
assert_eq!(net.shape(3), Some(Shape { channels: 16, level: 1 }));
assert_eq!(net.shape(7), Some(Shape { channels: 3, level: -1 }));
assert_eq!(net.depth(), 1);
assert!(net.fits_tile(64) && !net.fits_tile(63) && !net.fits_tile(0));
// 64 × 64 cells: layer 1 = 4096·4·8·9, and so on; just check it is the sum of what the layers cost
let t = 64u64;
let want = t * t * 4 * 8 * 9 + (t / 2) * (t / 2) * 8 * 16 * 9 + (t / 2) * (t / 2) * 16 * 8 * 4 + t * t * 16 * 8 * 9 + t * t * 8 * 12;
assert_eq!(net.macs(64), want);
}
#[test]
fn a_network_that_does_not_chain_is_refused() {
// wrong input channel count for the first convolution
assert!(Net::new(3, unet()).is_err());
// a layer reading a tensor that does not exist yet
let mut ops = unet();
ops[2] = conv(9, None, 8, 16, 3);
assert!(Net::new(4, ops).is_err());
// joining tensors of different resolutions
let mut ops = unet();
ops[4] = conv(3, Some(1), 24, 8, 3);
assert!(Net::new(4, ops).is_err());
// weights of the wrong size, and not finite
let mut ops = unet();
if let Op::Conv(c) = &mut ops[0] {
c.weight.pop();
}
assert!(Net::new(4, ops).is_err());
let mut ops = unet();
if let Op::Conv(c) = &mut ops[0] {
c.bias[0] = f32::NAN;
}
assert!(Net::new(4, ops).is_err());
// a kernel that is neither 1 nor 3 wide, no layers, depth-to-space on a channel count it cannot split
let mut ops = unet();
if let Op::Conv(c) = &mut ops[0] {
c.k = 5;
}
assert!(Net::new(4, ops).is_err());
assert!(Net::new(4, vec![]).is_err());
assert!(Net::new(4, vec![Op::DepthToSpace2 { src: 0 }]).is_ok());
assert!(Net::new(4, vec![Op::DepthToSpace2 { src: 0 }, Op::DepthToSpace2 { src: 1 }]).is_err());
assert!(Net::new(2, vec![Op::DepthToSpace2 { src: 0 }]).is_err());
}
#[test]
fn a_network_that_does_not_end_one_level_up_does_not_fit_a_tile() {
let net = Net::new(4, unet()[..6].to_vec()).unwrap();
assert!(!net.fits_tile(64), "no depth-to-space: the output is not at twice the resolution");
}
}

369
crates/denoise/src/onnx.rs Normal file
View File

@ -0,0 +1,369 @@
//! Reading an `.onnx` file into a [`Net`] with bounded, pure-Rust protobuf decoding.
//!
//! Only what the denoise U-Nets are made of is understood: `Conv` (3 × 3 "same" or 1 × 1, stride 1, no groups, no
//! dilation) optionally followed by `LeakyRelu`, `ConvTranspose` (2 × 2, stride 2) likewise, `MaxPool` (2 × 2, stride 2),
//! a two-input `Concat` on the channels that feeds a convolution, and a final `DepthToSpace` (2, `CRD`). Any other
//! operator or attribute is [`NetError::Unsupported`], which is reported to the user by both runners.
use std::collections::HashMap;
use std::path::Path;
use crate::onnx_proto::{self, AttributeProto, NodeProto, TensorProto, ValueInfoProto};
use std::io::Read;
use crate::net::{Conv, ConvT2, MAX_WEIGHTS, Net, NetError, Op, TensorId};
fn unsupported<T>(why: impl Into<String>) -> Result<T, NetError> {
Err(NetError::Unsupported(why.into()))
}
fn invalid<T>(why: impl Into<String>) -> Result<T, NetError> {
Err(NetError::Invalid(why.into()))
}
/// Read the model at `path`.
pub fn read(path: &Path) -> Result<Net, NetError> {
let file = std::fs::File::open(path).map_err(|e| NetError::Invalid(e.to_string()))?;
let size = usize::try_from(file.metadata().map_err(|e| NetError::Invalid(e.to_string()))?.len())
.ok()
.filter(|&n| n <= onnx_proto::MAX_BYTES)
.ok_or_else(|| NetError::Invalid("model file too large".into()))?;
let mut bytes = Vec::new();
bytes.try_reserve_exact(size).map_err(|_| NetError::Invalid("not enough memory to load the model".into()))?;
file.take(onnx_proto::MAX_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(|e| NetError::Invalid(e.to_string()))?;
read_bytes(&bytes)
}
fn read_bytes(bytes: &[u8]) -> Result<Net, NetError> {
let graph = onnx_proto::decode(bytes)?;
let initialisers: HashMap<&str, &TensorProto> = graph.initializer.iter().map(|t| (t.name.as_str(), t)).collect();
if initialisers.len() != graph.initializer.len() {
return invalid("duplicate initializer names");
}
let inputs: Vec<_> = graph.input.iter().filter(|i| !initialisers.contains_key(i.name.as_str())).collect();
let ([input], [output]) = (inputs.as_slice(), graph.output.as_slice()) else { return unsupported("not one input and one output") };
let in_channels = match channel_dim(input) {
Some(c) => c,
None => return unsupported("the input's channel count is not written in the file"),
};
// how many nodes read each name (a graph output counts as a reader)
let mut readers: HashMap<&str, usize> = HashMap::new();
for n in &graph.node {
for i in &n.input {
*readers.entry(i.as_str()).or_default() += 1;
}
}
*readers.entry(output.name.as_str()).or_default() += 1;
let mut ids: HashMap<String, TensorId> = HashMap::new();
ids.insert(input.name.clone(), 0);
// a Concat is not a layer of its own: the convolution that reads it takes both sources
let mut joins: HashMap<String, (TensorId, TensorId)> = HashMap::new();
let mut ops: Vec<Op> = Vec::new();
let mut weights_total = 0usize;
let nodes = &graph.node;
let mut i = 0;
while i < nodes.len() {
let Some(node) = nodes.get(i) else { break };
i += 1;
if node.output.len() != 1 || node.output.first().is_none_or(|n| n.is_empty() || ids.contains_key(n) || joins.contains_key(n)) {
return invalid("a layer has missing, repeated or multiple outputs");
}
let allowed: &[&str] = match node.op_type.as_str() {
"Conv" => &["kernel_shape", "pads", "strides", "dilations", "group", "auto_pad"],
"ConvTranspose" => &["kernel_shape", "pads", "strides", "dilations", "group", "output_padding", "auto_pad"],
"MaxPool" => &["kernel_shape", "pads", "strides", "dilations", "ceil_mode", "storage_order", "auto_pad"],
"Concat" => &["axis"],
"DepthToSpace" => &["blocksize", "mode"],
_ => &[],
};
if node.attribute.iter().any(|a| !allowed.contains(&a.name.as_str())) {
return unsupported("an unknown operator attribute");
}
let this = ops.len() + 1;
let lookup = |name: &str| -> Result<TensorId, NetError> {
ids.get(name).copied().ok_or_else(|| NetError::Invalid(format!("`{}` reads `{name}`, which nothing makes", node.op_type)))
};
// a LeakyRelu straight after this layer, which nothing else reads, is part of it
let mut fuse_leaky = |out: &str| -> Result<(Option<f32>, String), NetError> {
if let Some(next) = nodes.get(i)
&& next.op_type == "LeakyRelu"
&& next.input.first().map(String::as_str) == Some(out)
&& readers.get(out).copied() == Some(1)
{
if next.input.len() != 1 || next.output.len() != 1 || next.attribute.iter().any(|a| a.name != "alpha") {
return invalid("malformed LeakyRelu");
}
let alpha = attr(next, "alpha").map_or(0.01, |a| a.f);
i += 1;
let name = next.output.first().cloned().ok_or_else(|| NetError::Invalid("LeakyRelu without an output".into()))?;
return Ok((Some(alpha), name));
}
Ok((None, out.to_string()))
};
match node.op_type.as_str() {
"Concat" => {
if attr(node, "axis").map(|a| a.i) != Some(1) {
return unsupported("Concat on another axis than the channels");
}
let [a, b] = node.input.as_slice() else { return unsupported("Concat of other than two tensors") };
let out = node.output.first().ok_or_else(|| NetError::Invalid("Concat without an output".into()))?;
if readers.get(out.as_str()).copied() != Some(1) {
return unsupported("a Concat that is read by more than one layer");
}
joins.insert(out.clone(), (lookup(a)?, lookup(b)?));
}
"Conv" => {
let (w, b) = weights_of(node, &initialisers, &mut weights_total)?;
let (cout, cin, kh, kw) = four(&w.0)?;
let b = if b.is_empty() { vec![0.0; cout] } else { b };
if kh != kw || !(kh == 1 || kh == 3) {
return unsupported(format!("a {kh} × {kw} convolution"));
}
if ints_or(node, "strides", &[1, 1]) != [1, 1]
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|| attr(node, "group").is_some_and(|a| a.i != 1)
{
return unsupported("a convolution with strides, dilation or groups");
}
if attr(node, "kernel_shape").is_some_and(|a| a.ints != [kh as i64, kw as i64]) {
return invalid("kernel shape does not match the weights");
}
let pad = (kh as i64 - 1) / 2;
if ints_or(node, "pads", &[0; 4]) != [pad; 4] || attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET") {
return unsupported("a convolution that is not padded to keep its size");
}
let src_name = node.input.first().ok_or_else(|| NetError::Invalid("Conv without an input".into()))?;
let (src, src2) = match joins.remove(src_name) {
Some((a, b)) => (a, Some(b)),
None => (lookup(src_name)?, None),
};
let out = node.output.first().ok_or_else(|| NetError::Invalid("Conv without an output".into()))?;
let (leaky, out_name) = fuse_leaky(out)?;
ids.insert(out_name, this);
ops.push(Op::Conv(Conv { src, src2, cin, cout, k: kh, weight: w.1, bias: b, leaky }));
}
"ConvTranspose" => {
let (w, b) = weights_of(node, &initialisers, &mut weights_total)?;
let (cin, cout, kh, kw) = four(&w.0)?;
let b = if b.is_empty() { vec![0.0; cout] } else { b };
if (kh, kw) != (2, 2)
|| ints_or(node, "strides", &[1, 1]) != [2, 2]
|| ints_or(node, "pads", &[0; 4]) != [0; 4]
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|| attr(node, "group").is_some_and(|a| a.i != 1)
|| attr(node, "output_padding").is_some_and(|a| a.ints.iter().any(|&v| v != 0))
{
return unsupported("a transposed convolution other than 2 × 2 with stride 2");
}
if attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET") {
return unsupported("transposed convolution auto padding");
}
if attr(node, "kernel_shape").is_some_and(|a| a.ints != [2, 2]) {
return invalid("transposed kernel shape does not match the weights");
}
let src = lookup(node.input.first().map_or("", String::as_str))?;
let out = node.output.first().ok_or_else(|| NetError::Invalid("ConvTranspose without an output".into()))?;
let (leaky, out_name) = fuse_leaky(out)?;
ids.insert(out_name, this);
ops.push(Op::ConvT2(ConvT2 { src, cin, cout, weight: w.1, bias: b, leaky }));
}
"MaxPool" => {
if ints_or(node, "kernel_shape", &[]) != [2, 2]
|| ints_or(node, "strides", &[1, 1]) != [2, 2]
|| ints_or(node, "pads", &[0; 4]) != [0; 4]
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|| attr(node, "ceil_mode").is_some_and(|a| a.i != 0)
|| attr(node, "storage_order").is_some_and(|a| a.i != 0)
|| attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET")
{
return unsupported("a max-pool other than 2 × 2 with stride 2");
}
let src = lookup(node.input.first().map_or("", String::as_str))?;
let out = node.output.first().ok_or_else(|| NetError::Invalid("MaxPool without an output".into()))?;
ids.insert(out.clone(), this);
ops.push(Op::MaxPool2 { src });
}
"DepthToSpace" => {
if attr(node, "blocksize").map(|a| a.i) != Some(2) || attr(node, "mode").map(|a| a.s.as_slice()) != Some(&b"CRD"[..]) {
return unsupported("depth-to-space other than block size 2 in CRD order");
}
let src = lookup(node.input.first().map_or("", String::as_str))?;
let out = node.output.first().ok_or_else(|| NetError::Invalid("DepthToSpace without an output".into()))?;
ids.insert(out.clone(), this);
ops.push(Op::DepthToSpace2 { src });
}
other => return unsupported(format!("the `{other}` operator")),
}
}
if !joins.is_empty() {
return unsupported("a Concat whose result no convolution reads");
}
if ids.get(output.name.as_str()) != Some(&ops.len()) {
return unsupported("the output is not the last layer");
}
Net::new(in_channels, ops)
}
fn attr<'a>(n: &'a NodeProto, name: &str) -> Option<&'a AttributeProto> {
n.attribute.iter().find(|a| a.name == name)
}
/// The integer list attribute `name`, or `default` when the node does not have it.
fn ints_or(n: &NodeProto, name: &str, default: &[i64]) -> Vec<i64> {
attr(n, name).map_or_else(|| default.to_vec(), |a| a.ints.clone())
}
fn four(dims: &[i64]) -> Result<(usize, usize, usize, usize), NetError> {
match dims {
[a, b, c, d] if dims.iter().all(|&v| v > 0 && v <= 1 << 20) => Ok((*a as usize, *b as usize, *c as usize, *d as usize)),
_ => invalid("a weight tensor that is not four-dimensional"),
}
}
type Weights = ((Vec<i64>, Vec<f32>), Vec<f32>);
/// The weight and bias initialisers of a convolution node.
fn weights_of(node: &NodeProto, initialisers: &HashMap<&str, &TensorProto>, total: &mut usize) -> Result<Weights, NetError> {
let mut get = |idx: usize| -> Result<(Vec<i64>, Vec<f32>), NetError> {
let name = node.input.get(idx).ok_or_else(|| NetError::Invalid(format!("`{}` without its weights", node.op_type)))?;
let t = initialisers.get(name.as_str()).ok_or_else(|| NetError::Unsupported(format!("weights that are computed (`{name}`)")))?;
let values = floats(t)?;
*total = total.saturating_add(values.len());
if *total > MAX_WEIGHTS {
return invalid("the network has too many weights");
}
Ok((t.dims.clone(), values))
};
let w = get(1)?;
let b = if node.input.len() > 2 {
let (dims, values) = get(2)?;
if dims.len() != 1 || values.is_empty() {
return invalid("a bias tensor must be a nonempty vector");
}
values
} else {
Vec::new()
};
Ok((w, b))
}
fn floats(t: &TensorProto) -> Result<Vec<f32>, NetError> {
if t.data_type != 1 {
return unsupported("weights that are not 32-bit floats");
}
let want: usize = t
.dims
.iter()
.try_fold(1usize, |n, &d| usize::try_from(d).ok().and_then(|d| n.checked_mul(d)))
.ok_or_else(|| NetError::Invalid("a tensor with a negative or huge size".into()))?;
if want > MAX_WEIGHTS {
return invalid("a tensor with too many numbers");
}
if !t.raw_data.is_empty() && (!t.float_data.is_empty() || !t.raw_data.len().is_multiple_of(4)) {
return invalid("ambiguous or truncated tensor data");
}
let actual = if t.raw_data.is_empty() { t.float_data.len() } else { t.raw_data.len() / 4 };
if actual != want {
return invalid("tensor data does not match its dimensions");
}
let mut values = Vec::new();
values.try_reserve_exact(want).map_err(|_| NetError::Invalid("not enough memory for weights".into()))?;
if !t.raw_data.is_empty() {
values.extend(t.raw_data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)));
} else {
values.extend_from_slice(&t.float_data);
}
if values.len() != want {
return invalid(format!("tensor `{}` has {} numbers, not the {want} its shape says", t.name, values.len()));
}
Ok(values)
}
/// The channel count in an input's declared shape `[batch, channels, …]`.
fn channel_dim(v: &ValueInfoProto) -> Option<usize> {
v.channels
}
#[cfg(test)]
mod tests {
use super::*;
use crate::synthetic::unet_onnx;
fn temp_model(name: &str, bytes: &[u8]) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!("lc-denoise-onnx-{name}-{}.onnx", std::process::id()));
std::fs::write(&p, bytes).unwrap();
p
}
#[test]
fn a_u_net_is_read_with_its_layers_fused() {
let (tile, depth) = (32usize, 2usize);
let p = temp_model("unet", &unet_onnx(tile as u64, 8, depth, 7));
let net = read(&p).unwrap();
// per level two convolutions; a pool per level but the last; going up a transposed convolution and two convolutions
// per level (the join is part of the first); then the 1 × 1 convolution and depth-to-space
assert_eq!(net.ops().len(), 2 * (depth + 1) + depth + depth * 3 + 2);
assert!(matches!(net.ops().last(), Some(crate::net::Op::DepthToSpace2 { .. })));
assert!(net.ops().iter().any(|o| matches!(o, crate::net::Op::Conv(c) if c.src2.is_some())), "the joins became two-source convolutions");
assert!(
net.ops().iter().all(|o| !matches!(o, crate::net::Op::Conv(c) if c.leaky.is_none() && c.k == 3)),
"every 3 × 3 convolution has its activation"
);
assert_eq!((net.in_channels, net.out_channels(), net.depth()), (4, 3, 2));
assert!(net.fits_tile(tile) && !net.fits_tile(30));
let _ = std::fs::remove_file(p);
}
#[test]
fn an_explicit_empty_or_nonvector_bias_is_not_treated_as_missing() {
let w = TensorProto { name: "w".into(), dims: vec![12, 4, 1, 1], data_type: 1, float_data: vec![0.1; 48], ..Default::default() };
let n = NodeProto { input: vec!["data".into(), "w".into(), "b".into()], ..Default::default() };
for dims in [vec![0], vec![3, 4]] {
let b = TensorProto {
name: "b".into(),
float_data: vec![0.0; dims.iter().product::<i64>() as usize],
dims,
data_type: 1,
..Default::default()
};
let inits = HashMap::from([("w", &w), ("b", &b)]);
assert!(weights_of(&n, &inits, &mut 0).is_err());
}
}
#[test]
fn truncated_and_mutated_protobuf_is_bounded_and_never_panics() {
let good = unet_onnx(4, 3, 1, 7);
for n in (0..good.len()).step_by(31) {
let _ = read_bytes(&good[..n]);
}
for at in (0..good.len()).step_by(37) {
let mut bad = good.clone();
bad[at] ^= 0xff;
let _ = read_bytes(&bad);
}
assert!(read_bytes(&[0x3a, 0xff, 0xff, 0xff, 0xff, 0x7f]).is_err());
assert!(read_bytes(&[0x3a, 0, 0x3a, 0]).is_err());
}
proptest::proptest! {
#[test]
fn arbitrary_model_bytes_never_panic(bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..4096)) {
let _ = read_bytes(&bytes);
}
}
#[test]
fn what_the_description_cannot_say_is_unsupported_and_garbage_is_an_error() {
// not a model
let p = temp_model("junk", b"this is not an onnx file");
assert!(matches!(read(&p), Err(NetError::Invalid(_)) | Err(NetError::Unsupported(_))));
let _ = std::fs::remove_file(p);
// a missing file
assert!(read(Path::new("/does/not/exist.onnx")).is_err());
}
}

View File

@ -0,0 +1,295 @@
//! Bounded, borrowed decoding of the ONNX fields used by denoise, from the public Apache-2.0 schema:
//! https://github.com/onnx/onnx/blob/main/onnx/onnx.proto3
//! No generated code, protoc, recursive graph decoding, or external tensor files.
use crate::net::{MAX_OPS, MAX_WEIGHTS, NetError};
const MAX_ITEMS: usize = MAX_OPS * 4;
const MAX_STRING: usize = 4096;
pub(super) const MAX_BYTES: usize = MAX_WEIGHTS * 4 + 16 * 1024 * 1024;
type Result<T> = std::result::Result<T, NetError>;
fn bad() -> NetError {
NetError::Invalid("malformed or oversized ONNX protobuf".into())
}
struct Fields<'a> {
rest: &'a [u8],
count: usize,
}
enum Wire<'a> {
Int(u64),
Bytes(&'a [u8]),
F32(f32),
Other,
}
fn varint(rest: &mut &[u8]) -> Result<u64> {
let mut value = 0u64;
for shift in (0..70).step_by(7) {
let (b, tail) = rest.split_first().ok_or_else(bad)?;
*rest = tail;
if shift == 63 && *b > 1 {
return Err(bad());
}
value |= u64::from(*b & 127) << shift;
if b & 128 == 0 {
return Ok(value);
}
}
Err(bad())
}
impl<'a> Fields<'a> {
fn new(rest: &'a [u8]) -> Self {
Self { rest, count: 0 }
}
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
let out = self.rest.get(..n).ok_or_else(bad)?;
self.rest = self.rest.get(n..).ok_or_else(bad)?;
Ok(out)
}
fn next(&mut self) -> Result<Option<(u32, Wire<'a>)>> {
if self.rest.is_empty() {
return Ok(None);
}
self.count += 1;
if self.count > MAX_WEIGHTS {
return Err(bad());
}
let tag = varint(&mut self.rest)?;
let number = u32::try_from(tag >> 3).map_err(|_| bad())?;
if number == 0 || number >= 1 << 29 {
return Err(bad());
}
let wire = match tag & 7 {
0 => Wire::Int(varint(&mut self.rest)?),
1 => {
self.take(8)?;
Wire::Other
}
2 => {
let n = usize::try_from(varint(&mut self.rest)?).map_err(|_| bad())?;
Wire::Bytes(self.take(n)?)
}
5 => {
let b: [u8; 4] = self.take(4)?.try_into().map_err(|_| bad())?;
Wire::F32(f32::from_le_bytes(b))
}
_ => return Err(bad()),
};
Ok(Some((number, wire)))
}
}
fn string(b: &[u8]) -> Result<String> {
if b.len() > MAX_STRING {
return Err(bad());
}
std::str::from_utf8(b).map(str::to_owned).map_err(|_| bad())
}
fn push<T>(v: &mut Vec<T>, value: T, cap: usize) -> Result<()> {
if v.len() >= cap {
return Err(bad());
}
v.try_reserve(1).map_err(|_| bad())?;
v.push(value);
Ok(())
}
fn ints(w: Wire<'_>, out: &mut Vec<i64>, cap: usize) -> Result<()> {
match w {
Wire::Int(v) => push(out, v as i64, cap),
Wire::Bytes(mut b) => {
while !b.is_empty() {
let v = varint(&mut b)?;
push(out, v as i64, cap)?;
}
Ok(())
}
_ => Err(bad()),
}
}
#[derive(Default)]
pub(super) struct AttributeProto {
pub name: String,
pub f: f32,
pub i: i64,
pub s: Vec<u8>,
pub ints: Vec<i64>,
}
#[derive(Default)]
pub(super) struct NodeProto {
pub input: Vec<String>,
pub output: Vec<String>,
pub op_type: String,
pub attribute: Vec<AttributeProto>,
}
#[derive(Default)]
pub(super) struct TensorProto<'a> {
pub name: String,
pub dims: Vec<i64>,
pub data_type: i32,
pub raw_data: &'a [u8],
pub float_data: Vec<f32>,
}
pub(super) struct ValueInfoProto {
pub name: String,
pub channels: Option<usize>,
}
#[derive(Default)]
pub(super) struct Graph<'a> {
pub node: Vec<NodeProto>,
pub initializer: Vec<TensorProto<'a>>,
pub input: Vec<ValueInfoProto>,
pub output: Vec<ValueInfoProto>,
}
fn attribute(b: &[u8]) -> Result<AttributeProto> {
let mut a = AttributeProto::default();
let mut fields = Fields::new(b);
while let Some((n, w)) = fields.next()? {
match (n, w) {
(1, Wire::Bytes(b)) => a.name = string(b)?,
(2, Wire::F32(v)) => a.f = v,
(3, Wire::Int(v)) => a.i = v as i64,
(4, Wire::Bytes(b)) => {
if b.len() > MAX_STRING {
return Err(bad());
}
a.s = b.to_vec();
}
(8, w) => ints(w, &mut a.ints, 16)?,
(20, Wire::Int(1 | 2 | 3 | 7)) => {}
_ => return Err(NetError::Unsupported("unknown attribute value".into())),
}
}
Ok(a)
}
fn node(b: &[u8]) -> Result<NodeProto> {
let mut a = NodeProto::default();
let mut fields = Fields::new(b);
while let Some((n, w)) = fields.next()? {
match (n, w) {
(1, Wire::Bytes(b)) => push(&mut a.input, string(b)?, 4)?,
(2, Wire::Bytes(b)) => push(&mut a.output, string(b)?, 2)?,
(4, Wire::Bytes(b)) => a.op_type = string(b)?,
(5, Wire::Bytes(b)) => push(&mut a.attribute, attribute(b)?, 16)?,
(7, Wire::Bytes(b)) if !b.is_empty() => return Err(NetError::Unsupported("a custom operator domain".into())),
_ => {}
}
}
Ok(a)
}
fn tensor(b: &[u8]) -> Result<TensorProto<'_>> {
let mut a = TensorProto::default();
let mut fields = Fields::new(b);
while let Some((n, w)) = fields.next()? {
match (n, w) {
(1, w) => ints(w, &mut a.dims, 8)?,
(2, Wire::Int(v)) => a.data_type = i32::try_from(v).map_err(|_| bad())?,
(4, Wire::F32(v)) => push(&mut a.float_data, v, MAX_WEIGHTS)?,
(4, Wire::Bytes(b)) => {
if !b.len().is_multiple_of(4) {
return Err(bad());
}
for chunk in b.as_chunks::<4>().0 {
push(&mut a.float_data, f32::from_le_bytes(*chunk), MAX_WEIGHTS)?;
}
}
(8, Wire::Bytes(b)) => a.name = string(b)?,
(9, Wire::Bytes(b)) => {
if b.len() > MAX_WEIGHTS * 4 {
return Err(bad());
}
a.raw_data = b;
}
(13, _) | (14, Wire::Int(1..)) => return Err(NetError::Unsupported("external tensor data".into())),
_ => {}
}
}
Ok(a)
}
fn channel_shape(b: &[u8]) -> Result<Option<usize>> {
let mut fields = Fields::new(b);
let mut dim = 0;
let mut channels = None;
while let Some((n, w)) = fields.next()? {
if let (1, Wire::Bytes(b)) = (n, w) {
if dim >= 8 {
return Err(bad());
}
let mut df = Fields::new(b);
while let Some((n, w)) = df.next()? {
if let (1, Wire::Int(v)) = (n, w)
&& dim == 1
{
channels = usize::try_from(v).ok().filter(|&v| v > 0);
}
}
dim += 1;
}
}
Ok(channels)
}
fn channel_type(b: &[u8]) -> Result<Option<usize>> {
let mut fields = Fields::new(b);
while let Some((n, w)) = fields.next()? {
if let (1, Wire::Bytes(b)) = (n, w) {
let mut tf = Fields::new(b);
let (mut elem, mut channels) = (0, None);
while let Some((n, w)) = tf.next()? {
match (n, w) {
(1, Wire::Int(v)) => elem = v,
(2, Wire::Bytes(b)) => channels = channel_shape(b)?,
_ => {}
}
}
return Ok(if elem == 1 { channels } else { None });
}
}
Ok(None)
}
fn value_info(b: &[u8]) -> Result<ValueInfoProto> {
let mut out = ValueInfoProto { name: String::new(), channels: None };
let mut fields = Fields::new(b);
while let Some((n, w)) = fields.next()? {
match (n, w) {
(1, Wire::Bytes(b)) => out.name = string(b)?,
(2, Wire::Bytes(b)) => out.channels = channel_type(b)?,
_ => {}
}
}
Ok(out)
}
fn graph(b: &[u8]) -> Result<Graph<'_>> {
let mut out = Graph::default();
let mut fields = Fields::new(b);
while let Some((n, w)) = fields.next()? {
match (n, w) {
(1, Wire::Bytes(b)) => push(&mut out.node, node(b)?, MAX_ITEMS)?,
(5, Wire::Bytes(b)) => push(&mut out.initializer, tensor(b)?, MAX_ITEMS)?,
(11, Wire::Bytes(b)) => push(&mut out.input, value_info(b)?, MAX_ITEMS)?,
(12, Wire::Bytes(b)) => push(&mut out.output, value_info(b)?, 1)?,
(15, _) => return Err(NetError::Unsupported("sparse initializers".into())),
_ => {}
}
}
Ok(out)
}
pub(super) fn decode(b: &[u8]) -> Result<Graph<'_>> {
if b.len() > MAX_BYTES {
return Err(bad());
}
let mut fields = Fields::new(b);
let mut out = None;
while let Some((n, w)) = fields.next()? {
if let (7, Wire::Bytes(b)) = (n, w) {
if out.is_some() {
return Err(bad());
}
out = Some(graph(b)?);
}
}
out.ok_or_else(bad)
}

View File

@ -0,0 +1,133 @@
//! A plain-loop interpreter of a [`Net`], slow and obviously right: the reference the parser and the GPU runner are
//! tested against (tract's answer on the real model is the other). It is not used on photos.
use crate::net::{Net, NetError, Op, TensorId};
use crate::run::{Error, TileRunner};
/// A tensor in `[channels][side][side]` order.
struct T {
channels: usize,
side: usize,
data: Vec<f32>,
}
/// Run `net` on `input` (`in_channels` planes of `tile × tile`).
pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError> {
if !net.fits_tile(tile) || input.len() != net.in_channels * tile * tile {
return Err(NetError::Invalid("the tile does not fit the network".into()));
}
let mut tensors: Vec<T> = vec![T { channels: net.in_channels, side: tile, data: input.to_vec() }];
let get = |tensors: &[T], id: TensorId| -> Result<usize, NetError> {
if id < tensors.len() { Ok(id) } else { Err(NetError::Invalid("a layer reads a tensor that does not exist yet".into())) }
};
for op in net.ops() {
let out = match op {
Op::Conv(c) => {
let a = get(&tensors, c.src)?;
let b = c.src2.map(|s| get(&tensors, s)).transpose()?;
let side = tensors[a].side;
let pad = (c.k - 1) / 2;
let mut data = vec![0f32; c.cout * side * side];
// the input channels in order: the first source's, then the second's
let planes: Vec<&[f32]> =
std::iter::once(&tensors[a]).chain(b.map(|b| &tensors[b])).flat_map(|t| t.data.chunks(side * side)).collect();
for co in 0..c.cout {
for y in 0..side {
for x in 0..side {
let mut sum = c.bias[co];
for (ci, plane) in planes.iter().enumerate() {
for ky in 0..c.k {
for kx in 0..c.k {
let (yy, xx) = ((y + ky) as isize - pad as isize, (x + kx) as isize - pad as isize);
if yy < 0 || xx < 0 || yy >= side as isize || xx >= side as isize {
continue;
}
sum += plane[yy as usize * side + xx as usize] * c.weight[((co * c.cin + ci) * c.k + ky) * c.k + kx];
}
}
}
data[(co * side + y) * side + x] = leaky(sum, c.leaky);
}
}
}
T { channels: c.cout, side, data }
}
Op::ConvT2(c) => {
let a = get(&tensors, c.src)?;
let side = tensors[a].side;
let out_side = 2 * side;
let mut data = vec![0f32; c.cout * out_side * out_side];
for co in 0..c.cout {
for y in 0..side {
for x in 0..side {
for dy in 0..2 {
for dx in 0..2 {
let mut sum = c.bias[co];
for ci in 0..c.cin {
sum += tensors[a].data[(ci * side + y) * side + x] * c.weight[((ci * c.cout + co) * 2 + dy) * 2 + dx];
}
data[(co * out_side + 2 * y + dy) * out_side + 2 * x + dx] = leaky(sum, c.leaky);
}
}
}
}
}
T { channels: c.cout, side: out_side, data }
}
Op::MaxPool2 { src } => {
let a = get(&tensors, *src)?;
let (channels, side) = (tensors[a].channels, tensors[a].side / 2);
let mut data = vec![0f32; channels * side * side];
for ch in 0..channels {
for y in 0..side {
for x in 0..side {
let at = |dy: usize, dx: usize| tensors[a].data[(ch * 2 * side + 2 * y + dy) * 2 * side + 2 * x + dx];
data[(ch * side + y) * side + x] = at(0, 0).max(at(0, 1)).max(at(1, 0)).max(at(1, 1));
}
}
}
T { channels, side, data }
}
Op::DepthToSpace2 { src } => {
let a = get(&tensors, *src)?;
let (channels, side) = (tensors[a].channels / 4, tensors[a].side);
let out_side = 2 * side;
let mut data = vec![0f32; channels * out_side * out_side];
for ch in 0..channels {
for y in 0..side {
for x in 0..side {
for dy in 0..2 {
for dx in 0..2 {
data[(ch * out_side + 2 * y + dy) * out_side + 2 * x + dx] =
tensors[a].data[((4 * ch + 2 * dy + dx) * side + y) * side + x];
}
}
}
}
}
T { channels, side: out_side, data }
}
};
tensors.push(out);
}
tensors.pop().map(|t| t.data).ok_or_else(|| NetError::Invalid("no layers".into()))
}
fn leaky(v: f32, alpha: Option<f32>) -> f32 {
match alpha {
Some(a) if v < 0.0 => v * a,
_ => v,
}
}
/// The reference as a [`TileRunner`] (for tests of the code around the model).
pub struct ReferenceRunner {
pub net: Net,
pub tile: usize,
}
impl TileRunner for ReferenceRunner {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
run(&self.net, self.tile, input).map_err(|e| Error::Runtime(e.to_string()))
}
}

View File

@ -0,0 +1,433 @@
//! Model loading, a shared installation self-test, and deterministic test tiles.
//! `CpuRunner` is the pure-Rust executor; scalar operator tests live in `reference`.
use std::path::Path;
use web_time::Instant;
use serde::Serialize;
use crate::manifest::{DenoiserManifest, Gain};
use crate::run::{Error, TileRunner};
/// What `self_test` found.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SelfTest {
pub ok: bool,
/// Milliseconds to load and optimise the model.
pub load_ms: f64,
/// Median milliseconds to run one tile.
pub tile_ms: f64,
/// Megapixels of picture one tile covers, for estimating a whole photo's time.
pub tile_megapixels: f64,
/// The model's output scale relative to its input on the test picture (what `Gain::MatchMean` takes out).
pub scale: f64,
/// Each check: what was checked and whether it held.
pub checks: Vec<(String, bool)>,
}
/// The default pure-Rust CPU model, with the same manifest and installation self-test as the reference.
#[derive(Clone)]
pub struct CpuRunner {
runner: crate::cpu::NetRunner,
manifest: DenoiserManifest,
load_ms: f64,
}
impl CpuRunner {
pub fn load(path: &Path, manifest: &DenoiserManifest) -> Result<Self, Error> {
crate::manifest::validate(manifest).map_err(|e| Error::Load(e.to_string()))?;
let started = Instant::now();
let net = crate::onnx::read(path).map_err(|e| Error::Load(e.to_string()))?;
let runner = crate::cpu::NetRunner::new(&net, manifest.tile as usize).map_err(|e| Error::Load(e.to_string()))?;
Ok(Self { runner, manifest: manifest.clone(), load_ms: started.elapsed().as_secs_f64() * 1e3 })
}
pub fn manifest(&self) -> &DenoiserManifest {
&self.manifest
}
pub fn net(&self) -> &crate::net::Net {
self.runner.net()
}
pub fn bytes_per_tile(&self) -> usize {
self.runner.bytes_per_tile()
}
pub fn self_test(&self) -> SelfTest {
self_test(self, &self.manifest, self.load_ms)
}
}
impl TileRunner for CpuRunner {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
self.runner.run(input)
}
}
fn self_test(runner: &dyn TileRunner, manifest: &DenoiserManifest, load_ms: f64) -> SelfTest {
let t = manifest.tile as usize;
let mut checks: Vec<(String, bool)> = Vec::new();
let (input, base) = test_tile(t);
let (mut times, mut scale) = (Vec::new(), 0.0f64);
let mut run = || {
let started = Instant::now();
let r = runner.run(&input);
times.push(started.elapsed().as_secs_f64() * 1000.0);
r
};
let (a, b) = (run(), run());
match (&a, &b) {
(Ok(a), Ok(b)) => {
checks.push((format!("gives {} finite numbers per tile", a.len()), true));
checks.push((
"gives the same answer for the same tile".into(),
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-4 * x.abs().max(y.abs()).max(1e-9)),
));
let side = 2 * t;
let mean_in = base.iter().map(|&v| f64::from(v)).sum::<f64>() / base.len().max(1) as f64;
let mean_out = a.iter().map(|&v| f64::from(v)).sum::<f64>() / a.len().max(1) as f64;
scale = if mean_in > 0.0 { mean_out / mean_in } else { 0.0 };
if let Gain::MatchMean { nominal, max_deviation } = manifest.gain {
let ratio = scale / f64::from(nominal);
let tolerance = f64::from(max_deviation).max(0.05) * 4.0;
checks.push((format!("its scale is the {nominal:.3e} the description says (found {scale:.3e})"), (ratio - 1.0).abs() <= tolerance));
}
// noise left after bringing the answer back to the input's scale, against the noise that went in
let g = if scale > 0.0 { 1.0 / scale } else { 1.0 };
let (mut noise_in, mut noise_out, mut n_in, mut n_out) = (0.0f64, 0.0f64, 0usize, 0usize);
for (i, (&v, &s)) in input.iter().zip(base.iter().cycle()).enumerate() {
// centre of each plane only: edges are the model's guess
let (x, y) = ((i % (t * t)) % t, (i % (t * t)) / t);
if x >= t / 4 && x < 3 * t / 4 && y >= t / 4 && y < 3 * t / 4 {
noise_in += f64::from(v - s).powi(2);
n_in += 1;
}
}
for c in 0..3 {
for y in side / 4..3 * side / 4 {
for x in side / 4..3 * side / 4 {
if let (Some(&o), Some(&s)) = (a.get(c * side * side + y * side + x), base.get((y / 2) * t + x / 2)) {
noise_out += (f64::from(o) * g - f64::from(s)).powi(2);
n_out += 1;
}
}
}
}
let (rms_in, rms_out) = ((noise_in / n_in.max(1) as f64).sqrt(), (noise_out / n_out.max(1) as f64).sqrt());
checks.push((format!("takes noise down (from {rms_in:.4} to {rms_out:.4})"), rms_out < 0.8 * rms_in));
}
(Err(e), _) | (_, Err(e)) => checks.push((format!("runs: {e}"), false)),
}
times.sort_by(|x, y| x.total_cmp(y));
SelfTest {
ok: !checks.is_empty() && checks.iter().all(|(_, ok)| *ok),
load_ms,
tile_ms: times.get(times.len() / 2).copied().unwrap_or(0.0),
tile_megapixels: (2 * t * 2 * t) as f64 / 1e6,
scale,
checks,
}
}
/// A deterministic test tile: four planes of a smooth gradient with noise on top, and the gradient alone for one
/// plane (`tile × tile`).
pub fn test_tile(t: usize) -> (Vec<f32>, Vec<f32>) {
if t == 0 || t > 2048 {
return (Vec::new(), Vec::new());
}
let mut seed = 0x2545_f491u32;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
(seed >> 8) as f32 / (1u32 << 24) as f32
};
let base: Vec<f32> = (0..t * t).map(|i| 0.12 + 0.3 * (((i % t) + (i / t)) as f32 / (2 * t) as f32)).collect();
let input = (0..4 * t * t)
.map(|i| {
// sum of four uniforms: close to Gaussian, deviation 0.0577 × 2 before scaling
let n = next() + next() + next() + next() - 2.0;
base.get(i % (t * t)).copied().unwrap_or(0.0) + 0.05 * n
})
.collect();
(input, base)
}
/// A tile for checking that two runners of one model agree: the whole range a raw photo has rather than the test tile's
/// mid-greys — a noisy ramp from black to white across the tile, a flat black corner and a clipped white one (four
/// planes of `tile × tile`).
pub fn check_tile(t: usize) -> Vec<f32> {
if t == 0 || t > 2048 {
return Vec::new();
}
let mut seed = 0x9e37_79b9u32;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
(seed >> 8) as f32 / (1u32 << 24) as f32
};
let (edge, span) = ((t / 8).max(1), (2 * t).saturating_sub(2).max(1) as f32);
(0..4 * t * t)
.map(|i| {
let (x, y) = ((i % (t * t)) % t, (i % (t * t)) / t);
let n = next() + next() + next() + next() - 2.0;
if x < edge && y < edge {
0.0
} else if x + edge >= t && y + edge >= t {
1.0
} else {
((x + y) as f32 / span + 0.05 * n).clamp(0.0, 1.0)
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::licence::Licence;
use crate::manifest::Domain;
use crate::synthetic_proto::{len_field, value_info_bytes};
fn varint(mut v: u64, out: &mut Vec<u8>) {
loop {
let b = (v & 0x7f) as u8;
v >>= 7;
if v == 0 {
out.push(b);
return;
}
out.push(b | 0x80);
}
}
fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
varint(n << 3, out);
varint(v, out);
}
fn node(op: &str, inputs: &[&str], output: &str, ints: &[(&str, i64)]) -> Vec<u8> {
let mut n = Vec::new();
for i in inputs {
len_field(1, i.as_bytes(), &mut n);
}
len_field(2, output.as_bytes(), &mut n);
len_field(4, op.as_bytes(), &mut n);
for (name, value) in ints {
let mut a = Vec::new();
len_field(1, name.as_bytes(), &mut a);
varint_field(3, *value as u64, &mut a);
varint_field(20, 2, &mut a);
len_field(5, &a, &mut n);
}
n
}
fn tensor(name: &str, dims: &[u64], values: &[f32]) -> Vec<u8> {
let mut t = Vec::new();
for d in dims {
varint_field(1, *d, &mut t);
}
varint_field(2, 1, &mut t);
len_field(8, name.as_bytes(), &mut t);
let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
len_field(9, &raw, &mut t);
t
}
/// A tiny working denoiser-shaped network: `[1, 4, t, t]` → a 1 × 1 convolution to 12 channels (each output
/// channel is `scale` times one input plane, so the scale is known) → depth-to-space → `[1, 3, 2t, 2t]`.
fn tiny_model(t: u64, scale: f32) -> Vec<u8> {
tiny_model_with(t, scale, 12)
}
/// The same with another number of convolution channels; only 12 can be arranged into the three colours.
fn tiny_model_with(t: u64, scale: f32, channels: u64) -> Vec<u8> {
let mut graph = Vec::new();
len_field(1, &node("Conv", &["data", "w"], if channels == 12 { "c" } else { "rgb" }, &[("group", 1)]), &mut graph);
if channels == 12 {
let mut depth_to_space = node("DepthToSpace", &["c"], "rgb", &[("blocksize", 2)]);
let mut mode = Vec::new();
len_field(1, b"mode", &mut mode);
len_field(4, b"CRD", &mut mode);
varint_field(20, 3, &mut mode);
len_field(5, &mode, &mut depth_to_space);
len_field(1, &depth_to_space, &mut graph);
}
// CRD output channel colour·4 + dy·2 + dx reads input plane [R, G1, B] of the tile.
let mut w = vec![0f32; channels as usize * 4];
for oc in 0..channels as usize {
let colour = (oc / 4).min(2);
let plane = [0, 1, 3][colour];
w[oc * 4 + plane] = scale;
}
len_field(5, &tensor("w", &[channels, 4, 1, 1], &w), &mut graph);
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(t), Ok(t)]), &mut graph);
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(2 * t), Ok(2 * t)]), &mut graph);
let mut ops = Vec::new();
len_field(1, b"", &mut ops);
varint_field(2, 13, &mut ops);
let mut model = Vec::new();
varint_field(1, 8, &mut model);
len_field(7, &graph, &mut model);
len_field(8, &ops, &mut model);
model
}
fn manifest(tile: u32, nominal: f32) -> DenoiserManifest {
DenoiserManifest {
id: "tiny".into(),
name: "Tiny".into(),
version: "1".into(),
licence: Licence::default(),
source: None,
sha256: None,
size_bytes: None,
provenance: String::new(),
domain: Domain::BayerToRgb,
tile,
overlap: 16,
gain: Gain::MatchMean { nominal, max_deviation: 0.05 },
}
}
fn temp_model(name: &str, bytes: &[u8]) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!("lc-denoise-runtime-{name}-{}.onnx", std::process::id()));
std::fs::write(&p, bytes).unwrap();
p
}
#[test]
fn a_working_model_loads_runs_and_has_the_scale_it_says() {
let p = temp_model("ok", &tiny_model(64, 1000.0));
let r = CpuRunner::load(&p, &manifest(64, 1000.0)).unwrap();
let input: Vec<f32> = (0..4 * 64 * 64).map(|i| (i % 97) as f32 / 97.0).collect();
let out = r.run(&input).unwrap();
assert_eq!(out.len(), 3 * 128 * 128);
// output (colour c, pixel (2y+dy, 2x+dx)) = scale × input plane [R, G1, B][c] at (y, x)
for (c, plane) in [0usize, 1, 3].into_iter().enumerate() {
let (y, x) = (5usize, 9usize);
assert!((out[c * 128 * 128 + (2 * y + 1) * 128 + 2 * x] - 1000.0 * input[plane * 64 * 64 + y * 64 + x]).abs() < 1e-2);
}
let _ = std::fs::remove_file(p);
}
#[test]
fn the_self_test_checks_scale_and_noise() {
// a model that only multiplies cannot take noise down: it must fail that check but pass the others
let p = temp_model("selftest", &tiny_model(64, 1000.0));
let t = CpuRunner::load(&p, &manifest(64, 1000.0)).unwrap().self_test();
assert!(!t.ok, "{t:?}");
assert!(t.checks.iter().any(|(c, ok)| c.contains("same answer") && *ok), "{t:?}");
assert!(t.checks.iter().any(|(c, ok)| c.contains("scale") && *ok), "{t:?}");
assert!(t.checks.iter().any(|(c, ok)| c.contains("noise") && !*ok), "{t:?}");
// and one that claims the wrong scale fails that check
let q = CpuRunner::load(&p, &manifest(64, 5.0)).unwrap().self_test();
assert!(q.checks.iter().any(|(c, ok)| c.contains("scale") && !*ok), "{q:?}");
let _ = std::fs::remove_file(p);
}
#[test]
fn wrong_sizes_and_broken_files_are_errors() {
let p = temp_model("sizes", &tiny_model(64, 1.0));
let r = CpuRunner::load(&p, &manifest(64, 1.0)).unwrap();
assert!(matches!(r.run(&[0.0; 10]), Err(Error::Input(_))));
// a fully convolutional model works at whatever tile size the manifest asks for
let wide = CpuRunner::load(&p, &manifest(128, 1.0)).unwrap();
assert_eq!(wide.run(&vec![0.5; 4 * 128 * 128]).unwrap().len(), 3 * 256 * 256);
// a model that gives another number of values than the manifest promises is an error
let q = temp_model("channels", &tiny_model_with(64, 1.0, 8));
assert!(CpuRunner::load(&q, &manifest(64, 1.0)).is_err(), "shape is refused before execution");
let _ = std::fs::remove_file(q);
for (name, bytes) in [("junk", b"nonsense".to_vec()), ("empty", Vec::new())] {
let q = temp_model(name, &bytes);
assert!(CpuRunner::load(&q, &manifest(64, 1.0)).is_err(), "{name}");
let _ = std::fs::remove_file(q);
}
assert!(CpuRunner::load(&std::env::temp_dir().join("does-not-exist.onnx"), &manifest(64, 1.0)).is_err());
let _ = std::fs::remove_file(p);
}
#[test]
fn the_check_tile_covers_black_to_white() {
for t in [1, 2, 7, 64] {
let c = check_tile(t);
assert_eq!(c.len(), 4 * t * t);
assert!(c.iter().all(|v| (0.0..=1.0).contains(v)), "{t}");
}
let c = check_tile(64);
let near = |lo: f32, hi: f32| c.iter().filter(|v| (lo..=hi).contains(*v)).count();
assert!(near(0.0, 0.0) >= 4 * 8 * 8 && near(1.0, 1.0) >= 4 * 8 * 8, "a flat black and a clipped white corner");
for band in 0..10 {
let lo = band as f32 / 10.0;
assert!(near(lo, lo + 0.1) > 100, "values around {lo}");
}
assert_eq!(check_tile(64), c, "the same tile every time");
}
#[test]
fn truncated_and_corrupted_models_never_panic() {
let good = tiny_model(64, 1.0);
for n in (0..good.len()).step_by(5) {
let q = temp_model("trunc", &good[..n]);
let _ = CpuRunner::load(&q, &manifest(64, 1.0));
let _ = std::fs::remove_file(q);
}
for at in (0..good.len()).step_by(7) {
let mut bad = good.clone();
if let Some(b) = bad.get_mut(at) {
*b = b.wrapping_add(0x55);
}
let q = temp_model("flip", &bad);
if let Ok(r) = CpuRunner::load(&q, &manifest(64, 1.0)) {
let _ = r.run(&vec![0.5; 4 * 64 * 64]);
}
let _ = std::fs::remove_file(q);
}
}
/// Opt-in: `LC_DENOISE_MODEL=<model_bayer.onnx> cargo test -p lightcraft-denoise --release
/// -- --ignored --nocapture` self-tests a real model (RawNIND's Bayer model: tile 512, scale about 1e6).
#[test]
#[ignore = "needs a real model file: set LC_DENOISE_MODEL"]
fn a_real_model_passes_the_self_test() {
let Some(path) = std::env::var_os("LC_DENOISE_MODEL") else { return };
let m = manifest(512, 1.0e6);
let r = CpuRunner::load(Path::new(&path), &m).unwrap();
let t = r.self_test();
println!("load {:.0} ms | {:.0} ms per tile | scale {:.4e} | {:?}", t.load_ms, t.tile_ms, t.scale, t.checks);
assert!(t.ok);
}
}
#[cfg(test)]
mod cpu_tests {
use super::*;
#[test]
fn manifest_and_test_tile_sizes_are_checked_without_tract() {
for t in [0, 2049, usize::MAX] {
assert!(test_tile(t).0.is_empty());
assert!(check_tile(t).is_empty());
}
let path = std::env::temp_dir().join(format!("lc-cpu-load-{}.onnx", std::process::id()));
std::fs::write(&path, crate::synthetic::unet_onnx(64, 5, 1, 7)).unwrap();
let mut m = DenoiserManifest {
id: "synthetic".into(),
name: "Synthetic".into(),
version: "1".into(),
licence: Default::default(),
source: None,
sha256: None,
size_bytes: None,
provenance: "synthetic test".into(),
domain: crate::manifest::Domain::BayerToRgb,
tile: 64,
overlap: 16,
gain: Gain::None,
};
let r = CpuRunner::load(&path, &m).unwrap();
let x = check_tile(64);
assert_eq!(r.run(&x).unwrap(), r.run(&x).unwrap());
assert!(r.self_test().checks.iter().any(|(text, ok)| text.contains("same answer") && *ok));
m.tile = u32::MAX;
assert!(CpuRunner::load(&path, &m).is_err());
std::fs::remove_file(path).unwrap();
}
}

View File

@ -0,0 +1,234 @@
//! A small ONNX U-Net built in memory, for tests of the layers that read and run denoise networks (here, in the GPU
//! runner and in the engine): the same layer kinds and wiring as the real denoisers, with deterministic random weights.
//! Nothing here is a model anyone would use.
use crate::synthetic_proto::{len_field, value_info_bytes};
fn varint(mut v: u64, out: &mut Vec<u8>) {
loop {
let b = (v & 0x7f) as u8;
v >>= 7;
if v == 0 {
out.push(b);
return;
}
out.push(b | 0x80);
}
}
fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
varint(n << 3, out);
varint(v, out);
}
enum A<'a> {
Int(i64),
Ints(&'a [i64]),
Float(f32),
Str(&'a str),
}
fn node(op: &str, inputs: &[&str], output: &str, attrs: &[(&str, A)]) -> Vec<u8> {
let mut n = Vec::new();
for i in inputs {
len_field(1, i.as_bytes(), &mut n);
}
len_field(2, output.as_bytes(), &mut n);
len_field(4, op.as_bytes(), &mut n);
for (name, value) in attrs {
let mut a = Vec::new();
len_field(1, name.as_bytes(), &mut a);
match value {
A::Int(v) => {
varint_field(3, *v as u64, &mut a);
varint_field(20, 2, &mut a);
}
A::Ints(vs) => {
for v in *vs {
varint_field(8, *v as u64, &mut a);
}
varint_field(20, 7, &mut a);
}
A::Float(v) => {
varint((2 << 3) | 5, &mut a);
a.extend_from_slice(&v.to_le_bytes());
varint_field(20, 1, &mut a);
}
A::Str(s) => {
len_field(4, s.as_bytes(), &mut a);
varint_field(20, 3, &mut a);
}
}
len_field(5, &a, &mut n);
}
n
}
fn tensor(name: &str, dims: &[u64], values: &[f32]) -> Vec<u8> {
let mut t = Vec::new();
for d in dims {
varint_field(1, *d, &mut t);
}
varint_field(2, 1, &mut t);
len_field(8, name.as_bytes(), &mut t);
let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
len_field(9, &raw, &mut t);
t
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f32 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
((self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
}
}
struct Builder {
nodes: Vec<Vec<u8>>,
inits: Vec<Vec<u8>>,
rng: Rng,
n: usize,
}
impl Builder {
fn name(&mut self, kind: &str) -> String {
self.n += 1;
format!("{kind}{}", self.n)
}
/// Random weights scaled so activations keep a sane size through the layers.
fn weights(&mut self, name: &str, dims: &[u64], fan_in: usize) -> String {
let count: u64 = dims.iter().product();
let scale = (3.0 / fan_in as f32).sqrt();
let values: Vec<f32> = (0..count).map(|_| self.rng.next() * scale).collect();
self.inits.push(tensor(name, dims, &values));
name.to_string()
}
fn bias(&mut self, name: &str, n: u64) -> String {
let values: Vec<f32> = (0..n).map(|_| self.rng.next() * 0.1).collect();
self.inits.push(tensor(name, &[n], &values));
name.to_string()
}
/// `Conv` (+ `LeakyRelu` when `leaky`); gives the name of the tensor that results.
fn conv(&mut self, src: &str, cin: u64, cout: u64, k: u64, leaky: bool) -> String {
let (w, b, out) = (self.name("w"), self.name("b"), self.name("conv"));
let w = self.weights(&w, &[cout, cin, k, k], (cin * k * k) as usize);
let b = self.bias(&b, cout);
let pad = (k as i64 - 1) / 2;
self.nodes.push(node(
"Conv",
&[src, &w, &b],
&out,
&[("kernel_shape", A::Ints(&[k as i64, k as i64])), ("pads", A::Ints(&[pad; 4])), ("strides", A::Ints(&[1, 1])), ("group", A::Int(1))],
));
if !leaky {
return out;
}
let act = self.name("act");
self.nodes.push(node("LeakyRelu", &[&out], &act, &[("alpha", A::Float(0.2))]));
act
}
fn pool(&mut self, src: &str) -> String {
let out = self.name("pool");
self.nodes.push(node(
"MaxPool",
&[src],
&out,
&[("kernel_shape", A::Ints(&[2, 2])), ("strides", A::Ints(&[2, 2])), ("pads", A::Ints(&[0; 4]))],
));
out
}
fn up(&mut self, src: &str, cin: u64, cout: u64) -> String {
let (w, b, out) = (self.name("uw"), self.name("ub"), self.name("up"));
let w = self.weights(&w, &[cin, cout, 2, 2], (cin * 4) as usize);
let b = self.bias(&b, cout);
self.nodes.push(node(
"ConvTranspose",
&[src, &w, &b],
&out,
&[("kernel_shape", A::Ints(&[2, 2])), ("strides", A::Ints(&[2, 2])), ("pads", A::Ints(&[0; 4]))],
));
out
}
fn concat(&mut self, a: &str, b: &str) -> String {
let out = self.name("cat");
self.nodes.push(node("Concat", &[a, b], &out, &[("axis", A::Int(1))]));
out
}
}
/// A denoiser-shaped U-Net as ONNX bytes: `[1, 4, tile, tile]` → `[1, 3, 2·tile, 2·tile]`, with `depth` poolings
/// (so `tile` must be a multiple of `2^depth`) and `ch` channels on the first level, doubling on each (every count a
/// multiple of 4). Each level is two 3 × 3 convolutions with a leaky ReLU, as in the real networks; the output is a
/// 1 × 1 convolution to 12 channels and depth-to-space.
pub fn unet_onnx(tile: u64, ch: u64, depth: usize, seed: u64) -> Vec<u8> {
let mut b = Builder { nodes: Vec::new(), inits: Vec::new(), rng: Rng(seed | 1), n: 0 };
let mut skips: Vec<(String, u64)> = Vec::new();
let (mut cur, mut c_in, mut c) = ("data".to_string(), 4u64, ch);
for level in 0..=depth {
let x = b.conv(&cur, c_in, c, 3, true);
let x = b.conv(&x, c, c, 3, true);
if level < depth {
skips.push((x.clone(), c));
cur = b.pool(&x);
c_in = c;
c *= 2;
} else {
cur = x;
}
}
for (skip, sc) in skips.into_iter().rev() {
let up = b.up(&cur, c, sc);
let joined = b.concat(&up, &skip);
let x = b.conv(&joined, 2 * sc, sc, 3, true);
cur = b.conv(&x, sc, sc, 3, true);
c = sc;
}
let rgb = b.conv(&cur, c, 12, 1, false);
b.nodes.push(node("DepthToSpace", &[&rgb], "rgb", &[("blocksize", A::Int(2)), ("mode", A::Str("CRD"))]));
let mut graph = Vec::new();
for n in &b.nodes {
len_field(1, n, &mut graph);
}
for t in &b.inits {
len_field(5, t, &mut graph);
}
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(tile), Ok(tile)]), &mut graph);
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(2 * tile), Ok(2 * tile)]), &mut graph);
let mut ops = Vec::new();
len_field(1, b"", &mut ops);
varint_field(2, 13, &mut ops);
let mut model = Vec::new();
varint_field(1, 8, &mut model);
len_field(7, &graph, &mut model);
len_field(8, &ops, &mut model);
model
}
/// A tiny smoothing network for installing a real CPU model in setup-flow tests.
/// It averages a 3 × 3 neighborhood of the four Bayer planes and returns three identical channels.
pub fn smoothing_onnx() -> Vec<u8> {
let mut graph = Vec::new();
len_field(1, &node("Conv", &["data", "weights"], "samples", &[("kernel_shape", A::Ints(&[3, 3])), ("pads", A::Ints(&[1, 1, 1, 1]))]), &mut graph);
len_field(1, &node("DepthToSpace", &["samples"], "rgb", &[("blocksize", A::Int(2)), ("mode", A::Str("CRD"))]), &mut graph);
len_field(5, &tensor("weights", &[12, 4, 3, 3], &[1.0 / 36.0; 12 * 4 * 3 * 3]), &mut graph);
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(64), Ok(64)]), &mut graph);
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(128), Ok(128)]), &mut graph);
let mut model = Vec::new();
varint_field(1, 8, &mut model);
len_field(7, &graph, &mut model);
let mut ops = Vec::new();
varint_field(2, 13, &mut ops);
len_field(8, &ops, &mut model);
model
}

View File

@ -0,0 +1,44 @@
pub(crate) fn varint(mut v: u64, out: &mut Vec<u8>) {
loop {
let b = (v & 0x7f) as u8;
v >>= 7;
if v == 0 {
out.push(b);
return;
}
out.push(b | 0x80);
}
}
pub fn len_field(n: u64, body: &[u8], out: &mut Vec<u8>) {
varint(n << 3 | 2, out);
varint(body.len() as u64, out);
out.extend_from_slice(body);
}
pub(crate) fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
varint(n << 3, out);
varint(v, out);
}
/// A graph input or output: `Ok(n)` is a fixed dimension, `Err(name)` a named (dynamic) one.
pub fn value_info_bytes(name: &str, elem: u64, dims: &[std::result::Result<u64, &str>]) -> Vec<u8> {
let mut shape = Vec::new();
for d in dims {
let mut dim = Vec::new();
match d {
Ok(v) => varint_field(1, *v, &mut dim),
Err(p) => len_field(2, p.as_bytes(), &mut dim),
}
len_field(1, &dim, &mut shape);
}
let mut tensor = Vec::new();
varint_field(1, elem, &mut tensor);
len_field(2, &shape, &mut tensor);
let mut ty = Vec::new();
len_field(1, &tensor, &mut ty);
let mut vi = Vec::new();
len_field(1, name.as_bytes(), &mut vi);
len_field(2, &ty, &mut vi);
vi
}

View File

@ -0,0 +1,250 @@
//! Opt-in end-to-end check of the real RawNIND Bayer model on real mosaics with noise added:
//!
//! ```text
//! LC_DENOISE_MODEL=<model_bayer.onnx> LC_DENOISE_MOSAICS=<folder> LC_DENOISE_OUT=<folder> \
//! cargo test --release -p lightcraft-denoise --features runtime --test real_model -- --ignored --nocapture
//! ```
//!
//! `LC_DENOISE_MOSAICS` holds `<name>.mosaic.f32` (little-endian f32, normalised: black 0, white 1, not white
//! balanced), `<name>.rgb.f32` (a clean demosaic of it, camera RGB, interleaved) and `<name>.txt` (`width`,
//! `height`, `cfa`, `wb` lines). Noise is Poisson-Gaussian, made here. Optional: `LC_DENOISE_PARALLEL` (tiles at
//! once, default 8), `LC_DENOISE_ONLY` (one name), `LC_DENOISE_ISO` (noise strength, default 1.0; 0 adds none),
//! `LC_DENOISE_FULL` (the whole frame instead of a 2048 × 1536 crop).
#![cfg(feature = "runtime")]
use std::path::{Path, PathBuf};
use std::time::Instant;
use lightcraft_denoise::bayer::Layout;
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
use lightcraft_denoise::run::{Control, Params, denoise_bayer};
use lightcraft_denoise::runtime::CpuRunner;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
((self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 11) as f64 + 0.5) / (1u64 << 53) as f64
}
fn gauss(&mut self) -> f64 {
(-2.0 * self.next().ln()).sqrt() * (std::f64::consts::TAU * self.next()).cos()
}
}
fn read_f32(path: &Path) -> Vec<f32> {
std::fs::read(path).unwrap().as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
}
struct Dump {
width: usize,
height: usize,
layout: Layout,
wb: [f32; 3],
mosaic: Vec<f32>,
rgb: Vec<f32>,
}
fn load(dir: &Path, name: &str) -> Dump {
let text = std::fs::read_to_string(dir.join(format!("{name}.txt"))).unwrap();
let field = |k: &str| text.lines().find_map(|l| l.strip_prefix(k).map(|v| v.trim().to_string())).unwrap();
let cell = match field("cfa").as_str() {
"RGGB" => [0, 1, 1, 2],
"BGGR" => [2, 1, 1, 0],
"GRBG" => [1, 0, 2, 1],
"GBRG" => [1, 2, 0, 1],
other => panic!("cfa {other}"),
};
let wb: Vec<f32> = field("wb").split_whitespace().map(|v| v.parse().unwrap()).collect();
Dump {
width: field("width").parse().unwrap(),
height: field("height").parse().unwrap(),
layout: Layout::from_cell(cell).unwrap(),
wb: [wb[0], wb[1], wb[2]],
mosaic: read_f32(&dir.join(format!("{name}.mosaic.f32"))),
rgb: read_f32(&dir.join(format!("{name}.rgb.f32"))),
}
}
/// Crop with an even origin so the Bayer phase is kept.
fn crop(d: &Dump, x0: usize, y0: usize, w: usize, h: usize) -> (Vec<f32>, Vec<f32>) {
let mut m = Vec::with_capacity(w * h);
let mut c = Vec::with_capacity(w * h * 3);
for y in y0..y0 + h {
m.extend_from_slice(&d.mosaic[y * d.width + x0..y * d.width + x0 + w]);
c.extend_from_slice(&d.rgb[(y * d.width + x0) * 3..(y * d.width + x0 + w) * 3]);
}
(m, c)
}
/// 0 = red, 1 = green, 2 = blue at a mosaic position.
fn site(layout: Layout, x: usize, y: usize) -> usize {
match ((x + layout.dx) % 2, (y + layout.dy) % 2) {
(0, 0) => 0,
(1, 1) => 2,
_ => 1,
}
}
/// The simplest demosaic (each missing colour is the mean of the same-coloured samples in the 3 × 3 around),
/// as the baseline a user would get from the noisy picture without AI.
fn bilinear(m: &[f32], w: usize, h: usize, layout: Layout) -> Vec<f32> {
let mut out = vec![0f32; w * h * 3];
for y in 0..h {
for x in 0..w {
let mut sum = [0f32; 3];
let mut n = [0f32; 3];
for yy in y.saturating_sub(1)..(y + 2).min(h) {
for xx in x.saturating_sub(1)..(x + 2).min(w) {
let c = site(layout, xx, yy);
sum[c] += m[yy * w + xx];
n[c] += 1.0;
}
}
for c in 0..3 {
out[(y * w + x) * 3 + c] = if n[c] > 0.0 { sum[c] / n[c] } else { 0.0 };
}
}
}
out
}
/// Signal-to-error ratio in dB of `got` against `want` (both interleaved RGB), ignoring a border.
fn snr(got: &[f32], want: &[f32], w: usize, h: usize, border: usize) -> f64 {
let (mut sig, mut err) = (0f64, 0f64);
for y in border..h - border {
for x in border..w - border {
for c in 0..3 {
let (g, t) = (f64::from(got[(y * w + x) * 3 + c]), f64::from(want[(y * w + x) * 3 + c]));
sig += t * t;
err += (g - t).powi(2);
}
}
}
10.0 * (sig / err.max(1e-30)).log10()
}
fn crc32(data: &[u8]) -> u32 {
let mut c = !0u32;
for &b in data {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
/// A viewable PNG of interleaved camera-RGB panels side by side: white balanced, one exposure for all, gamma.
fn write_png(path: &Path, panels: &[&[f32]], w: usize, h: usize, wb: [f32; 3], exposure: f32) {
let total_w = w * panels.len();
let mut raw = Vec::with_capacity((total_w * 3 + 1) * h);
for y in 0..h {
raw.push(0u8);
for p in panels {
for x in 0..w {
for c in 0..3 {
let v = (p[(y * w + x) * 3 + c] * wb[c] * exposure).clamp(0.0, 1.0);
raw.push((v.powf(1.0 / 2.2) * 255.0 + 0.5) as u8);
}
}
}
}
let z = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
let mut chunk = |kind: &[u8; 4], body: &[u8]| {
png.extend_from_slice(&(body.len() as u32).to_be_bytes());
let mut k = kind.to_vec();
k.extend_from_slice(body);
png.extend_from_slice(&k);
png.extend_from_slice(&crc32(&k).to_be_bytes());
};
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&(total_w as u32).to_be_bytes());
ihdr.extend_from_slice(&(h as u32).to_be_bytes());
ihdr.extend_from_slice(&[8, 2, 0, 0, 0]);
chunk(b"IHDR", &ihdr);
chunk(b"IDAT", &z);
chunk(b"IEND", &[]);
std::fs::write(path, png).unwrap();
}
#[test]
#[ignore = "needs a real model and mosaics: see the module docs"]
fn the_real_model_denoises_real_mosaics() {
let (Some(model), Some(dir)) = (std::env::var_os("LC_DENOISE_MODEL"), std::env::var_os("LC_DENOISE_MOSAICS")) else { return };
let dir = PathBuf::from(dir);
let out_dir = std::env::var_os("LC_DENOISE_OUT").map(PathBuf::from);
let parallel: usize = std::env::var("LC_DENOISE_PARALLEL").ok().and_then(|v| v.parse().ok()).unwrap_or(8);
let iso: f64 = std::env::var("LC_DENOISE_ISO").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0);
let names: Vec<String> = match std::env::var("LC_DENOISE_ONLY") {
Ok(n) => vec![n],
Err(_) => ["arw-sony-a7m3-compressed", "cr2-canon-6d", "nef-nikon-d5100-lossless", "dng-google-pixel2xl", "pef-pentax-k3"]
.map(String::from)
.to_vec(),
};
let manifest = DenoiserManifest {
id: "rawnind-bayer".into(),
name: "RawNIND Bayer".into(),
version: "1".into(),
licence: Default::default(),
source: None,
sha256: None,
size_bytes: None,
provenance: String::new(),
domain: Domain::BayerToRgb,
tile: 512,
overlap: 64,
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
};
let runner = CpuRunner::load(Path::new(&model), &manifest).unwrap();
let params = Params { tile: 512, overlap: 64, gain: manifest.gain, clip: Some(1.0), parallel };
for name in names {
let d = load(&dir, &name);
// a crop of about 2 × 1.5 tiles, away from the edges, even origin
let full = std::env::var_os("LC_DENOISE_FULL").is_some();
let (w, h) = if full { (d.width & !1, d.height & !1) } else { (2048usize, 1536usize) };
let (x0, y0) = (((d.width - w) / 3) & !1, ((d.height - h) / 2) & !1);
let (clean, reference) = crop(&d, x0, y0, w, h);
// Poisson-Gaussian noise: variance a·signal + b, as a sensor at high ISO
let (a, b) = (1.5e-3 * iso, (4.0e-3 * iso.sqrt()).powi(2));
let mut rng = Rng(0x9e37_79b9_7f4a_7c15 ^ name.len() as u64);
let noisy: Vec<f32> = clean
.iter()
.map(|&v| {
let v = f64::from(v);
let sigma = (a * v.max(0.0) + b).sqrt();
((v + sigma * rng.gauss()) as f32).min(1.0)
})
.collect();
let started = Instant::now();
let done = std::sync::atomic::AtomicUsize::new(0);
let progress = |n: usize, total: usize| done.store(n * 1000 / total, std::sync::atomic::Ordering::Relaxed);
let denoised = denoise_bayer(&noisy, w, h, d.layout, &runner, &params, &Control { cancel: None, progress: Some(&progress) }).unwrap();
let secs = started.elapsed().as_secs_f64();
let baseline = bilinear(&noisy, w, h, d.layout);
let clean_bilinear = bilinear(&clean, w, h, d.layout);
let den: Vec<f32> = denoised.data.iter().flat_map(|p| p.iter().copied()).collect();
let (s_noisy, s_den) = (snr(&baseline, &reference, w, h, 64), snr(&den, &reference, w, h, 64));
let s_floor = snr(&clean_bilinear, &reference, w, h, 64);
println!(
"{name}: {w}×{h} crop, {} tiles, parallel {parallel}, {secs:.1} s | SNR vs clean demosaic: noisy bilinear {s_noisy:.2} dB, denoised {s_den:.2} dB (clean bilinear {s_floor:.2} dB)",
lightcraft_denoise::run::tile_count(w, h, d.layout, 512, 64)
);
if let Some(out) = &out_dir {
std::fs::create_dir_all(out).unwrap();
let mean = reference.iter().map(|&v| f64::from(v)).sum::<f64>() / reference.len() as f64;
let exposure = (0.18 / mean.max(1e-6)) as f32;
// three panels, a 512 × 384 window each, at 1:1
let (pw, ph, px, py) = (512usize, 384usize, 700usize, 500usize);
let window =
|buf: &[f32]| -> Vec<f32> { (0..ph).flat_map(|y| buf[((py + y) * w + px) * 3..((py + y) * w + px + pw) * 3].to_vec()).collect() };
write_png(&out.join(format!("{name}.png")), &[&window(&baseline), &window(&den), &window(&reference)], pw, ph, d.wb, exposure);
}
if iso >= 4.0 {
assert!(s_den > s_noisy + 2.0, "{name}: denoised {s_den:.2} dB is not clearly better than noisy {s_noisy:.2} dB");
}
}
}

View File

@ -207,6 +207,7 @@ controls! {
"grain.amount" => grain.amount, "Grain", Grain, 0, 100, 0, 1, 0, Plain;
"grain.size" => grain.size, "Size", Grain, 0, 100, 25, 1, 0, Plain;
"grain.roughness" => grain.roughness, "Roughness", Grain, 0, 100, 50, 1, 0, Plain;
"enhance.denoise" => enhance.denoise, "Amount", Detail, 0, 100, 0, 1, 0, Plain;
"detail.sharpenAmount" => detail.sharpen_amount, "Sharpening", Detail, 0, 150, 0, 1, 0, Plain;
"detail.sharpenRadius" => detail.sharpen_radius, "Radius", Detail, 0.5, 3, 1, 0.1, 1, Plain;
"detail.sharpenDetail" => detail.sharpen_detail, "Detail", Detail, 0, 100, 25, 1, 0, Plain;

View File

@ -94,6 +94,13 @@ impl DevelopSettings {
std::borrow::Cow::Owned(d)
}
/// How much of the AI-denoised picture to mix in, 0 (none) to 1 (all of it): the Denoise amount, unless the Detail
/// section is switched off or the value is not a number.
pub fn denoise_amount(&self) -> f32 {
let a = self.enhance.denoise;
if a.is_finite() && self.enhance.denoise_enabled() && self.section_enabled("detail") { (a / 100.0).clamp(0.0, 1.0) as f32 } else { 0.0 }
}
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
self.disabled_sections.retain(|s| s != section);
if !on {
@ -112,6 +119,7 @@ impl DevelopSettings {
self.curve = d;
}
Section::Color => self.wb.mode = WbMode::AsShot,
Section::Detail => self.enhance.denoise_on = None,
_ => {}
}
}
@ -193,4 +201,40 @@ mod tests {
s.set_section_enabled("effects", true);
assert!(s.section_enabled("effects"));
}
#[test]
fn the_denoise_amount_is_a_slider_and_follows_the_detail_section() {
let mut s = DevelopSettings::default();
assert_eq!(s.denoise_amount(), 0.0);
assert!(controls::set(&mut s, "enhance.denoise", 60.0));
assert_eq!(controls::get(&s, "enhance.denoise"), Some(60.0));
assert!((s.denoise_amount() - 0.6).abs() < 1e-6);
// the slider's range is 0 to 100; values outside it are brought in
controls::set(&mut s, "enhance.denoise", 500.0);
assert_eq!(s.denoise_amount(), 1.0);
controls::set(&mut s, "enhance.denoise", -5.0);
assert_eq!(s.denoise_amount(), 0.0);
// switching the Detail section off switches the denoise off with it; a settings file with a bad number does nothing
controls::set(&mut s, "enhance.denoise", 80.0);
s.set_section_enabled("detail", false);
assert_eq!(s.denoise_amount(), 0.0);
s.set_section_enabled("detail", true);
s.enhance.denoise = f64::NAN;
assert_eq!(s.denoise_amount(), 0.0);
// it is part of Detail: a reset of the section clears it
controls::set(&mut s, "enhance.denoise", 40.0);
s.reset_section(Section::Detail);
assert_eq!(s.enhance.denoise, 0.0);
// The later independent switch preserves Amount when off, and can be on at zero.
controls::set(&mut s, "enhance.denoise", 75.0);
s.enhance.denoise_on = Some(false);
assert_eq!(s.denoise_amount(), 0.0);
assert_eq!(s.enhance.denoise, 75.0);
s.enhance.denoise_on = Some(true);
assert_eq!(s.denoise_amount(), 0.75);
controls::set(&mut s, "enhance.denoise", 0.0);
assert!(s.enhance.denoise_enabled());
s.reset_section(Section::Detail);
assert_eq!(s.enhance.denoise_on, None);
}
}

View File

@ -915,6 +915,14 @@ pub struct LensBlur {
#[serde(default)]
pub struct Enhance {
pub denoise: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub denoise_on: Option<bool>,
pub raw_details: bool,
pub super_resolution: bool,
}
impl Enhance {
pub fn denoise_enabled(&self) -> bool {
self.denoise_on.unwrap_or(self.denoise > 0.0)
}
}

View File

@ -21,6 +21,8 @@ web-time = { workspace = true }
lightcraft-preview = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raw = { workspace = true }
lightcraft-denoise-core = { workspace = true }
lightcraft-denoise = { workspace = true, optional = true }
lightcraft-meta = { workspace = true }
lightcraft-faces = { workspace = true }
miniz_oxide = { workspace = true }
@ -43,6 +45,8 @@ lightcraft-fetch = { workspace = true }
[features]
# AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds.
sam = ["dep:lightcraft-segment"]
denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-gpu/denoise"]
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
[dev-dependencies]
libc = { workspace = true }

View File

@ -65,19 +65,8 @@ impl CameraProfile {
}
}
/// LightCraft's configuration folder (settings, GPU marker, camera profiles).
pub fn config_dir() -> Option<PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.map(|c| c.join("lightcraft"))
}
}
/// LightCraft's configuration folder (settings, GPU marker, camera profiles); one helper for the app.
pub use crate::config::config_dir;
/// Where camera profiles are read from and written to.
pub fn dir() -> Option<PathBuf> {

View File

@ -0,0 +1,659 @@
//! Denoise commands (`denoise.*`): the models (list, install, download, test, remove, choose), the settings, and the
//! work (queue, cancel, status, pump, clear). The machinery they drive is [`crate::denoise`].
//!
//! Models live in the folder the host provides (`Session::denoise.models_dir`): one subfolder per model with
//! `model.onnx`, `denoise-model.json` (its manifest) and `installed.json` (what the user accepted and the file's hash),
//! plus `settings.json` beside them. Model files, archives and manifests are hostile input: sizes are capped, ids are
//! validated before they become folder names, and nothing outside a model's own folder is ever touched.
use std::path::{Path, PathBuf};
use std::sync::{Arc, PoisonError, mpsc};
use lightcraft_catalog::PhotoId;
use lightcraft_denoise::archive;
use lightcraft_denoise::hash::sha256_file;
use lightcraft_denoise::known::{self, Known};
use lightcraft_denoise::manifest::{self, DenoiserManifest, MAX_MANIFEST_BYTES, MAX_MODEL_BYTES};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
use crate::denoise::{Pace, RunOn, Settings, forget_gpu_set_ups, installed_models, read_capped, read_settings, write_atomic, write_settings};
use crate::model_download::State;
use crate::{EngineError, Result, Session};
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
EngineError::Other(format!("{what}: {e}"))
}
fn models_dir(s: &Session, cmd: &str) -> Result<PathBuf> {
s.denoise.models_dir.clone().ok_or_else(|| bad(cmd, "this build has nowhere to keep denoise models (the desktop app does)"))
}
/// What a command says about a model.
fn row(m: &DenoiserManifest, download_host: Option<String>, installed: bool, selected: bool, accepted: &Value) -> Value {
json!({
"id": m.id,
"name": m.name,
"version": m.version,
"licence": m.licence,
"provenance": m.provenance,
"source": m.source,
"sizeBytes": m.size_bytes,
"sha256": m.sha256,
"tile": m.tile,
"known": known::find(&m.id).is_some(),
// a pinned address LightCraft can fetch it from (the user presses Download), and which site that is
"downloadHost": download_host,
"installed": installed,
"selected": selected,
"accepted": accepted,
})
}
fn host_of(k: &Known) -> Option<String> {
k.download.as_ref().map(|d| lightcraft_denoise::known::host(&d.url).to_string())
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
s.denoise_refresh_active(true);
let dir = s.denoise.models_dir.clone();
let st = dir.as_deref().map(read_settings).unwrap_or_default();
let installed = dir.as_deref().map(installed_models).unwrap_or_default();
let on_disk = |id: &str| installed.iter().find(|i| i.manifest.id == id);
let chosen = |id: &str| st.model.as_deref() == Some(id);
let mut models: Vec<Value> = Vec::new();
for k in known::all() {
let found = on_disk(&k.manifest.id);
models.push(row(&k.manifest, host_of(&k), found.is_some(), chosen(&k.manifest.id), found.map_or(&Value::Null, |f| &f.accepted)));
}
for i in installed.iter().filter(|i| known::find(&i.manifest.id).is_none()) {
models.push(row(&i.manifest, None, true, chosen(&i.manifest.id), &i.accepted));
}
Ok(json!({
"dir": dir.as_ref().map(|d| d.display().to_string()),
"productsDir": s.denoise_products_dir().map(|d| d.display().to_string()),
"model": st.model,
// whether this build can run denoise models (the `denoise` feature: our pure-Rust CPU/GPU runners)
"runtime": cfg!(feature = "denoise"),
"auto": st.auto(),
"cacheGb": st.cache_bytes() >> 30,
"threads": st.threads,
"runOn": st.run_on().name(),
"models": models,
}))
}
/// What a file the user pointed at is, as far as installing it goes.
struct Source {
manifest: DenoiserManifest,
/// The `.onnx` inside the archive; `None` when the file is the `.onnx`.
entry: Option<&'static str>,
file_name: String,
}
fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result<Source> {
let meta = std::fs::metadata(path).map_err(|e| bad(c, format!("cannot read `{}`: {e}", path.display())))?;
if !meta.is_file() {
return Err(bad(c, format!("`{}` is not a file", path.display())));
}
if meta.len() == 0 || meta.len() > MAX_MODEL_BYTES {
return Err(bad(c, "a model file must be between 1 byte and 2 GiB"));
}
let sha256 = match sha_known {
Some(h) => h.to_string(),
None => sha256_file(path).map_err(|e| fail(&format!("could not read {}", path.display()), e))?,
};
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
// an archive LightCraft knows (the file darktable publishes the model in)
if let Some(k) = known::all().into_iter().find(|k| k.download.as_ref().is_some_and(|d| d.sha256 == sha256)) {
return Ok(Source { manifest: k.manifest, entry: k.entry, file_name });
}
// the `.onnx` of a known model
if let Some(m) = known::by_sha256(&sha256) {
return Ok(Source { manifest: m, entry: None, file_name });
}
// an `.onnx` with its manifest beside it
let sibling = path.with_file_name("denoise-model.json");
if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx"))
&& let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES)
{
let m = manifest::parse(&bytes).map_err(|e| bad(c, format!("{}: {e}", sibling.display())))?;
return Ok(Source { manifest: m, entry: None, file_name });
}
Err(bad(
c,
"LightCraft does not know this file. A denoise model is an .onnx file with a denoise-model.json beside it that says how to use it \
(see docs/denoise.md), or an archive LightCraft can fetch itself (`denoise.models.download`)",
))
}
/// Install the model in `src`. A model that passes its self-test becomes the one in use (`activate`, the default): installing
/// a model is how someone says they want it. `verified` is for a download whose hash was already checked.
fn install_file(
dir: &Path,
loader: &crate::denoise::Loader,
run_on: RunOn,
accepted_at: String,
c: &str,
src: &Path,
acknowledged: bool,
verified: bool,
) -> Result<(Value, Arc<dyn crate::denoise::Model>)> {
let ident = identify(src, c, None)?;
let m = ident.manifest.clone();
manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?;
if !acknowledged {
return Err(bad(
c,
"the licence has not been accepted: show the user its terms (see `denoise.models.list`) and pass `acknowledged: true` once they agree",
));
}
let home = dir.join(&m.id);
if home.join("model.onnx").exists() {
return Err(bad(c, "that model is already installed; remove it before installing a replacement"));
}
std::fs::create_dir_all(&home).map_err(|e| fail("could not create the model's folder", e))?;
let mut cleanup = InstallCleanup { home: home.clone(), armed: true };
let (part, final_path) = (home.join("model.onnx.part"), home.join("model.onnx"));
let _ = std::fs::remove_file(&part);
let placed: Result<()> = match ident.entry {
Some(entry) => {
archive::extract(src, entry, &part, MAX_MODEL_BYTES).map(|_| ()).map_err(|e| fail("could not take the model out of the archive", e))
}
None if verified && std::fs::rename(src, &part).is_ok() => Ok(()),
None => std::fs::copy(src, &part).map(|_| ()).map_err(|e| fail("could not copy the model (is the disk full?)", e)),
};
let sha256 = placed.and_then(|_| sha256_file(&part).map_err(|e| fail("could not check the model", e))).and_then(|h| match &m.sha256 {
Some(want) if *want != h => Err(EngineError::Other("the model file is not the one its description names (its SHA-256 differs)".into())),
_ => Ok(h),
});
let sha256 = match sha256 {
Ok(h) => h,
Err(e) => {
let _ = std::fs::remove_file(&part);
return Err(e);
}
};
// Test the staged file first. A failed replacement preserves the installed model.
let model = loader(&part, &m).map_err(|e| bad(c, e))?;
let test = match model.self_test(run_on) {
Ok(t) => t,
Err(e) => {
let _ = std::fs::remove_file(&part);
return Err(bad(c, e));
}
};
// An installed id is immutable: installing again must never destroy a usable model.
if final_path.exists() {
let _ = std::fs::remove_file(&part);
return Err(bad(c, "that model is already installed; remove it before installing a replacement"));
}
std::fs::rename(&part, &final_path).map_err(|e| fail("could not finish the copy", e))?;
write_atomic(&home.join("denoise-model.json"), &serde_json::to_vec_pretty(&m).map_err(|e| fail("manifest", e))?).map_err(EngineError::Other)?;
let accepted = json!({
"acceptedAt": accepted_at,
"licence": m.licence.name,
"commercial": m.licence.commercial,
"fileName": ident.file_name,
"sha256": sha256,
"selfTest": test,
});
write_atomic(&home.join("installed.json"), &serde_json::to_vec_pretty(&accepted).map_err(|e| fail("record", e))?).map_err(EngineError::Other)?;
cleanup.armed = false;
Ok((json!({"installed": row(&m, known::find(&m.id).and_then(|k| host_of(&k)), true, false, &accepted), "model": m.id}), model))
}
/// Removes only files created for this new installation, including on a worker panic.
struct InstallCleanup {
home: PathBuf,
armed: bool,
}
impl Drop for InstallCleanup {
fn drop(&mut self) {
if self.armed {
for name in ["model.onnx.part", "model.onnx", "denoise-model.json", "installed.json"] {
let _ = std::fs::remove_file(self.home.join(name));
}
let _ = std::fs::remove_dir(&self.home);
}
}
}
pub(crate) struct InstallJob {
id: String,
activate: bool,
result: mpsc::Receiver<std::result::Result<(Value, Arc<dyn crate::denoise::Model>), String>>,
}
fn activate_install(s: &mut Session, result: &Value, model: Arc<dyn crate::denoise::Model>, activate: bool) -> Result<Value> {
let dir = models_dir(s, "denoise.models.install")?;
let id = result["installed"]["id"].as_str().ok_or_else(|| bad("denoise.models.install", "missing installed id"))?;
let mut st = read_settings(&dir);
if activate {
st.model = Some(id.to_string());
write_settings(&dir, &st).map_err(EngineError::Other)?;
}
s.denoise.touch();
s.denoise_refresh_active(true);
if let Some(a) = &s.denoise.active
&& a.id == id
{
*a.model.lock().unwrap_or_else(PoisonError::into_inner) = Some(model);
}
let mut v = result.clone();
v["installed"]["selected"] = json!(st.model.as_deref() == Some(id));
v["model"] = json!(st.model);
Ok(v)
}
fn start_install(s: &mut Session, src: PathBuf, id: String, activate: bool, verified: bool) -> Result<Value> {
const C: &str = "denoise.models.install";
if s.denoise.install.is_some() {
return Err(bad(C, "another model is being installed"));
}
let dir = models_dir(s, C)?;
let loader = s.denoise.loader.clone();
let run_on = s.denoise.settings.run_on();
let at = (s.clock)();
let (tx, rx) = mpsc::channel();
std::thread::Builder::new()
.name("denoise-model-install".into())
.stack_size(16 * 1024 * 1024)
.spawn(move || {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _held = crate::memory::work_gate().acquire(512 * 1024 * 1024);
install_file(&dir, &loader, run_on, at, C, &src, true, verified).map_err(|e| plain(&e))
}))
.unwrap_or_else(|_| Err("the model installation stopped unexpectedly".into()));
if verified {
let _ = std::fs::remove_file(&src);
}
let _ = tx.send(outcome);
})
.map_err(|e| bad(C, format!("could not start installation: {e}")))?;
s.denoise.install = Some(InstallJob { id: id.clone(), activate, result: rx });
s.denoise.downloads.set_outcome(&id, State::Installing);
Ok(json!({"started": id}))
}
fn install(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "denoise.models.install";
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
let acknowledged = bool_or(p, "acknowledged", false);
let activate = bool_or(p, "activate", true);
if !acknowledged {
return Err(bad(C, "the licence has not been accepted: show its terms first"));
}
if bool_or(p, "background", false) {
return start_install(s, PathBuf::from(path), "local-install".into(), activate, false);
}
if s.denoise.install.is_some() {
return Err(bad(C, "another model is being installed"));
}
let (result, model) =
install_file(&models_dir(s, C)?, &s.denoise.loader, s.denoise.settings.run_on(), (s.clock)(), C, Path::new(path), acknowledged, false)?;
activate_install(s, &result, model, activate)
}
/// Read only the small adjacent manifest; hashing and execution happen in the install worker.
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "denoise.models.inspect";
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
let src = Path::new(path);
if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) {
return Err(bad(C, "choose an .onnx file with denoise-model.json beside it"));
}
let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES)
.ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?;
let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?;
let dir = models_dir(s, C)?;
Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m,
"sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()}))
}
/// What an error says, without the command it came from (for a line shown to the user).
fn plain(e: &EngineError) -> String {
match e {
EngineError::BadParams { msg, .. } => msg.clone(),
other => other.to_string(),
}
}
/// Install every download that has arrived: each was accepted when it was started, so it is installed and chosen without
/// another question. Called by `denoise.models.downloads` and by every frame's `denoise.pump`.
pub(crate) fn finish_downloads(s: &mut Session) {
if let Some(job) = &s.denoise.install {
let outcome = match job.result.try_recv() {
Ok(v) => Some(v),
Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) => Some(Err("the installation worker stopped".into())),
};
if let Some(outcome) = outcome
&& let Some(job) = s.denoise.install.take()
{
let state = match outcome {
Ok((v, model)) => match activate_install(s, &v, model, job.activate) {
Ok(_) => State::Installed,
Err(e) => State::Failed(plain(&e)),
},
Err(e) => State::Failed(e),
};
s.denoise.downloads.set_outcome(&job.id, state);
}
}
if s.denoise.install.is_none()
&& let Some((id, path, _sha)) = s.denoise.downloads.finished().into_iter().next()
&& let Err(e) = start_install(s, path, id.clone(), true, true)
{
s.denoise.downloads.set_outcome(&id, State::Failed(plain(&e)));
}
}
fn download(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "denoise.models.download";
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let dir = models_dir(s, C)?;
let k = known::find(id).ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?;
let spec = k.download.clone().ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?;
if !cfg!(feature = "denoise") {
return Err(bad(C, "this build cannot run denoise models, so there is nothing to download for it"));
}
if !bool_or(p, "acknowledged", false) {
return Err(bad(
C,
"the licence has not been accepted: show the user the model's terms (see `denoise.models.list`) and pass `acknowledged: true` once they agree",
));
}
if installed_models(&dir).iter().any(|i| i.manifest.id == spec.id) {
return Err(bad(C, "that model is already installed"));
}
let host = lightcraft_denoise::known::host(&spec.url).to_string();
s.denoise.downloads.start(spec, &dir).map_err(|e| bad(C, e))?;
Ok(json!({"started": id, "from": host}))
}
fn download_row(id: &str, state: &State) -> Value {
let from = known::find(id).and_then(|k| host_of(&k));
match state {
State::Running { bytes, total } => json!({"id": id, "state": "running", "bytes": bytes, "total": total, "from": from}),
State::Done { path, .. } => json!({"id": id, "state": "done", "path": path.display().to_string(), "from": from}),
State::Installing => json!({"id": id, "state": "installing", "from": from}),
State::Installed => json!({"id": id, "state": "installed", "from": from}),
State::Failed(why) => json!({"id": id, "state": "failed", "error": why, "from": from}),
State::Cancelled => json!({"id": id, "state": "cancelled", "from": from}),
}
}
fn downloads(s: &mut Session, _: &Value) -> Result<Value> {
finish_downloads(s);
let all: Vec<Value> = s.denoise.downloads.snapshot().iter().map(|(id, st)| download_row(id, st)).collect();
Ok(json!({"running": s.denoise.downloads.running(), "downloads": all}))
}
fn download_cancel(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "denoise.models.downloadCancel";
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
Ok(json!({"discarded": s.denoise.downloads.discard(id)}))
}
fn test(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "denoise.models.test";
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let dir = models_dir(s, C)?;
let found = installed_models(&dir).into_iter().find(|i| i.manifest.id == id).ok_or_else(|| bad(C, "that model is not installed"))?;
let run_on = s.denoise.settings.run_on();
let result = (s.denoise.loader)(&found.onnx, &found.manifest).and_then(|m| m.self_test(run_on));
let (ok, detail) = match &result {
Ok(v) => (true, v.clone()),
Err(e) => (false, json!(e)),
};
// keep the latest outcome with the model's record
let mut record = found.accepted;
if let Some(o) = record.as_object_mut() {
o.insert("selfTest".into(), if ok { detail.clone() } else { json!({"ok": false, "error": detail}) });
write_atomic(&found.onnx.with_file_name("installed.json"), &serde_json::to_vec_pretty(&record).map_err(|e| fail("record", e))?)
.map_err(EngineError::Other)?;
}
Ok(json!({"id": id, "ok": ok, "result": detail}))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "denoise.models.remove";
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let dir = models_dir(s, C)?;
if !lightcraft_denoise::licence::valid_id(id) {
return Err(bad(C, "not a model id"));
}
let home = dir.join(id);
// only a folder that is a model (it has a manifest) is ever deleted
if !home.join("denoise-model.json").is_file() {
return Err(bad(C, "that model is not installed"));
}
std::fs::remove_dir_all(&home).map_err(|e| fail("could not remove the model", e))?;
let mut st = read_settings(&dir);
if st.model.as_deref() == Some(id) {
st.model = installed_models(&dir).into_iter().map(|i| i.manifest.id).find(|m| m != id);
write_settings(&dir, &st).map_err(EngineError::Other)?;
}
s.denoise.touch();
s.denoise_refresh_active(true);
Ok(json!({"removed": id, "model": st.model}))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "denoise.models.select";
let dir = models_dir(s, C)?;
let id = match p.get("id") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| bad(C, "`id` must be a model id or null"))?.to_string()),
};
if let Some(id) = &id
&& !installed_models(&dir).iter().any(|i| &i.manifest.id == id)
{
return Err(bad(C, "that denoise model is not installed"));
}
let mut st = read_settings(&dir);
st.model = id;
write_settings(&dir, &st).map_err(EngineError::Other)?;
s.denoise.touch();
s.denoise_refresh_active(true);
Ok(json!({"model": st.model}))
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "denoise.settings";
let dir = models_dir(s, C)?;
let mut st: Settings = read_settings(&dir);
if let Some(v) = p.get("auto") {
st.auto = Some(v.as_bool().ok_or_else(|| bad(C, "`auto` must be true or false"))?);
}
if let Some(v) = p.get("cacheGb") {
let n =
v.as_u64().filter(|n| (1..=10_000).contains(n)).ok_or_else(|| bad(C, "`cacheGb` must be a whole number of gigabytes from 1 to 10000"))?;
st.cache_gb = Some(n as u32);
}
if let Some(v) = p.get("threads") {
st.threads = match v {
Value::Null => None,
v => Some(v.as_u64().filter(|n| (1..=64).contains(n)).ok_or_else(|| bad(C, "`threads` must be from 1 to 64, or null"))? as u32),
};
}
let run_on = match p.get("runOn") {
None => None,
Some(v) => Some(v.as_str().and_then(RunOn::parse).ok_or_else(|| bad(C, "`runOn` must be auto, gpu or cpu"))?),
};
if let Some(r) = run_on {
st.run_on = Some(r.name().to_string());
st.gpu = None;
}
write_settings(&dir, &st).map_err(EngineError::Other)?;
if run_on.is_some() {
// a choice of where denoise runs is also a go-ahead to try the card again: a set-up that crashed or gave up
// before is forgotten, and the loaded model sets the card up afresh for the next photo
forget_gpu_set_ups(&dir);
if let Some(a) = &s.denoise.active {
*a.model.lock().unwrap_or_else(PoisonError::into_inner) = None;
}
}
s.denoise.touch();
s.denoise_refresh_active(true);
// a smaller limit takes effect now
s.denoise_evict(None);
Ok(json!({"auto": st.auto(), "cacheGb": st.cache_bytes() >> 30, "threads": st.threads, "runOn": st.run_on().name()}))
}
fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
}
fn status_json(s: &Session, photo: Option<PhotoId>) -> Value {
let (files, bytes) = s.denoise_cache_usage();
let st = &s.denoise.settings;
let model = s.denoise.active.as_ref().map(|a| json!({"id": a.id, "name": a.manifest.name}));
let mut v = json!({
"enabled": s.denoise.active.is_some(),
"model": model,
"runtime": cfg!(feature = "denoise"),
"productsDir": s.denoise_products_dir().map(|d| d.display().to_string()),
"queued": s.denoise.queued(),
"running": s.denoise_running_json(),
"ready": s.media.denoise.len(),
"made": s.denoise_made(),
"auto": st.auto(),
"runOn": st.run_on().name(),
"device": s.denoise_device(),
"cache": {"files": files, "bytes": bytes, "limitBytes": st.cache_bytes()},
"generation": s.denoise.generation,
});
if let (Some(id), Some(o)) = (photo, v.as_object_mut()) {
o.insert("photo".into(), serde_json::to_value(s.denoise_photo_state(id)).unwrap_or(Value::Null));
}
v
}
/// A per-photo switch, independent of the selection and Auto Sync. Turning on starts at 50%.
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "denoise.toggle";
let id = match p.get("id") {
Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, "`id` must be a photo id"))?),
None => s.active().ok_or_else(|| bad(C, "select a raw photo"))?,
};
let photo = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?;
if !crate::denoise::eligible(photo) {
return Err(bad(C, "AI Denoise applies to raw photos developed from their sensor data"));
}
let mut d = (*s.develop_of(id).ok_or_else(|| bad(C, "no such photo"))?).clone();
let on = match p.get("enabled") {
Some(v) => v.as_bool().ok_or_else(|| bad(C, "`enabled` must be true or false"))?,
None => !d.enhance.denoise_enabled(),
};
if on {
if !d.enhance.denoise.is_finite() {
return Err(bad(C, "the photo has an invalid Denoise amount"));
}
s.denoise_refresh_active(true);
if s.denoise.active.is_none() {
return Err(bad(C, "AI Denoise needs a model: install one in Settings > AI Denoise"));
}
}
// The switch is its own setting: the Amount stays where it is (0 included) when it is flipped, so flipping compares
// with and without. A photo that never had an Amount starts at 50 the first time.
let amount = if on && d.enhance.denoise_on.is_none() && d.enhance.denoise <= 0.0 { 50.0 } else { d.enhance.denoise };
if d.enhance.denoise != amount || d.enhance.denoise_on != Some(on) {
s.end_interaction()?;
d.enhance.denoise = amount;
d.enhance.denoise_on = Some(on);
let op = s.develop_op(id, d, "AI Denoise").ok_or_else(|| bad(C, "no such photo"))?;
s.commit("AI Denoise", op)?;
}
if on {
s.denoise_enqueue(&[id], true, false);
} else {
s.denoise_cancel(Some(id), false);
}
Ok(json!({"id": id.0, "enabled": on, "amount": amount}))
}
fn queue(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "denoise.queue";
s.denoise_refresh_active(true);
if s.denoise.active.is_none() {
return Err(bad(C, "no denoise model is chosen: install one first (`denoise.models.download` or `denoise.models.install`)"));
}
if s.denoise_products_dir().is_none() {
return Err(bad(C, "denoise keeps its pictures in the library's folder: open a library on disk first"));
}
let ids = match ids_param(p) {
Some(ids) => ids,
None => match str_param(p, "scope").unwrap_or("selected") {
"selected" => s.selection.active.iter().chain(s.selection.ids.iter()).copied().collect(),
"visible" => s.visible_cloned().to_vec(),
"withAmount" => s.catalog.photos().filter(|ph| ph.develop.denoise_amount() > 0.0).map(|ph| ph.id).collect(),
other => return Err(bad(C, format!("unknown scope `{other}`: use selected, visible or withAmount"))),
},
};
let queued = s.denoise_enqueue(&ids, false, bool_or(p, "retry", false));
let mut v = status_json(s, None);
if let Some(o) = v.as_object_mut() {
o.insert("added".into(), json!(queued));
}
Ok(v)
}
fn cancel(s: &mut Session, p: &Value) -> Result<Value> {
let photo = p.get("id").and_then(Value::as_u64).map(PhotoId);
let n = s.denoise_cancel(photo, bool_or(p, "all", photo.is_none()));
Ok(json!({"cancelled": n}))
}
fn status(s: &mut Session, p: &Value) -> Result<Value> {
s.denoise_refresh_active(false);
Ok(status_json(s, p.get("id").and_then(Value::as_u64).map(PhotoId)))
}
/// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going.
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
s.denoise_pump(Pace::parse(p.get("pace").and_then(Value::as_str)));
Ok(json!({
"active": s.denoise.active.is_some(),
"running": s.denoise_running_json(),
"queued": s.denoise.queued(),
"ready": s.media.denoise.len(),
"generation": s.denoise.generation,
"downloading": s.denoise.downloads.running(),
}))
}
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
let (files, bytes) = s.denoise_clear();
Ok(json!({"deleted": files, "bytes": bytes}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "denoise.models.list", "Denoise Models", [], None, "{} → {dir, productsDir, model, runtime, auto, cacheGb, threads, runOn, models: [{id, name, version, licence{name, commercial, url, notice}, provenance, source, sizeBytes, sha256, tile, known, downloadHost, installed, selected, accepted}]}", always, list),
cmd!(query "denoise.models.inspect", "Inspect Denoise Model", [], None, "{path} → model terms from an adjacent manifest; never executes the file", always, inspect),
cmd!(query "denoise.models.install", "Install Denoise Model", [], None, "{path, acknowledged: true, activate?: true, background?: false} → {installed, model} or {started} — install a denoise model from a file: an .onnx with a denoise-model.json beside it, or an archive LightCraft knows (the darktable `.dtmodel`). `acknowledged` must be true: the user has been shown the model's licence and accepted it. Unless `activate` is false it becomes the model in use", always, install),
cmd!(query "denoise.models.download", "Download Denoise Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a model LightCraft has a pinned address for (see `downloadHost` in the list) in the background over HTTPS with lightcraft-fetch. `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, installed and chosen by itself; `denoise.models.downloads` shows how far it is", always, download),
cmd!(query "denoise.models.downloads", "Denoise Model Downloads", [], None, "{} → {running, downloads: [{id, state: running | done | installed | failed | cancelled, bytes, total, error, from}]} — also installs any download that has arrived", always, downloads),
cmd!(query "denoise.models.downloadCancel", "Cancel Denoise Model Download", [], None, "{id} → {discarded} — stop a download, or delete a finished one that was not installed", always, download_cancel),
cmd!(query "denoise.models.test", "Test Denoise Model", [], None, "{id} → {ok, result} — load an installed model and check it gives sensible pictures; needs the denoise runtime", always, test),
cmd!(query "denoise.models.remove", "Remove Denoise Model", [], None, "{id} → {removed, model} — delete an installed model (its cached pictures stay until they are evicted)", always, remove),
cmd!(query "denoise.models.select", "Choose Denoise Model", [], None, "{id: installed model | null} → {model} — null switches denoise off", always, select),
cmd!(query "denoise.settings", "Denoise Settings", [], None, "{auto?: bool, cacheGb?: 1..10000, threads?: 1..64 | null, runOn?: auto | gpu | cpu} → the settings — `auto`: make the picture of the photos being looked at that have a Denoise amount; `cacheGb`: how much the cached pictures may take; `threads`: most tiles run at once; `runOn`: where the model runs — `auto` (the default) the graphics card where it is faster than the processor (both timed on this computer when the card is set up), `gpu` the card whenever it can run the model, `cpu` the processor. Setting `runOn` also lets the card be tried again after a set-up that failed or closed LightCraft", always, settings),
cmd!(
"denoise.toggle",
"AI Denoise",
[],
None,
"{id?: photoId, enabled?: bool} → {id, enabled, amount} — turn AI Denoise on (50% initially) or off for one raw photo, independent of selection and Auto Sync; needs an installed model to turn on",
always,
toggle
),
cmd!(query "denoise.queue", "Denoise Photos", [], None, "{ids?: [photoId], scope?: selected | visible | withAmount, retry?: false} → {added, …status} — make the denoised pictures of raw photos that have none, in the background. `retry` tries photos that failed before", always, queue),
cmd!(query "denoise.cancel", "Cancel Denoise", [], None, "{id?, all?} → {cancelled} — stop the photo being made and/or forget the ones waiting (all of them when `id` is omitted)", always, cancel),
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
cmd!(query "denoise.pump", "Denoise Pump", [], None, "{pace?: pause | light | normal | full} → {active, running, queued, ready, generation, downloading} — called every frame by the app: takes in finished work and keeps one photo going", always, pump),
cmd!(query "denoise.clear", "Clear Denoise Cache", [], None, "{} → {deleted, bytes} — delete every cached denoised picture of this library (they are made again when wanted)", always, clear),
]
}

View File

@ -10,6 +10,7 @@ mod color;
pub(crate) mod convert;
mod cull;
pub mod curves;
pub(crate) mod denoise;
mod develop;
mod edit;
mod export;
@ -143,6 +144,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(missing::specs());
v.extend(metadata::specs());
v.extend(filters::specs());
v.extend(denoise::specs());
v.extend(face_models::specs());
v.extend(face_detect::specs());
v.extend(face_recognize::specs());

View File

@ -1,9 +1,10 @@
//! Where LightCraft keeps per-user data on this machine: the config folder (app settings, face models).
//! Where LightCraft keeps per-user data on this machine: the config folder (app settings, camera
//! profiles, face and denoise models).
//!
//! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model
//! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no
//! face-models folder until a host asks for one ([`Session::with_default_face_models`]), so tests stay
//! hermetic.
//! face- or denoise-models folder until a host asks for one ([`Session::with_default_face_models`],
//! [`Session::with_default_denoise_models`]), so tests stay hermetic.
use std::path::PathBuf;
@ -28,10 +29,30 @@ pub fn default_face_models_dir() -> Option<PathBuf> {
std::env::var_os("LIGHTCRAFT_FACE_MODELS").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("models")))
}
/// Where opt-in denoise models are kept: `$LIGHTCRAFT_DENOISE_MODELS` if set, else `<config>/denoise-models`.
pub fn default_denoise_models_dir() -> Option<PathBuf> {
std::env::var_os("LIGHTCRAFT_DENOISE_MODELS")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| config_dir().map(|d| d.join("denoise-models")))
}
impl Session {
/// Keep face models in the shared default folder ([`default_face_models_dir`]).
pub fn with_default_face_models(mut self) -> Self {
self.face_models_dir = default_face_models_dir();
self
}
/// Keep denoise models in the shared default folder ([`default_denoise_models_dir`]).
pub fn with_default_denoise_models(mut self) -> Self {
self.set_denoise_models_dir(default_denoise_models_dir());
self
}
/// Point AI denoise at `dir` (or at no folder), dropping what was learned about the old one.
pub fn set_denoise_models_dir(&mut self, dir: Option<PathBuf>) {
self.denoise.models_dir = dir;
self.denoise.touch();
}
}

View File

@ -96,3 +96,143 @@ pub fn load(s: &mut Session) {
s.selection = crate::Selection::single(*first);
}
}
/// Procedural scenes are deterministic but costly to evaluate: every pixel runs the scene's noise
/// functions, so a 24 MP demo original takes minutes of CPU time on a small machine (a 4-core CI
/// VM). Each scene at each size is therefore generated once per process and shared:
///
/// - concurrent requests wait for one generation (Before and After at 1:1 both load the
/// original; sessions side by side, e.g. parallel tests, open the same photo);
/// - the most recently used results are kept, up to [`scene_cache_limit`] bytes. They are the same
/// allocations the sessions' source caches hold, so they cost extra memory only once no session
/// holds them any more.
///
/// Only demo photos use this; real files are decoded by their loader as before.
pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Arc<lightcraft_raster::Rgb32f> {
let key = SceneKey { kind: scene.kind, seed: scene.seed, width: scene.width, height: scene.height, max_edge };
let slot = {
let mut c = scene_cache();
c.tick += 1;
let tick = c.tick;
match c.entries.iter_mut().find(|e| e.key == key) {
Some(e) => {
e.used = tick;
e.slot.clone()
}
None => {
let slot = Arc::new(std::sync::OnceLock::new());
c.entries.push(SceneEntry { key, slot: slot.clone(), used: tick });
slot
}
}
};
// (no lock held while generating: other scenes and sizes are served meanwhile)
let pixels = slot.get_or_init(|| Arc::new(scene.render_fit(max_edge))).clone();
trim_scene_cache(&mut scene_cache(), scene_cache_limit());
pixels
}
/// Bytes of generated scenes kept for reuse: half the memory budget (768 MB by default; enough
/// for one 24 MP original and the previews around it).
fn scene_cache_limit() -> usize {
crate::memory::budget() / 2
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct SceneKey {
kind: lightcraft_scenes::Kind,
seed: u32,
width: u32,
height: u32,
max_edge: usize,
}
type SceneSlot = Arc<std::sync::OnceLock<Arc<lightcraft_raster::Rgb32f>>>;
struct SceneEntry {
key: SceneKey,
slot: SceneSlot,
used: u64,
}
#[derive(Default)]
struct SceneCache {
entries: Vec<SceneEntry>,
tick: u64,
}
fn scene_cache() -> std::sync::MutexGuard<'static, SceneCache> {
static CACHE: std::sync::OnceLock<std::sync::Mutex<SceneCache>> = std::sync::OnceLock::new();
CACHE.get_or_init(Default::default).lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn scene_bytes(slot: &SceneSlot) -> usize {
slot.get().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>()))
}
/// Drop the least recently used generated scenes until the rest fit in `limit` bytes. Scenes still
/// being generated are kept (their waiters hold the slot anyway).
fn trim_scene_cache(c: &mut SceneCache, limit: usize) {
loop {
let total: usize = c.entries.iter().map(|e| scene_bytes(&e.slot)).fold(0, usize::saturating_add);
if total <= limit {
return;
}
let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.get().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else {
return;
};
c.entries.swap_remove(i);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn small_scene() -> lightcraft_scenes::Scene {
lightcraft_scenes::demo_library().swap_remove(0)
}
#[test]
fn a_scene_is_generated_once_per_size_and_shared() {
let s = small_scene();
let a = scene_pixels(&s, 48);
let b = scene_pixels(&s, 48);
assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation");
assert_eq!(a.width.max(a.height), 48);
assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly");
let c = scene_pixels(&s, 40);
assert_eq!(c.width.max(c.height), 40, "another size is its own generation");
}
#[test]
fn concurrent_requests_wait_for_one_generation() {
let s = small_scene();
let edge = 37; // a size no other test asks for
let got: Vec<_> = std::thread::scope(|sc| {
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge))).collect();
hs.into_iter().map(|h| h.join().unwrap()).collect()
});
assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels");
}
#[test]
fn the_cache_keeps_the_most_recent_scenes_within_its_limit() {
let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge };
let entry = |max_edge, used, px: Option<usize>| {
let slot: SceneSlot = Arc::new(std::sync::OnceLock::new());
if let Some(n) = px {
let _ = slot.set(Arc::new(lightcraft_raster::Rgb32f::new(n, 1)));
}
SceneEntry { key: key(max_edge), slot, used }
};
let px = std::mem::size_of::<[f32; 3]>();
let mut c = SceneCache { entries: vec![entry(1, 3, Some(10)), entry(2, 1, Some(10)), entry(3, 0, None), entry(4, 2, Some(10))], tick: 3 };
trim_scene_cache(&mut c, 25 * px);
let mut kept: Vec<usize> = c.entries.iter().map(|e| e.key.max_edge).collect();
kept.sort_unstable();
assert_eq!(kept, vec![1, 3, 4], "the least recently used finished scene went; the one in progress stays");
trim_scene_cache(&mut c, 0);
assert_eq!(c.entries.iter().map(|e| e.key.max_edge).collect::<Vec<_>>(), vec![3], "only the scene in progress is left");
}
}

1644
crates/engine/src/denoise.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ use lightcraft_pipeline::SourceInfo;
use lightcraft_raster::Rgb32f;
use lightcraft_raster::resample::{Filter, fit};
use crate::media::{FileLoader, FileProbe, PreviewLoader, ProbeInfo};
use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo};
fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default();
@ -220,22 +220,56 @@ pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option<usi
/// instead of each one waking the pool from outside and waiting for it (which costs more than the
/// loops themselves when the machine is busy).
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge))
rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None).map(|(img, _, info)| (img, info)))
}
/// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded
/// (less memory held while it is developed).
pub fn load_vec(bytes: Vec<u8>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge))
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None).map(|(img, _, info)| (img, info)))
}
fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
/// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain
/// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all
/// there is) and the source facts. Both pictures are the same size.
pub fn load_vec_pair(bytes: Vec<u8>, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec)))
}
/// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default
/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned. `None` when the product
/// is missing, damaged, made from something else or not the size of this raw's active area.
fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, factor: usize) -> Option<Rgb32f> {
use lightcraft_denoise::product::{self, Window};
let a = raw.active_area;
let header = product::read_header_at(&spec.product).ok()?;
if header.key != spec.key || (header.width, header.height) != (a.width, a.height) {
return None;
}
if raw.opcodes.list3.is_empty() {
let c = raw.develop_crop(a.width, a.height);
let window = Window { x: c.x, y: c.y, width: c.width, height: c.height };
product::read_window(&spec.product, &spec.key, Some(window), factor, Some(HIGHLIGHT_CLIP)).ok()
} else {
// (the plain development is not binned either when the file has opcodes of this kind: `factor` is 1)
let whole = product::read(&spec.product, &spec.key, 1).ok()?;
Some(raw.finish_demosaiced(whole))
}
}
fn load_bytes_now(
bytes: std::borrow::Cow<'_, [u8]>,
max_edge: usize,
denoise: Option<&DenoiseSpec>,
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
if lightcraft_raw::probe(&bytes).is_some() {
let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
Ok(r) => r,
Err(Ok(why)) => {
// show the camera's embedded JPEG (rendered, not raw) until the variant is supported
return load_embedded_preview(&bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview"));
return load_embedded_preview(&bytes, max_edge)
.map(|(img, info)| (img, None, info))
.ok_or(format!("unsupported raw ({why}) without an embedded preview"));
}
Err(Err(e)) => return Err(e),
};
@ -251,48 +285,57 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
// larger levels (exports, 1:1) demosaic the whole sensor.
let t0 = web_time::Instant::now();
let binned = match bin_factor(&raw, max_edge) {
let bin = bin_factor(&raw, max_edge);
let binned = match bin {
Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
None => None,
};
let mut img = match binned {
let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 };
let img = match binned {
Some(img) => img,
None => {
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
raw.develop(method).map_err(|e| e.to_string())?
}
};
// the same development from the denoised mosaic, when the photo has been denoised
let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height);
// the samples aren't needed any more (the colour model below reads only the tags)
raw.data = lightcraft_raw::RawData::U16(Vec::new());
let mut stages = vec![("develop", t0.elapsed())];
stages.push(("transform", t0.elapsed()));
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP);
stages.push(("highlights", t0.elapsed()));
let wb = t.wb;
let m = camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new);
let gain = 2f32.powf(t.baseline_exposure as f32);
let wb = t.wb;
// A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6.
let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy));
img.map_in_place(|p| {
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
let rgb = [
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
];
let rgb = match &tables {
Some(tables) => tables.apply(rgb, gain),
None => rgb.map(|v| v * gain),
};
// after the baseline exposure, as when it was fitted
hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
});
stages.push(("colour", t0.elapsed()));
let img = fit(&img, max_edge, max_edge, Filter::Box);
stages.push(("fit", t0.elapsed()));
let img = img.into_oriented(raw.orientation);
stages.push(("orient", t0.elapsed()));
// camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied,
// fitted to `max_edge` and upright (the same for the plain and the denoised picture)
let finish = |mut img: Rgb32f| {
let mut stages = vec![("develop", t0.elapsed())];
stages.push(("transform", t0.elapsed()));
lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP);
stages.push(("highlights", t0.elapsed()));
img.map_in_place(|p| {
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
let rgb = [
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
];
let rgb = match &tables {
Some(tables) => tables.apply(rgb, gain),
None => rgb.map(|v| v * gain),
};
hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
});
stages.push(("colour", t0.elapsed()));
let img = fit(&img, max_edge, max_edge, Filter::Box);
stages.push(("fit", t0.elapsed()));
let img = img.into_oriented(raw.orientation);
stages.push(("orient", t0.elapsed()));
(img, stages)
};
let (img, stages) = finish(img);
if lightcraft_pipeline::profiling() {
let mut prev = std::time::Duration::ZERO;
let parts: Vec<String> = stages
@ -305,6 +348,7 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
.collect();
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
}
let twin = twin.map(|picture| finish(picture).0);
let (temp, tint) = xy_to_temp_tint(xy);
let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback;
// White balance re-evaluates the file's own colour model (when it has one and no
@ -317,6 +361,7 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
return Ok((
img,
twin,
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes },
));
}
@ -324,7 +369,7 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
drop(bytes);
let img = d.to_working();
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), SourceInfo::default()))
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
}
/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop,
@ -462,6 +507,21 @@ pub fn fs_preview_loader() -> PreviewLoader {
})
}
/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's
/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load.
pub fn fs_pair_loader() -> PairLoader {
Arc::new(|path: &str, max_edge: usize, spec: &DenoiseSpec| {
let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0);
let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 4 } else { 10 };
let gate = crate::memory::work_gate();
let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) };
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let r = load_vec_pair(bytes, max_edge, spec);
crate::memory::release();
r
})
}
/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead.
///
/// Their working memory is bounded by [`crate::memory::work_gate`]: background loads (grid
@ -497,6 +557,7 @@ impl crate::Session {
self.media.file_loader = Some(l);
self.media.file_probe = Some(p);
self.media.preview_loader = Some(fs_preview_loader());
self.media.denoise.loader = Some(fs_pair_loader());
self
}
}
@ -537,6 +598,37 @@ mod tests {
assert!(probe_bytes("x.jpg", b"not an image").is_err());
}
#[test]
fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() {
use lightcraft_denoise::product;
use lightcraft_raw::{DngWriteOptions, Method, Orientation, profile::HsvTable, write_dng};
let dir = crate::tests_xmp::temp_dir("denoise-color-finish");
let fixture = crate::tests_xmp::synthetic_dng_with(None, Default::default());
let mut raw = lightcraft_raw::decode(&fixture).unwrap();
raw.orientation = Orientation::Rotate90;
raw.color.baseline_exposure = 0.5;
raw.color.profile.look_table =
Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[23.0, 0.65, 0.8]; 2], srgb_value: false });
let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap();
let decoded = lightcraft_raw::decode(&bytes).unwrap();
assert!(decoded.color.profile.look_table.is_some());
// A cache containing the ordinary demosaic must undergo the identical downstream finish.
let camera_rgb = decoded.develop(Method::Ahd).unwrap();
let key = "synthetic-color-finish";
let path = dir.join("same.lcdn");
product::write(&path, &camera_rgb, key).unwrap();
let spec = DenoiseSpec { product: path, key: key.into(), make: None };
let (ordinary, _) = load_bytes(&bytes, 2048).unwrap();
let (plain, cached, _) = load_vec_pair(bytes, 2048, &spec).unwrap();
assert_eq!(plain.data, ordinary.data, "requesting a twin must not change the ordinary path");
let cached = cached.unwrap();
assert_eq!((plain.width, plain.height), (24, 32));
assert_eq!((cached.width, cached.height), (plain.width, plain.height));
let worst = plain.data.iter().zip(&cached.data).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max);
assert!(worst < 0.002, "color finish differs beyond cache half-float rounding: {worst}");
let _ = std::fs::remove_dir_all(dir);
}
/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG
/// mostly cancel, edges remain.
fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec<f32> {

View File

@ -10,6 +10,10 @@
//! run them off the UI thread.
#![forbid(unsafe_code)]
// Model/cache types remain available without linking the optional inference crate.
#[cfg(not(feature = "denoise"))]
extern crate lightcraft_denoise_core as lightcraft_denoise;
pub mod availability;
mod camera_preview;
pub mod camera_profiles;
@ -18,6 +22,7 @@ pub mod config;
pub mod crs;
pub mod crs_masks;
pub mod demo;
pub mod denoise;
pub mod devices;
pub mod export;
pub mod face_download;
@ -39,6 +44,7 @@ pub mod logging;
pub mod media;
pub mod memory;
pub mod merge;
mod model_download;
pub mod originals;
pub mod preset_import;
pub mod preset_luminar;
@ -138,8 +144,10 @@ pub struct Interaction {
/// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one).
static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
pub struct Session {
library_generation: u64,
/// Auto Sync: edits to the active photo also change the other selected photos (the settings
/// that changed, nothing else).
pub auto_sync: bool,
@ -163,6 +171,8 @@ pub struct Session {
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
pub(crate) skip_auto_write: bool,
/// AI denoise: the model in use, the photos that have their picture and the work in progress.
pub(crate) denoise: denoise::State,
/// Where the host keeps face models (one folder each); `None` where there is no file system (the web).
pub face_models_dir: Option<std::path::PathBuf>,
/// Face model downloads started this session (the staged files wait in `<face_models_dir>/.downloads`).
@ -262,8 +272,13 @@ impl Default for Session {
}
impl Session {
pub fn library_generation(&self) -> u64 {
self.library_generation
}
pub fn new() -> Session {
Session {
library_generation: LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
auto_sync: false,
catalog: Catalog::new(),
source: LibrarySource::All,
@ -278,6 +293,7 @@ impl Session {
redo: Vec::new(),
interaction: None,
skip_auto_write: false,
denoise: Default::default(),
face_models_dir: None,
face_downloads: Default::default(),
face_catalog: Default::default(),
@ -826,6 +842,8 @@ mod tests_album_order;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
mod tests_denoise;
#[cfg(test)]
mod tests_export;
#[cfg(test)]
mod tests_face_models;

View File

@ -297,6 +297,9 @@ impl Session {
{
log::error!("library: {e}");
}
// Photo ids belong to one library; denoise work must not carry into the next.
self.denoise.library_changed();
self.media.denoise.clear();
// what was learned about the old library's faces is saved, and none of it carries over (photo ids are per library)
#[cfg(not(target_arch = "wasm32"))]
self.faces.library_changed();
@ -308,6 +311,7 @@ impl Session {
static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32;
self.catalog = catalog;
self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.undo.clear();
self.redo.clear();
self.interaction = None;

View File

@ -125,6 +125,43 @@ pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), St
/// `max_edge` (set by the app). `None`: no usable preview.
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
/// Makes a photo's denoised picture when it is not cached yet (an export does not wait for the background queue):
/// `Ok(true)` once it is there, `Ok(false)` when denoise does not apply to this photo (render it plain), `Err` when it
/// should have worked and did not.
pub type MakeProduct = Arc<dyn Fn() -> Result<bool, String> + Send + Sync>;
/// A raw photo's cached AI-denoised picture (see [`crate::denoise`]): where the file is and the key it must have been
/// made with. A file that is missing, damaged or made for something else is a cache miss, never an error.
#[derive(Clone)]
pub struct DenoiseSpec {
pub product: std::path::PathBuf,
pub key: String,
/// Run before the picture is read, by a job that must not render without it (an export).
pub make: Option<MakeProduct>,
}
/// Decodes a raw file once and develops it twice: as usual, and from the denoised picture in `spec` when that can be
/// read (`None` when it cannot). Both are at most `max_edge` long and the same size.
pub type PairLoader = Arc<dyn Fn(&str, usize, &DenoiseSpec) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> + Send + Sync>;
/// A photo's denoised picture next to its plain one, and the last mix of the two that was asked for.
pub struct Twin {
pub image: Arc<Rgb32f>,
/// The Amount (in thousandths) and the picture mixed for it, so dragging other sliders keeps one source picture.
mix: std::sync::Mutex<Option<(u32, Arc<Rgb32f>)>>,
}
impl Twin {
pub fn new(image: Arc<Rgb32f>) -> Twin {
Twin { image, mix: std::sync::Mutex::new(None) }
}
/// Bytes reserved for the twin and its lazy mix, before either cache cost can grow.
fn bytes(&self) -> usize {
self.image.data.len().saturating_mul(24)
}
}
/// Pixels and the source interpretation learned while decoding; they must be cached together.
#[derive(Clone)]
pub struct DecodedSource {
@ -133,11 +170,46 @@ pub struct DecodedSource {
/// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the
/// catalog's header facts lack (used when `info` is `None`).
pub camera_tone: Option<lightcraft_pipeline::tone::CameraTone>,
/// The same picture developed from the AI-denoised mosaic, when the photo has one cached.
pub denoised: Option<Arc<Twin>>,
}
impl DecodedSource {
pub fn new(image: Arc<Rgb32f>, info: Option<SourceInfo>) -> Self {
DecodedSource { image, info, camera_tone: None }
DecodedSource { image, info, camera_tone: None, denoised: None }
}
/// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one,
/// the denoised one, or a mix of the two. A mix is made once per amount and kept, so the stages after it are
/// reused until the amount changes.
pub fn image_for(&self, amount: f32) -> Arc<Rgb32f> {
let Some(twin) = &self.denoised else { return self.image.clone() };
if amount.is_nan() || amount <= 0.0 || twin.image.width != self.image.width || twin.image.height != self.image.height {
return self.image.clone();
}
if amount >= 1.0 {
return twin.image.clone();
}
let thousandths = (amount * 1000.0).round().clamp(1.0, 999.0) as u32;
let mut memo = twin.mix.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((t, img)) = memo.as_ref()
&& *t == thousandths
{
return img.clone();
}
let mut mixed = (*self.image).clone();
if !lightcraft_denoise::product::blend(&mut mixed, &twin.image, thousandths as f32 / 1000.0) {
return self.image.clone();
}
let mixed = Arc::new(mixed);
*memo = Some((thousandths, mixed.clone()));
mixed
}
/// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix.
pub fn bytes(&self) -> usize {
let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes());
self.image.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes())
}
/// What to render these pixels against: the decoder's facts, else `header` (the catalog's)
@ -161,6 +233,8 @@ pub enum SourceRef {
/// The photo's smart preview, used when the original can't be read (offline drive). The
/// UI thread never checks whether the original is there; the render worker finds out.
fallback: Option<std::path::PathBuf>,
/// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]).
denoise: Option<(DenoiseSpec, PairLoader)>,
},
/// A smart preview standing in for a missing original.
Smart {
@ -176,16 +250,46 @@ impl SourceRef {
pub fn load_source(&self) -> Result<DecodedSource, String> {
let image = match self {
SourceRef::Loaded(a) => return Ok((**a).clone()),
SourceRef::Demo { scene, max_edge } => Arc::new(scene.render_fit(*max_edge)),
SourceRef::File { path, max_edge, loader, fallback } => {
let r = match loader {
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge),
SourceRef::File { path, max_edge, loader, fallback, denoise } => {
let plain = || match loader {
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
None => Err(format!("no decoder available for {path}")),
};
// the denoised picture comes with the plain one from a single decode; when it cannot be read the
// pair loader gives the plain picture alone (the cached file is never a reason to fail a render)
let r = match denoise {
Some((spec, pair)) => {
// a job that needs the denoised picture has it made first; if that fails the job fails (no smart
// preview stands in: an export must not quietly come out without the denoise it was asked for)
let made = match &spec.make {
Some(make) => make()?,
None => true,
};
if made {
pair(path, *max_edge, spec).and_then(|(image, twin, info)| {
if spec.make.is_some() && twin.is_none() {
return Err("the cached AI Denoise picture could not be read; clear the denoise cache and retry".into());
}
Ok(DecodedSource {
image: Arc::new(image),
info: Some(info),
camera_tone: None,
denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))),
})
})
} else {
plain()
}
}
None => plain(),
};
// An offline original renders from its smart preview (no decoder facts: header ones).
return match (r, fallback) {
#[cfg(not(target_arch = "wasm32"))]
(Err(e), Some(sp)) if crate::smart::is_valid(sp) => crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")),
(Err(e), Some(sp)) if denoise.as_ref().is_none_or(|(spec, _)| spec.make.is_none()) && crate::smart::is_valid(sp) => {
crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}"))
}
(r, _) => r,
};
}
@ -224,6 +328,54 @@ pub struct MediaCache {
scenes: Vec<lightcraft_scenes::Scene>,
/// Rendered thumbnails (memory, plus disk once a library is attached).
pub rendered: Arc<PreviewCache>,
/// Which photos have a cached denoised picture, and how to read it.
pub denoise: DenoiseIndex,
}
/// The photos that have a cached denoised picture for the denoise model in use. Only memory: nothing here touches the
/// file system, so it can be asked on the UI thread.
#[derive(Default)]
pub struct DenoiseIndex {
ready: std::collections::HashMap<PhotoId, DenoiseSpec>,
/// Reads the pictures (`None`: this build cannot, and nothing is ever ready).
pub loader: Option<PairLoader>,
}
impl DenoiseIndex {
/// What to load for `id` besides its plain picture, and how.
pub fn get(&self, id: PhotoId) -> Option<(&DenoiseSpec, &PairLoader)> {
self.ready.get(&id).zip(self.loader.as_ref())
}
pub fn spec(&self, id: PhotoId) -> Option<&DenoiseSpec> {
self.ready.get(&id)
}
pub fn set(&mut self, id: PhotoId, spec: DenoiseSpec) {
self.ready.insert(id, spec);
}
pub fn remove(&mut self, id: PhotoId) -> bool {
self.ready.remove(&id).is_some()
}
pub fn clear(&mut self) {
self.ready.clear();
}
pub fn len(&self) -> usize {
self.ready.len()
}
pub fn is_empty(&self) -> bool {
self.ready.is_empty()
}
pub fn ids(&self) -> Vec<PhotoId> {
let mut v: Vec<PhotoId> = self.ready.keys().copied().collect();
v.sort();
v
}
}
impl Default for MediaCache {
@ -244,6 +396,7 @@ impl Default for MediaCache {
availability: Default::default(),
scenes: Vec::new(),
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
denoise: DenoiseIndex::default(),
}
}
}
@ -253,11 +406,6 @@ fn rendered_budget(share: usize) -> usize {
RENDERED_MEM_BYTES.min(share / 4)
}
fn source_bytes(s: &DecodedSource) -> usize {
let mattes = s.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes());
s.image.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>() + mattes
}
impl MediaCache {
/// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired
/// (its files stay): render jobs still holding it can't write through it any more, even
@ -320,8 +468,8 @@ impl MediaCache {
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
let freed = match which {
0 => self.thumbs.pop_oldest(),
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1)),
2 => self.full.take().map(|e| source_bytes(&e.1)),
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| self.previews.remove(i).1.bytes()),
2 => self.full.take().map(|e| e.1.bytes()),
_ => self.rendered.evict_oldest(),
};
match freed {
@ -373,7 +521,7 @@ impl MediaCache {
let tick = lightcraft_preview::next_tick();
match level {
SourceLevel::Thumb => {
let cost = source_bytes(&img);
let cost = img.bytes();
self.thumbs.insert(id, img, cost);
}
SourceLevel::Preview => {
@ -416,13 +564,42 @@ impl MediaCache {
/// Decoded sources held: (thumbnail level, preview level, full size).
pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) {
use crate::memory::Usage;
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1)).sum());
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1))).unwrap_or_default();
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| e.1.bytes()).sum());
let full = self.full.as_ref().map(|e| Usage::new(1, e.1.bytes())).unwrap_or_default();
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
}
/// What a render of photo `id` with settings `s` adds to its identity for the denoised picture it will use: 0 unless
/// it uses one (the Denoise amount is above 0 and the photo has a cached denoised picture).
pub fn denoise_salt(&self, id: PhotoId, s: &DevelopSettings) -> u64 {
match self.denoise.spec(id) {
Some(spec) if s.denoise_amount() > 0.0 => Hasher128::new().str(&spec.key).finish().0 as u64 | 1,
_ => 0,
}
}
/// How to load photo `p` at `level`: from the cache when its source is there, else from its file (with its denoised
/// picture beside it when it has one), its smart preview or the demo scene.
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
if let Some(a) = self.get_source(p.id, level) {
self.source_ref_with(p, level, true)
}
/// [`Self::source_ref`] that never takes a decoded source from memory: for a job that needs something the cached
/// one lacks (an export, whose denoised picture is made when it runs).
pub fn source_ref_uncached(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
self.source_ref_with(p, level, false)
}
fn source_ref_with(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef {
let mut r = self.source_ref_plain(p, level, cached);
if let (SourceRef::File { denoise, .. }, Some((spec, loader))) = (&mut r, self.denoise.get(p.id)) {
*denoise = Some((spec.clone(), loader.clone()));
}
r
}
fn source_ref_plain(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef {
if cached && let Some(a) = self.get_source(p.id, level) {
return SourceRef::Loaded(Box::new(a));
}
// Procedural scenes have a nominal size: "full" is that size, not unbounded.
@ -460,10 +637,12 @@ impl MediaCache {
}
match self.scenes.iter().find(|s| s.id == *scene) {
Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge },
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None },
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None },
}
}
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None },
Source::File { path } => {
SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None }
}
}
}
}
@ -574,7 +753,8 @@ impl RenderJob {
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
match self.source.load_source() {
Ok(source) => {
let src = &source.image;
// the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for
let src = &source.image_for(self.settings.denoise_amount());
let info = source.info_or(self.info);
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
// the GPU when there is one.
@ -758,22 +938,23 @@ impl crate::Session {
let content = source_key.0 as u64;
// Temporary "before" settings must not replace the photo's cached develop hash.
let settings_hash = if before { settings.hash64() } else { self.media.settings_hashes.get(id, &settings) };
let salt = self.media.denoise_salt(id, &settings);
let key = settings_hash
^ ((max_w as u64) << 40)
^ ((max_h as u64) << 20)
^ (apply_crop as u64)
^ (level as u64) << 60
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15)
^ content.rotate_left(17);
^ content.rotate_left(17)
^ salt;
let cache = thumb_bucket.map(|b| {
let k = Hasher128::new()
.str(&content_key)
.u64(settings_hash)
.u64(b as u64)
.u64(RENDER_CACHE_VERSION)
.u64(crate::camera_profiles::cache_key())
.finish();
(self.media.rendered.clone(), k)
let mut k = Hasher128::new();
k.str(&content_key).u64(settings_hash).u64(b as u64).u64(RENDER_CACHE_VERSION).u64(crate::camera_profiles::cache_key());
// (only a render that uses a denoised picture has it in its key, so every other thumbnail stays valid)
if salt != 0 {
k.u64(salt);
}
(self.media.rendered.clone(), k.finish())
});
Some(RenderJob {
request_id: 0,
@ -1064,6 +1245,15 @@ impl crate::Session {
depth: lightcraft_pipeline::OutputDepth,
) -> Result<RenderJob, String> {
let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
// An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the
// background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.)
if let Some(wanted) = self.denoise_for_export(id, &job.settings)? {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
job.source = self.media.source_ref_uncached(&p, job.level);
if let SourceRef::File { denoise, .. } = &mut job.source {
*denoise = Some(wanted);
}
}
job.request.space = space;
job.request.depth = depth;
job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db);
@ -1211,6 +1401,20 @@ mod thumbnail_hash_tests {
mod tests {
use super::*;
#[test]
fn denoise_mix_is_reserved_before_the_source_enters_the_cache() {
let mut plain = Rgb32f::new(8, 8);
plain.data.fill([1.0; 3]);
let mut source = DecodedSource::new(Arc::new(plain), None);
source.denoised = Some(Arc::new(Twin::new(Arc::new(Rgb32f::new(8, 8)))));
let reserved = source.bytes();
assert!(reserved >= 3 * 8 * 8 * 12);
assert_eq!(source.image_for(0.5).data[0], [0.5; 3]);
assert_eq!(source.bytes(), reserved, "creating a mix cannot evade the LRU's recorded cost");
let _ = source.image_for(0.25);
assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix");
}
#[test]
fn decoder_info_survives_render_jobs_cache_and_eviction() {
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
@ -1232,6 +1436,7 @@ mod tests {
Ok((image, loaded_info.clone()))
})),
fallback: None,
denoise: None,
};
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
job.settings = Arc::new(DevelopSettings::default());

View File

@ -0,0 +1,359 @@
//! Downloading a denoise model explicitly requested after showing its terms.
//!
//! The transfer is [`lightcraft_fetch`] (pure-Rust HTTPS with resume, size and SHA-256 checks), on a background thread,
//! so the UI never waits for the network. Only a model listed in [`lightcraft_denoise::known::Download`] can be fetched:
//! the address and hash come from our pinned catalog or the user's model catalog, and no token is sent with it. What arrives
//! matches the model's recorded size and SHA-256 or it is thrown away; it then waits in a staging folder until the model
//! is installed (`denoise.models.install`, or by itself once the user has accepted the model's terms in the download).
//!
//! Failure is a message, never a panic: no network, a stalled or truncated transfer, a full disk, a file that is not the
//! one expected, and a panic inside the thread itself all end as [`State::Failed`].
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use lightcraft_denoise::known::Download;
/// The folder inside the models folder where a downloaded file waits to be installed. Its name starts with a dot, so it
/// can never be a model's own folder (ids start with a letter or digit).
pub const STAGING: &str = ".downloads";
/// Where a download stands.
#[derive(Clone, Debug, PartialEq)]
pub enum State {
/// Fetching (`bytes` of `total` so far); the file is checked as the last step.
Running {
bytes: u64,
total: u64,
},
/// Fetched and verified (its SHA-256 is `sha256`), waiting in the staging folder at `path` to be installed.
Done {
path: PathBuf,
sha256: String,
},
/// Installed and in use: nothing more to do but tell the user.
Installing,
Installed,
Failed(String),
Cancelled,
}
struct Shared {
state: Mutex<State>,
cancel: AtomicBool,
}
impl Shared {
fn new(total: u64) -> Self {
Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: AtomicBool::new(false) }
}
fn set(&self, s: State) {
*self.state.lock().unwrap_or_else(PoisonError::into_inner) = s;
}
fn get(&self) -> State {
self.state.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
}
/// The downloads of one session, by model id.
#[derive(Default)]
pub struct Downloads {
jobs: HashMap<String, Arc<Shared>>,
}
impl Downloads {
/// Start fetching `spec` into `<models dir>/.downloads/`. Different models can download side by side; the same
/// one is started once.
pub fn start(&mut self, spec: Download, models_dir: &Path) -> Result<(), String> {
if self.jobs.get(&spec.id).is_some_and(|j| matches!(j.get(), State::Running { .. })) {
return Err("that model is already downloading".into());
}
let staging = models_dir.join(STAGING);
std::fs::create_dir_all(&staging).map_err(|e| format!("could not create the download folder: {e}"))?;
let shared = Arc::new(Shared::new(spec.size_bytes));
let worker = shared.clone();
let id = spec.id.clone();
std::thread::Builder::new()
.name("denoise-model-download".into())
.spawn(move || {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, &spec.url, &staging, &worker)));
if outcome.is_err() {
worker.set(State::Failed("the download stopped unexpectedly".into()));
}
})
.map_err(|e| format!("could not start the download: {e}"))?;
self.jobs.insert(id, shared);
Ok(())
}
/// Every download this session has started and not discarded, by model id.
pub fn snapshot(&self) -> Vec<(String, State)> {
let mut all: Vec<_> = self.jobs.iter().map(|(id, j)| (id.clone(), j.get())).filter(|(_, s)| *s != State::Cancelled).collect();
all.sort_by(|a, b| a.0.cmp(&b.0));
all
}
pub fn running(&self) -> bool {
self.jobs.values().any(|j| matches!(j.get(), State::Running { .. }))
}
/// Stop a running download, or discard a finished one (its staged file is deleted). `true` if there was one.
pub fn discard(&mut self, id: &str) -> bool {
let Some(job) = self.jobs.get(id) else { return false };
job.cancel.store(true, Ordering::Relaxed);
match job.get() {
// the thread notices, deletes the partial file and marks itself cancelled
State::Running { .. } | State::Installing => {}
State::Done { path, .. } => {
let _ = std::fs::remove_file(path);
self.jobs.remove(id);
}
State::Installed | State::Failed(_) | State::Cancelled => {
self.jobs.remove(id);
}
}
true
}
/// Downloads that have arrived and been checked, ready to install: (model id, staged file, its SHA-256).
pub fn finished(&self) -> Vec<(String, PathBuf, String)> {
let mut out: Vec<_> = self
.jobs
.iter()
.filter_map(|(id, j)| match j.get() {
State::Done { path, sha256 } => Some((id.clone(), path, sha256)),
_ => None,
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
/// Record how installing a finished download went (`Installed` or `Failed`).
pub fn set_outcome(&mut self, id: &str, state: State) {
match self.jobs.get(id) {
Some(j) => j.set(state),
None => {
let j = Shared::new(0);
j.set(state);
self.jobs.insert(id.to_string(), Arc::new(j));
}
}
}
}
impl Drop for Downloads {
/// Quitting while a download runs stops it instead of leaving it behind.
fn drop(&mut self) {
for job in self.jobs.values() {
job.cancel.store(true, Ordering::Relaxed);
}
let started = web_time::Instant::now();
while self.running() && started.elapsed() < Duration::from_secs(1) {
std::thread::sleep(Duration::from_millis(10));
}
}
}
/// What went wrong, in words the user can act on.
#[cfg(not(target_arch = "wasm32"))]
fn explain(e: &lightcraft_fetch::DownloadError) -> String {
use lightcraft_fetch::DownloadError;
match e {
DownloadError::AllMirrorsFailed { errors, .. } => {
let detail: String = errors.join("; ").chars().take(300).collect();
format!("could not download the model ({detail}): check the internet connection, or get the file from its page and add it yourself")
}
other => other.to_string(),
}
}
/// Fetch `spec` from its exact URL into `staging`, check it and leave it there.
#[cfg(not(target_arch = "wasm32"))]
fn fetch(spec: &Download, mirror: &str, staging: &Path, shared: &Shared) -> Result<(), lightcraft_fetch::DownloadError> {
let file = lightcraft_fetch::FileSpec { name: &spec.file_name, size: Some(spec.size_bytes), sha256: Some(&spec.sha256), max: spec.size_bytes };
lightcraft_fetch::download_url(&file, mirror, staging, &lightcraft_fetch::Options::default(), &shared.cancel, &mut |p| {
shared.set(State::Running { bytes: p.done.min(spec.size_bytes), total: spec.size_bytes });
})
}
#[cfg(target_arch = "wasm32")]
fn fetch(_: &Download, _: &str, _: &Path, _: &Shared) -> Result<(), String> {
Err("the web build downloads no models".into())
}
/// The whole job: reuse a verified earlier copy, else fetch, check and stage. Always ends in a final [`State`].
fn run(spec: &Download, mirror: &str, staging: &Path, shared: &Shared) {
let target = staging.join(&spec.file_name);
let part = staging.join(format!("{}.part", spec.file_name));
match fetch(spec, mirror, staging, shared) {
Ok(()) => shared.set(State::Done { path: target, sha256: spec.sha256.to_string() }),
#[cfg(not(target_arch = "wasm32"))]
Err(lightcraft_fetch::DownloadError::Cancelled) => {
let _ = std::fs::remove_file(&part);
shared.set(State::Cancelled);
}
#[cfg(not(target_arch = "wasm32"))]
Err(e) => {
shared.set(State::Failed(explain(&e)));
}
#[cfg(target_arch = "wasm32")]
Err(e) => {
let _ = std::fs::remove_file(&part);
shared.set(State::Failed(e));
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use lightcraft_denoise::hash::sha256_hex;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-face-dl-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn spec_for(bytes: &[u8]) -> Download {
Download {
id: "test-model".into(),
url: "https://example.invalid/m/test-model.onnx".into(),
file_name: "test-model.onnx".into(),
size_bytes: bytes.len() as u64,
sha256: sha256_hex(bytes),
}
}
/// A local server that answers every request with `status` and `body` (the whole file, no ranges).
fn serve(status: &'static str, body: Vec<u8>) -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}/m", l.local_addr().unwrap());
std::thread::spawn(move || {
for mut s in l.incoming().flatten() {
let mut r = BufReader::new(s.try_clone().unwrap());
loop {
let mut line = String::new();
if r.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {
break;
}
}
let _ = s.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes());
let _ = s.write_all(&body);
}
});
base
}
#[test]
fn fetches_checks_and_stages_a_file() {
let dir = scratch("ok");
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
let (staging, spec) = (dir.join(STAGING), spec_for(&bytes));
std::fs::create_dir_all(&staging).unwrap();
let mirror = serve("200 OK", bytes.clone());
let shared = Shared::new(spec.size_bytes);
run(&spec, &mirror, &staging, &shared);
let State::Done { path, sha256 } = shared.get() else { panic!("{:?}", shared.get()) };
assert_eq!((std::fs::read(&path).unwrap(), sha256), (bytes, spec.sha256.to_string()));
assert!(!staging.join("test-model.onnx.part").exists());
// asked again with the copy still staged: no fetch needed (nothing listens there)
let again = Shared::new(spec.size_bytes);
run(&spec, "http://127.0.0.1:1/m", &staging, &again);
assert!(matches!(again.get(), State::Done { .. }), "{:?}", again.get());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_file_that_does_not_match_is_thrown_away() {
let dir = scratch("bad");
let staging = dir.join(STAGING);
std::fs::create_dir_all(&staging).unwrap();
// the right size and the wrong content
let spec = spec_for(&[7u8; 5000]);
let shared = Shared::new(spec.size_bytes);
run(&spec, &serve("200 OK", vec![8u8; 5000]), &staging, &shared);
assert!(matches!(shared.get(), State::Failed(_)), "{:?}", shared.get());
// nothing is left behind: not the file, not a partial one
assert_eq!(std::fs::read_dir(&staging).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unreachable_or_refusing_server_is_a_message() {
let dir = scratch("down");
let staging = dir.join(STAGING);
std::fs::create_dir_all(&staging).unwrap();
let spec = spec_for(b"abc");
for mirror in ["http://127.0.0.1:1/m".to_string(), serve("404 Not Found", b"not found".to_vec())] {
let shared = Shared::new(3);
run(&spec, &mirror, &staging, &shared);
let State::Failed(why) = shared.get() else { panic!("{:?}", shared.get()) };
assert!(why.contains("internet connection"), "{why}");
}
assert_eq!(std::fs::read_dir(&staging).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_cancelled_download_leaves_nothing() {
let dir = scratch("cancel");
let staging = dir.join(STAGING);
std::fs::create_dir_all(&staging).unwrap();
let spec = spec_for(b"abc");
let shared = Shared::new(3);
shared.cancel.store(true, Ordering::Relaxed);
run(&spec, &serve("200 OK", b"abc".to_vec()), &staging, &shared);
assert_eq!(shared.get(), State::Cancelled);
assert_eq!(std::fs::read_dir(&staging).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dropping_the_session_stops_a_running_download() {
let shared = Arc::new(Shared::new(10));
// a stand-in for the download thread: notices the cancel flag the way the downloader does
let worker = shared.clone();
let handle = std::thread::spawn(move || {
while !worker.cancel.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(5));
}
worker.set(State::Cancelled);
});
let mut d = Downloads::default();
d.jobs.insert("m".into(), shared.clone());
assert!(d.running());
let started = web_time::Instant::now();
drop(d);
assert!(started.elapsed() < Duration::from_millis(900), "quitting must not wait for a transfer to finish");
assert_eq!(shared.get(), State::Cancelled);
handle.join().unwrap();
}
#[test]
fn discarding_a_finished_download_deletes_its_file() {
let dir = scratch("discard");
let file = dir.join("staged.onnx");
std::fs::write(&file, b"x").unwrap();
let mut d = Downloads::default();
let shared = Arc::new(Shared::new(1));
shared.set(State::Done { path: file.clone(), sha256: String::new() });
d.jobs.insert("m".into(), shared);
assert_eq!(d.snapshot().len(), 1);
assert!(!d.running());
assert!(d.discard("m"));
assert!(!file.exists());
assert!(d.snapshot().is_empty());
assert!(!d.discard("m"), "nothing left to discard");
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -190,7 +190,7 @@ pub fn is_valid(path: &Path) -> bool {
/// Load the proxy at `path`.
pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone })
decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone, denoised: None })
}
#[cfg(test)]

View File

@ -0,0 +1,759 @@
//! AI denoise through the engine: installing a model, making a photo's denoised picture in the background and for an
//! export, the Amount slider mixing it in, and the cache. A tiny stand-in model (it halves every sample) takes the
//! place of a real one, which is enough to see what the machinery does with its answer; how well real models denoise is
//! measured separately (`crates/denoise/tests/real_model.rs`).
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
use lightcraft_denoise::run::TileRunner;
use serde_json::{Value, json};
use crate::Session;
use crate::denoise::{Loader, Model, PhotoState, RunOn};
use crate::export::{ExportOptions, export_photo};
use crate::tests_xmp::{synthetic_dng_of, temp_dir};
/// A model that makes every output pixel half of its cell's colour: a picture that is plainly not the plain one.
struct Dim {
runs: Arc<AtomicUsize>,
}
impl TileRunner for Dim {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, lightcraft_denoise::Error> {
self.runs.fetch_add(1, Ordering::Relaxed);
let t = ((input.len() / 4) as f64).sqrt() as usize;
let side = 2 * t;
let mut out = vec![0f32; 3 * side * side];
for y in 0..side {
for x in 0..side {
let i = (y / 2) * t + x / 2;
let (r, g, b) = (input[i], (input[t * t + i] + input[2 * t * t + i]) / 2.0, input[3 * t * t + i]);
out[y * side + x] = r * 0.5;
out[side * side + y * side + x] = g * 0.5;
out[2 * side * side + y * side + x] = b * 0.5;
}
}
Ok(out)
}
}
impl Model for Dim {
fn runner(&self, _: RunOn, threads: usize) -> (&dyn TileRunner, usize) {
(self, threads)
}
fn self_test(&self, _: RunOn) -> Result<Value, String> {
Ok(json!({"ok": true}))
}
}
fn dim_loader(runs: &Arc<AtomicUsize>) -> Loader {
let runs = runs.clone();
Arc::new(move |_, _| Ok(Arc::new(Dim { runs: runs.clone() }) as Arc<dyn Model>))
}
fn manifest(id: &str) -> DenoiserManifest {
DenoiserManifest {
id: id.into(),
name: "Stand-in".into(),
version: "1".into(),
licence: Default::default(),
source: None,
sha256: None,
size_bytes: None,
provenance: "made up for a test".into(),
domain: Domain::BayerToRgb,
tile: 64,
overlap: 16,
gain: Gain::None,
}
}
/// The model's `.onnx` (not a real one: the stand-in loader never reads it) with its manifest beside it.
fn model_files(dir: &Path, id: &str) -> PathBuf {
let src = dir.join(format!("src-{id}"));
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("denoise-model.json"), serde_json::to_vec(&manifest(id)).unwrap()).unwrap();
std::fs::write(src.join("model.onnx"), format!("not an onnx file, only a stand-in for {id}")).unwrap();
src.join("model.onnx")
}
#[test]
fn model_installation_keeps_the_frame_pump_responsive_and_reuses_the_loaded_model() {
use std::sync::{Mutex, mpsc};
use std::time::Duration;
let mut x = setup("background-install", false);
let path = model_files(&x.dir, "stand-in");
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let release_rx = Mutex::new(release_rx);
let runs = x.runs.clone();
let loads = Arc::new(AtomicUsize::new(0));
let worker_loads = loads.clone();
x.s.denoise.loader = Arc::new(move |_, _| {
worker_loads.fetch_add(1, Ordering::Relaxed);
entered_tx.send(()).map_err(|e| e.to_string())?;
release_rx.lock().unwrap().recv_timeout(Duration::from_secs(3)).map_err(|e| e.to_string())?;
Ok(Arc::new(Dim { runs: runs.clone() }))
});
let info = x.s.execute("denoise.models.inspect", &json!({"path": path})).unwrap();
assert_eq!(info["model"]["id"], "stand-in");
assert_eq!(loads.load(Ordering::Relaxed), 0, "showing terms never executes the model");
let r = x.s.execute("denoise.models.install", &json!({"path": path, "acknowledged": true, "background": true})).unwrap();
assert_eq!(r["started"], "local-install");
entered_rx.recv_timeout(Duration::from_secs(3)).unwrap();
// The loader is blocked: the frame pump must still return, reporting installation in progress.
let state = x.s.execute("denoise.models.downloads", &json!({})).unwrap();
assert_eq!(state["downloads"][0]["state"], "installing");
assert_eq!(x.s.execute("denoise.pump", &json!({"pace": "pause"})).unwrap()["active"], false);
assert!(x.s.execute("denoise.models.install", &json!({"path": path, "acknowledged": true, "background": true})).is_err());
release_tx.send(()).unwrap();
for _ in 0..600 {
let v = x.s.execute("denoise.models.downloads", &json!({})).unwrap();
if v["downloads"][0]["state"] == "installed" {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(x.s.execute("denoise.models.downloads", &json!({})).unwrap()["downloads"][0]["state"], "installed");
let a = x.s.denoise.active.as_ref().unwrap();
assert!(a.model.lock().unwrap().is_some(), "the worker's tested model is reused");
assert_eq!(loads.load(Ordering::Relaxed), 1);
drop(x.s);
let _ = std::fs::remove_dir_all(x.dir);
}
#[test]
fn failed_model_replacement_preserves_the_installed_model() {
let mut x = setup("replacement", true);
let installed = x.dir.join("denoise-models/stand-in/model.onnx");
let original = std::fs::read(&installed).unwrap();
let source = model_files(&x.dir, "stand-in");
std::fs::write(&source, "a different broken model").unwrap();
assert!(x.s.execute("denoise.models.install", &json!({"path": source, "acknowledged": true})).is_err());
assert_eq!(std::fs::read(installed).unwrap(), original);
assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["model"], "stand-in");
drop(x.s);
let _ = std::fs::remove_dir_all(x.dir);
}
/// A Bayer DNG that is a smooth ramp, big enough for a 64-cell tile and a second one.
fn ramp_dng(w: usize, h: usize, cfa: &str) -> Vec<u8> {
let data: Vec<u16> = (0..w * h).map(|i| 600 + ((i % w) * 30 + (i / w) * 35) as u16).collect();
synthetic_dng_of(w, h, cfa, data, None, Default::default())
}
struct Setup {
dir: PathBuf,
s: Session,
runs: Arc<AtomicUsize>,
}
/// A session with a library on disk holding one raw photo, and the stand-in model installed and chosen.
fn setup(tag: &str, with_model: bool) -> Setup {
let dir = temp_dir(tag);
let originals = dir.join("originals");
std::fs::create_dir_all(&originals).unwrap();
std::fs::write(originals.join("ramp.dng"), ramp_dng(192, 160, "RGGB")).unwrap();
let runs = Arc::new(AtomicUsize::new(0));
let mut s = Session::new().with_fs();
s.open_library(dir.join("library"), false).unwrap();
s.execute("library.import", &json!({"paths": [originals.join("ramp.dng").to_string_lossy()]})).unwrap();
let id = s.catalog.photos().next().unwrap().id;
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.set_denoise_models_dir(Some(dir.join("denoise-models")));
s.denoise.loader = dim_loader(&runs);
if with_model {
let onnx = model_files(&dir, "stand-in");
s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap();
}
Setup { dir, s, runs }
}
fn photo(s: &Session) -> lightcraft_catalog::PhotoId {
s.catalog.photos().next().unwrap().id
}
/// Pump until `n` photos are ready (the job runs on its own thread).
fn pump_until_ready(s: &mut Session, n: u64) -> Value {
for _ in 0..600 {
let r = s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
if r["ready"].as_u64() >= Some(n) {
return r;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!("not ready: {}", s.execute("denoise.status", &json!({})).unwrap());
}
fn mean(img: &lightcraft_raster::Rgba8) -> f64 {
let sum: u64 = img.data.iter().map(|p| u64::from(p[0]) + u64::from(p[1]) + u64::from(p[2])).sum();
sum as f64 / (img.data.len() * 3) as f64
}
fn rendered_mean(s: &mut Session, id: lightcraft_catalog::PhotoId) -> f64 {
mean(&s.render_now(id, 160, 160).unwrap().image)
}
fn set_amount(s: &mut Session, amount: f64) {
s.execute("develop.set", &json!({"control": "enhance.denoise", "value": amount})).unwrap();
}
#[test]
fn a_model_is_installed_chosen_and_removed_with_the_terms_accepted() {
let mut x = setup("model", false);
let onnx = model_files(&x.dir, "stand-in");
// the terms come first
let e = x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("licence has not been accepted"), "{e}");
let r = x.s.execute("denoise.models.list", &json!({})).unwrap();
assert_eq!(r["model"], Value::Null);
assert_eq!(r["runtime"], cfg!(feature = "denoise"));
// a file with no manifest is not guessed at
let stray = x.dir.join("stray.onnx");
std::fs::write(&stray, b"who knows").unwrap();
let e = x.s.execute("denoise.models.install", &json!({"path": stray.to_string_lossy(), "acknowledged": true})).unwrap_err().to_string();
assert!(e.contains("does not know this file"), "{e}");
// installed, it is the model in use
let r = x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap();
assert_eq!((r["installed"]["id"].clone(), r["model"].clone()), (json!("stand-in"), json!("stand-in")), "{r}");
assert!(r["installed"]["accepted"]["sha256"].as_str().is_some_and(|h| h.len() == 64), "the file's hash is recorded: {r}");
assert!(x.s.denoise.active.is_some());
assert_eq!(x.s.execute("denoise.status", &json!({})).unwrap()["model"]["id"], "stand-in");
// it can be checked again, switched off and on, and removed
assert_eq!(x.s.execute("denoise.models.test", &json!({"id": "stand-in"})).unwrap()["ok"], true);
assert_eq!(x.s.execute("denoise.models.select", &json!({"id": null})).unwrap()["model"], Value::Null);
assert!(x.s.denoise.active.is_none());
assert!(x.s.execute("denoise.models.select", &json!({"id": "nothing"})).is_err());
x.s.execute("denoise.models.select", &json!({"id": "stand-in"})).unwrap();
assert!(x.s.denoise.active.is_some());
let r = x.s.execute("denoise.models.remove", &json!({"id": "stand-in"})).unwrap();
assert_eq!(r["model"], Value::Null);
assert!(x.s.denoise.active.is_none());
assert!(!x.dir.join("denoise-models").join("stand-in").exists());
// ids that are not folder names, and folders that are not models, are never touched
for bad in ["../library", "", "a/b", "settings.json"] {
assert!(x.s.execute("denoise.models.remove", &json!({"id": bad})).is_err(), "{bad}");
}
assert!(x.dir.join("library").is_dir());
// a model that fails its self-test is not left installed
x.s.denoise.loader = Arc::new(|_, _| Err("it does not load".into()));
let e = x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap_err().to_string();
assert!(e.contains("it does not load"), "{e}");
assert!(!x.dir.join("denoise-models").join("stand-in").exists());
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn a_manifest_that_does_not_validate_or_name_the_file_is_refused() {
let mut x = setup("manifest", false);
let onnx = model_files(&x.dir, "stand-in");
let json_path = onnx.with_file_name("denoise-model.json");
// a tile the model cannot run on
let mut m = manifest("stand-in");
m.tile = 50;
std::fs::write(&json_path, serde_json::to_vec(&m).unwrap()).unwrap();
let e = x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap_err().to_string();
assert!(e.contains("tile"), "{e}");
// a hash that is not the file's
let mut m = manifest("stand-in");
m.sha256 = Some("0".repeat(64));
std::fs::write(&json_path, serde_json::to_vec(&m).unwrap()).unwrap();
let e = x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap_err().to_string();
assert!(e.contains("SHA-256"), "{e}");
assert!(!x.dir.join("denoise-models").join("stand-in").join("model.onnx").exists(), "nothing half-installed is left");
// an id that would leave the folder
let mut m = manifest("../escape");
m.id = "../escape".into();
std::fs::write(&json_path, serde_json::to_vec(&m).unwrap()).unwrap();
assert!(x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).is_err());
assert!(!x.dir.join("escape").exists());
// garbage instead of a manifest
std::fs::write(&json_path, b"{ not json").unwrap();
assert!(x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).is_err());
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn a_photo_is_denoised_in_the_background_and_the_amount_mixes_it_in() {
let mut x = setup("background", true);
let id = photo(&x.s);
// nothing is made until a photo has an amount
let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
assert_eq!((r["queued"].clone(), r["running"].clone(), r["ready"].clone()), (json!(0), Value::Null, json!(0)), "{r}");
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Idle);
let plain = rendered_mean(&mut x.s, id);
let key_before = x.s.render_job(id, 160, 160, false, true).unwrap().key;
set_amount(&mut x.s, 100.0);
// the photo being edited is picked up by itself, and made on another thread
let r = pump_until_ready(&mut x.s, 1);
assert_eq!(r["queued"], 0, "{r}");
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
assert!(x.runs.load(Ordering::Relaxed) >= 4, "a 192 × 160 mosaic is four 64-cell tiles: {}", x.runs.load(Ordering::Relaxed));
let products: Vec<_> = std::fs::read_dir(x.dir.join("library").join("denoise")).unwrap().flatten().collect();
assert_eq!(products.len(), 1, "one cached picture, no DNG or anything else in the library");
assert!(products[0].path().extension().is_some_and(|e| e == "lcdn"));
let originals: Vec<_> = std::fs::read_dir(x.dir.join("originals")).unwrap().flatten().collect();
assert_eq!(originals.len(), 1, "the original is untouched and alone");
// the render now comes from the denoised picture: darker at 100, in between at 50, the plain one at 0
let full = rendered_mean(&mut x.s, id);
assert!(full < plain - 3.0, "denoised {full} vs plain {plain}");
set_amount(&mut x.s, 50.0);
let half = rendered_mean(&mut x.s, id);
assert!(half < plain - 1.0 && half > full + 1.0, "{full} < {half} < {plain}");
set_amount(&mut x.s, 0.0);
assert!((rendered_mean(&mut x.s, id) - plain).abs() < 0.01, "amount 0 is the plain picture again");
// …and a render that uses it has another identity than one that does not
assert_ne!(key_before, {
set_amount(&mut x.s, 100.0);
x.s.render_job(id, 160, 160, false, true).unwrap().key
});
// switching the Detail section off switches the denoise off with it
set_amount(&mut x.s, 100.0);
x.s.execute("develop.sectionEnabled", &json!({"section": "detail", "enabled": false})).unwrap();
assert!((rendered_mean(&mut x.s, id) - plain).abs() < 0.01);
assert_eq!(x.s.media.denoise_salt(id, &x.s.catalog.photo(id).unwrap().develop), 0);
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn what_was_made_is_found_again_by_a_new_session_and_a_new_model_starts_over() {
let mut x = setup("again", true);
let id = photo(&x.s);
set_amount(&mut x.s, 100.0);
pump_until_ready(&mut x.s, 1);
let made = x.runs.load(Ordering::Relaxed);
x.s.close_library().unwrap();
drop(x.s);
// reopened: ready without running the model again
let mut s2 = Session::new().with_fs();
s2.set_denoise_models_dir(Some(x.dir.join("denoise-models")));
s2.denoise.loader = dim_loader(&x.runs);
s2.open_library(x.dir.join("library"), false).unwrap();
s2.denoise_pump(crate::denoise::Pace::Full);
assert_eq!(s2.denoise_photo_state(id), PhotoState::Ready);
assert_eq!(x.runs.load(Ordering::Relaxed), made, "nothing was made again");
// another model's pictures are different pictures
let onnx = model_files(&x.dir, "other");
s2.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap();
s2.denoise_pump(crate::denoise::Pace::Pause);
assert!(
matches!(s2.denoise_photo_state(id), PhotoState::Queued { .. }),
"the first model's picture is not this model's: it is to be made: {:?}",
s2.denoise_photo_state(id)
);
// and choosing the first again finds its picture where it was
s2.execute("denoise.models.select", &json!({"id": "stand-in"})).unwrap();
s2.denoise_pump(crate::denoise::Pace::Pause);
assert_eq!(s2.denoise_photo_state(id), PhotoState::Ready);
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn an_export_makes_its_picture_when_the_queue_has_not_and_never_comes_out_without_it() {
let mut x = setup("export", true);
let id = photo(&x.s);
let o = ExportOptions::from_json(&json!({"format": "png", "width": 160, "height": 160}));
let decode = |bytes: &[u8]| {
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(160, 160)).unwrap();
let w = d.to_working();
w.data.iter().map(|p| f64::from(p[0] + p[1] + p[2])).sum::<f64>() / (w.data.len() * 3) as f64
};
let plain = decode(&export_photo(&mut x.s, id, &o, 1).unwrap().bytes);
assert_eq!(x.runs.load(Ordering::Relaxed), 0, "no amount, no denoise");
set_amount(&mut x.s, 100.0);
// no pump: the export makes the picture itself
let denoised = decode(&export_photo(&mut x.s, id, &o, 1).unwrap().bytes);
assert!(x.runs.load(Ordering::Relaxed) >= 4);
assert!(denoised < plain * 0.9, "denoised {denoised} vs plain {plain}");
let products = std::fs::read_dir(x.dir.join("library").join("denoise")).unwrap().flatten().count();
assert_eq!(products, 1);
// a second export finds it
let runs = x.runs.load(Ordering::Relaxed);
let again = decode(&export_photo(&mut x.s, id, &o, 1).unwrap().bytes);
assert_eq!(x.runs.load(Ordering::Relaxed), runs);
assert!((again - denoised).abs() < 1e-6);
// the pump takes in what the export made
x.s.denoise_pump(crate::denoise::Pace::Pause);
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
// no model: the export says so instead of leaving the denoise out
x.s.execute("denoise.models.select", &json!({"id": null})).unwrap();
let e = export_photo(&mut x.s, id, &o, 1).err().unwrap();
assert!(e.contains("no denoise model is chosen") && e.contains("ramp.dng"), "{e}");
set_amount(&mut x.s, 0.0);
assert!(export_photo(&mut x.s, id, &o, 1).is_ok(), "without an amount nothing is asked of the model");
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn a_model_that_fails_fails_the_export_and_is_not_retried_by_the_queue() {
let mut x = setup("fails", true);
let id = photo(&x.s);
x.s.denoise.loader = Arc::new(|_, _| Err("the model went away".into()));
// Simulate loss of the in-memory model; successful installation now retains its tested instance.
*x.s.denoise.active.as_ref().unwrap().model.lock().unwrap() = None;
set_amount(&mut x.s, 80.0);
let o = ExportOptions::from_json(&json!({"format": "png", "width": 100, "height": 100}));
let e = export_photo(&mut x.s, id, &o, 1).err().unwrap();
assert!(e.contains("ramp.dng") && e.contains("the model went away"), "{e}");
// the background job fails too, says why once and does not try again every frame
for _ in 0..200 {
x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
if matches!(x.s.denoise_photo_state(id), PhotoState::Failed { .. }) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
match x.s.denoise_photo_state(id) {
PhotoState::Failed { why, unsupported } => assert!(why.contains("the model went away") && !unsupported, "{why}"),
other => panic!("{other:?}"),
}
let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
assert_eq!((r["queued"].clone(), r["running"].clone()), (json!(0), Value::Null), "{r}");
// asking again by hand does retry
x.s.denoise.loader = dim_loader(&x.runs);
let r = x.s.execute("denoise.queue", &json!({"retry": true})).unwrap();
assert_eq!(r["added"], 1, "{r}");
pump_until_ready(&mut x.s, 1);
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn a_corrupt_cache_with_a_current_header_never_silently_exports_the_plain_photo() {
let mut x = setup("corrupt-export", true);
let id = photo(&x.s);
set_amount(&mut x.s, 100.0);
let options = ExportOptions::from_json(&json!({"format": "png", "width": 160, "height": 160}));
export_photo(&mut x.s, id, &options, 1).unwrap();
x.s.denoise_pump(crate::denoise::Pace::Pause);
let spec = x.s.media.denoise.spec(id).unwrap().clone();
let mut bytes = std::fs::read(&spec.product).unwrap();
for byte in bytes.iter_mut().rev().take(64) {
*byte ^= 0xff;
}
std::fs::write(&spec.product, bytes).unwrap();
assert!(lightcraft_denoise::product::is_current(&spec.product, &spec.key), "header/length still look current");
x.s.media.forget(id);
let error = export_photo(&mut x.s, id, &options, 1).err().unwrap();
assert!(error.contains("AI Denoise picture could not be read"), "{error}");
let _ = std::fs::remove_dir_all(x.dir);
}
#[test]
fn only_raw_photos_on_disk_are_denoised_and_nothing_is_queued_without_a_model() {
let dir = temp_dir("eligible");
let mut demo = Session::with_demo();
demo.set_denoise_models_dir(Some(dir.join("models")));
let id = demo.active().unwrap();
assert_eq!(demo.denoise_photo_state(id), PhotoState::NotApplicable, "a generated photo has no sensor data");
let e = demo.execute("denoise.queue", &json!({})).unwrap_err().to_string();
assert!(e.contains("no denoise model is chosen"), "{e}");
// no models folder at all (the web build): the commands say so, nothing panics
let mut web = Session::with_demo();
for cmd in ["denoise.models.install", "denoise.models.select", "denoise.models.remove", "denoise.settings"] {
assert!(web.execute(cmd, &json!({"path": "x", "acknowledged": true, "id": "x"})).is_err(), "{cmd}");
}
assert_eq!(web.execute("denoise.pump", &json!({})).unwrap()["active"], false);
assert_eq!(web.execute("denoise.status", &json!({})).unwrap()["enabled"], false);
// no library open: nowhere to keep pictures, and the queue says so
let x = setup("eligible2", true);
let mut bare = Session::new().with_fs();
bare.set_denoise_models_dir(Some(x.dir.join("denoise-models")));
assert!(bare.denoise_products_dir().is_none());
let e = bare.execute("denoise.queue", &json!({})).unwrap_err().to_string();
assert!(e.contains("open a library on disk first"), "{e}");
assert_eq!(bare.execute("denoise.pump", &json!({})).unwrap()["queued"], 0);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn files_that_cannot_be_denoised_fail_with_a_reason_and_never_a_panic() {
let dir = temp_dir("makefails");
let spec = |path: &Path| crate::denoise::JobSpec {
path: path.to_string_lossy().into_owned(),
read: None,
product: dir.join("p.lcdn"),
key: "k".into(),
manifest: manifest("stand-in"),
onnx: dir.join("model.onnx"),
model: Default::default(),
loader: dim_loader(&Arc::new(AtomicUsize::new(0))),
parallel: 2,
run_on: RunOn::Auto,
};
use crate::denoise::{MakeError, make_product};
// not a file
assert!(matches!(make_product(&spec(&dir.join("missing.dng")), None), Err(MakeError::Failed(_))));
// not a raw
let junk = dir.join("junk.dng");
std::fs::write(&junk, b"this is not a raw file at all").unwrap();
assert!(matches!(make_product(&spec(&junk), None), Err(MakeError::Unsupported(_))));
// a raw cut short
let whole = ramp_dng(192, 160, "RGGB");
let cut = dir.join("cut.dng");
std::fs::write(&cut, &whole[..whole.len() / 3]).unwrap();
assert!(make_product(&spec(&cut), None).is_err());
// a model that gives the wrong number of values is an error from the model, not a crash
struct Wrong;
impl TileRunner for Wrong {
fn run(&self, _: &[f32]) -> Result<Vec<f32>, lightcraft_denoise::Error> {
Ok(vec![0.0; 7])
}
}
impl Model for Wrong {
fn runner(&self, _: RunOn, threads: usize) -> (&dyn TileRunner, usize) {
(self, threads)
}
fn self_test(&self, _: RunOn) -> Result<Value, String> {
Err("no".into())
}
}
let good = dir.join("good.dng");
std::fs::write(&good, &whole).unwrap();
let mut s = spec(&good);
s.loader = Arc::new(|_, _| Ok(Arc::new(Wrong) as Arc<dyn Model>));
match make_product(&s, None) {
Err(MakeError::Failed(why)) => assert!(why.contains("7 numbers"), "{why}"),
other => panic!("{other:?}"),
}
assert!(!dir.join("p.lcdn").exists(), "a failed job leaves no product");
// a job told to stop stops before doing anything
let progress = crate::denoise::Progress::default();
progress.cancel_now();
assert_eq!(make_product(&spec(&good), Some(&progress)), Err(MakeError::Cancelled));
// a Bayer layout of the other kinds is denoised too, and a product that exists is not made again
for cfa in ["BGGR", "GRBG", "GBRG"] {
let f = dir.join(format!("{cfa}.dng"));
std::fs::write(&f, ramp_dng(192, 160, cfa)).unwrap();
let mut s = spec(&f);
s.product = dir.join(format!("{cfa}.lcdn"));
assert_eq!(make_product(&s, None), Ok(true), "{cfa}");
assert_eq!(make_product(&s, None), Ok(false), "{cfa} is already there");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn two_threads_making_the_same_picture_make_it_once() {
let dir = temp_dir("twice");
let f = dir.join("a.dng");
std::fs::write(&f, ramp_dng(192, 160, "RGGB")).unwrap();
let runs = Arc::new(AtomicUsize::new(0));
let spec = crate::denoise::JobSpec {
path: f.to_string_lossy().into_owned(),
read: None,
product: dir.join("p.lcdn"),
key: "k".into(),
manifest: manifest("stand-in"),
onnx: dir.join("model.onnx"),
model: Default::default(),
loader: dim_loader(&runs),
parallel: 2,
run_on: RunOn::Auto,
};
let made: Vec<bool> = std::thread::scope(|sc| {
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| crate::denoise::make_product(&spec, None).unwrap())).collect();
hs.into_iter().map(|h| h.join().unwrap()).collect()
});
assert_eq!(made.iter().filter(|m| **m).count(), 1, "{made:?}");
assert_eq!(runs.load(Ordering::Relaxed), 4, "one photo's four tiles, once");
assert!(lightcraft_denoise::product::is_current(&spec.product, "k"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_cache_is_kept_within_its_limit_and_what_photos_use_goes_last() {
let mut x = setup("evict", true);
let id = photo(&x.s);
set_amount(&mut x.s, 100.0);
pump_until_ready(&mut x.s, 1);
let used = x.dir.join("library").join("denoise");
// two older pictures nobody uses
for (i, name) in ["aaaa", "bbbb"].iter().enumerate() {
let f = used.join(format!("{name}.lcdn"));
std::fs::write(&f, vec![7u8; 1000]).unwrap();
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(1000 - i as u64);
std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
}
let size_of = |p: &Path| std::fs::metadata(p).unwrap().len();
let mine: PathBuf = std::fs::read_dir(&used).unwrap().flatten().map(|e| e.path()).find(|p| p.file_stem().is_some_and(|s| s.len() == 32)).unwrap();
// room for the photo's own picture and one stray: the oldest stray goes, then the other
x.s.denoise_evict_to(size_of(&mine) + 1500, None);
assert!(!used.join("aaaa.lcdn").exists() && used.join("bbbb.lcdn").exists() && mine.exists());
x.s.denoise_evict_to(size_of(&mine) + 10, None);
assert!(!used.join("bbbb.lcdn").exists() && mine.exists());
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
// too small for even that: the photo's own picture is the last thing to go, and the photo knows
x.s.denoise_evict_to(10, None);
assert!(!mine.exists());
assert_ne!(x.s.denoise_photo_state(id), PhotoState::Ready);
// …and it is made again, because the photo still has its amount
pump_until_ready(&mut x.s, 1);
assert!(mine.exists());
// clearing deletes it all
let r = x.s.execute("denoise.clear", &json!({})).unwrap();
assert_eq!(r["deleted"], 1, "{r}");
assert_eq!(std::fs::read_dir(&used).unwrap().flatten().count(), 0);
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn settings_are_checked_saved_and_a_paused_pump_starts_nothing() {
let mut x = setup("settings", true);
let r = x.s.execute("denoise.settings", &json!({"auto": false, "cacheGb": 5, "threads": 3})).unwrap();
assert_eq!((r["auto"].clone(), r["cacheGb"].clone(), r["threads"].clone()), (json!(false), json!(5), json!(3)), "{r}");
for bad in [
json!({"cacheGb": 0}),
json!({"cacheGb": "big"}),
json!({"threads": 0}),
json!({"threads": 1000}),
json!({"auto": "yes"}),
json!({"runOn": "fastest"}),
json!({"runOn": true}),
] {
assert!(x.s.execute("denoise.settings", &bad).is_err(), "{bad}");
}
assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["cacheGb"], 5, "a refused change changes nothing");
// the faster of the card and the processor unless the user says otherwise, and the interface can read the choice back
assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["runOn"], "auto");
assert_eq!(x.s.execute("denoise.settings", &json!({"runOn": "cpu"})).unwrap()["runOn"], "cpu");
assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["runOn"], "cpu");
let status = x.s.execute("denoise.status", &json!({})).unwrap();
assert_eq!((status["runOn"].clone(), status["device"]["kind"].clone()), (json!("cpu"), json!("none")), "no model is loaded yet: {status}");
// the setting before `runOn` still reads as what it meant
let models = x.s.denoise.models_dir.clone().unwrap();
let mut st = crate::denoise::read_settings(&models);
(st.run_on, st.gpu) = (None, Some(false));
crate::denoise::write_settings(&models, &st).unwrap();
assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["runOn"], "cpu");
// choosing where it runs lets the card be tried again after a set-up that closed LightCraft
let onnx = crate::denoise::installed_models(&models).first().unwrap().onnx.clone();
let marker = onnx.with_file_name(crate::denoise::GPU_SETUP_MARKER);
std::fs::write(&marker, "setting up").unwrap();
assert_eq!(x.s.execute("denoise.settings", &json!({"runOn": "auto"})).unwrap()["runOn"], "auto");
assert!(!marker.exists(), "the crash is forgotten");
assert_eq!(crate::denoise::read_settings(&models).gpu, None, "the old setting is not kept beside the new one");
// with auto off nothing is started by itself, and the photo is made when asked
let id = photo(&x.s);
set_amount(&mut x.s, 100.0);
for _ in 0..3 {
x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
}
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Idle);
x.s.execute("denoise.settings", &json!({"auto": true})).unwrap();
// paused: it is queued but nothing starts
for _ in 0..3 {
let r = x.s.execute("denoise.pump", &json!({"pace": "pause"})).unwrap();
assert_eq!(r["running"], Value::Null);
}
assert!(matches!(x.s.denoise_photo_state(id), PhotoState::Queued { .. }), "{:?}", x.s.denoise_photo_state(id));
// …and cancelling forgets it
assert_eq!(x.s.execute("denoise.cancel", &json!({})).unwrap()["cancelled"], 1);
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Idle);
// when the pace allows, it is made
x.s.execute("denoise.queue", &json!({"scope": "withAmount"})).unwrap();
pump_until_ready(&mut x.s, 1);
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn a_photo_whose_file_changed_has_no_picture_until_it_is_made_again() {
let mut x = setup("changed", true);
let id = photo(&x.s);
set_amount(&mut x.s, 100.0);
pump_until_ready(&mut x.s, 1);
// the photo's content is something else now (a replaced file): its old picture is not its picture
let p = x.s.catalog.photo(id).unwrap().clone();
x.s.commit(
"test",
lightcraft_catalog::Op::SetContent {
id,
width: p.width,
height: p.height,
file_size: p.file_size + 1,
content_hash: Some("ffffffffffffffffffffffffffffffff".into()),
preview_only: None,
},
)
.unwrap();
x.s.denoise_pump(crate::denoise::Pace::Pause);
assert_ne!(x.s.denoise_photo_state(id), PhotoState::Ready);
// the old picture is not used by a render either
assert_eq!(x.s.media.denoise_salt(id, &x.s.catalog.photo(id).unwrap().develop), 0);
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn a_photo_waits_for_its_turn_unless_pictures_are_not_made_on_their_own_or_have_nowhere_to_go() {
let mut x = setup("waits", true);
let id = photo(&x.s);
set_amount(&mut x.s, 80.0);
// automatic off: the open photo is left alone until it is asked for
x.s.execute("denoise.settings", &json!({"auto": false})).unwrap();
assert!(!x.s.denoise_auto());
x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Idle);
assert!(!x.s.denoise_busy());
x.s.execute("denoise.queue", &json!({"ids": [id.0]})).unwrap();
assert!(x.s.denoise_busy(), "queued with a model: the headless runs wait for it");
pump_until_ready(&mut x.s, 1);
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
assert!(!x.s.denoise_busy());
// a session whose library is not on disk has nowhere to keep it: the photo says so instead of waiting for ever
x.s.denoise.settings.auto = Some(true);
if let Some(l) = x.s.library.as_mut() {
l.on_disk = false;
}
match x.s.denoise_photo_state(id) {
PhotoState::Failed { why, unsupported } => assert!(unsupported && why.contains("library folder"), "{why}"),
other => panic!("{other:?}"),
}
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn the_denoise_switch_validates_arguments_and_accepts_explicit_targets_without_selection() {
let mut x = setup("toggle", false);
let id = photo(&x.s);
for params in [json!({"id": "bad"}), json!({"id": u64::MAX}), json!({"id": id.0, "enabled": "yes"})] {
assert!(x.s.execute("denoise.toggle", &params).is_err());
}
assert!(x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": true})).is_err());
assert_eq!(x.s.develop_of(id).unwrap().enhance.denoise, 0.0);
let model = model_files(&x.dir, "toggle-model");
x.s.execute("denoise.models.install", &json!({"path": model.to_string_lossy(), "acknowledged": true})).unwrap();
x.s.selection = Default::default();
let undo = x.s.undo.len();
let r = x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": true})).unwrap();
assert_eq!(r["amount"], 50.0);
assert_eq!(x.s.undo.len(), undo + 1);
x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": false})).unwrap();
assert_eq!(x.s.develop_of(id).unwrap().denoise_amount(), 0.0);
// the switch is its own setting: flipping it keeps the Amount (so it compares with and without), and an Amount of 0
// does not switch it off
x.s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": true})).unwrap();
x.s.execute("develop.set", &json!({"control": "enhance.denoise", "value": 80.0})).unwrap();
let off = x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": false})).unwrap();
assert_eq!(off["amount"], 80.0);
let d = x.s.develop_of(id).unwrap();
assert!(!d.enhance.denoise_enabled() && d.denoise_amount() == 0.0);
assert_eq!(x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": true})).unwrap()["amount"], 80.0);
assert!((x.s.develop_of(id).unwrap().denoise_amount() - 0.8).abs() < 1e-6);
x.s.execute("develop.set", &json!({"control": "enhance.denoise", "value": 0.0})).unwrap();
assert!(x.s.develop_of(id).unwrap().enhance.denoise_enabled(), "sliding to 0 leaves it on");
assert_eq!(x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": true})).unwrap()["amount"], 0.0);
// with no `enabled`, the toggle flips what the switch says
assert_eq!(x.s.execute("denoise.toggle", &json!({"id": id.0})).unwrap()["enabled"], false);
drop(x.s);
let _ = std::fs::remove_dir_all(x.dir);
}

View File

@ -335,10 +335,15 @@ fn synthetic_dng(xmp: &str) -> Vec<u8> {
/// A tiny Bayer DNG written by our own DNG writer (optional embedded XMP, camera metadata).
pub(crate) fn synthetic_dng_with(xmp: Option<&str>, metadata: lightcraft_meta::Metadata) -> Vec<u8> {
use lightcraft_raw::*;
let (w, h) = (32usize, 24usize);
let cfa = Cfa::bayer("RGGB").unwrap();
let data: Vec<u16> = (0..w * h).map(|i| 256 + ((i % w) * 300 + (i / w) * 200) as u16).collect();
synthetic_dng_of(w, h, "RGGB", data, xmp, metadata)
}
/// A synthetic Bayer DNG for denoise and cache tests.
pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec<u16>, xmp: Option<&str>, metadata: lightcraft_meta::Metadata) -> Vec<u8> {
use lightcraft_raw::*;
let cfa = Cfa::bayer(cfa).unwrap();
let raw = RawImage {
format: RawFormat::Dng,
width: w,

View File

@ -0,0 +1,101 @@
//! Opt-in same-machine denoise model-stage benchmark on one public Bayer raw.
//! LC_DENOISE_MODEL=<onnx> LC_DENOISE_RAW=<CC0 corpus/raw> cargo test --release
//! -p lightcraft-engine --features denoise --test denoise_cpu -- --ignored --nocapture
//! LC_DENOISE_ONLY selects a file (default arw-sony-a7m3-compressed.arw).
//! The whole model stage includes packing, scale/clip protection, blending and normalization;
//! file decode, model loading and GPU setup are outside the timer. It uses the application's tile pool convention.
#![cfg(feature = "denoise")]
use lightcraft_denoise::{
TileRunner,
bayer::Layout,
run::{Control, Params, denoise_bayer},
runtime::{CpuRunner, check_tile},
};
use lightcraft_raster::Rgb32f;
use std::{path::Path, time::Instant};
fn agree(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len());
assert!(a.iter().chain(b).all(|v| v.is_finite()));
let scale = b.iter().fold(1e-6f32, |m, v| m.max(v.abs()));
let error = a.iter().zip(b).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
assert!(error <= 1e-3, "maximum relative error {error:e}");
error
}
fn tile_time(name: &str, r: &dyn TileRunner, x: &[f32]) {
r.run(x).unwrap();
let mut times: Vec<_> = (0..5)
.map(|_| {
let t = Instant::now();
r.run(x).unwrap();
t.elapsed().as_secs_f64() * 1000.0
})
.collect();
times.sort_by(f64::total_cmp);
println!("{name}, one 512-cell tile, one thread: {:.1} ms median ({:.1}..{:.1})", times[2], times[0], times[4]);
}
fn photo_time(name: &str, r: &dyn TileRunner, raw: &lightcraft_raw::Normalized, layout: Layout, p: &Params) -> Rgb32f {
let pool = rayon::ThreadPoolBuilder::new().num_threads(p.parallel).build().unwrap();
let run = || pool.install(|| denoise_bayer(&raw.data, raw.width, raw.height, layout, r, p, &Control::default())).unwrap();
drop(run()); // warm the workers and their workspace cache
let mut times = Vec::new();
let mut answer = Rgb32f { width: 0, height: 0, data: Vec::new() };
for i in 0..3 {
let t = Instant::now();
answer = run();
let secs = t.elapsed().as_secs_f64();
times.push(secs);
println!("{name}, whole model stage, {} workers, run {i}: {secs:.3} s", p.parallel);
}
times.sort_by(f64::total_cmp);
println!("{name}, whole model stage median {:.3} s", times[1]);
answer
}
#[test]
#[ignore = "needs LC_DENOISE_MODEL and LC_DENOISE_RAW; release timing and real-model accuracy"]
fn denoise_stage_compares_cpu_and_gpu() {
let model = std::env::var_os("LC_DENOISE_MODEL").expect("set LC_DENOISE_MODEL");
let folder = std::env::var_os("LC_DENOISE_RAW").expect("set LC_DENOISE_RAW to public CC0 corpus/raw");
let name = std::env::var("LC_DENOISE_ONLY").unwrap_or_else(|_| "arw-sony-a7m3-compressed.arw".into());
let bytes = std::fs::read(Path::new(&folder).join(&name)).unwrap();
let raw = lightcraft_raw::decode(&bytes).unwrap().normalized().unwrap();
let cfa = raw.cfa.as_ref().unwrap();
assert!(cfa.is_bayer());
let layout = Layout::from_cell([cfa.color_at(0, 0), cfa.color_at(1, 0), cfa.color_at(0, 1), cfa.color_at(1, 1)]).unwrap();
let m = lightcraft_denoise::known::find("rawnind-bayer").unwrap().manifest;
let cpu = CpuRunner::load(Path::new(&model), &m).unwrap();
let threads = std::env::var("LC_DENOISE_PARALLEL")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16));
let p = Params { tile: 512, overlap: 64, gain: m.gain, clip: Some(1.0), parallel: threads };
println!(
"{name}: {}x{}, {:.2} MP, {} tiles, {} CPU workers, {:.1} MB/workspace, {:.1} GMAC/tile",
raw.width,
raw.height,
raw.width as f64 * raw.height as f64 / 1e6,
lightcraft_denoise::run::tile_count(raw.width, raw.height, layout, 512, 64),
threads,
cpu.bytes_per_tile() as f64 / 1e6,
cpu.net().macs(512) as f64 / 1e9
);
let x = check_tile(512);
let want = cpu.run(&x).unwrap();
tile_time("pure Rust", &cpu, &x);
let done = photo_time("pure Rust", &cpu, &raw, layout, &p);
match lightcraft_gpu::nn::runner(cpu.net(), 512) {
Ok(gpu) => {
println!("GPU: {}", gpu.adapter());
println!("CPU/check tile vs GPU: {:e}", agree(&want, &gpu.run(&x).unwrap()));
tile_time("GPU", &gpu, &x);
let gp = Params { parallel: 4, ..p };
let answer = photo_time("GPU", &gpu, &raw, layout, &gp);
let a: Vec<_> = done.data.iter().flatten().copied().collect();
let b: Vec<_> = answer.data.iter().flatten().copied().collect();
println!("CPU/whole photo vs GPU: {:e}", agree(&a, &b));
}
Err(e) => println!("GPU unavailable: {e}"),
}
}

View File

@ -0,0 +1,117 @@
//! Opt-in end-to-end check of AI denoise with the real RawNIND Bayer model, through the engine: install it, make a real
//! raw photo's picture in the background, render and export it with the Amount slider, and see what a file that is not
//! Bayer says.
//!
//! ```text
//! LC_DENOISE_MODEL=<model_bayer.onnx> LC_DENOISE_RAW=<folder with raw files> \
//! cargo test --release -p lightcraft-engine --features denoise --test denoise_real -- --ignored --nocapture
//! ```
//!
//! `LC_DENOISE_RAW` is a folder of CC0 raw files (`cargo xtask corpus --download` → `corpus/raw`); every file in it is
//! tried. Optional: `LC_DENOISE_ONLY` (a file name to try alone), `LC_DENOISE_RUN_ON=cpu|gpu|auto` (where the model runs; `cpu` to compare with the card).
#![cfg(feature = "denoise")]
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use lightcraft_engine::Session;
use serde_json::{Value, json};
fn temp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-denoise-real-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Mean absolute second difference of the luma: the fine detail and the noise of a picture.
fn roughness(img: &lightcraft_raster::Rgba8) -> f64 {
let (w, h) = (img.width, img.height);
let luma = |x: usize, y: usize| {
let p = img.data[y * w + x];
0.299 * f64::from(p[0]) + 0.587 * f64::from(p[1]) + 0.114 * f64::from(p[2])
};
let (mut sum, mut n) = (0.0, 0usize);
for y in 1..h - 1 {
for x in 1..w - 1 {
sum += (4.0 * luma(x, y) - luma(x - 1, y) - luma(x + 1, y) - luma(x, y - 1) - luma(x, y + 1)).abs();
n += 1;
}
}
sum / n.max(1) as f64
}
fn files(dir: &Path) -> Vec<PathBuf> {
let only = std::env::var("LC_DENOISE_ONLY").ok();
let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && only.as_deref().is_none_or(|o| p.file_name().is_some_and(|n| n.to_string_lossy() == o)))
.collect();
v.sort();
v
}
#[test]
#[ignore = "needs the real model and raw files (see the file's header)"]
fn the_real_model_denoises_real_raws_through_the_engine() {
let (Some(model), Some(raws)) = (std::env::var_os("LC_DENOISE_MODEL"), std::env::var_os("LC_DENOISE_RAW")) else {
panic!("set LC_DENOISE_MODEL and LC_DENOISE_RAW");
};
let dir = temp("e2e");
let mut s = Session::new().with_fs();
s.open_library(dir.join("library"), false).unwrap();
s.set_denoise_models_dir(Some(dir.join("models")));
let t = Instant::now();
let r = s.execute("denoise.models.install", &json!({"path": model.to_string_lossy(), "acknowledged": true})).unwrap();
println!("installed {} in {:.1} s: self-test {}", r["installed"]["id"], t.elapsed().as_secs_f64(), r["installed"]["accepted"]["selfTest"]);
assert_eq!(r["installed"]["id"], "rawnind-bayer");
if let Ok(run_on) = std::env::var("LC_DENOISE_RUN_ON") {
s.execute("denoise.settings", &json!({"runOn": run_on})).unwrap();
}
for file in files(Path::new(&raws)) {
let name = file.file_name().unwrap().to_string_lossy().into_owned();
let imported = s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let Some(id) = imported["imported"].as_array().and_then(|a| a.first()).and_then(Value::as_u64) else {
println!("{name}: not imported ({imported})");
continue;
};
s.execute("library.select", &json!({"ids": [id]})).unwrap();
s.execute("develop.set", &json!({"control": "enhance.denoise", "value": 100})).unwrap();
let photo = lightcraft_engine::catalog::PhotoId(id);
let started = Instant::now();
let mut state = Value::Null;
while started.elapsed() < Duration::from_secs(600) {
s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
state = s.execute("denoise.status", &json!({"id": id})).unwrap()["photo"].clone();
if matches!(state["state"].as_str(), Some("ready" | "failed")) {
break;
}
std::thread::sleep(Duration::from_millis(250));
}
println!("{name}: {} in {:.1} s", state, started.elapsed().as_secs_f64());
if state["state"] == "failed" {
// a file that is not a Bayer raw says why, and its export is the plain one
assert_eq!(state["unsupported"], true, "{name}: {state}");
continue;
}
assert_eq!(state["state"], "ready", "{name}: {state}");
let denoised = s.render_now(photo, 1600, 1600).unwrap().image;
s.execute("develop.set", &json!({"control": "enhance.denoise", "value": 0})).unwrap();
let plain = s.render_now(photo, 1600, 1600).unwrap().image;
let (a, b) = (roughness(&plain), roughness(&denoised));
println!("{name}: roughness at 1600 px: plain {a:.3}, denoised {b:.3} ({:+.1} %)", 100.0 * (b - a) / a);
assert!(b < a, "{name}: the denoised picture is not smoother");
// the export is made from the same picture, without asking the queue
s.execute("develop.set", &json!({"control": "enhance.denoise", "value": 100})).unwrap();
let o = lightcraft_engine::export::ExportOptions::from_json(&json!({"format": "jpeg", "width": 1200, "height": 1200}));
let e = lightcraft_engine::export::export_photo(&mut s, photo, &o, 1).unwrap();
assert!(e.bytes.len() > 1000);
}
let status = s.execute("denoise.status", &json!({})).unwrap();
println!("device: {}", status["device"]);
println!("cache: {}", status["cache"]);
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -9,6 +9,7 @@ description = "LightCraft GPU develop pipeline: wgpu compute (WGSL) twin of the
workspace = true
[dependencies]
lightcraft-denoise = { workspace = true, optional = true }
lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
@ -23,5 +24,9 @@ wgpu = { workspace = true }
pollster = { workspace = true }
bytemuck = { workspace = true }
[features]
default = []
denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"]
[dev-dependencies]
lightcraft-scenes = { workspace = true }

View File

@ -29,6 +29,8 @@ use lightcraft_raster::Rgb32f;
pub mod backend;
#[cfg(not(target_arch = "wasm32"))]
mod ctx;
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
pub mod nn;
#[cfg(not(target_arch = "wasm32"))]
mod params;
#[cfg(not(target_arch = "wasm32"))]
@ -145,6 +147,16 @@ pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled()
}
/// Why GPU compute that has a device of its own (denoise) is switched off by the user's settings, or `None`: the
/// environment switches and the rendering preference, but not a render-device failure (the devices are separate).
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
pub(crate) fn switched_off() -> Option<String> {
if env_disabled() {
return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into());
}
(!ENABLED.load(Ordering::Relaxed)).then(|| "disabled by the GPU rendering preference (app.gpu)".into())
}
/// `LIGHTCRAFT_GPU=0` (or `LIGHTCRAFT_GPU_BACKEND=off`): no GPU for the whole process.
fn env_disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();

1048
crates/gpu/src/nn.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,162 @@
// Convolution for the denoise networks, as an implicit matrix product: every output pixel is a row, every input
// channel of every kernel tap is a column of K, and the output channels are the columns of N.
//
// out[m][n] = bias[n] + sum_k A[m][k] * W[k][n] A[m][k] = input pixel (m + tap offset), channel ci
//
// Tensors are NHWC, channels innermost, read four at a time (every channel count is a multiple of 4). A workgroup of
// 256 threads makes a BM x BN block of the output, 4 x 4 values per thread, and walks K in steps of BK through
// workgroup memory. The source can be two tensors whose channels follow each other (a skip connection's join, so it
// is never made), and a leaky ReLU is applied on the way out. Specialised by the host: BM, BN, BK (BM * BN = 4096).
//
// Output modes:
// 0 NHWC at the input's size.
// 1 a 2x2 stride-2 transposed convolution: the columns are (dy, dx, co) and each lands in the output pixel
// (2y + dy, 2x + dx) of an output twice the size.
// 2 the last 1x1 convolution followed by depth-to-space (CRD): columns 4c + 2dy + dx land in plane c at
// (2y + dy, 2x + dx) of a planar (CHW) output twice the size.
struct P {
h: u32,
w: u32,
c0: u32,
c1: u32,
n: u32,
npad: u32,
k: u32,
mode: u32,
cout: u32,
leaky: u32,
alpha: f32,
pad: u32,
}
@group(0) @binding(0) var<uniform> p: P;
@group(0) @binding(1) var<storage, read> src0: array<vec4<f32>>;
@group(0) @binding(2) var<storage, read> src1: array<vec4<f32>>;
@group(0) @binding(3) var<storage, read> wgt: array<vec4<f32>>;
@group(0) @binding(4) var<storage, read> bias: array<vec4<f32>>;
@group(0) @binding(5) var<storage, read_write> dst: array<f32>;
const BM: u32 = {{BM}}u;
const BN: u32 = {{BN}}u;
const BK: u32 = {{BK}}u;
const TX: u32 = BN / 4u;
const A_ITEMS: u32 = BM * (BK / 4u);
const B_ITEMS: u32 = BK * (BN / 4u);
// A[k][m] four pixels to a vec4, B[k][n] four channels to a vec4
var<workgroup> As: array<vec4<f32>, {{A_VECS}}>;
var<workgroup> Bs: array<vec4<f32>, {{B_VECS}}>;
@compute @workgroup_size(256)
fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
let m0 = wg.x * BM;
let n0 = wg.y * BN;
let tx = tid % TX;
let ty = tid / TX;
let hw = p.h * p.w;
let cin = p.c0 + p.c1;
let taps = p.k * p.k;
let pad = (p.k - 1u) / 2u;
let chunks = cin / BK;
var acc0 = vec4<f32>(0.0);
var acc1 = vec4<f32>(0.0);
var acc2 = vec4<f32>(0.0);
var acc3 = vec4<f32>(0.0);
for (var tap = 0u; tap < taps; tap++) {
let ty_off = i32(tap / p.k) - i32(pad);
let tx_off = i32(tap % p.k) - i32(pad);
for (var chunk = 0u; chunk < chunks; chunk++) {
let ci0 = chunk * BK;
for (var item = tid; item < A_ITEMS; item += 256u) {
let ml = item / (BK / 4u);
let q = item % (BK / 4u);
let m = m0 + ml;
var v = vec4<f32>(0.0);
if (m < hw) {
let y = i32(m / p.w) + ty_off;
let x = i32(m % p.w) + tx_off;
if (y >= 0 && y < i32(p.h) && x >= 0 && x < i32(p.w)) {
let pix = u32(y) * p.w + u32(x);
let ci = ci0 + q * 4u;
if (ci < p.c0) {
v = src0[pix * (p.c0 / 4u) + ci / 4u];
} else {
v = src1[pix * (p.c1 / 4u) + (ci - p.c0) / 4u];
}
}
}
let base = (q * 4u) * (BM / 4u) + ml / 4u;
let lane = ml % 4u;
As[base][lane] = v.x;
As[base + BM / 4u][lane] = v.y;
As[base + 2u * (BM / 4u)][lane] = v.z;
As[base + 3u * (BM / 4u)][lane] = v.w;
}
for (var item = tid; item < B_ITEMS; item += 256u) {
let r = item / (BN / 4u);
let c4 = item % (BN / 4u);
let krow = tap * cin + ci0 + r;
Bs[item] = wgt[krow * (p.npad / 4u) + n0 / 4u + c4];
}
workgroupBarrier();
for (var kk = 0u; kk < BK; kk++) {
let a = As[kk * (BM / 4u) + ty];
let b = Bs[kk * TX + tx];
acc0 += a.x * b;
acc1 += a.y * b;
acc2 += a.z * b;
acc3 += a.w * b;
}
workgroupBarrier();
}
}
let n = n0 + tx * 4u;
if (n >= p.n) {
return;
}
let bv = bias[n / 4u];
for (var i = 0u; i < 4u; i++) {
let m = m0 + ty * 4u + i;
if (m >= hw) {
continue;
}
var v = bv;
if (i == 0u) { v += acc0; } else if (i == 1u) { v += acc1; } else if (i == 2u) { v += acc2; } else { v += acc3; }
if (p.leaky != 0u) {
v = select(v * p.alpha, v, v >= vec4<f32>(0.0));
}
if (p.mode == 0u) {
let o = m * p.n + n;
dst[o] = v.x;
dst[o + 1u] = v.y;
dst[o + 2u] = v.z;
dst[o + 3u] = v.w;
} else if (p.mode == 1u) {
let q = n / p.cout;
let co = n % p.cout;
let y = m / p.w;
let x = m % p.w;
let opix = (2u * y + q / 2u) * (2u * p.w) + 2u * x + q % 2u;
let o = opix * p.cout + co;
dst[o] = v.x;
dst[o + 1u] = v.y;
dst[o + 2u] = v.z;
dst[o + 3u] = v.w;
} else {
let c = n / 4u;
let y = m / p.w;
let x = m % p.w;
let ow = 2u * p.w;
let plane = c * (2u * p.h) * ow;
let o = plane + (2u * y) * ow + 2u * x;
dst[o] = v.x;
dst[o + 1u] = v.y;
dst[o + ow] = v.z;
dst[o + ow + 1u] = v.w;
}
}
}

View File

@ -0,0 +1,28 @@
// 2x2 maximum with stride 2 over an NHWC tensor read and written four channels at a time.
struct P {
h: u32,
w: u32,
c4: u32,
total: u32,
}
@group(0) @binding(0) var<uniform> p: P;
@group(0) @binding(1) var<storage, read> src: array<vec4<f32>>;
@group(0) @binding(2) var<storage, read_write> dst: array<vec4<f32>>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>, @builtin(num_workgroups) nwg: vec3<u32>) {
let o = gid.x + gid.y * nwg.x * 64u;
if (o >= p.total) {
return;
}
let c = o % p.c4;
let pix = o / p.c4;
let ow = p.w / 2u;
let oy = pix / ow;
let ox = pix % ow;
let i00 = ((2u * oy) * p.w + 2u * ox) * p.c4 + c;
let i10 = i00 + p.w * p.c4;
dst[o] = max(max(src[i00], src[i00 + p.c4]), max(src[i10], src[i10 + p.c4]));
}

View File

@ -31,7 +31,7 @@ impl Drop for Headless {
impl Default for Headless {
fn default() -> Self {
Self::new(Session::new().with_fs().with_default_face_models())
Self::new(Session::new().with_fs().with_default_denoise_models().with_default_face_models())
}
}
@ -42,7 +42,7 @@ impl Headless {
/// A headless session with the procedurally generated demo library.
pub fn demo() -> Self {
Self::new(Session::with_demo().with_fs().with_default_face_models())
Self::new(Session::with_demo().with_fs().with_default_denoise_models().with_default_face_models())
}
fn photo_or_active(&self, p: &Value) -> Result<PhotoId, String> {

View File

@ -689,14 +689,27 @@ impl RawImage {
/// The result is camera RGB (not white balanced), white level = 1.0, not oriented.
pub fn develop(&self, method: Method) -> Result<Rgb32f> {
let n = self.normalized()?;
let mut rgb = demosaic(&n, method);
let rgb = demosaic(&n, method);
drop(n);
Ok(self.finish_demosaiced(rgb))
}
/// The last steps of [`Self::develop`] for camera RGB made some other way (a denoiser that demosaics too) from
/// [`Self::normalized`]'s mosaic: apply `OpcodeList3`, then the default crop.
pub fn finish_demosaiced(&self, mut rgb: Rgb32f) -> Rgb32f {
opcodes::apply_list3(&self.opcodes.list3, &mut rgb);
let c = self.crop.clipped(rgb.width, rgb.height);
if c.width == 0 || c.height == 0 || (c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height) {
return Ok(rgb);
let c = self.develop_crop(rgb.width, rgb.height);
if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height {
return rgb;
}
Ok(rgb.into_crop(c.x, c.y, c.width, c.height))
rgb.into_crop(c.x, c.y, c.width, c.height)
}
/// The part of a `width × height` demosaiced picture (the active area) that [`Self::develop`] keeps: the default
/// crop, or all of it when there is none.
pub fn develop_crop(&self, width: usize, height: usize) -> Rect {
let c = self.crop.clipped(width, height);
if c.width == 0 || c.height == 0 { Rect::new(0, 0, width, height) } else { c }
}
}
@ -729,6 +742,43 @@ mod tests {
}
}
#[test]
fn a_picture_made_elsewhere_is_finished_like_develop_does() {
let (w, h) = (12usize, 10usize);
let raw = RawImage {
format: RawFormat::Dng,
width: w,
height: h,
cpp: 1,
data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()),
cfa: Cfa::bayer("RGGB"),
bits: 12,
black: BlackLevel::uniform(64.0),
white: vec![1023.0],
active_area: Rect::new(2, 2, 8, 6),
crop: Rect::new(1, 1, 5, 4),
orientation: Orientation::Normal,
color: ColorData::default(),
wb_multipliers: None,
linearized: false,
opcodes: OpcodeLists::default(),
metadata: Metadata::default(),
};
let direct = raw.develop(Method::Bilinear).unwrap();
let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear));
assert_eq!((direct.width, direct.height), (5, 4));
assert_eq!(direct, by_hand);
// the crop is what develop keeps; no usable crop keeps everything
assert_eq!(raw.develop_crop(8, 6), Rect::new(1, 1, 5, 4));
let mut whole = raw.clone();
whole.crop = Rect::default();
assert_eq!(whole.develop_crop(8, 6), Rect::new(0, 0, 8, 6));
assert_eq!(whole.develop(Method::Bilinear).unwrap().width, 8);
// a crop that pokes out is clipped to the picture
whole.crop = Rect::new(6, 4, 10, 10);
assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2));
}
#[test]
fn black_levels() {
let b = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![1.0, 2.0, 3.0, 4.0], delta_h: vec![0.5, 0.0], delta_v: vec![0.0, 10.0] };

View File

@ -3,6 +3,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(not(target_arch = "wasm32"))]
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let dir = args.next().ok_or("usage: lightcraft-sam3-worker MODEL_DIR [127.0.0.1:8793]")?;
@ -11,3 +12,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
lightcraft_segment::remote::serve(address, std::path::Path::new(&dir), device)?;
Ok(())
}
/// The worker serves a local GPU over a socket; there is nothing to serve in a browser.
#[cfg(target_arch = "wasm32")]
fn main() {}

View File

@ -25,6 +25,7 @@ serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
lightcraft-denoise = { workspace = true, features = ["runtime"] }
lightcraft-codecs = { workspace = true }
lightcraft-faces = { workspace = true }

View File

@ -187,5 +187,24 @@
"Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft",
"{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)",
", {} star{}": ", Sterne: {}{:.0}",
", {} label": ", Markierung: {}"
", {} label": ", Markierung: {}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.",
"{:.1} MB": "{:.1} MB",
"Running on the processor: {}.": "Running on the processor: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.",
"Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}",
"{size} GB": "{size} GB",
"{} of {} from {host}": "{} of {} from {host}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Making the denoised picture… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"Trained on: {provenance}": "Trained on: {provenance}",
" · {queued} waiting": " · {queued} waiting",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.",
"In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…",
"Running on {}.": "Running on {}.",
"{file} · {size}": "{file} · {size}",
"• {assumption}": "• {assumption}"
}

View File

@ -1798,5 +1798,94 @@
"Install the SAM 3 Model": "SAM-3-Modell installieren",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen.",
"Open Log Folder": "Protokollordner öffnen",
"Back to this person": "Back to this person"
"Back to this person": "Back to this person",
"AI Denoise": "AI Denoise",
"AI Denoise Model": "AI Denoise Model",
"AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.",
"AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.",
"Accept & Download": "Accept & Download",
"Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.",
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.",
"Cache": "Cache",
"Checking and installing the model…": "Checking and installing the model…",
"Checking…": "Checking…",
"Clear cache": "Clear cache",
"Denoise Models": "Denoise Models",
"Denoise Settings": "Denoise Settings",
"Denoise all photos with an Amount": "Denoise all photos with an Amount",
"Downloaded and checked. Installing…": "Downloaded and checked. Installing…",
"Failed its last test": "Failed its last test",
"Graphics card": "Graphics card",
"I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use",
"Inspect Denoise Model": "Inspect Denoise Model",
"Install Denoise Model": "Install Denoise Model",
"Install from file…": "Install from file…",
"Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.",
"Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).",
"LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:",
"Limit": "Limit",
"Make it now": "Make it now",
"Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at",
"Next up for AI Denoise…": "Next up for AI Denoise…",
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.",
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.",
"Open page": "Open page",
"Open the model's page": "Open the model's page",
"Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.",
"Processor": "Processor",
"Set up AI Denoise…": "Set up AI Denoise…",
"Speed": "Speed",
"Starting…": "Starting…",
"Tested and works on this computer": "Tested and works on this computer",
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works",
"The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.",
"The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.",
"The processor does the work.": "The processor does the work.",
"There is no model file to install": "There is no model file to install",
"This build cannot run denoise models.": "This build cannot run denoise models.",
"This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)",
"This file cannot be used yet": "This file cannot be used yet",
"Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first",
"Try again": "Try again",
"Try the graphics card again": "Try the graphics card again",
"Turn off": "Turn off",
"Unknown licence": "Unknown licence",
"Use": "Use",
"Waiting to start…": "Waiting to start…",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.",
"cached picture": "cached picture",
"cached pictures": "cached pictures",
"commercial use allowed": "commercial use allowed",
"denoiseModel.accept": "denoiseModel.accept",
"in use": "in use",
"installed": "installed",
"makes AI Denoise available": "makes AI Denoise available",
"non-commercial use only": "non-commercial use only",
"photo has its denoised picture.": "photo has its denoised picture.",
"photo waiting": "photo waiting",
"photos have their denoised picture.": "photos have their denoised picture.",
"photos waiting": "photos waiting",
"starts using it for AI Denoise": "starts using it for AI Denoise",
"terms unclear": "terms unclear",
"the graphics card": "the graphics card",
"the graphics card is not used": "the graphics card is not used",
"Cancel Denoise": "Cancel Denoise",
"Cancel Denoise Model Download": "Cancel Denoise Model Download",
"Choose Denoise Model": "Choose Denoise Model",
"Clear Denoise Cache": "Clear Denoise Cache",
"Denoise Model Downloads": "Denoise Model Downloads",
"Denoise Photos": "Denoise Photos",
"Denoise Pump": "Denoise Pump",
"Denoise Status": "Denoise Status",
"Download Denoise Model": "Download Denoise Model",
"Remove Denoise Model": "Remove Denoise Model",
"Test Denoise Model": "Test Denoise Model",
"AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo",
"After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.",
"Cancel pending action": "Cancel pending action",
"Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo",
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
}

View File

@ -187,5 +187,24 @@
"Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) de nuevo",
"{} label (click more labels to show any of them)": "Etiqueta {} (hacer clic en más etiquetas para mostrar cualquiera de ellas)",
", {} star{}": ", {} estrella{}",
", {} label": ", etiqueta {}"
", {} label": ", etiqueta {}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.",
"{:.1} MB": "{:.1} MB",
"Running on the processor: {}.": "Running on the processor: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.",
"Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}",
"{size} GB": "{size} GB",
"{} of {} from {host}": "{} of {} from {host}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Making the denoised picture… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"Trained on: {provenance}": "Trained on: {provenance}",
" · {queued} waiting": " · {queued} waiting",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.",
"In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…",
"Running on {}.": "Running on {}.",
"{file} · {size}": "{file} · {size}",
"• {assumption}": "• {assumption}"
}

View File

@ -1643,5 +1643,94 @@
"Captured until": "Capturadas hasta",
"Only": "Solo",
"Open Log Folder": "Abrir carpeta de registros",
"Back to this person": "Back to this person"
"Back to this person": "Back to this person",
"AI Denoise": "AI Denoise",
"AI Denoise Model": "AI Denoise Model",
"AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.",
"AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.",
"Accept & Download": "Accept & Download",
"Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.",
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.",
"Cache": "Cache",
"Checking and installing the model…": "Checking and installing the model…",
"Checking…": "Checking…",
"Clear cache": "Clear cache",
"Denoise Models": "Denoise Models",
"Denoise Settings": "Denoise Settings",
"Denoise all photos with an Amount": "Denoise all photos with an Amount",
"Downloaded and checked. Installing…": "Downloaded and checked. Installing…",
"Failed its last test": "Failed its last test",
"Graphics card": "Graphics card",
"I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use",
"Inspect Denoise Model": "Inspect Denoise Model",
"Install Denoise Model": "Install Denoise Model",
"Install from file…": "Install from file…",
"Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.",
"Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).",
"LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:",
"Limit": "Limit",
"Make it now": "Make it now",
"Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at",
"Next up for AI Denoise…": "Next up for AI Denoise…",
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.",
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.",
"Open page": "Open page",
"Open the model's page": "Open the model's page",
"Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.",
"Processor": "Processor",
"Set up AI Denoise…": "Set up AI Denoise…",
"Speed": "Speed",
"Starting…": "Starting…",
"Tested and works on this computer": "Tested and works on this computer",
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works",
"The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.",
"The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.",
"The processor does the work.": "The processor does the work.",
"There is no model file to install": "There is no model file to install",
"This build cannot run denoise models.": "This build cannot run denoise models.",
"This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)",
"This file cannot be used yet": "This file cannot be used yet",
"Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first",
"Try again": "Try again",
"Try the graphics card again": "Try the graphics card again",
"Turn off": "Turn off",
"Unknown licence": "Unknown licence",
"Use": "Use",
"Waiting to start…": "Waiting to start…",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.",
"cached picture": "cached picture",
"cached pictures": "cached pictures",
"commercial use allowed": "commercial use allowed",
"denoiseModel.accept": "denoiseModel.accept",
"in use": "in use",
"installed": "installed",
"makes AI Denoise available": "makes AI Denoise available",
"non-commercial use only": "non-commercial use only",
"photo has its denoised picture.": "photo has its denoised picture.",
"photo waiting": "photo waiting",
"photos have their denoised picture.": "photos have their denoised picture.",
"photos waiting": "photos waiting",
"starts using it for AI Denoise": "starts using it for AI Denoise",
"terms unclear": "terms unclear",
"the graphics card": "the graphics card",
"the graphics card is not used": "the graphics card is not used",
"Cancel Denoise": "Cancel Denoise",
"Cancel Denoise Model Download": "Cancel Denoise Model Download",
"Choose Denoise Model": "Choose Denoise Model",
"Clear Denoise Cache": "Clear Denoise Cache",
"Denoise Model Downloads": "Denoise Model Downloads",
"Denoise Photos": "Denoise Photos",
"Denoise Pump": "Denoise Pump",
"Denoise Status": "Denoise Status",
"Download Denoise Model": "Download Denoise Model",
"Remove Denoise Model": "Remove Denoise Model",
"Test Denoise Model": "Test Denoise Model",
"AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo",
"After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.",
"Cancel pending action": "Cancel pending action",
"Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo",
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
}

View File

@ -187,5 +187,24 @@
"Moved; {} photo(s) relinked": "移動しました。{}枚の写真の参照先を更新しました",
"{} label (click more labels to show any of them)": "{}ラベル(ほかのラベルもクリックすると、いずれかに一致する写真を表示)",
", {} star{}": "、星{}つ{:.0}",
", {} label": "、{}ラベル"
", {} label": "、{}ラベル",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.",
"{:.1} MB": "{:.1} MB",
"Running on the processor: {}.": "Running on the processor: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.",
"Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}",
"{size} GB": "{size} GB",
"{} of {} from {host}": "{} of {} from {host}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Making the denoised picture… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"Trained on: {provenance}": "Trained on: {provenance}",
" · {queued} waiting": " · {queued} waiting",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.",
"In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…",
"Running on {}.": "Running on {}.",
"{file} · {size}": "{file} · {size}",
"• {assumption}": "• {assumption}"
}

View File

@ -1643,5 +1643,94 @@
"Install the SAM 3 Model": "SAM 3モデルのインストール",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。",
"Open Log Folder": "ログフォルダーを開く",
"Back to this person": "Back to this person"
"Back to this person": "Back to this person",
"AI Denoise": "AI Denoise",
"AI Denoise Model": "AI Denoise Model",
"AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.",
"AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.",
"Accept & Download": "Accept & Download",
"Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.",
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.",
"Cache": "Cache",
"Checking and installing the model…": "Checking and installing the model…",
"Checking…": "Checking…",
"Clear cache": "Clear cache",
"Denoise Models": "Denoise Models",
"Denoise Settings": "Denoise Settings",
"Denoise all photos with an Amount": "Denoise all photos with an Amount",
"Downloaded and checked. Installing…": "Downloaded and checked. Installing…",
"Failed its last test": "Failed its last test",
"Graphics card": "Graphics card",
"I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use",
"Inspect Denoise Model": "Inspect Denoise Model",
"Install Denoise Model": "Install Denoise Model",
"Install from file…": "Install from file…",
"Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.",
"Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).",
"LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:",
"Limit": "Limit",
"Make it now": "Make it now",
"Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at",
"Next up for AI Denoise…": "Next up for AI Denoise…",
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.",
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.",
"Open page": "Open page",
"Open the model's page": "Open the model's page",
"Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.",
"Processor": "Processor",
"Set up AI Denoise…": "Set up AI Denoise…",
"Speed": "Speed",
"Starting…": "Starting…",
"Tested and works on this computer": "Tested and works on this computer",
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works",
"The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.",
"The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.",
"The processor does the work.": "The processor does the work.",
"There is no model file to install": "There is no model file to install",
"This build cannot run denoise models.": "This build cannot run denoise models.",
"This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)",
"This file cannot be used yet": "This file cannot be used yet",
"Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first",
"Try again": "Try again",
"Try the graphics card again": "Try the graphics card again",
"Turn off": "Turn off",
"Unknown licence": "Unknown licence",
"Use": "Use",
"Waiting to start…": "Waiting to start…",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.",
"cached picture": "cached picture",
"cached pictures": "cached pictures",
"commercial use allowed": "commercial use allowed",
"denoiseModel.accept": "denoiseModel.accept",
"in use": "in use",
"installed": "installed",
"makes AI Denoise available": "makes AI Denoise available",
"non-commercial use only": "non-commercial use only",
"photo has its denoised picture.": "photo has its denoised picture.",
"photo waiting": "photo waiting",
"photos have their denoised picture.": "photos have their denoised picture.",
"photos waiting": "photos waiting",
"starts using it for AI Denoise": "starts using it for AI Denoise",
"terms unclear": "terms unclear",
"the graphics card": "the graphics card",
"the graphics card is not used": "the graphics card is not used",
"Cancel Denoise": "Cancel Denoise",
"Cancel Denoise Model Download": "Cancel Denoise Model Download",
"Choose Denoise Model": "Choose Denoise Model",
"Clear Denoise Cache": "Clear Denoise Cache",
"Denoise Model Downloads": "Denoise Model Downloads",
"Denoise Photos": "Denoise Photos",
"Denoise Pump": "Denoise Pump",
"Denoise Status": "Denoise Status",
"Download Denoise Model": "Download Denoise Model",
"Remove Denoise Model": "Remove Denoise Model",
"Test Denoise Model": "Test Denoise Model",
"AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo",
"After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.",
"Cancel pending action": "Cancel pending action",
"Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo",
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
}

View File

@ -187,5 +187,24 @@
"Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) novamente",
"{} label (click more labels to show any of them)": "Rótulo {} (clique em mais rótulos para mostrar qualquer um deles)",
", {} star{}": ", {} estrela{}",
", {} label": ", rótulo {}"
", {} label": ", rótulo {}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.",
"{:.1} MB": "{:.1} MB",
"Running on the processor: {}.": "Running on the processor: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.",
"Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}",
"{size} GB": "{size} GB",
"{} of {} from {host}": "{} of {} from {host}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Making the denoised picture… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"Trained on: {provenance}": "Trained on: {provenance}",
" · {queued} waiting": " · {queued} waiting",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.",
"In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…",
"Running on {}.": "Running on {}.",
"{file} · {size}": "{file} · {size}",
"• {assumption}": "• {assumption}"
}

View File

@ -1643,5 +1643,94 @@
"≥ at least": "≥ no mínimo",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)",
"Back to this person": "Back to this person"
"Back to this person": "Back to this person",
"AI Denoise": "AI Denoise",
"AI Denoise Model": "AI Denoise Model",
"AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.",
"AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.",
"Accept & Download": "Accept & Download",
"Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.",
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.",
"Cache": "Cache",
"Checking and installing the model…": "Checking and installing the model…",
"Checking…": "Checking…",
"Clear cache": "Clear cache",
"Denoise Models": "Denoise Models",
"Denoise Settings": "Denoise Settings",
"Denoise all photos with an Amount": "Denoise all photos with an Amount",
"Downloaded and checked. Installing…": "Downloaded and checked. Installing…",
"Failed its last test": "Failed its last test",
"Graphics card": "Graphics card",
"I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use",
"Inspect Denoise Model": "Inspect Denoise Model",
"Install Denoise Model": "Install Denoise Model",
"Install from file…": "Install from file…",
"Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.",
"Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).",
"LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:",
"Limit": "Limit",
"Make it now": "Make it now",
"Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at",
"Next up for AI Denoise…": "Next up for AI Denoise…",
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.",
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.",
"Open page": "Open page",
"Open the model's page": "Open the model's page",
"Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.",
"Processor": "Processor",
"Set up AI Denoise…": "Set up AI Denoise…",
"Speed": "Speed",
"Starting…": "Starting…",
"Tested and works on this computer": "Tested and works on this computer",
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works",
"The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.",
"The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.",
"The processor does the work.": "The processor does the work.",
"There is no model file to install": "There is no model file to install",
"This build cannot run denoise models.": "This build cannot run denoise models.",
"This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)",
"This file cannot be used yet": "This file cannot be used yet",
"Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first",
"Try again": "Try again",
"Try the graphics card again": "Try the graphics card again",
"Turn off": "Turn off",
"Unknown licence": "Unknown licence",
"Use": "Use",
"Waiting to start…": "Waiting to start…",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.",
"cached picture": "cached picture",
"cached pictures": "cached pictures",
"commercial use allowed": "commercial use allowed",
"denoiseModel.accept": "denoiseModel.accept",
"in use": "in use",
"installed": "installed",
"makes AI Denoise available": "makes AI Denoise available",
"non-commercial use only": "non-commercial use only",
"photo has its denoised picture.": "photo has its denoised picture.",
"photo waiting": "photo waiting",
"photos have their denoised picture.": "photos have their denoised picture.",
"photos waiting": "photos waiting",
"starts using it for AI Denoise": "starts using it for AI Denoise",
"terms unclear": "terms unclear",
"the graphics card": "the graphics card",
"the graphics card is not used": "the graphics card is not used",
"Cancel Denoise": "Cancel Denoise",
"Cancel Denoise Model Download": "Cancel Denoise Model Download",
"Choose Denoise Model": "Choose Denoise Model",
"Clear Denoise Cache": "Clear Denoise Cache",
"Denoise Model Downloads": "Denoise Model Downloads",
"Denoise Photos": "Denoise Photos",
"Denoise Pump": "Denoise Pump",
"Denoise Status": "Denoise Status",
"Download Denoise Model": "Download Denoise Model",
"Remove Denoise Model": "Remove Denoise Model",
"Test Denoise Model": "Test Denoise Model",
"AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo",
"After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.",
"Cancel pending action": "Cancel pending action",
"Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo",
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
}

View File

@ -187,5 +187,24 @@
"{} — show their photos": "{} — показать фото этого человека",
"· {dups} already in the library": "· уже в библиотеке: {dups}",
"· {n} photo{}": "· фото: {n}{:.0}",
"… and {more} more": "… и ещё {more}"
"… and {more} more": "… и ещё {more}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.",
"{:.1} MB": "{:.1} MB",
"Running on the processor: {}.": "Running on the processor: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.",
"Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}",
"{size} GB": "{size} GB",
"{} of {} from {host}": "{} of {} from {host}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Making the denoised picture… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"Trained on: {provenance}": "Trained on: {provenance}",
" · {queued} waiting": " · {queued} waiting",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.",
"In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…",
"Running on {}.": "Running on {}.",
"{file} · {size}": "{file} · {size}",
"• {assumption}": "• {assumption}"
}

View File

@ -1313,5 +1313,95 @@
"saved in": "сохранено в",
"this photo": "это фото",
"− harder border · + softer border": "− жёстче край · + мягче край",
"Back to this person": "Back to this person"
"Back to this person": "Back to this person",
"AI Denoise": "AI Denoise",
"AI Denoise Model": "AI Denoise Model",
"AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.",
"AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.",
"Accept & Download": "Accept & Download",
"Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.",
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.",
"Cache": "Cache",
"Checking and installing the model…": "Checking and installing the model…",
"Checking…": "Checking…",
"Clear cache": "Clear cache",
"Denoise Models": "Denoise Models",
"Denoise Settings": "Denoise Settings",
"Denoise all photos with an Amount": "Denoise all photos with an Amount",
"Downloaded and checked. Installing…": "Downloaded and checked. Installing…",
"Failed its last test": "Failed its last test",
"Graphics card": "Graphics card",
"I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use",
"Inspect Denoise Model": "Inspect Denoise Model",
"Install Denoise Model": "Install Denoise Model",
"Install from file…": "Install from file…",
"Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.",
"Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).",
"LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:",
"Limit": "Limit",
"Make it now": "Make it now",
"Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at",
"Models": "Models",
"Next up for AI Denoise…": "Next up for AI Denoise…",
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.",
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.",
"Open page": "Open page",
"Open the model's page": "Open the model's page",
"Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.",
"Processor": "Processor",
"Set up AI Denoise…": "Set up AI Denoise…",
"Speed": "Speed",
"Starting…": "Starting…",
"Tested and works on this computer": "Tested and works on this computer",
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works",
"The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.",
"The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.",
"The processor does the work.": "The processor does the work.",
"There is no model file to install": "There is no model file to install",
"This build cannot run denoise models.": "This build cannot run denoise models.",
"This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)",
"This file cannot be used yet": "This file cannot be used yet",
"Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first",
"Try again": "Try again",
"Try the graphics card again": "Try the graphics card again",
"Turn off": "Turn off",
"Unknown licence": "Unknown licence",
"Use": "Use",
"Waiting to start…": "Waiting to start…",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.",
"cached picture": "cached picture",
"cached pictures": "cached pictures",
"commercial use allowed": "commercial use allowed",
"denoiseModel.accept": "denoiseModel.accept",
"in use": "in use",
"installed": "installed",
"makes AI Denoise available": "makes AI Denoise available",
"non-commercial use only": "non-commercial use only",
"photo has its denoised picture.": "photo has its denoised picture.",
"photo waiting": "photo waiting",
"photos have their denoised picture.": "photos have their denoised picture.",
"photos waiting": "photos waiting",
"starts using it for AI Denoise": "starts using it for AI Denoise",
"terms unclear": "terms unclear",
"the graphics card": "the graphics card",
"the graphics card is not used": "the graphics card is not used",
"Cancel Denoise": "Cancel Denoise",
"Cancel Denoise Model Download": "Cancel Denoise Model Download",
"Choose Denoise Model": "Choose Denoise Model",
"Clear Denoise Cache": "Clear Denoise Cache",
"Denoise Model Downloads": "Denoise Model Downloads",
"Denoise Photos": "Denoise Photos",
"Denoise Pump": "Denoise Pump",
"Denoise Status": "Denoise Status",
"Download Denoise Model": "Download Denoise Model",
"Remove Denoise Model": "Remove Denoise Model",
"Test Denoise Model": "Test Denoise Model",
"AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo",
"After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.",
"Cancel pending action": "Cancel pending action",
"Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo",
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
}

View File

@ -187,5 +187,24 @@
"Moved; {} photo(s) relinked": "已移动;已重新链接 {0} 张照片",
"{} label (click more labels to show any of them)": "{0}标签(单击更多标签可显示符合其中任一标签的照片)",
", {} star{}": ",{0} 星{1:.0}",
", {} label": ",{0}标签"
", {} label": ",{0}标签",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.",
"{:.1} MB": "{:.1} MB",
"Running on the processor: {}.": "Running on the processor: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.",
"Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}",
"{size} GB": "{size} GB",
"{} of {} from {host}": "{} of {} from {host}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Making the denoised picture… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"Trained on: {provenance}": "Trained on: {provenance}",
" · {queued} waiting": " · {queued} waiting",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.",
"In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…",
"Running on {}.": "Running on {}.",
"{file} · {size}": "{file} · {size}",
"• {assumption}": "• {assumption}"
}

View File

@ -1643,5 +1643,94 @@
"Install the SAM 3 Model": "安装 SAM 3 模型",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。",
"Open Log Folder": "打开日志文件夹",
"Back to this person": "Back to this person"
"Back to this person": "Back to this person",
"AI Denoise": "AI Denoise",
"AI Denoise Model": "AI Denoise Model",
"AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.",
"AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.",
"Accept & Download": "Accept & Download",
"Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.",
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.",
"Cache": "Cache",
"Checking and installing the model…": "Checking and installing the model…",
"Checking…": "Checking…",
"Clear cache": "Clear cache",
"Denoise Models": "Denoise Models",
"Denoise Settings": "Denoise Settings",
"Denoise all photos with an Amount": "Denoise all photos with an Amount",
"Downloaded and checked. Installing…": "Downloaded and checked. Installing…",
"Failed its last test": "Failed its last test",
"Graphics card": "Graphics card",
"I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use",
"Inspect Denoise Model": "Inspect Denoise Model",
"Install Denoise Model": "Install Denoise Model",
"Install from file…": "Install from file…",
"Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.",
"Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).",
"LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:",
"Limit": "Limit",
"Make it now": "Make it now",
"Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at",
"Next up for AI Denoise…": "Next up for AI Denoise…",
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.",
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.",
"Open page": "Open page",
"Open the model's page": "Open the model's page",
"Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.",
"Processor": "Processor",
"Set up AI Denoise…": "Set up AI Denoise…",
"Speed": "Speed",
"Starting…": "Starting…",
"Tested and works on this computer": "Tested and works on this computer",
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works",
"The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.",
"The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.",
"The processor does the work.": "The processor does the work.",
"There is no model file to install": "There is no model file to install",
"This build cannot run denoise models.": "This build cannot run denoise models.",
"This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)",
"This file cannot be used yet": "This file cannot be used yet",
"Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first",
"Try again": "Try again",
"Try the graphics card again": "Try the graphics card again",
"Turn off": "Turn off",
"Unknown licence": "Unknown licence",
"Use": "Use",
"Waiting to start…": "Waiting to start…",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.",
"cached picture": "cached picture",
"cached pictures": "cached pictures",
"commercial use allowed": "commercial use allowed",
"denoiseModel.accept": "denoiseModel.accept",
"in use": "in use",
"installed": "installed",
"makes AI Denoise available": "makes AI Denoise available",
"non-commercial use only": "non-commercial use only",
"photo has its denoised picture.": "photo has its denoised picture.",
"photo waiting": "photo waiting",
"photos have their denoised picture.": "photos have their denoised picture.",
"photos waiting": "photos waiting",
"starts using it for AI Denoise": "starts using it for AI Denoise",
"terms unclear": "terms unclear",
"the graphics card": "the graphics card",
"the graphics card is not used": "the graphics card is not used",
"Cancel Denoise": "Cancel Denoise",
"Cancel Denoise Model Download": "Cancel Denoise Model Download",
"Choose Denoise Model": "Choose Denoise Model",
"Clear Denoise Cache": "Clear Denoise Cache",
"Denoise Model Downloads": "Denoise Model Downloads",
"Denoise Photos": "Denoise Photos",
"Denoise Pump": "Denoise Pump",
"Denoise Status": "Denoise Status",
"Download Denoise Model": "Download Denoise Model",
"Remove Denoise Model": "Remove Denoise Model",
"Test Denoise Model": "Test Denoise Model",
"AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo",
"After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.",
"Cancel pending action": "Cancel pending action",
"Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo",
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
}

View File

@ -187,5 +187,24 @@
"Moved; {} photo(s) relinked": "已移動;已重新連結 {} 張照片",
"{} label (click more labels to show any of them)": "{}標籤(點擊更多標籤可顯示符合其中任一標籤的照片)",
", {} star{}": ",{} 顆星{:.0}",
", {} label": ",{}標籤"
", {} label": ",{}標籤",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.",
"{:.1} MB": "{:.1} MB",
"Running on the processor: {}.": "Running on the processor: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.",
"Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}",
"{size} GB": "{size} GB",
"{} of {} from {host}": "{} of {} from {host}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Making the denoised picture… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"Trained on: {provenance}": "Trained on: {provenance}",
" · {queued} waiting": " · {queued} waiting",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.",
"In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…",
"Running on {}.": "Running on {}.",
"{file} · {size}": "{file} · {size}",
"• {assumption}": "• {assumption}"
}

View File

@ -1643,5 +1643,94 @@
"Install the SAM 3 Model": "安裝 SAM 3 模型",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。",
"Open Log Folder": "開啟記錄資料夾",
"Back to this person": "Back to this person"
"Back to this person": "Back to this person",
"AI Denoise": "AI Denoise",
"AI Denoise Model": "AI Denoise Model",
"AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.",
"AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.",
"Accept & Download": "Accept & Download",
"Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.",
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.",
"Cache": "Cache",
"Checking and installing the model…": "Checking and installing the model…",
"Checking…": "Checking…",
"Clear cache": "Clear cache",
"Denoise Models": "Denoise Models",
"Denoise Settings": "Denoise Settings",
"Denoise all photos with an Amount": "Denoise all photos with an Amount",
"Downloaded and checked. Installing…": "Downloaded and checked. Installing…",
"Failed its last test": "Failed its last test",
"Graphics card": "Graphics card",
"I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use",
"Inspect Denoise Model": "Inspect Denoise Model",
"Install Denoise Model": "Install Denoise Model",
"Install from file…": "Install from file…",
"Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.",
"Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).",
"LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:",
"Limit": "Limit",
"Make it now": "Make it now",
"Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at",
"Next up for AI Denoise…": "Next up for AI Denoise…",
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.",
"Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.",
"Open page": "Open page",
"Open the model's page": "Open the model's page",
"Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.",
"Processor": "Processor",
"Set up AI Denoise…": "Set up AI Denoise…",
"Speed": "Speed",
"Starting…": "Starting…",
"Tested and works on this computer": "Tested and works on this computer",
"The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works",
"The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.",
"The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.",
"The processor does the work.": "The processor does the work.",
"There is no model file to install": "There is no model file to install",
"This build cannot run denoise models.": "This build cannot run denoise models.",
"This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)",
"This file cannot be used yet": "This file cannot be used yet",
"Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first",
"Try again": "Try again",
"Try the graphics card again": "Try the graphics card again",
"Turn off": "Turn off",
"Unknown licence": "Unknown licence",
"Use": "Use",
"Waiting to start…": "Waiting to start…",
"Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.",
"cached picture": "cached picture",
"cached pictures": "cached pictures",
"commercial use allowed": "commercial use allowed",
"denoiseModel.accept": "denoiseModel.accept",
"in use": "in use",
"installed": "installed",
"makes AI Denoise available": "makes AI Denoise available",
"non-commercial use only": "non-commercial use only",
"photo has its denoised picture.": "photo has its denoised picture.",
"photo waiting": "photo waiting",
"photos have their denoised picture.": "photos have their denoised picture.",
"photos waiting": "photos waiting",
"starts using it for AI Denoise": "starts using it for AI Denoise",
"terms unclear": "terms unclear",
"the graphics card": "the graphics card",
"the graphics card is not used": "the graphics card is not used",
"Cancel Denoise": "Cancel Denoise",
"Cancel Denoise Model Download": "Cancel Denoise Model Download",
"Choose Denoise Model": "Choose Denoise Model",
"Clear Denoise Cache": "Clear Denoise Cache",
"Denoise Model Downloads": "Denoise Model Downloads",
"Denoise Photos": "Denoise Photos",
"Denoise Pump": "Denoise Pump",
"Denoise Status": "Denoise Status",
"Download Denoise Model": "Download Denoise Model",
"Remove Denoise Model": "Remove Denoise Model",
"Test Denoise Model": "Test Denoise Model",
"AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo",
"After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.",
"Cancel pending action": "Cancel pending action",
"Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo",
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
}

View File

@ -268,6 +268,7 @@ impl Headless {
|| self.app.scan.is_some()
|| self.app.import.is_some()
|| self.app.export.is_some()
|| self.app.session.denoise_busy()
|| !self.app.tasks.is_empty()
|| !self.app.synthetic.is_empty()
|| !self.events.is_empty()
@ -365,6 +366,62 @@ impl Headless {
#[cfg(test)]
mod tests {
#[test]
fn detail_offers_ai_denoise_for_raw_photos_and_leads_to_its_settings() {
use crate::state::Dialog;
use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source};
let mut h = demo([1400.0, 900.0]);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-ui-denoise-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models")));
// a raw photo (its file need not exist: nothing here reads it)
let id = PhotoId(h.app.session.catalog.photos().map(|p| p.id.0).max().unwrap_or(0) + 1);
let mut raw = Photo::new(
id,
Source::File { path: dir.join("IMG_1.dng").to_string_lossy().into_owned() },
"IMG_1.dng",
"DNG",
6000,
4000,
"2026-10-01T00:00:00",
);
raw.kind = MediaKind::Raw;
h.app.session.commit("setup", Op::AddPhoto { photo: Box::new(raw) }).unwrap();
let widgets = |h: &mut Headless| h.request("ui.widgets", json!({}), t).to_string();
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t);
h.request("ui.set", json!({"view": "detail"}), t);
h.app.ui.open_sections = vec!["detail".to_string()];
h.settle(SETTLE);
h.step();
h.step();
let w = widgets(&mut h);
assert!(w.contains("\"denoise:setup\""), "a raw photo with no model is offered the setup: {w}");
// One click lands in Settings, with local installation available even without a download offer.
let r = h.request("ui.clickWidget", json!({"id": "denoise:setup"}), t);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() }));
h.settle(SETTLE);
let listed = h.app.session.execute("denoise.models.list", &json!({})).unwrap();
assert_eq!(listed["models"].as_array().unwrap().len(), lightcraft_denoise::known::all().len(), "{listed}");
assert!(widgets(&mut h).contains("\"denoise:installFile\""));
h.app.ui.dialog = None;
// a photo that is not raw: no offer
h.request(
"engine.execute",
json!({"command": "library.select", "params": {"ids": [h.app.session.catalog.photos().find(|p| p.id != id).unwrap().id.0]}}),
t,
);
h.settle(SETTLE);
h.step();
h.step();
assert!(!widgets(&mut h).contains("\"denoise:setup\""), "nothing is offered for a photo denoise does not apply to");
let _ = std::fs::remove_dir_all(&dir);
}
use super::*;
#[test]

View File

@ -19,6 +19,7 @@ pub mod menu_level;
pub mod menubar;
pub mod menus;
pub mod merge;
mod model_setup;
pub mod panels;
pub mod pick;
pub mod region;
@ -106,6 +107,8 @@ pub struct Services {
pub picker: Option<pick::Picker>,
/// Show an open dialog for photos; returns paths.
pub pick_files: Option<PickFiles>,
/// Local ONNX model; a manifest must accompany it.
pub pick_denoise_model: Option<PickFiles>,
/// Open dialog for a face model (`.onnx`; Settings ▸ Faces ▸ Add a model file…).
pub pick_model_file: Option<PickFiles>,
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
@ -159,6 +162,7 @@ pub struct Perf {
}
pub struct LightcraftApp {
pub(crate) model_setup: model_setup::Pending,
/// Per-catalog-revision caches of library-wide results the panels show every frame
/// (expensive on big libraries).
pub caches: Caches,
@ -314,6 +318,7 @@ impl LightcraftApp {
gpu_applied: None,
memory_applied: None,
library_problem: None,
model_setup: Default::default(),
}
}
@ -326,6 +331,9 @@ impl LightcraftApp {
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
if let Some(result) = model_setup::intercept(self, id, &params) {
return result;
}
if let Some(r) = menus::run_ui_command(self, id, &params) {
return r;
}
@ -765,6 +773,8 @@ impl LightcraftApp {
ctx.request_repaint_after(std::time::Duration::from_secs(3));
}
self.session.persist_if_dirty();
panels::denoise::pump(self, ctx);
model_setup::pump(self, ctx);
panels::faces::pump(self, ctx);
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
@ -1052,6 +1062,8 @@ pub struct Caches {
album_counts: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>,
/// How often the album counts were recomputed (tests check that unchanged frames don't).
pub album_count_scans: usize,
/// AI denoise: what the pump last saw, the model list and the downloads being watched.
pub denoise: panels::denoise::Ui,
/// Bumped when a face model is installed, removed or chosen, so Settings re-reads the list at once.
pub faces_epoch: u64,
/// Face model downloads the user started, followed until installed (see `panels::faces::pump`).
@ -1313,3 +1325,6 @@ mod cache_tests {
assert_eq!(c.album_count_scans, 1);
}
}
#[cfg(test)]
mod tests_model_setup;

View File

@ -47,6 +47,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
}
pub const UI_COMMANDS: &[UiCommand] = &[
("modelSetup.cancel", "Cancel Pending Photo Action", None, ""),
("view.photoGrid", "Photo Grid", None, "View"),
("view.squareGrid", "Square Grid", None, "View"),
// G: Photo Grid ↔ Square Grid (from other views: the photo grid)
@ -464,7 +465,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"app.settings" => {
let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general");
if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) {
return Some(Err(format!("unknown settings tab `{tab}` (general|import|performance|interface|faces)")));
let tabs: Vec<&str> = crate::panels::settings::TABS.iter().map(|(id, _)| *id).collect();
return Some(Err(format!("unknown settings tab `{tab}` ({})", tabs.join("|"))));
}
app.ui.dialog = Some(Dialog::Settings { tab: tab.into() });
Ok(Value::Null)

View File

@ -0,0 +1,134 @@
//! Resume a denoise request after model setup, bound to its original photo and library.
use crate::i18n::tr;
use crate::{LightcraftApp, state::Dialog};
use lightcraft_catalog::{PhotoId, Source};
use lightcraft_engine::denoise::PhotoState;
use serde_json::{Value, json};
struct Request {
library: u64,
photo: PhotoId,
source: Source,
}
impl Request {
fn capture(app: &LightcraftApp, id: PhotoId) -> Result<Self, String> {
let photo = app.session.catalog.photo(id).filter(|p| !p.deleted).ok_or("the requested photo is no longer available")?;
Ok(Self { library: app.session.library_generation(), photo: id, source: photo.source.clone() })
}
fn valid(&self, app: &LightcraftApp) -> bool {
self.library == app.session.library_generation()
&& app.session.catalog.photo(self.photo).is_some_and(|p| !p.deleted && p.source == self.source)
}
}
#[derive(Default)]
pub(crate) struct Pending {
denoise: Option<Request>,
next_check: f64,
}
pub(crate) fn denoise_requested(app: &LightcraftApp, id: PhotoId) -> bool {
app.model_setup.denoise.as_ref().is_some_and(|r| r.photo == id && r.valid(app))
}
pub(crate) fn intercept(app: &mut LightcraftApp, command: &str, params: &Value) -> Option<Result<Value, String>> {
if matches!(command, "app.openLibrary" | "file.restoreLibrary") {
app.model_setup = Pending::default();
return None;
}
if command == "modelSetup.cancel" {
if params.get("kind").and_then(Value::as_str) != Some("denoise") {
return Some(Err("modelSetup.cancel: use kind denoise".into()));
}
app.model_setup.denoise = None;
app.ui.status = tr("Pending photo action cancelled").into();
return Some(Ok(json!({"cancelled": true})));
}
if command == "denoise.models.downloadCancel" {
app.model_setup.denoise = None;
}
if command != "denoise.toggle" {
return None;
}
let id = match params.get("id") {
Some(v) => match v.as_u64() {
Some(id) => PhotoId(id),
None => return Some(Err("denoise.toggle: id must be a photo id".into())),
},
None => match app.session.active() {
Some(id) => id,
None => return Some(Err("select a raw photo".into())),
},
};
let enabled = app.session.develop_of(id).is_some_and(|d| d.enhance.denoise_enabled());
let on = match params.get("enabled") {
Some(v) => match v.as_bool() {
Some(on) => on,
None => return Some(Err("denoise.toggle: enabled must be true or false".into())),
},
None => !enabled && !denoise_requested(app, id),
};
if !on {
app.model_setup.denoise = None;
return Some(app.session.execute("denoise.toggle", &json!({"id": id.0, "enabled": false})).map_err(|e| e.to_string()));
}
let _ = app.session.execute("denoise.status", &json!({"id": id.0}));
if !matches!(app.session.denoise_photo_state(id), PhotoState::NoModel) {
return None;
}
if app.session.execute("denoise.models.list", &json!({})).ok().is_none_or(|v| v["dir"].is_null()) {
return None;
}
Some(Request::capture(app, id).map(|request| {
app.model_setup.denoise = Some(request);
app.model_setup.next_check = 0.0;
app.ui.dialog = Some(Dialog::Settings { tab: "denoise".into() });
app.ui.status = tr("Install a denoise model to enable AI Denoise on this photo").into();
json!({"setupRequired": true, "photo": id.0})
}))
}
pub(crate) fn notice(app: &mut LightcraftApp, ui: &mut egui::Ui, kind: &str) {
if kind != "denoise" || !app.model_setup.denoise.as_ref().is_some_and(|r| r.valid(app)) {
return;
}
ui.add(egui::Label::new(tr("After installation, AI Denoise will turn on for the requesting photo.")).wrap());
let r = ui.small_button(tr("Cancel pending action"));
crate::widgets::register(ui.ctx(), "modelSetup:cancel:denoise", r.rect);
if r.clicked() {
let _ = app.run("modelSetup.cancel", json!({"kind": "denoise"}));
}
ui.add_space(6.0);
}
pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context) {
if app.model_setup.denoise.is_none() {
return;
}
if app.session.interaction.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(250));
return;
}
let now = ctx.input(|i| i.time);
if now < app.model_setup.next_check {
return;
}
app.model_setup.next_check = now + 0.25;
let Some(request) = app.model_setup.denoise.take() else { return };
if !request.valid(app) {
app.ui.status = tr("Pending photo action cancelled because its library or photo changed").into();
return;
}
if matches!(app.session.denoise_photo_state(request.photo), PhotoState::NoModel | PhotoState::NotApplicable) {
app.model_setup.denoise = Some(request);
ctx.request_repaint_after(std::time::Duration::from_millis(250));
return;
}
match app.run("denoise.toggle", json!({"id": request.photo.0, "enabled": true})) {
Ok(_) => app.ui.status = tr("AI Denoise is enabled on the requesting photo").into(),
Err(error) => app.ui.status = error,
}
}

View File

@ -0,0 +1,688 @@
//! AI denoise in the interface: the line under the Detail panel's Denoise slider (what the picture is doing), the
//! Settings ▸ Denoise tab (the model and the cache) and the per-frame pump that keeps the engine's background work going.
//!
//! The Amount slider is an ordinary develop control (`enhance.denoise`); everything here is about the cleaned picture the
//! slider mixes in. It is cached data made in the background for the photos being looked at, and never a file in the
//! library. A model is fetched only when the user presses Download and accepts its terms.
use crate::i18n::{tr, tr_format};
use egui::RichText;
use lightcraft_catalog::PhotoId;
use lightcraft_engine::denoise::PhotoState;
use serde_json::{Value, json};
use super::settings::{check, choices, heading, hint};
use crate::LightcraftApp;
use crate::theme::Tokens;
use crate::widgets::register;
/// How long a read of the model list is reused (it is a few small files, but not for every frame).
const REFRESH_SECS: f64 = 1.5;
/// Cache limits offered (GB).
const CACHE_LIMITS: [u32; 6] = [5, 10, 20, 50, 100, 250];
/// What the interface keeps between frames.
#[derive(Default)]
pub struct Ui {
next_pump: f64,
generation: u64,
active: bool,
/// (tiles done, tiles in all) of the photo being made.
running: Option<(u64, u64)>,
queued: u64,
/// Bumped when a model is installed, removed or chosen, so Settings re-reads the list at once.
pub epoch: u64,
/// Models the user pressed Download for that have not been installed yet.
dl_watch: Vec<String>,
list: Option<(u64, f64, Value)>,
}
/// Called every frame: keeps the engine's denoise work going and asks for a redraw when a photo's picture appears (the
/// loupe and the thumbnails then render from it) or while one is being made (the progress).
pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) {
watch_downloads(app, ctx);
let now = ctx.input(|i| i.time);
let (working, moving) = ctx.input(|i| {
let keys = i.events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::MouseWheel { .. }));
(i.pointer.any_down() || keys, i.pointer.is_moving())
});
if working {
app.caches.last_input = now;
}
if moving || working {
app.caches.last_move = now;
}
if now < app.caches.denoise.next_pump {
return;
}
let watching = matches!(&app.ui.dialog, Some(crate::state::Dialog::Settings { tab }) if tab == "denoise");
let pace = window_pace(app, ctx, now, watching);
let Ok(v) = app.session.execute("denoise.pump", &json!({"pace": pace})) else { return };
let c = &mut app.caches.denoise;
let generation = v["generation"].as_u64().unwrap_or(0);
if generation != c.generation {
c.generation = generation;
ctx.request_repaint();
}
c.active = v["active"] == true;
c.queued = v["queued"].as_u64().unwrap_or(0);
c.running = v["running"].is_object().then(|| (v["running"]["done"].as_u64().unwrap_or(0), v["running"]["total"].as_u64().unwrap_or(0)));
let busy = c.running.is_some() || c.queued > 0;
c.next_pump = now
+ if busy {
0.1
} else if c.active {
0.5
} else {
1.0
};
// frames are drawn only when something asks for one: while a photo is being made, ask again for its progress
if busy {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
/// The engine's model list, re-read at most every [`REFRESH_SECS`], or at once after an action changed it.
fn models(app: &mut LightcraftApp, ctx: &egui::Context) -> Value {
let now = ctx.input(|i| i.time);
let epoch = app.caches.denoise.epoch;
if let Some((e, at, v)) = &app.caches.denoise.list
&& *e == epoch
&& now - at < REFRESH_SECS
{
return v.clone();
}
let v = app.run("denoise.models.list", json!({})).unwrap_or(Value::Null);
app.caches.denoise.list = Some((epoch, now, v.clone()));
v
}
/// Follows the downloads the user started: keeps redrawing while one runs (the progress bar), and says so when a
/// model has been installed and is in use (the engine does all of that by itself).
fn watch_downloads(app: &mut LightcraftApp, ctx: &egui::Context) {
if app.caches.denoise.dl_watch.is_empty() {
return;
}
let Ok(v) = app.session.execute("denoise.models.downloads", &json!({})) else {
app.caches.denoise.dl_watch.clear();
return;
};
let rows: Vec<Value> = v["downloads"].as_array().cloned().unwrap_or_default();
let (mut keep, mut installed) = (Vec::new(), Vec::new());
for id in std::mem::take(&mut app.caches.denoise.dl_watch) {
match rows.iter().find(|r| r["id"] == id.as_str()).and_then(|r| r["state"].as_str()) {
Some("running" | "done" | "installing") => keep.push(id),
Some("installed") => installed.push(id),
_ => {}
}
}
app.caches.denoise.dl_watch = keep;
for id in installed {
let _ = app.session.execute("denoise.models.downloadCancel", &json!({"id": id}));
app.caches.denoise.epoch += 1;
let text = tr("The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works");
app.ui.status = text.into();
app.toast(ctx, text);
}
ctx.request_repaint_after(std::time::Duration::from_millis(120));
}
/// Per-photo AI Denoise switch, including a request waiting for model installation.
pub fn detail_toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, on: bool, applicable: bool) {
let mut enabled = on || crate::model_setup::denoise_requested(app, id);
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 2 }).show(ui, |ui| {
let r = ui.add_enabled(applicable, egui::Checkbox::new(&mut enabled, crate::i18n::tr("AI Denoise")));
register(ui.ctx(), "denoise:enabled", r.rect);
if r.changed() {
let _ = app.run("denoise.toggle", json!({"id": id.0, "enabled": enabled}));
}
});
}
/// The line under the Denoise slider for the photo `id`, whose Amount is `amount`.
pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, amount: f64) {
let t = Tokens::get(ui.ctx());
let state = app.session.denoise_photo_state(id);
let pad = egui::Margin { left: 24, right: 22, top: 0, bottom: 6 };
let note = |ui: &mut egui::Ui, text: &str, color: egui::Color32| {
ui.add(egui::Label::new(RichText::new(crate::i18n::tr(text)).size(11.0).color(color)).wrap());
};
match state {
PhotoState::NotApplicable | PhotoState::Ready => {}
PhotoState::NoModel => {
egui::Frame::NONE.inner_margin(pad).show(ui, |ui| {
note(ui, "AI Denoise needs a model. Install one in Settings.", t.text_dim);
ui.add_space(2.0);
let r = ui.small_button(crate::i18n::tr("Set up AI Denoise…"));
register(ui.ctx(), "denoise:setup", r.rect);
if r.clicked() {
let _ = app.run("denoise.toggle", json!({"id": id.0, "enabled": true}));
}
});
}
PhotoState::Idle => {
if amount > 0.0 && app.session.denoise_auto() {
// the pump has not met this photo yet: let it come round soon instead of waiting out its pause
let now = ui.ctx().input(|i| i.time);
app.caches.denoise.next_pump = app.caches.denoise.next_pump.min(now + 0.05);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(60));
egui::Frame::NONE.inner_margin(pad).show(ui, |ui| note(ui, "Waiting to start…", t.text_dim));
} else if amount > 0.0 {
egui::Frame::NONE.inner_margin(pad).show(ui, |ui| {
note(ui, "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", t.text_dim);
ui.add_space(2.0);
let r = ui.small_button(crate::i18n::tr("Make it now"));
register(ui.ctx(), "denoise:make", r.rect);
if r.clicked() {
let _ = app.run("denoise.queue", json!({"ids": [id.0]}));
}
});
}
}
PhotoState::Queued { ahead } => {
egui::Frame::NONE.inner_margin(pad).show(ui, |ui| {
let text = if ahead == 0 {
tr("Next up for AI Denoise…").to_string()
} else {
tr_format!("In line for AI Denoise ({ahead} ahead)…", ahead = ahead)
};
note(ui, &text, t.text_dim);
});
}
PhotoState::Running { done, total } => {
egui::Frame::NONE.inner_margin(pad).show(ui, |ui| {
let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 };
let text = if total > 0 {
tr_format!("Making the denoised picture… {}%", (frac * 100.0).round() as u32)
} else {
tr("Starting…").to_string()
};
let bar = ui.add(egui::ProgressBar::new(frac).desired_width(ui.available_width().min(240.0)).text(RichText::new(text).size(11.0)));
register(ui.ctx(), "denoise:progress", bar.rect);
});
}
PhotoState::Failed { why, unsupported } => {
egui::Frame::NONE.inner_margin(pad).show(ui, |ui| {
let color = if unsupported { t.text_dim } else { t.caution };
note(ui, &sentence(&why), color);
if !unsupported {
let r = ui.small_button(crate::i18n::tr("Try again"));
register(ui.ctx(), "denoise:retry", r.rect);
if r.clicked() {
let _ = app.run("denoise.queue", json!({"ids": [id.0], "retry": true}));
}
}
});
}
}
}
/// The Settings ▸ Denoise tab.
pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let list = models(app, ui.ctx());
heading(ui, t, "AI Denoise");
crate::model_setup::notice(app, ui, "denoise");
if list["dir"].is_null() {
hint(ui, t, "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)");
return;
}
hint(
ui,
t,
"Cleans noise out of raw photos with an AI model, before the picture is built: the AI Denoise Amount slider under Detail sets how much of it you see. \
Your files are never changed and no extra files are made in your library: the cleaned picture is cached data that is made again when needed.",
);
let runtime = list["runtime"].as_bool() == Some(true);
if !runtime {
hint(ui, t, "This build cannot run denoise models.");
}
let all: Vec<Value> = list["models"].as_array().cloned().unwrap_or_default();
let downloads = app
.session
.execute("denoise.models.downloads", &json!({}))
.map(|v| v["downloads"].as_array().cloned().unwrap_or_default())
.unwrap_or_default();
heading(ui, t, "Models");
let r = ui.add_enabled(runtime && app.services.pick_denoise_model.is_some(), egui::Button::new(tr("Install from file…")));
register(ui.ctx(), "denoise:installFile", r.rect);
if r.clicked()
&& let Some(path) = app.services.pick_denoise_model.as_mut().and_then(|f| f().into_iter().next())
&& let Ok(info) = app.run("denoise.models.inspect", json!({"path": path}))
{
app.ui.dialog = Some(crate::state::Dialog::DenoiseModel { info, accepted: false });
}
if all.is_empty() {
hint(ui, t, "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.");
}
if let Some(job) = downloads.iter().find(|d| d["id"] == "local-install") {
match job["state"].as_str() {
Some("installing") => hint(ui, t, "Checking and installing the model…"),
Some("failed") => hint(ui, t, job["error"].as_str().unwrap_or("Model installation failed")),
_ => {}
}
}
for m in &all {
let dl = downloads.iter().find(|d| d["id"] == m["id"]);
model_row(app, ui, t, m, dl, runtime);
}
if all.iter().all(|m| m["selected"] != true) && all.iter().any(|m| m["installed"] == true) {
hint(ui, t, "AI Denoise is off: press Use on a model.");
}
hint(ui, t, "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.");
work_section(app, ui, t, &list);
}
/// What the cache holds, its limit, and what is being made.
fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value) {
let status = app.session.execute("denoise.status", &json!({})).unwrap_or(Value::Null);
if status["enabled"] != true {
return;
}
heading(ui, t, "Speed");
let current = list["runOn"].as_str().unwrap_or("auto").to_string();
let mut run_on = current.as_str();
let options = [("auto", "Automatic"), ("gpu", "Graphics card"), ("cpu", "Processor")];
if ui.horizontal(|ui| choices(ui, "denoiseRunOn", &options, &mut run_on)).inner {
let _ = app.run("denoise.settings", json!({"runOn": run_on}));
app.caches.denoise.epoch += 1;
}
hint(
ui,
t,
"Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.",
);
hint(ui, t, &device_line(&status["device"], run_on));
if run_on != "cpu" && status["device"]["retry"] == true {
let r = ui.small_button(crate::i18n::tr("Try the graphics card again"));
register(ui.ctx(), "denoise:retryGpu", r.rect);
if r.clicked() {
// choosing where it runs, even the same again, forgets the failed set-up
let _ = app.run("denoise.settings", json!({"runOn": run_on}));
app.caches.denoise.epoch += 1;
}
}
heading(ui, t, "Photos");
let mut auto = list["auto"].as_bool().unwrap_or(true);
if check(ui, "settings.denoiseAuto", &mut auto, "Make the denoised picture of the photos I am looking at") {
let _ = app.run("denoise.settings", json!({"auto": auto}));
app.caches.denoise.epoch += 1;
}
hint(ui, t, "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.");
let (running, queued) = (app.caches.denoise.running, app.caches.denoise.queued);
match running {
Some((done, total)) => {
let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 };
let name = status["running"]["photo"]
.as_u64()
.and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone()))
.unwrap_or_default();
let text = tr_format!(
"Denoising {name}: {done} of {total} tiles{}",
if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() },
name = name,
done = done,
total = total
);
let bar = ui.add(egui::ProgressBar::new(frac).desired_width(380.0).text(RichText::new(text).size(11.5)));
register(ui.ctx(), "denoise:settingsProgress", bar.rect);
}
None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")),
None => {
let ready = status["ready"].as_u64().unwrap_or(0);
hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture."))
}
}
ui.horizontal(|ui| {
let r = ui.button(tr("Denoise all photos with an Amount"));
register(ui.ctx(), "denoise:queueAll", r.rect);
if r.clicked() {
let _ = app.run("denoise.queue", json!({"scope": "withAmount"}));
}
if running.is_some() || queued > 0 {
let r = ui.button(tr("Stop"));
register(ui.ctx(), "denoise:stop", r.rect);
if r.clicked() {
let _ = app.run("denoise.cancel", json!({}));
}
}
});
heading(ui, t, "Cache");
let (files, bytes) = (status["cache"]["files"].as_u64().unwrap_or(0), status["cache"]["bytes"].as_u64().unwrap_or(0));
hint(
ui,
t,
&tr_format!(
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.",
count(files, "cached picture", "cached pictures"),
mb(Some(bytes))
),
);
ui.horizontal(|ui| {
ui.label(RichText::new(crate::i18n::tr("Limit")).color(t.text_label));
let current = list["cacheGb"].as_u64().unwrap_or(20) as u32;
let mut choice = current;
egui::ComboBox::from_id_salt("denoise-cache-limit").selected_text(tr_format!("{size} GB", size = current)).show_ui(ui, |ui| {
for gb in CACHE_LIMITS {
ui.selectable_value(&mut choice, gb, tr_format!("{size} GB", size = gb));
}
});
if choice != current {
let _ = app.run("denoise.settings", json!({"cacheGb": choice}));
app.caches.denoise.epoch += 1;
}
let r = ui.button(tr("Clear cache"));
register(ui.ctx(), "denoise:clear", r.rect);
if r.clicked() {
let _ = app.run("denoise.clear", json!({}));
}
});
}
/// Where the work runs, in a sentence (`denoise.status` → `device`), for the setting `run_on`. No timings: they are this
/// computer's, and only the comparison matters.
fn device_line(d: &Value, run_on: &str) -> String {
match d["kind"].as_str() {
_ if run_on == "cpu" => tr("The processor does the work.").into(),
Some("gpu") => tr_format!("Running on {}.", d["adapter"].as_str().unwrap_or(tr("the graphics card"))),
Some("cpu") => {
tr_format!("Running on the processor: {}.", tr(d["reason"].as_str().unwrap_or("the graphics card is not used")).trim_end_matches('.'))
}
_ => tr("The graphics card is set up when the next photo is made.").into(),
}
}
fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, dl: Option<&Value>, can_run: bool) {
let id = m["id"].as_str().unwrap_or("").to_string();
let (installed, selected) = (m["installed"].as_bool() == Some(true), m["selected"].as_bool() == Some(true));
let dl_state = if installed { None } else { dl.and_then(|d| d["state"].as_str()) };
let host = if can_run { m["downloadHost"].as_str().unwrap_or("").to_string() } else { String::new() };
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(0, 3)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(m["name"].as_str().unwrap_or("")).color(t.text));
let size = mb(m["sizeBytes"].as_u64());
if !size.is_empty() {
ui.label(RichText::new(size).color(t.text_dim));
}
if selected {
ui.label(RichText::new(tr("in use")).color(t.accent));
} else if installed {
ui.label(RichText::new(tr("installed")).color(t.text_dim));
}
});
ui.label(RichText::new(licence_line(m)).font(t.font(11.5)).color(if m["licence"]["commercial"] == "yes" {
t.text_dim
} else {
t.caution
}));
match m["accepted"]["selfTest"].as_object() {
Some(test) if test.get("ok").and_then(Value::as_bool) == Some(true) => {
ui.label(RichText::new(tr("Tested and works on this computer")).font(t.font(11.5)).color(t.text_dim));
}
Some(_) => {
ui.label(RichText::new(tr("Failed its last test")).font(t.font(11.5)).color(t.caution));
}
None => {}
}
match (dl_state, dl) {
(Some("running"), Some(d)) => {
let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0));
let frac = if total > 0 { (got as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 };
let text = if total > 0 && got >= total {
tr("Checking…").to_string()
} else {
tr_format!("{} of {} from {host}", mb(Some(got)), mb(Some(total)), host = host)
};
let bar = ui.add(egui::ProgressBar::new(frac).desired_width(260.0).text(RichText::new(text).font(t.font(11.5))));
register(ui.ctx(), format!("denoise:progress:{id}"), bar.rect);
}
(Some("done" | "installing"), _) => {
ui.label(RichText::new(tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim));
}
(Some("failed"), Some(d)) => {
let why = sentence(d["error"].as_str().unwrap_or("The download failed"));
ui.add(egui::Label::new(RichText::new(why).font(t.font(11.5)).color(t.caution)).wrap());
}
_ => {}
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if installed {
let r = ui.button(tr("Remove"));
register(ui.ctx(), format!("denoise:remove:{id}"), r.rect);
if r.clicked() {
let _ = app.run("denoise.models.remove", json!({"id": id}));
app.caches.denoise.epoch += 1;
}
if selected {
let r = ui.button(tr("Turn off"));
register(ui.ctx(), format!("denoise:off:{id}"), r.rect);
if r.clicked() {
let _ = app.run("denoise.models.select", json!({"id": null}));
app.caches.denoise.epoch += 1;
}
} else {
let r = ui.button(tr("Use"));
register(ui.ctx(), format!("denoise:use:{id}"), r.rect);
if r.clicked() {
let _ = app.run("denoise.models.select", json!({"id": id}));
app.caches.denoise.epoch += 1;
}
}
} else {
match dl_state {
Some("running") => {
let r = ui.button(tr("Cancel"));
register(ui.ctx(), format!("denoise:cancelDownload:{id}"), r.rect);
if r.clicked() {
let _ = app.run("denoise.models.downloadCancel", json!({"id": id}));
app.caches.denoise.dl_watch.retain(|w| w != &id);
}
}
// the engine installs it within a moment; nothing to press
Some("done" | "installing") => {}
_ => {
if let Some(url) = m["source"].as_str() {
let r = ui
.button(tr("Open page"))
.on_hover_text(tr("Opens the model's own page in your browser, to read about it or get the file yourself."));
register(ui.ctx(), format!("denoise:get:{id}"), r.rect);
if r.clicked() {
open_page(app, url);
}
}
if !host.is_empty() {
let label = if dl_state == Some("failed") { "Try again" } else { "Download" };
let tip = tr_format!(
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.",
mb(m["sizeBytes"].as_u64()),
host = host
);
let r = ui.button(tr(label)).on_hover_text(tip);
register(ui.ctx(), format!("denoise:download:{id}"), r.rect);
if r.clicked() {
open_download_dialog(app, m);
}
}
}
}
}
});
});
});
}
/// Pressing Download: the model's terms first, in the licence dialog. Accepting them starts the download;
/// nothing is fetched before.
fn open_download_dialog(app: &mut LightcraftApp, m: &Value) {
let info = json!({
"kind": "known",
"domain": "denoise",
"fileName": m["id"],
"sizeBytes": m["sizeBytes"],
"model": m,
"alreadyInstalled": false,
"download": m["id"],
"host": m["downloadHost"],
"assumptions": [],
"reason": null,
});
app.ui.dialog = Some(crate::state::Dialog::DenoiseModel { info, accepted: false });
}
/// The dialog's OK for a denoise model: start the download and watch it.
pub fn install(app: &mut LightcraftApp, info: &Value, accepted: bool) -> Result<Value, String> {
if !accepted {
return Err(tr("Tick the box to accept the model's terms first").into());
}
app.caches.denoise.epoch += 1;
let (id, r) = if let Some(id) = info["download"].as_str() {
(id.to_string(), app.run("denoise.models.download", json!({"id": id, "acknowledged": true})))
} else {
let path = info["path"].as_str().ok_or_else(|| tr("There is no model file to install").to_string())?;
("local-install".into(), app.run("denoise.models.install", json!({"path": path, "acknowledged": true, "background": true})))
};
if r.is_ok() {
app.caches.denoise.dl_watch.push(id);
app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "denoise".into() });
}
r
}
/// "1 photo" / "3 photos": the count with the right noun.
fn count(n: u64, one: &str, many: &str) -> String {
tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many }))
}
pub(super) fn window_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64, watching: bool) -> &'static str {
let (focused, minimized) = ctx.input(|i| (i.focused, i.raw.viewports.get(&i.raw.viewport_id).and_then(|v| v.minimized).unwrap_or(false)));
pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching)
}
fn pace_for(focused: bool, minimized: bool, worked: f64, moved: f64, watching: bool) -> &'static str {
if minimized {
"light"
} else if !focused {
// still on screen, but the user is working in another app: no input here to get in the way of
"normal"
} else if worked < 0.4 {
"pause"
} else if moved < 3.0 {
"light"
} else if watching {
"full"
} else {
"normal"
}
}
pub(super) fn mb(bytes: Option<u64>) -> String {
match bytes {
Some(b) if b >= 10_000_000 => tr_format!("{} MB", (b as f64 / 1e6).round() as u64),
Some(b) if b >= 1_000_000 => tr_format!("{:.1} MB", b as f64 / 1e6),
Some(b) => tr_format!("{} KB", (b as f64 / 1e3).round().max(1.0) as u64),
None => String::new(),
}
}
pub(super) fn sentence(s: &str) -> String {
let s = s.trim();
let mut c = s.chars();
let mut out: String = c.next().map(|f| f.to_uppercase().collect()).unwrap_or_default();
out.push_str(c.as_str());
if !out.is_empty() && !out.ends_with(['.', '!', '?']) {
out.push('.');
}
out
}
pub(super) fn licence_line(m: &Value) -> String {
let name = m["licence"]["name"].as_str().filter(|s| !s.is_empty()).unwrap_or("Unknown licence");
let terms = match m["licence"]["commercial"].as_str() {
Some("yes") if name.starts_with("GPL") => "commercial use subject to GPL conditions",
Some("yes") => "commercial use allowed",
Some("no") => "non-commercial use only",
_ => "terms unclear",
};
tr_format!("{name} · {terms}", name = tr(name), terms = tr(terms))
}
pub(super) fn open_page(app: &mut LightcraftApp, url: &str) {
if let Some(f) = app.services.open_url.as_mut() {
let _ = f(url);
}
}
pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info: &Value, accepted: &mut bool) {
let kind = info["kind"].as_str().unwrap_or("unsupported");
let file = info["fileName"].as_str().unwrap_or("");
if kind == "unsupported" {
ui.label(RichText::new(tr("This file cannot be used yet")).font(t.semibold(13.5)).color(t.caution));
ui.add(
egui::Label::new(
RichText::new(sentence(tr(info["reason"].as_str().unwrap_or("It is not a denoise model LightCraft understands"))))
.color(t.text_label),
)
.wrap(),
);
ui.label(RichText::new(tr_format!("{file} · {size}", file = file, size = mb(info["sizeBytes"].as_u64()))).color(t.text_dim));
return;
}
let m = &info["model"];
ui.horizontal(|ui| {
ui.label(RichText::new(m["name"].as_str().unwrap_or(file)).font(t.semibold(14.0)).color(t.text));
ui.label(RichText::new(mb(info["sizeBytes"].as_u64())).color(t.text_dim));
});
if info["alreadyInstalled"] == true {
ui.label(RichText::new(tr("Already installed. Remove it before installing a replacement.")).color(t.text_dim));
}
let download = info["download"].is_string();
if download {
let host = info["host"].as_str().unwrap_or("its own repository");
let after = if info["domain"] == "denoise" { "starts using it for AI Denoise" } else { "makes AI Denoise available" };
ui.add(
egui::Label::new(
RichText::new(tr_format!(
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.",
host = host,
after = tr(after)
))
.color(t.text_label),
)
.wrap(),
);
}
let commercial = m["licence"]["commercial"].as_str().unwrap_or("unknown");
ui.label(RichText::new(licence_line(m)).font(t.semibold(12.5)).color(if commercial == "yes" { t.text } else { t.caution }));
let notice = m["licence"]["notice"].as_str().unwrap_or("");
if !notice.is_empty() {
ui.add(egui::Label::new(RichText::new(notice).color(t.text_label)).wrap());
}
let provenance = m["provenance"].as_str().unwrap_or("");
if !provenance.is_empty() {
ui.add(egui::Label::new(RichText::new(tr_format!("Trained on: {provenance}", provenance = provenance)).color(t.text_dim)).wrap());
}
if kind == "draft" {
ui.add_space(2.0);
ui.label(RichText::new(tr("LightCraft does not know this model, so it assumed:")).color(t.text_label));
for a in info["assumptions"].as_array().into_iter().flatten().filter_map(Value::as_str) {
ui.add(egui::Label::new(RichText::new(tr_format!("• {assumption}", assumption = tr(a))).font(t.font(12.0)).color(t.text_dim)).wrap());
}
}
if let Some(url) = m["source"].as_str().or(m["licence"]["url"].as_str()) {
let r = ui.link(tr("Open the model's page"));
register(ui.ctx(), "denoiseModel:page", r.rect);
if r.clicked() {
open_page(app, url);
}
}
ui.add_space(4.0);
check(ui, "denoiseModel.accept", accepted, "I have read these terms and accept them for my own use");
let closing = if download {
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it."
} else {
"The model is kept on this computer only. LightCraft never uploads or shares it."
};
ui.label(RichText::new(tr(closing)).font(t.font(11.5)).color(t.text_dim));
}

View File

@ -86,6 +86,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::NewSmartAlbum { .. } => "Create Smart Album",
Dialog::AllMetadata { .. } => "All Metadata",
Dialog::SystemInfo { .. } => "System Info",
Dialog::DenoiseModel { .. } => "AI Denoise Model",
Dialog::FaceModel { info, .. } if info["download"].is_string() => "Download Face Model",
Dialog::FaceModel { .. } => "Add Face Model",
Dialog::WhatsNew => "What's New",
@ -178,6 +179,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
});
}
Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, &t, info, accepted),
Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, &t, info, accepted),
Dialog::SystemInfo { rows } => {
egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| {
@ -790,7 +792,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.add_space(4.0);
ui.horizontal(|ui| {
// a model file LightCraft cannot use has nothing to confirm
let unusable_model = matches!(&dlg, Dialog::FaceModel { info, .. } if info["kind"] == "unsupported");
let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported");
let informational = unusable_model || matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. });
let sam = &app.session.segmenter;
let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some());
@ -817,6 +819,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
add_label.as_str()
}
Dialog::Merge { .. } => "Merge",
Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download",
Dialog::DenoiseModel { .. } => "Install",
Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download",
Dialog::FaceModel { .. } if !informational => "Install",
Dialog::ConfirmDelete { .. } => "Delete",
@ -829,7 +833,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
_ => "OK",
};
// installing waits for the licence to be accepted
let can_confirm = informational || !matches!(&dlg, Dialog::FaceModel { accepted: false, .. });
let can_confirm = informational || !matches!(&dlg, Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. });
let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok))));
if let Some(r) = &r {
crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect);
@ -1060,6 +1064,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})),
Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})),
Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})),
Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted),
Dialog::FaceModel { path, info, accepted } => crate::panels::faces::install(app, path, info, *accepted),
Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null),
Dialog::Cull { reject_below, pick_best } => {

View File

@ -272,6 +272,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.sharpenMasking"] {
control(app, ui, d, c, c == "detail.sharpenAmount" || d.detail.sharpen_amount > 0.0);
}
// AI Denoise is its own tool, apart from the manual noise reduction sliders below it
sub_title(ui, crate::i18n::tr("AI Denoise"));
// AI Denoise: how much of the photo's cleaned picture to mix in (raw files only), and what that picture is doing
let applicable = !matches!(app.session.denoise_photo_state(id), lightcraft_engine::denoise::PhotoState::NotApplicable);
let on = d.enhance.denoise_enabled();
super::denoise::detail_toggle(app, ui, id, on, applicable);
control(app, ui, d, "enhance.denoise", applicable && on);
super::denoise::detail_status(app, ui, id, if on { d.enhance.denoise } else { 0.0 });
sub_title(ui, crate::i18n::tr("Noise Reduction"));
for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast", "detail.nrColor", "detail.nrColorDetail", "detail.nrColorSmoothness"]
{

View File

@ -4,6 +4,7 @@ pub mod bottombar;
pub mod chips;
pub mod compare;
pub mod crop_overlay;
pub mod denoise;
pub mod detail;
pub mod dialogs;
pub mod edit;

View File

@ -1,4 +1,4 @@
//! The Settings dialog (⌘,): General, Import, Performance, Interface, Faces.
//! The Settings dialog (⌘,): General, Import, Performance, Interface, Faces, AI Denoise.
//!
//! Changes apply immediately (no OK/Cancel). Where they are stored:
//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU,
@ -17,8 +17,14 @@ use crate::theme::Tokens;
use crate::widgets::register;
/// (id, label) of the tabs, in order.
pub const TABS: &[(&str, &str)] =
&[("general", "General"), ("import", "Import"), ("performance", "Performance"), ("interface", "Interface"), ("faces", "Faces")];
pub const TABS: &[(&str, &str)] = &[
("general", "General"),
("import", "Import"),
("performance", "Performance"),
("interface", "Interface"),
("faces", "Faces"),
("denoise", "AI Denoise"),
];
/// Thumbnail cache sizes offered (MB).
const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192];
@ -45,6 +51,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) {
"performance" => performance_tab(app, ui, &t),
"interface" => interface_tab(app, ui, &t),
"faces" => super::faces::settings_tab(app, ui, &t),
"denoise" => super::denoise::settings_tab(app, ui, &t),
_ => general_tab(app, ui, &t),
}
}
@ -77,7 +84,7 @@ pub(super) fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str)
}
/// Mutually exclusive buttons (`button:{id}-{index}`).
fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool {
pub(super) fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool {
let mut changed = false;
ui.spacing_mut().item_spacing.x = 4.0;
for (i, (v, l)) in options.iter().enumerate() {

View File

@ -581,6 +581,10 @@ pub enum Dialog {
pick_best: bool,
},
/// Help ▸ System Info: (label, value) rows.
DenoiseModel {
info: serde_json::Value,
accepted: bool,
},
/// A face model file the user chose: what it is, its licence terms, and the "I accept" box.
FaceModel {
path: String,

View File

@ -0,0 +1,202 @@
//! Synthetic setup flows: model installation must resume the requested action on its original targets.
use std::path::{Path, PathBuf};
use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source};
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
use lightcraft_engine::Session;
use serde_json::json;
use crate::{LightcraftApp, Services, model_setup, state::Dialog};
fn setup(name: &str) -> (LightcraftApp, PathBuf, PhotoId) {
let dir = std::env::temp_dir().join(format!("lc-model-flow-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::new().with_fs();
s.open_library(dir.join("library"), true).unwrap();
s.set_denoise_models_dir(Some(dir.join("denoise")));
s.execute("denoise.settings", &json!({"runOn": "cpu"})).unwrap();
let id = PhotoId(s.catalog.photos().map(|p| p.id.0).max().unwrap() + 1);
let mut raw = Photo::new(
id,
Source::File { path: dir.join("synthetic.dng").to_string_lossy().into_owned() },
"synthetic.dng",
"DNG",
128,
128,
"2026-10-07",
);
raw.kind = MediaKind::Raw;
s.commit("fixture", Op::AddPhoto { photo: Box::new(raw) }).unwrap();
(LightcraftApp::new(s, Services::default()), dir, id)
}
fn denoise_file(dir: &Path) -> PathBuf {
let source = dir.join("source");
std::fs::create_dir_all(&source).unwrap();
let bytes = lightcraft_denoise::synthetic::smoothing_onnx();
std::fs::write(source.join("model.onnx"), &bytes).unwrap();
let m = DenoiserManifest {
id: "test-smoothing".into(),
name: "Synthetic smoothing".into(),
version: "1".into(),
licence: Default::default(),
source: None,
sha256: None,
size_bytes: Some(bytes.len() as u64),
provenance: "Synthetic tests".into(),
domain: Domain::BayerToRgb,
tile: 64,
overlap: 16,
gain: Gain::None,
};
std::fs::write(source.join("denoise-model.json"), serde_json::to_vec(&m).unwrap()).unwrap();
source.join("model.onnx")
}
fn install_denoise(app: &mut LightcraftApp, dir: &Path) {
app.run("denoise.models.install", json!({"path": denoise_file(dir), "acknowledged": true})).unwrap();
}
#[test]
fn accepting_a_local_model_installs_in_background_and_resumes_the_pending_action() {
let (mut app, dir, raw) = setup("local-install");
let path = denoise_file(&dir);
app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
let info = app.run("denoise.models.inspect", json!({"path": path})).unwrap();
assert!(crate::panels::denoise::install(&mut app, &info, false).is_err());
assert!(!dir.join("denoise/test-smoothing/model.onnx").exists());
let result = crate::panels::denoise::install(&mut app, &info, true).unwrap();
assert_eq!(result["started"], "local-install");
assert_eq!(app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() }));
let ctx = egui::Context::default();
for _ in 0..600 {
crate::panels::denoise::pump(&mut app, &ctx);
if app.session.denoise_photo_state(raw) != lightcraft_engine::denoise::PhotoState::NoModel {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
resume(&mut app);
assert_eq!(amount(&app, raw), 50.0);
assert!(dir.join("denoise/test-smoothing/model.onnx").is_file());
assert!(path.is_file(), "a user-supplied original is preserved");
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
fn amount(app: &LightcraftApp, id: PhotoId) -> f64 {
app.session.develop_of(id).unwrap().enhance.denoise
}
fn resume(app: &mut LightcraftApp) {
model_setup::pump(app, &egui::Context::default());
}
#[test]
fn denoise_installation_enables_only_the_requesting_photo_once_and_is_undoable() {
let (mut app, dir, raw) = setup("denoise");
let other = app.session.active().unwrap();
app.session.auto_sync = true;
let r = app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
assert_eq!(r["setupRequired"], true);
assert_eq!(app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() }));
assert_eq!(amount(&app, raw), 0.0, "setup alone never changes the photo");
app.run("library.select", json!({"ids": [other.0]})).unwrap();
let undo = app.session.undo.len();
install_denoise(&mut app, &dir);
resume(&mut app);
assert_eq!(amount(&app, raw), 50.0);
assert_eq!(amount(&app, other), 0.0);
assert_eq!(app.session.active(), Some(other));
assert_eq!(app.session.undo.len(), undo + 1);
resume(&mut app);
assert_eq!(app.session.undo.len(), undo + 1, "a later frame cannot apply it again");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(amount(&app, raw), 0.0);
app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
app.run("develop.set", json!({"ids": [raw.0], "control": "enhance.denoise", "value": 75})).unwrap();
app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
assert_eq!(amount(&app, raw), 75.0, "enabling an already chosen amount keeps it");
app.run("denoise.toggle", json!({"id": raw.0, "enabled": false})).unwrap();
assert_eq!(amount(&app, raw), 75.0, "switching off keeps the Amount");
assert_eq!(app.session.develop_of(raw).unwrap().denoise_amount(), 0.0, "but mixes nothing in");
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn cancelled_denoise_requests_do_not_apply_after_installation() {
for (name, command, params) in [
("cancel-action", "modelSetup.cancel", json!({"kind": "denoise"})),
("cancel-toggle", "denoise.toggle", json!({"enabled": false})),
("cancel-download", "denoise.models.downloadCancel", json!({"id": "rawnind-bayer"})),
] {
let (mut app, dir, raw) = setup(name);
app.run("library.select", json!({"ids": [raw.0]})).unwrap();
app.run("denoise.toggle", json!({"enabled": true})).unwrap();
app.run(command, params).unwrap();
install_denoise(&mut app, &dir);
resume(&mut app);
assert_eq!(amount(&app, raw), 0.0, "{name}");
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn pending_actions_do_not_cross_libraries_or_relinked_sources() {
for changed in ["library", "source"] {
let (mut app, dir, raw) = setup(changed);
app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
if changed == "library" {
app.session.open_library(dir.join("other-library"), true).unwrap();
} else {
app.session
.commit(
"relink",
Op::Relink {
id: raw,
file_name: "another.dng".into(),
source: Source::File { path: dir.join("another.dng").to_string_lossy().into_owned() },
format: Some("DNG".into()),
},
)
.unwrap();
}
install_denoise(&mut app, &dir);
resume(&mut app);
assert!(!model_setup::denoise_requested(&app, raw));
assert!(app.session.catalog.photos().all(|p| p.develop.enhance.denoise == 0.0));
assert!(app.ui.status.contains("cancelled"));
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn hostile_or_inapplicable_toggle_arguments_are_errors_without_changes() {
let (mut app, dir, raw) = setup("hostile");
let jpeg = app.session.active().unwrap();
for params in [json!({"id": "wrong"}), json!({"id": u64::MAX}), json!({"id": raw.0, "enabled": 1}), json!({"id": jpeg.0, "enabled": true})] {
assert!(app.run("denoise.toggle", params).is_err());
assert_eq!(amount(&app, raw), 0.0);
}
assert!(app.run("modelSetup.cancel", json!({"kind": "nonsense"})).is_err());
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn model_installation_waits_for_an_active_slider_gesture_to_finish() {
let (mut app, dir, raw) = setup("interaction");
app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
app.session.begin_interaction("Exposure").unwrap();
install_denoise(&mut app, &dir);
resume(&mut app);
assert_eq!(amount(&app, raw), 0.0);
assert!(app.session.interaction.is_some());
app.session.end_interaction().unwrap();
resume(&mut app);
assert_eq!(amount(&app, raw), 50.0);
drop(app);
let _ = std::fs::remove_dir_all(dir);
}

131
docs/denoise.md Normal file
View File

@ -0,0 +1,131 @@
# AI Denoise
Edit ▸ Detail ▸ **AI Denoise** is a per-photo switch above **Amount** (0–100).
Turning it on starts at 50, or preserves an already positive Amount. Turning it off
preserves Amount and mixes none of the cleaned picture. It affects only the requesting
photo, independently of Auto Sync, and is undoable. Manual luminance and colour noise
reduction remain separate controls.
If no model is selected, turning it on opens **Settings ▸ AI Denoise** without changing
the photo. After installation, the pending request enables the original photo even if
the selection changed. Cancel the pending action in Settings. Cancelled downloads,
deleted or relinked photos, switching libraries and quitting cancel the request.
An active slider gesture finishes before the pending action resumes.
## Models and terms
**No model weights are bundled.** Models live in the per-user `denoise-models` folder
(override with `LIGHTCRAFT_DENOISE_MODELS`). Settings provides **Install from file…**
for a user-supplied ONNX file with a `denoise-model.json` beside it. The default build
offers no model downloads. The separate `rawnind-model` Cargo feature enables the
pinned RawNIND offer only when a release's model policy permits it.
Terms must be acknowledged before installation or
download. Downloads use the shared pure-Rust `lightcraft-fetch` client, with resume,
progress, cancellation, size limits and SHA-256 verification.
RawNIND's optional ONNX weights are **GPL-3.0**, as recorded in the pinned model catalog.
They are downloaded separately after consent and read by our own Rust executor.
Whether releases should offer this download remains a maintainer policy decision;
permissively licensed or independently trained weights remain a gap. This is not a
claim that optional downloading settles licensing questions. No GPL decoder or neural
runtime code is included. Model metadata is in `crates/denoise-core/src/known.rs`.
Installation checks the manifest, model hash and size, then runs a synthetic self-test:
finite output of the promised shape, repeatability, the declared scale and reduced
noise. Unsupported operators, malformed files and a failed self-test return errors.
The UI performs copying, extraction, hashing, parsing and CPU/GPU self-tests on one
background installation worker. `denoise.models.downloads` and `denoise.pump` report
`installing`, `installed` or `failed`; an existing model is preserved on a failed replacement.
Command clients can request the same asynchronous path with `background: true`.
The synchronous command remains available for scripts that require the result immediately.
The current contract is `bayerToRgb`: normalized, black-subtracted, non-white-balanced
RGGB planes `[1, 4, tile, tile]` produce camera RGB `[1, 3, 2*tile, 2*tile]`.
RawNIND uses a 512-cell tile, 64-cell overlap and a mean-matched nominal gain of 1e6.
See `DenoiserManifest` for the validated JSON schema.
## Photos, previews and exports
Original RAW files stay untouched. A cleaned picture is cached under
`<library>/denoise/<key>.lcdn`, keyed by source content, model and algorithm version.
The compressed half-float cache has checked headers, bounded windows and checksums.
It can be cleared or recreated; it is not a new catalog photo or a duplicate DNG.
Plain and cleaned camera RGB go through the same highlight reconstruction, white
balance, colour matrix, DNG profile tables, camera hue/saturation look, crop and
orientation. Amount blends the two before the remaining develop pipeline, so edits
and exports use the same data. At zero Amount or with Detail disabled, the ordinary
render path remains in use.
The queue prioritizes the open and selected photos, pauses after input, and adjusts
its worker count while the user works. Progress, cancellation and retry are available
in Settings and through commands. The default cache limit is 20 GB; unused products
are evicted oldest first. A failed file is not continuously retried.
An export with a nonzero effective Amount creates a missing product synchronously.
It returns a clear error if it cannot produce the denoised result; it never silently
exports the plain image instead. Unsupported sensor layouts retain manual noise
reduction and report their limitation.
## Pure-Rust CPU and GPU execution
The CPU executor reads a bounded subset of ONNX into checked network data. Convolutions
use bounded im2col blocks and `faer-core 0.17.1` with its pure-Rust GEMM backend,
without BLAS, C dependencies or an inner thread pool. That backend includes
architecture-specific assembly in its dependencies. Tiles share the outer worker
pool. A scalar implementation checks the optimized operators on synthetic networks.
Tract is not a workspace dependency or a product feature.
CPU inference and the GPU model module are feature-gated; ordinary engine/web builds
do not compile the faer/GEMM backend. The engine depends on the inference crate only through `denoise = ["dep:lightcraft-denoise", …]`;
lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 100 megapixels; at most
four tile calls run together, chosen within the process-wide working-memory budget.
One idle CPU workspace is retained, and cache costs reserve room for the blended picture.
The GPU executor uses our WGSL kernels through wgpu. It checks a real model's output
against the CPU and falls back on errors, unsupported networks or a slower device.
Setup has a time limit and a crash marker. Automatic selection estimates throughput
from a check tile; this estimate can choose imperfectly on unmeasured hardware.
Settings offers Automatic, Graphics card and Processor. GPU tests cover supported
synthetic networks against the scalar executor; a real-model test compares CPU/GPU.
### Historical performance, measured 2026-10-07
These are the contributor's measurements **before the current upstream integration**,
on a Ryzen 9 7945HX and RTX 4090 Laptop GPU (DX12), release build, model SHA-256
`da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901`.
The 24.34 MP Sony a7 III photo is 35 tiles. Numbers exclude model loading, source
decode, GPU setup, cache encoding and export, but include packing and blending.
| Work | Pure-Rust CPU | Prior Tract reference | GPU |
|---|---:|---:|---:|
| One warm 512-cell tile, one caller | 1148 ms | 1463 ms | 15.1 ms |
| Whole photo, 16 CPU / 4 GPU feeding workers | 5.185 s | 9.534 s | 0.582 s |
The largest relative differences were 1.68e-6 CPU/reference and 1.57e-6 CPU/GPU for
the check tile, below the required 1e-3 tolerance. Reference comparisons were made
on the older contributor branch; that reference runtime is intentionally excluded
from the current workspace. These numbers are not a fresh benchmark of this rebase.
Fresh checks can be run using an already installed model and CC0 raw samples:
```text
LC_DENOISE_MODEL=<model.onnx> cargo test --release -p lightcraft-gpu --lib real_model_on_the_gpu_matches_cpu -- --ignored --nocapture
LC_DENOISE_MODEL=<model.onnx> LC_DENOISE_RAW=<corpus/raw> cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_cpu -- --ignored --nocapture
LC_DENOISE_MODEL=<model.onnx> LC_DENOISE_RAW=<corpus/raw> cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_real -- --ignored --nocapture
```
## Commands and limits
UI, CLI, control channel and MCP dispatch `denoise.toggle`, `denoise.settings`,
`denoise.queue`, `.cancel`, `.status`, `.pump`, `.clear`, and `denoise.models.*`
(`list`, `install`, `download`, `downloads`, `downloadCancel`, `test`, `remove`,
`select`). The develop control is `enhance.denoise`.
Native apps enable inference through their `denoise` feature. A build without it and
the web build report inference unavailable. Manifest/cache primitives still compile
for WASM. Settings and models remain opt-in; nothing is downloaded on launch.
This implementation supports **Bayer RAW only**. X-Trans, Foveon, demosaiced DNG and
non-RAW images remain unsupported by this model contract. No measured quality parity
with Lightroom is claimed. Apple/Metal and wider hardware verification, model policy,
a linear-RGB contract and a side-by-side fidelity suite remain future work.

View File

@ -26,7 +26,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) |
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) |
| F. Edit panel — global adjustments (EDIT) | 42 | 2 | 4 | 1 | 28/28 (100%) | 13/14 (93%) |
| G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) |
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
| I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
| **Total** | 394 | 37 | 85 | 36 | 193/200 (97%) | 142/152 (93%) |
| **Total** | 394 | 38 | 84 | 36 | 193/200 (97%) | 142/152 (93%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.9%** of 516 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 42.6% of 163.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **80.0%** of 516 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 42.9% of 163.
<!-- /parity:summary -->
## Top gaps
@ -267,7 +267,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview |
| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | |
| LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | |
| LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | ⬜ | | settings field reserved, not rendered |
| LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; X-Trans/linear RGB, model policy and Lightroom fidelity remain |
| LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | | |
| LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | |
| LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | |

View File

@ -15,6 +15,10 @@
- The catalog format is now version 3 (`Album.order`, `Op::SetAlbumOrder`). Once this version has opened a library,
older versions refuse it, as with every format change.
### AI RAW denoise
- Detail has a per-photo AI Denoise switch and Amount. Bayer RAW photos use a disposable cache, with matching previews and exports and pure-Rust CPU/GPU inference.
- Models are installed separately after accepting their terms; no weights are bundled. See [setup and limitations](denoise.md).
### Library keyboard culling
- Colour labels tint thumbnail surrounds in Square Grid and the Detail filmstrip, and a translucent footer along the bottom of the photo in Photo Grid. Label confirmations use a matching pale colour.
- Setting or clearing a colour label shows a brief bottom confirmation, like rating a photo; custom label names appear in the message too.

View File

@ -39,6 +39,8 @@ pub const TABLE: &[(&str, Class)] = &[
("tiff", Class::Layer(0)),
("sysmem", Class::Layer(0)),
("fetch", Class::Layer(0)),
("denoise-core", Class::Layer(1)),
("denoise", Class::Layer(1)),
("heif", Class::Layer(0)),
("raw", Class::Layer(1)),
("codecs", Class::Layer(1)),
@ -70,6 +72,7 @@ pub const TABLE: &[(&str, Class)] = &[
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
&["geom", "color", "raster"],
&["tiff", "raster"],
&["denoise-core", "denoise"],
&["meta", "raw"],
&["meta", "codecs"],
&["meta", "develop"],