Merge main at b1cfff9d3 into integration batch 10

Conflicts resolved as unions of both sides: codecs re-exports (PngRows + TiffBands), smart_cmds source imports
(main's source_surface_from_buffer and cache API with #8571's svg_source_image_in/convert_svg_source; main's
duplicate rendering import dropped), io flat.rs test modules (#8536's banded TIFF tests and main's PNG ones),
menus/invoke.rs (#8571's smart SVG intercept and main's layer.selectUnderCursor), state.rs (#8311's
type_box_overflow and main's type_insertion_style), the canvas_geom test keeps the is_finite guard, docs
status lines keep both change lists, i18n catalogs keep both sides' rows (hi.tsv: main's wording where both
added the same string).
This commit is contained in:
David Mann
2026-10-30 14:28:02 +01:00
476 changed files with 68985 additions and 5718 deletions
+23 -4
View File
@@ -19,10 +19,29 @@ gpu = { max-threads = 4 }
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
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.
# CI (`--profile ci`, .github/workflows/ci.yml). Collect all failures in one integration run
# instead of exposing one regression per rebuild. A hanging test still terminates, and every
# failure is repeated at the end of the log. The default profile's GPU override still applies.
[profile.ci]
fail-fast = true
fail-fast = false
slow-timeout = { period = "60s", terminate-after = 10 }
failure-output = "immediate-final"
# Keep phase timings for the remaining expensive Windows GPU regressions visible. This does
# not relax their timeout, isolate failures differently or remove any test cases.
# Page eviction and budget borrowing redraw multi-effect documents. On the Windows
# CPU-backed adapter it uses all GPU slots, avoiding oversubscription without skipping work.
[[profile.ci.overrides]]
platform = 'cfg(target_os = "windows")'
filter = 'package(photocraft-gpu) & test(/pages_are_evicted_under_the_budget|layer_pages_that_just_miss_borrow_the_effect_map_share/)'
threads-required = 4
success-output = "immediate"
[[profile.ci.overrides]]
platform = 'cfg(target_os = "windows")'
filter = 'package(photocraft-gpu) & test(/documents_larger_than_the_texture_limit|layer_effects_update_incrementally|pages_are_evicted_under_the_budget|the_focused_view_stays_resident_after_a_full_refresh/)'
success-output = "immediate"
# These multi-page effect regressions saturate the Windows software GPU. Reserve two
# scheduling slots so four independent devices do not compete for the same CPU renderer.
# All cases and the 60-second warning remain unchanged.
threads-required = 2
+183 -1
View File
@@ -11,6 +11,26 @@ on:
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
inputs:
full_sweep:
description: "Run every testkit sweep case (runtime comparison and integration validation)"
type: boolean
default: false
color_range:
description: "Compare cropped Color Range on 24 MP (Linux, exact outputs)"
type: boolean
default: false
selection_combining:
description: "Compare direct GRAY8 selection combining on 36 MP (Linux, exact outputs)"
type: boolean
default: false
brush_tip:
description: "Compare cached round-tip rasterization on 24 MP"
type: boolean
default: false
brush_tip_base:
description: "Baseline commit for the round-tip comparison"
type: string
default: "main"
selection_morphology:
description: "Compare selection morphology on 36 MP (Linux, exact outputs)"
type: boolean
@@ -22,6 +42,10 @@ on:
fuzz_seconds:
description: "Seconds per fuzz target"
default: "600"
dry_mixer:
description: "Compare the live Mixer Brush against the origin/main merge base on 24 MP (Linux)"
type: boolean
default: false
# The token is only used to check out the code: no job writes to the repository.
permissions:
@@ -30,6 +54,7 @@ permissions:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
PHOTOCRAFT_FULL_SWEEP: ${{ inputs.full_sweep && '1' || '0' }}
# 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"
@@ -133,6 +158,7 @@ jobs:
# Cargo.toml's dev profile already keeps line tables only.
dev-debug: ""
env: |
PHOTOCRAFT_FULL_SWEEP=${{ inputs.full_sweep && '1' || '0' }}
PHOTOCRAFT_NO_PRINT=1
LPDEST=photocraft-no-such-printer
timeout-minutes: 100
@@ -424,6 +450,36 @@ jobs:
name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}
path: crates/${{ matrix.target.crate }}/fuzz/artifacts
color-range-performance:
if: inputs.color_range == true
runs-on: ubuntu-latest
timeout-minutes: 45
env:
RAYON_NUM_THREADS: "4"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: color-range-perf
- name: Compare exact outputs and release timings on 24 MP
run: |
set -o pipefail
cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture | tee color-range-perf.txt
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: color-range-performance
path: color-range-perf.txt
selection-morphology:
if: inputs.selection_morphology == true
runs-on: ubuntu-latest
@@ -459,7 +515,8 @@ jobs:
path: selection-morphology-perf.txt
selection-morphology-quick-perf:
if: inputs.selection_morphology == true
if: inputs.selection_morphology == true || inputs.color_range == true || inputs.selection_combining == true || inputs.brush_tip == true || inputs.dry_mixer == true
name: quick performance suite
# Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by
# bevel_bench, so its required GPU rows cannot be measured there.
runs-on: macos-15
@@ -481,3 +538,128 @@ jobs:
path: |
target/perf/*.json
target/perf/summary.md
selection-combining:
if: inputs.selection_combining == true
runs-on: ubuntu-latest
timeout-minutes: 30
env:
RAYON_NUM_THREADS: "4"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: selection-combining-perf
- name: Compare exact outputs and release timings on 36 MP
run: |
set -o pipefail
cargo test --release -p photocraft-algo --lib gray8_combining_release_comparison -- --ignored --nocapture | tee selection-combining-perf.txt
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: selection-combining-performance
path: selection-combining-perf.txt
brush-tip:
if: inputs.brush_tip == true
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
RAYON_NUM_THREADS: "4"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
path: after
- uses: actions/checkout@v7
with:
persist-credentials: false
ref: ${{ inputs.brush_tip_base }}
path: before
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: |
before -> target
after -> target
key: brush-tip-release
- name: Build the same benchmark against both versions
run: |
cp after/crates/paint/examples/big_dab_bench.rs before/crates/paint/examples/big_dab_bench.rs
cargo build --release --locked --manifest-path before/Cargo.toml -p photocraft-paint --example big_dab_bench
cargo build --release --locked --manifest-path after/Cargo.toml -p photocraft-paint --example big_dab_bench
- name: Compare round and transformed tips on 24 MP
run: |
set -o pipefail
for size in 200 2000; do
for angle in 0 0.7; do
for version in before after; do
echo "$version: size=$size angle=$angle" | tee -a brush-tip.txt
"$version/target/release/examples/big_dab_bench" "$size" 0.37 "$angle" | tee -a brush-tip.txt
done
done
done
- uses: actions/upload-artifact@v7
if: always()
with:
name: brush-tip-performance
path: brush-tip.txt
dry-mixer:
if: inputs.dry_mixer == true
runs-on: ubuntu-latest
timeout-minutes: 90
env:
RAYON_NUM_THREADS: "4"
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Pin baseline to the merge base with origin/main
id: baseline
run: |
git remote add upstream https://github.com/storytold/photocraft.git
git fetch --no-tags upstream main
base=$(git merge-base HEAD upstream/main)
echo "sha=$base" >> "$GITHUB_OUTPUT"
git worktree add --detach "$RUNNER_TEMP/photocraft-baseline" "$base"
mkdir -p "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples"
cp crates/engine/examples/bench_dry_mixer.rs "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples/bench_dry_mixer.rs"
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
- name: Benchmark baseline and candidate on this runner
run: |
set -o pipefail
echo "baseline=${{ steps.baseline.outputs.sha }}" | tee dry-mixer-perf.txt
echo "candidate=${GITHUB_SHA}" | tee -a dry-mixer-perf.txt
CARGO_TARGET_DIR="$RUNNER_TEMP/target-baseline" cargo run --release --manifest-path "$RUNNER_TEMP/photocraft-baseline/Cargo.toml" -p photocraft-engine --example bench_dry_mixer | tee baseline-mixer.txt
CARGO_TARGET_DIR="$RUNNER_TEMP/target-candidate" cargo run --release -p photocraft-engine --example bench_dry_mixer | tee candidate-mixer.txt
cat baseline-mixer.txt candidate-mixer.txt >> dry-mixer-perf.txt
python3 - <<'PY'
import re
def fingerprints(path):
rows = open(path).readlines()
result = {}
for row in rows:
match = re.search(r'wet=\s*(\d+)% sample_all=\s*(\w+)\s+size=\s*(\d+).*blake3=(\w+)', row)
if match:
result[match.group(1, 2, 3)] = match.group(4)
return result
before, after = fingerprints('baseline-mixer.txt'), fingerprints('candidate-mixer.txt')
assert len(before) == 12 and before == after, 'baseline/candidate mixer output or state differs'
PY
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: dry-mixer-performance
path: dry-mixer-perf.txt
+2 -2
View File
@@ -26,8 +26,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: photocraft
app-dir: PhotoCraft
+5
View File
@@ -158,3 +158,8 @@ If the field has a PSD equivalent, map it in `crates/io` too, and keep unknown P
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+2 -1
View File
@@ -34,7 +34,7 @@ next to them.
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `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`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.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`, `align-edge-*.svg` and `distribute-*.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`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.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`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (114 files; `star-filled.svg` is `star.svg` with a solid fill, `rotate-ccw.svg` is `rotate-cw.svg` mirrored) | 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/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -44,6 +44,7 @@ next to them.
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/path-op-{new-layer,combine,subtract,intersect,exclude,merge}.svg` | Path Operations glyphs (Pen options bar): a single square for New Layer, and two overlapping squares showing the area each operation keeps (union, back minus front, overlap, everything but the overlap, the merged outline), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `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 |
Generated
+15 -11
View File
@@ -323,7 +323,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1425,13 +1425,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -2096,7 +2097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3297,7 +3298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
dependencies = [
"io-lifetimes 3.0.1",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4774,7 +4775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5102,6 +5103,7 @@ dependencies = [
"image",
"image-webp",
"jpeg-encoder",
"jxl-oxide",
"photocraft-heif",
"png 0.18.1",
"proptest",
@@ -5111,6 +5113,7 @@ dependencies = [
"tiff",
"zune-core 0.5.3",
"zune-jpeg 0.5.15",
"zune-jpegxl",
]
[[package]]
@@ -5303,6 +5306,7 @@ dependencies = [
"photocraft-geom",
"photocraft-raster",
"rayon",
"rustc-hash 2.1.3",
"serde",
"serde_json",
]
@@ -5907,7 +5911,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6442,7 +6446,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6511,7 +6515,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7389,10 +7393,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8661,7 +8665,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
+3 -1
View File
@@ -42,7 +42,8 @@ photocraft-ipp = { path = "crates/ipp" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -67,6 +68,7 @@ log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
rustc-hash = "2.1.3"
criterion = { version = "0.8", default-features = false }
getrandom = "0.3"
# Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump).
+2
View File
@@ -286,6 +286,8 @@ The Flatpak prints through the host's CUPS socket (File › Print talks IPP to i
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.
To use the bundled CLI, run `./photocraft-<version>-linux-x86_64.AppImage --cli --help`, or `--cli mcp` to start the MCP server on stdio. CLI launches skip desktop integration. A symlink named `photocraft-cli` also selects the CLI; see [`docs/development.md`](docs/development.md#headless-cli).
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 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:
+11 -1
View File
@@ -15,7 +15,7 @@
//! be stamped on the shortcut, or the taskbar would stop matching the two.
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
/// on Apple's icon grid; elsewhere the 256 px hicolor render of the full tile.
pub fn window_icon() -> egui::IconData {
match eframe::icon_data::from_png_bytes(PNG) {
Ok(icon) => icon,
@@ -108,6 +108,16 @@ mod tests {
assert!(icon.rgba.chunks(4).any(|p| p[3] > 0), "the icon isn't blank");
}
#[test]
fn windows_and_linux_icons_keep_the_round_corners() {
// rx=112 of 512 leaves (10, 10) of a 256 px render transparent; the old crop filled it (#4478).
let png = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
let icon = eframe::icon_data::from_png_bytes(png).unwrap();
assert_eq!(icon.width, 256);
assert_eq!(icon.rgba[(10 * 256 + 10) * 4 + 3], 0, "corner is transparent");
assert_eq!(icon.rgba[(128 * 256 + 2) * 4 + 3], 255, "the tile is full bleed");
}
/// The `.ico` build.rs embeds: every size Windows asks for, each a valid PNG or BMP image.
#[test]
fn exe_icon_has_every_shell_size() {
+27 -8
View File
@@ -16,15 +16,10 @@ use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
use serde_json::{Value, json};
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
/// Start serving a listener already bound during startup, before a window is opened.
pub fn start(listener: TcpListener, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
let port = listener.local_addr().map_or(0, |a| a.port());
eprintln!("photocraft: control server listening on {}", listening_addr(&listener, port));
std::thread::spawn(move || {
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
@@ -152,6 +147,30 @@ mod tests {
TcpStream::connect(&logged).unwrap();
}
#[test]
fn a_prebound_listener_authenticates_and_dispatches_requests() {
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let requests = start(listener, TOKEN.into(), egui::Context::default());
let mut stream = TcpStream::connect(address).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut replies = BufReader::new(stream.try_clone().unwrap());
writeln!(stream, "{}", json!({"id": 1, "method": "auth", "params": {"token": TOKEN}})).unwrap();
let mut line = String::new();
replies.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
writeln!(stream, "{}", json!({"id": 2, "method": "test.bound"})).unwrap();
let request = requests.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(request.method, "test.bound");
request.reply.send(json!({"ok": true, "result": "served"})).unwrap();
line.clear();
replies.read_line(&mut line).unwrap();
let reply: Value = serde_json::from_str(&line).unwrap();
assert_eq!(reply["id"], 2);
assert_eq!(reply["result"], "served");
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
+25 -1
View File
@@ -315,22 +315,31 @@ pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
/// The file [`AppLogger::attach_dir`] opened, so a failure can tell the user what to attach.
path: Mutex<Option<PathBuf>>,
/// The lock that marks the attached log file as in use (see [`rotate`]); held until exit.
owner: Mutex<Option<File>>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) }
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), path: Mutex::new(None), owner: Mutex::new(None) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
*self.path.lock().unwrap_or_else(PoisonError::into_inner) = Some(path.clone());
*self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner;
Ok(path)
}
/// The log file records are written to, once [`AppLogger::attach_dir`] opened one: `None` when
/// the file could not be created (standard error is the only sink, see [`install`]).
pub fn path(&self) -> Option<PathBuf> {
self.path.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
}
impl log::Log for AppLogger {
@@ -379,6 +388,21 @@ mod tests {
d
}
/// The startup-failure dialog names the log file, so the logger has to remember it (#7643).
/// A start that couldn't create one keeps `None` and the dialog leaves the line out.
#[test]
fn path_names_the_attached_log_file() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("path");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
assert_eq!(logger.path(), None, "nothing is written before a file is attached");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(logger.path(), Some(path));
drop(logger);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_default_filter_shows_photocraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
+30 -6
View File
@@ -68,6 +68,7 @@ mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events).
#[cfg(any(not(target_os = "macos"), test))]
mod single_instance;
mod startup_report;
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
mod tablet;
mod ui_state;
@@ -287,12 +288,19 @@ fn control_setup(
cli_port: Option<u16>,
auth: photocraft_automation::args::ControlArgs,
automation: &photocraft_automation::args::AutomationArgs,
) -> Option<(u16, String, photocraft_automation::workspace::AuthorizedWorkspace)> {
) -> Option<(std::net::TcpListener, String, photocraft_automation::workspace::AuthorizedWorkspace)> {
let (port, warning) = control_port_for(cli_port, &integrations(services::prefs_file().as_deref()));
if let Some(warning) = &warning {
eprint!("{warning}");
}
let port = port?;
let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => listener,
Err(error) => {
eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {error}");
std::process::exit(2);
}
};
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
Ok(token) => token,
@@ -315,7 +323,7 @@ fn control_setup(
std::process::exit(2);
}
};
Some((port, token, workspace))
Some((listener, token, workspace))
}
/// Record this start's backend in the GPU startup marker, if the marker is locked.
@@ -337,7 +345,7 @@ fn main() -> eframe::Result {
let logger = logging::install();
crash_guard::install_hook();
#[cfg(not(target_arch = "wasm32"))]
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::PHOTOCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(done) = craft_update::self_update::finish(&photocraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("PhotoCraft update helper failed: {e}");
std::process::exit(1);
@@ -387,12 +395,14 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
let _ = (new_instance, &launch_options);
// Bind and authenticate the authorized control listener before display/GPU initialization.
let control = control_setup(control_port, auth, &automation);
#[cfg(target_os = "linux")]
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
#[cfg(target_os = "linux")]
let session = linux_display_session();
let control = control_setup(control_port, auth, &automation);
// Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that
// launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@@ -475,6 +485,10 @@ fn main() -> eframe::Result {
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
let keep_marker = gpu_startup::keep_marker_after_error(&plan, os);
// For the failure dialog below: the plan itself moves into the window closure. With
// `WGPU_BACKEND` set (`plan.env`) wgpu picks the backend, so name that rather than `auto`.
let started_renderer = plan.env.clone().unwrap_or_else(|| plan.backend.name().to_string());
let started_note = plan.reason.clone();
let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_callback = app_created.clone();
let started_sentinel = sentinel.clone();
@@ -601,8 +615,8 @@ fn main() -> eframe::Result {
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
}
});
if let Some((port, token, _)) = control {
let rx = control_server::start(port, token, cc.egui_ctx.clone());
if let Some((listener, token, _)) = control {
let rx = control_server::start(listener, token, cc.egui_ctx.clone());
app = app.with_control(rx);
}
#[cfg(target_os = "macos")]
@@ -712,6 +726,16 @@ fn main() -> eframe::Result {
}
}
}
// A launch that fails before a window exists leaves nothing on screen, and a desktop launch
// has no console for the `Err` above: log it, then say why in a native dialog with the log
// file to attach (#7643). A terminal or automation launch can read standard error or the
// exit status instead, so it keeps the silent exit.
if let Err(error) = &result {
log::error!("PhotoCraft could not start: {error}");
if startup_report::needs_dialog(control_port.is_some()) {
startup_report::show(&error.to_string(), &started_renderer, started_note.as_deref(), logger.and_then(logging::AppLogger::path).as_deref());
}
}
result
}
+1
View File
@@ -92,6 +92,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenRaster", &["ora"]),
("PNG", &["png"]),
("JPEG", &["jpg", "jpeg"]),
("JPEG XL", &["jxl"]),
("WebP", &["webp"]),
("TIFF", &["tif", "tiff"]),
("Targa", &["tga"]),
+7 -2
View File
@@ -312,10 +312,15 @@ fn save_as_offers_only_writable_formats_including_ico() {
let subtype = name.rsplit('.').next().unwrap_or_default();
assert!(!filters.iter().any(|(_, exts)| exts.iter().any(|e| e == subtype)), "{name}: .{subtype} is offered");
}
// File › Open lists every file Save As writes, also in that format's own type (PDF is written
// but not read yet).
// File Open lists readable Save As formats in their own type. Export-only formats must
// stay absent from both Open filters rather than advertising an unavailable decoder.
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| **e != "pdf") {
if photocraft_codecs::from_extension(ext).is_some_and(|format| !format.caps().read) {
assert!(!OPEN_EXTS.contains(ext), "{name}: export-only .{ext} must not appear in All Formats");
assert!(!OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: export-only .{ext} must not appear in an Open type");
continue;
}
assert!(OPEN_EXTS.contains(ext), "{name}: Open's All Formats lacks .{ext}");
assert!(OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: no Open type lists .{ext}");
}
+104
View File
@@ -0,0 +1,104 @@
//! Telling the user why a launch failed when there is no window to say it in (#7643).
//!
//! A desktop launch has no console — release builds are GUI-subsystem apps on Windows — so the
//! `Err` that ends `main` used to vanish and PhotoCraft "just closed". This shows one native
//! dialog with the error, the renderer that was tried and the log file to attach. The text is
//! English diagnostics, like Help › System Info, so a bug report reads the same in every language.
//!
//! Terminal and automation launches keep the old behaviour: they can read standard error or the
//! exit status, and a modal dialog would leave a script waiting.
use std::io::IsTerminal;
use std::path::Path;
/// Whether a failed launch needs the dialog: no terminal under standard error, no control client
/// (an agent launch reads the exit status instead of clicking OK), and a screen to put it on.
pub fn dialog_wanted(stderr_is_terminal: bool, automation: bool, display: bool) -> bool {
!stderr_is_terminal && !automation && display
}
/// Whether this launch asks for the dialog (see [`dialog_wanted`]).
pub fn needs_dialog(automation: bool) -> bool {
dialog_wanted(std::io::stderr().is_terminal(), automation, has_display())
}
/// Whether there is a screen for a dialog: always on Windows and macOS; on Linux a headless box
/// (a CI runner, an SSH session — `startup_without_display` removes both variables) has none.
fn has_display() -> bool {
#[cfg(target_os = "linux")]
{
["DISPLAY", "WAYLAND_DISPLAY"].iter().any(|key| std::env::var_os(key).is_some_and(|v| !v.is_empty()))
}
#[cfg(not(target_os = "linux"))]
{
true
}
}
/// The dialog's text: the error, the renderer that was tried and why it wasn't the preference,
/// the version and, when one was opened, the log file.
pub fn text(error: &str, renderer: &str, note: Option<&str>, version: &str, log: Option<&Path>) -> String {
let mut text = format!("PhotoCraft could not start.\n\n{error}\n\nRenderer: {renderer}");
if let Some(note) = note {
text.push_str(&format!("\nStartup: {note}"));
}
text.push_str(&format!("\nVersion: {version}"));
if let Some(path) = log {
text.push_str(&format!("\nLog file: {}", path.display()));
}
text
}
/// Show [`text`] in a native error dialog. Called after the event loop returned, so nothing is
/// repainting and the launch was about to fail anyway.
pub fn show(error: &str, renderer: &str, note: Option<&str>, log: Option<&Path>) {
let version = photocraft_engine::build_info::long_version();
let body = text(error, renderer, note, &version, log);
log::info!("showing the startup-failure dialog");
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title(photocraft_ui_egui::i18n::t("PhotoCraft could not start"))
.set_description(body)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_desktop_launch_asks_the_user_and_a_script_does_not() {
assert!(dialog_wanted(false, false, true), "no console: the error would vanish");
assert!(!dialog_wanted(true, false, true), "a terminal shows the error itself");
assert!(!dialog_wanted(false, true, true), "an automation client reads the exit status");
assert!(!dialog_wanted(false, false, false), "a headless launch has no screen for a dialog");
}
#[test]
fn the_text_names_the_error_renderer_version_and_log() {
let log = Path::new("/home/me/.config/photocraft/logs/photocraft.log");
let body = text("no adapter for Vulkan", "dx12", None, "0.15.1 (abc1234)", Some(log));
for part in [
"could not start",
"no adapter for Vulkan",
"Renderer: dx12",
"Version: 0.15.1 (abc1234)",
"Log file: /home/me/.config/photocraft/logs/photocraft.log",
] {
assert!(body.contains(part), "{part:?} missing from {body:?}");
}
assert!(!body.contains("Startup:"), "no fallback to explain: {body:?}");
// A start whose log file could not be created says so by leaving the line out.
assert!(!text("boom", "cpu", None, "0.15.1", None).contains("Log file:"));
}
/// A crash fallback is the confusing case: the app picks a renderer the user never chose, so
/// the dialog has to say why as well as which.
#[test]
fn a_fallback_that_was_not_the_preference_is_explained() {
let note = "the previous start didn't finish on vulkan; using dx12";
let body = text("device lost", "dx12", Some(note), "0.15.1", None);
assert!(body.contains(&format!("Renderer: dx12\nStartup: {note}\n")), "{body:?}");
}
}
+1 -1
View File
@@ -22,6 +22,6 @@ pub fn service() -> CheckFn {
fn check() -> CheckResult {
let http = craft_update::Http::new("PhotoCraft/Update")?;
let found = craft_update::check_for_update(&craft_update::suite::PHOTOCRAFT, &http, photocraft_engine::build_info::VERSION)?;
let found = craft_update::check_for_update(&photocraft_ui_egui::UPDATE_APP, &http, photocraft_engine::build_info::VERSION)?;
Ok(found.map(|release| Release { version: release.tag.trim_start_matches(['v', 'V']).to_string(), url: release.html_url }))
}
+29 -4
View File
@@ -2,8 +2,7 @@
//! status 0 believes the control server is running when it is not.
//!
//! These tests run the real binary as a subprocess and assert the exit code,
//! covering both failure paths in `main.rs`: control authentication and the
//! automation workspace.
//! covering control authentication, the automation workspace and listener binding.
/// A syntactically valid 64-hex-char token, so the workspace test reaches the
/// workspace check instead of failing on token validation first.
@@ -28,7 +27,7 @@ fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(crate::binary())
let mut child = std::process::Command::new(crate::binary())
.args(args)
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
// Isolate from the host environment so the test is reproducible.
@@ -41,12 +40,38 @@ fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.envs(vars.iter().copied())
.output()
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to run photocraft binary");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let timed_out = loop {
if child.try_wait().unwrap().is_some() {
break false;
}
if std::time::Instant::now() >= deadline {
child.kill().unwrap();
break true;
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
let output = child.wait_with_output().unwrap();
let gpu_started = dir.join("gpu-starting.json").exists();
std::fs::remove_dir_all(&dir).ok();
assert!(!timed_out, "PhotoCraft did not exit: {}", String::from_utf8_lossy(&output.stderr));
assert!(!gpu_started, "control setup errors must exit before GPU startup");
Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
}
#[test]
fn an_occupied_control_port_exits_with_status_2() {
let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = held.local_addr().unwrap().port().to_string();
let Run { status, stderr } = run_photocraft(&["--control", &port, "--control-token", VALID_TOKEN]);
assert_eq!(status.code(), Some(2), "occupied control port must exit 2, stderr: {stderr}");
assert!(stderr.contains(&format!("failed to bind 127.0.0.1:{port}")), "stderr: {stderr}");
}
#[test]
fn invalid_control_token_exits_with_status_2() {
// `validate_token` rejects a token that is not exactly 64 hex characters.
+2 -2
View File
@@ -17,8 +17,8 @@ Exactly three colours:
## Geometry
A 512-unit tile (`viewBox="0 0 512 512"`), rounded square with `rx=112`, no border. The macOS
renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders crop 22 units off
each side so the figure reads at 16–48 px.
renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders use the whole tile,
as the other Crafting Apps do.
## Provenance
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 B

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 90 KiB

+13
View File
@@ -0,0 +1,13 @@
<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="M3 3h12v6h6v12H9v-6H3z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 266 B

+13
View File
@@ -0,0 +1,13 @@
<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="M3 3h12v12H3zM9 9h12v12H9z" fill="currentColor" fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 290 B

+15
View File
@@ -0,0 +1,15 @@
<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"
>
<rect x="3" y="3" width="12" height="12" />
<rect x="9" y="9" width="12" height="12" />
<path d="M9 9h6v6H9z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

+13
View File
@@ -0,0 +1,13 @@
<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="M3 3h12v6h6v12H9v-6H3z" />
</svg>

After

Width:  |  Height:  |  Size: 246 B

+13
View File
@@ -0,0 +1,13 @@
<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"
>
<rect x="4" y="4" width="16" height="16" rx="1" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 281 B

+14
View File
@@ -0,0 +1,14 @@
<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="M3 3h12v6H9v6H3z" fill="currentColor" />
<rect x="9" y="9" width="12" height="12" />
</svg>

After

Width:  |  Height:  |  Size: 306 B

+14
View File
@@ -0,0 +1,14 @@
<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="M3 12a9 9 0 1 0 9-9c-2.52 0-4.93 1-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 296 B

+13 -1
View File
@@ -1,11 +1,23 @@
# Raster formats
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate. Lossless JPEG XL (.jxl) export uses the pure-Rust `zune-jpegxl` encoder for 8/16-bit gray, RGB and RGBA data; gray+alpha is expanded to RGBA at the same bit depth. JPEG XL import is tracked separately in #2868.
## Layered TIFF
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
## JPEG XL
Save As, Export As and the CLI can write a lossless, single-image `.jxl` bare
codestream at 8 or 16 bits per sample. Gray, RGB and RGBA are encoded
natively. Grayscale with alpha is expanded to RGBA at the original bit depth
to preserve both grayscale and alpha samples. For 16-bit RGBA the encoder's
incorrect default 8-bit alpha header is corrected to explicitly describe
16-bit alpha, with an independent decoder oracle checking alpha values.
The bare codestream has no ICC, EXIF, XMP or DPI boxes; the export path
converts tagged RGB to sRGB and surfaces metadata losses as fidelity warnings.
JPEG XL opening remains under review in #2868.
## WebP
Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side.
+3
View File
@@ -285,3 +285,6 @@ sync_github_name = false
[people.jasallen]
real_name = "Jason DeBoever"
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
+1 -4
View File
@@ -23,10 +23,7 @@ impl Stream {
/// The raw slot count of an object's array field (including null slots), for charging
/// traversal work before nulls are filtered out. `None` when the field is not an array.
pub fn array_len(&self, id: ObjId, tag: &[u8; 4]) -> Option<usize> {
match self.field(id, tag)? {
Value::Array(a) => Some(a.len()),
_ => None,
}
self.field(id, tag)?.array_len()
}
/// A child object (shared, linked or inline); `None` for null or missing.
+23 -15
View File
@@ -153,8 +153,8 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
let fields: Vec<&Value> = s.positional(c).collect();
// The corner arrays are scanned for every occurrence of a shared curve, so charge
// them like records before they are walked.
if let Some(Value::Array(idx)) = fields.get(1)
&& let Err(reason) = r.geometry_budget.records(idx.len())
if let Some(len) = fields.get(1).and_then(|v| v.array_len())
&& let Err(reason) = r.geometry_budget.records(len)
{
r.warn(reason);
return None;
@@ -170,17 +170,23 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
Value::Bool(b) => *b,
_ => return None,
};
let Value::Array(records) = fields.next()? else { return None };
seen = seen.checked_add(records.len())?;
let records = fields.next()?;
let len = records.array_len()?;
seen = seen.checked_add(len)?;
if seen > MAX_NODES {
r.warn("curves with more than four million nodes");
return None;
}
if let Err(reason) = r.geometry_budget.records(records.len()) {
if let Err(reason) = r.geometry_budget.records(len) {
r.warn(reason);
return None;
}
let anchors = anchors(records)?;
let anchors = match records {
Value::Array(a) => anchors(a.iter().map(record)),
Value::Records { size: 18, data } => anchors(data.as_chunks::<18>().0.iter().map(record18)),
// Records of another width are no curve nodes.
_ => anchors(std::iter::repeat_n(None, len)),
}?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
path.subpaths.push(sp);
@@ -202,22 +208,24 @@ struct Anchor {
fn record(v: &Value) -> Option<(Point, u8)> {
let Value::Bytes(b) = v else { return None };
if b.len() != 18 {
return None;
}
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
record18(b.as_slice().try_into().ok()?)
}
/// One 18-byte curve node: f64 x, f64 y, u8 kind, u8 role.
fn record18(b: &[u8; 18]) -> Option<(Point, u8)> {
let x = f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]);
let y = f64::from_le_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]);
if !x.is_finite() || !y.is_finite() {
return None;
}
Some((Point { x, y }, *b.get(17)?))
Some((Point { x, y }, b[17]))
}
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
fn anchors(records: impl ExactSizeIterator<Item = Option<(Point, u8)>>) -> Option<Vec<Anchor>> {
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
let mut pending_in = None;
for (i, v) in records.iter().enumerate() {
let (p, role) = record(v)?;
for (i, v) in records.enumerate() {
let (p, role) = v?;
match role {
0 => {
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
+3
View File
@@ -300,6 +300,9 @@ 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)?;
// The parsed stream owns copies of everything it needs: do not hold the inflated bytes
// (hundreds of MB for a large vector document) while the model is built.
drop(doc);
Reader::new(&s, &mut archive, limits).document()
}
+9 -1
View File
@@ -129,11 +129,16 @@ const MAX_STOPS: usize = 1024;
fn floats(v: &Value) -> Option<Vec<f64>> {
match v {
Value::Floats(f) => Some(f.clone()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(f32_samples(b)),
_ => None,
}
}
/// Little-endian `f32` samples widened to `f64`.
fn f32_samples(b: &[u8]) -> Vec<f64> {
b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()
}
/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB
/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared
/// objects, so one can link to itself, and with both links followed at every level a colour read
@@ -241,6 +246,9 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(),
Some(Value::Records { size, data }) if *size % 4 == 0 && *size > 0 => {
data.chunks_exact(usize::from(*size)).map(f32_samples).take(MAX_STOPS).collect()
}
_ => Vec::new(),
};
let mut stops = Vec::new();
+55 -25
View File
@@ -53,6 +53,23 @@ pub enum Value {
/// `None` is a null object.
Obj(Option<ObjId>),
Array(Vec<Value>),
/// An array of fixed-size records (`0x2c`, curve nodes) held in one buffer: `size` bytes each.
/// Counts as one value per record against [`MAX_VALUES`], like the array of `Bytes` it replaces.
Records {
size: u16,
data: Vec<u8>,
},
}
impl Value {
/// Slot count of an array field (including null slots); `None` for a scalar.
pub fn array_len(&self) -> Option<usize> {
match self {
Value::Array(a) => Some(a.len()),
Value::Records { size, data } => Some(data.len() / usize::from(*size).max(1)),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -73,39 +90,50 @@ pub struct Object {
pub chain: Vec<Tag>,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
/// First-occurrence index of each non-positional tag, so `get` is O(1). A shared object
/// can be referenced by many sibling layers, and each reference used to rescan all of its
/// fields; indexing once keeps repeated lookups from multiplying into quadratic work.
index: std::collections::HashMap<Tag, usize>,
/// Index for objects with more than [`SCAN`] fields: (tag, index of its first occurrence)
/// sorted by tag, so `get` is a binary search. A shared object can be referenced by many
/// sibling layers, and each reference used to rescan all of its fields; indexing once keeps
/// repeated lookups from multiplying into quadratic work. Smaller objects have no index
/// (and no per-object hash map to build): scanning a handful of fields is cheaper.
index: Vec<(Tag, u32)>,
}
/// Objects with at most this many fields are scanned instead of indexed.
const SCAN: usize = 12;
fn build_index(fields: &[(Tag, Value)]) -> Vec<(Tag, u32)> {
if fields.len() <= SCAN {
return Vec::new();
}
// Positional fields share tag 0 and are not looked up by tag. Sorting by (tag, index) and
// keeping the first of each tag preserves first-match semantics.
let mut index: Vec<(Tag, u32)> = fields.iter().zip(0u32..).filter(|((t, _), _)| t.0 != 0).map(|((t, _), i)| (*t, i)).collect();
index.sort_unstable();
index.dedup_by_key(|e| e.0);
index
}
impl Object {
pub fn new(class: Tag, kind: Kind, chain: Vec<Tag>, fields: Vec<(Tag, Value)>) -> Self {
let mut index = std::collections::HashMap::with_capacity(fields.len());
for (i, (t, _)) in fields.iter().enumerate() {
// Positional fields share tag 0 and are not looked up by tag; keep first-match
// semantics for real tags.
if t.0 != 0 {
index.entry(*t).or_insert(i);
}
}
let index = build_index(&fields);
Object { class, kind, chain, fields, index }
}
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.index.get(&tag).map(|&i| &self.fields[i].1)
if tag.0 == 0 {
return None;
}
if self.fields.len() <= SCAN {
return self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v);
}
let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?;
self.index.get(at).and_then(|&(_, i)| self.fields.get(i as usize)).map(|(_, v)| v)
}
/// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the
/// tag index so `get` stays correct.
fn set_fields(&mut self, fields: Vec<(Tag, Value)>) {
self.index.clear();
self.index.reserve(fields.len());
for (i, (t, _)) in fields.iter().enumerate() {
if t.0 != 0 {
self.index.entry(*t).or_insert(i);
}
}
self.index = build_index(&fields);
self.fields = fields;
}
}
@@ -336,11 +364,13 @@ impl Parser<'_> {
if size == 0 || n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Bytes(self.c.take(size)?.to_vec()));
}
Ok(Value::Array(v))
// Still one value per record against the budget, but a single allocation.
self.spend(n)?;
let bytes = self.c.take(n * size)?;
let mut data = Vec::new();
data.try_reserve_exact(bytes.len()).map_err(|_| Error::Limit("not enough memory for the document stream"))?;
data.extend_from_slice(bytes);
Ok(Value::Records { size: size as u16, data })
}
0x32 => {
let n = self.count(1)?;
+39
View File
@@ -757,3 +757,42 @@ mod spot_colours {
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
}
/// Object lookup and record arrays against the structures they replaced.
mod stream_storage {
use crate::stream::{self, Kind, Object, Tag, Value};
use crate::synth::{self, F, tag};
/// The per-object map `Object::get` used before: first occurrence of each real tag.
fn reference(fields: &[(Tag, Value)], t: Tag) -> Option<&Value> {
let mut index = std::collections::HashMap::new();
for (i, (ft, _)) in fields.iter().enumerate() {
if ft.0 != 0 {
index.entry(*ft).or_insert(i);
}
}
index.get(&t).map(|&i| &fields[i].1)
}
#[test]
fn lookup_keeps_first_match_for_every_object_size() {
for n in 0..60u32 {
// Tags repeat (n % 7 distinct tags) and every fifth field is positional (tag 0).
let fields: Vec<(Tag, Value)> = (0..n).map(|i| (if i % 5 == 0 { Tag(0) } else { Tag(100 + i % 7) }, Value::UInt(u64::from(i)))).collect();
let object = Object::new(Tag(1), Kind::Inline, vec![Tag(1)], fields.clone());
for t in 0..120 {
assert_eq!(object.get(Tag(t)), reference(&fields, Tag(t)), "{n} fields, tag {t}");
}
}
}
#[test]
fn a_fixed_size_array_is_one_buffer_and_one_value_per_record() {
let records: Vec<u8> = (0..18 * 5).map(|i| i as u8).collect();
let bytes = synth::stream(&[(tag(b"Nods"), F::Records(18, records.chunks(18).map(<[u8]>::to_vec).collect()))]);
let s = stream::parse(&bytes).unwrap();
let root = s.object(s.root).unwrap();
assert_eq!(root.get(Tag::of(b"Nods")), Some(&Value::Records { size: 18, data: records }));
assert_eq!(root.get(Tag::of(b"Nods")).and_then(Value::array_len), Some(5));
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
_ => bounds.x0,
};
if !artistic {
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
if s.objs(frame, b"ColW").len() > 1 || s.array_len(frame, b"ColW").is_some_and(|n| n > 1) {
r.warn("text frames with several columns (imported as one column)");
}
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
+18
View File
@@ -122,6 +122,24 @@ fn main() {
};
wideangle::apply(&s, r, &p, Interp::Bicubic)
});
// The mesh solve alone, on the dialog's 800 x 533 proxy.
let proxy = Rect::new(0, 0, 800, 533);
let solve_case = |constraints: Vec<Constraint>| {
let p = WideAngle { model: WideModel::Fisheye, focal_length: 12.0, constraints, ..Default::default() };
wideangle::solve(&p, proxy)
};
time("wide angle solve: 800x533 proxy, H + V", &only, || {
solve_case(vec![
Constraint { a: [60.0, 80.0], b: [740.0, 80.0], orientation: Orientation::Horizontal },
Constraint { a: [100.0, 60.0], b: [100.0, 470.0], orientation: Orientation::Vertical },
])
});
time("wide angle solve: 800x533 proxy, 2 free", &only, || {
solve_case(vec![
Constraint { a: [60.0, 80.0], b: [740.0, 95.0], orientation: Orientation::Free },
Constraint { a: [100.0, 60.0], b: [110.0, 470.0], orientation: Orientation::Free },
])
});
time("hdr: merge 3 exposures + local adaptation", &only, || {
let shots: Vec<Vec<[f32; 4]>> =
[0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect();
+147 -14
View File
@@ -17,6 +17,7 @@ use photocraft_geom::Rect;
use crate::Ctx;
use crate::artistic::GalleryEffect;
use crate::artistic::GalleryFilter as F;
use crate::cutout;
use crate::fxutil::{box_blur_n, cell_point, gauss_blur_n, gauss_box_radii, rgba, set_rgba};
use crate::image::Image;
use crate::noise::hash01;
@@ -409,7 +410,8 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
let g = |k: &str| e.get(k);
match e.filter {
F::ColoredPencil => 2,
F::Cutout => gs(cutout_sigma(e)),
// Traced over the whole image before tiling (see [`Prepared`]).
F::Cutout => 0,
F::DryBrush | F::Fresco => kuwahara_reach(1 + g("brushSize") as usize) + gs(1.0) + 2,
F::FilmGrain | F::DiffuseGlow | F::ChalkCharcoal | F::Reticulation | F::Grain | F::MosaicTiles | F::Texturizer => 0,
F::RoughPastels => gs(0.6 + 1.2) + ((2.0 + g("strokeLength")) / 2.0) as i32 + 3,
@@ -451,8 +453,8 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
}
}
fn cutout_sigma(e: &GalleryEffect) -> f32 {
0.5 + e.get("edgeSimplicity") * (1.1 - 0.3 * e.get("edgeFidelity"))
fn cutout_settings(e: &GalleryEffect) -> cutout::Settings {
cutout::Settings { levels: e.get("numberOfLevels").round().clamp(2.0, 8.0) as usize, simplicity: e.get("edgeSimplicity"), fidelity: e.get("edgeFidelity") }
}
/// Halftone Pattern's screen spacing in pixels (about 3.5 at Size 1, 25.5 at 12).
fn halftone_cell(e: &GalleryEffect) -> f32 {
@@ -541,6 +543,54 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
for e in effects {
apply(e, &mut win, ctx);
}
finish(&win, src, out, ctx)
}
/// Whether a stack needs the whole image at once (Cutout traces it).
pub(crate) fn is_global(effects: &[GalleryEffect]) -> bool {
effects.iter().any(|e| e.filter == F::Cutout)
}
/// A stack with a Cutout, prepared once for all tiles: the effects up to the last Cutout run
/// over the whole image and it is traced; each tile then only fills in the traced shapes and
/// runs the effects after it.
pub(crate) struct Prepared {
traced: cutout::Traced,
rest: Vec<GalleryEffect>,
}
impl Prepared {
/// `None` for a stack that has no Cutout (it runs tile by tile as usual).
pub(crate) fn new(effects: &[GalleryEffect], src: &Image, ctx: &Ctx) -> Option<Prepared> {
let last = effects.iter().rposition(|e| e.filter == F::Cutout)?;
let r = src.rect;
let (w, h) = (r.width() as usize, r.height() as usize);
let mut win = Win { r, w, h, c: src.data.chunks_exact(src.ch).map(|px| rgb(rgba(ctx, px))).collect() };
for e in effects.get(..last).unwrap_or_default() {
apply(e, &mut win, ctx);
}
let traced = cutout::trace(&win.c, r, &cutout_settings(effects.get(last)?));
Some(Prepared { traced, rest: effects.get(last + 1..).unwrap_or_default().to_vec() })
}
/// The stack's result over `out` (`src` is the image it was prepared from).
pub(crate) fn run(&self, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let r = out.inflate(reach(&self.rest)).intersect(&src.rect);
let (w, h) = (r.width() as usize, r.height() as usize);
if w == 0 || h == 0 {
return src.crop(out);
}
let mut win = Win { r, w, h, c: self.traced.render(r) };
for e in &self.rest {
apply(e, &mut win, ctx);
}
finish(&win, src, out, ctx)
}
}
/// `out` of the source with its colour from `win` (alpha kept).
fn finish(win: &Win, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let n = src.ch;
let mut res = src.crop(out);
let ow = out.width() as usize;
for (i, px) in res.chunks_exact_mut(n).enumerate() {
@@ -585,16 +635,7 @@ fn apply_artistic(e: &GalleryEffect, win: &mut Win) -> bool {
win.c[i] = mix(paper, map(win.c[i], |v| v * 0.88), cov);
}
}
F::Cutout => {
blur3(&mut win.c, w, h, cutout_sigma(e));
let levels = g("numberOfLevels").round();
for p in &mut win.c {
// Flat tones per luminance level, keeping each region's hue.
let l = lum(p).max(1e-4);
let k = posterize(l, levels) / l;
*p = map(*p, |v| posterize(clamp01(v * k), levels * 2.0));
}
}
F::Cutout => win.c = cutout::trace(&win.c, win.r, &cutout_settings(e)).render(win.r),
F::DryBrush | F::Fresco => {
let fresco = e.filter == F::Fresco;
kuwahara(win, 1 + g("brushSize") as usize);
@@ -1344,7 +1385,7 @@ mod tests {
let fp = FilterParams::FilterGallery { effects: vec![e.clone()] };
apply_tiled(&s, &fp, b, b, None, tile, Some(b)).read_region(b)
};
for f in [G::RoughPastels, G::AngledStrokes] {
for f in [G::RoughPastels, G::AngledStrokes, G::Cutout] {
let base = run(&GalleryEffect::new(f), 256);
for prm in f.params() {
let crate::GalleryParamKind::Range { min, max, .. } = prm.kind else { continue };
@@ -1361,6 +1402,98 @@ mod tests {
}
}
/// Runs one Cutout over a grey image given as rows of values; returns the red channel.
fn cutout_on(rows: &[Vec<f32>], set: &[(&str, f32)]) -> Vec<Vec<f32>> {
let (w, h) = (rows[0].len() as i32, rows.len() as i32);
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
for (y, row) in rows.iter().enumerate() {
for (x, v) in row.iter().enumerate() {
s.write_pixel(x as i32, y as i32, &[*v, *v, *v, 1.0]);
}
}
let mut e = GalleryEffect::new(crate::GalleryFilter::Cutout);
for (k, v) in set {
e.set(k, *v);
}
let b = Rect::new(0, 0, w, h);
let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b);
out.chunks(4 * w as usize).map(|r| r.chunks(4).map(|p| p[0]).collect()).collect()
}
#[test]
fn cutout_makes_flat_shapes_with_clean_edges() {
// It used to posterize every pixel after a blur: noise survived as speckle, edges were
// ragged and smooth gradients banded instead of becoming flat paper.
let noisy: Vec<Vec<f32>> = (0..32).map(|y| (0..64).map(|x| if x < 32 { 0.2 } else { 0.8 } + (hash01(x, y, 0, 7) - 0.5) * 0.16).collect()).collect();
let out = cutout_on(&noisy, &[]);
for (x0, x1) in [(2, 28), (36, 62)] {
let vals: Vec<f32> = (2..30).flat_map(|y| out[y][x0..x1].to_vec()).collect();
let spread = vals.iter().fold(f32::MIN, |a, &v| a.max(v)) - vals.iter().fold(f32::MAX, |a, &v| a.min(v));
assert!(spread < 1e-4, "a noisy area should be one flat tone, spread {spread}");
}
// The cut is an anti-aliased pixel or two, not a blur.
for y in 0..32 {
let (lo, hi) = (out[y][16], out[y][48]);
let soft = out[y].iter().filter(|&&v| (v - lo).abs() > 0.01 && (v - hi).abs() > 0.01).count();
assert!(soft <= 2, "row {y}: {soft} edge pixels");
}
// A ramp becomes `numberOfLevels` flat steps (the palette is the image's own colours, so
// one may go to the wide flat margins and merge as noise).
let ramp: Vec<Vec<f32>> = (0..12).map(|_| (0..144).map(|x| ((x as f32 - 24.0) / 95.0).clamp(0.0, 1.0)).collect()).collect();
for levels in [2.0, 5.0, 8.0] {
let row = &cutout_on(&ramp, &[("numberOfLevels", levels)])[6];
let mut tones: Vec<f32> = Vec::new();
for win in row.windows(3) {
if (win[0] - win[2]).abs() < 1e-4 && tones.last().is_none_or(|t| (t - win[1]).abs() > 1e-3) {
tones.push(win[1]);
}
}
assert!(tones.len() <= levels as usize && tones.len() + 1 >= levels as usize, "levels {levels}: tones {tones:?}");
assert!(tones.windows(2).all(|t| t[0] < t[1]), "levels {levels}: tones {tones:?}");
}
// Edge Simplicity drops specks.
let speck: Vec<Vec<f32>> = (0..32).map(|y| (0..32).map(|x| if (15..18).contains(&x) && (15..18).contains(&y) { 0.9 } else { 0.2 }).collect()).collect();
let detailed = cutout_on(&speck, &[("edgeSimplicity", 0.0)]);
let simple = cutout_on(&speck, &[("edgeSimplicity", 10.0)]);
assert!(detailed[16][16] - detailed[2][2] > 0.5, "the speck should survive at simplicity 0");
assert!((simple[16][16] - simple[2][2]).abs() < 1e-4, "the speck should go at simplicity 10");
}
#[test]
fn cutout_keeps_the_tone_of_surviving_islands_at_low_fidelity() {
for (size, simplicity) in [(2, 0.0), (3, 2.0), (4, 0.0), (4, 2.0), (6, 2.0)] {
let rows: Vec<Vec<f32>> =
(0..32).map(|y| (0..32).map(|x| if (12..12 + size).contains(&x) && (12..12 + size).contains(&y) { 0.9 } else { 0.2 }).collect()).collect();
let reference = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", 2.0)]);
let centre = 12 + size / 2;
for fidelity in [1.0, 1.5, 2.0] {
let out = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", fidelity)]);
assert!(
size == 2 || (out[centre][centre] - reference[centre][centre]).abs() < 0.02,
"{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {} vs {}",
out[centre][centre],
reference[centre][centre]
);
assert!(out[centre][centre] - out[2][2] > 0.5, "the island keeps its contrast");
}
}
}
#[test]
fn a_stack_around_a_cutout_is_tile_independent() {
// Effects before a Cutout are traced with it over the whole image; effects after it run
// per tile on the filled-in shapes.
use crate::GalleryFilter as G;
let b = Rect::new(0, 0, 56, 40);
let s = pattern(SampleType::F32);
let fp =
FilterParams::FilterGallery { effects: vec![GalleryEffect::new(G::Spatter), GalleryEffect::new(G::Cutout), GalleryEffect::new(G::GlowingEdges)] };
let a = apply_tiled(&s, &fp, b, b, None, 256, Some(b)).read_region(b);
let t = apply_tiled(&s, &fp, b, b, None, 16, Some(b)).read_region(b);
let worst = a.iter().zip(&t).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
assert!(worst < 1e-3, "differs across tilings by {worst}");
}
/// Flat greys keep their tone through every pattern and Contrast, black and white stay
/// solid, and Contrast makes the screen crisper.
#[test]
+6 -7
View File
@@ -144,13 +144,12 @@ fn conv_row<const N: usize>(src: &[f32], dst: &mut [f32], kernel: &[f32]) {
pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
// Exact kernel for small radii; beyond that three box passes approximate the Gaussian with
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010).
match box_widths(&FilterParams::GaussianBlur { radius }) {
Some(boxes) => box_passes(src, out, ctx.alpha, &boxes),
None => {
let k = gaussian_blur_kernel(radius);
conv_sep(src, out, &k, &k, ctx.alpha)
}
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010). Built
// from the description other devices run (`separable`), so they blur the same (#5772).
match crate::separable::Separable::of(&FilterParams::GaussianBlur { radius }) {
Some(crate::separable::Separable::Boxes(boxes)) => box_passes(src, out, ctx.alpha, &boxes),
Some(crate::separable::Separable::Kernel(k)) => conv_sep(src, out, &k, &k, ctx.alpha),
None => src.crop(out),
}
}
+263
View File
@@ -0,0 +1,263 @@
//! Colour and palette: Oklab, edge-keeping smoothing, and the picture's own palette.
use super::Px;
use super::util::transpose;
use crate::par;
use crate::quantize::{kmeans, median_cut};
/// Oklab (Björn Ottosson, 2020) of an sRGB colour.
pub(super) fn oklab(p: Px) -> Px {
let lin = |v: f32| {
let v = v.clamp(0.0, 1.0);
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
};
let (r, g, b) = (lin(p[0]), lin(p[1]), lin(p[2]));
let l = (0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt();
let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt();
let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt();
[
0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s,
1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s,
0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s,
]
}
/// sRGB of an Oklab colour, or `None` outside the sRGB gamut.
pub(super) fn srgb(lab: Px) -> Option<Px> {
let l = (lab[0] + 0.396_337_78 * lab[1] + 0.215_803_76 * lab[2]).powi(3);
let m = (lab[0] - 0.105_561_346 * lab[1] - 0.063_854_17 * lab[2]).powi(3);
let s = (lab[0] - 0.089_484_18 * lab[1] - 1.291_485_5 * lab[2]).powi(3);
let lin = [
4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s,
-1.268_438 * l + 2.609_757_4 * m - 0.341_319_38 * s,
-0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s,
];
if lin.iter().any(|v| !(-1e-4..=1.0001).contains(v)) {
return None;
}
let enc = |v: f32| {
let v = v.clamp(0.0, 1.0);
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
};
Some(lin.map(enc))
}
/// sRGB at Oklab lightness `l`, hue/strength `a`, `b`; out of gamut it loses strength, not hue.
pub(super) fn in_gamut(l: f32, a: f32, b: f32) -> Px {
if let Some(c) = srgb([l, a, b]) {
return c;
}
let (mut ok, mut bad) = (0.0f32, 1.0f32);
for _ in 0..10 {
let t = (ok + bad) / 2.0;
if srgb([l, a * t, b * t]).is_some() { ok = t } else { bad = t }
}
srgb([l, a * ok, b * ok]).unwrap_or([l, l, l])
}
/// Squared distance between two colours.
pub(super) fn dist2(a: Px, b: Px) -> f32 {
(a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)
}
/// Bilateral smoothing in Oklab, `rounds` times along rows then columns.
pub(super) fn smooth(mut lab: Vec<Px>, w: usize, h: usize, rounds: usize) -> Vec<Px> {
for _ in 0..rounds {
bilateral_rows(&mut lab, w);
let mut t = transpose(&lab, w, h);
bilateral_rows(&mut t, h);
lab = transpose(&t, h, w);
}
lab
}
/// One bilateral pass along every row of `buf` (rows of `w` pixels), seven pixels wide.
fn bilateral_rows(buf: &mut [Px], w: usize) {
const R: isize = 3;
// Colours this far apart (Oklab) count half as much as equal ones (a Cauchy weight: as
// edge-keeping as a Gaussian one and much cheaper).
const HALF: f32 = 0.06;
let k = 1.0 / (HALF * HALF);
let spatial: [f32; 7] = std::array::from_fn(|i| (-((i as f32 - 3.0).powi(2)) / 8.0).exp());
par::chunks_mut(buf, w, true, |_, row| {
let src = row.to_vec();
for (x, out) in row.iter_mut().enumerate() {
let c = src[x];
let (mut acc, mut wsum) = ([0.0f32; 3], 0.0f32);
for d in -R..=R {
let q = src[(x as isize + d).clamp(0, w as isize - 1) as usize];
let wt = spatial[(d + R) as usize] / (1.0 + dist2(q, c) * k);
acc = [acc[0] + q[0] * wt, acc[1] + q[1] * wt, acc[2] + q[2] * wt];
wsum += wt;
}
*out = [acc[0] / wsum, acc[1] / wsum, acc[2] / wsum];
}
});
}
/// At most `levels` colours of the image's own; colours closer than noise are merged.
pub(super) fn palette(lab: &[Px], levels: usize) -> Vec<Px> {
const SAMPLES: usize = 1 << 16;
const SAME: f32 = 0.045;
// Hue centres (Oklab a, b) this far apart are different colours, not one colour's spread.
const HUES_APART: f32 = 0.08;
let step = lab.len().div_ceil(SAMPLES).max(1);
let pts: Vec<(Px, u32)> = lab.iter().step_by(step).map(|p| (*p, 1)).collect();
let mut centres = median_cut(&pts, levels);
kmeans(&pts, &mut centres, 10);
// Number of Levels counts tones: a palette colour whose pixels are two clearly different hues
// of that tone (a blue and a pink) splits in two, or touching areas of both would be one
// shape of their grey average.
let mut split = Vec::with_capacity(centres.len() * 2);
for (k, c) in centres.iter().enumerate() {
let members: Vec<(Px, u32)> = pts.iter().filter(|(p, _)| nearest(&centres, *p) as usize == k).map(|(p, _)| ([0.0, p[1], p[2]], 1)).collect();
let mut hues = median_cut(&members, 2);
kmeans(&members, &mut hues, 6);
match hues.as_slice() {
[a, b] if dist2(*a, *b).sqrt() > HUES_APART => split.extend([[c[0], a[1], a[2]], [c[0], b[1], b[2]]]),
_ => split.push(*c),
}
}
let mut centres = split;
kmeans(&pts, &mut centres, 4);
let mut weight = vec![0u32; centres.len()];
for (p, _) in &pts {
if let Some(wt) = weight.get_mut(nearest(&centres, *p) as usize) {
*wt += 1;
}
}
let mut cs: Vec<(Px, f32)> = centres.into_iter().zip(weight).filter(|c| c.1 > 0).map(|(c, wt)| (c, wt as f32)).collect();
loop {
let mut best: Option<(usize, usize, f32)> = None;
for i in 0..cs.len() {
for j in i + 1..cs.len() {
let d = dist2(cs[i].0, cs[j].0).sqrt();
if d < SAME && best.is_none_or(|b| d < b.2) {
best = Some((i, j, d));
}
}
}
let Some((i, j, _)) = best else { break };
let (a, b) = (cs[i], cs[j]);
let t = a.1 + b.1;
cs[i] = (std::array::from_fn(|k| (a.0[k] * a.1 + b.0[k] * b.1) / t), t);
cs.remove(j);
}
if cs.is_empty() {
return vec![[0.5, 0.0, 0.0]];
}
cs.into_iter().map(|c| c.0).collect()
}
/// Index of the palette colour closest to `p`.
pub(super) fn nearest(palette: &[Px], p: Px) -> u8 {
let mut best = (0usize, f32::MAX);
for (i, c) in palette.iter().enumerate() {
let d = dist2(*c, p);
if d < best.1 {
best = (i, d);
}
}
best.0 as u8
}
#[cfg(test)]
mod tests {
use super::*;
use crate::photo_util::hash01;
/// Oklab pixels of `w`×`h`: `colour(x)` plus noise of ±`noise` in each channel.
fn noisy(w: usize, h: usize, noise: f32, colour: impl Fn(usize) -> Px) -> Vec<Px> {
(0..w * h)
.map(|i| {
let (x, y) = ((i % w) as i64, (i / w) as i64);
let c = colour(i % w);
std::array::from_fn(|k| c[k] + (hash01(x, y, k as u64 + 1) - 0.5) * 2.0 * noise)
})
.collect()
}
#[test]
fn oklab_round_trips_and_marks_the_gamut() {
let white = oklab([1.0, 1.0, 1.0]);
assert!((white[0] - 1.0).abs() < 1e-3 && white[1].abs() < 1e-3 && white[2].abs() < 1e-3, "{white:?}");
assert!(oklab([0.0, 0.0, 0.0])[0].abs() < 1e-6);
for p in [[0.2, 0.5, 0.9], [0.95, 0.1, 0.3], [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]] {
let back = srgb(oklab(p)).unwrap_or([-1.0; 3]);
assert!(dist2(back, p) < 1e-6, "{p:?} came back as {back:?}");
}
// Very light and very colourful: no sRGB colour is that.
assert_eq!(srgb([0.95, 0.3, 0.0]), None);
}
#[test]
fn out_of_gamut_colours_lose_strength_not_hue() {
let (l, a, b) = (0.95, 0.3, 0.1);
let c = in_gamut(l, a, b);
let lab = oklab(c);
assert!((lab[0] - l).abs() < 0.01, "lightness kept: {lab:?}");
let chroma = (lab[1] * lab[1] + lab[2] * lab[2]).sqrt();
assert!(chroma > 0.01 && chroma < (a * a + b * b).sqrt(), "weaker but still coloured: {chroma}");
assert!((lab[2] / lab[1] - b / a).abs() < 0.05, "same hue: {lab:?}");
// In gamut it is just the conversion.
assert_eq!(in_gamut(0.6, 0.02, -0.03), srgb([0.6, 0.02, -0.03]).unwrap_or_default());
}
#[test]
fn smoothing_flattens_noise_and_keeps_edges() {
let (w, h) = (40usize, 20usize);
let lab = noisy(w, h, 0.02, |x| if x < 20 { [0.3, 0.0, 0.0] } else { [0.8, 0.0, 0.0] });
let out = smooth(lab.clone(), w, h, 2);
let spread = |v: &[Px], xs: std::ops::Range<usize>| {
let l: Vec<f32> = (0..h).flat_map(|y| xs.clone().map(move |x| y * w + x)).map(|i| v[i][0]).collect();
let mean = l.iter().sum::<f32>() / l.len() as f32;
(l.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / l.len() as f32).sqrt()
};
assert!(spread(&out, 2..16) < spread(&lab, 2..16) / 2.0, "noise kept: {} vs {}", spread(&out, 2..16), spread(&lab, 2..16));
// The step stays a step: the pixels either side of it keep most of their difference.
let step = (0..h).map(|y| out[y * w + 20][0] - out[y * w + 19][0]).sum::<f32>() / h as f32;
assert!(step > 0.4, "edge blurred to {step}");
}
#[test]
fn the_palette_finds_the_pictures_own_colours() {
let (w, h) = (60usize, 20usize);
let (dark, light) = ([0.25, 0.02, -0.03], [0.75, -0.04, 0.05]);
let lab = noisy(w, h, 0.01, |x| if x < 30 { dark } else { light });
// Asked for up to six, it gives two: colours only noise apart are one.
let p = palette(&lab, 6);
assert_eq!(p.len(), 2, "{p:?}");
for c in [dark, light] {
assert!(p.iter().any(|q| dist2(*q, c).sqrt() < 0.02), "{c:?} not in {p:?}");
}
assert_eq!(nearest(&p, dark), nearest(&p, [0.27, 0.0, -0.02]));
assert_ne!(nearest(&p, dark), nearest(&p, light));
}
#[test]
fn a_tone_of_two_different_hues_splits_in_two() {
// A dark tone and a light one, the light one half blue and half pink: two levels give
// three colours, or the blue and pink areas would be one shape of their grey average.
let (w, h) = (60usize, 20usize);
let (dark, blue, pink) = ([0.2, 0.0, 0.0], [0.7, -0.03, -0.1], [0.7, 0.12, 0.0]);
let lab = noisy(w, h, 0.005, |x| {
if x < 30 {
dark
} else if x < 45 {
blue
} else {
pink
}
});
let p = palette(&lab, 2);
assert_eq!(p.len(), 3, "{p:?}");
for c in [dark, blue, pink] {
assert!(p.iter().any(|q| dist2(*q, c).sqrt() < 0.03), "{c:?} not in {p:?}");
}
}
#[test]
fn an_empty_image_still_has_a_palette() {
assert_eq!(palette(&[], 4).len(), 1);
}
}
+380
View File
@@ -0,0 +1,380 @@
//! Curves: each chain fitted once, with its corners kept, its runs simplified (Douglas–Peucker)
//! and drawn as Catmull-Rom curves.
use super::{Pt, Settings};
/// `a - b`.
fn sub(a: Pt, b: Pt) -> Pt {
[a[0] - b[0], a[1] - b[1]]
}
/// Length of `a`.
fn len(a: Pt) -> f32 {
(a[0] * a[0] + a[1] * a[1]).sqrt()
}
/// Fits one chain: corners kept, runs simplified and smoothed. Its ends stay where they are, so
/// chains meeting at a junction still meet.
pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec<Pt> {
let n = pts.len();
if n < 3 {
return pts.to_vec();
}
let original = pts;
let rounded = round_off(pts, closed, s.rounding());
let pts = &rounded[..];
let at = |i: isize| pts[i.rem_euclid(n as isize) as usize];
// Turn at each point, measured over a few cracks either side.
const K: isize = 3;
let turn = |i: isize| {
let (a, b) = (sub(at(i), at(i - K)), sub(at(i + K), at(i)));
let (la, lb) = (len(a), len(b));
if la < 1e-6 || lb < 1e-6 { 0.0 } else { ((a[0] * b[0] + a[1] * b[1]) / (la * lb)).clamp(-1.0, 1.0).acos() }
};
let span = if closed { 0..n as isize } else { K..(n as isize - K) };
let turns: Vec<(isize, f32)> = span.clone().map(|i| (i, turn(i))).collect();
let limit = s.corner_turn();
let mut corners: Vec<usize> = Vec::new();
for &(i, t) in &turns {
if t <= limit {
continue;
}
// Only the sharpest point of a bend.
let sharpest = (-K..=K).all(|o| {
let j = i + o;
let in_span = closed || span.contains(&j);
!in_span || o == 0 || {
let tj = turn(j);
tj < t || (tj == t && o > 0)
}
});
if sharpest {
corners.push(i.rem_euclid(n as isize) as usize);
}
}
// Square a corner back where the pixels cut it: where the straight runs either side meet.
let mut p = pts.to_vec();
for &i in &corners {
let ii = i as isize;
if let Some(q) = intersect(at(ii - K), at(ii - 1), at(ii + 1), at(ii + K))
&& len(sub(q, at(ii))) <= 1.0
{
p[i] = q;
}
}
let mut keep: Vec<usize> = if closed { corners.clone() } else { std::iter::once(0).chain(corners.iter().copied()).chain(std::iter::once(n - 1)).collect() };
keep.sort_unstable();
keep.dedup();
let mut is_corner = vec![false; n];
for &k in &keep {
is_corner[k] = true;
}
if closed && keep.len() < 2 {
// A smooth loop: split it at two far-apart points (not corners) to simplify each half.
let a = keep.first().copied().unwrap_or(0);
let far = (0..n).max_by(|&i, &j| len(sub(p[i], p[a])).total_cmp(&len(sub(p[j], p[a])))).unwrap_or(n / 2);
keep = if a == far { vec![a, (a + n / 2) % n] } else { vec![a.min(far), a.max(far)] };
}
// Douglas–Peucker between consecutive kept points.
let tol = s.tolerance();
let mut verts: Vec<usize> = Vec::new();
let pieces = if closed { keep.len() } else { keep.len() - 1 };
for k in 0..pieces {
let a = keep[k];
let b = if k + 1 < keep.len() { keep[k + 1] } else { keep[0] + n };
verts.push(a);
let idx: Vec<usize> = (a..=b).collect();
let mut marks = vec![false; idx.len()];
simplify(&idx.iter().map(|&i| p[i % n]).collect::<Vec<_>>(), 0, idx.len() - 1, tol, &mut marks);
verts.extend(idx.iter().zip(&marks).skip(1).take(idx.len().saturating_sub(2)).filter(|m| *m.1).map(|(&i, _)| i % n));
}
if !closed {
verts.push(n - 1);
}
let v: Vec<Pt> = verts.iter().map(|&i| p[i]).collect();
let corner: Vec<bool> = verts.iter().map(|&i| is_corner[i]).collect();
let out = curves(&v, &corner, closed);
if closed {
let before = signed_area(original);
let after = signed_area(&out);
// Region merging decides which islands survive. Rounding and simplification must not
// erase one (or reverse its hole): keep the original shared outline if half its area
// would be lost. Close it explicitly because the fill consumes consecutive segments.
if before * after <= 0.0 || after.abs() < before.abs() * 0.5 {
let mut out = original.to_vec();
out.extend(original.first().copied());
return out;
}
}
out
}
/// Signed loop area, relative to its first point to avoid cancellation far from the origin.
fn signed_area(pts: &[Pt]) -> f64 {
let Some(origin) = pts.first() else { return 0.0 };
pts.iter()
.zip(pts.iter().cycle().skip(1))
.map(|(a, b)| {
let (ax, ay) = (f64::from(a[0]) - f64::from(origin[0]), f64::from(a[1]) - f64::from(origin[1]));
let (bx, by) = (f64::from(b[0]) - f64::from(origin[0]), f64::from(b[1]) - f64::from(origin[1]));
ax * by - bx * ay
})
.sum::<f64>()
* 0.5
}
/// Two passes of a moving average over `r` points either side; an open chain's ends (junctions)
/// stay put, and the window shrinks towards them so they don't pull the outline.
fn round_off(pts: &[Pt], closed: bool, r: usize) -> Vec<Pt> {
let n = pts.len();
let mut p = pts.to_vec();
if r == 0 || n < 3 {
return p;
}
for _ in 0..2 {
let src = p.clone();
for (i, q) in p.iter_mut().enumerate() {
let rr = if closed { r.min((n - 1) / 2) } else { r.min(i).min(n - 1 - i) };
if rr == 0 {
continue;
}
let (mut sx, mut sy) = (0.0f32, 0.0f32);
for o in -(rr as isize)..=rr as isize {
let s = src[(i as isize + o).rem_euclid(n as isize) as usize];
sx += s[0];
sy += s[1];
}
let k = (2 * rr + 1) as f32;
*q = [sx / k, sy / k];
}
}
p
}
/// Where the lines through `a0`–`a1` and `b0`–`b1` cross, if they aren't parallel.
fn intersect(a0: Pt, a1: Pt, b0: Pt, b1: Pt) -> Option<Pt> {
let (da, db) = (sub(a1, a0), sub(b1, b0));
let den = da[0] * db[1] - da[1] * db[0];
if den.abs() < 1e-6 {
return None;
}
let t = ((b0[0] - a0[0]) * db[1] - (b0[1] - a0[1]) * db[0]) / den;
Some([a0[0] + da[0] * t, a0[1] + da[1] * t])
}
/// Marks the points of `p[a..=b]` that Douglas–Peucker keeps at tolerance `tol`.
fn simplify(p: &[Pt], a: usize, b: usize, tol: f32, marks: &mut [bool]) {
let mut stack = vec![(a, b)];
while let Some((a, b)) = stack.pop() {
if b <= a + 1 {
continue;
}
let (pa, d) = (p[a], sub(p[b], p[a]));
let l = len(d);
let mut best = (a, 0.0f32);
for (i, q) in p.iter().enumerate().take(b).skip(a + 1) {
let e = sub(*q, pa);
let dist = if l < 1e-6 { len(e) } else { (e[0] * d[1] - e[1] * d[0]).abs() / l };
if dist > best.1 {
best = (i, dist);
}
}
if best.1 > tol {
marks[best.0] = true;
stack.push((a, best.0));
stack.push((best.0, b));
}
}
}
/// Catmull-Rom curves through `v` (one-sided at corners), flattened to a polyline.
fn curves(v: &[Pt], corner: &[bool], closed: bool) -> Vec<Pt> {
let m = v.len();
if m < 2 {
return v.to_vec();
}
let at = |i: isize| v[i.rem_euclid(m as isize) as usize];
let sharp = |i: isize| (!closed && (i <= 0 || i >= m as isize - 1)) || corner[i.rem_euclid(m as isize) as usize];
let mut out = vec![v[0]];
let segs = if closed { m } else { m - 1 };
for i in 0..segs as isize {
let (p0, p1) = (at(i), at(i + 1));
let d = len(sub(p1, p0));
let (s0, s1) = (sharp(i), sharp(i + 1));
if s0 && s1 || d < 1e-6 {
out.push(p1);
continue;
}
// Tangents scaled to this segment, so short and long neighbours don't overshoot.
let t0 = if s0 {
sub(p1, p0)
} else {
let dp = len(sub(p0, at(i - 1)));
let k = d / (dp + d).max(1e-6);
let t = sub(p1, at(i - 1));
[t[0] * k, t[1] * k]
};
let t1 = if s1 {
sub(p1, p0)
} else {
let dn = len(sub(at(i + 2), p1));
let k = d / (d + dn).max(1e-6);
let t = sub(at(i + 2), p0);
[t[0] * k, t[1] * k]
};
let c1 = [p0[0] + t0[0] / 3.0, p0[1] + t0[1] / 3.0];
let c2 = [p1[0] - t1[0] / 3.0, p1[1] - t1[1] / 3.0];
let steps = ((d / 1.5).ceil() as usize).clamp(2, 24);
for k in 1..=steps {
let t = k as f32 / steps as f32;
let u = 1.0 - t;
let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
out.push([b0 * p0[0] + b1 * c1[0] + b2 * c2[0] + b3 * p1[0], b0 * p0[1] + b1 * c1[1] + b2 * c2[1] + b3 * p1[1]]);
}
}
out
}
/// Bounding box `[x0, y0, x1, y1]` of `pts`.
pub(super) fn bounds(pts: &[Pt]) -> [f32; 4] {
pts.iter().fold([f32::MAX, f32::MAX, f32::MIN, f32::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])])
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(fidelity: f32) -> Settings {
Settings { levels: 4, simplicity: 2.0, fidelity }
}
/// The outline of a square from (2, 2) to (12, 12), one point per unit, starting at a corner.
fn square() -> Vec<Pt> {
let mut p = Vec::new();
for i in 0..10 {
p.push([2.0 + i as f32, 2.0]);
}
for i in 0..10 {
p.push([12.0, 2.0 + i as f32]);
}
for i in 0..10 {
p.push([12.0 - i as f32, 12.0]);
}
for i in 0..10 {
p.push([2.0, 12.0 - i as f32]);
}
p
}
fn close(a: Pt, b: Pt) -> bool {
len(sub(a, b)) < 1e-3
}
#[test]
fn douglas_peucker_keeps_only_points_off_the_line() {
// An L, slightly ragged: only its corner is further from the end-to-end line than the
// tolerance, and the two arms are straight within it.
let wobble = |i: usize| 0.05 * (i % 2) as f32;
let p: Vec<Pt> = (0..9).map(|i| if i <= 4 { [i as f32, wobble(i)] } else { [4.0 + wobble(i), (i - 4) as f32] }).collect();
let mut marks = vec![false; p.len()];
simplify(&p, 0, 8, 0.5, &mut marks);
assert_eq!(marks.iter().filter(|m| **m).count(), 1, "{marks:?}");
assert!(marks[4]);
let mut marks = vec![false; p.len()];
simplify(&p, 0, 8, 3.0, &mut marks);
assert!(marks.iter().all(|m| !m), "under the tolerance nothing is kept");
}
#[test]
fn lines_cross_where_they_should() {
let q = intersect([0.0, 0.0], [1.0, 1.0], [0.0, 2.0], [1.0, 1.0]);
assert!(q.is_some_and(|q| close(q, [1.0, 1.0])), "{q:?}");
assert_eq!(intersect([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [2.0, 1.0]), None);
}
#[test]
fn rounding_off_keeps_an_open_chains_ends() {
let p: Vec<Pt> = (0..12).map(|i| [i as f32, if i % 2 == 0 { 0.0 } else { 1.0 }]).collect();
let r = round_off(&p, false, 3);
assert_eq!((r[0], r[11]), (p[0], p[11]));
assert!(r[5][1] > 0.2 && r[5][1] < 0.8, "the zigzag is averaged out: {:?}", r[5]);
assert_eq!(round_off(&p, false, 0), p);
}
#[test]
fn a_square_keeps_its_four_corners_and_straight_sides() {
let out = fit(&square(), true, &settings(2.0));
for c in [[2.0, 2.0], [12.0, 2.0], [12.0, 12.0], [2.0, 12.0]] {
assert!(out.iter().any(|p| close(*p, c)), "corner {c:?} lost: {out:?}");
}
let on_side = |p: &Pt| [p[0] - 2.0, p[0] - 12.0, p[1] - 2.0, p[1] - 12.0].iter().any(|d| d.abs() < 1e-3);
assert!(out.iter().all(on_side), "{out:?}");
assert!(out.len() < 10, "the sides are simplified to their ends: {out:?}");
}
#[test]
fn a_rounded_corner_is_squared_back() {
// The pixels cut the corner at (12, 2): the outline goes (11, 2), (12, 3) instead.
let mut p = square();
p[10] = [11.5, 2.5];
let out = fit(&p, true, &settings(2.0));
assert!(out.iter().any(|q| close(*q, [12.0, 2.0])), "{out:?}");
}
#[test]
fn an_open_chain_ends_where_it_started_at_any_fidelity() {
let p: Vec<Pt> = (0..30).map(|i| [i as f32, (i as f32 / 5.0).sin() * 4.0 + 0.3 * (i % 3) as f32]).collect();
for f in [1.0, 2.0, 3.0] {
let out = fit(&p, false, &settings(f));
assert_eq!((out.first(), out.last()), (p.first(), p.last()), "fidelity {f}");
}
}
#[test]
fn curves_pass_through_their_vertices() {
let v = [[0.0, 0.0], [4.0, 3.0], [8.0, 0.0], [12.0, 4.0]];
let out = curves(&v, &[false; 4], false);
for p in v {
assert!(out.iter().any(|q| close(*q, p)), "{p:?} missed: {out:?}");
}
assert!(out.len() > 8, "flattened into steps: {}", out.len());
// Between two corners a segment is straight.
assert_eq!(curves(&v, &[true; 4], false), v.to_vec());
}
#[test]
fn fitting_keeps_small_islands_and_their_winding() {
use super::super::edges::follow_edges;
let (w, h) = (32, 32);
for size in [2, 3, 4, 6] {
let reg: Vec<u32> = (0..w * h).map(|i| u32::from((12..12 + size).contains(&(i % w)) && (12..12 + size).contains(&(i / w)))).collect();
let colours = [[0.2; 3], [0.9; 3]];
let c: Vec<_> = reg.iter().map(|&r| colours[r as usize]).collect();
let (chains, _) = follow_edges(&reg, &colours, &c, w, h);
let (pts, closed) = chains.iter().find(|(_, closed)| *closed).unwrap();
assert!(*closed);
let area = |p: &[Pt]| p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64).sum::<f64>() / 2.0;
let original = area(pts);
for simplicity in [0.0, 2.0] {
let s = Settings { simplicity, ..settings(2.0) };
if size * size < s.min_area() as usize {
continue;
}
for fidelity in [1.0, 1.5, 2.0, 3.0] {
let out = fit(pts, true, &Settings { fidelity, ..s });
let fitted = area(&out);
assert!(
fitted * original > 0.0 && fitted.abs() >= original.abs() * 0.5,
"{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {original} -> {fitted}"
);
assert_eq!(out.first(), out.last(), "fallbacks must close the loop for the fill");
}
}
}
}
#[test]
fn bounds_cover_every_point() {
assert_eq!(bounds(&[[1.0, 5.0], [-2.0, 3.0], [4.0, 4.0]]), [-2.0, 3.0, 4.0, 5.0]);
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Edges: the cracks between regions, linked into chains from junction to junction, each crack
//! placed where the anti-aliasing says the edge is.
use super::{OUT, Pt, Px};
use crate::par;
/// A chain's points, and whether it is closed.
pub(super) type Chain = (Vec<Pt>, bool);
/// A pixel, or a lattice point (the corner at a pixel's top left).
type Cell = (i64, i64);
/// A step along the lattice, y down.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Dir {
E,
W,
S,
N,
}
/// Links the cracks between regions into chains. Each chain is its points (the junctions it runs
/// between, if any, and one sub-pixel point per crack) and whether it is closed; its sides are
/// the regions on its left and right (y down).
pub(super) fn follow_edges(reg: &[u32], colours: &[Px], c: &[Px], w: usize, h: usize) -> (Vec<Chain>, Vec<(u32, u32)>) {
let (wi, hi) = (w as i64, h as i64);
let at = |x: i64, y: i64| -> u32 { if x < 0 || y < 0 || x >= wi || y >= hi { OUT } else { reg[y as usize * w + x as usize] } };
let hcount = w * (h + 1);
// Horizontal crack (x, y)–(x+1, y) is `y * w + x`; vertical (x, y)–(x, y+1) follows them.
let hc = |x: i64, y: i64| (y as usize) * w + x as usize;
let vc = |x: i64, y: i64| hcount + (y as usize) * (w + 1) + x as usize;
let mut visited = vec![false; hcount + (w + 1) * h];
// The crack leaving lattice point (x, y) in `d`: its id, the pixels on its left and right,
// and the point it reaches.
let step = |x: i64, y: i64, d: Dir| -> Option<(usize, Cell, Cell, Cell)> {
match d {
Dir::E if x < wi => Some((hc(x, y), (x, y - 1), (x, y), (x + 1, y))),
Dir::W if x > 0 => Some((hc(x - 1, y), (x - 1, y), (x - 1, y - 1), (x - 1, y))),
Dir::S if y < hi => Some((vc(x, y), (x, y), (x - 1, y), (x, y + 1))),
Dir::N if y > 0 => Some((vc(x, y - 1), (x - 1, y - 1), (x, y - 1), (x, y - 1))),
_ => None,
}
};
// Which cracks separate two regions, and which lattice points are junctions, worked out
// once (in parallel) before the walk.
let mut is_edge = vec![false; visited.len()];
let (hb, vb) = is_edge.split_at_mut(hcount);
par::chunks_mut(hb, w, true, |y, row| {
for (x, b) in row.iter_mut().enumerate() {
*b = at(x as i64, y as i64 - 1) != at(x as i64, y as i64);
}
});
par::chunks_mut(vb, w + 1, true, |y, row| {
for (x, b) in row.iter_mut().enumerate() {
*b = at(x as i64 - 1, y as i64) != at(x as i64, y as i64);
}
});
let boundary = |x: i64, y: i64, d: Dir| step(x, y, d).filter(|s| is_edge[s.0]);
let mut is_junction = vec![false; (w + 1) * (h + 1)];
par::chunks_mut(&mut is_junction, w + 1, true, |y, row| {
let y = y as i64;
for (x, j) in row.iter_mut().enumerate() {
let x = x as i64;
let deg = [Dir::E, Dir::W, Dir::S, Dir::N].iter().filter(|&&d| boundary(x, y, d).is_some()).count();
*j = deg >= 3 || (deg > 0 && (x == 0 || x == wi) && (y == 0 || y == hi));
}
});
let junction = |x: i64, y: i64| is_junction[y as usize * (w + 1) + x as usize];
// One point per crack: its middle, moved along the normal from left to right pixel by what
// their colours say about where the edge really is.
let crack_point = |l: (i64, i64), r: (i64, i64)| -> Pt {
let mid = [(l.0 + r.0 + 1) as f32 / 2.0, (l.1 + r.1 + 1) as f32 / 2.0];
let (n0, n1) = ((r.0 - l.0) as f32, (r.1 - l.1) as f32);
let (ra, rb) = (at(l.0, l.1), at(r.0, r.1));
let (Some(ca), Some(cb)) = (colours.get(ra as usize), colours.get(rb as usize)) else { return mid };
let d = [cb[0] - ca[0], cb[1] - ca[1], cb[2] - ca[2]];
let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if dd < 1e-4 {
return mid;
}
// How far each pixel's colour is from its own region's towards the other's (0–1).
let frac = |p: (i64, i64)| {
let q = c[p.1 as usize * w + p.0 as usize];
((q[0] - ca[0]) * d[0] + (q[1] - ca[1]) * d[1] + (q[2] - ca[2]) * d[2]) / dd
};
let (fl, fr) = (frac(l).clamp(0.0, 1.0), frac(r).clamp(0.0, 1.0));
let off = ((1.0 - fr) - fl).clamp(-0.5, 0.5);
[mid[0] + n0 * off, mid[1] + n1 * off]
};
let mut chains = Vec::new();
let mut sides = Vec::new();
let mut walk = |x: i64, y: i64, first: Dir, visited: &mut Vec<bool>| {
let open = junction(x, y);
let mut pts = if open { vec![[x as f32, y as f32]] } else { Vec::new() };
let (mut cx, mut cy, mut d) = (x, y, first);
let mut side = (OUT, OUT);
let mut closed = !open;
while let Some((id, l, r, to)) = boundary(cx, cy, d) {
if visited[id] {
break;
}
visited[id] = true;
if pts.len() <= usize::from(open) {
side = (at(l.0, l.1), at(r.0, r.1));
}
pts.push(crack_point(l, r));
(cx, cy) = to;
if junction(cx, cy) {
pts.push([cx as f32, cy as f32]);
closed = false;
break;
}
let back = match d {
Dir::E => Dir::W,
Dir::W => Dir::E,
Dir::S => Dir::N,
Dir::N => Dir::S,
};
let Some(next) = [Dir::E, Dir::S, Dir::W, Dir::N].into_iter().find(|&nd| nd != back && boundary(cx, cy, nd).is_some_and(|b| !visited[b.0])) else {
break;
};
d = next;
}
if pts.len() >= 2 {
chains.push((pts, closed));
sides.push(side);
}
};
for y in 0..=hi {
for x in 0..=wi {
if junction(x, y) {
for d in [Dir::E, Dir::S, Dir::W, Dir::N] {
if boundary(x, y, d).is_some_and(|b| !visited[b.0]) {
walk(x, y, d, &mut visited);
}
}
}
}
}
// What is left are closed loops with no junction (an island inside one other region).
for y in 0..=hi {
for x in 0..=wi {
for d in [Dir::E, Dir::S] {
if boundary(x, y, d).is_some_and(|b| !visited[b.0]) {
walk(x, y, d, &mut visited);
}
}
}
}
(chains, sides)
}
#[cfg(test)]
mod tests {
use super::*;
/// Regions drawn as digits, one per pixel, with grey `colours` (region `k` is `k / 4`).
fn regions(rows: &[&str]) -> (Vec<u32>, Vec<Px>, usize, usize) {
let reg: Vec<u32> = rows.iter().flat_map(|r| r.bytes()).map(|b| u32::from(b - b'0')).collect();
let n = reg.iter().max().map_or(0, |m| m + 1);
(reg, (0..n).map(|k| [k as f32 / 4.0; 3]).collect(), rows[0].len(), rows.len())
}
/// The chain whose sides are regions `a` and `b`, either way round.
fn between(chains: &[Chain], sides: &[(u32, u32)], a: u32, b: u32) -> Vec<Chain> {
chains.iter().zip(sides).filter(|(_, s)| **s == (a, b) || **s == (b, a)).map(|(c, _)| c.clone()).collect()
}
#[test]
fn the_edge_between_two_regions_is_one_chain_between_junctions() {
let (reg, colours, w, h) = regions(&["000111", "000111", "000111", "000111"]);
let c: Vec<Px> = reg.iter().map(|&r| colours[r as usize]).collect();
let (chains, sides) = follow_edges(&reg, &colours, &c, w, h);
let inner = between(&chains, &sides, 0, 1);
assert_eq!(inner.len(), 1, "{chains:?}");
let (pts, closed) = &inner[0];
assert!(!closed);
// From junction to junction (where it meets the canvas edge), one point per crack.
let ends = [pts[0], pts[pts.len() - 1]];
assert!(ends.contains(&[3.0, 0.0]) && ends.contains(&[3.0, 4.0]), "{pts:?}");
assert_eq!(pts.len(), 2 + 4);
assert!(pts.iter().all(|p| p[0] == 3.0), "flat colours: the edge is on the crack: {pts:?}");
// Every crack between two regions, the canvas edge included, is in exactly one chain.
let cracks: usize = chains.iter().map(|(p, closed)| p.len() - if *closed { 0 } else { 2 }).sum();
assert_eq!(cracks, 4 + 2 * (w + h));
}
#[test]
fn an_island_is_one_closed_chain() {
let (reg, colours, w, h) = regions(&["00000", "01100", "01100", "00000"]);
let c: Vec<Px> = reg.iter().map(|&r| colours[r as usize]).collect();
let (chains, sides) = follow_edges(&reg, &colours, &c, w, h);
let island = between(&chains, &sides, 0, 1);
assert_eq!(island.len(), 1);
assert!(island[0].1, "closed");
assert_eq!(island[0].0.len(), 8, "one point per crack around the 2x2 island");
}
#[test]
fn an_anti_aliased_edge_is_placed_inside_its_pixel() {
// Region 0 is black and region 1 white; the last column of region 0 is half white, so
// the edge runs down the middle of that column.
let (reg, _, w, h) = regions(&["000111", "000111", "000111"]);
let colours = vec![[0.0; 3], [1.0; 3]];
let c: Vec<Px> = reg.iter().enumerate().map(|(i, &r)| if i % w == 2 { [0.5; 3] } else { colours[r as usize] }).collect();
let (chains, sides) = follow_edges(&reg, &colours, &c, w, h);
let inner = between(&chains, &sides, 0, 1);
let pts = &inner[0].0;
assert!(pts[1..pts.len() - 1].iter().all(|p| (p[0] - 2.5).abs() < 1e-5), "{pts:?}");
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Fill: exact-area scan conversion of the traced shapes, so edges are anti-aliased and shapes
//! that share an edge sum to full coverage.
use super::Pt;
/// Accumulates the signed area of segment `a`–`b` into `acc` (rows of `stride` cells, `h` rows
/// visible, columns `0..stride - 2` visible): the part left of the tile is drawn down its left
/// edge (it still winds the pixels to its right), the part right of it is dropped.
pub(super) fn line_clipped(acc: &mut [f32], stride: usize, h: usize, a: Pt, b: Pt) {
if !(a[0].is_finite() && a[1].is_finite() && b[0].is_finite() && b[1].is_finite()) || a[1] == b[1] {
return;
}
let wf = (stride - 2) as f32;
// At most the two ends and two crossings; on the stack, as this runs for every segment drawn.
let mut buf = [0.0f32, 1.0, 0.0, 0.0];
let mut n = 2;
for x in [0.0, wf] {
let t = (x - a[0]) / (b[0] - a[0]);
if t > 0.0 && t < 1.0 {
buf[n] = t;
n += 1;
}
}
let cuts = &mut buf[..n];
cuts.sort_by(f32::total_cmp);
for t in cuts.windows(2) {
let p = [a[0] + (b[0] - a[0]) * t[0], a[1] + (b[1] - a[1]) * t[0]];
let q = [a[0] + (b[0] - a[0]) * t[1], a[1] + (b[1] - a[1]) * t[1]];
let mx = (p[0] + q[0]) / 2.0;
if mx >= wf {
continue;
}
if mx < 0.0 {
line(acc, stride, h, [0.0, p[1]], [0.0, q[1]]);
} else {
line(acc, stride, h, [p[0].clamp(0.0, wf), p[1]], [q[0].clamp(0.0, wf), q[1]]);
}
}
}
/// Signed-area accumulation of one segment inside the tile (the scanline method of font
/// rasterisers): after a running sum along each row, a cell holds the coverage winding.
fn line(acc: &mut [f32], stride: usize, h: usize, p0: Pt, p1: Pt) {
if p0[1] == p1[1] {
return;
}
let (dir, p0, p1) = if p0[1] < p1[1] { (1.0f32, p0, p1) } else { (-1.0, p1, p0) };
let dxdy = (p1[0] - p0[0]) / (p1[1] - p0[1]);
let mut x = p0[0];
let ystart = p0[1].max(0.0);
if p0[1] < 0.0 {
x -= p0[1] * dxdy;
}
let yend = p1[1].min(h as f32);
if ystart >= yend {
return;
}
let max_x = (stride - 1) as f32;
for y in (ystart as usize)..(yend.ceil() as usize).min(h) {
let ls = y * stride;
let dy = ((y + 1) as f32).min(p1[1]) - (y as f32).max(p0[1]);
let xnext = x + dxdy * dy;
let d = dy * dir;
let (x0, x1) = if x < xnext { (x, xnext) } else { (xnext, x) };
let (x0, x1) = (x0.clamp(0.0, max_x - 1.0), x1.clamp(0.0, max_x - 1.0));
let x0f = x0.floor();
let x0i = x0f as usize;
let x1c = x1.ceil();
let x1i = x1c as usize;
if x1i <= x0i + 1 {
let xmf = 0.5 * (x0 + x1) - x0f;
acc[ls + x0i] += d - d * xmf;
acc[ls + x0i + 1] += d * xmf;
} else {
let sx = (x1 - x0).recip();
let x0r = x0 - x0f;
let a0 = 0.5 * sx * (1.0 - x0r) * (1.0 - x0r);
let x1r = x1 - x1c + 1.0;
let am = 0.5 * sx * x1r * x1r;
acc[ls + x0i] += d * a0;
if x1i == x0i + 2 {
acc[ls + x0i + 1] += d * (1.0 - a0 - am);
} else {
let a1 = sx * (1.5 - x0r);
acc[ls + x0i + 1] += d * (a1 - a0);
for xi in x0i + 2..x1i - 1 {
acc[ls + xi] += d * sx;
}
let a2 = a1 + (x1i - x0i - 3) as f32 * sx;
acc[ls + x1i - 1] += d * (1.0 - a2 - am);
}
acc[ls + x1i] += d * am;
}
x = xnext;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_square_covers_its_area_exactly() {
let mut acc = vec![0.0f32; 12 * 10];
let sq = [[2.25, 2.5], [7.75, 2.5], [7.75, 6.5], [2.25, 6.5], [2.25, 2.5]];
for s in sq.windows(2) {
line_clipped(&mut acc, 12, 10, s[0], s[1]);
}
let mut total = 0.0;
for y in 0..10 {
let mut sum = 0.0;
for x in 0..10 {
sum += acc[y * 12 + x];
total += sum.abs().min(1.0);
}
}
assert!((total - 5.5 * 4.0).abs() < 1e-4, "{total}");
}
}
+352
View File
@@ -0,0 +1,352 @@
//! Cutout (Filter Gallery › Artistic): the image as flat sheets of cut paper.
//!
//! The image is traced into shapes, and the shapes are filled back in:
//!
//! 1. **Smooth** within tones, edges kept: a bilateral filter (C. Tomasi and R. Manduchi,
//! "Bilateral Filtering for Gray and Color Images", 1998) in Oklab, so a photo's noise doesn't
//! decide pixel by pixel which of two colours a pixel gets.
//! 2. **Palette**: median cut (Heckbert 1982) then k-means over the smoothed colours, Number of
//! Levels tones of the picture's own; a tone made of two clearly different hues splits in two,
//! and colours only noise apart are merged.
//! 3. **Regions**: 4-connected areas of one palette colour. Regions smaller than the Edge
//! Simplicity area, or thin slivers (the blends along an edge between two other colours), are
//! merged smallest first into the neighbour they share most border with. Each region is filled
//! with the mean colour of its own pixels.
//! 4. **Edges**: the cracks between regions are linked into chains from junction to junction
//! (where three regions meet, or the canvas edge). Each crack is moved along its normal to where
//! the pixels on either side say the edge is (an anti-aliased pixel is part one colour, part the
//! other), so outlines are right to a fraction of a pixel. A chain is fitted once, so two
//! neighbouring shapes share one and the same edge.
//! 5. **Curves**: turns sharper than Edge Fidelity allows are kept as corners (squared back where
//! the pixels rounded them); the runs between are simplified to the Edge Simplicity tolerance
//! (Douglas–Peucker) and drawn as Catmull-Rom curves.
//! 6. **Fill**: each region is scan-converted with exact area coverage, so edges are anti-aliased
//! and the coverages of shapes that share an edge sum to one: no seams.
//!
//! Tracing reads the whole image and runs once; filling runs per tile and gives the same pixels
//! whatever the tiling.
//!
//! Each stage is its own module: [`colour`] (steps 1–2), [`regions`] (3), [`edges`] (4),
//! [`curves`] (5) and [`fill`] (6); [`trace`] runs them in turn.
use photocraft_geom::Rect;
use crate::par;
mod colour;
mod curves;
mod edges;
mod fill;
mod regions;
mod util;
use colour::{nearest, oklab, palette, smooth};
use curves::{bounds, fit};
use edges::follow_edges;
use fill::line_clipped;
use regions::{components, local_paper, merge_regions, paper_colours};
type Px = [f32; 3];
type Pt = [f32; 2];
/// Region id of the area outside the image.
const OUT: u32 = u32::MAX;
/// The filter's three sliders.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Settings {
/// Number of Levels (2–8): the most colours the palette has.
pub levels: usize,
/// Edge Simplicity (0–10): smaller shapes merge and outlines simplify more.
pub simplicity: f32,
/// Edge Fidelity (1–3): more corners are kept.
pub fidelity: f32,
}
impl Settings {
/// Regions with fewer pixels merge into a neighbour.
fn min_area(&self) -> u32 {
(1.0 + self.simplicity.clamp(0.0, 10.0)).powi(2).round() as u32
}
/// Regions thinner than this (twice the area over the perimeter, in pixels) merge too.
fn sliver(&self) -> f32 {
0.6 + 0.1 * self.simplicity.clamp(0.0, 10.0)
}
/// Douglas–Peucker tolerance of the outlines, in pixels: looser with less Edge Fidelity
/// (twice as loose at 1, half at 3).
fn tolerance(&self) -> f32 {
(0.5 + 0.12 * self.simplicity.clamp(0.0, 10.0)) * 2f32.powf(2.0 - self.fidelity.clamp(1.0, 3.0))
}
/// Below Edge Fidelity 2 the outline is rounded off before it is fitted: points averaged with
/// this many neighbours either side (3 at fidelity 1).
fn rounding(&self) -> usize {
((2.0 - self.fidelity.clamp(1.0, 3.0)) * 3.0).round().max(0.0) as usize
}
/// Turns sharper than this stay corners.
fn corner_turn(&self) -> f32 {
(100.0 - 22.5 * self.fidelity.clamp(1.0, 3.0)).to_radians()
}
/// Rounds of edge-preserving smoothing.
fn smooth_rounds(&self) -> usize {
2 + (self.simplicity.clamp(0.0, 10.0) / 4.0) as usize
}
}
/// A traced image: the shapes and their colours, ready to fill any tile.
pub(crate) struct Traced {
/// Document position of the traced window's top-left pixel; outlines are relative to it.
x0: i32,
y0: i32,
/// Fitted edges (polylines), each shared by the one or two regions it separates.
chains: Vec<Vec<Pt>>,
/// Per chain, its bounding box `[x0, y0, x1, y1]`.
chain_boxes: Vec<[f32; 4]>,
regions: Vec<Region>,
/// Width of the traced window, each pixel's region, and its paper colour (the region's
/// lightness with the hue of its own pixels nearby).
width: usize,
reg: Vec<u32>,
field: Vec<Px>,
}
/// One shape: its flat colour, bounding box and outline.
struct Region {
colour: Px,
bbox: [f32; 4],
/// The chains around the region, reversed where the region is on their right.
edges: Vec<(u32, bool)>,
}
/// Traces `c` (sRGB colour of every pixel of `r`, row-major).
pub(crate) fn trace(c: &[Px], r: Rect, s: &Settings) -> Traced {
let (w, h) = (r.width() as usize, r.height() as usize);
let empty = Traced { x0: r.x0, y0: r.y0, chains: Vec::new(), chain_boxes: Vec::new(), regions: Vec::new(), width: 0, reg: Vec::new(), field: Vec::new() };
if w == 0 || h == 0 || c.len() != w * h {
return empty;
}
let mut lab = vec![[0.0f32; 3]; w * h];
par::chunks_mut(&mut lab, w, true, |y, row| {
for (o, p) in row.iter_mut().zip(&c[y * w..(y + 1) * w]) {
*o = oklab(*p);
}
});
let original = lab.clone();
let lab = smooth(lab, w, h, s.smooth_rounds());
let palette = palette(&lab, s.levels.clamp(2, 8));
let mut labels = vec![0u8; w * h];
par::chunks_mut(&mut labels, w, true, |y, row| {
for (o, p) in row.iter_mut().zip(&lab[y * w..(y + 1) * w]) {
*o = nearest(&palette, *p);
}
});
let (comp, n) = components(&labels, w, h);
let (reg, colours) = merge_regions(&comp, n, c, w, h, s);
let (chains, sides) = follow_edges(&reg, &colours, c, w, h);
let chains: Vec<Vec<Pt>> = par::map(chains.len(), true, |i| fit(&chains[i].0, chains[i].1, s));
let chain_boxes: Vec<[f32; 4]> = chains.iter().map(|ch| bounds(ch)).collect();
let paint = paper_colours(&reg, colours.len(), &original);
let field = local_paper(&reg, &paint, &original, w, h, 3.0 + 1.2 * s.simplicity.clamp(0.0, 10.0));
let mut regions: Vec<Region> =
paint.into_iter().map(|colour| Region { colour, bbox: [f32::MAX, f32::MAX, f32::MIN, f32::MIN], edges: Vec::new() }).collect();
for (k, &(left, right)) in sides.iter().enumerate() {
for (side, rev) in [(left, false), (right, true)] {
if let Some(g) = regions.get_mut(side as usize) {
g.edges.push((k as u32, rev));
let b = chain_boxes[k];
g.bbox = [g.bbox[0].min(b[0]), g.bbox[1].min(b[1]), g.bbox[2].max(b[2]), g.bbox[3].max(b[3])];
}
}
}
Traced { x0: r.x0, y0: r.y0, chains, chain_boxes, regions, width: w, reg, field }
}
impl Traced {
/// The cut paper over `r` (document coordinates): sRGB colour per pixel, row-major.
pub(crate) fn render(&self, r: Rect) -> Vec<Px> {
let (w, h) = (r.width() as usize, r.height() as usize);
let mut col = vec![[0.0f32; 3]; w * h];
if w == 0 || h == 0 {
return col;
}
let mut weight = vec![0.0f32; w * h];
let stride = w + 2;
let mut acc = vec![0.0f32; stride * h];
let (ox, oy) = ((self.x0 - r.x0) as f32, (self.y0 - r.y0) as f32);
let (wf, hf) = (w as f32, h as f32);
let (dx, dy) = ((r.x0 - self.x0) as i64, (r.y0 - self.y0) as i64);
let th = self.reg.len() / self.width.max(1);
for (gi, g) in self.regions.iter().enumerate() {
let b = [g.bbox[0] + ox, g.bbox[1] + oy, g.bbox[2] + ox, g.bbox[3] + oy];
if b[2] <= 0.0 || b[0] >= wf || b[3] <= 0.0 || b[1] >= hf {
continue;
}
for &(k, rev) in &g.edges {
let (Some(pts), Some(cb)) = (self.chains.get(k as usize), self.chain_boxes.get(k as usize)) else { continue };
// Chains left of the tile still count (they wind the rows they cross); chains to
// its right or above or below it don't.
if cb[0] + ox >= wf || cb[3] + oy <= 0.0 || cb[1] + oy >= hf {
continue;
}
for seg in pts.windows(2) {
let (a, z) = if rev { (seg[1], seg[0]) } else { (seg[0], seg[1]) };
line_clipped(&mut acc, stride, h, [a[0] + ox, a[1] + oy], [z[0] + ox, z[1] + oy]);
}
}
let y0 = b[1].floor().max(0.0) as usize;
let y1 = (b[3].ceil().max(0.0) as usize).min(h);
let xe = ((b[2].ceil().max(0.0) as usize) + 1).min(w);
for y in y0..y1 {
let row = &mut acc[y * stride..(y + 1) * stride];
let mut sum = 0.0f32;
for (x, v) in row.iter().enumerate().take(xe) {
sum += v;
let cov = sum.abs().min(1.0);
if cov > 0.0 {
let i = y * w + x;
// Inside its own pixels a shape takes the local colour; where it reaches
// into a neighbour's edge pixel, its own flat colour.
let (tx, ty) = (x as i64 + dx, y as i64 + dy);
let t = (tx >= 0 && ty >= 0 && (tx as usize) < self.width && (ty as usize) < th).then(|| ty as usize * self.width + tx as usize);
let paint = match t {
Some(t) if self.reg[t] as usize == gi => self.field[t],
_ => g.colour,
};
col[i] = [col[i][0] + paint[0] * cov, col[i][1] + paint[1] * cov, col[i][2] + paint[2] * cov];
weight[i] += cov;
}
}
row.fill(0.0);
}
}
for (p, &wt) in col.iter_mut().zip(&weight) {
if wt > 1e-6 {
*p = [p[0] / wt, p[1] / wt, p[2] / wt];
}
}
col
}
}
#[cfg(test)]
mod tests {
use super::*;
use colour::dist2;
fn settings(levels: usize, simplicity: f32, fidelity: f32) -> Settings {
Settings { levels, simplicity, fidelity }
}
/// Total coverage of every pixel: each pixel is covered exactly once by the shapes.
fn coverage(t: &Traced, r: Rect) -> Vec<f32> {
let (w, h) = (r.width() as usize, r.height() as usize);
let mut weight = vec![0.0f32; w * h];
let stride = w + 2;
let mut acc = vec![0.0f32; stride * h];
for g in &t.regions {
for &(k, rev) in &g.edges {
for seg in t.chains[k as usize].windows(2) {
let (a, z) = if rev { (seg[1], seg[0]) } else { (seg[0], seg[1]) };
line_clipped(&mut acc, stride, h, a, z);
}
}
for y in 0..h {
let mut sum = 0.0;
for x in 0..w {
sum += acc[y * stride + x];
weight[y * w + x] += sum.abs().min(1.0);
}
acc[y * stride..(y + 1) * stride].fill(0.0);
}
}
weight
}
#[test]
fn shapes_cover_every_pixel_once_with_no_seams() {
// Blobs of four colours touching each other and the canvas edge, with T-junctions,
// saddles and holes.
let (w, h) = (48usize, 36usize);
let pal = [[0.1, 0.1, 0.1], [0.9, 0.2, 0.2], [0.2, 0.7, 0.3], [0.95, 0.95, 0.9]];
let c: Vec<Px> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let k = ((x / 7.0).sin() + (y / 5.0).cos() + (x * y / 90.0).sin()).abs() * 1.6;
pal[(k as usize).min(3)]
})
.collect();
let r = Rect::new(0, 0, w as i32, h as i32);
for fidelity in [1.0, 3.0] {
let t = trace(&c, r, &settings(4, 2.0, fidelity));
assert!(t.regions.len() > 4);
let cov = coverage(&t, r);
let worst = cov.iter().map(|v| (v - 1.0).abs()).fold(0.0, f32::max);
assert!(worst < 1e-3, "coverage off by {worst}");
}
}
#[test]
fn edge_fidelity_changes_the_outlines() {
// It used to set only which bends count as corners: on smooth shapes 0.5% of pixels
// changed from fidelity 1 to 2 and 2.8% from 2 to 3 (now about 9% and 5%).
// Large smooth shapes, as in a photo: few corners, long curved outlines.
let (w, h) = (160usize, 120usize);
let pal = [[0.1, 0.1, 0.1], [0.9, 0.3, 0.2], [0.3, 0.6, 0.9]];
let c: Vec<Px> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let k = ((x / 17.0).sin() * 1.3 + (y / 13.0).cos() + (x * y / 900.0).sin()).abs();
pal[(k as usize).min(2)]
})
.collect();
let r = Rect::new(0, 0, w as i32, h as i32);
let out = |f: f32| trace(&c, r, &settings(3, 2.0, f)).render(r);
let changed = |a: &[Px], b: &[Px]| a.iter().zip(b).filter(|(p, q)| dist2(**p, **q) > 0.01).count() as f32 / a.len() as f32;
let (loose, normal, tight) = (out(1.0), out(2.0), out(3.0));
assert!(changed(&loose, &normal) > 0.04, "fidelity 1 vs 2: {}", changed(&loose, &normal));
assert!(changed(&normal, &tight) > 0.04, "fidelity 2 vs 3: {}", changed(&normal, &tight));
}
#[test]
fn empty_images_stop_before_the_transpose_and_row_passes() {
for r in [Rect::new(0, 0, 0, 4), Rect::new(0, 0, 4, 0), Rect::new(0, 0, 0, 0)] {
let t = trace(&[], r, &settings(4, 2.0, 1.0));
assert!(t.regions.is_empty());
assert!(t.chains.is_empty());
assert!(t.field.is_empty());
}
}
#[test]
fn filling_does_not_depend_on_the_tiling() {
let (w, h) = (40usize, 30usize);
let c: Vec<Px> = (0..w * h).map(|i| if ((i % w) / 6 + (i / w) / 5) % 3 == 0 { [0.8, 0.3, 0.1] } else { [0.1, 0.3, 0.7] }).collect();
let r = Rect::new(10, 20, 10 + w as i32, 20 + h as i32);
let t = trace(&c, r, &settings(3, 1.0, 2.0));
let whole = t.render(r);
for tile in [Rect::new(10, 20, 23, 31), Rect::new(23, 31, 50, 50), Rect::new(37, 20, 50, 33)] {
let part = t.render(tile);
for y in tile.y0..tile.y1 {
for x in tile.x0..tile.x1 {
let a = whole[((y - r.y0) as usize) * w + (x - r.x0) as usize];
let b = part[((y - tile.y0) as usize) * tile.width() as usize + (x - tile.x0) as usize];
assert!(dist2(a, b) < 1e-8, "({x}, {y}): {a:?} vs {b:?}");
}
}
}
}
#[test]
fn noise_merges_into_neighbours_and_noise_apart_colours_are_one() {
let (w, h) = (32usize, 32usize);
let c: Vec<Px> = (0..w * h)
.map(|i| {
let v = 0.3 + crate::photo_util::hash01((i % w) as i64, (i / w) as i64, 5) * 0.06;
if (11..14).contains(&(i % w)) && (11..14).contains(&(i / w)) { [0.9, 0.9, 0.9] } else { [v, v, v] }
})
.collect();
let r = Rect::new(0, 0, w as i32, h as i32);
assert_eq!(trace(&c, r, &settings(6, 0.0, 2.0)).regions.len(), 2, "the speck stays at simplicity 0");
assert_eq!(trace(&c, r, &settings(6, 10.0, 2.0)).regions.len(), 1, "the speck merges at simplicity 10");
}
}
+357
View File
@@ -0,0 +1,357 @@
//! Regions: connected areas of one palette colour, the merge of small ones and slivers, and the
//! paper colour of each region.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};
use std::hash::BuildHasherDefault;
use super::colour::{in_gamut, oklab, srgb};
use super::util::{FxHasher, find, transpose, transpose_u32};
use super::{Px, Settings};
use crate::par;
/// 4-connected components of equal labels, numbered in raster order of their first pixel.
pub(super) fn components(labels: &[u8], w: usize, h: usize) -> (Vec<u32>, usize) {
let mut comp = vec![0u32; w * h];
let mut parent: Vec<u32> = Vec::new();
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let up = (y > 0 && labels[i - w] == labels[i]).then(|| comp[i - w]);
let left = (x > 0 && labels[i - 1] == labels[i]).then(|| comp[i - 1]);
comp[i] = match (up, left) {
(Some(a), Some(b)) => {
let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
let (lo, hi) = (ra.min(rb), ra.max(rb));
parent[hi as usize] = lo;
lo
}
(Some(a), None) | (None, Some(a)) => a,
(None, None) => {
parent.push(parent.len() as u32);
parent.len() as u32 - 1
}
};
}
}
let mut index = vec![u32::MAX; parent.len()];
let mut n = 0u32;
for c in comp.iter_mut() {
let root = find(&mut parent, *c) as usize;
if index[root] == u32::MAX {
index[root] = n;
n += 1;
}
*c = index[root];
}
(comp, n as usize)
}
/// Merges small and sliver components into their neighbours. Returns each pixel's region and
/// each region's mean colour.
pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize, s: &Settings) -> (Vec<u32>, Vec<Px>) {
let mut area = vec![0u32; n];
let mut sum = vec![[0.0f64; 3]; n];
for (&k, p) in comp.iter().zip(c) {
let k = k as usize;
area[k] += 1;
sum[k] = [sum[k][0] + f64::from(p[0]), sum[k][1] + f64::from(p[1]), sum[k][2] + f64::from(p[2])];
}
// Shared border lengths (in cracks) and perimeters, the canvas edge included.
let mut shared: HashMap<u64, u32, BuildHasherDefault<FxHasher>> = HashMap::default();
let mut perim = vec![0u32; n];
for y in 0..h {
for x in 0..w {
let a = comp[y * w + x];
for (nx, ny) in [(x + 1, y), (x, y + 1)] {
if nx < w && ny < h {
let b = comp[ny * w + nx];
if a != b {
*shared.entry((u64::from(a.min(b)) << 32) | u64::from(a.max(b))).or_insert(0) += 1;
}
}
}
perim[a as usize] += u32::from(x == 0) + u32::from(x + 1 == w) + u32::from(y == 0) + u32::from(y + 1 == h);
}
}
let mut adj: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n];
let mut pairs: Vec<(u64, u32)> = shared.into_iter().collect();
pairs.sort_unstable();
for (key, len) in pairs {
let (a, b) = ((key >> 32) as u32, key as u32);
adj[a as usize].push((b, len));
adj[b as usize].push((a, len));
perim[a as usize] += len;
perim[b as usize] += len;
}
let (min_area, sliver) = (s.min_area(), s.sliver());
let mergeable = |a: u32, p: u32| a < min_area || 2.0 * a as f32 / (p.max(1) as f32) < sliver;
let mut parent: Vec<u32> = (0..n as u32).collect();
let mut heap: BinaryHeap<Reverse<(u32, u32)>> = (0..n).filter(|&i| mergeable(area[i], perim[i])).map(|i| Reverse((area[i], i as u32))).collect();
while let Some(Reverse((a, id))) = heap.pop() {
let i = id as usize;
if find(&mut parent, id) != id || area[i] != a || !mergeable(a, perim[i]) {
continue;
}
// The neighbours as they are now, with their total shared border.
let mut nb: Vec<(u32, u32)> = std::mem::take(&mut adj[i]).into_iter().map(|(k, len)| (find(&mut parent, k), len)).filter(|&(k, _)| k != id).collect();
nb.sort_unstable();
nb.dedup_by(|b, a| {
if a.0 == b.0 {
a.1 += b.1;
true
} else {
false
}
});
let Some(&(t, len)) = nb.iter().max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0))) else {
adj[i] = nb;
continue;
};
let ti = t as usize;
parent[i] = t;
area[ti] += area[i];
sum[ti] = [sum[ti][0] + sum[i][0], sum[ti][1] + sum[i][1], sum[ti][2] + sum[i][2]];
perim[ti] = (perim[ti] + perim[i]).saturating_sub(2 * len);
// Appended as they are; resolved and summed when `t` itself merges.
adj[ti].extend(nb.into_iter().filter(|&(k, _)| k != t));
if mergeable(area[ti], perim[ti]) {
heap.push(Reverse((area[ti], t)));
}
}
let mut index = vec![u32::MAX; n];
let mut colours = Vec::new();
let mut reg = vec![0u32; comp.len()];
for (r, &k) in reg.iter_mut().zip(comp) {
let root = find(&mut parent, k) as usize;
if index[root] == u32::MAX {
index[root] = colours.len() as u32;
let a = f64::from(area[root].max(1));
colours.push([(sum[root][0] / a) as f32, (sum[root][1] / a) as f32, (sum[root][2] / a) as f32]);
}
*r = index[root];
}
(reg, colours)
}
/// The paper colour of every region. A plain average of a region's pixels greys it (a face of
/// pink and cream patches averages to beige, hues cancel), and a whole image of averages sits in
/// the middle tones. So in Oklab each region keeps its average hue at the average *strength* of
/// its pixels' colour, and lightness is stretched over the image's regions so the darkest paper is
/// near black and the lightest near white. Out-of-gamut colours lose chroma, not hue.
pub(super) fn paper_colours(reg: &[u32], n: usize, lab: &[Px]) -> Vec<Px> {
let mut acc = vec![[0.0f64; 5]; n];
for (&r, p) in reg.iter().zip(lab) {
if let Some(a) = acc.get_mut(r as usize) {
let chroma = (p[1] * p[1] + p[2] * p[2]).sqrt();
*a = [a[0] + f64::from(p[0]), a[1] + f64::from(p[1]), a[2] + f64::from(p[2]), a[3] + f64::from(chroma), a[4] + 1.0];
}
}
let mean: Vec<(Px, f32, f64)> = acc
.iter()
.map(|a| {
let k = a[4].max(1.0);
([(a[0] / k) as f32, (a[1] / k) as f32, (a[2] / k) as f32], (a[3] / k) as f32, a[4])
})
.collect();
// The lightness range of the image's paper, by area (ignoring the extreme 1% at each end).
let mut by_l: Vec<(f32, f64)> = mean.iter().map(|m| (m.0[0], m.2)).collect();
by_l.sort_by(|a, b| a.0.total_cmp(&b.0));
let total: f64 = by_l.iter().map(|m| m.1).sum();
let at = |q: f64| {
let mut run = 0.0;
for &(l, a) in &by_l {
run += a;
if run >= q * total {
return l;
}
}
by_l.last().map_or(0.5, |m| m.0)
};
let (lo, hi) = (at(0.01), at(0.99));
const STRETCH: f32 = 0.75;
mean.iter()
.map(|(m, chroma, _)| {
let ab = (m[1] * m[1] + m[2] * m[2]).sqrt();
let k = if ab > 1e-5 { chroma / ab } else { 1.0 };
let stretched = if hi - lo > 0.05 { m[0] + (0.04 + (m[0] - lo) / (hi - lo) * 0.88 - m[0]) * STRETCH } else { m[0] };
let stretched = stretched.clamp(0.0, 1.0);
let colour = |l: f32, t: f32| srgb([l, m[1] * k * t, m[2] * k * t]);
if let Some(c) = colour(stretched, 1.0) {
return c;
}
// Colour comes before contrast: a strong colour the stretch took out of gamut (a
// bright blue pushed towards white) moves back towards its own lightness first.
let own = m[0].clamp(0.0, 1.0);
if colour(own, 1.0).is_some() {
let (mut ok, mut bad) = (own, stretched);
for _ in 0..12 {
let l = (ok + bad) / 2.0;
if colour(l, 1.0).is_some() { ok = l } else { bad = l }
}
return colour(ok, 1.0).unwrap_or([ok, ok, ok]);
}
// Even its own lightness can't hold it: keep the stretch, lose chroma (not hue).
let (mut ok, mut bad) = (0.0f32, 1.0f32);
for _ in 0..12 {
let t = (ok + bad) / 2.0;
if colour(stretched, t).is_some() { ok = t } else { bad = t }
}
colour(stretched, ok).unwrap_or([stretched, stretched, stretched])
})
.collect()
}
/// Each pixel's paper colour: its region's lightness (flat across the shape) with the average
/// hue, at the average strength, of the region's own pixels within about `reach` pixels. Colour
/// can drift gently across a large shape (pink cheeks on a cream face) but never crosses a cut.
/// `paper` is each region's colour (from [`paper_colours`]).
pub(super) fn local_paper(reg: &[u32], paper: &[Px], lab: &[Px], w: usize, h: usize, reach: f32) -> Vec<Px> {
let mut v: Vec<Px> = lab.iter().map(|p| [p[1], p[2], (p[1] * p[1] + p[2] * p[2]).sqrt()]).collect();
let regt = transpose_u32(reg, w, h);
let a = (-1.0 / reach.max(0.5)).exp();
for _ in 0..2 {
same_region_rows(&mut v, reg, w, a);
let mut t = transpose(&v, w, h);
same_region_rows(&mut t, &regt, h, a);
v = transpose(&t, h, w);
}
let light: Vec<f32> = paper.iter().map(|c| oklab(*c)[0]).collect();
let mut out = vec![[0.0f32; 3]; w * h];
par::chunks_mut(&mut out, w, true, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * w + x;
let (q, l) = (v[i], light.get(reg[i] as usize).copied().unwrap_or(0.5));
let ab = (q[0] * q[0] + q[1] * q[1]).sqrt();
let k = if ab > 1e-5 { q[2] / ab } else { 1.0 };
*o = in_gamut(l, q[0] * k, q[1] * k);
}
});
out
}
/// Exponential smoothing along each row (both ways) that restarts at every change of region.
fn same_region_rows(v: &mut [Px], reg: &[u32], w: usize, a: f32) {
let mut chunks: Vec<(&mut [Px], &[u32])> = v.chunks_mut(w).zip(reg.chunks(w)).collect();
let f = |(row, rg): &mut (&mut [Px], &[u32])| {
let n = row.len();
let src = row.to_vec();
let mut fwd = vec![([0.0f32; 3], 0.0f32); n];
for x in 0..n {
let (p, d) = if x > 0 && rg[x] == rg[x - 1] { fwd[x - 1] } else { ([0.0; 3], 0.0) };
fwd[x] = ([src[x][0] + a * p[0], src[x][1] + a * p[1], src[x][2] + a * p[2]], 1.0 + a * d);
}
let mut back = ([0.0f32; 3], 0.0f32);
for x in (0..n).rev() {
if x + 1 < n && rg[x] != rg[x + 1] {
back = ([0.0; 3], 0.0);
}
back = ([src[x][0] + a * back.0[0], src[x][1] + a * back.0[1], src[x][2] + a * back.0[2]], 1.0 + a * back.1);
let den = fwd[x].1 + back.1 - 1.0;
row[x] = std::array::from_fn(|c| (fwd[x].0[c] + back.0[c] - src[x][c]) / den);
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
chunks.par_iter_mut().for_each(f);
}
#[cfg(target_arch = "wasm32")]
chunks.iter_mut().for_each(f);
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(simplicity: f32) -> Settings {
Settings { levels: 4, simplicity, fidelity: 2.0 }
}
/// Labels drawn as text, one character per pixel.
fn labels(rows: &[&str]) -> (Vec<u8>, usize, usize) {
(rows.iter().flat_map(|r| r.bytes()).collect(), rows[0].len(), rows.len())
}
#[test]
fn components_are_four_connected_and_numbered_in_raster_order() {
let (l, w, h) = labels(&["aab", "abb", "bba"]);
let (comp, n) = components(&l, w, h);
// The top-left `a`s, the `b`s (one area), then the lone `a` (touching the first only
// diagonally), in the order their first pixels come.
assert_eq!(n, 3);
assert_eq!(comp, vec![0, 0, 1, 0, 1, 1, 1, 1, 2]);
// A U shape: both arms are found as separate runs first and joined further down.
let (l, w, h) = labels(&["a.a", "a.a", "aaa"]);
let (comp, n) = components(&l, w, h);
assert_eq!(n, 2);
assert_eq!(comp[0], comp[2]);
}
/// Regions from labels, with one flat colour per label.
fn merged(rows: &[&str], simplicity: f32) -> (Vec<u32>, Vec<Px>) {
let (l, w, h) = labels(rows);
let c: Vec<Px> = l.iter().map(|&b| [f32::from(b - b'a') / 4.0; 3]).collect();
let (comp, n) = components(&l, w, h);
merge_regions(&comp, n, &c, w, h, &settings(simplicity))
}
#[test]
fn small_regions_merge_into_the_neighbour_they_share_most_border_with() {
let rows = ["aaaaaaaa", "aaaaaaaa", "aacaaaaa", "aaccbbbb", "aaaabbbb", "aaaabbbb", "aaaabbbb"];
// At Edge Simplicity 0 every region is kept.
assert_eq!(merged(&rows, 0.0).1.len(), 3);
// At 1 (regions under four pixels merge) the three `c`s go into the `a`s, which they share seven
// cracks with (one with the `b`s).
let (reg, colours) = merged(&rows, 1.0);
assert_eq!(colours.len(), 2);
assert_eq!(reg[2 * 8 + 2], reg[0]);
assert_ne!(reg[3 * 8 + 4], reg[0]);
// A region's colour is the mean of its pixels: 37 `a`s (0.0) and 3 `c`s (0.5).
let a = colours[reg[0] as usize][0];
assert!((a - 1.5 / 40.0).abs() < 1e-6, "{a}");
}
#[test]
fn slivers_merge_though_they_are_large() {
// A line one pixel wide between two areas: the blend along an edge, not a shape.
let rows: Vec<String> = (0..40).map(|_| format!("{}c{}", "a".repeat(10), "b".repeat(9))).collect();
let rows: Vec<&str> = rows.iter().map(String::as_str).collect();
assert_eq!(merged(&rows, 0.0).1.len(), 3, "kept at Edge Simplicity 0");
let (_, colours) = merged(&rows, 4.0);
assert_eq!(colours.len(), 2, "40 pixels is over the 25 that merge at 4, but it is a sliver");
}
#[test]
fn paper_is_stretched_towards_black_and_white_and_keeps_its_hue() {
// Three regions: a dark and a light grey, and a mid-tone orange.
let lab = [[0.35, 0.0, 0.0], [0.65, 0.0, 0.0], [0.5, 0.06, 0.05]];
let reg: Vec<u32> = (0..300).map(|i| (i / 100) as u32).collect();
let px: Vec<Px> = reg.iter().map(|&r| lab[r as usize]).collect();
let paper: Vec<Px> = paper_colours(&reg, 3, &px).into_iter().map(oklab).collect();
assert!(paper[0][0] < 0.25 && paper[1][0] > 0.75, "{paper:?}");
assert!((paper[2][0] - 0.5).abs() < 0.05, "a middle tone stays in the middle: {paper:?}");
assert!((paper[2][2] / paper[2][1] - 0.05 / 0.06).abs() < 0.05, "hue kept: {paper:?}");
assert!(paper[0][1].abs() < 1e-3 && paper[0][2].abs() < 1e-3, "grey stays grey: {paper:?}");
}
#[test]
fn local_colour_drifts_within_a_shape_but_never_crosses_a_cut() {
// One light region, pink on the left and cream on the right, next to a blue one.
let (w, h) = (30usize, 4usize);
let reg: Vec<u32> = (0..w * h).map(|i| u32::from(i % w >= 20)).collect();
let lab: Vec<Px> = (0..w * h)
.map(|i| match i % w {
0..10 => [0.8, 0.08, 0.02],
10..20 => [0.8, 0.01, 0.04],
_ => [0.4, -0.02, -0.12],
})
.collect();
let paper = [srgb([0.8, 0.04, 0.03]).unwrap_or_default(), srgb([0.4, -0.02, -0.12]).unwrap_or_default()];
let out: Vec<Px> = local_paper(&reg, &paper, &lab, w, h, 3.0).into_iter().map(oklab).collect();
let (left, right, edge) = (out[w + 2], out[w + 17], out[w + 19]);
assert!(left[1] > right[1] + 0.03, "the pink side is pinker: {left:?} vs {right:?}");
assert!((left[0] - right[0]).abs() < 0.01, "one lightness across the shape: {left:?} vs {right:?}");
assert!(edge[2] > 0.0, "no blue from the next shape at the cut: {edge:?}");
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Small helpers shared by the Cutout stages.
use std::hash::Hasher;
use super::Px;
use crate::par;
/// Row-major `w`×`h` colours as `h`×`w` (columns become rows), so a pass along rows can run
/// along columns too.
pub(super) fn transpose(buf: &[Px], w: usize, h: usize) -> Vec<Px> {
let mut out = vec![[0.0f32; 3]; w * h];
par::chunks_mut(&mut out, h, true, |x, col| {
for (y, p) in col.iter_mut().enumerate() {
*p = buf[y * w + x];
}
});
out
}
/// [`transpose`] for region ids.
pub(super) fn transpose_u32(buf: &[u32], w: usize, h: usize) -> Vec<u32> {
let mut out = vec![0u32; w * h];
for (x, col) in out.chunks_mut(h).enumerate() {
for (y, p) in col.iter_mut().enumerate() {
*p = buf[y * w + x];
}
}
out
}
/// Union-find root of `i`, halving the path on the way.
pub(super) fn find(parent: &mut [u32], mut i: u32) -> u32 {
while parent[i as usize] != i {
let p = parent[i as usize];
parent[i as usize] = parent[p as usize];
i = p;
}
i
}
/// A fast deterministic hasher for the region adjacency map.
#[derive(Default)]
pub(super) struct FxHasher(u64);
impl Hasher for FxHasher {
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.write_u64(u64::from(b));
}
}
fn write_u64(&mut self, v: u64) {
self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x517c_c1b7_2722_0a95);
}
fn finish(&self) -> u64 {
self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transposing_twice_gives_the_image_back() {
let (w, h) = (5usize, 3usize);
let c: Vec<Px> = (0..w * h).map(|i| [i as f32, 0.0, 0.0]).collect();
let t = transpose(&c, w, h);
assert_eq!(t[1], c[w], "the second value of the first column is the first of the second row");
assert_eq!(transpose(&t, h, w), c);
let ids: Vec<u32> = (0..(w * h) as u32).collect();
assert_eq!(transpose_u32(&ids, w, h), t.iter().map(|p| p[0] as u32).collect::<Vec<_>>());
assert_eq!(transpose_u32(&transpose_u32(&ids, w, h), h, w), ids);
}
#[test]
fn find_returns_the_root_and_shortens_the_path() {
// 4 -> 3 -> 2 -> 1 -> 0.
let mut parent = vec![0, 0, 1, 2, 3];
assert_eq!(find(&mut parent, 4), 0);
assert_eq!(find(&mut parent, 0), 0);
assert!(parent[4] < 3, "the path was halved: {parent:?}");
assert!(parent.iter().all(|&p| p <= 2));
}
}
+15
View File
@@ -26,6 +26,7 @@ mod blur2;
pub mod camera_raw;
pub mod color_to_alpha;
pub mod content_aware;
mod cutout;
mod denoise;
pub mod dff;
pub mod direct;
@@ -72,6 +73,7 @@ pub mod seam;
pub mod segment;
pub mod selection;
mod selection_blur;
pub mod separable;
mod shape_blur;
mod sharpen;
pub mod stack;
@@ -564,6 +566,9 @@ impl FilterParams {
pub fn halo(&self) -> Halo {
let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1));
match self {
// Small Gaussian radii use the calibrated seven-tap kernel as well: its reach is
// three pixels even when ceil(3 * radius) + 1 would be smaller.
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } if (0.05..1.0).contains(radius) => Halo::Radius(3),
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
FilterParams::SmartSharpen { radius, .. } => g(*radius),
@@ -730,6 +735,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::PathBlur { paths } => r(paths.iter().map(|p| p.speed.abs()).fold(0.0, f32::max) / 2.0 + 1.0),
FilterParams::Custom { .. } => r(2.0),
FilterParams::DeInterlace { .. } => r(1.0),
FilterParams::FilterGallery { effects } if artistic_fx::is_global(effects) => Halo::Bounds,
FilterParams::FilterGallery { effects } => Halo::Radius(artistic_fx::reach(effects)),
_ => Halo::Radius(0),
}
@@ -1120,6 +1126,14 @@ fn apply_tiled_with_impl(
}
}
let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area)));
// A Filter Gallery stack with a Cutout traces the whole image once; tiles only fill it in.
let gallery = match (params, &shared) {
(FilterParams::FilterGallery { effects }, Some(src)) => artistic_fx::Prepared::new(effects, src, &ctx),
_ => None,
};
if ctl.cancelled() {
return None;
}
// Shape rasterization depends only on params, not on a tile's pixels.
let shape = match params {
FilterParams::ShapeBlur { radius, shape } => {
@@ -1197,6 +1211,7 @@ fn apply_tiled_with_impl(
}
};
let Some(mut data) = (match (&shape, &radial) {
_ if gallery.is_some() => gallery.as_ref().map(|g| g.run(src, *t, &ctx)),
(Some(shape), _) => shape.checked_run(src, *t, &ctx),
(None, Some(plan)) => match plan.filter(src, *t, &ctx, ctl) {
Some(data) => Some(data),
+20 -14
View File
@@ -35,7 +35,9 @@ 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
/// −100–100. 100% moves the edge by its soft width: `max(radius, feather, 2 × smooth σ)`,
/// at least 1 px, so a hard edge moves a pixel (Photoshop's Shift Edge moves soft borders;
/// a band of several pixels around hard line art fills it in solid). Values between whole
/// pixels blend the two neighbouring shifts, so the slider does not jump.
pub shift_edge: f32,
}
@@ -60,9 +62,11 @@ 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.
/// Signed shift in pixels, fractional. 100% is the edge's soft width (at least 1 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);
let soft = self.radius.max(self.feather).max(2.0 * self.smooth_sigma()).max(1.0);
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * soft;
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
@@ -765,22 +769,24 @@ mod tests {
assert!(f[5 * w + 29] > 0.5 && f[5 * w + 29] < 0.8 && f[5 * w + 31] > 0.1);
let c = refine_buffer(None, &f, w, h, &RefineParams { contrast: 100.0, ..Default::default() });
assert!(c[5 * w + 29] == 1.0 && c[5 * w + 31] == 0.0);
// A hard edge (no radius, feather or smooth) moves 1 px at 100%, so 50% is half a
// pixel. The pixel just outside fades in instead of snapping on.
let grown = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 100.0, ..Default::default() });
assert_eq!(grown[5 * w + 33], 1.0);
assert_eq!(grown[5 * w + 34], 0.0);
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_eq!(grown[5 * w + 30], 1.0);
assert_eq!(grown[5 * w + 31], 0.0);
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -100.0, ..Default::default() });
assert_eq!(shrunk[5 * w + 28], 1.0);
assert_eq!(shrunk[5 * w + 29], 0.0);
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 50.0, ..Default::default() });
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() });
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
// A softer edge moves farther: 100% is the feather, here 4 px.
let wide = refine_buffer(None, &m, w, h, &RefineParams { feather: 4.0, contrast: 100.0, shift_edge: 100.0, ..Default::default() });
assert_eq!(wide[5 * w + 33], 1.0);
assert_eq!(wide[5 * w + 34], 0.0);
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);
}
+225 -52
View File
@@ -62,68 +62,213 @@ impl Normal {
}
}
/// A sparse least-squares problem `min Σ w²(row·x − rhs)²`, solved by conjugate gradients on the
/// normal equations (CGLS). Used for mesh warps with thousands of unknowns.
#[derive(Default)]
pub(crate) struct SparseLs {
pub n: usize,
rows: Vec<(Vec<(usize, f64)>, f64)>,
/// A compressed sparse row matrix (symmetric, used by CG).
pub(crate) struct Csr {
pub rows: Vec<usize>,
pub cols: Vec<usize>,
pub vals: Vec<f64>,
pub diag: Vec<f64>,
}
impl SparseLs {
pub fn new(n: usize) -> Self {
SparseLs { n, rows: Vec::new() }
}
pub fn add(&mut self, row: Vec<(usize, f64)>, rhs: f64, w: f64) {
self.rows.push((row.into_iter().map(|(i, c)| (i, c * w)).collect(), rhs * w));
}
/// CGLS from `x0`; at most `iters` iterations.
pub fn solve(&self, x0: &[f64], iters: usize) -> Vec<f64> {
let n = self.n;
let mut x = x0.to_vec();
let ax = |x: &[f64]| -> Vec<f64> { self.rows.iter().map(|(r, _)| r.iter().map(|&(i, c)| c * x[i]).sum()).collect() };
let atv = |v: &[f64]| -> Vec<f64> {
let mut out = vec![0.0; n];
for ((r, _), vi) in self.rows.iter().zip(v) {
for &(i, c) in r {
out[i] += c * vi;
impl Csr {
pub fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr {
t.sort_by_key(|a| (a.0, a.1));
let mut rows = vec![0usize; n + 1];
let mut cols = Vec::with_capacity(t.len());
let mut vals: Vec<f64> = Vec::with_capacity(t.len());
let mut last = (usize::MAX, usize::MAX);
for (i, j, v) in t {
if (i, j) == last {
*vals.last_mut().unwrap_or(&mut 0.0) += v;
continue;
}
last = (i, j);
cols.push(j);
vals.push(v);
rows[i + 1] = cols.len();
}
for i in 0..n {
rows[i + 1] = rows[i + 1].max(rows[i]);
}
let mut diag = vec![1.0; n];
for (i, d) in diag.iter_mut().enumerate() {
for k in rows[i]..rows[i + 1] {
if cols[k] == i && vals[k] > 0.0 {
*d = vals[k];
}
}
out
};
let mut r: Vec<f64> = self.rows.iter().zip(ax(&x)).map(|((_, b), a)| b - a).collect();
let mut s = atv(&r);
let mut p = s.clone();
let mut gamma: f64 = s.iter().map(|v| v * v).sum();
let g0 = gamma.max(1e-30);
for _ in 0..iters {
if gamma <= g0 * 1e-20 {
}
Csr { rows, cols, vals, diag }
}
pub fn mul(&self, x: &[f64], out: &mut [f64]) {
for (i, o) in out.iter_mut().enumerate() {
let mut s = 0.0;
for k in self.rows[i]..self.rows[i + 1] {
s += self.vals[k] * x[self.cols[k]];
}
*o = s;
}
}
/// Jacobi-preconditioned conjugate gradients, warm-started from `x`.
pub fn solve(&self, b: &[f64], x: &mut [f64]) {
let n = b.len();
let mut r = vec![0.0; n];
self.mul(x, &mut r);
for i in 0..n {
r[i] = b[i] - r[i];
}
let bn: f64 = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);
let mut z: Vec<f64> = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect();
let mut p = z.clone();
let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let mut ap = vec![0.0; n];
for _ in 0..(4 * n).clamp(50, 2000) {
if r.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-12 * bn {
break;
}
let q = ax(&p);
let qq: f64 = q.iter().map(|v| v * v).sum();
if qq <= 1e-300 {
self.mul(&p, &mut ap);
let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
if pap.abs() < 1e-300 {
break;
}
let alpha = gamma / qq;
let alpha = rz / pap;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * ap[i];
}
for (ri, qi) in r.iter_mut().zip(&q) {
*ri -= alpha * qi;
}
s = atv(&r);
let g1: f64 = s.iter().map(|v| v * v).sum();
let beta = g1 / gamma;
gamma = g1;
for i in 0..n {
p[i] = s[i] + beta * p[i];
z[i] = r[i] / self.diag[i];
}
let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let beta = rz2 / rz;
rz = rz2;
for i in 0..n {
p[i] = z[i] + beta * p[i];
}
}
x
}
}
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
/// set, then each global step is two triangular solves.
pub(crate) struct BandChol {
n: usize,
p: usize,
/// Row `i` holds `L[i][i - p ..= i]`.
l: Vec<f64>,
}
impl BandChol {
/// `None` when the band would be too large (or the matrix isn't positive definite).
pub fn new(a: &Csr) -> Option<BandChol> {
let n = a.diag.len();
let mut p = 0;
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
p = p.max(i.abs_diff(a.cols[k]));
}
}
if n.saturating_mul(p + 1) > 40_000_000 {
return None;
}
let w = p + 1;
let mut l = vec![0.0f64; n * w];
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
let j = a.cols[k];
if j <= i {
l[i * w + (j + p - i)] = a.vals[k];
}
}
}
for i in 0..n {
let lo = i.saturating_sub(p);
for j in lo..=i {
let jlo = j.saturating_sub(p).max(lo);
let mut s = l[i * w + (j + p - i)];
for k in jlo..j {
s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)];
}
if i == j {
if s <= 0.0 || !s.is_finite() {
return None;
}
l[i * w + p] = s.sqrt();
} else {
l[i * w + (j + p - i)] = s / l[j * w + p];
}
}
}
Some(BandChol { n, p, l })
}
#[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices
pub fn solve(&self, b: &[f64], x: &mut [f64]) {
let (n, p, w) = (self.n, self.p, self.p + 1);
let mut y = b.to_vec();
for i in 0..n {
let mut s = y[i];
for k in i.saturating_sub(p)..i {
s -= self.l[i * w + (k + p - i)] * y[k];
}
y[i] = s / self.l[i * w + p];
}
for i in (0..n).rev() {
let mut s = y[i];
for k in i + 1..(i + p + 1).min(n) {
s -= self.l[k * w + (i + p - k)] * x[k];
}
x[i] = s / self.l[i * w + p];
}
}
}
/// Preconditioned conjugate gradients on the SPD system `A x = b`, warm-started from `x`.
/// `a(v, out)` writes `A·v` and `m(r, out)` writes `M⁻¹·r`. Stops when `‖r‖ ≤ tol·‖b‖`, when the
/// system stops being usable (non-finite or non-positive curvature), or after `max_iter`
/// iterations. Returns the iterations used.
pub(crate) fn pcg(a: impl Fn(&[f64], &mut [f64]), m: impl Fn(&[f64], &mut [f64]), b: &[f64], x: &mut [f64], tol: f64, max_iter: usize) -> usize {
let dot = |u: &[f64], v: &[f64]| -> f64 { u.iter().zip(v).map(|(p, q)| p * q).sum() };
let n = b.len().min(x.len());
let mut r = vec![0.0; n];
a(x, &mut r);
for (ri, bi) in r.iter_mut().zip(b) {
*ri = bi - *ri;
}
let stop = tol * dot(b, b).sqrt().max(1e-30);
let mut z = vec![0.0; n];
m(&r, &mut z);
let mut p = z.clone();
let mut rz = dot(&r, &z);
let mut ap = vec![0.0; n];
for it in 0..max_iter {
if dot(&r, &r).sqrt() <= stop || rz <= 1e-300 || !rz.is_finite() {
return it;
}
a(&p, &mut ap);
let pap = dot(&p, &ap);
if pap <= 1e-300 || !pap.is_finite() {
return it;
}
let alpha = rz / pap;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * ap[i];
}
m(&r, &mut z);
let rz2 = dot(&r, &z);
let beta = rz2 / rz;
rz = rz2;
for i in 0..n {
p[i] = z[i] + beta * p[i];
}
}
max_iter
}
/// Runs `f(y, row)` for every row of a `w × h × ch` buffer, in parallel on native targets.
pub(crate) fn par_rows<T: Send + Sync>(buf: &mut [T], w: usize, ch: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
let stride = (w * ch).max(1);
@@ -251,22 +396,50 @@ mod tests {
}
#[test]
fn dense_and_sparse_solvers_agree() {
fn dense_solver_fits_a_line_and_rejects_singular_systems() {
// Overdetermined line fit y = 2x + 1.
let mut ne = Normal::new(2);
let mut sp = SparseLs::new(2);
for x in 0..10 {
let y = 2.0 * x as f64 + 1.0;
ne.add(&[(0, x as f64), (1, 1.0)], y, 1.0);
sp.add(vec![(0, x as f64), (1, 1.0)], y, 1.0);
ne.add(&[(0, x as f64), (1, 1.0)], 2.0 * x as f64 + 1.0, 1.0);
}
let a = ne.solve().unwrap();
let b = sp.solve(&[0.0, 0.0], 50);
assert!((a[0] - 2.0).abs() < 1e-9 && (a[1] - 1.0).abs() < 1e-9);
assert!((b[0] - 2.0).abs() < 1e-6 && (b[1] - 1.0).abs() < 1e-6, "{b:?}");
assert!(solve_dense(2, vec![1.0, 2.0, 2.0, 4.0], vec![1.0, 2.0]).is_none());
}
/// A tridiagonal SPD system: identity preconditioning needs many iterations, the exact
/// banded Cholesky preconditioner one or two; both match the dense solve.
#[test]
fn pcg_matches_the_dense_solve_and_an_exact_preconditioner_converges_at_once() {
let n = 40;
let mut trip = Vec::new();
for i in 0..n {
trip.push((i, i, 2.5));
if i + 1 < n {
trip.push((i, i + 1, -1.0));
trip.push((i + 1, i, -1.0));
}
}
let k = Csr::from_triplets(n, trip);
let mut dense = vec![0.0; n * n];
for i in 0..n {
for e in k.rows[i]..k.rows[i + 1] {
dense[i * n + k.cols[e]] = k.vals[e];
}
}
let b: Vec<f64> = (0..n).map(|i| (i as f64 * 0.7).sin()).collect();
let want = solve_dense(n, dense, b.clone()).unwrap();
let mul = |v: &[f64], out: &mut [f64]| k.mul(v, out);
let mut x = vec![0.0; n];
let slow = pcg(mul, |r, z| z.copy_from_slice(r), &b, &mut x, 1e-12, 500);
assert!(x.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "identity-preconditioned result differs");
let chol = BandChol::new(&k).unwrap();
let mut y = vec![0.0; n];
let fast = pcg(mul, |r, z| chol.solve(r, z), &b, &mut y, 1e-12, 500);
assert!(y.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "cholesky-preconditioned result differs");
assert!(fast <= 2 && slow > fast, "iterations: exact {fast}, identity {slow}");
}
#[test]
fn srgb_round_trip_and_sampling() {
for v in [0.0f32, 0.01, 0.2, 0.5, 1.0] {
+1 -164
View File
@@ -26,6 +26,7 @@ use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
use crate::photo_util::{BandChol, Csr};
use crate::transform::Interp;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -744,170 +745,6 @@ fn band_order(pos: &[[f64; 2]], tris: &[[usize; 3]]) -> Vec<usize> {
order
}
/// A compressed sparse row matrix (symmetric, used by CG).
struct Csr {
rows: Vec<usize>,
cols: Vec<usize>,
vals: Vec<f64>,
diag: Vec<f64>,
}
impl Csr {
fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr {
t.sort_by_key(|a| (a.0, a.1));
let mut rows = vec![0usize; n + 1];
let mut cols = Vec::with_capacity(t.len());
let mut vals: Vec<f64> = Vec::with_capacity(t.len());
let mut last = (usize::MAX, usize::MAX);
for (i, j, v) in t {
if (i, j) == last {
*vals.last_mut().unwrap_or(&mut 0.0) += v;
continue;
}
last = (i, j);
cols.push(j);
vals.push(v);
rows[i + 1] = cols.len();
}
for i in 0..n {
rows[i + 1] = rows[i + 1].max(rows[i]);
}
let mut diag = vec![1.0; n];
for (i, d) in diag.iter_mut().enumerate() {
for k in rows[i]..rows[i + 1] {
if cols[k] == i && vals[k] > 0.0 {
*d = vals[k];
}
}
}
Csr { rows, cols, vals, diag }
}
fn mul(&self, x: &[f64], out: &mut [f64]) {
for (i, o) in out.iter_mut().enumerate() {
let mut s = 0.0;
for k in self.rows[i]..self.rows[i + 1] {
s += self.vals[k] * x[self.cols[k]];
}
*o = s;
}
}
/// Jacobi-preconditioned conjugate gradients, warm-started from `x`.
fn solve(&self, b: &[f64], x: &mut [f64]) {
let n = b.len();
let mut r = vec![0.0; n];
self.mul(x, &mut r);
for i in 0..n {
r[i] = b[i] - r[i];
}
let bn: f64 = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);
let mut z: Vec<f64> = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect();
let mut p = z.clone();
let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let mut ap = vec![0.0; n];
for _ in 0..(4 * n).clamp(50, 2000) {
if r.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-12 * bn {
break;
}
self.mul(&p, &mut ap);
let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
if pap.abs() < 1e-300 {
break;
}
let alpha = rz / pap;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * ap[i];
}
for i in 0..n {
z[i] = r[i] / self.diag[i];
}
let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let beta = rz2 / rz;
rz = rz2;
for i in 0..n {
p[i] = z[i] + beta * p[i];
}
}
}
}
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
/// set, then each global step is two triangular solves.
struct BandChol {
n: usize,
p: usize,
/// Row `i` holds `L[i][i - p ..= i]`.
l: Vec<f64>,
}
impl BandChol {
/// `None` when the band would be too large (or the matrix isn't positive definite).
fn new(a: &Csr) -> Option<BandChol> {
let n = a.diag.len();
let mut p = 0;
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
p = p.max(i.abs_diff(a.cols[k]));
}
}
if n.saturating_mul(p + 1) > 40_000_000 {
return None;
}
let w = p + 1;
let mut l = vec![0.0f64; n * w];
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
let j = a.cols[k];
if j <= i {
l[i * w + (j + p - i)] = a.vals[k];
}
}
}
for i in 0..n {
let lo = i.saturating_sub(p);
for j in lo..=i {
let jlo = j.saturating_sub(p).max(lo);
let mut s = l[i * w + (j + p - i)];
for k in jlo..j {
s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)];
}
if i == j {
if s <= 0.0 || !s.is_finite() {
return None;
}
l[i * w + p] = s.sqrt();
} else {
l[i * w + (j + p - i)] = s / l[j * w + p];
}
}
}
Some(BandChol { n, p, l })
}
#[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices
fn solve(&self, b: &[f64], x: &mut [f64]) {
let (n, p, w) = (self.n, self.p, self.p + 1);
let mut y = b.to_vec();
for i in 0..n {
let mut s = y[i];
for k in i.saturating_sub(p)..i {
s -= self.l[i * w + (k + p - i)] * y[k];
}
y[i] = s / self.l[i * w + p];
}
for i in (0..n).rev() {
let mut s = y[i];
for k in i + 1..(i + p + 1).min(n) {
s -= self.l[k * w + (i + p - k)] * x[k];
}
x[i] = s / self.l[i * w + p];
}
}
}
/// A pin bound to the mesh: barycentric weights on a triangle's vertices.
#[derive(Clone, Debug)]
struct Bound {
+156 -52
View File
@@ -286,7 +286,7 @@ fn sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
}
/// Median cut over `pts` (feature vectors + weights) into at most `n` boxes; returns box means.
fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
pub(crate) fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
if pts.is_empty() || n == 0 {
return Vec::new();
}
@@ -334,7 +334,7 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
}
/// Lloyd refinement of `centers` over weighted points.
fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
pub(crate) fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
if centers.is_empty() {
// No room left (the forced colours fill the palette): nothing to refine.
return;
@@ -639,53 +639,162 @@ fn spot(shape: HalftoneShape, u: f32, v: f32) -> f32 {
/// Converts gray values (0..=1, row-major `w` wide) to 0/1 in place.
pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
let h = gray.len().checked_div(w).unwrap_or(0);
match method {
BitmapMethod::Threshold => gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }),
BitmapMethod::Pattern => {
for (i, v) in gray.iter_mut().enumerate() {
*v = if *v > bayer8(i % w, i / w) + 0.5 { 1.0 } else { 0.0 };
BitmapStrips::new(w, method).convert(gray);
}
/// Image › Mode › Bitmap fed in strips of whole rows, top to bottom, so a huge image never needs a
/// full-size gray plane (#8477): [`BitmapStrips::convert`] turns each strip to 0/1 in place,
/// carrying error diffusion's error into the next strip. The result is the same however the image
/// is cut into strips.
pub struct BitmapStrips<'a> {
w: usize,
kind: StripKind<'a>,
/// Image row of the next strip's first row.
y: usize,
}
enum StripKind<'a> {
Threshold,
Pattern,
/// Floyd–Steinberg: the error each pixel of the last converted row passes down.
Diffusion {
errs: Vec<f32>,
},
/// The spot function's values over a cell, sorted (its CDF ranks coverage by tone).
Halftone {
cell: f32,
sin: f32,
cos: f32,
shape: HalftoneShape,
table: Vec<f32>,
},
/// Custom Pattern's threshold map, `mw` x `mh`, tiled from the top-left corner.
Custom {
map: &'a [f32],
mw: usize,
mh: usize,
},
}
impl<'a> BitmapStrips<'a> {
pub fn new(w: usize, method: BitmapMethod) -> Self {
let kind = match method {
BitmapMethod::Threshold => StripKind::Threshold,
BitmapMethod::Pattern => StripKind::Pattern,
BitmapMethod::Diffusion => StripKind::Diffusion { errs: Vec::new() },
BitmapMethod::Halftone { cell, angle, shape } => {
let (sin, cos) = angle.to_radians().sin_cos();
// Rank the spot function over the cell so coverage is proportional to the tone.
let mut table: Vec<f32> =
(0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect();
table.sort_by(f32::total_cmp);
StripKind::Halftone { cell: cell.max(2.0), sin, cos, shape, table }
}
};
Self { w, kind, y: 0 }
}
/// Bitmap's Custom Pattern: each gray value turns white where it reaches the threshold `map`
/// (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the image's top-left corner,
/// black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%.
pub fn custom(w: usize, map: &'a [f32], mw: usize, mh: usize) -> Self {
let fits = mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len());
let kind = if fits { StripKind::Custom { map, mw, mh } } else { StripKind::Threshold };
Self { w, kind, y: 0 }
}
/// Converts the next rows (gray 0..=1, `w` wide; a short last row is allowed) to 0/1 in place.
pub fn convert(&mut self, gray: &mut [f32]) {
let (w, y0) = (self.w, self.y);
if w == 0 {
// No rows to lay a pattern or diffuse along: a 50% threshold.
gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 });
return;
}
BitmapMethod::Diffusion => {
let mut buf: Vec<f32> = gray.to_vec();
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let old = buf[i];
let new = if old >= 0.5 { 1.0 } else { 0.0 };
let e = old - new;
gray[i] = new;
if x + 1 < w {
buf[i + 1] += e * 7.0 / 16.0;
}
if y + 1 < h {
if x > 0 {
buf[i + w - 1] += e * 3.0 / 16.0;
}
buf[i + w] += e * 5.0 / 16.0;
if x + 1 < w {
buf[i + w + 1] += e / 16.0;
}
}
self.y = y0.saturating_add(gray.len().div_ceil(w));
// Every method but diffusion is pointwise: rows convert in parallel.
let par = gray.len() >= 1 << 16;
match &mut self.kind {
StripKind::Threshold => crate::par::chunks_mut(gray, w, par, |_, row| {
row.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 });
}),
StripKind::Pattern => crate::par::chunks_mut(gray, w, par, |r, row| {
for (x, v) in row.iter_mut().enumerate() {
*v = if *v > bayer8(x, y0 + r) + 0.5 { 1.0 } else { 0.0 };
}
}),
StripKind::Diffusion { errs } => {
// The previous strip's last row passes its error down to this strip's first.
let first = w.min(gray.len());
pass_down(errs, gray.get_mut(..first).unwrap_or_default());
let mut rest = gray;
while !rest.is_empty() {
let n = w.min(rest.len());
let (row, tail) = std::mem::take(&mut rest).split_at_mut(n);
diffuse_row(row, errs);
let below = w.min(tail.len());
pass_down(errs, tail.get_mut(..below).unwrap_or_default());
rest = tail;
}
}
}
BitmapMethod::Halftone { cell, angle, shape } => {
let cell = cell.max(2.0);
let (s, c) = angle.to_radians().sin_cos();
// Rank the spot function over the cell so coverage is proportional to the tone.
let mut table: Vec<f32> = (0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect();
table.sort_by(f32::total_cmp);
let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32;
for (i, v) in gray.iter_mut().enumerate() {
let (x, y) = ((i % w) as f32 + 0.5, (i / w) as f32 + 0.5);
let (rx, ry) = (x * c + y * s, -x * s + y * c);
let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0;
let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0;
// Ink (black) covers where the spot function exceeds the gray level.
*v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 };
StripKind::Halftone { cell, sin, cos, shape, table } => {
let (cell, s, c, shape, table) = (*cell, *sin, *cos, *shape, &*table);
let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32;
crate::par::chunks_mut(gray, w, par, |r, row| {
let y = (y0 + r) as f32 + 0.5;
for (x, v) in row.iter_mut().enumerate() {
let x = x as f32 + 0.5;
let (rx, ry) = (x * c + y * s, -x * s + y * c);
let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0;
let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0;
// Ink (black) covers where the spot function exceeds the gray level.
*v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 };
}
});
}
StripKind::Custom { map, mw, mh } => {
let (map, mw, mh) = (&**map, *mw, *mh);
crate::par::chunks_mut(gray, w, par, |r, row| {
let base = (y0 + r) % mh * mw;
for (x, v) in row.iter_mut().enumerate() {
let t = map.get(base + x % mw).copied().unwrap_or(0.5);
*v = if *v >= t { 1.0 } else { 0.0 };
}
});
}
}
}
}
/// One Floyd–Steinberg row, in place: each pixel takes its error from the left (7/16), and
/// `errs` gets the error it passes down to the next row ([`pass_down`]).
fn diffuse_row(row: &mut [f32], errs: &mut Vec<f32>) {
errs.clear();
for x in 0..row.len() {
let old = row[x];
let new = if old >= 0.5 { 1.0 } else { 0.0 };
let e = old - new;
row[x] = new;
if let Some(next) = row.get_mut(x + 1) {
*next += e * 7.0 / 16.0;
}
errs.push(e);
}
}
/// Adds the error `errs` of a row to the row below: 1/16 from the pixel up-left, 5/16 from the
/// one above, 3/16 from the one up-right, in that order (the order the row's pixels were
/// converted in), so the result is the same whether or not the rows are in the same strip.
fn pass_down(errs: &[f32], below: &mut [f32]) {
for (t, v) in below.iter_mut().enumerate() {
if let Some(e) = t.checked_sub(1).and_then(|l| errs.get(l)) {
*v += e / 16.0;
}
if let Some(e) = errs.get(t) {
*v += e * 5.0 / 16.0;
}
if let Some(e) = errs.get(t + 1) {
*v += e * 3.0 / 16.0;
}
}
}
@@ -719,15 +828,10 @@ pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec<f32> {
out
}
/// Bitmap's Custom Pattern: each gray value (0..=1, row-major `w` wide) turns white where it
/// reaches the threshold `map` (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the
/// image's top-left corner, black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%.
/// Bitmap's Custom Pattern over a whole image (gray 0..=1, row-major `w` wide), see
/// [`BitmapStrips::custom`]. An image `w` of 0 thresholds at 50%.
pub fn to_bitmap_custom(gray: &mut [f32], w: usize, map: &[f32], mw: usize, mh: usize) {
let fits = w > 0 && mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len());
for (i, v) in gray.iter_mut().enumerate() {
let t = if fits { map.get((i / w) % mh * mw + (i % w) % mw).copied().unwrap_or(0.5) } else { 0.5 };
*v = if *v >= t { 1.0 } else { 0.0 };
}
BitmapStrips::custom(w, map, mw, mh).convert(gray);
}
#[cfg(test)]
+107
View File
@@ -440,3 +440,110 @@ fn custom_pattern_tiles_from_the_top_left() {
to_bitmap_custom(&mut g, 0, &map, 2, 1);
assert_eq!(g, [1.0]);
}
/// `gray` converted by `conv` in strips of `rows` rows each, cycling through `rows`.
fn in_strips(gray: &[f32], w: usize, rows: &[usize], mut conv: BitmapStrips<'_>) -> Vec<f32> {
let mut out = gray.to_vec();
let mut rest = out.as_mut_slice();
for r in rows.iter().cycle() {
if rest.is_empty() {
break;
}
let (strip, tail) = rest.split_at_mut((r * w).min(rest.len()));
conv.convert(strip);
rest = tail;
}
out
}
#[test]
fn bitmap_strips_match_the_whole_image_however_it_is_cut() {
// #8477: strip boundaries must preserve diffusion state and keep patterns/screens anchored.
let (w, h) = (37, 29);
let gray: Vec<f32> = (0..w * h).map(|i| ((i % w) as f32 / w as f32 * 0.7 + (i / w) as f32 / h as f32 * 0.3).fract()).collect();
let map = custom_pattern_thresholds(&[0.1, 0.9, 0.4, 0.6, 0.2, 0.8]);
let methods = [
BitmapMethod::Threshold,
BitmapMethod::Pattern,
BitmapMethod::Diffusion,
BitmapMethod::Halftone { cell: 5.5, angle: 30.0, shape: HalftoneShape::Ellipse },
];
for rows in [&[1][..], &[3], &[5, 2, 7], &[h]] {
for method in methods {
let mut whole = gray.clone();
to_bitmap(&mut whole, w, method);
assert!(whole.iter().all(|v| *v == 0.0 || *v == 1.0), "{method:?}");
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, method)), whole, "{method:?} in strips of {rows:?}");
}
let mut whole = gray.clone();
to_bitmap_custom(&mut whole, w, &map, 3, 2);
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::custom(w, &map, 3, 2)), whole, "custom in strips of {rows:?}");
}
}
#[test]
fn bitmap_strips_match_the_whole_image_with_a_ragged_last_row() {
// A short final row still receives the error from the row above, including in its own strip.
let w = 37;
let gray: Vec<f32> = (0..w * 5 + 11).map(|i| ((i % w) as f32 / w as f32 * 0.7 + (i / w) as f32 * 0.13).fract()).collect();
for method in [BitmapMethod::Diffusion, BitmapMethod::Threshold, BitmapMethod::Pattern] {
let mut whole = gray.clone();
to_bitmap(&mut whole, w, method);
for rows in [&[1][..], &[5], &[2, 3], &[5, 1]] {
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, method)), whole, "{method:?} in strips of {rows:?}");
}
}
}
/// Floyd–Steinberg over the whole image as `to_bitmap` did before strips (#8477).
fn diffusion_reference(gray: &mut [f32], w: usize) {
let h = gray.len() / w;
let mut buf = gray.to_vec();
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let old = buf[i];
let new = if old >= 0.5 { 1.0 } else { 0.0 };
let error = old - new;
gray[i] = new;
if x + 1 < w {
buf[i + 1] += error * 7.0 / 16.0;
}
if y + 1 < h {
if x > 0 {
buf[i + w - 1] += error * 3.0 / 16.0;
}
buf[i + w] += error * 5.0 / 16.0;
if x + 1 < w {
buf[i + w + 1] += error / 16.0;
}
}
}
}
}
#[test]
fn bitmap_diffusion_in_strips_is_bit_identical_to_one_pass() {
// Keep error accumulation order identical; one rounding change can flip many later pixels.
let (w, h) = (173, 61);
let gray: Vec<f32> = (0..w * h).map(|i| ((i % w) as f32 * 0.013 + (i / w) as f32 * 0.007 + ((i * 7919) % 101) as f32 * 0.001).fract()).collect();
let mut expected = gray.clone();
diffusion_reference(&mut expected, w);
for rows in [&[1][..], &[4, 9], &[h]] {
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, BitmapMethod::Diffusion)), expected, "strips of {rows:?}");
}
}
#[test]
fn bitmap_strips_handle_empty_and_ragged_input() {
// A zero width thresholds at 50%, and a short final row is converted too.
for method in [BitmapMethod::Pattern, BitmapMethod::Diffusion, BitmapMethod::Halftone { cell: 4.0, angle: 0.0, shape: HalftoneShape::Round }] {
let mut gray = vec![0.4, 0.6];
to_bitmap(&mut gray, 0, method);
assert_eq!(gray, [0.0, 1.0], "{method:?}");
let mut gray = vec![0.3; 7];
to_bitmap(&mut gray, 3, method);
assert!(gray.iter().all(|v| *v == 0.0 || *v == 1.0), "{method:?}: {gray:?}");
to_bitmap(&mut [], 3, method);
}
}
+21 -4
View File
@@ -252,14 +252,28 @@ pub fn grid_cut_cancellable(
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel)
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel, false)
}
/// [`grid_cut_cancellable`] solved with Boykov–Kolmogorov instead of push/relabel: faster when
/// the data costs come from colour models (GrabCut and the band re-cut), where terminal links
/// are few and paths short. The minimum cut value is the same.
pub fn grid_cut_bk_cancellable(
img: &RgbImage,
cost_fg: &[f32],
cost_bg: &[f32],
fixed: &[u8],
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel, true)
}
mod edge_starts;
// The const parameter also runs the previous full scan in before/after workflow tests.
fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec<bool> {
grid_cut_impl::<OPTIMIZED>(img, cost_fg, cost_bg, fixed, weight, &|| false).unwrap_or_default()
grid_cut_impl::<OPTIMIZED>(img, cost_fg, cost_bg, fixed, weight, &|| false, false).unwrap_or_default()
}
fn grid_cut_impl<const OPTIMIZED: bool>(
@@ -269,6 +283,7 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
fixed: &[u8],
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
bk: bool,
) -> Option<Vec<bool>> {
let (w, h) = (img.w as i32, img.h as i32);
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(Vec::new()) };
@@ -336,7 +351,9 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
if cancel() {
return None;
}
if OPTIMIZED {
if OPTIMIZED && bk {
g.maxflow_bk_with(cancel)?;
} else if OPTIMIZED {
g.maxflow_with(cancel)?;
} else {
g.maxflow_bk();
@@ -733,7 +750,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_bk_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let (cw, ch) = (t.width() as usize, t.height() as usize);
let mut core = Vec::with_capacity(cw * ch);
for yy in 0..ch {
+2 -2
View File
@@ -10,7 +10,7 @@ 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_bk_cancellable, subsample};
/// Definite background.
pub const BG: u8 = 0;
@@ -67,7 +67,7 @@ pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Inter
};
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_bk_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let mut changed = 0usize;
for (t, fgnd) in trimap.iter_mut().zip(&cut) {
if *t == PR_BG || *t == PR_FG {
+60 -1
View File
@@ -140,11 +140,22 @@ impl Graph {
self.bk(cancel)
}
/// [`Graph::maxflow_with`] that always runs Boykov–Kolmogorov. It is the faster solver on
/// the 8-connected colour-model grids of GrabCut (about 2.3x on a 107k-pixel working image);
/// push/relabel wins on Quick Selection's many area-cost terminals, which keep
/// [`Graph::maxflow_with`].
pub fn maxflow_bk_with(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
self.bk(cancel)
}
pub(super) fn maxflow_bk(&mut self) -> f64 {
self.bk(&never).unwrap_or(self.flow)
}
fn bk(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
if cancel() {
return None;
}
let n = self.node_count();
let mut queue: VecDeque<u32> = VecDeque::new();
let mut orphans: VecDeque<u32> = VecDeque::new();
@@ -219,7 +230,7 @@ impl Graph {
self.adopt(o as usize, time, &mut queue, &mut orphans);
}
}
Some(self.flow)
if cancel() { None } else { Some(self.flow) }
}
fn augment(&mut self, m: usize, orphans: &mut VecDeque<u32>) {
@@ -408,3 +419,51 @@ impl Graph {
s
}
}
#[cfg(test)]
mod cancellation_tests {
use super::{CANCEL_EVERY, Graph};
use std::cell::Cell;
#[test]
fn bk_checks_cancellation_on_short_runs() {
let mut graph = Graph::with_capacity(2, 0);
graph.add_tweights(0, 1.0, 0.0);
graph.add_tweights(1, 0.0, 1.0);
assert!(graph.maxflow_bk_with(&|| true).is_none());
let calls = Cell::new(0);
assert!(
graph
.maxflow_bk_with(&|| {
calls.set(calls.get() + 1);
calls.get() == 2
})
.is_none()
);
assert_eq!(calls.get(), 2, "short run must check before returning");
}
#[test]
fn bk_checks_cancellation_during_a_long_run() {
let mut graph = Graph::with_capacity(CANCEL_EVERY as usize + 1, 0);
for i in 0..graph.node_count() {
graph.add_tweights(i, 1.0, 0.0);
}
let calls = Cell::new(0);
assert!(
graph
.maxflow_bk_with(&|| {
calls.set(calls.get() + 1);
calls.get() == 2
})
.is_none()
);
// The first check is at entry; more than CANCEL_EVERY active nodes ensure the
// second is the periodic poll, before the solver can finish.
assert_eq!(calls.get(), 2);
// Without the periodic poll the final check would be the second call. The poll stops the
// run with the last queued node unprocessed, still marked active.
assert!(graph.active[CANCEL_EVERY as usize], "periodic cancel must stop before the last queued node is processed");
}
}
+2 -2
View File
@@ -265,7 +265,7 @@ fn cut_levels<const OPTIMIZED: 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_impl::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel, false);
}
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let coarse_img = img.downsample(2);
@@ -310,7 +310,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_impl::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel, false)
}
/// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel.
+143
View File
@@ -288,6 +288,65 @@ fn grabcut_trimap_on_disc() {
assert!(iou > 0.95, "IoU {iou}");
}
#[test]
fn grabcut_small_disc_mask_is_pinned() {
// The mask GrabCut produced with the push/relabel solver, before the switch to BK. Min-cut
// ties may break differently between solvers, so any drift here is a visible change.
let (img, _) = disc_scene(32, 24, 16.0, 12.0, 8.0, 3);
let mut tri = vec![grabcut::BG; img.w * img.h];
for y in 2..22 {
for x in 6..26 {
tri[y * img.w + x] = grabcut::PR_FG;
}
}
grabcut::grabcut(&img, &mut tri, 8).unwrap();
let got: Vec<String> = tri.chunks(img.w).map(|r| r.iter().map(|t| if *t == grabcut::PR_FG || *t == grabcut::FG { '#' } else { '.' }).collect()).collect();
let want = [
"................................",
"................................",
"................................",
"................................",
".............######.............",
"...........##########...........",
"..........############..........",
".........##############.........",
".........##############.........",
"........################........",
"........################........",
"........################........",
"........################........",
"........################........",
"........################........",
".........##############.........",
".........##############.........",
"..........############..........",
"...........##########...........",
".............######.............",
"................................",
"................................",
"................................",
"................................",
];
assert_eq!(got, want);
}
#[test]
fn grabcut_large_disc_mask_is_pinned() {
// 5168 free nodes with terminal links both ways, so the pre-BK solver was push/relabel here.
// The count and FNV-1a hash are of the mask that push/relabel produced at the merge base.
let (img, _) = disc_scene(96, 80, 48.0, 40.0, 26.0, 3);
let mut tri = vec![grabcut::BG; img.w * img.h];
for y in 6..74 {
for x in 10..86 {
tri[y * img.w + x] = grabcut::PR_FG;
}
}
grabcut::grabcut(&img, &mut tri, 8).unwrap();
let fg: Vec<bool> = tri.iter().map(|t| *t == grabcut::PR_FG || *t == grabcut::FG).collect();
let hash = fg.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3));
assert_eq!((fg.iter().filter(|b| **b).count(), hash), (2128, 0x62fd_c64a_0ee4_4e75));
}
#[test]
fn object_select_disc_full_resolution() {
let (img, truth) = disc_scene(200, 160, 100.0, 80.0, 50.0, 2);
@@ -1068,3 +1127,87 @@ fn hole_fill_without_specks_fills_object_coloured_holes_only() {
assert!(out[25 * w + 25], "a speck of the object's colour fills");
assert!(!out[10 * w + 10], "a speck of the background's colour stays open");
}
/// Energy of a labelling: data costs plus the contrast weights of separated 8-neighbours.
fn grid_energy(img: &RgbImage, cf: &[f32], cb: &[f32], cut: &[bool], weight: impl Fn(f32) -> f32) -> f64 {
let (w, h) = (img.w as i32, img.h as i32);
let mut e = 0.0f64;
for y in 0..h {
for x in 0..w {
let i = (y * w + x) as usize;
e += f64::from(if cut[i] { cf[i] } else { cb[i] });
for (dx, dy, len) in NB {
let (nx, ny) = (x + dx, y + dy);
if nx >= 0 && nx < w && ny < h {
let j = (ny * w + nx) as usize;
if cut[i] != cut[j] {
e += f64::from(weight(d2(img.px[i], img.px[j])) / len);
}
}
}
}
}
e
}
#[test]
fn bk_and_default_grid_cut_tie_reaches_the_same_energy() {
// Hard background down the left edge, hard foreground down the right, uniform pixels and equal
// data costs: a vertical cut at any column separates the borders at the same energy, so only
// the solvers' tie-breaking decides the labelling.
let (w, h) = (120usize, 100usize);
let img = RgbImage::from_fn(w, h, |_, _| [0.5, 0.5, 0.5]);
let (cf, cb) = (vec![1.0f32; w * h], vec![1.0f32; w * h]);
let fixed: Vec<u8> = (0..w * h)
.map(|i| match i % w {
0 => HARD_BG,
x if x == w - 1 => HARD_FG,
_ => FREE,
})
.collect();
let weight = |_: f32| 2.0;
// Two distinct candidate labellings (vertical cuts at different columns) tie at the minimum.
let split = |col: usize| -> Vec<bool> { (0..w * h).map(|i| i % w >= col).collect() };
let (e30, e90) = (grid_energy(&img, &cf, &cb, &split(30), weight), grid_energy(&img, &cf, &cb, &split(90), weight));
assert!((e30 - e90).abs() <= 1e-6 * e30.abs().max(1.0), "candidates must tie: {e30} vs {e90}");
assert_ne!(split(30), split(90));
let a = grid_cut_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("default cut");
let b = grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("bk cut");
let (ea, eb) = (grid_energy(&img, &cf, &cb, &a, weight), grid_energy(&img, &cf, &cb, &b, weight));
assert!((ea - e30).abs() <= 1e-3 * e30.abs().max(1.0), "default {ea} is not the tied minimum {e30}");
assert!((eb - e30).abs() <= 1e-3 * e30.abs().max(1.0), "bk {eb} is not the tied minimum {e30}");
// The solvers may pick different minimum cuts here; only the energy is guaranteed.
assert!(a.iter().any(|&l| l) && b.iter().any(|&l| l), "both label the foreground border");
}
#[test]
fn bk_and_default_grid_cut_reach_the_same_minimum_energy() {
// Large enough (>= 4096 free nodes, both terminals) that the default solver is push/relabel.
let (w, h) = (120usize, 100usize);
let mut rng = Rng::new(7);
let img = RgbImage::from_fn(w, h, |x, y| {
let inside = (x as f32 - 60.0).powi(2) + (y as f32 - 50.0).powi(2) < 1600.0;
let base = if inside { [0.8, 0.3, 0.2] } else { [0.2, 0.4, 0.7] };
base.map(|v| v + 0.08 * rng.normal())
});
let cf: Vec<f32> = img.px.iter().map(|p| 4.0 * d2(*p, [0.8, 0.3, 0.2])).collect();
let cb: Vec<f32> = img.px.iter().map(|p| 4.0 * d2(*p, [0.2, 0.4, 0.7])).collect();
let fixed: Vec<u8> = (0..w * h)
.map(|i| {
if i % w == 0 {
HARD_BG
} else if i == (50 * w + 60) {
HARD_FG
} else {
FREE
}
})
.collect();
let weight = |d: f32| 50.0 * (-8.0 * d).exp();
let a = grid_cut_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("default cut");
let b = grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("bk cut");
let (ea, eb) = (grid_energy(&img, &cf, &cb, &a, weight), grid_energy(&img, &cf, &cb, &b, weight));
assert!((ea - eb).abs() <= 1e-3 * ea.abs().max(1.0), "default {ea} vs bk {eb}");
assert_eq!(a, b, "same labelling, not just the same energy");
assert!(grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| true).is_none(), "BK honours cancel");
}
+18 -1
View File
@@ -134,18 +134,30 @@ pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface {
surface_from_masks(area, m, None, |_, v| v)
}
mod combine_mask;
pub use combine_mask::combine_band;
/// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected.
pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
if area.is_empty() {
return None;
}
// Replace already writes straight from the input mask to tiles; it needs no old mask.
if mode == SelectionMode::Replace || (old.is_none() && mode == SelectionMode::Add) {
let s = mask_to_surface(new, area);
return (s.tile_count() > 0).then_some(s);
}
let old = old?;
if old.format() == PixelFormat::GRAY8 {
return combine_mask::gray8(Some(old), new, area, mode, false);
}
// Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is
// exact. Below half an 8-bit step is nothing.
let q = |v: f32| f32::from(coverage8(v)) / 255.0;
let s = match mode {
SelectionMode::Replace => surface_from_masks(area, new, None, |_, v| v),
_ => {
let prev = mask_from_surface(old, area);
let prev = mask_from_surface(Some(old), area);
match mode {
SelectionMode::Add => surface_from_masks(area, new, Some(&prev), |p, v| p.max(q(v))),
SelectionMode::Subtract => surface_from_masks(area, new, Some(&prev), |p, v| (p - q(v)).max(0.0)),
@@ -381,6 +393,9 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti
let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() };
let new = new.unwrap_or(&empty);
let b = new.bbox;
if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) {
return combine_mask::gray8_bytes(old, &new.mask, b, mode);
}
let prev_u8 = |s: &Surface| -> Vec<u8> {
let mut v = Vec::new();
s.read_region_into(b, &mut v);
@@ -761,11 +776,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
}
mod distance;
mod inverse;
mod lasso;
mod marquee;
mod morphology;
pub use distance::edt;
pub use inverse::inverse_surface;
pub(crate) use lasso::clip_segment;
pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface};
pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface};
+112
View File
@@ -0,0 +1,112 @@
//! Float mask inputs meet stored GRAY8 selections at the tile boundary. No dense copy of
//! the previous selection or of the combined result is needed.
use super::{SelectionMode, combine, coverage8};
use crate::photo_util::par_map;
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
use std::sync::Arc;
/// Combines a mask covering only `band` (zero elsewhere). Add and Subtract retain the old
/// selection outside it, including pixels sharing a tile with the band's edges. Replace
/// and Intersect return only the selection inside the band. Missing mask samples read zero.
pub fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, mode: SelectionMode) -> Option<Surface> {
let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract);
if !keep || old.is_none() {
return combine(old, mask, band, mode);
}
if band.is_empty() {
return old.cloned();
}
if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) {
return gray8(old, mask, band, mode, true);
}
// Preserve the format and conversion behavior of unusual selection surfaces. The normal
// GRAY8 path above never materializes these band-sized buffers.
let inside = combine(old, mask, band, mode);
let mut out = old.cloned()?;
let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |s| s.read_region(band));
if out.channels() == 1 {
out.write_region(band, &values);
} else {
// A selection uses its first channel as coverage. Broadcast it when writing back to
// an unusual multichannel surface, rather than passing a short buffer to write_region.
let pixels: Vec<f32> = values.iter().flat_map(|v| std::iter::repeat_n(*v, out.channels())).collect();
out.write_region(band, &pixels);
}
out.prune();
(out.tile_count() > 0).then_some(out)
}
pub(super) fn gray8(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode, keep_outside: bool) -> Option<Surface> {
build(old, area, mode, keep_outside, |i| coverage8(mask.get(i).copied().unwrap_or(0.0)))
}
pub(super) fn gray8_bytes(old: Option<&Surface>, mask: &[u8], area: Rect, mode: SelectionMode) -> Option<Surface> {
let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract);
if area.is_empty() {
return if keep { old.cloned().filter(|s| !s.content_bounds().is_empty()) } else { None };
}
build(old, area, mode, keep, |i| mask.get(i).copied().unwrap_or(0))
}
fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: bool, sample: impl Fn(usize) -> u8 + Sync) -> Option<Surface> {
let mut out = if keep_outside { old.cloned().unwrap_or_else(|| Surface::new(PixelFormat::GRAY8)) } else { Surface::new(PixelFormat::GRAY8) };
let default = old.and_then(|s| s.default_bytes().first()).copied().unwrap_or(0);
let out_default = out.default_bytes().first().copied().unwrap_or(0);
let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0]));
let untouched = out.solid_tile(&[f32::from(out_default) / 255.0]);
let coords: Vec<TileCoord> = area.tiles().collect();
let width = area.width() as usize;
let ts = TILE_SIZE as usize;
let built = par_map(coords.len(), |k| {
let tc = *coords.get(k)?;
let rect = tc.rect();
let cut = rect.intersect(&area);
let previous = old.and_then(|s| s.tile(tc));
let mut tile = Tile::clone(if keep_outside { previous.unwrap_or(&untouched) } else { &blank });
for y in cut.y0..cut.y1 {
let offset = y.abs_diff(rect.y0) as usize * ts + cut.x0.abs_diff(rect.x0) as usize;
let len = cut.width() as usize;
let Some(dst) = tile.bytes_mut().get_mut(offset..offset + len) else { continue };
let src = previous.and_then(|t| t.bytes().get(offset..offset + len));
let start = (y.abs_diff(area.y0) as usize).checked_mul(width).and_then(|i| i.checked_add(cut.x0.abs_diff(area.x0) as usize));
for (i, d) in dst.iter_mut().enumerate() {
let new = start.and_then(|j| j.checked_add(i)).map(&sample).unwrap_or(0);
let prev = src.and_then(|s| s.get(i)).copied().unwrap_or(default);
*d = match mode {
SelectionMode::Replace => new,
SelectionMode::Add => prev.max(new),
SelectionMode::Subtract => prev.saturating_sub(new),
SelectionMode::Intersect => prev.min(new),
};
}
}
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), b| (lo.min(*b), hi.max(*b)));
let tile = if lo == out_default && hi == out_default {
None
} else if previous.is_some_and(|p| p.bytes() == tile.bytes()) {
previous.cloned()
} else if lo == u8::MAX {
Some(Arc::clone(&full))
} else {
Some(Arc::new(tile))
};
Some((tc, tile))
});
for (tc, tile) in built.into_iter().flatten() {
match tile {
Some(tile) => out.put_tiles([(tc, tile)]),
None if keep_outside && out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())),
None => {}
}
}
if keep_outside {
out.prune();
}
(out.tile_count() > 0).then_some(out)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,179 @@
use super::*;
use crate::selection::{Region, combine_region, mask_from_surface, surface_from_masks};
use photocraft_color::{ColorMode, SampleType};
const MODES: [SelectionMode; 4] = [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect];
// The pre-refactor float formulas, including quantization before the binary operation.
fn reference(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
if area.is_empty() {
return None;
}
if mode == SelectionMode::Replace {
let s = surface_from_masks(area, mask, None, |_, v| v);
return (s.tile_count() > 0).then_some(s);
}
let prev = mask_from_surface(old, area);
let s = surface_from_masks(area, mask, Some(&prev), |p, v| {
let q = f32::from(coverage8(v)) / 255.0;
match mode {
SelectionMode::Replace => v,
SelectionMode::Add => p.max(q),
SelectionMode::Subtract => (p - q).max(0.0),
SelectionMode::Intersect => p.min(q),
}
});
(s.tile_count() > 0).then_some(s)
}
fn reference_band(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
let inside = reference(old, mask, area, mode);
let Some(old) = old.filter(|_| matches!(mode, SelectionMode::Add | SelectionMode::Subtract)) else { return inside };
if area.is_empty() {
return Some(old.clone());
}
let values = inside.map_or_else(|| vec![0.0; area.width() as usize * area.height() as usize], |s| s.read_region(area));
let mut out = old.clone();
out.write_region(area, &values);
out.prune();
(out.tile_count() > 0).then_some(out)
}
#[test]
fn every_byte_pair_matches_float_combining() {
let area = Rect::new(0, 0, 256, 256);
let prev: Vec<f32> = (0..65536).map(|i| (i / 256) as f32 / 255.0).collect();
let mask: Vec<f32> = (0..65536).map(|i| (i % 256) as f32 / 255.0).collect();
let old = crate::selection::mask_to_surface(&prev, area);
for mode in MODES {
assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode), "{mode:?}");
}
}
#[test]
fn cropped_bands_match_dense_writeback_with_defaults_and_short_masks() {
let values = [f32::NAN, f32::NEG_INFINITY, -0.2, 0.0, 0.49 / 255.0, 0.5 / 255.0, 0.51 / 255.0, 0.5, 1.0, 1.2, f32::INFINITY];
for area in [Rect::new(-259, -2, 259, 3), Rect::new(255, 255, 258, 259), Rect::new(0, 0, 512, 256), Rect::EMPTY] {
let n = area.width() as usize * area.height() as usize;
let mask: Vec<f32> = (0..n).map(|i| values[i % values.len()]).collect();
for default in [0.0, 0.5, 1.0] {
let mut old = Surface::with_default(PixelFormat::GRAY8, &[default]);
old.fill_rect(Rect::new(-300, -20, 300, 270), &[0.75]);
old.write_pixel(1024, 1024, &[1.0]);
old.write_pixel(-257, 0, &[0.0]);
let snapshot = old.clone();
for prev in [None, Some(&old)] {
for len in [0, n / 2, n] {
for mode in MODES {
let mask = &mask[..len];
assert_eq!(combine(prev, mask, area, mode), reference(prev, mask, area, mode), "{area:?} {mode:?} {default} {len}");
assert_eq!(combine_band(prev, mask, area, mode), reference_band(prev, mask, area, mode), "band {area:?} {mode:?} {default} {len}");
}
}
}
assert_eq!(old, snapshot, "combining must leave undo snapshots unchanged");
}
}
}
#[test]
fn unusual_selection_formats_keep_their_float_behavior() {
let area = Rect::new(-2, -1, 3, 2);
for sample in SampleType::ALL {
for color in [ColorMode::Grayscale, ColorMode::Rgb] {
let mut old = Surface::with_default(PixelFormat::new(color, sample, false), &vec![0.123; if color == ColorMode::Rgb { 3 } else { 1 }]);
old.write_pixel(0, 0, &vec![0.754; old.channels()]);
for mode in MODES {
let mask = [0.5; 15];
assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode));
if color == ColorMode::Grayscale {
assert_eq!(combine_band(Some(&old), &mask, area, mode), reference_band(Some(&old), &mask, area, mode));
} else {
let out = combine_band(Some(&old), &mask, area, mode).unwrap();
let inside = reference(Some(&old), &mask, area, mode).unwrap();
let expected = inside.pixel(0, 0)[0];
let encoded = Surface::with_default(out.format(), &vec![expected; out.channels()]);
assert_eq!(out.pixel(0, 0), encoded.pixel(0, 0));
if matches!(mode, SelectionMode::Add | SelectionMode::Subtract) {
assert_eq!(out.pixel(20, 20), old.pixel(20, 20));
}
}
}
}
}
}
#[test]
fn region_bytes_match_float_bands() {
let bbox = Rect::new(-257, 255, 258, 258);
let mask: Vec<u8> = (0..bbox.width() * bbox.height()).map(|i| (i % 256) as u8).collect();
let floats: Vec<f32> = mask.iter().map(|b| f32::from(*b) / 255.0).collect();
let region = Region { bbox, mask };
let mut old = Surface::new(PixelFormat::GRAY8);
old.fill_rect(Rect::new(-300, 250, 300, 270), &[0.75]);
for prev in [None, Some(&old)] {
for mode in MODES {
assert_eq!(combine_region(prev, Some(&region), mode), reference_band(prev, &floats, bbox, mode));
assert_eq!(combine_region(prev, None, mode), reference_band(prev, &[], Rect::EMPTY, mode));
}
}
}
#[test]
fn tiles_stay_shared_or_are_pruned() {
let area = Rect::new(0, 0, 768, 256);
let mut old = Surface::new(PixelFormat::GRAY8);
old.fill_rect(area, &[1.0]);
let tc = TileCoord::new(0, 0);
let unchanged = combine(Some(&old), &[], area, SelectionMode::Add).unwrap();
assert!(Arc::ptr_eq(unchanged.tile(tc).unwrap(), old.tile(tc).unwrap()));
let band = Rect::new(257, 1, 260, 3);
let changed = combine_band(Some(&old), &[1.0; 6], band, SelectionMode::Subtract).unwrap();
assert!(Arc::ptr_eq(changed.tile(tc).unwrap(), old.tile(tc).unwrap()));
assert_eq!(changed.pixel(256, 1), vec![1.0]);
assert_eq!(changed.pixel(257, 1), vec![0.0]);
assert_eq!(changed.pixel(260, 1), vec![1.0]);
let full = combine(None, &vec![1.0; 768 * 256], area, SelectionMode::Replace).unwrap();
assert!(Arc::ptr_eq(full.tile(tc).unwrap(), full.tile(TileCoord::new(1, 0)).unwrap()));
assert!(combine_band(Some(&old), &vec![1.0; 768 * 256], area, SelectionMode::Subtract).is_none());
assert!(combine(None, &[], area, SelectionMode::Replace).is_none());
}
/// Run only in release on CI: compares the former implementation, including dense reads and
/// band writeback, against the byte path with identical 36 MP inputs and exact output checks.
#[test]
#[ignore = "36 MP release performance comparison; run on GitHub Actions"]
fn gray8_combining_release_comparison() {
use std::{hint::black_box, time::Instant};
let area = Rect::new(0, 0, 6000, 6000);
let mut old = Surface::new(PixelFormat::GRAY8);
old.fill_rect(Rect::new(100, 100, 5900, 5900), &[0.75]);
println!("36 MP; median of three release runs including allocation; exact output checks; previous dense mask alone = 144000000 bytes");
for (case, size) in [("sparse", 50), ("dense", 4000), ("full", 6000)] {
let mut mask = vec![0.0; 6000 * 6000];
let start = (6000 - size) / 2;
for row in mask.as_chunks_mut::<6000>().0.iter_mut().skip(start).take(size) {
row[start..start + size].fill(0.5);
}
for mode in MODES {
for band in [false, true] {
let baseline = if band { reference_band } else { reference };
let direct = if band { combine_band } else { combine };
assert_eq!(direct(Some(&old), &mask, area, mode), baseline(Some(&old), &mask, area, mode));
let mut medians = [0.0; 2];
for (median, op) in medians.iter_mut().zip([baseline, direct]) {
let mut runs = [0.0; 3];
for ms in &mut runs {
let start = Instant::now();
let out = black_box(op(Some(black_box(&old)), black_box(&mask), area, mode));
*ms = start.elapsed().as_secs_f64() * 1000.0;
drop(out);
}
runs.sort_by(f64::total_cmp);
*median = runs[1];
}
println!("{case} {mode:?} band={band}: old={:.2}ms new={:.2}ms speedup={:.2}x exact=true", medians[0], medians[1], medians[0] / medians[1]);
}
}
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Tile-wise selection inversion, with shared solid tiles and no canvas-sized buffers.
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use photocraft_color::{PixelFormat, read_sample};
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
use crate::photo_util::par_map;
use super::coverage8;
/// Invert coverage within `area`, returning a zero-default GRAY8 selection with nothing
/// outside it. Missing tiles become shared solid tiles; selected solid tiles disappear.
/// Each distinct interior source tile is inverted only once, preserving sharing and snapshots.
/// Selections are GRAY8; other formats use their first channel, quantized to selection coverage.
pub fn inverse_surface(source: &Surface, area: Rect) -> Surface {
let mut out = Surface::new(PixelFormat::GRAY8);
if area.is_empty() {
return out;
}
let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0]));
let default = coverage8(1.0 - source.default_pixel().first().copied().unwrap_or(0.0));
let missing = match default {
0 => None,
255 => Some(Arc::clone(&full)),
v => Some(out.solid_tile(&[f32::from(v) / 255.0])),
};
// Solid fills share one payload across many coordinates. Cache by payload, rather than
// scanning/copying the same 64 KiB for every tile. OnceLock allows parallel tile workers.
let cache: HashMap<_, OnceLock<Option<Arc<Tile>>>> =
source.tiles().filter(|(c, _)| area.contains_rect(&c.rect())).map(|(_, t)| (Arc::as_ptr(t) as usize, OnceLock::new())).collect();
let format = source.format();
let value = |t: &Tile, i: usize| {
if format == PixelFormat::GRAY8 {
t.bytes().get(i).map_or(default, |b| 255 - b)
} else {
let bpp = format.bytes_per_pixel();
t.bytes().get(i * bpp..(i + 1) * bpp).map_or(default, |pixel| coverage8(1.0 - read_sample(pixel, format.sample, 0)))
}
};
let coords: Vec<TileCoord> = area.tiles().collect();
let built = par_map(coords.len(), |k| {
let tc = *coords.get(k)?;
let rect = tc.rect();
let cut = rect.intersect(&area);
let input = source.tile(tc);
// At i32::MAX the coordinate rectangle is saturated and omits the tile's last
// column/row, so it must still take the clipped path.
if cut == rect && cut.width() == TILE_SIZE as u32 && cut.height() == TILE_SIZE as u32 {
let Some(input) = input else { return missing.clone().map(|t| (tc, t)) };
let cell = cache.get(&(Arc::as_ptr(input) as usize))?;
let tile = cell.get_or_init(|| {
if format == PixelFormat::GRAY8 {
if input.bytes() == full.bytes() {
return None;
}
if input.bytes() == blank.bytes() {
return Some(Arc::clone(&full));
}
let mut tile = Tile::clone(input);
for b in tile.bytes_mut() {
*b = 255 - *b;
}
return Some(Arc::new(tile));
}
let mut tile = Tile::clone(&blank);
for (i, b) in tile.bytes_mut().iter_mut().enumerate() {
*b = value(input, i);
}
canonical(tile, &blank, &full)
});
return tile.clone().map(|t| (tc, t));
}
// Boundary tiles start at zero so no inverted coverage leaks past the canvas.
let mut tile = Tile::clone(&blank);
let (x0, x1) = ((cut.x0 - rect.x0) as usize, (cut.x1 - rect.x0) as usize);
for y in (cut.y0 - rect.y0) as usize..(cut.y1 - rect.y0) as usize {
let start = y * TILE_SIZE as usize + x0;
if let Some(row) = tile.bytes_mut().get_mut(start..start + x1 - x0) {
for (i, b) in (start..).zip(row) {
*b = input.map_or(default, |t| value(t, i));
}
}
}
canonical(tile, &blank, &full).map(|t| (tc, t))
});
out.put_tiles(built.into_iter().flatten());
out
}
fn canonical(tile: Tile, blank: &Arc<Tile>, full: &Arc<Tile>) -> Option<Arc<Tile>> {
if tile.bytes() == blank.bytes() {
None
} else if tile.bytes() == full.bytes() {
Some(Arc::clone(full))
} else {
Some(Arc::new(tile))
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,85 @@
use super::*;
use photocraft_color::{ColorMode, SampleType};
fn reference(source: &Surface, area: Rect) -> Surface {
let data: Vec<f32> = source.read_region(area).chunks_exact(source.channels()).map(|p| 1.0 - p[0]).collect();
let mut out = Surface::new(PixelFormat::GRAY8);
out.write_region(area, &data);
out.prune();
out
}
#[test]
fn inverse_matches_float_reference_and_clips_boundary_tiles() {
// Negative, misaligned and empty bounds, including stored pixels beyond the canvas.
for area in [Rect::new(-17, -11, 530, 270), Rect::new(0, 0, 512, 256), Rect::new(3, 5, 4, 6), Rect::EMPTY] {
for sample in SampleType::ALL {
for default in [0.0, 0.25, 1.0] {
let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false);
let mut source = Surface::with_default(fmt, &[default]);
source.fill_rect(Rect::new(-100, -100, 260, 120), &[1.0]);
// Every encoded GRAY8 value, including feathered coverage at tile seams.
let values: Vec<f32> = (0..256).map(|v| v as f32 / 255.0).collect();
source.write_region(Rect::new(240, 125, 496, 126), &values);
source.write_pixel(750, 280, &[0.7]);
let got = inverse_surface(&source, area);
let want = reference(&source, area);
let window = Rect::new(-30, -20, 800, 290);
assert_eq!(got.to_interleaved(window), want.to_interleaved(window), "{sample:?} {default} {area:?}");
assert_eq!(got.default_bytes(), &[0]);
assert_eq!(got.tile_count(), want.tile_count(), "zero tiles must be pruned");
}
}
}
}
#[test]
fn inverse_preserves_sparse_storage_and_shared_payloads() {
let area = Rect::new(0, 0, 2048, 1024);
let empty = Surface::new(PixelFormat::GRAY8);
let selected = inverse_surface(&empty, area);
assert_eq!(selected.tile_count(), 32);
let full = selected.tile(TileCoord::new(0, 0)).unwrap();
assert!(selected.tiles().all(|(_, t)| Arc::ptr_eq(t, full)), "missing tiles share one solid payload");
let cleared = inverse_surface(&selected, area);
assert_eq!(cleared.tile_count(), 0, "fully selected tiles disappear");
let mut mixed = Surface::new(PixelFormat::GRAY8);
mixed.write_pixel(10, 11, &[0.25]);
let payload = mixed.tile(TileCoord::new(0, 0)).unwrap().clone();
mixed.put_tiles(area.tiles().map(|c| (c, payload.clone())));
let snapshot = mixed.clone();
let inverted = inverse_surface(&mixed, area);
let result = inverted.tile(TileCoord::new(0, 0)).unwrap();
assert!(inverted.tiles().all(|(_, t)| Arc::ptr_eq(t, result)), "shared mixed tiles are inverted once");
assert!(!Arc::ptr_eq(result, &payload));
assert_eq!(mixed.to_interleaved(area), snapshot.to_interleaved(area));
assert_eq!(inverse_surface(&inverted, area).to_interleaved(area), mixed.to_interleaved(area));
}
#[test]
fn inverse_of_unpruned_zero_tiles_is_shared_full() {
let mut source = Surface::new(PixelFormat::GRAY8);
let area = Rect::new(0, 0, 768, 256);
source.fill_rect(Rect::new(0, 0, 256, 256), &[0.0]);
source.fill_rect(Rect::new(256, 0, 512, 256), &[1.0]);
let out = inverse_surface(&source, area);
assert_eq!(out.tile_count(), 2);
assert!(out.tile(TileCoord::new(1, 0)).is_none());
assert!(Arc::ptr_eq(out.tile(TileCoord::new(0, 0)).unwrap(), out.tile(TileCoord::new(2, 0)).unwrap()));
}
#[test]
fn inverse_clips_at_coordinate_limits_and_uses_first_channel() {
for area in [Rect::new(i32::MAX - 255, 0, i32::MAX, 256), Rect::new(0, i32::MAX - 255, 256, i32::MAX), Rect::new(i32::MIN, -1, i32::MIN + 3, 2)] {
let source = Surface::new(PixelFormat::GRAY8);
let out = inverse_surface(&source, area);
assert_eq!(out.to_interleaved(area), reference(&source, area).to_interleaved(area));
assert_eq!(out.sample_channel(area.x1, area.y0, 0), 0.0);
assert_eq!(out.sample_channel(area.x0, area.y1, 0), 0.0);
}
let area = Rect::new(0, 0, 513, 260);
let mut source = Surface::with_default(PixelFormat::RGBA32F, &[0.25, 0.5, 0.75, 1.0]);
source.fill_rect(Rect::new(10, 10, 400, 200), &[0.75, 1.0, 0.0, 0.5]);
assert_eq!(inverse_surface(&source, area).to_interleaved(area), reference(&source, area).to_interleaved(area));
}
+191
View File
@@ -0,0 +1,191 @@
//! Separable blurs described as data, so another device (the GPU compositor's layer operators,
//! #5772) runs exactly the blur the CPU runs: the kernel weights or box widths come from here,
//! and [`crate::blur`] builds its own passes from the same description, so the two can't drift.
//!
//! [`OperatorRegion`] is the region rule that goes with it: which source pixels an output area
//! reads, with the edge pixels repeated at the extent as the CPU filters do.
use photocraft_geom::Rect;
use crate::{FilterParams, blur};
/// A blur made of passes along each axis, horizontal first, as the CPU runs it.
#[derive(Clone, Debug, PartialEq)]
pub enum Separable {
/// One pass of this normalised kernel (odd length, centred) along each axis.
Kernel(Vec<f32>),
/// Running-mean box passes of these odd widths, in turn, along each axis.
Boxes(Vec<usize>),
}
impl Separable {
/// The blur `params` runs on the CPU, or `None` for a filter that isn't one (only Gaussian
/// Blur so far). Small radii use the exact kernel, larger ones three box passes.
pub fn of(params: &FilterParams) -> Option<Self> {
match *params {
FilterParams::GaussianBlur { radius } => Some(match blur::box_widths(params) {
Some(boxes) => Separable::Boxes(boxes),
None => Separable::Kernel(blur::gaussian_blur_kernel(radius)),
}),
_ => None,
}
}
/// How far outside an output pixel the blur reads, along each axis.
pub fn reach(&self) -> i32 {
let reach = match self {
Separable::Kernel(k) => k.len() / 2,
Separable::Boxes(boxes) => boxes.iter().fold(0usize, |sum, w| sum.saturating_add(w / 2)),
};
i32::try_from(reach).unwrap_or(i32::MAX)
}
}
/// The area a neighbourhood filter on a layer repeats its edge pixels at: the canvas, or past it
/// where the layer has pixels off the canvas, as Photoshop does.
pub fn edge_extent(canvas: Rect, content: Rect) -> Rect {
canvas.union(&content)
}
/// Which source pixels an output area of a blur reads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OperatorRegion {
/// How far the blur reads outside an output pixel ([`Separable::reach`]).
pub reach: i32,
/// Where the source's edge pixels repeat ([`edge_extent`]): nothing outside it is read.
pub extent: Rect,
}
impl OperatorRegion {
/// The source pixels output `out` reads: `out` grown by the reach, inside the extent. Pixels
/// it needs beyond the extent repeat the edge of this area, so they need no source of their
/// own. Empty when `out` is empty or doesn't come within reach of the extent.
pub fn input(&self, out: Rect) -> Rect {
if out.is_empty() {
return Rect::EMPTY;
}
out.inflate(self.reach.max(0)).intersect(&self.extent)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Halo;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::{Interrupt, Surface};
const RADII: [f32; 11] = [0.01, 0.05, 0.1, 0.3, 0.8, 2.0, 4.0, 4.5, 12.0, 40.0, 250.0];
/// The CPU blur's own choice: the exact kernel up to radius 4, boxes beyond; and its halo
/// (how far the CPU grows an output area) covers the reach.
#[test]
fn the_description_is_the_cpus_blur() {
for radius in RADII {
let params = FilterParams::GaussianBlur { radius };
let blur = Separable::of(&params).unwrap();
match &blur {
Separable::Kernel(k) => {
assert!(radius <= 4.0, "radius {radius}");
assert_eq!(k, &blur::gaussian_blur_kernel(radius));
assert_eq!(k.len() % 2, 1);
assert!((k.iter().sum::<f32>() - 1.0).abs() < 1e-5);
}
Separable::Boxes(b) => {
assert!(radius > 4.0, "radius {radius}");
assert_eq!(Some(b.clone()), blur::box_widths(&params));
assert!(b.iter().all(|w| w % 2 == 1));
}
}
let Halo::Radius(halo) = params.halo() else { panic!("a Gaussian has a radius halo") };
assert!(blur.reach() <= halo, "radius {radius}: reach {} past the halo {halo}", blur.reach());
}
assert_eq!(Separable::of(&FilterParams::BoxBlur { radius: 9.0 }), None, "not a Gaussian (yet)");
assert_eq!(Separable::Boxes(vec![usize::MAX, 3]).reach(), i32::MAX, "saturates");
}
/// One opaque pixel in a transparent layer, blurred on the CPU, spreads no further than the
/// reach (beyond it the box passes' running sums leave only float residue, far below a
/// 16-bit level), and the exact kernel spreads exactly that far.
#[test]
fn an_impulse_spreads_no_further_than_the_reach() {
for radius in RADII {
let params = FilterParams::GaussianBlur { radius };
let reach = Separable::of(&params).unwrap().reach();
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
let size = 2 * reach + 41;
let canvas = Rect::new(0, 0, size, size);
let mut s = Surface::new(fmt);
let dot = Rect::new(size / 2, size / 2, size / 2 + 1, size / 2 + 1);
s.fill_rect(dot, &[1.0, 1.0, 1.0, 1.0]);
let out = crate::apply_in_with(&s, &params, canvas, canvas, None, canvas, &Interrupt::NONE).unwrap();
let mut px = Vec::new();
out.read_region_into(canvas, &mut px);
let touched = px
.as_chunks::<4>()
.0
.iter()
.enumerate()
// The box passes' running sums leave float residue (~1e-8) after the impulse.
.filter(|(_, p)| p[3] > 1e-6)
.fold(Rect::EMPTY, |r, (i, _)| {
let (x, y) = ((i as i32) % size, (i as i32) / size);
r.union(&Rect::new(x, y, x + 1, y + 1))
});
// Nothing past the reach; the exact kernel reaches all of it. (A single pixel's box
// tails are smaller than the running sums' residue, so they aren't checked for that.)
assert!(dot.inflate(reach).contains_rect(&touched), "radius {radius}: {touched:?} past the reach {reach}");
if let Some(Separable::Kernel(k)) = Separable::of(&params) {
// Calibrated subpixel kernels have tails below the visibility threshold.
let center = k.len() / 2;
let visible = k.iter().enumerate().filter(|(_, w)| **w * k[center] > 1e-6).map(|(i, _)| i.abs_diff(center)).max().unwrap_or(0);
assert_eq!(touched, dot.inflate(visible as i32), "radius {radius}");
}
}
}
/// Pixels outside [`OperatorRegion::input`] don't change an output area's result, and the
/// edge repeats at the extent: a layer's area next to the extent's edge blurs the same
/// whatever lies beyond it.
#[test]
fn output_depends_only_on_the_input_region() {
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
let extent = Rect::new(0, 0, 300, 200);
let noise = |seed: u32| {
let mut s = Surface::new(fmt);
for y in 0..200 {
for x in 0..300 {
let h = (x as u32).wrapping_mul(73_856_093) ^ (y as u32).wrapping_mul(19_349_663) ^ seed;
let v = (h % 1000) as f32 / 999.0;
s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[v, 1.0 - v, (h % 7) as f32 / 6.0, (h % 5) as f32 / 4.0]);
}
}
s
};
let a = noise(1);
for radius in [0.8, 4.0, 12.0] {
let params = FilterParams::GaussianBlur { radius };
let region = OperatorRegion { reach: Separable::of(&params).unwrap().reach(), extent };
for out in [Rect::new(100, 80, 140, 120), Rect::new(0, 0, 30, 25), Rect::new(270, 170, 300, 200)] {
let input = region.input(out);
assert!(extent.contains_rect(&input) && input.contains_rect(&out));
// Everything outside the input region replaced by other pixels.
let mut b = noise(2);
let mut inside = Vec::new();
a.read_region_into(input, &mut inside);
b.write_region(input, &inside);
let blur = |s: &Surface| {
let r = crate::apply_in_with(s, &params, out, extent, None, extent, &Interrupt::NONE).unwrap();
let mut px = Vec::new();
r.read_region_into(out, &mut px);
px
};
assert_eq!(blur(&a), blur(&b), "radius {radius}, {out:?}");
}
}
assert_eq!(OperatorRegion { reach: 5, extent }.input(Rect::EMPTY), Rect::EMPTY);
assert_eq!(OperatorRegion { reach: 5, extent }.input(Rect::new(400, 400, 410, 410)), Rect::EMPTY, "out of reach of the extent");
assert_eq!(OperatorRegion { reach: -3, extent }.input(Rect::new(10, 10, 20, 20)), Rect::new(10, 10, 20, 20));
assert_eq!(edge_extent(extent, Rect::new(-10, 50, 20, 60)), Rect::new(-10, 0, 300, 200));
}
}
+29 -2
View File
@@ -26,8 +26,9 @@ fn bits(v: Vec<f32>) -> Vec<u32> {
const MODELS: [ColorMode; 8] =
[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Bitmap, ColorMode::Duotone, ColorMode::Indexed, ColorMode::Multichannel];
#[test]
fn swept_blocks_match_the_pixel_search() {
// Keep the same daily/full-sweep cases, but expose four independent tests to nextest so one
// large reference comparison cannot hold a runner after its other tests have finished.
fn swept_blocks_match_the_pixel_search_part(part: u64) {
let bounds = Rect::new(-13, -9, 31, 26);
// Unless the heights are level-based, the colour mode reaches extrude only through the channel
// count and whether the samples are CMYK ink, so modes that agree on both repeat those cases.
@@ -35,6 +36,7 @@ fn swept_blocks_match_the_pixel_search() {
let mut seen = std::collections::HashSet::new();
let sweep = photocraft_testkit::Sweep::new(4);
let mut case = 0;
let mut selected = 0;
for mode in MODELS {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
@@ -48,6 +50,11 @@ fn swept_blocks_match_the_pixel_search() {
if !sweep.includes(case) {
continue;
}
let assigned = selected % 4;
selected += 1;
if assigned != part {
continue;
}
let spec = ExtrudeSpec {
kind: ExtrudeType::Blocks,
size,
@@ -70,6 +77,26 @@ fn swept_blocks_match_the_pixel_search() {
}
}
#[test]
fn swept_blocks_match_the_pixel_search_part_0() {
swept_blocks_match_the_pixel_search_part(0);
}
#[test]
fn swept_blocks_match_the_pixel_search_part_1() {
swept_blocks_match_the_pixel_search_part(1);
}
#[test]
fn swept_blocks_match_the_pixel_search_part_2() {
swept_blocks_match_the_pixel_search_part(2);
}
#[test]
fn swept_blocks_match_the_pixel_search_part_3() {
swept_blocks_match_the_pixel_search_part(3);
}
#[test]
fn streak_scatter_matches_the_pixel_search() {
let bounds = Rect::new(-17, -9, 39, 14);
+382 -83
View File
@@ -22,7 +22,7 @@ use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
use crate::photo_util::SparseLs;
use crate::photo_util::{BandChol, Csr, par_rows, pcg, solve_dense};
use crate::transform::Interp;
/// Lens projection of the source image.
@@ -234,95 +234,293 @@ impl WideMesh {
}
}
/// Solves the content-preserving warp for `p` over `frame`.
pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh {
let cam = Camera::new(p, frame);
let (fw, fh) = (frame.width().max(1) as f64, frame.height().max(1) as f64);
let cells = 48.0;
let cell = fw.max(fh) / cells;
let nx = ((fw / cell).round() as usize).clamp(2, 96);
let ny = ((fh / cell).round() as usize).clamp(2, 96);
let (cw, ch) = (fw / nx as f64, fh / ny as f64);
let w1 = nx + 1;
let nv = w1 * (ny + 1);
let src: Vec<[f64; 2]> = (0..nv).map(|k| [frame.x0 as f64 + (k % w1) as f64 * cw, frame.y0 as f64 + (k / w1) as f64 * ch]).collect();
let curves: Vec<Vec<[f64; 2]>> = p.constraints.iter().map(|c| cam.arc(c.a, c.b, 24)).collect();
let mut x: Vec<f64> = src.iter().flat_map(|s| [s[0], s[1]]).collect();
// Bilinear weights of a source point in the grid.
let weights = |q: [f64; 2]| -> [(usize, f64); 4] {
let gx = ((q[0] - frame.x0 as f64) / cw).clamp(0.0, nx as f64 - 1e-9);
let gy = ((q[1] - frame.y0 as f64) / ch).clamp(0.0, ny as f64 - 1e-9);
/// Weight of the line rows (applied squared in the normal equations).
const LINE_WEIGHT: f64 = 10.0;
/// Weight of the weak anchoring to the source (it fixes the global similarity).
const ANCHOR_WEIGHT: f64 = 0.02;
/// Grid cells along the longer side of the frame.
const GRID_CELLS: f64 = 48.0;
/// A sparse row `Σ coef·x[idx]`.
type Row = Vec<(usize, f64)>;
/// A solver for `(K + LᵀL) x = rhs` given the grid, the line rows `L` and a warm start.
type LinSolve<'a> = dyn FnMut(&Grid, &[Row], &mut Vec<f64>) + 'a;
/// The warp's grid over a frame; the unknowns are the output positions of its vertices,
/// interleaved `[x0, y0, x1, y1, …]`.
struct Grid {
frame: Rect,
nx: usize,
ny: usize,
cw: f64,
ch: f64,
w1: usize,
nv: usize,
/// Source position of each vertex.
src: Vec<[f64; 2]>,
}
impl Grid {
fn new(frame: Rect, cells: f64) -> Grid {
let (fw, fh) = (frame.width().max(1) as f64, frame.height().max(1) as f64);
let cell = fw.max(fh) / cells;
let nx = ((fw / cell).round() as usize).clamp(2, 96);
let ny = ((fh / cell).round() as usize).clamp(2, 96);
let (cw, ch) = (fw / nx as f64, fh / ny as f64);
let w1 = nx + 1;
let nv = w1 * (ny + 1);
let src = (0..nv).map(|k| [frame.x0 as f64 + (k % w1) as f64 * cw, frame.y0 as f64 + (k / w1) as f64 * ch]).collect();
Grid { frame, nx, ny, cw, ch, w1, nv, src }
}
/// Bilinear weights of a source point in the grid.
fn weights(&self, q: [f64; 2]) -> [(usize, f64); 4] {
let gx = ((q[0] - self.frame.x0 as f64) / self.cw).clamp(0.0, self.nx as f64 - 1e-9);
let gy = ((q[1] - self.frame.y0 as f64) / self.ch).clamp(0.0, self.ny as f64 - 1e-9);
let (i, j) = (gx.floor() as usize, gy.floor() as usize);
let (tx, ty) = (gx - i as f64, gy - j as f64);
let a = j * w1 + i;
[(a, (1.0 - tx) * (1.0 - ty)), (a + 1, tx * (1.0 - ty)), (a + w1, (1.0 - tx) * ty), (a + w1 + 1, tx * ty)]
};
let tris = {
let mut t = Vec::new();
for j in 0..ny {
for i in 0..nx {
let a = j * w1 + i;
t.push([a, a + 1, a + w1 + 1]);
t.push([a, a + w1 + 1, a + w1]);
// Both diagonals so the shape term is symmetric.
t.push([a, a + 1, a + w1]);
t.push([a + 1, a + w1 + 1, a + w1]);
let a = j * self.w1 + i;
[(a, (1.0 - tx) * (1.0 - ty)), (a + 1, tx * (1.0 - ty)), (a + self.w1, (1.0 - tx) * ty), (a + self.w1 + 1, tx * ty)]
}
/// The anchoring right-hand side `ANCHOR_WEIGHT² · src`, interleaved.
fn anchor_rhs(&self) -> Vec<f64> {
self.src.iter().flat_map(|s| [ANCHOR_WEIGHT * ANCHOR_WEIGHT * s[0], ANCHOR_WEIGHT * ANCHOR_WEIGHT * s[1]]).collect()
}
}
/// The two shape rows (x then y) that pin vertex `i0` to `v1 + u (v2 − v1) + v R90 (v2 − v1)`,
/// with `(u, v)` taken from the source positions. Vertex ids are whatever the caller numbers its
/// unknowns by; the rows' right-hand sides are zero.
fn shape_rows(i: [usize; 3], p: [[f64; 2]; 3]) -> [[(usize, f64); 5]; 2] {
let (e, d) = ([p[2][0] - p[1][0], p[2][1] - p[1][1]], [p[0][0] - p[1][0], p[0][1] - p[1][1]]);
let l2 = e[0] * e[0] + e[1] * e[1];
let u = (d[0] * e[0] + d[1] * e[1]) / l2;
let v = (d[0] * -e[1] + d[1] * e[0]) / l2;
let (i0, i1, i2) = (i[0], i[1], i[2]);
[
// x: v0x − v1x − u (v2x − v1x) + v (v2y − v1y) = 0
[(2 * i0, 1.0), (2 * i1, -1.0 + u), (2 * i2, -u), (2 * i2 + 1, v), (2 * i1 + 1, -v)],
// y: v0y − v1y − u (v2y − v1y) − v (v2x − v1x) = 0
[(2 * i0 + 1, 1.0), (2 * i1 + 1, -1.0 + u), (2 * i2 + 1, -u), (2 * i2, -v), (2 * i1, v)],
]
}
/// The four triangles of a cell over its corners `[a, a + 1, a + w1, a + w1 + 1]` (as 0..4);
/// both diagonals are used so the shape term is symmetric.
const CELL_TRIS: [[usize; 3]; 4] = [[0, 1, 3], [0, 3, 2], [0, 1, 2], [1, 3, 2]];
/// Every shape row of the grid (with global vertex ids), for reference solves.
#[cfg(test)]
fn all_shape_rows(g: &Grid) -> Vec<Row> {
let mut out = Vec::new();
for j in 0..g.ny {
for i in 0..g.nx {
let a = j * g.w1 + i;
let corner = [a, a + 1, a + g.w1, a + g.w1 + 1];
for t in CELL_TRIS {
for rot in 0..3 {
let ids = [corner[t[rot]], corner[t[(rot + 1) % 3]], corner[t[(rot + 2) % 3]]];
for row in shape_rows(ids, ids.map(|k| g.src[k])) {
out.push(row.to_vec());
}
}
}
}
t
};
}
out
}
/// The line rows (already multiplied by `LINE_WEIGHT`): every sample of a constraint's curve is
/// tied to the curve's first point along the constraint's normal.
fn line_rows(g: &Grid, curves: &[Vec<[f64; 2]>], active: &[usize], normals: &[[f64; 2]], p: &WideAngle) -> Vec<Row> {
let mut rows = Vec::new();
for (ci, &k) in active.iter().enumerate() {
let Some(c) = curves.get(k) else { continue };
let n = match p.constraints.get(k).map(|c| c.orientation) {
Some(Orientation::Horizontal) => [0.0, 1.0],
Some(Orientation::Vertical) => [1.0, 0.0],
_ => normals.get(ci).copied().unwrap_or([0.0, 1.0]),
};
let Some(first) = c.first() else { continue };
let w0 = g.weights(*first);
for q in c.iter().skip(1) {
let mut row: Row = Vec::with_capacity(16);
for &(i, w) in &g.weights(*q) {
row.push((2 * i, LINE_WEIGHT * n[0] * w));
row.push((2 * i + 1, LINE_WEIGHT * n[1] * w));
}
for &(i, w) in &w0 {
row.push((2 * i, -LINE_WEIGHT * n[0] * w));
row.push((2 * i + 1, -LINE_WEIGHT * n[1] * w));
}
rows.push(row);
}
}
rows
}
/// Above this many line rows the low-rank update's dense `rows × rows` system gets too big and
/// the solve falls back to conjugate gradients.
const MAX_UPDATE_ROWS: usize = 256;
/// The part of the normal equations that doesn't depend on the constraints: the shape and
/// anchor terms `K = Σ rᵀr + ANCHOR_WEIGHT²·I`, assembled and factored once per solve.
struct StaticSystem {
k: Csr,
chol: Option<BandChol>,
rhs: Vec<f64>,
/// `K⁻¹ · rhs`, the solution without any line rows.
base: Vec<f64>,
}
impl StaticSystem {
fn new(g: &Grid) -> StaticSystem {
let n = 2 * g.nv;
let mut trip: Vec<(usize, usize, f64)> = Vec::with_capacity(g.nx * g.ny * 64 + n);
for j in 0..g.ny {
for i in 0..g.nx {
let a = j * g.w1 + i;
let corner = [a, a + 1, a + g.w1, a + g.w1 + 1];
// The cell's 24 shape rows only involve its own 8 unknowns: accumulate their
// 8 × 8 normal block (unknown `2·c + axis` is corner `c`'s coordinate).
let mut m = [[0.0f64; 8]; 8];
for t in CELL_TRIS {
for rot in 0..3 {
let local = [t[rot], t[(rot + 1) % 3], t[(rot + 2) % 3]];
for row in shape_rows(local, local.map(|c| g.src[corner[c]])) {
for &(si, ca) in &row {
for &(sj, cb) in &row {
m[si][sj] += ca * cb;
}
}
}
}
}
for (si, mi) in m.iter().enumerate() {
for (sj, &v) in mi.iter().enumerate() {
trip.push((2 * corner[si / 2] + si % 2, 2 * corner[sj / 2] + sj % 2, v));
}
}
}
}
for d in 0..n {
trip.push((d, d, ANCHOR_WEIGHT * ANCHOR_WEIGHT));
}
let k = Csr::from_triplets(n, trip);
let chol = BandChol::new(&k);
let rhs = g.anchor_rhs();
let mut base = vec![0.0; n];
if let Some(c) = &chol {
c.solve(&rhs, &mut base);
}
StaticSystem { k, chol, rhs, base }
}
/// Solves `(K + LᵀL) x = rhs` for the line rows `L = lines`. With `K`'s factor this is the
/// exact low-rank update `x = K⁻¹b − K⁻¹Lᵀ (I + L K⁻¹ Lᵀ)⁻¹ L K⁻¹b` (one parallel solve per
/// line row). Without it, or with very many rows, conjugate gradients warm-started from `x`.
/// A solve that goes non-finite leaves `x` as it was.
fn solve(&self, lines: &[Row], x: &mut Vec<f64>) {
let n = self.rhs.len();
let updated = match &self.chol {
Some(chol) if lines.len() <= MAX_UPDATE_ROWS => self.solve_update(chol, lines),
_ => None,
};
let solved = match updated {
Some(v) => v,
None => {
let mut v = x.clone();
self.solve_cg(lines, &mut v);
v
}
};
if solved.len() == n && solved.iter().all(|v| v.is_finite()) {
*x = solved;
}
}
fn solve_update(&self, chol: &BandChol, lines: &[Row]) -> Option<Vec<f64>> {
let (n, m) = (self.rhs.len(), lines.len());
if m == 0 {
return Some(self.base.clone());
}
// Column `j` of `K⁻¹Lᵀ` is `K⁻¹` applied to line row `j`.
let mut cols = vec![0.0; n * m];
par_rows(&mut cols, n, 1, |j, col| {
let mut dense = vec![0.0; n];
for &(i, c) in lines.get(j).into_iter().flatten() {
if let Some(d) = dense.get_mut(i) {
*d += c;
}
}
chol.solve(&dense, col);
});
let dot_row = |row: &Row, v: &[f64]| -> f64 { row.iter().map(|&(i, c)| c * v.get(i).copied().unwrap_or(0.0)).sum() };
// S = I + L K⁻¹ Lᵀ, and t = L K⁻¹ b.
let mut s = vec![0.0; m * m];
for (a, row) in lines.iter().enumerate() {
for (b, col) in cols.chunks(n).enumerate() {
s[a * m + b] = dot_row(row, col) + if a == b { 1.0 } else { 0.0 };
}
}
let t: Vec<f64> = lines.iter().map(|row| dot_row(row, &self.base)).collect();
let z = solve_dense(m, s, t)?;
let mut x = self.base.clone();
for (zj, col) in z.iter().zip(cols.chunks(n)) {
for (xi, ci) in x.iter_mut().zip(col) {
*xi -= zj * ci;
}
}
Some(x)
}
fn solve_cg(&self, lines: &[Row], x: &mut [f64]) {
let op = |v: &[f64], out: &mut [f64]| {
self.k.mul(v, out);
for row in lines {
let t: f64 = row.iter().map(|&(i, c)| c * v.get(i).copied().unwrap_or(0.0)).sum();
for &(i, c) in row {
if let Some(o) = out.get_mut(i) {
*o += c * t;
}
}
}
};
match &self.chol {
Some(chol) => {
pcg(op, |r, z| chol.solve(r, z), &self.rhs, x, 1e-8, 500);
}
// The band was too wide to factor: Jacobi-preconditioned, and many more iterations.
None => {
let diag = &self.k.diag;
pcg(op, |r, z| z.iter_mut().zip(r.iter().zip(diag)).for_each(|(z, (r, d))| *z = r / d), &self.rhs, x, 1e-8, 4000);
}
}
}
}
/// Solves the content-preserving warp for `p` over `frame`.
pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh {
let mut system: Option<StaticSystem> = None;
solve_with(p, frame, GRID_CELLS, &mut |g, lines, x| system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x))
}
/// [`solve`] with the grid resolution and the linear solver for `(K + LᵀL) x = rhs` (given the
/// current line rows and a warm start) chosen by the caller.
fn solve_with(p: &WideAngle, frame: Rect, cells: f64, linsolve: &mut LinSolve<'_>) -> WideMesh {
let cam = Camera::new(p, frame);
let g = Grid::new(frame, cells);
let (nx, ny, nv, src) = (g.nx, g.ny, g.nv, &g.src);
let curves: Vec<Vec<[f64; 2]>> = p.constraints.iter().map(|c| cam.arc(c.a, c.b, 24)).collect();
let mut x: Vec<f64> = src.iter().flat_map(|s| [s[0], s[1]]).collect();
let active: Vec<usize> = (0..curves.len()).filter(|&k| curves[k].len() >= 3).collect();
if !active.is_empty() {
let mut normals: Vec<[f64; 2]> = active.iter().map(|_| [0.0, 1.0]).collect();
for round in 0..4 {
let mut ls = SparseLs::new(2 * nv);
// Shape: v0 = v1 + u (v2 − v1) + v R90 (v2 − v1), with (u, v) from the source.
for t in &tris {
for rot in 0..3 {
let (i0, i1, i2) = (t[rot], t[(rot + 1) % 3], t[(rot + 2) % 3]);
let (p0, p1, p2) = (src[i0], src[i1], src[i2]);
let e = [p2[0] - p1[0], p2[1] - p1[1]];
let l2 = e[0] * e[0] + e[1] * e[1];
let d = [p0[0] - p1[0], p0[1] - p1[1]];
let u = (d[0] * e[0] + d[1] * e[1]) / l2;
let v = (d[0] * -e[1] + d[1] * e[0]) / l2;
// x: v0x − v1x − u (v2x − v1x) + v (v2y − v1y) = 0
ls.add(vec![(2 * i0, 1.0), (2 * i1, -1.0 + u), (2 * i2, -u), (2 * i2 + 1, v), (2 * i1 + 1, -v)], 0.0, 1.0);
// y: v0y − v1y − u (v2y − v1y) − v (v2x − v1x) = 0
ls.add(vec![(2 * i0 + 1, 1.0), (2 * i1 + 1, -1.0 + u), (2 * i2 + 1, -u), (2 * i2, -v), (2 * i1, v)], 0.0, 1.0);
}
}
// Weak anchoring to the source (fixes the global similarity).
for (k, s) in src.iter().enumerate() {
ls.add(vec![(2 * k, 1.0)], s[0], 0.02);
ls.add(vec![(2 * k + 1, 1.0)], s[1], 0.02);
}
// Lines.
for (ci, &k) in active.iter().enumerate() {
let c = &curves[k];
let n = match p.constraints[k].orientation {
Orientation::Horizontal => [0.0, 1.0],
Orientation::Vertical => [1.0, 0.0],
Orientation::Free => normals[ci],
};
let w0 = weights(c[0]);
for q in &c[1..] {
let wq = weights(*q);
let mut row: Vec<(usize, f64)> = Vec::with_capacity(16);
for &(i, w) in &wq {
row.push((2 * i, n[0] * w));
row.push((2 * i + 1, n[1] * w));
}
for &(i, w) in &w0 {
row.push((2 * i, -n[0] * w));
row.push((2 * i + 1, -n[1] * w));
}
ls.add(row, 0.0, 10.0);
}
}
x = ls.solve(&x, 400 + 200 * round);
linsolve(&g, &line_rows(&g, &curves, &active, &normals, p), &mut x);
// Re-estimate free normals from the current end points.
let pos = |q: [f64; 2]| -> [f64; 2] { weights(q).iter().fold([0.0, 0.0], |acc, &(i, w)| [acc[0] + w * x[2 * i], acc[1] + w * x[2 * i + 1]]) };
let pos = |q: [f64; 2]| -> [f64; 2] { g.weights(q).iter().fold([0.0, 0.0], |acc, &(i, w)| [acc[0] + w * x[2 * i], acc[1] + w * x[2 * i + 1]]) };
let mut changed = false;
for (ci, &k) in active.iter().enumerate() {
let c = &curves[k];
@@ -350,7 +548,7 @@ pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh {
let mut mesh = WideMesh { nx, ny, verts, curves, residual: 0.0 };
// Straightness residual: distance of curve samples from the chord of their mapped ends.
let mut worst = 0.0f64;
for (k, cv) in mesh.curves.iter().enumerate() {
for cv in mesh.curves.iter() {
if cv.len() < 3 {
continue;
}
@@ -361,7 +559,6 @@ pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh {
for q in &pts {
worst = worst.max(((q[0] - a[0]) * d[1] - (q[1] - a[1]) * d[0]).abs() / l);
}
let _ = k;
}
mesh.residual = worst;
mesh
@@ -433,6 +630,108 @@ mod tests {
assert!((c[0] - 300.0).abs() < 80.0 && (c[1] - 250.0).abs() < 40.0, "{c:?}");
}
/// The exact solution of the same normal equations (dense, small grids only).
fn dense_linsolve(g: &Grid, lines: &[Row], x: &mut Vec<f64>) {
use crate::photo_util::Normal;
let mut ne = Normal::new(2 * g.nv);
for row in all_shape_rows(g) {
ne.add(&row, 0.0, 1.0);
}
for k in 0..2 * g.nv {
ne.add(&[(k, 1.0)], g.src[k / 2][k % 2], ANCHOR_WEIGHT);
}
for row in lines {
ne.add(row, 0.0, 1.0);
}
*x = ne.solve().expect("the anchored system is positive definite");
}
#[test]
fn direct_solve_matches_the_dense_reference() {
let frame = Rect::new(0, 0, 240, 160);
for constraints in [
vec![
Constraint { a: [30.0, 40.0], b: [210.0, 40.0], orientation: Orientation::Horizontal },
Constraint { a: [40.0, 30.0], b: [45.0, 130.0], orientation: Orientation::Free },
],
vec![
Constraint { a: [20.0, 120.0], b: [220.0, 110.0], orientation: Orientation::Free },
Constraint { a: [200.0, 20.0], b: [205.0, 140.0], orientation: Orientation::Vertical },
],
] {
let p = WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints, ..Default::default() };
let mut system: Option<StaticSystem> = None;
let fast = solve_with(&p, frame, 12.0, &mut |g, lines, x| system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x));
let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve);
assert_eq!(fast.verts.len(), exact.verts.len());
let worst = fast.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max);
assert!(worst < 0.05, "worst vertex difference {worst} px");
assert!(fast.residual <= exact.residual + 0.05, "residual {} vs exact {}", fast.residual, exact.residual);
}
}
/// More line rows than the low-rank update takes falls back to conjugate gradients, which
/// still agrees with the exact solution.
#[test]
fn many_constraints_use_the_conjugate_gradient_fallback() {
let frame = Rect::new(0, 0, 240, 160);
let constraints: Vec<Constraint> = (0..12)
.map(|k| Constraint {
a: [20.0 + 4.0 * k as f64, 15.0 + 10.0 * k as f64],
b: [210.0 - 3.0 * k as f64, 18.0 + 10.0 * k as f64],
orientation: Orientation::Horizontal,
})
.collect();
let p = WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints, ..Default::default() };
let mut rows = 0;
let mut system: Option<StaticSystem> = None;
let fast = solve_with(&p, frame, 12.0, &mut |g, lines, x| {
rows = lines.len();
system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x)
});
assert!(rows > MAX_UPDATE_ROWS, "{rows} line rows");
let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve);
let worst = fast.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max);
assert!(worst < 0.05, "worst vertex difference {worst} px");
}
/// Without a banded factor (band too wide) the Jacobi-preconditioned fallback still solves.
#[test]
fn unfactored_system_falls_back_to_jacobi_cg() {
let frame = Rect::new(0, 0, 240, 160);
let p = WideAngle {
model: WideModel::Fisheye,
focal_length: 8.0,
constraints: vec![Constraint { a: [30.0, 40.0], b: [210.0, 40.0], orientation: Orientation::Horizontal }],
..Default::default()
};
let mut system: Option<StaticSystem> = None;
let jacobi = solve_with(&p, frame, 12.0, &mut |g, lines, x| {
let sys = system.get_or_insert_with(|| {
let mut sys = StaticSystem::new(g);
sys.chol = None;
sys
});
sys.solve(lines, x)
});
let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve);
let worst = jacobi.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max);
assert!(worst < 0.1, "worst vertex difference {worst} px");
}
#[test]
fn hostile_frames_and_constraints_solve_to_finite_meshes() {
let wild = Constraint { a: [-1e6, 5e5], b: [1e6, -5e5], orientation: Orientation::Free };
let same = Constraint { a: [3.0, 3.0], b: [3.0, 3.0], orientation: Orientation::Free };
let nan = Constraint { a: [f64::NAN, 1.0], b: [2.0, f64::INFINITY], orientation: Orientation::Horizontal };
for frame in [Rect::new(0, 0, 1, 1), Rect::new(0, 0, 2, 200), Rect::new(0, 0, 400, 3), Rect::new(-50, -20, 90, 60)] {
let p =
WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints: vec![wild.clone(), same.clone(), nan.clone()], ..Default::default() };
let mesh = solve(&p, frame);
assert!(mesh.verts.iter().all(|(o, s)| o.iter().chain(s).all(|v| v.is_finite())), "{frame:?}");
}
}
#[test]
fn no_constraints_is_identity_and_renders() {
use photocraft_color::PixelFormat;
+4 -1
View File
@@ -86,6 +86,9 @@ impl Headless {
match method {
"engine.execute" => {
let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?;
if METHODS.contains(&id) && photocraft_engine::commands::find(id).is_none() {
return Err(bad(photocraft_engine::command_hints::protocol_method_as_command(id)));
}
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(true);
self.command_start(id, p.get("params").cloned().unwrap_or(Value::Null), wait)
}
@@ -186,7 +189,7 @@ impl Headless {
"doc.close" => self.close(index_of(&p)?),
"batch" => self.batch(&p),
"methods" => Ok(json!(METHODS)),
other => Err(bad(format!("unknown method `{other}` (try `methods`)"))),
other => Err(bad(format!("{} (try `methods`)", photocraft_engine::command_hints::unknown_method(other, METHODS)))),
}
}
+19 -1
View File
@@ -268,7 +268,8 @@ fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() {
}
h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": 85})).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]), "a WebP saved with a quality is lossy");
// The lossy chunk; an extended WebP (one carrying a colour profile) puts `VP8X` first.
assert!(bytes.windows(4).any(|c| c == b"VP8 ") && !bytes.windows(4).any(|c| c == b"VP8L"), "a WebP saved with a quality is lossy");
std::fs::remove_dir_all(dir).unwrap();
}
@@ -817,6 +818,23 @@ fn methods_lists_the_job_methods_and_every_listed_name_is_served() {
assert!(h.handle("jobs.nope", Value::Null).unwrap_err().to_string().contains("unknown method"));
}
/// #3674: a guessed name gets a pointer to the right one, in both directions.
#[test]
fn unknown_names_suggest_the_right_method_or_command() {
let mut h = Headless::new();
let err = |h: &mut Headless, m: &str, p: Value| h.handle(m, p).unwrap_err().to_string();
let e = err(&mut h, "engine.execute", json!({"command": "engine.commands"}));
assert!(e.contains("did you mean `command.list`") && e.contains("protocol method"), "{e}");
let e = err(&mut h, "engine.execute", json!({"command": "doc.inspect"}));
assert!(e.contains("`doc.inspect` is a protocol method"), "{e}");
let e = err(&mut h, "engine.execute", json!({"command": "edit.undoo"}));
assert!(e.contains("did you mean `edit.undo`"), "{e}");
let e = err(&mut h, "doc.inspct", Value::Null);
assert!(e.contains("did you mean `doc.inspect`") && e.contains("(try `methods`)"), "{e}");
let e = err(&mut h, "edit.undo", Value::Null);
assert!(e.contains("engine.execute {\"command\": \"edit.undo\"}"), "{e}");
}
#[test]
fn batch_response_budget_stops_later_steps_even_when_stop_on_error_is_false() {
let mut h = Headless::new();
+3 -1
View File
@@ -342,7 +342,9 @@ pub struct UiSetParams {
/// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled,
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
/// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware,
/// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// the Crop tool's Fill), penMode (path or shape, the Pen tools' mode), penPathOp (newLayer,
/// combine, subtract, intersect or exclude: how the next Pen path joins the selected shape
/// layer or path), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
+54 -24
View File
@@ -1,7 +1,7 @@
//! Capability-based filesystem policy for untrusted automation paths.
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use std::path::{Component, Components, Path, PathBuf, Prefix};
use std::sync::Arc;
use cap_std::ambient_authority;
@@ -16,16 +16,18 @@ const DENIED: &str = "automation filesystem access is not granted";
#[derive(Clone)]
struct RootCapability {
dir: Arc<Dir>,
/// The root as an absolute path (trusted launch configuration), to recognise absolute request
/// The canonical root path (trusted launch configuration), to recognise absolute request
/// paths that name a file beneath it.
path: PathBuf,
/// The trusted launch spelling also names the same held capability, even through a link.
configured_path: PathBuf,
}
/// Separate directory capabilities for automation reads and writes.
///
/// Root paths are trusted launch-time configuration. Request paths are always
/// untrusted, forward-slash relative paths resolved by `cap-std` beneath the
/// held directory handle, or absolute paths whose text lies beneath the root
/// held directory handle, or absolute paths beneath the canonical or configured root spelling
/// (translated to relative ones, see [`request_path`]). Parent traversal,
/// alternate separators, Windows prefixes and empty components are rejected
/// before I/O.
@@ -117,7 +119,10 @@ impl AuthorizedWorkspace {
// Best effort, and only when there is a file being replaced: a save still
// succeeds for a document whose Finder tag could not come along.
if let Ok(old) = root.dir.open(&relative) {
let old = old.into_std();
let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd());
// The creation time too (#9388), through the same handles.
let _ = photocraft_format::atomic::meta::copy_created_file(&old, &file);
}
}
// Falls back to a plain fsync on shares that refuse a full flush (#1336).
@@ -190,35 +195,60 @@ fn open_root(path: Option<&Path>, authority: &str) -> Result<Option<RootCapabili
if path.as_os_str().is_empty() {
return Err(AutomationError::BadRequest(format!("automation {authority} root is empty")));
}
let dir = Dir::open_ambient_dir(path, ambient_authority())
.map_err(|e| AutomationError::Io(format!("cannot open automation {authority} root `{}`: {e}", path.display())))?;
// Textual (no link resolution), like the request paths it is compared with.
// Windows absolute() removes parent components. Keep an absolute launch spelling intact:
// only that trusted prefix is exempt from request traversal checks, never its suffix.
let configured_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::path::absolute(path).map_err(|e| AutomationError::Io(format!("cannot resolve automation {authority} root `{}`: {e}", path.display())))?
};
// Resolve trusted configuration once, before opening that exact directory as a capability.
let absolute =
std::path::absolute(path).map_err(|e| AutomationError::Io(format!("cannot resolve automation {authority} root `{}`: {e}", path.display())))?;
Ok(Some(RootCapability { dir: Arc::new(dir), path: absolute }))
std::fs::canonicalize(path).map_err(|e| AutomationError::Io(format!("cannot resolve automation {authority} root `{}`: {e}", path.display())))?;
let dir = Dir::open_ambient_dir(&absolute, ambient_authority())
.map_err(|e| AutomationError::Io(format!("cannot open automation {authority} root `{}`: {e}", path.display())))?;
Ok(Some(RootCapability { dir: Arc::new(dir), path: absolute, configured_path }))
}
/// A request path as the relative path the root's capability resolves. An absolute path is
/// accepted only when its text names a file beneath `root`: the root's components are
/// stripped (the drive letter compared without case) and the rest must pass
/// [`relative_path`], so `..`, prefixes and device names are refused as before. Nothing is
/// resolved on the filesystem; the held directory handle still does all the opening, so
/// links cannot escape the root.
/// Both root spellings refer to the capability opened at startup. Do not resolve an untrusted
/// absolute request using ambient filesystem access: strip a known root first and let cap-std
/// resolve the remaining name. New targets and nested export directories need not exist yet.
fn request_path(raw: &str, root: Option<&RootCapability>) -> Result<PathBuf, AutomationError> {
let Some(root) = root.filter(|_| Path::new(raw).is_absolute()) else { return relative_path(raw) };
let mut request = Path::new(raw).components();
for want in root.path.components() {
let same = match (request.next(), want) {
(Some(Component::Prefix(a)), Component::Prefix(b)) => a.as_os_str().eq_ignore_ascii_case(b.as_os_str()),
(Some(got), want) => got == want,
(None, _) => false,
let path = Path::new(raw);
let Some(root) = root.filter(|_| path.is_absolute()) else { return relative_path(raw) };
// Prefer the trusted launch spelling: it can itself contain `..`. Only that
// exact granted prefix is exempt; traversal in the remaining request still fails below.
let request = path_beneath_root(path, &root.configured_path)
.or_else(|| path_beneath_root(path, &root.path))
.ok_or_else(|| path_error(raw, "absolute paths must be inside the automation root"))?;
let rest = request.map(|c| c.as_os_str().to_str().ok_or_else(|| path_error(raw, "path is not UTF-8"))).collect::<Result<Vec<_>, _>>()?;
relative_path(&rest.join("/"))
}
/// Strip a granted root prefix without resolving the client path with ambient authority.
/// Windows ASCII case variants name the same grant; the unstripped suffix keeps its case.
fn path_beneath_root<'a>(path: &'a Path, root: &Path) -> Option<Components<'a>> {
let mut request = path.components();
for want in root.components() {
let same = match (request.next()?, want) {
(Component::Prefix(a), Component::Prefix(b)) => match (a.kind(), b.kind()) {
// std::fs::canonicalize uses verbatim paths on Windows. Accept their ordinary
// drive/UNC spelling too, without treating a device prefix as a disk prefix.
(Prefix::Disk(a) | Prefix::VerbatimDisk(a), Prefix::Disk(b) | Prefix::VerbatimDisk(b)) => a.eq_ignore_ascii_case(&b),
(Prefix::UNC(a, b) | Prefix::VerbatimUNC(a, b), Prefix::UNC(c, d) | Prefix::VerbatimUNC(c, d)) => {
a.eq_ignore_ascii_case(c) && b.eq_ignore_ascii_case(d)
}
_ => a.as_os_str().eq_ignore_ascii_case(b.as_os_str()),
},
#[cfg(windows)]
(Component::Normal(a), Component::Normal(b)) => a.eq_ignore_ascii_case(b),
(got, want) => got == want,
};
if !same {
return Err(path_error(raw, "absolute paths must be inside the automation root"));
return None;
}
}
let rest: Vec<&str> = request.map(|c| c.as_os_str().to_str().unwrap_or("\u{FFFD}")).collect();
relative_path(&rest.join("/"))
Some(request)
}
fn relative_path(raw: &str) -> Result<PathBuf, AutomationError> {
+229
View File
@@ -162,6 +162,22 @@ fn write_refuses_a_fifo_target_without_blocking() {
assert!(temp_files(&inside).is_empty());
}
/// #9401 review: replacing a file through the automation path keeps its creation time, the
/// same as a save through `atomic_write` does on macOS (#9388).
#[cfg(target_os = "macos")]
#[test]
fn write_keeps_the_replaced_files_creation_time() {
use std::os::macos::fs::FileTimesExt;
let (inside, _, workspace) = roots("created");
let path = inside.join("doc.psd");
workspace.write("doc.psd", b"original").unwrap();
let born = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000);
std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_created(born)).unwrap();
workspace.write("doc.psd", b"replacement").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
assert_eq!(std::fs::metadata(&path).unwrap().created().unwrap(), born, "the creation time survives the automation write");
}
/// #7095: replacing a file through the automation path keeps the old file's extended
/// attributes, the same as a save through `atomic_write` does.
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
@@ -664,6 +680,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
| "path.convertPoint"
| "path.addAnchor"
| "path.deleteAnchor"
| "path.mergeShapeComponents"
| "path.setOperation"
| "path.clippingPath.set"
| "path.rename"
| "path.stroke"
@@ -766,6 +784,217 @@ fn absolute_paths_beneath_the_root_are_translated_and_others_refused() {
assert_eq!(std::fs::read(inside.join("a.bin")).unwrap(), b"one");
}
/// Equivalent drive, UNC and ASCII-case root spellings need no ambient path resolution.
#[cfg(windows)]
#[test]
fn canonical_windows_prefixes_match_without_ambient_resolution() {
for (request, root) in [
(r"c:\root\new.bin", r"\\?\C:\root"),
(r"\\?\C:\root\new.bin", r"C:\root"),
(r"\\server\share\root\new.bin", r"\\?\UNC\SERVER\SHARE\root"),
(r"c:\ROOT\new.bin", r"\\?\C:\root"),
(r"\\?\C:\ROOT\new.bin", r"c:\root"),
(r"\\server\share\ROOT\new.bin", r"\\?\UNC\SERVER\SHARE\root"),
] {
let remaining = path_beneath_root(Path::new(request), Path::new(root)).unwrap();
assert_eq!(remaining.as_path(), Path::new("new.bin"));
}
for request in [r"C:\root-sibling\new.bin", r"D:\root\new.bin", r"\\.\C:\root\new.bin", r"\\other\share\root\new.bin"] {
assert!(path_beneath_root(Path::new(request), Path::new(r"\\?\C:\root")).is_none());
}
}
/// Case variants of the granted Windows prefix still use its held directory capability.
#[cfg(windows)]
#[test]
fn windows_root_case_variants_keep_io_inside_the_grant() {
let (inside, outside, workspace) = roots("root-case");
let configured = PathBuf::from(inside.to_string_lossy().to_ascii_uppercase());
let canonical = PathBuf::from(std::fs::canonicalize(&inside).unwrap().to_string_lossy().to_ascii_uppercase());
for spelling in [&configured, &canonical] {
let path = spelling.join("new.png");
workspace.write(&path.to_string_lossy(), b"inside").unwrap();
assert_eq!(workspace.read(&path.to_string_lossy()).unwrap(), b"inside");
assert!(workspace.write(&spelling.join("..").join("outside").join("blocked.bin").to_string_lossy(), b"no").is_err());
}
assert_eq!(std::fs::read(inside.join("new.png")).unwrap(), b"inside");
assert!(workspace.write(&configured.with_file_name("INSIDE-SIBLING").join("blocked.bin").to_string_lossy(), b"no").is_err());
assert!(!outside.join("blocked.bin").exists());
// Case normalization must not bypass the refusal to replace unrelated existing files.
std::fs::write(inside.join("notes.bin"), b"keep").unwrap();
assert!(workspace.write(&configured.join("notes.bin").to_string_lossy(), b"no").is_err());
assert_eq!(std::fs::read(inside.join("notes.bin")).unwrap(), b"keep");
}
/// Unix roots with different case must not become aliases of the granted prefix.
#[cfg(unix)]
#[test]
fn unix_root_prefixes_remain_case_sensitive() {
assert!(path_beneath_root(Path::new("/Root/new.bin"), Path::new("/root")).is_none());
}
#[test]
fn configured_parent_components_accept_resolved_absolute_paths() {
let (inside, outside, _) = roots("resolved-parent-root");
let configured = inside.join("..").join("inside");
let workspace = AuthorizedWorkspace::new(Some(&configured), Some(&configured)).unwrap();
let resolved = std::fs::canonicalize(&inside).unwrap();
workspace.write(&resolved.join("new.bin").to_string_lossy(), b"new").unwrap();
assert_eq!(workspace.read(&resolved.join("new.bin").to_string_lossy()).unwrap(), b"new");
workspace.write(&configured.join("launch-spelling.bin").to_string_lossy(), b"trusted prefix").unwrap();
assert_eq!(workspace.read(&configured.join("launch-spelling.bin").to_string_lossy()).unwrap(), b"trusted prefix");
assert_eq!(workspace.read(&resolved.join("launch-spelling.bin").to_string_lossy()).unwrap(), b"trusted prefix");
assert!(workspace.write(&configured.join("..").join("outside/escaped.bin").to_string_lossy(), b"no").is_err());
assert!(!outside.join("escaped.bin").exists());
assert!(workspace.write(&outside.join("blocked.bin").to_string_lossy(), b"no").is_err());
assert!(!outside.join("blocked.bin").exists());
}
#[test]
fn relative_configured_root_accepts_resolved_absolute_paths() {
let relative = PathBuf::from("target").join(format!("automation-relative-root-{}", std::process::id()));
std::fs::create_dir_all(&relative).unwrap();
let workspace = AuthorizedWorkspace::new(Some(&relative), Some(&relative)).unwrap();
let resolved = std::fs::canonicalize(&relative).unwrap();
workspace.write(&resolved.join("new.bin").to_string_lossy(), b"relative").unwrap();
assert_eq!(workspace.read(&resolved.join("new.bin").to_string_lossy()).unwrap(), b"relative");
std::fs::remove_file(resolved.join("new.bin")).unwrap();
drop(workspace);
std::fs::remove_dir(resolved).unwrap();
}
#[cfg(unix)]
#[test]
fn trusted_root_names_are_not_validated_as_relative_request_names() {
let (inside, outside, _) = roots("trusted-root-names");
let root = inside.join("aux");
std::fs::create_dir(&root).unwrap();
let alias = outside.join("CON");
link_directory(&root, &alias);
let workspace = AuthorizedWorkspace::new(Some(&alias), Some(&alias)).unwrap();
for (index, spelling) in [&root, &alias].into_iter().enumerate() {
let fresh = spelling.join(format!("new-{index}.bin"));
workspace.write(&fresh.to_string_lossy(), b"trusted").unwrap();
assert_eq!(workspace.read(&fresh.to_string_lossy()).unwrap(), b"trusted");
assert!(workspace.write(&fresh.to_string_lossy(), b"replacement").is_err(), "unknown existing files remain protected");
assert!(workspace.write(&spelling.join("NUL").to_string_lossy(), b"no").is_err());
}
assert!(!root.join("NUL").exists());
remove_directory_link(&alias);
}
// Windows junctions exercise the same root alias without requiring Developer Mode.
#[cfg(any(unix, windows))]
fn link_directory(target: &Path, link: &Path) {
#[cfg(unix)]
std::os::unix::fs::symlink(target, link).unwrap();
#[cfg(windows)]
assert!(std::process::Command::new("cmd.exe").args(["/D", "/C", "mklink", "/J"]).arg(link).arg(target).output().unwrap().status.success());
}
#[cfg(any(unix, windows))]
fn remove_directory_link(link: &Path) {
#[cfg(unix)]
std::fs::remove_file(link).unwrap();
#[cfg(windows)]
std::fs::remove_dir(link).unwrap();
}
#[cfg(any(unix, windows))]
#[test]
fn linked_root_accepts_both_spellings_and_new_nested_exports() {
let (inside, outside, _) = roots("linked-root");
let link = outside.join("root-link");
link_directory(&inside, &link);
let workspace = AuthorizedWorkspace::new(Some(&link), Some(&link)).unwrap();
let resolved = std::fs::canonicalize(&inside).unwrap();
for (i, spelling) in [resolved, link.clone()].iter().enumerate() {
let name = format!("new-{i}.bin");
workspace.write(&spelling.join(&name).to_string_lossy(), b"new").unwrap();
assert_eq!(workspace.read(&spelling.join(&name).to_string_lossy()).unwrap(), b"new");
assert_eq!(workspace.read(&name).unwrap(), b"new");
let mut export = workspace.export_sequence(&spelling.join(format!("frames-{i}")).join("nested").to_string_lossy()).unwrap();
export.write("frame.png", b"frame").unwrap();
export.commit();
assert_eq!(std::fs::read(inside.join(format!("frames-{i}/nested/frame.png"))).unwrap(), b"frame");
}
remove_directory_link(&link);
}
/// TEMP can name the same directory differently from canonicalize (8.3 on Windows).
/// A junction makes that mismatch deterministic even when 8.3 names are disabled.
#[cfg(windows)]
#[test]
fn root_alias_tests_work_with_an_aliased_windows_temp() {
let (inside, outside, _) = roots("aliased-temp");
let temp_alias = outside.join("temp-alias");
link_directory(&inside, &temp_alias);
let exe = std::env::current_exe().unwrap();
for test in [
"workspace::tests::ungranted_absolute_aliases_and_link_escapes_are_refused",
"workspace::tests::retargeting_root_alias_does_not_regrant_the_held_authority",
] {
// Set TEMP only in the child; changing the process environment races other tests.
let output = std::process::Command::new(&exe).args(["--exact", test, "--nocapture"]).env("TEMP", &temp_alias).env("TMP", &temp_alias).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{test}: {stdout}\n{stderr}");
assert!(stdout.contains("1 passed;"), "child must run the requested test: {stdout}");
}
remove_directory_link(&temp_alias);
}
#[cfg(any(unix, windows))]
#[test]
fn ungranted_absolute_aliases_and_link_escapes_are_refused() {
let (inside, outside, workspace) = roots("request-alias");
// TEMP may use an 8.3 name or a junction. A grant through `alias` below recognises
// that configured alias and the canonical target, not every other name for `inside`.
let resolved_inside = std::fs::canonicalize(&inside).unwrap();
let alias = outside.join("inside-alias");
link_directory(&inside, &alias);
workspace.write("new.bin", b"inside").unwrap();
assert!(workspace.read(&alias.join("new.bin").to_string_lossy()).is_err());
assert!(workspace.write(&alias.join("ungranted.bin").to_string_lossy(), b"no").is_err());
assert!(!inside.join("ungranted.bin").exists());
std::fs::write(outside.join("secret.bin"), b"outside").unwrap();
let escape = inside.join("escape");
link_directory(&outside, &escape);
assert!(workspace.read(&escape.join("secret.bin").to_string_lossy()).is_err());
assert!(workspace.write(&escape.join("blocked.bin").to_string_lossy(), b"no").is_err());
assert!(workspace.export_sequence(&escape.join("frames").join("nested").to_string_lossy()).is_err());
assert!(!outside.join("blocked.bin").exists());
assert!(!outside.join("frames").exists());
assert_eq!(std::fs::read(outside.join("secret.bin")).unwrap(), b"outside");
let read_only = AuthorizedWorkspace::new(Some(&alias), Some(&outside)).unwrap();
assert_eq!(read_only.read(&resolved_inside.join("new.bin").to_string_lossy()).unwrap(), b"inside");
assert!(read_only.write(&alias.join("denied.bin").to_string_lossy(), b"no").is_err());
assert!(!inside.join("denied.bin").exists());
remove_directory_link(&escape);
remove_directory_link(&alias);
}
#[cfg(any(unix, windows))]
#[test]
fn retargeting_root_alias_does_not_regrant_the_held_authority() {
let (inside, outside, _) = roots("retargeted-root");
let resolved_inside = std::fs::canonicalize(&inside).unwrap();
let alias = outside.join("root-alias");
link_directory(&inside, &alias);
let workspace = AuthorizedWorkspace::new(Some(&alias), Some(&alias)).unwrap();
remove_directory_link(&alias);
link_directory(&outside, &alias);
std::fs::write(outside.join("secret.bin"), b"secret").unwrap();
workspace.write("held.bin", b"held").unwrap();
assert_eq!(workspace.read(&resolved_inside.join("held.bin").to_string_lossy()).unwrap(), b"held");
assert!(workspace.read(&alias.join("secret.bin").to_string_lossy()).is_err());
workspace.write(&alias.join("held-alias.bin").to_string_lossy(), b"original grant").unwrap();
assert_eq!(std::fs::read(inside.join("held-alias.bin")).unwrap(), b"original grant");
assert!(!outside.join("held-alias.bin").exists());
assert_eq!(std::fs::read(outside.join("secret.bin")).unwrap(), b"secret");
remove_directory_link(&alias);
}
/// Each authority is checked against its own root, and none granted refuses absolute paths.
#[test]
fn absolute_paths_are_checked_against_the_authority_s_own_root() {
+4
View File
@@ -311,6 +311,10 @@ async fn errors_are_tool_errors_not_crashes() {
let r = call(&client, "command_run", json!({"id": "no.such.command"})).await;
assert_eq!(r.is_error, Some(true));
assert!(text(&r).contains("unknown command"));
// #3674: the error names the nearest registered id.
let r = call(&client, "command_run", json!({"id": "engine.commands"})).await;
assert_eq!(r.is_error, Some(true));
assert!(text(&r).contains("did you mean `command.list`"), "{}", text(&r));
let r = call(&client, "doc_inspect", json!({"index": 0})).await;
assert_eq!(r.is_error, Some(true));
let r = call(&client, "doc_open", json!({"path": "/definitely/missing.png"})).await;
+208 -16
View File
@@ -225,34 +225,89 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
err("no LUT_3D_SIZE in .cube file")
}
/// Lustre `.3dl`: an optional shaper line of input values, then integer RGB rows with **blue**
/// varying fastest; the output bit depth is inferred from the largest value, and is at least 12
/// bits in a file without a header.
/// Lustre `.3dl`: an optional `3DMESH` magic and `Mesh <inBits> <outBits>` line, an optional
/// shaper line of input values, then integer RGB rows with **blue** varying fastest. The
/// `Mesh` line declares the cube edge (2^inBits + 1) and the output code range (up to
/// 2^outBits − 1), and Photoshop honours both (#9073, #9078); without it the edge is the cube
/// root of the row count and the range is inferred from the largest value (#9121).
pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
let mut mesh: Option<(u32, u32)> = None;
let mut rows: Vec<[f64; 3]> = Vec::new();
// How many numbers the first numeric line carried: a shaper of exactly `n` values (n ≤ 3)
// is otherwise indistinguishable from a node row.
let mut first_len: Option<usize> = None;
// Any text line before the rows is a header: a headerless table peaking at 255 or 1023 is
// on the 12-bit scale, as Photoshop 27.11 reads it (#9121).
let mut header = false;
for line in text.lines() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) {
header |= !line.is_empty();
if line.is_empty() {
continue;
}
if line.starts_with(|c: char| c.is_ascii_alphabetic()) {
header = true;
// The only header keyword that carries data. A malformed or out-of-range `Mesh`
// line is ignored, as every text line always was.
let mut it = line.split_whitespace();
if it.next().is_some_and(|k| k.eq_ignore_ascii_case("Mesh")) {
// A declaration is exactly two integer fields after the keyword. Anything else
// (a non-numeric field, too few or too many) is not a declaration: `mesh` stays
// unset so headerless inference applies, rather than recovering numbers from
// the wrong positions (#9078).
let fields: Vec<&str> = it.collect();
if let [a, b] = fields.as_slice()
&& let (Ok(n), Ok(bits)) = (a.parse::<u32>(), b.parse::<u32>())
&& (1..=7).contains(&n)
&& (1..=16).contains(&bits)
{
mesh = Some((n, bits));
}
}
continue;
}
let v: Vec<f64> = line.split_whitespace().filter_map(|s| s.parse().ok()).collect();
if v.is_empty() {
// The `3DMESH` magic carries no numbers, but it is a header all the same: a table
// behind it reads a 1023 peak on the 10-bit scale (#9121).
header = true;
continue;
}
first_len.get_or_insert(v.len());
if v.len() == 3 {
rows.push([v[0], v[1], v[2]]);
}
// Lines with more than three numbers are the input shaper; it is assumed linear.
}
let n = (rows.len() as f64).cbrt().round() as usize;
if n < 2 || n * n * n != rows.len() {
return err(format!("{} rows is not a cube", rows.len()));
}
let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
// Photoshop 27.11 reads a headerless table peaking at 255 or 1023 on the 12-bit scale (#9121).
let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] };
let scale = if max <= 1.0 { 1.0 } else { depths.iter().copied().find(|&s| max <= s).unwrap_or(max) };
let (n, nodes, scale) = match mesh {
Some((in_bits, out_bits)) => {
let n = 2usize.pow(in_bits) + 1;
let expected = n.checked_pow(3).ok_or_else(|| LutError(format!("unsupported 3dl cube size {n}")))?;
// One row more than the declared cube, and the first row has exactly `n` values:
// that row is the shaper, not a 28th node of a 3³ cube (#9078).
let nodes = if rows.len() == expected + 1 && first_len == Some(n) { &rows[1..] } else { &rows[..] };
if nodes.len() != expected {
return err(format!("expected {expected} rows for the declared {n}³ cube, found {}", nodes.len()));
}
// Under a valid declaration the node codes are integers in the declared range, so
// the declaration wins for every in-range value — including a small max, where 1 is
// 1/declared of full output, not full output itself (#9073). Only a node beyond the
// declared range is a wrong declaration, and the old inference keeps such a file
// loading as before.
let max = nodes.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
let declared = 2f64.powi(i32::try_from(out_bits).unwrap_or(16)) - 1.0;
(n, nodes, if max > declared { inferred_3dl_scale(max, header) } else { declared })
}
None => {
let n = (rows.len() as f64).cbrt().round() as usize;
if n < 2 || n * n * n != rows.len() {
return err(format!("{} rows is not a cube", rows.len()));
}
let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
(n, &rows[..], inferred_3dl_scale(max, header))
}
};
let mut data = vec![0.0f32; n * n * n * 3];
for (i, row) in rows.iter().enumerate() {
for (i, row) in nodes.iter().enumerate() {
let (r, g, b) = (i / (n * n), (i / n) % n, i % n);
let at = ((b * n + g) * n + r) * 3;
for k in 0..3 {
@@ -262,6 +317,18 @@ pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check()
}
/// The code range a `.3dl` node table without a declared output depth was written with: the
/// smallest common integer depth that covers the largest value; a table of 0..1 floats passes
/// through. A headerless table has no 10-bit reading — 255 and 1023 are on the 12-bit scale, as
/// Photoshop 27.11 reads them (#9121).
fn inferred_3dl_scale(max: f64, header: bool) -> f64 {
if max <= 1.0 {
return 1.0;
}
let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] };
depths.iter().copied().find(|&s| max <= s).unwrap_or(max)
}
/// SpeedGrade `.look`: `<size>` and a hex `<data>` string of little-endian float32 RGB triplets
/// (red fastest).
pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
@@ -515,8 +582,9 @@ DOMAIN_MAX 0.5 1 1
let white = parse_3dl(&table(peak)).unwrap().data[21..24].to_vec();
assert_eq!(white, [peak as f32 / 4095.0; 3], "peak {peak}");
}
// A header keeps the inferred 10-bit depth.
let white = parse_3dl(&format!("3DMESH\nMesh 1 10\n{}", table(1023))).unwrap().data[21..24].to_vec();
// A header keeps the inferred 10-bit depth. The bare `3DMESH` magic is a header without a
// cube declaration; `Mesh 1 10` would declare a 3³ cube and refuse this 2³ table (#9078).
let white = parse_3dl(&format!("3DMESH\n{}", table(1023))).unwrap().data[21..24].to_vec();
assert_eq!(white, [1.0; 3]);
}
@@ -535,6 +603,130 @@ DOMAIN_MAX 0.5 1 1
assert_eq!(l.data, LutFile::identity(2).data);
}
/// A `size`³ inverse-identity `.3dl` (blue fastest): node (r,g,b) outputs `max - max·v/(size-1)`
/// per channel, with a uniform shaper of `size` codes up to 1023.
fn mesh_3dl(n_bits: u32, out_bits: u32, size: usize, max: f64, shaper: bool) -> String {
let mut text = format!("3DMESH\nMesh {n_bits} {out_bits}\n");
if shaper {
for i in 0..size {
if i > 0 {
text.push(' ');
}
text.push_str(&format!("{}", (1023.0 * i as f64 / (size - 1) as f64).round()));
}
text.push('\n');
}
for r in 0..size {
for g in 0..size {
for b in 0..size {
let v = [r, g, b].map(|c| (max - max * c as f64 / (size - 1) as f64).round());
text.push_str(&format!("{} {} {}\n", v[0], v[1], v[2]));
}
}
}
text
}
#[test]
fn three_dl_mesh_three_point_shaper_is_not_a_node() {
// #9078: Photoshop loads a 3³ cube whose shaper line has three values; counting it as a
// 28th node rejected the whole file.
let l = parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, true)).unwrap();
assert_eq!(l.size, 3);
assert_eq!(l.data.len(), 3 * 3 * 3 * 3);
// The inverse identity: node (0,0,0) is full, node (2,2,2) black.
assert!((l.data[0] - 1.0).abs() < 1e-6 && (l.data[1] - 1.0).abs() < 1e-6 && (l.data[2] - 1.0).abs() < 1e-6);
let last = &l.data[l.data.len() - 3..];
assert_eq!(last, [0.0, 0.0, 0.0]);
// The same 27 nodes without the shaper row still load (the node-only control).
assert_eq!(parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, false)).unwrap().data, l.data);
}
#[test]
fn three_dl_mesh_declares_the_output_depth() {
// #9073: identical node codes divide by the declared range, not by the largest value.
let mut text_10 = String::new();
let mut text_12 = String::new();
for (n_bits, out_bits, dst) in [(2, 10, &mut text_10), (2, 12, &mut text_12)] {
// A 5³ identity scaled to 10-bit codes (max 1023).
*dst = format!("3DMESH\nMesh {n_bits} {out_bits}\n0 256 512 768 1023\n");
for r in 0..5 {
for g in 0..5 {
for b in 0..5 {
dst.push_str(&format!("{} {} {}\n", r * 255, g * 255, b * 255));
}
}
}
}
let white_10 = parse_3dl(&text_10).unwrap();
let white_12 = parse_3dl(&text_12).unwrap();
assert_eq!(white_10.size, 5);
let last_10 = &white_10.data[white_10.data.len() - 3..];
let last_12 = &white_12.data[white_12.data.len() - 3..];
assert!((last_10[0] - 1020.0 / 1023.0).abs() < 1e-6, "{last_10:?}");
// 1020/4095 ≈ 0.249: white renders grey, as Photoshop measures it.
assert!((last_12[0] - 1020.0 / 4095.0).abs() < 1e-6, "{last_12:?}");
}
#[test]
fn three_dl_mesh_header_mismatch_is_rejected() {
// A Mesh header that disagrees with the row count is refused, not silently re-inferred.
assert!(parse_3dl("3DMESH\nMesh 1 12\n0 0 0\n0 0 1\n").is_err());
// A node beyond the declared range is a wrong declaration: the old inference from the
// largest value keeps the file loading as it did before the header was honoured.
let mut text = String::from("3DMESH\nMesh 1 10\n");
for r in 0..3 {
for g in 0..3 {
for b in 0..3 {
text.push_str(&format!("{} {} {}\n", r * 2047, g * 2047, b * 2047));
}
}
}
let l = parse_3dl(&text).unwrap();
let last = &l.data[l.data.len() - 3..];
assert!((last[0] - 4094.0 / 4095.0).abs() < 1e-6, "{last:?}");
}
#[test]
fn three_dl_malformed_mesh_header_falls_back_to_inference() {
// A `Mesh` line that is not exactly two integer fields is not a declaration: it is
// ignored and the table is inferred as if headerless, rather than recovering numbers
// from the wrong positions (#9078). A 2³ cube would otherwise be read as a 3³ cube.
for header in ["Mesh invalid 1 12", "Mesh 1 12 13", "Mesh 1 x", "Mesh 1"] {
let mut text = format!("3DMESH\n{header}\n");
for r in 0..2 {
for g in 0..2 {
for b in 0..2 {
text.push_str(&format!("{} {} {}\n", r * 1023, g * 1023, b * 1023));
}
}
}
let l = parse_3dl(&text).unwrap();
assert_eq!(l.size, 2, "{header}");
assert_eq!(l.data, LutFile::identity(2).data, "{header}");
}
}
#[test]
fn three_dl_mesh_uses_declared_range_for_small_integer_codes() {
// #9073: under a valid `Mesh` declaration the node codes are integers in the declared
// range, so a max value of 1 is 1/4095 of full output — not full output itself.
let mut text = String::from("3DMESH\nMesh 1 12\n");
for r in 0..3 {
for g in 0..3 {
for b in 0..3 {
let v = u8::from(r == 0 && g == 0 && b == 0);
text.push_str(&format!("{v} {v} {v}\n"));
}
}
}
let l = parse_3dl(&text).unwrap();
assert_eq!(l.size, 3);
// blue fastest: node (0,0,0) is the first entry, code 1 → 1/4095.
assert!((l.data[0] - 1.0 / 4095.0).abs() < 1e-6, "{:?}", &l.data[0..3]);
assert!(l.data[3..].iter().all(|&v| v == 0.0), "{:?}", l.data);
}
#[test]
fn look_hex_floats() {
let id = LutFile::identity(2);
+6
View File
@@ -33,6 +33,8 @@ zune-core = "0.5"
jpeg-encoder = "0.6"
# 12-bit and arithmetic-coded JPEG, which zune-jpeg does not decode.
craft-jpeg.workspace = true
# Pure-Rust modular JPEG XL lossless encoder.
zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] }
photocraft-heif = { path = "../heif", optional = true }
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
@@ -46,5 +48,9 @@ proptest.workspace = true
# Oracle decoders for cross-checking in tests only.
image = { workspace = true, features = ["png", "jpeg", "tiff"] }
# Independent JPEG XL decoder oracle; not linked into the application.
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
jxl-oxide = { version = "=0.12.6", default-features = false, features = ["rayon"] }
[lints]
workspace = true
+12
View File
@@ -46,6 +46,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
| JPEG XL (.jxl) | **no** (see #2868) | yes | U8, U16 | Gray, RGB, RGBA (GrayA expands to RGBA) | yes | no (bare codestream) | no | no | no | no | no | `zune-jpegxl` |
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
| Cineon | yes | yes | U16 (10-bit code values read and written exactly through U16) | Gray, RGB | no | no | no | no | no | no | no | built in |
| DICOM | yes | **no** | U8, U16 | Gray, RGB | no | no | no | no | no | no | n/a | built in |
@@ -73,6 +74,17 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
## Documented asymmetries and limitations
* **JPEG XL export.** Writes lossless 8/16-bit bare codestreams with `zune-jpegxl`.
Native layouts are Gray, RGB and RGBA. GrayA is expanded to RGBA at the
original depth. For 16-bit RGBA (including expanded GrayA), we correct
zune-jpegxl 0.5.2's extra-channel header, which otherwise describes the
16-bit alpha samples as 8-bit. This preserves alpha precision; the independent
jxl-oxide decoder test checks the actual gray and alpha samples. The expansion
is not a native GrayA layout. No ICC, EXIF, XMP or DPI container boxes are written; the
document pipeline converts tagged images to sRGB and reports metadata loss.
Reading remains in the separate #2868 PR. The `ASYMMETRIC_EXCEPTIONS`
entry stays until import is merged.
* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
with the legacy header or the DX10 one. Uncompressed row padding follows the header's
+1
View File
@@ -11,6 +11,7 @@ pub(crate) mod gif_palette;
pub(crate) mod heif;
pub(crate) mod iff;
pub(crate) mod jpeg;
pub(crate) mod jxl;
pub(crate) mod pcx;
pub(crate) mod png;
pub(crate) mod pnm;
+159
View File
@@ -0,0 +1,159 @@
//! Pure-Rust lossless JPEG XL (Modular) writer.
//!
//! Currently writes a bare JPEG XL codestream. The encoder accepts 8- or 16-bit
//! gray, RGB and RGBA samples, but does not write ICC/EXIF/XMP boxes.
//! Gray+alpha sources are expanded losslessly to RGBA by the export plan.
//! The flat export pipeline converts tagged RGB documents to sRGB first.
use std::panic::{AssertUnwindSafe, catch_unwind};
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions as ZuneOptions};
use zune_jpegxl::JxlSimpleEncoder;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::format::Format;
use crate::image::{ChannelLayout, Image, SampleType};
const F: Format = Format::Jxl;
// zune-jpegxl 0.5.2 emits ExtraChannelInfo(all_default=true) for every alpha
// channel. In JPEG XL that flag means *8-bit* alpha, even when the modular
// payload was encoded from 16-bit samples. Replace only that metadata bundle,
// leaving the entire encoded frame and its TOC untouched. The replacement is
// exactly 16 bits longer, so subsequent byte-aligned data stays aligned.
//
// This deliberately verifies the pinned encoder's header layout rather than
// trying to parse or rewrite arbitrary JPEG XL codestreams. A different header
// must fail closed, not produce a file that silently corrupts transparency.
fn correct_16_bit_alpha_header(bytes: &[u8], width: u32, height: u32) -> Result<Vec<u8>, CodecError> {
fn read(bytes: &[u8], pos: &mut usize, count: usize) -> Option<u32> {
let mut value = 0u32;
for bit in 0..count {
let offset = *pos;
let byte = *bytes.get(offset / 8)?;
value |= u32::from((byte >> (offset % 8)) & 1) << bit;
*pos += 1;
}
Some(value)
}
fn read_dimension(bytes: &[u8], pos: &mut usize) -> Option<u32> {
let bits = match read(bytes, pos, 2)? {
0 => 9,
1 => 13,
2 => 18,
_ => 30,
};
read(bytes, pos, bits)?.checked_add(1)
}
fn write_bits(out: &mut Vec<u8>, pos: &mut usize, count: usize, value: u32) {
for bit in 0..count {
if (*pos).is_multiple_of(8) {
out.push(0);
}
out[*pos / 8] |= (((value >> bit) & 1) as u8) << (*pos % 8);
*pos += 1;
}
}
let mut pos = 0;
let header_matches = read(bytes, &mut pos, 16) == Some(0x0aff)
&& read(bytes, &mut pos, 1) == Some(0)
&& read_dimension(bytes, &mut pos) == Some(height)
&& read(bytes, &mut pos, 3) == Some(0)
&& read_dimension(bytes, &mut pos) == Some(width)
&& read(bytes, &mut pos, 1) == Some(0) // metadata not all-default
&& read(bytes, &mut pos, 1) == Some(0) // no extra fields
&& read(bytes, &mut pos, 1) == Some(0) // integer samples
&& read(bytes, &mut pos, 2) == Some(3) // custom bit depth
&& read(bytes, &mut pos, 6) == Some(15) // 16 bits
&& read(bytes, &mut pos, 1) == Some(0) // uses >14 bits
&& read(bytes, &mut pos, 2) == Some(1); // one extra channel
let alpha_flag_pos = pos;
let header_matches = header_matches
&& read(bytes, &mut pos, 1) == Some(1) // incorrect default 8-bit alpha
&& read(bytes, &mut pos, 1) == Some(0) // not XYB
&& read(bytes, &mut pos, 1) == Some(1) // default sRGB encoding (RGBA)
&& read(bytes, &mut pos, 2) == Some(0) // no extensions
&& read(bytes, &mut pos, 1) == Some(1); // default transform
let frame_offset = pos.div_ceil(8);
let header_matches = header_matches && frame_offset <= bytes.len() && (pos..frame_offset * 8).all(|bit| bytes[bit / 8] & (1 << (bit % 8)) == 0);
if !header_matches {
return Err(CodecError::encode(F, "unexpected zune-jpegxl header for 16-bit alpha"));
}
let mut fixed = Vec::with_capacity(bytes.len() + 2);
let mut cursor = 0;
for bit in 0..alpha_flag_pos {
write_bits(&mut fixed, &mut cursor, 1, u32::from((bytes[bit / 8] >> (bit % 8)) & 1));
}
// JPEG XL ExtraChannelInfo, non-default, straight 16-bit alpha:
// type=kAlpha (Enum=0), bit_depth=uint16, dim_shift=0, name_len=0,
// alpha_associated=false. Adds exactly 16 bits versus all_default=true.
write_bits(&mut fixed, &mut cursor, 1, 0);
write_bits(&mut fixed, &mut cursor, 2, 0);
write_bits(&mut fixed, &mut cursor, 1, 0);
write_bits(&mut fixed, &mut cursor, 2, 3);
write_bits(&mut fixed, &mut cursor, 6, 15);
write_bits(&mut fixed, &mut cursor, 2, 0);
write_bits(&mut fixed, &mut cursor, 2, 0);
write_bits(&mut fixed, &mut cursor, 1, 0);
for bit in alpha_flag_pos + 1..frame_offset * 8 {
write_bits(&mut fixed, &mut cursor, 1, u32::from((bytes[bit / 8] >> (bit % 8)) & 1));
}
if cursor != (frame_offset + 2) * 8 {
return Err(CodecError::encode(F, "JPEG XL alpha header alignment mismatch"));
}
fixed.extend_from_slice(&bytes[frame_offset..]);
Ok(fixed)
}
pub(crate) fn encode(image: &Image, plan: Plan) -> Result<Vec<u8>, CodecError> {
let image = image.converted(plan.layout, plan.sample);
let space = match image.layout() {
ChannelLayout::Gray => ColorSpace::Luma,
ChannelLayout::GrayA => ColorSpace::LumaA,
ChannelLayout::Rgb => ColorSpace::RGB,
ChannelLayout::Rgba => ColorSpace::RGBA,
_ => return Err(CodecError::unsupported(F, "JPEG XL export supports grayscale and RGB, with optional alpha")),
};
let depth = match image.sample_type() {
SampleType::U8 => BitDepth::Eight,
SampleType::U16 => BitDepth::Sixteen,
_ => return Err(CodecError::unsupported(F, "JPEG XL lossless export supports 8 or 16-bit integer pixels")),
};
let (width, height) = image.dimensions();
let samples = (width as usize)
.checked_mul(height as usize)
.and_then(|n| n.checked_mul(image.layout().channels()))
.and_then(|n| n.checked_mul(image.sample_type().bytes()))
.ok_or_else(|| CodecError::encode(F, "image dimensions overflow the buffer size"))?;
if image.data().len() != samples {
return Err(CodecError::InvalidImage("incorrect JPEG XL pixel buffer length".into()));
}
// A panic in an external image encoder must not terminate the host application.
catch_unwind(AssertUnwindSafe(|| {
let options = ZuneOptions::new(width as usize, height as usize, space, depth);
let mut output = Vec::new();
JxlSimpleEncoder::new(image.data(), options).encode(&mut output).map_err(|e| CodecError::encode(F, e.to_string()))?;
if depth == BitDepth::Sixteen && image.layout() == ChannelLayout::Rgba { correct_16_bit_alpha_header(&output, width, height) } else { Ok(output) }
}))
.unwrap_or_else(|_| Err(CodecError::encode(F, "the JPEG XL encoder failed")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alpha_header_correction_rejects_unexpected_or_truncated_headers() {
for bytes in [&[][..], &[0xff, 0x0a][..], &[0; 32][..]] {
assert!(correct_16_bit_alpha_header(bytes, 2, 2).is_err());
}
}
}
+405 -101
View File
@@ -306,42 +306,73 @@ fn is_latin1_keyword(k: &str) -> bool {
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
let (w, h) = img.dimensions();
let color = match img.layout() {
let bpp = img.layout().channels() * img.sample_type().bytes();
// Big-endian sample bytes.
let mut data = std::borrow::Cow::Borrowed(img.data());
if img.sample_type() == SampleType::U16 {
to_big_endian(data.to_mut());
}
let mut out = Vec::new();
{
let mut writer = start(&mut out, (w, h), (img.layout(), img.sample_type()), img.icc.as_ref(), &img.meta, opts)?;
if opts.png_interlaced {
let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?;
write_idat(&mut writer, &idat, MAX_IDAT)?;
} else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, bpp, opts.png_compression) {
write_idat(&mut writer, &idat, MAX_IDAT)?;
} else {
writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?;
}
writer.finish().map_err(|e| CodecError::encode(F, e))?;
}
Ok(out)
}
/// 16-bit native-endian samples to PNG's big-endian order, in place.
fn to_big_endian(data: &mut [u8]) {
for c in data.as_chunks_mut::<2>().0 {
let v = u16::from_ne_bytes([c[0], c[1]]);
c.copy_from_slice(&v.to_be_bytes());
}
}
/// Writes the signature and every chunk ahead of the image data for a `w` × `h` PNG of
/// `layout` and `sample` (already the encode plan's), with its profile and metadata.
fn start<W: Write>(
out: W,
(w, h): (u32, u32),
(layout, sample): (ChannelLayout, SampleType),
icc: Option<&Vec<u8>>,
meta: &Metadata,
opts: &EncodeOptions,
) -> Result<png::Writer<W>, CodecError> {
let color = match layout {
ChannelLayout::Gray => png::ColorType::Grayscale,
ChannelLayout::GrayA => png::ColorType::GrayscaleAlpha,
ChannelLayout::Rgb => png::ColorType::Rgb,
ChannelLayout::Rgba => png::ColorType::Rgba,
l => return Err(CodecError::encode(F, format!("unsupported layout {l:?}"))),
};
let depth = match img.sample_type() {
let depth = match sample {
SampleType::U8 => png::BitDepth::Eight,
SampleType::U16 => png::BitDepth::Sixteen,
s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))),
};
// Big-endian sample bytes.
let mut data = std::borrow::Cow::Borrowed(img.data());
if depth == png::BitDepth::Sixteen {
for c in data.to_mut().as_chunks_mut::<2>().0 {
let v = u16::from_ne_bytes([c[0], c[1]]);
c.copy_from_slice(&v.to_be_bytes());
}
}
let mut info = png::Info::with_size(w, h);
info.color_type = color;
info.bit_depth = depth;
info.interlaced = opts.png_interlaced;
if opts.embed_icc
&& let Some(icc) = &img.icc
&& let Some(icc) = icc
{
info.icc_profile = Some(icc.clone().into());
}
if opts.embed_metadata {
if let Some(exif) = &img.meta.exif {
if let Some(exif) = &meta.exif {
// The pixels are written as they are shown: never let a viewer rotate them again.
info.exif_metadata = Some(crate::resolution::export_exif(exif, img.meta.dpi).into_owned().into());
info.exif_metadata = Some(crate::resolution::export_exif(exif, meta.dpi).into_owned().into());
}
if let Some((x, y)) = img.meta.dpi
if let Some((x, y)) = meta.dpi
&& x > 0.0
&& y > 0.0
{
@@ -349,51 +380,150 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter });
}
}
let mut out = Vec::new();
{
let mut encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
encoder.set_compression(match opts.png_compression {
PngCompression::None => png::Compression::NoCompression,
PngCompression::Fast => png::Compression::Fast,
PngCompression::Default => png::Compression::Balanced,
PngCompression::Best => png::Compression::High,
});
if opts.embed_metadata {
for (k, v) in &img.meta.text {
let latin1_text = v.chars().all(|c| (c as u32) < 256);
let res = if is_latin1_keyword(k) && latin1_text {
encoder.add_text_chunk(k.clone(), v.clone())
} else if is_latin1_keyword(k) {
encoder.add_itxt_chunk(k.clone(), v.clone())
} else {
continue;
};
res.map_err(|e| CodecError::encode(F, e))?;
}
if let Some(xmp) = &img.meta.xmp {
encoder
.add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, img.meta.dpi).into_owned())
.map_err(|e| CodecError::encode(F, e))?;
}
let mut encoder = png::Encoder::with_info(out, info).map_err(|e| CodecError::encode(F, e))?;
encoder.set_compression(match opts.png_compression {
PngCompression::None => png::Compression::NoCompression,
PngCompression::Fast => png::Compression::Fast,
PngCompression::Default => png::Compression::Balanced,
PngCompression::Best => png::Compression::High,
});
if opts.embed_metadata {
for (k, v) in &meta.text {
let latin1_text = v.chars().all(|c| (c as u32) < 256);
let res = if is_latin1_keyword(k) && latin1_text {
encoder.add_text_chunk(k.clone(), v.clone())
} else if is_latin1_keyword(k) {
encoder.add_itxt_chunk(k.clone(), v.clone())
} else {
continue;
};
res.map_err(|e| CodecError::encode(F, e))?;
}
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
if let Some(stereo) = opts.png_stereo {
// Ancillary chunk before IDAT, as the spec recommends for `sTER`.
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
if let Some(xmp) = &meta.xmp {
encoder.add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, meta.dpi).into_owned()).map_err(|e| CodecError::encode(F, e))?;
}
if opts.png_interlaced {
let bpp = img.layout().channels() * img.sample_type().bytes();
let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?;
writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?;
} else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, img.layout().channels() * img.sample_type().bytes(), opts.png_compression) {
writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?;
} else {
writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?;
}
writer.finish().map_err(|e| CodecError::encode(F, e))?;
}
Ok(out)
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
if let Some(stereo) = opts.png_stereo {
// Ancillary chunk before IDAT, as the spec recommends for `sTER`.
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
}
Ok(writer)
}
/// Largest IDAT chunk written. A chunk holds at most 2^31 - 1 bytes, so a zlib stream past that
/// (a multi-gigapixel image) is split over several IDAT chunks, which readers join back.
const MAX_IDAT: usize = 1 << 30;
/// `zlib` as IDAT chunks of at most `max` bytes.
fn write_idat<W: Write>(writer: &mut png::Writer<W>, zlib: &[u8], max: usize) -> Result<(), CodecError> {
for part in zlib.chunks(max.max(1)) {
writer.write_chunk(png::chunk::IDAT, part).map_err(|e| CodecError::encode(F, e))?;
}
Ok(())
}
/// A PNG encoded a band of rows at a time, for images too large to hold whole (a multi-gigapixel
/// print): each band is converted to the encode plan, filtered and deflated on all cores as it
/// arrives and written as IDAT chunks, so only about one band is ever held. The deflate bands are
/// [`parallel_idat`]'s, so the zlib stream is the one [`crate::encode`] writes for a large image.
/// Never interlaced (Adam7 needs the whole image).
pub struct PngRows<W: Write> {
writer: png::Writer<W>,
plan: Plan,
width: u32,
height: u32,
/// Rows received so far.
rows: u32,
level: PngCompression,
/// Converted, big-endian rows that don't fill a deflate band yet.
pending: Vec<u8>,
/// The last row deflated: the row above `pending` for the filters.
prev: Vec<u8>,
adler: u32,
/// Whether the zlib header is written.
started: bool,
}
impl<W: Write> PngRows<W> {
/// Writes the header of a `first.width()` × `height` PNG to `out`. The layout, sample type,
/// profile and metadata come from `first`, the first band (not written yet: [`Self::push`]
/// it like the others), through the same plan as [`crate::encode`].
pub fn new(out: W, first: &Image, height: u32, opts: &EncodeOptions) -> Result<Self, CodecError> {
if opts.png_interlaced {
return Err(CodecError::unsupported(F, "an interlaced PNG can't be written a band of rows at a time"));
}
let width = first.width();
if width == 0 || height == 0 {
return Err(CodecError::InvalidImage("cannot encode an empty image".into()));
}
let plan = crate::fidelity::plan(first, F, opts);
let writer = start(out, (width, height), (plan.layout, plan.sample), first.icc.as_ref(), &first.meta, opts)?;
Ok(Self { writer, plan, width, height, rows: 0, level: opts.png_compression, pending: Vec::new(), prev: Vec::new(), adler: 1, started: false })
}
/// Encodes the next rows of the image: `band` is as wide as the image, in any layout and
/// sample type (converted like the first band).
pub fn push(&mut self, band: &Image) -> Result<(), CodecError> {
if band.width() != self.width {
return Err(CodecError::InvalidImage(format!("a {} px wide band for a {} px wide image", band.width(), self.width)));
}
let rows = self.rows.checked_add(band.height()).filter(|&r| r <= self.height);
let Some(rows) = rows else {
return Err(CodecError::InvalidImage(format!("more than the image's {} rows", self.height)));
};
let img = band.converted(self.plan.layout, self.plan.sample);
let at = self.pending.len();
self.pending.try_reserve(img.data().len()).map_err(|_| CodecError::encode(F, "not enough memory for a band of rows"))?;
self.pending.extend_from_slice(img.data());
if self.plan.sample == SampleType::U16 {
to_big_endian(self.pending.get_mut(at..).unwrap_or_default());
}
self.rows = rows;
self.deflate_pending()
}
/// Deflates the whole bands held, and the rest once every row is in.
fn deflate_pending(&mut self) -> Result<(), CodecError> {
let bpp = self.plan.layout.channels() * self.plan.sample.bytes();
let stride = self.width as usize * bpp;
let per_band = band_rows(stride);
let held = self.pending.len() / stride.max(1);
let finish = self.rows == self.height;
let take = if finish { held } else { held / per_band * per_band };
if take == 0 {
return Ok(());
}
let rows = self.pending.get(..take * stride).unwrap_or_default();
let prev = (!self.prev.is_empty()).then_some(self.prev.as_slice());
let parts = deflate_rows(rows, prev, stride, bpp, per_band, deflate_level(self.level), finish, encode_threads())
.ok_or_else(|| CodecError::encode(F, "a band of rows could not be compressed"))?;
let mut zlib = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::<usize>() + 6);
if !self.started {
zlib.extend_from_slice(&zlib_header(self.level));
self.started = true;
}
for p in &parts {
zlib.extend_from_slice(&p.z);
self.adler = adler32_combine(self.adler, p.adler, p.len);
}
if finish {
zlib.extend_from_slice(&self.adler.to_be_bytes());
}
write_idat(&mut self.writer, &zlib, MAX_IDAT)?;
self.prev.clear();
self.prev.extend_from_slice(rows.get(rows.len() - stride..).unwrap_or_default());
self.pending.drain(..take * stride);
Ok(())
}
/// Ends the file. Fails when fewer rows than the image's height were pushed.
pub fn finish(self) -> Result<(), CodecError> {
if self.rows != self.height || !self.pending.is_empty() {
return Err(CodecError::InvalidImage(format!("{} of the image's {} rows were written", self.rows, self.height)));
}
self.writer.finish().map_err(|e| CodecError::encode(F, e))
}
}
/// Build the zlib stream for an Adam7-interlaced image (filter type 0).
@@ -417,13 +547,7 @@ fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression
y += dy;
}
}
let lvl = match level {
PngCompression::None => flate2::Compression::none(),
PngCompression::Fast => FAST,
PngCompression::Default => flate2::Compression::default(),
PngCompression::Best => flate2::Compression::best(),
};
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), lvl);
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), deflate_level(level));
z.write_all(&raw).map_err(|e| CodecError::encode(F, e))?;
z.finish().map_err(|e| CodecError::encode(F, e))
}
@@ -447,44 +571,108 @@ fn parallel_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompress
if cfg!(target_arch = "wasm32") || data.len() < PARALLEL_MIN_BYTES || level == PngCompression::None || w == 0 || h == 0 {
return None;
}
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32);
banded_idat(data, w, h, bpp, level, threads)
banded_idat(data, w, h, bpp, level, encode_threads())
}
/// Filtered bytes per deflate band. The bands depend only on the image, never on the number of
/// threads, so the same image and options give the same bytes whatever the machine's core count.
const BAND_BYTES: usize = 512 << 10;
/// The deflate effort for `level`.
fn deflate_level(level: PngCompression) -> flate2::Compression {
match level {
PngCompression::None => flate2::Compression::none(),
PngCompression::Fast => FAST,
PngCompression::Default => flate2::Compression::default(),
PngCompression::Best => flate2::Compression::best(),
}
}
/// The zlib stream header: deflate, 32 K window; FLEVEL only advertises the effort.
fn zlib_header(level: PngCompression) -> [u8; 2] {
match level {
PngCompression::None | PngCompression::Fast => [0x78, 0x01],
PngCompression::Best => [0x78, 0xDA],
PngCompression::Default => [0x78, 0x9C],
}
}
/// Rows per deflate band for rows of `stride` bytes.
fn band_rows(stride: usize) -> usize {
(BAND_BYTES / stride.saturating_add(1)).max(1)
}
/// Worker threads for the banded encoders: one on wasm (no threads), else the machine's cores.
fn encode_threads() -> usize {
if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) }
}
/// [`parallel_idat`] on `threads` worker threads.
fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression, threads: usize) -> Option<Vec<u8>> {
let stride = w * bpp;
let band_rows = (BAND_BYTES / stride.saturating_add(1)).max(1);
let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows).map(|y| (y, (y + band_rows).min(h))).collect();
let lvl = match level {
PngCompression::Fast => FAST,
PngCompression::Best => flate2::Compression::best(),
_ => flate2::Compression::default(),
let parts = deflate_rows(data.get(..h * stride)?, None, stride, bpp, band_rows(stride), deflate_level(level), true, threads)?;
let mut out = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::<usize>() + 6);
out.extend_from_slice(&zlib_header(level));
let mut adler = 1u32;
for p in &parts {
out.extend_from_slice(&p.z);
adler = adler32_combine(adler, p.adler, p.len);
}
out.extend_from_slice(&adler.to_be_bytes());
Some(out)
}
/// One band of rows filtered and deflated: the raw deflate bytes, and the Adler-32 and length of
/// the filtered bytes they hold.
struct Deflated {
z: Vec<u8>,
adler: u32,
len: usize,
}
/// `rows` (whole rows of `stride` bytes; `prev` is the row above the first, if any) filtered and
/// deflated in independent bands of `band_rows` rows on up to `threads` workers (inline for one).
/// The last band finishes the deflate stream when `finish`; every other band ends on a sync flush.
/// `None` when a band did not deflate in one call ([`deflate_band`]).
#[allow(clippy::too_many_arguments)]
fn deflate_rows(
rows: &[u8],
prev: Option<&[u8]>,
stride: usize,
bpp: usize,
band_rows: usize,
lvl: flate2::Compression,
finish: bool,
threads: usize,
) -> Option<Vec<Deflated>> {
let h = rows.len() / stride.max(1);
let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows.max(1)).map(|y| (y, (y + band_rows).min(h))).collect();
let band = |&(y0, y1): &(usize, usize)| -> Option<Deflated> {
let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1));
let mut scratch = vec![0u8; stride];
for y in y0..y1 {
let row = rows.get(y * stride..(y + 1) * stride)?;
let above = if y > 0 { rows.get((y - 1) * stride..y * stride) } else { prev };
filter_row(row, above, bpp, &mut scratch, &mut filtered);
}
let adler = adler32(1, &filtered);
let z = deflate_band(&filtered, lvl, finish && y1 == h)?;
Some(Deflated { z, adler, len: filtered.len() })
};
if threads <= 1 || bands.len() <= 1 {
return bands.iter().map(band).collect();
}
let next = std::sync::atomic::AtomicUsize::new(0);
type Band = (usize, Vec<u8>, u32, usize);
let mut parts: Vec<Band> = std::thread::scope(|sc| {
let workers: Vec<_> = (0..threads.min(bands.len()).max(1))
let mut parts: Vec<(usize, Deflated)> = std::thread::scope(|sc| {
let workers: Vec<_> = (0..threads.min(bands.len()))
.map(|_| {
sc.spawn(|| {
let mut out: Vec<Band> = Vec::new();
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let Some(&(y0, y1)) = bands.get(i) else { break };
let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1));
let mut scratch = vec![0u8; stride];
for y in y0..y1 {
let row = &data[y * stride..(y + 1) * stride];
let prev = (y > 0).then(|| &data[(y - 1) * stride..y * stride]);
filter_row(row, prev, bpp, &mut scratch, &mut filtered);
}
let adler = adler32(1, &filtered);
let Some(z) = deflate_band(&filtered, lvl, y1 == h) else { return Vec::new() };
out.push((i, z, adler, filtered.len()));
let Some(b) = bands.get(i) else { break };
let Some(d) = band(b) else { return Vec::new() };
out.push((i, d));
}
out
})
@@ -496,20 +684,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio
return None;
}
parts.sort_by_key(|p| p.0);
let mut out = Vec::with_capacity(parts.iter().map(|p| p.1.len()).sum::<usize>() + 6);
// zlib header: deflate, 32 K window; FLEVEL only advertises the effort.
out.extend_from_slice(match level {
PngCompression::Fast => &[0x78, 0x5E],
PngCompression::Best => &[0x78, 0xDA],
_ => &[0x78, 0x9C],
});
let mut adler = 1u32;
for (_, z, a, len) in &parts {
out.extend_from_slice(z);
adler = adler32_combine(adler, *a, *len);
}
out.extend_from_slice(&adler.to_be_bytes());
Some(out)
Some(parts.into_iter().map(|p| p.1).collect())
}
/// Raw deflate of one band: ends on a sync flush, or finishes the stream for the `last` band.
@@ -978,3 +1153,132 @@ mod parallel_tests {
}
}
}
#[cfg(test)]
mod streamed_tests {
use super::*;
/// (bytes ahead of the first IDAT, the IDAT payloads joined, how many IDAT chunks).
fn split(png: &[u8]) -> (Vec<u8>, Vec<u8>, usize) {
let (mut at, mut head, mut zlib, mut count) = (8, None, Vec::new(), 0);
while at + 12 <= png.len() {
let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize;
if &png[at + 4..at + 8] == b"IDAT" {
head.get_or_insert_with(|| png[..at].to_vec());
zlib.extend_from_slice(&png[at + 8..at + 8 + len]);
count += 1;
}
at += 12 + len;
}
(head.unwrap(), zlib, count)
}
fn noise(n: usize, seed: u32) -> Vec<u8> {
(0..n).map(|i| ((i / 3) as u32 ^ seed).wrapping_mul(2_654_435_761).rotate_left(i as u32 % 7) as u8 / 3 + (i % 97) as u8).collect()
}
/// `img` pushed in bands of `heights` rows (the last one takes the rest).
fn streamed(img: &Image, heights: &[u32], opts: &EncodeOptions) -> Vec<u8> {
let (w, h) = img.dimensions();
let row = img.data().len() / h as usize;
let mut bands = Vec::new();
let mut y = 0;
for &n in heights.iter().chain(std::iter::once(&u32::MAX)) {
let n = n.min(h - y);
if n == 0 {
continue;
}
let data = img.data()[y as usize * row..(y + n) as usize * row].to_vec();
bands.push(Image::from_raw(w, n, img.layout(), img.sample_type(), data).unwrap().with_icc(img.icc.clone()).with_meta(img.meta.clone()));
y += n;
}
let mut out = Vec::new();
let mut rows = PngRows::new(&mut out, &bands[0], h, opts).unwrap();
for b in &bands {
rows.push(b).unwrap();
}
rows.finish().unwrap();
out
}
/// Bands of any height give the zlib stream, header chunks and pixels the whole-image encoder
/// gives, at 8 and 16 bits, with a profile, resolution, text and XMP.
#[test]
fn streamed_rows_match_the_whole_image_encoder() {
for (layout, sample, ch, bytes) in [(ChannelLayout::Rgba, SampleType::U8, 4usize, 1usize), (ChannelLayout::Rgb, SampleType::U16, 3, 2)] {
let (w, h) = (1201u32, 1013u32);
let meta = Metadata {
dpi: Some((300.0, 300.0)),
text: vec![("Title".into(), "Tent".into())],
xmp: Some("<x:xmpmeta xmlns:x='adobe:ns:meta/'/>".into()),
..Default::default()
};
let img = Image::from_raw(w, h, layout, sample, noise(w as usize * h as usize * ch * bytes, 7))
.unwrap()
.with_icc(Some((0..600u32).map(|i| (i % 251) as u8).collect()))
.with_meta(meta);
for level in [PngCompression::Fast, PngCompression::Default] {
let opts = EncodeOptions { png_compression: level, ..Default::default() };
let whole = crate::encode(&img, Format::Png, &opts).unwrap();
let (head, zlib, _) = split(&whole);
for heights in [&[1u32, 77, 300][..], &[256, 256, 256], &[1013]] {
let png = streamed(&img, heights, &opts);
let (h2, z2, _) = split(&png);
assert!(h2 == head, "{layout:?} {level:?} {heights:?}: header chunks differ");
assert!(z2 == zlib, "{layout:?} {level:?} {heights:?}: zlib stream differs");
assert_eq!(decode(&png, &Limits::default()).unwrap().data(), img.data());
}
}
}
}
/// Small images, no compression and bands in another sample type (converted by the plan).
#[test]
fn small_uncompressed_and_converted_bands_round_trip() {
let img = Image::from_raw(3, 5, ChannelLayout::GrayA, SampleType::U8, noise(30, 3)).unwrap();
for level in [PngCompression::None, PngCompression::Best] {
let opts = EncodeOptions { png_compression: level, ..Default::default() };
let png = streamed(&img, &[2, 1], &opts);
assert_eq!(decode(&png, &Limits::default()).unwrap().data(), img.data(), "{level:?}");
}
// Float bands are written with 16 bits, as `encode` writes them.
let f = img.convert(ChannelLayout::GrayA, SampleType::F32);
let png = streamed(&f, &[4], &EncodeOptions::default());
let back = decode(&png, &Limits::default()).unwrap();
assert_eq!(back.sample_type(), SampleType::U16);
assert_eq!(back.convert(ChannelLayout::GrayA, SampleType::U8).data(), img.data());
}
/// A zlib stream longer than one chunk may hold is split over IDAT chunks that read back.
#[test]
fn long_streams_split_over_several_idat_chunks() {
let img = Image::from_raw(64, 40, ChannelLayout::Rgb, SampleType::U8, noise(64 * 40 * 3, 9)).unwrap();
let whole = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
let (_, zlib, _) = split(&whole);
let mut out = Vec::new();
let mut w = start(&mut out, (64, 40), (ChannelLayout::Rgb, SampleType::U8), None, &Metadata::default(), &EncodeOptions::default()).unwrap();
write_idat(&mut w, &zlib, 100).unwrap();
w.finish().unwrap();
let (_, joined, count) = split(&out);
assert_eq!(count, zlib.len().div_ceil(100));
assert!(joined == zlib);
assert_eq!(decode(&out, &Limits::default()).unwrap().data(), img.data());
}
/// Bands that don't fit the image, missing rows and interlacing are errors, not panics.
#[test]
fn bands_that_dont_fit_are_errors() {
let band = Image::from_raw(4, 2, ChannelLayout::Rgb, SampleType::U8, vec![0; 24]).unwrap();
let opts = EncodeOptions::default();
assert!(PngRows::new(Vec::new(), &band, 0, &opts).is_err());
assert!(PngRows::new(Vec::new(), &band, 3, &EncodeOptions { png_interlaced: true, ..Default::default() }).is_err());
let mut rows = PngRows::new(Vec::new(), &band, 3, &opts).unwrap();
rows.push(&band).unwrap();
// Two more rows than the image holds.
assert!(rows.push(&band).is_err());
let narrow = Image::from_raw(3, 1, ChannelLayout::Rgb, SampleType::U8, vec![0; 9]).unwrap();
assert!(rows.push(&narrow).is_err());
// Two of three rows.
assert!(rows.finish().is_err());
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result<impl ImageDec
Some((fw, fh)) => u64::from(fw).saturating_mul(u64::from(fh)).saturating_mul(bpp).max(declared.saturating_sub(1 << 28)),
_ => declared,
};
if !matches!(f, Format::Avif | Format::Hdr) && !(f == Format::Bmp && bmp_is_rle(bytes)) {
if !(matches!(f, Format::Avif | Format::Hdr) || f == Format::Bmp && bmp_is_rle(bytes)) {
Limits::check_plausible(declared, bytes.len(), 1 << 14)?;
}
Ok(dec)
+14 -1
View File
@@ -34,6 +34,8 @@ pub enum Format {
Fits,
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
Dpx,
/// JPEG XL: lossless export-only until the separate importer lands.
Jxl,
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from).
Cineon,
/// DICOM single-frame images (uncompressed): read-only.
@@ -85,6 +87,7 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
),
(Format::Jxl, "Lossless JPEG XL export uses the pure-Rust zune-jpegxl encoder; JPEG XL reading is tracked separately and is not yet available."),
(
Format::Heif,
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
@@ -104,9 +107,10 @@ const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 22] = [
pub const ALL: [Format; 23] = [
Format::Png,
Format::Jpeg,
Format::Jxl,
Format::Tiff,
Format::WebP,
Format::Gif,
@@ -160,6 +164,7 @@ impl Format {
Format::Heif => "HEIF",
Format::Fits => "FITS",
Format::Dpx => "DPX",
Format::Jxl => "JPEG XL",
Format::Cineon => "Cineon",
Format::Dicom => "DICOM",
Format::Iff => "Amiga IFF",
@@ -189,6 +194,7 @@ impl Format {
Format::Heif => &["heic", "heif", "hif"],
Format::Fits => &["fits", "fit", "fts"],
Format::Dpx => &["dpx"],
Format::Jxl => &["jxl"],
Format::Cineon => &["cin"],
Format::Dicom => &["dcm", "dicom", "dc3"],
Format::Iff => &["iff", "lbm", "ilbm"],
@@ -216,6 +222,7 @@ impl Format {
Format::Heif => "image/heif",
Format::Fits => "image/fits",
Format::Dpx => "image/x-dpx",
Format::Jxl => "image/jxl",
Format::Cineon => "image/x-cin",
Format::Dicom => "application/dicom",
Format::Iff => "image/x-iff",
@@ -276,6 +283,8 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
// GrayA is widened to RGBA at the same depth; the 16-bit alpha header is corrected.
Format::Jxl => FormatCaps { read: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], ..base },
Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
Format::Iff => FormatCaps { depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base },
@@ -292,6 +301,10 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some(Format::Jpeg);
}
// JPEG XL bare codestream and ISO/IEC 18181-2 container signature box.
if b.starts_with(&[0xFF, 0x0A]) || b.starts_with(b"\0\0\0\x0cJXL \r\n\x87\n") {
return Some(Format::Jxl);
}
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") || b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") {
return Some(Format::Tiff);
}
+5 -3
View File
@@ -29,7 +29,7 @@ pub mod web;
pub use crate::cicp::Cicp;
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo};
pub use crate::codecs::png::{PngPalette, PngRows, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo};
pub use crate::codecs::tiff::{
PhotoshopTags as TiffPhotoshopTags, TiffBands, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian,
};
@@ -50,7 +50,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
pub use half::f16;
use crate::codecs::{dds, dicom, dpx, exr, fits, heif, iff, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm};
use crate::codecs::{dds, dicom, dpx, exr, fits, heif, iff, jpeg, jxl, pcx, png, pnm, tiff, via_image, webp, xpm};
/// Detect the format and decode with default [`Limits`].
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
@@ -71,7 +71,7 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
/// Decode as a specific format.
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
// HEIF without the `heif` feature: its decoder stub says what is missing from this build.
if !caps(format).read && format != Format::Heif {
if !caps(format).read && !matches!(format, Format::Heif | Format::Jxl) {
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
}
let l = &opts.limits;
@@ -91,6 +91,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Iff => iff::decode(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Jxl => Err(CodecError::unsupported(format, "JPEG XL reading is not yet available; export is supported")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
}?;
// Without a resolution of the format's own (PNG pHYs, TIFF tags, JPEG's metadata), the
@@ -309,6 +310,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Iff => iff::encode(image, plan),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Jxl => jxl::encode(image, plan),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Lossless JPEG XL export and honest export-only capability reporting.
use photocraft_codecs::{
ASYMMETRIC_EXCEPTIONS, ChannelLayout, CodecError, EncodeOptions, Format, Image, SampleType, caps, decode, detect, encode, from_extension,
};
#[test]
fn jpeg_xl_is_export_only_and_detects_both_signatures() {
let c = caps(Format::Jxl);
assert!(c.write && !c.read);
assert_eq!(c.depths, &[SampleType::U8, SampleType::U16]);
assert_eq!(c.layouts, &[ChannelLayout::Gray, ChannelLayout::Rgb, ChannelLayout::Rgba]);
assert!(c.alpha && !c.icc && !c.exif && !c.xmp && !c.dpi && !c.lossy);
assert_eq!(Format::Jxl.mime_type(), "image/jxl");
assert_eq!(from_extension("image.JXL"), Some(Format::Jxl));
assert_eq!(detect(&[0xff, 0x0a, 0, 0]), Some(Format::Jxl));
assert_eq!(detect(b"\0\0\0\x0cJXL \r\n\x87\n"), Some(Format::Jxl));
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(format, reason)| *format == Format::Jxl && !reason.is_empty()));
let err = decode(&[0xff, 0x0a]);
assert!(matches!(err, Err(CodecError::Unsupported { format: Format::Jxl, .. })), "{err:?}");
}
#[test]
fn jpeg_xl_encoding_is_deterministic_and_converts_float_to_integer() {
let input =
Image::from_u8(3, 2, ChannelLayout::Rgba, vec![0, 255, 17, 127, 20, 40, 80, 255, 255, 0, 2, 0, 10, 20, 30, 64, 0, 0, 0, 1, 90, 80, 70, 128]).unwrap();
let opts = EncodeOptions::default();
let one = encode(&input, Format::Jxl, &opts).unwrap();
let two = encode(&input, Format::Jxl, &opts).unwrap();
assert_eq!(one, two);
assert_eq!(detect(&one), Some(Format::Jxl));
let float = Image::from_f32(2, 2, ChannelLayout::Rgb, &[0.0, 0.5, 1.0, 0.25, 0.5, 0.75, 0.75, 0.5, 0.25, 1.0, 0.0, 0.5]).unwrap();
let bytes = encode(&float, Format::Jxl, &opts).unwrap();
assert_eq!(detect(&bytes), Some(Format::Jxl));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn jpeg_xl_decodes_losslessly_with_independent_decoder() {
use jxl_oxide::JxlImage;
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
let channels = layout.channels();
for sample in [SampleType::U8, SampleType::U16] {
let (w, h) = (7, 5);
let count = w as usize * h as usize * channels;
let expected = match sample {
SampleType::U8 => (0..count).map(|i| (((i * 37 + 19) % 256) as u16) * 257).collect::<Vec<_>>(),
SampleType::U16 => (0..count).map(|i| ((i * 9973 + 27) % 65536) as u16).collect::<Vec<_>>(),
_ => unreachable!(),
};
let input = match sample {
SampleType::U8 => {
let data = expected.iter().map(|v| (v / 257) as u8).collect();
Image::from_u8(w, h, layout, data).unwrap()
}
SampleType::U16 => Image::from_u16(w, h, layout, &expected).unwrap(),
_ => unreachable!(),
};
// GrayA is losslessly widened to RGBA. Keep the oracle comparison
// against original gray and alpha samples, not only against a converted image.
let (output_channels, expected_output) = if layout == ChannelLayout::GrayA {
let expanded: Vec<u16> = expected.as_chunks::<2>().0.iter().flat_map(|[gray, alpha]| [*gray, *gray, *gray, *alpha]).collect();
(4, expanded)
} else {
(channels, expected.clone())
};
let encoded = encode(&input, Format::Jxl, &EncodeOptions::default()).unwrap();
let decoder = JxlImage::builder().read(&encoded[..]).unwrap();
assert_eq!((decoder.width(), decoder.height()), (w, h));
let render = decoder.render_frame(0).unwrap();
let mut stream = render.stream();
assert_eq!(stream.channels() as usize, output_channels, "{layout:?} {sample:?}");
let mut output = vec![0u16; expected_output.len()];
assert_eq!(stream.write_to_buffer(&mut output), expected_output.len());
assert_eq!(output, expected_output, "{layout:?} {sample:?}");
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn jpeg_xl_preserves_sixteen_bit_alpha_across_dimension_header_sizes() {
use jxl_oxide::JxlImage;
// Exercise each size-header coding tier, and non-opaque alpha extremes.
for width in [2u32, 513, 8193] {
let height = 2u32;
let mut samples = Vec::with_capacity(width as usize * height as usize * 4);
for i in 0..(width as usize * height as usize) {
samples.extend_from_slice(&[(i as u16).wrapping_mul(127), 65535, (i as u16).wrapping_mul(9973), [0u16, 1, 32768, 65535][i % 4]]);
}
let input = Image::from_u16(width, height, ChannelLayout::Rgba, &samples).unwrap();
let encoded = encode(&input, Format::Jxl, &EncodeOptions::default()).unwrap();
let decoder = JxlImage::builder().read(&encoded[..]).unwrap();
let render = decoder.render_frame(0).unwrap();
let mut stream = render.stream();
assert_eq!(stream.channels(), 4, "width={width}");
let mut output = vec![0u16; samples.len()];
assert_eq!(stream.write_to_buffer(&mut output), samples.len());
assert_eq!(output, samples, "width={width}");
}
}
#[test]
fn jpeg_xl_rejects_one_pixel_images_without_panicking() {
// zune-jpegxl currently requires at least two pixels on each axis.
let image = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![42, 100, 255]).unwrap();
assert!(matches!(encode(&image, Format::Jxl, &EncodeOptions::default()), Err(CodecError::Encode { format: Format::Jxl, .. })));
}
+2 -2
View File
@@ -31,8 +31,8 @@ pub use color::{
use tone::LUT_SIZE;
pub(crate) use tone::apply_tone;
pub use tone::{
TONE_SLOPE_SPAN, contour_curve_lut, curve_lut, curve_lut_mode, curve_map_lut, float_tone_extrapolates, float_tone_identity, levels, levels_float,
levels_gamma, levels_q, modern_brightness, modern_contrast, modern_lut, posterize, posterize_in, tone_luts, tone_luts_depth, tone_luts_q,
TONE_SLOPE_SPAN, contour_curve_lut, curve_lut, curve_lut_mode, curve_map_lut, curve_samples, float_tone_extrapolates, float_tone_identity, levels,
levels_float, levels_gamma, levels_q, modern_brightness, modern_contrast, modern_lut, posterize, posterize_in, tone_luts, tone_luts_depth, tone_luts_q,
};
use tone::{lut, map_rgb};
+11 -3
View File
@@ -402,11 +402,19 @@ pub fn curve_map_lut(points: &[CurvePoint]) -> Vec<f32> {
/// overshoot between points, as Photoshop's does), constant outside the
/// first/last point, clamped to 0..=1, sampled into a LUT.
pub fn curve_lut(points: &[CurvePoint]) -> Vec<f32> {
curve_samples::<LUT_SIZE>(points)
}
/// The same natural spline as [`curve_lut`], sampled directly at `N` evenly spaced inputs
/// including both endpoints. UI graphs use a smaller table without building the rendering LUT.
/// Zero samples returns an empty table; one sample evaluates at input zero.
pub fn curve_samples<const N: usize>(points: &[CurvePoint]) -> Vec<f32> {
let last = N.saturating_sub(1).max(1) as f32;
let mut pts: Vec<(f32, f32)> = points.iter().map(|p| (p.input, p.output)).collect();
pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
if pts.len() < 2 {
return (0..LUT_SIZE).map(|i| i as f32 / (LUT_SIZE - 1) as f32).collect();
return (0..N).map(|i| i as f32 / last).collect();
}
let n = pts.len();
// Second derivatives of the natural spline (tridiagonal solve).
@@ -430,9 +438,9 @@ pub fn curve_lut(points: &[CurvePoint]) -> Vec<f32> {
m2[i] = d[i] - c[i] * m2[i + 1];
}
}
(0..LUT_SIZE)
(0..N)
.map(|k| {
let xv = k as f32 / (LUT_SIZE - 1) as f32;
let xv = k as f32 / last;
if xv <= pts[0].0 {
return pts[0].1.clamp(0.0, 1.0);
}
+51 -21
View File
@@ -72,8 +72,10 @@ pub(crate) fn branchless_floor(x: f32) -> f32 {
}
/// Blend an adjustment layer's result (`adjusted`) back into the backdrop it was made from, each
/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode`.
fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator<Item = f32>, mode: BlendMode) {
/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode` at layer Fill `fill`: below
/// 100% the special eight ([`psblend::fill_is_special`]) blend a result pulled toward the mode's
/// neutral colour, as a pixel layer does (#9501); for every other mode Fill is in `ks`.
fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator<Item = f32>, mode: BlendMode, fill: f32) {
for ((b, a), k) in backdrop.iter_mut().zip(adjusted).zip(ks) {
if k <= 0.0 {
continue;
@@ -81,8 +83,11 @@ fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterato
let s = [a[0], a[1], a[2]];
// Normal is the source as it is (`blend_rgb` gives exactly that), and most adjustment
// layers are Normal: no per-pixel mode dispatch for them.
let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb(mode, [b[0], b[1], b[2]], s) };
*b = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb_fill(mode, [b[0], b[1], b[2]], s, fill) };
// `b·(1−k) + s·k`, not `b + (s − b)·k`: with a huge 32-bit backdrop the difference loses a
// small result to f32 rounding, so full coverage gave 0 instead of a lookup's 0.5 (#9483).
let j = 1.0 - k;
*b = [b[0] * j + blended[0] * k, b[1] * j + blended[1] * k, b[2] * j + blended[2] * k, b[3]];
}
}
@@ -147,9 +152,9 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio
let Some(p) = px else { continue };
let v = |c: usize| blend_if_level(p[c]);
match mode {
// Gray is the colour channels' luma (Rec. 601 weights), then R, G, B.
// Gray, then R, G, B.
M::Rgb => {
let gray = 0.299 * v(0) + 0.587 * v(1) + 0.114 * v(2);
let gray = blend_if_gray(v(0), v(1), v(2));
k *= bi.get(0)[side].weight(gray);
for c in 0..3 {
k *= bi.get(c + 1)[side].weight(v(c));
@@ -166,15 +171,28 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio
k
}
/// A channel value on Blend If's 0..=255 scale. Compositing can leave an 8- or 16-bit level a few
/// millionths off (a level-20 backdrop composited at partial alpha reads 19.999998), which would
/// put it on the wrong side of a hard point at that level: a value that close to a level is that
/// level. Values of those depths that aren't on a level are at least 1/257 away from one. The GPU
/// compositor (`compose.wgsl` `blend_if_weight`) snaps alike.
/// A channel value on Blend If's 0..=255 scale. Photoshop compares Blend If at its 16-bit
/// precision (0..=32768): a cutoff at level `c` falls on the step `round(c · 32768 / 255)`, and a
/// value on that step is exactly level `c`, inside both a black point and a white point there
/// (#9521: RGB16 32895 is step 16448, level 128's, so it passes a hard black point at 128; read as
/// 127.996 it didn't). Every 8-bit level is on its own step, which also absorbs compositing's few
/// millionths of drift (a level-20 backdrop at partial alpha reads 19.999998). The GPU compositor
/// (`compose.wgsl` `blend_if_weight`) snaps alike.
fn blend_if_level(x: f32) -> f32 {
let v = x.clamp(0.0, 1.0) * 255.0;
let r = v.round();
if (v - r).abs() < 1e-4 { r } else { v }
let x = x.clamp(0.0, 1.0);
let v = x * 255.0;
let level = v.round();
if (x * 32768.0).round() == (level * 32768.0 / 255.0).round() { level } else { v }
}
/// Blend If's Gray from channel levels: Photoshop's (30 R + 59 G + 11 B) / 100, rounded half down
/// on 8-bit levels (#9522). Measured in Photoshop 2025: pure red passes a black point at 20 from
/// 66 (19.8) but not 65 (19.5), pure blue at 28 from 251 (27.61) but not 250 (27.5); (123, 126,
/// 151) is 128 (127.85) and (120, 129, 149) is 128 (128.5), where Rec. 601 luma gave 127.95 and
/// 128.59. Levels between whole ones (deeper documents) keep the unrounded weighting.
fn blend_if_gray(r: f32, g: f32, b: f32) -> f32 {
let sum = 30.0 * r + 59.0 * g + 11.0 * b;
if [r, g, b].iter().all(|c| c.fract() == 0.0) { ((sum + 49.0) / 100.0).floor() } else { sum / 100.0 }
}
/// Blend If groups inside one another whose own colour is still rendered separately. Each extra
@@ -191,13 +209,18 @@ thread_local! {
/// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since
/// every blend mode's source-over result is linear in the source alpha.
fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
// "This Layer" is the layer's own colour; adjustment layers (no content of their own) are
// judged by their result. So are groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend
// Ifs: a group's own colour re-composites its children, which doubles the work at every
// level of Blend If groups inside one another.
// "This Layer" is the layer's own colour. An adjustment layer (no content of its own) is
// judged by its result at full strength, before Opacity and its mask mix it in, as Photoshop
// does (#9542). Groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend Ifs are judged by
// the composite: a group's own colour re-composites its children, which doubles the work at
// every level of Blend If groups inside one another.
let is_group = matches!(layer.content, LayerContent::Group(_));
let nested = BLEND_IF_GROUPS.with(std::cell::Cell::get);
let own = if matches!(layer.content, LayerContent::Adjustment(_)) || (is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH) {
let own = if let LayerContent::Adjustment(adj) = &layer.content {
let mut result = before.clone();
cx.apply_adjustment(adj, &mut result);
Some(result)
} else if is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH {
None
} else {
BLEND_IF_GROUPS.with(|d| d.set(nested + u32::from(is_group)));
@@ -334,6 +357,8 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
// is the base of a clipping group and holds an adjustment layer (psd-tools
// passthrough_clipping_mask_adjustment: an Exposure inside leaves the layers beneath alone),
// while such a group's blend modes still reach the backdrop (passthrough_clipping_mask_blendmode).
// A group with Blend If renders isolated too: an adjustment inside it no longer changes the
// layers beneath (#9554).
let clip_base_adjusts = clipped.iter().any(|c| c.visible)
&& matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_))));
if let LayerContent::Group(g) = &layer.content
@@ -341,6 +366,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
&& layer.fill_opacity >= 1.0
&& !effects::has_effects(layer)
&& !clip_base_adjusts
&& !blend_if_active(layer, cx.mode)
{
let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some();
let has_clipped = clipped.iter().any(|c| c.visible);
@@ -420,9 +446,13 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
}
}
} else {
// Fill below 100% in one of the special eight changes the blend itself and leaves
// coverage to Opacity, as for a pixel layer (psblend::composite_fill, #9501).
let (k, fill) =
if layer.fill_opacity < 1.0 && psblend::fill_is_special(layer.blend) { (layer.opacity, layer.fill_opacity) } else { (opacity, 1.0) };
match &mv {
Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| opacity * v), layer.blend),
None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(opacity), layer.blend),
Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| k * v), layer.blend, fill),
None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(k), layer.blend, fill),
}
}
recycle_buffer(adjusted);
+9 -2
View File
@@ -41,9 +41,9 @@ mod paint;
pub use bevel::{BEVEL_H_EPS, BevelGeom, BevelPaint, TextureCtx, bevel_chisel_h, bevel_geom, satin_distance};
#[cfg(test)]
use bevel::{bevel_maps, satin_map};
pub use blur::tent_kernel;
#[cfg(test)]
use blur::{blur, box_line, dilate, tent, tent_direct};
pub use blur::{softer_glow_tail_amplitude, softer_glow_tail_width, tent_kernel};
pub(crate) use composite::{VectorStroke, build_maps_prepared, composite_with_effects_prepared, map_count};
pub use composite::{build_maps, composite_with_effects, outline_share, premerges_layer, shadow_see_through, stroke_widths};
pub use distance::{FieldKind, STROKE_CHAMFER_MAX, bevel_distance_fields, distance_field, stroke_outside_exact};
@@ -89,7 +89,14 @@ pub fn margin(layer: &Layer) -> i32 {
for e in layer.effects.items.iter().filter(|e| e.enabled()) {
let r = match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size,
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size,
Effect::OuterGlow(g) => {
if g.technique == GlowTechnique::Softer {
g.size + softer_glow_tail_width(g.size)
} else {
g.size
}
}
Effect::InnerGlow(g) => g.size,
Effect::Stroke(s) => s.size,
Effect::Satin(s) => satin_distance(s) + s.size,
Effect::BevelEmboss(b) => b.size + b.soften,
+11
View File
@@ -210,3 +210,14 @@ fn tent_fast(m: &mut Map, w: f32) {
rows_par(&t, &mut b, mh, twice);
m.v = transpose_map(&b, mh, mw);
}
/// Raw coverage at the Size boundary, scaled through the glow's Range to a subtle one-percent tail.
pub fn softer_glow_tail_amplitude(range: f32) -> f32 {
let range = if range.is_finite() { range.clamp(0.01, 1.0) } else { 1.0 };
0.005 * range
}
/// Width of the added fading tail on a Softer outer glow, capped so large settings stay local.
pub fn softer_glow_tail_width(size: f32) -> f32 {
if size.is_finite() && size > 0.0 { (size * 0.1).clamp(1.0, 6.0) } else { 0.0 }
}
+26 -1
View File
@@ -1,4 +1,4 @@
use super::blur::{blur, dilate};
use super::blur::{blur, dilate, softer_glow_tail_amplitude, softer_glow_tail_width};
use super::distance::{dist_inside, dist_outside};
use super::{
BlendMode, Buffer, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Map, Placement, PreparedPatterns, Rect,
@@ -303,6 +303,9 @@ pub(super) fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -
let (r, bw) = spread_split(g.size, g.spread);
let mut m = dilate(&src, r);
blur(&mut m, bw);
if !inner {
add_softer_glow_tail(&mut m, &src, g.size, g.range);
}
m
}
};
@@ -402,3 +405,25 @@ pub(super) fn paint_glow(dst: &mut Buffer, m: &Map, g: &Glow, shape_bounds: Rect
let strength = Map { w: m.w, h: m.h, v: m.v.iter().map(|v| (v * gain).min(1.0)).collect() };
paint(dst, &strength, |i| prepared.sample(1.0 - m.v.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0)), g.common.blend, g.common.opacity);
}
fn add_softer_glow_tail(map: &mut Map, shape: &Map, size: f32, range: f32) {
let width = softer_glow_tail_width(size);
if width == 0.0 {
return;
}
let amp = softer_glow_tail_amplitude(range);
let distance = dist_outside(shape);
for (coverage, distance) in map.v.iter_mut().zip(distance) {
let t = (distance + 0.5 - (size - width)) / width;
let tail = if (0.0..=1.0).contains(&t) {
amp * t * t * (3.0 - 2.0 * t)
} else if (1.0..=2.0).contains(&t) {
let u = t - 1.0;
let remaining = 1.0 - u;
amp * remaining * remaining * (1.0 + 2.0 * u)
} else {
0.0
};
*coverage = coverage.max(tail);
}
}
+50
View File
@@ -858,6 +858,56 @@ fn glows_match_photoshop() {
}
}
#[test]
fn softer_outer_glow_keeps_a_fading_tail_past_its_size() {
let mut shape = Map::new(220, 220, 0.0);
for y in 0..shape.h {
for x in 20..100 {
shape.v[y * shape.w + x] = 1.0;
}
}
let glow = Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
paint: FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 60.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
};
let m = glow_map(&shape, &glow, false, (0, 0));
let row = shape.h / 2;
let at = |distance: usize| m.v[row * m.w + 100 + distance];
assert!(at(50) > 0.024, "the softer shoulder should stay visible: {}", at(50));
assert!(at(60) > 1.0 / 255.0, "the glow must retain a visible tail at its Size: {}", at(60));
assert!(at(62) > 0.5 / 255.0, "the glow must extend beyond its Size: {}", at(62));
assert!(at(66) < 1e-6, "the added tail should fade to zero: {}", at(66));
}
#[test]
fn softer_outer_glow_margin_includes_its_tail_support() {
let glow = Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
paint: FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 60.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
};
let mut layer = Layer::raster("glow", photocraft_color::PixelFormat::RGBA8);
layer.effects.items.push(Effect::OuterGlow(glow));
assert_eq!(margin(&layer), 68);
}
#[test]
fn offsets_follow_light() {
let (dx, dy) = offset(120.0, 10.0);
+30
View File
@@ -137,6 +137,36 @@ fn parallel_feathered_pixel_mask_returns_the_same_tiles() {
assert!(outputs.iter().all(|surface| Arc::ptr_eq(surface.tiles().next().unwrap().1, outputs[0].tiles().next().unwrap().1)));
}
/// A feather reaching the canvas edge sees the mask continue past it with its edge values, as
/// in Photoshop (#9475): a rectangle near the edge feathers the same on a taller canvas holding
/// the same mask (clamping each blur pass at the edge brightened it by up to 12 levels at
/// 32 px), and a mask white up to the edge stays white along it.
#[test]
fn feather_at_the_canvas_edge_continues_the_mask_past_it() {
let mut layer = Layer::raster("edge", PixelFormat::RGBA8);
let mut mask = photocraft_doc::LayerMask::hide_all();
mask.surface.fill_rect(Rect::new(40, 20, 120, 60), &[1.0]);
mask.feather = 32.0;
layer.mask = Some(mask);
let canvas = Rect::new(0, 0, 160, 80);
let tight = combined_mask(&layer, canvas).unwrap().read_region(canvas);
let tall = combined_mask(&layer, Rect::new(0, -200, 160, 280)).unwrap().read_region(canvas);
let worst = tight.iter().zip(&tall).map(|(a, b)| (a - b).abs()).fold(0.0_f32, f32::max);
assert!(worst < 1e-4, "the canvas edge changed the feather by {worst}");
let mut half = photocraft_doc::LayerMask::hide_all();
half.surface.fill_rect(Rect::new(0, 0, 80, 80), &[1.0]);
half.feather = 32.0;
layer.mask = Some(half);
let m = combined_mask(&layer, canvas).unwrap().read_region(canvas);
// Photoshop: every row the same, no darkening towards the top or bottom edge.
let middle = &m[40 * 160..41 * 160];
for (y, row) in m.as_chunks::<160>().0.iter().enumerate() {
assert!(row.iter().zip(middle).all(|(a, b)| (a - b).abs() < 1e-5), "row {y} differs from row 40");
}
assert!(middle[0] > 0.99, "the left edge stays white: {}", middle[0]);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn vector_mask_build_inside_a_small_rayon_pool_completes() {
+2 -1
View File
@@ -91,7 +91,8 @@ fn density_shows_the_fill_beyond_the_path() {
/// mask break differently: 127 and 128, both what Photoshop's "127/128" allows.)
#[test]
fn soft_shape_matches_an_equal_vector_mask() {
for (density, feather) in [(1.0, 10.0), (0.45, 0.0), (0.3, 6.5), (0.0, 0.0), (1.0, 0.0), (0.5, 3.0)] {
// 40 px reaches past the canvas edge: both feather it the same way there (#9475).
for (density, feather) in [(1.0, 10.0), (0.45, 0.0), (0.3, 6.5), (0.0, 0.0), (1.0, 0.0), (0.5, 3.0), (1.0, 40.0)] {
for depth in [SampleType::U8, SampleType::U16] {
let (s, f) = (shape_doc(depth, density, feather, None), fill_doc(depth, density, feather));
for y in [0, 45, 50, 100, 151, 199] {
+261 -6
View File
@@ -417,6 +417,59 @@ fn every_blend_mode_matches_reference_on_opaque_pixels() {
}
}
/// #9483: a Color Lookup's bounded output survives full coverage over a huge 32-bit pixel. The
/// blend back to the backdrop computed `b + (r - b) · k`, and in f32 the difference had already
/// lost the small result (1e8 and 0.5 gave 0).
#[test]
fn full_coverage_adjustment_keeps_its_result_over_huge_hdr_values() {
for v in [1.0e6_f32, 1.0e8, -1.0e6] {
let mut d = Document::new("hdr", Size::new(2, 2), ColorMode::Rgb, SampleType::F32);
let mut base = Layer::raster("b", PixelFormat::RGBA32F);
base.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[v, v, v, 1.0]);
let look = Adjustment::ColorLookup {
name: "half".into(),
lut: Some(std::sync::Arc::new(vec![0.5; 24])),
size: 2,
tetrahedral: false,
dither: false,
source: None,
};
d.layers = vec![base, Layer::new("look", LayerContent::Adjustment(look))];
let got = px(&d, 0, 0);
for c in &got[..3] {
assert!((c - 0.5).abs() < 1e-6, "{v}: {got:?}");
}
}
}
/// #9501: an adjustment layer at 40% Fill in Color Dodge or Linear Burn blends a result pulled
/// toward the mode's neutral colour, as a pixel layer does, instead of fading the blend by Fill.
/// Photoshop: a constant 0.5 look over (64, 128, 192) gives (80, 160, 240) and (13, 77, 141).
#[test]
fn adjustment_fill_changes_the_special_blend_modes() {
for (mode, want) in [(BlendMode::ColorDodge, [80, 160, 240]), (BlendMode::LinearBurn, [13, 77, 141])] {
let mut d = Document::with_background("fill", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut base = Layer::raster("b", PixelFormat::RGBA8);
base.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0]);
let look = Adjustment::ColorLookup {
name: "half".into(),
lut: Some(std::sync::Arc::new(vec![0.5; 24])),
size: 2,
tetrahedral: false,
dither: false,
source: None,
};
let mut adj = Layer::new("look", LayerContent::Adjustment(look));
adj.blend = mode;
adj.fill_opacity = 0.4;
d.layers.push(base);
d.layers.push(adj);
let got = px(&d, 0, 0);
let got: Vec<i32> = got[..3].iter().map(|v| (v * 255.0).round() as i32).collect();
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "{mode:?}: {got:?} vs Photoshop {want:?}");
}
}
/// One opaque pixel: `top` in `mode` over `bottom`, in a document of `depth`.
fn blend_px(depth: SampleType, mode: BlendMode, bottom: [f32; 3], top: [f32; 3]) -> [f32; 4] {
let mut d = Document::new("b", Size::new(1, 1), ColorMode::Rgb, depth);
@@ -564,6 +617,35 @@ fn clipped_dissolve_matches_whole_render_at_offset_regions_and_depths() {
}
}
/// #4921: Dissolve coverage must survive clipping and use Opacity × Fill.
#[test]
fn clipped_dissolve_is_binary_and_preserves_the_base_shape() {
let mut d = doc_white(40, 12);
d.layers.push(solid_layer("base", Rect::new(0, 0, 20, 12), [0.0, 0.0, 1.0, 1.0]));
let mut clip = solid_layer("clip", d.bounds(), [1.0, 0.0, 0.0, 1.0]);
clip.clipped = true;
clip.blend = BlendMode::Dissolve;
clip.opacity = 0.6;
clip.fill_opacity = 0.5;
d.layers.push(clip);
let mut red = 0;
let mut blue = 0;
for y in 0..12 {
for x in 0..20 {
let p = px(&d, x, y);
if close4(p, [1.0, 0.0, 0.0, 1.0]) {
red += 1;
} else {
assert!(close4(p, [0.0, 0.0, 1.0, 1.0]), "Dissolve must not interpolate: {p:?} at ({x},{y})");
blue += 1;
}
}
assert!(close4(px(&d, 30, y), [1.0; 4]), "outside the base the clip is invisible");
}
assert!(red > 0 && blue > 0, "Dissolve must expose both source and base: {red}/{blue}");
assert!((red as f32 / (red + blue) as f32 - 0.3).abs() < 0.1, "coverage should follow 60% opacity × 50% fill: {red}/{blue}");
}
#[test]
fn hidden_base_hides_clipping_group() {
let mut d = doc_white(4, 4);
@@ -834,6 +916,32 @@ fn threshold_posterize_levels_curves() {
assert!(s[s.len() / 4] < 0.25 && s[3 * s.len() / 4] > 0.75);
}
#[test]
fn curve_samples_preserve_spline_and_endpoint_behavior() {
use photocraft_doc::adjust::CurvePoint;
let points = [CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.5, output: 1.0 }, CurvePoint { input: 1.0, output: 0.0 }];
// Analytic natural spline: at x=1/4 and 3/4 the output is 11/16.
assert_eq!(adjust::curve_samples::<5>(&points), vec![0.0, 0.6875, 1.0, 0.6875, 0.0]);
let low = adjust::curve_samples::<256>(&points);
let full = adjust::curve_lut(&points);
assert_eq!(low.len(), 256);
assert_eq!(full.len(), 4096);
// Both grids share these inputs exactly (17/255 == 273/4095).
for k in 0..=15 {
assert_eq!(low[k * 17], full[k * 273]);
}
let unsorted = [CurvePoint { input: 0.75, output: 0.8 }, CurvePoint { input: 0.25, output: 0.2 }];
let table = adjust::curve_samples::<5>(&unsorted);
for (actual, expected) in table.iter().zip([0.2, 0.2, 0.5, 0.8, 0.8]) {
assert!((actual - expected).abs() < 1e-6);
}
assert_eq!(adjust::curve_samples::<3>(&[]), vec![0.0, 0.5, 1.0]);
assert_eq!(adjust::curve_samples::<3>(&points[..1]), vec![0.0, 0.5, 1.0]);
assert!(adjust::curve_samples::<0>(&points).is_empty());
assert_eq!(adjust::curve_samples::<1>(&points), vec![0.0]);
}
#[test]
fn hue_saturation_roundtrips_and_desaturates() {
let c = [0.8, 0.3, 0.1];
@@ -933,6 +1041,55 @@ fn dissolve_coverage_matches_opacity() {
assert_eq!(flatten(&d), b);
}
/// Invert at 50% Dissolve is black or white, not the gray of a Normal mix (#3050).
#[test]
fn dissolve_adjustment_is_a_per_pixel_choice() {
let mut d = doc_white(16, 16);
let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
inv.blend = photocraft_color::BlendMode::Dissolve;
inv.opacity = 0.5;
d.layers.push(inv);
let b = flatten(&d);
let (black, white, other) = b.px.iter().fold((0, 0, 0), |(k, w, o), p| {
if close4(*p, [0.0, 0.0, 0.0, 1.0]) {
(k + 1, w, o)
} else if close4(*p, [1.0, 1.0, 1.0, 1.0]) {
(k, w + 1, o)
} else {
(k, w, o + 1)
}
});
assert_eq!(other, 0, "a partial mix would be gray");
assert!(black > 40 && white > 40, "black {black} white {white}");
}
/// A clipped black fill at 50% Dissolve stays black or white on an opaque base (#3052).
#[test]
fn clipped_dissolve_fill_is_a_per_pixel_choice() {
let mut d = doc_white(16, 16);
let mut l = solid_layer("k", Rect::new(0, 0, 16, 16), [0.0, 0.0, 0.0, 1.0]);
l.clipped = true;
l.blend = photocraft_color::BlendMode::Dissolve;
l.opacity = 0.5;
d.layers.push(l);
let b = flatten(&d);
assert!(b.px.iter().all(|p| close4(*p, [0.0, 0.0, 0.0, 1.0]) || close4(*p, [1.0; 4])));
}
/// A Color Overlay must not turn Dissolve coverage into a Normal mix (#3051).
#[test]
fn color_overlay_keeps_dissolve_coverage() {
use photocraft_doc::effects::{Effect, FxCommon};
let mut d = doc_white(16, 16);
let mut l = Layer::new("ink", LayerContent::Fill(Fill::Solid(photocraft_color::Color::BLACK)));
l.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: photocraft_color::Color::BLACK });
l.blend = photocraft_color::BlendMode::Dissolve;
l.opacity = 0.5;
d.layers.push(l);
let b = flatten(&d);
assert!(b.px.iter().all(|p| close4(*p, [0.0, 0.0, 0.0, 1.0]) || close4(*p, [1.0; 4])));
}
#[test]
fn render_is_tile_independent() {
// Rendering a sub-rect must equal the same region of a full render.
@@ -2453,6 +2610,71 @@ fn blend_if_hard_point_includes_its_own_level() {
}
}
/// #9521: Photoshop compares Blend If at its 16-bit precision (0..32768). A hard point at level
/// `c` sits on the step `round(c · 32768 / 255)`, inclusive for both black and white points:
/// measured in Photoshop 2025 at 1, 64, 128 and 200 (black first shown at steps 129, 8224, 16448,
/// 25700; white last shown at the same steps). RGB16 32895 is step 16448, so a white layer with
/// Underlying Blue black at 128 shows over it; 32894 (step 16447) hides it.
#[test]
fn blend_if_16_bit_cutoffs_follow_photoshops_steps() {
let shown = |v16: u16, black: u8, white: u8| {
let v = f32::from(v16) / 65535.0;
let mut d = Document::new("t", Size::new(1, 1), ColorMode::Rgb, SampleType::U16);
let layer16 = |name: &str, c: [f32; 4]| {
let mut l = Layer::raster(name, PixelFormat::RGBA16);
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &c);
l
};
d.layers.push(layer16("under", [v, v, v, 1.0]));
let mut l = layer16("top", [1.0, 1.0, 1.0, 1.0]);
l.blend_if.set(3, [FULL, range([black, black], [white, white])]);
d.layers.push(l);
px(&d, 0, 0)[0] > 0.999
};
// 16-bit file values for Photoshop's steps n: round(n · 65535 / 32768).
let at = |n: u32| ((n * 65535 + 16384) / 32768) as u16;
for (c, step) in [(1u8, 129u32), (64, 8224), (128, 16448), (200, 25700)] {
assert!(shown(at(step), c, 255), "black {c}: step {step} shows");
assert!(!shown(at(step - 1), c, 255), "black {c}: step {} hides", step - 1);
assert!(shown(at(step), 0, c), "white {c}: step {step} shows");
assert!(!shown(at(step + 1), 0, c), "white {c}: step {} hides", step + 1);
}
assert!(shown(32895, 128, 255) && !shown(32894, 128, 255), "the reporter's RGB16 boundary");
}
/// #9522: Blend If's Gray is Photoshop's (30 R + 59 G + 11 B) / 100 rounded half down, measured
/// in Photoshop 2025 with a white layer over one 8-bit backdrop colour and a hard Gray point on
/// Underlying Layer. Rec. 601 luma hid both of the reporter's colours.
#[test]
fn blend_if_gray_follows_photoshops_weights_and_rounding() {
let shown = |rgb: [u8; 3], black: u8, white: u8| {
let mut d = Document::new("t", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);
let c = rgb.map(|v| f32::from(v) / 255.0);
d.layers.push(solid_layer("under", Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]));
let mut l = solid_layer("top", Rect::new(0, 0, 1, 1), [1.0, 1.0, 1.0, 1.0]);
l.blend_if.set(0, [FULL, range([black, black], [white, white])]);
d.layers.push(l);
px(&d, 0, 0)[0] > 0.999 && px(&d, 0, 0)[2] > 0.999
};
// (colour hidden by a black point at `cut`, the next colour shown), single channels.
for (cut, below, at) in [
(20, [65, 0, 0], [66, 0, 0]),
(25, [81, 0, 0], [82, 0, 0]),
(28, [91, 0, 0], [92, 0, 0]),
(20, [0, 33, 0], [0, 34, 0]),
(28, [0, 46, 0], [0, 47, 0]),
(20, [0, 0, 177], [0, 0, 178]),
(28, [0, 0, 250], [0, 0, 251]),
] {
assert!(!shown(below, cut, 255), "{below:?} hidden by black {cut}");
assert!(shown(at, cut, 255), "{at:?} shown by black {cut}");
}
// The reporter's mixed colours, and the white point's half-down rounding.
assert!(shown([123, 126, 151], 128, 255), "127.85 is 128");
assert!(shown([120, 129, 149], 0, 128), "128.5 is 128");
assert!(!shown([121, 129, 149], 0, 128), "128.8 is 129");
}
#[test]
fn blend_if_split_point_fades_like_opacity() {
// A split black point (Alt-drag) at 50/150 shows a value-100 layer at half strength, which
@@ -2511,6 +2733,25 @@ fn blend_if_on_adjustment_and_clipped_layers() {
assert!(close4(px(&d, 6, 1), [0.0, 1.0, 0.0, 1.0]));
}
/// #9554: a Pass Through group with Blend If renders isolated, as Photoshop does: an Invert inside
/// it leaves the layer beneath alone. Photoshop: (64, 128, 192) under such a group with Red ›
/// Underlying Layer white at 110 stays (64, 128, 192); without the Blend If it is inverted.
#[test]
fn pass_through_group_with_blend_if_is_isolated() {
for (gated, want) in [(true, [64, 128, 192]), (false, [191, 127, 63])] {
let mut d = Document::with_background("pt", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::WHITE);
d.layers.push(solid_layer("base", Rect::new(0, 0, 1, 1), [64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0]));
let mut g = Layer::group("group", vec![Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert))]);
g.blend = BlendMode::PassThrough;
if gated {
g.blend_if.set(1, [FULL, range([0, 0], [110, 110])]);
}
d.layers.push(g);
let got: Vec<i32> = px(&d, 0, 0)[..3].iter().map(|v| (v * 255.0).round() as i32).collect();
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "Blend If {gated}: {got:?} vs Photoshop {want:?}");
}
}
#[test]
fn blend_if_modes() {
// Grayscale documents: the single channel's entry (or the gray entry) applies.
@@ -4161,12 +4402,8 @@ fn reused_render_matches_previous_allocating_stack() {
adjust::apply_in(adj, &mut adjusted, adjust::Transfer::for_document(doc.mode, depth), Some(depth), doc.mode);
for (b, a) in previous.px.iter_mut().zip(&adjusted.px) {
let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]);
*b = [
b[0] + (blended[0] - b[0]) * layer.opacity,
b[1] + (blended[1] - b[1]) * layer.opacity,
b[2] + (blended[2] - b[2]) * layer.opacity,
b[3],
];
let (k, j) = (layer.opacity, 1.0 - layer.opacity);
*b = [b[0] * j + blended[0] * k, b[1] * j + blended[1] * k, b[2] * j + blended[2] * k, b[3]];
}
if let Some(q) = adjustment_quantum(depth) {
quantize(&mut previous, q);
@@ -4347,3 +4584,21 @@ fn branchless_floor_matches_floor_bitwise() {
assert_eq!(branchless_floor(x).to_bits(), x.floor().to_bits(), "{x:e} ({:#010x})", x.to_bits());
}
}
/// #9542: an adjustment layer's "This Layer" range judges its result at full strength, before
/// Opacity mixes it in. Photoshop: Invert at 40% over (64, 128, 192) with Red / This Layer black
/// at 150 keeps the effect, (115, 128, 140), as Invert's red (191) passes 150; with white at 150
/// it drops it. Judged after Opacity (red 115) the two came out the other way round.
#[test]
fn adjustment_blend_if_judges_the_result_before_opacity() {
for (black, white, want) in [(150u8, 255u8, [115, 128, 140]), (0, 150, [64, 128, 192])] {
let mut d = Document::with_background("bi", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::WHITE);
d.layers.push(solid_layer("base", Rect::new(0, 0, 1, 1), [64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0]));
let mut inv = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert));
inv.opacity = 0.4;
inv.blend_if.set(1, [range([black, black], [white, white]), FULL]);
d.layers.push(inv);
let got: Vec<i32> = px(&d, 0, 0)[..3].iter().map(|v| (v * 255.0).round() as i32).collect();
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "black {black} white {white}: {got:?} vs Photoshop {want:?}");
}
}
+7 -2
View File
@@ -1026,12 +1026,17 @@ impl Document {
/// Pixel format for new raster layers in this document.
pub fn pixel_format(&self) -> PixelFormat {
let mode = match self.mode {
Self::pixel_format_for(self.mode, self.depth)
}
/// [`Self::pixel_format`] of a document in `mode` at `depth`.
pub fn pixel_format_for(mode: ColorMode, depth: SampleType) -> PixelFormat {
let mode = match mode {
ColorMode::Indexed | ColorMode::Multichannel => ColorMode::Rgb,
ColorMode::Bitmap | ColorMode::Duotone => ColorMode::Grayscale,
m => m,
};
PixelFormat::new(mode, self.depth, true)
PixelFormat::new(mode, depth, true)
}
pub fn bounds(&self) -> Rect {
+6
View File
@@ -101,6 +101,11 @@ pub struct CharStyle {
pub variations: Vec<FontVariation>,
/// BCP-47 language tag (affects shaping and line breaking).
pub language: Option<String>,
/// Vertical type: roman text (Latin, digits) runs rotated 90° clockwise along the column
/// (PSD `BaselineDirection` 2) instead of standing upright one character per em
/// (`BaselineDirection` 1, what Photoshop's Vertical Type tool sets, #9470). CJK text stands
/// upright either way.
pub rotate_roman: bool,
/// Applied character style (id in [`crate::TextStyles`]); `None` = "None". Attributes above
/// stay fully resolved; differences from the style are local overrides.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -135,6 +140,7 @@ impl Default for CharStyle {
features: Vec::new(),
variations: Vec::new(),
language: None,
rotate_roman: false,
style_sheet: None,
}
}

Some files were not shown because too many files have changed in this diff Show More