Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ffd811d679 feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399)
Opening a JPEG now estimates the quality it was saved at from its quantisation
tables (the Annex K tables of ITU-T T.81 scaled by the IJG quality curve; exact
for such tables, the closest fit otherwise). Opening a WebP records whether it
is lossless (VP8L) or lossy (VP8), and a lossy one's quality from its base
quantiser index. The settings live on the document (`Document::file_encoding`,
saved in .pcraft) and a save that asks for no quality writes the file back the
same way, as Photoshop's Save does: File › Save, Save As and Save a Copy start
there instead of the fixed codec defaults (quality 90, WebP always lossless).
An explicit quality still wins; new documents and other formats keep the
defaults. Hostile or truncated DQT/VP8 headers give no estimate, never a panic.

Refs #2399

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 04:13:51 -04:00
830 changed files with 8071 additions and 126894 deletions

No files matched your search

+1 -10
View File
@@ -1,6 +1,5 @@
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
# keeps working (#2204).
# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
#
# cargo nextest run --workspace # all tests except doctests
# cargo test --doc --workspace # nextest does not run doctests
@@ -14,11 +13,3 @@ gpu = { max-threads = 4 }
[[profile.default.overrides]]
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
test-group = 'gpu'
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
# at the end of the log. The default profile's GPU override still applies.
[profile.ci]
fail-fast = true
slow-timeout = { period = "60s", terminate-after = 10 }
failure-output = "immediate-final"
+21 -59
View File
@@ -17,10 +17,6 @@ on:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# No test or fuzz run may print a page: the engine refuses to spool, and if something ever
# bypasses that, lp fails loudly on a printer that does not exist.
PHOTOCRAFT_NO_PRINT: "1"
LPDEST: photocraft-no-such-printer
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
@@ -39,7 +35,6 @@ jobs:
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# macOS is tested only by release.yml.
platforms:
timeout-minutes: 10
name: select platforms
runs-on: ubuntu-latest
outputs:
@@ -66,7 +61,7 @@ jobs:
# The browser build (a fat-LTO wasm build) runs only when something it compiles or
# packages changed: the web app and its packaging, crate sources and manifests (the web
# build compiles nearly every crate), the workspace manifests and this workflow.
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
echo "web=true" >> "$GITHUB_OUTPUT"
else
echo "web=false" >> "$GITHUB_OUTPUT"
@@ -78,7 +73,6 @@ jobs:
fi
test:
timeout-minutes: 100
name: test (${{ matrix.os }})
needs: platforms
runs-on: ${{ matrix.os }}
@@ -113,52 +107,25 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
# The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
# after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
- name: Format
if: runner.os == 'Linux'
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
# prefs audit). Static: it reads committed files only, no benchmarks run here.
- name: Scorecard up to date
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: [email protected]
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
# Separate native commands into steps so Windows reports either command's failure.
- name: Test
run: cargo nextest run --workspace --locked --features heif --profile ci
- name: Doctests
run: cargo test --doc --workspace --locked --features heif
run: cargo test --workspace --locked --features heif
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: runner.os == 'Linux'
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
# --features heif` of the Test step: any other package or feature selection resolves a
# different feature set, recompiles the whole dependency graph into the same target dir and
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
# not what is built); engine and the apps only pass text on to them (#6120).
- name: Check out craft-fonts
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
@@ -172,12 +139,21 @@ jobs:
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: >-
cargo nextest run --workspace --locked --features heif --profile ci
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
run: cargo test --workspace --locked --features heif
- name: Clippy
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
# prefs audit). Static: it reads committed files only, no benchmarks run here.
- name: Scorecard up to date
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
corpus:
timeout-minutes: 30
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
@@ -210,7 +186,6 @@ jobs:
run: cargo xtask test-corpus
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
@@ -227,7 +202,6 @@ jobs:
run: cargo xtask wasm
web:
timeout-minutes: 60
name: web build and wasm size gate
# PRs run it only when web-relevant files change (see `platforms`); main always does.
needs: platforms
@@ -251,8 +225,8 @@ jobs:
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
# package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
# parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses.
# package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB
# wasm limit and exercises the same packaging step used by release.yml.
- name: Build and verify browser release
run: packaging/web/package.sh
- name: Report wasm size
@@ -263,19 +237,15 @@ jobs:
echo
echo "| File | Bytes | MiB | Limit |"
echo "| --- | ---: | ---: | ---: |"
row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; }
# The app's wasm as built; it ships gzipped in parts, so only the download grows with it.
for wasm in dist/web/*.wasm; do
[ -f "$wasm" ] || continue
row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none"
bytes=$(wc -c < "$wasm" | tr -d ' ')
mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
done
# What a host sees: the largest file of the packaged site.
largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1)
row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB"
} >> "$GITHUB_STEP_SUMMARY"
fuzz:
timeout-minutes: 60
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
# Placeholder: manual trigger only until a nightly schedule is enabled.
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
@@ -287,14 +257,6 @@ jobs:
- { crate: psd, name: parse }
- { crate: psd, name: descriptor }
- { crate: psd, name: image_source_data }
- { crate: psd, name: grd }
- { crate: psd, name: ase }
- { crate: psd, name: aco }
- { crate: psd, name: patterns }
- { crate: psd, name: filter_effects }
- { crate: psd, name: metadata }
- { crate: psd, name: tagged }
- { crate: psd, name: resources }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
-1
View File
@@ -27,7 +27,6 @@ concurrency:
jobs:
build:
timeout-minutes: 15
name: mdBook build
runs-on: ubuntu-latest
steps:
-1
View File
@@ -23,7 +23,6 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
verified-appimage-inputs:
timeout-minutes: 10
name: Verified AppImage inputs
runs-on: ubuntu-latest
steps:
-6
View File
@@ -35,9 +35,3 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+1 -1
View File
@@ -39,7 +39,6 @@ apps/
photocraft desktop app (eframe/wgpu), TCP control server
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
photocraft-web the same app in the browser (trunk + wasm-bindgen)
photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
```
@@ -132,6 +131,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
- `log/` (local, gitignored): the dev log.
-
## 8. Keeping the native format complete
+2 -11
View File
@@ -10,10 +10,8 @@ next to them.
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | <https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt> | MIT (X.Org app/rgb) |
| `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/nl.tsv` | Dutch UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
@@ -21,13 +19,9 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -39,10 +33,8 @@ next to them.
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -56,7 +48,6 @@ next to them.
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
### Optional build input: craft-fonts (not files in this repo)
@@ -114,6 +105,6 @@ next to the files:
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
Generated
+49 -1283
View File
File diff suppressed because it is too large. Load diff
+16 -36
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -38,7 +38,6 @@ photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -51,9 +50,7 @@ zune-core = "0.5"
zune-jpeg = "0.5"
proptest = "1"
eframe = { version = "0.36", default-features = false }
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
# (egui_fonts.rs; the web build's size gate).
egui = { version = "0.36", default-features = false }
egui = "0.36"
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
@@ -84,14 +81,13 @@ codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates,
# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters
# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured).
# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"`
# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB.
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O
# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "z"
opt-level = "s"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
@@ -99,35 +95,20 @@ strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
# Decoding and rasterizing keep their speed.
[profile.wasm-release.package.photocraft-codecs]
opt-level = "s"
[profile.wasm-release.package.photocraft-raw]
opt-level = "s"
[profile.wasm-release.package.photocraft-heif]
opt-level = "s"
[profile.wasm-release.package.photocraft-vector]
opt-level = "s"
# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below
# retain their speed-oriented optimization level.
[profile.wasm-release.package.photocraft-ui-egui]
opt-level = "z"
# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once
# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation
# (runs when a pipeline is created). "z" keeps the wasm well under the size gate.
[profile.wasm-release.package.photocraft-doc]
opt-level = "z"
[profile.wasm-release.package.photocraft-psd]
opt-level = "z"
[profile.wasm-release.package.photocraft-format]
opt-level = "z"
[profile.wasm-release.package.photocraft-affinity]
opt-level = "z"
[profile.wasm-release.package.serde_json]
# Keep browser command dispatch, import/export glue, and the entry point compact. Native
# builds and the speed-oriented pixel-processing overrides below retain their settings.
[profile.wasm-release.package.photocraft-engine]
opt-level = "z"
[profile.wasm-release.package.usvg]
[profile.wasm-release.package.photocraft-io]
opt-level = "z"
[profile.wasm-release.package.naga]
[profile.wasm-release.package.photocraft-web]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
@@ -140,4 +121,3 @@ opt-level = 3
opt-level = 3
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+3 -3
View File
@@ -187,7 +187,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗃️ Layers, done properly</h4>
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, a layer kind filter, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
</td>
</tr>
<tr>
@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
@@ -277,7 +277,7 @@ flatpak run ai.storyteller.photocraft
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it.
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
+3 -4
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
@@ -40,7 +40,7 @@ the one above. About 80% parallelizes (independent crates and command modules);
architecture and the PSD writer are the serial parts.
**Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to
end, and breadth is high, but 953 issues are open (2026-10-13), 22 of 25 performance budgets
end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets
are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta
needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows
(retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen;
@@ -110,7 +110,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut
| Rank | Milestone | Estimate |
|---|---|---:|
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 145 → 200+ of 258 | 150–250 h |
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h |
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
| 3 | User bug backlog to under 100 open | 250–400 h |
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
@@ -135,7 +135,6 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
+15 -66
View File
@@ -15,21 +15,19 @@ pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
photocraft-cli batch --actions <actions.json> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>]
Apply an action list to every image in a directory. Steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed
@@ -69,17 +67,17 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
bare: &["--tiff-layers"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
bare: &["--in-place", "--tiff-layers"],
run: batch,
},
@@ -215,10 +213,6 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
if let Some(b) = a.get("--tga-bits") {
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
}
if let Some(b) = a.get("--dpx-bits") {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
Ok(o)
}
@@ -246,7 +240,7 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
warn_all(err, &o.warnings);
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
warn_all(err, &ws);
Ok(())
@@ -298,16 +292,11 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
// Opening, running commands and exporting can all notice the same missing font:
// one `run` invocation reports each warning line at most once.
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
reported_fonts.extend(ws.iter().cloned());
warn_all(err, &ws);
reported_fonts.extend(ws);
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
@@ -319,11 +308,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if cmds.is_empty() && a.get("--out").is_none() {
return Err("run needs at least one --cmd (or --out)".into());
}
let mut reported_fonts = std::collections::HashSet::<String>::new();
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(format!("`{id}`: {e}"));
}
let new_warnings: Vec<String> = h
.session
.active()
@@ -352,7 +339,6 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
Ok(())
@@ -398,38 +384,6 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action
Ok((play, set))
}
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
/// its version word) is converted first; the played action's steps with no PhotoCraft
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
if !is_atn {
let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?;
let (steps, set) = load_actions(&text, name)?;
return Ok((steps, set, Vec::new()));
}
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?;
let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, imp.actions.as_slice()) {
(Some(n), _) => {
imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))?
}
(None, [only]) => only,
(None, []) => return Err(format!("{path}: the set holds no actions")),
(None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action <name> ({})", imp.set, imp.actions.len(), names())),
};
let warnings = imp
.unsupported
.iter()
.filter(|u| u.action == action.name)
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
.chain(imp.warnings.iter().cloned())
.collect();
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, imp.actions, warnings))
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
@@ -438,7 +392,7 @@ fn same_dir(a: &Path, b: &Path) -> bool {
fn is_input(p: &Path) -> bool {
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub")
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
};
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
@@ -448,10 +402,8 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
let (actions, set) = load_actions(&text, a.get("--action"))?;
let opts = export_opts(a)?;
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
@@ -541,7 +493,9 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
.map(|v| v.iter().collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
.filter(|c| {
needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
})
.collect();
if a.has("--json") {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
@@ -672,11 +626,6 @@ mod missing_font_warning_tests {
let (code, _, stderr) = invoke(&["convert", &ora, &png]);
assert_eq!(code, 0, "{stderr}");
assert!(is_input(std::path::Path::new(&ora)));
// Krita documents are batch inputs too (import-only).
for name in ["sketch.kra", "old.KRZ"] {
std::fs::write(folder.join(name), b"").unwrap();
assert!(is_input(&folder.join(name)), "{name}");
}
let _ = std::fs::remove_dir_all(&folder);
}
-113
View File
@@ -90,11 +90,6 @@ fn unknown_flags_are_usage_errors() {
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
for needle in ["ellipse", "marquee"] {
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
assert!(out.contains("select.rect"), "{needle}: {out}");
}
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
"--format=jpg",
"--quality",
@@ -208,50 +203,6 @@ fn run_commands_and_save() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_stops_after_failed_action_without_exporting() {
let d = tmp("run-failed-action");
let out = d.join("result.png");
let result = bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "layer.new.layer"])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out)
.output()
.unwrap();
let error = String::from_utf8_lossy(&result.stderr);
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
assert!(!out.exists(), "a failed action must not export a partial result");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
let out = d.join("result.png");
let (stdout, _) = ok(bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out));
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
assert_eq!(playback["result"]["ran"], 1);
assert!(playback["result"].get("failed").is_none());
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
assert!(out.is_file());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_errors() {
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
@@ -460,40 +411,6 @@ fn quality_outside_1_to_100_is_refused() {
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
}
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
#[test]
fn dpx_converts_both_ways_with_the_bits_asked_for() {
let d = tmp("dpx");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
let out = d.join("a.dpx");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(b) = bits {
convert.args(["--dpx-bits", b]);
}
ok(&mut convert);
let bytes = std::fs::read(&out).unwrap();
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
}
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
let back = d.join("back.png");
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
assert!(back.exists());
for bad in ["24", "0", "abc"] {
let out = d.join("bad.dpx");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
}
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
@@ -723,33 +640,3 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
assert!(!err.contains("panicked"), "{err}");
}
}
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
/// PhotoCraft can't play are warned about up front.
#[test]
fn batch_plays_a_photoshop_actions_file() {
use photocraft_engine::actions_cmds::Action;
let d = tmp("atn");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]);
let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]);
let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap();
assert!(skipped.is_empty());
let atn = d.join("Set.atn");
std::fs::write(&atn, bytes).unwrap();
let out_dir = d.join("out");
// Two actions: the set needs --action.
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
assert!(out_dir.join("a.png").exists());
assert!(!err.contains("warning"), "{err}");
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert!(err.contains("Generative Fill"), "{err}");
assert!(!o.status.success());
}
-24
View File
@@ -1,24 +0,0 @@
[package]
name = "photocraft-web-boot"
description = "Starts Photocraft in the browser from compressed parts of its WebAssembly"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[[bin]]
name = "photocraft-web-boot"
path = "src/main.rs"
[dependencies]
# Inflates the parts in browsers without `DecompressionStream`.
flate2 = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
-6
View File
@@ -1,6 +0,0 @@
# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot)
[build]
target = "index.html"
dist = "../../dist/web-boot"
public_url = "./"
filehash = true
-30
View File
@@ -1,30 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web-boot" data-cargo-profile="wasm-release" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
@keyframes photocraft-stalled { to { visibility: visible; } }
</style>
</head>
<body>
<canvas id="photocraft_canvas"></canvas>
<div id="photocraft_loading">
<p>Loading PhotoCraft&hellip;</p>
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
console lists what failed.</p>
</div>
</body>
</html>
-356
View File
@@ -1,356 +0,0 @@
//! Starts Photocraft in the browser from compressed parts of its WebAssembly.
//!
//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at
//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in
//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in
//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated
//! loader, so the browser still compiles while downloading. No file on the host is larger than
//! one part, however large the app grows.
//!
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
//! In one that can't, this module inflates them itself, a little slower.
//!
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
//!
//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and
//! for hosts without a per-file cap; only the packaged site starts through this module.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200;
/// without this an HTML page would be read as a list of parts.
#[cfg(any(target_arch = "wasm32", test))]
const MARKER: &str = "photocraft-parts 1";
/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a
/// file next to `parts.txt`: anything that could point elsewhere is refused.
#[cfg(any(target_arch = "wasm32", test))]
fn parts(list: &str) -> Option<Vec<String>> {
let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty());
if lines.next() != Some(MARKER) {
return None;
}
let parts: Vec<String> = lines.map(str::to_owned).collect();
let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']);
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
}
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
#[cfg(any(target_arch = "wasm32", test))]
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
#[cfg(any(target_arch = "wasm32", test))]
impl Inflater {
fn new() -> Self {
Inflater(flate2::write::GzDecoder::new(Vec::new()))
}
/// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on
/// to input until it has a whole block).
fn push(&mut self, chunk: &[u8]) -> std::io::Result<Vec<u8>> {
std::io::Write::write_all(&mut self.0, chunk)?;
Ok(std::mem::take(self.0.get_mut()))
}
/// The rest, once the stream has ended. An error when it ended early or its checksum is
/// wrong, the same as the browser's inflater gives.
fn finish(&mut self) -> std::io::Result<Vec<u8>> {
self.0.try_finish()?;
Ok(std::mem::take(self.0.get_mut()))
}
}
#[cfg(target_arch = "wasm32")]
mod boot {
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
TransformStreamDefaultController, Transformer, WritableStream,
};
use super::Inflater;
/// Where packaging puts the app, next to this module's own files.
const PARTS: &str = "./app/parts.txt";
/// Seconds to wait before each further try; one more than this many tries are made.
const RETRY_AFTER: [u32; 2] = [1, 3];
// The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and
// runs the app's `main`. The path is fixed at compile time, so packaging gives the file this
// name (Trunk's own name for it carries a content hash).
#[wasm_bindgen(raw_module = "./app/photocraft-web.js")]
extern "C" {
#[wasm_bindgen(js_name = default, catch)]
async fn start_app(module: JsValue) -> Result<JsValue, JsValue>;
}
// The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would
// have to be set for the whole build, so the three members used are declared here.
#[wasm_bindgen]
extern "C" {
type DecompressionStream;
#[wasm_bindgen(constructor, catch)]
fn new(format: &str) -> Result<DecompressionStream, JsValue>;
#[wasm_bindgen(method, getter)]
fn readable(this: &DecompressionStream) -> ReadableStream;
#[wasm_bindgen(method, getter)]
fn writable(this: &DecompressionStream) -> WritableStream;
}
fn window() -> Result<web_sys::Window, JsValue> {
web_sys::window().ok_or_else(|| JsValue::from_str("no window"))
}
/// `fresh` skips the browser's cache.
async fn fetch(url: &str, fresh: bool) -> Result<Response, JsValue> {
let init = RequestInit::new();
if fresh {
init.set_cache(RequestCache::Reload);
}
let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?;
if !response.ok() {
return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status())));
}
if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) {
return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file")));
}
Ok(response)
}
/// Copies each part into `sink` in order, then closes it.
async fn feed(parts: Vec<String>, sink: WritableStream, fresh: bool) -> Result<(), JsValue> {
let keep_open = StreamPipeOptions::new();
keep_open.set_prevent_close(true);
for part in &parts {
let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?;
JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?;
}
JsFuture::from(sink.close()).await?;
Ok(())
}
/// Passes inflated bytes on to the stream's reader.
fn emit(inflated: std::io::Result<Vec<u8>>, out: &TransformStreamDefaultController) -> Result<(), JsValue> {
let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?;
if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) }
}
/// A gzip inflater with the shape of `DecompressionStream`, running in this module.
fn own_inflater() -> Result<TransformStream, JsValue> {
let inflater = Rc::new(RefCell::new(Inflater::new()));
let last = inflater.clone();
let busy = || JsValue::from_str("the inflater is in use");
let transform = Closure::<dyn FnMut(JsValue, TransformStreamDefaultController) -> Result<(), JsValue>>::new(
move |chunk: JsValue, out: TransformStreamDefaultController| {
let chunk = js_sys::Uint8Array::new(&chunk).to_vec();
emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out)
},
);
let flush = Closure::<dyn FnMut(TransformStreamDefaultController) -> Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| {
emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out)
});
let transformer = Transformer::new();
transformer.set_transform(&transform.into_js_value().unchecked_into());
transformer.set_flush(&flush.into_js_value().unchecked_into());
TransformStream::new_with_transformer(&transformer)
}
/// `compressed` inflated: by the browser where it can, otherwise here.
fn inflated(compressed: &ReadableStream) -> Result<ReadableStream, JsValue> {
let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? {
let inflater = DecompressionStream::new("gzip")?;
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
} else {
let inflater = own_inflater()?;
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
};
Ok(compressed.pipe_through(&pair))
}
async fn run(fresh: bool) -> Result<(), JsValue> {
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
let joined = TransformStream::new()?;
let wasm = inflated(&joined.readable())?;
// Why the download stopped: the app's loader only reports that its stream broke.
let cause = Rc::new(RefCell::new(None));
let failed = cause.clone();
let sink = joined.writable();
spawn_local(async move {
if let Err(e) = feed(parts, sink.clone(), fresh).await {
let _ = sink.abort_with_reason(&e);
failed.replace(Some(e));
}
});
let headers = Headers::new()?;
headers.set("Content-Type", "application/wasm")?;
let init = ResponseInit::new();
init.set_headers(&headers);
let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?;
let arg = js_sys::Object::new();
js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?;
match start_app(arg.into()).await {
Ok(_) => Ok(()),
Err(e) => Err(cause.take().unwrap_or(e)),
}
}
async fn sleep(seconds: u32) {
let wait = js_sys::Promise::new(&mut |done, _| {
let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX);
if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() {
let _ = done.call0(&JsValue::NULL);
}
});
let _ = JsFuture::from(wait).await;
}
/// Replaces the page's "Loading" text. `reload` adds a link that reloads the page.
fn say(text: &str, reload: bool) {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(loading) = document.get_element_by_id("photocraft_loading") else { return };
loading.set_text_content(None);
let Ok(line) = document.create_element("p") else { return };
line.set_text_content(Some(text));
if reload && let Ok(link) = document.create_element("a") {
link.set_text_content(Some("Reload"));
let _ = link.set_attribute("href", "");
let _ = line.append_child(&link);
}
let _ = loading.append_child(&line);
}
pub fn start() {
spawn_local(async {
let mut attempt = 0;
loop {
let error = match run(attempt > 0).await {
Ok(()) => return,
Err(e) => e,
};
web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error);
let Some(&wait) = RETRY_AFTER.get(attempt) else {
say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true);
return;
};
say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false);
sleep(wait).await;
attempt += 1;
}
});
}
}
#[cfg(target_arch = "wasm32")]
fn main() {
boot::start();
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot");
}
#[cfg(test)]
mod tests {
use super::{Inflater, MARKER, parts};
fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut z, bytes).expect("gzip");
z.finish().expect("gzip")
}
/// Bytes that don't compress to nothing, so the gzip stream spans many chunks.
fn sample() -> Vec<u8> {
let mut x = 0x2545_f491_4f6c_dd1d_u64;
(0..200_000)
.map(|i| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
if i % 3 == 0 { x as u8 } else { (i / 7) as u8 }
})
.collect()
}
/// The parts arrive in whatever pieces the network gives; the inflater must not care where
/// they are cut.
#[test]
fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() {
let (plain, packed) = (sample(), gzip(&sample()));
for cut in [1, 7, 4096, 65_536, packed.len()] {
let mut inflater = Inflater::new();
let mut out = Vec::new();
for chunk in packed.chunks(cut) {
out.extend(inflater.push(chunk).expect("push"));
}
out.extend(inflater.finish().expect("finish"));
assert_eq!(out, plain, "chunks of {cut}");
}
}
/// A download that stops early, or whose bytes changed on the way, must fail (the boot module
/// then tries again), never start a damaged app.
#[test]
fn the_inflater_refuses_a_short_or_damaged_stream() {
let packed = gzip(&sample());
let run = |bytes: &[u8]| -> std::io::Result<Vec<u8>> {
let mut inflater = Inflater::new();
let mut out = inflater.push(bytes)?;
out.extend(inflater.finish()?);
Ok(out)
};
assert!(run(&packed).is_ok());
assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short");
assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut");
let mut damaged = packed.clone();
let middle = damaged.len() / 2;
damaged[middle] ^= 0x55;
assert!(run(&damaged).is_err(), "a changed byte");
assert!(run(b"<!DOCTYPE html><html>not gzip</html>").is_err(), "a web page");
assert!(run(b"").is_err(), "nothing at all");
}
#[test]
fn a_parts_list_gives_its_names_in_order() {
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()]));
}
/// A host that answers a missing file with its home page must not have that page read as a
/// list of parts.
#[test]
fn a_web_page_is_not_a_parts_list() {
assert_eq!(parts(include_str!("../index.html")), None);
assert_eq!(parts(""), None);
assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker");
assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts");
assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know");
}
/// Part names are joined onto `./app/`, so one that could leave that folder, or name another
/// origin, refuses the whole list.
#[test]
fn a_part_name_cannot_point_outside_the_app_folder() {
for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] {
assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}");
}
}
/// The packaged site shows this crate's page, the development build the app's own. They are
/// the same page apart from which program the `<link data-trunk>` line builds.
#[test]
fn the_page_is_the_apps_page() {
let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::<Vec<_>>().join("\n");
let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html"));
assert_eq!(trunk(boot), trunk(app));
assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}");
assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces");
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
-1
View File
@@ -337,7 +337,6 @@ mod tests {
let b = Bridge::default();
b.unavailable("SecurityError".into());
let (mut s, _) = session(b.clone());
assert!(!b.unsaved(), "nothing is lost before the first change, so the web shell shows no banner");
import(&mut s);
assert!(s.tools.presets.iter().any(|p| p.name == "Browser seam brush"));
assert!(b.unsaved());
+36 -27
View File
@@ -14,12 +14,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -305,27 +305,27 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
// Only while a preset change is unsaved. Storage the browser denies from the start (a
// sandboxed iframe, blocked site data) loses nothing until the first change, and that
// change brings the banner with the reason.
if self.presets.unsaved()
&& let Some(error) = self.presets.error()
{
let error = self.presets.error();
if error.is_some() || self.presets.unsaved() {
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
if let Some(error) = error {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
} else {
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
}
});
}
self.app.ui(ui, frame);
@@ -340,7 +340,19 @@ fn services(inbox: Inbox) -> Services {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
file_dialog: Some(Box::new(|request, _parent, reply| match request {
// The browser's file picker hands over the files' contents, not paths. File › Open
@@ -370,8 +382,6 @@ fn services(inbox: Inbox) -> Services {
};
reply.send(answer);
}),
// Browser folder handles cannot be represented as filesystem paths for engine commands.
FileDialogRequest::Folder { .. } => reply.send(None),
// No save dialog on the web: the suggested name becomes the download name.
FileDialogRequest::Save { suggested } => {
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());
@@ -433,7 +443,6 @@ fn mime_for(name: &str) -> &'static str {
Some("gif") => "image/gif",
Some("psd" | "psb") => "image/vnd.adobe.photoshop",
Some("ora") => "image/openraster",
Some("kra" | "krz") => "application/x-krita",
_ => "application/octet-stream",
}
}
+1 -7
View File
@@ -29,7 +29,7 @@ photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -66,14 +66,8 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for
# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
wl-clipboard-rs = { version = "=0.9.4", default-features = false }
# Wayland desktop color selection through the existing XDG portal transport.
zbus = "=5.19.0"
# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no
# text or images. Already in Cargo.lock (photocraft-io); pinned exactly.
resvg = { version = "=0.45.1", default-features = false }
# Already used by the portal stack; bounded waits let cancelling close the OS request.
futures-lite = "=2.6.1"
async-io = "=2.6.0"
-35
View File
@@ -26,25 +26,6 @@ pub fn window_icon() -> egui::IconData {
}
}
/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app
/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS
/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG
/// replaced it with a flat picture (#5849). `exe` is the running executable.
pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
}
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
fn in_app_bundle(exe: &std::path::Path) -> bool {
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
let macos = exe.parent();
let contents = macos.and_then(std::path::Path::parent);
let bundle = contents.and_then(std::path::Path::parent);
name(macos).as_deref() == Some("MacOS")
&& name(contents).as_deref() == Some("Contents")
&& name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app"))
}
#[cfg(target_os = "macos")]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
#[cfg(not(target_os = "macos"))]
@@ -64,22 +45,6 @@ mod tests {
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
}
/// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the
/// Linux and Windows builds) still set the runtime icon.
#[test]
fn only_a_macos_app_bundle_skips_the_runtime_icon() {
use std::path::Path;
assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")));
assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft")));
assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft")));
assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app");
assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name");
assert!(!in_app_bundle(Path::new("photocraft")));
assert!(sets_runtime_icon(None));
assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft"))));
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
}
#[test]
fn window_icon_decodes() {
let icon = window_icon();
+7 -10
View File
@@ -6,7 +6,7 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
pub(crate) fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
@@ -14,7 +14,7 @@ pub(crate) fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
portal_code(value).and_then(decode).or_else(gtk_theme)
decode(portal_code(value)?)
}
#[cfg(target_os = "linux")]
@@ -39,8 +39,8 @@ fn decode(code: u32) -> Option<egui::Theme> {
}
}
/// GNOME fallback when the portal has no preference. Read only on the appearance worker,
/// using an absolute path rather than the shell's PATH.
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
/// absolute path, and is never repeated.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
@@ -55,7 +55,7 @@ fn gtk_theme() -> Option<egui::Theme> {
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" | "default" => Some(egui::Theme::Light),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}
}
@@ -97,7 +97,7 @@ pub fn service() -> Option<photocraft_ui_egui::SystemThemeFn> {
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
publisher.publish(portal_code(changed).and_then(decode).or_else(gtk_theme));
publisher.publish(portal_code(changed).and_then(decode));
}
})
}
@@ -184,10 +184,7 @@ mod tests {
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
// GNOME's "default" appearance is light, even though the portal reports no preference.
assert_eq!(super::parse_gtk_scheme("'default'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("default"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), None);
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
}
/// #2798: a first read that misses the start-up wait still wakes the idle UI.
-399
View File
@@ -1,399 +0,0 @@
//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on
//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of
//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE
//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's
//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order.
//!
//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and
//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found.
#[cfg(target_os = "linux")]
use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle};
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Desktop {
Gnome,
Kde,
Other,
}
/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and
/// the like count as GNOME.
#[cfg(target_os = "linux")]
fn desktop(var: &dyn Fn(&str) -> Option<String>) -> Desktop {
for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] {
let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue };
return if v.contains("gnome") {
Desktop::Gnome
} else if v.contains("kde") || v.contains("plasma") {
Desktop::Kde
} else {
Desktop::Other
};
}
if var("KDE_FULL_SESSION").is_some() {
Desktop::Kde
} else if var("GNOME_DESKTOP_SESSION_ID").is_some() {
Desktop::Gnome
} else {
Desktop::Other
}
}
/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK
/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons.
#[cfg(target_os = "linux")]
fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily {
match desktop {
Desktop::Gnome => CaptionFamily::Adwaita,
Desktop::Kde => CaptionFamily::Breeze,
Desktop::Other => {
let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase();
if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) {
CaptionFamily::Adwaita
} else if theme.starts_with("breeze") {
CaptionFamily::Breeze
} else {
CaptionFamily::Generic
}
}
}
}
/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`).
#[cfg(target_os = "linux")]
fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {
let mut inside = false;
for line in text.lines().map(str::trim) {
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
inside = name == section;
} else if inside
&& let Some((k, v)) = line.split_once('=')
&& k.trim() == key
{
return Some(v.trim().to_string());
}
}
None
}
/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a
/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's
/// defaults: the window menu on the left, minimize, maximize and close on the right.
#[cfg(target_os = "linux")]
fn kwin_layout(kwinrc: &str) -> String {
let side = |key: &str, default: &str| {
let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string());
letters
.chars()
.filter_map(|c| match c {
'I' => Some("minimize"),
'A' => Some("maximize"),
'X' => Some("close"),
_ => None,
})
.collect::<Vec<_>>()
.join(",")
};
format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX"))
}
/// What the style is worked out from.
#[cfg(target_os = "linux")]
#[derive(Clone, Default)]
struct Found {
gtk_theme: Option<String>,
/// A GTK decoration layout.
layout: Option<String>,
icon_theme: Option<String>,
}
/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own),
/// the desktop's setting, or its default theme.
#[cfg(target_os = "linux")]
fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
match var("PHOTOCRAFT_CAPTION_ICONS") {
Some(name) if name.trim().eq_ignore_ascii_case("none") => None,
Some(name) => Some(name.trim().to_string()),
None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())),
}
}
/// The style from what was found; `icons` loads a theme's glyphs.
#[cfg(target_os = "linux")]
fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>, icons: &dyn Fn(&str) -> Option<CaptionIcons>) -> CaptionStyle {
let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref()));
// GNOME's own default shows Close only; GTK's shows all three.
let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" };
let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string());
let mut style = CaptionStyle::from_gtk_layout(family, &layout);
style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name));
style
}
#[cfg(target_os = "linux")]
fn read_text(path: &std::path::Path) -> Option<String> {
crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned())
}
/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's
/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them).
#[cfg(target_os = "linux")]
fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option<String>) -> Found {
let config = var("XDG_CONFIG_HOME")
.filter(|d| std::path::Path::new(d).is_absolute())
.map(std::path::PathBuf::from)
.or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")));
let Some(config) = config else { return Found::default() };
let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default();
let gtk_theme = ini_value(&gtk, "Settings", "gtk-theme-name");
if desktop == Desktop::Kde {
let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default()));
let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme");
Found { gtk_theme, layout, icon_theme }
} else {
let layout = ini_value(&gtk, "Settings", "gtk-decoration-layout");
Found { gtk_theme, layout, icon_theme: ini_value(&gtk, "Settings", "gtk-icon-theme-name") }
}
}
/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at
/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them.
#[cfg(target_os = "linux")]
fn load_icons(theme: &str) -> Option<CaptionIcons> {
use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small};
let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?;
let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from);
let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")];
if files.iter().all(Option::is_none) {
return None;
}
let render = move |icon: CaptionIcon, px: u32| {
let i = match icon {
CaptionIcon::Minimize => 0,
CaptionIcon::Maximize => 1,
CaptionIcon::Restore => 2,
CaptionIcon::Close => 3,
};
files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px))
};
Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) })
}
/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct IconCache(std::sync::Mutex<Option<(String, Option<CaptionIcons>)>>);
#[cfg(target_os = "linux")]
impl IconCache {
fn get(&self, theme: &str) -> Option<CaptionIcons> {
let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((name, icons)) = last.as_ref()
&& name == theme
{
return icons.clone();
}
let icons = load_icons(theme);
*last = Some((theme.to_string(), icons.clone()));
icons
}
}
#[cfg(target_os = "linux")]
const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences";
#[cfg(target_os = "linux")]
const LAYOUT_KEY: &str = "button-layout";
#[cfg(target_os = "linux")]
const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface";
#[cfg(target_os = "linux")]
const ICONS_KEY: &str = "icon-theme";
/// A string setting from the portal, unwrapping the variants some portals nest it in.
#[cfg(target_os = "linux")]
fn portal_string(value: zbus::zvariant::OwnedValue) -> Option<String> {
let mut value: zbus::zvariant::Value<'_> = value.into();
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
zbus::zvariant::Value::Str(s) => return Some(s.to_string()),
_ => return None,
}
}
None
}
/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings,
/// following the button layout and icon theme as they change. A worker does the file and D-Bus
/// work and wakes the UI only when the style changes; the first answer usually lands before the
/// first frame.
pub fn service() -> Option<photocraft_ui_egui::CaptionStyleFn> {
#[cfg(target_os = "linux")]
{
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
let var = |k: &str| std::env::var(k).ok();
let desktop = desktop(&var);
let mut found = from_files(desktop, &var);
// Icons need the theme read, which takes tens of milliseconds: the worker brings them.
let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None)));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (shared, waker) = (Arc::clone(&current), Arc::clone(&wake));
let cache = IconCache::default();
let publish = move |found: &Found| {
let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t));
let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner);
if *cur != style {
*cur = style;
if let Some(ctx) = waker.get() {
ctx.request_repaint();
}
}
};
let (ready, wait) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || {
let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None };
let Some(proxy) = proxy else {
publish(&found);
let _ = ready.send(());
return;
};
// Subscribe before the first read, so a change during start-up isn't lost.
let signals = proxy.receive_signal("SettingChanged").ok();
let read = |namespace: &str, key: &str| {
let value: Option<zbus::zvariant::OwnedValue> = proxy.call("Read", &(namespace, key)).ok();
value.and_then(portal_string)
};
found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take());
found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take());
publish(&found);
let _ = ready.send(());
for message in signals.into_iter().flatten() {
let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue };
match (namespace.as_str(), key.as_str()) {
(WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value),
(INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value),
_ => continue,
}
publish(&found);
}
});
// A fast desktop answers before the first frame; a slow one updates the bar later.
if spawned.is_ok() {
let _ = wait.recv_timeout(std::time::Duration::from_millis(150));
}
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
current.lock().unwrap_or_else(PoisonError::into_inner).clone()
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
use photocraft_ui_egui::Caption::{Close, Maximize, Minimize};
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone())
}
#[test]
fn the_desktop_comes_from_the_first_variable_set() {
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome);
assert_eq!(desktop(&env(&[])), Desktop::Other);
}
#[test]
fn the_family_follows_the_desktop_then_the_gtk_theme() {
assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze);
assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze);
assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic);
assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic);
}
#[test]
fn kwin_button_letters_become_a_gtk_layout() {
assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults");
let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n";
assert_eq!(kwin_layout(rc), "close,minimize,maximize:");
assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze"));
assert_eq!(ini_value(rc, "nope", "library"), None);
// Garbage is just no buttons on that side, never a panic.
assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close");
}
fn no_icons(_: &str) -> Option<CaptionIcons> {
None
}
#[test]
fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() {
let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() };
assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus"));
assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita"));
assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze"));
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None);
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela"));
// The style carries what the loader returns for that name.
let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| {
(t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) })
});
assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus"));
}
#[test]
fn layouts_and_overrides_resolve_to_a_style() {
let none = env(&[]);
let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons);
assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout");
let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons);
assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close]));
let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None };
let other = resolve(Desktop::Other, &found, &none, &no_icons);
assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![]));
let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]);
let s = resolve(Desktop::Gnome, &found, &forced, &no_icons);
assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize]));
// An unknown override name falls back to what was found.
assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze);
}
#[test]
fn portal_strings_unwrap_nested_variants() {
use zbus::zvariant::{OwnedValue, Value};
let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close")))));
assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close"));
assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None);
}
#[test]
fn settings_files_are_read_from_the_config_dir() {
let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id()));
std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap();
std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap();
std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap();
std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap();
let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]);
let other = from_files(Desktop::Other, &var);
assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:")));
let kde = from_files(Desktop::Kde, &var);
assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark")));
// A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found.
assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None);
let _ = std::fs::remove_dir_all(dir);
}
}
+6 -90
View File
@@ -5,15 +5,14 @@ use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
use std::time::Instant;
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream,
read_bounded_line, repeat_auth_reply,
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
use serde_json::{Value, json};
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
@@ -48,17 +47,11 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
}
/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_deadline: Duration) {
if configure_stream(&stream).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut reader = BufReader::new(read);
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -81,20 +74,11 @@ fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRe
if !authenticated {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok && reader.get_mut().authenticated().is_err() {
break;
}
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
break;
}
continue;
}
if let Some(reply) = repeat_auth_reply(&line) {
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() {
break;
}
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
@@ -103,13 +87,12 @@ fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRe
let deadline = Instant::now() + REPLY_DEADLINE;
let (mut req, rrx) = ControlRequest::new(method, params);
req.deadline = Some(deadline);
let dispatch = req.dispatch.clone();
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r =
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": dispatch.give_up()}));
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
@@ -130,34 +113,7 @@ fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRe
mod tests {
use super::*;
use std::io::BufRead;
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let started = Instant::now();
serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
started.elapsed()
});
// Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
// the idle timeout.
let mut stream = TcpStream::connect(addr).unwrap();
for i in 0..150 {
let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
if stream.write_all(byte).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let served = server.join().unwrap();
assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
assert!(rx.try_recv().is_err(), "nothing was dispatched");
}
use std::time::Duration;
#[test]
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
@@ -246,44 +202,4 @@ mod tests {
handler.join().unwrap();
server.join().unwrap();
}
#[test]
fn repeat_auth_reports_already_authenticated_without_dispatching() {
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
serve(stream, TOKEN, tx, egui::Context::default());
});
let handler = std::thread::spawn(move || {
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(req.method, "test.after", "a repeat auth must not reach the UI dispatcher");
req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
});
let mut stream = TcpStream::connect(addr).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
for id in ["auth", "again"] {
writeln!(stream, "{}", json!({"id": id, "method": "auth", "params": {"token": TOKEN}})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
}
let again: Value = serde_json::from_str(&line).unwrap();
assert_eq!(again["id"], "again");
assert_eq!(again["ok"], false);
assert_eq!(again["error"], "already authenticated");
writeln!(stream, "{}", json!({"id": 9, "method": "test.after"})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let after: Value = serde_json::from_str(&line).unwrap();
assert_eq!(after["id"], 9);
assert_eq!(after["result"], "still serving");
drop(reader);
drop(stream);
handler.join().unwrap();
server.join().unwrap();
}
}
+14 -111
View File
@@ -177,12 +177,6 @@ pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Ma
plan(backend, crashed, env, safe_gpu, os)
}
/// Persist successful hardware fallbacks, but never silently pin CPU mode.
/// The recovery dialog explicitly stores CPU mode when the user chooses it.
pub fn backend_to_remember(plan: &Plan) -> Option<GpuBackend> {
(plan.remember && plan.backend != GpuBackend::Cpu).then_some(plan.backend)
}
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
if plan.env.is_some() {
@@ -233,10 +227,9 @@ pub struct Candidate {
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
/// for presenting the window (see [`backends`]).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan);
let ty = match a.device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
@@ -443,14 +436,6 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
.unwrap_or_default()
}
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
/// turned on), read before the window opens: it picks the surface's present mode.
pub fn read_low_latency(path: Option<&Path>) -> bool {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing;
v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default)
}
/// Read the policy leniently at startup, including old settings without renderingMode.
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
@@ -471,23 +456,6 @@ mod tests {
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
}
#[test]
fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() {
let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("preferences.json");
let read = |json: &str| {
std::fs::write(&file, json).unwrap();
read_low_latency(Some(&file))
};
assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#));
assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#));
assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)");
assert!(!read("not json"));
assert!(!read_low_latency(None), "no settings file: the default (off)");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn display_server_preference_is_read_before_the_window_opens() {
use photocraft_engine::prefs::LinuxDisplayServer;
@@ -573,27 +541,6 @@ mod tests {
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
}
/// #3483: A temporary CPU recovery must not change the next launch.
#[test]
fn cpu_recovery_requires_explicit_confirmation_to_persist() {
let gl_crash = crashed("gl", "gl");
let cpu = plan_with_mode(Auto, RenderingMode::Auto, Some(&gl_crash), None, false, Os::Other);
assert_eq!((cpu.backend, cpu.remember), (Cpu, true));
assert_eq!(backend_to_remember(&cpu), None);
let safe = plan_with_mode(Auto, RenderingMode::Auto, None, None, true, Os::Other);
assert_eq!(safe.backend, Cpu);
assert_eq!(backend_to_remember(&safe), None);
// An unconfirmed fallback leaves the original automatic selection intact.
assert_eq!(plan_with_mode(Auto, RenderingMode::Auto, None, None, false, Os::Other).backend, Auto);
// A working hardware fallback can still be remembered.
let vk_crash = crashed("auto", "vulkan");
let gl = plan_with_mode(Auto, RenderingMode::Auto, Some(&vk_crash), None, false, Os::Other);
assert_eq!(backend_to_remember(&gl), Some(Gl));
// CPU explicitly selected in preferences still works.
assert_eq!(plan_with_mode(Auto, RenderingMode::Cpu, None, None, false, Os::Other).backend, Cpu);
}
#[test]
fn safe_gpu_forces_the_cpu_for_one_launch() {
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
@@ -661,20 +608,6 @@ mod tests {
assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1));
}
/// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12).
/// GL drew a black window; WARP works. GL stays the last resort.
#[test]
fn windows_auto_prefers_warp_over_gl() {
use wgpu::{Backend as B, DeviceType as T};
let svga_gl = cand(0x15ad, T::Other, B::Gl);
let warp = cand(0x1414, T::Cpu, B::Dx12);
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1));
assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1));
assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0));
// Elsewhere GL keeps its rank.
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0));
}
#[test]
fn adapters_must_present_even_when_none_support_the_surface() {
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
@@ -739,7 +672,7 @@ mod tests {
} else {
s.finish();
}
let (prev, _s) = begin_when_unlocked(&dir);
let (prev, _s) = Sentinel::begin(&dir);
assert_eq!(prev, Previous::Clean);
assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto);
let _ = std::fs::remove_dir_all(&dir);
@@ -751,22 +684,6 @@ mod tests {
d
}
/// `Sentinel::begin`, called again up to 50 times, 10 ms apart, while it returns
/// `Previous::Busy`. Other tests in this binary spawn processes: between fork and exec a child
/// briefly shares the marker's flock, so `begin` can see the lock as busy just after a test
/// drops its sentinel.
fn begin_when_unlocked(dir: &Path) -> (Previous, Option<Sentinel>) {
let (mut prev, mut s) = Sentinel::begin(dir);
for _ in 0..50 {
if prev != Previous::Busy {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
(prev, s) = Sentinel::begin(dir);
}
(prev, s)
}
#[test]
fn sentinel_detects_a_crash_and_clears_on_success() {
let dir = temp_dir("cycle");
@@ -782,7 +699,16 @@ mod tests {
assert!(none.is_none());
// The process dies in the driver: the lock goes, the marker stays.
drop(s);
let (prev, s) = begin_when_unlocked(&dir);
// Another test in this binary spawns a process: between its fork and exec the child briefly
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
let (mut prev, mut s) = Sentinel::begin(&dir);
for _ in 0..50 {
if prev != Previous::Busy {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
(prev, s) = Sentinel::begin(&dir);
}
let m = prev.crashed().cloned().expect("crash detected");
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
// This start succeeds: the next one finds nothing.
@@ -795,29 +721,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn begin_when_unlocked_waits_for_a_briefly_held_lock() {
let dir = temp_dir("shared-lock");
let (_, s) = Sentinel::begin(&dir);
let mut s = s.expect("sentinel");
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
// After the start dies, a second handle on the marker holds the lock for 50 ms, as a
// spawned child's copy of the descriptor does until the child calls exec.
let held = File::open(dir.join(MARKER_FILE)).unwrap();
drop(s);
held.lock().unwrap();
assert_eq!(Sentinel::begin(&dir).0, Previous::Busy);
let release = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
held.unlock().unwrap();
});
let (prev, s) = begin_when_unlocked(&dir);
release.join().unwrap();
assert_eq!(prev.crashed().map(|m| m.backend.as_str()), Some("vulkan"));
s.expect("sentinel").finish();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
let cases = [
@@ -835,7 +738,7 @@ mod tests {
} else {
s.finish();
}
let (previous, s) = begin_when_unlocked(&dir);
let (previous, s) = Sentinel::begin(&dir);
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
if !keep {
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
-329
View File
@@ -1,329 +0,0 @@
//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the
//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits,
//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG
//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour.
use std::path::{Path, PathBuf};
/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a
/// theme lists, and the bytes read from one file.
const MAX_THEMES: usize = 32;
const MAX_DIRS: usize = 4096;
/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`).
const SCAN_DEPTH: usize = 3;
const MAX_FILE: u64 = 1 << 20;
/// The size the caption glyphs are drawn at, in the spec's units.
const ICON_SIZE: i64 = 16;
/// A theme or icon name that stays inside its directory.
pub fn valid_name(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
}
/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small).
pub fn read_small(path: &Path) -> Option<Vec<u8>> {
use std::io::Read;
let mut bytes = vec![];
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?;
Some(bytes)
}
/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each
/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says.
pub fn base_dirs(var: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let absolute = |p: &String| Path::new(p).is_absolute();
let home = var("HOME").filter(absolute).map(PathBuf::from);
let mut dirs = vec![];
if let Some(home) = &home {
dirs.push(home.join(".icons"));
}
if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) {
dirs.push(data.join("icons"));
}
let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons")));
dirs
}
/// One `[section]` of an `index.theme`, as (key, value) pairs.
fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> {
let mut inside = false;
let mut pairs = vec![];
for line in index.lines().map(str::trim) {
if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
if inside {
break;
}
inside = s == name;
} else if inside && let Some((k, v)) = line.split_once('=') {
pairs.push((k.trim(), v.trim()));
}
}
pairs
}
fn list(value: Option<&str>) -> impl Iterator<Item = &str> {
value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty())
}
/// How far a theme directory is from a 16 px icon at scale 1 (the spec's
/// `DirectorySizeDistance`), then its scale: 0 is an exact fit.
fn distance(keys: &[(&str, &str)]) -> (i64, i64) {
let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::<i64>().ok()).unwrap_or(default).clamp(0, 1 << 16);
let size = num("Size", 0);
let scale = num("Scale", 1).max(1);
let d = match get("Type").unwrap_or("Threshold") {
"Fixed" => (size * scale - ICON_SIZE).abs(),
"Scalable" => {
let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale);
(min - ICON_SIZE).max(ICON_SIZE - max).max(0)
}
_ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0),
};
(d, scale)
}
/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in
/// path order: scalable and symbolic ones first, as icons there fit any size.
fn subdirectories(root: &Path) -> Vec<PathBuf> {
let mut found = vec![];
let mut level = vec![root.to_path_buf()];
for _ in 0..SCAN_DEPTH {
let mut next = vec![];
for dir in &level {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
for entry in entries.flatten().take(MAX_DIRS) {
let path = entry.path();
if found.len() + next.len() < MAX_DIRS && path.is_dir() {
next.push(path);
}
}
}
found.extend(next.iter().cloned());
level = next;
}
let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic")));
found.sort_by_key(|p| (vector(p), p.clone()));
found
}
/// One theme of a chain: its listed directories, best fit first, in every base directory that has
/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot.
struct Theme {
roots: Vec<PathBuf>,
listed: Vec<PathBuf>,
unlisted: std::cell::OnceCell<Vec<PathBuf>>,
}
impl Theme {
/// The directories the index forgot to list: GTK still finds icons there, and custom themes
/// often add `symbolic/ui` without listing it.
fn unlisted(&self) -> &[PathBuf] {
self.unlisted.get_or_init(|| {
let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect();
self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect()
})
}
}
/// A theme and the themes it inherits, in search order.
pub struct IconTheme {
themes: Vec<Theme>,
}
impl IconTheme {
/// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an
/// `index.theme`.
pub fn load(name: &str, bases: &[PathBuf]) -> Option<Self> {
let mut chain: Vec<String> = vec![name.to_string()];
let mut themes = vec![];
let mut i = 0;
loop {
let Some(theme) = chain.get(i).cloned() else {
// Every chain ends in hicolor, whether or not the themes before it exist.
if chain.iter().any(|t| t == "hicolor") {
break;
}
chain.push("hicolor".into());
continue;
};
i += 1;
if !valid_name(&theme) {
continue;
}
let roots: Vec<PathBuf> = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect();
// The first index wins; a theme can still be spread over several base directories.
let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue };
let index = String::from_utf8_lossy(&index);
let head = section(&index, "Icon Theme");
let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories"))
.chain(list(get("ScaledDirectories")))
.filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty()))
.take(MAX_DIRS)
.map(|d| (distance(&section(&index, d)), d))
.collect();
subdirs.sort_by_key(|(key, _)| *key);
// Not checked for existence: a theme lists hundreds, and the search stops at a hit.
let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect();
for parent in list(get("Inherits")) {
if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) {
chain.push(parent.to_string());
}
}
themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() });
}
(!themes.is_empty()).then_some(Self { themes })
}
/// The first `<icon>.svg` in search order: each theme's listed directories, then its unlisted
/// ones, before its parent.
pub fn find_svg(&self, icon: &str) -> Option<PathBuf> {
if !valid_name(icon) {
return None;
}
let file = format!("{icon}.svg");
let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file());
self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted())))
}
}
/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an
/// unreadable SVG or an absurd size.
pub fn rasterize_mask(svg: &[u8], px: u32) -> Option<Vec<u8>> {
use resvg::{tiny_skia, usvg};
if !(1..=256).contains(&px) {
return None;
}
// Glyphs are paths: no `<image>` is loaded, so an icon can't make us read another file
// (`/dev/zero`, a huge picture) or nest an SVG.
let opts = usvg::Options {
image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) },
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(svg, &opts).ok()?;
let size = tree.size();
let side = size.width().max(size.height());
if !side.is_finite() || side <= 0.0 {
return None;
}
let s = px as f32 / side;
let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0);
let mut pixmap = tiny_skia::Pixmap::new(px, px)?;
resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut());
Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect())
}
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway icon tree: `files` are (path under the base, contents).
fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for (path, text) in files {
let p = base.join(path);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
base
}
const SQUARE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M4 4h8v8H4z" fill="#2e3436"/></svg>"##;
#[test]
fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() {
let base = tree(
"chain",
&[
("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"),
("Child/48/a-symbolic.svg", "big"),
// Not in the index: still found, and before the parent's copy.
("Child/symbolic/ui/d-symbolic.svg", SQUARE),
("Parent/ui/d-symbolic.svg", SQUARE),
("Child/16/a-symbolic.svg", SQUARE),
("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"),
("Parent/ui/b-symbolic.svg", SQUARE),
("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"),
("hicolor/s/c-symbolic.svg", SQUARE),
],
);
let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads");
assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px");
assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg")));
assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg")));
assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own");
assert_eq!(theme.find_svg("missing"), None);
assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None);
assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers");
assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none());
let _ = std::fs::remove_dir_all(base);
}
/// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic.
#[test]
fn cyclic_or_hostile_indexes_are_harmless() {
let base = tree(
"hostile",
&[
(
"A/index.theme",
"[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n",
),
("A/ok/x.svg", SQUARE),
("B/index.theme", "[Icon Theme]\nInherits=A\n"),
],
);
let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads");
assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg")));
assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn distance_follows_the_spec() {
assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1));
assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1));
assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2));
assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1));
assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1));
assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default");
}
#[test]
fn masks_are_square_coverage() {
let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders");
assert_eq!(mask.len(), 32 * 32);
assert_eq!(mask[16 * 32 + 16], 255, "inside the square");
assert_eq!(mask[0], 0, "outside it");
assert!(rasterize_mask(b"not svg", 16).is_none());
assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none());
assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none());
}
/// An icon's `<image>` is never loaded: not a device file, not an embedded picture.
#[test]
fn masks_load_no_images() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
<image href="/dev/zero" width="16" height="16"/>
<image xlink:href="data:image/svg+xml;utf8,&lt;svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'&gt;&lt;rect width='16' height='16'/&gt;&lt;/svg&gt;" width="16" height="16"/>
</svg>"##;
let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders");
assert!(mask.iter().all(|a| *a == 0), "nothing drawn");
}
#[test]
fn base_dirs_follow_xdg() {
let var = |k: &str| match k {
"HOME" => Some("/home/u".to_string()),
"XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()),
_ => None,
};
let dirs = base_dirs(&var);
let want: Vec<PathBuf> = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect();
assert_eq!(dirs, want);
assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]);
}
}
+19 -96
View File
@@ -2,13 +2,7 @@
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [files…]`
//!
//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how
//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`;
//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`;
//! `photocraft_ui_egui::stroke_trace`).
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
//!
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
@@ -32,14 +26,11 @@ mod appearance;
#[cfg(target_os = "macos")]
mod apple_events;
mod caps_lock;
mod caption_style;
mod control_server;
mod crash_guard;
mod cursor;
mod file_dialog_probe;
mod gpu_startup;
#[cfg(target_os = "linux")]
mod icon_theme;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
@@ -50,7 +41,6 @@ mod linux_libs;
mod logging;
mod monitor_profile;
mod photoshop_settings;
mod presentation;
mod screen_color;
mod services;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
@@ -82,14 +72,11 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
/// different offset every launch (#419). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
let viewport = egui::ViewportBuilder::default();
// Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
eframe::NativeOptions {
viewport: viewport
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon::window_icon())
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
@@ -103,21 +90,11 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
// eframe saves native window geometry and egui panel/window sizes on exit.
// Keep that state beside preferences, including config overrides and portable mode.
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
window_builder: Some(Box::new(restored_window)),
window_builder: Some(Box::new(at_least_min_size)),
..Default::default()
}
}
/// The window eframe restores from `ui.ron`, corrected before it is created: at least the
/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but
/// eframe also saves and restores full screen, and a window closed in a full-screen mode is
/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps
/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode ›
/// Full Screen did nothing until `F` had cycled through Standard.
fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
at_least_min_size(builder).with_fullscreen(false)
}
/// Grow a restored window that is smaller than the minimum size, keeping its centre. On
/// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has
/// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened
@@ -198,7 +175,6 @@ fn main() -> eframe::Result {
// reported in a notice once the app is up; a control token is a usage error like a bad port.
let mut unreadable_paths: Vec<String> = Vec::new();
let mut safe_gpu = false;
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args_os().skip(1);
while let Some(a) = args.next() {
@@ -219,18 +195,6 @@ fn main() -> eframe::Result {
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
Some("--safe-gpu") => safe_gpu = true,
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => present_mode_arg = Some(v),
"--frame-latency" => frame_latency_arg = Some(v),
_ => stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => in_window_menus = true,
Some("--version") => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
@@ -353,9 +317,6 @@ fn main() -> eframe::Result {
}
// eframe restores the saved window layout before our code runs; drop values that would crash it.
ui_state::sanitize(options.persistence_path.as_deref());
// Centre only the first window: eframe centres after restoring the saved position, so a
// window closed on another monitor reopened in the middle of the main one (#4826).
options.centered = !ui_state::has_saved_position(options.persistence_path.as_deref());
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
// driver moves to a safer one), and lock this start's marker until the first frames render.
let t_sentinel = std::time::Instant::now();
@@ -384,21 +345,6 @@ fn main() -> eframe::Result {
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync;
}
// Testers' present mode and frame latency overrides (#1343), after the preference.
let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref());
options.wgpu_options.surface = present.apply(options.wgpu_options.surface);
let present_desc = presentation::describe(&options.wgpu_options.surface, &present);
// Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out.
let uncapped = !matches!(
options.wgpu_options.surface.present_mode,
eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync
);
log::info!("presentation: {present_desc}");
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
@@ -427,7 +373,6 @@ fn main() -> eframe::Result {
}
}
let mut app = PhotocraftApp::new(Session::new(), services);
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = custom_titlebar;
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
@@ -459,10 +404,6 @@ fn main() -> eframe::Result {
// composites on the CPU).
let info = &mut app.perf.gpu_info;
info.preference = pref.name().to_string();
info.present = present_desc.clone();
if let Some(v) = &stroke_trace_arg {
app.stroke_trace.set(v);
}
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
@@ -495,13 +436,24 @@ fn main() -> eframe::Result {
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
}
app.perf.span("gpuSentinel", sentinel_ms);
// CPU rendering after a failed GPU start is temporary. Only the
// recovery-dialog choice may persist it (#3483).
let remember = gpu_startup::backend_to_remember(&plan);
// Once the first frames rendered: clear the marker, and keep a crash fallback.
let remember_cpu =
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
let remember = plan.remember.then_some(plan.backend);
app.on_started(move |app| {
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
s.finish();
}
if remember_cpu
&& let Err(error) = app.run(
"prefs.set",
serde_json::json!({"values": {
"performance.renderingMode": "cpu", "performance.useGpu": false
}}),
)
{
log::warn!("couldn't remember CPU recovery: {error}");
}
if let Some(b) = remember
&& app.session.prefs().performance.gpu_backend != b
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
@@ -530,10 +482,6 @@ fn main() -> eframe::Result {
// The custom title bar's undecorated window must stay borderless, or its caption and
// border offset the content and the pointer on some Windows systems (#2246).
app.services.window_frame = window_frame::service(cc, custom_titlebar);
// Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them.
if custom_titlebar {
app.services.caption_style = caption_style::service();
}
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
// precise crosshair for painting tools while it is toggled on (#1758).
app.services.caps_lock = caps_lock::service(cc);
@@ -676,22 +624,6 @@ mod tests {
assert_eq!(super::at_least_min_size(builder).inner_size, None);
}
#[test]
fn a_window_saved_in_full_screen_reopens_maximized_not_full_screen() {
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
// What eframe restores from a ui.ron saved in a full-screen mode on Windows.
let saved = super::native_options(super::CUSTOM_TITLEBAR)
.viewport
.with_inner_size([2560.0, 1392.0])
.with_position([0.0, 0.0])
.with_fullscreen(true)
.with_maximized(true);
let opened = hook(saved);
assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts");
assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept");
assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0)));
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
@@ -712,13 +644,4 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
fn the_desktop_entry_names_the_window_class() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.photocraft.desktop");
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
assert_eq!(class, Some(super::APP_ID));
}
}
-121
View File
@@ -1,121 +0,0 @@
//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel
//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`,
//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo
//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may
//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a
//! warning in the log, never a failed start; a mode the driver may not offer is never requested.
//! The result is logged at start and shown in the stroke trace.
use eframe::egui_wgpu::SurfaceConfig;
use eframe::wgpu::PresentMode;
/// The present mode and frame latency this launch asked for (`None`: the default).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Overrides {
pub mode: Option<PresentMode>,
pub latency: Option<u32>,
}
/// Parse the two variables' values. Returns the overrides and a warning per rejected value.
pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec<String>) {
let mut warnings = Vec::new();
let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
let m = match v.to_ascii_lowercase().as_str() {
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
"fifo" | "vsync" => PresentMode::Fifo,
"autovsync" => PresentMode::AutoVsync,
"novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync,
_ => {
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
return None;
}
};
Some(m)
});
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::<u32>() {
Ok(n @ 1..=3) => Some(n),
_ => {
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
None
}
});
(Overrides { mode, latency }, warnings)
}
impl Overrides {
/// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` /
/// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged.
pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self {
let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok();
let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok();
let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref()));
for w in warnings {
log::warn!("{w}");
}
o
}
/// `surface` with the overrides applied.
pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig {
SurfaceConfig {
present_mode: self.mode.unwrap_or(surface.present_mode),
desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency),
}
}
}
/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with
/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency.
pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String {
let mode = match surface.present_mode {
PresentMode::AutoVsync => "autovsync",
PresentMode::AutoNoVsync => "autonovsync",
PresentMode::Fifo => "fifo",
PresentMode::FifoRelaxed => "fiforelaxed",
PresentMode::Immediate => "immediate",
PresentMode::Mailbox => "mailbox",
};
let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string());
let tag = |set: bool| if set { " (override)" } else { "" };
format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_parse_and_bad_ones_are_ignored_with_a_warning() {
assert_eq!(parse(None, None), (Overrides::default(), vec![]));
assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![]));
let (o, w) = parse(Some(" Mailbox "), Some("1"));
assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0));
assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo));
// Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support.
for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] {
assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}");
}
for (mode, latency) in
[(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))]
{
let (o, w) = parse(mode, latency);
assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}");
}
}
#[test]
fn overrides_replace_only_what_they_set_and_are_described() {
let default = SurfaceConfig::HIGH_THROUGHPUT;
assert_eq!(Overrides::default().apply(default), default);
let latency_only = Overrides { mode: None, latency: Some(1) };
let s = latency_only.apply(default);
assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1)));
assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)");
let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None };
assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2");
let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None };
assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default");
}
}
+25 -193
View File
@@ -16,13 +16,13 @@ use std::sync::Arc;
#[cfg(test)]
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
"arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -43,7 +43,6 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
("All Formats", open_filter_extensions(OPEN_EXTS)),
("PhotoCraft", open_filter_extensions(&["pcraft"])),
("OpenRaster", open_filter_extensions(&["ora"])),
("Krita", open_filter_extensions(&["kra", "krz"])),
("SVG", open_filter_extensions(SVG_EXTS)),
]
}
@@ -53,7 +52,6 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
/// keeps defaulting to PSD.
const SAVE_FILTERS: &[(&str, &[&str])] = &[
("PSD Document", &["psd", "psb"]),
("PDF", &["pdf"]),
("PhotoCraft", &["pcraft"]),
("OpenRaster", &["ora"]),
("PNG", &["png"]),
@@ -61,13 +59,8 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("WebP", &["webp"]),
("TIFF", &["tif"]),
("Targa", &["tga"]),
// Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha.
("BMP", &["bmp"]),
("GIF", &["gif"]),
("ICO", &["ico"]),
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -83,11 +76,8 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
let mut v: Vec<(String, Vec<String>)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect();
match v.iter().position(|(_, exts)| exts.contains(&ext)) {
Some(i) => {
let (name, mut exts) = v.remove(i);
// Windows adds the first type's first extension to a bare name: `.psb`, not `.psd`.
exts.retain(|e| *e != ext);
exts.insert(0, ext);
v.insert(0, (name, exts));
let f = v.remove(i);
v.insert(0, f);
}
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
None => {}
@@ -95,21 +85,6 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
v
}
/// The name the save dialog starts with. On Windows a visible extension stays put when the user
/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed
/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog
/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A
/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the
/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked.
fn dialog_file_name(suggested: &str, windows: bool) -> Option<String> {
let path = Path::new(suggested);
let name = path.file_name()?.to_string_lossy().into_owned();
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
}
/// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog
/// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned
/// modal window elsewhere), and a helper thread waits for it and hands the answer to the app.
@@ -140,19 +115,12 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
}
}
FileDialogRequest::Folder { initial_dir } => {
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
}
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
dialog = dialog.set_file_name(name);
if let Some(name) = Path::new(&suggested).file_name() {
dialog = dialog.set_file_name(name.to_string_lossy());
}
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
dialog = dialog.set_directory(dir);
@@ -229,7 +197,19 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
settings.io_options()
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts
}
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
@@ -336,43 +316,6 @@ fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
})
}
/// Largest clipboard image payload read on Wayland (1 GiB, a 16K × 16K RGBA BMP), so no real
/// screenshot is turned away, while a broken or hostile source can't make us allocate without end.
#[cfg(target_os = "linux")]
const MAX_CLIPBOARD_IMAGE_BYTES: u64 = 4 * 16384 * 16384;
/// The clipboard image arboard can't read on Wayland (#2546): arboard 3.6 asks for `image/png`
/// only, so a screenshot tool or clipboard manager that offers just `image/jpeg`, `image/bmp` or
/// `image/webp` pasted nothing. Reads the other `image/*` types through the same data-control
/// protocol arboard uses, and decodes them with our own codecs. `None` off Wayland.
#[cfg(target_os = "linux")]
fn wayland_clipboard_image() -> Option<(u32, u32, Vec<u8>)> {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
let offers = get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).ok()?;
image_from_offers(&offers, |mime| {
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
let mut bytes = Vec::new();
pipe.take(MAX_CLIPBOARD_IMAGE_BYTES + 1).read_to_end(&mut bytes).ok()?;
(bytes.len() as u64 <= MAX_CLIPBOARD_IMAGE_BYTES).then_some(bytes)
})
}
/// The first clipboard offer, in the source's order, that `fetch` returns as a decodable image,
/// upright as RGBA8. Only `image/*` types are fetched, so text offers are never transferred.
#[cfg(any(target_os = "linux", test))]
fn image_from_offers(offers: &[String], mut fetch: impl FnMut(&str) -> Option<Vec<u8>>) -> Option<(u32, u32, Vec<u8>)> {
offers.iter().filter(|mime| mime.starts_with("image/")).find_map(|mime| {
let bytes = fetch(mime)?;
// TGA has no magic number (see `image_from_files`), so trust it only when the type says so.
if photocraft_codecs::detect(&bytes)? == photocraft_codecs::Format::Tga && !mime.contains("tga") {
return None;
}
let img = photocraft_codecs::decode(&bytes).ok()?;
Some((img.width(), img.height(), img.to_rgba8()))
})
}
/// The shell command that starts this install under XWayland, where winit delivers file drops
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
@@ -473,13 +416,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
}
if let Ok(img) = cb.get_image() {
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
}
#[cfg(target_os = "linux")]
return wayland_clipboard_image();
#[cfg(not(target_os = "linux"))]
None
let img = cb.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
})
}),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
@@ -717,67 +655,11 @@ mod tests {
.join(" ");
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
for (name, ext) in asked.iter().zip(exts) {
// The first type's first extension is the one Windows adds to the bare name.
assert_eq!(save_filters(name)[0].1.first().map(String::as_str), Some(ext), "{name}: {:?}", save_filters(name)[0]);
assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]);
}
assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default");
}
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
#[test]
fn save_as_offers_only_writable_formats_including_ico() {
// Layered formats, and PDF, are written by `photocraft-io`, not a codec.
let layered = ["psd", "psb", "pcraft", "ora", "pdf"];
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| !layered.contains(e)) {
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
for ext in ["ico", "gif", "bmp"] {
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
}
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
// What Save As writes there: an icon up to 256×256, and a clear error past that.
let doc = |side: u32| {
let mut s = Session::new();
s.execute("file.new", json!({"width": side, "height": side})).unwrap();
s.active().unwrap().doc.clone()
};
let opts = export_options(&photocraft_ui_egui::ExportSettings::default());
let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap();
assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header");
let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string();
assert!(err.contains("256"), "{err}");
}
/// #4394: with File name extensions shown, Windows kept `image.psd` when the type was changed to
/// PhotoCraft. Given the bare `image`, it adds the extension of whichever type is picked
/// (checked by hand with rfd 0.17.2 on Windows 11), defaulting to the first type's.
#[test]
fn the_windows_save_dialog_gets_a_bare_name_so_the_type_picks_the_extension() {
for (suggested, name, first) in [
("/pics/image.psd", "image", "psd"),
("/pics/image.psb", "image", "psb"),
("/pics/image.pcraft", "image", "pcraft"),
("/pics/sky.fit", "sky", "fit"),
("cat.jpeg", "cat", "jpeg"),
("swatches.aco", "swatches", "aco"),
] {
assert_eq!(dialog_file_name(suggested, true).as_deref(), Some(name), "{suggested}");
assert_eq!(save_filters(suggested)[0].1[0], first, "{suggested}");
// Elsewhere the dialogs keep the whole name (see `dialog_file_name`).
let whole = Path::new(suggested).file_name().unwrap().to_string_lossy().into_owned();
assert_eq!(dialog_file_name(suggested, false), Some(whole), "{suggested}");
}
// Kept whole: a second dot (Windows could keep `scan.jpg`), the user's upper case, no
// extension, and a dotfile.
for name in ["scan.jpg.psd", "my.photo.psd", "transparent.PNG", "Untitled", ".psd"] {
assert_eq!(dialog_file_name(name, true).as_deref(), Some(name));
}
assert_eq!(dialog_file_name("", true), None);
}
#[test]
fn block_on_waits_for_a_wake_from_another_thread() {
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
@@ -896,7 +778,6 @@ mod tests {
let mut app = launch(&recovery);
new_doc(&mut app, "#ff0000");
let revision = app.session.active().unwrap().revision;
assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0");
autosave(&mut app, &ctx);
let mut failed = false;
@@ -917,16 +798,12 @@ mod tests {
std::fs::remove_file(&recovery).unwrap();
std::fs::create_dir(&recovery).unwrap();
autosave(&mut app, &ctx);
// The worker commits the bundle's manifest before it publishes the sidecar, and
// `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so
// the first non-empty listing can come from the middle of the write. Wait for the write
// to finish: the entry then carries the sidecar's revision.
let mut recovered = Vec::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
prefs_ui::tick(&mut app, &ctx);
recovered = list_recovery(&recovery);
if recovered.iter().any(|e| e.info.revision == revision) {
if !recovered.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
@@ -1001,51 +878,6 @@ mod tests {
assert!(image_from_files(&[bad]).is_none());
}
fn encoded(format: photocraft_codecs::Format, w: u32, h: u32) -> Vec<u8> {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap()
}
#[test]
fn non_png_clipboard_images_paste() {
// #2546: a screenshot tool that offers only image/bmp or image/jpeg pasted nothing.
let bmp = encoded(photocraft_codecs::Format::Bmp, 3, 2);
let offers = vec!["text/plain".to_string(), "image/bmp".to_string()];
let mut asked = Vec::new();
let got = image_from_offers(&offers, |mime| {
asked.push(mime.to_string());
Some(bmp.clone())
});
assert_eq!(got, Some((3, 2, [255u8, 0, 0, 255].repeat(6))));
assert_eq!(asked, ["image/bmp"], "text offers are never fetched");
let jpeg = encoded(photocraft_codecs::Format::Jpeg, 16, 8);
let offers = vec!["image/jpeg".to_string()];
assert_eq!(image_from_offers(&offers, |_| Some(jpeg.clone())).map(|(w, h, _)| (w, h)), Some((16, 8)));
}
#[test]
fn undecodable_clipboard_offers_fall_through_to_the_next() {
let bmp = encoded(photocraft_codecs::Format::Bmp, 2, 2);
let cut = encoded(photocraft_codecs::Format::Png, 8, 8)[..20].to_vec();
let offers: Vec<String> = ["image/webp", "image/png", "image/x-tga", "image/bmp"].map(String::from).to_vec();
let got = image_from_offers(&offers, |mime| match mime {
"image/webp" => None,
"image/png" => Some(cut.clone()),
"image/x-tga" => Some(b"not an image".to_vec()),
_ => Some(bmp.clone()),
});
assert_eq!(got.map(|(w, h, _)| (w, h)), Some((2, 2)));
assert!(image_from_offers(&offers[..3], |_| Some(cut.clone())).is_none());
assert!(image_from_offers(&[], |_| Some(bmp.clone())).is_none());
}
#[test]
fn clipboard_tga_is_trusted_only_by_its_type() {
assert_eq!(image_from_offers(&["image/x-tga".to_string()], |_| Some(tga_1x1())), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_offers(&["image/x-unknown".to_string()], |_| Some(tga_1x1())).is_none());
}
#[test]
fn tga_is_trusted_only_with_its_extension() {
let dir = temp("tga");
+6 -67
View File
@@ -64,33 +64,15 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<
#[cfg(any(target_os = "linux", test))]
const BTN_TOOL_PEN: u32 = 0x140;
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
#[cfg(any(target_os = "linux", test))]
const ABS_X: u32 = 0x00;
#[cfg(any(target_os = "linux", test))]
const ABS_Y: u32 = 0x01;
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`
/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard
/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but
/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a
/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639).
/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line
/// is its key bitmap as hex words of the kernel's `long`, most significant first.
#[cfg(any(target_os = "linux", test))]
pub fn has_pen(devices: &str) -> bool {
devices.split("\n\n").any(|device| {
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
})
}
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
/// significant first. Short or malformed bitmaps have no bits set.
#[cfg(any(target_os = "linux", test))]
fn bit_set(bitmap: &str, bit: u32) -> bool {
let bits = usize::BITS;
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX);
devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| {
keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1)
})
}
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
@@ -256,49 +238,6 @@ mod tests {
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
assert!(!has_pen(keys), "{keys:?}");
}
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
}
}
/// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has
/// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window
/// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are
/// the device's own, from `/proc/bus/input/devices`.
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
N: Name=\"RustDesk UInput Keyboard\"\n\
P: Phys=\n\
S: Sysfs=/devices/virtual/input/input54\n\
U: Uniq=\n\
H: Handlers=sysrq rfkill kbd event256 leds \n\
B: PROP=0\n\
B: EV=20013\n\
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\
B: MSC=10\n\
B: LED=7\n";
#[test]
fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() {
assert!(
bit_set(
"ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe",
BTN_TOOL_PEN
),
"the fixture claims the key"
);
assert!(!has_pen(VIRTUAL_KEYBOARD));
// Beside a real pen, the pen is still found; the keyboard's key bits never leak into the
// pen's device and the pen's axes never into the keyboard's.
let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}");
assert!(has_pen(&both));
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions");
// A pen display or pen-enabled touchscreen reports the pen with its own axes.
let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n";
assert!(has_pen(pen_display));
// Axes without `ABS_X` or `ABS_Y` don't count.
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
}
#[test]
+1 -33
View File
@@ -27,23 +27,6 @@ pub fn sanitize(path: Option<&Path>) {
}
}
/// Whether `path` holds a window position from an earlier session, which eframe restores. Then
/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry,
/// so it moved the window back to the centre of the main monitor on every launch (#4826).
pub fn has_saved_position(path: Option<&Path>) -> bool {
let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false };
saved_position(&text)
}
fn saved_position(text: &str) -> bool {
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
let Some(window) = kv.get("window").and_then(|w| ron::from_str::<ron::Value>(w).ok()) else { return false };
// eframe restores the inner position on macOS and the outer position elsewhere
// (`egui_winit::WindowSettings::initialize_viewport_builder`).
let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" };
matches!(field(&window, key), Some(ron::Value::Option(Some(_))))
}
/// `Err` names the first value that would crash startup.
fn check(text: &str) -> Result<(), String> {
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
@@ -95,7 +78,7 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
#[cfg(test)]
mod tests {
use super::{check, saved_position};
use super::check;
fn file(window: &str, zoom: &str) -> String {
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
@@ -122,21 +105,6 @@ mod tests {
}
}
#[test]
fn a_saved_window_position_is_detected() {
// #4826: the position from the report (Linux Mint, window moved to the third monitor).
let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(saved_position(&file(window, "1.0")));
let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(!saved_position(&file(no_position, "1.0")));
assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry");
for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] {
assert!(!saved_position(text), "{text:?}");
}
assert!(!super::has_saved_position(None));
assert!(!super::has_saved_position(Some(&std::env::temp_dir().join("photocraft-missing-ui.ron"))));
}
#[test]
fn unreadable_files_are_left_to_eframe() {
// eframe ignores files and entries it can't parse, so they can't crash it.
+38 -74
View File
@@ -81,16 +81,17 @@ mod platform {
HwndPlace::None,
POINT::new(),
SIZE::new(),
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// How far a parked maximize reaches past a monitor/work-area edge: the resize
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
/// parked but being dragged or snapped, and is left alone.
const FRAME: i32 = 32;
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
/// [`clamped_height`] for this frame's window: trim a maximized window whose rect runs under
/// the taskbar back to the work area plus its frame (#3144). No-op otherwise.
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
if !hwnd.IsZoomed() {
return;
@@ -99,58 +100,34 @@ mod platform {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
let Some((left, top, right, bottom)) =
clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
else {
let Some(height) = clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) else {
return;
};
let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else {
return;
};
// Moving the outer frame while winit pins the client rect to the work area must
// not copy the old frame's pixels into the custom title bar.
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT { x: left, y: top },
SIZE { cx: width, cy: height },
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
POINT::new(),
SIZE { cx: r.right - r.left, cy: height },
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
let _ = hwnd.InvalidateRect(None, false);
}
/// Return the corrected outer rect in desktop coordinates, preserving the resize border.
/// Each edge must be parked just outside either the monitor or work-area edge. Accepting
/// both makes the guard idempotent and handles Windows already correcting some edges.
/// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed.
fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> {
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it
/// already does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at
/// its maximize place. `monitor` is the monitor's full rect and `work` its work area (the
/// monitor less the taskbar) in the same coordinates. The parked check is against the full
/// monitor, because that is where Windows puts a maximize; the trim is against the work area.
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
let (l, t, r, b) = rect;
let (ml, mt, mr, mb) = monitor;
let (wl, wt, wr, wb) = work;
if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb {
let (ml, mt, mr, _) = monitor;
let (_, _, _, wb) = work;
let side = ml - l;
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
return None;
}
// Infer the frame from an edge without a taskbar. On an obstructed edge, the
// monitor and work-area offsets can both look like valid borders (e.g. auto-hide).
let border = [
(ml == wl).then(|| ml.checked_sub(l)).flatten(),
(mt == wt).then(|| mt.checked_sub(t)).flatten(),
(mr == wr).then(|| r.checked_sub(mr)).flatten(),
(mb == wb).then(|| b.checked_sub(mb)).flatten(),
]
.into_iter()
.flatten()
.filter(|n| (0..=FRAME).contains(n))
.min()?;
let parked = |monitor: Option<i32>, work: Option<i32>| monitor == Some(border) || work == Some(border);
if !parked(ml.checked_sub(l), wl.checked_sub(l))
|| !parked(mt.checked_sub(t), wt.checked_sub(t))
|| !parked(r.checked_sub(mr), r.checked_sub(wr))
|| !parked(b.checked_sub(mb), b.checked_sub(wb))
{
if b <= wb + side {
return None;
}
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
(corrected != rect).then_some(corrected)
Some(wb + side - t)
}
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
@@ -162,7 +139,7 @@ mod platform {
#[cfg(test)]
mod tests {
use super::{borderless, clamped_rect};
use super::{borderless, clamped_height};
use winsafe::co;
#[test]
@@ -179,47 +156,34 @@ mod platform {
}
#[test]
fn a_maximize_respects_taskbars_on_every_edge() {
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
// 1920 × 1080 with a 34 pt taskbar: a caption-less maximize covers the whole
// monitor, taskbar included (#3144). Trimmed so the rect ends at the work area
// plus the frame the window was parked with.
let monitor = (0, 0, 1920, 1080);
let rect = (-8, -8, 1928, 1088);
for (work, expected) in [
((0, 0, 1920, 1046), (-8, -8, 1928, 1054)),
((0, 48, 1920, 1080), (-8, 40, 1928, 1088)),
((0, 2, 1920, 1080), (-8, -6, 1928, 1088)),
((0, 0, 1920, 1078), (-8, -8, 1928, 1086)),
((48, 0, 1920, 1080), (40, -8, 1928, 1088)),
((0, 0, 1872, 1080), (-8, -8, 1880, 1088)),
] {
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected");
}
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
}
#[test]
fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() {
let monitor = (-1920, -1080, 0, 0);
let work = (-1920, -1032, 0, 0);
for border in [0, 8, 16, 32] {
let rect = (-1920 - border, -1080 - border, border, border);
let expected = (-1920 - border, -1032 - border, border, border);
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None);
}
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
// What Windows sizes a framed maximize to: the work area plus the frame.
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
}
#[test]
fn a_maximize_without_a_reserved_taskbar_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
// Taskbar on the left: the work area starts further right, but the monitor's bottom
// is still clear, so the rect reaches nothing.
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
}
#[test]
fn a_maximize_being_dragged_or_snapped_is_left_alone() {
fn a_maximize_being_dragged_away_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 48, 1920, 1080);
assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
}
}
}
+1 -1
View File
@@ -10,5 +10,5 @@
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a10 10 0 0 0 0 20z" fill="currentColor" stroke="none" />
<path d="M12 18a6 6 0 0 0 0-12v12z" />
</svg>

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 285 B

-19
View File
@@ -1,19 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<defs>
<linearGradient id="g" x1="3" y1="0" x2="20" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.9" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
<rect width="18" height="16" x="3" y="4" rx="2" fill="url(#g)" />
</svg>

Before

Width:  |  Height:  |  Size: 549 B

-16
View File
@@ -1,16 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
</svg>

Before

Width:  |  Height:  |  Size: 466 B

-9
View File
@@ -20,15 +20,6 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile;
invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains
8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency
over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use
the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink
separations, black generation or native CMYK blending, and is not a claim of Photoshop parity.
## Implemented decode limits
`DecodeOptions::default()` applies `Limits` before decoded pixel allocation:
-53
View File
@@ -17,10 +17,6 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.jonathanwxh-cell]
display_name = "jonathnwxh"
sync_github_name = false
[people.Skyfuller1303]
display_name = "Skyfuller"
sync_github_name = true
@@ -108,57 +104,8 @@ sync_github_name = true
[people.timothydaly1]
display_name = "SyntaxError"
[people.atoslins]
real_name = "Atos Lins"
display_name = "Atos Lins"
sync_github_name = false
[people.axlecoffee]
sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.FredApps]
sync_github_name = true
[people.arthurdeka]
sync_github_name = true
[people.aweussom]
real_name = "Tommy Leonhardsen"
[people.Furash]
sync_github_name = true
[people.lucAmbr0]
sync_github_name = true
[people.Hevarh1]
sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.deadmeu]
sync_github_name = true
[people.megaDon101]
display_name = "megadon"
sync_github_name = false
[people.sssehnsuchttt]
sync_github_name = true
[people.VanTan666]
display_name = "VanTan"
sync_github_name = false
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
sync_github_name = true
[people.sidney]
real_name = "Sidney Markowitz"
sync_github_name = true
+1 -1
View File
@@ -16,7 +16,7 @@ synth = []
[dependencies]
crc32fast = "1.5"
flate2 = { workspace = true }
ruzstd = "0.9.1"
ruzstd = "0.9"
[dev-dependencies]
proptest = { workspace = true }
+4 -6
View File
@@ -16,7 +16,7 @@ Affinity files.
| Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
| Compound shapes (`Comp`, add and subtract) | one even-odd shape |
| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom |
| Vector masks (`AdCh` curves and shapes) | vector masks |
@@ -34,7 +34,7 @@ Affinity files.
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, unrecognized
parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with
parametric shapes (imported as their bounding ellipses), corner types other than round, stars with
rounded points, several fills or strokes on one object (one of each is used), strokes behind the
fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved
outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's
@@ -132,10 +132,8 @@ test fixtures, not an exporter.
## Safety limits
Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import
and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels
are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB
zstd window, 4096 saved revisions, array lengths no longer than the
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
+1 -1
View File
@@ -6,5 +6,5 @@ use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = photocraft_affinity::container::header(data);
let _ = photocraft_affinity::preview(data);
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
});
+1 -1
View File
@@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target;
fuzz_target!(|stream: &[u8]| {
let _ = photocraft_affinity::stream::parse(stream);
let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
});
+1 -6
View File
@@ -26,16 +26,11 @@ pub struct Limits {
pub max_entry: usize,
/// Total bytes extracted over the whole import.
pub max_total: usize,
/// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the
/// temporary planes the layer being read decodes through. The entry limits bound what is
/// extracted, not what the layers materialized out of it cost (#4960).
pub max_pixel_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
// The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`).
Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 }
Self { max_entry: 256 << 20, max_total: 1 << 30 }
}
}
+3 -19
View File
@@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive, limits).document()
Reader::new(&s, &mut archive).document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
@@ -298,27 +298,11 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
/// far hold).
pixels_left: usize,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
}
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
/// output, which stays with the document; `planes` is the working set it decodes through and
/// releases. Every layer read so far is still live while this one decodes, so the peak is all
/// three together.
pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> {
let peak = rgba.saturating_add(planes);
if peak > self.pixels_left {
return Err(Error::Limit("decoded pixel layers exceed the pixel budget"));
}
self.pixels_left -= rgba;
Ok(())
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
-5
View File
@@ -117,11 +117,6 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
return Ok(None);
}
};
// A layer is materialized from its declared size, whatever the tiles hold: admit the output
// and the planes it decodes through before either is allocated (#4960).
let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?;
let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?;
r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?;
let mut planes = Vec::with_capacity(channels);
let mut cache = HashMap::new();
for c in 1..=channels {
+41 -74
View File
@@ -51,44 +51,28 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let types = match last(r, shape, b"CTyp") {
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
_ => Vec::new(),
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
let radius = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
_ => None,
};
let radii = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
_ => Vec::new(),
};
// Corners follow the first one (top left) unless they were unlocked; then each has
// its own radius and type, clockwise from the top left.
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
let i = if locked { 0 } else { i };
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
});
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
} else {
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
r.warn("rectangles with concave or other corner types (imported with round corners)");
if let (true, Some(f)) = (rounded, radius) {
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
{
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
}
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
cornered_rect(
b,
corners.map(|(f, t)| {
let (crx, cry) = if !absolute {
(f * w.min(h), f * w.min(h))
} else if sx > 0.0 && sy > 0.0 {
// An absolute radius is in document pixels, whatever the node's scale.
(f / sx, f / sy)
} else {
(0.0, 0.0)
};
(crx, cry, t == Some(1))
}),
)
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
// An absolute radius is in document pixels, whatever the node's scale.
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
} else {
(f * w.min(h), f * w.min(h))
};
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
} else {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
}
}
b"ShpE" => ellipse(cx, cy, rx, ry),
@@ -131,21 +115,13 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
}))?
}
b"ShPi" => {
let raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
// large enough that floating-point addition no longer changes the endpoint.
let start = raw_start.rem_euclid(TAU);
let delta = raw_end - raw_start;
let sweep = if raw_end >= raw_start {
delta.min(TAU)
} else if delta.is_finite() {
delta.rem_euclid(TAU)
} else {
(raw_end.rem_euclid(TAU) - start).rem_euclid(TAU)
};
let start = num(r, shape, b"AngS").unwrap_or(0.0);
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
while end < start {
end += TAU;
}
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
pie(cx, cy, rx, ry, start, start + sweep, inner)
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
}
b"ShpT" => {
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
@@ -370,31 +346,22 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
}
}
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
/// one side shrink in proportion until they do.
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
let [tl, tr, br, bl] = corners;
let (w, h) = (x1 - x0, y1 - y0);
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
// (where the corner leaves the previous side, its two handles, where it joins the next side)
let arcs = [
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
];
let mut segments = Vec::with_capacity(8);
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
segments.push([s, s, s]);
if s != e {
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
}
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
SubPath {
start: pt(x0 + rx, y0),
segments: vec![
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
],
closed: true,
}
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
}
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
+2 -191
View File
@@ -251,9 +251,9 @@ mod archive {
broken[90] ^= 0x55;
let mut a = Archive::open(&broken, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err());
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap();
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap();
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
a.read("doc.dat").unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
@@ -349,192 +349,3 @@ mod archive {
}
}
}
mod shapes {
use crate::container::Limits;
use crate::model::{Document, Kind, Path, Point, SubPath};
use crate::stream::Tag;
use crate::synth::{self, F, Method, tag};
fn shape(id: u32, class: &[u8; 4], parameters: Vec<(Tag, F)>) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))],
)
}
/// Reads a document whose one page holds the object `top` from `definitions`, on a thread of
/// its own, and fails the test when reading takes more than 20 s.
fn read(definitions: Vec<F>, top: u32) -> Document {
let data = synth::stream(&[
(tag(b"Defs"), F::Shared(definitions)),
(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))],
)]),
)],
),
),
]);
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(crate::model::read(&bytes, Limits::default())).ok());
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap()
}
fn path(document: &Document) -> &Path {
match &document.spreads[0].nodes[0].kind {
Kind::Shape { path, .. } => path,
other => panic!("expected shape, got {other:?}"),
}
}
#[test]
fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() {
let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100);
for subpath in &path(&document).subpaths {
assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite());
assert!(subpath.segments.len() <= 20);
assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite()));
}
}
#[test]
fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
}
/// A 10×20 rectangle (`ShNR`) with the given parameters, read back as its outline.
fn rectangle(parameters: Vec<(Tag, F)>) -> (SubPath, Vec<String>) {
let node =
F::Def(3, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(4, vec![tag(b"ShNR")], parameters))]);
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![node]))]);
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
let doc = crate::read(&synth::container(&[("doc.dat", &data, Method::Zlib)], None), Limits::default()).unwrap();
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected a shape") };
(path.subpaths[0].clone(), doc.warnings)
}
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
fn corner_types(ids: [u16; 4]) -> F {
let mut raw = vec![0xaa];
raw.extend(tag(b"CTyp").0.to_le_bytes());
raw.extend(4u32.to_le_bytes());
raw.extend(0u16.to_le_bytes());
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
F::Raw(raw)
}
#[test]
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
let p = |x: f64, y: f64| Point { x, y };
// Relative radii scale by 10. Round 5, round 1, straight 3, none.
let corners = |lock: Option<bool>| {
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
rectangle(fields)
};
let (sub, warnings) = corners(Some(false));
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(sub.start, p(5.0, 0.0));
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
// Locked (the default): every corner follows the top left one.
let (sub, _) = corners(None);
assert_eq!(sub.segments.len(), 8);
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
}
}
/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive
/// extracts; these cover what the decoded layers and their working planes cost, which is declared
/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all.
mod pixels {
use crate::Error;
use crate::container::Limits;
use crate::model::{Document, Kind};
use crate::synth::{self, F, Method, tag};
/// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles.
fn layer(id: u32, w: i32, h: i32) -> F {
F::Def(
id,
vec![tag(b"Rstr")],
vec![
(tag(b"Desc"), F::Str(format!("Layer {id}"))),
(tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])),
],
)
}
/// A document whose one page holds a pixel layer per `sizes` entry.
fn document(sizes: &[(i32, i32)]) -> Vec<u8> {
let children: Vec<F> = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect();
let data = synth::stream(&[(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))],
)]),
)],
),
)]);
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
}
fn imported_layers(d: &Document) -> usize {
d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count()
}
/// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two.
const ONE_LAYER: usize = 40 << 10;
#[test]
fn a_pixel_layer_within_the_budget_imports() {
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap();
assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings);
}
#[test]
fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() {
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
// The document is a few hundred bytes: the sizes are in the headers, not the entries.
let bytes = document(&[(64, 64), (64, 64)]);
assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len());
assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized");
// Two layers fit once the budget covers their pixels together.
let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() };
assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2);
}
#[test]
fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() {
// 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget.
let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() };
assert!(
matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))),
"16 MiB of declared pixels materialized under a 1 MiB budget"
);
}
}
-16
View File
@@ -59,22 +59,6 @@ fn main() {
prev = p;
}
println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst);
// One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow
// (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole
// stroke, and how far the two results differ.
let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
let mut fields = Vec::new();
let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] };
for step in [2.0, 60.0] {
drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect();
let t = Instant::now();
let mut f = LiquifyField::new(b, cell);
f.apply_stroke(&drag);
println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
fields.push(f);
}
let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement());
st.tool = LiquifyTool::Bloat;
let t = Instant::now();
let mut d = Rect::EMPTY;
+6 -21
View File
@@ -427,7 +427,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
F::Crosshatch => (g("strokeLength") / 3.0) as i32 + gs(1.0) + 2,
F::DarkStrokes => 9,
F::InkOutlines => (g("strokeLength") / 2.0) as i32 + 3,
F::Spatter => (g("sprayRadius") * 1.5).ceil() as i32 + 2,
F::Spatter => g("sprayRadius").ceil() as i32 + 2,
F::SprayedStrokes => g("sprayRadius").ceil() as i32 + (g("strokeLength") / 2.0) as i32 + 3,
F::SumiE => gs(g("strokeWidth") * 0.2) + (g("strokeWidth") / 2.0) as i32 + 2,
F::Glass => (g("distortion") * 2.0).ceil() as i32 + 2,
@@ -768,26 +768,9 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
win.c[i] = map(win.c[i], |v| (v + (1.0 - v) * lt) * (1.0 - dk) * (1.0 - clamp01(ink)));
}
}
F::Spatter => {
// Smoothness smooths the random displacement: a larger feature size and, as with any
// averaged noise, a smaller reach. Low smoothness sprays specks out to the full
// radius; high smoothness leaves a torn but attached, hard edge (what Photoshop
// shows at Smoothness 15).
F::Spatter | F::SprayedStrokes => {
let rad = g("sprayRadius");
let feature = 1.0 + g("smoothness") * 0.35;
let reach = (rad * 1.2 / feature).min(rad);
let src = Win { r: win.r, w, h, c: win.c.clone() };
for i in 0..win.c.len() {
let (x, y) = win.xy(i);
let (fx, fy) = (x as f32 / feature, y as f32 / feature);
let dx = (fbm(fx, fy, 91) - 0.5) * 3.0 * reach;
let dy = (fbm(fx, fy, 92) - 0.5) * 3.0 * reach;
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
}
}
F::SprayedStrokes => {
let rad = g("sprayRadius");
let sc = 1.2;
let sc = if e.filter == F::Spatter { 0.6 + g("smoothness") * 0.25 } else { 1.2 };
let src = Win { r: win.r, w, h, c: win.c.clone() };
for i in 0..win.c.len() {
let (x, y) = win.xy(i);
@@ -796,7 +779,9 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let dy = (vnoise(fx, fy, 92) - 0.5) * 2.0 * rad;
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
}
line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize);
if e.filter == F::SprayedStrokes {
line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize);
}
}
F::SumiE => {
let sw = g("strokeWidth");
+2 -3
View File
@@ -992,9 +992,8 @@ mod tests {
let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas);
for (x, y) in [(0, 0), (18, 11), (36, 22)] {
let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y));
assert!((m[3] - (o[3] + f[3]) / 2.0).abs() < 1e-5, "({x},{y}) alpha");
for c in 0..3 {
assert!((m[c] * m[3] - (o[c] * o[3] + f[c] * f[3]) / 2.0).abs() < 1e-5, "({x},{y}) premultiplied channel {c}");
for c in 0..4 {
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
}
}
let cancel = || true;
+1 -1
View File
@@ -169,7 +169,7 @@ fn apply_inner(
return None;
}
if let Some(selection) = selection {
crate::mix_selection(&mut data, output, selection, &src, fmt.alpha);
crate::mix_selection(&mut data, output, selection, &src);
}
if ctl.cancelled() {
return None;
+2 -2
View File
@@ -280,7 +280,7 @@ fn motion_fft_pipeline_supports_depths_models_edges_selections_and_tile_seams()
for mask in [None, Some(&selection)] {
let mut expected_data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut expected_data, rect, mask, &src, alpha);
crate::mix_selection(&mut expected_data, rect, mask, &src);
}
let mut expected = source.clone();
expected.write_region(rect, &expected_data);
@@ -362,7 +362,7 @@ fn motion_fft_production_crosses_real_tile_seams_and_leaves_other_pixels_untouch
let mut expected = source.clone();
let mut data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut data, area, mask, &src, true);
crate::mix_selection(&mut data, area, mask, &src);
}
expected.write_region(area, &data);
let expected = Image::read(&expected, check);
+1 -1
View File
@@ -69,7 +69,7 @@ fn row_surface(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rec
let ctx = Ctx { bounds, mode: format.mode, alpha: format.alpha };
let mut data = motion_rows(&src, area, &ctx, *angle, *distance);
if let Some(selection) = selection {
crate::mix_selection(&mut data, area, selection, &src, format.alpha);
crate::mix_selection(&mut data, area, selection, &src);
}
let mut result = surface.clone();
result.write_region(area, &data);
+24 -87
View File
@@ -9,10 +9,9 @@
//! with the guided filter); negative amounts add haze toward the atmospheric light.
//! 3. **Tone** on luminance with colour ratios kept: Highlights / Shadows as exposure changes
//! weighted by an edge-preserving base layer (guided filter: K. He, J. Sun, X. Tang, *Guided
//! Image Filtering*, ECCV 2010), so local contrast survives; Contrast is an S-curve about
//! middle grey; Clarity (large radius, midtone weighted) and Texture (small radius) add
//! band-pass local contrast. Whites / Blacks follow as Photoshop's Camera Raw applies them:
//! image-adaptive global curves measured on Photoshop 25.4 (see `whites_blacks`).
//! Image Filtering*, ECCV 2010), so local contrast survives; Whites / Blacks move the ends of
//! the curve; Contrast is an S-curve about middle grey; Clarity (large radius, midtone
//! weighted) and Texture (small radius) add band-pass local contrast.
//! 4. **Presence**: Vibrance (weighted toward less saturated colours) and Saturation.
//! 5. **Tone Curve**: parametric regions (Highlights, Lights, Darks, Shadows with movable splits)
//! then point curves (master and per channel), monotone cubic interpolation (F. Fritsch,
@@ -55,38 +54,6 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
mod local_tone;
mod whites_blacks;
/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8,
/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Encoding(f32);
impl Encoding {
pub(crate) fn of(gamma: f32) -> Self {
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
}
pub(crate) fn decode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
}
pub(crate) fn encode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
}
}
fn srgb_encode(v: f32) -> f32 {
linear_to_srgb(v)
}
fn srgb_decode(v: f32) -> f32 {
srgb_to_linear(v)
}
fn luma_linear(c: [f32; 3]) -> f32 {
luma(c)
}
/// One colour grading wheel.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -169,9 +136,6 @@ pub struct CameraRaw {
pub seed: u32,
/// Pixel-size multiplier for previews on downsampled proxies (radii in px scale by it).
pub pixel_scale: f32,
/// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8
/// for ProPhoto). Set from the document's profile; Whites and Blacks act on light.
pub encoding_gamma: f32,
}
impl Default for CameraRaw {
@@ -227,7 +191,6 @@ impl Default for CameraRaw {
vignette_style: "highlightPriority".into(),
seed: 0,
pixel_scale: 1.0,
encoding_gamma: 0.0,
}
}
}
@@ -528,11 +491,7 @@ impl CameraRaw {
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
}
fn tone_neutral(&self) -> bool {
self.local_tone_neutral() && self.whites == 0.0 && self.blacks == 0.0
}
/// Contrast, Highlights, Shadows, Texture and Clarity (the luminance stage).
fn local_tone_neutral(&self) -> bool {
[self.contrast, self.highlights, self.shadows, self.texture, self.clarity].iter().all(|v| *v == 0.0)
[self.contrast, self.highlights, self.shadows, self.whites, self.blacks, self.texture, self.clarity].iter().all(|v| *v == 0.0)
}
fn hsl_neutral(&self) -> bool {
self.hsl_hue.iter().chain(&self.hsl_sat).chain(&self.hsl_lum).all(|v| *v == 0.0)
@@ -602,13 +561,16 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
if p.dehaze != 0.0 {
dehaze(px, w, h, p.dehaze / 100.0);
}
// 3. Tone. Highlights and Shadows: a local Laplacian filter (see `local_tone`).
if p.highlights != 0.0 || p.shadows != 0.0 {
local_tone::apply(px, w, h, p.shadows, p.highlights, Encoding::of(p.encoding_gamma));
}
if !p.local_tone_neutral() && [p.contrast, p.texture, p.clarity].iter().any(|v| *v != 0.0) {
// 3. Tone on luminance.
if !p.tone_neutral() {
let l: Vec<f32> = px.iter().map(|q| luma([q[0], q[1], q[2]]).max(0.0)).collect();
let lc: Vec<f32> = l.iter().map(|v| v.min(1.0)).collect();
let base = if p.highlights != 0.0 || p.shadows != 0.0 {
let r = ((long * 0.015) as usize).max(2);
Some(guided_fast(&lc, &lc, w, h, r, 0.02))
} else {
None
};
let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps)));
let k_con = p.contrast / 100.0 * 0.6;
@@ -618,6 +580,12 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let i = y * w + x;
let l0 = l[i];
let mut lv = lc[i];
if let Some(b) = &base {
let bv = b[i].clamp(0.0, 1.0);
let hm = smoothstep(0.45, 0.95, bv);
let sm = 1.0 - smoothstep(0.05, 0.5, bv);
lv *= 2f32.powf(p.highlights / 100.0 * 1.2 * hm + p.shadows / 100.0 * 1.4 * sm);
}
if let Some(c) = &clar {
let mw = 1.0 - (2.0 * lv.clamp(0.0, 1.0) - 1.0).powi(2);
lv += p.clarity / 100.0 * 0.8 * (lc[i] - c[i]) * mw;
@@ -625,6 +593,12 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
if let Some(t) = &tex {
lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1);
}
if p.whites != 0.0 {
lv += p.whites / 100.0 * 0.25 * smoothstep(0.4, 1.0, lv);
}
if p.blacks != 0.0 {
lv += p.blacks / 100.0 * 0.15 * (1.0 - smoothstep(0.0, 0.45, lv));
}
if k_con != 0.0 {
let xx = lv.clamp(0.0, 1.0);
lv += -k_con * (tau * xx).sin() / tau;
@@ -637,13 +611,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
}
});
}
// 3b. Whites and Blacks: global curves adapted to the image's black and white points.
if p.whites != 0.0 || p.blacks != 0.0 {
let enc = Encoding::of(p.encoding_gamma);
let (bp, wp) = luminance_points(px, w, h, enc);
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
whites_blacks::apply(px, w, xw, xb, enc);
}
// 4–7: per-pixel colour stages.
let need_color = p.vibrance != 0.0 || p.saturation != 0.0 || !p.curves_neutral() || !p.hsl_neutral() || !p.grade_neutral();
if need_color {
@@ -765,36 +732,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
}
}
/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the
/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a
/// preview proxy and the full image agree).
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) {
let f = w.max(h).div_ceil(256).max(1);
let (sw, sh) = (w.div_ceil(f), h.div_ceil(f));
let mut sum = vec![[0.0f64; 4]; sw * sh];
for y in 0..h {
for x in 0..w {
let q = px[y * w + x];
let s = &mut sum[(y / f) * sw + x / f];
for c in 0..3 {
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
}
s[3] += 1.0;
}
}
let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
if cells.is_empty() {
return (0.0, 1.0);
}
let pct = |mut v: Vec<f32>, q: f32| {
v.sort_by(f32::total_cmp);
v[((v.len() - 1) as f32 * q).round() as usize]
};
let bp = pct(cells.iter().map(|c| luma(*c)).collect(), 0.03);
let wp = pct(cells.iter().map(|c| c[0].max(c[1]).max(c[2])).collect(), 0.995);
(linear_to_srgb(bp), linear_to_srgb(wp))
}
fn value_noise(x: f32, y: f32, seed: u64) -> f32 {
let (xi, yi) = (x.floor(), y.floor());
let (tx, ty) = (x - xi, y - yi);
-428
View File
@@ -1,428 +0,0 @@
//! Shadows and Highlights as a local Laplacian filter.
//!
//! Adobe has said Camera Raw's process version 2012 rests on local Laplacian filters (S. Paris,
//! S. W. Hasinoff, J. Kautz, *Local Laplacian Filters: Edge-aware Image Processing with a
//! Laplacian Pyramid*, SIGGRAPH 2011). This follows that paper, in its fast form (M. Aubry et
//! al., *Fast Local Laplacian Filters*, ACM TOG 2014: the remapping sampled at a few reference
//! values, each pyramid coefficient interpolated between them), on the log2 luminance:
//!
//! - **Shadows** raises pixels darker than their surroundings, by an amount set by their own and
//! their surroundings' place in the image's range (1st to 99th percentile); **Highlights**
//! pulls pixels brighter than their surroundings toward them. Differences under a threshold
//! are left alone, so fine detail stays.
//! - Shadows then keeps the image's 99th percentile, Highlights its 1st (Photoshop keeps the
//! brightest content under Shadows and the darkest under Highlights).
//! - A uniform image is unchanged, edges keep no halo, and the result does not depend on the
//! image size.
//!
//! The strengths were fitted to Photoshop 25.4's Camera Raw Filter on ten public-domain photos
//! (black-box measurements, no Adobe code or data). It is an approximation: on those photos the
//! mean error against Photoshop is 2.8–9.4 levels (of 255) where the previous heuristic's was
//! 3.7–14.3.
use super::{Encoding, luma};
use crate::photo_util::par_rows;
/// Spacing of the fast filter's reference values in stops. Its remapping changes within about
/// half a stop, and coefficients interpolated across wider gaps show as contour lines on smooth
/// gradients (12 references over a whole raw's range left visible steps; at an eighth of a stop a
/// ramp's slope varies by under a third, as the remapping itself makes it).
const REF_STEP: f32 = 0.125;
/// The filter runs on a copy at most this many pixels across; the change it makes is brought back
/// to full size by a guided upsampling, so a preview and the full image agree.
const WORK_SIDE: usize = 1600;
/// Knots of the strength curves over the surroundings' place in the image range (0 … 1).
const KNOTS: usize = 5;
/// Shadows: how far a pixel darker than its surroundings rises, in stops per unit slider, as
/// a function of its own place in the image range (`own`) times a factor of its surroundings'
/// place (`around`); it rises fully once it is `sigma` stops below them. The lift depends on
/// the pixel's tone, not on the size of the difference, so it is the same at every pyramid
/// level and a big edge keeps no halo. Lowering (negative values) is `down` times as strong.
const SHADOWS: Lift = Lift { own: [-0.017, 0.466, 0.886, 1.221, -0.081], around: [4.566, 4.067, 1.088, 0.149, 0.878], sigma: 0.571, down: 0.808 };
const HIGHLIGHTS: Side = Side { pull: [0.179, 0.141, 0.196, 0.629, 2.07], push: [0.156, 0.08, 0.175, 0.049, 1.261], sigma: 0.05 };
/// See [`SHADOWS`].
struct Lift {
own: [f32; KNOTS],
around: [f32; KNOTS],
sigma: f32,
down: f32,
}
/// Highlights: strengths per unit slider over the surroundings' place in the image range, for
/// lowering (`pull`, toward the surroundings) and raising (`push`), and the detail threshold in
/// stops.
struct Side {
pull: [f32; KNOTS],
push: [f32; KNOTS],
sigma: f32,
}
/// One channel, `w × h`.
struct Plane {
w: usize,
h: usize,
v: Vec<f32>,
}
/// Mirrors `i` into `0..n` (the edge sample is not repeated).
fn mirror(i: isize, n: usize) -> usize {
let n = n as isize;
if n == 1 {
return 0;
}
let p = 2 * (n - 1);
let m = i.rem_euclid(p);
(if m >= n { p - m } else { m }) as usize
}
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
fn blur_step(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
// Columns first at the kept rows, then rows at the kept columns.
let mut tmp = vec![0.0f32; p.w * h2];
par_rows(&mut tmp, p.w, 1, |y2, row| {
let y = (y2 * step) as isize;
for (x, o) in row.iter_mut().enumerate() {
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
}
});
let mut out = vec![0.0f32; w2 * h2];
par_rows(&mut out, w2, 1, |y2, row| {
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
for (x2, o) in row.iter_mut().enumerate() {
let x = (x2 * step) as isize;
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
}
});
Plane { w: w2, h: h2, v: out }
}
/// The next coarser level.
fn down(p: &Plane) -> Plane {
blur_step(p, 2)
}
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
fn up(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
}
blur_step(&z, 1)
}
/// Levels down to about 2 pixels on the short side.
fn depth(w: usize, h: usize) -> usize {
let mut s = w.min(h);
let mut n = 1;
while s > 2 {
s = s.div_ceil(2);
n += 1;
}
n
}
fn gaussian(p: Plane, n: usize) -> Vec<Plane> {
let mut g = vec![p];
for _ in 1..n {
let next = down(&g[g.len() - 1]);
g.push(next);
}
g
}
/// The local Laplacian filter of `u` with `remap(value, reference)`; the coarsest level is kept.
fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
let n = depth(u.w, u.h);
let g = gaussian(Plane { w: u.w, h: u.h, v: u.v.clone() }, n);
// References span the image's tones; the deepest 0.1 % (noise down to the floor) share the
// lowest reference instead of stretching the spacing.
let hi = u.v.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
let lo = quantile(&u.v, 0.001).min(hi);
if hi - lo <= 1e-6 || (hi - lo).is_nan() {
return Plane { w: u.w, h: u.h, v: u.v.clone() };
}
let refs = (((hi - lo) / REF_STEP).ceil() as usize + 1).clamp(2, 96);
let step = (hi - lo) / (refs - 1) as f32;
let mut lap: Vec<Vec<f32>> = g[..n - 1].iter().map(|p| vec![0.0; p.v.len()]).collect();
for k in 0..refs {
let r = lo + step * k as f32;
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
par_rows(&mut m.v, u.w, 1, |_, row| {
for v in row.iter_mut() {
*v = remap(*v, r);
}
});
let gr = gaussian(m, n);
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
par_rows(&mut lap[l], lw, 1, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
if wgt > 0.0 {
*o += wgt * (fine[i] - coarse.v[i]);
}
}
});
}
}
let mut o = Plane { w: g[n - 1].w, h: g[n - 1].h, v: g[n - 1].v.clone() };
for l in (0..n - 1).rev() {
let mut next = up(&o, g[l].w, g[l].h);
for (a, b) in next.v.iter_mut().zip(&lap[l]) {
*a += b;
}
o = next;
}
o
}
/// The `q` quantile (0 … 1) of `v`.
fn quantile(v: &[f32], q: f32) -> f32 {
let mut s = v.to_vec();
s.sort_by(f32::total_cmp);
s[((s.len() - 1) as f32 * q).round() as usize]
}
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
fn range(u: &Plane) -> (f32, f32) {
let f = u.w.max(u.h).div_ceil(256).max(1);
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
let mut sum = vec![(0.0f64, 0u32); sw * sh];
for y in 0..u.h {
for x in 0..u.w {
let c = &mut sum[(y / f) * sw + x / f];
c.0 += f64::from(u.v[y * u.w + x]);
c.1 += 1;
}
}
let cells: Vec<f32> = sum.iter().filter(|c| c.1 > 0).map(|c| (c.0 / f64::from(c.1)) as f32).collect();
(quantile(&cells, 0.01), quantile(&cells, 0.99))
}
/// `d` past the detail threshold: about 0 below `sigma`, `d − sigma` well above it.
fn excess(d: f32, sigma: f32) -> f32 {
d - sigma * (d / sigma).tanh()
}
fn knot(t: f32, k: &[f32; KNOTS]) -> f32 {
let x = t.clamp(0.0, 1.0) * (KNOTS - 1) as f32;
let i = (x as usize).min(KNOTS - 2);
k[i] + (k[i + 1] - k[i]) * (x - i as f32)
}
/// The Highlights pass on the log luminance `u` (`amount` −1 … 1): pixels brighter than their
/// surroundings move toward them (or away, for positive values) by the part of the difference
/// past `sigma`, then the darkest content is put back where it was.
fn highlights_pass(u: &Plane, amount: f32, side: &Side) -> Plane {
let (lo, hi) = range(u);
let span = (hi - lo).max(1.0);
let strengths = if amount < 0.0 { &side.pull } else { &side.push };
let o = llf(u, |x, g| x + knot((g - lo) / span, strengths) * amount * excess((x - g).max(0.0), side.sigma));
let shift = quantile(&u.v, 0.01) - quantile(&o.v, 0.01);
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
}
/// The Shadows pass on the log luminance `u` (`amount` −1 … 1).
fn shadows_pass(u: &Plane, amount: f32) -> Plane {
let (lo, hi) = range(u);
let span = (hi - lo).max(1.0);
let k = if amount > 0.0 { amount } else { amount * SHADOWS.down };
let o = llf(u, |x, g| {
let below = ((g - x) / SHADOWS.sigma).clamp(0.0, 1.0);
if below == 0.0 {
return x;
}
let w = below * below * (3.0 - 2.0 * below);
x + k * knot((x - lo) / span, &SHADOWS.own) * knot((g - lo) / span, &SHADOWS.around) * w
});
// Keep the brightest content.
let shift = quantile(&u.v, 0.99) - quantile(&o.v, 0.99);
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
}
/// Applies Shadows and Highlights (−100 … 100) to straight RGBA pixels encoded with `enc`.
pub(crate) fn apply(px: &mut [[f32; 4]], w: usize, h: usize, shadows: f32, highlights: f32, enc: Encoding) {
if (shadows == 0.0 && highlights == 0.0) || w < 2 || h < 2 {
return;
}
const FLOOR: f32 = 1.0 / 65536.0;
let u = Plane { w, h, v: px.iter().map(|q| (luma([0, 1, 2].map(|c| enc.decode(q[c]))).max(0.0) + FLOOR).log2()).collect() };
// The filter on a copy at most WORK_SIDE across (box-averaged in log).
let f = w.max(h).div_ceil(WORK_SIDE).max(1);
let small = if f == 1 { Plane { w, h, v: u.v.clone() } } else { shrink(&u, f) };
let mut o = Plane { w: small.w, h: small.h, v: small.v.clone() };
if highlights != 0.0 {
o = highlights_pass(&o, highlights.clamp(-100.0, 100.0) / 100.0, &HIGHLIGHTS);
}
if shadows != 0.0 {
o = shadows_pass(&o, shadows.clamp(-100.0, 100.0) / 100.0);
}
// The change in log luminance, at full size.
let change: Vec<f32> = if f == 1 {
o.v.iter().zip(&u.v).map(|(a, b)| a - b).collect()
} else {
let d: Vec<f32> = o.v.iter().zip(&small.v).map(|(a, b)| a - b).collect();
guided_upsample(&small, &d, &u)
};
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
let gain = change[y * w + x].exp2();
for c in 0..3 {
q[c] = enc.encode(enc.decode(q[c]) * gain);
}
}
});
}
/// `u` averaged over `f × f` blocks.
fn shrink(u: &Plane, f: usize) -> Plane {
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
let mut v = vec![0.0f32; sw * sh];
par_rows(&mut v, sw, 1, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let (x0, y0) = (x * f, y * f);
let (x1, y1) = ((x0 + f).min(u.w), (y0 + f).min(u.h));
let mut s = 0.0f64;
for yy in y0..y1 {
for xx in x0..x1 {
s += f64::from(u.v[yy * u.w + xx]);
}
}
*o = (s / ((x1 - x0) * (y1 - y0)) as f64) as f32;
}
});
Plane { w: sw, h: sh, v }
}
/// The change `d` computed on `small` brought to the size of `full`, following its edges: a local
/// linear model of the change against the log luminance (the guided filter of K. He, J. Sun,
/// *Fast Guided Filter*, 2015), fitted on the small copy and evaluated on the full one.
fn guided_upsample(small: &Plane, d: &[f32], full: &Plane) -> Vec<f32> {
let (sw, sh) = (small.w, small.h);
let r = 2;
let eps = 0.01;
let mean = |v: &[f32]| super::box_mean(v, sw, sh, r);
let ud: Vec<f32> = small.v.iter().zip(d).map(|(a, b)| a * b).collect();
let uu: Vec<f32> = small.v.iter().map(|a| a * a).collect();
let (mu, md, mud, muu) = (mean(&small.v), mean(d), mean(&ud), mean(&uu));
let a: Vec<f32> = (0..sw * sh).map(|i| (mud[i] - mu[i] * md[i]) / (muu[i] - mu[i] * mu[i] + eps)).collect();
let b: Vec<f32> = (0..sw * sh).map(|i| md[i] - a[i] * mu[i]).collect();
let (ma, mb) = (mean(&a), mean(&b));
let (fx, fy) = (sw as f32 / full.w as f32, sh as f32 / full.h as f32);
let mut out = vec![0.0f32; full.w * full.h];
par_rows(&mut out, full.w, 1, |y, row| {
let sy = (y as f32 + 0.5) * fy - 0.5;
for (x, o) in row.iter_mut().enumerate() {
let sx = (x as f32 + 0.5) * fx - 0.5;
let av = crate::photo_util::bilinear(&ma, sw, sh, 1, 0, sx, sy);
let bv = crate::photo_util::bilinear(&mb, sw, sh, 1, 0, sx, sy);
*o = av * full.v[y * full.w + x] + bv;
}
});
out
}
#[cfg(test)]
mod tests {
use super::*;
const SRGB: Encoding = Encoding(0.0);
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
#[test]
fn a_flat_image_is_unchanged() {
let mut px = grey(64, 48, |_, _| 0.4);
apply(&mut px, 64, 48, 100.0, -100.0, SRGB);
assert!(px.iter().all(|q| (q[0] - 0.4).abs() < 1e-5));
}
#[test]
fn shadows_raise_a_dark_area_without_a_halo() {
// A 0.1 | 0.6 step (the dark half is the image's black point, which Photoshop barely
// moves either): the bright half stays, and no halo forms on either side of the edge.
let (w, h) = (256, 128);
let mut px = grey(w, h, |x, _| if x < w / 2 { 0.1 } else { 0.6 });
apply(&mut px, w, h, 100.0, 0.0, SRGB);
let row = &px[64 * w..65 * w];
assert!((row[w / 2 + 1][0] - 0.6).abs() < 0.002 && (row[w - 5][0] - 0.6).abs() < 0.002);
let (far, near) = (row[10][0], row[w / 2 - 1][0]);
assert!((far - 0.1).abs() < 0.004 && (far - near).abs() < 0.004, "{far} {near}");
}
#[test]
fn grey_moves_as_in_photoshop() {
// Mid-grey with 1 % black (0.015) and white (0.97) corners, Photoshop 25.4: Shadows +100
// takes the grey from 127.5 to 165.3, Highlights −100 to 89.6 (of 255).
let n = 256;
let image = |c: f32| {
grey(n, n, |x, y| {
if x >= n - 26 && y >= n - 26 {
0.97
} else if x >= n - 26 && y < 26 {
0.015
} else {
c
}
})
};
for (s, hl, want) in [(100.0, 0.0, 165.3), (0.0, -100.0, 89.6)] {
let mut px = image(0.5);
apply(&mut px, n, n, s, hl, SRGB);
let got = px[175 * n + 100][0] * 255.0;
assert!((got - want).abs() < 5.0, "{s} {hl}: {got} vs {want}");
}
}
#[test]
fn highlights_lower_a_bright_area_and_keep_the_darkest() {
let (w, h) = (192, 192);
let mut px = grey(w, h, |x, y| if (64..128).contains(&x) && (64..128).contains(&y) { 0.8 } else { 0.2 });
apply(&mut px, w, h, 0.0, -100.0, SRGB);
assert!(px[96 * w + 96][0] < 0.75, "{}", px[96 * w + 96][0]);
assert!((px[5 * w + 5][0] - 0.2).abs() < 0.01, "{}", px[5 * w + 5][0]);
}
#[test]
fn smooth_gradients_stay_smooth() {
// A soft ramp over eight stops, as in an out-of-focus background. Coefficients interpolated
// between references too far apart drew contour lines across such ramps (Shadows +100 on
// a D4 raw): the output must rise steadily, without ripples.
let (w, h) = (640, 48);
let mut px = grey(w, h, |x, _| crate::photo_util::linear_to_srgb((x as f32 / (w - 1) as f32 * 8.0 - 9.0).exp2()));
for (s, hl) in [(100.0, 0.0), (0.0, -100.0), (-100.0, 100.0)] {
let mut p = px.clone();
apply(&mut p, w, h, s, hl, SRGB);
let row: Vec<f32> = (0..w).map(|x| crate::photo_util::srgb_to_linear(p[24 * w + x][0]).max(1e-6).log2()).collect();
let steps: Vec<f32> = row.windows(2).map(|d| d[1] - d[0]).collect();
assert!(steps.iter().all(|d| *d > 0.0), "{s} {hl}: the ramp reverses");
let jumps: Vec<f32> = steps.windows(2).map(|d| (d[1] - d[0]).abs()).collect();
let ripple = jumps[16..jumps.len() - 16].iter().fold(0.0f32, |a, b| a.max(*b));
// The ramp rises 0.0125 stops per pixel; its slope may change by a third at most.
assert!(ripple < 0.004, "{s} {hl}: slope jumps by {ripple} stops per pixel");
}
px.clear();
}
#[test]
fn the_result_does_not_depend_on_the_image_size() {
let at = |n: usize| {
let mut px = grey(n, n, |x, y| if x < n / 3 || y < n / 4 { 0.08 } else { 0.7 });
apply(&mut px, n, n, 70.0, -60.0, SRGB);
[px[(n / 8) * n + n / 8][0], px[(3 * n / 4) * n + 3 * n / 4][0]]
};
let (a, b) = (at(96), at(384));
assert!((a[0] - b[0]).abs() < 0.02 && (a[1] - b[1]).abs() < 0.02, "{a:?} {b:?}");
}
}
-418
View File
@@ -1,418 +0,0 @@
//! Whites and Blacks as Adobe Camera Raw (process version 2012) applies them.
//!
//! Measured as a black box on the Camera Raw Filter of Photoshop 25.4: grey blocks with chosen
//! histograms went through the filter by script and came back exactly; no Adobe code or data
//! was used.
//!
//! - Both are global curves of the grey level, applied after Shadows and Highlights, Whites
//! first. On an image whose darkest and lightest percent reach black and white the curve of a
//! slider value `x` is the measured row at `x` of [`WHITES`] or [`BLACKS`].
//! - The direction that only compresses (Whites down, Blacks up) is that fixed curve on every
//! image. The direction that clips (Whites up, Blacks down) adapts: the row used is
//! `x · A(BP, WP)`. On the block images BP and WP are their darkest and lightest levels; on
//! photographs Photoshop's amounts follow best with BP the 3rd percentile of the luminance and
//! WP the 99.5th of the brightest channel, and Blacks at least 1.2 times. A low
//! white point makes Whites +100 stronger (up to 2.7 times), a raised black point Blacks
//! (up to 1.45 times at −40). Whites rows past +100 come from images where `A > 1`.
//! - Not reproduced: past −40 Photoshop's Blacks grows faster on images with a raised black
//! point (on one spanning 0.6–1 its −100 blacks out everything below 0.74, here below about
//! 0.15); its curves there are not rows of the full-range family.
//! - Colour: the clipping directions act on each channel, the compressing ones scale the pixel
//! by the curve's change of its luminance (closest to Photoshop on saturated colours).
use super::Encoding;
use crate::photo_util::par_rows;
/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded).
const STEPS: usize = 33;
/// The first row's internal amount; rows are 10 apart.
const WHITES_FROM: f32 = -100.0;
const BLACKS_FROM: f32 = -100.0;
// Measured levels; some happen to lie near π/8 and π/4.
/// Whites curves at internal amounts -100, -90, …, 270 (rows) over grey levels 0, 1/32, …, 1.
#[allow(clippy::approx_constant)]
static WHITES: [[f32; 33]; 38] = [
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2391, 0.2671, 0.2946, 0.3215, 0.3480, 0.3738, 0.3995, 0.4256, 0.4517, 0.4774, 0.5028,
0.5278, 0.5524, 0.5768, 0.6008, 0.6245, 0.6482, 0.6717, 0.6957, 0.7207, 0.7483, 0.7814, 0.8263, 0.8949, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2392, 0.2672, 0.2947, 0.3217, 0.3482, 0.3742, 0.4000, 0.4263, 0.4526, 0.4785, 0.5042,
0.5296, 0.5548, 0.5797, 0.6044, 0.6291, 0.6539, 0.6789, 0.7046, 0.7317, 0.7617, 0.7969, 0.8425, 0.9074, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2108, 0.2393, 0.2673, 0.2950, 0.3221, 0.3487, 0.3748, 0.4008, 0.4274, 0.4540, 0.4803, 0.5064,
0.5323, 0.5580, 0.5837, 0.6093, 0.6350, 0.6610, 0.6875, 0.7149, 0.7438, 0.7756, 0.8122, 0.8576, 0.9184, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2109, 0.2395, 0.2677, 0.2954, 0.3227, 0.3495, 0.3759, 0.4022, 0.4291, 0.4561, 0.4828, 0.5094,
0.5360, 0.5624, 0.5888, 0.6154, 0.6423, 0.6695, 0.6973, 0.7261, 0.7566, 0.7897, 0.8271, 0.8716, 0.9280, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1525, 0.1820, 0.2111, 0.2398, 0.2682, 0.2961, 0.3237, 0.3508, 0.3775, 0.4042, 0.4317, 0.4591, 0.4864, 0.5136,
0.5408, 0.5680, 0.5953, 0.6229, 0.6508, 0.6791, 0.7082, 0.7382, 0.7698, 0.8037, 0.8412, 0.8843, 0.9365, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0927, 0.1227, 0.1527, 0.1822, 0.2116, 0.2404, 0.2690, 0.2972, 0.3251, 0.3527, 0.3799, 0.4071, 0.4351, 0.4630, 0.4910, 0.5189,
0.5469, 0.5749, 0.6031, 0.6316, 0.6605, 0.6899, 0.7199, 0.7509, 0.7832, 0.8174, 0.8544, 0.8958, 0.9439, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0927, 0.1229, 0.1529, 0.1827, 0.2122, 0.2414, 0.2702, 0.2989, 0.3272, 0.3553, 0.3831, 0.4110, 0.4396, 0.4682, 0.4968, 0.5254,
0.5542, 0.5830, 0.6121, 0.6414, 0.6712, 0.7014, 0.7322, 0.7637, 0.7964, 0.8305, 0.8668, 0.9062, 0.9503, 0.9999,
],
[
0.0000, 0.0298, 0.0622, 0.0928, 0.1231, 0.1534, 0.1834, 0.2132, 0.2427, 0.2720, 0.3011, 0.3300, 0.3587, 0.3872, 0.4160, 0.4452, 0.4745, 0.5038, 0.5332,
0.5627, 0.5923, 0.6221, 0.6522, 0.6826, 0.7134, 0.7447, 0.7765, 0.8092, 0.8429, 0.8781, 0.9155, 0.9559, 1.0000,
],
[
0.0000, 0.0298, 0.0623, 0.0930, 0.1236, 0.1541, 0.1844, 0.2145, 0.2445, 0.2744, 0.3041, 0.3337, 0.3632, 0.3925, 0.4221, 0.4520, 0.4820, 0.5120, 0.5421,
0.5723, 0.6026, 0.6330, 0.6636, 0.6946, 0.7257, 0.7572, 0.7890, 0.8214, 0.8545, 0.8885, 0.9237, 0.9608, 1.0000,
],
[
0.0000, 0.0298, 0.0624, 0.0934, 0.1242, 0.1551, 0.1858, 0.2164, 0.2470, 0.2775, 0.3080, 0.3383, 0.3687, 0.3989, 0.4293, 0.4600, 0.4906, 0.5213, 0.5520,
0.5827, 0.6136, 0.6445, 0.6755, 0.7067, 0.7380, 0.7694, 0.8011, 0.8330, 0.8652, 0.8978, 0.9310, 0.9651, 1.0000,
],
[
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
],
[
0.0000, 0.0299, 0.0626, 0.0940, 0.1253, 0.1567, 0.1881, 0.2195, 0.2510, 0.2825, 0.3140, 0.3456, 0.3772, 0.4089, 0.4405, 0.4720, 0.5036, 0.5351, 0.5667,
0.5983, 0.6298, 0.6613, 0.6929, 0.7244, 0.7559, 0.7874, 0.8189, 0.8503, 0.8816, 0.9128, 0.9438, 0.9742, 1.0000,
],
[
0.0000, 0.0299, 0.0627, 0.0941, 0.1257, 0.1573, 0.1890, 0.2208, 0.2526, 0.2846, 0.3167, 0.3488, 0.3811, 0.4134, 0.4456, 0.4777, 0.5098, 0.5419, 0.5741,
0.6062, 0.6383, 0.6704, 0.7025, 0.7345, 0.7664, 0.7983, 0.8301, 0.8617, 0.8931, 0.9241, 0.9543, 0.9829, 1.0000,
],
[
0.0000, 0.0299, 0.0628, 0.0944, 0.1262, 0.1582, 0.1903, 0.2225, 0.2550, 0.2876, 0.3204, 0.3534, 0.3867, 0.4200, 0.4529, 0.4858, 0.5186, 0.5516, 0.5845,
0.6174, 0.6502, 0.6830, 0.7157, 0.7482, 0.7807, 0.8131, 0.8451, 0.8769, 0.9082, 0.9385, 0.9673, 0.9928, 1.0000,
],
[
0.0000, 0.0299, 0.0629, 0.0948, 0.1269, 0.1594, 0.1920, 0.2249, 0.2582, 0.2917, 0.3256, 0.3597, 0.3942, 0.4289, 0.4628, 0.4966, 0.5305, 0.5644, 0.5983,
0.6321, 0.6658, 0.6994, 0.7327, 0.7660, 0.7989, 0.8316, 0.8639, 0.8956, 0.9263, 0.9553, 0.9815, 1.0000, 1.0000,
],
[
0.0000, 0.0299, 0.0631, 0.0953, 0.1280, 0.1610, 0.1944, 0.2282, 0.2625, 0.2973, 0.3325, 0.3683, 0.4045, 0.4407, 0.4758, 0.5109, 0.5460, 0.5811, 0.6161,
0.6510, 0.6857, 0.7201, 0.7541, 0.7879, 0.8213, 0.8543, 0.8864, 0.9175, 0.9468, 0.9733, 0.9950, 1.0000, 1.0000,
],
[
0.0000, 0.0299, 0.0633, 0.0961, 0.1293, 0.1632, 0.1976, 0.2327, 0.2684, 0.3048, 0.3419, 0.3798, 0.4183, 0.4562, 0.4928, 0.5294, 0.5660, 0.6024, 0.6387,
0.6748, 0.7104, 0.7456, 0.7803, 0.8145, 0.8480, 0.8807, 0.9121, 0.9417, 0.9684, 0.9907, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0300, 0.0637, 0.0971, 0.1313, 0.1663, 0.2020, 0.2387, 0.2763, 0.3149, 0.3546, 0.3952, 0.4365, 0.4763, 0.5149, 0.5532, 0.5914, 0.6294, 0.6670,
0.7041, 0.7406, 0.7763, 0.8113, 0.8456, 0.8788, 0.9104, 0.9401, 0.9667, 0.9888, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0300, 0.0641, 0.0984, 0.1338, 0.1703, 0.2079, 0.2468, 0.2870, 0.3286, 0.3717, 0.4161, 0.4602, 0.5024, 0.5430, 0.5833, 0.6232, 0.6627, 0.7015,
0.7394, 0.7764, 0.8123, 0.8472, 0.8808, 0.9126, 0.9420, 0.9681, 0.9896, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0302, 0.0647, 0.1003, 0.1374, 0.1760, 0.2160, 0.2579, 0.3016, 0.3472, 0.3949, 0.4436, 0.4908, 0.5355, 0.5784, 0.6207, 0.6622, 0.7029, 0.7424,
0.7806, 0.8176, 0.8530, 0.8868, 0.9185, 0.9474, 0.9726, 0.9926, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0304, 0.0656, 0.1029, 0.1423, 0.1837, 0.2271, 0.2731, 0.3215, 0.3726, 0.4262, 0.4791, 0.5294, 0.5769, 0.6220, 0.6660, 0.7086, 0.7498, 0.7893,
0.8272, 0.8631, 0.8969, 0.9281, 0.9560, 0.9795, 0.9967, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0305, 0.0679, 0.1094, 0.1509, 0.1968, 0.2459, 0.2956, 0.3510, 0.4094, 0.4680, 0.5248, 0.5781, 0.6278, 0.6742, 0.7195, 0.7624, 0.8031, 0.8417,
0.8779, 0.9113, 0.9414, 0.9675, 0.9886, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0306, 0.0702, 0.1160, 0.1623, 0.2144, 0.2709, 0.3243, 0.3885, 0.4556, 0.5199, 0.5807, 0.6369, 0.6884, 0.7355, 0.7799, 0.8217, 0.8606, 0.8964,
0.9288, 0.9570, 0.9803, 0.9958, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0308, 0.0725, 0.1225, 0.1737, 0.2320, 0.2960, 0.3641, 0.4393, 0.5133, 0.5825, 0.6460, 0.7032, 0.7544, 0.8003, 0.8426, 0.8817, 0.9168, 0.9475,
0.9733, 0.9924, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0322, 0.0751, 0.1303, 0.1918, 0.2594, 0.3348, 0.4189, 0.5044, 0.5841, 0.6565, 0.7208, 0.7771, 0.8262, 0.8692, 0.9074, 0.9408, 0.9686, 0.9900,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0331, 0.0818, 0.1482, 0.2171, 0.2977, 0.3892, 0.4865, 0.5779, 0.6602, 0.7322, 0.7943, 0.8470, 0.8915, 0.9286, 0.9600, 0.9844, 0.9996, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0340, 0.0884, 0.1661, 0.2544, 0.3540, 0.4658, 0.5729, 0.6681, 0.7496, 0.8183, 0.8748, 0.9208, 0.9568, 0.9832, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.3356, 0.5799, 0.6166, 0.6166, 0.6166, 0.6166, 0.6624, 0.7556, 0.8321, 0.8935, 0.9411, 0.9755, 0.9970, 0.9998, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.5007, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.1655, 0.1655, 0.2972, 0.4728, 0.6327, 0.7616, 0.8598, 0.9317, 0.9790, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0457, 0.1830, 0.3836, 0.5790, 0.7379, 0.8549, 0.9360, 0.9869, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0520, 0.2329, 0.4864, 0.6989, 0.8449, 0.9403, 0.9884, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0773, 0.3027, 0.6040, 0.8058, 0.9302, 0.9930, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.1454, 0.4738, 0.7615, 0.9074, 0.9908, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0964, 0.5416, 0.8589, 0.9897, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.1538, 0.1538, 0.7080, 0.9596, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.8439, 0.8439, 0.8439, 0.9993, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.9228, 0.9228, 0.9228, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
];
/// Blacks curves at internal amounts -100, …, 100 in steps of 10.
#[allow(clippy::approx_constant)]
static BLACKS: [[f32; 33]; 21] = [
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0290, 0.0572, 0.0879, 0.1222, 0.1580, 0.1935, 0.2287, 0.2636, 0.2988, 0.3355, 0.3741, 0.4151, 0.4571,
0.4990, 0.5406, 0.5818, 0.6226, 0.6631, 0.7031, 0.7424, 0.7811, 0.8192, 0.8568, 0.8937, 0.9300, 0.9655, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0015, 0.0363, 0.0640, 0.0956, 0.1300, 0.1651, 0.2000, 0.2345, 0.2686, 0.3026, 0.3369, 0.3727, 0.4105, 0.4495, 0.4885,
0.5272, 0.5657, 0.6039, 0.6419, 0.6796, 0.7169, 0.7538, 0.7903, 0.8264, 0.8621, 0.8974, 0.9322, 0.9664, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0346, 0.0625, 0.0934, 0.1271, 0.1616, 0.1959, 0.2298, 0.2635, 0.2969, 0.3301, 0.3638, 0.3990, 0.4357, 0.4727, 0.5094,
0.5460, 0.5824, 0.6187, 0.6547, 0.6906, 0.7262, 0.7615, 0.7965, 0.8313, 0.8657, 0.8998, 0.9336, 0.9671, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0235, 0.0554, 0.0850, 0.1175, 0.1514, 0.1852, 0.2187, 0.2519, 0.2849, 0.3178, 0.3505, 0.3837, 0.4183, 0.4537, 0.4891, 0.5243,
0.5594, 0.5944, 0.6292, 0.6639, 0.6985, 0.7329, 0.7671, 0.8010, 0.8347, 0.8683, 0.9016, 0.9347, 0.9676, 1.0000,
],
[
0.0000, 0.0000, 0.0020, 0.0438, 0.0724, 0.1035, 0.1365, 0.1699, 0.2030, 0.2359, 0.2686, 0.3011, 0.3336, 0.3659, 0.3986, 0.4327, 0.4669, 0.5011, 0.5352,
0.5692, 0.6031, 0.6369, 0.6706, 0.7043, 0.7378, 0.7711, 0.8043, 0.8373, 0.8702, 0.9029, 0.9355, 0.9679, 1.0000,
],
[
0.0000, 0.0000, 0.0230, 0.0567, 0.0865, 0.1182, 0.1510, 0.1839, 0.2164, 0.2489, 0.2812, 0.3133, 0.3455, 0.3775, 0.4100, 0.4433, 0.4767, 0.5100, 0.5433,
0.5765, 0.6096, 0.6426, 0.6756, 0.7086, 0.7414, 0.7741, 0.8067, 0.8392, 0.8716, 0.9039, 0.9361, 0.9682, 1.0000,
],
[
0.0000, 0.0000, 0.0368, 0.0670, 0.0976, 0.1294, 0.1619, 0.1943, 0.2265, 0.2586, 0.2906, 0.3225, 0.3544, 0.3862, 0.4184, 0.4512, 0.4839, 0.5166, 0.5492,
0.5818, 0.6144, 0.6469, 0.6793, 0.7117, 0.7441, 0.7763, 0.8085, 0.8406, 0.8727, 0.9046, 0.9365, 0.9683, 1.0000,
],
[
0.0000, 0.0000, 0.0444, 0.0749, 0.1058, 0.1377, 0.1699, 0.2020, 0.2339, 0.2657, 0.2975, 0.3292, 0.3609, 0.3926, 0.4245, 0.4568, 0.4891, 0.5213, 0.5535,
0.5857, 0.6179, 0.6499, 0.6820, 0.7140, 0.7460, 0.7779, 0.8098, 0.8416, 0.8734, 0.9052, 0.9368, 0.9685, 1.0000,
],
[
0.0000, 0.0086, 0.0511, 0.0817, 0.1128, 0.1445, 0.1763, 0.2080, 0.2397, 0.2713, 0.3029, 0.3344, 0.3660, 0.3975, 0.4292, 0.4611, 0.4930, 0.5249, 0.5567,
0.5886, 0.6204, 0.6522, 0.6839, 0.7157, 0.7475, 0.7791, 0.8108, 0.8424, 0.8740, 0.9056, 0.9371, 0.9686, 1.0000,
],
[
0.0000, 0.0198, 0.0564, 0.0874, 0.1186, 0.1501, 0.1817, 0.2133, 0.2447, 0.2762, 0.3076, 0.3390, 0.3705, 0.4018, 0.4333, 0.4649, 0.4965, 0.5281, 0.5597,
0.5912, 0.6227, 0.6543, 0.6858, 0.7173, 0.7488, 0.7802, 0.8117, 0.8431, 0.8745, 0.9059, 0.9373, 0.9686, 1.0000,
],
[
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
],
[
0.0000, 0.0313, 0.0650, 0.0979, 0.1303, 0.1624, 0.1942, 0.2260, 0.2576, 0.2891, 0.3206, 0.3520, 0.3833, 0.4146, 0.4455, 0.4763, 0.5070, 0.5378, 0.5684,
0.5991, 0.6298, 0.6605, 0.6912, 0.7219, 0.7526, 0.7833, 0.8141, 0.8450, 0.8758, 0.9067, 0.9377, 0.9688, 1.0000,
],
[
0.0000, 0.0326, 0.0677, 0.1022, 0.1357, 0.1686, 0.2010, 0.2332, 0.2652, 0.2969, 0.3286, 0.3601, 0.3915, 0.4227, 0.4532, 0.4835, 0.5138, 0.5440, 0.5741,
0.6042, 0.6344, 0.6645, 0.6946, 0.7248, 0.7550, 0.7853, 0.8156, 0.8461, 0.8766, 0.9072, 0.9380, 0.9689, 1.0000,
],
[
0.0000, 0.0341, 0.0706, 0.1066, 0.1413, 0.1749, 0.2079, 0.2405, 0.2728, 0.3048, 0.3366, 0.3682, 0.3996, 0.4307, 0.4608, 0.4906, 0.5203, 0.5500, 0.5796,
0.6092, 0.6387, 0.6683, 0.6979, 0.7276, 0.7573, 0.7871, 0.8170, 0.8472, 0.8773, 0.9077, 0.9382, 0.9690, 1.0000,
],
[
0.0000, 0.0354, 0.0736, 0.1113, 0.1472, 0.1814, 0.2149, 0.2479, 0.2805, 0.3127, 0.3446, 0.3763, 0.4077, 0.4385, 0.4681, 0.4974, 0.5267, 0.5558, 0.5849,
0.6139, 0.6429, 0.6720, 0.7011, 0.7303, 0.7595, 0.7889, 0.8184, 0.8481, 0.8780, 0.9081, 0.9384, 0.9690, 1.0000,
],
[
0.0000, 0.0368, 0.0768, 0.1162, 0.1531, 0.1881, 0.2221, 0.2554, 0.2882, 0.3206, 0.3527, 0.3843, 0.4158, 0.4461, 0.4753, 0.5041, 0.5328, 0.5615, 0.5900,
0.6185, 0.6470, 0.6755, 0.7041, 0.7328, 0.7616, 0.7906, 0.8197, 0.8491, 0.8787, 0.9085, 0.9386, 0.9691, 1.0000,
],
[
0.0000, 0.0381, 0.0802, 0.1214, 0.1593, 0.1949, 0.2293, 0.2630, 0.2961, 0.3286, 0.3607, 0.3924, 0.4236, 0.4536, 0.4823, 0.5106, 0.5388, 0.5669, 0.5949,
0.6229, 0.6509, 0.6789, 0.7070, 0.7352, 0.7636, 0.7922, 0.8209, 0.8500, 0.8793, 0.9089, 0.9388, 0.9691, 1.0000,
],
[
0.0000, 0.0394, 0.0837, 0.1267, 0.1656, 0.2018, 0.2367, 0.2707, 0.3039, 0.3365, 0.3686, 0.4003, 0.4314, 0.4608, 0.4890, 0.5168, 0.5445, 0.5721, 0.5996,
0.6271, 0.6546, 0.6822, 0.7098, 0.7375, 0.7655, 0.7937, 0.8221, 0.8509, 0.8799, 0.9092, 0.9390, 0.9692, 1.0000,
],
[
0.0000, 0.0407, 0.0875, 0.1322, 0.1720, 0.2088, 0.2441, 0.2784, 0.3118, 0.3444, 0.3765, 0.4082, 0.4389, 0.4679, 0.4955, 0.5229, 0.5501, 0.5771, 0.6042,
0.6312, 0.6582, 0.6853, 0.7124, 0.7398, 0.7673, 0.7952, 0.8233, 0.8517, 0.8804, 0.9096, 0.9391, 0.9692, 1.0000,
],
[
0.0000, 0.0421, 0.0915, 0.1379, 0.1786, 0.2160, 0.2516, 0.2861, 0.3196, 0.3523, 0.3844, 0.4159, 0.4462, 0.4747, 0.5019, 0.5287, 0.5554, 0.5820, 0.6085,
0.6351, 0.6616, 0.6882, 0.7149, 0.7419, 0.7690, 0.7965, 0.8243, 0.8524, 0.8810, 0.9099, 0.9393, 0.9693, 1.0000,
],
[
0.0000, 0.0436, 0.0957, 0.1438, 0.1853, 0.2232, 0.2592, 0.2938, 0.3274, 0.3601, 0.3922, 0.4235, 0.4533, 0.4813, 0.5080, 0.5343, 0.5605, 0.5866, 0.6127,
0.6387, 0.6648, 0.6910, 0.7173, 0.7439, 0.7707, 0.7978, 0.8253, 0.8532, 0.8815, 0.9102, 0.9394, 0.9693, 1.0000,
],
];
const BPS: [f32; 9] = [0.0000, 0.0200, 0.0500, 0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000];
const WPS: [f32; 8] = [0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000, 0.9000, 1.0000];
static AW: [[f32; 8]; 9] = [
[1.7750, 2.3875, 2.4000, 2.1625, 1.9438, 1.6125, 1.3875, 1.0000],
[1.8125, 2.5250, 2.3937, 2.1688, 1.9375, 1.6062, 1.3938, 0.9875],
[2.7000, 2.4688, 2.2750, 2.0688, 1.8875, 1.6000, 1.3875, 0.9938],
[2.3250, 2.3250, 2.1875, 1.9750, 1.7937, 1.5188, 1.3250, 1.0437],
[2.0187, 2.0187, 2.0187, 1.8625, 1.7063, 1.4062, 1.2250, 0.9250],
[1.7500, 1.7500, 1.7500, 1.7500, 1.6438, 1.3562, 1.1687, 0.8625],
[1.4937, 1.4937, 1.4937, 1.4937, 1.4937, 1.2875, 1.1000, 0.7875],
[1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.0375, 0.7562],
[0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.7562],
];
static AB: [[f32; 8]; 9] = [
[0.9938, 0.9812, 1.0000, 1.0125, 0.9563, 0.9688, 1.0437, 1.0000],
[1.0437, 1.0250, 1.0125, 1.0000, 1.0000, 1.0375, 1.0000, 0.9938],
[1.2312, 1.2063, 1.1875, 1.1625, 1.1438, 1.1438, 1.1375, 1.1250],
[1.2500, 1.2500, 1.2312, 1.2125, 1.2125, 1.2375, 1.2375, 1.2375],
[1.1750, 1.1750, 1.1750, 1.1750, 1.2063, 1.2688, 1.3125, 1.3375],
[1.1812, 1.1812, 1.1812, 1.1812, 1.2125, 1.2812, 1.3313, 1.4125],
[1.2375, 1.2375, 1.2375, 1.2375, 1.2375, 1.3062, 1.3562, 1.4375],
[1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3687, 1.4500],
[1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.4312],
];
/// A curve row at a fractional internal amount `x`, at grey level `v` (both interpolated).
fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 {
if v >= 1.0 {
return 1.0;
}
let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32);
let i = (r as usize).min(rows.len() - 2);
let t = r - i as f32;
let at = |row: &[f32; STEPS]| {
let p = v.max(0.0) * (STEPS - 1) as f32;
let j = (p as usize).min(STEPS - 2);
let s = p - j as f32;
row[j] + (row[j + 1] - row[j]) * s
};
at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t
}
/// Whites at internal amount `x` (−100 … 270) of grey level `v`.
pub(crate) fn whites(v: f32, x: f32) -> f32 {
curve(&WHITES, WHITES_FROM, x, v)
}
/// Blacks at internal amount `x` (−100 … 100) of grey level `v`.
pub(crate) fn blacks(v: f32, x: f32) -> f32 {
curve(&BLACKS, BLACKS_FROM, x, v)
}
/// The least Blacks adaptation on photographs.
const PHOTO_BLACKS: f32 = 1.2;
/// Bilinear lookup of an adaptation table at black point `bp` and white point `wp`.
fn adapt(table: &[[f32; 8]; 9], bp: f32, wp: f32) -> f32 {
let idx = |grid: &[f32], v: f32| {
let v = v.clamp(grid[0], grid[grid.len() - 1]);
let i = grid.windows(2).position(|w| v <= w[1]).unwrap_or(grid.len() - 2);
(i, (v - grid[i]) / (grid[i + 1] - grid[i]))
};
let (i, s) = idx(&BPS, bp);
let (j, t) = idx(&WPS, wp);
let row = |i: usize| table[i][j] * (1.0 - t) + table[i][j + 1] * t;
row(i) * (1.0 - s) + row(i + 1) * s
}
/// The internal amounts of the Whites and Blacks sliders (−100 … 100) for an image whose 1st
/// and 99th luminance percentiles are the grey levels `bp` and `wp` (sRGB-encoded).
pub(crate) fn amounts(whites: f32, blacks: f32, bp: f32, wp: f32) -> (f32, f32) {
let w = if whites > 0.0 { whites * adapt(&AW, bp, wp) } else { whites };
// On photographs Photoshop's Blacks is about 1.2 times the block images' amount even where
// their black point is 0 (ten public-domain photos: 1.08–1.36).
let b = if blacks < 0.0 { blacks * adapt(&AB, bp, wp).max(PHOTO_BLACKS) } else { blacks };
(w.clamp(-100.0, 270.0), b.clamp(-100.0, 100.0))
}
/// Applies Whites and Blacks (internal amounts `w`, `b`) to straight RGBA pixels (`width` per
/// row) encoded with `enc`. Values above 1 (float documents) keep their excess.
pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Encoding) {
if w == 0.0 && b == 0.0 {
return;
}
// A grey level's new level, both sRGB-encoded.
let grey = |v: f32| {
let v = if w != 0.0 { whites(v, w) } else { v };
if b != 0.0 { blacks(v, b) } else { v }
};
let per_channel = w > 0.0 || b < 0.0;
par_rows(px, width, 1, |_, row| {
for q in row.iter_mut() {
let lin = [0, 1, 2].map(|c| enc.decode(q[c]));
if per_channel {
for c in 0..3 {
let over = (lin[c] - 1.0).max(0.0);
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
}
} else {
let y = super::luma_linear(lin).max(0.0);
if y <= 1e-7 {
continue;
}
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
let k = y2 / y;
for c in 0..3 {
q[c] = enc.encode(lin[c] * k);
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_range_rows_match_photoshop() {
// Photoshop 25.4 on a 64-level block image spanning black to white (sRGB levels).
assert!((whites(97.1 / 255.0, 100.0) * 255.0 - 137.4).abs() < 0.6);
assert!((whites(194.3 / 255.0, -100.0) * 255.0 - 167.6).abs() < 0.6);
assert!((blacks(97.1 / 255.0, -100.0) * 255.0 - 60.0).abs() < 0.6);
assert!((blacks(48.6 / 255.0, 100.0) * 255.0 - 67.0).abs() < 0.6);
// The compressing directions keep black and white.
assert_eq!(whites(1.0, -100.0), 1.0);
assert_eq!(blacks(0.0, 100.0), 0.0);
}
#[test]
fn the_clipping_directions_adapt_to_the_image() {
// A dark, low-contrast image needs more: Whites +50 acts like +94.5 on [0.05, 0.6].
let (w, _) = amounts(50.0, 0.0, 0.05, 0.6);
assert!((w - 94.5).abs() < 2.0, "{w}");
// A raised black point strengthens Blacks −40 to −58 on [0.6, 1].
let (_, b) = amounts(0.0, -40.0, 0.6, 1.0);
assert!((b + 58.0).abs() < 1.0, "{b}");
// Never below the photographs' 1.2.
assert!((amounts(0.0, -50.0, 0.0, 1.0).1 + 60.0).abs() < 1e-3);
// The compressing directions never adapt.
assert_eq!(amounts(-50.0, 50.0, 0.3, 0.6), (-50.0, 50.0));
}
}
+1 -174
View File
@@ -474,10 +474,8 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
let mut cost_fg = vec![0.0f32; sw * sh];
let mut cost_bg = vec![0.0f32; sw * sh];
let mut fixed = vec![FREE; sw * sh];
// Always the hole's own edge, and around a thin selection each part's surroundings.
let keep = thin_band(sw, sh, &shole);
for i in 0..sw * sh {
if shole[i] || dist[i] <= keep {
if shole[i] || dist[i] <= 1.5 {
fixed[i] = HARD_FG;
continue;
}
@@ -510,22 +508,6 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
Ok(out)
}
/// How far around a thin selection (strings, wires, and any small parts selected with them, all
/// at most a quarter as thick as the selection is long) the Auto area always reaches, in cells of
/// a `w × h` grid: as far as the selection is thick, so that each part, however far from the
/// rest, keeps its own surroundings to copy from. 1.5 cells for any other selection.
fn thin_band(w: usize, h: usize, hole: &[bool]) -> f32 {
let inside = crate::selection::edt(&hole.iter().map(|h| !h).collect::<Vec<_>>(), w, h);
let thickness = 2.0 * inside.iter().zip(hole).filter(|(_, h)| **h).map(|(d, _)| *d).fold(0.0f32, f32::max);
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for (i, _) in hole.iter().enumerate().filter(|(_, h)| **h) {
let (x, y) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
}
let extent = x1.saturating_sub(x0).max(y1.saturating_sub(y0)) as f32;
if 4.0 * thickness <= extent { thickness.max(1.5) } else { 1.5 }
}
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
/// of the copied content. Measured on Photoshop 25.4 with content brighter than its new place: 0
/// keeps it as it is; 3 moves it about 0.12 (32 of 42 levels, and 31 of 102: a limit, not a
@@ -680,26 +662,6 @@ mod tests {
assert!(err < 0.12, "mean error {err}");
}
/// A vertical sky gradient (each row one colour) filled at every adaptation level stays the
/// gradient: no tinted blotch where the copied rows' colours were shifted per channel.
#[test]
fn color_adaptation_leaves_a_smooth_gradient_untinted() {
let (w, h) = (120, 60);
let row = |y: usize| {
let t = y as f32 / (h - 1) as f32;
[0.27 + 0.45 * t, 0.53 + 0.32 * t, 0.86 + 0.1 * t]
};
let img: Vec<f32> = (0..w * h).flat_map(|i| row(i / w)).collect();
let hole: Vec<bool> = (0..w * h).map(|i| (50..70).contains(&(i % w)) && (20..40).contains(&(i / w))).collect();
let src = vec![true; w * h];
for level in ["none", "default", "high", "veryHigh"] {
let out = fill(w, h, 3, &img, &hole, &src, &FillOptions { color_adaptation: color_level(level), ..Default::default() });
let (out, img) = (&out, &img);
let worst = (0..w * h).filter(|i| hole[*i]).flat_map(|i| (0..3).map(move |c| (out[i * 3 + c] - img[i * 3 + c]).abs())).fold(0.0f32, f32::max);
assert!(worst < 3.0 / 255.0, "{level}: off by {}", worst * 255.0);
}
}
/// I.i.d. noise: every value occurs once, so a filled pixel equal to an image pixel was copied.
fn noise(w: usize, h: usize, seed: u64) -> Vec<f32> {
let mut z = seed;
@@ -769,31 +731,6 @@ mod tests {
}
}
#[test]
fn auto_sampling_keeps_the_surroundings_of_every_part_of_a_thin_selection() {
// A long thin selection across a grey ramp, and a small knob selected with it in a red
// corner. Each part keeps its own surroundings; a compact selection is unchanged.
let (w, h) = (900, 600);
let tex = noise(w, h, 4);
let img: Vec<f32> = (0..w * h * 3)
.map(|i| {
let (x, y, c) = ((i / 3) % w, (i / 3) / w, i % 3);
let base = if x > 690 && y > 420 { [0.7, 0.25, 0.2][c] } else { 0.2 + 0.5 * x as f32 / w as f32 };
base + (tex[i] - 0.5) * 0.04
})
.collect();
let line = |i: usize| (30..870).contains(&(i % w)) && (180..192).contains(&(i / w));
let knob = |i: usize| ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 10.0;
let hole: Vec<bool> = (0..w * h).map(|i| line(i) || knob(i)).collect();
let area = auto_sampling(w, h, 3, &img, &hole);
let around_knob = (0..w * h).filter(|&i| !hole[i] && ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 24.0);
let (n, kept) = around_knob.fold((0, 0), |(n, k), i| (n + 1, k + usize::from(area[i])));
assert!(kept as f32 > 0.9 * n as f32, "{kept} of {n} pixels around the knob");
assert!(thin_band(w, h, &hole) >= 20.0, "{}", thin_band(w, h, &hole));
let disc: Vec<bool> = (0..w * h).map(|i| ((i % w) as f32 - 450.0).hypot((i / w) as f32 - 300.0) < 90.0).collect();
assert_eq!(thin_band(w, h, &disc), 1.5);
}
#[test]
fn without_colour_adaptation_the_fill_copies_pixels_exactly() {
// A smooth texture with fine noise, so that every value is unique: a filled pixel equal to
@@ -841,116 +778,6 @@ mod tests {
assert!((shift(10) - 0.4).abs() < 0.01, "{}", shift(10));
}
/// A soft background (gradients, broad out-of-focus blobs of colour, fine noise) with thin, long bright
/// objects over it: three coiled strings 3 to 7 px wide and two straighter ones, each with an
/// anti-aliased edge and a faint halo. Returns the image; what removing the coils should give
/// (the background and the straighter strings, which stay); and the selection a user would draw
/// around the coils, `margin` px wide.
fn strings_scene(w: usize, h: usize, margin: f32) -> (Vec<f32>, Vec<f32>, Vec<bool>) {
let fine = noise(w, h, 11);
// Out-of-focus colour: broad blobs over two gradients.
let blob = noise(3, 3, 5);
let bg: Vec<f32> = (0..w * h * 3)
.map(|i| {
let (x, y, c) = (((i / 3) % w) as f32 / w as f32, ((i / 3) / w) as f32 / h as f32, i % 3);
let mut v = [0.18 + 0.25 * x, 0.22 + 0.1 * y, 0.3 - 0.1 * x][c];
for b in blob.as_chunks::<3>().0 {
let (bx, by, s) = (b[0] * 1.6 - 0.3, b[1] * 1.6 - 0.3, 0.1 + 0.25 * b[2]);
v += (b[(c + 1) % 3] - 0.2) * 0.5 * (-((x - bx).powi(2) + (y - by).powi(2) * 0.56) / (2.0 * s * s)).exp();
}
v + (fine[i] - 0.5) * 0.03
})
.collect();
// Signed distance to each object's surface, stamped from points along it.
let stamp = |sd: &mut [f32], pts: &mut dyn Iterator<Item = (f32, f32)>, half: f32| {
for (px, py) in pts {
let reach = (half + 4.0) as i32 + 1;
for dy in -reach..=reach {
for dx in -reach..=reach {
let (x, y) = (px as i32 + dx, py as i32 + dy);
if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h {
let i = y as usize * w + x as usize;
sd[i] = sd[i].min((x as f32 - px).hypot(y as f32 - py) - half);
}
}
}
}
};
let mut coils = vec![f32::INFINITY; w * h];
for (k, half) in [1.5f32, 2.5, 3.5].into_iter().enumerate() {
let (y0, r) = ((0.22 + 0.28 * k as f32) * h as f32, 10.0 + 2.0 * k as f32);
let mut pts = (0..4000).map(|j| {
let t = j as f32 / 4000.0;
let a = 8.0 * std::f32::consts::TAU * t;
(18.0 + t * (w as f32 - 60.0) + r * a.cos(), y0 + 6.0 * (6.0 * t).sin() + 0.7 * r * a.sin())
});
stamp(&mut coils, &mut pts, half);
}
let mut straight = vec![f32::INFINITY; w * h];
for y in [0.07, 0.93] {
stamp(&mut straight, &mut (0..2000).map(|j| (j as f32 / 2000.0 * w as f32, y * h as f32 + 3.0 * (j as f32 / 300.0).sin())), 2.0);
}
let draw = |base: &[f32], sd: &[f32]| -> Vec<f32> {
let mut img = base.to_vec();
for i in 0..w * h {
let cover = (0.5 - sd[i]).clamp(0.0, 1.0);
let halo = if sd[i] > 0.0 { 0.08 * (-sd[i] * sd[i] / 2.9).exp() } else { 0.0 };
for (c, metal) in [0.9f32, 0.85, 0.7].into_iter().enumerate() {
let v = &mut img[i * 3 + c];
*v = *v * (1.0 - cover) + metal * cover + halo * (1.0 - cover);
}
}
img
};
let clean = draw(&bg, &straight);
let both: Vec<f32> = coils.iter().zip(&straight).map(|(a, b)| a.min(*b)).collect();
let img = draw(&bg, &both);
let hole = coils.iter().map(|d| *d <= margin).collect();
(img, clean, hole)
}
/// RMS over the hole of the error averaged over 7 × 7 pixels (in levels of 255): the noise
/// cancels out, a ghost of the removed outline or a blotch of another shade does not.
fn blotch_error(w: usize, h: usize, out: &[f32], clean: &[f32], hole: &[bool]) -> f32 {
let (mut sum, mut n) = (0.0f64, 0usize);
for y in 3..h - 3 {
for x in 3..w - 3 {
if !hole[y * w + x] {
continue;
}
let mut e = [0.0f32; 3];
for j in (y - 3..=y + 3).flat_map(|yy| (x - 3..=x + 3).map(move |xx| yy * w + xx)) {
for (c, ec) in e.iter_mut().enumerate() {
*ec += out[j * 3 + c] - clean[j * 3 + c];
}
}
sum += f64::from(e.iter().map(|v| (v / 49.0).powi(2)).sum::<f32>() / 3.0);
n += 1;
}
}
((sum / n.max(1) as f64).sqrt() * 255.0) as f32
}
/// Removing thin, long objects (guitar strings, wires) from a soft background (#1366): the
/// fill is the background, with no ghost of their outline and nothing of their colour copied
/// back in. Their selection is long but only a few pixels thick, so the fill is worked out at
/// a scale where it is still a few pixels thick rather than one where the strings' surroundings
/// have merged into a blur.
#[test]
fn thin_objects_leave_no_ghost_and_none_of_their_colour() {
let (w, h) = (360, 270);
let (img, clean, hole) = strings_scene(w, h, 2.0);
let source = auto_sampling(w, h, 3, &img, &hole);
let out = fill(w, h, 3, &img, &hole, &source, &FillOptions::default());
let blotch = blotch_error(w, h, &out, &clean, &hole);
let luma = |v: &[f32], i: usize| (v[i * 3] + v[i * 3 + 1] + v[i * 3 + 2]) / 3.0;
let bright = (0..w * h).filter(|&i| hole[i] && luma(&out, i) > luma(&clean, i) + 0.06).count();
// Before #1366 the outlines stayed as darker and lighter blotches (13 levels of 255) and
// 1121 pixels took the strings' colour.
assert!(blotch < 2.5, "ghosts and blotches: {blotch:.2} levels of 255");
assert_eq!(bright, 0, "filled pixels with the strings' colour");
}
#[test]
fn variant_geometry() {
let img = vec![0.5f32; 10 * 6];
-990
View File
@@ -1,990 +0,0 @@
//! Depth from focus for Edit › Auto-Blend Layers › Stack Images: a dense, sub-layer depth map
//! from the aligned stack, the index of the layer each pixel is sharpest in.
//!
//! The pipeline is the non-learned "focus volume" recipe, written from the papers:
//!
//! 1. **Focus measure** per layer on luminance at full resolution: the *ring difference filter*
//! of Jeon, Surh, Im & Kweon, *Ring Difference Filter for Fast and Noise Robust Depth From
//! Focus*, IEEE TIP 29 (2019), the magnitude of the difference between the mean of a small
//! disk and the mean of the ring around it, a band-pass whose support averages sensor noise
//! away while staying local. Block-averaged to a working grid (half resolution).
//! 2. **Cost aggregation**: each slice of the focus volume is filtered with the *guided filter*
//! (He, Sun & Tang, PAMI 2013) using the all-in-focus luminance as guide, edge-aware
//! aggregation as in fast cost-volume filtering (Hosni et al., PAMI 2013).
//! 3. **Peak search** per pixel over the layer axis, one slice at a time: the global peak and its
//! two neighbours for the *Gaussian interpolation* of Nayar & Nakagawa, *Shape from Focus*,
//! PAMI 1994 (sub-layer depth), the second-best local maximum for a peak-ratio confidence,
//! the profile mean for a prominence term and the profile minimum for a noise gate.
//! 4. **Regularisation**: the sub-layer depth is smoothed and holes filled by an edge-aware
//! weighted least squares energy (Farbman, Fattal, Lischinski & Szeliski, SIGGRAPH 2008)
//! whose data term is the confidence, solved by conjugate gradients preconditioned by a
//! multigrid V-cycle from the separable sweeps of the *fast global smoother* (Min, Choi, Lu,
//! Ham, Sohn & Do, IEEE TIP 2014) as the start, with one Huber reweighting so isolated
//! outliers stop pulling.
//! 5. **Upsampling** to full resolution with the guided filter's linear coefficients.
//!
//! Everything is deterministic; the slices are folded one at a time, so memory is one working
//! grid per layer plus a few planes.
use crate::photo_util::{par_map, par_rows, par_rows2};
/// Depth-from-focus settings.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DepthParams {
/// The working grid is `1 / 2^scale` of full resolution (1 = half).
pub scale: usize,
/// Ring difference filter radii: the disk `r ≤ r_in`, the ring `r_in < r ≤ r_out`.
pub r_in: usize,
pub r_out: usize,
/// Guided-filter aggregation radius (working-grid pixels) and regulariser.
pub agg_radius: usize,
pub agg_eps: f32,
/// Smoothness weight and guide-edge sensitivity (luminance units) of the regularisation.
pub lambda: f32,
pub sigma_c: f32,
/// Noise gate: full confidence needs a peak ≥ `(1 + gate) ×` the noise floor (the median
/// over pixels of the profile minimum). 0 = off.
pub gate: f32,
/// Robust data term: after the first solve the data weights are scaled by the Huber factor
/// `min(1, robust / |d − u|)` (in layers) and the system is solved once more. 0 = off.
pub robust: f32,
}
impl Default for DepthParams {
fn default() -> Self {
DepthParams { scale: 1, r_in: 1, r_out: 3, agg_radius: 3, agg_eps: 1e-4, lambda: 3.0, sigma_c: 0.04, gate: 1.0, robust: 1.0 }
}
}
/// The result of [`depth_from_focus`].
#[derive(Clone, Debug, PartialEq)]
pub struct DepthMap {
/// Full-resolution fractional layer index per pixel, in `0 ..= n − 1`.
pub depth: Vec<f32>,
/// Full-resolution confidence in `0 ..= 1`.
pub conf: Vec<f32>,
pub w: usize,
pub h: usize,
}
/// Mean over `k × k` blocks (partial blocks at the far edges use their own count).
pub fn block_mean(src: &[f32], w: usize, h: usize, k: usize) -> (Vec<f32>, usize, usize) {
let k = k.max(1);
let (dw, dh) = (w.div_ceil(k), h.div_ceil(k));
if k == 1 {
return (src.to_vec(), dw, dh);
}
let mut out = vec![0.0f32; dw * dh];
par_rows(&mut out, dw, 1, |oy, row| {
let (y0, y1) = (oy * k, (oy * k + k).min(h));
for y in y0..y1 {
let s = &src[y * w..y * w + w];
for (ox, o) in row.iter_mut().enumerate() {
*o += s[ox * k..(ox * k + k).min(w)].iter().sum::<f32>();
}
}
for (ox, o) in row.iter_mut().enumerate() {
*o /= ((y1 - y0) * ((ox * k + k).min(w) - ox * k)) as f32;
}
});
(out, dw, dh)
}
/// Bilinear resampling of a working-grid plane (block size `k`, samples at block centres) to
/// `w × h`.
pub fn upsample_bilinear(g: &[f32], dw: usize, dh: usize, w: usize, h: usize, k: usize) -> Vec<f32> {
let mut out = vec![0.0f32; w * h];
if dw == 0 || dh == 0 {
return out;
}
let inv = 1.0 / k.max(1) as f32;
par_rows(&mut out, w, 1, |y, row| {
let fy = ((y as f32 + 0.5) * inv - 0.5).clamp(0.0, (dh - 1) as f32);
let y0 = fy as usize;
let y1 = (y0 + 1).min(dh - 1);
let ty = fy - y0 as f32;
let (r0, r1) = (&g[y0 * dw..y0 * dw + dw], &g[y1 * dw..y1 * dw + dw]);
for (x, o) in row.iter_mut().enumerate() {
let fx = ((x as f32 + 0.5) * inv - 0.5).clamp(0.0, (dw - 1) as f32);
let x0 = fx as usize;
let x1 = (x0 + 1).min(dw - 1);
let tx = fx - x0 as f32;
let top = r0[x0] + (r0[x1] - r0[x0]) * tx;
let bot = r1[x0] + (r1[x1] - r1[x0]) * tx;
*o = top + (bot - top) * ty;
}
});
out
}
/// Box means over a `(2r + 1)²` window clamped to the plane (partial windows at the borders use
/// their own count): a horizontal moving sum per row in parallel, then a vertical moving sum
/// carried down the rows as one accumulator row.
struct BoxFilter {
w: usize,
h: usize,
r: usize,
inv_count: Vec<f32>,
}
impl BoxFilter {
fn new(w: usize, h: usize, r: usize) -> BoxFilter {
let cnt = |n: usize, i: usize| ((i + r + 1).min(n) - i.saturating_sub(r)) as f32;
let mut inv_count = vec![0.0f32; w * h];
par_rows(&mut inv_count, w, 1, |y, row| {
let cy = cnt(h, y);
for (x, o) in row.iter_mut().enumerate() {
*o = 1.0 / (cy * cnt(w, x));
}
});
BoxFilter { w, h, r, inv_count }
}
fn mean(&self, src: &[f32]) -> Vec<f32> {
let (w, h, r) = (self.w, self.h, self.r);
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, 1, |y, row| {
let s = &src[y * w..y * w + w];
let mut acc: f64 = s[..(r + 1).min(w)].iter().map(|v| f64::from(*v)).sum();
for x in 0..w {
row[x] = acc as f32;
if x + r + 1 < w {
acc += f64::from(s[x + r + 1]);
}
if x >= r {
acc -= f64::from(s[x - r]);
}
}
});
let mut out = vec![0.0f32; w * h];
let mut acc = vec![0.0f64; w];
for y in 0..(r + 1).min(h) {
for (a, v) in acc.iter_mut().zip(&tmp[y * w..y * w + w]) {
*a += f64::from(*v);
}
}
for y in 0..h {
for ((o, a), c) in out[y * w..y * w + w].iter_mut().zip(&acc).zip(&self.inv_count[y * w..y * w + w]) {
*o = *a as f32 * c;
}
if y + r + 1 < h {
for (a, v) in acc.iter_mut().zip(&tmp[(y + r + 1) * w..(y + r + 2) * w]) {
*a += f64::from(*v);
}
}
if y >= r {
for (a, v) in acc.iter_mut().zip(&tmp[(y - r) * w..(y - r + 1) * w]) {
*a -= f64::from(*v);
}
}
}
out
}
}
/// Guided filter (He, Sun & Tang 2013) with a fixed grayscale guide: made once per guide and
/// radius, it keeps the guide's statistics so a slice costs only the arithmetic.
struct GuidedFilter {
guide: Vec<f32>,
mean_i: Vec<f32>,
var_i: Vec<f32>,
bf: BoxFilter,
eps: f32,
}
impl GuidedFilter {
fn new(guide: Vec<f32>, w: usize, h: usize, r: usize, eps: f32) -> GuidedFilter {
let bf = BoxFilter::new(w, h, r);
let mean_i = bf.mean(&guide);
let ii: Vec<f32> = guide.iter().map(|v| v * v).collect();
let var_i: Vec<f32> = bf.mean(&ii).iter().zip(&mean_i).map(|(v, m)| (v - m * m).max(0.0)).collect();
GuidedFilter { guide, mean_i, var_i, bf, eps }
}
/// The box-averaged linear coefficients `(ā, b̄)` such that `q ≈ ā·I + b̄`.
fn coeffs(&self, p: &[f32]) -> (Vec<f32>, Vec<f32>) {
let mean_p = self.bf.mean(p);
let ip: Vec<f32> = self.guide.iter().zip(p).map(|(i, p)| i * p).collect();
let corr_ip = self.bf.mean(&ip);
let (mut a, mut b) = (vec![0.0f32; p.len()], vec![0.0f32; p.len()]);
for k in 0..p.len() {
let cov = corr_ip[k] - self.mean_i[k] * mean_p[k];
a[k] = cov / (self.var_i[k] + self.eps);
b[k] = mean_p[k] - a[k] * self.mean_i[k];
}
(self.bf.mean(&a), self.bf.mean(&b))
}
fn filter(&self, p: &[f32]) -> Vec<f32> {
let (a, b) = self.coeffs(p);
(0..p.len()).map(|k| a[k] * self.guide[k] + b[k]).collect()
}
}
/// Ring difference filter taps `(dy, dx, weight)`: `+1/|disk|` for `r ≤ r_in`, `−1/|ring|` for
/// `r_in < r ≤ r_out`.
fn rdf_taps(r_in: usize, r_out: usize) -> Vec<(i64, i64, f32)> {
let (ri2, ro2) = ((r_in * r_in) as i64, (r_out * r_out) as i64);
let (mut disk, mut ring) = (Vec::new(), Vec::new());
let r = r_out as i64;
for dy in -r..=r {
for dx in -r..=r {
let d2 = dx * dx + dy * dy;
if d2 <= ri2 {
disk.push((dy, dx));
} else if d2 <= ro2 {
ring.push((dy, dx));
}
}
}
let (wd, wr) = (1.0 / disk.len().max(1) as f32, -1.0 / ring.len().max(1) as f32);
disk.into_iter().map(|(y, x)| (y, x, wd)).chain(ring.into_iter().map(|(y, x)| (y, x, wr))).collect()
}
/// Per-pixel focus measure of a luminance plane: the magnitude of the ring difference filter,
/// clamped borders.
pub fn focus_measure(y: &[f32], w: usize, h: usize, r_in: usize, r_out: usize) -> Vec<f32> {
let taps = rdf_taps(r_in, r_out.max(r_in + 1));
let mut out = vec![0.0f32; w * h];
if w == 0 || h == 0 {
return out;
}
par_rows(&mut out, w, 1, |yy, row| {
for &(dy, dx, wt) in &taps {
let sy = (yy as i64 + dy).clamp(0, h as i64 - 1) as usize;
let s = &y[sy * w..sy * w + w];
for (x, o) in row.iter_mut().enumerate() {
*o += wt * s[(x as i64 + dx).clamp(0, w as i64 - 1) as usize];
}
}
row.iter_mut().for_each(|v| *v = v.abs());
});
out
}
/// Per-pixel state of the focus profile, updated one slice at a time.
#[derive(Clone, Copy)]
struct Px {
/// Best local maximum (value, left neighbour, right neighbour; −1 = none) and its index.
c1: f32,
l1: f32,
r1: f32,
i1: u32,
/// Second-best local maximum (−1 = none).
c2: f32,
/// The last two slice values.
prev: f32,
prev2: f32,
sum: f32,
cmin: f32,
}
impl Px {
fn register(&mut self, val: f32, idx: u32, l: f32, r: f32) {
if val > self.c1 {
if self.c1 >= 0.0 {
self.c2 = self.c1;
}
(self.c1, self.i1, self.l1, self.r1) = (val, idx, l, r);
} else if val > self.c2 {
self.c2 = val;
}
}
}
/// The streaming peak search over the layer axis.
struct PeakTracker {
px: Vec<Px>,
m: usize,
}
impl PeakTracker {
fn new(n: usize) -> PeakTracker {
PeakTracker { px: vec![Px { c1: -1.0, l1: -1.0, r1: -1.0, i1: 0, c2: -1.0, prev: 0.0, prev2: 0.0, sum: 0.0, cmin: f32::INFINITY }; n], m: 0 }
}
/// Fold the next slice (aggregated focus values ≥ 0).
fn push(&mut self, c: &[f32]) {
let m = self.m;
for (p, &v) in self.px.iter_mut().zip(c) {
if m >= 1 && (m == 1 || p.prev >= p.prev2) && p.prev > v {
let l = if m >= 2 { p.prev2 } else { -1.0 };
p.register(p.prev, (m - 1) as u32, l, v);
}
p.sum += v;
p.cmin = p.cmin.min(v);
(p.prev2, p.prev) = (p.prev, v);
}
self.m += 1;
}
/// Close the profiles: the sub-layer depth and the raw confidence per pixel.
fn finish(mut self, gate: f32) -> (Vec<f32>, Vec<f32>) {
let n = self.m;
if n == 0 {
return (vec![0.0; self.px.len()], vec![0.0; self.px.len()]);
}
for p in self.px.iter_mut() {
if n == 1 || p.prev >= p.prev2 {
let l = if n >= 2 { p.prev2 } else { -1.0 };
p.register(p.prev, (n - 1) as u32, l, -1.0);
}
}
// The noise floor: the median of the per-pixel profile minimum (subsampled).
let mut mins: Vec<f32> = self.px.iter().step_by(7).map(|p| if p.cmin.is_finite() { p.cmin } else { 0.0 }).collect();
let floor = if mins.is_empty() {
0.0
} else {
let mid = mins.len() / 2;
*mins.select_nth_unstable_by(mid, |a, b| a.total_cmp(b)).1
};
let inv_n = 1.0 / n as f32;
let out: Vec<(f32, f32)> = par_map(self.px.len(), |i| {
let p = &self.px[i];
let (c1, l, r) = (p.c1, p.l1, p.r1);
let mut delta = 0.0f32;
if l >= 0.0 && r >= 0.0 && c1 > 0.0 {
// Gaussian interpolation (Nayar & Nakagawa 1994): a parabola in the log domain.
let (ll, lc, lr) = (l.max(1e-12).ln(), c1.ln(), r.max(1e-12).ln());
let den = ll - 2.0 * lc + lr;
if den < 0.0 {
delta = (0.5 * (ll - lr) / den).clamp(-0.5, 0.5);
}
}
let conf = if c1 > 0.0 {
let prom = (1.0 - p.sum * inv_n / c1).clamp(0.0, 1.0);
// A rival local maximum of similar height means an ambiguous profile.
let pkr = if p.c2 >= 0.0 { (1.0 - p.c2 / c1).clamp(0.0, 1.0) } else { 1.0 };
let g = if gate > 0.0 && floor > 0.0 { ((c1 - floor) / (gate * floor)).clamp(0.0, 1.0) } else { 1.0 };
prom * pkr * g
} else {
0.0
};
(p.i1 as f32 + delta, conf)
});
out.into_iter().unzip()
}
}
/// Scale the confidence so its 90th percentile maps to 1: `lambda` then means the same on every
/// stack.
fn normalize_conf(conf: &mut [f32]) {
let mut sample: Vec<f32> = conf.iter().step_by(7).copied().collect();
if sample.is_empty() {
return;
}
let k = (sample.len() * 9 / 10).min(sample.len() - 1);
let p90 = *sample.select_nth_unstable_by(k, |a, b| a.total_cmp(b)).1;
if p90 > 1e-6 {
conf.iter_mut().for_each(|c| *c = (*c / p90).min(1.0));
}
}
/// 3×3 median, clamped borders.
fn median3(src: &[f32], w: usize, h: usize) -> Vec<f32> {
let mut out = vec![0.0f32; w * h];
par_rows(&mut out, w, 1, |y, row| {
let ys = [y.saturating_sub(1), y, (y + 1).min(h - 1)];
let mut v = [0.0f32; 9];
for (x, o) in row.iter_mut().enumerate() {
let xs = [x.saturating_sub(1), x, (x + 1).min(w - 1)];
for (k, (yy, xx)) in ys.iter().flat_map(|yy| xs.iter().map(move |xx| (*yy, *xx))).enumerate() {
v[k] = src[yy * w + xx];
}
v.sort_unstable_by(|a, b| a.total_cmp(b));
*o = v[4];
}
});
out
}
/// Edge weights between horizontal and vertical neighbours: `exp(−|ΔI| / σ)`; `ax[i]` sits
/// between `(x, y)` and `(x + 1, y)`, `ay[i]` between `(x, y)` and `(x, y + 1)`.
fn edge_weights(guide: &[f32], w: usize, h: usize, sigma: f32) -> (Vec<f32>, Vec<f32>) {
let inv = -1.0 / sigma.max(1e-6);
let (mut ax, mut ay) = (vec![0.0f32; w * h], vec![0.0f32; w * h]);
par_rows2(&mut ax, &mut ay, w, |y, rx, ry| {
let g = &guide[y * w..y * w + w];
for x in 0..w.saturating_sub(1) {
rx[x] = ((g[x] - g[x + 1]).abs() * inv).exp();
}
if y + 1 < h {
let g1 = &guide[(y + 1) * w..(y + 2) * w];
for x in 0..w {
ry[x] = ((g[x] - g1[x]).abs() * inv).exp();
}
}
});
(ax, ay)
}
/// One 1-D weighted-least-squares line `(wd_i + λ(a_{i−1} + a_i)) u_i − λ a_{i−1} u_{i−1} −
/// λ a_i u_{i+1} = wd_i f_i` by the Thomas algorithm; `a[i]` is the weight between `i` and
/// `i + 1`; `scratch` holds the sweep's two coefficient lines.
fn solve_line(u: &mut [f32], f: &[f32], wd: &[f32], a: &[f32], lam: f32, scratch: &mut [f32]) {
let n = u.len();
if n == 0 || scratch.len() < 2 * n {
return;
}
let (cp, dp) = scratch.split_at_mut(n);
let mut prev_a = 0.0f32;
for i in 0..n {
let ai = if i + 1 < n { a[i] } else { 0.0 };
let diag = wd[i] + lam * (prev_a + ai);
let lower = -lam * prev_a;
let upper = -lam * ai;
let (c0, d0) = if i > 0 { (cp[i - 1], dp[i - 1]) } else { (0.0, 0.0) };
let m = diag - lower * c0;
let m = if m.abs() < 1e-12 { 1e-12 } else { m };
cp[i] = upper / m;
dp[i] = (wd[i] * f[i] - lower * d0) / m;
prev_a = ai;
}
u[n - 1] = dp[n - 1];
for i in (0..n - 1).rev() {
u[i] = dp[i] - cp[i] * u[i + 1];
}
}
/// The row sweeps of the fast global smoother, in parallel over rows.
fn solve_rows(u: &mut [f32], f: &[f32], wd: &[f32], ax: &[f32], w: usize, lam: f32) {
par_rows(u, w, 1, |y, row| {
let o = y * w;
let mut scratch = vec![0.0f32; 2 * w];
solve_line(row, &f[o..o + w], &wd[o..o + w], &ax[o..o + w], lam, &mut scratch);
});
}
/// The column sweeps: the planes are transposed, swept as rows, and transposed back.
fn solve_cols(u: &mut [f32], f: &[f32], wd: &[f32], ay: &[f32], w: usize, h: usize, lam: f32) {
let t = |p: &[f32]| -> Vec<f32> {
let mut o = vec![0.0f32; w * h];
par_rows(&mut o, h, 1, |x, col| {
for (y, v) in col.iter_mut().enumerate() {
*v = p[y * w + x];
}
});
o
};
let (ft, wdt, ayt) = (t(f), t(wd), t(ay));
let mut ut = vec![0.0f32; w * h];
solve_rows(&mut ut, &ft, &wdt, &ayt, h, lam);
par_rows(u, w, 1, |y, row| {
for (x, v) in row.iter_mut().enumerate() {
*v = ut[x * h + y];
}
});
}
/// The fast global smoother's three separable passes with the `λ_t` schedule of Min et al.
/// 2014, the first pass carrying the data weights so holes are filled by interpolation, the
/// later ones the previous output: the initial guess of [`wls_solve`].
fn sweeps(d: &[f32], wd: &[f32], ex: &[f32], ey: &[f32], w: usize, h: usize, lambda: f32) -> Vec<f32> {
const T: i32 = 3;
let ones = vec![1.0f32; w * h];
let mut u = vec![0.0f32; w * h];
let mut f = d.to_vec();
for t in 1..=T {
let lam_t = 1.5 * lambda * 4f32.powi(T - t) / (4f32.powi(T) - 1.0);
let wt: &[f32] = if t == 1 { wd } else { &ones };
solve_rows(&mut u, &f, wt, ex, w, lam_t);
f.copy_from_slice(&u);
solve_cols(&mut u, &f, &ones, ey, w, h, lam_t);
f.copy_from_slice(&u);
}
u
}
/// The WLS operator `A = W + Σ λ a_pq` on one grid of the multigrid hierarchy, with the planes
/// a V-cycle needs there.
struct MgLevel {
w: usize,
h: usize,
/// Data weight per cell; on the coarse grids the sum over the block.
wd: Vec<f32>,
/// `λ·a` between `(x, y)` and `(x + 1, y)`, and between `(x, y)` and `(x, y + 1)`; on the
/// coarse grids the sum of the fine edges the block boundary cuts.
ax: Vec<f32>,
ay: Vec<f32>,
/// `1 / diag(A)`.
dinv: Vec<f32>,
x: Vec<f32>,
b: Vec<f32>,
r: Vec<f32>,
}
/// Damped-Jacobi weight (4/5 is the smoothing optimum of the 5-point stencil).
const MG_OMEGA: f32 = 0.8;
/// Smoothing sweeps before and after the coarse correction, and on the coarsest grid (where
/// they are the solve).
const MG_PRE: usize = 1;
const MG_POST: usize = 1;
const MG_COARSE: usize = 32;
/// Coarsen until the grid is this small on its longer side.
const MG_MIN: usize = 16;
impl MgLevel {
fn new(w: usize, h: usize) -> MgLevel {
let n = w * h;
MgLevel { w, h, wd: vec![0.0; n], ax: vec![0.0; n], ay: vec![0.0; n], dinv: vec![0.0; n], x: vec![0.0; n], b: vec![0.0; n], r: vec![0.0; n] }
}
/// `out_i = f(i, (A x)_i)` over the grid.
fn apply<F: Fn(usize, f32) -> f32 + Sync + Send>(&self, x: &[f32], out: &mut [f32], f: F) {
let (w, h) = (self.w, self.h);
let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay);
par_rows(out, w, 1, |y, row| {
let o = y * w;
for (i, r) in row.iter_mut().enumerate() {
let k = o + i;
let xk = x[k];
let mut v = wd[k] * xk;
if i > 0 {
v += ax[k - 1] * (xk - x[k - 1]);
}
if i + 1 < w {
v += ax[k] * (xk - x[k + 1]);
}
if y > 0 {
v += ay[k - w] * (xk - x[k - w]);
}
if y + 1 < h {
v += ay[k] * (xk - x[k + w]);
}
*r = f(k, v);
}
});
}
fn set_dinv(&mut self) {
let (w, h) = (self.w, self.h);
let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay);
par_rows(&mut self.dinv, w, 1, |y, row| {
for (i, r) in row.iter_mut().enumerate() {
let k = y * w + i;
let mut v = wd[k];
if i > 0 {
v += ax[k - 1];
}
if i + 1 < w {
v += ax[k];
}
if y > 0 {
v += ay[k - w];
}
if y + 1 < h {
v += ay[k];
}
*r = 1.0 / v.max(1e-12);
}
});
}
/// One damped-Jacobi sweep on `x` for `b`: `x += ω D⁻¹ (b − A x)`.
fn jacobi(&mut self, zero_start: bool) {
if zero_start {
for ((x, b), d) in self.x.iter_mut().zip(&self.b).zip(&self.dinv) {
*x = MG_OMEGA * d * b;
}
return;
}
let mut r = std::mem::take(&mut self.r);
{
let (x, b, dinv) = (&self.x, &self.b, &self.dinv);
self.apply(x, &mut r, |k, ax| x[k] + MG_OMEGA * dinv[k] * (b[k] - ax));
}
self.r = std::mem::replace(&mut self.x, r);
}
/// The 2×2 aggregation of this grid: the Galerkin operator of a piecewise-constant
/// prolongation (data weights summed over the block, the fine edges cut by a block boundary
/// summed into the coarse edge).
fn coarsen(&self) -> MgLevel {
let (w, h) = (self.w, self.h);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let mut c = MgLevel::new(cw, ch);
self.coarsen_wd_into(&mut c);
let (ax, ay) = (&self.ax, &self.ay);
par_rows(&mut c.ax, cw, 1, |yy, row| {
for y in 2 * yy..(2 * yy + 2).min(h) {
for (xx, o) in row.iter_mut().enumerate() {
if xx + 1 < cw {
*o += ax[y * w + 2 * xx + 1];
}
}
}
});
par_rows(&mut c.ay, cw, 1, |yy, row| {
if yy + 1 < ch {
let y = 2 * yy + 1;
for (xx, o) in row.iter_mut().enumerate() {
*o = ay[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
c
}
/// The data weights of the coarse grid `c` from this grid's (after a reweighting; the edges
/// do not change).
fn coarsen_wd_into(&self, c: &mut MgLevel) {
let (w, h, cw) = (self.w, self.h, c.w);
let wd = &self.wd;
par_rows(&mut c.wd, cw, 1, |yy, row| {
row.fill(0.0);
for y in 2 * yy..(2 * yy + 2).min(h) {
for (xx, o) in row.iter_mut().enumerate() {
*o += wd[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
}
}
/// `x += P x_c`: the coarse correction injected (piecewise constant).
fn prolong_add(x: &mut [f32], w: usize, xc: &[f32], cw: usize) {
par_rows(x, w, 1, |y, row| {
let c = &xc[(y / 2) * cw..(y / 2) * cw + cw];
for (i, o) in row.iter_mut().enumerate() {
*o += c[i / 2];
}
});
}
/// `b_c = Pᵀ r`: the residual summed over each block.
fn restrict(r: &[f32], w: usize, h: usize, bc: &mut [f32], cw: usize) {
par_rows(bc, cw, 1, |yy, row| {
row.fill(0.0);
for y in 2 * yy..(2 * yy + 2).min(h) {
let s = &r[y * w..y * w + w];
for (xx, o) in row.iter_mut().enumerate() {
*o += s[2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
}
/// One V-cycle from `levels[0]` (whose `b` is set) into its `x`.
fn vcycle(levels: &mut [MgLevel]) {
let Some((top, rest)) = levels.split_first_mut() else { return };
let Some(next) = rest.first_mut() else {
for k in 0..MG_COARSE {
top.jacobi(k == 0);
}
return;
};
for k in 0..MG_PRE {
top.jacobi(k == 0);
}
{
let mut r = std::mem::take(&mut top.r);
let (x, b) = (&top.x, &top.b);
top.apply(x, &mut r, |k, ax| b[k] - ax);
top.r = r;
}
restrict(&top.r, top.w, top.h, &mut next.b, next.w);
vcycle(rest);
let Some(next) = rest.first() else { return };
prolong_add(&mut top.x, top.w, &next.x, next.w);
for _ in 0..MG_POST {
top.jacobi(false);
}
}
/// Edge-aware WLS: `argmin_u Σ w_p (u_p − d_p)² + λ Σ a_pq (u_p − u_q)²`, the 2-D system
/// `(W + λL) u = W d` solved by conjugate gradients from the separable sweeps' guess,
/// preconditioned by one multigrid V-cycle (2×2 aggregation, damped Jacobi), so the low modes
/// converge where the confidence is low over a large area (a flat wall). The solver keeps its
/// hierarchy between solves, so a reweighting costs no rebuild.
struct WlsSolver {
w: usize,
h: usize,
lambda: f32,
/// Edge weights `exp(−|ΔI| / σ)` (without λ), for the separable sweeps.
ex: Vec<f32>,
ey: Vec<f32>,
levels: Vec<MgLevel>,
}
impl WlsSolver {
fn new(guide: &[f32], w: usize, h: usize, lambda: f32, sigma_c: f32) -> WlsSolver {
let (ex, ey) = edge_weights(guide, w, h, sigma_c);
let mut top = MgLevel::new(w, h);
for (a, e) in top.ax.iter_mut().zip(&ex) {
*a = lambda * e;
}
for (a, e) in top.ay.iter_mut().zip(&ey) {
*a = lambda * e;
}
let mut levels = vec![top];
while levels.last().is_some_and(|l| l.w.max(l.h) > MG_MIN) {
let Some(last) = levels.last() else { break };
let c = last.coarsen();
levels.push(c);
}
WlsSolver { w, h, lambda, ex, ey, levels }
}
/// The data weights: the confidence (plus a floor so the system is definite where nothing
/// is known), aggregated down the hierarchy.
fn set_weights(&mut self, conf: &[f32]) {
const EPS_DATA: f32 = 1e-4;
if let Some(top) = self.levels.first_mut() {
for (w, c) in top.wd.iter_mut().zip(conf) {
*w = c.max(0.0) + EPS_DATA;
}
}
for l in 0..self.levels.len() {
if l > 0 {
let (fine, coarse) = self.levels.split_at_mut(l);
if let (Some(f), Some(c)) = (fine.last(), coarse.first_mut()) {
f.coarsen_wd_into(c);
}
}
self.levels[l].set_dinv();
}
}
/// Solve for the data `d`: the solution and its final relative residual.
fn solve(&mut self, d: &[f32], max_iters: usize) -> (Vec<f32>, f32) {
let (w, h, lambda) = (self.w, self.h, self.lambda);
let n = w * h;
let Some(top) = self.levels.first() else { return (vec![0.0; n], 0.0) };
let mut u = sweeps(d, &top.wd, &self.ex, &self.ey, w, h, lambda);
if w == 1 || h == 1 || n == 0 {
return (u, 0.0);
}
let dot = |a: &[f32], b: &[f32]| -> f64 { a.iter().zip(b).map(|(x, y)| f64::from(*x) * f64::from(*y)).sum() };
let b: Vec<f32> = top.wd.iter().zip(d).map(|(w, d)| w * d).collect();
let bnorm = dot(&b, &b).sqrt().max(1e-30);
let mut r = vec![0.0f32; n];
top.apply(&u, &mut r, |k, au| b[k] - au);
let precond = |levels: &mut [MgLevel], r: &[f32], z: &mut [f32]| {
if let Some(top) = levels.first_mut() {
top.b.copy_from_slice(r);
}
vcycle(levels);
if let Some(top) = levels.first() {
z.copy_from_slice(&top.x);
}
};
let mut z = vec![0.0f32; n];
precond(&mut self.levels, &r, &mut z);
let mut p = z.clone();
let mut ap = vec![0.0f32; n];
let mut rz = dot(&r, &z);
let mut rel = (dot(&r, &r).sqrt() / bnorm) as f32;
for _ in 0..max_iters {
if rel < 1e-5 {
break;
}
let Some(top) = self.levels.first() else { break };
top.apply(&p, &mut ap, |_, v| v);
let pap = dot(&p, &ap);
if pap <= 0.0 {
break;
}
let alpha = (rz / pap) as f32;
for (u, p) in u.iter_mut().zip(&p) {
*u += alpha * p;
}
for (r, ap) in r.iter_mut().zip(&ap) {
*r -= alpha * ap;
}
rel = (dot(&r, &r).sqrt() / bnorm) as f32;
if rel < 1e-5 {
break;
}
precond(&mut self.levels, &r, &mut z);
let rz_new = dot(&r, &z);
let beta = (rz_new / rz) as f32;
rz = rz_new;
for (p, z) in p.iter_mut().zip(&z) {
*p = z + beta * *p;
}
}
(u, rel)
}
}
/// Conjugate-gradient iteration cap of the regularisation.
const CG_ITERS: usize = 200;
/// Depth from focus over the `n` layers' luminance planes `luma` (`w × h`, aligned), guided by
/// the all-in-focus luminance `guide` of the same size. Without layers, or when `guide` or a
/// plane is not `w × h` long, the map is empty: depth 0 and confidence 0 everywhere.
pub fn depth_from_focus(luma: &[&[f32]], guide: &[f32], w: usize, h: usize, p: &DepthParams) -> DepthMap {
let n = luma.len();
let empty = n == 0 || w == 0 || h == 0 || guide.len() != w * h || luma.iter().any(|l| l.len() != w * h);
if empty {
return DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h };
}
let k = 1usize << p.scale.min(8);
let (g, dw, dh) = block_mean(guide, w, h, k);
let gf = GuidedFilter::new(g.clone(), dw, dh, p.agg_radius, p.agg_eps);
let mut tracker = PeakTracker::new(dw * dh);
for l in luma {
let slice = block_mean(&focus_measure(l, w, h, p.r_in, p.r_out), w, h, k).0;
if p.agg_radius > 0 {
// The guided filter can undershoot; the profile statistics assume ≥ 0.
let agg: Vec<f32> = gf.filter(&slice).into_iter().map(|v| v.max(0.0)).collect();
tracker.push(&agg);
} else {
tracker.push(&slice);
}
}
let (depth_w, mut conf) = tracker.finish(p.gate);
let depth_w = median3(&depth_w, dw, dh);
normalize_conf(&mut conf);
let depth_w = if p.lambda > 0.0 {
let mut solver = WlsSolver::new(&g, dw, dh, p.lambda, p.sigma_c);
solver.set_weights(&conf);
let (u, _) = solver.solve(&depth_w, CG_ITERS);
if p.robust > 0.0 {
// One reweighted step with a Huber loss on the data residual.
let wd2: Vec<f32> = conf.iter().zip(&depth_w).zip(&u).map(|((c, d), u)| c * (p.robust / (d - u).abs().max(1e-6)).min(1.0)).collect();
solver.set_weights(&wd2);
solver.solve(&depth_w, CG_ITERS).0
} else {
u
}
} else {
depth_w
};
let max_d = (n - 1) as f32;
let up = GuidedFilter::new(g, dw, dh, 2, 1e-2);
let depth: Vec<f32> = if k == 1 {
up.filter(&depth_w)
} else {
let (a, b) = up.coeffs(&depth_w);
let a = upsample_bilinear(&a, dw, dh, w, h, k);
let b = upsample_bilinear(&b, dw, dh, w, h, k);
(0..w * h).map(|i| a[i] * guide[i] + b[i]).collect()
};
let depth = depth.into_iter().map(|v| v.clamp(0.0, max_d)).collect();
let conf = if k == 1 { conf } else { upsample_bilinear(&conf, dw, dh, w, h, k) };
DepthMap { depth, conf, w, h }
}
#[cfg(test)]
mod tests {
use super::*;
fn lcg(seed: &mut u64) -> f32 {
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((*seed >> 33) % 10000) as f32 / 10000.0
}
#[test]
fn box_filter_matches_the_naive_window() {
let (w, h, r) = (23, 17, 3);
let mut s = 5u64;
let src: Vec<f32> = (0..w * h).map(|_| lcg(&mut s)).collect();
let m = BoxFilter::new(w, h, r).mean(&src);
for y in 0..h {
for x in 0..w {
let (mut acc, mut cnt) = (0.0, 0.0);
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
acc += src[yy * w + xx];
cnt += 1.0;
}
}
assert!((m[y * w + x] - acc / cnt).abs() < 1e-4, "({x},{y})");
}
}
}
#[test]
fn guided_filter_with_a_flat_guide_is_a_double_box_mean() {
let (w, h) = (40, 30);
let mut s = 9u64;
let p: Vec<f32> = (0..w * h).map(|_| lcg(&mut s)).collect();
let q = GuidedFilter::new(vec![0.5; w * h], w, h, 2, 1e-3).filter(&p);
let bf = BoxFilter::new(w, h, 2);
let m = bf.mean(&bf.mean(&p));
assert!(q.iter().zip(&m).all(|(a, b)| (a - b).abs() < 1e-4));
}
#[test]
fn ring_difference_is_zero_mean_and_sees_texture() {
let taps = rdf_taps(1, 3);
assert!(taps.iter().map(|t| t.2).sum::<f32>().abs() < 1e-6);
let (w, h) = (32, 32);
assert!(focus_measure(&vec![0.4; w * h], w, h, 1, 3).iter().all(|v| v.abs() < 1e-6));
let check: Vec<f32> = (0..w * h).map(|i| if ((i % w) / 2 + (i / w) / 2).is_multiple_of(2) { 0.2 } else { 0.8 }).collect();
assert!(focus_measure(&check, w, h, 1, 3).iter().cloned().fold(0.0, f32::max) > 0.1);
let (m, dw, dh) = block_mean(&check, w, h, 2);
assert_eq!((dw, dh, m.len()), (16, 16, 256));
assert!(m.iter().all(|v| (v - 0.2).abs() < 1e-6 || (v - 0.8).abs() < 1e-6));
let up = upsample_bilinear(&m, dw, dh, w, h, 2);
assert_eq!(up.len(), w * h);
}
#[test]
fn peak_search_interpolates_between_layers() {
// A profile peaking between layers 2 and 3 (log-parabola), one flat profile.
let mut t = PeakTracker::new(2);
for m in 0..6 {
let v = (-((m as f32 - 2.4).powi(2)) / 2.0).exp();
t.push(&[v, 0.1]);
}
let (d, c) = t.finish(0.0);
assert!((d[0] - 2.4).abs() < 0.05, "{d:?}");
assert!(c[0] > 0.5 && c[1] < 1e-6, "{c:?}");
}
#[test]
fn smoothing_fills_holes_and_respects_edges() {
let (w, h) = (40, 20);
// A guide with a hard edge at x = 20; data known only at two pixels, one per side.
let guide: Vec<f32> = (0..w * h).map(|i| if i % w < 20 { 0.0 } else { 1.0 }).collect();
let mut d = vec![0.0f32; w * h];
let mut wd = vec![1e-4f32; w * h];
(d[10 * w + 5], wd[10 * w + 5]) = (1.0, 1.0);
(d[10 * w + 35], wd[10 * w + 35]) = (5.0, 1.0);
let mut solver = WlsSolver::new(&guide, w, h, 3.0, 0.04);
solver.set_weights(&wd);
let (u, rel) = solver.solve(&d, CG_ITERS);
assert!(rel < 1e-4, "residual {rel}");
// Each side follows its own sample (the data floor on the unknown pixels pulls a little
// toward zero), and the edge keeps them apart.
assert!((u[3 * w + 15] - 1.0).abs() < 0.15, "left side follows its sample: {}", u[3 * w + 15]);
assert!((u[15 * w + 25] - 5.0).abs() < 0.6, "right side follows its sample: {}", u[15 * w + 25]);
assert!(u[10 * w + 19] < 1.5 && u[10 * w + 20] > 4.0, "the edge holds: {} | {}", u[10 * w + 19], u[10 * w + 20]);
// The sweeps alone leave the holes near zero; the solve fills them.
let guess = sweeps(&d, &wd, &solver.ex, &solver.ey, w, h, 3.0);
assert!(guess[3 * w + 15] < 0.5);
assert_eq!(median3(&[0.5; 9], 3, 3)[4], 0.5);
}
/// Three layers: the left third sharp in layer 0, the middle in 1, the right in 2. The depth
/// map says so, sub-layer in between, with high confidence on the texture.
#[test]
fn depth_from_focus_finds_the_sharp_layer() {
let (w, h) = (96, 48);
let texture = |x: usize, y: usize| if ((x / 2) + (y / 2)).is_multiple_of(2) { 0.2 } else { 0.8 };
let flat = 0.5;
let layers: Vec<Vec<f32>> = (0..3).map(|k| (0..w * h).map(|i| if (i % w) / 32 == k { texture(i % w, i / w) } else { flat }).collect()).collect();
let guide: Vec<f32> = (0..w * h).map(|i| texture(i % w, i / w)).collect();
let refs: Vec<&[f32]> = layers.iter().map(Vec::as_slice).collect();
let dm = depth_from_focus(&refs, &guide, w, h, &DepthParams::default());
assert_eq!((dm.w, dm.h, dm.depth.len()), (w, h, w * h));
for (x, want) in [(8, 0.0), (48, 1.0), (88, 2.0)] {
let d = dm.depth[24 * w + x];
assert!((d - want).abs() < 0.35, "x {x}: depth {d}, want {want}");
assert!(dm.conf[24 * w + x] > 0.3, "x {x}: conf {}", dm.conf[24 * w + x]);
}
assert!(dm.depth.iter().all(|v| (0.0..=2.0).contains(v)));
// Degenerate inputs are fine.
assert_eq!(depth_from_focus(&[], &guide, w, h, &DepthParams::default()).depth, vec![0.0; w * h]);
// A guide or a plane of the wrong length is no input: the empty map, never a panic.
let empty = DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h };
assert_eq!(depth_from_focus(&refs, &guide[1..], w, h, &DepthParams::default()), empty);
let short = &refs[0][..w * h - 1];
assert_eq!(depth_from_focus(&[refs[0], short], &guide, w, h, &DepthParams::default()), empty);
assert_eq!(depth_from_focus(&refs, &guide, w + 1, h, &DepthParams::default()).w, w + 1);
let one = depth_from_focus(&refs[..1], &guide, w, h, &DepthParams::default());
assert!(one.depth.iter().all(|v| *v == 0.0));
}
}
+7 -18
View File
@@ -160,30 +160,19 @@ pub(crate) fn polar(src: &Image, out: Rect, ctx: &Ctx, mode: PolarMode) -> Vec<f
let (w, h) = (b.width().max(1) as f32, b.height().max(1) as f32);
let (cx, cy) = (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0);
let rmax = w.min(h) / 2.0;
// Photoshop 27.11: the angle runs counter-clockwise from 12 o'clock, and points past the
// source's edge repeat its edge pixels (the corners outside the circle are filled, not empty).
// Photoshop evaluates the map at pixel corners and reads the source at the resulting index
// (half a pixel from `remap`'s centres): within 2 levels on a whole test chart in both directions.
remap(src, out, ctx, Edge::Repeat, |x, y| polar_map(x - 0.5, y - 0.5, mode, b, (w, h), (cx, cy), rmax))
}
fn polar_map(x: f32, y: f32, mode: PolarMode, b: Rect, (w, h): (f32, f32), (cx, cy): (f32, f32), rmax: f32) -> (f32, f32) {
let (sx, sy) = match mode {
remap(src, out, ctx, Edge::Transparent, |x, y| match mode {
PolarMode::RectangularToPolar => {
// Output is polar: angle from 12 o'clock counter-clockwise → source x, radius → source y.
// Output is polar: angle from 12 o'clock clockwise → source x,
// radius → source y.
let (dx, dy) = (x - cx, y - cy);
// The 12 o'clock seam itself reads the source's right edge (360°, not 0°).
let theta = (-dx).atan2(-dy);
let theta = if theta <= 0.0 { theta + std::f32::consts::TAU } else { theta };
let theta = dx.atan2(-dy).rem_euclid(std::f32::consts::TAU);
let r = (dx * dx + dy * dy).sqrt();
(b.x0 as f32 + theta / std::f32::consts::TAU * w, b.y0 as f32 + r / rmax * h)
}
PolarMode::PolarToRectangular => {
// Photoshop's angle is one column ahead of its radius row (fitted exactly).
let theta = (x + 1.0 - b.x0 as f32) / w * std::f32::consts::TAU;
let theta = (x - b.x0 as f32) / w * std::f32::consts::TAU;
let r = (y - b.y0 as f32) / h * rmax;
(cx - r * theta.sin(), cy - r * theta.cos())
(cx + r * theta.sin(), cy - r * theta.cos())
}
};
(sx + 0.5, sy + 0.5)
})
}
+1 -12
View File
@@ -18,8 +18,7 @@ pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection
let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
let bbox = region.bbox;
let bw = bbox.width() as usize;
let Some(pixels) = bw.checked_mul(bbox.height() as usize) else { return Rect::EMPTY };
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != pixels {
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize {
return Rect::EMPTY;
}
let fmt = target.format();
@@ -148,16 +147,6 @@ mod tests {
assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]);
}
#[test]
fn oversized_region_mask_is_rejected_without_overflow() {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
let before = s.pixel(0, 0);
let oversized = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![255] };
assert_eq!(magic_erase(&mut s, &oversized, 1.0, None, None), Rect::EMPTY);
assert_eq!(s.pixel(0, 0), before);
}
#[test]
fn degenerate_inputs_do_nothing() {
let mut s = Surface::new(PixelFormat::RGBA8);
+5 -132
View File
@@ -16,15 +16,6 @@
//! source (the usual case), the vote copies the source pixels exactly; only along the seams
//! between copied pieces does it blend.
//!
//! A thin hole (a selected wire, string or hair: long, but only a few patches thick) is
//! measured by its thickness, not its bounds (#1366): the pyramid stops where it is still about
//! two patches thick. That level is big, so the onion peel starts it only where its exhaustive
//! search stays affordable; otherwise a membrane across the hole does, which over so short a
//! distance is the surroundings themselves. Shrunk to where its bounds are four patches across,
//! a long thin hole swallows its surroundings, the matches found there copy whatever happens to
//! look like the blur (a dark blotch, or a piece of another string), and the finer levels,
//! which only refine those matches, keep them: the removed object's outline stays visible.
//!
//! This is what Photoshop's Content-Aware Fill does, measured as a black box on Photoshop 25.4
//! (see `content_aware`): with colour adaptation off its fill is pieces of the image copied pixel
//! for pixel, typically 15–40 px across, joined by seams a few pixels wide; with colour
@@ -287,38 +278,6 @@ fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize,
b
}
/// A hole at most `1 / THIN_RATIO` as thick as its bounds are long counts as thin (a wire, a
/// string, a hair, a long brush stroke): see the module docs.
const THIN_RATIO: usize = 4;
/// How thick the hole within bounds `hb` of a `w`-wide role grid is: twice the largest distance
/// from a hole pixel to the nearest pixel that is not one (a long, thin hole is thin however long
/// its bounds).
fn hole_thickness(w: usize, hb: Bounds, px: &[Px]) -> usize {
// The bounds and a ring of non-hole cells around them (also past the grid's edge).
let (gw, gh) = (hb.width() + 2, hb.height() + 2);
let outside: Vec<bool> = par::map(gw * gh, gw * gh >= PAR_MIN, |i| {
let (gx, gy) = (i % gw, i / gw);
if gx == 0 || gy == 0 || gx > hb.width() || gy > hb.height() {
return true;
}
px.get((hb.y0 + gy - 1) * w + hb.x0 + gx - 1) != Some(&Px::Hole)
});
let deepest = crate::selection::edt(&outside, gw, gh).into_iter().fold(0.0f32, f32::max);
(2.0 * deepest).ceil() as usize
}
/// Most target pixels × sources the onion peel of a thin hole may compare (its search is
/// exhaustive, though most comparisons stop early: about a second at this many).
const PEEL_BUDGET: usize = 1 << 28;
/// Whether the onion peel of the hole cells within `hb` of a `w`-wide role grid from `sources`
/// source centres stays within [`PEEL_BUDGET`].
fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool {
let holes: usize = (hb.y0..hb.y1).map(|y| px.get(y * w + hb.x0..y * w + hb.x1).map_or(0, |row| row.iter().filter(|k| **k == Px::Hole).count())).sum();
holes.saturating_mul(sources) <= PEEL_BUDGET
}
/// Bounds of the hole pixels of a role grid.
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option<Bounds> {
// Per row the first and last hole column; the union of the rows' spans.
@@ -549,9 +508,6 @@ struct Solver<'a> {
ctl: photocraft_raster::Interrupt<'a>,
}
/// A channel deviation under which a patch counts as flat for [`fit`] (about 5 levels of 255).
const FLAT: f32 = 0.02;
/// Gain and bias of one channel for target moments `(Σt, Σt²)` and source moments `(Σs, Σs²)`
/// over `n` samples, within the limits.
#[inline]
@@ -560,20 +516,7 @@ pub(crate) fn fit(a: Adapt, n: f32, st: f32, stt: f32, ss: f32, sss: f32) -> (f3
let vt = (stt / n - mt * mt).max(0.0);
let vs = (sss / n - ms * ms).max(0.0);
let g = if vs > 1e-10 { (vt / vs).sqrt() } else { 1.0 };
let (mut lo, mut hi) = a.gain;
// Nearly flat patches of about the same colour (their means within the bias of each other):
// the ratio of their deviations is noise, so match the mean first, keeping the gain where the
// bias can still bring the source's mean to the target's. Gained about zero, a gain at its
// limit moved bright means further than the bias could bring back, and each channel by a
// different amount: a tinted blotch in a smooth gradient. Textured patches, and flat ones
// whose colours differ more (Content-Aware Move's Color), are fitted as before.
if vt.max(vs) < FLAT * FLAT && (mt - ms).abs() <= a.bias && ms > 1e-6 {
let (glo, ghi) = ((mt - a.bias) / ms, (mt + a.bias) / ms);
if glo.max(lo) <= ghi.min(hi) {
(lo, hi) = (glo.max(lo), ghi.min(hi));
}
}
let g = g.clamp(lo, hi);
let g = g.clamp(a.gain.0, a.gain.1);
let b = (mt - g * ms).clamp(-a.bias, a.bias);
(g, b)
}
@@ -1616,20 +1559,14 @@ fn complete_cow<'a>(
let r = p.patch_radius.max(1);
let psz = 2 * r + 1;
// Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the
// structure where the texture has averaged out, and fills flat). A thin hole stops while it
// is still about two patches thick: through it, not along it, is what counts (#1366).
// structure where the texture has averaged out, and fills flat).
let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }];
let mut boxes = vec![hb0];
let mut ext = hb0.width().max(hb0.height());
let thickness = hole_thickness(w, hb0, px);
let thin = thickness.saturating_mul(THIN_RATIO) <= ext;
if thin {
ext = thickness;
}
let mut exts = vec![ext];
while let Some(l) = levels.last() {
ctl.check()?;
if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
if ext <= 4 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
break;
}
let d = downsample(l, ch);
@@ -1685,21 +1622,9 @@ fn complete_cow<'a>(
}
}
}
// A thin hole, when peeling it would take too long, takes the membrane below: a few
// patches thick here, a membrane across it is its surroundings.
None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => {
onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?
}
None if sources.len() > 0 => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?,
None => {
let mut unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
// Across a thin hole the membrane starts a patch radius out, past the
// anti-aliased fringe or halo that a selection drawn close to the object leaves.
if thin {
let hole: Vec<bool> = lpx.iter().map(|k| *k == Px::Hole).collect();
for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) {
*u |= d <= r as f32;
}
}
let unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
let m = membrane_fill(lw, lh, ch, &limg, &unknown);
for (i, k) in lpx.iter().enumerate() {
if *k == Px::Hole {
@@ -2034,21 +1959,6 @@ fn min_cut(across: usize, along: usize, err: impl Fn(usize, usize) -> f32) -> Ve
mod tests {
use super::*;
/// The mean is matched first: on a nearly flat bright patch with a noisy deviation ratio the
/// gain stays where the bias can still reach the target's mean.
#[test]
fn fit_matches_the_mean_before_the_contrast() {
let a = Adapt { gain: (0.9, 1.1), bias: 0.04 };
// Target mean 0.70, source mean 0.70; deviations 0.01 vs 0.004 (ratio 2.5).
let n = 25.0;
let (mt, ms, sdt, sds) = (0.70f32, 0.70f32, 0.01f32, 0.004f32);
let (g, b) = fit(a, n, mt * n, (sdt * sdt + mt * mt) * n, ms * n, (sds * sds + ms * ms) * n);
assert!((g * ms + b - mt).abs() < 1e-5, "mean {} vs {mt} (g {g}, b {b})", g * ms + b);
// Where the mean can't be reached anyway, the limits hold as before.
let (g, b) = fit(a, n, 0.2 * n, 0.04 * n, 0.9 * n, 0.81 * n);
assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6);
}
/// Smooth gradient plus deterministic fine noise.
fn texture(w: usize, h: usize) -> Vec<f32> {
let mut rng = Rng::new(7);
@@ -2173,43 +2083,6 @@ mod tests {
.collect()
}
#[test]
fn a_hole_is_as_thick_as_it_is_through_not_along() {
let (w, h) = (240, 120);
let all = Bounds { x0: 0, y0: 0, x1: w, y1: h };
let thickness = |hole: &[bool]| {
let px: Vec<Px> = hole.iter().map(|&o| if o { Px::Hole } else { Px::Source }).collect();
hole_thickness(w, hole_bounds(w, h, &px).unwrap_or(all), &px)
};
// A band 200 px long and 9 px thick; a disc of radius 20; the band touching the edge
// counts the outside of the image as not hole.
let band: Vec<bool> = (0..w * h).map(|i| (20..220).contains(&(i % w)) && (50..59).contains(&(i / w))).collect();
assert!((9..=11).contains(&thickness(&band)), "{}", thickness(&band));
assert!((39..=42).contains(&thickness(&disc(w, h, 120.0, 60.0, 20.0))));
let edge: Vec<bool> = (0..w * h).map(|i| i / w < 9).collect();
assert!((9..=11).contains(&thickness(&edge)), "{}", thickness(&edge));
}
/// A long thin hole goes the thin way (a shallow pyramid that starts from a membrane): it is
/// filled, only it changes, and the result does not depend on the thread count either.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn thin_holes_are_filled_the_same_on_any_number_of_threads() {
let (w, h) = (300, 200);
let img = busy(w, h, 3, 5);
// A wavy line 6 px thick across the image.
let hole: Vec<bool> = (0..w * h).map(|i| (((i % w) as f32 * 0.05).sin() * 30.0 + 100.0 - (i / w) as f32).abs() < 3.0).collect();
let px = roles(w, h, &hole);
let run = |threads: usize| {
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
pool.install(|| complete_masked(w, h, 3, &img, &px, &CompleteParams::default(), &photocraft_raster::Interrupt::NONE)).unwrap().unwrap()
};
let one = run(1);
assert_eq!(bits(&one), bits(&run(7)));
assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| one[i * 3 + c].to_bits() == img[i * 3 + c].to_bits())));
assert!((0..w * h).filter(|&i| hole[i]).all(|i| (0..3).all(|c| one[i * 3 + c].is_finite())));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn results_do_not_depend_on_the_thread_count() {
+20 -87
View File
@@ -26,7 +26,6 @@ pub mod camera_raw;
pub mod color_to_alpha;
pub mod content_aware;
mod denoise;
pub mod dff;
pub mod direct;
mod distort;
mod distort2;
@@ -553,8 +552,7 @@ impl FilterParams {
let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1);
match self {
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
FilterParams::SmartSharpen { radius, .. } => g(*radius),
FilterParams::UnsharpMask { radius, .. } | FilterParams::SmartSharpen { radius, .. } => g(*radius),
FilterParams::BoxBlur { radius }
| FilterParams::SurfaceBlur { radius, .. }
| FilterParams::Median { radius }
@@ -600,13 +598,6 @@ impl FilterParams {
self.halo() == Halo::Bounds
}
/// Whether the filter renders new pixels without reading the layer's own (Fibers): it fills
/// the reference bounds even where the layer is transparent, as Photoshop's does on an
/// empty layer, while still reading no neighbourhood.
pub fn renders_from_nothing(&self) -> bool {
matches!(self, FilterParams::Fibers { .. })
}
/// Human-readable name.
pub fn label(&self) -> &'static str {
match self {
@@ -749,7 +740,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
FilterParams::FindEdges => stylize::find_edges(src, out, ctx),
FilterParams::Solarize => stylize::solarize(src, out, ctx),
FilterParams::Solarize => stylize::per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }),
FilterParams::Invert => stylize::per_pixel(src, out, ctx, |v| 1.0 - v),
FilterParams::Desaturate => stylize::desaturate(src, out, ctx),
FilterParams::Twirl { angle } => distort::twirl(src, out, ctx, *angle),
@@ -912,13 +903,12 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
pub const TILE: i32 = 256;
/// The area a filter writes for a layer whose pixels cover `content`:
/// global filters and renders that ignore the layer's pixels (Fibers) write the reference
/// bounds; others the content grown by the halo (blurs spread into transparent areas).
/// Clipped to the selection's bounds when there is one.
/// global filters write the reference bounds; others the content grown by
/// the halo (blurs spread into transparent areas). Clipped to the
/// selection's bounds when there is one.
pub fn output_area(params: &FilterParams, content: Rect, bounds: Rect, selection_bounds: Option<Rect>) -> Rect {
let mut area = match params.halo_for(bounds) {
Halo::Bounds => bounds,
Halo::Radius(_) if params.renders_from_nothing() => bounds,
Halo::Radius(r) => {
if content.is_empty() {
Rect::EMPTY
@@ -947,8 +937,7 @@ pub fn apply_in(surface: &Surface, params: &FilterParams, area: Rect, bounds: Re
}
/// [`apply_in`] that can be cancelled (checked before each tile) and reports progress per tile
/// group. `None` when cancelled, or when a checked filter rejects geometry/allocation;
/// the input surface is never modified.
/// group. `None` when `ctl` was cancelled; the input surface is never modified.
pub fn apply_in_with(
surface: &Surface,
params: &FilterParams,
@@ -980,13 +969,13 @@ pub fn apply_tiled(
tile: i32,
extent: Option<Rect>,
) -> Surface {
// Preserve the input if a checked filter allocation cannot be completed.
// Preserve the input if a checked Shape Blur allocation cannot be completed.
apply_tiled_with(surface, params, area, bounds, selection, tile, extent, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|| surface.clone())
}
/// [`apply_tiled`] with cancellation (checked before each tile, so a cancel takes effect within
/// one tile's work) and progress (after each group of tiles). `None` when cancelled,
/// or when a checked filter rejects geometry or cannot allocate a tile.
/// or when Shape Blur cannot allocate a tile.
#[allow(clippy::too_many_arguments)]
pub fn apply_tiled_with(
surface: &Surface,
@@ -1011,27 +1000,15 @@ fn apply_tiled_with_impl(
tile: i32,
extent: Option<Rect>,
ctl: &photocraft_raster::Interrupt,
prepare_fast_paths: bool,
prepare_radial: bool,
) -> Option<Surface> {
if ctl.cancelled() {
return None;
}
if let FilterParams::UnsharpMask { amount, radius, threshold } = *params
&& (!sharpen::exact::valid_params(amount, radius, threshold)
|| !sharpen::exact::valid_area(area)
|| !sharpen::exact::valid_area(bounds)
|| extent.is_some_and(|e| !sharpen::exact::valid_area(e)))
{
return None;
}
let mut out = surface.clone();
if area.is_empty() {
return Some(out);
}
#[cfg(not(target_arch = "wasm32"))]
if prepare_fast_paths && let Some(result) = sharpen::fft::apply(surface, params, area, selection, extent, ctl) {
return result;
}
if let Some(boxes) = blur::box_widths(params) {
return apply_box_blur(out, surface, area, extent, selection, &boxes, ctl);
}
@@ -1039,15 +1016,15 @@ fn apply_tiled_with_impl(
return result;
}
// The public tile override must not create a zero-step loop or an
// unbounded scratch buffer for these checked filters. Automatic tiles are unchanged.
let tile = if matches!(params, FilterParams::ShapeBlur { .. } | FilterParams::UnsharpMask { .. }) { tile.clamp(1, 2048) } else { tile };
// unbounded Shape Blur scratch buffer. Standard automatic tiles are unchanged.
let tile = if matches!(params, FilterParams::ShapeBlur { .. }) { tile.clamp(1, 2048) } else { tile };
let fmt = surface.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
// Prepare the immutable sample transforms once for all output tiles. If the
// optional table allocation fails, use the existing sampler; only a real
// cancellation returns None. Source reads keep their upstream semantics.
let radial = match params {
FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_fast_paths => {
FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_radial => {
blur::radial::Plan::for_area(bounds, *amount, *method, *quality, (*center_x, *center_y), area, ctl)
}
_ => None,
@@ -1056,17 +1033,6 @@ fn apply_tiled_with_impl(
return None;
}
let halo = params.halo_for(bounds);
if matches!(params, FilterParams::UnsharpMask { .. }) {
let Halo::Radius(reach) = halo else { return None };
let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize);
let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?;
let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?;
// Reject hostile source-window sizes before Image::read allocates them.
// Ordinary filter parameters and automatic tiles fit these bounds.
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || tile_count > 1_048_576 {
return None;
}
}
let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area)));
// Shape rasterization depends only on params, not on a tile's pixels.
let shape = match params {
@@ -1124,15 +1090,12 @@ fn apply_tiled_with_impl(
None if ctl.cancelled() => None,
None => Some(kernel(params, src, *t, &ctx)),
},
(None, None) => match *params {
FilterParams::UnsharpMask { amount, radius, threshold } => sharpen::exact::run(src, *t, &ctx, amount, radius, threshold, ctl),
_ => Some(kernel(params, src, *t, &ctx)),
},
(None, None) => Some(kernel(params, src, *t, &ctx)),
}) else {
return (*t, Vec::new());
};
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, src, fmt.alpha);
mix_selection(&mut data, *t, sel, src);
}
tick();
(*t, data)
@@ -1160,11 +1123,9 @@ fn apply_tiled_with_impl(
if ctl.cancelled() {
return None;
}
// Checked filters can reject malformed geometry or an allocation failure.
// Shape Blur can reject malformed geometry or an allocation failure.
// Do not hand an incomplete tile to Surface::write_region.
if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. }))
&& results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels())
{
if shape.is_some() && results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels()) {
return None;
}
for (t, data) in results {
@@ -1218,7 +1179,7 @@ fn apply_box_blur(
let write = |(s, v): &mut (Surface, Vec<blur::Strip>)| {
for (t, data) in v.iter_mut() {
if let Some(sel) = selection {
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
mix_selection(data, *t, sel, &Image::read(surface, *t));
}
s.write_region(*t, data);
}
@@ -1241,40 +1202,15 @@ fn apply_box_blur(
}
/// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's
/// coverage (channel 0 of `sel`). Changing alpha requires premultiplied colour interpolation;
/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them.
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) {
/// coverage (channel 0 of `sel`).
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
let w = t.width() as usize;
// The selection over `t` read once, not a lookup and an allocation per pixel (#4533).
let mask = sel.read_region(t);
let sc = sel.channels().max(1);
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
let k = mask.get(i * sc).copied().unwrap_or(0.0).clamp(0.0, 1.0);
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
if k >= 1.0 {
continue;
}
if k <= 0.0 {
for (c, v) in px.iter_mut().enumerate() {
*v = orig.get(x, y, c);
}
continue;
}
if alpha {
let a = px.len() - 1;
let original_alpha = orig.get(x, y, a);
let filtered_alpha = px[a];
if original_alpha != filtered_alpha {
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
let original_weight = original_alpha * (1.0 - k);
let filtered_weight = filtered_alpha * k;
for (c, v) in px[..a].iter_mut().enumerate() {
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
}
px[a] = mixed_alpha;
continue;
}
}
for (c, v) in px.iter_mut().enumerate() {
let o = orig.get(x, y, c);
*v = o + (*v - o) * k;
@@ -1293,9 +1229,6 @@ mod tests_ext;
#[cfg(test)]
mod tests_mosaic;
#[cfg(test)]
mod tests_selection_mix;
/// The current upstream sampler through the identical source/selection/tile pipeline.
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
+56 -196
View File
@@ -154,7 +154,7 @@ impl LiquifyStroke {
}
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct LiquifyField {
pub bounds: Rect,
/// Document pixels per field node.
@@ -165,38 +165,6 @@ pub struct LiquifyField {
pub d: Vec<[f32; 2]>,
/// Freeze mask per node (0 = free, 1 = frozen).
pub freeze: Vec<f32>,
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
cursor: Option<StrokeCursor>,
}
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
/// transient brush state, not part of the result.
impl PartialEq for LiquifyField {
fn eq(&self, o: &Self) -> bool {
self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze
}
}
/// The brush stroke in progress: the point its last segment ended at, its last dab and how far
/// the brush has travelled along the path since that dab.
#[derive(Clone, Copy, Debug, PartialEq)]
struct StrokeCursor {
end: [f64; 2],
last_dab: [f64; 2],
carry: f64,
}
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
(r * 0.05).max(0.5)
}
/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze,
/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it
/// was with dabs a fifth of the radius apart.
fn moving_weight(r: f64) -> f64 {
dab_spacing(r) / (r * 0.2).max(0.5)
}
/// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond.
@@ -209,7 +177,7 @@ impl LiquifyField {
let cell = cell.clamp(1.0, 64.0);
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
}
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
@@ -252,7 +220,6 @@ impl LiquifyField {
/// Applies one stroke. Returns the document rectangle whose output changed.
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
self.cursor = None;
match s.tool {
LiquifyTool::LassoMask => return self.apply_lasso(s),
LiquifyTool::ReconstructAll => {
@@ -339,120 +306,77 @@ impl LiquifyField {
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
/// [`Self::apply_stroke`] does with the whole point list.
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY }
}
/// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled
/// along the whole stroke, carried from one segment to the next, so the result is the same
/// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
/// ended, the stroke starts afresh at `a`.
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
/// tool held still dabs once at `b`).
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
self.walk_segment(s, a, b, true)
}
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
/// would change nothing), which tests compare with the full walk.
fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect {
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let stationary = s.tool.is_stationary();
let cur = match self.cursor {
Some(c) if c.end == [a[0], a[1]] => c,
_ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 },
};
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = dx.hypot(dy);
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
if len == 0.0 {
self.cursor = Some(cur);
return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty };
}
// NaN or a length that overflowed: nothing sensible to walk; the next segment restarts.
if !len.is_finite() {
self.cursor = None;
return dirty;
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
}
let r = (s.size / 2.0).max(0.5);
let spacing = dab_spacing(r);
let weight = if stationary { moving_weight(r) } else { 1.0 };
// Distance along this segment of its first dab, then one every `spacing`.
let first = (spacing - cur.carry).clamp(0.0, spacing);
// Distances are compared with a little slack: a dab that rounding puts a hair past the
// segment's end still falls on it, so cutting one segment in two never adds or drops a dab.
let slack = spacing * 1e-6;
if first > len + slack {
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len });
return dirty;
let spacing = (r * 0.2).max(0.5);
let steps = (len / spacing).ceil().max(1.0) as usize;
// Walk only the steps whose dab can reach a field node: the rest return from `dab` before
// touching anything, and a segment to a far-off point has trillions of them (#937).
let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty };
for k in k0..=k1 {
let t0 = (k - 1) as f64 / steps as f64;
let t1 = k as f64 / steps as f64;
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let pr = pa + (pb - pa) * t1;
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&self.dab(s, centre, delta, pr));
}
let last_k = ((len - first + slack) / spacing).floor().max(0.0);
let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len];
let (mut k0, mut k1) = (0.0f64, last_k);
if skip {
match self.reachable_span(a, [dx, dy], len, r) {
// A dragging dab is centred one spacing behind its position: pad by that and a step.
Some((d0, d1)) => {
k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0);
k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0);
}
None => k1 = -1.0,
}
}
if k0 <= k1 {
let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab };
for k in (k0 as u64)..=(k1 as u64) {
let dist = (first + k as f64 * spacing).min(len);
let c = at(dist);
let delta = [c[0] - prev[0], c[1] - prev[1]];
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if stationary { c } else { prev };
dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight));
prev = c;
}
}
let last = (first + last_k * spacing).min(len);
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) });
dirty
}
/// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie
/// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs
/// near that span can touch a node; a segment to a far-off point has trillions of others
/// (#937).
fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> {
/// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or
/// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1
/// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a
/// node only when its centre is within `r` plus a node of the node grid, so the window below
/// pads that by another node and a step: the dabs it leaves out would each change nothing.
fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> {
if self.w == 0 || self.h == 0 {
return None;
}
let pad = r + 2.0 * self.cell;
let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad];
let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad];
let (mut u0, mut u1) = (0.0f64, 1.0f64);
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
let (mut k0, mut k1) = (1.0f64, n);
for ax in 0..2 {
let d = dir[ax];
let d = b[ax] - a[ax];
if d == 0.0 {
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
return None;
}
continue;
}
let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
u0 = u0.max(v0.min(v1));
u1 = u1.min(v0.max(v1));
let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0);
k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0);
}
if u0.is_nan() || u1.is_nan() || u0 > u1 {
// NaN: a segment whose length overflowed.
if k0.is_nan() || k1.is_nan() || k0 > k1 {
return None;
}
Some((u0 * len, u1 * len))
Some((k0 as usize, k1 as usize))
}
/// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the
/// document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect {
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
if s.tool == LiquifyTool::LassoMask {
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
}
let r = (s.size / 2.0).max(0.5);
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
let density = (s.density / 100.0).clamp(0.0, 1.0);
if strength <= 0.0 {
@@ -997,9 +921,21 @@ mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
/// The segment walk before #937: every dab, whether or not it can reach the field.
/// The segment walk before #937: every step, whether or not its dab can reach the field.
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
f.walk_segment(s, a, b, false)
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
let r = (s.size / 2.0).max(0.5);
let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize;
for k in 1..=steps {
let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64);
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1));
}
dirty
}
#[test]
@@ -1009,7 +945,7 @@ mod tests {
// every result bit-identical to the full walk.
use LiquifyTool::*;
let bounds = Rect::new(-7, 3, 89, 67);
let segments: [([f64; 3], [f64; 3]); 10] = [
let segments: [([f64; 3], [f64; 3]); 9] = [
([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside
([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left
([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards
@@ -1019,7 +955,6 @@ mod tests {
([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge
([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside
([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step
([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left
];
for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] {
for size in [6.0, 40.0] {
@@ -1038,81 +973,6 @@ mod tests {
}
}
/// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly.
fn sampled(path: &[[f64; 3]], parts: usize) -> Vec<Vec<f64>> {
let mut out = vec![path[0].to_vec()];
for w in path.windows(2) {
let (a, b) = (w[0], w[1]);
// Uneven steps, as a real pointer gives: t grows quadratically.
for k in 1..=parts {
let t = (k * k) as f64 / (parts * parts) as f64;
out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect());
}
}
out
}
/// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a
/// slow one (many close ones) warped the same path differently. Spacing now runs on along the
/// whole stroke: any sampling of a path gives the same field.
#[test]
fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() {
use LiquifyTool::*;
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]];
for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] {
for size in [12.0, 40.0] {
let field = |parts: usize| {
let mut warp = LiquifyStroke::new(ForwardWarp, 30.0);
warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]];
let mut s = LiquifyStroke::new(tool, size);
s.points = sampled(&path, parts);
// Some displacement first, so Reconstruct and Smooth have something to change.
LiquifyField::from_strokes(bounds(), 2.0, &[warp, s])
};
let coarse = field(1);
for parts in [3, 17, 60] {
let fine = field(parts);
let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max);
assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}");
}
}
}
}
/// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not
/// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds
/// up differs by a whole dab between a slow and a fast pointer.
#[test]
fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() {
let field = |step: f64| {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect();
LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s])
};
let (slow, fast) = (field(2.0), field(60.0));
let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement");
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
}
#[test]
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
// A segment that does not start where the last one ended begins a new run of dabs there.
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0);
let mut f = LiquifyField::new(bounds(), 2.0);
f.stroke_begin(&s, [10.0, 10.0, 1.0]);
f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]);
let mut g = LiquifyField::new(bounds(), 2.0);
f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
g.stroke_begin(&s, [40.0, 30.0, 1.0]);
g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
assert_eq!(f, g);
// Hostile points: nothing panics and the next segment starts afresh.
f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]);
f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]);
}
#[test]
fn a_segment_to_a_far_off_point_returns() {
// The issue's request: about 3.3e14 steps before the fix. Also a segment whose length
@@ -1250,8 +1110,8 @@ mod tests {
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
#[test]
fn forward_warp_leaves_the_brush_rim_in_place() {
// A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 1.0);
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 4.0);
for density in [0.0, 50.0, 100.0] {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
s.density = density;
+5 -51
View File
@@ -35,8 +35,7 @@ pub struct RefineParams {
pub feather: f32,
/// 0–100.
pub contrast: f32,
/// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole
/// pixels blend the two neighbouring shifts, so the slider does not jump.
/// −100–100 (% of `max(radius, 4 px)`).
pub shift_edge: f32,
}
@@ -60,18 +59,8 @@ impl RefineParams {
fn feather_sigma(&self) -> f32 {
self.feather.max(0.0) / 2.0
}
/// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64.
fn shift_amount(&self) -> f32 {
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0);
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
}
/// How many pixels the mask can move: the farther of the two integer radii a fractional
/// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`).
fn shift_px(&self) -> i32 {
let px = self.shift_amount();
let reach = px.abs().ceil();
let signed = if px.is_sign_negative() { -reach } else { reach };
if signed.is_finite() { signed as i32 } else { 0 }
(self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0)).round().clamp(-64.0, 64.0) as i32
}
fn uses_image(&self) -> bool {
self.radius > 0.0
@@ -381,38 +370,13 @@ where
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
}
}
let amount = p.shift_amount();
if amount.abs() >= 1.0e-4 {
a = shift_mask(&a, w, h, amount, morphology);
let d = p.shift_px();
if d != 0 {
a = morphology(&a, w, h, d.unsigned_abs() as usize, d > 0);
}
a
}
/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology;
/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px.
fn shift_mask<F>(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec<f32>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
{
let mag = amount.abs();
if !mag.is_finite() || mag < 1.0e-4 {
return src.to_vec();
}
let grow = amount > 0.0;
let whole = mag.floor() as usize;
let frac = mag - whole as f32;
if frac < 1.0e-4 {
return morphology(src, w, h, whole, grow);
}
let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) };
let hi = morphology(src, w, h, whole + 1, grow);
let n = w.saturating_mul(h);
if lo.len() < n || hi.len() < n || lo.len() != hi.len() {
return hi;
}
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
}
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
@@ -756,16 +720,6 @@ mod tests {
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
assert_eq!(shrunk[5 * w + 27], 1.0);
assert_eq!(shrunk[5 * w + 28], 0.0);
// Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel.
// The pixel just outside a hard edge fades in instead of snapping on.
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() });
let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() });
assert_eq!(m[5 * w + 30], 0.0);
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
assert_eq!(one[5 * w + 30], 1.0);
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() });
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
}
+2 -80
View File
@@ -128,32 +128,12 @@ pub(crate) fn edge_of(u: UndefinedAreas) -> Edge {
/// Shifts the bounds' content by whole pixels.
pub(crate) fn offset(src: &Image, out: Rect, ctx: &Ctx, dx: i32, dy: i32, undefined: UndefinedAreas) -> Vec<f32> {
let n = src.ch;
// Subtraction can leave i32 even for valid command parameters. Apply the edge
// policy before narrowing: saturating first would change the wrapped pixel.
let coordinate = |position: i32, distance: i32, start: i32, end: i32| -> Option<i32> {
if start >= end {
return None;
}
let p = i64::from(position) - i64::from(distance);
let (start, end) = (i64::from(start), i64::from(end));
let mapped = match undefined {
UndefinedAreas::Wrap => start + (p - start).rem_euclid(end - start),
UndefinedAreas::Repeat => p.clamp(start, end - 1),
UndefinedAreas::Transparent if p >= start && p < end => p,
UndefinedAreas::Transparent => return None,
};
i32::try_from(mapped).ok()
};
let edge = edge_of(undefined);
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
for y in out.y0..out.y1 {
let sy = coordinate(y, dy, ctx.bounds.y0, ctx.bounds.y1);
for x in out.x0..out.x1 {
let sx = coordinate(x, dx, ctx.bounds.x0, ctx.bounds.x1);
for c in 0..n {
res.push(match (sx, sy) {
(Some(sx), Some(sy)) => src.get(sx, sy, c),
_ => 0.0,
});
res.push(src.get_edge(x - dx, y - dy, c, edge, ctx.bounds));
}
}
}
@@ -206,61 +186,3 @@ mod tests {
}
}
}
#[cfg(test)]
mod offset_overflow_tests {
use super::*;
#[test]
fn offset_limits_match_equivalent_small_offsets_for_every_edge_policy() {
for bounds in [Rect::new(0, 0, 3, 5), Rect::new(-7, 11, -4, 16)] {
let mut src = Image::new(bounds, 4);
// Include signed/HDR values and varying alpha; offset must only move samples.
for (i, sample) in src.data.iter_mut().enumerate() {
*sample = i as f32 * 0.125 - 2.0;
}
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Rgb, alpha: true };
for policy in [UndefinedAreas::Wrap, UndefinedAreas::Repeat, UndefinedAreas::Transparent] {
for dx in [i32::MIN, i32::MAX, -8, 0, 7] {
for dy in [i32::MIN, i32::MAX, -9, 0, 6] {
let reduce = |d: i32, size: i32| match policy {
UndefinedAreas::Wrap => d.rem_euclid(size),
_ => d.clamp(-100, 100),
};
let (rx, ry) = (reduce(dx, 3), reduce(dy, 5));
let mut expected = Vec::new();
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
for c in 0..4 {
expected.push(src.get_edge(x - rx, y - ry, c, edge_of(policy), bounds));
}
}
}
assert_eq!(offset(&src, bounds, &ctx, dx, dy, policy), expected, "{bounds:?} {policy:?} {dx} {dy}");
}
}
}
}
}
#[test]
fn offset_wrap_handles_bounds_at_signed_coordinate_limits() {
for start in [i32::MIN, i32::MAX - 3] {
let bounds = Rect::new(start, start, start + 3, start + 3);
let mut src = Image::new(bounds, 1);
src.data = (0..9).map(|i| i as f32).collect();
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false };
assert_eq!(offset(&src, bounds, &ctx, i32::MIN, i32::MIN, UndefinedAreas::Wrap), vec![8.0, 6.0, 7.0, 2.0, 0.0, 1.0, 5.0, 3.0, 4.0]);
}
}
#[test]
fn offset_wrap_accepts_minimum_signed_distance() {
let bounds = Rect::new(0, 0, 3, 1);
let mut src = Image::new(bounds, 1);
src.data = vec![0.1, 0.5, 0.9];
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false };
let got = offset(&src, bounds, &ctx, i32::MIN, 0, UndefinedAreas::Wrap);
assert_eq!(got, vec![0.9, 0.1, 0.5]);
}
}
+2 -52
View File
@@ -63,7 +63,6 @@ fn composite_area(
coverage: impl Fn(i32, i32) -> f32 + Sync,
color: impl Fn(i32, i32) -> [f32; 4] + Sync,
lock_transparency: bool,
inks: Option<[f32; 4]>,
) {
// Composite tile by tile (in parallel on native); tiles with zero coverage are left untouched,
// so their copy-on-write storage (and GPU residency) survives.
@@ -94,14 +93,6 @@ fn composite_area(
o[3] = d[3];
}
from_rgba_into(&fmt, o, &mut enc);
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
if let Some(i) = inks
&& fmt.mode == photocraft_color::ColorMode::Cmyk
&& blend_mode == BlendMode::Normal
&& k >= 1.0
{
enc[..4].copy_from_slice(&i);
}
px.copy_from_slice(&enc[..n]);
}
}
@@ -153,7 +144,6 @@ pub fn paint_gradient(
c
},
lock_transparency,
None,
);
}
@@ -189,25 +179,6 @@ pub fn bucket_fill_src(
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) -> bool {
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None)
}
/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK).
#[allow(clippy::too_many_arguments)]
pub fn bucket_fill_inks(
s: &mut Surface,
area: Rect,
seed: (i32, i32),
tolerance: f32,
contiguous: bool,
anti_alias: bool,
opacity: f32,
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
inks: Option<[f32; 4]>,
) -> bool {
if !area.contains(seed.0, seed.1) {
return false;
@@ -215,7 +186,7 @@ pub fn bucket_fill_inks(
let img = crate::selection::rgba8_image(s, area);
let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false };
drop(img);
bucket_fill_region_inks(s, &region, opacity, selection, lock_transparency, src, inks);
bucket_fill_region_src(s, &region, opacity, selection, lock_transparency, src);
true
}
@@ -228,31 +199,10 @@ pub fn bucket_fill_region_src(
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) {
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None);
}
/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`].
pub fn bucket_fill_region_inks(
s: &mut Surface,
region: &crate::selection::Region,
opacity: f32,
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
inks: Option<[f32; 4]>,
) {
// Composite only over the filled region's box.
let b = region.bbox;
composite_area(
s,
b,
BlendMode::Normal,
|x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
src,
lock_transparency,
inks,
);
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency);
}
#[cfg(test)]
+2 -13
View File
@@ -115,11 +115,8 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
res
}
/// Crystallize: Voronoi cells around jittered points, each filled with the colour at its
/// point, as Photoshop's are: cells stay solid, so a black-and-white image (a layer mask
/// being distressed) keeps hard edges and its weight instead of greying where cells straddle
/// an edge. A cell whose point falls outside the image takes the (premultiplied) average
/// colour of its pixels.
/// Crystallize: Voronoi cells around jittered points, each filled with the
/// (premultiplied) average colour of its pixels.
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
@@ -180,14 +177,6 @@ pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
continue;
}
let (cx, cy) = if win.contains(x, y) { owners[i] } else { owner(x, y) };
let (sx, sy) = cell_point(cx, cy, cell, seed, origin);
let (sx, sy) = (sx.floor() as i32, sy.floor() as i32);
if src.rect.contains(sx, sy) && b.contains(sx, sy) {
for (c, v) in px.iter_mut().enumerate() {
*v = src.get(sx, sy, c);
}
continue;
}
if cx < c0.0 || cy < c0.1 || cx > c1.0 || cy > c1.1 {
continue;
}
+1 -1
View File
@@ -153,7 +153,7 @@ fn tiled_filters_match_scalar_at_all_depths_with_soft_selection() {
FilterParams::ColorHalftone { max_radius, angles } => reference::halftone(&image, bounds, &ctx, max_radius, angles),
_ => Vec::new(),
};
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
crate::mix_selection(&mut expected_data, bounds, &selection, &image);
let mut expected = surface.clone();
expected.write_region(bounds, &expected_data);
for tile in [1, 7, 17, 256] {
+2 -86
View File
@@ -1277,18 +1277,11 @@ pub const MAX_WARP_PIXELS: f64 = (1u64 << 30) as f64;
/// Applies a puppet warp to `src` (content inside `bounds`). `None` when the deformed mesh would
/// span more than [`MAX_WARP_PIXELS`].
pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) -> Option<Surface> {
puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out)
}
/// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same
/// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the
/// result when none was given.
pub fn puppet_warp_with_mask(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp, mask: Option<&Surface>) -> Option<(Surface, Option<Surface>)> {
let (solver, v, order) = deform(src, bounds, w, ITERATIONS);
// No triangles (an expansion that shrinks every pixel away): nothing to warp. Rendering the
// empty mesh would drop every pixel of the layer.
if solver.mesh.tris.is_empty() {
return Some((src.clone(), mask.cloned()));
return Some(src.clone());
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &v {
@@ -1298,57 +1291,7 @@ pub fn puppet_warp_with_mask(src: &Surface, bounds: Rect, w: &PuppetWarp, interp
if area.is_nan() || area > MAX_WARP_PIXELS {
return None;
}
let out = render(src, bounds, &solver.mesh, &v, &order, interp);
Some((out, mask.map(|m| render_mask(m, &solver.mesh, &v, &order, interp))))
}
/// Moves a one-channel layer mask with a deformed mesh: the mask under the mesh is lifted,
/// deformed like the layer's pixels and laid over the rest. The area the mesh leaves takes the
/// mask's default value (as [`crate::warp::warp_mesh_gray`] leaves it); outside the mesh the
/// mask keeps its values.
pub fn render_mask(mask: &Surface, mesh: &PuppetMesh, deformed: &[[f64; 2]], order: &[usize], interp: Interp) -> Surface {
let mut out = mask.clone();
if mesh.tris.is_empty() || deformed.len() != mesh.verts.len() || mask.format().channels() != 1 {
return out;
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &mesh.verts {
(x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1]));
}
if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
return out;
}
// The mesh's source area, a pixel wider so its edge samples stay inside.
let lim = f64::from(1 << 20);
let src = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1);
if src.is_empty() {
return out;
}
let default = mask.default_pixel().first().copied().unwrap_or(0.0);
let fmt = mask.format();
let mut tmp = Surface::new(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true));
tmp.write_region(src, &mask.read_region(src).iter().flat_map(|g| [*g, 1.0]).collect::<Vec<f32>>());
let tris: Vec<[usize; 3]> = order.iter().filter_map(|&t| mesh.tris.get(t).copied()).collect();
// Lift: where the undeformed mesh covers the mask, the mask takes its default value.
let still: Vec<([f64; 2], [f64; 2])> = mesh.verts.iter().map(|s| (*s, *s)).collect();
let lifted = crate::warp::warp_triangles(&tmp, src, &still, &tris, Interp::Nearest);
let b = lifted.content_bounds();
if !b.is_empty() {
let under = out.read_region(b);
let flat: Vec<f32> = lifted.read_region(b).as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| default * p[1] + u * (1.0 - p[1])).collect();
out.write_region(b, &flat);
}
// Lay the deformed mask over what is left.
let moved: Vec<([f64; 2], [f64; 2])> = deformed.iter().zip(&mesh.verts).map(|(d, s)| (*d, *s)).collect();
let warped = crate::warp::warp_triangles(&tmp, src, &moved, &tris, interp);
let b = warped.content_bounds();
if !b.is_empty() {
let under = out.read_region(b);
let flat: Vec<f32> = warped.read_region(b).as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| p[0] * p[1] + u * (1.0 - p[1])).collect();
out.write_region(b, &flat);
}
out.prune();
out
Some(render(src, bounds, &solver.mesh, &v, &order, interp))
}
#[cfg(test)]
@@ -1491,33 +1434,6 @@ mod tests {
assert!(!near.content_bounds().is_empty());
}
#[test]
fn a_linked_mask_moves_with_the_mesh_and_leaves_its_default_behind() {
// #4910. A hide-all mask (default 0) that reveals the bar's top (y 10..30): moved down by
// 8, the revealed patch moves along, what it left is hidden again, and the mask away
// from the puppet keeps its values.
let s = blob(SampleType::U8);
let b = s.content_bounds();
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut mask = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, sample, false), &[0.0]);
mask.fill_rect(Rect::new(20, 10, 36, 30), &[1.0]);
mask.fill_rect(Rect::new(90, 90, 94, 94), &[0.5]);
let w = PuppetWarp {
pins: vec![PuppetPin { src: [28.0, 30.0], dst: [28.0, 38.0], rotate: None, depth: 0 }],
mode: PuppetMode::Rigid,
density: PuppetDensity::Normal,
expansion: 2.0,
};
let (_, moved) = puppet_warp_with_mask(&s, b, &w, Interp::Bilinear, Some(&mask)).expect("warps");
let m = moved.expect("a mask in, a mask out");
let at = |x, y| m.sample_channel(x, y, 0);
assert!(at(28, 20) > 0.99 && at(28, 36) > 0.99, "{sample:?}: the revealed patch moved down");
assert!(at(28, 15) < 0.01, "{sample:?}: its old top is hidden again");
assert!((at(91, 91) - 0.5).abs() <= 1.0 / 255.0, "{sample:?}: away from the mesh nothing changes");
assert!(puppet_warp_with_mask(&s, b, &w, Interp::Bilinear, None).expect("warps").1.is_none());
}
}
#[test]
fn a_single_pin_translates_rigidly() {
let s = blob(SampleType::U8);
+29 -33
View File
@@ -117,53 +117,49 @@ pub fn sponge(c: [f32; 3], amount: f32, saturate: bool, vibrance: bool) -> [f32;
pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usize>, win: (usize, usize, usize, usize), sigma: f32) -> Vec<f32> {
let (x0, y0, x1, y1) = win;
let (ow, oh) = (x1 - x0, y1 - y0);
if ow == 0 || oh == 0 || ch == 0 {
return Vec::new();
}
let r = (sigma * 3.0).ceil().max(1.0) as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let stride = ow * ch;
let mut tmp = vec![0.0f32; stride * th];
let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
for ty in 0..th {
let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize;
// Clamp and premultiply each source pixel once, rather than once for every kernel tap.
for (rx, px) in row.chunks_exact_mut(ch).enumerate() {
let x = (x0 as i32 + rx as i32 - r).clamp(0, w as i32 - 1) as usize;
let i = (y * w + x) * ch;
px.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = px[a];
for (c, v) in px.iter_mut().enumerate() {
if c != a {
*v *= av;
}
let get = |x: i32, y: i32, out: &mut [f32]| {
let (xx, yy) = (x.clamp(0, w as i32 - 1) as usize, y.clamp(0, h as i32 - 1) as usize);
let i = (yy * w + xx) * ch;
out.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = out[a];
for (c, v) in out.iter_mut().enumerate() {
if c != a {
*v *= av;
}
}
}
let dst = &mut tmp[ty * stride..(ty + 1) * stride];
// Contiguous accumulation can be vectorized for any channel layout. Each output still
// sums the same products in the same kernel order as the scalar convolution.
for (i, kv) in k.iter().enumerate() {
for (d, s) in dst.iter_mut().zip(&row[i * ch..i * ch + stride]) {
*d += s * kv;
};
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let mut tmp = vec![0.0f32; ow * th * ch];
let mut px = vec![0.0f32; ch];
for ty in 0..th {
let y = y0 as i32 + ty as i32 - r;
for ox in 0..ow {
let o = (ty * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
get(x0 as i32 + ox as i32 + i as i32 - r, y, &mut px);
for c in 0..ch {
tmp[o + c] += px[c] * kv;
}
}
}
}
let mut out = vec![0.0f32; ow * oh * ch];
for oy in 0..oh {
let dst = &mut out[oy * stride..(oy + 1) * stride];
for (i, kv) in k.iter().enumerate() {
for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) {
*d += s * kv;
}
}
for ox in 0..ow {
let o = (oy * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
let s = ((oy + i) * ow + ox) * ch;
for c in 0..ch {
out[o + c] += tmp[s + c] * kv;
}
}
if let Some(a) = alpha {
let av = out[o + a];
for c in 0..ch {
+4 -161
View File
@@ -7,10 +7,9 @@
//! `γ e^{−β‖z_m − z_n‖²} / dist(m, n)` (γ = 50, β from the mean squared neighbour difference).
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::gmm::{DEFAULT_REG, Gmm};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_cancellable, subsample};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample};
/// Definite background.
pub const BG: u8 = 0;
@@ -34,13 +33,6 @@ fn is_fg(t: u8) -> bool {
/// Runs `iters` GrabCut iterations, updating the probable labels of `trimap` in place. Returns
/// the final (foreground, background) models, or `None` if either side has no pixels.
pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm, Gmm)> {
grabcut_with(img, trimap, iters, &Interrupt::NONE).unwrap_or_default()
}
/// [`grabcut`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked each iteration
/// and inside its min cut. `trimap` is then partly updated.
pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Interrupt) -> Result<Option<(Gmm, Gmm)>, Cancelled> {
let cancel = || ctl.cancelled();
let beta = contrast_beta(img);
let fixed: Vec<u8> = trimap
.iter()
@@ -52,22 +44,17 @@ pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Inter
.collect();
let mut models: Option<(Gmm, Gmm)> = None;
for _ in 0..iters.max(1) {
ctl.check()?;
let (mut fs, mut bs) = (Vec::new(), Vec::new());
for (p, t) in img.px.iter().zip(trimap.iter()) {
if is_fg(*t) { fs.push(*p) } else { bs.push(*p) }
}
let (fs, bs) = (subsample(&fs, MAX_SAMPLES), subsample(&bs, MAX_SAMPLES));
let (fg, bg) = match &models {
None => match (Gmm::fit(&fs, K, DEFAULT_REG), Gmm::fit(&bs, K, DEFAULT_REG)) {
(Some(f), Some(b)) => (f, b),
_ => return Ok(None),
},
None => (Gmm::fit(&fs, K, DEFAULT_REG)?, Gmm::fit(&bs, K, DEFAULT_REG)?),
Some((f, b)) => (f.refit(&fs).unwrap_or_else(|| f.clone()), b.refit(&bs).unwrap_or_else(|| b.clone())),
};
let (cf, cb) = data_costs(img, &fg, &bg);
// `grid_cut`'s weight, with the cut stopping on cancel.
let cut = grid_cut_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let cut = grid_cut(img, &cf, &cb, &fixed, GAMMA, beta);
let mut changed = 0usize;
for (t, fgnd) in trimap.iter_mut().zip(&cut) {
if *t == PR_BG || *t == PR_FG {
@@ -81,7 +68,7 @@ pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Inter
break;
}
}
Ok(models)
models
}
/// Object Selection (rectangle mode): GrabCut initialised from `rect` (outside = background,
@@ -129,147 +116,3 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u
let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
}
/// Working-resolution budget (pixels) for [`object_at`]: small enough to run while the pointer
/// hovers.
pub const OBJECT_AT_WORK_PX: usize = 60_000;
/// Object Selection's Object Finder (hover / click): the object under document point `(x, y)`.
///
/// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the
/// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both
/// by the distance to the seed's running mean and by the local edge step), so it stops at the
/// object's outline. A region that grows over most of the canvas is the background: nothing is
/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable
/// foreground, the cursor definite foreground, outside the box definite background), the part
/// connected to the cursor is kept and the boundary is re-cut at full resolution, as the
/// rectangle mode does.
pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option<Region> {
if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() {
return None;
}
let (px, py) = (x.floor() as i32, y.floor() as i32);
if !canvas.contains(px, py) {
return None;
}
let step = super::scale_for(canvas, work_px.max(1024));
let img = sampler.rgb_scaled(canvas, step);
let (w, h) = (img.w, img.h);
if w < 2 || h < 2 {
return None;
}
// Lightly smoothed, so noise and texture don't stop the growth; edges survive.
let lab: Vec<[f32; 3]> = box3(&box3(&img.px, w, h), w, h).iter().map(|c| super::slic::rgb_to_lab(*c)).collect();
let sx = (((px - canvas.x0) as usize) / step).min(w - 1);
let sy = (((py - canvas.y0) as usize) / step).min(h - 1);
let grown = grow_similar(&lab, w, h, sx, sy);
let count = grown.iter().filter(|g| **g).count();
// Background (or a flat image): no single object under the cursor.
if count * 10 > w * h * 6 || count < 4 {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
for (i, g) in grown.iter().enumerate() {
if *g {
let (gx, gy) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(gx), y0.min(gy), x1.max(gx + 1), y1.max(gy + 1));
}
}
let pad = ((x1 - x0).max(y1 - y0) / 6).max(2);
let (bx0, by0, bx1, by1) = (x0.saturating_sub(pad), y0.saturating_sub(pad), (x1 + pad).min(w), (y1 + pad).min(h));
let mut trimap = vec![BG; w * h];
let mut any_bg = false;
for yy in 0..h {
for xx in 0..w {
let i = yy * w + xx;
let inside = xx >= bx0 && xx < bx1 && yy >= by0 && yy < by1;
trimap[i] = if grown[i] {
PR_FG
} else if inside {
PR_BG
} else {
any_bg = true;
BG
};
}
}
if !any_bg {
// The box is the whole canvas: the frame is background.
for yy in 0..h {
for xx in 0..w {
if (xx == 0 || yy == 0 || xx + 1 == w || yy + 1 == h) && !(xx == sx && yy == sy) {
trimap[yy * w + xx] = BG;
}
}
}
}
trimap[sy * w + sx] = FG;
let (fg, bg) = grabcut(&img, &mut trimap, 5)?;
let low: Vec<bool> = trimap.iter().map(|t| is_fg(*t)).collect();
let mut seeds = vec![false; w * h];
seeds[sy * w + sx] = true;
let low = super::keep_seeded(&low, &seeds, w, h);
let low = super::clean_mask(&low, w, h, 0.1, 0.01);
if !low.iter().any(|v| *v) {
return None;
}
super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg)))
}
/// Region growing (4-connected) from `(sx, sy)` over pixels whose CIELAB colour is within a
/// distance of the seed area's running mean and whose step from the neighbour it was reached
/// from is small, so it stops at edges.
fn grow_similar(lab: &[[f32; 3]], w: usize, h: usize, sx: usize, sy: usize) -> Vec<bool> {
const MEAN_DIST: f32 = 32.0;
const EDGE_STEP: f32 = 10.0;
let d = |a: [f32; 3], b: [f32; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
let mut out = vec![false; w * h];
let Some(&seed) = lab.get(sy * w + sx) else { return out };
let (mut sum, mut n) = ([seed[0] as f64, seed[1] as f64, seed[2] as f64], 1.0f64);
let mut stack = vec![(sx, sy)];
out[sy * w + sx] = true;
while let Some((x, y)) = stack.pop() {
let c = lab[y * w + x];
let mean = [(sum[0] / n) as f32, (sum[1] / n) as f32, (sum[2] / n) as f32];
let nbrs = [(x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1)];
for (nx, ny) in nbrs {
if nx >= w || ny >= h {
continue;
}
let i = ny * w + nx;
if out[i] {
continue;
}
let q = lab[i];
if d(q, c) <= EDGE_STEP && d(q, mean) <= MEAN_DIST {
out[i] = true;
sum = [sum[0] + q[0] as f64, sum[1] + q[1] as f64, sum[2] + q[2] as f64];
n += 1.0;
stack.push((nx, ny));
}
}
}
out
}
/// 3×3 box blur (clamped at the edges).
fn box3(px: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> {
let mut out = vec![[0.0f32; 3]; px.len()];
for y in 0..h {
for x in 0..w {
let (mut acc, mut n) = ([0.0f32; 3], 0.0f32);
for yy in y.saturating_sub(1)..(y + 2).min(h) {
for xx in x.saturating_sub(1)..(x + 2).min(w) {
if let Some(p) = px.get(yy * w + xx) {
acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]];
n += 1.0;
}
}
}
if let Some(o) = out.get_mut(y * w + x) {
*o = acc.map(|v| v / n.max(1.0));
}
}
}
out
}
@@ -71,10 +71,7 @@ impl Graph {
let ai = a as usize;
let j = self.head[ai] as usize;
work += 1;
// `checked_add`: two nodes a global relabel left unreachable (`u32::MAX`) must
// not count as admissible for each other, or float-stranded excess ping-pongs
// between them forever.
if self.r_cap[ai] > EPS && self.dist[j].checked_add(1) == Some(self.dist[i]) {
if self.r_cap[ai] > EPS && self.dist[i] == self.dist[j].saturating_add(1) {
let delta = (excess[i] as f32).min(self.r_cap[ai]);
self.r_cap[ai] -= delta;
self.r_cap[ai ^ 1] += delta;
@@ -179,28 +179,3 @@ fn solver_workload_release_comparison() {
}
}
}
#[test]
fn stranded_excess_terminates_and_matches_bk() {
// Source capacities far beyond f32 precision (ulp 32 at 3e8) next to small links: rounding
// strands excess on nodes a global relabel finds reachable from neither terminal. Those
// nodes (height u32::MAX) used to count as admissible for each other and pushed the excess
// back and forth forever (Remove Background never finished on some photos).
let mut g = Graph::with_capacity(6, 7);
for (i, &(s, t)) in [(33123824.0, 0.0), (347622050.0, 0.0), (0.0, 5539047.5), (76592.22, 0.0), (3514.0022, 0.0), (0.0, 13670.268)].iter().enumerate() {
g.add_tweights(i, s, t);
}
for (i, j, c) in [(0, 1, 0.0), (0, 3, 0.0007301044), (1, 2, 2.803e-42), (1, 4, 8.160078), (2, 5, 3.1224866e-7), (3, 4, 28.512966), (4, 5, 4.099367)] {
g.add_edge(i, j, c, c);
}
let mut bk = g.clone();
let checks = std::cell::Cell::new(0);
let cancel = || {
checks.set(checks.get() + 1);
checks.get() > 1000
};
let flow = g.push_relabel(&cancel).expect("push/relabel did not terminate");
let bk_flow = bk.maxflow_bk();
assert!((flow - bk_flow).abs() <= 1e-6 * bk_flow, "{flow} vs {bk_flow}");
assert_eq!((0..6).map(|i| g.in_source(i)).collect::<Vec<_>>(), (0..6).map(|i| bk.in_source(i)).collect::<Vec<_>>());
}
+9 -93
View File
@@ -35,7 +35,7 @@ pub mod slic;
pub mod subject;
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt, Surface};
use photocraft_raster::Surface;
pub use crate::selection::Region;
use gmm::Gmm;
@@ -288,9 +288,6 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
if count == 0 {
return Some(out);
}
if cancel() {
return None;
}
let mut g = Graph::with_capacity(count as usize, count as usize * 4);
for i in 0..n {
if idx[i] != u32::MAX {
@@ -333,9 +330,6 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
}
}
}
if cancel() {
return None;
}
if OPTIMIZED {
g.maxflow_with(cancel)?;
} else {
@@ -448,51 +442,6 @@ pub fn clean_mask(mask: &[bool], w: usize, h: usize, frac: f32, hole_frac: f32)
out
}
/// Fills the holes of `mask` (4-connected background areas not touching the border) by what
/// they look like: a hole up to `speck_frac` of the mask's area always fills; a larger one, up
/// to `hole_frac`, only when its pixels fit the foreground model `fg` better than `bg` on
/// average, so an enclosed patch of backdrop stays open.
pub fn fill_holes_by_model(mask: &[bool], img: &RgbImage, fg: &Gmm, bg: &Gmm, speck_frac: f32, hole_frac: f32) -> Vec<bool> {
let (w, h) = (img.w, img.h);
let mut out = mask.to_vec();
let area = out.iter().filter(|v| **v).count();
let max_speck = (area as f32 * speck_frac) as usize;
let max_hole = (area as f32 * hole_frac) as usize;
let inv: Vec<bool> = out.iter().map(|v| !v).collect();
let (hl, hs) = components4(&inv, w, h);
let mut touches = vec![false; hs.len()];
for x in 0..w {
touches[hl[x] as usize] = true;
touches[hl[(h - 1) * w + x] as usize] = true;
}
for y in 0..h {
touches[hl[y * w] as usize] = true;
touches[hl[y * w + w - 1] as usize] = true;
}
// Summed data costs per hole; only holes small enough to be candidates are evaluated.
let mut cost = vec![(0.0f64, 0.0f64); hs.len()];
for (i, l) in hl.iter().enumerate() {
let l = *l as usize;
if l == 0 || touches[l] || hs[l] > max_hole || hs[l] <= max_speck {
continue;
}
if let Some(p) = img.px.get(i) {
cost[l].0 += f64::from(fg.neg_log(*p));
cost[l].1 += f64::from(bg.neg_log(*p));
}
}
for (o, l) in out.iter_mut().zip(&hl) {
let l = *l as usize;
if l == 0 || touches[l] || hs[l] > max_hole {
continue;
}
if hs[l] <= max_speck || cost[l].0 <= cost[l].1 {
*o = true;
}
}
out
}
fn components4(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
let mut lab = vec![0u32; w * h];
let mut sizes = vec![0usize];
@@ -590,22 +539,6 @@ pub fn trim_region(r: Region) -> Option<Region> {
/// pixels wide around the upsampled boundary, using the given colour models, tile by tile.
/// The result is anti-aliased by one pixel.
pub fn finish_region(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], lw: usize, lh: usize, models: Option<(&Gmm, &Gmm)>) -> Option<Region> {
finish_region_with(sampler, window, step, low, lw, lh, models, &Interrupt::NONE).unwrap_or_default()
}
/// [`finish_region`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked before each
/// band tile and inside its min cut.
#[allow(clippy::too_many_arguments)]
pub fn finish_region_with(
sampler: &dyn Sampler,
window: Rect,
step: usize,
low: &[bool],
lw: usize,
lh: usize,
models: Option<(&Gmm, &Gmm)>,
ctl: &Interrupt,
) -> Result<Option<Region>, Cancelled> {
let (w, h) = (window.width() as usize, window.height() as usize);
let mut mask = vec![0u8; w * h];
if step <= 1 {
@@ -643,28 +576,16 @@ pub fn finish_region_with(
#[cfg(target_arch = "wasm32")]
mask.chunks_mut(w).enumerate().for_each(|(y, r)| fill(y, r));
if let Some((fg, bg)) = models {
refine_band(sampler, window, step, low, &band, lw, lh, fg, bg, &mut mask, ctl)?;
refine_band(sampler, window, step, low, &band, lw, lh, fg, bg, &mut mask);
}
}
antialias_u8(&mut mask, w, h);
Ok(trim_region(Region { bbox: window, mask }))
trim_region(Region { bbox: window, mask })
}
/// Full-resolution graph cut restricted to the band blocks (see [`finish_region`]).
#[allow(clippy::too_many_arguments)]
fn refine_band(
sampler: &dyn Sampler,
window: Rect,
step: usize,
low: &[bool],
band: &[bool],
lw: usize,
lh: usize,
fg: &Gmm,
bg: &Gmm,
mask: &mut [u8],
ctl: &Interrupt,
) -> Result<(), Cancelled> {
fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], band: &[bool], lw: usize, lh: usize, fg: &Gmm, bg: &Gmm, mask: &mut [u8]) {
const T: i32 = 192;
const HALO: i32 = 12;
let w = window.width() as usize;
@@ -685,9 +606,7 @@ fn refine_band(
}
ty += T;
}
let cancel = || ctl.cancelled();
let run = |t: &Rect| -> Result<(Rect, Vec<bool>), Cancelled> {
ctl.check()?;
let run = |t: &Rect| -> (Rect, Vec<bool>) {
let r = t.inflate(HALO).intersect(&window);
let img = sampler.rgb(r);
let (rw, rh) = (r.width() as usize, r.height() as usize);
@@ -710,8 +629,7 @@ fn refine_band(
}
}
let beta = contrast_beta(&img);
// `grid_cut`'s weight, with the cut stopping on cancel.
let cut = grid_cut_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let cut = grid_cut(&img, &cfg, &cbg, &fixed, 50.0, beta);
let (cw, ch) = (t.width() as usize, t.height() as usize);
let mut core = Vec::with_capacity(cw * ch);
for yy in 0..ch {
@@ -719,16 +637,15 @@ fn refine_band(
let sx = (t.x0 - r.x0) as usize;
core.extend_from_slice(&cut[sy * rw + sx..sy * rw + sx + cw]);
}
Ok((*t, core))
(*t, core)
};
// A cancelled tile ends the collect; tiles not yet started see `ctl` and return at once.
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<bool>)> = {
use rayon::prelude::*;
tiles.par_iter().map(run).collect::<Result<_, _>>()?
tiles.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<bool>)> = tiles.iter().map(run).collect::<Result<_, _>>()?;
let results: Vec<(Rect, Vec<bool>)> = tiles.iter().map(run).collect();
for (t, core) in results {
let cw = t.width() as usize;
for (yy, row) in core.chunks_exact(cw).enumerate() {
@@ -738,7 +655,6 @@ fn refine_band(
}
}
}
Ok(())
}
/// Deterministic pseudo-random numbers (xorshift) for sampling and tests.
+19 -119
View File
@@ -88,19 +88,6 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3
}
pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option<Region> {
quick_select_batched::<OPTIMIZED>(sampler, canvas, points, size, t, SPECULATE).map(|(region, _)| region)
}
/// [`quick_select_using`] cutting `batch` window sizes at a time (1: one after another). Also
/// returns how many window sizes, smallest first, it took to find the kept one.
pub(super) fn quick_select_batched<const OPTIMIZED: bool>(
sampler: &dyn Sampler,
canvas: Rect,
points: &[(f32, f32)],
size: f32,
t: &Tuning,
batch: usize,
) -> Option<(Region, usize)> {
if points.is_empty() || canvas.is_empty() {
return None;
}
@@ -120,15 +107,8 @@ pub(super) fn quick_select_batched<const OPTIMIZED: bool>(
let step = working_step(canvas.width() as usize, canvas.height() as usize) as i32;
let rw = r / step as f32;
// Work in a window around the stroke; grow it while the selection runs into its border.
// The window's sides are free like the canvas' edges, so a selection that stays clear of
// them is kept; one that reaches a side inside the canvas is recomputed in a larger window.
// Index of the smallest window kept so far in this batch: larger ones stop cutting.
let kept = std::sync::atomic::AtomicUsize::new(usize::MAX);
let attempt = |index: usize, margin: i32| -> Attempt {
let cancel = || kept.load(std::sync::atomic::Ordering::Relaxed) < index;
if cancel() {
return Attempt::Cancelled;
}
let mut margin = (48.0f32).max(2.0 * rw) as i32 * step;
loop {
let snap0 = |v: i32, o: i32| o + (v - o).div_euclid(step) * step;
let grown = bbox.inflate(margin).intersect(&canvas);
let window = Rect::new(snap0(grown.x0, canvas.x0), snap0(grown.y0, canvas.y0), grown.x1, grown.y1);
@@ -138,13 +118,13 @@ pub(super) fn quick_select_batched<const OPTIMIZED: bool>(
let pts: Vec<(f32, f32)> = points.iter().map(|&(x, y)| ((x - window.x0 as f32) / s, (y - window.y0 as f32) / s)).collect();
let seeds = stroke_mask(&pts, rw.max(0.5), lw, lh);
if !seeds.iter().any(|v| *v) {
return Attempt::NoSeeds;
}
if cancel() {
return Attempt::Cancelled;
return None;
}
// The window's sides are free like the canvas' edges, so a selection that stays clear of
// them is the best over the whole canvas too (outside the window it could only add cost).
// One that reaches a side inside the canvas is recomputed in a larger window.
let inner = [window.x0 > canvas.x0, window.y0 > canvas.y0, window.x1 < canvas.x1, window.y1 < canvas.y1];
let Some(low) = select_working_using::<OPTIMIZED>(&img, &seeds, rw, t, &cancel) else { return Attempt::Cancelled };
let low = select_working_using::<OPTIMIZED>(&img, &seeds, rw, t);
let touches = |side: usize| match side {
0 => (0..lh).any(|y| low[y * lw]),
1 => (0..lw).any(|x| low[x]),
@@ -152,87 +132,21 @@ pub(super) fn quick_select_batched<const OPTIMIZED: bool>(
_ => (0..lw).any(|x| low[(lh - 1) * lw + x]),
};
if (0..4).any(|k| inner[k] && touches(k)) {
return Attempt::Touches;
margin = margin.saturating_mul(2);
continue;
}
kept.fetch_min(index, std::sync::atomic::Ordering::Relaxed);
Attempt::Done { window, low, lw, lh }
};
let mut margin = (48.0f32).max(2.0 * rw) as i32 * step;
let mut sizes = 0;
loop {
// The attempts don't depend on each other, so a few window sizes are cut at once and the
// first in growing order that stays clear of its sides is kept: the same result as trying
// them one after another, without waiting for the smaller ones to fail first. Once one is
// kept, the larger ones still cutting stop, so a stroke settled by a small window costs
// about what that window alone costs.
let batch = margins_to_try(margin, bbox, canvas, batch);
kept.store(usize::MAX, std::sync::atomic::Ordering::Relaxed);
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<Attempt> = {
use rayon::prelude::*;
batch.par_iter().enumerate().map(|(i, &m)| attempt(i, m)).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<Attempt> = batch.iter().enumerate().map(|(i, &m)| attempt(i, m)).collect();
for result in results {
sizes += 1;
match result {
Attempt::NoSeeds => return None,
// Only a window larger than one already kept is cancelled, and that one comes
// first in this order, so a cancelled attempt is never reached.
Attempt::Touches | Attempt::Cancelled => continue,
Attempt::Done { window, low, lw, lh } => {
let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize);
return super::trim_region(Region { bbox: window, mask }).map(|region| (region, sizes));
}
}
}
let &last = batch.last()?;
margin = last.saturating_mul(2);
let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize);
return super::trim_region(Region { bbox: window, mask });
}
}
/// One window size's outcome in [`quick_select_using`].
enum Attempt {
/// The stroke misses the window.
NoSeeds,
/// The selection reaches a side of the window inside the canvas.
Touches,
/// A smaller window of the same batch was kept first.
Cancelled,
Done {
window: Rect,
low: Vec<bool>,
lw: usize,
lh: usize,
},
}
/// Window sizes cut together: most strokes settle within this many doublings.
const SPECULATE: usize = 4;
/// The next `count` margins from `margin`, doubling, stopping at the first one whose window
/// already covers the whole canvas (a larger one would be the same window).
pub(super) fn margins_to_try(margin: i32, bbox: Rect, canvas: Rect, count: usize) -> Vec<i32> {
let mut out = Vec::with_capacity(count.max(1));
let mut m = margin;
for _ in 0..count.max(1) {
out.push(m);
if bbox.inflate(m).intersect(&canvas) == canvas {
break;
}
m = m.saturating_mul(2);
}
out
}
/// The model at working resolution: `seeds` (the brush footprint, `radius` working pixels) are
/// selected; returns the selection.
pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec<bool> {
select_working_using::<true>(img, seeds, radius, t, &|| false).unwrap_or_default()
select_working_using::<true>(img, seeds, radius, t)
}
fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning, cancel: &dyn Fn() -> bool) -> Option<Vec<bool>> {
fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec<bool> {
let (w, h) = (img.w, img.h);
let d = crate::selection::edt(seeds, w, h);
let grow = (t.grow + t.grow_frac * radius).max(0.0);
@@ -240,10 +154,7 @@ fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], r
let fixed: Vec<u8> = d.iter().map(|d| if *d <= grow { HARD_FG } else { FREE }).collect();
let (floor, sigma, k) = (t.floor, t.sigma.max(1e-4), t.k);
let weight = move |d2: f32| floor + 1.0 / (1.0 + (d2.sqrt() / sigma).powf(k));
if cancel() {
return None;
}
cut_levels::<OPTIMIZED>(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024), cancel)
cut_levels::<OPTIMIZED>(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024))
}
/// Band half-width (pixels) re-cut at each finer level.
@@ -253,19 +164,11 @@ const BAND: f32 = 3.0;
/// problem (colours averaged, pixel costs summed, cut weights doubled since a coarse side spans two
/// fine ones) gives a boundary, and the full-size cut is redone only within `BAND` of it. Seeds
/// stay foreground.
fn cut_levels<const OPTIMIZED: bool>(
img: &RgbImage,
fixed: &[u8],
cost_fg: &[f32],
weight: &dyn Fn(f32) -> f32,
scale: f32,
direct_px: usize,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec<bool> {
let (w, h) = (img.w, img.h);
let zeros = vec![0.0f32; w * h];
if w * h <= direct_px || w < 8 || h < 8 {
return super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel);
return super::grid_cut_weighted::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2));
}
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let coarse_img = img.downsample(2);
@@ -280,10 +183,7 @@ fn cut_levels<const OPTIMIZED: bool>(
}
}
}
let coarse = cut_levels::<OPTIMIZED>(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px, cancel)?;
if cancel() {
return None;
}
let coarse = cut_levels::<OPTIMIZED>(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px);
let proj: Vec<bool> = (0..w * h).map(|i| coarse[(i / w / 2) * cw + (i % w) / 2]).collect();
// Band: within BAND of the projected boundary.
let edge: Vec<bool> = (0..w * h)
@@ -294,7 +194,7 @@ fn cut_levels<const OPTIMIZED: bool>(
})
.collect();
if !edge.iter().any(|e| *e) {
return Some((0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect());
return (0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect();
}
let near = crate::selection::edt(&edge, w, h);
let banded: Vec<u8> = (0..w * h)
@@ -310,7 +210,7 @@ fn cut_levels<const OPTIMIZED: bool>(
}
})
.collect();
super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel)
super::grid_cut_weighted::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2))
}
/// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel.
-34
View File
@@ -132,31 +132,9 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
}
}
let mask = clean_mask(&mask, w, h, 0.002, 0.01);
if is_backdrop(img, &mask, top) {
return None;
}
mask.iter().any(|v| *v).then_some(mask)
}
/// A neutral region that also covers most of the bottom rows is a studio or product backdrop,
/// not an overcast sky (which never reaches the bottom of a photo); blue sky is never one.
fn is_backdrop(img: &RgbImage, mask: &[bool], rows: usize) -> bool {
let (w, h) = (img.w, img.h);
let bottom = (h - rows.min(h)) * w..h * w;
let Some(bottom_rows) = mask.get(bottom) else { return false };
let covered = bottom_rows.iter().filter(|m| **m).count();
if covered * 10 < bottom_rows.len() * 6 {
return false;
}
let (mut sat, mut n) = (0.0f32, 0usize);
for (p, _) in img.px.iter().zip(mask).filter(|(_, m)| **m) {
let (mx, mn) = (p[0].max(p[1]).max(p[2]), p[0].min(p[1]).min(p[2]));
sat += if mx > 1e-4 { (mx - mn) / mx } else { 0.0 };
n += 1;
}
n > 0 && sat / (n as f32) < 0.1
}
/// Selects the sky over `canvas`. `None` if no sky was found.
pub fn select_sky(sampler: &dyn Sampler, canvas: Rect, p: SkyParams) -> Option<Region> {
if canvas.width() < 4 || canvas.height() < 4 {
@@ -291,18 +269,6 @@ mod tests {
assert!(r.at(30, 10) > 0.9 && r.at(30, 50) < 0.1);
}
/// #4634: a near-white studio backdrop around a product is not an overcast sky, but blue sky
/// reaching the bottom (a photo taken upward) still is.
#[test]
fn a_white_backdrop_is_not_sky_but_a_blue_sky_down_to_the_bottom_is() {
let disc = |x: usize, y: usize| ((x as f32 - 75.0).powi(2) + (y as f32 - 50.0).powi(2)).sqrt() < 30.0;
let img = RgbImage::from_fn(150, 100, |x, y| if disc(x, y) { [0.12, 0.12, 0.12] } else { [0.95, 0.95, 0.94] });
assert!(select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 150, 100), SkyParams::default()).is_none());
let img = RgbImage::from_fn(150, 100, |x, y| if disc(x, y) { [0.12, 0.3, 0.1] } else { [0.35, 0.6, 0.95] });
let r = select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 150, 100), SkyParams::default()).expect("blue sky");
assert!(r.at(5, 95) > 0.9 && r.at(75, 50) < 0.1);
}
#[test]
fn colour_prior_ranks_sky_over_foliage() {
assert!(colour_prior([0.4, 0.6, 0.95]) > 0.8);
+8 -78
View File
@@ -13,17 +13,8 @@
//! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the
//! lightly blurred CIELAB image from its mean colour.
//! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels
//! are definite background); the result is cleaned (small islands dropped).
//! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted
//! to the image's frame marks every pixel it explains as probable background, and the rest
//! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground.
//! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white
//! wall) have low saliency and in step 4 land in the background model, which then explains
//! them away; here they start as foreground and the cut keeps them (#2788). Only what
//! touches the first pass's result survives, so the pass cannot pull in unrelated things.
//! 6. Holes are filled only when their colour fits the foreground model better than the
//! background's, so a patch of backdrop enclosed by the subject (between an arm and the
//! torso) stays open; specks still fill. Then the mask is re-cut at full resolution.
//! are definite background); the result is cleaned (small islands dropped, small holes
//! filled) and re-cut at full resolution.
//!
//! Works well for a distinct object on a comparatively plain background; it is not a semantic
//! segmenter.
@@ -32,9 +23,7 @@ use std::cmp::Ordering;
use std::collections::BinaryHeap;
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::gmm::Gmm;
use super::grabcut::{self, BG, PR_BG, PR_FG};
use super::slic::{rgb_to_lab, slic_lab};
use super::{Region, Sampler};
@@ -46,20 +35,13 @@ const SIGMA_SPA: f32 = 0.25;
/// Selects the most salient object on the canvas.
pub fn select_subject(sampler: &dyn Sampler, canvas: Rect) -> Option<Region> {
select_subject_with(sampler, canvas, &Interrupt::NONE).unwrap_or_default()
}
/// [`select_subject`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked between
/// stages and inside every min cut (the GrabCut iterations and the full-resolution re-cut).
pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) -> Result<Option<Region>, Cancelled> {
if canvas.width() < 4 || canvas.height() < 4 {
return Ok(None);
return None;
}
let step = super::scale_for(canvas, WORK_PX);
let img = sampler.rgb_scaled(canvas, step);
let (w, h) = (img.w, img.h);
let sal = saliency(&img);
ctl.check()?;
let t = otsu(&sal).clamp(0.15, 0.85);
let mut trimap = vec![PR_BG; w * h];
let mut any_fg = false;
@@ -78,67 +60,15 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt)
}
}
if !any_fg {
return Ok(None);
return None;
}
let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) };
let (fg, bg) = grabcut::grabcut(&img, &mut trimap, 6)?;
let low: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
let low = super::clean_mask(&low, w, h, 0.2, 0.0);
let low = super::clean_mask(&low, w, h, 0.2, 0.05);
if !low.iter().any(|v| *v) {
return Ok(None);
return None;
}
let (low, fg, bg) = backdrop_pass(&img, &low, ctl)?.unwrap_or((low, fg, bg));
ctl.check()?;
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.002, 0.05);
super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl)
}
/// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model
/// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the
/// subject runs out of the picture); the frame is definite background, every other pixel it
/// explains well is probable background, and the rest (the blob, and anything the backdrop
/// cannot account for, such as a cream shirt on a near-white wall) starts as probable
/// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that
/// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass.
fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result<Option<(Vec<bool>, Gmm, Gmm)>, Cancelled> {
let (w, h) = (img.w, img.h);
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
for (i, on) in blob.iter().enumerate() {
if *on {
let (x, y) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
if x1 < x0 || y1 < y0 {
return Ok(None);
}
let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4);
let (bx0, by0) = (x0.saturating_sub(margin), y0.saturating_sub(margin));
let (bx1, by1) = ((x1 + margin).min(w - 1), (y1 + margin).min(h - 1));
let frame = ((w.min(h) as f32 * 0.06).round() as usize).max(2);
let in_frame = |x: usize, y: usize| x < frame || y < frame || x + frame >= w || y + frame >= h;
let near_blob = |x: usize, y: usize| (bx0..=bx1).contains(&x) && (by0..=by1).contains(&y);
let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| in_frame(i % w, i / w) && !near_blob(i % w, i / w)).map(|i| img.px[i]).collect();
let Some(backdrop) = Gmm::fit(&super::subsample(&samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG) else { return Ok(None) };
// What the frame itself costs under its own model bounds "explained well".
let mut frame_costs: Vec<f32> = samples.iter().map(|p| backdrop.neg_log(*p)).collect();
frame_costs.sort_by(|a, b| a.total_cmp(b));
let Some(limit) = frame_costs.get(frame_costs.len() * 99 / 100).map(|c| c + 4.0) else { return Ok(None) };
let mut trimap: Vec<u8> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
if in_frame(x, y) && !near_blob(x, y) {
BG
} else if blob[i] || backdrop.neg_log(img.px[i]) > limit {
PR_FG
} else {
PR_BG
}
})
.collect();
let Some((fg, bg)) = grabcut::grabcut_with(img, &mut trimap, 4, ctl)? else { return Ok(None) };
let cut: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
let low = super::keep_seeded(&cut, blob, w, h);
Ok(low.iter().any(|v| *v).then_some((low, fg, bg)))
super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg)))
}
/// Per-pixel saliency in `0..=1` (see the module docs).
-292
View File
@@ -344,45 +344,6 @@ fn quick_select_with_points_far_off_canvas_selects_nothing() {
}
}
#[test]
fn quick_select_cuts_window_sizes_in_parallel_with_the_same_result() {
// Soft gradients and faint blobs let a selection spread, so most strokes need more than one
// window size; cutting several at once must keep exactly what one-after-another keeps.
let (w, h) = (1200usize, 900usize);
let img = RgbImage::from_fn(w, h, |x, y| {
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
let blob = (((fx * 9.0).sin() * (fy * 7.0).cos()) * 0.5 + 0.5) * 0.06;
[0.3 + 0.4 * fx + blob, 0.5 + 0.2 * fy, 0.7 - 0.3 * fx + blob]
});
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, w as i32, h as i32);
let t = quick::Tuning::default();
let strokes: [(&[(f32, f32)], f32); 4] = [
(&[(300.0, 300.0)], 30.0),
(&[(500.0, 400.0), (580.0, 400.0), (660.0, 420.0)], 60.0),
(&[(200.0, 700.0), (260.0, 650.0), (320.0, 700.0), (380.0, 650.0)], 30.0),
(&[(900.0, 200.0)], 120.0),
];
let mut most = 0;
for (pts, size) in strokes {
let one = quick::quick_select_batched::<true>(&s, canvas, pts, size, &t, 1);
let many = quick::quick_select_batched::<true>(&s, canvas, pts, size, &t, 4);
assert_eq!(one.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), many.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), "{pts:?} size {size}");
most = most.max(one.map_or(0, |(_, n)| n));
}
assert!(most > 1, "no stroke needed a larger window, so the test exercises nothing");
}
#[test]
fn quick_select_window_sizes_stop_at_the_canvas() {
let canvas = Rect::new(0, 0, 1000, 800);
let bbox = Rect::new(400, 300, 460, 360);
assert_eq!(quick::margins_to_try(96, bbox, canvas, 4), vec![96, 192, 384, 768]);
// 768 already covers the canvas: no larger (identical) window is cut.
assert_eq!(quick::margins_to_try(384, bbox, canvas, 4), vec![384, 768]);
assert_eq!(quick::margins_to_try(96, bbox, canvas, 0), vec![96]);
}
#[test]
fn quick_select_grows_without_leaking() {
let img = two_regions(140, 100);
@@ -565,87 +526,6 @@ fn select_subject_finds_centred_object() {
assert!(v > 0.9, "IoU {v}");
}
/// Counts full-resolution reads; the working copy goes through `rgb_scaled` uncounted, so each
/// read is one band tile of the full-resolution re-cut.
struct TileReads<'a>(ImageSampler<'a>, std::sync::atomic::AtomicUsize);
impl Sampler for TileReads<'_> {
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.0.rgba(r)
}
fn rgb_scaled(&self, r: Rect, step: usize) -> RgbImage {
self.0.rgb_scaled(r, step)
}
}
#[test]
fn finish_region_cancel_stops_inside_the_band_cut() {
use photocraft_raster::{Cancelled, Interrupt};
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
// One 180-pixel window is one band tile, and nothing asks after the band re-cut, so a cancel
// seen after the tile's own check can only have been seen inside its min cut.
let (n, step) = (180, 3);
let (img, truth) = disc_scene(n, n, 90.0, 90.0, 55.0, 4);
let s = ImageSampler { img: &img, origin: (0, 0) };
let ln = n / step;
let low: Vec<bool> = (0..ln * ln).map(|i| truth[((i / ln) * step + 1) * n + (i % ln) * step + 1]).collect();
let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&truth).partition(|(_, t)| **t);
let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::<Vec<_>>(), 5, gmm::DEFAULT_REG).unwrap();
let (fg, bg) = (fit(fs), fit(bs));
let window = Rect::new(0, 0, n as i32, n as i32);
let polls = AtomicUsize::new(0);
let finish = |cancel_from: usize| {
polls.store(0, SeqCst);
let cancel = || polls.fetch_add(1, SeqCst) + 1 >= cancel_from;
finish_region_with(&s, window, step, &low, ln, ln, Some((&fg, &bg)), &Interrupt::cancel_only(&cancel))
};
let reg = finish(usize::MAX).unwrap().unwrap();
assert!(polls.load(SeqCst) >= 2, "the cut polls too ({} polls)", polls.load(SeqCst));
assert!(iou(&reg, &truth, n, n) > 0.95);
assert_eq!(finish_region(&s, window, step, &low, ln, ln, Some((&fg, &bg))), Some(reg));
// Cancelled at the cut's first poll (the second in all): no region.
assert_eq!(finish(2), Err(Cancelled));
assert_eq!(polls.load(SeqCst), 2);
assert_eq!(finish(1), Err(Cancelled), "cancelled before the tile");
}
#[test]
fn select_subject_cancelled_in_the_band_recut_reads_no_more_tiles() {
use photocraft_raster::{Cancelled, Interrupt};
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
let (w, h) = (3000, 2000);
let (img, truth) = disc_scene(w, h, 1500.0, 1000.0, 700.0, 6);
let canvas = Rect::new(0, 0, w as i32, h as i32);
let sampler = || TileReads(ImageSampler { img: &img, origin: (0, 0) }, AtomicUsize::new(0));
// Not cancelled: the same region as `select_subject`, and the band cuts themselves ask
// (more polls once tiles are read than the one check before each tile).
let s = sampler();
let band_polls = AtomicUsize::new(0);
let count = || {
if s.1.load(SeqCst) > 0 {
band_polls.fetch_add(1, SeqCst);
}
false
};
let reg = subject::select_subject_with(&s, canvas, &Interrupt::cancel_only(&count)).unwrap().unwrap();
let tiles = s.1.load(SeqCst);
assert!(tiles > 4, "{tiles} band tiles");
assert!(band_polls.load(SeqCst) > tiles, "{} polls over {tiles} tiles", band_polls.load(SeqCst));
assert!(iou(&reg, &truth, w, h) > 0.9);
assert_eq!(subject::select_subject(&sampler(), canvas), Some(reg));
// Cancelled once the first tile is read: the run stops there instead of re-cutting every
// tile. One thread, so no other tile is already in flight.
let s = sampler();
let cancel = || s.1.load(SeqCst) > 0;
let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
let r = pool.install(|| subject::select_subject_with(&s, canvas, &Interrupt::cancel_only(&cancel)));
assert_eq!(r, Err(Cancelled));
assert_eq!(s.1.load(SeqCst), 1, "tiles read after the cancel");
}
#[test]
fn saliency_highlights_the_object() {
let (img, truth) = disc_scene(120, 90, 60.0, 45.0, 22.0, 8);
@@ -740,175 +620,3 @@ fn dump_quick_scenes() {
save("thin_line", &line);
save("disc_scene", &disc_scene(600, 500, 300.0, 250.0, 180.0, 4).0);
}
#[test]
fn object_at_finds_the_disc_under_the_cursor() {
let (img, truth) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 320, 240);
let reg = grabcut::object_at(&s, canvas, 165.0, 118.0, 20_000).unwrap();
let v = iou(&reg, &truth, 320, 240);
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn object_at_on_the_background_or_off_canvas_finds_nothing() {
let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 320, 240);
assert!(grabcut::object_at(&s, canvas, 10.0, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, -5.0, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
}
// ---------- Select Subject on the cases reported in #2788 ----------
/// A figure whose upper part is close to the background colour (a cream shirt on a near-white
/// backdrop): the whole figure is the subject. Returns (image, truth, the low-contrast part).
pub(crate) fn low_contrast_figure(seed: u64) -> (RgbImage, Vec<bool>, Vec<bool>) {
let (w, h) = (240usize, 200usize);
let mut rng = Rng::new(seed);
let mut img = RgbImage::new(w, h);
let mut truth = vec![false; w * h];
let mut cream = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let body = (90..150).contains(&x) && (70..190).contains(&y);
let shoulders = (80..160).contains(&x) && (40..70).contains(&y);
let print = (105..135).contains(&x) && (100..130).contains(&y);
let n = 0.012 * rng.normal();
let i = y * w + x;
truth[i] = body || shoulders;
cream[i] = shoulders;
img.px[i] = if print {
[0.9 + n, 0.15 + n, 0.1 + n]
} else if body {
[0.12 + n, 0.2 + n, 0.45 + n]
} else if shoulders {
// Cream, a few levels off the backdrop.
[0.90 + n, 0.88 + n, 0.80 + n]
} else {
[0.96 + n, 0.96 + n, 0.95 + n]
}
.map(|v| v.clamp(0.0, 1.0));
}
}
(img, truth, cream)
}
fn coverage(region: &Region, part: &[bool], w: usize, h: usize) -> f32 {
let (mut hit, mut n) = (0usize, 0usize);
for y in 0..h {
for x in 0..w {
if part[y * w + x] {
n += 1;
hit += (region.at(x as i32, y as i32) >= 0.5) as usize;
}
}
}
hit as f32 / n.max(1) as f32
}
#[test]
fn select_subject_keeps_parts_close_to_the_background_colour() {
let (img, truth, cream) = low_contrast_figure(7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap();
let v = iou(&reg, &truth, 240, 200);
let c = coverage(&reg, &cream, 240, 200);
assert!(c > 0.85, "the cream shoulders are part of the subject: covered {c}");
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn select_subject_leaves_a_background_coloured_gap_open() {
// A dark ring (arms on hips) around a patch of plain backdrop: the patch is not the subject.
let (w, h) = (220usize, 200usize);
let mut rng = Rng::new(11);
let mut img = RgbImage::new(w, h);
let mut truth = vec![false; w * h];
let mut gap = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let d = (x as f32 - 110.0).hypot(y as f32 - 100.0);
let ring = (17.0..80.0).contains(&d);
let hole = d < 17.0;
let n = 0.012 * rng.normal();
let i = y * w + x;
truth[i] = ring;
gap[i] = hole;
img.px[i] = if ring { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0));
}
}
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap();
let g = coverage(&reg, &gap, w, h);
assert!(g < 0.1, "the backdrop inside the ring stays unselected: covered {g}");
let v = iou(&reg, &truth, w, h);
assert!(v > 0.9, "IoU {v}");
}
/// Timing on a 6 MP figure, for before/after numbers (`cargo test --release -- --ignored select_subject_timing --nocapture`).
#[test]
#[ignore = "timing only"]
fn select_subject_timing() {
let (w, h) = (3000usize, 2000usize);
let img = RgbImage::from_fn(w, h, |x, y| {
let body = (1100..1900).contains(&x) && (700..1900).contains(&y);
let shoulders = (1000..2000).contains(&x) && (400..700).contains(&y);
let t = ((x * 7 + y * 13) % 17) as f32 / 1700.0;
if body {
[0.12 + t, 0.2, 0.45]
} else if shoulders {
[0.90, 0.88 + t, 0.80]
} else {
[0.96, 0.96 + t, 0.95]
}
});
let s = ImageSampler { img: &img, origin: (0, 0) };
for _ in 0..2 {
let t0 = std::time::Instant::now();
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap();
eprintln!("select_subject 6 MP: {:?} bbox {:?}", t0.elapsed(), reg.bbox);
}
}
/// Writes the #2788 scenes and the Select Subject mask over them as PPM files into
/// `$SUBJECT_DUMP` (for the PR's before/after pictures).
#[test]
#[ignore = "writes pictures"]
fn select_subject_dump_pictures() {
let Ok(dir) = std::env::var("SUBJECT_DUMP") else { return };
let write = |name: &str, img: &RgbImage, reg: Option<&Region>| {
let mut out = format!("P6\n{} {}\n255\n", img.w, img.h).into_bytes();
for y in 0..img.h {
for x in 0..img.w {
let mut c = img.at(x, y);
if let Some(r) = reg
&& r.at(x as i32, y as i32) >= 0.5
{
c = [c[0] * 0.5 + 0.5, c[1] * 0.5, c[2] * 0.5 + 0.5];
}
out.extend(c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
}
}
std::fs::write(format!("{dir}/{name}.ppm"), out).unwrap();
};
let (img, _, _) = low_contrast_figure(7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200));
write("figure", &img, None);
write("figure-selected", &img, reg.as_ref());
let (w, h) = (220usize, 200usize);
let mut rng = Rng::new(11);
let img = RgbImage::from_fn(w, h, |x, y| {
let d = (x as f32 - 110.0).hypot(y as f32 - 100.0);
let n = 0.012 * rng.normal();
if (17.0..80.0).contains(&d) { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0))
});
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32));
write("ring", &img, None);
write("ring-selected", &img, reg.as_ref());
}
+35 -282
View File
@@ -166,8 +166,7 @@ fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
/// around the seed. `px` covers `area`.
pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let Some(n) = w.checked_mul(h).filter(|&n| n <= px.len()) else { return Vec::new() };
let mut m = vec![0.0f32; n];
let mut m = vec![0.0f32; w * h];
if !area.contains(seed.0, seed.1) {
return m;
}
@@ -246,9 +245,7 @@ impl Region {
if !self.bbox.contains(x, y) {
return 0.0;
}
let row = (y.abs_diff(self.bbox.y0) as usize).checked_mul(self.bbox.width() as usize);
let value = row.and_then(|i| i.checked_add(x.abs_diff(self.bbox.x0) as usize)).and_then(|i| self.mask.get(i)).copied().unwrap_or(0);
f32::from(value) / 255.0
self.mask[(y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize] as f32 / 255.0
}
}
@@ -260,9 +257,6 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
return None;
}
let (w, h) = (area.width() as usize, area.height() as usize);
if w.checked_mul(h).is_none_or(|n| n > img.len()) {
return None;
}
let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize];
let tol = (tolerance + 1e-3).floor().max(0.0) as i32;
// Copying a selection can leave RGB under zero alpha. Those invisible colours must not
@@ -682,57 +676,18 @@ fn hue_chroma([r, g, b]: [f32; 3]) -> (f32, f32) {
/// Color Range › Select: Reds, Yellows, Greens, Cyans, Blues or Magentas (`center` = 0, 60,
/// 120, 180, 240, 300°). A pixel belongs to a family by how close its hue is (full at the
/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma
/// (max − min), so neutrals are never selected and muted colours only partly. Darker colours
/// weigh more, by `2 − max`: measured against Photoshop 2025's presets on 126 colours each for
/// Reds, Yellows and Blues (hue ±40°, saturation 5..100 %, brightness 33..100 %), this matches
/// its selection to within one level, where chroma alone fell up to 61 levels short (#5243).
/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma, so
/// neutrals are never selected and muted colours only partly.
pub fn hue_range(px: &[[f32; 4]], center: f32) -> Vec<f32> {
px.iter()
.map(|&[r, g, b, a]| {
if a <= 0.0 {
return 0.0;
}
let rgb = [r, g, b].map(|v| v.clamp(0.0, 1.0));
let (h, chroma) = hue_chroma(rgb);
let max = rgb[0].max(rgb[1]).max(rgb[2]);
let (h, chroma) = hue_chroma([r, g, b].map(|v| v.clamp(0.0, 1.0)));
let dh = (h - center).rem_euclid(360.0);
let dh = dh.min(360.0 - dh);
((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma * (2.0 - max)).clamp(0.0, 1.0)
})
.collect()
}
/// Color Range › Select: Skin Tones. Skin of every complexion falls in a compact region of the
/// YCbCr chroma plane (Chai & Ngan, "Face segmentation using skin-color map", 1999: Cb about
/// 77..127, Cr about 133..173, Rec. 601 on 0..255) that runs from pale (higher Cb, lower Cr) to
/// saturated (lower Cb, higher Cr). Pixels inside an ellipse along that run are fully selected;
/// outside, selection falls off linearly over `fuzziness` (0..200, as the dialog's Fuzziness: 100
/// is one ellipse radius). Pale neutrals such as cream or beige sit just off its pale end, so they
/// stay out. Near-black and near-white pixels, whose chroma is noise, are faded out.
pub fn skin_tones(px: &[[f32; 4]], fuzziness: f32) -> Vec<f32> {
const CB: f32 = 104.0;
const CR: f32 = 158.0;
/// Radii along the run and across it, and the run's angle from the Cb axis.
const ALONG: f32 = 22.0;
const ACROSS: f32 = 14.0;
const ANGLE: f32 = -40.0;
let (sin, cos) = ANGLE.to_radians().sin_cos();
let ramp = (fuzziness / 100.0).max(1e-3);
px.iter()
.map(|&[r, g, b, a]| {
if a <= 0.0 {
return 0.0;
}
let [r, g, b] = [r, g, b].map(|v| v.clamp(0.0, 1.0) * 255.0);
let y = 0.299 * r + 0.587 * g + 0.114 * b;
let cb = 128.0 - 0.168_736 * r - 0.331_264 * g + 0.5 * b - CB;
let cr = 128.0 + 0.5 * r - 0.418_688 * g - 0.081_312 * b - CR;
let d = ((cos * cb + sin * cr) / ALONG).hypot((cos * cr - sin * cb) / ACROSS);
let chroma = (1.0 - (d - 1.0) / ramp).clamp(0.0, 1.0);
let dark = ((y - 25.0) / 30.0).clamp(0.0, 1.0);
let bright = ((250.0 - y) / 20.0).clamp(0.0, 1.0);
chroma * dark * bright
((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma).clamp(0.0, 1.0)
})
.collect()
}
@@ -811,84 +766,47 @@ pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
}
/// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased). Bands of
/// rows are filled in parallel, and each sub-row tests only the edges that
/// cross it (an active edge list over the edges sorted by their top).
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased).
pub fn polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
if points.len() < 3 || w == 0 {
if points.len() < 3 {
return m;
}
let sub = if anti_alias { 4 } else { 1 };
let sample_y = |py: usize, s: usize| area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
// A sub-row at y crosses the edge a→b iff top <= y < bottom; horizontal and NaN edges
// never cross one.
struct Edge {
top: f32,
bottom: f32,
a: (f32, f32),
b: (f32, f32),
}
let mut edges: Vec<Edge> = points
.iter()
.zip(points.iter().cycle().skip(1))
.filter_map(|(&a, &b)| {
if a.1 < b.1 {
Some(Edge { top: a.1, bottom: b.1, a, b })
} else if b.1 < a.1 {
Some(Edge { top: b.1, bottom: a.1, a, b })
} else {
None
}
})
.collect();
edges.sort_by(|p, q| p.top.total_cmp(&q.top));
const BAND: usize = 64;
par_rows(&mut m, w, BAND, |bi, rows| {
let py0 = bi * BAND;
let (first, last) = (sample_y(py0, 0), sample_y(py0 + rows.len() / w - 1, sub - 1));
let reach = edges.partition_point(|e| e.top <= last);
let band_edges = edges.get(..reach).unwrap_or_default();
let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable();
let mut active: Vec<&Edge> = Vec::new();
let mut xs: Vec<f32> = Vec::new();
for (r, row) in rows.chunks_exact_mut(w).enumerate() {
for s in 0..sub {
// Sub-row ys never decrease, so an edge whose bottom is passed stays out.
let y = sample_y(py0 + r, s);
while let Some(e) = pending.next_if(|e| e.top <= y) {
active.push(e);
let mut xs: Vec<f32> = Vec::new();
for py in 0..h {
for s in 0..sub {
let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
xs.clear();
for i in 0..points.len() {
let (a, b) = (points[i], points[(i + 1) % points.len()]);
if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) {
xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
active.retain(|e| e.bottom > y);
if active.is_empty() {
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
xs.clear();
xs.extend(active.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)));
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for (cx, v) in row.iter_mut().enumerate().take(c1).skip(c0) {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
*v += cov.max(0.0) / sub as f32;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
m[py * w + cx] += cov.max(0.0) / sub as f32;
}
}
}
for v in rows {
*v = v.min(1.0);
}
});
}
for v in &mut m {
*v = v.min(1.0);
}
m
}
@@ -947,28 +865,6 @@ mod tests {
}
}
#[test]
fn skin_tones_selects_skin_and_not_cream_or_white() {
let px = |rgb: [u8; 3]| [rgb[0], rgb[1], rgb[2]].map(|v| f32::from(v) / 255.0);
let rgba = |rgb: [u8; 3]| {
let [r, g, b] = px(rgb);
[r, g, b, 1.0]
};
// Skin in light and shadow, then a cream shirt, a near-white wall, blue denim and black.
let skin =
[[183, 119, 81], [177, 132, 93], [180, 121, 84], [120, 76, 52], [173, 107, 75], [255, 219, 172], [194, 140, 104], [141, 85, 36], [92, 51, 23]]
.map(rgba);
let m = skin_tones(&skin, 40.0);
assert!(m.iter().all(|&v| v > 0.95), "skin {m:?}");
let other = [[231, 225, 202], [252, 253, 247], [40, 60, 110], [5, 5, 5], [128, 128, 128], [220, 40, 40], [200, 160, 40]].map(rgba);
let m = skin_tones(&other, 40.0);
assert!(m.iter().all(|&v| v < 0.05), "not skin {m:?}");
// Fuzziness widens the region; transparent pixels are never selected.
let pale = [rgba([230, 215, 200])];
assert!(skin_tones(&pale, 200.0)[0] > skin_tones(&pale, 0.0)[0]);
assert_eq!(skin_tones(&[[0.7, 0.45, 0.3, 0.0]], 40.0), vec![0.0]);
}
#[test]
fn color_range_falloff() {
let px = vec![[1.0, 0.0, 0.0, 1.0], [0.9, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.0]];
@@ -1185,17 +1081,6 @@ mod tests {
assert!((hue_range(&[[1.0, 0.0, 0.5, 1.0]], 300.0)[0] - 0.5).abs() < 1e-5);
}
#[test]
fn hue_families_weigh_dark_colours_like_photoshop() {
// Photoshop 2025's Blues on an 8-bit sRGB document (selection levels 0..255, #5243): the
// muted navy #3d3f54, a dark blue (0, 0, 84) and a mid blue (61, 61, 153).
let c = |r: u8, g: u8, b: u8| [f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, 1.0];
let blues = hue_range(&[c(0x3d, 0x3f, 0x54), c(0, 0, 84), c(61, 61, 153)], 240.0);
for (got, want) in blues.iter().zip([35.0, 140.0, 129.0]) {
assert!((got * 255.0 - want).abs() <= 1.0, "{} vs Photoshop {want}", got * 255.0);
}
}
#[test]
fn tonal_ranges() {
let g = |v: f32| [v / 255.0, v / 255.0, v / 255.0, 1.0];
@@ -1276,107 +1161,6 @@ mod tests {
assert!(polygon(&[(0.0, 0.0), (1.0, 1.0)], a, true).iter().all(|v| *v == 0.0));
}
/// The previous polygon fill (every edge tested on every sub-row), kept as the oracle.
fn old_polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
if points.len() < 3 {
return m;
}
let sub = if anti_alias { 4 } else { 1 };
let mut xs: Vec<f32> = Vec::new();
for py in 0..h {
for s in 0..sub {
let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
xs.clear();
for i in 0..points.len() {
let (a, b) = (points[i], points[(i + 1) % points.len()]);
if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) {
xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
m[py * w + cx] += cov.max(0.0) / sub as f32;
}
}
}
}
for v in &mut m {
*v = v.min(1.0);
}
m
}
#[test]
fn polygon_matches_the_every_edge_fill_bit_for_bit() {
let mut seed = 0x2545_f491_u32;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed as f32 / u32::MAX as f32
};
// Taller than one 64-row band, offset, and a 1-pixel-wide area.
let areas = [Rect::new(0, 0, 90, 150), Rect::new(-13, 7, 60, 211), Rect::new(5, -40, 6, 30)];
let mut cases: Vec<Vec<(f32, f32)>> = vec![
// Self-intersecting star, horizontal edges, repeated points, collinear runs.
(0..11)
.map(|i| {
let t = i as f32 * 4.0 * std::f32::consts::PI / 11.0;
(40.0 + 35.0 * t.cos(), 80.0 + 70.0 * t.sin())
})
.collect(),
vec![(10.0, 10.0), (50.0, 10.0), (50.0, 10.0), (50.0, 60.0), (30.0, 60.0), (30.0, 30.0), (10.0, 30.0)],
vec![(0.0, 0.0), (20.0, 0.0), (40.0, 0.0), (40.0, 0.0), (0.0, 0.0)],
// Vertices on the sub-row centres and the pixel edges.
vec![(3.125, 2.375), (70.5, 2.375), (70.875, 140.625), (3.0, 140.0), (35.0, 64.0), (35.0, 64.125)],
// Entirely outside, and straddling every side.
vec![(-500.0, -500.0), (-400.0, -500.0), (-450.0, -300.0)],
vec![(-80.0, -60.0), (300.0, 20.0), (60.0, 400.0)],
// Non-finite coordinates and a negative zero.
vec![(10.0, 10.0), (f32::NAN, 40.0), (60.0, 70.0), (20.0, f32::INFINITY), (-0.0, -0.0), (0.0, 0.0)],
];
for n in [3, 7, 40, 400] {
for spread in [1.0, 2.0] {
cases.push((0..n).map(|_| (rnd() * 100.0 * spread - 50.0 * (spread - 1.0), rnd() * 220.0 * spread - 60.0 * spread)).collect());
// Lasso-like: a random walk snapped to quarter pixels.
let (mut x, mut y) = (45.0f32, 75.0f32);
cases.push(
(0..n)
.map(|_| {
x = ((x + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0;
y = ((y + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0;
(x, y)
})
.collect(),
);
}
}
for pts in &cases {
for &area in &areas {
for aa in [true, false] {
let (new, old) = (polygon(pts, area, aa), old_polygon(pts, area, aa));
assert_eq!(new.len(), old.len());
let diff = new.iter().zip(&old).position(|(a, b)| a.to_bits() != b.to_bits());
assert!(diff.is_none(), "{pts:?} {area:?} aa={aa}: first difference at {diff:?}");
}
}
}
}
#[test]
fn combine_modes() {
let a = Rect::new(0, 0, 4, 1);
@@ -1478,37 +1262,6 @@ mod tests {
.collect()
}
#[test]
fn magic_wand_rejects_incomplete_and_overflowing_pixel_inputs() {
let area = Rect::new(0, 0, 4, 4);
let short = vec![[255u8, 0, 0, 255]; 4];
let floats: Vec<[f32; 4]> = short.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect();
assert_eq!(magic_wand(&floats, area, (1, 1), 0.0, true, false), Vec::<f32>::new());
assert_eq!(wand_region(&short, area, (1, 1), 0.0, true, false), None);
let huge = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX);
assert!(magic_wand(&floats, huge, (0, 0), 0.0, false, false).is_empty());
assert_eq!(wand_region(&short, huge, (0, 0), 0.0, false, false), None);
let valid = Rect::new(0, 0, 2, 2);
assert_eq!(magic_wand(&floats, valid, (0, 0), 0.0, false, false), vec![1.0; 4]);
assert!(wand_region(&short, valid, (0, 0), 0.0, false, false).is_some());
}
#[test]
fn region_coverage_outside_a_short_mask_is_zero() {
let region = Region { bbox: Rect::new(-2, -3, 1, -1), mask: vec![255, 128] };
assert_eq!(region.at(-2, -3), 1.0);
assert!((region.at(-1, -3) - 128.0 / 255.0).abs() < 1e-6);
assert_eq!(region.at(0, -3), 0.0);
assert_eq!(region.at(-2, -2), 0.0);
assert_eq!(region.at(-3, -3), 0.0);
let extreme = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![] };
assert_eq!(extreme.at(i32::MAX - 1, i32::MAX - 1), 0.0);
assert_eq!(extreme.at(i32::MIN, i32::MIN), 0.0);
}
#[test]
fn wand_region_matches_reference_magic_wand() {
let (w, h) = (37, 29);
+1 -4
View File
@@ -569,14 +569,11 @@ pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
let s = f64::from(sigma);
let (kh, kv) = (Kernel::new(s, r, gw), Kernel::new(s, r, gh));
// Rows: only the bounding box's rows are non-zero.
// The run table is scratch: one buffer per worker thread, reused across rows instead of
// one allocation per row (thousands of 8-byte-per-sample Vecs on a 24 MP mask).
thread_local! { static RUNS: std::cell::RefCell<Vec<usize>> = const { std::cell::RefCell::new(Vec::new()) } }
let mut tmp = vec![0.0f32; b.h() * gw];
par_rows(&mut tmp, gw, 1, |y, row| {
let o = (b.y0 + y) * w + win.x0;
if let Some(s) = m.get(o..o + gw) {
RUNS.with(|runs| kh.line(s, row, &mut runs.borrow_mut()));
kh.line(s, row, &mut Vec::new());
}
});
// Columns, a strip of columns at a time.
+1 -1
View File
@@ -129,7 +129,7 @@ fn surfaces_exact_depth_models_alpha_selection_and_tile_boundaries() {
};
let spans = Spans::new(radius, shape_inside(shape));
let mut expected = conv_spans(&src, area, &ctx, &spans);
crate::mix_selection(&mut expected, area, &selection, &src, alpha);
crate::mix_selection(&mut expected, area, &selection, &src);
let mut reference = surface.clone();
reference.write_region(area, &expected);
for tile in [17, 256] {
+6 -12
View File
@@ -6,17 +6,6 @@ use crate::Ctx;
use crate::blur::{conv_sep, gaussian_kernel};
use crate::image::Image;
pub(crate) mod exact;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod fft;
#[cfg(test)]
mod tests;
#[cfg(all(test, not(target_arch = "wasm32")))]
mod fft_bench;
fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec<f32> {
let k = gaussian_kernel(radius);
let blurred = conv_sep(src, out, &k, &k, ctx.alpha);
@@ -35,7 +24,12 @@ fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f
}
pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec<f32> {
exact::run(src, out, ctx, amount, radius, threshold, &photocraft_raster::Interrupt::NONE).unwrap_or_default()
let k = amount / 100.0;
let t = threshold / 255.0;
with_blur(src, out, ctx, radius, |o, b| {
let d = o - b;
if d.abs() + 1e-6 >= t { o + k * d } else { o }
})
}
pub(crate) fn smart(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, reduce_noise: f32) -> Vec<f32> {
-134
View File
@@ -1,134 +0,0 @@
//! Exact separable convolution for Unsharp Mask: stream one source row and reuse
//! the blurred output for the final arithmetic. Each sample retains its tap order.
use crate::image::premultiply;
use crate::{Ctx, Image};
use photocraft_geom::Rect;
use photocraft_raster::Interrupt;
const MAX_SAMPLES: usize = 128 * 1024 * 1024;
// The filter dialog caps radius at 1000. Preserve Details resampling can ask
// for more; permit that while the unchanged kernel's i32 square is safe.
const MAX_RADIUS: f32 = 15_446.0;
pub(crate) fn valid_params(amount: f32, radius: f32, threshold: f32) -> bool {
amount.is_finite() && threshold.is_finite() && radius.is_finite() && (0.0..=MAX_RADIUS).contains(&radius)
}
pub(crate) fn halo(radius: f32) -> i32 {
if radius.is_finite() && (0.0..=MAX_RADIUS).contains(&radius) { (radius * 3.0).ceil() as i32 + 1 } else { 0 }
}
pub(crate) fn valid_area(r: Rect) -> bool {
r.x0 <= r.x1
&& r.y0 <= r.y1
&& r.width() <= 300_000
&& r.height() <= 300_000
&& [r.x0, r.x1, r.y0, r.y1].iter().all(|v| (-1_000_000..=1_000_000).contains(v))
}
fn zeros(len: usize) -> Option<Vec<f32>> {
if len > MAX_SAMPLES {
return None;
}
let mut data = Vec::new();
data.try_reserve_exact(len).ok()?;
data.resize(len, 0.0);
Some(data)
}
/// Checked contiguous row copy, with the same zero extension as Image::get.
fn read_row(src: &Image, x0: i32, x1: i32, y: i32, dst: &mut [f32]) -> Option<()> {
dst.fill(0.0);
if y < src.rect.y0 || y >= src.rect.y1 {
return Some(());
}
let (lo, hi) = (x0.max(src.rect.x0), x1.min(src.rect.x1));
if lo >= hi {
return Some(());
}
let n = src.ch;
let count = usize::try_from(i64::from(hi) - i64::from(lo)).ok()?.checked_mul(n)?;
let offset = usize::try_from(i64::from(lo) - i64::from(x0)).ok()?.checked_mul(n)?;
let row = usize::try_from(i64::from(y) - i64::from(src.rect.y0)).ok()?;
let col = usize::try_from(i64::from(lo) - i64::from(src.rect.x0)).ok()?;
let start = row.checked_mul(src.rect.width() as usize)?.checked_add(col)?.checked_mul(n)?;
dst.get_mut(offset..offset.checked_add(count)?)?.copy_from_slice(src.data.get(start..start.checked_add(count)?)?);
Some(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32, ctl: &Interrupt) -> Option<Vec<f32>> {
if ctl.cancelled() || !valid_params(amount, radius, threshold) || !valid_area(out) || !valid_area(src.rect) {
return None;
}
let n = src.ch;
if n == 0 || n > crate::fxutil::MAXC {
return None;
}
if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(n)? {
return None;
}
if out.is_empty() {
return Some(Vec::new());
}
let kernel = super::gaussian_kernel(radius);
let r = kernel.len() / 2;
let (ow, oh) = (out.width() as usize, out.height() as usize);
let row_len = ow.checked_mul(n)?;
let th = oh.checked_add(2 * r)?;
let ww = ow.checked_add(2 * r)?;
let (x0, x1, y0) = (out.x0.checked_sub(r as i32)?, out.x1.checked_add(r as i32)?, out.y0.checked_sub(r as i32)?);
let mut row = zeros(ww.checked_mul(n)?)?;
let mut tmp = zeros(row_len.checked_mul(th)?)?;
let mut result = zeros(row_len.checked_mul(oh)?)?;
for (y, dst) in tmp.chunks_exact_mut(row_len).enumerate() {
if ctl.cancelled() {
return None;
}
read_row(src, x0, x1, y0.checked_add(y as i32)?, &mut row)?;
premultiply(&mut row, n, ctx.alpha);
// Tap-major traversal exposes contiguous independent samples to LLVM. No
// reduction, tap merging, symmetry or fused multiply-add changes the sums.
for (i, weight) in kernel.iter().enumerate() {
let start = i.checked_mul(n)?;
let samples = row.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
}
}
}
let (amount, threshold) = (amount / 100.0, threshold / 255.0);
let cc = n - usize::from(ctx.alpha);
for (y, dst) in result.chunks_exact_mut(row_len).enumerate() {
if ctl.cancelled() {
return None;
}
for (i, weight) in kernel.iter().enumerate() {
let start = y.checked_add(i)?.checked_mul(row_len)?;
let samples = tmp.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
}
}
let originals = row.get_mut(..row_len)?;
read_row(src, out.x0, out.x1, out.y0.checked_add(y as i32)?, originals)?;
for (blurred, original) in dst.chunks_exact_mut(n).zip(originals.chunks_exact(n)) {
if ctx.alpha && *original.last()? <= 0.0 {
blurred.copy_from_slice(original);
continue;
}
let a = if ctx.alpha { *blurred.last()? } else { 1.0 };
for (b, o) in blurred.iter_mut().zip(original).take(cc) {
// Keep the original unpremultiply cut-off and division, followed by
// the threshold epsilon and inclusive comparison, at every depth.
let b_value = if ctx.alpha { if a > 1e-7 { *b / a } else { 0.0 } } else { *b };
let d = o - b_value;
*b = if d.abs() + 1e-6 >= threshold { o + amount * d } else { *o };
}
if ctx.alpha {
*blurred.last_mut()? = *original.last()?;
}
}
}
Some(result)
}
-325
View File
@@ -1,325 +0,0 @@
//! Wide exact-kernel Gaussian convolution using FFTs. Summation order changes:
//! positive thresholds and unstable samples retain the sequential f32 fallback.
use crate::{FilterParams, Image};
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface};
use rayon::prelude::*;
use rustfft::{Fft, FftPlanner, num_complex::Complex};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
const MAX_BYTES: usize = 1 << 30;
const MAX_SIDE: usize = 32_768;
const STRIPE: usize = 32;
type Number = Complex<f64>;
fn buffer<T: Clone>(len: usize, value: T) -> Option<Vec<T>> {
if len.checked_mul(std::mem::size_of::<T>())? > MAX_BYTES {
return None;
}
let mut data = Vec::new();
data.try_reserve_exact(len).ok()?;
data.resize(len, value);
Some(data)
}
struct Plan {
size: usize,
forward: Arc<dyn Fft<f64>>,
inverse: Arc<dyn Fft<f64>>,
spectrum: Vec<Number>,
scratch: usize,
}
impl Plan {
fn new(needed: usize, kernel: &[f32], planner: &mut FftPlanner<f64>) -> Option<Self> {
let size = needed.checked_next_power_of_two()?;
if size > MAX_SIDE || kernel.len() > size {
return None;
}
let forward = planner.plan_fft_forward(size);
let inverse = planner.plan_fft_inverse(size);
let scratch = forward.get_inplace_scratch_len().max(inverse.get_inplace_scratch_len());
let mut spectrum = buffer(size, Number::default())?;
for (dst, &weight) in spectrum.iter_mut().zip(kernel) {
dst.re = f64::from(weight) / size as f64;
}
forward.process_with_scratch(&mut spectrum, &mut buffer(scratch, Number::default())?);
Some(Self { size, forward, inverse, spectrum, scratch })
}
fn work(&self) -> Option<(Vec<Number>, Vec<Number>)> {
Some((buffer(self.size, Number::default())?, buffer(self.scratch, Number::default())?))
}
fn convolve(&self, data: &mut [Number], scratch: &mut [Number]) -> Option<()> {
if data.len() != self.size || scratch.len() < self.scratch {
return None;
}
self.forward.process_with_scratch(data, scratch);
for (d, k) in data.iter_mut().zip(&self.spectrum) {
*d *= k;
}
self.inverse.process_with_scratch(data, scratch);
Some(())
}
}
// Bound the complete temporary working set, including all concurrently live
// column gathers, originals, FFT scratch and result stripes. Document tiles are
// excluded, just as in the existing tile scheduler's scratch budget.
fn group_size(resident: usize, worker: usize, count: usize, workers: usize) -> Option<usize> {
let group = MAX_BYTES.checked_sub(resident)?.checked_div(worker.max(1))?.min(workers).min(count);
(group > 0).then_some(group)
}
/// `None` declines the optimization, `Some(None)` propagates cancellation.
#[allow(clippy::too_many_arguments)]
pub(crate) fn apply(
surface: &Surface,
params: &FilterParams,
area: Rect,
selection: Option<&Surface>,
extent: Option<Rect>,
ctl: &Interrupt,
) -> Option<Option<Surface>> {
let FilterParams::UnsharpMask { amount, radius, threshold } = *params else { return None };
// At a positive threshold, even a tiny sum-order difference can switch the
// entire sharpen contribution. Zero/negative thresholds always keep it.
if !(64.0..=1000.0).contains(&radius)
|| threshold > 0.0
|| amount == 0.0
|| !super::exact::valid_params(amount, radius, threshold)
|| !super::exact::valid_area(area)
|| area.is_empty()
|| u64::from(area.width()) * u64::from(area.height()) < 65_536
|| !(1..=crate::fxutil::MAXC).contains(&surface.channels())
|| selection.is_some_and(|s| s.channels() == 0)
|| extent.is_some_and(|e| !super::exact::valid_area(e) || area.intersect(&e) != area)
{
return None;
}
if ctl.cancelled() {
return Some(None);
}
match run(surface, area, amount, radius, selection, extent, ctl) {
Some(out) => Some(Some(out)),
None if ctl.cancelled() => Some(None),
None => None,
}
}
#[allow(clippy::too_many_arguments)]
fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Option<&Surface>, extent: Option<Rect>, ctl: &Interrupt) -> Option<Surface> {
let (w, h, n) = (area.width() as usize, area.height() as usize, surface.channels());
let alpha = surface.format().alpha;
let kernel = super::gaussian_kernel(radius);
let r = kernel.len() / 2;
let reach = i32::try_from(r).ok()?;
let (x0, x1) = (area.x0.checked_sub(reach)?, area.x1.checked_add(reach)?);
let (y0, y1) = (area.y0.checked_sub(reach)?, area.y1.checked_add(reach)?);
let (read_x0, read_x1, read_y0, read_y1) = match extent {
Some(e) => (x0.max(e.x0), x1.min(e.x1), y0.max(e.y0), y1.min(e.y1)),
None => (x0, x1, y0, y1),
};
let rows = usize::try_from(i64::from(read_y1) - i64::from(read_y0)).ok()?;
let read_width = usize::try_from(i64::from(read_x1) - i64::from(read_x0)).ok()?;
let row_len = w.checked_mul(n)?;
let samples = row_len.checked_mul(rows)?;
let resident = samples.checked_mul(4)?;
let mut planner = FftPlanner::<f64>::new();
let horizontal = Plan::new(w.checked_add(2 * r)?, &kernel, &mut planner)?;
let vertical = Plan::new(h.checked_add(2 * r)?, &kernel, &mut planner)?;
// A conservative allowance includes FFT plans/twiddles in addition to spectra.
let plans = horizontal.size.checked_add(vertical.size)?.checked_mul(256)?;
let resident = resident.checked_add(plans)?;
let workers = rayon::current_num_threads().max(1);
let horizontal_worker = read_width.checked_mul(n)?.checked_mul(4)?.checked_add(horizontal.size.checked_add(horizontal.scratch)?.checked_mul(16)?)?;
let vertical_worker = STRIPE
.checked_mul(rows)?
.checked_mul(n)?
.checked_mul(4)?
.checked_add(STRIPE.checked_mul(h)?.checked_mul(n)?.checked_mul(12)?)?
.checked_add(vertical.size.checked_add(vertical.scratch)?.checked_mul(16)?)?;
let group = group_size(resident, horizontal_worker.max(vertical_worker), w.div_ceil(STRIPE), workers)?;
let mut tmp = buffer(samples, 0.0f32)?;
let finished = AtomicUsize::new(0);
// Read each needed real row once. Outside a repeated extent, horizontal
// results are repeated lazily by the vertical gather, not transformed again.
for band in tmp.chunks_mut(row_len.checked_mul(group)?) {
let first = finished.load(Ordering::Relaxed);
let ok: Option<Vec<()>> = band
.par_chunks_exact_mut(row_len)
.enumerate()
.map(|(offset, dst)| {
if ctl.cancelled() {
return None;
}
let y = read_y0.checked_add(i32::try_from(first.checked_add(offset)?).ok()?)?;
let mut row = buffer(read_width.checked_mul(n)?, 0.0f32)?;
// Reservation above covers resize, so the reusable raster read cannot allocate.
surface.read_region_into(Rect::new(read_x0, y, read_x1, y.checked_add(1)?), &mut row);
let mut scales = [0.0f64; crate::fxutil::MAXC];
for px in row.chunks_exact_mut(n) {
if px.iter().any(|v| !v.is_finite() || v.abs() > 1e6) || (alpha && !px.last().is_some_and(|a| (0.0..=1.0).contains(a))) {
return None;
}
let a = if alpha { *px.last()? } else { 1.0 };
for (c, v) in px.iter_mut().enumerate() {
if alpha && c + 1 < n {
*v *= a;
}
let scale = scales.get_mut(c)?;
*scale = scale.max(f64::from(v.abs()));
}
}
let (mut data, mut scratch) = horizontal.work()?;
for channel in (0..n).step_by(2) {
if ctl.cancelled() {
return None;
}
data.fill(Number::default());
for (i, d) in data.iter_mut().take(w + 2 * r).enumerate() {
let sx = x0.checked_add(i32::try_from(i).ok()?)?.clamp(read_x0, read_x1.checked_sub(1)?);
let start = usize::try_from(sx - read_x0).ok()?.checked_mul(n)?;
let px = row.get(start..start.checked_add(n)?)?;
let sample = |c: usize| -> f64 {
match (px.get(c), scales.get(c)) {
(Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s,
_ => 0.0,
}
};
*d = Number::new(sample(channel), sample(channel + 1));
}
horizontal.convolve(&mut data, &mut scratch)?;
for (px, d) in dst.chunks_exact_mut(n).zip(data.get(2 * r..2 * r + w)?) {
*px.get_mut(channel)? = (d.re * scales.get(channel)?) as f32;
if let Some(v) = px.get_mut(channel + 1) {
*v = (d.im * scales.get(channel + 1)?) as f32;
}
}
}
finished.fetch_add(1, Ordering::Relaxed);
Some(())
})
.collect();
ok?;
ctl.progress(0.5 * finished.load(Ordering::Relaxed) as f32 / rows as f32);
if ctl.cancelled() {
return None;
}
}
let mut out = surface.clone();
let count = w.div_ceil(STRIPE);
for first in (0..count).step_by(group) {
let end = first.checked_add(group)?.min(count);
let results: Option<Vec<_>> = (first..end)
.into_par_iter()
.map(|index| {
if ctl.cancelled() {
return None;
}
let x = index.checked_mul(STRIPE)?;
let width = STRIPE.min(w.checked_sub(x)?);
let mut columns = buffer(width.checked_mul(rows)?.checked_mul(n)?, 0.0f32)?;
for (y, row) in tmp.chunks_exact(row_len).enumerate() {
if y % 64 == 0 && ctl.cancelled() {
return None;
}
for (c, px) in row.get(x * n..(x + width) * n)?.chunks_exact(n).enumerate() {
let start = (c * rows + y) * n;
columns.get_mut(start..start + n)?.copy_from_slice(px);
}
}
let rect = Rect::new(area.x0.checked_add(x as i32)?, area.y0, area.x0.checked_add((x + width) as i32)?, area.y1);
let len = width.checked_mul(h)?.checked_mul(n)?;
let mut result = buffer(len, 0.0f32)?;
let mut original = buffer(len, 0.0f32)?;
surface.read_region_into(rect, &mut original);
let (mut data, mut scratch) = vertical.work()?;
for (c, samples) in columns.chunks_exact(rows * n).enumerate() {
if ctl.cancelled() {
return None;
}
let mut scales = [0.0f64; crate::fxutil::MAXC];
for px in samples.chunks_exact(n) {
for (s, v) in scales.iter_mut().zip(px) {
*s = s.max(f64::from(v.abs()));
}
}
for channel in (0..n).step_by(2) {
if ctl.cancelled() {
return None;
}
data.fill(Number::default());
for (i, d) in data.iter_mut().take(h + 2 * r).enumerate() {
let sy = y0.checked_add(i as i32)?.clamp(read_y0, read_y1.checked_sub(1)?);
let start = usize::try_from(sy - read_y0).ok()?.checked_mul(n)?;
let px = samples.get(start..start + n)?;
let sample = |c: usize| -> f64 {
match (px.get(c), scales.get(c)) {
(Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s,
_ => 0.0,
}
};
*d = Number::new(sample(channel), sample(channel + 1));
}
vertical.convolve(&mut data, &mut scratch)?;
for (y, d) in data.get(2 * r..2 * r + h)?.iter().enumerate() {
let start = (y * width + c) * n;
*result.get_mut(start + channel)? = (d.re * scales.get(channel)?) as f32;
if let Some(v) = result.get_mut(start + channel + 1).filter(|_| channel + 1 < n) {
*v = (d.im * scales.get(channel + 1)?) as f32;
}
}
}
}
for (px, o) in result.chunks_exact_mut(n).zip(original.chunks_exact(n)) {
if alpha && *o.last()? <= 0.0 {
px.copy_from_slice(o);
continue;
}
let a = if alpha { *px.last()? } else { 1.0 };
// Near the original unpremultiply cutoff, cancellation/FFT noise
// or f32 sum order can expose colour. Decline the whole fast path.
let uncertainty = 4.0 * kernel.len() as f32 * f32::EPSILON;
if alpha && a <= 1e-7 + uncertainty {
return None;
}
for (v, o) in px.iter_mut().zip(o).take(n - usize::from(alpha)) {
let b = if alpha { *v / a } else { *v };
*v = o + amount / 100.0 * (o - b);
if !v.is_finite() {
return None;
}
}
if alpha {
*px.last_mut()? = *o.last()?;
}
}
if let Some(sel) = selection {
crate::mix_selection(&mut result, rect, sel, &Image { rect, ch: n, data: original }, alpha);
}
Some((rect, result))
})
.collect();
for (rect, data) in results? {
if ctl.cancelled() {
return None;
}
out.try_write_region(rect, &data).ok()?;
}
ctl.progress(0.5 + 0.48 * end as f32 / count as f32);
}
if ctl.cancelled() {
return None;
}
out.prune();
if ctl.cancelled() {
return None;
}
ctl.progress(1.0);
Some(out)
}
#[cfg(test)]
mod tests;
-146
View File
@@ -1,146 +0,0 @@
use super::*;
use crate::sharpen::tests::{reference, source};
use crate::{Ctx, image::Image};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
fn compare(old: &Surface, new: &Surface, area: Rect) {
let tolerance = match old.format().sample {
SampleType::U8 => 1.0 / 255.0 + 1e-7,
SampleType::U16 => 2.0 / 65535.0 + 1e-7,
SampleType::F32 => 3e-5,
};
for (a, b) in old.read_region(area).iter().zip(new.read_region(area)) {
let limit = if old.format().sample == SampleType::F32 { tolerance * (1.0 + a.abs()) } else { tolerance };
assert!((a - b).abs() <= limit, "{a} != {b}, tolerance {limit}");
}
}
#[test]
fn fft_preserves_gaussian_quality_at_every_depth_and_model() {
let area = Rect::new(-3, 2, 10, 13);
for mode in [
ColorMode::Bitmap,
ColorMode::Grayscale,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Rgb,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Multichannel,
] {
for depth in SampleType::ALL {
for alpha in [false, true] {
let s = source(area, PixelFormat::new(mode, depth, alpha));
let ctx = Ctx { bounds: area, mode, alpha };
for radius in [3.25, 64.0] {
let src = Image::read_clamped(&s, area.inflate((radius * 3.0_f32).ceil() as i32 + 1), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, radius, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
let actual = run(&s, area, 500.0, radius, None, Some(area), &Interrupt::NONE).unwrap();
compare(&expected, &actual, area);
if alpha {
for (a, b) in s.read_region(area).chunks_exact(s.channels()).zip(actual.read_region(area).chunks_exact(s.channels())) {
assert_eq!(a.last(), b.last());
if a.last() == Some(&0.0) {
assert_eq!(a, b);
}
}
}
}
}
}
}
}
#[test]
fn fft_keeps_partial_regions_selections_and_edge_policies() {
let extent = Rect::new(-7, -9, 35, 39);
let area = Rect::new(-3, 2, 19, 23);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(area, &[0.4]);
for depth in SampleType::ALL {
let s = source(extent, PixelFormat::new(ColorMode::Rgb, depth, true));
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 0.0 };
for edge in [None, Some(extent)] {
let expected = crate::sharpen::tests::apply_reference(&s, &p, area, extent, Some(&sel), edge, 256);
let actual = run(&s, area, 150.0, 64.0, Some(&sel), edge, &Interrupt::NONE).unwrap();
compare(&expected, &actual, extent);
}
}
}
#[test]
fn fft_falls_back_for_thresholds_and_unstable_samples() {
let area = Rect::new(0, 0, 256, 256);
let mut s = source(area, PixelFormat::RGBA32F);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 8.0 };
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 0.0 };
s.fill_rect(Rect::new(0, 0, 1, 1), &[f32::NAN, 0.5, 0.5, 1.0]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
s.fill_rect(area, &[0.5, 0.5, 0.5, 1e-8]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
s.fill_rect(area, &[0.5, 0.5, 0.5, -0.1]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
}
#[test]
fn fft_cancellation_discards_partial_work_and_bounds_scratch() {
let area = Rect::new(0, 0, 256, 256);
let s = source(area, PixelFormat::RGBA8);
let original = s.to_interleaved(area);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 300.0, threshold: 0.0 };
let flag = std::sync::atomic::AtomicBool::new(false);
let cancel = || flag.load(Ordering::Relaxed);
let progress = |_| flag.store(true, Ordering::Relaxed);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::new(&cancel, &progress)).unwrap().is_none());
assert_eq!(s.to_interleaved(area), original);
flag.store(false, Ordering::Relaxed);
let after_writes = |value| {
if value > 0.55 {
flag.store(true, Ordering::Relaxed);
}
};
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::new(&cancel, &after_writes)).unwrap().is_none());
assert_eq!(s.to_interleaved(area), original);
assert_eq!(group_size(MAX_BYTES, 1, 1, 1), None);
assert_eq!(group_size(0, usize::MAX, 1, 1), None);
assert!(Plan::new(usize::MAX, &[1.0], &mut FftPlanner::new()).is_none());
assert!(buffer::<f32>(usize::MAX, 0.0).is_none());
}
#[test]
fn positive_threshold_dispatch_retains_exact_pixel_decisions() {
let area = Rect::new(-3, 2, 253, 258);
for depth in SampleType::ALL {
let s = source(area, PixelFormat::new(ColorMode::Rgb, depth, true));
let p = FilterParams::UnsharpMask { amount: 500.0, radius: 64.0, threshold: 8.0 };
let expected = crate::sharpen::tests::apply_reference(&s, &p, area, area, None, Some(area), 256);
let actual = crate::apply_in(&s, &p, area, area, None, area);
assert_eq!(expected.to_interleaved(area), actual.to_interleaved(area));
}
}
#[test]
fn fft_handles_maximum_radius_and_hdr_alpha_channel_pairs() {
let area = Rect::new(0, 0, 1, 1);
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
s.fill_rect(area, &[0.5]);
let ctx = Ctx { bounds: area, mode: ColorMode::Grayscale, alpha: false };
let src = Image::read_clamped(&s, area.inflate(3001), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, 1000.0, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
compare(&expected, &run(&s, area, 500.0, 1000.0, None, Some(area), &Interrupt::NONE).unwrap(), area);
let area = Rect::new(-2, 3, 11, 14);
let mut s = Surface::new(PixelFormat::RGBA32F);
s.fill_rect(area, &[1e6, -1e5, 1e-4, 0.02]);
let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true };
let src = Image::read_clamped(&s, area.inflate(193), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, 64.0, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
compare(&expected, &run(&s, area, 500.0, 64.0, None, Some(area), &Interrupt::NONE).unwrap(), area);
}
-112
View File
@@ -1,112 +0,0 @@
//! Opt-in benchmark of the adaptive production path against the frozen original.
use super::tests::{apply_reference, source};
use crate::FilterParams;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use std::time::Instant;
#[test]
#[ignore = "adaptive large-radius full-operation quality and performance; run alone"]
fn unsharp_fft_performance() {
let number = |name: &str, default| std::env::var(name).ok().and_then(|v| v.parse::<i32>().ok()).unwrap_or(default);
let (w, h) = (number("PHOTOCRAFT_UNSHARP_WIDTH", 1920), number("PHOTOCRAFT_UNSHARP_HEIGHT", 1080));
let reps = number("PHOTOCRAFT_UNSHARP_REPS", 3);
assert!((1..=8192).contains(&w) && (1..=8192).contains(&h) && (1..=5).contains(&reps));
let depth = match number("PHOTOCRAFT_UNSHARP_DEPTH", 8) {
8 => SampleType::U8,
16 => SampleType::U16,
32 => SampleType::F32,
_ => panic!("depth"),
};
let area = Rect::new(0, 0, w, h);
let surface = source(area, PixelFormat::new(ColorMode::Rgb, depth, true));
let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "100:150:0,300:150:0,500:150:8".into());
for case in cases.split(',') {
let v: Vec<f32> = case.split(':').map(|v| v.parse().unwrap()).collect();
let (radius, amount, threshold) = (v[0], v[1], v[2]);
let params = FilterParams::UnsharpMask { amount, radius, threshold };
let baseline = || apply_reference(&surface, &params, area, area, None, Some(area), crate::auto_tile(&params, area));
let candidate = || crate::apply_in(&surface, &params, area, area, None, area);
for _ in 0..2 {
std::hint::black_box(baseline());
std::hint::black_box(candidate());
}
let mut before = Vec::new();
let mut after = Vec::new();
let mut max_error = 0.0f32;
let mut changed = 0usize;
let mut sum_error = 0.0f64;
for rep in 0..reps {
let timed = |f: &dyn Fn() -> Surface| {
let t = Instant::now();
let output = f();
(output, t.elapsed().as_secs_f64() * 1000.0)
};
let ((old, a), (new, b)) = if rep % 2 == 0 {
(timed(&baseline), timed(&candidate))
} else {
let b = timed(&candidate);
(timed(&baseline), b)
};
before.push(a);
after.push(b);
for (a, b) in old.read_region(area).iter().zip(new.read_region(area)) {
let e = (a - b).abs();
let limit = match depth {
SampleType::U8 => 1.0 / 255.0 + 1e-7,
SampleType::U16 => 3.0 / 65535.0 + 1e-7,
SampleType::F32 => 3e-5 * (1.0 + a.abs()),
};
assert!(e <= limit, "quality regression: {a} != {b} at radius {radius}, threshold {threshold}");
max_error = max_error.max(e);
if rep == 0 {
sum_error += f64::from(e);
changed += usize::from(a.to_bits() != b.to_bits());
}
}
}
let median = |v: &[f64]| {
let mut v = v.to_vec();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
};
println!(
"{}",
serde_json::json!({"experiment":"adaptive FFT same Gaussian, changed summation","width":w,"height":h,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"before_ms":before,"after_ms":after,"before_median_ms":median(&before),"after_median_ms":median(&after),"speedup":median(&before)/median(&after),"max_error":max_error,"mean_error":sum_error/(w as f64*h as f64*4.0),"changed_samples":changed})
);
}
}
#[test]
#[ignore = "4K wide-radius cancellation latency; run alone in release"]
fn unsharp_fft_cancel_latency() {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
let area = Rect::new(0, 0, 3840, 2160);
let surface = source(area, PixelFormat::RGBA8);
let input = surface.to_interleaved(area);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 1000.0, threshold: 0.0 };
let mut latencies = Vec::new();
for _ in 0..5 {
let flag = AtomicBool::new(false);
let cancel_at = AtomicU64::new(0);
let started = Instant::now();
std::thread::scope(|scope| {
let timer = scope.spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(10));
cancel_at.store(started.elapsed().as_micros() as u64, Ordering::Relaxed);
flag.store(true, Ordering::Release);
});
let cancel = || flag.load(Ordering::Acquire);
let result = crate::apply_in_with(&surface, &p, area, area, None, area, &photocraft_raster::Interrupt::cancel_only(&cancel));
let elapsed = started.elapsed().as_micros() as u64;
timer.join().unwrap();
assert!(result.is_none());
latencies.push(elapsed.saturating_sub(cancel_at.load(Ordering::Relaxed)) as f64 / 1000.0);
});
assert_eq!(surface.to_interleaved(area), input);
}
println!(
"{}",
serde_json::json!({"width":3840,"height":2160,"radius":1000,"cancel_after_ms":10,"cancel_return_latencies_ms":latencies,"scope":"native full-resolution algo dispatch, excludes GUI scheduling/proxy creation"})
);
}
-113
View File
@@ -1,113 +0,0 @@
//! Frozen exact Unsharp Mask from a28d7866, independent of production helpers.
use crate::Ctx;
use crate::image::{Image, premultiply, unpremultiply};
use photocraft_geom::Rect;
pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
if sigma < 0.05 {
return vec![1.0];
}
let r = (sigma * 3.0).ceil() as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect();
let s: f32 = k.iter().sum();
k.into_iter().map(|v| v / s).collect()
}
/// Separable convolution of `src` (premultiplied internally) for `out`.
/// `src` must cover `out` grown by the kernel radii.
pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec<f32> {
let n = src.ch;
let (rx, ry) = ((kx.len() / 2) as i32, (ky.len() / 2) as i32);
let (ow, oh) = (out.width() as usize, out.height() as usize);
let th = oh + 2 * ry as usize;
// Premultiplied copy of the needed source window.
let win = Rect::new(out.x0 - rx, out.y0 - ry, out.x1 + rx, out.y1 + ry);
let ww = win.width() as usize;
let mut p = vec![0.0f32; ww * win.height() as usize * n];
for y in win.y0..win.y1 {
for x in win.x0..win.x1 {
let o = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n;
for c in 0..n {
p[o + c] = src.get(x, y, c);
}
}
}
premultiply(&mut p, n, alpha);
// Horizontal pass: (ow × th).
let mut tmp = vec![0.0f32; ow * th * n];
for ty in 0..th {
let row = &p[ty * ww * n..(ty + 1) * ww * n];
let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n];
match n {
1 => conv_row::<1>(row, dst, kx),
2 => conv_row::<2>(row, dst, kx),
3 => conv_row::<3>(row, dst, kx),
4 => conv_row::<4>(row, dst, kx),
5 => conv_row::<5>(row, dst, kx),
_ => {
for ox in 0..ow {
let d = &mut dst[ox * n..(ox + 1) * n];
for (i, kv) in kx.iter().enumerate() {
let s = &row[(ox + i) * n..(ox + i + 1) * n];
for c in 0..n {
d[c] += s[c] * kv;
}
}
}
}
}
}
// Vertical pass.
let mut res = vec![0.0f32; ow * oh * n];
for oy in 0..oh {
let d = &mut res[oy * ow * n..(oy + 1) * ow * n];
for (i, kv) in ky.iter().enumerate() {
let s = &tmp[(oy + i) * ow * n..(oy + i + 1) * ow * n];
for (dv, sv) in d.iter_mut().zip(s) {
*dv += sv * kv;
}
}
}
unpremultiply(&mut res, n, alpha);
res
}
// Fixed channel counts let LLVM keep one pixel's accumulators in registers and vectorize
// channels. The tap order is unchanged; unusual multichannel images keep the generic path.
fn conv_row<const N: usize>(src: &[f32], dst: &mut [f32], kernel: &[f32]) {
let pixels = src.as_chunks::<N>().0;
for (x, out) in dst.as_chunks_mut::<N>().0.iter_mut().enumerate() {
let mut acc = [0.0f32; N];
for (pixel, weight) in pixels.get(x..).unwrap_or(&[]).iter().zip(kernel) {
for c in 0..N {
acc[c] += pixel[c] * weight;
}
}
*out = acc;
}
}
fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec<f32> {
let k = gaussian_kernel(radius);
let blurred = conv_sep(src, out, &k, &k, ctx.alpha);
let mut res = src.crop(out);
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
for (px, bl) in res.chunks_exact_mut(n).zip(blurred.chunks_exact(n)) {
if ctx.alpha && px[n - 1] <= 0.0 {
continue;
}
for c in 0..cc {
px[c] = f(px[c], bl[c]);
}
}
res
}
pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec<f32> {
let k = amount / 100.0;
let t = threshold / 255.0;
with_blur(src, out, ctx, radius, |o, b| {
let d = o - b;
if d.abs() + 1e-6 >= t { o + k * d } else { o }
})
}
-434
View File
@@ -1,434 +0,0 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::{Interrupt, Surface};
use std::time::Instant;
#[path = "reference.rs"]
pub(super) mod reference;
pub(super) fn source(rect: Rect, fmt: PixelFormat) -> Surface {
let mut s = Surface::new(fmt);
for y in rect.y0..rect.y1 {
let data: Vec<f32> = (rect.x0..rect.x1)
.flat_map(|x| {
(0..fmt.channels()).map(move |c| {
if fmt.alpha && c + 1 == fmt.channels() {
if (x + y).rem_euclid(17) == 0 { 0.0 } else { ((x ^ y).rem_euclid(255) + 1) as f32 / 255.0 }
} else {
let value = ((x * (7 + c as i32) + y * 13).rem_euclid(257)) as f32 / 256.0;
if fmt.sample == SampleType::F32 { 3.0 * value - 0.5 } else { value }
}
})
})
.collect();
s.write_region(Rect::new(rect.x0, y, rect.x1, y + 1), &data);
}
s
}
// Preserve the surrounding tiled workload as well as the old convolution: halo reads,
// automatic tiles, grouping, selection mixing, writes and pruning are timed in both paths.
#[allow(clippy::too_many_arguments)]
pub(super) fn apply_reference(
s: &Surface,
p: &crate::FilterParams,
area: Rect,
bounds: Rect,
sel: Option<&Surface>,
extent: Option<Rect>,
tile: i32,
) -> Surface {
let crate::FilterParams::UnsharpMask { amount, radius, threshold } = *p else { panic!("unsharp params") };
let crate::Halo::Radius(halo) = p.halo_for(bounds) else { panic!("radius halo") };
let fmt = s.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
let tiles: Vec<_> = (area.y0..area.y1)
.step_by(tile as usize)
.flat_map(|y| (area.x0..area.x1).step_by(tile as usize).map(move |x| Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1))))
.collect();
let group = (crate::RESULT_BUDGET / ((tile as usize).pow(2) * fmt.channels() * 4)).max(workers()).max(1);
let finished = std::sync::atomic::AtomicUsize::new(0);
let ctl = Interrupt::NONE;
let run = |t: &Rect| {
let src = match extent {
Some(e) => Image::read_clamped(s, t.inflate(halo), e),
None => Image::read(s, t.inflate(halo)),
};
let mut data = reference::unsharp(&src, *t, &ctx, amount, radius, threshold);
if let Some(sel) = sel {
crate::mix_selection(&mut data, *t, sel, &src, fmt.alpha);
}
let done = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
ctl.progress(0.98 * done as f32 / tiles.len().max(1) as f32);
(*t, data)
};
let mut out = s.clone();
for chunk in tiles.chunks(group) {
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<_> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<_> = chunk.iter().map(run).collect();
for (t, data) in results {
out.write_region(t, &data);
}
}
out.prune();
out
}
fn workers() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
rayon::current_num_threads()
}
#[cfg(target_arch = "wasm32")]
{
1
}
}
#[test]
fn frozen_reference_matches_current_kernel() {
let out = Rect::new(-3, 2, 16, 15);
let src = Image { rect: out.inflate(13), ch: 4, data: (0..(out.width() + 26) * (out.height() + 26) * 4).map(|i| (i % 127) as f32 / 126.0).collect() };
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
for radius in [0.1, 0.6, 1.5, 4.0] {
assert_eq!(unsharp(&src, out, &ctx, 150.0, radius, 8.0), reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
}
}
fn same_bits(a: &[f32], b: &[f32]) {
assert_eq!(a.len(), b.len());
for (i, (&a, &b)) in a.iter().zip(b).enumerate() {
assert!(a.to_bits() == b.to_bits() || a.is_nan() && b.is_nan(), "sample {i}: {a:?} ({:x}) != {b:?} ({:x})", a.to_bits(), b.to_bits());
}
}
#[test]
fn exact_kernel_across_models_depths_edges_and_thresholds() {
let modes = [
ColorMode::Bitmap,
ColorMode::Grayscale,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Rgb,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Multichannel,
];
for mode in modes {
for depth in SampleType::ALL {
for alpha in [false, true] {
let fmt = PixelFormat::new(mode, depth, alpha);
let area = Rect::new(-3, 2, 8, 7);
let mut surface = source(area, fmt);
if depth == SampleType::F32 {
// HDR, negative, signed zero, subnormal and nonfinite samples; colour is
// never clamped by this filter. Transparent hidden colour must survive.
let px: Vec<f32> = (0..fmt.channels()).map(|c| if alpha && c + 1 == fmt.channels() { 0.0 } else { -4.0 }).collect();
surface.fill_rect(Rect::new(0, 3, 1, 4), &px);
for (x, value) in [3.5, -0.0, f32::MIN_POSITIVE, f32::INFINITY, f32::NAN].into_iter().enumerate() {
let mut px = vec![value; fmt.channels()];
if alpha {
*px.last_mut().unwrap() = if x == 2 { 1e-8 } else { 1.0 };
}
surface.fill_rect(Rect::new(x as i32, 5, x as i32 + 1, 6), &px);
}
}
let ctx = Ctx { bounds: area, mode, alpha };
for radius in [0.0, 0.049, 0.1, 0.6, 1.5, 4.0, 17.25] {
let src = Image::read_clamped(&surface, area.inflate((radius * 3.0_f32).ceil() as i32 + 1), area);
for (amount, threshold) in [(0.0, 0.0), (50.0, 0.0), (500.0, 8.0), (300.0, 255.0)] {
same_bits(&unsharp(&src, area, &ctx, amount, radius, threshold), &reference::unsharp(&src, area, &ctx, amount, radius, threshold));
}
// An incomplete halo has zero extension, even with alpha.
let src = Image::read(&surface, area);
let out = Rect::new(-5, 1, 10, 8);
same_bits(&unsharp(&src, out, &ctx, 150.0, radius, 8.0), &reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
}
}
}
}
}
#[test]
fn exact_threshold_boundary_and_degenerate_images() {
for (w, h, n) in [(1, 1, 1), (1, 7, 2), (9, 1, 3), (7, 5, 4), (2, 3, 5), (5, 2, 7)] {
let out = Rect::new(-2, 3, w - 2, h + 3);
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: n % 2 == 0 };
let src = Image { rect: out, ch: n, data: (0..w * h * n as i32).map(|i| if i % 7 == 0 { -0.0 } else { (i % 17) as f32 / 16.0 }).collect() };
for radius in [0.0, 0.1, 0.75, 8.5, 64.0] {
let k = reference::gaussian_kernel(radius);
let blurred = reference::conv_sep(&src, out, &k, &k, ctx.alpha);
for (&o, &b) in src.data.iter().zip(&blurred).take(8) {
let boundary = ((o - b).abs() + 1e-6) * 255.0;
for threshold in [boundary, f32::from_bits(boundary.to_bits().saturating_sub(1)), f32::from_bits(boundary.to_bits() + 1)] {
same_bits(&unsharp(&src, out, &ctx, 500.0, radius, threshold), &reference::unsharp(&src, out, &ctx, 500.0, radius, threshold));
}
}
}
}
let empty = Image { rect: Rect::EMPTY, ch: 4, data: Vec::new() };
let ctx = Ctx { bounds: Rect::EMPTY, mode: ColorMode::Rgb, alpha: true };
assert!(unsharp(&empty, Rect::EMPTY, &ctx, 100.0, 1.0, 0.0).is_empty());
}
#[test]
fn exact_tiled_selected_outputs_and_cancellation() {
let bounds = Rect::new(-13, -7, 281, 271);
let area = Rect::new(-3, 5, 273, 265);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(area, &[0.4]);
sel.fill_rect(Rect::new(250, 250, 270, 265), &[1.0]);
for depth in SampleType::ALL {
let surface = source(bounds, PixelFormat::new(ColorMode::Rgb, depth, true));
for radius in [0.6, 3.0, 10.0] {
let p = crate::FilterParams::UnsharpMask { amount: 150.0, radius, threshold: 8.0 };
for extent in [None, Some(bounds)] {
let expected = apply_reference(&surface, &p, area, bounds, Some(&sel), extent, 256);
for tile in [63, 256, 512] {
let actual = crate::apply_tiled(&surface, &p, area, bounds, Some(&sel), tile, extent);
assert_eq!(expected.to_interleaved(bounds), actual.to_interleaved(bounds));
}
}
let cancelled = std::sync::atomic::AtomicBool::new(false);
let cancel = || cancelled.load(std::sync::atomic::Ordering::Relaxed);
let progress = |_| cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
let ctl = Interrupt::new(&cancel, &progress);
let input = surface.to_interleaved(bounds);
assert!(crate::apply_in_with(&surface, &p, area, bounds, Some(&sel), bounds, &ctl).is_none());
assert_eq!(surface.to_interleaved(bounds), input);
}
}
}
#[test]
fn preserve_details_resampling_keeps_the_same_sharpening() {
use crate::resample::{Resample, resize_surface};
let src = Rect::new(-3, 2, 19, 17);
let (sx, sy) = (2.0, 1.5);
let dst = crate::resample::scaled_rect(src, sx, sy);
for depth in SampleType::ALL {
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] {
let s = source(src, PixelFormat::new(mode, depth, true));
let resized = resize_surface(&s, sx, sy, Resample::Lanczos);
let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 };
let area = crate::output_area(&p, resized.content_bounds(), dst, None);
let expected = apply_reference(&resized, &p, area, dst, None, None, crate::auto_tile(&p, dst));
let actual = resize_surface(&s, sx, sy, Resample::PreserveDetails);
assert_eq!(actual.to_interleaved(area), expected.to_interleaved(area));
}
}
}
#[test]
fn flat_fields_and_sharp_edges_keep_exact_halos() {
let out = Rect::new(0, 0, 17, 9);
for alpha in [false, true] {
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha };
for radius in [0.1, 0.6, 3.25, 64.0] {
let rect = out.inflate((radius * 3.0_f32).ceil() as i32 + 1);
for edge in [false, true] {
let n = 3 + usize::from(alpha);
let data = (rect.y0..rect.y1)
.flat_map(|_| {
(rect.x0..rect.x1).flat_map(|x| {
(0..n).map(move |c| {
if alpha && c + 1 == n {
0.7
} else if edge && x >= 8 {
0.75
} else {
0.25
}
})
})
})
.collect();
let src = Image { rect, ch: n, data };
same_bits(&unsharp(&src, out, &ctx, 300.0, radius, 0.0), &reference::unsharp(&src, out, &ctx, 300.0, radius, 0.0));
}
}
}
}
#[test]
fn malformed_inputs_and_allocations_stop_gracefully() {
let rect = Rect::new(0, 0, 2, 2);
let src = Image { rect, ch: 1, data: vec![0.5; 4] };
let ctx = Ctx { bounds: rect, mode: ColorMode::Grayscale, alpha: false };
for (amount, radius, threshold) in [(f32::NAN, 1.0, 0.0), (100.0, f32::INFINITY, 0.0), (100.0, -1.0, 0.0), (100.0, f32::MAX, 0.0), (100.0, 1.0, f32::NAN)] {
assert!(exact::run(&src, rect, &ctx, amount, radius, threshold, &Interrupt::NONE).is_none());
let s = source(rect, PixelFormat::GRAY8);
let p = crate::FilterParams::UnsharpMask { amount, radius, threshold };
assert!(crate::apply_in_with(&s, &p, rect, rect, None, rect, &Interrupt::NONE).is_none());
assert_eq!(crate::apply_in(&s, &p, rect, rect, None, rect), s);
}
for bad in [Image { ch: 0, ..src.clone() }, Image { data: vec![0.0], ..src.clone() }, Image { rect: Rect::new(5, 0, 0, 2), ..src.clone() }] {
assert!(exact::run(&bad, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none());
}
for rect in [Rect::new(i32::MIN, 0, i32::MAX, 2), Rect::new(0, 0, 300_000, 300_000), Rect::new(2, 0, 0, 2)] {
assert!(exact::run(&src, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none());
}
let s = source(Rect::new(0, 0, 2, 2), PixelFormat::GRAY8);
let p = crate::FilterParams::UnsharpMask { amount: 100.0, radius: 1.0, threshold: 0.0 };
for tile in [i32::MIN, 0, i32::MAX] {
let r = s.content_bounds();
assert_eq!(crate::apply_tiled(&s, &p, r, r, None, tile, Some(r)), crate::apply_in(&s, &p, r, r, None, r));
}
// Allocation/geometry guard prevents a huge halo read before allocating it.
let p = crate::FilterParams::UnsharpMask { amount: 100.0, radius: 15_446.0, threshold: 0.0 };
assert!(crate::apply_in_with(&s, &p, s.content_bounds(), s.content_bounds(), None, s.content_bounds(), &Interrupt::NONE).is_none());
}
#[test]
fn maximum_supported_radius_matches_the_frozen_kernel() {
let out = Rect::new(0, 0, 1, 1);
let src = Image { rect: out, ch: 1, data: vec![0.75] };
let ctx = Ctx { bounds: out, mode: ColorMode::Grayscale, alpha: false };
same_bits(&unsharp(&src, out, &ctx, 500.0, 1000.0, 8.0), &reference::unsharp(&src, out, &ctx, 500.0, 1000.0, 8.0));
}
#[test]
fn cancellation_stops_inside_a_tile_before_publishing() {
let rect = Rect::new(0, 0, 16, 16);
let s = source(rect, PixelFormat::RGBA8);
let src = Image::read(&s, rect);
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
let checks = std::sync::atomic::AtomicUsize::new(0);
let cancel = || checks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 10;
assert!(exact::run(&src, rect, &ctx, 150.0, 30.0, 8.0, &Interrupt::cancel_only(&cancel)).is_none());
assert_eq!(src, Image::read(&s, rect));
}
#[test]
#[ignore = "release full-operation baseline/candidate comparison; run alone"]
fn unsharp_performance() {
let number = |name: &str, default: i32| std::env::var(name).ok().and_then(|s| s.parse::<i32>().ok()).unwrap_or(default);
let (w, h, reps) = (number("PHOTOCRAFT_UNSHARP_WIDTH", 1500), number("PHOTOCRAFT_UNSHARP_HEIGHT", 1000), number("PHOTOCRAFT_UNSHARP_REPS", 5));
assert!((1..=8192).contains(&w) && (1..=8192).contains(&h) && (1..=15).contains(&reps));
let depth = match number("PHOTOCRAFT_UNSHARP_DEPTH", 8) {
8 => SampleType::U8,
16 => SampleType::U16,
32 => SampleType::F32,
_ => panic!("depth"),
};
let area = Rect::new(0, 0, w, h);
let channels = number("PHOTOCRAFT_UNSHARP_CHANNELS", 4);
let (mode, alpha) = match channels {
1 => (ColorMode::Grayscale, false),
2 => (ColorMode::Grayscale, true),
3 => (ColorMode::Rgb, false),
4 => (ColorMode::Rgb, true),
5 => (ColorMode::Cmyk, true),
_ => panic!("channels 1..5"),
};
let surface = source(area, PixelFormat::new(mode, depth, alpha));
let measure = std::env::var("PHOTOCRAFT_UNSHARP_MODE").unwrap_or_else(|_| "paired".into());
assert!(matches!(measure.as_str(), "paired" | "reference" | "candidate"));
let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "0.6:150:0,3:150:8,10:300:16,30:150:0".into());
for case in cases.split(',') {
let values: Vec<f32> = case.split(':').map(|v| v.parse().unwrap()).collect();
let (radius, amount, threshold) = (values[0], values[1], values[2]);
let p = crate::FilterParams::UnsharpMask { amount, radius, threshold };
let tile = crate::auto_tile(&p, area);
let before = || apply_reference(&surface, &p, area, area, None, Some(area), tile);
// Isolate the streamed exact path from the new adaptive FFT dispatcher.
let after = || crate::apply_tiled_with_impl(&surface, &p, area, area, None, tile, Some(area), &Interrupt::NONE, false).unwrap();
if measure != "paired" {
let f = if measure == "reference" { &before as &dyn Fn() -> Surface } else { &after as &dyn Fn() -> Surface };
let mut times = Vec::new();
for rep in 0..reps + 2 {
let start = Instant::now();
let output = f();
let ms = start.elapsed().as_secs_f64() * 1000.0;
std::hint::black_box(output);
if rep >= 2 {
times.push(ms);
}
}
println!(
"{}",
serde_json::json!({"mode":measure,"width":w,"height":h,"channels":channels,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"workers":workers(),"runs_ms":times})
);
continue;
}
for _ in 0..2 {
std::hint::black_box(before());
std::hint::black_box(after());
}
let (mut a, mut b) = (Vec::new(), Vec::new());
for rep in 0..reps {
let timed = |f: &dyn Fn() -> Surface| {
let start = Instant::now();
let s = f();
(s, start.elapsed().as_secs_f64() * 1000.0)
};
let ((old, old_ms), (new, new_ms)) = if rep % 2 == 0 {
(timed(&before), timed(&after))
} else {
let b = timed(&after);
(timed(&before), b)
};
assert_eq!(old.to_interleaved(area), new.to_interleaved(area));
a.push(old_ms);
b.push(new_ms);
}
let median = |v: &[f64]| {
let mut v = v.to_vec();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
};
println!(
"{}",
serde_json::json!({"width":w,"height":h,"channels":channels,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"workers":workers(),"before_ms":a,"after_ms":b,"before_median_ms":median(&a),"after_median_ms":median(&b),"speedup":median(&a)/median(&b),"max_error":0})
);
}
}
#[test]
#[ignore = "unchanged-filter stage profile; run alone in release"]
fn unsharp_baseline_profile() {
let out = Rect::new(0, 0, 256, 256);
for radius in [0.6, 3.0, 10.0, 30.0] {
let reach = (radius * 3.0_f32).ceil() as i32 + 1;
let s = source(out.inflate(reach), PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
let src = Image::read(&s, out.inflate(reach));
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
let k = reference::gaussian_kernel(radius);
let mut times = [0.0; 4];
for _ in 0..20 {
let start = Instant::now();
std::hint::black_box(reference::gaussian_kernel(radius));
times[0] += start.elapsed().as_secs_f64();
let start = Instant::now();
let bl = reference::conv_sep(&src, out, &k, &k, true);
times[1] += start.elapsed().as_secs_f64();
let start = Instant::now();
let mut res = src.crop(out);
for (px, b) in res.as_chunks_mut::<4>().0.iter_mut().zip(bl.as_chunks::<4>().0) {
if px[3] <= 0.0 {
continue;
}
for c in 0..3 {
let d = px[c] - b[c];
if d.abs() + 1e-6 >= 8.0 / 255.0 {
px[c] += 1.5 * d;
}
}
}
std::hint::black_box(res);
times[2] += start.elapsed().as_secs_f64();
let start = Instant::now();
std::hint::black_box(reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
times[3] += start.elapsed().as_secs_f64();
}
println!(
"{}",
serde_json::json!({"baseline_tile_radius":radius,"kernel_ms":times[0]*50.0,"convolution_ms":times[1]*50.0,"crop_blend_ms":times[2]*50.0,"total_kernel_ms":times[3]*50.0})
);
}
}
+2 -17
View File
@@ -5,7 +5,6 @@ use photocraft_geom::Rect;
use photocraft_raster::{from_rgba, to_rgba};
use crate::Ctx;
use crate::fxutil::subtractive;
use crate::image::Image;
fn fmt(ctx: &Ctx) -> PixelFormat {
@@ -24,16 +23,6 @@ fn set_gray(ctx: &Ctx, px: &mut [f32], g: f32) {
px.copy_from_slice(&v[..px.len()]);
}
/// Solarize: inverts the highlights. CMYK samples are ink, so there the
/// channels below 50% ink (the light ones) invert.
pub(crate) fn solarize(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
if subtractive(ctx) {
per_pixel(src, out, ctx, |v| if v < 0.5 { 1.0 - v } else { v })
} else {
per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v })
}
}
/// Colour channels through `f` (alpha untouched).
pub(crate) fn per_pixel(src: &Image, out: Rect, ctx: &Ctx, f: impl Fn(f32) -> f32) -> Vec<f32> {
let n = src.ch;
@@ -178,13 +167,10 @@ pub(crate) fn emboss(src: &Image, out: Rect, ctx: &Ctx, angle: f32, height: f32,
res
}
/// Find Edges: Sobel magnitude per colour channel, dark edges on white (CMYK samples are ink, so
/// there the edge strength is the ink itself). At full strength, as Photoshop's: a 60-level step
/// darkens by 240 (Photoshop 27.11; a quarter of that was 4× too pale).
/// Find Edges: Sobel magnitude per colour channel, dark edges on white.
pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
let sub = subtractive(ctx);
let mut res = src.crop(out);
let w = out.width() as usize;
for (i, px) in res.chunks_exact_mut(n).enumerate() {
@@ -193,8 +179,7 @@ pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let g = |dx: i32, dy: i32| src.get(x + dx, y + dy, c);
let gx = (g(1, -1) + 2.0 * g(1, 0) + g(1, 1)) - (g(-1, -1) + 2.0 * g(-1, 0) + g(-1, 1));
let gy = (g(-1, 1) + 2.0 * g(0, 1) + g(1, 1)) - (g(-1, -1) + 2.0 * g(0, -1) + g(1, -1));
let e = (gx * gx + gy * gy).sqrt().clamp(0.0, 1.0);
*v = if sub { e } else { 1.0 - e };
*v = (1.0 - (gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0);
}
}
res
+11 -1
View File
@@ -167,7 +167,17 @@ fn storage_selection_and_tile_sizes_match_original_kernels() {
FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed),
_ => continue,
};
crate::mix_selection(&mut raw, area, &selection, &src, fmt.alpha);
let original = s.read_region(area);
for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() {
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
let k = selection.sample_channel(x, y, 0);
if k >= 1.0 {
continue;
}
for (c, v) in px.iter_mut().enumerate() {
*v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k;
}
}
let mut want = s.clone();
want.write_region(area, &raw);
for tile in [1, 7, 17, 256] {
-112
View File
@@ -367,30 +367,6 @@ fn stylize_basics() {
assert!((d[0] - d[1]).abs() < 1e-6 && (d[1] - d[2]).abs() < 1e-6);
}
/// CMYK samples are ink (1 = full ink): Find Edges leaves flat areas white (no ink, not 100% ink)
/// and Solarize inverts the channels below 50% ink, as Photoshop does (#4222). Alpha is untouched.
#[test]
fn find_edges_and_solarize_treat_cmyk_as_ink() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let f = PixelFormat::new(ColorMode::Cmyk, depth, true);
let mut s = Surface::new(f);
s.fill_rect(R, &[0.1, 0.1, 0.1, 0.1, 1.0]);
let e = run(&s, &FilterParams::FindEdges).pixel(20, 15);
assert!(e[..4].iter().all(|v| v.abs() < 1e-6) && (e[4] - 1.0).abs() < 1e-6, "{depth:?} flat CMYK -> white: {e:?}");
let mut s = Surface::new(f);
s.fill_rect(R, &[0.1, 0.2, 0.8, 0.9, 0.6]);
let so = run(&s, &FilterParams::Solarize).pixel(3, 3);
let want = [0.9, 0.8, 0.8, 0.9, 0.6];
assert!(so.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} solarize CMYK: {so:?}");
}
// RGB keeps the light-polarity behaviour: flat -> white, channels above 50% invert.
let s = flat(SampleType::U8, R, [0.1, 0.2, 0.8, 1.0]);
let e = run(&s, &FilterParams::FindEdges).pixel(20, 15);
assert!(e[..3].iter().all(|v| (v - 1.0).abs() < 1e-6), "{e:?}");
let so = run(&s, &FilterParams::Solarize).pixel(3, 3);
assert!(so.iter().zip([0.1, 0.2, 0.2, 1.0]).all(|(a, b)| (a - b).abs() < 0.01), "{so:?}");
}
#[test]
fn distortions_are_identity_at_zero() {
let s = pattern(SampleType::U8, R);
@@ -446,10 +422,6 @@ fn halos_and_output_area() {
assert_eq!(output_area(&FilterParams::Twirl { angle: 1.0 }, content, R, None), R);
assert_eq!(output_area(&FilterParams::BoxBlur { radius: 1.0 }, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(8, 8, 12, 12));
assert!(output_area(&FilterParams::Invert, Rect::EMPTY, R, None).is_empty());
// Fibers renders without reading the layer: an empty layer still fills the bounds (#5241).
let fibers = FilterParams::Fibers { variance: 16.0, strength: 4.0, seed: 0, foreground: [0.0; 4], background: [1.0; 4] };
assert_eq!(output_area(&fibers, Rect::EMPTY, R, None), R);
assert_eq!(output_area(&fibers, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(0, 0, 12, 12));
}
#[test]
@@ -491,87 +463,3 @@ fn layer_filters_repeat_the_canvas_edge() {
assert!(faded.pixel(0, 0)[3] < 0.9, "{depth:?}: unclamped path should read transparency");
}
}
/// Photoshop 27.11's Polar Coordinates turn counter-clockwise from 12 o'clock: just left of the
/// top centre, Rectangular to Polar samples the source's left edge (clockwise would take its right).
#[test]
fn polar_coordinates_turn_counter_clockwise() {
let r = Rect::new(0, 0, 64, 64);
let mut s = flat(SampleType::U8, r, [0.0, 0.0, 1.0, 1.0]);
s.fill_rect(Rect::new(0, 0, 32, 64), &[1.0, 0.0, 0.0, 1.0]);
let p = FilterParams::PolarCoordinates { mode: PolarMode::RectangularToPolar };
let out = apply(&s, &p, r, r, None);
let left_of_top = out.rgba(28, 12);
assert!(left_of_top[0] > 0.9 && left_of_top[2] < 0.1, "{left_of_top:?}");
// The corners outside the circle repeat the source's edge instead of going transparent.
assert!(out.rgba(0, 0)[3] > 0.99, "{:?}", out.rgba(0, 0));
}
/// `mix_selection` before #4533, one selection lookup per pixel: the oracle for the per-tile read.
fn mix_selection_reference(data: &mut [f32], t: Rect, sel: &Surface, orig: &crate::image::Image, alpha: bool) {
let w = t.width() as usize;
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
if k >= 1.0 {
continue;
}
if k <= 0.0 {
for (c, v) in px.iter_mut().enumerate() {
*v = orig.get(x, y, c);
}
continue;
}
if alpha {
let a = px.len() - 1;
let original_alpha = orig.get(x, y, a);
let filtered_alpha = px[a];
if original_alpha != filtered_alpha {
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
let original_weight = original_alpha * (1.0 - k);
let filtered_weight = filtered_alpha * k;
for (c, v) in px[..a].iter_mut().enumerate() {
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
}
px[a] = mixed_alpha;
continue;
}
}
for (c, v) in px.iter_mut().enumerate() {
let o = orig.get(x, y, c);
*v = o + (*v - o) * k;
}
}
}
/// Reading the selection once per tile gives exactly the per-pixel result (#4533): a soft
/// selection with fully selected, unselected and partial pixels, with and without alpha, on a
/// tile that crosses the selection's tile boundaries.
#[test]
fn mix_selection_reads_the_selection_once_per_tile() {
let noise = |seed: u32, n: usize| -> Vec<f32> {
(0..n as u32)
.map(|i| {
let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b);
h ^= h >> 13;
h = h.wrapping_mul(0xc2b2_ae35);
(h >> 16) as f32 / 65535.0
})
.collect()
};
let t = Rect::new(200, 180, 330, 300);
let n = (t.width() * t.height()) as usize;
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(150, 150, 260, 250), &[1.0]);
let soft = Rect::new(260, 150, 330, 250);
sel.write_region(soft, &noise(5, (soft.width() * soft.height()) as usize));
for (alpha, fmt) in [(true, PixelFormat::RGBA8), (false, PixelFormat { alpha: false, ..PixelFormat::RGBA8 })] {
let ch = fmt.channels();
let orig = crate::image::Image { rect: t, ch, data: noise(1, n * ch) };
let filtered = noise(2, n * ch);
let (mut a, mut b) = (filtered.clone(), filtered);
mix_selection(&mut a, t, &sel, &orig, alpha);
mix_selection_reference(&mut b, t, &sel, &orig, alpha);
assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ");
}
}
+1 -1
View File
@@ -170,7 +170,7 @@ fn iris_depths_models_selection_tiles_and_cancellation() {
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
let src = Image::read_clamped(&surface, t.inflate(radius), bounds);
let mut data = reference::iris(&src, t, &ctx, &pins);
crate::mix_selection(&mut data, t, &selection, &src, alpha);
crate::mix_selection(&mut data, t, &selection, &src);
want.write_region(t, &data);
}
}
-98
View File
@@ -1,98 +0,0 @@
use super::*;
use photocraft_color::{PixelFormat, SampleType};
fn selection(coverage: f32) -> Surface {
Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[coverage])
}
#[test]
fn feathered_color_to_alpha_preserves_appearance_over_the_key() {
let r = Rect::new(0, 0, 1, 1);
let filter = FilterParams::ColorToAlpha { color: [1.0; 4], transparency_threshold: 0.0, opacity_threshold: 1.0 };
for sample in SampleType::ALL {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [0.5, 1.0] {
let format = PixelFormat::new(mode, sample, true);
let mut s = Surface::new(format);
let mut p = vec![0.5; format.channels()];
*p.last_mut().unwrap() = alpha;
s.write_pixel(0, 0, &p);
let original = s.pixel(0, 0);
let a = format.channels() - 1;
for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] {
let out = apply(&s, &filter, r, r, Some(&selection(coverage)));
let actual = out.pixel(0, 0);
let tolerance = if sample == SampleType::U8 { 2.0 / 255.0 } else { 1e-4 };
for c in 0..a {
let before = original[c] * original[a] + 1.0 - original[a];
let after = actual[c] * actual[a] + 1.0 - actual[a];
assert!(
(before - after).abs() < tolerance,
"{sample:?}/{mode:?}, alpha={alpha}, coverage={coverage}: {original:?} -> {actual:?}, over white {before} -> {after}"
);
}
}
}
}
}
}
#[test]
fn feathered_offset_ignores_hidden_colour_of_the_original_pixel() {
let r = Rect::new(0, 0, 2, 1);
let filter = FilterParams::Offset { horizontal: 1, vertical: 0, undefined: UndefinedAreas::Wrap };
for sample in SampleType::ALL {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
s.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.0]);
s.write_pixel(1, 0, &[0.0, 0.0, 1.0, 1.0]);
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
let p = out.pixel(0, 0);
assert_eq!(&p[..3], &[0.0, 0.0, 1.0], "{sample:?}: hidden red contaminated the selected blue: {p:?}");
assert!((p[3] - 0.5).abs() < 1.0 / 255.0);
}
}
#[test]
fn feathered_blurs_do_not_reintroduce_hidden_colour() {
let r = Rect::new(0, 0, 3, 3);
let mut s = Surface::new(PixelFormat::RGBA32F);
s.fill_rect(r, &[1.0, 0.0, 0.0, 0.0]);
s.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]);
for filter in [FilterParams::BoxBlur { radius: 1.0 }, FilterParams::MotionBlur { angle: 0.0, distance: 3.0 }] {
let full = apply(&s, &filter, r, r, None);
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
let p = out.pixel(0, 1);
assert!(p[3] > 0.0, "{filter:?}: expected blur to spread coverage");
assert!((p[3] - full.pixel(0, 1)[3] * 0.5).abs() < 1e-6, "{filter:?}: selection should halve the new coverage");
assert!(p[0].abs() < 1e-6 && (p[2] - 1.0).abs() < 1e-6, "{filter:?}: hidden red reappeared: {p:?}");
}
}
#[test]
fn selection_mixing_keeps_hdr_range_and_exact_endpoints() {
let r = Rect::new(0, 0, 1, 1);
let original = Image { rect: r, ch: 4, data: vec![-2.0, 4.0, 0.5, 0.25] };
let filtered = vec![8.0, -4.0, 0.75, 1.0];
let mut data = filtered.clone();
mix_selection(&mut data, r, &selection(0.5), &original, true);
for (&actual, expected) in data.iter().zip([6.0, -2.4, 0.7, 0.625]) {
assert!((actual - expected).abs() < 1e-6, "{data:?}");
}
for coverage in [0.0, 1.0] {
let mut data = filtered.clone();
mix_selection(&mut data, r, &selection(coverage), &original, true);
assert_eq!(data, if coverage == 0.0 { &original.data } else { &filtered }.clone());
}
}
#[test]
fn selection_mixing_without_alpha_keeps_every_channel_independent() {
let r = Rect::new(0, 0, 1, 1);
// One channel is a mask; three/four channels can be RGB or CMYK without alpha.
for ch in [1, 3, 4] {
let original = Image { rect: r, ch, data: vec![0.25; ch] };
let mut data = vec![0.75; ch];
mix_selection(&mut data, r, &selection(0.25), &original, false);
assert_eq!(data, vec![0.375; ch]);
}
}
+1 -1
View File
@@ -177,7 +177,7 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
None => Image::read(&surface, t.inflate(radius)),
};
let mut data = reference::tilt_shift(&src, t, &ctx, 17.0, (0.3, 0.7), 29.0, 0.07, 0.3);
crate::mix_selection(&mut data, t, &selection, &src, alpha);
crate::mix_selection(&mut data, t, &selection, &src);
want.write_region(t, &data);
}
}
+129 -12
View File
@@ -4,8 +4,7 @@
//! All work on straight (non-premultiplied) RGBA buffers in 0..=1, row-major `w × h`, so callers
//! convert any colour model / bit depth to RGBA first (as `Image › Adjustments` does for the
//! per-pixel adjustments). Formulas are documented approximations of Photoshop's behaviour,
//! derived from its manual and observation, not from its implementation; Shadows/Highlights is
//! measured on Photoshop (see [`shadows_highlights`]).
//! derived from its manual and observation, not from its implementation.
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
use serde::{Deserialize, Serialize};
@@ -18,6 +17,18 @@ fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Blurred luminance (Gaussian, `radius` px ≈ 2σ like Photoshop's radius fields).
fn blurred_luma(px: &[[f32; 4]], w: usize, h: usize, radius: f32) -> Vec<f32> {
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]])).collect();
gauss_blur_n(&mut l, w, h, 1, radius.max(0.0) / 2.0);
l
}
/// Image › Adjustments › Shadows/Highlights parameters (Photoshop's dialog, percentages as 0..=100).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ShadowsHighlights {
@@ -53,22 +64,102 @@ impl Default for ShadowsHighlights {
}
}
mod shadows_highlights;
pub use shadows_highlights::shadows_highlights;
/// Local tone mapping: a blurred-luminance mask selects shadows (dark neighbourhoods, up to the
/// tonal width) and highlights; shadows are lifted and highlights pulled down by a gain that
/// preserves hue, then saturation is corrected where the gain moved tones, midtone contrast is
/// applied, and the result is stretched by the clip percentages.
pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) {
if px.is_empty() {
return;
}
let ls = blurred_luma(px, w, h, p.shadow_radius);
let lh = if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 { ls.clone() } else { blurred_luma(px, w, h, p.highlight_radius) };
let (sa, ha) = (p.shadow_amount / 100.0, p.highlight_amount / 100.0);
let (st, ht) = ((p.shadow_tone / 100.0).max(0.01), (p.highlight_tone / 100.0).max(0.01));
let color = p.color / 100.0;
let mid = p.midtone / 100.0;
par_rows(px, w, 1, |y, pxrow| {
for (x, q) in pxrow.iter_mut().enumerate() {
let i = y * w + x;
if q[3] <= 0.0 {
continue;
}
let c = [q[0], q[1], q[2]];
// Mask weights: 1 in deep shadows (resp. bright highlights), fading out at the tonal width.
let ms = 1.0 - smoothstep(0.0, st, ls[i]);
let mh = smoothstep(1.0 - ht, 1.0, lh[i]);
let mut out = c;
if sa > 0.0 && ms > 0.0 {
// Lift: a gamma-like curve on the pixel, stronger for darker neighbourhoods.
let g = 1.0 / (1.0 + 2.0 * sa * ms);
out = out.map(|v| v.max(0.0).powf(g));
}
if ha > 0.0 && mh > 0.0 {
let g = 1.0 / (1.0 + 2.0 * ha * mh);
out = out.map(|v| 1.0 - (1.0 - v.min(1.0)).powf(g));
}
if mid != 0.0 {
let k = 1.0 + mid * (1.0 - ms - mh).clamp(0.0, 1.0);
out = out.map(|v| (v - 0.5) * k + 0.5);
}
let changed = (ms * sa + mh * ha).min(1.0);
if color != 0.0 && changed > 0.0 {
let l1 = luma(out);
let s = 1.0 + color * changed;
out = out.map(|v| l1 + (v - l1) * s);
}
q[0] = out[0].clamp(0.0, 1.0);
q[1] = out[1].clamp(0.0, 1.0);
q[2] = out[2].clamp(0.0, 1.0);
}
});
clip_stretch(px, p.black_clip, p.white_clip);
}
/// Stretches levels so `black`% of pixels clip to 0 and `white`% to 1 (luminance histogram).
fn clip_stretch(px: &mut [[f32; 4]], black: f32, white: f32) {
if black <= 0.0 && white <= 0.0 {
return;
}
let mut hist = [0u32; 1024];
let mut n = 0u32;
for q in px.iter().filter(|q| q[3] > 0.0) {
hist[((luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)) * 1023.0).round() as usize] += 1;
n += 1;
}
if n == 0 {
return;
}
let find = |pct: f32, rev: bool| -> f32 {
let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32;
let mut acc = 0;
for k in 0..1024 {
let i = if rev { 1023 - k } else { k };
acc += hist[i];
if acc > target {
return i as f32 / 1023.0;
}
}
if rev { 0.0 } else { 1.0 }
};
let lo = find(black, false);
let hi = find(white, true);
if hi - lo < 1e-3 {
return;
}
for q in px.iter_mut().filter(|q| q[3] > 0.0) {
for v in &mut q[..3] {
*v = ((*v - lo) / (hi - lo)).clamp(0.0, 1.0);
}
}
}
/// Image › Adjustments › Replace Color: Color Range-style selection (fuzziness in 0..=200 levels)
/// around `color`, then a hue (±180°), saturation and lightness (±100) shift weighted by it.
/// Returns the selection mask (what the dialog previews).
pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: f32, saturation: f32, lightness: f32) -> Vec<f32> {
let mask = crate::selection::color_range(px, color, fuzziness);
replace_color_masked(px, &mask, hue, saturation, lightness);
mask
}
/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask`
/// (`0..=1`, one per pixel: the colour match, localized or not) covers it.
pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) {
for (q, &k) in px.iter_mut().zip(mask) {
for (q, &k) in px.iter_mut().zip(&mask) {
if k <= 0.0 {
continue;
}
@@ -81,6 +172,7 @@ pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturat
q[i] = (c[i] + (o[i] - c[i]) * k).clamp(0.0, 1.0);
}
}
mask
}
/// Lab mean and standard deviation of the opaque pixels, `[L, a, b]` each.
@@ -286,6 +378,31 @@ mod tests {
.collect()
}
#[test]
fn shadows_lift_dark_pixels_more_than_bright() {
let mut px = ramp(32, 8);
let orig = px.clone();
shadows_highlights(&mut px, 32, 8, &ShadowsHighlights { black_clip: 0.0, white_clip: 0.0, ..Default::default() });
let gain = |i: usize| luma([px[i][0], px[i][1], px[i][2]]) - luma([orig[i][0], orig[i][1], orig[i][2]]);
assert!(gain(3) > 0.02, "{}", gain(3));
assert!(gain(3) > gain(30), "{} {}", gain(3), gain(30));
// Zero amounts and no clipping: identity.
let mut px2 = orig.clone();
let id = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 0.0, color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
shadows_highlights(&mut px2, 32, 8, &id);
assert_eq!(px2, orig);
}
#[test]
fn highlights_darken_bright_pixels() {
let mut px = ramp(32, 8);
let orig = px.clone();
let p = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 80.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
shadows_highlights(&mut px, 32, 8, &p);
assert!(px[30][0] < orig[30][0]);
assert!((px[1][0] - orig[1][0]).abs() < 1e-6);
}
#[test]
fn replace_color_only_touches_matching_pixels() {
let mut px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]];
-423
View File
@@ -1,423 +0,0 @@
//! Image › Adjustments › Shadows/Highlights, as Photoshop 25.4 does it.
//!
//! Measured on Photoshop as a black box (synthetic 16-bit images through the adjustment, results
//! read back exactly); no Photoshop code was read. The pieces:
//!
//! - **Neighbourhood brightness.** Per pixel, the mean of its brightest channel and its Rec. 601
//! luma. Its neighbourhood value is an edge-aware average: a Gaussian of σ = 1.14 · Radius whose
//! weights fall linearly with the difference from the pixel's own (lightly blurred) brightness,
//! so pixels across a strong edge count less and halos stay small. It is quantised to 8 bits.
//! - **Tonal mask.** Tone sets which neighbourhoods are treated: shadows take the complement of a
//! Beta distribution function of the neighbourhood brightness, from Beta(1.95, 23) at Tone 0 to
//! 1 − L at Tone 95 and over; highlights take the same of 1 − L.
//! - **Curve.** Amount sets a curve that screens the image with itself: 1 − (1 − v)^q, blended
//! between q = 1, 2, 4 and 8 by a strength s = 3 at 100, 2 at 50 and 1 near 15. Shadows lift each
//! channel by mask × (curve − v); highlights darken with the mirrored curve.
//! - **Colour.** Only the luma of that result is kept: the pixel's own colour differences are
//! added back, scaled by the Color slider (by exp(h · ΔY / max Y) for positive values, by
//! 1 + h · ΔY / max Y for negative ones) and shortened where they would leave the gamut.
//! - **Midtone** is a global S-curve on the luma, k · (Y − ½) · (4Y(1 − Y))^p.
use crate::fxutil::gauss_blur_n;
use crate::photo_util::par_rows;
use super::ShadowsHighlights;
const W: [f32; 3] = [0.299, 0.587, 0.114];
fn luma(c: [f32; 3]) -> f32 {
W[0] * c[0] + W[1] * c[1] + W[2] * c[2]
}
/// Neighbourhood σ per pixel of Radius.
const SPATIAL: f32 = 1.1424;
/// σ of the blur of the pixel's own brightness the edge weights compare with.
const GUIDE: f32 = 1.3559;
/// Brightness difference at which a neighbour's weight reaches zero.
const RANGE: f32 = 1.0152;
/// Brightness levels of the piecewise-linear edge-aware average.
const LEVELS: usize = 17;
/// Edge-aware neighbourhood brightness of `lum` (`w × h`) for Photoshop's Radius.
fn neighbourhood(lum: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
let mut guide = lum.to_vec();
gauss_blur_n(&mut guide, w, h, 1, GUIDE);
let sigma = SPATIAL * radius;
if sigma < 0.3 {
return guide;
}
// The average at each of a few brightness levels, interpolated at the pixel's own level
// (Durand and Dorsey, "Fast bilateral filtering", 2002). The averages are smooth, so they are
// taken on a grid `d` times coarser, with σ ≥ 4 cells there.
let d = ((sigma / 4.0).floor() as usize).max(1);
let (rw, rh) = (w.div_ceil(d), h.div_ceil(d));
let cells = rw * rh;
let step = 1.0 / (LEVELS - 1) as f32;
let mut avg = vec![0.0f32; LEVELS * cells];
let mut buf = vec![0.0f32; 2 * cells];
for (k, out) in avg.chunks_exact_mut(cells).enumerate() {
let g = k as f32 * step;
buf.fill(0.0);
for (i, &l) in lum.iter().enumerate() {
let wgt = (1.0 - (l - g).abs() / RANGE).max(0.0);
let c = 2 * ((i / w) / d * rw + (i % w) / d);
buf[c] += wgt * l;
buf[c + 1] += wgt;
}
gauss_blur_n(&mut buf, rw, rh, 2, sigma / d as f32);
for (o, b) in out.iter_mut().zip(buf.as_chunks::<2>().0) {
*o = if b[1] > 1e-6 { b[0] / b[1] } else { g };
}
}
let mut out = vec![0.0f32; lum.len()];
par_rows(&mut out, w, 1, |y, row| {
let fy = ((y as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rh - 1) as f32);
let (y0, ty) = (fy.floor() as usize, fy.fract());
let y1 = (y0 + 1).min(rh - 1);
for (x, o) in row.iter_mut().enumerate() {
let fx = ((x as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rw - 1) as f32);
let (x0, tx) = (fx.floor() as usize, fx.fract());
let x1 = (x0 + 1).min(rw - 1);
let at = |k: usize| {
let a = &avg[k * cells..(k + 1) * cells];
let top = a[y0 * rw + x0] + (a[y0 * rw + x1] - a[y0 * rw + x0]) * tx;
let bot = a[y1 * rw + x0] + (a[y1 * rw + x1] - a[y1 * rw + x0]) * tx;
top + (bot - top) * ty
};
let t = (guide[y * w + x].clamp(0.0, 1.0) / step).min((LEVELS - 1) as f32 - 1e-4);
let k = t.floor() as usize;
let f = t - k as f32;
*o = at(k) * (1.0 - f) + at(k + 1) * f;
}
});
out
}
/// Beta(α, β) parameters of the tonal mask for Tone 0, 5, …, 95 (fitted to Photoshop's masks
/// within 2/255).
const MASK_BETA: [(f32, f32); 20] = [
(1.9468, 22.9366),
(1.7770, 18.3471),
(1.6403, 14.7559),
(1.5270, 11.9346),
(1.4269, 9.6467),
(1.3470, 7.8430),
(1.2807, 6.4038),
(1.2240, 5.2384),
(1.1772, 4.2967),
(1.1373, 3.5350),
(1.1021, 2.9109),
(1.0755, 2.4186),
(1.0508, 2.0189),
(1.0307, 1.7034),
(1.0140, 1.4583),
(1.0015, 1.2758),
(1.0, 1.1403),
(1.0, 1.0510),
(1.0, 1.0002),
(1.0, 1.0),
];
/// The tonal mask for `tone` (0..=100) at the 256 neighbourhood levels: 1 − I_L(α, β).
fn tonal_mask(tone: f32) -> [f32; 256] {
let t = (tone.clamp(0.0, 95.0)) / 5.0;
let i = (t.floor() as usize).min(MASK_BETA.len() - 2);
let f = t - i as f32;
let (a0, b0) = MASK_BETA[i];
let (a1, b1) = MASK_BETA[i + 1];
let (a, b) = ((a0 + (a1 - a0) * f) as f64, (b0 + (b1 - b0) * f) as f64);
// Midpoint sums of the Beta density, 16 per level.
const SUB: usize = 16;
let n = 255 * SUB;
let dens = |x: f64| ((a - 1.0) * x.ln() + (b - 1.0) * (1.0 - x).ln()).exp();
let mut cum = vec![0.0f64; 256];
let mut acc = 0.0;
for j in 0..n {
acc += dens((j as f64 + 0.5) / n as f64);
if (j + 1) % SUB == 0 {
cum[(j + 1) / SUB] = acc;
}
}
let total = acc.max(f64::MIN_POSITIVE);
std::array::from_fn(|k| (1.0 - cum[k] / total) as f32)
}
/// Curve strength for Amount `a` (0..=100): 0 at 0, 1 near 15, 2 at 50, 3 at 100.
fn strength(a: f32) -> f32 {
let u = (a.clamp(0.0, 100.0) / 100.0).powf(0.6216);
if u <= 0.3 { u / 0.3 } else { (1.0 + 20.0 * u) / 7.0 }
}
/// The full-strength curve of strength `s`: 1 − (1 − v)^q between q = 2^⌊s⌋ and 2^(⌊s⌋+1).
fn lift(s: f32, v: f32) -> f32 {
let n = (s.floor() as i32).clamp(0, 2);
let t = (s - n as f32).clamp(0.0, 1.0);
let c = (1.0 - v).clamp(0.0, 1.0);
let lo = c.powi(1 << n);
let hi = lo * lo;
1.0 - ((1.0 - t) * lo + t * hi)
}
/// Linear interpolation in a table of `(x, y)` sorted by `x`.
fn interp(table: &[(f32, f32)], x: f32) -> f32 {
let Some(&(x0, y0)) = table.first() else { return 0.0 };
if x <= x0 {
return y0;
}
for p in table.windows(2) {
let ((xa, ya), (xb, yb)) = (p[0], p[1]);
if x <= xb {
return ya + (yb - ya) * (x - xa) / (xb - xa);
}
}
table.last().map_or(0.0, |p| p.1)
}
/// Color: the h of the colour-difference gain for positive values (exp(h · F))…
const COLOR_UP: [(f32, f32); 12] = [
(0.0, 0.0),
(1.0, 0.1566),
(2.0, 0.2271),
(5.0, 0.3776),
(10.0, 0.5653),
(20.0, 0.8669),
(30.0, 1.1299),
(40.0, 1.3748),
(50.0, 1.6093),
(60.0, 1.8375),
(75.0, 2.1721),
(100.0, 2.7193),
];
/// … and for negative values (1 + h · F), by −Color.
const COLOR_DOWN: [(f32, f32); 9] =
[(0.0, 0.0), (1.0, -0.0145), (5.0, -0.0709), (10.0, -0.1373), (20.0, -0.2570), (30.0, -0.3612), (50.0, -0.5312), (75.0, -0.6866), (100.0, -0.7968)];
/// Midtone: (value, k, p) of k · (Y − ½) · (4Y(1 − Y))^p.
const MIDTONE: [(f32, f32, f32); 11] = [
(-100.0, -0.6955, 2.267),
(-75.0, -0.5873, 2.362),
(-50.0, -0.4429, 2.492),
(-25.0, -0.2514, 2.667),
(-10.0, -0.1088, 2.803),
(0.0, 0.0, 2.9),
(10.0, 0.1206, 3.016),
(25.0, 0.3262, 3.216),
(50.0, 0.7423, 3.625),
(75.0, 1.2635, 4.160),
(100.0, 1.9032, 4.850),
];
fn midtone(m: f32, y: f32) -> f32 {
if m == 0.0 {
return y;
}
let ks: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.1)).collect();
let ps: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.2)).collect();
let (k, p) = (interp(&ks, m), interp(&ps, m));
(y + k * (y - 0.5) * (4.0 * y * (1.0 - y)).max(0.0).powf(p)).clamp(0.0, 1.0)
}
fn level(v: f32) -> usize {
(v.clamp(0.0, 1.0) * 255.0).round() as usize
}
/// Image › Adjustments › Shadows/Highlights on straight RGBA `w × h`.
pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) {
if px.is_empty() || px.len() != w * h {
return;
}
let (sa, ha) = (p.shadow_amount, p.highlight_amount);
let lum: Vec<f32> = px.iter().map(|q| (q[0].max(q[1]).max(q[2]) + luma([q[0], q[1], q[2]])) / 2.0).collect();
let ls = (sa > 0.0).then(|| neighbourhood(&lum, w, h, p.shadow_radius));
let lh = (ha > 0.0).then(|| match &ls {
Some(l) if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 => l.clone(),
_ => neighbourhood(&lum, w, h, p.highlight_radius),
});
let (ms, mh) = (tonal_mask(p.shadow_tone), tonal_mask(p.highlight_tone));
let (ss, hs) = (strength(sa), strength(ha));
// The new luma of every pixel.
let mut y2 = vec![0.0f32; px.len()];
par_rows(&mut y2, w, 1, |y, row| {
for (x, out) in row.iter_mut().enumerate() {
let i = y * w + x;
let q = px[i];
let mut adj = [q[0], q[1], q[2]];
if let Some(l) = &ls {
let m = ms[level(l[i])];
for v in &mut adj {
*v += m * (lift(ss, *v) - *v);
}
}
if let Some(l) = &lh {
let m = mh[255 - level(l[i])];
for (v, &o) in adj.iter_mut().zip(&q[..3]) {
*v -= m * (lift(hs, 1.0 - o) - (1.0 - o));
}
}
*out = midtone(p.midtone, luma(adj).clamp(0.0, 1.0));
}
});
clip_levels(&mut y2, px, p.black_clip, p.white_clip);
let gain = |f: f32| {
if p.color >= 0.0 { (interp(&COLOR_UP, p.color) * f).exp() } else { 1.0 + interp(&COLOR_DOWN, -p.color) * f }
};
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
if q[3] <= 0.0 {
continue;
}
let y1 = luma([q[0], q[1], q[2]]);
let yn = y2[y * w + x];
let f = (yn - y1).abs() / yn.max(y1).max(1e-6);
let g = gain(f);
let d = [(q[0] - y1) * g, (q[1] - y1) * g, (q[2] - y1) * g];
// Shorten the colour difference where it would leave 0..=1.
let mut k = 1.0f32;
for v in d {
if v > 1e-9 {
k = k.min((1.0 - yn) / v);
} else if v < -1e-9 {
k = k.min(yn / -v);
}
}
let k = k.clamp(0.0, 1.0);
for c in 0..3 {
q[c] = (yn + k * d[c]).clamp(0.0, 1.0);
}
}
});
}
/// Black and White Clip: stretches the new luma so `black`% of pixels reach 0 and `white`% reach 1.
fn clip_levels(y2: &mut [f32], px: &[[f32; 4]], black: f32, white: f32) {
if black <= 0.0 && white <= 0.0 {
return;
}
let mut hist = vec![0u32; 4096];
let mut n = 0u32;
for (&y, q) in y2.iter().zip(px) {
if q[3] > 0.0 {
hist[(y.clamp(0.0, 1.0) * 4095.0).round() as usize] += 1;
n += 1;
}
}
if n == 0 {
return;
}
let find = |pct: f32, rev: bool| -> f32 {
let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32;
let mut acc = 0;
for k in 0..4096 {
let i = if rev { 4095 - k } else { k };
acc += hist[i];
if acc > target {
return i as f32 / 4095.0;
}
}
if rev { 0.0 } else { 1.0 }
};
let (lo, hi) = (if black > 0.0 { find(black, false) } else { 0.0 }, if white > 0.0 { find(white, true) } else { 1.0 });
if hi - lo < 1e-3 {
return;
}
for y in y2.iter_mut() {
*y = ((*y - lo) / (hi - lo)).clamp(0.0, 1.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A flat `w × h` grey or colour image with one different pixel in the middle; returns that
/// pixel after Shadows/Highlights.
fn probe(bg: [f32; 3], px: [f32; 3], p: &ShadowsHighlights) -> [f32; 3] {
let (w, h) = (121, 121);
let mut img = vec![[bg[0], bg[1], bg[2], 1.0]; w * h];
img[60 * w + 60] = [px[0], px[1], px[2], 1.0];
shadows_highlights(&mut img, w, h, p);
let q = img[60 * w + 60];
[q[0], q[1], q[2]]
}
fn levels(v: [i32; 3]) -> [f32; 3] {
v.map(|c| c as f32 / 32768.0)
}
fn off(a: [f32; 3], b: [f32; 3]) -> f32 {
a.iter().zip(b).map(|(x, y)| (x - y).abs() * 255.0).fold(0.0, f32::max)
}
/// Photoshop 25.4's results (16-bit levels of 32768) for grey pixels lifted by the default
/// Shadows (35 %, Tone 50 %, Radius 30 px; Color 0), in black surroundings and in grey 0.5.
#[test]
fn grey_matches_photoshop() {
let p = ShadowsHighlights { color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
for (bg, want) in [(0.0, [11389, 19422, 28451, 31931]), (0.5, [5211, 9909, 18229, 25700])] {
for (v, w) in [0.125f32, 0.25, 0.5, 0.749969].into_iter().zip(want) {
let got = probe([bg; 3], [v; 3], &p);
assert!(off(got, levels([w; 3])) < 2.0, "bg {bg} v {v}: {} vs {}", got[0] * 32768.0, w);
}
}
}
/// Colour pixels keep their colour differences, scaled by Color (+20 here; Amount 35 %,
/// Radius 10 px), in black, 0.15 and 0.3 grey surroundings: Photoshop's results.
#[test]
fn colour_matches_photoshop() {
let p = ShadowsHighlights { shadow_radius: 10.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
for (bg, px, want) in [
(0.0, [3277, 1638, 0], [8235, 5202, 2170]),
(0.0, [8192, 6554, 4915], [19350, 16514, 13676]),
(4915.0 / 32768.0, [4915, 3277, 1638], [10124, 7424, 4723]),
(9830.0 / 32768.0, [11469, 9830, 8192], [16540, 14376, 12214]),
] {
let got = probe([bg; 3], levels(px), &p);
assert!(off(got, levels(want)) < 2.0, "{px:?} on {bg}: {:?} vs {want:?}", got.map(|c| (c * 32768.0).round()));
}
}
/// Across a strong edge the neighbourhood barely leaks (Amount 100 %, Tone 100 %, Radius
/// 10 px; 0.1 | 0.9): Photoshop's results 8, 2 and 1 pixels either side.
#[test]
fn edge_matches_photoshop() {
let (w, h) = (200, 24);
let (a, b) = (3277.0 / 32768.0, 29491.0 / 32768.0);
let mut img: Vec<[f32; 4]> = (0..w * h).map(|i| if i % w < 100 { [a, a, a, 1.0] } else { [b, b, b, 1.0] }).collect();
let p = ShadowsHighlights {
shadow_amount: 100.0,
shadow_tone: 100.0,
shadow_radius: 10.0,
color: 0.0,
black_clip: 0.0,
white_clip: 0.0,
..Default::default()
};
shadows_highlights(&mut img, w, h, &p);
let row = &img[12 * w..13 * w];
for (x, want) in [(92, 16370), (98, 14439), (99, 12146), (100, 30891), (101, 30403), (102, 30146), (108, 29979)] {
let got = row[x][0] * 32768.0;
assert!((got - want as f32).abs() / 32768.0 * 255.0 < 6.0, "x {x}: {got} vs {want}");
}
}
#[test]
fn strength_and_curve_follow_photoshop() {
assert!((strength(50.0) - 2.0).abs() < 2e-3 && (strength(100.0) - 3.0).abs() < 1e-4);
assert!((strength(15.0) - 1.02).abs() < 0.01 && (strength(1.0) - 0.19).abs() < 0.01);
// Amount 50 is the image screened with itself twice: 1 − (1 − v)^4.
for v in [0.1f32, 0.3, 0.6] {
assert!((lift(2.0, v) - (1.0 - (1.0 - v).powi(4))).abs() < 1e-6);
}
}
#[test]
fn tonal_mask_spans_photoshop_tones() {
let m95 = tonal_mask(95.0);
assert!((m95[128] - (1.0 - 128.0 / 255.0)).abs() < 1e-3, "{}", m95[128]);
assert_eq!(tonal_mask(100.0), m95);
// Tone 0 still reaches about 7 % into the levels (Photoshop: half at level 18).
let m0 = tonal_mask(0.0);
assert!(m0[0] > 0.999 && (m0[18] - 0.5).abs() < 0.03 && m0[60] < 0.02, "{} {}", m0[18], m0[60]);
}
}
+34 -268
View File
@@ -3,13 +3,13 @@
//! A transform is described by where the four corners of a source rectangle land (a quad). Scale,
//! rotate, skew and flips are affine special cases; Distort and Perspective use the full
//! homography. Warping is an inverse mapping with premultiplied-alpha sampling (nearest, bilinear
//! or Photoshop's Keys bicubic), processed per destination tile (in parallel on native) so memory stays
//! or Catmull-Rom bicubic), processed per destination tile (in parallel on native) so memory stays
//! bounded by the tile working set. Large reductions are pre-filtered with a proper resize first,
//! so shrinking a layer doesn't alias.
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface};
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
use crate::resample::{Resample, resize_surface};
@@ -43,27 +43,6 @@ impl Homography {
((m[0] * x + m[1] * y + m[2]) / w, (m[3] * x + m[4] * y + m[5]) / w)
}
/// The homogeneous `w` of `(x, y)`: its sign is the side of the horizon the point maps to
/// (0 on the horizon itself).
pub fn w(&self, x: f64, y: f64) -> f64 {
let m = &self.0;
m[6] * x + m[7] * y + m[8]
}
/// The side of the horizon (+1 or −1) that most corners of `r` (`[x0, y0, x1, y1]`) map to,
/// the centre's on a tie. A Distort dragged past the horizon maps the rest of the source to the
/// far side, where it would show as a mirrored copy wrapped round infinity; drawing keeps to
/// this side, as Photoshop's does (#4863).
pub fn front_side(&self, r: [f64; 4]) -> f64 {
let ahead = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].iter().filter(|(x, y)| self.w(*x, *y) > 0.0).count();
match ahead {
3 | 4 => 1.0,
0 | 1 => -1.0,
_ if self.w((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0) >= 0.0 => 1.0,
_ => -1.0,
}
}
pub fn mul(&self, o: &Homography) -> Homography {
let (a, b) = (&self.0, &o.0);
let mut r = [0.0; 9];
@@ -154,40 +133,24 @@ impl Interp {
}
}
/// Photoshop's Bicubic: the Keys cubic at a = −0.75 (Catmull-Rom is −0.5), the weights of the
/// four source pixels around a sample `t` (0..1) past the second. Measured on Photoshop 27.11's
/// Free Transform: a 200 % step edge matches within 0.5 levels; Catmull-Rom was 6 off.
pub(crate) fn bicubic(t: f64) -> [f64; 4] {
const A: f64 = -0.75;
let near = |x: f64| (A + 2.0) * x * x * x - (A + 3.0) * x * x + 1.0;
let far = |x: f64| A * (x * x * x - 5.0 * x * x + 8.0 * x - 4.0);
[far(1.0 + t), near(t), near(1.0 - t), far(2.0 - t)]
fn catmull_rom(t: f64) -> [f64; 4] {
let t2 = t * t;
let t3 = t2 * t;
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
}
/// Warp `src` (its content inside `src_rect`) by `h` (source → destination document space).
/// The result has the same format as `src`, with alpha added if `src` had none; pixels outside
/// the warped quad are transparent.
pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result<Surface, photocraft_raster::AllocationError> {
warp(src, src_rect, h, interp, None, &Interrupt::NONE)
}
/// [`warp_surface`] reporting progress per destination tile; `Ok(None)` once `ctl` is cancelled.
pub fn warp_surface_with(
src: &Surface,
src_rect: Rect,
h: &Homography,
interp: Interp,
ctl: &Interrupt,
) -> Result<Option<Surface>, photocraft_raster::AllocationError> {
let out = warp(src, src_rect, h, interp, None, ctl)?;
Ok((!ctl.cancelled()).then_some(out))
warp(src, src_rect, h, interp, None)
}
/// [`warp_surface`] keeping only the destination pixels inside `clip`. The work is bounded by
/// `clip` even when part of the source lies at or beyond the map's horizon (a Perspective Crop of a
/// layer that reaches far past the crop quad), which would make the full warped extent unbounded.
pub fn warp_surface_clipped(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Rect) -> Result<Surface, photocraft_raster::AllocationError> {
warp(src, src_rect, h, interp, Some(clip), &Interrupt::NONE)
warp(src, src_rect, h, interp, Some(clip))
}
/// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every
@@ -229,39 +192,7 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse()
}
/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners
/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to
/// infinity (the result is then empty). Lets a caller price a warp before allocating it.
pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option<Rect> {
if src_rect.is_empty() || h.inverse().is_none() {
return None;
}
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64));
corner_bounds(&corners)
}
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) {
return None;
}
let lim = (1 << 20) as f64;
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
Some(Rect::new(bx0, by0, bx1, by1))
}
/// A cancelled `ctl` skips the remaining tiles; the caller discards the result.
fn warp(
src: &Surface,
src_rect: Rect,
h: &Homography,
interp: Interp,
clip: Option<Rect>,
ctl: &Interrupt,
) -> Result<Surface, photocraft_raster::AllocationError> {
fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option<Rect>) -> Result<Surface, photocraft_raster::AllocationError> {
let mut fmt = src.format();
let converted;
let mut src = if fmt.alpha {
@@ -281,16 +212,12 @@ fn warp(
// that matters is the one at the source point landing in the clip's centre.
let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) {
Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0),
None => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0),
None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0),
};
let scale = h.local_scale(cx, cy);
let reduced;
// Photoshop reduces with its Bicubic widened by 1 / scale (as Image Size does), so the source is
// brought down by the whole local scale first and the warp samples it at about 1:1; a plain
// 4×4 kernel at 50 % aliased (Photoshop 27.11 Free Transform, 50 % of an impulse image).
// Bilinear samples the source directly, as Photoshop's does (exact at 50 % without a prefilter).
if interp == Interp::Bicubic && scale < 0.999 && scale > 0.0 {
let f = scale.max(1.0 / 64.0);
if interp != Interp::Nearest && scale < 0.5 && scale > 0.0 {
let f = 2f64.powf(scale.log2().ceil()).min(1.0); // remaining scale lands in [0.5, 1)
reduced = resize_surface(src, f, f, Resample::Bicubic);
src = &reduced;
h = h.mul(&Homography([1.0 / f, 0.0, 0.0, 0.0, 1.0 / f, 0.0, 0.0, 0.0, 1.0]));
@@ -307,17 +234,21 @@ fn warp(
let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]);
let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0));
let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs()));
// A source straddling the horizon draws only its front side: the inverse w at a pixel has the
// sign of the forward w at the source point it samples, and the other side would show as a
// mirrored copy wrapped round infinity (#4863).
let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]));
let bounds = corner_bounds(&corners);
let dst = match (clip, bounds) {
let finite = corners.iter().all(|c| c.0.is_finite() && c.1.is_finite());
let lim = 1 << 20;
let bounds = || {
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
Rect::new(bx0, by0, bx1, by1)
};
let dst = match clip {
// The warped corners only bound the result while the whole source is in front of the horizon.
(Some(c), Some(b)) if side.is_some() => b.intersect(&c),
(Some(c), _) => c,
(None, Some(b)) => b,
(None, None) => return Ok(out),
Some(c) if finite && side.is_some() => bounds().intersect(&c),
Some(c) => c,
None if finite => bounds(),
None => return Ok(out),
};
if dst.is_empty() {
return Ok(out);
@@ -330,16 +261,7 @@ fn warp(
tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?;
tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()));
let src_ref: &Surface = src;
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let work = |t: &Rect| -> Result<Option<(Rect, Vec<f32>)>, photocraft_raster::AllocationError> {
if ctl.cancelled() {
return Ok(None);
}
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are the writes.
ctl.progress(0.98 * k as f32 / total as f32);
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
let wc = tc.map(|(x, y)| inv.0[6] * x + inv.0[7] * y + inv.0[8]);
@@ -366,10 +288,10 @@ fn warp(
return Ok(None);
}
let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect();
let fx0 = (fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
let fy0 = (fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
let fx1 = (fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
let fy1 = (fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3;
let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3;
let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3;
let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3;
// Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint.
let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0);
let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) };
@@ -392,15 +314,6 @@ fn warp(
px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c] as f64
}
};
// Samples past the source rect repeat its edge, so a stretched edge row or column keeps its
// colour and opacity (the "pixel stretch", #3773); the outline is antialiased by coverage
// instead. `foot_u`/`foot_v` are a destination pixel's extent in source pixels.
let clamped = |x: i32, y: i32, c: usize| at(x.clamp(src_rect.x0, src_rect.x1 - 1), y.clamp(src_rect.y0, src_rect.y1 - 1), c);
let (mx, my) = ((t.x0 + t.x1) as f64 / 2.0, (t.y0 + t.y1) as f64 / 2.0);
let (o, dx, dy) = (inv.apply(mx, my), inv.apply(mx + 1.0, my), inv.apply(mx, my + 1.0));
let extent = |e: f64| if e.is_finite() && e > 1e-12 { e } else { 1.0 };
let foot_u = extent((dx.0 - o.0).abs() + (dy.0 - o.0).abs());
let foot_v = extent((dx.1 - o.1).abs() + (dy.1 - o.1).abs());
let w = t.width() as usize;
let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let bytes = len.checked_mul(std::mem::size_of::<f32>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
@@ -411,9 +324,6 @@ fn warp(
let mut acc = [0.0f64; 8];
for y in t.y0..t.y1 {
for x in t.x0..t.x1 {
if front.is_some_and(|s| s * inv.w(x as f64 + 0.5, y as f64 + 0.5) <= 0.0) {
continue;
}
let (u, v) = inv.apply(x as f64 + 0.5, y as f64 + 0.5);
let (u, v) = (u - 0.5, v - 0.5);
if u < src_rect.x0 as f64 - 1.0 || v < src_rect.y0 as f64 - 1.0 || u > src_rect.x1 as f64 || v > src_rect.y1 as f64 {
@@ -433,33 +343,24 @@ fn warp(
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
for (c, s) in acc.iter_mut().enumerate().take(n) {
*s += clamped(ix + dx, iy + dy, c) * wx * wy;
*s += at(ix + dx, iy + dy, c) * wx * wy;
}
}
}
}
Interp::Bicubic => {
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (wx, wy) = (bicubic(u - ix as f64), bicubic(v - iy as f64));
let (wx, wy) = (catmull_rom(u - ix as f64), catmull_rom(v - iy as f64));
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
for (c, s) in acc.iter_mut().enumerate().take(n) {
*s += clamped(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
*s += at(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
}
}
}
}
}
if interp != Interp::Nearest {
// How much of this pixel the warped source rect covers, per axis.
let (uc, vc) = (u + 0.5, v + 0.5);
let cu = ((uc - src_rect.x0 as f64).min(src_rect.x1 as f64 - uc) / foot_u + 0.5).clamp(0.0, 1.0);
let cv = ((vc - src_rect.y0 as f64).min(src_rect.y1 as f64 - vc) / foot_v + 0.5).clamp(0.0, 1.0);
for s in acc.iter_mut().take(n) {
*s *= cu * cv;
}
}
let al = acc[a].clamp(0.0, 1.0);
if al <= 0.0 {
continue;
@@ -467,8 +368,7 @@ fn warp(
any = true;
let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n;
for c in 0..a {
// Integer storage clips on write; float colours retain their signed/HDR range.
outp[o + c] = (acc[c] / al) as f32;
outp[o + c] = (acc[c] / al).clamp(0.0, 1.0) as f32;
}
outp[o + a] = al as f32;
}
@@ -496,9 +396,6 @@ fn warp(
}
}
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
if ctl.cancelled() {
return Ok(out);
}
for (r, v) in done {
out.try_write_region(r, &v)?;
}
@@ -510,25 +407,6 @@ fn warp(
mod tests {
use super::*;
#[test]
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]);
let r = s.content_bounds();
let h = Homography::rect_to_quad([0.0, 0.0, 600.0, 400.0], [[10.0, 0.0], [590.0, 20.0], [600.0, 400.0], [0.0, 380.0]]).unwrap();
let reported = std::sync::Mutex::new(0.0f32);
let progress = |p: f32| {
let mut m = reported.lock().unwrap();
*m = m.max(p);
};
let never = || false;
let out = warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::new(&never, &progress)).unwrap().expect("not cancelled");
assert_eq!(out.read_region(r), warp_surface(&s, r, &h, Interp::Bicubic).unwrap().read_region(r));
assert!(*reported.lock().unwrap() > 0.9);
let always = || true;
assert!(warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::cancel_only(&always)).unwrap().is_none());
}
/// Perspective Crop's map lands each quad corner exactly on its rectangle corner; degenerate,
/// self-crossing, counter-clockwise and non-finite quads have none.
#[test]
@@ -557,42 +435,6 @@ mod tests {
Surface::new(PixelFormat::RGBA8)
}
/// The "pixel stretch" (#3773): a 1 px column scaled 100x stays solid across the stretch, as in
/// Photoshop. Samples past the source edge repeat the edge, and only the outline is antialiased.
#[test]
fn stretched_single_column_stays_opaque() {
let mut s = rgba();
s.fill_rect(Rect::new(0, 0, 1, 4), &[0.0, 1.0, 0.0, 1.0]);
for interp in [Interp::Bicubic, Interp::Bilinear] {
let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 4.0], [[0.0, 0.0], [100.0, 0.0], [100.0, 4.0], [0.0, 4.0]]).unwrap();
let w = warp_surface(&s, Rect::new(0, 0, 1, 4), &h, interp).unwrap();
assert_eq!(w.content_bounds(), Rect::new(0, 0, 100, 4), "{interp:?}");
for x in [0, 1, 25, 50, 75, 99] {
for y in 0..4 {
assert!((w.sample_channel(x, y, 3) - 1.0).abs() < 1e-6, "{interp:?} alpha at ({x}, {y}) = {}", w.sample_channel(x, y, 3));
}
}
}
}
/// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left
/// half is grey 64 and right half grey 192: the row across the edge, columns 26..36.
#[test]
fn bicubic_upscale_matches_photoshop() {
let mut src = rgba();
let g = |v: f32| v / 255.0;
src.fill_rect(Rect::new(0, 0, 32, 64), &[g(64.0), g(64.0), g(64.0), 1.0]);
src.fill_rect(Rect::new(32, 0, 64, 64), &[g(192.0), g(192.0), g(192.0), 1.0]);
let q = [[-32.0, -32.0], [96.0, -32.0], [96.0, 96.0], [-32.0, 96.0]];
let h = Homography::rect_to_quad([0.0, 0.0, 64.0, 64.0], q).unwrap();
let out = warp_surface(&src, Rect::new(0, 0, 64, 64), &h, Interp::Bicubic).unwrap();
let photoshop = [64.0, 64.0, 64.0, 60.0, 51.0, 93.0, 163.0, 206.0, 197.0, 192.0];
for (i, want) in photoshop.iter().enumerate() {
let got = out.pixel(26 + i as i32, 50)[0] * 255.0;
assert!((got - want).abs() <= 1.5, "column {}: {got} vs {want}", 26 + i);
}
}
#[test]
fn homography_maps_rect_corners_and_inverts() {
let r = [10.0, 20.0, 110.0, 70.0];
@@ -627,44 +469,6 @@ mod tests {
}
}
/// #4863: a Distort with the bottom corners pulled in and one dragged past the horizon (a
/// concave quad) leaves part of the source behind the horizon. That part maps to the far side,
/// where it showed as a mirrored copy wrapped round infinity; only the front side is drawn.
#[test]
fn a_distort_past_the_horizon_draws_only_its_front_side() {
let mut s = rgba();
s.fill_rect(Rect::new(0, 0, 100, 100), &[0.0, 0.5, 1.0, 1.0]);
let r = s.content_bounds();
let h = Homography::rect_to_quad([0.0, 0.0, 100.0, 100.0], [[0.0, 0.0], [300.0, 0.0], [60.0, 60.0], [0.0, 300.0]]).unwrap();
let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| h.w(x, y));
assert!(corners.iter().any(|w| *w > 0.0) && corners.iter().any(|w| *w < 0.0), "the source straddles the horizon: {corners:?}");
let front = h.front_side([0.0, 0.0, 100.0, 100.0]);
let inv = h.inverse().unwrap();
let clip = Rect::new(-400, -400, 700, 700);
for interp in [Interp::Nearest, Interp::Bilinear] {
let w = warp_surface_clipped(&s, r, &h, interp, clip).unwrap();
let px = w.read_region(clip);
let (mut drawn, mut behind) = (0, 0);
for y in clip.y0..clip.y1 {
for x in clip.x0..clip.x1 {
let i = ((y - clip.y0) * clip.width() as i32 + (x - clip.x0)) as usize * 4;
if px[i + 3] > 0.0 {
drawn += 1;
// The source point this pixel samples is on the far side of the horizon.
if front * inv.w(x as f64 + 0.5, y as f64 + 0.5) <= 0.0 {
behind += 1;
}
}
}
}
assert!(drawn > 1000, "{interp:?}: the front side is drawn ({drawn} px)");
assert_eq!(behind, 0, "{interp:?}: {behind} pixels come from behind the horizon");
}
// A quad wholly in front of the horizon is drawn exactly as before.
let ok = Homography::rect_to_quad([0.0, 0.0, 100.0, 100.0], [[0.0, 0.0], [300.0, 0.0], [155.0, 155.0], [0.0, 300.0]]).unwrap();
assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum());
}
#[test]
fn identity_and_translation_are_exact() {
let mut s = rgba();
@@ -678,44 +482,6 @@ mod tests {
}
}
#[test]
fn translation_preserves_colour_range_at_every_depth() {
use photocraft_color::{ColorMode, SampleType};
let h = Homography([1.0, 0.0, 7.0, 0.0, 1.0, -3.0, 0.0, 0.0, 1.0]);
for sample in SampleType::ALL {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [false, true] {
let mut s = Surface::new(PixelFormat::new(mode, sample, alpha));
let mut pixel = if mode == ColorMode::Rgb { vec![2.0, -0.25, 0.5] } else { vec![2.0] };
if alpha {
pixel.push(0.5);
}
s.fill_rect(Rect::new(4, 4, 8, 8), &pixel);
let mut expected = s.pixel(5, 5);
if !alpha {
expected.push(1.0);
}
for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap();
assert_eq!(out.pixel(12, 2), expected, "{sample:?}/{mode:?}/alpha={alpha}/{interp:?}");
assert_eq!(out.pixel(0, 0).last(), Some(&0.0));
}
}
}
}
}
#[test]
fn bilinear_translation_interpolates_signed_hdr_colours() {
let mut s = Surface::new(PixelFormat::RGBA32F);
s.write_pixel(0, 0, &[2.0, -0.5, 0.25, 0.5]);
s.write_pixel(1, 0, &[4.0, -0.25, 0.75, 0.5]);
let h = Homography([1.0, 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let out = warp_surface(&s, s.content_bounds(), &h, Interp::Bilinear).unwrap();
assert_eq!(out.pixel(1, 0), vec![3.0, -0.375, 0.5, 0.5]);
}
#[test]
fn scale_doubles_size_and_rotation_keeps_area() {
let mut s = rgba();
+26 -163
View File
@@ -4,28 +4,27 @@
//! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each
//! cell's two triangles are rasterized in destination space with the source position
//! interpolated barycentrically, then the source is sampled there (premultiplied, nearest /
//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few pixels across in
//! both the source and the destination (a thin layer stretched far gets as many cells as its
//! warped size needs), so the piecewise-affine inverse is within a small fraction of a pixel of
//! the true one; affine
//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each
//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per
//! source pixel), so thin lines don't alias. Where the warp folds over itself the
//! bilinear / Catmull-Rom bicubic, like Free Transform). Cells are a few source pixels across, so
//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine
//! maps (the identity included) are reproduced exactly. Where the warp folds over itself the
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use crate::transform::{Interp, bicubic};
use crate::transform::Interp;
fn catmull_rom(t: f64) -> [f64; 4] {
let t2 = t * t;
let t3 = t2 * t;
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
}
/// Grid cell size (source px) used to approximate the map.
const CELL: f64 = 4.0;
/// Most cells along one axis (bounds memory and time on huge layers).
const MAX_CELLS: usize = 512;
/// Most subsamples per axis when a warp shrinks the source; also the longest source step per
/// destination pixel the filter follows (bounds the footprint each tile reads).
const MAX_SS: f64 = 8.0;
/// Places a smart object's source image (`src`, its pixels in `src_rect`) in the document:
/// through `warp` (source space) and then the affine `t` (source → document) in one resampling
@@ -79,11 +78,8 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true));
}
let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height()));
// Cells are a few pixels across on both sides of the map: a thin source stretched far (a
// 3 px column scaled into a wide band, then bent) needs many cells, or it comes out straight.
let (lu, lv) = mapped_lengths(src_rect, f);
let cells = |s: f64, d: f64| ((s.max(d) / CELL).ceil() as usize).clamp(1, MAX_CELLS);
let (nx, ny) = (cells(sw, lu), cells(sh, lv));
let nx = ((sw / CELL).ceil() as usize).clamp(1, MAX_CELLS);
let ny = ((sh / CELL).ceil() as usize).clamp(1, MAX_CELLS);
// Forward-mapped vertices (and their source positions).
let mut verts = Vec::with_capacity((nx + 1) * (ny + 1));
for j in 0..=ny {
@@ -106,43 +102,6 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
warp_triangles(src, src_rect, &verts, &tris, interp)
}
/// Probe lines per axis [`mapped_lengths`] follows through the map.
const PROBE: usize = 16;
/// The longest destination length of a source row and of a source column under `f`, measured
/// along polylines through a [`PROBE`]-step grid. Non-finite steps count as zero.
fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -> (f64, f64) {
let (x0, y0) = (f64::from(src_rect.x0), f64::from(src_rect.y0));
let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height()));
let n = PROBE + 1;
let mut pts = Vec::with_capacity(n * n);
for j in 0..n {
for i in 0..n {
let (dx, dy) = f(x0 + sw * i as f64 / PROBE as f64, y0 + sh * j as f64 / PROBE as f64);
pts.push([dx, dy]);
}
}
let step = |a: [f64; 2], b: [f64; 2]| {
let d = (b[0] - a[0]).hypot(b[1] - a[1]);
if d.is_finite() { d } else { 0.0 }
};
let (mut lu, mut lv) = (0.0f64, 0.0f64);
for k in 0..n {
let (mut row, mut col) = (0.0, 0.0);
for m in 1..n {
if let (Some(&a), Some(&b)) = (pts.get(k * n + m - 1), pts.get(k * n + m)) {
row += step(a, b);
}
if let (Some(&a), Some(&b)) = (pts.get((m - 1) * n + k), pts.get(m * n + k)) {
col += step(a, b);
}
}
lu = lu.max(row);
lv = lv.max(col);
}
(lu, lv)
}
/// Rasterizes textured triangles: each vertex is `(destination, source)` in document pixels and
/// each triangle samples `src` (restricted to `src_rect`) through the barycentric
/// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the
@@ -213,37 +172,14 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let w = t.width() as usize;
let h = t.height() as usize;
// Subsamples along one destination axis, from the source step `j` it makes: one where
// the warp keeps or enlarges the source, else about one per source pixel the output pixel
// spans (an area average, as the transform path gets from its pre-reduction; a single
// bicubic sample aliases thin lines when shrinking). The subsamples are bilinear: the
// box does the filtering, and it is ~4x cheaper than bicubic at no loss.
let count = |j: [f64; 2]| if interp == Interp::Nearest { 1 } else { j[0].hypot(j[1]).round().clamp(1.0, MAX_SS) as usize };
// Source position per destination pixel (NaN = not covered) and the triangle it came
// from. Per triangle: its source step per destination pixel along x and y (`[dx_u, dx_v,
// dy_u, dy_v]`, the inverse map's Jacobian, constant over a triangle; zero when
// point-sampled) and the subsamples along each.
// Source position per destination pixel (NaN = not covered).
let mut uv = vec![[f64::NAN; 2]; w * h];
let mut owner = vec![0u32; w * h];
let mut jac: Vec<([f64; 4], usize, usize)> = Vec::with_capacity(tris.len());
for (k, tri) in tris.iter().enumerate() {
for tri in tris {
let [(p0, s0), (p1, s1), (p2, s2)] = tri.map(|v| verts[v]);
let det = (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]);
if det.abs() < 1e-12 {
jac.push(([0.0; 4], 1, 1));
continue;
}
let (d1, d2) = ([s1[0] - s0[0], s1[1] - s0[1]], [s2[0] - s0[0], s2[1] - s0[1]]);
let (l1x, l1y, l2x, l2y) = ((p2[1] - p0[1]) / det, (p0[0] - p2[0]) / det, (p0[1] - p1[1]) / det, (p1[0] - p0[0]) / det);
// Capped, so a sliver triangle can't ask for an unbounded footprint.
let cap = |j: [f64; 2]| {
let l = j[0].hypot(j[1]);
if l > MAX_SS { [j[0] * MAX_SS / l, j[1] * MAX_SS / l] } else { j }
};
let jx = cap([l1x * d1[0] + l2x * d2[0], l1x * d1[1] + l2x * d2[1]]);
let jy = cap([l1y * d1[0] + l2y * d2[0], l1y * d1[1] + l2y * d2[1]]);
let (nx, ny) = (count(jx), count(jy));
jac.push(if nx * ny == 1 { ([0.0; 4], 1, 1) } else { ([jx[0], jx[1], jy[0], jy[1]], nx, ny) });
let bx0 = (p0[0].min(p1[0]).min(p2[0]) - 0.5).floor().max(f64::from(t.x0)) as i32;
let by0 = (p0[1].min(p1[1]).min(p2[1]) - 0.5).floor().max(f64::from(t.y0)) as i32;
let bx1 = (p0[0].max(p1[0]).max(p2[0]) + 0.5).ceil().min(f64::from(t.x1)) as i32;
@@ -261,18 +197,16 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let o = (y - t.y0) as usize * w + (x - t.x0) as usize;
uv[o] = [l0 * s0[0] + l1 * s1[0] + l2 * s2[0], l0 * s0[1] + l1 * s1[1] + l2 * s2[1]];
owner[o] = k as u32;
}
}
}
// Source footprint of the covered pixels, including the spread of their subsamples.
// Source footprint of the covered pixels.
let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for (q, j) in uv.iter().zip(&owner).filter(|(q, _)| !q[0].is_nan()).map(|(q, &k)| (q, jac[k as usize].0)) {
let (ex, ey) = (0.5 * (j[0].abs() + j[2].abs()), 0.5 * (j[1].abs() + j[3].abs()));
fx0 = fx0.min(q[0] - ex);
fy0 = fy0.min(q[1] - ey);
fx1 = fx1.max(q[0] + ex);
fy1 = fy1.max(q[1] + ey);
for q in uv.iter().filter(|q| !q[0].is_nan()) {
fx0 = fx0.min(q[0]);
fy0 = fy0.min(q[1]);
fx1 = fx1.max(q[0]);
fy1 = fy1.max(q[1]);
}
if fx0 > fx1 {
return None;
@@ -299,9 +233,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
let mut outp = vec![0.0f32; w * h * n];
let mut any = false;
let mut acc = [0.0f64; 8];
let mut one = [0.0f64; 8];
// One premultiplied sample at source position (u, v) (pixel centres at integers).
let point = |interp: Interp, u: f64, v: f64, acc: &mut [f64; 8]| {
for (o, q) in uv.iter().enumerate() {
if q[0].is_nan() {
continue;
}
let (u, v) = (q[0] - 0.5, q[1] - 0.5);
acc[..n].fill(0.0);
match interp {
Interp::Nearest => {
@@ -326,7 +262,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
let snap = |t: f64| if (t - t.round()).abs() < 1e-7 { t.round() } else { t };
let (u, v) = (snap(u), snap(v));
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy)));
let (wx, wy) = (catmull_rom(u - f64::from(ix)), catmull_rom(v - f64::from(iy)));
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
@@ -340,31 +276,6 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
}
}
};
for (o, (q, &k)) in uv.iter().zip(&owner).enumerate() {
if q[0].is_nan() {
continue;
}
let (j, nx, ny) = jac[k as usize];
if nx * ny == 1 {
point(interp, q[0] - 0.5, q[1] - 0.5, &mut acc);
} else {
acc[..n].fill(0.0);
for sy in 0..ny {
let t = (sy as f64 + 0.5) / ny as f64 - 0.5;
for sx in 0..nx {
let s = (sx as f64 + 0.5) / nx as f64 - 0.5;
point(Interp::Bilinear, q[0] + s * j[0] + t * j[2] - 0.5, q[1] + s * j[1] + t * j[3] - 0.5, &mut one);
for (c, v) in acc.iter_mut().zip(&one).take(n) {
*c += v;
}
}
}
let k = 1.0 / (nx * ny) as f64;
for c in acc.iter_mut().take(n) {
*c *= k;
}
}
let al = acc[a].clamp(0.0, 1.0);
// Float noise (a vertex a hair off the pixel grid) must not leave invisible
// colour behind in transparent pixels.
@@ -441,28 +352,6 @@ mod tests {
surf
}
#[test]
fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() {
// #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in
// source pixels (one cell across), so the arch came out as a straight band.
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
src.fill_rect(Rect::new(0, 0, 3, 40), &[1.0, 0.0, 0.0, 1.0]);
let arch = |x: f64, y: f64| {
let dx = x * 100.0;
let t = (dx - 150.0) / 150.0;
(dx, y + 40.0 * (1.0 - t * t))
};
let out = warp_mesh_surface(&src, Rect::new(0, 0, 3, 40), &arch, Interp::Bilinear);
let alpha = |x: i32, y: i32| {
let mut p = [0.0f32; 4];
out.read_pixel(x, y, &mut p);
p[3]
};
// The band follows the arch: 40 px lower in the middle, about 36 px a third of the way in.
assert!(alpha(150, 60) > 0.99 && alpha(150, 20) < 0.01, "mid {} {}", alpha(150, 60), alpha(150, 20));
assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20));
}
#[test]
fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() {
// #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about
@@ -518,32 +407,6 @@ mod tests {
assert!(c.y0 < r.y0 - 2 && c.x0 < r.x0 && c.x1 > r.x1, "the top rises and spreads past the box: {c:?}");
}
#[test]
fn a_shrinking_mesh_warp_is_prefiltered() {
// A warped smart object placed at ~1/5 size: a grid of 1 px lines every 4 px must come
// out its area average (9/16 white) everywhere, not point-sampled moiré (crinkled,
// doubled or missing lines), like the affine path's `large_downscale_is_prefiltered`.
for s in [SampleType::U8, SampleType::U16, SampleType::F32] {
let (w, h) = (400, 320);
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, s, true));
let px: Vec<f32> =
(0..h).flat_map(|y| (0..w).map(move |x| if x % 4 == 0 || y % 4 == 0 { 0.0 } else { 1.0 })).flat_map(|v| [v, v, v, 1.0]).collect();
let r = Rect::new(0, 0, w, h);
src.write_region(r, &px);
let warp = Warp::preset(WarpStyle::Arc, 15.0, [0.0, 0.0, f64::from(w), f64::from(h)]);
let t = photocraft_geom::Affine { m: [0.2, 0.0, 0.0, 0.2, 7.0, 5.0] };
let out = place_source(&src, r, &t, Some(&warp)).unwrap();
let b = out.content_bounds();
let o = out.read_region(b);
let opaque: Vec<f32> = o.as_chunks::<4>().0.iter().filter(|p| p[3] >= 0.999).map(|p| p[0]).collect();
assert!(opaque.len() > 2000, "{s:?}: {} opaque pixels", opaque.len());
let worst = opaque.iter().map(|v| (v - 0.5625).abs()).fold(0.0f32, f32::max);
// An area average over the ~5 px footprint still covers one or two lines per axis
// (up to 0.2 off); point sampling lands wholly on or off a line (up to 0.56 off).
assert!(worst < 0.25, "{s:?}: worst deviation from the area average {worst}");
}
}
#[test]
fn gray_masks_keep_their_default() {
let mut m = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
-90
View File
@@ -1,90 +0,0 @@
//! Frozen scalar convolution from main 5dac5c6, used as an exact-output oracle.
use photocraft_algo::retouch::local_blur;
#[test]
fn matches_scalar_blur_bit_for_bit_across_layouts_edges_and_hdr() {
for (w, h) in [(1, 1), (3, 7), (37, 29)] {
for ch in 1..=5 {
for alpha in std::iter::once(None).chain((0..ch).map(Some)) {
let data: Vec<f32> = (0..w * h * ch)
.map(|i| if Some(i % ch) == alpha { [0.0, 1e-8, 0.25, 0.75, 1.0][(i / ch) % 5] } else { ((i * 73 + 19) % 113) as f32 / 17.0 - 2.0 })
.collect();
for win in [(0, 0, w, h), (w / 2, h / 2, w, h), (0, 0, 1, 1), (0, 0, 0, h)] {
for sigma in [0.0, 0.25, 1.0, 3.0, 4.0, 12.0] {
let expected = scalar_local_blur(&data, w, h, ch, alpha, win, sigma);
let actual = local_blur(&data, w, h, ch, alpha, win, sigma);
assert_eq!(actual.len(), expected.len());
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
assert_eq!(a.to_bits(), b.to_bits(), "{w}x{h} ch={ch} alpha={alpha:?} win={win:?} sigma={sigma} sample={i}");
}
}
}
}
}
}
}
#[test]
fn empty_channel_layout_remains_empty() {
assert!(local_blur(&[], 5, 5, 0, None, (0, 0, 5, 5), 4.0).is_empty());
}
#[allow(clippy::too_many_arguments)]
fn scalar_local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usize>, win: (usize, usize, usize, usize), sigma: f32) -> Vec<f32> {
let (x0, y0, x1, y1) = win;
let (ow, oh) = (x1 - x0, y1 - y0);
let r = (sigma * 3.0).ceil().max(1.0) as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
let get = |x: i32, y: i32, out: &mut [f32]| {
let (xx, yy) = (x.clamp(0, w as i32 - 1) as usize, y.clamp(0, h as i32 - 1) as usize);
let i = (yy * w + xx) * ch;
out.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = out[a];
for (c, v) in out.iter_mut().enumerate() {
if c != a {
*v *= av;
}
}
}
};
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let mut tmp = vec![0.0f32; ow * th * ch];
let mut px = vec![0.0f32; ch];
for ty in 0..th {
let y = y0 as i32 + ty as i32 - r;
for ox in 0..ow {
let o = (ty * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
get(x0 as i32 + ox as i32 + i as i32 - r, y, &mut px);
for c in 0..ch {
tmp[o + c] += px[c] * kv;
}
}
}
}
let mut out = vec![0.0f32; ow * oh * ch];
for oy in 0..oh {
for ox in 0..ow {
let o = (oy * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
let s = ((oy + i) * ow + ox) * ch;
for c in 0..ch {
out[o + c] += tmp[s + c] * kv;
}
}
if let Some(a) = alpha {
let av = out[o + a];
for c in 0..ch {
if c != a {
out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
}
}
}
}
}
out
}
Loaded 100 of 830 files, more files were not shown because too many files have changed in this diff. Show more