diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a80b19fe..1c2384496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [ main ] pull_request: branches: [ main ] + # mz/core runs on demand: gh workflow run ci.yml --ref mz/core + workflow_dispatch: jobs: test: @@ -12,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true cache-dependency-glob: "uv.lock" @@ -23,6 +25,20 @@ jobs: - name: Set up Python run: uv python install 3.13 + - name: Set up Rust + # Installs the toolchain pinned in negpy_core/rust-toolchain.toml. + working-directory: negpy_core + run: rustup toolchain install + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: negpy_core + + - name: Rust licenses and bans (cargo-deny) + uses: EmbarkStudios/cargo-deny-action@v2 + with: + manifest-path: negpy_core/Cargo.toml + - name: Install System Dependencies (Linux) if: runner.os == 'Linux' run: | @@ -34,6 +50,10 @@ jobs: # them those backend tests importorskip away. run: uv sync --group dev --group plustek --group pieusb + - name: Rust checks (fmt, clippy, test) + # Under uv run, PyO3's build script finds the project's Python 3.13, not the runner's. + run: uv run --no-sync make rust + - name: Run Linting (Ruff) run: uv run ruff check . @@ -58,8 +78,55 @@ jobs: run: uv run pytest tests/ --cov=negpy --cov-report=term-missing --cov-report=xml - name: Upload Coverage Report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage path: coverage.xml retention-days: 7 + + native: + # negpy_core must give the same bits on every release platform: run the Rust tests and + # the pytest oracles of the ported functions on each one. + name: Native (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-14, macos-15-intel] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.3.2 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python + run: uv python install 3.13 + + - name: Set up Rust + working-directory: negpy_core + run: rustup toolchain install + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: negpy_core + + - name: Rust tests + working-directory: negpy_core + run: cargo test + + - name: Install System Dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 + + - name: Install Dependencies + # The plustek group brings opencv, the oracle the ported-function tests compare against. + run: uv sync --group dev --group plustek + + - name: Ported-function oracles + run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py tests/test_core_lab.py tests/test_core_altprocess.py tests/test_core_finish.py diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml index a88b666ee..75cc3455d 100644 --- a/.github/workflows/metrics.yml +++ b/.github/workflows/metrics.yml @@ -48,10 +48,10 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true @@ -65,7 +65,7 @@ jobs: - name: Restore perf fixture cache id: cache-fixture - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.cache/negpy-metrics key: negpy-perf-raw-rawsamples-v1 @@ -105,7 +105,7 @@ jobs: test -f "$NEGPY_METRICS_OUT" && cat "$NEGPY_METRICS_OUT" || true - name: Upload metrics JSON - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: preview-metrics path: metrics-artifacts/preview_metrics.json @@ -114,7 +114,7 @@ jobs: - name: Post PR comment if: github.event_name == 'pull_request' continue-on-error: true - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const fs = require('fs'); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 967bec815..6b1c0f573 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,11 @@ on: - windows - linux - macos + publish: + description: 'Create the GitHub release (off for a test build)' + required: true + default: true + type: boolean jobs: setup: @@ -24,14 +29,16 @@ jobs: outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: + # Linux builds on a fixed Ubuntu: the AppImage needs the oldest glibc it can get, and + # ubuntu-latest moves to a newer release on its own. - id: set-matrix run: | if [ "${{ github.event.inputs.platform }}" == "all" ]; then - echo 'matrix=[{"os":"ubuntu-latest"},{"os":"windows-latest"},{"os":"macos-14","arch":"arm64"},{"os":"macos-15-intel","arch":"x86_64"}]' >> $GITHUB_OUTPUT + echo 'matrix=[{"os":"ubuntu-24.04"},{"os":"windows-latest"},{"os":"macos-14","arch":"arm64"},{"os":"macos-15-intel","arch":"x86_64"}]' >> $GITHUB_OUTPUT elif [ "${{ github.event.inputs.platform }}" == "windows" ]; then echo 'matrix=[{"os":"windows-latest"}]' >> $GITHUB_OUTPUT elif [ "${{ github.event.inputs.platform }}" == "linux" ]; then - echo 'matrix=[{"os":"ubuntu-latest"}]' >> $GITHUB_OUTPUT + echo 'matrix=[{"os":"ubuntu-24.04"}]' >> $GITHUB_OUTPUT elif [ "${{ github.event.inputs.platform }}" == "macos" ]; then echo 'matrix=[{"os":"macos-14","arch":"arm64"},{"os":"macos-15-intel","arch":"x86_64"}]' >> $GITHUB_OUTPUT fi @@ -45,10 +52,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true cache-dependency-glob: "uv.lock" @@ -56,8 +63,17 @@ jobs: - name: Set up Python run: uv python install 3.13 + - name: Set up Rust + # uv sync builds negpy_core with the toolchain pinned in negpy_core/rust-toolchain.toml. + working-directory: negpy_core + run: rustup toolchain install + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: negpy_core + - name: Install System Dependencies (Linux) - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-24.04' run: | sudo apt-get update sudo apt-get install -y libfuse2 libxcb-cursor0 libxkbcommon-x11-0 libxcb-xinerama0 patchelf libsane-dev @@ -66,7 +82,7 @@ jobs: - name: Install System Dependencies (macOS) if: startsWith(matrix.os, 'macos-') - run: brew install sane-backends llvm@20 + run: brew install sane-backends - name: Install Packaging Tools (Windows) if: matrix.os == 'windows-latest' @@ -80,18 +96,9 @@ jobs: } - name: Install Dependencies (Linux) - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-24.04' run: uv sync --all-groups - - name: Configure LLVM for llvmlite (macOS x86_64) - if: matrix.arch == 'x86_64' - shell: bash - run: | - LLVM20="$(brew --prefix llvm@20)" - echo "PATH=/usr/local/bin:${LLVM20}/bin:${PATH}" >> "$GITHUB_ENV" - echo "LLVM_CONFIG=${LLVM20}/bin/llvm-config" >> "$GITHUB_ENV" - echo "CMAKE_ARGS=-DLLVM_DIR=${LLVM20}/lib/cmake/llvm -DCMAKE_PREFIX_PATH=${LLVM20}" >> "$GITHUB_ENV" - - name: Install Dependencies (macOS) if: startsWith(matrix.os, 'macos-') run: CFLAGS="-I$(brew --prefix)/include" LDFLAGS="-L$(brew --prefix)/lib" uv sync --all-groups @@ -121,7 +128,7 @@ jobs: run: ls -R dist/ - name: Upload Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: negpy-${{ matrix.os }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }}-${{ github.event.inputs.version }} if-no-files-found: error @@ -133,20 +140,20 @@ jobs: publish: needs: build runs-on: ubuntu-latest - if: success() + if: success() && inputs.publish environment: production permissions: contents: write steps: - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: all-binaries pattern: negpy-* merge-multiple: true - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.event.inputs.version }} name: NegPy ${{ github.event.inputs.version }} diff --git a/CLAUDE.md b/CLAUDE.md index 2f0c96ae4..939d501ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,22 +15,25 @@ Guidance for Claude Code in this repository. It holds rules and traps the code d ```bash make run # Launch the desktop app make run-sim # Launch with a simulated camera, Scanlight and film scanners -make all # lint + type check + tests (run before committing) +make all # format + Rust checks + lint + type check + tests (run before committing) +make rust # cargo fmt --check + clippy -D warnings + cargo test for negpy_core +make replay # replay recorded real calls through the current code, bit for bit (PHASE=N) +make bench # all-core speed of ported functions against the recorded Python (PHASE=N) make test # pytest only make lint # ruff check make type # ty check (not mypy) -make format # ruff format + autofix +make format # cargo fmt + ruff format + autofix make translations # Extract translation sources and preserve existing translations # Single test uv run pytest tests/test_exposure_logic.py::test_name -v ``` -All commands run through `uv run`; never invoke pytest/ruff/ty directly. Run `make format` before committing; if it reformats unrelated files, commit those as lint fixes. +All commands run through `uv run`; never invoke pytest/ruff/ty directly. `uv run` compiles `negpy_core` again after a Rust file changes (`tool.uv.cache-keys`). Run `make format` before committing; if it reformats unrelated files, commit those as lint fixes. ## Architecture -NegPy is a film-negative processing desktop app (PyQt6 + WebGPU). Images flow through a multi-stage pipeline implemented twice, CPU (numpy/Numba) and GPU (WGSL via `wgpu`), which must stay in numerical parity. +NegPy is a film-negative processing desktop app (PyQt6 + WebGPU). Images flow through a multi-stage pipeline implemented twice, CPU (numpy and `negpy_core`) and GPU (WGSL via `wgpu`), which must stay in numerical parity. ### Data model @@ -50,6 +53,24 @@ Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored **Film edge print** (`services/film_edge/`): a stock's own marks, DX number and respool source (`prints_as`) are the `edge` object in `gear/film_stocks.json`, and each entry names its `source`. The `edge-print-from-photo` skill adds a stock from a photo of the film. +### negpy_core (Rust) + +`negpy_core/` is a PyO3 extension (abi3-py313, maturin, a `uv` workspace member) that takes over CPU kernels one group at a time. + +- A ported function keeps its Python signature; the Python body becomes a call into `negpy_core`, and the Python implementation goes in the same change. No runtime switch, no fallback. +- Core code in `src/.rs` takes slices or `ndarray` views and plain numbers, never config. `src/py/.rs` holds only the wrappers: dtype, shape and contiguity checks, `py::detach` around the work, errors as Python exceptions. No panic crosses into Python. +- `cargo test` builds without Python: PyO3 is behind the `python` feature, which maturin turns on. +- Pure Rust: no dependency compiles C or C++ (`deny.toml` bans `cc`, `cmake`, `pkg-config`, `bindgen`). Codecs that need C stay on their Python wheels. A new crate's license goes in `NOTICE.md`. +- No `unsafe` except SIMD, each with a reason. Names mirror the Python names. +- `mul_add` is one instruction only on a CPU with FMA; the release baseline has none, so a loop that uses it takes `#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))]`. `mul_add` is exactly fused on every path, so the bits never depend on the CPU. +- Hot loops: an element-wise pass goes through `par_zip` (rayon blocks; `par_iter_mut().zip` makes a task per element), and its closure is `move`. A capture read through a reference can alias the output, so LLVM reloads it per element and does not vectorize. +- A float-to-int `as` cast saturates, and LLVM leaves such a loop scalar. Clamp first, then take the integer part as `convert.rs::trunc_bits` does (add 2^52, read the low bits). +- **Bit-exact**: a port matches the Python result bit for bit (a zero's sign may differ where NumPy ranks -0.0 and 0.0 equal), checked by a pytest oracle against the numpy or `cv2` call it replaces. The `cv2` oracles run on Linux x86-64 only (`tests/core_oracle.py`: opencv-python's other builds differ in the last bits); `tests/test_core_same_bits.py` holds output hashes that every release OS must match (`ci.yml` job `native`). NumPy's rules to copy: the linear percentile (virtual index in f64, interpolation in f32, the `t >= 0.5` branch from the upper value); float32 sums in NumPy's order; NEP 50 promotion (a Python float joins float32 math as float32); Numba `fastmath` as f64 with `mul_add`; Python `round` ties to even. No fast-math. Threads only over independent outputs, or integer reductions, so the core count never changes a result. A reduction with a large accumulator takes one chunk per thread (rayon's `fold` makes one per split job) and enough work per chunk to repay clearing and merging it. +- `negpy_core` holds every CPU kernel; NegPy has no Numba and imports no `cv2` (`tests/test_no_cv2.py`). opencv-python is only a dependency of the plustek extra and group, for pyopticfilm's pass alignment; the tests use it as their oracle through the plustek group. Every binding runs its work through `py::detach(py, n, f)`, never `py.detach`: it releases the GIL, keeps work under 64K elements on the calling thread and enters the all-core pool once per call (`tests/test_core_ir.py` checks). A thread marked by `negpy_core.set_background_thread(True)` (the thumbnail refresh) enters a pool of a quarter of the cores (at least two) instead, so the live preview keeps the rest. The app has no single-core mode. +- `negpy_core/manifest.toml` lists every function to port (phase, status, `depends`). A port sets its entries to `ported`; `uv run python scripts/core_manifest.py` refreshes the rest after a change or a merge from `main`, and `tests/test_core_manifest.py` fails while it is stale. +- **Replay**: before a group is ported, record its calls on main (`scripts/replay.py record FRAME... --phase N`, corpus in `$NEGPY_CORPUS`). The port passes `make replay` and `make bench` (never slower with every core in use, as users run the app). Both are local tools, not CI. +- Transcendental functions (`log10`, `exp`, `pow`, ...) in array loops come from `src/mathf.rs` (branch-free, so they vectorize; `libm` is too slow for the speed gate), scalar ones from the `libm` crate. NumPy uses SVML on an AVX-512 CPU and libm elsewhere, so the Python bits already differ by CPU. A port that cannot be bit-exact writes an entry in `negpy_core/LEDGER.md` and a `compare` mode in the manifest (`rows`, `ulp=N`); the release bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for the whole ledger. + ### Pipeline - **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`): base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish. The first four stages are cached per config-hash via `_run_stage()`. Base and exposure route on `process.path.render_path`: `PRINT` runs `features/exposure/`; `TRANSFER` and `POSITIVE` run `features/transparency/`. `features/exposure/` holds no slide branch and never imports `features/transparency/` (`tests/test_slide_separation.py`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8bd37caf..d92164d9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,10 +11,12 @@ A 👀 reaction on your comment confirms that the command ran. ## Development Setup -NegPy requires Python 3.13+ and `uv`. +NegPy requires Python 3.13+, `uv` and a Rust toolchain. ### 1. Prerequisites -Install [uv](https://docs.astral.sh/uv/getting-started/installation/). +Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and [rustup](https://rustup.rs/). `rustup` reads the toolchain version from `negpy_core/rust-toolchain.toml`. + +The `negpy_core/` folder is a Rust library with NegPy's CPU kernels. The first `uv sync` compiles it. After you edit a Rust file, the next `uv run` or `make test` compiles it again. `negpy_core` builds no C or C++ code: `cargo-deny` (`negpy_core/deny.toml`) rejects a dependency that does. Its tests compare against opencv, which only the `plustek` group installs; `make install` (`uv sync --all-groups`) brings it. Scanner support (optional): @@ -108,6 +110,7 @@ backend are fake. - `negpy/kernel/`: core system services (Logging, Config, caching). - `negpy/services/`: high-level orchestration (Rendering engine, Export service). - `negpy/desktop/`: PyQt6 UI (View, Controller, Workers). +- `negpy_core/`: Rust library for CPU kernels (`src/.rs`), with its PyO3 wrappers in `src/py/`. - `tests/`: unit and integration tests. ## Coding Standards @@ -149,8 +152,10 @@ Every developer command runs through `uv run`: - `make lint`: Ruff checks. - `make type`: `ty` type checks. - `make test`: unit tests. -- `make format`: auto-format with Ruff. -- `make all`: lint, type and test in sequence. +- `make format`: auto-format with `cargo fmt` and Ruff. +- `make rust`: `cargo fmt --check`, `cargo clippy` (warnings fail) and `cargo test` for `negpy_core`. +- `make all`: format, Rust checks, lint, type and test in sequence. +- `make replay` / `make bench`: check a `negpy_core` port against calls recorded on real frames, for identical results and speed. Record with `uv run python scripts/replay.py record FRAME...`; the corpus goes to `$NEGPY_CORPUS` (default `~/negpy-corpus`). - `make clean`: remove cache and build artifacts. - `make run-sim`: run with simulated scanning hardware (see [above](#running-without-scanning-hardware)). - `make clear-devhome`: delete the development user directory (see [above](#a-separate-user-directory-for-development)). diff --git a/Makefile b/Makefile index 1990f2891..09e440fb8 100644 --- a/Makefile +++ b/Makefile @@ -16,9 +16,23 @@ $(error NEGPY_USER_DIR is "$(NEGPY_USER_DIR)", which is not an absolute path. ma endif endif +# maturin reads ARCHFLAGS on every OS, so a stray "-arch x86_64" builds negpy_core for macOS. +unexport ARCHFLAGS + +# From inside negpy_core/, so rustup picks the toolchain pinned in its rust-toolchain.toml. +CARGO = cd negpy_core && cargo --quiet + # Default target .PHONY: all -all: format lint type test +all: format rust lint type test + +# Rust checks for negpy_core: format, clippy (warnings fail), unit tests +.PHONY: rust +rust: + @echo "Running Rust checks (cargo fmt, clippy, test)..." + @$(CARGO) fmt --check + @$(CARGO) clippy --all-targets --all-features -- -D warnings + @$(CARGO) test .PHONY: translations translations-check translations: @@ -70,10 +84,22 @@ test: @echo "Running unit tests (pytest)..." @$(UV) pytest tests/ --cov=negpy --cov-report=term-missing +# negpy_core replay corpus ($$NEGPY_CORPUS, default ~/negpy-corpus). PHASE=2 limits to one phase. +# Record first, on main: uv run python scripts/replay.py record FRAME... [--phase N] +REPLAY_PHASE = $(if $(PHASE),--phase $(PHASE)) + +.PHONY: replay bench +replay: + @$(UV) python scripts/replay.py replay $(REPLAY_PHASE) + +bench: + @$(UV) python scripts/replay.py bench $(REPLAY_PHASE) + # Auto-format and fix (ruff) .PHONY: format format: @echo "Running ruff format and fix..." + @$(CARGO) fmt @$(UV) ruff format . @$(UV) ruff check --fix . @@ -165,4 +191,5 @@ clean: rm -rf .ruff_cache rm -rf build rm -rf dist + rm -rf negpy_core/target find . -type d -name "__pycache__" -exec rm -rf {} + diff --git a/NOTICE.md b/NOTICE.md index f01b65e93..d16794516 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -58,3 +58,26 @@ without warranties: Modified by NegPy: each four-digit DX number is split into the part 1 and part 2 of the film edge barcode. NegPy distributes them under the GPL v3, which Creative Commons lists as a BY-SA compatible license. + +The negpy_core extension module links these Rust crates: PyO3 and rust-numpy +(MIT OR Apache-2.0; rust-numpy BSD-2-Clause), rayon and crossbeam (MIT OR +Apache-2.0), ndarray, num-traits, num-complex, num-integer, matrixmultiply and +rawpointer (MIT OR Apache-2.0), libc, once_cell, either, portable-atomic and +rustc-hash (MIT OR Apache-2.0), multiversion and target-features (MIT OR Apache-2.0), libm (MIT, a port of +musl's math library), rustfft, realfft, primal-check, strength_reduce and transpose (MIT OR +Apache-2.0). +negpy_core/Cargo.lock lists the exact versions. + +negpy_core/src/resize.rs and negpy_core/src/filter.rs port OpenCV's float32 area +resize, separable filter engine and bit-exact Gaussian kernel (including its +softfloat exp table) so their results match cv2 bit for bit. negpy_core/src/lsd.rs +ports OpenCV's line segment detector (`modules/imgproc/src/lsd.cpp`), +negpy_core/src/inpaint.rs its Navier-Stokes inpaint, and +negpy_core/src/seam.rs its dynamic-programming seam finder. OpenCV, Apache +License 2.0. https://github.com/opencv/opencv + +negpy_core/src/sift.rs and negpy_core/src/register.rs port OpenCV 4.13's SIFT +detector and descriptor (sift.dispatch.cpp, sift.simd.hpp), its RANSAC +similarity estimate and Levenberg-Marquardt refinement (ptsetreg.cpp, +levmarq.cpp). OpenCV, Apache License 2.0. SIFT is by David G. Lowe; its patent +expired in March 2020. diff --git a/README.md b/README.md index 7e382232b..081372951 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,14 @@ [![Downloads](https://img.shields.io/github/downloads/marcinz606/NegPy/total)](https://github.com/marcinz606/NegPy/releases) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.13%2B-blue)](pyproject.toml) + [![Rust](https://img.shields.io/badge/rust-negpy__core-orange?logo=rust)](negpy_core) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](#getting-started) [![Contributors](https://img.shields.io/github/contributors/marcinz606/NegPy)](https://github.com/marcinz606/NegPy/graphs/contributors) [![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/JySNzUWgwy) -NegPy turns scans of film negatives and slides into finished pictures. I wrote it because I wanted a tool made for film scans that does more than invert them. It models film and photographic paper, and adds the conveniences of a lab scanner. It is written in Python and runs on Linux, macOS and Windows. +NegPy turns scans of film negatives and slides into finished pictures. I wrote it because I wanted a tool made for film scans that does more than invert them. It models film and photographic paper, and adds the conveniences of a lab scanner. It runs on Linux, macOS and Windows. Python runs the interface and organizes the work, and Rust does the image processing. The GPU renders through wgpu, a Rust library. Every CPU step runs in `negpy_core` on all cores, with or without the GPU: analysis, detection, dust removal, export encoding and the CPU render path. ![NegPy main window](docs/media/0650.webp) @@ -53,7 +54,7 @@ The desktop interface and panel guides support English, Simplified Chinese (简 * Darkroom tools: test strips, ring-around, step wedge, zone overlay, spot densitometer and zone placement. * Contrast mask, tilt and swing, edge burn, filed carrier and print mats. * Lith, cyanotype and toners for B&W. -* GPU rendering (Vulkan, Metal or DX12) with a CPU fallback that gives the same result. +* GPU rendering through wgpu (Vulkan, Metal or DX12), and a multi-core CPU path in Rust that gives the same result. ### Output @@ -71,11 +72,9 @@ The desktop interface and panel guides support English, Simplified Chinese (简 ## Getting started -Download the build for your OS from the [Releases page](https://github.com/marcinz606/NegPy/releases). When a new version is out, a dot shows on the ⋯ menu, and **Update to vX.Y.Z…** in its Help menu installs it. +The recommended way is to run NegPy from source: you get each fix as soon as it is on `main`, and your system shows no warning for an unsigned app. [CONTRIBUTING.md](CONTRIBUTING.md#development-setup) has the steps: install `uv` and `rustup`, then run `make install` and `make run`. -To build from source, see [CONTRIBUTING.md](CONTRIBUTING.md). - -The builds are not signed, so expect a warning on first run. +You can also download the build for your OS from the [Releases page](https://github.com/marcinz606/NegPy/releases). When a new version is out, a dot shows on the ⋯ menu, and **Update to vX.Y.Z…** in its Help menu installs it. The builds are not signed, so expect a warning on first run. ### Linux diff --git a/build.py b/build.py index 2ac90f5f5..4b4e70f1e 100644 --- a/build.py +++ b/build.py @@ -43,7 +43,6 @@ def get_macos_target_arch(): "--hidden-import=rawpy", "--hidden-import=cv2", "--hidden-import=numpy", - "--hidden-import=numba", "--hidden-import=PIL", "--hidden-import=PIL.Image", "--hidden-import=PIL.ImageCms", @@ -435,9 +434,8 @@ def check_bundled_dylib_collisions(): bump that silently drops a symbol fails the build instead of shipping broken silently. - Does not check libraries PyInstaller fails to bundle at all (e.g. numba's - optional libomp.dylib) -- that is a different failure mode (absence, not - a collision) with no evidence of user impact; see LIBLCMS2_DYLIB_COLLISION.md. + Does not check libraries PyInstaller fails to bundle at all -- that is a + different failure mode (absence, not a collision); see LIBLCMS2_DYLIB_COLLISION.md. """ frameworks = os.path.join("dist", f"{APP_NAME}.app", "Contents", "Frameworks") all_files = [ diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a6ed2ac79..6ff2923be 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -838,7 +838,6 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra ### Performance * **GPU Acceleration**: renders on the graphics card. Off uses the slower CPU, with the same result. If the GPU fails at launch, an amber line here says so. -* **Multi-core CPU Rendering**: uses all CPU cores. It is off at first on macOS, where it can crash. * **Search by Meaning**: find frames by description (§2). * **Update Thumbnails Automatically**: renders out-of-date thumbnails again in the background, when a roll opens and after a change to many frames. Off (the default), the frames get an amber dot until you use **Update Thumbnails**. * **Preview size**: the canvas resolution. Higher is sharper and uses more memory. @@ -861,7 +860,6 @@ If NegPy crashes at launch or draws wrongly, edit `Documents/NegPy/override.toml | `display.qt_rhi_backend` | `"auto"`, `"vulkan"`, `"d3d12"`, `"metal"`, `"opengl"`, `"software"` | How the window is drawn. | | `display.qt_platform` | `"auto"`, `"xcb"`, `"wayland"` | Window system (Linux only). | | `performance.force_hq_preview` | `true` / `false` | Turns HQ Preview on or off. | -| `performance.cpu_parallel` | `true` / `false` | Multi-core CPU Rendering. | | `logging.level` | `"debug"`, `"info"`, `"warning"`, `"error"` | How much NegPy logs. Use `"debug"` for a bug report. | The memory rows of Preferences can be set here too (`max_texture_size`, `preview_render_size`, `preview_cache_max_bytes`, `preview_cache_max_entries`, `preview_cache_max_full_res_entries`, `render_memo_max_entries`). diff --git a/flake.nix b/flake.nix index 3eee678d7..57d859c14 100644 --- a/flake.nix +++ b/flake.nix @@ -40,8 +40,22 @@ sourcePreference = "wheel"; }; + # negpy-core stays a normal build: a Rust edit needs a new `nix develop`. editableOverlay = workspace.mkEditablePyprojectOverlay { root = "$REPO_ROOT"; + members = ["negpy"]; + }; + + # The Rust extension builds with maturin from the vendored Cargo.lock; nothing is + # downloaded at build time. + rustOverlay = pkgs: final: prev: { + negpy-core = prev.negpy-core.overrideAttrs (old: { + cargoDeps = pkgs.rustPlatform.importCargoLock {lockFile = ./negpy_core/Cargo.lock;}; + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [pkgs.rustPlatform.cargoSetupHook pkgs.cargo pkgs.rustc] + ++ final.resolveBuildSystem {maturin = [];}; + }); }; # Packages that need system Qt6/Vulkan/LLVM rather than what autoPatchelf can wire @@ -55,8 +69,6 @@ }; in { numpy = fromNixpkgs "numpy" prev.numpy; - llvmlite = fromNixpkgs "llvmlite" prev.llvmlite; - numba = fromNixpkgs "numba" prev.numba; # nixpkgs' own "opencv-python-headless" is a metapackage that only propagates # "opencv4" (where cv2 actually lives); nixpkgsPrebuilt strips propagation, so # point straight at opencv4 or the venv ends up without a cv2 module. @@ -141,6 +153,7 @@ pyproject-build-systems.overlays.wheel overlay (nixpkgsFallbackOverlay pkgs pkgs.python313Packages) + (rustOverlay pkgs) (dataFixupOverlay python) ] ); @@ -254,6 +267,10 @@ virtualenv pkgs.uv pkgs.qt6.qtbase + pkgs.cargo + pkgs.rustc + pkgs.rustfmt + pkgs.clippy ]; env = { UV_NO_SYNC = "1"; diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 83489e9e8..951373963 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, fields, replace from typing import Any, Callable, Collection, Dict, List, Optional, Set, Tuple, Union -import cv2 +import negpy_core import numpy as np from PyQt6.QtCore import Q_ARG, QMetaObject, QObject, Qt, QThread, QTimer, pyqtSignal from PyQt6.QtGui import QIcon, QPixmap, QTransform @@ -43,6 +43,7 @@ resolve_output_dir, ) from negpy.desktop.workers.render import ( + _THUMBNAIL_WORKERS, AssetDiscoveryTask, AssetDiscoveryWorker, AutoDetectAllSplitsTask, @@ -293,7 +294,7 @@ def _interactive_proxy(raw: Optional[Any]) -> Optional[Any]: return None scale = APP_CONFIG.preview_render_size / float(long_edge) w, h = max(1, round(raw.shape[1] * scale)), max(1, round(raw.shape[0] * scale)) - return cv2.resize(raw, (w, h), interpolation=cv2.INTER_AREA) + return negpy_core.resize_area(np.ascontiguousarray(raw, dtype=np.float32), (w, h)) def _interactive_ir_proxy(ir: Optional[Any], proxy: Optional[Any]) -> Optional[Any]: @@ -453,13 +454,15 @@ def thumbnail_refresh_progress_text( mean_render_s: float, *, in_flight: bool = False, + frames_at_once: int = 1, ) -> str: - """With ``in_flight`` the ``index``-th frame is still decoding; otherwise ``index`` frames are done.""" + """With ``in_flight`` the ``index``-th frame is still decoding; otherwise ``index`` frames are done. + The time left divides by ``frames_at_once``, the frames the worker renders side by side.""" text = tr("Thumbnails {index}/{total}", index=index, total=total) samples = index - 1 if in_flight else index left = total - samples if in_flight else total - index if samples >= 2 and left > 0: - seconds = (mean_decode_s + mean_render_s) * left + seconds = (mean_decode_s + mean_render_s) * left / frames_at_once left_text = ( tr("~{value} min left", value=round(seconds / 60)) if seconds >= 60 else tr("~{value} s left", value=max(1, round(seconds))) ) @@ -4762,7 +4765,11 @@ def _thumbnail_render_means(self) -> tuple[float, float]: def _on_thumbnail_render_frame_started(self, index: int, total: int, name: str) -> None: if not self._thumbnail_render_running: return - self.thumbnail_refresh_progress.emit(thumbnail_refresh_progress_text(index, total, *self._thumbnail_render_means(), in_flight=True)) + self.thumbnail_refresh_progress.emit( + thumbnail_refresh_progress_text( + index, total, *self._thumbnail_render_means(), in_flight=True, frames_at_once=_THUMBNAIL_WORKERS + ) + ) def _on_thumbnail_render_progress(self, current: int, total: int, name: str, decode_s: float, render_s: float) -> None: if not self._thumbnail_render_running: @@ -4772,7 +4779,9 @@ def _on_thumbnail_render_progress(self, current: int, total: int, name: str, dec timing[1] += render_s timing[2] += 1 self.status_progress_requested.emit(current, total) - self.thumbnail_refresh_progress.emit(thumbnail_refresh_progress_text(current, total, *self._thumbnail_render_means())) + self.thumbnail_refresh_progress.emit( + thumbnail_refresh_progress_text(current, total, *self._thumbnail_render_means(), frames_at_once=_THUMBNAIL_WORKERS) + ) def _on_thumbnail_rendered(self, frame: ThumbnailRenderInput, buffer: np.ndarray) -> None: if not self._thumbnail_render_running: @@ -7817,7 +7826,6 @@ def _paint_flatfield_peek(self) -> None: """Paint the selected profile's reference times its gain, ±EVENNESS_RANGE as black..white: a good profile shows gray. With no stored reference, show the light its gain corrects. A sensor-layout profile takes the frame's EXIF orientation, quarter turns and flips only.""" - import cv2 from negpy.features.flatfield.logic import EVENNESS_RANGE, GAIN_VIEW_RANGE, evenness_view, self_corrected from negpy.services.assets.flatfield import FlatFieldProfiles @@ -7862,7 +7870,7 @@ def _paint_flatfield_peek(self) -> None: view = np.ascontiguousarray(view) h, w = view.shape[:2] scale = APP_CONFIG.preview_render_size / max(h, w) - view = cv2.resize(view, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_LINEAR) + view = negpy_core.resize_linear(np.ascontiguousarray(view, dtype=np.float32), (round(w * scale), round(h * scale))) # Display-ready: the map is already black..white, so no working OETF. self._store_peek_frame(view, splash=False, crop_preview_full=False) self.set_status(message, 10000) diff --git a/negpy/desktop/main.py b/negpy/desktop/main.py index 728c54217..54c795fd5 100644 --- a/negpy/desktop/main.py +++ b/negpy/desktop/main.py @@ -35,7 +35,6 @@ from negpy.kernel.system.override import apply as apply_override from negpy.kernel.system.override import apply_stored as apply_stored_override from negpy.kernel.system.override import load_or_create as load_override -from negpy.kernel.system.parallel import configure_cpu_parallel, parallel_enabled, resolve_cpu_parallel, set_parallel_enabled from negpy.kernel.system.paths import get_resource_path logger = get_logger(__name__) @@ -222,35 +221,6 @@ def _bootstrap_environment() -> None: GearProfiles.ensure_user_dir() -def _offer_to_disable_cpu_parallel(repo, parent) -> None: - """The previous run had multi-core kernels on and did not exit cleanly. Say so. - - Numba's workqueue threading layer terminates the process on concurrent entry, with no - Python exception to catch and nothing written to the log, so the app cannot report the - crash as it happens — only notice afterwards that the last run never finished. Without - this the setting is un-diagnosable: a user turns it on, hits an abort weeks later, and - reports a crash nobody can connect to it. - """ - from PyQt6.QtWidgets import QMessageBox - - box = QMessageBox(parent) - box.setIcon(QMessageBox.Icon.Warning) - box.setWindowTitle(tr("NegPy Closed Unexpectedly")) - box.setText(tr("The last session ended unexpectedly with multi-core CPU rendering turned on.")) - box.setInformativeText( - tr( - "That setting is the most likely cause. Turning it off costs some speed on merges " - "and exports, and is the safe setting.\n\nTurn multi-core CPU rendering off?" - ) - ) - box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - box.setDefaultButton(QMessageBox.StandardButton.Yes) - if box.exec() == QMessageBox.StandardButton.Yes: - repo.save_global_setting("cpu_parallel", False) - set_parallel_enabled(False) - logger.warning("CPU parallel kernels disabled after an unclean shutdown") - - def main() -> None: """ Desktop entry point. @@ -260,8 +230,6 @@ def main() -> None: _install_exception_hook() # log unhandled slot exceptions to negpy.log instead of aborting try: - os.environ["NUMBA_THREADING_LAYER"] = "workqueue" - apply_override(override_cfg, APP_CONFIG) try: @@ -296,18 +264,6 @@ def main() -> None: if override_cfg.low_vram_export_tiling is None: APP_CONFIG.low_vram_export_tiling = bool(repo.get_global_setting("low_vram_export_tiling", False)) - # Multi-core Numba kernels: override.toml, then the saved setting, then the platform - # default (off on macOS). Read before the flags below are overwritten. - prev_clean_exit = bool(repo.get_global_setting("clean_shutdown", True)) - prev_run_parallel = bool(repo.get_global_setting("cpu_parallel_active", False)) - stored_parallel = repo.get_global_setting("cpu_parallel", None) - configure_cpu_parallel(resolve_cpu_parallel(APP_CONFIG.cpu_parallel, None if stored_parallel is None else bool(stored_parallel))) - # Numba's workqueue layer aborts the process outright on concurrent entry, with no - # exception, no dialog and nothing in the log. A marker is the only way an abort can - # later be attributed to this setting rather than reported as a mystery. - repo.save_global_setting("clean_shutdown", False) - repo.save_global_setting("cpu_parallel_active", parallel_enabled()) - # Resolve flat-field gains by profile id from the on-disk store, which keeps the numpy # logic layer free of any storage dependency, then migrate any legacy DB-backed profiles # into that store. @@ -358,13 +314,10 @@ def main() -> None: controller = AppController(session_manager) window = MainWindow(controller) - if prev_clean_exit is False and prev_run_parallel: - _offer_to_disable_cpu_parallel(repo, window) window.show() exit_code = app.exec() controller.cleanup() - repo.save_global_setting("clean_shutdown", True) # PyQt's at-exit cleanup walks sip's wrapper map while it deletes these, and the Python # code their destructors run (event filters, the proxy style) can reallocate the map. for widget in [w for w in app.topLevelWidgets() if w.parent() is None and sip.ispyowned(w)]: diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 140a4a028..03f15a384 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -7,7 +7,7 @@ from dataclasses import replace from typing import Any, Dict, List, Optional, Tuple -import cv2 +import negpy_core import numpy as np import qtawesome as qta from PyQt6.QtCore import QEvent, QLineF, QPointF, QRectF, QSize, Qt, QTimer, pyqtSignal @@ -1713,11 +1713,10 @@ def _zone_map_image(self, zones: np.ndarray) -> QImage: def _zone_outline(self, zones: np.ndarray, zone: int) -> List[QPolygonF]: if zone not in self._zone_outlines: - contours, _ = cv2.findContours((zones == zone).astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + mask = np.ascontiguousarray(zones == zone, dtype=np.uint8) self._zone_outlines[zone] = [ - QPolygonF([QPointF(float(x) + 0.5, float(y) + 0.5) for [[x, y]] in cv2.approxPolyDP(c, 1.0, True)]) - for c in contours - if cv2.contourArea(c) >= _ZONE_OUTLINE_MIN_AREA + QPolygonF([QPointF(float(x) + 0.5, float(y) + 0.5) for x, y in c]) + for c in negpy_core.contour_outlines(mask, float(_ZONE_OUTLINE_MIN_AREA), 1.0) ] return self._zone_outlines[zone] @@ -2372,7 +2371,7 @@ def _ir_layer_qimage(self) -> Optional[QImage]: h_ir, w_ir = ir.shape[:2] map_x = (uv_grid[..., 0] * (w_ir - 1)).astype(np.float32) map_y = (uv_grid[..., 1] * (h_ir - 1)).astype(np.float32) - remapped = cv2.remap(np.ascontiguousarray(ir, dtype=np.float32), map_x, map_y, interpolation=cv2.INTER_LINEAR) + remapped = negpy_core.remap(np.ascontiguousarray(ir, dtype=np.float32), map_x, map_y, border="constant") gray = np.ascontiguousarray((np.clip(remapped, 0.0, 1.0) * 255.0).astype(np.uint8)) gh, gw = gray.shape[:2] img = QImage(gray.data, gw, gh, gw, QImage.Format.Format_Grayscale8).copy() @@ -2410,7 +2409,7 @@ def _mask_wash_qimage(self, mask: np.ndarray, color: QColor) -> Optional[QImage] h_m, w_m = mask.shape[:2] map_x = (uv_grid[..., 0] * (w_m - 1)).astype(np.float32) map_y = (uv_grid[..., 1] * (h_m - 1)).astype(np.float32) - remapped = cv2.remap(np.ascontiguousarray(mask, dtype=np.float32), map_x, map_y, interpolation=cv2.INTER_NEAREST) + remapped = negpy_core.remap(np.ascontiguousarray(mask, dtype=np.float32), map_x, map_y, "nearest", "constant") img = _tint_qimage([(remapped > 0.5, color)], _IR_CORRECTED_ALPHA) if len(self._wash_cache) > 8: # drop stale ids from prior frames self._wash_cache.clear() diff --git a/negpy/desktop/view/sidebar/files.py b/negpy/desktop/view/sidebar/files.py index 95c4868f8..3fb40a115 100644 --- a/negpy/desktop/view/sidebar/files.py +++ b/negpy/desktop/view/sidebar/files.py @@ -3,6 +3,7 @@ import os from typing import Any, List, Optional +import negpy_core import numpy as np import qtawesome as qta @@ -682,13 +683,8 @@ def _rgb_pixels(image: QImage) -> np.ndarray: def print_dmax(image: QImage) -> Optional[tuple[int, int, int]]: """A print's D-max tone: the mean color of the darkest pixels of a thumbnail, its padding left out.""" - pixels = _rgb_pixels(image.copy(picture_in_thumbnail(image))).reshape(-1, 3).astype(np.float32) - if not len(pixels): - return None - luma = pixels @ np.array([0.2126, 0.7152, 0.0722], np.float32) - darkest = pixels[luma <= np.quantile(luma, _DMAX_SHARE)] - red, green, blue = (int(round(v)) for v in darkest.mean(axis=0)) - return red, green, blue + pixels = np.ascontiguousarray(_rgb_pixels(image.copy(picture_in_thumbnail(image)))) + return negpy_core.print_dmax(pixels, _DMAX_SHARE) def picture_in_thumbnail(image: QImage) -> QRect: diff --git a/negpy/desktop/view/sidebar/scanlight.py b/negpy/desktop/view/sidebar/scanlight.py index 5ba2b1d14..6da932044 100644 --- a/negpy/desktop/view/sidebar/scanlight.py +++ b/negpy/desktop/view/sidebar/scanlight.py @@ -14,7 +14,7 @@ from dataclasses import asdict, dataclass, fields, replace from typing import Optional -import cv2 +import negpy_core import numpy as np import qtawesome as qta from PyQt6.QtCore import QTimer, pyqtSignal, pyqtSlot @@ -59,7 +59,7 @@ from negpy.features.exposure.analysis import output_histogram from negpy.services.capture.focus_meter import FocusMeter from negpy.services.capture.live_stats import clip_masks -from negpy.services.capture.meter import framing_mismatch, ranked_thumb, raw_clip_mask +from negpy.services.capture.meter import framing_mismatch, gray_of, ranked_thumb, raw_clip_mask from negpy.services.capture.calibration import ( CLIP_CEILING, REFERENCE_LEVELS, @@ -165,7 +165,7 @@ class _RawClip: def at(self, shape: tuple) -> np.ndarray: if self.resized is None or self.resized[0] != shape: h, w = shape - self.resized = (shape, cv2.resize(self.mask.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST).astype(bool)) + self.resized = (shape, negpy_core.resize_nearest(self.mask.astype(np.uint8), (w, h)).astype(bool)) return self.resized[1] @@ -1493,7 +1493,7 @@ def _current_raw_clip(self, rgb: np.ndarray) -> "_RawClip | None": return clip # the metered shutter is on its way; the stepper may still lag if self._exposure_key() != clip.exposure: return None - thumb = ranked_thumb(rgb.mean(axis=2)) + thumb = ranked_thumb(gray_of(rgb)) if clip.reference is None: # The first preview at the metered exposure must frame the RAW the probe read, or # the map would land on the wrong pixels: drop it and say why. diff --git a/negpy/desktop/view/widgets/preferences_dialog.py b/negpy/desktop/view/widgets/preferences_dialog.py index 7321546cb..063c4db66 100644 --- a/negpy/desktop/view/widgets/preferences_dialog.py +++ b/negpy/desktop/view/widgets/preferences_dialog.py @@ -34,7 +34,6 @@ load_or_create, toml_pinned_keys, ) -from negpy.kernel.system.parallel import parallel_enabled, set_parallel_enabled from negpy.services.assets import semantic_model UI_SCALES: tuple[int, ...] = (80, 90, 100, 110, 120) @@ -326,14 +325,7 @@ def hint(widget_or_text, kind: str = "muted") -> None: self.session.state.gpu_enabled and self._gpu_available, tr("Render the pipeline on the GPU"), ) - self.parallel_box = labeled_toggle( - "mdi6.cpu-64-bit", - tr("Multi-core CPU Rendering"), - parallel_enabled(), - tr("Render on all CPU cores, an experimental mode that can close the app without warning."), - ) - self.parallel_box.toggled.connect(self._on_parallel_changed) - self._add_toggles(grid, row, self.gpu_box, self.parallel_box) + self._add_toggles(grid, row, self.gpu_box) row += 1 if self._gpu_available: self.gpu_box.toggled.connect(self._on_gpu_changed) @@ -511,18 +503,6 @@ def _on_semantic_search_changed(self, checked: bool) -> None: if checked != self.session.state.semantic_search_enabled: self.session.set_semantic_search_enabled(checked) - def _on_parallel_changed(self, checked: bool) -> None: - """Takes effect at once: every kernel is compiled both ways and dispatched per - call, so there is nothing to recompile and no restart to wait for.""" - set_parallel_enabled(bool(checked)) - self.repo.save_global_settings({"cpu_parallel": bool(checked), "cpu_parallel_active": bool(checked)}) - self.controller.set_status( - tr("Multi-core CPU rendering is on. Turn it off if the app closes unexpectedly.") - if checked - else tr("Multi-core CPU rendering off"), - 5000, - ) - def _on_low_vram_tiling_changed(self, checked: bool) -> None: APP_CONFIG.low_vram_export_tiling = bool(checked) self.repo.save_global_setting("low_vram_export_tiling", bool(checked)) diff --git a/negpy/desktop/view/widgets/prescan_dialog.py b/negpy/desktop/view/widgets/prescan_dialog.py index 4f993c03c..a47dc495f 100644 --- a/negpy/desktop/view/widgets/prescan_dialog.py +++ b/negpy/desktop/view/widgets/prescan_dialog.py @@ -5,6 +5,7 @@ from negpy.kernel.system.i18n import tr +import negpy_core import numpy as np from PyQt6.QtGui import QPixmap from PyQt6.QtWidgets import ( @@ -26,19 +27,10 @@ def _preview_u8(rgb: np.ndarray) -> np.ndarray: """Scale scan RGB to uint8 for the crop widget (percentile stretch).""" - a = np.asarray(rgb, dtype=np.float32) + a = np.ascontiguousarray(rgb, dtype=np.float32) if a.ndim == 2: a = a[:, :, None] - out = np.empty(a.shape[:2] + (3,), dtype=np.uint8) - channels = a.shape[2] - for c in range(3): - ch = a[..., min(c, channels - 1)] - lo, hi = np.percentile(ch, 1), np.percentile(ch, 99) - if hi <= lo: - out[..., c] = 0 - else: - out[..., c] = np.clip((ch - lo) / (hi - lo) * 255.0, 0, 255).astype(np.uint8) - return out + return negpy_core.stretch_preview(a, "prescan") class PrescanCropDialog(QDialog): diff --git a/negpy/desktop/view/widgets/scan_preview_common.py b/negpy/desktop/view/widgets/scan_preview_common.py index 207f9e7a9..3cee43714 100644 --- a/negpy/desktop/view/widgets/scan_preview_common.py +++ b/negpy/desktop/view/widgets/scan_preview_common.py @@ -1,5 +1,6 @@ """Shared display helpers for scan-preview dialogs (StripPreviewDialog, QuickScanPreviewDialog).""" +import negpy_core import numpy as np from negpy.kernel.system.i18n import mark, tr @@ -78,17 +79,7 @@ def preview_positive(rgb: np.ndarray, film_type: str = FilmType.NEGATIVE.value) the stretch. Mirrors services.assets.thumbnails.preview_positive, which decides the same thing off the frame's stored process mode. """ - a = rgb.astype(np.float32) + a = np.ascontiguousarray(rgb, dtype=np.float32) if a.ndim == 2: a = a[:, :, None] - positive = film_reads_positive(film_type) - out = np.empty_like(a) - for c in range(a.shape[2]): - ch = a[..., c] - lo, hi = np.percentile(ch, 1), np.percentile(ch, 99) - if hi <= lo: - out[..., c] = 0.0 - continue - scaled = (ch - lo) if positive else (hi - ch) - out[..., c] = np.clip(scaled / (hi - lo), 0.0, 1.0) * 255.0 - return out.astype(np.uint8) + return negpy_core.stretch_preview(a, "positive" if film_reads_positive(film_type) else "negative") diff --git a/negpy/desktop/view/widgets/sensor_calibration_dialog.py b/negpy/desktop/view/widgets/sensor_calibration_dialog.py index 3ae87cd01..3cf6e125d 100644 --- a/negpy/desktop/view/widgets/sensor_calibration_dialog.py +++ b/negpy/desktop/view/widgets/sensor_calibration_dialog.py @@ -6,6 +6,7 @@ from negpy.kernel.system.i18n import tr +import negpy_core import numpy as np from PyQt6.QtCore import QTimer, pyqtSignal from PyQt6.QtWidgets import ( @@ -165,7 +166,7 @@ def _compute_and_save(self) -> None: for band in ("R", "G", "B"): img = self._decode(self._paths[band]) measured[band] = measure_capture(img) - if float(np.mean(np.any(np.asarray(img[:, :, :3]) >= _CLIP_LEVEL, axis=2))) > _CLIP_FRACTION: + if negpy_core.any_channel_at_least(np.ascontiguousarray(img, dtype=np.float32), _CLIP_LEVEL) > _CLIP_FRACTION: clipped.append(band) matrix = build_sensor_matrix(measured["R"], measured["G"], measured["B"]) except Exception as exc: diff --git a/negpy/desktop/workers/chart_fit.py b/negpy/desktop/workers/chart_fit.py index 601e0d5ba..280fe8cb6 100644 --- a/negpy/desktop/workers/chart_fit.py +++ b/negpy/desktop/workers/chart_fit.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, replace from typing import Optional -import cv2 +import negpy_core import numpy as np from PyQt6.QtCore import QThread, pyqtSignal @@ -64,7 +64,8 @@ def __init__(self, source: ChartSource, parent=None) -> None: buf = np.asarray(source.buffer, dtype=np.float32) scale = FIT_LONG_EDGE / max(buf.shape[:2]) if scale < 1.0: - buf = cv2.resize(buf, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) + h, w = buf.shape[:2] + buf = negpy_core.resize_area(np.ascontiguousarray(buf), (max(1, round(w * scale)), max(1, round(h * scale)))) self._buffer = buf self._source = source self._processor = ImageProcessor(use_gpu=False) diff --git a/negpy/desktop/workers/export.py b/negpy/desktop/workers/export.py index 19605a303..2cbfc88d6 100644 --- a/negpy/desktop/workers/export.py +++ b/negpy/desktop/workers/export.py @@ -1,6 +1,7 @@ from negpy.kernel.system.i18n import tr from negpy.kernel.system import timing from negpy.kernel.system.logging import get_logger +from collections import deque from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass, replace from typing import List, Optional, Any, Union @@ -10,6 +11,7 @@ import tempfile import threading +import negpy_core import numpy as np from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot from negpy.domain.models import ( @@ -38,6 +40,8 @@ from negpy.services.export.contact_sheet_roll import SheetFrame, SheetLook, tile_params, turns_for from negpy.services.export.encoders import encode_jpeg from negpy.kernel.system.paths import apply_umask_mode +from negpy.kernel.system.memory import available_system_memory_bytes +from negpy.services.rendering.prefetch_policy import MIN_RAM_RESERVE_BYTES logger = get_logger(__name__) @@ -178,12 +182,14 @@ def _looks_border_crushed(buffer, task: "ExportTask") -> bool: if task.params.geometry.crop_rect is not None: return False arr = buffer[:: max(1, buffer.shape[0] // 512), :: max(1, buffer.shape[1] // 512)] - arr = arr if arr.ndim == 2 else arr[..., :3].mean(axis=2) + arr = np.ascontiguousarray(arr, dtype=np.float32) + arr = arr if arr.ndim == 2 else negpy_core.mean_channels(np.ascontiguousarray(arr[..., :3])) h, w = arr.shape[:2] m = max(2, int(0.04 * min(h, w))) - ring = np.concatenate([arr[:m].ravel(), arr[-m:].ravel(), arr[:, :m].ravel(), arr[:, -m:].ravel()]) - inner = arr[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4] - return float(np.median(ring)) < 0.02 and float(np.median(inner)) < 0.12 + if 2 * m >= min(h, w): + return False + ring, inner = negpy_core.ring_inner_medians(arr, m) + return float(ring) < 0.02 and float(inner) < 0.12 def _companion_source_paths(task: "ExportTask") -> tuple: @@ -269,6 +275,18 @@ def find_export_conflicts(tasks: List[ExportTask]) -> List[str]: return [path for task in tasks if os.path.exists(path := resolve_export_target_path(task))] +# Each encode runs on one core; more than a few at once only multiplies the held renders. +_ENCODE_WORKERS = max(1, min(3, (os.cpu_count() or 1) // 4)) + + +def encode_slots(nbytes: int, available: Optional[int]) -> int: + """Renders that may wait for or run an encode at once: each holds its buffer and about as much + again in encoded bytes, above the RAM reserve. Unknown RAM gives one.""" + if available is None: + return 1 + return max(1, min(_ENCODE_WORKERS, (available - MIN_RAM_RESERVE_BYTES) // max(1, 2 * nbytes))) + + class ExportWorker(QObject): """ Background batch export orchestrator. @@ -288,6 +306,7 @@ def __init__(self) -> None: self._processor = ImageProcessor() self._cancel = threading.Event() self._written: set[str] = set() # paths this batch wrote + self._names_lock = threading.Lock() # finishers pick output names one at a time @pyqtSlot() def cancel(self) -> None: @@ -301,14 +320,14 @@ def arm(self) -> None: @pyqtSlot(list) def run_batch(self, tasks: List[ExportTask]) -> None: - """Export the tasks pipelined: the next source prepares and the previous render encodes - while the current one renders, so at most two full-res buffers are held. A failed file + """Export the tasks pipelined: the next source prepares and earlier renders encode while + the current one renders. `encode_slots` bounds the renders held for encoding. A failed file never stops the batch, and every exit emits `finished` or `cancelled` to free the lane.""" self._written = set() total = len(tasks) - finisher = ThreadPoolExecutor(max_workers=1) + finisher = ThreadPoolExecutor(max_workers=_ENCODE_WORKERS) prefetcher = ThreadPoolExecutor(max_workers=1) - pending: Optional[Future] = None + pending: deque[Future] = deque() def _drain(fut: Future) -> None: try: @@ -385,15 +404,14 @@ def _drain(fut: Future) -> None: self.error.emit(status) continue - if pending is not None: - _drain(pending) - pending = finisher.submit(self._finish_task, task, buffer, status, embed_plan) + while pending and len(pending) >= encode_slots(buffer.nbytes, available_system_memory_bytes()): + _drain(pending.popleft()) + pending.append(finisher.submit(self._finish_task, task, buffer, status, embed_plan)) except Exception as e: self.error.emit(tr("Export failed for {full_name}: {e}", full_name=full_name, e=e)) - if pending is not None: - _drain(pending) - pending = None + while pending: + _drain(pending.popleft()) if border_crushed: self.warning.emit( f"{border_crushed} of {len(tasks)} exports rendered almost black with no crop set: " @@ -465,10 +483,11 @@ def _encode_and_write(self, task: ExportTask, buffer: np.ndarray, color_space: s # Overwrite replaces files from before the batch, never another frame of it. counter = 2 - while path in self._written or (not task.export_settings.overwrite and os.path.exists(path)): - path = os.path.join(out_dir, f"{filename}_{counter}.{ext}") - counter += 1 - self._written.add(path) + with self._names_lock: + while path in self._written or (not task.export_settings.overwrite and os.path.exists(path)): + path = os.path.join(out_dir, f"{filename}_{counter}.{ext}") + counter += 1 + self._written.add(path) if _export_target_is_a_source(path, task): return f"Export skipped for {task.file_info['name']}: it would overwrite the source file. Change the filename pattern or destination." diff --git a/negpy/desktop/workers/render.py b/negpy/desktop/workers/render.py index 5b04b0551..bdac321a5 100644 --- a/negpy/desktop/workers/render.py +++ b/negpy/desktop/workers/render.py @@ -1,10 +1,13 @@ +import queue import threading import time -from concurrent.futures import ThreadPoolExecutor, as_completed +from collections import deque +from concurrent.futures import Future, ThreadPoolExecutor, as_completed from contextlib import contextmanager from dataclasses import dataclass, replace from typing import Any, Callable, Iterator, Optional +import negpy_core import numpy as np from PyQt6.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot @@ -30,6 +33,7 @@ from negpy.features.process.sensor import apply_sensor_correction, effective_sensor_matrix from negpy.features.process.logic import effective_highlight_reconstruction, effective_linear_raw, highlight_reconstruction_bakes_wb from negpy.features.process.models import DemosaicMode +from negpy.infrastructure.loaders.factory import loader_factory from negpy.infrastructure.loaders.helpers import unsupported_raw_reason from negpy.features.rgbscan.models import RgbScanConfig, is_rgb_triplet from negpy.features.stitch.models import StitchConfig, stitch_active @@ -40,8 +44,10 @@ from negpy.infrastructure.gpu.resources import GPUTexture from negpy.kernel.system.config import APP_CONFIG from negpy.kernel.system.logging import get_logger +from negpy.kernel.system.memory import available_system_memory_bytes from negpy.kernel.system import timing from negpy.services.rendering.image_processor import ImageProcessor, preview_takes_unmix, read_source_base +from negpy.services.rendering.prefetch_policy import MIN_RAM_RESERVE_BYTES logger = get_logger(__name__) @@ -1841,6 +1847,22 @@ def _name(index: int) -> str: self.error.emit(str(exc)) +_THUMBNAIL_WORKERS = 2 + + +def _room_for_another_frame(frame: ThumbnailRenderInput) -> bool: + """True when RAM holds this frame's preview decode beside the one already in flight. A composite + reads several files, and an unknown RAM size gives no margin, so both wait their turn.""" + config = frame.config + if stitch_active(config.stitch) or hdr_active(config.hdr) or is_rgb_triplet(config.rgbscan): + return False + available = available_system_memory_bytes() + if available is None: + return False + estimate = loader_factory.estimate_linear_preview_prefetch_memory(frame.file_info["path"], APP_CONFIG.preview_render_size) + return available >= MIN_RAM_RESERVE_BYTES + estimate.temporary_bytes + estimate.cached_bytes + + class ThumbnailRenderWorker(QObject): """Re-render frames a bulk settings write touched but did not open, off the live render path. Its own CPU-only ImageProcessor never contends with the live render's GPU texture @@ -1859,7 +1881,11 @@ def __init__(self, preview_service, live_preview_service=None) -> None: super().__init__() self._preview_service = preview_service self._live_preview_service = live_preview_service - self._processor = ImageProcessor(use_gpu=False) + # One processor per pool thread: its caches hold one frame each. + self._all_processors = [ImageProcessor(use_gpu=False) for _ in range(_THUMBNAIL_WORKERS)] + self._processors: queue.SimpleQueue[ImageProcessor] = queue.SimpleQueue() + for processor in self._all_processors: + self._processors.put(processor) self._cancel_lock = threading.RLock() self._cancelled_generations: set[int] = set() self._active_generation: int | None = None @@ -1939,8 +1965,8 @@ def _emit_finished_unless_cancelled(self, generation: int, rendered_count: int) @pyqtSlot(ThumbnailRenderTask) def process(self, task: ThumbnailRenderTask) -> None: - """Render each frame sequentially: the CPU engine is already multi-core via numba, - and one frame at a time keeps the live UI responsive while the user keeps editing.""" + """Render up to `_THUMBNAIL_WORKERS` frames at once on negpy_core's background pool, so the + live preview keeps the other cores. Signals leave this thread in frame order.""" generation = int(task.generation) with self._cancel_lock: self._active_generation = generation @@ -1948,56 +1974,37 @@ def process(self, task: ThumbnailRenderTask) -> None: return total = len(task.frames) rendered_count = 0 + pending: deque[tuple[int, str, ThumbnailRenderInput, Future]] = deque() try: - for done, frame in enumerate(task.frames, 1): - if self._cancel_requested(generation): - break - name = str(frame.file_info.get("name") or frame.file_info.get("path") or done) - self.frame_started.emit(done, total, name) - decode_s = render_s = 0.0 - decoding = True - started = time.perf_counter() - try: - buffer, meta = self._decode(frame, task.workspace_color_space) - decode_s = time.perf_counter() - started - decoding = False - cam_xyz = meta.get("cam_xyz") - if frame.icc_input_active: - cam_xyz = wb_only_cam_xyz(cam_xyz) - if not self._cancel_requested(generation): - started = time.perf_counter() - result, _metrics = self._processor.run_pipeline( - buffer, - frame.config, - frame.file_info["hash"], - render_size_ref=float(APP_CONFIG.preview_render_size), - prefer_gpu=False, - readback_metrics=False, - wants_uv_grid=False, - cache_stages=False, - ir_buffer=meta.get("ir_preview"), - detect_buffer=meta.get("detect_preview"), - cam_xyz=cam_xyz, - camera_wb=meta.get("camera_wb"), - gain_slices=gain_slices_for_asset(frame.file_info), - source_orientation=meta.get("orientation", 1), - ) - render_s = time.perf_counter() - started - if isinstance(result, np.ndarray) and not self._cancel_requested(generation): - self.rendered.emit(frame, np.ascontiguousarray(result[:, :, :3])) - rendered_count += 1 - except Exception: - if self._cancel_requested(generation): + with ThreadPoolExecutor(_THUMBNAIL_WORKERS, initializer=negpy_core.set_background_thread, initargs=(True,)) as pool: + frames = iter(enumerate(task.frames, 1)) + nxt = next(frames, None) + while nxt is not None or pending: + if ( + nxt is not None + and not self._cancel_requested(generation) + and (not pending or (len(pending) < _THUMBNAIL_WORKERS and _room_for_another_frame(nxt[1]))) + ): + done, frame = nxt + name = str(frame.file_info.get("name") or frame.file_info.get("path") or done) + self.frame_started.emit(done, total, name) + future = pool.submit(self._render_one, frame, task.workspace_color_space, generation) + pending.append((done, name, frame, future)) + nxt = next(frames, None) + continue + if not pending: break - # A failed read costs time too; the time-left estimate counts it. - if decoding: - decode_s = time.perf_counter() - started - else: - render_s = time.perf_counter() - started - logger.exception("Background thumbnail refresh skipped failed frame %s", name) - self.progress.emit(done, total, name, decode_s, render_s) - - self._processor.cleanup(release_source_cache=True, collect=False) + done, name, frame, future = pending.popleft() + result, decode_s, render_s = future.result() + if self._cancel_requested(generation): + continue + if result is not None: + self.rendered.emit(frame, result) + rendered_count += 1 + self.progress.emit(done, total, name, decode_s, render_s) + + for processor in self._all_processors: + processor.cleanup(release_source_cache=True, collect=False) self._emit_finished_unless_cancelled(generation, rendered_count) except Exception as exc: if self._emit_cancelled_if_requested(generation): @@ -2008,6 +2015,52 @@ def process(self, task: ThumbnailRenderTask) -> None: logger.exception("Background thumbnail refresh worker failure") self.error.emit(str(exc)) + def _render_one(self, frame: ThumbnailRenderInput, color_space: str, generation: int) -> tuple[Optional[np.ndarray], float, float]: + """Decode and render one frame on a pool thread: the RGB result (None when skipped or + failed), decode seconds and render seconds. A failed frame counts its time too.""" + decode_s = render_s = 0.0 + decoding = True + started = time.perf_counter() + processor = self._processors.get() + try: + buffer, meta = self._decode(frame, color_space) + decode_s = time.perf_counter() - started + decoding = False + cam_xyz = meta.get("cam_xyz") + if frame.icc_input_active: + cam_xyz = wb_only_cam_xyz(cam_xyz) + if self._cancel_requested(generation): + return None, decode_s, render_s + started = time.perf_counter() + result, _metrics = processor.run_pipeline( + buffer, + frame.config, + frame.file_info["hash"], + render_size_ref=float(APP_CONFIG.preview_render_size), + prefer_gpu=False, + readback_metrics=False, + wants_uv_grid=False, + cache_stages=False, + ir_buffer=meta.get("ir_preview"), + detect_buffer=meta.get("detect_preview"), + cam_xyz=cam_xyz, + camera_wb=meta.get("camera_wb"), + gain_slices=gain_slices_for_asset(frame.file_info), + source_orientation=meta.get("orientation", 1), + ) + render_s = time.perf_counter() - started + return (np.ascontiguousarray(result[:, :, :3]) if isinstance(result, np.ndarray) else None), decode_s, render_s + except Exception: + if decoding: + decode_s = time.perf_counter() - started + else: + render_s = time.perf_counter() - started + if not self._cancel_requested(generation): + logger.exception("Background thumbnail refresh skipped failed frame %s", frame.file_info.get("name")) + return None, decode_s, render_s + finally: + self._processors.put(processor) + class NormalizationWorker(QObject): """ diff --git a/negpy/domain/types.py b/negpy/domain/types.py index afa8d7d12..9cbb0b4f4 100644 --- a/negpy/domain/types.py +++ b/negpy/domain/types.py @@ -43,9 +43,6 @@ class AppConfig: override_toml_path: str = "" max_texture_size: int | None = None force_hq_preview: bool | None = None - # Multi-core Numba kernels on the CPU pipeline. None = the platform default, on - # everywhere except macOS. True/False = an explicit override.toml choice. - cpu_parallel: bool | None = None # Smaller tiles and no readback pipelining in the tiled export path, at the cost of # export speed. Off by default -- a user opts in from Preferences or override.toml # when their specific GPU (typically an older/weaker integrated one) needs it. diff --git a/negpy/features/cyanotype/logic.py b/negpy/features/cyanotype/logic.py index fdaa56107..7e8a0759c 100644 --- a/negpy/features/cyanotype/logic.py +++ b/negpy/features/cyanotype/logic.py @@ -1,11 +1,11 @@ -from typing import Any, Dict, Sequence, Tuple +from typing import Any, Dict +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer from negpy.features.altprocess.models import Sensitizer -from negpy.kernel.image.logic import _LAB_EPS, _LAB_KAPPA, lab_to_rgb_working -from negpy.kernel.image.validation import ensure_image +from negpy.kernel.image.logic import WORKING_LAB CYANOTYPE_CONSTANTS: Dict[str, Any] = { # Transfer. @@ -52,14 +52,6 @@ def sensitizer_constants(sensitizer: str) -> Dict[str, Any]: return SENSITIZERS.get(Sensitizer(sensitizer), SENSITIZERS[Sensitizer.CLASSIC]) -def _hue_path(u: np.ndarray, path: Sequence[Sequence[float]]) -> Tuple[np.ndarray, np.ndarray]: - """(a*, b*) along the sensitizer's density hue path, clamped outside the anchors.""" - xp = np.asarray(CYANOTYPE_CONSTANTS["path_u"], dtype=np.float32) - a = np.interp(u, xp, np.asarray([p[0] for p in path], dtype=np.float32)).astype(np.float32) - b = np.interp(u, xp, np.asarray([p[1] for p in path], dtype=np.float32)).astype(np.float32) - return a, b - - def apply_cyanotype( img: ImageBuffer, sensitizer: str = Sensitizer.CLASSIC, @@ -94,33 +86,19 @@ def apply_cyanotype( c = CYANOTYPE_CONSTANTS sc = sensitizer_constants(sensitizer) - d_max = np.float32(sc["d_max"]) - - arr = np.clip(img.astype(np.float32), 1e-6, 1.0) - luma = arr[:, :, 0] * np.float32(0.2126) + arr[:, :, 1] * np.float32(0.7152) + arr[:, :, 2] * np.float32(0.0722) - - base = -np.log10(np.clip(luma, 1e-6, 1.0)) - t = np.clip((base + np.float32(0.301 * exposure)) / np.float32(max(scale, 0.1)), 0.0, 1.0).astype(np.float32) - - m = np.float32(c["mid_compress"]) - v = np.float32(2.0) * t - np.float32(1.0) - u0 = (np.float32(1.0) - m) * t + m * np.float32(0.5) * (np.float32(1.0) + v * np.abs(v)) - - b_amt = np.float32(bleach) - u_b = u0 * (np.float32(1.0) - b_amt * (np.float32(1.0) - np.float32(c["bleach_floor"]) * u0)) - - t_amt = np.float32(tannin) - u = u_b + t_amt * (u0 * np.float32(c["tannin_restore"]) - u_b) - dens = (d_max * (np.float32(1.0) + t_amt * np.float32(c["tannin_dmax_gain"])) * u).astype(np.float32) - - frac = np.clip(u, 0.0, 1.0).astype(np.float32) - a_blue, b_blue = _hue_path(frac, sc["path"]) - brown = c["brown_dir"] - a_star = a_blue + t_amt * (np.float32(brown[0]) * frac - a_blue) - b_star = b_blue + t_amt * (np.float32(brown[1]) * frac - b_blue) - - grey = np.power(np.float32(10.0), -dens) - f = np.where(grey > np.float32(_LAB_EPS), np.cbrt(grey), np.float32(_LAB_KAPPA) * grey + np.float32(16.0 / 116.0)) - lab = np.dstack([np.float32(116.0) * f - np.float32(16.0), a_star, b_star]) - - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + f32 = np.float32 + scalars = [ + f32(sc["d_max"]), + f32(0.301 * exposure), + f32(max(scale, 0.1)), + f32(c["mid_compress"]), + f32(bleach), + f32(c["bleach_floor"]), + f32(tannin), + f32(c["tannin_restore"]), + f32(c["tannin_dmax_gain"]), + ] + brown = (f32(c["brown_dir"][0]), f32(c["brown_dir"][1])) + path_u = [f32(v) for v in c["path_u"]] + anchors = [(f32(a), f32(b)) for a, b in sc["path"]] + return negpy_core.cyanotype(np.ascontiguousarray(img, dtype=np.float32), scalars, brown, path_u, anchors, WORKING_LAB) diff --git a/negpy/features/exposure/analysis.py b/negpy/features/exposure/analysis.py index fc1aa93f5..a1e6084a7 100644 --- a/negpy/features/exposure/analysis.py +++ b/negpy/features/exposure/analysis.py @@ -3,10 +3,20 @@ from functools import lru_cache from typing import Any, List, Optional, Sequence, Tuple -import cv2 +import negpy_core import numpy as np -from negpy.kernel.image.logic import get_luminance, rgb_to_lab_working, working_oetf_decode, working_oetf_encode +from negpy.kernel.image.logic import ( + _LAB_EPS, + _LAB_KAPPA, + _WORKING_GAMMA, + _WORKING_TO_XYZ, + _WORKING_WHITE, + get_luminance, + rgb_to_lab_working, + working_oetf_decode, + working_oetf_encode, +) # Mirrored by the chart's x-range (PhotometricCurveWidget._X_MIN/_X_MAX) and the WGSL # literals in shaders/density_hist.wgsl. Keep them in lock step. @@ -44,32 +54,24 @@ _MAX_HIST_SAMPLES = 2_000_000 +def _frame_step(shape: Tuple[int, ...], max_samples: int) -> int: + """The stride that keeps a whole frame near `max_samples` samples.""" + return max(1, round(np.sqrt(shape[0] * shape[1] / max_samples))) + + def output_histogram(buffer: Any) -> Optional[np.ndarray]: - """(4, 256) [R, G, B, L] counts from a (4, 256) bin array or an H×W×3 encoded buffer.""" + """(4, 256) [R, G, B, L] counts from a (4, 256) bin array or an H×W×3 encoded buffer. An + 8-bit frame (the camera live view) bins by value: the same bins as the float path's + floor(v / 255 * 256).""" if buffer is None: return None buffer = np.asarray(buffer) if buffer.ndim == 2 and buffer.shape == (4, OUTPUT_HIST_BINS): return buffer.astype(float) - if buffer.ndim != 3 or buffer.shape[-1] != 3: + if buffer.ndim != 3 or buffer.shape[-1] != 3 or min(buffer.shape[:2]) < 1: return None - step = max(1, round(np.sqrt(buffer.shape[0] * buffer.shape[1] / _MAX_HIST_SAMPLES))) - if step > 1: - buffer = buffer[::step, ::step] - if buffer.dtype == np.uint8: - # An 8-bit frame (the camera live view) bins by value: the same bins as the float - # path's floor(v / 255 * 256). - rows = [np.bincount(buffer[..., c].ravel(), minlength=OUTPUT_HIST_BINS) for c in range(3)] - lum = get_luminance(np.ascontiguousarray(buffer.astype(np.float32) / 255.0)) - rows.append( - np.bincount(np.minimum((lum * OUTPUT_HIST_BINS).astype(np.int64), OUTPUT_HIST_BINS - 1).ravel(), minlength=OUTPUT_HIST_BINS) - ) - return np.stack(rows).astype(float) - buffer = np.ascontiguousarray(buffer.astype(np.float32, copy=False)) - lum = get_luminance(buffer) - rows = [np.histogram(buffer[..., c], bins=OUTPUT_HIST_BINS, range=(0, 1))[0] for c in range(3)] - rows.append(np.histogram(lum, bins=OUTPUT_HIST_BINS, range=(0, 1))[0]) - return np.stack(rows).astype(float) + dtype = np.uint8 if buffer.dtype == np.uint8 else np.float32 + return negpy_core.output_histogram(np.ascontiguousarray(buffer, dtype=dtype), _frame_step(buffer.shape, _MAX_HIST_SAMPLES)) def color_histogram(buffer: Any) -> Optional[np.ndarray]: @@ -80,15 +82,9 @@ def color_histogram(buffer: Any) -> Optional[np.ndarray]: buffer = np.asarray(buffer) if buffer.ndim == 3 and buffer.shape == (COLOR_HIST_BINS,) * 3: return buffer.astype(float) - if buffer.ndim != 3 or buffer.shape[-1] != 3: + if buffer.ndim != 3 or buffer.shape[-1] != 3 or min(buffer.shape[:2]) < 1: return None - step = max(1, round(np.sqrt(buffer.shape[0] * buffer.shape[1] / _MAX_HIST_SAMPLES))) - if step > 1: - buffer = buffer[::step, ::step] - q = np.clip((np.asarray(buffer, dtype=np.float32) * COLOR_HIST_BINS).astype(np.int32), 0, COLOR_HIST_BINS - 1) - flat = (q[..., 0] * COLOR_HIST_BINS + q[..., 1]) * COLOR_HIST_BINS + q[..., 2] - counts = np.bincount(flat.ravel(), minlength=COLOR_HIST_BINS**3) - return counts.reshape((COLOR_HIST_BINS,) * 3).astype(float) + return negpy_core.color_histogram(np.ascontiguousarray(buffer, dtype=np.float32), _frame_step(buffer.shape, _MAX_HIST_SAMPLES)) def gamut_fraction(color_hist: Any, out_of_gamut: Any) -> Optional[float]: @@ -108,56 +104,32 @@ def gamut_fraction(color_hist: Any, out_of_gamut: Any) -> Optional[float]: return float(hist[mask].sum() / total) if total > 0 else None -def _waveform_cells(buffer: Any) -> Optional[Tuple[np.ndarray, np.ndarray]]: - """(sampled H×W×3 float32 buffer, flat bin * WAVEFORM_COLS + col index per pixel).""" +def _waveform(buffer: Any, kind: str) -> Optional[np.ndarray]: + """The waveform counts or sums of an H×W×3 encoded buffer, binned by float32 luma or channel.""" if buffer is None: return None buffer = np.asarray(buffer) if buffer.ndim != 3 or buffer.shape[-1] < 3 or min(buffer.shape[:2]) < 1: return None - step = max(1, round(np.sqrt(buffer.shape[0] * buffer.shape[1] / _MAX_HIST_SAMPLES))) - if step > 1: - buffer = buffer[::step, ::step] - rgb = np.ascontiguousarray(buffer[..., :3], dtype=np.float32) - lum = get_luminance(rgb) - w = lum.shape[1] - cols = np.minimum(np.arange(w) * WAVEFORM_COLS // w, WAVEFORM_COLS - 1) - bins = np.clip((np.nan_to_num(lum) * WAVEFORM_BINS).astype(np.int32), 0, WAVEFORM_BINS - 1) - return rgb, (bins * WAVEFORM_COLS + cols[None, :]).ravel() + return negpy_core.waveform(np.ascontiguousarray(buffer, dtype=np.float32), _frame_step(buffer.shape, _MAX_HIST_SAMPLES), kind) def waveform(buffer: Any) -> Optional[np.ndarray]: """(WAVEFORM_BINS, WAVEFORM_COLS) luma counts per image column of an H×W×3 encoded buffer. CPU counterpart of waveform.wgsl.""" - cells = _waveform_cells(buffer) - if cells is None: - return None - return np.bincount(cells[1], minlength=WAVEFORM_BINS * WAVEFORM_COLS).reshape(WAVEFORM_BINS, WAVEFORM_COLS).astype(float) + return _waveform(buffer, "luma") def waveform_color(buffer: Any) -> Optional[np.ndarray]: """(3, WAVEFORM_BINS, WAVEFORM_COLS) fixed-point sums of the encoded R, G, B in each waveform cell. CPU counterpart of the sums in waveform.wgsl.""" - cells = _waveform_cells(buffer) - if cells is None: - return None - rgb, flat = cells - fixed = np.round(np.clip(np.nan_to_num(rgb.reshape(-1, 3)), 0.0, 1.0) * WAVEFORM_COLOR_SCALE) - sums = [np.bincount(flat, weights=fixed[:, c], minlength=WAVEFORM_BINS * WAVEFORM_COLS) for c in range(3)] - return np.stack(sums).reshape(3, WAVEFORM_BINS, WAVEFORM_COLS) + return _waveform(buffer, "color") def waveform_parade(buffer: Any) -> Optional[np.ndarray]: """(3, WAVEFORM_BINS, WAVEFORM_COLS) counts of each encoded channel per image column: the RGB parade. CPU counterpart of the parade counts in waveform.wgsl.""" - cells = _waveform_cells(buffer) - if cells is None: - return None - rgb, flat = cells - cols = flat % WAVEFORM_COLS - bins = np.clip((np.nan_to_num(rgb.reshape(-1, 3)) * WAVEFORM_BINS).astype(np.int32), 0, WAVEFORM_BINS - 1) - counts = [np.bincount(bins[:, c] * WAVEFORM_COLS + cols, minlength=WAVEFORM_BINS * WAVEFORM_COLS) for c in range(3)] - return np.stack(counts).reshape(3, WAVEFORM_BINS, WAVEFORM_COLS).astype(float) + return _waveform(buffer, "parade") def scope_radius(chroma: Any, log: bool) -> Any: @@ -176,13 +148,6 @@ def scope_chroma(radius: Any, log: bool) -> Any: return r * SCOPE_MAX_CHROMA -def _scope_bins(a: np.ndarray, b: np.ndarray) -> np.ndarray: - hue = np.floor((np.arctan2(b, a) + np.pi) / (2 * np.pi) * SCOPE_HUE_BINS).astype(np.int64) % SCOPE_HUE_BINS - radius = scope_radius(np.hypot(a, b), log=True) - chroma = np.clip(np.floor(radius * SCOPE_CHROMA_BINS), 0, SCOPE_CHROMA_BINS - 1).astype(np.int64) - return hue * SCOPE_CHROMA_BINS + chroma - - def vectorscope_histogram(buffer: Any) -> Optional[np.ndarray]: """(SCOPE_HUE_BINS, SCOPE_CHROMA_BINS) a*b* counts of an H×W×3 encoded buffer. CPU counterpart of vectorscope.wgsl.""" @@ -192,13 +157,15 @@ def vectorscope_histogram(buffer: Any) -> Optional[np.ndarray]: if buffer.ndim != 3 or buffer.shape[-1] < 3 or min(buffer.shape[:2]) < 1: return None step = max(1, round(np.sqrt(buffer.shape[0] * buffer.shape[1] / _SCOPE_MAX_SAMPLES))) - if step > 1: - buffer = buffer[::step, ::step] - encoded = np.clip(np.nan_to_num(np.asarray(buffer[..., :3], dtype=np.float32)), 0.0, 1.0) - lab = rgb_to_lab_working(np.asarray(working_oetf_decode(encoded))) - flat = _scope_bins(lab[..., 1].ravel().astype(np.float64), lab[..., 2].ravel().astype(np.float64)) - counts = np.bincount(flat, minlength=SCOPE_HUE_BINS * SCOPE_CHROMA_BINS) - return counts.reshape(SCOPE_HUE_BINS, SCOPE_CHROMA_BINS).astype(float) + return negpy_core.vectorscope_histogram( + np.ascontiguousarray(buffer, dtype=np.float32), + step, + _WORKING_TO_XYZ, + _WORKING_WHITE, + np.float32(_LAB_EPS), + np.float32(_LAB_KAPPA), + np.float32(_WORKING_GAMMA), + ) def scope_point(rgb: Sequence[float]) -> Tuple[float, float, float]: @@ -213,35 +180,14 @@ def scope_point(rgb: Sequence[float]) -> Tuple[float, float, float]: def _scope_lookup(size: int, log: bool) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Polar bin of each display pixel (-1 outside the disc), each bin's pixel count, and for the bins no pixel samples (near neutral, finer than a pixel) the flat pixel they fold into.""" - u = (np.arange(size) + 0.5) / size * 2.0 - 1.0 - x, y = np.meshgrid(u, u[::-1]) - radius = np.hypot(x, y) - chroma = scope_chroma(np.minimum(radius, 1.0), log) - hue = np.arctan2(y, x) - idx = _scope_bins(chroma * np.cos(hue), chroma * np.sin(hue)) - idx[radius > 1.0] = -1 - area = np.bincount(idx[idx >= 0], minlength=SCOPE_HUE_BINS * SCOPE_CHROMA_BINS).astype(float) - - orphans = np.flatnonzero(area == 0) - centre_hue = ((orphans // SCOPE_CHROMA_BINS) + 0.5) / SCOPE_HUE_BINS * 2 * np.pi - np.pi - centre_c = scope_chroma(((orphans % SCOPE_CHROMA_BINS) + 0.5) / SCOPE_CHROMA_BINS, log=True) - r = np.minimum(scope_radius(centre_c, log), 1.0) - col = np.clip(((r * np.cos(centre_hue) + 1.0) / 2.0 * size).astype(np.int64), 0, size - 1) - row = np.clip(((1.0 - r * np.sin(centre_hue)) / 2.0 * size).astype(np.int64), 0, size - 1) - return idx, area, orphans, row * size + col + return negpy_core.scope_lookup(int(size), bool(log)) def vectorscope_density(counts: Any, size: int, log: bool) -> np.ndarray: """(size, size) pixels per display pixel, +a right and +b up. Each polar bin's count is spread over the display pixels it covers, so a bin's on-screen area does not change its brightness.""" - idx, area, orphans, orphan_px = _scope_lookup(size, log) - flat = np.asarray(counts, dtype=float).ravel() - out = np.zeros(idx.shape) - inside = idx >= 0 - picked = idx[inside] - out[inside] = flat[picked] / np.maximum(area[picked], 1.0) - np.add.at(out.reshape(-1), orphan_px, flat[orphans]) - return out + flat = np.ascontiguousarray(np.asarray(counts, dtype=float).ravel()) + return negpy_core.vectorscope_density(flat, *_scope_lookup(size, log)) def output_clip_fractions(bins: np.ndarray) -> Tuple[float, float]: @@ -283,15 +229,7 @@ def encoded_of_zone(zone: float) -> float: def zone_occupancy(l_bins: np.ndarray) -> np.ndarray: """Fold display-encoded luma histogram bins into the share of each zone 0..X, rounded to the nearest zone as zone_field rounds, so the strip and the Zone Overlay count alike.""" - l_bins = np.asarray(l_bins, dtype=float) - out = np.zeros(ZONE_COUNT + 1) - total = float(l_bins.sum()) - if total <= 0.0: - return out - centers = (np.arange(l_bins.size) + 0.5) / l_bins.size - cells = np.clip(np.rint(np.asarray(zone_of_encoded(centers))), 0, ZONE_COUNT).astype(int) - np.add.at(out, cells, l_bins) - return out / total + return negpy_core.zone_occupancy(np.ascontiguousarray(l_bins, dtype=np.float64).ravel(), _mid_gray_encoded()) def zone_warnings(occ: np.ndarray) -> Tuple[bool, bool]: @@ -336,15 +274,7 @@ def zone_grid(rgb: Any, cells: int = ZONE_GRID_CELLS) -> Optional[np.ndarray]: rgb = np.asarray(rgb) if rgb.ndim != 3 or rgb.shape[-1] < 3 or min(rgb.shape[:2]) < 2: return None - h, w = rgb.shape[:2] - # Slice first: INTER_AREA straight off a full-res HQ buffer costs ~50 ms. - step = max(1, min(h, w) // (4 * cells)) - small = np.ascontiguousarray(rgb[::step, ::step, :3], dtype=np.float32) - scale = cells / max(small.shape[:2]) - sh, sw = small.shape[:2] - small = cv2.resize(small, (max(1, round(sw * scale)), max(1, round(sh * scale))), interpolation=cv2.INTER_AREA) - z = np.nan_to_num(zone_of_encoded(get_luminance(small))) - return np.rint(z).astype(np.int32) + return negpy_core.zone_grid(np.ascontiguousarray(rgb, dtype=np.float32), int(cells), _mid_gray_encoded()) def zone_region_labels(zones: np.ndarray) -> List[Tuple[int, int, int]]: @@ -353,15 +283,7 @@ def zone_region_labels(zones: np.ndarray) -> List[Tuple[int, int, int]]: Anchors are snapped to a cell the region actually owns, so a concave region's numeral can't land outside it (its centroid can). """ - out: List[Tuple[int, int, int]] = [] - for zone in np.unique(zones): - count, comp, _, centroids = cv2.connectedComponentsWithStats((zones == zone).astype(np.uint8), connectivity=4) - for i in range(1, count): # 0 is the background (everything not this zone) - ys, xs = np.nonzero(comp == i) - cx, cy = centroids[i] - j = int(np.argmin((xs - cx) ** 2 + (ys - cy) ** 2)) - out.append((int(xs[j]), int(ys[j]), int(zone))) - return out + return negpy_core.zone_region_labels(np.ascontiguousarray(zones, dtype=np.int32)) ZONE_FIELD_MAX_EDGE = 768 # drawn scaled to the view; finer only sharpens grain @@ -374,41 +296,19 @@ def zone_field(rgb: Any, max_edge: int = ZONE_FIELD_MAX_EDGE) -> Optional[np.nda rgb = np.asarray(rgb) if rgb.ndim != 3 or rgb.shape[-1] < 3 or min(rgb.shape[:2]) < 2: return None - h, w = rgb.shape[:2] - # Slice first: INTER_AREA straight off a full-res HQ buffer is slow. - step = max(1, max(h, w) // (2 * max_edge)) - small = np.ascontiguousarray(rgb[::step, ::step, :3], dtype=np.float32) - scale = min(1.0, max_edge / max(small.shape[:2])) - sh, sw = small.shape[:2] - lum = get_luminance(cv2.resize(small, (max(1, round(sw * scale)), max(1, round(sh * scale))), interpolation=cv2.INTER_AREA)) - lum = cv2.GaussianBlur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), 2.0) - zones = np.clip(np.rint(np.nan_to_num(zone_of_encoded(lum))), 0, ZONE_COUNT).astype(np.uint8) - return cv2.medianBlur(zones, 5) + return negpy_core.zone_field(np.ascontiguousarray(rgb, dtype=np.float32), int(max_edge), _mid_gray_encoded()) def zone_shares(zones: np.ndarray) -> np.ndarray: """Fraction of the frame in each zone 0..10.""" - return np.bincount(zones.ravel(), minlength=ZONE_COUNT + 1)[: ZONE_COUNT + 1] / max(1, zones.size) + return negpy_core.zone_shares(np.ascontiguousarray(zones, dtype=np.uint8)) def zone_anchors(zones: np.ndarray, min_share: float = 0.01) -> List[Tuple[int, int, int, float]]: """(x, y, zone, inset) per region holding at least `min_share` of the frame, at its most interior pixel; `inset` is that pixel's distance to the region's edge. The frame edge counts as an edge, so a numeral never sits half off the picture.""" - out: List[Tuple[int, int, int, float]] = [] - min_area = min_share * zones.size - for zone in np.unique(zones): - count, comp, stats, _ = cv2.connectedComponentsWithStats((zones == zone).astype(np.uint8), connectivity=4) - for i in range(1, count): # 0 is the background (everything not this zone) - if stats[i, cv2.CC_STAT_AREA] < min_area: - continue - mask = (comp == i).astype(np.uint8) - mask[0, :] = mask[-1, :] = 0 - mask[:, 0] = mask[:, -1] = 0 - dist = cv2.distanceTransform(mask, cv2.DIST_L2, 3) - y, x = np.unravel_index(int(np.argmax(dist)), dist.shape) - out.append((int(x), int(y), int(zone), float(dist[y, x]))) - return out + return negpy_core.zone_anchors(np.ascontiguousarray(zones, dtype=np.uint8), float(min_share)) # Absolute ladders, centred on the defaults and inside the sliders' travel (density 0-2, @@ -536,7 +436,7 @@ def paper_grid_mosaic(tiles: Sequence[np.ndarray]) -> np.ndarray: h, w = out.shape[:2] for tile, (x0, y0, x1, y1) in zip(tiles, paper_grid_cells(len(tiles))): top, bottom, left, right = round(y0 * h), round(y1 * h), round(x0 * w), round(x1 * w) - out[top:bottom, left:right] = cv2.resize(tile, (right - left, bottom - top), interpolation=cv2.INTER_AREA) + out[top:bottom, left:right] = negpy_core.resize_area(np.ascontiguousarray(tile, dtype=np.float32), (right - left, bottom - top)) return out @@ -635,26 +535,16 @@ def loupe_acutance(patch: Any) -> float: patch = np.asarray(patch) if patch.ndim != 3 or patch.shape[-1] < 3 or min(patch.shape[:2]) < 3: return 0.0 - lum = get_luminance(np.ascontiguousarray(patch[..., :3], dtype=np.float32)) - return float(np.sqrt(np.mean(cv2.Laplacian(lum, cv2.CV_32F) ** 2)) * 100.0) + return negpy_core.loupe_acutance(np.ascontiguousarray(patch, dtype=np.float32)) def density_histogram(normalized_log: np.ndarray, roi: Optional[Tuple[int, int, int, int]] = None) -> np.ndarray: """(4, DENSITY_HIST_BINS) [R, G, B, L] val-domain occupancy; out-of-range mass lands in the edge bins. `roi` is the crop rect (y1, y2, x1, x2) in the same frame as `normalized_log`.""" - img = normalized_log - if roi is not None: - y1, y2, x1, x2 = roi - img = img[y1:y2, x1:x2] - step = max(1, round(np.sqrt(img.shape[0] * img.shape[1] / _MAX_HIST_SAMPLES))) - if step > 1: - img = img[::step, ::step] - img = np.ascontiguousarray(img) - lum = get_luminance(img) - lo, hi = DENSITY_HIST_RANGE - rows = [np.histogram(np.clip(img[..., c], lo, hi), bins=DENSITY_HIST_BINS, range=DENSITY_HIST_RANGE)[0] for c in range(3)] - rows.append(np.histogram(np.clip(lum, lo, hi), bins=DENSITY_HIST_BINS, range=DENSITY_HIST_RANGE)[0]) - return np.stack(rows).astype(np.float64) + crop = _sample_crop(normalized_log.shape, roi) + if crop is None: + return np.zeros((4, DENSITY_HIST_BINS)) + return negpy_core.density_histogram(np.ascontiguousarray(normalized_log, dtype=np.float32), *crop) def scan_density_axis(density: Any) -> Any: @@ -670,17 +560,16 @@ def scan_density_of_val(val: Any, bounds: Any) -> np.ndarray: return -(floors + np.asarray(val, dtype=np.float64)[:3] * (ceils - floors)) -def _parade_samples(img: np.ndarray, roi: Optional[Tuple[int, int, int, int]]) -> Optional[Tuple[np.ndarray, np.ndarray]]: - """The crop's RGB, stride-subsampled, and each sample column's parade column.""" - if roi is not None: - y1, y2, x1, x2 = roi - img = img[y1:y2, x1:x2] - if min(img.shape[:2]) < 1: +def _sample_crop(shape: Tuple[int, ...], roi: Optional[Tuple[int, int, int, int]]) -> Optional[Tuple[Tuple[int, int, int, int], int]]: + """The crop (y1, y2, x1, x2) as numpy slicing clamps it, and the stride that keeps it near + _MAX_HIST_SAMPLES samples; None for an empty crop.""" + h, w = shape[:2] + y1, y2, x1, x2 = roi if roi is not None else (0, h, 0, w) + y1, y2, _ = slice(y1, y2).indices(h) + x1, x2, _ = slice(x1, x2).indices(w) + if y2 <= y1 or x2 <= x1: return None - full_w = img.shape[1] - step = max(1, round(np.sqrt(img.shape[0] * full_w / _MAX_HIST_SAMPLES))) - cols = np.minimum(np.arange(0, full_w, step) * WAVEFORM_COLS // full_w, WAVEFORM_COLS - 1) - return img[::step, ::step, :3], cols + return (y1, y2, x1, x2), _frame_step((y2 - y1, x2 - x1), _MAX_HIST_SAMPLES) def density_parade(img_log: Any, roi: Optional[Tuple[int, int, int, int]] = None) -> Optional[np.ndarray]: @@ -693,15 +582,10 @@ def density_parade(img_log: Any, roi: Optional[Tuple[int, int, int, int]] = None img = np.asarray(img_log) if img.ndim != 3 or img.shape[-1] < 3: return None - sampled = _parade_samples(img, roi) - if sampled is None: + crop = _sample_crop(img.shape, roi) + if crop is None: return None - img, cols = sampled - pos = scan_density_axis(-np.nan_to_num(img.astype(np.float32))) - bins = np.clip(np.floor(pos * WAVEFORM_BINS), 0, WAVEFORM_BINS - 1).astype(np.int64) - flat_cols = np.broadcast_to(cols[None, :], bins.shape[:2]).ravel() - counts = [np.bincount(bins[..., c].ravel() * WAVEFORM_COLS + flat_cols, minlength=WAVEFORM_BINS * WAVEFORM_COLS) for c in range(3)] - return np.stack(counts).reshape(3, WAVEFORM_BINS, WAVEFORM_COLS).astype(float) + return negpy_core.density_parade(np.ascontiguousarray(img, dtype=np.float32), *crop) def scan_full_scale_columns(image: Any, roi: Optional[Tuple[int, int, int, int]] = None) -> Optional[np.ndarray]: @@ -715,10 +599,8 @@ def scan_full_scale_columns(image: Any, roi: Optional[Tuple[int, int, int, int]] img = np.asarray(image) if img.ndim != 3 or img.shape[-1] < 3: return None - sampled = _parade_samples(img, roi) - if sampled is None: + crop = _sample_crop(img.shape, roi) + if crop is None: return None - img, cols = sampled - clipped = img >= float(EXPOSURE_CONSTANTS["scan_clip_level"]) - flat_cols = np.broadcast_to(cols[None, :], clipped.shape[:2]).ravel() - return np.stack([np.bincount(flat_cols, weights=clipped[..., c].ravel().astype(np.float64), minlength=WAVEFORM_COLS) for c in range(3)]) + level = float(EXPOSURE_CONSTANTS["scan_clip_level"]) + return negpy_core.scan_full_scale_columns(np.ascontiguousarray(img, dtype=np.float32), *crop, level) diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index ad1d3a824..e8c668c65 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -1,11 +1,10 @@ import math from typing import Any, Dict, Optional, Tuple -import cv2 +import negpy_core import numpy as np -from numba import njit, prange # type: ignore -from negpy.domain.types import LUMA_B, LUMA_G, LUMA_R, ImageBuffer +from negpy.domain.types import ImageBuffer from negpy.features.exposure.papers import ( PaperProfile, compose_density_matrices, @@ -14,9 +13,8 @@ resolve_saturation_matrix, slope_gain, ) -from negpy.features.exposure.print_lut import lut_black_luminance, tetra_lookup +from negpy.features.exposure.print_lut import lut_black_luminance from negpy.kernel.image.validation import ensure_image -from negpy.kernel.system.parallel import parallel_njit def _expit(x: Any) -> Any: @@ -27,40 +25,12 @@ def _expit(x: Any) -> Any: return np.exp(-np.logaddexp(0.0, -x)) -@njit(inline="always") def _fast_sigmoid(x: float) -> float: - """ - Fast implementation of the logistic sigmoid function. - expit(x) = 1 / (1 + exp(-x)) - """ + """The logistic sigmoid 1 / (1 + exp(-x)), overflow-safe for large |x|.""" if x >= 0: - z = np.exp(-x) - return float(1.0 / (1.0 + z)) - else: - z = np.exp(x) - return float(z / (1.0 + z)) - - -# Plain Python calls this twin: an inline kernel has no cache, so it would compile on every launch. -_fast_sigmoid_cached = njit(cache=True)(_fast_sigmoid.py_func) - - -@njit(inline="always") -def _black_hold(v: float, d_max_eff: float, taper: float) -> float: - """Hold Black's weight on a shadow lift: 1 below `taper` under paper black, smoothstep to 0 at it. - Falls only where the lift is negative, so the lifted curve gets steeper and stays monotone.""" - t = min(max((d_max_eff - v) / taper, 0.0), 1.0) - return t * t * (3.0 - 2.0 * t) - - -@njit(inline="always") -def _softplus(x: float) -> float: - """ - Numerically stable softplus: log(1 + exp(x)). Antiderivative of the sigmoid. - """ - if x > 0: - return float(x + np.log1p(np.exp(-x))) - return float(np.log1p(np.exp(x))) + return 1.0 / (1.0 + math.exp(-x)) + z = math.exp(x) + return z / (1.0 + z) def _inv_softplus_np(y: Any) -> Any: @@ -68,7 +38,6 @@ def _inv_softplus_np(y: Any) -> Any: return np.where(y > 20.0, y, np.log(np.expm1(np.maximum(y, 1e-12)))) -@njit(inline="always") def separation_damping_gain(k: float, damping: float, chroma: float, ref_spread: float) -> float: """ One pixel's effective dye-separation k: the frame-wide k tapered by the @@ -79,7 +48,7 @@ def separation_damping_gain(k: float, damping: float, chroma: float, ref_spread: frame-wide matrix cannot do. Monotone in chroma for k < e**2; the clamp bounds the k -> 0 corner (weakly monotone, so no rank swap). - Single source for the CPU kernel and the tests; exposure.wgsl mirrors it. + negpy_core's print curve and exposure.wgsl mirror it. """ if k <= 0.0: return 0.0 @@ -92,7 +61,7 @@ def separation_damping_gain(k: float, damping: float, chroma: float, ref_spread: def separation_damping_gain_np(k: float, damping: float, chroma: Any, ref_spread: float) -> Any: """Vectorized numpy twin of separation_damping_gain, for the transfer curve's - per-pixel gain over a whole image rather than the numba kernel's per-pixel scalar. + per-pixel gain over a whole image rather than the print curve's per-pixel scalar. Same math; see that function for the derivation.""" if k <= 0.0: return np.zeros_like(chroma, dtype=np.float32) @@ -101,336 +70,17 @@ def separation_damping_gain_np(k: float, damping: float, chroma: Any, ref_spread return np.minimum(kf, np.float32(3.0)) -@njit(inline="always") -def plane_taps(gh: int, gw: int, x: int, y: int, rect_x: float, rect_y: float, rect_w: float, rect_h: float): - """Taps and weights of render pixel (x, y) on a gh×gw analysis-grid plane covering `rect`: OpenCV's half-pixel - bilinear, taps clamped (edge replication outside the frame). exposure.wgsl mirrors this.""" - px = (x + 0.5 - rect_x) * gw / rect_w - 0.5 - py = (y + 0.5 - rect_y) * gh / rect_h - 0.5 - lx = min(max(np.floor(px), 0.0), gw - 1.0) - ly = min(max(np.floor(py), 0.0), gh - 1.0) - hx = min(lx + 1.0, gw - 1.0) - hy = min(ly + 1.0, gh - 1.0) - fx = min(max(px - lx, 0.0), 1.0) - fy = min(max(py - ly, 0.0), 1.0) - return int(lx), int(hx), int(ly), int(hy), fx, fy - - -@njit(inline="always") -def tone_key_weight(lum: float, e0: float, e1: float) -> float: - """A tone-limited mask's weight at luma `lum`: a smoothstep from 0 at e0 to 1 at e1 - (see placement.key_edges). Either edge order works. exposure.wgsl mirrors it.""" - t = (lum - e0) / (e1 - e0) - if t < 0.0: - t = 0.0 - elif t > 1.0: - t = 1.0 - return t * t * (3.0 - 2.0 * t) - - -@njit(inline="always") -def quadratic_core(k: float, pivot: float, curv: float, u: float) -> float: - """k*(u - pivot) + curv*u², held at its vertex on the side where it would fold back. - The Cast Removal clamp bounds curv only near the frame's range. exposure.wgsl mirrors it.""" - if curv != 0.0: - vertex = -k / (2.0 * curv) - if (curv > 0.0 and u < vertex) or (curv < 0.0 and u > vertex): - u = vertex - return k * (u - pivot) + curv * u * u - - def tone_key_weight_np(lum: Any, e0: float, e1: float) -> Any: - """Vectorized numpy twin of tone_key_weight, for the canvas tint over a whole raster.""" + """A tone-limited mask's weight at luma `lum`, over a whole raster: a smoothstep from 0 at e0 + to 1 at e1 (see placement.key_edges). The print curve in negpy_core and exposure.wgsl mirror it.""" t = np.clip((np.asarray(lum, dtype=np.float32) - np.float32(e0)) / np.float32(e1 - e0), 0.0, 1.0) return t * t * (np.float32(3.0) - np.float32(2.0) * t) -@parallel_njit(cache=True, nogil=True, fastmath=True) -def _apply_print_curve_kernel( - img: np.ndarray, - pivots: np.ndarray, - slopes: np.ndarray, - curvatures: np.ndarray, - toe: np.ndarray, - shoulder: np.ndarray, - toe_width: np.ndarray, - shoulder_width: np.ndarray, - cmy_offsets: np.ndarray, - shadow_cmy: np.ndarray, - highlight_cmy: np.ndarray, - d_min_rgb: np.ndarray, - d_max: float, - a_toe_base: float, - a_sh_base: float, - width_ref: float, - toe_height: float, - sh_height: float, - zone_center: float, - shadow_density: float, - highlight_density: float, - shadow_grade: np.ndarray, - highlight_grade: np.ndarray, - zone_sh_center: float, - zone_hi_center: float, - zone_k: float, - v_star: float, - midtone_gamma: np.ndarray, - gamma_width: float, - dye_mix: np.ndarray, - use_dye_mix: bool, - sep_k3: np.ndarray, - sep_damping: float, - sep_ref: float, - use_sep_damping: bool, - ev_map: np.ndarray, - ev_scale: np.ndarray, - use_ev: bool, - grade_map: np.ndarray, - use_grade: bool, - key_alpha: np.ndarray, - key_params: np.ndarray, - use_key: bool, - grade_deltas: np.ndarray, - r0: float, - r_min: float, - r_max: float, - flash: float, - flash_v: float, - flash_gamma: float, - flash_map: np.ndarray, - use_flash: bool, - diff_plane: np.ndarray, - diff_rect: np.ndarray, - diff_mix: float, - diff_range: np.ndarray, - use_diff: bool, - lut: np.ndarray, - lut_e_max: np.ndarray, - lut_black: float, - use_lut: bool, - bpc: bool = False, - black_hold: float = 0.0, -) -> np.ndarray: - """ - Asymmetric H&D print curve: a straight line of slope `slope` through the - exposure pivot, smoothly bounded above by the toe (shadows -> paper black - d_max) and below by the shoulder (highlights -> paper white d_min). Toe and - shoulder are independent softplus bounds, so the `toe` slider shapes only - shadows and `shoulder` only highlights (film/print convention). `toe`/`shoulder` - are per-channel 3-arrays (global value + per-layer trims — endpoint crossover), - pre-scaled by toe_shoulder_strength. - - d_min_rgb: per-channel paper-white floor (base+fog incl. tint). dye_mix: - dye coupling above that floor (D_rgb = M · D_dye) when use_dye_mix is set. - ev_map/ev_scale: per-pixel dodge/burn print-exposure offset (stops × the - normalized-space stop size, positive = burn) when use_ev is set; same domain as - cmy_offsets. - grade_map: per-pixel slope multiplier (local_grade_factor_map) when use_grade - is set — burning or dodging through a harder/softer filter. - key_alpha/key_params: tone-limited masks when use_key is set (see - apply_characteristic_curve). Their grade adds to grade_deltas in ISO-R space about r0, - which then replaces grade_map. - flash/flash_v/flash_gamma: preflash as a fraction of the threshold exposure (see - preflash_value). - - Output is linear reflectance (transmittance = 10^-D); the working-space OETF is - applied at the engine output, not here. - - bpc: black point compensation — paper Dmax maps to display black (ICC - relative-colorimetric style). - - lut/lut_e_max: the stock's LUT when use_lut is set; it replaces 10^-D on the density above - base, and BPC then maps the luminance lut_black to 0 for all channels alike. - """ - h, w, c = img.shape - res = np.empty_like(img) - eps = 1e-6 - - # Roll-off sharpness from the width, so a larger width is gentler; the slider sets the - # height. toe = shadow (upper, paper-black) bound, shoulder = highlight (lower, - # paper-white) bound. a_toe_base/a_sh_base carry the sharpness. - a_hl = np.empty(3, dtype=np.float64) - a_sh = np.empty(3, dtype=np.float64) - d_min_eff = np.empty(3, dtype=np.float64) - d_max_eff = np.empty(3, dtype=np.float64) - bpc_black = np.empty(3, dtype=np.float64) - for ch in range(3): - a_hl[ch] = a_sh_base * width_ref / max(shoulder_width[ch], eps) - a_sh_w = a_toe_base * width_ref / max(toe_width[ch], eps) - t_ch = toe[ch] - if t_ch >= 0.0: - d_max_base = d_max - t_ch * toe_height - a_sh[ch] = a_sh_w - else: - # Negative toe tightens the shadow roll-off instead of extending d_max_eff past - # paper black, which has almost no visible effect. - d_max_base = d_max - a_sh[ch] = a_sh_w * (1.0 - t_ch * 4.0) - dmn = d_min_rgb[ch] + shoulder[ch] * sh_height - if dmn < 0.0: - dmn = 0.0 - dmx = d_max_base - if dmx < dmn + 0.1: - dmx = dmn + 0.1 - d_min_eff[ch] = dmn - d_max_eff[ch] = dmx - # BPC references the physical d_max, not d_max_eff, so toe lifts survive. A negative - # toe raises the clip point: the bound reaches d_max only asymptotically, so an exact - # 0 needs the clip inside the shadow range. - db = d_max - if t_ch < 0.0: - db = d_max + t_ch * toe_height - bpc_black[ch] = 10.0**-db - - use_split = ( - shadow_grade[0] != 0.0 - or shadow_grade[1] != 0.0 - or shadow_grade[2] != 0.0 - or highlight_grade[0] != 0.0 - or highlight_grade[1] != 0.0 - or highlight_grade[2] != 0.0 - ) - - # Rows are independent, so parallelise over y. `dens` is allocated per row, so each - # worker thread has its own scratch. - for y in prange(h): - dens = np.empty(3, dtype=np.float64) - for x in range(w): - gfac = 1.0 - ev_px = 0.0 - if use_key: - if use_ev: - ev_px = ev_map[y, x] - # The unburned tone: no mask can move the pixels it selects. - lum = LUMA_R * img[y, x, 0] + LUMA_G * img[y, x, 1] + LUMA_B * img[y, x, 2] - dr = grade_deltas[y, x] - for k in range(key_params.shape[0]): - a = key_alpha[y, x, k] * tone_key_weight(lum, key_params[k, 2], key_params[k, 3]) - ev_px += key_params[k, 0] * a - dr += key_params[k, 1] * a - r1 = r0 + dr - if r1 < r_min: - r1 = r_min - elif r1 > r_max: - r1 = r_max - gfac = r0 / r1 - elif use_grade: - gfac = grade_map[y, x] - # The frame's preflash plus the masks' own, pre-summed like the stops. - fl = flash - if use_flash: - fl = fl + flash_map[y, x] - i0x = i1x = i0y = i1y = 0 - fx = fy = 0.0 - if use_diff: - i0x, i1x, i0y, i1y, fx, fy = plane_taps( - diff_plane.shape[0], diff_plane.shape[1], x, y, diff_rect[0], diff_rect[1], diff_rect[2], diff_rect[3] - ) - for ch in range(3): - base = img[y, x, ch] - if use_diff: - # Diffusion mixes light, not density: the paper sees (1-d)·I + d·blur(I), - # with I = 10^(val·r), r the channel's log range (diffusion_plane). - r = diff_range[ch] - top = diff_plane[i0y, i0x, ch] * (1.0 - fx) + diff_plane[i0y, i1x, ch] * fx - bot = diff_plane[i1y, i0x, ch] * (1.0 - fx) + diff_plane[i1y, i1x, ch] * fx - p = top * (1.0 - fy) + bot * fy - base = np.log10((1.0 - diff_mix) * 10.0 ** (base * r) + diff_mix * 10.0 ** (p * r)) / r - val = base + cmy_offsets[ch] - if use_key: - val = val + np.float32(ev_px * ev_scale[ch]) - elif use_ev: - val = val + ev_map[y, x] * ev_scale[ch] - # Quadratic per-channel core; curvature 0 gives the original straight line. - # gfac is the local grade, a slope rotation about this channel's pivot, so the - # region's own midtone holds. The cast-removal curvature stays global. - v = quadratic_core(slopes[ch] * gfac, pivots[ch], curvatures[ch], val) - if fl > 0.0: - v = flash_v + flash_gamma * np.log10(10.0 ** ((v - flash_v) / flash_gamma) + fl) - - # Variable-gamma paper S-curve: extra local gamma at the midtone centre - # (v_star), easing to zero toward the toe and shoulder. Centred on v_star, so - # the reference tone is preserved. - if midtone_gamma[ch] != 0.0: - v = v + midtone_gamma[ch] * gamma_width * np.tanh((v - v_star) / gamma_width) - - # Regional CMY: shadow weight rises with density, highlight falls. - w_sh = _fast_sigmoid(3.0 * (v - zone_center)) - w_hi = 1.0 - w_sh - v = v + shadow_cmy[ch] * w_sh + highlight_cmy[ch] * w_hi - - # Split Grade: a mid-sparing local contrast rotation about the zone centres. - # Must run as its own block before Zone Density, because sequential stays - # monotone and shared weights do not. - if use_split: - w_gsh = _fast_sigmoid(zone_k * (v - zone_sh_center)) - w_ghi = 1.0 - _fast_sigmoid(zone_k * (v - zone_hi_center)) - v = v + shadow_grade[ch] * w_gsh * (v - zone_sh_center) + highlight_grade[ch] * w_ghi * (v - zone_hi_center) - - # Zone Density (ΔD): neutral brightness offsets, mid-sparing - # weights centred in the three-quarter/quarter tones. - if shadow_density != 0.0 or highlight_density != 0.0: - w_zsh = _fast_sigmoid(zone_k * (v - zone_sh_center)) - if black_hold > 0.0 and shadow_density < 0.0: - w_zsh = w_zsh * _black_hold(v, d_max_eff[ch], black_hold) - w_zhi = 1.0 - _fast_sigmoid(zone_k * (v - zone_hi_center)) - v = v + shadow_density * w_zsh + highlight_density * w_zhi - - # Shoulder: smooth lower bound at paper white (highlights). - v1 = d_min_eff[ch] + _softplus(a_hl[ch] * (v - d_min_eff[ch])) / a_hl[ch] - # Toe: smooth upper bound at paper black (shadows). - dens[ch] = d_max_eff[ch] - _softplus(a_sh[ch] * (d_max_eff[ch] - v1)) / a_sh[ch] - - if use_dye_mix: - # Dye unwanted absorptions: mix the densities above paper base. - e0 = dens[0] - d_min_rgb[0] - e1 = dens[1] - d_min_rgb[1] - e2 = dens[2] - d_min_rgb[2] - dens[0] = d_min_rgb[0] + dye_mix[0, 0] * e0 + dye_mix[0, 1] * e1 + dye_mix[0, 2] * e2 - dens[1] = d_min_rgb[1] + dye_mix[1, 0] * e0 + dye_mix[1, 1] * e1 + dye_mix[1, 2] * e2 - dens[2] = d_min_rgb[2] + dye_mix[2, 0] * e0 + dye_mix[2, 1] * e1 + dye_mix[2, 2] * e2 - - if use_sep_damping: - # Chroma-selective dye separation, in place of the frame-wide saturation - # matrix, which then carries the paper's coupling only and keeps - # compose_density_matrices' separation-outermost order. Chroma mirrors - # _rms_chroma in normalization.py. - s0 = dens[0] - d_min_rgb[0] - s1 = dens[1] - d_min_rgb[1] - s2 = dens[2] - d_min_rgb[2] - s_mean = (s0 + s1 + s2) / 3.0 - chroma = np.sqrt(((s0 - s1) ** 2 + (s1 - s2) ** 2 + (s0 - s2) ** 2) / 3.0) - dens[0] = d_min_rgb[0] + s_mean + separation_damping_gain(sep_k3[0], sep_damping, chroma, sep_ref) * (s0 - s_mean) - dens[1] = d_min_rgb[1] + s_mean + separation_damping_gain(sep_k3[1], sep_damping, chroma, sep_ref) * (s1 - s_mean) - dens[2] = d_min_rgb[2] + s_mean + separation_damping_gain(sep_k3[2], sep_damping, chroma, sep_ref) * (s2 - s_mean) - - if use_lut: - lr, lg, lb = tetra_lookup(lut, lut_e_max, dens[0] - d_min_rgb[0], dens[1] - d_min_rgb[1], dens[2] - d_min_rgb[2]) - rgb = (lr, lg, lb) - for ch in range(3): - transmittance = 10.0 ** (-d_min_rgb[ch]) * rgb[ch] - if bpc: - transmittance = (transmittance - lut_black) / (1.0 - lut_black) - res[y, x, ch] = min(max(transmittance, 0.0), 1.0) - else: - for ch in range(3): - transmittance = 10.0 ** (-dens[ch]) - if bpc: - transmittance = (transmittance - bpc_black[ch]) / (1.0 - bpc_black[ch]) - - final_val = transmittance - if final_val < 0.0: - final_val = 0.0 - elif final_val > 1.0: - final_val = 1.0 - res[y, x, ch] = final_val - return res - - class CharacteristicCurve: """ Asymmetric H&D print curve (toe-linear-shoulder) in density space — the NumPy - mirror of _apply_print_curve_kernel, used by the curve chart so the displayed + mirror of the print curve in negpy_core (print_curve.rs), used by the curve chart so the displayed curve matches the render. Returns density (pre-transmittance/encode). Neutral (no regional CMY color), since the chart shows the achromatic transfer; the achromatic zone density offsets (shadow/highlight ΔD) are included. @@ -815,7 +465,7 @@ def apply_characteristic_curve( lut_black = 0.0 if lut is not None and bpc: lut_black = lut_black_luminance(lut, d_min_rgb, black_density_rgb(float(c["d_max"]), toe_k, float(c["toe_height"]))) - res = _apply_print_curve_kernel( + res = negpy_core.apply_print_curve( np.ascontiguousarray(img.astype(np.float32)), pivots, slopes, @@ -1237,7 +887,7 @@ def highlight_hold_offset( if v >= v_hold: return 0.0 z_hi = float(c["reference_print_density"]) + float(c["zone_density_highlight_offset"]) - w = 1.0 - _fast_sigmoid_cached(float(c["zone_density_sharpness"]) * (v - z_hi)) + w = 1.0 - _fast_sigmoid(float(c["zone_density_sharpness"]) * (v - z_hi)) return float(min((v_hold - v) / max(w, 1e-6), float(c["highlight_hold_max"]))) @@ -1605,23 +1255,21 @@ def expand_mask_plane( Split from `contrast_mask_ev` so a caller can cache this across slider values: only the scalar depends on the gamma. The GPU shader mirrors the bilinear mapping. """ + return _expanded(plane, out_shape, roi, 1.0) + + +def _expanded( + plane: Optional[np.ndarray], out_shape: Tuple[int, int], roi: Optional[Tuple[int, int, int, int]], scale: float +) -> Optional[np.ndarray]: if plane is None: return None h, w = out_shape - if roi is None: - if plane.shape[:2] != (h, w): - plane = cv2.resize(plane, (w, h), interpolation=cv2.INTER_LINEAR) - return np.ascontiguousarray(plane, dtype=np.float32) - - y1, y2, x1, x2 = roi + y1, y2, x1, x2 = (0, h, 0, w) if roi is None else roi y1, x1 = max(0, y1), max(0, x1) y2, x2 = min(h, y2), min(w, x2) if y2 - y1 < 1 or x2 - x1 < 1: return None - inner = cv2.resize(plane, (x2 - x1, y2 - y1), interpolation=cv2.INTER_LINEAR) - if (y1, x1, y2, x2) == (0, 0, h, w): - return np.ascontiguousarray(inner, dtype=np.float32) - return np.ascontiguousarray(np.pad(inner, ((y1, h - y2), (x1, w - x2)), mode="edge"), dtype=np.float32) + return negpy_core.expand_plane(np.ascontiguousarray(plane, dtype=np.float32), (h, w), (y1, y2, x1, x2), scale) def contrast_mask_scale(gamma: float, density_range: float) -> float: @@ -1647,10 +1295,7 @@ def contrast_mask_ev( """The contrast mask as print-exposure stops, ready to add to the dodge/burn map.""" if plane is None or abs(gamma) < 1e-6: return None - expanded = expand_mask_plane(plane, out_shape, roi) - if expanded is None: - return None - return (expanded * contrast_mask_scale(gamma, density_range)).astype(np.float32) + return _expanded(plane, out_shape, roi, contrast_mask_scale(gamma, density_range)) def cmy_to_density(val: float, log_range: float = 1.0) -> float: diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 18a934797..8c585f8d1 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -1,13 +1,10 @@ -import os -from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, Optional, TYPE_CHECKING, Tuple -import cv2 +import negpy_core import numpy as np -from numba import njit # type: ignore -from negpy.domain.types import LUMA_B, LUMA_G, LUMA_R, ROI, ImageBuffer -from negpy.features.geometry.logic import AUTOCROP_DETECT_RES, _normalize_detection_input, film_and_picture +from negpy.domain.types import LUMA_B, LUMA_G, LUMA_R, ImageBuffer +from negpy.features.geometry.logic import AUTOCROP_DETECT_RES, _normalize_detection_input from negpy.features.process.models import ProcessMode from negpy.kernel.image.logic import percentile_from_sorted from negpy.kernel.image.validation import ensure_image @@ -17,10 +14,7 @@ # Bump in any change that alters the bounds a frame measures for unchanged settings. Saved # per-frame bounds from an older version are dropped at startup (migrations/bounds.py). -BOUNDS_VERSION = 1 - -# Above this size the block-median is threaded over row strips (np.median frees the GIL). -_BLOCK_MEDIAN_PARALLEL_MIN_PIXELS = 2_000_000 +BOUNDS_VERSION = 2 # Contrast-mask spacer, per-cent of the analysis grid's short side. Of the grid, not of # the render, so preview and export mask alike. Under the minimum the mask stops being @@ -33,35 +27,6 @@ DIFFUSION_SPREAD = 2.0 -@njit(cache=True, nogil=True, fastmath=True) -def _normalize_log_image_jit(img_log: np.ndarray, floors: np.ndarray, ceils: np.ndarray) -> np.ndarray: - """ - Log -> ~0.0-1.0 (Linear stretch, unclamped: out-of-bounds densities are - rolled off by the downstream characteristic curve). - Supports both f < c (Negative) and f > c (Positive) mapping. - """ - h, w, c = img_log.shape - res = np.empty_like(img_log) - epsilon = 1e-6 - - for y in range(h): - for x in range(w): - for ch in range(3): - f = floors[ch] - c_val = ceils[ch] - delta = c_val - f - - denom = delta - if abs(delta) < epsilon: - if delta >= 0: - denom = epsilon - else: - denom = -epsilon - - res[y, x, ch] = (img_log[y, x, ch] - f) / denom - return res - - class LogNegativeBounds: """ D-min / D-max container. @@ -133,32 +98,8 @@ def _block_median_grid(img_log: ImageBuffer) -> ImageBuffer: b = int(np.ceil(max(h, w) / grid)) if b <= 1 or h < b or w < b: return img_log - hb, wb = (h // b) * b, (w // b) * b - arr = img_log[:hb, :wb] - grid_rows, c = hb // b, arr.shape[2] - - def _median(rows: np.ndarray) -> np.ndarray: - v = rows.reshape(rows.shape[0] // b, b, wb // b, b, c) - if b == 2: - # Median of 4 = (sum - min - max) / 2; strided views, no partition. - p00, p01, p10, p11 = v[:, 0, :, 0], v[:, 0, :, 1], v[:, 1, :, 0], v[:, 1, :, 1] - s = p00.astype(np.float64) + p01 + p10 + p11 - mn = np.minimum(np.minimum(p00, p01), np.minimum(p10, p11)) - mx = np.maximum(np.maximum(p00, p01), np.maximum(p10, p11)) - return ((s - mn - mx) * 0.5).astype(rows.dtype, copy=False) - return np.median(v, axis=(1, 3)) - - workers = min(os.cpu_count() or 1, grid_rows) - if workers < 2 or hb * wb < _BLOCK_MEDIAN_PARALLEL_MIN_PIXELS: - return _median(arr) - - # Block-aligned strips -> per-cell median identical to the single pass. - rows_per = -(-grid_rows // workers) - strips = [arr[i * b : min(grid_rows, i + rows_per) * b] for i in range(0, grid_rows, rows_per)] - with ThreadPoolExecutor(max_workers=workers) as ex: - parts = list(ex.map(_median, strips)) - return np.concatenate(parts, axis=0) + return negpy_core.block_median_grid(np.ascontiguousarray(img_log[:hb, :wb], dtype=np.float32), b) def prefilter_log_grid( @@ -193,17 +134,16 @@ def measure_clip_fractions( """ from negpy.features.exposure.models import EXPOSURE_CONSTANTS - img = image - if roi: - y1, y2, x1, x2 = roi - img = img[y1:y2, x1:x2] + h, w = image.shape[:2] + y1, y2, x1, x2 = roi if roi else (0, h, 0, w) + y2, x2 = min(y2, h), min(x2, w) + y1, x1 = min(y1, y2), min(x1, x2) if analysis_buffer > 0: - img = get_analysis_crop(img, analysis_buffer) - # Stride-subsampled: a warning metric doesn't need every pixel. - img = img[::4, ::4] + b = min(max(analysis_buffer, 0.0), 0.3) + cut_h, cut_w = int((y2 - y1) * b), int((x2 - x1) * b) + y1, y2, x1, x2 = y1 + cut_h, y2 - cut_h, x1 + cut_w, x2 - cut_w level = float(EXPOSURE_CONSTANTS["scan_clip_level"]) - clipped = np.mean(img >= level, axis=(0, 1)) - return (float(clipped[0]), float(clipped[1]), float(clipped[2])) + return negpy_core.clip_fractions(np.ascontiguousarray(image, dtype=np.float32), (y1, y2, x1, x2), level) def effective_crosstalk_matrix(process: "ProcessConfig", process_mode: Optional[str]) -> Optional[np.ndarray]: @@ -259,41 +199,8 @@ def sorted_channel_grid(img_log: ImageBuffer) -> np.ndarray: def to_log_density(image: ImageBuffer) -> ImageBuffer: - """ - Linear scan -> log10 density, the domain every meter reads. - - fmin/fmax rather than clip: they drop a NaN in favour of the bound, so one clamp covers - the NaN and infinity fixup as well. - """ - epsilon = 1e-6 - return np.log10(np.fmin(np.fmax(image, epsilon), 1.0)) - - -def _rms_chroma(triplets: np.ndarray) -> np.ndarray: - """Distance from the neutral axis in normalized-density space (pairwise RMS): - rotation-symmetric around grey, so the near-neutral ranking is hue-uniform - (max-min scores an opposed R/B split double a same-side deviation).""" - r, g, b = triplets[..., 0], triplets[..., 1], triplets[..., 2] - return np.sqrt(((r - g) ** 2 + (g - b) ** 2 + (r - b) ** 2) / 3.0) - - -def _subject_cells(norm_f: np.ndarray) -> np.ndarray: - """Cells in the foliage or the sky sector. In a frame with no grays the lowest-chroma cells are - desaturated leaves or pale sky, and neutralizing them prints magenta or yellow (a printer's - subject failure).""" - from negpy.features.exposure.models import EXPOSURE_CONSTANTS - - c = EXPOSURE_CONSTANTS - dr = norm_f[:, 0] - norm_f[:, 1] - db = norm_f[:, 2] - norm_f[:, 1] - t, s = (dr + db) / 2.0, (dr - db) / 2.0 - hue = np.degrees(np.arctan2(-s, t)) - chroma = np.hypot(t, s) - cells = np.zeros(chroma.shape, dtype=bool) - for name in ("foliage", "sky"): - lo, hi = c[f"neutral_axis_{name}_hue"] - cells |= (chroma > float(c[f"neutral_axis_{name}_chroma"])) & (hue > float(lo)) & (hue < float(hi)) - return cells + """Linear scan -> log10 density clamped to 1e-6..1 (NaN to 1e-6), the domain every meter reads.""" + return negpy_core.to_log_density(np.ascontiguousarray(image, dtype=np.float32)) def measure_neutral_axis_from_log( @@ -317,97 +224,25 @@ def measure_neutral_axis_from_log( if analysis_buffer > 0: img_log = get_analysis_crop(img_log, analysis_buffer) - img_log = _block_median_grid(img_log) - norm = normalize_log_image(img_log, bounds) - luma = LUMA_R * norm[:, :, 0] + LUMA_G * norm[:, :, 1] + LUMA_B * norm[:, :, 2] - - flat_log = img_log.reshape(-1, 3) - norm_f = norm.reshape(-1, 3) - luma_f = luma.reshape(-1) - if exclude_subjects: - keep = ~_subject_cells(norm_f) - flat_log, norm_f, luma_f = flat_log[keep], norm_f[keep], luma_f[keep] - chroma_f = _rms_chroma(norm_f) - c = EXPOSURE_CONSTANTS - q = float(c["neutral_axis_chroma_quantile"]) - cap = float(c["neutral_axis_chroma_cap"]) - pass1_cap = float(c["neutral_axis_first_pass_cap"]) - min_px = int(c["neutral_axis_min_pixels"]) - epsilon = 1e-6 - - def _band_refs( - lo: float, hi: float, chroma_vals: np.ndarray, cap_val: float - ) -> Optional[Tuple[Tuple[float, float, float], float, int, float]]: - band = (luma_f >= lo) & (luma_f <= hi) - if int(band.sum()) < min_px: - return None - band_chroma = chroma_vals[band] - thr = float(np.quantile(band_chroma, q)) - keep = band_chroma <= thr - idx = np.nonzero(band)[0][keep] - near_neutral_chroma = float(np.median(band_chroma[keep])) if idx.size else cap_val - if idx.size < min_px or near_neutral_chroma > cap_val: - return None - # One gather for all three channels: the per-channel fancy index is the cost here. - sel = flat_log[idx] - # R and B are green plus the set's median offset from green. Independent channel - # medians can each fall in a different cluster of a set that holds two colors. - green = float(np.median(sel[:, 1])) - refs = (green + float(np.median(sel[:, 0] - sel[:, 1])), green, green + float(np.median(sel[:, 2] - sel[:, 1]))) - offsets = (norm_f[idx][:, (0, 2)] - norm_f[idx][:, 1:2]).astype(np.float64) - p10, p90 = np.percentile(offsets, (10.0, 90.0), axis=0) - return (refs, near_neutral_chroma, int(idx.size), float(np.max(p90 - p10))) - - def _norm_ref(refs: Tuple[float, float, float]) -> Tuple[float, float, float]: - out = [] - for ch in range(3): - denom = bounds.ceils[ch] - bounds.floors[ch] - if abs(denom) < epsilon: - denom = epsilon if denom >= 0 else -epsilon - out.append((refs[ch] - bounds.floors[ch]) / denom) - return (out[0], out[1], out[2]) - - hb = c["neutral_axis_highlight_band"] - mb = c["neutral_axis_mid_band"] - sb = c["neutral_axis_shadow_band"] - mid1 = _band_refs(float(mb[0]), float(mb[1]), chroma_f, pass1_cap) - sh1 = _band_refs(float(sb[0]), float(sb[1]), chroma_f, pass1_cap) - if mid1 is None or sh1 is None: - return None - - nm, ns = _norm_ref(mid1[0]), _norm_ref(sh1[0]) - corrected = norm_f.copy() - for ch in (0, 2): - du = nm[ch] - ns[ch] - if abs(du) < epsilon: - a, b = 1.0, nm[1] - nm[ch] - else: - a = (nm[1] - ns[1]) / du - b = nm[1] - a * nm[ch] - corrected[:, ch] = a * norm_f[:, ch] + b - chroma2_f = _rms_chroma(corrected) - - mid = _band_refs(float(mb[0]), float(mb[1]), chroma2_f, cap) - shadow = _band_refs(float(sb[0]), float(sb[1]), chroma2_f, cap) - if mid is None or shadow is None: - return None - highlight = _band_refs(float(hb[0]), float(hb[1]), chroma2_f, cap) - - # Confidence: the corrected tightness of the grey sets, times the midtone sample size, - # times the mid/shadow deviation agreement, where a dead zone passes plausible crossover. - n0 = float(c["neutral_axis_confidence_n0"]) - dead = float(c["neutral_axis_agreement_deadzone"]) - scale = float(c["neutral_axis_agreement_scale"]) - tight = float(np.clip(1.0 - max(mid[1], shadow[1]) / cap, 0.0, 1.0)) - size_term = mid[2] / (mid[2] + n0) - dm, ds = _norm_ref(mid[0]), _norm_ref(shadow[0]) - spread = max(abs((dm[ch] - dm[1]) - (ds[ch] - ds[1])) for ch in (0, 2)) - agree = 1.0 - min(max(spread - dead, 0.0) / scale, 1.0) - width = max(mid[3], shadow[3]) - single = 1.0 - min(max(width - float(c["neutral_axis_width_deadzone"]), 0.0) / float(c["neutral_axis_width_scale"]), 1.0) - confidence = float(np.clip(tight * size_term * agree * single, 0.0, 1.0)) - return (mid[0], shadow[0], highlight[0] if highlight is not None else None, confidence) + consts = ( + float(c["neutral_axis_chroma_quantile"]), + float(c["neutral_axis_chroma_cap"]), + float(c["neutral_axis_first_pass_cap"]), + int(c["neutral_axis_min_pixels"]), + tuple(float(v) for v in c["neutral_axis_highlight_band"]), + tuple(float(v) for v in c["neutral_axis_mid_band"]), + tuple(float(v) for v in c["neutral_axis_shadow_band"]), + (float(c["neutral_axis_foliage_chroma"]), *(float(v) for v in c["neutral_axis_foliage_hue"])), + (float(c["neutral_axis_sky_chroma"]), *(float(v) for v in c["neutral_axis_sky_hue"])), + float(c["neutral_axis_confidence_n0"]), + (float(c["neutral_axis_agreement_deadzone"]), float(c["neutral_axis_agreement_scale"])), + (float(c["neutral_axis_width_deadzone"]), float(c["neutral_axis_width_scale"])), + ) + grid = np.ascontiguousarray(_block_median_grid(img_log), dtype=np.float32) + floors = tuple(float(v) for v in bounds.floors) + ceils = tuple(float(v) for v in bounds.ceils) + return negpy_core.neutral_axis(grid, floors, ceils, exclude_subjects, consts) def measure_neutral_axis( @@ -421,79 +256,19 @@ def measure_neutral_axis( return measure_neutral_axis_from_log(img_log, bounds, roi, analysis_buffer) -#: The clear base is read from flat blocks in the film box's margin, grouped by brightness, the -#: way negdensity.py reads it. The film box comes from the crop's detector at its own resolution, -#: whose edge walk is calibrated there. -_BASE_MARGIN = 0.15 # the band inside the film box, per side, that holds rebate, perforations and picture edge -_BASE_BLOCK = 8 # block size at detection resolution -_FLAT_CV = 0.03 # a block whose std over mean is under this is flat film, not edge print or a gradient -_GROUP_GAP = 0.04 # decades between neighbouring blocks that split two brightness groups _OPEN_LIGHT_GAP = 0.08 # decades a brighter, smaller group must clear the base to be bare light -_MIN_BASE_BLOCKS = 5 -_CLIP_LEVEL = 0.98 # a block with a pixel at sensor white is light the scan clipped, never film - - -def _margin_band(shape: Tuple[int, int], film_roi: ROI) -> np.ndarray: - """Inside the film box, outside its central core: where the rebate and the perforations lie.""" - fy1, fy2, fx1, fx2 = film_roi - band = np.zeros(shape, bool) - band[fy1:fy2, fx1:fx2] = True - my, mx = int((fy2 - fy1) * _BASE_MARGIN), int((fx2 - fx1) * _BASE_MARGIN) - band[fy1 + my : fy2 - my, fx1 + mx : fx2 - mx] = False - return band - - -def _base_blocks(det: np.ndarray, band: np.ndarray) -> Optional[np.ndarray]: - """Per-channel means of the band's flat blocks that are clear base, or None without enough. - - Flat blocks are grouped by brightness; the largest group is the base. A smaller group - well above it is bare light through a perforation or past the film's edge. A flat - picture region in the band that outnumbers the base is taken for it.""" - b = _BASE_BLOCK - hb, wb = det.shape[0] // b, det.shape[1] // b - blocks = det[: hb * b, : wb * b].reshape(hb, b, wb, b, 3) - mean = blocks.mean(axis=(1, 3)) - lum = mean.mean(axis=2) - inside = band[: hb * b, : wb * b].reshape(hb, b, wb, b).mean(axis=(1, 3)) >= 0.9 - unclipped = blocks.max(axis=(1, 3, 4)) < _CLIP_LEVEL - flat = inside & unclipped & (lum > 1e-4) & (blocks.std(axis=(1, 3)).mean(axis=2) / np.maximum(lum, 1e-6) < _FLAT_CV) - if int(flat.sum()) < _MIN_BASE_BLOCKS: - return None - cand = mean[flat] - log_lum = np.log10(np.maximum(cand.mean(axis=1), 1e-6)) - order = np.argsort(log_lum)[::-1] # brightest first - groups: list[np.ndarray] = [] - start = 0 - for i in range(1, len(order) + 1): - if i == len(order) or log_lum[order[i - 1]] - log_lum[order[i]] > _GROUP_GAP: - groups.append(order[start:i]) - start = i - min_n = max(_MIN_BASE_BLOCKS, int(0.02 * len(order))) - big = [g for g in groups if len(g) >= min_n] - if not big: - return None - return cand[max(big, key=len)] def measure_base_log(image: ImageBuffer) -> Optional[Tuple[float, float, float]]: """Per-channel log10 transmittance of the clear base: the median of the flat base blocks in the film box's margin on the uncropped frame. None when the scan shows no film box, the margin holds too few flat blocks, or the base is darker than the picture, because a - negative's base is its thinnest film.""" + negative's base is its thinnest film. The blocks group by brightness, the way negdensity.py + reads the base; the film box comes from the crop's detector at its own resolution, whose + edge walk is calibrated there.""" det, _ = _normalize_detection_input(image, AUTOCROP_DETECT_RES) - det = np.ascontiguousarray(det, dtype=np.float32) - film_roi, gate = film_and_picture(det) - if film_roi is None: - return None - base = _base_blocks(det, _margin_band(det.shape[:2], film_roi)) - if base is None: - return None - gy1, gy2, gx1, gx2 = gate - picture = det[gy1:gy2, gx1:gx2].reshape(-1, 3) - if np.median(base.mean(axis=1)) <= np.median(picture.mean(axis=1)): - return None - r, g, b = (float(np.log10(max(float(v), 1e-6))) for v in np.median(base, axis=0)) - return (r, g, b) + base = negpy_core.measure_base_log(np.ascontiguousarray(det, dtype=np.float32)) + return None if base is None else (base[0], base[1], base[2]) def log_from_val(val_rgb: Tuple[float, float, float], bounds: LogNegativeBounds) -> Tuple[float, float, float]: @@ -520,7 +295,7 @@ def density_above_base( base = float(np.dot(w, np.asarray(base_rgb, dtype=np.float64))) thin = float(np.dot(w, np.asarray(bounds.ceils, dtype=np.float64))) dense = float(np.dot(w, np.asarray(bounds.floors, dtype=np.float64))) - median = float(np.median(np.asarray(prefiltered, dtype=np.float64).reshape(-1, 3) @ w)) + median = negpy_core.luma_median(np.ascontiguousarray(prefiltered, dtype=np.float32)) return (base - thin, base - median, base - dense) @@ -546,43 +321,31 @@ def luminance_density_range(bounds: LogNegativeBounds) -> float: return float(LUMA_R * rr + LUMA_G * rg + LUMA_B * rb) -def _textured_cells(lum: np.ndarray) -> np.ndarray: - """ - Cells of the prefiltered luma grid that carry detail. The grid is tiled into - sectors of 2x2 blocks of activity_block cells; a sector votes when its four block - means span more than activity_gate_density (Boyack & Juenger, US 5,724,456). Block - means average grain out, and a flat sector beside a textured one stays out because - only its own blocks are compared. Falls back to every cell when too few pass. - """ +def _activity_gate() -> Tuple[int, float, float]: + """The activity gate shared by the anchor and textural-range meters: the grid is tiled into + sectors of 2x2 blocks of activity_block cells, and a sector votes when its four block means + span more than activity_gate_density (Boyack & Juenger, US 5,724,456). Block means average + grain out, and a flat sector beside a textured one stays out because only its own blocks are + compared. Every cell votes when fewer than activity_min_fraction of the sectors pass.""" from negpy.features.exposure.models import EXPOSURE_CONSTANTS c = EXPOSURE_CONSTANTS - b = int(c["activity_block"]) - h, w = lum.shape - hs, ws = (h // (2 * b)) * 2 * b, (w // (2 * b)) * 2 * b - if hs == 0 or ws == 0: - return lum.ravel() - core = lum[:hs, :ws] - blocks = core.reshape(hs // b, b, ws // b, b).mean(axis=(1, 3)) - sectors = blocks.reshape(hs // (2 * b), 2, ws // (2 * b), 2) - active = (sectors.max(axis=(1, 3)) - sectors.min(axis=(1, 3))) > float(c["activity_gate_density"]) - if active.mean() < float(c["activity_min_fraction"]): - return lum.ravel() - mask = np.repeat(np.repeat(active, 2 * b, axis=0), 2 * b, axis=1) - return core[mask] - - -def _textured_norm_luma(grid: ImageBuffer, bounds: LogNegativeBounds) -> np.ndarray: - """Luma of the grid normalized against `bounds`, textured cells only.""" + return int(c["activity_block"]), float(c["activity_gate_density"]), float(c["activity_min_fraction"]) + + +def _meter_norm(bounds: LogNegativeBounds) -> list: + """Per channel (floor, span, and whether each is a float64 operand): NumPy normalizes in + float64 when the bounds hold NumPy float64 scalars, in float32 for Python floats.""" epsilon = 1e-6 - norm = np.empty_like(grid) + out = [] for ch in range(3): f = bounds.floors[ch] denom = bounds.ceils[ch] - f if abs(denom) < epsilon: denom = epsilon if denom >= 0 else -epsilon - norm[:, :, ch] = (grid[:, :, ch] - f) / denom - return _textured_cells(LUMA_R * norm[:, :, 0] + LUMA_G * norm[:, :, 1] + LUMA_B * norm[:, :, 2]) + wide = (np.result_type(np.float32, f) == np.float64, np.result_type(np.float32, denom) == np.float64) + out.append((float(f), bool(wide[0]), float(denom), bool(wide[1]))) + return out def measure_shadow_point_from_log( @@ -600,8 +363,10 @@ def measure_shadow_point_from_log( img_log = img_log[y1:y2, x1:x2] if analysis_buffer > 0: img_log = get_analysis_crop(img_log, analysis_buffer) - lum = _textured_norm_luma(_block_median_grid(img_log), bounds) - return float(np.percentile(lum, float(EXPOSURE_CONSTANTS["shadow_reach_percentile"]))) + grid = np.ascontiguousarray(_block_median_grid(img_log), dtype=np.float32) + return float( + negpy_core.textured_percentile(grid, _meter_norm(bounds), _activity_gate(), float(EXPOSURE_CONSTANTS["shadow_reach_percentile"])) + ) def measure_highlight_point_from_log( @@ -619,8 +384,10 @@ def measure_highlight_point_from_log( img_log = img_log[y1:y2, x1:x2] if analysis_buffer > 0: img_log = get_analysis_crop(img_log, analysis_buffer) - lum = _textured_norm_luma(_block_median_grid(img_log), bounds) - return float(np.percentile(lum, float(EXPOSURE_CONSTANTS["highlight_hold_percentile"]))) + grid = np.ascontiguousarray(_block_median_grid(img_log), dtype=np.float32) + return float( + negpy_core.textured_percentile(grid, _meter_norm(bounds), _activity_gate(), float(EXPOSURE_CONSTANTS["highlight_hold_percentile"])) + ) def measure_anchor_from_log( @@ -657,11 +424,8 @@ def measure_anchor_from_log( if analysis_buffer > 0: img_log = get_analysis_crop(img_log, analysis_buffer) - lum = _textured_norm_luma(_block_median_grid(img_log), bounds) - clip = float(EXPOSURE_CONSTANTS["anchor_trim_clip"]) - lo, hi = np.percentile(lum, [clip, 100.0 - clip]) - inner = lum[(lum >= lo) & (lum <= hi)] - measured = 0.5 * (float(inner.mean()) + 0.5 * (float(lo) + float(hi))) + grid = np.ascontiguousarray(_block_median_grid(img_log), dtype=np.float32) + measured = negpy_core.textured_anchor(grid, _meter_norm(bounds), _activity_gate(), float(EXPOSURE_CONSTANTS["anchor_trim_clip"])) a = float(EXPOSURE_CONSTANTS["assumed_anchor"]) if assumed is None else float(assumed) strength = float(EXPOSURE_CONSTANTS["anchor_meter_strength"]) @@ -703,12 +467,8 @@ def measure_textural_range_from_log( if analysis_buffer > 0: img_log = get_analysis_crop(img_log, analysis_buffer) - img_log = _block_median_grid(img_log) - - lum = _textured_cells(LUMA_R * img_log[:, :, 0] + LUMA_G * img_log[:, :, 1] + LUMA_B * img_log[:, :, 2]) - clip = float(EXPOSURE_CONSTANTS["textural_range_clip"]) - lo, hi = np.percentile(lum, [clip, 100.0 - clip]) - return float(abs(hi - lo)) + grid = np.ascontiguousarray(_block_median_grid(img_log), dtype=np.float32) + return negpy_core.textured_range(grid, _activity_gate(), float(EXPOSURE_CONSTANTS["textural_range_clip"])) def measure_textural_range( @@ -770,7 +530,7 @@ def analysis_grid( grid = int(EXPOSURE_CONSTANTS["analysis_grid"]) if max(h, w) > grid: scale = grid / float(max(h, w)) - image = cv2.resize(image, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_AREA) + image = negpy_core.resize_area(np.ascontiguousarray(image, dtype=np.float32), (max(1, round(w * scale)), max(1, round(h * scale)))) if rotation: image = np.rot90(image, k=rotation) @@ -819,11 +579,9 @@ def contrast_mask_plane( return np.zeros(image.shape[:2], dtype=np.float32), 0.5 val = normalize_log_image(unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), bounds) - lum = LUMA_R * val[:, :, 0] + LUMA_G * val[:, :, 1] + LUMA_B * val[:, :, 2] - sigma = min(max(spacer, MASK_SPACER_MIN), MASK_SPACER_MAX) * 0.01 * min(lum.shape[:2]) - blurred = cv2.GaussianBlur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), sigma, borderType=cv2.BORDER_REPLICATE) - centre = float(blurred.mean()) - return blurred - centre, centre + sigma = min(max(spacer, MASK_SPACER_MIN), MASK_SPACER_MAX) * 0.01 * min(val.shape[:2]) + plane, centre = negpy_core.contrast_mask_tail(np.ascontiguousarray(val, dtype=np.float32), sigma) + return plane, float(centre) def diffusion_grid( @@ -841,9 +599,9 @@ def diffusion_grid( """The unmixed light blurred by `DIFFUSION_SPREAD`, as log density on `analysis_grid`. It takes no bounds: the print's normalization is a per-channel gain on that light.""" image = analysis_grid(image, rotation, fine_rotation, flip_horizontal, flip_vertical, distortion_k1, converge_v, converge_h, roi_norm) - light = np.ascontiguousarray(10.0 ** unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), dtype=np.float32) - sigma = DIFFUSION_SPREAD * 0.01 * min(light.shape[:2]) - return np.log10(cv2.GaussianBlur(light, (0, 0), sigma, borderType=cv2.BORDER_REPLICATE)) + unmixed = np.ascontiguousarray(unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), dtype=np.float32) + sigma = DIFFUSION_SPREAD * 0.01 * min(unmixed.shape[:2]) + return negpy_core.diffusion_tail(unmixed, sigma) def diffusion_plane(grid: np.ndarray, bounds: LogNegativeBounds, panchromatic: bool = False) -> Optional[np.ndarray]: @@ -853,19 +611,18 @@ def diffusion_plane(grid: np.ndarray, bounds: LogNegativeBounds, panchromatic: b return None val = normalize_log_image(grid, bounds) if panchromatic: - lum = LUMA_R * val[:, :, 0] + LUMA_G * val[:, :, 1] + LUMA_B * val[:, :, 2] - val = np.dstack([lum, lum, lum]) + val = negpy_core.luma_planes(np.ascontiguousarray(val, dtype=np.float32)) return np.ascontiguousarray(val, dtype=np.float32) def normalize_log_image(img_log: ImageBuffer, bounds: LogNegativeBounds) -> ImageBuffer: """ - Stretches log-data to fit [0, 1]. + Log -> ~0.0-1.0 (linear stretch, unclamped: out-of-bounds densities are rolled off by + the downstream characteristic curve). Supports both f < c (negative) and f > c (positive). """ - floors = np.ascontiguousarray(np.array(bounds.floors, dtype=np.float32)) - ceils = np.ascontiguousarray(np.array(bounds.ceils, dtype=np.float32)) - - return ensure_image(_normalize_log_image_jit(np.ascontiguousarray(img_log.astype(np.float32)), floors, ceils)) + floors = [float(v) for v in np.asarray(bounds.floors, dtype=np.float32)] + ceils = [float(v) for v in np.asarray(bounds.ceils, dtype=np.float32)] + return ensure_image(negpy_core.normalize_log_image(np.ascontiguousarray(img_log, dtype=np.float32), floors, ceils)) def _sample_log_bounds( @@ -889,13 +646,13 @@ def _sample_log_bounds( margin = -percentile_clip p_low, p_high = np.float64(clip), np.float64(100.0 - clip) - def _pct(p) -> list: - if sorted_grid is not None: - return [float(v) for v in percentile_from_sorted(sorted_grid, p)] - return [float(np.percentile(img_log[:, :, ch], p)) for ch in range(3)] - - floors = _pct(p_low) - ceils = _pct(p_high) + if sorted_grid is not None: + floors = [float(v) for v in percentile_from_sorted(sorted_grid, p_low)] + ceils = [float(v) for v in percentile_from_sorted(sorted_grid, p_high)] + else: + per_channel = negpy_core.channel_percentiles(np.ascontiguousarray(img_log, dtype=np.float32), [float(p_low), float(p_high)]) + floors = [lo for lo, _ in per_channel] + ceils = [hi for _, hi in per_channel] if margin > 0.0: # Expand outward; per-channel sign handles both f < c and f > c. @@ -931,53 +688,22 @@ def _same_pixel_color_floor_refs( from negpy.features.exposure.models import EXPOSURE_CONSTANTS c = EXPOSURE_CONSTANTS - q = float(c["neutral_axis_chroma_quantile"]) - cap = float(c["neutral_axis_chroma_cap"]) - min_px = int(c["neutral_axis_min_pixels"]) - width = float(c["color_bounds_band_width"]) - epsilon = 1e-6 - - flat = img_log.reshape(-1, 3).astype(np.float64) - base = np.asarray(base_refs, dtype=np.float64) - norm = np.empty_like(flat) - for ch in range(3): - denom = luma_ceils[ch] - luma_floors[ch] - if abs(denom) < epsilon: - denom = epsilon if denom >= 0 else -epsilon - norm[:, ch] = (flat[:, ch] - luma_floors[ch]) / denom - luma = LUMA_R * norm[:, 0] + LUMA_G * norm[:, 1] + LUMA_B * norm[:, 2] - - clip = max(0.00001, min(50.0 - width, float(color_clip))) - lo, hi = np.percentile(luma, [clip, clip + width]) - band = (luma >= lo) & (luma <= hi) - if int(band.sum()) < min_px: - return None - d = flat[band] - base[None, :] - - def _select(gamma: np.ndarray) -> Optional[Tuple[np.ndarray, float]]: - g = gamma.copy() - g[np.abs(g) < epsilon] = epsilon - chroma = _rms_chroma(d / g[None, :]) - thr = float(np.quantile(chroma, q)) - sel = chroma <= thr - if int(sel.sum()) < min_px: - return None - return sel, float(np.median(chroma[sel])) - - spans = np.array([luma_floors[ch] - base_refs[ch] for ch in range(3)], dtype=np.float64) - first = _select(spans) - # Pass-1 loose cap: a homogeneous colored cluster would otherwise be self-normalized to - # zero chroma by pass 2 and read as neutral. - if first is None or first[1] > float(c["neutral_axis_first_pass_cap"]): - return None - provisional = np.median(d[first[0]], axis=0) - if np.any(np.abs(provisional) < epsilon): - return None - second = _select(provisional) - if second is None or second[1] > cap: - return None - refs = base + np.median(d[second[0]], axis=0) - return (float(refs[0]), float(refs[1]), float(refs[2])) + constants = ( + float(c["neutral_axis_chroma_quantile"]), + float(c["neutral_axis_chroma_cap"]), + float(c["neutral_axis_first_pass_cap"]), + int(c["neutral_axis_min_pixels"]), + float(c["color_bounds_band_width"]), + ) + refs = negpy_core.same_pixel_color_floor_refs( + np.ascontiguousarray(img_log, dtype=np.float32), + [float(v) for v in luma_floors], + [float(v) for v in luma_ceils], + [float(v) for v in base_refs], + float(color_clip), + constants, + ) + return None if refs is None else (refs[0], refs[1], refs[2]) def analyze_log_exposure_bounds( diff --git a/negpy/features/exposure/print_lut.py b/negpy/features/exposure/print_lut.py index ffc4bc738..ba42aba15 100644 --- a/negpy/features/exposure/print_lut.py +++ b/negpy/features/exposure/print_lut.py @@ -11,7 +11,7 @@ from typing import Optional, Tuple import numpy as np -from numba import njit # type: ignore +import negpy_core from negpy.features.lab.logic import LUM_B, LUM_G, LUM_R from negpy.kernel.system.paths import get_resource_path @@ -69,59 +69,14 @@ def load_print_lut(key: str) -> PrintLut: ) -@njit(inline="always") def tetra_lookup(table: np.ndarray, e_max: np.ndarray, e0: float, e1: float, e2: float) -> Tuple[float, float, float]: """Tetrahedral interpolation, clamped to the table. exposure.wgsl's lut_tetra mirrors it.""" - n = table.shape[0] - 1 - x = min(max(e0 / e_max[0], 0.0), 1.0) * n - y = min(max(e1 / e_max[1], 0.0), 1.0) * n - z = min(max(e2 / e_max[2], 0.0), 1.0) * n - i = min(int(x), n - 1) - j = min(int(y), n - 1) - k = min(int(z), n - 1) - fx = x - i - fy = y - j - fz = z - k - out = np.empty(3, dtype=np.float64) - for c in range(3): - c000 = table[i, j, k, c] - c111 = table[i + 1, j + 1, k + 1, c] - if fx >= fy: - if fy >= fz: - c100 = table[i + 1, j, k, c] - c110 = table[i + 1, j + 1, k, c] - out[c] = c000 + fx * (c100 - c000) + fy * (c110 - c100) + fz * (c111 - c110) - elif fx >= fz: - c100 = table[i + 1, j, k, c] - c101 = table[i + 1, j, k + 1, c] - out[c] = c000 + fx * (c100 - c000) + fz * (c101 - c100) + fy * (c111 - c101) - else: - c001 = table[i, j, k + 1, c] - c101 = table[i + 1, j, k + 1, c] - out[c] = c000 + fz * (c001 - c000) + fx * (c101 - c001) + fy * (c111 - c101) - else: - if fz >= fy: - c001 = table[i, j, k + 1, c] - c011 = table[i, j + 1, k + 1, c] - out[c] = c000 + fz * (c001 - c000) + fy * (c011 - c001) + fx * (c111 - c011) - elif fz >= fx: - c010 = table[i, j + 1, k, c] - c011 = table[i, j + 1, k + 1, c] - out[c] = c000 + fy * (c010 - c000) + fz * (c011 - c010) + fx * (c111 - c011) - else: - c010 = table[i, j + 1, k, c] - c110 = table[i + 1, j + 1, k, c] - out[c] = c000 + fy * (c010 - c000) + fx * (c110 - c010) + fz * (c111 - c110) - return out[0], out[1], out[2] - - -# Plain Python calls this twin: an inline kernel has no cache, so it would compile on every launch. -_tetra_lookup_cached = njit(cache=True)(tetra_lookup.py_func) + return negpy_core.tetra_lookup(np.ascontiguousarray(table, np.float32), np.ascontiguousarray(e_max, np.float32), e0, e1, e2) def lut_black_luminance(lut: PrintLut, d_min_rgb: Tuple[float, float, float], black_rgb: Tuple[float, float, float]) -> float: """Luminance of the print's black (density black_rgb), which black point compensation maps to 0.""" - rgb = _tetra_lookup_cached(lut.table, lut.e_max, *(b - d for b, d in zip(black_rgb, d_min_rgb))) + rgb = tetra_lookup(lut.table, lut.e_max, *(b - d for b, d in zip(black_rgb, d_min_rgb))) return float(sum(w * 10.0**-d * v for w, d, v in zip((LUM_R, LUM_G, LUM_B), d_min_rgb, rgb))) diff --git a/negpy/features/exposure/processor.py b/negpy/features/exposure/processor.py index f0539c692..c1383d13c 100644 --- a/negpy/features/exposure/processor.py +++ b/negpy/features/exposure/processor.py @@ -1,5 +1,6 @@ from typing import Any, Dict, Optional +import negpy_core import numpy as np from negpy.domain.interfaces import PipelineContext @@ -62,10 +63,9 @@ def __init__(self, config: ProcessConfig): self.config = config def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: - epsilon = 1e-6 # No upper clamp, mirroring normalization.wgsl, which clamps only the low side. Values # above 1.0 occur only with flat-field gain and must match the GPU. - img_log = np.log10(np.clip(np.nan_to_num(image, nan=epsilon, posinf=1.0, neginf=epsilon), epsilon, None)) + img_log = negpy_core.log_density_open(np.ascontiguousarray(image, dtype=np.float32)) # Shared prefilter, once for all five meters, with the ROI and buffer applied here. A # freehand analysis_rect overrides the crop ROI and centered buffer for the metered region. an_roi, an_buffer = resolve_analysis_region( @@ -303,11 +303,12 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: if context.process_mode == ProcessMode.BW: # Panchromatic response: collapse to a single density BEFORE the curve, so the curve # shapes one channel instead of mixing three. - lum = get_luminance(image) - image = np.stack([lum, lum, lum], axis=-1) + image = negpy_core.gray_to_rgb(get_luminance(image)) local_maps = self._build_local_maps(image, context) - ev_map = None if local_maps is None else np.ascontiguousarray(local_maps[:, :, 0]) + if local_maps is not None: + local_maps = np.ascontiguousarray(local_maps, dtype=np.float32) + ev_map = None if local_maps is None else negpy_core.extract_channel(local_maps, 0) # The mask is a print-exposure input like a dodge, so it rides the same map. mask_ev = contrast_mask_ev( context.metrics.get("contrast_mask_plane"), @@ -317,13 +318,13 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: context.metrics.get("contrast_mask_roi"), ) if mask_ev is not None: - ev_map = mask_ev if ev_map is None else np.ascontiguousarray(ev_map + mask_ev) + ev_map = mask_ev if ev_map is None else negpy_core.add(ev_map, mask_ev) grade_map = None - if local_maps is not None and local_maps[:, :, 1].any(): - grade_map = local_grade_factor_map(np.ascontiguousarray(local_maps[:, :, 1]), self.config.grade) + if local_maps is not None and negpy_core.channel_any(local_maps, 1): + grade_map = local_grade_factor_map(negpy_core.extract_channel(local_maps, 1), self.config.grade) flash_map = None - if local_maps is not None and local_maps[:, :, 2].any(): - flash_map = np.ascontiguousarray(local_maps[:, :, 2]) + if local_maps is not None and negpy_core.channel_any(local_maps, 2): + flash_map = negpy_core.extract_channel(local_maps, 2) diff_kw: Dict[str, Any] = {} diff_plane = context.metrics.get("diffusion_plane") if self.config.diffusion > 0.0 and diff_plane is not None: @@ -405,9 +406,7 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: ) if context.process_mode == ProcessMode.BW: - res = get_luminance(img_pos) - res = np.stack([res, res, res], axis=-1) - return res + return negpy_core.gray_to_rgb(get_luminance(img_pos)) return img_pos @@ -432,14 +431,12 @@ def _process_flat(self, image: ImageBuffer, context: PipelineContext) -> ImageBu is_bw = context.process_mode == ProcessMode.BW if is_bw: - lum = get_luminance(image) - image = np.stack([lum, lum, lum], axis=-1) + image = negpy_core.gray_to_rgb(get_luminance(image)) stock_matrix = None if is_bw else resolve_flat_matrix(effective_paper_profile(self.config.paper_profile, context.process_mode)) img_pos = apply_flat_curve(image, gain, lift, cmy_offsets=cmy_offsets, stock_matrix=stock_matrix) if is_bw: - res = get_luminance(img_pos) - return np.stack([res, res, res], axis=-1) + return negpy_core.gray_to_rgb(get_luminance(img_pos)) return img_pos diff --git a/negpy/features/exposure/shaders/exposure.wgsl b/negpy/features/exposure/shaders/exposure.wgsl index a07a364a2..78b108a1c 100644 --- a/negpy/features/exposure/shaders/exposure.wgsl +++ b/negpy/features/exposure/shaders/exposure.wgsl @@ -249,8 +249,8 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } let eps = 1e-6; - // Asymmetric H&D print curve (toe-linear-shoulder); mirrors the CPU - // _apply_print_curve_kernel. toe -> shadow (paper-black) bound, shoulder -> + // Asymmetric H&D print curve (toe-linear-shoulder); mirrors the CPU print curve + // (negpy_core print_curve.rs). toe -> shadow (paper-black) bound, shoulder -> // highlight (paper-white) bound. a_toe_base/a_sh_base carry shadow/highlight // sharpness; width sets gentleness, slider sets roll-off height. let toe_w3 = vec3(params.toe_width_r, params.toe_width_g, params.toe_width_b); @@ -374,7 +374,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } // Separation Damping: chroma-selective dye separation, in place of the - // frame-wide saturation matrix. Mirrors _apply_print_curve_kernel. + // frame-wide saturation matrix. Mirrors negpy_core print_curve.rs. if (params.sep_damping > 0.0) { let s = dens - d_min_rgb; let s_mean = (s.x + s.y + s.z) / 3.0; diff --git a/negpy/features/finish/logic.py b/negpy/features/finish/logic.py index dec7113a4..9ba36ce16 100644 --- a/negpy/features/finish/logic.py +++ b/negpy/features/finish/logic.py @@ -1,40 +1,17 @@ +import negpy_core import numpy as np + from negpy.domain.types import ImageBuffer from negpy.kernel.image.validation import ensure_image # Fixed seed: one filed carrier per darkroom, identical every frame and on both paths. _CARRIER_SEED = 1898 -# All CARRIER_* below are fractions of the rebate width and mirrored as WGSL literals in -# finish.wgsl. The picture-side boundary is the camera's film gate, machine-cut, so it -# gets JITTER * INNER_ROUGH and no slider. The paper-side one is the hand-filed aperture. +# Profile samples per edge; the other carrier constants live in negpy_core/src/finish.rs, +# mirrored as WGSL literals in finish.wgsl. CARRIER_SAMPLES = 2048 -CARRIER_JITTER = 0.24 -CARRIER_INNER_ROUGH = 0.2 -CARRIER_OUTER_JITTER = 0.4 -CARRIER_CORNER = 1.4 -CARRIER_GATE_CORNER = 0.2 -# The film never sits centered in the carrier. -CARRIER_OFFSET_X = -0.2 -CARRIER_OFFSET_Y = -0.25 -CARRIER_MARGIN = 0.7 -# The gate prints soft; filed metal is a hard stop, which is what reads as filed. -CARRIER_SOFT = 0.22 -CARRIER_FILED_SOFT = 0.06 -CARRIER_FLARE_DEPTH = 0.25 -CARRIER_FLARE_GAIN = 0.6 -CARRIER_FLARE_BASE = 0.35 -# A 1-D profile is a height field, so it cannot overhang or shed a fleck. The 2-D field -# displacing the distance field is what makes the edge read as torn metal. Hash noise -# rather than a library, because WGSL has to reproduce it bit for bit. -CARRIER_NOISE_SEED = 0x51ED270B -CARRIER_NOISE_CELL = 0.35 -CARRIER_NOISE_OCTAVES = 4 -CARRIER_NOISE_OUTER = 0.12 -CARRIER_NOISE_INNER = 0.02 # Tone table sampled at t = u**POWER, dense in the toe. CARRIER_TONE_SAMPLES = 64 CARRIER_TONE_POWER = 3.0 -_CARRIER_BLOCK_PIXELS = 1 << 19 _carrier_cache: np.ndarray | None = None @@ -87,43 +64,6 @@ def carrier_profiles() -> np.ndarray: return _carrier_cache -def _hash_lattice(ix: np.ndarray, iy: np.ndarray) -> np.ndarray: - """Lattice value in [-1, 1). u32 wrap-around only, so WGSL reproduces it exactly.""" - h = (ix * np.uint32(0x27D4EB2D)) ^ (iy * np.uint32(0x165667B1)) ^ np.uint32(CARRIER_NOISE_SEED) - h ^= h >> np.uint32(15) - h = h * np.uint32(0x2C1B3C6D) - h ^= h >> np.uint32(13) - h = h * np.uint32(0x297A2D39) - h ^= h >> np.uint32(16) - return (h >> np.uint32(8)).astype(np.float32) * np.float32(2.0 / 16777216.0) - np.float32(1.0) - - -def _value_noise(x: np.ndarray, y: np.ndarray) -> np.ndarray: - fx, fy = np.floor(x), np.floor(y) - ux, uy = (x - fx), (y - fy) - ux = ux * ux * (3.0 - 2.0 * ux) - uy = uy * uy * (3.0 - 2.0 * uy) - i0, j0 = fx.astype(np.int64).astype(np.uint32), fy.astype(np.int64).astype(np.uint32) - i1, j1 = i0 + np.uint32(1), j0 + np.uint32(1) - lo = _hash_lattice(i0, j0) - lo = lo + (_hash_lattice(i1, j0) - lo) * ux - hi = _hash_lattice(i0, j1) - hi = hi + (_hash_lattice(i1, j1) - hi) * ux - return lo + (hi - lo) * uy - - -def carrier_noise(x: np.ndarray, y: np.ndarray) -> np.ndarray: - """fBm value noise in [-1, 1]; x/y in noise cells. Mirrored in finish.wgsl.""" - total = np.zeros(np.broadcast_shapes(x.shape, y.shape), dtype=np.float32) - amp, freq, norm = np.float32(1.0), np.float32(1.0), np.float32(0.0) - for _ in range(CARRIER_NOISE_OCTAVES): - total += amp * _value_noise(x * freq, y * freq) - norm += amp - amp *= np.float32(0.5) - freq *= np.float32(2.0) - return total / norm - - def carrier_tone_exposures() -> np.ndarray: """Exposure fraction of each tone-table entry, 0 (bare paper) to 1 (full rebate).""" return np.linspace(0.0, 1.0, CARRIER_TONE_SAMPLES, dtype=np.float32) ** np.float32(CARRIER_TONE_POWER) @@ -134,14 +74,6 @@ def linear_carrier_tone() -> np.ndarray: return np.repeat((1.0 - carrier_tone_exposures())[:, None], 3, axis=1).astype(np.float32) -def carrier_tone_lookup(tone: np.ndarray, t: np.ndarray) -> np.ndarray: - """(..., 3) paper-relative color at exposure fraction t.""" - # cbrt inverts CARRIER_TONE_POWER = 3. - u = np.cbrt(np.clip(t, 0.0, 1.0)) * np.float32(CARRIER_TONE_SAMPLES - 1) - grid = np.arange(CARRIER_TONE_SAMPLES, dtype=np.float32) - return np.stack([np.interp(u, grid, tone[:, c]).astype(np.float32) for c in range(3)], axis=-1) - - def apply_carrier( img: ImageBuffer, width_px: float, @@ -158,130 +90,11 @@ def apply_carrier( """ if width_px <= 0.0: return img - - h, w = img.shape[:2] - tone = linear_carrier_tone() if tone is None else np.asarray(tone, dtype=np.float32) - band = ( - int( - np.ceil( - width_px - * ( - CARRIER_MARGIN - + CARRIER_CORNER * corner - + 1.0 - + CARRIER_JITTER - + CARRIER_NOISE_OUTER - + CARRIER_NOISE_INNER - + max(abs(CARRIER_OFFSET_X), abs(CARRIER_OFFSET_Y)) - ) - + max(1.0, width_px * CARRIER_SOFT) - ) - ) - + 1 + tone = linear_carrier_tone() if tone is None else np.ascontiguousarray(tone, dtype=np.float32) + paper32 = tuple(np.float32(v) for v in paper) + return negpy_core.apply_carrier( + np.ascontiguousarray(img, dtype=np.float32), width_px, rough, flare, corner, paper32, tone, carrier_profiles() ) - out = img.copy() - if 2 * band >= h or 2 * band >= w: - regions = [(0, h, 0, w, (0, 1, 2, 3))] - else: - # An edge's effect ends within `band`. - hb, wb = h - band, w - band - regions = [ - (0, band, 0, band, (0, 2)), - (0, band, wb, w, (0, 3)), - (hb, h, 0, band, (1, 2)), - (hb, h, wb, w, (1, 3)), - (0, band, band, wb, (0,)), - (hb, h, band, wb, (1,)), - (band, hb, 0, band, (2,)), - (band, hb, wb, w, (3,)), - ] - for y0, y1, x0, x1, sides in regions: - rows = max(1, _CARRIER_BLOCK_PIXELS // max(1, x1 - x0)) - for b0 in range(y0, y1, rows): - b1 = min(b0 + rows, y1) - block = img[b0:b1, x0:x1] - out[b0:b1, x0:x1] = _carrier_block(block, b0, x0, h, w, width_px, rough, flare, corner, paper, tone, sides) - return ensure_image(np.clip(out, 0.0, 1.0)) - - -def _carrier_block( - img: np.ndarray, - y0: int, - x0: int, - h: int, - w: int, - width_px: float, - rough: float, - flare: float, - corner: float, - paper: tuple[float, float, float], - tone: np.ndarray, - sides: tuple[int, ...], -) -> np.ndarray: - profiles = carrier_profiles() - soft = max(1.0, width_px * CARRIER_SOFT) - soft_filed = max(1.0, width_px * CARRIER_FILED_SOFT) - margin = width_px * CARRIER_MARGIN - radius = width_px * CARRIER_CORNER * corner - gate_radius = width_px * CARRIER_GATE_CORNER - cell = max(1.0, width_px * CARRIER_NOISE_CELL) - py = np.arange(y0, y0 + img.shape[0], dtype=np.float32)[:, None] - px = np.arange(x0, x0 + img.shape[1], dtype=np.float32)[None, :] - sx = (px + 0.5) / np.float32(w) - sy = (py + 0.5) / np.float32(h) - end_x = np.minimum(px, (w - 1.0) - px) - end_y = np.minimum(py, (h - 1.0) - py) - qx = px - np.float32(width_px * CARRIER_OFFSET_X) - qy = py - np.float32(width_px * CARRIER_OFFSET_Y) - fend_x = np.minimum(qx, (w - 1.0) - qx) - fend_y = np.minimum(qy, (h - 1.0) - qy) - n2 = carrier_noise(px / cell, py / cell) - - def prof(row: int, s: np.ndarray) -> np.ndarray: - return profiles[row, np.minimum((s * CARRIER_SAMPLES).astype(np.int32), CARRIER_SAMPLES - 1)] - - def arc(r: float, at: float, end: np.ndarray) -> np.ndarray | np.float32: - """Corner retreat of a boundary `at` px from the print edge, from its own corner.""" - if r <= 0.0: - return np.float32(0.0) - x = np.clip(r - (end - at), 0.0, r) - return r - np.sqrt(np.maximum(r * r - x * x, 0.0)) - - def bounds(edge: int, s: np.ndarray, end: np.ndarray, fend: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - outer = margin + width_px * rough * (CARRIER_OUTER_JITTER * prof(edge + 4, s) + CARRIER_NOISE_OUTER * n2) - wobble = CARRIER_JITTER * CARRIER_INNER_ROUGH * prof(edge, s) + CARRIER_NOISE_INNER * n2 - inner = margin + width_px * (1.0 + wobble) + arc(gate_radius, margin + width_px, end) - return outer + arc(radius, margin, fend), inner - - # (edge, profile position, gate end, filed end, gate distance, filed distance) - edges = ( - (0, sx, end_x, fend_x, py, qy), - (1, sx, end_x, fend_x, (h - 1.0) - py, (h - 1.0) - qy), - (2, sy, end_y, fend_y, px, qx), - (3, sy, end_y, fend_y, (w - 1.0) - px, (w - 1.0) - qx), - ) - reach = max(1.0, width_px * CARRIER_FLARE_DEPTH) - # Floored |noise|, not a one-sided gate: that left whole edges with no flare. - flare_amp = flare * CARRIER_FLARE_GAIN * (CARRIER_FLARE_BASE + (1.0 - CARRIER_FLARE_BASE) * np.abs(n2)) - a_in = np.ones(img.shape[:2], dtype=np.float32) - a_out = np.ones(img.shape[:2], dtype=np.float32) - peaks, gates = [], [] - for e, s, end, fend, d, fd in (edges[k] for k in sides): - outer, inner = bounds(e, s, end, fend) - a_in = a_in * np.clip((d - inner) / soft + 0.5, 0.0, 1.0) - a_out = a_out * np.clip((fd - outer) / soft_filed + 0.5, 0.0, 1.0) - peaks.append(np.clip(1.0 - np.abs(fd - outer) / reach, 0.0, 1.0) ** 2) - gates.append(np.clip(1.0 + (fd - outer) / reach, 0.0, 1.0)) - lit = np.float32(0.0) - if flare > 0.0: - # The bevel spans only the aperture, so the other edges gate each edge's flare. - for e in range(len(peaks)): - lit = lit + peaks[e] * np.prod([gates[k] for k in range(len(gates)) if k != e] or [np.float32(1.0)], axis=0) - lit = flare_amp * lit - - rebate = np.asarray(paper, dtype=np.float32) * carrier_tone_lookup(tone, a_out + lit) - shown = (a_in * a_out)[..., None] - return img * shown + rebate * (1.0 - shown) def apply_vignette(img: ImageBuffer, stops: float, size: float, roundness: float = 0.0) -> ImageBuffer: diff --git a/negpy/features/finish/shaders/finish.wgsl b/negpy/features/finish/shaders/finish.wgsl index 068af2f15..1b78cdff1 100644 --- a/negpy/features/finish/shaders/finish.wgsl +++ b/negpy/features/finish/shaders/finish.wgsl @@ -23,7 +23,7 @@ struct FinishUniforms { // (CARRIER_TONE_SAMPLES, 3) from rebate_tone(). @group(0) @binding(4) var carrier_tone: array; -// Every CARRIER_* below mirrors logic.py — keep in sync or preview drifts from export. +// Every CARRIER_* below mirrors negpy_core/src/finish.rs — keep in sync or preview drifts from export. const CARRIER_SAMPLES: i32 = 2048; const CARRIER_JITTER: f32 = 0.24; const CARRIER_INNER_ROUGH: f32 = 0.2; @@ -152,7 +152,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { color = color * exp2(-params.vignette_stops * factor); } - // Filed-carrier rebate, mirroring apply_carrier() in logic.py. + // Filed-carrier rebate, mirroring apply_carrier() in negpy_core/src/finish.rs. if (params.carrier_width_px > 0.0) { let soft = max(1.0, params.carrier_width_px * CARRIER_SOFT); let sx = (px.x + 0.5) / full.x; diff --git a/negpy/features/flatfield/logic.py b/negpy/features/flatfield/logic.py index c5a0d6689..d7db4e009 100644 --- a/negpy/features/flatfield/logic.py +++ b/negpy/features/flatfield/logic.py @@ -1,7 +1,7 @@ import hashlib from typing import Callable, Dict, NamedTuple, Optional, Tuple -import cv2 +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer @@ -44,6 +44,7 @@ # pixels at _CLIP_LEVEL in some channel means the reference clipped. _CLIP_LEVEL = 0.98 _CLIP_FRACTION = 0.001 +_LIT = (_LIT_PERCENTILE, _LIT_FRACTION, _MIN_LIT_FRACTION) class GainEntry(NamedTuple): @@ -87,19 +88,12 @@ def _resolve(profile_id: str) -> Optional[GainEntry]: def compute_gain(reference: ImageBuffer) -> np.ndarray: """Per-channel gain = mean(blur) / blur over the lit area, on a downsampled copy.""" - ref = work_copy(reference) + ref = np.ascontiguousarray(work_copy(reference), dtype=np.float32) sigma = max(ref.shape[:2]) / _BLUR_DIVISOR - eps = 1e-4 - lit = _lit_mask(ref) - # Normalized convolution: a dark carrier edge in the reference would otherwise bleed into - # the blur and overcorrect the frame next to it. - num = cv2.GaussianBlur(ref * lit[..., None], (0, 0), sigmaX=sigma, sigmaY=sigma) - den = cv2.GaussianBlur(lit, (0, 0), sigmaX=sigma, sigmaY=sigma) - blur = np.clip(num / np.clip(den, eps, None)[..., None], eps, None) - blur = _fill_from_nearest(blur, den >= _MIN_LIT_WEIGHT) - means = (blur * lit[..., None]).sum(axis=(0, 1)) / lit.sum() - gain = means[None, None, :] / blur - return np.clip(gain, _GAIN_MIN, _GAIN_MAX).astype(np.float32) + gain, dark = negpy_core.compute_gain(ref, _LIT, sigma, 1e-4, _MIN_LIT_WEIGHT, (_GAIN_MIN, _GAIN_MAX)) + if dark: + _warn_dark() + return gain def work_copy(reference: ImageBuffer, long_edge: int = _GAIN_WORK_SIZE) -> np.ndarray: @@ -108,7 +102,7 @@ def work_copy(reference: ImageBuffer, long_edge: int = _GAIN_WORK_SIZE) -> np.nd h, w = ref.shape[:2] scale = min(1.0, long_edge / max(h, w)) if scale < 1.0: - ref = cv2.resize(ref, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_AREA) + ref = negpy_core.resize_area(np.ascontiguousarray(ref, dtype=np.float32), (max(1, round(w * scale)), max(1, round(h * scale)))) return ref @@ -119,26 +113,20 @@ def check_copy(reference: ImageBuffer) -> np.ndarray: def _lit_mask(ref: np.ndarray) -> np.ndarray: """1 where the reference sees the light source, 0 on carrier or mask edges in frame.""" - lum = ref.mean(axis=2) - lit = (lum > _LIT_FRACTION * np.percentile(lum, _LIT_PERCENTILE)).astype(np.uint8) - # Drop the soft transition at the carrier edge: a holder's shadow edge spans about two pixels - # here above the fraction. Outside the image counts as dark, so a partly lit carrier lip on the - # outermost rows or columns goes too. - lit = cv2.erode(lit, np.ones((5, 5), np.uint8), borderType=cv2.BORDER_CONSTANT, borderValue=0) - if lit.sum() < _MIN_LIT_FRACTION * lit.size: - logger.warning("Flat-field: reference is almost all dark; computing the gain over the whole frame") - return np.ones(lum.shape, np.float32) + lit = negpy_core.lit_mask(np.ascontiguousarray(ref, dtype=np.float32), _LIT) + if lit is None: + _warn_dark() + return np.ones(ref.shape[:2], np.float32) return lit.astype(np.float32) +def _warn_dark() -> None: + logger.warning("Flat-field: reference is almost all dark; computing the gain over the whole frame") + + def _fill_from_nearest(values: np.ndarray, valid: np.ndarray) -> np.ndarray: """Replace each invalid pixel with the value of the nearest valid one.""" - if valid.all() or not valid.any(): - return values - _, labels = cv2.distanceTransformWithLabels((~valid).astype(np.uint8), cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL) - source = np.zeros(labels.max() + 1, np.int64) - source[labels[valid]] = np.flatnonzero(valid) - return values.reshape(-1, values.shape[2])[source[labels]].reshape(values.shape) + return negpy_core.fill_from_nearest(np.ascontiguousarray(values, dtype=np.float32), np.ascontiguousarray(valid, dtype=bool)) class Evenness(NamedTuple): @@ -163,8 +151,8 @@ def evenness(image: ImageBuffer) -> Evenness: def evenness_view(image: ImageBuffer, span: float = EVENNESS_RANGE) -> Tuple[np.ndarray, Evenness]: """Each channel over its lit-area median, ±span mapped to black..white; carrier black.""" ratio, result = _evenness(image) - view = np.clip(0.5 + (ratio - 1.0) / (2.0 * span), 0.0, 1.0) - return view.astype(np.float32), result + negpy_core.evenness_view(ratio, 2.0 * span) + return ratio, result def check_reference(reference: ImageBuffer, gain: np.ndarray) -> Evenness: @@ -179,26 +167,22 @@ def check_reference(reference: ImageBuffer, gain: np.ndarray) -> Evenness: def self_corrected(reference: np.ndarray, gain: np.ndarray) -> np.ndarray: if gain.shape[:2] != reference.shape[:2]: - gain = cv2.resize(gain, (reference.shape[1], reference.shape[0]), interpolation=cv2.INTER_LINEAR) - return reference.astype(np.float32) * gain + gain = negpy_core.resize_linear(np.ascontiguousarray(gain, dtype=np.float32), (reference.shape[1], reference.shape[0])) + return negpy_core.multiply(np.ascontiguousarray(reference, dtype=np.float32), np.ascontiguousarray(gain, dtype=np.float32)) def _is_clipped(image: np.ndarray) -> bool: - lit = _lit_mask(image).astype(bool) - return float((image[lit] >= _CLIP_LEVEL).any(axis=1).mean()) > _CLIP_FRACTION + fraction, dark = negpy_core.clipped_fraction(np.ascontiguousarray(image, dtype=np.float32), _LIT, _CLIP_LEVEL) + if dark: + _warn_dark() + return fraction > _CLIP_FRACTION def _evenness(image: ImageBuffer) -> Tuple[np.ndarray, Evenness]: - img = image.astype(np.float32) - lit = _lit_mask(img).astype(bool) - median = np.clip(np.median(img[lit], axis=0), 1e-6, None) - ratio = img / median - ratio[~lit] = 0.0 - lum = ratio[lit].mean(axis=1) - low, high = np.percentile(lum - 1.0, [1.0, 99.0]) - chroma = np.percentile(ratio[lit] / np.clip(lum, 1e-6, None)[:, None] - 1.0, [1.0, 99.0], axis=0) - clipped = float((img[lit] >= _CLIP_LEVEL).any(axis=1).mean()) > _CLIP_FRACTION - return ratio, Evenness(float(low), float(high), float(np.abs(chroma).max()), clipped) + ratio, (low, high, color, clipped, dark) = negpy_core.evenness(np.ascontiguousarray(image, dtype=np.float32), _LIT, _CLIP_LEVEL) + if dark: + _warn_dark() + return ratio, Evenness(low, high, color, clipped > _CLIP_FRACTION) def gain_token(gain: np.ndarray) -> str: @@ -240,5 +224,5 @@ def apply_flatfield( if gain_crop is not None: gain = gain_crop(gain) if gain.shape[:2] != image.shape[:2]: - gain = cv2.resize(gain, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_LINEAR) - return (image * gain).astype(np.float32) + gain = negpy_core.resize_linear(np.ascontiguousarray(gain, dtype=np.float32), (image.shape[1], image.shape[0])) + return negpy_core.multiply(np.ascontiguousarray(image, dtype=np.float32), np.ascontiguousarray(gain, dtype=np.float32)) diff --git a/negpy/features/geometry/batch_autocrop.py b/negpy/features/geometry/batch_autocrop.py index 28ab73649..daf80ff79 100644 --- a/negpy/features/geometry/batch_autocrop.py +++ b/negpy/features/geometry/batch_autocrop.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field, replace from typing import Sequence -import cv2 +import negpy_core import numpy as np from negpy.domain.types import ROI, ImageBuffer @@ -38,7 +38,6 @@ _TRUSTED_CONFIDENCE = 0.58 _MAX_AUTOMATIC_DESKEW = 8.0 _DEFAULT_SAFETY_BORDER = 0.01 -_MIN_PROFILE_CONTRAST = 0.06 # The rebate width is a roll property, so require enough agreeing frames that a few # bright frame edges cannot move the median. _MIN_BORDER_SAMPLES = 5 @@ -52,11 +51,6 @@ # because a fit can still lock onto the wrong edge, and one that disagrees with every other # frame by more than this probably has. _CONFIRMED_ANGLE_TOL = 2.5 -# How near the roll's film width a pair of edge-profile peaks must span before their -# agreement replaces raw peak strength. Both peaks are already constrained to a window around -# where the roll puts each edge, so this is a second, independent check: they must also be -# the right distance apart. -_EDGE_PAIR_WIDTH_TOL = 0.02 # Confidence given to a frame whose rect is the threshold box rather than a detected film # box. Low: the box is the frame, and every real measurement on it came from the border walk. _FALLBACK_CONFIDENCE = 0.30 @@ -143,76 +137,17 @@ class ResolvedCrop: def _vertical_edge_profile(img: ImageBuffer) -> tuple[np.ndarray, float]: - """Return a contrast-normalized vertical-edge profile for template fallback.""" - arr = np.asarray(img, dtype=np.float32) - if arr.ndim == 3: - gray = arr[..., :3].mean(axis=2) - else: - gray = arr - finite = gray[np.isfinite(gray)] - if finite.size == 0 or gray.shape[1] < 2: - return np.zeros(gray.shape[1], dtype=np.float32), 0.0 - - low, high = np.percentile(finite, (2.0, 98.0)) - span = max(float(high - low), 1e-6) - normalized = np.clip((gray - low) / span, 0.0, 1.0).astype(np.float32) - grad = np.abs(cv2.Sobel(normalized, cv2.CV_32F, 1, 0, ksize=3)) - profile = np.percentile(grad, 80, axis=0).astype(np.float32) - window = max(5, int(round(profile.size * 0.012))) - if window % 2 == 0: - window += 1 - profile = cv2.GaussianBlur(profile.reshape(1, -1), (window, 1), 0).ravel() - peak = float(np.max(profile)) if profile.size else 0.0 - contrast = max(0.0, float(np.percentile(profile, 99.5)) - float(np.percentile(profile, 50.0))) if profile.size else 0.0 - if peak > 0.0: - profile /= peak - return profile.astype(np.float32), contrast + """Return a contrast-normalized vertical-edge profile for template fallback; negpy_core + holds the rules.""" + profile, contrast = negpy_core.batch_edge_profile(np.ascontiguousarray(img, dtype=np.float32)) + return profile, contrast def _top_edge_slope(lum: np.ndarray, roi: ROI) -> float | None: - """Residual tilt of the film box's top edge, in degrees, or None when unreadable. - - Sign matches _correction_angle_from_quad: the measured image-coordinate residual is - already the additive fix. - """ - y1, _, x1, x2 = roi - height = lum.shape[0] - left, right = x1 + 20, x2 - 20 - columns = np.arange(left, right, 3) - min_points = max(20, round((right - left) * 0.08)) - if columns.size < min_points: - return None - - radius = max(12, round(height * 0.035)) - low, high = max(0, y1 - radius), min(height, y1 + radius) - if high - low < 4: - return None - - gradient = np.abs(cv2.Sobel(cv2.GaussianBlur(lum.astype(np.float32), (0, 0), 1.5), cv2.CV_32F, 0, 1, ksize=3)) - band = gradient[low:high, columns] - rows = low + np.argmax(band, axis=0) - strength = band.max(axis=0) - # With no edge under the band every column peaks on noise and the fit still comes back - # clean: a straight line through nothing, reported as a confident zero. - if float(np.median(strength)) < max(1e-6, 3.0 * float(np.median(band))): - return None - points = np.column_stack([columns, rows])[strength >= np.quantile(strength, 0.60)] - if len(points) < min_points: - return None - - residuals = np.zeros(len(points)) - slope = 0.0 - for _ in range(5): - slope, intercept = np.polyfit(points[:, 0], points[:, 1], 1) - residuals = np.abs(points[:, 1] - (slope * points[:, 0] + intercept)) - keep = residuals <= max(2.0, float(np.median(residuals)) * 2.5) - if keep.all() or keep.sum() < min_points: - break - points = points[keep] - - if float(np.median(residuals)) > 2.0: - return None - return float(np.degrees(np.arctan(slope))) + """Residual tilt of the film box's top edge, in degrees, or None when unreadable; negpy_core + holds the fit. Sign matches the film detector's quad angle: the measured image-coordinate + residual is already the additive fix.""" + return negpy_core.top_edge_slope(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in roi)) def _side_border(lum: np.ndarray, roi: ROI) -> dict[str, float]: @@ -594,7 +529,7 @@ def _map_rect_between_rotations( ], dtype=np.float64, ) - matrix = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), delta, 1.0) + matrix = negpy_core.rotation_matrix((w / 2.0, h / 2.0), delta, 1.0) mapped = points @ matrix[:, :2].T + matrix[:, 2] # Quantize outward before normalizing. Later conversion back to a half-open # pixel ROI must not round an extremum inward, especially when one edge touches @@ -653,62 +588,20 @@ def _calibrate_detected_rect( return _clamp_rect((x1, y1, x2, y2)), calibrated -def _peak_near(profile: np.ndarray, expected: float, radius: float) -> tuple[float, float]: - if profile.size == 0: - return expected, 0.0 - center = int(round(expected * (profile.size - 1))) - half = max(2, int(round(radius * profile.size))) - lo, hi = max(0, center - half), min(profile.size, center + half + 1) - if hi <= lo: - return expected, 0.0 - local = profile[lo:hi] - offset = int(np.argmax(local)) - idx = lo + offset - return idx / max(profile.size - 1, 1), float(local[offset]) - - def _rect_from_edge_profile(item: CropEvidence, template: RollCropTemplate) -> tuple[float, float, float, float] | None: - profile = np.asarray(item.vertical_edge_profile, dtype=np.float32) - if item.vertical_edge_contrast < _MIN_PROFILE_CONTRAST or profile.size < 8 or not np.any(np.isfinite(profile)): - return None - - baseline_level = float(np.percentile(profile, 50)) - peak_level = float(np.percentile(profile, 99.5)) - if peak_level - baseline_level < 0.20 or peak_level < 1.35 * max(baseline_level, 0.05): - return None - - left_expected = template.center_x - template.width * 0.5 - right_expected = template.center_x + template.width * 0.5 - radius = max(0.045, 4.0 * template.center_mad + 2.0 * template.width_mad) - left, left_strength = _peak_near(profile, left_expected, radius) - right, right_strength = _peak_near(profile, right_expected, radius) - - baseline = float(np.percentile(profile, 70)) - spread = float(np.percentile(profile, 90) - np.percentile(profile, 50)) - threshold = max(0.24, baseline + 0.55 * spread) - left_ok, right_ok = left_strength >= threshold, right_strength >= threshold - - # Strength is measured against the frame's own edges, so a busy picture raises the bar - # above the film edge that is plainly there. Where both peaks land on the width the roll - # expects, that agreement stands in for it: a profile with no film edge in it does not - # produce two independently located edges spanning the right distance. - if not (left_ok and right_ok) and abs((right - left) - template.width) <= _EDGE_PAIR_WIDTH_TOL: - left_ok = right_ok = True - - if not left_ok and not right_ok: - return None - if left_ok and right_ok: - if right - left < 0.65 * template.width or right - left > 1.35 * template.fallback_width: - return None - x1, x2 = left, right - elif left_ok and left_strength >= threshold + 0.12: - x1, x2 = left, left + template.fallback_width - elif right_ok and right_strength >= threshold + 0.12: - x1, x2 = right - template.fallback_width, right - else: - return None - - return _clamp_rect((x1, template.top, x2, template.top + template.height)) + """A film box from the frame's vertical-edge profile where the roll puts the edges; negpy_core + holds the rules.""" + fields = ( + template.center_x, + template.width, + template.fallback_width, + template.top, + template.height, + template.center_mad, + template.width_mad, + ) + profile = np.ascontiguousarray(item.vertical_edge_profile, dtype=np.float32).ravel() + return negpy_core.rect_from_edge_profile(profile, float(item.vertical_edge_contrast), fields) def _resolve_border( diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 98f55a5b3..dee666d3e 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1,9 +1,9 @@ import math from dataclasses import dataclass, field, replace from functools import lru_cache -from typing import Callable, Dict, List, NamedTuple, Optional, Tuple +from typing import Dict, List, Optional, Tuple -import cv2 +import negpy_core import numpy as np from negpy.domain.types import ROI, ImageBuffer @@ -14,19 +14,10 @@ AutocropMode, GeometryConfig, ) -from negpy.kernel.image.logic import get_luminance, percentiles +from negpy.kernel.image.logic import get_luminance from negpy.kernel.image.validation import ensure_image AUTOCROP_DETECT_RES = 1800 -_ADAPTIVE_THRESHOLD_COUNT = 7 -_SIDE_NAMES = ("top", "right", "bottom", "left") -_CORNER_SIDES = { - "top_left": frozenset({"top", "left"}), - "top_right": frozenset({"top", "right"}), - "bottom_right": frozenset({"bottom", "right"}), - "bottom_left": frozenset({"bottom", "left"}), -} -_EVIDENCE_SOURCE_ORDER = ("adaptive-dark", "adaptive-bright", "inverse-threshold", "blackhat", "edges") @dataclass(frozen=True) @@ -68,32 +59,6 @@ def __post_init__(self) -> None: object.__setattr__(self, "vertical_edge_profile", profile) -@dataclass(frozen=True) -class _FilmCandidate: - roi: ROI - correction_angle: float - source: str - threshold_index: int | None - polarity: str - boundary_score: float - geometry_score: float - supported_sides: frozenset[str] - supported_corners: frozenset[str] - - -@dataclass(frozen=True) -class _FilmConsensus: - roi: ROI - correction_angle: float - confidence: float - supported_sides: frozenset[str] - supported_corners: frozenset[str] - evidence_sources: tuple[str, ...] - geometry_score: float - dispersion: float - polarity: str - - def _normalize_detection_input(img: ImageBuffer, detect_res: int) -> tuple[np.ndarray, float]: """ Downsamples to detect_res longest edge (INTER_AREA). Never upscales. @@ -104,7 +69,7 @@ def _normalize_detection_input(img: ImageBuffer, detect_res: int) -> tuple[np.nd if det_scale >= 1.0: return img, 1.0 d_w, d_h = max(1, round(w * det_scale)), max(1, round(h * det_scale)) - det = cv2.resize(np.ascontiguousarray(img), (d_w, d_h), interpolation=cv2.INTER_AREA) + det = negpy_core.resize_area(np.ascontiguousarray(img, dtype=np.float32), (d_w, d_h)) return det, det_scale @@ -126,663 +91,26 @@ def _scale_roi(roi: ROI, det_scale: float, h: int, w: int) -> ROI: def _normalize_to_uint8(image: np.ndarray) -> np.ndarray: if image.dtype == np.uint8: return image - - image_float = image.astype(np.float32) - finite_mask = np.isfinite(image_float) - if not np.any(finite_mask): - return np.zeros(image.shape, dtype=np.uint8) - - valid = image_float[finite_mask] - low, high = (float(v) for v in percentiles(valid, (1, 99))) - if high <= low: - high = low + 1.0 - - scaled = np.clip((image_float - low) * (255.0 / (high - low)), 0, 255) - return scaled.astype(np.uint8) + return negpy_core.normalize_to_uint8(np.ascontiguousarray(image, dtype=np.float32)) def _ensure_color(image: np.ndarray) -> np.ndarray: if image.ndim == 2: - return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + return np.repeat(image[..., None], 3, axis=2) return image def _smooth_signal(signal: np.ndarray, window: int) -> np.ndarray: - if window <= 1 or signal.size == 0: - return signal.astype(np.float32) - - kernel = np.ones(window, dtype=np.float32) / window - return np.convolve(signal.astype(np.float32), kernel, mode="same") - - -def _boundary_candidates(signal: np.ndarray, *, from_start: bool) -> tuple[int, float, int, float]: - length = signal.size - if length == 0: - return 0, 0.0, 0, 0.0 - - edge_window = max(int(round(length * 0.08)), 32) - edge_window = min(edge_window, max(length - 1, 1)) - search_end = max(int(round(length * 0.45)), edge_window + 1) - search_end = min(search_end, length) - search_start = min(int(round(length * 0.55)), max(length - edge_window - 1, 0)) - - if from_start: - edge_slice = signal[:edge_window] - search_slice = signal[edge_window:search_end] - edge_idx = int(np.argmax(edge_slice)) if edge_slice.size else 0 - edge_value = float(edge_slice[edge_idx]) if edge_slice.size else 0.0 - if search_slice.size == 0: - return edge_idx, edge_value, edge_idx, edge_value - inner_offset = int(np.argmax(search_slice)) - inner_idx = edge_window + inner_offset - inner_value = float(search_slice[inner_offset]) - return edge_idx, edge_value, inner_idx, inner_value - - edge_slice = signal[length - edge_window :] - search_slice = signal[search_start : length - edge_window] - edge_offset = int(np.argmax(edge_slice)) if edge_slice.size else 0 - edge_idx = length - edge_window + edge_offset - edge_value = float(edge_slice[edge_offset]) if edge_slice.size else 0.0 - if search_slice.size == 0: - return edge_idx, edge_value, edge_idx, edge_value - inner_offset = int(np.argmax(search_slice)) - inner_idx = search_start + inner_offset - inner_value = float(search_slice[inner_offset]) - return edge_idx, edge_value, inner_idx, inner_value - - -def _dark_region_bounds(image: np.ndarray) -> tuple[int, int, int, int] | None: - preview = _normalize_to_uint8(_ensure_color(image)) - gray = cv2.cvtColor(preview, cv2.COLOR_BGR2GRAY) - - threshold = float(np.percentile(gray, 55)) - mask = (gray <= threshold).astype(np.uint8) * 255 - open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) - close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)) - mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, open_kernel) - mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, close_kernel, iterations=2) - - contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - if not contours: - return None - - contour = max(contours, key=cv2.contourArea) - x, y, box_w, box_h = cv2.boundingRect(contour) - image_area = float(gray.shape[0] * gray.shape[1]) - area_ratio = float(cv2.contourArea(contour)) / max(image_area, 1.0) - if area_ratio < 0.15 or area_ratio > 0.85: - return None - - min_width = int(round(image.shape[1] * 0.25)) - min_height = int(round(image.shape[0] * 0.25)) - if box_w < min_width or box_h < min_height: - return None - - pad_x = max(int(round(image.shape[1] * 0.004)), 4) - pad_y = max(int(round(image.shape[0] * 0.004)), 4) - left = max(x - pad_x, 0) - top = max(y - pad_y, 0) - right = min(x + box_w + pad_x, image.shape[1]) - bottom = min(y + box_h + pad_y, image.shape[0]) - - min_inset_x = int(round(image.shape[1] * 0.03)) - min_inset_y = int(round(image.shape[0] * 0.03)) - if left < min_inset_x or top < min_inset_y or (image.shape[1] - right) < min_inset_x or (image.shape[0] - bottom) < min_inset_y: - return None - - return left, top, right, bottom + return negpy_core.smooth_signal(np.ascontiguousarray(signal, dtype=np.float32), int(window)) def _refine_frame_bounds(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int, int, int]]: - preview = _normalize_to_uint8(_ensure_color(image)) - gray = cv2.cvtColor(preview, cv2.COLOR_BGR2GRAY) - - grad_x = cv2.convertScaleAbs(cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)) - grad_y = cv2.convertScaleAbs(cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)) - col_signal = _smooth_signal(np.percentile(grad_x, 95, axis=0), 31) - row_signal = _smooth_signal(np.percentile(grad_y, 95, axis=1), 31) - - left_edge, left_edge_value, left_inner, left_inner_value = _boundary_candidates(col_signal, from_start=True) - right_edge, right_edge_value, right_inner, right_inner_value = _boundary_candidates(col_signal, from_start=False) - top_edge, top_edge_value, top_inner, top_inner_value = _boundary_candidates(row_signal, from_start=True) - bottom_edge, bottom_edge_value, bottom_inner, bottom_inner_value = _boundary_candidates(row_signal, from_start=False) - - col_noise_floor = float(np.percentile(col_signal, 75)) - row_noise_floor = float(np.percentile(row_signal, 75)) - - left = left_edge - right = right_edge + 1 - top = top_edge - bottom = bottom_edge + 1 - - use_inner_pair_x = ( - left_inner >= int(round(image.shape[1] * 0.12)) - and right_inner <= int(round(image.shape[1] * 0.88)) - and left_inner_value >= col_noise_floor * 4.0 - and right_inner_value >= col_noise_floor * 4.0 - and (right_inner - left_inner) >= int(round(image.shape[1] * 0.5)) - ) - if use_inner_pair_x: - left = left_inner - right = right_inner + 1 - else: - if left_inner >= int(round(image.shape[1] * 0.12)) and left_inner_value >= col_noise_floor * 5.0: - left = left_inner - if right_inner <= int(round(image.shape[1] * 0.88)) and right_inner_value >= col_noise_floor * 5.0: - right = right_inner + 1 - - use_inner_pair_y = ( - top_inner > top_edge + 20 - and bottom_inner < bottom_edge - 20 - and top_inner_value > max(top_edge_value * 1.2, row_noise_floor + 25.0) - and bottom_inner_value > max(bottom_edge_value * 1.2, row_noise_floor + 25.0) - and (bottom_inner - top_inner) >= int(round(image.shape[0] * 0.5)) - ) - if use_inner_pair_y: - top = top_inner - bottom = bottom_inner + 1 - else: - if top_inner > top_edge + 20 and top_inner_value > max(top_edge_value * 1.45, row_noise_floor + 35.0): - top = top_inner - if bottom_inner < bottom_edge - 20 and bottom_inner_value > max(bottom_edge_value * 1.45, row_noise_floor + 35.0): - bottom = bottom_inner + 1 - - pad_x = max(int(round(image.shape[1] * 0.004)), 4) - pad_y = max(int(round(image.shape[0] * 0.004)), 4) - left = max(left - pad_x, 0) - right = min(right + pad_x, image.shape[1]) - top = max(top - pad_y, 0) - bottom = min(bottom + pad_y, image.shape[0]) - - min_width = max(int(round(image.shape[1] * 0.5)), 1) - min_height = max(int(round(image.shape[0] * 0.5)), 1) - if right - left < min_width: - left, right = 0, image.shape[1] - if bottom - top < min_height: - top, bottom = 0, image.shape[0] - - refined_area_ratio = ((right - left) * (bottom - top)) / max(float(image.shape[0] * image.shape[1]), 1.0) - dark_bounds = _dark_region_bounds(image) - if dark_bounds is not None and refined_area_ratio > 0.8: - dark_left, dark_top, dark_right, dark_bottom = dark_bounds - dark_area_ratio = ((dark_right - dark_left) * (dark_bottom - dark_top)) / max(float(image.shape[0] * image.shape[1]), 1.0) - if 0.15 <= dark_area_ratio <= 0.85: - left, top, right, bottom = dark_left, dark_top, dark_right, dark_bottom - + """The picture inside a film box by Sobel profiles of its gray preview, and its box + (left, top, right, bottom); negpy_core holds the rules.""" + left, top, right, bottom = negpy_core.refine_frame_bounds(np.ascontiguousarray(image, dtype=np.float32)) return image[top:bottom, left:right], (left, top, right, bottom) -def _mask_from_blackhat(gray: np.ndarray) -> np.ndarray: - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)) - blackhat = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel) - blackhat = cv2.GaussianBlur(blackhat, (5, 5), 0) - _, thresh = cv2.threshold(blackhat, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) - close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (21, 21)) - return cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=2) - - -def _mask_from_inverse_threshold(gray: np.ndarray) -> np.ndarray: - blurred = cv2.GaussianBlur(gray, (7, 7), 0) - _, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) - close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 17)) - open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) - cleaned = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=2) - return cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, open_kernel, iterations=1) - - -def _mask_from_edges(gray: np.ndarray) -> np.ndarray: - blurred = cv2.GaussianBlur(gray, (5, 5), 0) - edges = cv2.Canny(blurred, 40, 160) - dilate_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)) - close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) - dilated = cv2.dilate(edges, dilate_kernel, iterations=2) - return cv2.morphologyEx(dilated, cv2.MORPH_CLOSE, close_kernel, iterations=2) - - -def _score_contour(contour: np.ndarray, image_area: float) -> tuple[float, np.ndarray] | None: - rect = cv2.minAreaRect(contour) - width, height = rect[1] - rect_area = float(width * height) - if rect_area <= 0: - return None - - contour_area = float(cv2.contourArea(contour)) - area_ratio = rect_area / image_area - fill_ratio = contour_area / rect_area if rect_area else 0.0 - short_side = min(width, height) - long_side = max(width, height) - aspect_ratio = long_side / max(short_side, 1.0) - - if area_ratio < 0.08: - return None - if short_side < 40: - return None - if aspect_ratio > 8.0: - return None - - score = area_ratio * 1.5 + min(fill_ratio, 1.0) - return score, cv2.boxPoints(rect) - - -def _odd_kernel_size(value: float, lower: int, upper: int) -> int: - size = int(round(value)) - size = min(max(size, lower), upper) - if size % 2 == 0: - size = size + 1 if size < upper else size - 1 - return max(size, 1) - - -def _outer_ring_values(lum: np.ndarray) -> np.ndarray: - # 2% not 5%: a tightly framed scan leaves only 1-3% of bed around the film, so a - # wider ring reads mostly film, fails the ring_delta gate in - # _oriented_boundary_evidence, and abstains on an otherwise perfect box. - h, w = lum.shape[:2] - ring_width = max(2, round(0.02 * min(h, w))) - ring_width = min(ring_width, max(1, min(h, w) // 2)) - parts = [lum[:ring_width, :].reshape(-1), lum[-ring_width:, :].reshape(-1)] - if h > 2 * ring_width: - parts.extend( - [ - lum[ring_width:-ring_width, :ring_width].reshape(-1), - lum[ring_width:-ring_width, -ring_width:].reshape(-1), - ] - ) - return np.concatenate(parts) - - -def _threshold_polarities(lum: np.ndarray, robust_span: float, ring_median: float) -> tuple[str, ...]: - h, w = lum.shape[:2] - y1, y2 = round(0.2 * h), round(0.8 * h) - x1, x2 = round(0.2 * w), round(0.8 * w) - center = lum[y1 : max(y2, y1 + 1), x1 : max(x2, x1 + 1)] - center_median = float(np.median(center)) - direction_gate = max(0.025, 0.05 * robust_span) - if ring_median - center_median > direction_gate: - return ("dark",) - if center_median - ring_median > direction_gate: - return ("bright",) - return ("dark", "bright") - - -def _adaptive_threshold_levels(lum: np.ndarray, polarity: str) -> tuple[float, ...]: - lo, hi = (float(value) for value in np.percentile(lum, (2.0, 98.0))) - if hi - lo < 0.04: - return () - if polarity == "dark": - # Bias toward the bright surround. On a three-tier negative this gives the - # outer film/rebate edge at least as many trials as the inner image edge. - fractions = (0.35, 0.50, 0.62, 0.72, 0.80, 0.87, 0.93) - else: - # Mirror the sampling pressure toward a dark holder for positive polarity. - fractions = (0.07, 0.13, 0.20, 0.28, 0.38, 0.50, 0.65) - return tuple(lo + fraction * (hi - lo) for fraction in fractions) - - -def _adaptive_threshold_mask(blurred_lum: np.ndarray, threshold: float, polarity: str) -> np.ndarray: - selected = blurred_lum <= threshold if polarity == "dark" else blurred_lum >= threshold - mask = selected.astype(np.uint8) * 255 - min_dim = min(mask.shape[:2]) - close_size = _odd_kernel_size(0.010 * min_dim, 3, 21) - open_size = _odd_kernel_size(0.003 * min_dim, 3, 9) - close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (close_size, close_size)) - open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (open_size, open_size)) - mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, close_kernel, iterations=1) - return cv2.morphologyEx(mask, cv2.MORPH_OPEN, open_kernel, iterations=1) - - -def _order_quad(quad: np.ndarray) -> np.ndarray: - points = np.asarray(quad, dtype=np.float32).reshape(4, 2) - ordered = np.empty((4, 2), dtype=np.float32) - sums = points.sum(axis=1) - diffs = np.diff(points, axis=1).reshape(-1) - ordered[0] = points[int(np.argmin(sums))] # top-left - ordered[1] = points[int(np.argmin(diffs))] # top-right - ordered[2] = points[int(np.argmax(sums))] # bottom-right - ordered[3] = points[int(np.argmax(diffs))] # bottom-left - return ordered - - -def _polygon_values(lum: np.ndarray, polygon: np.ndarray) -> np.ndarray: - h, w = lum.shape[:2] - points = np.asarray(polygon, dtype=np.float32) - left = max(0, int(math.floor(float(np.min(points[:, 0]))))) - right = min(w, int(math.ceil(float(np.max(points[:, 0])))) + 1) - top = max(0, int(math.floor(float(np.min(points[:, 1]))))) - bottom = min(h, int(math.ceil(float(np.max(points[:, 1])))) + 1) - if right <= left or bottom <= top: - return np.empty(0, dtype=np.float32) - local_points = np.rint(points - np.array([left, top], dtype=np.float32)).astype(np.int32) - mask = np.zeros((bottom - top, right - left), dtype=np.uint8) - cv2.fillConvexPoly(mask, local_points, 1) - values = lum[top:bottom, left:right][mask.astype(bool)] - return values[np.isfinite(values)].astype(np.float32, copy=False) - - -def _oriented_boundary_evidence( - lum: np.ndarray, - quad: np.ndarray, - polarity: str, - ring_median: float, - robust_span: float, -) -> tuple[float, frozenset[str], frozenset[str]]: - ordered = _order_quad(quad) - edge_lengths = np.linalg.norm(np.roll(ordered, -1, axis=0) - ordered, axis=1) - band = float(max(2, min(24, round(0.012 * max(1.0, float(np.min(edge_lengths))))))) - contrast_gate = max(0.02, 0.04 * robust_span) - contrast_scale = max(0.06, 0.20 * robust_span) - ring_tolerance = max(0.06, 0.16 * robust_span) - - supported: set[str] = set() - side_scores: list[float] = [] - for index, name in enumerate(_SIDE_NAMES): - start = ordered[index] - end = ordered[(index + 1) % 4] - vector = end - start - length = float(np.linalg.norm(vector)) - if length < 4.0: - continue - # Ignore corners, where interpolation and sprocket holes mix the two populations. - # Ordered points run clockwise in image coordinates, so (-dy, dx) is the inward normal. - inner_start = start + 0.08 * vector - inner_end = end - 0.08 * vector - inward = np.array([-vector[1], vector[0]], dtype=np.float32) / length - inside_polygon = np.array([inner_start, inner_end, inner_end + inward * band, inner_start + inward * band]) - outside_polygon = np.array([inner_start, inner_end, inner_end - inward * band, inner_start - inward * band]) - inside = _polygon_values(lum, inside_polygon) - outside = _polygon_values(lum, outside_polygon) - expected = max(8.0, 0.20 * 0.84 * length * band) - if inside.size < expected or outside.size < expected: - continue - - inside_median = float(np.median(inside)) - outside_median = float(np.median(outside)) - contrast = outside_median - inside_median if polarity == "dark" else inside_median - outside_median - ring_delta = abs(outside_median - ring_median) - if contrast < contrast_gate or ring_delta > ring_tolerance: - continue - - contrast_score = float(np.clip(contrast / contrast_scale, 0.0, 1.0)) - ring_score = float(np.clip(1.0 - ring_delta / ring_tolerance, 0.0, 1.0)) - supported.add(name) - side_scores.append(0.75 * contrast_score + 0.25 * ring_score) - - supported_sides = frozenset(supported) - supported_corners = frozenset(name for name, sides in _CORNER_SIDES.items() if sides <= supported_sides) - boundary_score = float(sum(side_scores) / 4.0) - return boundary_score, supported_sides, supported_corners - - -def _correction_angle_from_quad(quad: np.ndarray, aspect_ratio: float) -> float: - if aspect_ratio < 1.12: - return 0.0 # Near-square boxes do not have a stable orientation axis. - points = np.asarray(quad, dtype=np.float32) - edges = np.roll(points, -1, axis=0) - points - edge = edges[int(np.argmax(np.linalg.norm(edges, axis=1)))] - angle = math.degrees(math.atan2(float(edge[1]), float(edge[0]))) - while angle >= 90.0: - angle -= 180.0 - while angle < -90.0: - angle += 180.0 - if angle > 45.0: - angle -= 90.0 - elif angle < -45.0: - angle += 90.0 - # OpenCV's positive fine rotation moves a horizontal edge toward a negative - # image-coordinate angle, so this measured residual is already the additive fix. - return float(np.clip(angle, -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT)) - - -def _candidate_from_contour( - contour: np.ndarray, - image_shape: tuple[int, int], - lum: np.ndarray, - ring_median: float, - robust_span: float, - source: str, - threshold_index: int | None, - polarity: str | None, -) -> _FilmCandidate | None: - h, w = image_shape - image_area = float(h * w) - rect = cv2.minAreaRect(contour) - rect_width, rect_height = (float(value) for value in rect[1]) - rect_area = rect_width * rect_height - if rect_area <= 0.0: - return None - contour_area = float(cv2.contourArea(contour)) - area_ratio = rect_area / max(image_area, 1.0) - short_side = min(rect_width, rect_height) - long_side = max(rect_width, rect_height) - aspect_ratio = long_side / max(short_side, 1.0) - rectangularity = contour_area / rect_area - if area_ratio < 0.08 or area_ratio > 1.02: - return None - if short_side < max(8.0, 0.04 * min(h, w)) or aspect_ratio > 8.0: - return None - if rectangularity < 0.30: - return None - - quad = cv2.boxPoints(rect).astype(np.float32) - x, y, box_width, box_height = cv2.boundingRect(quad) - roi = (max(0, y), min(h, y + box_height), max(0, x), min(w, x + box_width)) - if roi[1] <= roi[0] or roi[3] <= roi[2]: - return None - - best: _FilmCandidate | None = None - for candidate_polarity in (polarity,) if polarity is not None else ("dark", "bright"): - boundary_score, supported_sides, supported_corners = _oriented_boundary_evidence( - lum, quad, candidate_polarity, ring_median, robust_span - ) - if len(supported_sides) < 2: - continue - rectangularity_score = float(np.clip((rectangularity - 0.30) / 0.65, 0.0, 1.0)) - geometry_score = 0.70 * rectangularity_score + 0.30 * (len(supported_sides) / 4.0) - candidate = _FilmCandidate( - roi=roi, - correction_angle=_correction_angle_from_quad(quad, aspect_ratio), - source=source, - threshold_index=threshold_index, - polarity=candidate_polarity, - boundary_score=boundary_score, - geometry_score=float(np.clip(geometry_score, 0.0, 1.0)), - supported_sides=supported_sides, - supported_corners=supported_corners, - ) - if best is None or (candidate.boundary_score, candidate.geometry_score) > (best.boundary_score, best.geometry_score): - best = candidate - return best - - -def _candidates_from_mask( - mask: np.ndarray, - image_shape: tuple[int, int], - lum: np.ndarray, - ring_median: float, - robust_span: float, - source: str, - threshold_index: int | None = None, - polarity: str | None = None, -) -> list[_FilmCandidate]: - contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - candidates: list[_FilmCandidate] = [] - for contour in sorted(contours, key=cv2.contourArea, reverse=True)[:12]: - candidate = _candidate_from_contour( - contour, - image_shape, - lum, - ring_median, - robust_span, - source, - threshold_index, - polarity, - ) - if candidate is not None: - candidates.append(candidate) - return candidates - - -def _roi_iou(first: ROI, second: ROI) -> float: - y1 = max(first[0], second[0]) - y2 = min(first[1], second[1]) - x1 = max(first[2], second[2]) - x2 = min(first[3], second[3]) - intersection = max(0, y2 - y1) * max(0, x2 - x1) - first_area = max(0, first[1] - first[0]) * max(0, first[3] - first[2]) - second_area = max(0, second[1] - second[0]) * max(0, second[3] - second[2]) - return intersection / max(float(first_area + second_area - intersection), 1.0) - - -def _normalized_edge_distance(first: ROI, second: ROI, image_shape: tuple[int, int]) -> float: - h, w = image_shape - scales = (max(h, 1), max(h, 1), max(w, 1), max(w, 1)) - return max(abs(a - b) / scale for a, b, scale in zip(first, second, scales)) - - -def _cluster_film_candidates(candidates: list[_FilmCandidate], image_shape: tuple[int, int]) -> list[list[_FilmCandidate]]: - count = len(candidates) - parents = list(range(count)) - - def find(index: int) -> int: - while parents[index] != index: - parents[index] = parents[parents[index]] - index = parents[index] - return index - - def union(first: int, second: int) -> None: - first_root, second_root = find(first), find(second) - if first_root != second_root: - parents[second_root] = first_root - - for first in range(count): - for second in range(first + 1, count): - if _roi_iou(candidates[first].roi, candidates[second].roi) < 0.78: - continue - if _normalized_edge_distance(candidates[first].roi, candidates[second].roi, image_shape) <= 0.035: - union(first, second) - - grouped: dict[int, list[_FilmCandidate]] = {} - for index, candidate in enumerate(candidates): - grouped.setdefault(find(index), []).append(candidate) - return list(grouped.values()) - - -def _weighted_median(values: list[float], weights: list[float]) -> float: - order = np.argsort(np.asarray(values, dtype=np.float64)) - ordered_values = np.asarray(values, dtype=np.float64)[order] - ordered_weights = np.asarray(weights, dtype=np.float64)[order] - cutoff = 0.5 * float(np.sum(ordered_weights)) - index = int(np.searchsorted(np.cumsum(ordered_weights), cutoff, side="left")) - return float(ordered_values[min(index, ordered_values.size - 1)]) - - -def _dedupe_cluster(cluster: list[_FilmCandidate]) -> list[_FilmCandidate]: - by_trial: dict[tuple[str, int | None, str], _FilmCandidate] = {} - for candidate in cluster: - key = (candidate.source, candidate.threshold_index, candidate.polarity) - current = by_trial.get(key) - if current is None or (candidate.boundary_score, candidate.geometry_score) > (current.boundary_score, current.geometry_score): - by_trial[key] = candidate - return list(by_trial.values()) - - -def _source_tuple(cluster: list[_FilmCandidate]) -> tuple[str, ...]: - present = {candidate.source for candidate in cluster} - ordered = [source for source in _EVIDENCE_SOURCE_ORDER if source in present] - ordered.extend(sorted(present - set(ordered))) - return tuple(ordered) - - -def _supported_by_majority(cluster: list[_FilmCandidate], attribute: str) -> frozenset[str]: - values = {value for candidate in cluster for value in getattr(candidate, attribute)} - needed = max(1, math.ceil(0.5 * len(cluster))) - return frozenset(value for value in values if sum(value in getattr(candidate, attribute) for candidate in cluster) >= needed) - - -def _select_consensus_cluster(candidates: list[_FilmCandidate], image_shape: tuple[int, int]) -> _FilmConsensus | None: - h, w = image_shape - accepted: list[_FilmConsensus] = [] - for raw_cluster in _cluster_film_candidates(candidates, image_shape): - cluster = _dedupe_cluster(raw_cluster) - adaptive_trials = { - (candidate.polarity, candidate.threshold_index) - for candidate in cluster - if candidate.source.startswith("adaptive-") and candidate.threshold_index is not None - } - structural_sources = {candidate.source for candidate in cluster if not candidate.source.startswith("adaptive-")} - if len(adaptive_trials) < 3 and not (len(adaptive_trials) >= 2 and len(structural_sources) >= 2): - continue - - weights = [max(0.05, candidate.boundary_score * candidate.geometry_score) for candidate in cluster] - roi_values = list(zip(*(candidate.roi for candidate in cluster))) - roi = tuple(int(round(_weighted_median(list(values), weights))) for values in roi_values) - if roi[1] <= roi[0] or roi[3] <= roi[2]: - continue - - scales = (max(h, 1), max(h, 1), max(w, 1), max(w, 1)) - edge_mads = [] - for edge_index, values in enumerate(roi_values): - center = _weighted_median(list(values), weights) - edge_mads.append(_weighted_median([abs(value - center) / scales[edge_index] for value in values], weights)) - dispersion = float(max(edge_mads, default=1.0)) - stability = float(np.clip(1.0 - dispersion / 0.03, 0.0, 1.0)) - - angles = [candidate.correction_angle for candidate in cluster] - correction_angle = _weighted_median(angles, weights) - angle_mad = _weighted_median([abs(angle - correction_angle) for angle in angles], weights) - angle_stability = float(np.clip(1.0 - angle_mad / 3.0, 0.0, 1.0)) - median_geometry = _weighted_median([candidate.geometry_score for candidate in cluster], weights) - geometry_score = float(np.clip(0.55 * median_geometry + 0.25 * stability + 0.20 * angle_stability, 0.0, 1.0)) - boundary_score = _weighted_median([candidate.boundary_score for candidate in cluster], weights) - support = min(1.0, len(adaptive_trials) / _ADAPTIVE_THRESHOLD_COUNT) - corroboration = min(1.0, len(structural_sources) / 3.0) - confidence = float( - np.clip(0.40 * support + 0.25 * stability + 0.20 * boundary_score + 0.10 * geometry_score + 0.05 * corroboration, 0.0, 1.0) - ) - if confidence < 0.55: - continue - - polarity_weights = { - polarity: sum(weight for candidate, weight in zip(cluster, weights) if candidate.polarity == polarity) - for polarity in ("dark", "bright") - } - accepted.append( - _FilmConsensus( - roi=roi, - correction_angle=correction_angle, - confidence=confidence, - supported_sides=_supported_by_majority(cluster, "supported_sides"), - supported_corners=_supported_by_majority(cluster, "supported_corners"), - evidence_sources=_source_tuple(cluster), - geometry_score=geometry_score, - dispersion=dispersion, - polarity=max(polarity_weights, key=polarity_weights.get), - ) - ) - - if not accepted: - return None - # Confidence is primary. A small area preference breaks equivalent inner/outer clusters - # toward the film extent instead of picture content. - return max(accepted, key=lambda result: (result.confidence, (result.roi[1] - result.roi[0]) * (result.roi[3] - result.roi[2]))) - - -def _vertical_edge_profile(lum: np.ndarray) -> tuple[np.ndarray, float]: - if lum.size == 0: - return np.empty(0, dtype=np.float32), 0.0 - gradient = np.abs(cv2.Sobel(lum.astype(np.float32), cv2.CV_32F, 1, 0, ksize=3)) - profile = np.mean(gradient, axis=0).astype(np.float32) - window = _odd_kernel_size(0.006 * profile.size, 3, 15) - profile = cv2.GaussianBlur(profile.reshape(1, -1), (window, 1), 0).ravel() - baseline = float(np.percentile(profile, 50.0)) if profile.size else 0.0 - normalizer = float(np.percentile(profile, 99.5)) if profile.size else 0.0 - contrast = max(0.0, normalizer - baseline) - if normalizer > 1e-8: - profile = np.clip(profile / normalizer, 0.0, 1.0) - else: - profile.fill(0.0) - return profile, contrast - - def _empty_autocrop_detection( vertical_edge_profile: np.ndarray, vertical_edge_contrast: float = 0.0, @@ -801,73 +129,24 @@ def _empty_autocrop_detection( def detect_film_bounds_with_confidence(img: ImageBuffer) -> AutocropDetection: - """Detect the outer film box by agreement across adaptive thresholds. - - Adaptive intensity trials provide the required consensus. The previous - inverse-threshold, black-hat, and edge masks are retained as independent - corroborating evidence, but no structural mask can win by itself. - """ + """Detect the outer film box by agreement across adaptive thresholds, corroborated by + structural masks that cannot win alone; negpy_core holds the rules.""" if img.ndim < 2 or img.shape[0] < 2 or img.shape[1] < 2: return _empty_autocrop_detection(np.empty(0, dtype=np.float32)) - - lum = _detection_luma(img) - vertical_edge_profile, vertical_edge_contrast = _vertical_edge_profile(lum) - lo, hi = (float(value) for value in np.percentile(lum, (2.0, 98.0))) - robust_span = hi - lo - if robust_span < 0.04: - return _empty_autocrop_detection(vertical_edge_profile, vertical_edge_contrast) - ring_values = _outer_ring_values(lum) - ring_median = float(np.median(ring_values)) - polarities = _threshold_polarities(lum, robust_span, ring_median) - - min_dim = min(lum.shape[:2]) - blur_size = _odd_kernel_size(0.004 * min_dim, 3, 9) - blurred_lum = cv2.GaussianBlur(lum.astype(np.float32), (blur_size, blur_size), 0) - image_shape = lum.shape[:2] - candidates: list[_FilmCandidate] = [] - for polarity in polarities: - source = f"adaptive-{polarity}" - for threshold_index, threshold in enumerate(_adaptive_threshold_levels(lum, polarity)): - mask = _adaptive_threshold_mask(blurred_lum, threshold, polarity) - candidates.extend( - _candidates_from_mask( - mask, - image_shape, - lum, - ring_median, - robust_span, - source, - threshold_index=threshold_index, - polarity=polarity, - ) - ) - - color = _ensure_color(img) - preview = _normalize_to_uint8(color) - gray = cv2.cvtColor(preview, cv2.COLOR_BGR2GRAY) - structural_masks = ( - ("inverse-threshold", _mask_from_inverse_threshold(gray)), - ("blackhat", _mask_from_blackhat(gray)), - ("edges", _mask_from_edges(gray)), - ) - for source, mask in structural_masks: - candidates.extend(_candidates_from_mask(mask, image_shape, lum, ring_median, robust_span, source)) - - consensus = _select_consensus_cluster(candidates, image_shape) - if consensus is None or not _film_box_covers_enough(lum, consensus.roi) or not _film_surround_is_plausible(lum, consensus.roi): - return _empty_autocrop_detection(vertical_edge_profile, vertical_edge_contrast) - - roi = _snap_film_bounds_to_bed_gradient(consensus.roi, lum) + consensus, profile, contrast = negpy_core.detect_film_bounds(np.ascontiguousarray(img, dtype=np.float32)) + if consensus is None: + return _empty_autocrop_detection(profile, contrast) + roi, angle, confidence, sides, corners, sources, geometry = consensus return AutocropDetection( roi=roi, - correction_angle=consensus.correction_angle, - confidence=consensus.confidence, - supported_sides=consensus.supported_sides, - supported_corners=consensus.supported_corners, - evidence_sources=consensus.evidence_sources, - geometry_score=consensus.geometry_score, - vertical_edge_contrast=vertical_edge_contrast, - vertical_edge_profile=vertical_edge_profile, + correction_angle=angle, + confidence=confidence, + supported_sides=frozenset(sides), + supported_corners=frozenset(corners), + evidence_sources=tuple(sources), + geometry_score=geometry, + vertical_edge_contrast=contrast, + vertical_edge_profile=profile, ) @@ -876,98 +155,16 @@ def _detect_film_bounds(img: ImageBuffer) -> ROI | None: return detect_film_bounds_with_confidence(img).roi -# How much of the frame a detected film box must cover to be read as film. Film fills most -# of a scan under every holder we have samples for, and on a positive whose bed clips to -# white a dark subject can otherwise satisfy every other check on its own geometry. The -# smallest legitimate box in the corpus covers 62.5%, so this sits clear of real geometry. -_MIN_FILM_BOX_COVERAGE = 0.25 - - -def _film_box_covers_enough(lum: np.ndarray, roi: ROI) -> bool: - h, w = lum.shape[:2] - y1, y2, x1, x2 = roi - return h > 0 and w > 0 and (y2 - y1) * (x2 - x1) >= _MIN_FILM_BOX_COVERAGE * h * w - - -def _film_surround_is_plausible(lum: np.ndarray, roi: ROI) -> bool: - """ - A real film box sits on a light bed (uniform, near-clipping surround) or in a - dark holder. A mid-tone textured surround means the contour latched onto image - content in a borderless scan — reject so detection falls back to full frame. - """ - h, w = lum.shape[:2] - y1, y2, x1, x2 = roi - out_mask = np.ones((h, w), dtype=bool) - out_mask[y1:y2, x1:x2] = False - out = lum[out_mask] - if out.size < 0.005 * lum.size: - return True # box covers nearly the whole scan; no surround evidence either way - out_med = float(np.median(out)) - out_high = float(np.percentile(out, 90)) - box_med = float(np.median(lum[y1:y2, x1:x2])) - # Near-clipping, not merely bright. On a thin negative the picture itself clears 0.85, - # so a median alone accepts a blown region as bed and cuts the frame down to whatever is - # darker beside it. The bed is the light source and sits at the top of the histogram, - # where _detection_luma's anchor puts it. A highlight does not. - bed_like = out_med >= 0.85 and out_high >= 0.98 - holder_like = out_med <= 0.30 and out_med <= box_med - 0.15 - return bed_like or holder_like - - -def _snap_film_bounds_to_bed_gradient(roi: ROI, lum: np.ndarray) -> ROI: - """ - Refines contour film bounds to the strongest bed->film luminance gradient within - a +/-2% window per edge (contour morphology kernels inflate/deflate bounds by - ~10-20px). Each edge moves only on a dominant gradient; otherwise it is kept. - """ - h, w = lum.shape[:2] - y1, y2, x1, x2 = roi - col_profile = lum[y1:y2, :].mean(axis=0) - row_profile = lum[:, x1:x2].mean(axis=1) - # 16px floor: contour morphology kernels are fixed-pixel (21-31px), so their - # inflation doesn't shrink with image size the way the 2% window does. - snap = dict(window_out=0.02, window_in=0.02, min_dominance=3.0, min_window_px=16) - nx1 = _snap_edge_to_gradient(col_profile, x1, is_start=True, **snap) - nx2 = _snap_edge_to_gradient(col_profile, x2, is_start=False, **snap) - ny1 = _snap_edge_to_gradient(row_profile, y1, is_start=True, **snap) - ny2 = _snap_edge_to_gradient(row_profile, y2, is_start=False, **snap) - if ny2 - ny1 <= 0 or nx2 - nx1 <= 0: - return roi - # Keep the bed-to-film transition rows inside the box: downstream refinement needs them, - # and the (2+offset) crop margin re-tightens afterwards. - return max(0, ny1 - 2), min(h, ny2 + 2), max(0, nx1 - 2), min(w, nx2 + 2) - - -class _TierLevels(NamedTuple): - bed: float - rebate: float - image: float - ring_spread: float - - -# Smallest fraction of a film-box side the tier refinement may keep. A rebate never takes -# half a side, so anything below this is a broken occupancy run, not a frame. -_TIER_MIN_KEEP = 0.5 - - def _detection_luma(img: np.ndarray) -> np.ndarray: """ Luminance normalized so the light bed sits near 1.0 (anchored at P99.5). Content-stable alternative to _normalize_to_uint8's 1/99 stretch. """ - lum = get_luminance(ensure_image(_ensure_color(img))) - anchor = float(np.percentile(lum, 99.5)) - return np.clip(lum / max(anchor, 1e-6), 0.0, 2.0) + return negpy_core.detection_luma(get_luminance(ensure_image(_ensure_color(img)))) BORDER_SIDES: Tuple[str, ...] = ("top", "bottom", "left", "right") -# Walk cap per side, as a fraction of that side. Real rebate plus bed slop stays well under -# it, so reaching the cap means the profile never came back down and bright picture content -# touches that edge. Those sides report NaN and pooling ignores them. -_BORDER_WALK_CAP = 0.06 -_BORDER_MIN_SEPARATION = 0.15 - def measure_film_border(lum: np.ndarray, film_roi: ROI) -> dict[str, float]: """ @@ -978,42 +175,8 @@ def measure_film_border(lum: np.ndarray, film_roi: ROI) -> dict[str, float]: bright sky reaching the frame edge. Callers pool these across a roll and take the median. A side with no clean border->image transition reports NaN. """ - y1, y2, x1, x2 = film_roi - box = lum[y1:y2, x1:x2] - bh, bw = box.shape[:2] - if bh < 16 or bw < 16: - return {name: float("nan") for name in BORDER_SIDES} - - image_level = float(np.percentile(box, 40)) - row_profile = box.mean(axis=1) - col_profile = box.mean(axis=0) - - result: dict[str, float] = {} - for name in BORDER_SIDES: - profile, length = (row_profile, bh) if name in ("top", "bottom") else (col_profile, bw) - walk = profile[::-1] if name in ("bottom", "right") else profile - # A 1-2 px border is too few samples for a median; use a high percentile. - outer = walk[: max(2, round(0.03 * length))] - base = float(np.percentile(outer, 90)) - if base - image_level < _BORDER_MIN_SEPARATION: - result[name] = 0.0 # no bright border on this side (full-bleed edge) - continue - threshold = 0.5 * (base + image_level) - cap = max(3, int(_BORDER_WALK_CAP * length)) - head = walk[:cap] - # From the brightest sample, not index 0. The box edge usually carries a sliver of bed - # that ramps up into the rebate, and a walk starting there is already under the - # threshold and reports zero on a side that has a border. - peak = int(np.argmax(head)) - if head[peak] <= threshold: - result[name] = 0.0 # no bright border on this side (full-bleed edge) - continue - below = np.flatnonzero(head[peak:] <= threshold) - result[name] = float("nan") if below.size == 0 else float(peak + int(below[0])) / length - return result - - -_OPPOSITE_SIDE = {"top": "bottom", "bottom": "top", "left": "right", "right": "left"} + sides = negpy_core.measure_film_border(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) + return dict(zip(BORDER_SIDES, sides)) def _roi_from_measured_border(lum: np.ndarray, film_roi: ROI) -> ROI | None: @@ -1024,251 +187,7 @@ def _roi_from_measured_border(lum: np.ndarray, film_roi: ROI) -> ROI | None: bright side is usually a uniform scene region, and trusting it carves the picture down to a dark subject. """ - measured = measure_film_border(lum, film_roi) - y1, y2, x1, x2 = film_roi - height, width = y2 - y1, x2 - x1 - - # Cap against the opposite side, so one over-long walk cannot cut deep against a much - # smaller reading across the same gate. - insets: dict[str, float] = {} - for name in BORDER_SIDES: - own, opposite = measured[name], measured[_OPPOSITE_SIDE[name]] - if not np.isfinite(own): - own = opposite - if not np.isfinite(own): - insets[name] = 0.0 - continue - if own > 0.0 and np.isfinite(opposite) and opposite > 0.0: - own = min(own, 2.0 * opposite) - insets[name] = float(own) - - paired = any(insets[first] > 0.0 and insets[second] > 0.0 for first, second in (("top", "bottom"), ("left", "right"))) - if not paired: - return None - - roi = ( - y1 + round(insets["top"] * height), - y2 - round(insets["bottom"] * height), - x1 + round(insets["left"] * width), - x2 - round(insets["right"] * width), - ) - if roi[1] <= roi[0] or roi[3] <= roi[2]: - return None - return roi - - -# Camera-scan holder mask against a bed normalized to 1.0. Dense negative bottoms out near -# 0.05, so the cut sits below that. The mask is not always this clean, which is why the walk -# bridges instead of classifying every pixel. -_EDGE_MASK_LEVEL = 0.03 -# Clear film base / bare bed. _detection_luma anchors P99.5 at 1.0, so the bed is a stable -# landmark; picture only reaches here when the frame carries no base at all. -_EDGE_BASE_LEVEL = 0.95 -# Gap the walk carries across, covering the mid-gray ramp between mask and base. -_EDGE_BRIDGE = 8 -_EDGE_TRIM_CAP = 0.03 -_EDGE_CORE = 0.70 -_EDGE_QUANTILE = 80 -_EDGE_MIN_SUPPORT = 0.45 -# Borders that no absolute level finds, read against the picture on the same line. A rebate -# on a thin negative sits above the picture but below the bed, because the frame's own -# highlights set the P99.5 anchor. A holder edge sits below the picture but above the mask -# cut, shading from near-black to mid-grey along the side. Both must end in a step, which is -# what separates them from a bright subject or a deep shadow touching the frame edge. -_EDGE_REBATE_MARGIN = 0.10 -# The dark side runs a wider margin: dense negative crowds the bottom of the range, so a -# shadow sits nearer the picture median than a highlight does. -_EDGE_SHADE_MARGIN = 0.16 -_EDGE_REBATE_STEP = 0.18 -_EDGE_REBATE_REF = 60 - -# Second, deeper walk. _EDGE_TRIM_CAP is the depth a border may reach on trust alone. Past -# it a border must prove itself, because that much of a side is worth real picture. A wide -# holder margin, a sprocket rail or a neighbouring frame all run several times deeper than -# the cap, and clamping there leaves a band the eye reads as an uncropped edge. -_EDGE_DEEP_CAP = 0.10 -# Wider than _EDGE_BRIDGE: a border this thick is layered (mask, ramp, base, sometimes a -# second mask segment) and the mid-gray runs between the tiers are longer at this depth. -_EDGE_DEEP_BRIDGE = 12 -# Proof one: the step ends a straight edge. A film boundary is a cut line, so the per-line -# depths fit a sloped line to within a fraction of the allowance. Picture content resolved -# by the same rule scatters several times wider. -_EDGE_DEEP_STRAIGHT = 0.025 -# Proof two: the walk stopped on its own. Reaching the allowance means no boundary was -# found inside it, and the depth is then an artefact of where the search was cut off. -_EDGE_DEEP_FULL = 0.92 -# Proof three: the step carries most of the drop from the outer level to the picture. A -# boundary does that and a subject shading toward the edge does not. Replaces the fixed -# _EDGE_REBATE_STEP at depth, where a dense frame compresses every level. -_EDGE_STEP_SHARE = 0.45 -_EDGE_STEP_FLOOR = 0.06 -# Proof four: the outermost pixels are one material. Mask, bed and film base each hold their -# level along the whole side, so the outer level barely moves from line to line, while -# picture reaching the frame edge moves with the subject. -_EDGE_DEEP_OUTER_SPREAD = 0.15 - -_EDGE_ORIENT = { - "top": lambda box: box, - "bottom": lambda box: box[::-1], - "left": lambda box: box.T, - "right": lambda box: box.T[::-1], -} - - -def _walk_border_depth(lines: np.ndarray, limit: int, bridge: int, relative_step: bool) -> np.ndarray: - """ - Per-line depth of the non-image border, walking inward from the frame edge. - - A camera scan puts two tiers outside the picture, either of which may be absent: an - opaque holder mask at the very edge, then a clear film-base sliver. Neither is - separable by level alone — the mask overlaps dense negative around 0.05 and the ramp - between the tiers sits squarely in picture range — but the tiers are adjacent, so the - walk carries across a short gap and stops at the last border evidence instead of the - first picture-like pixel. - - relative_step scales the step a soft-ended run must show by that run's own drop, - instead of the fixed _EDGE_REBATE_STEP; see _EDGE_STEP_SHARE. - """ - head = lines[: limit + 1] - # Picture level per line, taken past the deepest cut allowed so a thick border - # cannot raise its own reference and hide itself. - picture = np.median(lines[limit : limit + _EDGE_REBATE_REF], axis=0) - - firm = (head < _EDGE_MASK_LEVEL) | (head > _EDGE_BASE_LEVEL) - # Tiers no absolute level reaches, read against the picture on the same line: a rebate - # brighter than the picture but under the bed, and a holder edge darker than the picture - # but over the mask cut. A holder edge shades from near-black to mid-grey along the side, - # so the absolute cut catches only the dark end. - reference = picture[None, :] - border = firm | (head > reference + _EDGE_REBATE_MARGIN) | (head < reference - _EDGE_SHADE_MARGIN) - - depth = np.zeros(head.shape[1], dtype=np.int32) - last = np.full(head.shape[1], -1, dtype=np.int32) - soft = np.zeros(head.shape[1], dtype=bool) - alive = np.ones(head.shape[1], dtype=bool) - # Tracked alongside: how deep the mask and bed alone reach. Firm evidence is its own - # proof, so wherever it runs the border runs and the gradient below never arbitrates. It - # matters when the border stacks two firm tiers, mask outside and film base inside: the - # outer boundary is the stronger step, and a gradient picks that and leaves the base - # inside the frame. - firm_depth = np.zeros(head.shape[1], dtype=np.int32) - firm_last = np.full(head.shape[1], -1, dtype=np.int32) - firm_alive = np.ones(head.shape[1], dtype=bool) - for i in range(head.shape[0]): - # Evidence must chain back to the frame edge: the border tiers are the outermost - # things on the film, never something found well inside the picture. - alive &= (i - last) <= bridge - firm_alive &= (i - firm_last) <= bridge - if not alive.any() and not firm_alive.any(): - break - hit = alive & border[i] - last = np.where(hit, i, last) - depth = np.where(hit, i + 1, depth) - soft = np.where(hit, ~firm[i], soft) - - firm_hit = firm_alive & firm[i] - firm_last = np.where(firm_hit, i, firm_last) - firm_depth = np.where(firm_hit, i + 1, firm_depth) - - # Where the walk ended on a relative test alone, the border edge comes from the strongest - # gradient inside the run, not from where the level test gave out. A level test only - # nominates candidates: against a bright picture, "darker than the reference" stays true - # well into the picture's own shadows and the run ends mid-ramp. The gradient is the - # boundary. - # - # That peak must also be a real step. A border stops at the frame edge, a hard boundary, - # while a bright subject or deep shadow running to the picture edge shades off, and - # reading that as a border carves the frame down. Magnitude, not sign: a rebate is - # brighter than the picture it ends against and a holder edge is darker. Mask and bed - # need no such proof, since nothing else on the film reaches those levels. - span = np.abs(lines[3 : limit + 3] - lines[:limit]) - reach = np.arange(span.shape[0])[:, None] < depth[None, :] - masked = np.where(reach, span, -1.0) - peak = masked.max(axis=0) - # The outermost span that nearly matches the peak, not the peak itself. Where the picture - # keeps rising past the boundary, a span straddling the step plus some picture outscores - # the step alone and drags the cut inward. Ties go outward, keeping picture. - peak_at = np.argmax(masked >= (0.9 * peak)[None, :], axis=0) - # span[j] compares j against j+3, so a step between j+2 and j+3 first peaks at j: the - # boundary is the far end of the winning span, not its middle. - resolved = np.clip(peak_at + 3, 0, limit) - if relative_step: - # Scaled by the run's own drop, not fixed. A dense frame compresses every level toward - # black, so its borders step by less while still stepping by nearly all there is. - outer = np.median(head[:3], axis=0) - gate = np.maximum(_EDGE_STEP_FLOOR, _EDGE_STEP_SHARE * np.abs(outer - picture)) - else: - gate = np.full(peak.shape, _EDGE_REBATE_STEP, dtype=peak.dtype) - depth = np.where(soft, np.where(peak >= gate, resolved, 0), depth) - return np.maximum(depth, firm_depth) - - -def _pool_border_depth(depth: np.ndarray, limit: int) -> int: - if float((depth > 0).mean()) < _EDGE_MIN_SUPPORT: - return 0 # a minority of lines: incidental highlights, not a border - return int(min(limit, np.percentile(depth, _EDGE_QUANTILE))) - - -def _border_edge_scatter(depth: np.ndarray) -> float: - """ - How far the resolved depths sit off a straight line, in pixels (median residual). - - A film boundary is a cut line, so a robust fit through the per-line depths leaves - almost nothing; the slope absorbs the tilt every frame has. Picture content resolved - by the same rule follows the subject instead and scatters. - """ - live = depth > 0 - if int(live.sum()) < 8: - return float("inf") - index = np.nonzero(live)[0].astype(np.float64) - value = depth[live].astype(np.float64) - step = max(1, index.size // 48) - a, b = index[::step], value[::step] - # Theil-Sen over a coarse subsample: the median pairwise slope ignores the runs that - # follow a subject, which a least-squares fit would let tilt the line. - da, db = a[:, None] - a[None, :], b[:, None] - b[None, :] - upper = np.triu(np.ones_like(da, dtype=bool), 1) & (da != 0) - slope = float(np.median(db[upper] / da[upper])) if upper.any() else 0.0 - intercept = float(np.median(value - slope * index)) - return float(np.median(np.abs(value - (slope * index + intercept)))) - - -def _edge_border_depth(box: np.ndarray, side: str, limit: int, deep_limit: int = 0) -> int: - """ - Depth of the non-image border on one side of a film box, in pixels. - - Walks each line inward on its own and pools the depths — per line rather than per row - because these frames are slightly tilted, and one row crosses both border tiers and the - picture at once, which smears every per-row statistic together. - - Two passes. The first reaches `limit` and is believed as it stands. The second reaches - `deep_limit`, and is adopted only where it goes deeper and proves itself: it must stop - inside its allowance, leave a straight edge, and end on a step that carries most of the - run's drop. Past `limit` a wrong answer costs real picture, so the burden is on the - deeper reading. - """ - lines = _EDGE_ORIENT[side](box) - n = lines.shape[1] - margin = int(round((1.0 - _EDGE_CORE) * 0.5 * n)) - if n - 2 * margin >= 8: - lines = lines[:, margin : n - margin] - if limit <= 0 or lines.shape[0] < limit + 8 or lines.shape[1] < 8: - return 0 - - near = _pool_border_depth(_walk_border_depth(lines, limit, _EDGE_BRIDGE, False), limit) - if deep_limit <= limit or lines.shape[0] < deep_limit + 8: - return near - - deep = _walk_border_depth(lines, deep_limit, _EDGE_DEEP_BRIDGE, True) - far = _pool_border_depth(deep, deep_limit) - if far <= near or far >= _EDGE_DEEP_FULL * deep_limit: - return near - if _border_edge_scatter(deep) > _EDGE_DEEP_STRAIGHT * deep_limit: - return near - outer = np.median(lines[:3], axis=0)[deep > 0] - if float(np.median(np.abs(outer - np.median(outer)))) > _EDGE_DEEP_OUTER_SPREAD: - return near - return far + return negpy_core.roi_from_measured_border(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) def measure_film_edges(lum: np.ndarray, film_roi: ROI) -> dict[str, float]: @@ -1281,22 +200,8 @@ def measure_film_edges(lum: np.ndarray, film_roi: ROI) -> dict[str, float]: few pixels, so that ring straddles both tiers plus the picture and abstains; this walks the edge itself instead. """ - y1, y2, x1, x2 = film_roi - box = lum[y1:y2, x1:x2] - bh, bw = box.shape[:2] - if bh < 16 or bw < 16: - return {name: 0.0 for name in BORDER_SIDES} - - ly = int(round(_EDGE_TRIM_CAP * bh)) - lx = int(round(_EDGE_TRIM_CAP * bw)) - dy = int(round(_EDGE_DEEP_CAP * bh)) - dx = int(round(_EDGE_DEEP_CAP * bw)) - return { - "top": _edge_border_depth(box, "top", ly, dy) / bh, - "bottom": _edge_border_depth(box, "bottom", ly, dy) / bh, - "left": _edge_border_depth(box, "left", lx, dx) / bw, - "right": _edge_border_depth(box, "right", lx, dx) / bw, - } + sides = negpy_core.measure_film_edges(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) + return dict(zip(BORDER_SIDES, sides)) def _trim_film_edges(lum: np.ndarray, film_roi: ROI) -> ROI: @@ -1306,22 +211,10 @@ def _trim_film_edges(lum: np.ndarray, film_roi: ROI) -> ROI: Sides are independent: a frame sitting off-centre in the holder shows a border on some sides and none on others, so requiring an opposite pair (as _roi_from_measured_border does) would leave the border on. The caps are what bound the damage instead — picture - running to the frame edge is trimmed by at most _EDGE_TRIM_CAP of the side, or - _EDGE_DEEP_CAP where the deeper walk proved a border. - """ - y1, y2, x1, x2 = film_roi - height, width = y2 - y1, x2 - x1 - measured = measure_film_edges(lum, film_roi) - - roi = ( - y1 + round(measured["top"] * height), - y2 - round(measured["bottom"] * height), - x1 + round(measured["left"] * width), - x2 - round(measured["right"] * width), - ) - if roi[1] - roi[0] < 0.5 * height or roi[3] - roi[2] < 0.5 * width: - return film_roi - return roi + running to the frame edge is trimmed by at most 3% of the side, or 10% where the + deeper walk proved a border (negpy_core holds the walk and its constants). + """ + return negpy_core.trim_film_edges(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) def _find_rebate_level(lum: np.ndarray, film_roi: ROI, bed: Optional[float] = None) -> Optional[Tuple[float, float]]: @@ -1333,125 +226,7 @@ def _find_rebate_level(lum: np.ndarray, film_roi: ROI, bed: Optional[float] = No qualifying plateau wins. Returns (rebate_level, spread) or None. `bed` is the 99th percentile of `lum`, for a caller that already has it. """ - y1, y2, x1, x2 = film_roi - box = lum[y1:y2, x1:x2] - bh, bw = box.shape[:2] - if bh < 16 or bw < 16: - return None - if bed is None: - bed = float(np.percentile(lum, 99)) - box_median = float(np.percentile(box, 50)) - ring_w = max(3, round(0.04 * min(bh, bw))) - sides = { - "top": box[:ring_w, :], - "bottom": box[-ring_w:, :], - "left": box[:, :ring_w], - "right": box[:, -ring_w:], - } - - qualifying: dict[str, Tuple[float, float]] = {} - for name, strip in sides.items(): - vals = strip[strip < bed - 0.05] # exclude sprocket holes / bed slop in the box - if vals.size < 0.25 * strip.size: - continue - p80, p20, p60 = percentiles(vals, (80, 20, 60)) - spread = float(p80 - p20) - if spread > 0.10: - continue # textured strip = image content reaches this film edge - p60 = float(p60) - if p60 < box_median + 0.10: - continue # rebate is the lowest-density tier: must sit clearly above the - # image interior; a plateau near the median is bright image content - # reaching the film edge (full-bleed) or holder slop, not film base - qualifying[name] = (p60, spread) - - # A genuine film rebate borders the frame, so it shows up as an opposite pair. A lone - # qualifying side is almost always a uniform bright scene region in a full-bleed frame, - # and trusting it carves the picture down to a dark subject. Require a pair. - has_pair = ("top" in qualifying and "bottom" in qualifying) or ("left" in qualifying and "right" in qualifying) - if not has_pair: - return None - return max(qualifying.values(), key=lambda t: t[0]) - - -def _estimate_tier_levels(lum: np.ndarray, film_roi: ROI) -> Optional[_TierLevels]: - """ - Estimates the three luminance tiers (bed, rebate, exposed image) for a film box. - Returns None when the tiers are not reliably separable. - """ - y1, y2, x1, x2 = film_roi - box = lum[y1:y2, x1:x2] - - bed = float(np.percentile(lum, 99)) - found = _find_rebate_level(lum, film_roi, bed) - if found is None: - return None - rebate, ring_spread = found - - dark = box[box < rebate - 0.02] - if dark.size < 0.05 * box.size: - return None - image_level = float(np.percentile(dark, 30)) - - separation = rebate - image_level - if separation < max(0.04, 3.0 * ring_spread): - return None - return _TierLevels(bed, rebate, image_level, ring_spread) - - -def _longest_run_above(profile: np.ndarray, threshold: float) -> Optional[Tuple[int, int]]: - """ - Longest contiguous half-open index run with profile >= threshold. - """ - idx = np.where(profile >= threshold)[0] - if idx.size == 0: - return None - breaks = np.where(np.diff(idx) > 1)[0] - starts = np.concatenate(([0], breaks + 1)) - ends = np.concatenate((breaks, [idx.size - 1])) - k = int(np.argmax(idx[ends] - idx[starts])) - return int(idx[starts[k]]), int(idx[ends[k]]) + 1 - - -def _snap_edge_to_gradient( - profile: np.ndarray, - idx: int, - is_start: bool, - window_out: float = 0.06, - window_in: float = 0.02, - min_dominance: float = 2.0, - min_window_px: int = 3, -) -> int: - """ - Snaps a coarse edge index to the strongest |gradient| of the smoothed profile - within an asymmetric window biased outward (toward the film border) — recovers - frame area when bright content touching the edge suppresses occupancy. - Keeps idx unless the peak clearly dominates the window (>= 2x median). - """ - n = profile.size - if n < 8: - return idx - sm = _smooth_signal(profile, max(3, round(0.01 * n))) - grad = np.abs(np.diff(sm)) - out_px = max(min_window_px, round(window_out * n)) - in_px = max(min_window_px, round(window_in * n)) - if is_start: - lo, hi = max(0, idx - out_px), min(n - 1, idx + in_px) - else: - lo, hi = max(0, idx - in_px), min(n - 1, idx + out_px) - window = grad[lo:hi] - if window.size == 0: - return idx - peak = float(np.max(window)) - if peak >= min_dominance * float(np.median(window)) + 1e-6: - # Box smoothing turns a sharp step into a nearly flat plateau, so picking the first - # raw maximum lets sub-ULP OpenCV differences move the snapped edge by several pixels - # across resolutions and platforms. Treat equivalent maxima as one peak and keep the - # transition nearest the coarse contour edge. - near_peak = np.flatnonzero(np.isclose(window, peak, rtol=1e-5, atol=1e-7)) - transitions = lo + near_peak + 1 - return int(transitions[np.argmin(np.abs(transitions - idx))]) - return idx + return negpy_core.find_rebate_level(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi), bed) def _refine_film_roi_by_tiers(lum: np.ndarray, film_roi: ROI) -> Optional[Tuple[ROI, np.ndarray, np.ndarray]]: @@ -1462,78 +237,14 @@ def _refine_film_roi_by_tiers(lum: np.ndarray, film_roi: ROI) -> Optional[Tuple[ detection-image coords (profiles padded to full image length), or None when tiers are not separable (caller falls back to the Sobel path). """ - levels = _estimate_tier_levels(lum, film_roi) - if levels is None: - return None - - y1, y2, x1, x2 = film_roi - box = lum[y1:y2, x1:x2] - bh, bw = box.shape[:2] - # Image pixels are anything meaningfully darker than the rebate plateau, since any - # exposure adds density over base+fog. This keeps thin, bright frame regions classified as - # image. The midpoint is the floor for tightly separated tiers. - threshold = max( - 0.5 * (levels.rebate + levels.image), - levels.rebate - max(0.04, 3.0 * levels.ring_spread), - ) - mask = box < threshold - - row_occ = mask.mean(axis=1) - vrun = _longest_run_above(row_occ, 0.55) - if vrun is None: - return None - vt, vb = vrun - # Restrict to the vertical run so rebate rows don't dilute column occupancy. - col_occ = mask[vt:vb].mean(axis=0) - hrun = _longest_run_above(col_occ, 0.55) - if hrun is None: - return None - hl, hr = hrun - - # Single-frame sanity. Multi-frame strip scans fail here and fall back to Sobel. - # The floor is half a side: no rebate eats more than that, so a shorter run means the - # occupancy broke on bright picture content inside a full-bleed frame and the "rebate" - # tier was really the picture reaching the film edge. That splits the frame into a - # sliver, and the sliver is what the caller would crop to. - if (vb - vt) < _TIER_MIN_KEEP * bh or (hr - hl) < _TIER_MIN_KEEP * bw: - return None - area_ratio = ((vb - vt) * (hr - hl)) / float(bh * bw) - if not (0.25 <= area_ratio <= 0.95): - return None - - col_profile = box[vt:vb, :].mean(axis=0) - row_profile = box[:, hl:hr].mean(axis=1) - hl = _snap_edge_to_gradient(col_profile, hl, is_start=True) - hr = _snap_edge_to_gradient(col_profile, hr, is_start=False) - vt = _snap_edge_to_gradient(row_profile, vt, is_start=True) - vb = _snap_edge_to_gradient(row_profile, vb, is_start=False) - - pad_y = max(2, round(0.004 * bh)) - pad_x = max(2, round(0.004 * bw)) - vt, vb = max(0, vt - pad_y), min(bh, vb + pad_y) - hl, hr = max(0, hl - pad_x), min(bw, hr + pad_x) - if vb - vt <= 0 or hr - hl <= 0: - return None - - h_det, w_det = lum.shape[:2] - row_occ_full = np.zeros(h_det, dtype=np.float32) - row_occ_full[y1:y2] = row_occ - col_occ_full = np.zeros(w_det, dtype=np.float32) - col_occ_full[x1:x2] = col_occ - - return (y1 + vt, y1 + vb, x1 + hl, x1 + hr), row_occ_full, col_occ_full + return negpy_core.refine_film_roi_by_tiers(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) def film_and_picture(det: np.ndarray) -> Tuple[Optional[ROI], ROI]: """The film box (None when the scan shows no film edge) and the picture gate inside it, at detection resolution. With no film box the gate is refined over the whole frame; a degenerate gate (a blank or dark frame) falls back to the whole frame.""" - h, w = det.shape[:2] - film = detect_film_bounds_with_confidence(det) - gate, _, _ = _refine_roi_to_image(det, film.roi if film.roi is not None else (0, h, 0, w)) - if gate[1] <= gate[0] or gate[3] <= gate[2]: - gate = (0, h, 0, w) - return film.roi, gate + return negpy_core.film_and_picture(np.ascontiguousarray(det, dtype=np.float32)) def rebate_mask(shape: Tuple[int, int], film_roi: Optional[ROI], gate: ROI) -> np.ndarray: @@ -1555,37 +266,7 @@ def _refine_roi_to_image(img: ImageBuffer, film_roi: ROI) -> Tuple[ROI, Optional measurement, then Sobel. Returns (roi, row_occupancy | None, col_occupancy | None). """ - lum = _detection_luma(img) - refined = _refine_film_roi_by_tiers(lum, film_roi) - if refined is not None: - return refined - - # Ahead of the border measurement, which reads one bright tier through a narrow ring. A - # camera scan that fills the sensor compresses mask and base into a few pixels, so that - # ring straddles both plus the picture and abstains. - walked = _trim_film_edges(lum, film_roi) - if walked != film_roi: - return walked, None, None - - measured = _roi_from_measured_border(lum, film_roi) - if measured is not None: - return measured, None, None - - if _find_rebate_level(lum, film_roi) is None: - # No uniform rebate plateau on any side means image content runs to the film edge. - # There is nothing to refine away, and Sobel would cut into the picture. - return film_roi, None, None - - y1, y2, x1, x2 = film_roi - _, (ref_left, ref_top, ref_right, ref_bottom) = _refine_frame_bounds(img[y1:y2, x1:x2]) - roi = (y1 + ref_top, y1 + ref_bottom, x1 + ref_left, x1 + ref_right) - - # Rebate is a small fraction of the film area, so a Sobel cut removing more than a - # quarter of the box latched onto image content. - film_area = max(1, (y2 - y1) * (x2 - x1)) - if (roi[1] - roi[0]) * (roi[3] - roi[2]) < 0.75 * film_area: - return film_roi, None, None - return roi, None, None + return negpy_core.refine_roi_to_image(np.ascontiguousarray(img, dtype=np.float32), tuple(int(v) for v in film_roi)) def _find_autocrop_roi_from_contours(img: ImageBuffer) -> ROI | None: @@ -1611,13 +292,8 @@ def _get_threshold_autocrop_coords( if assist_luma is not None: threshold = float(np.clip(assist_luma - 0.02, 0.5, 0.98)) - rows_det = np.where(np.mean(lum, axis=1) < threshold)[0] - cols_det = np.where(np.mean(lum, axis=0) < threshold)[0] - - if len(rows_det) < 10 or len(cols_det) < 10: - return 0, h, 0, w - - return int(rows_det[0]), int(rows_det[-1]), int(cols_det[0]), int(cols_det[-1]) + box = negpy_core.threshold_box(np.ascontiguousarray(lum, dtype=np.float32), threshold) + return box if box is not None else (0, h, 0, w) def _trim_opaque_border( @@ -1669,39 +345,6 @@ def _run(profile: np.ndarray, limit: int, from_start: bool) -> int: return ny1, ny2, nx1, nx2 -# OpenCV's warps assert on a side of SHRT_MAX px or more; a larger frame is warped in tiles. -_CV_WARP_MAX = 32767 -_WARP_TILE = 8192 - - -def _remap_tiled(img: ImageBuffer, source_of: Callable[[np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray]]) -> ImageBuffer: - """Same-size bilinear warp with replicated edges, one output tile at a time. ``source_of(xs, ys)`` maps output - to source coords; each tile reads only its source box, so every remap stays under OpenCV's size limit.""" - h, w = img.shape[:2] - out = np.empty_like(img) - for y0 in range(0, h, _WARP_TILE): - for x0 in range(0, w, _WARP_TILE): - y1, x1 = min(h, y0 + _WARP_TILE), min(w, x0 + _WARP_TILE) - ys, xs = np.meshgrid(np.arange(y0, y1, dtype=np.float64), np.arange(x0, x1, dtype=np.float64), indexing="ij") - map_x, map_y = source_of(xs, ys) - sx0 = int(np.clip(np.floor(map_x.min()) - 1, 0, w - 1)) - sx1 = int(np.clip(np.ceil(map_x.max()) + 2, sx0 + 1, w)) - sy0 = int(np.clip(np.floor(map_y.min()) - 1, 0, h - 1)) - sy1 = int(np.clip(np.ceil(map_y.max()) + 2, sy0 + 1, h)) - out[y0:y1, x0:x1] = cv2.remap( - np.ascontiguousarray(img[sy0:sy1, sx0:sx1]), - (map_x - sx0).astype(np.float32), - (map_y - sy0).astype(np.float32), - interpolation=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_REPLICATE, - ) - return out - - -def _too_big_to_warp(img: ImageBuffer) -> bool: - return max(img.shape[:2]) >= _CV_WARP_MAX - - def apply_fine_rotation(img: ImageBuffer, angle: float) -> ImageBuffer: """ Sub-degree rotation (bilinear). @@ -1710,22 +353,8 @@ def apply_fine_rotation(img: ImageBuffer, angle: float) -> ImageBuffer: return img h, w = img.shape[:2] - center = (w / 2.0, h / 2.0) - m_mat = cv2.getRotationMatrix2D(center, angle, 1.0) - if _too_big_to_warp(img): - inv = cv2.invertAffineTransform(m_mat) - return ensure_image( - _remap_tiled(img, lambda xs, ys: (inv[0, 0] * xs + inv[0, 1] * ys + inv[0, 2], inv[1, 0] * xs + inv[1, 1] * ys + inv[1, 2])) - ) - - res = cv2.warpAffine( - img, - m_mat, - (w, h), - flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_REPLICATE, - ) - return ensure_image(res) + m_mat = negpy_core.rotation_matrix((w / 2.0, h / 2.0), angle, 1.0) + return negpy_core.warp_affine(np.ascontiguousarray(img, dtype=np.float32), m_mat, (w, h)) # Radial lens-distortion correction (poly3 / Brown-Conrady k1). The radius is normalized to @@ -1786,39 +415,15 @@ def max_ratio(s: float) -> float: return 0.5 * (lo + hi) -def _radial_maps(k1: float, w: int, h: int) -> Tuple[np.ndarray, np.ndarray]: - """cv2.remap source-coordinate maps for the radial correction (incl. scale-to-fill).""" - cx, cy, halfdiag = _radial_center(w, h) - s = compute_distortion_scale(k1, w, h) - inv_hd2 = 1.0 / (halfdiag * halfdiag) - ys, xs = np.meshgrid(np.arange(h, dtype=np.float32), np.arange(w, dtype=np.float32), indexing="ij") - px = (xs - cx) * s - py = (ys - cy) * s - f = 1.0 + k1 * (px * px + py * py) * inv_hd2 - map_x = (cx + px * f).astype(np.float32) - map_y = (cy + py * f).astype(np.float32) - return map_x, map_y - - def apply_radial_distortion(img: ImageBuffer, k1: float) -> ImageBuffer: """Radial lens-distortion correction. Purely geometric — moves pixels via a coordinate remap, never scales values (brightness-preserving). No-op for k1≈0.""" if abs(k1) < _DISTORT_EPS: return img h, w = img.shape[:2] - if _too_big_to_warp(img): - cx, cy, halfdiag = _radial_center(w, h) - s = compute_distortion_scale(k1, w, h) - - def source_of(xs: np.ndarray, ys: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: - px, py = (xs - cx) * s, (ys - cy) * s - f = 1.0 + k1 * (px * px + py * py) / (halfdiag * halfdiag) - return cx + px * f, cy + py * f - - return ensure_image(_remap_tiled(img, source_of)) - map_x, map_y = _radial_maps(k1, w, h) - res = cv2.remap(img, map_x, map_y, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) - return ensure_image(res) + cx, cy, halfdiag = _radial_center(w, h) + s = compute_distortion_scale(k1, w, h) + return negpy_core.remap_radial(np.ascontiguousarray(img, dtype=np.float32), (cx, cy), s, k1, 1.0 / (halfdiag * halfdiag)) def map_point_radial(px: float, py: float, k1: float, w: int, h: int) -> Tuple[float, float]: @@ -1867,7 +472,7 @@ def keystone_matrix_normalized(converge_v: float, converge_h: float) -> np.ndarr """ src = keystone_quad(converge_v, converge_h) dst = np.float32([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) - return cv2.getPerspectiveTransform(src, dst).astype(np.float64) + return negpy_core.perspective_transform(src, dst).astype(np.float64) def _norm_to_index(w: int, h: int) -> np.ndarray: @@ -1888,22 +493,8 @@ def apply_keystone(img: ImageBuffer, converge_v: float, converge_h: float) -> Im if abs(converge_v) < _KEYSTONE_EPS and abs(converge_h) < _KEYSTONE_EPS: return img h, w = img.shape[:2] - if _too_big_to_warp(img): - hi = np.linalg.inv(keystone_matrix(converge_v, converge_h, w, h)) - - def source_of(xs: np.ndarray, ys: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: - d = hi[2, 0] * xs + hi[2, 1] * ys + hi[2, 2] - return (hi[0, 0] * xs + hi[0, 1] * ys + hi[0, 2]) / d, (hi[1, 0] * xs + hi[1, 1] * ys + hi[1, 2]) / d - - return ensure_image(_remap_tiled(img, source_of)) - res = cv2.warpPerspective( - img, - keystone_matrix(converge_v, converge_h, w, h), - (w, h), - flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_REPLICATE, - ) - return ensure_image(res) + m = keystone_matrix(converge_v, converge_h, w, h) + return negpy_core.warp_perspective(np.ascontiguousarray(img, dtype=np.float32), m, (w, h)) def map_point_keystone(px: float, py: float, converge_v: float, converge_h: float, w: int, h: int) -> Tuple[float, float]: @@ -1944,7 +535,7 @@ def compute_geometry_crop_rect( return 0.0, 0.0, 1.0, 1.0 cx, cy = w / 2.0, h / 2.0 - rot = cv2.getRotationMatrix2D((cx, cy), fine_rotation, 1.0) + rot = negpy_core.rotation_matrix((cx, cy), fine_rotation, 1.0) m_rot = np.vstack([rot, [0.0, 0.0, 1.0]]) m_combined = keystone_matrix(converge_v, converge_h, w, h) @ m_rot m_inv = np.linalg.inv(m_combined) @@ -2096,21 +687,9 @@ def _place_window_by_occupancy(start: int, end: int, target_len: float, occupanc centered position, so uniform occupancy reproduces plain centering. Returns the new window start in input coords. """ - d_start = max(0, int(round(start * scale))) - d_end = min(occupancy.size, int(round(end * scale))) - d_len = max(1, int(round(target_len * scale))) - if d_len >= d_end - d_start: - return start - - cs = np.concatenate(([0.0], np.cumsum(occupancy[d_start:d_end], dtype=np.float64))) - n_pos = (d_end - d_start) - d_len + 1 - scores = cs[d_len : d_len + n_pos] - cs[:n_pos] - candidates = np.where(scores >= float(np.max(scores)) - 1e-9)[0] - centered = ((d_end - d_start) - d_len) / 2.0 - k = int(candidates[np.argmin(np.abs(candidates - centered))]) - - new_start = round((d_start + k) / scale) - return int(min(max(start, new_start), end - target_len)) + return negpy_core.place_window_by_occupancy( + int(start), int(end), float(target_len), np.ascontiguousarray(occupancy, dtype=np.float32), float(scale) + ) def _enforce_ratio_by_occupancy( @@ -2302,12 +881,7 @@ def resolve_autocrop_rect( the same at any resolution. Runs at AUTOCROP_DETECT_RES in GeometryProcessor order (rot90 -> flips -> fine rotation -> distortion -> keystone). Excludes Crop Offset, which get_manual_rect_coords adds per render; None when too small, or with `require_film_box` when no film box stands out from a holder or bed.""" - h, w = img.shape[:2] - det_s = min(1.0, AUTOCROP_DETECT_RES / max(h, w)) - if det_s < 1.0: - tmp = cv2.resize(img, (max(1, round(w * det_s)), max(1, round(h * det_s))), interpolation=cv2.INTER_AREA) - else: - tmp = img + tmp, _ = _normalize_detection_input(img, AUTOCROP_DETECT_RES) if geometry.rotation != 0: tmp = np.rot90(tmp, k=geometry.rotation) if geometry.flip_horizontal: @@ -2375,7 +949,7 @@ def _pre_keystone_point( if fine_rotation != 0.0: center = (w / 2.0, h / 2.0) - m_mat = cv2.getRotationMatrix2D(center, fine_rotation, 1.0) + m_mat = negpy_core.rotation_matrix(center, fine_rotation, 1.0) pt = np.array([px, py, 1.0]) res_pt = m_mat @ pt px, py = float(res_pt[0]), float(res_pt[1]) diff --git a/negpy/features/geometry/skew.py b/negpy/features/geometry/skew.py index 976ae8bca..9b95af509 100644 --- a/negpy/features/geometry/skew.py +++ b/negpy/features/geometry/skew.py @@ -5,7 +5,7 @@ import math from dataclasses import dataclass, replace -import cv2 +import negpy_core import numpy as np from negpy.domain.interfaces import PipelineContext @@ -35,21 +35,10 @@ _ANGLE_BIN = 0.05 # Sprocket holes, edge print and most picture detail run shorter than this. _MIN_SEGMENT = 0.04 -_MIN_LINE_SPAN = 0.25 -# Admits the bow a scanning lens or film curl puts on a frame edge; its chord stays level. -_MAX_LINE_RMS = 2.0 -_MIN_INLIER_FRACTION = 0.5 # A line that disagrees with the rest by more than this after the solve is not the frame. _MAX_LINE_RESIDUAL = 0.15 # Opposite edges this close together cannot separate keystone from noise. _MIN_PAIR_SEPARATION = 0.3 -# A box bounds its edge to within the film detector's snap window (`_snap_film_bounds_to_bed_gradient`). -_BOX_TOLERANCE = 0.02 -_BOX_TOLERANCE_PX = 16 -# A tight scan's border lines sit within this fraction of the cross extent from the canvas edge. -_CANVAS_EDGE_BAND = 0.12 -# Film base or bed outside a border never reads this dark; an opaque holder does. -_OPAQUE_RING = 0.04 # Must admit a clean two-line pair: a strip across the canvas shows two film edges, not four. _SKEW_TRUSTED_CONFIDENCE = 0.5 @@ -90,134 +79,15 @@ class FrameSkew: def _coarse_angles(lum: np.ndarray) -> list[float]: """Dominant folded angles of long segments, strongest first. The runner-up is a second start: picture lines at their own tilt can outvote the frame.""" - h, w = lum.shape - lo, hi = np.percentile(lum, (1.0, 99.0)) - u8 = (np.clip((lum - lo) / max(hi - lo, 1e-6), 0.0, 1.0) * 255.0).astype(np.uint8) - segments = cv2.createLineSegmentDetector().detect(u8)[0] - if segments is None: - return [] - seg = segments.reshape(-1, 4).astype(np.float64) - dx, dy = seg[:, 2] - seg[:, 0], seg[:, 3] - seg[:, 1] - length = np.hypot(dx, dy) - angle = np.degrees(np.arctan2(dy, dx)) - angle = (angle + 45.0) % 90.0 - 45.0 - keep = (length >= _MIN_SEGMENT * max(h, w)) & (np.abs(angle) <= _MAX_SKEW_DEGREES) - if not np.any(keep): - return [] - bins = np.arange(-_MAX_SKEW_DEGREES, _MAX_SKEW_DEGREES + _ANGLE_BIN, _ANGLE_BIN) - hist, _ = np.histogram(angle[keep], bins=bins, weights=length[keep] ** 2) - hist = cv2.GaussianBlur(hist.astype(np.float32).reshape(1, -1), (0, 0), 3.0).ravel() - first = int(np.argmax(hist)) - modes = [float(bins[first] + _ANGLE_BIN / 2)] - rest = hist.copy() - rest[max(0, first - 10) : first + 11] = 0.0 - second = int(np.argmax(rest)) - if rest[second] >= 0.25 * hist[first]: - modes.append(float(bins[second] + _ANGLE_BIN / 2)) - return modes - - -def _profile_peaks(profile: np.ndarray) -> np.ndarray: - """Subpixel positions of the strong gradient steps along one profile.""" - if profile.size < 5: - return np.empty(0) - grad = np.abs(np.gradient(cv2.GaussianBlur(profile.reshape(-1, 1).astype(np.float32), (1, 0), 1.0).ravel())) - peak = float(grad.max()) - if peak <= 3.0 * float(np.median(grad)) + 1e-6: - return np.empty(0) - i = np.arange(1, grad.size - 1) - i = i[(grad[i] >= grad[i - 1]) & (grad[i] > grad[i + 1]) & (grad[i] >= 0.4 * peak)] - a, b, c = grad[i - 1], grad[i], grad[i + 1] - denom = a - 2 * b + c - safe = np.where(np.abs(denom) > 1e-9, denom, 1.0) - return i + np.where(np.abs(denom) > 1e-9, 0.5 * (a - c) / safe, 0.0) - - -def _ransac_line(xs: np.ndarray, ys: np.ndarray, expected: float, half: float, span: float) -> np.ndarray | None: - """The straight run of peaks with the most distinct stations, preferring one near `expected`.""" - if xs.size < 12: - return None - rng = np.random.default_rng(0) - a, b = rng.integers(0, xs.size, (2, 300)) - ok = np.abs(xs[a] - xs[b]) >= 0.3 * span - a, b = a[ok], b[ok] - if a.size == 0: - return None - slope = (ys[b] - ys[a]) / (xs[b] - xs[a]) - ok = np.abs(slope) <= math.tan(math.radians(4.0)) - a, b, slope = a[ok], b[ok], slope[ok] - if a.size == 0: - return None - intercept = ys[a] - slope * xs[a] - res = np.abs(ys[None, :] - (slope[:, None] * xs[None, :] + intercept[:, None])) - inlier = res <= 1.5 - stations = np.array([np.unique(xs[row]).size for row in inlier]) - mid = slope * (0.5 * (xs.min() + xs.max())) + intercept - score = stations * (1.0 - 0.5 * np.minimum(1.0, np.abs(mid - expected) / half)) - best = int(np.argmax(score)) - if stations[best] < 12: - return None - return np.polyfit(xs[inlier[best]], ys[inlier[best]], 1) + return negpy_core.coarse_angles(np.ascontiguousarray(lum, dtype=np.float32), _MIN_SEGMENT, _MAX_SKEW_DEGREES, _ANGLE_BIN) def _fit_side(lum: np.ndarray, horizontal: bool, at: float, start: float, stop: float, source: str, outward: int = 0) -> EdgeLine | None: """Fit the edge running along one axis near `at`, between `start` and `stop`. `outward` is - the sign of the box's outside across the axis, or 0 when `at` is the edge itself.""" - img = lum if horizontal else lum.T - h, w = img.shape - length = stop - start - if length < _MIN_LINE_SPAN * w or not _off_border(at, at, h): - return None - stations = np.linspace(start + 0.1 * length, stop - 0.1 * length, int(min(240, max(24, length / 4)))) - - def sample(center: np.ndarray, half: float, nearest: bool) -> tuple[np.ndarray, np.ndarray]: - xs, ys = [], [] - for x, c in zip(stations, center): - x0 = int(round(x)) - lo, hi = int(max(0, math.floor(c - half))), int(min(h, math.ceil(c + half) + 1)) - if hi - lo < 5 or x0 < 1 or x0 > w - 2: - continue - peaks = _profile_peaks(img[lo:hi, x0 - 1 : x0 + 2].mean(axis=1)) - if peaks.size == 0: - continue - if nearest: - peaks = peaks[[int(np.argmin(np.abs(peaks - (c - lo))))]] - xs.extend([x] * peaks.size) - ys.extend(lo + peaks) - return np.asarray(xs), np.asarray(ys) - - # Keystone leaves the far end of an edge a degree or two inside the box; a stronger edge - # outside it (the holder, the next frame) sits past the detector's error. - inside = max(10.0, 0.035 * max(h, w)) - outside = inside if outward == 0 else max(float(_BOX_TOLERANCE_PX), _BOX_TOLERANCE * h) - center = float(at) + 0.5 * outward * (outside - inside) - half = 0.5 * (inside + outside) - xs, ys = sample(np.full(stations.size, center), half, nearest=False) - fit = _ransac_line(xs, ys, float(at), half, length) - if fit is None: - return None - xs, ys = sample(np.polyval(fit, stations), max(6.0, 0.3 * inside), nearest=True) - if xs.size < 12: - return None - keep = np.ones(xs.size, bool) - for _ in range(8): - fit = np.polyfit(xs[keep], ys[keep], 1) - res = ys - np.polyval(fit, xs) - mad = 1.4826 * float(np.median(np.abs(res[keep] - np.median(res[keep])))) - new = np.abs(res) <= max(1.0, 2.5 * mad) - if new.sum() < 12 or np.array_equal(new, keep): - break - keep = new - inliers = float(keep.sum()) / stations.size - rms = float(np.sqrt(np.mean((ys[keep] - np.polyval(fit, xs[keep])) ** 2))) - if inliers < _MIN_INLIER_FRACTION or rms > _MAX_LINE_RMS: - return None - x1, x2 = float(xs[keep].min()), float(xs[keep].max()) - if x2 - x1 < _MIN_LINE_SPAN * w: - return None - y1, y2 = float(np.polyval(fit, x1)), float(np.polyval(fit, x2)) - p1, p2 = ((x1, y1), (x2, y2)) if horizontal else ((y1, x1), (y2, x2)) - return EdgeLine(p1, p2, horizontal, source, inliers, rms) + the sign of the box's outside across the axis, or 0 when `at` is the edge itself; negpy_core + holds the sampling and the fit.""" + found = negpy_core.skew_fit_side(np.ascontiguousarray(lum, dtype=np.float32), horizontal, at, start, stop, outward) + return None if found is None else EdgeLine(found[0], found[1], found[2], source, found[3], found[4]) def _off_border(lo: float, hi: float, extent: int) -> bool: @@ -227,7 +97,7 @@ def _off_border(lo: float, hi: float, extent: int) -> bool: def _unrotate(line: EdgeLine, angle: float, w: int, h: int) -> EdgeLine: """Map a line found on the image turned by `angle` back to the unturned image.""" - m = np.vstack([cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0), [0.0, 0.0, 1.0]]) + m = np.vstack([negpy_core.rotation_matrix((w / 2.0, h / 2.0), angle, 1.0), [0.0, 0.0, 1.0]]) inv = np.linalg.inv(m) p1 = inv @ [line.p1[0], line.p1[1], 1.0] p2 = inv @ [line.p2[0], line.p2[1], 1.0] @@ -281,50 +151,18 @@ def _line_position(line: EdgeLine) -> float: return 0.5 * (line.p1[idx] + line.p2[idx]) -def _outer_peaks(coverage: np.ndarray, floor: float) -> tuple[int, int] | None: - """First and last local maxima of `coverage` at or above `floor`, off the canvas border.""" - n = coverage.size - i = np.arange(3, n - 3) - peaks = i[(coverage[i] >= floor) & (coverage[i] >= coverage[i - 1]) & (coverage[i] >= coverage[i + 1])] - if peaks.size < 2 or peaks[-1] - peaks[0] < _MIN_PAIR_SEPARATION * n: - return None - return int(peaks[0]), int(peaks[-1]) - - def _profile_lines(lum: np.ndarray) -> list[EdgeLine]: - """Outermost long edges by row and column coverage, for frames the film detector cannot box. - The spread keeps an edge a degree off axis inside one band.""" - h, w = lum.shape - lines: list[EdgeLine] = [] - spread = max(3, int(0.02 * max(h, w))) - gy = np.abs(cv2.Sobel(lum.astype(np.float32), cv2.CV_32F, 0, 1, ksize=3)) - strong = (gy >= 0.25 * float(np.percentile(gy, 99.5))).astype(np.uint8) - strong = cv2.dilate(strong, np.ones((2 * spread + 1, 1), np.uint8)) - rows = _outer_peaks(strong[:, int(0.05 * w) : int(0.95 * w)].mean(axis=1), 0.35) - top, bottom = rows if rows is not None else (0, h) - if rows is not None: - for y in rows: - line = _fit_side(lum, True, float(y), 0.0, float(w), "profile-horizontal") - if line is not None: - lines.append(line) - - gx = np.abs(cv2.Sobel(lum.astype(np.float32), cv2.CV_32F, 1, 0, ksize=3)) - strong = (gx >= 0.25 * float(np.percentile(gx, 99.5))).astype(np.uint8) - strong = cv2.dilate(strong, np.ones((1, 2 * spread + 1), np.uint8)) - lo, hi = top + int(0.05 * (bottom - top)), bottom - int(0.05 * (bottom - top)) - cols = _outer_peaks(strong[lo:hi].mean(axis=0), 0.35) if hi - lo > 10 else None - if cols is not None: - for x in cols: - line = _fit_side(lum, False, float(x), float(top), float(bottom), "profile-vertical") - if line is not None: - lines.append(line) - # A frame shows its edges in opposite pairs; a lone long line is picture content. - return [line for line in lines if sum(other.horizontal == line.horizontal for other in lines) >= 2] + """Outermost long edges by row and column coverage, for frames the film detector cannot box.""" + found = negpy_core.skew_profile_lines(np.ascontiguousarray(lum, dtype=np.float32)) + return [ + EdgeLine(p1, p2, horizontal, f"profile-{'horizontal' if horizontal else 'vertical'}", inliers, rms) + for p1, p2, horizontal, inliers, rms in found + ] def _map(points: np.ndarray, angle: float, cv: float, ch: float, w: int, h: int) -> np.ndarray: """Where input points land after fine rotation then keystone, as the pipeline applies them.""" - m = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) + m = negpy_core.rotation_matrix((w / 2.0, h / 2.0), angle, 1.0) out = points @ m[:, :2].T + m[:, 2] if abs(cv) < 1e-6 and abs(ch) < 1e-6: return out @@ -365,23 +203,8 @@ def _solve(lines: list[EdgeLine], w: int, h: int, fit_v: bool, fit_h: bool) -> t def _at_canvas_edges(lines: list[EdgeLine], lum: np.ndarray) -> bool: """Whether every line hugs the canvas edge, paired on both axes, with no opaque holder outside.""" - if not lines: - return False - h, w = lum.shape - for line in lines: - extent = h if line.horizontal else w - position = _line_position(line) - if min(position, extent - position) > _CANVAS_EDGE_BAND * extent: - return False - index = int(position) - outside = ( - (lum[: max(0, index - 2)] if position < extent / 2 else lum[index + 3 :]) - if line.horizontal - else (lum[:, : max(0, index - 2)] if position < extent / 2 else lum[:, index + 3 :]) - ) - if outside.size and float(np.median(outside)) < _OPAQUE_RING: - return False - return _pair_spread(lines, True, h) >= _MIN_PAIR_SEPARATION and _pair_spread(lines, False, w) >= _MIN_PAIR_SEPARATION + placed = [(line.horizontal, _line_position(line)) for line in lines] + return negpy_core.skew_at_canvas_edges(np.ascontiguousarray(lum, dtype=np.float32), placed) def _pair_spread(lines: list[EdgeLine], horizontal: bool, extent: int) -> float: diff --git a/negpy/features/hdr/logic.py b/negpy/features/hdr/logic.py index 1691155e1..3033fa5d9 100644 --- a/negpy/features/hdr/logic.py +++ b/negpy/features/hdr/logic.py @@ -18,13 +18,11 @@ from dataclasses import dataclass from typing import Callable, List, Optional, Sequence, Tuple -import cv2 +import negpy_core import numpy as np -from numba import prange # type: ignore from negpy.features.hdr.models import ANCHOR_EV_UNSET, HdrConfig from negpy.features.rgbscan.logic import estimate_shift -from negpy.kernel.system.parallel import parallel_njit #: A sample at or above this is saturated and carries no information. SATURATION = 0.995 @@ -66,24 +64,18 @@ def subsample(frame: np.ndarray) -> np.ndarray: A stride, not a resize: averaging neighbours would blend back in the very samples these measurements exclude — saturated ones, noise-floor ones. """ - step = int(np.sqrt(frame.shape[0] * frame.shape[1] / MEASURE_PIXELS)) + step = _step(frame) return frame[::step, ::step] if step > 1 else frame -@parallel_njit(cache=True, fastmath=True) +def _step(frame: np.ndarray) -> int: + """The stride that leaves about MEASURE_PIXELS pixels.""" + return max(1, int(np.sqrt(frame.shape[0] * frame.shape[1] / MEASURE_PIXELS))) + + def _count_clipped_rows(values: np.ndarray, out: np.ndarray) -> None: - """Saturated-pixel count per row. Per row rather than one total: numba's parfor pass - cannot analyse a reduction whose addend is itself computed by a loop.""" - height, width, channels = values.shape - for y in prange(height): - n = 0 - for x in range(width): - hit = 0 - for c in range(channels): - if values[y, x, c] >= SATURATION: - hit = 1 - n += hit - out[y] = n + """Saturated-pixel count per row.""" + negpy_core.count_clipped_rows(np.ascontiguousarray(values, dtype=np.float32), SATURATION, out) def clipped_fraction(values: np.ndarray) -> float: @@ -104,8 +96,8 @@ def measure_exposure(frame: np.ndarray) -> Tuple[float, float]: It saturates too, only later: a frame clipping more than 1% has level exactly 1.0, so level alone cannot order the long end of a bracket. Use `exposure_order_key`. """ - f = to_float(frame) - return float(np.percentile(subsample(f), 99.0)), float(clipped_fraction(f)) + f = np.ascontiguousarray(to_float(frame), dtype=np.float32) + return float(negpy_core.exposure_level(f, _step(f), 99.0)), float(clipped_fraction(f)) def exposure_order_key(level: float, clipped: float) -> Tuple[float, float]: @@ -159,12 +151,8 @@ def pair_ratio(short: np.ndarray, long_: np.ndarray) -> Optional[float]: s, lo = to_float(short), to_float(long_) if s.shape != lo.shape: raise ValueError(f"bracket frames differ in shape: {s.shape}, {lo.shape}") - s, lo = subsample(s), subsample(lo) - valid = (s >= RATIO_FLOOR) & (lo < SATURATION) - if int(valid.sum()) < 1000: - return None - ratio = float(np.median(lo[valid] / s[valid])) - return ratio if np.isfinite(ratio) and ratio > 0.0 else None + s, lo = (np.ascontiguousarray(a, dtype=np.float32) for a in (s, lo)) + return negpy_core.pair_ratio(s, lo, _step(s), (RATIO_FLOOR, SATURATION)) def solve_ratios(frames: Sequence[np.ndarray], reference: int) -> Tuple[float, ...]: @@ -314,7 +302,6 @@ def seed_shadow_density(ratios: Sequence[float], anchor: Optional[float] = None) return -min(SEED_SHADOW_PER_STOP * stops, SEED_SHADOW_LIMIT) -@parallel_njit(cache=True, fastmath=True) def _accumulate(num: np.ndarray, den: np.ndarray, values: np.ndarray, ratio: float) -> None: """Add one frame to the weighted sums, in reference units. One fused pass: the numpy form cost more than the RAW decode it follows, mostly in full-size temporaries. @@ -330,22 +317,7 @@ def _accumulate(num: np.ndarray, den: np.ndarray, values: np.ndarray, ratio: flo still carry signal — and is gradual so the frame a pixel is drawn from changes smoothly. A hard switch prints as banding across a gradient. """ - inv_span = np.float32(1.0) / np.float32(SATURATION - ROLLOFF_START) - weight = np.float32(ratio) ** 2 - scale = np.float32(max(ratio, 1e-9)) - height, width, channels = values.shape - for y in prange(height): - for x in range(width): - for c in range(channels): - v = values[y, x, c] - rolloff = (np.float32(SATURATION) - v) * inv_span - if rolloff < np.float32(0.0): - rolloff = np.float32(0.0) - elif rolloff > np.float32(1.0): - rolloff = np.float32(1.0) - w = weight * rolloff - num[y, x, c] += w * (v / scale) - den[y, x, c] += w + negpy_core.hdr_accumulate(num, den, np.ascontiguousarray(values, dtype=np.float32), ratio, SATURATION, ROLLOFF_START) def merge_providers( @@ -378,7 +350,7 @@ def merge_providers( raise ValueError("no frames to merge") ref = to_float(providers[reference]()) - ref_gray = ref.mean(axis=2) + ref_gray = negpy_core.mean_channels(np.ascontiguousarray(ref, dtype=np.float32)) max_shift = max(16.0, 0.02 * ref.shape[1]) num = np.zeros_like(ref) den = np.zeros_like(ref) @@ -395,11 +367,7 @@ def merge_providers( # Every frame saturated in a channel leaves no measurement there. It is a genuine # highlight, so take the reference's own clipped value rather than a hole. - empty = den <= 0.0 - if empty.any(): - num[empty] = ref[empty] - den[empty] = 1.0 - return np.ascontiguousarray(num / den, dtype=np.float32) + return negpy_core.finish_merge(num, den, np.ascontiguousarray(ref, dtype=np.float32)) def apply_render_exposure(merged: np.ndarray, ratios: Sequence[float], anchor: Optional[float] = None) -> np.ndarray: @@ -408,20 +376,20 @@ def apply_render_exposure(merged: np.ndarray, ratios: Sequence[float], anchor: O Split from the merge because it is cheap and the merge is not: one full-res multiply against seconds of decode. That is what lets the render exposure be dragged. """ - scale = np.float32(output_scale(ratios, anchor)) - return np.clip(merged * scale, 0.0, 1.0).astype(np.float32) + scale = float(np.float32(output_scale(ratios, anchor))) + return negpy_core.render_exposure(np.ascontiguousarray(merged, dtype=np.float32), scale) def _align(ref_gray: np.ndarray, mov: np.ndarray, max_shift: float) -> np.ndarray: """Register `mov` to the reference by sub-pixel translation; no-op on an implausible estimate. Translation only: the camera does not move between bracket frames, so a richer model would only fit noise.""" - dx, dy = estimate_shift(ref_gray, mov.mean(axis=2)) + dx, dy = estimate_shift(ref_gray, negpy_core.mean_channels(np.ascontiguousarray(mov, dtype=np.float32))) if max(abs(dx), abs(dy)) > max_shift: return mov h, w = mov.shape[:2] matrix = np.array([[1.0, 0.0, -dx], [0.0, 1.0, -dy]], dtype=np.float32) - return cv2.warpAffine(mov, matrix, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) + return negpy_core.warp_affine(np.ascontiguousarray(mov, dtype=np.float32), matrix.astype(np.float64), (w, h), interpolation="cubic") def merge_frames( diff --git a/negpy/features/lab/logic.py b/negpy/features/lab/logic.py index 19be7a3a2..8ea9a6504 100644 --- a/negpy/features/lab/logic.py +++ b/negpy/features/lab/logic.py @@ -1,11 +1,13 @@ import math from typing import Callable -import cv2 +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer from negpy.kernel.image.logic import ( + WORKING_INV_GAMMA, + WORKING_LAB, gamut_aware_chroma_scale, lab_to_rgb_working, rgb_to_lab_working, @@ -14,45 +16,6 @@ working_oetf_encode, ) from negpy.kernel.image.validation import ensure_image -from negpy.kernel.system.parallel import SERIAL_MAX_ELEMENTS - - -CLAHE_GRID = 8 -CLAHE_BINS = 256 - - -def _clahe_cdfs(bins: np.ndarray, clip_limit: float) -> np.ndarray: - """ - Per-tile clipped CDFs, (64, 256) float32. Mirrors clahe_hist.wgsl / - clahe_cdf.wgsl exactly (integer counts, f32-truncated limit, excess - redistributed evenly with the remainder going to the first bins). - """ - h, w = bins.shape - tsy, tsx = (h + CLAHE_GRID - 1) // CLAHE_GRID, (w + CLAHE_GRID - 1) // CLAHE_GRID - ty = (np.arange(h) // tsy).astype(np.int32) - tx = (np.arange(w) // tsx).astype(np.int32) - comb = (ty[:, None] * CLAHE_GRID + tx[None, :]) * CLAHE_BINS + bins - hist = np.bincount(comb.ravel(), minlength=CLAHE_GRID * CLAHE_GRID * CLAHE_BINS) - hist = hist.reshape(CLAHE_GRID * CLAHE_GRID, CLAHE_BINS) - - total = hist.sum(axis=1) - limit = np.maximum(1, (np.float32(clip_limit) * total.astype(np.float32) / np.float32(CLAHE_BINS)).astype(np.int64)) - clipped = np.minimum(hist, limit[:, None]) - excess = (hist - clipped).sum(axis=1) - inc, rem = excess // CLAHE_BINS, excess % CLAHE_BINS - counts = clipped + inc[:, None] + (np.arange(CLAHE_BINS)[None, :] < rem[:, None]) - return np.cumsum(counts, axis=1).astype(np.float32) / np.maximum(total, 1)[:, None].astype(np.float32) - - -def _clahe_axis(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - # tile_pos = pos/dims*8 - 0.5, smoothstep frac; true floor (can be -1), clamp 0..7. - tp = np.arange(n, dtype=np.float32) / np.float32(n) * np.float32(CLAHE_GRID) - np.float32(0.5) - tf = np.floor(tp) - rf = tp - tf - fr = rf * rf * (np.float32(3.0) - np.float32(2.0) * rf) - lo = np.maximum(tf.astype(np.int32), 0) - hi = np.minimum(tf.astype(np.int32) + 1, CLAHE_GRID - 1) - return lo, hi, fr def apply_clahe(img: ImageBuffer, strength: float) -> ImageBuffer: @@ -63,37 +26,11 @@ def apply_clahe(img: ImageBuffer, strength: float) -> ImageBuffer: """ if strength <= 0: return img + return negpy_core.clahe(np.ascontiguousarray(img, dtype=np.float32), strength, *WORKING_LAB) - lab = rgb_to_lab_working(img) - l_chan = lab[..., 0] - h, w = l_chan.shape - bins = np.clip(l_chan / np.float32(100.0) * np.float32(255.0), 0.0, 255.0).astype(np.int32) - cdfs = _clahe_cdfs(bins, strength * 2.5).reshape(-1) - - y0, y1, fy = _clahe_axis(h) - x0, x1, fx = _clahe_axis(w) - v00 = cdfs[(y0[:, None] * CLAHE_GRID + x0[None, :]) * CLAHE_BINS + bins] - v10 = cdfs[(y0[:, None] * CLAHE_GRID + x1[None, :]) * CLAHE_BINS + bins] - v01 = cdfs[(y1[:, None] * CLAHE_GRID + x0[None, :]) * CLAHE_BINS + bins] - v11 = cdfs[(y1[:, None] * CLAHE_GRID + x1[None, :]) * CLAHE_BINS + bins] - top = v00 + (v10 - v00) * fx[None, :] - bot = v01 + (v11 - v01) * fx[None, :] - cdf_l = top + (bot - top) * fy[:, None] - - lab[..., 0] = l_chan + (cdf_l * np.float32(100.0) - l_chan) * np.float32(strength) - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) - - -# Sharpen constants, mirrored as WGSL consts in shaders/lab.wgsl. -# Gate separating grain from detail in L*, sized against |L - blur| at a 1 px radius, -# which tops out near 1.0. -SHARPEN_GATE_LO = 0.25 -SHARPEN_GATE_HI = 0.33 -# L*-domain USM exaggerates light halos, so overshoot above the local max is clamped -# tighter than undershoot below the local min. -SHARPEN_OVERSHOOT_LIGHT = 1.0 -SHARPEN_OVERSHOOT_DARK = 2.0 -SHARPEN_MASK_T_HI = 10.0 + +# Sharpen constants, mirrored as WGSL consts in shaders/lab.wgsl; the USM gate, overshoot and +# edge-mask limits live in negpy_core/src/lab.rs. # Gain rolls off toward paper black, where the thin negative's grain lives: floor at # L* 0, full gain from SHARPEN_SHADOW_L_HI up (Gallagher & Gindele, US 7,228,004). SHARPEN_SHADOW_FLOOR = 1.0 / 3.0 @@ -123,24 +60,6 @@ def _smoothstep(e0: float, e1: float, x: np.ndarray) -> np.ndarray: return t * t * (np.float32(3.0) - np.float32(2.0) * t) -def _lab_l_from_y(y: np.ndarray) -> np.ndarray: - """CIELAB L* from linear luminance Y (D65, Yn=1) — mirrors lab_l_from_y in the shaders.""" - y = np.maximum(y, 0.0) - f = np.where(y > 0.008856, np.cbrt(y), np.float32(7.787) * y + np.float32(16.0 / 116.0)) - return (np.float32(116.0) * f - np.float32(16.0)).astype(np.float32) - - -def _edge_mask(l_chan: np.ndarray, masking: float) -> np.ndarray: - """Boxed |∇L*| edge mask (smoothstep over 0.5t..t, t=10·masking); shared by - both sharpen methods. Mirrors the WGSL boxed-gradient loop.""" - lp = np.pad(l_chan, 1, mode="edge") - gx = (lp[1:-1, 2:] - lp[1:-1, :-2]) * np.float32(0.5) - gy = (lp[2:, 1:-1] - lp[:-2, 1:-1]) * np.float32(0.5) - grad = cv2.blur(np.hypot(gx, gy).astype(np.float32), (3, 3), borderType=cv2.BORDER_REPLICATE) - t = SHARPEN_MASK_T_HI * masking - return _smoothstep(0.5 * t, t, grad) - - def sharpen_shadow_gain(l_chan: np.ndarray) -> np.ndarray: """Per-pixel sharpen gain multiplier from L*; mirrors sharpen_shadow_gain in lab.wgsl.""" f = np.float32(SHARPEN_SHADOW_FLOOR) @@ -158,12 +77,9 @@ def _map_row_blocks(img: ImageBuffer, fn: Callable[[ImageBuffer], ImageBuffer], if img.shape[0] <= rows: return fn(img) output = np.empty(img.shape, dtype=np.float32) - min_rows = math.ceil(SERIAL_MAX_ELEMENTS / img[0].size) for start in range(0, img.shape[0], rows): end = min(start + rows, img.shape[0]) low, high = max(0, start - halo), min(img.shape[0], end + halo) - # Keep a short tail on the full frame's Numba dispatch path. - low = min(low, max(0, high - min_rows)) block = fn(img[low:high]) output[start:end] = block[start - low : end - low] return output @@ -211,31 +127,8 @@ def _usm_sharpening_block( lab_sharpen_h/v.wgsl and lab.wgsl mirror this.""" if amount <= 0: return img - - lab = rgb_to_lab_working(img.astype(np.float32)) - l_chan, a, b = cv2.split(lab) - k = gaussian_kernel_1d(radius) - l_blur = cv2.sepFilter2D(l_chan, -1, k, k, borderType=cv2.BORDER_REFLECT_101) - - diff = l_chan - l_blur - gain = np.float32(amount * 2.5) * _smoothstep(SHARPEN_GATE_LO, SHARPEN_GATE_HI, np.abs(diff)) * sharpen_shadow_gain(l_chan) - - if masking > 0.0: - gain = gain * _edge_mask(l_chan, masking) - - kern3 = np.ones((3, 3), np.uint8) - l_min = cv2.erode(l_chan, kern3, borderType=cv2.BORDER_REPLICATE) - l_max = cv2.dilate(l_chan, kern3, borderType=cv2.BORDER_REPLICATE) - - l_new = l_chan + diff * gain - l_new = np.clip(l_new, l_min - np.float32(SHARPEN_OVERSHOOT_DARK), l_max + np.float32(SHARPEN_OVERSHOOT_LIGHT)) - l_new = np.clip(l_new, 0.0, 100.0) - - res_lab = cv2.merge([l_new.astype(np.float32), a, b]) - res_rgb = lab_to_rgb_working(res_lab) - - return ensure_image(np.clip(res_rgb, 0.0, 1.0)) + return negpy_core.usm_sharpen(np.ascontiguousarray(img, dtype=np.float32), np.float32(amount * 2.5), masking, k, *WORKING_LAB) def apply_rl_sharpening( @@ -249,30 +142,11 @@ def apply_rl_sharpening( rl_*.wgsl mirrors this.""" if amount <= 0: return img - - rgb = img.astype(np.float32) - obs = ( - np.maximum(rgb[..., 0], 0.0) * np.float32(LUM_R) - + np.maximum(rgb[..., 1], 0.0) * np.float32(LUM_G) - + np.maximum(rgb[..., 2], 0.0) * np.float32(LUM_B) - ).astype(np.float32) - + lum = (np.float32(LUM_R), np.float32(LUM_G), np.float32(LUM_B)) k = gaussian_kernel_1d(radius) - est = obs.copy() - for _ in range(rl_iterations(radius)): - blurred = cv2.sepFilter2D(est, -1, k, k, borderType=cv2.BORDER_REFLECT_101) - corr = cv2.sepFilter2D(obs / np.maximum(blurred, np.float32(RL_EPS)), -1, k, k, borderType=cv2.BORDER_REFLECT_101) - est = est * corr - - ratio = est / np.maximum(obs, np.float32(RL_EPS)) - l_obs = _lab_l_from_y(obs) - gain = np.float32(amount) * sharpen_shadow_gain(l_obs) - if masking > 0.0: - gain = gain * _edge_mask(l_obs, masking) - - factor = np.maximum(np.float32(1.0) + (ratio - np.float32(1.0)) * gain, 0.0) - out = rgb * factor[..., np.newaxis] - return ensure_image(np.clip(out, 0.0, 1.0)) + return negpy_core.rl_sharpen( + np.ascontiguousarray(img, dtype=np.float32), np.float32(amount), masking, k, rl_iterations(radius), lum, np.float32(RL_EPS) + ) def apply_saturation(img: ImageBuffer, saturation: float, skin_protection: float = 0.0) -> ImageBuffer: @@ -302,40 +176,20 @@ def _saturation_block(img: ImageBuffer, saturation: float, skin_protection: floa if saturation == 1.0 and skin_protection <= 0.0: return img - lab = rgb_to_lab_working(img.astype(np.float32)) + lab = rgb_to_lab_working(np.ascontiguousarray(img, dtype=np.float32)) if saturation != 1.0: lab = gamut_aware_chroma_scale(lab, saturation) if skin_protection > 0.0: lab = skin_chroma_rein(lab, skin_protection) - res_rgb = lab_to_rgb_working(lab) - return ensure_image(np.clip(res_rgb, 0.0, 1.0)) + res_rgb = np.ascontiguousarray(lab_to_rgb_working(lab), dtype=np.float32) + negpy_core.clip_unit(res_rgb) + return ensure_image(res_rgb) -# Chroma Denoise edge test: the low band's gradient times sigma, in CIELAB a*/b* units. Chroma -# above the cutoff is kept from LO, fully from HI. Mirrored as WGSL literals in lab.wgsl. -CHROMA_EDGE_LO = 1.0 -CHROMA_EDGE_HI = 2.5 # Below this blur sigma (px) both engines skip the stage. CHROMA_DENOISE_MIN_SIGMA = 0.25 -def chroma_edge_keep(low_ab: np.ndarray, sigma: float) -> np.ndarray: - """How much of the chroma above the cutoff a pixel keeps (0 to 1). Central differences on the - edge-replicated low band, as the shader reads it with clamped coordinates.""" - gx = cv2.filter2D(low_ab, -1, _CENTRAL_DIFF, borderType=cv2.BORDER_REPLICATE) - gy = cv2.filter2D(low_ab, -1, _CENTRAL_DIFF.T, borderType=cv2.BORDER_REPLICATE) - sq = gx * gx - sq += gy * gy - g = np.sqrt(sq[:, :, 0] + sq[:, :, 1]) - g *= np.float32(sigma / (CHROMA_EDGE_HI - CHROMA_EDGE_LO)) - g -= np.float32(CHROMA_EDGE_LO / (CHROMA_EDGE_HI - CHROMA_EDGE_LO)) - t = np.clip(g, 0.0, 1.0, out=g) - return t * t * (3.0 - 2.0 * t) - - -_CENTRAL_DIFF = np.array([[-0.5, 0.0, 0.5]], dtype=np.float32) - - def chroma_denoise_sigma(radius: float, scale_factor: float) -> float: """The low-pass sigma in render pixels for both engines; 0.0 when the stage does not run.""" sigma = radius * scale_factor @@ -351,16 +205,8 @@ def apply_chroma_denoise(img: ImageBuffer, radius: float, scale_factor: float = if sigma == 0.0: return img - lab = rgb_to_lab_working(img.astype(np.float32)) - ab = np.ascontiguousarray(lab[:, :, 1:3]) k = gaussian_kernel_1d(sigma) - low = cv2.sepFilter2D(ab, -1, k, k) - ab -= low - ab *= chroma_edge_keep(low, sigma)[:, :, None] - ab += low - lab[:, :, 1:3] = ab - - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + return negpy_core.chroma_denoise(np.ascontiguousarray(img, dtype=np.float32), sigma, k, *WORKING_LAB) # Halation mask threshold in LINEAR reflectance: the regions the negative rendered dense, @@ -385,33 +231,13 @@ def apply_glow_and_halation( if glow_amount == 0.0 and halation_strength == 0.0: return img - result = img.copy().astype(np.float32) - + glow = halation = None if glow_amount > 0.0: # Highlight mask in the display domain (keeps the 0.5 threshold); bloom is linear. - enc = working_oetf_encode(img) - luma = enc[:, :, 0] * np.float32(LUM_R) + enc[:, :, 1] * np.float32(LUM_G) + enc[:, :, 2] * np.float32(LUM_B) - threshold = 0.5 - glow_mask = np.clip((luma - threshold) / (1.0 - threshold), 0.0, 1.0) base_r = max(3, int(15 * scale_factor)) - k = min((base_r * 2 + 1) | 1, 201) - sigma = base_r * 0.5 - highlights = (img * glow_mask[:, :, np.newaxis]).astype(np.float32) - glow_blur = cv2.GaussianBlur(highlights, (k, k), sigma) - result = result + glow_blur * glow_amount - + glow = (np.float32(glow_amount), min((base_r * 2 + 1) | 1, 201), base_r * 0.5, 0.5) if halation_strength > 0.0: - lin_luma = img[:, :, 0] * np.float32(LUM_R) + img[:, :, 1] * np.float32(LUM_G) + img[:, :, 2] * np.float32(LUM_B) - t = HALATION_THRESHOLD_LINEAR - hal_mask = np.clip((lin_luma - t) / (1.0 - t), 0.0, 1.0) base_r = max(5, int(25 * scale_factor)) - k = min((base_r * 2 + 1) | 1, 301) - sigma = base_r * 0.5 - red_hl = np.zeros_like(img, dtype=np.float32) - red_hl[:, :, 0] = img[:, :, 0] * hal_mask - red_hl[:, :, 1] = img[:, :, 0] * hal_mask * 0.3 - red_hl[:, :, 2] = img[:, :, 0] * hal_mask * 0.05 - hal_blur = cv2.GaussianBlur(red_hl, (k, k), sigma) - result = result + hal_blur * halation_strength - - return ensure_image(np.clip(result, 0.0, 1.0)) + halation = (np.float32(halation_strength), min((base_r * 2 + 1) | 1, 301), base_r * 0.5, HALATION_THRESHOLD_LINEAR) + lum = (np.float32(LUM_R), np.float32(LUM_G), np.float32(LUM_B)) + return negpy_core.glow_and_halation(np.ascontiguousarray(img, dtype=np.float32), glow, halation, lum, WORKING_INV_GAMMA) diff --git a/negpy/features/lab/processor.py b/negpy/features/lab/processor.py index 7e2247dd3..fdbd268a2 100644 --- a/negpy/features/lab/processor.py +++ b/negpy/features/lab/processor.py @@ -1,3 +1,4 @@ +import negpy_core import numpy as np from negpy.domain.interfaces import PipelineContext @@ -40,4 +41,4 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: if self.config.glow_amount > 0 or self.config.halation_strength > 0: img = apply_glow_and_halation(img, self.config.glow_amount, self.config.halation_strength, context.scale_factor) - return np.clip(img, 0, 1) + return negpy_core.clip01(np.ascontiguousarray(img, dtype=np.float32)) diff --git a/negpy/features/lab/shaders/lab.wgsl b/negpy/features/lab/shaders/lab.wgsl index 0783e2f92..1e9b8748d 100644 --- a/negpy/features/lab/shaders/lab.wgsl +++ b/negpy/features/lab/shaders/lab.wgsl @@ -24,7 +24,7 @@ struct LabUniforms { // (low-pass a*, low-pass b*) from chroma_lowpass_h/v.wgsl. 1x1 dummy when Chroma Denoise is off. @group(0) @binding(4) var chroma_tex: texture_2d; -// Sharpen constants — mirrored from features/lab/logic.py. +// Sharpen constants — mirrored from negpy_core/src/lab.rs. const SHARPEN_GATE_LO = 0.25; const SHARPEN_GATE_HI = 0.33; const SHARPEN_OVERSHOOT_LIGHT = 1.0; @@ -38,7 +38,7 @@ const RL_EPS = 1e-6; // logic.py and the y row in rgb_to_lab below. const WORKING_LUMA_COEFFS = vec3(0.2973769, 0.6273491, 0.0752741); -// CIELAB L* from linear luminance Y (D65, Yn=1) — mirrors _lab_l_from_y in logic.py. +// CIELAB L* from linear luminance Y (D65, Yn=1) — mirrors lab_l_from_y in negpy_core/src/lab.rs. // Mirrors sharpen_shadow_gain in logic.py: gain rolls off toward paper black. fn sharpen_shadow_gain(l: f32) -> f32 { return SHARPEN_SHADOW_FLOOR + (1.0 - SHARPEN_SHADOW_FLOOR) * smoothstep(0.0, SHARPEN_SHADOW_L_HI, l); @@ -122,7 +122,7 @@ const FIBONACCI_64 = array, 64>( // accumulator the same way a Gaussian convolution kernel is normalized (sum=1). const BLOOM_GAUSS_SUM = 27.668145; -// Chroma Denoise edge test — mirrors CHROMA_EDGE_LO/HI in lab/logic.py. +// Chroma Denoise edge test — mirrors CHROMA_EDGE_LO/HI in negpy_core/src/lab.rs. const CHROMA_EDGE_LO = 1.0; const CHROMA_EDGE_HI = 2.5; diff --git a/negpy/features/lens/logic.py b/negpy/features/lens/logic.py index 0151eb841..b8aec4200 100644 --- a/negpy/features/lens/logic.py +++ b/negpy/features/lens/logic.py @@ -3,7 +3,7 @@ from dataclasses import replace from functools import lru_cache -import cv2 +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer @@ -130,7 +130,7 @@ def apply_lens( for start in range(0, h, 256): stop = min(start + 256, h) mx, my = warp.remap(warp_lens, source.shape, start, stop, channel, corrections) - result[start:stop, :, channel] = cv2.remap(plane, mx, my, cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) + result[start:stop, :, channel] = negpy_core.remap(plane, mx, my, "cubic") # Flat-field gains can exceed white before sensor unmix. source = np.maximum(result, 0.0, out=result) return np.ascontiguousarray(apply_exif_orientation(source, orientation)) diff --git a/negpy/features/lens/warps.py b/negpy/features/lens/warps.py index 415f053b0..9973293f7 100644 --- a/negpy/features/lens/warps.py +++ b/negpy/features/lens/warps.py @@ -1,8 +1,9 @@ """Inverse lens maps in unrotated sensor coordinates.""" from dataclasses import dataclass +from functools import lru_cache -import cv2 +import negpy_core import numpy as np from negpy.features.lens.models import LensCorrections, LensMetadata @@ -10,23 +11,16 @@ IDENTITY = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0) -def _rectilinear_xy(x: np.ndarray, y: np.ndarray, coefficients: tuple[float, ...]) -> tuple[np.ndarray, np.ndarray]: - k0, k1, k2, k3, t0, t1 = coefficients - r2 = x * x + y * y - factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)) - return x * factor + 2 * t0 * x * y + t1 * (r2 + 2 * x * x), y * factor + 2 * t1 * x * y + t0 * (r2 + 2 * y * y) - - -def _coordinates(lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, center: tuple[float, float]) -> tuple: +@lru_cache(maxsize=64) +def _frame(lens: LensMetadata, shape: tuple[int, ...], center: tuple[float, float]) -> tuple: + """(buffer origin, buffer-to-image scale, warp center, radius, fill scale) of the frame.""" h, w = shape[:2] t, left, b, r = lens.active_area or (0, 0, h, w) bt, bl, bb, br = lens.buffer_area or (t, left, b, r) sx, sy = (br - bl) / w, (bb - bt) / h cx, cy = left + center[0] * (r - left - 1), t + center[1] * (b - t - 1) radius = np.hypot(max(cx - left, r - 1 - cx), max(cy - t, b - 1 - cy)) - x = (bl + (np.arange(w, dtype=np.float32)[None, :] + 0.5) * sx - 0.5 - cx) / radius * lens.fill_scale - y = (bt + (np.arange(start, stop, dtype=np.float32)[:, None] + 0.5) * sy - 0.5 - cy) / radius * lens.fill_scale - return x, y, cx, cy, radius, sx, sy, bl, bt + return (float(bl), float(bt)), (sx, sy), (cx, cy), float(radius), float(lens.fill_scale) @dataclass(frozen=True) @@ -53,37 +47,19 @@ def remap( channel: int, corrections: LensCorrections = LensCorrections(True, True), ) -> tuple[np.ndarray, np.ndarray]: - x, y, cx, cy, radius, sx, sy, left, top = _coordinates(lens, shape, start, stop, self.center) common = self.coefficients[0 if len(self.coefficients) == 1 else 1] selected = self.coefficients[channel] if corrections.ca and len(self.coefficients) == 3 else common - target_x, target_y = x, y - if not corrections.distortion: - if selected == common: - my, mx = np.mgrid[start:stop, : shape[1]].astype(np.float32) - return mx, my - # CA-only maps to the original green geometry: channel(green_inverse(x, y)). - g0, g1, g2, g3, t0, t1 = common - points = np.stack(np.broadcast_arrays(x, y), axis=-1) - unwarped = cv2.undistortPointsIter( - points.reshape(-1, 1, 2), - np.diag([g0, g0, 1.0]), - np.array([g1, g2, t0, t1, g3]) / g0, - None, - None, - (cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS, 50, 1e-8), - ).reshape(points.shape) - x, y = unwarped[..., 0], unwarped[..., 1] - # Outside the calibrated radius, extend the edge's relative CA displacement. - limit = np.maximum(np.hypot(x, y), 1.0) - x, y = x / limit, y / limit - wx, wy = _rectilinear_xy(x, y, selected) - if not corrections.distortion: - gx, gy = _rectilinear_xy(x, y, common) - scale = np.hypot(target_x, target_y) / np.maximum(np.hypot(gx, gy), 1e-8) - wx, wy = target_x + (wx - gx) * scale, target_y + (wy - gy) * scale - mx = (cx + radius * wx - left + 0.5) / sx - 0.5 - my = (cy + radius * wy - top + 0.5) / sy - 0.5 - return mx.astype(np.float32), my.astype(np.float32) + if corrections.distortion: + frame = _frame(lens, tuple(shape[:2]), self.center) + return negpy_core.rectilinear_map(int(shape[1]), (start, stop), [float(v) for v in selected], frame) + if selected == common: + my, mx = np.mgrid[start:stop, : shape[1]].astype(np.float32) + return mx, my + # CA-only maps to the original green geometry: channel(green_inverse(x, y)); outside the + # calibrated radius the edge's relative CA displacement extends. + frame = _frame(lens, tuple(shape[:2]), self.center) + coefficients = ([float(v) for v in selected], [float(v) for v in common]) + return negpy_core.rectilinear_ca_map(int(shape[1]), (start, stop), coefficients, frame) @dataclass(frozen=True) @@ -112,25 +88,12 @@ def remap( ) -> tuple[np.ndarray, np.ndarray]: # Model from darktable's embedded-metadata reader (GPL-3.0+) and https://github.com/trou/panasonic-rw2: # Ru = Rd + scale*(a*Rd^3 + b*Rd^5 + c*Rd^7), Rd (source) and Ru (corrected) normalized to the half-diagonal. - h, w = shape[:2] - x = (np.arange(w, dtype=np.float32)[None, :] - w * 0.5) * lens.fill_scale - y = (np.arange(start, stop, dtype=np.float32)[:, None] - h * 0.5) * lens.fill_scale - ru = np.hypot(x, y) / np.hypot(w * 0.5, h * 0.5) if not corrections.distortion: my, mx = np.mgrid[start:stop, : shape[1]].astype(np.float32) return mx, my - # Invert Ru -> Rd by fixed-point iteration; dr = Rd/Ru is the source-sampling multiplier. - rd = ru - for _ in range(8): - r2 = rd * rd - f = 1.0 + self.scale * (self.a * r2 + self.b * r2 * r2 + self.c * r2 * r2 * r2) - f = np.where(f > 0.0, f, 1.0) - new = ru / f - if np.abs(new - rd).max() < 1e-7: - break - rd = new - dr = np.divide(rd, ru, out=np.ones_like(ru), where=ru > 0.0) - return (x * dr + w * 0.5).astype(np.float32), (y * dr + h * 0.5).astype(np.float32) + # Ru -> Rd by fixed-point iteration; Rd/Ru is the source-sampling multiplier. + model = (float(self.scale), float(self.a), float(self.b), float(self.c)) + return negpy_core.panasonic_map((int(shape[0]), int(shape[1])), (start, stop), model, float(lens.fill_scale)) @dataclass(frozen=True) @@ -158,17 +121,11 @@ def remap( ) -> tuple[np.ndarray, np.ndarray]: # Sony's knot positions and units follow darktable's embedded-metadata model (GPL-3.0+). # https://github.com/darktable-org/darktable/blob/master/src/iop/lens.cc - h, w = shape[:2] - x = (np.arange(w, dtype=np.float32)[None, :] - w * 0.5) * lens.fill_scale - y = (np.arange(start, stop, dtype=np.float32)[:, None] - h * 0.5) * lens.fill_scale - radius = np.hypot(x, y) / np.hypot(w * 0.5, h * 0.5) n = len(self.distortion) or len(self.ca_red) or len(self.ca_blue) - knots = (np.arange(n) + 0.5) / (n - 1) factors = np.ones(n) if corrections.distortion and self.distortion: factors += np.asarray(self.distortion) / 16384.0 ca = self.ca_red if channel == 0 else self.ca_blue if channel == 2 else () if corrections.ca and ca: factors *= 1 + np.asarray(ca) / 2097152.0 - factor = np.interp(radius, knots, factors).astype(np.float32) - return (x * factor + w * 0.5).astype(np.float32), (y * factor + h * 0.5).astype(np.float32) + return negpy_core.sony_map((int(shape[0]), int(shape[1])), (start, stop), factors.tolist(), float(lens.fill_scale)) diff --git a/negpy/features/lith/logic.py b/negpy/features/lith/logic.py index f2f2e37ae..0e19465f9 100644 --- a/negpy/features/lith/logic.py +++ b/negpy/features/lith/logic.py @@ -1,10 +1,10 @@ -from typing import Any, Dict, Sequence, Tuple +from typing import Any, Dict, Sequence +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer -from negpy.kernel.image.logic import _LAB_EPS, _LAB_KAPPA, lab_to_rgb_working -from negpy.kernel.image.validation import ensure_image +from negpy.kernel.image.logic import WORKING_LAB LITH_CONSTANTS: Dict[str, Any] = { # Highlight branch (first development phase). @@ -38,14 +38,6 @@ } -def _hue_path(u: np.ndarray, path: Sequence[Sequence[float]]) -> Tuple[np.ndarray, np.ndarray]: - """(a*, b*) along the paper's density hue path, clamped outside the anchors.""" - xp = np.asarray(LITH_CONSTANTS["path_u"], dtype=np.float32) - a = np.interp(u, xp, np.asarray([p[0] for p in path], dtype=np.float32)).astype(np.float32) - b = np.interp(u, xp, np.asarray([p[1] for p in path], dtype=np.float32)).astype(np.float32) - return a, b - - def apply_lith( img: ImageBuffer, path: Sequence[Sequence[float]], @@ -74,26 +66,16 @@ def apply_lith( return img c = LITH_CONSTANTS - arr = np.clip(img.astype(np.float32), 1e-6, 1.0) - luma = arr[:, :, 0] * np.float32(0.2126) + arr[:, :, 1] * np.float32(0.7152) + arr[:, :, 2] * np.float32(0.0722) - - base = -np.log10(np.clip(luma, 1e-6, 1.0)) - over = np.float32(0.301 * exposure) - d0 = base + over - - foot_max = np.float32(c["foot_max"]) - d_foot = base + over * np.float32(c["foot_veil"]) - d_h = foot_max * (np.float32(1.0) - np.exp(-np.float32(c["foot_rate"]) * d_foot / foot_max)) - - knee = np.float32(d_max * (c["knee_lo"] - c["knee_span"] * snatch)) - width = np.float32(max(c["abrupt_lo"] - c["abrupt_span"] * abruptness, 0.01)) - - g = 1.0 / (1.0 + np.exp(-np.clip((d0 - knee) / width, -30.0, 30.0))) - dens = (d_h + (np.float32(d_max) - d_h) * g).astype(np.float32) - - grey = np.power(np.float32(10.0), -dens) - a_star, b_star = _hue_path(np.clip(dens / np.float32(d_max), 0.0, 1.0), path) - f = np.where(grey > np.float32(_LAB_EPS), np.cbrt(grey), np.float32(_LAB_KAPPA) * grey + np.float32(16.0 / 116.0)) - lab = np.dstack([np.float32(116.0) * f - np.float32(16.0), a_star, b_star]) - - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + f32 = np.float32 + scalars = [ + f32(d_max), + f32(0.301 * exposure), + f32(c["foot_max"]), + f32(c["foot_veil"]), + f32(c["foot_rate"]), + f32(d_max * (c["knee_lo"] - c["knee_span"] * snatch)), + f32(max(c["abrupt_lo"] - c["abrupt_span"] * abruptness, 0.01)), + ] + path_u = [f32(v) for v in c["path_u"]] + anchors = [(f32(a), f32(b)) for a, b in path] + return negpy_core.lith(np.ascontiguousarray(img, dtype=np.float32), scalars, path_u, anchors, WORKING_LAB) diff --git a/negpy/features/local/logic.py b/negpy/features/local/logic.py index 7a1b8dd47..40a92497f 100644 --- a/negpy/features/local/logic.py +++ b/negpy/features/local/logic.py @@ -1,7 +1,7 @@ import math from typing import List, Sequence, Tuple -import cv2 +import negpy_core import numpy as np from negpy.features.local.models import MAX_KEYED_MASKS, LocalAdjustmentsConfig, LocalMask, MaskKey, MaskShape @@ -40,17 +40,19 @@ def outline_points(shape: MaskShape, pts: Sequence[Point]) -> List[Point]: return smooth_polyline(list(pts), closed=True) -def _rasterise_ramp(a: Point, b: Point, h: int, w: int) -> np.ndarray: - """The card-edge ramp. Alpha is 1 at `a` and 0 at `b`, and constant beyond each.""" - ax, ay = a[0] * w, a[1] * h - dx, dy = b[0] * w - ax, b[1] * h - ay - denom = dx * dx + dy * dy - if denom <= 1e-9: - return np.zeros((h, w), dtype=np.float32) - xs = (np.arange(w, dtype=np.float32) - ax) * (dx / denom) - ys = (np.arange(h, dtype=np.float32) - ay) * (dy / denom) - t = np.clip(xs[None, :] + ys[:, None], 0.0, 1.0) - return (1.0 - t * t * (3.0 - 2.0 * t)).astype(np.float32) +def _mask_spec(shape: MaskShape, pts: Sequence[Point], h: int, w: int, feather_sigma: float) -> tuple: + """The alpha half of a negpy_core mask spec: ("fill", outline px, (sigma, ...)) or a gradient's + card-edge ramp, 1 at the first point and 0 at the second, constant beyond each.""" + if shape == MaskShape.GRADIENT: + (a, b) = pts[0], pts[1] + ax, ay = a[0] * w, a[1] * h + dx, dy = b[0] * w - ax, b[1] * h - ay + denom = dx * dx + dy * dy + if denom <= 1e-9: + return ("empty", None, (0.0, 0.0, 0.0, 0.0, 0.0)) + return ("ramp", None, (0.0, ax, ay, dx / denom, dy / denom)) + outline = np.array([[x * w, y * h] for x, y in outline_points(shape, pts)], dtype=np.float32) + return ("fill", outline.astype(np.int32), (float(feather_sigma), 0.0, 0.0, 0.0, 0.0)) def rasterise( @@ -63,28 +65,18 @@ def rasterise( ) -> np.ndarray: """Rasterise the control points, normalised to [0,1], to a float32 alpha [h, w]. - `feather_sigma` is a Gaussian sigma in pixels on the hard fill. A gradient ignores - it, because the distance between its two points sets the softness. + `feather_sigma` is a Gaussian sigma in pixels on the hard fill, over a kernel of about 3 + sigma. A gradient ignores it, because the distance between its two points sets the softness. """ - if shape == MaskShape.GRADIENT: - alpha = _rasterise_ramp(pts[0], pts[1], h, w) - else: - outline = np.array([[x * w, y * h] for x, y in outline_points(shape, pts)], dtype=np.float32) - filled = np.zeros((h, w), dtype=np.uint8) - cv2.fillPoly(filled, [outline.astype(np.int32)], 255) - alpha = filled.astype(np.float32) / 255.0 - if feather_sigma > 1e-3: - k = int(feather_sigma * 3) | 1 # odd kernel covering ~3 sigma - alpha = cv2.GaussianBlur(alpha, (k, k), feather_sigma) - return 1.0 - alpha if invert else alpha + kind, outline, params = _mask_spec(shape, pts, h, w, feather_sigma) + return negpy_core.rasterise_mask((h, w), (kind, outline, params, bool(invert), -1, (0.0, 0.0, 0.0))) def _tinted_area(mask, grid: int) -> np.ndarray | None: - """The mask's alpha above the faint-tail threshold, or None if it has too few points.""" + """The mask's alpha on the overlap grid, or None if it has too few points.""" if len(mask.vertices) < min_points(mask.shape): return None - alpha = rasterise(mask.shape, mask.vertices, grid, grid, mask.feather * grid, mask.invert) - return alpha > _OVERLAP_ALPHA + return rasterise(mask.shape, mask.vertices, grid, grid, mask.feather * grid, mask.invert) def overlapping_masks(config: LocalAdjustmentsConfig, index: int, grid: int = _OVERLAP_GRID) -> frozenset: @@ -106,7 +98,7 @@ def overlapping_masks(config: LocalAdjustmentsConfig, index: int, grid: int = _O if i == index: continue other = _tinted_area(mask, grid) - if other is not None and bool((target & other).any()): + if other is not None and negpy_core.alphas_overlap(target, other, _OVERLAP_ALPHA): hits.add(i) return frozenset(hits) @@ -133,6 +125,7 @@ def compute_local_maps( return maps short_side = float(min(h, w)) + specs = [] for i, mask in enumerate(config.masks): if not mask.enabled or len(mask.vertices) < min_points(mask.shape): continue @@ -152,18 +145,10 @@ def compute_local_maps( ) for rx, ry in mask.vertices ] - - alpha = rasterise(mask.shape, transformed, h, w, mask.feather * short_side, mask.invert) - if i in limited: - maps[:, :, 3 + limited.index(i)] = alpha - continue - maps[:, :, 0] += mask.stops * alpha - if mask.grade: - maps[:, :, 1] += mask.grade * alpha - if mask.flash: - maps[:, :, 2] += mask.flash * alpha - - return maps + kind, outline, params = _mask_spec(mask.shape, transformed, h, w, mask.feather * short_side) + plane = limited.index(i) if i in limited else -1 + specs.append((kind, outline, params, bool(mask.invert), plane, (float(mask.stops), float(mask.grade), float(mask.flash)))) + return negpy_core.local_maps((h, w, 3 + len(limited)), specs) def limited_indices(config: LocalAdjustmentsConfig) -> List[int]: diff --git a/negpy/features/process/capture_color.py b/negpy/features/process/capture_color.py index bc7f08ae7..d49816fbd 100644 --- a/negpy/features/process/capture_color.py +++ b/negpy/features/process/capture_color.py @@ -21,6 +21,7 @@ from typing import Optional, Sequence +import negpy_core import numpy as np # Adobe RGB (1998) D65, the working space (see infrastructure.display.color_spaces). @@ -106,8 +107,7 @@ def apply_camera_matrix(img: np.ndarray, matrix: Optional[np.ndarray]) -> np.nda """ if matrix is None: return img - flat = img.reshape(-1, 3).astype(np.float32, copy=False) - return (flat @ np.asarray(matrix, dtype=np.float32).T).reshape(img.shape) + return negpy_core.matmul_3x3(np.ascontiguousarray(img, dtype=np.float32), np.asarray(matrix, dtype=np.float32)) # Percentile of the whole frame taken as the light, and where it lands in the working @@ -126,12 +126,10 @@ def lightbox_level(img: np.ndarray, matrix: Optional[np.ndarray]) -> Optional[fl olive. ``img`` is the whole frame before ``matrix`` and before any crop, so framing does not move the reference. None when there is no signal. """ - flat = img.reshape(-1, 3) + flat = np.ascontiguousarray(img, dtype=np.float32).reshape(-1, 1, 3) step = max(1, flat.shape[0] // 200_000) - sample = flat[::step].astype(np.float64) - if matrix is not None: - sample = sample @ np.asarray(matrix, dtype=np.float64).T - white = float(np.percentile(sample, _LIGHTBOX_PCT)) + m = None if matrix is None else np.asarray(matrix, dtype=np.float64).reshape(3, 3).tolist() + white = negpy_core.lightbox_white(flat, step, m, _LIGHTBOX_PCT) if not np.isfinite(white) or white <= 1e-6: return None return _LIGHTBOX_TARGET / white diff --git a/negpy/features/process/chart.py b/negpy/features/process/chart.py index a4cd6e76f..abf75e8a6 100644 --- a/negpy/features/process/chart.py +++ b/negpy/features/process/chart.py @@ -9,9 +9,10 @@ from dataclasses import dataclass from typing import Callable, Optional +import negpy_core import numpy as np -from negpy.kernel.image.logic import rgb_to_lab_working, working_oetf_decode +from negpy.kernel.image.logic import _WORKING_GAMMA, rgb_to_lab_working ROWS = 4 COLS = 6 @@ -167,23 +168,27 @@ def sample_radius(corners: np.ndarray) -> float: return float(0.5 * _SAMPLE_FRACTION * min(pitch_x, pitch_y)) -def sample_patches(img: np.ndarray, corners_px: np.ndarray) -> np.ndarray: - """(24, 3) median of each patch box. Median, so dust on the card does not move it.""" +def _patch_medians(img: np.ndarray, corners_px: np.ndarray, gamma: Optional[float]) -> np.ndarray: + """(24, 3) median of each patch box; with `gamma`, of the clipped image decoded from the working encoding.""" h, w = img.shape[:2] r = max(1, int(round(sample_radius(corners_px)))) - out = np.empty((ROWS * COLS, 3), dtype=np.float64) - for i, (x, y) in enumerate(patch_centers(corners_px)): - x0, x1 = int(np.clip(round(x) - r, 0, w - 1)), int(np.clip(round(x) + r + 1, 1, w)) - y0, y1 = int(np.clip(round(y) - r, 0, h - 1)), int(np.clip(round(y) + r + 1, 1, h)) - out[i] = np.median(img[y0:y1, x0:x1, :3].reshape(-1, 3), axis=0) - return out + boxes = [] + for x, y in patch_centers(corners_px): + cx, cy = int(round(x)), int(round(y)) + boxes.append((min(max(cx - r, 0), w - 1), min(max(cx + r + 1, 1), w), min(max(cy - r, 0), h - 1), min(max(cy + r + 1, 1), h))) + return np.array(negpy_core.chart_patches(np.ascontiguousarray(img, dtype=np.float32), boxes, gamma), dtype=np.float64) + + +def sample_patches(img: np.ndarray, corners_px: np.ndarray) -> np.ndarray: + """(24, 3) median of each patch box. Median, so dust on the card does not move it.""" + return _patch_medians(img, corners_px, None) def measure_lab(encoded: np.ndarray, corners_px: np.ndarray, chart: ChartSpec, order: np.ndarray) -> np.ndarray: """The chart's patches in printed order, as Lab, after one exposure gain that matches its mid grays. Exposure is the user's choice and the matrix keeps grays gray, so the gain is not a fit term.""" - linear = sample_patches(np.asarray(working_oetf_decode(np.clip(encoded, 0.0, 1.0))), corners_px) + linear = _patch_medians(encoded, corners_px, float(np.float32(_WORKING_GAMMA))) rgb = np.empty_like(linear) rgb[order] = linear ref = reference_lab(chart) diff --git a/negpy/features/process/hue.py b/negpy/features/process/hue.py index 5a583c2cb..24cea3eac 100644 --- a/negpy/features/process/hue.py +++ b/negpy/features/process/hue.py @@ -10,10 +10,11 @@ from __future__ import annotations +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer -from negpy.kernel.image.logic import lab_to_rgb_working, rgb_to_lab_working +from negpy.kernel.image.logic import WORKING_LAB from negpy.kernel.image.validation import ensure_image @@ -21,11 +22,6 @@ def apply_hue_trim(img: ImageBuffer, degrees: float) -> ImageBuffer: """Rotate every hue by `degrees` in the working CIELAB a*b* plane. Identity at 0.""" if degrees == 0.0: return img - lab = rgb_to_lab_working(np.asarray(img[:, :, :3], dtype=np.float32)) rad = float(np.radians(degrees)) - cos, sin = float(np.cos(rad)), float(np.sin(rad)) - a = lab[:, :, 1].copy() - b = lab[:, :, 2].copy() - lab[:, :, 1] = a * cos - b * sin - lab[:, :, 2] = a * sin + b * cos - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + rotation = (float(np.cos(rad)), float(np.sin(rad))) + return ensure_image(negpy_core.hue_trim(np.ascontiguousarray(img[:, :, :3], dtype=np.float32), rotation, *WORKING_LAB)) diff --git a/negpy/features/process/logic.py b/negpy/features/process/logic.py index 73a4cb879..16615e8a2 100644 --- a/negpy/features/process/logic.py +++ b/negpy/features/process/logic.py @@ -5,6 +5,7 @@ from typing import Optional, Sequence +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer @@ -191,14 +192,6 @@ def _downsample(img: ImageBuffer, max_dim: int) -> ImageBuffer: return img[::step, ::step] -def _corr(a: np.ndarray, b: np.ndarray) -> float: - """Pearson correlation between two flattened channels.""" - a = a.ravel() - float(a.mean()) - b = b.ravel() - float(b.mean()) - denom = float(np.sqrt(np.sum(a * a) * np.sum(b * b))) + 1e-12 - return float(np.sum(a * b) / denom) - - def _has_purple_mask(r_v: float, g_v: float, b_v: float) -> bool: """True iff a single (r,g,b) triplet shows the purple-mask pattern (R≈B>>G).""" deficit = (r_v + b_v) / 2 - g_v @@ -214,24 +207,18 @@ def detect_process_mode(raw: Optional[ImageBuffer]) -> ProcessMode: if raw is None or raw.ndim != 3 or raw.shape[2] < 3: return ProcessMode.C41 - img = get_analysis_crop(raw[:, :, :3].astype(np.float32), _ANALYSIS_BUFFER) - img = _downsample(img, _MAX_ANALYSIS_DIM) + img = _downsample(get_analysis_crop(raw[:, :, :3], _ANALYSIS_BUFFER), _MAX_ANALYSIS_DIM).astype(np.float32) img = np.clip(np.nan_to_num(img, nan=0.0, posinf=1.0, neginf=0.0), 0.0, 1.0) if img.size == 0: return ProcessMode.C41 - r, g, b = img[:, :, 0], img[:, :, 1], img[:, :, 2] + min_corr, (r_mean, b_mean, r_p25, b_p25, r_p98, g_p98, b_p98) = negpy_core.process_mode_stats(np.ascontiguousarray(img)) # B&W: the channels stay near-perfectly correlated even with a color tint, while real # color (C41, E-6) has varied hues and lower correlation. - min_corr = min(_corr(r, g), _corr(g, b), _corr(r, b)) if min_corr > _BW_CORR_THRESHOLD: return ProcessMode.BW - r_mean, b_mean = float(np.mean(r)), float(np.mean(b)) - r_p25, b_p25 = float(np.percentile(r, 25)), float(np.percentile(b, 25)) - r_p98, g_p98, b_p98 = float(np.percentile(r, 98)), float(np.percentile(g, 98)), float(np.percentile(b, 98)) - # Orange mask (standard C41): R much greater than B. Scanners sometimes correct the mask # only in bright areas, so check across density levels. orange_score = max( diff --git a/negpy/features/process/sensor.py b/negpy/features/process/sensor.py index a1670eab2..b56ecac8d 100644 --- a/negpy/features/process/sensor.py +++ b/negpy/features/process/sensor.py @@ -6,14 +6,12 @@ from typing import Optional -import cv2 +import negpy_core import numpy as np -from numba import prange from negpy.domain.types import ImageBuffer from negpy.features.exposure.normalization import get_analysis_crop from negpy.features.process.models import ProcessConfig, ProcessMode, SensorUnmix -from negpy.kernel.system.parallel import parallel_njit _EPS = 1e-4 # Three captures whose normalized responses are this ill-conditioned are one light three times. @@ -86,7 +84,7 @@ def apply_sensor_correction(img: ImageBuffer, matrix: Optional[tuple], mode: str if matrix is None: return img m = np.asarray(matrix, dtype=np.float32).reshape(3, 3) - x = img[:, :, :3].astype(np.float32, copy=False) + x = np.ascontiguousarray(img[:, :, :3], dtype=np.float32) mode = SensorUnmix(mode) if mode in (SensorUnmix.DENSITY, SensorUnmix.TWO_SCALE): out = _density_unmix(x, m) if mode == SensorUnmix.DENSITY else _two_scale_unmix(x, m) @@ -97,31 +95,16 @@ def apply_sensor_correction(img: ImageBuffer, matrix: Optional[tuple], mode: str return out -@parallel_njit(cache=True, fastmath=True) def _unmix_kernel(x: np.ndarray, m: np.ndarray, out: np.ndarray) -> None: """The unmix, clipped at zero.""" - h, w = x.shape[0], x.shape[1] - for i in prange(h): - for j in range(w): - x0, x1, x2 = x[i, j, 0], x[i, j, 1], x[i, j, 2] - for c in range(3): - out[i, j, c] = max(m[c, 0] * x0 + m[c, 1] * x1 + m[c, 2] * x2, 0.0) + negpy_core.sensor_unmix(x, m, out) -@parallel_njit(cache=True, fastmath=True) def _log_unmix_kernel(x: np.ndarray, c: np.ndarray, offset: np.ndarray, floor: np.ndarray, out: np.ndarray) -> None: """exp(c @ ln x + offset) per pixel, with each channel held at its ``floor`` or above.""" - h, w = x.shape[0], x.shape[1] - for i in prange(h): - for j in range(w): - l0 = np.log(max(x[i, j, 0], floor[0])) - l1 = np.log(max(x[i, j, 1], floor[1])) - l2 = np.log(max(x[i, j, 2], floor[2])) - for k in range(3): - out[i, j, k] = np.exp(c[k, 0] * l0 + c[k, 1] * l1 + c[k, 2] * l2 + offset[k]) + negpy_core.sensor_log_unmix(x, c, offset, floor, out) -@parallel_njit(cache=True, fastmath=True) def _two_scale_layer_kernel( blurred: np.ndarray, m: np.ndarray, @@ -136,22 +119,9 @@ def _two_scale_layer_kernel( """Soft-floored unmix of the blurred capture, and the blend weight: a smoothstep on the largest ratio of a channel's unmix gain to its gain at the film base (``own``). The gain is the unfloored unmix's, so a channel the floor holds up still reads as amplified.""" - h, w = blurred.shape[0], blurred.shape[1] - for i in prange(h): - for j in range(w): - x0, x1, x2 = blurred[i, j, 0], blurred[i, j, 1], blurred[i, j, 2] - ratio = 0.0 - for c in range(3): - u = m[c, 0] * x0 + m[c, 1] * x1 + m[c, 2] * x2 - ratio = max(ratio, blurred[i, j, c] * own[c] / max(u, log_floor)) - f = floor * m[c, c] * blurred[i, j, c] - d = u - f - color[i, j, c] = max(0.5 * (u + f + np.sqrt(d * d + f * f)), log_floor) - t = min(max((ratio - start) / (full - start), 0.0), 1.0) - weight[i, j] = t * t * (3.0 - 2.0 * t) - - -@parallel_njit(cache=True, fastmath=True) + negpy_core.sensor_two_scale_layer(blurred, m, own, floor, log_floor, start, full, color, weight) + + def _two_scale_kernel( x: np.ndarray, blurred: np.ndarray, @@ -165,24 +135,7 @@ def _two_scale_kernel( ) -> None: """Linear, blended by ``weight`` toward ``color`` carrying the detail x / blurred through c. A channel whose detail sits ``log_defect`` below both others takes their mean instead.""" - h, w = x.shape[0], x.shape[1] - for i in prange(h): - for j in range(w): - x0, x1, x2 = x[i, j, 0], x[i, j, 1], x[i, j, 2] - wt = weight[i, j] - if wt > 0.0: - e0 = np.log(max(x0, floor) / max(blurred[i, j, 0], floor)) - e1 = np.log(max(x1, floor) / max(blurred[i, j, 1], floor)) - e2 = np.log(max(x2, floor) / max(blurred[i, j, 2], floor)) - d0 = 0.5 * (e1 + e2) if e0 < log_defect + min(e1, e2) else e0 - d1 = 0.5 * (e0 + e2) if e1 < log_defect + min(e0, e2) else e1 - d2 = 0.5 * (e0 + e1) if e2 < log_defect + min(e0, e1) else e2 - for k in range(3): - lin = max(m[k, 0] * x0 + m[k, 1] * x1 + m[k, 2] * x2, 0.0) - if wt > 0.0: - capped = color[i, j, k] * np.exp(c[k, 0] * d0 + c[k, 1] * d1 + c[k, 2] * d2) - lin += wt * (capped - lin) - out[i, j, k] = lin + negpy_core.sensor_two_scale(x, blurred, color, weight, m, c, floor, log_defect, out) def density_unmix_matrix(matrix, base) -> np.ndarray: @@ -197,17 +150,9 @@ def film_base(x: np.ndarray) -> Optional[np.ndarray]: together. One shared cell set, so a colored object cannot pass for the base in a single channel. Only the color is meaningful. None when the thin end is clipped in the capture.""" sample = np.ascontiguousarray(get_analysis_crop(x[::4, ::4, :3], _BASE_BUFFER), dtype=np.float32) - flat = sample.reshape(-1, 3) - if flat.shape[0] == 0 or np.any(np.percentile(flat, _BASE_PERCENTILE, axis=0) >= _CLIP_PLATEAU * flat.max(axis=0)): - return None - h, w = sample.shape[:2] - scale = _BASE_GRID_EDGE / max(h, w) - if scale < 1.0: - sample = cv2.resize(sample, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_AREA) - logs = np.log(np.maximum(sample.reshape(-1, 3), _LOG_FLOOR)).astype(np.float64) # A cell is as thin as its least-thin channel, each channel against its own thin end. - thin = (logs - np.percentile(logs, _BASE_PERCENTILE, axis=0)).min(axis=1) - return np.exp(np.median(logs[thin >= np.quantile(thin, 1.0 - _BASE_SHARE)], axis=0)) + base = negpy_core.film_base(sample, (_BASE_PERCENTILE, _CLIP_PLATEAU, float(_BASE_GRID_EDGE), _LOG_FLOOR, _BASE_SHARE)) + return None if base is None else np.array(base) def _density_unmix(x: np.ndarray, m: np.ndarray) -> Optional[np.ndarray]: @@ -239,13 +184,13 @@ def _two_scale_unmix(x: np.ndarray, m: np.ndarray) -> Optional[np.ndarray]: sigma = max(1.0, max(h, w) * _TWO_SCALE_SIGMA) # The color layer and the weight are smooth by construction, so they are built small. step = max(1, int(sigma // 2)) - small = x if step == 1 else cv2.resize(x, (-(-w // step), -(-h // step)), interpolation=cv2.INTER_AREA) - small = cv2.GaussianBlur(small, (0, 0), sigma / step) + small = x if step == 1 else negpy_core.resize_area(np.ascontiguousarray(x, dtype=np.float32), (-(-w // step), -(-h // step))) + small = negpy_core.gaussian_blur(np.ascontiguousarray(small, dtype=np.float32), (0, 0), sigma / step) color = np.empty_like(small) weight = np.empty(small.shape[:2], np.float32) _two_scale_layer_kernel(small, m, own, COLOR_FLOOR, _LOG_FLOOR, TWO_SCALE_START, TWO_SCALE_FULL, color, weight) if step > 1: - small, color, weight = (cv2.resize(a, (w, h), interpolation=cv2.INTER_LINEAR) for a in (small, color, weight)) + small, color, weight = (negpy_core.resize_linear(np.ascontiguousarray(a, dtype=np.float32), (w, h)) for a in (small, color, weight)) out = np.empty_like(x) _two_scale_kernel(x, small, color, weight, m, c, _LOG_FLOOR, float(np.log(_DEFECT_RATIO)), out) return out diff --git a/negpy/features/retouch/clone.py b/negpy/features/retouch/clone.py index e5be3acba..17e31cf68 100644 --- a/negpy/features/retouch/clone.py +++ b/negpy/features/retouch/clone.py @@ -2,19 +2,14 @@ stroke can copy from an earlier one.""" import hashlib -from typing import Iterable, Tuple +from typing import Iterable -import cv2 +import negpy_core import numpy as np from negpy.features.geometry.logic import smooth_polyline from negpy.features.retouch.models import HEAL_SIZE_REF -# Tone match gain, six stops either way; also bounds the ratio where the source surround is near black. -_MATCH_GAIN_MIN = 1.0 / 64.0 -_MATCH_GAIN_MAX = 64.0 -_EPS = 1e-6 - def clone_token(retouch) -> str: """Config identity of the clone strokes, folded into source_hash.""" @@ -24,28 +19,19 @@ def clone_token(retouch) -> str: return "|clone" + hashlib.sha1(repr(strokes).encode()).hexdigest()[:12] -def _cover(points, radius: float, x0: int, y0: int, shape: Tuple[int, int], w: int, h: int) -> np.ndarray: +def _path(points, x0: int, y0: int, w: int, h: int) -> np.ndarray: + """The stroke's path, smoothed past two points, in crop pixels.""" chain = [(float(p[0]) * w, float(p[1]) * h) for p in points] if len(chain) >= 3: chain = smooth_polyline(chain, closed=False) - local = np.round(np.array(chain, dtype=np.float32) - (x0, y0)).astype(np.int32) - cover = np.zeros(shape, dtype=np.uint8) - r = max(1, int(round(radius))) - if len(local) > 1: - cv2.polylines(cover, [local], False, 1, thickness=2 * r) - for cx, cy in local: - cv2.circle(cover, (int(cx), int(cy)), r, 1, -1) - return cover - - -def _local_mean(plane: np.ndarray, weight: np.ndarray, sigma: float) -> np.ndarray: - """Gaussian mean of ``plane`` over the pixels ``weight`` admits (normalized convolution).""" - num = cv2.GaussianBlur(plane * weight[..., None], (0, 0), sigma) - den = cv2.GaussianBlur(weight, (0, 0), sigma)[..., None] - return num / np.maximum(den, _EPS) + return np.ascontiguousarray(np.round(np.array(chain, dtype=np.float32) - (x0, y0)).astype(np.int32)) def apply_clone_stroke(out: np.ndarray, stroke) -> None: + """One stroke in place: the source patch over the brush footprint (a thick path with round + caps), feathered by distance inside it. With match_tone the source keeps its texture and + takes the destination's low frequencies, both read only outside the brush, as the healing + brush does, so the covered defect never tints its patch.""" points, size, dx, dy, strength, feather, match_tone = stroke if not points or strength <= 0.0: return @@ -62,36 +48,11 @@ def apply_clone_stroke(out: np.ndarray, stroke) -> None: x1, y1 = min(w, int(max(xs)) + pad + 1), min(h, int(max(ys)) + pad + 1) if x1 <= x0 or y1 <= y0: return - - cover = _cover(points, radius, x0, y0, (y1 - y0, x1 - x0), w, h) - if not cover.any(): - return - # Source pixels off the frame clamp to the edge and carry no weight. - yy = np.arange(y0, y1) + sy - xx = np.arange(x0, x1) + sx - valid = ((yy >= 0) & (yy < h))[:, None] & ((xx >= 0) & (xx < w))[None, :] - src = out[np.clip(yy, 0, h - 1)[:, None], np.clip(xx, 0, w - 1)[None, :]].astype(np.float32) - dst = out[y0:y1, x0:x1].astype(np.float32) - - if feather > 0.0: - # The ramp ends at the brush core at most, so full feather still covers the center. - depth = cv2.distanceTransform(cover, cv2.DIST_L2, 3) - alpha = np.clip(depth / max(float(feather) * min(radius, float(depth.max())), _EPS), 0.0, 1.0) - else: - alpha = cover.astype(np.float32) - alpha *= float(strength) * valid - - patch = src - if match_tone: - # Healing-brush tone match: the source keeps its texture and takes the destination's low frequencies. - # Both means read only pixels outside the brush, so the covered defect never tints its patch. - ring = ((cover == 0) & valid).astype(np.float32) - if ring.any(): - gain = _local_mean(dst, ring, sigma) / np.maximum(_local_mean(src, ring, sigma), _EPS) - patch = src * np.clip(gain, _MATCH_GAIN_MIN, _MATCH_GAIN_MAX) - - a = alpha[..., None] - out[y0:y1, x0:x1] = dst * (1.0 - a) + patch * a + brush = max(1, int(round(radius))) + path = _path(points, x0, y0, w, h) + negpy_core.clone_stroke( + out, (x0, y0, x1, y1), path, brush, (radius, sigma), (sx, sy), (float(feather), float(strength), bool(match_tone)) + ) def apply_clone_strokes(img: np.ndarray, strokes: Iterable) -> np.ndarray: diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 9414879e7..b50657829 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -2,14 +2,12 @@ import math from typing import List, Optional, Tuple -import cv2 +import negpy_core import numpy as np -from numba import prange # type: ignore from negpy.domain.types import ImageBuffer from negpy.features.geometry.logic import smooth_polyline from negpy.features.retouch.models import HEAL_SIZE_REF -from negpy.kernel.system.parallel import parallel_njit from negpy.kernel.image.logic import get_luminance from negpy.kernel.image.validation import ensure_image from negpy.kernel.system.logging import get_logger @@ -23,7 +21,7 @@ _DETECT_PAD_PX = 2.5 # Optical detection: density excess over a robust background, averaged over 3x3, in units of # its local MAD σ. Under the average a speck keeps its full excess while grain drops by 3. -# The robust filters run on 8-bit planes (cv2.medianBlur is O(1) there); the excess is +# The robust filters run on 8-bit planes (the uint8 median blur is O(1) there); the excess is # scaled by _DETECT_MAD_GAIN first so grain σ spans enough levels. _DETECT_AVG_PX = 3 _DETECT_MAD_GAIN = 4.0 @@ -44,7 +42,7 @@ # defect raises its own bar. Clean film of any grain sits under the knee. _DETECT_TEXTURE_KNEE = 0.02 _DETECT_TEXTURE_GAIN = 40.0 # bar multiplier per unit of σ over the knee -# A hair-shaped component (_is_hair) tests its median σ against a higher, steeper knee: a hair across +# A hair-shaped component (negpy_core's is_hair) tests its median σ against a higher, steeper knee: a hair across # busy film stays under it, a seed grown along a tonal edge does not. _DETECT_HAIR_TEXTURE_KNEE = 0.075 _DETECT_HAIR_TEXTURE_GAIN = 150.0 @@ -207,12 +205,12 @@ # Strong hairs and scratches route to structure-following inpaint instead of the weighted # fill: a long twist crosses varied background, and averaging across it smears the structure -# the inpaint follows. Detection-scale px. See _is_hair. +# the inpaint follows. Detection-scale px. See is_hair in negpy_core/src/retouch.rs. _HAIR_MIN_AREA = 20 _HAIR_MIN_ELONG = 8.0 # area/thickness² ≈ length/thickness; round specks measure 1–3 -# cv2.inpaint fill: the dilate covers the PSF skirt at 1:1 (apply_hair_inpaint widens it to +# Inpaint fill: the dilate covers the PSF skirt at 1:1 (apply_hair_inpaint widens it to # track the mask's upsample), then the NS radius, then gamma to give the 8-bit encode a -# perceptual spread (cv2.inpaint is 8-bit only). Navier-Stokes propagates outward from the +# perceptual spread (the inpaint is 8-bit). Navier-Stokes propagates outward from the # mask boundary only, so each defect is filled in its own bbox + _HAIR_INPAINT_PAD. Same # pixels, without gamma-encoding the whole frame to serve a hairline. _HAIR_DILATE_PX = 1 @@ -224,37 +222,6 @@ _HAIR_INPAINT_PAD = 16 -def _proxy_norm(img: ImageBuffer) -> Tuple[float, float]: - """(lo, spread) percentile normalization of the detection proxy.""" - dens = -np.log10(np.clip(get_luminance(img), 1e-6, None)) - lo, hi = np.percentile(dens, (0.5, 99.5)) - return float(lo), max(float(hi - lo), _PROXY_MIN_SPREAD) - - -def _is_hair(labels_sub: np.ndarray, area: int) -> bool: - """Hair/scratch (thin) rather than speck: ``2*max(distanceTransform)`` is the widest - the defect ever gets, so ``area/thickness²`` reads as length/thickness for a ribbon. - - Thin, not straight — bending moves no interior pixel further from its edge, so a - twist scores like a straight hair, where PCA extent/width (the obvious measure) - calls it compact. The real hair on samples/ir/18.tiff: PCA aspect 2.45 = "speck", - thinness 26.3 = hair. - """ - if area < _HAIR_MIN_AREA: - return False - # Pad, or a component touching the sub-image border reads as thin along that edge. - dist = cv2.distanceTransform(np.pad(labels_sub.astype(np.uint8), 1), cv2.DIST_L2, 5) - thickness = 2.0 * float(dist.max()) - return area / max(thickness * thickness, 1e-6) >= _HAIR_MIN_ELONG - - -def _box_mean_std(plane: np.ndarray, win: int) -> Tuple[np.ndarray, np.ndarray]: - """Local mean and standard deviation of ``plane`` over a ``win``×``win`` box.""" - mean = cv2.blur(plane, (win, win)) - var = cv2.blur(plane * plane, (win, win)) - mean * mean - return mean, np.sqrt(np.clip(var, 0.0, None)) - - def _density(img: ImageBuffer) -> np.ndarray: """Log density of the luminance. Local contrast measured here is exposure-invariant: a gain on the source is an offset in density and cancels in every difference.""" @@ -265,29 +232,14 @@ def _mask_to_score(mask: np.ndarray, pad_px: float) -> np.ndarray: """Binary defect mask → the continuous score every repair consumes: at the floor on the defect, ramping to clean over ``pad_px``. The ramp is the skirt allowance a hard mask edge would leave behind as a halo.""" - d = cv2.distanceTransform((np.asarray(mask) == 0).astype(np.uint8), cv2.DIST_L2, 3) - t = np.clip(d / max(pad_px, 1e-3), 0.0, 1.0) - return (_IR_SCORE_FLOOR + (1.0 - _IR_SCORE_FLOOR) * (t * t * (3.0 - 2.0 * t))).astype(np.float32) + return negpy_core.mask_to_score((np.asarray(mask) != 0).astype(np.uint8), float(pad_px), _IR_SCORE_FLOOR) def split_hairs(mask: np.ndarray) -> Tuple[np.ndarray, Optional[np.ndarray]]: """Defect mask → ``(compact, hairs)``. Compact defects go to the weighted fill; long twisted ones to structure-following inpaint, which keeps detail the fill would average across. ``hairs`` is None when nothing is thin enough.""" - n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(np.ascontiguousarray(mask, dtype=np.uint8), connectivity=8) - compact = np.zeros(mask.shape[:2], dtype=np.uint8) - hairs: Optional[np.ndarray] = None - for i in range(1, n_lbl): - x0, y0 = int(stats[i, cv2.CC_STAT_LEFT]), int(stats[i, cv2.CC_STAT_TOP]) - bw, bh = int(stats[i, cv2.CC_STAT_WIDTH]), int(stats[i, cv2.CC_STAT_HEIGHT]) - labels_sub = labels[y0 : y0 + bh, x0 : x0 + bw] == i - if _is_hair(labels_sub, int(stats[i, cv2.CC_STAT_AREA])): - if hairs is None: - hairs = np.zeros(mask.shape[:2], dtype=np.uint8) - hairs[y0 : y0 + bh, x0 : x0 + bw][labels_sub] = 1 - else: - compact[y0 : y0 + bh, x0 : x0 + bw][labels_sub] = 1 - return compact, hairs + return negpy_core.split_hairs(np.ascontiguousarray(mask, dtype=np.uint8), (_HAIR_MIN_AREA, _HAIR_MIN_ELONG)) def _u8(plane: np.ndarray) -> np.ndarray: @@ -303,22 +255,16 @@ def compute_dust_stats(img: ImageBuffer, dust_size: int) -> Tuple[np.ndarray, .. expensive part of a detection pass, cacheable across threshold changes. ``z`` is the 3x3-averaged density excess over the median background in units of its own local MAD σ; ``texture`` the wide-window σ of the density.""" - lo, spread = _proxy_norm(img) - proxy = np.clip((_density(img) - lo) / spread, 0.0, 1.0).astype(np.float32) + shape = img.shape[:2] # Size is film footprint at _IR_DETECT_REF; the windows follow the plane (see _ir_win). - base_size = max(1.0, float(dust_size)) * film_scale(proxy.shape) + base_size = max(1.0, float(dust_size)) * film_scale(shape) v_win = int(max(3, base_size * 3.0)) * 2 + 1 - w_win = _texture_win(proxy.shape, dust_size) # Median: the level of the film around a defect narrower than half the window. Opening: # identity on the soft ramp of a tonal edge and on anything wider than a speck, both of # which the median alone calls dense. Whichever is higher is the background. - disk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(round(base_size)) + 1,) * 2) - background = np.maximum(cv2.medianBlur(_u8(proxy), v_win).astype(np.float32) / 255.0, cv2.morphologyEx(proxy, cv2.MORPH_OPEN, disk)) - excess = cv2.blur(proxy - background, (_DETECT_AVG_PX, _DETECT_AVG_PX)) - mad = cv2.medianBlur(_u8(np.abs(excess) * _DETECT_MAD_GAIN), w_win).astype(np.float32) / (255.0 * _DETECT_MAD_GAIN) - z = excess / np.maximum(mad / 0.6745, _DETECT_SIGMA_MIN) - _, texture = _box_mean_std(proxy, w_win) - return tuple(np.ascontiguousarray(a.astype(np.float32)) for a in (proxy, background, z, texture)) + disk = negpy_core.ellipse_kernel((2 * int(round(base_size)) + 1,) * 2) + constants = (_PROXY_MIN_SPREAD, _DETECT_AVG_PX, _DETECT_MAD_GAIN, _DETECT_SIGMA_MIN) + return negpy_core.dust_stats(_density(img), v_win, _texture_win(shape, dust_size), disk, constants) def detect_bar(slider: float) -> float: @@ -347,44 +293,21 @@ def detect_luma_score( proxy, background, z, texture = stats[:4] hi = detect_bar(dust_threshold) hi_hair = hi if hair_threshold is None else detect_bar(hair_threshold) - seeds = (z >= min(hi, hi_hair)) & (proxy > _DETECT_PROXY_MIN) - if not np.any(seeds): - return None, None scale = film_scale(proxy.shape) lo = max(_DETECT_Z_GROW, min(hi, hi_hair) * _DETECT_Z_GROW_FRAC) reach = int(round(_DETECT_GROW_REACH * max(1, dust_size) * scale)) - near = cv2.dilate(seeds.astype(np.uint8), cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * reach + 1,) * 2)) > 0 - n_lbl, lab, stats_cc, _ = cv2.connectedComponentsWithStats((near & (z >= lo)).astype(np.uint8), connectivity=8) - bar = hi * (1.0 + _DETECT_TEXTURE_GAIN * np.maximum(texture - _DETECT_TEXTURE_KNEE, 0.0)) - strong = np.zeros(n_lbl, dtype=bool) - strong[np.unique(lab[seeds & (z >= bar)])] = True - peak = np.zeros(n_lbl, dtype=np.float32) - np.maximum.at(peak, lab[seeds], z[seeds]) - keep = np.zeros(n_lbl, dtype=bool) - hairs = [] - for i in np.flatnonzero(peak[1:] > 0) + 1: - x0, y0 = int(stats_cc[i, cv2.CC_STAT_LEFT]), int(stats_cc[i, cv2.CC_STAT_TOP]) - bw, bh = int(stats_cc[i, cv2.CC_STAT_WIDTH]), int(stats_cc[i, cv2.CC_STAT_HEIGHT]) - if _is_hair(lab[y0 : y0 + bh, x0 : x0 + bw] == i, int(stats_cc[i, cv2.CC_STAT_AREA])): - hairs.append(i) - else: - keep[i] = strong[i] - if hairs: - is_hair = np.zeros(n_lbl, dtype=bool) - is_hair[hairs] = True - _, filled = _box_mean_std(np.where(is_hair[lab], background, proxy), _texture_win(proxy.shape, dust_size)) - hair_tex = np.minimum(texture, _DETECT_HAIR_FILL_GAIN * filled) - for i in hairs: - x0, y0 = int(stats_cc[i, cv2.CC_STAT_LEFT]), int(stats_cc[i, cv2.CC_STAT_TOP]) - bw, bh = int(stats_cc[i, cv2.CC_STAT_WIDTH]), int(stats_cc[i, cv2.CC_STAT_HEIGHT]) - tex = float(np.median(hair_tex[y0 : y0 + bh, x0 : x0 + bw][lab[y0 : y0 + bh, x0 : x0 + bw] == i])) - keep[i] = peak[i] >= hi_hair * (1.0 + _DETECT_HAIR_TEXTURE_GAIN * max(tex - _DETECT_HAIR_TEXTURE_KNEE, 0.0)) - if not keep.any(): - return None, None - hit = keep[lab].astype(np.uint8) - compact, hair_mask = split_hairs(hit) - score = _mask_to_score(compact, _DETECT_PAD_PX * scale) if compact.any() else None - return score, hair_mask + disk = negpy_core.ellipse_kernel((2 * reach + 1,) * 2) + planes = tuple(np.ascontiguousarray(a, dtype=np.float32) for a in (proxy, background, z, texture)) + return negpy_core.detect_luma_score( + planes, + (hi, hi_hair, lo), + disk, + (_DETECT_PROXY_MIN, _DETECT_TEXTURE_KNEE, _DETECT_TEXTURE_GAIN), + (_HAIR_MIN_AREA, _HAIR_MIN_ELONG), + (_DETECT_HAIR_TEXTURE_KNEE, _DETECT_HAIR_TEXTURE_GAIN, _DETECT_HAIR_FILL_GAIN), + _texture_win(proxy.shape, dust_size), + (_DETECT_PAD_PX * scale, _IR_SCORE_FLOOR), + ) def exclusion_cover(strokes: List[Tuple], shape: Tuple[int, int]) -> np.ndarray: @@ -393,19 +316,16 @@ def exclusion_cover(strokes: List[Tuple], shape: Tuple[int, int]) -> np.ndarray: loose dabs would leave the film between them repaired. Smoothed past two points and round-capped, so the band matches the one the overlay draws.""" h, w = shape[:2] - cover = np.zeros((h, w), dtype=np.uint8) scale = max(w, h) / HEAL_SIZE_REF + bands = [] for points, size in strokes: radius = max(1, int(round(float(size) * scale * 0.5))) chain = [(float(p[0]) * w, float(p[1]) * h) for p in points] if len(chain) >= 3: chain = smooth_polyline(chain, closed=False) - pts = np.round(np.array(chain, dtype=np.float32)).astype(np.int32) - if len(pts) > 1: - cv2.polylines(cover, [pts], False, 1, thickness=max(1, 2 * radius)) - for cx, cy in pts: # round caps and joins, as a thick polyline alone ends flat - cv2.circle(cover, (int(cx), int(cy)), radius, 1, -1) - return cover + pts = np.ascontiguousarray(np.round(np.array(chain, dtype=np.float32)).astype(np.int32)) + bands.append((pts, radius, max(1, 2 * radius))) + return negpy_core.stroke_cover((h, w), bands) def drop_exclusions( @@ -425,10 +345,8 @@ def drop_exclusions( if score is not None: # Feather the rim, as the manual heal does its own: the score is a ramp, and cutting # one at the brush edge prints the edge as a step. - d = cv2.distanceTransform(cover, cv2.DIST_L2, 3) - alpha = np.clip(d / _MANUAL_RIM_PX, 0.0, 1.0) - score = (score + alpha * (1.0 - score)).astype(np.float32) - if not (score < 1.0).any(): + score = np.array(score, dtype=np.float32) + if not negpy_core.release_score(score, cover, _MANUAL_RIM_PX): score = None if hair_mask is not None: # Binary, so it takes the footprint unfeathered. @@ -491,50 +409,25 @@ def strokes_to_score( if x1 <= x0 or y1 <= y0: continue - cover = np.zeros((y1 - y0, x1 - x0), dtype=np.uint8) - local = np.round(pts - (x0, y0)).astype(np.int32) - if len(local) > 1: - cv2.polylines(cover, [local], False, 1, thickness=max(1, int(round(2.0 * radius)))) - for cx, cy in local: # round caps and joins — a thick polyline alone leaves flat ends - cv2.circle(cover, (int(cx), int(cy)), max(1, int(round(radius))), 1, -1) - if not cover.any(): - continue - - crop = _density(img[y0:y1, x0:x1]) - detail = cv2.blur(crop - cv2.blur(crop, (win, win)), (3, 3)) - # MAD about zero over the whole crop, so the defect stays a minority in its own - # noise estimate. 0.6745 is the half-normal median, which turns it into a σ. - sigma = float(np.median(np.abs(detail))) / 0.6745 - z = np.abs(detail) / max(sigma, 1e-9) - - inside = cover > 0 - peak = float(z[inside].max()) - if peak < _MANUAL_Z_MIN: - continue # the brush found clean film; repairing it would only smooth grain - # Self-normalize a faint defect against its own peak, so a real mark under the - # absolute bar still repairs. - hi = _MANUAL_Z_HI if peak >= _MANUAL_Z_HI else peak * 0.9 - # Hysteresis, because a single bar cannot do this job: a defect's core clears any bar - # but its soft skirt does not, and a bar low enough to catch the skirt catches grain - # everywhere. Grow from the core to the low bar through connected pixels only. Grain - # is isolated, so it never joins. - lo = max(_MANUAL_Z_GROW, hi * _MANUAL_Z_GROW_FRAC) - strong = inside & (z >= hi) - if not strong.any(): + local = np.ascontiguousarray(np.round(pts - (x0, y0)).astype(np.int32)) + brush = (max(1, int(round(radius))), max(1, int(round(2.0 * radius)))) + # Inside the brush a pixel repairs by how far it stands out from the film around it: + # MAD about zero over the whole crop, so the defect stays a minority in its own noise + # estimate, then hysteresis from the core, because grain is isolated and never joins. + constants = ( + win, + _MANUAL_Z_MIN, + _MANUAL_Z_HI, + _MANUAL_Z_GROW, + _MANUAL_Z_GROW_FRAC, + _DETECT_PAD_PX * film_scale((h, w)), + _IR_SCORE_FLOOR, + _MANUAL_RIM_PX, + ) + region = negpy_core.heal_region(_density(img[y0:y1, x0:x1]), local, brush, constants) + if region is None: continue - _n, lab = cv2.connectedComponents((inside & (z >= lo)).astype(np.uint8), connectivity=8) - seeded = np.unique(lab[strong]) - keep = np.isin(lab, seeded[seeded > 0]).astype(np.uint8) - - # Pad past the defect, like the detector does: the soft PSF skirt falls under any bar - # that keeps grain out, and left unrepaired it prints as a dark outline. - region = _mask_to_score(keep, _DETECT_PAD_PX * film_scale((h, w))) - # Soften the search area's rim, or a defect crossing it repairs to a hard line. - d = cv2.distanceTransform(cover, cv2.DIST_L2, 3) - alpha = np.clip(d / _MANUAL_RIM_PX, 0.0, 1.0) - - region = 1.0 - alpha * (1.0 - region) - np.minimum(score[y0:y1, x0:x1], region.astype(np.float32), out=score[y0:y1, x0:x1]) + np.minimum(score[y0:y1, x0:x1], region, out=score[y0:y1, x0:x1]) touched = True return score if touched else None @@ -579,14 +472,9 @@ def _scratch_ridge(img: ImageBuffer) -> np.ndarray: divided by a local scale of that same response. Local, because a global scale lets one busy corner of the frame set the bar and bury a faint scratch running through smooth sky. """ - dens = _density(img) - fine = cv2.GaussianBlur(dens, (1, 0), sigmaX=0, sigmaY=_SCRATCH_FINE_PX) - broad = cv2.GaussianBlur(dens, (1, 0), sigmaX=0, sigmaY=_SCRATCH_BROAD_PX) - ridge = fine - broad - win = (_SCRATCH_NOISE_WIN, _SCRATCH_NOISE_WIN) - # mean|x| is 0.8σ for gaussian noise, and the bars above are quoted in that ratio. - scale = cv2.blur(np.abs(ridge), win) / 0.8 - return ridge / np.maximum(scale, 1e-6) + # The noise scale is mean|x| over 0.8, its ratio to σ for gaussian noise: the bars above + # are quoted in σ. + return negpy_core.scratch_ridge(_density(img), (_SCRATCH_FINE_PX, _SCRATCH_BROAD_PX, _SCRATCH_NOISE_WIN)) def _shear_rows(plane: np.ndarray, slope: float, about_x: float, width: int) -> np.ndarray: @@ -597,7 +485,7 @@ def _shear_rows(plane: np.ndarray, slope: float, about_x: float, width: int) -> ``row``. """ m = np.float32([[1.0, 0.0, 0.0], [-slope, 1.0, slope * about_x]]) - return cv2.warpAffine(plane, m, (width, plane.shape[0]), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + return negpy_core.warp_affine(np.ascontiguousarray(plane, dtype=np.float32), m.astype(np.float64), (width, plane.shape[0])) def trace_scratch(img: ImageBuffer, nx: float, ny: float, threshold: float = 0.5) -> Optional[Tuple[float, float, float, float, float]]: @@ -623,22 +511,19 @@ def trace_scratch(img: ImageBuffer, nx: float, ny: float, threshold: float = 0.5 z, z0 = _scratch_ridge_rows(img, y0 - reach, y1 + reach) pull = np.exp(-0.5 * ((np.arange(y0, y1) - cy) / _SCRATCH_CLICK_PULL) ** 2) - best: Optional[Tuple[float, float, int, np.ndarray]] = None - for slope in np.arange(-_SCRATCH_SLOPE_MAX, _SCRATCH_SLOPE_MAX + 1e-9, _SCRATCH_SLOPE_STEP): - # Sheared with its reach, then cut: a line leaves the search rows across a wide frame. - sheared = _shear_rows(z, float(slope), cx, w)[y0 - z0 : y1 - z0] - strength = np.abs(sheared.mean(axis=1)) * pull - k = int(np.argmax(strength)) - if best is None or strength[k] > best[0]: - best = (float(strength[k]), float(slope), k, sheared[k]) - if best is None or best[0] < _SCRATCH_MIN_EVIDENCE: + slopes = np.arange(-_SCRATCH_SLOPE_MAX, _SCRATCH_SLOPE_MAX + 1e-9, _SCRATCH_SLOPE_STEP) + # Each slope shears the ridge with its reach, then cuts the search rows: a line leaves them + # across a wide frame. + found = negpy_core.scratch_slope_search(np.ascontiguousarray(z, dtype=np.float32), cx, (y0 - z0, y1 - z0), pull, slopes) + if found is None or found[0] < _SCRATCH_MIN_EVIDENCE: return None - _, slope, k, along = best + _, index, k, along = found + slope = float(slopes[index]) # A transport scratch fades in and out, so measure its extent instead of assuming it # spans the frame. on = (along * np.sign(along.mean()) > bar).astype(np.float32) - run = cv2.blur(on.reshape(1, -1), (_SCRATCH_RUN_WIN, 1)).ravel() >= _SCRATCH_RUN_FRAC + run = negpy_core.box_filter(on.reshape(1, -1), (_SCRATCH_RUN_WIN, 1)).ravel() >= _SCRATCH_RUN_FRAC if not run.any(): return None cols = np.flatnonzero(run) @@ -708,7 +593,7 @@ def lines_to_score(img: ImageBuffer, lines: List[Tuple], threshold: float = 0.5) along = sheared[row] sign = np.sign(along.mean()) or 1.0 on = (along * sign > bar).astype(np.float32) - run = cv2.blur(on.reshape(1, -1), (_SCRATCH_RUN_WIN, 1)).ravel() >= _SCRATCH_RUN_FRAC + run = negpy_core.box_filter(on.reshape(1, -1), (_SCRATCH_RUN_WIN, 1)).ravel() >= _SCRATCH_RUN_FRAC lo, hi = int(max(0, min(x0, x1))), int(min(w, max(x0, x1)) + 1) keep = np.zeros(w, dtype=bool) keep[lo:hi] = run[lo:hi] @@ -740,24 +625,12 @@ def ir_detect_target(buffer_long_edge: int, preview_long_edge: int) -> int: def _ir_live(plane: np.ndarray) -> np.ndarray: """Film under the head: everything but the below-floor regions large enough to be holder.""" - dead = plane < _IR_DEAD_FLOOR - if not dead.any(): - return np.ones(plane.shape[:2], dtype=bool) - n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(dead.astype(np.uint8), connectivity=8) - holder = np.zeros(n_lbl, dtype=bool) - holder[1:] = stats[1:, cv2.CC_STAT_AREA] >= _IR_DEAD_MIN_AREA * dead.size - return ~holder[labels] + return negpy_core.ir_live(np.ascontiguousarray(plane, dtype=np.float32), _IR_DEAD_FLOOR, _IR_DEAD_MIN_AREA) def _ir_glare(plane: np.ndarray, live: np.ndarray) -> Optional[np.ndarray]: """Pixels more than ``_IR_GLARE_SIGMA`` over the median of the live film, or None.""" - sample = plane.ravel()[_fit_sample(live)] - if sample.size < 500: - return None - med = float(np.median(sample)) - sigma = 1.4826 * float(np.median(np.abs(sample - med))) - glare = plane > med + _IR_GLARE_SIGMA * sigma - return glare if glare.any() else None + return negpy_core.ir_glare(np.ascontiguousarray(plane, dtype=np.float32), np.ascontiguousarray(live), _IR_FIT_MAX_PX, _IR_GLARE_SIGMA) def _ir_detect_scale(plane: np.ndarray) -> float: @@ -798,9 +671,9 @@ def _erode_resize_bounded(plane: np.ndarray, dims: Tuple[int, int], kernel: np.n read_top = max(0, source_top - radius) read_bottom = min(h, source_bottom + radius) source = np.ascontiguousarray(plane[read_top:read_bottom], dtype=np.float32) - eroded = cv2.erode(source, kernel) - core = eroded[source_top - read_top : source_bottom - read_top] - horizontal = cv2.resize(core, (dw, len(core)), interpolation=cv2.INTER_AREA) + eroded = negpy_core.morphology_ex(source, negpy_core.MORPH_ERODE, kernel) + core = np.ascontiguousarray(eroded[source_top - read_top : source_bottom - read_top]) + horizontal = negpy_core.resize_area(core, (dw, len(core))) # Keep the full-image sampling grid across fractional block boundaries. for row in range(top, bottom): start = row * scale_y @@ -837,14 +710,17 @@ def downsample_ir(plane: np.ndarray, target_long_edge: int, dims: Optional[Tuple dims = (max(1, int(round(w * s))), max(1, int(round(h * s)))) if dims == (w, h): return np.ascontiguousarray(plane) + # An HxWx1 plane comes back as HxW. + if plane.ndim == 3 and plane.shape[2] == 1: + plane = plane[:, :, 0] # Erode by the resample footprint: a 1.25x downsample must not fatten by a 4.5x kernel. k = max(1, int(round(long_edge / max(dims))) | 1) if k > 1: - kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)) + kernel = negpy_core.ellipse_kernel((k, k)) if plane.nbytes > _IR_DOWNSAMPLE_WORK_BYTES: return _erode_resize_bounded(plane, dims, kernel) - plane = cv2.erode(np.ascontiguousarray(plane), kernel) - return cv2.resize(plane, dims, interpolation=cv2.INTER_AREA).astype(np.float32) + plane = negpy_core.morphology_ex(np.ascontiguousarray(plane), negpy_core.MORPH_ERODE, kernel) + return negpy_core.resize_area(np.ascontiguousarray(plane), dims) def normalize_ir(plane: np.ndarray, glare: Optional[np.ndarray] = None) -> np.ndarray: @@ -854,9 +730,7 @@ def normalize_ir(plane: np.ndarray, glare: Optional[np.ndarray] = None) -> np.nd ``glare`` pixels stay out of the base.""" plane = np.ascontiguousarray(plane, dtype=np.float32) win = _ir_win(_IR_BASE_WIN, _ir_detect_scale(plane)) - src = plane if glare is None else np.where(glare, np.float32(0.0), plane) - base = cv2.blur(cv2.dilate(src, cv2.getStructuringElement(cv2.MORPH_RECT, (win, win))), (win, win)) - return plane / np.maximum(base, 1e-4) + return negpy_core.normalize_ir(plane, None if glare is None else np.ascontiguousarray(glare), win) def ir_detect_cutoff(slider: float, attenuation: bool) -> float: @@ -873,65 +747,8 @@ def ir_defect_score(ratio: np.ndarray, cutoff: float) -> np.ndarray: scale: a sampling allowance, not a film footprint — widening it with the plane re-fattens the mask the finer detection just tightened.""" span = max(_IR_GAIN_IDENTITY - cutoff, 1e-4) - t = (np.ascontiguousarray(ratio, dtype=np.float32) - cutoff) / span - score = np.clip(t * (1.0 - _IR_SCORE_FLOOR) + _IR_SCORE_FLOOR, _IR_SCORE_FLOOR, 1.0) - return cv2.erode(score, np.ones((3, 3), np.uint8)) - - -def score_weighted_fill( - img: np.ndarray, - score: np.ndarray, - scales: Tuple[int, ...] = _IR_FILL_SCALES, - reject_floor_mass: bool = False, -) -> np.ndarray: - """Multiscale score-normalized average, blended coarse→fine by clean fraction. - Where no support holds clean film the quotient tends to zero and the original-floor - rule in apply_score_repair keeps the source pixel. - - ``reject_floor_mass`` measures that clean fraction *above* the score floor. A defect - pixel scores 0.02 rather than 0, so a support seeing nothing but defect still carries - den ≈ 0.02 — enough confidence to win, with the defect's own value as its candidate, - and the fill quietly puts back a share of what it was asked to remove. Callers keeping - the original-floor rule leave this off: it corrects them downstream, and rejecting those - rungs outright shifts weight onto the coarsest support, which is the one that reaches - across a tonal edge and over-lifts the dense side of it. A repair allowed to darken has - no such corrector, so it has to pay for honest confidence there instead. - """ - weighted = img * score[..., None] - fill = np.empty_like(img) - for i, k in enumerate(scales): - if i == len(scales) - 1: - num = cv2.GaussianBlur(weighted, (k, k), 0) - den = cv2.GaussianBlur(score, (k, k), 0) - else: - num = cv2.boxFilter(weighted, -1, (k, k)) - den = cv2.boxFilter(score, -1, (k, k)) - _fill_rung(num, den, fill, i == 0, reject_floor_mass) - return fill - - -@parallel_njit(cache=True) -def _fill_rung(num, den, fill, first, reject_floor_mass): - """One rung of the fill ladder in place: the score-normalized candidate, blended over the - coarser result by the rung's clean fraction. Same float32 operation order as the array - form, one pass over the frame.""" - f32 = np.float32 - h, w = den.shape - floor = f32(_IR_SCORE_FLOOR) - inv_span = f32(1.0) - floor - tau = f32(_IR_FILL_TAU) - for y in prange(h): - for x in range(w): - d = max(den[y, x], f32(1e-6)) - if first: - for c in range(3): - fill[y, x, c] = num[y, x, c] / d - else: - mass = (den[y, x] - floor) / inv_span if reject_floor_mass else den[y, x] - conf = min(max(mass / tau, f32(0.0)), f32(1.0)) - keep = f32(1.0) - conf - for c in range(3): - fill[y, x, c] = fill[y, x, c] * keep + (num[y, x, c] / d) * conf + constants = (cutoff, span, 1.0 - _IR_SCORE_FLOOR, _IR_SCORE_FLOOR) + return negpy_core.ir_defect_score(np.ascontiguousarray(ratio, dtype=np.float32), constants) def _fill_supports(buffer_long_edge: int, factor: float) -> Tuple[int, ...]: @@ -944,32 +761,6 @@ def _fill_supports(buffer_long_edge: int, factor: float) -> Tuple[int, ...]: return tuple(dict.fromkeys([int(round(_IR_FILL_SCALES[0] * film)) | 1] + fine)) -def _borrow_clean_grain(src: np.ndarray, clean: np.ndarray, sigma: float, idx: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: - """Detail of the nearest clean pixel, high-passed at ``sigma``, for the flat pixel - indices ``idx`` only: an (N, 3) array, N = len(idx), plus the ``sigma`` blur of ``src`` - it was measured against, for a caller that needs the same low-pass again. - - Real film rather than synthesized noise: the donor is the closest pixel the IR score calls - clean, so it carries the same emulsion, density and scanner noise as the hole it fills. - Ceiling: deep inside a wide defect every pixel resolves to the same few boundary donors and - the paste flattens toward a constant, leaving those interiors to the fill's own blend. - Only the repaired pixels take grain, so the donor lookup runs on their indices alone; the - distance transform still covers the frame, because a donor can sit anywhere near a hole. - """ - h, w = clean.shape - # DIST_LABEL_PIXEL numbers the zero pixels 1..N in raster order, so flatnonzero inverts it. - _, labels = cv2.distanceTransformWithLabels((~clean).astype(np.uint8), cv2.DIST_L2, 3, labelType=cv2.DIST_LABEL_PIXEL) - nearest = np.flatnonzero(clean.ravel())[labels.ravel()[idx].astype(np.intp) - 1] - # Mirror through the donor, do not sample it: a whole row across a hair shares one - # nearest clean pixel, and pasting that verbatim streaks the grain into bands. - ny, nx = np.divmod(nearest, w) - py, px = np.divmod(idx, w) - mirrored = np.clip(2 * ny - py, 0, h - 1) * w + np.clip(2 * nx - px, 0, w - 1) - take = np.where(clean.ravel()[mirrored], mirrored, nearest) - blur = cv2.GaussianBlur(src, (0, 0), sigma) - return src.reshape(-1, 3)[take] - blur.reshape(-1, 3)[take], blur - - def apply_score_repair( img: ImageBuffer, score_det: np.ndarray, @@ -997,64 +788,13 @@ def apply_score_repair( factor = factor or 1.0 else: factor = max(h / score_det.shape[0], w / score_det.shape[1]) - score = cv2.resize(score_det, (w, h), interpolation=cv2.INTER_LINEAR) - out = score_weighted_fill(src, score, _fill_supports(long_edge or max(h, w), factor), reject_floor_mass=not floor) - # The fill buffer becomes the output: blended over the source under the write ramp. - a = np.empty((h, w), dtype=np.float32) - _blend_fill(src, score, out, a) - # A weighted average lands grainless, so a repair reads as a smooth patch against film. - sigma = max(1.0, factor) - clean = score >= _IR_WRITE_HI - idx = np.flatnonzero(~clean) - blur_src: Optional[np.ndarray] = None - if clean.any() and idx.size: - grain, blur_src = _borrow_clean_grain(src, clean, sigma, idx) - out.reshape(-1, 3)[idx] += a.reshape(-1, 1)[idx] * grain - # Original-floor rule: dust is dark in negative transmittance, so repairs only lighten. - # Compared on the low-frequency deficit, because per pixel the rule is a half-wave - # rectifier that keeps the fill's grain peaks, clips its troughs and leaves the repair - # bright and half-textured. Under the same ramp, so an untouched pixel stays identical. - if floor: - if blur_src is None: - blur_src = cv2.GaussianBlur(src, (0, 0), sigma) - _add_floor_deficit(out, a, blur_src, cv2.GaussianBlur(out, (0, 0), sigma)) - else: - np.maximum(out, 0.0, out=out) + score = negpy_core.resize_linear(np.ascontiguousarray(score_det, dtype=np.float32), (w, h)) + supports = list(_fill_supports(long_edge or max(h, w), factor)) + constants = (_IR_SCORE_FLOOR, _IR_FILL_TAU, _IR_WRITE_HI, _IR_WRITE_HI - _IR_WRITE_LO) + out = negpy_core.apply_score_repair(src, score, supports, bool(floor), max(1.0, factor), constants) return ensure_image(out) -@parallel_njit(cache=True) -def _blend_fill(src, score, fill, a_out): - """``fill`` becomes ``src * (1 - a) + fill * a`` under the smoothstep write ramp of the - score; the ramp is kept in ``a_out`` for the grain and floor passes.""" - f32 = np.float32 - h, w = score.shape - hi = f32(_IR_WRITE_HI) - span = f32(_IR_WRITE_HI - _IR_WRITE_LO) - for y in prange(h): - for x in range(w): - a = min(max((hi - score[y, x]) / span, f32(0.0)), f32(1.0)) - a = a * a * (f32(3.0) - f32(2.0) * a) - a_out[y, x] = a - keep = f32(1.0) - a - for c in range(3): - fill[y, x, c] = src[y, x, c] * keep + fill[y, x, c] * a - - -@parallel_njit(cache=True) -def _add_floor_deficit(out, a, blur_src, blur_out): - """``out += a * max(blur_src - blur_out, 0)``, then clamps at zero, in place.""" - f32 = np.float32 - h, w = a.shape - for y in prange(h): - for x in range(w): - av = a[y, x] - for c in range(3): - deficit = blur_src[y, x, c] - blur_out[y, x, c] - v = out[y, x, c] + av * max(deficit, f32(0.0)) - out[y, x, c] = max(v, f32(0.0)) - - def film_scale(shape: Tuple[int, int]) -> float: """Pixels per unit of film footprint for a buffer this size — the factor a score measured at the buffer's own resolution needs so the fill's supports stay film-scale rather than @@ -1093,34 +833,19 @@ def repair_components( factor = factor or 1.0 else: factor = max(h / score_det.shape[0], w / score_det.shape[1]) - score = cv2.resize(score_det, (w, h), interpolation=cv2.INTER_LINEAR) + score = negpy_core.resize_linear(np.ascontiguousarray(score_det, dtype=np.float32), (w, h)) if dirty is not None and dirty.shape[:2] != (h, w): - dirty = cv2.resize(dirty.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST) > 0 - m = (score < 1.0).astype(np.uint8) - if not m.any(): - return img - n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8) - # Past this many, cropping costs more than the whole-frame convolutions it avoids — - # and more than reusing base_out row by row, so this path stays non-incremental. - if n_lbl - 1 > _REPAIR_MAX_COMPONENTS: - return apply_score_repair(img, score, floor=floor, factor=factor) + dirty = negpy_core.resize_nearest(dirty.astype(np.uint8), (w, h)) > 0 src = np.ascontiguousarray(img, dtype=np.float32) out = np.ascontiguousarray(base_out, dtype=np.float32).copy() if base_out is not None else src.copy() - # Reach of the coarsest support, so every crop holds the clean film the fill averages. - pad = max(_fill_supports(max(h, w), factor)) - for i in range(1, n_lbl): - bx, by = int(stats[i, cv2.CC_STAT_LEFT]), int(stats[i, cv2.CC_STAT_TOP]) - cw, ch = int(stats[i, cv2.CC_STAT_WIDTH]), int(stats[i, cv2.CC_STAT_HEIGHT]) - if dirty is not None and not dirty[by : by + ch, bx : bx + cw][labels[by : by + ch, bx : bx + cw] == i].any(): - continue # untouched since base_out was made: its fill there is already correct - x0, y0 = max(0, bx - pad), max(0, by - pad) - x1 = min(w, bx + cw + pad) - y1 = min(h, by + ch + pad) - sub = apply_score_repair(src[y0:y1, x0:x1], score[y0:y1, x0:x1], floor=floor, long_edge=max(h, w), factor=factor) - # This component only: a neighbour clipped by the crop repairs badly here and gets - # its own padded crop anyway. - mb = labels[y0:y1, x0:x1] == i - out[y0:y1, x0:x1][mb] = np.asarray(sub)[mb] + supports = _fill_supports(max(h, w), factor) + constants = (_IR_SCORE_FLOOR, _IR_FILL_TAU, _IR_WRITE_HI, _IR_WRITE_HI - _IR_WRITE_LO) + # Past the cap, cropping costs more than the whole-frame convolutions it avoids. The pad is + # the coarsest support's reach, so every crop holds the clean film the fill averages. + dirty_mask = None if dirty is None else np.ascontiguousarray(dirty, dtype=bool) + args = (list(supports), bool(floor), max(1.0, factor), constants, max(supports), _REPAIR_MAX_COMPONENTS) + if not negpy_core.repair_components(src, score, out, *args, dirty_mask): + return img return ensure_image(out) @@ -1132,29 +857,12 @@ def route_wide_defects(score: np.ndarray, *, budget: Optional[float] = _IR_ROUTE detector, where a misregistered IR plane can call half the frame a defect; a full-width line clears it on its own, so it would refuse exactly the case that needs the inpaint. """ - at_floor = (score <= _IR_SCORE_FLOOR + 1e-6).astype(np.uint8) - if not at_floor.any(): - return None - n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(at_floor, connectivity=8) scale = _ir_detect_scale(score) - radius = int(round(_IR_ROUTE_RADIUS * scale)) - side = 2 * radius - 1 - routed = np.zeros_like(at_floor) - hit = False - for i in range(1, n_lbl): - bw, bh = int(stats[i, cv2.CC_STAT_WIDTH]), int(stats[i, cv2.CC_STAT_HEIGHT]) - if min(bw, bh) < side: # can't contain a side² solid → radius under the bar - continue - x0, y0 = int(stats[i, cv2.CC_STAT_LEFT]), int(stats[i, cv2.CC_STAT_TOP]) - own = labels[y0 : y0 + bh, x0 : x0 + bw] == i - if float(cv2.distanceTransform(np.pad(own.astype(np.uint8), 1), cv2.DIST_C, 3).max()) >= radius: - # Written inside the bounding box: a hand-placed score arrives at full resolution. - routed[y0 : y0 + bh, x0 : x0 + bw][own] = 1 - hit = True - if not hit: + k = negpy_core.ellipse_kernel((2 * int(round(_IR_ROUTE_DILATE * scale)) + 1,) * 2) + bar = np.float32(_IR_SCORE_FLOOR + 1e-6) + routed = negpy_core.route_wide_defects(np.ascontiguousarray(score, dtype=np.float32), bar, int(round(_IR_ROUTE_RADIUS * scale)), k) + if routed is None: return None - k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(round(_IR_ROUTE_DILATE * scale)) + 1,) * 2) - routed = cv2.dilate(routed, k) frac = float(routed.mean()) if budget is not None and frac > budget: logger.warning("Retouch: routed defects cover %.1f%% of the frame — inpaint skipped, fill only", frac * 100.0) @@ -1173,7 +881,8 @@ def _ir_decontaminate(ratio: np.ndarray, vis_log: np.ndarray) -> Tuple[np.ndarra # defect away as ghost and the division stops lifting it. Trim by ratio percentile: a # fixed cutoff fails because a strong ghost drags clean film below it, and residual # fails because the dust fits itself perfectly. - keep = _fit_sample(ratio >= np.percentile(ratio, _IR_XTALK_TRIM)) + ratio = np.ascontiguousarray(ratio, dtype=np.float32) + keep = _fit_sample(ratio >= np.float32(negpy_core.percentiles(ratio.ravel(), [_IR_XTALK_TRIM])[0])) y = np.log(np.clip(ratio.ravel()[keep], 1e-4, 1.0)) x = vis_log.reshape(-1, vis_log.shape[-1])[keep] if y.size < 500: @@ -1186,7 +895,7 @@ def _ir_decontaminate(ratio: np.ndarray, vis_log: np.ndarray) -> Tuple[np.ndarra ghost = float(np.abs(b).sum()) if ghost < _IR_XTALK_MIN: return ratio, ghost - return np.clip(ratio / np.exp((vis_log * b).sum(-1)), 0.0, 1.5).astype(np.float32), ghost + return negpy_core.ir_ghost_divide(ratio, np.ascontiguousarray(vis_log, dtype=np.float32), b.tolist()), ghost def _fit_refraction_gammas(ratio: np.ndarray, vis_log: np.ndarray, img_det: np.ndarray) -> Tuple[float, ...]: @@ -1199,32 +908,19 @@ def _fit_refraction_gammas(ratio: np.ndarray, vis_log: np.ndarray, img_det: np.n dominated it, reading γ 1.9/2.2/2.2 for dust measuring ~1.0/1.1/1.2 and over-correcting every speck into a dark cyan blob. Median alone reads 1.3/1.8/1.8, flat-only least squares 1.4/2.1/2.0, and γ 1.5 already tints.""" - band = (ratio > 0.70) & (ratio < 0.92) - if int(band.sum()) < 500: - return (_IR_GAMMA_FALLBACK,) * 3 - # ksize=5 carries its own smoothing, so no separate blur. - edge = np.abs(cv2.Laplacian(img_det[:, :, 1], cv2.CV_32F, ksize=5)) - flat = band & (edge < np.percentile(edge[band], _IR_FIT_FLAT_PCT)) - fit = _fit_sample(flat if int(flat.sum()) >= _IR_FIT_MIN_PX else band) - # The band bounds the ratio away from 1, so the per-pixel slope needs no guard. - xb = np.log(ratio.ravel()[fit]) - vl = vis_log.reshape(-1, 3)[fit] - return tuple(float(np.clip(np.median(vl[:, c] / xb), _IR_GAMMA_LO, _IR_GAMMA_HI)) for c in range(3)) + f32 = (np.ascontiguousarray(a, dtype=np.float32) for a in (ratio, vis_log, img_det)) + gamma = (_IR_GAMMA_LO, _IR_GAMMA_HI) + return negpy_core.fit_refraction_gammas(*f32, (0.70, 0.92), _IR_FIT_FLAT_PCT, _IR_FIT_MIN_PX, _IR_FIT_MAX_PX, _IR_GAMMA_FALLBACK, gamma) def _ir_clean_base(img_det: np.ndarray, ratio: np.ndarray) -> np.ndarray: """Local clean-film level per channel over ``_IR_CAP_WIN``: mean of the pixels the IR ratio calls clean, minus ``_IR_CAP_SIGMA`` of their σ (see the constants block).""" - win_px = _ir_win(_IR_CAP_WIN, _ir_detect_scale(ratio)) - win = (win_px, win_px) - w_clean = (ratio >= _IR_GAIN_IDENTITY).astype(np.float32) - den = np.maximum(cv2.blur(w_clean, win), 1e-6)[..., None] - mean = cv2.blur(img_det * w_clean[..., None], win) / den - var = cv2.blur(img_det * img_det * w_clean[..., None], win) / den - mean * mean - base = mean - _IR_CAP_SIGMA * np.sqrt(np.clip(var, 0.0, None)) - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, win) - dil = cv2.blur(cv2.dilate(img_det, kernel), win) - return np.where(den > _IR_CAP_MIN_SUPPORT, base, dil) + win = _ir_win(_IR_CAP_WIN, _ir_detect_scale(ratio)) + constants = (win, _IR_GAIN_IDENTITY, _IR_CAP_SIGMA, _IR_CAP_MIN_SUPPORT) + return negpy_core.ir_clean_base( + np.ascontiguousarray(img_det, dtype=np.float32), np.ascontiguousarray(ratio, dtype=np.float32), constants + ) def _ir_normalize_ratio(ratio: np.ndarray, live: np.ndarray) -> np.ndarray: @@ -1232,23 +928,11 @@ def _ir_normalize_ratio(ratio: np.ndarray, live: np.ndarray) -> np.ndarray: ``_IR_GAIN_IDENTITY``, dip scale on ``_IR_REF_SIGMA`` (see the constants block). ``live`` only: the dead-margin 1.0s inflate σ ~20% on a strip scan. MAD, not std, so a dusty minority can't move either landmark.""" - sample = ratio[live] - if sample.size < 500: # nothing measurable: leave the landmarks alone - return ratio - med = float(np.median(sample)) - sigma = 1.4826 * float(np.median(np.abs(sample - med))) - pivot = float(np.clip(med - _IR_NOISE_SIGMA * sigma, _IR_PIVOT_LO, _IR_GAIN_IDENTITY)) - if pivot <= _IR_PIVOT_LO: - logger.warning("IR dust: clean-film pivot floored at %.2f (IR σ %.3f) — very noisy IR plane", _IR_PIVOT_LO, sigma) - # A clipped pivot is an exact no-op: x/x is 1.0 in IEEE, float32 × 1.0 exact. - scale = _IR_GAIN_IDENTITY / pivot - ratio = (ratio * scale).astype(np.float32) - # The rescale carries σ with it, so there is no second median pass. Dips only: a - # noiseless plane takes the full _IR_SCALE_MAX and would land its clean level above 1. - k = float(np.clip(_IR_REF_SIGMA / max(sigma * scale, 1e-6), 1.0, _IR_SCALE_MAX)) - if k > 1.0: - stretched = _IR_GAIN_IDENTITY - (_IR_GAIN_IDENTITY - ratio) * k - ratio = np.where(ratio < _IR_GAIN_IDENTITY, stretched, ratio).astype(np.float32) + ratio = np.array(ratio, dtype=np.float32) + constants = (_IR_GAIN_IDENTITY, _IR_NOISE_SIGMA, _IR_PIVOT_LO, _IR_REF_SIGMA, _IR_SCALE_MAX) + measured = negpy_core.ir_normalize_ratio(ratio, np.ascontiguousarray(live), constants) + if measured is not None and measured[0] <= _IR_PIVOT_LO: + logger.warning("IR dust: clean-film pivot floored at %.2f (IR σ %.3f) — very noisy IR plane", _IR_PIVOT_LO, measured[1]) return ratio @@ -1265,9 +949,9 @@ def ir_ratio_and_gain(ir_det: np.ndarray, img_det: np.ndarray) -> Tuple[np.ndarr ratio[~live] = 1.0 img_det = np.ascontiguousarray(img_det, dtype=np.float32) if img_det.shape[:2] != ratio.shape[:2]: - img_det = cv2.resize(img_det, (ratio.shape[1], ratio.shape[0]), interpolation=cv2.INTER_AREA) + img_det = negpy_core.resize_area(np.ascontiguousarray(img_det, dtype=np.float32), (ratio.shape[1], ratio.shape[0])) - vis_log = np.stack([np.log(np.clip(normalize_ir(img_det[:, :, c]), 1e-4, 1.0)) for c in range(3)], axis=-1) + vis_log = negpy_core.ir_vis_log(img_det, _ir_win(_IR_BASE_WIN, _ir_detect_scale(img_det))) ratio, ghost = _ir_decontaminate(ratio, vis_log) # On the fitted exponent, not on how far the ratio dips. A few percent of IR noise, # deepened by the min-preserving downsample, reads as silver on clean C41 rolls. @@ -1277,24 +961,20 @@ def ir_ratio_and_gain(ir_det: np.ndarray, img_det: np.ndarray) -> Tuple[np.ndarr ratio = _ir_normalize_ratio(ratio, live) gammas = _fit_refraction_gammas(ratio, vis_log, img_det) - base = np.clip(ratio / _IR_GAIN_IDENTITY, 1e-4, 1.0) - gain = np.empty(ratio.shape + (3,), dtype=np.float32) - for c in range(3): - gain[:, :, c] = np.minimum(_IR_GAIN_CLAMP, base ** (-gammas[c])) # Never lift a pixel past its own local clean base (see _IR_CAP_WIN). Floored at 1, so # the cap only holds the bake back and never darkens a pixel. clean = _ir_clean_base(img_det, ratio) - np.minimum(gain, np.maximum(clean / np.maximum(img_det, 1e-5), 1.0), out=gain) + gain = negpy_core.ir_gain( + ratio, img_det, np.ascontiguousarray(clean, dtype=np.float32), list(gammas), (_IR_GAIN_IDENTITY, _IR_GAIN_CLAMP) + ) return ratio, gain, degenerate, gammas def apply_ir_attenuation(img: ImageBuffer, gain_det: np.ndarray) -> ImageBuffer: """Visible buffer × upsampled per-channel IR gain map (new array — buffers are read-only).""" h, w = img.shape[:2] - gain = gain_det if gain_det.shape[:2] == (h, w) else cv2.resize(gain_det, (w, h), interpolation=cv2.INTER_LINEAR) - # cv2.multiply, not `a * b`: the product of two float32 buffers is already float32, so - # the astype numpy needs here would copy the whole frame a second time. - return ensure_image(cv2.multiply(np.ascontiguousarray(img, dtype=np.float32), gain)) + gain = gain_det if gain_det.shape[:2] == (h, w) else negpy_core.resize_linear(np.ascontiguousarray(gain_det, dtype=np.float32), (w, h)) + return ensure_image(negpy_core.multiply(np.ascontiguousarray(img, dtype=np.float32), np.ascontiguousarray(gain, dtype=np.float32))) def luma_bake_token(retouch) -> str: @@ -1318,29 +998,16 @@ def ir_bake_token(retouch, has_ir: bool) -> str: return f"|ir{int(retouch.ir_attenuation)}r{round(float(retouch.ir_threshold), 3)}" -@parallel_njit(cache=True) -def _hair_encode(crop, lo, inv_span, out_u8): - """``clip((crop - lo) / span, 0, 1) ** (1/γ)`` to 8-bit, for cv2.inpaint.""" - f32 = np.float32 - inv_gamma = f32(1.0 / _HAIR_INPAINT_GAMMA) - h, w = crop.shape[0], crop.shape[1] - for y in prange(h): - for x in range(w): - for c in range(3): - v = min(max((crop[y, x, c] - lo) * inv_span, f32(0.0)), f32(1.0)) - out_u8[y, x, c] = np.uint8(v**inv_gamma * f32(255.0) + f32(0.5)) - - def apply_hair_inpaint( img: ImageBuffer, hair_masks: List[np.ndarray], radius: int = _HAIR_INPAINT_RADIUS, dilate_px: Optional[int] = None, ) -> ImageBuffer: - """Structure-following fill of long/twisted defects (``cv2.inpaint``, Navier–Stokes) + """Structure-following fill of long/twisted defects (Navier–Stokes inpaint, ``negpy_core::inpaint``) baked into the linear source. Each detection-scale mask is upsampled to the buffer, unioned and dilated to cover the PSF skirt; only masked pixels are overwritten (the - rest stay byte-identical — the 8-bit encode cv2.inpaint requires touches only the + rest stay byte-identical — the 8-bit encode of the inpaint touches only the fabricated hairline). Returns a new array (buffers are read-only).""" h, w = img.shape[:2] masks = [hm for hm in hair_masks if hm is not None] @@ -1353,73 +1020,24 @@ def apply_hair_inpaint( dilate_px = max(_HAIR_DILATE_PX, round(factor)) m = np.zeros((h, w), dtype=np.uint8) for hm in masks: - r = hm if hm.shape[:2] == (h, w) else cv2.resize(hm.astype(np.float32), (w, h), interpolation=cv2.INTER_LINEAR) + r = hm if hm.shape[:2] == (h, w) else negpy_core.resize_linear(np.ascontiguousarray(hm, dtype=np.float32), (w, h)) m |= (np.asarray(r) > 0.5).astype(np.uint8) if not m.any(): return img if dilate_px > 0: - k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate_px + 1, 2 * dilate_px + 1)) - m = cv2.dilate(m, k) + k = negpy_core.ellipse_kernel((2 * dilate_px + 1, 2 * dilate_px + 1)) + m = negpy_core.morphology_ex(m, negpy_core.MORPH_DILATE, k) src = np.ascontiguousarray(img, dtype=np.float32) - out = src.copy() - n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8) - for i in range(1, n_lbl): - bx = int(stats[i, cv2.CC_STAT_LEFT]) - by = int(stats[i, cv2.CC_STAT_TOP]) - x0, y0 = max(0, bx - _HAIR_INPAINT_PAD), max(0, by - _HAIR_INPAINT_PAD) - x1 = min(w, bx + int(stats[i, cv2.CC_STAT_WIDTH]) + _HAIR_INPAINT_PAD) - y1 = min(h, by + int(stats[i, cv2.CC_STAT_HEIGHT]) + _HAIR_INPAINT_PAD) - # Mask the whole crop, not just this component: a neighbour reaching into the bbox - # must stay unknown, or it becomes clone source and its dust is filled back in. - sub_m = np.ascontiguousarray(m[y0:y1, x0:x1]) - crop = np.ascontiguousarray(src[y0:y1, x0:x1]) - # Encode against the crop's clean range: clip(0,1) posterizes fills in dark regions. - # A hair can join enough specks that one component spans the frame, so the range is - # read off a strided sample of the clean pixels rather than all of them. - step = max(1, int(math.isqrt(max(1, crop.shape[0] * crop.shape[1] // _HAIR_RANGE_SAMPLE_PX)))) - ctx = crop[::step, ::step][sub_m[::step, ::step] == 0] - lo = float(np.percentile(ctx, 0.5)) if ctx.size else 0.0 - hi = float(np.percentile(ctx, 99.5)) if ctx.size else 1.0 - span = max(hi - lo, 1e-4) - # The fill is 8-bit, so its decode is a 256-entry table. - dec_lut = ((np.arange(256, dtype=np.float32) / 255.0) ** _HAIR_INPAINT_GAMMA) * span + lo - ch, cw = crop.shape[:2] - # The inpaint walks the whole image it is handed, and a hair joining specks can make - # this crop the frame: tile it, skip tiles without this component, keep a halo so - # the fill at a tile's core sees the same neighbourhood. - for ty0 in range(0, ch, _HAIR_TILE_PX): - for tx0 in range(0, cw, _HAIR_TILE_PX): - ty1, tx1 = min(ch, ty0 + _HAIR_TILE_PX), min(cw, tx0 + _HAIR_TILE_PX) - if not (labels[y0 + ty0 : y0 + ty1, x0 + tx0 : x0 + tx1] == i).any(): - continue - ay0, ay1 = max(0, ty0 - _HAIR_TILE_HALO), min(ch, ty1 + _HAIR_TILE_HALO) - ax0, ax1 = max(0, tx0 - _HAIR_TILE_HALO), min(cw, tx1 + _HAIR_TILE_HALO) - tile = np.ascontiguousarray(crop[ay0:ay1, ax0:ax1]) - enc = np.empty(tile.shape, dtype=np.uint8) - _hair_encode(tile, np.float32(lo), np.float32(1.0 / span), enc) - filled = cv2.inpaint(enc, np.ascontiguousarray(sub_m[ay0:ay1, ax0:ax1]), radius, cv2.INPAINT_NS) - # ...but keep only this component, alpha-feathered across the dilate band: full - # fill on the detected defect, ramp over the skirt. A neighbour clipped by the - # bbox fills badly here and gets its own padded crop anyway. dilate_px=0 means - # no feather. Only the tile's core is written; the halo belongs to its neighbours. - mb = labels[y0 + ay0 : y0 + ay1, x0 + ax0 : x0 + ax1] == i - d = cv2.distanceTransform(mb.astype(np.uint8), cv2.DIST_C, 3) - core = np.zeros(mb.shape, dtype=bool) - core[ty0 - ay0 : ty1 - ay0, tx0 - ax0 : tx1 - ax0] = True - sel = np.flatnonzero(mb & core) - a = np.minimum(d.ravel()[sel] / float(dilate_px + 1), 1.0)[:, None] - # Written flat into the frame: the crop view is not contiguous, so a reshape - # of it would write to a copy. - dec = dec_lut[filled.reshape(-1, 3)[sel]] - tile_px = tile.reshape(-1, 3)[sel] - ry, rx = np.divmod(sel, ax1 - ax0) - out.reshape(-1, 3)[(y0 + ay0 + ry) * w + (x0 + ax0 + rx)] = tile_px * (1.0 - a) + dec * a + # Each component fills in its padded box, tiled with a halo; a neighbour reaching into the + # box stays unknown, or it becomes clone source and its dust is filled back in. + out = negpy_core.hair_fill( + src, + m, + (float(radius), dilate_px, _HAIR_INPAINT_PAD, _HAIR_TILE_PX, _HAIR_TILE_HALO, _HAIR_RANGE_SAMPLE_PX, _HAIR_INPAINT_GAMMA), + ) # Navier-Stokes propagates a smooth field, so the fill lands grainless. See # apply_score_repair, which fills the same kind of hole by a different route. - idx = np.flatnonzero(m) - clean = m == 0 - if clean.any() and idx.size: - out.reshape(-1, 3)[idx] += _borrow_clean_grain(src, clean, max(1.0, factor), idx)[0] + negpy_core.add_clean_grain(out, np.ascontiguousarray(src, dtype=np.float32), m == 0, max(1.0, factor)) return out diff --git a/negpy/features/rgbscan/logic.py b/negpy/features/rgbscan/logic.py index 4358368a4..24b2d0fbf 100644 --- a/negpy/features/rgbscan/logic.py +++ b/negpy/features/rgbscan/logic.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, replace from typing import Callable, Dict, List, Optional, Sequence, Tuple -import cv2 +import negpy_core import numpy as np from negpy.domain.tokens import composite_token @@ -62,12 +62,10 @@ def classify_channel(means: Sequence[float]) -> int: def _channel_means(mosaic: np.ndarray, colors: np.ndarray, black: float) -> Tuple[float, float, float]: - def mean_of(*idx: int) -> float: - mask = np.isin(colors, idx) - return float(mosaic[mask].mean()) - black if mask.any() else 0.0 - # color_desc is RGBG: 0=R, 1=G, 2=B, 3=second green. - return mean_of(0), mean_of(1, 3), mean_of(2) + return negpy_core.channel_means( + np.ascontiguousarray(mosaic, dtype=np.float32), np.ascontiguousarray(colors, dtype=np.uint8), float(black) + ) def _scene_signature(mosaic: np.ndarray, black: float) -> np.ndarray: @@ -269,12 +267,11 @@ def estimate_shift(ref_gray: np.ndarray, mov_gray: np.ndarray) -> Tuple[float, f if w > _EST_WIDTH: scale = w / _EST_WIDTH sz = (_EST_WIDTH, max(1, round(h / scale))) - r = cv2.resize(ref_gray, sz, interpolation=cv2.INTER_AREA) - m = cv2.resize(mov_gray, sz, interpolation=cv2.INTER_AREA) + r = negpy_core.resize_area(np.ascontiguousarray(ref_gray, dtype=np.float32), sz) + m = negpy_core.resize_area(np.ascontiguousarray(mov_gray, dtype=np.float32), sz) r = np.ascontiguousarray(r, dtype=np.float32) m = np.ascontiguousarray(m, dtype=np.float32) - win = cv2.createHanningWindow((r.shape[1], r.shape[0]), cv2.CV_32F) - (dx, dy), _resp = cv2.phaseCorrelate(r, m, win) + (dx, dy), _resp = negpy_core.phase_correlate(r, m) return dx * scale, dy * scale @@ -286,7 +283,7 @@ def _align_to(ref_gray: np.ndarray, mov: np.ndarray, mov_ch: int, max_shift: flo return mov h, w = mov.shape[:2] matrix = np.array([[1.0, 0.0, -dx], [0.0, 1.0, -dy]], dtype=np.float32) - return cv2.warpAffine(mov, matrix, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) + return negpy_core.warp_affine(np.ascontiguousarray(mov, dtype=np.float32), matrix.astype(np.float64), (w, h), interpolation="cubic") def assemble_rgb(r: np.ndarray, g: np.ndarray, b: np.ndarray, align: bool = True) -> np.ndarray: @@ -297,16 +294,12 @@ def assemble_rgb(r: np.ndarray, g: np.ndarray, b: np.ndarray, align: bool = True """ if not (r.shape == g.shape == b.shape): raise ValueError(f"RGB-scan exposures differ in shape: {r.shape}, {g.shape}, {b.shape}") - out = np.empty_like(r) - out[..., RED] = r[..., RED] if align: - ref = r[..., RED].astype(np.float32) + ref = negpy_core.extract_channel(np.ascontiguousarray(r, dtype=np.float32), RED) max_shift = max(16.0, 0.02 * r.shape[1]) g = _align_to(ref, g, GREEN, max_shift) b = _align_to(ref, b, BLUE, max_shift) - out[..., GREEN] = g[..., GREEN] - out[..., BLUE] = b[..., BLUE] - return out + return negpy_core.pick_channels(*(np.ascontiguousarray(x, dtype=r.dtype) for x in (r, g, b))) def merge_rgb_triplet( diff --git a/negpy/features/sabattier/logic.py b/negpy/features/sabattier/logic.py index af91c1f47..53a745c48 100644 --- a/negpy/features/sabattier/logic.py +++ b/negpy/features/sabattier/logic.py @@ -1,11 +1,10 @@ from typing import Any, Dict -import cv2 +import negpy_core import numpy as np from negpy.domain.types import ImageBuffer from negpy.features.lab.logic import gaussian_kernel_1d -from negpy.kernel.image.validation import ensure_image SABATTIER_CONSTANTS: Dict[str, Any] = { # Softness of the fold at the re-exposure density, in D. The softplus below turns the @@ -35,22 +34,10 @@ def apply_sabattier( return img c = SABATTIER_CONSTANTS - arr = np.clip(img.astype(np.float32), 1e-6, 1.0) - luma = arr[:, :, 0] * np.float32(0.2126) + arr[:, :, 1] * np.float32(0.7152) + arr[:, :, 2] * np.float32(0.0722) - dens = -np.log10(np.clip(luma, 1e-6, 1.0)) - - re = np.float32(reexposure * d_max) - w = np.float32(c["fold_width"]) - shortfall = w * np.log1p(np.exp(np.clip((re - dens) / w, -30.0, 30.0))) - reversal = np.float32(strength) * shortfall - if line_sigma_px > 0.0: - developed = 1.0 / (1.0 + np.exp(-np.clip((dens - re) / np.float32(c["edge_width"]), -30.0, 30.0))) - k = gaussian_kernel_1d(float(line_sigma_px)) - bromide = cv2.sepFilter2D(developed.astype(np.float32), -1, k, k, borderType=cv2.BORDER_REPLICATE) - reversal = reversal * (np.float32(1.0) - bromide) - - grey = np.power(np.float32(10.0), -np.minimum(dens + reversal, np.float32(d_max))).astype(np.float32) - return ensure_image(np.clip(np.dstack([grey, grey, grey]), 0.0, 1.0)) + f32 = np.float32 + scalars = (f32(reexposure * d_max), f32(c["fold_width"]), f32(c["edge_width"]), f32(strength), f32(d_max)) + k = gaussian_kernel_1d(float(line_sigma_px)) if line_sigma_px > 0.0 else np.zeros(0, np.float32) + return negpy_core.sabattier(np.ascontiguousarray(img, dtype=np.float32), scalars, k) def line_sigma_px(agitation: float, shape: tuple) -> float: diff --git a/negpy/features/stitch/logic.py b/negpy/features/stitch/logic.py index 904bf047c..c05ccc30e 100644 --- a/negpy/features/stitch/logic.py +++ b/negpy/features/stitch/logic.py @@ -5,9 +5,10 @@ buffers decoded the same way. """ +import math from typing import Callable, List, Optional, Sequence, Tuple -import cv2 +import negpy_core import numpy as np from negpy.features.stitch.models import StitchConfig @@ -35,10 +36,8 @@ def build_proxy(rgb: np.ndarray) -> Tuple[np.ndarray, float]: scale = min(1.0, PROXY_MAX_EDGE / max(gray.shape)) if scale < 1.0: size = (max(1, round(gray.shape[1] * scale)), max(1, round(gray.shape[0] * scale))) - gray = cv2.resize(gray, size, interpolation=cv2.INTER_AREA) - lo, hi = np.percentile(gray, (1.0, 99.0)) - gray = np.clip((gray - lo) / max(hi - lo, 1e-9), 0.0, 1.0) - return (gray ** (1.0 / 2.2) * 255.0).astype(np.uint8), scale + gray = negpy_core.resize_area(np.ascontiguousarray(gray, dtype=np.float32), size) + return negpy_core.proxy_stretch(np.ascontiguousarray(gray, dtype=np.float32)), scale def register_pair(ref_proxy: np.ndarray, mov_proxy: np.ndarray, min_inliers: int = MIN_INLIERS) -> Tuple[np.ndarray, int]: @@ -47,24 +46,13 @@ def register_pair(ref_proxy: np.ndarray, mov_proxy: np.ndarray, min_inliers: int SIFT + ratio test + RANSAC; ORB and phase correlation both fail on film negatives (feature-poor orange-mask content, sub-degree rotations). """ - sift = cv2.SIFT_create(nfeatures=6000) - ref_kp, ref_desc = sift.detectAndCompute(ref_proxy, None) - mov_kp, mov_desc = sift.detectAndCompute(mov_proxy, None) - if ref_desc is None or mov_desc is None or len(ref_kp) < 2 or len(mov_kp) < 2: + found = negpy_core.register_pair( + np.ascontiguousarray(ref_proxy, dtype=np.uint8), np.ascontiguousarray(mov_proxy, dtype=np.uint8), min_inliers + ) + if found is None: raise StitchError(_NO_OVERLAP_MSG) - - pairs = cv2.BFMatcher().knnMatch(mov_desc, ref_desc, k=2) - good = [m for m, n in (p for p in pairs if len(p) == 2) if m.distance < 0.75 * n.distance] - if len(good) < min_inliers: - raise StitchError(_NO_OVERLAP_MSG) - - mov_pts = np.float32([mov_kp[m.queryIdx].pt for m in good]) - ref_pts = np.float32([ref_kp[m.trainIdx].pt for m in good]) - matrix, inlier_mask = cv2.estimateAffinePartial2D(mov_pts, ref_pts, method=cv2.RANSAC, ransacReprojThreshold=3.0) - inliers = int(inlier_mask.sum()) if inlier_mask is not None else 0 - if matrix is None or inliers < min_inliers: - raise StitchError(_NO_OVERLAP_MSG) - return matrix.astype(np.float64), inliers + matrix, inliers = found + return np.array(matrix, np.float64).reshape(2, 3), inliers def scale_affine(matrix: np.ndarray, ref_scale: float, mov_scale: float) -> np.ndarray: @@ -86,8 +74,9 @@ def compute_canvas(sizes: Sequence[Tuple[int, int]], transforms: Sequence[np.nda """Union bounding box of the warped parts. Returns ((W, H), transforms shifted to it).""" corners = [] for (w, h), matrix in zip(sizes, transforms): - box = np.array([[[0, 0], [w, 0], [w, h], [0, h]]], np.float64) - corners.append(cv2.transform(box, matrix)[0]) + # cv2.transform's order: its dispatched build fuses the first product. + m = matrix.tolist() + corners.append([[math.fma(r[0], x, r[1] * y) + r[2] for r in m] for x, y in ((0, 0), (w, 0), (w, h), (0, h))]) points = np.vstack(corners) lo = np.floor(points.min(axis=0)) hi = np.ceil(points.max(axis=0)) @@ -142,13 +131,16 @@ def warp_into_canvas( img: np.ndarray, transform: np.ndarray, canvas_wh: Tuple[int, int], - interpolation: int = cv2.INTER_CUBIC, + interpolation: str = "cubic", ) -> Tuple[np.ndarray, np.ndarray]: """Warp a part and its validity mask into the canvas. The mask is eroded by the >0.999 threshold so resampled border pixels never bleed into the blend.""" - warped = cv2.warpAffine(img, transform.astype(np.float32), canvas_wh, flags=interpolation) + m = transform.astype(np.float32).astype(np.float64) + warped = negpy_core.warp_affine( + np.ascontiguousarray(img, dtype=np.float32), m, canvas_wh, interpolation=interpolation, border="constant" + ) ones = np.ones(img.shape[:2], np.float32) - mask = cv2.warpAffine(ones, transform.astype(np.float32), canvas_wh, flags=cv2.INTER_LINEAR) + mask = negpy_core.warp_affine(ones, m, canvas_wh, border="constant") return warped, mask > 0.999 @@ -158,16 +150,17 @@ def warp_into_canvas( def gain_compensate(ref: np.ndarray, ref_mask: np.ndarray, mov: np.ndarray, mov_mask: np.ndarray) -> np.ndarray: """Scale ``mov`` (in place) so its overlap with ``ref`` matches per channel — removes light-source drift between shots. No-op on tiny overlap.""" - overlap = ref_mask & mov_mask - if int(overlap.sum()) < _MIN_OVERLAP_PX: - return mov - # float64: a full-res overlap is tens of millions of rows, and a float32 axis-0 sum + # The means are float64: a full-res overlap is tens of millions of rows, and a float32 sum # drifts per channel, so export gains would differ from the preview's. - ref_mean = ref[overlap].mean(axis=0, dtype=np.float64) - mov_mean = mov[overlap].mean(axis=0, dtype=np.float64) - if np.any(mov_mean < 1e-6): - return mov - mov *= (ref_mean / mov_mean).astype(np.float32) + gains = negpy_core.compensation_gains( + np.ascontiguousarray(ref, dtype=np.float32), + np.ascontiguousarray(ref_mask), + np.ascontiguousarray(mov, dtype=np.float32), + np.ascontiguousarray(mov_mask), + _MIN_OVERLAP_PX, + ) + if gains is not None: + negpy_core.scale_channels(mov, gains) return mov @@ -177,13 +170,11 @@ def feather_blend(warped: Sequence[np.ndarray], masks: Sequence[np.ndarray]) -> Averaging draws misregistered detail twice, so this is the fallback for when a seam cannot be cut, not the normal path. See ``seam_blend``. """ - acc = np.zeros_like(warped[0]) - weight_sum = np.zeros(warped[0].shape[:2], np.float32) - for img, mask in zip(warped, masks): - weight = cv2.distanceTransform(mask.astype(np.uint8), cv2.DIST_L2, 3) - acc += img * weight[..., None] - weight_sum += weight - return acc / np.maximum(weight_sum, 1e-6)[..., None] + return negpy_core.feather_blend(_contiguous(warped, np.float32), _contiguous(masks, bool)) + + +def _contiguous(arrays: Sequence[np.ndarray], dtype) -> List[np.ndarray]: + return [np.ascontiguousarray(a, dtype=dtype) for a in arrays] _SEAM_MAX_EDGE = 1024 @@ -218,11 +209,10 @@ def find_seams(warped: Sequence[np.ndarray], masks: Sequence[np.ndarray], canvas # this is where an export tight on memory fails, and it has to degrade to the # average rather than end the export. try: - small = [_seam_cost_image(cv2.resize(img, size, interpolation=cv2.INTER_AREA)) for img in warped] - small_masks = [cv2.resize(m.astype(np.uint8), size, interpolation=cv2.INTER_NEAREST) for m in masks] - corners = [(0, 0)] * len(small) - cut = cv2.detail_DpSeamFinder("COLOR").find(small, corners, [cv2.UMat(m) for m in small_masks]) - seams = [(cv2.resize(cv2.UMat.get(m), (w, h), interpolation=cv2.INTER_NEAREST) > 0) & valid for m, valid in zip(cut, masks)] + small = [_seam_cost_image(negpy_core.resize_area(np.ascontiguousarray(img, dtype=np.float32), size)) for img in warped] + small_masks = [negpy_core.resize_nearest(m.astype(np.uint8), size) for m in masks] + cut = negpy_core.dp_seams(small, small_masks) + seams = [(negpy_core.resize_nearest(m, (w, h)) > 0) & valid for m, valid in zip(cut, masks)] # Nearest-neighbour upscaling can leave a covered pixel assigned to no part, and # an unassigned pixel has no weight to normalize against. Give each one back to a # part that actually covers it. @@ -236,7 +226,7 @@ def find_seams(warped: Sequence[np.ndarray], masks: Sequence[np.ndarray], canvas claim = orphan & mask seam |= claim orphan &= ~claim - except (cv2.error, MemoryError): + except (ValueError, MemoryError): return None return seams @@ -252,44 +242,11 @@ def seam_blend( Weight ramps on the *signed* distance to the cut, so it reaches across into the neighbour's side and the two parts actually mix there. A weight that only ramped inside a part's own side would leave the halves disjoint and the cut hard. + + Each part's weight is damped to zero at its own coverage edge, so an overlap narrower + than the band does not end on a step in the weight sum. """ - acc = np.zeros_like(warped[0]) - weight_sum = np.zeros(warped[0].shape[:2], np.float32) - for img, seam, valid in zip(warped, seams, masks): - weight = _seam_weight(seam, valid, max(feather_px, 1.0)) - acc += img * weight[..., None] - weight_sum += weight - return acc / np.maximum(weight_sum, 1e-6)[..., None] - - -def _seam_weight(seam: np.ndarray, valid: np.ndarray, feather_px: float) -> np.ndarray: - """Cross-fade weight for one part: a ramp across the cut, damped to zero at the - part's own coverage edge. - - The damping is what keeps an overlap narrower than the band from ending on a step: - truncating the ramp at the coverage edge instead leaves the two parts' weights - summing to a discontinuity there. Each distance transform is released before the - next is taken, because at export these are full-canvas float32 planes. - """ - span = 2.0 * feather_px - owned = seam.astype(np.uint8) - weight = cv2.distanceTransform(owned, cv2.DIST_L2, 3) - weight /= span - weight += 0.5 - np.clip(weight, 0.0, 1.0, out=weight) - - beyond = cv2.distanceTransform(1 - owned, cv2.DIST_L2, 3) - beyond /= span - np.subtract(0.5, beyond, out=beyond) - np.clip(beyond, 0.0, 1.0, out=beyond) - np.copyto(weight, beyond, where=~seam) - del beyond - - edge = cv2.distanceTransform(valid.astype(np.uint8), cv2.DIST_L2, 3) - edge /= feather_px - np.clip(edge, 0.0, 1.0, out=edge) - weight *= edge - return weight + return negpy_core.seam_blend(_contiguous(warped, np.float32), _contiguous(masks, bool), _contiguous(seams, bool), max(feather_px, 1.0)) def blend_ir( @@ -310,13 +267,10 @@ def blend_ir( w, h = canvas_wh acc = np.full((h, w), -1.0, np.float32) for i, (ir, transform) in enumerate(zip(irs, transforms)): - warped, mask = warp_into_canvas(ir, transform, canvas_wh, interpolation=cv2.INTER_LINEAR) - if seams is not None: - owned = seams[i] - acc[owned] = warped[owned] - else: - acc[mask] = np.maximum(acc[mask], warped[mask]) - acc[acc < 0.0] = 1.0 + warped, mask = warp_into_canvas(ir, transform, canvas_wh, interpolation="linear") + sel = mask if seams is None else seams[i] + negpy_core.ir_blend_into(acc, np.ascontiguousarray(warped, dtype=np.float32), np.ascontiguousarray(sel, dtype=bool), seams is None) + negpy_core.ir_finish(acc) return acc @@ -337,7 +291,7 @@ def stitch_composite( img, mask = warp_into_canvas(parts[i], transforms[i], canvas) parts[i] = None # type: ignore[call-overload] for j in range(len(warped)): - if (masks[j] & mask).sum() >= _MIN_OVERLAP_PX: + if negpy_core.mask_overlap(masks[j], mask) >= _MIN_OVERLAP_PX: gain_compensate(warped[j], masks[j], img, mask) break warped.append(img) @@ -348,7 +302,8 @@ def stitch_composite( else: decoded_scale = canvas[0] / max(config.stitch_canvas[0], 1) blended = seam_blend(warped, masks, seams, _SEAM_FEATHER_PX * decoded_scale) - rgb = np.clip(blended, 0.0, 1.0) + rgb = np.ascontiguousarray(blended, dtype=np.float32) + negpy_core.clip_unit(rgb) ir = None if irs and all(x is not None for x in irs): ir = blend_ir(irs, transforms, canvas, seams) # type: ignore[arg-type] diff --git a/negpy/features/toning/logic.py b/negpy/features/toning/logic.py index 6892c9a15..e057410d4 100644 --- a/negpy/features/toning/logic.py +++ b/negpy/features/toning/logic.py @@ -1,12 +1,11 @@ -from typing import Any, Dict +from typing import Any, Dict, Optional, Tuple import numpy as np -from numba import prange # type: ignore +import negpy_core from negpy.domain.types import ImageBuffer -from negpy.kernel.image.logic import lab_to_rgb_working, rgb_to_lab_working +from negpy.kernel.image.logic import WORKING_LAB from negpy.kernel.image.validation import ensure_image -from negpy.kernel.system.parallel import parallel_njit TONING_CONSTANTS: Dict[str, Any] = { # Selenium (silver -> silver selenide, densest silver first). @@ -87,7 +86,6 @@ } -@parallel_njit(cache=True, fastmath=True) def _apply_chemical_toning_jit( img: np.ndarray, sel_strength: float, @@ -133,121 +131,35 @@ def _apply_chemical_toning_jit( conversion — how it behaves on a lith print, where it "attacks all densities evenly" instead of starting in the highlights. """ - h, w, c = img.shape - res = np.empty_like(img) - eps = 1e-6 - - for y in prange(h): - d3 = np.empty(3, dtype=np.float64) - for x in range(w): - d0 = 0.0 - for ch in range(3): - t = img[y, x, ch] - if t < eps: - t = eps - elif t > 1.0: - t = 1.0 - d3[ch] = -np.log10(t) - d0 += d3[ch] - d0 /= 3.0 - - # Conversion caps at 1: all the remaining silver is toned - # (slider > 1 = longer bath). - c_sel = 0.0 - if sel_strength > 0.0: - frac = d0 / sel_d_ref - if frac > 1.0: - frac = 1.0 - c_sel = sel_strength * frac**sel_power - if c_sel > 1.0: - c_sel = 1.0 - - c_sep = 0.0 - if sep_strength > 0.0: - frac = d0 / sep_d_bleach - if frac > 1.0: - frac = 1.0 - c_sep = sep_strength * (1.0 - frac) ** sep_power - if c_sep > 1.0: - c_sep = 1.0 - - c_au = 0.0 - if gold_strength > 0.0: - if gold_flat > 0.5: - c_au = gold_strength - else: - frac = d0 / gold_d_ref - if frac > 1.0: - frac = 1.0 - c_au = gold_strength * (1.0 - frac) ** gold_power - if c_au > 1.0: - c_au = 1.0 - - c_blue = 0.0 - if blue_strength > 0.0: - frac = d0 / blue_d_ref - if frac > 1.0: - frac = 1.0 - c_blue = blue_strength * frac**blue_power - if c_blue > 1.0: - c_blue = 1.0 - - c_cu = 0.0 - if copper_strength > 0.0: - frac = d0 / copper_d_ref - if frac > 1.0: - frac = 1.0 - c_cu = copper_strength * frac**copper_power - if c_cu > 1.0: - c_cu = 1.0 - - c_van = 0.0 - if van_strength > 0.0: - frac = d0 / van_d_ref - if frac > 1.0: - frac = 1.0 - c_van = van_strength * (1.0 - frac) ** van_power - if c_van > 1.0: - c_van = 1.0 - - a = 1.0 - f_sel = a * c_sel - a -= f_sel - f_sep = a * c_sep - a -= f_sep - f_au = a * c_au - a -= f_au - f_ausp = f_sep * c_au - f_sep -= f_ausp - f_blue = a * c_blue - a -= f_blue - f_cu = a * c_cu - a -= f_cu - f_van = a * c_van - a -= f_van - - for ch in range(3): - # Covering power rides each channel's OWN density, not the mean. The - # mean is the silver reservoir above, but rebuilding the output from it - # would throw away the color a lith print hands this stage. Identical - # for a normal grey B&W print, where d3[ch] == d0. - d = d3[ch] * ( - a - + f_sel * sel_gain[ch] - + f_sep * sep_gain[ch] - + f_au * gold_gain[ch] - + f_ausp * sep_gain[ch] * gold_sepia_gain[ch] - + f_blue * blue_gain[ch] - + f_cu * copper_gain[ch] - + f_van * van_gain[ch] - ) - pixel = 10.0**-d - if pixel < 0.0: - pixel = 0.0 - elif pixel > 1.0: - pixel = 1.0 - res[y, x, ch] = pixel - return res + return negpy_core.apply_chemical_toning( + img, + sel_strength, + sep_strength, + gold_strength, + sel_d_ref, + sel_power, + sel_gain, + sep_d_bleach, + sep_power, + sep_gain, + gold_d_ref, + gold_power, + gold_gain, + gold_sepia_gain, + blue_strength, + blue_d_ref, + blue_power, + blue_gain, + copper_strength, + copper_d_ref, + copper_power, + copper_gain, + van_strength, + van_d_ref, + van_power, + van_gain, + gold_flat, + ) def apply_split_toning( @@ -264,25 +176,13 @@ def apply_split_toning( if shadow_strength == 0.0 and highlight_strength == 0.0: return img - lab = rgb_to_lab_working(img.astype(np.float32)) - L = lab[:, :, 0] # 0–100 CIELAB (Adobe RGB working space) - - # Smoothstep masks, as toning.wgsl. - if shadow_strength > 0.0: - t = np.clip(1.0 - L / 50.0, 0.0, 1.0) - s_mask = t * t * (3.0 - 2.0 * t) - rad = np.radians(shadow_hue) - lab[:, :, 1] += np.cos(rad) * 20.0 * shadow_strength * s_mask - lab[:, :, 2] += np.sin(rad) * 20.0 * shadow_strength * s_mask - - if highlight_strength > 0.0: - t = np.clip((L - 50.0) / 50.0, 0.0, 1.0) - h_mask = t * t * (3.0 - 2.0 * t) - rad = np.radians(highlight_hue) - lab[:, :, 1] += np.cos(rad) * 20.0 * highlight_strength * h_mask - lab[:, :, 2] += np.sin(rad) * 20.0 * highlight_strength * h_mask + def tint(hue: float, strength: float) -> Optional[Tuple[float, float]]: + rad = np.radians(hue) + return (float(np.cos(rad) * 20.0 * strength), float(np.sin(rad) * 20.0 * strength)) if strength > 0.0 else None - return ensure_image(np.clip(lab_to_rgb_working(lab), 0.0, 1.0)) + # Smoothstep masks of L* below and above 50, as toning.wgsl. + shadow, highlight = tint(shadow_hue, shadow_strength), tint(highlight_hue, highlight_strength) + return negpy_core.split_toning(np.ascontiguousarray(img, dtype=np.float32), shadow, highlight, *WORKING_LAB) def apply_chemical_toning( diff --git a/negpy/features/transparency/logic.py b/negpy/features/transparency/logic.py index 3e03a3ff4..9585fdea4 100644 --- a/negpy/features/transparency/logic.py +++ b/negpy/features/transparency/logic.py @@ -39,7 +39,7 @@ from negpy.domain.types import ImageBuffer from negpy.features.exposure.logic import ( - _fast_sigmoid_cached, + _fast_sigmoid, effective_grade_range, grade_to_slope, per_channel_dye_separation, @@ -285,7 +285,7 @@ def transfer_auto_terms( d_highlight = pivot + (float(highlight_point) * R - offset - pivot) * contrast if d_highlight < target: _sh_c, hi_c, k_zone = zone_geometry() - w_hi = 1.0 - _fast_sigmoid_cached(k_zone * (d_highlight - hi_c)) + w_hi = 1.0 - _fast_sigmoid(k_zone * (d_highlight - hi_c)) highlight_auto = min((target - d_highlight) / max(w_hi, 1e-6), float(EXPOSURE_CONSTANTS["highlight_hold_max"])) # Shadow Hold: a partial shadow-zone lift on the same tail as Shadow Reach. Never burns. diff --git a/negpy/features/transparency/processor.py b/negpy/features/transparency/processor.py index 02c9623a1..6354419ee 100644 --- a/negpy/features/transparency/processor.py +++ b/negpy/features/transparency/processor.py @@ -1,6 +1,7 @@ """The slide renders: a Slide as captured and a Positive frame. The engine routes here by process.path.render_path.""" +import negpy_core import numpy as np from negpy.domain.interfaces import PipelineContext @@ -66,9 +67,9 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: # continuous-spectrum scene, and there is no such scene to describe (see # should_fold_camera_wb). matrix = camera_to_working_matrix(context.cam_xyz, context.camera_wb if should_fold_camera_wb(self.config) else None) - linear = apply_camera_matrix(np.nan_to_num(image, nan=epsilon, posinf=1.0, neginf=epsilon), matrix) - - img_log = np.log10(np.clip(linear, epsilon, None)) + image = np.ascontiguousarray(image, dtype=np.float32) + linear = image if matrix is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), matrix) + img_log = negpy_core.log_density_open(np.ascontiguousarray(linear, dtype=np.float32)) # Honoured here too: a rig-calibrated matrix is a capture correction like Hue Trim, and # the mode gate keeps a negative's profile from touching a slide. Inert at the shipped # default, so the as-captured render is unperturbed. diff --git a/negpy/infrastructure/capture/raw_demosaic.py b/negpy/infrastructure/capture/raw_demosaic.py index 3870f7807..485a439fb 100644 --- a/negpy/infrastructure/capture/raw_demosaic.py +++ b/negpy/infrastructure/capture/raw_demosaic.py @@ -17,6 +17,7 @@ from typing import Optional, Sequence +import negpy_core import numpy as np @@ -116,40 +117,6 @@ def linear_demosaic(path: str, half_size: bool = False) -> np.ndarray: _MIN_PLATEAU_SITES = 4 # a plateau is a pile, never a handful of photosites on a small ROI -def _plateau_clip_fraction(values: np.ndarray, dark_frame_guard: int) -> float: - """Fraction of photosites pinned on a saturation plateau, found from the data's shape alone. - - A sensor can saturate below the white level its metadata publishes — or on a body with no - metadata at all. Saturation still has a shape: photosites pile up against the highest level - the sensor reaches, while noise above that pile falls off smoothly. The pile is not always the - ROI's own maximum — a handful of noise-elevated sites (dust, a hot pixel, ordinary shot noise) - routinely sit a few dozen counts above the true plateau, so anchoring on `values.max()` itself - can land in that sparse tail and miss the pile beneath it. Scanning the top `_PLATEAU_WINDOW` - counts for the densest bin, rather than assuming the maximum sample instead, finds it either - way. `dark_frame_guard` (typically the raw white level, loosely, or 0 to skip the guard) keeps - a frame nowhere near saturation from having its noise floor mistaken for a plateau. - """ - top = int(values.max()) - if dark_frame_guard > 0 and top * 2 < dark_frame_guard: - return 0.0 - lo = max(0, top - _PLATEAU_WINDOW) - counts = np.bincount(values[values >= lo] - lo, minlength=_PLATEAU_WINDOW + 1) - # Only codes the file holds count: a lossy raw encoding (Nikon compressed NEF) stores a sparse - # set of codes, and the gaps between them are not a falling tail. - populated = np.flatnonzero(counts) - peak_at = int(np.argmax(counts[populated])) - peak_bin = int(populated[peak_at]) - peak = int(counts[peak_bin]) - if peak < _MIN_PLATEAU_SITES: - return 0.0 - neighbors = counts[populated[peak_at + 1 : peak_at + 1 + _PLATEAU_NEIGHBOR_BINS]] - neighbor_density = float(neighbors.max()) if neighbors.size else 0.0 - if peak < _PLATEAU_DENSITY_RATIO * max(neighbor_density, 1.0): - return 0.0 - anchor = lo + peak_bin - return float(np.count_nonzero(values >= anchor)) / values.size - - def raw_channel_clip_fraction(path: str, channel_index: int, roi, saturation_margin: int = 16) -> tuple[float, float]: """Fraction of *raw Bayer* photosites for one channel past two independent points, inside the ROI: (linearity_fraction, plateau_fraction). @@ -161,7 +128,7 @@ def raw_channel_clip_fraction(path: str, channel_index: int, roi, saturation_mar number here: `linearity_fraction` (`_linearity_limit`, when the body publishes a calibrated ceiling) can overstate real clipping — a body's calibrated ceiling is often a conservative linearity limit, not where it actually saturates, so a channel legitimately using the top of - its range reads as partly "past" it. `plateau_fraction` (`_plateau_clip_fraction`) finds the + its range reads as partly "past" it. `plateau_fraction` (`negpy_core.raw_clip_fractions`) finds the true saturation pile from the data's shape, needs no metadata, and is what should gate a hard abort; the caller is expected to budget the two separately. `roi` is any object with a `.pixels(w, h)` method (duck-typed to avoid an infra→services import). channel_index: R=0, @@ -178,18 +145,17 @@ def raw_channel_clip_fraction(path: str, channel_index: int, roi, saturation_mar return 0.0, 0.0 h, w = img.shape[:2] x0, y0, x1, y1 = roi.pixels(w, h) - sub_img = img[y0:y1, x0:x1] - mask = np.isin(colors[y0:y1, x0:x1], wanted) - if not mask.any(): - return 0.0, 0.0 - values = sub_img[mask] - - linearity_fraction = 0.0 # raw_image_visible is absolute ADC counts (black not subtracted), matching the limit's scale. limit = _linearity_limit(raw, wanted) - if limit > 0: - threshold = max(0, limit - saturation_margin) - linearity_fraction = float(np.mean(values >= threshold)) - - plateau_fraction = _plateau_clip_fraction(values, int(raw.white_level or 0)) - return linearity_fraction, plateau_fraction + found = negpy_core.raw_clip_fractions( + np.ascontiguousarray(img[y0:y1, x0:x1], dtype=np.uint16), + np.ascontiguousarray(colors[y0:y1, x0:x1], dtype=np.uint8), + wanted, + max(0, limit - saturation_margin) if limit > 0 else None, + int(raw.white_level or 0), + _PLATEAU_WINDOW, + _MIN_PLATEAU_SITES, + _PLATEAU_NEIGHBOR_BINS, + _PLATEAU_DENSITY_RATIO, + ) + return (0.0, 0.0) if found is None else found diff --git a/negpy/infrastructure/display/icc_lut.py b/negpy/infrastructure/display/icc_lut.py index 74bb88dd3..7cb1ce18e 100644 --- a/negpy/infrastructure/display/icc_lut.py +++ b/negpy/infrastructure/display/icc_lut.py @@ -9,12 +9,11 @@ """ from typing import Any, Optional + +import negpy_core import numpy as np -from numba import prange # type: ignore from PIL import Image, ImageCms -from negpy.kernel.system.parallel import parallel_njit - DEFAULT_LUT_SIZE = 33 @@ -45,115 +44,11 @@ def build_3d_lut( return np.ascontiguousarray(lut) -@parallel_njit(cache=True, nogil=True, fastmath=True) -def _apply_lut_u16_jit(img: np.ndarray, lut: np.ndarray) -> np.ndarray: - h = img.shape[0] - w = img.shape[1] - n = lut.shape[0] - scale = np.float32(n - 1) / np.float32(65535.0) - out = np.empty_like(img) - for y in prange(h): - for x in range(w): - rf = np.float32(img[y, x, 0]) * scale - gf = np.float32(img[y, x, 1]) * scale - bf = np.float32(img[y, x, 2]) * scale - r0 = int(rf) - g0 = int(gf) - b0 = int(bf) - if r0 >= n - 1: - r0 = n - 2 - if g0 >= n - 1: - g0 = n - 2 - if b0 >= n - 1: - b0 = n - 2 - if r0 < 0: - r0 = 0 - if g0 < 0: - g0 = 0 - if b0 < 0: - b0 = 0 - r1 = r0 + 1 - g1 = g0 + 1 - b1 = b0 + 1 - dr = rf - r0 - dg = gf - g0 - db = bf - b0 - for c in range(3): - c000 = lut[r0, g0, b0, c] - c001 = lut[r0, g0, b1, c] - c010 = lut[r0, g1, b0, c] - c011 = lut[r0, g1, b1, c] - c100 = lut[r1, g0, b0, c] - c101 = lut[r1, g0, b1, c] - c110 = lut[r1, g1, b0, c] - c111 = lut[r1, g1, b1, c] - c00 = c000 * (1.0 - db) + c001 * db - c01 = c010 * (1.0 - db) + c011 * db - c10 = c100 * (1.0 - db) + c101 * db - c11 = c110 * (1.0 - db) + c111 * db - c0 = c00 * (1.0 - dg) + c01 * dg - c1 = c10 * (1.0 - dg) + c11 * dg - v = c0 * (1.0 - dr) + c1 * dr - vi = int(v * 65535.0 + 0.5) - if vi < 0: - vi = 0 - elif vi > 65535: - vi = 65535 - out[y, x, c] = vi - return out - - def apply_lut_u16(img: np.ndarray, lut: np.ndarray) -> np.ndarray: """Trilinearly interpolate `lut` onto a uint16 RGB image.""" img_c = np.ascontiguousarray(img) lut_c = np.ascontiguousarray(lut, dtype=np.float32) - return _apply_lut_u16_jit(img_c, lut_c) - - -@parallel_njit(cache=True, nogil=True, fastmath=True) -def _apply_lut_f32_jit(img: np.ndarray, lut: np.ndarray) -> np.ndarray: - h = img.shape[0] - w = img.shape[1] - n = lut.shape[0] - scale = np.float32(n - 1) - out = np.empty_like(img) - for y in prange(h): - for x in range(w): - rf = min(max(img[y, x, 0], np.float32(0.0)), np.float32(1.0)) * scale - gf = min(max(img[y, x, 1], np.float32(0.0)), np.float32(1.0)) * scale - bf = min(max(img[y, x, 2], np.float32(0.0)), np.float32(1.0)) * scale - r0 = int(rf) - g0 = int(gf) - b0 = int(bf) - if r0 >= n - 1: - r0 = n - 2 - if g0 >= n - 1: - g0 = n - 2 - if b0 >= n - 1: - b0 = n - 2 - r1 = r0 + 1 - g1 = g0 + 1 - b1 = b0 + 1 - dr = rf - r0 - dg = gf - g0 - db = bf - b0 - for c in range(3): - c000 = lut[r0, g0, b0, c] - c001 = lut[r0, g0, b1, c] - c010 = lut[r0, g1, b0, c] - c011 = lut[r0, g1, b1, c] - c100 = lut[r1, g0, b0, c] - c101 = lut[r1, g0, b1, c] - c110 = lut[r1, g1, b0, c] - c111 = lut[r1, g1, b1, c] - c00 = c000 * (1.0 - db) + c001 * db - c01 = c010 * (1.0 - db) + c011 * db - c10 = c100 * (1.0 - db) + c101 * db - c11 = c110 * (1.0 - db) + c111 * db - c0 = c00 * (1.0 - dg) + c01 * dg - c1 = c10 * (1.0 - dg) + c11 * dg - out[y, x, c] = c0 * (1.0 - dr) + c1 * dr - return out + return negpy_core.apply_lut_u16(img_c, lut_c) def apply_lut_f32(img: np.ndarray, lut: np.ndarray) -> np.ndarray: @@ -161,7 +56,7 @@ def apply_lut_f32(img: np.ndarray, lut: np.ndarray) -> np.ndarray: # No copy when the input is already contiguous f32, on the hot display path img_c = np.ascontiguousarray(img, dtype=np.float32) lut_c = np.ascontiguousarray(lut, dtype=np.float32) - return _apply_lut_f32_jit(img_c, lut_c) + return negpy_core.apply_lut_f32(img_c, lut_c) def apply_icc_u16_rgb( @@ -199,38 +94,6 @@ def apply_icc_u16_greyscale( return full_lut[img_u16] -@parallel_njit(cache=True, nogil=True, fastmath=True) -def _matrix_trc_u8_jit(img: np.ndarray, dec_lut: np.ndarray, m: np.ndarray, enc_lut: np.ndarray) -> np.ndarray: - h = img.shape[0] - w = img.shape[1] - kmax = enc_lut.shape[1] - 1 - out = np.empty_like(img) - for y in prange(h): - for x in range(w): - r = dec_lut[0, img[y, x, 0]] - g = dec_lut[1, img[y, x, 1]] - b = dec_lut[2, img[y, x, 2]] - for c in range(3): - v = m[c, 0] * r + m[c, 1] * g + m[c, 2] * b - if v < 0.0: - v = 0.0 - elif v > 1.0: - v = 1.0 - f = v * kmax - i0 = int(f) - if i0 >= kmax: - i0 = kmax - 1 - d = f - i0 - e = enc_lut[c, i0] * (1.0 - d) + enc_lut[c, i0 + 1] * d - vi = int(e * 255.0 + 0.5) - if vi < 0: - vi = 0 - elif vi > 255: - vi = 255 - out[y, x, c] = vi - return out - - _ENC_LUT_SIZE = 65536 @@ -268,4 +131,4 @@ def apply_matrix_trc_u8(img_u8: np.ndarray, src_icc: bytes, dst_icc: bytes) -> O enc_lut = np.ascontiguousarray(np.stack(enc_rows).astype(np.float32)) dec_lut = np.ascontiguousarray(np.clip(dec, 0.0, 1.0).astype(np.float32)) m32 = np.ascontiguousarray(m.astype(np.float32)) - return _matrix_trc_u8_jit(np.ascontiguousarray(img_u8), dec_lut, m32, enc_lut) + return negpy_core.matrix_trc_u8(np.ascontiguousarray(img_u8), dec_lut, m32, enc_lut) diff --git a/negpy/infrastructure/loaders/logluv.py b/negpy/infrastructure/loaders/logluv.py index c79d8b901..1b63497df 100644 --- a/negpy/infrastructure/loaders/logluv.py +++ b/negpy/infrastructure/loaders/logluv.py @@ -10,6 +10,7 @@ from typing import Tuple +import negpy_core import numpy as np _M_LN2 = 0.69314718055994530942 @@ -322,12 +323,7 @@ def normalize_linear(lin: np.ndarray) -> np.ndarray: per channel, correcting the per-channel black/gain offset inherent in Flextight CCD data (worst on red — weak CCD response, shallow C-41 cyan). """ - flat = lin.reshape(-1, lin.shape[-1]) - lo = np.percentile(flat, 0.2, axis=0) - hi = np.percentile(flat, 99.8, axis=0) - denom = hi - lo - denom = np.where(denom > 0, denom, 1.0) - return np.clip((lin - lo) / denom, 0.0, 1.0) + return negpy_core.normalize_linear(np.ascontiguousarray(lin, dtype=np.float64)) def xyz_to_linear_rgb(xyz: np.ndarray) -> np.ndarray: diff --git a/negpy/infrastructure/loaders/rawpy_loader.py b/negpy/infrastructure/loaders/rawpy_loader.py index 1c3cbacb5..a216328e2 100644 --- a/negpy/infrastructure/loaders/rawpy_loader.py +++ b/negpy/infrastructure/loaders/rawpy_loader.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import Any, ContextManager, Optional, Tuple -import cv2 +import negpy_core import numpy as np import rawpy import tifffile @@ -115,7 +115,7 @@ def tag(name: str) -> Optional[Any]: if min_preserve_ir and samples == 4 and scale < 1.0: size = max(1, int(round(1.0 / scale)) | 1) if size > 1: - ir_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (size, size)) + ir_kernel = negpy_core.ellipse_kernel((size, size)) for decoded, position, _shape in page.segments(maxworkers=1): if should_cancel is not None and should_cancel(): @@ -150,14 +150,14 @@ def tag(name: str) -> Optional[Any]: continue target = (right - left, bottom - top) if min_preserve_ir and samples == 4: - output[top:bottom, left:right, :3] = cv2.resize(data[:, :, :3], target, interpolation=cv2.INTER_AREA) - ir = data[:, :, 3] + output[top:bottom, left:right, :3] = negpy_core.resize_area(np.ascontiguousarray(data[:, :, :3], dtype=np.float32), target) + ir = np.ascontiguousarray(data[:, :, 3], dtype=np.float32) if ir_kernel is not None: # A boundary defect needs its inward footprint without decoding adjacent segments. - ir = cv2.erode(ir, ir_kernel, borderType=cv2.BORDER_REPLICATE) - output[top:bottom, left:right, 3] = cv2.resize(ir, target, interpolation=cv2.INTER_AREA) + ir = negpy_core.morphology_ex(ir, negpy_core.MORPH_ERODE, ir_kernel, border_type=negpy_core.BORDER_REPLICATE) + output[top:bottom, left:right, 3] = negpy_core.resize_area(ir, target) else: - output[top:bottom, left:right] = cv2.resize(data, target, interpolation=cv2.INTER_AREA) + output[top:bottom, left:right] = negpy_core.resize_area(np.ascontiguousarray(data, dtype=np.float32), target) full_dims = (height, width) if normalize_tags: diff --git a/negpy/infrastructure/scanners/sane_backend.py b/negpy/infrastructure/scanners/sane_backend.py index 344c51aef..818bba47d 100644 --- a/negpy/infrastructure/scanners/sane_backend.py +++ b/negpy/infrastructure/scanners/sane_backend.py @@ -4,7 +4,7 @@ import threading from typing import Callable -import cv2 +import negpy_core import numpy as np from negpy.kernel.system.text import count_of @@ -688,7 +688,7 @@ def _align_ir_to_rgb(rgb: np.ndarray, ir: np.ndarray) -> np.ndarray: return ir ref = rgb.astype(np.float32) if ref.ndim == 3: - ref = ref.mean(axis=2) + ref = negpy_core.mean_channels(ref) if ref.shape[2] == 3 else ref.mean(axis=2) mov = ir.astype(np.float32) if mov.ndim == 3: mov = mov[:, :, 0] @@ -696,12 +696,11 @@ def _align_ir_to_rgb(rgb: np.ndarray, ir: np.ndarray) -> np.ndarray: scale = max(1.0, w / _IR_ALIGN_PROBE_WIDTH) if scale > 1.0: sz = (_IR_ALIGN_PROBE_WIDTH, max(1, round(h / scale))) - r = cv2.resize(ref, sz, interpolation=cv2.INTER_AREA) - m = cv2.resize(mov, sz, interpolation=cv2.INTER_AREA) + r = negpy_core.resize_area(np.ascontiguousarray(ref), sz) + m = negpy_core.resize_area(np.ascontiguousarray(mov), sz) else: r, m = ref, mov - win = cv2.createHanningWindow((r.shape[1], r.shape[0]), cv2.CV_32F) - (dx, dy), _resp = cv2.phaseCorrelate(np.ascontiguousarray(r), np.ascontiguousarray(m), win) + (dx, dy), _resp = negpy_core.phase_correlate(np.ascontiguousarray(r), np.ascontiguousarray(m)) dx, dy = dx * scale, dy * scale if max(abs(dx), abs(dy)) > max(16.0, _IR_ALIGN_MAX_SHIFT_FRAC * w): return ir @@ -722,14 +721,16 @@ def _resample_rows_to_dpi(array: np.ndarray, *, native_dpi: int, target_dpi: int caller): interpolating in more rows than the stepper actually moved is an honest resample of real data, never inventing detail a downsample would have thrown away. - Bilinear (cv2's default): plenty for reconciling a mechanically-limited stepper axis + Bilinear: plenty for reconciling a mechanically-limited stepper axis against a genuinely finer sensor axis. Not used anywhere IR is involved — see the capture_ir guard at the call site — since _align_ir_to_rgb documents that even interpolating IR data risks softening a thin dust defect's minimum. """ target_rows = max(1, round(array.shape[0] * target_dpi / native_dpi)) width = array.shape[1] - return cv2.resize(array, (width, target_rows), interpolation=cv2.INTER_LINEAR) + if array.dtype == np.float32: + return negpy_core.resize_linear(np.ascontiguousarray(array), (width, target_rows)) + return negpy_core.resize_rows_linear(np.ascontiguousarray(array), target_rows) def _validate_inline_rgbi_parameters( diff --git a/negpy/infrastructure/simulated/gphoto.py b/negpy/infrastructure/simulated/gphoto.py index 81b0a1a2c..ee56ec435 100644 --- a/negpy/infrastructure/simulated/gphoto.py +++ b/negpy/infrastructure/simulated/gphoto.py @@ -7,8 +7,8 @@ import io from typing import Optional -import cv2 import numpy as np +from PIL import Image import tifffile from negpy.infrastructure.capture.base import CAPTURE_ORDER @@ -221,8 +221,9 @@ def still_dng(self) -> bytes: def preview_jpeg(self) -> bytes: display = np.clip(self._signal(*_film(*_PREVIEW_HW, self._frame)), 0.0, 1.0) ** (1 / 2.2) - _ok, jpeg = cv2.imencode(".jpg", cv2.cvtColor((display * 255).astype(np.uint8), cv2.COLOR_RGB2BGR)) - return jpeg.tobytes() + out = io.BytesIO() + Image.fromarray((display * 255).astype(np.uint8)).save(out, "JPEG", quality=95) + return out.getvalue() @functools.lru_cache(maxsize=4) diff --git a/negpy/kernel/image/logic.py b/negpy/kernel/image/logic.py index 477606794..9411d0970 100644 --- a/negpy/kernel/image/logic.py +++ b/negpy/kernel/image/logic.py @@ -1,140 +1,60 @@ import hashlib import os -from typing import Any, Optional, Sequence +from typing import Any, Optional +import negpy_core import numpy as np -from numba import njit, prange # type: ignore -from negpy.kernel.system.parallel import parallel_njit from negpy.domain.types import LUMA_R, LUMA_G, LUMA_B -from negpy.kernel.image.validation import ensure_image from negpy.kernel.system.logging import get_logger logger = get_logger(__name__) -@njit(cache=True, nogil=True, fastmath=True) -def _get_luminance_jit(img: np.ndarray) -> np.ndarray: - """ - Rec. 709 luminance. - """ - h, w, _ = img.shape - res = np.empty((h, w), dtype=np.float32) - for y in range(h): - for x in range(w): - res[y, x] = LUMA_R * img[y, x, 0] + LUMA_G * img[y, x, 1] + LUMA_B * img[y, x, 2] - return res - - -@njit(cache=True, nogil=True, fastmath=True) def _to_uint16_jit(img: np.ndarray) -> np.ndarray: """ - Scale to uint16 (rounds to nearest, clips & handles NaNs). + Scale a float32 buffer to uint16: rounds to nearest, clips, NaN to 0. The +0.5 rounds. A bare cast truncates toward zero, which biases every sample down by half a level and doubles the quantisation error: the error distribution runs [-1, 0] - instead of [-0.5, +0.5]. The luma kernels below have always rounded; these did not. + instead of [-0.5, +0.5]. """ - res = np.empty_like(img, dtype=np.uint16) - img_flat = img.reshape(-1) - res_flat = res.reshape(-1) - - for i in range(len(img_flat)): - val = img_flat[i] - if np.isnan(val): - v = 0.0 - else: - v = val * 65535.0 + 0.5 - - if v < 0.0: - v = 0.0 - elif v > 65535.0: - v = 65535.0 - - res_flat[i] = np.uint16(v) - return res + return negpy_core.to_uint16(np.ascontiguousarray(img, dtype=np.float32)) -@njit(cache=True, nogil=True, fastmath=True) def _to_uint8_jit(img: np.ndarray) -> np.ndarray: """ - Scale to uint8 (rounds to nearest, clips & handles NaNs). See _to_uint16_jit. + Scale a float32 buffer to uint8: rounds to nearest, clips, NaN to 0. See _to_uint16_jit. """ - res = np.empty_like(img, dtype=np.uint8) - img_flat = img.reshape(-1) - res_flat = res.reshape(-1) - - for i in range(len(img_flat)): - val = img_flat[i] - if np.isnan(val): - v = 0.0 - else: - v = val * 255.0 + 0.5 - - if v < 0.0: - v = 0.0 - elif v > 255.0: - v = 255.0 - - res_flat[i] = np.uint8(v) - return res + return negpy_core.to_uint8(np.ascontiguousarray(img, dtype=np.float32)) -@njit(cache=True, nogil=True, fastmath=True) def uint8_to_float32(img: np.ndarray) -> np.ndarray: """ - Fast JIT conversion from uint8 to float32 [0.0, 1.0]. + uint8 to float32 [0.0, 1.0]. """ - h, w, c = img.shape - res = np.empty((h, w, c), dtype=np.float32) - inv_255 = 1.0 / 255.0 - for y in range(h): - for x in range(w): - for ch in range(c): - res[y, x, ch] = np.float32(img[y, x, ch]) * inv_255 - return res + return negpy_core.uint8_to_float32(np.ascontiguousarray(img, dtype=np.uint8)) -@njit(cache=True, nogil=True) def rgb_to_rgba_into(src: np.ndarray, dst: np.ndarray) -> None: - """Copies the three colour planes of ``src`` into the RGBA ``dst``; alpha is left as is. - numpy cannot vectorise a 3-of-4 strided copy, so a texture upload spent longer here than - on the GPU.""" - h, w = src.shape[0], src.shape[1] - for y in range(h): - for x in range(w): - dst[y, x, 0] = src[y, x, 0] - dst[y, x, 1] = src[y, x, 1] - dst[y, x, 2] = src[y, x, 2] - - -@njit(cache=True, nogil=True) + """Copies the three color planes of float32 ``src`` into the RGBA ``dst``; alpha is left + as is. numpy cannot vectorize a 3-of-4 strided copy.""" + negpy_core.rgb_to_rgba_into(np.asarray(src, dtype=np.float32), dst) + + def rgba_to_rgb_into(src: np.ndarray, dst: np.ndarray, oy: int, ox: int) -> None: - """Writes the RGB of ``src[oy:oy+h, ox:ox+w]`` into the (h, w, 3) ``dst``.""" - h, w = dst.shape[0], dst.shape[1] - for y in range(h): - for x in range(w): - dst[y, x, 0] = src[oy + y, ox + x, 0] - dst[y, x, 1] = src[oy + y, ox + x, 1] - dst[y, x, 2] = src[oy + y, ox + x, 2] + """Writes the RGB of float32 ``src[oy:oy+h, ox:ox+w]`` into the (h, w, 3) ``dst``.""" + negpy_core.rgba_to_rgb_into(np.asarray(src, dtype=np.float32), dst, int(oy), int(ox)) -@njit(cache=True, nogil=True, fastmath=True) def uint16_to_float32(img: np.ndarray) -> np.ndarray: """ - Fast JIT conversion from uint16 to float32 [0.0, 1.0]. + uint16 to float32 [0.0, 1.0]. """ - h, w, c = img.shape - res = np.empty((h, w, c), dtype=np.float32) - inv_65535 = 1.0 / 65535.0 - for y in range(h): - for x in range(w): - for ch in range(c): - res[y, x, ch] = np.float32(img[y, x, ch]) * inv_65535 - return res + return negpy_core.uint16_to_float32(np.ascontiguousarray(img, dtype=np.uint16)) def srgb_to_linear(img: np.ndarray) -> np.ndarray: """Convert sRGB gamma-encoded float32 image to linear light (IEC 61966-2-1).""" - return np.where(img <= 0.04045, img / 12.92, ((img + 0.055) / 1.055) ** 2.4).astype(np.float32) + return negpy_core.srgb_to_linear(np.ascontiguousarray(img, dtype=np.float32)) # Working-space output transform: the Adobe RGB (1998) TRC, a pure 563/256 power with no @@ -143,38 +63,23 @@ def srgb_to_linear(img: np.ndarray) -> np.ndarray: _WORKING_GAMMA = 563.0 / 256.0 # 2.19921875 -@parallel_njit(cache=True, fastmath=True) def _oetf_encode_flat(flat: np.ndarray, inv_gamma: float) -> np.ndarray: - """Row-parallel working-space encode over a flattened buffer (shape-agnostic).""" - n = flat.shape[0] - out = np.empty(n, dtype=np.float32) - for i in prange(n): - x = flat[i] - if x < 0.0: - x = 0.0 - elif x > 1.0: - x = 1.0 - out[i] = x**inv_gamma - return out - - -@parallel_njit(cache=True, fastmath=True) + """Working-space encode of a flat buffer: clamped to 0..1, raised to `inv_gamma`.""" + return negpy_core.oetf_encode(np.ascontiguousarray(flat, dtype=np.float32), inv_gamma) + + def _oetf_decode_flat(flat: np.ndarray, gamma: float) -> np.ndarray: """Inverse of _oetf_encode_flat.""" - n = flat.shape[0] - out = np.empty(n, dtype=np.float32) - for i in prange(n): - e = flat[i] - if e < 0.0: - e = 0.0 - out[i] = e**gamma - return out + return negpy_core.oetf_decode(np.ascontiguousarray(flat, dtype=np.float32), gamma) + + +WORKING_INV_GAMMA = np.float32(1.0 / _WORKING_GAMMA) def working_oetf_encode(img: np.ndarray) -> np.ndarray: """Scene-linear -> display-encoded code values [0,1] (Adobe RGB TRC).""" flat = np.ascontiguousarray(img, dtype=np.float32).reshape(-1) - return _oetf_encode_flat(flat, np.float32(1.0 / _WORKING_GAMMA)).reshape(img.shape) + return _oetf_encode_flat(flat, WORKING_INV_GAMMA).reshape(img.shape) def working_oetf_decode(img: np.ndarray) -> np.ndarray: @@ -206,17 +111,9 @@ def working_oetf_decode(img: np.ndarray) -> np.ndarray: _LAB_KAPPA = 7.787 -@parallel_njit(cache=True, fastmath=True) def _matmul_3x3_kernel(px: np.ndarray, m: np.ndarray) -> np.ndarray: - """Row-parallel 3x3 matrix multiply over an (N, 3) pixel array.""" - n = px.shape[0] - out = np.empty((n, 3), dtype=np.float32) - for i in prange(n): - r, g, b = px[i, 0], px[i, 1], px[i, 2] - out[i, 0] = m[0, 0] * r + m[0, 1] * g + m[0, 2] * b - out[i, 1] = m[1, 0] * r + m[1, 1] * g + m[1, 2] * b - out[i, 2] = m[2, 0] * r + m[2, 1] * g + m[2, 2] * b - return out + """3x3 matrix multiply over an (N, 3) pixel array.""" + return negpy_core.matmul_3x3(np.ascontiguousarray(px, dtype=np.float32), m) def apply_primaries_transform(img: np.ndarray, src_to_xyz: np.ndarray) -> np.ndarray: @@ -237,156 +134,24 @@ def apply_primaries_transform(img: np.ndarray, src_to_xyz: np.ndarray) -> np.nda return working_oetf_encode(out) -@parallel_njit(cache=True, fastmath=True) def _rgb_to_lab_kernel(px: np.ndarray, m: np.ndarray, white: np.ndarray, eps: float, kappa: float) -> np.ndarray: - """Row-parallel linear working RGB -> CIELAB (D65) over an (N, 3) pixel list.""" - n = px.shape[0] - out = np.empty((n, 3), dtype=np.float32) - c = np.float32(16.0 / 116.0) - for i in prange(n): - r = px[i, 0] - g = px[i, 1] - b = px[i, 2] - if r < 0.0: - r = 0.0 - if g < 0.0: - g = 0.0 - if b < 0.0: - b = 0.0 - xr = (m[0, 0] * r + m[0, 1] * g + m[0, 2] * b) / white[0] - yr = (m[1, 0] * r + m[1, 1] * g + m[1, 2] * b) / white[1] - zr = (m[2, 0] * r + m[2, 1] * g + m[2, 2] * b) / white[2] - fx = xr ** (1.0 / 3.0) if xr > eps else kappa * xr + c - fy = yr ** (1.0 / 3.0) if yr > eps else kappa * yr + c - fz = zr ** (1.0 / 3.0) if zr > eps else kappa * zr + c - out[i, 0] = 116.0 * fy - 16.0 - out[i, 1] = 500.0 * (fx - fy) - out[i, 2] = 200.0 * (fy - fz) - return out - - -@parallel_njit(cache=True, fastmath=True) + """Linear working RGB -> CIELAB (D65) over an (N, 3) pixel list.""" + return negpy_core.rgb_to_lab(np.ascontiguousarray(px, dtype=np.float32), m, white, eps, kappa) + + def _lab_to_rgb_kernel(lab: np.ndarray, m: np.ndarray, white: np.ndarray, eps: float, kappa: float) -> np.ndarray: - """Row-parallel inverse: CIELAB (D65) -> linear working RGB over an (N, 3) pixel list.""" - n = lab.shape[0] - out = np.empty((n, 3), dtype=np.float32) - c = np.float32(16.0 / 116.0) - for i in prange(n): - fy = (lab[i, 0] + 16.0) / 116.0 - fx = lab[i, 1] / 500.0 + fy - fz = fy - lab[i, 2] / 200.0 - fx3 = fx * fx * fx - fy3 = fy * fy * fy - fz3 = fz * fz * fz - xr = (fx3 if fx3 > eps else (fx - c) / kappa) * white[0] - yr = (fy3 if fy3 > eps else (fy - c) / kappa) * white[1] - zr = (fz3 if fz3 > eps else (fz - c) / kappa) * white[2] - r = m[0, 0] * xr + m[0, 1] * yr + m[0, 2] * zr - g = m[1, 0] * xr + m[1, 1] * yr + m[1, 2] * zr - b = m[2, 0] * xr + m[2, 1] * yr + m[2, 2] * zr - out[i, 0] = r if r > 0.0 else 0.0 - out[i, 1] = g if g > 0.0 else 0.0 - out[i, 2] = b if b > 0.0 else 0.0 - return out - - -@njit(inline="always") -def _in_gamut_lab(l_val: float, a: float, b: float, m: np.ndarray, white: np.ndarray, eps: float, kappa: float, c: float) -> bool: - """Whether (L, a, b) decodes to linear working RGB within [0,1] on all channels.""" - fy = (l_val + 16.0) / 116.0 - fx = a / 500.0 + fy - fz = fy - b / 200.0 - fx3 = fx * fx * fx - fy3 = fy * fy * fy - fz3 = fz * fz * fz - xr = (fx3 if fx3 > eps else (fx - c) / kappa) * white[0] - yr = (fy3 if fy3 > eps else (fy - c) / kappa) * white[1] - zr = (fz3 if fz3 > eps else (fz - c) / kappa) * white[2] - r = m[0, 0] * xr + m[0, 1] * yr + m[0, 2] * zr - g = m[1, 0] * xr + m[1, 1] * yr + m[1, 2] * zr - bl = m[2, 0] * xr + m[2, 1] * yr + m[2, 2] * zr - tol = np.float32(1e-4) - one = np.float32(1.0) - return r >= -tol and r <= one + tol and g >= -tol and g <= one + tol and bl >= -tol and bl <= one + tol - - -# Skin-tone mask for skin_chroma_rein: an axis-aligned reduction of the CIELAB skin locus. -# Hue is the axis that stays put across the whole tonal range, chroma is bounded, and -# lightness is free apart from the two ends where the hue angle turns to noise. -# -# The chroma window is the discriminator, and it is the measured skin locus, not a gamut -# bound: sunset, terracotta and brick all sit inside the hue band. It cuts both ways. Skin -# above the window keeps only partial weight, and warm objects at skin's own chroma (wood, -# tan leather, sand) are the same color as skin. Neither is separable per pixel. -_SKIN_HUE_CENTER_DEG = np.float32(52.0) -_SKIN_HUE_WIDTH_DEG = np.float32(20.0) -_SKIN_CHROMA_FULL = np.float32(35.0) -_SKIN_CHROMA_ZERO = np.float32(60.0) -_SKIN_L_LO = np.float32(15.0) -_SKIN_L_HI = np.float32(95.0) - -# Chroma ceiling the rein pulls toward: _SKIN_CEIL_AT_FULL / strength, so the ceiling runs -# past any reachable chroma as strength approaches 0 and the control fades out continuously. -# A lerp to a finite "off" ceiling would step the moment the slider left 0. The knee starts -# at a fraction of the ceiling, so ordinary skin below it passes through untouched. -_SKIN_CEIL_AT_FULL = np.float32(22.0) -_SKIN_KNEE_START_FRAC = np.float32(0.6) - - -@njit(inline="always") -def _smoothstep_scalar(e0: float, e1: float, x: float) -> float: - t = (x - e0) / (e1 - e0) - if t < 0.0: - t = 0.0 - elif t > 1.0: - t = 1.0 - return float(t * t * (np.float32(3.0) - np.float32(2.0) * t)) - - -@njit(inline="always") + """Inverse: CIELAB (D65) -> linear working RGB over an (N, 3) pixel list.""" + return negpy_core.lab_to_rgb(np.ascontiguousarray(lab, dtype=np.float32), m, white, eps, kappa) + + def _skin_weight(l_val: float, a: float, b: float) -> float: - """0 (not skin) to 1 (dead centre of the skin locus). Near-neutral pixels - have an undefined hue angle, so gate on chroma before trusting atan2.""" - chroma = np.sqrt(a * a + b * b) - if chroma < np.float32(2.0): - return 0.0 - hue_deg = np.degrees(np.arctan2(b, a)) - dist = hue_deg - _SKIN_HUE_CENTER_DEG - # Wrap to [-180, 180], so 179 against -179 reads as 2 degrees apart, not 358. - dist = dist - np.float32(360.0) * np.round(dist / np.float32(360.0)) - x = dist / _SKIN_HUE_WIDTH_DEG - w_hue = np.exp(np.float32(-0.5) * x * x) - w_chroma = np.float32(1.0) - _smoothstep_scalar(_SKIN_CHROMA_FULL, _SKIN_CHROMA_ZERO, chroma) - w_light = _smoothstep_scalar(np.float32(0.0), _SKIN_L_LO, l_val) * ( - np.float32(1.0) - _smoothstep_scalar(_SKIN_L_HI, np.float32(100.0), l_val) - ) - return float(w_hue * w_chroma * w_light) + """0 (not skin) to 1 (dead centre of the skin locus); 0 below a chroma of 2, where the hue is noise.""" + return negpy_core.skin_weight(l_val, a, b) -@parallel_njit(cache=True, fastmath=True) def _skin_chroma_rein_kernel(lab: np.ndarray, strength: float) -> np.ndarray: - """Soft chroma ceiling inside the skin mask -- see skin_chroma_rein.""" - n = lab.shape[0] - out = np.empty((n, 3), dtype=np.float32) - one = np.float32(1.0) - ceiling = _SKIN_CEIL_AT_FULL / strength - start = _SKIN_KNEE_START_FRAC * ceiling - span = ceiling - start - for i in prange(n): - l_val = lab[i, 0] - a = lab[i, 1] - b = lab[i, 2] - chroma = np.sqrt(a * a + b * b) - scale = one - if chroma > start: - w = _skin_weight(l_val, a, b) - if w > 0.0: - knee = start + span * (one - np.exp(-(chroma - start) / span)) - scale = (chroma + w * (knee - chroma)) / chroma - out[i, 0] = l_val - out[i, 1] = a * scale - out[i, 2] = b * scale - return out + """Soft chroma ceiling inside the skin mask over an (N, 3) Lab list -- see skin_chroma_rein.""" + return negpy_core.skin_chroma_rein(np.ascontiguousarray(lab, dtype=np.float32), strength) def skin_chroma_rein(lab: np.ndarray, strength: float) -> np.ndarray: @@ -412,7 +177,6 @@ def skin_chroma_rein(lab: np.ndarray, strength: float) -> np.ndarray: return out.reshape(arr.shape) -@parallel_njit(cache=True, fastmath=True) def _gamut_aware_chroma_scale_kernel( lab: np.ndarray, saturation: float, m: np.ndarray, white: np.ndarray, eps: float, kappa: float, iters: int ) -> np.ndarray: @@ -432,44 +196,7 @@ def _gamut_aware_chroma_scale_kernel( changes the R:G:B ratio the eye actually sees. This sidesteps that by never overshooting in the first place. """ - n = lab.shape[0] - out = np.empty((n, 3), dtype=np.float32) - c = np.float32(16.0 / 116.0) - one = np.float32(1.0) - for i in prange(n): - l_val = lab[i, 0] - a = lab[i, 1] - b = lab[i, 2] - if saturation <= one: - # Desaturation never overshoots the gamut: moving toward the achromatic axis - # cannot push a channel further out of range. - eff = saturation - else: - if _in_gamut_lab(l_val, a * saturation, b * saturation, m, white, eps, kappa, c): - # The full push already lands in gamut, so use it directly. Without this, - # bisecting within [1, saturation] converges lo toward saturation itself, which - # is the search's own upper bound and not a real constraint, and the knee - # formula below then throttles pixels that were never going to clip. - eff = saturation - else: - lo = one - hi = saturation - still_ok = _in_gamut_lab(l_val, a, b, m, white, eps, kappa, c) - for _ in range(iters): - mid = (lo + hi) / np.float32(2.0) - if still_ok and _in_gamut_lab(l_val, a * mid, b * mid, m, white, eps, kappa, c): - lo = mid - else: - hi = mid - s_max = lo - if s_max < one + np.float32(1e-4): - s_max = one + np.float32(1e-4) - knee = s_max - one - eff = one + knee * (one - np.exp(-(saturation - one) / knee)) - out[i, 0] = l_val - out[i, 1] = a * eff - out[i, 2] = b * eff - return out + return negpy_core.gamut_aware_chroma_scale(np.ascontiguousarray(lab, dtype=np.float32), saturation, m, white, eps, kappa, iters) def gamut_aware_chroma_scale(lab: np.ndarray, saturation: float, iters: int = 10) -> np.ndarray: @@ -482,6 +209,10 @@ def gamut_aware_chroma_scale(lab: np.ndarray, saturation: float, iters: int = 10 return out.reshape(arr.shape) +# The working Lab constants, in the order negpy_core's Lab-stage kernels take them. +WORKING_LAB = (_WORKING_TO_XYZ, _XYZ_TO_WORKING, _WORKING_WHITE, np.float32(_LAB_EPS), np.float32(_LAB_KAPPA)) + + def rgb_to_lab_working(img: np.ndarray) -> np.ndarray: """Linear working RGB -> CIELAB (D65). No transfer decode — the buffer is linear. @@ -498,86 +229,12 @@ def lab_to_rgb_working(lab: np.ndarray) -> np.ndarray: return out.reshape(arr.shape) -@njit(cache=True, nogil=True, fastmath=True) -def _float_to_uint8_luma_jit(img: np.ndarray) -> np.ndarray: - """ - Luminance -> uint8. - """ - scale = 255.0 - dtype = np.uint8 - - if img.ndim == 2: - h, w = img.shape - res = np.empty((h, w), dtype=dtype) - for y in range(h): - for x in range(w): - v = img[y, x] * scale + 0.5 - if v < 0: - v = 0 - elif v > scale: - v = scale - res[y, x] = dtype(v) - return res - else: - h, w, c = img.shape - res = np.empty((h, w), dtype=dtype) - for y in range(h): - for x in range(w): - lum = LUMA_R * img[y, x, 0] + LUMA_G * img[y, x, 1] + LUMA_B * img[y, x, 2] - v = lum * scale + 0.5 - if v < 0: - v = 0 - elif v > scale: - v = scale - res[y, x] = dtype(v) - return res - - -@njit(cache=True, nogil=True, fastmath=True) -def _float_to_uint16_luma_jit(img: np.ndarray) -> np.ndarray: - """ - Luminance -> uint16. - """ - scale = 65535.0 - dtype = np.uint16 - - if img.ndim == 2: - h, w = img.shape - res = np.empty((h, w), dtype=dtype) - for y in range(h): - for x in range(w): - v = img[y, x] * scale + 0.5 - if v < 0: - v = 0 - elif v > scale: - v = scale - res[y, x] = dtype(v) - return res - else: - h, w, c = img.shape - res = np.empty((h, w), dtype=dtype) - for y in range(h): - for x in range(w): - lum = LUMA_R * img[y, x, 0] + LUMA_G * img[y, x, 1] + LUMA_B * img[y, x, 2] - v = lum * scale + 0.5 - if v < 0: - v = 0 - elif v > scale: - v = scale - res[y, x] = dtype(v) - return res - - def float_to_uint_luma(img: np.ndarray, bit_depth: int = 8) -> np.ndarray: """ - Fuses luminance calculation and bit-depth conversion. - Dispatches to specialized JIT kernels based on bit_depth. + Fuses luminance calculation and bit-depth conversion: an HxWx3 float32 buffer (or an + HxW one, taken as luminance) to HxW uint8, or uint16 for ``bit_depth`` 16. """ - if bit_depth == 16: - res_16: np.ndarray = _float_to_uint16_luma_jit(img) - return res_16 - res_8: np.ndarray = _float_to_uint8_luma_jit(img) - return res_8 + return negpy_core.luma_to_uint(np.ascontiguousarray(img, dtype=np.float32), 16 if bit_depth == 16 else 8) def float_to_uint16(img: np.ndarray) -> np.ndarray: @@ -632,7 +289,7 @@ def get_luminance(img: np.ndarray) -> np.ndarray: Calculates relative luminance. Supports (H, W, 3) and (N, 3) arrays. """ if img.ndim == 3: - return ensure_image(_get_luminance_jit(np.ascontiguousarray(img.astype(np.float32)))) + return negpy_core.luminance(np.ascontiguousarray(img[..., :3], dtype=np.float32)) return LUMA_R * img[..., 0] + LUMA_G * img[..., 1] + LUMA_B * img[..., 2] @@ -720,17 +377,3 @@ def percentile_from_sorted(sorted_grid: np.ndarray, q: float) -> np.ndarray: a, b = sorted_grid[lo], sorted_grid[hi] diff = b - a return b - diff * (1 - t) if t >= 0.5 else a + diff * t - - -def percentiles(values: np.ndarray, qs: Sequence[float]) -> tuple: - """`np.percentile(values, q)` for each q, bit for bit, from one partition instead of one per q.""" - flat = values.ravel() - n = flat.size - if n == 0: - raise IndexError("percentiles of an empty array") - ranks = [] - for q in qs: - lo = min(max(int(np.floor((n - 1) * np.true_divide(q, 100))), 0), n - 1) - ranks += [lo, min(lo + 1, n - 1)] - part = np.partition(flat, sorted(set(ranks))) - return tuple(percentile_from_sorted(part, q) for q in qs) diff --git a/negpy/kernel/system/override.py b/negpy/kernel/system/override.py index 40b947ceb..dca8e9d8b 100644 --- a/negpy/kernel/system/override.py +++ b/negpy/kernel/system/override.py @@ -53,10 +53,6 @@ # Override HQ preview on startup. Uncomment to force a value. # force_hq_preview = false -# Multi-core CPU rendering kernels. Defaults to true except on macOS, where it -# defaults to false while crash reports are investigated. Uncomment to force. -# cpu_parallel = true - # Smaller tiles and no readback pipelining during export, at some cost to export speed. # Off by default. Turn on if exporting crashes on your GPU (typically an older or # memory-constrained integrated one) -- also available in Preferences. @@ -104,7 +100,6 @@ class OverrideConfig: qt_rhi_backend: str = "auto" qt_platform: str = "auto" force_hq_preview: bool | None = None - cpu_parallel: bool | None = None low_vram_export_tiling: bool | None = None max_texture_size: int | None = None preview_render_size: int | None = None @@ -170,9 +165,6 @@ def _parse(data: dict) -> OverrideConfig: raw_hq = performance.get("force_hq_preview") force_hq: bool | None = bool(raw_hq) if isinstance(raw_hq, bool) else None - raw_par = performance.get("cpu_parallel") - cpu_parallel: bool | None = bool(raw_par) if isinstance(raw_par, bool) else None - raw_low_vram = performance.get("low_vram_export_tiling") low_vram_tiling: bool | None = bool(raw_low_vram) if isinstance(raw_low_vram, bool) else None @@ -203,7 +195,6 @@ def _parse(data: dict) -> OverrideConfig: qt_rhi_backend=qt_rhi, qt_platform=qt_platform, force_hq_preview=force_hq, - cpu_parallel=cpu_parallel, low_vram_export_tiling=low_vram_tiling, max_texture_size=max_tex, preview_render_size=prev_size, @@ -268,9 +259,6 @@ def apply(cfg: OverrideConfig, app_config: AppConfig) -> None: if cfg.force_hq_preview is not None: app_config.force_hq_preview = cfg.force_hq_preview - if cfg.cpu_parallel is not None: - app_config.cpu_parallel = cfg.cpu_parallel - if cfg.low_vram_export_tiling is not None: app_config.low_vram_export_tiling = cfg.low_vram_export_tiling diff --git a/negpy/kernel/system/parallel.py b/negpy/kernel/system/parallel.py deleted file mode 100644 index 7dd926c06..000000000 --- a/negpy/kernel/system/parallel.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -Runtime gate for Numba parallel (multi-core) CPU kernels. - -The app pins NUMBA_THREADING_LAYER=workqueue (desktop/main.py), and workqueue -is not thread-safe under concurrent access: if two threads invoke -parallel=True kernels at the same time, Numba hard-terminates the process. -The render worker, export worker and the CPU display path (ICC LUT) can all -overlap, which surfaced as crashes during rendering/export (reported on macOS -first, but the race is cross-platform). Two safeguards: - -- every parallel kernel is compiled in BOTH serial and parallel variants via - `parallel_njit` and dispatched per call, so parallelism can be switched off - at runtime without recompiling or restarting — the serial variant is the - plain @njit path that has always been stable; -- parallel invocations are serialized behind a process-wide lock, so two - threads can never be inside the workqueue scheduler at once. The kernels - are themselves multi-core, so cross-thread serialization costs ~nothing. - -Default policy: parallel on, except macOS until the lock fix is verified on -real hardware there. Override with `cpu_parallel = true/false` under -[performance] in override.toml. -""" - -import sys -import threading -import types -from typing import Any, Callable, Optional - -from numba import njit - -from negpy.kernel.system.logging import get_logger - -logger = get_logger(__name__) - -# workqueue threading layer: concurrent parallel-kernel entry aborts the process. -_invocation_gate = threading.Lock() - -# Below this many elements a kernel runs the serial variant and skips the gate: the GUI thread's -# chart and wedge curves must not wait behind the render thread's full-frame kernels. -SERIAL_MAX_ELEMENTS = 1 << 16 - - -def default_cpu_parallel(platform: str = sys.platform) -> bool: - """Platform policy: parallel kernels on, except macOS (pending verification).""" - return platform != "darwin" - - -_parallel_enabled: bool = default_cpu_parallel() - - -def parallel_enabled() -> bool: - return _parallel_enabled - - -def set_parallel_enabled(enabled: bool) -> None: - global _parallel_enabled - _parallel_enabled = bool(enabled) - - -def resolve_cpu_parallel(file_override: Optional[bool], stored: Optional[bool]) -> Optional[bool]: - """Which answer wins: override.toml, then the saved setting, then the platform default. - - None at every level means "platform default", so an installation that has never touched - the setting keeps whatever its platform does today — on for Windows and Linux, off for - macOS. Surfacing the control must not silently re-decide it for anyone. - """ - if file_override is not None: - return file_override - return stored - - -def configure_cpu_parallel(override: Optional[bool]) -> None: - """Apply the startup policy: explicit override wins, else the platform default.""" - set_parallel_enabled(default_cpu_parallel() if override is None else override) - logger.info( - "CPU parallel kernels: %s (override=%s, platform=%s)", - "enabled" if _parallel_enabled else "disabled", - override, - sys.platform, - ) - - -def parallel_njit(**jit_kwargs: Any) -> Callable: - """@njit that compiles serial and parallel variants lazily from one source (`prange` runs as - `range` in the serial one) and picks one per call; parallel calls serialize behind the module - lock. cache=True gives each variant its own file, or a serial call could load the parallel binary.""" - jit_kwargs.pop("parallel", None) - - def wrap(py_func: Callable) -> "_DualDispatcher": - return _DualDispatcher(py_func, jit_kwargs) - - return wrap - - -class _DualDispatcher: - """Callable pairing the serial and parallel compilations of one kernel.""" - - def __init__(self, py_func: Callable, jit_kwargs: dict): - self.serial = njit(**jit_kwargs, parallel=False)(_serial_copy(py_func)) - self.parallel = njit(**jit_kwargs, parallel=True)(py_func) - self.__wrapped__ = py_func - self.__name__ = getattr(py_func, "__name__", "kernel") - - def __call__(self, *args: Any, **kwargs: Any) -> Any: - if _parallel_enabled and _first_array_size(args) >= SERIAL_MAX_ELEMENTS: - with _invocation_gate: - return self.parallel(*args, **kwargs) - return self.serial(*args, **kwargs) - - -def _serial_copy(py_func: Callable) -> Callable: - """py_func under its own qualname: Numba's disk cache is keyed by qualname and source line, - so the serial variant gets a cache file apart from the parallel one.""" - copy = types.FunctionType( - py_func.__code__, py_func.__globals__, py_func.__name__ + "__serial", py_func.__defaults__, py_func.__closure__ - ) - copy.__qualname__ = py_func.__qualname__ + "__serial" - copy.__module__ = py_func.__module__ - return copy - - -def _first_array_size(args: tuple) -> int: - for a in args: - size = getattr(a, "size", None) - if isinstance(size, int): - return size - return SERIAL_MAX_ELEMENTS diff --git a/negpy/services/assets/half_frame.py b/negpy/services/assets/half_frame.py index 28ac7b9aa..276b30106 100644 --- a/negpy/services/assets/half_frame.py +++ b/negpy/services/assets/half_frame.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence +import negpy_core import numpy as np from negpy.kernel.system.logging import get_logger @@ -363,6 +364,9 @@ def diptych_configs(repo: Any, file_hash: Optional[str]) -> Optional[tuple[Any, def _luma(buf: np.ndarray) -> np.ndarray: a = np.asarray(buf) + if a.ndim == 3 and a.shape[2] == 3 and a.dtype in (np.uint8, np.uint16, np.float32): + # Integer channel sums are exact in float32, so the float32 mean matches NumPy's. + return negpy_core.mean_channels(np.ascontiguousarray(a, dtype=np.float32)) if a.ndim == 3: a = a.mean(axis=2) return a.astype(np.float32, copy=False) @@ -403,8 +407,8 @@ def _gutter_scan(a: np.ndarray) -> tuple[float, float, float]: peak_val = float(a.max()) if peak_val <= 0: return 0.5, 0.0, 0.0 - sub = a[:: max(1, h // 512)] / peak_val - col = sub.mean(axis=0) + step = max(1, h // 512) + col = negpy_core.column_means(np.ascontiguousarray(a, dtype=np.float32), step, peak_val) k = max(3, w // 150) sm = np.convolve(col, np.ones(k, np.float32) / k, mode="same") win = max(9, (w // 8) | 1) @@ -435,7 +439,7 @@ def _gutter_scan(a: np.ndarray) -> tuple[float, float, float]: if min(abs(d1), abs(d2)) < 0.04 or d1 * d2 <= 0: return 0.5, 0.0, 0.0 # Unexposed film is uniform top to bottom; a bright/dark in-scene feature isn't. - if float(sub[:, center].std()) > 0.10: + if float((a[::step, center] / peak_val).std()) > 0.10: return 0.5, 0.0, 0.0 thickness = min(_MAX_GUTTER_THICKNESS, max(0.0, (right_edge - left_edge) / w)) return float(center_f / w), float(thickness), min(abs(d1), abs(d2)) diff --git a/negpy/services/assets/thumbnail_fingerprint.py b/negpy/services/assets/thumbnail_fingerprint.py index 31bb0dbad..e2566ab18 100644 --- a/negpy/services/assets/thumbnail_fingerprint.py +++ b/negpy/services/assets/thumbnail_fingerprint.py @@ -12,7 +12,7 @@ from negpy.domain.models import WorkspaceConfig # Bump in any change that alters rendered pixels for unchanged settings, a changed default included. -THUMBNAIL_RENDER_VERSION = 16 +THUMBNAIL_RENDER_VERSION = 17 # Marks a thumbnail made from the source preview, which runs none of the frame's settings. QUICK = "quick" diff --git a/negpy/services/assets/thumbnails.py b/negpy/services/assets/thumbnails.py index 6f4becd13..b0e8f05f0 100644 --- a/negpy/services/assets/thumbnails.py +++ b/negpy/services/assets/thumbnails.py @@ -3,8 +3,9 @@ from PIL import Image import rawpy from negpy.kernel.system.config import APP_CONFIG +import negpy_core import numpy as np -from negpy.kernel.image.logic import apply_exif_orientation, ensure_rgb, float_to_uint8, prepare_thumbnail, srgb_to_linear, uint8_to_float32 +from negpy.kernel.image.logic import apply_exif_orientation, ensure_rgb, prepare_thumbnail, srgb_to_linear, uint8_to_float32 from negpy.infrastructure.capture.raw_demosaic import _user_sat from negpy.infrastructure.loaders.factory import loader_factory from negpy.infrastructure.loaders.helpers import NonStandardFileWrapper, embedded_preview @@ -173,20 +174,9 @@ def preview_positive(img: Image.Image, process_mode: str = "") -> Image.Image: return img # A lone narrowband exposure carries its picture in one channel, and the other two hold - # only near-black noise that the log below would stretch into a solid color cast. - # Borrowing the channel that has the signal renders it as the monochrome it is. - peaks = np.percentile(linear, 99.0, axis=(0, 1)) - strongest = int(np.argmax(peaks)) - empty = peaks < peaks[strongest] * 0.05 - if empty.any(): - linear = linear.copy() - linear[:, :, empty] = linear[:, :, strongest : strongest + 1] - - density = -np.log10(np.clip(linear, 1e-4, None)) - lo = np.percentile(density, 1.0, axis=(0, 1)) - hi = np.percentile(density, 99.0, axis=(0, 1)) - positive = (density - lo) / np.maximum(hi - lo, 1e-6) - return Image.fromarray(float_to_uint8(np.clip(positive, 0.0, 1.0))) + # only near-black noise that the log would stretch into a solid color cast; negpy_core + # borrows the channel that has the signal, so it renders as the monochrome it is. + return Image.fromarray(negpy_core.log_stretch_u8(np.ascontiguousarray(linear, dtype=np.float32))) def get_thumbnail_worker( diff --git a/negpy/services/capture/calibration.py b/negpy/services/capture/calibration.py index 879adb83a..0c4031438 100644 --- a/negpy/services/capture/calibration.py +++ b/negpy/services/capture/calibration.py @@ -13,6 +13,7 @@ from functools import lru_cache from typing import Callable, Optional, Sequence +import negpy_core import numpy as np from negpy.features.process.sensor import build_sensor_matrix @@ -430,7 +431,7 @@ def meter_base(plane: np.ndarray, roi: Roi) -> float: patch = plane[y0:y1, x0:x1] if patch.size == 0: return 0.0 - return float(np.percentile(patch, 99.9)) + return negpy_core.patch_level_stats(np.ascontiguousarray(patch), 0.0, 99.9)[1] def clip_fraction(plane: np.ndarray, roi: Roi, saturation: float = SATURATION_VALUE) -> float: @@ -444,7 +445,7 @@ def clip_fraction(plane: np.ndarray, roi: Roi, saturation: float = SATURATION_VA patch = plane[y0:y1, x0:x1] if patch.size == 0: return 0.0 - return float(np.mean(patch >= saturation)) + return negpy_core.patch_level_stats(np.ascontiguousarray(patch), float(saturation), 50.0)[0] def nearest_shutter(label: str, candidates: tuple[str, ...]) -> str: diff --git a/negpy/services/capture/focus_meter.py b/negpy/services/capture/focus_meter.py index 49b8b1d37..9aaa5d2da 100644 --- a/negpy/services/capture/focus_meter.py +++ b/negpy/services/capture/focus_meter.py @@ -4,6 +4,7 @@ from typing import Optional +import negpy_core import numpy as np #: Mean level (0..255) below which a frame carries no usable contrast: the light is off. @@ -17,14 +18,10 @@ def sharpness(gray: np.ndarray) -> float: 0.0 for a frame too dark or too small. """ - img = np.asarray(gray, dtype=np.float32) + img = np.ascontiguousarray(gray, dtype=np.float32) if img.ndim != 2 or min(img.shape) < 3: return 0.0 - mean = float(img.mean()) - if mean < MIN_MEAN_LEVEL: - return 0.0 - lap = 4.0 * img[1:-1, 1:-1] - img[:-2, 1:-1] - img[2:, 1:-1] - img[1:-1, :-2] - img[1:-1, 2:] - return float(np.mean(lap * lap)) / (mean * mean) + return negpy_core.sharpness(img, MIN_MEAN_LEVEL) class FocusMeter: diff --git a/negpy/services/capture/meter.py b/negpy/services/capture/meter.py index 238cf4454..803cf40f6 100644 --- a/negpy/services/capture/meter.py +++ b/negpy/services/capture/meter.py @@ -8,7 +8,7 @@ import math from dataclasses import dataclass, field -import cv2 +import negpy_core import numpy as np from negpy.features.geometry.logic import ( @@ -65,10 +65,15 @@ class MeterReading: ring_saturated: bool = False +def gray_of(rgb: np.ndarray) -> np.ndarray: + """`rgb.mean(axis=2)` as float32 for an integer or float32 frame.""" + return negpy_core.mean_channels(np.ascontiguousarray(rgb[..., :3], dtype=np.float32)) + + def ranked_thumb(gray: np.ndarray) -> np.ndarray: """A picture at thumbnail size as zero-mean, unit-variance ranks: two thumbnails' mean product is their rank correlation, which no monotonic tone curve and no exposure change can move.""" - small = cv2.resize(np.ascontiguousarray(gray, dtype=np.float32), _THUMB, interpolation=cv2.INTER_AREA).ravel() + small = negpy_core.resize_area(np.ascontiguousarray(gray, dtype=np.float32), _THUMB).ravel() ranks = np.argsort(np.argsort(small)).astype(np.float32) ranks -= ranks.mean() return ranks / max(float(ranks.std()), 1e-6) @@ -84,19 +89,16 @@ def framing_mismatch(reading: MeterReading, preview: np.ndarray) -> str | None: vh, vw = preview.shape[:2] if abs((pw / ph) / (vw / vh) - 1.0) > ASPECT_TOLERANCE: return f"the preview is {vw}:{vh} and the RAW {pw}:{ph}" - if float(np.mean(ranked_thumb(preview.mean(axis=2)) * reading.thumb)) < SAME_FRAMING: + if float(np.mean(ranked_thumb(gray_of(preview)) * reading.thumb)) < SAME_FRAMING: return "the preview does not frame the RAW the same way" return None -def _peak_map(brightest: np.ndarray) -> np.ndarray: - """Max-pool to a long edge of PEAK_MAP_EDGE. The frame is zero-padded to whole blocks, so - every pixel lands in a block and the edge blocks keep their own clips.""" - h, w = brightest.shape - f = max(1, math.ceil(max(h, w) / PEAK_MAP_EDGE)) - padded = np.pad(brightest, ((0, -h % f), (0, -w % f))) - hh, ww = padded.shape - return np.ascontiguousarray(padded.reshape(hh // f, f, ww // f, f).max(axis=(1, 3))) +def _peak_map(linear: np.ndarray) -> np.ndarray: + """The brightest channel max-pooled to a long edge of PEAK_MAP_EDGE. The frame is zero-padded + to whole blocks, so every pixel lands in a block and the edge blocks keep their own clips.""" + h, w = linear.shape[:2] + return negpy_core.peak_map(np.ascontiguousarray(linear, dtype=np.float32), max(1, math.ceil(max(h, w) / PEAK_MAP_EDGE))) def raw_clip_mask(reading: MeterReading) -> np.ndarray | None: @@ -126,7 +128,7 @@ def meter_frame( holds after its gain, while the clip test reads the sensor itself.""" candidates = tuple(candidates) or SHUTTER_CANDIDATES gain = np.asarray(wb, np.float32) / min(wb) if wb else np.ones(3, np.float32) - linear = np.clip(img.astype(np.float32) / CLIP_CEILING, 0.0, 1.0) + linear = negpy_core.normalized(np.ascontiguousarray(img), float(CLIP_CEILING)) if medium is None: medium = "positive" if detect_process_mode(linear) == ProcessMode.E6 else "negative" det, scale = _normalize_detection_input(linear, AUTOCROP_DETECT_RES) @@ -137,17 +139,10 @@ def meter_frame( # The picture at the probe's own resolution: a highlight narrower than a detection pixel # is averaged away at detection size, and it still clips. ly1, ly2, lx1, lx2 = _scale_roi(gate, scale, linear.shape[0], linear.shape[1]) - picture = linear[ly1:ly2, lx1:lx2].reshape(-1, 3) - frame_brightest = np.max(linear, axis=2) - brightest = frame_brightest[ly1:ly2, lx1:lx2] - clipped_fraction = float(np.mean(brightest >= _SATURATION)) + clipped_fraction, blown, thin = negpy_core.meter_picture(linear, (ly1, ly2, lx1, lx2), _SATURATION, _BLOWN_SHARE) + thinnest = np.array(thin, dtype=np.float32) # the picture's thin end, per channel ring = det[rebate_mask(det.shape[:2], film_roi, gate)] - # Saturated pixels are light unless they reach _BLOWN_SHARE of the picture; then the picture is blown. - saturated = picture.max(axis=1) >= _SATURATION - blown = bool(saturated.mean() >= _BLOWN_SHARE) - film_pixels = picture if blown or saturated.all() else picture[~saturated] - thinnest = np.percentile(film_pixels, 99.9, axis=0) # the picture's thin end, per channel # Only unclipped ring pixels are film: a gap, a sprocket hole and a clipped base all saturate. film_ring = ring[ring.max(axis=1) < _SATURATION] if ring.size else ring ring_saturated = medium == "negative" and bool(ring.size) and film_ring.shape[0] < 0.5 * ring.shape[0] @@ -175,8 +170,8 @@ def meter_frame( # The step down passes the fastest rung, which is still a step down: take it and meter again. recommended = candidates[0] stops = float(np.log2(true_seconds(recommended, candidates) / probe_seconds)) if recommended else needed - peaks = _peak_map(frame_brightest) - thumb = ranked_thumb(np.mean(linear, axis=2)) + peaks = _peak_map(linear) + thumb = ranked_thumb(gray_of(linear)) shape = (int(linear.shape[0]), int(linear.shape[1])) return MeterReading( medium, region, measured, clipped, clipped_fraction, shutter, recommended, stops, peaks, thumb, shape, ring_saturated=ring_saturated diff --git a/negpy/services/export/contact_sheet.py b/negpy/services/export/contact_sheet.py index d30b27217..a1da53f09 100644 --- a/negpy/services/export/contact_sheet.py +++ b/negpy/services/export/contact_sheet.py @@ -4,7 +4,7 @@ from dataclasses import replace from typing import Optional, Sequence -import cv2 +import negpy_core import numpy as np from PIL import Image, ImageDraw, ImageFont @@ -54,8 +54,7 @@ def _place_tile(canvas: np.ndarray, tile: np.ndarray, box: tuple[int, int, int, cover = abs(math.log(tile_aspect / window_aspect)) <= math.log(1 + COVER_TOLERANCE) scale = max(ww / tw, wh / th) if cover else min(ww / tw, wh / th) rw, rh = max(1, int(round(tw * scale))), max(1, int(round(th * scale))) - interp = cv2.INTER_AREA if scale < 1 else cv2.INTER_CUBIC - resized = cv2.resize(np.ascontiguousarray(tile[..., :3]), (rw, rh), interpolation=interp) + resized = negpy_core.resize_u8(np.ascontiguousarray(tile[..., :3], dtype=np.uint8), (rw, rh), cubic=scale >= 1) if cover: cx, cy = (rw - ww) // 2, (rh - wh) // 2 resized = resized[max(0, cy) : max(0, cy) + wh, max(0, cx) : max(0, cx) + ww] diff --git a/negpy/services/export/encoders.py b/negpy/services/export/encoders.py index ab88dfbd2..73a28197e 100644 --- a/negpy/services/export/encoders.py +++ b/negpy/services/export/encoders.py @@ -12,6 +12,7 @@ from typing import Any, Dict, Optional import imagecodecs +import negpy_core import numpy as np import tifffile from PIL import Image, ImageFile, PngImagePlugin @@ -22,7 +23,7 @@ _PNG_METRE = 1 # pHYs unit specifier _INCH_PER_METRE = 39.3701 -# optimize and progressive make PIL encode the whole frame into one buffer sized +# PIL's progressive encode holds the whole frame in one buffer sized # `w*h + ImageFile.MAXBLOCK`, which a large noisy scan overflows: the save then fails # with "broken data stream when writing image file". MAXBLOCK is module-global, so the # raise is serialised and restored around the encode. @@ -98,7 +99,13 @@ def encode_jpeg( quality: int = 90, progressive: bool = False, ) -> bytes: - """Encode uint8 pixels as JPEG. Chroma subsampling stays 4:4:4 (#224).""" + """Encode uint8 pixels as JPEG. Chroma subsampling stays 4:4:4 (#224). A baseline file comes + from negpy_core, which encodes strips in parallel: its pixels are PIL's and only the restart + markers differ.""" + if not progressive and arr.dtype == np.uint8 and (arr.ndim == 2 or (arr.ndim == 3 and arr.shape[2] == 3)): + # PIL writes a density only when both rounded values are positive. + dpi = None if resolution is None else (round(resolution.x_dpi), round(resolution.y_dpi)) + return negpy_core.encode_jpeg(np.ascontiguousarray(arr), quality, icc or None, dpi if dpi and min(dpi) > 0 else None) buf = io.BytesIO() kwargs: Dict[str, Any] = { "format": "JPEG", diff --git a/negpy/services/export/linear_output.py b/negpy/services/export/linear_output.py index 37bab5724..c073a9f9e 100644 --- a/negpy/services/export/linear_output.py +++ b/negpy/services/export/linear_output.py @@ -9,6 +9,7 @@ from typing import Any, Optional import imagecodecs +import negpy_core import numpy as np import rawpy import tifffile as _tifffile @@ -307,10 +308,8 @@ def _apply_ice(rgb: np.ndarray, ir: np.ndarray, retouch: RetouchConfig) -> np.nd ir_det = downsample_ir(np.ascontiguousarray(ir, dtype=np.float32), target) h, w = rgb.shape[:2] if max(h, w) > target: - import cv2 - s = target / max(h, w) - rgb_det = cv2.resize(rgb, (max(1, round(w * s)), max(1, round(h * s))), interpolation=cv2.INTER_AREA) + rgb_det = negpy_core.resize_area(np.ascontiguousarray(rgb, dtype=np.float32), (max(1, round(w * s)), max(1, round(h * s)))) else: rgb_det = rgb ratio_det, gain_det, degenerate, _ = ir_ratio_and_gain(ir_det, rgb_det) diff --git a/negpy/services/export/print.py b/negpy/services/export/print.py index e51f3bc8b..106b88103 100644 --- a/negpy/services/export/print.py +++ b/negpy/services/export/print.py @@ -1,5 +1,5 @@ from PIL import Image -import cv2 +import negpy_core import numpy as np from typing import Optional, Tuple from negpy.domain.models import ExportConfig, AspectRatio, ExportResolutionMode @@ -253,11 +253,9 @@ def apply_layout( # OpenCV gives INTER_LANCZOS4 no prefilter on a shrink, so it degrades to bilinear and # aliases. INTER_AREA is the only area-correct downscale here. shrinking = target_w < img_w or target_h < img_h - img_scaled = cv2.resize( - img, - (target_w, target_h), - interpolation=cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4, - ) + src = np.ascontiguousarray(img, dtype=np.float32) + size = (target_w, target_h) + img_scaled = negpy_core.resize_area(src, size) if shrinking else negpy_core.resize_kernel(src, size, lanczos=True) offset_x = (paper_w - target_w) // 2 offset_y = PrintService.weighted_offset_y(paper_h, target_h, border_px, border_bottom_px) diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index a515ceb4a..4c18b4fb0 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -5,7 +5,7 @@ import time from typing import Any, Dict, Optional, Tuple -import cv2 +import negpy_core import numpy as np import wgpu # type: ignore @@ -153,7 +153,7 @@ def _downsample_for_analysis(img: np.ndarray, max_size: int) -> np.ndarray: scale = min(1.0, max_size / max(h, w)) if scale >= 1.0: return img - return cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) + return negpy_core.resize_area(np.ascontiguousarray(img, dtype=np.float32), (int(w * scale), int(h * scale))) def _build_analysis_source( @@ -2169,7 +2169,7 @@ def _upload_unified_uniforms( exp.highlight_yellow * cmy_m, _sh3[2], ) - # Asymmetric H&D print-curve scalars; mirrors _apply_print_curve_kernel. + # Asymmetric H&D print-curve scalars; mirrors negpy_core print_curve.rs. # Per-channel knee widths occupy the dead scalar toe/shoulder/ # midtone_gamma slots and the former flare pad (see exposure.wgsl). + struct.pack( @@ -2825,7 +2825,7 @@ def _process_tiled( ) preview_scale = APP_CONFIG.preview_render_size / max(h, w) - img_small = cv2.resize(img, (int(w * preview_scale), int(h * preview_scale))) + img_small = negpy_core.resize_linear(np.ascontiguousarray(img, dtype=np.float32), (int(w * preview_scale), int(h * preview_scale))) # This render meters the whole frame for the tiles; the CLAHE CDF it leaves behind # is the one they share. Taken from here rather than from the last preview, which @@ -3104,15 +3104,11 @@ def _meter_grid() -> np.ndarray: # Mirrors PrintService.apply_layout: only INTER_AREA is area-correct on a shrink. shrinking = content_w < crop_w or content_h < crop_h - scaled_content = ( - cv2.resize( - full_source_res, - (content_w, content_h), - interpolation=cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4, - ) - if (content_w != crop_w or content_h != crop_h) - else full_source_res - ) + scaled_content = full_source_res + if content_w != crop_w or content_h != crop_h: + src = np.ascontiguousarray(full_source_res, dtype=np.float32) + size = (content_w, content_h) + scaled_content = negpy_core.resize_area(src, size) if shrinking else negpy_core.resize_kernel(src, size, lanczos=True) # No border means the paper buffer is a full-res allocation, fill and copy that # reproduces the content exactly. if (paper_w, paper_h, off_x, off_y) == (content_w, content_h, 0, 0): diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index 82b7e9acf..10de509a1 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -2,9 +2,9 @@ import os import ctypes import threading -import cv2 import rawpy import imagecodecs +import negpy_core import numpy as np from concurrent.futures import ThreadPoolExecutor from dataclasses import replace as dc_replace @@ -288,7 +288,7 @@ def _downsample_to_long_edge(buf: np.ndarray, long_px: int) -> np.ndarray: if long_px <= 0 or long_edge <= long_px: return buf s = long_px / long_edge - return cv2.resize(buf, (max(1, int(round(w * s))), max(1, int(round(h * s)))), interpolation=cv2.INTER_AREA) + return negpy_core.resize_area(np.ascontiguousarray(buf, dtype=np.float32), (max(1, int(round(w * s))), max(1, int(round(h * s))))) def _detection_downsample(buf: np.ndarray) -> np.ndarray: @@ -317,7 +317,7 @@ def _without_ir(score: Optional[np.ndarray], hairs: List[np.ndarray], ir_mask: n def _resize_mask(mask: np.ndarray, shape: Tuple[int, int]) -> np.ndarray: if mask.shape[:2] == tuple(shape): return mask > 0 - return cv2.resize(mask.astype(np.uint8), (shape[1], shape[0]), interpolation=cv2.INTER_NEAREST) > 0 + return negpy_core.resize_nearest(mask.astype(np.uint8), (shape[1], shape[0])) > 0 def preview_takes_unmix(params: WorkspaceConfig) -> bool: @@ -1900,7 +1900,7 @@ def render_display_array( f32_buffer = _downsample_to_long_edge(f32_buffer, target_long_px) if ir_full is not None and ir_full.shape[:2] != f32_buffer.shape[:2]: th, tw = f32_buffer.shape[:2] - ir_full = cv2.resize(ir_full, (tw, th), interpolation=cv2.INTER_AREA) + ir_full = negpy_core.resize_area(np.ascontiguousarray(ir_full, dtype=np.float32), (tw, th)) # Keep the decode only for the other half of the same scan, rendered next; else it pins memory across the next decode. if not keep_source: self._source_cache_key = None diff --git a/negpy/services/rendering/preview_manager.py b/negpy/services/rendering/preview_manager.py index d714face8..e0c245664 100644 --- a/negpy/services/rendering/preview_manager.py +++ b/negpy/services/rendering/preview_manager.py @@ -3,7 +3,7 @@ from collections.abc import Callable from typing import Any, Optional, Sequence, Tuple -import cv2 +import negpy_core import numpy as np import rawpy @@ -21,7 +21,7 @@ is_xtrans, ) from negpy.infrastructure.gpu.device import GPUDevice -from negpy.kernel.image.logic import apply_exif_orientation, ensure_rgb, uint16_to_float32, working_oetf_decode, working_oetf_encode +from negpy.kernel.image.logic import apply_exif_orientation, ensure_rgb, uint16_to_float32 from negpy.kernel.image.validation import ensure_image from negpy.kernel.system.config import APP_CONFIG from negpy.kernel.system.override import effective_max_texture_size @@ -73,15 +73,6 @@ def _output_dimensions_from_raw(raw: Any, postprocessed_h: int, postprocessed_w: return (postprocessed_h, postprocessed_w) -# Pre-warm the Numba JIT so the first actual preview load doesn't pay the compile cost. The -# small-array OETF calls take the serial variant, which has no disk cache, so the Analysis -# chart's first paint would otherwise compile it on the GUI thread. -_warmup = np.zeros((2, 2, 3), dtype=np.uint16) -uint16_to_float32(_warmup) -working_oetf_decode(working_oetf_encode(np.zeros(4, dtype=np.float32))) -del _warmup - - def _linear_preview_key( file_hash: str, *, @@ -279,7 +270,7 @@ def _try_splash_from_open_raw( if max(h, w) > APP_CONFIG.preview_render_size: scale = APP_CONFIG.preview_render_size / max(h, w) tw, th = int(w * scale), int(h * scale) - arr = ensure_image(cv2.resize(arr, (tw, th), interpolation=cv2.INTER_AREA).astype(np.float32)) + arr = ensure_image(negpy_core.resize_area(np.ascontiguousarray(arr, dtype=np.float32), (tw, th))) logger.debug("preview _try_splash_from_open_raw ok %.3fs for %s", time.perf_counter() - t0, file_path) return ensure_image(arr), full_dims @@ -467,26 +458,14 @@ def _load_from_open_raw( scale = vram_cap / max(h_p, w_p) target_w = int(w_p * scale) target_h = int(h_p * scale) - preview_raw = ensure_image( - cv2.resize( - full_linear, - (target_w, target_h), - interpolation=cv2.INTER_AREA, - ) - ) + preview_raw = ensure_image(negpy_core.resize_area(np.ascontiguousarray(full_linear, dtype=np.float32), (target_w, target_h))) metadata["vram_capped_long_edge"] = vram_cap log("preview vram_cap applied: %dx%d -> %dx%d", w_p, h_p, target_w, target_h) elif max(h_p, w_p) > max_res and not full_resolution: scale = max_res / max(h_p, w_p) target_w = int(w_p * scale) target_h = int(h_p * scale) - preview_raw = ensure_image( - cv2.resize( - full_linear, - (target_w, target_h), - interpolation=cv2.INTER_AREA, - ) - ) + preview_raw = ensure_image(negpy_core.resize_area(np.ascontiguousarray(full_linear, dtype=np.float32), (target_w, target_h))) else: # Full-res, or already preview-sized: hand the decoded buffer through as it is. A # defensive copy doubles peak RSS on HQ loads of large scans for no benefit, since @@ -769,7 +748,7 @@ def load_linear_preview_rgb( def _match(buf: ImageBuffer) -> np.ndarray: arr = np.asarray(buf, dtype=np.float32) if arr.shape[:2] != red.shape[:2]: - arr = cv2.resize(arr, (red.shape[1], red.shape[0]), interpolation=cv2.INTER_AREA) + arr = negpy_core.resize_area(np.ascontiguousarray(arr, dtype=np.float32), (red.shape[1], red.shape[0])) return arr merged = assemble_rgb(red, _match(green_out), _match(blue_out), align=align) @@ -877,7 +856,7 @@ def _load(path: str) -> np.ndarray: # at stake. if abs(arr.shape[1] * ref.shape[0] - arr.shape[0] * ref.shape[1]) > 0.02 * ref.shape[1] * arr.shape[0]: raise ValueError(f"bracket frames differ in shape: {arr.shape}, {ref.shape}") - arr = cv2.resize(arr, (ref.shape[1], ref.shape[0]), interpolation=cv2.INTER_AREA) + arr = negpy_core.resize_area(np.ascontiguousarray(arr, dtype=np.float32), (ref.shape[1], ref.shape[0])) return arr providers = [lambda: ref, *[lambda p=p: _load(p) for p in other_paths]] diff --git a/negpy/services/scanning/writer.py b/negpy/services/scanning/writer.py index 6703fd081..23a24fb66 100644 --- a/negpy/services/scanning/writer.py +++ b/negpy/services/scanning/writer.py @@ -1,6 +1,7 @@ import os import tempfile +import negpy_core import numpy as np import tifffile @@ -36,7 +37,7 @@ def _to_grey(rgb: np.ndarray) -> np.ndarray: """ if rgb.ndim == 2: return rgb - return ((np.sum(rgb, axis=-1, dtype=np.uint32) + 1) // 3).astype(rgb.dtype) + return negpy_core.to_grey(np.ascontiguousarray(rgb)) def write_tiff_16bit(result: ScanResult, path: str, *, mono: bool = False) -> str: diff --git a/negpy/services/view/coordinate_mapping.py b/negpy/services/view/coordinate_mapping.py index c7504f60d..82be83856 100644 --- a/negpy/services/view/coordinate_mapping.py +++ b/negpy/services/view/coordinate_mapping.py @@ -1,8 +1,16 @@ +import negpy_core import numpy as np -import cv2 from typing import Tuple, Optional +def _invert_affine(m: np.ndarray) -> np.ndarray: + """cv2.invertAffineTransform in its operation order.""" + d = m[0, 0] * m[1, 1] - m[0, 1] * m[1, 0] + d = 1.0 / d if d != 0 else 0.0 + a11, a22, a12, a21 = m[1, 1] * d, m[0, 0] * d, -m[0, 1] * d, -m[1, 0] * d + return np.array([[a11, a12, -a11 * m[0, 2] - a12 * m[1, 2]], [a21, a22, -a21 * m[0, 2] - a22 * m[1, 2]]]) + + class CoordinateMapping: """ Raw <-> Viewport coordinate transforms. @@ -49,7 +57,7 @@ def create_uv_grid( # The grid is affine in pixel position, so the rotated grid is solved exactly. A # warpAffine fills the corner wedges with 0, which sends a click there to (0, 0). h_r, w_r = uv_grid.shape[:2] - m_inv = cv2.invertAffineTransform(cv2.getRotationMatrix2D((w_r / 2.0, h_r / 2.0), fine_rot, 1.0)) + m_inv = _invert_affine(negpy_core.rotation_matrix((w_r / 2.0, h_r / 2.0), fine_rot, 1.0)) o = uv_grid[0, 0].astype(np.float64) du = (uv_grid[0, -1] - o) / max(w_r - 1, 1) dv = (uv_grid[-1, 0] - o) / max(h_r - 1, 1) @@ -96,7 +104,7 @@ def _grid_homography(uv_grid: np.ndarray) -> np.ndarray: ] ) dst = np.float32([uv_grid[y0, x0], uv_grid[y0, x1], uv_grid[y1, x1], uv_grid[y1, x0]]) - return cv2.getPerspectiveTransform(src, dst).astype(np.float64) + return negpy_core.perspective_transform(src, dst).astype(np.float64) @staticmethod def _apply_homography(m: np.ndarray, nx: float, ny: float) -> Tuple[float, float]: diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index a6ed2ac79..6ff2923be 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -838,7 +838,6 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra ### Performance * **GPU Acceleration**: renders on the graphics card. Off uses the slower CPU, with the same result. If the GPU fails at launch, an amber line here says so. -* **Multi-core CPU Rendering**: uses all CPU cores. It is off at first on macOS, where it can crash. * **Search by Meaning**: find frames by description (§2). * **Update Thumbnails Automatically**: renders out-of-date thumbnails again in the background, when a roll opens and after a change to many frames. Off (the default), the frames get an amber dot until you use **Update Thumbnails**. * **Preview size**: the canvas resolution. Higher is sharper and uses more memory. @@ -861,7 +860,6 @@ If NegPy crashes at launch or draws wrongly, edit `Documents/NegPy/override.toml | `display.qt_rhi_backend` | `"auto"`, `"vulkan"`, `"d3d12"`, `"metal"`, `"opengl"`, `"software"` | How the window is drawn. | | `display.qt_platform` | `"auto"`, `"xcb"`, `"wayland"` | Window system (Linux only). | | `performance.force_hq_preview` | `true` / `false` | Turns HQ Preview on or off. | -| `performance.cpu_parallel` | `true` / `false` | Multi-core CPU Rendering. | | `logging.level` | `"debug"`, `"info"`, `"warning"`, `"error"` | How much NegPy logs. Use `"debug"` for a bug report. | The memory rows of Preferences can be set here too (`max_texture_size`, `preview_render_size`, `preview_cache_max_bytes`, `preview_cache_max_entries`, `preview_cache_max_full_res_entries`, `render_memo_max_entries`). diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index 413ff2f97..9c3f8281e 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -627,5 +627,5 @@ Scanlight の接続中に表示されます。プリセットは、一種類の - 言語の初期値は「システム設定」です。英語、簡体字中国語、日本語、韓国語を選択でき、再起動後に有効になります。未翻訳のパネルガイドは英語で表示します。 - インターフェースは 80% ~ 120% ズームし、再起動後に有効になります。キャンバスの背景は黒、ダークグレー、ミディアムグレー、または白を選択します。キャンバスはツールバーが画像を覆うように埋め込まれます。ズームおよび逆スクロール ホイール コントロールの切り替えとズーム動作は保持されます。 - ショートカット キーのカスタマイズ、ツールバーの編集、パネル レイアウト管理操作の入り口のリセット、および色覚選択はさまざまなマーカーの色を区別するために使用されます。 -- GPU アクセラレーションが失敗した場合は CPU にフォールバックします。マルチコアCPUは実験的な設定です。セマンティック検索の最初のダウンロード モデル。プレビュー サイズ、キャッシュ、高品質バッファリング、テクスチャ キャップによってメモリが制御され、起動設定を再起動する必要があることを示す行が表示されます。 +- GPU アクセラレーションが失敗した場合は CPU にフォールバックします。セマンティック検索の最初のダウンロード モデル。プレビュー サイズ、キャッシュ、高品質バッファリング、テクスチャ キャップによってメモリが制御され、起動設定を再起動する必要があることを示す行が表示されます。 - 継承と永続化の設定は、新しいファイルの継承を制御します。データベースを管理して、使用状況を表示したり、編集、ギャラリー、キャッシュをクリアしたりできます。 override.toml の仕様は設定よりも優先されます。 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index 39cc79a99..c5db136f1 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -627,5 +627,5 @@ Scanlight가 연결되어 있을 때 보입니다. 사전 설정은 한 종류 - 언어 기본값은 시스템 기본값입니다. 영어, 중국어 간체, 일본어와 한국어를 선택할 수 있으며 다시 시작하면 적용됩니다. 번역이 없는 패널 도움말은 영어로 표시합니다. - 인터페이스는 80%~120% 확대되고 다시 시작한 후에 적용됩니다. 캔버스 배경은 검정색, 어두운 회색, 중간 회색 또는 흰색을 선택합니다. 도구 모음이 이미지를 덮도록 캔버스가 잠겨 있습니다. 확대/축소 및 역방향 스크롤 휠 제어 전환 및 확대/축소 동작은 유지됩니다. - 단축키 사용자 정의, 도구 모음 편집 및 패널 레이아웃 관리 작업 입구 재설정 및 컬러 비전 선택을 사용하여 다양한 마커의 색상을 구분합니다. -- GPU 가속이 실패하면 CPU로 대체됩니다. 멀티코어 CPU는 실험적인 설정입니다. 의미 검색 첫 번째 다운로드 모델. 미리보기 크기, 캐시, 고품질 버퍼링 및 텍스처 캡이 메모리를 제어하며 시작 설정을 나타내는 줄을 다시 시작해야 합니다. +- GPU 가속이 실패하면 CPU로 대체됩니다. 의미 검색 첫 번째 다운로드 모델. 미리보기 크기, 캐시, 고품질 버퍼링 및 텍스처 캡이 메모리를 제어하며 시작 설정을 나타내는 줄을 다시 시작해야 합니다. - 상속 및 지속성 설정은 새 파일의 상속을 제어합니다. 데이터베이스를 관리하여 사용량을 보거나 편집 내용, 갤러리 및 캐시를 지울 수 있습니다. override.toml의 사양은 기본 설정보다 우선합니다. diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index 033425611..0fd57f56d 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -627,5 +627,5 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - 语言默认跟随系统,可选英文、简体中文、日文或韩文,重启后生效;缺失的面板帮助回退英文。 - 界面缩放 80%–120%,重启生效;画布背景选择黑、深灰、中灰或白;沉浸画布使工具栏覆盖图像;保留缩放与反向滚轮控制切换和缩放行为。 - 自定义快捷键、编辑工具栏与重置面板布局管理操作入口,色觉选择用于区分各类标记的配色。 -- GPU 加速失败时回退 CPU;多核 CPU 为实验设置。语义搜索首次下载模型。预览尺寸、缓存、高质量缓冲与纹理上限控制内存,标明启动设置的行需重启。 +- GPU 加速失败时回退 CPU;语义搜索首次下载模型。预览尺寸、缓存、高质量缓冲与纹理上限控制内存,标明启动设置的行需重启。 - 沿用设置和持久设置控制新文件继承;管理数据库可查看占用或清除编辑、图库与缓存。override.toml 的指定项优先于偏好设置。 diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index 5336fb76e..86350a86c 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -1524,9 +1524,6 @@ "Moves the white point: positive brightens the print, and negative brings back highlights.": "白点を動かします: 正の値でプリントが明るくなり、負の値でハイライトが戻ります。", "Multi-Exposure and Multi-Pass are not available at {dpi} dpi.": "{dpi} dpi では多重露出とマルチパスを使用できません。", "Multi-Pass": "マルチパス", - "Multi-core CPU Rendering": "マルチコアCPUレンダリング", - "Multi-core CPU rendering is on. Turn it off if the app closes unexpectedly.": "マルチコア CPU レンダリングがオンになっています - アプリが予期せず終了した場合はオフにします", - "Multi-core CPU rendering off": "マルチコアCPUレンダリングオフ", "Multiply by the as-shot WB gains before writing": "書き込み前にショット時の WB ゲインを乗算します", "Multiply the scan values so that they fill more of the file's 16-bit range.": "スキャンの値を乗算し、ファイルの 16 ビットの範囲をより広く使います。", "My Gear": "マイ機材", @@ -1540,7 +1537,6 @@ "Navigation": "ナビゲーション", "Navigator: click or drag to move the view": "ナビゲータ: クリックまたはドラッグしてビューを移動します", "NegPy": "NegPy", - "NegPy Closed Unexpectedly": "NegPy が予期せず終了しました", "NegPy Hit an Error": "NegPy でエラーが発生しました", "NegPy Sidecars (*.negpy)": "NegPy サイドカー (*.negpy)", "NegPy data folder": "NegPyデータフォルダー", @@ -1983,7 +1979,6 @@ "Render Exposure ↑/↓": "レンダリング露出 ↑/↓", "Render exposure down": "レンダリング露出ダウン", "Render exposure up": "レンダリング露出アップ", - "Render on all CPU cores, an experimental mode that can close the app without warning.": "すべての CPU コアでレンダリングします。実験的なモードで、警告なしにアプリが終了することがあります。", "Render out-of-date thumbnails again in the background": "古いサムネイルをバックグラウンドで再レンダリングします", "Render the full-resolution scan instead of a downscaled preview": "縮小プレビューの代わりにフル解像度のスキャンをレンダリングします。", "Render the pipeline on the GPU": "GPU でパイプラインをレンダリングする", @@ -2517,7 +2512,6 @@ "Test Strip: show a grid of versions at different brightness and contrast, then click the one you like.": "テストストリップ: 明るさとコントラストを変えた版のグリッドを表示し、気に入ったものをクリックします。", "Test strip": "テストストリップ", "That roll no longer exists": "そのロールはもう存在しません", - "That setting is the most likely cause. Turning it off costs some speed on merges and exports, and is the safe setting.\n\nTurn multi-core CPU rendering off?": "その設定が最も可能性の高い原因です。これをオフにすると、マージとエクスポートの速度が若干低下しますが、これが安全な設定です。\n\nマルチコア CPU レンダリングをオフにしますか?", "The 120 camera's frame size": "120カメラのフレームサイズ", "The Analysis Readout": "分析結果", "The Library: Rolls": "ライブラリ:ロール", @@ -2567,7 +2561,6 @@ "The language of the interface, applied when NegPy starts again.": "インターフェースの言語。NegPy の次回起動時に適用されます。", "The largest brightness change Auto Density may make, so a lower value keeps a roll more even.": "自動濃度が変えてよい明るさの最大量で、下げるとロールがよりそろいます。", "The largest image size the GPU loads at once.": "GPU が一度に読み込む最大の画像サイズ。", - "The last session ended unexpectedly with multi-core CPU rendering turned on.": "前回のセッションは、マルチコア CPU レンダリングがオンの状態で予期せず終了しました。", "The lens model, written to the photo's metadata": "写真のメタデータに書き込むレンズのモデル", "The lens this preset writes onto a frame": "このプリセットがフレームに書き込むレンズ", "The light behind the negative": "ネガの背後にある光源", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index e81c8b5d3..1d51fb30e 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -1524,9 +1524,6 @@ "Moves the white point: positive brightens the print, and negative brings back highlights.": "흰점을 옮깁니다: 양수는 인화를 밝게 하고, 음수는 밝은 영역을 되살립니다.", "Multi-Exposure and Multi-Pass are not available at {dpi} dpi.": "{dpi} dpi에서는 다중 노출과 멀티패스를 사용할 수 없습니다.", "Multi-Pass": "멀티패스", - "Multi-core CPU Rendering": "멀티코어 CPU 렌더링", - "Multi-core CPU rendering is on. Turn it off if the app closes unexpectedly.": "멀티 코어 CPU 렌더링 켜기 - 앱이 예기치 않게 닫히면 끄세요.", - "Multi-core CPU rendering off": "멀티코어 CPU 렌더링 꺼짐", "Multiply by the as-shot WB gains before writing": "쓰기 전에 실제 WB 게인을 곱하세요.", "Multiply the scan values so that they fill more of the file's 16-bit range.": "스캔 값을 곱해 파일의 16비트 범위를 더 많이 채웁니다.", "My Gear": "내 장비", @@ -1540,7 +1537,6 @@ "Navigation": "네비게이션", "Navigator: click or drag to move the view": "네비게이터: 클릭하거나 드래그하여 보기를 이동합니다.", "NegPy": "NegPy", - "NegPy Closed Unexpectedly": "NegPy가 예기치 않게 종료되었습니다.", "NegPy Hit an Error": "NegPy에 오류가 발생했습니다", "NegPy Sidecars (*.negpy)": "NegPy 사이드카(*.negpy)", "NegPy data folder": "NegPy 데이터 폴더", @@ -1983,7 +1979,6 @@ "Render Exposure ↑/↓": "렌더 노출 ↑/↓", "Render exposure down": "렌더 노출 감소", "Render exposure up": "렌더 노출 증가", - "Render on all CPU cores, an experimental mode that can close the app without warning.": "모든 CPU 코어로 렌더링합니다. 실험적 모드로, 앱이 경고 없이 닫힐 수 있습니다.", "Render out-of-date thumbnails again in the background": "오래된 썸네일을 백그라운드에서 다시 렌더링합니다", "Render the full-resolution scan instead of a downscaled preview": "축소된 미리보기 대신 전체 해상도 스캔을 렌더링합니다.", "Render the pipeline on the GPU": "GPU에서 파이프라인 렌더링", @@ -2517,7 +2512,6 @@ "Test Strip: show a grid of versions at different brightness and contrast, then click the one you like.": "테스트 스트립: 밝기와 대비를 달리한 버전의 격자를 보여준 뒤, 마음에 드는 것을 클릭합니다.", "Test strip": "테스트 스트립", "That roll no longer exists": "해당 롤은 더 이상 존재하지 않습니다.", - "That setting is the most likely cause. Turning it off costs some speed on merges and exports, and is the safe setting.\n\nTurn multi-core CPU rendering off?": "이 설정이 가장 유력한 원인입니다. 끄면 병합과 내보내기가 조금 느려지지만 안전한 설정입니다.\n\n멀티코어 CPU 렌더링을 끄시겠습니까?", "The 120 camera's frame size": "120 카메라의 프레임 크기", "The Analysis Readout": "분석 판독", "The Library: Rolls": "라이브러리: 롤", @@ -2567,7 +2561,6 @@ "The language of the interface, applied when NegPy starts again.": "인터페이스 언어로, NegPy를 다시 시작하면 적용됩니다.", "The largest brightness change Auto Density may make, so a lower value keeps a roll more even.": "자동 농도가 바꿀 수 있는 최대 밝기 변화로, 낮을수록 롤이 더 고르게 됩니다.", "The largest image size the GPU loads at once.": "GPU가 한 번에 로드하는 최대 이미지 크기입니다.", - "The last session ended unexpectedly with multi-core CPU rendering turned on.": "멀티 코어 CPU 렌더링이 켜진 상태에서 마지막 세션이 예기치 않게 종료되었습니다.", "The lens model, written to the photo's metadata": "사진의 메타데이터에 기록되는 렌즈 모델", "The lens this preset writes onto a frame": "이 사전 설정이 프레임에 기록하는 렌즈", "The light behind the negative": "네거티브 뒤의 광원", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index b5a86d6ec..39cbfb511 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -1524,9 +1524,6 @@ "Moves the white point: positive brightens the print, and negative brings back highlights.": "移动白点:正值使照片变亮,负值找回高光。", "Multi-Exposure and Multi-Pass are not available at {dpi} dpi.": "{dpi} dpi 下无法使用多重曝光和多次扫描。", "Multi-Pass": "多次扫描", - "Multi-core CPU Rendering": "多核 CPU 渲染", - "Multi-core CPU rendering is on. Turn it off if the app closes unexpectedly.": "多核 CPU 渲染已开启,若应用意外关闭请禁用", - "Multi-core CPU rendering off": "多核 CPU 渲染已关闭", "Multiply by the as-shot WB gains before writing": "写入前应用拍摄时的白平衡增益", "Multiply the scan values so that they fill more of the file's 16-bit range.": "放大扫描数值,使其占用文件 16 位范围中的更多部分。", "My Gear": "个人器材", @@ -1540,7 +1537,6 @@ "Navigation": "导航", "Navigator: click or drag to move the view": "导航器:点击或拖动以移动视图", "NegPy": "NegPy", - "NegPy Closed Unexpectedly": "NegPy 意外关闭", "NegPy Hit an Error": "NegPy 发生错误", "NegPy Sidecars (*.negpy)": "NegPy 附属文件 (*.negpy)", "NegPy data folder": "NegPy 数据文件夹", @@ -1983,7 +1979,6 @@ "Render Exposure ↑/↓": "渲染曝光 ↑/↓", "Render exposure down": "降低渲染曝光", "Render exposure up": "提高渲染曝光", - "Render on all CPU cores, an experimental mode that can close the app without warning.": "使用所有 CPU 核心渲染,此模式为实验性质,可能导致应用无提示关闭。", "Render out-of-date thumbnails again in the background": "在后台重新渲染过期的缩略图", "Render the full-resolution scan instead of a downscaled preview": "渲染全分辨率扫描,而非缩小的预览", "Render the pipeline on the GPU": "使用 GPU 渲染管线", @@ -2517,7 +2512,6 @@ "Test Strip: show a grid of versions at different brightness and contrast, then click the one you like.": "试条:显示不同亮度和反差的网格版本,然后点击你喜欢的那一格。", "Test strip": "试条", "That roll no longer exists": "该胶卷已不存在", - "That setting is the most likely cause. Turning it off costs some speed on merges and exports, and is the safe setting.\n\nTurn multi-core CPU rendering off?": "此设置可能是主要原因。关闭后合并和导出速度会有所降低,但更稳定。\n\n关闭多核 CPU 渲染?", "The 120 camera's frame size": "120 相机的画幅尺寸", "The Analysis Readout": "分析读数", "The Library: Rolls": "图库:胶卷", @@ -2567,7 +2561,6 @@ "The language of the interface, applied when NegPy starts again.": "界面语言,在 NegPy 重新启动后生效。", "The largest brightness change Auto Density may make, so a lower value keeps a roll more even.": "自动密度可做的最大亮度变化,数值越低整卷越一致。", "The largest image size the GPU loads at once.": "GPU 一次加载的最大图像尺寸。", - "The last session ended unexpectedly with multi-core CPU rendering turned on.": "上次会话启用多核 CPU 渲染时意外结束。", "The lens model, written to the photo's metadata": "镜头型号,写入照片的元数据", "The lens this preset writes onto a frame": "此预设写入画面的镜头", "The light behind the negative": "负片背后的光源", diff --git a/negpy_core/.gitignore b/negpy_core/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/negpy_core/.gitignore @@ -0,0 +1 @@ +/target diff --git a/negpy_core/Cargo.lock b/negpy_core/Cargo.lock new file mode 100644 index 000000000..f13707c69 --- /dev/null +++ b/negpy_core/Cargo.lock @@ -0,0 +1,377 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "multiversion" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "negpy_core" +version = "0.1.0" +dependencies = [ + "libm", + "multiversion", + "numpy", + "pyo3", + "rayon", + "realfft", + "rustfft", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa0b9111761885449e894981fa8051dcf69e0bd114aecbd4bcdd830b6be4ed92" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "pyo3-build-config", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229ca13b8a15bb0d1487b81ab975bfcd97513416499a8312011b6036e42e0de4" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70130e2b6dd1ee589a8788026a429561ca5c40fa7a2fa7914c2282eddae24700" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7ff73c555c286d212d4ce7e5fbda845a973c4f61bd5f160f5f2c3c2a04d1c4" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c46d2e5862c7bb8207e3b639f713dda3c499933444784154c11d013c5a691b60" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b89f066f2ae72a2aef52d1537ca2fcd104440ebfadee06e97aa90738a25b9b" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" diff --git a/negpy_core/Cargo.toml b/negpy_core/Cargo.toml new file mode 100644 index 000000000..70ab2811e --- /dev/null +++ b/negpy_core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "negpy_core" +version = "0.1.0" +edition = "2024" +license = "GPL-3.0-only" +publish = false + +[lib] +name = "negpy_core" +crate-type = ["cdylib", "rlib"] + +[features] +python = ["dep:pyo3", "dep:numpy"] + +[dependencies] +rayon = "1.12" +pyo3 = { version = "0.30", features = ["abi3-py313"], optional = true } +numpy = { version = "0.30", optional = true } +multiversion = "0.8" +libm = "0.2" +rustfft = "6" +realfft = "3" diff --git a/negpy_core/LEDGER.md b/negpy_core/LEDGER.md new file mode 100644 index 000000000..41eaa3a7d --- /dev/null +++ b/negpy_core/LEDGER.md @@ -0,0 +1,47 @@ +# Version-bump ledger + +A port whose output cannot match the Python version bit for bit is listed here. The release +that ships negpy_core bumps `BOUNDS_VERSION` and `THUMBNAIL_RENDER_VERSION` one time for all +entries, so users render their thumbnails again only once. + +Transcendental functions in array loops come from `src/mathf.rs`, scalar ones from the pure-Rust +`libm` crate: the same bits on every platform. NumPy computes them through SVML on an AVX-512 CPU +and through libm elsewhere, so the Python results already differ by CPU. Replay accepts the +tolerance listed for each entry. + +| Function | Differs because | Tolerance | +|---|---|---| +| `features/exposure/normalization.py::_base_blocks` | Blocks of equal log brightness come out in block order, not in NumPy's SIMD argsort order. log10 is libm's, not SVML's, so a block on a group gap can change group in rare cases. | rows in any order | +| `negpy_core.distance_transform` (DIST_L2, mask 3), once a port calls it | opencv-python runs IPP's float32 chamfer, whose 3x3 pass picks a path one float32 unit longer than the two-pass order at rare pixels; OpenCV without IPP (Apple Silicon wheels) returns its own fixed-point values. | ulp=1 | +| `features/exposure/normalization.py::to_log_density` (through `prefilter_log_grid`), the normalization processors' log density (`negpy_core.log_density_open`, through `NormalizationProcessor.process` at abs=2e-6 on the normalized image, values up to 5.5) | log10 is correctly rounded (`mathf::log10f`); NumPy's float32 log10 (SVML) is up to 3 ULP off on about half the inputs. | ulp=3 | +| `kernel/image/logic.py::_rgb_to_lab_kernel` (and `rgb_to_lab_working`) | The cube root is `mathf::pow_f64`, not glibc's correctly rounded pow; a* and b* near zero take the float64 difference in a few ULP. | abs=1e-6 | +| `kernel/image/logic.py::_lab_to_rgb_kernel` (and `lab_to_rgb_working`) | One value in the corpus rounds the other way; the float64 operation order copies Numba's fastmath build. | ulp=1 | +| `kernel/image/logic.py::_skin_chroma_rein_kernel` (and `skin_chroma_rein`) | `atan2f` and `exp` are `mathf`'s, not glibc's; Numba's own serial and parallel builds of the kernel already differ in the last bit. | ulp=1 | +| `features/exposure/analysis.py::vectorscope_histogram` | atan2 and log1p are `mathf`'s, not SVML's; a sample on a bin edge can change bin (none did in 39 recorded frames). | abs=2 | +| `features/exposure/logic.py::apply_characteristic_curve` (the print curve) | `exp`, `log1p`, `tanh` and 10^x are `mathf`'s, not glibc's; 2 of 205 M recorded values round the other way. Every feature branch (masks, flash, diffusion, dye mix, separation damping, the print LUTs) matched the Numba kernel bit for bit on test frames. | ulp=1 | +| `features/process/sensor.py::_log_unmix_kernel` (density unmix) | `logf` and `expf` are `mathf`'s (correctly rounded) instead of glibc's; the log-domain matrix and the exp amplify a one-ULP log to up to 16 ULP on 0.2 % of values. The other three unmix kernels match Numba bit for bit. | ulp=16 | +| `features/geometry/logic.py::apply_fine_rotation`, `apply_keystone`, `apply_radial_distortion`, frames of 32767 px or more on a side | OpenCV cannot warp such a frame, so the Python version warped it in tiles from float64 source maps. negpy_core warps the whole frame with OpenCV's fixed-point coordinates, which can move a sample by 1/32 px. Smaller frames match `cv2` bit for bit. | none (the corpus has no such frame) | +| `negpy_core.resize_linear` (bilinear `cv2.resize` of float32: the contrast mask plane, the two-scale unmix layers, dust score and IR gain maps, flat-field gains) | opencv-python on x86-64 runs IPP's float32 bilinear resize, whose bits depend on the CPU it dispatches for; Apple Silicon wheels run OpenCV's own code. negpy_core copies OpenCV's own code. Upscales differ by up to 4 ULP, downscales more; the flat-field check (`self_corrected`, `check_reference`) carries it. | ulp=4 (upscale); abs=1e-6 for `check_reference` | +| `features/lab/logic.py::apply_rl_sharpening` | L* takes a correctly rounded cube root (`mathf::cbrtf`); NumPy's float32 `np.cbrt` runs SVML here, up to 2 ULP off on a third of inputs. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | +| `features/sabattier/logic.py::apply_sabattier`, `features/cyanotype/logic.py::apply_cyanotype`, `features/lith/logic.py::apply_lith` | log10, exp, log1p, 10^x and the cube root are `mathf`'s, correctly rounded (or within an ULP of it); NumPy's float32 versions run SVML or its own SIMD code. With correctly rounded transcendentals each port matches bit for bit. | abs=1e-6 | +| `features/finish/logic.py::apply_carrier` | The rebate tone lookup takes a correctly rounded cube root; NumPy's float32 `np.cbrt` runs SVML here. With a correctly rounded cube root the port matches bit for bit. | abs=1e-6 | +| `features/retouch/logic.py::_fit_refraction_gammas` | The flatness test takes OpenCV's own 5x5 Laplacian; opencv-python on x86-64 runs IPP's, whose bits depend on the CPU. A pixel on the 40th-percentile edge can change sides, which can move a fitted gamma. The 6 recorded frames match bit for bit. | none (exact in the corpus) | +| `features/retouch/logic.py::ir_ratio_and_gain` (the gain map) | `base ** -gamma` is `mathf::pow_f64` rounded to float32; NumPy's float32 power runs SVML on an AVX-512 CPU. Under 0.1 % of gain values differ, by 1 ULP; the ratio, the degenerate flag and the gammas match bit for bit. | ulp=1 | +| `features/process/sensor.py::film_base` | The final `exp` of the three median logs is libm's; NumPy's float64 exp runs SVML on an AVX-512 CPU. One channel of one recorded frame differs by 1 ULP (float64); the unmix that consumes it matches bit for bit. | ulp=1 | +| `features/geometry/skew.py::_fit_side`, `_profile_lines` (and `measure_frame_skew` through them) | The line fits are centered least-squares sums, not `np.polyfit`'s LAPACK SVD; the RANSAC draws copy NumPy's PCG64 stream exactly. Every recorded skew lands within 4e-13 degrees, with the same lines and the same decisions. | abs=1e-6 | +| `features/geometry/batch_autocrop.py::_top_edge_slope` | The same centered least-squares fit in place of `np.polyfit`. | abs=1e-9 | +| `features/geometry/skew.py::_coarse_angles` (the line segment detector) | The detector copies OpenCV's `fastAtan2`, its float32 region angles and its fixed-point Gaussian and INTER_LINEAR_EXACT downscale. OpenCV's build can contract the float32 polynomial and call a different `cosf`, and one changed angle can change a region. The recorded planes and the test frames match cv2 bit for bit. | quality: `tests/test_core_lsd.py` (95 % of the cv2 segments longer than 4 % of the long side matched within 1 px; angle modes and `measure_frame_skew` within 0.05) | +| `features/stitch/logic.py::register_pair` | SIFT, the ratio test and RANSAC copy OpenCV 4.13, but the sub-pixel solve, `exp`, `fastAtan2` and the float32 sums run in another order. A keypoint position can move by a float32 unit, which can change a descriptor byte, a match or a RANSAC draw. | quality: `tests/test_core_sift.py` asks for each corner of the moved part within 0.1 px of the true similarity on synthetic pairs, within 0.25 px of cv2 on the recorded panorama, and keypoint counts within 10 % of cv2's | +| `features/exposure/analysis.py::zone_anchors` | The inset is `negpy_core.distance_transform`'s, which sits one float32 unit from opencv-python's IPP chamfer at rare pixels (the `distance_transform` row above); where two pixels tie for the deepest point, the anchor can take the other one. | quality: `tests/test_core_zones.py` (same regions; the inset within 1e-4 of cv2's deepest distance, at a pixel cv2 also rates that deep) | +| `features/retouch/logic.py::apply_hair_inpaint` | `cv2.inpaint` (Navier-Stokes) is `negpy_core::inpaint::inpaint_ns`, and the tiles run in parallel. The decode table takes `mathf::pow_f64`, not NumPy's float32 power. OpenCV reads past the image on a one-pixel-wide tile; negpy_core clamps there. Gate `tests/test_core_inpaint.py`: inside the mask a mean difference of at most 1 and a maximum of 4 in cv2's 8-bit values, identical bytes outside. Measured: 549 846 inpainted values on 14 recorded crops and synthetic tiles match cv2 bit for bit; the final image differs by at most 1.2e-7. | quality | +| `features/stitch/logic.py::find_seams` | `cv2.detail_DpSeamFinder("COLOR")` is `negpy_core::seam::dp_seams`, for parts on one canvas only. The pairs run in reverse order of OpenCV's sort, which keeps equal pairs in order up to 16 pairs, that is 6 parts; OpenCV's introsort can reorder more. Gate `tests/test_core_seam.py`: the color cost along the cut at most 1 % above cv2's, identical masks outside the overlap. Measured on synthetic overlaps of 2 to 4 parts and the recorded panorama: 0 % of overlap pixels differ, so the cut costs are equal. | quality | +| `features/stitch/logic.py::seam_blend`, `feather_blend` (and `_seam_weight`, now inside `seam_blend`) | The distances come from `negpy_core.distance_transform`, not IPP's float32 chamfer (see its entry above); the error grows with the distance from the edge, and the weights clip it away beyond the blend band. Gate `tests/test_core_seam.py`: distances within 0.005 of cv2 on masks up to 1600 px, blends within 1e-4 of the cv2 version. Measured on the recorded panorama and synthetic overlaps: distances within 0.0042, `seam_blend` within 1.2e-7, `feather_blend` within 4.2e-7. On a 6000 px mask the distances differ by up to 0.085 far from the edge, and the blends still within 3.3e-7. | quality | +| `features/exposure/normalization.py::contrast_mask_plane`, `diffusion_grid` | Both read `prefilter_log_grid`, whose log10 is `mathf::log10f` (the `to_log_density` row). Diffusion's 10^x and log10 are `mathf`'s, not SVML's. Given the same log grid, `contrast_mask_plane` matches bit for bit. | abs=1e-6 | +| `features/rgbscan/logic.py::estimate_shift`, `infrastructure/scanners/sane_backend.py::_align_ir_to_rgb` (and the HDR and RGB Scan alignment through them) | `cv2.phaseCorrelate` is `negpy_core::phase::phase_correlate`, in float32 through rustfft and realfft, as OpenCV works. cv2 measures the shift from (n / 2.0, m / 2.0) after an fftShift that rolls by (n / 2, m / 2), so along an axis whose padded DFT size is odd it reads every shift half a pixel too far; negpy_core measures from the roll's origin. Gate `tests/test_core_phase.py`: within 0.2 px of the true shift on synthetic pairs (the 5x5 peak centroid's own bias), within 0.05 px of cv2 once its half pixel is taken off. The recorded HDR and RGB Scan merges match bit for bit. | quality | +| `services/export/print.py` (print layout) and `services/rendering/gpu_engine.py::GPUEngine._process_tiled`: the Lanczos4 upscale | `negpy_core.resize_kernel` copies OpenCV's taps and weights (`interpolateLanczos4` with libm's sin and cos) and sums each pass in float32 tap by tap; opencv-python's vector code orders the sums its own way. Gate `tests/test_core_resize_kernel.py`: within 1e-6 of cv2 (INTER_CUBIC within 1e-5). | quality | +| `services/export/contact_sheet.py::_place_tile`: the uint8 cubic tile upscale | `negpy_core.resize_u8(cubic=True)` copies OpenCV's own uint8 cubic resize (11-bit fixed-point weights, integer rows, the float32 column pass over 8-value blocks), bit for bit with cv2 when IPP is off. opencv-python on x86-64 runs IPP's resize, which differs by 1 level on a few per cent of values. The area shrink matches cv2 bit for bit. | ulp=1 (uint8 levels) | +| `infrastructure/scanners/sane_backend.py::_resample_rows_to_dpi` | `negpy_core.resize_rows_linear` copies OpenCV's own row-only bilinear resize: integer for uint8, float32 for uint16. With IPP, cv2 matches for uint8 and differs by one level on some uint16 values. | ulp=1 (levels) | +| `desktop/workers/chart_fit.py::ChartFitWorker.__init__` | cv2 took the area scale from `fx` while it rounded the output size; negpy_core takes the scale from the rounded size, so the cells tile the frame exactly. | none (no recordings) | +| `infrastructure/simulated/gphoto.py::preview_jpeg` | The simulated camera's preview JPEG is Pillow's encoder at quality 95, not libjpeg through cv2. | none | +| `kernel/image/logic.py::srgb_to_linear` (JPEG and 8-bit TIFF loads, placeholder thumbnails) | The 2.4 power is `mathf::pow_f64` rounded to float32, within an ULP of the correctly rounded value; NumPy's float32 power here runs SVML, up to 4 ULP off on most inputs. The placeholder thumbnail's log stretch (`thumbnails.preview_positive`) also takes `mathf::log10f`, so a few values land one uint8 level apart. | ulp=4; uint8 levels within 1 | +| `services/export/encoders.py::encode_jpeg` (baseline files) | Restart markers split the scan, so strips encode in parallel. Each interval starts its DC prediction at zero, which changes the Huffman statistics, so the tables and bytes differ from PIL's. Without restart markers the bytes are PIL's (`tests/test_core_jpeg.py`). Progressive files stay on PIL. | decoded pixels exact | diff --git a/negpy_core/deny.toml b/negpy_core/deny.toml new file mode 100644 index 000000000..fd78dec9e --- /dev/null +++ b/negpy_core/deny.toml @@ -0,0 +1,19 @@ +[graph] +all-features = true + +[licenses] +# Each must be GPL-3.0 compatible. Add a license here only after that check. +allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "Unicode-3.0"] +private = { ignore = true } + +[bans] +multiple-versions = "allow" +# negpy_core builds no C or C++: a codec that needs a C library stays on its Python wheel. +deny = [{ name = "cc" }, { name = "cmake" }, { name = "pkg-config" }, { name = "bindgen" }] + +[advisories] +yanked = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/negpy_core/manifest.toml b/negpy_core/manifest.toml new file mode 100644 index 000000000..c15ae3e5e --- /dev/null +++ b/negpy_core/manifest.toml @@ -0,0 +1,2796 @@ +# One entry per Python function that negpy_core replaces. scripts/core_manifest.py writes +# calls and depends; phase, status (todo, ported, stays), owner, compare (replay: exact, rows, +# ulp=N, abs=X, quality: a pytest gate; see negpy_core/LEDGER.md) and note are kept by hand. + +["kernel/image/logic.py::percentiles"] +phase = 1 +status = "ported" +calls = ["negpy_core.percentiles"] +depends = [] +note = "negpy_core::stats::percentiles" + +["features/exposure/analysis.py::_mid_gray_encoded"] +phase = 2 +status = "stays" +calls = [] +depends = [] +note = "scalar or roll-size math" + +["features/exposure/analysis.py::_scope_lookup"] +phase = 2 +status = "ported" +calls = ["negpy_core.scope_lookup"] +depends = [] + +["features/exposure/analysis.py::_waveform"] +phase = 2 +status = "ported" +calls = ["negpy_core.waveform"] +depends = [] + +["features/exposure/analysis.py::_waveform_cells"] +phase = 2 +status = "ported" +calls = [] +depends = [] + +["features/exposure/analysis.py::color_histogram"] +phase = 2 +status = "ported" +calls = ["negpy_core.color_histogram"] +depends = [] + +["features/exposure/analysis.py::density_histogram"] +phase = 2 +status = "ported" +calls = ["negpy_core.density_histogram"] +depends = [] + +["features/exposure/analysis.py::density_parade"] +phase = 2 +status = "ported" +calls = ["negpy_core.density_parade"] +depends = [] + +["features/exposure/analysis.py::encoded_of_zone"] +phase = 2 +status = "stays" +calls = [] +depends = ["_mid_gray_encoded"] +note = "scalar or roll-size math" + +["features/exposure/analysis.py::loupe_acutance"] +phase = 2 +status = "ported" +calls = ["negpy_core.loupe_acutance"] +depends = [] + +["features/exposure/analysis.py::output_histogram"] +phase = 2 +status = "ported" +calls = ["negpy_core.output_histogram"] +depends = [] + +["features/exposure/analysis.py::paper_grid_mosaic"] +phase = 2 +status = "stays" +calls = ["negpy_core.resize_area"] +depends = [] +note = "orchestration of negpy_core kernels" + +["features/exposure/analysis.py::scan_full_scale_columns"] +phase = 2 +status = "ported" +calls = ["negpy_core.scan_full_scale_columns"] +depends = [] + +["features/exposure/analysis.py::scope_point"] +phase = 2 +status = "stays" +calls = [] +depends = [] +note = "scalar or roll-size math" + +["features/exposure/analysis.py::vectorscope_density"] +phase = 2 +status = "ported" +calls = ["negpy_core.vectorscope_density"] +depends = ["_scope_lookup"] + +["features/exposure/analysis.py::vectorscope_histogram"] +phase = 2 +status = "ported" +calls = ["negpy_core.vectorscope_histogram"] +depends = [] + +["features/exposure/analysis.py::waveform"] +phase = 2 +status = "ported" +calls = [] +depends = ["_waveform"] + +["features/exposure/analysis.py::waveform_color"] +phase = 2 +status = "ported" +calls = [] +depends = ["_waveform"] + +["features/exposure/analysis.py::waveform_parade"] +phase = 2 +status = "ported" +calls = [] +depends = ["_waveform"] + +["features/exposure/analysis.py::zone_anchors"] +phase = 2 +status = "ported" +compare = "quality" +calls = ["negpy_core.zone_anchors"] +depends = [] + +["features/exposure/analysis.py::zone_field"] +phase = 2 +status = "ported" +calls = ["negpy_core.zone_field"] +depends = ["_mid_gray_encoded"] + +["features/exposure/analysis.py::zone_grid"] +phase = 2 +status = "ported" +calls = ["negpy_core.zone_grid"] +depends = ["_mid_gray_encoded"] + +["features/exposure/analysis.py::zone_occupancy"] +phase = 2 +status = "ported" +calls = ["negpy_core.zone_occupancy"] +depends = ["_mid_gray_encoded"] + +["features/exposure/analysis.py::zone_of_encoded"] +phase = 2 +status = "stays" +calls = [] +depends = ["_mid_gray_encoded"] +note = "scalar or roll-size math" + +["features/exposure/analysis.py::zone_region_labels"] +phase = 2 +status = "ported" +calls = ["negpy_core.zone_region_labels"] +depends = [] + +["features/exposure/analysis.py::zone_shares"] +phase = 2 +status = "ported" +calls = ["negpy_core.zone_shares"] +depends = [] + +["features/exposure/normalization.py::_base_blocks"] +phase = 2 +status = "ported" +compare = "rows" +calls = ["negpy_core.base_blocks"] +depends = [] +note = "negpy_core::analysis::base_blocks" + +["features/exposure/normalization.py::_block_median_grid"] +phase = 2 +status = "ported" +calls = ["negpy_core.block_median_grid"] +depends = [] + +["features/exposure/normalization.py::_block_median_grid._median"] +phase = 2 +status = "ported" +calls = ["np.median"] +depends = [] + +["features/exposure/normalization.py::_normalize_log_image_jit"] +phase = 2 +status = "ported" +calls = ["numba"] +depends = [] + +["features/exposure/normalization.py::_same_pixel_color_floor_refs"] +phase = 2 +status = "ported" +calls = ["negpy_core.same_pixel_color_floor_refs"] +depends = [] + +["features/exposure/normalization.py::_same_pixel_color_floor_refs._select"] +phase = 2 +status = "ported" +calls = ["np.median", "np.quantile"] +depends = [] + +["features/exposure/normalization.py::_sample_log_bounds"] +phase = 2 +status = "ported" +calls = ["negpy_core.channel_percentiles"] +depends = [] + +["features/exposure/normalization.py::_sample_log_bounds._pct"] +phase = 2 +status = "ported" +calls = ["np.percentile"] +depends = [] + +["features/exposure/normalization.py::_smooth_along_scan_order"] +phase = 2 +status = "stays" +calls = ["np.median"] +depends = [] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::_weighted_median"] +phase = 2 +status = "stays" +calls = ["np.argsort", "np.cumsum"] +depends = [] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::analysis_grid"] +phase = 2 +status = "stays" +calls = ["negpy_core.resize_area"] +depends = [] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::analyze_log_exposure_bounds"] +phase = 2 +status = "stays" +calls = [] +depends = ["analyze_log_exposure_bounds_from_log", "to_log_density"] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::analyze_log_exposure_bounds_from_log"] +phase = 2 +status = "stays" +calls = [] +depends = ["_block_median_grid", "_same_pixel_color_floor_refs", "_sample_log_bounds"] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::contrast_mask_plane"] +phase = 2 +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.contrast_mask_tail"] +depends = ["analysis_grid", "normalize_log_image", "prefilter_log_grid"] +note = "negpy_core::analysis::contrast_mask_tail" + +["features/exposure/normalization.py::density_above_base"] +phase = 2 +status = "ported" +calls = ["negpy_core.luma_median"] +depends = [] +note = "negpy_core::analysis" + +["features/exposure/normalization.py::detect_light_groups"] +phase = 2 +status = "stays" +calls = ["np.median"] +depends = ["_smooth_along_scan_order", "pool_neutral_axis"] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::diffusion_grid"] +phase = 2 +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.diffusion_tail"] +depends = ["analysis_grid", "prefilter_log_grid"] +note = "negpy_core::analysis::diffusion_tail" + +["features/exposure/normalization.py::diffusion_plane"] +phase = 2 +status = "ported" +calls = ["negpy_core.luma_planes"] +depends = ["normalize_log_image"] +note = "negpy_core::analysis" + +["features/exposure/normalization.py::effective_crosstalk_matrix"] +phase = 2 +status = "stays" +calls = [] +depends = ["resolve_crosstalk_matrix"] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::measure_anchor"] +phase = 2 +status = "stays" +calls = [] +depends = ["measure_anchor_from_log", "to_log_density"] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::measure_anchor_from_log"] +phase = 2 +status = "ported" +calls = ["negpy_core.textured_anchor"] +depends = ["_block_median_grid"] + +["features/exposure/normalization.py::measure_base_log"] +phase = 2 +status = "ported" +calls = ["negpy_core.measure_base_log"] +depends = [] + +["features/exposure/normalization.py::measure_clip_fractions"] +phase = 2 +status = "ported" +calls = ["negpy_core.clip_fractions"] +depends = [] +note = "negpy_core::analysis" + +["features/exposure/normalization.py::measure_highlight_point_from_log"] +phase = 2 +status = "ported" +calls = ["negpy_core.textured_percentile"] +depends = ["_block_median_grid"] + +["features/exposure/normalization.py::measure_neutral_axis"] +phase = 2 +status = "stays" +calls = [] +depends = ["measure_neutral_axis_from_log", "to_log_density"] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::measure_neutral_axis_from_log"] +phase = 2 +status = "ported" +calls = ["negpy_core.neutral_axis"] +depends = ["_block_median_grid"] + +["features/exposure/normalization.py::measure_neutral_axis_from_log._band_refs"] +phase = 2 +status = "ported" +calls = ["np.median", "np.percentile", "np.quantile"] +depends = [] + +["features/exposure/normalization.py::measure_shadow_point_from_log"] +phase = 2 +status = "ported" +calls = ["negpy_core.textured_percentile"] +depends = ["_block_median_grid"] + +["features/exposure/normalization.py::measure_textural_range"] +phase = 2 +status = "stays" +calls = [] +depends = ["measure_textural_range_from_log", "to_log_density"] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::measure_textural_range_from_log"] +phase = 2 +status = "ported" +calls = ["negpy_core.textured_range"] +depends = ["_block_median_grid"] + +["features/exposure/normalization.py::normalize_log_image"] +phase = 2 +status = "ported" +calls = ["negpy_core.normalize_log_image"] +depends = [] + +["features/exposure/normalization.py::pool_frame_bounds"] +phase = 2 +status = "stays" +calls = ["np.median"] +depends = [] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::pool_neutral_axis"] +phase = 2 +status = "stays" +calls = ["np.median"] +depends = ["_weighted_median", "band"] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::pool_neutral_axis.band"] +phase = 2 +status = "stays" +calls = [] +depends = ["_weighted_median"] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::prefilter_log_grid"] +phase = 2 +status = "ported" +compare = "ulp=3" +calls = [] +depends = ["_block_median_grid", "to_log_density"] + +["features/exposure/normalization.py::resolve_crosstalk_matrix"] +phase = 2 +status = "stays" +calls = ["np.sum"] +depends = [] +note = "orchestration or roll-size math" + +["features/exposure/normalization.py::sorted_channel_grid"] +phase = 2 +status = "stays" +calls = ["np.sort"] +depends = [] +note = "np.sort (SIMD sort) is faster than a radix port" + +["features/exposure/normalization.py::to_log_density"] +phase = 2 +status = "ported" +compare = "ulp=3" +calls = ["negpy_core.to_log_density"] +depends = [] +note = "negpy_core::analysis::to_log_density" + +["features/geometry/batch_autocrop.py::_border_without_a_film_box"] +phase = 2 +status = "stays" +calls = [] +depends = ["_bright_side_border", "_side_border"] +note = "orchestration of negpy_core kernels" + +["features/geometry/batch_autocrop.py::_bright_side_border"] +phase = 2 +status = "stays" +calls = [] +depends = [] +note = "orchestration of negpy_core kernels" + +["features/geometry/batch_autocrop.py::_edge_fit"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = [] +depends = [] +note = "orchestration of negpy_core kernels" + +["features/geometry/batch_autocrop.py::_mad"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = ["np.median"] +depends = [] +note = "scalar or roll-size math" + +["features/geometry/batch_autocrop.py::_map_rect_between_rotations"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = ["negpy_core.rotation_matrix"] +depends = [] +note = "scalar or roll-size math" + +["features/geometry/batch_autocrop.py::_no_box_evidence"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = [] +depends = ["_border_without_a_film_box"] +note = "orchestration of negpy_core kernels" + +["features/geometry/batch_autocrop.py::_rect_from_edge_profile"] +phase = 2 +status = "ported" +calls = ["negpy_core.rect_from_edge_profile"] +depends = [] + +["features/geometry/batch_autocrop.py::_roll_border"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = ["np.median"] +depends = [] +note = "scalar or roll-size math" + +["features/geometry/batch_autocrop.py::_side_border"] +phase = 2 +status = "stays" +calls = [] +depends = [] +note = "orchestration of negpy_core kernels" + +["features/geometry/batch_autocrop.py::_threshold_fallback_frames"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = ["np.median"] +depends = [] +note = "scalar or roll-size math" + +["features/geometry/batch_autocrop.py::_top_edge_slope"] +phase = 2 +status = "ported" +compare = "abs=1e-9" +calls = ["negpy_core.top_edge_slope"] +depends = [] + +["features/geometry/batch_autocrop.py::_vertical_edge_profile"] +phase = 2 +status = "ported" +calls = ["negpy_core.batch_edge_profile"] +depends = [] + +["features/geometry/batch_autocrop.py::build_roll_template"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = ["np.median", "np.percentile"] +depends = ["_mad", "_roll_border", "_threshold_fallback_frames"] +note = "scalar or roll-size math" + +["features/geometry/batch_autocrop.py::detect_crop_candidate"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = [] +depends = ["_bright_side_border", "_edge_fit", "_no_box_evidence", "_side_border", "_top_edge_slope", "_vertical_edge_profile"] +note = "orchestration of negpy_core kernels" + +["features/geometry/batch_autocrop.py::resolve_roll_crops"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = [] +depends = ["_map_rect_between_rotations", "_rect_from_edge_profile", "build_roll_template"] +note = "scalar or roll-size math" + +["features/geometry/logic.py::_adaptive_threshold_levels"] +phase = 2 +status = "ported" +calls = ["np.percentile"] +depends = [] + +["features/geometry/logic.py::_adaptive_threshold_mask"] +phase = 2 +status = "ported" +calls = ["cv2.getStructuringElement", "negpy_core.morphology_ex"] +depends = [] + +["features/geometry/logic.py::_border_edge_scatter"] +phase = 2 +status = "ported" +calls = ["np.median"] +depends = [] +note = "negpy_core::analysis::border_edge_scatter (Python deleted)" + +["features/geometry/logic.py::_candidate_from_contour"] +phase = 2 +status = "ported" +calls = ["cv2.boundingRect", "cv2.boxPoints", "cv2.contourArea", "cv2.minAreaRect"] +depends = ["_oriented_boundary_evidence"] + +["features/geometry/logic.py::_candidates_from_mask"] +phase = 2 +status = "ported" +calls = ["negpy_core.find_external_contours"] +depends = ["_candidate_from_contour"] + +["features/geometry/logic.py::_dark_region_bounds"] +phase = 2 +status = "ported" +calls = ["cv2.boundingRect", "cv2.contourArea", "cv2.getStructuringElement", "negpy_core.bgr_to_gray", "negpy_core.find_external_contours", "negpy_core.morphology_ex", "np.percentile"] +depends = ["_normalize_to_uint8"] +note = "negpy_core::analysis::dark_region_bounds (Python deleted)" + +["features/geometry/logic.py::_detect_film_bounds"] +phase = 2 +status = "stays" +calls = [] +depends = ["detect_film_bounds_with_confidence"] +note = "orchestration of negpy_core kernels" + +["features/geometry/logic.py::_detection_luma"] +phase = 2 +status = "ported" +calls = ["negpy_core.detection_luma"] +depends = [] +note = "negpy_core::analysis::detection_luma" + +["features/geometry/logic.py::_edge_border_depth"] +phase = 2 +status = "ported" +calls = ["np.median"] +depends = ["_border_edge_scatter", "_pool_border_depth", "_walk_border_depth"] +note = "negpy_core::analysis::edge_border_depth (Python deleted)" + +["features/geometry/logic.py::_enforce_ratio_by_occupancy"] +phase = 2 +status = "stays" +calls = [] +depends = ["_place_window_by_occupancy"] +note = "orchestration of negpy_core kernels" + +["features/geometry/logic.py::_estimate_tier_levels"] +phase = 2 +status = "ported" +calls = ["np.percentile"] +depends = ["_find_rebate_level"] +note = "negpy_core::analysis::estimate_tier_levels (Python deleted)" + +["features/geometry/logic.py::_film_surround_is_plausible"] +phase = 2 +status = "ported" +calls = ["np.median", "np.percentile"] +depends = [] + +["features/geometry/logic.py::_find_autocrop_roi_from_contours"] +phase = 2 +status = "stays" +calls = [] +depends = ["_detect_film_bounds", "_refine_roi_to_image"] +note = "orchestration of negpy_core kernels" + +["features/geometry/logic.py::_find_rebate_level"] +phase = 2 +status = "ported" +calls = ["negpy_core.find_rebate_level"] +depends = [] +note = "negpy_core::analysis::find_rebate_level" + +["features/geometry/logic.py::_get_threshold_autocrop_coords"] +phase = 2 +status = "ported" +calls = ["negpy_core.threshold_box"] +depends = [] + +["features/geometry/logic.py::_mask_from_blackhat"] +phase = 2 +status = "ported" +calls = ["cv2.getStructuringElement", "negpy_core.gaussian_blur_u8", "negpy_core.morphology_ex", "negpy_core.threshold_otsu"] +depends = [] + +["features/geometry/logic.py::_mask_from_edges"] +phase = 2 +status = "ported" +calls = ["cv2.getStructuringElement", "negpy_core.canny", "negpy_core.gaussian_blur_u8", "negpy_core.morphology_ex"] +depends = [] + +["features/geometry/logic.py::_mask_from_inverse_threshold"] +phase = 2 +status = "ported" +calls = ["cv2.getStructuringElement", "negpy_core.gaussian_blur_u8", "negpy_core.morphology_ex", "negpy_core.threshold_otsu"] +depends = [] + +["features/geometry/logic.py::_normalize_detection_input"] +phase = 2 +status = "stays" +calls = ["negpy_core.resize_area"] +depends = [] +note = "orchestration of negpy_core kernels" + +["features/geometry/logic.py::_normalize_to_uint8"] +phase = 2 +status = "ported" +calls = ["negpy_core.normalize_to_uint8"] +depends = [] +note = "negpy_core::analysis::normalize_to_uint8" + +["features/geometry/logic.py::_oriented_boundary_evidence"] +phase = 2 +status = "ported" +calls = ["np.median"] +depends = ["_polygon_values"] + +["features/geometry/logic.py::_place_window_by_occupancy"] +phase = 2 +status = "ported" +calls = ["negpy_core.place_window_by_occupancy"] +depends = [] + +["features/geometry/logic.py::_polygon_values"] +phase = 2 +status = "ported" +calls = ["cv2.fillConvexPoly"] +depends = [] + +["features/geometry/logic.py::_pool_border_depth"] +phase = 2 +status = "ported" +calls = ["np.percentile"] +depends = [] +note = "negpy_core::analysis::pool_border_depth (Python deleted)" + +["features/geometry/logic.py::_pre_keystone_point"] +phase = 2 +status = "stays" +calls = ["negpy_core.rotation_matrix"] +depends = [] +note = "scalar or roll-size math" + +["features/geometry/logic.py::_refine_film_roi_by_tiers"] +phase = 2 +status = "ported" +calls = ["negpy_core.refine_film_roi_by_tiers"] +depends = [] +note = "negpy_core::analysis::refine_film_roi_by_tiers" + +["features/geometry/logic.py::_refine_frame_bounds"] +phase = 2 +status = "ported" +calls = ["negpy_core.refine_frame_bounds"] +depends = [] +note = "negpy_core::analysis::refine_frame_bounds" + +["features/geometry/logic.py::_refine_roi_to_image"] +phase = 2 +status = "ported" +calls = ["negpy_core.refine_roi_to_image"] +depends = [] +note = "negpy_core::analysis::refine_roi_to_image" + +["features/geometry/logic.py::_roi_from_measured_border"] +phase = 2 +status = "ported" +calls = ["negpy_core.roi_from_measured_border"] +depends = [] +note = "negpy_core::analysis::roi_from_measured_border" + +["features/geometry/logic.py::_select_consensus_cluster"] +phase = 2 +status = "ported" +calls = [] +depends = ["_weighted_median"] + +["features/geometry/logic.py::_smooth_signal"] +phase = 2 +status = "ported" +calls = ["negpy_core.smooth_signal"] +depends = [] + +["features/geometry/logic.py::_snap_edge_to_gradient"] +phase = 2 +status = "ported" +calls = ["negpy_core.snap_edge_to_gradient"] +depends = [] +note = "negpy_core::analysis::snap_edge_to_gradient" + +["features/geometry/logic.py::_snap_film_bounds_to_bed_gradient"] +phase = 2 +status = "ported" +calls = [] +depends = ["_snap_edge_to_gradient"] + +["features/geometry/logic.py::_threshold_polarities"] +phase = 2 +status = "ported" +calls = ["np.median"] +depends = [] + +["features/geometry/logic.py::_trim_film_edges"] +phase = 2 +status = "ported" +calls = ["negpy_core.trim_film_edges"] +depends = [] +note = "negpy_core::analysis::trim_film_edges" + +["features/geometry/logic.py::_vertical_edge_profile"] +phase = 2 +status = "ported" +calls = ["negpy_core.gaussian_blur", "negpy_core.sobel", "np.mean", "np.percentile"] +depends = [] + +["features/geometry/logic.py::_walk_border_depth"] +phase = 2 +status = "ported" +calls = ["negpy_core.walk_border_depth"] +depends = [] +note = "negpy_core::analysis::walk_border_depth" + +["features/geometry/logic.py::_weighted_median"] +phase = 2 +status = "ported" +calls = ["np.argsort", "np.cumsum", "np.sum"] +depends = [] + +["features/geometry/logic.py::apply_fine_rotation"] +phase = 2 +status = "ported" +calls = ["negpy_core.rotation_matrix", "negpy_core.warp_affine"] +depends = [] + +["features/geometry/logic.py::apply_keystone"] +phase = 2 +status = "ported" +calls = ["negpy_core.warp_perspective"] +depends = ["keystone_matrix"] + +["features/geometry/logic.py::apply_radial_distortion"] +phase = 2 +status = "ported" +calls = ["negpy_core.remap_radial"] +depends = [] + +["features/geometry/logic.py::compute_geometry_crop_rect"] +phase = 2 +status = "stays" +calls = ["negpy_core.rotation_matrix"] +depends = ["keystone_matrix"] +note = "scalar or roll-size math" + +["features/geometry/logic.py::detect_closest_aspect_ratio"] +phase = 2 +status = "stays" +calls = [] +depends = ["_find_autocrop_roi_from_contours", "_get_threshold_autocrop_coords", "_normalize_detection_input"] +note = "orchestration of negpy_core kernels" + +["features/geometry/logic.py::detect_film_bounds_with_confidence"] +phase = 2 +status = "ported" +calls = ["negpy_core.detect_film_bounds"] +depends = [] + +["features/geometry/logic.py::film_and_picture"] +phase = 2 +status = "ported" +calls = ["negpy_core.film_and_picture"] +depends = [] + +["features/geometry/logic.py::get_autocrop_coords"] +phase = 2 +status = "stays" +calls = [] +depends = ["_detect_film_bounds", "_detection_luma", "_enforce_ratio_by_occupancy", "_get_threshold_autocrop_coords", "_normalize_detection_input", "_refine_roi_to_image", "_trim_film_edges"] +note = "orchestration of negpy_core kernels" + +["features/geometry/logic.py::keystone_inverse_normalized"] +phase = 2 +status = "stays" +calls = [] +depends = ["keystone_matrix_normalized"] +note = "scalar or roll-size math" + +["features/geometry/logic.py::keystone_matrix"] +phase = 2 +status = "stays" +calls = [] +depends = ["keystone_matrix_normalized"] +note = "scalar or roll-size math" + +["features/geometry/logic.py::keystone_matrix_normalized"] +phase = 2 +status = "stays" +calls = ["negpy_core.perspective_transform"] +depends = [] +note = "scalar or roll-size math; the perspective solve is negpy_core's" + +["features/geometry/logic.py::map_coords_to_geometry"] +phase = 2 +status = "stays" +calls = [] +depends = ["_pre_keystone_point", "map_point_keystone"] +note = "scalar or roll-size math" + +["features/geometry/logic.py::map_point_keystone"] +phase = 2 +status = "stays" +calls = [] +depends = ["keystone_matrix"] +note = "scalar or roll-size math" + +["features/geometry/logic.py::measure_film_border"] +phase = 2 +status = "ported" +calls = ["negpy_core.measure_film_border"] +depends = [] +note = "negpy_core::analysis::measure_film_border" + +["features/geometry/logic.py::measure_film_edges"] +phase = 2 +status = "ported" +calls = ["negpy_core.measure_film_edges"] +depends = [] +note = "negpy_core::analysis::measure_film_edges" + +["features/geometry/logic.py::resolve_autocrop_rect"] +phase = 2 +status = "stays" +calls = [] +depends = ["_detect_film_bounds", "_normalize_detection_input", "apply_fine_rotation", "apply_keystone", "apply_radial_distortion", "get_autocrop_coords"] +note = "orchestration of negpy_core kernels" + +["features/geometry/logic.py::resolve_meter_rect"] +phase = 2 +status = "stays" +calls = [] +depends = ["resolve_autocrop_rect"] +note = "orchestration of negpy_core kernels" + +["features/geometry/logic.py::solve_keystone_from_edges"] +phase = 2 +status = "stays" +calls = [] +depends = ["_pre_keystone_point", "map_point_keystone", "residuals", "slope_h", "slope_v"] +note = "scalar or roll-size math" + +["features/geometry/logic.py::solve_keystone_from_edges.residuals"] +phase = 2 +status = "stays" +calls = [] +depends = ["slope_h", "slope_v"] +note = "scalar or roll-size math" + +["features/geometry/logic.py::solve_keystone_from_edges.slope_h"] +phase = 2 +status = "stays" +calls = [] +depends = ["map_point_keystone"] +note = "scalar or roll-size math" + +["features/geometry/logic.py::solve_keystone_from_edges.slope_v"] +phase = 2 +status = "stays" +calls = [] +depends = ["map_point_keystone"] +note = "scalar or roll-size math" + +["features/geometry/processor.py::GeometryProcessor.process"] +phase = 2 +status = "stays" +calls = [] +depends = [] +note = "orchestration of negpy_core kernels" + +["features/geometry/skew.py::_at_canvas_edges"] +phase = 2 +status = "ported" +calls = ["negpy_core.skew_at_canvas_edges"] +depends = [] + +["features/geometry/skew.py::_fit_side"] +phase = 2 +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.skew_fit_side"] +depends = [] + +["features/geometry/skew.py::_frame_lines"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = [] +depends = ["_fit_side"] +note = "orchestration of negpy_core kernels" + +["features/geometry/skew.py::_line_angles"] +phase = 2 +status = "stays" +calls = [] +depends = ["_map"] +note = "scalar line geometry; the rotation matrix is negpy_core's" + +["features/geometry/skew.py::_map"] +phase = 2 +status = "stays" +calls = ["negpy_core.rotation_matrix"] +depends = [] +note = "scalar line geometry; the rotation matrix is negpy_core's" + +["features/geometry/skew.py::_measure_from"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = ["np.mean"] +depends = ["_at_canvas_edges", "_frame_lines", "_profile_lines", "_solve", "_unrotate"] +note = "orchestration of negpy_core kernels" + +["features/geometry/skew.py::_profile_lines"] +phase = 2 +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.skew_profile_lines"] +depends = [] + +["features/geometry/skew.py::_solve"] +phase = 2 +status = "stays" +calls = [] +depends = ["_line_angles"] +note = "scalar line geometry; the rotation matrix is negpy_core's" + +["features/geometry/skew.py::_unrotate"] +phase = 2 +status = "stays" +calls = ["negpy_core.rotation_matrix"] +depends = [] +note = "scalar line geometry; the rotation matrix is negpy_core's" + +["features/geometry/skew.py::measure_frame_skew"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = [] +depends = ["_coarse_angles", "_measure_from"] +note = "orchestration of negpy_core kernels" + +["features/geometry/skew.py::squared_geometry"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = [] +depends = ["trusted_frame_skew"] +note = "orchestration of negpy_core kernels" + +["features/geometry/skew.py::trusted_frame_skew"] +phase = 2 +status = "stays" +compare = "abs=1e-6" +calls = [] +depends = ["measure_frame_skew"] +note = "orchestration of negpy_core kernels" + +["infrastructure/loaders/logluv.py::normalize_linear"] +phase = 3 +status = "ported" +calls = ["negpy_core.normalize_linear"] +depends = [] +note = "negpy_core::loaders::normalize_linear" + +["infrastructure/loaders/pakon_loader.py::PakonLoader._looks_interleaved"] +phase = 3 +status = "stays" +calls = ["np.mean"] +depends = [] +note = "6000-sample layout probe" + +["infrastructure/loaders/rawpy_loader.py::_stream_linearraw_preview"] +phase = 3 +status = "ported" +calls = ["negpy_core.ellipse_kernel", "negpy_core.morphology_ex", "negpy_core.resize_area"] +depends = [] +note = "negpy_core resize_area, morphology_ex, ellipse_kernel" + +["kernel/image/logic.py::_float_to_uint16_luma_jit"] +phase = 3 +status = "ported" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_float_to_uint8_luma_jit"] +phase = 3 +status = "ported" +calls = ["numba"] +depends = [] + +["kernel/image/logic.py::_gamut_aware_chroma_scale_kernel"] +phase = 3 +status = "ported" +calls = ["negpy_core.gamut_aware_chroma_scale"] +depends = [] + +["kernel/image/logic.py::_lab_to_rgb_kernel"] +phase = 3 +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.lab_to_rgb"] +depends = [] + +["kernel/image/logic.py::_matmul_3x3_kernel"] +phase = 3 +status = "ported" +calls = ["negpy_core.matmul_3x3"] +depends = [] + +["kernel/image/logic.py::_oetf_decode_flat"] +phase = 3 +status = "ported" +calls = ["negpy_core.oetf_decode"] +depends = [] + +["kernel/image/logic.py::_oetf_encode_flat"] +phase = 3 +status = "ported" +calls = ["negpy_core.oetf_encode"] +depends = [] + +["kernel/image/logic.py::_rgb_to_lab_kernel"] +phase = 3 +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.rgb_to_lab"] +depends = [] + +["kernel/image/logic.py::_skin_chroma_rein_kernel"] +phase = 3 +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.skin_chroma_rein"] +depends = [] + +["kernel/image/logic.py::_skin_weight"] +phase = 3 +status = "ported" +calls = ["negpy_core.skin_weight"] +depends = [] + +["kernel/image/logic.py::_to_uint16_jit"] +phase = 3 +status = "ported" +calls = ["negpy_core.to_uint16"] +depends = [] + +["kernel/image/logic.py::_to_uint8_jit"] +phase = 3 +status = "ported" +calls = ["negpy_core.to_uint8"] +depends = [] + +["kernel/image/logic.py::apply_primaries_transform"] +phase = 3 +status = "ported" +calls = [] +depends = ["_matmul_3x3_kernel", "working_oetf_decode", "working_oetf_encode"] + +["kernel/image/logic.py::float_to_uint16"] +phase = 3 +status = "ported" +calls = [] +depends = ["_to_uint16_jit"] + +["kernel/image/logic.py::float_to_uint8"] +phase = 3 +status = "ported" +calls = [] +depends = ["_to_uint8_jit"] + +["kernel/image/logic.py::float_to_uint_luma"] +phase = 3 +status = "ported" +calls = ["negpy_core.luma_to_uint"] +depends = [] + +["kernel/image/logic.py::gamut_aware_chroma_scale"] +phase = 3 +status = "ported" +calls = [] +depends = ["_gamut_aware_chroma_scale_kernel"] + +["kernel/image/logic.py::get_luminance"] +phase = 3 +status = "ported" +calls = ["negpy_core.luminance"] +depends = [] +note = "negpy_core::analysis::luminance" + +["kernel/image/logic.py::lab_to_rgb_working"] +phase = 3 +status = "ported" +compare = "ulp=1" +calls = [] +depends = ["_lab_to_rgb_kernel"] + +["kernel/image/logic.py::rgb_to_lab_working"] +phase = 3 +status = "ported" +compare = "abs=1e-6" +calls = [] +depends = ["_rgb_to_lab_kernel"] + +["kernel/image/logic.py::rgb_to_rgba_into"] +phase = 3 +status = "ported" +calls = ["negpy_core.rgb_to_rgba_into"] +depends = [] + +["kernel/image/logic.py::rgba_to_rgb_into"] +phase = 3 +status = "ported" +calls = ["negpy_core.rgba_to_rgb_into"] +depends = [] + +["kernel/image/logic.py::skin_chroma_rein"] +phase = 3 +status = "ported" +compare = "ulp=1" +calls = [] +depends = ["_skin_chroma_rein_kernel"] + +["kernel/image/logic.py::srgb_to_linear"] +phase = 3 +status = "ported" +compare = "ulp=4" +calls = ["negpy_core.srgb_to_linear"] +depends = [] +note = "negpy_core::color::srgb_to_linear" + +["kernel/image/logic.py::uint16_to_float32"] +phase = 3 +status = "ported" +calls = ["negpy_core.uint16_to_float32"] +depends = [] + +["kernel/image/logic.py::uint8_to_float32"] +phase = 3 +status = "ported" +calls = ["negpy_core.uint8_to_float32"] +depends = [] + +["kernel/image/logic.py::working_oetf_decode"] +phase = 3 +status = "ported" +calls = [] +depends = ["_oetf_decode_flat"] + +["kernel/image/logic.py::working_oetf_encode"] +phase = 3 +status = "ported" +calls = [] +depends = ["_oetf_encode_flat"] + +["features/flatfield/logic.py::_evenness"] +phase = 4 +status = "ported" +calls = ["negpy_core.evenness"] +depends = [] + +["features/flatfield/logic.py::_fill_from_nearest"] +phase = 4 +status = "ported" +calls = ["negpy_core.fill_from_nearest"] +depends = [] + +["features/flatfield/logic.py::_is_clipped"] +phase = 4 +status = "ported" +calls = ["negpy_core.clipped_fraction"] +depends = [] + +["features/flatfield/logic.py::_lit_mask"] +phase = 4 +status = "ported" +calls = ["negpy_core.lit_mask"] +depends = [] + +["features/flatfield/logic.py::apply_flatfield"] +phase = 4 +status = "ported" +calls = ["negpy_core.multiply", "negpy_core.resize_linear"] +depends = [] + +["features/flatfield/logic.py::check_copy"] +phase = 4 +status = "ported" +calls = [] +depends = ["work_copy"] + +["features/flatfield/logic.py::check_reference"] +phase = 4 +status = "ported" +compare = "abs=1e-6" +calls = [] +depends = ["_is_clipped", "check_copy", "evenness", "self_corrected"] + +["features/flatfield/logic.py::compute_gain"] +phase = 4 +status = "ported" +calls = ["negpy_core.compute_gain"] +depends = ["work_copy"] + +["features/flatfield/logic.py::evenness"] +phase = 4 +status = "ported" +calls = [] +depends = ["_evenness"] + +["features/flatfield/logic.py::evenness_view"] +phase = 4 +status = "ported" +calls = ["negpy_core.evenness_view"] +depends = ["_evenness"] + +["features/flatfield/logic.py::self_corrected"] +phase = 4 +status = "ported" +compare = "ulp=4" +calls = ["negpy_core.multiply", "negpy_core.resize_linear"] +depends = [] + +["features/flatfield/logic.py::work_copy"] +phase = 4 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = [] + +["features/lens/logic.py::_edge_reads"] +phase = 4 +status = "stays" +calls = [] +depends = ["_lookup"] +note = "orchestration over Rust warp maps, edge-size math" + +["features/lens/logic.py::_lookup"] +phase = 4 +status = "stays" +calls = ["np.unique"] +depends = [] +note = "orchestration over Rust warp maps, edge-size math" + +["features/lens/logic.py::_search_fill_scale"] +phase = 4 +status = "stays" +calls = [] +depends = ["_edge_reads"] +note = "orchestration over Rust warp maps, edge-size math" + +["features/lens/logic.py::apply_lens"] +phase = 4 +status = "ported" +calls = ["negpy_core.remap"] +depends = ["fill_scale"] + +["features/lens/logic.py::fill_scale"] +phase = 4 +status = "stays" +calls = [] +depends = ["_search_fill_scale"] +note = "orchestration over Rust warp maps, edge-size math" + +["features/lens/warps.py::PanasonicWarp.remap"] +phase = 4 +status = "ported" +calls = ["negpy_core.panasonic_map"] +depends = [] +note = "negpy_core::lens" + +["features/lens/warps.py::RectilinearWarp.remap"] +phase = 4 +status = "ported" +calls = ["negpy_core.rectilinear_ca_map", "negpy_core.rectilinear_map"] +depends = [] + +["features/lens/warps.py::SonyWarp.remap"] +phase = 4 +status = "ported" +calls = ["negpy_core.sony_map"] +depends = [] +note = "negpy_core::lens" + +["features/local/logic.py::_tinted_area"] +phase = 4 +status = "stays" +calls = [] +depends = ["rasterise"] +note = "calls the Rust rasteriser" + +["features/local/logic.py::compute_local_maps"] +phase = 4 +status = "ported" +calls = ["negpy_core.local_maps"] +depends = [] + +["features/local/logic.py::overlapping_masks"] +phase = 4 +status = "ported" +calls = ["negpy_core.alphas_overlap"] +depends = ["_tinted_area"] +note = "negpy_core::shapes::alphas_overlap" + +["features/local/logic.py::rasterise"] +phase = 4 +status = "ported" +calls = ["negpy_core.rasterise_mask"] +depends = [] + +["features/process/capture_color.py::apply_camera_matrix"] +phase = 4 +status = "ported" +calls = ["negpy_core.matmul_3x3"] +depends = [] +note = "negpy_core.matmul_3x3" + +["features/process/capture_color.py::lightbox_level"] +phase = 4 +status = "ported" +calls = ["negpy_core.lightbox_white"] +depends = [] +note = "negpy_core::process::lightbox_white" + +["features/process/chart.py::_patch_medians"] +phase = 4 +status = "ported" +calls = ["negpy_core.chart_patches"] +depends = ["patch_centers", "sample_radius"] +note = "negpy_core::process::chart_patches" + +["features/process/chart.py::detect_orientation"] +phase = 4 +status = "stays" +calls = ["np.mean"] +depends = ["measure_lab"] +note = "24-patch math and orchestration" + +["features/process/chart.py::fit_chart_matrix"] +phase = 4 +status = "stays" +calls = [] +depends = ["detect_orientation", "lab_for", "measure_lab"] +note = "24-patch math and orchestration" + +["features/process/chart.py::fit_chart_matrix.lab_for"] +phase = 4 +status = "stays" +calls = [] +depends = ["measure_lab"] +note = "24-patch math and orchestration" + +["features/process/chart.py::grid_corners"] +phase = 4 +status = "stays" +calls = ["np.argsort"] +depends = [] +note = "24-patch math and orchestration" + +["features/process/chart.py::measure_lab"] +phase = 4 +status = "ported" +calls = ["np.mean"] +depends = ["_patch_medians"] +note = "negpy_core::process::chart_patches" + +["features/process/chart.py::patch_centers"] +phase = 4 +status = "stays" +calls = [] +depends = ["grid_corners"] +note = "24-patch math and orchestration" + +["features/process/chart.py::sample_patches"] +phase = 4 +status = "ported" +calls = [] +depends = ["_patch_medians"] +note = "negpy_core::process::chart_patches" + +["features/process/chart.py::sample_radius"] +phase = 4 +status = "stays" +calls = [] +depends = ["grid_corners"] +note = "24-patch math and orchestration" + +["features/process/hue.py::apply_hue_trim"] +phase = 4 +status = "ported" +calls = ["negpy_core.hue_trim"] +depends = [] + +["features/process/logic.py::detect_process_mode"] +phase = 4 +status = "ported" +calls = ["negpy_core.process_mode_stats"] +depends = [] +note = "negpy_core::process::process_mode_stats" + +["features/process/sensor.py::_density_unmix"] +phase = 4 +status = "ported" +compare = "ulp=16" +calls = [] +depends = ["_log_unmix_kernel", "film_base"] +note = "the 3x3 setup stays NumPy" + +["features/process/sensor.py::_log_unmix_kernel"] +phase = 4 +status = "ported" +calls = ["negpy_core.sensor_log_unmix"] +depends = [] + +["features/process/sensor.py::_two_scale_kernel"] +phase = 4 +status = "ported" +calls = ["negpy_core.sensor_two_scale"] +depends = [] + +["features/process/sensor.py::_two_scale_layer_kernel"] +phase = 4 +status = "ported" +calls = ["negpy_core.sensor_two_scale_layer"] +depends = [] + +["features/process/sensor.py::_two_scale_unmix"] +phase = 4 +status = "ported" +calls = ["negpy_core.gaussian_blur", "negpy_core.resize_area", "negpy_core.resize_linear"] +depends = ["_two_scale_kernel", "_two_scale_layer_kernel", "film_base"] +note = "the 3x3 setup stays NumPy" + +["features/process/sensor.py::_unmix_kernel"] +phase = 4 +status = "ported" +calls = ["negpy_core.sensor_unmix"] +depends = [] + +["features/process/sensor.py::apply_sensor_correction"] +phase = 4 +status = "ported" +compare = "ulp=16" +calls = [] +depends = ["_density_unmix", "_two_scale_unmix", "_unmix_kernel"] +note = "dispatch only" + +["features/process/sensor.py::film_base"] +phase = 4 +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.film_base"] +depends = [] + +["features/retouch/clone.py::apply_clone_stroke"] +phase = 4 +status = "ported" +calls = ["negpy_core.clone_stroke"] +depends = [] + +["features/retouch/clone.py::apply_clone_strokes"] +phase = 4 +status = "ported" +calls = [] +depends = ["apply_clone_stroke"] + +["features/retouch/logic.py::_add_floor_deficit"] +phase = 4 +status = "ported" +calls = ["negpy_core.add_floor_deficit"] +depends = [] + +["features/retouch/logic.py::_blend_fill"] +phase = 4 +status = "ported" +calls = ["negpy_core.blend_fill"] +depends = [] + +["features/retouch/logic.py::_box_mean_std"] +phase = 4 +status = "ported" +calls = ["cv2.blur"] +depends = [] + +["features/retouch/logic.py::_density"] +phase = 4 +status = "stays" +calls = [] +depends = [] +note = "NumPy float32 log10 (SVML on AVX-512); the detection ports take its output as input" + +["features/retouch/logic.py::_erode_resize_bounded"] +phase = 4 +status = "ported" +calls = ["negpy_core.morphology_ex", "negpy_core.resize_area", "np.sum"] +depends = [] + +["features/retouch/logic.py::_fill_rung"] +phase = 4 +status = "ported" +calls = ["negpy_core.fill_rung"] +depends = [] + +["features/retouch/logic.py::_fit_refraction_gammas"] +phase = 4 +status = "ported" +calls = ["negpy_core.fit_refraction_gammas"] +depends = [] + +["features/retouch/logic.py::_hair_encode"] +phase = 4 +status = "ported" +calls = ["negpy_core.hair_encode"] +depends = [] + +["features/retouch/logic.py::_ir_clean_base"] +phase = 4 +status = "ported" +calls = ["negpy_core.ir_clean_base"] +depends = [] + +["features/retouch/logic.py::_ir_decontaminate"] +phase = 4 +status = "ported" +calls = ["negpy_core.ir_ghost_divide", "negpy_core.percentiles"] +depends = [] + +["features/retouch/logic.py::_ir_glare"] +phase = 4 +status = "ported" +calls = ["negpy_core.ir_glare"] +depends = [] + +["features/retouch/logic.py::_ir_live"] +phase = 4 +status = "ported" +calls = ["negpy_core.ir_live"] +depends = [] + +["features/retouch/logic.py::_ir_normalize_ratio"] +phase = 4 +status = "ported" +calls = ["negpy_core.ir_normalize_ratio"] +depends = [] + +["features/retouch/logic.py::_is_hair"] +phase = 4 +status = "ported" +calls = ["negpy_core.distance_transform"] +depends = [] + +["features/retouch/logic.py::_mask_to_score"] +phase = 4 +status = "ported" +calls = ["negpy_core.mask_to_score"] +depends = [] + +["features/retouch/logic.py::_scratch_ridge"] +phase = 4 +status = "ported" +calls = ["negpy_core.scratch_ridge"] +depends = ["_density"] + +["features/retouch/logic.py::_scratch_ridge_rows"] +phase = 4 +status = "ported" +calls = [] +depends = ["_scratch_ridge"] + +["features/retouch/logic.py::_shear_rows"] +phase = 4 +status = "ported" +calls = ["negpy_core.warp_affine"] +depends = [] + +["features/retouch/logic.py::apply_ir_attenuation"] +phase = 4 +status = "ported" +calls = ["negpy_core.multiply", "negpy_core.resize_linear"] +depends = [] + +["features/retouch/logic.py::apply_score_repair"] +phase = 4 +status = "ported" +calls = ["negpy_core.apply_score_repair", "negpy_core.resize_linear"] +depends = [] + +["features/retouch/logic.py::compute_dust_stats"] +phase = 4 +status = "ported" +calls = ["negpy_core.dust_stats", "negpy_core.ellipse_kernel"] +depends = ["_density"] + +["features/retouch/logic.py::detect_luma_score"] +phase = 4 +status = "ported" +calls = ["negpy_core.detect_luma_score", "negpy_core.ellipse_kernel"] +depends = ["compute_dust_stats"] + +["features/retouch/logic.py::downsample_ir"] +phase = 4 +status = "ported" +calls = ["negpy_core.ellipse_kernel", "negpy_core.morphology_ex", "negpy_core.resize_area"] +depends = ["_erode_resize_bounded"] + +["features/retouch/logic.py::drop_exclusions"] +phase = 4 +status = "ported" +calls = ["negpy_core.release_score"] +depends = ["exclusion_cover"] + +["features/retouch/logic.py::exclusion_cover"] +phase = 4 +status = "ported" +calls = ["negpy_core.stroke_cover"] +depends = [] + +["features/retouch/logic.py::ir_defect_score"] +phase = 4 +status = "ported" +calls = ["negpy_core.ir_defect_score"] +depends = [] + +["features/retouch/logic.py::ir_ratio_and_gain"] +phase = 4 +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.ir_gain", "negpy_core.ir_vis_log", "negpy_core.resize_area"] +depends = ["_fit_refraction_gammas", "_ir_clean_base", "_ir_decontaminate", "_ir_glare", "_ir_live", "_ir_normalize_ratio", "normalize_ir"] + +["features/retouch/logic.py::lines_to_score"] +phase = 4 +status = "ported" +calls = ["negpy_core.box_filter"] +depends = ["_mask_to_score", "_scratch_ridge_rows", "_shear_rows"] +note = "negpy_core.box_filter" + +["features/retouch/logic.py::normalize_ir"] +phase = 4 +status = "ported" +calls = ["negpy_core.normalize_ir"] +depends = [] + +["features/retouch/logic.py::repair_components"] +phase = 4 +status = "ported" +calls = ["negpy_core.repair_components", "negpy_core.resize_linear", "negpy_core.resize_nearest"] +depends = [] + +["features/retouch/logic.py::route_wide_defects"] +phase = 4 +status = "ported" +calls = ["negpy_core.ellipse_kernel", "negpy_core.route_wide_defects"] +depends = [] + +["features/retouch/logic.py::score_weighted_fill"] +phase = 4 +status = "ported" +calls = ["cv2.GaussianBlur", "cv2.boxFilter"] +depends = ["_fill_rung"] + +["features/retouch/logic.py::split_hairs"] +phase = 4 +status = "ported" +calls = ["negpy_core.split_hairs"] +depends = [] + +["features/retouch/logic.py::strokes_to_score"] +phase = 4 +status = "ported" +calls = ["negpy_core.heal_region"] +depends = ["_density"] + +["features/retouch/logic.py::trace_scratch"] +phase = 4 +status = "ported" +calls = ["negpy_core.box_filter", "negpy_core.scratch_slope_search", "np.median"] +depends = ["_scratch_ridge_rows", "_shear_rows"] + +["features/hdr/logic.py::_accumulate"] +phase = 5 +status = "ported" +calls = ["negpy_core.hdr_accumulate"] +depends = [] + +["features/hdr/logic.py::_align"] +phase = 5 +status = "ported" +calls = ["negpy_core.mean_channels", "negpy_core.warp_affine"] +depends = [] + +["features/hdr/logic.py::_count_clipped_rows"] +phase = 5 +status = "ported" +calls = ["negpy_core.count_clipped_rows"] +depends = [] + +["features/hdr/logic.py::apply_render_exposure"] +phase = 5 +status = "ported" +calls = ["negpy_core.render_exposure"] +depends = ["output_scale"] + +["features/hdr/logic.py::clipped_fraction"] +phase = 5 +status = "ported" +calls = [] +depends = ["_count_clipped_rows"] +note = "negpy_core.count_clipped_rows" + +["features/hdr/logic.py::measure_exposure"] +phase = 5 +status = "ported" +calls = ["negpy_core.exposure_level"] +depends = ["clipped_fraction"] + +["features/hdr/logic.py::merge_bracket"] +phase = 5 +status = "stays" +calls = [] +depends = ["apply_render_exposure", "merge_providers"] +note = "orchestration or bracket-size math" + +["features/hdr/logic.py::merge_frames"] +phase = 5 +status = "stays" +calls = [] +depends = ["apply_render_exposure", "merge_providers"] +note = "orchestration or bracket-size math" + +["features/hdr/logic.py::merge_providers"] +phase = 5 +status = "ported" +calls = ["negpy_core.finish_merge", "negpy_core.mean_channels"] +depends = ["_accumulate", "_align"] +note = "negpy_core mean_channels, hdr_accumulate, finish_merge" + +["features/hdr/logic.py::output_scale"] +phase = 5 +status = "stays" +calls = ["np.median"] +depends = [] +note = "orchestration or bracket-size math" + +["features/hdr/logic.py::pair_ratio"] +phase = 5 +status = "ported" +calls = ["negpy_core.pair_ratio"] +depends = [] + +["features/hdr/logic.py::probe_exposures"] +phase = 5 +status = "stays" +calls = [] +depends = ["measure_exposure"] +note = "orchestration or bracket-size math" + +["features/hdr/logic.py::seed_shadow_density"] +phase = 5 +status = "stays" +calls = [] +depends = ["shadow_reach_stops"] +note = "orchestration or bracket-size math" + +["features/hdr/logic.py::shadow_reach_stops"] +phase = 5 +status = "stays" +calls = [] +depends = ["output_scale"] +note = "orchestration or bracket-size math" + +["features/hdr/logic.py::solve_ratios"] +phase = 5 +status = "stays" +calls = [] +depends = ["measure_exposure", "pair_ratio", "step"] +note = "orchestration or bracket-size math" + +["features/hdr/logic.py::solve_ratios.step"] +phase = 5 +status = "stays" +calls = [] +depends = ["pair_ratio"] +note = "orchestration or bracket-size math" + +["features/rgbscan/logic.py::_align_to"] +phase = 5 +status = "ported" +calls = ["negpy_core.warp_affine"] +depends = ["estimate_shift"] +note = "negpy_core phase_correlate, warp_affine" + +["features/rgbscan/logic.py::_channel_means"] +phase = 5 +status = "ported" +calls = ["negpy_core.channel_means"] +depends = [] +note = "negpy_core::rgbscan::channel_means" + +["features/rgbscan/logic.py::assemble_rgb"] +phase = 5 +status = "ported" +calls = ["negpy_core.extract_channel", "negpy_core.pick_channels"] +depends = ["_align_to"] +note = "negpy_core pick_channels, extract_channel" + +["features/rgbscan/logic.py::merge_rgb_triplet"] +phase = 5 +status = "stays" +calls = [] +depends = ["assemble_rgb"] +note = "orchestration" + +["features/rgbscan/logic.py::probe_channel_means"] +phase = 5 +status = "stays" +calls = [] +depends = ["probe_frame"] +note = "orchestration" + +["features/rgbscan/logic.py::probe_frame"] +phase = 5 +status = "ported" +calls = ["np.mean"] +depends = ["_channel_means"] +note = "negpy_core::rgbscan::channel_means" + +["features/stitch/logic.py::_seam_weight"] +phase = 5 +status = "ported" +compare = "quality" +calls = [] +depends = [] +note = "negpy_core::stitch::seam_weight, inside seam_blend" + +["features/stitch/logic.py::blend_ir"] +phase = 5 +status = "ported" +calls = ["negpy_core.ir_blend_into", "negpy_core.ir_finish"] +depends = ["warp_into_canvas"] +note = "negpy_core::stitch" + +["features/stitch/logic.py::build_proxy"] +phase = 5 +status = "ported" +calls = ["negpy_core.proxy_stretch", "negpy_core.resize_area"] +depends = [] +note = "negpy_core::stitch" + +["features/stitch/logic.py::feather_blend"] +phase = 5 +status = "ported" +compare = "quality" +calls = ["negpy_core.feather_blend"] +depends = [] + +["features/stitch/logic.py::gain_compensate"] +phase = 5 +status = "ported" +calls = ["negpy_core.compensation_gains", "negpy_core.scale_channels"] +depends = [] +note = "negpy_core::stitch" + +["features/stitch/logic.py::register_parts"] +phase = 5 +status = "stays" +compare = "abs=0.01" +calls = [] +depends = ["build_proxy", "register_pair"] +note = "orchestration of negpy_core kernels" + +["features/stitch/logic.py::seam_blend"] +phase = 5 +status = "ported" +compare = "quality" +calls = ["negpy_core.seam_blend"] +depends = [] + +["features/stitch/logic.py::stitch_composite"] +phase = 5 +status = "ported" +compare = "abs=1e-4" +calls = ["negpy_core.clip_unit", "negpy_core.mask_overlap"] +depends = ["blend_ir", "feather_blend", "find_seams", "gain_compensate", "seam_blend", "warp_into_canvas"] +note = "negpy_core::stitch" + +["features/stitch/logic.py::warp_into_canvas"] +phase = 5 +status = "ported" +calls = ["negpy_core.warp_affine"] +depends = [] + +["features/cyanotype/logic.py::apply_cyanotype"] +phase = 6 +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.cyanotype"] +depends = [] + +["features/cyanotype/processor.py::CyanotypeProcessor.process"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "orchestration" + +["features/exposure/densitometer.py::compute_reading"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "one pixel" + +["features/exposure/logic.py::_apply_print_curve_kernel"] +phase = 6 +status = "ported" +calls = ["numba"] +depends = ["_black_hold", "_fast_sigmoid", "_softplus", "plane_taps", "quadratic_core", "separation_damping_gain", "tone_key_weight"] + +["features/exposure/logic.py::_black_hold"] +phase = 6 +status = "ported" +calls = ["numba"] +depends = [] + +["features/exposure/logic.py::_expanded"] +phase = 6 +status = "ported" +calls = ["negpy_core.expand_plane"] +depends = [] +note = "negpy_core.expand_plane" + +["features/exposure/logic.py::_softplus"] +phase = 6 +status = "ported" +calls = ["numba"] +depends = [] + +["features/exposure/logic.py::apply_characteristic_curve"] +phase = 6 +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.apply_print_curve"] +depends = [] + +["features/exposure/logic.py::contrast_mask_ev"] +phase = 6 +status = "ported" +calls = [] +depends = ["_expanded"] +note = "negpy_core.expand_plane" + +["features/exposure/logic.py::expand_mask_plane"] +phase = 6 +status = "ported" +calls = [] +depends = ["_expanded"] +note = "negpy_core.expand_plane" + +["features/exposure/logic.py::plane_taps"] +phase = 6 +status = "ported" +calls = ["numba"] +depends = [] + +["features/exposure/logic.py::print_curve_output"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "chart samples" + +["features/exposure/logic.py::quadratic_core"] +phase = 6 +status = "ported" +calls = ["numba"] +depends = [] + +["features/exposure/logic.py::tone_key_weight"] +phase = 6 +status = "ported" +calls = ["numba"] +depends = [] + +["features/exposure/placement.py::_pick_knee"] +phase = 6 +status = "stays" +calls = [] +depends = ["predicted_zone"] +note = "scalar solver" + +["features/exposure/placement.py::_solve_density"] +phase = 6 +status = "stays" +calls = [] +depends = ["predicted_zone"] +note = "scalar solver" + +["features/exposure/placement.py::_solve_density.residual"] +phase = 6 +status = "stays" +calls = [] +depends = ["predicted_zone"] +note = "scalar solver" + +["features/exposure/placement.py::_solve_grade_and_density"] +phase = 6 +status = "stays" +calls = [] +depends = ["_solve_density", "predicted_zone"] +note = "scalar solver" + +["features/exposure/placement.py::_solve_grade_and_density.light_residual"] +phase = 6 +status = "stays" +calls = [] +depends = ["_solve_density", "predicted_zone"] +note = "scalar solver" + +["features/exposure/placement.py::_solve_knee_field"] +phase = 6 +status = "stays" +calls = [] +depends = ["predicted_zone"] +note = "scalar solver" + +["features/exposure/placement.py::_solve_knee_field.residual"] +phase = 6 +status = "stays" +calls = [] +depends = ["predicted_zone"] +note = "scalar solver" + +["features/exposure/placement.py::_solve_with_knee"] +phase = 6 +status = "stays" +calls = [] +depends = ["_pick_knee", "_solve_grade_and_density", "_solve_knee_field", "predicted_zone"] +note = "scalar solver" + +["features/exposure/placement.py::_zones_of"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "scalar solver" + +["features/exposure/placement.py::key_edges"] +phase = 6 +status = "stays" +calls = [] +depends = ["_zones_of"] +note = "scalar solver" + +["features/exposure/placement.py::limited_mask_params"] +phase = 6 +status = "stays" +calls = [] +depends = ["key_edges"] +note = "scalar solver" + +["features/exposure/placement.py::predicted_zone"] +phase = 6 +status = "stays" +calls = [] +depends = ["_zones_of"] +note = "scalar solver" + +["features/exposure/placement.py::solve_placement"] +phase = 6 +status = "stays" +calls = [] +depends = ["_solve_density", "_solve_grade_and_density", "_solve_with_knee", "predicted_zone"] +note = "scalar solver" + +["features/exposure/print_lut.py::lut_black_luminance"] +phase = 6 +status = "stays" +calls = [] +depends = ["tetra_lookup"] +note = "one color" + +["features/exposure/print_lut.py::tetra_lookup"] +phase = 6 +status = "ported" +calls = ["negpy_core.tetra_lookup"] +depends = [] + +["features/exposure/processor.py::NormalizationProcessor.process"] +phase = 6 +status = "ported" +compare = "abs=2e-6" +calls = ["negpy_core.log_density_open"] +depends = [] +note = "negpy_core.log_density_open" + +["features/exposure/processor.py::NormalizationProcessor.process.analyze_base"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "orchestration" + +["features/exposure/processor.py::PhotometricProcessor._build_local_maps"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "calls the Rust local maps" + +["features/exposure/processor.py::PhotometricProcessor._process_flat"] +phase = 6 +status = "ported" +calls = ["negpy_core.gray_to_rgb"] +depends = [] +note = "negpy_core.gray_to_rgb" + +["features/exposure/processor.py::PhotometricProcessor.process"] +phase = 6 +status = "ported" +calls = ["negpy_core.add", "negpy_core.channel_any", "negpy_core.extract_channel", "negpy_core.gray_to_rgb"] +depends = [] +note = "negpy_core gray_to_rgb, extract_channel, add, channel_any" + +["features/finish/logic.py::_carrier_block"] +phase = 6 +status = "ported" +compare = "abs=1e-6" +calls = [] +depends = ["carrier_profiles"] + +["features/finish/logic.py::_filed_edge"] +phase = 6 +status = "stays" +calls = ["np.cumsum"] +depends = [] +note = "one cached 1-D profile set" + +["features/finish/logic.py::apply_carrier"] +phase = 6 +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.apply_carrier"] +depends = ["carrier_profiles"] + +["features/finish/logic.py::carrier_profiles"] +phase = 6 +status = "stays" +calls = [] +depends = ["_filed_edge"] +note = "one cached 1-D profile set" + +["features/finish/processor.py::FinishProcessor.process"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "orchestration" + +["features/finish/processor.py::rebate_tone"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "one 1-D strip" + +["features/lab/logic.py::_saturation_block"] +phase = 6 +status = "ported" +calls = ["negpy_core.clip_unit"] +depends = [] +note = "negpy_core Lab kernels, clip_unit" + +["features/lab/logic.py::_usm_sharpening_block"] +phase = 6 +status = "ported" +calls = ["negpy_core.usm_sharpen"] +depends = [] + +["features/lab/logic.py::apply_chroma_denoise"] +phase = 6 +status = "ported" +calls = ["negpy_core.chroma_denoise"] +depends = [] + +["features/lab/logic.py::apply_clahe"] +phase = 6 +status = "ported" +calls = ["negpy_core.clahe"] +depends = [] + +["features/lab/logic.py::apply_glow_and_halation"] +phase = 6 +status = "ported" +calls = ["negpy_core.glow_and_halation"] +depends = [] + +["features/lab/logic.py::apply_resize_sharpening"] +phase = 6 +status = "ported" +calls = [] +depends = ["_usm_sharpening_block"] +note = "negpy_core decode, usm, encode" + +["features/lab/logic.py::apply_resize_sharpening.block"] +phase = 6 +status = "ported" +calls = [] +depends = ["_usm_sharpening_block"] +note = "negpy_core decode, usm, encode" + +["features/lab/logic.py::apply_rl_sharpening"] +phase = 6 +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.rl_sharpen"] +depends = [] + +["features/lab/logic.py::apply_saturation"] +phase = 6 +status = "stays" +calls = [] +depends = ["_saturation_block"] +note = "row-block orchestration" + +["features/lab/logic.py::apply_usm_sharpening"] +phase = 6 +status = "ported" +calls = [] +depends = ["_usm_sharpening_block"] + +["features/lab/processor.py::PhotoLabProcessor.process"] +phase = 6 +status = "ported" +calls = ["negpy_core.clip01"] +depends = [] +note = "negpy_core.clip01" + +["features/lith/logic.py::apply_lith"] +phase = 6 +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.lith"] +depends = [] + +["features/lith/processor.py::LithProcessor.process"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "orchestration" + +["features/sabattier/logic.py::apply_sabattier"] +phase = 6 +status = "ported" +compare = "abs=1e-6" +calls = ["negpy_core.sabattier"] +depends = [] + +["features/sabattier/processor.py::SabattierProcessor.process"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "orchestration" + +["features/toning/logic.py::_apply_chemical_toning_jit"] +phase = 6 +status = "ported" +calls = ["negpy_core.apply_chemical_toning"] +depends = [] + +["features/toning/logic.py::apply_chemical_toning"] +phase = 6 +status = "ported" +calls = [] +depends = ["_apply_chemical_toning_jit"] + +["features/toning/logic.py::apply_split_toning"] +phase = 6 +status = "ported" +calls = ["negpy_core.split_toning"] +depends = [] + +["features/toning/processor.py::ToningProcessor.process"] +phase = 6 +status = "stays" +calls = [] +depends = [] +note = "orchestration" + +["features/transparency/processor.py::TransparencyBaseProcessor.process"] +phase = 6 +status = "ported" +compare = "abs=2e-6" +calls = ["negpy_core.log_density_open", "negpy_core.nan_to_num"] +depends = [] +note = "negpy_core nan_to_num, matmul_3x3, log_density_open" + +["services/rendering/gpu_engine.py::GPUEngine._process_tiled"] +phase = 6 +status = "ported" +compare = "quality" +calls = ["negpy_core.resize_area", "negpy_core.resize_kernel", "negpy_core.resize_linear"] +depends = ["_downsample_for_analysis"] +note = "negpy_core resize_area, resize_kernel" + +["services/rendering/gpu_engine.py::_downsample_for_analysis"] +phase = 6 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = [] +note = "negpy_core.resize_area" + +["services/rendering/image_processor.py::ImageProcessor.render_display_array"] +phase = 6 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = ["_downsample_to_long_edge"] +note = "negpy_core.resize_area" + +["services/rendering/image_processor.py::_downsample_to_long_edge"] +phase = 6 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = [] +note = "negpy_core.resize_area" + +["services/rendering/image_processor.py::_resize_mask"] +phase = 6 +status = "ported" +calls = ["negpy_core.resize_nearest"] +depends = [] +note = "negpy_core.resize_nearest" + +["services/rendering/preview_manager.py::PreviewManager._load_from_open_raw"] +phase = 6 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = [] +note = "negpy_core.resize_area" + +["services/rendering/preview_manager.py::PreviewManager._try_splash_from_open_raw"] +phase = 6 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = [] +note = "negpy_core.resize_area" + +["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_hdr"] +phase = 6 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = ["_load"] +note = "negpy_core.resize_area" + +["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_hdr._load"] +phase = 6 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = [] +note = "negpy_core.resize_area" + +["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_rgb"] +phase = 6 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = ["_match"] +note = "negpy_core.resize_area" + +["services/rendering/preview_manager.py::PreviewManager.load_linear_preview_rgb._match"] +phase = 6 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = [] +note = "negpy_core.resize_area" + +["desktop/controller.py::AppController._paint_flatfield_peek"] +phase = 7 +status = "ported" +calls = ["negpy_core.resize_linear"] +depends = [] +note = "calls negpy_core" + +["desktop/controller.py::_interactive_proxy"] +phase = 7 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = [] +note = "negpy_core.resize_area" + +["desktop/view/canvas/overlay.py::CanvasOverlay._ir_layer_qimage"] +phase = 7 +status = "ported" +calls = ["negpy_core.remap"] +depends = [] + +["desktop/view/canvas/overlay.py::CanvasOverlay._mask_wash_qimage"] +phase = 7 +status = "ported" +calls = ["negpy_core.remap"] +depends = [] + +["desktop/view/canvas/overlay.py::CanvasOverlay._zone_outline"] +phase = 7 +status = "ported" +calls = ["negpy_core.contour_outlines"] +depends = [] +note = "negpy_core.contour_outlines" + +["desktop/view/sidebar/files.py::FileBrowser._measure_rebate"] +phase = 7 +status = "stays" +calls = ["np.mean"] +depends = ["print_dmax"] +note = "per-frame tone list" + +["desktop/view/sidebar/files.py::print_dmax"] +phase = 7 +status = "ported" +calls = ["negpy_core.print_dmax"] +depends = [] +note = "negpy_core.print_dmax" + +["desktop/view/sidebar/scanlight.py::ScanlightSidebar._current_raw_clip"] +phase = 7 +status = "ported" +calls = ["np.mean"] +depends = [] +note = "negpy_core.mean_channels" + +["desktop/view/sidebar/scanlight.py::_RawClip.at"] +phase = 7 +status = "ported" +calls = ["negpy_core.resize_nearest"] +depends = [] +note = "calls negpy_core" + +["desktop/view/widgets/charts.py::VectorscopeWidget._trace_image"] +phase = 7 +status = "stays" +calls = ["np.percentile"] +depends = [] +note = "scope bin grid" + +["desktop/view/widgets/charts.py::WaveformWidget._trace_images"] +phase = 7 +status = "stays" +calls = ["np.percentile"] +depends = [] +note = "scope bin grid" + +["desktop/view/widgets/prescan_dialog.py::_preview_u8"] +phase = 7 +status = "ported" +calls = ["negpy_core.stretch_preview"] +depends = [] +note = "negpy_core.stretch_preview" + +["desktop/view/widgets/scan_preview_common.py::preview_positive"] +phase = 7 +status = "ported" +calls = ["negpy_core.stretch_preview"] +depends = [] +note = "negpy_core.stretch_preview" + +["desktop/view/widgets/sensor_calibration_dialog.py::SensorCalibrationDialog._compute_and_save"] +phase = 7 +status = "ported" +calls = ["negpy_core.any_channel_at_least"] +depends = [] +note = "negpy_core.any_channel_at_least" + +["desktop/workers/chart_fit.py::ChartFitWorker.__init__"] +phase = 7 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = [] +note = "negpy_core.resize_area" + +["desktop/workers/export.py::_looks_border_crushed"] +phase = 7 +status = "ported" +calls = ["negpy_core.mean_channels", "negpy_core.ring_inner_medians"] +depends = [] +note = "negpy_core mean_channels, ring_inner_medians" + +["infrastructure/display/icc_lut.py::_apply_lut_f32_jit"] +phase = 7 +status = "ported" +calls = ["numba"] +depends = [] + +["infrastructure/display/icc_lut.py::_apply_lut_u16_jit"] +phase = 7 +status = "ported" +calls = ["numba"] +depends = [] + +["infrastructure/display/icc_lut.py::_matrix_trc_u8_jit"] +phase = 7 +status = "ported" +calls = ["numba"] +depends = [] + +["infrastructure/display/icc_lut.py::apply_lut_f32"] +phase = 7 +status = "ported" +calls = ["negpy_core.apply_lut_f32"] +depends = [] +note = "calls negpy_core" + +["infrastructure/display/icc_lut.py::apply_lut_u16"] +phase = 7 +status = "ported" +calls = ["negpy_core.apply_lut_u16"] +depends = [] +note = "calls negpy_core" + +["infrastructure/display/icc_lut.py::apply_matrix_trc_u8"] +phase = 7 +status = "ported" +calls = ["negpy_core.matrix_trc_u8"] +depends = [] +note = "calls negpy_core" + +["services/assets/half_frame.py::_edge_band"] +phase = 7 +status = "stays" +calls = ["np.median"] +depends = [] +note = "1-D edge profile" + +["services/assets/half_frame.py::_gutter_scan"] +phase = 7 +status = "ported" +calls = ["negpy_core.column_means", "np.median"] +depends = [] +note = "negpy_core.column_means" + +["services/assets/half_frame.py::_luma"] +phase = 7 +status = "ported" +calls = ["negpy_core.mean_channels"] +depends = [] +note = "negpy_core.mean_channels" + +["services/assets/semantic_model.py::rank_by_similarity"] +phase = 7 +status = "stays" +calls = ["np.argsort"] +depends = [] +note = "embedding vectors" + +["services/assets/thumbnails.py::preview_positive"] +phase = 7 +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.log_stretch_u8"] +depends = [] +note = "negpy_core srgb_to_linear, log_stretch_u8" + +["services/capture/calibration.py::_ladder_stops"] +phase = 7 +status = "stays" +calls = ["np.median"] +depends = [] +note = "shutter ladder values" + +["services/capture/calibration.py::clip_fraction"] +phase = 7 +status = "ported" +calls = ["negpy_core.patch_level_stats"] +depends = [] +note = "negpy_core.patch_level_stats" + +["services/capture/calibration.py::meter_base"] +phase = 7 +status = "ported" +calls = ["negpy_core.patch_level_stats"] +depends = [] +note = "negpy_core.patch_level_stats" + +["services/capture/focus_meter.py::sharpness"] +phase = 7 +status = "ported" +calls = ["negpy_core.sharpness"] +depends = [] +note = "negpy_core.sharpness" + +["services/capture/meter.py::_peak_map"] +phase = 7 +status = "ported" +calls = ["negpy_core.peak_map"] +depends = [] +note = "negpy_core.peak_map" + +["services/capture/meter.py::framing_mismatch"] +phase = 7 +status = "ported" +calls = ["np.mean"] +depends = ["gray_of", "ranked_thumb"] +note = "negpy_core.mean_channels" + +["services/capture/meter.py::gray_of"] +phase = 7 +status = "ported" +calls = ["negpy_core.mean_channels"] +depends = [] +note = "negpy_core.mean_channels" + +["services/capture/meter.py::meter_frame"] +phase = 7 +status = "ported" +calls = ["negpy_core.meter_picture", "negpy_core.normalized", "np.median"] +depends = ["_peak_map", "gray_of", "ranked_thumb"] +note = "negpy_core normalized, meter_picture, peak_map" + +["services/capture/meter.py::ranked_thumb"] +phase = 7 +status = "stays" +calls = ["negpy_core.resize_area", "np.argsort"] +depends = [] +note = "2752-value rank on a Rust resize" + +["services/export/contact_sheet.py::_place_tile"] +phase = 7 +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.resize_u8"] +depends = [] +note = "negpy_core.resize_u8" + +["services/export/encoders.py::encode_jpeg"] +phase = 7 +status = "ported" +compare = "quality" +calls = ["negpy_core.encode_jpeg"] +depends = [] + +["services/export/linear_output.py::_apply_ice"] +phase = 7 +status = "ported" +calls = ["negpy_core.resize_area"] +depends = [] +note = "negpy_core.resize_area" + +["services/export/print.py::PrintService.apply_layout"] +phase = 7 +status = "ported" +compare = "quality" +calls = ["negpy_core.resize_area", "negpy_core.resize_kernel"] +depends = [] +note = "negpy_core resize_area, resize_kernel" + +["services/scanning/writer.py::_to_grey"] +phase = 7 +status = "ported" +calls = ["negpy_core.to_grey"] +depends = [] +note = "negpy_core.to_grey" + +["services/view/coordinate_mapping.py::CoordinateMapping._grid_homography"] +phase = 7 +status = "ported" +calls = ["negpy_core.perspective_transform"] +depends = [] +note = "calls negpy_core" + +["services/view/coordinate_mapping.py::CoordinateMapping.create_uv_grid"] +phase = 7 +status = "ported" +calls = ["negpy_core.rotation_matrix"] +depends = [] +note = "calls negpy_core" + +["features/geometry/skew.py::_coarse_angles"] +phase = 9 +status = "ported" +compare = "quality" +calls = ["negpy_core.coarse_angles"] +depends = [] + +["features/retouch/logic.py::apply_hair_inpaint"] +phase = 9 +status = "ported" +compare = "quality" +calls = ["negpy_core.add_clean_grain", "negpy_core.ellipse_kernel", "negpy_core.hair_fill", "negpy_core.morphology_ex", "negpy_core.resize_linear"] +depends = [] + +["features/rgbscan/logic.py::estimate_shift"] +phase = 9 +status = "ported" +compare = "quality" +calls = ["negpy_core.phase_correlate", "negpy_core.resize_area"] +depends = [] +note = "negpy_core::phase::phase_correlate" + +["features/stitch/logic.py::find_seams"] +phase = 9 +status = "ported" +compare = "quality" +calls = ["negpy_core.dp_seams", "negpy_core.resize_area", "negpy_core.resize_nearest"] +depends = [] + +["features/stitch/logic.py::register_pair"] +phase = 9 +status = "ported" +compare = "quality" +calls = ["negpy_core.register_pair"] +depends = [] +note = "negpy_core::register::register_pair, SIFT in negpy_core::sift" + +["infrastructure/capture/raw_demosaic.py::raw_channel_clip_fraction"] +phase = 9 +status = "ported" +calls = ["negpy_core.raw_clip_fractions"] +depends = [] +note = "negpy_core.raw_clip_fractions" + +["infrastructure/scanners/sane_backend.py::_align_ir_to_rgb"] +phase = 9 +status = "ported" +compare = "quality" +calls = ["negpy_core.mean_channels", "negpy_core.phase_correlate", "negpy_core.resize_area"] +depends = [] +note = "negpy_core phase_correlate, mean_channels" + +["infrastructure/scanners/sane_backend.py::_resample_rows_to_dpi"] +phase = 9 +status = "ported" +compare = "ulp=1" +calls = ["negpy_core.resize_linear", "negpy_core.resize_rows_linear"] +depends = [] +note = "negpy_core.resize_rows_linear" diff --git a/negpy_core/pyproject.toml b/negpy_core/pyproject.toml new file mode 100644 index 000000000..9b9dd97de --- /dev/null +++ b/negpy_core/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "negpy-core" +version = "0.1.0" +description = "NegPy's native CPU and I/O kernels." +license = "GPL-3.0" +requires-python = ">=3.13" +dependencies = ["numpy"] + +[build-system] +requires = ["maturin>=1.9,<2"] +build-backend = "maturin" + +[tool.maturin] +features = ["python"] +module-name = "negpy_core" + +[tool.uv] +cache-keys = [{ file = "pyproject.toml" }, { file = "Cargo.toml" }, { file = "Cargo.lock" }, { file = "src/**/*.rs" }] diff --git a/negpy_core/rust-toolchain.toml b/negpy_core/rust-toolchain.toml new file mode 100644 index 000000000..454825c92 --- /dev/null +++ b/negpy_core/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.99" +components = ["rustfmt", "clippy"] diff --git a/negpy_core/src/altprocess.rs b/negpy_core/src/altprocess.rs new file mode 100644 index 000000000..fe9bb531b --- /dev/null +++ b/negpy_core/src/altprocess.rs @@ -0,0 +1,297 @@ +//! The alternative processes of negpy/features/{sabattier,cyanotype,lith}/logic.py, in NumPy's +//! float32 order. The transcendentals are `mathf`'s, correctly rounded or nearly; NumPy's +//! float32 ones run SVML or its own SIMD code, whose bits differ by CPU. + +// The steps index several per-pixel arrays of one run in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::color::{self, LabSpace}; +use crate::filter::{self, Border}; +use crate::mathf::{cbrtf, exp_f64, exp10_f64, log1p_f64, log10f}; + +/// `np.clip(v, lo, hi)`: a NaN comes through. +#[inline(always)] +fn clip(v: f32, lo: f32, hi: f32) -> f32 { + let m = if v.is_nan() || v > lo { v } else { lo }; + if m.is_nan() || m < hi { m } else { hi } +} + +#[inline(always)] +fn expf(x: f32) -> f32 { + exp_f64(x as f64) as f32 +} + +/// `np.power(np.float32(10.0), x)`. +#[inline(always)] +fn exp10f(x: f32) -> f32 { + exp10_f64(x as f64) as f32 +} + +/// Pixels per pass: each step runs as a flat loop over a run, so it vectorizes. +const RUN: usize = 256; + +/// The print density of each pixel of a run: `-log10` of the clipped Rec. 709 luma. +#[inline(always)] +fn density_run(px: &[[f32; 3]], out: &mut [f32]) { + for (o, p) in out.iter_mut().zip(px) { + let c = |v: f32| clip(v, 1e-6, 1.0); + let luma = c(p[0]) * 0.2126 + c(p[1]) * 0.7152 + c(p[2]) * 0.0722; + *o = -log10f(clip(luma, 1e-6, 1.0)); + } +} + +/// `np.interp(x, xp, fp)` of float32 anchors, in float64: the slope of each segment, then +/// slope * (x - xp[j]) + fp[j]; the ends hold, and a NaN comes through. +#[derive(Clone, Copy)] +pub struct Interp { + xp: [f64; 4], + fp: [f64; 4], + slope: [f64; 3], +} + +impl Interp { + pub fn new(xp: [f32; 4], fp: [f32; 4]) -> Self { + let (xp, fp) = (xp.map(|v| v as f64), fp.map(|v| v as f64)); + let slope = std::array::from_fn(|j| (fp[j + 1] - fp[j]) / (xp[j + 1] - xp[j])); + Self { xp, fp, slope } + } + + #[inline(always)] + fn at(&self, x: f32) -> f32 { + let (xp, fp, sl) = (self.xp, self.fp, self.slope); + let x = x as f64; + let (s, x0, f0) = if x < xp[1] { + (sl[0], xp[0], fp[0]) + } else if x < xp[2] { + (sl[1], xp[1], fp[1]) + } else { + (sl[2], xp[2], fp[2]) + }; + let v = s * (x - x0) + f0; + let v = if x >= xp[3] { fp[3] } else { v }; + (if x < xp[0] { fp[0] } else { v }) as f32 + } +} + +/// L* of a linear grey level (D65, Yn = 1), the CIELAB constants as float32. +#[inline(always)] +fn lightness(grey: f32, eps: f32, kappa: f32) -> f32 { + let c = cbrtf(grey); + let lin = kappa * grey + (16.0f64 / 116.0) as f32; + 116.0 * (if grey > eps { c } else { lin }) - 16.0 +} + +/// Lab of each pixel from `block`, then clipped working RGB. +fn print_lab( + src: &[f32], + block: impl Fn(&[[f32; 3]], &mut [[f32; 3]]) + Sync, + to_rgb: LabSpace, + dst: &mut [f32], +) { + let mut lab = vec![0f32; src.len()]; + lab.par_chunks_mut(3 * 4096) + .zip(src.par_chunks(3 * 4096)) + .for_each(|(o, s)| block(s.as_chunks::<3>().0, o.as_chunks_mut::<3>().0)); + color::lab_to_rgb(&lab, dst, to_rgb); + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); +} + +/// The scalars of `apply_sabattier`; float32 ones are rounded as NumPy rounds them. +pub struct Sabattier { + /// `np.float32(reexposure * d_max)`. + pub re: f32, + pub fold_width: f32, + pub edge_width: f32, + pub strength: f32, + pub d_max: f32, +} + +/// Density and reversal of a block; with `developed`, the silver the bromide mask reads. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn sabattier_block( + px: &[[f32; 3]], + s: &Sabattier, + dens: &mut [f32], + rev: &mut [f32], + developed: Option<&mut [f32]>, +) { + density_run(px, dens); + for (r, &d) in rev.iter_mut().zip(dens.iter()) { + let x = clip((s.re - d) / s.fold_width, -30.0, 30.0); + *r = s.strength * (s.fold_width * log1p_f64(expf(x) as f64) as f32); + } + if let Some(dev) = developed { + for (o, &d) in dev.iter_mut().zip(dens.iter()) { + *o = 1.0 / (1.0 + expf(-clip((d - s.re) / s.edge_width, -30.0, 30.0))); + } + } +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn sabattier_out(dens: &[f32], rev: &[f32], d_max: f32, out: &mut [[f32; 3]]) { + for ((o, &d), &r) in out.iter_mut().zip(dens).zip(rev) { + let v = d + r; + let m = if v.is_nan() || v < d_max { v } else { d_max }; + *o = [clip(exp10f(-m), 0.0, 1.0); 3]; + } +} + +/// `apply_sabattier` once enabled; `k` holds the Mackie-line taps (empty: no line). +pub fn sabattier( + src: &[f32], + (h, w): (usize, usize), + s: &Sabattier, + k: &[f32], + dst: &mut [f32], +) -> Option<()> { + let n = h * w; + let (mut dens, mut rev) = (vec![0f32; n], vec![0f32; n]); + let px = src.as_chunks::<3>().0; + let line = !k.is_empty(); + let mut developed = vec![0f32; if line { n } else { 0 }]; + let blocks = dens + .par_chunks_mut(RUN) + .zip(rev.par_chunks_mut(RUN)) + .zip(px.par_chunks(RUN)); + if line { + blocks + .zip(developed.par_chunks_mut(RUN)) + .for_each(|(((d, r), p), dev)| sabattier_block(p, s, d, r, Some(dev))); + } else { + blocks.for_each(|((d, r), p)| sabattier_block(p, s, d, r, None)); + } + if line { + let mut bromide = vec![0f32; n]; + filter::sep_filter2d( + &developed, + &mut bromide, + (h, w, 1), + (k, k), + Border::Replicate, + )?; + rev.par_iter_mut() + .zip(bromide.par_iter()) + .for_each(|(r, &b)| *r *= 1.0 - b); + } + dst.as_chunks_mut::<3>() + .0 + .par_chunks_mut(RUN) + .zip(dens.par_chunks(RUN).zip(rev.par_chunks(RUN))) + .for_each(|(o, (d, r))| sabattier_out(d, r, s.d_max, o)); + Some(()) +} + +/// The scalars of `apply_cyanotype`, float32 as NumPy rounds them. +pub struct Cyanotype { + pub d_max: f32, + /// `np.float32(0.301 * exposure)` and `np.float32(max(scale, 0.1))`. + pub over: f32, + pub scale: f32, + pub mid_compress: f32, + pub bleach: f32, + pub bleach_floor: f32, + pub tannin: f32, + pub tannin_restore: f32, + pub tannin_dmax_gain: f32, + pub brown: [f32; 2], + pub a_path: Interp, + pub b_path: Interp, + pub lab_eps: f32, + pub lab_kappa: f32, +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn cyanotype_block(px: &[[f32; 3]], c: &Cyanotype, out: &mut [[f32; 3]]) { + let (mut base, mut dens, mut frac) = ([0f32; RUN], [0f32; RUN], [0f32; RUN]); + for (px, out) in px.chunks(RUN).zip(out.chunks_mut(RUN)) { + let n = px.len(); + density_run(px, &mut base[..n]); + for i in 0..n { + let t = clip((base[i] + c.over) / c.scale, 0.0, 1.0); + let m = c.mid_compress; + let v = 2.0 * t - 1.0; + let u0 = (1.0 - m) * t + m * 0.5 * (1.0 + v * v.abs()); + let u_b = u0 * (1.0 - c.bleach * (1.0 - c.bleach_floor * u0)); + let u = u_b + c.tannin * (u0 * c.tannin_restore - u_b); + dens[i] = c.d_max * (1.0 + c.tannin * c.tannin_dmax_gain) * u; + frac[i] = clip(u, 0.0, 1.0); + } + for i in 0..n { + out[i][0] = lightness(exp10f(-dens[i]), c.lab_eps, c.lab_kappa); + } + for i in 0..n { + let (f, a, b) = (frac[i], c.a_path.at(frac[i]), c.b_path.at(frac[i])); + out[i][1] = a + c.tannin * (c.brown[0] * f - a); + out[i][2] = b + c.tannin * (c.brown[1] * f - b); + } + } +} + +/// `apply_cyanotype` once enabled. +pub fn cyanotype(src: &[f32], c: &Cyanotype, to_rgb: LabSpace, dst: &mut [f32]) { + print_lab(src, |px, out| cyanotype_block(px, c, out), to_rgb, dst); +} + +/// The scalars of `apply_lith`, float32 as NumPy rounds them. +pub struct Lith { + pub d_max: f32, + /// `np.float32(0.301 * exposure)`. + pub over: f32, + pub foot_max: f32, + pub foot_veil: f32, + pub foot_rate: f32, + pub knee: f32, + pub width: f32, + pub a_path: Interp, + pub b_path: Interp, + pub lab_eps: f32, + pub lab_kappa: f32, +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn lith_block(px: &[[f32; 3]], c: &Lith, out: &mut [[f32; 3]]) { + let (mut base, mut dens) = ([0f32; RUN], [0f32; RUN]); + for (px, out) in px.chunks(RUN).zip(out.chunks_mut(RUN)) { + let n = px.len(); + density_run(px, &mut base[..n]); + for i in 0..n { + let d0 = base[i] + c.over; + let d_foot = base[i] + c.over * c.foot_veil; + let d_h = c.foot_max * (1.0 - expf(-c.foot_rate * d_foot / c.foot_max)); + let g = 1.0 / (1.0 + expf(-clip((d0 - c.knee) / c.width, -30.0, 30.0))); + dens[i] = d_h + (c.d_max - d_h) * g; + } + for i in 0..n { + out[i][0] = lightness(exp10f(-dens[i]), c.lab_eps, c.lab_kappa); + } + for i in 0..n { + let frac = clip(dens[i] / c.d_max, 0.0, 1.0); + out[i][1] = c.a_path.at(frac); + out[i][2] = c.b_path.at(frac); + } + } +} + +/// `apply_lith` once enabled. +pub fn lith(src: &[f32], c: &Lith, to_rgb: LabSpace, dst: &mut [f32]) { + print_lab(src, |px, out| lith_block(px, c, out), to_rgb, dst); +} diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs new file mode 100644 index 000000000..638a52cab --- /dev/null +++ b/negpy_core/src/analysis.rs @@ -0,0 +1,1807 @@ +//! Ports of the frame analysis in negpy/features/geometry/logic.py and exposure/normalization.py. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::filter::Border; +use crate::stats::{np_median, np_percentiles, pairwise_sum_f32, percentiles}; + +const LUMA_R: f64 = 0.2126; +const LUMA_G: f64 = 0.7152; +const LUMA_B: f64 = 0.0722; + +/// `kernel.image.logic.get_luminance` of an HxWx3 float32 image: Numba's fastmath kernel, +/// f64 with fused multiply-adds, then float32. +pub fn luminance(rgb: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(4096) + .zip(rgb.par_chunks(4096 * 3)) + .for_each(|(d, p)| luminance_chunk(p, d)); +} + +/// `mul_add` is one instruction only where the CPU has FMA, so this compiles twice. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +pub(crate) fn luminance_chunk(rgb: &[f32], dst: &mut [f32]) { + for (d, p) in dst.iter_mut().zip(rgb.as_chunks::<3>().0) { + let t = LUMA_R * p[0] as f64; + let t = LUMA_G.mul_add(p[1] as f64, t); + *d = LUMA_B.mul_add(p[2] as f64, t) as f32; + } +} + +/// An HxW plane as HxWx3, or an HxWx3 image as it is. +pub fn rgb(img: &[f32], cn: usize) -> std::borrow::Cow<'_, [f32]> { + if cn == 1 { + img.iter() + .flat_map(|&v| [v, v, v]) + .collect::>() + .into() + } else { + img.into() + } +} + +/// `_detection_luma` of the luminance of an HxW or HxWx3 image whose `memo::key` is `key`; +/// None for an empty one. +pub fn detection_plane( + img: &[f32], + h: usize, + w: usize, + cn: usize, + key: u128, +) -> Option>> { + static LAST: crate::memo::Memo>>, 4> = crate::memo::Memo::new(); + LAST.get_or(key, || { + let mut lum = vec![0f32; h * w]; + luminance(&rgb(img, cn), &mut lum); + let mut det = vec![0f32; h * w]; + detection_luma(&lum, &mut det)?; + Some(det.into()) + }) +} + +/// `_normalize_to_uint8` for a float32 image: the 1st and 99th percentiles of its finite +/// values stretched to 0..255, NumPy's float32 arithmetic (NEP 50) and truncating cast. +pub fn normalize_to_uint8(img: &[f32], dst: &mut [u8]) { + let finite = img.iter().fold(true, |all, v| all & v.is_finite()); + let valid: std::borrow::Cow<[f32]> = if finite { + img.into() + } else { + img.iter().copied().filter(|v| v.is_finite()).collect() + }; + let Some(p) = percentiles(&valid, &[1.0, 99.0]) else { + dst.fill(0); + return; + }; + let low = p[0] as f64; + let high = if p[1] as f64 <= low { + low + 1.0 + } else { + p[1] as f64 + }; + let (low32, scale32) = (low as f32, (255.0 / (high - low)) as f32); + crate::par_zip(dst, img, move |d, &v| { + let s = ((v - low32) * scale32).clamp(0.0, 255.0); + // NumPy's float32 to uint8 cast truncates, NaN to 0 on x86; Rust's `as` does the same. + *d = s as u8; + }); +} + +/// `_detection_luma` of a float32 luminance plane: divided by its 99.5th percentile and +/// clipped to 0..2, in float32. +pub fn detection_luma(lum: &[f32], dst: &mut [f32]) -> Option<()> { + // Python's max(nan, 1e-6) keeps the NaN. + let p = np_percentiles(lum, &[99.5])?[0] as f64; + let anchor = if p.is_nan() { + f32::NAN + } else { + p.max(1e-6) as f32 + }; + crate::par_zip(dst, lum, move |d, &v| { + *d = (v / anchor).clamp(0.0, 2.0); + }); + Some(()) +} + +/// `to_log_density`: log10 of the value clamped to 1e-6..1, NaN to 1e-6. +pub fn to_log_density(img: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(1 << 14) + .zip(img.par_chunks(1 << 14)) + .for_each(|(d, s)| log_density_block(s, d)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +#[allow(clippy::manual_clamp)] +fn log_density_block(src: &[f32], dst: &mut [f32]) { + for (d, &v) in dst.iter_mut().zip(src) { + // max drops NaN, as np.fmax does; clamp would keep it. + *d = crate::mathf::log10f(v.max(1e-6).min(1.0)); + } +} + +/// The normalization processors' log density: log10 of the value clamped below at 1e-6, with +/// NaN and -inf at 1e-6 and +inf at 1 (`np.nan_to_num`), no upper clamp. +pub fn log_density_open(img: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(1 << 14) + .zip(img.par_chunks(1 << 14)) + .for_each(|(d, s)| log_density_open_block(s, d)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn log_density_open_block(src: &[f32], dst: &mut [f32]) { + for (d, &v) in dst.iter_mut().zip(src) { + let v = if v == f32::INFINITY { 1.0 } else { v }; + // max drops NaN, as nan_to_num then np.clip do. + *d = crate::mathf::log10f(v.max(1e-6)); + } +} + +/// `normalize_log_image`: `(v - floor) / (ceil - floor)` per channel of an HxWx3 log image, +/// unclamped. A span under 1e-6 divides by +-1e-6, by the span's sign. +pub fn normalize_log_image(img: &[f32], floors: [f32; 3], ceils: [f32; 3], dst: &mut [f32]) { + let denom = std::array::from_fn::(|c| { + let d = ceils[c] - floors[c]; + // Numba types the span float64 here; float32 division of the difference rounds the same. + if (d.abs() as f64) < 1e-6 { + if d >= 0.0 { 1e-6 } else { -1e-6 } + } else { + d + } + }); + crate::par_zip( + dst.as_chunks_mut::<3>().0, + img.as_chunks::<3>().0, + move |d, p| { + for c in 0..3 { + d[c] = ((p[c] - floors[c]) as f64 / denom[c] as f64) as f32; + } + }, + ); +} + +/// `_block_median_grid` for block size `b` over the whole `b` x `b` blocks of an HxWxC image: +/// the median of each block per channel, (H/b)x(W/b)xC. A block of 2x2 is (sum - min - max) / 2, +/// the sum in float64. +pub fn block_median_grid(img: &[f32], (h, w, c): (usize, usize, usize), b: usize) -> Vec { + let (gh, gw) = (h / b, w / b); + let mut out = vec![0f32; gh * gw * c]; + if gh == 0 || gw == 0 { + return out; + } + let at = |y: usize, x: usize, ch: usize| img[(y * w + x) * c + ch]; + out.par_chunks_mut(gw * c) + .enumerate() + .for_each(|(gy, line)| { + let mut block = Vec::with_capacity(b * b); + for gx in 0..gw { + for ch in 0..c { + let (y, x) = (gy * b, gx * b); + line[gx * c + ch] = if b == 2 { + let (p00, p01, p10, p11) = ( + at(y, x, ch), + at(y, x + 1, ch), + at(y + 1, x, ch), + at(y + 1, x + 1, ch), + ); + let s = p00 as f64 + p01 as f64 + p10 as f64 + p11 as f64; + let mn = np_min(np_min(p00, p01), np_min(p10, p11)); + let mx = np_max(np_max(p00, p01), np_max(p10, p11)); + ((s - mn as f64 - mx as f64) * 0.5) as f32 + } else { + block.clear(); + for yy in y..y + b { + block.extend((x..x + b).map(|xx| at(yy, xx, ch))); + } + np_median(&mut block) + }; + } + } + }); + out +} + +/// np.minimum with NaN propagation. +fn np_min(a: f32, b: f32) -> f32 { + if a.is_nan() || b.is_nan() { + f32::NAN + } else { + a.min(b) + } +} + +/// `_get_threshold_autocrop_coords` on an HxW luma plane: the first and last rows and columns +/// whose float32 mean is under `threshold` (row means pairwise, column means summed down in +/// order, as NumPy sums them), or None with fewer than ten of either. +pub fn threshold_box( + lum: &[f32], + h: usize, + w: usize, + threshold: f32, +) -> Option<(usize, usize, usize, usize)> { + let rows: Vec = (0..h) + .filter(|&y| pairwise_sum_f32(&lum[y * w..(y + 1) * w]) / (w as f32) < threshold) + .collect(); + let mut sums = vec![0f32; w]; + for row in lum.chunks_exact(w) { + sums.iter_mut().zip(row).for_each(|(s, &v)| *s += v); + } + let cols: Vec = (0..w) + .filter(|&x| sums[x] / (h as f32) < threshold) + .collect(); + if rows.len() < 10 || cols.len() < 10 { + return None; + } + Some((rows[0], rows[rows.len() - 1], cols[0], cols[cols.len() - 1])) +} + +/// `_vertical_edge_profile` of batch autocrop for an HxW or HxWxC float32 image: the 80th +/// percentile of each column's |Sobel x| on the 2..98 percentile stretch of the channel mean, +/// blurred along the row and scaled to its peak, and its 99.5 - 50 percentile contrast. +pub fn batch_edge_profile(img: &[f32], h: usize, w: usize, cn: usize) -> (Vec, f64) { + let gray: Vec = if cn == 1 { + img.to_vec() + } else { + img.chunks_exact(cn) + .map(|p| (p[0] + p[1] + p[2]) / 3.0) + .collect() + }; + let finite: Vec = gray.iter().copied().filter(|v| v.is_finite()).collect(); + if finite.is_empty() || w < 2 { + return (vec![0.0; w], 0.0); + } + let p = crate::stats::np_percentiles_f64(&finite, &[2.0, 98.0]); + let (low, span) = (p[0], (p[1] - p[0]).max(1e-6)); + let normalized: Vec = gray + .iter() + .map(|&v| ((v as f64 - low) / span).clamp(0.0, 1.0) as f32) + .collect(); + let mut grad = vec![0f32; h * w]; + let _ = crate::filter::sep_filter2d( + &normalized, + &mut grad, + (h, w, 1), + (&[-1.0, 0.0, 1.0], &[1.0, 2.0, 1.0]), + Border::Reflect101, + ); + let columns: Vec = (0..w) + .into_par_iter() + .map(|x| { + let col: Vec = (0..h).map(|y| grad[y * w + x].abs()).collect(); + np_percentiles(&col, &[80.0]).map_or(f32::NAN, |p| p[0]) + }) + .collect(); + let window = { + let k = 5.max((w as f64 * 0.012).round_ties_even() as usize); + if k % 2 == 0 { k + 1 } else { k } + }; + let mut profile = vec![0f32; w]; + let _ = crate::filter::gaussian_blur( + &columns, + &mut profile, + (1, w, 1), + (window, 1), + (0.0, 0.0), + Border::Reflect101, + ); + let peak = profile.iter().copied().fold(f32::NEG_INFINITY, np_max); + let pct = |q: f64| np_percentiles(&profile, &[q]).map_or(f32::NAN, |p| p[0]) as f64; + let contrast = 0f64.max(pct(99.5) - pct(50.0)); + if peak > 0.0 { + profile.iter_mut().for_each(|v| *v /= peak); + } + (profile, contrast) +} + +/// The end of `measure_base_log`: per-channel log10 of the base blocks' median, or None when +/// the base is not brighter than the picture inside `gate` (median of the float32 pixel means, +/// summed left to right). +pub fn base_log_from_blocks( + det: &[f32], + w: usize, + (gy1, gy2, gx1, gx2): (usize, usize, usize, usize), + base: &[[f32; 3]], +) -> Option<[f64; 3]> { + let mean = |p: &[f32]| (p[0] + p[1] + p[2]) / 3.0; + let mut picture = Vec::with_capacity((gy2 - gy1) * (gx2 - gx1)); + for y in gy1..gy2 { + picture.extend( + det[(y * w + gx1) * 3..(y * w + gx2) * 3] + .as_chunks::<3>() + .0 + .iter() + .map(|p| mean(p)), + ); + } + let mut base_means: Vec = base.iter().map(|p| mean(p)).collect(); + // `<=` is false for NaN, so a NaN median keeps the base. + if np_median(&mut base_means) <= np_median(&mut picture) { + return None; + } + Some(std::array::from_fn(|c| { + let mut col: Vec = base.iter().map(|p| p[c]).collect(); + libm::log10((np_median(&mut col) as f64).max(1e-6)) + })) +} + +/// `film_and_picture(det)` for an HxWx3 detection image: the film box (None when the scan +/// shows no film edge) and the picture gate inside it, or over the whole frame without a box; a +/// degenerate gate falls back to the whole frame. None when the frame is too small to refine. +pub fn film_and_picture(det: &[f32], h: usize, w: usize) -> Option<(Option, Roi)> { + let film = crate::detect::detect_film_bounds(det, h, w, 3) + .consensus + .map(|c| c.roi); + let whole = (0, h as i64, 0, w as i64); + let (gate, _) = refine_roi_to_image(det, h, w, 3, film.unwrap_or(whole))?; + let gate = if gate.1 <= gate.0 || gate.3 <= gate.2 { + whole + } else { + gate + }; + Some((film, gate)) +} + +// The band inside the film box, per side, that holds rebate, perforations and picture edge. +const BASE_MARGIN: f64 = 0.15; + +/// `measure_base_log(image)` on its HxWx3 detection image: per-channel log10 transmittance of +/// the clear base, the median of the flat blocks in the film box's margin band. None without a +/// film box, with too few flat blocks, or when the base is darker than the picture, because a +/// negative's base is its thinnest film. +pub fn measure_base_log(det: &[f32], h: usize, w: usize) -> Option<[f64; 3]> { + let (film, gate) = film_and_picture(det, h, w)?; + let (fy1, fy2, fx1, fx2) = film?; + let (my, mx) = ( + ((fy2 - fy1) as f64 * BASE_MARGIN) as i64, + ((fx2 - fx1) as f64 * BASE_MARGIN) as i64, + ); + let clamp = |v: i64, n: usize| v.clamp(0, n as i64) as usize; + let mut band = vec![0u8; h * w]; + for y in clamp(fy1, h)..clamp(fy2, h) { + band[y * w + clamp(fx1, w)..y * w + clamp(fx2, w).max(clamp(fx1, w))].fill(1); + } + let (cy1, cy2, cx1, cx2) = ( + clamp(fy1 + my, h), + clamp(fy2 - my, h), + clamp(fx1 + mx, w), + clamp(fx2 - mx, w), + ); + for y in cy1..cy2.max(cy1) { + band[y * w + cx1..y * w + cx2.max(cx1)].fill(0); + } + let base = base_blocks(det, &band, h, w)?; + let gate = ( + clamp(gate.0, h), + clamp(gate.1, h), + clamp(gate.2, w), + clamp(gate.3, w), + ); + base_log_from_blocks(det, w, gate, &base) +} + +const BASE_BLOCK: usize = 8; +const CLIP_LEVEL: f32 = 0.98; +const FLAT_CV: f32 = 0.03; +const MIN_BASE_BLOCKS: usize = 5; + +/// The first half of `_base_blocks`: per-channel means of the 8x8 blocks of `det` (HxWx3) that +/// lie in `band`, hold no clipped pixel and are flat, in block order; None with fewer than +/// five. NumPy's order: each block sums row by row, the 8 rows outer, from +0. +fn flat_block_means(det: &[f32], band: &[u8], h: usize, w: usize) -> Option> { + let b = BASE_BLOCK; + let (hb, wb) = (h / b, w / b); + let rows: Vec> = (0..hb) + .into_par_iter() + .map(|by| { + let mut out = Vec::new(); + for bx in 0..wb { + let px = |i1: usize, i3: usize| { + let o = ((by * b + i1) * w + bx * b + i3) * 3; + [det[o], det[o + 1], det[o + 2]] + }; + let mut sum = [0f32; 3]; + let mut top = f32::NEG_INFINITY; + let mut count = 0u32; + for i1 in 0..b { + for i3 in 0..b { + let p = px(i1, i3); + for c in 0..3 { + sum[c] += p[c]; + // np.max propagates NaN, so a block with one is never unclipped. + top = if p[c].is_nan() || top.is_nan() { + f32::NAN + } else { + top.max(p[c]) + }; + } + count += (band[(by * b + i1) * w + bx * b + i3] != 0) as u32; + } + } + let n = (b * b) as f32; + let mean = sum.map(|v| v / n); + let mut sq = [0f32; 3]; + for i1 in 0..b { + for i3 in 0..b { + let p = px(i1, i3); + for c in 0..3 { + let d = p[c] - mean[c]; + sq[c] += d * d; + } + } + } + let std = sq.map(|v| (v / n).sqrt()); + let lum = ((mean[0] + mean[1]) + mean[2]) / 3.0; + let cv = ((std[0] + std[1]) + std[2]) / 3.0 / lum.max(1e-6); + let inside = count as f64 / (b * b) as f64 >= 0.9; + if inside && top < CLIP_LEVEL && lum > 1e-4 && cv < FLAT_CV { + out.push(mean); + } + } + out + }) + .collect(); + let flat: Vec<[f32; 3]> = rows.into_iter().flatten().collect(); + (flat.len() >= MIN_BASE_BLOCKS).then_some(flat) +} + +const GROUP_GAP: f32 = 0.04; + +/// `_base_blocks`: the flat blocks grouped by log brightness, brightest first, split where two +/// neighbours differ by more than `GROUP_GAP` decades; the largest group of at least +/// max(5, 2%) blocks. log10 is libm's (see LEDGER.md); ties sort by block index. +pub fn base_blocks(det: &[f32], band: &[u8], h: usize, w: usize) -> Option> { + let cand = flat_block_means(det, band, h, w)?; + let log_lum: Vec = cand + .iter() + .map(|m| libm::log10f((((m[0] + m[1]) + m[2]) / 3.0).max(1e-6))) + .collect(); + let mut order: Vec = (0..cand.len()).collect(); + order.sort_by(|&a, &b| log_lum[a].total_cmp(&log_lum[b])); + order.reverse(); + let mut groups: Vec<&[usize]> = Vec::new(); + let mut start = 0; + for i in 1..=order.len() { + if i == order.len() || log_lum[order[i - 1]] - log_lum[order[i]] > GROUP_GAP { + groups.push(&order[start..i]); + start = i; + } + } + let min_n = MIN_BASE_BLOCKS.max((0.02 * order.len() as f64) as usize); + let mut best: Option<&[usize]> = None; + for g in groups.into_iter().filter(|g| g.len() >= min_n) { + if best.is_none_or(|b| g.len() > b.len()) { + best = Some(g); + } + } + best.map(|g| g.iter().map(|&i| cand[i]).collect()) +} + +/// A copy of `img[y1:y2, x1:x2]` of an HxW plane, row by row. +pub(crate) fn crop( + img: &[f32], + w: usize, + (y1, y2, x1, x2): (usize, usize, usize, usize), +) -> Vec { + let mut out = Vec::with_capacity(y2.saturating_sub(y1) * x2.saturating_sub(x1)); + for y in y1..y2 { + out.extend_from_slice(&img[y * w + x1..y * w + x2]); + } + out +} + +/// Python's round() of a float: half to even. +fn py_round(v: f64) -> i64 { + v.round_ties_even() as i64 +} + +/// `_find_rebate_level(lum, film_roi, bed)`: the brightest uniform rebate plateau on a side of +/// the film box, (level, spread), when two opposite sides show one. +pub fn find_rebate_level( + lum: &[f32], + h: usize, + w: usize, + roi: (usize, usize, usize, usize), + bed: Option, +) -> Option<(f64, f64)> { + let (y1, y2, x1, x2) = (roi.0.min(h), roi.1.min(h), roi.2.min(w), roi.3.min(w)); + let (bh, bw) = (y2.saturating_sub(y1), x2.saturating_sub(x1)); + if bh < 16 || bw < 16 { + return None; + } + let bed = match bed { + Some(b) => b, + None => np_percentiles(lum, &[99.0])?[0] as f64, + }; + let boxed = crop(lum, w, (y1, y2, x1, x2)); + let box_median = np_percentiles(&boxed, &[50.0])?[0] as f64; + let ring = py_round(0.04 * bh.min(bw) as f64).max(3) as usize; + let ring_h = ring.min(bh); + let ring_w = ring.min(bw); + // top, bottom, left, right: NumPy slices of the box, in its row-major order. + let sides = [ + crop(&boxed, bw, (0, ring_h, 0, bw)), + crop(&boxed, bw, (bh - ring_h, bh, 0, bw)), + crop(&boxed, bw, (0, bh, 0, ring_w)), + crop(&boxed, bw, (0, bh, bw - ring_w, bw)), + ]; + // NEP 50: the Python float threshold joins the float32 comparison as float32. + let cut = (bed - 0.05) as f32; + let mut qualifying: [Option<(f64, f64)>; 4] = [None; 4]; + for (slot, strip) in qualifying.iter_mut().zip(&sides) { + let vals: Vec = strip.iter().copied().filter(|&v| v < cut).collect(); + if (vals.len() as f64) < 0.25 * strip.len() as f64 { + continue; + } + let Some(p) = percentiles(&vals, &[80.0, 20.0, 60.0]) else { + continue; + }; + let spread = (p[0] - p[1]) as f64; + if spread > 0.10 { + continue; + } + let p60 = p[2] as f64; + if p60 < box_median + 0.10 { + continue; + } + *slot = Some((p60, spread)); + } + let [top, bottom, left, right] = qualifying; + if !((top.is_some() && bottom.is_some()) || (left.is_some() && right.is_some())) { + return None; + } + // max() keeps the first of equal levels, in the order the sides were checked. + qualifying + .into_iter() + .flatten() + .fold(None, |best: Option<(f64, f64)>, q| match best { + Some(b) if q.0 <= b.0 => Some(b), + _ => Some(q), + }) +} + +// Camera-scan holder mask against a bed normalized to 1.0. Dense negative bottoms out near +// 0.05, so the cut sits below that. The mask is not always this clean, which is why the walk +// bridges instead of classifying every pixel. +const EDGE_MASK_LEVEL: f32 = 0.03; +// Clear film base / bare bed. _detection_luma anchors P99.5 at 1.0, so the bed is a stable +// landmark; picture only reaches here when the frame carries no base at all. +const EDGE_BASE_LEVEL: f32 = 0.95; +// Borders that no absolute level finds, read against the picture on the same line. A rebate +// on a thin negative sits above the picture but below the bed, because the frame's own +// highlights set the P99.5 anchor. A holder edge sits below the picture but above the mask +// cut, shading from near-black to mid-grey along the side. Both must end in a step, which is +// what separates them from a bright subject or a deep shadow touching the frame edge. +const EDGE_REBATE_MARGIN: f32 = 0.10; +// The dark side runs a wider margin: dense negative crowds the bottom of the range, so a +// shadow sits nearer the picture median than a highlight does. +const EDGE_SHADE_MARGIN: f32 = 0.16; +const EDGE_REBATE_STEP: f32 = 0.18; +const EDGE_REBATE_REF: usize = 60; +// The step carries most of the drop from the outer level to the picture. A boundary does that +// and a subject shading toward the edge does not. Replaces the fixed EDGE_REBATE_STEP at +// depth, where a dense frame compresses every level. +const EDGE_STEP_SHARE: f32 = 0.45; +const EDGE_STEP_FLOOR: f32 = 0.06; + +/// np.max with NaN propagation. +fn np_max(a: f32, b: f32) -> f32 { + if a.is_nan() || b.is_nan() { + f32::NAN + } else { + a.max(b) + } +} + +/// `_walk_border_depth(lines, limit, bridge, relative_step)`: per line, the depth of the +/// non-image border walking inward from the frame edge. `lines` is L x N (steps inward x +/// lines). A camera scan puts two tiers outside the picture, either of which may be absent: a +/// holder mask at the very edge, then a film-base sliver. Neither is separable by level alone, +/// but the tiers are adjacent, so the walk carries across a short gap and stops at the last +/// border evidence. A run that ended on a relative test takes its boundary from the strongest +/// step inside it, and that step must clear a gate (fixed, or a share of the run's own drop). +pub fn walk_border_depth( + lines: &[f32], + rows: usize, + n: usize, + limit: usize, + bridge: i64, + relative_step: bool, +) -> Option> { + if rows < limit + 3 || lines.len() != rows * n { + return None; + } + let at = |r: usize, c: usize| lines[r * n + c]; + let ref_end = (limit + EDGE_REBATE_REF).min(rows); + let picture: Vec = (0..n) + .map(|c| np_median(&mut (limit..ref_end).map(|r| at(r, c)).collect::>())) + .collect(); + + let mut depth = vec![0i64; n]; + let mut last = vec![-1i64; n]; + let mut soft = vec![false; n]; + let mut alive = vec![true; n]; + let mut firm_depth = vec![0i64; n]; + let mut firm_last = vec![-1i64; n]; + let mut firm_alive = vec![true; n]; + for i in 0..=limit { + let ii = i as i64; + for c in 0..n { + alive[c] &= ii - last[c] <= bridge; + firm_alive[c] &= ii - firm_last[c] <= bridge; + } + if !alive.iter().any(|&a| a) && !firm_alive.iter().any(|&a| a) { + break; + } + for c in 0..n { + let v = at(i, c); + let firm = v < EDGE_MASK_LEVEL || v > EDGE_BASE_LEVEL; + let border = + firm || v > picture[c] + EDGE_REBATE_MARGIN || v < picture[c] - EDGE_SHADE_MARGIN; + if alive[c] && border { + last[c] = ii; + depth[c] = ii + 1; + soft[c] = !firm; + } + if firm_alive[c] && firm { + firm_last[c] = ii; + firm_depth[c] = ii + 1; + } + } + } + + let out = (0..n) + .map(|c| { + // span[j] = |lines[j + 3] - lines[j]|, masked to -1 past the walk's depth. + let masked: Vec = (0..limit) + .map(|j| { + if (j as i64) < depth[c] { + (at(j + 3, c) - at(j, c)).abs() + } else { + -1.0 + } + }) + .collect(); + let peak = masked.iter().copied().fold(masked[0], np_max); + let near = 0.9f32 * peak; + let peak_at = masked.iter().position(|&m| m >= near).unwrap_or(0) as i64; + let resolved = (peak_at + 3).clamp(0, limit as i64); + let gate = if relative_step { + let outer = np_median(&mut [at(0, c), at(1, c), at(2, c)]); + let g = EDGE_STEP_SHARE * (outer - picture[c]).abs(); + if g.is_nan() { + g + } else { + g.max(EDGE_STEP_FLOOR) + } + } else { + EDGE_REBATE_STEP + }; + let d = if soft[c] { + if peak >= gate { resolved } else { 0 } + } else { + depth[c] + }; + d.max(firm_depth[c]) + }) + .collect(); + Some(out) +} + +// Gap the walk carries across, covering the mid-gray ramp between mask and base. +const EDGE_BRIDGE: i64 = 8; +const EDGE_TRIM_CAP: f64 = 0.03; +const EDGE_CORE: f64 = 0.70; +const EDGE_QUANTILE: f64 = 80.0; +const EDGE_MIN_SUPPORT: f64 = 0.45; +// Second, deeper walk. EDGE_TRIM_CAP is the depth a border may reach on trust alone. Past it +// a border must prove itself, because that much of a side is worth real picture. A wide +// holder margin, a sprocket rail or a neighbouring frame all run several times deeper than +// the cap, and clamping there leaves a band the eye reads as an uncropped edge. +const EDGE_DEEP_CAP: f64 = 0.10; +// Wider than EDGE_BRIDGE: a border this thick is layered (mask, ramp, base, sometimes a second +// mask segment) and the mid-gray runs between the tiers are longer at this depth. +const EDGE_DEEP_BRIDGE: i64 = 12; +// Proof one: the step ends a straight edge. A film boundary is a cut line, so the per-line +// depths fit a sloped line to within a fraction of the allowance. Picture content resolved by +// the same rule scatters several times wider. +const EDGE_DEEP_STRAIGHT: f64 = 0.025; +// Proof two: the walk stopped on its own. Reaching the allowance means no boundary was found +// inside it, and the depth is then an artefact of where the search was cut off. +const EDGE_DEEP_FULL: f64 = 0.92; +// Proof three is EDGE_STEP_SHARE. Proof four: the outermost pixels are one material. Mask, +// bed and film base each hold their level along the whole side, so the outer level barely +// moves from line to line, while picture reaching the frame edge moves with the subject. +const EDGE_DEEP_OUTER_SPREAD: f64 = 0.15; + +/// `np.percentile` of float64 data (or integers, which NumPy interpolates in float64). +fn np_percentile_f64(values: &[f64], q: f64) -> f64 { + let mut v = values.to_vec(); + if v.iter().any(|x| x.is_nan()) { + return f64::NAN; + } + let n = v.len(); + let virtual_index = (n - 1) as f64 * (q / 100.0); + let lo = (virtual_index.floor().max(0.0) as usize).min(n - 1); + let hi = (lo + 1).min(n - 1); + let t = virtual_index - lo as f64; + v.sort_by(f64::total_cmp); + let (a, b) = (v[lo], v[hi]); + let diff = b - a; + if t >= 0.5 { + b - diff * (1.0 - t) + } else { + a + diff * t + } +} + +/// `np.median` of float64 data. +fn np_median_f64(values: &[f64]) -> f64 { + if values.is_empty() || values.iter().any(|x| x.is_nan()) { + return f64::NAN; + } + let mut v = values.to_vec(); + v.sort_by(f64::total_cmp); + let n = v.len(); + if n % 2 == 1 { + v[n / 2] + } else { + (v[n / 2 - 1] + v[n / 2]) / 2.0 + } +} + +/// `_pool_border_depth`: the 80th percentile of the line depths, capped at `limit`, or 0 when +/// fewer than 45% of the lines show a border. +fn pool_border_depth(depth: &[i64], limit: i64) -> i64 { + let live = depth.iter().filter(|&&d| d > 0).count(); + if (live as f64 / depth.len() as f64) < EDGE_MIN_SUPPORT { + return 0; + } + let as_f: Vec = depth.iter().map(|&d| d as f64).collect(); + (limit as f64).min(np_percentile_f64(&as_f, EDGE_QUANTILE)) as i64 +} + +/// `_border_edge_scatter`: the median residual of the live depths around a Theil-Sen line. +fn border_edge_scatter(depth: &[i64]) -> f64 { + let index: Vec = (0..depth.len()) + .filter(|&i| depth[i] > 0) + .map(|i| i as f64) + .collect(); + if index.len() < 8 { + return f64::INFINITY; + } + let value: Vec = depth + .iter() + .filter(|&&d| d > 0) + .map(|&d| d as f64) + .collect(); + let step = (index.len() / 48).max(1); + let a: Vec = index.iter().step_by(step).copied().collect(); + let b: Vec = value.iter().step_by(step).copied().collect(); + let mut slopes = Vec::new(); + for i in 0..a.len() { + for j in i + 1..a.len() { + let da = a[i] - a[j]; + if da != 0.0 { + slopes.push((b[i] - b[j]) / da); + } + } + } + let slope = if slopes.is_empty() { + 0.0 + } else { + np_median_f64(&slopes) + }; + let offsets: Vec = value + .iter() + .zip(&index) + .map(|(v, i)| v - slope * i) + .collect(); + let intercept = np_median_f64(&offsets); + let residuals: Vec = value + .iter() + .zip(&index) + .map(|(v, i)| (v - (slope * i + intercept)).abs()) + .collect(); + np_median_f64(&residuals) +} + +#[derive(Clone, Copy)] +enum Side { + Top, + Bottom, + Left, + Right, +} + +/// A film box inside an HxW plane: the plane's row stride, and the box's top, left, height +/// and width. +#[derive(Clone, Copy)] +struct BoxView<'a> { + lum: &'a [f32], + w: usize, + y1: usize, + x1: usize, + bh: usize, + bw: usize, +} + +/// `_EDGE_ORIENT[side](box)`, cropped to the core columns and to its first `keep` lines (rows), +/// each one position along the edge (columns); and the uncropped line count. +fn edge_lines(b: BoxView, side: Side, keep: usize) -> (Vec, usize, usize, usize) { + let (rows, n) = match side { + Side::Top | Side::Bottom => (b.bh, b.bw), + Side::Left | Side::Right => (b.bw, b.bh), + }; + let margin = py_round((1.0 - EDGE_CORE) * 0.5 * n as f64).max(0) as usize; + let (c0, c1) = if n >= 2 * margin + 8 { + (margin, n - margin) + } else { + (0, n) + }; + let (cols, kept) = (c1 - c0, keep.min(rows)); + let row = |y: usize| &b.lum[(b.y1 + y) * b.w + b.x1..][..b.bw]; + let mut out = vec![0f32; kept * cols]; + match side { + Side::Top | Side::Bottom => { + for (r, line) in out.chunks_exact_mut(cols).enumerate() { + let y = if matches!(side, Side::Top) { + r + } else { + b.bh - 1 - r + }; + line.copy_from_slice(&row(y)[c0..c1]); + } + } + // Read along the source rows; the box's columns are these lines. + Side::Left | Side::Right => { + for c in c0..c1 { + let src = row(c); + for r in 0..kept { + let x = if matches!(side, Side::Left) { + r + } else { + b.bw - 1 - r + }; + out[r * cols + c - c0] = src[x]; + } + } + } + } + (out, rows, kept, cols) +} + +/// `_edge_border_depth(box, side, limit, deep_limit)`: the border depth on one side, per line +/// (these frames are slightly tilted) and pooled. A first walk to `limit` is believed; a second +/// to `deep_limit` is adopted only where it goes deeper and passes all four proofs. +fn edge_border_depth(b: BoxView, side: Side, limit: i64, deep_limit: i64) -> i64 { + // The walks read no line past their limit plus the picture reference. + let keep = limit.max(deep_limit).max(0) as usize + EDGE_REBATE_REF; + let (lines, rows, kept, cols) = edge_lines(b, side, keep); + if limit <= 0 || (rows as i64) < limit + 8 || cols < 8 { + return 0; + } + let Some(walk) = walk_border_depth(&lines, kept, cols, limit as usize, EDGE_BRIDGE, false) + else { + return 0; + }; + let near = pool_border_depth(&walk, limit); + if deep_limit <= limit || (rows as i64) < deep_limit + 8 { + return near; + } + let Some(deep) = walk_border_depth( + &lines, + kept, + cols, + deep_limit as usize, + EDGE_DEEP_BRIDGE, + true, + ) else { + return near; + }; + let far = pool_border_depth(&deep, deep_limit); + if far <= near || far as f64 >= EDGE_DEEP_FULL * deep_limit as f64 { + return near; + } + if border_edge_scatter(&deep) > EDGE_DEEP_STRAIGHT * deep_limit as f64 { + return near; + } + let mut outer: Vec = (0..cols) + .filter(|&c| deep[c] > 0) + .map(|c| np_median(&mut [lines[c], lines[cols + c], lines[2 * cols + c]])) + .collect(); + // np.median of nothing is NaN, and a NaN spread passes no comparison. + if outer.is_empty() { + return far; + } + let mid = np_median(&mut outer.clone()); + let mut spread: Vec = outer.iter_mut().map(|v| (*v - mid).abs()).collect(); + if np_median(&mut spread) as f64 > EDGE_DEEP_OUTER_SPREAD { + return near; + } + far +} + +/// `measure_film_edges(lum, film_roi)`: per side (top, bottom, left, right) the border depth as +/// a fraction of that side. +pub fn measure_film_edges( + lum: &[f32], + h: usize, + w: usize, + roi: (usize, usize, usize, usize), +) -> [f64; 4] { + let (y1, y2, x1, x2) = (roi.0.min(h), roi.1.min(h), roi.2.min(w), roi.3.min(w)); + let (bh, bw) = (y2.saturating_sub(y1), x2.saturating_sub(x1)); + if bh < 16 || bw < 16 { + return [0.0; 4]; + } + let view = BoxView { + lum, + w, + y1, + x1, + bh, + bw, + }; + let ly = py_round(EDGE_TRIM_CAP * bh as f64); + let lx = py_round(EDGE_TRIM_CAP * bw as f64); + let dy = py_round(EDGE_DEEP_CAP * bh as f64); + let dx = py_round(EDGE_DEEP_CAP * bw as f64); + let sides = [ + (Side::Top, ly, dy, bh), + (Side::Bottom, ly, dy, bh), + (Side::Left, lx, dx, bw), + (Side::Right, lx, dx, bw), + ]; + let mut out = [0.0; 4]; + out.par_iter_mut() + .zip(sides) + .for_each(|(o, (side, limit, deep, extent))| { + *o = edge_border_depth(view, side, limit, deep) as f64 / extent as f64; + }); + out +} + +/// `_trim_film_edges(lum, film_roi)`: the box inset past the border on each side, or the box +/// itself when that would take half a side. +pub fn trim_film_edges( + lum: &[f32], + h: usize, + w: usize, + roi: (i64, i64, i64, i64), +) -> (i64, i64, i64, i64) { + let (y1, y2, x1, x2) = roi; + let (height, width) = (y2 - y1, x2 - x1); + let clamp = |v: i64| v.max(0) as usize; + let m = measure_film_edges(lum, h, w, (clamp(y1), clamp(y2), clamp(x1), clamp(x2))); + let out = ( + y1 + py_round(m[0] * height as f64), + y2 - py_round(m[1] * height as f64), + x1 + py_round(m[2] * width as f64), + x2 - py_round(m[3] * width as f64), + ); + if ((out.1 - out.0) as f64) < 0.5 * height as f64 + || ((out.3 - out.2) as f64) < 0.5 * width as f64 + { + return roi; + } + out +} + +// Walk cap per side, as a fraction of that side. Real rebate plus bed slop stays well under +// it, so reaching the cap means the profile never came back down and bright picture content +// touches that edge. Those sides report NaN and pooling ignores them. +const BORDER_WALK_CAP: f64 = 0.06; +const BORDER_MIN_SEPARATION: f64 = 0.15; + +/// `measure_film_border(lum, film_roi)`: per side (top, bottom, left, right) the bright +/// border's thickness as a fraction of that side; 0.0 for none, NaN for no clean transition. +pub fn measure_film_border( + lum: &[f32], + h: usize, + w: usize, + roi: (usize, usize, usize, usize), +) -> [f64; 4] { + let (y1, y2, x1, x2) = (roi.0.min(h), roi.1.min(h), roi.2.min(w), roi.3.min(w)); + let (bh, bw) = (y2.saturating_sub(y1), x2.saturating_sub(x1)); + if bh < 16 || bw < 16 { + return [f64::NAN; 4]; + } + let boxed = crop(lum, w, (y1, y2, x1, x2)); + let Some(level) = np_percentiles(&boxed, &[40.0]) else { + return [f64::NAN; 4]; + }; + let image_level = level[0] as f64; + // mean(axis=1): a pairwise sum per row; mean(axis=0): sequential down each column. + let rows: Vec = boxed + .chunks_exact(bw) + .map(|r| pairwise_sum_f32(r) / bw as f32) + .collect(); + let mut cols = vec![0f32; bw]; + for r in boxed.chunks_exact(bw) { + for (c, &v) in cols.iter_mut().zip(r) { + *c += v; + } + } + cols.iter_mut().for_each(|c| *c /= bh as f32); + + let side = |profile: &[f32], reversed: bool| -> f64 { + let length = profile.len(); + let walk: Vec = if reversed { + profile.iter().rev().copied().collect() + } else { + profile.to_vec() + }; + let outer_n = py_round(0.03 * length as f64).max(2) as usize; + let Some(b) = np_percentiles(&walk[..outer_n.min(length)], &[90.0]) else { + return f64::NAN; + }; + let base = b[0] as f64; + if base - image_level < BORDER_MIN_SEPARATION { + return 0.0; + } + // NEP 50: the Python float threshold joins float32 comparisons as float32. + let threshold = (0.5 * (base + image_level)) as f32; + let cap = ((BORDER_WALK_CAP * length as f64) as usize) + .max(3) + .min(length); + let head = &walk[..cap]; + // np.argmax: the first maximum, or the first NaN. + let peak = head.iter().position(|v| v.is_nan()).unwrap_or_else(|| { + head.iter() + .enumerate() + .fold(0, |best, (i, &v)| if v > head[best] { i } else { best }) + }); + if head[peak] <= threshold { + return 0.0; + } + match head[peak..].iter().position(|&v| v <= threshold) { + Some(k) => (peak + k) as f64 / length as f64, + None => f64::NAN, + } + }; + [ + side(&rows, false), + side(&rows, true), + side(&cols, false), + side(&cols, true), + ] +} + +/// `_roi_from_measured_border(lum, film_roi)`: the box inset by its measured border, when an +/// opposite pair agrees. +pub fn roi_from_measured_border( + lum: &[f32], + h: usize, + w: usize, + roi: (i64, i64, i64, i64), +) -> Option<(i64, i64, i64, i64)> { + let (y1, y2, x1, x2) = roi; + let clamp = |v: i64| v.max(0) as usize; + let m = measure_film_border(lum, h, w, (clamp(y1), clamp(y2), clamp(x1), clamp(x2))); + let (height, width) = ((y2 - y1) as f64, (x2 - x1) as f64); + let opposite = [1, 0, 3, 2]; + let mut insets = [0f64; 4]; + for i in 0..4 { + let (mut own, other) = (m[i], m[opposite[i]]); + if !own.is_finite() { + own = other; + } + if !own.is_finite() { + continue; + } + if own > 0.0 && other.is_finite() && other > 0.0 { + own = own.min(2.0 * other); + } + insets[i] = own; + } + if !((insets[0] > 0.0 && insets[1] > 0.0) || (insets[2] > 0.0 && insets[3] > 0.0)) { + return None; + } + let out = ( + y1 + py_round(insets[0] * height), + y2 - py_round(insets[1] * height), + x1 + py_round(insets[2] * width), + x2 - py_round(insets[3] * width), + ); + (out.1 > out.0 && out.3 > out.2).then_some(out) +} + +// Smallest fraction of a film-box side the tier refinement may keep. A rebate never takes half +// a side, so anything below this is a broken occupancy run, not a frame. +const TIER_MIN_KEEP: f64 = 0.5; + +/// `_smooth_signal(signal, window)`: `np.convolve(signal, ones(window) / window, "same")` in +/// NumPy's float32 arithmetic. A full-overlap output of a window up to 11 goes through NumPy's +/// small_correlate (float32 sums); every other output through BLAS sdot (float32 products, a +/// float64 sum). None for a window over 31 (BLAS's vector path) or longer than the signal +/// (np.convolve swaps the arrays): no caller uses either. +pub fn smooth_signal(signal: &[f32], window: usize) -> Option> { + let n = signal.len(); + if window <= 1 || n == 0 { + return Some(signal.to_vec()); + } + if window > 31 || window > n { + return None; + } + let k = 1.0f32 / window as f32; + let left = window / 2; + (0..n) + .map(|o| { + let lo = left.saturating_sub(o); + let hi = window.min(n + left - o); + let terms = (lo..hi).map(|j| signal[o + j - left] * k); + if lo == 0 && hi == window && window <= 11 { + terms.fold(0f32, |s, p| s + p) + } else { + terms.fold(0f64, |s, p| s + p as f64) as f32 + } + }) + .collect::>() + .into() +} + +/// `_snap_edge_to_gradient`'s windows: outward and inward fractions of the profile, the +/// dominance over the window median, and the smallest window in pixels. +#[derive(Clone, Copy)] +pub struct Snap { + pub window_out: f64, + pub window_in: f64, + pub min_dominance: f64, + pub min_window_px: i64, +} + +impl Default for Snap { + fn default() -> Self { + Snap { + window_out: 0.06, + window_in: 0.02, + min_dominance: 2.0, + min_window_px: 3, + } + } +} + +/// `_snap_edge_to_gradient(profile, idx, is_start, ...)`: the edge moved to the strongest step +/// of the smoothed profile in a window biased outward, when that step dominates the window. +pub fn snap_edge_to_gradient(profile: &[f32], idx: i64, is_start: bool, snap: Snap) -> i64 { + let n = profile.len(); + if n < 8 { + return idx; + } + let Some(sm) = smooth_signal(profile, py_round(0.01 * n as f64).max(3) as usize) else { + return idx; + }; + let grad: Vec = sm.windows(2).map(|w| (w[1] - w[0]).abs()).collect(); + let out_px = py_round(snap.window_out * n as f64).max(snap.min_window_px); + let in_px = py_round(snap.window_in * n as f64).max(snap.min_window_px); + let (lo, hi) = if is_start { + ((idx - out_px).max(0), (idx + in_px).min(n as i64 - 1)) + } else { + ((idx - in_px).max(0), (idx + out_px).min(n as i64 - 1)) + }; + if hi <= lo { + return idx; + } + let window = &grad[lo as usize..hi as usize]; + let peak = window.iter().copied().fold(window[0], np_max); + let median = np_median(&mut window.to_vec()); + if (peak as f64) < snap.min_dominance * median as f64 + 1e-6 || peak.is_nan() || median.is_nan() + { + return idx; + } + // np.isclose(window, peak, rtol=1e-5, atol=1e-7): the difference in float32, the tolerance + // a Python float joining the comparison as float32 (NEP 50). + let tol = (1e-7 + 1e-5 * (peak as f64).abs()) as f32; + let mut best: Option<(i64, i64)> = None; + for (i, &v) in window.iter().enumerate() { + if (v - peak).abs() <= tol || v == peak { + let t = lo + i as i64 + 1; + let d = (t - idx).abs(); + if best.is_none_or(|(_, bd)| d < bd) { + best = Some((t, d)); + } + } + } + best.map_or(idx, |(t, _)| t) +} + +/// `_longest_run_above(profile, threshold)`: the longest half-open run with profile >= threshold, +/// the first of equal length. +fn longest_run_above(profile: &[f64], threshold: f64) -> Option<(i64, i64)> { + let idx: Vec = (0..profile.len()) + .filter(|&i| profile[i] >= threshold) + .map(|i| i as i64) + .collect(); + if idx.is_empty() { + return None; + } + let mut best = (idx[0], idx[0]); + let mut start = idx[0]; + for w in 0..idx.len() { + let end_here = w + 1 == idx.len() || idx[w + 1] - idx[w] > 1; + if end_here { + if idx[w] - start > best.1 - best.0 { + best = (start, idx[w]); + } + if w + 1 < idx.len() { + start = idx[w + 1]; + } + } + } + Some((best.0, best.1 + 1)) +} + +/// `_estimate_tier_levels(lum, film_roi)`: (bed, rebate, image, ring spread), or None. +fn estimate_tier_levels( + lum: &[f32], + h: usize, + w: usize, + roi: (usize, usize, usize, usize), +) -> Option<(f64, f64, f64, f64)> { + let bed = np_percentiles(lum, &[99.0])?[0] as f64; + let (rebate, ring_spread) = find_rebate_level(lum, h, w, roi, Some(bed))?; + let (y1, y2, x1, x2) = (roi.0.min(h), roi.1.min(h), roi.2.min(w), roi.3.min(w)); + let boxed = crop(lum, w, (y1, y2, x1, x2)); + let cut = (rebate - 0.02) as f32; + let dark: Vec = boxed.iter().copied().filter(|&v| v < cut).collect(); + if (dark.len() as f64) < 0.05 * boxed.len() as f64 { + return None; + } + let image = np_percentiles(&dark, &[30.0])?[0] as f64; + if rebate - image < 0.04f64.max(3.0 * ring_spread) { + return None; + } + Some((bed, rebate, image, ring_spread)) +} + +#[derive(Clone)] +pub struct TierRefinement { + pub roi: (i64, i64, i64, i64), + pub rows: Vec, + pub cols: Vec, +} + +/// `_refine_film_roi_by_tiers(lum, film_roi)`: the picture box from rebate/image occupancy runs +/// snapped to the strongest gradients, with the full-length occupancy profiles. +pub fn refine_film_roi_by_tiers( + lum: &[f32], + h: usize, + w: usize, + roi: (usize, usize, usize, usize), +) -> Option { + let (_, rebate, image, ring_spread) = estimate_tier_levels(lum, h, w, roi)?; + let (y1, y2, x1, x2) = (roi.0.min(h), roi.1.min(h), roi.2.min(w), roi.3.min(w)); + let (bh, bw) = (y2 - y1, x2 - x1); + let boxed = crop(lum, w, (y1, y2, x1, x2)); + let threshold = (0.5 * (rebate + image)).max(rebate - 0.04f64.max(3.0 * ring_spread)) as f32; + let mask: Vec = boxed.iter().map(|&v| v < threshold).collect(); + let count = |r0: usize, r1: usize, c0: usize, c1: usize, by_row: bool| -> Vec { + if by_row { + (r0..r1) + .map(|r| { + mask[r * bw + c0..r * bw + c1] + .iter() + .filter(|&&m| m) + .count() as f64 + / (c1 - c0) as f64 + }) + .collect() + } else { + (c0..c1) + .map(|c| (r0..r1).filter(|&r| mask[r * bw + c]).count() as f64 / (r1 - r0) as f64) + .collect() + } + }; + let row_occ = count(0, bh, 0, bw, true); + let (vt, vb) = longest_run_above(&row_occ, 0.55)?; + let col_occ = count(vt as usize, vb as usize, 0, bw, false); + let (hl, hr) = longest_run_above(&col_occ, 0.55)?; + if ((vb - vt) as f64) < TIER_MIN_KEEP * bh as f64 + || ((hr - hl) as f64) < TIER_MIN_KEEP * bw as f64 + { + return None; + } + let area = ((vb - vt) * (hr - hl)) as f64 / (bh * bw) as f64; + if !(0.25..=0.95).contains(&area) { + return None; + } + // box[vt:vb, :].mean(axis=0) sums down the columns; box[:, hl:hr].mean(axis=1) is a pairwise + // sum per row. + let (vtu, vbu, hlu, hru) = (vt as usize, vb as usize, hl as usize, hr as usize); + let mut col_profile = vec![0f32; bw]; + for r in vtu..vbu { + for (c, p) in col_profile.iter_mut().enumerate() { + *p += boxed[r * bw + c]; + } + } + col_profile + .iter_mut() + .for_each(|p| *p /= (vbu - vtu) as f32); + let row_profile: Vec = (0..bh) + .map(|r| pairwise_sum_f32(&boxed[r * bw + hlu..r * bw + hru]) / (hru - hlu) as f32) + .collect(); + let hl = snap_edge_to_gradient(&col_profile, hl, true, Snap::default()); + let hr = snap_edge_to_gradient(&col_profile, hr, false, Snap::default()); + let vt = snap_edge_to_gradient(&row_profile, vt, true, Snap::default()); + let vb = snap_edge_to_gradient(&row_profile, vb, false, Snap::default()); + let pad_y = py_round(0.004 * bh as f64).max(2); + let pad_x = py_round(0.004 * bw as f64).max(2); + let (vt, vb) = ((vt - pad_y).max(0), (vb + pad_y).min(bh as i64)); + let (hl, hr) = ((hl - pad_x).max(0), (hr + pad_x).min(bw as i64)); + if vb - vt <= 0 || hr - hl <= 0 { + return None; + } + let mut rows = vec![0f32; h]; + rows[y1..y2] + .iter_mut() + .zip(&row_occ) + .for_each(|(d, &v)| *d = v as f32); + let mut cols = vec![0f32; w]; + cols[x1..x2] + .iter_mut() + .zip(&col_occ) + .for_each(|(d, &v)| *d = v as f32); + let (y1, x1) = (y1 as i64, x1 as i64); + Some(TierRefinement { + roi: (y1 + vt, y1 + vb, x1 + hl, x1 + hr), + rows, + cols, + }) +} + +/// The uint8 preview and its gray plane, as `_refine_frame_bounds` and `_dark_region_bounds` +/// build them from an HxW or HxWx3 float32 image. +fn preview_gray(img: &[f32], h: usize, w: usize, cn: usize) -> Vec { + let mut preview = vec![0u8; h * w * 3]; + normalize_to_uint8(&rgb(img, cn), &mut preview); + let mut gray = vec![0u8; h * w]; + crate::gray::bgr_to_gray(&preview, &mut gray); + gray +} + +/// `_boundary_candidates(signal, from_start)`: (edge index, value, inner index, value). +fn boundary_candidates(signal: &[f32], from_start: bool) -> (i64, f64, i64, f64) { + let length = signal.len() as i64; + if length == 0 { + return (0, 0.0, 0, 0.0); + } + let edge_window = (py_round(length as f64 * 0.08)) + .max(32) + .min((length - 1).max(1)); + let search_end = py_round(length as f64 * 0.45) + .max(edge_window + 1) + .min(length); + let search_start = py_round(length as f64 * 0.55).min((length - edge_window - 1).max(0)); + // np.argmax: the first maximum, or the first NaN. + let argmax = |s: &[f32]| -> usize { + s.iter().position(|v| v.is_nan()).unwrap_or_else(|| { + s.iter() + .enumerate() + .fold(0, |b, (i, &v)| if v > s[b] { i } else { b }) + }) + }; + let at = |a: i64, b: i64| { + &signal[a.clamp(0, length) as usize..b.clamp(0, length).max(a.clamp(0, length)) as usize] + }; + let (edge, edge_base, search, search_base) = if from_start { + ( + at(0, edge_window), + 0, + at(edge_window, search_end), + edge_window, + ) + } else { + ( + at(length - edge_window, length), + length - edge_window, + at(search_start, length - edge_window), + search_start, + ) + }; + let (edge_idx, edge_value) = if edge.is_empty() { + (edge_base, 0.0) + } else { + let k = argmax(edge); + (edge_base + k as i64, edge[k] as f64) + }; + if search.is_empty() { + return (edge_idx, edge_value, edge_idx, edge_value); + } + let k = argmax(search); + ( + edge_idx, + edge_value, + search_base + k as i64, + search[k] as f64, + ) +} + +/// `_dark_region_bounds(image)`: (left, top, right, bottom) of the largest dark region, or None. +fn dark_region_bounds(gray: &[u8], h: usize, w: usize) -> Option<(i64, i64, i64, i64)> { + let as_f: Vec = gray.iter().map(|&v| v as f64).collect(); + let threshold = np_percentile_f64(&as_f, 55.0); + let mask: Vec = gray + .iter() + .map(|&v| if (v as f64) <= threshold { 255 } else { 0 }) + .collect(); + let mut opened = vec![0u8; h * w]; + crate::morph::morphology( + &mask, + &mut opened, + (h, w, 1), + crate::morph::Op::Open, + (9, 9), + 1, + Border::Constant, + )?; + let mut closed = vec![0u8; h * w]; + crate::morph::morphology( + &opened, + &mut closed, + (h, w, 1), + crate::morph::Op::Close, + (31, 31), + 2, + Border::Constant, + )?; + let found = crate::contours::find_external_contours(&closed, h, w); + // max(contours, key=cv2.contourArea): the first of the largest. + let contour = found + .iter() + .fold(None::<&Vec<(i32, i32)>>, |best, c| match best { + Some(b) if crate::contours::contour_area(c) <= crate::contours::contour_area(b) => { + Some(b) + } + _ => Some(c), + })?; + let (x, y, bw, bh) = crate::contours::bounding_rect(contour); + let (x, y, bw, bh) = (x as i64, y as i64, bw as i64, bh as i64); + let area_ratio = crate::contours::contour_area(contour) / ((h * w) as f64).max(1.0); + if !(0.15..=0.85).contains(&area_ratio) { + return None; + } + let (hf, wf) = (h as f64, w as f64); + if bw < py_round(wf * 0.25) || bh < py_round(hf * 0.25) { + return None; + } + let pad_x = py_round(wf * 0.004).max(4); + let pad_y = py_round(hf * 0.004).max(4); + let (left, top) = ((x - pad_x).max(0), (y - pad_y).max(0)); + let (right, bottom) = ( + (x + bw + pad_x).min(w as i64), + (y + bh + pad_y).min(h as i64), + ); + let (inset_x, inset_y) = (py_round(wf * 0.03), py_round(hf * 0.03)); + if left < inset_x + || top < inset_y + || (w as i64 - right) < inset_x + || (h as i64 - bottom) < inset_y + { + return None; + } + Some((left, top, right, bottom)) +} + +/// `_refine_frame_bounds(image)`'s box (left, top, right, bottom) for an HxW or HxWx3 float32 +/// image: Sobel profiles of the gray preview, edge and inner candidates, and the dark-region +/// fallback. None for an image narrower or shorter than the 31-tap smoothing. +pub fn refine_frame_bounds( + img: &[f32], + h: usize, + w: usize, + cn: usize, +) -> Option<(i64, i64, i64, i64)> { + let gray = preview_gray(img, h, w, cn); + let gray_f: Vec = gray.iter().map(|&v| v as f32).collect(); + let grad = |dx: bool| -> Option> { + let (kx, ky): (&[f32], &[f32]) = if dx { + (&[-1.0, 0.0, 1.0], &[1.0, 2.0, 1.0]) + } else { + (&[1.0, 2.0, 1.0], &[-1.0, 0.0, 1.0]) + }; + let mut out = vec![0f32; h * w]; + crate::filter::sep_filter2d(&gray_f, &mut out, (h, w, 1), (kx, ky), Border::Reflect101)?; + Some( + out.iter() + .map(|&v| v.abs().min(255.0) as u8 as f64) + .collect(), + ) + }; + let (gx, gy) = (grad(true)?, grad(false)?); + let col: Vec = (0..w) + .map(|c| np_percentile_f64(&(0..h).map(|r| gx[r * w + c]).collect::>(), 95.0) as f32) + .collect(); + let row: Vec = (0..h) + .map(|r| np_percentile_f64(&gy[r * w..(r + 1) * w], 95.0) as f32) + .collect(); + let col_signal = smooth_signal(&col, 31)?; + let row_signal = smooth_signal(&row, 31)?; + + let (left_edge, _, left_inner, left_inner_value) = boundary_candidates(&col_signal, true); + let (right_edge, _, right_inner, right_inner_value) = boundary_candidates(&col_signal, false); + let (top_edge, top_edge_value, top_inner, top_inner_value) = + boundary_candidates(&row_signal, true); + let (bottom_edge, bottom_edge_value, bottom_inner, bottom_inner_value) = + boundary_candidates(&row_signal, false); + let col_floor = np_percentiles(&col_signal, &[75.0])?[0] as f64; + let row_floor = np_percentiles(&row_signal, &[75.0])?[0] as f64; + let (wf, hf) = (w as f64, h as f64); + + let (mut left, mut right, mut top, mut bottom) = + (left_edge, right_edge + 1, top_edge, bottom_edge + 1); + let pair_x = left_inner >= py_round(wf * 0.12) + && right_inner <= py_round(wf * 0.88) + && left_inner_value >= col_floor * 4.0 + && right_inner_value >= col_floor * 4.0 + && (right_inner - left_inner) >= py_round(wf * 0.5); + if pair_x { + left = left_inner; + right = right_inner + 1; + } else { + if left_inner >= py_round(wf * 0.12) && left_inner_value >= col_floor * 5.0 { + left = left_inner; + } + if right_inner <= py_round(wf * 0.88) && right_inner_value >= col_floor * 5.0 { + right = right_inner + 1; + } + } + let pair_y = top_inner > top_edge + 20 + && bottom_inner < bottom_edge - 20 + && top_inner_value > (top_edge_value * 1.2).max(row_floor + 25.0) + && bottom_inner_value > (bottom_edge_value * 1.2).max(row_floor + 25.0) + && (bottom_inner - top_inner) >= py_round(hf * 0.5); + if pair_y { + top = top_inner; + bottom = bottom_inner + 1; + } else { + if top_inner > top_edge + 20 + && top_inner_value > (top_edge_value * 1.45).max(row_floor + 35.0) + { + top = top_inner; + } + if bottom_inner < bottom_edge - 20 + && bottom_inner_value > (bottom_edge_value * 1.45).max(row_floor + 35.0) + { + bottom = bottom_inner + 1; + } + } + let pad_x = py_round(wf * 0.004).max(4); + let pad_y = py_round(hf * 0.004).max(4); + left = (left - pad_x).max(0); + right = (right + pad_x).min(w as i64); + top = (top - pad_y).max(0); + bottom = (bottom + pad_y).min(h as i64); + if right - left < py_round(wf * 0.5).max(1) { + (left, right) = (0, w as i64); + } + if bottom - top < py_round(hf * 0.5).max(1) { + (top, bottom) = (0, h as i64); + } + let area = ((right - left) * (bottom - top)) as f64 / (hf * wf).max(1.0); + if area > 0.8 + && let Some((dl, dt, dr, db)) = dark_region_bounds(&gray, h, w) + { + let dark_area = ((dr - dl) * (db - dt)) as f64 / (hf * wf).max(1.0); + if (0.15..=0.85).contains(&dark_area) { + return Some((dl, dt, dr, db)); + } + } + Some((left, top, right, bottom)) +} + +pub type Roi = (i64, i64, i64, i64); + +/// `_refine_roi_to_image(img, film_roi)`: the exposed picture inside the film box, by tiers, +/// then the edge walk, then the border measurement, then Sobel; with the occupancy profiles +/// when the tiers found it. +pub fn refine_roi_to_image( + img: &[f32], + h: usize, + w: usize, + cn: usize, + roi: Roi, +) -> Option<(Roi, Option)> { + type Refined = Option<(Roi, Option)>; + static LAST: crate::memo::Memo = crate::memo::Memo::new(); + let key = crate::memo::key(img, &[h, w, cn]); + let refined = || { + let det = detection_plane(img, h, w, cn, key)?; + refine_roi(img, &det, h, w, cn, roi) + }; + LAST.get_or( + crate::memo::extend(key, &[roi.0, roi.1, roi.2, roi.3]), + refined, + ) +} + +fn refine_roi( + img: &[f32], + det: &[f32], + h: usize, + w: usize, + cn: usize, + roi: Roi, +) -> Option<(Roi, Option)> { + let clamp = |v: i64| v.max(0) as usize; + let uroi = (clamp(roi.0), clamp(roi.1), clamp(roi.2), clamp(roi.3)); + if let Some(r) = refine_film_roi_by_tiers(det, h, w, uroi) { + return Some((r.roi, Some(r))); + } + let walked = trim_film_edges(det, h, w, roi); + if walked != roi { + return Some((walked, None)); + } + if let Some(m) = roi_from_measured_border(det, h, w, roi) { + return Some((m, None)); + } + if find_rebate_level(det, h, w, uroi, None).is_none() { + return Some((roi, None)); + } + let (y1, y2, x1, x2) = (uroi.0.min(h), uroi.1.min(h), uroi.2.min(w), uroi.3.min(w)); + let crop_w = x2.saturating_sub(x1); + let color = rgb(img, cn); + let crop: Vec = (y1..y2) + .flat_map(|y| color[(y * w + x1) * 3..(y * w + x2) * 3].iter().copied()) + .collect(); + let (left, top, right, bottom) = refine_frame_bounds(&crop, y2.saturating_sub(y1), crop_w, 3)?; + let out = (roi.0 + top, roi.0 + bottom, roi.2 + left, roi.2 + right); + // A Sobel cut removing more than a quarter of the box latched onto picture content. + let film_area = ((roi.1 - roi.0) * (roi.3 - roi.2)).max(1) as f64; + if (((out.1 - out.0) * (out.3 - out.2)) as f64) < 0.75 * film_area { + return Some((roi, None)); + } + Some((out, None)) +} + +/// `measure_clip_fractions`: per channel, the share of every 4th pixel of rows y1..y2 and +/// columns x1..x2 of an HxWx3 image at or above `level`. NaN for an empty window. +pub fn clip_fractions( + img: &[f32], + w: usize, + (y1, y2, x1, x2): (usize, usize, usize, usize), + level: f32, +) -> [f64; 3] { + let rows: Vec = (y1..y2).step_by(4).collect(); + let cols = (x1..x2).step_by(4).len(); + let counts = rows + .par_iter() + .map(|&y| { + let mut c = [0u64; 3]; + let row = &img[(y * w + x1) * 3..(y * w + x2) * 3]; + for p in row.as_chunks::<3>().0.iter().step_by(4) { + for ch in 0..3 { + c[ch] += (p[ch] >= level) as u64; + } + } + c + }) + .reduce(|| [0; 3], |a, b| [a[0] + b[0], a[1] + b[1], a[2] + b[2]]); + let n = (rows.len() * cols) as f64; + counts.map(|c| c as f64 / n) +} + +/// `grid @ w` in float64 for Rec. 709 luma, in the order OpenBLAS's dgemv sums the three terms. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn luma_f64_chunk(grid: &[f32], dst: &mut [f64]) { + for (d, p) in dst.iter_mut().zip(grid.as_chunks::<3>().0) { + let t = LUMA_R.mul_add(p[0] as f64, LUMA_G * p[1] as f64); + *d = LUMA_B.mul_add(p[2] as f64, t); + } +} + +/// `density_above_base`'s metered median: `np.median` of an Nx3 grid's float64 Rec. 709 luma. +pub fn luma_median(grid: &[f32]) -> f64 { + let mut lum = vec![0f64; grid.len() / 3]; + lum.par_chunks_mut(4096) + .zip(grid.par_chunks(4096 * 3)) + .for_each(|(d, p)| luma_f64_chunk(p, d)); + crate::stats::median_f64(&mut lum) +} + +/// Rec. 709 luma in float32, NumPy's order for `LUMA_R * r + LUMA_G * g + LUMA_B * b` (NEP 50). +#[inline(always)] +fn luma_f32(p: &[f32; 3]) -> f32 { + LUMA_R as f32 * p[0] + LUMA_G as f32 * p[1] + LUMA_B as f32 * p[2] +} + +/// `diffusion_plane`'s panchromatic collapse: each pixel of an HxWx3 plane set to its float32 luma. +pub fn luma_planes(val: &[f32], dst: &mut [f32]) { + crate::par_zip( + dst.as_chunks_mut::<3>().0, + val.as_chunks::<3>().0, + move |d, p| *d = [luma_f32(p); 3], + ); +} + +/// `contrast_mask_plane` after normalization: the float32 luma of an HxWx3 plane, blurred by +/// `sigma` with a replicated border, minus its float32 mean. Returns the plane and the mean. +pub fn contrast_mask_tail(val: &[f32], h: usize, w: usize, sigma: f64) -> Option<(Vec, f32)> { + let mut lum = vec![0f32; h * w]; + crate::par_zip(&mut lum, val.as_chunks::<3>().0, move |d, p| { + *d = luma_f32(p) + }); + let mut blurred = vec![0f32; h * w]; + crate::filter::gaussian_blur( + &lum, + &mut blurred, + (h, w, 1), + (0, 0), + (sigma, 0.0), + Border::Replicate, + )?; + let centre = pairwise_sum_f32(&blurred) / blurred.len() as f32; + blurred.par_chunks_mut(1 << 14).for_each(|d| { + for v in d { + *v -= centre; + } + }); + Some((blurred, centre)) +} + +/// `diffusion_grid` after the unmix: 10^x of an HxWx3 log plane, blurred by `sigma` with a +/// replicated border, back to log10. +pub fn diffusion_tail( + unmixed: &[f32], + h: usize, + w: usize, + sigma: f64, + dst: &mut [f32], +) -> Option<()> { + let mut light = vec![0f32; unmixed.len()]; + crate::par_zip(&mut light, unmixed, move |d, &v| { + *d = crate::mathf::exp10_f64(v as f64) as f32; + }); + crate::filter::gaussian_blur( + &light, + dst, + (h, w, 3), + (0, 0), + (sigma, 0.0), + Border::Replicate, + )?; + dst.par_chunks_mut(1 << 14).for_each(|d| { + for v in d { + *v = crate::mathf::log10f(*v); + } + }); + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_spans_0_to_255() { + let img: Vec = (0..1000).map(|i| i as f32 / 999.0).collect(); + let mut out = vec![0u8; img.len()]; + normalize_to_uint8(&img, &mut out); + assert_eq!(out[0], 0); + assert_eq!(out[999], 255); + } + + #[test] + fn all_nan_normalizes_to_zero() { + let img = vec![f32::NAN; 10]; + let mut out = vec![7u8; 10]; + normalize_to_uint8(&img, &mut out); + assert!(out.iter().all(|&v| v == 0)); + } + + #[test] + fn clip_fractions_count_every_fourth_pixel() { + let (h, w) = (8, 8); + let mut img = vec![0f32; h * w * 3]; + img[0] = 1.0; + img[(4 * w + 5) * 3 + 1] = 1.0; + img[(4 * w + 4) * 3 + 2] = 0.99; + let f = clip_fractions(&img, w, (0, h, 0, w), 0.99); + assert_eq!(f, [0.25, 0.0, 0.25]); + } +} diff --git a/negpy_core/src/autocrop.rs b/negpy_core/src/autocrop.rs new file mode 100644 index 000000000..6f76033e8 --- /dev/null +++ b/negpy_core/src/autocrop.rs @@ -0,0 +1,255 @@ +//! The profile reads of negpy/features/geometry/batch_autocrop.py and the occupancy window of +//! geometry/logic.py. + +use crate::filter::{Border, gaussian_blur, sep_filter2d}; +use crate::stats::{median_f64, np_median, np_percentiles}; + +/// `round` of a float64 as Python rounds it: ties to even. +fn py_round(v: f64) -> i64 { + v.round_ties_even() as i64 +} + +/// Least-squares line through the points, as `skew::polyfit1`. +fn polyfit1(xs: &[f64], ys: &[f64]) -> (f64, f64) { + let n = xs.len() as f64; + let mx = xs.iter().sum::() / n; + let my = ys.iter().sum::() / n; + let (mut sxy, mut sxx) = (0.0, 0.0); + for (&x, &y) in xs.iter().zip(ys) { + sxy += (x - mx) * (y - my); + sxx += (x - mx) * (x - mx); + } + let slope = sxy / sxx; + (slope, my - slope * mx) +} + +/// `_top_edge_slope(lum, roi)`: the residual tilt of the film box's top edge in degrees, from +/// the strongest vertical gradient per column near it; None when unreadable. +pub fn top_edge_slope( + lum: &[f32], + (h, w): (usize, usize), + (y1, x1, x2): (i64, i64, i64), +) -> Option { + let (left, right) = (x1 + 20, x2 - 20); + let columns: Vec = (left..right).step_by(3).collect(); + let min_points = 20i64.max(py_round((right - left) as f64 * 0.08)) as usize; + if columns.len() < min_points { + return None; + } + let radius = 12i64.max(py_round(h as f64 * 0.035)); + let (low, high) = ( + (y1 - radius).max(0) as usize, + (y1 + radius).min(h as i64) as usize, + ); + if high < low + 4 { + return None; + } + // The blur and the 3-tap derivative reach 7 rows; a slab 8 rows wider reads like the frame. + let (s0, s1) = (low.saturating_sub(8), (high + 8).min(h)); + let slab = &lum[s0 * w..s1 * w]; + let rows = s1 - s0; + let mut blur = vec![0f32; slab.len()]; + gaussian_blur( + slab, + &mut blur, + (rows, w, 1), + (0, 0), + (1.5, 0.0), + Border::Reflect101, + )?; + let mut grad = vec![0f32; slab.len()]; + sep_filter2d( + &blur, + &mut grad, + (rows, w, 1), + (&[1.0, 2.0, 1.0], &[-1.0, 0.0, 1.0]), + Border::Reflect101, + )?; + let at = |y: usize, x: i64| grad[(y - s0) * w + x as usize].abs(); + let mut band = Vec::with_capacity((high - low) * columns.len()); + let mut peaks = Vec::with_capacity(columns.len()); + let mut strength = Vec::with_capacity(columns.len()); + for y in low..high { + band.extend(columns.iter().map(|&x| at(y, x))); + } + for (j, _) in columns.iter().enumerate() { + let (mut best, mut row) = (f32::NEG_INFINITY, 0usize); + for (i, y) in (low..high).enumerate() { + let v = band[i * columns.len() + j]; + if v > best { + best = v; + row = y; + } + } + peaks.push(row as f64); + strength.push(best); + } + let med_strength = np_median(&mut strength.clone()) as f64; + if med_strength < 1e-6f64.max(3.0 * np_median(&mut band) as f64) { + return None; + } + let bar = np_percentiles(&strength, &[60.0])?[0]; + let (mut xs, mut ys): (Vec, Vec) = columns + .iter() + .zip(&peaks) + .zip(&strength) + .filter(|&(_, &s)| s >= bar) + .map(|((&x, &y), _)| (x as f64, y)) + .unzip(); + if xs.len() < min_points { + return None; + } + let mut residuals = Vec::new(); + let mut slope = 0.0; + for _ in 0..5 { + let intercept; + (slope, intercept) = polyfit1(&xs, &ys); + residuals = xs + .iter() + .zip(&ys) + .map(|(&x, &y)| (y - (slope * x + intercept)).abs()) + .collect(); + let bar = 2f64.max(median_f64(&mut residuals.clone()) * 2.5); + let keep: Vec = residuals.iter().map(|&r| r <= bar).collect(); + let kept = keep.iter().filter(|&&k| k).count(); + if kept == keep.len() || kept < min_points { + break; + } + (xs, ys) = (0..xs.len()) + .filter(|&i| keep[i]) + .map(|i| (xs[i], ys[i])) + .unzip(); + } + if median_f64(&mut residuals) > 2.0 { + return None; + } + Some(slope.atan().to_degrees()) +} + +/// The roll template fields `_rect_from_edge_profile` reads. +pub struct Template { + pub center_x: f64, + pub width: f64, + pub fallback_width: f64, + pub top: f64, + pub height: f64, + pub center_mad: f64, + pub width_mad: f64, +} + +const MIN_PROFILE_CONTRAST: f64 = 0.06; +/// How near the roll's film width a pair of edge-profile peaks must span before their +/// agreement replaces raw peak strength. +const EDGE_PAIR_WIDTH_TOL: f64 = 0.02; + +fn clamp_rect((x1, y1, x2, y2): (f64, f64, f64, f64)) -> (f64, f64, f64, f64) { + let c = |v: f64| v.clamp(0.0, 1.0); + let (x1, x2) = (c(x1).min(c(x2)), c(x1).max(c(x2))); + let (y1, y2) = (c(y1).min(c(y2)), c(y1).max(c(y2))); + (x1, y1, x2, y2) +} + +/// `_peak_near`: the strongest profile sample within `radius` of `expected`, as (position, value). +fn peak_near(profile: &[f32], expected: f64, radius: f64) -> (f64, f64) { + let n = profile.len(); + if n == 0 { + return (expected, 0.0); + } + let center = py_round(expected * (n - 1) as f64); + let half = 2i64.max(py_round(radius * n as f64)); + let (lo, hi) = ((center - half).max(0), (center + half + 1).min(n as i64)); + if hi <= lo { + return (expected, 0.0); + } + let local = &profile[lo as usize..hi as usize]; + // np.argmax: the first NaN, else the first maximum. + let offset = local.iter().position(|v| v.is_nan()).unwrap_or_else(|| { + local + .iter() + .enumerate() + .fold(0, |b, (i, &v)| if v > local[b] { i } else { b }) + }); + let idx = lo as usize + offset; + (idx as f64 / 1usize.max(n - 1) as f64, local[offset] as f64) +} + +/// `_rect_from_edge_profile`: a film box from the frame's vertical-edge profile at the edges the +/// roll expects, or None when the profile cannot place one. +pub fn rect_from_edge_profile( + profile: &[f32], + contrast: f64, + t: &Template, +) -> Option<(f64, f64, f64, f64)> { + if contrast < MIN_PROFILE_CONTRAST + || profile.len() < 8 + || !profile.iter().any(|v| v.is_finite()) + { + return None; + } + let q = np_percentiles(profile, &[50.0, 99.5, 70.0, 90.0])?; + let (baseline_level, peak_level) = (q[0] as f64, q[1] as f64); + if peak_level - baseline_level < 0.20 || peak_level < 1.35 * baseline_level.max(0.05) { + return None; + } + let radius = 0.045f64.max(4.0 * t.center_mad + 2.0 * t.width_mad); + let (left, left_strength) = peak_near(profile, t.center_x - t.width * 0.5, radius); + let (right, right_strength) = peak_near(profile, t.center_x + t.width * 0.5, radius); + let baseline = q[2] as f64; + let spread = (q[3] - q[0]) as f64; + let threshold = 0.24f64.max(baseline + 0.55 * spread); + let (mut left_ok, mut right_ok) = (left_strength >= threshold, right_strength >= threshold); + if !(left_ok && right_ok) && ((right - left) - t.width).abs() <= EDGE_PAIR_WIDTH_TOL { + (left_ok, right_ok) = (true, true); + } + let (x1, x2) = if left_ok && right_ok { + if right - left < 0.65 * t.width || right - left > 1.35 * t.fallback_width { + return None; + } + (left, right) + } else if left_ok && left_strength >= threshold + 0.12 { + (left, left + t.fallback_width) + } else if right_ok && right_strength >= threshold + 0.12 { + (right - t.fallback_width, right) + } else { + return None; + }; + Some(clamp_rect((x1, t.top, x2, t.top + t.height))) +} + +/// `_place_window_by_occupancy`: the start, in input pixels, of the `target_len` window within +/// [start, end) that holds the most occupancy (detection pixels, `scale` = detection/input). +/// Ties go to the centered position. +pub fn place_window_by_occupancy( + start: i64, + end: i64, + target_len: f64, + occupancy: &[f32], + scale: f64, +) -> i64 { + let d_start = py_round(start as f64 * scale).max(0); + let d_end = py_round(end as f64 * scale).min(occupancy.len() as i64); + let d_len = py_round(target_len * scale).max(1); + if d_len >= d_end - d_start { + return start; + } + let mut cs = vec![0f64]; + let mut sum = 0f64; + for &v in &occupancy[d_start as usize..d_end as usize] { + sum += v as f64; + cs.push(sum); + } + let n_pos = ((d_end - d_start) - d_len + 1) as usize; + let d_len = d_len as usize; + let scores: Vec = (0..n_pos).map(|i| cs[i + d_len] - cs[i]).collect(); + let top = scores.iter().copied().fold(f64::NEG_INFINITY, f64::max) - 1e-9; + let centered = ((d_end - d_start) as f64 - d_len as f64) / 2.0; + let k = (0..n_pos) + .filter(|&i| scores[i] >= top) + .fold(None::, |b, i| match b { + Some(j) if (j as f64 - centered).abs() <= (i as f64 - centered).abs() => Some(j), + _ => Some(i), + }) + .unwrap_or(0); + let new_start = py_round((d_start as f64 + k as f64) / scale); + (start.max(new_start) as f64).min(end as f64 - target_len) as i64 +} diff --git a/negpy_core/src/bounds.rs b/negpy_core/src/bounds.rs new file mode 100644 index 000000000..a73f38a88 --- /dev/null +++ b/negpy_core/src/bounds.rs @@ -0,0 +1,174 @@ +//! The density bounds sampling of negpy/features/exposure/normalization.py. + +use crate::stats::np_percentiles_f64; + +const LUMA_R: f64 = 0.2126; +const LUMA_G: f64 = 0.7152; +const LUMA_B: f64 = 0.0722; +const EPSILON: f64 = 1e-6; + +/// `np.percentile(grid[:, :, ch], qs)` for each channel of an HxWx3 float32 grid, with float64 +/// interpolation (an `np.float64` or sequence q). +pub fn channel_percentiles(grid: &[f32], qs: &[f64]) -> [Vec; 3] { + std::array::from_fn(|ch| { + let plane: Vec = grid.iter().skip(ch).step_by(3).copied().collect(); + np_percentiles_f64(&plane, qs) + }) +} + +/// NumPy's linear quantile of float64 values at a virtual index of `(n - 1) * q`, the far half +/// counted back from the upper sample; NaN when any value is NaN. Reorders `values`. +fn quantile_at(values: &mut [f64], virtual_index: f64) -> f64 { + let n = values.len(); + if n == 0 || values.iter().any(|v| v.is_nan()) { + return f64::NAN; + } + let lo = (virtual_index.floor().max(0.0) as usize).min(n - 1); + let t = virtual_index - lo as f64; + let a = *values.select_nth_unstable_by(lo, f64::total_cmp).1; + let b = if lo + 1 < n { + values[lo + 1..] + .iter() + .copied() + .fold(f64::INFINITY, f64::min) + } else { + a + }; + let diff = b - a; + if t >= 0.5 { + b - diff * (1.0 - t) + } else { + a + diff * t + } +} + +fn np_quantile(values: &mut [f64], q: f64) -> f64 { + let n = values.len(); + quantile_at(values, n.saturating_sub(1) as f64 * q) +} + +fn np_percentile(values: &mut [f64], q: f64) -> f64 { + let n = values.len(); + quantile_at(values, n.saturating_sub(1) as f64 * (q / 100.0)) +} + +/// `np.median` of float64 values: the middle one, or the mean of the two middle ones. +fn np_median(values: &mut [f64]) -> f64 { + let n = values.len(); + if n == 0 || values.iter().any(|v| v.is_nan()) { + return f64::NAN; + } + let hi = *values.select_nth_unstable_by(n / 2, f64::total_cmp).1; + if n % 2 == 1 { + return hi; + } + let lo = values[..n / 2] + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + (lo + hi) / 2.0 +} + +/// `_rms_chroma` of one triplet. +fn rms_chroma([r, g, b]: [f64; 3]) -> f64 { + (((r - g) * (r - g) + (g - b) * (g - b) + (r - b) * (r - b)) / 3.0).sqrt() +} + +/// The constants of the same-pixel color pass. +#[derive(Clone, Copy)] +pub struct ColorPass { + pub chroma_quantile: f64, + pub chroma_cap: f64, + pub first_pass_cap: f64, + pub min_pixels: usize, + pub band_width: f64, +} + +/// `_same_pixel_color_floor_refs`: the dense-end color refs from the luma band's lowest-chroma +/// pixels, or None when that set is too small or too chromatic. +pub fn same_pixel_color_floor_refs( + grid: &[f32], + luma_floors: [f64; 3], + luma_ceils: [f64; 3], + base: [f64; 3], + color_clip: f64, + c: ColorPass, +) -> Option<[f64; 3]> { + let flat: Vec<[f64; 3]> = grid + .as_chunks::<3>() + .0 + .iter() + .map(|p| p.map(|v| v as f64)) + .collect(); + let denom: [f64; 3] = std::array::from_fn(|ch| { + let d = luma_ceils[ch] - luma_floors[ch]; + if d.abs() < EPSILON { + if d >= 0.0 { EPSILON } else { -EPSILON } + } else { + d + } + }); + let luma: Vec = flat + .iter() + .map(|p| { + let n: [f64; 3] = std::array::from_fn(|ch| (p[ch] - luma_floors[ch]) / denom[ch]); + LUMA_R * n[0] + LUMA_G * n[1] + LUMA_B * n[2] + }) + .collect(); + + let clip = 0.00001f64.max((50.0 - c.band_width).min(color_clip)); + let (lo, hi) = { + let mut v = luma.clone(); + ( + np_percentile(&mut v, clip), + np_percentile(&mut v, clip + c.band_width), + ) + }; + let d: Vec<[f64; 3]> = flat + .iter() + .zip(&luma) + .filter(|&(_, &l)| l >= lo && l <= hi) + .map(|(p, _)| std::array::from_fn(|ch| p[ch] - base[ch])) + .collect(); + if d.len() < c.min_pixels { + return None; + } + + // The lowest-chroma quantile of the band, chroma read with `gamma` as each channel's span: + // the selected rows and their median chroma. + let select = |gamma: [f64; 3]| -> Option<(Vec, f64)> { + let g = gamma.map(|v| if v.abs() < EPSILON { EPSILON } else { v }); + let chroma: Vec = d + .iter() + .map(|p| rms_chroma(std::array::from_fn(|ch| p[ch] / g[ch]))) + .collect(); + let thr = np_quantile(&mut chroma.clone(), c.chroma_quantile); + let sel: Vec = (0..chroma.len()).filter(|&i| chroma[i] <= thr).collect(); + if sel.len() < c.min_pixels { + return None; + } + let mut picked: Vec = sel.iter().map(|&i| chroma[i]).collect(); + Some((sel, np_median(&mut picked))) + }; + let column_medians = |rows: &[usize]| -> [f64; 3] { + std::array::from_fn(|ch| np_median(&mut rows.iter().map(|&i| d[i][ch]).collect::>())) + }; + + let spans: [f64; 3] = std::array::from_fn(|ch| luma_floors[ch] - base[ch]); + let (first, first_chroma) = select(spans)?; + // A loose first cap: pass 2 would self-normalize a homogeneous colored cluster to zero + // chroma and read it as neutral. + if first_chroma > c.first_pass_cap { + return None; + } + let provisional = column_medians(&first); + if provisional.iter().any(|v| v.abs() < EPSILON) { + return None; + } + let (second, second_chroma) = select(provisional)?; + if second_chroma > c.chroma_cap { + return None; + } + let m = column_medians(&second); + Some(std::array::from_fn(|ch| base[ch] + m[ch])) +} diff --git a/negpy_core/src/capture.rs b/negpy_core/src/capture.rs new file mode 100644 index 000000000..389a70c6a --- /dev/null +++ b/negpy_core/src/capture.rs @@ -0,0 +1,354 @@ +//! Ports of the preview and capture metering in negpy/services/capture, negpy/services/scanning +//! and the scan preview widgets. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::stats::{np_median, np_percentiles, np_quantile, pairwise_sum_f32}; + +/// How `stretch_preview` maps a channel between its 1st and 99th percentiles. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Stretch { + /// `preview_positive` of a positive: `clip((v - lo) / (hi - lo), 0, 1) * 255`. + Positive, + /// `preview_positive` of a negative: `clip((hi - v) / (hi - lo), 0, 1) * 255`. + Negative, + /// `_preview_u8`: `clip((v - lo) / (hi - lo) * 255, 0, 255)`. + Prescan, +} + +/// The scan preview stretch of an HxWxC float32 image into `out_cn` uint8 channels, output channel +/// c reading input channel min(c, C - 1). A channel whose 99th percentile is not above its 1st is 0. +pub fn stretch_preview(img: &[f32], cn: usize, out_cn: usize, mode: Stretch, dst: &mut [u8]) { + let bounds: Vec> = (0..cn) + .into_par_iter() + .map(|c| { + let ch: Vec = img.iter().skip(c).step_by(cn).copied().collect(); + let p = np_percentiles(&ch, &[1.0, 99.0])?; + (p[1] > p[0]).then_some((p[0], p[1])) + }) + .collect(); + dst.par_chunks_mut(out_cn * 4096) + .zip(img.par_chunks(cn * 4096)) + .for_each(|(d, s)| { + for (o, p) in d.chunks_exact_mut(out_cn).zip(s.chunks_exact(cn)) { + for (c, v) in o.iter_mut().enumerate() { + let src = c.min(cn - 1); + let Some((lo, hi)) = bounds[src] else { + *v = 0; + continue; + }; + let x = p[src]; + // NumPy's float32 to uint8 cast truncates. + *v = match mode { + Stretch::Positive => ((x - lo) / (hi - lo)).clamp(0.0, 1.0) * 255.0, + Stretch::Negative => ((hi - x) / (hi - lo)).clamp(0.0, 1.0) * 255.0, + Stretch::Prescan => ((x - lo) / (hi - lo) * 255.0).clamp(0.0, 255.0), + } as u8; + } + } + }); +} + +/// `sharpness`: the float32 mean square of the 4-neighbour Laplacian over the squared float32 +/// mean level, or 0 below `min_mean`. +pub fn sharpness(img: &[f32], h: usize, w: usize, min_mean: f64) -> f64 { + let mean = (pairwise_sum_f32(img) / img.len() as f32) as f64; + if mean < min_mean { + return 0.0; + } + let mut sq = vec![0f32; (h - 2) * (w - 2)]; + sq.par_chunks_mut(w - 2).enumerate().for_each(|(i, row)| { + let y = i + 1; + let at = |yy: usize, x: usize| img[yy * w + x]; + for (j, d) in row.iter_mut().enumerate() { + let x = j + 1; + let lap = 4.0 * at(y, x) - at(y - 1, x) - at(y + 1, x) - at(y, x - 1) - at(y, x + 1); + *d = lap * lap; + } + }); + (pairwise_sum_f32(&sq) / sq.len() as f32) as f64 / (mean * mean) +} + +/// The share of pixels of an image with `cn` channels where any of the first three is at least `level`. +pub fn any_channel_at_least(img: &[f32], cn: usize, level: f32) -> f64 { + let n = img.len() / cn; + let hits: usize = img + .par_chunks(cn * 4096) + .map(|s| { + s.chunks_exact(cn) + .filter(|p| p[..cn.min(3)].iter().any(|&v| v >= level)) + .count() + }) + .sum(); + hits as f64 / n as f64 +} + +/// `_to_grey`: `(r + g + b + 1) // 3` per pixel, summed in uint32. +pub fn to_grey + TryFrom>( + rgb: &[T], + cn: usize, + dst: &mut [T], +) { + dst.par_chunks_mut(4096) + .zip(rgb.par_chunks(cn * 4096)) + .for_each(|(d, s)| { + for (o, p) in d.iter_mut().zip(s.chunks_exact(cn)) { + let sum: u32 = p.iter().map(|&v| v.into()).sum(); + // The average of the channels fits the type. + *o = T::try_from((sum + 1) / 3).unwrap_or(p[0]); + } + }); +} + +/// `np.clip(img.astype(np.float32) / divisor, 0, 1)` in float32. +pub fn normalized>(img: &[T], divisor: f32, dst: &mut [f32]) { + crate::par_zip(dst, img, move |d, &v| { + // NaN stays, as np.clip keeps it. + let x: f64 = v.into(); + *d = (x as f32 / divisor).clamp(0.0, 1.0); + }); +} + +/// `meter_frame`'s picture statistics over rows y1..y2 and columns x1..x2 of an HxWx3 float32 +/// frame: the share whose brightest channel reaches `sat`, and the 99.9th percentile per channel +/// of the unsaturated pixels (of every pixel when at least `blown_share` saturate, or all do). +pub fn meter_picture( + img: &[f32], + w: usize, + (y1, y2, x1, x2): (usize, usize, usize, usize), + sat: f32, + blown_share: f64, +) -> (f64, bool, [f32; 3]) { + let mut picture: Vec<[f32; 3]> = Vec::with_capacity((y2 - y1) * (x2 - x1)); + for y in y1..y2 { + picture.extend_from_slice(img[(y * w + x1) * 3..(y * w + x2) * 3].as_chunks::<3>().0); + } + let saturated: Vec = picture + .iter() + .map(|p| p[0].max(p[1]).max(p[2]) >= sat) + .collect(); + let n = picture.len(); + let hits = saturated.iter().filter(|&&s| s).count(); + let clipped = hits as f64 / n as f64; + let blown = clipped >= blown_share; + let keep_all = blown || hits == n; + let thinnest = std::array::from_fn(|c| { + let v: Vec = picture + .iter() + .zip(&saturated) + .filter(|(_, s)| keep_all || !**s) + .map(|(p, _)| p[c]) + .collect(); + np_percentiles(&v, &[99.9]).map_or(f32::NAN, |p| p[0]) + }); + (clipped, blown, thinnest) +} + +/// `_peak_map(np.max(img, axis=2))`: the brightest channel of an HxWx3 float32 frame, max-pooled +/// in f x f blocks over the frame zero-padded to whole blocks. +pub fn peak_map(img: &[f32], h: usize, w: usize, f: usize) -> (Vec, usize, usize) { + let (ph, pw) = (h.div_ceil(f), w.div_ceil(f)); + let mut out = vec![0f32; ph * pw]; + out.par_chunks_mut(pw).enumerate().for_each(|(by, row)| { + for y in by * f..((by + 1) * f).min(h) { + for (x, p) in img[y * w * 3..(y + 1) * w * 3] + .as_chunks::<3>() + .0 + .iter() + .enumerate() + { + let o = &mut row[x / f]; + *o = o.max(p[0].max(p[1]).max(p[2])); + } + } + }); + (out, ph, pw) +} + +/// `raw_channel_clip_fraction`'s two shares of the photosites of one CFA colour: at or above +/// `threshold` (None to skip), and on the saturation plateau. A sensor can saturate below its +/// published white level, but its sites still pile up at the highest code it reaches: the densest +/// code in the top `window` counts, `ratio` times denser than each of the next codes, with a +/// few noisy sites allowed above it. `guard` (the white level, 0 for none) keeps a frame far +/// from saturation from reading its noise floor as a plateau. +pub fn raw_clip_fractions( + values: &[u16], + threshold: Option, + guard: u32, + (window, min_sites, neighbours, ratio): (usize, usize, usize, f64), +) -> (f64, f64) { + let n = values.len() as f64; + let linearity = threshold.map_or(0.0, |t| { + values.iter().filter(|&&v| v >= t).count() as f64 / n + }); + let top = values.iter().copied().max().unwrap_or(0) as u32; + if guard > 0 && top * 2 < guard { + return (linearity, 0.0); + } + let lo = top.saturating_sub(window as u32); + let mut counts = vec![0usize; (top - lo) as usize + 1]; + for &v in values { + if v as u32 >= lo { + counts[(v as u32 - lo) as usize] += 1; + } + } + // Only codes the file holds count: a lossy raw encoding stores a sparse set of codes. + let populated: Vec = (0..counts.len()).filter(|&i| counts[i] > 0).collect(); + let peak_at = populated.iter().enumerate().fold(0, |b, (i, &p)| { + if counts[p] > counts[populated[b]] { + i + } else { + b + } + }); + let peak = counts[populated[peak_at]]; + if peak < min_sites { + return (linearity, 0.0); + } + let density = populated[peak_at + 1..] + .iter() + .take(neighbours) + .map(|&p| counts[p]) + .max() + .unwrap_or(0); + if (peak as f64) < ratio * (density as f64).max(1.0) { + return (linearity, 0.0); + } + let anchor = lo + populated[peak_at] as u32; + ( + linearity, + values.iter().filter(|&&v| v as u32 >= anchor).count() as f64 / n, + ) +} + +/// `_gutter_scan`'s column profile: every `step`th row of an HxW float32 plane over `peak`, then +/// the float32 mean of each column, rows added in order as NumPy reduces axis 0. +pub fn column_means(a: &[f32], w: usize, step: usize, peak: f32) -> Vec { + let rows: Vec<&[f32]> = a.chunks_exact(w).step_by(step.max(1)).collect(); + let mut sum = vec![0f32; w]; + for r in &rows { + for (s, &v) in sum.iter_mut().zip(*r) { + *s += v / peak; + } + } + sum.iter().map(|&s| s / rows.len() as f32).collect() +} + +/// `_looks_border_crushed`'s two medians of an HxW float32 sample: of the border ring `m` wide +/// and of the central half. +pub fn ring_inner_medians(arr: &[f32], h: usize, w: usize, m: usize) -> (f32, f32) { + let at = |y: usize| &arr[y * w..][..w]; + let mut ring: Vec = Vec::new(); + for y in (0..m).chain(h - m..h) { + ring.extend_from_slice(at(y)); + } + for y in 0..h { + ring.extend_from_slice(&at(y)[..m]); + ring.extend_from_slice(&at(y)[w - m..]); + } + let mut inner = Vec::with_capacity((3 * h / 4 - h / 4) * (3 * w / 4 - w / 4)); + for y in h / 4..3 * h / 4 { + inner.extend_from_slice(&at(y)[w / 4..3 * w / 4]); + } + (np_median(&mut ring), np_median(&mut inner)) +} + +/// `print_dmax`: the float32 mean colour, each channel rounded half to even, of the pixels whose +/// Rec. 709 luma (OpenBLAS's sgemv order) is at most its `share` quantile. None for no pixels. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +pub fn print_dmax(px: &[[u8; 3]], share: f64) -> Option<[i64; 3]> { + if px.is_empty() { + return None; + } + let (wr, wg, wb) = (0.2126f32, 0.7152f32, 0.0722f32); + let luma: Vec = px + .iter() + .map(|p| { + let p = p.map(f32::from); + p[2].mul_add(wb, p[0].mul_add(wr, p[1] * wg)) + }) + .collect(); + let cut = np_quantile(&luma, share)?; + let mut sum = [0f32; 3]; + let mut k = 0usize; + for (p, &l) in px.iter().zip(&luma) { + if l <= cut { + for c in 0..3 { + sum[c] += p[c] as f32; + } + k += 1; + } + } + Some(sum.map(|s| ((s / k as f32) as f64).round_ties_even() as i64)) +} + +/// `preview_positive`'s stretch of a linear HxWx3 float32 thumbnail: a channel whose 99th +/// percentile is under 5 % of the strongest borrows the strongest; then the density -log10 of +/// the value clipped at 1e-4, stretched between its 1st and 99th percentiles per channel and +/// quantized as `float_to_uint8`. +pub fn log_stretch_u8(linear: &[f32], dst: &mut [u8]) { + let ch = |c: usize| { + linear + .iter() + .skip(c) + .step_by(3) + .copied() + .collect::>() + }; + let peaks: Vec = (0..3) + .map(|c| np_percentiles(&ch(c), &[99.0]).map_or(f32::NAN, |p| p[0])) + .collect(); + let strongest = (0..3).fold(0, |b, c| if peaks[c] > peaks[b] { c } else { b }); + let source: [usize; 3] = std::array::from_fn(|c| { + if peaks[c] < peaks[strongest] * 0.05 { + strongest + } else { + c + } + }); + let mut density = vec![0f32; linear.len()]; + crate::par_zip( + density.as_chunks_mut::<3>().0, + linear.as_chunks::<3>().0, + move |d, p| { + // max drops NaN where np.clip keeps it; a thumbnail decoded from uint8 holds none. + *d = source.map(|s| -crate::mathf::log10f(p[s].max(1e-4))); + }, + ); + let bounds: Vec<(f32, f32)> = (0..3) + .map(|c| { + let v: Vec = density.iter().skip(c).step_by(3).copied().collect(); + let p = np_percentiles(&v, &[1.0, 99.0]).unwrap_or(vec![0.0, 0.0]); + (p[0], (p[1] - p[0]).max(1e-6)) + }) + .collect(); + let mut positive = vec![0f32; linear.len()]; + crate::par_zip( + positive.as_chunks_mut::<3>().0, + density.as_chunks::<3>().0, + move |d, p| { + *d = std::array::from_fn(|c| ((p[c] - bounds[c].0) / bounds[c].1).clamp(0.0, 1.0)); + }, + ); + crate::convert::to_uint(&positive, dst); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flat_frame_has_no_sharpness() { + let img = vec![10f32; 25]; + assert_eq!(sharpness(&img, 5, 5, 4.0), 0.0); + assert_eq!(sharpness(&[1f32; 25], 5, 5, 4.0), 0.0); + } + + #[test] + fn grey_rounds_up_from_two_thirds() { + let mut out = [0u16; 2]; + to_grey(&[1u16, 1, 0, 2, 2, 1], 3, &mut out); + assert_eq!(out, [1, 2]); + } +} diff --git a/negpy_core/src/color.rs b/negpy_core/src/color.rs new file mode 100644 index 000000000..596fa9544 --- /dev/null +++ b/negpy_core/src/color.rs @@ -0,0 +1,424 @@ +//! The working-space colour kernels of negpy/kernel/image/logic.py: the Adobe RGB TRC and +//! CIELAB. Numba's fastmath build computes them in float64 with FMA contraction; these copy its +//! operation order, and `mathf::pow_f64` stands in for glibc's pow. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::pow_f64; + +const BLOCK: usize = 1 << 14; + +/// `_oetf_encode_flat`: clamped to 0..1 (NaN stays), raised to `inv_gamma`. +pub fn oetf_encode(src: &[f32], dst: &mut [f32], inv_gamma: f32) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK)) + .for_each(|(d, s)| encode_block(s, d, inv_gamma as f64)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn encode_block(src: &[f32], dst: &mut [f32], y: f64) { + for (d, &v) in dst.iter_mut().zip(src) { + // clamp keeps NaN, as the kernel did. + let x = (v as f64).clamp(0.0, 1.0); + *d = pow_f64(x, y) as f32; + } +} + +/// `_oetf_decode_flat`: negatives to 0 (NaN stays), raised to `gamma`. +pub fn oetf_decode(src: &[f32], dst: &mut [f32], gamma: f32) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK)) + .for_each(|(d, s)| decode_block(s, d, gamma as f64)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn decode_block(src: &[f32], dst: &mut [f32], y: f64) { + for (d, &v) in dst.iter_mut().zip(src) { + let x = v as f64; + *d = pow_f64(if x < 0.0 { 0.0 } else { x }, y) as f32; + } +} + +/// `srgb_to_linear`: v / 12.92 up to 0.04045, else ((v + 0.055) / 1.055)^2.4, the power by +/// `mathf::pow_f64` rounded to float32. +pub fn srgb_to_linear(src: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK)) + .for_each(|(d, s)| srgb_block(s, d)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn srgb_block(src: &[f32], dst: &mut [f32]) { + for (d, &v) in dst.iter_mut().zip(src) { + let curve = pow_f64(((v + 0.055) / 1.055) as f64, 2.4) as f32; + // np.where picks the linear branch for v <= 0.04045, NaN included in neither. + *d = if v <= 0.04045 { v / 12.92 } else { curve }; + } +} + +/// The constants of the Lab kernels, as float64. +#[derive(Clone, Copy)] +pub struct LabSpace { + pub m: [[f64; 3]; 3], + pub white: [f64; 3], + pub eps: f64, + pub kappa: f64, + pub inv_kappa: f64, +} + +impl LabSpace { + pub fn new(m: [[f32; 3]; 3], white: [f32; 3], eps: f32, kappa: f32) -> Self { + Self { + m: m.map(|r| r.map(|v| v as f64)), + white: white.map(|v| v as f64), + eps: eps as f64, + kappa: kappa as f64, + inv_kappa: 1.0 / kappa as f64, + } + } +} + +/// `np.float32(16/116)` as float64. +const C: f64 = (16.0f32 / 116.0f32) as f64; + +/// One row of the 3x3 product as Numba contracts it. +#[inline(always)] +fn row(m: [f64; 3], x: f64, y: f64, z: f64) -> f64 { + z.mul_add(m[2], y.mul_add(m[1], x * m[0])) +} + +/// `_rgb_to_lab_kernel` over N x 3 linear RGB. +pub fn rgb_to_lab(src: &[f32], dst: &mut [f32], s: LabSpace) { + dst.par_chunks_mut(BLOCK * 3) + .zip(src.par_chunks(BLOCK * 3)) + .for_each(|(d, p)| rgb_to_lab_block(p, d, s)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn rgb_to_lab_block(src: &[f32], dst: &mut [f32], s: LabSpace) { + // Three passes over short runs: the cube root vectorizes only as a flat loop. + const RUN: usize = 3 * 512; + let mut t = [0f64; RUN]; + for (d, p) in dst.chunks_mut(RUN).zip(src.chunks(RUN)) { + let t = &mut t[..p.len()]; + for (t, p) in t + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(p.as_chunks::<3>().0) + { + let [r, g, b] = p.map(|v| { + let v = v as f64; + if v < 0.0 { 0.0 } else { v } + }); + *t = std::array::from_fn(|c| row(s.m[c], r, g, b) / s.white[c]); + } + for v in t.iter_mut() { + let root = pow_f64(*v, 1.0 / 3.0); + let lin = s.kappa.mul_add(*v, C); + *v = if *v > s.eps { root } else { lin }; + } + for (d, f) in d + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(t.as_chunks::<3>().0) + { + let [fx, fy, fz] = *f; + d[0] = fy.mul_add(116.0, -16.0) as f32; + d[1] = ((fx - fy) * 500.0) as f32; + d[2] = ((fy - fz) * 200.0) as f32; + } + } +} + +/// `_lab_to_rgb_kernel` over N x 3 Lab: Numba folds the divisions into reciprocal products +/// and `fy - c` into `L / 116 + (16/116 - c)`. +pub fn lab_to_rgb(src: &[f32], dst: &mut [f32], s: LabSpace) { + dst.par_chunks_mut(BLOCK * 3) + .zip(src.par_chunks(BLOCK * 3)) + .for_each(|(d, p)| lab_to_rgb_block(p, d, s)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn lab_to_rgb_block(src: &[f32], dst: &mut [f32], s: LabSpace) { + for (d, &[l, a, b]) in dst + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(src.as_chunks::<3>().0) + { + let [xr, yr, zr] = lab_to_xyz(l, a, b, &s); + *d = std::array::from_fn(|c| { + let v = row(s.m[c], xr, yr, zr); + if v > 0.0 { v as f32 } else { 0.0 } + }); + } +} + +/// XYZ of one Lab triplet in float64, in the Numba build's order. +#[inline(always)] +fn lab_to_xyz(l: f32, a: f32, b: f32, s: &LabSpace) -> [f64; 3] { + let inv_kappa = s.inv_kappa; + let (l, a, b) = (l as f64, a as f64, b as f64); + let fy_l = l * (1.0 / 116.0); + let fy = fy_l + 16.0 / 116.0; + let fx = a.mul_add(0.002, fy); + let fz = (-b).mul_add(0.005, fy); + let (fx3, fy3, fz3) = (fx * fx * fx, fy * fy * fy, fz * fz * fz); + let xr = if fx3 > s.eps { + fx3 * s.white[0] + } else { + ((fx - C) * s.white[0]) * inv_kappa + }; + let yr = if fy3 > s.eps { + fy3 + } else { + (fy_l + (16.0 / 116.0 - C)) * inv_kappa + } * s.white[1]; + let zr = if fz3 > s.eps { + fz3 + } else { + (fz - C) * inv_kappa + } * s.white[2]; + [xr, yr, zr] +} + +// Skin-tone mask: an axis-aligned reduction of the CIELAB skin locus. Hue stays put across the +// tonal range, chroma is bounded, and lightness is free apart from the two ends where the hue +// turns to noise. The chroma window is the measured skin locus, not a gamut bound: sunset, +// terracotta and brick sit inside the hue band, and wood, tan leather and sand at skin's own +// chroma are skin's colour. Neither is separable per pixel. +const HUE_CENTER: f32 = 52.0; +const HUE_WIDTH: f64 = 20.0; +const CHROMA_FULL: f32 = 35.0; +const CHROMA_ZERO: f32 = 60.0; +const L_LO: f32 = 15.0; +const L_HI: f32 = 95.0; +// The ceiling is CEIL_AT_FULL / strength, so it runs past any reachable chroma as strength nears +// 0 and the control fades out without a step. The knee starts at a fraction of the ceiling, so +// ordinary skin below it passes untouched. +const CEIL_AT_FULL: f32 = 22.0; +const KNEE_START: f32 = 0.6; + +/// `smoothstep` of a clamped `t`, as float64: Numba returns `float(...)`. +#[inline(always)] +fn clamp01(t: f32) -> f64 { + if t < 0.0 { + 0.0 + } else if t > 1.0 { + 1.0 + } else { + t as f64 + } +} + +/// `_skin_chroma_rein_kernel` over N x 3 Lab. Numba's fastmath build folds the smoothstep +/// divisions into reciprocal constants and regroups the weight's products; this copies its +/// machine code. +pub fn skin_chroma_rein(src: &[f32], dst: &mut [f32], strength: f32) { + let ceiling = CEIL_AT_FULL / strength; + let start = ceiling * KNEE_START; + let span = ceiling * (1.0 - KNEE_START); + let inv_span = 1.0f32 / span; + dst.par_chunks_mut(BLOCK * 3) + .zip(src.par_chunks(BLOCK * 3)) + .for_each(|(d, p)| rein_block(p, d, (start, span, inv_span))); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn rein_block(src: &[f32], dst: &mut [f32], (start, span, inv_span): (f32, f32, f32)) { + // The skin candidates of each run are gathered first, so their weights compute in one + // vectorized loop; every other pixel keeps its values (a scale of 1 is exact). + const RUN: usize = 512; + let mut idx = [0usize; RUN]; + let mut cand = [[0f32; 4]; RUN]; + let mut scale = [1f64; RUN]; + dst.copy_from_slice(src); + for (d, p) in dst.chunks_mut(3 * RUN).zip(src.chunks(3 * RUN)) { + let mut n = 0; + for (i, &[l, a, b]) in p.as_chunks::<3>().0.iter().enumerate() { + let chroma = b.mul_add(b, a * a).sqrt(); + idx[n] = i; + cand[n] = [l, a, b, chroma]; + // `chroma <= start` is unordered-or-less: a NaN chroma keeps the pixel. + n += !(chroma <= start || chroma < 2.0) as usize; + } + for (s, &[l, a, b, chroma]) in scale[..n].iter_mut().zip(&cand[..n]) { + let w = skin_weight(l, a, b, chroma); + let gap = start - chroma; + let e = crate::mathf::exp_f64((gap * inv_span) as f64) as f32; + let knee_gap = (1.0 - e).mul_add(span, gap); + let c = chroma as f64; + let rein = w.mul_add(knee_gap as f64, c) / c; + *s = if w > 0.0 { rein } else { 1.0 }; + } + let out = d.as_chunks_mut::<3>().0; + for ((&i, &[_, a, b, _]), &s) in idx[..n].iter().zip(&cand[..n]).zip(&scale[..n]) { + out[i][1] = (s * a as f64) as f32; + out[i][2] = (s * b as f64) as f32; + } + } +} + +/// `_skin_weight`: 0 below a chroma of 2, where the hue angle is noise. +pub fn skin_weight_of(l: f32, a: f32, b: f32) -> f64 { + let chroma = b.mul_add(b, a * a).sqrt(); + if chroma < 2.0 { + 0.0 + } else { + skin_weight(l, a, b, chroma) + } +} + +/// `_skin_weight` for a chroma above 2. +#[inline(always)] +fn skin_weight(l: f32, a: f32, b: f32, chroma: f32) -> f64 { + let deg = 180.0f32 / std::f32::consts::PI; + let dist = crate::mathf::atan2f(b, a).mul_add(deg, -HUE_CENTER); + let turns = (dist * (1.0f32 / 360.0)).round_ties_even(); + let wrapped = (-(turns as f64)).mul_add(360.0, dist as f64); + let w_hue = crate::mathf::exp_f64(wrapped * wrapped * (-0.5 / (HUE_WIDTH * HUE_WIDTH))); + let window = CHROMA_ZERO - CHROMA_FULL; + let tc = clamp01(chroma.mul_add(1.0 / window, -CHROMA_FULL / window)); + let w_chroma = (-(tc * tc)).mul_add((-tc).mul_add(2.0, 3.0), 1.0); + let t_lo = clamp01(l * (1.0 / L_LO)); + let t_hi = clamp01(l.mul_add(1.0 / (100.0 - L_HI), -L_HI / (100.0 - L_HI))); + let rise = (w_hue * w_chroma) * ((t_lo * t_lo) * (-t_lo).mul_add(2.0, 3.0)); + let fall = (t_hi * t_hi) * (-t_hi).mul_add(2.0, 3.0); + (-fall).mul_add(rise, rise) +} + +/// `_matmul_3x3_kernel` over N x 3 float32 pixels, contracted as Numba does. +pub fn matmul_3x3(src: &[f32], dst: &mut [f32], m: [[f32; 3]; 3]) { + dst.par_chunks_mut(BLOCK * 3) + .zip(src.par_chunks(BLOCK * 3)) + .for_each(|(d, p)| matmul_block(p, d, m)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn matmul_block(src: &[f32], dst: &mut [f32], m: [[f32; 3]; 3]) { + for (d, &[r, g, b]) in dst + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(src.as_chunks::<3>().0) + { + *d = m.map(|row| b.mul_add(row[2], g.mul_add(row[1], row[0] * r))); + } +} + +/// `_in_gamut_lab`: (L, a, b) decodes to linear RGB within 0..1, 1e-4 either side; the bounds +/// are float32 sums, compared in float64. +#[inline(always)] +fn in_gamut(l: f32, a: f32, b: f32, s: &LabSpace) -> bool { + let [xr, yr, zr] = lab_to_xyz(l, a, b, s); + let (lo, hi) = (-1e-4f32 as f64, (1.0f32 + 1e-4f32) as f64); + s.m.iter().fold(true, |ok, &m| { + let v = row(m, xr, yr, zr); + ok & (v >= lo) & (v <= hi) + }) +} + +/// `_gamut_aware_chroma_scale_kernel` over N x 3 Lab: the flat `saturation` where its full push +/// stays in gamut, else a softplus knee toward the pixel's own headroom, found by bisection. +pub fn gamut_aware_chroma_scale( + src: &[f32], + dst: &mut [f32], + saturation: f32, + iters: usize, + s: LabSpace, +) { + dst.par_chunks_mut(BLOCK * 3) + .zip(src.par_chunks(BLOCK * 3)) + .for_each(|(d, p)| gamut_block(p, d, saturation, iters, &s)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn gamut_block(src: &[f32], dst: &mut [f32], saturation: f32, iters: usize, s: &LabSpace) { + if saturation <= 1.0 { + // L times 1 is exact; a flat pattern of 48 lanes vectorizes where triplets do not. + let pattern: [f32; 48] = std::array::from_fn(|i| if i % 3 == 0 { 1.0 } else { saturation }); + for (d, p) in dst.chunks_mut(48).zip(src.chunks(48)) { + for ((d, &p), &k) in d.iter_mut().zip(p).zip(&pattern) { + *d = p * k; + } + } + return; + } + // Pixels whose full push clips are gathered per run and bisected in lockstep, so each step + // vectorizes across them. + const RUN: usize = 512; + let mut idx = [0usize; RUN]; + let mut cand = [[0f32; 3]; RUN]; + let mut ok = [false; RUN]; + let mut full = [false; RUN]; + let (mut lo, mut hi) = ([1f32; RUN], [saturation; RUN]); + for (d, sp) in dst.chunks_mut(3 * RUN).zip(src.chunks(3 * RUN)) { + let (px, src_px) = (d.as_chunks_mut::<3>().0, sp.as_chunks::<3>().0); + for (f, &[l, a, b]) in full.iter_mut().zip(src_px) { + *f = in_gamut(l, a * saturation, b * saturation, s); + } + let mut n = 0; + for (i, ((p, &q), &f)) in px.iter_mut().zip(src_px).zip(&full).enumerate() { + idx[n] = i; + cand[n] = q; + n += !f as usize; + *p = [q[0], q[1] * saturation, q[2] * saturation]; + } + for (k, &[l, a, b]) in ok[..n].iter_mut().zip(&cand[..n]) { + *k = in_gamut(l, a, b, s); + } + lo[..n].fill(1.0); + hi[..n].fill(saturation); + for _ in 0..iters { + for j in 0..n { + let [l, a, b] = cand[j]; + let mid = (lo[j] + hi[j]) / 2.0; + let fits = ok[j] & in_gamut(l, a * mid, b * mid, s); + lo[j] = if fits { mid } else { lo[j] }; + hi[j] = if fits { hi[j] } else { mid }; + } + } + for j in 0..n { + let [l, a, b] = cand[j]; + let knee = lo[j].max(1.0f32 + 1e-4f32) - 1.0; + let e = crate::mathf::exp_f64(((1.0 - saturation) / knee) as f64) as f32; + let eff = (1.0 - e).mul_add(knee, 1.0); + px[idx[j]] = [l, a * eff, b * eff]; + } + } +} diff --git a/negpy_core/src/components.rs b/negpy_core/src/components.rs new file mode 100644 index 000000000..0bd43a9ba --- /dev/null +++ b/negpy_core/src/components.rs @@ -0,0 +1,174 @@ +//! `cv2.connectedComponents(WithStats)` for a uint8 mask, with OpenCV's labels: its Spaghetti +//! labelling numbers 4-connected components in raster order of their first pixel, and +//! 8-connected ones in raster order of their first 2x2 block. The numbering does not depend on +//! OpenCV's thread count. + +pub struct Components { + pub count: usize, + pub labels: Vec, + /// Per label: left, top, width, height, area. + pub stats: Vec<[i32; 5]>, + pub centroids: Vec<[f64; 2]>, +} + +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 +} + +fn union(parent: &mut [u32], a: u32, b: u32) -> u32 { + let (ra, rb) = (find(parent, a), find(parent, b)); + let (lo, hi) = (ra.min(rb), ra.max(rb)); + parent[hi as usize] = lo; + lo +} + +/// The `[x0, x1)` runs of nonzero bytes in a row; zero words of eight are skipped whole. +fn row_runs(row: &[u8]) -> Vec<(usize, usize)> { + let mut runs = Vec::new(); + let mut x = 0; + let w = row.len(); + while x < w { + if x + 8 <= w && row[x..x + 8].iter().all(|&v| v == 0) { + x += 8; + continue; + } + if row[x] == 0 { + x += 1; + continue; + } + let start = x; + while x < w && row[x] != 0 { + x += 1; + } + runs.push((start, x)); + } + runs +} + +/// Components of the nonzero pixels of an `h` x `w` mask, `connectivity` 4 or 8, labelled +/// over runs of each row so that only foreground pixels cost work. +pub fn connected_components(mask: &[u8], h: usize, w: usize, connectivity: u8) -> Components { + use rayon::prelude::*; + let rows: Vec> = mask.par_chunks(w.max(1)).map(row_runs).collect(); + // An 8-connected run touches runs above that reach one column past either end. + let reach = usize::from(connectivity == 8); + let mut parent: Vec = vec![0]; + let mut run_label: Vec> = Vec::with_capacity(h); + for y in 0..h { + let mut labels = Vec::with_capacity(rows[y].len()); + let above = if y > 0 { &rows[y - 1][..] } else { &[] }; + let above_labels = if y > 0 { &run_label[y - 1][..] } else { &[] }; + let mut j = 0; + for &(x0, x1) in &rows[y] { + let lo = x0.saturating_sub(reach); + let hi = x1 + reach; + while j < above.len() && above[j].1 <= lo { + j += 1; + } + let mut l = 0u32; + let mut k = j; + while k < above.len() && above[k].0 < hi { + let a = above_labels[k]; + l = if l == 0 { + find(&mut parent, a) + } else { + union(&mut parent, l, a) + }; + k += 1; + } + if l == 0 { + parent.push(parent.len() as u32); + l = (parent.len() - 1) as u32; + } + labels.push(l); + } + run_label.push(labels); + } + + // Number the roots by the first pixel (4-connected) or 2x2 block (8-connected) of their + // component in raster order; a run's first pixel is its smallest key. + let blocks_w = w.div_ceil(2); + let mut first = vec![usize::MAX; parent.len()]; + for y in 0..h { + for (&(x0, _), &l) in rows[y].iter().zip(&run_label[y]) { + let root = find(&mut parent, l) as usize; + let key = if connectivity == 8 { + (y / 2) * blocks_w + x0 / 2 + } else { + y * w + x0 + }; + first[root] = first[root].min(key); + } + } + let mut roots: Vec = (1..parent.len()) + .filter(|&r| first[r] != usize::MAX) + .collect(); + roots.sort_by_key(|&r| first[r]); + let mut final_label = vec![0i32; parent.len()]; + for (i, &r) in roots.iter().enumerate() { + final_label[r] = i as i32 + 1; + } + + let count = roots.len() + 1; + let mut labels = vec![0i32; h * w]; + let mut lo = vec![[i32::MAX, i32::MAX, i32::MIN, i32::MIN, 0i32]; count]; + let mut integrals = vec![[0u64; 2]; count]; + let sum_to = |n: usize| (n as u64) * (n as u64).saturating_sub(1) / 2; + for y in 0..h { + // The background between this row's runs. + let mut gaps = Vec::with_capacity(rows[y].len() + 1); + let mut x = 0; + for &(x0, x1) in &rows[y] { + if x0 > x { + gaps.push((x, x0)); + } + x = x1; + } + if x < w { + gaps.push((x, w)); + } + let fg = rows[y].iter().zip(&run_label[y]).map(|(&r, &l)| { + let root = find(&mut parent, l) as usize; + (r, final_label[root]) + }); + let spans: Vec<((usize, usize), i32)> = + fg.chain(gaps.into_iter().map(|g| (g, 0))).collect(); + for ((x0, x1), l) in spans { + if l != 0 { + labels[y * w + x0..y * w + x1].fill(l); + } + let s = &mut lo[l as usize]; + let yi = y as i32; + s[0] = s[0].min(x0 as i32); + s[1] = s[1].min(yi); + s[2] = s[2].max(x1 as i32 - 1); + s[3] = s[3].max(yi); + s[4] += (x1 - x0) as i32; + integrals[l as usize][0] += sum_to(x1) - sum_to(x0); + integrals[l as usize][1] += (y * (x1 - x0)) as u64; + } + } + let mut stats = Vec::with_capacity(count); + let mut centroids = Vec::with_capacity(count); + for (s, i) in lo.iter().zip(&integrals) { + let area = s[4] as f64; + if area > 0.0 { + stats.push([s[0], s[1], s[2] - s[0] + 1, s[3] - s[1] + 1, s[4]]); + centroids.push([i[0] as f64 / area, i[1] as f64 / area]); + } else { + stats.push([-1, s[1], 0, 0, 0]); + centroids.push([f64::NAN, f64::NAN]); + } + } + Components { + count, + labels, + stats, + centroids, + } +} diff --git a/negpy_core/src/contours.rs b/negpy_core/src/contours.rs new file mode 100644 index 000000000..daab80077 --- /dev/null +++ b/negpy_core/src/contours.rs @@ -0,0 +1,424 @@ +//! `cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)`, OpenCV 4.13's +//! Suzuki–Abe scanner (contours_new.cpp) with its marks, so contours and points come out in +//! OpenCV's order. + +const DELTAS: [(isize, isize); 8] = [ + (1, 0), + (1, -1), + (0, -1), + (-1, -1), + (-1, 0), + (-1, 1), + (0, 1), + (1, 1), +]; +const RIGHT: i8 = -128; // 0x80 +const NEW: i8 = 2; +const FLAGS: i8 = -2; // 0xFE + +/// Outer contours of the nonzero pixels of an `h` x `w` uint8 image, each a list of (x, y) +/// corner points, in OpenCV's output order (last found first). +pub fn find_external_contours(src: &[u8], h: usize, w: usize) -> Vec> { + // One pixel of zero border, values thresholded to 0/1, as findContours prepares them. + let (pw, ph) = (w + 2, h + 2); + let mut img = vec![0i8; pw * ph]; + for (line, row) in img.chunks_exact_mut(pw).skip(1).zip(src.chunks_exact(w)) { + for (d, &v) in line[1..=w].iter_mut().zip(row) { + *d = (v > 0) as i8; + } + } + let delta = |s: usize| DELTAS[s % 8].0 + DELTAS[s % 8].1 * pw as isize; + let mut found: Vec> = Vec::new(); + + let (width, height) = (pw - 1, ph - 1); + let mut last_pos = (0usize, 1usize); + for y in 1..height { + let mut prev: i8 = 0; + let mut x = 1; + while x < width { + let line = &img[y * pw..y * pw + width]; + match first_other(&line[x..], prev) { + Some(skip) => x += skip, + None => break, + } + let p = img[y * pw + x]; + // contourScan for RETR_EXTERNAL: start only at an outer border whose left + // neighbour is not inside an already traced contour. + let mut start = false; + if prev == 0 && p == 1 { + start = img[last_pos.1 * pw + last_pos.0] <= 0; + } else if p == 0 && prev >= 1 && prev & FLAGS != 0 { + last_pos.0 = x - 1; + } + if start { + last_pos.0 = x; + found.push(trace( + &mut img, + y * pw + x, + (x as i32 - 1, y as i32 - 1), + 4, + &delta, + )); + } else { + prev = p; + if prev & FLAGS != 0 { + last_pos.0 = x; + } + } + x += 1; + if start { + prev = img[y * pw + x - 1]; + } + } + last_pos = (0, y + 1); + } + found.reverse(); + found +} + +/// `cv2.findContours(img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)`: every outer and hole border +/// of the nonzero pixels, in OpenCV's output order. +pub fn find_list_contours(src: &[u8], h: usize, w: usize) -> Vec> { + let (pw, ph) = (w + 2, h + 2); + let mut img = vec![0i8; pw * ph]; + for (line, row) in img.chunks_exact_mut(pw).skip(1).zip(src.chunks_exact(w)) { + for (d, &v) in line[1..=w].iter_mut().zip(row) { + *d = (v > 0) as i8; + } + } + let delta = |s: usize| DELTAS[s % 8].0 + DELTAS[s % 8].1 * pw as isize; + let mut found: Vec> = Vec::new(); + let (width, height) = (pw - 1, ph - 1); + for y in 1..height { + let mut prev: i8 = 0; + let mut x = 1; + while x < width { + match first_other(&img[y * pw + x..y * pw + width], prev) { + Some(skip) => x += skip, + None => break, + } + let p = img[y * pw + x]; + // contourScan: an outer border starts at 0 -> 1, a hole at an inside pixel -> 0. + let hole = if prev == 0 && p == 1 { + Some(false) + } else if p == 0 && prev >= 1 { + Some(true) + } else { + None + }; + match hole { + Some(hole) => { + let sx = x - hole as usize; + let s_end = if hole { 0 } else { 4 }; + found.push(trace( + &mut img, + y * pw + sx, + (sx as i32 - 1, y as i32 - 1), + s_end, + &delta, + )); + x += 1; + prev = img[y * pw + x - 1]; + } + None => { + prev = p; + x += 1; + } + } + } + } + found.reverse(); + found +} + +/// `cv2.approxPolyDP(curve, epsilon, closed)` of integer points, OpenCV 4.13's +/// Douglas-Peucker with its segment-distance test and final straight-line clean-up. +pub fn approx_poly_dp(src: &[(i32, i32)], eps: f64, closed: bool) -> Vec<(i32, i32)> { + let mut count = src.len(); + if count == 0 { + return Vec::new(); + } + let eps = eps * eps; + let mut dst: Vec<(i32, i32)> = Vec::with_capacity(count); + let mut stack: Vec<(usize, usize)> = Vec::new(); + let mut is_closed = closed; + let mut init_iters = 3; + let mut right = (0usize, 0usize); + let mut start_pt = (-1_000_000i32, -1_000_000i32); + let mut pos = 0usize; + let mut le_eps = false; + if !is_closed { + right.0 = count; + let (end_pt, first) = (src[0], src[count - 1]); + start_pt = first; + if start_pt != end_pt { + stack.push((0, count - 1)); + } else { + is_closed = true; + init_iters = 1; + } + } + if is_closed { + right.0 = 0; + for _ in 0..init_iters { + let mut max_dist = 0f64; + pos = (pos + right.0) % count; + start_pt = src[pos]; + pos = (pos + 1) % count; + for j in 1..count { + let pt = src[pos]; + pos = (pos + 1) % count; + let (dx, dy) = ((pt.0 - start_pt.0) as f64, (pt.1 - start_pt.1) as f64); + let dist = dx * dx + dy * dy; + if dist > max_dist { + max_dist = dist; + right.0 = j; + } + } + le_eps = max_dist <= eps; + } + if !le_eps { + let start = pos % count; + right.1 = start; + right.0 = (right.0 + start) % count; + stack.push(right); + stack.push((start, right.0)); + } else { + dst.push(start_pt); + } + } + while let Some(slice) = stack.pop() { + let end_pt = src[slice.1]; + start_pt = src[slice.0]; + pos = (slice.0 + 1) % count; + if pos != slice.1 { + let (dx, dy) = ( + (end_pt.0 - start_pt.0) as f64, + (end_pt.1 - start_pt.1) as f64, + ); + let seg = dx * dx + dy * dy; + let mut max_d = 0f64; + while pos != slice.1 { + let pt = src[pos]; + pos = (pos + 1) % count; + let (px, py) = (pt.0 - start_pt.0, pt.1 - start_pt.1); + let projection = px as f64 * dx + py as f64 * dy; + let d = if projection < 0.0 { + (px * px + py * py) as f64 * seg + } else if projection > seg { + let (ex, ey) = (pt.0 - end_pt.0, pt.1 - end_pt.1); + (ex * ex + ey * ey) as f64 * seg + } else { + let dist = py as f64 * dx - px as f64 * dy; + dist * dist + }; + if d > max_d { + max_d = d; + right.0 = (pos + count - 1) % count; + } + } + le_eps = max_d <= eps * seg; + } else { + le_eps = true; + } + if le_eps { + dst.push(start_pt); + } else { + stack.push((right.0, slice.1)); + stack.push((slice.0, right.0)); + } + } + if !is_closed { + dst.push(src[count - 1]); + } + // Clean-up: drop points on nearly straight runs. + let is_closed = closed; + count = dst.len(); + let mut new_count = count; + let mut pos = if is_closed { count - 1 } else { 0 }; + let read = |pos: &mut usize, d: &[(i32, i32)]| { + let p = d[*pos]; + *pos += 1; + if *pos >= count { + *pos = 0; + } + p + }; + let mut start_pt = read(&mut pos, &dst); + let mut wpos = pos; + let mut pt = read(&mut pos, &dst); + let open = !is_closed as usize; + let mut i = open; + while i < count - open && new_count > 2 { + let end_pt = read(&mut pos, &dst); + let (dx, dy) = ( + (end_pt.0 - start_pt.0) as f64, + (end_pt.1 - start_pt.1) as f64, + ); + let dist = ((pt.0 - start_pt.0) as f64 * dy - (pt.1 - start_pt.1) as f64 * dx).abs(); + let inner = ((pt.0 - start_pt.0) * (end_pt.0 - pt.0) + + (pt.1 - start_pt.1) * (end_pt.1 - pt.1)) as f64; + if dist * dist <= 0.5 * eps * (dx * dx + dy * dy) && dx != 0.0 && dy != 0.0 && inner >= 0.0 + { + new_count -= 1; + start_pt = end_pt; + dst[wpos] = end_pt; + wpos += 1; + if wpos >= count { + wpos = 0; + } + pt = read(&mut pos, &dst); + i += 2; + continue; + } + start_pt = pt; + dst[wpos] = pt; + wpos += 1; + if wpos >= count { + wpos = 0; + } + pt = end_pt; + i += 1; + } + if !is_closed { + dst[wpos] = pt; + } + dst.truncate(new_count); + dst +} + +/// The index of the first byte that is not `v`, eight bytes at a time over the long runs. +fn first_other(line: &[i8], v: i8) -> Option { + let run = u64::from_ne_bytes([v as u8; 8]); + let (words, _) = line.as_chunks::<8>(); + let mut i = 0; + for word in words { + if u64::from_ne_bytes(word.map(|b| b as u8)) != run { + break; + } + i += 8; + } + line[i..].iter().position(|&b| b != v).map(|p| i + p) +} + +/// `icvFetchContourEx` for a border from `start` (`s_end_outer` 4 for an outer border, 0 for a hole) (an index into the padded image), +/// with CHAIN_APPROX_SIMPLE: a point wherever the chain direction changes. +fn trace( + img: &mut [i8], + start: usize, + origin: (i32, i32), + s_end_outer: usize, + delta: &dyn Fn(usize) -> isize, +) -> Vec<(i32, i32)> { + let at = |i: usize, d: isize| (i as isize + d) as usize; + let mut pts = Vec::new(); + let mut pt = origin; + let i0 = start; + let mut s = s_end_outer; + let mut i1; + loop { + s = (s + 7) & 7; + i1 = at(i0, delta(s)); + if img[i1] != 0 || s == s_end_outer { + break; + } + } + if s == s_end_outer { + img[i0] = NEW | RIGHT; + pts.push(pt); + return pts; + } + let mut i3 = i0; + let mut prev_s = s ^ 4; + loop { + let s_end = s; + let mut i4 = i3; + s = s.min(15); + while s < 15 { + s += 1; + i4 = at(i3, delta(s)); + if img[i4] != 0 { + break; + } + } + s &= 7; + if s.wrapping_sub(1) < s_end { + img[i3] = NEW | RIGHT; + } else if img[i3] == 1 { + img[i3] = NEW; + } + if s != prev_s { + pts.push(pt); + } + prev_s = s; + pt = (pt.0 + DELTAS[s].0 as i32, pt.1 + DELTAS[s].1 as i32); + if i4 == i0 && i3 == i1 { + break; + } + i3 = i4; + s = (s + 4) & 7; + } + pts +} + +/// `cv2.contourArea` of integer points: the shoelace sum in double, unsigned. +pub fn contour_area(pts: &[(i32, i32)]) -> f64 { + let Some(&last) = pts.last() else { return 0.0 }; + let mut prev = (last.0 as f32, last.1 as f32); + let mut a = 0f64; + for &(x, y) in pts { + let p = (x as f32, y as f32); + a += prev.0 as f64 * p.1 as f64 - prev.1 as f64 * p.0 as f64; + prev = p; + } + (a * 0.5).abs() +} + +/// `cv2.boundingRect` of integer points: (x, y, width, height). +pub fn bounding_rect(pts: &[(i32, i32)]) -> (i32, i32, i32, i32) { + if pts.is_empty() { + return (0, 0, 0, 0); + } + let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); + for &(x, y) in pts { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + (x0, y0, x1 - x0 + 1, y1 - y0 + 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn square_gives_its_four_corners() { + let (h, w) = (6, 7); + let mut img = vec![0u8; h * w]; + for y in 1..4 { + for x in 2..6 { + img[y * w + x] = 255; + } + } + let c = find_external_contours(&img, h, w); + assert_eq!(c, vec![vec![(2, 1), (2, 3), (5, 3), (5, 1)]]); + } + + #[test] + fn single_pixel_and_nested_blob() { + let (h, w) = (9, 9); + let mut img = vec![0u8; h * w]; + img[w + 1] = 1; + for y in 3..8 { + for x in 3..8 { + img[y * w + x] = 1; + } + } + img[5 * w + 5] = 0; // a hole: not an external contour + let c = find_external_contours(&img, h, w); + assert_eq!(c.len(), 2); + assert_eq!(c[1], vec![(1, 1)]); + } +} diff --git a/negpy_core/src/convert.rs b/negpy_core/src/convert.rs new file mode 100644 index 000000000..780043b43 --- /dev/null +++ b/negpy_core/src/convert.rs @@ -0,0 +1,240 @@ +//! The pixel-format kernels of negpy/kernel/image/logic.py: float to uint8/uint16 and back, +//! luminance to uint, and the RGB/RGBA copies of the texture paths. + +use multiversion::multiversion; +use rayon::prelude::*; + +const LUMA_R: f64 = 0.2126; +const LUMA_G: f64 = 0.7152; +const LUMA_B: f64 = 0.0722; +const BLOCK: usize = 1 << 14; + +/// `v * scale + 0.5` clipped to 0..scale, NaN to 0, then truncated: the rounding of +/// `_to_uint8_jit` and `_to_uint16_jit`. A float32 times the scale is exact in float64. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn quantize_block(src: &[f32], dst: &mut [T]) { + for (d, &v) in dst.iter_mut().zip(src) { + // max and min drop NaN, so NaN becomes 0. + *d = T::truncate((v as f64 * T::SCALE + 0.5).max(0.0).min(T::SCALE)); + } +} + +pub fn to_uint(src: &[f32], dst: &mut [T]) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK)) + .for_each(|(d, s)| quantize_block(s, d)); +} + +/// `uint8_to_float32`: the float32 value times 1/255 in float64, rounded to float32. +pub fn uint8_to_float32(src: &[u8], dst: &mut [f32]) { + let inv = 1.0 / 255.0; + crate::par_zip(dst, src, move |d, &v| *d = (v as f32 as f64 * inv) as f32); +} + +/// `uint16_to_float32`: the float32 value times 1/65535 in float64, rounded to float32. +pub fn uint16_to_float32(src: &[u16], dst: &mut [f32]) { + let inv = 1.0 / 65535.0; + crate::par_zip(dst, src, move |d, &v| *d = (v as f32 as f64 * inv) as f32); +} + +/// Numba's fastmath luminance in float64: two fused multiply-adds. +fn luma(p: &[f32]) -> f64 { + LUMA_B.mul_add( + p[2] as f64, + LUMA_G.mul_add(p[1] as f64, LUMA_R * p[0] as f64), + ) +} + +pub trait Level: Copy + Send { + const SCALE: f64; + fn truncate(v: f64) -> Self; +} + +/// The integer part of `v` in 0..2^31: added to 2^52, it fills the low mantissa bits. The +/// saturating `as` cast keeps LLVM from vectorizing the loops; this rounds and adds instead. +pub(crate) fn trunc_bits(v: f64) -> u64 { + (v.trunc() + 4503599627370496.0).to_bits() & ((1 << 52) - 1) +} + +impl Level for u8 { + const SCALE: f64 = 255.0; + fn truncate(v: f64) -> Self { + trunc_bits(v) as u8 + } +} + +impl Level for u16 { + const SCALE: f64 = 65535.0; + fn truncate(v: f64) -> Self { + trunc_bits(v) as u16 + } +} + +/// `_float_to_uint8_luma_jit` / `_float_to_uint16_luma_jit` over HxWx3 pixels. The +/// scale-and-round fuses too under fastmath. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn luma_block(src: &[f32], cn: usize, dst: &mut [T]) { + let scale = T::SCALE; + // NaN becomes 0, as Numba's cast of it does. + let clip = |v: f64| v.max(0.0).min(scale); + match cn { + 1 => { + for (d, &v) in dst.iter_mut().zip(src) { + *d = T::truncate(clip(v as f64 * scale + 0.5)); + } + } + 3 => { + for (d, p) in dst.iter_mut().zip(src.as_chunks::<3>().0) { + *d = T::truncate(clip(luma(p).mul_add(scale, 0.5))); + } + } + _ => { + for (d, p) in dst.iter_mut().zip(src.chunks_exact(cn)) { + *d = T::truncate(clip(luma(p).mul_add(scale, 0.5))); + } + } + } +} + +pub fn luma_to_uint(src: &[f32], cn: usize, dst: &mut [T]) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK * cn)) + .for_each(|(d, s)| luma_block(s, cn, d)); +} + +/// The first three channels of each `cn`-channel pixel of `src` into the RGBA `dst`. +pub fn rgb_to_rgba(src: &[f32], cn: usize, dst: &mut [f32]) { + dst.par_chunks_mut(4 * BLOCK) + .zip(src.par_chunks(cn * BLOCK)) + .for_each(|(d, s)| { + for (dp, sp) in d.as_chunks_mut::<4>().0.iter_mut().zip(s.chunks_exact(cn)) { + dp[..3].copy_from_slice(&sp[..3]); + } + }); +} + +/// `a * b` of two float32 arrays of one shape. +pub fn multiply(a: &[f32], b: &[f32], dst: &mut [f32]) { + const BLOCK: usize = 1 << 14; + dst.par_chunks_mut(BLOCK) + .zip(a.par_chunks(BLOCK).zip(b.par_chunks(BLOCK))) + .for_each(|(o, (a, b))| { + for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) { + *o = a * b; + } + }); +} + +/// `img.mean(axis=2)` of an HxWx3 float32 image: ((r + g) + b) / 3 in float32, NumPy's order. +pub fn mean_channels(src: &[f32], dst: &mut [f32]) { + crate::par_zip(dst, src.as_chunks::<3>().0, |d, p| { + *d = ((p[0] + p[1]) + p[2]) / 3.0; + }); +} + +/// Channel `c` of each pixel of every source into an HxWx3 image, one source per channel. +pub fn pick_channels(sources: [&[T]; 3], dst: &mut [T]) { + dst.par_chunks_mut(3 * BLOCK) + .enumerate() + .for_each(|(i, d)| { + let o = i * 3 * BLOCK; + for (j, p) in d.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let k = o + 3 * j; + *p = [sources[0][k], sources[1][k + 1], sources[2][k + 2]]; + } + }); +} + +/// Channel `c` of an image with `cn` channels, as a plane. +pub fn extract_channel(src: &[f32], cn: usize, c: usize, dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(cn * BLOCK)) + .for_each(|(d, s)| { + for (d, p) in d.iter_mut().zip(s.chunks_exact(cn)) { + *d = p[c]; + } + }); +} + +/// `np.stack([lum, lum, lum], axis=-1)`. +pub fn gray_to_rgb(src: &[f32], dst: &mut [f32]) { + crate::par_zip(dst.as_chunks_mut::<3>().0, src, |d, &v| *d = [v; 3]); +} + +/// `a + b` of two float32 arrays of one shape. +pub fn add(a: &[f32], b: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(a.par_chunks(BLOCK).zip(b.par_chunks(BLOCK))) + .for_each(|(o, (a, b))| { + for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) { + *o = a + b; + } + }); +} + +/// `img[..., c].any()`: whether channel `c` holds a nonzero (NaN counts, as in NumPy). +pub fn channel_any(src: &[f32], cn: usize, c: usize) -> bool { + src.par_chunks(cn * BLOCK) + .any(|s| s.chunks_exact(cn).any(|p| p[c] != 0.0)) +} + +/// `np.clip(src, 0, 1)` into `dst`; NaN stays. +pub fn clip_unit(src: &[f32], dst: &mut [f32]) { + crate::par_zip(dst, src, |d, &v| *d = v.clamp(0.0, 1.0)); +} + +/// `np.nan_to_num(src, nan=nan, posinf=posinf, neginf=neginf)` into `dst`. +pub fn nan_to_num(src: &[f32], dst: &mut [f32], (nan, posinf, neginf): (f32, f32, f32)) { + crate::par_zip(dst, src, move |d, &v| { + *d = if v.is_nan() { + nan + } else if v == f32::INFINITY { + posinf + } else if v == f32::NEG_INFINITY { + neginf + } else { + v + }; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quantize_rounds_clips_and_zeroes_nan() { + let src = [ + 0.0, + 1.0, + 0.5 / 255.0, + 0.499 / 255.0, + -1.0, + 2.0, + f32::NAN, + f32::INFINITY, + ]; + let mut dst = [0u8; 8]; + to_uint(&src, &mut dst); + assert_eq!(dst, [0, 255, 1, 0, 0, 255, 0, 255]); + let mut wide = [0u16; 8]; + to_uint(&src, &mut wide); + assert_eq!(wide[1], 65535); + assert_eq!(wide[6], 0); + } + + #[test] + fn trunc_bits_is_the_integer_part() { + for v in [0.0, 0.999, 1.0, 254.9, 65535.0, 2_147_483_647.0] { + assert_eq!(trunc_bits(v) & 0xffff_ffff, v.trunc() as u64, "{v}"); + } + } +} diff --git a/negpy_core/src/detect.rs b/negpy_core/src/detect.rs new file mode 100644 index 000000000..cc88c0366 --- /dev/null +++ b/negpy_core/src/detect.rs @@ -0,0 +1,1125 @@ +//! `detect_film_bounds_with_confidence`: the outer film box by agreement across adaptive +//! thresholds, corroborated by structural masks (geometry/logic.py). + +use rayon::prelude::*; + +use crate::analysis::{Snap, crop, detection_plane, rgb, snap_edge_to_gradient}; +use crate::contours::{contour_area, find_external_contours}; +use crate::filter::Border; +use crate::gray; +use crate::morph::{self, Op}; +use crate::shapes::{bounding_rect_f32, box_points, fill_convex_poly, min_area_rect}; +use crate::stats::{np_median, np_percentiles, np_percentiles_f64, pairwise_sum_f32}; + +pub type Roi = (i64, i64, i64, i64); + +const FINE_ROTATION_LIMIT: f64 = 45.0; +const ADAPTIVE_THRESHOLD_COUNT: f64 = 7.0; +// Film fills most of a scan under every known holder; on a positive whose bed clips to white, a +// dark subject can pass every other check on its own geometry. +const MIN_FILM_BOX_COVERAGE: f64 = 0.25; +pub const SIDE_NAMES: [&str; 4] = ["top", "right", "bottom", "left"]; +pub const CORNER_SIDES: [(&str, [usize; 2]); 4] = [ + ("top_left", [0, 3]), + ("top_right", [0, 1]), + ("bottom_right", [2, 1]), + ("bottom_left", [2, 3]), +]; +pub const EVIDENCE_SOURCE_ORDER: [&str; 5] = [ + "adaptive-dark", + "adaptive-bright", + "inverse-threshold", + "blackhat", + "edges", +]; + +fn py_round(v: f64) -> i64 { + v.round_ties_even() as i64 +} + +fn clip(v: f64, lo: f64, hi: f64) -> f64 { + v.clamp(lo, hi) +} + +/// `_odd_kernel_size(value, lower, upper)`. +fn odd_kernel_size(value: f64, lower: i64, upper: i64) -> i64 { + let mut size = py_round(value).clamp(lower, upper); + if size % 2 == 0 { + size = if size < upper { size + 1 } else { size - 1 }; + } + size.max(1) +} + +/// `_vertical_edge_profile(lum)`: per column, the normalized mean |Sobel x|, and its contrast. +fn vertical_edge_profile(lum: &[f32], h: usize, w: usize) -> (Vec, f64) { + if lum.is_empty() { + return (Vec::new(), 0.0); + } + let mut grad = vec![0f32; h * w]; + if crate::filter::sep_filter2d( + lum, + &mut grad, + (h, w, 1), + (&[-1.0, 0.0, 1.0], &[1.0, 2.0, 1.0]), + Border::Reflect101, + ) + .is_none() + { + return (vec![0.0; w], 0.0); + } + // np.mean(axis=0): summed down each column. + let mut profile = vec![0f32; w]; + for r in grad.chunks_exact(w) { + for (p, &g) in profile.iter_mut().zip(r) { + *p += g.abs(); + } + } + profile.iter_mut().for_each(|p| *p /= h as f32); + let window = odd_kernel_size(0.006 * w as f64, 3, 15) as usize; + let mut blurred = vec![0f32; w]; + if crate::filter::gaussian_blur( + &profile, + &mut blurred, + (1, w, 1), + (window, 1), + (0.0, 0.0), + Border::Reflect101, + ) + .is_none() + { + blurred = profile; + } + let p = np_percentiles(&blurred, &[50.0, 99.5]).unwrap_or(vec![0.0, 0.0]); + let (baseline, normalizer) = (p[0] as f64, p[1] as f64); + let contrast = (normalizer - baseline).max(0.0); + if normalizer > 1e-8 { + let n32 = normalizer as f32; + blurred + .iter_mut() + .for_each(|v| *v = (*v / n32).clamp(0.0, 1.0)); + } else { + blurred.fill(0.0); + } + (blurred, contrast) +} + +/// `_outer_ring_values(lum)`: the frame's outer ring, top, bottom, left, right. +/// 2% wide: a tightly framed scan leaves 1-3% of bed, so a wider ring reads film and fails the +/// ring gate in `oriented_boundary_evidence` on a good box. +fn outer_ring_values(lum: &[f32], h: usize, w: usize) -> Vec { + let m = h.min(w); + let ring = (py_round(0.02 * m as f64).max(2) as usize).min((m / 2).max(1)); + let mut out = Vec::new(); + out.extend_from_slice(&lum[..ring.min(h) * w]); + out.extend_from_slice(&lum[h.saturating_sub(ring) * w..]); + if h > 2 * ring { + for y in ring..h - ring { + out.extend_from_slice(&lum[y * w..y * w + ring.min(w)]); + } + for y in ring..h - ring { + out.extend_from_slice(&lum[y * w + w.saturating_sub(ring)..(y + 1) * w]); + } + } + out +} + +/// `_threshold_polarities`: which polarities the trials sample. +fn threshold_polarities( + lum: &[f32], + h: usize, + w: usize, + span: f64, + ring_median: f64, +) -> Vec<&'static str> { + let (y1, y2) = ( + py_round(0.2 * h as f64) as usize, + py_round(0.8 * h as f64) as usize, + ); + let (x1, x2) = ( + py_round(0.2 * w as f64) as usize, + py_round(0.8 * w as f64) as usize, + ); + let (y2, x2) = (y2.max(y1 + 1).min(h), x2.max(x1 + 1).min(w)); + let center_median = np_median(&mut crop(lum, w, (y1.min(h), y2, x1.min(w), x2))) as f64; + let gate = 0.025f64.max(0.05 * span); + if ring_median - center_median > gate { + vec!["dark"] + } else if center_median - ring_median > gate { + vec!["bright"] + } else { + vec!["dark", "bright"] + } +} + +fn rect_close_open( + mask: &[u8], + h: usize, + w: usize, + close: (usize, usize, usize), + open: (usize, usize, usize), +) -> Vec { + let mut a = vec![0u8; h * w]; + let mut b = vec![0u8; h * w]; + let _ = morph::morphology( + mask, + &mut a, + (h, w, 1), + Op::Close, + (close.0, close.1), + close.2, + Border::Constant, + ); + let _ = morph::morphology( + &a, + &mut b, + (h, w, 1), + Op::Open, + (open.0, open.1), + open.2, + Border::Constant, + ); + b +} + +/// `_adaptive_threshold_mask(blurred_lum, threshold, polarity)`. +fn adaptive_threshold_mask( + blurred: &[f32], + h: usize, + w: usize, + threshold: f64, + dark: bool, +) -> Vec { + let t = threshold as f32; + let mask: Vec = blurred + .iter() + .map(|&v| { + if (dark && v <= t) || (!dark && v >= t) { + 255 + } else { + 0 + } + }) + .collect(); + let m = h.min(w) as f64; + let c = odd_kernel_size(0.010 * m, 3, 21) as usize; + let o = odd_kernel_size(0.003 * m, 3, 9) as usize; + rect_close_open(&mask, h, w, (c, c, 1), (o, o, 1)) +} + +fn mask_from_inverse_threshold(gray_img: &[u8], h: usize, w: usize) -> Vec { + let mut blurred = vec![0u8; h * w]; + let _ = gray::gaussian_blur_u8( + gray_img, + &mut blurred, + (h, w, 1), + (7, 7), + (0.0, 0.0), + Border::Reflect101, + ); + let mut thresh = vec![0u8; h * w]; + let t = gray::otsu_threshold(&blurred); + gray::threshold_binary(&blurred, &mut thresh, t, 255, true); + let mut closed = vec![0u8; h * w]; + let _ = morph::morphology( + &thresh, + &mut closed, + (h, w, 1), + Op::Close, + (17, 17), + 2, + Border::Constant, + ); + let mut out = vec![0u8; h * w]; + let _ = morph::morphology( + &closed, + &mut out, + (h, w, 1), + Op::Open, + (5, 5), + 1, + Border::Constant, + ); + out +} + +fn mask_from_blackhat(gray_img: &[u8], h: usize, w: usize) -> Vec { + let mut bh = vec![0u8; h * w]; + let _ = morph::morphology( + gray_img, + &mut bh, + (h, w, 1), + Op::BlackHat, + (31, 31), + 1, + Border::Constant, + ); + let mut blurred = vec![0u8; h * w]; + let _ = gray::gaussian_blur_u8( + &bh, + &mut blurred, + (h, w, 1), + (5, 5), + (0.0, 0.0), + Border::Reflect101, + ); + let mut thresh = vec![0u8; h * w]; + let t = gray::otsu_threshold(&blurred); + gray::threshold_binary(&blurred, &mut thresh, t, 255, false); + let mut out = vec![0u8; h * w]; + let _ = morph::morphology( + &thresh, + &mut out, + (h, w, 1), + Op::Close, + (21, 21), + 2, + Border::Constant, + ); + out +} + +fn mask_from_edges(gray_img: &[u8], h: usize, w: usize) -> Vec { + let mut blurred = vec![0u8; h * w]; + let _ = gray::gaussian_blur_u8( + gray_img, + &mut blurred, + (h, w, 1), + (5, 5), + (0.0, 0.0), + Border::Reflect101, + ); + let mut edges = vec![0u8; h * w]; + let _ = gray::canny(&blurred, &mut edges, h, w, 40.0, 160.0); + let mut dilated = vec![0u8; h * w]; + let _ = morph::morphology( + &edges, + &mut dilated, + (h, w, 1), + Op::Dilate, + (7, 7), + 2, + Border::Constant, + ); + let mut out = vec![0u8; h * w]; + let _ = morph::morphology( + &dilated, + &mut out, + (h, w, 1), + Op::Close, + (15, 15), + 2, + Border::Constant, + ); + out +} + +#[derive(Clone, Debug)] +pub struct Candidate { + pub roi: Roi, + pub correction_angle: f64, + pub source: &'static str, + pub threshold_index: Option, + pub polarity: &'static str, + pub boundary_score: f64, + pub geometry_score: f64, + pub sides: [bool; 4], + pub corners: [bool; 4], +} + +/// np.linalg.norm of a float32 2-vector: sqrt((-0 + x*x) + y*y) in float32. +fn norm2(x: f32, y: f32) -> f32 { + ((-0.0f32 + x * x) + y * y).sqrt() +} + +/// `_order_quad`: top-left, top-right, bottom-right, bottom-left. +fn order_quad(q: &[(f32, f32); 4]) -> [(f32, f32); 4] { + let sums: Vec = q.iter().map(|p| (-0.0f32 + p.0) + p.1).collect(); + let diffs: Vec = q.iter().map(|p| p.1 - p.0).collect(); + let argmin = |v: &[f32]| { + v.iter() + .enumerate() + .fold(0, |b, (i, &x)| if x < v[b] { i } else { b }) + }; + let argmax = |v: &[f32]| { + v.iter() + .enumerate() + .fold(0, |b, (i, &x)| if x > v[b] { i } else { b }) + }; + [ + q[argmin(&sums)], + q[argmin(&diffs)], + q[argmax(&sums)], + q[argmax(&diffs)], + ] +} + +/// `_polygon_values(lum, polygon)`: the finite luma values under a filled convex polygon. +fn polygon_values(lum: &[f32], h: usize, w: usize, poly: &[(f32, f32); 4]) -> Vec { + let minx = poly.iter().map(|p| p.0).fold(f32::INFINITY, f32::min); + let maxx = poly.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max); + let miny = poly.iter().map(|p| p.1).fold(f32::INFINITY, f32::min); + let maxy = poly.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max); + let left = (minx as f64).floor().max(0.0) as i64; + let right = ((maxx as f64).ceil() as i64 + 1).min(w as i64); + let top = (miny as f64).floor().max(0.0) as i64; + let bottom = ((maxy as f64).ceil() as i64 + 1).min(h as i64); + if right <= left || bottom <= top { + return Vec::new(); + } + let (mw, mh) = ((right - left) as usize, (bottom - top) as usize); + let local: Vec<(i64, i64)> = poly + .iter() + .map(|p| { + ( + (p.0 - left as f32).round_ties_even() as i64, + (p.1 - top as f32).round_ties_even() as i64, + ) + }) + .collect(); + let mut mask = vec![0u8; mw * mh]; + fill_convex_poly(&mut mask, mw, mh, &local, 1); + let mut out = Vec::new(); + for y in 0..mh { + for x in 0..mw { + if mask[y * mw + x] != 0 { + let v = lum[(top as usize + y) * w + left as usize + x]; + if v.is_finite() { + out.push(v); + } + } + } + } + out +} + +/// `_oriented_boundary_evidence`: (boundary score, supported sides, supported corners). +fn oriented_boundary_evidence( + lum: &[f32], + h: usize, + w: usize, + quad: &[(f32, f32); 4], + dark: bool, + ring_median: f64, + span: f64, +) -> (f64, [bool; 4], [bool; 4]) { + let ordered = order_quad(quad); + let min_edge = (0..4) + .map(|i| { + norm2( + ordered[(i + 1) % 4].0 - ordered[i].0, + ordered[(i + 1) % 4].1 - ordered[i].1, + ) + }) + .fold(f32::INFINITY, f32::min); + let band = py_round(0.012 * 1.0f64.max(min_edge as f64)).clamp(2, 24) as f64; + let contrast_gate = 0.02f64.max(0.04 * span); + let contrast_scale = 0.06f64.max(0.20 * span); + let ring_tolerance = 0.06f64.max(0.16 * span); + let mut supported = [false; 4]; + let mut scores = Vec::new(); + for i in 0..4 { + let (start, end) = (ordered[i], ordered[(i + 1) % 4]); + let vector = (end.0 - start.0, end.1 - start.1); + let length = norm2(vector.0, vector.1) as f64; + if length < 4.0 { + continue; + } + // Skip the corners, where interpolation and sprocket holes mix the two populations. + // The ordered points run clockwise in image coordinates, so (-dy, dx) points inward. + let k = 0.08f32; + let inner_start = (start.0 + k * vector.0, start.1 + k * vector.1); + let inner_end = (end.0 - k * vector.0, end.1 - k * vector.1); + let l32 = length as f32; + let inward = (-vector.1 / l32, vector.0 / l32); + let b32 = band as f32; + let off = (inward.0 * b32, inward.1 * b32); + let inside_poly = [ + inner_start, + inner_end, + (inner_end.0 + off.0, inner_end.1 + off.1), + (inner_start.0 + off.0, inner_start.1 + off.1), + ]; + let outside_poly = [ + inner_start, + inner_end, + (inner_end.0 - off.0, inner_end.1 - off.1), + (inner_start.0 - off.0, inner_start.1 - off.1), + ]; + let mut inside = polygon_values(lum, h, w, &inside_poly); + let mut outside = polygon_values(lum, h, w, &outside_poly); + let expected = 8.0f64.max(0.20 * 0.84 * length * band); + if (inside.len() as f64) < expected || (outside.len() as f64) < expected { + continue; + } + let (im, om) = ( + np_median(&mut inside) as f64, + np_median(&mut outside) as f64, + ); + let contrast = if dark { om - im } else { im - om }; + let ring_delta = (om - ring_median).abs(); + if contrast < contrast_gate + || ring_delta > ring_tolerance + || contrast.is_nan() + || ring_delta.is_nan() + { + continue; + } + let cs = clip(contrast / contrast_scale, 0.0, 1.0); + let rs = clip(1.0 - ring_delta / ring_tolerance, 0.0, 1.0); + supported[i] = true; + scores.push(0.75 * cs + 0.25 * rs); + } + let mut corners = [false; 4]; + for (c, (_, sides)) in CORNER_SIDES.iter().enumerate() { + corners[c] = supported[sides[0]] && supported[sides[1]]; + } + let boundary = scores.iter().fold(0.0f64, |s, &v| s + v) / 4.0; + (boundary, supported, corners) +} + +/// `_correction_angle_from_quad(quad, aspect_ratio)`: the residual deskew in degrees. +fn correction_angle_from_quad(quad: &[(f32, f32); 4], aspect: f64) -> f64 { + // A near-square box has no stable orientation axis. + if aspect < 1.12 { + return 0.0; + } + let edges: Vec<(f32, f32)> = (0..4) + .map(|i| { + ( + quad[(i + 1) % 4].0 - quad[i].0, + quad[(i + 1) % 4].1 - quad[i].1, + ) + }) + .collect(); + let norms: Vec = edges.iter().map(|e| norm2(e.0, e.1)).collect(); + let k = norms + .iter() + .enumerate() + .fold(0, |b, (i, &n)| if n > norms[b] { i } else { b }); + // Python's math.degrees multiplies by 180 / pi. + let mut angle = + libm::atan2(edges[k].1 as f64, edges[k].0 as f64) * (180.0 / std::f64::consts::PI); + while angle >= 90.0 { + angle -= 180.0; + } + while angle < -90.0 { + angle += 180.0; + } + if angle > 45.0 { + angle -= 90.0; + } else if angle < -45.0 { + angle += 90.0; + } + // OpenCV's positive fine rotation turns a horizontal edge to a negative image angle, so the + // measured residual is already the additive fix. + clip(angle, -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT) +} + +struct Scene<'a> { + lum: &'a [f32], + h: usize, + w: usize, + ring_median: f64, + span: f64, +} + +/// `_candidate_from_contour`. +fn candidate_from_contour( + contour: &[(i32, i32)], + s: &Scene, + source: &'static str, + threshold_index: Option, + polarity: Option<&'static str>, +) -> Option { + let (h, w) = (s.h, s.w); + let image_area = (h * w) as f64; + let rect = min_area_rect(contour); + let (rw, rh) = (rect.size.0 as f64, rect.size.1 as f64); + let rect_area = rw * rh; + if rect_area <= 0.0 { + return None; + } + let area = contour_area(contour); + let area_ratio = rect_area / image_area.max(1.0); + let (short, long) = (rw.min(rh), rw.max(rh)); + let aspect = long / short.max(1.0); + let rectangularity = area / rect_area; + if !(0.08..=1.02).contains(&area_ratio) { + return None; + } + if short < 8.0f64.max(0.04 * h.min(w) as f64) || aspect > 8.0 || rectangularity < 0.30 { + return None; + } + let quad = box_points(&rect); + let (x, y, bw, bh) = bounding_rect_f32(&quad); + let (x, y, bw, bh) = (x as i64, y as i64, bw as i64, bh as i64); + let roi = ( + y.max(0), + (y + bh).min(h as i64), + x.max(0), + (x + bw).min(w as i64), + ); + if roi.1 <= roi.0 || roi.3 <= roi.2 { + return None; + } + let polarities: Vec<&'static str> = match polarity { + Some(p) => vec![p], + None => vec!["dark", "bright"], + }; + let mut best: Option = None; + for p in polarities { + let (boundary, sides, corners) = + oriented_boundary_evidence(s.lum, h, w, &quad, p == "dark", s.ring_median, s.span); + let n_sides = sides.iter().filter(|&&b| b).count(); + if n_sides < 2 { + continue; + } + let rs = clip((rectangularity - 0.30) / 0.65, 0.0, 1.0); + let geometry = 0.70 * rs + 0.30 * (n_sides as f64 / 4.0); + let c = Candidate { + roi, + correction_angle: correction_angle_from_quad(&quad, aspect), + source, + threshold_index, + polarity: p, + boundary_score: boundary, + geometry_score: clip(geometry, 0.0, 1.0), + sides, + corners, + }; + let better = match &best { + None => true, + Some(b) => (c.boundary_score, c.geometry_score) > (b.boundary_score, b.geometry_score), + }; + if better { + best = Some(c); + } + } + best +} + +/// `_candidates_from_mask`: the twelve largest contours (stable by area, largest first) scored. +fn candidates_from_mask( + mask: &[u8], + s: &Scene, + source: &'static str, + threshold_index: Option, + polarity: Option<&'static str>, +) -> Vec { + let mut contours = find_external_contours(mask, s.h, s.w); + let areas: Vec = contours.iter().map(|c| contour_area(c)).collect(); + let mut order: Vec = (0..contours.len()).collect(); + order.sort_by(|&a, &b| areas[b].total_cmp(&areas[a])); + let picked: Vec> = order + .iter() + .take(12) + .map(|&i| std::mem::take(&mut contours[i])) + .collect(); + picked + .iter() + .filter_map(|c| candidate_from_contour(c, s, source, threshold_index, polarity)) + .collect() +} + +fn roi_iou(a: &Roi, b: &Roi) -> f64 { + let (y1, y2, x1, x2) = (a.0.max(b.0), a.1.min(b.1), a.2.max(b.2), a.3.min(b.3)); + let inter = (y2 - y1).max(0) * (x2 - x1).max(0); + let area = |r: &Roi| (r.1 - r.0).max(0) * (r.3 - r.2).max(0); + inter as f64 / ((area(a) + area(b) - inter) as f64).max(1.0) +} + +fn normalized_edge_distance(a: &Roi, b: &Roi, h: usize, w: usize) -> f64 { + let scales = [ + h.max(1) as f64, + h.max(1) as f64, + w.max(1) as f64, + w.max(1) as f64, + ]; + let (av, bv) = ([a.0, a.1, a.2, a.3], [b.0, b.1, b.2, b.3]); + (0..4) + .map(|i| (av[i] - bv[i]).abs() as f64 / scales[i]) + .fold(f64::NEG_INFINITY, f64::max) +} + +/// `_cluster_film_candidates`: union-find on overlapping boxes, groups in first-seen order. +fn cluster_candidates(c: &[Candidate], h: usize, w: usize) -> Vec> { + let n = c.len(); + let mut parents: Vec = (0..n).collect(); + fn find(p: &mut [usize], mut i: usize) -> usize { + while p[i] != i { + p[i] = p[p[i]]; + i = p[i]; + } + i + } + for a in 0..n { + for b in a + 1..n { + if roi_iou(&c[a].roi, &c[b].roi) < 0.78 { + continue; + } + if normalized_edge_distance(&c[a].roi, &c[b].roi, h, w) <= 0.035 { + let (ra, rb) = (find(&mut parents, a), find(&mut parents, b)); + if ra != rb { + parents[rb] = ra; + } + } + } + } + let mut roots: Vec = Vec::new(); + let mut groups: Vec> = Vec::new(); + for i in 0..n { + let r = find(&mut parents, i); + match roots.iter().position(|&x| x == r) { + Some(g) => groups[g].push(i), + None => { + roots.push(r); + groups.push(vec![i]); + } + } + } + groups +} + +/// The geometry module's `_weighted_median`: values sorted (ties by position), the first whose +/// cumulative weight reaches half the total. +fn weighted_median(values: &[f64], weights: &[f64]) -> f64 { + let mut order: Vec = (0..values.len()).collect(); + order.sort_by(|&a, &b| values[a].total_cmp(&values[b])); + let ow: Vec = order.iter().map(|&i| weights[i]).collect(); + let total = if ow.len() < 8 { + ow.iter().fold(-0.0f64, |s, &v| s + v) + } else { + pairwise_sum_f64(&ow) + }; + let cutoff = 0.5 * total; + let mut cum = 0.0f64; + let mut index = ow.len(); + for (i, &v) in ow.iter().enumerate() { + cum += v; + if cum >= cutoff { + index = i; + break; + } + } + values[order[index.min(order.len() - 1)]] +} + +/// NumPy's float64 pairwise sum of a contiguous run. +fn pairwise_sum_f64(a: &[f64]) -> f64 { + let n = a.len(); + if n < 8 { + return a.iter().fold(-0.0, |s, &v| s + v); + } + if n <= 128 { + let mut r = [0f64; 8]; + r.copy_from_slice(&a[..8]); + let mut i = 8; + while i < n - n % 8 { + for j in 0..8 { + r[j] += a[i + j]; + } + i += 8; + } + let mut res = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7])); + for &v in &a[i..] { + res += v; + } + return res; + } + let mut n2 = n / 2; + n2 -= n2 % 8; + pairwise_sum_f64(&a[..n2]) + pairwise_sum_f64(&a[n2..]) +} + +#[derive(Clone)] +pub struct Consensus { + pub roi: Roi, + pub correction_angle: f64, + pub confidence: f64, + pub sides: Vec<&'static str>, + pub corners: Vec<&'static str>, + pub sources: Vec<&'static str>, + pub geometry_score: f64, +} + +/// `_select_consensus_cluster`. +pub fn select_consensus(cands: &[Candidate], h: usize, w: usize) -> Option { + let mut accepted: Vec = Vec::new(); + for raw in cluster_candidates(cands, h, w) { + // _dedupe_cluster: the best candidate per trial, in first-seen trial order. + let mut keys: Vec<(&str, Option, &str)> = Vec::new(); + let mut cluster: Vec<&Candidate> = Vec::new(); + for &i in &raw { + let c = &cands[i]; + let key = (c.source, c.threshold_index, c.polarity); + match keys.iter().position(|k| *k == key) { + Some(k) => { + if (c.boundary_score, c.geometry_score) + > (cluster[k].boundary_score, cluster[k].geometry_score) + { + cluster[k] = c; + } + } + None => { + keys.push(key); + cluster.push(c); + } + } + } + let mut adaptive: Vec<(&str, usize)> = Vec::new(); + let mut structural: Vec<&str> = Vec::new(); + for c in &cluster { + if c.source.starts_with("adaptive-") { + if let Some(t) = c.threshold_index + && !adaptive.contains(&(c.polarity, t)) + { + adaptive.push((c.polarity, t)); + } + } else if !structural.contains(&c.source) { + structural.push(c.source); + } + } + if adaptive.len() < 3 && !(adaptive.len() >= 2 && structural.len() >= 2) { + continue; + } + let weights: Vec = cluster + .iter() + .map(|c| 0.05f64.max(c.boundary_score * c.geometry_score)) + .collect(); + let edge = |e: usize, c: &Candidate| [c.roi.0, c.roi.1, c.roi.2, c.roi.3][e] as f64; + let roi_values: Vec> = (0..4) + .map(|e| cluster.iter().map(|c| edge(e, c)).collect()) + .collect(); + let r: Vec = roi_values + .iter() + .map(|v| py_round(weighted_median(v, &weights))) + .collect(); + let roi = (r[0], r[1], r[2], r[3]); + if roi.1 <= roi.0 || roi.3 <= roi.2 { + continue; + } + let scales = [ + h.max(1) as f64, + h.max(1) as f64, + w.max(1) as f64, + w.max(1) as f64, + ]; + let mads: Vec = (0..4) + .map(|e| { + let center = weighted_median(&roi_values[e], &weights); + let dev: Vec = roi_values[e] + .iter() + .map(|v| (v - center).abs() / scales[e]) + .collect(); + weighted_median(&dev, &weights) + }) + .collect(); + let dispersion = mads.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let stability = clip(1.0 - dispersion / 0.03, 0.0, 1.0); + let angles: Vec = cluster.iter().map(|c| c.correction_angle).collect(); + let correction_angle = weighted_median(&angles, &weights); + let angle_dev: Vec = angles + .iter() + .map(|a| (a - correction_angle).abs()) + .collect(); + let angle_stability = clip(1.0 - weighted_median(&angle_dev, &weights) / 3.0, 0.0, 1.0); + let geo: Vec = cluster.iter().map(|c| c.geometry_score).collect(); + let geometry_score = clip( + 0.55 * weighted_median(&geo, &weights) + 0.25 * stability + 0.20 * angle_stability, + 0.0, + 1.0, + ); + let bnd: Vec = cluster.iter().map(|c| c.boundary_score).collect(); + let boundary = weighted_median(&bnd, &weights); + let support = 1.0f64.min(adaptive.len() as f64 / ADAPTIVE_THRESHOLD_COUNT); + let corroboration = 1.0f64.min(structural.len() as f64 / 3.0); + let confidence = clip( + 0.40 * support + + 0.25 * stability + + 0.20 * boundary + + 0.10 * geometry_score + + 0.05 * corroboration, + 0.0, + 1.0, + ); + if confidence < 0.55 { + continue; + } + // _supported_by_majority: values present in at least half the cluster. + let needed = ((0.5 * cluster.len() as f64).ceil() as usize).max(1); + let sides: Vec<&'static str> = (0..4) + .filter(|&i| cluster.iter().filter(|c| c.sides[i]).count() >= needed) + .map(|i| SIDE_NAMES[i]) + .collect(); + let corners: Vec<&'static str> = (0..4) + .filter(|&i| cluster.iter().filter(|c| c.corners[i]).count() >= needed) + .map(|i| CORNER_SIDES[i].0) + .collect(); + let mut sources: Vec<&'static str> = EVIDENCE_SOURCE_ORDER + .iter() + .copied() + .filter(|s| cluster.iter().any(|c| c.source == *s)) + .collect(); + let mut rest: Vec<&'static str> = cluster + .iter() + .map(|c| c.source) + .filter(|s| !sources.contains(s)) + .collect(); + rest.sort(); + rest.dedup(); + sources.extend(rest); + accepted.push(Consensus { + roi, + correction_angle, + confidence, + sides, + corners, + sources, + geometry_score, + }); + } + // Confidence first; area breaks a tie between inner and outer clusters toward the film. + let area = |c: &Consensus| (c.roi.1 - c.roi.0) * (c.roi.3 - c.roi.2); + let mut best: Option = None; + for c in accepted { + let better = match &best { + None => true, + Some(b) => (c.confidence, area(&c)) > (b.confidence, area(b)), + }; + if better { + best = Some(c); + } + } + best +} + +pub fn film_box_covers_enough(h: usize, w: usize, roi: &Roi) -> bool { + h > 0 + && w > 0 + && ((roi.1 - roi.0) * (roi.3 - roi.2)) as f64 >= MIN_FILM_BOX_COVERAGE * (h * w) as f64 +} + +/// `_film_surround_is_plausible`: a light bed or a dark holder around the box. +pub fn film_surround_is_plausible(lum: &[f32], h: usize, w: usize, roi: &Roi) -> bool { + let clampy = |v: i64| v.clamp(0, h as i64) as usize; + let clampx = |v: i64| v.clamp(0, w as i64) as usize; + let (y1, y2, x1, x2) = (clampy(roi.0), clampy(roi.1), clampx(roi.2), clampx(roi.3)); + let mut out = Vec::with_capacity(h * w); + for (y, row) in lum.chunks_exact(w).enumerate() { + if y >= y1 && y < y2 && x2 > x1 { + out.extend_from_slice(&row[..x1]); + out.extend_from_slice(&row[x2..]); + } else { + out.extend_from_slice(row); + } + } + if (out.len() as f64) < 0.005 * lum.len() as f64 { + return true; + } + let out_med = np_median(&mut out) as f64; + let out_high = np_percentiles(&out, &[90.0]).map_or(f64::NAN, |p| p[0] as f64); + let box_med = np_median(&mut crop(lum, w, (y1, y2.max(y1), x1, x2.max(x1)))) as f64; + // Near-clipping, not only bright: a thin negative's picture clears 0.85, but the bed is the + // light source at the top of the histogram, where detection_luma anchors it. + let bed_like = out_med >= 0.85 && out_high >= 0.98; + let holder_like = out_med <= 0.30 && out_med <= box_med - 0.15; + bed_like || holder_like +} + +/// `_snap_film_bounds_to_bed_gradient(roi, lum)`. +fn snap_film_bounds(roi: Roi, lum: &[f32], h: usize, w: usize) -> Roi { + let (y1, y2, x1, x2) = roi; + let (yu1, yu2, xu1, xu2) = ( + y1.max(0) as usize, + (y2.max(0) as usize).min(h), + x1.max(0) as usize, + (x2.max(0) as usize).min(w), + ); + let mut col = vec![0f32; w]; + for y in yu1..yu2 { + for (c, p) in col.iter_mut().enumerate() { + *p += lum[y * w + c]; + } + } + col.iter_mut().for_each(|p| *p /= (yu2 - yu1) as f32); + let row: Vec = (0..h) + .map(|y| pairwise_sum_f32(&lum[y * w + xu1..y * w + xu2]) / (xu2 - xu1) as f32) + .collect(); + // The 16 px floor: the mask morphology kernels are fixed-size, so their inflation does not + // shrink with the image the way the 2% window does. + let snap = Snap { + window_out: 0.02, + window_in: 0.02, + min_dominance: 3.0, + min_window_px: 16, + }; + let nx1 = snap_edge_to_gradient(&col, x1, true, snap); + let nx2 = snap_edge_to_gradient(&col, x2, false, snap); + let ny1 = snap_edge_to_gradient(&row, y1, true, snap); + let ny2 = snap_edge_to_gradient(&row, y2, false, snap); + if ny2 - ny1 <= 0 || nx2 - nx1 <= 0 { + return roi; + } + // Keep the bed-to-film rows inside the box: the refinement after this needs them. + ( + (ny1 - 2).max(0), + (ny2 + 2).min(h as i64), + (nx1 - 2).max(0), + (nx2 + 2).min(w as i64), + ) +} + +/// The structural masks of an HxW or HxWx3 image, in trial order. +fn structural_masks(img: &[f32], h: usize, w: usize, cn: usize) -> Vec<(&'static str, Vec)> { + let mut preview = vec![0u8; h * w * 3]; + crate::analysis::normalize_to_uint8(&rgb(img, cn), &mut preview); + let mut gray_img = vec![0u8; h * w]; + gray::bgr_to_gray(&preview, &mut gray_img); + ["inverse-threshold", "blackhat", "edges"] + .par_iter() + .map(|&source| { + let mask = match source { + "inverse-threshold" => mask_from_inverse_threshold(&gray_img, h, w), + "blackhat" => mask_from_blackhat(&gray_img, h, w), + _ => mask_from_edges(&gray_img, h, w), + }; + (source, mask) + }) + .collect() +} + +/// One adaptive threshold trial. +struct Trial { + source: &'static str, + index: usize, + polarity: &'static str, + threshold: f64, +} + +#[derive(Clone)] +pub struct Detection { + pub consensus: Option, + pub profile: Vec, + pub contrast: f64, +} + +/// `detect_film_bounds_with_confidence(img)` for an HxW or HxWx3 float32 image. +pub fn detect_film_bounds(img: &[f32], h: usize, w: usize, cn: usize) -> Detection { + static LAST: crate::memo::Memo = crate::memo::Memo::new(); + let key = crate::memo::key(img, &[h, w, cn]); + LAST.get_or(key, || detect(img, h, w, cn, key)) +} + +fn detect(img: &[f32], h: usize, w: usize, cn: usize, key: u128) -> Detection { + let empty = |profile: Vec, contrast: f64| Detection { + consensus: None, + profile, + contrast, + }; + if h < 2 || w < 2 { + return empty(Vec::new(), 0.0); + } + let Some(lum) = detection_plane(img, h, w, cn, key) else { + return empty(Vec::new(), 0.0); + }; + let lum = lum.as_slice(); + let threshold_inputs = || { + let lohi = np_percentiles_f64(lum, &[2.0, 98.0]); + let span = lohi[1] - lohi[0]; + if span < 0.04 || span.is_nan() { + return None; + } + let ring_median = np_median(&mut outer_ring_values(lum, h, w)) as f64; + let polarities = threshold_polarities(lum, h, w, span, ring_median); + let blur = odd_kernel_size(0.004 * h.min(w) as f64, 3, 9) as usize; + let mut blurred = vec![0f32; h * w]; + let _ = crate::filter::gaussian_blur( + lum, + &mut blurred, + (h, w, 1), + (blur, blur), + (0.0, 0.0), + Border::Reflect101, + ); + Some((lohi, span, ring_median, polarities, blurred)) + }; + // The structural masks need none of the threshold inputs, so they build alongside them. + let (((profile, contrast), inputs), masks) = rayon::join( + || rayon::join(|| vertical_edge_profile(lum, h, w), threshold_inputs), + || structural_masks(img, h, w, cn), + ); + let Some((lohi, span, ring_median, polarities, blurred)) = inputs else { + return empty(profile, contrast); + }; + let scene = Scene { + lum, + h, + w, + ring_median, + span, + }; + + // Trials in Python's order: each polarity's thresholds, then the three structural masks. + let mut trials: Vec = Vec::new(); + for &p in &polarities { + // Biased toward the surround, so the outer film edge of a three-tier negative gets at + // least as many trials as the inner picture edge. + let fractions: [f64; 7] = if p == "dark" { + [0.35, 0.50, 0.62, 0.72, 0.80, 0.87, 0.93] + } else { + [0.07, 0.13, 0.20, 0.28, 0.38, 0.50, 0.65] + }; + let (lo, hi) = (lohi[0], lohi[1]); + if hi - lo < 0.04 { + continue; + } + let source = if p == "dark" { + "adaptive-dark" + } else { + "adaptive-bright" + }; + for (i, f) in fractions.iter().enumerate() { + trials.push(Trial { + source, + index: i, + polarity: p, + threshold: lo + f * (hi - lo), + }); + } + } + let (mut results, structural_results): (Vec>, Vec>) = rayon::join( + || { + trials + .par_iter() + .map(|t| { + let mask = + adaptive_threshold_mask(&blurred, h, w, t.threshold, t.polarity == "dark"); + candidates_from_mask(&mask, &scene, t.source, Some(t.index), Some(t.polarity)) + }) + .collect() + }, + || { + masks + .par_iter() + .map(|(source, mask)| candidates_from_mask(mask, &scene, source, None, None)) + .collect() + }, + ); + results.extend(structural_results); + let candidates: Vec = results.into_iter().flatten().collect(); + + let Some(consensus) = select_consensus(&candidates, h, w) else { + return empty(profile, contrast); + }; + if !film_box_covers_enough(h, w, &consensus.roi) + || !film_surround_is_plausible(lum, h, w, &consensus.roi) + { + return empty(profile, contrast); + } + let roi = snap_film_bounds(consensus.roi, lum, h, w); + Detection { + consensus: Some(Consensus { roi, ..consensus }), + profile, + contrast, + } +} diff --git a/negpy_core/src/display.rs b/negpy_core/src/display.rs new file mode 100644 index 000000000..e86ee3fe8 --- /dev/null +++ b/negpy_core/src/display.rs @@ -0,0 +1,173 @@ +//! The display transforms of negpy/infrastructure/display/icc_lut.py: a 3D LUT applied by +//! trilinear interpolation, and the matrix/TRC transform between two profiles. Numba types the +//! fractions float64 (a float32 minus an integer), so the blends run in float64. + +// The blend steps index several per-pixel arrays of one run in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +/// An N x N x N x 3 float32 LUT indexed [r, g, b]. +#[derive(Clone, Copy)] +pub struct Lut3<'a> { + pub table: &'a [f32], + pub n: usize, +} + +/// x (1 - d) + y d, contracted as Numba's fastmath build contracts it. +#[inline(always)] +fn lerp(x: f64, y: f64, d: f64) -> f64 { + x.mul_add(1.0 - d, y * d) +} + +/// The lattice cell and float64 fractions of three scaled float32 coordinates (0 to n - 1). +#[inline(always)] +fn cell(pos: [f32; 3], n: usize) -> ((usize, usize, usize), (f64, f64, f64)) { + let idx = pos.map(|p| (crate::convert::trunc_bits(p as f64) as usize).min(n - 2)); + let frac: [f64; 3] = std::array::from_fn(|k| pos[k] as f64 - idx[k] as f64); + ((idx[0], idx[1], idx[2]), (frac[0], frac[1], frac[2])) +} + +/// Pixels per pass: the cell lookup and each channel's blend run as flat loops over a run, so +/// the corner reads vectorize as gathers. +const RUN: usize = 256; + +/// The LUT blend of a run of scaled coordinates (0 to n - 1), per channel. +#[inline(always)] +fn blend_run(lut: &Lut3, pos: &[[f32; 3]], out: &mut [[f64; RUN]; 3]) { + let n = lut.n; + let len = pos.len(); + let mut base = [0usize; RUN]; + let mut frac = [[0f64; RUN]; 3]; + for (i, p) in pos.iter().enumerate() { + let ((r0, g0, b0), (dr, dg, db)) = cell(*p, n); + base[i] = (r0 * n + g0) * n + b0; + frac[0][i] = dr; + frac[1][i] = dg; + frac[2][i] = db; + } + let t = lut.table; + let last = t.len() - 1; + let (sg, sr) = (n, n * n); + for (c, o) in out.iter_mut().enumerate() { + for i in 0..len { + let at = |off: usize| t[((base[i] + off) * 3 + c).min(last)] as f64; + let (dr, dg, db) = (frac[0][i], frac[1][i], frac[2][i]); + let c00 = lerp(at(0), at(1), db); + let c01 = lerp(at(sg), at(sg + 1), db); + let c10 = lerp(at(sr), at(sr + 1), db); + let c11 = lerp(at(sr + sg), at(sr + sg + 1), db); + o[i] = lerp(lerp(c00, c01, dg), lerp(c10, c11, dg), dr); + } + } +} + +/// `_apply_lut_u16_jit`: a uint16 image through the LUT, rounded half up and clipped. +pub fn apply_lut_u16(img: &[u16], lut: Lut3, out: &mut [u16]) { + out.par_chunks_mut(3 * 4096) + .zip(img.par_chunks(3 * 4096)) + .for_each(|(o, i)| lut_u16_block(i, o, lut)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn lut_u16_block(img: &[u16], out: &mut [u16], lut: Lut3) { + let scale = (lut.n - 1) as f32 / 65535.0; + let mut pos = [[0f32; 3]; RUN]; + let mut v = [[0f64; RUN]; 3]; + for (o, p) in out.chunks_mut(3 * RUN).zip(img.chunks(3 * RUN)) { + let (o, p) = (o.as_chunks_mut::<3>().0, p.as_chunks::<3>().0); + let pos = &mut pos[..p.len()]; + for (q, p) in pos.iter_mut().zip(p) { + *q = p.map(|x| x as f32 * scale); + } + blend_run(&lut, pos, &mut v); + for (c, v) in v.iter().enumerate() { + for (o, &x) in o.iter_mut().zip(v) { + o[c] = crate::convert::trunc_bits((x * 65535.0 + 0.5).clamp(0.0, 65535.0)) as u16; + } + } + } +} + +/// `_apply_lut_f32_jit`: a float32 image clipped to 0..1 through the LUT. +pub fn apply_lut_f32(img: &[f32], lut: Lut3, out: &mut [f32]) { + out.par_chunks_mut(3 * 4096) + .zip(img.par_chunks(3 * 4096)) + .for_each(|(o, i)| lut_f32_block(i, o, lut)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn lut_f32_block(img: &[f32], out: &mut [f32], lut: Lut3) { + let scale = (lut.n - 1) as f32; + let mut pos = [[0f32; 3]; RUN]; + let mut v = [[0f64; RUN]; 3]; + for (o, p) in out.chunks_mut(3 * RUN).zip(img.chunks(3 * RUN)) { + let (o, p) = (o.as_chunks_mut::<3>().0, p.as_chunks::<3>().0); + let pos = &mut pos[..p.len()]; + for (q, p) in pos.iter_mut().zip(p) { + *q = p.map(|x| x.clamp(0.0, 1.0) * scale); + } + blend_run(&lut, pos, &mut v); + for (c, v) in v.iter().enumerate() { + for (o, &x) in o.iter_mut().zip(v) { + o[c] = x as f32; + } + } + } +} + +/// `_matrix_trc_u8_jit`: TRC decode through `dec` (3 x 256), the 3x3 `m` with a gamut clip, +/// TRC encode through `enc` (3 x K) by linear interpolation, to 8-bit rounded half up. +pub fn matrix_trc_u8(img: &[u8], dec: &[f32], m: [[f32; 3]; 3], enc: &[f32], out: &mut [u8]) { + out.par_chunks_mut(3 * 4096) + .zip(img.par_chunks(3 * 4096)) + .for_each(|(o, i)| matrix_trc_block(i, o, dec, m, enc)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn matrix_trc_block(img: &[u8], out: &mut [u8], dec: &[f32], m: [[f32; 3]; 3], enc: &[f32]) { + let k = enc.len() / 3; + let kmax = (k - 1) as f64; + let (dlast, elast) = (dec.len() - 1, enc.len() - 1); + let mut rgb = [[0f32; 3]; RUN]; + let (mut idx, mut frac) = ([0usize; RUN], [0f64; RUN]); + for (o, p) in out.chunks_mut(3 * RUN).zip(img.chunks(3 * RUN)) { + let (o, p) = (o.as_chunks_mut::<3>().0, p.as_chunks::<3>().0); + let n = p.len(); + for (q, p) in rgb.iter_mut().zip(p) { + *q = std::array::from_fn(|c| dec[(c * 256 + p[c] as usize).min(dlast)]); + } + for c in 0..3 { + let row = m[c]; + // The index arithmetic vectorizes; the table reads stay scalar, faster than gathers. + for i in 0..n { + let q = rgb[i]; + let v = q[2] + .mul_add(row[2], q[1].mul_add(row[1], row[0] * q[0])) + .clamp(0.0, 1.0); + let f = v as f64 * kmax; + let i0 = (crate::convert::trunc_bits(f) as usize).min(k - 2); + idx[i] = c * k + i0; + frac[i] = f - i0 as f64; + } + for i in 0..n { + let (lo, hi) = (enc[idx[i].min(elast)], enc[(idx[i] + 1).min(elast)]); + let e = lerp(lo as f64, hi as f64, frac[i]); + o[i][c] = crate::convert::trunc_bits((e * 255.0 + 0.5).clamp(0.0, 255.0)) as u8; + } + } + } +} diff --git a/negpy_core/src/distance.rs b/negpy_core/src/distance.rs new file mode 100644 index 000000000..e44bcee32 --- /dev/null +++ b/negpy_core/src/distance.rs @@ -0,0 +1,259 @@ +//! `cv2.distanceTransform` for a uint8 mask: chamfer distances to the nearest zero pixel. +//! Without labels, opencv-python runs IPP's float32 two-pass chamfer; with labels, OpenCV's own +//! 5x5 pass in 16-bit fixed point. IPP's 3x3 pass picks a path one float32 unit longer at rare +//! pixels than this two-pass order does (negpy_core/LEDGER.md). + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Metric { + /// DIST_C, 3x3: steps of 1. + Chessboard, + /// DIST_L2, 3x3: 0.955 and 1.3693. + L2Mask3, + /// DIST_L2, 5x5: 1, 1.4 and 2.1969. + L2Mask5, +} + +/// (row offset, column offset, step index) of the forward pass; the backward pass mirrors it. +fn forward(metric: Metric) -> &'static [(isize, isize, usize)] { + match metric { + Metric::Chessboard | Metric::L2Mask3 => &[(-1, -1, 1), (-1, 0, 0), (-1, 1, 1), (0, -1, 0)], + Metric::L2Mask5 => &[ + (-2, -1, 2), + (-2, 1, 2), + (-1, -2, 2), + (-1, -1, 1), + (-1, 0, 0), + (-1, 1, 1), + (-1, 2, 2), + (0, -1, 0), + ], + } +} + +fn steps(metric: Metric) -> [f32; 3] { + match metric { + Metric::Chessboard => [1.0, 1.0, 0.0], + Metric::L2Mask3 => [0.955, 1.3693, 0.0], + Metric::L2Mask5 => [1.0, 1.4, 2.1969], + } +} + +/// The smaller of two finite distances: a plain compare, with no NaN handling in the chain. +fn lo(x: f32, y: f32) -> f32 { + if y < x { y } else { x } +} + +/// Distances in float32: a forward and a backward pass over a frame two pixels wide. Each row +/// first takes the minimum over the rows above (below) in a vector loop, then carries the +/// left (right) neighbour in a short sequential loop: the same sums and an exact minimum, so +/// the same bits as one combined pass. +pub fn distance_transform(src: &[u8], h: usize, w: usize, metric: Metric) -> Vec { + const B: usize = 2; + let pw = w + 2 * B; + let far = f32::MAX; + let mut t = vec![far; pw * (h + 2 * B)]; + let [a, b, c] = steps(metric); + let long = metric == Metric::L2Mask5; + let mut cand = vec![far; w]; + for y in B..h + B { + let (above, rest) = t.split_at_mut(y * pw); + let (up2, up1) = above[(y - 2) * pw..].split_at(pw); + let row = &mut rest[..pw]; + let s = &src[(y - B) * w..(y - B + 1) * w]; + vertical(&mut cand, up1, up2, (a, b, c), long); + for x in B..w + B { + row[x] = if s[x - B] == 0 { + 0.0 + } else { + lo(cand[x - B], row[x - 1] + a) + }; + } + } + let mut out = vec![0f32; h * w]; + for y in (B..h + B).rev() { + let (head, below) = t.split_at_mut((y + 1) * pw); + let row = &mut head[y * pw..]; + let (dn1, dn2) = below.split_at(pw); + vertical_back(&mut cand, dn1, dn2, (a, b, c), long); + let o = &mut out[(y - B) * w..(y - B + 1) * w]; + for x in (B..w + B).rev() { + let v0 = row[x]; + if v0 > a { + row[x] = lo(lo(v0, cand[x - B]), row[x + 1] + a); + } + o[x - B] = row[x]; + } + } + out +} + +/// The forward candidates from the two rows above, for the row's `w` pixels. +fn vertical(cand: &mut [f32], up1: &[f32], up2: &[f32], (a, b, c): (f32, f32, f32), long: bool) { + for (x, v) in cand.iter_mut().enumerate() { + let x = x + 2; + *v = lo(lo(up1[x - 1] + b, up1[x] + a), up1[x + 1] + b); + } + if long { + for (x, v) in cand.iter_mut().enumerate() { + let x = x + 2; + *v = lo( + lo(lo(lo(*v, up2[x - 1] + c), up2[x + 1] + c), up1[x - 2] + c), + up1[x + 2] + c, + ); + } + } +} + +/// The backward candidates from the two rows below. +fn vertical_back( + cand: &mut [f32], + dn1: &[f32], + dn2: &[f32], + (a, b, c): (f32, f32, f32), + long: bool, +) { + for (x, v) in cand.iter_mut().enumerate() { + let x = x + 2; + *v = lo(lo(dn1[x + 1] + b, dn1[x] + a), dn1[x - 1] + b); + } + if long { + for (x, v) in cand.iter_mut().enumerate() { + let x = x + 2; + *v = lo( + lo(lo(lo(*v, dn2[x + 1] + c), dn2[x - 1] + c), dn1[x + 2] + c), + dn1[x - 2] + c, + ); + } + } +} + +/// `cv2.distanceTransformWithLabels(src, DIST_L2, ..., labelType=DIST_LABEL_PIXEL)`: OpenCV's +/// 5x5 fixed-point pass, each pixel labelled with its nearest zero pixel, zeros numbered from 1 +/// in raster order. +pub fn distance_transform_labels(src: &[u8], h: usize, w: usize) -> (Vec, Vec) { + const B: usize = 2; + const SHIFT: i32 = 16; + let fix = |v: f32| ((v as f64) * (1 << SHIFT) as f64).round_ties_even() as u32; + let [hv, diag, long] = steps(Metric::L2Mask5).map(fix); + let cost = [hv, diag, long]; + let far = u32::MAX - long; + let scale = 1.0f32 / (1 << SHIFT) as f32; + let (pw, ph) = (w + 2 * B, h + 2 * B); + let mut t = vec![far; pw * ph]; + let mut lab = vec![0i32; pw * ph]; + let mut next = 1; + for y in 0..h { + for x in 0..w { + if src[y * w + x] == 0 { + lab[(y + B) * pw + x + B] = next; + next += 1; + } + } + } + let fwd = forward(Metric::L2Mask5); + let at = |y: usize, x: usize, dy: isize, dx: isize| { + ((y as isize + dy) as usize) * pw + (x as isize + dx) as usize + }; + for y in B..h + B { + for x in B..w + B { + if src[(y - B) * w + x - B] == 0 { + t[y * pw + x] = 0; + continue; + } + let (mut v, mut l) = (far, 0); + for &(dy, dx, s) in fwd { + let i = at(y, x, dy, dx); + let c = t[i].wrapping_add(cost[s]); + if v > c { + v = c; + l = lab[i]; + } + } + t[y * pw + x] = v; + lab[y * pw + x] = l; + } + } + let mut dist = vec![0f32; h * w]; + let mut labels = vec![0i32; h * w]; + for y in (B..h + B).rev() { + for x in (B..w + B).rev() { + let (mut v, mut l) = (t[y * pw + x], lab[y * pw + x]); + if v > hv { + for &(dy, dx, s) in fwd { + let i = at(y, x, -dy, -dx); + let c = t[i].wrapping_add(cost[s]); + if v > c { + v = c; + l = lab[i]; + } + } + t[y * pw + x] = v; + lab[y * pw + x] = l; + } + dist[(y - B) * w + x - B] = v as f32 * scale; + labels[(y - B) * w + x - B] = l; + } + } + (dist, labels) +} + +/// `_mask_to_score(mask, pad_px)` of retouch: the floor on the defect (nonzero), rising to 1 over +/// `pad` pixels of 3x3 chamfer distance along a smoothstep, in float32. +pub fn mask_to_score(mask: &[u8], h: usize, w: usize, pad: f64, floor: f64) -> Vec { + use rayon::prelude::*; + let clean: Vec = mask.par_iter().map(|&m| (m == 0) as u8).collect(); + let (pad, floor, rise) = (pad.max(1e-3) as f32, floor as f32, (1.0 - floor) as f32); + // The score is flat past `pad`, and a chamfer path no longer than `pad` moves at most + // pad / 0.955 rows, so bands with that much margin give every unclamped distance exactly. + let halo = (pad / steps(Metric::L2Mask3)[0]).ceil() as usize + 2; + let band = 64.max(h.div_ceil(4 * rayon::current_num_threads())); + let mut out = vec![0f32; h * w]; + out.par_chunks_mut(band * w.max(1)) + .enumerate() + .for_each(|(i, rows)| { + let (y0, y1) = (i * band, i * band + rows.len() / w.max(1)); + let (lo, hi) = (y0.saturating_sub(halo), (y1 + halo).min(h)); + let d = distance_transform(&clean[lo * w..hi * w], hi - lo, w, Metric::L2Mask3); + for (o, &v) in rows.iter_mut().zip(&d[(y0 - lo) * w..]) { + let t = (v / pad).clamp(0.0, 1.0); + *o = floor + rise * (t * t * (3.0 - 2.0 * t)); + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn banded_score_matches_one_pass() { + let (h, w) = (700, 97); + let mut seed = 7u64; + let mut mask = vec![0u8; h * w]; + for m in mask.iter_mut() { + seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + *m = (seed >> 33).is_multiple_of(400) as u8; + } + for pad in [0.5, 2.5, 9.0, 40.0] { + let clean: Vec = mask.iter().map(|&m| (m == 0) as u8).collect(); + let (p, floor) = (pad as f32, 0.02f32); + let want: Vec = distance_transform(&clean, h, w, Metric::L2Mask3) + .iter() + .map(|&v| { + let t = (v / p).clamp(0.0, 1.0); + floor + (1.0 - floor) * (t * t * (3.0 - 2.0 * t)) + }) + .collect(); + let got = mask_to_score(&mask, h, w, pad, floor as f64); + assert!( + want.iter() + .zip(&got) + .all(|(a, b)| a.to_bits() == b.to_bits()), + "pad {pad}" + ); + } + } +} diff --git a/negpy_core/src/exptab.rs b/negpy_core/src/exptab.rs new file mode 100644 index 000000000..61b78ea9b --- /dev/null +++ b/negpy_core/src/exptab.rs @@ -0,0 +1,68 @@ +/// OpenCV's `expTab` (softfloat.cpp): 2^(i/64) for i in 0..64. +const EXP_TAB: [u64; 64] = [ + 0x3ff0000000000000, + 0x3ff02c9a3e778061, + 0x3ff059b0d3158574, + 0x3ff0874518759bc8, + 0x3ff0b5586cf9890f, + 0x3ff0e3ec32d3d1a2, + 0x3ff11301d0125b51, + 0x3ff1429aaea92de0, + 0x3ff172b83c7d517b, + 0x3ff1a35beb6fcb75, + 0x3ff1d4873168b9aa, + 0x3ff2063b88628cd6, + 0x3ff2387a6e756238, + 0x3ff26b4565e27cdd, + 0x3ff29e9df51fdee1, + 0x3ff2d285a6e4030b, + 0x3ff306fe0a31b715, + 0x3ff33c08b26416ff, + 0x3ff371a7373aa9cb, + 0x3ff3a7db34e59ff7, + 0x3ff3dea64c123422, + 0x3ff4160a21f72e2a, + 0x3ff44e086061892d, + 0x3ff486a2b5c13cd0, + 0x3ff4bfdad5362a27, + 0x3ff4f9b2769d2ca7, + 0x3ff5342b569d4f82, + 0x3ff56f4736b527da, + 0x3ff5ab07dd485429, + 0x3ff5e76f15ad2148, + 0x3ff6247eb03a5585, + 0x3ff6623882552225, + 0x3ff6a09e667f3bcd, + 0x3ff6dfb23c651a2f, + 0x3ff71f75e8ec5f74, + 0x3ff75feb564267c9, + 0x3ff7a11473eb0187, + 0x3ff7e2f336cf4e62, + 0x3ff82589994cce13, + 0x3ff868d99b4492ed, + 0x3ff8ace5422aa0db, + 0x3ff8f1ae99157736, + 0x3ff93737b0cdc5e5, + 0x3ff97d829fde4e50, + 0x3ff9c49182a3f090, + 0x3ffa0c667b5de565, + 0x3ffa5503b23e255d, + 0x3ffa9e6b5579fdbf, + 0x3ffae89f995ad3ad, + 0x3ffb33a2b84f15fb, + 0x3ffb7f76f2fb5e47, + 0x3ffbcc1e904bc1d2, + 0x3ffc199bdd85529c, + 0x3ffc67f12e57d14b, + 0x3ffcb720dcef9069, + 0x3ffd072d4a07897c, + 0x3ffd5818dcfba487, + 0x3ffda9e603db3285, + 0x3ffdfc97337b9b5f, + 0x3ffe502ee78b3ff6, + 0x3ffea4afa2a490da, + 0x3ffefa1bee615a27, + 0x3fff50765b6e4540, + 0x3fffa7c1819e90d8, +]; + diff --git a/negpy_core/src/filter.rs b/negpy_core/src/filter.rs new file mode 100644 index 000000000..cf53cccc4 --- /dev/null +++ b/negpy_core/src/filter.rs @@ -0,0 +1,938 @@ +//! OpenCV's float32 separable filter engine (`sepFilter2D`, `GaussianBlur`), bit for bit with +//! opencv-python's AVX2 build: an 8-lane vector region with fused multiply-adds, then the scalar +//! loops in the arithmetic GCC compiled them to. + +use multiversion::multiversion; +use rayon::prelude::*; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Border { + Constant, + Replicate, + Reflect, + Reflect101, +} + +impl Border { + /// From an OpenCV border code; `BORDER_ISOLATED` is ignored, since inputs are whole arrays. + pub fn from_cv(code: i32) -> Option { + match code & !16 { + 0 => Some(Border::Constant), + 1 => Some(Border::Replicate), + 2 => Some(Border::Reflect), + 4 => Some(Border::Reflect101), + _ => None, + } + } +} + +/// OpenCV's `borderInterpolate`; None for a constant (zero) border. +pub fn border_interpolate(p: isize, len: usize, border: Border) -> Option { + let n = len as isize; + if (0..n).contains(&p) { + return Some(p as usize); + } + match border { + Border::Constant => None, + Border::Replicate => Some(if p < 0 { 0 } else { len - 1 }), + Border::Reflect | Border::Reflect101 => { + if len == 1 { + return Some(0); + } + let delta = (border == Border::Reflect101) as isize; + let mut p = p; + while !(0..n).contains(&p) { + p = if p < 0 { + -p - 1 + delta + } else { + n - 1 - (p - n) - delta + }; + } + Some(p as usize) + } + } +} + +include!("exptab.rs"); + +/// OpenCV's `exp(softdouble)` (softfloat.cpp `f64_exp`): a table and a polynomial, not libm. +fn soft_exp(x: f64) -> f64 { + if x.is_nan() { + return f64::NAN; + } + if x.is_infinite() { + return if x > 0.0 { x } else { 0.0 }; + } + let a0p = f64::from_bits(0x3f83ce0f3e46f431); + let a5 = 1.0 / a0p; + let a4 = f64::from_bits(0x3fe62e42fefa39f1) / a0p; + let a3 = f64::from_bits(0x3fcebfbdff82a45a) / a0p; + let a2 = f64::from_bits(0x3fac6b08d81fec75) / a0p; + let a1 = f64::from_bits(0x3f83b2a72b4f3cd3) / a0p; + let a0 = f64::from_bits(0x3f55e7aa1566c2a4) / a0p; + let prescale = f64::from_bits(0x3ff71547652b82fe) * 64.0; + let postscale = 1.0 / 64.0; + let max_val = 3000.0 * 64.0; + + let biased = ((x.to_bits() >> 52) & 0x7ff) as i64; + let mut x0 = if biased > 1023 + 10 { + if x < 0.0 { -max_val } else { max_val } + } else { + x * prescale + }; + let val0 = x0.round_ties_even() as i32; + let t = ((val0 >> 6) + 1023).clamp(0, 2047); + let buf = f64::from_bits((t as u64) << 52); + x0 = (x0 - x0.round_ties_even()) * postscale; + buf * a0p + * f64::from_bits(EXP_TAB[(val0 & 63) as usize]) + * (((((a0 * x0 + a1) * x0 + a2) * x0 + a3) * x0 + a4) * x0 + a5) +} + +/// `cv2.getGaussianKernel(n, sigma, cv2.CV_32F)`. +pub fn gaussian_kernel(n: usize, sigma: f64) -> Vec { + gaussian_kernel_f64(n, sigma) + .iter() + .map(|&v| v as f32) + .collect() +} + +/// `getGaussianKernelBitExact`, in its softdouble (here f64) values. +pub fn gaussian_kernel_f64(n: usize, sigma: f64) -> Vec { + if sigma <= 0.0 { + let fixed: Option<&[f64]> = match n { + 1 => Some(&[1.0]), + 3 => Some(&[0.25, 0.5, 0.25]), + 5 => Some(&[0.0625, 0.25, 0.375, 0.25, 0.0625]), + 7 => Some(&[ + 0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125, + ]), + 9 => Some(&[ + 4.0 / 256.0, + 13.0 / 256.0, + 30.0 / 256.0, + 51.0 / 256.0, + 60.0 / 256.0, + 51.0 / 256.0, + 30.0 / 256.0, + 13.0 / 256.0, + 4.0 / 256.0, + ]), + _ => None, + }; + if let Some(k) = fixed { + return k.to_vec(); + } + } + let sigma_x = if sigma > 0.0 { + sigma + } else { + (n as f64).mul_add( + f64::from_bits(0x3fc3333333333333), + f64::from_bits(0x3fd6666666666666), + ) + }; + let scale2x = -0.125 / (sigma_x * sigma_x); + let n2 = (n - 1) / 2; + let mut values = Vec::with_capacity(n2); + let mut sum = 0.0f64; + let mut x = 1 - n as i64; + for _ in 0..n2 { + let t = soft_exp((x * x) as f64 * scale2x); + values.push(t); + sum += t; + x += 2; + } + sum *= 2.0; + sum += 1.0; + if n.is_multiple_of(2) { + sum += 1.0; + } + let mul1 = 1.0 / sum; + let mut result = vec![0f64; n]; + for (i, v) in values.iter().enumerate() { + let t = v * mul1; + result[i] = t; + result[n - 1 - i] = t; + } + result[n2] = mul1; + if n.is_multiple_of(2) { + result[n2 + 1] = result[n2]; + } + result +} + +/// `createGaussianKernels`' size from sigma for a float image. +pub fn gaussian_ksize(sigma: f64) -> usize { + ((sigma * 4.0 * 2.0 + 1.0).round_ties_even() as i64 | 1) as usize +} + +#[derive(Clone, Copy, PartialEq)] +enum Symmetry { + Symmetric, + Asymmetric, + None, +} + +fn symmetry(k: &[f32]) -> Symmetry { + let n = k.len(); + if (0..n).all(|i| k[i] == k[n - 1 - i]) { + Symmetry::Symmetric + } else if (0..n).all(|i| k[i] == -k[n - 1 - i]) { + Symmetry::Asymmetric + } else { + Symmetry::None + } +} + +const LANES: usize = 8; + +/// The 8-lane vector region of a row, `out.len()` a multiple of 8. Tap-outer slice loops keep +/// each output's order of operations and let the compiler vectorize. +#[inline(always)] +fn row_vector(p: &[f32], out: &mut [f32], cn: usize, k: &[f32], sym: Symmetry) { + let n = out.len(); + let ks = k.len(); + let r = ks / 2; + let sl = |o: isize| &p[((r * cn) as isize + o * cn as isize) as usize..][..n]; + if ks == 1 { + out.iter_mut() + .zip(&p[..n]) + .for_each(|(o, &x)| *o = k[0] * x); + } else if sym == Symmetry::Symmetric && ks <= 5 { + let (c, m1, p1) = (sl(0), sl(-1), sl(1)); + let (k0, k1) = (k[r], k[r + 1]); + if ks == 3 { + if k0.abs() == 2.0 && k1 == 1.0 { + for i in 0..n { + out[i] = c[i].mul_add(k0, m1[i] + p1[i]); + } + } else { + for i in 0..n { + out[i] = c[i].mul_add(k0, (m1[i] + p1[i]) * k1); + } + } + } else { + let (m2, p2, k2) = (sl(-2), sl(2), k[r + 2]); + if k0 == -2.0 && k1 == 0.0 && k2 == 1.0 { + for i in 0..n { + out[i] = c[i].mul_add(-2.0, m2[i] + p2[i]); + } + } else { + for i in 0..n { + out[i] = (p2[i] + m2[i]).mul_add(k2, c[i].mul_add(k0, (m1[i] + p1[i]) * k1)); + } + } + } + } else if sym == Symmetry::Asymmetric && ks == 3 { + let (m1, p1, k1) = (sl(-1), sl(1), k[r + 1]); + if k[r] == 0.0 && k1 == 1.0 { + for i in 0..n { + out[i] = p1[i] - m1[i]; + } + } else { + for i in 0..n { + out[i] = (p1[i] - m1[i]) * k1; + } + } + } else { + let (blocks, rest) = out.split_at_mut(n / 16 * 16); + for (b, chunk) in blocks.as_chunks_mut::<16>().0.iter_mut().enumerate() { + row_taps::<16>(p, chunk, b * 16, cn, k); + } + if !rest.is_empty() { + row_taps::<8>(p, rest, n / 16 * 16, cn, k); + } + } +} + +/// RowVec_32f's sum for `B` outputs from `base`, the accumulators held in registers. +#[inline(always)] +fn row_taps(p: &[f32], out: &mut [f32], base: usize, cn: usize, k: &[f32]) { + let mut acc = [0f32; B]; + let t0 = &p[base..base + B]; + for l in 0..B { + acc[l] = t0[l].mul_add(k[0], 0.0); + } + for (j, &kj) in k.iter().enumerate().skip(1) { + let t = &p[base + j * cn..base + j * cn + B]; + for l in 0..B { + acc[l] = t[l].mul_add(kj, acc[l]); + } + } + out.copy_from_slice(&acc); +} + +/// One row: `p` is the source row padded by `r = k.len() / 2` pixels on each side. +#[inline(always)] +fn row_filter_impl(p: &[f32], out: &mut [f32], cn: usize, k: &[f32], sym: Symmetry) { + let n = out.len(); + let ks = k.len(); + let r = ks / 2; + let vec_end = n / LANES * LANES; + let at = |i: usize, o: isize| p[(i as isize + (r * cn) as isize + o * cn as isize) as usize]; + row_vector(p, &mut out[..vec_end], cn, k, sym); + let tail = out.iter_mut().enumerate().skip(vec_end); + + if ks == 1 { + for (i, o) in tail { + *o = k[0] * p[i]; + } + return; + } + // After the vector region: a pair loop, then the odd last float alone, centre first + // (fused for 3 taps, plain for 5). The pair loop matches the vector arithmetic. + let single = |i: usize| i + 2 > n && (n - vec_end) % 2 == 1; + let small = sym != Symmetry::None && ks <= 5; + if small && sym == Symmetry::Symmetric { + let (k0, k1) = (k[r], k[r + 1]); + let k2 = if ks == 5 { k[r + 2] } else { 0.0 }; + for (i, o) in tail { + let (s0, p1) = (at(i, 0), at(i, -1) + at(i, 1)); + *o = if ks == 3 { + if single(i) { + k1.mul_add(at(i, 1) + at(i, -1), k0 * s0) + } else if k0 == 2.0 && k1 == 1.0 { + (at(i, -1) + s0 * 2.0) + at(i, 1) + } else if k0 == -2.0 && k1 == 1.0 { + (at(i, -1) - s0 * 2.0) + at(i, 1) + } else { + s0.mul_add(k0, p1 * k1) + } + } else if single(i) { + (k0 * s0 + k1 * (at(i, 1) + at(i, -1))) + k2 * (at(i, 2) + at(i, -2)) + } else if k0 == -2.0 && k1 == 0.0 && k2 == 1.0 { + (-2.0 * s0 + at(i, -2)) + at(i, 2) + } else { + (at(i, 2) + at(i, -2)).mul_add(k2, s0.mul_add(k0, p1 * k1)) + }; + } + return; + } + if small && ks == 3 { + let (k0, k1) = (k[r], k[r + 1]); + for (i, o) in tail { + let d = at(i, 1) - at(i, -1); + *o = if single(i) { + k1.mul_add(d, k0 * at(i, 0)) + } else if k0 == 0.0 && k1 == 1.0 { + d + } else { + d * k1 + }; + } + return; + } + // RowFilter's scalar loops: four at a time (contracted), then one at a time, where GCC + // unrolled the tap loop by four uncontracted and fused the remainder. + let unrolled_end = vec_end + (n - vec_end) / 4 * 4; + let plain_adds = (ks - 1) / 4 * 4; + for (i, o) in tail { + let tap = |j: usize| p[i + j * cn]; + *o = if i < unrolled_end { + (1..ks).fold(k[0] * tap(0), |s, j| k[j].mul_add(tap(j), s)) + } else { + (1..ks).fold(k[0] * tap(0), |s, j| { + if j <= plain_adds { + s + k[j] * tap(j) + } else { + k[j].mul_add(tap(j), s) + } + }) + }; + } +} + +/// One output row from the row-filtered rows `rows[0..ks]` (top to bottom). +#[inline(always)] +fn column_filter_impl(rows: &[&[f32]], out: &mut [f32], k: &[f32], sym: Symmetry) { + let n = out.len(); + let ks = k.len(); + let r = ks / 2; + let vec_end = n / LANES * LANES; + let (vec, tail) = out.split_at_mut(vec_end); + match sym { + Symmetry::Symmetric if ks == 3 => { + let (f0, f1) = (k[1], k[2]); + let (s0, s1, s2) = (rows[0], rows[1], rows[2]); + if f0.abs() == 2.0 && f1 == 1.0 { + for i in 0..vec_end { + vec[i] = s1[i].mul_add(f0, (s0[i] + s2[i]) + 0.0); + } + } else { + for i in 0..vec_end { + vec[i] = (s0[i] + s2[i]).mul_add(f1, s1[i].mul_add(f0, 0.0)); + } + } + for (t, o) in tail.iter_mut().enumerate() { + let i = vec_end + t; + *o = if f0 == 2.0 && f1 == 1.0 { + ((s0[i] + s1[i] * 2.0) + s2[i]) + 0.0 + } else if f0 == -2.0 && f1 == 1.0 { + ((s0[i] - s1[i] * 2.0) + s2[i]) + 0.0 + } else { + (s0[i] + s2[i]).mul_add(f1, s1[i] * f0) + 0.0 + }; + } + } + Symmetry::Symmetric => { + let c = rows[r]; + let (blocks, rest) = vec.split_at_mut(vec_end / 16 * 16); + for (b, chunk) in blocks.as_chunks_mut::<16>().0.iter_mut().enumerate() { + column_taps::<16>(rows, chunk, b * 16, k); + } + if !rest.is_empty() { + column_taps::<8>(rows, rest, vec_end / 16 * 16, k); + } + // GCC left the scalar column loops uncontracted: plain multiply, then add. + for (t, o) in tail.iter_mut().enumerate() { + let i = vec_end + t; + *o = (1..=r).fold(k[r] * c[i] + 0.0, |s, j| { + s + k[r + j] * (rows[r + j][i] + rows[r - j][i]) + }); + } + } + // sep_filter2d admits only a 3-tap antisymmetric column kernel. + Symmetry::Asymmetric | Symmetry::None => { + let f1 = k[2]; + let (lo, hi) = if f1 < 0.0 { + (rows[2], rows[0]) + } else { + (rows[0], rows[2]) + }; + for i in 0..n { + let o = if i < vec_end { + &mut vec[i] + } else { + &mut tail[i - vec_end] + }; + *o = if f1.abs() == 1.0 { + (hi[i] - lo[i]) + 0.0 + } else { + (rows[2][i] - rows[0][i]).mul_add(f1, 0.0) + }; + } + } + } +} + +/// SymmColumnVec_32f's sum for `B` outputs from `base`, the accumulators held in registers. +#[inline(always)] +fn column_taps(rows: &[&[f32]], out: &mut [f32], base: usize, k: &[f32]) { + let r = k.len() / 2; + let mut acc = [0f32; B]; + let c = &rows[r][base..base + B]; + for l in 0..B { + acc[l] = c[l].mul_add(k[r], 0.0); + } + for j in 1..=r { + let (a, b, kj) = ( + &rows[r + j][base..base + B], + &rows[r - j][base..base + B], + k[r + j], + ); + for l in 0..B { + acc[l] = (a[l] + b[l]).mul_add(kj, acc[l]); + } + } + out.copy_from_slice(&acc); +} + +/// Copies of the row and column passes compiled for AVX2 + FMA, so `mul_add` is one +/// instruction and the slice loops vectorize; the plain copies serve other CPUs. +#[cfg(target_arch = "x86_64")] +mod fma { + use super::Symmetry; + + #[target_feature(enable = "avx2,fma")] + pub fn row_filter(p: &[f32], out: &mut [f32], cn: usize, k: &[f32], sym: Symmetry) { + super::row_filter_impl(p, out, cn, k, sym) + } + + #[target_feature(enable = "avx2,fma")] + pub fn column_filter(rows: &[&[f32]], out: &mut [f32], k: &[f32], sym: Symmetry) { + super::column_filter_impl(rows, out, k, sym) + } +} + +fn has_fma() -> bool { + #[cfg(target_arch = "x86_64")] + { + std::arch::is_x86_feature_detected!("avx2") && std::arch::is_x86_feature_detected!("fma") + } + #[cfg(not(target_arch = "x86_64"))] + { + false + } +} + +fn row_filter(fast: bool, p: &[f32], out: &mut [f32], cn: usize, k: &[f32], sym: Symmetry) { + #[cfg(target_arch = "x86_64")] + if fast { + // SAFETY: `fast` is true only when has_fma() found AVX2 and FMA on this CPU. + return unsafe { fma::row_filter(p, out, cn, k, sym) }; + } + let _ = fast; + row_filter_impl(p, out, cn, k, sym) +} + +fn column_filter(fast: bool, rows: &[&[f32]], out: &mut [f32], k: &[f32], sym: Symmetry) { + #[cfg(target_arch = "x86_64")] + if fast { + // SAFETY: `fast` is true only when has_fma() found AVX2 and FMA on this CPU. + return unsafe { fma::column_filter(rows, out, k, sym) }; + } + let _ = fast; + column_filter_impl(rows, out, k, sym) +} + +/// `cv2.sepFilter2D(src, -1, kx, ky, borderType=border)` for float32, anchor at the center, +/// written into `dst`. None for a kernel OpenCV filters on a path this port does not cover. +pub fn sep_filter2d( + src: &[f32], + dst: &mut [f32], + (h, w, cn): (usize, usize, usize), + (kx, ky): (&[f32], &[f32]), + border: Border, +) -> Option<()> { + if h == 0 + || w == 0 + || cn == 0 + || src.len() != h * w * cn + || dst.len() != src.len() + || kx.len().is_multiple_of(2) + || ky.len().is_multiple_of(2) + { + return None; + } + let (sx, sy) = (symmetry(kx), symmetry(ky)); + // Every caller passes a symmetric kernel, or Sobel's antisymmetric 3-tap one. + let covered = |s: Symmetry, len: usize| { + s == Symmetry::Symmetric || (s == Symmetry::Asymmetric && len == 3) + }; + if !covered(sx, kx.len()) || !covered(sy, ky.len()) { + return None; + } + let (rx, ry) = (kx.len() / 2, ky.len() / 2); + let n = w * cn; + + let fast = has_fma(); + let xmap: Vec> = (0..w + 2 * rx) + .map(|px| border_interpolate(px as isize - rx as isize, w, border)) + .collect(); + let row_pass = |padded: &mut [f32], sy: usize, out: &mut [f32]| { + let row = &src[sy * n..(sy + 1) * n]; + padded[rx * cn..(rx + w) * cn].copy_from_slice(row); + for px in (0..rx).chain(rx + w..w + 2 * rx) { + let chunk = &mut padded[px * cn..(px + 1) * cn]; + match xmap[px] { + Some(sxi) => chunk.copy_from_slice(&row[sxi * cn..(sxi + 1) * cn]), + None => chunk.fill(0.0), + } + } + row_filter(fast, padded, out, cn, kx, sx); + }; + + // Strips of output rows, each with the row-filtered rows it reads kept in cache. A strip + // filters its 2 * ry halo rows again, so it grows with the kernel. + let strip_rows = (8 * ry).max(32); + dst.par_chunks_mut(strip_rows * n) + .enumerate() + .for_each_init( + || { + ( + vec![0f32; (w + 2 * rx) * cn], + vec![0f32; (strip_rows + 2 * ry) * n], + ) + }, + |(padded, buf), (strip, out)| { + let y0 = strip * strip_rows; + let count = out.len() / n; + for t in 0..count + 2 * ry { + let line = &mut buf[t * n..(t + 1) * n]; + match border_interpolate((y0 + t) as isize - ry as isize, h, border) { + Some(sy) => row_pass(padded, sy, line), + None => line.fill(0.0), + } + } + for (yy, line) in out.chunks_exact_mut(n).enumerate() { + let rows: Vec<&[f32]> = (0..ky.len()) + .map(|j| &buf[(yy + j) * n..(yy + j + 1) * n]) + .collect(); + column_filter(fast, &rows, line, ky, sy); + } + }, + ); + Some(()) +} + +/// `cv2.GaussianBlur(src, (kw, kh), sigma_x, sigmaY=sigma_y, borderType=border)` for float32. +pub fn gaussian_blur( + src: &[f32], + dst: &mut [f32], + (h, w, cn): (usize, usize, usize), + ksize: (usize, usize), + (sigma_x, sigma_y): (f64, f64), + border: Border, +) -> Option<()> { + let (mut kw, mut kh) = ksize; + let sigma_y = if sigma_y <= 0.0 { sigma_x } else { sigma_y }; + if kw == 0 && sigma_x > 0.0 { + kw = gaussian_ksize(sigma_x); + } + if kh == 0 && sigma_y > 0.0 { + kh = gaussian_ksize(sigma_y); + } + if kw.is_multiple_of(2) || kh.is_multiple_of(2) { + return None; + } + if border != Border::Constant { + if h == 1 { + kh = 1; + } + if w == 1 { + kw = 1; + } + } + if kw == 1 && kh == 1 { + dst.copy_from_slice(src); + return Some(()); + } + let kx = gaussian_kernel(kw, sigma_x.max(0.0)); + let ky = if kh == kw && (sigma_x - sigma_y).abs() < f64::EPSILON { + kx.clone() + } else { + gaussian_kernel(kh, sigma_y.max(0.0)) + }; + sep_filter2d(src, dst, (h, w, cn), (&kx, &ky), border) +} + +/// Fresh row sums of a narrow box (up to five taps) over a row slice whose taps all lie inside +/// it: each output sums its taps left to right from zero, in float64. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn narrow_rows(out: &mut [f64], row: &[f32], cn: usize, kw: usize) { + let tap = |k: usize| &row[k * cn..]; + let f = |v: f32| v as f64; + match kw { + 1 => out + .iter_mut() + .zip(tap(0)) + .for_each(|(o, &a)| *o = 0.0 + f(a)), + 2 => out + .iter_mut() + .zip(tap(0).iter().zip(tap(1))) + .for_each(|(o, (&a, &b))| *o = (0.0 + f(a)) + f(b)), + 3 => out + .iter_mut() + .zip(tap(0).iter().zip(tap(1)).zip(tap(2))) + .for_each(|(o, ((&a, &b), &c))| *o = ((0.0 + f(a)) + f(b)) + f(c)), + 4 => out + .iter_mut() + .zip(tap(0).iter().zip(tap(1)).zip(tap(2)).zip(tap(3))) + .for_each(|(o, (((&a, &b), &c), &d))| *o = (((0.0 + f(a)) + f(b)) + f(c)) + f(d)), + _ => out + .iter_mut() + .zip( + tap(0) + .iter() + .zip(tap(1)) + .zip(tap(2)) + .zip(tap(3)) + .zip(tap(4)), + ) + .for_each(|(o, ((((&a, &b), &c), &d), &e))| { + *o = ((((0.0 + f(a)) + f(b)) + f(c)) + f(d)) + f(e) + }), + } +} + +/// One row of the column recurrence: add the new row sum, write the scaled total when the window +/// is full, subtract the row sum leaving it. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn column_step(sum: &mut [f64], sp: &[f64], old: &[f64], out: Option<&mut [f32]>, scale: f64) { + match out { + Some(o) => { + for (((s, &p), &q), o) in sum.iter_mut().zip(sp).zip(old).zip(o.iter_mut()) { + let s0 = *s + p; + *o = (s0 * scale) as f32; + *s = s0 - q; + } + } + None => { + for ((s, &p), &q) in sum.iter_mut().zip(sp).zip(old) { + *s = (*s + p) - q; + } + } + } +} + +/// `box_filter` for a kernel up to five wide in one parallel pass: each strip of pixels sums +/// its own rows (fresh sums need no state from the left) into a ring of `kh` rows that stays in +/// cache, then runs the column recurrence down the frame. +fn box_narrow( + src: &[f32], + dst: &mut [f32], + (h, w, cn): (usize, usize, usize), + (kw, kh): (usize, usize), + scale: f64, + (xmap, ymap): (&[Option], &[Option]), +) { + let n = w * cn; + let px = w.div_ceil(rayon::current_num_threads()).max(64 / cn.max(1)); + let total = h + kh - 1; + let ax = kw / 2; + let (lo, hi) = (ax.min(w), (w + ax + 1).saturating_sub(kw).max(ax.min(w))); + // Many threads writing the same fresh huge page at once stall each other. + dst.par_chunks_mut(1 << 19).for_each(|c| c.fill(0.0)); + let mut outs: Vec> = (0..w.div_ceil(px)).map(|_| Vec::new()).collect(); + for row in dst.chunks_exact_mut(n) { + for (o, part) in outs.iter_mut().zip(row.chunks_mut(px * cn)) { + o.push(part); + } + } + outs.par_iter_mut().enumerate().for_each(|(si, out)| { + let x0 = si * px; + let sw = out[0].len(); + let pw = sw / cn; + let mut ring = vec![0f64; kh * sw]; + let mut sum = vec![0f64; sw]; + let zeros = vec![0f64; sw]; + for (rc, &source) in ymap.iter().enumerate().take(total) { + let slot = rc % kh; + let line = &mut ring[slot * sw..(slot + 1) * sw]; + match source { + None => line.fill(0.0), + Some(y) => { + let row = &src[y * n..(y + 1) * n]; + // Pixels whose taps all lie inside the row sum straight off the source. + let (a, b) = (lo.clamp(x0, x0 + pw), hi.clamp(x0, x0 + pw)); + for x in (x0..a).chain(b.max(a)..x0 + pw) { + for c in 0..cn { + line[(x - x0) * cn + c] = (0..kw).fold(0.0, |s, k| { + s + xmap[x + k].map_or(0.0, |sx| row[sx * cn + c] as f64) + }); + } + } + if b > a { + let out = &mut line[(a - x0) * cn..(b - x0) * cn]; + narrow_rows(out, &row[(a - ax) * cn..], cn, kw); + } + } + } + // After writing, row rc + 1 - kh leaves the window; it sits kh - 1 slots back. + let line = &ring[slot * sw..(slot + 1) * sw]; + let old = if rc + 1 >= kh { + let o = (rc + 1) % kh; + &ring[o * sw..(o + 1) * sw] + } else { + &zeros[..] + }; + let o = rc.checked_sub(kh - 1).map(|y| &mut *out[y]); + column_step(&mut sum, line, old, o, scale); + } + }); +} + +/// `cv2.boxFilter(src, -1, (kw, kh), normalize=..., borderType=border)` (and `cv2.blur`) for +/// float32, anchor at the center. Row sums in float64: a fresh sum per output up to five wide, +/// a running sum (`s += a - b`) past that. Column sums keep a running total: add the new row +/// sum, write, subtract the row sum from `kh - 1` rows back. Both OpenCV paths (`blockSum` and +/// the filter engine) run that recurrence. +pub fn box_filter( + src: &[f32], + dst: &mut [f32], + (h, w, cn): (usize, usize, usize), + (kw, kh): (usize, usize), + normalize: bool, + border: Border, +) -> Option<()> { + if h == 0 + || w == 0 + || cn == 0 + || kw == 0 + || kh == 0 + || src.len() != h * w * cn + || dst.len() != src.len() + { + return None; + } + let (ax, ay) = (kw / 2, kh / 2); + let n = w * cn; + let scale = if normalize { + 1.0 / (kw * kh) as f64 + } else { + 1.0 + }; + let xmap: Vec> = (0..w + kw - 1) + .map(|p| border_interpolate(p as isize - ax as isize, w, border)) + .collect(); + + let ymap: Vec> = (0..h + kh - 1) + .map(|r| border_interpolate(r as isize - ay as isize, h, border)) + .collect(); + if kw <= 5 { + box_narrow(src, dst, (h, w, cn), (kw, kh), scale, (&xmap, &ymap)); + return Some(()); + } + let row_sums = |y: Option, pad: &mut Vec, out: &mut [f64]| { + let Some(y) = y else { + out.fill(0.0); + return; + }; + let row = &src[y * n..(y + 1) * n]; + let tap = |p: usize, c: usize| xmap[p].map_or(0.0, |sx| row[sx * cn + c] as f64); + if kw <= 5 { + // Each output sums its taps left to right from zero. Outputs whose taps all lie + // inside the row run kernel-major straight off the source. + let lo = ax.min(w); + let hi = (w + ax + 1).saturating_sub(kw).max(lo); + for x in (0..lo).chain(hi..w) { + for c in 0..cn { + out[x * cn + c] = (0..kw).fold(0.0, |s, k| s + tap(x + k, c)); + } + } + if hi == lo { + return; + } + narrow_rows(&mut out[lo * cn..hi * cn], &row[(lo - ax) * cn..], cn, kw); + return; + } + pad.clear(); + pad.resize((w + kw - 1) * cn, 0.0); + for (p, &v) in pad[ax * cn..ax * cn + n].iter_mut().zip(row) { + *p = v as f64; + } + for p in (0..ax).chain(ax + w..w + kw - 1) { + for c in 0..cn { + pad[p * cn + c] = tap(p, c); + } + } + for c in 0..cn { + let mut s = (0..kw).fold(0.0, |s, k| s + pad[k * cn + c]); + out[c] = s; + for x in 1..w { + s += pad[(x + kw - 1) * cn + c] - pad[(x - 1) * cn + c]; + out[x * cn + c] = s; + } + } + }; + + // Bands of rows: their row sums in parallel, then the column recurrence (sequential down a + // column, independent across columns) in parallel strips. The ring keeps the row sums a + // later row still subtracts. + // Bands as tall as about 2 MB of row sums allow: each band costs two parallel phases, and the + // ring stays in cache. + const STRIP: usize = 64; + let total = h + kh - 1; + let band_rows = ((1usize << 18) / n).clamp(128, total.max(128)); + let slots = kh + band_rows.min(total); + let mut ring_buf = vec![0f64; slots * n]; + ring_buf.par_chunks_mut(1 << 18).for_each(|c| c.fill(0.0)); + let mut ring: Vec<&mut [f64]> = ring_buf.chunks_exact_mut(n).collect(); + let mut sum = vec![0f64; n]; + let zeros = vec![0f64; n]; + // One strip of columns per thread, each writing its part of every output row. + let strip = n + .div_ceil(rayon::current_num_threads()) + .next_multiple_of(16) + .max(STRIP); + // Many threads writing the same fresh huge page at once stall each other; the ring and dst + // are touched first in large blocks. + dst.par_chunks_mut(1 << 19).for_each(|c| c.fill(0.0)); + let mut rc0 = 0; + while rc0 < total { + let rc1 = (rc0 + band_rows).min(total); + let band: Vec<(usize, &mut &mut [f64])> = ring + .iter_mut() + .enumerate() + .filter_map(|(slot, row)| { + // The band row in this slot, if any: rc0 + ((slot - rc0) mod slots). + let rc = rc0 + (slot + slots - rc0 % slots) % slots; + (rc < rc1).then_some((rc, row)) + }) + .collect(); + band.into_par_iter() + .for_each_init(Vec::new, |pad, (rc, out)| row_sums(ymap[rc], pad, out)); + let ring = ˚ + let line = |rc: usize| &ring[rc % slots][..]; + // Output rows this band writes: extended row rc writes image row rc - (kh - 1). + let y0 = rc0.max(kh - 1) - (kh - 1); + let y1 = rc1.saturating_sub(kh - 1).max(y0); + let mut outs: Vec> = (0..n.div_ceil(strip)).map(|_| Vec::new()).collect(); + for row in dst[y0 * n..y1 * n].chunks_exact_mut(n) { + for (o, part) in outs.iter_mut().zip(row.chunks_mut(strip)) { + o.push(part); + } + } + sum.par_chunks_mut(strip) + .zip(outs.par_iter_mut()) + .enumerate() + .for_each(|(si, (s, out))| { + let (x0, sw) = (si * strip, s.len()); + for rc in rc0..rc1 { + let sp = &line(rc)[x0..x0 + sw]; + // Before kh - 1 rows have passed, nothing leaves the window yet. + let old = if rc + 1 >= kh { + &line(rc + 1 - kh)[x0..x0 + sw] + } else { + &zeros[..sw] + }; + let o = rc.checked_sub(kh - 1).map(|y| &mut *out[y - y0]); + column_step(s, sp, old, o, scale); + } + }); + rc0 = rc1; + } + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reflect101_and_reflect_borders() { + let r101: Vec<_> = (-3..8) + .map(|p| border_interpolate(p, 5, Border::Reflect101).unwrap()) + .collect(); + assert_eq!(r101, vec![3, 2, 1, 0, 1, 2, 3, 4, 3, 2, 1]); + let r: Vec<_> = (-2..7) + .map(|p| border_interpolate(p, 5, Border::Reflect).unwrap()) + .collect(); + assert_eq!(r, vec![1, 0, 0, 1, 2, 3, 4, 4, 3]); + assert_eq!(border_interpolate(-1, 5, Border::Constant), None); + assert_eq!(border_interpolate(9, 5, Border::Replicate), Some(4)); + } + + #[test] + fn soft_exp_is_close_to_exp() { + for x in [-30.0, -7.5, -1.0, -0.125, 0.0, 0.5, 3.0] { + let rel = (soft_exp(x) - f64::exp(x)).abs() / f64::exp(x); + assert!(rel < 1e-14, "x={x} rel={rel}"); + } + } + + #[test] + fn kernels_sum_to_one() { + for (n, sigma) in [(5, 0.0), (11, 0.0), (17, 2.0), (25, 3.0)] { + let s: f64 = gaussian_kernel(n, sigma).iter().map(|&v| v as f64).sum(); + assert!((s - 1.0).abs() < 1e-6, "n={n}"); + } + } + + #[test] + fn flat_image_stays_flat() { + let src = vec![0.5f32; 23 * 31 * 3]; + let mut out = vec![0f32; src.len()]; + gaussian_blur( + &src, + &mut out, + (23, 31, 3), + (5, 5), + (0.0, 0.0), + Border::Reflect101, + ) + .unwrap(); + assert!(out.iter().all(|&v| v == 0.5)); + } +} diff --git a/negpy_core/src/finish.rs b/negpy_core/src/finish.rs new file mode 100644 index 000000000..33e27c5c7 --- /dev/null +++ b/negpy_core/src/finish.rs @@ -0,0 +1,346 @@ +//! The filed negative carrier of negpy/features/finish/logic.py (`apply_carrier`), in the +//! float32 order NumPy evaluates it. Python floats join float32 arrays as float32, so each +//! Python-side product (width * rough, r * r, ...) is rounded once before it meets an array. + +// The steps index several per-pixel arrays of one run in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::cbrtf; + +// Fractions of the rebate width, mirrored as WGSL literals in finish.wgsl. The picture-side +// boundary is the camera's film gate, machine-cut, so it gets JITTER * INNER_ROUGH and no +// slider. The paper-side one is the hand-filed aperture. +const SAMPLES: usize = 2048; +const JITTER: f64 = 0.24; +const INNER_ROUGH: f64 = 0.2; +const OUTER_JITTER: f64 = 0.4; +const CORNER: f64 = 1.4; +const GATE_CORNER: f64 = 0.2; +// The film never sits centered in the carrier. +const OFFSET_X: f64 = -0.2; +const OFFSET_Y: f64 = -0.25; +const MARGIN: f64 = 0.7; +// The gate prints soft; filed metal is a hard stop, which is what reads as filed. +const SOFT: f64 = 0.22; +const FILED_SOFT: f64 = 0.06; +const FLARE_DEPTH: f64 = 0.25; +const FLARE_GAIN: f64 = 0.6; +const FLARE_BASE: f64 = 0.35; +// A 1-D profile is a height field, so it cannot overhang or shed a fleck. The 2-D field +// displacing the distance field is what makes the edge read as torn metal. Hash noise rather +// than a library, because WGSL has to reproduce it bit for bit. +const NOISE_SEED: u32 = 0x51ED_270B; +const NOISE_CELL: f64 = 0.35; +const NOISE_OCTAVES: usize = 4; +const NOISE_OUTER: f64 = 0.12; +const NOISE_INNER: f64 = 0.02; +const TONE_SAMPLES: usize = 64; + +/// `np.clip(v, lo, hi)`: a NaN comes through. +#[inline(always)] +fn clip(v: f32, lo: f32, hi: f32) -> f32 { + let m = if v.is_nan() || v > lo { v } else { lo }; + if m.is_nan() || m < hi { m } else { hi } +} + +/// A whole float32 below 2^22 in magnitude as an integer: added to 1.5 * 2^23, it fills the low +/// mantissa bits. The saturating `as` cast keeps LLVM from vectorizing. +#[inline(always)] +fn whole(v: f32) -> i32 { + (v + 12_582_912.0).to_bits() as i32 - 0x4B40_0000 +} + +/// `_hash_lattice`: a lattice value in [-1, 1) from u32 wrap-around arithmetic. +#[inline(always)] +fn hash_lattice(ix: u32, iy: u32) -> f32 { + let mut h = ix.wrapping_mul(0x27D4_EB2D) ^ iy.wrapping_mul(0x1656_67B1) ^ NOISE_SEED; + h ^= h >> 15; + h = h.wrapping_mul(0x2C1B_3C6D); + h ^= h >> 13; + h = h.wrapping_mul(0x297A_2D39); + h ^= h >> 16; + (h >> 8) as f32 * (2.0f64 / 16_777_216.0) as f32 - 1.0 +} + +/// `_value_noise` at one point. +#[inline(always)] +fn value_noise(x: f32, y: f32) -> f32 { + let (fx, fy) = (x.floor(), y.floor()); + let (ux, uy) = (x - fx, y - fy); + let ux = ux * ux * (3.0 - 2.0 * ux); + let uy = uy * uy * (3.0 - 2.0 * uy); + // astype(int64).astype(uint32) wraps a negative index. + let (i0, j0) = (whole(fx) as u32, whole(fy) as u32); + let (i1, j1) = (i0.wrapping_add(1), j0.wrapping_add(1)); + let lo = hash_lattice(i0, j0); + let lo = lo + (hash_lattice(i1, j0) - lo) * ux; + let hi = hash_lattice(i0, j1); + let hi = hi + (hash_lattice(i1, j1) - hi) * ux; + lo + (hi - lo) * uy +} + +/// `carrier_noise`: fBm value noise in [-1, 1], x and y in noise cells. +#[inline(always)] +fn carrier_noise(x: f32, y: f32) -> f32 { + let (mut total, mut amp, mut freq, mut norm) = (0.0f32, 1.0f32, 1.0f32, 0.0f32); + for _ in 0..NOISE_OCTAVES { + total += amp * value_noise(x * freq, y * freq); + norm += amp; + amp *= 0.5; + freq *= 2.0; + } + total / norm +} + +/// The inputs of `apply_carrier`; `profiles` is `carrier_profiles()` (8 x 2048) and `tone` the +/// (64, 3) rebate tone table. +pub struct Carrier<'a> { + pub width_px: f64, + pub rough: f64, + pub flare: f64, + pub corner: f64, + pub paper: [f32; 3], + pub tone: &'a [[f32; 3]], + pub profiles: &'a [f32], +} + +/// The scalars `_carrier_block` derives from the inputs. +struct Derived { + soft: f32, + soft_filed: f32, + margin: f64, + radius: f64, + gate_radius: f64, + cell: f32, + off_x: f32, + off_y: f32, + reach: f32, +} + +/// `arc(r, at, end)`: the corner retreat of a boundary `at` px from the print edge. +#[inline(always)] +fn arc(r: f64, at: f64, end: f32) -> f32 { + if r <= 0.0 { + return 0.0; + } + let rf = r as f32; + let x = clip(rf - (end - at as f32), 0.0, rf); + let s = (r * r) as f32 - x * x; + rf - (if s.is_nan() || s > 0.0 { s } else { 0.0 }).sqrt() +} + +/// `carrier_tone_lookup` of one exposure fraction: np.interp on the table at +/// cbrt(t) * 63, in float64. +#[inline(always)] +fn tone_at(tone: &[[f32; 3]], t: f32) -> [f32; 3] { + let u = cbrtf(clip(t, 0.0, 1.0)) * (TONE_SAMPLES - 1) as f32; + let last = TONE_SAMPLES - 1; + // u is in 0..63 (or NaN, which np.interp passes through). + let j = (whole(u.floor()).clamp(0, last as i32 - 1)) as usize; + let x = u as f64; + std::array::from_fn(|c| { + let (f0, f1) = (tone[j][c] as f64, tone[j + 1][c] as f64); + let v = ((f1 - f0) * (x - j as f64) + f0) as f32; + if x >= last as f64 { tone[last][c] } else { v } + }) +} + +impl Carrier<'_> { + fn derived(&self) -> Derived { + let w = self.width_px; + Derived { + soft: (w * SOFT).max(1.0) as f32, + soft_filed: (w * FILED_SOFT).max(1.0) as f32, + margin: w * MARGIN, + radius: w * CORNER * self.corner, + gate_radius: w * GATE_CORNER, + cell: (w * NOISE_CELL).max(1.0) as f32, + off_x: (w * OFFSET_X) as f32, + off_y: (w * OFFSET_Y) as f32, + reach: (w * FLARE_DEPTH).max(1.0) as f32, + } + } + + /// The edge band past which no edge has an effect. + fn band(&self) -> usize { + let w = self.width_px; + let extent = MARGIN + + CORNER * self.corner + + 1.0 + + JITTER + + NOISE_OUTER + + NOISE_INNER + + OFFSET_X.abs().max(OFFSET_Y.abs()); + (w * extent + (w * SOFT).max(1.0)).ceil() as usize + 1 + } + + #[inline(always)] + fn prof(&self, row: usize, s: f32) -> f32 { + // astype(int32) truncates; s is positive. + let i = whole((s * SAMPLES as f32).floor()).clamp(0, SAMPLES as i32 - 1); + self.profiles[row * SAMPLES + i as usize] + } +} + +/// Pixels per pass: each step runs as a flat loop over a run, so it vectorizes. +const RUN: usize = 256; + +/// `_carrier_block` over the pixels x0.. of row y (`src` and `out`) of an HxW print, for the +/// edges in `sides`, in order, composited and clipped. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +#[allow(clippy::too_many_arguments)] +fn carrier_run( + c: &Carrier, + d: &Derived, + (x0, y): (usize, usize), + (h, w): (usize, usize), + sides: &[usize], + src: &[[f32; 3]], + out: &mut [[f32; 3]], +) { + let n = src.len(); + let py = y as f32; + let (wl, hl) = ((w as f64 - 1.0) as f32, (h as f64 - 1.0) as f32); + let sy = (py + 0.5) / h as f32; + let end_y = py.min(hl - py); + let qy = py - d.off_y; + let fend_y = qy.min(hl - qy); + let wr = (c.width_px * c.rough) as f32; + let mut n2 = [0f32; RUN]; + for i in 0..n { + n2[i] = carrier_noise((x0 + i) as f32 / d.cell, py / d.cell); + } + let (mut a_in, mut a_out) = ([1f32; RUN], [1f32; RUN]); + let (mut peaks, mut gates) = ([[0f32; RUN]; 4], [[0f32; RUN]; 4]); + for (k, &e) in sides.iter().enumerate() { + for i in 0..n { + let px = (x0 + i) as f32; + let sx = (px + 0.5) / w as f32; + let end_x = px.min(wl - px); + let qx = px - d.off_x; + let fend_x = qx.min(wl - qx); + let (s, end, fend, dist, fdist) = match e { + 0 => (sx, end_x, fend_x, py, qy), + 1 => (sx, end_x, fend_x, hl - py, hl - qy), + 2 => (sy, end_y, fend_y, px, qx), + _ => (sy, end_y, fend_y, wl - px, wl - qx), + }; + let jitter = OUTER_JITTER as f32 * c.prof(e + 4, s) + NOISE_OUTER as f32 * n2[i]; + let outer = d.margin as f32 + wr * jitter + arc(d.radius, d.margin, fend); + let wobble = (JITTER * INNER_ROUGH) as f32 * c.prof(e, s) + NOISE_INNER as f32 * n2[i]; + let inner = d.margin as f32 + + c.width_px as f32 * (1.0 + wobble) + + arc(d.gate_radius, d.margin + c.width_px, end); + a_in[i] *= clip((dist - inner) / d.soft + 0.5, 0.0, 1.0); + a_out[i] *= clip((fdist - outer) / d.soft_filed + 0.5, 0.0, 1.0); + let p = clip(1.0 - (fdist - outer).abs() / d.reach, 0.0, 1.0); + peaks[k][i] = p * p; + gates[k][i] = clip(1.0 + (fdist - outer) / d.reach, 0.0, 1.0); + } + } + let mut lit = [0f32; RUN]; + // The bevel spans only the aperture, so the other edges gate each edge's flare. + if c.flare > 0.0 { + let m = sides.len(); + for e in 0..m { + let mut g = [1f32; RUN]; + let mut first = true; + for k in (0..m).filter(|&k| k != e) { + for i in 0..n { + g[i] = if first { + gates[k][i] + } else { + g[i] * gates[k][i] + }; + } + first = false; + } + for i in 0..n { + lit[i] += peaks[e][i] * g[i]; + } + } + // Floored |noise|, not a one-sided gate: that left whole edges with no flare. + let gain = (c.flare * FLARE_GAIN) as f32; + for i in 0..n { + lit[i] *= gain * (FLARE_BASE as f32 + (1.0 - FLARE_BASE) as f32 * n2[i].abs()); + } + } + for i in 0..n { + let t = tone_at(c.tone, a_out[i] + lit[i]); + let shown = a_in[i] * a_out[i]; + let p = src[i]; + out[i] = std::array::from_fn(|k| { + clip(p[k] * shown + c.paper[k] * t[k] * (1.0 - shown), 0.0, 1.0) + }); + } +} + +/// `apply_carrier` for width_px > 0: the rebate composited along the print edges, then the +/// whole print clipped to 0..1. +pub fn apply_carrier(src: &[f32], (h, w): (usize, usize), c: &Carrier, dst: &mut [f32]) { + let d = c.derived(); + let band = c.band(); + let all = 2 * band >= h || 2 * band >= w; + dst.par_chunks_mut(w * 3).enumerate().for_each(|(y, out)| { + let row = src[y * w * 3..][..w * 3].as_chunks::<3>().0; + let out = out.as_chunks_mut::<3>().0; + let vert = if y < band { + Some(0) + } else if y >= h - band { + Some(1) + } else { + None + }; + // Spans of one side set: the left band, the middle, the right band. + let spans: [(usize, usize, Option); 3] = if all { + [(0, w, None), (w, w, None), (w, w, None)] + } else { + [ + (0, band, Some(2)), + (band, w - band, None), + (w - band, w, Some(3)), + ] + }; + for (a, b, horiz) in spans { + let mut sides = [0usize; 4]; + let m = if all { + sides = [0, 1, 2, 3]; + 4 + } else { + let mut m = 0; + for e in [vert, horiz].into_iter().flatten() { + sides[m] = e; + m += 1; + } + m + }; + if m == 0 { + for (o, p) in out[a..b].iter_mut().zip(&row[a..b]) { + *o = p.map(|v| clip(v, 0.0, 1.0)); + } + continue; + } + let mut x = a; + while x < b { + let e = (x + RUN).min(b); + carrier_run( + c, + &d, + (x, y), + (h, w), + &sides[..m], + &row[x..e], + &mut out[x..e], + ); + x = e; + } + } + }); +} diff --git a/negpy_core/src/flatfield.rs b/negpy_core/src/flatfield.rs new file mode 100644 index 000000000..66b97aac1 --- /dev/null +++ b/negpy_core/src/flatfield.rs @@ -0,0 +1,267 @@ +//! The flat-field reference checks of negpy/features/flatfield/logic.py, in NumPy's float32 +//! operation order. + +use rayon::prelude::*; + +use crate::stats::{np_percentiles, np_percentiles_f64}; + +const BLOCK: usize = 1 << 13; + +/// `img.mean(axis=2)` of a 3-channel pixel: the three summed in order, then over 3. +#[inline(always)] +fn mean3(p: &[f32]) -> f32 { + ((p[0] + p[1]) + p[2]) / 3.0 +} + +/// The scalars of `_lit_mask`, as Python floats. +pub struct Lit { + pub percentile: f64, + pub fraction: f64, + pub min_fraction: f64, +} + +/// `_lit_mask(ref)`: true where the mean of the channels clears `fraction` of its +/// `percentile`, eroded 5x5 with the outside dark. `None` when under `min_fraction` of the +/// frame is lit, where the caller takes the whole frame. +pub fn lit_mask(img: &[f32], (h, w): (usize, usize), c: &Lit) -> Option> { + let lum: Vec = img.par_chunks(3).map(mean3).collect(); + let bar = (c.fraction as f32) * np_percentiles(&lum, &[c.percentile])?[0]; + let above: Vec = lum.par_iter().map(move |&l| (l > bar) as u8).collect(); + let mut lit = vec![0u8; h * w]; + crate::morph::morphology( + &above, + &mut lit, + (h, w, 1), + crate::morph::Op::Erode, + (5, 5), + 1, + crate::filter::Border::Constant, + )?; + // A dark outside clears every pixel whose window leaves the frame. + for (y, row) in lit.chunks_exact_mut(w).enumerate() { + if y < 2 || y + 2 >= h { + row.fill(0); + } else { + row[..2.min(w)].fill(0); + row[w.saturating_sub(2)..].fill(0); + } + } + let count = lit.par_iter().filter(|&&v| v != 0).count(); + (count as f64 >= c.min_fraction * (h * w) as f64).then(|| lit.iter().map(|&v| v != 0).collect()) +} + +/// Lit pixels with a channel at `level` or over, as a fraction of the lit pixels (float64). +fn clipped_fraction(img: &[f32], lit: &[bool], level: f32) -> f64 { + let (n, hit) = img + .par_chunks(3 * BLOCK) + .zip(lit.par_chunks(BLOCK)) + .map(|(px, l)| { + let mut n = 0usize; + let mut hit = 0usize; + for (p, &l) in px.as_chunks::<3>().0.iter().zip(l) { + if l { + n += 1; + hit += (p[0] >= level || p[1] >= level || p[2] >= level) as usize; + } + } + (n, hit) + }) + .reduce(|| (0, 0), |a, b| (a.0 + b.0, a.1 + b.1)); + hit as f64 / n as f64 +} + +/// `_is_clipped(image)`'s fraction of lit pixels at the clip level, and whether the frame was +/// too dark for a lit mask. +pub fn clipped(img: &[f32], dims: (usize, usize), c: &Lit, level: f32) -> (f64, bool) { + let lit = lit_mask(img, dims, c); + let dark = lit.is_none(); + let lit = lit.unwrap_or_else(|| vec![true; dims.0 * dims.1]); + (clipped_fraction(img, &lit, level), dark) +} + +/// What `_evenness` measures: (low, high, color, clipped fraction). +pub struct Evenness { + pub low: f64, + pub high: f64, + pub color: f64, + pub clipped: f64, + /// Too dark for a lit mask: the whole frame was measured. + pub dark: bool, +} + +/// `_evenness(image)`: each channel over its lit-area median (0 off the lit area) into +/// `ratio`, and the spread of the lit pixels about it. +pub fn evenness( + img: &[f32], + dims: (usize, usize), + c: &Lit, + level: f32, + ratio: &mut [f32], +) -> Option { + let n = dims.0 * dims.1; + let lit = lit_mask(img, dims, c); + let dark = lit.is_none(); + let lit = lit.unwrap_or_else(|| vec![true; n]); + let lit_px: Vec = (0..n).filter(|&p| lit[p]).collect(); + let median: [f32; 3] = std::array::from_fn(|ch| { + let vals: Vec = lit_px.par_iter().map(|&p| img[3 * p + ch]).collect(); + let m = crate::stats::median(&vals); + if m.is_nan() || m > 1e-6 { m } else { 1e-6 } + }); + ratio + .par_chunks_mut(3 * BLOCK) + .zip(img.par_chunks(3 * BLOCK).zip(lit.par_chunks(BLOCK))) + .for_each(|(r, (px, l))| { + for ((r, p), &l) in r + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(px.as_chunks::<3>().0.iter()) + .zip(l) + { + for ch in 0..3 { + r[ch] = if l { p[ch] / median[ch] } else { 0.0 }; + } + } + }); + let lum: Vec = lit_px + .par_iter() + .map(|&p| mean3(&ratio[3 * p..3 * p + 3])) + .collect(); + let dev: Vec = lum.par_iter().map(|&l| l - 1.0).collect(); + let q = np_percentiles_f64(&dev, &[1.0, 99.0]); + let color = (0..3) + .map(|ch| { + let chroma: Vec = lit_px + .par_iter() + .zip(&lum) + .map(|(&p, &l)| { + ratio[3 * p + ch] / if l.is_nan() || l > 1e-6 { l } else { 1e-6 } - 1.0 + }) + .collect(); + np_percentiles_f64(&chroma, &[1.0, 99.0]) + }) + .flat_map(|q| q.into_iter().map(f64::abs)) + .fold( + f64::NEG_INFINITY, + |m, v| if v > m || v.is_nan() { v } else { m }, + ); + Some(Evenness { + low: q[0], + high: q[1], + color, + clipped: clipped_fraction(img, &lit, level), + dark, + }) +} + +/// `_fill_from_nearest(values, valid)` in place: each invalid pixel takes the values of its +/// nearest valid one (OpenCV's L2 5x5 labels). +pub fn fill_from_nearest(values: &mut [f32], valid: &[bool], (h, w): (usize, usize), cn: usize) { + if valid.iter().all(|&v| v) || !valid.iter().any(|&v| v) { + return; + } + let holes: Vec = valid.iter().map(|&v| !v as u8).collect(); + let (_, labels) = crate::distance::distance_transform_labels(&holes, h, w); + // The labels number the valid pixels from 1 in raster order. + let donors: Vec = (0..h * w).filter(|&p| valid[p]).collect(); + for p in 0..h * w { + if !valid[p] { + let d = donors[labels[p] as usize - 1]; + for c in 0..cn { + values[cn * p + c] = values[cn * d + c]; + } + } + } +} + +/// The scalars of `compute_gain`. +pub struct Gain { + pub lit: Lit, + pub sigma: f64, + pub eps: f32, + pub min_lit_weight: f32, + pub range: (f32, f32), +} + +/// `compute_gain` of the reference at its working size: the lit-area mean of a normalized +/// Gaussian blur over the blur, per channel. Returns whether the reference was too dark for a +/// lit mask. +pub fn compute_gain( + reference: &[f32], + (h, w): (usize, usize), + c: &Gain, + gain: &mut [f32], +) -> Option { + use crate::filter::{Border, gaussian_blur}; + let n = h * w; + let lit_mask = lit_mask(reference, (h, w), &c.lit); + let dark = lit_mask.is_none(); + let lit: Vec = match lit_mask { + Some(m) => m.iter().map(|&v| v as u8 as f32).collect(), + None => vec![1.0; n], + }; + let weighted: Vec = reference + .as_chunks::<3>() + .0 + .iter() + .zip(&lit) + .map(|(p, &l)| [p[0] * l, p[1] * l, p[2] * l]) + .collect::>() + .into_flattened(); + let (mut num, mut den) = (vec![0f32; 3 * n], vec![0f32; n]); + let s = (c.sigma, c.sigma); + gaussian_blur( + &weighted, + &mut num, + (h, w, 3), + (0, 0), + s, + Border::Reflect101, + )?; + gaussian_blur(&lit, &mut den, (h, w, 1), (0, 0), s, Border::Reflect101)?; + let eps = c.eps; + let clip_lo = |v: f32, lo: f32| if v.is_nan() || v > lo { v } else { lo }; + for (b, &d) in num.as_chunks_mut::<3>().0.iter_mut().zip(&den) { + let d = clip_lo(d, eps); + for v in b { + *v = clip_lo(*v / d, eps); + } + } + let valid: Vec = den.iter().map(|&d| d >= c.min_lit_weight).collect(); + fill_from_nearest(&mut num, &valid, (h, w), 3); + // (blur * lit).sum(axis=(0, 1)): one running sum per channel in raster order. + let mut sums = [0f32; 3]; + for (b, &l) in num.as_chunks::<3>().0.iter().zip(&lit) { + for ch in 0..3 { + sums[ch] += b[ch] * l; + } + } + let total = crate::stats::pairwise_sum_f32(&lit); + let means = sums.map(|s| s / total); + let (lo, hi) = c.range; + for (g, b) in gain + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(num.as_chunks::<3>().0.iter()) + { + for ch in 0..3 { + let v = means[ch] / b[ch]; + let v = if v.is_nan() || v > lo { v } else { lo }; + g[ch] = if v.is_nan() || v < hi { v } else { hi }; + } + } + Some(dark) +} + +/// `evenness_view`'s map, in place on the ratio: ±`span` about 1 onto 0..1. +pub fn evenness_view(ratio: &mut [f32], span: f32) { + ratio.par_chunks_mut(BLOCK).for_each(move |run| { + for v in run { + let m = 0.5 + (*v - 1.0) / span; + let m = if m.is_nan() || m > 0.0 { m } else { 0.0 }; + *v = if m.is_nan() || m < 1.0 { m } else { 1.0 }; + } + }); +} diff --git a/negpy_core/src/gray.rs b/negpy_core/src/gray.rs new file mode 100644 index 000000000..4bc67424d --- /dev/null +++ b/negpy_core/src/gray.rs @@ -0,0 +1,329 @@ +//! OpenCV's uint8 grayscale operations, all integer arithmetic: BGR to gray, the fixed-point +//! GaussianBlur, Otsu's threshold and Canny. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::filter::{Border, border_interpolate, gaussian_kernel_f64}; + +/// `cv2.cvtColor(src, cv2.COLOR_BGR2GRAY)`: 15-bit fixed-point weights, rounded. +pub fn bgr_to_gray(src: &[u8], dst: &mut [u8]) { + crate::par_zip(dst, src.as_chunks::<3>().0, move |d, p| { + *d = ((p[0] as u32 * 3735 + p[1] as u32 * 19235 + p[2] as u32 * 9798 + (1 << 14)) >> 15) + as u8; + }); +} + +/// `getGaussianKernelFixedPoint_ED`: the bit-exact kernel in 8 fractional bits, rounding errors +/// carried along the half kernel and the centre taking what makes the sum exactly 256. +pub fn gaussian_kernel_u8(n: usize, sigma: f64) -> Vec { + let k = gaussian_kernel_f64(n, sigma); + let mut out = vec![0u32; n]; + let mut err = 0.0f64; + let mut sum = 0i64; + for i in 0..n / 2 { + let adj = k[i] * 256.0 + err; + let v = adj.round_ties_even() as i64; + err = adj - v as f64; + out[i] = v as u32; + out[n - 1 - i] = v as u32; + sum += v; + } + out[n / 2] = (256 - 2 * sum) as u32; + out +} + +/// `cv2.GaussianBlur` for uint8 (`GaussianBlurFixedPoint`): integer rows, then integer columns, +/// rounded once from 16 fractional bits. +pub fn gaussian_blur_u8( + src: &[u8], + dst: &mut [u8], + (h, w, cn): (usize, usize, usize), + ksize: (usize, usize), + (sigma_x, sigma_y): (f64, f64), + border: Border, +) -> Option<()> { + if h == 0 || w == 0 || cn == 0 || src.len() != h * w * cn || dst.len() != src.len() { + return None; + } + let (mut kw, mut kh) = ksize; + let sigma_y = if sigma_y <= 0.0 { sigma_x } else { sigma_y }; + let size_of = |s: f64| ((s * 3.0 * 2.0 + 1.0).round_ties_even() as i64 | 1) as usize; + if kw == 0 && sigma_x > 0.0 { + kw = size_of(sigma_x); + } + if kh == 0 && sigma_y > 0.0 { + kh = size_of(sigma_y); + } + if kw.is_multiple_of(2) || kh.is_multiple_of(2) { + return None; + } + if border != Border::Constant { + if h == 1 { + kh = 1; + } + if w == 1 { + kw = 1; + } + } + if kw == 1 && kh == 1 { + dst.copy_from_slice(src); + return Some(()); + } + let kx = gaussian_kernel_u8(kw, sigma_x.max(0.0)); + let ky = if kh == kw && (sigma_x - sigma_y).abs() < f64::EPSILON { + kx.clone() + } else { + gaussian_kernel_u8(kh, sigma_y.max(0.0)) + }; + let (rx, ry) = (kw / 2, kh / 2); + let n = w * cn; + let xmap: Vec> = (0..w + 2 * rx) + .map(|px| border_interpolate(px as isize - rx as isize, w, border)) + .collect(); + + // Kernel-major sums over a padded row: integer, so the order leaves the bits alone. A row sum + // is at most 255 * 256, so rows fit u16, as in OpenCV. + let mut rows = vec![0u16; h * n]; + rows.par_chunks_mut(n).enumerate().for_each(|(y, out)| { + let row = &src[y * n..(y + 1) * n]; + let mut pad = vec![0u16; (w + 2 * rx) * cn]; + for (p, &v) in pad[rx * cn..(rx + w) * cn].iter_mut().zip(row) { + *p = v as u16; + } + for p in (0..rx).chain(rx + w..w + 2 * rx) { + if let Some(sx) = xmap[p] { + for c in 0..cn { + pad[p * cn + c] = row[sx * cn + c] as u16; + } + } + } + for (j, &k) in kx.iter().enumerate() { + mac_u16(out, &pad[j * cn..j * cn + n], k as u16); + } + }); + dst.par_chunks_mut(n).enumerate().for_each(|(y, out)| { + let mut acc = vec![0u32; n]; + for (j, &k) in ky.iter().enumerate() { + if let Some(sy) = border_interpolate(y as isize + j as isize - ry as isize, h, border) { + mac_u32(&mut acc, &rows[sy * n..(sy + 1) * n], k); + } + } + for (o, &v) in out.iter_mut().zip(&acc) { + *o = ((v + (1 << 15)) >> 16).min(255) as u8; + } + }); + Some(()) +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn mac_u16(out: &mut [u16], src: &[u16], k: u16) { + for (o, &v) in out.iter_mut().zip(src) { + *o += k * v; + } +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn mac_u32(out: &mut [u32], src: &[u16], k: u32) { + for (o, &v) in out.iter_mut().zip(src) { + *o += k * v as u32; + } +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn sobel_row(a: &[i16], b: &[i16], c: &[i16], rx: &mut [i16], ry: &mut [i16], rm: &mut [i16]) { + for x in 0..rx.len() { + let gx = (a[x + 2] + 2 * b[x + 2] + c[x + 2]) - (a[x] + 2 * b[x] + c[x]); + let gy = (c[x] + 2 * c[x + 1] + c[x + 2]) - (a[x] + 2 * a[x + 1] + a[x + 2]); + rx[x] = gx; + ry[x] = gy; + rm[x] = gx.abs() + gy.abs(); + } +} + +/// Otsu's threshold for uint8 (`getThreshVal_Otsu`), in its double arithmetic and first-max rule. +pub fn otsu_threshold(src: &[u8]) -> f64 { + let mut hist = [0u64; 256]; + for &v in src { + hist[v as usize] += 1; + } + let scale = 1.0 / src.len() as f64; + let mut mu = 0.0f64; + for (i, &c) in hist.iter().enumerate() { + mu += i as f64 * c as f64; + } + mu *= scale; + let (mut mu1, mut q1, mut max_sigma, mut max_val) = (0.0f64, 0.0f64, 0.0f64, 0.0f64); + let eps = f32::EPSILON as f64; + for (i, &c) in hist.iter().enumerate() { + let p = c as f64 * scale; + mu1 *= q1; + q1 += p; + let q2 = 1.0 - q1; + if q1.min(q2) < eps || q1.max(q2) > 1.0 - eps { + continue; + } + mu1 = (mu1 + i as f64 * p) / q1; + let mu2 = (mu - q1 * mu1) / q2; + let sigma = q1 * q2 * (mu1 - mu2) * (mu1 - mu2); + if sigma > max_sigma { + max_sigma = sigma; + max_val = i as f64; + } + } + max_val +} + +/// `cv2.threshold` with THRESH_BINARY (`invert` false) or THRESH_BINARY_INV on uint8. +pub fn threshold_binary(src: &[u8], dst: &mut [u8], thresh: f64, maxval: u8, invert: bool) { + // OpenCV compares against cvFloor(thresh) for uint8. + let t = thresh.floor().clamp(-1.0, 255.0) as i32; + crate::par_zip(dst, src, move |d, &s| { + *d = if (s as i32 > t) != invert { maxval } else { 0 }; + }); +} + +/// `cv2.Canny(src, low, high)` for a single-channel uint8 image, aperture 3, L1 gradient. +pub fn canny(src: &[u8], dst: &mut [u8], h: usize, w: usize, low: f64, high: f64) -> Option<()> { + if h == 0 || w == 0 || src.len() != h * w || dst.len() != src.len() { + return None; + } + let (low, high) = if low > high { (high, low) } else { (low, high) }; + let (low, high) = (low.floor() as i32, high.floor() as i32); + // Sobel 3x3 over a replicated one-pixel border, then |dx| + |dy| in a frame of zeros. + let pw = w + 2; + let mut pad = vec![0i16; (h + 2) * pw]; + for py in 0..h + 2 { + let sy = py.saturating_sub(1).min(h - 1); + let line = &mut pad[py * pw..(py + 1) * pw]; + for (px, v) in line.iter_mut().enumerate() { + *v = src[sy * w + px.saturating_sub(1).min(w - 1)] as i16; + } + } + let mut dx = vec![0i16; h * w]; + let mut dy = vec![0i16; h * w]; + let mut mag = vec![0i16; (h + 2) * pw]; + dx.par_chunks_mut(w) + .zip(dy.par_chunks_mut(w)) + .zip(mag.par_chunks_mut(pw).skip(1)) + .enumerate() + .for_each(|(y, ((rx, ry), rm))| { + sobel_row( + &pad[y * pw..(y + 1) * pw], + &pad[(y + 1) * pw..(y + 2) * pw], + &pad[(y + 2) * pw..(y + 3) * pw], + rx, + ry, + &mut rm[1..w + 1], + ); + }); + + // Map: 0 may be an edge, 1 cannot, 2 is one. A one-pixel frame of 1 surrounds the image. + const TG22: i64 = 13573; + let mw = pw; + let mut map = vec![1u8; (h + 2) * mw]; + map.par_chunks_mut(mw) + .enumerate() + .skip(1) + .take(h) + .for_each(|(my, line)| { + let y = my - 1; + let (up, mid, down) = ( + &mag[(my - 1) * pw..my * pw], + &mag[my * pw..(my + 1) * pw], + &mag[(my + 1) * pw..(my + 2) * pw], + ); + for x in 0..w { + let px = x + 1; + let m = mid[px] as i32; + let mut v = 1u8; + if m > low { + let i = y * w + x; + let (xs, ys) = (dx[i], dy[i]); + let ax = xs.abs() as i64; + let ay = (ys.abs() as i64) << 15; + let tg22x = ax * TG22; + let m = m as i16; + let peak = if ay < tg22x { + m > mid[px - 1] && m >= mid[px + 1] + } else if ay > tg22x + (ax << 16) { + m > up[px] && m >= down[px] + } else if (xs ^ ys) < 0 { + m > up[px + 1] && m > down[px - 1] + } else { + m > up[px - 1] && m > down[px + 1] + }; + let m = m as i32; + if peak { + v = if m > high { 2 } else { 0 }; + } + } + line[px] = v; + } + }); + + // Hysteresis: grow every edge pixel into 8-connected candidates. + let mut stack: Vec = map + .iter() + .enumerate() + .filter(|(_, v)| **v == 2) + .map(|(i, _)| i) + .collect(); + while let Some(i) = stack.pop() { + for o in [ + i - mw - 1, + i - mw, + i - mw + 1, + i - 1, + i + 1, + i + mw - 1, + i + mw, + i + mw + 1, + ] { + if map[o] == 0 { + map[o] = 2; + stack.push(o); + } + } + } + dst.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + let line = &map[(y + 1) * mw + 1..(y + 1) * mw + 1 + w]; + for (o, &v) in out.iter_mut().zip(line) { + *o = if v == 2 { 255 } else { 0 }; + } + }); + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_point_kernels_sum_to_256() { + for (n, s) in [(5, 0.0), (7, 0.0), (11, 0.0), (13, 2.0), (25, 4.0)] { + assert_eq!(gaussian_kernel_u8(n, s).iter().sum::(), 256, "n={n}"); + } + assert_eq!(gaussian_kernel_u8(5, 0.0), vec![16, 64, 96, 64, 16]); + } + + #[test] + fn otsu_splits_two_levels() { + let mut img = vec![20u8; 500]; + img.extend(vec![200u8; 500]); + let t = otsu_threshold(&img); + assert!((20.0..200.0).contains(&t)); + } + + #[test] + fn canny_finds_a_step() { + let (h, w) = (20, 20); + let src: Vec = (0..h * w) + .map(|i| if i % w < 10 { 0 } else { 255 }) + .collect(); + let mut dst = vec![0u8; h * w]; + canny(&src, &mut dst, h, w, 40.0, 160.0).unwrap(); + assert!(dst.contains(&255)); + assert!((0..h).all(|y| dst[y * w] == 0)); + } +} diff --git a/negpy_core/src/hdr.rs b/negpy_core/src/hdr.rs new file mode 100644 index 000000000..e0d14084f --- /dev/null +++ b/negpy_core/src/hdr.rs @@ -0,0 +1,172 @@ +//! The bracket-merge kernels of negpy/features/hdr/logic.py. + +use multiversion::multiversion; +use rayon::prelude::*; + +const BLOCK: usize = 1 << 14; + +/// `_count_clipped_rows`: per row, the pixels with any channel at or above `saturation`, +/// compared in float64 as Numba compares a float32 with a Python float. +pub fn count_clipped_rows( + values: &[f32], + (w, cn): (usize, usize), + saturation: f64, + out: &mut [i64], +) { + // The smallest float32 at or above the threshold: the float32 compare is then the float64 one. + let mut limit = saturation as f32; + if (limit as f64) < saturation { + limit = f32::from_bits(limit.to_bits() + 1); + } + out.par_iter_mut() + .zip(values.par_chunks(w * cn)) + .for_each(|(n, row)| *n = count_row(row, cn, limit)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn count_row(row: &[f32], cn: usize, limit: f32) -> i64 { + if cn == 3 { + return row + .as_chunks::<3>() + .0 + .iter() + .map(|p| ((p[0] >= limit) | (p[1] >= limit) | (p[2] >= limit)) as i64) + .sum(); + } + row.chunks(cn) + .map(|p| p.iter().fold(false, |hit, &v| hit | (v >= limit)) as i64) + .sum() +} + +/// `_accumulate`: one frame added to the weighted sums in reference units. Weight is ratio^2, +/// rolled off per channel toward saturation; the order is Numba's vector loop. +pub fn accumulate( + num: &mut [f32], + den: &mut [f32], + values: &[f32], + ratio: f64, + (saturation, rolloff_start): (f64, f64), +) { + // The span is a Python float difference, rounded to float32 once. + let inv_span = 1.0 / ((saturation - rolloff_start) as f32); + let saturation = saturation as f32; + let weight = (ratio as f32) * (ratio as f32); + let inv_scale = 1.0 / (ratio.max(1e-9) as f32); + num.par_chunks_mut(1 << 14) + .zip(den.par_chunks_mut(1 << 14)) + .zip(values.par_chunks(1 << 14)) + .for_each(|((n, d), v)| { + accumulate_block(n, d, v, (saturation, inv_span, weight, inv_scale)) + }); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn accumulate_block( + num: &mut [f32], + den: &mut [f32], + values: &[f32], + (sat, inv_span, weight, inv_scale): (f32, f32, f32, f32), +) { + for ((n, d), &v) in num.iter_mut().zip(den.iter_mut()).zip(values) { + let r = (sat - v) * inv_span; + let rolloff = r.clamp(0.0, 1.0); + let w = rolloff * weight; + *n = (v * inv_scale).mul_add(w, *n); + *d += w; + } +} + +/// The first three channels of every `step`-th pixel of every `step`-th row, as +/// `frame[::step, ::step, :3]` reads them, flattened; the frame itself when that is all of it. +fn subsample( + frame: &[f32], + (h, w, cn): (usize, usize, usize), + step: usize, +) -> std::borrow::Cow<'_, [f32]> { + let step = step.max(1); + if step == 1 && cn == 3 { + return frame.into(); + } + let rows: Vec = (0..h).step_by(step).collect(); + rows.par_iter() + .flat_map_iter(|&y| { + (0..w) + .step_by(step) + .flat_map(move |x| (0..3).map(move |c| frame[(y * w + x) * cn + c])) + }) + .collect::>() + .into() +} + +/// `measure_exposure`'s level: the 99th percentile of the subsampled frame. +pub fn exposure_level( + frame: &[f32], + dims: (usize, usize, usize), + step: usize, + q: f64, +) -> Option { + let s = subsample(frame, dims, step); + crate::stats::np_percentiles(&s, &[q]).map(|v| v[0]) +} + +/// `pair_ratio`: the median of long / short over the subsampled samples that clear `floor` +/// in the short frame and stay under `saturation` in the long one; `None` under 1000 such +/// samples or for a ratio that is not finite and positive. +pub fn pair_ratio( + short: &[f32], + long: &[f32], + dims: (usize, usize, usize), + step: usize, + (floor, saturation): (f32, f32), +) -> Option { + let (s, l) = (subsample(short, dims, step), subsample(long, dims, step)); + let ratios: Vec = s + .par_chunks(BLOCK) + .zip(l.par_chunks(BLOCK)) + .flat_map_iter(|(s, l)| { + s.iter() + .zip(l) + .filter(move |&(&s, &l)| s >= floor && l < saturation) + .map(|(&s, &l)| l / s) + }) + .collect(); + if ratios.len() < 1000 { + return None; + } + let r = crate::stats::median(&ratios) as f64; + (r.is_finite() && r > 0.0).then_some(r) +} + +/// The end of `merge_providers`: `num / den`, the reference's own value where no frame left a +/// measurement (`den <= 0`). +pub fn finish_merge(num: &[f32], den: &[f32], reference: &[f32], dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(num.par_chunks(BLOCK)) + .zip(den.par_chunks(BLOCK).zip(reference.par_chunks(BLOCK))) + .for_each(|((o, n), (d, r))| { + for (((o, &n), &d), &r) in o.iter_mut().zip(n).zip(d).zip(r) { + *o = if d <= 0.0 { r / 1.0 } else { n / d }; + } + }); +} + +/// `apply_render_exposure`: the merge times `scale`, clipped to 0..1. +pub fn render_exposure(merged: &[f32], scale: f32, dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(merged.par_chunks(BLOCK)) + .for_each(move |(o, m)| { + for (o, &m) in o.iter_mut().zip(m) { + let v = m * scale; + let v = if v.is_nan() || v > 0.0 { v } else { 0.0 }; + *o = if v.is_nan() || v < 1.0 { v } else { 1.0 }; + } + }); +} diff --git a/negpy_core/src/histogram.rs b/negpy_core/src/histogram.rs new file mode 100644 index 000000000..027919fb0 --- /dev/null +++ b/negpy_core/src/histogram.rs @@ -0,0 +1,430 @@ +//! The scope counts of negpy/features/exposure/analysis.py: integer counts, so only the bin each +//! sample lands in must match NumPy. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::analysis::luminance_chunk; +use crate::color::{self, LabSpace}; + +pub const DENSITY_HIST_BINS: usize = 120; +const DENSITY_HIST_RANGE: (f64, f64) = (-0.1, 1.1); +pub const WAVEFORM_BINS: usize = 128; +pub const WAVEFORM_COLS: usize = 256; +pub const OUTPUT_HIST_BINS: usize = 256; +pub const COLOR_HIST_BINS: usize = 32; +const WAVEFORM_COLOR_SCALE: f32 = 1023.0; +const SCAN_DENSITY_RANGE: (f64, f64) = (0.0, 3.0); + +/// A stride-subsampled crop of an HxWxC float32 image: rows `y1..y2` and columns `x1..x2`, every +/// `step`-th from the first, as `img[y1:y2:step, x1:x2:step]` reads them. +#[derive(Clone, Copy)] +pub struct Samples<'a, T = f32> { + pub img: &'a [T], + pub w: usize, + pub cn: usize, + pub rows: (usize, usize), + pub cols: (usize, usize), + pub step: usize, +} + +impl Samples<'_, T> { + fn row_starts(&self) -> Vec { + (self.rows.0..self.rows.1).step_by(self.step).collect() + } + + /// The first three channels of row `y`'s samples, packed: a slice of the image when they + /// already are, else a copy in `buf`. + fn rgb_row<'b>(&'b self, y: usize, buf: &'b mut Vec) -> &'b [T] { + if self.step == 1 && self.cn == 3 { + return &self.img[(y * self.w + self.cols.0) * 3..(y * self.w + self.cols.1) * 3]; + } + buf.clear(); + for x in (self.cols.0..self.cols.1).step_by(self.step) { + let p = (y * self.w + x) * self.cn; + buf.extend_from_slice(&self.img[p..p + 3]); + } + buf + } +} + +/// `np.histogram(np.clip(values, lo, hi), bins, range=(lo, hi))` for float32 values with Python +/// float edges: NumPy bins in float32 (NEP 50), then corrects each index against the float32 +/// `np.linspace` edges, `f32(i * step + lo)`. NaN is dropped. +#[derive(Clone, Copy)] +struct Binner { + lo: f64, + hi: f64, + bins: usize, +} + +const NO_BIN: u8 = u8::MAX; + +impl Binner { + /// Each value's bin, or NO_BIN for NaN; branch-free, so it vectorizes. + fn bin_into(self, vals: &[f32], out: &mut [u8]) { + bin_into(self, vals, out) + } +} + +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn bin_into(b: Binner, vals: &[f32], out: &mut [u8]) { + let (lo, hi, n) = (b.lo as f32, b.hi as f32, b.bins as i32); + let (denom, step) = ((b.hi - b.lo) as f32, (b.hi - b.lo) / b.bins as f64); + let edge = |i: i32| (i as f64 * step + b.lo) as f32; + for (o, &v) in out.iter_mut().zip(vals) { + let c = v.max(lo).min(hi); + let i = ((((c - lo) / denom) * n as f32) as i32).min(n - 1); + let down = c < edge(i); + let up = !down & (i != n - 1) & (c >= edge(i + 1)); + let i = i - down as i32 + up as i32; + *o = if v.is_nan() { NO_BIN } else { i as u8 }; + } +} + +/// The parade bin of each value: np.nan_to_num and the negation in float32, the scan density +/// axis in float64, floored and clipped. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn parade_bins(vals: &[f32], out: &mut [u8]) { + let (lo, hi) = SCAN_DENSITY_RANGE; + for (o, &v) in out.iter_mut().zip(vals) { + let v = if v.is_nan() { + 0.0 + } else { + v.clamp(f32::MIN, f32::MAX) + }; + let pos = (hi - (-v) as f64) / (hi - lo); + *o = (pos * WAVEFORM_BINS as f64) + .floor() + .clamp(0.0, (WAVEFORM_BINS - 1) as f64) as u8; + } +} + +/// Neighbouring samples often share a bin, and one counter serializes on its own increments, +/// so each sample counts into lane `i % LANES` (u32: a cell holds one column's samples at most). +const LANES: usize = 4; + +fn fold_lanes(lanes: &[u32]) -> Vec { + let n = lanes.len() / LANES; + let mut out = vec![0u64; n]; + for lane in lanes.chunks_exact(n) { + out.iter_mut().zip(lane).for_each(|(o, &c)| *o += c as u64); + } + out +} + +/// `density_histogram`: (4, DENSITY_HIST_BINS) counts of R, G, B and luminance. +pub fn density_histogram(s: Samples) -> Vec { + let binner = Binner { + lo: DENSITY_HIST_RANGE.0, + hi: DENSITY_HIST_RANGE.1, + bins: DENSITY_HIST_BINS, + }; + count_rows(s, 4 * DENSITY_HIST_BINS, |rgb, counts| { + let px = rgb.len() / 3; + let mut lum = vec![0f32; px]; + luminance_chunk(rgb, &mut lum); + let (mut bins, mut lbins) = (vec![0u8; 3 * px], vec![0u8; px]); + binner.bin_into(rgb, &mut bins); + binner.bin_into(&lum, &mut lbins); + for (i, (p, &l)) in bins.as_chunks::<3>().0.iter().zip(&lbins).enumerate() { + let lane = (i % LANES) * 4 * DENSITY_HIST_BINS; + for (c, &b) in p.iter().chain([&l]).enumerate() { + if b != NO_BIN { + counts[lane + c * DENSITY_HIST_BINS + b as usize] += 1; + } + } + } + }) +} + +/// `density_parade`: (3, WAVEFORM_BINS, WAVEFORM_COLS) counts of each channel's scan density +/// (the negated log) per parade column, the column of the sample's x in the crop. +pub fn density_parade(s: Samples) -> Vec { + let full_w = s.cols.1 - s.cols.0; + let cols: Vec = (0..full_w) + .step_by(s.step) + .map(|x| (x * WAVEFORM_COLS / full_w).min(WAVEFORM_COLS - 1)) + .collect(); + let plane = WAVEFORM_BINS * WAVEFORM_COLS; + count_rows(s, 3 * plane, |rgb, counts| { + let mut bins = vec![0u8; rgb.len()]; + parade_bins(rgb, &mut bins); + for (i, (p, &col)) in bins.as_chunks::<3>().0.iter().zip(&cols).enumerate() { + let lane = (i % LANES) * 3 * plane; + for (c, &b) in p.iter().enumerate() { + counts[lane + c * plane + b as usize * WAVEFORM_COLS + col] += 1; + } + } + }) +} + +/// NumPy's float32 to int32 cast on x86: truncation, and INT_MIN for NaN or out of range. +fn np_i32(f: f32) -> i32 { + if (-2147483648.0..2147483648.0).contains(&f) { + f as i32 + } else { + i32::MIN + } +} + +/// np.nan_to_num for float32. +fn nan_to_num(v: f32) -> f32 { + if v.is_nan() { + 0.0 + } else { + v.clamp(f32::MIN, f32::MAX) + } +} + +/// `clip((v * bins).astype(int32), 0, bins - 1)` in float32. +fn quantize(v: f32, bins: usize) -> usize { + np_i32(v * bins as f32).clamp(0, bins as i32 - 1) as usize +} + +/// Counts over the rows of `s` in parallel: `count(row, counts)` adds one row into `n` lanes' +/// worth of u32 counters, which fold into one u64 count per cell. +fn count_rows( + s: Samples<'_, T>, + n: usize, + count: impl Fn(&[T], &mut [u32]) + Sync, +) -> Vec { + // One chunk of rows per thread: rayon's fold makes an accumulator per split job, and each + // holds lanes of counters for every cell. + let rows = s.row_starts(); + let chunk = rows.len().div_ceil(rayon::current_num_threads()).max(1); + rows.par_chunks(chunk) + .map(|ys| { + let (mut counts, mut buf) = (vec![0u32; LANES * n], Vec::new()); + for &y in ys { + count(s.rgb_row(y, &mut buf), &mut counts); + } + fold_lanes(&counts) + }) + .reduce( + || vec![0u64; n], + |mut a, b| { + a.iter_mut().zip(&b).for_each(|(x, y)| *x += y); + a + }, + ) + .iter() + .map(|&c| c as f64) + .collect() +} + +/// `output_histogram` of float32 samples: (4, 256) counts of R, G, B and luminance on 0..1. +/// `np.histogram(range=(0, 1))` divides by a uint64 span, so it bins in float64, where the +/// edges i / 256 are exact; values outside 0..1 and NaN are dropped. +pub fn output_histogram(s: Samples) -> Vec { + let bin = |v: f32| { + (0.0..=1.0) + .contains(&v) + .then(|| ((v as f64 * OUTPUT_HIST_BINS as f64) as usize).min(OUTPUT_HIST_BINS - 1)) + }; + count_rows(s, 4 * OUTPUT_HIST_BINS, |rgb, counts| { + let mut lum = vec![0f32; rgb.len() / 3]; + luminance_chunk(rgb, &mut lum); + for (i, (p, &l)) in rgb.as_chunks::<3>().0.iter().zip(&lum).enumerate() { + let lane = (i % LANES) * 4 * OUTPUT_HIST_BINS; + for (c, &v) in p.iter().chain([&l]).enumerate() { + if let Some(b) = bin(v) { + counts[lane + c * OUTPUT_HIST_BINS + b] += 1; + } + } + } + }) +} + +/// `output_histogram` of uint8 samples: each channel by value, luminance of value / 255. +pub fn output_histogram_u8(s: Samples<'_, u8>) -> Vec { + count_rows(s, 4 * OUTPUT_HIST_BINS, |rgb, counts| { + let scaled: Vec = rgb.iter().map(|&v| v as f32 / 255.0).collect(); + let mut lum = vec![0f32; rgb.len() / 3]; + luminance_chunk(&scaled, &mut lum); + for (i, (p, &l)) in rgb.as_chunks::<3>().0.iter().zip(&lum).enumerate() { + let lane = (i % LANES) * 4 * OUTPUT_HIST_BINS; + for (c, &v) in p.iter().enumerate() { + counts[lane + c * OUTPUT_HIST_BINS + v as usize] += 1; + } + let b = ((l * OUTPUT_HIST_BINS as f32) as i64).min(OUTPUT_HIST_BINS as i64 - 1); + counts[lane + 3 * OUTPUT_HIST_BINS + b as usize] += 1; + } + }) +} + +/// `color_histogram`: (32, 32, 32) joint RGB counts. +pub fn color_histogram(s: Samples) -> Vec { + let n = COLOR_HIST_BINS; + count_rows(s, n * n * n, |rgb, counts| { + for (i, p) in rgb.as_chunks::<3>().0.iter().enumerate() { + let lane = (i % LANES) * n * n * n; + let [r, g, b] = p.map(|v| quantize(v, n)); + counts[lane + (r * n + g) * n + b] += 1; + } + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Waveform { + /// (BINS, COLS) luma counts. + Luma, + /// (3, BINS, COLS) sums of R, G, B rounded to 0..1023, in each luma cell. + Color, + /// (3, BINS, COLS) counts of each channel. + Parade, +} + +/// `waveform`, `waveform_color` and `waveform_parade`: the column of a sample is its x in the +/// sampled frame, its luma cell from the float32 luminance. +pub fn waveform(s: Samples, kind: Waveform) -> Vec { + let w = (s.cols.1 - s.cols.0).div_ceil(s.step); + let cols: Vec = (0..w) + .map(|x| (x * WAVEFORM_COLS / w).min(WAVEFORM_COLS - 1)) + .collect(); + let plane = WAVEFORM_BINS * WAVEFORM_COLS; + let n = if kind == Waveform::Luma { + plane + } else { + 3 * plane + }; + count_rows(s, n, |rgb, counts| { + let mut lum = vec![0f32; rgb.len() / 3]; + if kind != Waveform::Parade { + luminance_chunk(rgb, &mut lum); + } + for (i, (p, &col)) in rgb.as_chunks::<3>().0.iter().zip(&cols).enumerate() { + let lane = (i % LANES) * n; + match kind { + Waveform::Luma => { + let b = quantize(nan_to_num(lum[i]), WAVEFORM_BINS); + counts[lane + b * WAVEFORM_COLS + col] += 1; + } + Waveform::Color => { + let b = quantize(nan_to_num(lum[i]), WAVEFORM_BINS); + for (c, &v) in p.iter().enumerate() { + let fixed = (nan_to_num(v).clamp(0.0, 1.0) * WAVEFORM_COLOR_SCALE) + .round_ties_even(); + counts[lane + c * plane + b * WAVEFORM_COLS + col] += fixed as u32; + } + } + Waveform::Parade => { + for (c, &v) in p.iter().enumerate() { + let b = quantize(nan_to_num(v), WAVEFORM_BINS); + counts[lane + c * plane + b * WAVEFORM_COLS + col] += 1; + } + } + } + } + }) +} + +/// `scan_full_scale_columns`: (3, WAVEFORM_COLS) counts of samples at or above `level`, per +/// parade column of the crop. +pub fn scan_full_scale_columns(s: Samples, level: f32) -> Vec { + let full_w = s.cols.1 - s.cols.0; + let cols: Vec = (0..full_w) + .step_by(s.step) + .map(|x| (x * WAVEFORM_COLS / full_w).min(WAVEFORM_COLS - 1)) + .collect(); + count_rows(s, 3 * WAVEFORM_COLS, |rgb, counts| { + for (i, (p, &col)) in rgb.as_chunks::<3>().0.iter().zip(&cols).enumerate() { + let lane = (i % LANES) * 3 * WAVEFORM_COLS; + for (c, &v) in p.iter().enumerate() { + counts[lane + c * WAVEFORM_COLS + col] += (v >= level) as u32; + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edges_and_extremes_land_in_the_edge_bins() { + let b = Binner { + lo: DENSITY_HIST_RANGE.0, + hi: DENSITY_HIST_RANGE.1, + bins: DENSITY_HIST_BINS, + }; + let step = (b.hi - b.lo) / b.bins as f64; + let mut vals = vec![-5.0, 5.0, 1.1, f32::NAN]; + vals.extend((0..DENSITY_HIST_BINS).map(|i| (i as f64 * step + b.lo) as f32)); + let mut out = vec![0u8; vals.len()]; + b.bin_into(&vals, &mut out); + let last = (DENSITY_HIST_BINS - 1) as u8; + assert_eq!(out[..4], [0, last, last, NO_BIN]); + for (i, &o) in out[4..].iter().enumerate() { + assert_eq!(o as usize, i, "edge {i}"); + } + } +} + +pub const SCOPE_HUE_BINS: usize = 180; +pub const SCOPE_CHROMA_BINS: usize = 64; +const SCOPE_MAX_CHROMA: f64 = 60.0; +const SCOPE_LOG_KNEE: f64 = 4.0; + +/// `vectorscope_histogram`: a*b* counts of the sampled pixels, (hue, chroma) bins in float64 +/// as `_scope_bins` computes them. The encoded values are clipped to 0..1 (NaN to 0), decoded +/// with `gamma` and converted to Lab by the working-space kernels. +pub fn vectorscope_histogram(s: Samples, space: LabSpace, gamma: f32) -> Vec { + let mut rgb = Vec::new(); + let mut buf = Vec::new(); + for y in s.row_starts() { + rgb.extend_from_slice(s.rgb_row(y, &mut buf)); + } + for v in rgb.iter_mut() { + *v = if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }; + } + let mut lin = vec![0f32; rgb.len()]; + color::oetf_decode(&rgb, &mut lin, gamma); + color::rgb_to_lab(&lin, &mut rgb, space); + let denom = libm::log1p(SCOPE_MAX_CHROMA / SCOPE_LOG_KNEE); + let bins = SCOPE_HUE_BINS * SCOPE_CHROMA_BINS; + rgb.par_chunks(3 * 4096) + .fold( + || vec![0u32; bins], + |mut acc, chunk| { + let mut idx = [0u16; 4096]; + scope_bins(chunk, &mut idx, denom); + for &i in &idx[..chunk.len() / 3] { + acc[i as usize] += 1; + } + acc + }, + ) + .reduce( + || vec![0u32; bins], + |mut a, b| { + a.iter_mut().zip(b).for_each(|(a, b)| *a += b); + a + }, + ) + .into_iter() + .map(f64::from) + .collect() +} + +/// `_scope_bins` of Lab triplets: the hue bin of atan2(b, a) and the chroma bin of the log +/// radius, flat as hue * SCOPE_CHROMA_BINS + chroma. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn scope_bins(lab: &[f32], idx: &mut [u16], denom: f64) { + use std::f64::consts::PI; + for (i, p) in idx.iter_mut().zip(lab.as_chunks::<3>().0) { + let (a, b) = (p[1] as f64, p[2] as f64); + let turn = (crate::mathf::atan2_f64(b, a) + PI) / (2.0 * PI) * SCOPE_HUE_BINS as f64; + let hue = crate::convert::trunc_bits(turn.floor()) as usize % SCOPE_HUE_BINS; + let chroma = (a * a + b * b).sqrt(); + let radius = crate::mathf::ln_f64(1.0 + chroma / SCOPE_LOG_KNEE) / denom; + let c = (radius * SCOPE_CHROMA_BINS as f64) + .floor() + .clamp(0.0, (SCOPE_CHROMA_BINS - 1) as f64); + *i = (hue * SCOPE_CHROMA_BINS + crate::convert::trunc_bits(c) as usize) as u16; + } +} diff --git a/negpy_core/src/inpaint.rs b/negpy_core/src/inpaint.rs new file mode 100644 index 000000000..f9a66206e --- /dev/null +++ b/negpy_core/src/inpaint.rs @@ -0,0 +1,296 @@ +//! `cv2.inpaint(..., cv2.INPAINT_NS)` of an 8-bit 3-channel image (OpenCV 4.13 +//! `modules/photo/src/inpaint.cpp`, Apache-2.0), and the hair fill of +//! negpy/features/retouch/logic.py that runs it tile by tile. + +use std::cmp::Reverse; +use std::collections::BinaryHeap; + +use rayon::prelude::*; + +const KNOWN: u8 = 0; +const BAND: u8 = 1; +const INSIDE: u8 = 2; + +/// OpenCV's `FastMarching_solve`, in float64. +fn solve(f: &[u8], t: &[f32], a: usize, b: usize) -> f32 { + let (a11, a22) = (t[a] as f64, t[b] as f64); + let m12 = a11.min(a22); + let sol = match (f[a] != INSIDE, f[b] != INSIDE) { + (true, true) if (a11 - a22).abs() >= 1.0 => 1.0 + m12, + (true, true) => (a11 + a22 + (2.0 - (a11 - a22) * (a11 - a22)).sqrt()) * 0.5, + (true, false) => 1.0 + a11, + (false, true) => 1.0 + a22, + (false, false) => 1.0 + m12, + }; + sol as f32 +} + +/// `cv2.inpaint(img, mask, radius, cv2.INPAINT_NS)` of an HxWx3 uint8 image, in place: the +/// fast-marching order and the float32 weights of OpenCV, ties popped in insertion order. +pub fn inpaint_ns(img: &mut [u8], mask: &[u8], (h, w): (usize, usize), radius: f64) { + let range = (radius.round_ties_even() as i64).clamp(1, 100) as isize; + let (rows, cols) = (h + 2, w + 2); + let mut f = vec![KNOWN; rows * cols]; + for y in 0..h { + for x in 0..w { + if mask[y * w + x] != 0 { + f[(y + 1) * cols + x + 1] = INSIDE; + } + } + } + let mut t = vec![1.0e6f32; rows * cols]; + let mut heap = BinaryHeap::new(); + let mut order = 0u64; + for y in 1..rows - 1 { + for x in 1..cols - 1 { + let p = y * cols + x; + let cross = [p - cols, p - 1, p + 1, p + cols]; + if f[p] == KNOWN && cross.iter().any(|&q| f[q] == INSIDE) { + heap.push(Reverse((0u32, order, p))); + order += 1; + } + } + } + for &Reverse((_, _, p)) in heap.iter() { + f[p] = BAND; + t[p] = 0.0; + } + let (ri, ci) = (rows as isize, cols as isize); + while let Some(Reverse((_, _, p))) = heap.pop() { + f[p] = KNOWN; + let (ii, jj) = ((p / cols) as isize, (p % cols) as isize); + for (i, j) in [(ii - 1, jj), (ii, jj - 1), (ii + 1, jj), (ii, jj + 1)] { + if i <= 0 || j <= 0 || i > ri - 1 || j > ci - 1 { + continue; + } + let q = (i * ci + j) as usize; + if f[q] != INSIDE { + continue; + } + let (up, dn, lf, rt) = (q - cols, q + cols, q - 1, q + 1); + let dist = solve(&f, &t, up, lf) + .min(solve(&f, &t, dn, lf)) + .min(solve(&f, &t, up, rt).min(solve(&f, &t, dn, rt))); + t[q] = dist; + let mut ia = [0f32; 3]; + let mut s = [1.0e-20f32; 3]; + for k in i - range..=i + range { + if k <= 0 || k >= ri - 1 { + continue; + } + let km = k - 1 + (k == 1) as isize; + let kp = k - 1 - (k == ri - 2) as isize; + for l in j - range..=j + range { + if l <= 0 || l >= ci - 1 { + continue; + } + let kl = (k * ci + l) as usize; + if f[kl] == INSIDE || (l - j) * (l - j) + (k - i) * (k - i) > range * range { + continue; + } + let lm = l - 1 + (l == 1) as isize; + let lp = l - 1 - (l == ci - 2) as isize; + let (ry, rx) = ((k - i) as f32, (l - j) as f32); + let rl = rx * rx + ry * ry; + let dst = 1.0 / (rl * rl + 1.0); + let (dn_k, up_k) = (f[kl + cols] != INSIDE, f[kl - cols] != INSIDE); + let (rt_l, lf_l) = (f[kl + 1] != INSIDE, f[kl - 1] != INSIDE); + // Clamped: on a one-pixel-wide image OpenCV reads out of bounds here. + let at = |y: isize, x: isize| { + 3 * (y.clamp(0, h as isize - 1) as usize * w + + x.clamp(0, w as isize - 1) as usize) + }; + let (below, mid, above) = (at(kp + 1, lm), at(kp, lm), at(km - 1, lm)); + let (right, centre, left) = (at(km, lp + 1), at(km, lm), at(km, lm - 1)); + let here = at(k - 1, l - 1); + for c in 0..3 { + let v = |o: usize| img[o + c] as i32; + let gx = match (dn_k, up_k) { + (true, true) => { + ((v(below) - v(mid)).abs() + (v(mid) - v(above)).abs()) as f32 + } + (true, false) => (v(below) - v(mid)).abs() as f32 * 2.0, + (false, true) => (v(mid) - v(above)).abs() as f32 * 2.0, + (false, false) => 0.0, + }; + let gy = match (rt_l, lf_l) { + (true, true) => { + ((v(right) - v(centre)).abs() + (v(centre) - v(left)).abs()) as f32 + } + (true, false) => (v(right) - v(centre)).abs() as f32 * 2.0, + (false, true) => (v(centre) - v(left)).abs() as f32 * 2.0, + (false, false) => 0.0, + }; + let gx = -gx; + let dot = rx * gx + ry * gy; + let dir = if (dot as f64).abs() <= 0.01 { + 0.000001f32 + } else { + (dot / (rl * (gx * gx + gy * gy)).sqrt()).abs() + }; + let wgt = dst * dir; + ia[c] += wgt * v(here) as f32; + s[c] += wgt; + } + } + } + let o = ((i - 1) as usize * w + (j - 1) as usize) * 3; + for c in 0..3 { + img[o + c] = (ia[c] as f64 / s[c] as f64) + .round_ties_even() + .clamp(0.0, 255.0) as u8; + } + f[q] = BAND; + heap.push(Reverse((dist.to_bits(), order, q))); + order += 1; + } + } +} + +/// The constants of `apply_hair_inpaint`. +pub struct HairFill { + pub radius: f64, + pub dilate_px: usize, + pub pad: usize, + pub tile: usize, + pub halo: usize, + pub sample_px: usize, + pub gamma: f64, +} + +/// One tile of one component: the frame rectangle the inpaint sees and the core it writes. +struct Job { + label: i32, + lo: f32, + inv_span: f32, + lut: [f32; 256], + around: [usize; 4], + core: [usize; 4], +} + +/// `apply_hair_inpaint` of an HxWx3 image and its dilated hair mask, up to the clean grain: +/// each 8-connected component is filled in its padded box, encoded against the box's clean +/// range, in tiles with a halo, and feathered over the dilate band. Writes only the filled +/// pixels of `out`; tiles run in parallel, as their written pixels never overlap. +pub fn hair_fill(src: &[f32], (h, w): (usize, usize), m: &[u8], c: &HairFill, out: &mut [f32]) { + let comps = crate::components::connected_components(m, h, w, 8); + let labels = &comps.labels; + let jobs: Vec = (1..comps.count) + .into_par_iter() + .flat_map_iter(|i| component_jobs(src, (h, w), m, labels, i as i32, comps.stats[i], c)) + .collect(); + let writes: Vec> = jobs + .par_iter() + .map(|j| fill_tile(src, (h, w), m, labels, j, c)) + .collect(); + for (p, v) in writes.into_iter().flatten() { + out[3 * p..3 * p + 3].copy_from_slice(&v); + } +} + +fn component_jobs( + src: &[f32], + (h, w): (usize, usize), + m: &[u8], + labels: &[i32], + label: i32, + [bx, by, bw, bh, _]: [i32; 5], + c: &HairFill, +) -> Vec { + let (bx, by, bw, bh) = (bx as usize, by as usize, bw as usize, bh as usize); + let (x0, y0) = (bx.saturating_sub(c.pad), by.saturating_sub(c.pad)); + let (x1, y1) = (w.min(bx + bw + c.pad), h.min(by + bh + c.pad)); + let (ch, cw) = (y1 - y0, x1 - x0); + // A hair can join enough specks to span the frame: the range comes off a strided sample. + let step = ((ch * cw / c.sample_px).max(1)).isqrt().max(1); + let mut ctx = Vec::new(); + for y in (y0..y1).step_by(step) { + for x in (x0..x1).step_by(step) { + if m[y * w + x] == 0 { + ctx.extend_from_slice(&src[3 * (y * w + x)..3 * (y * w + x) + 3]); + } + } + } + let (lo, hi) = match crate::stats::percentiles(&ctx, &[0.5, 99.5]) { + Some(p) => (p[0] as f64, p[1] as f64), + None => (0.0, 1.0), + }; + let span = (hi - lo).max(1e-4); + let lut = std::array::from_fn(|v| { + let e = crate::mathf::pow_f64((v as f32 / 255.0) as f64, c.gamma as f32 as f64) as f32; + e * span as f32 + lo as f32 + }); + let (nty, ntx) = (ch.div_ceil(c.tile), cw.div_ceil(c.tile)); + let mut used = vec![false; nty * ntx]; + for y in by..by + bh { + for x in bx..bx + bw { + if labels[y * w + x] == label { + used[(y - y0) / c.tile * ntx + (x - x0) / c.tile] = true; + } + } + } + let mut jobs = Vec::new(); + for (t, _) in used.iter().enumerate().filter(|(_, u)| **u) { + let (ty0, tx0) = (y0 + t / ntx * c.tile, x0 + t % ntx * c.tile); + let (ty1, tx1) = (y1.min(ty0 + c.tile), x1.min(tx0 + c.tile)); + jobs.push(Job { + label, + lo: lo as f32, + inv_span: (1.0 / span) as f32, + lut, + around: [ + y0.max(ty0.saturating_sub(c.halo)), + y1.min(ty1 + c.halo), + x0.max(tx0.saturating_sub(c.halo)), + x1.min(tx1 + c.halo), + ], + core: [ty0, ty1, tx0, tx1], + }); + } + jobs +} + +fn fill_tile( + src: &[f32], + (_, w): (usize, usize), + m: &[u8], + labels: &[i32], + j: &Job, + c: &HairFill, +) -> Vec<(usize, [f32; 3])> { + let [ay0, ay1, ax0, ax1] = j.around; + let (th, tw) = (ay1 - ay0, ax1 - ax0); + let mut tile = Vec::with_capacity(3 * th * tw); + let mut sub = Vec::with_capacity(th * tw); + let mut mine = Vec::with_capacity(th * tw); + for y in ay0..ay1 { + tile.extend_from_slice(&src[3 * (y * w + ax0)..3 * (y * w + ax1)]); + sub.extend_from_slice(&m[y * w + ax0..y * w + ax1]); + mine.extend( + labels[y * w + ax0..y * w + ax1] + .iter() + .map(|&l| (l == j.label) as u8), + ); + } + let mut enc = vec![0u8; 3 * th * tw]; + crate::retouch::hair_encode(&tile, 3, j.lo, j.inv_span, (1.0 / c.gamma) as f32, &mut enc); + inpaint_ns(&mut enc, &sub, (th, tw), c.radius); + let d = crate::distance::distance_transform(&mine, th, tw, crate::distance::Metric::Chessboard); + let feather = (c.dilate_px + 1) as f32; + let [ty0, ty1, tx0, tx1] = j.core; + let mut writes = Vec::new(); + for y in ty0..ty1 { + for x in tx0..tx1 { + let p = (y - ay0) * tw + x - ax0; + if mine[p] == 0 { + continue; + } + let a = (d[p] / feather).min(1.0); + let v = std::array::from_fn(|k| { + tile[3 * p + k] * (1.0 - a) + j.lut[enc[3 * p + k] as usize] * a + }); + writes.push((y * w + x, v)); + } + } + writes +} diff --git a/negpy_core/src/jpeg.rs b/negpy_core/src/jpeg.rs new file mode 100644 index 000000000..697d7d8a0 --- /dev/null +++ b/negpy_core/src/jpeg.rs @@ -0,0 +1,572 @@ +//! Baseline 4:4:4 JPEG of uint8 RGB or gray, as Pillow writes it through libjpeg-turbo with +//! `subsampling=0, optimize=True`: the quality-scaled Annex K tables, fixed-point YCbCr, the +//! islow DCT and optimal Huffman tables. Restart markers split the scan into strips that encode +//! in parallel; they change the bytes but not one decoded pixel. Without them the bytes are +//! Pillow's. + +// The tables index several arrays by symbol or by code length in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +const NATURAL: [usize; 64] = [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, + 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, + 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +]; + +const STD_LUMA: [u32; 64] = [ + 16, 11, 10, 16, 24, 40, 51, 61, 12, 12, 14, 19, 26, 58, 60, 55, 14, 13, 16, 24, 40, 57, 69, 56, + 14, 17, 22, 29, 51, 87, 80, 62, 18, 22, 37, 56, 68, 109, 103, 77, 24, 35, 55, 64, 81, 104, 113, + 92, 49, 64, 78, 87, 103, 121, 120, 101, 72, 92, 95, 98, 112, 100, 103, 99, +]; + +const STD_CHROMA: [u32; 64] = [ + 17, 18, 24, 47, 99, 99, 99, 99, 18, 21, 26, 66, 99, 99, 99, 99, 24, 26, 56, 99, 99, 99, 99, 99, + 47, 66, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, +]; + +pub struct Options<'a> { + pub quality: i32, + pub icc: Option<&'a [u8]>, + pub dpi: Option<(u16, u16)>, + /// MCU rows per restart interval; 0 for one interval, None to pick one for the cores. + pub restart_rows: Option, +} + +/// `jpeg_set_quality(q, force_baseline=TRUE)`'s table, in natural order. +fn quant_table(basic: &[u32; 64], quality: i32) -> [u32; 64] { + let q = quality.clamp(1, 100); + let scale = if q < 50 { 5000 / q } else { 200 - 2 * q } as i64; + basic.map(|b| ((b as i64 * scale + 50) / 100).clamp(1, 255) as u32) +} + +/// jccolor.c's fixed-point YCbCr weights. Its tables are these products, so a row computes the +/// same sums in vector lanes. +const fn fix(x: f64) -> i32 { + (x * 65536.0 + 0.5) as i32 +} +const HALF: i32 = 1 << 15; +const CBCR: i32 = (128 << 16) + HALF - 1; +const Y_W: [i32; 3] = [fix(0.29900), fix(0.58700), fix(0.11400)]; +const CB_W: [i32; 3] = [-fix(0.16874), -fix(0.33126), fix(0.5)]; +const CR_W: [i32; 3] = [fix(0.5), -fix(0.41869), -fix(0.08131)]; + +/// One RGB row to Y, Cb and Cr rows of the padded width, the last pixel repeated into the pad. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2", + "aarch64+neon" +))] +fn ycc_row(src: &[u8], [y, cb, cr]: [&mut [u8]; 3]) { + let w = src.len() / 3; + let px = src.as_chunks::<3>().0; + let sum = |wt: [i32; 3], p: &[u8; 3], off: i32| { + ((wt[0] * p[0] as i32 + wt[1] * p[1] as i32 + wt[2] * p[2] as i32 + off) >> 16) as u8 + }; + for (((y, cb), cr), p) in y.iter_mut().zip(cb.iter_mut()).zip(cr.iter_mut()).zip(px) { + *y = sum(Y_W, p, HALF); + *cb = sum(CB_W, p, CBCR); + *cr = sum(CR_W, p, CBCR); + } + for plane in [y, cb, cr] { + let last = plane[w - 1]; + plane[w..].fill(last); + } +} + +/// jfdctint.c, `jpeg_fdct_islow`, on a block given transposed (`t[col][row]`), so each pass +/// runs the eight rows or columns side by side; the result is in natural order, scaled by 8. +#[inline(always)] +fn fdct_islow(t: &mut [[i32; 8]; 8]) { + for j in 0..8 { + fdct_pass::(t, j); + } + let mut u = [[0i32; 8]; 8]; + for (r, row) in u.iter_mut().enumerate() { + for (c, v) in row.iter_mut().enumerate() { + *v = t[c][r]; + } + } + for j in 0..8 { + fdct_pass::(&mut u, j); + } + *t = u; +} + +/// One lane of the islow DCT: the row pass (`FIRST`) or the column pass. +#[inline(always)] +fn fdct_pass(d: &mut [[i32; 8]; 8], j: usize) { + const CB: i32 = 13; + const P1: i32 = 2; + let descale = |x: i32, n: i32| (x + (1 << (n - 1))) >> n; + let odd = if FIRST { CB - P1 } else { CB + P1 }; + let (tmp0, tmp7) = (d[0][j] + d[7][j], d[0][j] - d[7][j]); + let (tmp1, tmp6) = (d[1][j] + d[6][j], d[1][j] - d[6][j]); + let (tmp2, tmp5) = (d[2][j] + d[5][j], d[2][j] - d[5][j]); + let (tmp3, tmp4) = (d[3][j] + d[4][j], d[3][j] - d[4][j]); + let (tmp10, tmp13) = (tmp0 + tmp3, tmp0 - tmp3); + let (tmp11, tmp12) = (tmp1 + tmp2, tmp1 - tmp2); + if FIRST { + d[0][j] = (tmp10 + tmp11) << P1; + d[4][j] = (tmp10 - tmp11) << P1; + } else { + d[0][j] = descale(tmp10 + tmp11, P1); + d[4][j] = descale(tmp10 - tmp11, P1); + } + let z1 = (tmp12 + tmp13) * 4433; + d[2][j] = descale(z1 + tmp13 * 6270, odd); + d[6][j] = descale(z1 + tmp12 * -15137, odd); + let (z1, z2, z3, z4) = (tmp4 + tmp7, tmp5 + tmp6, tmp4 + tmp6, tmp5 + tmp7); + let z5 = (z3 + z4) * 9633; + let (tmp4, tmp5, tmp6, tmp7) = (tmp4 * 2446, tmp5 * 16819, tmp6 * 25172, tmp7 * 12299); + let (z1, z2) = (z1 * -7373, z2 * -20995); + let (z3, z4) = (z3 * -16069 + z5, z4 * -3196 + z5); + d[7][j] = descale(tmp4 + z1 + z3, odd); + d[5][j] = descale(tmp5 + z2 + z4, odd); + d[3][j] = descale(tmp6 + z2 + z3, odd); + d[1][j] = descale(tmp7 + z1 + z4, odd); +} + +/// libjpeg's rounded quantization, `(|v| + d / 2) / d` with the sign put back, for the islow +/// divisor `d = 8 q`. The division is a multiply by ceil(2^32 / d): exact while |v| + d / 2 +/// stays under 2^16 and d under 2^12, which every 8-bit DCT output and baseline table keeps. +struct Divisors { + half: [u32; 64], + recip: [u64; 64], +} + +impl Divisors { + fn new(q: &[u32; 64]) -> Self { + Divisors { + half: q.map(|q| (q << 3) >> 1), + recip: q.map(|q| (1u64 << 32).div_ceil((q << 3) as u64)), + } + } + + #[inline(always)] + fn quantize(&self, block: &mut [[i32; 8]; 8]) { + for ((v, &h), &r) in block + .as_flattened_mut() + .iter_mut() + .zip(&self.half) + .zip(&self.recip) + { + let m = (((v.unsigned_abs() + h) as u64 * r) >> 32) as i32; + *v = if *v < 0 { -m } else { m }; + } + } +} + +/// The quantized blocks of one MCU row, in scan order (block column, then component), from +/// the component planes of its eight padded rows. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2", + "aarch64+neon" +))] +fn dct_row(planes: &[Vec], pw: usize, divisors: &[Divisors; 2], out: &mut [[[i32; 8]; 8]]) { + let cn = planes.len(); + for (i, block) in out.iter_mut().enumerate() { + let (bx, ci) = (i / cn, i % cn); + let plane = &planes[ci]; + for (r, row) in plane.chunks_exact(pw).enumerate() { + for (c, &v) in row[bx * 8..bx * 8 + 8].iter().enumerate() { + block[c][r] = v as i32 - 128; + } + } + fdct_islow(block); + divisors[ci.min(1)].quantize(block); + } +} + +/// The quantized coefficients of every block of MCU rows `rows`, handed to `f` in scan order +/// (component index, block in natural order). Rows and columns past the image repeat its edge. +fn for_each_block( + px: &[u8], + (h, w, cn): (usize, usize, usize), + rows: std::ops::Range, + divisors: &[Divisors; 2], + mut f: impl FnMut(usize, &[i32]), +) { + let pw = w.div_ceil(8) * 8; + let mut planes = vec![vec![0u8; 8 * pw]; cn]; + let mut blocks = vec![[[0i32; 8]; 8]; pw / 8 * cn]; + for mcu_row in rows { + for r in 0..8 { + let y = (mcu_row * 8 + r).min(h - 1); + let src = &px[y * w * cn..(y + 1) * w * cn]; + if cn == 3 { + let [a, b, c] = &mut planes[..] else { + unreachable!() + }; + let span = r * pw..(r + 1) * pw; + ycc_row( + src, + [&mut a[span.clone()], &mut b[span.clone()], &mut c[span]], + ); + } else { + let dst = &mut planes[0][r * pw..(r + 1) * pw]; + dst[..w].copy_from_slice(src); + let last = src[w - 1]; + dst[w..].fill(last); + } + } + dct_row(&planes, pw, divisors, &mut blocks); + for (i, b) in blocks.iter().enumerate() { + f(i % cn, b.as_flattened()); + } + } +} + +fn nbits(v: i32) -> u32 { + 32 - v.unsigned_abs().leading_zeros() +} + +/// Symbol counts per table: [dc0, ac0, dc1, ac1]. +type Counts = [[i64; 257]; 4]; + +fn count_block(counts: &mut Counts, t: usize, block: &[i32], last_dc: &mut i32) { + counts[2 * t][nbits(block[0] - *last_dc) as usize] += 1; + *last_dc = block[0]; + let mut run = 0; + for &n in &NATURAL[1..] { + let v = block[n]; + if v == 0 { + run += 1; + continue; + } + while run > 15 { + counts[2 * t + 1][0xF0] += 1; + run -= 16; + } + counts[2 * t + 1][(run << 4) + nbits(v) as usize] += 1; + run = 0; + } + if run > 0 { + counts[2 * t + 1][0] += 1; + } +} + +/// jchuff.c's `jpeg_gen_optimal_table`: (bits[1..=16], huffval). +fn optimal_table(freq: &[i64; 257]) -> ([u8; 17], Vec) { + const MAX_CLEN: usize = 32; + let mut freq = *freq; + let mut bits = [0u8; MAX_CLEN + 1]; + let mut codesize = [0usize; 257]; + let mut others = [-1i64; 257]; + freq[256] = 1; + loop { + let mut c1 = -1i64; + let mut v = 1_000_000_000i64; + for (i, &f) in freq.iter().enumerate() { + if f != 0 && f <= v { + v = f; + c1 = i as i64; + } + } + let mut c2 = -1i64; + v = 1_000_000_000; + for (i, &f) in freq.iter().enumerate() { + if f != 0 && f <= v && i as i64 != c1 { + v = f; + c2 = i as i64; + } + } + if c2 < 0 { + break; + } + let (mut a, mut b) = (c1 as usize, c2 as usize); + freq[a] += freq[b]; + freq[b] = 0; + codesize[a] += 1; + while others[a] >= 0 { + a = others[a] as usize; + codesize[a] += 1; + } + others[a] = b as i64; + codesize[b] += 1; + while others[b] >= 0 { + b = others[b] as usize; + codesize[b] += 1; + } + } + for &s in &codesize { + if s != 0 { + bits[s] += 1; + } + } + let mut i = MAX_CLEN; + while i > 16 { + while bits[i] > 0 { + let mut j = i - 2; + while bits[j] == 0 { + j -= 1; + } + bits[i] -= 2; + bits[i - 1] += 1; + bits[j + 1] += 2; + bits[j] -= 1; + } + i -= 1; + } + while bits[i] == 0 { + i -= 1; + } + bits[i] -= 1; + let mut huffval = Vec::new(); + for len in 1..=MAX_CLEN { + for (sym, &s) in codesize.iter().enumerate().take(256) { + if s == len { + huffval.push(sym as u8); + } + } + } + let mut out = [0u8; 17]; + out.copy_from_slice(&bits[..17]); + (out, huffval) +} + +/// Canonical codes (code, length) by symbol, as `jpeg_make_c_derived_tbl` assigns them. +fn derived(bits: &[u8; 17], huffval: &[u8]) -> [(u32, u32); 256] { + let mut table = [(0u32, 0u32); 256]; + let (mut code, mut k) = (0u32, 0usize); + for len in 1..=16 { + for _ in 0..bits[len] { + table[huffval[k] as usize] = (code, len as u32); + code += 1; + k += 1; + } + code <<= 1; + } + table +} + +struct BitWriter { + out: Vec, + acc: u64, + n: u32, +} + +impl BitWriter { + fn put(&mut self, code: u32, size: u32) { + self.acc = (self.acc << size) | code as u64; + self.n += size; + while self.n >= 8 { + self.n -= 8; + let b = (self.acc >> self.n) as u8; + self.out.push(b); + if b == 0xFF { + self.out.push(0); + } + } + self.acc &= (1u64 << self.n) - 1; + } + + /// Pads the last byte with ones, as libjpeg's `flush_bits` does. + fn flush(&mut self) { + if self.n > 0 { + self.put((1 << (8 - self.n)) - 1, 8 - self.n); + } + } +} + +type Codes = [[(u32, u32); 256]; 4]; + +fn encode_block(bw: &mut BitWriter, codes: &Codes, t: usize, block: &[i32], last_dc: &mut i32) { + let (dc, ac) = (&codes[2 * t], &codes[2 * t + 1]); + let emit = |bw: &mut BitWriter, v: i32, n: u32| { + if n > 0 { + let bits = if v < 0 { v - 1 } else { v }; + bw.put(bits as u32 & ((1 << n) - 1), n); + } + }; + let diff = block[0] - *last_dc; + *last_dc = block[0]; + let n = nbits(diff); + bw.put(dc[n as usize].0, dc[n as usize].1); + emit(bw, diff, n); + let mut run = 0; + for &k in &NATURAL[1..] { + let v = block[k]; + if v == 0 { + run += 1; + continue; + } + while run > 15 { + bw.put(ac[0xF0].0, ac[0xF0].1); + run -= 16; + } + let n = nbits(v); + let s = (run << 4) + n as usize; + bw.put(ac[s].0, ac[s].1); + emit(bw, v, n); + run = 0; + } + if run > 0 { + bw.put(ac[0].0, ac[0].1); + } +} + +fn marker(out: &mut Vec, m: u8, body: &[u8]) { + out.extend_from_slice(&[0xFF, m]); + out.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(body); +} + +/// The JPEG of an HxW (`cn` 1) or HxWx3 uint8 image. None for an empty image or one too wide +/// for a restart interval. +pub fn encode(px: &[u8], (h, w, cn): (usize, usize, usize), o: &Options) -> Option> { + if h == 0 || w == 0 || h > 65535 || w > 65535 || !(cn == 1 || cn == 3) || px.len() != h * w * cn + { + return None; + } + let quant = [ + quant_table(&STD_LUMA, o.quality), + quant_table(&STD_CHROMA, o.quality), + ]; + let divisors = [Divisors::new(&quant[0]), Divisors::new(&quant[1])]; + let (mcu_rows, mcu_cols) = (h.div_ceil(8), w.div_ceil(8)); + let rows = match o.restart_rows { + Some(0) => mcu_rows, + Some(r) => r.max(1), + None => mcu_rows.div_ceil(4 * rayon::current_num_threads()).max(1), + }; + // The restart interval counts MCUs in 16 bits. + let rows = if rows < mcu_rows { + rows.min(65535 / mcu_cols).max(1) + } else { + rows + }; + let restart = rows < mcu_rows; + let strips: Vec> = (0..mcu_rows) + .step_by(rows) + .map(|s| s..(s + rows).min(mcu_rows)) + .collect(); + let table = |ci: usize| ci.min(1); + + let counts = strips + .par_iter() + .map(|r| { + let mut counts: Counts = [[0; 257]; 4]; + let mut last = [0i32; 3]; + for_each_block(px, (h, w, cn), r.clone(), &divisors, |ci, b| { + count_block(&mut counts, table(ci), b, &mut last[ci]); + }); + counts + }) + .reduce( + || [[0; 257]; 4], + |mut a, b| { + for (x, y) in a.iter_mut().zip(&b) { + x.iter_mut().zip(y).for_each(|(p, q)| *p += q); + } + a + }, + ); + let tables: Vec<([u8; 17], Vec)> = (0..if cn == 3 { 4 } else { 2 }) + .map(|t| optimal_table(&counts[t])) + .collect(); + let mut codes: Codes = [[(0, 0); 256]; 4]; + for (c, (bits, vals)) in codes.iter_mut().zip(&tables) { + *c = derived(bits, vals); + } + + let scans: Vec> = strips + .par_iter() + .map(|r| { + let mut bw = BitWriter { + out: Vec::with_capacity(r.len() * mcu_cols * cn * 32), + acc: 0, + n: 0, + }; + let mut last = [0i32; 3]; + for_each_block(px, (h, w, cn), r.clone(), &divisors, |ci, b| { + encode_block(&mut bw, &codes, table(ci), b, &mut last[ci]); + }); + bw.flush(); + bw.out + }) + .collect(); + + let mut out = vec![0xFF, 0xD8]; + let (unit, dx, dy) = o.dpi.map_or((0u8, 1u16, 1u16), |(x, y)| (1, x, y)); + let mut jfif = b"JFIF\0\x01\x01".to_vec(); + jfif.push(unit); + jfif.extend_from_slice(&dx.to_be_bytes()); + jfif.extend_from_slice(&dy.to_be_bytes()); + jfif.extend_from_slice(&[0, 0]); + marker(&mut out, 0xE0, &jfif); + if let Some(icc) = o.icc.filter(|i| !i.is_empty()) { + let chunks: Vec<&[u8]> = icc.chunks(65533 - 14).collect(); + for (i, chunk) in chunks.iter().enumerate() { + let mut body = b"ICC_PROFILE\0".to_vec(); + body.extend_from_slice(&[i as u8 + 1, chunks.len() as u8]); + body.extend_from_slice(chunk); + marker(&mut out, 0xE2, &body); + } + } + for (t, q) in quant.iter().enumerate().take(if cn == 3 { 2 } else { 1 }) { + let mut body = vec![t as u8]; + body.extend(NATURAL.iter().map(|&n| q[n] as u8)); + marker(&mut out, 0xDB, &body); + } + let mut sof = vec![8]; + sof.extend_from_slice(&(h as u16).to_be_bytes()); + sof.extend_from_slice(&(w as u16).to_be_bytes()); + sof.push(cn as u8); + for ci in 0..cn { + sof.extend_from_slice(&[ci as u8 + 1, 0x11, table(ci) as u8]); + } + marker(&mut out, 0xC0, &sof); + for (t, (bits, vals)) in tables.iter().enumerate() { + let mut body = vec![(((t % 2) << 4) | (t / 2)) as u8]; + body.extend_from_slice(&bits[1..]); + body.extend_from_slice(vals); + marker(&mut out, 0xC4, &body); + } + if restart { + marker(&mut out, 0xDD, &((rows * mcu_cols) as u16).to_be_bytes()); + } + let mut sos = vec![cn as u8]; + for ci in 0..cn { + let t = table(ci) as u8; + sos.extend_from_slice(&[ci as u8 + 1, t << 4 | t]); + } + sos.extend_from_slice(&[0, 63, 0]); + marker(&mut out, 0xDA, &sos); + let total: usize = scans.iter().map(Vec::len).sum(); + out.reserve(total + 2 * scans.len() + 2); + for (i, s) in scans.iter().enumerate() { + if i > 0 { + out.extend_from_slice(&[0xFF, 0xD0 + ((i - 1) % 8) as u8]); + } + out.extend_from_slice(s); + } + out.extend_from_slice(&[0xFF, 0xD9]); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quality_90_tables_match_libjpeg() { + let q = quant_table(&STD_LUMA, 90); + assert_eq!(&q[..8], &[3, 2, 2, 3, 5, 8, 10, 12]); + assert_eq!(quant_table(&STD_CHROMA, 90)[0], 3); + assert_eq!(quant_table(&STD_LUMA, 1)[0], 255); + } + + #[test] + fn a_flat_block_has_only_dc() { + let mut b = [[10i32; 8]; 8]; + fdct_islow(&mut b); + let b = b.as_flattened(); + assert_eq!(b[0], 10 * 64); + assert!(b[1..].iter().all(|&v| v == 0)); + } +} diff --git a/negpy_core/src/lab.rs b/negpy_core/src/lab.rs new file mode 100644 index 000000000..a1722a881 --- /dev/null +++ b/negpy_core/src/lab.rs @@ -0,0 +1,488 @@ +//! The Lab-stage kernels of negpy/features/lab/logic.py, in the float32 order NumPy evaluates +//! them, around the bit-exact OpenCV filters of `filter` and `morph`. + +use rayon::prelude::*; + +use crate::color::{self, LabSpace}; +use crate::filter::{self, Border}; + +// Sharpen constants; shaders/lab.wgsl mirrors them. The gate separates grain from detail in +// L*, sized against |L - blur| at a 1 px radius, which tops out near 1.0. +const GATE_LO: f32 = 0.25; +const GATE_HI: f64 = 0.33; +// L*-domain USM exaggerates light halos, so overshoot above the local max is clamped tighter +// than undershoot below the local min. +const OVERSHOOT_LIGHT: f32 = 1.0; +const OVERSHOOT_DARK: f32 = 2.0; +const MASK_T_HI: f64 = 10.0; +const SHADOW_FLOOR: f64 = 1.0 / 3.0; +const SHADOW_L_HI: f32 = 35.0; + +/// `_smoothstep(e0, e1, x)`: the edges are Python floats, so e1 - e0 is a float64 difference +/// rounded to float32 once. +#[inline(always)] +fn smoothstep(e0: f64, e1: f64, x: f32) -> f32 { + let t = clip((x - e0 as f32) / (e1 - e0) as f32, 0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// `sharpen_shadow_gain(l)`. +#[inline(always)] +fn shadow_gain(l: f32) -> f32 { + let f = SHADOW_FLOOR as f32; + f + (1.0 - f) * smoothstep(0.0, SHADOW_L_HI as f64, l) +} + +/// `_edge_mask(l, masking)`: central differences on the edge-padded plane, `np.hypot` (glibc's +/// float64 formula), a 3x3 replicate-border box mean, then the smoothstep over 0.5t..t. +fn edge_mask(l: &[f32], (h, w): (usize, usize), masking: f64) -> Option> { + let mut hyp = vec![0f32; h * w]; + hyp.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + let (up, down) = (y.saturating_sub(1), (y + 1).min(h - 1)); + let row = &l[y * w..][..w]; + for (x, o) in out.iter_mut().enumerate() { + let gx = (row[(x + 1).min(w - 1)] - row[x.saturating_sub(1)]) * 0.5; + let gy = (l[down * w + x] - l[up * w + x]) * 0.5; + let (gx, gy) = (gx as f64, gy as f64); + *o = (gx * gx + gy * gy).sqrt() as f32; + } + }); + let mut grad = vec![0f32; h * w]; + filter::box_filter(&hyp, &mut grad, (h, w, 1), (3, 3), true, Border::Replicate)?; + let t = MASK_T_HI * masking; + grad.par_iter_mut() + .for_each(|g| *g = smoothstep(0.5 * t, t, *g)); + Some(grad) +} + +/// `_usm_sharpening_block` of an HxWx3 linear image: the L* channel's soft-gated unsharp mask +/// with `k` as the blur taps, clamped to the local 3x3 range, then clipped to 0..1 in RGB. +/// `gain` is `np.float32(amount * 2.5)`. None where a filter does not cover the shape. +pub fn usm_sharpen( + src: &[f32], + (h, w): (usize, usize), + (gain, masking): (f32, f64), + k: &[f32], + (to_lab, to_rgb): (LabSpace, LabSpace), + dst: &mut [f32], +) -> Option<()> { + let n = h * w; + let mut lab = vec![0f32; n * 3]; + color::rgb_to_lab(src, &mut lab, to_lab); + let l: Vec = lab.par_chunks(3).map(|p| p[0]).collect(); + let mut blur = vec![0f32; n]; + filter::sep_filter2d(&l, &mut blur, (h, w, 1), (k, k), Border::Reflect101)?; + let edge = if masking > 0.0 { + Some(edge_mask(&l, (h, w), masking)?) + } else { + None + }; + // The 3x3 erode and dilate (replicated edges) run inline, a row at a time. + lab.par_chunks_mut(w * 3).enumerate().for_each(|(y, out)| { + let rows = [y.saturating_sub(1), y, (y + 1).min(h - 1)].map(|r| &l[r * w..][..w]); + let col = |x: usize, f: fn(f32, f32) -> f32| f(f(rows[0][x], rows[1][x]), rows[2][x]); + let (mut lo_col, mut hi_col) = (vec![0f32; w], vec![0f32; w]); + for x in 0..w { + lo_col[x] = col(x, f32_lo); + hi_col[x] = col(x, f32_hi); + } + for (x, p) in out.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let (xl, xr) = (x.saturating_sub(1), (x + 1).min(w - 1)); + let lo = f32_lo(f32_lo(lo_col[xl], lo_col[x]), lo_col[xr]); + let hi = f32_hi(f32_hi(hi_col[xl], hi_col[x]), hi_col[xr]); + let i = y * w + x; + let (l0, d) = (l[i], l[i] - blur[i]); + let mut g = gain * smoothstep(GATE_LO as f64, GATE_HI, d.abs()) * shadow_gain(l0); + if let Some(e) = &edge { + g *= e[i]; + } + let v = clip(l0 + d * g, lo - OVERSHOOT_DARK, hi + OVERSHOOT_LIGHT); + p[0] = clip(v, 0.0, 100.0); + } + }); + color::lab_to_rgb(&lab, dst, to_rgb); + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); + Some(()) +} + +/// `cv2.erode`'s float32 minimum. +fn f32_lo(a: f32, b: f32) -> f32 { + if b < a { b } else { a } +} + +/// `cv2.dilate`'s float32 maximum. +fn f32_hi(a: f32, b: f32) -> f32 { + if b > a { b } else { a } +} + +/// `np.clip(v, lo, hi)`, NumPy's `min(max(v, lo), hi)` with `max(a, b) = a > b ? a : b` +/// unless a is NaN: a NaN in any argument comes through, and clip(-0.0, 0.0, ..) is +0.0. +#[inline(always)] +fn clip(v: f32, lo: f32, hi: f32) -> f32 { + let m = if v.is_nan() || v > lo { v } else { lo }; + if m.is_nan() || m < hi { m } else { hi } +} + +// Chroma Denoise edge test: the low band's gradient times sigma, in CIELAB a*/b* units. Chroma +// above the cutoff is kept from LO, fully from HI. lab.wgsl mirrors them as literals. +const CHROMA_EDGE_LO: f64 = 1.0; +const CHROMA_EDGE_HI: f64 = 2.5; + +/// `apply_chroma_denoise` once sigma is known: the a*/b* band above the Gaussian low pass `k` +/// (reflect-101 edges) is kept only where the low band has a color edge (`chroma_edge_keep`). +pub fn chroma_denoise( + src: &[f32], + (h, w): (usize, usize), + sigma: f64, + k: &[f32], + (to_lab, to_rgb): (LabSpace, LabSpace), + dst: &mut [f32], +) -> Option<()> { + let n = h * w; + let mut lab = vec![0f32; n * 3]; + color::rgb_to_lab(src, &mut lab, to_lab); + let ab: Vec = lab.par_chunks(3).flat_map_iter(|p| [p[1], p[2]]).collect(); + let mut low = vec![0f32; n * 2]; + filter::sep_filter2d(&ab, &mut low, (h, w, 2), (k, k), Border::Reflect101)?; + let span = CHROMA_EDGE_HI - CHROMA_EDGE_LO; + let (scale, shift) = ((sigma / span) as f32, (CHROMA_EDGE_LO / span) as f32); + lab.par_chunks_mut(w * 3).enumerate().for_each(|(y, out)| { + let (up, down) = (y.saturating_sub(1), (y + 1).min(h - 1)); + let at = |r: usize, x: usize| &low[(r * w + x) * 2..][..2]; + for (x, p) in out.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let (left, right) = (at(y, x.saturating_sub(1)), at(y, (x + 1).min(w - 1))); + let (top, bottom) = (at(up, x), at(down, x)); + // cv2.filter2D drops the zero tap: -0.5 a + 0.5 c rounds once, as (c - a) * 0.5 does. + let sq: [f32; 2] = std::array::from_fn(|c| { + let gx = (right[c] - left[c]) * 0.5; + let gy = (bottom[c] - top[c]) * 0.5; + let s = gx * gx; + s + gy * gy + }); + let mut g = (sq[0] + sq[1]).sqrt(); + g *= scale; + g -= shift; + let t = clip(g, 0.0, 1.0); + let keep = t * t * (3.0 - 2.0 * t); + let i = y * w + x; + for c in 0..2 { + let lo = low[i * 2 + c]; + let mut v = ab[i * 2 + c] - lo; + v *= keep; + p[1 + c] = v + lo; + } + } + }); + color::lab_to_rgb(&lab, dst, to_rgb); + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); + Some(()) +} + +/// One Gaussian bloom of `apply_glow_and_halation`: the highlights above `threshold` (a Python +/// float) of luma, blurred by (ksize, sigma) and added at `amount`. +pub struct Bloom { + pub amount: f32, + pub ksize: usize, + pub sigma: f64, + pub threshold: f64, +} + +/// `apply_glow_and_halation`: glow masks highlights on the display-encoded luma (`inv_gamma` +/// TRC), halation the red channel on linear luma, each blurred and added, then clipped. `lum` +/// is the luminance row. +pub fn glow_and_halation( + src: &[f32], + (h, w): (usize, usize), + (glow, halation): (Option, Option), + (lum, inv_gamma): ([f32; 3], f32), + dst: &mut [f32], +) -> Option<()> { + let n = h * w; + dst.copy_from_slice(src); + let luma = |p: &[f32]| p[0] * lum[0] + p[1] * lum[1] + p[2] * lum[2]; + let ramp = |v: f32, t: f64| clip((v - t as f32) / (1.0 - t) as f32, 0.0, 1.0); + let mut light = vec![0f32; n * 3]; + let mut blur = vec![0f32; n * 3]; + let mut add = |light: &[f32], b: &Bloom, dst: &mut [f32]| -> Option<()> { + filter::gaussian_blur( + light, + &mut blur, + (h, w, 3), + (b.ksize, b.ksize), + (b.sigma, 0.0), + Border::Reflect101, + )?; + dst.par_iter_mut() + .zip(blur.par_iter()) + .for_each(|(d, &v)| *d += v * b.amount); + Some(()) + }; + if let Some(b) = &glow { + let mut enc = vec![0f32; n * 3]; + color::oetf_encode(src, &mut enc, inv_gamma); + light + .par_chunks_mut(3) + .zip(src.par_chunks(3)) + .zip(enc.par_chunks(3)) + .for_each(|((o, p), e)| { + let m = ramp(luma(e), b.threshold); + o.copy_from_slice(&[p[0] * m, p[1] * m, p[2] * m]); + }); + add(&light, b, dst)?; + } + if let Some(b) = &halation { + light + .par_chunks_mut(3) + .zip(src.par_chunks(3)) + .for_each(|(o, p)| { + let r = p[0] * ramp(luma(p), b.threshold); + o.copy_from_slice(&[r, r * 0.3, r * 0.05]); + }); + add(&light, b, dst)?; + } + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); + Some(()) +} + +const CLAHE_GRID: usize = 8; +const CLAHE_BINS: usize = 256; + +/// `_clahe_cdfs`: per tile of the 8x8 grid, the clipped CDF of the bins. The limit is the +/// float32 product truncated, at least 1; the excess spreads evenly, the remainder to the +/// first bins. +fn clahe_cdfs(bins: &[u8], (h, w): (usize, usize), clip_limit: f32) -> Vec<[f32; CLAHE_BINS]> { + let (tsy, tsx) = (h.div_ceil(CLAHE_GRID), w.div_ceil(CLAHE_GRID)); + let tiles = CLAHE_GRID * CLAHE_GRID; + // One chunk of rows per thread: rayon's fold would make a histogram per split job. + let chunk = h.div_ceil(rayon::current_num_threads()).max(1); + let hist = bins + .par_chunks(chunk * w) + .enumerate() + .map(|(ci, block)| { + let mut acc = vec![0i64; tiles * CLAHE_BINS]; + for (dy, row) in block.chunks(w).enumerate() { + let base = ((ci * chunk + dy) / tsy) * CLAHE_GRID; + for (x, &b) in row.iter().enumerate() { + acc[(base + x / tsx) * CLAHE_BINS + b as usize] += 1; + } + } + acc + }) + .reduce( + || vec![0i64; tiles * CLAHE_BINS], + |mut a, b| { + a.iter_mut().zip(b).for_each(|(a, b)| *a += b); + a + }, + ); + hist.as_chunks::() + .0 + .iter() + .map(|t| { + let total: i64 = t.iter().sum(); + let limit = ((clip_limit * total as f32 / CLAHE_BINS as f32) as i64).max(1); + let excess: i64 = t.iter().map(|&c| c - c.min(limit)).sum(); + let (inc, rem) = (excess / CLAHE_BINS as i64, excess % CLAHE_BINS as i64); + let denom = total.max(1) as f32; + let mut run = 0i64; + std::array::from_fn(|b| { + run += t[b].min(limit) + inc + ((b as i64) < rem) as i64; + run as f32 / denom + }) + }) + .collect() +} + +/// `_clahe_axis(n)`: per position the two tile indices and the smoothstep fraction, from +/// tile_pos = pos / n * 8 - 0.5 in float32. +fn clahe_axis(n: usize) -> Vec<(usize, usize, f32)> { + (0..n) + .map(|i| { + let tp = i as f32 / n as f32 * CLAHE_GRID as f32 - 0.5; + let tf = tp.floor(); + let rf = tp - tf; + let t = tf as i32; + let (lo, hi) = ( + t.max(0) as usize, + (t + 1).min(CLAHE_GRID as i32 - 1) as usize, + ); + (lo, hi, rf * rf * (3.0 - 2.0 * rf)) + }) + .collect() +} + +/// `apply_clahe(img, strength)` for strength > 0: the L* channel equalized per tile of a +/// fixed 8x8 grid, blended between tiles, and mixed back by `strength`; clahe_*.wgsl mirror it. +pub fn clahe( + src: &[f32], + (h, w): (usize, usize), + strength: f64, + (to_lab, to_rgb): (LabSpace, LabSpace), + dst: &mut [f32], +) { + let n = h * w; + let mut lab = vec![0f32; n * 3]; + color::rgb_to_lab(src, &mut lab, to_lab); + // np.clip(...).astype(int32) truncates; a NaN lands in bin 0 here. + let bins: Vec = lab + .par_chunks(3) + .map(|p| clip(p[0] / 100.0 * 255.0, 0.0, 255.0) as u8) + .collect(); + let cdfs = clahe_cdfs(&bins, (h, w), (strength * 2.5) as f32); + let (ys, xs) = (clahe_axis(h), clahe_axis(w)); + let s = strength as f32; + lab.par_chunks_mut(w * 3).enumerate().for_each(|(y, out)| { + let (y0, y1, fy) = ys[y]; + for (x, p) in out.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let (x0, x1, fx) = xs[x]; + let b = bins[y * w + x] as usize; + let v = |ty: usize, tx: usize| cdfs[ty * CLAHE_GRID + tx][b]; + let (v00, v10, v01, v11) = (v(y0, x0), v(y0, x1), v(y1, x0), v(y1, x1)); + let top = v00 + (v10 - v00) * fx; + let bot = v01 + (v11 - v01) * fx; + let cdf = top + (bot - top) * fy; + let l = p[0]; + p[0] = l + (cdf * 100.0 - l) * s; + } + }); + color::lab_to_rgb(&lab, dst, to_rgb); + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); +} + +/// The scalars of `apply_rl_sharpening`: `amount` as float32, the edge `masking`, the +/// iteration count, the luminance row and the noise floor `eps`. +pub struct Rl { + pub amount: f32, + pub masking: f64, + pub iterations: usize, + pub lum: [f32; 3], + pub eps: f32, +} + +/// `_lab_l_from_y`: CIELAB L* of linear luminance (D65, Yn = 1), the cube root correctly +/// rounded where NumPy runs SVML's. +#[inline(always)] +fn lab_l_from_y(y: f32) -> f32 { + let y = if y.is_nan() || y > 0.0 { y } else { 0.0 }; + let f = if y > 0.008856 { + crate::mathf::cbrtf(y) + } else { + 7.787 * y + (16.0f64 / 116.0) as f32 + }; + 116.0 * f - 16.0 +} + +/// `apply_rl_sharpening` for amount > 0: Richardson-Lucy deconvolution of linear Y with the +/// Gaussian taps `k` (reflect-101 edges), applied to RGB as the ratio est / obs, gated by the +/// shadow gain and the optional edge mask, then clipped. +pub fn rl_sharpen( + src: &[f32], + (h, w): (usize, usize), + p: &Rl, + k: &[f32], + dst: &mut [f32], +) -> Option<()> { + let n = h * w; + let lum = p.lum; + let pos = |v: f32| if v.is_nan() || v > 0.0 { v } else { 0.0 }; + let obs: Vec = src + .par_chunks(3) + .map(|q| pos(q[0]) * lum[0] + pos(q[1]) * lum[1] + pos(q[2]) * lum[2]) + .collect(); + let floor = |v: f32| if v.is_nan() || v > p.eps { v } else { p.eps }; + let mut est = obs.clone(); + let (mut blurred, mut ratio, mut corr) = (vec![0f32; n], vec![0f32; n], vec![0f32; n]); + let blur = |s: &[f32], d: &mut [f32]| { + filter::sep_filter2d(s, d, (h, w, 1), (k, k), Border::Reflect101) + }; + for _ in 0..p.iterations { + blur(&est, &mut blurred)?; + ratio + .par_iter_mut() + .zip(obs.par_iter().zip(blurred.par_iter())) + .for_each(|(r, (&o, &b))| *r = o / floor(b)); + blur(&ratio, &mut corr)?; + est.par_iter_mut() + .zip(corr.par_iter()) + .for_each(|(e, &c)| *e *= c); + } + let l_obs: Vec = obs.par_iter().map(|&y| lab_l_from_y(y)).collect(); + let edge = if p.masking > 0.0 { + Some(edge_mask(&l_obs, (h, w), p.masking)?) + } else { + None + }; + dst.par_chunks_mut(3) + .zip(src.par_chunks(3)) + .enumerate() + .for_each(|(i, (d, q))| { + let r = est[i] / floor(obs[i]); + let mut g = p.amount * shadow_gain(l_obs[i]); + if let Some(e) = &edge { + g *= e[i]; + } + let f = 1.0 + (r - 1.0) * g; + let f = if f.is_nan() || f > 0.0 { f } else { 0.0 }; + for c in 0..3 { + d[c] = clip(q[c] * f, 0.0, 1.0); + } + }); + Some(()) +} + +/// `apply_split_toning`: each tint is (cos, sin) of its hue times 20 times its strength, as +/// float64; NumPy adds tint * mask to a* and b* in float64 and rounds once. The masks are the +/// float32 smoothsteps of L* below and above 50. +pub fn split_toning( + src: &[f32], + (shadow, highlight): (Option<[f64; 2]>, Option<[f64; 2]>), + (to_lab, to_rgb): (LabSpace, LabSpace), + dst: &mut [f32], +) { + let mut lab = vec![0f32; src.len()]; + color::rgb_to_lab(src, &mut lab, to_lab); + let step = |t: f32| { + let t = clip(t, 0.0, 1.0); + t * t * (3.0 - 2.0 * t) + }; + lab.par_chunks_mut(3).for_each(|p| { + let l = p[0]; + for (tint, mask) in [ + (shadow, step(1.0 - l / 50.0)), + (highlight, step((l - 50.0) / 50.0)), + ] { + if let Some(c) = tint { + p[1] = (p[1] as f64 + c[0] * mask as f64) as f32; + p[2] = (p[2] as f64 + c[1] * mask as f64) as f32; + } + } + }); + color::lab_to_rgb(&lab, dst, to_rgb); + dst.par_iter_mut().for_each(|v| *v = clip(*v, 0.0, 1.0)); +} + +/// `apply_hue_trim` of an HxWx3 image for a nonzero angle given as its (cos, sin): Lab, a*b* +/// rotated in float32, back to working RGB clipped to 0..1. +pub fn hue_trim( + src: &[f32], + (cos, sin): (f32, f32), + spaces: (LabSpace, LabSpace), + dst: &mut [f32], +) { + let mut lab = vec![0f32; src.len()]; + color::rgb_to_lab(src, &mut lab, spaces.0); + lab.par_chunks_mut(3 * 4096).for_each(move |run| { + for p in run.as_chunks_mut::<3>().0 { + let (a, b) = (p[1], p[2]); + p[1] = a * cos - b * sin; + p[2] = a * sin + b * cos; + } + }); + color::lab_to_rgb(&lab, dst, spaces.1); + dst.par_chunks_mut(4096).for_each(|run| { + for v in run { + let c = if v.is_nan() || *v > 0.0 { *v } else { 0.0 }; + *v = if c.is_nan() || c < 1.0 { c } else { 1.0 }; + } + }); +} diff --git a/negpy_core/src/lens.rs b/negpy_core/src/lens.rs new file mode 100644 index 000000000..f382f0f5b --- /dev/null +++ b/negpy_core/src/lens.rs @@ -0,0 +1,309 @@ +//! The embedded lens warps of negpy/features/lens/warps.py: inverse maps, in NumPy's operation +//! order and dtypes. + +use rayon::prelude::*; + +/// The frame geometry of `_coordinates`, its scalars as Python computes them. +#[derive(Clone, Copy)] +pub struct Frame { + /// Buffer-area origin and the buffer-to-image scale per axis. + pub origin: (f64, f64), + pub scale: (f64, f64), + /// The warp center and radius in buffer pixels. + pub center: (f64, f64), + pub radius: f64, + pub fill: f64, +} + +/// A normalized coordinate of `_coordinates`: float32 up to the division by the float64 radius. +#[inline(always)] +fn normalized(i: usize, origin: f64, scale: f64, center: f64, radius: f64, fill: f64) -> f64 { + let v = ((origin as f32 + (i as f32 + 0.5) * scale as f32) - 0.5) - center as f32; + v as f64 / radius * fill +} + +/// `RectilinearWarp.remap` with the distortion applied: the map of rows `rows` of a w-wide +/// frame through `coefficients` (k0..k3, t0, t1), into `mx` and `my`. +pub fn rectilinear_map( + w: usize, + rows: std::ops::Range, + coefficients: [f64; 6], + f: Frame, + mx: &mut [f32], + my: &mut [f32], +) { + let [k0, k1, k2, k3, t0, t1] = coefficients; + let xs: Vec = (0..w) + .map(|i| normalized(i, f.origin.0, f.scale.0, f.center.0, f.radius, f.fill)) + .collect(); + let start = rows.start; + mx.par_chunks_mut(w) + .zip(my.par_chunks_mut(w)) + .enumerate() + .for_each(|(r, (ox, oy))| { + let y = normalized( + start + r, + f.origin.1, + f.scale.1, + f.center.1, + f.radius, + f.fill, + ); + for ((ox, oy), &x) in ox.iter_mut().zip(oy.iter_mut()).zip(&xs) { + let r2 = x * x + y * y; + let factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + let wx = x * factor + 2.0 * t0 * x * y + t1 * (r2 + 2.0 * x * x); + let wy = y * factor + 2.0 * t1 * x * y + t0 * (r2 + 2.0 * y * y); + *ox = + ((((f.center.0 + f.radius * wx) - f.origin.0) + 0.5) / f.scale.0 - 0.5) as f32; + *oy = + ((((f.center.1 + f.radius * wy) - f.origin.1) + 0.5) / f.scale.1 - 0.5) as f32; + } + }); +} + +/// `np.hypot` of float32, which is correctly rounded. +#[inline(always)] +fn hypotf(x: f32, y: f32) -> f32 { + let (x, y) = (x as f64, y as f64); + (x * x + y * y).sqrt() as f32 +} + +/// The float32 centered coordinates of the Panasonic and Sony models. +fn centered(n: usize, start: usize, len: usize, fill: f32) -> Vec { + let half = (n as f64 * 0.5) as f32; + (start..start + len) + .map(|i| (i as f32 - half) * fill) + .collect() +} + +/// `PanasonicWarp.remap` with the distortion applied, for rows `rows` of an h x w frame: +/// Ru -> Rd by fixed-point iteration over the whole block, stopping when no pixel moves 1e-7. +pub fn panasonic_map( + (h, w): (usize, usize), + rows: std::ops::Range, + (scale, a, b, c): (f64, f64, f64, f64), + fill: f64, + mx: &mut [f32], + my: &mut [f32], +) { + let xs = centered(w, 0, w, fill as f32); + let ys = centered(h, rows.start, rows.len(), fill as f32); + let half_diag = (w as f64 * 0.5).hypot(h as f64 * 0.5); + let ru: Vec = ys + .iter() + .flat_map(|&y| xs.iter().map(move |&x| hypotf(x, y) as f64 / half_diag)) + .collect(); + let mut rd = ru.clone(); + for _ in 0..8 { + let new: Vec = rd + .par_iter() + .zip(&ru) + .map(|(&rd, &ru)| { + let r2 = rd * rd; + let f = 1.0 + scale * (a * r2 + b * r2 * r2 + c * r2 * r2 * r2); + ru / if f > 0.0 { f } else { 1.0 } + }) + .collect(); + let moved = new + .par_iter() + .zip(&rd) + .map(|(&n, &r)| (n - r).abs()) + .reduce( + || f64::NEG_INFINITY, + |p, q| if q > p || q.is_nan() { q } else { p }, + ); + if moved < 1e-7 { + break; + } + rd = new; + } + let (cx, cy) = (w as f64 * 0.5, h as f64 * 0.5); + mx.par_chunks_mut(w) + .zip(my.par_chunks_mut(w)) + .enumerate() + .for_each(|(r, (ox, oy))| { + let y = ys[r] as f64; + for (i, (ox, oy)) in ox.iter_mut().zip(oy.iter_mut()).enumerate() { + let (ru, rd) = (ru[r * w + i], rd[r * w + i]); + let dr = if ru > 0.0 { rd / ru } else { 1.0 }; + *ox = (xs[i] as f64 * dr + cx) as f32; + *oy = (y * dr + cy) as f32; + } + }); +} + +/// `np.interp(x, xp, fp)` of float64, with NumPy's rules at the anchors and its slope order. +fn interp(x: f64, xp: &[f64], fp: &[f64], slopes: &[f64]) -> f64 { + let n = xp.len(); + if x.is_nan() { + return x; + } + if x < xp[0] { + return fp[0]; + } + if x > xp[n - 1] { + return fp[n - 1]; + } + let j = xp.partition_point(|&v| v <= x) - 1; + if j == n - 1 || xp[j] == x { + return fp[j]; + } + let v = slopes[j] * (x - xp[j]) + fp[j]; + if !v.is_nan() { + return v; + } + let v = slopes[j] * (x - xp[j + 1]) + fp[j + 1]; + if v.is_nan() && fp[j] == fp[j + 1] { + fp[j] + } else { + v + } +} + +/// `SonyWarp.remap` for rows `rows` of an h x w frame, its knot factors already combined. +pub fn sony_map( + (h, w): (usize, usize), + rows: std::ops::Range, + factors: &[f64], + fill: f64, + mx: &mut [f32], + my: &mut [f32], +) { + let n = factors.len(); + let knots: Vec = (0..n).map(|i| (i as f64 + 0.5) / (n - 1) as f64).collect(); + let slopes: Vec = (0..n.saturating_sub(1)) + .map(|j| (factors[j + 1] - factors[j]) / (knots[j + 1] - knots[j])) + .collect(); + let xs = centered(w, 0, w, fill as f32); + let ys = centered(h, rows.start, rows.len(), fill as f32); + let half_diag = (w as f64 * 0.5).hypot(h as f64 * 0.5); + let (cx, cy) = ((w as f64 * 0.5) as f32, (h as f64 * 0.5) as f32); + mx.par_chunks_mut(w) + .zip(my.par_chunks_mut(w)) + .enumerate() + .for_each(|(r, (ox, oy))| { + let y = ys[r]; + for ((ox, oy), &x) in ox.iter_mut().zip(oy.iter_mut()).zip(&xs) { + let radius = hypotf(x, y) as f64 / half_diag; + let factor = interp(radius, &knots, factors, &slopes) as f32; + *ox = x * factor + cx; + *oy = y * factor + cy; + } + }); +} + +/// `_rectilinear_xy` for one float64 point. +#[inline(always)] +fn rectilinear_xy(x: f64, y: f64, [k0, k1, k2, k3, t0, t1]: [f64; 6]) -> (f64, f64) { + let r2 = x * x + y * y; + let factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + ( + x * factor + 2.0 * t0 * x * y + t1 * (r2 + 2.0 * x * x), + y * factor + 2.0 * t1 * x * y + t0 * (r2 + 2.0 * y * y), + ) +} + +/// `cv2.undistortPointsIter` of one point with camera matrix diag(f, f, 1), distortion +/// (k1, k2, p1, p2, k3), no R or P, and (COUNT | EPS, `max_count`, `eps`): OpenCV's damped +/// fixed-point iteration, its operation order kept. +fn undistort_point(u: f64, v: f64, f: f64, k: [f64; 5], max_count: usize, eps: f64) -> (f64, f64) { + let [k1, k2, p1, p2, k3] = k; + let ifx = 1.0 / f; + let (x0, y0) = ((u - 0.0) * ifx, (v - 0.0) * ify(f)); + let (mut x, mut y) = (x0, y0); + let (mut error, mut prev_error) = (f64::MAX, f64::MAX); + let mut alpha = 1.0; + let mut j = 0; + loop { + if j >= max_count || error < eps { + break; + } + let r2 = x * x + y * y; + let icdist = + (1.0 + ((0.0 * r2 + 0.0) * r2 + 0.0) * r2) / (1.0 + ((k3 * r2 + k2) * r2 + k1) * r2); + if icdist < 0.0 { + return ((u - 0.0) * ifx, (v - 0.0) * ify(f)); + } + let dx = 2.0 * p1 * x * y + p2 * (r2 + 2.0 * x * x) + 0.0 * r2 + 0.0 * r2 * r2; + let dy = p1 * (r2 + 2.0 * y * y) + 2.0 * p2 * x * y + 0.0 * r2 + 0.0 * r2 * r2; + let nx = (1.0 - alpha) * x + alpha * (x0 - dx) * icdist; + let ny = (1.0 - alpha) * y + alpha * (y0 - dy) * icdist; + let r2 = nx * nx + ny * ny; + let r4 = r2 * r2; + let r6 = r4 * r2; + let a1 = 2.0 * nx * ny; + let a2 = r2 + 2.0 * nx * nx; + let a3 = r2 + 2.0 * ny * ny; + let cdist = 1.0 + k1 * r2 + k2 * r4 + k3 * r6; + let icdist2 = 1.0 / (1.0 + 0.0 * r2 + 0.0 * r4 + 0.0 * r6); + let xd0 = nx * cdist * icdist2 + p1 * a1 + p2 * a2 + 0.0 * r2 + 0.0 * r4; + let yd0 = ny * cdist * icdist2 + p1 * a3 + p2 * a1 + 0.0 * r2 + 0.0 * r4; + // The identity tilt: 1 * v + 0 * w + 0 * 1, then over 0 * . + 0 * . + 1 * 1. + let xd = 1.0 * (1.0 * xd0 + 0.0 * yd0 + 0.0); + let yd = 1.0 * (0.0 * xd0 + 1.0 * yd0 + 0.0); + let (xp, yp) = (xd * f + 0.0, yd * f + 0.0); + error = ((xp - u) * (xp - u) + (yp - v) * (yp - v)).sqrt(); + if error > prev_error { + alpha *= 0.5; + } else { + x = nx; + y = ny; + } + prev_error = error; + j += 1; + } + (x, y) +} + +#[inline(always)] +fn ify(f: f64) -> f64 { + 1.0 / f +} + +/// `RectilinearWarp.remap` with lateral CA only: each point taken back through the green +/// (`common`) model, clamped to the calibrated radius, through the channel's model, and the +/// channel's offset from green scaled onto the point's own radius. +pub fn rectilinear_ca_map( + w: usize, + rows: std::ops::Range, + (selected, common): ([f64; 6], [f64; 6]), + f: Frame, + mx: &mut [f32], + my: &mut [f32], +) { + let [g0, g1, g2, g3, t0, t1] = common; + let k = [g1 / g0, g2 / g0, t0 / g0, t1 / g0, g3 / g0]; + let xs: Vec = (0..w) + .map(|i| normalized(i, f.origin.0, f.scale.0, f.center.0, f.radius, f.fill)) + .collect(); + let start = rows.start; + mx.par_chunks_mut(w) + .zip(my.par_chunks_mut(w)) + .enumerate() + .for_each(|(r, (ox, oy))| { + let ty = normalized( + start + r, + f.origin.1, + f.scale.1, + f.center.1, + f.radius, + f.fill, + ); + for ((ox, oy), &tx) in ox.iter_mut().zip(oy.iter_mut()).zip(&xs) { + let (x, y) = undistort_point(tx, ty, g0, k, 50, 1e-8); + let h = x.hypot(y); + let limit = if h.is_nan() || h > 1.0 { h } else { 1.0 }; + let (x, y) = (x / limit, y / limit); + let (wx, wy) = rectilinear_xy(x, y, selected); + let (gx, gy) = rectilinear_xy(x, y, common); + let gh = gx.hypot(gy); + let scale = tx.hypot(ty) / if gh.is_nan() || gh > 1e-8 { gh } else { 1e-8 }; + let (wx, wy) = (tx + (wx - gx) * scale, ty + (wy - gy) * scale); + *ox = + ((((f.center.0 + f.radius * wx) - f.origin.0) + 0.5) / f.scale.0 - 0.5) as f32; + *oy = + ((((f.center.1 + f.radius * wy) - f.origin.1) + 0.5) / f.scale.1 - 0.5) as f32; + } + }); +} diff --git a/negpy_core/src/lib.rs b/negpy_core/src/lib.rs new file mode 100644 index 000000000..05508b3da --- /dev/null +++ b/negpy_core/src/lib.rs @@ -0,0 +1,102 @@ +pub mod altprocess; +pub mod analysis; +pub mod autocrop; +pub mod bounds; +pub mod capture; +pub mod color; +pub mod components; +pub mod contours; +pub mod convert; +pub mod detect; +pub mod display; +pub mod distance; +pub mod filter; +pub mod finish; +pub mod flatfield; +pub mod gray; +pub mod hdr; +pub mod histogram; +pub mod inpaint; +pub mod jpeg; +pub mod lab; +pub mod lens; +pub mod loaders; +pub mod lsd; +pub mod mathf; +pub mod median; +pub mod memo; +pub mod meter; +pub mod morph; +pub mod phase; +pub mod print_curve; +pub mod process; +pub mod register; +pub mod resize; +pub mod retouch; +pub mod rgbscan; +pub mod seam; +pub mod sensor; +pub mod shapes; +pub mod sift; +pub mod skew; +pub mod stats; +pub mod stitch; +pub mod toning; +pub mod warp; +pub mod zones; + +/// `f(dst[i], src[i])` for every i, in parallel blocks: a rayon task per element costs more than +/// the work. Pass a `move` closure: a capture read through a reference may alias `dst`, so the +/// loop reloads it per element and does not vectorize. +pub(crate) fn par_zip(dst: &mut [T], src: &[U], f: F) { + use rayon::prelude::*; + const BLOCK: usize = 1 << 14; + dst.par_chunks_mut(BLOCK) + .zip(src.par_chunks(BLOCK)) + .for_each(|(d, s)| d.iter_mut().zip(s).for_each(|(d, s)| f(d, s))); +} + +#[cfg(feature = "python")] +mod py; + +#[cfg(feature = "python")] +#[pyo3::pymodule] +fn negpy_core(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> { + py::stats::register(m)?; + py::resize::register(m)?; + py::filter::register(m)?; + py::morph::register(m)?; + py::loaders::register(m)?; + py::process::register(m)?; + py::phase::register(m)?; + py::rgbscan::register(m)?; + py::capture::register(m)?; + py::print_curve::register(m)?; + py::gray::register(m)?; + py::contours::register(m)?; + py::components::register(m)?; + py::convert::register(m)?; + py::histogram::register(m)?; + py::meter::register(m)?; + py::retouch::register(m)?; + py::bounds::register(m)?; + py::color::register(m)?; + py::toning::register(m)?; + py::sensor::register(m)?; + py::hdr::register(m)?; + py::display::register(m)?; + py::analysis::register(m)?; + py::warp::register(m)?; + py::lab::register(m)?; + py::lens::register(m)?; + py::altprocess::register(m)?; + py::finish::register(m)?; + py::flatfield::register(m)?; + py::stitch::register(m)?; + py::skew::register(m)?; + py::register::register(m)?; + py::autocrop::register(m)?; + py::zones::register(m)?; + py::pool::register(m)?; + py::jpeg::register(m) +} diff --git a/negpy_core/src/loaders.rs b/negpy_core/src/loaders.rs new file mode 100644 index 000000000..e425037e7 --- /dev/null +++ b/negpy_core/src/loaders.rs @@ -0,0 +1,47 @@ +//! Ports of the pixel work in negpy/infrastructure/loaders. + +use rayon::prelude::*; + +use crate::stats::quantile_f64; + +/// `normalize_linear`: each channel of a float64 image with `c` channels stretched from its +/// 0.2nd to its 99.8th percentile onto 0..1 and clipped. A flat channel divides by 1. +pub fn normalize_linear(lin: &[f64], c: usize, dst: &mut [f64]) { + let bounds: Vec<(f64, f64)> = (0..c) + .into_par_iter() + .map(|ch| { + let mut v: Vec = lin.iter().skip(ch).step_by(c).copied().collect(); + let lo = quantile_f64(&mut v, 0.2 / 100.0).unwrap_or(f64::NAN); + let hi = quantile_f64(&mut v, 99.8 / 100.0).unwrap_or(f64::NAN); + let denom = hi - lo; + (lo, if denom > 0.0 { denom } else { 1.0 }) + }) + .collect(); + dst.par_chunks_mut(c * 4096) + .zip(lin.par_chunks(c * 4096)) + .for_each(|(d, s)| { + for (d, s) in d.chunks_mut(c).zip(s.chunks(c)) { + for ch in 0..c { + let (lo, denom) = bounds[ch]; + d[ch] = ((s[ch] - lo) / denom).clamp(0.0, 1.0); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flat_channel_maps_to_zero() { + let lin: Vec = (0..3000) + .map(|i| if i % 3 == 1 { 5.0 } else { (i / 3) as f64 }) + .collect(); + let mut out = vec![0f64; lin.len()]; + normalize_linear(&lin, 3, &mut out); + assert!(out.iter().skip(1).step_by(3).all(|&v| v == 0.0)); + assert_eq!(out[0], 0.0); + assert_eq!(out[2997], 1.0); + } +} diff --git a/negpy_core/src/lsd.rs b/negpy_core/src/lsd.rs new file mode 100644 index 000000000..9b0360664 --- /dev/null +++ b/negpy_core/src/lsd.rs @@ -0,0 +1,482 @@ +//! `cv2.createLineSegmentDetector().detect(u8)` with its defaults (LSD_REFINE_STD, scale 0.8), +//! ported from OpenCV 4.13's `modules/imgproc/src/lsd.cpp` (Apache-2.0). The Gaussian and the +//! INTER_LINEAR_EXACT downscale copy OpenCV's fixed-point 8-bit code. + +use rayon::prelude::*; + +use crate::filter::{self, Border}; + +const SCALE: f64 = 0.8; +const SIGMA_SCALE: f64 = 0.6; +const QUANT: f64 = 2.0; +const ANG_TH: f64 = 22.5; +const DENSITY_TH: f64 = 0.7; +const N_BINS: usize = 1024; +const NOTDEF: f64 = -1024.0; +const DEG_TO_RADS: f64 = std::f64::consts::PI / 180.0; + +/// OpenCV's `fastAtan2`, in degrees on [0, 360): a polynomial, about 0.3 degrees off. +fn fast_atan2(y: f32, x: f32) -> f32 { + const K: f32 = (180.0 / std::f64::consts::PI) as f32; + let (p1, p3, p5, p7) = ( + 0.9997878412794807_f64 as f32 * K, + -0.3258083974640975_f64 as f32 * K, + 0.1555786518463281_f64 as f32 * K, + -0.04432655554792128_f64 as f32 * K, + ); + let (ax, ay) = (x.abs(), y.abs()); + let poly = |c: f32| { + let c2 = c * c; + (((p7 * c2 + p5) * c2 + p3) * c2 + p1) * c + }; + let mut a = if ax >= ay { + poly(ay / (ax + f64::EPSILON as f32)) + } else { + 90.0 - poly(ax / (ay + f64::EPSILON as f32)) + }; + if x < 0.0 { + a = 180.0 - a; + } + if y < 0.0 { + a = 360.0 - a; + } + a +} + +/// `getGaussianKernelFixedPoint_ED` with 8 fraction bits: the kernel rounded with error +/// diffusion, its centre taking what makes the sum exactly 256. +fn fixed_kernel(n: usize, sigma: f64) -> Vec { + let k = filter::gaussian_kernel_f64(n, sigma); + let mut out = vec![0u32; n]; + let (mut err, mut sum) = (0f64, 0i64); + for i in 0..n / 2 { + let adj = k[i] * 256.0 + err; + let v = adj.round_ties_even() as i64; + err = adj - v as f64; + out[i] = v as u32; + out[n - 1 - i] = v as u32; + sum += v; + } + out[n / 2] = (256 - 2 * sum) as u32; + out +} + +/// `cv2.GaussianBlur(u8, (n, n), sigma)` (reflect-101): exact integer rows in 8.8, columns in +/// 16.16, rounded half up. +fn gaussian_blur_u8(src: &[u8], (h, w): (usize, usize), n: usize, sigma: f64) -> Vec { + let k = fixed_kernel(n, sigma); + let r = (n / 2) as isize; + let idx = + |p: isize, len: usize| filter::border_interpolate(p, len, Border::Reflect101).unwrap_or(0); + let xs: Vec> = (0..w as isize) + .map(|x| (-r..=r).map(|d| idx(x + d, w)).collect()) + .collect(); + let mut rows = vec![0u32; h * w]; + rows.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + let line = &src[y * w..(y + 1) * w]; + for (o, taps) in out.iter_mut().zip(&xs) { + *o = taps.iter().zip(&k).map(|(&t, &c)| line[t] as u32 * c).sum(); + } + }); + let mut dst = vec![0u8; h * w]; + dst.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + let taps: Vec<&[u32]> = (-r..=r) + .map(|d| &rows[idx(y as isize + d, h) * w..][..w]) + .collect(); + for (x, o) in out.iter_mut().enumerate() { + let s: u32 = taps.iter().zip(&k).map(|(t, &c)| t[x] * c).sum(); + *o = ((s + (1 << 15)) >> 16).min(255) as u8; + } + }); + dst +} + +/// `interpolationLinear::getCoeffs` for every destination index: (offset, c0, c1) in 8.8, with +/// c0 = 256 and c1 = 0 past either end. +fn linear_exact_axis(src: usize, dst: usize, inv_scale: f64) -> Vec<(usize, u32, u32)> { + let scale = 1.0 / inv_scale; + (0..dst) + .map(|d| { + let f = scale * (d as f64 + 0.5) - 0.5; + let i = f.floor() as i64; + if i < 0 || src <= 1 { + (0, 256, 0) + } else if (i as usize) < src - 1 { + let c1 = ((f - i as f64) * 256.0).round_ties_even() as u32; + (i as usize, 256 - c1, c1) + } else { + (src - 1, 256, 0) + } + }) + .collect() +} + +/// `cv2.resize(u8, None, fx=SCALE, fy=SCALE, interpolation=cv2.INTER_LINEAR_EXACT)`. +fn resize_exact_u8(src: &[u8], (h, w): (usize, usize)) -> (Vec, usize, usize) { + let dw = (w as f64 * SCALE).round_ties_even() as usize; + let dh = (h as f64 * SCALE).round_ties_even() as usize; + let xs = linear_exact_axis(w, dw, SCALE); + let ys = linear_exact_axis(h, dh, SCALE); + let hline = |sy: usize, out: &mut [u32]| { + let row = &src[sy * w..(sy + 1) * w]; + for (o, &(i, c0, c1)) in out.iter_mut().zip(&xs) { + let b = if c1 == 0 { 0 } else { row[i + 1] as u32 * c1 }; + *o = row[i] as u32 * c0 + b; + } + }; + let mut dst = vec![0u8; dh * dw]; + dst.par_chunks_mut(dw) + .zip(&ys) + .for_each(|(out, &(i, c0, c1))| { + let (mut a, mut b) = (vec![0u32; dw], vec![0u32; dw]); + hline(i, &mut a); + if c1 != 0 { + hline(i + 1, &mut b); + } + for ((o, &a), &b) in out.iter_mut().zip(&a).zip(&b) { + *o = ((a * c0 + b * c1 + (1 << 15)) >> 16).min(255) as u8; + } + }); + (dst, dh, dw) +} + +struct Point { + x: usize, + y: usize, + angle: f64, + modgrad: f64, +} + +struct Rect { + x1: f64, + y1: f64, + x2: f64, + y2: f64, + width: f64, +} + +struct Detector { + w: usize, + h: usize, + angles: Vec, + modgrad: Vec, + /// `(cosf, sinf)` of each defined angle, as the region sums take them. + unit: Vec<(f32, f32)>, + /// Taken by a region, or with no defined angle: no region can take it. + used: Vec, +} + +fn dist(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { + ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)).sqrt() +} + +fn angle_diff_signed(a: f64, b: f64) -> f64 { + let mut d = a - b; + while d <= -std::f64::consts::PI { + d += 2.0 * std::f64::consts::PI; + } + while d > std::f64::consts::PI { + d -= 2.0 * std::f64::consts::PI; + } + d +} + +impl Detector { + /// `ll_angle`: gradient angle and norm per pixel, and the pixels pseudo-ordered by norm. + fn new(img: &[u8], h: usize, w: usize, threshold: f64) -> (Self, Vec) { + let mut angles = vec![NOTDEF; h * w]; + let mut modgrad = vec![0f64; h * w]; + angles + .par_chunks_mut(w) + .zip(modgrad.par_chunks_mut(w)) + .enumerate() + .take(h.saturating_sub(1)) + .for_each(|(y, (ang, md))| { + let (r0, r1) = (&img[y * w..(y + 1) * w], &img[(y + 1) * w..(y + 2) * w]); + for x in 0..w - 1 { + let da = r1[x + 1] as i32 - r0[x] as i32; + let bc = r0[x + 1] as i32 - r1[x] as i32; + let (gx, gy) = (da + bc, da - bc); + let norm = ((gx * gx + gy * gy) as f64 / 4.0).sqrt(); + md[x] = norm; + if norm > threshold { + ang[x] = fast_atan2(gx as f32, -gy as f32) as f64 * DEG_TO_RADS; + } + } + }); + let max_grad = angles + .iter() + .zip(&modgrad) + .filter(|(a, _)| **a != NOTDEF) + .map(|(_, m)| *m) + .fold(-1f64, f64::max); + let coef = if max_grad > 0.0 { + (N_BINS - 1) as f64 / max_grad + } else { + 0.0 + }; + // A stable sort by descending bin is a bucket sort that keeps raster order in a bin. + let bins: Vec = (0..h.saturating_sub(1) * w) + .map(|i| { + if i % w == w - 1 { + u16::MAX + } else { + (modgrad[i] * coef) as u16 + } + }) + .collect(); + let mut start = vec![0usize; N_BINS + 1]; + for &b in bins.iter().filter(|&&b| b != u16::MAX) { + start[N_BINS - 1 - b as usize + 1] += 1; + } + for i in 0..N_BINS { + start[i + 1] += start[i]; + } + let mut order = vec![0u32; start[N_BINS]]; + for (i, &b) in bins.iter().enumerate().filter(|&(_, &b)| b != u16::MAX) { + let s = &mut start[N_BINS - 1 - b as usize]; + order[*s] = i as u32; + *s += 1; + } + let mut unit = vec![(0f32, 0f32); h * w]; + let mut used = vec![false; h * w]; + unit.par_chunks_mut(w) + .zip(used.par_chunks_mut(w)) + .zip(angles.par_chunks(w)) + .for_each(|((u, d), a)| { + for ((u, d), &a) in u.iter_mut().zip(d).zip(a) { + if a == NOTDEF { + *d = true; + } else { + *u = (libm::cosf(a as f32), libm::sinf(a as f32)); + } + } + }); + let det = Detector { + w, + h, + angles, + modgrad, + unit, + used, + }; + (det, order) + } + + fn is_aligned(&self, x: usize, y: usize, theta: f64, prec: f64) -> bool { + let a = self.angles[y * self.w + x]; + if a == NOTDEF { + return false; + } + let mut n = (theta - a).abs(); + if n > 1.5 * std::f64::consts::PI { + n = (n - 2.0 * std::f64::consts::PI).abs(); + } + n <= prec + } + + /// Returns the region's mean angle. + fn region_grow(&mut self, sx: usize, sy: usize, reg: &mut Vec, prec: f64) -> f64 { + reg.clear(); + let i = sy * self.w + sx; + let mut reg_angle = self.angles[i]; + reg.push(Point { + x: sx, + y: sy, + angle: reg_angle, + modgrad: self.modgrad[i], + }); + let mut sumdx = reg_angle.cos() as f32; + let mut sumdy = reg_angle.sin() as f32; + self.used[i] = true; + let mut k = 0; + while k < reg.len() { + let (px, py) = (reg[k].x, reg[k].y); + for yy in py.saturating_sub(1)..=(py + 1).min(self.h - 1) { + for xx in px.saturating_sub(1)..=(px + 1).min(self.w - 1) { + let j = yy * self.w + xx; + if !self.used[j] && self.is_aligned(xx, yy, reg_angle, prec) { + let angle = self.angles[j]; + self.used[j] = true; + reg.push(Point { + x: xx, + y: yy, + angle, + modgrad: self.modgrad[j], + }); + let (c, s) = self.unit[j]; + sumdx += c; + sumdy += s; + reg_angle = fast_atan2(sumdy, sumdx) as f64 * DEG_TO_RADS; + } + } + } + k += 1; + } + reg_angle + } + + fn region2rect(reg: &[Point], reg_angle: f64, prec: f64) -> Rect { + let (mut x, mut y, mut sum) = (0.0, 0.0, 0.0); + for p in reg { + x += p.x as f64 * p.modgrad; + y += p.y as f64 * p.modgrad; + sum += p.modgrad; + } + x /= sum; + y /= sum; + let theta = Self::get_theta(reg, x, y, reg_angle, prec); + let (dx, dy) = (theta.cos(), theta.sin()); + let (mut l_min, mut l_max, mut w_min, mut w_max) = (0f64, 0f64, 0f64, 0f64); + for p in reg { + let (rx, ry) = (p.x as f64 - x, p.y as f64 - y); + let l = rx * dx + ry * dy; + let w = -rx * dy + ry * dx; + if l > l_max { + l_max = l; + } else if l < l_min { + l_min = l; + } + if w > w_max { + w_max = w; + } else if w < w_min { + w_min = w; + } + } + Rect { + x1: x + l_min * dx, + y1: y + l_min * dy, + x2: x + l_max * dx, + y2: y + l_max * dy, + width: (w_max - w_min).max(1.0), + } + } + + fn get_theta(reg: &[Point], x: f64, y: f64, reg_angle: f64, prec: f64) -> f64 { + let (mut ixx, mut iyy, mut ixy) = (0.0, 0.0, 0.0); + for p in reg { + let (dx, dy) = (p.x as f64 - x, p.y as f64 - y); + ixx += dy * dy * p.modgrad; + iyy += dx * dx * p.modgrad; + ixy -= dx * dy * p.modgrad; + } + let lambda = 0.5 * (ixx + iyy - ((ixx - iyy) * (ixx - iyy) + 4.0 * ixy * ixy).sqrt()); + let deg = if ixx.abs() > iyy.abs() { + fast_atan2((lambda - ixx) as f32, ixy as f32) + } else { + fast_atan2(ixy as f32, (lambda - iyy) as f32) + }; + let theta = deg as f64 * DEG_TO_RADS; + if angle_diff_signed(theta, reg_angle).abs() > prec { + theta + std::f64::consts::PI + } else { + theta + } + } + + fn density(reg: &[Point], r: &Rect) -> f64 { + reg.len() as f64 / (dist(r.x1, r.y1, r.x2, r.y2) * r.width) + } + + fn release(&mut self, p: &Point) { + self.used[p.y * self.w + p.x] = false; + } + + fn refine(&mut self, reg: &mut Vec, prec: f64, rec: &mut Rect) -> bool { + if Self::density(reg, rec) >= DENSITY_TH { + return true; + } + let (xc, yc, ang_c) = (reg[0].x as f64, reg[0].y as f64, reg[0].angle); + let (seed_x, seed_y) = (reg[0].x, reg[0].y); + let (mut sum, mut s_sum, mut n) = (0.0, 0.0, 0usize); + for p in reg.iter() { + self.release(p); + if dist(xc, yc, p.x as f64, p.y as f64) < rec.width { + let d = angle_diff_signed(p.angle, ang_c); + sum += d; + s_sum += d * d; + n += 1; + } + } + let mean = sum / n as f64; + let tau = 2.0 * ((s_sum - 2.0 * mean * sum) / n as f64 + mean * mean).sqrt(); + let reg_angle = self.region_grow(seed_x, seed_y, reg, tau); + if reg.len() < 2 { + return false; + } + *rec = Self::region2rect(reg, reg_angle, prec); + let density = Self::density(reg, rec); + if density >= DENSITY_TH { + return true; + } + self.reduce_region_radius(reg, reg_angle, prec, rec, density) + } + + fn reduce_region_radius( + &mut self, + reg: &mut Vec, + reg_angle: f64, + prec: f64, + rec: &mut Rect, + mut density: f64, + ) -> bool { + let (xc, yc) = (reg[0].x as f64, reg[0].y as f64); + let d2 = |x: f64, y: f64| (x - xc) * (x - xc) + (y - yc) * (y - yc); + let mut rad = d2(rec.x1, rec.y1).max(d2(rec.x2, rec.y2)); + while density < DENSITY_TH { + rad *= 0.75 * 0.75; + let mut i = 0; + while i < reg.len() { + if d2(reg[i].x as f64, reg[i].y as f64) > rad { + let p = reg.swap_remove(i); + self.release(&p); + } else { + i += 1; + } + } + if reg.len() < 2 { + return false; + } + *rec = Self::region2rect(reg, reg_angle, prec); + density = Self::density(reg, rec); + } + true + } +} + +/// Segments (x1, y1, x2, y2) of an HxW 8-bit image, in pixels of that image. +pub fn line_segments(img: &[u8], (h, w): (usize, usize)) -> Vec<[f32; 4]> { + let sigma = SIGMA_SCALE / SCALE; + let half = (sigma * (2.0 * 3.0 * 10f64.ln()).sqrt()).ceil() as usize; + let blurred = gaussian_blur_u8(img, (h, w), 1 + 2 * half, sigma); + let (scaled, sh, sw) = resize_exact_u8(&blurred, (h, w)); + if sh < 2 || sw < 2 { + return Vec::new(); + } + let prec = std::f64::consts::PI * ANG_TH / 180.0; + let p = ANG_TH / 180.0; + let rho = QUANT / prec.sin(); + let (mut det, order) = Detector::new(&scaled, sh, sw, rho); + let log_nt = 5.0 * ((sw as f64).log10() + (sh as f64).log10()) / 2.0 + 11f64.log10(); + let min_reg_size = (-log_nt / p.log10()) as usize; + let mut reg = Vec::new(); + let mut lines = Vec::new(); + for &i in &order { + let i = i as usize; + if det.used[i] { + continue; + } + let (x, y) = (i % sw, i / sw); + let reg_angle = det.region_grow(x, y, &mut reg, prec); + if reg.len() < min_reg_size { + continue; + } + let mut rec = Detector::region2rect(®, reg_angle, prec); + if !det.refine(&mut reg, prec, &mut rec) { + continue; + } + let s = |v: f64| ((v + 0.5) / SCALE) as f32; + lines.push([s(rec.x1), s(rec.y1), s(rec.x2), s(rec.y2)]); + } + lines +} diff --git a/negpy_core/src/mathf.rs b/negpy_core/src/mathf.rs new file mode 100644 index 000000000..26a0d5b48 --- /dev/null +++ b/negpy_core/src/mathf.rs @@ -0,0 +1,476 @@ +//! Transcendental functions for hot array loops: branch-free, so LLVM vectorizes them, and pure +//! float arithmetic, so every platform gets the same bits. Scalar calls may use `libm`. + +/// log10 of a positive normal f32, correctly rounded (`exhaustive` checks every input). +/// x = m * 2^e with m in [sqrt(1/2), sqrt(2)); ln(m) from the atanh series in +/// f64, rounded once to f32. +#[inline(always)] +pub fn log10f(x: f32) -> f32 { + let bits = x.to_bits(); + // Subtracting the bits of sqrt(1/2) puts the exponent split at sqrt(2). + let e = (bits.wrapping_sub(0x3f35_04f3) as i32) >> 23; + let m = f32::from_bits(bits.wrapping_sub((e as u32) << 23)) as f64; + let s = (m - 1.0) / (m + 1.0); + let s2 = s * s; + // 2 s (1 + s^2/3 + s^4/5 + ...), |s| <= 0.1716: these terms reach f64 accuracy. + let p = 1.0 + + s2 * (1.0 / 3.0 + + s2 * (1.0 / 5.0 + + s2 * (1.0 / 7.0 + + s2 * (1.0 / 9.0 + + s2 * (1.0 / 11.0 + + s2 * (1.0 / 13.0 + s2 * (1.0 / 15.0 + s2 * (1.0 / 17.0)))))))); + // log10(2) in two parts: HI has 20 trailing zero bits, so e * HI is exact for any exponent. + const HI: f64 = 0.301_029_995_607_677_85; + const LO: f64 = std::f64::consts::LOG10_2 - HI; + let e = e as f64; + (e * HI + (e * LO + 2.0 * s * p * std::f64::consts::LOG10_E)) as f32 +} + +/// atan2 of finite f32 in f64, rounded once. Signed zeros follow C's atan2. +#[inline(always)] +pub fn atan2f(y: f32, x: f32) -> f32 { + atan2_f64(y as f64, x as f64) as f32 +} + +/// atan2 of finite values within 2^-37 relative: |y/x| folded to [0, 1], then to +/// |u| <= tan(pi/8) by atan(r) = pi/4 + atan((r - 1) / (r + 1)), and a Taylor series. +#[inline(always)] +pub fn atan2_f64(y: f64, x: f64) -> f64 { + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI}; + let (ay, ax) = (y.abs(), x.abs()); + let swap = ay > ax; + let (n, d) = if swap { (ax, ay) } else { (ay, ax) }; + let r = if d == 0.0 { 0.0 } else { n / d }; + let big = r > std::f64::consts::SQRT_2 - 1.0; + let u = if big { (r - 1.0) / (r + 1.0) } else { r }; + let u2 = u * u; + // (-1)^k / (2k + 1): the series to u^25, whose next term is under 2^-37 of atan(u). + const C: [f64; 13] = [ + 1.0, + -1.0 / 3.0, + 1.0 / 5.0, + -1.0 / 7.0, + 1.0 / 9.0, + -1.0 / 11.0, + 1.0 / 13.0, + -1.0 / 15.0, + 1.0 / 17.0, + -1.0 / 19.0, + 1.0 / 21.0, + -1.0 / 23.0, + 1.0 / 25.0, + ]; + let mut p = C[12]; + for &c in C[..12].iter().rev() { + p = c + u2 * p; + } + let mut a = u * p; + a = if big { FRAC_PI_4 + a } else { a }; + a = if swap { FRAC_PI_2 - a } else { a }; + a = if x.is_sign_negative() { PI - a } else { a }; + a.copysign(y) +} + +/// ln of a positive normal f64 to about 1 ULP: x = m * 2^e with m in [sqrt(1/2), sqrt(2)), +/// ln(m) from the atanh series. +#[inline(always)] +pub fn ln_f64(x: f64) -> f64 { + let bits = x.to_bits(); + let e = (bits.wrapping_sub(0x3fe6_a09e_667f_3bcd) as i64) >> 52; + let m = f64::from_bits(bits.wrapping_sub((e as u64) << 52)); + let s = (m - 1.0) / (m + 1.0); + let s2 = s * s; + // 2 s (1 + s^2/3 + ... + s^20/21), |s| <= 0.1716: the next term is under 2^-56 of the sum. + let mut p = 1.0 / 21.0; + for k in (0..10).rev() { + p = 1.0 / (2 * k + 1) as f64 + s2 * p; + } + const LN2_HI: f64 = 0.693_147_180_369_123_8; + const LN2_LO: f64 = std::f64::consts::LN_2 - LN2_HI; + let e = e as f64; + e * LN2_HI + (e * LN2_LO + 2.0 * s * p) +} + +/// e^t to about 1 ULP for t in -708..709 (clamped there): t = k ln2 + r with |r| <= ln2/2, e^r by its Taylor +/// series, times 2^k through the exponent bits. +#[inline(always)] +pub fn exp_f64(t: f64) -> f64 { + const LN2_HI: f64 = 0.693_147_180_369_123_8; + const LN2_LO: f64 = std::f64::consts::LN_2 - LN2_HI; + let t = t.clamp(-708.0, 709.0); + let k = (t * std::f64::consts::LOG2_E).round_ties_even(); + let r = (t - k * LN2_HI) - k * LN2_LO; + // 1/n! to n = 13: the next term is under 2^-57 at |r| = ln2/2. + let mut p = 1.0 / 6_227_020_800.0; + let mut f = 6_227_020_800.0; + for n in (1..13).rev() { + f /= (n + 1) as f64; + p = 1.0 / f + r * p; + } + let y = 1.0 + r * p; + // 2^k from the low bits of k + 1023 + 2^52: an `as` cast saturates and keeps the loop scalar. + y * f64::from_bits((k + (1023.0 + 4_503_599_627_370_496.0)).to_bits() << 52) +} + +/// ln(1 + u) for u > -1, accurate for small u: ln(w) * u / (w - 1) with w = 1 + u cancels the +/// rounding of w (Goldberg). +#[inline(always)] +pub fn log1p_f64(u: f64) -> f64 { + let w = 1.0 + u; + let l = ln_f64(w) * (u / (w - 1.0)); + if w == 1.0 { u } else { l } +} + +/// e^x - 1, accurate for small x: (w - 1) * x / ln(w) with w = e^x (Kahan). +#[inline(always)] +pub fn expm1_f64(x: f64) -> f64 { + let w = exp_f64(x); + let m = (w - 1.0) * (x / ln_f64(w)); + if w == 1.0 { + x + } else if x < -30.0 { + -1.0 + } else { + m + } +} + +/// tanh(t) = expm1(2t) / (expm1(2t) + 2), with |t| capped where it is +-1 in f64. +#[inline(always)] +pub fn tanh_f64(t: f64) -> f64 { + let e = expm1_f64(2.0 * t.clamp(-20.0, 20.0)); + e / (e + 2.0) +} + +/// log10 of a positive normal f64. +#[inline(always)] +pub fn log10_f64(x: f64) -> f64 { + ln_f64(x) * std::f64::consts::LOG10_E +} + +/// 10^x. +#[inline(always)] +pub fn exp10_f64(x: f64) -> f64 { + exp_f64(x * std::f64::consts::LN_10) +} + +/// x^y for x >= 0 (or NaN) and y > 0: e^(y ln x), 0 at x = 0. The relative error grows with +/// |y ln x|, so the result is for rounding to f32, where it is almost always correct. +#[inline(always)] +pub fn pow_f64(x: f64, y: f64) -> f64 { + let v = exp_f64(y * ln_f64(x)); + if x == 0.0 { + 0.0 + } else if x.is_nan() || x == f64::INFINITY { + x + } else { + v + } +} + +/// Cube root of a non-negative f32 (NaN and +inf pass through): an exponent-third estimate +/// in f64, then three Halley steps, rounded once to f32. The test checks a sample against +/// the correctly rounded value. +#[inline(always)] +pub fn cbrtf(x: f32) -> f32 { + let d = x as f64; + // fdlibm's estimate: a third of the bits plus a bias puts the exponent at e/3, within 7%. + let mut y = f64::from_bits(d.to_bits() / 3 + 0x2a9f_7893_0000_0000); + for _ in 0..3 { + let y3 = y * y * y; + y *= (y3 + 2.0 * d) / (2.0 * y3 + d); + } + if x == 0.0 || !x.is_finite() { + x + } else { + y as f32 + } +} + +/// NumPy's float32 `exp` on x86 with AVX2 or AVX-512, which runs its own SIMD code, not libm: +/// a Cody-Waite reduction by ln 2 and a rational polynomial, every step fused. Other platforms +/// run libm's expf. +// The constants are NumPy's literals, rounded to f32 as its C compiler rounds them. +#[allow(clippy::excessive_precision)] +#[inline(always)] +pub fn np_expf(x: f32) -> f32 { + const XMAX: f32 = 88.722_839_355_468_75; + const XMIN: f32 = -103.972_084_045_410_156_25; + const MAGIC: f32 = 12_582_912.0; + const LN2_HI: f32 = -6.931_457_52e-1; + const LN2_LO: f32 = -1.428_606_77e-6; + let (nan, big, small) = (x.is_nan(), x >= XMAX, x <= XMIN); + let x = if nan || big || small { 0.0 } else { x }; + let q = (x * std::f32::consts::LOG2_E + MAGIC) - MAGIC; + let r = q.mul_add(LN2_LO, q.mul_add(LN2_HI, x)); + let num = 5.082_762_527_590_693_718_096e-4f32 + .mul_add(r, 6.757_896_990_527_504_603_057e-3) + .mul_add(r, 5.114_512_081_637_298_353_406e-2) + .mul_add(r, 2.473_615_434_895_520_810_817e-1) + .mul_add(r, 7.257_664_613_233_124_478_488e-1) + .mul_add(r, 9.999_999_999_980_870_924_916e-1); + let den = 2.159_509_375_685_829_852_307e-2f32 + .mul_add(r, -2.742_335_390_411_667_452_936e-1) + .mul_add(r, 1.0); + // AVX-512's scalef rounds once, also into the subnormals; the product is exact in f64. + let scale = f64::from_bits((q as f64 + (1023.0 + 4_503_599_627_370_496.0)).to_bits() << 52); + let y = ((num / den) as f64 * scale) as f32; + if nan { + f32::NAN + } else if big { + f32::INFINITY + } else if small { + 0.0 + } else { + y + } +} + +/// NumPy's float32 `log` on x86 with AVX2 or AVX-512, its own SIMD code: the mantissa folded +/// into [sqrt(1/2), sqrt(2)), a rational polynomial for log(1 + x), every step fused. Negative +/// input gives -NaN, as NumPy's does. +#[allow(clippy::excessive_precision)] +#[inline(always)] +pub fn np_logf(x: f32) -> f32 { + use std::f32::consts::{FRAC_1_SQRT_2, LN_2}; + let (neg, zero, inf, nan) = (x < 0.0, x == 0.0, x == f32::INFINITY, x.is_nan()); + // Subnormals scale by 2^100 first, exactly. + let sub = x < f32::MIN_POSITIVE; + let v = if sub { + x * f32::from_bits(0x7180_0000) + } else { + x + }; + let bits = v.to_bits(); + let e = ((bits >> 23) & 0xff) as i32 - 126 - if sub { 100 } else { 0 }; + let m = f32::from_bits((bits & 0x7f_ffff) | (126 << 23)); + let fold = m <= FRAC_1_SQRT_2; + let m = if fold { m + m } else { m }; + let e = (if fold { e - 1 } else { e }) as f32; + let r = m - 1.0; + let num = 2.589_979_117_907_922_693_523e-2f32 + .mul_add(r, 3.808_837_741_388_407_920_751e-1) + .mul_add(r, 1.480_000_633_576_506_585_156) + .mul_add(r, 2.112_677_543_073_053_063_722) + .mul_add(r, 9.999_999_999_999_998_702_752e-1) + .mul_add(r, 0.0); + let den = 5.875_095_403_124_574_342_950e-3f32 + .mul_add(r, 1.546_476_374_983_906_719_538e-1) + .mul_add(r, 9.864_942_958_519_418_960_339e-1) + .mul_add(r, 2.453_006_071_784_736_363_091) + .mul_add(r, 2.612_677_543_073_109_236_779) + .mul_add(r, 1.0); + let y = e.mul_add(LN_2, num / den); + if nan { + f32::NAN + } else if neg { + -f32::NAN + } else if zero { + f32::NEG_INFINITY + } else if inf { + f32::INFINITY + } else { + y + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn np_expf_matches_numpy() { + // np.exp of a float32 array on an AVX-512 x86 CPU, as bits. + for (x, want) in [ + (0.0f32, 0x3f80_0000u32), + (1.0, 0x402d_f855), + (-1.0, 0x3ebc_5ab1), + (0.5, 0x3fd3_094c), + (10.0, 0x46ac_14ef), + (-50.0, 0x1b69_2beb), + (88.0, 0x7ef8_82b7), + (-90.0, 0x0008_ec28), + ] { + assert_eq!(np_expf(x).to_bits(), want, "{x}"); + } + assert_eq!(np_expf(89.0), f32::INFINITY); + assert_eq!(np_expf(-104.0), 0.0); + assert!(np_expf(f32::NAN).is_nan()); + } + + #[test] + fn np_logf_matches_numpy() { + // np.log of a float32 array on an AVX-512 x86 CPU, as bits. + for (x, want) in [ + (1.0f32, 0x0000_0000u32), + (2.0, 0x3f31_7218), + (0.5, 0xbf31_7218), + (0.1, 0xc013_5d8e), + (0.75, 0xbe93_4b11), + (3.0e-5, 0xc126_a107), + (1e30, 0x428a_27b5), + (0.707_106_77, 0xbeb1_7218), + (1e-40, 0xc2b8_34f2), + (123.456, 0x409a_1bbb), + (-1.0, 0xffc0_0000), + ] { + assert_eq!(np_logf(x).to_bits(), want, "{x}"); + } + assert_eq!(np_logf(0.0), f32::NEG_INFINITY); + assert_eq!(np_logf(f32::INFINITY), f32::INFINITY); + } + + #[test] + fn cbrtf_is_correctly_rounded_on_a_sample() { + let mut bits = 1u32; + while bits < f32::INFINITY.to_bits() { + let x = f32::from_bits(bits); + assert_eq!(cbrtf(x), libm::cbrt(x as f64) as f32, "{x:e}"); + bits += 9973; + } + assert_eq!(cbrtf(0.0), 0.0); + assert_eq!(cbrtf(27.0), 3.0); + assert!(cbrtf(f32::NAN).is_nan()); + assert_eq!(cbrtf(f32::INFINITY), f32::INFINITY); + } + + #[test] + fn log10f_is_correctly_rounded_on_a_sample() { + let mut bits = 1e-6f32.to_bits(); + while bits <= 1f32.to_bits() { + let x = f32::from_bits(bits); + assert_eq!(log10f(x), libm::log10(x as f64) as f32, "{x:e}"); + bits += 997; + } + for x in [ + 1.0f32, + 0.1, + 0.01, + 1e-6, + 0.5, + 2.0, + 10.0, + 1e30, + f32::MIN_POSITIVE, + ] { + assert_eq!(log10f(x), libm::log10(x as f64) as f32, "{x:e}"); + } + } +} + +#[cfg(test)] +mod atan2_tests { + use super::atan2f; + + #[test] + fn atan2f_is_within_one_ulp_and_keeps_c_zeros() { + let mut state = 0x2545_f491_4f6c_dd1du64; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + ((state >> 40) as f32 / (1u64 << 24) as f32) * 4.0 - 2.0 + }; + let mut off = 0; + for _ in 0..1_000_000 { + let (y, x) = (next(), next()); + let want = libm::atan2(y as f64, x as f64) as f32; + let got = atan2f(y, x); + let ulp = (got.to_bits() as i64 - want.to_bits() as i64).abs(); + assert!(ulp <= 1, "{y} {x}"); + off += (ulp > 0) as usize; + } + assert!(off < 10, "{off}"); + let pi = std::f32::consts::PI; + for (y, x, want) in [ + (0.0, 0.0, 0.0), + (-0.0, 0.0, -0.0), + (0.0, -0.0, pi), + (-0.0, -0.0, -pi), + (1.0, 0.0, pi / 2.0), + ] { + assert_eq!(atan2f(y, x).to_bits(), want.to_bits(), "{y} {x}"); + } + } +} + +#[cfg(test)] +mod pow_tests { + use super::pow_f64; + + #[test] + fn pow_f64_rounds_to_the_same_f32() { + let mut state = 0x9e37_79b9_7f4a_7c15u64; + let mut off = 0; + for _ in 0..1_000_000 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let x = f32::from_bits((state >> 33) as u32 % 0x3f80_0001) as f64; + for y in [563.0 / 256.0, 256.0 / 563.0, 1.0 / 3.0] { + let (got, want) = (pow_f64(x, y) as f32, libm::pow(x, y) as f32); + let ulp = (got.to_bits() as i64 - want.to_bits() as i64).abs(); + assert!(ulp <= 1, "{x} {y}"); + off += ulp as usize; + } + } + assert!(off <= 3, "{off}"); + assert_eq!(pow_f64(0.0, 2.2), 0.0); + assert!(pow_f64(f64::NAN, 2.2).is_nan()); + assert_eq!(pow_f64(1.0, 2.2), 1.0); + } +} + +#[cfg(test)] +mod f64_tests { + use super::*; + + fn rel(a: f64, b: f64) -> f64 { + if a == b { 0.0 } else { ((a - b) / b).abs() } + } + + #[test] + fn float64_helpers_match_libm() { + let mut worst = [0f64; 5]; + let mut x = -12.0f64; + while x < 12.0 { + worst[0] = worst[0].max(rel( + log1p_f64(x.exp() * 0.999), + libm::log1p(x.exp() * 0.999), + )); + worst[1] = worst[1].max(rel(expm1_f64(x * 0.05), libm::expm1(x * 0.05))); + worst[2] = worst[2].max(rel(tanh_f64(x * 0.3), libm::tanh(x * 0.3))); + worst[3] = worst[3].max(rel(log10_f64(x.exp()), libm::log10(x.exp()))); + worst[4] = worst[4].max(rel(exp10_f64(x * 0.25), libm::exp10(x * 0.25))); + x += 0.000_37; + } + for (i, w) in worst.iter().enumerate() { + assert!(*w < 1e-14, "{i}: {w:e}"); + } + assert_eq!(log1p_f64(1e-300), 1e-300); + assert_eq!(expm1_f64(1e-300), 1e-300); + assert_eq!(tanh_f64(1e5), 1.0); + } +} + +#[cfg(test)] +mod exhaustive { + /// `cargo test --release -- --ignored`. + #[test] + #[ignore] + fn all() { + let (lo, hi) = (f32::MIN_POSITIVE.to_bits(), f32::MAX.to_bits()); + let mut bad = 0u64; + for b in lo..=hi { + let x = f32::from_bits(b); + if super::log10f(x) != libm::log10(x as f64) as f32 { + bad += 1; + if bad < 20 { + eprintln!("{b:#x} {x:e}"); + } + } + } + eprintln!("checked {} bad {bad}", hi - lo + 1); + } +} diff --git a/negpy_core/src/median.rs b/negpy_core/src/median.rs new file mode 100644 index 000000000..a3b2d96fe --- /dev/null +++ b/negpy_core/src/median.rs @@ -0,0 +1,263 @@ +//! `cv2.medianBlur` for single-channel uint8: the median of a `k` x `k` window, edges +//! replicated as OpenCV does. A median of integers is exact, so only the border rule matters. + +// The steps index several per-column arrays of one row in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +pub fn median_blur_u8(src: &[u8], dst: &mut [u8], h: usize, w: usize, k: usize) -> Option<()> { + if h == 0 + || w == 0 + || k == 0 + || k.is_multiple_of(2) + || src.len() != h * w + || dst.len() != src.len() + { + return None; + } + match k { + 1 => dst.copy_from_slice(src), + 3 => median3(src, dst, h, w), + 5 => small(src, dst, h, w, k), + _ => histogram(src, dst, h, w, k), + } + Some(()) +} + +/// 3 x 3: each column's three values sorted, then the median of the nine as the middle of +/// (max of the lows, median of the middles, min of the highs) of three neighboring columns. +fn median3(src: &[u8], dst: &mut [u8], h: usize, w: usize) { + dst.par_chunks_mut(w).enumerate().for_each_init( + || vec![0u8; 3 * (w + 2)], + |rows, (y, out)| { + for (dy, padded) in rows.chunks_exact_mut(w + 2).enumerate() { + let row = &src[(y + dy).saturating_sub(1).min(h - 1) * w..][..w]; + padded[1..=w].copy_from_slice(row); + padded[0] = row[0]; + padded[w + 1] = row[w - 1]; + } + median3_row(rows, out); + }, + ); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn median3_row(rows: &[u8], out: &mut [u8]) { + let w = out.len(); + let pw = w + 2; + let (r0, rest) = rows.split_at(pw); + let (r1, r2) = rest.split_at(pw); + let (mut lo, mut mid, mut hi) = (vec![0u8; pw], vec![0u8; pw], vec![0u8; pw]); + for x in 0..pw { + let (a, b, c) = (r0[x], r1[x], r2[x]); + let (a, b) = (a.min(b), a.max(b)); + let (b, c) = (b.min(c), b.max(c)); + let (a, b) = (a.min(b), a.max(b)); + lo[x] = a; + mid[x] = b; + hi[x] = c; + } + let med3 = |a: u8, b: u8, c: u8| a.min(b).max(a.max(b).min(c)); + for x in 0..w { + let l = lo[x].max(lo[x + 1]).max(lo[x + 2]); + let m = med3(mid[x], mid[x + 1], mid[x + 2]); + let u = hi[x].min(hi[x + 1]).min(hi[x + 2]); + out[x] = med3(l, m, u); + } +} + +/// Windows up to 5 x 5, a row at a time: the median is the largest t that at least k^2/2 + 1 +/// of the window's values reach, found bit by bit, so every step is a flat loop over the row. +fn small(src: &[u8], dst: &mut [u8], h: usize, w: usize, k: usize) { + let r = k / 2; + dst.par_chunks_mut(w).enumerate().for_each_init( + || vec![0u8; k * (w + 2 * r)], + |rows, (y, out)| { + // The k source rows, each padded by r replicated pixels on both sides. + let pw = w + 2 * r; + for (dy, padded) in rows.chunks_exact_mut(pw).enumerate() { + let sy = (y + dy).saturating_sub(r).min(h - 1); + let row = &src[sy * w..][..w]; + padded[r..r + w].copy_from_slice(row); + padded[..r].fill(row[0]); + padded[r + w..].fill(row[w - 1]); + } + small_row(rows, pw, k, out); + }, + ); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn small_row(rows: &[u8], pw: usize, k: usize, out: &mut [u8]) { + let w = out.len(); + let need = (k * k / 2 + 1) as u8; + out.fill(0); + let mut count = vec![0u8; w]; + for bit in (0..8).rev() { + count.fill(0); + for dy in 0..k { + let row = &rows[dy * pw..][..pw]; + for dx in 0..k { + let s = &row[dx..dx + w]; + for x in 0..w { + count[x] += (s[x] >= (out[x] | 1 << bit)) as u8; + } + } + } + for x in 0..w { + if count[x] >= need { + out[x] |= 1 << bit; + } + } + } +} + +/// Columns per stripe: a stripe's column histograms stay in cache. +const STRIPE: usize = 512; +/// Output rows per task. +const BAND: usize = 128; + +/// Perreault and Hebert's constant-time median: column histograms slid down the rows, a 16-bin +/// coarse window histogram slid along each row, and each 16-bin fine segment brought up to date +/// only when the search enters it. Counts fit u16 for any window OpenCV allows. +fn histogram(src: &[u8], dst: &mut [u8], h: usize, w: usize, k: usize) { + let tasks: Vec<(usize, usize)> = (0..h.div_ceil(BAND)) + .flat_map(|b| (0..w.div_ceil(STRIPE)).map(move |s| (b, s))) + .collect(); + let parts: Vec<(usize, usize, Vec)> = tasks + .into_par_iter() + .map(|(b, s)| { + let (y0, x0) = (b * BAND, s * STRIPE); + let (rows, cols) = (BAND.min(h - y0), STRIPE.min(w - x0)); + let mut out = vec![0u8; rows * cols]; + histogram_tile(src, (h, w), k, (y0, x0), cols, &mut out); + (y0, x0, out) + }) + .collect(); + for (y0, x0, out) in parts { + let cols = STRIPE.min(w - x0); + for (dy, row) in out.chunks_exact(cols).enumerate() { + dst[(y0 + dy) * w + x0..][..cols].copy_from_slice(row); + } + } +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn histogram_tile( + src: &[u8], + (h, w): (usize, usize), + k: usize, + (y0, x0): (usize, usize), + cols: usize, + out: &mut [u8], +) { + let r = k / 2; + let rank = (k * k / 2) as u16; + let cy = |v: isize| v.clamp(0, h as isize - 1) as usize; + // Column histograms for the stripe and r columns either side, replicated at the edges. + // Fine ones by coarse bin, then column: the window slides along one contiguous run. + let span = cols + 2 * r; + let mut fine = vec![[0u16; 16]; 16 * span]; + let mut coarse = vec![[0u16; 16]; span]; + let mut padded = vec![0u8; span]; + let fill = |y: usize, padded: &mut [u8]| { + let row = &src[y * w..][..w]; + for (i, p) in padded.iter_mut().enumerate() { + *p = row[(x0 + i).saturating_sub(r).min(w - 1)]; + } + }; + let add = |fine: &mut [[u16; 16]], coarse: &mut [[u16; 16]], padded: &[u8], up: bool| { + for (i, (&v, c)) in padded.iter().zip(coarse.iter_mut()).enumerate() { + let (hi, lo) = ((v >> 4) as usize, (v & 15) as usize); + let f = &mut fine[hi * span + i][lo]; + if up { + *f += 1; + c[hi] += 1; + } else { + *f -= 1; + c[hi] -= 1; + } + } + }; + for dy in -(r as isize)..=r as isize { + fill(cy(y0 as isize + dy), &mut padded); + add(&mut fine, &mut coarse, &padded, true); + } + for (dy, out) in out.chunks_exact_mut(cols).enumerate() { + let y = (y0 + dy) as isize; + if dy > 0 { + fill(cy(y - r as isize - 1), &mut padded); + add(&mut fine, &mut coarse, &padded, false); + fill(cy(y + r as isize), &mut padded); + add(&mut fine, &mut coarse, &padded, true); + } + // Output column x reads histogram columns x ..= x + 2r. + let mut win = [0u16; 16]; + for c in &coarse[..k] { + for b in 0..16 { + win[b] += c[b]; + } + } + let mut seg = [[0u16; 16]; 16]; + // The output column each fine segment was last summed for. + let mut synced = [usize::MAX; 16]; + for (x, o) in out.iter_mut().enumerate() { + let mut seen = 0u16; + let mut b = 0; + for (i, &c) in win.iter().enumerate() { + b = i; + if seen + c > rank { + break; + } + seen += c; + } + let run = &fine[b * span..][..span]; + let s = &mut seg[b]; + if synced[b] == usize::MAX || synced[b] + k <= x { + *s = [0; 16]; + for col in &run[x..x + k] { + for i in 0..16 { + s[i] += col[i]; + } + } + } else { + for j in synced[b] + 1..=x { + let (gone, new) = (&run[j - 1], &run[j + 2 * r]); + for i in 0..16 { + s[i] = s[i].wrapping_add(new[i]).wrapping_sub(gone[i]); + } + } + } + synced[b] = x; + let mut v = 0; + for (i, &c) in s.iter().enumerate() { + v = i; + if seen + c > rank { + break; + } + seen += c; + } + *o = ((b << 4) + v) as u8; + if x + 1 < cols { + let (gone, new) = (&coarse[x], &coarse[x + k]); + for i in 0..16 { + win[i] = win[i].wrapping_add(new[i]).wrapping_sub(gone[i]); + } + } + } + } +} diff --git a/negpy_core/src/memo.rs b/negpy_core/src/memo.rs new file mode 100644 index 000000000..65d99df40 --- /dev/null +++ b/negpy_core/src/memo.rs @@ -0,0 +1,118 @@ +//! Last results of detection work that several callers repeat on one frame: the base-log +//! meter, auto crop and the skew fit each find the film box on the same buffer. + +use rayon::prelude::*; +use std::sync::Mutex; + +const SEEDS: [u64; 2] = [0x9e37_79b9_7f4a_7c15, 0xd6e8_feb8_6659_fd93]; + +fn fold(mut h: [u64; 2], v: u64) -> [u64; 2] { + const P: u64 = 0xa076_1d64_78bd_642f; + for (s, seed) in h.iter_mut().zip(SEEDS) { + let r = ((*s ^ v) as u128) * ((P ^ seed) as u128); + *s = (r as u64) ^ ((r >> 64) as u64); + } + h +} + +fn join(h: [u64; 2]) -> u128 { + (h[0] as u128) << 64 | h[1] as u128 +} + +/// A 128-bit key of a float32 buffer's bits and shape: two seeded multiply-mix chains per block, +/// blocks in parallel, folded in order. Not for adversarial input. +pub fn key(data: &[f32], shape: &[usize]) -> u128 { + let blocks: Vec<[u64; 2]> = data + .par_chunks(1 << 16) + .map(|block| { + let (pairs, rest) = block.as_chunks::<2>(); + let h = pairs.iter().fold(SEEDS, |h, p| { + fold(h, (p[0].to_bits() as u64) << 32 | p[1].to_bits() as u64) + }); + rest.iter().fold(h, |h, v| fold(h, v.to_bits() as u64)) + }) + .collect(); + let h = shape + .iter() + .map(|&d| d as u64) + .chain([data.len() as u64]) + .fold(SEEDS, fold); + join(blocks.into_iter().flatten().fold(h, fold)) +} + +/// `key` extended by more values, such as the box a call refines. +pub fn extend(key: u128, extra: &[i64]) -> u128 { + let h = [(key >> 64) as u64, key as u64]; + join(extra.iter().fold(h, |h, &v| fold(h, v as u64))) +} + +/// The `N` most recently used values by key. +pub struct Memo { + slots: Mutex>, +} + +impl Default for Memo { + fn default() -> Self { + Self::new() + } +} + +impl Memo { + pub const fn new() -> Self { + Memo { + slots: Mutex::new(Vec::new()), + } + } + + /// The value under `key`, or `f()` stored under it. `f` runs unlocked, so two callers may + /// both compute a missing value; both get the same result. + pub fn get_or(&self, key: u128, f: impl FnOnce() -> V) -> V { + let lock = || self.slots.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut slots = lock(); + if let Some(i) = slots.iter().position(|(k, _)| *k == key) { + let hit = slots.remove(i); + slots.insert(0, hit); + return slots[0].1.clone(); + } + } + let v = f(); + let mut slots = lock(); + slots.retain(|(k, _)| *k != key); + slots.insert(0, (key, v.clone())); + slots.truncate(N); + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_sees_every_bit_and_the_shape() { + let a: Vec = (0..200_000).map(|i| i as f32 * 0.25).collect(); + let k = key(&a, &[400, 500]); + assert_eq!(k, key(&a.clone(), &[400, 500])); + assert_ne!(k, key(&a, &[500, 400])); + let mut b = a.clone(); + b[123_457] = f32::from_bits(b[123_457].to_bits() ^ 1); + assert_ne!(k, key(&b, &[400, 500])); + let mut c = a.clone(); + c[0] = -0.0; + assert_ne!(key(&c, &[400, 500]), { + c[0] = 0.0; + key(&c, &[400, 500]) + }); + } + + #[test] + fn memo_keeps_the_last_n() { + let m: Memo = Memo::new(); + assert_eq!(m.get_or(1, || 10), 10); + assert_eq!(m.get_or(2, || 20), 20); + assert_eq!(m.get_or(1, || 99), 10); + assert_eq!(m.get_or(3, || 30), 30); + assert_eq!(m.get_or(2, || 21), 21); + } +} diff --git a/negpy_core/src/meter.rs b/negpy_core/src/meter.rs new file mode 100644 index 000000000..04a2da2c0 --- /dev/null +++ b/negpy_core/src/meter.rs @@ -0,0 +1,335 @@ +//! The textured-cell meters of negpy/features/exposure/normalization.py: anchor, textural +//! range, shadow reach and highlight hold, read off the block-median grid. + +use crate::stats::{np_median, np_percentiles, np_percentiles_f64, np_quantile, pairwise_sum_f32}; +use rayon::prelude::*; + +const LUMA_R: f32 = 0.2126; +const LUMA_G: f32 = 0.7152; +const LUMA_B: f32 = 0.0722; + +/// One channel of `(grid - floor) / span`. A float64 operand (a NumPy float64 scalar) makes +/// NumPy compute that step in float64; a Python float joins the float32 math (NEP 50). +#[derive(Clone, Copy)] +pub struct ChannelNorm { + pub floor: f64, + pub floor_wide: bool, + pub span: f64, + pub span_wide: bool, +} + +impl ChannelNorm { + fn apply(&self, g: f32) -> f32 { + if self.floor_wide || self.span_wide { + let d = if self.floor_wide { + g as f64 - self.floor + } else { + (g - self.floor as f32) as f64 + }; + (d / self.span) as f32 + } else { + (g - self.floor as f32) / self.span as f32 + } + } +} + +/// The float32 luma of an HxWx3 grid, each channel normalized first when `norm` is given. +pub fn luma(grid: &[f32], norm: Option<&[ChannelNorm; 3]>) -> Vec { + grid.as_chunks::<3>() + .0 + .iter() + .map(|p| { + let [r, g, b] = match norm { + Some(n) => [n[0].apply(p[0]), n[1].apply(p[1]), n[2].apply(p[2])], + None => *p, + }; + LUMA_R * r + LUMA_G * g + LUMA_B * b + }) + .collect() +} + +/// The activity gate: sectors of 2x2 blocks of `block` x `block` cells vote when their four +/// block means span more than `gate`. +#[derive(Clone, Copy)] +pub struct Activity { + pub block: usize, + pub gate: f32, + pub min_fraction: f64, +} + +/// `_textured_cells(lum)` of an HxW luma plane: the cells of the voting sectors in row order, +/// or every cell when fewer than `min_fraction` of the sectors vote. +pub fn textured_cells(lum: Vec, h: usize, w: usize, a: Activity) -> Vec { + let b = a.block; + let (hs, ws) = ((h / (2 * b)) * 2 * b, (w / (2 * b)) * 2 * b); + if hs == 0 || ws == 0 { + return lum; + } + let (bh, bw) = (hs / b, ws / b); + // NumPy's block mean: each row of the block summed pairwise, the rows added in order. + let mut means = vec![0f32; bh * bw]; + for (i, m) in means.iter_mut().enumerate() { + let (by, bx) = (i / bw, i % bw); + let mut sum = 0f32; + for y in by * b..(by + 1) * b { + sum += pairwise_sum_f32(&lum[y * w + bx * b..y * w + (bx + 1) * b]); + } + *m = sum / (b * b) as f32; + } + let (sh, sw) = (bh / 2, bw / 2); + let active: Vec = (0..sh * sw) + .map(|i| { + let (sy, sx) = (i / sw, i % sw); + let quad = [ + means[2 * sy * bw + 2 * sx], + means[2 * sy * bw + 2 * sx + 1], + means[(2 * sy + 1) * bw + 2 * sx], + means[(2 * sy + 1) * bw + 2 * sx + 1], + ]; + let (lo, hi) = quad + .iter() + .fold((f32::INFINITY, f32::NEG_INFINITY), |(l, h), &v| { + (np_min(l, v), np_max(h, v)) + }); + hi - lo > a.gate + }) + .collect(); + let voting = active.iter().filter(|&&v| v).count(); + if (voting as f64 / active.len() as f64) < a.min_fraction { + return lum; + } + let mut out = Vec::with_capacity(voting * 4 * b * b); + for y in 0..hs { + let sy = y / (2 * b); + for x in 0..ws { + if active[sy * sw + x / (2 * b)] { + out.push(lum[y * w + x]); + } + } + } + out +} + +/// np.min / np.max with NaN propagation. +fn np_min(a: f32, b: f32) -> f32 { + if a.is_nan() || b.is_nan() { + f32::NAN + } else { + a.min(b) + } +} + +fn np_max(a: f32, b: f32) -> f32 { + if a.is_nan() || b.is_nan() { + f32::NAN + } else { + a.max(b) + } +} + +/// `np.percentile(lum, q)` for a Python float q: float32 interpolation. +pub fn percentile(lum: &[f32], q: f64) -> f32 { + np_percentiles(lum, &[q]).map_or(f32::NAN, |p| p[0]) +} + +/// The anchor meter's measurement: the mean of the cells inside the `clip` percentile trim +/// (float32) averaged with the trim's midpoint. +pub fn anchor_measure(lum: &[f32], clip: f64) -> f64 { + let p = np_percentiles_f64(lum, &[clip, 100.0 - clip]); + let (lo, hi) = (p[0], p[1]); + let inner: Vec = lum + .iter() + .copied() + .filter(|&v| v as f64 >= lo && v as f64 <= hi) + .collect(); + let mean = pairwise_sum_f32(&inner) / inner.len() as f32; + 0.5 * (mean as f64 + 0.5 * (lo + hi)) +} + +/// The textural range: the spread between the `clip` and `100 - clip` percentiles. +pub fn textural_range(lum: &[f32], clip: f64) -> f64 { + let p = np_percentiles_f64(lum, &[clip, 100.0 - clip]); + (p[1] - p[0]).abs() +} + +/// The constants of `measure_neutral_axis_from_log`. Bands and subject sectors compare in +/// float32, as NumPy compares a float32 array with a Python float. +pub struct NeutralConsts { + pub quantile: f64, + pub cap: f64, + pub first_pass_cap: f64, + pub min_pixels: usize, + pub highlight: (f32, f32), + pub mid: (f32, f32), + pub shadow: (f32, f32), + /// Foliage and sky: (chroma floor, hue low, hue high) in degrees. + pub subjects: [(f32, f32, f32); 2], + pub n0: f64, + /// (dead zone, scale) of the mid/shadow agreement and of the offset width. + pub agreement: (f64, f64), + pub width: (f64, f64), +} + +pub type Refs = [f64; 3]; + +pub struct NeutralAxis { + pub mid: Refs, + pub shadow: Refs, + pub highlight: Option, + pub confidence: f64, +} + +struct BandRefs { + refs: Refs, + chroma: f64, + size: usize, + width: f64, +} + +/// `_rms_chroma` of one normalized triplet, in float32. +fn rms_chroma(n: [f32; 3]) -> f32 { + let [r, g, b] = n; + ((((r - g) * (r - g) + (g - b) * (g - b)) + (r - b) * (r - b)) / 3.0).sqrt() +} + +/// `_subject_cells` for one normalized triplet: foliage or sky by hue and chroma. +fn is_subject(n: [f32; 3], subjects: &[(f32, f32, f32); 2]) -> bool { + let (dr, db) = (n[0] - n[1], n[2] - n[1]); + let (t, s) = ((dr + db) / 2.0, (dr - db) / 2.0); + // np.hypot of float32 rounds the exact hypotenuse once. + let chroma = ((t as f64) * (t as f64) + (s as f64) * (s as f64)).sqrt() as f32; + if !subjects.iter().any(|&(c, _, _)| chroma > c) { + return false; + } + let hue = crate::mathf::atan2f(-s, t) * (180.0f32 / std::f32::consts::PI); + subjects + .iter() + .any(|&(c, lo, hi)| chroma > c && hue > lo && hue < hi) +} + +/// `measure_neutral_axis_from_log` over a block-median grid (HxWx3 raw log), after its crop. +/// `floors32`/`ceils32` are the bounds as `normalize_log_image` reads them, `floors`/`ceils` +/// as Python floats. +pub fn neutral_axis( + grid: &[f32], + (floors32, ceils32): ([f32; 3], [f32; 3]), + (floors, ceils): (Refs, Refs), + exclude_subjects: bool, + c: &NeutralConsts, +) -> Option { + let mut norm_all = vec![0f32; grid.len()]; + crate::analysis::normalize_log_image(grid, floors32, ceils32, &mut norm_all); + let (norm_all, log_all) = (norm_all.as_chunks::<3>().0, grid.as_chunks::<3>().0); + let keep: Vec = (0..norm_all.len()) + .into_par_iter() + .filter(|&i| !exclude_subjects || !is_subject(norm_all[i], &c.subjects)) + .collect(); + let norm: Vec<[f32; 3]> = keep.par_iter().map(|&i| norm_all[i]).collect(); + let log: Vec<[f32; 3]> = keep.par_iter().map(|&i| log_all[i]).collect(); + let lum: Vec = norm + .par_iter() + .map(|n| LUMA_R * n[0] + LUMA_G * n[1] + LUMA_B * n[2]) + .collect(); + + let band_refs = |(lo, hi): (f32, f32), chroma: &[f32], cap: f64| -> Option { + let band: Vec = (0..lum.len()) + .into_par_iter() + .filter(|&i| lum[i] >= lo && lum[i] <= hi) + .collect(); + if band.len() < c.min_pixels { + return None; + } + let band_chroma: Vec = band.iter().map(|&i| chroma[i]).collect(); + let thr = np_quantile(&band_chroma, c.quantile)?; + let idx: Vec = band.into_iter().filter(|&i| chroma[i] <= thr).collect(); + let near_neutral = if idx.is_empty() { + cap + } else { + np_median(&mut idx.iter().map(|&i| chroma[i]).collect::>()) as f64 + }; + if idx.len() < c.min_pixels || near_neutral > cap { + return None; + } + let median_of = |f: &dyn Fn(usize) -> f32| { + np_median(&mut idx.iter().map(|&i| f(i)).collect::>()) as f64 + }; + let green = median_of(&|i| log[i][1]); + let refs = [ + green + median_of(&|i| log[i][0] - log[i][1]), + green, + green + median_of(&|i| log[i][2] - log[i][1]), + ]; + let width_of = |ch: usize| { + let off: Vec = idx.iter().map(|&i| norm[i][ch] - norm[i][1]).collect(); + let p = np_percentiles_f64(&off, &[10.0, 90.0]); + p[1] - p[0] + }; + let (w0, w2) = (width_of(0), width_of(2)); + Some(BandRefs { + refs, + chroma: near_neutral, + size: idx.len(), + // np.max propagates NaN. + width: if w0.is_nan() || w2.is_nan() { + f64::NAN + } else { + w0.max(w2) + }, + }) + }; + let norm_ref = |refs: Refs| -> Refs { + std::array::from_fn(|ch| { + let mut denom = ceils[ch] - floors[ch]; + if denom.abs() < 1e-6 { + denom = if denom >= 0.0 { 1e-6 } else { -1e-6 }; + } + (refs[ch] - floors[ch]) / denom + }) + }; + + let chroma1: Vec = norm.par_iter().map(|&n| rms_chroma(n)).collect(); + let mid1 = band_refs(c.mid, &chroma1, c.first_pass_cap)?; + let sh1 = band_refs(c.shadow, &chroma1, c.first_pass_cap)?; + let (nm, ns) = (norm_ref(mid1.refs), norm_ref(sh1.refs)); + let mut gain = [(1.0f32, 0.0f32); 3]; + for ch in [0, 2] { + let du = nm[ch] - ns[ch]; + let (a, b) = if du.abs() < 1e-6 { + (1.0, nm[1] - nm[ch]) + } else { + let a = (nm[1] - ns[1]) / du; + (a, nm[1] - a * nm[ch]) + }; + gain[ch] = (a as f32, b as f32); + } + let chroma2: Vec = norm + .par_iter() + .map(|n| { + rms_chroma([ + gain[0].0 * n[0] + gain[0].1, + n[1], + gain[2].0 * n[2] + gain[2].1, + ]) + }) + .collect(); + let mid = band_refs(c.mid, &chroma2, c.cap)?; + let shadow = band_refs(c.shadow, &chroma2, c.cap)?; + let highlight = band_refs(c.highlight, &chroma2, c.cap); + + let tight = (1.0 - mid.chroma.max(shadow.chroma) / c.cap).clamp(0.0, 1.0); + let size_term = mid.size as f64 / (mid.size as f64 + c.n0); + let (dm, ds) = (norm_ref(mid.refs), norm_ref(shadow.refs)); + let spread = [0, 2] + .map(|ch| ((dm[ch] - dm[1]) - (ds[ch] - ds[1])).abs()) + .into_iter() + .fold(f64::NEG_INFINITY, f64::max); + let agree = 1.0 - ((spread - c.agreement.0).max(0.0) / c.agreement.1).min(1.0); + let width = mid.width.max(shadow.width); + let single = 1.0 - ((width - c.width.0).max(0.0) / c.width.1).min(1.0); + Some(NeutralAxis { + mid: mid.refs, + shadow: shadow.refs, + highlight: highlight.map(|h| h.refs), + confidence: (tight * size_term * agree * single).clamp(0.0, 1.0), + }) +} diff --git a/negpy_core/src/morph.rs b/negpy_core/src/morph.rs new file mode 100644 index 000000000..2dde98f5c --- /dev/null +++ b/negpy_core/src/morph.rs @@ -0,0 +1,538 @@ +//! OpenCV's erode, dilate and morphologyEx for rectangular kernels. A min or max has no +//! rounding, so a separable row-then-column pass gives OpenCV's bits. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::filter::{Border, border_interpolate}; + +pub trait Pixel: Copy + PartialOrd + Send + Sync { + /// Erode's identity, OpenCV's default constant border for it. + const HIGH: Self; + /// Dilate's identity. + const LOW: Self; + fn sub(self, other: Self) -> Self; + fn lo(self, other: Self) -> Self; + fn hi(self, other: Self) -> Self; +} + +impl Pixel for u8 { + const HIGH: Self = u8::MAX; + const LOW: Self = u8::MIN; + fn sub(self, other: Self) -> Self { + self.saturating_sub(other) + } + fn lo(self, other: Self) -> Self { + self.min(other) + } + fn hi(self, other: Self) -> Self { + self.max(other) + } +} + +impl Pixel for f32 { + const HIGH: Self = f32::INFINITY; + const LOW: Self = f32::NEG_INFINITY; + fn sub(self, other: Self) -> Self { + self - other + } + fn lo(self, other: Self) -> Self { + if other < self { other } else { self } + } + fn hi(self, other: Self) -> Self { + if other > self { other } else { self } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Op { + Erode, + Dilate, + Open, + Close, + Gradient, + TopHat, + BlackHat, +} + +impl Op { + /// From `cv2.MORPH_*`. + pub fn from_cv(code: i32) -> Option { + Some(match code { + 0 => Op::Erode, + 1 => Op::Dilate, + 2 => Op::Open, + 3 => Op::Close, + 4 => Op::Gradient, + 5 => Op::TopHat, + 6 => Op::BlackHat, + _ => return None, + }) + } +} + +#[inline(always)] +fn pick(a: T, b: T) -> T { + if ERODE { a.lo(b) } else { a.hi(b) } +} + +/// `out[i] = extreme(out[i], line[i])`; AVX2 doubles the lanes of the baseline build. +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn extreme_into(out: &mut [T], line: &[T]) { + for (o, &v) in out.iter_mut().zip(line) { + *o = pick::(*o, v); + } +} + +/// `out[i]` = the extreme of `out[i]` and every `line[i]`, a register-sized chunk at a time +/// so the accumulator never leaves the registers between taps. +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn taps_into(out: &mut [T], lines: &[&[T]]) { + const CHUNK: usize = 64; + let n = out.len(); + let mut x = 0; + while x + CHUNK <= n { + let mut acc: [T; CHUNK] = std::array::from_fn(|i| out[x + i]); + for line in lines { + let l = &line[x..x + CHUNK]; + for i in 0..CHUNK { + acc[i] = pick::(acc[i], l[i]); + } + } + out[x..x + CHUNK].copy_from_slice(&acc); + x += CHUNK; + } + for line in lines { + for (o, &v) in out[x..].iter_mut().zip(&line[x..]) { + *o = pick::(*o, v); + } + } +} + +/// `out[i] = min (or max) of src[i + j * stride]` for `j < len`, from overlapping power-of-two +/// windows: a min or max is idempotent, so the overlap changes nothing and a window of +/// `len` costs log2(len) vector passes. +#[inline(always)] +fn window( + cur: &mut Vec, + scratch: &mut Vec, + out: &mut [T], + len: usize, + stride: usize, +) { + let mut win = 1; + while win * 2 <= len { + let off = win * stride; + let valid = cur.len() - off; + scratch.resize(valid, cur[0]); + for ((o, &x), &y) in scratch.iter_mut().zip(&cur[..valid]).zip(&cur[off..]) { + *o = pick::(x, y); + } + std::mem::swap(cur, scratch); + win *= 2; + } + let off = (len - win) * stride; + for ((o, &x), &y) in out.iter_mut().zip(cur.iter()).zip(&cur[off..]) { + *o = pick::(x, y); + } +} + +/// One erode (`ERODE`) or dilate pass with a `kw` x `kh` rectangle anchored at `(ax, ay)`. +fn rect_pass( + src: &[T], + dst: &mut [T], + (h, w, cn): (usize, usize, usize), + (kw, kh): (usize, usize), + (ax, ay): (usize, usize), + border: Border, +) { + let n = w * cn; + let identity = if ERODE { T::HIGH } else { T::LOW }; + let xmap: Vec> = (0..w + kw - 1) + .map(|px| border_interpolate(px as isize - ax as isize, w, border)) + .collect(); + + // Bands of output rows, each with its own row pass over the kh - 1 rows of halo, so the + // intermediate rows stay in cache. + let band = (2 * kh).max(32); + dst.par_chunks_mut(band * n).enumerate().for_each_init( + || (Vec::new(), Vec::new(), Vec::new(), Vec::new()), + |(padded, scratch, cur, next): &mut (Vec, Vec, Vec, Vec), (bi, out)| { + let total = out.len() / n + kh - 1; + cur.clear(); + cur.resize(total * n, identity); + for (t, line) in cur.chunks_exact_mut(n).enumerate() { + let Some(y) = border_interpolate((bi * band + t) as isize - ay as isize, h, border) + else { + continue; + }; + padded.resize((w + kw - 1) * cn, identity); + let row = &src[y * n..(y + 1) * n]; + padded[ax * cn..(ax + w) * cn].copy_from_slice(row); + for px in (0..ax).chain(ax + w..w + kw - 1) { + let chunk = &mut padded[px * cn..(px + 1) * cn]; + match xmap[px] { + Some(sx) => chunk.copy_from_slice(&row[sx * cn..(sx + 1) * cn]), + None => chunk.fill(identity), + } + } + window::(padded, scratch, line, kw, cn); + } + window::(cur, next, out, kh, n); + }, + ); +} + +/// `cv2.erode`/`cv2.dilate` with a full `kw` x `kh` rectangle and `iterations`: OpenCV turns +/// iterations of a rectangle into one larger rectangle with the anchor scaled. +fn erode_dilate( + src: &[T], + dst: &mut [T], + dims: (usize, usize, usize), + ksize: (usize, usize), + iterations: usize, + erode: bool, + border: Border, +) { + let (kw, kh) = ksize; + let (mut ax, mut ay) = (kw / 2, kh / 2); + let (mut kw, mut kh) = (kw, kh); + if iterations == 0 || (kw == 1 && kh == 1) { + dst.copy_from_slice(src); + return; + } + if iterations > 1 { + ax *= iterations; + ay *= iterations; + kw += (iterations - 1) * (kw - 1); + kh += (iterations - 1) * (kh - 1); + } + if erode { + rect_pass::(src, dst, dims, (kw, kh), (ax, ay), border); + } else { + rect_pass::(src, dst, dims, (kw, kh), (ax, ay), border); + } +} + +/// `cv2.morphologyEx(src, op, ones((kh, kw)), iterations=..., borderType=...)`, the border value +/// OpenCV's default. +pub fn morphology( + src: &[T], + dst: &mut [T], + dims: (usize, usize, usize), + op: Op, + ksize: (usize, usize), + iterations: usize, + border: Border, +) -> Option<()> { + let (h, w, cn) = dims; + if h == 0 + || w == 0 + || cn == 0 + || ksize.0 == 0 + || ksize.1 == 0 + || src.len() != h * w * cn + || dst.len() != src.len() + { + return None; + } + compose(src, dst, op, |s: &[T], d: &mut [T], erode: bool| { + erode_dilate(s, d, dims, ksize, iterations, erode, border) + }); + Some(()) +} + +/// An operation built from erode (`true`) and dilate passes. +fn compose(src: &[T], dst: &mut [T], op: Op, ed: impl Fn(&[T], &mut [T], bool)) { + match op { + Op::Erode => return ed(src, dst, true), + Op::Dilate => return ed(src, dst, false), + _ => {} + } + let mut tmp = vec![src[0]; src.len()]; + match op { + Op::Erode | Op::Dilate => {} + Op::Open => { + ed(src, &mut tmp, true); + ed(&tmp, dst, false); + } + Op::Close => { + ed(src, &mut tmp, false); + ed(&tmp, dst, true); + } + Op::Gradient => { + ed(src, &mut tmp, false); + ed(src, dst, true); + crate::par_zip(dst, &tmp, move |d, &t| *d = t.sub(*d)); + } + Op::TopHat => { + ed(src, &mut tmp, true); + ed(&tmp, dst, false); + crate::par_zip(dst, src, move |d, &s| *d = s.sub(*d)); + } + Op::BlackHat => { + ed(src, &mut tmp, false); + ed(&tmp, dst, true); + crate::par_zip(dst, src, move |d, &s| *d = d.sub(s)); + } + } +} + +/// One erode or dilate pass with an arbitrary `kw` x `kh` kernel mask (nonzero = in), anchor at +/// the center: each kernel row splits into runs, a run is a sliding extreme along the row, and +/// the output takes the extreme over the runs. +fn mask_pass( + src: &[T], + dst: &mut [T], + (h, w, cn): (usize, usize, usize), + mask: &[u8], + (kw, kh): (usize, usize), + border: Border, +) { + let (ax, ay) = (kw / 2, kh / 2); + let identity = if ERODE { T::HIGH } else { T::LOW }; + let n = w * cn; + let pw = (w + kw - 1) * cn; + // (kernel row, run start, run length) + let mut runs: Vec<(usize, usize, usize)> = Vec::new(); + for j in 0..kh { + let mut x = 0; + while x < kw { + if mask[j * kw + x] != 0 { + let start = x; + while x < kw && mask[j * kw + x] != 0 { + x += 1; + } + runs.push((j, start, x - start)); + } else { + x += 1; + } + } + } + if runs.is_empty() { + dst.fill(identity); + return; + } + let xmap: Vec> = (0..w + kw - 1) + .map(|px| border_interpolate(px as isize - ax as isize, w, border)) + .collect(); + let pad_row = |row: &[T], padded: &mut [T]| { + padded[ax * cn..ax * cn + n].copy_from_slice(row); + for px in (0..ax).chain(ax + w..w + kw - 1) { + if let Some(sx) = xmap[px] { + padded[px * cn..(px + 1) * cn].copy_from_slice(&row[sx * cn..(sx + 1) * cn]); + } + } + }; + let cells: usize = runs.iter().map(|r| r.2).sum(); + if cells <= 64 { + // Small kernels: one vector min or max per kernel cell, as OpenCV. Interior columns + // read the source rows directly; the few columns whose taps cross the border go + // through the border map. + let taps: Vec<(usize, usize)> = runs + .iter() + .flat_map(|&(j, start, len)| (start..start + len).map(move |x| (j, x))) + .collect(); + let (left, right) = (ax.min(w), (kw - 1 - ax).min(w - ax.min(w))); + dst.par_chunks_mut(n).enumerate().for_each(|(y, out)| { + out.fill(identity); + let rows: Vec> = (0..kh) + .map(|j| border_interpolate((y + j) as isize - ay as isize, h, border)) + .collect(); + let inner = left * cn..n - right * cn; + if inner.start < inner.end { + let lines: Vec<&[T]> = taps + .iter() + .filter_map(|&(j, x)| { + rows[j].map(|sy| { + let at = sy * n + inner.start + x * cn - ax * cn; + &src[at..at + inner.len()] + }) + }) + .collect(); + taps_into::(&mut out[inner.clone()], &lines); + } + for px in (0..left).chain(w - right..w) { + for &(j, x) in &taps { + let (Some(sy), Some(sx)) = (rows[j], xmap[px + x]) else { + continue; + }; + for c in 0..cn { + let o = &mut out[px * cn + c]; + *o = pick::(*o, src[sy * n + sx * cn + c]); + } + } + } + }); + return; + } + // Large kernels: each output row recomputes its runs' sliding extremes from the padded + // source rows, a log-time window per run and no full-image intermediate. + dst.par_chunks_mut(n).enumerate().for_each_init( + || (Vec::new(), Vec::new(), Vec::new(), Vec::new(), usize::MAX), + |(padded, cur, scratch, slid, padded_row): &mut (Vec, Vec, Vec, Vec, usize), + (y, out)| { + out.fill(identity); + for &(j, start, len) in &runs { + let Some(sy) = border_interpolate((y + j) as isize - ay as isize, h, border) else { + // A constant border row holds the identity, which changes nothing. + continue; + }; + if *padded_row != sy { + padded.clear(); + padded.resize(pw, identity); + pad_row(&src[sy * n..(sy + 1) * n], padded); + *padded_row = sy; + } + cur.clear(); + cur.extend_from_slice(&padded[start * cn..start * cn + n + (len - 1) * cn]); + slid.clear(); + slid.resize(n, identity); + window::(cur, scratch, slid, len, cn); + for (o, &v) in out.iter_mut().zip(slid.iter()) { + *o = pick::(*o, v); + } + } + }, + ); +} + +/// A structuring element: `h` rows of `w` cells, nonzero = in. +#[derive(Clone, Copy)] +pub struct Kernel<'a> { + pub mask: &'a [u8], + pub w: usize, + pub h: usize, +} + +/// `cv2.morphologyEx(src, op, kernel, iterations=..., borderType=...)` for an arbitrary kernel, +/// OpenCV's default anchor and border value. Iterations repeat the pass, as OpenCV does for a +/// kernel that is not a full rectangle. +pub fn morphology_mask( + src: &[T], + dst: &mut [T], + dims: (usize, usize, usize), + op: Op, + kernel: Kernel, + iterations: usize, + border: Border, +) -> Option<()> { + let (h, w, cn) = dims; + let (mask, kw, kh) = (kernel.mask, kernel.w, kernel.h); + let ksize = (kw, kh); + if h == 0 + || w == 0 + || cn == 0 + || kw == 0 + || kh == 0 + || mask.len() != kw * kh + || src.len() != h * w * cn + || dst.len() != src.len() + { + return None; + } + compose(src, dst, op, |s: &[T], d: &mut [T], erode: bool| { + let pass = |from: &[T], to: &mut [T]| { + if erode { + mask_pass::(from, to, dims, mask, ksize, border); + } else { + mask_pass::(from, to, dims, mask, ksize, border); + } + }; + if iterations == 0 { + d.copy_from_slice(s); + return; + } + pass(s, d); + if iterations > 1 { + let mut cur = d.to_vec(); + for _ in 1..iterations { + pass(&cur, d); + cur.copy_from_slice(d); + } + } + }); + Some(()) +} + +/// `cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (w, h))`: `h` rows of `w` cells. +pub fn ellipse_kernel(w: usize, h: usize) -> Vec { + let (r, c) = ((h / 2) as i64, (w / 2) as i64); + let inv_r2 = if r > 0 { 1.0 / (r * r) as f64 } else { 0.0 }; + let mut out = vec![0u8; w * h]; + for (i, row) in out.chunks_mut(w.max(1)).enumerate() { + let dy = i as i64 - r; + if dy.abs() > r { + continue; + } + // OpenCV's saturate_cast rounds half to even. + let dx = (c as f64 * (((r * r - dy * dy) as f64) * inv_r2).sqrt()).round_ties_even() as i64; + let (j1, j2) = ((c - dx).max(0) as usize, ((c + dx + 1) as usize).min(w)); + row[j1..j2].fill(1); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn erode_takes_the_window_minimum() { + let src: Vec = vec![5, 9, 1, 7, 3, 8]; + let mut dst = vec![0u8; 6]; + morphology( + &src, + &mut dst, + (1, 6, 1), + Op::Erode, + (3, 1), + 1, + Border::Constant, + ) + .unwrap(); + assert_eq!(dst, vec![5, 1, 1, 1, 3, 3]); + morphology( + &src, + &mut dst, + (1, 6, 1), + Op::Dilate, + (3, 1), + 1, + Border::Constant, + ) + .unwrap(); + assert_eq!(dst, vec![9, 9, 9, 7, 8, 8]); + } + + #[test] + fn blackhat_of_a_flat_image_is_zero() { + let src = vec![0.5f32; 7 * 9]; + let mut dst = vec![1f32; 7 * 9]; + morphology( + &src, + &mut dst, + (7, 9, 1), + Op::BlackHat, + (3, 3), + 2, + Border::Constant, + ) + .unwrap(); + assert!(dst.iter().all(|&v| v == 0.0)); + } + + #[test] + fn ellipse_matches_opencv() { + assert_eq!(ellipse_kernel(1, 1), vec![1]); + #[rustfmt::skip] + let want = vec![ + 0, 0, 1, 0, 0, + 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, + 0, 0, 1, 0, 0, + ]; + assert_eq!(ellipse_kernel(5, 5), want); + } +} diff --git a/negpy_core/src/phase.rs b/negpy_core/src/phase.rs new file mode 100644 index 000000000..f5b5fa367 --- /dev/null +++ b/negpy_core/src/phase.rs @@ -0,0 +1,234 @@ +//! `cv2.phaseCorrelate` with a `cv2.createHanningWindow` window, through rustfft and realfft. + +use rayon::prelude::*; +use rustfft::num_complex::Complex32; +use rustfft::{Fft, FftPlanner}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +use realfft::RealFftPlanner; + +/// `cv2.getOptimalDFTSize`: the smallest 2^a 3^b 5^c at least `n`. +pub fn optimal_dft_size(n: usize) -> usize { + (n.max(1)..) + .find(|&m| { + let mut k = m; + for p in [2, 3, 5] { + while k % p == 0 { + k /= p; + } + } + k == 1 + }) + .unwrap_or(n) +} + +/// `cv2.createHanningWindow((w, h), cv2.CV_32F)` for h, w >= 2. +pub fn hanning_window(h: usize, w: usize) -> Vec { + let tau = 2.0 * std::f64::consts::PI; + let (c0, c1) = (tau / (w - 1) as f64, tau / (h - 1) as f64); + let wc: Vec = (0..w) + .map(|j| 0.5 * (1.0 - (c0 * j as f64).cos())) + .collect(); + let mut out = vec![0f32; h * w]; + out.par_chunks_mut(w).enumerate().for_each(|(i, row)| { + let wr = 0.5 * (1.0 - (c1 * i as f64).cos()); + for (d, &c) in row.iter_mut().zip(&wc) { + *d = ((wr * c) as f32).sqrt(); + } + }); + out +} + +type C32 = Complex32; + +/// One planner of each kind for every call: they keep the plans they have made. +fn plan(len: usize, inverse: bool) -> Arc> { + static PLANNER: OnceLock>> = OnceLock::new(); + let mut planner = PLANNER + .get_or_init(|| Mutex::new(FftPlanner::new())) + .lock() + .unwrap_or_else(|e| e.into_inner()); + if inverse { + planner.plan_fft_inverse(len) + } else { + planner.plan_fft_forward(len) + } +} + +fn real_planner() -> MutexGuard<'static, RealFftPlanner> { + static PLANNER: OnceLock>> = OnceLock::new(); + PLANNER + .get_or_init(|| Mutex::new(RealFftPlanner::new())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +fn fft_rows(data: &mut [C32], len: usize, fft: &Arc>) { + data.par_chunks_mut(len).with_min_len(16).for_each_init( + || vec![C32::default(); fft.get_inplace_scratch_len()], + |scratch, row| fft.process_with_scratch(row, scratch), + ); +} + +/// `src` (rows x cols) transposed, in 32 x 32 tiles. +fn transpose(src: &[C32], rows: usize, cols: usize) -> Vec { + const T: usize = 32; + let mut dst = vec![C32::default(); src.len()]; + dst.par_chunks_mut(rows * T) + .enumerate() + .for_each(|(bx, band)| { + let x0 = bx * T; + let bw = band.len() / rows; + for y0 in (0..rows).step_by(T) { + for x in 0..bw { + for y in y0..(y0 + T).min(rows) { + band[x * rows + y] = src[y * cols + x0 + x]; + } + } + } + }); + dst +} + +/// The 2-D DFT of the windowed h x w plane zero-padded to m x n, as its n / 2 + 1 columns +/// (a real plane's spectrum holds the rest as conjugates), stored column after column. +fn forward(img: &[f32], win: &[f32], (h, w): (usize, usize), (m, n): (usize, usize)) -> Vec { + let k = n / 2 + 1; + let r2c = real_planner().plan_fft_forward(n); + let mut spec = vec![C32::default(); m * k]; + spec.par_chunks_mut(k).take(h).enumerate().for_each_init( + || (r2c.make_input_vec(), r2c.make_scratch_vec()), + |(input, scratch), (y, out)| { + let (src, wv) = (&img[y * w..][..w], &win[y * w..][..w]); + for (d, (&p, &q)) in input.iter_mut().zip(src.iter().zip(wv)) { + *d = p * q; + } + input[w..].fill(0.0); + // Fails only on buffer lengths, which make_*_vec sets. + let _ = r2c.process_with_scratch(input, out, scratch); + }, + ); + let mut cols = transpose(&spec, m, k); + fft_rows(&mut cols, m, &plan(m, false)); + cols +} + +/// The real m x n inverse DFT, unscaled, of n / 2 + 1 spectrum columns stored column after column. +fn inverse(mut cols: Vec, (m, n): (usize, usize)) -> Vec { + let k = n / 2 + 1; + fft_rows(&mut cols, m, &plan(m, true)); + let mut spec = transpose(&cols, k, m); + let c2r = real_planner().plan_fft_inverse(n); + let mut out = vec![0f32; m * n]; + out.par_chunks_mut(n) + .zip(spec.par_chunks_mut(k)) + .for_each_init( + || c2r.make_scratch_vec(), + |scratch, (o, s)| { + // Rounding leaves the DC and Nyquist bins a trace of imaginary part; theirs is 0. + s[0].im = 0.0; + if n % 2 == 0 { + s[k - 1].im = 0.0; + } + let _ = c2r.process_with_scratch(s, o, scratch); + }, + ); + out +} + +/// `cv2.phaseCorrelate(a, b, window)` of two h x w float32 planes with the Hanning window: +/// the shift of `b` against `a` as (dx, dy), and the peak response. cv2's is half a pixel off +/// along an axis whose padded size is odd. +pub fn phase_correlate(a: &[f32], b: &[f32], h: usize, w: usize) -> (f64, f64, f64) { + let win = hanning_window(h, w); + let (m, n) = (optimal_dft_size(h), optimal_dft_size(w)); + let (mut fa, fb) = rayon::join( + || forward(a, &win, (h, w), (m, n)), + || forward(b, &win, (h, w), (m, n)), + ); + fa.par_chunks_mut(m) + .zip(fb.par_chunks(m)) + .for_each(|(p, q)| { + for (p, q) in p.iter_mut().zip(q) { + let v = *p * q.conj(); + let sq = v.norm_sqr(); + *p = v * (sq.sqrt() / (sq + f32::EPSILON)); + } + }); + let corr = inverse(fa, (m, n)); + + // fftShift rolls by (n / 2, m / 2). cv2 then measures from (n / 2.0, m / 2.0), half a pixel + // off on an odd padded size; the roll's own origin is the true zero shift. + let (xm, ym) = (n / 2, m / 2); + let mut c = vec![0f64; m * n]; + c.par_chunks_mut(n).enumerate().for_each(|(y, row)| { + let src = &corr[((y + m - ym) % m) * n..][..n]; + let (head, tail) = row.split_at_mut(xm); + for (d, &v) in head.iter_mut().zip(&src[n - xm..]) { + *d = v as f64; + } + for (d, &v) in tail.iter_mut().zip(src) { + *d = v as f64; + } + }); + + let peak = c + .iter() + .enumerate() + .fold(0, |best, (i, &v)| if v > c[best] { i } else { best }); + let (py, px) = ((peak / n) as i64, (peak % n) as i64); + let (y0, y1) = ( + (py - 2).max(0) as usize, + (py + 2).min(m as i64 - 1) as usize, + ); + let (x0, x1) = ( + (px - 2).max(0) as usize, + (px + 2).min(n as i64 - 1) as usize, + ); + let (mut cx, mut cy, mut sum) = (0f64, 0f64, 0f64); + for y in y0..=y1 { + for x in x0..=x1 { + let v = c[y * n + x]; + cx += x as f64 * v; + cy += y as f64 * v; + sum += v; + } + } + let response = sum / (m * n) as f64; + let sum = sum + f64::EPSILON; + (xm as f64 - cx / sum, ym as f64 - cy / sum, response) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn optimal_sizes() { + assert_eq!(optimal_dft_size(1024), 1024); + assert_eq!(optimal_dft_size(1025), 1080); + assert_eq!(optimal_dft_size(7), 8); + assert_eq!(optimal_dft_size(361), 375); + } + + #[test] + fn finds_an_integer_shift() { + let (h, w) = (64, 80); + let mut seed = 12345u64; + let mut noise = || { + seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (seed >> 40) as f32 / (1u64 << 24) as f32 + }; + let field: Vec = (0..(h + 8) * (w + 8)).map(|_| noise()).collect(); + let at = |x: usize, y: usize| field[y * (w + 8) + x]; + let a: Vec = (0..h * w).map(|i| at(i % w + 4, i / w + 4)).collect(); + let b: Vec = (0..h * w).map(|i| at(i % w + 1, i / w + 6)).collect(); + let (dx, dy, _) = phase_correlate(&a, &b, h, w); + assert!( + (dx - 3.0).abs() < 0.1 && (dy + 2.0).abs() < 0.1, + "{dx} {dy}" + ); + } +} diff --git a/negpy_core/src/print_curve.rs b/negpy_core/src/print_curve.rs new file mode 100644 index 000000000..0d94ab1e0 --- /dev/null +++ b/negpy_core/src/print_curve.rs @@ -0,0 +1,483 @@ +//! The print curve of `apply_characteristic_curve` (negpy/features/exposure/logic.py): a straight +//! line of slope `slope` through the exposure pivot, bounded above by the toe (shadows to paper +//! black d_max) and below by the shoulder (highlights to paper white), two independent softplus +//! bounds. Output is linear reflectance, 10^-D; the working-space OETF comes at the engine output. +//! The float64 order follows the Numba kernel it replaced, which fastmath regrouped, so the float32 +//! output can differ from it by an ULP (negpy_core/LEDGER.md). + +// The steps index several per-pixel arrays of one run in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::{exp_f64, exp10_f64, log1p_f64, log10_f64, pow_f64, tanh_f64}; + +const LUMA_R: f64 = 0.2126; +const LUMA_G: f64 = 0.7152; +const LUMA_B: f64 = 0.0722; + +/// An HxW (or HxWxK) float32 plane. +#[derive(Clone, Copy)] +pub struct Plane<'a> { + pub data: &'a [f32], + pub w: usize, +} + +/// Tone-limited masks: K shape alphas per pixel, (stops, ISO-R delta, e0, e1) per mask, and the +/// unlimited masks' ISO-R plane. +#[derive(Clone, Copy)] +pub struct Keys<'a> { + pub alpha: &'a [f32], + pub params: &'a [[f64; 4]], + pub deltas: Plane<'a>, +} + +#[derive(Clone, Copy)] +pub struct Diffusion<'a> { + pub plane: &'a [f32], + pub gh: usize, + pub gw: usize, + pub rect: [f64; 4], + pub mix: f64, + pub range: [f64; 3], +} + +#[derive(Clone, Copy)] +pub struct Lut<'a> { + pub table: &'a [f32], + pub n: usize, + pub e_max: [f32; 3], + pub black: f64, +} + +/// Every input but the image. Toe and shoulder are per channel (value plus layer trim), scaled +/// by toe_shoulder_strength. `d_min_rgb` is paper white per channel; `dye_mix` couples the dye +/// densities above it. `ev` is dodge and burn in print stops times `ev_scale` (positive burns), +/// `grade` a slope factor per pixel. `keys` are tone-limited masks whose ISO-R grade adds to +/// their deltas about `r0` and replaces `grade`. `flash` is preflash as a fraction of the +/// threshold exposure. `bpc` maps paper black (or the LUT's `black`) to display black. `lut` +/// replaces 10^-D on the density above base. +#[derive(Clone, Copy)] +pub struct PrintCurve<'a> { + pub pivots: [f32; 3], + pub slopes: [f32; 3], + pub curvatures: [f32; 3], + pub toe: [f64; 3], + pub shoulder: [f64; 3], + pub toe_width: [f64; 3], + pub shoulder_width: [f64; 3], + pub cmy_offsets: [f32; 3], + pub shadow_cmy: [f32; 3], + pub highlight_cmy: [f32; 3], + pub d_min_rgb: [f64; 3], + pub d_max: f64, + pub a_toe_base: f64, + pub a_sh_base: f64, + pub width_ref: f64, + pub toe_height: f64, + pub sh_height: f64, + pub zone_center: f64, + pub shadow_density: f64, + pub highlight_density: f64, + pub shadow_grade: [f64; 3], + pub highlight_grade: [f64; 3], + pub zone_sh_center: f64, + pub zone_hi_center: f64, + pub zone_k: f64, + pub v_star: f64, + pub midtone_gamma: [f64; 3], + pub gamma_width: f64, + pub dye_mix: Option<[[f64; 3]; 3]>, + /// Per-channel k, damping, reference spread. + pub sep: Option<([f64; 3], f64, f64)>, + pub ev: Option>, + pub ev_scale: [f32; 3], + pub grade: Option>, + pub keys: Option>, + pub r0: f64, + pub r_min: f64, + pub r_max: f64, + pub flash: f64, + pub flash_v: f64, + pub flash_gamma: f64, + pub flash_map: Option>, + pub diffusion: Option>, + pub lut: Option>, + pub bpc: bool, + pub black_hold: f64, +} + +/// The per-channel bounds the kernel's prologue derives. +#[derive(Clone, Copy)] +struct Bounds { + a_hl: [f64; 3], + a_sh: [f64; 3], + d_min_eff: [f64; 3], + d_max_eff: [f64; 3], + bpc_black: [f64; 3], + use_split: bool, +} + +fn bounds(p: &PrintCurve) -> Bounds { + let eps = 1e-6; + let mut b = Bounds { + a_hl: [0.0; 3], + a_sh: [0.0; 3], + d_min_eff: [0.0; 3], + d_max_eff: [0.0; 3], + bpc_black: [0.0; 3], + use_split: p + .shadow_grade + .iter() + .chain(&p.highlight_grade) + .any(|&g| g != 0.0), + }; + for ch in 0..3 { + b.a_hl[ch] = p.a_sh_base * p.width_ref / p.shoulder_width[ch].max(eps); + let a_sh_w = p.a_toe_base * p.width_ref / p.toe_width[ch].max(eps); + let t = p.toe[ch]; + // A negative toe tightens the shadow roll-off instead of moving d_max_eff past paper black. + let d_max_base = if t >= 0.0 { + b.a_sh[ch] = a_sh_w; + p.d_max - t * p.toe_height + } else { + b.a_sh[ch] = a_sh_w * (1.0 - t * 4.0); + p.d_max + }; + let dmn = (p.d_min_rgb[ch] + p.shoulder[ch] * p.sh_height).max(0.0); + b.d_min_eff[ch] = dmn; + b.d_max_eff[ch] = if d_max_base < dmn + 0.1 { + dmn + 0.1 + } else { + d_max_base + }; + // BPC references the physical d_max, so toe lifts survive; a negative toe raises the clip. + let db = if t < 0.0 { + p.d_max + t * p.toe_height + } else { + p.d_max + }; + b.bpc_black[ch] = exp10_f64(-db); + } + b +} + +/// `_fast_sigmoid`: 1 / (1 + e^-x) above 0, e^x / (1 + e^x) below, both from e^-|x| so the +/// loop has no branch. +#[inline(always)] +fn sigmoid(x: f64) -> f64 { + let z = exp_f64(-x.abs()); + let d = 1.0 + z; + if x >= 0.0 { 1.0 / d } else { z / d } +} + +/// `_softplus`: x + log1p(e^-x) above 0, log1p(e^x) below: max(x, 0) + log1p(e^-|x|). +#[inline(always)] +fn softplus(x: f64) -> f64 { + let l = log1p_f64(exp_f64(-x.abs())); + if x > 0.0 { x + l } else { l } +} + +#[inline(always)] +fn smoothstep01(t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// `quadratic_core`: k (u - pivot) + curv u^2, held at its vertex where it would fold back. +#[inline(always)] +fn quadratic_core(k: f64, pivot: f64, curv: f64, mut u: f64) -> f64 { + if curv != 0.0 { + let vertex = -k / (2.0 * curv); + if (curv > 0.0 && u < vertex) || (curv < 0.0 && u > vertex) { + u = vertex; + } + } + k * (u - pivot) + curv * u * u +} + +/// `separation_damping_gain`. +#[inline(always)] +fn separation_gain(k: f64, damping: f64, chroma: f64, reference: f64) -> f64 { + if k <= 0.0 { + return 0.0; + } + let h = (reference - chroma) / (reference + chroma); + pow_f64(k, (1.0 - damping) + damping * h).min(3.0) +} + +/// `plane_taps`: OpenCV's half-pixel bilinear taps on a gh x gw plane covering `rect`. +#[inline(always)] +fn plane_taps( + gh: usize, + gw: usize, + x: usize, + y: usize, + r: [f64; 4], +) -> (usize, usize, usize, usize, f64, f64) { + let px = (x as f64 + 0.5 - r[0]) * gw as f64 / r[2] - 0.5; + let py = (y as f64 + 0.5 - r[1]) * gh as f64 / r[3] - 0.5; + let lx = px.floor().clamp(0.0, gw as f64 - 1.0); + let ly = py.floor().clamp(0.0, gh as f64 - 1.0); + let hx = (lx + 1.0).min(gw as f64 - 1.0); + let hy = (ly + 1.0).min(gh as f64 - 1.0); + let fx = (px - lx).clamp(0.0, 1.0); + let fy = (py - ly).clamp(0.0, 1.0); + (lx as usize, hx as usize, ly as usize, hy as usize, fx, fy) +} + +/// `tetra_lookup`: tetrahedral interpolation clamped to the table; corner differences in float32 +/// as Numba computes them on the float32 table. +#[inline(always)] +pub fn tetra(l: &Lut, e: [f64; 3]) -> [f64; 3] { + let n = l.n - 1; + let pos: [f64; 3] = + std::array::from_fn(|c| (e[c] / l.e_max[c] as f64).clamp(0.0, 1.0) * n as f64); + let idx: [usize; 3] = std::array::from_fn(|c| (pos[c] as usize).min(n - 1)); + let [fx, fy, fz]: [f64; 3] = std::array::from_fn(|c| pos[c] - idx[c] as f64); + let at = |di: usize, dj: usize, dk: usize, c: usize| { + l.table[(((idx[0] + di) * l.n + idx[1] + dj) * l.n + idx[2] + dk) * 3 + c] + }; + std::array::from_fn(|c| { + let c000 = at(0, 0, 0, c); + let c111 = at(1, 1, 1, c); + let d = |a: f32, b: f32| (b - a) as f64; + if fx >= fy { + if fy >= fz { + let (c100, c110) = (at(1, 0, 0, c), at(1, 1, 0, c)); + c000 as f64 + fx * d(c000, c100) + fy * d(c100, c110) + fz * d(c110, c111) + } else if fx >= fz { + let (c100, c101) = (at(1, 0, 0, c), at(1, 0, 1, c)); + c000 as f64 + fx * d(c000, c100) + fz * d(c100, c101) + fy * d(c101, c111) + } else { + let (c001, c101) = (at(0, 0, 1, c), at(1, 0, 1, c)); + c000 as f64 + fz * d(c000, c001) + fx * d(c001, c101) + fy * d(c101, c111) + } + } else if fz >= fy { + let (c001, c011) = (at(0, 0, 1, c), at(0, 1, 1, c)); + c000 as f64 + fz * d(c000, c001) + fy * d(c001, c011) + fx * d(c011, c111) + } else if fz >= fx { + let (c010, c011) = (at(0, 1, 0, c), at(0, 1, 1, c)); + c000 as f64 + fy * d(c000, c010) + fz * d(c010, c011) + fx * d(c011, c111) + } else { + let (c010, c110) = (at(0, 1, 0, c), at(1, 1, 0, c)); + c000 as f64 + fy * d(c000, c010) + fx * d(c010, c110) + fz * d(c110, c111) + } + }) +} + +/// The print curve over an HxWx3 float32 image. +pub fn apply_print_curve(img: &[f32], w: usize, p: &PrintCurve, dst: &mut [f32]) { + let b = bounds(p); + dst.par_chunks_mut(w * 3) + .zip(img.par_chunks(w * 3)) + .enumerate() + .for_each(|(y, (d, s))| { + for (i, (d, s)) in d.chunks_mut(3 * RUN).zip(s.chunks(3 * RUN)).enumerate() { + run(s, d, (y, i * RUN, w), p, &b); + } + }); +} + +/// Pixels per pass: each step of the curve runs as a flat loop over a run, so it vectorizes. +const RUN: usize = 256; + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn run( + src: &[f32], + dst: &mut [f32], + (y, x0, w): (usize, usize, usize), + p: &PrintCurve, + b: &Bounds, +) { + let px = src.as_chunks::<3>().0; + let n = px.len(); + let at = |plane: &Plane, i: usize| plane.data[y * plane.w + x0 + i]; + let mut gfac = [1f64; RUN]; + let mut ev_px = [0f64; RUN]; + if let Some(k) = &p.keys { + let m = k.params.len(); + for i in 0..n { + if let Some(ev) = &p.ev { + ev_px[i] = at(ev, i) as f64; + } + // The unburned tone: no mask can move the pixels it selects. + let q = px[i]; + let lum = LUMA_R * q[0] as f64 + LUMA_G * q[1] as f64 + LUMA_B * q[2] as f64; + let mut dr = at(&k.deltas, i) as f64; + for (j, kp) in k.params.iter().enumerate() { + let a = k.alpha[(y * w + x0 + i) * m + j] as f64 + * smoothstep01((lum - kp[2]) / (kp[3] - kp[2])); + ev_px[i] += kp[0] * a; + dr += kp[1] * a; + } + gfac[i] = p.r0 / (p.r0 + dr).clamp(p.r_min, p.r_max); + } + } else if let Some(g) = &p.grade { + for i in 0..n { + gfac[i] = at(g, i) as f64; + } + } + let mut fl = [p.flash; RUN]; + if let Some(m) = &p.flash_map { + for i in 0..n { + fl[i] += at(m, i) as f64; + } + } + let any_flash = fl[..n].iter().any(|&f| f > 0.0); + let mut dens = [[0f64; RUN]; 3]; + for ch in 0..3 { + let v = &mut dens[ch]; + for i in 0..n { + v[i] = px[i][ch] as f64; + } + if let Some(d) = &p.diffusion { + // Diffusion mixes light, not density: (1-d) I + d blur(I), I = 10^(val r). + let r = d.range[ch]; + for i in 0..n { + let (i0x, i1x, i0y, i1y, fx, fy) = plane_taps(d.gh, d.gw, x0 + i, y, d.rect); + let tap = |yy: usize, xx: usize| d.plane[(yy * d.gw + xx) * 3 + ch] as f64; + let top = tap(i0y, i0x) * (1.0 - fx) + tap(i0y, i1x) * fx; + let bot = tap(i1y, i0x) * (1.0 - fx) + tap(i1y, i1x) * fx; + let blur = top * (1.0 - fy) + bot * fy; + v[i] = log10_f64((1.0 - d.mix) * exp10_f64(v[i] * r) + d.mix * exp10_f64(blur * r)) + / r; + } + } + let offset = p.cmy_offsets[ch] as f64; + let scale = p.ev_scale[ch]; + if p.keys.is_some() { + for i in 0..n { + v[i] = v[i] + offset + (ev_px[i] * scale as f64) as f32 as f64; + } + } else if let Some(ev) = &p.ev { + for i in 0..n { + v[i] = v[i] + offset + (at(ev, i) * scale) as f64; + } + } else { + for x in v[..n].iter_mut() { + *x += offset; + } + } + let (slope, pivot, curv) = ( + p.slopes[ch] as f64, + p.pivots[ch] as f64, + p.curvatures[ch] as f64, + ); + for i in 0..n { + v[i] = quadratic_core(slope * gfac[i], pivot, curv, v[i]); + } + if any_flash { + for i in 0..n { + let lifted = p.flash_v + + p.flash_gamma + * log10_f64(exp10_f64((v[i] - p.flash_v) / p.flash_gamma) + fl[i]); + v[i] = if fl[i] > 0.0 { lifted } else { v[i] }; + } + } + let mg = p.midtone_gamma[ch]; + if mg != 0.0 { + for x in v[..n].iter_mut() { + *x += mg * p.gamma_width * tanh_f64((*x - p.v_star) / p.gamma_width); + } + } + let (s_cmy, h_cmy) = (p.shadow_cmy[ch] as f64, p.highlight_cmy[ch] as f64); + for x in v[..n].iter_mut() { + let w_sh = sigmoid(3.0 * (*x - p.zone_center)); + *x += s_cmy * w_sh + h_cmy * (1.0 - w_sh); + } + if b.use_split { + let (sg, hg) = (p.shadow_grade[ch], p.highlight_grade[ch]); + for x in v[..n].iter_mut() { + let w_gsh = sigmoid(p.zone_k * (*x - p.zone_sh_center)); + let w_ghi = 1.0 - sigmoid(p.zone_k * (*x - p.zone_hi_center)); + *x = *x + + sg * w_gsh * (*x - p.zone_sh_center) + + hg * w_ghi * (*x - p.zone_hi_center); + } + } + if p.shadow_density != 0.0 || p.highlight_density != 0.0 { + let hold = p.black_hold > 0.0 && p.shadow_density < 0.0; + let dmx = b.d_max_eff[ch]; + for x in v[..n].iter_mut() { + let mut w_zsh = sigmoid(p.zone_k * (*x - p.zone_sh_center)); + if hold { + w_zsh *= smoothstep01((dmx - *x) / p.black_hold); + } + let w_zhi = 1.0 - sigmoid(p.zone_k * (*x - p.zone_hi_center)); + *x += p.shadow_density * w_zsh + p.highlight_density * w_zhi; + } + } + let (dmn, dmx, a_hl, a_sh) = (b.d_min_eff[ch], b.d_max_eff[ch], b.a_hl[ch], b.a_sh[ch]); + for x in v[..n].iter_mut() { + let v1 = dmn + softplus(a_hl * (*x - dmn)) / a_hl; + *x = dmx - softplus(a_sh * (dmx - v1)) / a_sh; + } + } + let dmin = p.d_min_rgb; + if let Some(m) = &p.dye_mix { + for i in 0..n { + let e = [ + dens[0][i] - dmin[0], + dens[1][i] - dmin[1], + dens[2][i] - dmin[2], + ]; + for c in 0..3 { + dens[c][i] = dmin[c] + m[c][0] * e[0] + m[c][1] * e[1] + m[c][2] * e[2]; + } + } + } + if let Some((k3, damping, reference)) = &p.sep { + for i in 0..n { + let s = [ + dens[0][i] - dmin[0], + dens[1][i] - dmin[1], + dens[2][i] - dmin[2], + ]; + let mean = (s[0] + s[1] + s[2]) / 3.0; + let chroma = (((s[0] - s[1]).powi(2) + (s[1] - s[2]).powi(2) + (s[0] - s[2]).powi(2)) + / 3.0) + .sqrt(); + for c in 0..3 { + dens[c][i] = dmin[c] + + mean + + separation_gain(k3[c], *damping, chroma, *reference) * (s[c] - mean); + } + } + } + let out = dst.as_chunks_mut::<3>().0; + if let Some(l) = &p.lut { + let base: [f64; 3] = std::array::from_fn(|c| exp10_f64(-dmin[c])); + for i in 0..n { + let rgb = tetra( + l, + [ + dens[0][i] - dmin[0], + dens[1][i] - dmin[1], + dens[2][i] - dmin[2], + ], + ); + out[i] = std::array::from_fn(|c| { + let mut t = base[c] * rgb[c]; + if p.bpc { + t = (t - l.black) / (1.0 - l.black); + } + t.clamp(0.0, 1.0) as f32 + }); + } + } else { + for c in 0..3 { + let (black, bpc) = (b.bpc_black[c], p.bpc); + for i in 0..n { + let t = exp10_f64(-dens[c][i]); + let t = if bpc { (t - black) / (1.0 - black) } else { t }; + out[i][c] = t.clamp(0.0, 1.0) as f32; + } + } + } +} diff --git a/negpy_core/src/process.rs b/negpy_core/src/process.rs new file mode 100644 index 000000000..9cdb0c7bc --- /dev/null +++ b/negpy_core/src/process.rs @@ -0,0 +1,136 @@ +//! Ports of the scan statistics in negpy/features/process: process-mode detection, the +//! lightbox level and the chart patch reads. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::pow_f64; +use crate::stats::{np_median, np_percentiles, pairwise_sum_f32, quantile_f64}; + +/// `x @ m.T` for one pixel, in the order OpenBLAS's dgemm sums it. +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn matmul_rows(px: &[f64], m: &[[f64; 3]; 3], dst: &mut [f64]) { + for (d, x) in dst + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(px.as_chunks::<3>().0) + { + for c in 0..3 { + d[c] = x[2].mul_add(m[c][2], x[1].mul_add(m[c][1], x[0] * m[c][0])); + } + } +} + +/// `lightbox_level`'s reference: the `pct` percentile of every value of every `step`th pixel +/// of an Nx3 image, through `matrix` in float64 when given. NaN when any value is NaN. +pub fn lightbox_white(img: &[f32], step: usize, matrix: Option<[[f64; 3]; 3]>, pct: f64) -> f64 { + let mut sample: Vec = img + .as_chunks::<3>() + .0 + .iter() + .step_by(step.max(1)) + .flat_map(|p| p.map(f64::from)) + .collect(); + if let Some(m) = matrix { + let src = sample.clone(); + sample + .par_chunks_mut(3 * 4096) + .zip(src.par_chunks(3 * 4096)) + .for_each(|(d, s)| matmul_rows(s, &m, d)); + } + if sample.iter().any(|v| v.is_nan()) { + return f64::NAN; + } + quantile_f64(&mut sample, pct / 100.0).unwrap_or(f64::NAN) +} + +/// `_corr`: Pearson correlation of two float32 channels in NumPy's float32 arithmetic. +fn corr(a: &[f32], b: &[f32]) -> f64 { + let n = a.len() as f32; + let ma = pairwise_sum_f32(a) / n; + let mb = pairwise_sum_f32(b) / n; + let da: Vec = a.iter().map(|v| v - ma).collect(); + let db: Vec = b.iter().map(|v| v - mb).collect(); + let sq = |x: &[f32], y: &[f32]| { + pairwise_sum_f32(&x.iter().zip(y).map(|(p, q)| p * q).collect::>()) + }; + let denom = ((sq(&da, &da) * sq(&db, &db)).sqrt() as f64 + 1e-12) as f32; + (sq(&da, &db) / denom) as f64 +} + +/// The statistics `detect_process_mode` reads off an HxWx3 float32 image: the lowest channel +/// correlation, the red and blue means, the red and blue 25th and the RGB 98th percentiles. +pub fn process_mode_stats(img: &[f32]) -> (f64, [f64; 7]) { + let ch: Vec> = (0..3) + .map(|c| img.iter().skip(c).step_by(3).copied().collect()) + .collect(); + let (r, g, b) = (&ch[0], &ch[1], &ch[2]); + // Each q interpolates in float32, as a separate np.percentile call with a Python number does. + let (min_corr, p) = rayon::join( + || corr(r, g).min(corr(g, b)).min(corr(r, b)), + || { + ch.par_iter() + .map(|v| np_percentiles(v, &[25.0, 98.0]).unwrap_or(vec![f32::NAN; 2])) + .collect::>() + }, + ); + let n = r.len() as f32; + let (mr, mb) = ( + (pairwise_sum_f32(r) / n) as f64, + (pairwise_sum_f32(b) / n) as f64, + ); + let p = |c: usize, i: usize| p[c][i] as f64; + ( + min_corr, + [mr, mb, p(0, 0), p(2, 0), p(0, 1), p(1, 1), p(2, 1)], + ) +} + +/// `sample_patches` of an HxWxC float32 image: the float32 median per channel of the first three +/// channels in each (x0, x1, y0, y1) box. With `gamma`, of `working_oetf_decode(np.clip(img, 0, 1))`. +pub fn chart_patches( + img: &[f32], + w: usize, + cn: usize, + boxes: &[(usize, usize, usize, usize)], + gamma: Option, +) -> Vec<[f64; 3]> { + boxes + .par_iter() + .map(|&(x0, x1, y0, y1)| { + std::array::from_fn(|c| { + let mut v: Vec = (y0..y1) + .flat_map(|yy| (x0..x1).map(move |xx| img[(yy * w + xx) * cn + c])) + .map(|v| { + let Some(g) = gamma else { return v }; + // np.clip keeps NaN; the decode maps negatives to 0. + let x = v.clamp(0.0, 1.0) as f64; + pow_f64(if x < 0.0 { 0.0 } else { x }, g as f64) as f32 + }) + .collect(); + np_median(&mut v) as f64 + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identical_channels_correlate_fully() { + let img: Vec = (0..300).flat_map(|i| [i as f32 / 300.0; 3]).collect(); + let (c, s) = process_mode_stats(&img); + assert!((c - 1.0).abs() < 1e-6); + assert_eq!(s[0], s[1]); + } + + #[test] + fn lightbox_white_reads_the_top_percentile() { + let img: Vec = (0..1000).flat_map(|i| [i as f32; 3]).collect(); + assert_eq!(lightbox_white(&img, 1, None, 100.0), 999.0); + assert!(lightbox_white(&[f32::NAN, 0.0, 0.0], 1, None, 50.0).is_nan()); + } +} diff --git a/negpy_core/src/py/altprocess.rs b/negpy_core/src/py/altprocess.rs new file mode 100644 index 000000000..b6b1d292b --- /dev/null +++ b/negpy_core/src/py/altprocess.rs @@ -0,0 +1,166 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use super::color::space; +use crate::altprocess::{self, Cyanotype, Interp, Lith, Sabattier}; +use crate::color::LabSpace; + +/// The HxWx3 image, and a new output of its shape filled by `f`. +fn run<'py>( + py: Python<'py>, + img: &PyReadonlyArrayDyn<'py, f32>, + f: impl FnOnce(&[f32], (usize, usize), &mut [f32]) -> Option<()> + Send, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || f(src, (h, w), dst)) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + } + Ok(out) +} + +/// The working Lab space for Lab to RGB: the matrix, the white, eps and kappa. +type Working<'py> = ( + PyReadonlyArray2<'py, f32>, + PyReadonlyArray2<'py, f32>, + PyReadonlyArray1<'py, f32>, + f32, + f32, +); + +fn to_rgb(lab: &Working<'_>) -> PyResult { + space(&lab.1, &lab.2, lab.3, lab.4) +} + +fn path(xp: [f32; 4], anchors: [(f32, f32); 4]) -> (Interp, Interp) { + ( + Interp::new(xp, anchors.map(|a| a.0)), + Interp::new(xp, anchors.map(|a| a.1)), + ) +} + +/// `apply_sabattier` once enabled: (re, fold_width, edge_width, strength, d_max) as float32, +/// and the Mackie-line taps (empty: no line). +#[pyfunction] +fn sabattier<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + scalars: (f32, f32, f32, f32, f32), + k: PyReadonlyArray1<'py, f32>, +) -> PyResult>> { + let (re, fold_width, edge_width, strength, d_max) = scalars; + let s = Sabattier { + re, + fold_width, + edge_width, + strength, + d_max, + }; + let k = k + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous taps"))?; + run(py, &img, |src, dims, dst| { + altprocess::sabattier(src, dims, &s, k, dst) + }) +} + +/// `apply_cyanotype` once enabled. `scalars` is (d_max, over, scale, mid_compress, bleach, +/// bleach_floor, tannin, tannin_restore, tannin_dmax_gain) as float32; then the brown +/// direction, the hue path and the working Lab constants. +#[pyfunction] +fn cyanotype<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + scalars: [f32; 9], + brown: (f32, f32), + path_u: [f32; 4], + anchors: [(f32, f32); 4], + lab: Working<'py>, +) -> PyResult>> { + let [ + d_max, + over, + scale, + mid_compress, + bleach, + bleach_floor, + tannin, + tannin_restore, + tannin_dmax_gain, + ] = scalars; + let (a_path, b_path) = path(path_u, anchors); + let c = Cyanotype { + d_max, + over, + scale, + mid_compress, + bleach, + bleach_floor, + tannin, + tannin_restore, + tannin_dmax_gain, + brown: [brown.0, brown.1], + a_path, + b_path, + lab_eps: lab.3, + lab_kappa: lab.4, + }; + let s = to_rgb(&lab)?; + run(py, &img, |src, _, dst| { + altprocess::cyanotype(src, &c, s, dst); + Some(()) + }) +} + +/// `apply_lith` once enabled. `scalars` is (d_max, over, foot_max, foot_veil, foot_rate, knee, +/// width) as float32; then the hue path and the working Lab constants. +#[pyfunction] +fn lith<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + scalars: [f32; 7], + path_u: [f32; 4], + anchors: [(f32, f32); 4], + lab: Working<'py>, +) -> PyResult>> { + let [d_max, over, foot_max, foot_veil, foot_rate, knee, width] = scalars; + let (a_path, b_path) = path(path_u, anchors); + let c = Lith { + d_max, + over, + foot_max, + foot_veil, + foot_rate, + knee, + width, + a_path, + b_path, + lab_eps: lab.3, + lab_kappa: lab.4, + }; + let s = to_rgb(&lab)?; + run(py, &img, |src, _, dst| { + altprocess::lith(src, &c, s, dst); + Some(()) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(sabattier, m)?)?; + m.add_function(wrap_pyfunction!(cyanotype, m)?)?; + m.add_function(wrap_pyfunction!(lith, m)?) +} diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs new file mode 100644 index 000000000..3a27ddcd5 --- /dev/null +++ b/negpy_core/src/py/analysis.rs @@ -0,0 +1,807 @@ +use numpy::{Element, PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::analysis; + +fn empty<'py, T: Element>( + py: Python<'py>, + shape: &[usize], + dtype: &str, +) -> PyResult>> { + Ok(py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), dtype))? + .cast_into::>()?) +} + +fn slice<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<&'a [f32]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +/// `get_luminance` of an HxWx3 float32 image. +#[pyfunction] +fn luminance<'py>( + py: Python<'py>, + rgb: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let shape = rgb.shape().to_vec(); + if shape.len() != 3 || shape[2] != 3 { + return Err(PyValueError::new_err("luminance needs an HxWx3 array")); + } + let data = slice(&rgb)?; + let out = empty::(py, &shape[..2], "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || analysis::luminance(data, dst)); + } + Ok(out) +} + +/// `to_log_density` of a float32 image, any shape. +#[pyfunction] +fn to_log_density<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let shape = img.shape().to_vec(); + let data = slice(&img)?; + let out = empty::(py, &shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || analysis::to_log_density(data, dst)); + } + Ok(out) +} + +/// `_normalize_to_uint8` of a float32 image. +#[pyfunction] +fn normalize_to_uint8<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let shape = img.shape().to_vec(); + let data = slice(&img)?; + let out = empty::(py, &shape, "uint8")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || analysis::normalize_to_uint8(data, dst)); + } + Ok(out) +} + +/// `_detection_luma` of a float32 luminance plane. +#[pyfunction] +fn detection_luma<'py>( + py: Python<'py>, + lum: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let shape = lum.shape().to_vec(); + let data = slice(&lum)?; + let out = empty::(py, &shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || analysis::detection_luma(data, dst)).ok_or_else(|| { + pyo3::exceptions::PyIndexError::new_err("percentiles of an empty array") + })?; + } + Ok(out) +} + +/// `_base_blocks(det, band)`: an Nx3 float32 array of the base blocks' means, or None. +#[pyfunction] +fn base_blocks<'py>( + py: Python<'py>, + det: PyReadonlyArrayDyn<'py, f32>, + band: PyReadonlyArrayDyn<'py, bool>, +) -> PyResult>>> { + let shape = det.shape().to_vec(); + let [h, w, 3] = shape[..] else { + return Err(PyValueError::new_err( + "flat_block_means needs an HxWx3 array", + )); + }; + if band.shape() != [h, w] { + return Err(PyValueError::new_err("band must be HxW")); + } + let data = slice(&det)?; + let mask: Vec = band + .as_slice() + .map_err(|_| PyValueError::new_err("band must be contiguous"))? + .iter() + .map(|&v| v as u8) + .collect(); + let found = super::detach(py, data.len(), || analysis::base_blocks(data, &mask, h, w)); + found + .map(|rows| { + let flat: Vec> = rows.iter().map(|r| r.to_vec()).collect(); + numpy::PyArray2::from_vec2(py, &flat).map_err(|e| PyValueError::new_err(e.to_string())) + }) + .transpose() +} + +/// `_find_rebate_level(lum, film_roi, bed)` for a 2-D float32 plane: (level, spread) or None. +#[pyfunction] +#[pyo3(signature = (lum, film_roi, bed=None))] +fn find_rebate_level( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + film_roi: (usize, usize, usize, usize), + bed: Option, +) -> PyResult> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err("find_rebate_level needs a 2-D array")); + }; + let data = slice(&lum)?; + Ok(super::detach(py, data.len(), || { + analysis::find_rebate_level(data, h, w, film_roi, bed) + })) +} + +/// `_walk_border_depth(lines, limit, bridge, relative_step)` for an L x N float32 array. +#[pyfunction] +fn walk_border_depth<'py>( + py: Python<'py>, + lines: PyReadonlyArrayDyn<'py, f32>, + limit: usize, + bridge: i64, + relative_step: bool, +) -> PyResult>> { + let [rows, n] = lines.shape()[..] else { + return Err(PyValueError::new_err("walk_border_depth needs a 2-D array")); + }; + let data = slice(&lines)?; + let out = super::detach(py, data.len(), || { + analysis::walk_border_depth(data, rows, n, limit, bridge, relative_step) + }) + .ok_or_else(|| PyValueError::new_err("walk_border_depth needs at least limit + 3 rows"))?; + Ok(numpy::PyArray1::from_vec(py, out)) +} + +/// `measure_film_edges(lum, film_roi)`: (top, bottom, left, right) border fractions. +#[pyfunction] +fn measure_film_edges( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + film_roi: (usize, usize, usize, usize), +) -> PyResult<(f64, f64, f64, f64)> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err( + "measure_film_edges needs a 2-D array", + )); + }; + let data = slice(&lum)?; + let [t, b, l, r] = super::detach(py, data.len(), || { + analysis::measure_film_edges(data, h, w, film_roi) + }); + Ok((t, b, l, r)) +} + +/// `_trim_film_edges(lum, film_roi)`. +#[pyfunction] +fn trim_film_edges( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + film_roi: (i64, i64, i64, i64), +) -> PyResult<(i64, i64, i64, i64)> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err("trim_film_edges needs a 2-D array")); + }; + let data = slice(&lum)?; + Ok(super::detach(py, data.len(), || { + analysis::trim_film_edges(data, h, w, film_roi) + })) +} + +/// `measure_film_border(lum, film_roi)`: (top, bottom, left, right) border fractions. +#[pyfunction] +fn measure_film_border( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + film_roi: (usize, usize, usize, usize), +) -> PyResult<(f64, f64, f64, f64)> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err( + "measure_film_border needs a 2-D array", + )); + }; + let data = slice(&lum)?; + let [t, b, l, r] = super::detach(py, data.len(), || { + analysis::measure_film_border(data, h, w, film_roi) + }); + Ok((t, b, l, r)) +} + +/// `_roi_from_measured_border(lum, film_roi)`. +#[pyfunction] +fn roi_from_measured_border( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + film_roi: (i64, i64, i64, i64), +) -> PyResult> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err( + "roi_from_measured_border needs a 2-D array", + )); + }; + let data = slice(&lum)?; + Ok(super::detach(py, data.len(), || { + analysis::roi_from_measured_border(data, h, w, film_roi) + })) +} + +/// `_smooth_signal(signal, window)` for a 1-D float32 signal. +#[pyfunction] +fn smooth_signal<'py>( + py: Python<'py>, + signal: PyReadonlyArrayDyn<'py, f32>, + window: usize, +) -> PyResult>> { + let data = slice(&signal)?.to_vec(); + let out = super::detach(py, data.len(), || analysis::smooth_signal(&data, window)).ok_or_else( + || { + PyValueError::new_err( + "smooth_signal covers a window of at most 31, no longer than the signal", + ) + }, + )?; + Ok(numpy::PyArray1::from_vec(py, out)) +} + +/// `_snap_edge_to_gradient(profile, idx, is_start, window_out, window_in, min_dominance, min_window_px)`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn snap_edge_to_gradient( + py: Python<'_>, + profile: PyReadonlyArrayDyn<'_, f32>, + idx: i64, + is_start: bool, + window_out: f64, + window_in: f64, + min_dominance: f64, + min_window_px: i64, +) -> PyResult { + let data = slice(&profile)?; + let snap = analysis::Snap { + window_out, + window_in, + min_dominance, + min_window_px, + }; + Ok(super::detach(py, data.len(), || { + analysis::snap_edge_to_gradient(data, idx, is_start, snap) + })) +} + +type Refined<'py> = ( + (i64, i64, i64, i64), + Bound<'py, numpy::PyArray1>, + Bound<'py, numpy::PyArray1>, +); + +/// `_refine_film_roi_by_tiers(lum, film_roi)`: (roi, row occupancy, column occupancy) or None. +#[pyfunction] +fn refine_film_roi_by_tiers<'py>( + py: Python<'py>, + lum: PyReadonlyArrayDyn<'py, f32>, + film_roi: (usize, usize, usize, usize), +) -> PyResult>> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err( + "refine_film_roi_by_tiers needs a 2-D array", + )); + }; + let data = slice(&lum)?; + let found = super::detach(py, data.len(), || { + analysis::refine_film_roi_by_tiers(data, h, w, film_roi) + }); + Ok(found.map(|r| { + ( + r.roi, + numpy::PyArray1::from_vec(py, r.rows), + numpy::PyArray1::from_vec(py, r.cols), + ) + })) +} + +/// `_refine_frame_bounds(image)`'s (left, top, right, bottom) for an HxW or HxWx3 float32 image. +#[pyfunction] +fn refine_frame_bounds( + py: Python<'_>, + image: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult<(i64, i64, i64, i64)> { + let (h, w, cn) = match image.shape()[..] { + [h, w] => (h, w, 1), + [h, w, 3] => (h, w, 3), + _ => { + return Err(PyValueError::new_err( + "refine_frame_bounds needs an HxW or HxWx3 array", + )); + } + }; + let data = slice(&image)?; + super::detach(py, data.len(), || { + analysis::refine_frame_bounds(data, h, w, cn) + }) + .ok_or_else(|| { + PyValueError::new_err("refine_frame_bounds needs an image at least 31 px on each side") + }) +} + +type Gated<'py> = ( + analysis::Roi, + Option>>, + Option>>, +); + +/// `_refine_roi_to_image(img, film_roi)` for an HxW or HxWx3 float32 image: (roi, row +/// occupancy or None, column occupancy or None). +#[pyfunction] +fn refine_roi_to_image<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + film_roi: analysis::Roi, +) -> PyResult> { + let (h, w, cn) = match img.shape()[..] { + [h, w] => (h, w, 1), + [h, w, 3] => (h, w, 3), + _ => { + return Err(PyValueError::new_err( + "refine_roi_to_image needs an HxW or HxWx3 array", + )); + } + }; + let data = slice(&img)?; + let (roi, tiers) = super::detach(py, data.len(), || { + analysis::refine_roi_to_image(data, h, w, cn, film_roi) + }) + .ok_or_else(|| { + PyValueError::new_err("refine_roi_to_image: image too small for the Sobel path") + })?; + Ok(match tiers { + Some(t) => ( + roi, + Some(numpy::PyArray1::from_vec(py, t.rows)), + Some(numpy::PyArray1::from_vec(py, t.cols)), + ), + None => (roi, None, None), + }) +} + +type DetectionFields<'py> = ( + Option, + Bound<'py, numpy::PyArray1>, + f64, +); + +/// `detect_film_bounds_with_confidence(img)` for an HxW or HxWx3 float32 image: +/// ((roi, angle, confidence, sides, corners, sources, geometry) or None, profile, contrast). +#[pyfunction] +fn detect_film_bounds<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult> { + let (h, w, cn) = match img.shape()[..] { + [h, w] => (h, w, 1), + [h, w, 3] => (h, w, 3), + _ => { + return Err(PyValueError::new_err( + "detect_film_bounds needs an HxW or HxWx3 array", + )); + } + }; + let data = slice(&img)?; + let d = super::detach(py, data.len(), || { + crate::detect::detect_film_bounds(data, h, w, cn) + }); + Ok(( + d.consensus.map(consensus_fields), + numpy::PyArray1::from_vec(py, d.profile), + d.contrast, + )) +} + +/// `normalize_log_image(img_log, bounds)` of an HxWx3 float32 log image. +#[pyfunction] +fn normalize_log_image<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + floors: [f32; 3], + ceils: [f32; 3], +) -> PyResult>> { + let shape = img.shape().to_vec(); + if shape.len() != 3 || shape[2] != 3 { + return Err(PyValueError::new_err( + "normalize_log_image needs an HxWx3 array", + )); + } + let data = slice(&img)?; + let out = empty::(py, &shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + analysis::normalize_log_image(data, floors, ceils, dst) + }); + } + Ok(out) +} + +/// `_block_median_grid` of an HxWxC float32 image in `b` x `b` blocks. +#[pyfunction] +fn block_median_grid<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + b: usize, +) -> PyResult>> { + let [h, w, c] = img.shape()[..] else { + return Err(PyValueError::new_err( + "block_median_grid needs an HxWxC array", + )); + }; + if b == 0 { + return Err(PyValueError::new_err("block size must be positive")); + } + let data = slice(&img)?; + let grid = super::detach(py, data.len(), || { + analysis::block_median_grid(data, (h, w, c), b) + }); + numpy::PyArray1::from_vec(py, grid).reshape(vec![h / b, w / b, c]) +} + +/// `_get_threshold_autocrop_coords` of an HxW float32 luma plane: (y1, y2, x1, x2) or None. +#[pyfunction] +fn threshold_box( + py: Python<'_>, + lum: PyReadonlyArrayDyn<'_, f32>, + threshold: f32, +) -> PyResult> { + let [h, w] = lum.shape()[..] else { + return Err(PyValueError::new_err("threshold_box needs a 2-D array")); + }; + let data = slice(&lum)?; + Ok(super::detach(py, data.len(), || { + analysis::threshold_box(data, h, w, threshold) + })) +} + +/// Batch autocrop's `_vertical_edge_profile` of an HxW or HxWxC (C >= 3) float32 image: +/// (profile, contrast). +#[pyfunction] +fn batch_edge_profile<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult<(Bound<'py, numpy::PyArray1>, f64)> { + let (h, w, cn) = match img.shape()[..] { + [h, w] | [h, w, 1] => (h, w, 1), + [h, w, c] if c >= 3 => (h, w, c), + _ => { + return Err(PyValueError::new_err( + "batch_edge_profile needs an HxW, HxWx1 or HxWxC array, C >= 3", + )); + } + }; + let data = slice(&img)?; + let (profile, contrast) = super::detach(py, data.len(), || { + analysis::batch_edge_profile(data, h, w, cn) + }); + Ok((numpy::PyArray1::from_vec(py, profile), contrast)) +} + +/// The HxW of an HxWx3 detection image. +fn det_dims(det: &PyReadonlyArrayDyn<'_, f32>) -> PyResult<(usize, usize)> { + match det.shape()[..] { + [h, w, 3] => Ok((h, w)), + _ => Err(PyValueError::new_err("needs an HxWx3 array")), + } +} + +/// `film_and_picture(det)` of an HxWx3 float32 detection image: (film roi or None, gate). +#[pyfunction] +fn film_and_picture( + py: Python<'_>, + det: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult<(Option, analysis::Roi)> { + let (h, w) = det_dims(&det)?; + let data = slice(&det)?; + super::detach(py, data.len(), || analysis::film_and_picture(data, h, w)).ok_or_else(|| { + PyValueError::new_err("film_and_picture: image too small for the Sobel path") + }) +} + +/// `measure_base_log` of an HxWx3 float32 detection image: (r, g, b) log10 or None. +#[pyfunction] +fn measure_base_log( + py: Python<'_>, + det: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult> { + let (h, w) = det_dims(&det)?; + let data = slice(&det)?; + Ok(super::detach(py, data.len(), || { + analysis::measure_base_log(data, h, w) + })) +} + +type ConsensusFields = ( + analysis::Roi, + f64, + f64, + Vec<&'static str>, + Vec<&'static str>, + Vec<&'static str>, + f64, +); + +fn consensus_fields(c: crate::detect::Consensus) -> ConsensusFields { + ( + c.roi, + c.correction_angle, + c.confidence, + c.sides, + c.corners, + c.sources, + c.geometry_score, + ) +} + +fn known(value: &str, names: &[&'static str]) -> PyResult<&'static str> { + names + .iter() + .find(|n| **n == value) + .copied() + .ok_or_else(|| PyValueError::new_err(format!("unknown name {value:?}"))) +} + +fn flags(values: &[String], names: &[&'static str]) -> PyResult<[bool; 4]> { + let mut out = [false; 4]; + for v in values { + out[names + .iter() + .position(|n| n == v) + .ok_or_else(|| PyValueError::new_err(format!("unknown name {v:?}")))?] = true; + } + Ok(out) +} + +type CandidateFields = ( + analysis::Roi, + f64, + String, + Option, + String, + f64, + f64, + Vec, + Vec, +); + +/// `_select_consensus_cluster` over (roi, angle, source, threshold index, polarity, boundary, +/// geometry, sides, corners) candidates in an HxW image. +#[pyfunction] +fn select_consensus( + candidates: Vec, + h: usize, + w: usize, +) -> PyResult> { + use crate::detect::{CORNER_SIDES, Candidate, EVIDENCE_SOURCE_ORDER, SIDE_NAMES}; + let corners: Vec<&'static str> = CORNER_SIDES.iter().map(|c| c.0).collect(); + let cands = candidates + .into_iter() + .map( + |(roi, angle, source, index, polarity, boundary, geometry, s, c)| { + Ok(Candidate { + roi, + correction_angle: angle, + source: known(&source, &EVIDENCE_SOURCE_ORDER)?, + threshold_index: index, + polarity: known(&polarity, &["dark", "bright"])?, + boundary_score: boundary, + geometry_score: geometry, + sides: flags(&s, &SIDE_NAMES)?, + corners: flags(&c, &corners)?, + }) + }, + ) + .collect::>>()?; + Ok(crate::detect::select_consensus(&cands, h, w).map(consensus_fields)) +} + +fn lum_dims<'a>(lum: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], usize, usize)> { + match lum.shape()[..] { + [h, w] => Ok((slice(lum)?, h, w)), + _ => Err(PyValueError::new_err("needs an HxW array")), + } +} + +/// `_film_box_covers_enough(lum, roi)`. +#[pyfunction] +fn film_box_covers_enough(lum: PyReadonlyArrayDyn<'_, f32>, roi: analysis::Roi) -> PyResult { + let (_, h, w) = lum_dims(&lum)?; + Ok(crate::detect::film_box_covers_enough(h, w, &roi)) +} + +/// `_film_surround_is_plausible(lum, roi)`. +#[pyfunction] +fn film_surround_is_plausible( + lum: PyReadonlyArrayDyn<'_, f32>, + roi: analysis::Roi, +) -> PyResult { + let (data, h, w) = lum_dims(&lum)?; + Ok(crate::detect::film_surround_is_plausible(data, h, w, &roi)) +} + +fn rgb_dims(a: &PyReadonlyArrayDyn<'_, f32>, name: &str) -> PyResult<(usize, usize)> { + match a.shape()[..] { + [h, w, 3] => Ok((h, w)), + _ => Err(PyValueError::new_err(format!( + "{name} needs an HxWx3 array" + ))), + } +} + +fn filled_f32<'py>( + py: Python<'py>, + shape: &[usize], + n: usize, + f: impl FnOnce(&mut [f32]) -> Option<()> + Send, +) -> PyResult>> { + let out = empty::(py, shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, n, || f(dst)) + .ok_or_else(|| PyValueError::new_err("blur sigma must be positive"))?; + } + Ok(out) +} + +/// `measure_clip_fractions` of an HxWx3 float32 image over the window (y1, y2, x1, x2). +#[pyfunction] +fn clip_fractions( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + window: (usize, usize, usize, usize), + level: f32, +) -> PyResult<(f64, f64, f64)> { + let (h, w) = rgb_dims(&img, "clip_fractions")?; + let (y1, y2, x1, x2) = window; + if y1 > y2 || x1 > x2 || y2 > h || x2 > w { + return Err(PyValueError::new_err("window outside the image")); + } + let data = slice(&img)?; + let f = super::detach(py, (y2 - y1) * (x2 - x1) * 3 / 16, || { + analysis::clip_fractions(data, w, window, level) + }); + Ok((f[0], f[1], f[2])) +} + +/// The median of a float32 grid's float64 Rec. 709 luma, any shape that ends in 3. +#[pyfunction] +fn luma_median(py: Python<'_>, grid: PyReadonlyArrayDyn<'_, f32>) -> PyResult { + if grid.shape().last() != Some(&3) { + return Err(PyValueError::new_err("luma_median needs an ...x3 array")); + } + let data = slice(&grid)?; + Ok(super::detach(py, data.len(), || { + analysis::luma_median(data) + })) +} + +/// Each pixel of an HxWx3 float32 plane set to its float32 Rec. 709 luma. +#[pyfunction] +fn luma_planes<'py>( + py: Python<'py>, + val: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let (h, w) = rgb_dims(&val, "luma_planes")?; + let data = slice(&val)?; + filled_f32(py, &[h, w, 3], data.len(), |dst| { + analysis::luma_planes(data, dst); + Some(()) + }) +} + +/// `contrast_mask_plane`'s blurred zero-mean luma of a normalized HxWx3 plane, and its mean. +#[pyfunction] +fn contrast_mask_tail<'py>( + py: Python<'py>, + val: PyReadonlyArrayDyn<'py, f32>, + sigma: f64, +) -> PyResult<(Bound<'py, PyArrayDyn>, f32)> { + let (h, w) = rgb_dims(&val, "contrast_mask_tail")?; + let data = slice(&val)?; + let (plane, centre) = super::detach(py, data.len(), || { + analysis::contrast_mask_tail(data, h, w, sigma) + }) + .ok_or_else(|| PyValueError::new_err("blur sigma must be positive"))?; + Ok(( + numpy::PyArray1::from_vec(py, plane).reshape(vec![h, w])?, + centre, + )) +} + +/// `diffusion_grid`'s blurred light of an unmixed HxWx3 log plane, as log10. +#[pyfunction] +fn diffusion_tail<'py>( + py: Python<'py>, + unmixed: PyReadonlyArrayDyn<'py, f32>, + sigma: f64, +) -> PyResult>> { + let (h, w) = rgb_dims(&unmixed, "diffusion_tail")?; + let data = slice(&unmixed)?; + filled_f32(py, &[h, w, 3], data.len(), |dst| { + analysis::diffusion_tail(data, h, w, sigma, dst) + }) +} + +/// The normalization processors' open-ended log density of a float32 image, any shape. +#[pyfunction] +fn log_density_open<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let shape = img.shape().to_vec(); + let data = slice(&img)?; + let out = empty::(py, &shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || analysis::log_density_open(data, dst)); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(luminance, m)?)?; + m.add_function(wrap_pyfunction!(to_log_density, m)?)?; + m.add_function(wrap_pyfunction!(log_density_open, m)?)?; + m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; + m.add_function(wrap_pyfunction!(detection_luma, m)?)?; + m.add_function(wrap_pyfunction!(base_blocks, m)?)?; + m.add_function(wrap_pyfunction!(find_rebate_level, m)?)?; + m.add_function(wrap_pyfunction!(walk_border_depth, m)?)?; + m.add_function(wrap_pyfunction!(measure_film_edges, m)?)?; + m.add_function(wrap_pyfunction!(trim_film_edges, m)?)?; + m.add_function(wrap_pyfunction!(measure_film_border, m)?)?; + m.add_function(wrap_pyfunction!(roi_from_measured_border, m)?)?; + m.add_function(wrap_pyfunction!(smooth_signal, m)?)?; + m.add_function(wrap_pyfunction!(snap_edge_to_gradient, m)?)?; + m.add_function(wrap_pyfunction!(refine_film_roi_by_tiers, m)?)?; + m.add_function(wrap_pyfunction!(refine_frame_bounds, m)?)?; + m.add_function(wrap_pyfunction!(refine_roi_to_image, m)?)?; + m.add_function(wrap_pyfunction!(detect_film_bounds, m)?)?; + m.add_function(wrap_pyfunction!(select_consensus, m)?)?; + m.add_function(wrap_pyfunction!(normalize_log_image, m)?)?; + m.add_function(wrap_pyfunction!(block_median_grid, m)?)?; + m.add_function(wrap_pyfunction!(threshold_box, m)?)?; + m.add_function(wrap_pyfunction!(batch_edge_profile, m)?)?; + m.add_function(wrap_pyfunction!(film_and_picture, m)?)?; + m.add_function(wrap_pyfunction!(measure_base_log, m)?)?; + m.add_function(wrap_pyfunction!(film_box_covers_enough, m)?)?; + m.add_function(wrap_pyfunction!(film_surround_is_plausible, m)?)?; + m.add_function(wrap_pyfunction!(clip_fractions, m)?)?; + m.add_function(wrap_pyfunction!(luma_median, m)?)?; + m.add_function(wrap_pyfunction!(luma_planes, m)?)?; + m.add_function(wrap_pyfunction!(contrast_mask_tail, m)?)?; + m.add_function(wrap_pyfunction!(diffusion_tail, m)?) +} diff --git a/negpy_core/src/py/autocrop.rs b/negpy_core/src/py/autocrop.rs new file mode 100644 index 000000000..e7df53dbd --- /dev/null +++ b/negpy_core/src/py/autocrop.rs @@ -0,0 +1,73 @@ +use numpy::{PyReadonlyArray1, PyReadonlyArray2, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::autocrop::{self, Template}; + +/// `_top_edge_slope(lum, (y1, y2, x1, x2))` on an HxW float32 luminance plane: degrees or None. +#[pyfunction] +fn top_edge_slope( + py: Python<'_>, + lum: PyReadonlyArray2<'_, f32>, + roi: (i64, i64, i64, i64), +) -> PyResult> { + let dims = (lum.shape()[0], lum.shape()[1]); + let img = lum + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous plane"))?; + let (y1, _, x1, x2) = roi; + Ok(super::detach(py, img.len(), || { + autocrop::top_edge_slope(img, dims, (y1, x1, x2)) + })) +} + +/// `_rect_from_edge_profile` for a float32 profile, its contrast and the roll template's +/// (center_x, width, fallback_width, top, height, center_mad, width_mad). +#[pyfunction] +fn rect_from_edge_profile( + py: Python<'_>, + profile: PyReadonlyArray1<'_, f32>, + contrast: f64, + template: (f64, f64, f64, f64, f64, f64, f64), +) -> PyResult> { + let p = profile + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous profile"))?; + let (center_x, width, fallback_width, top, height, center_mad, width_mad) = template; + let t = Template { + center_x, + width, + fallback_width, + top, + height, + center_mad, + width_mad, + }; + Ok(super::detach(py, p.len(), || { + autocrop::rect_from_edge_profile(p, contrast, &t) + })) +} + +/// `_place_window_by_occupancy(start, end, target_len, occupancy, scale)` for a float32 profile. +#[pyfunction] +fn place_window_by_occupancy( + py: Python<'_>, + start: i64, + end: i64, + target_len: f64, + occupancy: PyReadonlyArray1<'_, f32>, + scale: f64, +) -> PyResult { + let occ = occupancy + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous profile"))?; + Ok(super::detach(py, occ.len(), || { + autocrop::place_window_by_occupancy(start, end, target_len, occ, scale) + })) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(top_edge_slope, m)?)?; + m.add_function(wrap_pyfunction!(rect_from_edge_profile, m)?)?; + m.add_function(wrap_pyfunction!(place_window_by_occupancy, m)?) +} diff --git a/negpy_core/src/py/bounds.rs b/negpy_core/src/py/bounds.rs new file mode 100644 index 000000000..bff521999 --- /dev/null +++ b/negpy_core/src/py/bounds.rs @@ -0,0 +1,57 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::bounds::{self, ColorPass}; + +fn grid_of<'a>(grid: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<&'a [f32]> { + if grid.ndim() != 3 || grid.shape()[2] != 3 { + return Err(PyValueError::new_err("needs an HxWx3 array")); + } + grid.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +/// `np.percentile(grid[:, :, ch], qs)` per channel of an HxWx3 float32 grid, float64 interpolation. +#[pyfunction] +fn channel_percentiles( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + qs: Vec, +) -> PyResult<[Vec; 3]> { + let g = grid_of(&grid)?; + Ok(super::detach(py, g.len(), || { + bounds::channel_percentiles(g, &qs) + })) +} + +/// `_same_pixel_color_floor_refs` with its constants (chroma quantile, chroma cap, first-pass +/// cap, minimum pixels, band width). +#[pyfunction] +fn same_pixel_color_floor_refs( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + luma_floors: [f64; 3], + luma_ceils: [f64; 3], + base: [f64; 3], + color_clip: f64, + constants: (f64, f64, f64, usize, f64), +) -> PyResult> { + let g = grid_of(&grid)?; + let (chroma_quantile, chroma_cap, first_pass_cap, min_pixels, band_width) = constants; + let c = ColorPass { + chroma_quantile, + chroma_cap, + first_pass_cap, + min_pixels, + band_width, + }; + Ok(super::detach(py, g.len(), || { + bounds::same_pixel_color_floor_refs(g, luma_floors, luma_ceils, base, color_clip, c) + })) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(channel_percentiles, m)?)?; + m.add_function(wrap_pyfunction!(same_pixel_color_floor_refs, m)?) +} diff --git a/negpy_core/src/py/capture.rs b/negpy_core/src/py/capture.rs new file mode 100644 index 000000000..158b1a417 --- /dev/null +++ b/negpy_core/src/py/capture.rs @@ -0,0 +1,382 @@ +use numpy::{PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::prelude::*; + +use crate::capture::{self, Stretch}; +use crate::stats::{np_percentiles, np_percentiles_f64}; + +fn contiguous<'a, T: numpy::Element>(a: &'a PyReadonlyArrayDyn<'_, T>) -> PyResult<&'a [T]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +fn dims(shape: &[usize]) -> PyResult<(usize, usize, usize)> { + match shape { + [h, w] => Ok((*h, *w, 1)), + [h, w, c] if *c > 0 => Ok((*h, *w, *c)), + _ => Err(PyValueError::new_err("needs an HxW or HxWxC array")), + } +} + +/// The scan preview stretch of a float32 image: `mode` "positive", "negative" (`preview_positive`) +/// or "prescan" (`_preview_u8`, always three channels out). +#[pyfunction] +fn stretch_preview<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + mode: &str, +) -> PyResult>> { + let (h, w, cn) = dims(img.shape())?; + let mode = match mode { + "positive" => Stretch::Positive, + "negative" => Stretch::Negative, + "prescan" => Stretch::Prescan, + _ => { + return Err(PyValueError::new_err( + "mode must be positive, negative or prescan", + )); + } + }; + let out_cn = if mode == Stretch::Prescan { 3 } else { cn }; + let src = contiguous(&img)?; + let out = PyArrayDyn::::zeros(py, vec![h, w, out_cn], false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || { + capture::stretch_preview(src, cn, out_cn, mode, dst) + }); + } + Ok(out) +} + +/// `sharpness` of an HxW float32 frame. +#[pyfunction] +fn sharpness(py: Python<'_>, gray: PyReadonlyArrayDyn<'_, f32>, min_mean: f64) -> PyResult { + let [h, w] = gray.shape()[..] else { + return Err(PyValueError::new_err("sharpness needs a 2-D array")); + }; + if h < 3 || w < 3 { + return Ok(0.0); + } + let src = contiguous(&gray)?; + Ok(super::detach(py, src.len(), || { + capture::sharpness(src, h, w, min_mean) + })) +} + +/// The share of pixels of an HxWxC float32 image where any of the first three channels is at least `level`. +#[pyfunction] +fn any_channel_at_least( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + level: f32, +) -> PyResult { + let (_, _, cn) = dims(img.shape())?; + let src = contiguous(&img)?; + if src.is_empty() { + return Ok(f64::NAN); + } + Ok(super::detach(py, src.len(), || { + capture::any_channel_at_least(src, cn, level) + })) +} + +/// `(np.mean(patch >= level), np.percentile(patch, q))` of a uint16, float32 or float64 patch, the +/// percentile in float32 for float32 and in float64 otherwise, as NumPy computes them. +#[pyfunction] +fn patch_level_stats( + py: Python<'_>, + patch: &Bound<'_, PyAny>, + level: f64, + q: f64, +) -> PyResult<(f64, f64)> { + fn stats(v: &[f32], level: f32, q: f64, wide: bool) -> (f64, f64) { + let hits = v.iter().filter(|&&x| x >= level).count(); + let p = if wide { + np_percentiles_f64(v, &[q])[0] + } else { + np_percentiles(v, &[q]).map_or(f64::NAN, |p| p[0] as f64) + }; + (hits as f64 / v.len() as f64, p) + } + if let Ok(a) = patch.extract::>() { + let src = contiguous(&a)?; + if src.is_empty() { + return Ok((0.0, 0.0)); + } + Ok(super::detach(py, src.len(), || { + let v: Vec = src.iter().map(|&x| x as f32).collect(); + stats(&v, level as f32, q, true) + })) + } else if let Ok(a) = patch.extract::>() { + let src = contiguous(&a)?; + if src.is_empty() { + return Ok((0.0, 0.0)); + } + Ok(super::detach(py, src.len(), || { + stats(src, level as f32, q, false) + })) + } else if let Ok(a) = patch.extract::>() { + let src = contiguous(&a)?; + if src.is_empty() { + return Ok((0.0, 0.0)); + } + Ok(super::detach(py, src.len(), || { + let hits = src.iter().filter(|&&x| x >= level).count(); + let p = if src.iter().any(|v| v.is_nan()) { + f64::NAN + } else { + crate::stats::quantile_f64(&mut src.to_vec(), q / 100.0).unwrap_or(f64::NAN) + }; + (hits as f64 / src.len() as f64, p) + })) + } else { + Err(PyTypeError::new_err( + "patch_level_stats needs uint16, float32 or float64", + )) + } +} + +/// `_to_grey` of a uint8 or uint16 HxWxC image. +#[pyfunction] +fn to_grey<'py>(py: Python<'py>, rgb: &Bound<'py, PyAny>) -> PyResult> { + fn run<'py, T: numpy::Element + Copy + Send + Sync + Into + TryFrom>( + py: Python<'py>, + rgb: &Bound<'py, PyAny>, + ) -> PyResult> { + let a: PyReadonlyArrayDyn<'py, T> = rgb.extract()?; + let (h, w, cn) = dims(a.shape())?; + let src = contiguous(&a)?; + let out = PyArrayDyn::::zeros(py, vec![h, w], false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || capture::to_grey(src, cn, dst)); + } + Ok(out.into_any()) + } + if rgb.extract::>().is_ok() { + run::(py, rgb) + } else if rgb.extract::>().is_ok() { + run::(py, rgb) + } else { + Err(PyTypeError::new_err("to_grey needs uint8 or uint16")) + } +} + +/// `np.clip(img.astype(np.float32) / divisor, 0, 1)` of a uint8, uint16, float32 or float64 array. +#[pyfunction] +fn normalized<'py>( + py: Python<'py>, + img: &Bound<'py, PyAny>, + divisor: f32, +) -> PyResult>> { + fn run<'py, T: numpy::Element + Copy + Send + Sync + Into>( + py: Python<'py>, + img: &Bound<'py, PyAny>, + divisor: f32, + ) -> PyResult>> { + let a: PyReadonlyArrayDyn<'py, T> = img.extract()?; + let src = contiguous(&a)?; + let out = PyArrayDyn::::zeros(py, a.shape().to_vec(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || capture::normalized(src, divisor, dst)); + } + Ok(out) + } + if img.extract::>().is_ok() { + run::(py, img, divisor) + } else if img.extract::>().is_ok() { + run::(py, img, divisor) + } else if img.extract::>().is_ok() { + run::(py, img, divisor) + } else if img.extract::>().is_ok() { + run::(py, img, divisor) + } else { + Err(PyTypeError::new_err( + "normalized needs uint8, uint16, float32 or float64", + )) + } +} + +/// `meter_frame`'s picture statistics of an HxWx3 float32 frame over (y1, y2, x1, x2): +/// (clipped fraction, blown, 99.9th percentile per channel of the film pixels). +#[pyfunction] +fn meter_picture( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + roi: (usize, usize, usize, usize), + sat: f32, + blown_share: f64, +) -> PyResult<(f64, bool, [f32; 3])> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("meter_picture needs an HxWx3 array")); + }; + let (y1, y2, x1, x2) = roi; + if y1 >= y2 || x1 >= x2 || y2 > h || x2 > w { + return Err(PyValueError::new_err("roi outside the frame or empty")); + } + let src = contiguous(&img)?; + Ok(super::detach(py, (y2 - y1) * (x2 - x1) * 3, || { + capture::meter_picture(src, w, roi, sat, blown_share) + })) +} + +/// `raw_channel_clip_fraction` of a uint16 ROI and its uint8 CFA colours, keeping the colours in +/// `wanted`: (linearity fraction or 0 without a threshold, plateau fraction); None for no sites. +#[pyfunction] +#[pyo3(signature = (img, colors, wanted, threshold, guard, window, min_sites, neighbours, ratio))] +#[allow(clippy::too_many_arguments)] +fn raw_clip_fractions( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, u16>, + colors: PyReadonlyArrayDyn<'_, u8>, + wanted: Vec, + threshold: Option, + guard: u32, + window: usize, + min_sites: usize, + neighbours: usize, + ratio: f64, +) -> PyResult> { + if img.shape() != colors.shape() { + return Err(PyValueError::new_err("image and colours differ in shape")); + } + let (v, c) = (contiguous(&img)?, contiguous(&colors)?); + Ok(super::detach(py, v.len(), || { + let values: Vec = v + .iter() + .zip(c) + .filter(|(_, k)| wanted.contains(k)) + .map(|(&x, _)| x) + .collect(); + (!values.is_empty()).then(|| { + capture::raw_clip_fractions( + &values, + threshold, + guard, + (window, min_sites, neighbours, ratio), + ) + }) + })) +} + +/// `_gutter_scan`'s column profile of an HxW float32 plane: every `step`th row over `peak`, averaged per column. +#[pyfunction] +fn column_means<'py>( + py: Python<'py>, + a: PyReadonlyArrayDyn<'py, f32>, + step: usize, + peak: f32, +) -> PyResult>> { + let [_, w] = a.shape()[..] else { + return Err(PyValueError::new_err("column_means needs a 2-D array")); + }; + let src = contiguous(&a)?; + let out = super::detach(py, src.len() / step.max(1), || { + capture::column_means(src, w, step, peak) + }); + Ok(numpy::PyArray1::from_vec(py, out)) +} + +/// `_looks_border_crushed`'s medians of an HxW float32 sample: (ring `m` wide, central half). +#[pyfunction] +fn ring_inner_medians( + py: Python<'_>, + arr: PyReadonlyArrayDyn<'_, f32>, + m: usize, +) -> PyResult<(f32, f32)> { + let [h, w] = arr.shape()[..] else { + return Err(PyValueError::new_err( + "ring_inner_medians needs a 2-D array", + )); + }; + if 2 * m > h || 2 * m > w || h < 2 || w < 2 { + return Err(PyValueError::new_err("ring wider than the sample")); + } + let src = contiguous(&arr)?; + Ok(super::detach(py, src.len(), || { + capture::ring_inner_medians(src, h, w, m) + })) +} + +/// `print_dmax` of an HxWx3 uint8 thumbnail: its D-max colour, or None without pixels. +#[pyfunction] +fn print_dmax( + py: Python<'_>, + rgb: PyReadonlyArrayDyn<'_, u8>, + share: f64, +) -> PyResult> { + let src = contiguous(&rgb)?; + if src.len() % 3 != 0 { + return Err(PyValueError::new_err("print_dmax needs RGB pixels")); + } + let px = src.as_chunks::<3>().0; + Ok(super::detach(py, src.len(), || capture::print_dmax(px, share)).map(|c| (c[0], c[1], c[2]))) +} + +/// `_peak_map(np.max(img, axis=2))` of an HxWx3 float32 frame with blocks of f x f. +#[pyfunction] +fn peak_map<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + f: usize, +) -> PyResult> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("peak_map needs an HxWx3 array")); + }; + let src = contiguous(&img)?; + let (out, ph, pw) = super::detach(py, src.len(), || capture::peak_map(src, h, w, f.max(1))); + Ok(numpy::PyArray1::from_vec(py, out) + .reshape([ph, pw])? + .into_any()) +} + +/// `preview_positive`'s log stretch of a linear HxWx3 float32 thumbnail, as uint8. +#[pyfunction] +fn log_stretch_u8<'py>( + py: Python<'py>, + linear: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let [h, w, 3] = linear.shape()[..] else { + return Err(PyValueError::new_err("log_stretch_u8 needs an HxWx3 array")); + }; + let src = contiguous(&linear)?; + if src.is_empty() { + return Err(PyValueError::new_err("log_stretch_u8 needs pixels")); + } + let out = PyArrayDyn::::zeros(py, vec![h, w, 3], false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || capture::log_stretch_u8(src, dst)); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(log_stretch_u8, m)?)?; + m.add_function(wrap_pyfunction!(peak_map, m)?)?; + m.add_function(wrap_pyfunction!(normalized, m)?)?; + m.add_function(wrap_pyfunction!(meter_picture, m)?)?; + m.add_function(wrap_pyfunction!(raw_clip_fractions, m)?)?; + m.add_function(wrap_pyfunction!(column_means, m)?)?; + m.add_function(wrap_pyfunction!(ring_inner_medians, m)?)?; + m.add_function(wrap_pyfunction!(print_dmax, m)?)?; + m.add_function(wrap_pyfunction!(stretch_preview, m)?)?; + m.add_function(wrap_pyfunction!(sharpness, m)?)?; + m.add_function(wrap_pyfunction!(any_channel_at_least, m)?)?; + m.add_function(wrap_pyfunction!(patch_level_stats, m)?)?; + m.add_function(wrap_pyfunction!(to_grey, m)?) +} diff --git a/negpy_core/src/py/color.rs b/negpy_core/src/py/color.rs new file mode 100644 index 000000000..9455309e8 --- /dev/null +++ b/negpy_core/src/py/color.rs @@ -0,0 +1,179 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::color::{self, LabSpace}; + +fn filled<'py>( + py: Python<'py>, + src: &PyReadonlyArrayDyn<'py, f32>, + fill: impl FnOnce(&[f32], &mut [f32]) + Send, +) -> PyResult>> { + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let out = PyArrayDyn::::zeros(py, src.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || fill(data, dst)); + } + Ok(out) +} + +/// `_oetf_encode_flat` of a contiguous float32 array. +#[pyfunction] +fn oetf_encode<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + inv_gamma: f32, +) -> PyResult>> { + filled(py, &img, |s, d| color::oetf_encode(s, d, inv_gamma)) +} + +/// `_oetf_decode_flat` of a contiguous float32 array. +#[pyfunction] +fn oetf_decode<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + gamma: f32, +) -> PyResult>> { + filled(py, &img, |s, d| color::oetf_decode(s, d, gamma)) +} + +/// `srgb_to_linear` of a float32 array, any shape. +#[pyfunction] +fn srgb_to_linear<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + filled(py, &img, color::srgb_to_linear) +} + +pub(crate) fn space( + m: &PyReadonlyArray2<'_, f32>, + white: &PyReadonlyArray1<'_, f32>, + eps: f32, + kappa: f32, +) -> PyResult { + let m = m.as_array(); + let white = white.as_array(); + if m.shape() != [3, 3] || white.len() != 3 { + return Err(PyValueError::new_err( + "needs a 3x3 matrix and a 3-vector white", + )); + } + Ok(LabSpace::new( + std::array::from_fn(|r| std::array::from_fn(|c| m[[r, c]])), + std::array::from_fn(|c| white[c]), + eps, + kappa, + )) +} + +fn triplets(img: &PyReadonlyArrayDyn<'_, f32>) -> PyResult<()> { + if img.shape().last() != Some(&3) { + return Err(PyValueError::new_err("needs a last axis of 3")); + } + Ok(()) +} + +/// `_rgb_to_lab_kernel` of an (..., 3) float32 array. +#[pyfunction] +fn rgb_to_lab<'py>( + py: Python<'py>, + px: PyReadonlyArrayDyn<'py, f32>, + m: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + triplets(&px)?; + let s = space(&m, &white, eps, kappa)?; + filled(py, &px, |src, d| color::rgb_to_lab(src, d, s)) +} + +/// `_lab_to_rgb_kernel` of an (..., 3) float32 array. +#[pyfunction] +fn lab_to_rgb<'py>( + py: Python<'py>, + lab: PyReadonlyArrayDyn<'py, f32>, + m: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + triplets(&lab)?; + let s = space(&m, &white, eps, kappa)?; + filled(py, &lab, |src, d| color::lab_to_rgb(src, d, s)) +} + +/// `_skin_chroma_rein_kernel` of an (..., 3) float32 Lab array. +#[pyfunction] +fn skin_chroma_rein<'py>( + py: Python<'py>, + lab: PyReadonlyArrayDyn<'py, f32>, + strength: f32, +) -> PyResult>> { + triplets(&lab)?; + filled(py, &lab, |src, d| color::skin_chroma_rein(src, d, strength)) +} + +/// `_skin_weight(l, a, b)`. +#[pyfunction] +fn skin_weight(l: f32, a: f32, b: f32) -> f64 { + color::skin_weight_of(l, a, b) +} + +/// `_matmul_3x3_kernel` of an (..., 3) float32 array. +#[pyfunction] +fn matmul_3x3<'py>( + py: Python<'py>, + px: PyReadonlyArrayDyn<'py, f32>, + m: PyReadonlyArray2<'py, f32>, +) -> PyResult>> { + triplets(&px)?; + let m = m.as_array(); + if m.shape() != [3, 3] { + return Err(PyValueError::new_err("needs a 3x3 matrix")); + } + let m = std::array::from_fn(|r| std::array::from_fn(|c| m[[r, c]])); + filled(py, &px, |src, d| color::matmul_3x3(src, d, m)) +} + +/// `_gamut_aware_chroma_scale_kernel` of an (..., 3) float32 Lab array. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn gamut_aware_chroma_scale<'py>( + py: Python<'py>, + lab: PyReadonlyArrayDyn<'py, f32>, + saturation: f32, + m: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, + iters: usize, +) -> PyResult>> { + triplets(&lab)?; + let s = space(&m, &white, eps, kappa)?; + filled(py, &lab, |src, d| { + color::gamut_aware_chroma_scale(src, d, saturation, iters, s) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(oetf_encode, m)?)?; + m.add_function(wrap_pyfunction!(oetf_decode, m)?)?; + m.add_function(wrap_pyfunction!(rgb_to_lab, m)?)?; + m.add_function(wrap_pyfunction!(lab_to_rgb, m)?)?; + m.add_function(wrap_pyfunction!(skin_chroma_rein, m)?)?; + m.add_function(wrap_pyfunction!(skin_weight, m)?)?; + m.add_function(wrap_pyfunction!(matmul_3x3, m)?)?; + m.add_function(wrap_pyfunction!(srgb_to_linear, m)?)?; + m.add_function(wrap_pyfunction!(gamut_aware_chroma_scale, m)?) +} diff --git a/negpy_core/src/py/components.rs b/negpy_core/src/py/components.rs new file mode 100644 index 000000000..18bae0628 --- /dev/null +++ b/negpy_core/src/py/components.rs @@ -0,0 +1,124 @@ +use numpy::{PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::components; + +type Labelled<'py> = ( + usize, + Bound<'py, numpy::PyArrayDyn>, + Bound<'py, numpy::PyArray2>, + Bound<'py, numpy::PyArray2>, +); + +/// `cv2.connectedComponentsWithStats(mask, connectivity=...)` of an HxW uint8 mask: +/// (count, labels, stats, centroids). +#[pyfunction] +#[pyo3(signature = (mask, connectivity=8))] +fn connected_components_with_stats<'py>( + py: Python<'py>, + mask: PyReadonlyArrayDyn<'py, u8>, + connectivity: u8, +) -> PyResult> { + let [h, w] = mask.shape()[..] else { + return Err(PyValueError::new_err("needs an HxW array")); + }; + if connectivity != 4 && connectivity != 8 { + return Err(PyValueError::new_err("connectivity must be 4 or 8")); + } + let data = mask + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let c = super::detach(py, data.len(), || { + components::connected_components(data, h, w, connectivity) + }); + let labels = numpy::PyArray1::from_vec(py, c.labels).reshape(vec![h, w])?; + let stats = + numpy::PyArray2::from_vec2(py, &c.stats.iter().map(|s| s.to_vec()).collect::>()) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let centroids = numpy::PyArray2::from_vec2( + py, + &c.centroids.iter().map(|s| s.to_vec()).collect::>(), + ) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok((c.count, labels, stats, centroids)) +} + +fn mask_of<'a>(mask: &'a PyReadonlyArrayDyn<'_, u8>) -> PyResult<(&'a [u8], usize, usize)> { + let [h, w] = mask.shape()[..] else { + return Err(PyValueError::new_err("needs an HxW array")); + }; + let data = mask + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok((data, h, w)) +} + +/// `cv2.distanceTransform(mask, dist_type, mask_size)` for DIST_C (3) and DIST_L2 (3 or 5). +#[pyfunction] +fn distance_transform<'py>( + py: Python<'py>, + mask: PyReadonlyArrayDyn<'py, u8>, + dist_type: i32, + mask_size: i32, +) -> PyResult>> { + use crate::distance::{self, Metric}; + let metric = match (dist_type, mask_size) { + (3, _) => Metric::Chessboard, + (2, 3) => Metric::L2Mask3, + (2, 5) => Metric::L2Mask5, + _ => { + return Err(PyValueError::new_err( + "covers DIST_C and DIST_L2 with mask 3 or 5", + )); + } + }; + let (data, h, w) = mask_of(&mask)?; + let d = super::detach(py, data.len(), || { + distance::distance_transform(data, h, w, metric) + }); + numpy::PyArray1::from_vec(py, d).reshape(vec![h, w]) +} + +type Distances<'py> = ( + Bound<'py, numpy::PyArrayDyn>, + Bound<'py, numpy::PyArrayDyn>, +); + +/// `cv2.distanceTransformWithLabels(mask, DIST_L2, _, labelType=DIST_LABEL_PIXEL)`: (dist, labels). +#[pyfunction] +fn distance_transform_labels<'py>( + py: Python<'py>, + mask: PyReadonlyArrayDyn<'py, u8>, +) -> PyResult> { + let (data, h, w) = mask_of(&mask)?; + let (d, l) = super::detach(py, data.len(), || { + crate::distance::distance_transform_labels(data, h, w) + }); + Ok(( + numpy::PyArray1::from_vec(py, d).reshape(vec![h, w])?, + numpy::PyArray1::from_vec(py, l).reshape(vec![h, w])?, + )) +} + +/// Retouch's `_mask_to_score(mask, pad_px)` with its score floor. +#[pyfunction] +fn mask_to_score<'py>( + py: Python<'py>, + mask: PyReadonlyArrayDyn<'py, u8>, + pad: f64, + floor: f64, +) -> PyResult>> { + let (data, h, w) = mask_of(&mask)?; + let d = super::detach(py, data.len(), || { + crate::distance::mask_to_score(data, h, w, pad, floor) + }); + numpy::PyArray1::from_vec(py, d).reshape(vec![h, w]) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(mask_to_score, m)?)?; + m.add_function(wrap_pyfunction!(connected_components_with_stats, m)?)?; + m.add_function(wrap_pyfunction!(distance_transform, m)?)?; + m.add_function(wrap_pyfunction!(distance_transform_labels, m)?) +} diff --git a/negpy_core/src/py/contours.rs b/negpy_core/src/py/contours.rs new file mode 100644 index 000000000..50ffd3733 --- /dev/null +++ b/negpy_core/src/py/contours.rs @@ -0,0 +1,282 @@ +use numpy::{PyArray3, PyArrayMethods, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyTuple; + +use crate::contours; + +type PyRotatedRect = ((f32, f32), (f32, f32), f32); + +/// `cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)` for a uint8 image: +/// (contours as Nx1x2 int32 arrays, hierarchy as 1xKx4 int32), in OpenCV's order. +#[pyfunction] +fn find_external_contours<'py>( + py: Python<'py>, + src: PyReadonlyArray2<'py, u8>, +) -> PyResult<(Bound<'py, PyTuple>, Bound<'py, PyAny>)> { + let arr = src.as_array(); + let (h, w) = arr.dim(); + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let found = super::detach(py, data.len(), || { + contours::find_external_contours(data, h, w) + }); + let k = found.len(); + let arrays: Vec>> = found + .iter() + .map(|c| { + let flat: Vec>> = c.iter().map(|&(x, y)| vec![vec![x, y]]).collect(); + PyArray3::from_vec3(py, &flat) + }) + .collect::>() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let hierarchy: Vec> = (0..k) + .map(|i| { + vec![ + if i + 1 < k { i as i32 + 1 } else { -1 }, + i as i32 - 1, + -1, + -1, + ] + }) + .collect(); + let np = py.import("numpy")?; + let hier = if k == 0 { + py.None().into_bound(py) + } else { + np.getattr("array")?.call1((vec![hierarchy], "int32"))? + }; + Ok((PyTuple::new(py, arrays)?, hier)) +} + +fn int_points(pts: &numpy::PyReadonlyArrayDyn<'_, i32>) -> PyResult> { + let flat = pts + .as_slice() + .map_err(|_| PyValueError::new_err("points must be contiguous"))?; + if flat.len() % 2 != 0 { + return Err(PyValueError::new_err("points must be pairs")); + } + Ok(flat + .as_chunks::<2>() + .0 + .iter() + .map(|p| (p[0], p[1])) + .collect()) +} + +/// `cv2.minAreaRect(points)` of int32 points: ((cx, cy), (w, h), angle). +#[pyfunction] +fn min_area_rect(pts: numpy::PyReadonlyArrayDyn<'_, i32>) -> PyResult { + let r = crate::shapes::min_area_rect(&int_points(&pts)?); + Ok((r.center, r.size, r.angle)) +} + +/// `cv2.boxPoints(rect)`: four (x, y) float32 corners. +#[pyfunction] +fn box_points(rect: PyRotatedRect) -> Vec<(f32, f32)> { + let r = crate::shapes::RotatedRect { + center: rect.0, + size: rect.1, + angle: rect.2, + }; + crate::shapes::box_points(&r).to_vec() +} + +/// `cv2.convexHull(points)` of int32 points, as a list of (x, y). +#[pyfunction] +fn convex_hull(pts: numpy::PyReadonlyArrayDyn<'_, i32>) -> PyResult> { + Ok(crate::shapes::convex_hull(&int_points(&pts)?)) +} + +/// `cv2.fillConvexPoly(zeros((h, w), uint8), points, 1)` as a new mask. +#[pyfunction] +fn fill_convex_poly<'py>( + py: Python<'py>, + shape: (usize, usize), + pts: numpy::PyReadonlyArrayDyn<'_, i32>, +) -> PyResult>> { + let (h, w) = shape; + let v: Vec<(i64, i64)> = int_points(&pts)? + .iter() + .map(|&(x, y)| (x as i64, y as i64)) + .collect(); + let mut mask = vec![0u8; h * w]; + crate::shapes::fill_convex_poly(&mut mask, w, h, &v, 1); + numpy::PyArray1::from_vec(py, mask).reshape([h, w]) +} + +/// `cv2.fillPoly(zeros((h, w), uint8), [points], 255)` as a new mask. +#[pyfunction] +fn fill_poly<'py>( + py: Python<'py>, + shape: (usize, usize), + pts: numpy::PyReadonlyArrayDyn<'_, i32>, +) -> PyResult>> { + let (h, w) = shape; + let v: Vec<(i64, i64)> = int_points(&pts)? + .iter() + .map(|&(x, y)| (x as i64, y as i64)) + .collect(); + let mut mask = vec![0u8; h * w]; + crate::shapes::fill_poly(&mut mask, w, h, &v, 255); + numpy::PyArray1::from_vec(py, mask).reshape([h, w]) +} + +/// One mask for `local_maps`: ("fill", outline Nx2 int32, sigma) or ("ramp", (ax, ay, kx, ky)) +/// or ("empty",), then invert, plane (-1 for none), stops, grade, flash. +type MaskSpec<'py> = ( + String, + Option>, + (f64, f32, f32, f32, f32), + bool, + i64, + (f32, f32, f32), +); + +fn local_mask(spec: MaskSpec<'_>, planes: usize) -> PyResult { + use crate::shapes::{LocalMask, MaskAlpha}; + let (kind, outline, (sigma, ax, ay, kx, ky), invert, plane, (stops, grade, flash)) = spec; + let alpha = match kind.as_str() { + "fill" => { + let pts = outline.ok_or_else(|| PyValueError::new_err("a fill needs its outline"))?; + let outline = int_points(&pts)? + .iter() + .map(|&(x, y)| (x as i64, y as i64)) + .collect(); + MaskAlpha::Fill { outline, sigma } + } + "ramp" => MaskAlpha::Ramp { ax, ay, kx, ky }, + _ => MaskAlpha::Empty, + }; + let plane = usize::try_from(plane).ok(); + if plane.is_some_and(|k| 3 + k >= planes) { + return Err(PyValueError::new_err("plane out of range")); + } + Ok(LocalMask { + alpha, + invert, + plane, + stops, + grade, + flash, + }) +} + +/// `rasterise` of one mask spec (as for `local_maps`) to an HxW float32 alpha. +#[pyfunction] +fn rasterise_mask<'py>( + py: Python<'py>, + shape: (usize, usize), + spec: MaskSpec<'py>, +) -> PyResult>> { + let (h, w) = shape; + let m = local_mask(spec, 4)?; + let mut alpha = vec![0f32; h * w]; + super::detach(py, alpha.len(), || { + crate::shapes::rasterise(&m.alpha, (h, w), m.invert, &mut alpha) + }) + .ok_or_else(|| PyValueError::new_err("invalid mask"))?; + numpy::PyArray1::from_vec(py, alpha).reshape([h, w]) +} + +/// `compute_local_maps`' HxWxP float32 planes from masks already in pixel space. +#[pyfunction] +fn local_maps<'py>( + py: Python<'py>, + shape: (usize, usize, usize), + masks: Vec>, +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let (h, w, planes) = shape; + let specs = masks + .into_iter() + .map(|m| local_mask(m, planes)) + .collect::>>()?; + let out = PyArrayDyn::::zeros(py, vec![h, w, planes], false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + crate::shapes::local_maps(&specs, (h, w), planes, dst) + }) + .ok_or_else(|| PyValueError::new_err("invalid mask"))?; + } + Ok(out) +} + +/// Brush footprints drawn into one new uint8 mask: for each (points, radius, thickness), +/// `cv2.polylines(mask, [pts], False, 1, thickness)` when there are two points or more, then +/// `cv2.circle(mask, p, radius, 1, -1)` at every point. +#[pyfunction] +fn stroke_cover<'py>( + py: Python<'py>, + shape: (usize, usize), + strokes: Vec<(numpy::PyReadonlyArrayDyn<'py, i32>, i64, i64)>, +) -> PyResult>> { + let (h, w) = shape; + let mut mask = vec![0u8; h * w]; + for (pts, radius, thickness) in &strokes { + let v: Vec<(i64, i64)> = int_points(pts)? + .iter() + .map(|&(x, y)| (x as i64, y as i64)) + .collect(); + crate::shapes::stroke_cover(&mut mask, w, h, &v, *radius, *thickness); + } + numpy::PyArray1::from_vec(py, mask).reshape([h, w]) +} + +/// Whether two same-shape float32 alphas both exceed `threshold` at any pixel. +#[pyfunction] +fn alphas_overlap( + a: numpy::PyReadonlyArrayDyn<'_, f32>, + b: numpy::PyReadonlyArrayDyn<'_, f32>, + threshold: f32, +) -> PyResult { + use numpy::PyUntypedArrayMethods; + if a.shape() != b.shape() { + return Err(PyValueError::new_err("alphas differ in shape")); + } + let (Ok(a), Ok(b)) = (a.as_slice(), b.as_slice()) else { + return Err(PyValueError::new_err("needs contiguous arrays")); + }; + Ok(crate::shapes::alphas_overlap(a, b, threshold)) +} + +/// The overlay's zone outline: `cv2.findContours(img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)`, +/// the contours of at least `min_area` (`cv2.contourArea`), each `cv2.approxPolyDP(c, epsilon, True)`. +#[pyfunction] +fn contour_outlines( + py: Python<'_>, + src: PyReadonlyArray2<'_, u8>, + min_area: f64, + epsilon: f64, +) -> PyResult>> { + let (h, w) = src.as_array().dim(); + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok(super::detach(py, data.len(), || { + contours::find_list_contours(data, h, w) + .into_iter() + .filter(|c| contours::contour_area(c) >= min_area) + .map(|c| contours::approx_poly_dp(&c, epsilon, true)) + .collect() + })) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(contour_outlines, m)?)?; + m.add_function(wrap_pyfunction!(alphas_overlap, m)?)?; + m.add_function(wrap_pyfunction!(stroke_cover, m)?)?; + m.add_function(wrap_pyfunction!(local_maps, m)?)?; + m.add_function(wrap_pyfunction!(rasterise_mask, m)?)?; + m.add_function(wrap_pyfunction!(fill_poly, m)?)?; + m.add_function(wrap_pyfunction!(find_external_contours, m)?)?; + m.add_function(wrap_pyfunction!(min_area_rect, m)?)?; + m.add_function(wrap_pyfunction!(box_points, m)?)?; + m.add_function(wrap_pyfunction!(convex_hull, m)?)?; + m.add_function(wrap_pyfunction!(fill_convex_poly, m)?) +} diff --git a/negpy_core/src/py/convert.rs b/negpy_core/src/py/convert.rs new file mode 100644 index 000000000..6a1b0b8f4 --- /dev/null +++ b/negpy_core/src/py/convert.rs @@ -0,0 +1,357 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray3, PyReadonlyArrayDyn, PyReadwriteArray3, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::convert::{self, Level}; + +fn contiguous<'a, T: numpy::Element>(a: &'a PyReadonlyArrayDyn<'_, T>) -> PyResult<&'a [T]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +fn empty<'py, T: numpy::Element>( + py: Python<'py>, + shape: &[usize], + dtype: &str, +) -> PyResult>> { + Ok(py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), dtype))? + .cast_into::>()?) +} + +/// Fills a new array of `shape` and `dtype` from `fill(dst)` with the GIL released. +fn filled<'py, T: numpy::Element>( + py: Python<'py>, + shape: &[usize], + dtype: &str, + fill: impl FnOnce(&mut [T]) + Send, +) -> PyResult>> { + let out = empty::(py, shape, dtype)?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || fill(dst)); + } + Ok(out) +} + +/// `_to_uint8_jit` of a contiguous float32 array. +#[pyfunction] +fn to_uint8<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "uint8", |d| convert::to_uint(src, d)) +} + +/// `_to_uint16_jit` of a contiguous float32 array. +#[pyfunction] +fn to_uint16<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "uint16", |d| convert::to_uint(src, d)) +} + +/// `uint8_to_float32` of a contiguous uint8 array. +#[pyfunction] +fn uint8_to_float32<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u8>, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "float32", |d| { + convert::uint8_to_float32(src, d) + }) +} + +/// `uint16_to_float32` of a contiguous uint16 array. +#[pyfunction] +fn uint16_to_float32<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u16>, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "float32", |d| { + convert::uint16_to_float32(src, d) + }) +} + +fn luma_to<'py, T: Level + numpy::Element>( + py: Python<'py>, + img: &PyReadonlyArrayDyn<'py, f32>, + dtype: &str, +) -> PyResult>> { + let (h, w, cn) = match img.shape()[..] { + [h, w] => (h, w, 1), + [h, w, c] if c >= 3 => (h, w, c), + _ => { + return Err(PyValueError::new_err("needs an HxW or HxWxC array, C >= 3")); + } + }; + let src = contiguous(img)?; + filled(py, &[h, w], dtype, |d| convert::luma_to_uint(src, cn, d)) +} + +/// `float_to_uint_luma(img, bit_depth)` of a contiguous float32 HxW or HxWxC array. +#[pyfunction] +fn luma_to_uint<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + bit_depth: u8, +) -> PyResult> { + Ok(if bit_depth == 16 { + luma_to::(py, &img, "uint16")?.into_any() + } else { + luma_to::(py, &img, "uint8")?.into_any() + }) +} + +/// `rgb_to_rgba_into(src, dst)`: the first three channels of `src` into the HxWx4 `dst`. +#[pyfunction] +fn rgb_to_rgba_into( + py: Python<'_>, + src: PyReadonlyArray3<'_, f32>, + mut dst: PyReadwriteArray3<'_, f32>, +) -> PyResult<()> { + let (s, mut d) = (src.as_array(), dst.as_array_mut()); + if s.dim().0 != d.dim().0 || s.dim().1 != d.dim().1 || s.dim().2 < 3 || d.dim().2 != 4 { + return Err(PyValueError::new_err("needs HxWx3 into HxWx4")); + } + let cn = s.dim().2; + if let (Some(src), Some(dst)) = (s.as_slice(), d.as_slice_mut()) { + super::detach(py, dst.len(), || convert::rgb_to_rgba(src, cn, dst)); + return Ok(()); + } + super::detach(py, 0, || { + for (srow, mut drow) in s.outer_iter().zip(d.outer_iter_mut()) { + for (sp, mut dp) in srow.outer_iter().zip(drow.outer_iter_mut()) { + for c in 0..3 { + dp[c] = sp[c]; + } + } + } + }); + Ok(()) +} + +/// `rgba_to_rgb_into(src, dst, oy, ox)`: the RGB of `src[oy:oy+h, ox:ox+w]` into `dst`. +#[pyfunction] +fn rgba_to_rgb_into( + py: Python<'_>, + src: PyReadonlyArray3<'_, f32>, + mut dst: PyReadwriteArray3<'_, f32>, + oy: usize, + ox: usize, +) -> PyResult<()> { + let (s, mut d) = (src.as_array(), dst.as_array_mut()); + let (h, w, dc) = d.dim(); + if oy + h > s.dim().0 || ox + w > s.dim().1 || s.dim().2 < 3 || dc < 3 { + return Err(PyValueError::new_err("region outside the source")); + } + super::detach(py, 0, || { + for (y, mut drow) in d.outer_iter_mut().enumerate() { + let srow = s.index_axis(numpy::ndarray::Axis(0), oy + y); + // A padded readback has whole RGBA rows: copy them as slices when it does. + if let (Some(sr), Some(dr)) = (srow.as_slice(), drow.as_slice_mut()) { + let sc = srow.dim().1; + for (dp, sp) in dr.chunks_exact_mut(dc).zip(sr[ox * sc..].chunks_exact(sc)) { + dp[..3].copy_from_slice(&sp[..3]); + } + continue; + } + for (x, mut dp) in drow.outer_iter_mut().enumerate() { + for c in 0..3 { + dp[c] = srow[[ox + x, c]]; + } + } + } + }); + Ok(()) +} + +/// `a * b` of two float32 arrays of one shape (`cv2.multiply` without the copies NumPy makes). +#[pyfunction] +fn multiply<'py>( + py: Python<'py>, + a: PyReadonlyArrayDyn<'py, f32>, + b: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + if a.shape() != b.shape() { + return Err(PyValueError::new_err("needs arrays of one shape")); + } + let (sa, sb) = (contiguous(&a)?, contiguous(&b)?); + filled(py, a.shape(), "float32", |d| convert::multiply(sa, sb, d)) +} + +/// `img.mean(axis=2)` of an HxWx3 float32 image. +#[pyfunction] +fn mean_channels<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("mean_channels needs an HxWx3 array")); + }; + let src = contiguous(&img)?; + filled(py, &[h, w], "float32", |d| convert::mean_channels(src, d)) +} + +fn pick<'py, T: numpy::Element + Copy + Send + Sync>( + py: Python<'py>, + src: [&Bound<'py, PyAny>; 3], + dtype: &str, +) -> PyResult> { + let arrays: Vec> = + src.iter().map(|a| a.extract()).collect::>()?; + let shape = arrays[0].shape().to_vec(); + let [_, _, 3] = shape[..] else { + return Err(PyValueError::new_err("pick_channels needs HxWx3 arrays")); + }; + if arrays.iter().any(|a| a.shape() != shape.as_slice()) { + return Err(PyValueError::new_err("needs arrays of one shape")); + } + let s = [ + contiguous(&arrays[0])?, + contiguous(&arrays[1])?, + contiguous(&arrays[2])?, + ]; + Ok(filled(py, &shape, dtype, |d| convert::pick_channels(s, d))?.into_any()) +} + +/// An HxWx3 uint8, uint16 or float32 image whose channel c is channel c of the c-th source. +#[pyfunction] +fn pick_channels<'py>( + py: Python<'py>, + r: &Bound<'py, PyAny>, + g: &Bound<'py, PyAny>, + b: &Bound<'py, PyAny>, +) -> PyResult> { + let src = [r, g, b]; + if r.extract::>().is_ok() { + pick::(py, src, "float32") + } else if r.extract::>().is_ok() { + pick::(py, src, "uint16") + } else if r.extract::>().is_ok() { + pick::(py, src, "uint8") + } else { + Err(pyo3::exceptions::PyTypeError::new_err( + "pick_channels needs uint8, uint16 or float32 arrays", + )) + } +} + +/// Channel `c` of an HxWxC float32 image as a new HxW array. +#[pyfunction] +fn extract_channel<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + c: usize, +) -> PyResult>> { + let [h, w, cn] = img.shape()[..] else { + return Err(PyValueError::new_err( + "extract_channel needs an HxWxC array", + )); + }; + if c >= cn { + return Err(PyValueError::new_err("channel out of range")); + } + let src = contiguous(&img)?; + filled(py, &[h, w], "float32", |d| { + convert::extract_channel(src, cn, c, d) + }) +} + +/// `np.stack([lum, lum, lum], axis=-1)` of a float32 plane. +#[pyfunction] +fn gray_to_rgb<'py>( + py: Python<'py>, + lum: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let mut shape = lum.shape().to_vec(); + shape.push(3); + let src = contiguous(&lum)?; + filled(py, &shape, "float32", |d| convert::gray_to_rgb(src, d)) +} + +/// `a + b` of two float32 arrays of one shape. +#[pyfunction] +fn add<'py>( + py: Python<'py>, + a: PyReadonlyArrayDyn<'py, f32>, + b: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + if a.shape() != b.shape() { + return Err(PyValueError::new_err("needs arrays of one shape")); + } + let (sa, sb) = (contiguous(&a)?, contiguous(&b)?); + filled(py, a.shape(), "float32", |d| convert::add(sa, sb, d)) +} + +/// `img[..., c].any()` of an HxWxC float32 image. +#[pyfunction] +fn channel_any(py: Python<'_>, img: PyReadonlyArrayDyn<'_, f32>, c: usize) -> PyResult { + let [_, _, cn] = img.shape()[..] else { + return Err(PyValueError::new_err("channel_any needs an HxWxC array")); + }; + if c >= cn { + return Err(PyValueError::new_err("channel out of range")); + } + let src = contiguous(&img)?; + Ok(super::detach(py, src.len(), || { + convert::channel_any(src, cn, c) + })) +} + +/// `np.clip(img, 0, 1)` of a float32 array, as a new array. +#[pyfunction] +fn clip01<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "float32", |d| convert::clip_unit(src, d)) +} + +/// `np.nan_to_num(img, nan=nan, posinf=posinf, neginf=neginf)` of a float32 array. +#[pyfunction] +fn nan_to_num<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + nan: f32, + posinf: f32, + neginf: f32, +) -> PyResult>> { + let src = contiguous(&img)?; + filled(py, img.shape(), "float32", |d| { + convert::nan_to_num(src, d, (nan, posinf, neginf)) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(nan_to_num, m)?)?; + m.add_function(wrap_pyfunction!(gray_to_rgb, m)?)?; + m.add_function(wrap_pyfunction!(add, m)?)?; + m.add_function(wrap_pyfunction!(channel_any, m)?)?; + m.add_function(wrap_pyfunction!(clip01, m)?)?; + m.add_function(wrap_pyfunction!(mean_channels, m)?)?; + m.add_function(wrap_pyfunction!(pick_channels, m)?)?; + m.add_function(wrap_pyfunction!(extract_channel, m)?)?; + m.add_function(wrap_pyfunction!(multiply, m)?)?; + m.add_function(wrap_pyfunction!(to_uint8, m)?)?; + m.add_function(wrap_pyfunction!(to_uint16, m)?)?; + m.add_function(wrap_pyfunction!(uint8_to_float32, m)?)?; + m.add_function(wrap_pyfunction!(uint16_to_float32, m)?)?; + m.add_function(wrap_pyfunction!(luma_to_uint, m)?)?; + m.add_function(wrap_pyfunction!(rgb_to_rgba_into, m)?)?; + m.add_function(wrap_pyfunction!(rgba_to_rgb_into, m)?) +} diff --git a/negpy_core/src/py/display.rs b/negpy_core/src/py/display.rs new file mode 100644 index 000000000..693723ace --- /dev/null +++ b/negpy_core/src/py/display.rs @@ -0,0 +1,106 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray2, PyReadonlyArrayDyn, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::display::{self, Lut3}; + +fn rgb<'a, T: numpy::Element>(img: &'a PyReadonlyArrayDyn<'_, T>) -> PyResult<&'a [T]> { + if img.shape().len() != 3 || img.shape()[2] != 3 { + return Err(PyValueError::new_err("needs an HxWx3 image")); + } + img.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image")) +} + +fn lut3<'a>(lut: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult> { + let [n, n1, n2, 3] = lut.shape()[..] else { + return Err(PyValueError::new_err("the LUT must be N x N x N x 3")); + }; + if n != n1 || n != n2 || n < 2 { + return Err(PyValueError::new_err("the LUT must be N x N x N x 3")); + } + Ok(Lut3 { + table: lut + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous LUT"))?, + n, + }) +} + +fn filled<'py, T: numpy::Element>( + py: Python<'py>, + shape: &[usize], + fill: impl FnOnce(&mut [T]) + Send, +) -> PyResult>> { + let out = PyArrayDyn::::zeros(py, shape, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || fill(dst)); + } + Ok(out) +} + +/// `_apply_lut_u16_jit(img, lut)`. +#[pyfunction] +fn apply_lut_u16<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u16>, + lut: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let (src, l) = (rgb(&img)?, lut3(&lut)?); + filled(py, img.shape(), |d| display::apply_lut_u16(src, l, d)) +} + +/// `_apply_lut_f32_jit(img, lut)`. +#[pyfunction] +fn apply_lut_f32<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + lut: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + let (src, l) = (rgb(&img)?, lut3(&lut)?); + filled(py, img.shape(), |d| display::apply_lut_f32(src, l, d)) +} + +/// `_matrix_trc_u8_jit(img, dec_lut, m, enc_lut)`. +#[pyfunction] +fn matrix_trc_u8<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u8>, + dec_lut: PyReadonlyArray2<'py, f32>, + m: PyReadonlyArray2<'py, f32>, + enc_lut: PyReadonlyArray2<'py, f32>, +) -> PyResult>> { + let src = rgb(&img)?; + if dec_lut.shape() != [3, 256] + || m.shape() != [3, 3] + || enc_lut.shape()[0] != 3 + || enc_lut.shape()[1] < 2 + { + return Err(PyValueError::new_err( + "needs a 3 x 256 decode, a 3x3 matrix and a 3 x K encode", + )); + } + let dec = dec_lut + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous tables"))?; + let enc = enc_lut + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous tables"))?; + let m = m.as_array(); + let m = std::array::from_fn(|r| std::array::from_fn(|c| m[[r, c]])); + filled(py, img.shape(), |d| { + display::matrix_trc_u8(src, dec, m, enc, d) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(apply_lut_u16, m)?)?; + m.add_function(wrap_pyfunction!(apply_lut_f32, m)?)?; + m.add_function(wrap_pyfunction!(matrix_trc_u8, m)?) +} diff --git a/negpy_core/src/py/filter.rs b/negpy_core/src/py/filter.rs new file mode 100644 index 000000000..f8fbb274d --- /dev/null +++ b/negpy_core/src/py/filter.rs @@ -0,0 +1,161 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArrayDyn, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::filter::{self, Border}; + +/// A contiguous HxW or HxWxC float32 array. +struct Image<'a> { + data: &'a [f32], + shape: Vec, + dims: (usize, usize, usize), +} + +fn image<'a>(src: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult> { + let shape = src.shape().to_vec(); + let dims = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => return Err(PyValueError::new_err("needs a 2-D or 3-D array")), + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok(Image { data, shape, dims }) +} + +fn border(code: i32) -> PyResult { + Border::from_cv(code) + .ok_or_else(|| PyValueError::new_err(format!("unsupported border type {code}"))) +} + +/// Runs `fill` on a new `numpy.empty` array shaped like the input, with the GIL released. +/// `numpy.empty` neither zeroes the memory nor faults in 4 KiB pages one at a time. +fn filled<'py>( + py: Python<'py>, + shape: &[usize], + fill: impl FnOnce(&mut [f32]) -> Option<()> + Send, +) -> PyResult>> { + let out = py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), "float32"))? + .cast_into::>()?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || fill(dst)) + .ok_or_else(|| PyValueError::new_err("kernel shape not covered by negpy_core"))?; + } + Ok(out) +} + +/// `cv2.GaussianBlur(src, ksize, sigma_x, sigmaY=sigma_y, borderType=border)` for float32. +#[pyfunction] +#[pyo3(signature = (src, ksize, sigma_x, sigma_y=0.0, border_type=4))] +fn gaussian_blur<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + ksize: (usize, usize), + sigma_x: f64, + sigma_y: f64, + border_type: i32, +) -> PyResult>> { + let img = image(&src)?; + let b = border(border_type)?; + filled(py, &img.shape, |dst| { + filter::gaussian_blur(img.data, dst, img.dims, ksize, (sigma_x, sigma_y), b) + }) +} + +/// `cv2.sepFilter2D(src, -1, kx, ky, borderType=border)` for float32. +#[pyfunction] +#[pyo3(signature = (src, kx, ky, border_type=4))] +fn sep_filter2d<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + kx: PyReadonlyArray1<'py, f32>, + ky: PyReadonlyArray1<'py, f32>, + border_type: i32, +) -> PyResult>> { + let img = image(&src)?; + let (h, w, cn) = img.dims; + let b = border(border_type)?; + let kx = kx + .as_slice() + .map_err(|_| PyValueError::new_err("kx must be contiguous"))? + .to_vec(); + let ky = ky + .as_slice() + .map_err(|_| PyValueError::new_err("ky must be contiguous"))? + .to_vec(); + filled(py, &img.shape, |dst| { + filter::sep_filter2d(img.data, dst, (h, w, cn), (&kx, &ky), b) + }) +} + +/// `cv2.Sobel(src, cv2.CV_32F, dx, dy, ksize=3, borderType=border)` for float32: sepFilter2D +/// with getDerivKernels' 3-tap kernels. +#[pyfunction] +#[pyo3(signature = (src, dx, dy, border_type=4))] +fn sobel<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + dx: u8, + dy: u8, + border_type: i32, +) -> PyResult>> { + let kernel = |order: u8| match order { + 0 => Ok(vec![1.0f32, 2.0, 1.0]), + 1 => Ok(vec![-1.0f32, 0.0, 1.0]), + _ => Err(PyValueError::new_err( + "sobel covers derivative orders 0 and 1", + )), + }; + let (kx, ky) = (kernel(dx)?, kernel(dy)?); + let img = image(&src)?; + let b = border(border_type)?; + filled(py, &img.shape, |dst| { + filter::sep_filter2d(img.data, dst, img.dims, (&kx, &ky), b) + }) +} + +/// `cv2.boxFilter(src, -1, ksize, normalize=normalize, borderType=border_type)` for float32. +#[pyfunction] +#[pyo3(signature = (src, ksize, normalize=true, border_type=4))] +fn box_filter<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + ksize: (usize, usize), + normalize: bool, + border_type: i32, +) -> PyResult>> { + let img = image(&src)?; + let b = border(border_type)?; + filled(py, &img.shape, |dst| { + filter::box_filter(img.data, dst, img.dims, ksize, normalize, b) + }) +} + +/// `cv2.getGaussianKernel(n, sigma, cv2.CV_32F)` as a flat list. +#[pyfunction] +fn gaussian_kernel(n: usize, sigma: f64) -> Vec { + filter::gaussian_kernel(n, sigma) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + // OpenCV's border codes, so callers need no cv2 import. + m.add("BORDER_CONSTANT", 0)?; + m.add("BORDER_REPLICATE", 1)?; + m.add("BORDER_REFLECT", 2)?; + m.add("BORDER_REFLECT_101", 4)?; + m.add_function(wrap_pyfunction!(box_filter, m)?)?; + m.add_function(wrap_pyfunction!(gaussian_blur, m)?)?; + m.add_function(wrap_pyfunction!(sep_filter2d, m)?)?; + m.add_function(wrap_pyfunction!(sobel, m)?)?; + m.add_function(wrap_pyfunction!(gaussian_kernel, m)?) +} diff --git a/negpy_core/src/py/finish.rs b/negpy_core/src/py/finish.rs new file mode 100644 index 000000000..c3ee0ab9d --- /dev/null +++ b/negpy_core/src/py/finish.rs @@ -0,0 +1,59 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray2, PyReadonlyArrayDyn, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::finish::{self, Carrier}; + +/// `apply_carrier(img, width_px, rough, flare, corner, paper, tone)` of an HxWx3 float32 image +/// for width_px > 0, with `carrier_profiles()` as `profiles`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn apply_carrier<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + width_px: f64, + rough: f64, + flare: f64, + corner: f64, + paper: (f32, f32, f32), + tone: PyReadonlyArray2<'py, f32>, + profiles: PyReadonlyArray2<'py, f32>, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + if tone.shape() != [64, 3] || profiles.shape() != [8, 2048] { + return Err(PyValueError::new_err( + "needs a 64x3 tone table and 8x2048 profiles", + )); + } + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let src = img.as_slice().map_err(not_contiguous)?; + let tone = tone.as_slice().map_err(not_contiguous)?; + let c = Carrier { + width_px, + rough, + flare, + corner, + paper: paper.into(), + tone: tone.as_chunks::<3>().0, + profiles: profiles.as_slice().map_err(not_contiguous)?, + }; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + finish::apply_carrier(src, (h, w), &c, dst) + }); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(apply_carrier, m)?) +} diff --git a/negpy_core/src/py/flatfield.rs b/negpy_core/src/py/flatfield.rs new file mode 100644 index 000000000..aa845c0e2 --- /dev/null +++ b/negpy_core/src/py/flatfield.rs @@ -0,0 +1,162 @@ +use numpy::{PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::flatfield::{self, Lit}; + +fn image3<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], (usize, usize))> { + let [h, w, 3] = a.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let s = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + Ok((s, (h, w))) +} + +fn lit(c: (f64, f64, f64)) -> Lit { + Lit { + percentile: c.0, + fraction: c.1, + min_fraction: c.2, + } +} + +/// `_lit_mask(ref)` with (percentile, fraction, minimum lit fraction): an HxW bool mask, or +/// None when too little of the frame is lit. +#[pyfunction] +fn lit_mask<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + constants: (f64, f64, f64), +) -> PyResult>>> { + use numpy::IntoPyArray; + let (src, (h, w)) = image3(&img)?; + let c = lit(constants); + let mask = super::detach(py, src.len(), || flatfield::lit_mask(src, (h, w), &c)); + mask.map(|m| m.into_pyarray(py).reshape([h, w])).transpose() +} + +/// `_is_clipped(image)`'s (fraction of lit pixels at `level`, frame too dark for a lit mask). +#[pyfunction] +fn clipped_fraction( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + constants: (f64, f64, f64), + level: f32, +) -> PyResult<(f64, bool)> { + let (src, dims) = image3(&img)?; + let c = lit(constants); + Ok(super::detach(py, src.len(), || { + flatfield::clipped(src, dims, &c, level) + })) +} + +type Evenness<'py> = (Bound<'py, PyArrayDyn>, (f64, f64, f64, f64, bool)); + +/// `_evenness(image)`: (ratio, (low, high, color, clipped fraction, too dark)). +#[pyfunction] +fn evenness<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + constants: (f64, f64, f64), + level: f32, +) -> PyResult> { + let (src, dims) = image3(&img)?; + let c = lit(constants); + let out = py + .import("numpy")? + .getattr("empty")? + .call1((img.shape().to_vec(), "float32"))? + .cast_into::>()?; + let e = { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || { + flatfield::evenness(src, dims, &c, level, dst) + }) + .ok_or_else(|| PyValueError::new_err("invalid evenness input"))? + }; + Ok((out, (e.low, e.high, e.color, e.clipped, e.dark))) +} + +/// `compute_gain` of the reference at its working size, with the lit-mask constants, sigma, +/// eps, the minimum lit weight and (gain min, gain max): (gain, too dark). +#[pyfunction] +fn compute_gain<'py>( + py: Python<'py>, + reference: PyReadonlyArrayDyn<'py, f32>, + constants: (f64, f64, f64), + sigma: f64, + eps: f32, + min_lit_weight: f32, + range: (f32, f32), +) -> PyResult<(Bound<'py, PyArrayDyn>, bool)> { + let (src, dims) = image3(&reference)?; + let c = flatfield::Gain { + lit: lit(constants), + sigma, + eps, + min_lit_weight, + range, + }; + let out = PyArrayDyn::::zeros(py, reference.shape(), false); + let dark = { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, src.len(), || { + flatfield::compute_gain(src, dims, &c, dst) + }) + .ok_or_else(|| PyValueError::new_err("invalid reference"))? + }; + Ok((out, dark)) +} + +/// `_fill_from_nearest(values, valid)` of an HxWxC float32 array: a filled copy. +#[pyfunction] +fn fill_from_nearest<'py>( + py: Python<'py>, + values: PyReadonlyArrayDyn<'py, f32>, + valid: PyReadonlyArrayDyn<'py, bool>, +) -> PyResult>> { + let [h, w, cn] = values.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWxC array")); + }; + if valid.shape() != [h, w] { + return Err(PyValueError::new_err("needs an HxW mask")); + } + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let mut v = values.as_slice().map_err(not_contiguous)?.to_vec(); + let m = valid.as_slice().map_err(not_contiguous)?; + super::detach(py, v.len(), || { + flatfield::fill_from_nearest(&mut v, m, (h, w), cn) + }); + numpy::PyArray1::from_vec(py, v).reshape(vec![h, w, cn]) +} + +/// `evenness_view`'s map in place on a ratio from `evenness`, with twice the span. +#[pyfunction] +fn evenness_view( + py: Python<'_>, + mut ratio: numpy::PyReadwriteArrayDyn<'_, f32>, + span: f32, +) -> PyResult<()> { + let r = ratio + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous ratio"))?; + super::detach(py, r.len(), || flatfield::evenness_view(r, span)); + Ok(()) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(evenness_view, m)?)?; + m.add_function(wrap_pyfunction!(compute_gain, m)?)?; + m.add_function(wrap_pyfunction!(fill_from_nearest, m)?)?; + m.add_function(wrap_pyfunction!(lit_mask, m)?)?; + m.add_function(wrap_pyfunction!(clipped_fraction, m)?)?; + m.add_function(wrap_pyfunction!(evenness, m)?) +} diff --git a/negpy_core/src/py/gray.rs b/negpy_core/src/py/gray.rs new file mode 100644 index 000000000..c7ab90e3f --- /dev/null +++ b/negpy_core/src/py/gray.rs @@ -0,0 +1,168 @@ +use numpy::{PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::filter::Border; +use crate::gray; + +fn empty_u8<'py>(py: Python<'py>, shape: &[usize]) -> PyResult>> { + Ok(py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), "uint8"))? + .cast_into::>()?) +} + +fn contiguous<'a>(src: &'a PyReadonlyArrayDyn<'_, u8>) -> PyResult<&'a [u8]> { + src.as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +/// `cv2.cvtColor(src, cv2.COLOR_BGR2GRAY)` for an HxWx3 uint8 image. +#[pyfunction] +fn bgr_to_gray<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, +) -> PyResult>> { + let shape = src.shape().to_vec(); + if shape.len() != 3 || shape[2] != 3 { + return Err(PyValueError::new_err("bgr_to_gray needs an HxWx3 array")); + } + let data = contiguous(&src)?; + let out = empty_u8(py, &shape[..2])?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || gray::bgr_to_gray(data, dst)); + } + Ok(out) +} + +/// `cv2.GaussianBlur(src, ksize, sigma_x, sigmaY=sigma_y, borderType=border)` for uint8. +#[pyfunction] +#[pyo3(signature = (src, ksize, sigma_x, sigma_y=0.0, border_type=4))] +fn gaussian_blur_u8<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + ksize: (usize, usize), + sigma_x: f64, + sigma_y: f64, + border_type: i32, +) -> PyResult>> { + let shape = src.shape().to_vec(); + let dims = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => return Err(PyValueError::new_err("needs a 2-D or 3-D array")), + }; + let border = Border::from_cv(border_type) + .ok_or_else(|| PyValueError::new_err(format!("unsupported border type {border_type}")))?; + let data = contiguous(&src)?; + let out = empty_u8(py, &shape)?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + gray::gaussian_blur_u8(data, dst, dims, ksize, (sigma_x, sigma_y), border) + }) + .ok_or_else(|| PyValueError::new_err("invalid kernel size"))?; + } + Ok(out) +} + +/// `cv2.threshold(src, 0, maxval, THRESH_BINARY[_INV] + THRESH_OTSU)` for uint8: (threshold, mask). +#[pyfunction] +#[pyo3(signature = (src, maxval=255, invert=false))] +fn threshold_otsu<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + maxval: u8, + invert: bool, +) -> PyResult<(f64, Bound<'py, PyArrayDyn>)> { + let shape = src.shape().to_vec(); + let data = contiguous(&src)?; + if data.is_empty() { + return Err(PyValueError::new_err("threshold of an empty array")); + } + let out = empty_u8(py, &shape)?; + let t = { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + let t = gray::otsu_threshold(data); + gray::threshold_binary(data, dst, t, maxval, invert); + t + }) + }; + Ok((t, out)) +} + +/// `cv2.Canny(src, low, high)` for a 2-D uint8 image (aperture 3, L1 gradient). +#[pyfunction] +fn canny<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + low: f64, + high: f64, +) -> PyResult>> { + let shape = src.shape().to_vec(); + let [h, w] = shape[..] else { + return Err(PyValueError::new_err("canny needs a 2-D array")); + }; + let data = contiguous(&src)?; + let out = empty_u8(py, &shape)?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || gray::canny(data, dst, h, w, low, high)) + .ok_or_else(|| PyValueError::new_err("invalid canny input"))?; + } + Ok(out) +} + +/// `cv2.medianBlur(src, ksize)` for a single-channel uint8 image. +#[pyfunction] +fn median_blur<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + ksize: usize, +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w) = match shape.as_slice() { + [h, w] | [h, w, 1] => (*h, *w), + _ => { + return Err(PyValueError::new_err( + "median_blur needs a single-channel array", + )); + } + }; + let data = contiguous(&src)?; + let out = empty_u8(py, &shape)?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + crate::median::median_blur_u8(data, dst, h, w, ksize) + }) + .ok_or_else(|| PyValueError::new_err("ksize must be odd"))?; + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(median_blur, m)?)?; + m.add_function(wrap_pyfunction!(bgr_to_gray, m)?)?; + m.add_function(wrap_pyfunction!(gaussian_blur_u8, m)?)?; + m.add_function(wrap_pyfunction!(threshold_otsu, m)?)?; + m.add_function(wrap_pyfunction!(canny, m)?) +} diff --git a/negpy_core/src/py/hdr.rs b/negpy_core/src/py/hdr.rs new file mode 100644 index 000000000..cfd4b7615 --- /dev/null +++ b/negpy_core/src/py/hdr.rs @@ -0,0 +1,170 @@ +use numpy::{PyReadonlyArrayDyn, PyReadwriteArray1, PyReadwriteArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::hdr; + +fn contiguous<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<&'a [f32]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays")) +} + +/// `_count_clipped_rows(values, out)` of an HxWxC float32 frame. +#[pyfunction] +fn count_clipped_rows( + py: Python<'_>, + values: PyReadonlyArrayDyn<'_, f32>, + saturation: f64, + mut out: PyReadwrite1<'_>, +) -> PyResult<()> { + let [h, w, cn] = values.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWxC frame")); + }; + if out.len() != h { + return Err(PyValueError::new_err("out needs one entry per row")); + } + let v = contiguous(&values)?; + let dst = out + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous out"))?; + super::detach(py, dst.len(), || { + hdr::count_clipped_rows(v, (w, cn), saturation, dst) + }); + Ok(()) +} + +type PyReadwrite1<'py> = PyReadwriteArray1<'py, i64>; + +/// `_accumulate(num, den, values, ratio)`, with the module's saturation constants. +#[pyfunction] +fn hdr_accumulate( + py: Python<'_>, + mut num: PyReadwriteArrayDyn<'_, f32>, + mut den: PyReadwriteArrayDyn<'_, f32>, + values: PyReadonlyArrayDyn<'_, f32>, + ratio: f64, + saturation: f64, + rolloff_start: f64, +) -> PyResult<()> { + if num.shape() != values.shape() || den.shape() != values.shape() { + return Err(PyValueError::new_err("the sums do not match the frame")); + } + let v = contiguous(&values)?; + let n = num + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs contiguous sums"))?; + let d = den + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs contiguous sums"))?; + super::detach(py, v.len(), || { + hdr::accumulate(n, d, v, ratio, (saturation, rolloff_start)) + }); + Ok(()) +} + +type Dims = (usize, usize, usize); + +fn frame3<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], Dims)> { + let [h, w, cn] = a.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWxC frame")); + }; + if cn < 3 { + return Err(PyValueError::new_err("needs three channels or more")); + } + Ok((contiguous(a)?, (h, w, cn))) +} + +fn new_like<'py>( + py: Python<'py>, + shape: &[usize], + fill: impl FnOnce(&mut [f32]) + Send, +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let out = py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), "float32"))? + .cast_into::>()?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || fill(dst)); + } + Ok(out) +} + +/// `measure_exposure`'s level of a float32 frame: the q-th percentile of `frame[::step, ::step, :3]`. +#[pyfunction] +fn exposure_level( + py: Python<'_>, + frame: PyReadonlyArrayDyn<'_, f32>, + step: usize, + q: f64, +) -> PyResult { + let (f, dims) = frame3(&frame)?; + super::detach(py, f.len(), || hdr::exposure_level(f, dims, step, q)) + .ok_or_else(|| PyValueError::new_err("percentile of an empty frame")) +} + +/// `pair_ratio` of two float32 frames of one shape, with the subsample step and (floor, +/// saturation). +#[pyfunction] +fn pair_ratio( + py: Python<'_>, + short: PyReadonlyArrayDyn<'_, f32>, + long: PyReadonlyArrayDyn<'_, f32>, + step: usize, + limits: (f32, f32), +) -> PyResult> { + let (s, dims) = frame3(&short)?; + if long.shape() != short.shape() { + return Err(PyValueError::new_err("bracket frames differ in shape")); + } + let l = contiguous(&long)?; + Ok(super::detach(py, s.len(), || { + hdr::pair_ratio(s, l, dims, step, limits) + })) +} + +/// The end of `merge_providers`: num / den, the reference where den <= 0. +#[pyfunction] +fn finish_merge<'py>( + py: Python<'py>, + num: PyReadonlyArrayDyn<'py, f32>, + den: PyReadonlyArrayDyn<'py, f32>, + reference: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + if den.shape() != num.shape() || reference.shape() != num.shape() { + return Err(PyValueError::new_err("needs arrays of one shape")); + } + let (n, d, r) = ( + contiguous(&num)?, + contiguous(&den)?, + contiguous(&reference)?, + ); + new_like(py, num.shape(), |dst| hdr::finish_merge(n, d, r, dst)) +} + +/// `apply_render_exposure`: merged times scale, clipped to 0..1. +#[pyfunction] +fn render_exposure<'py>( + py: Python<'py>, + merged: PyReadonlyArrayDyn<'py, f32>, + scale: f32, +) -> PyResult>> { + let m = contiguous(&merged)?; + new_like(py, merged.shape(), |dst| { + hdr::render_exposure(m, scale, dst) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(count_clipped_rows, m)?)?; + m.add_function(wrap_pyfunction!(exposure_level, m)?)?; + m.add_function(wrap_pyfunction!(pair_ratio, m)?)?; + m.add_function(wrap_pyfunction!(finish_merge, m)?)?; + m.add_function(wrap_pyfunction!(render_exposure, m)?)?; + m.add_function(wrap_pyfunction!(hdr_accumulate, m)?) +} diff --git a/negpy_core/src/py/histogram.rs b/negpy_core/src/py/histogram.rs new file mode 100644 index 000000000..f0d4b77a3 --- /dev/null +++ b/negpy_core/src/py/histogram.rs @@ -0,0 +1,185 @@ +use numpy::{PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::color::LabSpace; +use crate::histogram::{ + self, COLOR_HIST_BINS, DENSITY_HIST_BINS, OUTPUT_HIST_BINS, SCOPE_CHROMA_BINS, SCOPE_HUE_BINS, + Samples, WAVEFORM_BINS, WAVEFORM_COLS, Waveform, +}; + +type Counts<'py> = Bound<'py, numpy::PyArrayDyn>; + +/// The samples `img[y1:y2:step, x1:x2:step, :3]` of an HxWxC float32 image, C >= 3. +fn samples<'a, T: numpy::Element>( + img: &'a PyReadonlyArrayDyn<'_, T>, + (y1, y2, x1, x2): (usize, usize, usize, usize), + step: usize, +) -> PyResult> { + let [h, w, cn] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWxC array")); + }; + if cn < 3 || step == 0 || y2 > h || x2 > w || y1 >= y2 || x1 >= x2 { + return Err(PyValueError::new_err("invalid channels, crop or step")); + } + let img = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok(Samples { + img, + w, + cn, + rows: (y1, y2), + cols: (x1, x2), + step, + }) +} + +/// `density_histogram` of a crop (y1, y2, x1, x2) sampled every `step`: (4, bins) counts. +#[pyfunction] +fn density_histogram<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + roi: (usize, usize, usize, usize), + step: usize, +) -> PyResult> { + let s = samples(&img, roi, step)?; + let counts = super::detach(py, s.img.len(), || histogram::density_histogram(s)); + numpy::PyArray1::from_vec(py, counts).reshape(vec![4, DENSITY_HIST_BINS]) +} + +/// `density_parade` of a crop (y1, y2, x1, x2) sampled every `step`: (3, bins, cols) counts. +#[pyfunction] +fn density_parade<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + roi: (usize, usize, usize, usize), + step: usize, +) -> PyResult> { + let s = samples(&img, roi, step)?; + let counts = super::detach(py, s.img.len(), || histogram::density_parade(s)); + numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_BINS, WAVEFORM_COLS]) +} + +/// The whole frame of an HxWxC image, sampled every `step`. +fn frame<'a, T: numpy::Element>( + img: &'a PyReadonlyArrayDyn<'_, T>, + step: usize, +) -> PyResult> { + let (h, w) = (img.shape()[0], img.shape().get(1).copied().unwrap_or(0)); + samples(img, (0, h, 0, w), step) +} + +/// `output_histogram` of an HxWx3 float32 or uint8 frame sampled every `step`: (4, 256) counts. +#[pyfunction] +fn output_histogram<'py>( + py: Python<'py>, + img: &Bound<'py, PyAny>, + step: usize, +) -> PyResult> { + let counts = if let Ok(a) = img.extract::>() { + let s = frame(&a, step)?; + super::detach(py, s.img.len(), || histogram::output_histogram_u8(s)) + } else { + let a = img.extract::>()?; + let s = frame(&a, step)?; + super::detach(py, s.img.len(), || histogram::output_histogram(s)) + }; + numpy::PyArray1::from_vec(py, counts).reshape(vec![4, OUTPUT_HIST_BINS]) +} + +/// `color_histogram` of an HxWx3 float32 frame sampled every `step`: (32, 32, 32) counts. +#[pyfunction] +fn color_histogram<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + step: usize, +) -> PyResult> { + let s = frame(&img, step)?; + let counts = super::detach(py, s.img.len(), || histogram::color_histogram(s)); + numpy::PyArray1::from_vec(py, counts).reshape(vec![COLOR_HIST_BINS; 3]) +} + +/// `waveform` ("luma"), `waveform_color` ("color") or `waveform_parade` ("parade") of an +/// HxWxC float32 frame sampled every `step`. +#[pyfunction] +fn waveform<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + step: usize, + kind: &str, +) -> PyResult> { + let kind = match kind { + "luma" => Waveform::Luma, + "color" => Waveform::Color, + "parade" => Waveform::Parade, + _ => return Err(PyValueError::new_err(format!("unknown waveform {kind:?}"))), + }; + let s = frame(&img, step)?; + let counts = super::detach(py, s.img.len(), || histogram::waveform(s, kind)); + let shape = if kind == Waveform::Luma { + vec![WAVEFORM_BINS, WAVEFORM_COLS] + } else { + vec![3, WAVEFORM_BINS, WAVEFORM_COLS] + }; + numpy::PyArray1::from_vec(py, counts).reshape(shape) +} + +/// `scan_full_scale_columns` of a crop (y1, y2, x1, x2) sampled every `step`: (3, cols) counts. +#[pyfunction] +fn scan_full_scale_columns<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + roi: (usize, usize, usize, usize), + step: usize, + level: f32, +) -> PyResult> { + let s = samples(&img, roi, step)?; + let counts = super::detach(py, s.img.len(), || { + histogram::scan_full_scale_columns(s, level) + }); + numpy::PyArray1::from_vec(py, counts).reshape(vec![3, WAVEFORM_COLS]) +} + +/// `vectorscope_histogram` of an HxWxC float32 encoded frame sampled every `step`, through the +/// working space (`m` RGB to XYZ, `white`, `eps`, `kappa`, `gamma`): (180, 64) counts. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn vectorscope_histogram<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + step: usize, + m: numpy::PyReadonlyArray2<'py, f32>, + white: numpy::PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, + gamma: f32, +) -> PyResult> { + let s = frame(&img, step)?; + let (m, white) = (m.as_array(), white.as_array()); + if m.shape() != [3, 3] || white.len() != 3 { + return Err(PyValueError::new_err( + "needs a 3x3 matrix and a 3-vector white", + )); + } + let space = LabSpace::new( + std::array::from_fn(|r| std::array::from_fn(|c| m[[r, c]])), + std::array::from_fn(|c| white[c]), + eps, + kappa, + ); + let counts = super::detach(py, s.img.len(), || { + histogram::vectorscope_histogram(s, space, gamma) + }); + numpy::PyArray1::from_vec(py, counts).reshape(vec![SCOPE_HUE_BINS, SCOPE_CHROMA_BINS]) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(vectorscope_histogram, m)?)?; + m.add_function(wrap_pyfunction!(density_histogram, m)?)?; + m.add_function(wrap_pyfunction!(density_parade, m)?)?; + m.add_function(wrap_pyfunction!(output_histogram, m)?)?; + m.add_function(wrap_pyfunction!(color_histogram, m)?)?; + m.add_function(wrap_pyfunction!(waveform, m)?)?; + m.add_function(wrap_pyfunction!(scan_full_scale_columns, m)?) +} diff --git a/negpy_core/src/py/jpeg.rs b/negpy_core/src/py/jpeg.rs new file mode 100644 index 000000000..2d4a0beee --- /dev/null +++ b/negpy_core/src/py/jpeg.rs @@ -0,0 +1,46 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyBytes; + +/// Pillow's `save(format="JPEG", quality=..., subsampling=0, optimize=True, icc_profile=..., +/// dpi=...)` of an HxW or HxWx3 uint8 array, with restart markers every `restart_rows` MCU rows +/// (0 for none, None to split the scan across the cores). +#[pyfunction] +#[pyo3(signature = (img, quality=90, icc=None, dpi=None, restart_rows=None))] +fn encode_jpeg<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u8>, + quality: i32, + icc: Option<&[u8]>, + dpi: Option<(u16, u16)>, + restart_rows: Option, +) -> PyResult> { + let (h, w, cn) = match img.shape()[..] { + [h, w] => (h, w, 1), + [h, w, 3] => (h, w, 3), + _ => { + return Err(PyValueError::new_err( + "encode_jpeg needs an HxW or HxWx3 array", + )); + } + }; + let data = img + .as_slice() + .map_err(|_| PyValueError::new_err("encode_jpeg needs a contiguous array"))?; + let options = crate::jpeg::Options { + quality, + icc, + dpi, + restart_rows, + }; + let out = super::detach(py, data.len(), || { + crate::jpeg::encode(data, (h, w, cn), &options) + }) + .ok_or_else(|| PyValueError::new_err("encode_jpeg: image empty or over 65535 px"))?; + Ok(PyBytes::new(py, &out)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(encode_jpeg, m)?) +} diff --git a/negpy_core/src/py/lab.rs b/negpy_core/src/py/lab.rs new file mode 100644 index 000000000..abaf09fb4 --- /dev/null +++ b/negpy_core/src/py/lab.rs @@ -0,0 +1,290 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use super::color::space; +use crate::lab; + +/// `_usm_sharpening_block(img, amount, radius, masking)` of an HxWx3 float32 image: `gain` is +/// `np.float32(amount * 2.5)`, `k` the blur taps, then the working Lab constants. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn usm_sharpen<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + gain: f32, + masking: f64, + k: PyReadonlyArray1<'py, f32>, + to_xyz: PyReadonlyArray2<'py, f32>, + to_rgb: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let spaces = ( + space(&to_xyz, &white, eps, kappa)?, + space(&to_rgb, &white, eps, kappa)?, + ); + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let src = img.as_slice().map_err(not_contiguous)?; + let k = k.as_slice().map_err(not_contiguous)?; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + lab::usm_sharpen(src, (h, w), (gain, masking), k, spaces, dst) + }) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + } + Ok(out) +} + +/// `apply_chroma_denoise` of an HxWx3 float32 image at a known low-pass `sigma` with blur taps +/// `k`, then the working Lab constants. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn chroma_denoise<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + sigma: f64, + k: PyReadonlyArray1<'py, f32>, + to_xyz: PyReadonlyArray2<'py, f32>, + to_rgb: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let spaces = ( + space(&to_xyz, &white, eps, kappa)?, + space(&to_rgb, &white, eps, kappa)?, + ); + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let src = img.as_slice().map_err(not_contiguous)?; + let k = k.as_slice().map_err(not_contiguous)?; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + lab::chroma_denoise(src, (h, w), sigma, k, spaces, dst) + }) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + } + Ok(out) +} + +type BloomArgs = Option<(f32, usize, f64, f64)>; + +/// `apply_glow_and_halation` of an HxWx3 float32 image. Each bloom is `(amount, ksize, sigma, +/// threshold)` or None when off; `lum` is the luminance row, `inv_gamma` the working TRC's. +#[pyfunction] +fn glow_and_halation<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + glow: BloomArgs, + halation: BloomArgs, + lum: (f32, f32, f32), + inv_gamma: f32, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + let bloom = |b: BloomArgs| { + b.map(|(amount, ksize, sigma, threshold)| lab::Bloom { + amount, + ksize, + sigma, + threshold, + }) + }; + let (glow, halation) = (bloom(glow), bloom(halation)); + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + lab::glow_and_halation(src, (h, w), (glow, halation), (lum.into(), inv_gamma), dst) + }) + .ok_or_else(|| PyValueError::new_err("a bloom needs an odd kernel size"))?; + } + Ok(out) +} + +/// `apply_clahe(img, strength)` of an HxWx3 float32 image for strength > 0, then the working +/// Lab constants. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn clahe<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + strength: f64, + to_xyz: PyReadonlyArray2<'py, f32>, + to_rgb: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let spaces = ( + space(&to_xyz, &white, eps, kappa)?, + space(&to_rgb, &white, eps, kappa)?, + ); + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + lab::clahe(src, (h, w), strength, spaces, dst) + }); + } + Ok(out) +} + +/// `apply_rl_sharpening(img, amount, radius, masking)` of an HxWx3 float32 image for amount > 0: +/// `amount` as float32, the blur taps `k`, the iteration count, the luminance row and RL_EPS. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn rl_sharpen<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + amount: f32, + masking: f64, + k: PyReadonlyArray1<'py, f32>, + iterations: usize, + lum: (f32, f32, f32), + eps: f32, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let src = img.as_slice().map_err(not_contiguous)?; + let k = k.as_slice().map_err(not_contiguous)?; + let params = lab::Rl { + amount, + masking, + iterations, + lum: lum.into(), + eps, + }; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + lab::rl_sharpen(src, (h, w), ¶ms, k, dst) + }) + .ok_or_else(|| PyValueError::new_err("the image is too small for the blur"))?; + } + Ok(out) +} + +/// `apply_split_toning` of an HxWx3 float32 image: each tint is (cos, sin) of its hue times 20 +/// times its strength, or None when off; then the working Lab constants. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn split_toning<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + shadow: Option<(f64, f64)>, + highlight: Option<(f64, f64)>, + to_xyz: PyReadonlyArray2<'py, f32>, + to_rgb: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + if img.shape().last() != Some(&3) { + return Err(PyValueError::new_err("needs an HxWx3 image")); + } + let spaces = ( + space(&to_xyz, &white, eps, kappa)?, + space(&to_rgb, &white, eps, kappa)?, + ); + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + let tints = (shadow.map(|t| [t.0, t.1]), highlight.map(|t| [t.0, t.1])); + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || lab::split_toning(src, tints, spaces, dst)); + } + Ok(out) +} + +/// `apply_hue_trim` of an HxWx3 float32 image with (cos, sin) of a nonzero angle, then the +/// working Lab constants. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn hue_trim<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + rotation: (f32, f32), + to_xyz: PyReadonlyArray2<'py, f32>, + to_rgb: PyReadonlyArray2<'py, f32>, + white: PyReadonlyArray1<'py, f32>, + eps: f32, + kappa: f32, +) -> PyResult>> { + if img.shape().last() != Some(&3) { + return Err(PyValueError::new_err("needs an HxWx3 image")); + } + let spaces = ( + space(&to_xyz, &white, eps, kappa)?, + space(&to_rgb, &white, eps, kappa)?, + ); + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || lab::hue_trim(src, rotation, spaces, dst)); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(hue_trim, m)?)?; + m.add_function(wrap_pyfunction!(split_toning, m)?)?; + m.add_function(wrap_pyfunction!(rl_sharpen, m)?)?; + m.add_function(wrap_pyfunction!(clahe, m)?)?; + m.add_function(wrap_pyfunction!(glow_and_halation, m)?)?; + m.add_function(wrap_pyfunction!(usm_sharpen, m)?)?; + m.add_function(wrap_pyfunction!(chroma_denoise, m)?) +} diff --git a/negpy_core/src/py/lens.rs b/negpy_core/src/py/lens.rs new file mode 100644 index 000000000..46d15146f --- /dev/null +++ b/negpy_core/src/py/lens.rs @@ -0,0 +1,128 @@ +use numpy::{IntoPyArray, PyArray2, PyArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::lens::{self, Frame}; + +type Maps<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray2>); +type FrameArgs = ((f64, f64), (f64, f64), (f64, f64), f64, f64); + +/// `RectilinearWarp.remap`'s maps with the distortion applied, for rows (start, stop) of a +/// w-wide frame: the coefficients (k0..k3, t0, t1), then the frame's buffer origin, scale, +/// warp center, radius and fill scale. +#[pyfunction] +fn rectilinear_map<'py>( + py: Python<'py>, + w: usize, + rows: (usize, usize), + coefficients: [f64; 6], + frame: FrameArgs, +) -> PyResult> { + let (start, stop) = rows; + if stop < start || w == 0 { + return Err(PyValueError::new_err("invalid rows")); + } + let (origin, scale, center, radius, fill) = frame; + let f = Frame { + origin, + scale, + center, + radius, + fill, + }; + let n = (stop - start) * w; + let (mut mx, mut my) = (vec![0f32; n], vec![0f32; n]); + super::detach(py, n, || { + lens::rectilinear_map(w, start..stop, coefficients, f, &mut mx, &mut my) + }); + Ok(( + mx.into_pyarray(py).reshape([stop - start, w])?, + my.into_pyarray(py).reshape([stop - start, w])?, + )) +} + +fn maps<'py>( + py: Python<'py>, + (rows, w): (usize, usize), + fill: impl FnOnce(&mut [f32], &mut [f32]) + Send, +) -> PyResult> { + let n = rows * w; + let (mut mx, mut my) = (vec![0f32; n], vec![0f32; n]); + super::detach(py, n, || fill(&mut mx, &mut my)); + Ok(( + mx.into_pyarray(py).reshape([rows, w])?, + my.into_pyarray(py).reshape([rows, w])?, + )) +} + +/// `RectilinearWarp.remap`'s maps with lateral CA only, for rows (start, stop) of a w-wide +/// frame: the channel's and the green coefficients, then the frame as for `rectilinear_map`. +#[pyfunction] +fn rectilinear_ca_map<'py>( + py: Python<'py>, + w: usize, + rows: (usize, usize), + coefficients: ([f64; 6], [f64; 6]), + frame: FrameArgs, +) -> PyResult> { + let (start, stop) = rows; + if stop < start || w == 0 { + return Err(PyValueError::new_err("invalid rows")); + } + let (origin, scale, center, radius, fill) = frame; + let f = Frame { + origin, + scale, + center, + radius, + fill, + }; + maps(py, (stop - start, w), |mx, my| { + lens::rectilinear_ca_map(w, start..stop, coefficients, f, mx, my) + }) +} + +/// `PanasonicWarp.remap`'s maps with the distortion applied, for rows (start, stop) of an h x w +/// frame, with (scale, a, b, c) and the fill scale. +#[pyfunction] +fn panasonic_map<'py>( + py: Python<'py>, + shape: (usize, usize), + rows: (usize, usize), + model: (f64, f64, f64, f64), + fill: f64, +) -> PyResult> { + let (start, stop) = rows; + if stop < start || shape.1 == 0 { + return Err(PyValueError::new_err("invalid rows")); + } + maps(py, (stop - start, shape.1), |mx, my| { + lens::panasonic_map(shape, start..stop, model, fill, mx, my) + }) +} + +/// `SonyWarp.remap`'s maps for rows (start, stop) of an h x w frame, with the combined knot +/// factors and the fill scale. +#[pyfunction] +fn sony_map<'py>( + py: Python<'py>, + shape: (usize, usize), + rows: (usize, usize), + factors: Vec, + fill: f64, +) -> PyResult> { + let (start, stop) = rows; + if stop < start || shape.1 == 0 || factors.len() < 2 { + return Err(PyValueError::new_err("invalid rows or knots")); + } + maps(py, (stop - start, shape.1), |mx, my| { + lens::sony_map(shape, start..stop, &factors, fill, mx, my) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(rectilinear_map, m)?)?; + m.add_function(wrap_pyfunction!(rectilinear_ca_map, m)?)?; + m.add_function(wrap_pyfunction!(panasonic_map, m)?)?; + m.add_function(wrap_pyfunction!(sony_map, m)?) +} diff --git a/negpy_core/src/py/loaders.rs b/negpy_core/src/py/loaders.rs new file mode 100644 index 000000000..c9e51b767 --- /dev/null +++ b/negpy_core/src/py/loaders.rs @@ -0,0 +1,42 @@ +use numpy::{PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::loaders; + +/// `normalize_linear` of a float64 image whose last axis holds the channels. +#[pyfunction] +fn normalize_linear<'py>( + py: Python<'py>, + lin: PyReadonlyArrayDyn<'py, f64>, +) -> PyResult>> { + let shape = lin.shape().to_vec(); + let c = match shape.last() { + Some(&c) if c > 0 && shape.len() >= 2 => c, + _ => { + return Err(PyValueError::new_err( + "normalize_linear needs an ...xC array", + )); + } + }; + let data = lin + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let out = py + .import("numpy")? + .getattr("empty")? + .call1((shape, "float64"))? + .cast_into::>()?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || loaders::normalize_linear(data, c, dst)); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(normalize_linear, m)?) +} diff --git a/negpy_core/src/py/meter.rs b/negpy_core/src/py/meter.rs new file mode 100644 index 000000000..58bd4dfe2 --- /dev/null +++ b/negpy_core/src/py/meter.rs @@ -0,0 +1,180 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::meter::{self, Activity, ChannelNorm, NeutralConsts}; + +/// Per channel (floor, floor is float64, span, span is float64), as the caller's types decide. +type Norm = [(f64, bool, f64, bool); 3]; + +/// The data and size of an HxWx3 float32 grid. +fn grid_of<'a>(grid: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], usize, usize)> { + let [h, w, 3] = grid.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 array")); + }; + let data = grid + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok((data, h, w)) +} + +fn activity((block, gate, min_fraction): (usize, f32, f64)) -> PyResult { + if block == 0 { + return Err(PyValueError::new_err("block must be positive")); + } + Ok(Activity { + block, + gate, + min_fraction, + }) +} + +/// The textured luma of a grid, normalized when `norm` is given. +fn textured((data, h, w): (&[f32], usize, usize), norm: Option, a: Activity) -> Vec { + let norm = norm.map(|n| { + n.map(|(floor, floor_wide, span, span_wide)| ChannelNorm { + floor, + floor_wide, + span, + span_wide, + }) + }); + meter::textured_cells(meter::luma(data, norm.as_ref()), h, w, a) +} + +/// `np.percentile` (float32 q) of the grid's textured normalized luma: shadow reach, highlight hold. +#[pyfunction] +fn textured_percentile( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + norm: Norm, + gate: (usize, f32, f64), + q: f64, +) -> PyResult { + let (g, a) = (grid_of(&grid)?, activity(gate)?); + Ok(super::detach(py, g.0.len(), || { + meter::percentile(&textured(g, Some(norm), a), q) + })) +} + +/// The anchor meter's measurement over the grid's textured normalized luma. +#[pyfunction] +fn textured_anchor( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + norm: Norm, + gate: (usize, f32, f64), + clip: f64, +) -> PyResult { + let (g, a) = (grid_of(&grid)?, activity(gate)?); + Ok(super::detach(py, g.0.len(), || { + meter::anchor_measure(&textured(g, Some(norm), a), clip) + })) +} + +/// The textural range of the grid's textured raw-log luma. +#[pyfunction] +fn textured_range( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + gate: (usize, f32, f64), + clip: f64, +) -> PyResult { + let (g, a) = (grid_of(&grid)?, activity(gate)?); + Ok(super::detach(py, g.0.len(), || { + meter::textural_range(&textured(g, None, a), clip) + })) +} + +type Band = (f32, f32); +type Sector = (f32, f32, f32); +type Triplet = (f64, f64, f64); +/// (midtone, shadow, highlight or None, confidence). +type Axis = (Triplet, Triplet, Option, f64); +/// quantile, cap, first-pass cap, min pixels, highlight/mid/shadow bands, foliage and sky +/// sectors, n0, agreement (dead zone, scale), width (dead zone, scale). +type Consts = ( + f64, + f64, + f64, + usize, + Band, + Band, + Band, + Sector, + Sector, + f64, + (f64, f64), + (f64, f64), +); + +fn triplet(r: [f64; 3]) -> Triplet { + (r[0], r[1], r[2]) +} + +/// `measure_neutral_axis_from_log` of a cropped block-median grid: (midtone, shadow, +/// highlight or None, confidence), or None. +#[pyfunction] +fn neutral_axis( + py: Python<'_>, + grid: PyReadonlyArrayDyn<'_, f32>, + floors: Triplet, + ceils: Triplet, + exclude_subjects: bool, + consts: Consts, +) -> PyResult> { + let (data, _, _) = grid_of(&grid)?; + let ( + quantile, + cap, + first_pass_cap, + min_pixels, + highlight, + mid, + shadow, + foliage, + sky, + n0, + agreement, + width, + ) = consts; + let c = NeutralConsts { + quantile, + cap, + first_pass_cap, + min_pixels, + highlight, + mid, + shadow, + subjects: [foliage, sky], + n0, + agreement, + width, + }; + let f = [floors.0, floors.1, floors.2]; + let e = [ceils.0, ceils.1, ceils.2]; + let axis = super::detach(py, data.len(), || { + meter::neutral_axis( + data, + (f.map(|v| v as f32), e.map(|v| v as f32)), + (f, e), + exclude_subjects, + &c, + ) + }); + Ok(axis.map(|a| { + ( + triplet(a.mid), + triplet(a.shadow), + a.highlight.map(triplet), + a.confidence, + ) + })) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(textured_percentile, m)?)?; + m.add_function(wrap_pyfunction!(textured_anchor, m)?)?; + m.add_function(wrap_pyfunction!(textured_range, m)?)?; + m.add_function(wrap_pyfunction!(neutral_axis, m)?) +} diff --git a/negpy_core/src/py/mod.rs b/negpy_core/src/py/mod.rs new file mode 100644 index 000000000..e4caf415d --- /dev/null +++ b/negpy_core/src/py/mod.rs @@ -0,0 +1,104 @@ +pub mod altprocess; +pub mod analysis; +pub mod autocrop; +pub mod bounds; +pub mod capture; +pub mod color; +pub mod components; +pub mod contours; +pub mod convert; +pub mod display; +pub mod filter; +pub mod finish; +pub mod flatfield; +pub mod gray; +pub mod hdr; +pub mod histogram; +pub mod jpeg; +pub mod lab; +pub mod lens; +pub mod loaders; +pub mod meter; +pub mod morph; +pub mod phase; +pub mod pool; +pub mod print_curve; +pub mod process; +pub mod register; +pub mod resize; +pub mod retouch; +pub mod rgbscan; +pub mod sensor; +pub mod skew; +pub mod stats; +pub mod stitch; +pub mod toning; +pub mod warp; +pub mod zones; + +use pyo3::Python; +use pyo3::marker::Ungil; + +/// Work under this many elements stays on the calling thread: waking other workers for each +/// parallel pass of a tiny image costs more than the passes. +const SMALL: usize = 1 << 16; + +thread_local! { + /// A one-thread pool made of the calling thread itself, so its parallel passes run inline. + static LOCAL: Option = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .use_current_thread() + .build() + .ok(); + /// Set on a thread whose large work runs in `background()`, such as a thumbnail worker. + static BACKGROUND: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Every core. A thread that joined `LOCAL` stays in it, so large work enters this pool +/// explicitly rather than through rayon's global one. +fn all_cores() -> &'static rayon::ThreadPool { + static POOL: std::sync::OnceLock = std::sync::OnceLock::new(); + POOL.get_or_init(|| { + rayon::ThreadPoolBuilder::new() + .build() + .expect("cannot start the negpy_core thread pool") + }) +} + +/// A quarter of the cores, so background work leaves the rest to the live preview. At least +/// two: on a small machine one thread would leave thumbnails slower than the all-core pool. +fn background() -> &'static rayon::ThreadPool { + static POOL: std::sync::OnceLock = std::sync::OnceLock::new(); + POOL.get_or_init(|| { + let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); + rayon::ThreadPoolBuilder::new() + .num_threads((cores / 4).max(2).min(cores)) + .thread_name(|i| format!("negpy-bg-{i}")) + .build() + .expect("cannot start the negpy_core background pool") + }) +} + +/// The pool that large work from the calling thread enters. +fn large_pool() -> &'static rayon::ThreadPool { + if BACKGROUND.get() { + background() + } else { + all_cores() + } +} + +/// `py.detach(f)` for work of `n` elements: the GIL released, and every binding's work run +/// through here so that each call enters a pool once. +pub fn detach( + py: Python<'_>, + n: usize, + f: impl FnOnce() -> T + Ungil + Send, +) -> T { + py.detach(|| { + LOCAL.with(|local| match local { + Some(p) if n < SMALL => p.install(f), + _ => large_pool().install(f), + }) + }) +} diff --git a/negpy_core/src/py/morph.rs b/negpy_core/src/py/morph.rs new file mode 100644 index 000000000..fbb2d9ba5 --- /dev/null +++ b/negpy_core/src/py/morph.rs @@ -0,0 +1,114 @@ +use numpy::{ + Element, PyArrayDyn, PyArrayMethods, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::prelude::*; + +use crate::filter::Border; +use crate::morph::{self, Op, Pixel}; + +fn run<'py, T: Pixel + Element>( + py: Python<'py>, + src: &Bound<'py, PyAny>, + op: Op, + kernel: morph::Kernel, + iterations: usize, + border: Border, + dtype: &str, +) -> PyResult> { + let src: PyReadonlyArrayDyn<'py, T> = src.extract()?; + let shape = src.shape().to_vec(); + let dims = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => return Err(PyValueError::new_err("needs a 2-D or 3-D array")), + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let out = py + .import("numpy")? + .getattr("empty")? + .call1((shape.clone(), dtype))? + .cast_into::>()?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + if kernel.mask.iter().all(|&v| v != 0) { + let ksize = (kernel.w, kernel.h); + morph::morphology(data, dst, dims, op, ksize, iterations, border) + } else { + morph::morphology_mask(data, dst, dims, op, kernel, iterations, border) + } + }) + .ok_or_else(|| PyValueError::new_err("invalid morphology input"))?; + } + Ok(out.into_any()) +} + +/// `cv2.morphologyEx(src, op, kernel, iterations=..., borderType=...)` for a uint8 or float32 +/// image and any kernel, with OpenCV's default anchor and border value. +#[pyfunction] +#[pyo3(signature = (src, op, kernel, iterations=1, border_type=0))] +fn morphology_ex<'py>( + py: Python<'py>, + src: &Bound<'py, PyAny>, + op: i32, + kernel: PyReadonlyArray2<'py, u8>, + iterations: usize, + border_type: i32, +) -> PyResult> { + let op = + Op::from_cv(op).ok_or_else(|| PyValueError::new_err(format!("unsupported op {op}")))?; + let border = Border::from_cv(border_type) + .ok_or_else(|| PyValueError::new_err(format!("unsupported border type {border_type}")))?; + let k = kernel.as_array(); + let cells: Vec = k.iter().copied().collect(); + let kernel = morph::Kernel { + mask: &cells, + w: k.ncols(), + h: k.nrows(), + }; + if src.extract::>().is_ok() { + run::(py, src, op, kernel, iterations, border, "uint8") + } else if src.extract::>().is_ok() { + run::(py, src, op, kernel, iterations, border, "float32") + } else { + Err(PyTypeError::new_err( + "morphology needs a uint8 or float32 array", + )) + } +} + +/// `cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (w, h))` as an h x w uint8 array. +#[pyfunction] +fn ellipse_kernel<'py>( + py: Python<'py>, + ksize: (usize, usize), +) -> PyResult>> { + let (w, h) = ksize; + if w == 0 || h == 0 { + return Err(PyValueError::new_err("kernel size must be positive")); + } + numpy::PyArray1::from_vec(py, morph::ellipse_kernel(w, h)).reshape([h, w]) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + for (name, code) in [ + ("MORPH_ERODE", 0), + ("MORPH_DILATE", 1), + ("MORPH_OPEN", 2), + ("MORPH_CLOSE", 3), + ("MORPH_GRADIENT", 4), + ("MORPH_TOPHAT", 5), + ("MORPH_BLACKHAT", 6), + ] { + m.add(name, code)?; + } + m.add_function(wrap_pyfunction!(morphology_ex, m)?)?; + m.add_function(wrap_pyfunction!(ellipse_kernel, m)?) +} diff --git a/negpy_core/src/py/phase.rs b/negpy_core/src/py/phase.rs new file mode 100644 index 000000000..8ae92da18 --- /dev/null +++ b/negpy_core/src/py/phase.rs @@ -0,0 +1,30 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// `cv2.phaseCorrelate(a, b, cv2.createHanningWindow(...))` of two same-size HxW float32 +/// planes: ((dx, dy), response). +#[pyfunction] +fn phase_correlate( + py: Python<'_>, + a: PyReadonlyArrayDyn<'_, f32>, + b: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult<((f64, f64), f64)> { + let [h, w] = a.shape()[..] else { + return Err(PyValueError::new_err("phase_correlate needs 2-D arrays")); + }; + if b.shape() != a.shape() || h < 2 || w < 2 { + return Err(PyValueError::new_err( + "phase_correlate needs two same-size planes of at least 2x2", + )); + } + let (Ok(a), Ok(b)) = (a.as_slice(), b.as_slice()) else { + return Err(PyValueError::new_err("needs contiguous arrays")); + }; + let (dx, dy, r) = super::detach(py, h * w * 8, || crate::phase::phase_correlate(a, b, h, w)); + Ok(((dx, dy), r)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(phase_correlate, m)?) +} diff --git a/negpy_core/src/py/pool.rs b/negpy_core/src/py/pool.rs new file mode 100644 index 000000000..ad1dfd6b5 --- /dev/null +++ b/negpy_core/src/py/pool.rs @@ -0,0 +1,18 @@ +use pyo3::prelude::*; + +/// Sends the calling thread's large work to the background pool, or back to every core. +#[pyfunction] +fn set_background_thread(on: bool) { + super::BACKGROUND.set(on); +} + +/// The thread count of the pool that large work from the calling thread enters. +#[pyfunction] +fn pool_threads() -> usize { + super::large_pool().current_num_threads() +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(set_background_thread, m)?)?; + m.add_function(wrap_pyfunction!(pool_threads, m)?) +} diff --git a/negpy_core/src/py/print_curve.rs b/negpy_core/src/py/print_curve.rs new file mode 100644 index 000000000..95ce4d0ca --- /dev/null +++ b/negpy_core/src/py/print_curve.rs @@ -0,0 +1,275 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::print_curve::{self, Diffusion, Keys, Lut, Plane, PrintCurve}; + +fn contiguous<'a, T: numpy::Element>(a: &'a PyReadonlyArrayDyn<'_, T>) -> PyResult<&'a [T]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays")) +} + +fn triplet(a: &PyReadonlyArray1<'_, T>) -> PyResult<[T; 3]> { + let s = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + s.try_into() + .map_err(|_| PyValueError::new_err("needs a 3-vector")) +} + +/// An HxW plane of the image's height, or an error. +fn plane<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>, h: usize, w: usize) -> PyResult> { + if a.shape().len() < 2 || a.shape()[0] != h || a.shape()[1] != w { + return Err(PyValueError::new_err( + "a per-pixel map does not match the image", + )); + } + Ok(Plane { + data: contiguous(a)?, + w, + }) +} + +/// `_apply_print_curve_kernel` of an HxWx3 float32 image; the arguments are the kernel's. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +#[pyo3(signature = ( + img, pivots, slopes, curvatures, toe, shoulder, toe_width, shoulder_width, cmy_offsets, + shadow_cmy, highlight_cmy, d_min_rgb, d_max, a_toe_base, a_sh_base, width_ref, toe_height, + sh_height, zone_center, shadow_density, highlight_density, shadow_grade, highlight_grade, + zone_sh_center, zone_hi_center, zone_k, v_star, midtone_gamma, gamma_width, dye_mix, + use_dye_mix, sep_k3, sep_damping, sep_ref, use_sep_damping, ev_map, ev_scale, use_ev, + grade_map, use_grade, key_alpha, key_params, use_key, grade_deltas, r0, r_min, r_max, flash, + flash_v, flash_gamma, flash_map, use_flash, diff_plane, diff_rect, diff_mix, diff_range, + use_diff, lut, lut_e_max, lut_black, use_lut, bpc=false, black_hold=0.0 +))] +fn apply_print_curve<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + pivots: PyReadonlyArray1<'py, f32>, + slopes: PyReadonlyArray1<'py, f32>, + curvatures: PyReadonlyArray1<'py, f32>, + toe: PyReadonlyArray1<'py, f64>, + shoulder: PyReadonlyArray1<'py, f64>, + toe_width: PyReadonlyArray1<'py, f64>, + shoulder_width: PyReadonlyArray1<'py, f64>, + cmy_offsets: PyReadonlyArray1<'py, f32>, + shadow_cmy: PyReadonlyArray1<'py, f32>, + highlight_cmy: PyReadonlyArray1<'py, f32>, + d_min_rgb: PyReadonlyArray1<'py, f64>, + d_max: f64, + a_toe_base: f64, + a_sh_base: f64, + width_ref: f64, + toe_height: f64, + sh_height: f64, + zone_center: f64, + shadow_density: f64, + highlight_density: f64, + shadow_grade: PyReadonlyArray1<'py, f64>, + highlight_grade: PyReadonlyArray1<'py, f64>, + zone_sh_center: f64, + zone_hi_center: f64, + zone_k: f64, + v_star: f64, + midtone_gamma: PyReadonlyArray1<'py, f64>, + gamma_width: f64, + dye_mix: PyReadonlyArray2<'py, f64>, + use_dye_mix: bool, + sep_k3: PyReadonlyArray1<'py, f64>, + sep_damping: f64, + sep_ref: f64, + use_sep_damping: bool, + ev_map: PyReadonlyArrayDyn<'py, f32>, + ev_scale: PyReadonlyArray1<'py, f32>, + use_ev: bool, + grade_map: PyReadonlyArrayDyn<'py, f32>, + use_grade: bool, + key_alpha: PyReadonlyArrayDyn<'py, f32>, + key_params: PyReadonlyArray2<'py, f64>, + use_key: bool, + grade_deltas: PyReadonlyArrayDyn<'py, f32>, + r0: f64, + r_min: f64, + r_max: f64, + flash: f64, + flash_v: f64, + flash_gamma: f64, + flash_map: PyReadonlyArrayDyn<'py, f32>, + use_flash: bool, + diff_plane: PyReadonlyArrayDyn<'py, f32>, + diff_rect: PyReadonlyArray1<'py, f64>, + diff_mix: f64, + diff_range: PyReadonlyArray1<'py, f64>, + use_diff: bool, + lut: PyReadonlyArrayDyn<'py, f32>, + lut_e_max: PyReadonlyArray1<'py, f32>, + lut_black: f64, + use_lut: bool, + bpc: bool, + black_hold: f64, +) -> PyResult>> { + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let src = contiguous(&img)?; + let dye = dye_mix.as_array(); + if use_dye_mix && dye.shape() != [3, 3] { + return Err(PyValueError::new_err("dye_mix must be 3x3")); + } + let keys = if use_key { + let kp = key_params.as_array(); + let n = kp.shape()[0]; + if kp.shape()[1] != 4 || key_alpha.shape() != [h, w, n] { + return Err(PyValueError::new_err( + "key_alpha must be HxWxK with K x 4 key_params", + )); + } + Some(( + contiguous(&key_alpha)?, + (0..n) + .map(|i| std::array::from_fn(|j| kp[[i, j]])) + .collect::>(), + plane(&grade_deltas, h, w)?, + )) + } else { + None + }; + let diffusion = if use_diff { + let [gh, gw, 3] = diff_plane.shape()[..] else { + return Err(PyValueError::new_err("diff_plane must be gh x gw x 3")); + }; + Some(Diffusion { + plane: contiguous(&diff_plane)?, + gh, + gw, + rect: triplet_n(&diff_rect)?, + mix: diff_mix, + range: triplet(&diff_range)?, + }) + } else { + None + }; + let lut = if use_lut { + let [n, n1, n2, 3] = lut.shape()[..] else { + return Err(PyValueError::new_err("lut must be N x N x N x 3")); + }; + if n != n1 || n != n2 || n < 2 { + return Err(PyValueError::new_err("lut must be N x N x N x 3")); + } + Some(Lut { + table: contiguous(&lut)?, + n, + e_max: triplet(&lut_e_max)?, + black: lut_black, + }) + } else { + None + }; + let p = PrintCurve { + pivots: triplet(&pivots)?, + slopes: triplet(&slopes)?, + curvatures: triplet(&curvatures)?, + toe: triplet(&toe)?, + shoulder: triplet(&shoulder)?, + toe_width: triplet(&toe_width)?, + shoulder_width: triplet(&shoulder_width)?, + cmy_offsets: triplet(&cmy_offsets)?, + shadow_cmy: triplet(&shadow_cmy)?, + highlight_cmy: triplet(&highlight_cmy)?, + d_min_rgb: triplet(&d_min_rgb)?, + d_max, + a_toe_base, + a_sh_base, + width_ref, + toe_height, + sh_height, + zone_center, + shadow_density, + highlight_density, + shadow_grade: triplet(&shadow_grade)?, + highlight_grade: triplet(&highlight_grade)?, + zone_sh_center, + zone_hi_center, + zone_k, + v_star, + midtone_gamma: triplet(&midtone_gamma)?, + gamma_width, + dye_mix: use_dye_mix.then(|| std::array::from_fn(|r| std::array::from_fn(|c| dye[[r, c]]))), + sep: use_sep_damping + .then(|| triplet(&sep_k3).map(|k| (k, sep_damping, sep_ref))) + .transpose()?, + ev: use_ev.then(|| plane(&ev_map, h, w)).transpose()?, + ev_scale: triplet(&ev_scale)?, + grade: (use_grade && !use_key) + .then(|| plane(&grade_map, h, w)) + .transpose()?, + keys: keys.as_ref().map(|(alpha, params, deltas)| Keys { + alpha, + params, + deltas: *deltas, + }), + r0, + r_min, + r_max, + flash, + flash_v, + flash_gamma, + flash_map: use_flash.then(|| plane(&flash_map, h, w)).transpose()?, + diffusion, + lut, + bpc, + black_hold, + }; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + print_curve::apply_print_curve(src, w, &p, dst) + }); + } + Ok(out) +} + +fn triplet_n(a: &PyReadonlyArray1<'_, f64>) -> PyResult<[f64; 4]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))? + .try_into() + .map_err(|_| PyValueError::new_err("diff_rect must hold 4 values")) +} + +/// `tetra_lookup(table, e_max, e0, e1, e2)` of an N x N x N x 3 float32 table. +#[pyfunction] +fn tetra_lookup( + table: PyReadonlyArrayDyn<'_, f32>, + e_max: PyReadonlyArray1<'_, f32>, + e0: f64, + e1: f64, + e2: f64, +) -> PyResult<(f64, f64, f64)> { + let [n, n1, n2, 3] = table.shape()[..] else { + return Err(PyValueError::new_err("the table must be N x N x N x 3")); + }; + if n != n1 || n != n2 || n < 2 { + return Err(PyValueError::new_err("the table must be N x N x N x 3")); + } + let l = Lut { + table: contiguous(&table)?, + n, + e_max: triplet(&e_max)?, + black: 0.0, + }; + let [r, g, b] = print_curve::tetra(&l, [e0, e1, e2]); + Ok((r, g, b)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(apply_print_curve, m)?)?; + m.add_function(wrap_pyfunction!(tetra_lookup, m)?) +} diff --git a/negpy_core/src/py/process.rs b/negpy_core/src/py/process.rs new file mode 100644 index 000000000..ee9aeee2a --- /dev/null +++ b/negpy_core/src/py/process.rs @@ -0,0 +1,94 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::process; + +fn pixels<'a>( + a: &'a PyReadonlyArrayDyn<'_, f32>, + cn_min: usize, +) -> PyResult<(&'a [f32], usize, usize)> { + let shape = a.shape(); + let (w, cn) = match shape { + [_, w, c] if *c >= cn_min => (*w, *c), + _ => { + return Err(PyValueError::new_err(format!( + "needs an HxWxC array with C >= {cn_min}" + ))); + } + }; + let data = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + Ok((data, w, cn)) +} + +/// `lightbox_level`'s float64 reference value of an HxWx3 float32 image. +#[pyfunction] +#[pyo3(signature = (img, step, matrix, pct))] +fn lightbox_white( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + step: usize, + matrix: Option<[[f64; 3]; 3]>, + pct: f64, +) -> PyResult { + let (data, _, cn) = pixels(&img, 3)?; + if cn != 3 { + return Err(PyValueError::new_err("lightbox_white needs 3 channels")); + } + Ok(super::detach(py, data.len() / step.max(1), || { + process::lightbox_white(data, step, matrix, pct) + })) +} + +/// `detect_process_mode`'s statistics of an HxWx3 float32 image: (min correlation, +/// (r mean, b mean, r p25, b p25, r p98, g p98, b p98)). +#[pyfunction] +fn process_mode_stats( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, +) -> PyResult<(f64, [f64; 7])> { + let (data, _, cn) = pixels(&img, 3)?; + if cn != 3 || data.is_empty() { + return Err(PyValueError::new_err( + "process_mode_stats needs a non-empty HxWx3 array", + )); + } + Ok(super::detach(py, data.len(), || { + process::process_mode_stats(data) + })) +} + +/// `sample_patches` of an HxWxC float32 image, one (x0, x1, y0, y1) box per patch; with `gamma`, +/// of the clipped image decoded from the working encoding. +#[pyfunction] +fn chart_patches( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + boxes: Vec<(usize, usize, usize, usize)>, + gamma: Option, +) -> PyResult> { + let (data, w, cn) = pixels(&img, 3)?; + let h = img.shape()[0]; + if boxes + .iter() + .any(|&(x0, x1, y0, y1)| x0 >= x1 || y0 >= y1 || x1 > w || y1 > h) + { + return Err(PyValueError::new_err("patch box outside the image")); + } + let n = boxes + .iter() + .map(|b| (b.1 - b.0) * (b.3 - b.2)) + .sum::() + * 3; + Ok(super::detach(py, n, || { + process::chart_patches(data, w, cn, &boxes, gamma) + })) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(lightbox_white, m)?)?; + m.add_function(wrap_pyfunction!(process_mode_stats, m)?)?; + m.add_function(wrap_pyfunction!(chart_patches, m)?) +} diff --git a/negpy_core/src/py/register.rs b/negpy_core/src/py/register.rs new file mode 100644 index 000000000..2db52548f --- /dev/null +++ b/negpy_core/src/py/register.rs @@ -0,0 +1,63 @@ +use numpy::{PyArray2, PyReadonlyArray2, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::{register, sift}; + +fn gray<'a>(img: &'a PyReadonlyArray2<'_, u8>) -> PyResult<(&'a [u8], usize, usize)> { + let (h, w) = (img.shape()[0], img.shape()[1]); + let data = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous uint8 image"))?; + Ok((data, h, w)) +} + +/// `register_pair(ref_proxy, mov_proxy, min_inliers)`: (2x3 matrix as 6 floats, inliers), or +/// None when the pair has no usable overlap. +#[pyfunction] +fn register_pair( + py: Python<'_>, + ref_proxy: PyReadonlyArray2<'_, u8>, + mov_proxy: PyReadonlyArray2<'_, u8>, + min_inliers: usize, +) -> PyResult> { + let (r, m) = (gray(&ref_proxy)?, gray(&mov_proxy)?); + Ok(super::detach(py, r.0.len() + m.0.len(), || { + register::register_pair(r, m, min_inliers) + })) +} + +type Detected<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray2>); + +/// `cv2.SIFT_create(nfeatures).detectAndCompute(gray, None)`: an Nx2 float32 array of keypoint +/// positions and the Nx128 uint8 descriptors. +#[pyfunction] +fn sift_detect<'py>( + py: Python<'py>, + img: PyReadonlyArray2<'py, u8>, + nfeatures: usize, +) -> PyResult> { + let (data, h, w) = gray(&img)?; + let (kp, desc) = super::detach(py, data.len(), || { + sift::detect_and_compute(data, h, w, nfeatures) + }); + let pts: Vec> = kp.iter().map(|k| vec![k.x, k.y]).collect(); + let desc: Vec> = desc.iter().map(|d| d.to_vec()).collect(); + let err = |e: numpy::FromVecError| PyValueError::new_err(e.to_string()); + let pts = if pts.is_empty() { + PyArray2::zeros(py, [0, 2], false) + } else { + PyArray2::from_vec2(py, &pts).map_err(err)? + }; + let desc = if desc.is_empty() { + PyArray2::zeros(py, [0, sift::DESCR_LEN], false) + } else { + PyArray2::from_vec2(py, &desc).map_err(err)? + }; + Ok((pts, desc)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(register_pair, m)?)?; + m.add_function(wrap_pyfunction!(sift_detect, m)?) +} diff --git a/negpy_core/src/py/resize.rs b/negpy_core/src/py/resize.rs new file mode 100644 index 000000000..2d4f69aea --- /dev/null +++ b/negpy_core/src/py/resize.rs @@ -0,0 +1,309 @@ +use numpy::{IntoPyArray, PyArrayDyn, PyArrayMethods, PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::resize; + +/// `cv2.resize(src, dsize, interpolation=cv2.INTER_AREA)` of a contiguous float32 image. +#[pyfunction] +fn resize_area<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + dsize: (usize, usize), +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => { + return Err(PyValueError::new_err( + "resize_area needs a 2-D or 3-D array", + )); + } + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("resize_area needs a contiguous array"))?; + let (dw, dh) = dsize; + let out = super::detach(py, data.len(), || { + resize::resize_area(data, h, w, cn, dh, dw) + }) + .ok_or_else(|| { + PyValueError::new_err("resize_area does not cover a 2x2 two-channel downscale") + })?; + let dims: Vec = if shape.len() == 2 { + vec![dh, dw] + } else { + vec![dh, dw, cn] + }; + let arr = numpy::ndarray::ArrayD::from_shape_vec(dims, out) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok(arr.into_pyarray(py)) +} + +/// `cv2.resize(src, dsize, interpolation=cv2.INTER_LINEAR)` of a contiguous float32 image, as +/// OpenCV computes it without IPP. +#[pyfunction] +fn resize_linear<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + dsize: (usize, usize), +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => { + return Err(PyValueError::new_err( + "resize_linear needs a 2-D or 3-D array", + )); + } + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("resize_linear needs a contiguous array"))?; + let (dw, dh) = dsize; + let mut dims = vec![dh, dw]; + dims.extend_from_slice(&shape[2..]); + // NumPy's allocator asks for huge pages, so the output faults in far fewer pages. + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + resize::resize_linear(data, (h, w, cn), (dh, dw), false, dst) + }) + .ok_or_else(|| { + PyValueError::new_err( + "resize_linear needs a non-empty size and covers no 2x2 two-channel halving", + ) + })?; + } + Ok(out) +} + +/// `cv2.resize(src, dsize, interpolation=cv2.INTER_NEAREST)` of a contiguous uint8 image. +#[pyfunction] +fn resize_nearest<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + dsize: (usize, usize), +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => { + return Err(PyValueError::new_err( + "resize_nearest needs a 2-D or 3-D array", + )); + } + }; + let (dw, dh) = dsize; + if h == 0 || w == 0 || cn == 0 || dh == 0 || dw == 0 { + return Err(PyValueError::new_err( + "resize_nearest needs a non-empty size", + )); + } + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("resize_nearest needs a contiguous array"))?; + let mut dims = vec![dh, dw]; + dims.extend_from_slice(&shape[2..]); + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + resize::resize_nearest(data, (h, w, cn), (dh, dw), dst) + }); + } + Ok(out) +} + +/// `expand_mask_plane(plane, (h, w), roi)` times `scale` for a float32 plane and a clamped, +/// non-empty roi (y1, y2, x1, x2). +#[pyfunction] +fn expand_plane<'py>( + py: Python<'py>, + plane: PyReadonlyArrayDyn<'py, f32>, + out_shape: (usize, usize), + roi: (usize, usize, usize, usize), + scale: f32, +) -> PyResult>> { + let [ph, pw] = plane.shape()[..] else { + return Err(PyValueError::new_err("expand_plane needs a 2-D plane")); + }; + let src = plane + .as_slice() + .map_err(|_| PyValueError::new_err("expand_plane needs a contiguous plane"))?; + let (h, w) = out_shape; + let out = PyArrayDyn::::zeros(py, vec![h, w], false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + resize::expand_plane(src, (ph, pw), (h, w), roi, scale, dst) + }) + .ok_or_else(|| { + PyValueError::new_err("expand_plane needs a non-empty roi inside the output") + })?; + } + Ok(out) +} + +/// `cv2.resize(src, dsize, interpolation=cv2.INTER_CUBIC)` (`lanczos=False`) or +/// `cv2.INTER_LANCZOS4` of a contiguous float32 image. +#[pyfunction] +#[pyo3(signature = (src, dsize, lanczos=false))] +fn resize_kernel<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + dsize: (usize, usize), + lanczos: bool, +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => { + return Err(PyValueError::new_err( + "resize_kernel needs a 2-D or 3-D array", + )); + } + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("resize_kernel needs a contiguous array"))?; + let (dw, dh) = dsize; + let mut dims = vec![dh, dw]; + dims.extend_from_slice(&shape[2..]); + let kernel = if lanczos { + resize::Kernel::Lanczos4 + } else { + resize::Kernel::Cubic + }; + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + resize::resize_kernel(data, (h, w, cn), (dh, dw), kernel, dst) + }) + .ok_or_else(|| PyValueError::new_err("resize_kernel needs a non-empty image and size"))?; + } + Ok(out) +} + +/// `cv2.resize(src, dsize, interpolation=...)` of a contiguous uint8 image: `cubic` False is +/// INTER_AREA, True INTER_CUBIC as OpenCV computes it without IPP. +#[pyfunction] +#[pyo3(signature = (src, dsize, cubic=false))] +fn resize_u8<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, u8>, + dsize: (usize, usize), + cubic: bool, +) -> PyResult>> { + let shape = src.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => return Err(PyValueError::new_err("resize_u8 needs a 2-D or 3-D array")), + }; + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("resize_u8 needs a contiguous array"))?; + let (dw, dh) = dsize; + let mut dims = vec![dh, dw]; + dims.extend_from_slice(&shape[2..]); + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + if cubic { + resize::resize_cubic_u8(data, (h, w, cn), (dh, dw), dst) + } else { + resize::resize_area_u8(data, (h, w, cn), (dh, dw), dst) + } + }) + .ok_or_else(|| PyValueError::new_err("resize_u8 needs a non-empty image and size"))?; + } + Ok(out) +} + +type RowResize = fn(&[T], (usize, usize, usize), usize, &mut [T]) -> Option<()>; + +/// `cv2.resize(src, (w, dh), interpolation=cv2.INTER_LINEAR)` of a contiguous uint8 or uint16 +/// image whose width stays, as OpenCV computes it without IPP. +#[pyfunction] +fn resize_rows_linear<'py>( + py: Python<'py>, + src: &Bound<'py, PyAny>, + dh: usize, +) -> PyResult> { + fn run<'py, T: numpy::Element + Copy + Send + Sync + Default>( + py: Python<'py>, + src: &Bound<'py, PyAny>, + dh: usize, + f: RowResize, + ) -> PyResult> { + let a: PyReadonlyArrayDyn<'py, T> = src.extract()?; + let shape = a.shape().to_vec(); + let (h, w, cn) = match shape.as_slice() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => { + return Err(PyValueError::new_err( + "resize_rows_linear needs a 2-D or 3-D array", + )); + } + }; + let data = a + .as_slice() + .map_err(|_| PyValueError::new_err("resize_rows_linear needs a contiguous array"))?; + let mut dims = shape.clone(); + dims[0] = dh; + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || f(data, (h, w, cn), dh, dst)).ok_or_else(|| { + PyValueError::new_err("resize_rows_linear needs a non-empty image and size") + })?; + } + Ok(out.into_any()) + } + if src.extract::>().is_ok() { + run::(py, src, dh, resize::resize_rows_linear_u8) + } else if src.extract::>().is_ok() { + run::(py, src, dh, resize::resize_rows_linear_u16) + } else { + Err(pyo3::exceptions::PyTypeError::new_err( + "resize_rows_linear needs uint8 or uint16", + )) + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(resize_rows_linear, m)?)?; + m.add_function(wrap_pyfunction!(resize_u8, m)?)?; + m.add_function(wrap_pyfunction!(resize_kernel, m)?)?; + m.add_function(wrap_pyfunction!(expand_plane, m)?)?; + m.add_function(wrap_pyfunction!(resize_nearest, m)?)?; + m.add_function(wrap_pyfunction!(resize_area, m)?)?; + m.add_function(wrap_pyfunction!(resize_linear, m)?) +} diff --git a/negpy_core/src/py/retouch.rs b/negpy_core/src/py/retouch.rs new file mode 100644 index 000000000..10021afff --- /dev/null +++ b/negpy_core/src/py/retouch.rs @@ -0,0 +1,842 @@ +use numpy::{PyReadonlyArrayDyn, PyReadwriteArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::retouch; + +fn plane<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>, n: usize) -> PyResult<&'a [f32]> { + let s = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + if s.len() != n { + return Err(PyValueError::new_err("array sizes disagree")); + } + Ok(s) +} + +/// `_hair_encode(crop, lo, inv_span, out_u8)` of an HxWxC float32 tile. +#[pyfunction] +fn hair_encode( + py: Python<'_>, + crop: numpy::PyReadonlyArrayDyn<'_, f32>, + lo: f32, + inv_span: f32, + inv_gamma: f32, + mut out: numpy::PyReadwriteArrayDyn<'_, u8>, +) -> PyResult<()> { + use numpy::PyUntypedArrayMethods; + let [_, _, cn] = crop.shape()[..] else { + return Err(pyo3::exceptions::PyValueError::new_err( + "needs an HxWxC tile", + )); + }; + if cn < 3 || out.shape() != crop.shape() { + return Err(pyo3::exceptions::PyValueError::new_err( + "needs 3 channels and a matching out", + )); + } + let src = crop + .as_slice() + .map_err(|_| pyo3::exceptions::PyValueError::new_err("needs a contiguous tile"))?; + let dst = out + .as_slice_mut() + .map_err(|_| pyo3::exceptions::PyValueError::new_err("needs a contiguous out"))?; + super::detach(py, dst.len(), || { + crate::retouch::hair_encode(src, cn, lo, inv_span, inv_gamma, dst) + }); + Ok(()) +} + +/// `_ir_clean_base(img_det, ratio)` with (window, identity, sigma, min_support). +#[pyfunction] +fn ir_clean_base<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + ratio: PyReadonlyArrayDyn<'py, f32>, + constants: (usize, f32, f32, f32), +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + if ratio.shape() != [h, w] { + return Err(PyValueError::new_err("needs an HxW ratio")); + } + let (src, ratio) = (plane(&img, 3 * h * w)?, plane(&ratio, h * w)?); + let (win, identity, sigma, min_support) = constants; + let c = retouch::CleanBase { + win, + identity, + sigma, + min_support, + }; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + retouch::ir_clean_base(src, ratio, (h, w), &c, dst) + }) + .ok_or_else(|| PyValueError::new_err("invalid clean-base input"))?; + } + Ok(out) +} + +fn plane2<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], (usize, usize))> { + let [h, w] = a.shape()[..] else { + return Err(PyValueError::new_err("needs an HxW plane")); + }; + Ok((plane(a, h * w)?, (h, w))) +} + +fn mask<'a>(a: &'a PyReadonlyArrayDyn<'_, bool>, n: usize) -> PyResult<&'a [bool]> { + let s = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + if s.len() != n { + return Err(PyValueError::new_err("array sizes disagree")); + } + Ok(s) +} + +fn bool_array<'py>( + py: Python<'py>, + data: Vec, + (h, w): (usize, usize), +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + data.into_pyarray(py).reshape(vec![h, w]) +} + +/// `_ir_live(plane)` with (dead floor, minimum holder area as a fraction of the frame). +#[pyfunction] +fn ir_live<'py>( + py: Python<'py>, + plane: PyReadonlyArrayDyn<'py, f32>, + floor: f32, + min_area: f64, +) -> PyResult>> { + let (src, dims) = plane2(&plane)?; + let live = super::detach(py, src.len(), || { + retouch::ir_live(src, dims, floor, min_area) + }); + bool_array(py, live, dims) +} + +/// `_ir_glare(plane, live)` with (sample cap, sigmas). +#[pyfunction] +fn ir_glare<'py>( + py: Python<'py>, + plane: PyReadonlyArrayDyn<'py, f32>, + live: PyReadonlyArrayDyn<'py, bool>, + cap: usize, + sigmas: f64, +) -> PyResult>>> { + let (src, dims) = plane2(&plane)?; + let live = mask(&live, src.len())?; + super::detach(py, src.len(), || retouch::ir_glare(src, live, cap, sigmas)) + .map(|g| bool_array(py, g, dims)) + .transpose() +} + +/// `normalize_ir(plane, glare)` for a float32 plane, with the base window in pixels. +#[pyfunction] +#[pyo3(signature = (plane, glare, win))] +fn normalize_ir<'py>( + py: Python<'py>, + plane: PyReadonlyArrayDyn<'py, f32>, + glare: Option>, + win: usize, +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let (src, dims) = plane2(&plane)?; + let glare = glare.as_ref().map(|g| mask(g, src.len())).transpose()?; + let out = PyArrayDyn::::zeros(py, plane.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + retouch::normalize_ir(src, glare, dims, win, dst) + }) + .ok_or_else(|| PyValueError::new_err("invalid normalize_ir input"))?; + } + Ok(out) +} + +/// `_ir_normalize_ratio(ratio, live)` in place, with (identity, noise sigma, pivot floor, +/// reference sigma, scale cap); returns (pivot, sigma), or None when it left the ratio alone. +#[pyfunction] +fn ir_normalize_ratio( + py: Python<'_>, + mut ratio: PyReadwriteArrayDyn<'_, f32>, + live: PyReadonlyArrayDyn<'_, bool>, + constants: (f64, f64, f64, f64, f64), +) -> PyResult> { + let r = ratio + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + let live = mask(&live, r.len())?; + let (identity, noise_sigma, pivot_lo, ref_sigma, scale_max) = constants; + let c = retouch::RatioNorm { + identity, + noise_sigma, + pivot_lo, + ref_sigma, + scale_max, + }; + Ok(super::detach(py, r.len(), || { + retouch::ir_normalize_ratio(r, live, &c) + })) +} + +/// `_ir_decontaminate`'s division of an HxW ratio by the ghost of HxWx3 `vis_log`. +#[pyfunction] +fn ir_ghost_divide<'py>( + py: Python<'py>, + ratio: PyReadonlyArrayDyn<'py, f32>, + vis_log: PyReadonlyArrayDyn<'py, f32>, + b: [f32; 3], +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + let (r, (h, w)) = plane2(&ratio)?; + if vis_log.shape() != [h, w, 3] { + return Err(PyValueError::new_err("needs an HxWx3 vis_log")); + } + let v = plane(&vis_log, 3 * h * w)?; + let out = PyArrayDyn::::zeros(py, ratio.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || retouch::ir_ghost_divide(r, v, b, dst)); + } + Ok(out) +} + +fn image3<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<(&'a [f32], (usize, usize))> { + let [h, w, 3] = a.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + Ok((plane(a, 3 * h * w)?, (h, w))) +} + +fn new_image<'py>( + py: Python<'py>, + shape: &[usize], + fill: impl FnOnce(&mut [f32]) -> Option<()> + Send, +) -> PyResult>> { + use numpy::{PyArrayDyn, PyArrayMethods}; + // numpy.empty, not zeros: NumPy's allocator asks for huge pages, and every pixel is written. + let out = py + .import("numpy")? + .getattr("empty")? + .call1((shape.to_vec(), "float32"))? + .cast_into::>()?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || fill(dst)) + .ok_or_else(|| PyValueError::new_err("invalid input"))?; + } + Ok(out) +} + +/// `out[idx] += _borrow_clean_grain(src, clean, sigma, idx)[0]` for every pixel `clean` leaves +/// out, in place. +#[pyfunction] +fn add_clean_grain( + py: Python<'_>, + mut out: PyReadwriteArrayDyn<'_, f32>, + src: PyReadonlyArrayDyn<'_, f32>, + clean: PyReadonlyArrayDyn<'_, bool>, + sigma: f64, +) -> PyResult<()> { + let (s, dims) = image3(&src)?; + let clean = mask(&clean, dims.0 * dims.1)?; + if out.shape() != src.shape() { + return Err(PyValueError::new_err("needs a matching out")); + } + let o = out + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous out"))?; + super::detach(py, s.len(), || { + retouch::add_clean_grain(s, clean, dims, sigma, None, o) + }); + Ok(()) +} + +/// `apply_score_repair` of an HxWx3 image and a score at its resolution, with the supports, the +/// floor rule, the grain sigma and (score floor, tau, write hi, write hi - write lo). +#[pyfunction] +fn apply_score_repair<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + score: PyReadonlyArrayDyn<'py, f32>, + supports: Vec, + floor: bool, + sigma: f64, + constants: (f32, f32, f32, f32), +) -> PyResult>> { + let (src, dims) = image3(&img)?; + let sc = plane(&score, dims.0 * dims.1)?; + let (score_floor, tau, write_hi, write_span) = constants; + let c = retouch::ScoreRepair { + score_floor, + tau, + write_hi, + write_span, + }; + new_image(py, img.shape(), |dst| { + retouch::apply_score_repair(src, sc, dims, &supports, floor, sigma, &c, dst) + }) +} + +/// `repair_components` into `out`, which holds the base, with the supports, the floor rule, +/// the grain sigma, (score floor, tau, write hi, write hi - write lo), the crop pad and the +/// component cap; returns False, leaving `out`, when the score holds no defect. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +#[pyo3(signature = (img, score, out, supports, floor, sigma, constants, pad, max_components, dirty=None))] +fn repair_components( + py: Python<'_>, + img: PyReadonlyArrayDyn<'_, f32>, + score: PyReadonlyArrayDyn<'_, f32>, + mut out: PyReadwriteArrayDyn<'_, f32>, + supports: Vec, + floor: bool, + sigma: f64, + constants: (f32, f32, f32, f32), + pad: usize, + max_components: usize, + dirty: Option>, +) -> PyResult { + let (src, dims) = image3(&img)?; + let n = dims.0 * dims.1; + let sc = plane(&score, n)?; + let dirty = dirty.as_ref().map(|d| mask(d, n)).transpose()?; + if out.shape() != img.shape() { + return Err(PyValueError::new_err("needs a matching out")); + } + let o = out + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous out"))?; + let (score_floor, tau, write_hi, write_span) = constants; + let c = retouch::ScoreRepair { + score_floor, + tau, + write_hi, + write_span, + }; + super::detach(py, src.len(), || { + retouch::repair_components( + src, + sc, + dims, + &supports, + floor, + sigma, + &c, + pad, + max_components, + dirty, + o, + ) + }) + .ok_or_else(|| PyValueError::new_err("invalid repair input")) +} + +/// `route_wide_defects` before its budget check, with the at-floor bar, the radius and the +/// dilate kernel: a uint8 HxW mask, or None. +#[pyfunction] +fn route_wide_defects<'py>( + py: Python<'py>, + score: PyReadonlyArrayDyn<'py, f32>, + bar: f32, + radius: usize, + kernel: numpy::PyReadonlyArray2<'py, u8>, +) -> PyResult>>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let (sc, (h, w)) = plane2(&score)?; + let k = kernel.as_array(); + let cells: Vec = k.iter().copied().collect(); + let kernel = crate::morph::Kernel { + mask: &cells, + w: k.ncols(), + h: k.nrows(), + }; + super::detach(py, sc.len(), || { + retouch::route_wide_defects(sc, (h, w), bar, radius, kernel) + }) + .map(|r| r.into_pyarray(py).reshape([h, w])) + .transpose() +} + +/// `_fit_refraction_gammas(ratio, vis_log, img_det)` with (band low, band high), the flat +/// percentile, the minimum flat count, the sample cap, the fallback and (gamma low, high). +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn fit_refraction_gammas( + py: Python<'_>, + ratio: PyReadonlyArrayDyn<'_, f32>, + vis_log: PyReadonlyArrayDyn<'_, f32>, + img: PyReadonlyArrayDyn<'_, f32>, + band: (f32, f32), + flat_pct: f64, + min_flat: usize, + cap: usize, + fallback: f64, + range: (f32, f32), +) -> PyResult<(f64, f64, f64)> { + let (r, dims) = plane2(&ratio)?; + let (v, (h, w)) = image3(&vis_log)?; + let (src, idims) = image3(&img)?; + if dims != (h, w) || idims != dims { + return Err(PyValueError::new_err("array shapes disagree")); + } + let c = retouch::GammaFit { + band, + flat_pct, + min_flat, + cap, + fallback, + range, + }; + let g = super::detach(py, r.len(), || { + retouch::fit_refraction_gammas(r, v, src, dims, &c) + }) + .ok_or_else(|| PyValueError::new_err("invalid gamma fit input"))?; + Ok((g[0], g[1], g[2])) +} + +/// `ir_defect_score(ratio, cutoff)` with (cutoff, span, 1 - floor, floor) as float32. +#[pyfunction] +fn ir_defect_score<'py>( + py: Python<'py>, + ratio: PyReadonlyArrayDyn<'py, f32>, + constants: (f32, f32, f32, f32), +) -> PyResult>> { + let (r, dims) = plane2(&ratio)?; + new_image(py, ratio.shape(), |dst| { + retouch::ir_defect_score(r, dims, constants, dst) + }) +} + +type Plane<'py> = Bound<'py, numpy::PyArray2>; + +fn plane_array<'py>(py: Python<'py>, v: Vec, (h, w): (usize, usize)) -> PyResult> { + use numpy::{IntoPyArray, PyArrayMethods}; + v.into_pyarray(py).reshape([h, w]) +} + +/// `compute_dust_stats` from the density plane, with the median and texture windows, the +/// opening disk and (minimum spread, averaging window, MAD gain, sigma floor). +#[pyfunction] +fn dust_stats<'py>( + py: Python<'py>, + dens: PyReadonlyArrayDyn<'py, f32>, + v_win: usize, + w_win: usize, + disk: numpy::PyReadonlyArray2<'py, u8>, + constants: (f64, usize, f32, f32), +) -> PyResult<(Plane<'py>, Plane<'py>, Plane<'py>, Plane<'py>)> { + let (d, dims) = plane2(&dens)?; + let k = disk.as_array(); + let cells: Vec = k.iter().copied().collect(); + let (min_spread, avg_px, mad_gain, sigma_min) = constants; + let c = retouch::DustStats { + v_win, + w_win, + disk: crate::morph::Kernel { + mask: &cells, + w: k.ncols(), + h: k.nrows(), + }, + min_spread, + avg_px, + mad_gain, + sigma_min, + }; + let [p, b, z, t] = super::detach(py, d.len(), || retouch::dust_stats(d, dims, &c)) + .ok_or_else(|| PyValueError::new_err("invalid dust stats input"))?; + Ok(( + plane_array(py, p, dims)?, + plane_array(py, b, dims)?, + plane_array(py, z, dims)?, + plane_array(py, t, dims)?, + )) +} + +fn u8_array<'py>( + py: Python<'py>, + v: Vec, + (h, w): (usize, usize), +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + v.into_pyarray(py).reshape([h, w]) +} + +type SplitHairs<'py> = ( + Bound<'py, numpy::PyArray2>, + Option>>, +); + +/// `split_hairs(mask)` of a uint8 mask with (minimum hair area, minimum elongation). +#[pyfunction] +fn split_hairs<'py>( + py: Python<'py>, + mask: numpy::PyReadonlyArray2<'py, u8>, + hair: (usize, f64), +) -> PyResult> { + let dims = (mask.shape()[0], mask.shape()[1]); + let m = mask + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous mask"))?; + let (compact, hairs) = super::detach(py, m.len(), || retouch::split_hairs(m, dims, hair)); + Ok(( + u8_array(py, compact, dims)?, + hairs.map(|h| u8_array(py, h, dims)).transpose()?, + )) +} + +type LumaScore<'py> = (Option>, Option>>); + +/// `detect_luma_score` from (proxy, background, z, texture) with (hi, hi_hair, lo), the +/// reach disk, and the constants it reads, in the order of `retouch::LumaDetect`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn detect_luma_score<'py>( + py: Python<'py>, + stats: ( + PyReadonlyArrayDyn<'py, f32>, + PyReadonlyArrayDyn<'py, f32>, + PyReadonlyArrayDyn<'py, f32>, + PyReadonlyArrayDyn<'py, f32>, + ), + bars: (f64, f64, f64), + reach_disk: numpy::PyReadonlyArray2<'py, u8>, + texture: (f32, f32, f32), + hair: (usize, f64), + hair_texture: (f64, f64, f32), + texture_win: usize, + pad: (f64, f64), +) -> PyResult> { + let (proxy, dims) = plane2(&stats.0)?; + let n = proxy.len(); + let (background, z, tex) = ( + plane(&stats.1, n)?, + plane(&stats.2, n)?, + plane(&stats.3, n)?, + ); + let k = reach_disk.as_array(); + let cells: Vec = k.iter().copied().collect(); + let c = retouch::LumaDetect { + hi: bars.0, + hi_hair: bars.1, + lo: bars.2, + proxy_min: texture.0, + reach_disk: crate::morph::Kernel { + mask: &cells, + w: k.ncols(), + h: k.nrows(), + }, + texture_knee: texture.1, + texture_gain: texture.2, + hair, + hair_fill_gain: hair_texture.2, + hair_texture: (hair_texture.0, hair_texture.1), + texture_win, + pad_px: pad.0, + score_floor: pad.1, + }; + let (score, hairs) = super::detach(py, n, || { + retouch::detect_luma_score([proxy, background, z, tex], dims, &c) + }) + .ok_or_else(|| PyValueError::new_err("invalid detection input"))?; + Ok(( + score.map(|s| plane_array(py, s, dims)).transpose()?, + hairs.map(|h| u8_array(py, h, dims)).transpose()?, + )) +} + +/// `_scratch_ridge` from the density plane, with (fine sigma, broad sigma, noise window). +#[pyfunction] +fn scratch_ridge<'py>( + py: Python<'py>, + dens: PyReadonlyArrayDyn<'py, f32>, + constants: (f64, f64, usize), +) -> PyResult>> { + let (d, dims) = plane2(&dens)?; + new_image(py, dens.shape(), |dst| { + retouch::scratch_ridge(d, dims, constants, dst) + }) +} + +type SlopeFound<'py> = (f64, usize, usize, Bound<'py, numpy::PyArray1>); + +/// `trace_scratch`'s slope search over the ridge band `z`: (strength, slope index, row, +/// that sheared row), or None for bad input. +#[pyfunction] +fn scratch_slope_search<'py>( + py: Python<'py>, + z: PyReadonlyArrayDyn<'py, f32>, + cx: f64, + rows: (usize, usize), + pull: numpy::PyReadonlyArray1<'py, f64>, + slopes: numpy::PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + use numpy::IntoPyArray; + let (zs, dims) = plane2(&z)?; + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let pull = pull.as_slice().map_err(not_contiguous)?; + let slopes = slopes.as_slice().map_err(not_contiguous)?; + let found = super::detach(py, zs.len() * slopes.len(), || { + retouch::scratch_slope_search(zs, dims, cx, rows.0..rows.1, pull, slopes) + }); + Ok(found.map(|(s, i, k, row)| (s, i, k, row.into_pyarray(py)))) +} + +/// `apply_clone_stroke` in place: the crop (x0, y0, x1, y1), the smoothed path in crop +/// pixels, the brush radius in whole pixels, then (radius, sigma), the source shift and +/// (feather, strength, match tone). +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn clone_stroke( + py: Python<'_>, + mut out: PyReadwriteArrayDyn<'_, f32>, + rect: (usize, usize, usize, usize), + path: numpy::PyReadonlyArrayDyn<'_, i32>, + brush: i64, + sizes: (f64, f64), + shift: (i64, i64), + brush_settings: (f64, f64, bool), +) -> PyResult<()> { + use numpy::PyUntypedArrayMethods; + let [h, w, 3] = out.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + let (x0, y0, x1, y1) = rect; + if x1 > w || y1 > h || x0 >= x1 || y0 >= y1 { + return Err(PyValueError::new_err("crop outside the image")); + } + let path = path_points(&path)?; + let (radius, sigma) = sizes; + let (feather, strength, match_tone) = brush_settings; + let s = retouch::CloneStroke { + rect, + path: &path, + brush, + radius, + shift, + sigma, + feather, + strength, + match_tone, + }; + let o = out + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + super::detach(py, (x1 - x0) * (y1 - y0), || { + retouch::clone_stroke(o, (h, w), &s) + }) + .ok_or_else(|| PyValueError::new_err("invalid clone stroke")) +} + +fn path_points(path: &numpy::PyReadonlyArrayDyn<'_, i32>) -> PyResult> { + let pts = path + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous path"))?; + Ok(pts + .as_chunks::<2>() + .0 + .iter() + .map(|p| (p[0] as i64, p[1] as i64)) + .collect()) +} + +/// `drop_exclusions`' score release in place under a uint8 footprint, with the rim in pixels; +/// returns whether anything stays under 1. +#[pyfunction] +fn release_score( + py: Python<'_>, + mut score: PyReadwriteArrayDyn<'_, f32>, + cover: numpy::PyReadonlyArray2<'_, u8>, + rim: f32, +) -> PyResult { + let [h, w] = score.shape()[..] else { + return Err(PyValueError::new_err("needs an HxW score")); + }; + if cover.shape() != [h, w] { + return Err(PyValueError::new_err( + "needs a footprint of the score's shape", + )); + } + let c = cover + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous footprint"))?; + let s = score + .as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous score"))?; + Ok(super::detach(py, s.len(), || { + retouch::release_score(s, c, (h, w), rim) + })) +} + +/// One stroke of `strokes_to_score`: the density crop, the smoothed path in crop pixels, (cap +/// radius, line thickness), then (window, z min, z hi, z grow, grow fraction, pad, floor, rim). +#[pyfunction] +fn heal_region<'py>( + py: Python<'py>, + dens: PyReadonlyArrayDyn<'py, f32>, + path: numpy::PyReadonlyArrayDyn<'py, i32>, + brush: (i64, i64), + constants: (usize, f64, f64, f64, f64, f64, f64, f32), +) -> PyResult>> { + let (d, dims) = plane2(&dens)?; + let path = path_points(&path)?; + let (win, z_min, z_hi, z_grow, z_grow_frac, pad_px, score_floor, rim) = constants; + let c = retouch::HealSearch { + win, + z_min, + z_hi, + z_grow, + z_grow_frac, + pad_px, + score_floor, + rim, + }; + super::detach(py, d.len(), || { + retouch::heal_region(d, dims, &path, brush, &c) + }) + .map(|r| plane_array(py, r, dims)) + .transpose() +} + +/// `ir_ratio_and_gain`'s visible logs of an HxWx3 image, with the base window in pixels. +#[pyfunction] +fn ir_vis_log<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + win: usize, +) -> PyResult>> { + let (src, dims) = image3(&img)?; + new_image(py, img.shape(), |dst| { + retouch::ir_vis_log(src, dims, win, dst) + }) +} + +/// `ir_ratio_and_gain`'s gain from the ratio, the image, its clean base and the gammas, with +/// (identity, clamp). +#[pyfunction] +fn ir_gain<'py>( + py: Python<'py>, + ratio: PyReadonlyArrayDyn<'py, f32>, + img: PyReadonlyArrayDyn<'py, f32>, + clean: PyReadonlyArrayDyn<'py, f32>, + gammas: [f64; 3], + constants: (f32, f32), +) -> PyResult>> { + let (src, (h, w)) = image3(&img)?; + let r = plane(&ratio, h * w)?; + let c = plane(&clean, 3 * h * w)?; + new_image(py, img.shape(), |dst| { + retouch::ir_gain(r, src, c, gammas, constants, dst); + Some(()) + }) +} + +/// `cv2.inpaint(img, mask, radius, cv2.INPAINT_NS)` of an HxWx3 uint8 image. +#[pyfunction] +fn inpaint_ns<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, u8>, + mask: PyReadonlyArrayDyn<'py, u8>, + radius: f64, +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let [h, w, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 image")); + }; + if mask.shape() != [h, w] { + return Err(PyValueError::new_err("needs an HxW mask")); + } + let (src, m) = match (img.as_slice(), mask.as_slice()) { + (Ok(s), Ok(m)) => (s, m), + _ => return Err(PyValueError::new_err("needs contiguous arrays")), + }; + let out = super::detach(py, src.len(), || { + let mut out = src.to_vec(); + crate::inpaint::inpaint_ns(&mut out, m, (h, w), radius); + out + }); + out.into_pyarray(py).reshape(vec![h, w, 3]) +} + +/// `apply_hair_inpaint` of an HxWx3 image and its dilated hair mask, before the clean grain, with +/// (radius, dilate px, pad, tile, halo, range sample px, gamma). +#[pyfunction] +fn hair_fill<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + mask: PyReadonlyArrayDyn<'py, u8>, + constants: (f64, usize, usize, usize, usize, usize, f64), +) -> PyResult>> { + let (src, dims) = image3(&img)?; + let m = mask + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous mask"))?; + let (radius, dilate_px, pad, tile, halo, sample_px, gamma) = constants; + if m.len() != dims.0 * dims.1 || tile == 0 || sample_px == 0 { + return Err(PyValueError::new_err( + "needs an HxW mask and nonzero tile and sample", + )); + } + let c = crate::inpaint::HairFill { + radius, + dilate_px, + pad, + tile, + halo, + sample_px, + gamma, + }; + new_image(py, img.shape(), |dst| { + dst.copy_from_slice(src); + crate::inpaint::hair_fill(src, dims, m, &c, dst); + Some(()) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(inpaint_ns, m)?)?; + m.add_function(wrap_pyfunction!(hair_fill, m)?)?; + m.add_function(wrap_pyfunction!(ir_vis_log, m)?)?; + m.add_function(wrap_pyfunction!(ir_gain, m)?)?; + m.add_function(wrap_pyfunction!(release_score, m)?)?; + m.add_function(wrap_pyfunction!(heal_region, m)?)?; + m.add_function(wrap_pyfunction!(clone_stroke, m)?)?; + m.add_function(wrap_pyfunction!(scratch_ridge, m)?)?; + m.add_function(wrap_pyfunction!(scratch_slope_search, m)?)?; + m.add_function(wrap_pyfunction!(split_hairs, m)?)?; + m.add_function(wrap_pyfunction!(detect_luma_score, m)?)?; + m.add_function(wrap_pyfunction!(dust_stats, m)?)?; + m.add_function(wrap_pyfunction!(ir_defect_score, m)?)?; + m.add_function(wrap_pyfunction!(fit_refraction_gammas, m)?)?; + m.add_function(wrap_pyfunction!(route_wide_defects, m)?)?; + m.add_function(wrap_pyfunction!(repair_components, m)?)?; + m.add_function(wrap_pyfunction!(add_clean_grain, m)?)?; + m.add_function(wrap_pyfunction!(apply_score_repair, m)?)?; + m.add_function(wrap_pyfunction!(ir_ghost_divide, m)?)?; + m.add_function(wrap_pyfunction!(ir_live, m)?)?; + m.add_function(wrap_pyfunction!(ir_glare, m)?)?; + m.add_function(wrap_pyfunction!(normalize_ir, m)?)?; + m.add_function(wrap_pyfunction!(ir_normalize_ratio, m)?)?; + m.add_function(wrap_pyfunction!(ir_clean_base, m)?)?; + m.add_function(wrap_pyfunction!(hair_encode, m)?) +} diff --git a/negpy_core/src/py/rgbscan.rs b/negpy_core/src/py/rgbscan.rs new file mode 100644 index 000000000..b2bc738e4 --- /dev/null +++ b/negpy_core/src/py/rgbscan.rs @@ -0,0 +1,25 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// `_channel_means` of a float32 mosaic and its uint8 CFA color indices. +#[pyfunction] +fn channel_means( + py: Python<'_>, + mosaic: PyReadonlyArrayDyn<'_, f32>, + colors: PyReadonlyArrayDyn<'_, u8>, + black: f64, +) -> PyResult<(f64, f64, f64)> { + if mosaic.shape() != colors.shape() { + return Err(PyValueError::new_err("mosaic and colors differ in shape")); + } + let (Ok(m), Ok(c)) = (mosaic.as_slice(), colors.as_slice()) else { + return Err(PyValueError::new_err("needs contiguous arrays")); + }; + let r = super::detach(py, m.len(), || crate::rgbscan::channel_means(m, c, black)); + Ok((r[0], r[1], r[2])) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(channel_means, m)?) +} diff --git a/negpy_core/src/py/sensor.rs b/negpy_core/src/py/sensor.rs new file mode 100644 index 000000000..9aa41ca47 --- /dev/null +++ b/negpy_core/src/py/sensor.rs @@ -0,0 +1,175 @@ +use numpy::{ + PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArrayDyn, PyReadwriteArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::sensor::{self, Layer, Matrix}; + +fn data<'a>(a: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult<&'a [f32]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays")) +} + +fn matrix(m: &PyReadonlyArray2<'_, f32>) -> PyResult { + let m = m.as_array(); + if m.shape() != [3, 3] { + return Err(PyValueError::new_err("needs a 3x3 matrix")); + } + Ok(std::array::from_fn(|r| std::array::from_fn(|c| m[[r, c]]))) +} + +fn triplet(a: &PyReadonlyArray1<'_, f32>) -> PyResult<[f32; 3]> { + a.as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))? + .try_into() + .map_err(|_| PyValueError::new_err("needs a 3-vector")) +} + +/// The writable data of `out`, which must have `shape`. +fn target<'a>( + out: &'a mut PyReadwriteArrayDyn<'_, f32>, + shape: &[usize], +) -> PyResult<&'a mut [f32]> { + if out.shape() != shape { + return Err(PyValueError::new_err("out does not match the input")); + } + out.as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous out")) +} + +fn image(x: &PyReadonlyArrayDyn<'_, f32>) -> PyResult<()> { + if x.shape().len() != 3 || x.shape()[2] != 3 { + return Err(PyValueError::new_err("needs an HxWx3 image")); + } + Ok(()) +} + +/// `_unmix_kernel(x, m, out)`. +#[pyfunction] +fn sensor_unmix( + py: Python<'_>, + x: PyReadonlyArrayDyn<'_, f32>, + m: PyReadonlyArray2<'_, f32>, + mut out: PyReadwriteArrayDyn<'_, f32>, +) -> PyResult<()> { + image(&x)?; + let (src, m) = (data(&x)?, matrix(&m)?); + let dst = target(&mut out, x.shape())?; + super::detach(py, dst.len(), || sensor::unmix(src, dst, m)); + Ok(()) +} + +/// `_log_unmix_kernel(x, c, offset, floor, out)`. +#[pyfunction] +fn sensor_log_unmix( + py: Python<'_>, + x: PyReadonlyArrayDyn<'_, f32>, + c: PyReadonlyArray2<'_, f32>, + offset: PyReadonlyArray1<'_, f32>, + floor: PyReadonlyArray1<'_, f32>, + mut out: PyReadwriteArrayDyn<'_, f32>, +) -> PyResult<()> { + image(&x)?; + let (src, c, offset, floor) = (data(&x)?, matrix(&c)?, triplet(&offset)?, triplet(&floor)?); + let dst = target(&mut out, x.shape())?; + super::detach(py, dst.len(), || { + sensor::log_unmix(src, dst, c, offset, floor) + }); + Ok(()) +} + +/// `_two_scale_layer_kernel(blurred, m, own, floor, log_floor, start, full, color, weight)`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn sensor_two_scale_layer( + py: Python<'_>, + blurred: PyReadonlyArrayDyn<'_, f32>, + m: PyReadonlyArray2<'_, f32>, + own: PyReadonlyArray1<'_, f32>, + floor: f64, + log_floor: f64, + start: f64, + full: f64, + mut color: PyReadwriteArrayDyn<'_, f32>, + mut weight: PyReadwriteArrayDyn<'_, f32>, +) -> PyResult<()> { + image(&blurred)?; + let (src, m, own) = (data(&blurred)?, matrix(&m)?, triplet(&own)?); + let shape = blurred.shape().to_vec(); + let c = target(&mut color, &shape)?; + let w = target(&mut weight, &shape[..2])?; + let s = Layer { + floor, + log_floor, + start, + full, + }; + super::detach(py, src.len(), || { + sensor::two_scale_layer(src, m, own, s, c, w) + }); + Ok(()) +} + +/// `_two_scale_kernel(x, blurred, color, weight, m, c, floor, log_defect, out)`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn sensor_two_scale( + py: Python<'_>, + x: PyReadonlyArrayDyn<'_, f32>, + blurred: PyReadonlyArrayDyn<'_, f32>, + color: PyReadonlyArrayDyn<'_, f32>, + weight: PyReadonlyArrayDyn<'_, f32>, + m: PyReadonlyArray2<'_, f32>, + c: PyReadonlyArray2<'_, f32>, + floor: f64, + log_defect: f64, + mut out: PyReadwriteArrayDyn<'_, f32>, +) -> PyResult<()> { + image(&x)?; + let shape = x.shape().to_vec(); + if blurred.shape() != shape || color.shape() != shape || weight.shape() != &shape[..2] { + return Err(PyValueError::new_err("the layers do not match the image")); + } + let (xs, b, col, w) = (data(&x)?, data(&blurred)?, data(&color)?, data(&weight)?); + let (m, c) = (matrix(&m)?, matrix(&c)?); + let dst = target(&mut out, &shape)?; + super::detach(py, dst.len(), || { + sensor::two_scale(xs, b, col, w, m, c, (floor, log_defect), dst) + }); + Ok(()) +} + +/// `film_base` of its HxWx3 analysis crop with (percentile, clip plateau, grid edge, log +/// floor, share): the base color, or None. +#[pyfunction] +fn film_base( + py: Python<'_>, + sample: PyReadonlyArrayDyn<'_, f32>, + constants: (f64, f64, f64, f32, f64), +) -> PyResult> { + let [h, w, 3] = sample.shape()[..] else { + return Err(PyValueError::new_err("needs an HxWx3 crop")); + }; + let s = data(&sample)?; + let (percentile, clip_plateau, grid_edge, log_floor, share) = constants; + let c = sensor::FilmBase { + percentile, + clip_plateau, + grid_edge, + log_floor, + share, + }; + Ok(super::detach(py, s.len(), || { + sensor::film_base(s, (h, w), &c) + })) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(film_base, m)?)?; + m.add_function(wrap_pyfunction!(sensor_unmix, m)?)?; + m.add_function(wrap_pyfunction!(sensor_log_unmix, m)?)?; + m.add_function(wrap_pyfunction!(sensor_two_scale_layer, m)?)?; + m.add_function(wrap_pyfunction!(sensor_two_scale, m)?) +} diff --git a/negpy_core/src/py/skew.rs b/negpy_core/src/py/skew.rs new file mode 100644 index 000000000..513a4888f --- /dev/null +++ b/negpy_core/src/py/skew.rs @@ -0,0 +1,142 @@ +use numpy::{ + IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::{lsd, skew}; + +type Samples<'py> = (Bound<'py, PyArray1>, Bound<'py, PyArray1>); + +/// `_fit_side`'s `sample(center, half, nearest)` on an HxW float32 luminance plane, read along +/// rows (`horizontal`) or columns: (stations, positions) of the profile peaks. +#[pyfunction] +fn skew_sample<'py>( + py: Python<'py>, + lum: PyReadonlyArray2<'py, f32>, + horizontal: bool, + stations: PyReadonlyArray1<'py, f64>, + centers: PyReadonlyArray1<'py, f64>, + half: f64, + nearest: bool, +) -> PyResult> { + let (h, w) = (lum.shape()[0], lum.shape()[1]); + let not_contiguous = |_| PyValueError::new_err("needs contiguous arrays"); + let img = lum.as_slice().map_err(not_contiguous)?; + let (st, ce) = ( + stations.as_slice().map_err(not_contiguous)?, + centers.as_slice().map_err(not_contiguous)?, + ); + if st.len() != ce.len() { + return Err(PyValueError::new_err("needs one center per station")); + } + let (xs, ys) = super::detach(py, img.len(), || { + skew::sample(img, (h, w), horizontal, (st, ce), half, nearest) + }); + Ok((xs.into_pyarray(py), ys.into_pyarray(py))) +} + +type PyLine = ((f64, f64), (f64, f64), bool, f64, f64); + +fn py_line(l: skew::Line) -> PyLine { + (l.p1, l.p2, l.horizontal, l.inliers, l.rms) +} + +fn plane<'a>(lum: &'a PyReadonlyArray2<'_, f32>) -> PyResult<(&'a [f32], (usize, usize))> { + let dims = (lum.shape()[0], lum.shape()[1]); + let img = lum + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous plane"))?; + Ok((img, dims)) +} + +/// `_fit_side(lum, horizontal, at, start, stop, outward)` on an HxW float32 luminance plane: +/// (p1, p2, horizontal, inlier fraction, rms) or None. +#[pyfunction] +fn skew_fit_side( + py: Python<'_>, + lum: PyReadonlyArray2<'_, f32>, + horizontal: bool, + at: f64, + start: f64, + stop: f64, + outward: i32, +) -> PyResult> { + let (img, dims) = plane(&lum)?; + Ok(super::detach(py, img.len(), || { + skew::fit_side(img, dims, horizontal, (at, start, stop), outward) + }) + .map(py_line)) +} + +/// `_profile_lines(lum)`: the outermost long edges, each (p1, p2, horizontal, inliers, rms). +#[pyfunction] +fn skew_profile_lines(py: Python<'_>, lum: PyReadonlyArray2<'_, f32>) -> PyResult> { + let (img, dims) = plane(&lum)?; + let lines = super::detach(py, img.len(), || skew::profile_lines(img, dims)); + Ok(lines.into_iter().map(py_line).collect()) +} + +/// `_at_canvas_edges` for lines given as (horizontal, position). +#[pyfunction] +fn skew_at_canvas_edges( + py: Python<'_>, + lum: PyReadonlyArray2<'_, f32>, + lines: Vec<(bool, f64)>, +) -> PyResult { + let (img, dims) = plane(&lum)?; + Ok(super::detach(py, img.len(), || { + skew::at_canvas_edges(img, dims, &lines) + })) +} + +/// `_coarse_angles` on an HxW float32 luminance plane: the dominant folded segment angles in +/// degrees, strongest first. +#[pyfunction] +fn coarse_angles( + py: Python<'_>, + lum: PyReadonlyArray2<'_, f32>, + min_segment: f64, + max_skew: f64, + angle_bin: f64, +) -> PyResult> { + let (h, w) = (lum.shape()[0], lum.shape()[1]); + let img = lum + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + if img.is_empty() || angle_bin <= 0.0 || max_skew <= 0.0 { + return Err(PyValueError::new_err( + "needs a non-empty plane and positive bins", + )); + } + Ok(super::detach(py, img.len(), || { + skew::coarse_angles(img, (h, w), min_segment, max_skew, angle_bin) + })) +} + +/// `cv2.createLineSegmentDetector().detect(img)[0]` for an HxW uint8 image, as an (N, 4) +/// float32 array of (x1, y1, x2, y2). +#[pyfunction] +fn line_segments<'py>( + py: Python<'py>, + img: PyReadonlyArray2<'py, u8>, +) -> PyResult>> { + let (h, w) = (img.shape()[0], img.shape()[1]); + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let lines = super::detach(py, src.len(), || lsd::line_segments(src, (h, w))); + let n = lines.len(); + let flat: Vec = lines.into_iter().flatten().collect(); + flat.into_pyarray(py).reshape([n, 4]) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(skew_sample, m)?)?; + m.add_function(wrap_pyfunction!(coarse_angles, m)?)?; + m.add_function(wrap_pyfunction!(line_segments, m)?)?; + m.add_function(wrap_pyfunction!(skew_fit_side, m)?)?; + m.add_function(wrap_pyfunction!(skew_profile_lines, m)?)?; + m.add_function(wrap_pyfunction!(skew_at_canvas_edges, m)?) +} diff --git a/negpy_core/src/py/stats.rs b/negpy_core/src/py/stats.rs new file mode 100644 index 000000000..81e75f09d --- /dev/null +++ b/negpy_core/src/py/stats.rs @@ -0,0 +1,28 @@ +use numpy::PyReadonlyArray1; +use pyo3::exceptions::{PyIndexError, PyValueError}; +use pyo3::prelude::*; + +use crate::stats; + +/// `np.percentile(values, q)` for each q, bit for bit, on a contiguous 1-D float32 array. +#[pyfunction] +fn percentiles( + py: Python<'_>, + values: PyReadonlyArray1<'_, f32>, + qs: Vec, +) -> PyResult> { + if qs.iter().any(|q| !(0.0..=100.0).contains(q)) { + return Err(PyValueError::new_err( + "Percentiles must be in the range [0, 100]", + )); + } + let values = values + .as_slice() + .map_err(|_| PyValueError::new_err("percentiles needs a contiguous array"))?; + super::detach(py, values.len(), || stats::percentiles(values, &qs)) + .ok_or_else(|| PyIndexError::new_err("percentiles of an empty array")) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(percentiles, m)?) +} diff --git a/negpy_core/src/py/stitch.rs b/negpy_core/src/py/stitch.rs new file mode 100644 index 000000000..30f3b9a8a --- /dev/null +++ b/negpy_core/src/py/stitch.rs @@ -0,0 +1,225 @@ +use numpy::{PyReadonlyArrayDyn, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::stitch; + +fn slices<'a, T: numpy::Element>( + arrays: &'a [PyReadonlyArrayDyn<'_, T>], + n: usize, +) -> PyResult> { + arrays + .iter() + .map(|a| { + let s = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))?; + if s.len() != n { + return Err(PyValueError::new_err("array sizes disagree")); + } + Ok(s) + }) + .collect() +} + +/// `gain_compensate`'s gains for `moving` against `reference`, or None to leave it. +#[pyfunction] +fn compensation_gains( + py: Python<'_>, + reference: PyReadonlyArrayDyn<'_, f32>, + ref_mask: PyReadonlyArrayDyn<'_, bool>, + moving: PyReadonlyArrayDyn<'_, f32>, + mov_mask: PyReadonlyArrayDyn<'_, bool>, + min_overlap: usize, +) -> PyResult> { + let [h, w, 3] = reference.shape()[..] else { + return Err(PyValueError::new_err("needs HxWx3 parts")); + }; + let n = h * w; + let images = [reference, moving]; + let coverage = [ref_mask, mov_mask]; + let imgs = slices(&images, 3 * n)?; + let masks = slices(&coverage, n)?; + Ok(super::detach(py, n, || { + stitch::compensation_gains(imgs[0], masks[0], imgs[1], masks[1], min_overlap) + })) +} + +/// `cv2.detail_DpSeamFinder("COLOR").find(images, [(0, 0)] * n, masks)` of HxWx3 float32 parts +/// on one canvas: the cut masks, as new uint8 arrays. +#[pyfunction] +fn dp_seams<'py>( + py: Python<'py>, + images: Vec>, + masks: Vec>, +) -> PyResult>>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let Some(&[h, w, 3]) = images.first().map(|a| a.shape()) else { + return Err(PyValueError::new_err("needs HxWx3 parts")); + }; + if images.len() != masks.len() { + return Err(PyValueError::new_err("needs one mask per part")); + } + let imgs = slices(&images, 3 * h * w)?; + let mut cut: Vec> = slices(&masks, h * w)? + .into_iter() + .map(<[u8]>::to_vec) + .collect(); + super::detach(py, h * w, || crate::seam::dp_seams(&imgs, &mut cut, (h, w))); + cut.into_iter() + .map(|m| m.into_pyarray(py).reshape(vec![h, w])) + .collect() +} + +type BlendInputs<'a> = (Vec<&'a [f32]>, Vec<&'a [bool]>, (usize, usize)); + +fn blend_inputs<'a>( + warped: &'a [PyReadonlyArrayDyn<'_, f32>], + masks: &'a [PyReadonlyArrayDyn<'_, bool>], +) -> PyResult> { + let Some(&[h, w, 3]) = warped.first().map(|a| a.shape()) else { + return Err(PyValueError::new_err("needs HxWx3 parts")); + }; + if warped.len() != masks.len() { + return Err(PyValueError::new_err("needs one mask per part")); + } + Ok((slices(warped, 3 * h * w)?, slices(masks, h * w)?, (h, w))) +} + +/// `feather_blend(warped, masks)` of HxWx3 float32 parts and HxW bool masks. +#[pyfunction] +fn feather_blend<'py>( + py: Python<'py>, + warped: Vec>, + masks: Vec>, +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let (imgs, m, (h, w)) = blend_inputs(&warped, &masks)?; + let out = super::detach(py, h * w, || stitch::feather_blend(&imgs, &m, (h, w))); + out.into_pyarray(py).reshape(vec![h, w, 3]) +} + +/// `seam_blend(warped, masks, seams, feather_px)` with the band already floored at 1 px. +#[pyfunction] +fn seam_blend<'py>( + py: Python<'py>, + warped: Vec>, + masks: Vec>, + seams: Vec>, + feather: f64, +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let (imgs, m, (h, w)) = blend_inputs(&warped, &masks)?; + if seams.len() != masks.len() { + return Err(PyValueError::new_err("needs one seam per part")); + } + let s = slices(&seams, h * w)?; + let out = super::detach(py, h * w, || { + stitch::seam_blend(&imgs, &m, &s, (h, w), feather) + }); + out.into_pyarray(py).reshape(vec![h, w, 3]) +} + +fn rw<'a, T: numpy::Element>( + a: &'a mut numpy::PyReadwriteArrayDyn<'_, T>, +) -> PyResult<&'a mut [T]> { + a.as_slice_mut() + .map_err(|_| PyValueError::new_err("needs a contiguous array")) +} + +/// `build_proxy`'s uint8 stretch of a float32 plane. +#[pyfunction] +fn proxy_stretch<'py>( + py: Python<'py>, + gray: PyReadonlyArrayDyn<'py, f32>, +) -> PyResult>> { + use numpy::{IntoPyArray, PyArrayMethods}; + let shape = gray.shape().to_vec(); + let src = gray + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let out = super::detach(py, src.len(), || { + let mut d = vec![0u8; src.len()]; + stitch::proxy_stretch(src, &mut d); + d + }); + out.into_pyarray(py).reshape(shape) +} + +/// One part of `blend_ir` into `acc` in place: where `sel`, the part's value, or the larger +/// value with `take_max`. +#[pyfunction] +fn ir_blend_into( + py: Python<'_>, + mut acc: numpy::PyReadwriteArrayDyn<'_, f32>, + warped: PyReadonlyArrayDyn<'_, f32>, + sel: PyReadonlyArrayDyn<'_, bool>, + take_max: bool, +) -> PyResult<()> { + let n = acc.len(); + let w = slices(std::slice::from_ref(&warped), n)?[0]; + let s = slices(std::slice::from_ref(&sel), n)?[0]; + let a = rw(&mut acc)?; + super::detach(py, n, || stitch::ir_blend_into(a, w, s, take_max)); + Ok(()) +} + +/// `blend_ir`'s fill of uncovered canvas pixels (below 0) with 1.0, in place. +#[pyfunction] +fn ir_finish(py: Python<'_>, mut acc: numpy::PyReadwriteArrayDyn<'_, f32>) -> PyResult<()> { + let a = rw(&mut acc)?; + super::detach(py, a.len(), || stitch::ir_finish(a)); + Ok(()) +} + +/// `(a & b).sum()` of two same-shape bool masks. +#[pyfunction] +fn mask_overlap( + py: Python<'_>, + a: PyReadonlyArrayDyn<'_, bool>, + b: PyReadonlyArrayDyn<'_, bool>, +) -> PyResult { + let n = a.len(); + if b.shape() != a.shape() { + return Err(PyValueError::new_err("masks differ in shape")); + } + let masks = [a, b]; + let m = slices(&masks, n)?; + Ok(super::detach(py, n, || stitch::mask_overlap(m[0], m[1]))) +} + +/// `img *= gains` per channel of an HxWx3 float32 image, in place. +#[pyfunction] +fn scale_channels( + py: Python<'_>, + mut img: numpy::PyReadwriteArrayDyn<'_, f32>, + gains: [f32; 3], +) -> PyResult<()> { + let [_, _, 3] = img.shape()[..] else { + return Err(PyValueError::new_err("scale_channels needs an HxWx3 array")); + }; + let d = rw(&mut img)?; + super::detach(py, d.len(), || stitch::scale_channels(d, gains)); + Ok(()) +} + +/// `np.clip(img, 0, 1, out=img)` of a float32 array. +#[pyfunction] +fn clip_unit(py: Python<'_>, mut img: numpy::PyReadwriteArrayDyn<'_, f32>) -> PyResult<()> { + let d = rw(&mut img)?; + super::detach(py, d.len(), || stitch::clip_unit(d)); + Ok(()) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(proxy_stretch, m)?)?; + m.add_function(wrap_pyfunction!(ir_blend_into, m)?)?; + m.add_function(wrap_pyfunction!(ir_finish, m)?)?; + m.add_function(wrap_pyfunction!(mask_overlap, m)?)?; + m.add_function(wrap_pyfunction!(scale_channels, m)?)?; + m.add_function(wrap_pyfunction!(clip_unit, m)?)?; + m.add_function(wrap_pyfunction!(feather_blend, m)?)?; + m.add_function(wrap_pyfunction!(seam_blend, m)?)?; + m.add_function(wrap_pyfunction!(dp_seams, m)?)?; + m.add_function(wrap_pyfunction!(compensation_gains, m)?) +} diff --git a/negpy_core/src/py/toning.rs b/negpy_core/src/py/toning.rs new file mode 100644 index 000000000..723d3c5e9 --- /dev/null +++ b/negpy_core/src/py/toning.rs @@ -0,0 +1,91 @@ +use numpy::{ + PyArrayDyn, PyArrayMethods, PyReadonlyArray1, PyReadonlyArrayDyn, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::toning::{self, Bath, Toning}; + +/// A float32 gain triplet, widened to float64 as the kernel's arithmetic reads it. +fn gain(a: &PyReadonlyArray1<'_, f32>) -> PyResult<[f64; 3]> { + let g: [f32; 3] = a + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous arrays"))? + .try_into() + .map_err(|_| PyValueError::new_err("a gain needs 3 values"))?; + Ok(g.map(f64::from)) +} + +/// `_apply_chemical_toning_jit` of an HxWx3 float32 image; the arguments are the kernel's. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn apply_chemical_toning<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + sel_strength: f64, + sep_strength: f64, + gold_strength: f64, + sel_d_ref: f64, + sel_power: f64, + sel_gain: PyReadonlyArray1<'py, f32>, + sep_d_bleach: f64, + sep_power: f64, + sep_gain: PyReadonlyArray1<'py, f32>, + gold_d_ref: f64, + gold_power: f64, + gold_gain: PyReadonlyArray1<'py, f32>, + gold_sepia_gain: PyReadonlyArray1<'py, f32>, + blue_strength: f64, + blue_d_ref: f64, + blue_power: f64, + blue_gain: PyReadonlyArray1<'py, f32>, + copper_strength: f64, + copper_d_ref: f64, + copper_power: f64, + copper_gain: PyReadonlyArray1<'py, f32>, + van_strength: f64, + van_d_ref: f64, + van_power: f64, + van_gain: PyReadonlyArray1<'py, f32>, + gold_flat: f64, +) -> PyResult>> { + if img.shape().last() != Some(&3) { + return Err(PyValueError::new_err("needs an HxWx3 image")); + } + let src = img + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous array"))?; + let bath = |strength, d_ref, power, g: &PyReadonlyArray1<'py, f32>| -> PyResult { + Ok(Bath { + strength, + d_ref, + power, + gain: gain(g)?, + }) + }; + let t = Toning { + selenium: bath(sel_strength, sel_d_ref, sel_power, &sel_gain)?, + sepia: bath(sep_strength, sep_d_bleach, sep_power, &sep_gain)?, + gold: bath(gold_strength, gold_d_ref, gold_power, &gold_gain)?, + gold_sepia_gain: gain(&gold_sepia_gain)?, + blue: bath(blue_strength, blue_d_ref, blue_power, &blue_gain)?, + copper: bath(copper_strength, copper_d_ref, copper_power, &copper_gain)?, + vanadium: bath(van_strength, van_d_ref, van_power, &van_gain)?, + gold_flat: gold_flat > 0.5, + }; + let out = PyArrayDyn::::zeros(py, img.shape(), false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + toning::apply_chemical_toning(src, dst, &t) + }); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(apply_chemical_toning, m)?) +} diff --git a/negpy_core/src/py/warp.rs b/negpy_core/src/py/warp.rs new file mode 100644 index 000000000..5bb5e6dea --- /dev/null +++ b/negpy_core/src/py/warp.rs @@ -0,0 +1,199 @@ +use numpy::{ + PyArray2, PyArrayDyn, PyArrayMethods, PyReadonlyArray2, PyReadonlyArrayDyn, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::warp::{self, Border, Image, Interp}; + +fn image<'a>(src: &'a PyReadonlyArrayDyn<'_, f32>) -> PyResult> { + let (h, w, cn) = match src.shape() { + [h, w] => (*h, *w, 1), + [h, w, c] => (*h, *w, *c), + _ => return Err(PyValueError::new_err("needs an HxW or HxWxC image")), + }; + if h == 0 || w == 0 || cn == 0 { + return Err(PyValueError::new_err("needs a non-empty image")); + } + let data = src + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous image"))?; + Ok(Image { data, h, w, cn }) +} + +/// A new float32 output of `dh` x `dw` with the source's channel layout, filled by `f`. +fn output<'py>( + py: Python<'py>, + src: &PyReadonlyArrayDyn<'py, f32>, + (dw, dh): (usize, usize), + f: impl FnOnce(&mut [f32]) + Send, +) -> PyResult>> { + let mut dims = vec![dh, dw]; + dims.extend_from_slice(&src.shape()[2..]); + let out = PyArrayDyn::::zeros(py, dims, false); + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || f(dst)); + } + Ok(out) +} + +fn matrix(m: &PyReadonlyArray2<'_, f64>, rows: usize) -> PyResult<[f64; N]> { + let a = m.as_array(); + if a.shape() != [rows, 3] { + return Err(PyValueError::new_err(format!("needs a {rows}x3 matrix"))); + } + Ok(std::array::from_fn(|i| a[[i / 3, i % 3]])) +} + +/// The interpolation ("nearest", "linear", "cubic") and border ("replicate", "constant"). +fn mode(interpolation: &str, border: &str, nearest: bool) -> PyResult<(Interp, Border)> { + let interp = match interpolation { + "nearest" if nearest => Interp::Nearest, + "linear" => Interp::Linear, + "cubic" => Interp::Cubic, + _ => { + return Err(PyValueError::new_err(format!( + "unknown interpolation {interpolation:?}" + ))); + } + }; + let border = match border { + "replicate" => Border::Replicate, + "constant" => Border::Constant, + _ => return Err(PyValueError::new_err(format!("unknown border {border:?}"))), + }; + Ok((interp, border)) +} + +/// `cv2.remap(src, map_x, map_y, interpolation, borderMode)` of a float32 image; a constant +/// border reads zero. +#[pyfunction] +#[pyo3(signature = (src, map_x, map_y, interpolation="linear", border="replicate"))] +fn remap<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + map_x: PyReadonlyArray2<'py, f32>, + map_y: PyReadonlyArray2<'py, f32>, + interpolation: &str, + border: &str, +) -> PyResult>> { + let img = image(&src)?; + let mode = mode(interpolation, border, true)?; + if map_x.shape() != map_y.shape() { + return Err(PyValueError::new_err("the maps differ in size")); + } + let (dh, dw) = (map_x.shape()[0], map_x.shape()[1]); + let not_contiguous = |_| PyValueError::new_err("needs contiguous maps"); + let mx = map_x.as_slice().map_err(not_contiguous)?; + let my = map_y.as_slice().map_err(not_contiguous)?; + output(py, &src, (dw, dh), |dst| { + warp::remap(&img, mode, (mx, my), dw, dst) + }) +} + +/// `apply_radial_distortion` of a float32 image: the remap with the scale-to-fill `s`, the +/// center and 1 / halfdiag^2, each rounded to float32. +#[pyfunction] +fn remap_radial<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + center: (f32, f32), + s: f32, + k1: f32, + inv_hd2: f32, +) -> PyResult>> { + let img = image(&src)?; + output(py, &src, (img.w, img.h), |dst| { + warp::remap_radial(&img, center, s, k1, inv_hd2, dst) + }) +} + +/// `cv2.warpAffine(src, m, dsize, interpolation, borderMode)` of a float32 image, bilinear or +/// bicubic; a constant border reads zero. +#[pyfunction] +#[pyo3(signature = (src, m, dsize, inverse_map=false, interpolation="linear", border="replicate"))] +fn warp_affine<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + m: PyReadonlyArray2<'py, f64>, + dsize: (usize, usize), + inverse_map: bool, + interpolation: &str, + border: &str, +) -> PyResult>> { + let img = image(&src)?; + let mode = mode(interpolation, border, false)?; + let m = matrix::<6>(&m, 2)?; + output(py, &src, dsize, |dst| { + warp::warp_affine(&img, mode, m, inverse_map, dsize.0, dst) + }) +} + +/// `cv2.warpPerspective(src, m, dsize, interpolation, borderMode)` of a float32 image, bilinear +/// or bicubic; a constant border reads zero. +#[pyfunction] +#[pyo3(signature = (src, m, dsize, inverse_map=false, interpolation="linear", border="replicate"))] +fn warp_perspective<'py>( + py: Python<'py>, + src: PyReadonlyArrayDyn<'py, f32>, + m: PyReadonlyArray2<'py, f64>, + dsize: (usize, usize), + inverse_map: bool, + interpolation: &str, + border: &str, +) -> PyResult>> { + let img = image(&src)?; + let mode = mode(interpolation, border, false)?; + let m = matrix::<9>(&m, 3)?; + output(py, &src, dsize, |dst| { + warp::warp_perspective(&img, mode, m, inverse_map, dsize, dst) + }) +} + +/// `cv2.getRotationMatrix2D(center, angle, scale)` as a 2x3 float64 array. +#[pyfunction] +fn rotation_matrix<'py>( + py: Python<'py>, + center: (f32, f32), + angle: f64, + scale: f64, +) -> Bound<'py, PyArray2> { + let m = warp::rotation_matrix(center, angle, scale); + PyArray2::from_vec2(py, &[m[..3].to_vec(), m[3..].to_vec()]).expect("two rows of three") +} + +/// `cv2.getPerspectiveTransform(src, dst)` for two 4x2 float32 point arrays, as 3x3 float64. +#[pyfunction] +fn perspective_transform<'py>( + py: Python<'py>, + src: PyReadonlyArray2<'py, f32>, + dst: PyReadonlyArray2<'py, f32>, +) -> PyResult>> { + let quad = |p: &PyReadonlyArray2<'_, f32>| -> PyResult<[[f32; 2]; 4]> { + let a = p.as_array(); + if a.shape() != [4, 2] { + return Err(PyValueError::new_err("needs 4x2 points")); + } + Ok(std::array::from_fn(|i| [a[[i, 0]], a[[i, 1]]])) + }; + let m = warp::perspective_transform(&quad(&src)?, &quad(&dst)?) + .ok_or_else(|| PyValueError::new_err("the points do not define a perspective transform"))?; + Ok( + PyArray2::from_vec2(py, &[m[..3].to_vec(), m[3..6].to_vec(), m[6..].to_vec()]) + .expect("3x3"), + ) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(rotation_matrix, m)?)?; + m.add_function(wrap_pyfunction!(perspective_transform, m)?)?; + m.add_function(wrap_pyfunction!(remap, m)?)?; + m.add_function(wrap_pyfunction!(remap_radial, m)?)?; + m.add_function(wrap_pyfunction!(warp_affine, m)?)?; + m.add_function(wrap_pyfunction!(warp_perspective, m)?) +} diff --git a/negpy_core/src/py/zones.rs b/negpy_core/src/py/zones.rs new file mode 100644 index 000000000..5ec3f77e2 --- /dev/null +++ b/negpy_core/src/py/zones.rs @@ -0,0 +1,192 @@ +use numpy::{ + IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, + PyReadonlyArray3, PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::zones; + +type Frame<'a> = (&'a [f32], (usize, usize, usize)); + +fn frame<'a>(rgb: &'a PyReadonlyArray3<'_, f32>) -> PyResult> { + let s = rgb.shape(); + let dims = (s[0], s[1], s[2]); + if dims.0 < 1 || dims.1 < 1 || dims.2 < 3 { + return Err(PyValueError::new_err("needs an HxWx3 frame")); + } + let data = rgb + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous frame"))?; + Ok((data, dims)) +} + +fn grid<'py, T: numpy::Element>( + py: Python<'py>, + v: Vec, + h: usize, + w: usize, +) -> PyResult>> { + v.into_pyarray(py).reshape([h, w]) +} + +/// `zone_field(rgb, max_edge)` for an HxWxC float32 display frame and the encoded mid gray. +#[pyfunction] +fn zone_field<'py>( + py: Python<'py>, + rgb: PyReadonlyArray3<'py, f32>, + max_edge: usize, + mid: f64, +) -> PyResult>> { + let (data, dims) = frame(&rgb)?; + let (z, h, w) = super::detach(py, data.len(), || { + zones::zone_field(data, dims, max_edge, mid) + }) + .ok_or_else(|| PyValueError::new_err("invalid frame"))?; + grid(py, z, h, w) +} + +/// `zone_grid(rgb, cells)` for an HxWxC float32 display frame and the encoded mid gray. +#[pyfunction] +fn zone_grid<'py>( + py: Python<'py>, + rgb: PyReadonlyArray3<'py, f32>, + cells: usize, + mid: f64, +) -> PyResult>> { + let (data, dims) = frame(&rgb)?; + let step = (dims.0.min(dims.1) / (4 * cells.max(1))).max(1); + let sampled = dims.0.div_ceil(step) * dims.1.div_ceil(step) * 3; + let (z, h, w) = super::detach(py, sampled, || zones::zone_grid(data, dims, cells, mid)) + .ok_or_else(|| PyValueError::new_err("invalid frame"))?; + grid(py, z, h, w) +} + +/// `zone_anchors(zones, min_share)` for a uint8 zone map: [(x, y, zone, inset)]. +#[pyfunction] +fn zone_anchors( + py: Python<'_>, + zones: PyReadonlyArray2<'_, u8>, + min_share: f64, +) -> PyResult> { + let dims = (zones.shape()[0], zones.shape()[1]); + let z = zones + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous map"))?; + Ok(super::detach(py, z.len(), || { + zones::zone_anchors(z, dims, min_share) + })) +} + +/// `zone_region_labels(zones)` for an int32 zone grid: [(col, row, zone)]. +#[pyfunction] +fn zone_region_labels( + py: Python<'_>, + zones: PyReadonlyArray2<'_, i32>, +) -> PyResult> { + let dims = (zones.shape()[0], zones.shape()[1]); + let z = zones + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous grid"))?; + Ok(super::detach(py, z.len(), || { + zones::zone_region_labels(z, dims) + })) +} + +/// `zone_shares(zones)` for a uint8 zone map. +#[pyfunction] +fn zone_shares<'py>( + py: Python<'py>, + zones: PyReadonlyArray2<'py, u8>, +) -> PyResult>> { + let z = zones + .as_slice() + .map_err(|_| PyValueError::new_err("needs a contiguous map"))?; + Ok(super::detach(py, z.len(), || zones::zone_shares(z)).into_pyarray(py)) +} + +/// `zone_occupancy(l_bins)` for float64 bins and the encoded mid gray. +#[pyfunction] +fn zone_occupancy<'py>( + py: Python<'py>, + bins: PyReadonlyArray1<'py, f64>, + mid: f64, +) -> PyResult>> { + let b = bins + .as_slice() + .map_err(|_| PyValueError::new_err("needs contiguous bins"))?; + Ok(zones::zone_occupancy(b, mid).into_pyarray(py)) +} + +/// `loupe_acutance(patch)` for an HxWxC float32 display patch, at least 3x3. +#[pyfunction] +fn loupe_acutance(py: Python<'_>, patch: PyReadonlyArray3<'_, f32>) -> PyResult { + let (data, dims) = frame(&patch)?; + Ok(super::detach(py, data.len(), || { + zones::loupe_acutance(data, dims) + })) +} + +type Lookup<'py> = ( + Bound<'py, PyArray2>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +/// `_scope_lookup(size, log)`: (bin per pixel, pixels per bin, unsampled bins, their pixels). +#[pyfunction] +fn scope_lookup(py: Python<'_>, size: usize, log: bool) -> PyResult> { + let (idx, area, orphans, px) = + super::detach(py, size * size, || zones::scope_lookup(size, log)); + Ok(( + grid(py, idx, size, size)?, + area.into_pyarray(py), + orphans.into_pyarray(py), + px.into_pyarray(py), + )) +} + +/// `vectorscope_density(counts, size, log)` from float64 counts and a `scope_lookup`. +#[pyfunction] +fn vectorscope_density<'py>( + py: Python<'py>, + counts: PyReadonlyArray1<'py, f64>, + idx: PyReadonlyArray2<'py, i64>, + area: PyReadonlyArray1<'py, f64>, + orphans: PyReadonlyArray1<'py, i64>, + px: PyReadonlyArray1<'py, i64>, +) -> PyResult>> { + let bad = |_| PyValueError::new_err("needs contiguous arrays"); + let (c, i, a, o, p) = ( + counts.as_slice().map_err(bad)?, + idx.as_slice().map_err(bad)?, + area.as_slice().map_err(bad)?, + orphans.as_slice().map_err(bad)?, + px.as_slice().map_err(bad)?, + ); + let n = i.len(); + if a.len() > c.len() + || i.iter().any(|&v| v >= a.len() as i64) + || o.len() != p.len() + || o.iter().any(|&v| v < 0 || v >= c.len() as i64) + || p.iter().any(|&v| v < 0 || v >= n as i64) + { + return Err(PyValueError::new_err("counts and lookup do not match")); + } + let out = super::detach(py, n, || zones::vectorscope_density(c, (i, a, o, p))); + let size = idx.shape()[0]; + grid(py, out, size, idx.shape()[1]) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(zone_field, m)?)?; + m.add_function(wrap_pyfunction!(zone_grid, m)?)?; + m.add_function(wrap_pyfunction!(zone_anchors, m)?)?; + m.add_function(wrap_pyfunction!(zone_region_labels, m)?)?; + m.add_function(wrap_pyfunction!(zone_shares, m)?)?; + m.add_function(wrap_pyfunction!(zone_occupancy, m)?)?; + m.add_function(wrap_pyfunction!(loupe_acutance, m)?)?; + m.add_function(wrap_pyfunction!(scope_lookup, m)?)?; + m.add_function(wrap_pyfunction!(vectorscope_density, m)?) +} diff --git a/negpy_core/src/register.rs b/negpy_core/src/register.rs new file mode 100644 index 000000000..206d58b5e --- /dev/null +++ b/negpy_core/src/register.rs @@ -0,0 +1,374 @@ +//! `register_pair` of negpy/features/stitch/logic.py: SIFT features, a brute-force L2 ratio +//! test and OpenCV 4.13's `estimateAffinePartial2D` (`ptsetreg.cpp`, `levmarq.cpp`, +//! Apache-2.0): RANSAC over 2-point similarity models, then Levenberg-Marquardt on the inliers. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::sift::{self, DESCR_LEN}; + +const NFEATURES: usize = 6000; +const RATIO: f64 = 0.75; +const REPROJ_THRESHOLD: f64 = 3.0; +const CONFIDENCE: f64 = 0.99; +const MAX_ITERS: usize = 2000; +const REFINE_ITERS: usize = 10; + +/// The squared L2 distance of two descriptors; exact, as OpenCV's float32 sum of integers under +/// 2^24 is. +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn dist2(a: &[u8; DESCR_LEN], b: &[u8; DESCR_LEN]) -> i32 { + a.iter() + .zip(b) + .map(|(&x, &y)| { + let d = x as i16 - y as i16; + d as i32 * d as i32 + }) + .sum() +} + +/// `BFMatcher().knnMatch(query, train, k=2)` and Lowe's ratio test: (query, train) index pairs +/// whose nearest train descriptor is nearer than RATIO times the second nearest. A tie keeps the +/// lower train index first, as OpenCV does. +pub fn ratio_matches(query: &[[u8; DESCR_LEN]], train: &[[u8; DESCR_LEN]]) -> Vec<(usize, usize)> { + if train.len() < 2 { + return Vec::new(); + } + query + .par_iter() + .enumerate() + .filter_map(|(qi, q)| { + let (mut best, mut second) = ((i32::MAX, 0usize), i32::MAX); + for (ti, t) in train.iter().enumerate() { + let d = dist2(q, t); + if d < best.0 { + second = best.0; + best = (d, ti); + } else if d < second { + second = d; + } + } + let (d1, d2) = ((best.0 as f32).sqrt() as f64, (second as f32).sqrt() as f64); + (d1 < RATIO * d2).then_some((qi, best.1)) + }) + .collect() +} + +/// OpenCV's `cv::RNG`, a multiply-with-carry generator. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u32 { + self.0 = (self.0 as u32 as u64) + .wrapping_mul(4_164_903_690) + .wrapping_add(self.0 >> 32); + self.0 as u32 + } + + fn uniform(&mut self, n: usize) -> usize { + (self.next() % n as u32) as usize + } +} + +/// `RANSACUpdateNumIters`. +fn update_num_iters(p: f64, ep: f64, model_points: i32, max_iters: usize) -> usize { + let (p, ep) = (p.clamp(0.0, 1.0), ep.clamp(0.0, 1.0)); + let num = (1.0 - p).max(f64::MIN_POSITIVE); + let denom = 1.0 - libm::pow(1.0 - ep, model_points as f64); + if denom < f64::MIN_POSITIVE { + return 0; + } + let (num, denom) = (libm::log(num), libm::log(denom)); + if denom >= 0.0 || -num >= max_iters as f64 * -denom { + max_iters + } else { + (num / denom).round_ties_even() as usize + } +} + +/// The 2-point similarity of `AffinePartial2DEstimatorCallback::runKernel`, as (a, b, tx, ty) +/// for the matrix [a -b tx; b a ty]. +fn similarity(from: [[f32; 2]; 2], to: [[f32; 2]; 2]) -> [f64; 4] { + let [[x1, y1], [x2, y2]] = from.map(|p| p.map(f64::from)); + let [[bx1, by1], [bx2, by2]] = to.map(|p| p.map(f64::from)); + let d = 1.0 / ((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)); + let s0 = d * ((bx1 - bx2) * (x1 - x2) + (by1 - by2) * (y1 - y2)); + let s1 = d * ((by1 - by2) * (x1 - x2) - (bx1 - bx2) * (y1 - y2)); + let s2 = d + * ((by1 - by2) * (x1 * y2 - x2 * y1) + - (bx1 * y2 - bx2 * y1) * (y1 - y2) + - (bx1 * x2 - bx2 * x1) * (x1 - x2)); + let s3 = d + * (-(bx1 - bx2) * (x1 * y2 - x2 * y1) + - (by1 * x2 - by2 * x1) * (x1 - x2) + - (by1 * y2 - by2 * y1) * (y1 - y2)); + [s0, s1, s2, s3] +} + +/// `findInliers`: the float32 squared reprojection error against the threshold. +fn inliers(from: &[[f32; 2]], to: &[[f32; 2]], h: [f64; 4], mask: &mut [bool]) -> usize { + let (a, b, tx, ty) = (h[0] as f32, h[1] as f32, h[2] as f32, h[3] as f32); + let t = (REPROJ_THRESHOLD * REPROJ_THRESHOLD) as f32; + let mut n = 0; + for ((f, p), m) in from.iter().zip(to).zip(mask.iter_mut()) { + let ex = a * f[0] - b * f[1] + tx - p[0]; + let ey = b * f[0] + a * f[1] + ty - p[1]; + *m = ex * ex + ey * ey <= t; + n += *m as usize; + } + n +} + +/// Gauss-Jordan solve of a 4x4 system with partial pivoting; None when singular. +fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { + for i in 0..4 { + let p = (i..4).max_by(|&x, &y| a[x][i].abs().total_cmp(&a[y][i].abs()))?; + if a[p][i].abs() < f64::EPSILON * 1e-6 { + return None; + } + a.swap(i, p); + b.swap(i, p); + let (pivot, pb) = (a[i], b[i]); + for (j, (row, bj)) in a.iter_mut().zip(b.iter_mut()).enumerate() { + if j != i { + let f = row[i] / pivot[i]; + for (x, &p) in row.iter_mut().zip(&pivot).skip(i) { + *x -= f * p; + } + *bj -= f * pb; + } + } + } + Some(std::array::from_fn(|i| b[i] / a[i][i])) +} + +/// `LMSolver` (`levmarq.cpp`) over (a, b, tx, ty) with `AffinePartial2DRefineCallback`. +fn refine(from: &[[f32; 2]], to: &[[f32; 2]], x0: [f64; 4]) -> [f64; 4] { + let residuals = |h: &[f64; 4]| -> Vec { + from.iter() + .zip(to) + .flat_map(|(f, p)| { + let (mx, my) = (f[0] as f64, f[1] as f64); + [ + h[0] * mx - h[1] * my + h[2] - p[0] as f64, + h[1] * mx + h[0] * my + h[3] - p[1] as f64, + ] + }) + .collect() + }; + let jac_rows = |i: usize| -> [f64; 4] { + let (mx, my) = (from[i / 2][0] as f64, from[i / 2][1] as f64); + if i.is_multiple_of(2) { + [mx, -my, 1.0, 0.0] + } else { + [my, mx, 0.0, 1.0] + } + }; + let n = 2 * from.len(); + let mut a = [[0f64; 4]; 4]; + for i in 0..n { + let j = jac_rows(i); + for r in 0..4 { + for c in 0..4 { + a[r][c] += j[r] * j[c]; + } + } + } + let jt_r = |r: &[f64]| -> [f64; 4] { + let mut v = [0f64; 4]; + for (i, &e) in r.iter().enumerate() { + let j = jac_rows(i); + for k in 0..4 { + v[k] += j[k] * e; + } + } + v + }; + let norm2 = |r: &[f64]| r.iter().map(|e| e * e).sum::(); + let inf = |r: &[f64]| r.iter().fold(0f64, |m, e| m.max(e.abs())); + let eps = f32::EPSILON as f64; + + let mut x = x0; + let mut r = residuals(&x); + let mut s = norm2(&r); + let mut v = jt_r(&r); + let diag: [f64; 4] = std::array::from_fn(|i| a[i][i]); + let (rlo, rhi) = (0.25, 0.75); + let (mut lambda, mut lc) = (1.0f64, 0.75f64); + let mut iter = 0; + loop { + let mut ap = a; + for i in 0..4 { + ap[i][i] += lambda * diag[i]; + } + let Some(d) = solve4(ap, v) else { break }; + let xd: [f64; 4] = std::array::from_fn(|i| x[i] - d[i]); + let rd = residuals(&xd); + let sd = norm2(&rd); + let ad: [f64; 4] = std::array::from_fn(|i| (0..4).map(|k| a[i][k] * d[k]).sum::()); + let ds: f64 = (0..4).map(|i| d[i] * (2.0 * v[i] - ad[i])).sum(); + let ratio = (s - sd) / if ds.abs() > f64::EPSILON { ds } else { 1.0 }; + if ratio > rhi { + lambda *= 0.5; + if lambda < lc { + lambda = 0.0; + } + } else if ratio < rlo { + let t: f64 = (0..4).map(|i| d[i] * v[i]).sum(); + let mut nu = (sd - s) / if t.abs() > f64::EPSILON { t } else { 1.0 } + 2.0; + nu = nu.clamp(2.0, 10.0); + if lambda == 0.0 { + let maxval = (0..4) + .filter_map(|i| { + let mut e = [0f64; 4]; + e[i] = 1.0; + solve4(a, e).map(|col| col[i].abs()) + }) + .fold(f64::EPSILON, f64::max); + lambda = 1.0 / maxval; + lc = lambda; + nu *= 0.5; + } + lambda *= nu; + } + if sd < s { + s = sd; + x = xd; + r = rd; + v = jt_r(&r); + } + iter += 1; + if !(iter < REFINE_ITERS && inf(&d) >= eps && inf(&r) >= eps) { + break; + } + } + x +} + +/// `cv2.estimateAffinePartial2D(from, to, method=RANSAC, ransacReprojThreshold=3)`: the matrix +/// as (a, b, tx, ty) and the inlier mask, or None when no model is found. +pub fn estimate_affine_partial( + from: &[[f32; 2]], + to: &[[f32; 2]], +) -> Option<([f64; 4], Vec)> { + let count = from.len(); + if count < 2 || to.len() != count { + return None; + } + if count == 2 { + return Some(( + similarity([from[0], from[1]], [to[0], to[1]]), + vec![true; 2], + )); + } + let mut rng = Rng(u64::MAX); + let (mut best, mut best_mask) = (None, vec![false; count]); + let mut mask = vec![false; count]; + let (mut niters, mut max_good) = (MAX_ITERS, 0usize); + let mut iter = 0; + while iter < niters { + let i0 = rng.uniform(count); + let mut i1 = rng.uniform(count); + while i1 == i0 { + i1 = rng.uniform(count); + } + let h = similarity([from[i0], from[i1]], [to[i0], to[i1]]); + let good = inliers(from, to, h, &mut mask); + if good > max_good.max(1) { + std::mem::swap(&mut mask, &mut best_mask); + best = Some(h); + max_good = good; + niters = update_num_iters(CONFIDENCE, (count - good) as f64 / count as f64, 2, niters); + } + iter += 1; + } + let h = best?; + let (src, dst): (Vec<[f32; 2]>, Vec<[f32; 2]>) = from + .iter() + .zip(to) + .zip(&best_mask) + .filter(|(_, m)| **m) + .map(|((f, t), _)| (*f, *t)) + .unzip(); + Some((refine(&src, &dst, h), best_mask)) +} + +/// `register_pair`: the 2x3 similarity mapping `mov` coordinates to `ref_` coordinates and its +/// RANSAC inlier count, or None when the pair has fewer than `min_inliers` good matches or +/// inliers. +pub fn register_pair( + ref_: (&[u8], usize, usize), + mov: (&[u8], usize, usize), + min_inliers: usize, +) -> Option<([f64; 6], usize)> { + let ((ref_kp, ref_desc), (mov_kp, mov_desc)) = rayon::join( + || sift::detect_and_compute(ref_.0, ref_.1, ref_.2, NFEATURES), + || sift::detect_and_compute(mov.0, mov.1, mov.2, NFEATURES), + ); + if ref_kp.len() < 2 || mov_kp.len() < 2 { + return None; + } + let good = ratio_matches(&mov_desc, &ref_desc); + if good.len() < min_inliers { + return None; + } + let mov_pts: Vec<[f32; 2]> = good + .iter() + .map(|&(q, _)| [mov_kp[q].x, mov_kp[q].y]) + .collect(); + let ref_pts: Vec<[f32; 2]> = good + .iter() + .map(|&(_, t)| [ref_kp[t].x, ref_kp[t].y]) + .collect(); + let (h, mask) = estimate_affine_partial(&mov_pts, &ref_pts)?; + let n = mask.iter().filter(|&&m| m).count(); + if n < min_inliers { + return None; + } + Some(([h[0], -h[1], h[2], h[1], h[0], h[3]], n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rng_matches_opencv() { + // cv::RNG((uint64)-1): the first draws of rng.next(). + let mut rng = Rng(u64::MAX); + let first = rng.next(); + assert_eq!( + first, + (u32::MAX as u64 * 4_164_903_690 + u32::MAX as u64) as u32 + ); + } + + #[test] + fn ransac_recovers_a_similarity_among_outliers() { + let (a, b, tx, ty) = (0.99f64, 0.03f64, 12.5f64, -7.25f64); + let mut from = Vec::new(); + let mut to = Vec::new(); + for i in 0..200 { + let (x, y) = ((i * 37 % 400) as f64, (i * 91 % 300) as f64); + from.push([x as f32, y as f32]); + let (u, v) = if i % 4 == 0 { + ((i * 53 % 400) as f64, (i * 17 % 300) as f64) + } else { + (a * x - b * y + tx, b * x + a * y + ty) + }; + to.push([u as f32, v as f32]); + } + let (h, mask) = estimate_affine_partial(&from, &to).unwrap(); + assert_eq!(mask.iter().filter(|&&m| m).count(), 150); + for (got, want) in h.iter().zip([a, b, tx, ty]) { + assert!((got - want).abs() < 1e-3, "{h:?}"); + } + } + + #[test] + fn iteration_count_follows_opencv() { + assert_eq!(update_num_iters(0.99, 0.0, 2, 2000), 0); + assert_eq!(update_num_iters(0.99, 0.5, 2, 2000), 16); + assert_eq!(update_num_iters(0.99, 1.0, 2, 2000), 2000); + } +} diff --git a/negpy_core/src/resize.rs b/negpy_core/src/resize.rs new file mode 100644 index 000000000..5ddceed43 --- /dev/null +++ b/negpy_core/src/resize.rs @@ -0,0 +1,1051 @@ +use multiversion::multiversion; +use rayon::prelude::*; + +/// One source-to-destination contribution of OpenCV's area table (`DecimateAlpha`). +#[derive(Clone, Copy)] +struct Tap { + di: usize, + si: usize, + alpha: f32, +} + +/// OpenCV's `computeResizeAreaTab`, in its f64 arithmetic. +fn area_tab(ssize: usize, dsize: usize, scale: f64) -> Vec { + let mut tab = Vec::with_capacity(ssize * 2); + for dx in 0..dsize { + let fsx1 = dx as f64 * scale; + let fsx2 = fsx1 + scale; + let cell = scale.min(ssize as f64 - fsx1); + let mut sx1 = fsx1.ceil() as i64; + let mut sx2 = fsx2.floor() as i64; + sx2 = sx2.min(ssize as i64 - 1); + sx1 = sx1.min(sx2); + if sx1 as f64 - fsx1 > 1e-3 { + tab.push(Tap { + di: dx, + si: (sx1 - 1) as usize, + alpha: ((sx1 as f64 - fsx1) / cell) as f32, + }); + } + for sx in sx1..sx2 { + tab.push(Tap { + di: dx, + si: sx as usize, + alpha: (1.0 / cell) as f32, + }); + } + if fsx2 - sx2 as f64 > 1e-3 { + tab.push(Tap { + di: dx, + si: sx2 as usize, + alpha: ((fsx2 - sx2 as f64).min(1.0).min(cell) / cell) as f32, + }); + } + } + tab +} + +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_AREA)` of a float32 image. `src` is +/// row-major with `cn` channels interleaved. An axis that grows makes it OpenCV's bilinear +/// variant. None for a 2x2 two-channel image. +pub fn resize_area( + src: &[f32], + h: usize, + w: usize, + cn: usize, + dh: usize, + dw: usize, +) -> Option> { + if h == 0 || w == 0 || dh == 0 || dw == 0 || cn == 0 || src.len() != h * w * cn { + return None; + } + // OpenCV computes inv_scale = dsize / ssize, then scale = 1 / inv_scale. + let scale_x = 1.0 / (dw as f64 / w as f64); + let scale_y = 1.0 / (dh as f64 / h as f64); + let int_x = (scale_x - scale_x.round()).abs() < f64::EPSILON; + let int_y = (scale_y - scale_y.round()).abs() < f64::EPSILON; + if scale_x < 1.0 || scale_y < 1.0 { + let mut dst = vec![0f32; dh * dw * cn]; + resize_linear(src, (h, w, cn), (dh, dw), true, &mut dst)?; + return Some(dst); + } + if dh == h && dw == w { + return Some(src.to_vec()); + } + if int_x && int_y { + return resize_area_fast( + src, + w, + cn, + dh, + dw, + scale_x.round() as usize, + scale_y.round() as usize, + ); + } + let xtab = area_tab(w, dw, scale_x); + let ytab = area_tab(h, dh, scale_y); + let dwidth = dw * cn; + let mut dst = vec![0f32; dh * dwidth]; + + // Each destination pixel's taps are consecutive in its table, so rows (and pixels) are + // independent and one accumulator per pixel keeps OpenCV's order: ((0 + s0*a0) + s1*a1) ... + let xspans = spans(&xtab, dw); + let yspans = spans(&ytab, dh); + + dst.par_chunks_mut(dwidth) + .enumerate() + .for_each(|(dy, out)| { + let mut first = true; + for t in &ytab[yspans[dy]..yspans[dy + 1]] { + let row = &src[t.si * w * cn..(t.si + 1) * w * cn]; + match cn { + 1 => accumulate::<1>(row, &xtab, &xspans, t.alpha, first, out), + 3 => accumulate::<3>(row, &xtab, &xspans, t.alpha, first, out), + _ => accumulate_any(row, cn, &xtab, &xspans, t.alpha, first, out), + } + first = false; + } + }); + Some(dst) +} + +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_NEAREST)`: each output pixel copies the +/// source pixel at floor(d * ssize / dsize), as OpenCV's `resizeNN` rounds it. +pub fn resize_nearest( + src: &[T], + (h, w, cn): (usize, usize, usize), + (dh, dw): (usize, usize), + dst: &mut [T], +) { + let ofs = |s: usize, d: usize, i: usize| { + ((i as f64 * (1.0 / (d as f64 / s as f64))).floor() as usize).min(s - 1) + }; + // u32 offsets keep a wide row's table in L1. + let xs: Vec = (0..dw).map(|x| (ofs(w, dw, x) * cn) as u32).collect(); + let dwidth = dw * cn; + const ROWS: usize = 16; + dst.par_chunks_mut(ROWS * dwidth) + .enumerate() + .for_each(|(block, out)| { + let mut prev = usize::MAX; + for i in 0..out.len() / dwidth { + let sy = ofs(h, dh, block * ROWS + i); + let (done, rest) = out.split_at_mut(i * dwidth); + let o = &mut rest[..dwidth]; + // An upscale repeats a source row: copy the output row it gave. + if sy == prev { + o.copy_from_slice(&done[(i - 1) * dwidth..]); + continue; + } + prev = sy; + let row = &src[sy * w * cn..][..w * cn]; + match cn { + 1 => nearest_row::(row, &xs, o), + 3 => nearest_row::(row, &xs, o), + 4 => nearest_row::(row, &xs, o), + _ => { + for (o, &x) in o.chunks_exact_mut(cn).zip(&xs) { + o.copy_from_slice(&row[x as usize..][..cn]); + } + } + } + } + }); +} + +fn nearest_row(row: &[T], xs: &[u32], out: &mut [T]) { + let (pixels, _) = row.as_chunks::(); + let last = pixels.len() - 1; + for (o, &x) in out.as_chunks_mut::().0.iter_mut().zip(xs) { + *o = pixels[(x as usize / CN).min(last)]; + } +} + +/// One axis of OpenCV's bilinear resize (`hal::resize`): per destination index, the first +/// source index and the float32 weight of the next one. `area` takes INTER_AREA's upscale +/// fractions. Without `clamp` (the y axis) a fraction outside the source stays and both rows +/// clip to the edge. +fn linear_axis(ssize: usize, dsize: usize, area: bool, clamp: bool) -> Vec<(i64, f32)> { + let inv = dsize as f64 / ssize as f64; + let scale = 1.0 / inv; + (0..dsize) + .map(|d| { + let (mut s, mut f) = if area { + let s = (d as f64 * scale).floor() as i64; + let f = ((d + 1) as f64 - (s + 1) as f64 * inv) as f32; + (s, if f <= 0.0 { 0.0 } else { f - f.floor() }) + } else { + let f = ((d as f64 + 0.5) * scale - 0.5) as f32; + let s = f.floor(); + (s as i64, f - s) + }; + if clamp && s < 0 { + (s, f) = (0, 0.0); + } + if clamp && s >= ssize as i64 - 1 { + (s, f) = (ssize as i64 - 1, 0.0); + } + (s, f) + }) + .collect() +} + +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_LINEAR)` of a float32 image, or with +/// `area` INTER_AREA where either axis grows, as OpenCV runs it without IPP: a row pass then a +/// column pass, each `a * (1 - f) + b * f` in float32. An exact halving is INTER_AREA's mean. +pub fn resize_linear( + src: &[f32], + (h, w, cn): (usize, usize, usize), + (dh, dw): (usize, usize), + area: bool, + dst: &mut [f32], +) -> Option<()> { + if h == 0 || w == 0 || dh == 0 || dw == 0 || cn == 0 || src.len() != h * w * cn { + return None; + } + if dst.len() != dh * dw * cn { + return None; + } + if !area && dw * 2 == w && dh * 2 == h { + dst.copy_from_slice(&resize_area(src, h, w, cn, dh, dw)?); + return Some(()); + } + let xs = linear_axis(w, dw, area, true); + let ys = linear_axis(h, dh, area, false); + // From xmax on, OpenCV reads one source pixel (weight 1) instead of two. + let xmax = xs + .iter() + .position(|&(s, _)| s + 1 >= w as i64) + .unwrap_or(dw); + let xs: Vec<(usize, f32, f32)> = xs.iter().map(|&(s, f)| (s as usize, 1.0 - f, f)).collect(); + let row_pass = |sy: usize, out: &mut [f32]| { + let row = &src[sy * w * cn..][..w * cn]; + match cn { + 1 => linear_row::<1>(row, &xs, xmax, out), + 3 => linear_row::<3>(row, &xs, xmax, out), + 4 => linear_row::<4>(row, &xs, xmax, out), + _ => linear_row_any(row, cn, &xs, xmax, out), + } + }; + let dwidth = dw * cn; + let clip = |v: i64| v.clamp(0, h as i64 - 1) as usize; + // A block of output rows reuses each row pass; a few blocks per thread keep the + // repeated passes at block starts rare. + let block = dh.div_ceil(4 * rayon::current_num_threads()).max(16); + dst.par_chunks_mut(block * dwidth) + .zip(ys.par_chunks(block)) + .for_each(|(out, ys)| { + let mut cache = [ + (usize::MAX, vec![0f32; dwidth]), + (usize::MAX, vec![0f32; dwidth]), + ]; + for (o, &(sy, fy)) in out.chunks_exact_mut(dwidth).zip(ys) { + let (r0, r1) = (clip(sy), clip(sy + 1)); + if cache[0].0 != r0 && cache[1].0 == r0 { + cache.swap(0, 1); + } + let (c0, c1) = cache.split_at_mut(1); + let (c0, c1) = (&mut c0[0], &mut c1[0]); + if c0.0 != r0 { + c0.0 = r0; + row_pass(r0, &mut c0.1); + } + if c1.0 != r1 { + c1.0 = r1; + if r1 == r0 { + c1.1.copy_from_slice(&c0.1); + } else { + row_pass(r1, &mut c1.1); + } + } + blend_rows(&c0.1, &c1.1, (1.0 - fy, fy), o); + } + }); + Some(()) +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn blend_rows(r0: &[f32], r1: &[f32], (b0, b1): (f32, f32), out: &mut [f32]) { + for ((o, &a), &b) in out.iter_mut().zip(r0).zip(r1) { + *o = a * b0 + b * b1; + } +} + +/// One row of the bilinear row pass; from `xmax` on, OpenCV weights one source pixel. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn linear_row( + row: &[f32], + xs: &[(usize, f32, f32)], + xmax: usize, + out: &mut [f32], +) { + let last = row.len() - CN; + let (two, one) = out.split_at_mut(xmax * CN); + for (o, &(sx, a0, a1)) in two.as_chunks_mut::().0.iter_mut().zip(&xs[..xmax]) { + let i = (sx * CN).min(last - CN); + let (p, q) = (&row[i..][..CN], &row[i + CN..][..CN]); + for k in 0..CN { + o[k] = p[k] * a0 + q[k] * a1; + } + } + for (o, &(sx, a0, _)) in one.as_chunks_mut::().0.iter_mut().zip(&xs[xmax..]) { + let p = &row[(sx * CN).min(last)..][..CN]; + for k in 0..CN { + o[k] = p[k] * a0; + } + } +} + +fn linear_row_any(row: &[f32], cn: usize, xs: &[(usize, f32, f32)], xmax: usize, out: &mut [f32]) { + for (dx, (o, &(sx, a0, a1))) in out.chunks_exact_mut(cn).zip(xs).enumerate() { + let p = &row[sx * cn..]; + for (k, o) in o.iter_mut().enumerate() { + *o = if dx < xmax { + p[k] * a0 + p[cn + k] * a1 + } else { + p[k] * a0 + }; + } + } +} + +/// OpenCV's `resizeAreaFast_` for an integer scale. A 2x2 one- or four-channel image takes its +/// SIMD order, ((a + b) + (c + d)) * 0.25, over the columns the 4-lane vector loop reaches; the +/// rest sums each cell row by row, four at a time: sum + (((a + b) + c) + d). +fn resize_area_fast( + src: &[f32], + w: usize, + cn: usize, + dh: usize, + dw: usize, + sx: usize, + sy: usize, +) -> Option> { + let simd_cols = match (sx, sy, cn) { + (2, 2, 1) => dw / 4 * 4, + (2, 2, 4) => dw, + (2, 2, 2) => return None, + _ => 0, + }; + let area = sx * sy; + let scale = 1.0f32 / area as f32; + let row_len = w * cn; + let ofs: Vec = (0..sy) + .flat_map(|y| (0..sx).map(move |x| y * row_len + x * cn)) + .collect(); + let mut dst = vec![0f32; dh * dw * cn]; + dst.par_chunks_mut(dw * cn) + .enumerate() + .for_each(|(dy, out)| { + let base = dy * sy * row_len; + for dx in 0..dw { + for c in 0..cn { + let s0 = base + dx * sx * cn + c; + out[dx * cn + c] = if dx < simd_cols { + let (a, b) = (src[s0], src[s0 + cn]); + let (p, q) = (src[s0 + row_len], src[s0 + row_len + cn]); + ((a + b) + (p + q)) * 0.25 + } else { + let mut sum = 0f32; + let mut k = 0; + while k + 4 <= area { + sum += ((src[s0 + ofs[k]] + src[s0 + ofs[k + 1]]) + + src[s0 + ofs[k + 2]]) + + src[s0 + ofs[k + 3]]; + k += 4; + } + for o in &ofs[k..] { + sum += src[s0 + o]; + } + sum * scale + }; + } + } + }); + Some(dst) +} + +/// Start index in `tab` of each destination index's taps, plus the end. +fn spans(tab: &[Tap], dsize: usize) -> Vec { + let mut starts = vec![tab.len(); dsize + 1]; + for (j, t) in tab.iter().enumerate().rev() { + starts[t.di] = j; + } + starts +} + +/// One source row's horizontal pass, weighted by `beta` into `out` (OpenCV's buf and sum). +fn accumulate( + row: &[f32], + xtab: &[Tap], + xspans: &[usize], + beta: f32, + first: bool, + out: &mut [f32], +) { + for (dx, px) in out.as_chunks_mut::().0.iter_mut().enumerate() { + let mut acc = [0f32; CN]; + for tap in &xtab[xspans[dx]..xspans[dx + 1]] { + let s = &row[tap.si * CN..tap.si * CN + CN]; + for c in 0..CN { + acc[c] += s[c] * tap.alpha; + } + } + for c in 0..CN { + px[c] = if first { + beta * acc[c] + } else { + px[c] + beta * acc[c] + }; + } + } +} + +fn accumulate_any( + row: &[f32], + cn: usize, + xtab: &[Tap], + xspans: &[usize], + beta: f32, + first: bool, + out: &mut [f32], +) { + for (dx, px) in out.chunks_exact_mut(cn).enumerate() { + for (c, p) in px.iter_mut().enumerate() { + let mut acc = 0f32; + for tap in &xtab[xspans[dx]..xspans[dx + 1]] { + acc += row[tap.si * cn + c] * tap.alpha; + } + *p = if first { beta * acc } else { *p + beta * acc }; + } + } +} + +/// The kernels of `cv2.INTER_CUBIC` and `cv2.INTER_LANCZOS4`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Kernel { + Cubic, + Lanczos4, +} + +impl Kernel { + fn taps(self) -> usize { + match self { + Kernel::Cubic => 4, + Kernel::Lanczos4 => 8, + } + } + + /// OpenCV's `interpolateCubic` and `interpolateLanczos4` in their float arithmetic. + fn weights(self, x: f32) -> [f32; 8] { + let mut c = [0f32; 8]; + match self { + Kernel::Cubic => { + let a = -0.75f32; + c[0] = ((a * (x + 1.0) - 5.0 * a) * (x + 1.0) + 8.0 * a) * (x + 1.0) - 4.0 * a; + c[1] = ((a + 2.0) * x - (a + 3.0)) * x * x + 1.0; + c[2] = ((a + 2.0) * (1.0 - x) - (a + 3.0)) * (1.0 - x) * (1.0 - x) + 1.0; + c[3] = 1.0 - c[0] - c[1] - c[2]; + } + Kernel::Lanczos4 => { + let s45 = std::f64::consts::FRAC_1_SQRT_2; + let cs = [ + (1.0, 0.0), + (-s45, -s45), + (0.0, 1.0), + (s45, -s45), + (-1.0, 0.0), + (s45, s45), + (0.0, -1.0), + (-s45, s45), + ]; + let y0 = -(x as f64 + 3.0) * std::f64::consts::PI * 0.25; + let (s0, c0) = (libm::sin(y0), libm::cos(y0)); + let mut sum = 0f32; + for (i, (k, &(a, b))) in c.iter_mut().zip(&cs).enumerate() { + let t = x + 3.0 - i as f32; + *k = if t.abs() >= 1e-6 { + let y = -t as f64 * std::f64::consts::PI * 0.25; + ((a * s0 + b * c0) / (y * y)) as f32 + } else { + 1e30 + }; + sum += *k; + } + let inv = 1.0 / sum; + for k in &mut c { + *k *= inv; + } + } + } + c + } +} + +/// Per output position along one axis: the first source tap (before clamping) and its weights. +fn kernel_axis(src: usize, dst: usize, kernel: Kernel) -> Vec<(i64, [f32; 8])> { + let scale = 1.0 / (dst as f64 / src as f64); + let back = (kernel.taps() / 2 - 1) as i64; + (0..dst) + .map(|d| { + let f = ((d as f64 + 0.5) * scale - 0.5) as f32; + let s = f.floor(); + (s as i64 - back, kernel.weights(f - s)) + }) + .collect() +} + +/// One output row of the vertical pass: per element the float32 sum of its taps in order. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2", + "aarch64+neon" +))] +fn column_pass(out: &mut [f32], rows: [&[f32]; N], wt: [f32; N]) { + for (i, d) in out.iter_mut().enumerate() { + let mut v = rows[0][i] * wt[0]; + for k in 1..N { + v += rows[k][i] * wt[k]; + } + *d = v; + } +} + +/// One row of the horizontal pass for `C` channels and `N` taps: each output the float32 sum of +/// its taps in order. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2", + "aarch64+neon" +))] +fn row_pass( + s: &[f32], + taps: &[usize], + wts: &[f32], + out: &mut [f32], +) { + let px = s.as_chunks::().0; + let (taps, wts) = (taps.as_chunks::().0, wts.as_chunks::().0); + for ((o, t), wt) in out.as_chunks_mut::().0.iter_mut().zip(taps).zip(wts) { + let p = px[t[0]]; + let mut acc: [f32; C] = std::array::from_fn(|c| p[c] * wt[0]); + for k in 1..N { + let p = px[t[k]]; + for c in 0..C { + acc[c] += p[c] * wt[k]; + } + } + *o = acc; + } +} + +fn row_pass_any(s: &[f32], taps: &[usize], wts: &[f32], n: usize, cn: usize, out: &mut [f32]) { + for ((o, t), wt) in out + .chunks_exact_mut(cn) + .zip(taps.chunks_exact(n)) + .zip(wts.chunks_exact(n)) + { + o.fill(0.0); + for (&sx, &wk) in t.iter().zip(wt) { + for (d, &v) in o.iter_mut().zip(&s[sx * cn..][..cn]) { + *d += v * wk; + } + } + } +} + +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_CUBIC or cv2.INTER_LANCZOS4)` of a float32 +/// image: OpenCV's taps and weights, edges replicated, each pass summed in float32 tap by tap. +pub fn resize_kernel( + src: &[f32], + dims: (usize, usize, usize), + dsize: (usize, usize), + kernel: Kernel, + dst: &mut [f32], +) -> Option<()> { + resize_kernel_as(src, dims, dsize, kernel, dst, |v| v, |v| v) +} + +/// The kernel resize, reading `S` and writing `D` through float32 inside one parallel pass. +fn resize_kernel_as( + src: &[S], + (h, w, cn): (usize, usize, usize), + (dh, dw): (usize, usize), + kernel: Kernel, + dst: &mut [D], + load: impl Fn(S) -> f32 + Sync, + store: impl Fn(f32) -> D + Sync, +) -> Option<()> { + if h == 0 + || w == 0 + || dh == 0 + || dw == 0 + || cn == 0 + || src.len() != h * w * cn + || dst.len() != dh * dw * cn + { + return None; + } + let n = kernel.taps(); + let (xs, ys) = (kernel_axis(w, dw, kernel), kernel_axis(h, dh, kernel)); + let clamp = |i: i64, len: usize| i.clamp(0, len as i64 - 1) as usize; + // Source column of every tap, clamped once: replicated edges. + let taps: Vec = xs + .iter() + .flat_map(|&(x0, _)| (0..n).map(move |k| clamp(x0 + k as i64, w))) + .collect(); + let wts: Vec = xs + .iter() + .flat_map(|(_, wt)| wt[..n].iter().copied()) + .collect(); + let hrow = |y: usize, line: &mut Vec, out: &mut [f32]| { + line.clear(); + line.extend(src[y * w * cn..][..w * cn].iter().map(|&v| load(v))); + let s = line.as_slice(); + match (cn, n) { + (1, 4) => row_pass::<1, 4>(s, &taps, &wts, out), + (3, 4) => row_pass::<3, 4>(s, &taps, &wts, out), + (4, 4) => row_pass::<4, 4>(s, &taps, &wts, out), + (1, 8) => row_pass::<1, 8>(s, &taps, &wts, out), + (3, 8) => row_pass::<3, 8>(s, &taps, &wts, out), + (4, 8) => row_pass::<4, 8>(s, &taps, &wts, out), + _ => row_pass_any(s, &taps, &wts, n, cn, out), + } + }; + // Bands of output rows; each filters the source rows it reads into its own buffer, so a + // band's halo is filtered twice: bands as wide as the thread count allows. + let band = (dh / (4 * rayon::current_num_threads())).clamp(16, 128); + let row_len = dw * cn; + dst.par_chunks_mut(band * row_len) + .zip(ys.par_chunks(band)) + .for_each(|(band, ys)| { + let lo = clamp(ys[0].0, h); + let hi = clamp(ys[ys.len() - 1].0 + n as i64 - 1, h); + let mut rows = vec![0f32; (hi - lo + 1) * row_len]; + let mut line = Vec::with_capacity(w * cn); + for (i, r) in rows.chunks_mut(row_len).enumerate() { + hrow(lo + i, &mut line, r); + } + let mut out = vec![0f32; row_len]; + for (o, (y0, wt)) in band.chunks_mut(row_len).zip(ys) { + let r = |k: usize| &rows[(clamp(y0 + k as i64, h) - lo) * row_len..][..row_len]; + if n == 4 { + column_pass( + &mut out, + [r(0), r(1), r(2), r(3)], + [wt[0], wt[1], wt[2], wt[3]], + ); + } else { + column_pass(&mut out, std::array::from_fn::<_, 8, _>(r), *wt); + } + for (d, &v) in o.iter_mut().zip(&out) { + *d = store(v); + } + } + }); + Some(()) +} + +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_AREA)` of a uint8 image: OpenCV's area +/// weights in float32, rounded half to even and saturated to uint8. +pub fn resize_area_u8( + src: &[u8], + (h, w, cn): (usize, usize, usize), + (dh, dw): (usize, usize), + dst: &mut [u8], +) -> Option<()> { + let mut f = vec![0f32; src.len()]; + crate::par_zip(&mut f, src, |d, &v| *d = v as f32); + let out = resize_area(&f, h, w, cn, dh, dw)?; + // Adding 1.5 * 2^23 rounds half to even into the low mantissa bits. + crate::par_zip(dst, &out, |d, &v| { + *d = ((v.clamp(-1.0, 256.0) + 12_582_912.0).to_bits() as i32 - 0x4b40_0000).clamp(0, 255) + as u8; + }); + Some(()) +} + +/// `cv2.resize(src, (dw, dh), interpolation=cv2.INTER_CUBIC)` of a uint8 image, as OpenCV +/// computes it without IPP: 11-bit fixed-point weights, integer rows, and a column pass that +/// runs in float32 over blocks of 8 values of each row and in fixed point over the rest. +pub fn resize_cubic_u8( + src: &[u8], + (h, w, cn): (usize, usize, usize), + (dh, dw): (usize, usize), + dst: &mut [u8], +) -> Option<()> { + if h == 0 + || w == 0 + || dh == 0 + || dw == 0 + || cn == 0 + || src.len() != h * w * cn + || dst.len() != dh * dw * cn + { + return None; + } + // saturate_cast of the float weight: round half to even. + let fixed = |wt: [f32; 8]| { + std::array::from_fn::(|k| (wt[k] * 2048.0).round_ties_even() as i16) + }; + let xs = kernel_axis(w, dw, Kernel::Cubic); + let ys: Vec<(i64, [i16; 4])> = kernel_axis(h, dh, Kernel::Cubic) + .into_iter() + .map(|(y, wt)| (y, fixed(wt))) + .collect(); + // A row padded with PAD replicated pixels each side holds every tap window: x0 >= -2, x0 + 3 <= w + 1. + let starts: Vec = xs.iter().map(|&(x0, _)| (x0 + PAD as i64) as u32).collect(); + // Each pixel's four weights repeated per channel lane group, as the AVX2 row pass loads them. + let wts: Vec<[i16; 16]> = xs + .iter() + .map(|&(_, wt)| { + let a = fixed(wt); + std::array::from_fn(|i| a[i % 4]) + }) + .collect(); + let padded_len = (w + 2 * PAD) * cn + SLACK; + let windows_fit = starts + .iter() + .all(|&s| (s as usize + 4) * cn + 16 <= padded_len); + let clamp = |i: i64, len: usize| i.clamp(0, len as i64 - 1) as usize; + let row_len = dw * cn; + let vec_len = row_len / 8 * 8; + let band = dh + .div_ceil(BANDS_PER_THREAD * rayon::current_num_threads()) + .max(8); + dst.par_chunks_mut(band * row_len) + .zip(ys.par_chunks(band)) + .for_each(|(band, ys)| { + let lo = clamp(ys[0].0, h); + let hi = clamp(ys[ys.len() - 1].0 + 3, h); + BUFFERS.with_borrow_mut(|(rows, padded)| { + rows.resize((hi - lo + 1) * row_len, 0); + padded.resize(padded_len, 0); + for (r, out) in rows.chunks_mut(row_len).enumerate() { + let s = &src[(lo + r) * w * cn..][..w * cn]; + padded[PAD * cn..][..w * cn].copy_from_slice(s); + for p in 0..PAD { + padded[p * cn..][..cn].copy_from_slice(&s[..cn]); + padded[(PAD + w + p) * cn..][..cn].copy_from_slice(&s[(w - 1) * cn..]); + } + cubic_row_u8(padded, cn, &starts, &wts, out, windows_fit); + } + for (o, (y0, b)) in band.chunks_mut(row_len).zip(ys) { + let r = |k: usize| &rows[(clamp(y0 + k as i64, h) - lo) * row_len..][..row_len]; + cubic_column_u8(o, [r(0), r(1), r(2), r(3)], *b, vec_len); + } + }); + }); + Some(()) +} + +thread_local! { + /// Each worker's row buffers, kept across calls: a fresh large buffer page-faults on every call. + static BUFFERS: std::cell::RefCell<(Vec, Vec)> = const { std::cell::RefCell::new((Vec::new(), Vec::new())) }; +} + +const PAD: usize = 2; +const BANDS_PER_THREAD: usize = 4; +const SLACK: usize = 16; + +/// `HResizeCubic` of one padded row: output (dx, c) is the integer dot product of channel c of +/// the four pixels from `starts[dx]` with the weights of dx. `windows_fit` says each 16-byte +/// load at a window start lies inside `padded`. +fn cubic_row_u8( + padded: &[u8], + cn: usize, + starts: &[u32], + wts: &[[i16; 16]], + out: &mut [i32], + windows_fit: bool, +) { + #[cfg(target_arch = "x86_64")] + if windows_fit && (cn == 3 || cn == 4) && std::arch::is_x86_feature_detected!("avx2") { + // SAFETY: AVX2 is present, and every 16-byte load at a window start lies inside `padded`. + let done = unsafe { cubic_row_u8_avx2(padded, cn, starts, wts, out) }; + cubic_row_u8_scalar( + padded, + cn, + &starts[done..], + &wts[done..], + &mut out[done * cn..], + ); + return; + } + cubic_row_u8_scalar(padded, cn, starts, wts, out); +} + +fn cubic_row_u8_scalar( + padded: &[u8], + cn: usize, + starts: &[u32], + wts: &[[i16; 16]], + out: &mut [i32], +) { + for ((o, &s), a) in out.chunks_exact_mut(cn).zip(starts).zip(wts) { + let p = &padded[s as usize * cn..][..4 * cn]; + for (c, d) in o.iter_mut().enumerate() { + *d = (0..4).map(|k| p[k * cn + c] as i32 * a[k] as i32).sum(); + } + } +} + +/// Two pixels per step: `pshufb` sorts each 16-byte window by channel, `pmaddwd` multiplies the +/// four taps pairwise and `phaddd` adds the pairs. Returns how many pixels it wrote; a pixel whose +/// 8-lane store would pass the end of `out` is left to the caller. +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2")] +unsafe fn cubic_row_u8_avx2( + padded: &[u8], + cn: usize, + starts: &[u32], + wts: &[[i16; 16]], + out: &mut [i32], +) -> usize { + use std::arch::x86_64::*; + // Channel-major order of a window's bytes; 3 channels leave the last four lanes zero. + let order: [i8; 16] = if cn == 3 { + [0, 3, 6, 9, 1, 4, 7, 10, 2, 5, 8, 11, -1, -1, -1, -1] + } else { + [0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15] + }; + // Lanes of [p.c0, p.c1, q.c0, q.c1 | p.c2, p.c3, q.c2, q.c3] in pixel order. + let lanes = if cn == 3 { + [0, 1, 4, 2, 3, 6, 7, 7] + } else { + [0, 1, 4, 5, 2, 3, 6, 7] + }; + let n = starts.len(); + let mut dx = 0; + // SAFETY: every load at a window start is inside `padded` (the caller's check), and each + // store writes 8 lanes at dx * cn with (dx + 2) * cn + 8 - 2 * cn <= out.len() checked below. + unsafe { + let shuf = _mm_loadu_si128(order.as_ptr() as *const __m128i); + let perm = _mm256_loadu_si256(lanes.as_ptr() as *const __m256i); + let pixel = |d: usize| { + let v = _mm_loadu_si128(padded.as_ptr().add(starts[d] as usize * cn) as *const __m128i); + let taps = _mm256_cvtepu8_epi16(_mm_shuffle_epi8(v, shuf)); + let wv = _mm256_loadu_si256(wts[d].as_ptr() as *const __m256i); + _mm256_madd_epi16(taps, wv) + }; + while dx + 2 <= n && dx * cn + 8 <= out.len() { + let sums = _mm256_hadd_epi32(pixel(dx), pixel(dx + 1)); + let ordered = _mm256_permutevar8x32_epi32(sums, perm); + _mm256_storeu_si256(out.as_mut_ptr().add(dx * cn) as *mut __m256i, ordered); + dx += 2; + } + } + dx +} + +/// `VResizeCubic` with `VResizeCubicVec_32s8u` at 128-bit lanes for one plane: the first `split` +/// values in float32 (weights scaled by 2^-22, the last product added first, rounded half to +/// even), the rest `(sum + 2^21) >> 22`. Both saturate to uint8. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2", + "aarch64+neon" +))] +fn cubic_column_u8(out: &mut [u8], r: [&[i32]; 4], b: [i16; 4], split: usize) { + let scale = 1.0f32 / (2048.0 * 2048.0); + let bf = b.map(|v| v as f32 * scale); + let n = out.len(); + let r = r.map(|x| &x[..n]); + let mut done = 0; + #[cfg(target_arch = "x86_64")] + if std::arch::is_x86_feature_detected!("avx2") { + // SAFETY: AVX2 is present; the kernel reads and writes only below `split <= n`. + done = unsafe { cubic_column_u8_avx2(&mut out[..split], r, bf) }; + } + let (vec, rest) = out.split_at_mut(split); + let vec = &mut vec[done..]; + let rows = r[0].iter().zip(r[1]).zip(r[2]).zip(r[3]); + for (d, (((&s0, &s1), &s2), &s3)) in vec.iter_mut().zip(rows.clone().skip(done)) { + let v = s3 as f32 * bf[3]; + let v = s2 as f32 * bf[2] + v; + let v = s1 as f32 * bf[1] + v; + let v = s0 as f32 * bf[0] + v; + // 1.5 * 2^23 rounds half to even into the low mantissa bits for |v| < 2^22. + let q = (v.clamp(-1.0, 256.0) + 12_582_912.0).to_bits() as i32 - 0x4b40_0000; + *d = q.clamp(0, 255) as u8; + } + let b = b.map(i32::from); + for (d, (((&s0, &s1), &s2), &s3)) in rest.iter_mut().zip(rows.skip(split)) { + let s = s0 * b[0] + s1 * b[1] + s2 * b[2] + s3 * b[3]; + *d = ((s + (1 << 21)) >> 22).clamp(0, 255) as u8; + } +} + +/// The float32 part of the column pass, 16 values per step: `cvtps2dq` rounds half to even as +/// `v_round` does, and the saturating packs clamp to uint8 as `v_pack` and `v_pack_u_store` do. +/// Returns how many values it wrote. +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2")] +unsafe fn cubic_column_u8_avx2(out: &mut [u8], r: [&[i32]; 4], bf: [f32; 4]) -> usize { + use std::arch::x86_64::*; + let n = out.len() / 16 * 16; + // SAFETY: each step reads 16 values of every row and writes 16 bytes below n <= out.len() + // and n <= r[k].len(). + unsafe { + let b = bf.map(|v| _mm256_set1_ps(v)); + let sum = |i: usize| { + let ld = |k: usize| { + _mm256_cvtepi32_ps(_mm256_loadu_si256(r[k].as_ptr().add(i) as *const __m256i)) + }; + let v = _mm256_mul_ps(ld(3), b[3]); + let v = _mm256_add_ps(_mm256_mul_ps(ld(2), b[2]), v); + let v = _mm256_add_ps(_mm256_mul_ps(ld(1), b[1]), v); + _mm256_cvtps_epi32(_mm256_add_ps(_mm256_mul_ps(ld(0), b[0]), v)) + }; + for i in (0..n).step_by(16) { + let words = + _mm256_permute4x64_epi64::<0b11_01_10_00>(_mm256_packs_epi32(sum(i), sum(i + 8))); + let bytes = _mm256_packus_epi16(words, words); + let low = _mm256_castsi256_si128(_mm256_permute4x64_epi64::<0b10_00_10_00>(bytes)); + _mm_storeu_si128(out.as_mut_ptr().add(i) as *mut __m128i, low); + } + } + n +} + +/// Per output row of OpenCV's linear axis: the two clipped source rows and the fraction toward the second. +fn linear_rows(h: usize, dh: usize) -> Vec<(usize, usize, f32)> { + let scale = 1.0 / (dh as f64 / h as f64); + (0..dh) + .map(|dy| { + let f = ((dy as f64 + 0.5) * scale - 0.5) as f32; + let s = f.floor(); + let clip = |y: i64| y.clamp(0, h as i64 - 1) as usize; + (clip(s as i64), clip(s as i64 + 1), f - s) + }) + .collect() +} + +/// `cv2.resize(src, (w, dh), interpolation=cv2.INTER_LINEAR)` of a uint8 image whose width stays, +/// as OpenCV computes it without IPP: 11-bit weights and `VResizeLinear`'s integer arithmetic. +pub fn resize_rows_linear_u8( + src: &[u8], + (h, w, cn): (usize, usize, usize), + dh: usize, + dst: &mut [u8], +) -> Option<()> { + let row = w * cn; + if h == 0 || row == 0 || dh == 0 || src.len() != h * row || dst.len() != dh * row { + return None; + } + let rows = linear_rows(h, dh); + dst.par_chunks_mut(row) + .zip(&rows) + .for_each(|(out, &(r0, r1, f))| { + // saturate_cast: round half to even. + let b0 = ((1.0 - f) * 2048.0).round_ties_even() as i32; + let b1 = (f * 2048.0).round_ties_even() as i32; + let (s0, s1) = (&src[r0 * row..][..row], &src[r1 * row..][..row]); + for ((d, &p0), &p1) in out.iter_mut().zip(s0).zip(s1) { + // A row holds pixel * 2048; the vertical pass takes it >> 4. + let v = ((b0 * (p0 as i32 * 128)) >> 16) + ((b1 * (p1 as i32 * 128)) >> 16); + *d = ((v + 2) >> 2).clamp(0, 255) as u8; + } + }); + Some(()) +} + +/// The uint16 counterpart: float32 weights, `S0 * b0 + S1 * b1` rounded half to even, saturated. +pub fn resize_rows_linear_u16( + src: &[u16], + (h, w, cn): (usize, usize, usize), + dh: usize, + dst: &mut [u16], +) -> Option<()> { + let row = w * cn; + if h == 0 || row == 0 || dh == 0 || src.len() != h * row || dst.len() != dh * row { + return None; + } + let rows = linear_rows(h, dh); + dst.par_chunks_mut(row) + .zip(&rows) + .for_each(|(out, &(r0, r1, f))| { + let (b0, b1) = (1.0 - f, f); + let (s0, s1) = (&src[r0 * row..][..row], &src[r1 * row..][..row]); + for ((d, &p0), &p1) in out.iter_mut().zip(s0).zip(s1) { + let v = p0 as f32 * b0 + p1 as f32 * b1; + *d = v.round_ties_even().clamp(0.0, 65535.0) as u16; + } + }); + Some(()) +} + +/// `expand_mask_plane` times `scale`: a ph x pw plane resized bilinearly onto rows y1..y2 and +/// columns x1..x2 of an oh x ow plane, the edges replicated outside, each value times `scale` +/// in float32. +pub fn expand_plane( + plane: &[f32], + (ph, pw): (usize, usize), + (oh, ow): (usize, usize), + (y1, y2, x1, x2): (usize, usize, usize, usize), + scale: f32, + dst: &mut [f32], +) -> Option<()> { + let (ih, iw) = (y2.checked_sub(y1)?, x2.checked_sub(x1)?); + if ih == 0 || iw == 0 || y2 > oh || x2 > ow || dst.len() != oh * ow { + return None; + } + let mut inner = vec![0f32; ih * iw]; + resize_linear(plane, (ph, pw, 1), (ih, iw), false, &mut inner)?; + dst.par_chunks_mut(ow).enumerate().for_each(|(y, row)| { + let src = &inner[(y.clamp(y1, y2 - 1) - y1) * iw..][..iw]; + for (x, d) in row.iter_mut().enumerate() { + *d = src[x.clamp(x1, x2 - 1) - x1] * scale; + } + }); + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flat_image_stays_flat() { + let src = vec![0.25f32; 37 * 53 * 3]; + let out = resize_area(&src, 37, 53, 3, 11, 17).unwrap(); + assert!(out.iter().all(|&v| (v - 0.25).abs() < 1e-6)); + } + + #[test] + fn upscale_of_a_flat_image_stays_flat() { + let src = vec![0.5f32; 40 * 40]; + let out = resize_area(&src, 40, 40, 1, 80, 30).unwrap(); + assert!(out.iter().all(|&v| v == 0.5)); + let mut lin = vec![0f32; 90 * 70 * 3]; + resize_linear( + &vec![0.5f32; 40 * 40 * 3], + (40, 40, 3), + (90, 70), + false, + &mut lin, + ) + .unwrap(); + assert!(lin.iter().all(|&v| v == 0.5)); + } + + #[test] + fn taps_cover_each_destination_with_unit_weight() { + let tab = area_tab(1600, 1059, 1600.0 / 1059.0); + let mut total = vec![0f64; 1059]; + for t in &tab { + total[t.di] += t.alpha as f64; + } + // OpenCV drops a fragment under 1e-3 of a source pixel, so a cell can fall short by that much. + assert!(total.iter().all(|&s| (s - 1.0).abs() < 2e-3)); + } +} diff --git a/negpy_core/src/retouch.rs b/negpy_core/src/retouch.rs new file mode 100644 index 000000000..b55365faf --- /dev/null +++ b/negpy_core/src/retouch.rs @@ -0,0 +1,1698 @@ +//! The per-pixel passes of the retouch score fill (negpy/features/retouch/logic.py), in its +//! float32 operation order. + +use multiversion::multiversion; +use rayon::prelude::*; + +const BLOCK: usize = 1 << 13; + +/// Python's `max(a, b)`, which Numba keeps: `a` unless `b` is greater. +fn py_max(a: f32, b: f32) -> f32 { + if b > a { b } else { a } +} + +/// Python's `min(a, b)`: `a` unless `b` is smaller. +fn py_min(a: f32, b: f32) -> f32 { + if b < a { b } else { a } +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn fill_rung_block( + num: &[f32], + den: &[f32], + fill: &mut [f32], + first: bool, + reject: bool, + (floor, tau): (f32, f32), +) { + let inv_span = 1.0 - floor; + for ((f, n), &dv) in fill + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(num.as_chunks::<3>().0) + .zip(den) + { + let d = py_max(dv, 1e-6); + if first { + for c in 0..3 { + f[c] = n[c] / d; + } + } else { + let mass = if reject { (dv - floor) / inv_span } else { dv }; + let conf = py_min(py_max(mass / tau, 0.0), 1.0); + let keep = 1.0 - conf; + for c in 0..3 { + f[c] = f[c] * keep + (n[c] / d) * conf; + } + } + } +} + +/// `_fill_rung`: one rung of the fill ladder in place over HxW pixels of three channels. +pub fn fill_rung( + num: &[f32], + den: &[f32], + fill: &mut [f32], + first: bool, + reject_floor_mass: bool, + constants: (f32, f32), +) { + fill.par_chunks_mut(3 * BLOCK) + .zip(num.par_chunks(3 * BLOCK).zip(den.par_chunks(BLOCK))) + .for_each(|(f, (n, d))| fill_rung_block(n, d, f, first, reject_floor_mass, constants)); +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn blend_block( + src: &[f32], + score: &[f32], + fill: &mut [f32], + a_out: &mut [f32], + (hi, span): (f32, f32), +) { + for (((f, s), &sc), ao) in fill + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(src.as_chunks::<3>().0) + .zip(score) + .zip(a_out.iter_mut()) + { + let a = py_min(py_max((hi - sc) / span, 0.0), 1.0); + let a = a * a * (3.0 - 2.0 * a); + *ao = a; + let keep = 1.0 - a; + for c in 0..3 { + f[c] = s[c] * keep + f[c] * a; + } + } +} + +/// `_blend_fill`: `fill` becomes `src * (1 - a) + fill * a` under the smoothstep write ramp of +/// the score, the ramp kept in `a_out`. +pub fn blend_fill( + src: &[f32], + score: &[f32], + fill: &mut [f32], + a_out: &mut [f32], + ramp: (f32, f32), +) { + fill.par_chunks_mut(3 * BLOCK) + .zip(a_out.par_chunks_mut(BLOCK)) + .zip(src.par_chunks(3 * BLOCK).zip(score.par_chunks(BLOCK))) + .for_each(|((f, ao), (s, sc))| blend_block(s, sc, f, ao, ramp)); +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn deficit_block(out: &mut [f32], a: &[f32], blur_src: &[f32], blur_out: &[f32]) { + for (((o, &av), bs), bo) in out + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(a) + .zip(blur_src.as_chunks::<3>().0) + .zip(blur_out.as_chunks::<3>().0) + { + for c in 0..3 { + let v = o[c] + av * py_max(bs[c] - bo[c], 0.0); + o[c] = py_max(v, 0.0); + } + } +} + +/// `_add_floor_deficit`: `out += a * max(blur_src - blur_out, 0)`, then clamped at zero. +pub fn add_floor_deficit(out: &mut [f32], a: &[f32], blur_src: &[f32], blur_out: &[f32]) { + out.par_chunks_mut(3 * BLOCK) + .zip(a.par_chunks(BLOCK)) + .zip( + blur_src + .par_chunks(3 * BLOCK) + .zip(blur_out.par_chunks(3 * BLOCK)), + ) + .for_each(|((o, av), (bs, bo))| deficit_block(o, av, bs, bo)); +} + +/// `_hair_encode`: `clip((crop - lo) * inv_span, 0, 1) ** inv_gamma` to 8-bit for the first +/// three channels, in float32 (no fastmath), truncated as `np.uint8` truncates. +pub fn hair_encode( + crop: &[f32], + cn: usize, + lo: f32, + inv_span: f32, + inv_gamma: f32, + out: &mut [u8], +) { + out.par_chunks_mut(cn * 1024) + .zip(crop.par_chunks(cn * 1024)) + .for_each(|(o, c)| hair_encode_block(c, o, cn, (lo, inv_span, inv_gamma))); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn hair_encode_block( + crop: &[f32], + out: &mut [u8], + cn: usize, + (lo, inv_span, inv_gamma): (f32, f32, f32), +) { + let encode = |v: f32| { + let v = ((v - lo) * inv_span).clamp(0.0, 1.0); + let p = crate::mathf::pow_f64(v as f64, inv_gamma as f64) as f32; + (p * 255.0 + 0.5) as u8 + }; + if cn == 3 { + for (o, &v) in out.iter_mut().zip(crop) { + *o = encode(v); + } + return; + } + for (o, c) in out.chunks_mut(cn).zip(crop.chunks(cn)) { + for k in 0..3 { + o[k] = encode(c[k]); + } + } +} + +/// The scalars of `_ir_clean_base`, float32 as NumPy rounds them. +pub struct CleanBase { + pub win: usize, + pub identity: f32, + pub sigma: f32, + pub min_support: f32, +} + +/// `_ir_clean_base(img_det, ratio)` of an HxWx3 image: the clean-film mean minus `sigma` of its +/// spread where enough of the window is clean, else the box mean of the window maximum. +pub fn ir_clean_base( + img: &[f32], + ratio: &[f32], + (h, w): (usize, usize), + c: &CleanBase, + dst: &mut [f32], +) -> Option<()> { + use crate::filter::{Border, box_filter}; + use crate::morph::{self, Op}; + let n = h * w; + if ratio.len() != n || img.len() != 3 * n || dst.len() != img.len() { + return None; + } + let win = (c.win, c.win); + let identity = c.identity; + let clean: Vec = ratio + .par_iter() + .map(move |&r| if r >= identity { 1.0 } else { 0.0 }) + .collect(); + let (mut p1, mut p2) = (vec![0f32; 3 * n], vec![0f32; 3 * n]); + p1.par_chunks_mut(3 * BLOCK) + .zip(p2.par_chunks_mut(3 * BLOCK)) + .zip(img.par_chunks(3 * BLOCK).zip(clean.par_chunks(BLOCK))) + .for_each(|((p1, p2), (px, wc))| { + for (i, &k) in wc.iter().enumerate() { + for j in 3 * i..3 * i + 3 { + let v = px[j]; + p1[j] = v * k; + p2[j] = v * v * k; + } + } + }); + let border = Border::Reflect101; + let mut den = vec![0f32; n]; + box_filter(&clean, &mut den, (h, w, 1), win, true, border)?; + let (mut b1, mut b2) = (vec![0f32; 3 * n], vec![0f32; 3 * n]); + box_filter(&p1, &mut b1, (h, w, 3), win, true, border)?; + box_filter(&p2, &mut b2, (h, w, 3), win, true, border)?; + // p1 holds the dilated image, then its box mean. + morph::morphology( + img, + &mut p1, + (h, w, 3), + Op::Dilate, + win, + 1, + Border::Constant, + )?; + box_filter(&p1, &mut p2, (h, w, 3), win, true, border)?; + let (sigma, support) = (c.sigma, c.min_support); + dst.par_chunks_mut(3 * BLOCK) + .zip(den.par_chunks(BLOCK)) + .zip(b1.par_chunks(3 * BLOCK).zip(b2.par_chunks(3 * BLOCK))) + .zip(p2.par_chunks(3 * BLOCK)) + .for_each(|(((o, d), (b1, b2)), dil)| clean_base_block(o, d, b1, b2, dil, sigma, support)); + Some(()) +} + +#[multiversion(targets("x86_64+avx2", "aarch64+neon"))] +fn clean_base_block( + out: &mut [f32], + den: &[f32], + b1: &[f32], + b2: &[f32], + dil: &[f32], + sigma: f32, + support: f32, +) { + for (i, &d) in den.iter().enumerate() { + let d = py_max(d, 1e-6); + for j in 3 * i..3 * i + 3 { + let mean = b1[j] / d; + let var = b2[j] / d - mean * mean; + let var = if var.is_nan() || var > 0.0 { var } else { 0.0 }; + let base = mean - sigma * var.sqrt(); + out[j] = if d > support { base } else { dil[j] }; + } + } +} + +/// `_ir_live(plane)`: 1 where film lies under the head, 0 in the below-`floor` regions of at +/// least `min_area` of the frame. +pub fn ir_live(plane: &[f32], (h, w): (usize, usize), floor: f32, min_area: f64) -> Vec { + let dead: Vec = plane.par_iter().map(move |&v| (v < floor) as u8).collect(); + if !dead.par_iter().any(|&d| d != 0) { + return vec![true; h * w]; + } + let cc = crate::components::connected_components(&dead, h, w, 8); + let bar = min_area * (h * w) as f64; + let holder: Vec = (0..cc.count) + .map(|l| l > 0 && cc.stats[l][4] as f64 >= bar) + .collect(); + cc.labels.par_iter().map(|&l| !holder[l as usize]).collect() +} + +/// Strided flat indices of `mask`, at most `cap` of them, as `_fit_sample` takes them: every +/// `step`-th set pixel in raster order, counted per block and then picked in parallel. +fn fit_indices(mask: &[bool], cap: usize) -> Vec { + const CHUNK: usize = 1 << 16; + let counts: Vec = mask + .par_chunks(CHUNK) + .map(|c| c.iter().filter(|&&m| m).count()) + .collect(); + let step = counts.iter().sum::().div_ceil(cap).max(1); + let starts: Vec = counts + .iter() + .scan(0, |acc, &c| { + let s = *acc; + *acc += c; + Some(s) + }) + .collect(); + mask.par_chunks(CHUNK) + .zip(starts) + .enumerate() + .flat_map_iter(|(b, (c, start))| { + let mut rank = start; + c.iter().enumerate().filter_map(move |(i, &m)| { + if !m { + return None; + } + let take = rank % step == 0; + rank += 1; + take.then_some(b * CHUNK + i) + }) + }) + .collect() +} + +/// `_fit_sample(mask)`: the values under `mask`, every `step`-th one so at most `cap` remain. +fn fit_sample(values: &[f32], mask: &[bool], cap: usize) -> Vec { + fit_indices(mask, cap) + .into_iter() + .map(|i| values[i]) + .collect() +} + +/// The median of `sample` and 1.4826 times its median absolute deviation, as Python floats. +fn median_and_sigma(sample: &[f32]) -> (f64, f64) { + let med = crate::stats::median(sample); + let dev: Vec = sample.par_iter().map(move |&v| (v - med).abs()).collect(); + (med as f64, 1.4826 * crate::stats::median(&dev) as f64) +} + +/// `_ir_glare(plane, live)`: 1 more than `sigmas` deviations over the live median, `None` when +/// fewer than 500 samples or no pixel qualifies. +pub fn ir_glare(plane: &[f32], live: &[bool], cap: usize, sigmas: f64) -> Option> { + let sample = fit_sample(plane, live, cap); + if sample.len() < 500 { + return None; + } + let (med, sigma) = median_and_sigma(&sample); + let bar = (med + sigmas * sigma) as f32; + let glare: Vec = plane.par_iter().map(move |&v| v > bar).collect(); + glare.par_iter().any(|&g| g).then_some(glare) +} + +/// `normalize_ir(plane, glare)`: the plane over the box mean of its `win` x `win` maximum, +/// glare pixels zeroed for the base. +pub fn normalize_ir( + plane: &[f32], + glare: Option<&[bool]>, + (h, w): (usize, usize), + win: usize, + dst: &mut [f32], +) -> Option<()> { + use crate::filter::{Border, box_filter}; + use crate::morph::{self, Op}; + let n = h * w; + if plane.len() != n || dst.len() != n || glare.is_some_and(|g| g.len() != n) { + return None; + } + let src: std::borrow::Cow<[f32]> = match glare { + Some(g) => plane + .par_iter() + .zip(g) + .map(|(&v, &g)| if g { 0.0 } else { v }) + .collect::>() + .into(), + None => plane.into(), + }; + let mut dil = vec![0f32; n]; + morph::morphology( + &src, + &mut dil, + (h, w, 1), + Op::Dilate, + (win, win), + 1, + Border::Constant, + )?; + box_filter(&dil, dst, (h, w, 1), (win, win), true, Border::Reflect101)?; + crate::par_zip(dst, plane, move |d, &p| *d = p / py_max(*d, 1e-4)); + Some(()) +} + +/// The scalars of `_ir_normalize_ratio`, as Python floats. +pub struct RatioNorm { + pub identity: f64, + pub noise_sigma: f64, + pub pivot_lo: f64, + pub ref_sigma: f64, + pub scale_max: f64, +} + +/// `np.clip(v, lo, hi)` of Python floats: a NaN comes through. +fn clip_f64(v: f64, lo: f64, hi: f64) -> f64 { + let m = if v.is_nan() || v > lo { v } else { lo }; + if m.is_nan() || m < hi { m } else { hi } +} + +/// `_ir_normalize_ratio(ratio, live)` in place; returns the (pivot, sigma) it measured, or +/// `None` when fewer than 500 live pixels leave the ratio alone. +pub fn ir_normalize_ratio(ratio: &mut [f32], live: &[bool], c: &RatioNorm) -> Option<(f64, f64)> { + let sample: Vec = ratio + .par_iter() + .zip(live) + .filter(|&(_, &l)| l) + .map(|(&v, _)| v) + .collect(); + if sample.len() < 500 { + return None; + } + let (med, sigma) = median_and_sigma(&sample); + let pivot = clip_f64(med - c.noise_sigma * sigma, c.pivot_lo, c.identity); + let scale = c.identity / pivot; + let spread = sigma * scale; + let k = clip_f64( + c.ref_sigma / if 1e-6 > spread { 1e-6 } else { spread }, + 1.0, + c.scale_max, + ); + let (s32, k32, id32) = (scale as f32, k as f32, c.identity as f32); + let stretch = k > 1.0; + ratio.par_chunks_mut(BLOCK).for_each(move |run| { + for v in run { + let r = *v * s32; + *v = if stretch && r < id32 { + id32 - (id32 - r) * k32 + } else { + r + }; + } + }); + Some((pivot, sigma)) +} + +/// `np.clip(ratio / np.exp((vis_log * b).sum(-1)), 0.0, 1.5)`: `_ir_decontaminate`'s division, +/// in float32 (lstsq keeps float32). +pub fn ir_ghost_divide(ratio: &[f32], vis_log: &[f32], b: [f32; 3], dst: &mut [f32]) { + dst.par_chunks_mut(BLOCK) + .zip(ratio.par_chunks(BLOCK).zip(vis_log.par_chunks(3 * BLOCK))) + .for_each(move |(o, (r, v))| ghost_divide_block(o, r, v, b)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn ghost_divide_block(out: &mut [f32], ratio: &[f32], vis_log: &[f32], b: [f32; 3]) { + for ((o, &r), v) in out.iter_mut().zip(ratio).zip(vis_log.as_chunks::<3>().0) { + let s = ((-0.0 + v[0] * b[0]) + v[1] * b[1]) + v[2] * b[2]; + let q = r / crate::mathf::np_expf(s); + let m = if q.is_nan() || q > 0.0 { q } else { 0.0 }; + *o = if m.is_nan() || m < 1.5 { m } else { 1.5 }; + } +} + +/// `score_weighted_fill(img, score, scales, reject_floor_mass)` of an HxWx3 image: box means +/// of the score-weighted image over the score at every support but the last, which is a +/// Gaussian, each rung blended over the coarser ones by `fill_rung`. `reject_floor_mass` counts +/// a rung's confidence above the score floor: without the original-floor rule, a support that +/// sees only defect would otherwise win with the defect as its candidate. +pub fn score_weighted_fill( + img: &[f32], + score: &[f32], + (h, w): (usize, usize), + scales: &[usize], + reject_floor_mass: bool, + constants: (f32, f32), + fill: &mut [f32], +) -> Option<()> { + use crate::filter::{Border, box_filter, gaussian_blur}; + let n = h * w; + if score.len() != n || img.len() != 3 * n || fill.len() != img.len() { + return None; + } + let mut weighted = vec![0f32; 3 * n]; + weighted + .par_chunks_mut(3 * BLOCK) + .zip(img.par_chunks(3 * BLOCK).zip(score.par_chunks(BLOCK))) + .for_each(|(o, (px, s))| { + for (i, &k) in s.iter().enumerate() { + for j in 3 * i..3 * i + 3 { + o[j] = px[j] * k; + } + } + }); + let (mut num, mut den) = (vec![0f32; 3 * n], vec![0f32; n]); + let border = Border::Reflect101; + for (i, &k) in scales.iter().enumerate() { + if i == scales.len() - 1 { + gaussian_blur(&weighted, &mut num, (h, w, 3), (k, k), (0.0, 0.0), border)?; + gaussian_blur(score, &mut den, (h, w, 1), (k, k), (0.0, 0.0), border)?; + } else { + box_filter(&weighted, &mut num, (h, w, 3), (k, k), true, border)?; + box_filter(score, &mut den, (h, w, 1), (k, k), true, border)?; + } + fill_rung(&num, &den, fill, i == 0, reject_floor_mass, constants); + } + Some(()) +} + +/// `_borrow_clean_grain` added into `out` at every pixel `clean` leaves out: the detail of the +/// pixel mirrored through the nearest clean one (that clean pixel itself when the mirror is +/// not clean), high-passed at `sigma`, times `a` when given. Returns the blur of `src`, or +/// `None` without touching `out` when no pixel or every pixel is clean. +pub fn add_clean_grain( + src: &[f32], + clean: &[bool], + (h, w): (usize, usize), + sigma: f64, + a: Option<&[f32]>, + out: &mut [f32], +) -> Option> { + if !clean.par_iter().any(|&c| c) || clean.par_iter().all(|&c| c) { + return None; + } + let holes: Vec = clean.par_iter().map(|&c| !c as u8).collect(); + let (_, labels) = crate::distance::distance_transform_labels(&holes, h, w); + // The labels number the clean pixels from 1 in raster order. + let donors: Vec = (0..h * w).filter(|&p| clean[p]).collect(); + let mut blur = vec![0f32; 3 * h * w]; + crate::filter::gaussian_blur( + src, + &mut blur, + (h, w, 3), + (0, 0), + (sigma, sigma), + crate::filter::Border::Reflect101, + )?; + let (hi, wi) = (h as isize - 1, w as isize - 1); + out.par_chunks_mut(3 * w).enumerate().for_each(|(y, row)| { + for x in 0..w { + let p = y * w + x; + if clean[p] { + continue; + } + let nearest = donors[labels[p] as usize - 1]; + let (ny, nx) = ((nearest / w) as isize, (nearest % w) as isize); + let my = (2 * ny - y as isize).clamp(0, hi) as usize; + let mx = (2 * nx - x as isize).clamp(0, wi) as usize; + let mirrored = my * w + mx; + let take = if clean[mirrored] { mirrored } else { nearest }; + for c in 0..3 { + let g = src[3 * take + c] - blur[3 * take + c]; + row[3 * x + c] += match a { + Some(a) => a[p] * g, + None => g, + }; + } + } + }); + Some(blur) +} + +/// The scalars of `apply_score_repair`, float32 as NumPy rounds them. +pub struct ScoreRepair { + pub score_floor: f32, + pub tau: f32, + pub write_hi: f32, + /// `np.float32(write_hi - write_lo)`, subtracted as Python floats. + pub write_span: f32, +} + +/// `apply_score_repair` of an HxWx3 image and a score at its resolution, with the fill +/// supports and grain `sigma` already chosen. +#[allow(clippy::too_many_arguments)] +pub fn apply_score_repair( + src: &[f32], + score: &[f32], + (h, w): (usize, usize), + supports: &[usize], + floor: bool, + sigma: f64, + c: &ScoreRepair, + out: &mut [f32], +) -> Option<()> { + let n = h * w; + score_weighted_fill( + src, + score, + (h, w), + supports, + !floor, + (c.score_floor, c.tau), + out, + )?; + let mut a = vec![0f32; n]; + blend_fill(src, score, out, &mut a, (c.write_hi, c.write_span)); + let hi = c.write_hi; + let clean: Vec = score.par_iter().map(move |&s| s >= hi).collect(); + let blur_src = add_clean_grain(src, &clean, (h, w), sigma, Some(&a), out); + if floor { + let gauss = |img: &[f32]| { + let mut b = vec![0f32; 3 * n]; + crate::filter::gaussian_blur( + img, + &mut b, + (h, w, 3), + (0, 0), + (sigma, sigma), + crate::filter::Border::Reflect101, + ) + .map(|_| b) + }; + let blur_src = match blur_src { + Some(b) => b, + None => gauss(src)?, + }; + let blur_out = gauss(out)?; + add_floor_deficit(out, &a, &blur_src, &blur_out); + } else { + out.par_chunks_mut(BLOCK).for_each(|run| { + for v in run { + *v = if v.is_nan() || *v > 0.0 { *v } else { 0.0 }; + } + }); + } + Some(()) +} + +/// Repaired pixels of one defect: flat index and value. +type Patch = Vec<(usize, [f32; 3])>; + +/// `repair_components`: `apply_score_repair` per 8-connected defect (score < 1), each in its +/// own crop padded by `pad`, written back on that defect's pixels only. `out` holds the base +/// to write into. With `dirty`, a defect holding no dirty pixel keeps `out` as it is. Past +/// `max_components` the whole frame is repaired at once and `dirty` is ignored. Returns false, +/// with `out` untouched, when the score holds no defect. +#[allow(clippy::too_many_arguments)] +pub fn repair_components( + src: &[f32], + score: &[f32], + (h, w): (usize, usize), + supports: &[usize], + floor: bool, + sigma: f64, + c: &ScoreRepair, + pad: usize, + max_components: usize, + dirty: Option<&[bool]>, + out: &mut [f32], +) -> Option { + let m: Vec = score.par_iter().map(|&s| (s < 1.0) as u8).collect(); + if !m.par_iter().any(|&v| v != 0) { + return Some(false); + } + let cc = crate::components::connected_components(&m, h, w, 8); + if cc.count - 1 > max_components { + apply_score_repair(src, score, (h, w), supports, floor, sigma, c, out)?; + return Some(true); + } + let labels = &cc.labels; + let fixes: Vec = (1..cc.count) + .into_par_iter() + .map(|i| { + let [bx, by, bw, bh, _] = cc.stats[i].map(|v| v as usize); + let own = |y: usize, x: usize| labels[y * w + x] == i as i32; + if let Some(d) = dirty { + let touched = + (by..by + bh).any(|y| (bx..bx + bw).any(|x| own(y, x) && d[y * w + x])); + if !touched { + return Some(Vec::new()); + } + } + let (x0, y0) = (bx.saturating_sub(pad), by.saturating_sub(pad)); + let (x1, y1) = ((bx + bw + pad).min(w), (by + bh + pad).min(h)); + let (cw, ch) = (x1 - x0, y1 - y0); + let mut crop = Vec::with_capacity(3 * cw * ch); + let mut crop_score = Vec::with_capacity(cw * ch); + for y in y0..y1 { + crop.extend_from_slice(&src[3 * (y * w + x0)..3 * (y * w + x1)]); + crop_score.extend_from_slice(&score[y * w + x0..y * w + x1]); + } + let mut sub = vec![0f32; crop.len()]; + apply_score_repair( + &crop, + &crop_score, + (ch, cw), + supports, + floor, + sigma, + c, + &mut sub, + )?; + let mut px = Vec::new(); + for y in y0..y1 { + for x in x0..x1 { + if own(y, x) { + let k = 3 * ((y - y0) * cw + x - x0); + px.push((y * w + x, [sub[k], sub[k + 1], sub[k + 2]])); + } + } + } + Some(px) + }) + .collect::>>()?; + for px in fixes { + for (p, v) in px { + out[3 * p..3 * p + 3].copy_from_slice(&v); + } + } + Some(true) +} + +/// `route_wide_defects` up to its budget check: 1 on the at-floor (score <= `bar`) components +/// that hold a chessboard distance of `radius` from their own edge, dilated by `kernel`; +/// `None` when no component qualifies. +pub fn route_wide_defects( + score: &[f32], + (h, w): (usize, usize), + bar: f32, + radius: usize, + kernel: crate::morph::Kernel, +) -> Option> { + use crate::distance::{Metric, distance_transform}; + let at_floor: Vec = score.par_iter().map(move |&s| (s <= bar) as u8).collect(); + if !at_floor.par_iter().any(|&v| v != 0) { + return None; + } + let cc = crate::components::connected_components(&at_floor, h, w, 8); + let side = (2 * radius).saturating_sub(1); + let labels = &cc.labels; + let wide: Vec = (1..cc.count) + .into_par_iter() + .filter(|&i| { + let [bx, by, bw, bh, _] = cc.stats[i].map(|v| v as usize); + if bw.min(bh) < side { + return false; + } + // The component alone in its box, with a zero border. + let (pw, ph) = (bw + 2, bh + 2); + let mut own = vec![0u8; pw * ph]; + for y in 0..bh { + for x in 0..bw { + own[(y + 1) * pw + x + 1] = (labels[(by + y) * w + bx + x] == i as i32) as u8; + } + } + let d = distance_transform(&own, ph, pw, Metric::Chessboard); + d.iter().fold(0f32, |m, &v| m.max(v)) >= radius as f32 + }) + .collect(); + if wide.is_empty() { + return None; + } + let mut routed = vec![0u8; h * w]; + for &i in &wide { + let [bx, by, bw, bh, _] = cc.stats[i].map(|v| v as usize); + for y in by..by + bh { + for x in bx..bx + bw { + if labels[y * w + x] == i as i32 { + routed[y * w + x] = 1; + } + } + } + } + let mut out = vec![0u8; h * w]; + crate::morph::morphology_mask( + &routed, + &mut out, + (h, w, 1), + crate::morph::Op::Dilate, + kernel, + 1, + crate::filter::Border::Constant, + )?; + Some(out) +} + +/// The scalars of `_fit_refraction_gammas`. +pub struct GammaFit { + pub band: (f32, f32), + pub flat_pct: f64, + pub min_flat: usize, + pub cap: usize, + pub fallback: f64, + pub range: (f32, f32), +} + +/// `_fit_refraction_gammas(ratio, vis_log, img_det)`: per channel, the median slope of the +/// visible log over the IR log across the shallow-dust band, on its locally flat part. The +/// flatness is cv2's 5x5 Laplacian of the green channel without IPP. +pub fn fit_refraction_gammas( + ratio: &[f32], + vis_log: &[f32], + img: &[f32], + (h, w): (usize, usize), + c: &GammaFit, +) -> Option<[f64; 3]> { + use crate::filter::{Border, sep_filter2d}; + let (lo, hi) = c.band; + let band: Vec = ratio.par_iter().map(move |&r| r > lo && r < hi).collect(); + if band.par_iter().filter(|&&b| b).count() < 500 { + return Some([c.fallback; 3]); + } + let mut green = vec![0f32; h * w]; + green + .par_chunks_mut(BLOCK) + .zip(img.par_chunks(3 * BLOCK)) + .for_each(|(g, px)| { + for (g, p) in g.iter_mut().zip(px.as_chunks::<3>().0) { + *g = p[1]; + } + }); + let d2 = [1.0f32, 0.0, -2.0, 0.0, 1.0]; + let sm = [1.0f32, 4.0, 6.0, 4.0, 1.0]; + let (mut dx, mut dy) = (vec![0f32; h * w], vec![0f32; h * w]); + sep_filter2d(&green, &mut dx, (h, w, 1), (&d2, &sm), Border::Reflect101)?; + sep_filter2d(&green, &mut dy, (h, w, 1), (&sm, &d2), Border::Reflect101)?; + crate::par_zip(&mut dx, &dy, |a, &b| *a = (*a + b).abs()); + let in_band: Vec = dx + .par_chunks(BLOCK) + .zip(band.par_chunks(BLOCK)) + .flat_map_iter(|(e, b)| { + e.iter() + .zip(b) + .filter(|&(_, &b)| b) + .map(|(&e, _)| e) + .collect::>() + }) + .collect(); + let bar = crate::stats::np_percentiles(&in_band, &[c.flat_pct])?[0]; + let flat: Vec = band + .par_iter() + .zip(&dx) + .map(move |(&b, &e)| b && e < bar) + .collect(); + let enough = flat.par_iter().filter(|&&f| f).count() >= c.min_flat; + let fit = fit_indices(if enough { &flat } else { &band }, c.cap); + let xb: Vec = fit + .iter() + .map(|&i| crate::mathf::np_logf(ratio[i])) + .collect(); + let (glo, ghi) = c.range; + Some(std::array::from_fn(|ch| { + let slopes: Vec = fit + .iter() + .zip(&xb) + .map(|(&i, &x)| vis_log[3 * i + ch] / x) + .collect(); + let m = crate::stats::median(&slopes); + let m = if m.is_nan() || m > glo { m } else { glo }; + (if m.is_nan() || m < ghi { m } else { ghi }) as f64 + })) +} + +/// `ir_defect_score`: the ratio mapped from `cutoff` (score `floor`) to `cutoff + span` +/// (score 1), clipped, then a 3x3 erode, in float32. +pub fn ir_defect_score( + ratio: &[f32], + (h, w): (usize, usize), + (cutoff, span, keep, floor): (f32, f32, f32, f32), + dst: &mut [f32], +) -> Option<()> { + let mut score = vec![0f32; h * w]; + crate::par_zip(&mut score, ratio, move |s, &r| { + let v = ((r - cutoff) / span) * keep + floor; + let v = if v.is_nan() || v > floor { v } else { floor }; + *s = if v.is_nan() || v < 1.0 { v } else { 1.0 }; + }); + crate::morph::morphology( + &score, + dst, + (h, w, 1), + crate::morph::Op::Erode, + (3, 3), + 1, + crate::filter::Border::Constant, + ) +} + +/// `_box_mean_std(plane, win)`: the box mean and the standard deviation over `win` x `win`, +/// `sqrt(max(blur(p * p) - mean * mean, 0))` in float32. +pub fn box_mean_std( + plane: &[f32], + (h, w): (usize, usize), + win: usize, + mean: &mut [f32], + std: &mut [f32], +) -> Option<()> { + use crate::filter::{Border, box_filter}; + let mut sq = vec![0f32; h * w]; + crate::par_zip(&mut sq, plane, |s, &p| *s = p * p); + box_filter(plane, mean, (h, w, 1), (win, win), true, Border::Reflect101)?; + box_filter(&sq, std, (h, w, 1), (win, win), true, Border::Reflect101)?; + crate::par_zip(std, mean, |s, &m| { + let v = *s - m * m; + *s = (if v.is_nan() || v > 0.0 { v } else { 0.0 }).sqrt(); + }); + Some(()) +} + +/// `_u8(plane * gain)`: clipped to 0..1, scaled to 8 bits and rounded half up, in float32. +fn to_u8(plane: &[f32], gain: f32) -> Vec { + plane + .par_iter() + .map(move |&p| { + let v = p * gain; + let v = if v.is_nan() || v > 0.0 { v } else { 0.0 }; + let v = if v.is_nan() || v < 1.0 { v } else { 1.0 }; + (v * 255.0 + 0.5) as u8 + }) + .collect() +} + +/// The scalars of `compute_dust_stats`. +pub struct DustStats<'a> { + /// Median and texture windows. + pub v_win: usize, + pub w_win: usize, + pub disk: crate::morph::Kernel<'a>, + pub min_spread: f64, + pub avg_px: usize, + pub mad_gain: f32, + pub sigma_min: f32, +} + +/// `compute_dust_stats` from the density plane: (proxy, background, z, texture). +pub fn dust_stats(dens: &[f32], (h, w): (usize, usize), c: &DustStats) -> Option<[Vec; 4]> { + use crate::filter::{Border, box_filter}; + let n = h * w; + let q = crate::stats::np_percentiles_f64(dens, &[0.5, 99.5]); + let spread = q[1] - q[0]; + let spread = if c.min_spread > spread { + c.min_spread + } else { + spread + }; + let (lo, spread) = (q[0] as f32, spread as f32); + let mut proxy = vec![0f32; n]; + crate::par_zip(&mut proxy, dens, move |p, &d| { + let v = (d - lo) / spread; + let v = if v.is_nan() || v > 0.0 { v } else { 0.0 }; + *p = if v.is_nan() || v < 1.0 { v } else { 1.0 }; + }); + let mut med = vec![0u8; n]; + crate::median::median_blur_u8(&to_u8(&proxy, 1.0), &mut med, h, w, c.v_win)?; + let mut background = vec![0f32; n]; + crate::morph::morphology_mask( + &proxy, + &mut background, + (h, w, 1), + crate::morph::Op::Open, + crate::morph::Kernel { ..c.disk }, + 1, + Border::Constant, + )?; + background + .par_chunks_mut(BLOCK) + .zip(med.par_chunks(BLOCK)) + .for_each(|(b, m)| { + for (b, &m) in b.iter_mut().zip(m) { + let a = m as f32 / 255.0; + *b = if a.is_nan() || b.is_nan() { + f32::NAN + } else if a > *b { + a + } else { + *b + }; + } + }); + let mut diff = vec![0f32; n]; + diff.par_chunks_mut(BLOCK) + .zip(proxy.par_chunks(BLOCK).zip(background.par_chunks(BLOCK))) + .for_each(|(d, (p, b))| { + for ((d, &p), &b) in d.iter_mut().zip(p).zip(b) { + *d = p - b; + } + }); + let mut z = vec![0f32; n]; + box_filter( + &diff, + &mut z, + (h, w, 1), + (c.avg_px, c.avg_px), + true, + Border::Reflect101, + )?; + let excess_u8: Vec = { + let abs: Vec = z.par_iter().map(|v| v.abs()).collect(); + to_u8(&abs, c.mad_gain) + }; + crate::median::median_blur_u8(&excess_u8, &mut med, h, w, c.w_win)?; + let (div, floor) = (255.0 * c.mad_gain, c.sigma_min); + z.par_chunks_mut(BLOCK) + .zip(med.par_chunks(BLOCK)) + .for_each(|(z, m)| { + for (z, &m) in z.iter_mut().zip(m) { + let sigma = (m as f32 / div) / 0.6745; + let sigma = if sigma.is_nan() || sigma > floor { + sigma + } else { + floor + }; + *z /= sigma; + } + }); + let (mut mean, mut texture) = (vec![0f32; n], vec![0f32; n]); + box_mean_std(&proxy, (h, w), c.w_win, &mut mean, &mut texture)?; + Some([proxy, background, z, texture]) +} + +/// `_is_hair`: thin rather than a speck. Twice the largest L2 (5x5) distance from the edge of +/// the padded component is its thickness; area over thickness squared reads as length over +/// thickness for a ribbon, bent or straight. +fn is_hair( + own: &[u8], + (bh, bw): (usize, usize), + area: usize, + (min_area, min_elong): (usize, f64), +) -> bool { + use crate::distance::{Metric, distance_transform}; + if area < min_area { + return false; + } + let (ph, pw) = (bh + 2, bw + 2); + let mut padded = vec![0u8; ph * pw]; + for y in 0..bh { + padded[(y + 1) * pw + 1..(y + 1) * pw + 1 + bw].copy_from_slice(&own[y * bw..(y + 1) * bw]); + } + let d = distance_transform(&padded, ph, pw, Metric::L2Mask5); + let thickness = 2.0 * d.iter().fold(0f32, |m, &v| m.max(v)) as f64; + let sq = thickness * thickness; + area as f64 / if 1e-6 > sq { 1e-6 } else { sq } >= min_elong +} + +/// Component `i` of `labels` in its bounding box, 1 on its pixels. +fn component_crop( + labels: &[i32], + w: usize, + i: usize, + s: [i32; 5], +) -> (Vec, (usize, usize, usize, usize)) { + let [bx, by, bw, bh, _] = s.map(|v| v as usize); + let mut own = vec![0u8; bw * bh]; + for y in 0..bh { + for x in 0..bw { + own[y * bw + x] = (labels[(by + y) * w + bx + x] == i as i32) as u8; + } + } + (own, (bx, by, bw, bh)) +} + +/// `split_hairs(mask)`: (compact, hairs), hairs `None` when no component is thin enough. +pub fn split_hairs( + mask: &[u8], + (h, w): (usize, usize), + hair: (usize, f64), +) -> (Vec, Option>) { + let cc = crate::components::connected_components(mask, h, w, 8); + let classes: Vec = (1..cc.count) + .into_par_iter() + .map(|i| { + let (own, (_, _, bw, bh)) = component_crop(&cc.labels, w, i, cc.stats[i]); + is_hair(&own, (bh, bw), cc.stats[i][4] as usize, hair) + }) + .collect(); + let (mut compact, mut hairs) = (vec![0u8; h * w], vec![0u8; h * w]); + let mut any_hair = false; + for (p, &l) in cc.labels.iter().enumerate() { + if l > 0 { + if classes[l as usize - 1] { + hairs[p] = 1; + any_hair = true; + } else { + compact[p] = 1; + } + } + } + (compact, any_hair.then_some(hairs)) +} + +/// The scalars of `detect_luma_score`, as Python floats unless noted. +pub struct LumaDetect<'a> { + pub hi: f64, + pub hi_hair: f64, + pub lo: f64, + pub proxy_min: f32, + pub reach_disk: crate::morph::Kernel<'a>, + pub texture_knee: f32, + pub texture_gain: f32, + pub hair: (usize, f64), + pub hair_fill_gain: f32, + pub hair_texture: (f64, f64), + pub texture_win: usize, + pub pad_px: f64, + pub score_floor: f64, +} + +/// A fill score for specks and an inpaint mask for hairs, each `None` when empty. +pub type Detection = (Option>, Option>); + +/// `detect_luma_score` from its stats planes: (score, hair mask). +pub fn detect_luma_score( + stats: [&[f32]; 4], + (h, w): (usize, usize), + c: &LumaDetect, +) -> Option { + let [proxy, background, z, texture] = stats; + let n = h * w; + let seed_bar = c.hi.min(c.hi_hair) as f32; + let (pmin, lo) = (c.proxy_min, c.lo as f32); + let seeds: Vec = z + .par_iter() + .zip(proxy) + .map(move |(&z, &p)| (z >= seed_bar && p > pmin) as u8) + .collect(); + if !seeds.par_iter().any(|&s| s != 0) { + return Some((None, None)); + } + let mut near = vec![0u8; n]; + crate::morph::morphology_mask( + &seeds, + &mut near, + (h, w, 1), + crate::morph::Op::Dilate, + crate::morph::Kernel { ..c.reach_disk }, + 1, + crate::filter::Border::Constant, + )?; + let grow: Vec = near + .par_iter() + .zip(z) + .map(move |(&nv, &z)| (nv != 0 && z >= lo) as u8) + .collect(); + let cc = crate::components::connected_components(&grow, h, w, 8); + let lab = &cc.labels; + let (hi32, knee, gain) = (c.hi as f32, c.texture_knee, c.texture_gain); + let mut strong = vec![false; cc.count]; + let mut peak = vec![0f32; cc.count]; + for p in 0..n { + if seeds[p] == 0 { + continue; + } + let l = lab[p] as usize; + let t = texture[p] - knee; + let bar = hi32 * (1.0 + gain * if t.is_nan() || t > 0.0 { t } else { 0.0 }); + if z[p] >= bar { + strong[l] = true; + } + if z[p] > peak[l] || z[p].is_nan() { + peak[l] = if peak[l].is_nan() { peak[l] } else { z[p] }; + } + } + let candidates: Vec = (1..cc.count).filter(|&i| peak[i] > 0.0).collect(); + let hairy: Vec = candidates + .par_iter() + .map(|&i| { + let (own, (_, _, bw, bh)) = component_crop(lab, w, i, cc.stats[i]); + is_hair(&own, (bh, bw), cc.stats[i][4] as usize, c.hair) + }) + .collect(); + let mut keep = vec![false; cc.count]; + let mut is_hair_label = vec![false; cc.count]; + for (&i, &hr) in candidates.iter().zip(&hairy) { + if hr { + is_hair_label[i] = true; + } else { + keep[i] = strong[i]; + } + } + if is_hair_label.iter().any(|&b| b) { + let filled_src: Vec = (0..n) + .into_par_iter() + .map(|p| { + if is_hair_label[lab[p] as usize] { + background[p] + } else { + proxy[p] + } + }) + .collect(); + let (mut mean, mut filled) = (vec![0f32; n], vec![0f32; n]); + box_mean_std(&filled_src, (h, w), c.texture_win, &mut mean, &mut filled)?; + let fill_gain = c.hair_fill_gain; + let hair_tex: Vec = texture + .par_iter() + .zip(&filled) + .map(move |(&t, &f)| { + let g = fill_gain * f; + if t.is_nan() || g.is_nan() { + f32::NAN + } else if g < t { + g + } else { + t + } + }) + .collect(); + let (hknee, hgain) = c.hair_texture; + for (&i, _) in candidates.iter().zip(&hairy).filter(|(_, h)| **h) { + let [bx, by, bw, bh, _] = cc.stats[i].map(|v| v as usize); + let vals: Vec = (by..by + bh) + .flat_map(|y| (bx..bx + bw).map(move |x| y * w + x)) + .filter(|&p| lab[p] == i as i32) + .map(|p| hair_tex[p]) + .collect(); + let tex = crate::stats::median(&vals) as f64; + let over = tex - hknee; + // Python's max keeps a NaN first argument. + let bar = c.hi_hair * (1.0 + hgain * if 0.0 > over { 0.0 } else { over }); + keep[i] = peak[i] >= bar as f32; + } + } + if !keep.iter().any(|&k| k) { + return Some((None, None)); + } + let hit: Vec = lab.par_iter().map(|&l| keep[l as usize] as u8).collect(); + let (compact, hairs) = split_hairs(&hit, (h, w), c.hair); + let score = compact + .par_iter() + .any(|&v| v != 0) + .then(|| crate::distance::mask_to_score(&compact, h, w, c.pad_px, c.score_floor)); + Some((score, hairs)) +} + +/// `_scratch_ridge` from the density plane: the vertical band-pass (Gaussian of `fine` minus +/// Gaussian of `broad` sigma, down the columns only) over the box mean of its magnitude across +/// `win`, which is 0.8 sigma for Gaussian noise. +pub fn scratch_ridge( + dens: &[f32], + (h, w): (usize, usize), + (fine, broad, win): (f64, f64, usize), + dst: &mut [f32], +) -> Option<()> { + use crate::filter::{Border, box_filter, gaussian_blur}; + let n = h * w; + let (mut a, mut b) = (vec![0f32; n], vec![0f32; n]); + gaussian_blur( + dens, + &mut a, + (h, w, 1), + (1, 0), + (0.0, fine), + Border::Reflect101, + )?; + gaussian_blur( + dens, + &mut b, + (h, w, 1), + (1, 0), + (0.0, broad), + Border::Reflect101, + )?; + crate::par_zip(&mut a, &b, |r, &b| *r -= b); + let abs: Vec = a.par_iter().map(|v| v.abs()).collect(); + box_filter( + &abs, + &mut b, + (h, w, 1), + (win, win), + true, + Border::Reflect101, + )?; + dst.par_chunks_mut(BLOCK) + .zip(a.par_chunks(BLOCK).zip(b.par_chunks(BLOCK))) + .for_each(|(o, (r, s))| { + for ((o, &r), &s) in o.iter_mut().zip(r).zip(s) { + let s = s / 0.8; + *o = r / if s.is_nan() || s > 1e-6 { s } else { 1e-6 }; + } + }); + Some(()) +} + +/// The strongest sheared row of `trace_scratch`'s slope search: (strength, slope index, row, +/// that row). For each slope the ridge band `z` is sheared about `cx` as `_shear_rows` does, +/// rows `rows` of it averaged and weighted by `pull`. The first slope wins a tie. +pub fn scratch_slope_search( + z: &[f32], + (h, w): (usize, usize), + cx: f64, + rows: std::ops::Range, + pull: &[f64], + slopes: &[f64], +) -> Option<(f64, usize, usize, Vec)> { + use crate::warp::{Border, Image, Interp, warp_affine}; + if rows.end > h || pull.len() != rows.len() || z.len() != h * w { + return None; + } + let img = Image { + data: z, + h, + w, + cn: 1, + }; + let found: Vec<(f64, usize, Vec)> = slopes + .par_iter() + .map(|&slope| { + // The matrix goes through float32, as `np.float32([...])` builds it. + let m = [ + 1.0, + 0.0, + 0.0, + -slope as f32 as f64, + 1.0, + (slope * cx) as f32 as f64, + ]; + let mut sheared = vec![0f32; h * w]; + warp_affine( + &img, + (Interp::Linear, Border::Replicate), + m, + false, + w, + &mut sheared, + ); + let mut best = (f64::NEG_INFINITY, 0usize); + for (i, (y, &p)) in rows.clone().zip(pull).enumerate() { + let row = &sheared[y * w..(y + 1) * w]; + let mean = crate::stats::pairwise_sum_f32(row) / w as f32; + let s = mean.abs() as f64 * p; + if s > best.0 || (s.is_nan() && !best.0.is_nan()) { + best = (s, i); + } + } + let y = rows.start + best.1; + (best.0, best.1, sheared[y * w..(y + 1) * w].to_vec()) + }) + .collect(); + let mut best: Option<(f64, usize, usize, Vec)> = None; + for (i, (s, k, row)) in found.into_iter().enumerate() { + if best.as_ref().is_none_or(|b| s > b.0) { + best = Some((s, i, k, row)); + } + } + best +} + +/// One clone stroke's geometry, as `apply_clone_stroke` derives it. +pub struct CloneStroke<'a> { + /// The crop it works in: x0, y0, x1, y1. + pub rect: (usize, usize, usize, usize), + /// Its smoothed path, in crop pixels. + pub path: &'a [(i64, i64)], + pub brush: i64, + pub radius: f64, + pub shift: (i64, i64), + pub sigma: f64, + pub feather: f64, + pub strength: f64, + pub match_tone: bool, +} + +/// `_local_mean(plane, weight, sigma)` of a 3-channel crop: a Gaussian mean over the pixels the +/// weight admits. +fn local_mean( + plane: &[f32], + weight: &[f32], + (h, w): (usize, usize), + sigma: f64, +) -> Option> { + use crate::filter::{Border, gaussian_blur}; + let weighted: Vec = plane + .as_chunks::<3>() + .0 + .iter() + .zip(weight) + .map(|(p, &k)| [p[0] * k, p[1] * k, p[2] * k]) + .collect::>() + .into_flattened(); + let (mut num, mut den) = (vec![0f32; 3 * h * w], vec![0f32; h * w]); + gaussian_blur( + &weighted, + &mut num, + (h, w, 3), + (0, 0), + (sigma, sigma), + Border::Reflect101, + )?; + gaussian_blur( + weight, + &mut den, + (h, w, 1), + (0, 0), + (sigma, sigma), + Border::Reflect101, + )?; + for (n, &d) in num.as_chunks_mut::<3>().0.iter_mut().zip(&den) { + let d = if d.is_nan() || d > 1e-6 { d } else { 1e-6 }; + for v in n { + *v /= d; + } + } + Some(num) +} + +/// `apply_clone_stroke` in place on an HxWx3 image: the source patch, tone-matched to the +/// destination's surround when asked, blended over the brush footprint. +pub fn clone_stroke(out: &mut [f32], (h, w): (usize, usize), s: &CloneStroke) -> Option<()> { + use crate::distance::{Metric, distance_transform}; + let (x0, y0, x1, y1) = s.rect; + let (ch, cw) = (y1 - y0, x1 - x0); + let mut cover = vec![0u8; ch * cw]; + crate::shapes::stroke_cover(&mut cover, cw, ch, s.path, s.brush, 2 * s.brush); + if !cover.iter().any(|&c| c != 0) { + return Some(()); + } + let (sx, sy) = s.shift; + let (hi, wi) = (h as i64, w as i64); + let mut valid = vec![false; ch * cw]; + let (mut src, mut dst) = (vec![0f32; 3 * ch * cw], vec![0f32; 3 * ch * cw]); + for y in 0..ch { + let yy = (y0 + y) as i64 + sy; + let ys = yy.clamp(0, hi - 1) as usize; + for x in 0..cw { + let xx = (x0 + x) as i64 + sx; + let xs = xx.clamp(0, wi - 1) as usize; + let p = y * cw + x; + valid[p] = (0..hi).contains(&yy) && (0..wi).contains(&xx); + src[3 * p..3 * p + 3].copy_from_slice(&out[3 * (ys * w + xs)..3 * (ys * w + xs) + 3]); + let d = 3 * ((y0 + y) * w + x0 + x); + dst[3 * p..3 * p + 3].copy_from_slice(&out[d..d + 3]); + } + } + let mut alpha: Vec = if s.feather > 0.0 { + let depth = distance_transform(&cover, ch, cw, Metric::L2Mask3); + let deepest = depth.iter().fold(0f32, |m, &v| m.max(v)) as f64; + // Python's min and max: the first argument unless the second is strictly past it. + let reach = if deepest < s.radius { + deepest + } else { + s.radius + }; + let denom = s.feather * reach; + let denom = (if 1e-6 > denom { 1e-6 } else { denom }) as f32; + depth + .iter() + .map(|&d| { + let v = d / denom; + let v = if v.is_nan() || v > 0.0 { v } else { 0.0 }; + if v.is_nan() || v < 1.0 { v } else { 1.0 } + }) + .collect() + } else { + cover.iter().map(|&c| c as f32).collect() + }; + // `alpha *= strength * valid` multiplies by a float64 array. + for (a, &v) in alpha.iter_mut().zip(&valid) { + *a = (*a as f64 * (s.strength * v as u8 as f64)) as f32; + } + let mut patch = src.clone(); + if s.match_tone { + let ring: Vec = cover + .iter() + .zip(&valid) + .map(|(&c, &v)| (c == 0 && v) as u8 as f32) + .collect(); + if ring.iter().any(|&r| r != 0.0) { + let near = local_mean(&dst, &ring, (ch, cw), s.sigma)?; + let far = local_mean(&src, &ring, (ch, cw), s.sigma)?; + for ((p, &n), &f) in patch.iter_mut().zip(&near).zip(&far) { + let gain = n / if f.is_nan() || f > 1e-6 { f } else { 1e-6 }; + let gain = if gain.is_nan() || gain > 1.0 / 64.0 { + gain + } else { + 1.0 / 64.0 + }; + let gain = if gain.is_nan() || gain < 64.0 { + gain + } else { + 64.0 + }; + *p *= gain; + } + } + } + for y in 0..ch { + for x in 0..cw { + let p = y * cw + x; + let a = alpha[p]; + let d = 3 * ((y0 + y) * w + x0 + x); + for c in 0..3 { + out[d + c] = dst[3 * p + c] * (1.0 - a) + patch[3 * p + c] * a; + } + } + } + Some(()) +} + +/// `drop_exclusions`' release of a score under the excluded footprint: the score lifted to 1 +/// over `rim` pixels of distance inside the footprint. Returns whether anything stays under 1. +pub fn release_score(score: &mut [f32], cover: &[u8], (h, w): (usize, usize), rim: f32) -> bool { + // The distance only matters inside the footprint, and a one-pixel ring of zeros around its + // box holds every pixel's nearest zero, so the transform runs on that box alone. + let rows: Vec = (0..h) + .filter(|&y| cover[y * w..(y + 1) * w].iter().any(|&c| c != 0)) + .collect(); + if let (Some(&top), Some(&bottom)) = (rows.first(), rows.last()) { + let cols: Vec = (0..w) + .filter(|&x| (top..=bottom).any(|y| cover[y * w + x] != 0)) + .collect(); + let (y0, y1) = (top.saturating_sub(1), (bottom + 2).min(h)); + let (x0, x1) = (cols[0].saturating_sub(1), (cols[cols.len() - 1] + 2).min(w)); + let (ch, cw) = (y1 - y0, x1 - x0); + let crop: Vec = (y0..y1) + .flat_map(|y| &cover[y * w + x0..y * w + x1]) + .copied() + .collect(); + let d = + crate::distance::distance_transform(&crop, ch, cw, crate::distance::Metric::L2Mask3); + for y in 0..ch { + for x in 0..cw { + let a = d[y * cw + x] / rim; + let a = if a.is_nan() || a > 0.0 { a } else { 0.0 }; + let a = if a.is_nan() || a < 1.0 { a } else { 1.0 }; + let s = &mut score[(y0 + y) * w + x0 + x]; + *s += a * (1.0 - *s); + } + } + } + score.par_iter().any(|&s| s < 1.0) +} + +/// The scalars of `strokes_to_score`, as Python floats. +pub struct HealSearch { + pub win: usize, + pub z_min: f64, + pub z_hi: f64, + pub z_grow: f64, + pub z_grow_frac: f64, + pub pad_px: f64, + pub score_floor: f64, + pub rim: f32, +} + +/// One stroke of `strokes_to_score` on its crop: the score of what stands out from the film +/// inside the brush footprint (a two-sided local z-score on density, grown by hysteresis), +/// softened at the footprint's rim. `None` when the brush found nothing. +pub fn heal_region( + dens: &[f32], + (h, w): (usize, usize), + path: &[(i64, i64)], + (radius, thickness): (i64, i64), + c: &HealSearch, +) -> Option> { + use crate::filter::{Border, box_filter}; + let n = h * w; + let mut cover = vec![0u8; n]; + crate::shapes::stroke_cover(&mut cover, w, h, path, radius, thickness); + if !cover.iter().any(|&v| v != 0) { + return None; + } + let mut local = vec![0f32; n]; + box_filter( + dens, + &mut local, + (h, w, 1), + (c.win, c.win), + true, + Border::Reflect101, + )?; + let diff: Vec = dens.iter().zip(&local).map(|(&d, &l)| d - l).collect(); + let mut detail = vec![0f32; n]; + box_filter( + &diff, + &mut detail, + (h, w, 1), + (3, 3), + true, + Border::Reflect101, + )?; + let abs: Vec = detail.iter().map(|v| v.abs()).collect(); + let sigma = crate::stats::median(&abs) as f64 / 0.6745; + let sigma = (if 1e-9 > sigma { 1e-9 } else { sigma }) as f32; + let z: Vec = abs.iter().map(|&a| a / sigma).collect(); + let peak = + z.iter() + .zip(&cover) + .filter(|&(_, &c)| c != 0) + .fold( + f32::NEG_INFINITY, + |m, (&z, _)| if z > m || z.is_nan() { z } else { m }, + ) as f64; + if peak < c.z_min { + return None; + } + let hi = if peak >= c.z_hi { c.z_hi } else { peak * 0.9 }; + let lo = if hi * c.z_grow_frac > c.z_grow { + hi * c.z_grow_frac + } else { + c.z_grow + }; + let (hi32, lo32) = (hi as f32, lo as f32); + let strong: Vec = z + .iter() + .zip(&cover) + .map(|(&z, &c)| c != 0 && z >= hi32) + .collect(); + if !strong.iter().any(|&s| s) { + return None; + } + let grow: Vec = z + .iter() + .zip(&cover) + .map(|(&z, &c)| (c != 0 && z >= lo32) as u8) + .collect(); + let cc = crate::components::connected_components(&grow, h, w, 8); + let mut seeded = vec![false; cc.count]; + for (p, &s) in strong.iter().enumerate() { + if s { + seeded[cc.labels[p] as usize] = true; + } + } + seeded[0] = false; + let keep: Vec = cc + .labels + .iter() + .map(|&l| seeded[l as usize] as u8) + .collect(); + let region = crate::distance::mask_to_score(&keep, h, w, c.pad_px, c.score_floor); + let d = crate::distance::distance_transform(&cover, h, w, crate::distance::Metric::L2Mask3); + Some( + region + .iter() + .zip(&d) + .map(|(&r, &d)| { + let a = d / c.rim; + let a = if a.is_nan() || a > 0.0 { a } else { 0.0 }; + let a = if a.is_nan() || a < 1.0 { a } else { 1.0 }; + 1.0 - a * (1.0 - r) + }) + .collect(), + ) +} + +/// `np.log(np.clip(normalize_ir(img[:, :, c]), 1e-4, 1.0))` stacked over the three channels, +/// NumPy's float32 log included. +pub fn ir_vis_log(img: &[f32], (h, w): (usize, usize), win: usize, dst: &mut [f32]) -> Option<()> { + let n = h * w; + let mut plane = vec![0f32; n]; + let mut norm = vec![0f32; n]; + for c in 0..3 { + plane + .par_iter_mut() + .enumerate() + .for_each(|(p, v)| *v = img[3 * p + c]); + normalize_ir(&plane, None, (h, w), win, &mut norm)?; + dst.par_chunks_mut(3 * BLOCK) + .zip(norm.par_chunks(BLOCK)) + .for_each(|(o, r)| { + for (o, &v) in o.as_chunks_mut::<3>().0.iter_mut().zip(r) { + let v = if v.is_nan() || v > 1e-4 { v } else { 1e-4 }; + let v = if v.is_nan() || v < 1.0 { v } else { 1.0 }; + o[c] = crate::mathf::np_logf(v); + } + }); + } + Some(()) +} + +/// `ir_ratio_and_gain`'s gain: `min(clamp, (clip(ratio / identity, 1e-4, 1)) ** -gamma)` per +/// channel, then held at the local clean base over the pixel, floored at 1. The power is +/// `mathf`'s; NumPy's float32 power runs SVML on an AVX-512 CPU. +pub fn ir_gain( + ratio: &[f32], + img: &[f32], + clean: &[f32], + gammas: [f64; 3], + (identity, clamp): (f32, f32), + dst: &mut [f32], +) { + let neg = gammas.map(|g| -g as f32); + dst.par_chunks_mut(3 * BLOCK) + .zip(ratio.par_chunks(BLOCK)) + .zip(img.par_chunks(3 * BLOCK).zip(clean.par_chunks(3 * BLOCK))) + .for_each(|((o, r), (px, cl))| { + for (i, &r) in r.iter().enumerate() { + let b = r / identity; + let b = if b.is_nan() || b > 1e-4 { b } else { 1e-4 }; + let b = if b.is_nan() || b < 1.0 { b } else { 1.0 }; + for (c, &e) in neg.iter().enumerate() { + let j = 3 * i + c; + let p = crate::mathf::pow_f64(b as f64, e as f64) as f32; + let g = if clamp.is_nan() || p.is_nan() { + f32::NAN + } else if p < clamp { + p + } else { + clamp + }; + let d = px[j]; + let d = if d.is_nan() || d > 1e-5 { d } else { 1e-5 }; + let cap = cl[j] / d; + let cap = if cap.is_nan() || cap > 1.0 { cap } else { 1.0 }; + o[j] = if g.is_nan() || cap.is_nan() { + f32::NAN + } else if cap < g { + cap + } else { + g + }; + } + } + }); +} diff --git a/negpy_core/src/rgbscan.rs b/negpy_core/src/rgbscan.rs new file mode 100644 index 000000000..45618b845 --- /dev/null +++ b/negpy_core/src/rgbscan.rs @@ -0,0 +1,38 @@ +//! Ports of the raw probes in negpy/features/rgbscan/logic.py. + +use rayon::prelude::*; + +use crate::stats::pairwise_sum_f32; + +/// `_channel_means`: per CFA group (R = 0, G = 1 or 3, B = 2) the float32 mean of the mosaic +/// values in row-major order, NumPy's pairwise sum, minus `black` in float64. 0 for an empty group. +pub fn channel_means(mosaic: &[f32], colors: &[u8], black: f64) -> [f64; 3] { + let groups: [&[u8]; 3] = [&[0], &[1, 3], &[2]]; + let means: Vec = groups + .par_iter() + .map(|g| { + let picked: Vec = mosaic + .iter() + .zip(colors) + .filter(|(_, c)| g.contains(c)) + .map(|(&v, _)| v) + .collect(); + if picked.is_empty() { + return 0.0; + } + (pairwise_sum_f32(&picked) / picked.len() as f32) as f64 - black + }) + .collect(); + [means[0], means[1], means[2]] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn groups_greens_and_skips_empty() { + let mosaic = [10.0, 20.0, 30.0, 40.0]; + assert_eq!(channel_means(&mosaic, &[0, 1, 3, 1], 2.0), [8.0, 28.0, 0.0]); + } +} diff --git a/negpy_core/src/seam.rs b/negpy_core/src/seam.rs new file mode 100644 index 000000000..4c5151c52 --- /dev/null +++ b/negpy_core/src/seam.rs @@ -0,0 +1,695 @@ +//! `cv2.detail_DpSeamFinder("COLOR").find(...)` of float32 RGB parts that share one canvas +//! (corners all at the origin), after OpenCV 4.13 `modules/stitching/src/seam_finders.cpp` +//! (Apache-2.0). + +use std::collections::BTreeSet; + +use rayon::prelude::*; + +const FIRST: u8 = 1; +const SECOND: u8 = 2; +const INTERS: u8 = 4; + +/// (x, y) as OpenCV's `Point`. +type Pt = (i32, i32); + +/// The state of one pair: labels over the canvas and, per component, its state, bounding box +/// and contour. +struct Pair<'a> { + w: i32, + h: i32, + img1: &'a [f32], + img2: &'a [f32], + labels: Vec, + contour1: Vec, + contour2: Vec, + states: Vec, + tls: Vec, + brs: Vec, + contours: Vec>, + edges: BTreeSet<(i32, i32)>, +} + +/// `floodFill(img, seed, new_val)` with 4-connectivity and zero tolerance, a row span at a time. +fn flood_fill(img: &mut [i32], (w, h): (i32, i32), seed: Pt, new_val: i32) { + let old = img[(seed.1 * w + seed.0) as usize]; + if old == new_val { + return; + } + let mut stack = vec![seed]; + while let Some((x, y)) = stack.pop() { + let row = &mut img[(y * w) as usize..((y + 1) * w) as usize]; + if row[x as usize] != old { + continue; + } + let (mut x0, mut x1) = (x as usize, x as usize); + while x0 > 0 && row[x0 - 1] == old { + x0 -= 1; + } + while x1 + 1 < w as usize && row[x1 + 1] == old { + x1 += 1; + } + row[x0..=x1].fill(new_val); + for ny in [y - 1, y + 1] { + if ny < 0 || ny >= h { + continue; + } + let next = &img[(ny * w) as usize..((ny + 1) * w) as usize]; + let mut inside = false; + for (nx, &v) in next.iter().enumerate().take(x1 + 1).skip(x0) { + if v == old && !inside { + stack.push((nx as i32, ny)); + } + inside = v == old; + } + } + } +} + +/// The contour of a mask: set pixels with a 4-neighbour unset or off the canvas. +fn contour(mask: &[u8], w: i32, h: i32) -> Vec { + let at = |x: i32, y: i32| mask[(y * w + x) as usize] != 0; + let mut out = vec![false; (w * h) as usize]; + out.par_chunks_mut(w as usize) + .enumerate() + .for_each(|(y, row)| { + let y = y as i32; + for (x, o) in (0..w).zip(row.iter_mut()) { + *o = at(x, y) + && (x == 0 + || !at(x - 1, y) + || x == w - 1 + || !at(x + 1, y) + || y == 0 + || !at(x, y - 1) + || y == h - 1 + || !at(x, y + 1)); + } + }); + out +} + +/// The stitching module's `normL2(Point3f(255, 255, 255), Point3f(0, 0, 0))`, which is the +/// squared norm: the cost of a step outside the component. +fn bad_region_cost() -> f32 { + 3.0 * 255.0 * 255.0 +} + +fn diff(a: &[f32], pa: usize, b: &[f32], pb: usize) -> f32 { + let d = |c: usize| { + let v = a[3 * pa + c] - b[3 * pb + c]; + v * v + }; + d(0) + d(1) + d(2) +} + +/// OpenCV's `cv::partition` with `ClosePoints(min_dist)`: classes numbered in the order of their +/// first point. +fn partition(points: &[Pt], min_dist: i32) -> Vec { + let n = points.len(); + let mut label = vec![usize::MAX; n]; + let mut next = 0; + for i in 0..n { + if label[i] != usize::MAX { + continue; + } + label[i] = next; + let mut stack = vec![i]; + while let Some(a) = stack.pop() { + for b in 0..n { + let (dx, dy) = (points[a].0 - points[b].0, points[a].1 - points[b].1); + if label[b] == usize::MAX && dx * dx + dy * dy < min_dist * min_dist { + label[b] = next; + stack.push(b); + } + } + } + next += 1; + } + label +} + +/// `min_element` of OpenCV's (cost, control) steps that apply: the cheapest, then the lowest +/// control. +fn best_step(steps: &[(bool, f32, u8)]) -> Option<(f32, u8)> { + steps + .iter() + .filter(|s| s.0) + .map(|s| (s.1, s.2)) + .min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))) +} + +fn round_div(sum: i32, n: usize) -> f64 { + (sum as f64 / n as f64).round_ties_even() +} + +impl<'a> Pair<'a> { + fn lab(&self, x: i32, y: i32) -> i32 { + self.labels[(y * self.w + x) as usize] + } + + fn on_contour(&self, x: i32, y: i32, l: i32) -> bool { + let (w, h) = (self.w, self.h); + x == 0 + || self.lab(x - 1, y) != l + || x == w - 1 + || self.lab(x + 1, y) != l + || y == 0 + || self.lab(x, y - 1) != l + || y == h - 1 + || self.lab(x, y + 1) != l + } + + fn touches(&self, x: i32, y: i32, l: i32) -> bool { + (x > 0 && self.lab(x - 1, y) == l) + || (y > 0 && self.lab(x, y - 1) == l) + || (x < self.w - 1 && self.lab(x + 1, y) == l) + || (y < self.h - 1 && self.lab(x, y + 1) == l) + } + + fn find_components(&mut self, m1: &[u8], m2: &[u8]) { + let (w, h) = (self.w, self.h); + self.labels = m1 + .iter() + .zip(m2) + .map(|(&a, &b)| match (a != 0, b != 0) { + (true, true) => i32::MAX, + (true, false) => i32::MAX - 1, + (false, true) => i32::MAX - 2, + _ => 0, + }) + .collect(); + let mut ncomps = 0; + for y in 0..h { + for x in 0..w { + let v = self.lab(x, y); + if v >= i32::MAX - 2 { + self.states.push(match v { + i32::MAX => INTERS, + v if v == i32::MAX - 1 => FIRST, + _ => SECOND, + }); + ncomps += 1; + flood_fill(&mut self.labels, (w, h), (x, y), ncomps); + } + } + } + let n = ncomps as usize; + (self.tls, self.brs) = (vec![(i32::MAX, i32::MAX); n], vec![(i32::MIN, i32::MIN); n]); + self.contours = vec![Vec::new(); n]; + for (l, x, y) in self.contour_points(0..h, 0..w, None) { + self.add_contour_point((l - 1) as usize, x, y); + } + } + + /// The contour pixels of every component (or of label `only`) in the box, in raster order; + /// a component's box is the box of its contour. + fn contour_points( + &self, + ys: std::ops::Range, + xs: std::ops::Range, + only: Option, + ) -> Vec<(i32, i32, i32)> { + let rows: Vec> = ys + .into_par_iter() + .map(|y| { + xs.clone() + .filter_map(|x| { + let l = self.lab(x, y); + let wanted = match only { + Some(o) => l == o, + None => l != 0, + }; + (wanted && self.on_contour(x, y, l)).then_some((l, x, y)) + }) + .collect() + }) + .collect(); + rows.concat() + } + + fn add_contour_point(&mut self, ci: usize, x: i32, y: i32) { + self.tls[ci] = (self.tls[ci].0.min(x), self.tls[ci].1.min(y)); + self.brs[ci] = (self.brs[ci].0.max(x + 1), self.brs[ci].1.max(y + 1)); + self.contours[ci].push((x, y)); + } + + fn find_edges(&mut self) { + for ci in 0..self.contours.len() { + let l = ci as i32 + 1; + for &(x, y) in &self.contours[ci] { + let mut near = [0; 4]; + if x > 0 { + near[0] = self.lab(x - 1, y); + } + if y > 0 { + near[1] = self.lab(x, y - 1); + } + if x < self.w - 1 { + near[2] = self.lab(x + 1, y); + } + if y < self.h - 1 { + near[3] = self.lab(x, y + 1); + } + for n in near { + if n != 0 && n != l { + self.edges.insert((ci as i32, n - 1)); + self.edges.insert((n - 1, ci as i32)); + } + } + } + } + } + + fn has_only_one_neighbor(&self, comp: i32) -> bool { + self.edges + .range((comp, i32::MIN)..=(comp, i32::MAX)) + .count() + == 1 + } + + fn close_to_contour(&self, x: i32, y: i32, mask: &[bool]) -> bool { + (-2..=2).any(|dy| { + let yy = y + dy; + yy >= 0 + && yy < self.h + && (-2..=2).any(|dx| { + let xx = x + dx; + xx >= 0 && xx < self.w && mask[(yy * self.w + xx) as usize] + }) + }) + } + + fn seam_tips(&self, comp1: usize, comp2: usize) -> Option<(Pt, Pt)> { + let l2 = comp2 as i32 + 1; + let special: Vec = self.contours[comp1] + .iter() + .copied() + .filter(|&(x, y)| { + self.close_to_contour(x, y, &self.contour1) + && self.close_to_contour(x, y, &self.contour2) + && self.touches(x, y, l2) + }) + .collect(); + if special.len() < 2 { + return None; + } + let labels = partition(&special, 10); + let n = labels.iter().max()? + 1; + if n < 2 { + return None; + } + let mut sum = vec![(0i32, 0i32); n]; + let mut points: Vec> = vec![Vec::new(); n]; + for (&p, &l) in special.iter().zip(&labels) { + sum[l] = (sum[l].0 + p.0, sum[l].1 + p.1); + points[l].push(p); + } + let centre = |i: usize| { + ( + round_div(sum[i].0, points[i].len()), + round_div(sum[i].1, points[i].len()), + ) + }; + let (mut idx, mut max_dist) = ([0, 0], f64::MIN); + for i in 0..n - 1 { + for j in i + 1..n { + let ((x1, y1), (x2, y2)) = (centre(i), centre(j)); + let d = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2); + if d > max_dist { + max_dist = d; + idx = [i, j]; + } + } + } + let tip = |i: usize| { + let (cx, cy) = centre(i); + let mut best = (f64::MAX, points[i][0]); + for &p in &points[i] { + let d = + (p.0 as f64 - cx) * (p.0 as f64 - cx) + (p.1 as f64 - cy) * (p.1 as f64 - cy); + if d < best.0 { + best = (d, p); + } + } + best.1 + }; + Some((tip(idx[0]), tip(idx[1]))) + } + + /// The vertical and horizontal step costs over the component's box: the mean squared color + /// difference of the two parts across the step, or the bad-region cost. + fn costs(&self, comp: usize) -> (Vec, Vec) { + let l = comp as i32 + 1; + let ((x0, y0), (x1, y1)) = (self.tls[comp], self.brs[comp]); + let bad = bad_region_cost(); + let w = self.w; + let px = |x: i32, y: i32| (y * w + x) as usize; + let (img1, img2) = (self.img1, self.img2); + let step = |x: i32, y: i32, (xb, yb): Pt| { + (diff(img1, px(xb, yb), img2, px(x, y)) + diff(img1, px(x, y), img2, px(xb, yb))) / 2.0 + }; + let cost_v: Vec = (y0..y1) + .into_par_iter() + .flat_map_iter(|y| { + (x0..x1 + 1).map(move |x| { + if x > 0 && x < w && self.lab(x, y) == l && self.lab(x - 1, y) == l { + step(x, y, (x - 1, y)) + } else { + bad + } + }) + }) + .collect(); + let cost_h: Vec = (y0..y1 + 1) + .into_par_iter() + .flat_map_iter(|y| { + (x0..x1).map(move |x| { + if y > 0 && y < self.h && self.lab(x, y) == l && self.lab(x, y - 1) == l { + step(x, y, (x, y - 1)) + } else { + bad + } + }) + }) + .collect(); + (cost_v, cost_h) + } + + /// The minimum-cost path between the tips through the component, or None when the far tip + /// cannot be reached; and whether it runs horizontally. + fn estimate_seam(&self, comp: usize, p1: Pt, p2: Pt) -> Option<(Vec, bool)> { + let (cost_v, cost_h) = self.costs(comp); + let l = comp as i32 + 1; + let (tl, br) = (self.tls[comp], self.brs[comp]); + let (rw, rh) = (br.0 - tl.0, br.1 - tl.1); + let (mut src, mut dst) = ((p1.0 - tl.0, p1.1 - tl.1), (p2.0 - tl.0, p2.1 - tl.1)); + let horizontal = (dst.0 - src.0).abs() > (dst.1 - src.1).abs(); + let swapped = if horizontal { + src.0 > dst.0 + } else { + src.1 > dst.1 + }; + if swapped { + std::mem::swap(&mut src, &mut dst); + } + let n = (rw * rh) as usize; + let at = |x: i32, y: i32| (y * rw + x) as usize; + let cv = |x: i32, y: i32| cost_v[(y * (rw + 1) + x) as usize]; + let ch = |x: i32, y: i32| cost_h[(y * rw + x) as usize]; + let mut control = vec![0u8; n]; + let mut reachable = vec![false; n]; + let mut cost = vec![0f32; n]; + reachable[at(src.0, src.1)] = true; + if horizontal { + for x in src.0 + 1..=dst.0 { + for y in 0..rh { + if self.lab(x + tl.0, y + tl.1) != l { + continue; + } + let (r, c) = (&reachable, &cost); + let steps = [ + (r[at(x - 1, y)], c[at(x - 1, y)] + ch(x - 1, y), 1), + ( + y > 0 && r[at(x - 1, (y - 1).max(0))], + c[at(x - 1, (y - 1).max(0))] + + ch(x - 1, (y - 1).max(0)) + + cv(x, (y - 1).max(0)), + 2, + ), + ( + y < rh - 1 && r[at(x - 1, (y + 1).min(rh - 1))], + c[at(x - 1, (y + 1).min(rh - 1))] + + ch(x - 1, (y + 1).min(rh - 1)) + + cv(x, y), + 3, + ), + ]; + if let Some((c, k)) = best_step(&steps) { + cost[at(x, y)] = c; + control[at(x, y)] = k; + reachable[at(x, y)] = true; + } + } + } + } else { + for y in src.1 + 1..=dst.1 { + for x in 0..rw { + if self.lab(x + tl.0, y + tl.1) != l { + continue; + } + let (r, c) = (&reachable, &cost); + let steps = [ + (r[at(x, y - 1)], c[at(x, y - 1)] + cv(x, y - 1), 1), + ( + x > 0 && r[at((x - 1).max(0), y - 1)], + c[at((x - 1).max(0), y - 1)] + + cv((x - 1).max(0), y - 1) + + ch((x - 1).max(0), y), + 2, + ), + ( + x < rw - 1 && r[at((x + 1).min(rw - 1), y - 1)], + c[at((x + 1).min(rw - 1), y - 1)] + + cv((x + 1).min(rw - 1), y - 1) + + ch(x, y), + 3, + ), + ]; + if let Some((c, k)) = best_step(&steps) { + cost[at(x, y)] = c; + control[at(x, y)] = k; + reachable[at(x, y)] = true; + } + } + } + } + if !reachable[at(dst.0, dst.1)] { + return None; + } + let mut p = dst; + let mut seam = vec![(p.0 + tl.0, p.1 + tl.1)]; + while if horizontal { + p.0 != src.0 + } else { + p.1 != src.1 + } { + let k = control[at(p.0, p.1)]; + if horizontal { + p.1 += (k == 3) as i32 - (k == 2) as i32; + p.0 -= 1; + } else { + p.0 += (k == 3) as i32 - (k == 2) as i32; + p.1 -= 1; + } + seam.push((p.0 + tl.0, p.1 + tl.1)); + } + if !swapped { + seam.reverse(); + } + Some((seam, horizontal)) + } + + fn update_labels_using_seam( + &mut self, + comp1: usize, + comp2: usize, + seam: &[Pt], + horizontal: bool, + ) { + let (tl, br) = (self.tls[comp1], self.brs[comp1]); + let (mw, mh) = (br.0 - tl.0, br.1 - tl.1); + let mut mask = vec![0i32; (mw * mh) as usize]; + let at = |x: i32, y: i32| (y * mw + x) as usize; + for &(x, y) in self.contours[comp1].iter().chain(seam) { + mask[at(x - tl.0, y - tl.1)] = 255; + } + let (l1, l2) = (comp1 as i32 + 1, comp2 as i32 + 1); + let mut ncomps = 0; + for y in 0..mh { + for x in 0..mw { + if mask[at(x, y)] == 0 && self.lab(x + tl.0, y + tl.1) == l1 { + ncomps += 1; + flood_fill(&mut mask, (mw, mh), (x, y), ncomps); + } + } + } + const DX: [i32; 8] = [-1, 1, 0, 0, -1, 1, -1, 1]; + const DY: [i32; 8] = [0, 0, -1, 1, -1, -1, 1, 1]; + for &(cx, cy) in &self.contours[comp1] { + let (x, y) = (cx - tl.0, cy - tl.1); + let mut ok = false; + for j in 0..8 { + let (c, r) = (x + DX[j], y + DY[j]); + if c >= 0 + && c < mw + && r >= 0 + && r < mh + && mask[at(c, r)] != 0 + && mask[at(c, r)] != 255 + { + ok = true; + mask[at(x, y)] = mask[at(c, r)]; + } + } + if !ok { + mask[at(x, y)] = 0; + } + } + for &(sx, sy) in seam { + let (x, y) = (sx - tl.0, sy - tl.1); + let (nx, ny) = if horizontal { (x, y + 1) } else { (x + 1, y) }; + mask[at(x, y)] = + if nx < mw && ny < mh && mask[at(nx, ny)] != 0 && mask[at(nx, ny)] != 255 { + mask[at(nx, ny)] + } else { + 0 + }; + } + let mut connect2 = vec![0usize; ncomps as usize + 1]; + let mut connect_other = vec![0usize; ncomps as usize + 1]; + let (w, h) = (self.w, self.h); + let other = |s: &Self, x: i32, y: i32| { + let v = s.lab(x, y); + v != l1 && v != l2 + }; + for &(x, y) in &self.contours[comp1] { + // A mask value of 255 or 0 here counts nowhere, as in OpenCV's maps. + let m = mask[at(x - tl.0, y - tl.1)]; + let slot = (1..=ncomps).contains(&m).then_some(m as usize); + if self.touches(x, y, l2) + && let Some(s) = slot + { + connect2[s] += 1; + } + let near_other = (x > 0 && other(self, x - 1, y)) + || (y > 0 && other(self, x, y - 1)) + || (x < w - 1 && other(self, x + 1, y)) + || (y < h - 1 && other(self, x, y + 1)); + if near_other && let Some(s) = slot { + connect_other[s] += 1; + } + } + let len = self.contours[comp1].len() as f64; + let adjacent: Vec = (0..=ncomps as usize) + .map(|i| { + i > 0 && connect2[i] as f64 / len > 0.05 && (connect_other[i] as f64 / len) < 0.1 + }) + .collect(); + for y in 0..mh { + for x in 0..mw { + let m = mask[at(x, y)]; + if m > 0 && m <= ncomps && adjacent[m as usize] { + self.labels[((y + tl.1) * w + x + tl.0) as usize] = l2; + } + } + } + } + + fn refresh(&mut self, comp: usize) { + let l = comp as i32 + 1; + let ((x0, y0), (x1, y1)) = (self.tls[comp], self.brs[comp]); + self.tls[comp] = (i32::MAX, i32::MAX); + self.brs[comp] = (i32::MIN, i32::MIN); + self.contours[comp].clear(); + for (_, x, y) in self.contour_points(y0..y1, x0..x1, Some(l)) { + self.add_contour_point(comp, x, y); + } + } + + fn resolve_conflicts(&mut self) { + loop { + let conflict = self.edges.iter().copied().find(|&(c1, c2)| { + let s1 = self.states[c1 as usize]; + s1 & INTERS != 0 && s1 & !INTERS != self.states[c2 as usize] + }); + let Some((c1, c2)) = conflict else { break }; + let (u1, u2) = (c1 as usize, c2 as usize); + if self.has_only_one_neighbor(c1) { + let ((x0, y0), (x1, y1)) = (self.tls[u1], self.brs[u1]); + for y in y0..y1 { + for x in x0..x1 { + let p = (y * self.w + x) as usize; + if self.labels[p] == c1 + 1 { + self.labels[p] = c2 + 1; + } + } + } + self.states[u1] = if self.states[u2] == FIRST { + SECOND + } else { + FIRST + }; + } else { + if let Some((p1, p2)) = self.seam_tips(u1, u2) + && let Some((seam, horizontal)) = self.estimate_seam(u1, p1, p2) + { + self.update_labels_using_seam(u1, u2, &seam, horizontal); + } + self.states[u1] = if self.states[u2] == FIRST { + INTERS | SECOND + } else { + INTERS | FIRST + }; + } + self.refresh(u1); + self.refresh(u2); + self.edges.remove(&(c1, c2)); + self.edges.remove(&(c2, c1)); + } + } +} + +/// One pair of `DpSeamFinder::process`: cut the overlap of `m1` and `m2` between the parts. +fn process(img1: &[f32], img2: &[f32], m1: &mut [u8], m2: &mut [u8], (h, w): (usize, usize)) { + let (w, h) = (w as i32, h as i32); + let mut pair = Pair { + w, + h, + img1, + img2, + labels: Vec::new(), + contour1: contour(m1, w, h), + contour2: contour(m2, w, h), + states: Vec::new(), + tls: Vec::new(), + brs: Vec::new(), + contours: Vec::new(), + edges: BTreeSet::new(), + }; + pair.find_components(m1, m2); + pair.find_edges(); + pair.resolve_conflicts(); + let won = |l: i32, side: u8| l > 0 && pair.states[(l - 1) as usize] & side != 0; + m2.par_iter_mut() + .zip(m1.par_iter()) + .zip(pair.labels.par_iter()) + .for_each(|((b, &a), &l)| { + if won(l, FIRST) && a != 0 { + *b = 0; + } + }); + m1.par_iter_mut() + .zip(m2.par_iter()) + .zip(pair.labels.par_iter()) + .for_each(|((a, &b), &l)| { + if won(l, SECOND) && b != 0 { + *a = 0; + } + }); +} + +/// `DpSeamFinder("COLOR").find(images, [(0, 0)] * n, masks)` of HxWx3 float32 parts on one +/// canvas: each pair cuts its overlap in place, the pairs in reverse order of OpenCV's sort, +/// which leaves pairs of equal centre distance in order for up to 16 pairs. +pub fn dp_seams(images: &[&[f32]], masks: &mut [Vec], (h, w): (usize, usize)) { + let n = images.len(); + let mut pairs: Vec<(usize, usize)> = (0..n) + .flat_map(|i| (i + 1..n).map(move |j| (i, j))) + .collect(); + pairs.reverse(); + for (i, j) in pairs { + let (lo, hi) = masks.split_at_mut(j); + process(images[i], images[j], &mut lo[i], &mut hi[0], (h, w)); + } +} diff --git a/negpy_core/src/sensor.rs b/negpy_core/src/sensor.rs new file mode 100644 index 000000000..c8502f1f3 --- /dev/null +++ b/negpy_core/src/sensor.rs @@ -0,0 +1,283 @@ +//! The sensor unmix kernels of negpy/features/process/sensor.py, in the types Numba gives them: +//! float32 arrays, and float64 where a Python float scalar joins the arithmetic. + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::{exp_f64, ln_f64}; + +pub type Matrix = [[f32; 3]; 3]; + +/// One row of the 3x3 product in float32, contracted as Numba does. +#[inline(always)] +fn row(m: [f32; 3], x: [f32; 3]) -> f32 { + x[2].mul_add(m[2], x[1].mul_add(m[1], m[0] * x[0])) +} + +/// `_unmix_kernel`: the unmix, clipped at zero. +pub fn unmix(src: &[f32], dst: &mut [f32], m: Matrix) { + dst.par_chunks_mut(3 * 4096) + .zip(src.par_chunks(3 * 4096)) + .for_each(|(d, s)| unmix_block(s, d, m)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn unmix_block(src: &[f32], dst: &mut [f32], m: Matrix) { + for (d, &x) in dst + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(src.as_chunks::<3>().0) + { + *d = m.map(|r| { + let v = row(r, x); + if v < 0.0 { 0.0 } else { v } + }); + } +} + +/// `_log_unmix_kernel`: exp(c ln x + offset) per pixel, each channel held at its floor or above. +pub fn log_unmix(src: &[f32], dst: &mut [f32], c: Matrix, offset: [f32; 3], floor: [f32; 3]) { + dst.par_chunks_mut(3 * 4096) + .zip(src.par_chunks(3 * 4096)) + .for_each(|(d, s)| log_unmix_block(s, d, c, offset, floor)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn log_unmix_block(src: &[f32], dst: &mut [f32], c: Matrix, offset: [f32; 3], floor: [f32; 3]) { + for (d, x) in dst + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(src.as_chunks::<3>().0) + { + let l: [f32; 3] = std::array::from_fn(|k| { + let v = if floor[k] > x[k] { floor[k] } else { x[k] }; + ln_f64(v as f64) as f32 + }); + // Numba adds the offset first. + *d = std::array::from_fn(|k| { + let a = l[2].mul_add( + c[k][2], + l[1].mul_add(c[k][1], l[0].mul_add(c[k][0], offset[k])), + ); + exp_f64(a as f64) as f32 + }); + } +} + +/// The scalars of the two-scale layer pass, Python floats (float64) in the kernel. +#[derive(Clone, Copy)] +pub struct Layer { + pub floor: f64, + pub log_floor: f64, + pub start: f64, + pub full: f64, +} + +/// `_two_scale_layer_kernel`: the soft-floored unmix of the blurred capture into `color`, and +/// the blend weight, a smoothstep on the largest unmix gain over its gain at the film base. +pub fn two_scale_layer( + blurred: &[f32], + m: Matrix, + own: [f32; 3], + s: Layer, + color: &mut [f32], + weight: &mut [f32], +) { + color + .par_chunks_mut(3 * 4096) + .zip(weight.par_chunks_mut(4096)) + .zip(blurred.par_chunks(3 * 4096)) + .for_each(|((c, w), b)| layer_block(b, m, own, s, c, w)); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn layer_block( + blurred: &[f32], + m: Matrix, + own: [f32; 3], + s: Layer, + color: &mut [f32], + weight: &mut [f32], +) { + for ((c, w), x) in color + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(weight.iter_mut()) + .zip(blurred.as_chunks::<3>().0) + { + let mut ratio = 0.0f64; + for k in 0..3 { + let u = row(m[k], *x); + let gain = (x[k] * own[k]) as f64 / (u as f64).max(s.log_floor); + ratio = if gain > ratio { gain } else { ratio }; + let f = s.floor * m[k][k] as f64 * x[k] as f64; + let d = u as f64 - f; + c[k] = (0.5 * (u as f64 + f + (d * d + f * f).sqrt())).max(s.log_floor) as f32; + } + let t = ((ratio - s.start) / (s.full - s.start)).clamp(0.0, 1.0); + *w = (t * t * (3.0 - 2.0 * t)) as f32; + } +} + +/// `_two_scale_kernel`: linear, blended by `weight` toward `color` carrying the detail +/// x / blurred through `c`; a channel whose detail sits `log_defect` below both others takes +/// their mean. +#[allow(clippy::too_many_arguments)] +pub fn two_scale( + x: &[f32], + blurred: &[f32], + color: &[f32], + weight: &[f32], + m: Matrix, + c: Matrix, + (floor, log_defect): (f64, f64), + out: &mut [f32], +) { + out.par_chunks_mut(3 * 4096) + .zip(x.par_chunks(3 * 4096)) + .zip(blurred.par_chunks(3 * 4096)) + .zip(color.par_chunks(3 * 4096)) + .zip(weight.par_chunks(4096)) + .for_each(|((((o, x), b), col), w)| { + two_scale_block(x, b, col, w, m, c, (floor, log_defect), o) + }); +} + +#[allow(clippy::too_many_arguments)] +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn two_scale_block( + x: &[f32], + blurred: &[f32], + color: &[f32], + weight: &[f32], + m: Matrix, + c: Matrix, + (floor, log_defect): (f64, f64), + out: &mut [f32], +) { + let (x, b, col) = ( + x.as_chunks::<3>().0, + blurred.as_chunks::<3>().0, + color.as_chunks::<3>().0, + ); + for (i, o) in out.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let wt = weight[i]; + let mut detail = [0f64; 3]; + if wt > 0.0 { + let e: [f64; 3] = std::array::from_fn(|k| { + ln_f64((x[i][k] as f64).max(floor) / (b[i][k] as f64).max(floor)) + }); + let pick = |k: usize, p: usize, q: usize| { + if e[k] < log_defect + e[p].min(e[q]) { + 0.5 * (e[p] + e[q]) + } else { + e[k] + } + }; + detail = [pick(0, 1, 2), pick(1, 0, 2), pick(2, 0, 1)]; + } + *o = std::array::from_fn(|k| { + let v = row(m[k], x[i]); + let mut lin = if v < 0.0 { 0.0 } else { v } as f64; + if wt > 0.0 { + let gain = c[k][0] as f64 * detail[0] + + c[k][1] as f64 * detail[1] + + c[k][2] as f64 * detail[2]; + let capped = col[i][k] as f64 * exp_f64(gain); + lin += wt as f64 * (capped - lin); + } + lin as f32 + }); + } +} + +/// The scalars of `film_base`, as Python floats. +pub struct FilmBase { + pub percentile: f64, + pub clip_plateau: f64, + pub grid_edge: f64, + pub log_floor: f32, + pub share: f64, +} + +/// `film_base` of its analysis crop (h x w x 3): the median log of the cells thinnest in all +/// three channels together, back through `exp`. `None` when the thin end is clipped. The +/// final `exp` of the three medians is libm's; NumPy's float64 exp runs SVML on AVX-512. +pub fn film_base(sample: &[f32], (h, w): (usize, usize), c: &FilmBase) -> Option<[f64; 3]> { + if h * w == 0 { + return None; + } + let clipped = + (0..3).into_par_iter().any(|ch| { + let vals: Vec = sample.iter().skip(ch).step_by(3).copied().collect(); + let Some(p) = crate::stats::np_percentiles(&vals, &[c.percentile]) else { + return true; + }; + let max = vals.iter().copied().fold(f32::NEG_INFINITY, |m, v| { + if v > m || v.is_nan() { v } else { m } + }); + p[0] >= c.clip_plateau as f32 * max + }); + if clipped { + return None; + } + let scale = c.grid_edge / h.max(w) as f64; + let small; + let (cells, (sh, sw)) = if scale < 1.0 { + let dw = ((w as f64 * scale).round_ties_even() as usize).max(1); + let dh = ((h as f64 * scale).round_ties_even() as usize).max(1); + small = crate::resize::resize_area(sample, h, w, 3, dh, dw)?; + (&small[..], (dh, dw)) + } else { + (sample, (h, w)) + }; + let n = sh * sw; + let floor = c.log_floor; + let logs: Vec = cells + .par_iter() + .map(|&v| crate::mathf::np_logf(if v.is_nan() || v > floor { v } else { floor }) as f64) + .collect(); + let q = c.percentile / 100.0; + let thin_end: Vec = (0..3) + .into_par_iter() + .map(|ch| { + let mut col: Vec = logs.iter().skip(ch).step_by(3).copied().collect(); + crate::stats::quantile_f64(&mut col, q).unwrap_or(f64::NAN) + }) + .collect(); + let thin: Vec = (0..n) + .map(|i| { + (0..3).fold(f64::INFINITY, |m, ch| { + let v = logs[3 * i + ch] - thin_end[ch]; + if v < m || v.is_nan() { v } else { m } + }) + }) + .collect(); + let bar = crate::stats::quantile_f64(&mut thin.clone(), 1.0 - c.share)?; + Some(std::array::from_fn(|ch| { + let mut sel: Vec = (0..n) + .filter(|&i| thin[i] >= bar) + .map(|i| logs[3 * i + ch]) + .collect(); + libm::exp(crate::stats::median_f64(&mut sel)) + })) +} diff --git a/negpy_core/src/shapes.rs b/negpy_core/src/shapes.rs new file mode 100644 index 000000000..cc92c3756 --- /dev/null +++ b/negpy_core/src/shapes.rs @@ -0,0 +1,1142 @@ +//! OpenCV's shape helpers for integer contours: convexHull, minAreaRect (rotating calipers), +//! boxPoints, boundingRect of float points, and fillConvexPoly. Ported from convhull.cpp, +//! rotcalipers.cpp, geometry.cpp, types.cpp and drawing.cpp, which build without FMA on +//! x86-64, so plain float arithmetic matches them. + +/// `convexHull(points, clockwise=false, returnPoints=true)` of integer points. +pub fn convex_hull(points: &[(i32, i32)]) -> Vec<(i32, i32)> { + let total = points.len(); + if total == 0 { + return Vec::new(); + } + // Sorted by x, then y, then original position (the comparator's pointer fallback). + let mut order: Vec = (0..total).collect(); + order.sort_by(|&a, &b| { + points[a] + .0 + .cmp(&points[b].0) + .then(points[a].1.cmp(&points[b].1)) + .then(a.cmp(&b)) + }); + let pt = |i: usize| points[order[i]]; + let (mut miny, mut maxy) = (0usize, 0usize); + for i in 1..total { + let y = pt(i).1; + if pt(miny).1 > y { + miny = i; + } + if pt(maxy).1 < y { + maxy = i; + } + } + let mut hull: Vec = Vec::new(); + if pt(0) == pt(total - 1) { + hull.push(0); + } else { + let mut stack = vec![0usize; total + 2]; + let tl_count = sklansky(&pt, 0, maxy as isize, &mut stack[..], -1, 1); + let tl: Vec = stack[..tl_count].to_vec(); + let tr_count = sklansky( + &pt, + total as isize - 1, + maxy as isize, + &mut stack[..], + -1, + -1, + ); + let tr: Vec = stack[..tr_count].to_vec(); + // Counter-clockwise: the right chain first. + let (tl, tr) = (tr, tl); + for &i in &tl[..tl.len().saturating_sub(1)] { + hull.push(i); + } + for i in (1..tr.len()).rev() { + hull.push(tr[i]); + } + let stop_idx: isize = if tr.len() > 2 { + tr[1] as isize + } else if tl.len() > 2 { + tl[tl.len() - 2] as isize + } else { + -1 + }; + let bl_count = sklansky(&pt, 0, miny as isize, &mut stack[..], 1, -1); + let bl: Vec = stack[..bl_count].to_vec(); + let br_count = sklansky(&pt, total as isize - 1, miny as isize, &mut stack[..], 1, 1); + let br: Vec = stack[..br_count].to_vec(); + let (mut blc, mut brc) = (bl.len(), br.len()); + if stop_idx >= 0 { + let check_idx: isize = if blc > 2 { + bl[1] as isize + } else if blc + brc > 2 { + br[2 - blc] as isize + } else { + -1 + }; + if check_idx == stop_idx + || (check_idx >= 0 && pt(check_idx as usize) == pt(stop_idx as usize)) + { + blc = blc.min(2); + brc = brc.min(2); + } + } + for &i in &bl[..blc.saturating_sub(1)] { + hull.push(i); + } + for i in (1..brc).rev() { + hull.push(br[i]); + } + } + let mut idx: Vec = hull.iter().map(|&i| order[i]).collect(); + // Rotate toward an ascending or descending index sequence, as OpenCV does. + let nout = idx.len(); + if nout >= 3 { + let (mut min_idx, mut max_idx, mut lt) = (0usize, 0usize, 0usize); + for i in 1..nout { + let v = idx[i]; + lt += (idx[i - 1] < v) as usize; + if lt > 1 && lt + 2 <= i { + break; + } + if v < idx[min_idx] { + min_idx = i; + } + if v > idx[max_idx] { + max_idx = i; + } + } + let mmdist = max_idx.abs_diff(min_idx); + if (mmdist == 1 || mmdist == nout - 1) && (lt <= 1 || lt + 2 >= nout) { + let ascending = (max_idx + 1) % nout == min_idx; + let i0 = if ascending { min_idx } else { max_idx }; + if i0 > 0 { + let mut out = Vec::with_capacity(nout); + let mut j = i0; + let mut complete = true; + for i in 0..nout { + let curr = idx[j]; + out.push(curr); + let next_j = if j + 1 < nout { j + 1 } else { 0 }; + if i < nout - 1 && (ascending != (curr < idx[next_j])) { + complete = false; + break; + } + j = next_j; + } + if complete { + idx = out; + } + } + } + } + idx.iter().map(|&i| points[i]).collect() +} + +/// `Sklansky_`: one monotone chain between `start` and `end` (sorted indices). +fn sklansky( + pt: &dyn Fn(usize) -> (i32, i32), + start: isize, + end: isize, + stack: &mut [usize], + nsign: i32, + sign2: i32, +) -> usize { + let incr: isize = if end > start { 1 } else { -1 }; + let (mut pprev, mut pcur) = (start, start + incr); + let mut pnext = pcur + incr; + let mut size = 3usize; + let p = |i: isize| pt(i as usize); + if start == end || p(start) == p(end) { + stack[0] = start as usize; + return 1; + } + stack[0] = pprev as usize; + stack[1] = pcur as usize; + stack[2] = pnext as usize; + let end = end + incr; + while pnext != end { + let (cury, nexty) = (p(pcur).1, p(pnext).1); + let by = nexty - cury; + if by.signum() != nsign { + let a = (p(pcur).0 - p(pprev).0, cury - p(pprev).1); + let b = (p(pnext).0 - p(pcur).0, by); + let convexity = a.1 as i64 * b.0 as i64 - a.0 as i64 * b.1 as i64; + if convexity.signum() as i32 == sign2 && (a.0 != 0 || a.1 != 0) { + pprev = pcur; + pcur = pnext; + pnext += incr; + stack[size] = pnext as usize; + size += 1; + } else if pprev == start { + pcur = pnext; + stack[1] = pcur as usize; + pnext += incr; + stack[2] = pnext as usize; + } else { + stack[size - 2] = pnext as usize; + pcur = pprev; + pprev = stack[size - 4] as isize; + size -= 1; + } + } else { + pnext += incr; + stack[size - 1] = pnext as usize; + } + } + size - 1 +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RotatedRect { + pub center: (f32, f32), + pub size: (f32, f32), + pub angle: f32, +} + +fn right_of(v1: (f32, f32), v2: (f32, f32)) -> bool { + // rotate90CW(v1) . v2 < 0 + v1.1 * v2.0 + (-v1.0) * v2.1 < 0.0 +} + +/// `cv2.minAreaRect` of integer points. +pub fn min_area_rect(points: &[(i32, i32)]) -> RotatedRect { + let hull: Vec<(f32, f32)> = convex_hull(points) + .iter() + .map(|&(x, y)| (x as f32, y as f32)) + .collect(); + let n = hull.len(); + let mut angle = -std::f64::consts::PI / 2.0; + let mut rect = RotatedRect { + center: (0.0, 0.0), + size: (0.0, 0.0), + angle: 0.0, + }; + if n > 2 { + let out = rotating_calipers(&hull); + rect.center = ( + out[0].0 + (out[1].0 + out[2].0) * 0.5, + out[0].1 + (out[1].1 + out[2].1) * 0.5, + ); + rect.size.0 = + ((out[2].0 as f64 * out[2].0 as f64 + out[2].1 as f64 * out[2].1 as f64).sqrt()) as f32; + rect.size.1 = + ((out[1].0 as f64 * out[1].0 as f64 + out[1].1 as f64 * out[1].1 as f64).sqrt()) as f32; + if out[1].0 == 0.0 && out[1].1 > 0.0 { + rect.size = (rect.size.1, rect.size.0); + } else { + angle = -libm::atan2(out[1].0 as f64, out[1].1 as f64); + } + } else if n == 2 { + rect.center = ((hull[0].0 + hull[1].0) * 0.5, (hull[0].1 + hull[1].1) * 0.5); + let dx = hull[0].0 as f64 - hull[1].0 as f64; + let dy = hull[0].1 as f64 - hull[1].1 as f64; + rect.size = (0.0, (dx * dx + dy * dy).sqrt() as f32); + if dx == 0.0 { + rect.size = (rect.size.1, rect.size.0); + } else if dy < 0.0 { + angle = libm::atan2(dy, dx); + rect.size = (rect.size.1, rect.size.0); + } else if dy > 0.0 { + angle = -libm::atan2(dx, dy); + } + } else if n == 1 { + rect.center = hull[0]; + } + rect.angle = (angle * 180.0 / std::f64::consts::PI) as f32; + rect +} + +/// `rotatingCalipers(..., CALIPERS_MINAREARECT)` for a counter-clockwise hull: the corner point +/// and the two side vectors. +fn rotating_calipers(points: &[(f32, f32)]) -> [(f32, f32); 3] { + let n = points.len(); + let mut vect = vec![(0f32, 0f32); n]; + let mut inv_len = vec![0f32; n]; + let (mut left, mut bottom, mut right, mut top) = (0usize, 0usize, 0usize, 0usize); + let mut pt0 = points[0]; + let (mut left_x, mut right_x, mut top_y, mut bottom_y) = (pt0.0, pt0.0, pt0.1, pt0.1); + for i in 0..n { + if pt0.0 < left_x { + left_x = pt0.0; + left = i; + } + if pt0.0 > right_x { + right_x = pt0.0; + right = i; + } + if pt0.1 > top_y { + top_y = pt0.1; + top = i; + } + if pt0.1 < bottom_y { + bottom_y = pt0.1; + bottom = i; + } + let pt = points[if i + 1 < n { i + 1 } else { 0 }]; + let dx = pt.0 as f64 - pt0.0 as f64; + let dy = pt.1 as f64 - pt0.1 as f64; + vect[i] = (dx as f32, dy as f32); + inv_len[i] = (1.0 / (dx * dx + dy * dy).sqrt()) as f32; + pt0 = pt; + } + let mut seq = [bottom, right, top, left]; + let mut minarea = f32::MAX; + let mut best_left = 0usize; + let mut best_bottom = 0usize; + let (mut ba, mut bw, mut bb, mut bh) = (0f32, 0f32, 0f32, 0f32); + for _ in 0..n { + let rot = [ + vect[seq[0]], + (vect[seq[1]].1, -vect[seq[1]].0), + (-vect[seq[2]].0, -vect[seq[2]].1), + (-vect[seq[3]].1, vect[seq[3]].0), + ]; + let mut main = 0usize; + for i in 1..4 { + if right_of(rot[i], rot[main]) { + main = i; + } + } + let pindex = seq[main]; + let lead_x = vect[pindex].0 * inv_len[pindex]; + let lead_y = vect[pindex].1 * inv_len[pindex]; + let (base_a, base_b) = match main { + 0 => (lead_x, lead_y), + 1 => (lead_y, -lead_x), + 2 => (-lead_x, -lead_y), + _ => (-lead_y, lead_x), + }; + seq[main] = if seq[main] + 1 == n { 0 } else { seq[main] + 1 }; + let dx = points[seq[1]].0 - points[seq[3]].0; + let dy = points[seq[1]].1 - points[seq[3]].1; + let width = dx * base_a + dy * base_b; + let dx = points[seq[2]].0 - points[seq[0]].0; + let dy = points[seq[2]].1 - points[seq[0]].1; + let height = -dx * base_b + dy * base_a; + let area = width * height; + if area <= minarea { + minarea = area; + best_left = seq[3]; + ba = base_a; + bw = width; + bb = base_b; + bh = height; + best_bottom = seq[0]; + } + } + let (a1, b1) = (ba, bb); + let (a2, b2) = (-bb, ba); + let c1 = a1 * points[best_left].0 + points[best_left].1 * b1; + let c2 = a2 * points[best_bottom].0 + points[best_bottom].1 * b2; + let idet = 1.0f32 / (a1 * b2 - a2 * b1); + let px = (c1 * b2 - c2 * b1) * idet; + let py = (a1 * c2 - a2 * c1) * idet; + [(px, py), (a1 * bw, b1 * bw), (a2 * bh, b2 * bh)] +} + +/// `cv2.boxPoints(rect)` (`RotatedRect::points`); cos and sin are libm's (see LEDGER.md). +pub fn box_points(r: &RotatedRect) -> [(f32, f32); 4] { + let a_rad = r.angle as f64 * std::f64::consts::PI / 180.0; + let b = libm::cos(a_rad) as f32 * 0.5; + let a = libm::sin(a_rad) as f32 * 0.5; + let (cx, cy) = r.center; + let (w, h) = r.size; + let p0 = (cx - a * h - b * w, cy + b * h - a * w); + let p1 = (cx + a * h - b * w, cy - b * h - a * w); + let p2 = (2.0 * cx - p0.0, 2.0 * cy - p0.1); + let p3 = (2.0 * cx - p1.0, 2.0 * cy - p1.1); + [p0, p1, p2, p3] +} + +/// `cv2.boundingRect` of float points: floors of the extremes, (x, y, width, height). +pub fn bounding_rect_f32(pts: &[(f32, f32)]) -> (i32, i32, i32, i32) { + if pts.is_empty() { + return (0, 0, 0, 0); + } + let floor = |v: f32| v.floor() as i32; + let (mut x0, mut y0, mut x1, mut y1) = ( + floor(pts[0].0), + floor(pts[0].1), + floor(pts[0].0), + floor(pts[0].1), + ); + for &(x, y) in pts { + x0 = x0.min(floor(x)); + y0 = y0.min(floor(y)); + x1 = x1.max(floor(x)); + y1 = y1.max(floor(y)); + } + (x0, y0, x1 - x0 + 1, y1 - y0 + 1) +} + +const XY_SHIFT: i64 = 16; +const XY_ONE: i64 = 1 << XY_SHIFT; + +/// `clipLine(Size2l, pt1, pt2)`: false when the segment misses the image. +fn clip_line(w: i64, h: i64, p1: &mut (i64, i64), p2: &mut (i64, i64)) -> bool { + let (right, bottom) = (w - 1, h - 1); + if w <= 0 || h <= 0 { + return false; + } + let code = |p: &(i64, i64)| { + (p.0 < 0) as i32 + + (p.0 > right) as i32 * 2 + + (p.1 < 0) as i32 * 4 + + (p.1 > bottom) as i32 * 8 + }; + let (mut c1, mut c2) = (code(p1), code(p2)); + if (c1 & c2) == 0 && (c1 | c2) != 0 { + if c1 & 12 != 0 { + let a = if c1 < 8 { 0 } else { bottom }; + p1.0 += ((a - p1.1) as f64 * (p2.0 - p1.0) as f64 / (p2.1 - p1.1) as f64) as i64; + p1.1 = a; + c1 = (p1.0 < 0) as i32 + (p1.0 > right) as i32 * 2; + } + if c2 & 12 != 0 { + let a = if c2 < 8 { 0 } else { bottom }; + p2.0 += ((a - p2.1) as f64 * (p2.0 - p1.0) as f64 / (p2.1 - p1.1) as f64) as i64; + p2.1 = a; + c2 = (p2.0 < 0) as i32 + (p2.0 > right) as i32 * 2; + } + if (c1 & c2) == 0 && (c1 | c2) != 0 { + if c1 != 0 { + let a = if c1 == 1 { 0 } else { right }; + p1.1 += ((a - p1.0) as f64 * (p2.1 - p1.1) as f64 / (p2.0 - p1.0) as f64) as i64; + p1.0 = a; + c1 = 0; + } + if c2 != 0 { + let a = if c2 == 1 { 0 } else { right }; + p2.1 += ((a - p2.0) as f64 * (p2.1 - p1.1) as f64 / (p2.0 - p1.0) as f64) as i64; + p2.0 = a; + c2 = 0; + } + } + } + (c1 | c2) == 0 +} + +/// `Line(img, pt1, pt2, color, 8)`: an 8-connected line drawn left to right. +fn draw_line(mask: &mut [u8], w: usize, h: usize, pt1: (i64, i64), pt2: (i64, i64), color: u8) { + let (mut p1, mut p2) = (pt1, pt2); + let outside = |p: &(i64, i64)| p.0 < 0 || p.0 >= w as i64 || p.1 < 0 || p.1 >= h as i64; + if (outside(&p1) || outside(&p2)) && !clip_line(w as i64, h as i64, &mut p1, &mut p2) { + return; + } + let (mut dx, mut dy) = (p2.0 - p1.0, p2.1 - p1.1); + let (mut delta_x, mut delta_y) = (1i64, 1i64); + if dx < 0 { + dx = -dx; + dy = -dy; + p1 = p2; + } + if dy < 0 { + dy = -dy; + delta_y = -1; + } + let vert = dy > dx; + if vert { + std::mem::swap(&mut dx, &mut dy); + std::mem::swap(&mut delta_x, &mut delta_y); + } + let mut err = dx - (dy + dy); + let plus_delta = dx + dx; + let minus_delta = -(dy + dy); + // (x, y) moves. OpenCV swaps step and shift for a steep line, after swapping the deltas. + let (minus, plus) = if vert { + ((0, delta_x), (delta_y, 0)) + } else { + ((delta_x, 0), (0, delta_y)) + }; + let (mut x, mut y) = p1; + for _ in 0..=dx { + if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h { + mask[y as usize * w + x as usize] = color; + } + let m = err < 0; + err += minus_delta + if m { plus_delta } else { 0 }; + x += minus.0 + if m { plus.0 } else { 0 }; + y += minus.1 + if m { plus.1 } else { 0 }; + } +} + +/// `cv2.fillConvexPoly(mask, pts, color)` (8-connected outline, shift 0) on a w x h uint8 mask. +pub fn fill_convex_poly(mask: &mut [u8], w: usize, h: usize, v: &[(i64, i64)], color: u8) { + fill_convex_poly_shift(mask, w, h, v, color, 0); +} + +/// OpenCV's FillConvexPoly with points of `shift` fractional bits, 8-connected. +fn fill_convex_poly_shift( + mask: &mut [u8], + w: usize, + h: usize, + v: &[(i64, i64)], + color: u8, + shift: i64, +) { + let npts = v.len(); + if npts == 0 { + return; + } + let delta1 = XY_ONE >> 1; + let delta2 = XY_ONE >> 1; + let delta = (1i64 << shift) >> 1; + let up = XY_SHIFT - shift; + let mut p0 = (v[npts - 1].0 << up, v[npts - 1].1 << up); + let (mut xmin, mut xmax, mut ymin, mut ymax) = (v[0].0, v[0].0, v[0].1, v[0].1); + let mut imin = 0usize; + for (i, &q) in v.iter().enumerate() { + if q.1 < ymin { + ymin = q.1; + imin = i; + } + ymax = ymax.max(q.1); + xmax = xmax.max(q.0); + xmin = xmin.min(q.0); + let p = (q.0 << up, q.1 << up); + if shift == 0 { + draw_line( + mask, + w, + h, + (p0.0 >> XY_SHIFT, p0.1 >> XY_SHIFT), + (p.0 >> XY_SHIFT, p.1 >> XY_SHIFT), + color, + ); + } else { + line2(mask, w, h, p0, p, color); + } + p0 = p; + } + let (xmin, xmax) = ((xmin + delta) >> shift, (xmax + delta) >> shift); + let (ymin, ymax) = ((ymin + delta) >> shift, (ymax + delta) >> shift); + if npts < 3 || xmax < 0 || ymax < 0 || xmin >= w as i64 || ymin >= h as i64 { + return; + } + let ymax = ymax.min(h as i64 - 1); + #[derive(Clone, Copy)] + struct Edge { + idx: usize, + di: usize, + x: i64, + dx: i64, + ye: i64, + } + let mut edge = [ + Edge { + idx: imin, + di: 1, + x: -XY_ONE, + dx: 0, + ye: ymin, + }, + Edge { + idx: imin, + di: npts - 1, + x: -XY_ONE, + dx: 0, + ye: ymin, + }, + ]; + let mut edges = npts as i64; + let mut y = ymin; + loop { + for e in edge.iter_mut() { + if y >= e.ye { + let mut idx0 = e.idx; + let mut idx = (idx0 + e.di) % npts; + loop { + let go = edges > 0; + edges -= 1; + if !go { + break; + } + let ty = (v[idx].1 + delta) >> shift; + if ty > y { + let xs = v[idx0].0 << up; + let xe = v[idx].0 << up; + e.ye = ty; + e.dx = ((xe - xs) * 2 + (ty - y)) / (2 * (ty - y)); + e.x = xs; + e.idx = idx; + break; + } + idx0 = idx; + idx = (idx + e.di) % npts; + } + } + } + if edges < 0 { + break; + } + if y >= 0 { + let (left, right) = if edge[0].x > edge[1].x { + (1, 0) + } else { + (0, 1) + }; + let mut xx1 = (edge[left].x + delta1) >> XY_SHIFT; + let mut xx2 = (edge[right].x + delta2) >> XY_SHIFT; + if xx2 >= 0 && xx1 < w as i64 { + xx1 = xx1.max(0); + xx2 = xx2.min(w as i64 - 1); + let row = y as usize * w; + mask[row + xx1 as usize..=row + xx2 as usize].fill(color); + } + } + edge[0].x += edge[0].dx; + edge[1].x += edge[1].dx; + y += 1; + if y > ymax { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hull_of_a_square_with_inner_points() { + let pts = [(0, 0), (5, 5), (10, 0), (10, 10), (0, 10), (3, 4)]; + let hull = convex_hull(&pts); + assert_eq!(hull.len(), 4); + } + + #[test] + fn axis_aligned_rect() { + let pts = [(2, 3), (12, 3), (12, 8), (2, 8)]; + let r = min_area_rect(&pts); + assert_eq!(r.center, (7.0, 5.5)); + let mut s = [r.size.0, r.size.1]; + s.sort_by(f32::total_cmp); + assert_eq!(s, [5.0, 10.0]); + } + + #[test] + fn filled_square() { + let mut mask = vec![0u8; 10 * 10]; + fill_convex_poly(&mut mask, 10, 10, &[(2, 2), (6, 2), (6, 6), (2, 6)], 1); + assert_eq!(mask.iter().filter(|&&v| v == 1).count(), 25); + } +} + +#[derive(Clone, Copy)] +struct PolyEdge { + y0: i64, + y1: i64, + x: i64, + dx: i64, + next: usize, +} + +/// `cv2.fillPoly(mask, [pts], color)` for one contour (8-connected outline, shift 0): OpenCV's +/// CollectPolyEdges and FillEdgeCollection, the active edge list kept as indices. +pub fn fill_poly(mask: &mut [u8], w: usize, h: usize, v: &[(i64, i64)], color: u8) { + const NONE: usize = usize::MAX; + let count = v.len(); + if count == 0 { + return; + } + let mut edges: Vec = Vec::with_capacity(count + 1); + let mut pt0 = (v[count - 1].0 << XY_SHIFT, v[count - 1].1); + for &p in v { + let pt1 = (p.0 << XY_SHIFT, p.1); + let (mut pt0c, mut pt1c) = (pt0, pt1); + let mut t0 = ((pt0.0 + (XY_ONE >> 1)) >> XY_SHIFT, pt0.1); + let mut t1 = ((pt1.0 + (XY_ONE >> 1)) >> XY_SHIFT, pt1.1); + draw_line(mask, w, h, t0, t1, color); + let out = |q: (i64, i64)| q.0 < 0 || q.0 >= w as i64 || q.1 < 0 || q.1 >= h as i64; + if out(t0) || out(t1) { + clip_line(w as i64, h as i64, &mut t0, &mut t1); + if t0.1 != t1.1 { + pt0c.1 = t0.1; + pt1c.1 = t1.1; + } + } + pt0c.0 = t0.0 << XY_SHIFT; + pt1c.0 = t1.0 << XY_SHIFT; + if pt0.1 != pt1.1 { + let dx = (pt1c.0 - pt0c.0) / (pt1c.1 - pt0c.1); + let (y0, y1, x) = if pt0.1 < pt1.1 { + (pt0.1, pt1.1, pt0c.0 + (pt0.1 - pt0c.1) * dx) + } else { + (pt1.1, pt0.1, pt1c.0 + (pt1.1 - pt1c.1) * dx) + }; + edges.push(PolyEdge { + y0, + y1, + x, + dx, + next: NONE, + }); + } + pt0 = pt1; + } + + let total = edges.len(); + if total < 2 { + return; + } + let (mut y_max, mut y_min) = (i64::MIN, i64::MAX); + let (mut x_max, mut x_min) = (-1i64, i64::MAX); + for e in &edges { + let x1 = e.x + (e.y1 - e.y0) * e.dx; + y_min = y_min.min(e.y0); + y_max = y_max.max(e.y1); + x_min = x_min.min(e.x).min(x1); + x_max = x_max.max(e.x).max(x1); + } + if y_max < 0 || y_min >= h as i64 || x_max < 0 || x_min >= (w as i64) << XY_SHIFT { + return; + } + edges.sort_by_key(|e| (e.y0, e.x, e.dx)); + edges.push(PolyEdge { + y0: i64::MAX, + y1: 0, + x: 0, + dx: 0, + next: NONE, + }); + // Slot `total + 1` is the list head (OpenCV's `tmp`). + let head = total + 1; + edges.push(PolyEdge { + y0: i64::MAX, + y1: 0, + x: 0, + dx: 0, + next: NONE, + }); + let delta = XY_ONE - 1; + let mut i = 0; + let y_max = y_max.min(h as i64); + let mut y = edges[0].y0; + while y < y_max { + let mut draw = false; + let clipline = y < 0; + let mut prelast = head; + let mut last = edges[head].next; + while last != NONE || edges[i].y0 == y { + if last != NONE && edges[last].y1 == y { + edges[prelast].next = edges[last].next; + last = edges[last].next; + continue; + } + let keep_prelast = prelast; + if last != NONE && (edges[i].y0 > y || edges[last].x < edges[i].x) { + prelast = last; + last = edges[last].next; + } else if i < total { + edges[prelast].next = i; + edges[i].next = last; + prelast = i; + i += 1; + } else { + break; + } + if draw { + if !clipline { + let (a, b) = (edges[keep_prelast].x, edges[prelast].x); + let (mut x1, mut x2) = if a > b { + ((b + delta) >> XY_SHIFT, a >> XY_SHIFT) + } else { + ((a + delta) >> XY_SHIFT, b >> XY_SHIFT) + }; + if x1 < w as i64 && x2 >= 0 { + x1 = x1.max(0); + x2 = x2.min(w as i64 - 1); + let row = y as usize * w; + mask[row + x1 as usize..=row + x2 as usize].fill(color); + } + } + edges[keep_prelast].x += edges[keep_prelast].dx; + edges[prelast].x += edges[prelast].dx; + } + draw = !draw; + } + + // Bubble sort of the active list by x. + let mut keep_prelast = NONE; + loop { + let mut prelast = head; + let mut last = edges[head].next; + let mut last_exchange = NONE; + while last != keep_prelast && last != NONE && edges[last].next != NONE { + let te = edges[last].next; + if edges[last].x > edges[te].x { + edges[prelast].next = te; + edges[last].next = edges[te].next; + edges[te].next = last; + prelast = te; + last_exchange = prelast; + } else { + prelast = last; + last = te; + } + } + if last_exchange == NONE { + break; + } + keep_prelast = last_exchange; + if keep_prelast == edges[head].next || keep_prelast == head { + break; + } + } + y += 1; + } +} + +/// One mask of `compute_local_maps`, its outline already in pixels. +pub enum MaskAlpha { + /// A filled outline, feathered by a Gaussian of this sigma in pixels. + Fill { + outline: Vec<(i64, i64)>, + sigma: f64, + }, + /// `_rasterise_ramp`: 1 at (ax, ay), 0 at (ax + dx, ay + dy), the offsets over their + /// squared length. + Ramp { ax: f32, ay: f32, kx: f32, ky: f32 }, + /// A ramp whose two points coincide. + Empty, +} + +/// `rasterise` into `alpha` (h x w float32). +pub fn rasterise( + mask: &MaskAlpha, + (h, w): (usize, usize), + invert: bool, + alpha: &mut [f32], +) -> Option<()> { + use rayon::prelude::*; + match mask { + MaskAlpha::Fill { outline, sigma } => { + let mut filled = vec![0u8; h * w]; + fill_poly(&mut filled, w, h, outline, 255); + let hard: Vec = filled.par_iter().map(|&v| v as f32 / 255.0).collect(); + if *sigma > 1e-3 { + let k = (sigma * 3.0) as usize | 1; + crate::filter::gaussian_blur( + &hard, + alpha, + (h, w, 1), + (k, k), + (*sigma, *sigma), + crate::filter::Border::Reflect101, + )?; + } else { + alpha.copy_from_slice(&hard); + } + } + &MaskAlpha::Ramp { ax, ay, kx, ky } => { + let xs: Vec = (0..w).map(|x| (x as f32 - ax) * kx).collect(); + alpha.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let yv = (y as f32 - ay) * ky; + for (a, &xv) in row.iter_mut().zip(&xs) { + let t = xv + yv; + let t = if t.is_nan() || t > 0.0 { t } else { 0.0 }; + let t = if t.is_nan() || t < 1.0 { t } else { 1.0 }; + *a = 1.0 - t * t * (3.0 - 2.0 * t); + } + }); + } + MaskAlpha::Empty => alpha.fill(0.0), + } + if invert { + alpha.par_iter_mut().for_each(|a| *a = 1.0 - *a); + } + Some(()) +} + +/// One mask of `compute_local_maps`: its alpha, and either its own plane (a tone-limited +/// mask) or the stops, grade and flash it adds to planes 0-2. +pub struct LocalMask { + pub alpha: MaskAlpha, + pub invert: bool, + pub plane: Option, + pub stops: f32, + pub grade: f32, + pub flash: f32, +} + +/// `compute_local_maps`' planes (h x w x planes float32) from masks in pixel space. +/// `overlapping_masks`' test: whether any pixel has both alphas above `threshold`. +pub fn alphas_overlap(a: &[f32], b: &[f32], threshold: f32) -> bool { + a.iter() + .zip(b) + .any(|(&p, &q)| p > threshold && q > threshold) +} + +pub fn local_maps( + masks: &[LocalMask], + (h, w): (usize, usize), + planes: usize, + maps: &mut [f32], +) -> Option<()> { + use rayon::prelude::*; + maps.fill(0.0); + let mut alpha = vec![0f32; h * w]; + for m in masks { + rasterise(&m.alpha, (h, w), m.invert, &mut alpha)?; + let (stops, grade, flash, plane) = (m.stops, m.grade, m.flash, m.plane); + maps.par_chunks_mut(planes * 1024) + .zip(alpha.par_chunks(1024)) + .for_each(|(px, a)| { + for (p, &a) in px.chunks_exact_mut(planes).zip(a) { + match plane { + Some(k) => p[3 + k] = a, + None => { + p[0] += stops * a; + if grade != 0.0 { + p[1] += grade * a; + } + if flash != 0.0 { + p[2] += flash * a; + } + } + } + } + }); + } + Some(()) +} + +/// OpenCV's Line2: an 8-connected line between points of XY_SHIFT fractional bits. +fn line2(mask: &mut [u8], w: usize, h: usize, pt1: (i64, i64), pt2: (i64, i64), color: u8) { + let (mut p1, mut p2) = (pt1, pt2); + if !clip_line( + (w as i64) << XY_SHIFT, + (h as i64) << XY_SHIFT, + &mut p1, + &mut p2, + ) { + return; + } + let (mut dx, mut dy) = (p2.0 - p1.0, p2.1 - p1.1); + let j = if dx < 0 { -1i64 } else { 0 }; + let ax = (dx ^ j) - j; + let i = if dy < 0 { -1i64 } else { 0 }; + let ay = (dy ^ i) - i; + let mut put = |x: i64, y: i64| { + if 0 <= x && x < w as i64 && 0 <= y && y < h as i64 { + mask[y as usize * w + x as usize] = color; + } + }; + let (x_step, y_step, mut ecount); + let swap = |p1: &mut (i64, i64), p2: &mut (i64, i64), m: i64| { + p1.0 ^= p2.0 & m; + p2.0 ^= p1.0 & m; + p1.0 ^= p2.0 & m; + p1.1 ^= p2.1 & m; + p2.1 ^= p1.1 & m; + p1.1 ^= p2.1 & m; + }; + if ax > ay { + dy = (dy ^ j) - j; + swap(&mut p1, &mut p2, j); + x_step = XY_ONE; + y_step = dy * (1 << XY_SHIFT) / (ax | 1); + ecount = (p2.0 - p1.0) >> XY_SHIFT; + } else { + dx = (dx ^ i) - i; + swap(&mut p1, &mut p2, i); + x_step = dx * (1 << XY_SHIFT) / (ay | 1); + y_step = XY_ONE; + ecount = (p2.1 - p1.1) >> XY_SHIFT; + } + p1.0 += XY_ONE >> 1; + p1.1 += XY_ONE >> 1; + put( + (p2.0 + (XY_ONE >> 1)) >> XY_SHIFT, + (p2.1 + (XY_ONE >> 1)) >> XY_SHIFT, + ); + if ax > ay { + p1.0 >>= XY_SHIFT; + while ecount >= 0 { + put(p1.0, p1.1 >> XY_SHIFT); + p1.0 += 1; + p1.1 += y_step; + ecount -= 1; + } + } else { + p1.1 >>= XY_SHIFT; + while ecount >= 0 { + put(p1.0 >> XY_SHIFT, p1.1); + p1.0 += x_step; + p1.1 += 1; + ecount -= 1; + } + } +} + +/// OpenCV's Circle with `fill`: the midpoint circle, filled with spans when asked. +pub fn circle( + mask: &mut [u8], + w: usize, + h: usize, + center: (i64, i64), + radius: i64, + color: u8, + fill: bool, +) { + let (wi, hi) = (w as i64, h as i64); + let (mut err, mut dx, mut dy, mut plus, mut minus) = + (0i64, radius, 0i64, 1i64, (radius << 1) - 1); + let hline = |m: &mut [u8], y: i64, x1: i64, x2: i64| { + let (x1, x2) = (x1.max(0), x2.min(wi - 1)); + if (0..hi).contains(&y) && x1 <= x2 { + let row = y as usize * w; + m[row + x1 as usize..=row + x2 as usize].fill(color); + } + }; + let point = |m: &mut [u8], y: i64, x: i64| { + if (0..hi).contains(&y) && (0..wi).contains(&x) { + m[y as usize * w + x as usize] = color; + } + }; + while dx >= dy { + let (y11, y12, y21, y22) = (center.1 - dy, center.1 + dy, center.1 - dx, center.1 + dx); + let (x11, x12, x21, x22) = (center.0 - dx, center.0 + dx, center.0 - dy, center.0 + dy); + // OpenCV draws the spans unclipped when the circle is inside and clips them otherwise; + // both write the same pixels. + if x11 < wi && x12 >= 0 && y21 < hi && y22 >= 0 { + if fill { + hline(mask, y11, x11, x12); + hline(mask, y12, x11, x12); + if x21 < wi && x22 >= 0 { + hline(mask, y21, x21, x22); + hline(mask, y22, x21, x22); + } + } else { + for (y, x) in [(y11, x11), (y11, x12), (y12, x11), (y12, x12)] { + point(mask, y, x); + } + if x21 < wi && x22 >= 0 { + for (y, x) in [(y21, x21), (y21, x22), (y22, x21), (y22, x22)] { + point(mask, y, x); + } + } + } + } + dy += 1; + err += plus; + plus += 2; + let m = ((err <= 0) as i64) - 1; + err -= minus & m; + dx += m; + minus -= m & 2; + } +} + +/// OpenCV's ThickLine for shift 0 and 8-connected lines; `flags` bit 0 caps `p0`, bit 1 `p1`. +#[allow(clippy::too_many_arguments)] +fn thick_line( + mask: &mut [u8], + w: usize, + h: usize, + p0: (i64, i64), + p1: (i64, i64), + color: u8, + thickness: i64, + flags: u8, +) { + let (wi, hi) = (w as i64, h as i64); + let (mut p0, mut p1) = (p0, p1); + let inside = |p: (i64, i64)| p.0 >= 0 && p.0 < wi && p.1 >= 0 && p.1 < hi; + if thickness > 1 && (!inside(p0) || !inside(p1)) { + let m = thickness; + let (mut a, mut b) = ((p0.0 + m, p0.1 + m), (p1.0 + m, p1.1 + m)); + clip_line(wi + 2 * m, hi + 2 * m, &mut a, &mut b); + p0 = (a.0 - m, a.1 - m); + p1 = (b.0 - m, b.1 - m); + } + let mut p0 = (p0.0 << XY_SHIFT, p0.1 << XY_SHIFT); + let p1 = (p1.0 << XY_SHIFT, p1.1 << XY_SHIFT); + if thickness <= 1 { + let round = |p: (i64, i64)| { + ( + (p.0 + (XY_ONE >> 1)) >> XY_SHIFT, + (p.1 + (XY_ONE >> 1)) >> XY_SHIFT, + ) + }; + draw_line(mask, w, h, round(p0), round(p1), color); + return; + } + let inv = 1.0 / XY_ONE as f64; + let dx = (p0.0 - p1.0) as f64 * inv; + let dy = (p1.1 - p0.1) as f64 * inv; + let r = dx * dx + dy * dy; + let odd = thickness & 1; + let thickness = thickness << (XY_SHIFT - 1); + if r.abs() > f64::EPSILON { + let r = (thickness as f64 + odd as f64 * XY_ONE as f64 * 0.5) / r.sqrt(); + let dp = ( + (dy * r).round_ties_even() as i64, + (dx * r).round_ties_even() as i64, + ); + let pt = [ + (p0.0 + dp.0, p0.1 + dp.1), + (p0.0 - dp.0, p0.1 - dp.1), + (p1.0 - dp.0, p1.1 - dp.1), + (p1.0 + dp.0, p1.1 + dp.1), + ]; + fill_convex_poly_shift(mask, w, h, &pt, color, XY_SHIFT); + } + for i in 0..2 { + if flags & (i + 1) != 0 { + let center = ( + (p0.0 + (XY_ONE >> 1)) >> XY_SHIFT, + (p0.1 + (XY_ONE >> 1)) >> XY_SHIFT, + ); + circle( + mask, + w, + h, + center, + (thickness + (XY_ONE >> 1)) >> XY_SHIFT, + color, + true, + ); + } + p0 = p1; + } +} + +/// `cv2.polylines(mask, [pts], False, color, thickness)`, 8-connected, shift 0. +pub fn polyline_open( + mask: &mut [u8], + w: usize, + h: usize, + v: &[(i64, i64)], + color: u8, + thickness: i64, +) { + let Some(&first) = v.first() else { + return; + }; + let mut p0 = first; + let mut flags = 3; + for &p in &v[1..] { + thick_line(mask, w, h, p0, p, color, thickness, flags); + p0 = p; + flags = 2; + } +} + +/// A brush footprint: the open polyline of `pts` at twice `radius` and a filled circle of +/// `radius` at every point, as retouch's `_cover` and `exclusion_cover` draw it. +pub fn stroke_cover( + mask: &mut [u8], + w: usize, + h: usize, + pts: &[(i64, i64)], + radius: i64, + thickness: i64, +) { + if pts.len() > 1 { + polyline_open(mask, w, h, pts, 1, thickness); + } + for &p in pts { + circle(mask, w, h, p, radius, 1, true); + } +} diff --git a/negpy_core/src/sift.rs b/negpy_core/src/sift.rs new file mode 100644 index 000000000..2f73892b2 --- /dev/null +++ b/negpy_core/src/sift.rs @@ -0,0 +1,681 @@ +//! OpenCV 4.13's SIFT detector and float descriptor (`sift.dispatch.cpp`, `sift.simd.hpp`, +//! Apache-2.0) with its default parameters. OpenCV rounds each float descriptor element to an +//! integer in 0..=255, so the descriptors here are bytes of the same values. + +use rayon::prelude::*; + +use crate::filter::{self, Border}; +use crate::resize; + +const LAYERS: usize = 3; +const CONTRAST: f32 = 0.04; +const EDGE: f32 = 10.0; +const SIGMA: f32 = 1.6; +const INIT_SIGMA: f32 = 0.5; +const IMG_BORDER: usize = 5; +const MAX_INTERP_STEPS: usize = 5; +const ORI_BINS: usize = 36; +const ORI_SIG_FCTR: f32 = 1.5; +const ORI_RADIUS: f32 = 4.5; +const ORI_PEAK_RATIO: f32 = 0.8; +const DESCR_WIDTH: usize = 4; +const DESCR_BINS: usize = 8; +const DESCR_SCL_FCTR: f32 = 3.0; +const DESCR_MAG_THR: f32 = 0.2; +const INT_DESCR_FCTR: f32 = 512.0; +pub const DESCR_LEN: usize = DESCR_WIDTH * DESCR_WIDTH * DESCR_BINS; +/// The first octave is the input upscaled two times. +const FIRST_OCTAVE: i32 = -1; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Keypoint { + pub x: f32, + pub y: f32, + pub size: f32, + pub angle: f32, + pub response: f32, + /// OpenCV's packed octave: octave in bits 0..8, layer in 8..16, sub-layer in 16..24. + pub octave: i32, +} + +struct Plane { + data: Vec, + h: usize, + w: usize, +} + +impl Plane { + #[inline(always)] + fn row(&self, r: usize) -> &[f32] { + &self.data[r * self.w..(r + 1) * self.w] + } + + #[inline(always)] + fn at(&self, r: usize, c: usize) -> f32 { + self.data[r * self.w + c] + } +} + +#[inline(always)] +fn cv_round(v: f32) -> i32 { + v.round_ties_even() as i32 +} + +/// OpenCV's `fastAtan2` in degrees, 0..360. +#[inline(always)] +fn fast_atan2(y: f32, x: f32) -> f32 { + const R2D: f32 = (180.0 / std::f64::consts::PI) as f32; + let (p1, p3, p5, p7) = ( + 0.999_787_87_f32 * R2D, + -0.325_808_4_f32 * R2D, + 0.155_578_66_f32 * R2D, + -0.044_326_555_f32 * R2D, + ); + let (ax, ay) = (x.abs(), y.abs()); + let poly = |c: f32| { + let c2 = c * c; + (((p7 * c2 + p5) * c2 + p3) * c2 + p1) * c + }; + let mut a = if ax >= ay { + poly(ay / (ax + f64::EPSILON as f32)) + } else { + 90.0 - poly(ax / (ay + f64::EPSILON as f32)) + }; + if x < 0.0 { + a = 180.0 - a; + } + if y < 0.0 { + a = 360.0 - a; + } + a +} + +fn blur(src: &Plane, sigma: f64) -> Plane { + let mut data = vec![0f32; src.data.len()]; + filter::gaussian_blur( + &src.data, + &mut data, + (src.h, src.w, 1), + (0, 0), + (sigma, sigma), + Border::Reflect101, + ) + .expect("an odd Gaussian kernel"); + Plane { + data, + h: src.h, + w: src.w, + } +} + +/// `createInitialImage` as `cv2.SIFT_create` runs it, with `enable_precise_upscale` off: a +/// bilinear `resize` to twice the size, then the blur that brings the assumed input blur up to +/// SIGMA. +fn initial_image(gray: &[u8], h: usize, w: usize) -> Plane { + let src: Vec = gray.iter().map(|&v| v as f32).collect(); + let (dh, dw) = (2 * h, 2 * w); + let mut dbl = vec![0f32; dh * dw]; + resize::resize_linear(&src, (h, w, 1), (dh, dw), false, &mut dbl).expect("a non-empty image"); + let sig_diff = (SIGMA * SIGMA - INIT_SIGMA * INIT_SIGMA * 4.0) + .max(0.01) + .sqrt(); + blur( + &Plane { + data: dbl, + h: dh, + w: dw, + }, + sig_diff as f64, + ) +} + +fn gaussian_pyramid(base: Plane, octaves: usize) -> Vec { + let k = 2f64.powf(1.0 / LAYERS as f64); + let sig: Vec = (0..LAYERS + 3) + .map(|i| { + if i == 0 { + return SIGMA as f64; + } + let prev = k.powi(i as i32 - 1) * SIGMA as f64; + let total = prev * k; + (total * total - prev * prev).sqrt() + }) + .collect(); + let mut pyr: Vec = Vec::with_capacity(octaves * (LAYERS + 3)); + pyr.push(base); + for o in 0..octaves { + for (i, &s) in sig.iter().enumerate() { + if o == 0 && i == 0 { + continue; + } + let next = if i == 0 { + let src = &pyr[(o - 1) * (LAYERS + 3) + LAYERS]; + let (dh, dw) = (src.h / 2, src.w / 2); + let mut data = vec![0f32; dh * dw]; + resize::resize_nearest(&src.data, (src.h, src.w, 1), (dh, dw), &mut data); + Plane { data, h: dh, w: dw } + } else { + blur(&pyr[o * (LAYERS + 3) + i - 1], s) + }; + pyr.push(next); + } + } + pyr +} + +fn dog_pyramid(gpyr: &[Plane], octaves: usize) -> Vec { + (0..octaves * (LAYERS + 2)) + .map(|a| { + let (o, i) = (a / (LAYERS + 2), a % (LAYERS + 2)); + let (lo, hi) = (&gpyr[o * (LAYERS + 3) + i], &gpyr[o * (LAYERS + 3) + i + 1]); + let mut data = hi.data.clone(); + crate::par_zip(&mut data, &lo.data, move |d, &s| *d -= s); + Plane { + data, + h: lo.h, + w: lo.w, + } + }) + .collect() +} + +/// OpenCV's 3x3 `Matx::solve` (DECOMP_LU): Cramer's rule; zeros for a singular matrix. +fn solve3(a: [[f32; 3]; 3], b: [f32; 3]) -> [f32; 3] { + let d = a[0][0] as f64 * (a[1][1] as f64 * a[2][2] as f64 - a[1][2] as f64 * a[2][1] as f64) + - a[0][1] as f64 * (a[1][0] as f64 * a[2][2] as f64 - a[1][2] as f64 * a[2][0] as f64) + + a[0][2] as f64 * (a[1][0] as f64 * a[2][1] as f64 - a[1][1] as f64 * a[2][0] as f64); + let d = d as f32; + if d == 0.0 { + return [0.0; 3]; + } + let d = 1.0 / d; + [ + d * (b[0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) + - a[0][1] * (b[1] * a[2][2] - a[1][2] * b[2]) + + a[0][2] * (b[1] * a[2][1] - a[1][1] * b[2])), + d * (a[0][0] * (b[1] * a[2][2] - a[1][2] * b[2]) + - b[0] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + + a[0][2] * (a[1][0] * b[2] - b[1] * a[2][0])), + d * (a[0][0] * (a[1][1] * b[2] - b[1] * a[2][1]) + - a[0][1] * (a[1][0] * b[2] - b[1] * a[2][0]) + + b[0] * (a[1][0] * a[2][1] - a[1][1] * a[2][0])), + ] +} + +/// `adjustLocalExtrema`: the sub-pixel, sub-layer extremum and its contrast and edge tests. +fn adjust_local_extrema( + dog: &[Plane], + octv: usize, + layer: &mut usize, + r: &mut usize, + c: &mut usize, +) -> Option { + const IMG_SCALE: f32 = 1.0 / 255.0; + const DERIV: f32 = IMG_SCALE * 0.5; + const SECOND: f32 = IMG_SCALE; + const CROSS: f32 = IMG_SCALE * 0.25; + let (mut xi, mut xr, mut xc) = (0f32, 0f32, 0f32); + let mut step = 0; + while step < MAX_INTERP_STEPS { + let idx = octv * (LAYERS + 2) + *layer; + let (img, prev, next) = (&dog[idx], &dog[idx - 1], &dog[idx + 1]); + let (r0, c0) = (*r, *c); + let d_d = [ + (img.at(r0, c0 + 1) - img.at(r0, c0 - 1)) * DERIV, + (img.at(r0 + 1, c0) - img.at(r0 - 1, c0)) * DERIV, + (next.at(r0, c0) - prev.at(r0, c0)) * DERIV, + ]; + let v2 = img.at(r0, c0) * 2.0; + let dxx = (img.at(r0, c0 + 1) + img.at(r0, c0 - 1) - v2) * SECOND; + let dyy = (img.at(r0 + 1, c0) + img.at(r0 - 1, c0) - v2) * SECOND; + let dss = (next.at(r0, c0) + prev.at(r0, c0) - v2) * SECOND; + let dxy = (img.at(r0 + 1, c0 + 1) - img.at(r0 + 1, c0 - 1) - img.at(r0 - 1, c0 + 1) + + img.at(r0 - 1, c0 - 1)) + * CROSS; + let dxs = (next.at(r0, c0 + 1) - next.at(r0, c0 - 1) - prev.at(r0, c0 + 1) + + prev.at(r0, c0 - 1)) + * CROSS; + let dys = (next.at(r0 + 1, c0) - next.at(r0 - 1, c0) - prev.at(r0 + 1, c0) + + prev.at(r0 - 1, c0)) + * CROSS; + let x = solve3([[dxx, dxy, dxs], [dxy, dyy, dys], [dxs, dys, dss]], d_d); + xi = -x[2]; + xr = -x[1]; + xc = -x[0]; + if xi.abs() < 0.5 && xr.abs() < 0.5 && xc.abs() < 0.5 { + break; + } + let far = (i32::MAX / 3) as f32; + if xi.abs() > far || xr.abs() > far || xc.abs() > far { + return None; + } + let nc = c0 as i64 + cv_round(xc) as i64; + let nr = r0 as i64 + cv_round(xr) as i64; + let nl = *layer as i64 + cv_round(xi) as i64; + if nl < 1 + || nl > LAYERS as i64 + || nc < IMG_BORDER as i64 + || nc >= (img.w - IMG_BORDER) as i64 + || nr < IMG_BORDER as i64 + || nr >= (img.h - IMG_BORDER) as i64 + { + return None; + } + (*c, *r, *layer) = (nc as usize, nr as usize, nl as usize); + step += 1; + } + if step >= MAX_INTERP_STEPS { + return None; + } + let idx = octv * (LAYERS + 2) + *layer; + let (img, prev, next) = (&dog[idx], &dog[idx - 1], &dog[idx + 1]); + let (r0, c0) = (*r, *c); + let d_d = [ + (img.at(r0, c0 + 1) - img.at(r0, c0 - 1)) * DERIV, + (img.at(r0 + 1, c0) - img.at(r0 - 1, c0)) * DERIV, + (next.at(r0, c0) - prev.at(r0, c0)) * DERIV, + ]; + let t = d_d[0] * xc + d_d[1] * xr + d_d[2] * xi; + let contr = img.at(r0, c0) * IMG_SCALE + t * 0.5; + if contr.abs() * (LAYERS as f32) < CONTRAST { + return None; + } + let v2 = img.at(r0, c0) * 2.0; + let dxx = (img.at(r0, c0 + 1) + img.at(r0, c0 - 1) - v2) * SECOND; + let dyy = (img.at(r0 + 1, c0) + img.at(r0 - 1, c0) - v2) * SECOND; + let dxy = (img.at(r0 + 1, c0 + 1) - img.at(r0 + 1, c0 - 1) - img.at(r0 - 1, c0 + 1) + + img.at(r0 - 1, c0 - 1)) + * CROSS; + let tr = dxx + dyy; + let det = dxx * dyy - dxy * dxy; + if det <= 0.0 || tr * tr * EDGE >= (EDGE + 1.0) * (EDGE + 1.0) * det { + return None; + } + let scale = (1u32 << octv) as f32; + Some(Keypoint { + x: (c0 as f32 + xc) * scale, + y: (r0 as f32 + xr) * scale, + size: SIGMA * libm::powf(2.0, (*layer as f32 + xi) / LAYERS as f32) * scale * 2.0, + angle: 0.0, + response: contr.abs(), + octave: octv as i32 + + ((*layer as i32) << 8) + + ((((xi as f64 + 0.5) * 255.0).round_ties_even() as i32) << 16), + }) +} + +/// `calcOrientationHist`: the smoothed gradient orientation histogram around (r, c); returns its +/// maximum. +fn orientation_hist( + img: &Plane, + (r, c): (usize, usize), + radius: i32, + sigma: f32, + hist: &mut [f32; ORI_BINS], +) -> f32 { + let n = ORI_BINS; + let expf_scale = -1.0 / (2.0 * sigma * sigma); + let mut temp = [0f32; ORI_BINS + 4]; + for i in -radius..=radius { + let y = r as i32 + i; + if y <= 0 || y >= img.h as i32 - 1 { + continue; + } + for j in -radius..=radius { + let x = c as i32 + j; + if x <= 0 || x >= img.w as i32 - 1 { + continue; + } + let (y, x) = (y as usize, x as usize); + let dx = img.at(y, x + 1) - img.at(y, x - 1); + let dy = img.at(y - 1, x) - img.at(y + 1, x); + let w = libm::expf((i * i + j * j) as f32 * expf_scale); + let ori = fast_atan2(dy, dx); + let mag = (dx * dx + dy * dy).sqrt(); + let mut bin = cv_round((n as f32 / 360.0) * ori); + if bin >= n as i32 { + bin -= n as i32; + } + if bin < 0 { + bin += n as i32; + } + temp[bin as usize + 2] += w * mag; + } + } + temp[1] = temp[n + 1]; + temp[0] = temp[n]; + temp[n + 2] = temp[2]; + temp[n + 3] = temp[3]; + let mut maxval = f32::MIN; + for (i, h) in hist.iter_mut().enumerate() { + let t = |k: usize| temp[i + k]; + *h = (t(0) + t(4)) * (1.0 / 16.0) + (t(1) + t(3)) * (4.0 / 16.0) + t(2) * (6.0 / 16.0); + maxval = maxval.max(*h); + } + maxval +} + +/// The keypoints of one DoG row: local extrema in the 3x3x3 neighborhood, refined, then one +/// keypoint per dominant orientation. +fn row_extrema(gpyr: &[Plane], dog: &[Plane], o: usize, i: usize, r: usize) -> Vec { + let threshold = (0.5 * CONTRAST as f64 / LAYERS as f64 * 255.0).floor() as f32; + let idx = o * (LAYERS + 2) + i; + let (img, prev, next) = (&dog[idx], &dog[idx - 1], &dog[idx + 1]); + let w = img.w; + let (cur, up, down) = (img.row(r), img.row(r - 1), img.row(r + 1)); + let nb = |p: &Plane, c: usize| { + let (a, b, d) = (p.row(r - 1), p.row(r), p.row(r + 1)); + [ + a[c - 1], + a[c], + a[c + 1], + b[c - 1], + b[c + 1], + d[c - 1], + d[c], + d[c + 1], + ] + }; + let mut out = Vec::new(); + for c in IMG_BORDER..w - IMG_BORDER { + let val = cur[c]; + if val.abs() <= threshold { + continue; + } + let ring = [ + up[c - 1], + up[c], + up[c + 1], + cur[c - 1], + cur[c + 1], + down[c - 1], + down[c], + down[c + 1], + ]; + let is_ext = if val > 0.0 { + let mx = |v: [f32; 8]| v.iter().fold(f32::MIN, |m, &x| m.max(x)); + val >= mx(ring) + && val >= mx(nb(prev, c)) + && val >= mx(nb(next, c)) + && val >= prev.at(r, c).max(next.at(r, c)) + } else { + let mn = |v: [f32; 8]| v.iter().fold(f32::MAX, |m, &x| m.min(x)); + val <= mn(ring) + && val <= mn(nb(prev, c)) + && val <= mn(nb(next, c)) + && val <= prev.at(r, c).min(next.at(r, c)) + }; + if !is_ext { + continue; + } + let (mut r1, mut c1, mut layer) = (r, c, i); + let Some(kpt) = adjust_local_extrema(dog, o, &mut layer, &mut r1, &mut c1) else { + continue; + }; + let scl_octv = kpt.size * 0.5 / (1u32 << o) as f32; + let mut hist = [0f32; ORI_BINS]; + let omax = orientation_hist( + &gpyr[o * (LAYERS + 3) + layer], + (r1, c1), + cv_round(ORI_RADIUS * scl_octv), + ORI_SIG_FCTR * scl_octv, + &mut hist, + ); + let mag_thr = omax * ORI_PEAK_RATIO; + let n = ORI_BINS; + for j in 0..n { + let l = if j > 0 { j - 1 } else { n - 1 }; + let r2 = if j < n - 1 { j + 1 } else { 0 }; + if hist[j] > hist[l] && hist[j] > hist[r2] && hist[j] >= mag_thr { + let mut bin = + j as f32 + 0.5 * (hist[l] - hist[r2]) / (hist[l] - 2.0 * hist[j] + hist[r2]); + bin = if bin < 0.0 { + n as f32 + bin + } else if bin >= n as f32 { + bin - n as f32 + } else { + bin + }; + let mut angle = 360.0 - (360.0 / n as f32) * bin; + if (angle - 360.0).abs() < f32::EPSILON { + angle = 0.0; + } + out.push(Keypoint { angle, ..kpt }); + } + } + } + out +} + +/// `calcSIFTDescriptor`: the 4x4x8 trilinear gradient histogram, clipped at 0.2 of its norm and +/// scaled to bytes. +fn descriptor(img: &Plane, (px, py): (f32, f32), ori: f32, scl: f32) -> [u8; DESCR_LEN] { + let (d, n) = (DESCR_WIDTH as i32, DESCR_BINS as i32); + let (pxi, pyi) = (cv_round(px), cv_round(py)); + let rad = ori * (std::f64::consts::PI / 180.0) as f32; + let bins_per_rad = n as f32 / 360.0; + let exp_scale = -1.0 / (d as f32 * d as f32 * 0.5); + let hist_width = DESCR_SCL_FCTR * scl; + let radius = cv_round(hist_width * std::f32::consts::SQRT_2 * (d + 1) as f32 * 0.5); + let diag = ((img.w as f64).powi(2) + (img.h as f64).powi(2)).sqrt() as i32; + let radius = radius.min(diag); + let cos_t = libm::cosf(rad) / hist_width; + let sin_t = libm::sinf(rad) / hist_width; + let (dp, np) = ((d + 2) as usize, (n + 2) as usize); + let mut hist = vec![0f32; dp * dp * np]; + let (rows, cols) = (img.h as i32, img.w as i32); + for i in -radius..=radius { + for j in -radius..=radius { + let c_rot = j as f32 * cos_t - i as f32 * sin_t; + let r_rot = j as f32 * sin_t + i as f32 * cos_t; + let rbin = r_rot + (d / 2) as f32 - 0.5; + let cbin = c_rot + (d / 2) as f32 - 0.5; + let (r, c) = (pyi + i, pxi + j); + if !(rbin > -1.0 + && rbin < d as f32 + && cbin > -1.0 + && cbin < d as f32 + && r > 0 + && r < rows - 1 + && c > 0 + && c < cols - 1) + { + continue; + } + let (ru, cu) = (r as usize, c as usize); + let dx = img.at(ru, cu + 1) - img.at(ru, cu - 1); + let dy = img.at(ru - 1, cu) - img.at(ru + 1, cu); + let w = libm::expf((c_rot * c_rot + r_rot * r_rot) * exp_scale); + let o = fast_atan2(dy, dx); + let mag = (dx * dx + dy * dy).sqrt() * w; + let obin = (o - ori) * bins_per_rad; + let (r0, c0, mut o0) = ( + rbin.floor() as i32, + cbin.floor() as i32, + obin.floor() as i32, + ); + let (rbin, cbin, obin) = (rbin - r0 as f32, cbin - c0 as f32, obin - o0 as f32); + if o0 < 0 { + o0 += n; + } + if o0 >= n { + o0 -= n; + } + let v_r1 = mag * rbin; + let v_r0 = mag - v_r1; + let v_rc11 = v_r1 * cbin; + let v_rc10 = v_r1 - v_rc11; + let v_rc01 = v_r0 * cbin; + let v_rc00 = v_r0 - v_rc01; + let v_rco111 = v_rc11 * obin; + let v_rco110 = v_rc11 - v_rco111; + let v_rco101 = v_rc10 * obin; + let v_rco100 = v_rc10 - v_rco101; + let v_rco011 = v_rc01 * obin; + let v_rco010 = v_rc01 - v_rco011; + let v_rco001 = v_rc00 * obin; + let v_rco000 = v_rc00 - v_rco001; + let idx = (((r0 + 1) as usize * dp) + (c0 + 1) as usize) * np + o0 as usize; + hist[idx] += v_rco000; + hist[idx + 1] += v_rco001; + hist[idx + np] += v_rco010; + hist[idx + np + 1] += v_rco011; + hist[idx + dp * np] += v_rco100; + hist[idx + dp * np + 1] += v_rco101; + hist[idx + (dp + 1) * np] += v_rco110; + hist[idx + (dp + 1) * np + 1] += v_rco111; + } + } + let (du, nu) = (d as usize, n as usize); + let mut raw = [0f32; DESCR_LEN]; + for i in 0..du { + for j in 0..du { + let idx = ((i + 1) * dp + (j + 1)) * np; + hist[idx] += hist[idx + nu]; + hist[idx + 1] += hist[idx + nu + 1]; + raw[(i * du + j) * nu..(i * du + j + 1) * nu].copy_from_slice(&hist[idx..idx + nu]); + } + } + let nrm2: f32 = raw.iter().map(|v| v * v).sum(); + let thr = nrm2.sqrt() * DESCR_MAG_THR; + let mut nrm2 = 0f32; + for v in raw.iter_mut() { + *v = v.min(thr); + nrm2 += *v * *v; + } + let nrm2 = INT_DESCR_FCTR / nrm2.sqrt().max(f32::EPSILON); + raw.map(|v| (v * nrm2).round_ties_even().clamp(0.0, 255.0) as u8) +} + +/// `KeypointGreater`, the order `removeDuplicatedSorted` sorts by. +fn keypoint_order(a: &Keypoint, b: &Keypoint) -> std::cmp::Ordering { + a.x.total_cmp(&b.x) + .then(a.y.total_cmp(&b.y)) + .then(b.size.total_cmp(&a.size)) + .then(a.angle.total_cmp(&b.angle)) + .then(b.response.total_cmp(&a.response)) + .then(b.octave.cmp(&a.octave)) +} + +/// `SIFT_create(nfeatures).detectAndCompute(gray, None)` on an 8-bit image: the keypoints and +/// their descriptors, row for row. `nfeatures` 0 keeps every keypoint. +pub fn detect_and_compute( + gray: &[u8], + h: usize, + w: usize, + nfeatures: usize, +) -> (Vec, Vec<[u8; DESCR_LEN]>) { + if h == 0 || w == 0 || gray.len() != h * w { + return (Vec::new(), Vec::new()); + } + let base = initial_image(gray, h, w); + let octaves = ((base.w.min(base.h) as f64).ln() / 2f64.ln() - 2.0).round_ties_even() as i32 + - FIRST_OCTAVE; + if octaves < 1 { + return (Vec::new(), Vec::new()); + } + let octaves = octaves as usize; + let gpyr = gaussian_pyramid(base, octaves); + let dog = dog_pyramid(&gpyr, octaves); + let mut kpts: Vec = Vec::new(); + for o in 0..octaves { + for i in 1..=LAYERS { + let rows = dog[o * (LAYERS + 2) + i].h; + if rows <= 2 * IMG_BORDER || dog[o * (LAYERS + 2) + i].w <= 2 * IMG_BORDER { + continue; + } + let found: Vec = (IMG_BORDER..rows - IMG_BORDER) + .into_par_iter() + .flat_map_iter(|r| row_extrema(&gpyr, &dog, o, i, r)) + .collect(); + kpts.extend(found); + } + } + kpts.sort_by(keypoint_order); + kpts.dedup_by(|b, a| a.x == b.x && a.y == b.y && a.size == b.size && a.angle == b.angle); + if nfeatures > 0 && kpts.len() > nfeatures { + // retainBest: the nfeatures strongest, and every keypoint tied with the last of them. + let mut by_response = kpts.clone(); + by_response.sort_by(|a, b| b.response.total_cmp(&a.response)); + let cut = by_response[nfeatures - 1].response; + kpts.retain(|k| k.response >= cut); + } + let scale = 1.0 / (1u32 << -FIRST_OCTAVE) as f32; + for k in kpts.iter_mut() { + k.octave = (k.octave & !255) | ((k.octave + FIRST_OCTAVE) & 255); + k.x *= scale; + k.y *= scale; + k.size *= scale; + } + let desc = kpts + .par_iter() + .map(|k| { + let mut octave = k.octave & 255; + let layer = ((k.octave >> 8) & 255) as usize; + if octave >= 128 { + octave |= -128; + } + let s = if octave >= 0 { + 1.0 / (1u32 << octave) as f32 + } else { + (1u32 << -octave) as f32 + }; + let img = &gpyr[(octave - FIRST_OCTAVE) as usize * (LAYERS + 3) + layer]; + let mut angle = 360.0 - k.angle; + if (angle - 360.0).abs() < f32::EPSILON { + angle = 0.0; + } + descriptor(img, (k.x * s, k.y * s), angle, k.size * s * 0.5) + }) + .collect(); + (kpts, desc) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blobs(h: usize, w: usize) -> Vec { + (0..h * w) + .map(|p| { + let (y, x) = ((p / w) as f32, (p % w) as f32); + let v = (x * 0.21).sin() * (y * 0.13).cos() + (x * 0.05 + y * 0.07).sin(); + (127.0 + 60.0 * v) as u8 + }) + .collect() + } + + #[test] + fn fast_atan2_matches_atan2() { + for k in 0..360 { + let t = (k as f32 + 0.5).to_radians(); + let (y, x) = (t.sin(), t.cos()); + let want = y.atan2(x).to_degrees().rem_euclid(360.0); + assert!((fast_atan2(y, x) - want).abs() < 0.02, "{k}"); + } + } + + #[test] + fn detects_keypoints_with_unit_norm_descriptors() { + let (h, w) = (160, 200); + let (kp, desc) = detect_and_compute(&blobs(h, w), h, w, 0); + assert!(kp.len() > 20); + assert_eq!(kp.len(), desc.len()); + for k in &kp { + assert!(k.x >= 0.0 && k.x < w as f32 && k.y >= 0.0 && k.y < h as f32); + } + for d in &desc { + let n: f32 = d.iter().map(|&v| (v as f32).powi(2)).sum::().sqrt(); + assert!((n - 512.0).abs() < 8.0, "{n}"); + } + } + + #[test] + fn nfeatures_keeps_the_strongest() { + let (h, w) = (160, 200); + let (all, _) = detect_and_compute(&blobs(h, w), h, w, 0); + let (best, desc) = detect_and_compute(&blobs(h, w), h, w, 10); + assert!(best.len() >= 10 && best.len() < all.len()); + assert_eq!(desc.len(), best.len()); + } +} diff --git a/negpy_core/src/skew.rs b/negpy_core/src/skew.rs new file mode 100644 index 000000000..04fac7797 --- /dev/null +++ b/negpy_core/src/skew.rs @@ -0,0 +1,637 @@ +//! The edge sampling of negpy/features/geometry/skew.py: `_fit_side`'s per-station profiles +//! and `_profile_peaks`, in NumPy's float32 and float64 order. + +use rayon::prelude::*; + +use crate::filter::{self, Border}; + +/// `_profile_peaks`: subpixel positions of the strong gradient steps along one profile, after a +/// sigma-1 Gaussian (reflect-101) and `np.gradient`. +pub fn profile_peaks(profile: &[f32]) -> Vec { + let n = profile.len(); + if n < 5 { + return Vec::new(); + } + let mut blur = vec![0f32; n]; + if filter::gaussian_blur( + profile, + &mut blur, + (n, 1, 1), + (1, 0), + (0.0, 1.0), + Border::Reflect101, + ) + .is_none() + { + return Vec::new(); + } + let mut grad: Vec = (0..n) + .map(|i| match i { + 0 => blur[1] - blur[0], + _ if i == n - 1 => blur[n - 1] - blur[n - 2], + _ => (blur[i + 1] - blur[i - 1]) / 2.0, + }) + .map(f32::abs) + .collect(); + let peak = grad.iter().copied().fold(f32::NEG_INFINITY, f32::max) as f64; + let mut sorted = grad.clone(); + sorted.sort_unstable_by(f32::total_cmp); + let median = if n % 2 == 1 { + sorted[n / 2] + } else { + (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 + }; + if peak <= 3.0 * median as f64 + 1e-6 { + return Vec::new(); + } + // A Python float meets the float32 gradient as float32. + let (bar, tiny) = ((0.4 * peak) as f32, 1e-9f64 as f32); + let g = std::mem::take(&mut grad); + (1..n - 1) + .filter(|&i| g[i] >= g[i - 1] && g[i] > g[i + 1] && g[i] >= bar) + .map(|i| { + let (a, b, c) = (g[i - 1], g[i], g[i + 1]); + let denom = a - 2.0 * b + c; + let frac = if denom.abs() > tiny { + 0.5 * (a - c) / denom + } else { + 0.0 + }; + i as f64 + frac as f64 + }) + .collect() +} + +/// Python's `round` of a float64: ties to even. +fn round_half_even(v: f64) -> i64 { + v.round_ties_even() as i64 +} + +/// `_fit_side`'s `sample(center, half, nearest)` on an HxW luminance plane read along rows +/// (`horizontal`) or columns: per station, the peaks of the 3-column mean profile within +/// `half` of its center, or only the one nearest the center. Returns (stations, positions). +pub fn sample( + img: &[f32], + (h, w): (usize, usize), + horizontal: bool, + (stations, centers): (&[f64], &[f64]), + half: f64, + nearest: bool, +) -> (Vec, Vec) { + let (vh, vw) = if horizontal { (h, w) } else { (w, h) }; + let at = |r: usize, c: usize| { + if horizontal { + img[r * w + c] + } else { + img[c * w + r] + } + }; + let found: Vec<(f64, Vec)> = stations + .par_iter() + .zip(centers.par_iter()) + .map(|(&x, &c)| { + let x0 = round_half_even(x); + let lo = ((c - half).floor() as i64).max(0); + let hi = ((c + half).ceil() as i64 + 1).min(vh as i64); + if hi - lo < 5 || x0 < 1 || x0 > vw as i64 - 2 { + return (x, Vec::new()); + } + let (lo, x0) = (lo as usize, x0 as usize); + let profile: Vec = (lo..hi as usize) + .map(|r| (at(r, x0 - 1) + at(r, x0) + at(r, x0 + 1)) / 3.0) + .collect(); + let mut peaks = profile_peaks(&profile); + if nearest && !peaks.is_empty() { + let want = c - lo as f64; + let best = peaks + .iter() + .enumerate() + .fold((0, f64::INFINITY), |(bi, bd), (i, &p)| { + let d = (p - want).abs(); + if d < bd { (i, d) } else { (bi, bd) } + }) + .0; + peaks = vec![peaks[best]]; + } + (x, peaks.into_iter().map(|p| lo as f64 + p).collect()) + }) + .collect(); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for (x, peaks) in found { + xs.extend(std::iter::repeat_n(x, peaks.len())); + ys.extend(peaks); + } + (xs, ys) +} + +/// NumPy's PCG64 as `np.random.default_rng(0)` seeds it, and `integers(0, n)` for n < 2^32 +/// (Lemire's bounded draw on the 32-bit halves of each 64-bit output). +struct Pcg64 { + state: u128, + inc: u128, + half: Option, +} + +impl Pcg64 { + const MULT: u128 = (2549297995355413924u128 << 64) | 4865540595714422341u128; + + fn seed0() -> Self { + Pcg64 { + state: (0x1aa1b5345996452du128 << 64) | 0x09585eb7a69561e3, + inc: (0x418ddadb3af71a82u128 << 64) | 0x588133bc447873a9, + half: None, + } + } + + fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_mul(Self::MULT).wrapping_add(self.inc); + let s = self.state; + (((s >> 64) as u64) ^ (s as u64)).rotate_right((s >> 122) as u32) + } + + fn next_u32(&mut self) -> u32 { + if let Some(v) = self.half.take() { + return v; + } + let v = self.next_u64(); + self.half = Some((v >> 32) as u32); + v as u32 + } + + /// One draw of `integers(0, n)`, n >= 1. + fn below(&mut self, n: u32) -> u32 { + let rng = n - 1; + if rng == 0 { + return 0; + } + if rng == u32::MAX { + return self.next_u32(); + } + let excl = rng + 1; + let mut m = self.next_u32() as u64 * excl as u64; + let mut left = m as u32; + if left < excl { + let threshold = (u32::MAX - rng) % excl; + while left < threshold { + m = self.next_u32() as u64 * excl as u64; + left = m as u32; + } + } + (m >> 32) as u32 + } +} + +/// Least-squares line `y = slope * x + intercept` through the points (`np.polyfit(x, y, 1)` up +/// to rounding: centered sums, not LAPACK's SVD). +fn polyfit1(xs: &[f64], ys: &[f64]) -> (f64, f64) { + let n = xs.len() as f64; + let mx = xs.iter().sum::() / n; + let my = ys.iter().sum::() / n; + let (mut sxy, mut sxx) = (0.0, 0.0); + for (&x, &y) in xs.iter().zip(ys) { + sxy += (x - mx) * (y - my); + sxx += (x - mx) * (x - mx); + } + let slope = sxy / sxx; + (slope, my - slope * mx) +} + +/// `_ransac_line`: the straight run of peaks with the most distinct stations, preferring one +/// near `expected`. +fn ransac_line(xs: &[f64], ys: &[f64], expected: f64, half: f64, span: f64) -> Option<(f64, f64)> { + let n = xs.len(); + if n < 12 { + return None; + } + let mut rng = Pcg64::seed0(); + let draws: Vec = (0..600).map(|_| rng.below(n as u32) as usize).collect(); + let (a, b) = draws.split_at(300); + let max_slope = 4.0f64.to_radians().tan(); + let (lo, hi) = xs + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), &x| { + (l.min(x), h.max(x)) + }); + let mid_x = 0.5 * (lo + hi); + let mut best: Option<(f64, Vec)> = None; + let mut best_stations = 0usize; + for (&a, &b) in a.iter().zip(b) { + if (xs[a] - xs[b]).abs() < 0.3 * span { + continue; + } + let slope = (ys[b] - ys[a]) / (xs[b] - xs[a]); + if slope.abs() > max_slope { + continue; + } + let intercept = ys[a] - slope * xs[a]; + let inlier: Vec = xs + .iter() + .zip(ys) + .map(|(&x, &y)| (y - (slope * x + intercept)).abs() <= 1.5) + .collect(); + let mut seen: Vec = (0..n).filter(|&i| inlier[i]).map(|i| xs[i]).collect(); + seen.sort_unstable_by(f64::total_cmp); + seen.dedup(); + let stations = seen.len(); + let mid = slope * mid_x + intercept; + let score = stations as f64 * (1.0 - 0.5 * ((mid - expected).abs() / half).min(1.0)); + if best.as_ref().is_none_or(|(s, _)| score > *s) { + best = Some((score, inlier)); + best_stations = stations; + } + } + let (_, inlier) = best?; + if best_stations < 12 { + return None; + } + let (px, py): (Vec, Vec) = (0..n) + .filter(|&i| inlier[i]) + .map(|i| (xs[i], ys[i])) + .unzip(); + Some(polyfit1(&px, &py)) +} + +/// A fitted frame edge in pixels of the detection buffer. +pub struct Line { + pub p1: (f64, f64), + pub p2: (f64, f64), + pub horizontal: bool, + pub inliers: f64, + pub rms: f64, +} + +/// A box bounds its edge to within the film detector's snap window (`snap_film_bounds`). +const BOX_TOLERANCE: f64 = 0.02; +const BOX_TOLERANCE_PX: f64 = 16.0; +const MIN_LINE_SPAN: f64 = 0.25; +/// Admits the bow a scanning lens or film curl puts on a frame edge; its chord stays level. +const MAX_LINE_RMS: f64 = 2.0; +const MIN_INLIER_FRACTION: f64 = 0.5; + +/// `_fit_side`: the edge running along one axis near `at`, between `start` and `stop`, on an +/// HxW luminance plane. `outward` is the sign of the box's outside across the axis, or 0 when +/// `at` is the edge itself. +pub fn fit_side( + lum: &[f32], + dims: (usize, usize), + horizontal: bool, + (at, start, stop): (f64, f64, f64), + outward: i32, +) -> Option { + let (h, w) = if horizontal { dims } else { (dims.1, dims.0) }; + let length = stop - start; + if length < MIN_LINE_SPAN * w as f64 || !(at >= 2.0 && at <= h as f64 - 3.0) { + return None; + } + let num = (240f64.min(24f64.max(length / 4.0))) as usize; + let (first, last) = (start + 0.1 * length, stop - 0.1 * length); + let step = (last - first) / (num - 1) as f64; + let mut stations: Vec = (0..num).map(|i| i as f64 * step + first).collect(); + stations[num - 1] = last; + + let inside = 10f64.max(0.035 * h.max(w) as f64); + let outside = if outward == 0 { + inside + } else { + BOX_TOLERANCE_PX.max(BOX_TOLERANCE * h as f64) + }; + let center = at + 0.5 * outward as f64 * (outside - inside); + let half = 0.5 * (inside + outside); + let (xs, ys) = sample( + lum, + dims, + horizontal, + (&stations, &vec![center; num]), + half, + false, + ); + let (mut slope, mut intercept) = ransac_line(&xs, &ys, at, half, length)?; + let centers: Vec = stations.iter().map(|&x| slope * x + intercept).collect(); + let (xs, ys) = sample( + lum, + dims, + horizontal, + (&stations, ¢ers), + 6f64.max(0.3 * inside), + true, + ); + if xs.len() < 12 { + return None; + } + let mut keep = vec![true; xs.len()]; + for _ in 0..8 { + let (kx, ky): (Vec, Vec) = (0..xs.len()) + .filter(|&i| keep[i]) + .map(|i| (xs[i], ys[i])) + .unzip(); + (slope, intercept) = polyfit1(&kx, &ky); + let res: Vec = xs + .iter() + .zip(&ys) + .map(|(&x, &y)| y - (slope * x + intercept)) + .collect(); + let mut kept: Vec = (0..xs.len()).filter(|&i| keep[i]).map(|i| res[i]).collect(); + let med = crate::stats::median_f64(&mut kept.clone()); + for v in &mut kept { + *v = (*v - med).abs(); + } + let mad = 1.4826 * crate::stats::median_f64(&mut kept); + let bar = 1f64.max(2.5 * mad); + let new: Vec = res.iter().map(|r| r.abs() <= bar).collect(); + if new.iter().filter(|&&v| v).count() < 12 || new == keep { + break; + } + keep = new; + } + let kept: Vec = (0..xs.len()).filter(|&i| keep[i]).collect(); + let inliers = kept.len() as f64 / num as f64; + let sq: f64 = kept + .iter() + .map(|&i| { + let e = ys[i] - (slope * xs[i] + intercept); + e * e + }) + .sum(); + let rms = (sq / kept.len() as f64).sqrt(); + if inliers < MIN_INLIER_FRACTION || rms > MAX_LINE_RMS { + return None; + } + let (x1, x2) = kept + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), &i| { + (l.min(xs[i]), h.max(xs[i])) + }); + if x2 - x1 < MIN_LINE_SPAN * w as f64 { + return None; + } + let (y1, y2) = (slope * x1 + intercept, slope * x2 + intercept); + let (p1, p2) = if horizontal { + ((x1, y1), (x2, y2)) + } else { + ((y1, x1), (y2, x2)) + }; + Some(Line { + p1, + p2, + horizontal, + inliers, + rms, + }) +} + +/// `_outer_peaks`: first and last local maxima of `coverage` at or above `floor`, off the +/// canvas border, at least `_MIN_PAIR_SEPARATION` of the extent apart. +fn outer_peaks(coverage: &[f64], floor: f64) -> Option<(usize, usize)> { + let n = coverage.len(); + let peaks: Vec = (3..n.saturating_sub(3)) + .filter(|&i| { + coverage[i] >= floor && coverage[i] >= coverage[i - 1] && coverage[i] >= coverage[i + 1] + }) + .collect(); + let (&a, &b) = (peaks.first()?, peaks.last()?); + (peaks.len() >= 2 && (b - a) as f64 >= 0.3 * n as f64).then_some((a, b)) +} + +/// Pixels of `|sobel(lum)|` at a quarter of its 99.5th percentile or more, dilated along the +/// edge's normal by `spread`. +fn strong_edges(lum: &[f32], (h, w): (usize, usize), dx: bool, spread: usize) -> Option> { + use crate::filter::{Border, sep_filter2d}; + let (smooth, deriv) = ([1.0f32, 2.0, 1.0], [-1.0f32, 0.0, 1.0]); + let (kx, ky) = if dx { (deriv, smooth) } else { (smooth, deriv) }; + let mut grad = vec![0f32; h * w]; + sep_filter2d(lum, &mut grad, (h, w, 1), (&kx, &ky), Border::Reflect101)?; + grad.iter_mut().for_each(|v| *v = v.abs()); + let bar = (0.25 * crate::stats::np_percentiles(&grad, &[99.5])?[0] as f64) as f32; + let strong: Vec = grad.iter().map(|&g| (g >= bar) as u8).collect(); + let mut out = vec![0u8; h * w]; + let k = 2 * spread + 1; + let ksize = if dx { (k, 1) } else { (1, k) }; + crate::morph::morphology( + &strong, + &mut out, + (h, w, 1), + crate::morph::Op::Dilate, + ksize, + 1, + crate::filter::Border::Constant, + )?; + Some(out) +} + +/// `_profile_lines`: the outermost long edges by row and column coverage, for frames the film +/// detector cannot box. Lines come in opposite pairs or not at all, per axis. +pub fn profile_lines(lum: &[f32], (h, w): (usize, usize)) -> Vec { + let spread = 3usize.max((0.02 * h.max(w) as f64) as usize); + let mut lines = Vec::new(); + let Some(gy) = strong_edges(lum, (h, w), false, spread) else { + return lines; + }; + let (c0, c1) = ((0.05 * w as f64) as usize, (0.95 * w as f64) as usize); + let rows: Vec = (0..h) + .map(|y| { + gy[y * w + c0..y * w + c1] + .iter() + .map(|&v| v as u64) + .sum::() as f64 + / (c1 - c0) as f64 + }) + .collect(); + let found_rows = outer_peaks(&rows, 0.35); + let (top, bottom) = found_rows.unwrap_or((0, h)); + if let Some((a, b)) = found_rows { + for y in [a, b] { + lines.extend(fit_side(lum, (h, w), true, (y as f64, 0.0, w as f64), 0)); + } + } + if let Some(gx) = strong_edges(lum, (h, w), true, spread) { + let inset = (0.05 * (bottom - top) as f64) as usize; + let (lo, hi) = (top + inset, bottom - inset); + if hi > lo + 10 { + let cols: Vec = (0..w) + .map(|x| { + (lo..hi).map(|y| gx[y * w + x] as u64).sum::() as f64 / (hi - lo) as f64 + }) + .collect(); + if let Some((a, b)) = outer_peaks(&cols, 0.35) { + for x in [a, b] { + lines.extend(fit_side( + lum, + (h, w), + false, + (x as f64, top as f64, bottom as f64), + 0, + )); + } + } + } + } + let count = |horizontal: bool, lines: &[Line]| { + lines.iter().filter(|l| l.horizontal == horizontal).count() + }; + let (nh, nv) = (count(true, &lines), count(false, &lines)); + lines.retain(|l| if l.horizontal { nh >= 2 } else { nv >= 2 }); + lines +} + +/// `_at_canvas_edges`: whether every line (horizontal, position) hugs the canvas edge, paired on +/// both axes, with no opaque holder outside it. +pub fn at_canvas_edges(lum: &[f32], (h, w): (usize, usize), lines: &[(bool, f64)]) -> bool { + // A tight scan's border lines sit within this fraction of the cross extent from the edge. + const CANVAS_EDGE_BAND: f64 = 0.12; + // Film base or bed outside a border never reads this dark; an opaque holder does. + const OPAQUE_RING: f64 = 0.04; + const MIN_PAIR_SEPARATION: f64 = 0.3; + let extent = |horizontal: bool| if horizontal { h } else { w }; + if lines.is_empty() + || lines.iter().any(|&(horizontal, position)| { + let e = extent(horizontal) as f64; + position.min(e - position) > CANVAS_EDGE_BAND * e + }) + { + return false; + } + for &(horizontal, position) in lines { + let extent = extent(horizontal); + let index = position as usize; + let (lo, hi) = if position < extent as f64 / 2.0 { + (0, index.saturating_sub(2)) + } else { + ((index + 3).min(extent), extent) + }; + let mut outside: Vec = if horizontal { + lum[lo * w..hi * w].to_vec() + } else { + lum.chunks_exact(w) + .flat_map(|row| row[lo..hi].iter().copied()) + .collect() + }; + if !outside.is_empty() && (crate::stats::np_median(&mut outside) as f64) < OPAQUE_RING { + return false; + } + } + let spread = |horizontal: bool, extent: usize| { + let pos: Vec = lines + .iter() + .filter(|l| l.0 == horizontal) + .map(|l| l.1) + .collect(); + if pos.len() < 2 { + return 0.0; + } + let (lo, hi) = pos + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), &p| { + (l.min(p), h.max(p)) + }); + (hi - lo) / extent as f64 + }; + spread(true, h) >= MIN_PAIR_SEPARATION && spread(false, w) >= MIN_PAIR_SEPARATION +} + +/// `_coarse_angles`: the dominant folded angles (degrees) of long line segments on an HxW +/// luminance plane, strongest first, with a runner-up at a quarter of its weight or more. +pub fn coarse_angles( + lum: &[f32], + (h, w): (usize, usize), + min_segment: f64, + max_skew: f64, + angle_bin: f64, +) -> Vec { + // Squaring the frame and the crop's own edge fit both measure the same plane. + static LAST: crate::memo::Memo, 4> = crate::memo::Memo::new(); + let params = [min_segment, max_skew, angle_bin].map(|v| v.to_bits() as i64); + let key = crate::memo::extend(crate::memo::key(lum, &[h, w]), ¶ms); + LAST.get_or(key, || { + segment_angles(lum, (h, w), min_segment, max_skew, angle_bin) + }) +} + +fn segment_angles( + lum: &[f32], + (h, w): (usize, usize), + min_segment: f64, + max_skew: f64, + angle_bin: f64, +) -> Vec { + let q = crate::stats::np_percentiles_f64(lum, &[1.0, 99.0]); + let (lo, span) = (q[0], (q[1] - q[0]).max(1e-6)); + let mut u8s = vec![0u8; lum.len()]; + crate::par_zip(&mut u8s, lum, move |o, &v| { + *o = (((v as f64 - lo) / span).clamp(0.0, 1.0) * 255.0) as u8; + }); + let min_len = min_segment * h.max(w) as f64; + let found: Vec<(f64, f64)> = crate::lsd::line_segments(&u8s, (h, w)) + .iter() + .map(|s| { + let (dx, dy) = ((s[2] - s[0]) as f64, (s[3] - s[1]) as f64); + let angle = libm::atan2(dy, dx).to_degrees(); + ((angle + 45.0).rem_euclid(90.0) - 45.0, dx.hypot(dy)) + }) + .filter(|&(a, l)| l >= min_len && a.abs() <= max_skew) + .collect(); + if found.is_empty() { + return Vec::new(); + } + // np.arange(-max_skew, max_skew + angle_bin, angle_bin), filled as NumPy fills it. + let start = -max_skew; + let delta = (start + angle_bin) - start; + let n = (((max_skew + angle_bin) - start) / angle_bin).ceil() as usize; + let edges: Vec = (0..n).map(|i| start + i as f64 * delta).collect(); + let mut hist = vec![0f64; n - 1]; + for &(a, l) in &found { + if a < edges[0] || a > edges[n - 1] { + continue; + } + let bin = (edges.partition_point(|&e| e <= a) - 1).min(n - 2); + hist[bin] += l * l; + } + let hist32: Vec = hist.iter().map(|&v| v as f32).collect(); + let mut smooth = vec![0f32; n - 1]; + if filter::gaussian_blur( + &hist32, + &mut smooth, + (1, n - 1, 1), + (0, 0), + (3.0, 0.0), + Border::Reflect101, + ) + .is_none() + { + return Vec::new(); + } + let argmax = |v: &[f32]| { + v.iter() + .enumerate() + .fold(0, |b, (i, &x)| if x > v[b] { i } else { b }) + }; + let first = argmax(&smooth); + let mut modes = vec![edges[first] + angle_bin / 2.0]; + let mut rest = smooth.clone(); + rest[first.saturating_sub(10)..(first + 11).min(n - 1)].fill(0.0); + let second = argmax(&rest); + if rest[second] >= 0.25 * smooth[first] { + modes.push(edges[second] + angle_bin / 2.0); + } + modes +} + +#[cfg(test)] +mod tests { + use super::Pcg64; + + fn draws(n: u32, count: usize) -> Vec { + let mut rng = Pcg64::seed0(); + (0..count).map(|_| rng.below(n)).collect() + } + + #[test] + fn default_rng_0_integers_match_numpy() { + assert_eq!(draws(17, 12), [14, 10, 8, 4, 5, 0, 1, 0, 2, 13, 11, 15]); + assert_eq!( + draws(1_000_003, 6), + [850626, 636963, 511138, 269787, 307830, 40973] + ); + assert_eq!(draws(2, 8), [1, 1, 1, 0, 0, 0, 0, 0]); + } +} diff --git a/negpy_core/src/stats.rs b/negpy_core/src/stats.rs new file mode 100644 index 000000000..e4ec4dd37 --- /dev/null +++ b/negpy_core/src/stats.rs @@ -0,0 +1,502 @@ +use rayon::prelude::*; + +const BUCKETS: usize = 1 << 16; +const CHUNK: usize = 1 << 16; +/// Below this size one `select_nth_unstable` beats the histogram passes. +const DIRECT_MAX: usize = 1 << 17; + +/// Total-order key of an f32. Every NaN maps to the top, where `np.partition` puts it. +fn order_key(v: f32) -> u32 { + if v.is_nan() { + return u32::MAX; + } + let bits = v.to_bits(); + if bits & 0x8000_0000 != 0 { + !bits + } else { + bits | 0x8000_0000 + } +} + +fn from_key(key: u32) -> f32 { + if key & 0x8000_0000 != 0 { + f32::from_bits(key & 0x7fff_ffff) + } else { + f32::from_bits(!key) + } +} + +/// The lower rank of `q` and its interpolation weight, in numpy's float64 arithmetic. +fn virtual_rank(n: usize, q: f64) -> (usize, f64) { + let virtual_index = (n - 1) as f64 * (q / 100.0); + let lo = (virtual_index.floor().max(0.0) as usize).min(n - 1); + (lo, virtual_index - lo as f64) +} + +/// numpy's interpolation for a Python-number q: the weight joins float32 math (NEP 50), +/// and the far half counts back from the upper sample. +fn interpolate(a: f32, b: f32, t: f64) -> f32 { + let diff = b - a; + if t >= 0.5 { + b - diff * ((1.0 - t) as f32) + } else { + a + diff * (t as f32) + } +} + +/// The value at each rank in `ranks` (sorted, unique) if `values` were sorted. +fn select_ranks(values: &[f32], ranks: &[usize]) -> Vec { + if values.len() <= DIRECT_MAX { + let mut keys: Vec = values.iter().map(|&v| order_key(v)).collect(); + return ranks + .iter() + .map(|&r| from_key(*keys.select_nth_unstable(r).1)) + .collect(); + } + + // One histogram per thread-sized block, and at least a million values per block: each costs a + // megabyte to clear and merge. Four interleaved u32 lanes, because on a smooth image + // neighbours share a bucket and one counter would serialize on its own increments. + let block = values + .len() + .div_ceil(rayon::current_num_threads()) + .max(1 << 20); + let hist = values + .par_chunks(block) + .map(|chunk| { + let mut h = vec![0u32; 4 * BUCKETS]; + let (quads, rest) = chunk.as_chunks::<4>(); + for q in quads { + for (j, &v) in q.iter().enumerate() { + h[j * BUCKETS + (order_key(v) >> 16) as usize] += 1; + } + } + for &v in rest { + h[(order_key(v) >> 16) as usize] += 1; + } + let mut sum = vec![0usize; BUCKETS]; + for lane in h.as_chunks::().0 { + sum.iter_mut() + .zip(lane) + .for_each(|(s, &c)| *s += c as usize); + } + sum + }) + .reduce( + || vec![0usize; BUCKETS], + |mut a, b| { + a.iter_mut().zip(&b).for_each(|(x, y)| *x += y); + a + }, + ); + + // Each rank becomes (bucket, rank inside the bucket); ranks are sorted, so one walk finds all. + let mut located = Vec::with_capacity(ranks.len()); + let mut below = 0usize; + let mut bucket = 0usize; + for &r in ranks { + while below + hist[bucket] <= r { + below += hist[bucket]; + bucket += 1; + } + located.push((bucket, r - below)); + } + + let mut wanted: Vec = located.iter().map(|&(b, _)| b as u32).collect(); + wanted.dedup(); + let slot = |key: u32| wanted.iter().position(|&b| b == key >> 16); + + // Matches are rare: a vectorized test per block skips the blocks with none. + let gathered = values + .par_chunks(CHUNK) + .fold( + || vec![Vec::new(); wanted.len()], + |mut g: Vec>, chunk| { + for block in chunk.chunks(64) { + // One pass per wanted bucket keeps the test a plain vector compare. + let hit = wanted.iter().fold(false, |any, &b| { + any | block + .iter() + .fold(false, |a, &v| a | (order_key(v) >> 16 == b)) + }); + if hit { + for &v in block { + let key = order_key(v); + if let Some(i) = slot(key) { + g[i].push(key); + } + } + } + } + g + }, + ) + .reduce( + || vec![Vec::new(); wanted.len()], + |mut a, b| { + a.iter_mut().zip(b).for_each(|(x, y)| x.extend(y)); + a + }, + ); + + let mut gathered = gathered; + located + .iter() + .map(|&(b, r)| { + let keys = &mut gathered[wanted.iter().position(|&w| w as usize == b).unwrap_or(0)]; + from_key(*keys.select_nth_unstable(r).1) + }) + .collect() +} + +/// The two samples around each q's virtual rank and the weight between them. +fn rank_pairs(values: &[f32], qs: &[f64]) -> Vec<(f32, f32, f64)> { + let n = values.len(); + let spans: Vec<(usize, usize, f64)> = qs + .iter() + .map(|&q| { + let (lo, t) = virtual_rank(n, q); + (lo, (lo + 1).min(n - 1), t) + }) + .collect(); + let mut ranks: Vec = spans.iter().flat_map(|&(lo, hi, _)| [lo, hi]).collect(); + ranks.sort_unstable(); + ranks.dedup(); + let picked = select_ranks(values, &ranks); + let at = |r: usize| picked[ranks.binary_search(&r).unwrap_or_default()]; + spans + .iter() + .map(|&(lo, hi, t)| (at(lo), at(hi), t)) + .collect() +} + +/// `np.percentile(values, q)` (method "linear") for each q, bit for bit, except that -0.0 and +/// 0.0 may swap: `np.partition` ranks them equal and returns either. `None` for an empty input. +pub fn percentiles(values: &[f32], qs: &[f64]) -> Option> { + if values.is_empty() { + return None; + } + Some( + rank_pairs(values, qs) + .into_iter() + .map(|(a, b, t)| interpolate(a, b, t)) + .collect(), + ) +} + +/// `np.percentile(values, (a, b, ...))`: a sequence of q interpolates in float64. NaN for every +/// q when any value is NaN or the input is empty. +pub fn np_percentiles_f64(values: &[f32], qs: &[f64]) -> Vec { + if values.is_empty() || values.iter().any(|v| v.is_nan()) { + return vec![f64::NAN; qs.len()]; + } + rank_pairs(values, qs) + .into_iter() + .map(|(a, b, t)| { + let (a, b) = (a as f64, b as f64); + if t >= 0.5 { + b - (b - a) * (1.0 - t) + } else { + a + (b - a) * t + } + }) + .collect() +} + +/// `np.median` of float32 values: the middle one, or the float32 mean of the two middle ones; +/// NaN when any value is NaN or there are none. May reorder `values`. +pub fn np_median(values: &mut [f32]) -> f32 { + let n = values.len(); + if n > DIRECT_MAX { + return median(values); + } + if values.is_empty() || values.iter().any(|v| v.is_nan()) { + return f32::NAN; + } + let (_, &mut hi, _) = values.select_nth_unstable_by(n / 2, f32::total_cmp); + if n % 2 == 1 { + return hi; + } + let lo = values[..n / 2] + .iter() + .copied() + .fold(f32::NEG_INFINITY, f32::max); + (lo + hi) / 2.0 +} + +/// `np.median` of float32 values without reordering them, by the histogram selection. +pub fn median(values: &[f32]) -> f32 { + let n = values.len(); + if n == 0 || values.par_iter().any(|v| v.is_nan()) { + return f32::NAN; + } + if n % 2 == 1 { + return select_ranks(values, &[n / 2])[0]; + } + let m = select_ranks(values, &[n / 2 - 1, n / 2]); + (m[0] + m[1]) / 2.0 +} + +/// NumPy's float32 sum of a contiguous run: one pairwise sum (blocks of 128, eight +/// accumulators), the order `np.sum` and `mean(axis=-1)` use. +pub fn pairwise_sum_f32(a: &[f32]) -> f32 { + let n = a.len(); + if n < 8 { + return a.iter().fold(-0.0f32, |s, &v| s + v); + } + if n <= 128 { + let mut r = [0f32; 8]; + r.copy_from_slice(&a[..8]); + let mut i = 8; + while i < n - n % 8 { + for j in 0..8 { + r[j] += a[i + j]; + } + i += 8; + } + let mut res = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7])); + for &v in &a[i..] { + res += v; + } + return res; + } + let mut n2 = n / 2; + n2 -= n2 % 8; + pairwise_sum_f32(&a[..n2]) + pairwise_sum_f32(&a[n2..]) +} + +/// `np.quantile(values, q)` for a Python-number q in 0..1: the virtual rank is `(n - 1) * q`, +/// not `percentile`'s `(n - 1) * (q / 100)`. NaN when any value is NaN; `None` when empty. +pub fn np_quantile(values: &[f32], q: f64) -> Option { + if values.is_empty() { + return None; + } + if values.iter().any(|v| v.is_nan()) { + return Some(f32::NAN); + } + let n = values.len(); + let virtual_index = (n - 1) as f64 * q; + let lo = (virtual_index.floor().max(0.0) as usize).min(n - 1); + let hi = (lo + 1).min(n - 1); + let mut ranks = vec![lo, hi]; + ranks.dedup(); + let picked = select_ranks(values, &ranks); + Some(interpolate( + picked[0], + picked[ranks.len() - 1], + virtual_index - lo as f64, + )) +} + +/// `np.percentile` itself, which is NaN for every q when any value is NaN; `percentiles` +/// matches NegPy's partition helper, which callers feed only finite values. +pub fn np_percentiles(values: &[f32], qs: &[f64]) -> Option> { + let out = percentiles(values, qs)?; + if values.iter().any(|v| v.is_nan()) { + return Some(vec![f32::NAN; qs.len()]); + } + Some(out) +} + +/// NumPy's linear interpolation between two float64 samples for a float64 weight: the far +/// half counts back from the upper sample. +fn lerp_f64(a: f64, b: f64, t: f64) -> f64 { + let diff = b - a; + if t >= 0.5 { + b - diff * (1.0 - t) + } else { + a + diff * t + } +} + +/// `np.quantile(values, q)` of float64 values for q in 0..1 (`np.percentile` passes q / 100). +/// Reorders `values`; `None` when empty. +pub fn quantile_f64(values: &mut [f64], q: f64) -> Option { + let n = values.len(); + if n == 0 { + return None; + } + let virtual_index = (n - 1) as f64 * q; + let lo = (virtual_index.floor().max(0.0) as usize).min(n - 1); + let hi = (lo + 1).min(n - 1); + let a = *values.select_nth_unstable_by(lo, f64::total_cmp).1; + let b = if hi == lo { + a + } else { + values[lo + 1..] + .iter() + .copied() + .fold(f64::INFINITY, f64::min) + }; + Some(lerp_f64(a, b, virtual_index - lo as f64)) +} + +/// `np.median` of float64 values: the middle one, or the mean of the two middle ones. +/// Reorders `values`; NaN when empty. +pub fn median_f64(values: &mut [f64]) -> f64 { + let n = values.len(); + if n == 0 { + return f64::NAN; + } + let hi = *values.select_nth_unstable_by(n / 2, f64::total_cmp).1; + if n % 2 == 1 { + return hi; + } + let lo = values[..n / 2] + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + (lo + hi) / 2.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sorted_percentile(values: &[f32], q: f64) -> f32 { + let mut s = values.to_vec(); + s.sort_by(f32::total_cmp); + let (lo, t) = virtual_rank(s.len(), q); + interpolate(s[lo], s[(lo + 1).min(s.len() - 1)], t) + } + + fn lcg(n: usize, seed: u64) -> Vec { + let mut x = seed; + (0..n) + .map(|_| { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((x >> 40) as f32 / (1u64 << 24) as f32) * 4.0 - 1.0 + }) + .collect() + } + + #[test] + fn quantile_ranks_at_n_minus_one_times_q() { + for n in [1, 2, 7, 64, 1000, 70_000] { + let v = lcg(n, n as u64); + let mut s = v.clone(); + s.sort_by(f32::total_cmp); + let vi = (n - 1) as f64 * 0.3; + let lo = vi.floor() as usize; + let want = interpolate(s[lo], s[(lo + 1).min(n - 1)], vi - lo as f64); + assert_eq!(np_quantile(&v, 0.3), Some(want), "{n}"); + } + assert_eq!(np_quantile(&[], 0.3), None); + } + + #[test] + fn pairwise_sum_regions() { + let v: Vec = (0..1000).map(|i| (i as f32).sqrt()).collect(); + for n in [0, 1, 7, 8, 9, 128, 129, 1000] { + let exact: f64 = v[..n].iter().map(|&x| x as f64).sum(); + assert!( + (pairwise_sum_f32(&v[..n]) as f64 - exact).abs() < 1e-2, + "n={n}" + ); + } + } + + #[test] + fn empty_is_none() { + assert!(percentiles(&[], &[50.0]).is_none()); + } + + #[test] + fn one_element() { + assert_eq!( + percentiles(&[3.5], &[0.0, 50.0, 100.0]).unwrap(), + vec![3.5; 3] + ); + } + + #[test] + fn two_elements_interpolate() { + assert_eq!( + percentiles(&[1.0, 3.0], &[50.0, 25.0, 75.0]).unwrap(), + vec![2.0, 1.5, 2.5] + ); + } + + #[test] + fn all_equal() { + let v = vec![0.25f32; 100_000]; + assert_eq!(percentiles(&v, &[1.0, 50.0, 99.0]).unwrap(), vec![0.25; 3]); + } + + #[test] + fn infinities_and_signed_zero() { + let v = [f32::INFINITY, -0.0, 0.0, f32::NEG_INFINITY, 1.0]; + // numpy's lerp gives NaN at an infinite end (inf * 0), and so does the port. + let got = percentiles(&v, &[0.0, 50.0, 100.0]).unwrap(); + assert!(got[0].is_nan() && got[2].is_nan()); + assert_eq!(got[1], 0.0); + } + + #[test] + fn nan_sorts_last() { + let v = [f32::NAN, 2.0, 1.0, -f32::NAN, 3.0]; + assert_eq!(percentiles(&v, &[0.0, 25.0]).unwrap(), vec![1.0, 2.0]); + assert!(percentiles(&v, &[50.0]).unwrap()[0].is_nan()); + } + + #[test] + fn order_key_round_trips_and_orders() { + let vals = [ + f32::NEG_INFINITY, + -2.5, + -f32::MIN_POSITIVE, + -0.0, + 0.0, + 1e-30, + 7.0, + f32::INFINITY, + ]; + for w in vals.windows(2) { + assert!(order_key(w[0]) < order_key(w[1])); + } + for v in vals { + assert_eq!(from_key(order_key(v)).to_bits(), v.to_bits()); + } + } + + #[test] + fn histogram_path_matches_sort() { + let qs = [0.0, 0.5, 1.0, 20.0, 50.0, 60.0, 80.0, 99.0, 99.5, 100.0]; + for (n, seed) in [(DIRECT_MAX + 1, 1), (1_000_003, 2)] { + let v = lcg(n, seed); + let got = percentiles(&v, &qs).unwrap(); + for (g, &q) in got.iter().zip(&qs) { + assert_eq!( + g.to_bits(), + sorted_percentile(&v, q).to_bits(), + "n={n} q={q}" + ); + } + } + } + + #[test] + fn histogram_path_with_more_buckets_than_a_byte() { + let v = lcg(100_000, 4); + let qs: Vec = (0..300).map(|i| i as f64 / 3.0).collect(); + let got = percentiles(&v, &qs).unwrap(); + for (g, &q) in got.iter().zip(&qs) { + assert_eq!(g.to_bits(), sorted_percentile(&v, q).to_bits(), "q={q}"); + } + } + + #[test] + fn histogram_path_with_heavy_repeats() { + let v: Vec = lcg(200_000, 3) + .iter() + .map(|x| (x * 4.0).round() / 4.0) + .collect(); + let qs = [1.0, 50.0, 99.0]; + let got = percentiles(&v, &qs).unwrap(); + for (g, &q) in got.iter().zip(&qs) { + assert_eq!(g.to_bits(), sorted_percentile(&v, q).to_bits()); + } + } +} diff --git a/negpy_core/src/stitch.rs b/negpy_core/src/stitch.rs new file mode 100644 index 000000000..4a3521545 --- /dev/null +++ b/negpy_core/src/stitch.rs @@ -0,0 +1,204 @@ +//! The panorama gain compensation of negpy/features/stitch/logic.py. + +use rayon::prelude::*; + +/// `gain_compensate`'s per-channel gains: the float64 mean of `reference` over the overlap +/// over that of `moving`, rows added in order. `None` under `min_overlap` pixels or for a +/// mean under 1e-6, where the part stays as it is. +pub fn compensation_gains( + reference: &[f32], + ref_mask: &[bool], + moving: &[f32], + mov_mask: &[bool], + min_overlap: usize, +) -> Option<[f32; 3]> { + let (mut a, mut b, mut n) = ([0f64; 3], [0f64; 3], 0usize); + for (p, (&r, &m)) in ref_mask.iter().zip(mov_mask).enumerate() { + if r && m { + for c in 0..3 { + a[c] += reference[3 * p + c] as f64; + b[c] += moving[3 * p + c] as f64; + } + n += 1; + } + } + if n < min_overlap { + return None; + } + let (a, b) = (a.map(|v| v / n as f64), b.map(|v| v / n as f64)); + if b.iter().any(|&v| v < 1e-6) { + return None; + } + Some([0, 1, 2].map(|c| (a[c] / b[c]) as f32)) +} + +/// One part's `seam_blend` weight: a cross-fade ramp of width `2 * feather` on the signed +/// distance to the cut, times a ramp of width `feather` up from the part's coverage edge, in +/// float32. The two distances to the cut run at once; the coverage distance runs after them. +pub fn seam_weight( + seam: &[bool], + valid: &[bool], + (h, w): (usize, usize), + feather: f64, +) -> Vec { + use crate::distance::{Metric, distance_transform}; + use rayon::prelude::*; + const BLOCK: usize = 1 << 14; + let dist = |m: &[bool], inside: bool| { + let src: Vec = m.par_iter().map(|&v| (v == inside) as u8).collect(); + distance_transform(&src, h, w, Metric::L2Mask3) + }; + let span = (2.0 * feather) as f32; + let (mut weight, beyond) = rayon::join(|| dist(seam, true), || dist(seam, false)); + weight + .par_chunks_mut(BLOCK) + .zip(beyond.par_chunks(BLOCK).zip(seam.par_chunks(BLOCK))) + .for_each(move |(wt, (b, s))| { + for ((wt, &b), &s) in wt.iter_mut().zip(b).zip(s) { + *wt = if s { + (*wt / span + 0.5).clamp(0.0, 1.0) + } else { + (0.5 - b / span).clamp(0.0, 1.0) + }; + } + }); + drop(beyond); + let edge = dist(valid, true); + let feather = feather as f32; + crate::par_zip(&mut weight, &edge, move |wt, &e| { + *wt *= (e / feather).clamp(0.0, 1.0) + }); + weight +} + +/// The weighted mean of HxWx3 parts, the weight of part `i` from `weight(i)`: parts added in +/// order as NumPy adds `img * weight[..., None]`, over `max(weight sum, 1e-6)`. One weight plane +/// is alive at a time. +fn weighted_mean(images: &[&[f32]], n: usize, weight: impl Fn(usize) -> Vec) -> Vec { + use rayon::prelude::*; + const BLOCK: usize = 1 << 13; + let mut acc = vec![0f32; 3 * n]; + let mut sum = vec![0f32; n]; + for (i, img) in images.iter().enumerate() { + let wt = weight(i); + acc.par_chunks_mut(3 * BLOCK) + .zip(sum.par_chunks_mut(BLOCK)) + .zip(img.par_chunks(3 * BLOCK).zip(wt.par_chunks(BLOCK))) + .for_each(move |((a, s), (px, wt))| { + for (((a, s), px), &wt) in a + .as_chunks_mut::<3>() + .0 + .iter_mut() + .zip(s) + .zip(px.as_chunks::<3>().0) + .zip(wt) + { + for c in 0..3 { + a[c] += px[c] * wt; + } + *s += wt; + } + }); + } + let floor = 1e-6f32; + acc.par_chunks_mut(3 * BLOCK) + .zip(sum.par_chunks(BLOCK)) + .for_each(move |(a, s)| { + for (a, &s) in a.as_chunks_mut::<3>().0.iter_mut().zip(s) { + let d = if floor > s { floor } else { s }; + for v in a { + *v /= d; + } + } + }); + acc +} + +/// `feather_blend`: the parts weighted by the distance to their own coverage edge. +pub fn feather_blend(images: &[&[f32]], masks: &[&[bool]], (h, w): (usize, usize)) -> Vec { + use crate::distance::{Metric, distance_transform}; + use rayon::prelude::*; + weighted_mean(images, h * w, |i| { + let src: Vec = masks[i].par_iter().map(|&v| v as u8).collect(); + distance_transform(&src, h, w, Metric::L2Mask3) + }) +} + +/// `seam_blend`: the parts weighted by `seam_weight` with a band of `feather`. +pub fn seam_blend( + images: &[&[f32]], + masks: &[&[bool]], + seams: &[&[bool]], + (h, w): (usize, usize), + feather: f64, +) -> Vec { + weighted_mean(images, h * w, |i| { + seam_weight(seams[i], masks[i], (h, w), feather) + }) +} + +/// `build_proxy`'s stretch of a float32 plane: its 1st and 99th percentiles (float64, as for a +/// sequence of q) onto 0..1, clipped, raised to 1 / 2.2 and scaled to uint8 by truncation. +pub fn proxy_stretch(gray: &[f32], dst: &mut [u8]) { + let p = crate::stats::np_percentiles_f64(gray, &[1.0, 99.0]); + let (lo, hi) = (p[0], p[1]); + let span = (hi - lo).max(1e-9); + crate::par_zip(dst, gray, move |d, &v| { + let t = ((v as f64 - lo) / span).clamp(0.0, 1.0); + // NaN casts to 0, as NumPy's float to uint8 cast does on x86. + *d = (libm::pow(t, 1.0 / 2.2) * 255.0) as u8; + }); +} + +/// One part of `blend_ir`: where `sel` is set, the canvas takes the part's value, or the larger +/// of the two with `take_max`. +pub fn ir_blend_into(acc: &mut [f32], warped: &[f32], sel: &[bool], take_max: bool) { + acc.par_chunks_mut(1 << 14) + .zip(warped.par_chunks(1 << 14).zip(sel.par_chunks(1 << 14))) + .for_each(|(a, (w, s))| { + for ((a, &w), &s) in a.iter_mut().zip(w).zip(s) { + if s { + *a = if take_max { a.max(w) } else { w }; + } + } + }); +} + +/// `blend_ir`'s last step: every canvas pixel no part reached (below 0) becomes 1.0. +pub fn ir_finish(acc: &mut [f32]) { + acc.par_chunks_mut(1 << 14).for_each(|a| { + for v in a { + if *v < 0.0 { + *v = 1.0; + } + } + }); +} + +/// `(a & b).sum()` of two masks. +pub fn mask_overlap(a: &[bool], b: &[bool]) -> usize { + a.par_chunks(1 << 16) + .zip(b.par_chunks(1 << 16)) + .map(|(a, b)| a.iter().zip(b).filter(|(x, y)| **x & **y).count()) + .sum() +} + +/// `img *= gains` per channel of an HxWx3 float32 image, in place. +pub fn scale_channels(img: &mut [f32], gains: [f32; 3]) { + img.par_chunks_mut(3 << 14).for_each(|c| { + for p in c.as_chunks_mut::<3>().0 { + for k in 0..3 { + p[k] *= gains[k]; + } + } + }); +} + +/// `np.clip(img, 0, 1)` in place; NaN stays. +pub fn clip_unit(img: &mut [f32]) { + img.par_chunks_mut(1 << 14).for_each(|c| { + for v in c { + *v = v.clamp(0.0, 1.0); + } + }); +} diff --git a/negpy_core/src/toning.rs b/negpy_core/src/toning.rs new file mode 100644 index 000000000..d1751aaf8 --- /dev/null +++ b/negpy_core/src/toning.rs @@ -0,0 +1,127 @@ +//! `_apply_chemical_toning_jit` of negpy/features/toning/logic.py: silver-ledger toning on +//! linear reflectance, in float64 as Numba types it. + +// The steps index several per-pixel arrays of one run in step. +#![allow(clippy::needless_range_loop)] + +use multiversion::multiversion; +use rayon::prelude::*; + +use crate::mathf::{exp10_f64, log10_f64, pow_f64}; + +/// One bath: strength, reference density, exponent, per-channel covering-power gain. +#[derive(Clone, Copy)] +pub struct Bath { + pub strength: f64, + pub d_ref: f64, + pub power: f64, + pub gain: [f64; 3], +} + +#[derive(Clone, Copy)] +pub struct Toning { + pub selenium: Bath, + pub sepia: Bath, + pub gold: Bath, + pub gold_sepia_gain: [f64; 3], + pub blue: Bath, + pub copper: Bath, + pub vanadium: Bath, + /// Gold converts all densities alike (a lith print) instead of highlights first. + pub gold_flat: bool, +} + +impl Bath { + /// Conversion per pixel of `d0`, capped at 1: strength (D / d_ref)^power when `rising`, + /// else strength (1 - D / d_ref)^power. All zero while the bath is off. + fn conversion(&self, d0: &[f64], out: &mut [f64], rising: bool) { + if self.strength <= 0.0 { + out.fill(0.0); + return; + } + for (c, &d) in out.iter_mut().zip(d0) { + let frac = (d / self.d_ref).min(1.0); + let x = if rising { frac } else { 1.0 - frac }; + *c = (self.strength * pow_f64(x, self.power)).min(1.0); + } + } +} + +pub fn apply_chemical_toning(img: &[f32], dst: &mut [f32], t: &Toning) { + dst.par_chunks_mut(3 * RUN) + .zip(img.par_chunks(3 * RUN)) + .for_each(|(d, s)| tone_run(s, d, t)); +} + +/// Pixels per pass: each bath's conversion runs as a flat loop over a run, so it vectorizes. +const RUN: usize = 256; + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn tone_run(src: &[f32], dst: &mut [f32], t: &Toning) { + let px = src.as_chunks::<3>().0; + let n = px.len(); + let mut d3 = [[0f64; RUN]; 3]; + for (ch, d) in d3.iter_mut().enumerate() { + for (d, p) in d.iter_mut().zip(px) { + *d = -log10_f64((p[ch] as f64).clamp(1e-6, 1.0)); + } + } + let mut d0 = [0f64; RUN]; + for (i, m) in d0[..n].iter_mut().enumerate() { + *m = (d3[0][i] + d3[1][i] + d3[2][i]) / 3.0; + } + let d0 = &d0[..n]; + let mut c = [[0f64; RUN]; 6]; + t.selenium.conversion(d0, &mut c[0][..n], true); + t.sepia.conversion(d0, &mut c[1][..n], false); + if t.gold_flat { + c[2][..n].fill(t.gold.strength.clamp(0.0, 1.0)); + } else { + t.gold.conversion(d0, &mut c[2][..n], false); + } + t.blue.conversion(d0, &mut c[3][..n], true); + t.copper.conversion(d0, &mut c[4][..n], true); + t.vanadium.conversion(d0, &mut c[5][..n], false); + // The ledger leaves each channel's covering-power factor k. + let mut k = [[0f64; RUN]; 3]; + for i in 0..n { + let [c_sel, c_sep, c_au, c_blue, c_cu, c_van] = std::array::from_fn(|j| c[j][i]); + // One silver reservoir: each bath claims a share of what is left; gold also plates + // the sulfide. + let mut a = 1.0; + let f_sel = a * c_sel; + a -= f_sel; + let mut f_sep = a * c_sep; + a -= f_sep; + let f_au = a * c_au; + a -= f_au; + let f_ausp = f_sep * c_au; + f_sep -= f_ausp; + let f_blue = a * c_blue; + a -= f_blue; + let f_cu = a * c_cu; + a -= f_cu; + let f_van = a * c_van; + a -= f_van; + for ch in 0..3 { + k[ch][i] = a + + f_sel * t.selenium.gain[ch] + + f_sep * t.sepia.gain[ch] + + f_au * t.gold.gain[ch] + + f_ausp * t.sepia.gain[ch] * t.gold_sepia_gain[ch] + + f_blue * t.blue.gain[ch] + + f_cu * t.copper.gain[ch] + + f_van * t.vanadium.gain[ch]; + } + } + let out = dst.as_chunks_mut::<3>().0; + for ch in 0..3 { + for i in 0..n { + out[i][ch] = exp10_f64(-(d3[ch][i] * k[ch][i])).clamp(0.0, 1.0) as f32; + } + } +} diff --git a/negpy_core/src/warp.rs b/negpy_core/src/warp.rs new file mode 100644 index 000000000..85499257a --- /dev/null +++ b/negpy_core/src/warp.rs @@ -0,0 +1,627 @@ +//! OpenCV's warps of a float32 image: `remap`, `warpAffine` and `warpPerspective`, bilinear or +//! bicubic, with replicated edges or a zero border. Each source coordinate is fixed point at +//! 1/32 px, rounded as OpenCV rounds it; the weights are its table's float32 products, summed +//! in its order. + +use multiversion::multiversion; +use rayon::prelude::*; + +/// An HxWxC float32 image. +#[derive(Clone, Copy)] +pub struct Image<'a> { + pub data: &'a [f32], + pub h: usize, + pub w: usize, + pub cn: usize, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Interp { + /// `remap` only: the source pixel nearest the map point. + Nearest, + Linear, + Cubic, +} + +/// What a sample outside the source reads: the nearest edge pixel, or zero. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Border { + Replicate, + Constant, +} + +const BITS: i32 = 5; +const MASK: i32 = (1 << BITS) - 1; +const STEP: f32 = 1.0 / (1 << BITS) as f32; + +/// OpenCV's bicubic weight table: for each pair of 1/32 px fractions (y major), the products +/// of `interpolateCubic` (A = -0.75) of the y and the x fraction, row major. +type CubicTable = [[f32; 16]; 1 << (2 * BITS)]; + +fn cubic_table() -> &'static CubicTable { + static TAB: std::sync::OnceLock> = std::sync::OnceLock::new(); + TAB.get_or_init(|| { + const A: f32 = -0.75; + let c: [[f32; 4]; 1 << BITS] = std::array::from_fn(|i| { + let x = i as f32 * STEP; + let c0 = ((A * (x + 1.0) - 5.0 * A) * (x + 1.0) + 8.0 * A) * (x + 1.0) - 4.0 * A; + let c1 = ((A + 2.0) * x - (A + 3.0)) * x * x + 1.0; + let c2 = ((A + 2.0) * (1.0 - x) - (A + 3.0)) * (1.0 - x) * (1.0 - x) + 1.0; + [c0, c1, c2, 1.0 - c0 - c1 - c2] + }); + let mut tab = Box::new([[0f32; 16]; 1 << (2 * BITS)]); + for (f, w) in tab.iter_mut().enumerate() { + let (cy, cx) = (c[f >> BITS], c[f & MASK as usize]); + *w = std::array::from_fn(|i| cy[i / 4] * cx[i % 4]); + } + tab + }) +} + +/// The source index of `v` on an axis of `n` under `border`; None reads zero. +#[inline(always)] +fn edge(v: i32, n: usize, border: Border) -> Option { + match border { + Border::Replicate => Some(v.clamp(0, n as i32 - 1) as usize), + Border::Constant => (v >= 0 && (v as usize) < n).then_some(v as usize), + } +} + +/// The bilinear pixel at fixed-point source coordinates (x, y) into `out`. `CN` is the channel +/// count, or 0 for one known only at run time. +#[inline(always)] +fn linear(src: &Image, border: Border, x: i32, y: i32, out: &mut [f32]) { + let (fx, fy) = ((x & MASK) as f32 * STEP, (y & MASK) as f32 * STEP); + let (ix, iy) = (x >> BITS, y >> BITS); + let w0 = (1.0 - fy) * (1.0 - fx); + let w1 = (1.0 - fy) * fx; + let w2 = fy * (1.0 - fx); + let w3 = fy * fx; + let cn = if CN == 0 { src.cn } else { CN }; + let row = src.w * cn; + let s = src.data; + if (ix as u32) < src.w as u32 - 1 && (iy as u32) < src.h as u32 - 1 { + let a = iy as usize * row + ix as usize * cn; + let (p, q) = (&s[a..][..2 * cn], &s[a + row..][..2 * cn]); + for (k, o) in out[..cn].iter_mut().enumerate() { + *o = p[k] * w0 + p[cn + k] * w1 + q[k] * w2 + q[cn + k] * w3; + } + return; + } + let (wi, hi) = (src.w as i32, src.h as i32); + if border == Border::Constant && (ix >= wi || ix + 1 < 0 || iy >= hi || iy + 1 < 0) { + out[..cn].fill(0.0); + return; + } + let (x0, x1) = (edge(ix, src.w, border), edge(ix + 1, src.w, border)); + let (y0, y1) = (edge(iy, src.h, border), edge(iy + 1, src.h, border)); + let at = |yy: Option, xx: Option, k: usize| match (yy, xx) { + (Some(yy), Some(xx)) => s[yy * row + xx * cn + k], + _ => 0.0, + }; + for (k, o) in out[..cn].iter_mut().enumerate() { + *o = at(y0, x0, k) * w0 + at(y0, x1, k) * w1 + at(y1, x0, k) * w2 + at(y1, x1, k) * w3; + } +} + +/// The bicubic pixel at fixed-point source coordinates (x, y): rows of four summed, then added +/// row by row; at the border every term is added in turn, as OpenCV does. +#[inline(always)] +fn cubic( + src: &Image, + border: Border, + tab: &CubicTable, + x: i32, + y: i32, + out: &mut [f32], +) { + let w = &tab[(((y & MASK) << BITS) | (x & MASK)) as usize]; + let (sx, sy) = ((x >> BITS) - 1, (y >> BITS) - 1); + let cn = if CN == 0 { src.cn } else { CN }; + let row = src.w * cn; + let s = src.data; + if (sx as u32) < (src.w as u32).saturating_sub(3) + && (sy as u32) < (src.h as u32).saturating_sub(3) + { + let a = sy as usize * row + sx as usize * cn; + let p: [&[f32]; 4] = std::array::from_fn(|i| &s[a + i * row..][..4 * cn]); + for (k, o) in out[..cn].iter_mut().enumerate() { + let r = |i: usize| { + let (p, w) = (p[i], &w[i * 4..][..4]); + p[k] * w[0] + p[cn + k] * w[1] + p[2 * cn + k] * w[2] + p[3 * cn + k] * w[3] + }; + let mut sum = r(0); + sum += r(1); + sum += r(2); + sum += r(3); + *o = sum; + } + return; + } + let (wi, hi) = (src.w as i32, src.h as i32); + if border == Border::Constant && (sx >= wi || sx + 4 <= 0 || sy >= hi || sy + 4 <= 0) { + out[..cn].fill(0.0); + return; + } + let xs: [Option; 4] = std::array::from_fn(|i| edge(sx + i as i32, src.w, border)); + let ys: [Option; 4] = std::array::from_fn(|i| edge(sy + i as i32, src.h, border)); + for (k, o) in out[..cn].iter_mut().enumerate() { + let mut sum = 0.0f32; + for (i, yy) in ys.iter().enumerate() { + let Some(yy) = yy else { continue }; + for (j, xx) in xs.iter().enumerate() { + if let Some(xx) = xx { + sum += s[yy * row + xx * cn + k] * w[i * 4 + j]; + } + } + } + *o = sum; + } +} + +/// The source pixel at whole-pixel coordinates (x, y) in 1/32 px units. +#[inline(always)] +fn nearest(src: &Image, border: Border, x: i32, y: i32, out: &mut [f32]) { + let cn = if CN == 0 { src.cn } else { CN }; + let (ix, iy) = (x >> BITS, y >> BITS); + if (ix as u32) < src.w as u32 && (iy as u32) < src.h as u32 { + out[..cn].copy_from_slice(&src.data[(iy as usize * src.w + ix as usize) * cn..][..cn]); + return; + } + match ( + edge(x >> BITS, src.w, border), + edge(y >> BITS, src.h, border), + ) { + (Some(xx), Some(yy)) => { + out[..cn].copy_from_slice(&src.data[(yy * src.w + xx) * cn..][..cn]); + } + _ => out[..cn].fill(0.0), + } +} + +fn sample_row( + src: &Image, + (interp, border): (Interp, Border), + tab: &CubicTable, + (xs, ys): (&[i32], &[i32]), + row: &mut [f32], +) { + let cn = if CN == 0 { src.cn } else { CN }; + let px = row.chunks_exact_mut(cn).zip(xs).zip(ys); + match interp { + Interp::Linear => px.for_each(|((o, &x), &y)| linear::(src, border, x, y, o)), + Interp::Cubic => px.for_each(|((o, &x), &y)| cubic::(src, border, tab, x, y, o)), + Interp::Nearest => px.for_each(|((o, &x), &y)| nearest::(src, border, x, y, o)), + } +} + +static EMPTY: CubicTable = [[0.0; 16]; 1 << (2 * BITS)]; + +/// Fills a `dw`-wide output, row by row in parallel, from `coords(y, xs, ys)`, which writes the +/// fixed-point source coordinates of output row y. +fn warp( + src: &Image, + mode: (Interp, Border), + dw: usize, + out: &mut [f32], + coords: impl Fn(usize, &mut [i32], &mut [i32]) + Sync, +) { + let cn = src.cn; + let tab = if mode.0 == Interp::Cubic { + cubic_table() + } else { + &EMPTY + }; + out.par_chunks_mut(dw * cn).enumerate().for_each_init( + || (vec![0i32; dw], vec![0i32; dw]), + |(xs, ys), (y, row)| { + coords(y, xs, ys); + let c = (&xs[..], &ys[..]); + match cn { + 1 => sample_row::<1>(src, mode, tab, c, row), + 3 => sample_row::<3>(src, mode, tab, c, row), + 4 => sample_row::<4>(src, mode, tab, c, row), + _ => sample_row::<0>(src, mode, tab, c, row), + } + }, + ); +} + +/// OpenCV's `cvRound` of a float64 into an int, ties to even, for |v| < 2^30: added to 1.5 * 2^52, +/// the integer fills the low mantissa bits. `round_ties_even` is a libm call on the x86-64 +/// baseline, and the saturating `as` cast keeps LLVM from vectorizing. +#[inline(always)] +fn round_i32(v: f64) -> i32 { + (v + 6755399441055744.0).to_bits() as u32 as i32 +} + +/// Coordinates far outside any image, held where `round_i32` stays exact. +const FAR: f64 = (1u32 << 30) as f64; + +/// `cv2.remap(src, map_x, map_y, interpolation, borderMode)`; the maps are float32, one entry +/// per output pixel, `dw` wide. +pub fn remap( + src: &Image, + mode: (Interp, Border), + (map_x, map_y): (&[f32], &[f32]), + dw: usize, + out: &mut [f32], +) { + // Nearest rounds the map point to a whole pixel. + let scale = if mode.0 == Interp::Nearest { + 1.0 + } else { + (1 << BITS) as f32 + }; + let shift = if mode.0 == Interp::Nearest { BITS } else { 0 }; + warp(src, mode, dw, out, move |y, xs, ys| { + remap_coords( + &map_x[y * dw..][..dw], + &map_y[y * dw..][..dw], + (scale, shift), + xs, + ys, + ) + }); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn remap_coords( + mx: &[f32], + my: &[f32], + (scale, shift): (f32, i32), + xs: &mut [i32], + ys: &mut [i32], +) { + let far = FAR / (1 << shift) as f64; + for (((x, y), &a), &b) in xs.iter_mut().zip(ys.iter_mut()).zip(mx).zip(my) { + *x = round_i32(((a * scale) as f64).clamp(-far, far)) << shift; + *y = round_i32(((b * scale) as f64).clamp(-far, far)) << shift; + } +} + +/// The radial lens correction of a same-size image (`apply_radial_distortion`): the remap +/// source of output pixel p is c + (p - c) s f with f = 1 + k1 |(p - c) s|^2 inv_hd2, in float32 +/// as NumPy builds the maps. +pub fn remap_radial( + src: &Image, + (cx, cy): (f32, f32), + s: f32, + k1: f32, + inv_hd2: f32, + out: &mut [f32], +) { + let dw = src.w; + warp( + src, + (Interp::Linear, Border::Replicate), + dw, + out, + move |y, xs, ys| radial_coords(y as f32, (cx, cy), s, k1, inv_hd2, xs, ys), + ); +} + +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn radial_coords( + y: f32, + (cx, cy): (f32, f32), + s: f32, + k1: f32, + inv_hd2: f32, + xs: &mut [i32], + ys: &mut [i32], +) { + let scale = (1 << BITS) as f32; + let py = (y - cy) * s; + for (i, (x, yv)) in xs.iter_mut().zip(ys.iter_mut()).enumerate() { + let px = (i as f32 - cx) * s; + let f = 1.0 + k1 * (px * px + py * py) * inv_hd2; + *x = round_i32((((cx + px * f) * scale) as f64).clamp(-FAR, FAR)); + *yv = round_i32((((cy + py * f) * scale) as f64).clamp(-FAR, FAR)); + } +} + +/// `cv2.warpAffine(src, m, (dw, dh), interpolation, borderMode)`, bilinear or bicubic; `m` maps +/// source to output unless `inverse_map` (OpenCV's WARP_INVERSE_MAP). +pub fn warp_affine( + src: &Image, + mode: (Interp, Border), + m: [f64; 6], + inverse_map: bool, + dw: usize, + out: &mut [f32], +) { + let mut m = m; + if !inverse_map { + let d = m[0] * m[4] - m[1] * m[3]; + let d = if d != 0.0 { 1.0 / d } else { 0.0 }; + let (a11, a22) = (m[4] * d, m[0] * d); + m[0] = a11; + m[1] *= -d; + m[3] *= -d; + m[4] = a22; + let b1 = -m[0] * m[2] - m[1] * m[5]; + let b2 = -m[3] * m[2] - m[4] * m[5]; + m[2] = b1; + m[5] = b2; + } + const AB_SCALE: f64 = 1024.0; + const ROUND: i32 = 1 << (10 - BITS - 1); + let adelta: Vec = (0..dw) + .map(|x| round_i32((m[0] * x as f64 * AB_SCALE).clamp(-FAR, FAR))) + .collect(); + let bdelta: Vec = (0..dw) + .map(|x| round_i32((m[3] * x as f64 * AB_SCALE).clamp(-FAR, FAR))) + .collect(); + warp(src, mode, dw, out, move |y, xs, ys| { + let x0 = round_i32(((m[1] * y as f64 + m[2]) * AB_SCALE).clamp(-FAR, FAR)) + ROUND; + let y0 = round_i32(((m[4] * y as f64 + m[5]) * AB_SCALE).clamp(-FAR, FAR)) + ROUND; + for (i, (x, yv)) in xs.iter_mut().zip(ys.iter_mut()).enumerate() { + *x = x0.wrapping_add(adelta[i]) >> (10 - BITS); + *yv = y0.wrapping_add(bdelta[i]) >> (10 - BITS); + } + }); +} + +/// OpenCV's closed-form 3x3 inverse (`cv::invert`, DECOMP_LU, n <= 3); None when singular. +fn invert3(s: [f64; 9]) -> Option<[f64; 9]> { + let e = |i: usize, j: usize| s[i * 3 + j]; + let d = e(0, 0) * (e(1, 1) * e(2, 2) - e(1, 2) * e(2, 1)) + - e(0, 1) * (e(1, 0) * e(2, 2) - e(1, 2) * e(2, 0)) + + e(0, 2) * (e(1, 0) * e(2, 1) - e(1, 1) * e(2, 0)); + if d == 0.0 { + return None; + } + let d = 1.0 / d; + Some([ + (e(1, 1) * e(2, 2) - e(1, 2) * e(2, 1)) * d, + (e(0, 2) * e(2, 1) - e(0, 1) * e(2, 2)) * d, + (e(0, 1) * e(1, 2) - e(0, 2) * e(1, 1)) * d, + (e(1, 2) * e(2, 0) - e(1, 0) * e(2, 2)) * d, + (e(0, 0) * e(2, 2) - e(0, 2) * e(2, 0)) * d, + (e(0, 2) * e(1, 0) - e(0, 0) * e(1, 2)) * d, + (e(1, 0) * e(2, 1) - e(1, 1) * e(2, 0)) * d, + (e(0, 1) * e(2, 0) - e(0, 0) * e(2, 1)) * d, + (e(0, 0) * e(1, 1) - e(0, 1) * e(1, 0)) * d, + ]) +} + +/// `cv2.warpPerspective(src, m, (dw, dh), interpolation, borderMode)`, bilinear or bicubic; `m` +/// maps source to output unless `inverse_map`. A singular `m` inverts to zeros, as `cv::invert` +/// leaves it. +pub fn warp_perspective( + src: &Image, + mode: (Interp, Border), + m: [f64; 9], + inverse_map: bool, + (dw, dh): (usize, usize), + out: &mut [f32], +) { + let m = if inverse_map { + m + } else { + invert3(m).unwrap_or([0.0; 9]) + }; + // OpenCV starts the projective sums again at each block of bw0 columns. + let bh0 = dh.min(16); + let bw0 = (1024 / bh0).min(dw); + warp(src, mode, dw, out, move |y, xs, ys| { + let yf = y as f64; + for (b, (xs, ys)) in xs.chunks_mut(bw0).zip(ys.chunks_mut(bw0)).enumerate() { + let xb = (b * bw0) as f64; + let x0 = m[0] * xb + m[1] * yf + m[2]; + let y0 = m[3] * xb + m[4] * yf + m[5]; + let w0 = m[6] * xb + m[7] * yf + m[8]; + persp_coords([x0, y0, w0], [m[0], m[3], m[6]], xs, ys); + } + }); +} + +/// One block's coordinates from the projective sums `start` at its first column and their +/// steps per column. +#[multiversion(targets( + "x86_64+avx512f+avx512bw+avx512vl+avx512dq", + "x86_64+avx2+fma", + "aarch64+neon" +))] +fn persp_coords(start: [f64; 3], step: [f64; 3], xs: &mut [i32], ys: &mut [i32]) { + for (i, (x, y)) in xs.iter_mut().zip(ys.iter_mut()).enumerate() { + let x1 = i as f64; + let w = start[2] + step[2] * x1; + let w = if w != 0.0 { + (1 << BITS) as f64 / w + } else { + 0.0 + }; + *x = round_i32(((start[0] + step[0] * x1) * w).clamp(-FAR, FAR)); + *y = round_i32(((start[1] + step[1] * x1) * w).clamp(-FAR, FAR)); + } +} + +/// `cv2.getRotationMatrix2D(center, angle, scale)`: the 2x3 matrix that turns about `center` +/// by `angle` degrees counter-clockwise. OpenCV takes the center as float32. +pub fn rotation_matrix((cx, cy): (f32, f32), angle: f64, scale: f64) -> [f64; 6] { + let angle = angle * (std::f64::consts::PI / 180.0); + let alpha = angle.cos() * scale; + let beta = angle.sin() * scale; + let (cx, cy) = (cx as f64, cy as f64); + [ + alpha, + beta, + (1.0 - alpha) * cx - beta * cy, + -beta, + alpha, + beta * cx + (1.0 - alpha) * cy, + ] +} + +/// `cv2.getPerspectiveTransform(src, dst)`: the 3x3 matrix mapping four float32 points onto +/// four, solved with c22 = 1 by OpenCV's LU with partial pivoting. None for a degenerate quad. +pub fn perspective_transform(src: &[[f32; 2]; 4], dst: &[[f32; 2]; 4]) -> Option<[f64; 9]> { + let mut a = [[0f64; 8]; 8]; + let mut b = [0f64; 8]; + for i in 0..4 { + let ([x, y], [u, v]) = (src[i], dst[i]); + a[i][0] = x as f64; + a[i + 4][3] = x as f64; + a[i][1] = y as f64; + a[i + 4][4] = y as f64; + a[i][2] = 1.0; + a[i + 4][5] = 1.0; + // float32 products, as OpenCV multiplies two Point2f fields. + a[i][6] = (-x * u) as f64; + a[i][7] = (-y * u) as f64; + a[i + 4][6] = (-x * v) as f64; + a[i + 4][7] = (-y * v) as f64; + b[i] = u as f64; + b[i + 4] = v as f64; + } + let (a0, b0) = (a, b); + for i in 0..8 { + let mut k = i; + for j in i + 1..8 { + if a[j][i].abs() > a[k][i].abs() { + k = j; + } + } + if a[k][i].abs() < f64::EPSILON * 100.0 { + return None; + } + if k != i { + a.swap(i, k); + b.swap(i, k); + } + let d = -1.0 / a[i][i]; + for j in i + 1..8 { + let alpha = a[j][i] * d; + let pivot = a[i]; + for (v, p) in a[j][i + 1..].iter_mut().zip(&pivot[i + 1..]) { + *v += alpha * p; + } + b[j] += alpha * b[i]; + } + } + for i in (0..8).rev() { + let mut s = b[i]; + for k in i + 1..8 { + s -= a[i][k] * b[k]; + } + b[i] = s / a[i][i]; + } + let residual = (0..8) + .map(|r| { + let e = (0..8).map(|c| a0[r][c] * b[c]).sum::() - b0[r]; + e * e + }) + .sum::() + .sqrt(); + (residual < 1e-8).then(|| [b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], 1.0]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ramp(h: usize, w: usize, cn: usize) -> Vec { + (0..h * w * cn) + .map(|i| (i % 7) as f32 * 0.25 - 0.5) + .collect() + } + + #[test] + fn identity_affine_copies() { + let data = ramp(9, 13, 3); + let src = Image { + data: &data, + h: 9, + w: 13, + cn: 3, + }; + let mut out = vec![0.0; data.len()]; + let mode = (Interp::Linear, Border::Replicate); + warp_affine( + &src, + mode, + [1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + false, + 13, + &mut out, + ); + assert_eq!(out, data); + warp_perspective( + &src, + mode, + [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], + false, + (13, 9), + &mut out, + ); + assert_eq!(out, data); + } + + #[test] + fn remap_half_pixel_averages_and_replicates() { + let data = vec![0.0, 1.0, 2.0, 3.0]; + let src = Image { + data: &data, + h: 1, + w: 4, + cn: 1, + }; + let mut out = vec![0.0; 3]; + let (mx, my) = ([0.5, -3.0, 9.0], [0.0; 3]); + remap( + &src, + (Interp::Linear, Border::Replicate), + (&mx, &my), + 3, + &mut out, + ); + assert_eq!(out, [0.5, 0.0, 3.0]); + remap( + &src, + (Interp::Linear, Border::Constant), + (&mx, &my), + 3, + &mut out, + ); + assert_eq!(out, [0.5, 0.0, 0.0]); + remap( + &src, + (Interp::Nearest, Border::Replicate), + (&[1.4, 2.5, 3.5], &my), + 3, + &mut out, + ); + assert_eq!(out, [1.0, 2.0, 3.0]); + } + + #[test] + fn cubic_weights_sum_to_one() { + for w in cubic_table().iter() { + assert!((w.iter().sum::() - 1.0).abs() < 1e-5); + } + assert_eq!(cubic_table()[0][5], 1.0); + } + + #[test] + fn invert3_matches_identity_product() { + let m = [2.0, 0.5, 1.0, 0.25, 3.0, -1.0, 0.001, 0.002, 1.0]; + let inv = invert3(m).unwrap(); + for i in 0..3 { + for j in 0..3 { + let p: f64 = (0..3).map(|k| m[i * 3 + k] * inv[k * 3 + j]).sum(); + assert!((p - (i == j) as i32 as f64).abs() < 1e-12); + } + } + assert!(invert3([0.0; 9]).is_none()); + } +} diff --git a/negpy_core/src/zones.rs b/negpy_core/src/zones.rs new file mode 100644 index 000000000..6b2799f8c --- /dev/null +++ b/negpy_core/src/zones.rs @@ -0,0 +1,389 @@ +//! The display-buffer reads of negpy/features/exposure/analysis.py: print zones, their regions +//! and label anchors, the loupe's acutance and the vectorscope's display map. + +use rayon::prelude::*; + +use crate::components::connected_components; +use crate::distance::{Metric, distance_transform}; +use crate::filter::{Border, gaussian_blur}; + +pub const ZONE_COUNT: u8 = 10; + +fn py_round(v: f64) -> usize { + v.round_ties_even() as usize +} + +/// `zone_of_encoded` of a float32 lightness against the encoded mid gray `mid` (a Python +/// float, so it joins the arithmetic as float32). +#[inline] +fn zone_f32(enc: f32, mid: f32, upper: f32) -> f32 { + let enc = if enc.is_nan() { + enc + } else { + enc.clamp(0.0, 1.0) + }; + if enc <= mid { + 5.0 * enc / mid + } else { + 5.0 + 5.0 * (enc - mid) / upper + } +} + +/// `np.rint(np.nan_to_num(z))` clipped to the zone range. +#[inline] +fn zone_index(z: f32) -> f32 { + let z = if z.is_nan() { 0.0 } else { z }; + z.round_ties_even().clamp(0.0, ZONE_COUNT as f32) +} + +/// The first three channels of every `step`-th row and column, then INTER_AREA to `scale` of +/// that, as luminance. +fn sampled_luma( + rgb: &[f32], + (h, w, cn): (usize, usize, usize), + step: usize, + scale: impl Fn(usize, usize) -> f64, +) -> Option<(Vec, usize, usize)> { + let (sh, sw) = (h.div_ceil(step), w.div_ceil(step)); + let copy; + let small: &[f32] = if step == 1 && cn == 3 { + rgb + } else { + copy = (0..sh) + .into_par_iter() + .flat_map_iter(|r| { + let row = &rgb[r * step * w * cn..]; + (0..sw).flat_map(move |c| row[c * step * cn..c * step * cn + 3].iter().copied()) + }) + .collect::>(); + © + }; + let s = scale(sh, sw); + let (dw, dh) = ( + py_round(sw as f64 * s).max(1), + py_round(sh as f64 * s).max(1), + ); + let resized = crate::resize::resize_area(small, sh, sw, 3, dh, dw)?; + let mut lum = vec![0f32; dh * dw]; + crate::analysis::luminance(&resized, &mut lum); + Some((lum, dh, dw)) +} + +/// `zone_field(rgb, max_edge)`: the zone (0..10) per pixel of an HxWxC display frame, at most +/// `max_edge` on the long side, smoothed before and median-filtered after the rounding. +pub fn zone_field( + rgb: &[f32], + dims: (usize, usize, usize), + max_edge: usize, + mid: f64, +) -> Option<(Vec, usize, usize)> { + let (h, w, _) = dims; + let step = (h.max(w) / (2 * max_edge)).max(1); + let (lum, dh, dw) = sampled_luma(rgb, dims, step, |sh, sw| { + 1f64.min(max_edge as f64 / sh.max(sw) as f64) + })?; + let mut blur = vec![0f32; lum.len()]; + gaussian_blur( + &lum, + &mut blur, + (dh, dw, 1), + (0, 0), + (2.0, 0.0), + Border::Reflect101, + )?; + let (m, upper) = (mid as f32, (1.0 - mid) as f32); + let zones: Vec = blur + .par_iter() + .map(move |&v| zone_index(zone_f32(v, m, upper)) as u8) + .collect(); + let mut out = vec![0u8; zones.len()]; + crate::median::median_blur_u8(&zones, &mut out, dh, dw, 5)?; + Some((out, dh, dw)) +} + +/// `zone_grid(rgb, cells)`: the zone per cell of a grid `cells` long on the frame's long side. +pub fn zone_grid( + rgb: &[f32], + dims: (usize, usize, usize), + cells: usize, + mid: f64, +) -> Option<(Vec, usize, usize)> { + let (h, w, _) = dims; + let step = (h.min(w) / (4 * cells)).max(1); + let (lum, dh, dw) = sampled_luma(rgb, dims, step, |sh, sw| cells as f64 / sh.max(sw) as f64)?; + let (m, upper) = (mid as f32, (1.0 - mid) as f32); + Some(( + lum.par_iter() + .map(move |&v| zone_index(zone_f32(v, m, upper)) as i32) + .collect(), + dh, + dw, + )) +} + +/// The distinct values of a zone map, ascending. +fn unique(zones: &[T]) -> Vec { + let mut u = zones.to_vec(); + u.sort_unstable(); + u.dedup(); + u +} + +/// `zone_anchors(zones, min_share)`: (x, y, zone, inset) per 4-connected region of at least +/// `min_share` of the frame, at its most interior pixel; the frame edge counts as an edge. +pub fn zone_anchors( + zones: &[u8], + (h, w): (usize, usize), + min_share: f64, +) -> Vec<(usize, usize, u8, f32)> { + let min_area = min_share * zones.len() as f64; + let mut out = Vec::new(); + for zone in unique(zones) { + let mask: Vec = zones.iter().map(|&z| (z == zone) as u8).collect(); + let cc = connected_components(&mask, h, w, 4); + for i in 1..cc.count { + let [left, top, cw, ch, area] = cc.stats[i]; + if (area as f64) < min_area { + continue; + } + // Zeros ring the region's box, so its distances need nothing outside it. + let (y0, x0) = ( + (top as usize).saturating_sub(1), + (left as usize).saturating_sub(1), + ); + let (y1, x1) = ((top + ch) as usize + 1, (left + cw) as usize + 1); + let (y1, x1) = (y1.min(h), x1.min(w)); + let (bh, bw) = (y1 - y0, x1 - x0); + let mut sub = vec![0u8; bh * bw]; + for y in y0.max(1)..y1.min(h - 1) { + for x in x0.max(1)..x1.min(w - 1) { + sub[(y - y0) * bw + x - x0] = (cc.labels[y * w + x] == i as i32) as u8; + } + } + let dist = distance_transform(&sub, bh, bw, Metric::L2Mask3); + let best = dist + .iter() + .enumerate() + .fold(0, |b, (j, &d)| if d > dist[b] { j } else { b }); + if dist[best] > 0.0 { + out.push((x0 + best % bw, y0 + best / bw, zone, dist[best])); + } else { + out.push((0, 0, zone, 0.0)); + } + } + } + out +} + +/// `zone_region_labels(zones)`: (col, row, zone) per 4-connected region, at the region's cell +/// nearest its centroid. +pub fn zone_region_labels(zones: &[i32], (h, w): (usize, usize)) -> Vec<(usize, usize, i32)> { + let mut out = Vec::new(); + for zone in unique(zones) { + let mask: Vec = zones.iter().map(|&z| (z == zone) as u8).collect(); + let cc = connected_components(&mask, h, w, 4); + let mut best = vec![(f64::INFINITY, 0usize); cc.count]; + for (p, &label) in cc.labels.iter().enumerate() { + if label <= 0 { + continue; + } + let [cx, cy] = cc.centroids[label as usize]; + let (dx, dy) = ((p % w) as f64 - cx, (p / w) as f64 - cy); + let d = dx * dx + dy * dy; + if d < best[label as usize].0 { + best[label as usize] = (d, p); + } + } + out.extend(best[1..].iter().map(|&(_, p)| (p % w, p / w, zone))); + } + out +} + +/// `zone_shares(zones)`: the fraction of the frame in each zone 0..10. +pub fn zone_shares(zones: &[u8]) -> Vec { + let mut count = [0usize; ZONE_COUNT as usize + 1]; + for &z in zones { + if (z as usize) < count.len() { + count[z as usize] += 1; + } + } + let n = zones.len().max(1) as f64; + count.iter().map(|&c| c as f64 / n).collect() +} + +/// `zone_occupancy(l_bins)`: display-luma histogram bins folded into the share of each zone, +/// each bin at the zone its center rounds to. +pub fn zone_occupancy(bins: &[f64], mid: f64) -> Vec { + let mut out = vec![0f64; ZONE_COUNT as usize + 1]; + let total: f64 = bins.iter().sum(); + if total <= 0.0 { + return out; + } + let n = bins.len() as f64; + for (i, &b) in bins.iter().enumerate() { + let enc = ((i as f64 + 0.5) / n).clamp(0.0, 1.0); + let z = if enc <= mid { + 5.0 * enc / mid + } else { + 5.0 + 5.0 * (enc - mid) / (1.0 - mid) + }; + out[z.round_ties_even().clamp(0.0, ZONE_COUNT as f64) as usize] += b; + } + out.iter().map(|&v| v / total).collect() +} + +/// `loupe_acutance(patch)`: 100 x the RMS of the luminance's 3x3 Laplacian (cv2.Laplacian, +/// ksize 1, reflect-101 border) of an HxWxC display patch. +pub fn loupe_acutance(rgb: &[f32], (h, w, cn): (usize, usize, usize)) -> f64 { + let mut lum = vec![0f32; h * w]; + if cn == 3 { + crate::analysis::luminance_chunk(rgb, &mut lum); + } else { + let three: Vec = rgb + .chunks_exact(cn) + .map(|p| [p[0], p[1], p[2]]) + .collect::>() + .into_flattened(); + crate::analysis::luminance_chunk(&three, &mut lum); + } + // Reflect-101 padding, one pixel wide, so every tap reads inside the buffer. + let reflect = |i: isize, n: usize| -> usize { + if n == 1 { + 0 + } else if i < 0 { + (-i) as usize + } else if i as usize >= n { + 2 * (n - 1) - i as usize + } else { + i as usize + } + }; + let pw = w + 2; + let mut pad = vec![0f32; (h + 2) * pw]; + for (y, out) in pad.chunks_exact_mut(pw).enumerate() { + let src = &lum[reflect(y as isize - 1, h) * w..][..w]; + out[1..=w].copy_from_slice(src); + out[0] = src[reflect(-1, w)]; + out[w + 1] = src[reflect(w as isize, w)]; + } + let mut sq = vec![0f32; h * w]; + for (y, out) in sq.chunks_exact_mut(w).enumerate() { + let (up, row, down) = ( + &pad[y * pw + 1..][..w], + &pad[(y + 1) * pw..][..w + 2], + &pad[(y + 2) * pw + 1..][..w], + ); + for (x, o) in out.iter_mut().enumerate() { + // OpenCV's float filter sums the kernel's nonzero taps in row-major order from 0. + let v = ((((0.0 + up[x]) + row[x]) + -4.0 * row[x + 1]) + row[x + 2]) + down[x]; + *o = v * v; + } + } + let mean = crate::stats::pairwise_sum_f32(&sq) / sq.len() as f32; + // np.sqrt of the float32 mean, times a Python float, stays float32. + (mean.sqrt() * 100.0) as f64 +} + +const SCOPE_HUE_BINS: usize = crate::histogram::SCOPE_HUE_BINS; +const SCOPE_CHROMA_BINS: usize = crate::histogram::SCOPE_CHROMA_BINS; +const SCOPE_MAX_CHROMA: f64 = 60.0; +const SCOPE_LOG_KNEE: f64 = 4.0; + +fn scope_radius(c: f64, log: bool) -> f64 { + if log { + libm::log1p(c / SCOPE_LOG_KNEE) / libm::log1p(SCOPE_MAX_CHROMA / SCOPE_LOG_KNEE) + } else { + c / SCOPE_MAX_CHROMA + } +} + +fn scope_chroma(r: f64, log: bool) -> f64 { + if log { + libm::expm1(r * libm::log1p(SCOPE_MAX_CHROMA / SCOPE_LOG_KNEE)) * SCOPE_LOG_KNEE + } else { + r * SCOPE_MAX_CHROMA + } +} + +/// `_scope_bins` of one a*b* point. +fn scope_bin(a: f64, b: f64) -> i64 { + use std::f64::consts::PI; + let hue = (((libm::atan2(b, a) + PI) / (2.0 * PI) * SCOPE_HUE_BINS as f64).floor() as i64) + .rem_euclid(SCOPE_HUE_BINS as i64); + let radius = scope_radius(libm::hypot(a, b), true); + let chroma = (radius * SCOPE_CHROMA_BINS as f64) + .floor() + .clamp(0.0, (SCOPE_CHROMA_BINS - 1) as f64) as i64; + hue * SCOPE_CHROMA_BINS as i64 + chroma +} + +/// `_scope_lookup(size, log)`: the polar bin of each display pixel (-1 outside the disc), each +/// bin's pixel count, the bins no pixel samples, and the pixel each of those folds into. +pub fn scope_lookup(size: usize, log: bool) -> (Vec, Vec, Vec, Vec) { + let u: Vec = (0..size) + .map(|i| (i as f64 + 0.5) / size as f64 * 2.0 - 1.0) + .collect(); + let idx: Vec = (0..size) + .into_par_iter() + .flat_map_iter(|r| { + let y = u[size - 1 - r]; + u.iter().map(move |&x| { + let radius = libm::hypot(x, y); + let chroma = scope_chroma(radius.min(1.0), log); + let hue = libm::atan2(y, x); + let bin = scope_bin(chroma * libm::cos(hue), chroma * libm::sin(hue)); + if radius > 1.0 { -1 } else { bin } + }) + }) + .collect(); + let mut area = vec![0f64; SCOPE_HUE_BINS * SCOPE_CHROMA_BINS]; + for &i in idx.iter().filter(|&&i| i >= 0) { + area[i as usize] += 1.0; + } + let orphans: Vec = (0..area.len() as i64) + .filter(|&i| area[i as usize] == 0.0) + .collect(); + let px = orphans + .iter() + .map(|&o| { + use std::f64::consts::PI; + let centre_hue = + ((o / SCOPE_CHROMA_BINS as i64) as f64 + 0.5) / SCOPE_HUE_BINS as f64 * 2.0 * PI + - PI; + let centre_c = scope_chroma( + ((o % SCOPE_CHROMA_BINS as i64) as f64 + 0.5) / SCOPE_CHROMA_BINS as f64, + true, + ); + let r = scope_radius(centre_c, log).min(1.0); + let last = (size - 1) as i64; + let col = + (((r * libm::cos(centre_hue) + 1.0) / 2.0 * size as f64) as i64).clamp(0, last); + let row = + (((1.0 - r * libm::sin(centre_hue)) / 2.0 * size as f64) as i64).clamp(0, last); + row * size as i64 + col + }) + .collect(); + (idx, area, orphans, px) +} + +/// `vectorscope_density`: each polar bin's count spread over the display pixels it covers, and +/// the bins finer than a pixel added to the pixel they fold into. +pub fn vectorscope_density( + counts: &[f64], + (idx, area, orphans, px): (&[i64], &[f64], &[i64], &[i64]), +) -> Vec { + let mut out: Vec = idx + .iter() + .map(|&i| { + if i >= 0 { + counts[i as usize] / area[i as usize].max(1.0) + } else { + 0.0 + } + }) + .collect(); + for (&o, &p) in orphans.iter().zip(px) { + out[p as usize] += counts[o as usize]; + } + out +} diff --git a/pyproject.toml b/pyproject.toml index eaf61f7f0..f93ad1f90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,12 +15,10 @@ dependencies = [ "imagecodecs==2026.6.6", "imageio==2.37.3", "jinja2==3.1.6", - "numba==0.65.1", "numpy==2.4.4", # Pinned below its latest release: 1.24.0+ dropped macOS x86_64 wheels entirely, # which breaks install on an Intel Mac. 1.23.2 is the last version with one. "onnxruntime==1.23.2", - "opencv-python-headless==4.13.0.92", "pillow==12.2.0", "pyqt6==6.11.0", "pyqt6-charts==6.11.0", @@ -28,6 +26,7 @@ dependencies = [ "qtawesome==1.4.2", "wgpu==0.31.1", "pyserial>=3.5", + "negpy-core", ] classifiers = [ "Development Status :: 3 - Alpha", @@ -44,7 +43,8 @@ classifiers = [ [project.optional-dependencies] nkscan = ["nkscan>=0.14.2"] -plustek = ["pyopticfilm>=1.3.7"] +# pyopticfilm aligns multi-pass and IR scans with cv2, and silently skips the alignment without it. +plustek = ["pyopticfilm>=1.3.7", "opencv-python-headless==4.13.0.92"] sane = ["python-sane>=2.9"] camera = ["gphoto2>=2.5 ; sys_platform != 'win32'"] @@ -61,6 +61,7 @@ sane = [ ] plustek = [ "pyopticfilm>=1.3.7", + "opencv-python-headless==4.13.0.92", ] camera = [ # Tethered camera scanning. libgphoto2 has no Windows build, so the wheels — and the @@ -81,12 +82,18 @@ Documentation = "https://github.com/marcinz606/NegPy/docs" Repository = "https://github.com/marcinz606/NegPy.git" Issues = "https://github.com/marcinz606/NegPy/issues" +[tool.uv.workspace] +members = ["negpy_core"] + +[tool.uv.sources] +negpy-core = { workspace = true } + [build-system] requires = ["setuptools~=80.10.2"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["negpy*"] +include = ["negpy", "negpy.*"] [tool.setuptools.package-data] negpy = ["translations/*.json", "translations/*.md"] diff --git a/scripts/core_manifest.py b/scripts/core_manifest.py new file mode 100644 index 000000000..29ad8faf5 --- /dev/null +++ b/scripts/core_manifest.py @@ -0,0 +1,171 @@ +"""Keep negpy_core/manifest.toml in step with the Python functions that negpy_core will replace.""" + +import argparse +import ast +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = ROOT / "negpy_core" / "manifest.toml" + +ORDER_STATS = {"percentile", "median", "partition", "quantile", "nanpercentile", "nanmedian"} +# Reductions over a whole image: the Python-overhead chains (measure_base_log, bounds) live on these. +REDUCTIONS = {"std", "var", "mean", "sum", "histogram", "bincount", "cumsum", "sort", "argsort", "unique"} +# cv2 calls the plan ports last, or may never port: each needs a decision of its own. +LAST_CV2 = {"SIFT_create", "BFMatcher", "detail_DpSeamFinder", "createLineSegmentDetector", "inpaint", "phaseCorrelate", "UMat"} +STATUSES = ("todo", "ported", "stays") +# Where a pure-Python caller of ported code is itself ported; orchestration elsewhere stays. +CHAIN_PREFIXES = ("features/", "kernel/image/") + +# First match wins. Entries keep a hand-set phase; this only seeds new ones. +PHASE_BY_PATH = [ + ("features/geometry/", 2), + ("features/exposure/normalization.py", 2), + ("features/exposure/analysis.py", 2), + ("infrastructure/loaders/", 3), + ("kernel/image/", 3), + ("features/flatfield/", 4), + ("features/process/", 4), + ("features/retouch/", 4), + ("features/lens/", 4), + ("features/local/", 4), + ("features/hdr/", 5), + ("features/stitch/", 5), + ("features/rgbscan/", 5), + ("features/", 6), + ("services/rendering/", 6), + ("infrastructure/display/", 7), + ("services/", 7), + ("desktop/", 7), + ("infrastructure/", 9), +] + + +def _phase(rel: str, calls: list[str]) -> int: + if any(c.removeprefix("cv2.") in LAST_CV2 for c in calls): + return 9 + return next((phase for prefix, phase in PHASE_BY_PATH if rel.startswith(prefix)), 9) + + +class _Scanner(ast.NodeVisitor): + def __init__(self, rel: str) -> None: + self.rel = rel + self.stack: list[str] = [] + self.found: dict[str, dict] = {} + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self.stack.append(node.name) + self.generic_visit(node) + self.stack.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self.stack.append(node.name) + jit = any("jit" in ast.unparse(d) for d in node.decorator_list) + calls: set[str] = set() + names: set[str] = set() + for n in ast.walk(node): + if not isinstance(n, ast.Call): + continue + f = n.func + if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name): + if f.value.id in ("cv2", "negpy_core") or (f.value.id == "np" and f.attr in ORDER_STATS | REDUCTIONS): + calls.add(f"{f.value.id}.{f.attr}") + elif isinstance(f, ast.Name): + names.add(f.id) + if jit: + calls.add("numba") + # Every function is kept for now; scan() drops those outside the closure. + self.found[f"{self.rel}::{'.'.join(self.stack)}"] = {"calls": sorted(calls), "names": names} + self.generic_visit(node) + self.stack.pop() + + +def scan() -> dict[str, dict]: + """Every function under negpy/ that calls cv2, negpy_core, a numpy order statistic or reduction, or is a Numba kernel.""" + found: dict[str, dict] = {} + for path in sorted((ROOT / "negpy").rglob("*.py")): + rel = path.relative_to(ROOT / "negpy").as_posix() + scanner = _Scanner(rel) + scanner.visit(ast.parse(path.read_text(encoding="utf-8"))) + found.update(scanner.found) + # In scope: a function with a hot call, or one that calls an in-scope function by name (the + # pure-Python links of a chain such as measure_base_log), to a fixed point. + scope = {k for k, v in found.items() if v["calls"]} + while True: + names_in = {k.split("::")[1].split(".")[-1] for k in scope} + grown = {k for k, v in found.items() if k not in scope and k.startswith(CHAIN_PREFIXES) and v["names"] & names_in} + if not grown: + break + scope |= grown + found = {k: v for k, v in found.items() if k in scope} + by_module: dict[str, set[str]] = {} + for key in found: + module, qual = key.split("::") + by_module.setdefault(module, set()).add(qual.split(".")[-1]) + for key, entry in found.items(): + module, qual = key.split("::") + own = qual.split(".")[-1] + entry["depends"] = sorted(n for n in entry.pop("names") & by_module[module] if n != own) + return found + + +def load() -> dict[str, dict]: + return tomllib.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else {} + + +def merge(current: dict[str, dict], found: dict[str, dict]) -> dict[str, dict]: + """Scanned call sites with the hand-kept fields (phase, status, owner, note) of existing entries. + A ported entry stays when its function is gone from Python or calls negpy_core.""" + merged: dict[str, dict] = {} + for key, entry in found.items(): + old = current.get(key, {}) + merged[key] = { + "phase": old.get("phase", _phase(key.split("::")[0], entry["calls"])), + "status": old.get("status", "todo"), + "calls": entry["calls"], + "depends": entry["depends"], + **{k: old[k] for k in ("owner", "compare", "note") if k in old}, + } + for key, old in current.items(): + if key not in merged and old.get("status") == "ported": + merged[key] = old + return merged + + +def _toml_value(value) -> str: + if isinstance(value, list): + return "[" + ", ".join(_toml_value(v) for v in value) + "]" + if isinstance(value, int): + return str(value) + return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def dump(manifest: dict[str, dict]) -> str: + lines = [ + "# One entry per Python function that negpy_core replaces. scripts/core_manifest.py writes", + "# calls and depends; phase, status (todo, ported, stays), owner, compare (replay: exact, rows,", + "# ulp=N, abs=X, quality: a pytest gate; see negpy_core/LEDGER.md) and note are kept by hand.", + ] + for key in sorted(manifest, key=lambda k: (manifest[k]["phase"], k)): + lines.append("") + lines.append(f"[{_toml_value(key)}]") + for field in ("phase", "status", "owner", "compare", "calls", "depends", "note"): + if field in manifest[key]: + lines.append(f"{field} = {_toml_value(manifest[key][field])}") + return "\n".join(lines) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail when the manifest is out of date") + args = parser.parse_args() + text = dump(merge(load(), scan())) + if args.check: + if MANIFEST.read_text(encoding="utf-8") != text: + raise SystemExit("negpy_core/manifest.toml is out of date: run uv run python scripts/core_manifest.py") + return + MANIFEST.write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/scripts/replay.py b/scripts/replay.py new file mode 100644 index 000000000..99d9bba3f --- /dev/null +++ b/scripts/replay.py @@ -0,0 +1,338 @@ +"""Record real calls of the functions in negpy_core/manifest.toml, then replay them through the current code. + + record FILE... run each frame through the preview load, the CPU pipeline, Square Before Crop and + the Auto-crop All Frames evidence, then resolve the files as one roll; save every + call of a recorded function: inputs, result and its time on all cores, called + alone. + replay call the current code with each saved input and compare the result bit for bit. + bench time the current code on all cores against the saved time (older recordings + saved a one-core time). + +Record on main just before a group is ported, so the corpus holds the behavior users have now. +The corpus lives in $NEGPY_CORPUS (default ~/negpy-corpus), on a local disk that every worktree reads. +""" + +import argparse +import copy +import dataclasses +import fcntl +import importlib +import json +import os +import pickle +import statistics +import sys +import time +from pathlib import Path + +import numpy as np # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from core_manifest import load as load_manifest # noqa: E402 + +CORPUS = Path(os.environ.get("NEGPY_CORPUS", Path.home() / "negpy-corpus")) +CALLS_PER_FRAME = 3 +CALLS_PER_FUNCTION = 60 +MAX_CALL_BYTES = 256 << 20 +TIMING_REPEATS = 3 +BENCH_LOCK = Path("/tmp/negpy-bench.lock") + + +def _resolve(key: str): + """(owner, attribute name, function) for a manifest key, or None for a nested or deleted function.""" + rel, qual = key.split("::") + module = importlib.import_module("negpy." + rel.removesuffix(".py").replace("/", ".")) + owner, *rest = qual.split(".") + if not rest: + # A ported helper whose Python is deleted: its callers' replays cover it. + return (module, owner, getattr(module, owner)) if hasattr(module, owner) else None + if len(rest) == 1 and isinstance(getattr(module, owner, None), type): + cls = getattr(module, owner) + return cls, rest[0], cls.__dict__[rest[0]] + return None + + +def _folder(key: str) -> Path: + return CORPUS / key.replace("/", "__").replace("::", "--") + + +def _targets(phases: list[int] | None, statuses: tuple[str, ...]) -> list[str]: + manifest = load_manifest() + return sorted(k for k, v in manifest.items() if v["status"] in statuses and (not phases or v["phase"] in phases)) + + +def _time(fn, args, kwargs) -> float: + """Best of a few runs on every core, as the app runs.""" + import cv2 + + cv2.setNumThreads(-1) + # One untimed call first: the first call in a process starts the thread pools. + a, kw = copy.deepcopy((args, kwargs)) + fn(*a, **kw) + best = float("inf") + for _ in range(TIMING_REPEATS): + a, kw = copy.deepcopy((args, kwargs)) + start = time.perf_counter() + fn(*a, **kw) + best = min(best, time.perf_counter() - start) + return best + + +class Recorder: + def __init__(self, keys: list[str]) -> None: + self.frame = "" + self.active = True + self.per_frame: dict[str, int] = {} + self.saved: dict[str, int] = {k: len(list(_folder(k).glob("*.pkl"))) for k in keys} + self.originals: list[tuple] = [] + for key in keys: + resolved = _resolve(key) + if resolved is None: + continue + owner, name, fn = resolved + wrapper = self._wrap(key, fn) + setattr(owner, name, wrapper) + self.originals.append((owner, name, fn)) + # `from module import fn` binds the name in each importer too. + for module in list(sys.modules.values()): + if getattr(module, "__name__", "").startswith("negpy.") and module is not owner: + for attr, value in list(vars(module).items()): + if value is fn: + setattr(module, attr, wrapper) + self.originals.append((module, attr, fn)) + + def _wrap(self, key: str, fn): + def wrapper(*args, **kwargs): + if not self.active or self.per_frame.get(key, 0) >= CALLS_PER_FRAME or self.saved[key] >= CALLS_PER_FUNCTION: + return fn(*args, **kwargs) + try: + inputs = copy.deepcopy((args, kwargs)) + except (TypeError, AttributeError): + return fn(*args, **kwargs) # a lock or a device handle in the arguments: not replayable + result = fn(*args, **kwargs) + self.per_frame[key] = self.per_frame.get(key, 0) + 1 + self.active = False + try: + payload = pickle.dumps({"args": inputs[0], "kwargs": inputs[1], "result": result, "frame": self.frame}, protocol=5) + if len(payload) <= MAX_CALL_BYTES: + record = pickle.loads(payload) + record["seconds_all"] = _time(fn, inputs[0], inputs[1]) + folder = _folder(key) + folder.mkdir(parents=True, exist_ok=True) + (folder / f"{self.saved[key]:04d}.pkl").write_bytes(pickle.dumps(record, protocol=5)) + self.saved[key] += 1 + except (pickle.PicklingError, TypeError, AttributeError): + pass # a result that does not pickle: not replayable + finally: + self.active = True + return result + + return wrapper + + def restore(self) -> None: + for owner, name, fn in reversed(self.originals): + setattr(owner, name, fn) + + +def _run_frame(path: str, repo=None, overrides: dict | None = None) -> tuple: + """The frame's saved edit (or the defaults), with Auto Crop armed so detection runs.""" + from negpy.domain.models import WorkspaceConfig + from negpy.features.geometry.models import with_auto_crop + from negpy.kernel.image.logic import file_hashes + from negpy.services.rendering.image_processor import ImageProcessor + from negpy.services.rendering.preview_manager import PreviewManager + + buffer, _, meta = PreviewManager().load_linear_preview(path) + saved = repo.load_file_settings(file_hashes(path)[0]) if repo else None + config = with_auto_crop(saved or WorkspaceConfig(), True) + for section, fields in (overrides or {}).items(): + config = dataclasses.replace(config, **{section: dataclasses.replace(getattr(config, section), **fields)}) + # Stage caching on, as the preview runs: the base read (measure_base_log) happens only then. + ImageProcessor(use_gpu=False).run_pipeline( + buffer, config, source_hash=path, render_size_ref=1600.0, prefer_gpu=False, ir_buffer=meta.get("ir_preview") + ) + return buffer, config + + +def _crop_evidence(key: str, buffer, config): + """One frame of Auto-crop All Frames and Square Before Crop, as the batch worker runs them + (without the flat field).""" + from dataclasses import replace + + from negpy.domain.interfaces import PipelineContext + from negpy.features.geometry.batch_autocrop import detect_crop_candidate + from negpy.features.geometry.processor import GeometryProcessor + from negpy.features.geometry.skew import squared_geometry + + geometry = config.geometry + squared_geometry(buffer, geometry, config.process.process_mode) + detection = replace(geometry, crop_rect=None, crop_from_auto=False, autocrop_offset=0) + context = PipelineContext(original_size=(buffer.shape[1], buffer.shape[0]), scale_factor=1.0, process_mode=config.process.process_mode) + transformed = GeometryProcessor(detection).process(buffer, context) + return detect_crop_candidate(key, transformed, target_ratio=geometry.autocrop_ratio, rebate_trim=geometry.autocrop_rebate_trim) + + +def _edits_repo(edits_db: str | None): + """A repository over a copy of *edits_db*, so recording never writes to the user's edits.""" + if not edits_db: + return None + import shutil + import tempfile + + from negpy.infrastructure.storage.repository import StorageRepository + + folder = Path(tempfile.mkdtemp(prefix="negpy-replay-")) + shutil.copy(edits_db, folder / "edits.db") + return StorageRepository(str(folder / "edits.db"), str(folder / "settings.db")) + + +def record(files: list[str], phases: list[int] | None, edits_db: str | None, overrides: dict | None = None) -> None: + keys = _targets(phases, ("todo",)) + repo = _edits_repo(edits_db) + import negpy.services.rendering.image_processor # noqa: F401 (load the pipeline before wrapping) + import negpy.services.rendering.preview_manager # noqa: F401 + + from negpy.features.geometry.batch_autocrop import resolve_roll_crops + + recorder = Recorder(keys) + try: + evidence = [] + for path in files: + recorder.frame, recorder.per_frame = Path(path).name, {} + buffer, config = _run_frame(path, repo, overrides) + evidence.append(_crop_evidence(str(len(evidence)), buffer, config)) + print(f"recorded {path}") + recorder.frame, recorder.per_frame = "roll", {} + resolve_roll_crops(evidence) + finally: + recorder.restore() + for key in keys: + if recorder.saved.get(key): + print(f"{recorder.saved[key]:4d} {key}") + + +def _ulps(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """Distance in units in the last place between two float arrays of one dtype.""" + ints = {np.dtype(np.float32): np.int32, np.dtype(np.float64): np.int64}[a.dtype] + ia, ib = (np.ascontiguousarray(x).view(ints).astype(np.int64) for x in (a, b)) + # Map the sign-magnitude float bits onto one ordered integer line. + ia = np.where(ia < 0, np.iinfo(ints).min - ia, ia) + ib = np.where(ib < 0, np.iinfo(ints).min - ib, ib) + return np.abs(ia - ib) + + +def same(a, b, compare: str = "exact") -> bool: + """Equality for replay. "exact": bit for bit, NaN payloads and the sign of zero included. + "rows": a 2-D array's rows in any order. "ulp=N": floats within N units in the last place. + "abs=X": floats within X of each other.""" + if type(a) is not type(b): + return False + if isinstance(a, np.ndarray): + if a.dtype != b.dtype or a.shape != b.shape: + return False + if compare == "rows" and a.ndim == 2: + rows = f"V{a.shape[1] * a.dtype.itemsize}" + a, b = (np.sort(np.ascontiguousarray(x).view(rows).ravel()) for x in (a, b)) + return a.tobytes() == b.tobytes() + if compare.startswith("ulp=") and a.dtype.kind == "f": + nan = np.isnan(a) + return bool(np.array_equal(nan, np.isnan(b)) and np.all(_ulps(a[~nan], b[~nan]) <= int(compare[4:]))) + if compare.startswith("abs=") and a.dtype.kind == "f": + nan = np.isnan(a) + return bool(np.array_equal(nan, np.isnan(b)) and np.all(np.abs(a[~nan] - b[~nan]) <= float(compare[4:]))) + return np.ascontiguousarray(a).tobytes() == np.ascontiguousarray(b).tobytes() + if isinstance(a, (np.generic, float)): + if compare.startswith(("ulp=", "abs=")): + return same(np.atleast_1d(np.asarray(a)), np.atleast_1d(np.asarray(b)), compare) + return np.asarray(a).tobytes() == np.asarray(b).tobytes() + if isinstance(a, (list, tuple)): + return len(a) == len(b) and all(same(x, y, compare) for x, y in zip(a, b)) + if isinstance(a, dict): + return a.keys() == b.keys() and all(same(a[k], b[k], compare) for k in a) + if dataclasses.is_dataclass(a) or hasattr(a, "__dict__"): + return same(vars(a), vars(b), compare) + return a == b + + +KEY_FILTER = "" + + +def _calls(keys: list[str]): + for key in [k for k in keys if KEY_FILTER in k]: + resolved = _resolve(key) + files = sorted(_folder(key).glob("*.pkl")) + if resolved is None or not files: + continue + yield key, resolved[2], files + + +def replay(phases: list[int] | None) -> int: + failed = 0 + manifest = load_manifest() + for key, fn, files in _calls(_targets(phases, ("todo", "ported", "stays"))): + compare = manifest[key].get("compare", "exact") + bad = [] + for f in files: + rec = pickle.loads(f.read_bytes()) + result = fn(*rec["args"], **rec["kwargs"]) + # A "quality" port has its own gate against the replaced cv2/NumPy call in pytest. + if compare != "quality" and not same(result, rec["result"], compare): + bad.append(f"{f.name} ({rec['frame']})") + failed += bool(bad) + print( + f"{'DIFF' if bad else 'qual' if compare == 'quality' else 'same'}{len(files) - len(bad):3d}/{len(files):<3d} {key} {' '.join(bad[:5])}" + ) + return 1 if failed else 0 + + +def bench(phases: list[int] | None) -> int: + slower = 0 + BENCH_LOCK.touch(exist_ok=True) + with BENCH_LOCK.open() as lock: + fcntl.flock(lock, fcntl.LOCK_EX) # one benchmark at a time on this machine + for key, fn, files in _calls(_targets(phases, ("ported",))): + ratios = [] + for f in files: + rec = pickle.loads(f.read_bytes()) + # Older recordings saved a one-core time only. + saved = rec.get("seconds_all", rec.get("seconds")) + ratios.append(_time(fn, rec["args"], rec["kwargs"]) / max(saved, 1e-9)) + ratio = statistics.median(ratios) + slower += ratio > 1.0 + print(f"{'SLOWER' if ratio > 1.0 else 'ok':6s} {ratio:6.3f}x {key}") + return 1 if slower else 0 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("mode", choices=("record", "replay", "bench")) + parser.add_argument("files", nargs="*") + parser.add_argument("--phase", type=int, action="append", help="only functions of this phase (repeatable)") + parser.add_argument("--key", default="", help="only functions whose manifest key holds this text") + parser.add_argument("--edits", help="record each frame with its saved edit from this edits.db (read from a copy)") + parser.add_argument( + "--set", + action="append", + default=[], + metavar="SECTION.FIELD=JSON", + help="override one config field for every frame, e.g. retouch.dust_remove=true", + ) + args = parser.parse_args() + global KEY_FILTER + KEY_FILTER = args.key + if args.mode == "record": + if not args.files: + parser.error("record needs at least one frame") + overrides: dict = {} + for item in args.set: + key, value = item.split("=", 1) + section, name = key.split(".", 1) + overrides.setdefault(section, {})[name] = json.loads(value) + record(args.files, args.phase, args.edits, overrides) + else: + sys.exit((replay if args.mode == "replay" else bench)(args.phase)) + + +if __name__ == "__main__": + main() diff --git a/tests/core_oracle.py b/tests/core_oracle.py new file mode 100644 index 000000000..ebba992d3 --- /dev/null +++ b/tests/core_oracle.py @@ -0,0 +1,11 @@ +import platform +import sys + +import pytest + +# opencv-python's AVX2 build on Linux x86-64 is the reference negpy_core copies. Its other +# builds differ in the last bits (NEON lanes, FMA contraction), so they are not oracles. +cv2_oracle = pytest.mark.skipif( + not (sys.platform == "linux" and platform.machine() == "x86_64"), + reason="cv2 is the oracle only on its Linux x86-64 build", +) diff --git a/tests/test_autocrop_consensus.py b/tests/test_autocrop_consensus.py index 0d913a88a..45207ab19 100644 --- a/tests/test_autocrop_consensus.py +++ b/tests/test_autocrop_consensus.py @@ -1,11 +1,10 @@ from dataclasses import FrozenInstanceError +import negpy_core import numpy as np import pytest from negpy.features.geometry.logic import ( - _FilmCandidate, - _select_consensus_cluster, apply_fine_rotation, detect_film_bounds_with_confidence, ) @@ -39,16 +38,16 @@ def _candidate( geometry_score=0.9, boundary_score=0.9, ): - return _FilmCandidate( - roi=roi, - correction_angle=0.0, - source=source, - threshold_index=threshold_index, - polarity="dark", - boundary_score=boundary_score, - geometry_score=geometry_score, - supported_sides=frozenset({"top", "bottom", "left", "right"}), - supported_corners=frozenset({"top_left", "top_right", "bottom_left", "bottom_right"}), + return ( + roi, + 0.0, + source, + threshold_index, + "dark", + boundary_score, + geometry_score, + ["top", "bottom", "left", "right"], + ["top_left", "top_right", "bottom_left", "bottom_right"], ) @@ -68,17 +67,18 @@ def test_consensus_rejects_a_high_scoring_single_source_outlier(): boundary_score=1.0, ) - consensus = _select_consensus_cluster([outlier, *stable], (360, 540)) + consensus = negpy_core.select_consensus([outlier, *stable], 360, 540) assert consensus is not None - assert all(abs(actual - expected) <= 1 for actual, expected in zip(consensus.roi, (80, 300, 100, 440))) - assert "adaptive-dark" in consensus.evidence_sources - assert "edges" not in consensus.evidence_sources + roi, *_, sources, _ = consensus + assert all(abs(actual - expected) <= 1 for actual, expected in zip(roi, (80, 300, 100, 440))) + assert "adaptive-dark" in sources + assert "edges" not in sources def test_consensus_abstains_on_a_single_structural_mask(): candidate = _candidate((80, 300, 100, 440), threshold_index=None, source="edges") - assert _select_consensus_cluster([candidate], (360, 540)) is None + assert negpy_core.select_consensus([candidate], 360, 540) is None def test_detects_dark_film_on_light_bed_and_returns_immutable_evidence(): diff --git a/tests/test_bounds_migration.py b/tests/test_bounds_migration.py index 1aa7327f2..c7341e6e7 100644 --- a/tests/test_bounds_migration.py +++ b/tests/test_bounds_migration.py @@ -15,7 +15,7 @@ # Bounds the analysis measures on _synthetic_negative() at each (luma_range_clip, color_range_clip), # under BOUNDS_VERSION. A change to them needs a bump; then record the new values here. -_RECORDED_VERSION = 1 +_RECORDED_VERSION = 2 _RECORDED = { (0.0, 1.0): ([-1.135058, -1.168505, -1.038996], [-0.247402, -0.451715, -0.607022]), (-0.3, 0.5): ([-1.512205, -1.48816, -1.244211], [0.135708, -0.13491, -0.408953]), diff --git a/tests/test_capture_meter.py b/tests/test_capture_meter.py index e08f60957..b9a6e6af1 100644 --- a/tests/test_capture_meter.py +++ b/tests/test_capture_meter.py @@ -196,11 +196,11 @@ def test_the_raw_clip_mask_predicts_the_metered_shutter(): def test_the_peak_map_keeps_the_edge_blocks(): from negpy.services.capture.meter import _peak_map - brightest = np.zeros((3001, 4003), np.float32) - brightest[-1, -1] = 1.0 # a clip in the last row and column, past a whole block - peaks = _peak_map(brightest) + frame = np.zeros((3001, 4003, 3), np.float32) + frame[-1, -1, 2] = 1.0 # a clip in the last row and column, past a whole block + peaks = _peak_map(frame) assert float(peaks[-1, -1]) == 1.0 - assert _peak_map(np.ones((2, 5000), np.float32)).shape[0] == 1 # a short edge under one block + assert _peak_map(np.ones((2, 5000, 3), np.float32)).shape[0] == 1 # a short edge under one block def _scene() -> np.ndarray: diff --git a/tests/test_core_altprocess.py b/tests/test_core_altprocess.py new file mode 100644 index 000000000..d4663ca4b --- /dev/null +++ b/tests/test_core_altprocess.py @@ -0,0 +1,117 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.cyanotype.logic import CYANOTYPE_CONSTANTS, apply_cyanotype, sensitizer_constants +from negpy.features.lab.logic import gaussian_kernel_1d +from negpy.features.lith.logic import LITH_CONSTANTS, apply_lith +from negpy.features.sabattier.logic import SABATTIER_CONSTANTS, apply_sabattier +from negpy.kernel.image.logic import _LAB_EPS, _LAB_KAPPA, lab_to_rgb_working + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(23) +_F = np.float32 + + +def _cr(fn): + """A float32 transcendental, correctly rounded: NumPy's own run SVML, whose bits depend on the CPU.""" + return lambda x: fn(np.asarray(x, dtype=np.float64)).astype(np.float32) + + +_log10, _exp, _log1p, _cbrt = _cr(np.log10), _cr(np.exp), _cr(np.log1p), _cr(np.cbrt) + + +def _pow10(x): + return np.power(10.0, np.asarray(x, dtype=np.float64)).astype(np.float32) + + +def _density(img): + arr = np.clip(img, 1e-6, 1.0) + luma = arr[:, :, 0] * _F(0.2126) + arr[:, :, 1] * _F(0.7152) + arr[:, :, 2] * _F(0.0722) + return -_log10(np.clip(luma, 1e-6, 1.0)) + + +def _interp(u, xp, anchors, i): + return np.interp(u, np.asarray(xp, _F), np.asarray([p[i] for p in anchors], _F)).astype(_F) + + +def _to_rgb(dens, a, b): + grey = _pow10(-dens) + f = np.where(grey > _F(_LAB_EPS), _cbrt(grey), _F(_LAB_KAPPA) * grey + _F(16.0 / 116.0)) + return np.clip(lab_to_rgb_working(np.dstack([_F(116.0) * f - _F(16.0), a, b])), 0.0, 1.0) + + +def _sabattier_reference(img, d_max, strength, reexposure, sigma): + c = SABATTIER_CONSTANTS + dens = _density(img) + re, w = _F(reexposure * d_max), _F(c["fold_width"]) + reversal = _F(strength) * (w * _log1p(_exp(np.clip((re - dens) / w, -30.0, 30.0)))) + if sigma > 0.0: + developed = 1.0 / (1.0 + _exp(-np.clip((dens - re) / _F(c["edge_width"]), -30.0, 30.0))) + k = gaussian_kernel_1d(sigma) + reversal = reversal * (_F(1.0) - cv2.sepFilter2D(developed.astype(_F), -1, k, k, borderType=cv2.BORDER_REPLICATE)) + grey = _pow10(-np.minimum(dens + reversal, _F(d_max))) + return np.clip(np.dstack([grey, grey, grey]), 0.0, 1.0) + + +def _cyanotype_reference(img, sensitizer, exposure, scale, bleach, tannin): + c, sc = CYANOTYPE_CONSTANTS, sensitizer_constants(sensitizer) + t = np.clip((_density(img) + _F(0.301 * exposure)) / _F(max(scale, 0.1)), 0.0, 1.0) + m = _F(c["mid_compress"]) + v = _F(2.0) * t - _F(1.0) + u0 = (_F(1.0) - m) * t + m * _F(0.5) * (_F(1.0) + v * np.abs(v)) + u_b = u0 * (_F(1.0) - _F(bleach) * (_F(1.0) - _F(c["bleach_floor"]) * u0)) + u = u_b + _F(tannin) * (u0 * _F(c["tannin_restore"]) - u_b) + dens = _F(sc["d_max"]) * (_F(1.0) + _F(tannin) * _F(c["tannin_dmax_gain"])) * u + frac = np.clip(u, 0.0, 1.0) + a, b = (_interp(frac, c["path_u"], sc["path"], i) for i in (0, 1)) + a = a + _F(tannin) * (_F(c["brown_dir"][0]) * frac - a) + b = b + _F(tannin) * (_F(c["brown_dir"][1]) * frac - b) + return _to_rgb(dens, a, b) + + +def _lith_reference(img, path, d_max, exposure, snatch, abruptness): + c = LITH_CONSTANTS + base = _density(img) + over = _F(0.301 * exposure) + foot_max = _F(c["foot_max"]) + d_h = foot_max * (_F(1.0) - _exp(-_F(c["foot_rate"]) * (base + over * _F(c["foot_veil"])) / foot_max)) + knee = _F(d_max * (c["knee_lo"] - c["knee_span"] * snatch)) + width = _F(max(c["abrupt_lo"] - c["abrupt_span"] * abruptness, 0.01)) + g = 1.0 / (1.0 + _exp(-np.clip((base + over - knee) / width, -30.0, 30.0))) + dens = d_h + (_F(d_max) - d_h) * g + frac = np.clip(dens / _F(d_max), 0.0, 1.0) + return _to_rgb(dens, *(_interp(frac, c["path_u"], path, i) for i in (0, 1))) + + +_IMAGES = [_RNG.random((97, 131, 3), dtype=np.float32), _RNG.random((40, 50, 3), dtype=np.float32) ** 3] + + +@pytest.mark.parametrize("img", _IMAGES) +@pytest.mark.parametrize("d_max, strength, reexposure, sigma", [(2.1, 1.3, 0.45, 0.0), (1.9, 0.6, 0.3, 2.5)]) +def test_sabattier_matches_numpy(img, d_max, strength, reexposure, sigma): + want = _sabattier_reference(img, d_max, strength, reexposure, sigma) + assert apply_sabattier(img, d_max, True, strength, reexposure, sigma).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("img", _IMAGES) +@pytest.mark.parametrize("args", [("classic", 0.0, 1.4, 0.0, 0.0), ("new", 1.0, 2.3, 0.6, 0.4)]) +def test_cyanotype_matches_numpy(img, args): + assert apply_cyanotype(img, args[0], True, *args[1:]).tobytes() == _cyanotype_reference(img, *args).tobytes() + + +@pytest.mark.parametrize("img", _IMAGES) +@pytest.mark.parametrize("args", [(2.0, 0.0, 0.55, 0.6), (1.8, 1.5, 0.9, 0.1)]) +def test_lith_matches_numpy(img, args): + path = ((4.0, 12.0), (6.0, 18.0), (2.0, 8.0), (0.0, 0.5)) + want = _lith_reference(img, path, *args) + d_max, exposure, snatch, abruptness = args + assert apply_lith(img, path, d_max, True, exposure, snatch, abruptness).tobytes() == want.tobytes() + + +def test_sabattier_rejects_a_bad_image(): + with pytest.raises(ValueError): + negpy_core.sabattier(np.zeros((4, 4), np.float32), (_F(1.0),) * 5, np.zeros(0, np.float32)) diff --git a/tests/test_core_autocrop.py b/tests/test_core_autocrop.py new file mode 100644 index 000000000..407ae8942 --- /dev/null +++ b/tests/test_core_autocrop.py @@ -0,0 +1,164 @@ +"""The batch autocrop profile reads and the occupancy window against the NumPy code they replace.""" + +import numpy as np +import negpy_core +import pytest + +from negpy.features.geometry.batch_autocrop import CropEvidence, RollCropTemplate, _rect_from_edge_profile, _top_edge_slope +from negpy.features.geometry.logic import _place_window_by_occupancy + + +def _top_edge_slope_numpy(lum, roi): + y1, _, x1, x2 = roi + height = lum.shape[0] + left, right = x1 + 20, x2 - 20 + columns = np.arange(left, right, 3) + min_points = max(20, round((right - left) * 0.08)) + if columns.size < min_points: + return None + radius = max(12, round(height * 0.035)) + low, high = max(0, y1 - radius), min(height, y1 + radius) + if high - low < 4: + return None + gradient = np.abs(negpy_core.sobel(negpy_core.gaussian_blur(lum.astype(np.float32), (0, 0), 1.5), 0, 1)) + band = gradient[low:high, columns] + rows = low + np.argmax(band, axis=0) + strength = band.max(axis=0) + if float(np.median(strength)) < max(1e-6, 3.0 * float(np.median(band))): + return None + points = np.column_stack([columns, rows])[strength >= np.quantile(strength, 0.60)] + if len(points) < min_points: + return None + residuals = np.zeros(len(points)) + slope = 0.0 + for _ in range(5): + slope, intercept = np.polyfit(points[:, 0], points[:, 1], 1) + residuals = np.abs(points[:, 1] - (slope * points[:, 0] + intercept)) + keep = residuals <= max(2.0, float(np.median(residuals)) * 2.5) + if keep.all() or keep.sum() < min_points: + break + points = points[keep] + if float(np.median(residuals)) > 2.0: + return None + return float(np.degrees(np.arctan(slope))) + + +def _tilted_box(tilt, seed, size=(400, 600)): + h, w = size + y, x = np.mgrid[0:h, 0:w].astype(np.float64) + top = 100 + np.tan(np.radians(tilt)) * (x - w / 2) + lum = np.where(y >= top, 0.8, 0.1) + np.random.default_rng(seed).normal(0, 0.02, (h, w)) + return lum.astype(np.float32) + + +@pytest.mark.parametrize("tilt", [-2.0, -0.7, 0.0, 0.3, 1.6]) +def test_top_edge_slope_matches_numpy(tilt): + for seed in range(4): + lum = _tilted_box(tilt, seed) + for roi in ((100, 300, 50, 550), (8, 300, 0, 600), (100, 300, 300, 330)): + want, got = _top_edge_slope_numpy(lum, roi), _top_edge_slope(lum, roi) + assert (want is None) == (got is None) + if want is not None: + assert got == pytest.approx(want, abs=1e-9) + + +def _peak_near(profile, expected, radius): + if profile.size == 0: + return expected, 0.0 + center = int(round(expected * (profile.size - 1))) + half = max(2, int(round(radius * profile.size))) + lo, hi = max(0, center - half), min(profile.size, center + half + 1) + if hi <= lo: + return expected, 0.0 + local = profile[lo:hi] + offset = int(np.argmax(local)) + return (lo + offset) / max(profile.size - 1, 1), float(local[offset]) + + +def _clamp(rect): + x1, y1, x2, y2 = rect + x1, x2 = sorted((float(np.clip(x1, 0.0, 1.0)), float(np.clip(x2, 0.0, 1.0)))) + y1, y2 = sorted((float(np.clip(y1, 0.0, 1.0)), float(np.clip(y2, 0.0, 1.0)))) + return x1, y1, x2, y2 + + +def _rect_numpy(profile, contrast, t): + if contrast < 0.06 or profile.size < 8 or not np.any(np.isfinite(profile)): + return None + base, peak = float(np.percentile(profile, 50)), float(np.percentile(profile, 99.5)) + if peak - base < 0.20 or peak < 1.35 * max(base, 0.05): + return None + radius = max(0.045, 4.0 * t.center_mad + 2.0 * t.width_mad) + left, ls = _peak_near(profile, t.center_x - t.width * 0.5, radius) + right, rs = _peak_near(profile, t.center_x + t.width * 0.5, radius) + baseline = float(np.percentile(profile, 70)) + spread = float(np.percentile(profile, 90) - np.percentile(profile, 50)) + threshold = max(0.24, baseline + 0.55 * spread) + lok, rok = ls >= threshold, rs >= threshold + if not (lok and rok) and abs((right - left) - t.width) <= 0.02: + lok = rok = True + if not lok and not rok: + return None + if lok and rok: + if right - left < 0.65 * t.width or right - left > 1.35 * t.fallback_width: + return None + x1, x2 = left, right + elif lok and ls >= threshold + 0.12: + x1, x2 = left, left + t.fallback_width + elif rok and rs >= threshold + 0.12: + x1, x2 = right - t.fallback_width, right + else: + return None + return _clamp((x1, t.top, x2, t.top + t.height)) + + +def test_rect_from_edge_profile_matches_numpy(): + rng = np.random.default_rng(7) + hits = 0 + for _ in range(3000): + n = int(rng.integers(8, 400)) + profile = (rng.random(n) * rng.uniform(0.05, 0.6)).astype(np.float32) + t = RollCropTemplate( + width=float(rng.uniform(0.5, 0.95)), fallback_width=float(rng.uniform(0.5, 1.0)), height=0.8, center_x=float(rng.uniform(0.4, 0.6)), + top=0.1, correction_angle=0.0, width_mad=float(rng.uniform(0, 0.02)), center_mad=float(rng.uniform(0, 0.02)), top_mad=0.0, + angle_mad=0.0, confidence=0.7, sample_count=5, + ) # fmt: skip + for edge in (t.center_x - t.width / 2, t.center_x + t.width / 2): + if rng.random() < 0.8: + profile[int(np.clip(round((edge + rng.normal(0, 0.01)) * (n - 1)), 0, n - 1))] = rng.uniform(0.5, 2.0) + if rng.random() < 0.05: + profile[int(rng.integers(n))] = np.nan + item = CropEvidence( + "k", (100, 150), None, 0.0, 0.0, vertical_edge_contrast=float(rng.uniform(0, 0.3)), vertical_edge_profile=profile + ) + want = _rect_numpy(profile, item.vertical_edge_contrast, t) + assert _rect_from_edge_profile(item, t) == want + hits += want is not None + assert hits > 100 + + +def _window_numpy(start, end, target_len, occupancy, scale): + d_start = max(0, int(round(start * scale))) + d_end = min(occupancy.size, int(round(end * scale))) + d_len = max(1, int(round(target_len * scale))) + if d_len >= d_end - d_start: + return start + cs = np.concatenate(([0.0], np.cumsum(occupancy[d_start:d_end], dtype=np.float64))) + n_pos = (d_end - d_start) - d_len + 1 + scores = cs[d_len : d_len + n_pos] - cs[:n_pos] + candidates = np.where(scores >= float(np.max(scores)) - 1e-9)[0] + centered = ((d_end - d_start) - d_len) / 2.0 + k = int(candidates[np.argmin(np.abs(candidates - centered))]) + return int(min(max(start, round((d_start + k) / scale)), end - target_len)) + + +def test_place_window_by_occupancy_matches_numpy(): + rng = np.random.default_rng(3) + for _ in range(5000): + n = int(rng.integers(5, 300)) + occ = (rng.random(n) > rng.random()).astype(np.float32) * rng.choice([1.0, 0.5], n).astype(np.float32) + scale = float(rng.choice([1.0, 0.5, 0.37, 0.25])) + end = int(rng.integers(2, int(n / scale) + 2)) + start = int(rng.integers(0, end)) + target = float(rng.uniform(1, end - start + 1)) if rng.random() < 0.5 else float(rng.integers(1, end - start + 1)) + assert _place_window_by_occupancy(start, end, target, occ, scale) == _window_numpy(start, end, target, occ, scale) diff --git a/tests/test_core_background_pool.py b/tests/test_core_background_pool.py new file mode 100644 index 000000000..34ccefe46 --- /dev/null +++ b/tests/test_core_background_pool.py @@ -0,0 +1,43 @@ +"""A background thread's large work enters the small pool, with the same bits.""" + +import threading + +import numpy as np + +import negpy_core + + +def _on_thread(fn): + out = [] + t = threading.Thread(target=lambda: out.append(fn())) + t.start() + t.join() + return out[0] + + +def _background(fn): + def run(): + negpy_core.set_background_thread(True) + return fn() + + return _on_thread(run) + + +def test_a_background_thread_uses_a_quarter_of_the_cores_and_at_least_two(): + cores = negpy_core.pool_threads() + assert _background(negpy_core.pool_threads) == min(cores, max(2, cores // 4)) + assert _on_thread(negpy_core.pool_threads) == cores + + +def test_the_flag_stays_on_its_thread(): + def mark_then_read(): + negpy_core.set_background_thread(True) + return _on_thread(negpy_core.pool_threads) + + assert _on_thread(mark_then_read) == negpy_core.pool_threads() + + +def test_the_background_pool_gives_the_same_bits(): + values = np.random.default_rng(0).random(1 << 20, dtype=np.float32) + qs = [0.5, 1.0, 50.0, 99.0, 99.5] + assert _background(lambda: negpy_core.percentiles(values, qs)) == negpy_core.percentiles(values, qs) diff --git a/tests/test_core_capture.py b/tests/test_core_capture.py new file mode 100644 index 000000000..5d17189c7 --- /dev/null +++ b/tests/test_core_capture.py @@ -0,0 +1,182 @@ +"""The preview and capture helpers against the NumPy code they replace.""" + +import numpy as np +import pytest + +import negpy_core +from negpy.desktop.view.widgets.prescan_dialog import _preview_u8 +from negpy.desktop.view.widgets.scan_preview_common import preview_positive +from negpy.infrastructure.scanners.params import FilmType +from negpy.services.capture.focus_meter import MIN_MEAN_LEVEL, sharpness +from negpy.services.scanning.writer import _to_grey + + +def _scan(seed, shape=(151, 203, 3), dtype=np.uint16): + rng = np.random.default_rng(seed) + return (rng.uniform(0.05, 0.9, shape) * np.iinfo(dtype).max).astype(dtype) + + +def _preview_positive_numpy(rgb, positive): + a = rgb.astype(np.float32) + if a.ndim == 2: + a = a[:, :, None] + out = np.empty_like(a) + for c in range(a.shape[2]): + ch = a[..., c] + lo, hi = np.percentile(ch, 1), np.percentile(ch, 99) + if hi <= lo: + out[..., c] = 0.0 + continue + scaled = (ch - lo) if positive else (hi - ch) + out[..., c] = np.clip(scaled / (hi - lo), 0.0, 1.0) * 255.0 + return out.astype(np.uint8) + + +def _preview_u8_numpy(rgb): + a = np.asarray(rgb, dtype=np.float32) + if a.ndim == 2: + a = a[:, :, None] + out = np.empty(a.shape[:2] + (3,), dtype=np.uint8) + for c in range(3): + ch = a[..., min(c, a.shape[2] - 1)] + lo, hi = np.percentile(ch, 1), np.percentile(ch, 99) + out[..., c] = 0 if hi <= lo else np.clip((ch - lo) / (hi - lo) * 255.0, 0, 255).astype(np.uint8) + return out + + +@pytest.mark.parametrize("shape", [(151, 203, 3), (40, 50), (30, 20, 4)]) +def test_preview_stretches_match_numpy(shape): + rgb = _scan(1, shape) + if len(shape) == 3: + rgb[..., -1] = 7 # a flat channel + np.testing.assert_array_equal(preview_positive(rgb, "negative"), _preview_positive_numpy(rgb, False)) + np.testing.assert_array_equal(preview_positive(rgb, FilmType.POSITIVE.value), _preview_positive_numpy(rgb, True)) + np.testing.assert_array_equal(_preview_u8(rgb), _preview_u8_numpy(rgb)) + + +def test_sharpness_matches_numpy(): + img = _scan(2, (120, 160), np.uint8).astype(np.float32) + mean = float(img.mean()) + lap = 4.0 * img[1:-1, 1:-1] - img[:-2, 1:-1] - img[2:, 1:-1] - img[1:-1, :-2] - img[1:-1, 2:] + assert sharpness(img) == float(np.mean(lap * lap)) / (mean * mean) + assert sharpness(np.full((10, 10), MIN_MEAN_LEVEL / 2, np.float32)) == 0.0 + + +def test_level_stats_match_numpy(): + for patch in (_scan(3, (50, 70), np.uint16), _scan(4, (33, 41), np.uint16).astype(np.float32) / 65535): + level = 60000 if patch.dtype == np.uint16 else 0.9 + frac, p = negpy_core.patch_level_stats(np.ascontiguousarray(patch), float(level), 99.9) + assert frac == float(np.mean(patch >= level)) + assert p == float(np.percentile(patch, 99.9)) + img = _scan(5, (40, 60, 4)).astype(np.float32) / 65535 + assert negpy_core.any_channel_at_least(img, 0.85) == float(np.mean(np.any(img[:, :, :3] >= 0.85, axis=2))) + + +@pytest.mark.parametrize("dtype", [np.uint8, np.uint16]) +def test_to_grey_matches_numpy(dtype): + rgb = _scan(6, (31, 47, 3), dtype) + np.testing.assert_array_equal(_to_grey(rgb), ((np.sum(rgb, axis=-1, dtype=np.uint32) + 1) // 3).astype(dtype)) + + +def test_level_stats_of_float64_match_numpy(): + patch = np.random.default_rng(7).uniform(0, 65535, (37, 29)) + frac, p = negpy_core.patch_level_stats(patch, 60000.0, 99.9) + assert (frac, p) == (float(np.mean(patch >= 60000.0)), float(np.percentile(patch, 99.9))) + + +def test_meter_picture_matches_numpy(): + rng = np.random.default_rng(8) + linear = np.clip(rng.uniform(0.2, 1.05, (90, 120, 3)).astype(np.float32), 0, 1) + sat, roi = np.float32(0.998), (10, 80, 15, 110) + picture = linear[10:80, 15:110].reshape(-1, 3) + saturated = picture.max(axis=1) >= sat + film = picture if saturated.mean() >= 0.02 or saturated.all() else picture[~saturated] + clipped, blown, thin = negpy_core.meter_picture(linear, roi, float(sat), 0.02) + assert clipped == float(np.mean(saturated)) and blown == bool(saturated.mean() >= 0.02) + np.testing.assert_array_equal(np.array(thin, np.float32), np.percentile(film, 99.9, axis=0)) + + +def _plateau_clip_fraction(values, guard, R): + top = int(values.max()) + if guard > 0 and top * 2 < guard: + return 0.0 + lo = max(0, top - R._PLATEAU_WINDOW) + counts = np.bincount(values[values >= lo] - lo, minlength=R._PLATEAU_WINDOW + 1) + populated = np.flatnonzero(counts) + peak_at = int(np.argmax(counts[populated])) + peak_bin = int(populated[peak_at]) + peak = int(counts[peak_bin]) + if peak < R._MIN_PLATEAU_SITES: + return 0.0 + neighbors = counts[populated[peak_at + 1 : peak_at + 1 + R._PLATEAU_NEIGHBOR_BINS]] + density = float(neighbors.max()) if neighbors.size else 0.0 + if peak < R._PLATEAU_DENSITY_RATIO * max(density, 1.0): + return 0.0 + return float(np.count_nonzero(values >= lo + peak_bin)) / values.size + + +@pytest.mark.parametrize("plateau", [None, 15500, 15790]) +def test_raw_clip_fractions_match_numpy(plateau): + from negpy.infrastructure.capture import raw_demosaic as R + + rng = np.random.default_rng(9) + img = rng.integers(14000, 15800, (60, 80)).astype(np.uint16) + if plateau is not None: + img[rng.uniform(0, 1, img.shape) < 0.3] = plateau + colors = np.tile(np.array([[0, 1], [3, 2]], np.uint8), (30, 40)) + values = img[np.isin(colors, [1, 3])] + want = (float(np.mean(values >= 15000)), _plateau_clip_fraction(values, 16383, R)) + got = negpy_core.raw_clip_fractions( + img, colors, [1, 3], 15000, 16383, R._PLATEAU_WINDOW, R._MIN_PLATEAU_SITES, R._PLATEAU_NEIGHBOR_BINS, R._PLATEAU_DENSITY_RATIO + ) + assert got == want + + +def test_column_means_ring_medians_and_dmax_match_numpy(): + rng = np.random.default_rng(10) + a = rng.uniform(0, 1, (700, 300)).astype(np.float32) + peak = float(a.max()) + np.testing.assert_array_equal(negpy_core.column_means(a, 2, peak), (a[::2] / peak).mean(axis=0)) + arr = rng.uniform(0, 0.2, (256, 190)).astype(np.float32) + m, (h, w) = 10, arr.shape + ring = np.concatenate([arr[:m].ravel(), arr[-m:].ravel(), arr[:, :m].ravel(), arr[:, -m:].ravel()]) + inner = arr[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4] + assert negpy_core.ring_inner_medians(arr, m) == (np.median(ring), np.median(inner)) + px = rng.integers(0, 256, (80, 60, 3)).astype(np.uint8) + pixels = px.reshape(-1, 3).astype(np.float32) + luma = pixels @ np.array([0.2126, 0.7152, 0.0722], np.float32) + darkest = pixels[luma <= np.quantile(luma, 0.01)] + assert negpy_core.print_dmax(px, 0.01) == tuple(int(round(v)) for v in darkest.mean(axis=0)) + + +def _srgb_numpy(img): + return np.where(img <= 0.04045, img / 12.92, ((img + 0.055) / 1.055) ** 2.4).astype(np.float32) + + +def test_srgb_to_linear_stays_within_an_ulp(): + from negpy.kernel.image.logic import srgb_to_linear + + img = np.linspace(0, 1, 70001, dtype=np.float32) + base = ((img + np.float32(0.055)) / np.float32(1.055)).astype(np.float64) + exact = np.where(img <= 0.04045, img / np.float32(12.92), (base**2.4).astype(np.float32)).astype(np.float32) + got = srgb_to_linear(img) + assert np.max(np.abs(got.view(np.int32) - exact.view(np.int32))) <= 1 + assert np.max(np.abs(got.view(np.int32) - _srgb_numpy(img).view(np.int32))) <= 4 + + +def test_thumbnail_log_stretch_matches_numpy_within_a_level(): + rng = np.random.default_rng(12) + linear = _srgb_numpy((rng.integers(0, 256, (90, 130, 3)) / 255).astype(np.float32)) + linear[..., 2] *= 0.01 # a near-empty channel borrows the strongest + peaks = np.percentile(linear, 99.0, axis=(0, 1)) + strongest = int(np.argmax(peaks)) + empty = peaks < peaks[strongest] * 0.05 + work = linear.copy() + work[:, :, empty] = work[:, :, strongest : strongest + 1] + density = -np.log10(np.clip(work, 1e-4, None)) + lo, hi = np.percentile(density, 1.0, axis=(0, 1)), np.percentile(density, 99.0, axis=(0, 1)) + positive = np.clip((density - lo) / np.maximum(hi - lo, 1e-6), 0.0, 1.0) + want = (positive.astype(np.float64) * 255 + 0.5).astype(np.uint8) + got = negpy_core.log_stretch_u8(linear) + assert np.max(np.abs(got.astype(int) - want)) <= 1 + assert np.mean(got != want) < 0.01 diff --git a/tests/test_core_color.py b/tests/test_core_color.py new file mode 100644 index 000000000..3e40b10de --- /dev/null +++ b/tests/test_core_color.py @@ -0,0 +1,60 @@ +import numpy as np +import pytest + +from negpy.kernel.image.logic import ( + _WORKING_GAMMA, + _WORKING_TO_XYZ, + _WORKING_WHITE, + lab_to_rgb_working, + rgb_to_lab_working, + working_oetf_decode, + working_oetf_encode, +) + +_RNG = np.random.default_rng(11) + + +def _f32_ulps(a, b): + ia, ib = a.view(np.int32).astype(np.int64), b.view(np.int32).astype(np.int64) + return np.abs(ia - ib) + + +def test_oetf_is_the_float64_power_rounded_once(): + x = _RNG.random(200_000, dtype=np.float32) * 1.2 - 0.1 + x[:4] = [0.0, 1.0, -0.5, 1.5] + clamped = np.clip(x, 0.0, 1.0).astype(np.float64) + want = (clamped ** np.float64(np.float32(1.0 / _WORKING_GAMMA))).astype(np.float32) + got = working_oetf_encode(x) + assert np.max(_f32_ulps(got, want)) <= 1 + assert np.mean(got != want) < 1e-5 + back = working_oetf_decode(got) + want = (np.maximum(got, 0.0).astype(np.float64) ** np.float64(np.float32(_WORKING_GAMMA))).astype(np.float32) + assert np.max(_f32_ulps(back, want)) <= 1 + + +def test_oetf_keeps_nan(): + assert np.isnan(working_oetf_encode(np.array([np.nan], np.float32))[0]) + assert np.isnan(working_oetf_decode(np.array([np.nan], np.float32))[0]) + + +def _lab_reference(rgb): + lin = np.maximum(rgb.astype(np.float64), 0.0) + t = (lin @ _WORKING_TO_XYZ.astype(np.float64).T) / _WORKING_WHITE.astype(np.float64) + eps, kappa, c = float(np.float32(0.008856)), float(np.float32(7.787)), float(np.float32(16.0 / 116.0)) + f = np.where(t > eps, np.cbrt(t), kappa * t + c) + return np.stack([116.0 * f[:, 1] - 16.0, 500.0 * (f[:, 0] - f[:, 1]), 200.0 * (f[:, 1] - f[:, 2])], axis=1) + + +@pytest.mark.parametrize("scale", [1.0, 0.01]) +def test_lab_matches_a_float64_reference_and_round_trips(scale): + rgb = (_RNG.random((50_000, 3), dtype=np.float32) * scale).astype(np.float32) + lab = rgb_to_lab_working(rgb) + assert np.max(np.abs(lab - _lab_reference(rgb))) < 1e-4 + back = lab_to_rgb_working(lab) + assert np.max(np.abs(back - rgb)) < 1e-5 * max(scale, 0.1) + + +def test_lab_keeps_the_shape(): + img = _RNG.random((7, 5, 3), dtype=np.float32) + assert rgb_to_lab_working(img).shape == (7, 5, 3) + assert lab_to_rgb_working(rgb_to_lab_working(img)).shape == (7, 5, 3) diff --git a/tests/test_core_components.py b/tests/test_core_components.py new file mode 100644 index 000000000..ff2c27168 --- /dev/null +++ b/tests/test_core_components.py @@ -0,0 +1,59 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(4) + + +def _mask(h, w, density): + return ((_RNG.random((h, w)) < density) * 255).astype(np.uint8) + + +@pytest.mark.parametrize("connectivity", [4, 8]) +@pytest.mark.parametrize("shape", [(1, 1), (1, 57), (57, 1), (2, 2), (31, 47), (128, 200), (257, 129)]) +@pytest.mark.parametrize("density", [0.0, 0.1, 0.4, 0.7, 1.0]) +def test_connected_components_with_stats_match_cv2(connectivity, shape, density): + mask = _mask(*shape, density) + want = cv2.connectedComponentsWithStats(mask, connectivity=connectivity) + count, labels, stats, centroids = negpy_core.connected_components_with_stats(mask, connectivity) + assert count == want[0] + assert np.array_equal(labels, want[1]) + assert np.array_equal(stats, want[2]) + assert centroids.tobytes() == want[3].tobytes() + + +def _sparse(h, w, density): + return (_RNG.random((h, w)) >= density).astype(np.uint8) + + +@pytest.mark.parametrize("shape", [(1, 1), (1, 40), (40, 1), (37, 53), (150, 90)]) +@pytest.mark.parametrize("density", [0.0, 0.005, 0.05, 0.5]) +@pytest.mark.parametrize("dist_type, mask_size", [(cv2.DIST_C, 3), (cv2.DIST_L2, 5)]) +def test_distance_transform_matches_cv2(shape, density, dist_type, mask_size): + mask = _sparse(*shape, density) + want = cv2.distanceTransform(mask, dist_type, mask_size) + assert negpy_core.distance_transform(mask, dist_type, mask_size).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("shape", [(1, 1), (37, 53), (150, 90)]) +@pytest.mark.parametrize("density", [0.0, 0.005, 0.05, 0.5]) +def test_distance_transform_l2_mask3_within_one_ulp(shape, density): + mask = _sparse(*shape, density) + want = cv2.distanceTransform(mask, cv2.DIST_L2, 3).view(np.int32).astype(np.int64) + got = negpy_core.distance_transform(mask, cv2.DIST_L2, 3).view(np.int32).astype(np.int64) + assert np.abs(got - want).max(initial=0) <= 1 + + +@pytest.mark.parametrize("shape", [(1, 1), (1, 40), (37, 53), (150, 90)]) +@pytest.mark.parametrize("density", [0.005, 0.05, 0.5]) +@pytest.mark.parametrize("mask_size", [3, 5]) +def test_distance_transform_with_labels_matches_cv2(shape, density, mask_size): + mask = _sparse(*shape, density) + want_d, want_l = cv2.distanceTransformWithLabels(mask, cv2.DIST_L2, mask_size, labelType=cv2.DIST_LABEL_PIXEL) + got_d, got_l = negpy_core.distance_transform_labels(mask) + assert got_d.tobytes() == want_d.tobytes() + assert np.array_equal(got_l, want_l) diff --git a/tests/test_core_composite.py b/tests/test_core_composite.py new file mode 100644 index 000000000..f88f67bc8 --- /dev/null +++ b/tests/test_core_composite.py @@ -0,0 +1,83 @@ +"""The HDR, RGB Scan and stitch helpers against the NumPy code they replace.""" + +import numpy as np +import pytest + +import negpy_core +from negpy.features.rgbscan import logic as rgbscan +from negpy.features.stitch import logic as stitch + + +def _img(seed, shape=(123, 211, 3), dtype=np.float32): + return np.random.default_rng(seed).uniform(-0.1, 1.3, shape).astype(dtype) + + +def test_mean_channels_matches_numpy(): + img = _img(1) + np.testing.assert_array_equal(negpy_core.mean_channels(img), img.mean(axis=2)) + + +@pytest.mark.parametrize("dtype", [np.float32, np.uint16, np.uint8]) +def test_pick_channels_matches_numpy(dtype): + r, g, b = (np.random.default_rng(s).integers(0, 250, (31, 47, 3)).astype(dtype) for s in (2, 3, 4)) + want = np.empty_like(r) + want[..., 0], want[..., 1], want[..., 2] = r[..., 0], g[..., 1], b[..., 2] + np.testing.assert_array_equal(negpy_core.pick_channels(r, g, b), want) + np.testing.assert_array_equal(rgbscan.assemble_rgb(r, g, b, align=False), want) + + +def test_extract_channel_matches_numpy(): + img = _img(5, (20, 30, 4)) + np.testing.assert_array_equal(negpy_core.extract_channel(img, 2), img[..., 2]) + + +def test_proxy_stretch_matches_numpy(): + gray = _img(6, (600, 900)) + lo, hi = np.percentile(gray, (1.0, 99.0)) + want = (np.clip((gray - lo) / max(hi - lo, 1e-9), 0.0, 1.0) ** (1.0 / 2.2) * 255.0).astype(np.uint8) + np.testing.assert_array_equal(negpy_core.proxy_stretch(gray), want) + + +@pytest.mark.parametrize("take_max", [True, False]) +def test_ir_blend_matches_numpy(take_max): + rng = np.random.default_rng(7) + acc = np.full((40, 50), -1.0, np.float32) + want = acc.copy() + for _ in range(3): + warped = rng.uniform(0, 1, (40, 50)).astype(np.float32) + sel = rng.uniform(0, 1, (40, 50)) > 0.5 + negpy_core.ir_blend_into(acc, warped, sel, take_max) + want[sel] = np.maximum(want[sel], warped[sel]) if take_max else warped[sel] + negpy_core.ir_finish(acc) + want[want < 0.0] = 1.0 + np.testing.assert_array_equal(acc, want) + + +def test_mask_overlap_scale_and_clip_match_numpy(): + rng = np.random.default_rng(8) + a, b = rng.uniform(0, 1, (2, 64, 80)) > 0.4 + assert negpy_core.mask_overlap(a, b) == int((a & b).sum()) + img = _img(9) + want = img * np.array([0.9, 1.1, 1.3], dtype=np.float32) + negpy_core.scale_channels(img, [0.9, 1.1, 1.3]) + np.testing.assert_array_equal(img, want) + want = np.clip(img, 0.0, 1.0) + negpy_core.clip_unit(img) + np.testing.assert_array_equal(img, want) + + +def test_channel_means_match_numpy(): + rng = np.random.default_rng(10) + mosaic = rng.uniform(0, 4000, (300, 401)).astype(np.float32) + colors = np.tile(np.array([[0, 1], [3, 2]], np.uint8), (150, 201))[:, :401] + + def mean_of(*idx): + mask = np.isin(colors, idx) + return float(mosaic[mask].mean()) - 64.0 if mask.any() else 0.0 + + assert rgbscan._channel_means(mosaic, colors, 64.0) == (mean_of(0), mean_of(1, 3), mean_of(2)) + + +def test_build_proxy_keeps_its_contract(): + proxy, scale = stitch.build_proxy(_img(11, (2000, 3000, 3))) + assert proxy.dtype == np.uint8 and max(proxy.shape) == stitch.PROXY_MAX_EDGE and scale < 1.0 diff --git a/tests/test_core_contours.py b/tests/test_core_contours.py new file mode 100644 index 000000000..d2b631709 --- /dev/null +++ b/tests/test_core_contours.py @@ -0,0 +1,50 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(14) + + +def _mask(kind, shape): + if kind == "noise": + m = _RNG.random(shape) > 0.5 + elif kind == "blobs": + m = cv2.GaussianBlur(_RNG.random(shape).astype(np.float32), (0, 0), 3) > 0.5 + elif kind == "sparse": + m = _RNG.random(shape) > 0.97 + else: + m = np.zeros(shape, bool) + for _ in range(5): + y0, x0 = _RNG.integers(0, shape[0]), _RNG.integers(0, shape[1]) + m[y0 : y0 + _RNG.integers(1, 60), x0 : x0 + _RNG.integers(1, 60)] ^= True + return m.astype(np.uint8) * np.uint8(255) + + +@pytest.mark.parametrize("kind", ["noise", "blobs", "sparse", "rects"]) +@pytest.mark.parametrize("shape", [(1, 1), (1, 50), (50, 1), (3, 3), (120, 97), (199, 200)]) +def test_find_external_contours_matches_cv2(kind, shape): + img = _mask(kind, shape) + want, want_h = cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + got, got_h = negpy_core.find_external_contours(img) + assert len(got) == len(want) + for g, w in zip(got, want): + assert g.dtype == w.dtype and g.shape == w.shape and np.array_equal(g, w) + assert (want_h is None and got_h is None) or np.array_equal(got_h, want_h) + + +@cv2_oracle +def test_contour_outlines_match_cv2_list_contours_and_approx(): + rng = np.random.default_rng(11) + for _ in range(120): + h, w = (int(v) for v in rng.integers(5, 90, 2)) + base = cv2.GaussianBlur(rng.uniform(0, 1, (h, w)).astype(np.float32), (0, 0), float(rng.uniform(0.5, 4))) + zones = np.digitize(base, np.quantile(base, [0.25, 0.5, 0.75])).astype(np.uint8) + for z in range(4): + m = (zones == z).astype(np.uint8) + cs, _ = cv2.findContours(m, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + want = [[tuple(p) for [p] in cv2.approxPolyDP(c, 1.0, True)] for c in cs if cv2.contourArea(c) >= 4] + assert [[tuple(p) for p in c] for c in negpy_core.contour_outlines(m, 4.0, 1.0)] == want diff --git a/tests/test_core_filter.py b/tests/test_core_filter.py new file mode 100644 index 000000000..5b35f0ae0 --- /dev/null +++ b/tests/test_core_filter.py @@ -0,0 +1,78 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(11) +_SHAPES = [(41, 83, 1), (37, 53, 3), (57, 61, 4), (1, 301, 1), (300, 1, 1), (13, 9, 1)] +_BORDERS = [cv2.BORDER_REFLECT_101, cv2.BORDER_REPLICATE, cv2.BORDER_CONSTANT, cv2.BORDER_REFLECT] + + +def _image(h, w, cn): + return (_RNG.random((h, w, cn) if cn > 1 else (h, w), dtype=np.float32) - 0.3) * 3 + + +def _symmetric(n): + half = _RNG.random(n // 2 + 1).astype(np.float32) + return np.concatenate([half[:-1], half[-1:], half[:-1][::-1]]).astype(np.float32) + + +@pytest.mark.parametrize("n, sigma", [(1, 0), (3, 0), (5, 0), (7, 0), (9, 0), (11, 0), (15, 0), (17, 2.0), (13, 1.5), (33, 4.0), (5, 0.8)]) +def test_gaussian_kernel_matches_cv2(n, sigma): + want = cv2.getGaussianKernel(n, sigma, cv2.CV_32F).ravel() + assert np.array(negpy_core.gaussian_kernel(n, sigma), np.float32).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize("border", _BORDERS) +@pytest.mark.parametrize( + "ksize, sigma_x, sigma_y", + [((5, 5), 0, 0), ((7, 7), 0, 0), ((11, 1), 0, 0), ((0, 0), 2.0, 0), ((0, 0), 0.7, 0), ((1, 0), 0, 2.5), ((3, 3), 0, 0), ((9, 9), 0, 0)], +) +def test_gaussian_blur_matches_cv2_bit_for_bit(shape, border, ksize, sigma_x, sigma_y): + img = _image(*shape) + want = cv2.GaussianBlur(img, ksize, sigma_x, sigmaY=sigma_y, borderType=border) + got = negpy_core.gaussian_blur(img, ksize, sigma_x, sigma_y, border) + assert got.tobytes() == want.tobytes() + + +_KERNELS = { + "sym3": (_symmetric(3), _symmetric(3)), + "sym5": (_symmetric(5), _symmetric(5)), + "sym9x3": (_symmetric(9), _symmetric(3)), + "sym11": (_symmetric(11), _symmetric(11)), + "sobel_x": (np.array([-1, 0, 1], np.float32), np.array([1, 2, 1], np.float32)), + "sobel_y": (np.array([1, 2, 1], np.float32), np.array([-1, 0, 1], np.float32)), + "scaled_derivative": (np.array([-0.5, 0, 0.5], np.float32), np.array([0.25, 0.5, 0.25], np.float32)), + "second_derivative": (np.array([1, -2, 1], np.float32), np.array([1, -2, 1], np.float32)), + "row5_special": (np.array([1, 0, -2, 0, 1], np.float32), np.array([1], np.float32)), +} + + +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize("border", _BORDERS[:3]) +@pytest.mark.parametrize("name", sorted(_KERNELS)) +def test_sep_filter2d_matches_cv2_bit_for_bit(shape, border, name): + kx, ky = _KERNELS[name] + img = _image(*shape) + want = cv2.sepFilter2D(img, -1, kx, ky, borderType=border) + assert negpy_core.sep_filter2d(img, kx, ky, border).tobytes() == want.tobytes() + + +def test_sep_filter2d_rejects_a_kernel_it_does_not_cover(): + with pytest.raises(ValueError): + negpy_core.sep_filter2d(_image(9, 9, 1), np.array([0.2, 0.5, 0.3], np.float32), np.array([1], np.float32)) + + +@pytest.mark.parametrize("shape", _SHAPES + [(9, 700, 1)]) +@pytest.mark.parametrize("border", _BORDERS) +@pytest.mark.parametrize("ksize", [(1, 1), (3, 3), (5, 5), (2, 4), (4, 2), (7, 7), (3, 9), (9, 3), (15, 15), (6, 11), (31, 1), (1, 31)]) +@pytest.mark.parametrize("normalize", [True, False]) +def test_box_filter_matches_cv2_bit_for_bit(shape, border, ksize, normalize): + img = (_image(*shape) * _RNG.choice([1.0, 1e3])).astype(np.float32) + want = cv2.boxFilter(img, -1, ksize, normalize=normalize, borderType=border) + got = negpy_core.box_filter(img, ksize, normalize, border) + assert got.tobytes() == want.tobytes() diff --git a/tests/test_core_finish.py b/tests/test_core_finish.py new file mode 100644 index 000000000..77f85ca63 --- /dev/null +++ b/tests/test_core_finish.py @@ -0,0 +1,237 @@ +"""negpy_core.apply_carrier against the NumPy carrier it replaces, with a correctly rounded +cube root where NumPy's own depends on the CPU.""" + +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.finish.logic import CARRIER_SAMPLES, CARRIER_TONE_SAMPLES, apply_carrier, carrier_profiles, linear_carrier_tone + +pytestmark = cv2_oracle + +CARRIER_JITTER = 0.24 +CARRIER_INNER_ROUGH = 0.2 +CARRIER_OUTER_JITTER = 0.4 +CARRIER_CORNER = 1.4 +CARRIER_GATE_CORNER = 0.2 +# The film never sits centered in the carrier. +CARRIER_OFFSET_X = -0.2 +CARRIER_OFFSET_Y = -0.25 +CARRIER_MARGIN = 0.7 +# The gate prints soft; filed metal is a hard stop, which is what reads as filed. +CARRIER_SOFT = 0.22 +CARRIER_FILED_SOFT = 0.06 +CARRIER_FLARE_DEPTH = 0.25 +CARRIER_FLARE_GAIN = 0.6 +CARRIER_FLARE_BASE = 0.35 +# A 1-D profile is a height field, so it cannot overhang or shed a fleck. The 2-D field +# displacing the distance field is what makes the edge read as torn metal. Hash noise +# rather than a library, because WGSL has to reproduce it bit for bit. +CARRIER_NOISE_SEED = 0x51ED270B +CARRIER_NOISE_CELL = 0.35 +CARRIER_NOISE_OCTAVES = 4 +CARRIER_NOISE_OUTER = 0.12 +CARRIER_NOISE_INNER = 0.02 +# Tone table sampled at t = u**POWER, dense in the toe. +_CARRIER_BLOCK_PIXELS = 1 << 19 + + +def _hash_lattice(ix: np.ndarray, iy: np.ndarray) -> np.ndarray: + """Lattice value in [-1, 1). u32 wrap-around only, so WGSL reproduces it exactly.""" + h = (ix * np.uint32(0x27D4EB2D)) ^ (iy * np.uint32(0x165667B1)) ^ np.uint32(CARRIER_NOISE_SEED) + h ^= h >> np.uint32(15) + h = h * np.uint32(0x2C1B3C6D) + h ^= h >> np.uint32(13) + h = h * np.uint32(0x297A2D39) + h ^= h >> np.uint32(16) + return (h >> np.uint32(8)).astype(np.float32) * np.float32(2.0 / 16777216.0) - np.float32(1.0) + + +def _value_noise(x: np.ndarray, y: np.ndarray) -> np.ndarray: + fx, fy = np.floor(x), np.floor(y) + ux, uy = (x - fx), (y - fy) + ux = ux * ux * (3.0 - 2.0 * ux) + uy = uy * uy * (3.0 - 2.0 * uy) + i0, j0 = fx.astype(np.int64).astype(np.uint32), fy.astype(np.int64).astype(np.uint32) + i1, j1 = i0 + np.uint32(1), j0 + np.uint32(1) + lo = _hash_lattice(i0, j0) + lo = lo + (_hash_lattice(i1, j0) - lo) * ux + hi = _hash_lattice(i0, j1) + hi = hi + (_hash_lattice(i1, j1) - hi) * ux + return lo + (hi - lo) * uy + + +def carrier_noise(x: np.ndarray, y: np.ndarray) -> np.ndarray: + """fBm value noise in [-1, 1]; x/y in noise cells. Mirrored in finish.wgsl.""" + total = np.zeros(np.broadcast_shapes(x.shape, y.shape), dtype=np.float32) + amp, freq, norm = np.float32(1.0), np.float32(1.0), np.float32(0.0) + for _ in range(CARRIER_NOISE_OCTAVES): + total += amp * _value_noise(x * freq, y * freq) + norm += amp + amp *= np.float32(0.5) + freq *= np.float32(2.0) + return total / norm + + +def carrier_tone_lookup(tone, t): + u = np.cbrt(np.clip(t, 0.0, 1.0).astype(np.float64)).astype(np.float32) * np.float32(CARRIER_TONE_SAMPLES - 1) + grid = np.arange(CARRIER_TONE_SAMPLES, dtype=np.float32) + return np.stack([np.interp(u, grid, tone[:, c]).astype(np.float32) for c in range(3)], axis=-1) + + +def _carrier_reference( + img: np.ndarray, + width_px: float, + rough: float, + flare: float = 0.0, + corner: float = 0.0, + paper: tuple[float, float, float] = (1.0, 1.0, 1.0), + tone: np.ndarray | None = None, +) -> np.ndarray: + """ + Filed-out negative carrier: the rebate prints between the film gate and the filed + aperture, inside a margin of bare paper (scene-linear, matching the mat). The filed + edge's penumbra and the flare are exposure, read through tone (rebate_tone). + """ + if width_px <= 0.0: + return img + + h, w = img.shape[:2] + tone = linear_carrier_tone() if tone is None else np.asarray(tone, dtype=np.float32) + band = ( + int( + np.ceil( + width_px + * ( + CARRIER_MARGIN + + CARRIER_CORNER * corner + + 1.0 + + CARRIER_JITTER + + CARRIER_NOISE_OUTER + + CARRIER_NOISE_INNER + + max(abs(CARRIER_OFFSET_X), abs(CARRIER_OFFSET_Y)) + ) + + max(1.0, width_px * CARRIER_SOFT) + ) + ) + + 1 + ) + out = img.copy() + if 2 * band >= h or 2 * band >= w: + regions = [(0, h, 0, w, (0, 1, 2, 3))] + else: + # An edge's effect ends within `band`. + hb, wb = h - band, w - band + regions = [ + (0, band, 0, band, (0, 2)), + (0, band, wb, w, (0, 3)), + (hb, h, 0, band, (1, 2)), + (hb, h, wb, w, (1, 3)), + (0, band, band, wb, (0,)), + (hb, h, band, wb, (1,)), + (band, hb, 0, band, (2,)), + (band, hb, wb, w, (3,)), + ] + for y0, y1, x0, x1, sides in regions: + rows = max(1, _CARRIER_BLOCK_PIXELS // max(1, x1 - x0)) + for b0 in range(y0, y1, rows): + b1 = min(b0 + rows, y1) + block = img[b0:b1, x0:x1] + out[b0:b1, x0:x1] = _carrier_block(block, b0, x0, h, w, width_px, rough, flare, corner, paper, tone, sides) + return np.clip(out, 0.0, 1.0) + + +def _carrier_block( + img: np.ndarray, + y0: int, + x0: int, + h: int, + w: int, + width_px: float, + rough: float, + flare: float, + corner: float, + paper: tuple[float, float, float], + tone: np.ndarray, + sides: tuple[int, ...], +) -> np.ndarray: + profiles = carrier_profiles() + soft = max(1.0, width_px * CARRIER_SOFT) + soft_filed = max(1.0, width_px * CARRIER_FILED_SOFT) + margin = width_px * CARRIER_MARGIN + radius = width_px * CARRIER_CORNER * corner + gate_radius = width_px * CARRIER_GATE_CORNER + cell = max(1.0, width_px * CARRIER_NOISE_CELL) + py = np.arange(y0, y0 + img.shape[0], dtype=np.float32)[:, None] + px = np.arange(x0, x0 + img.shape[1], dtype=np.float32)[None, :] + sx = (px + 0.5) / np.float32(w) + sy = (py + 0.5) / np.float32(h) + end_x = np.minimum(px, (w - 1.0) - px) + end_y = np.minimum(py, (h - 1.0) - py) + qx = px - np.float32(width_px * CARRIER_OFFSET_X) + qy = py - np.float32(width_px * CARRIER_OFFSET_Y) + fend_x = np.minimum(qx, (w - 1.0) - qx) + fend_y = np.minimum(qy, (h - 1.0) - qy) + n2 = carrier_noise(px / cell, py / cell) + + def prof(row: int, s: np.ndarray) -> np.ndarray: + return profiles[row, np.minimum((s * CARRIER_SAMPLES).astype(np.int32), CARRIER_SAMPLES - 1)] + + def arc(r: float, at: float, end: np.ndarray) -> np.ndarray | np.float32: + """Corner retreat of a boundary `at` px from the print edge, from its own corner.""" + if r <= 0.0: + return np.float32(0.0) + x = np.clip(r - (end - at), 0.0, r) + return r - np.sqrt(np.maximum(r * r - x * x, 0.0)) + + def bounds(edge: int, s: np.ndarray, end: np.ndarray, fend: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + outer = margin + width_px * rough * (CARRIER_OUTER_JITTER * prof(edge + 4, s) + CARRIER_NOISE_OUTER * n2) + wobble = CARRIER_JITTER * CARRIER_INNER_ROUGH * prof(edge, s) + CARRIER_NOISE_INNER * n2 + inner = margin + width_px * (1.0 + wobble) + arc(gate_radius, margin + width_px, end) + return outer + arc(radius, margin, fend), inner + + # (edge, profile position, gate end, filed end, gate distance, filed distance) + edges = ( + (0, sx, end_x, fend_x, py, qy), + (1, sx, end_x, fend_x, (h - 1.0) - py, (h - 1.0) - qy), + (2, sy, end_y, fend_y, px, qx), + (3, sy, end_y, fend_y, (w - 1.0) - px, (w - 1.0) - qx), + ) + reach = max(1.0, width_px * CARRIER_FLARE_DEPTH) + # Floored |noise|, not a one-sided gate: that left whole edges with no flare. + flare_amp = flare * CARRIER_FLARE_GAIN * (CARRIER_FLARE_BASE + (1.0 - CARRIER_FLARE_BASE) * np.abs(n2)) + a_in = np.ones(img.shape[:2], dtype=np.float32) + a_out = np.ones(img.shape[:2], dtype=np.float32) + peaks, gates = [], [] + for e, s, end, fend, d, fd in (edges[k] for k in sides): + outer, inner = bounds(e, s, end, fend) + a_in = a_in * np.clip((d - inner) / soft + 0.5, 0.0, 1.0) + a_out = a_out * np.clip((fd - outer) / soft_filed + 0.5, 0.0, 1.0) + peaks.append(np.clip(1.0 - np.abs(fd - outer) / reach, 0.0, 1.0) ** 2) + gates.append(np.clip(1.0 + (fd - outer) / reach, 0.0, 1.0)) + lit = np.float32(0.0) + if flare > 0.0: + # The bevel spans only the aperture, so the other edges gate each edge's flare. + for e in range(len(peaks)): + lit = lit + peaks[e] * np.prod([gates[k] for k in range(len(gates)) if k != e] or [np.float32(1.0)], axis=0) + lit = flare_amp * lit + + rebate = np.asarray(paper, dtype=np.float32) * carrier_tone_lookup(tone, a_out + lit) + shown = (a_in * a_out)[..., None] + return img * shown + rebate * (1.0 - shown) + + +_RNG = np.random.default_rng(29) +_TONES = [ + linear_carrier_tone(), + np.ascontiguousarray(np.clip(linear_carrier_tone() * _RNG.uniform(0.8, 1.1, (64, 3)).astype(np.float32), 0, 1.2)), +] + + +@pytest.mark.parametrize("shape", [(300, 400), (120, 90), (40, 30)]) +@pytest.mark.parametrize("width, rough, flare, corner, tone", [(12.0, 0.5, 0.0, 0.0, 0), (20.0, 1.0, 0.7, 0.5, 1), (7.3, 0.2, 1.0, 1.0, 1)]) +def test_carrier_matches_numpy(shape, width, rough, flare, corner, tone): + img = _RNG.random((*shape, 3), dtype=np.float32) * np.float32(1.2) + paper = (0.98, 0.97, 0.95) + want = _carrier_reference(img, width, rough, flare, corner, paper, _TONES[tone]) + assert apply_carrier(img, width, rough, flare, corner, paper, _TONES[tone]).tobytes() == want.tobytes() diff --git a/tests/test_core_flatfield.py b/tests/test_core_flatfield.py new file mode 100644 index 000000000..0c49d129b --- /dev/null +++ b/tests/test_core_flatfield.py @@ -0,0 +1,81 @@ +"""The flat-field reference checks against the NumPy and cv2 code they replace.""" + +import cv2 +import numpy as np +from core_oracle import cv2_oracle + +from negpy.features.flatfield import logic as F + +pytestmark = cv2_oracle + + +def _reference(shape=(170, 256), seed=0): + rng = np.random.default_rng(seed) + h, w = shape + y, x = np.mgrid[0:h, 0:w].astype(np.float32) + r2 = ((x - w / 2) / w) ** 2 + ((y - h / 2) / h) ** 2 + ref = (0.8 * (1.0 - 0.9 * r2))[..., None] * np.array([1.0, 0.95, 0.9], dtype=np.float32) + ref = ref + 0.01 * rng.standard_normal(ref.shape) + ref[:, : w // 20] *= 0.1 + ref[5:8, 100:104] = 0.99 # clipped + return ref.astype(np.float32) + + +def _lit_numpy(ref): + lum = ref.mean(axis=2) + lit = (lum > F._LIT_FRACTION * np.percentile(lum, F._LIT_PERCENTILE)).astype(np.uint8) + lit = cv2.erode(lit, np.ones((5, 5), np.uint8), borderType=cv2.BORDER_CONSTANT, borderValue=0) + if lit.sum() < F._MIN_LIT_FRACTION * lit.size: + return np.ones(lum.shape, np.float32) + return lit.astype(np.float32) + + +def _fill_numpy(values, valid): + if valid.all() or not valid.any(): + return values + _, labels = cv2.distanceTransformWithLabels((~valid).astype(np.uint8), cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL) + source = np.zeros(labels.max() + 1, np.int64) + source[labels[valid]] = np.flatnonzero(valid) + return values.reshape(-1, values.shape[2])[source[labels]].reshape(values.shape) + + +def test_lit_mask_matches_numpy(): + ref = _reference() + assert F._lit_mask(ref).tobytes() == _lit_numpy(ref).tobytes() + dark = np.zeros((40, 40, 3), dtype=np.float32) + assert F._lit_mask(dark).tobytes() == np.ones((40, 40), np.float32).tobytes() + + +def test_evenness_matches_numpy(): + img = _reference(seed=1) + lit = _lit_numpy(img).astype(bool) + median = np.clip(np.median(img[lit], axis=0), 1e-6, None) + ratio = img / median + ratio[~lit] = 0.0 + lum = ratio[lit].mean(axis=1) + low, high = np.percentile(lum - 1.0, [1.0, 99.0]) + chroma = np.percentile(ratio[lit] / np.clip(lum, 1e-6, None)[:, None] - 1.0, [1.0, 99.0], axis=0) + clipped = float((img[lit] >= F._CLIP_LEVEL).any(axis=1).mean()) > F._CLIP_FRACTION + got_ratio, got = F._evenness(img) + assert got_ratio.tobytes() == ratio.tobytes() + assert got == F.Evenness(float(low), float(high), float(np.abs(chroma).max()), clipped) + + +def test_compute_gain_matches_numpy(): + ref = F.work_copy(_reference((400, 600), seed=2)) + sigma = max(ref.shape[:2]) / F._BLUR_DIVISOR + lit = _lit_numpy(ref) + num = cv2.GaussianBlur(ref * lit[..., None], (0, 0), sigmaX=sigma, sigmaY=sigma) + den = cv2.GaussianBlur(lit, (0, 0), sigmaX=sigma, sigmaY=sigma) + blur = np.clip(num / np.clip(den, 1e-4, None)[..., None], 1e-4, None) + blur = _fill_numpy(blur, den >= F._MIN_LIT_WEIGHT) + means = (blur * lit[..., None]).sum(axis=(0, 1)) / lit.sum() + want = np.clip(means[None, None, :] / blur, F._GAIN_MIN, F._GAIN_MAX).astype(np.float32) + assert F.compute_gain(_reference((400, 600), seed=2)).tobytes() == want.tobytes() + + +def test_fill_from_nearest_matches_cv2(): + rng = np.random.default_rng(3) + values = rng.random((50, 70, 3), dtype=np.float32) + valid = rng.random((50, 70)) > 0.6 + assert F._fill_from_nearest(values, valid).tobytes() == _fill_numpy(values, valid).tobytes() diff --git a/tests/test_core_gray.py b/tests/test_core_gray.py new file mode 100644 index 000000000..60e99870c --- /dev/null +++ b/tests/test_core_gray.py @@ -0,0 +1,81 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(13) +_SHAPES = [(31, 281), (15, 243), (6, 297), (1, 77), (40, 1), (2, 2)] + + +@pytest.fixture +def one_cv2_thread(): + # cv2's parallel uint8 5x5 GaussianBlur can return different bytes on repeated calls; + # on one thread it is deterministic and negpy_core matches it. + before = cv2.getNumThreads() + cv2.setNumThreads(1) + yield + cv2.setNumThreads(before) + + +def _gray(shape): + return cv2.GaussianBlur(_RNG.integers(0, 256, shape).astype(np.uint8), (0, 0), 1.5) + + +@pytest.mark.parametrize("shape", _SHAPES) +def test_bgr_to_gray_matches_cv2(shape): + img = _RNG.integers(0, 256, (*shape, 3)).astype(np.uint8) + assert np.array_equal(negpy_core.bgr_to_gray(img), cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)) + + +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize( + "ksize, sigma, border", + [ + ((5, 5), 0, 4), + ((7, 7), 0, 4), + ((3, 3), 0, 4), + ((0, 0), 1.5, 4), + ((0, 0), 2.5, 1), + ((9, 1), 0, 4), + ((0, 0), 1.0, 0), + ((11, 11), 0, 2), + ], +) +def test_gaussian_blur_u8_matches_cv2(one_cv2_thread, shape, ksize, sigma, border): + img = _gray(shape) + want = cv2.GaussianBlur(img, ksize, sigma, borderType=border) + assert np.array_equal(negpy_core.gaussian_blur_u8(img, ksize, sigma, 0.0, border), want) + + +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize("invert", [False, True]) +def test_threshold_otsu_matches_cv2(shape, invert): + img = _gray(shape) + flag = (cv2.THRESH_BINARY_INV if invert else cv2.THRESH_BINARY) + cv2.THRESH_OTSU + want_t, want = cv2.threshold(img, 0, 255, flag) + got_t, got = negpy_core.threshold_otsu(img, 255, invert) + assert got_t == want_t + assert np.array_equal(got, want) + + +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize("low, high", [(40, 160), (10, 30), (100, 50)]) +def test_canny_matches_cv2(shape, low, high): + img = _gray(shape) + assert np.array_equal(negpy_core.canny(img, low, high), cv2.Canny(img, low, high)) + + +@pytest.mark.parametrize("shape", [(1, 1), (1, 30), (30, 1), (5, 5), (37, 53), (130, 140)]) +@pytest.mark.parametrize("ksize", [1, 3, 5, 7, 9, 25, 33]) +@pytest.mark.parametrize("kind", ["noise", "smooth", "levels"]) +def test_median_blur_matches_cv2(shape, ksize, kind): + rng = np.random.default_rng(ksize * 7 + shape[0]) + img = rng.integers(0, 256, shape, dtype=np.uint8) + if kind == "smooth": + img = cv2.GaussianBlur(img, (5, 5), 0) + elif kind == "levels": + img = (img // 64 * 64).astype(np.uint8) + assert negpy_core.median_blur(img, ksize).tobytes() == cv2.medianBlur(img, ksize).tobytes() diff --git a/tests/test_core_inpaint.py b/tests/test_core_inpaint.py new file mode 100644 index 000000000..cfffdc58f --- /dev/null +++ b/tests/test_core_inpaint.py @@ -0,0 +1,156 @@ +import math +import pickle +from pathlib import Path + +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.retouch import logic +from negpy.features.retouch.logic import apply_hair_inpaint + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(23) +_CORPUS = Path("~/negpy-corpus/features__retouch__logic.py--_hair_encode").expanduser() + + +def _corpus_crops(): + """Real scan crops from the recorded hair encodes; empty when the corpus is absent.""" + return [pickle.loads(p.read_bytes())["args"][:3] for p in sorted(_CORPUS.glob("*.pkl"))] + + +def _textured(h, w): + img = cv2.GaussianBlur(_RNG.random((h, w, 3), dtype=np.float32), (0, 0), 2.5) + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + img += (0.3 * np.sin(xx / 9.0) * np.cos(yy / 13.0))[..., None] + return np.ascontiguousarray((img - img.min()) / (img.max() - img.min()), dtype=np.float32) + + +def _encode(crop, lo, inv_span): + enc = np.empty(crop.shape, np.uint8) + negpy_core.hair_encode(np.ascontiguousarray(crop), np.float32(lo), np.float32(inv_span), np.float32(1 / 2.2), enc) + return enc + + +def _strokes(h, w, n, width): + m = np.zeros((h, w), np.uint8) + for _ in range(n): + pts = np.cumsum(_RNG.normal(0, max(2.0, min(h, w) / 12), (40, 2)), 0) + (_RNG.random(2) * (w, h)) + cv2.polylines(m, [pts.astype(np.int32)], False, 1, width) + return m + + +def _masks(h, w): + return [ + (_RNG.random((h, w)) < 0.15).astype(np.uint8), + _strokes(h, w, 3, 1), + _strokes(h, w, 2, 4), + cv2.dilate(_strokes(h, w, 1, 2), np.ones((3, 3), np.uint8)), + ] + + +def _gate(want, got, mask): + d = np.abs(want.astype(np.int16) - got.astype(np.int16)) + assert np.array_equal(want[mask == 0], got[mask == 0]) + if mask.any(): + inside = d[mask > 0] + assert inside.mean() <= 1.0 and inside.max() <= 4, (inside.mean(), inside.max()) + + +def _tiles(): + tiles = [_encode(crop, lo, inv) for crop, lo, inv in _corpus_crops()] + tiles += [_encode(_textured(h, w), 0.0, 1.0) for h, w in ((64, 80), (2, 2), (5, 300), (257, 190))] + return tiles + + +@pytest.mark.parametrize("radius", [1, 3, 7]) +def test_inpaint_ns_matches_cv2(radius): + # A one-pixel-wide image is left out: OpenCV reads past the image there. + for enc in _tiles(): + h, w = enc.shape[:2] + for mask in _masks(h, w): + want = cv2.inpaint(enc, mask, radius, cv2.INPAINT_NS) + _gate(want, negpy_core.inpaint_ns(enc, mask, float(radius)), mask) + + +def test_inpaint_ns_without_mask_is_identity(): + enc = _encode(_textured(30, 40), 0.0, 1.0) + assert np.array_equal(negpy_core.inpaint_ns(enc, np.zeros((30, 40), np.uint8), 3.0), enc) + + +def _hair_inpaint_cv2(img, hair_masks, radius=logic._HAIR_INPAINT_RADIUS, dilate_px=None): + """`apply_hair_inpaint` as it ran on cv2.inpaint.""" + h, w = img.shape[:2] + masks = [hm for hm in hair_masks if hm is not None] + factor = max(1.0, h / masks[0].shape[0], w / masks[0].shape[1]) + if dilate_px is None: + dilate_px = max(logic._HAIR_DILATE_PX, round(factor)) + m = np.zeros((h, w), dtype=np.uint8) + for hm in masks: + r = hm if hm.shape[:2] == (h, w) else negpy_core.resize_linear(np.ascontiguousarray(hm, dtype=np.float32), (w, h)) + m |= (np.asarray(r) > 0.5).astype(np.uint8) + if dilate_px > 0: + m = cv2.dilate(m, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate_px + 1, 2 * dilate_px + 1))) + src = np.ascontiguousarray(img, dtype=np.float32) + out = src.copy() + pad, tile_px, halo = logic._HAIR_INPAINT_PAD, logic._HAIR_TILE_PX, logic._HAIR_TILE_HALO + n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8) + for i in range(1, n_lbl): + bx, by, bw, bh = (int(v) for v in stats[i, :4]) + x0, y0 = max(0, bx - pad), max(0, by - pad) + x1, y1 = min(w, bx + bw + pad), min(h, by + bh + pad) + sub_m = np.ascontiguousarray(m[y0:y1, x0:x1]) + crop = np.ascontiguousarray(src[y0:y1, x0:x1]) + step = max(1, int(math.isqrt(max(1, crop.shape[0] * crop.shape[1] // logic._HAIR_RANGE_SAMPLE_PX)))) + ctx = crop[::step, ::step][sub_m[::step, ::step] == 0] + lo = float(np.percentile(ctx, 0.5)) if ctx.size else 0.0 + hi = float(np.percentile(ctx, 99.5)) if ctx.size else 1.0 + span = max(hi - lo, 1e-4) + dec_lut = ((np.arange(256, dtype=np.float32) / 255.0) ** logic._HAIR_INPAINT_GAMMA) * span + lo + ch, cw = crop.shape[:2] + for ty0 in range(0, ch, tile_px): + for tx0 in range(0, cw, tile_px): + ty1, tx1 = min(ch, ty0 + tile_px), min(cw, tx0 + tile_px) + if not (labels[y0 + ty0 : y0 + ty1, x0 + tx0 : x0 + tx1] == i).any(): + continue + ay0, ay1 = max(0, ty0 - halo), min(ch, ty1 + halo) + ax0, ax1 = max(0, tx0 - halo), min(cw, tx1 + halo) + tile = np.ascontiguousarray(crop[ay0:ay1, ax0:ax1]) + enc = _encode(tile, lo, 1.0 / span) + filled = cv2.inpaint(enc, np.ascontiguousarray(sub_m[ay0:ay1, ax0:ax1]), radius, cv2.INPAINT_NS) + mb = labels[y0 + ay0 : y0 + ay1, x0 + ax0 : x0 + ax1] == i + d = cv2.distanceTransform(mb.astype(np.uint8), cv2.DIST_C, 3) + core = np.zeros(mb.shape, dtype=bool) + core[ty0 - ay0 : ty1 - ay0, tx0 - ax0 : tx1 - ax0] = True + sel = np.flatnonzero(mb & core) + a = np.minimum(d.ravel()[sel] / float(dilate_px + 1), 1.0)[:, None] + dec = dec_lut[filled.reshape(-1, 3)[sel]] + tile_px_v = tile.reshape(-1, 3)[sel] + ry, rx = np.divmod(sel, ax1 - ax0) + out.reshape(-1, 3)[(y0 + ay0 + ry) * w + (x0 + ax0 + rx)] = tile_px_v * (1.0 - a) + dec * a + negpy_core.add_clean_grain(out, src, m == 0, max(1.0, factor)) + return out, m + + +def _frames(): + frames = [crop for crop, _, _ in _corpus_crops() if min(crop.shape[:2]) >= 8] + return frames + [_textured(220, 340), _textured(90, 2100)] + + +def test_hair_inpaint_matches_cv2_version(monkeypatch): + # A small tile makes the larger frames run the tiled path with its halo. + monkeypatch.setattr(logic, "_HAIR_TILE_PX", 96) + worst = 0.0 + for img in _frames(): + h, w = img.shape[:2] + for hair in (_strokes(h, w, 2, 1), _strokes(h // 2, w // 2, 3, 2)): + want, m = _hair_inpaint_cv2(img, [hair]) + got = np.asarray(apply_hair_inpaint(img, [hair])) + assert np.array_equal(want[m == 0], got[m == 0]) + # One 8-bit step of the encode, on the crop's range. + span = max(float(img.max() - img.min()), 1e-4) + worst = max(worst, float(np.abs(want - got).max()) / span) + assert worst <= 4 / 255, worst diff --git a/tests/test_core_ir.py b/tests/test_core_ir.py new file mode 100644 index 000000000..af7e516b6 --- /dev/null +++ b/tests/test_core_ir.py @@ -0,0 +1,198 @@ +"""The IR dust kernels against the cv2 and NumPy code they replace.""" + +from pathlib import Path + +import cv2 +import numpy as np +from core_oracle import cv2_oracle + +import negpy_core +from negpy.features.retouch import logic as L + +pytestmark = cv2_oracle + + +def _scene(seed=0, shape=(120, 90)): + rng = np.random.default_rng(seed) + plane = (0.8 + 0.05 * rng.standard_normal(shape)).astype(np.float32) + plane[30:34, 20:60] = 0.3 # a hair + plane[:, :6] = 0.01 # the holder margin + plane[70:72, 40:42] = 2.0 # glare + img = rng.random(shape + (3,), dtype=np.float32) + return plane, img + + +def test_ir_live_matches_components(): + plane, _ = _scene() + dead = plane < L._IR_DEAD_FLOOR + n, labels, stats, _ = cv2.connectedComponentsWithStats(dead.astype(np.uint8), connectivity=8) + holder = np.zeros(n, dtype=bool) + holder[1:] = stats[1:, cv2.CC_STAT_AREA] >= L._IR_DEAD_MIN_AREA * dead.size + np.testing.assert_array_equal(L._ir_live(plane), ~holder[labels]) + + +def test_ir_glare_matches_median_rule(): + plane, _ = _scene(1) + live = L._ir_live(plane) + sample = plane.ravel()[L._fit_sample(live)] + med = float(np.median(sample)) + sigma = 1.4826 * float(np.median(np.abs(sample - med))) + np.testing.assert_array_equal(L._ir_glare(plane, live), plane > med + L._IR_GLARE_SIGMA * sigma) + + +def test_normalize_ir_matches_cv2(): + plane, _ = _scene(2) + glare = plane > 1.5 + win = L._ir_win(L._IR_BASE_WIN, L._ir_detect_scale(plane)) + src = np.where(glare, np.float32(0.0), plane) + base = cv2.blur(cv2.dilate(src, cv2.getStructuringElement(cv2.MORPH_RECT, (win, win))), (win, win)) + want = plane / np.maximum(base, 1e-4) + assert L.normalize_ir(plane, glare).tobytes() == want.tobytes() + + +def test_ir_clean_base_matches_cv2(): + plane, img = _scene(3) + ratio = L.normalize_ir(plane) + win = (L._ir_win(L._IR_CAP_WIN, L._ir_detect_scale(ratio)),) * 2 + w_clean = (ratio >= L._IR_GAIN_IDENTITY).astype(np.float32) + den = np.maximum(cv2.blur(w_clean, win), 1e-6)[..., None] + mean = cv2.blur(img * w_clean[..., None], win) / den + var = cv2.blur(img * img * w_clean[..., None], win) / den - mean * mean + base = mean - L._IR_CAP_SIGMA * np.sqrt(np.clip(var, 0.0, None)) + dil = cv2.blur(cv2.dilate(img, cv2.getStructuringElement(cv2.MORPH_RECT, win)), win) + want = np.where(den > L._IR_CAP_MIN_SUPPORT, base, dil) + assert L._ir_clean_base(img, ratio).tobytes() == want.tobytes() + + +def test_ir_normalize_ratio_matches_numpy(): + plane, _ = _scene(4) + live = L._ir_live(plane) + ratio = L.normalize_ir(plane) + sample = ratio[live] + med = float(np.median(sample)) + sigma = 1.4826 * float(np.median(np.abs(sample - med))) + pivot = float(np.clip(med - L._IR_NOISE_SIGMA * sigma, L._IR_PIVOT_LO, L._IR_GAIN_IDENTITY)) + scale = L._IR_GAIN_IDENTITY / pivot + want = (ratio * scale).astype(np.float32) + k = float(np.clip(L._IR_REF_SIGMA / max(sigma * scale, 1e-6), 1.0, L._IR_SCALE_MAX)) + if k > 1.0: + stretched = L._IR_GAIN_IDENTITY - (L._IR_GAIN_IDENTITY - want) * k + want = np.where(want < L._IR_GAIN_IDENTITY, stretched, want).astype(np.float32) + assert L._ir_normalize_ratio(ratio, live).tobytes() == want.tobytes() + + +def test_ghost_divide_matches_numpy_float32_exp(): + rng = np.random.default_rng(5) + ratio = rng.random((64, 48), dtype=np.float32) + vis_log = -3.0 * rng.random((64, 48, 3), dtype=np.float32) + b = np.array([0.31, 0.07, 0.52], dtype=np.float32) + want = np.clip(ratio / np.exp((vis_log * b).sum(-1)), 0.0, 1.5).astype(np.float32) + assert negpy_core.ir_ghost_divide(ratio, vis_log, b.tolist()).tobytes() == want.tobytes() + + +def test_every_binding_enters_a_pool_through_detach(): + """A thread that ran small work stays in its one-thread pool, so a binding that called + py.detach directly would run its large work on one core.""" + src = Path(__file__).parents[1] / "negpy_core" / "src" / "py" + offenders = [p.name for p in src.glob("*.rs") if p.name != "mod.rs" and ".detach(" in p.read_text()] + assert offenders == [] + + +def _route_cv2(score): + at_floor = (score <= L._IR_SCORE_FLOOR + 1e-6).astype(np.uint8) + n, labels, stats, _ = cv2.connectedComponentsWithStats(at_floor, connectivity=8) + scale = L._ir_detect_scale(score) + radius = int(round(L._IR_ROUTE_RADIUS * scale)) + routed = np.zeros_like(at_floor) + for i in range(1, n): + bw, bh = int(stats[i, cv2.CC_STAT_WIDTH]), int(stats[i, cv2.CC_STAT_HEIGHT]) + if min(bw, bh) < 2 * radius - 1: + continue + x0, y0 = int(stats[i, cv2.CC_STAT_LEFT]), int(stats[i, cv2.CC_STAT_TOP]) + own = labels[y0 : y0 + bh, x0 : x0 + bw] == i + if float(cv2.distanceTransform(np.pad(own.astype(np.uint8), 1), cv2.DIST_C, 3).max()) >= radius: + routed[y0 : y0 + bh, x0 : x0 + bw][own] = 1 + if not routed.any(): + return None + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(round(L._IR_ROUTE_DILATE * scale)) + 1,) * 2) + return cv2.dilate(routed, k) + + +def test_route_wide_defects_matches_cv2(): + score = np.ones((200, 160), dtype=np.float32) + score[20:40, 30:60] = L._IR_SCORE_FLOOR # wide: routed + score[100:103, 10:150] = L._IR_SCORE_FLOOR # a thin line: too narrow + score[150:170, 100:104] = L._IR_SCORE_FLOOR + want = _route_cv2(score) + assert want is not None + np.testing.assert_array_equal(L.route_wide_defects(score, budget=None), want) + assert L.route_wide_defects(np.ones((50, 50), dtype=np.float32)) is None + + +def test_ir_defect_score_matches_cv2(): + plane, _ = _scene(6) + ratio = L.normalize_ir(plane) + cutoff = 0.6 + span = max(L._IR_GAIN_IDENTITY - cutoff, 1e-4) + t = (ratio - cutoff) / span + score = np.clip(t * (1.0 - L._IR_SCORE_FLOOR) + L._IR_SCORE_FLOOR, L._IR_SCORE_FLOOR, 1.0) + want = cv2.erode(score, np.ones((3, 3), np.uint8)) + assert L.ir_defect_score(ratio, cutoff).tobytes() == want.tobytes() + + +def test_fit_refraction_gammas_matches_numpy(): + rng = np.random.default_rng(7) + ratio = rng.uniform(0.6, 1.0, (160, 120)).astype(np.float32) + img = rng.random((160, 120, 3), dtype=np.float32) + vis_log = (np.log(ratio)[..., None] * np.float32([1.1, 1.3, 1.6])).astype(np.float32) + vis_log += 0.01 * rng.standard_normal(vis_log.shape).astype(np.float32) + band = (ratio > 0.70) & (ratio < 0.92) + cv2.ipp.setUseIPP(False) + try: + edge = np.abs(cv2.Laplacian(img[:, :, 1], cv2.CV_32F, ksize=5)) + finally: + cv2.ipp.setUseIPP(True) + flat = band & (edge < np.percentile(edge[band], L._IR_FIT_FLAT_PCT)) + fit = L._fit_sample(flat if int(flat.sum()) >= L._IR_FIT_MIN_PX else band) + xb = np.log(ratio.ravel()[fit]) + vl = vis_log.reshape(-1, 3)[fit] + want = tuple(float(np.clip(np.median(vl[:, c] / xb), L._IR_GAMMA_LO, L._IR_GAMMA_HI)) for c in range(3)) + assert L._fit_refraction_gammas(ratio, vis_log, img) == want + + +def _dust_stats_cv2(img, dust_size): + dens = L._density(img) + lo, hi = np.percentile(dens, (0.5, 99.5)) + lo, spread = float(lo), max(float(hi - lo), L._PROXY_MIN_SPREAD) + proxy = np.clip((dens - lo) / spread, 0.0, 1.0).astype(np.float32) + base_size = max(1.0, float(dust_size)) * L.film_scale(proxy.shape) + v_win = int(max(3, base_size * 3.0)) * 2 + 1 + w_win = L._texture_win(proxy.shape, dust_size) + disk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(round(base_size)) + 1,) * 2) + background = np.maximum(cv2.medianBlur(L._u8(proxy), v_win).astype(np.float32) / 255.0, cv2.morphologyEx(proxy, cv2.MORPH_OPEN, disk)) + excess = cv2.blur(proxy - background, (L._DETECT_AVG_PX, L._DETECT_AVG_PX)) + mad = cv2.medianBlur(L._u8(np.abs(excess) * L._DETECT_MAD_GAIN), w_win).astype(np.float32) / (255.0 * L._DETECT_MAD_GAIN) + z = excess / np.maximum(mad / 0.6745, L._DETECT_SIGMA_MIN) + mean = cv2.blur(proxy, (w_win, w_win)) + texture = np.sqrt(np.clip(cv2.blur(proxy * proxy, (w_win, w_win)) - mean * mean, 0.0, None)) + return proxy, background, z, texture + + +def test_compute_dust_stats_matches_cv2(): + rng = np.random.default_rng(8) + img = (0.3 + 0.2 * rng.random((140, 180, 3))).astype(np.float32) + img[40:43, 50:53] *= 0.2 # a speck + for got, want in zip(L.compute_dust_stats(img, 2), _dust_stats_cv2(img, 2)): + assert got.tobytes() == want.tobytes() + + +def test_split_hairs_matches_components(): + mask = np.zeros((80, 80), dtype=np.uint8) + mask[10:12, 5:70] = 1 # a hair + mask[40:45, 40:45] = 1 # a speck + compact, hairs = L.split_hairs(mask) + want_hair = np.zeros_like(mask) + want_hair[10:12, 5:70] = 1 + np.testing.assert_array_equal(hairs, want_hair) + np.testing.assert_array_equal(compact, mask - want_hair) + assert L.split_hairs(mask - want_hair)[1] is None diff --git a/tests/test_core_jpeg.py b/tests/test_core_jpeg.py new file mode 100644 index 000000000..3056b7e32 --- /dev/null +++ b/tests/test_core_jpeg.py @@ -0,0 +1,83 @@ +"""negpy_core's baseline JPEG against PIL's: the same bytes without restart markers, the same +decoded pixels with them, and the same pixels through the export encoder.""" + +import io + +import numpy as np +import pytest +from PIL import Image, ImageFile + +import negpy_core +from negpy.features.metadata.resolution import Resolution +from negpy.services.export.encoders import encode_jpeg + + +def _pil(arr, quality=90, icc=None, dpi=None): + kwargs = {"format": "JPEG", "quality": quality, "subsampling": 0, "optimize": True} + if icc: + kwargs["icc_profile"] = icc + if dpi: + kwargs["dpi"] = dpi + buf = io.BytesIO() + prev = ImageFile.MAXBLOCK + ImageFile.MAXBLOCK = max(prev, 3 * arr.shape[0] * arr.shape[1]) + try: + Image.fromarray(arr).save(buf, **kwargs) + finally: + ImageFile.MAXBLOCK = prev + return buf.getvalue() + + +def _pixels(data): + return np.asarray(Image.open(io.BytesIO(data))) + + +def _photo(h, w): + """Smooth gradients with edges and grain, so every coefficient band carries signal.""" + y, x = np.mgrid[0:h, 0:w].astype(np.float32) + rng = np.random.default_rng(3) + base = np.stack([128 + 90 * np.sin(x / 23 + c) * np.cos(y / 17 - c) for c in range(3)], axis=-1) + base += (x[..., None] > w / 2) * 40 + rng.normal(0, 6, (h, w, 3)) + return np.clip(base, 0, 255).astype(np.uint8) + + +CASES = { + "photo": _photo(203, 317), + "noise": (np.random.default_rng(0).random((37, 53, 3)) * 255).astype(np.uint8), + "gray": _photo(121, 89)[..., 1].copy(), + "flat": np.full((16, 24, 3), 77, np.uint8), +} + + +@pytest.mark.parametrize("name", CASES) +@pytest.mark.parametrize("quality", [10, 75, 90, 100]) +def test_without_restarts_the_bytes_are_pils(name, quality): + arr = CASES[name] + assert negpy_core.encode_jpeg(arr, quality, None, None, 0) == _pil(arr, quality) + + +def test_icc_chunks_and_density_are_written_as_pil_writes_them(): + arr = CASES["photo"] + icc = bytes(range(256)) * 300 + assert negpy_core.encode_jpeg(arr, 90, icc, (300, 240), 0) == _pil(arr, icc=icc, dpi=(300, 240)) + + +@pytest.mark.parametrize("name", CASES) +@pytest.mark.parametrize("rows", [1, 3, None]) +def test_restart_markers_keep_every_pixel(name, rows): + arr = CASES[name] + np.testing.assert_array_equal(_pixels(negpy_core.encode_jpeg(arr, 90, None, None, rows)), _pixels(_pil(arr))) + + +def test_the_export_encoder_keeps_pils_pixels_and_density(): + arr = CASES["photo"] + data = encode_jpeg(arr, icc=b"\x00" * 128, resolution=Resolution.from_dpi(300), quality=88) + np.testing.assert_array_equal(_pixels(data), _pixels(_pil(arr, 88))) + img = Image.open(io.BytesIO(data)) + assert img.info["dpi"] == (300, 300) + assert img.info["icc_profile"] == b"\x00" * 128 + + +def test_a_progressive_export_stays_on_pil(): + arr = CASES["photo"] + assert Image.open(io.BytesIO(encode_jpeg(arr, progressive=True))).info.get("progressive") diff --git a/tests/test_core_lab.py b/tests/test_core_lab.py new file mode 100644 index 000000000..ee98da2b0 --- /dev/null +++ b/tests/test_core_lab.py @@ -0,0 +1,204 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.lab.logic import _smoothstep, gaussian_kernel_1d, sharpen_shadow_gain +from negpy.features.lab.logic import LUM_B, LUM_G, LUM_R, RL_EPS, apply_clahe, apply_glow_and_halation, apply_rl_sharpening, rl_iterations +from negpy.features.toning.logic import apply_split_toning +from negpy.kernel.image.logic import WORKING_LAB, lab_to_rgb_working, rgb_to_lab_working, working_oetf_encode + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(17) + + +def _usm_reference(img, amount, radius, masking): + """The NumPy and OpenCV sharpen block that negpy_core.usm_sharpen replaces.""" + lab = rgb_to_lab_working(img) + l_chan, a, b = cv2.split(lab) + k = gaussian_kernel_1d(radius) + diff = l_chan - cv2.sepFilter2D(l_chan, -1, k, k, borderType=cv2.BORDER_REFLECT_101) + gain = np.float32(amount * 2.5) * _smoothstep(0.25, 0.33, np.abs(diff)) * sharpen_shadow_gain(l_chan) + if masking > 0.0: + lp = np.pad(l_chan, 1, mode="edge") + gx = (lp[1:-1, 2:] - lp[1:-1, :-2]) * np.float32(0.5) + gy = (lp[2:, 1:-1] - lp[:-2, 1:-1]) * np.float32(0.5) + grad = cv2.blur(np.hypot(gx, gy).astype(np.float32), (3, 3), borderType=cv2.BORDER_REPLICATE) + gain = gain * _smoothstep(5.0 * masking, 10.0 * masking, grad) + kern3 = np.ones((3, 3), np.uint8) + l_min = cv2.erode(l_chan, kern3, borderType=cv2.BORDER_REPLICATE) + l_max = cv2.dilate(l_chan, kern3, borderType=cv2.BORDER_REPLICATE) + l_new = np.clip(l_chan + diff * gain, l_min - np.float32(2.0), l_max + np.float32(1.0)) + l_new = np.clip(l_new, 0.0, 100.0) + return np.clip(lab_to_rgb_working(cv2.merge([l_new.astype(np.float32), a, b])), 0.0, 1.0) + + +@pytest.mark.parametrize("shape", [(97, 131), (5, 7), (300, 200)]) +@pytest.mark.parametrize("amount, radius, masking", [(0.25, 1.0, 0.0), (1.0, 2.3, 0.0), (0.6, 0.8, 0.5), (2.0, 1.0, 1.0)]) +def test_usm_sharpen_matches_numpy_and_cv2(shape, amount, radius, masking): + img = cv2.GaussianBlur(_RNG.random((*shape, 3), dtype=np.float32), (0, 0), 1.2) + want = _usm_reference(img, amount, radius, masking) + got = negpy_core.usm_sharpen(img, np.float32(amount * 2.5), masking, gaussian_kernel_1d(radius), *WORKING_LAB) + assert got.tobytes() == want.tobytes() + + +def _chroma_reference(img, sigma): + """The NumPy and OpenCV chroma denoise that negpy_core.chroma_denoise replaces.""" + lab = rgb_to_lab_working(img) + ab = np.ascontiguousarray(lab[:, :, 1:3]) + k = gaussian_kernel_1d(sigma) + low = cv2.sepFilter2D(ab, -1, k, k) + diff = np.array([[-0.5, 0.0, 0.5]], dtype=np.float32) + gx = cv2.filter2D(low, -1, diff, borderType=cv2.BORDER_REPLICATE) + gy = cv2.filter2D(low, -1, diff.T, borderType=cv2.BORDER_REPLICATE) + g = np.sqrt((gx * gx + gy * gy).sum(axis=2, dtype=np.float32)) + g = g * np.float32(sigma / 1.5) - np.float32(1.0 / 1.5) + t = np.clip(g, 0.0, 1.0) + ab = (ab - low) * (t * t * (3.0 - 2.0 * t))[:, :, None] + low + lab[:, :, 1:3] = ab + return np.clip(lab_to_rgb_working(lab), 0.0, 1.0) + + +@pytest.mark.parametrize("shape", [(97, 131), (3, 4), (300, 200)]) +@pytest.mark.parametrize("sigma", [0.5, 2.0, 6.0]) +def test_chroma_denoise_matches_numpy_and_cv2(shape, sigma): + img = _RNG.random((*shape, 3), dtype=np.float32) + want = _chroma_reference(img, sigma) + got = negpy_core.chroma_denoise(img, sigma, gaussian_kernel_1d(sigma), *WORKING_LAB) + assert got.tobytes() == want.tobytes() + + +def _glow_reference(img, glow_amount, halation_strength, scale_factor): + """The NumPy and OpenCV bloom that negpy_core.glow_and_halation replaces.""" + lum = (np.float32(LUM_R), np.float32(LUM_G), np.float32(LUM_B)) + result = img.copy() + if glow_amount > 0.0: + enc = working_oetf_encode(img) + mask = np.clip((enc[:, :, 0] * lum[0] + enc[:, :, 1] * lum[1] + enc[:, :, 2] * lum[2] - 0.5) / 0.5, 0.0, 1.0) + r = max(3, int(15 * scale_factor)) + result = result + cv2.GaussianBlur(img * mask[:, :, None], (min((r * 2 + 1) | 1, 201),) * 2, r * 0.5) * glow_amount + if halation_strength > 0.0: + mask = np.clip((img[:, :, 0] * lum[0] + img[:, :, 1] * lum[1] + img[:, :, 2] * lum[2] - 0.65) / (1.0 - 0.65), 0.0, 1.0) + red = img[:, :, 0] * mask + r = max(5, int(25 * scale_factor)) + hl = np.stack([red, red * 0.3, red * 0.05], axis=-1) + result = result + cv2.GaussianBlur(hl, (min((r * 2 + 1) | 1, 301),) * 2, r * 0.5) * halation_strength + return np.clip(result, 0.0, 1.0) + + +@pytest.mark.parametrize("shape", [(97, 131), (5, 7)]) +@pytest.mark.parametrize("glow, halation, scale", [(0.3, 0.0, 1.0), (0.0, 0.5, 1.0), (0.4, 0.2, 0.5), (1.0, 1.0, 2.0)]) +def test_glow_and_halation_matches_numpy_and_cv2(shape, glow, halation, scale): + img = _RNG.random((*shape, 3), dtype=np.float32) + want = _glow_reference(img, glow, halation, scale) + assert apply_glow_and_halation(img, glow, halation, scale).tobytes() == want.tobytes() + + +def _clahe_cdfs(bins, clip_limit): + """Per-tile clipped CDFs, (64, 256): integer counts, a float32-truncated limit, the excess + spread evenly with the remainder to the first bins, as clahe_hist.wgsl and clahe_cdf.wgsl.""" + h, w = bins.shape + tsy, tsx = -(-h // 8), -(-w // 8) + comb = ((np.arange(h) // tsy)[:, None] * 8 + (np.arange(w) // tsx)[None, :]) * 256 + bins + hist = np.bincount(comb.ravel(), minlength=64 * 256).reshape(64, 256) + total = hist.sum(axis=1) + limit = np.maximum(1, (np.float32(clip_limit) * total.astype(np.float32) / np.float32(256)).astype(np.int64)) + clipped = np.minimum(hist, limit[:, None]) + excess = (hist - clipped).sum(axis=1) + counts = clipped + (excess // 256)[:, None] + (np.arange(256)[None, :] < (excess % 256)[:, None]) + return np.cumsum(counts, axis=1).astype(np.float32) / np.maximum(total, 1)[:, None].astype(np.float32) + + +def _clahe_axis(n): + tp = np.arange(n, dtype=np.float32) / np.float32(n) * np.float32(8) - np.float32(0.5) + tf = np.floor(tp) + rf = tp - tf + return np.maximum(tf.astype(np.int32), 0), np.minimum(tf.astype(np.int32) + 1, 7), rf * rf * (np.float32(3.0) - np.float32(2.0) * rf) + + +def _clahe_reference(img, strength): + """The NumPy CLAHE that negpy_core.clahe replaces.""" + lab = rgb_to_lab_working(img) + l_chan = lab[..., 0] + bins = np.clip(l_chan / np.float32(100.0) * np.float32(255.0), 0.0, 255.0).astype(np.int32) + cdfs = _clahe_cdfs(bins, strength * 2.5).reshape(-1) + (y0, y1, fy), (x0, x1, fx) = _clahe_axis(img.shape[0]), _clahe_axis(img.shape[1]) + v = [cdfs[(ty[:, None] * 8 + tx[None, :]) * 256 + bins] for ty, tx in ((y0, x0), (y0, x1), (y1, x0), (y1, x1))] + top = v[0] + (v[1] - v[0]) * fx[None, :] + bot = v[2] + (v[3] - v[2]) * fx[None, :] + cdf_l = top + (bot - top) * fy[:, None] + lab[..., 0] = l_chan + (cdf_l * np.float32(100.0) - l_chan) * np.float32(strength) + return np.clip(lab_to_rgb_working(lab), 0.0, 1.0) + + +def test_clahe_cdfs_are_monotone_and_end_at_one(): + cdfs = _clahe_cdfs(np.random.default_rng(7).integers(0, 256, (128, 128)).astype(np.int32), 2.5) + assert cdfs.shape == (64, 256) + assert np.all(np.diff(cdfs, axis=1) >= 0) + assert np.all(cdfs[:, -1] == 1.0) + + +@pytest.mark.parametrize("shape", [(97, 131), (5, 7), (1, 1), (300, 200)]) +@pytest.mark.parametrize("strength", [0.1, 0.5, 1.0, 3.0]) +def test_clahe_matches_numpy(shape, strength): + img = _RNG.random((*shape, 3), dtype=np.float32) + assert apply_clahe(img, strength).tobytes() == _clahe_reference(img, strength).tobytes() + + +def _rl_reference(img, amount, radius, masking): + """The NumPy and OpenCV Richardson-Lucy sharpen that negpy_core.rl_sharpen replaces, with a + correctly rounded cube root where NumPy's own depends on the CPU.""" + lum = (np.float32(LUM_R), np.float32(LUM_G), np.float32(LUM_B)) + obs = (np.maximum(img[..., 0], 0.0) * lum[0] + np.maximum(img[..., 1], 0.0) * lum[1] + np.maximum(img[..., 2], 0.0) * lum[2]).astype( + np.float32 + ) + k = gaussian_kernel_1d(radius) + est = obs.copy() + for _ in range(rl_iterations(radius)): + blurred = cv2.sepFilter2D(est, -1, k, k, borderType=cv2.BORDER_REFLECT_101) + est = est * cv2.sepFilter2D(obs / np.maximum(blurred, np.float32(RL_EPS)), -1, k, k, borderType=cv2.BORDER_REFLECT_101) + ratio = est / np.maximum(obs, np.float32(RL_EPS)) + y = np.maximum(obs, 0.0) + cbrt = np.cbrt(y.astype(np.float64)).astype(np.float32) + l_obs = np.float32(116.0) * np.where(y > 0.008856, cbrt, np.float32(7.787) * y + np.float32(16.0 / 116.0)) - np.float32(16.0) + gain = np.float32(amount) * sharpen_shadow_gain(l_obs) + if masking > 0.0: + lp = np.pad(l_obs, 1, mode="edge") + gx = (lp[1:-1, 2:] - lp[1:-1, :-2]) * np.float32(0.5) + gy = (lp[2:, 1:-1] - lp[:-2, 1:-1]) * np.float32(0.5) + grad = cv2.blur(np.hypot(gx, gy).astype(np.float32), (3, 3), borderType=cv2.BORDER_REPLICATE) + gain = gain * _smoothstep(5.0 * masking, 10.0 * masking, grad) + factor = np.maximum(np.float32(1.0) + (ratio - np.float32(1.0)) * gain, 0.0) + return np.clip(img * factor[..., None], 0.0, 1.0) + + +@pytest.mark.parametrize("shape", [(97, 131), (6, 7), (200, 300)]) +@pytest.mark.parametrize("amount, radius, masking", [(0.25, 1.0, 0.0), (1.0, 0.6, 0.0), (0.6, 1.4, 0.5)]) +def test_rl_sharpen_matches_numpy_and_cv2(shape, amount, radius, masking): + img = cv2.GaussianBlur(_RNG.random((*shape, 3), dtype=np.float32), (0, 0), 1.0) - np.float32(0.05) + assert apply_rl_sharpening(img, amount, radius, masking).tobytes() == _rl_reference(img, amount, radius, masking).tobytes() + + +def _split_reference(img, shadow_hue, shadow_strength, highlight_hue, highlight_strength): + """The NumPy split toning that negpy_core.split_toning replaces.""" + lab = rgb_to_lab_working(img) + lum = lab[:, :, 0] + for hue, strength, t in ( + (shadow_hue, shadow_strength, 1.0 - lum / 50.0), + (highlight_hue, highlight_strength, (lum - 50.0) / 50.0), + ): + if strength > 0.0: + t = np.clip(t, 0.0, 1.0) + mask = t * t * (3.0 - 2.0 * t) + rad = np.radians(hue) + lab[:, :, 1] += np.cos(rad) * 20.0 * strength * mask + lab[:, :, 2] += np.sin(rad) * 20.0 * strength * mask + return np.clip(lab_to_rgb_working(lab), 0.0, 1.0) + + +@pytest.mark.parametrize("args", [(30.0, 0.4, 210.0, 0.0), (0.0, 0.0, 45.0, 0.7), (300.0, 1.0, 120.0, 0.5)]) +def test_split_toning_matches_numpy(args): + img = _RNG.random((61, 83, 3), dtype=np.float32) + assert apply_split_toning(img, *args).tobytes() == _split_reference(img, *args).tobytes() diff --git a/tests/test_core_lens.py b/tests/test_core_lens.py new file mode 100644 index 000000000..3340fc6cf --- /dev/null +++ b/tests/test_core_lens.py @@ -0,0 +1,113 @@ +"""The embedded lens maps against the NumPy and cv2 code they replace.""" + +import cv2 +import numpy as np +from core_oracle import cv2_oracle + +from negpy.features.lens.models import LensCorrections, LensMetadata +from negpy.features.lens.warps import PanasonicWarp, RectilinearWarp, SonyWarp + +pytestmark = cv2_oracle + +_BARREL = (1.0, -0.06, 0.012, -0.002, 0.0004, -0.0003) +_RED = (1.0006, -0.0598, 0.0119, -0.002, 0.0004, -0.0003) +_BLUE = (0.9993, -0.0603, 0.0121, -0.002, 0.0004, -0.0003) +_SHAPE = (120, 170, 3) + + +def _rectilinear_xy(x, y, coefficients): + k0, k1, k2, k3, t0, t1 = coefficients + r2 = x * x + y * y + factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)) + return x * factor + 2 * t0 * x * y + t1 * (r2 + 2 * x * x), y * factor + 2 * t1 * x * y + t0 * (r2 + 2 * y * y) + + +def _coordinates(lens, shape, start, stop, center): + h, w = shape[:2] + t, left, b, r = lens.active_area or (0, 0, h, w) + bt, bl, bb, br = lens.buffer_area or (t, left, b, r) + sx, sy = (br - bl) / w, (bb - bt) / h + cx, cy = left + center[0] * (r - left - 1), t + center[1] * (b - t - 1) + radius = np.hypot(max(cx - left, r - 1 - cx), max(cy - t, b - 1 - cy)) + x = (bl + (np.arange(w, dtype=np.float32)[None, :] + 0.5) * sx - 0.5 - cx) / radius * lens.fill_scale + y = (bt + (np.arange(start, stop, dtype=np.float32)[:, None] + 0.5) * sy - 0.5 - cy) / radius * lens.fill_scale + return x, y, cx, cy, radius, sx, sy, bl, bt + + +def _rectilinear_numpy(warp, lens, start, stop, channel, corrections): + x, y, cx, cy, radius, sx, sy, left, top = _coordinates(lens, _SHAPE, start, stop, warp.center) + common = warp.coefficients[0 if len(warp.coefficients) == 1 else 1] + selected = warp.coefficients[channel] if corrections.ca and len(warp.coefficients) == 3 else common + target_x, target_y = x, y + if not corrections.distortion: + g0, g1, g2, g3, t0, t1 = common + points = np.stack(np.broadcast_arrays(x, y), axis=-1) + unwarped = cv2.undistortPointsIter( + points.reshape(-1, 1, 2), + np.diag([g0, g0, 1.0]), + np.array([g1, g2, t0, t1, g3]) / g0, + None, + None, + (cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS, 50, 1e-8), + ).reshape(points.shape) + x, y = unwarped[..., 0], unwarped[..., 1] + limit = np.maximum(np.hypot(x, y), 1.0) + x, y = x / limit, y / limit + wx, wy = _rectilinear_xy(x, y, selected) + if not corrections.distortion: + gx, gy = _rectilinear_xy(x, y, common) + scale = np.hypot(target_x, target_y) / np.maximum(np.hypot(gx, gy), 1e-8) + wx, wy = target_x + (wx - gx) * scale, target_y + (wy - gy) * scale + mx = (cx + radius * wx - left + 0.5) / sx - 0.5 + my = (cy + radius * wy - top + 0.5) / sy - 0.5 + return mx.astype(np.float32), my.astype(np.float32) + + +def test_rectilinear_maps_match_numpy_and_cv2(): + cases = [ + (RectilinearWarp((_BARREL,)), LensMetadata("DNG", fill_scale=0.93), LensCorrections(True, True), 0), + ( + RectilinearWarp((_RED, _BARREL, _BLUE), (0.51, 0.49)), + LensMetadata("DNG", active_area=(3, 5, 118, 166)), + LensCorrections(True, True), + 2, + ), + (RectilinearWarp((_RED, _BARREL, _BLUE)), LensMetadata("DNG"), LensCorrections(False, True), 0), + ] + for warp, lens, corrections, channel in cases: + got = warp.remap(lens, _SHAPE, 10, 50, channel, corrections) + want = _rectilinear_numpy(warp, lens, 10, 50, channel, corrections) + for g, wv in zip(got, want): + assert g.tobytes() == wv.tobytes() + + +def test_panasonic_and_sony_maps_match_numpy(): + h, w = _SHAPE[:2] + lens = LensMetadata("x", fill_scale=0.97) + x = (np.arange(w, dtype=np.float32)[None, :] - w * 0.5) * lens.fill_scale + y = (np.arange(5, 40, dtype=np.float32)[:, None] - h * 0.5) * lens.fill_scale + radius = np.hypot(x, y) / np.hypot(w * 0.5, h * 0.5) + + pana = PanasonicWarp(0.9, 0.04, -0.01, 0.002) + rd = radius + for _ in range(8): + r2 = rd * rd + f = 1.0 + pana.scale * (pana.a * r2 + pana.b * r2 * r2 + pana.c * r2 * r2 * r2) + f = np.where(f > 0.0, f, 1.0) + new = radius / f + if np.abs(new - rd).max() < 1e-7: + break + rd = new + dr = np.divide(rd, radius, out=np.ones_like(radius), where=radius > 0.0) + want = ((x * dr + w * 0.5).astype(np.float32), (y * dr + h * 0.5).astype(np.float32)) + got = pana.remap(lens, _SHAPE, 5, 40, 1) + assert all(g.tobytes() == wv.tobytes() for g, wv in zip(got, want)) + + knots = tuple(float(v) for v in range(-60, 100, 10)) + sony = SonyWarp(knots, tuple(float(v) for v in range(16)), ()) + n = len(knots) + factors = (np.ones(n) + np.asarray(knots) / 16384.0) * (1 + np.arange(16.0) / 2097152.0) + factor = np.interp(radius, (np.arange(n) + 0.5) / (n - 1), factors).astype(np.float32) + want = ((x * factor + w * 0.5).astype(np.float32), (y * factor + h * 0.5).astype(np.float32)) + got = sony.remap(lens, _SHAPE, 5, 40, 0) + assert all(g.tobytes() == wv.tobytes() for g, wv in zip(got, want)) diff --git a/tests/test_core_loaders.py b/tests/test_core_loaders.py new file mode 100644 index 000000000..4174dc557 --- /dev/null +++ b/tests/test_core_loaders.py @@ -0,0 +1,88 @@ +"""The loader kernels against the NumPy and cv2 code they replace.""" + +import cv2 +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core +from negpy.infrastructure.loaders.logluv import normalize_linear + + +def _normalize_linear_numpy(lin): + flat = lin.reshape(-1, lin.shape[-1]) + lo = np.percentile(flat, 0.2, axis=0) + hi = np.percentile(flat, 99.8, axis=0) + denom = hi - lo + denom = np.where(denom > 0, denom, 1.0) + return np.clip((lin - lo) / denom, 0.0, 1.0) + + +@pytest.mark.parametrize("shape", [(1, 1, 3), (7, 5, 3), (311, 517, 3), (40, 30, 4)]) +def test_normalize_linear_matches_numpy(shape): + rng = np.random.default_rng(sum(shape)) + lin = rng.lognormal(0.0, 1.5, shape) * np.array([0.3, 1.0, 2.0, 1.0][: shape[-1]]) + lin[..., -1] = 0.25 if shape[0] == 7 else lin[..., -1] + np.testing.assert_array_equal(normalize_linear(lin), _normalize_linear_numpy(lin)) + + +@cv2_oracle +def test_ellipse_kernel_matches_cv2(): + for w in range(1, 42): + for h in (1, 2, 3, 6, 9, 17, w): + want = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (w, h)) + np.testing.assert_array_equal(negpy_core.ellipse_kernel((w, h)), want, err_msg=f"{w}x{h}") + + +def test_constants_match_cv2(): + for name in ( + "BORDER_CONSTANT", + "BORDER_REPLICATE", + "BORDER_REFLECT", + "BORDER_REFLECT_101", + "MORPH_ERODE", + "MORPH_DILATE", + "MORPH_OPEN", + "MORPH_CLOSE", + "MORPH_GRADIENT", + "MORPH_TOPHAT", + "MORPH_BLACKHAT", + ): + assert getattr(negpy_core, name) == getattr(cv2, name), name + + +class _Cv2Core: + MORPH_ERODE = cv2.MORPH_ERODE + BORDER_REPLICATE = cv2.BORDER_REPLICATE + + @staticmethod + def ellipse_kernel(ksize): + return cv2.getStructuringElement(cv2.MORPH_ELLIPSE, ksize) + + @staticmethod + def resize_area(src, dsize): + return cv2.resize(src, dsize, interpolation=cv2.INTER_AREA) + + @staticmethod + def morphology_ex(src, op, kernel, border_type): + return cv2.morphologyEx(src, op, kernel, borderType=border_type) + + +@cv2_oracle +@pytest.mark.parametrize("samples,preserve_ir", [(3, False), (4, False), (4, True)]) +def test_streamed_linearraw_preview_matches_cv2(tmp_path, monkeypatch, samples, preserve_ir): + import tifffile + + from negpy.infrastructure.loaders import rawpy_loader + + rng = np.random.default_rng(samples) + data = rng.integers(0, 65535, (300, 470, samples), dtype=np.uint16) + path = tmp_path / "scan.tif" + tifffile.imwrite(path, data, tile=(64, 64), photometric="rgb", extrasamples=[0] * (samples - 3)) + with tifffile.TiffFile(path) as tf: + page = tf.pages[0] + got = rawpy_loader._stream_linearraw_preview(page, page, 100, normalize_tags=False, min_preserve_ir=preserve_ir) + monkeypatch.setattr(rawpy_loader, "negpy_core", _Cv2Core) + want = rawpy_loader._stream_linearraw_preview(page, page, 100, normalize_tags=False, min_preserve_ir=preserve_ir) + np.testing.assert_array_equal(got[0], want[0]) + assert got[1] == want[1] diff --git a/tests/test_core_local.py b/tests/test_core_local.py new file mode 100644 index 000000000..75120ff1c --- /dev/null +++ b/tests/test_core_local.py @@ -0,0 +1,70 @@ +"""cv2.fillPoly and the local-mask rasteriser against the cv2 and NumPy code they replace.""" + +import cv2 +import numpy as np +from core_oracle import cv2_oracle + +import negpy_core +from negpy.features.local.logic import outline_points, rasterise +from negpy.features.local.models import MaskShape + +pytestmark = cv2_oracle + + +def test_fill_poly_matches_cv2_on_random_polygons(): + rng = np.random.default_rng(0) + for _ in range(500): + h, w = (int(v) for v in rng.integers(5, 90, 2)) + n = int(rng.integers(3, 30)) + pts = np.stack([rng.integers(-20, w + 20, n), rng.integers(-20, h + 20, n)], 1).astype(np.int32) + want = np.zeros((h, w), np.uint8) + cv2.fillPoly(want, [pts], 255) + np.testing.assert_array_equal(negpy_core.fill_poly((h, w), pts), want) + + +def _rasterise_cv2(shape, pts, h, w, sigma, invert): + if shape == MaskShape.GRADIENT: + (a, b) = pts + ax, ay = a[0] * w, a[1] * h + dx, dy = b[0] * w - ax, b[1] * h - ay + denom = dx * dx + dy * dy + xs = (np.arange(w, dtype=np.float32) - ax) * (dx / denom) + ys = (np.arange(h, dtype=np.float32) - ay) * (dy / denom) + t = np.clip(xs[None, :] + ys[:, None], 0.0, 1.0) + alpha = (1.0 - t * t * (3.0 - 2.0 * t)).astype(np.float32) + else: + outline = np.array([[x * w, y * h] for x, y in outline_points(shape, pts)], dtype=np.float32) + filled = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(filled, [outline.astype(np.int32)], 255) + alpha = filled.astype(np.float32) / 255.0 + if sigma > 1e-3: + k = int(sigma * 3) | 1 + alpha = cv2.GaussianBlur(alpha, (k, k), sigma) + return 1.0 - alpha if invert else alpha + + +def test_rasterise_matches_cv2(): + cases = [ + (MaskShape.POLYGON, ((0.2, 0.2), (0.5, 0.15), (0.6, 0.45), (0.25, 0.5)), 6.0, False), + (MaskShape.OVAL, ((0.6, 0.6), (0.85, 0.6), (0.6, 0.8)), 3.5, True), + (MaskShape.GRADIENT, ((0.5, 0.0), (0.4, 0.7)), 0.0, False), + (MaskShape.POLYGON, ((0.1, 0.9), (0.9, 0.1), (0.9, 0.9), (0.1, 0.1)), 0.0, False), + ] + for shape, pts, sigma, invert in cases: + want = _rasterise_cv2(shape, pts, 150, 210, sigma, invert) + assert rasterise(shape, pts, 150, 210, sigma, invert).tobytes() == want.tobytes(), shape + + +def test_stroke_cover_matches_cv2_on_random_strokes(): + rng = np.random.default_rng(1) + for _ in range(800): + h, w = (int(v) for v in rng.integers(4, 90, 2)) + n = int(rng.integers(1, 7)) + pts = np.stack([rng.integers(-30, w + 30, n), rng.integers(-30, h + 30, n)], 1).astype(np.int32) + radius, thickness = int(rng.integers(0, 25)), int(rng.integers(1, 50)) + want = np.zeros((h, w), np.uint8) + if n > 1: + cv2.polylines(want, [pts], False, 1, thickness=thickness) + for cx, cy in pts: + cv2.circle(want, (int(cx), int(cy)), radius, 1, -1) + np.testing.assert_array_equal(negpy_core.stroke_cover((h, w), [(pts, radius, thickness)]), want) diff --git a/tests/test_core_lsd.py b/tests/test_core_lsd.py new file mode 100644 index 000000000..b4f6448f8 --- /dev/null +++ b/tests/test_core_lsd.py @@ -0,0 +1,136 @@ +"""negpy_core's line segment detector and `_coarse_angles` against cv2's LSD. The port is gated on +quality, not bits: long segments must land on cv2's and the angle modes must agree.""" + +import glob +import os +import pickle + +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.geometry import skew +from negpy.features.geometry.logic import AUTOCROP_DETECT_RES, _detection_luma, _normalize_detection_input + +pytestmark = cv2_oracle + +_CORPUS = os.path.expanduser("~/negpy-corpus/features__geometry__skew.py--measure_frame_skew") +_RECORDINGS = sorted(glob.glob(os.path.join(_CORPUS, "*.pkl"))) + + +def _to_u8(lum): + lo, hi = np.percentile(lum, (1.0, 99.0)) + return (np.clip((lum - lo) / max(hi - lo, 1e-6), 0.0, 1.0) * 255.0).astype(np.uint8) + + +def _coarse_angles_cv2(lum): + """The cv2-based `_coarse_angles` that negpy_core.coarse_angles replaces.""" + h, w = lum.shape + segments = cv2.createLineSegmentDetector().detect(_to_u8(lum))[0] + if segments is None: + return [] + seg = segments.reshape(-1, 4).astype(np.float64) + dx, dy = seg[:, 2] - seg[:, 0], seg[:, 3] - seg[:, 1] + length = np.hypot(dx, dy) + angle = np.degrees(np.arctan2(dy, dx)) + angle = (angle + 45.0) % 90.0 - 45.0 + keep = (length >= skew._MIN_SEGMENT * max(h, w)) & (np.abs(angle) <= skew._MAX_SKEW_DEGREES) + if not np.any(keep): + return [] + bins = np.arange(-skew._MAX_SKEW_DEGREES, skew._MAX_SKEW_DEGREES + skew._ANGLE_BIN, skew._ANGLE_BIN) + hist, _ = np.histogram(angle[keep], bins=bins, weights=length[keep] ** 2) + hist = cv2.GaussianBlur(hist.astype(np.float32).reshape(1, -1), (0, 0), 3.0).ravel() + first = int(np.argmax(hist)) + modes = [float(bins[first] + skew._ANGLE_BIN / 2)] + rest = hist.copy() + rest[max(0, first - 10) : first + 11] = 0.0 + second = int(np.argmax(rest)) + if rest[second] >= 0.25 * hist[first]: + modes.append(float(bins[second] + skew._ANGLE_BIN / 2)) + return modes + + +def _matched_fraction(lum): + """Fraction of cv2's segments longer than `_MIN_SEGMENT` of the long side that a negpy_core + segment matches with both endpoints within 1 px, in either direction.""" + u8 = _to_u8(lum) + ref = cv2.createLineSegmentDetector().detect(u8)[0] + ref = np.empty((0, 4), np.float32) if ref is None else ref.reshape(-1, 4) + got = negpy_core.line_segments(u8) + long = ref[np.hypot(ref[:, 2] - ref[:, 0], ref[:, 3] - ref[:, 1]) >= skew._MIN_SEGMENT * max(u8.shape)] + if long.size == 0: + return 1.0 + if got.size == 0: + return 0.0 + + def far(a, b): + return np.maximum(np.hypot(got[:, 0] - a[0], got[:, 1] - a[1]), np.hypot(got[:, 2] - b[0], got[:, 3] - b[1])) + + hits = [min(far(s[:2], s[2:]).min(), far(s[2:], s[:2]).min()) <= 1.0 for s in long] + return float(np.mean(hits)) + + +def _assert_same_modes(lum): + want, got = _coarse_angles_cv2(lum), skew._coarse_angles(lum) + assert len(want) == len(got) + assert np.allclose(want, got, rtol=0.0, atol=0.05) + + +def _synthetic(angle, seed): + """A light bed, a dark film frame and a picture with a few hard lines, turned by `angle`.""" + rng = np.random.default_rng(seed) + h, w = 900, 1300 + img = np.full((h, w), 0.92, np.float32) + img[150:750, 180:1120] = 0.08 + img[190:710, 230:1070] = 0.55 + img[300:330, 300:1000] = 0.3 + img[420:640, 600:615] = 0.8 + m = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) + img = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + return img + rng.normal(0.0, 0.02, img.shape).astype(np.float32) + + +@pytest.mark.parametrize("angle", [-9.5, -4.2, -0.7, 0.0, 0.3, 2.6, 6.1, 9.8]) +def test_synthetic_frames_match_cv2(angle): + lum = _synthetic(angle, int(abs(angle) * 10)) + assert _matched_fraction(lum) >= 0.95 + _assert_same_modes(lum) + + +def test_flat_and_tiny_planes(): + flat = np.full((64, 80), 0.5, np.float32) + assert negpy_core.line_segments(_to_u8(flat)).shape == (0, 4) + assert skew._coarse_angles(flat) == [] + assert negpy_core.line_segments(np.zeros((1, 1), np.uint8)).shape == (0, 4) + + +def _recorded_luma(rec): + det, _ = _normalize_detection_input(rec["args"][0], AUTOCROP_DETECT_RES) + return _detection_luma(np.ascontiguousarray(det, dtype=np.float32)) + + +@pytest.mark.skipif(not _RECORDINGS, reason="needs the replay corpus") +def test_recorded_planes_match_cv2(): + for path in _RECORDINGS: + with open(path, "rb") as f: + rec = pickle.load(f) + lum = _recorded_luma(rec) + assert _matched_fraction(lum) >= 0.95, path + _assert_same_modes(lum) + + +@pytest.mark.skipif(not _RECORDINGS, reason="needs the replay corpus") +def test_recorded_frame_skew_unchanged(): + for path in _RECORDINGS: + with open(path, "rb") as f: + rec = pickle.load(f) + want, got = rec["result"], skew.measure_frame_skew(*rec["args"], **rec["kwargs"]) + assert (want is None) == (got is None), path + if want is None or got is None: + continue + assert abs(want.fine_rotation - got.fine_rotation) <= 0.05, path + for a, b in ((want.converge_v, got.converge_v), (want.converge_h, got.converge_h)): + assert (a is None) == (b is None), path + assert a is None or abs(a - b) <= 0.05, path diff --git a/tests/test_core_manifest.py b/tests/test_core_manifest.py new file mode 100644 index 000000000..9beb29f10 --- /dev/null +++ b/tests/test_core_manifest.py @@ -0,0 +1,18 @@ +import subprocess +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_manifest_is_current(): + result = subprocess.run([sys.executable, "scripts/core_manifest.py", "--check"], cwd=ROOT, capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_manifest_fields_are_valid(): + manifest = tomllib.loads((ROOT / "negpy_core" / "manifest.toml").read_text(encoding="utf-8")) + for key, entry in manifest.items(): + assert entry["status"] in ("todo", "ported", "stays"), key + assert entry["phase"] in range(1, 10), key diff --git a/tests/test_core_morph.py b/tests/test_core_morph.py new file mode 100644 index 000000000..6ec6f188b --- /dev/null +++ b/tests/test_core_morph.py @@ -0,0 +1,65 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(12) +_OPS = [cv2.MORPH_ERODE, cv2.MORPH_DILATE, cv2.MORPH_OPEN, cv2.MORPH_CLOSE, cv2.MORPH_GRADIENT, cv2.MORPH_TOPHAT, cv2.MORPH_BLACKHAT] +_SHAPES = [(41, 83, 1), (37, 53, 3), (1, 301, 1), (300, 1, 1), (13, 9, 1)] + + +def _image(dtype, h, w, cn): + shape = (h, w, cn) if cn > 1 else (h, w) + if dtype == np.uint8: + return _RNG.integers(0, 256, shape).astype(np.uint8) + return (_RNG.random(shape, dtype=np.float32) - 0.3) * 3 + + +@pytest.mark.parametrize("dtype", [np.uint8, np.float32]) +@pytest.mark.parametrize("shape", _SHAPES) +@pytest.mark.parametrize("op", _OPS) +@pytest.mark.parametrize("ksize, iterations", [((31, 31), 2), ((9, 9), 1), ((5, 5), 1), ((1, 7), 2), ((4, 6), 1), ((1, 1), 1)]) +def test_morphology_matches_cv2_bit_for_bit(dtype, shape, op, ksize, iterations): + img = _image(dtype, *shape) + kernel = np.ones((ksize[1], ksize[0]), np.uint8) + want = cv2.morphologyEx(img, op, kernel, iterations=iterations) + got = negpy_core.morphology_ex(img, op, kernel, iterations) + assert got.dtype == want.dtype + assert got.tobytes() == want.tobytes() + + +@pytest.mark.parametrize("border", [cv2.BORDER_REPLICATE, cv2.BORDER_REFLECT_101]) +@pytest.mark.parametrize("op", [cv2.MORPH_ERODE, cv2.MORPH_DILATE]) +def test_erode_dilate_borders_match_cv2(border, op): + img = _image(np.float32, 37, 53, 3) + kernel = np.ones((7, 5), np.uint8) + want = cv2.morphologyEx(img, op, kernel, iterations=2, borderType=border) + assert negpy_core.morphology_ex(img, op, kernel, 2, border).tobytes() == want.tobytes() + + +@pytest.mark.parametrize( + "shape_code, ksize", + [ + (cv2.MORPH_ELLIPSE, (5, 5)), + (cv2.MORPH_ELLIPSE, (11, 11)), + (cv2.MORPH_ELLIPSE, (7, 3)), + (cv2.MORPH_CROSS, (3, 3)), + (cv2.MORPH_CROSS, (5, 7)), + ], +) +@pytest.mark.parametrize("op", range(7)) +@pytest.mark.parametrize("iterations", [1, 2]) +@pytest.mark.parametrize("border", [cv2.BORDER_CONSTANT, cv2.BORDER_REPLICATE, cv2.BORDER_REFLECT_101]) +@pytest.mark.parametrize("dtype", [np.uint8, np.float32]) +def test_shaped_kernels_match_cv2(shape_code, ksize, op, iterations, border, dtype): + rng = np.random.default_rng(ksize[0] * 31 + op) + img = rng.integers(0, 256, (37, 53, 3)).astype(dtype) + if dtype == np.float32: + img = (img / 255.0 - 0.3).astype(np.float32) + kernel = cv2.getStructuringElement(shape_code, ksize) + want = cv2.morphologyEx(img, op, kernel, iterations=iterations, borderType=border) + got = negpy_core.morphology_ex(img, op, kernel, iterations, border) + assert got.tobytes() == want.tobytes() diff --git a/tests/test_core_normalization.py b/tests/test_core_normalization.py new file mode 100644 index 000000000..79e4092d9 --- /dev/null +++ b/tests/test_core_normalization.py @@ -0,0 +1,75 @@ +"""The normalization planes and metrics against the NumPy and cv2 code they replace.""" + +import cv2 +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core +from negpy.domain.types import LUMA_B, LUMA_G, LUMA_R +from negpy.features.exposure import normalization as N +from negpy.features.exposure.models import EXPOSURE_CONSTANTS + + +def _rgb(seed, shape=(301, 457)): + rng = np.random.default_rng(seed) + return rng.uniform(0.0, 1.0, (*shape, 3)).astype(np.float32) + + +def _clip_fractions_numpy(image, roi, buffer): + img = image + if roi: + y1, y2, x1, x2 = roi + img = img[y1:y2, x1:x2] + if buffer > 0: + img = N.get_analysis_crop(img, buffer) + clipped = np.mean(img[::4, ::4] >= float(EXPOSURE_CONSTANTS["scan_clip_level"]), axis=(0, 1)) + return tuple(float(v) for v in clipped) + + +@pytest.mark.parametrize( + "roi,buffer", + [(None, 0.0), (None, 0.1), ((13, 250, 7, 401), 0.0), ((13, 250, 7, 401), 0.05), ((0, 500, 0, 900), 0.0)], +) +def test_clip_fractions_match_numpy(roi, buffer): + img = _rgb(1) + img[img > 0.97] = 1.0 + assert N.measure_clip_fractions(img, roi, buffer) == _clip_fractions_numpy(img, roi, buffer) + + +def _lum32(val): + return LUMA_R * val[:, :, 0] + LUMA_G * val[:, :, 1] + LUMA_B * val[:, :, 2] + + +def test_luma_planes_match_numpy(): + val = _rgb(2) * 2.0 - 0.5 + lum = _lum32(val) + np.testing.assert_array_equal(negpy_core.luma_planes(val), np.dstack([lum, lum, lum])) + + +@cv2_oracle +def test_luma_median_matches_numpy(): + grid = -3.0 * _rgb(3) + w = np.array([LUMA_R, LUMA_G, LUMA_B], dtype=np.float64) + assert negpy_core.luma_median(grid) == float(np.median(grid.astype(np.float64).reshape(-1, 3) @ w)) + + +@cv2_oracle +@pytest.mark.parametrize("sigma", [3.0, 12.4]) +def test_contrast_mask_tail_matches_numpy(sigma): + val = _rgb(4) + blurred = cv2.GaussianBlur( + np.ascontiguousarray(_lum32(val), dtype=np.float32), (0, 0), sigma, sigmaY=0, borderType=cv2.BORDER_REPLICATE + ) + centre = float(blurred.mean()) + plane, got = negpy_core.contrast_mask_tail(val, sigma) + assert got == np.float32(centre) + np.testing.assert_array_equal(plane, blurred - centre) + + +@cv2_oracle +def test_diffusion_tail_matches_numpy(): + unmixed = -2.5 * _rgb(5) + light = np.ascontiguousarray(10.0**unmixed, dtype=np.float32) + want = np.log10(cv2.GaussianBlur(light, (0, 0), 6.0, sigmaY=0, borderType=cv2.BORDER_REPLICATE)) + np.testing.assert_allclose(negpy_core.diffusion_tail(unmixed, 6.0), want, rtol=0, atol=1e-6) diff --git a/tests/test_core_phase.py b/tests/test_core_phase.py new file mode 100644 index 000000000..f2e5cb60d --- /dev/null +++ b/tests/test_core_phase.py @@ -0,0 +1,58 @@ +"""Quality gate for negpy_core.phase_correlate, the float64 port of cv2.phaseCorrelate.""" + +import cv2 +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core + +SHIFTS = [(3.0, -2.0), (0.4, 1.7), (-12.3, 5.5), (0.0, 0.0), (7.75, -0.25)] + + +def _texture(seed, shape): + rng = np.random.default_rng(seed) + return cv2.GaussianBlur(rng.uniform(0, 1, shape).astype(np.float32), (0, 0), 2.5) + + +def _shifted(img, dx, dy): + h, w = img.shape + m = np.float32([[1, 0, dx], [0, 1, dy]]) + return cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REFLECT) + + +@pytest.mark.parametrize("shape", [(300, 400), (257, 361), (512, 1024)]) +@pytest.mark.parametrize("dx,dy", SHIFTS) +def test_recovers_the_true_shift(shape, dx, dy): + """Within the bias of the 5x5 peak centroid, which cv2 shares.""" + base = _texture(sum(shape), shape) + (gx, gy), response = negpy_core.phase_correlate(base, _shifted(base, dx, dy)) + assert abs(gx - dx) < 0.2 and abs(gy - dy) < 0.2 + assert response > 0.5 + + +@pytest.mark.parametrize("shape", [(257, 361), (300, 401), (375, 270)]) +def test_an_odd_padded_size_reads_no_shift_as_zero(shape): + base = _texture(3, shape) + (gx, gy), _ = negpy_core.phase_correlate(base, base.copy()) + assert abs(gx) < 1e-6 and abs(gy) < 1e-6 + + +@cv2_oracle +@pytest.mark.parametrize("shape", [(300, 400), (257, 361)]) +@pytest.mark.parametrize("dx,dy", SHIFTS) +def test_stays_near_cv2(shape, dx, dy): + """cv2 measures from n / 2.0, half a pixel off along an odd padded axis.""" + h, w = shape + base = _texture(7, shape) + mov = _shifted(base, dx, dy) + (cx, cy), _ = cv2.phaseCorrelate(base, mov, cv2.createHanningWindow((w, h), cv2.CV_32F)) + cx -= 0.5 * (cv2.getOptimalDFTSize(w) % 2) + cy -= 0.5 * (cv2.getOptimalDFTSize(h) % 2) + (gx, gy), _ = negpy_core.phase_correlate(base, mov) + assert abs(gx - cx) < 0.05 and abs(gy - cy) < 0.05 + + +def test_rejects_mismatched_planes(): + with pytest.raises(ValueError): + negpy_core.phase_correlate(np.zeros((4, 4), np.float32), np.zeros((4, 5), np.float32)) diff --git a/tests/test_core_process.py b/tests/test_core_process.py new file mode 100644 index 000000000..141cbf7b5 --- /dev/null +++ b/tests/test_core_process.py @@ -0,0 +1,73 @@ +"""The process statistics, chart reads and mask overlap against the NumPy code they replace.""" + +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core +from negpy.features.local.logic import _OVERLAP_ALPHA +from negpy.features.process import chart +from negpy.features.process.capture_color import _LIGHTBOX_PCT +from negpy.kernel.image.logic import working_oetf_decode + + +def _scan(seed, shape=(380, 251)): + rng = np.random.default_rng(seed) + return rng.uniform(0.0, 1.2, (*shape, 3)).astype(np.float32) * np.array([0.9, 0.5, 0.3], dtype=np.float32) + + +@cv2_oracle +@pytest.mark.parametrize("matrix", [None, [[1.1, -0.05, -0.05], [-0.1, 1.2, -0.1], [0.02, -0.3, 1.28]]]) +def test_lightbox_white_matches_numpy(matrix): + img = _scan(1) + flat = img.reshape(-1, 3) + step = 7 + sample = flat[::step].astype(np.float64) + if matrix is not None: + sample = sample @ np.asarray(matrix, dtype=np.float64).T + want = float(np.percentile(sample, _LIGHTBOX_PCT)) + assert negpy_core.lightbox_white(img, step, matrix, _LIGHTBOX_PCT) == want + + +def _corr(a, b): + a = a.ravel() - float(a.mean()) + b = b.ravel() - float(b.mean()) + denom = float(np.sqrt(np.sum(a * a) * np.sum(b * b))) + 1e-12 + return float(np.sum(a * b) / denom) + + +@pytest.mark.parametrize("seed", [2, 3]) +def test_process_mode_stats_match_numpy(seed): + img = np.clip(_scan(seed), 0.0, 1.0) + r, g, b = img[:, :, 0], img[:, :, 1], img[:, :, 2] + want_corr = min(_corr(r, g), _corr(g, b), _corr(r, b)) + want = [float(np.mean(r)), float(np.mean(b))] + want += [float(np.percentile(r, 25)), float(np.percentile(b, 25))] + want += [float(np.percentile(c, 98)) for c in (r, g, b)] + got_corr, got = negpy_core.process_mode_stats(img) + assert got_corr == want_corr + assert list(got) == want + + +def test_chart_patches_match_numpy(): + encoded = _scan(4) - 0.05 + corners = np.array([[20.0, 30.0], [230.0, 28.0], [232.0, 350.0], [18.0, 352.0]]) + h, w = encoded.shape[:2] + linear = working_oetf_decode(np.clip(encoded, 0.0, 1.0)) + r = max(1, int(round(chart.sample_radius(corners)))) + want = np.empty((24, 3)) + for i, (x, y) in enumerate(chart.patch_centers(corners)): + x0, x1 = int(np.clip(round(x) - r, 0, w - 1)), int(np.clip(round(x) + r + 1, 1, w)) + y0, y1 = int(np.clip(round(y) - r, 0, h - 1)), int(np.clip(round(y) + r + 1, 1, h)) + want[i] = np.median(linear[y0:y1, x0:x1, :3].reshape(-1, 3), axis=0) + np.testing.assert_array_equal(chart._patch_medians(encoded, corners, float(np.float32(563.0 / 256.0))), want) + np.testing.assert_array_equal(chart.sample_patches(linear, corners), want) + + +def test_alphas_overlap_matches_numpy(): + rng = np.random.default_rng(5) + for _ in range(20): + a = rng.uniform(0, 0.3, (96, 96)).astype(np.float32) + b = rng.uniform(0, 0.27, (96, 96)).astype(np.float32) + want = bool(((a > _OVERLAP_ALPHA) & (b > _OVERLAP_ALPHA)).any()) + assert negpy_core.alphas_overlap(a, b, _OVERLAP_ALPHA) == want diff --git a/tests/test_core_render.py b/tests/test_core_render.py new file mode 100644 index 000000000..d178cb2d6 --- /dev/null +++ b/tests/test_core_render.py @@ -0,0 +1,72 @@ +"""The render-stage helpers against the NumPy code they replace.""" + +import numpy as np +import pytest + +import negpy_core +from negpy.features.exposure.logic import contrast_mask_ev, contrast_mask_scale, expand_mask_plane +from negpy.features.process.capture_color import apply_camera_matrix + + +def _img(seed, shape=(97, 131, 3)): + return np.random.default_rng(seed).uniform(-0.2, 1.4, shape).astype(np.float32) + + +def test_log_density_open_matches_numpy_within_the_ledger(): + img = _img(1) + img[0, 0] = [np.nan, np.inf, -np.inf] + want = np.log10(np.clip(np.nan_to_num(img, nan=1e-6, posinf=1.0, neginf=1e-6), 1e-6, None)) + np.testing.assert_allclose(negpy_core.log_density_open(img), want, rtol=4e-7, atol=0) + + +def test_small_helpers_match_numpy(): + img = _img(2) + lum = img[..., 1].copy() + np.testing.assert_array_equal(negpy_core.gray_to_rgb(lum), np.stack([lum, lum, lum], axis=-1)) + np.testing.assert_array_equal(negpy_core.add(lum, img[..., 0].copy()), lum + img[..., 0]) + np.testing.assert_array_equal(negpy_core.clip01(img), np.clip(img, 0, 1)) + zeros = np.zeros((5, 6, 3), np.float32) + assert not negpy_core.channel_any(zeros, 1) + zeros[4, 5, 1] = np.nan + assert negpy_core.channel_any(zeros, 1) == bool(zeros[..., 1].any()) + nan = _img(3) + nan[1, 2] = [np.nan, np.inf, -np.inf] + np.testing.assert_array_equal(negpy_core.nan_to_num(nan, 1e-6, 1.0, 1e-6), np.nan_to_num(nan, nan=1e-6, posinf=1.0, neginf=1e-6)) + + +def test_camera_matrix_matches_numpy(): + img = _img(4) + m = np.random.default_rng(5).uniform(-0.4, 1.3, (3, 3)) + want = (img.reshape(-1, 3).astype(np.float32) @ np.asarray(m, dtype=np.float32).T).reshape(img.shape) + np.testing.assert_array_equal(apply_camera_matrix(img, m), want) + + +def _expand_numpy(plane, out_shape, roi): + h, w = out_shape + if roi is None: + if plane.shape[:2] != (h, w): + plane = negpy_core.resize_linear(np.ascontiguousarray(plane, dtype=np.float32), (w, h)) + return np.ascontiguousarray(plane, dtype=np.float32) + y1, y2, x1, x2 = roi + y1, x1 = max(0, y1), max(0, x1) + y2, x2 = min(h, y2), min(w, x2) + if y2 - y1 < 1 or x2 - x1 < 1: + return None + inner = negpy_core.resize_linear(np.ascontiguousarray(plane, dtype=np.float32), (x2 - x1, y2 - y1)) + if (y1, x1, y2, x2) == (0, 0, h, w): + return inner + return np.pad(inner, ((y1, h - y2), (x1, w - x2)), mode="edge").astype(np.float32) + + +@pytest.mark.parametrize("roi", [None, (0, 300, 0, 400), (20, 250, 35, 390), (-5, 320, 10, 500), (100, 100, 0, 10)]) +@pytest.mark.parametrize("plane_shape", [(300, 400), (61, 87)]) +def test_expand_mask_plane_matches_numpy(roi, plane_shape): + plane = _img(6, plane_shape) + want = _expand_numpy(plane, (300, 400), roi) + got = expand_mask_plane(plane, (300, 400), roi) + if want is None: + assert got is None + return + np.testing.assert_array_equal(got, want) + scale = contrast_mask_scale(0.6, 1.8) + np.testing.assert_array_equal(contrast_mask_ev(plane, 0.6, 1.8, (300, 400), roi), (want * scale).astype(np.float32)) diff --git a/tests/test_core_resize.py b/tests/test_core_resize.py new file mode 100644 index 000000000..dd633270d --- /dev/null +++ b/tests/test_core_resize.py @@ -0,0 +1,96 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(5) + + +def _image(h, w, cn): + return _RNG.random((h, w, cn) if cn > 1 else (h, w), dtype=np.float32) ** 2.2 + + +@pytest.mark.parametrize("cn", [1, 3, 4]) +@pytest.mark.parametrize( + "shape, dsize", + [ + ((394, 566), (160, 111)), # the detection downscale, at a tenth of its size + ((1600, 1059), (1059, 701)), + ((37, 53), (17, 11)), + ((100, 98), (49, 40)), # integer scale on one axis only + ((64, 64), (32, 32)), # 2x2: SIMD order + ((100, 98), (49, 50)), + ((40, 74), (37, 20)), # 2x2 with a scalar tail column + ((90, 90), (30, 30)), # 3x3 + ((80, 80), (20, 20)), # 4x4 + ((80, 60), (60, 80)), # same size + ], +) +def test_resize_area_matches_cv2_bit_for_bit(shape, dsize, cn): + img = _image(*shape, cn) + want = cv2.resize(img, dsize, interpolation=cv2.INTER_AREA) + got = negpy_core.resize_area(img, dsize) + assert got.shape == want.shape + assert got.tobytes() == want.tobytes() + + +_SIZES = [ + ((60, 90), (130, 200)), + ((61, 91), (177, 300)), + ((100, 140), (37, 51)), + ((7, 5), (40, 64)), + ((300, 517), (900, 1100)), + ((80, 120), (40, 60)), # an exact halving + ((80, 120), (160, 50)), # one axis grows, one shrinks + ((1, 9), (4, 30)), + ((9, 1), (30, 4)), +] + + +@pytest.fixture +def no_ipp(): + # opencv-python runs IPP's float32 bilinear resize on x86, whose bits depend on the CPU; + # negpy_core copies OpenCV's own code, which a build without IPP runs. + cv2.ipp.setUseIPP(False) + yield + cv2.ipp.setUseIPP(True) + + +@pytest.mark.parametrize("cn", [1, 2, 3, 4]) +@pytest.mark.parametrize("shape, dshape", _SIZES) +def test_resize_linear_matches_cv2_without_ipp(shape, dshape, cn, no_ipp): + img = _image(*shape, cn) + dsize = (dshape[1], dshape[0]) + if cn == 2 and dshape[0] * 2 == shape[0] and dshape[1] * 2 == shape[1]: + with pytest.raises(ValueError): + negpy_core.resize_linear(img, dsize) + return + want = cv2.resize(img, dsize, interpolation=cv2.INTER_LINEAR) + assert negpy_core.resize_linear(img, dsize).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("cn", [1, 3, 4]) +@pytest.mark.parametrize("shape, dshape", [s for s in _SIZES if s[1][0] > s[0][0] or s[1][1] > s[0][1]]) +def test_resize_area_upscale_matches_cv2(shape, dshape, cn): + img = _image(*shape, cn) + dsize = (dshape[1], dshape[0]) + want = cv2.resize(img, dsize, interpolation=cv2.INTER_AREA) + assert negpy_core.resize_area(img, dsize).tobytes() == want.tobytes() + + +def test_resize_area_rejects_strided_input(): + img = _image(20, 20, 3) + with pytest.raises(ValueError): + negpy_core.resize_area(img[:, ::2], (5, 10)) + + +@pytest.mark.parametrize("cn", [1, 3, 4]) +@pytest.mark.parametrize("shape, dshape", _SIZES) +def test_resize_nearest_matches_cv2(shape, dshape, cn): + img = (_image(*shape, cn) * 255).astype(np.uint8) + dsize = (dshape[1], dshape[0]) + want = cv2.resize(img, dsize, interpolation=cv2.INTER_NEAREST) + assert negpy_core.resize_nearest(img, dsize).tobytes() == want.tobytes() diff --git a/tests/test_core_resize_kernel.py b/tests/test_core_resize_kernel.py new file mode 100644 index 000000000..b53ce58a9 --- /dev/null +++ b/tests/test_core_resize_kernel.py @@ -0,0 +1,66 @@ +"""Quality gate for negpy_core.resize_kernel against cv2's INTER_LANCZOS4 and INTER_CUBIC.""" + +import cv2 +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core + +pytestmark = cv2_oracle + +CASES = [ + ((120, 170, 3), (400, 290)), + ((300, 400, 3), (1003, 751)), + ((64, 80), (97, 61)), + ((200, 300, 4), (301, 201)), + ((50, 70, 3), (35, 25)), +] + + +@pytest.mark.parametrize("shape,dsize", CASES) +@pytest.mark.parametrize("lanczos,flag,tol", [(True, cv2.INTER_LANCZOS4, 1e-6), (False, cv2.INTER_CUBIC, 1e-5)]) +def test_resize_kernel_stays_near_cv2(shape, dsize, lanczos, flag, tol): + rng = np.random.default_rng(sum(shape)) + src = cv2.GaussianBlur(rng.uniform(0, 1, shape).astype(np.float32), (0, 0), 1.2) + want = cv2.resize(src, dsize, interpolation=flag) + got = negpy_core.resize_kernel(np.ascontiguousarray(src), dsize, lanczos=lanczos) + assert got.shape == want.shape + np.testing.assert_allclose(got, want, rtol=0, atol=tol) + + +U8_CASES = [ + ((20, 30, 3), (45, 31)), + ((17, 23), (40, 33)), + ((200, 300, 3), (450, 300)), + ((300, 200, 4), (500, 333)), + ((64, 64, 3), (48, 40)), + ((9, 7, 3), (31, 29)), +] + + +@pytest.fixture +def no_ipp(): + cv2.ipp.setUseIPP(False) + yield + cv2.ipp.setUseIPP(True) + + +@pytest.mark.parametrize("shape,dsize", U8_CASES) +def test_uint8_cubic_matches_opencv_without_ipp(shape, dsize, no_ipp): + src = np.random.default_rng(sum(shape)).integers(0, 256, shape).astype(np.uint8) + np.testing.assert_array_equal(negpy_core.resize_u8(src, dsize, cubic=True), cv2.resize(src, dsize, interpolation=cv2.INTER_CUBIC)) + + +@pytest.mark.parametrize("shape,dsize", [((400, 600, 3), (240, 160)), ((600, 900, 3), (300, 200)), ((101, 77, 3), (40, 31))]) +def test_uint8_area_matches_cv2(shape, dsize): + src = np.random.default_rng(sum(shape)).integers(0, 256, shape).astype(np.uint8) + np.testing.assert_array_equal(negpy_core.resize_u8(src, dsize), cv2.resize(src, dsize, interpolation=cv2.INTER_AREA)) + + +@pytest.mark.parametrize("dtype,top", [(np.uint8, 256), (np.uint16, 65536)]) +@pytest.mark.parametrize("shape,dh", [((100, 37, 3), 173), ((50, 64), 101), ((40, 40, 4), 77)]) +def test_row_linear_matches_opencv_without_ipp(dtype, top, shape, dh, no_ipp): + src = np.random.default_rng(dh).integers(0, top, shape).astype(dtype) + want = cv2.resize(src, (shape[1], dh), interpolation=cv2.INTER_LINEAR) + np.testing.assert_array_equal(negpy_core.resize_rows_linear(src, dh), want) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py new file mode 100644 index 000000000..c5accec89 --- /dev/null +++ b/tests/test_core_same_bits.py @@ -0,0 +1,162 @@ +import hashlib + +import negpy_core +import numpy as np +import pytest + +from negpy.features.exposure.logic import apply_characteristic_curve +from negpy.features.toning.logic import apply_chemical_toning +from negpy.kernel.image.logic import WORKING_LAB + +# Recorded on Linux x86-64, where the cv2 oracles pass. Every release platform must give these +# bytes; CI runs this file on each one. A port adds its case here; a hash only changes with a +# decided change of output. +_RNG = np.random.default_rng(2026) +_IMG3 = _RNG.random((97, 131, 3), dtype=np.float32) +_IMG1 = (_RNG.random((83, 61), dtype=np.float32) - 0.4) * 5 +_GRAY = negpy_core.bgr_to_gray((_IMG3 * 255).astype(np.uint8)) +_M = np.array([[0.58, 0.19, 0.19], [0.30, 0.63, 0.08], [0.03, 0.07, 0.99]], np.float32) + + +_LUT = np.ascontiguousarray(np.clip(np.cumsum(_RNG.random((9, 9, 9, 3), dtype=np.float32), axis=0) / 6, 0, 1)) +# Plain arithmetic only: NumPy's own float32 power differs between CPUs. +_LIN = np.linspace(0.0, 1.0, 65536, dtype=np.float32) +_RAMP = np.ascontiguousarray(np.tile(_LIN * (np.float32(2.0) - _LIN), (3, 1))) + + +def _filled(fill): + out = np.empty_like(_IMG3) + fill(out) + return out + + +_LAB = (_IMG3 * np.array([100.0, 160.0, 160.0], np.float32) - np.array([0.0, 80.0, 80.0], np.float32)).astype(np.float32) + +_CASES = { + "percentiles": lambda: np.array(negpy_core.percentiles(_IMG1.ravel(), [0.5, 1.0, 37.0, 50.0, 99.0]), np.float32), + "resize_area_general": lambda: negpy_core.resize_area(_IMG3, (47, 31)), + "resize_linear_up": lambda: negpy_core.resize_linear(_IMG3, (300, 177)), + "resize_area_up": lambda: negpy_core.resize_area(_IMG1, (100, 120)), + "resize_nearest": lambda: negpy_core.resize_nearest(_GRAY, (300, 41)), + "resize_area_2x2": lambda: negpy_core.resize_area(_IMG1[:82, :60].copy(), (30, 41)), + "gaussian_5x5": lambda: negpy_core.gaussian_blur(_IMG3, (5, 5), 0.0), + "gaussian_sigma2_replicate": lambda: negpy_core.gaussian_blur(_IMG1, (0, 0), 2.0, 0.0, 1), + "gaussian_kernel_17": lambda: np.array(negpy_core.gaussian_kernel(17, 2.0), np.float32), + "sobel_x": lambda: negpy_core.sep_filter2d(_IMG1, np.array([-1, 0, 1], np.float32), np.array([1, 2, 1], np.float32)), + "bgr_to_gray": lambda: _GRAY, + "gaussian_blur_u8": lambda: negpy_core.gaussian_blur_u8(_GRAY, (5, 5), 0.0), + "otsu": lambda: negpy_core.threshold_otsu(_GRAY)[1], + "canny": lambda: negpy_core.canny(_GRAY, 40, 160), + "find_external_contours": lambda: np.concatenate( + [c.ravel() for c in negpy_core.find_external_contours((_IMG1 > 0.5).astype(np.uint8) * np.uint8(255))[0]] + ), + "morph_close_u8": lambda: negpy_core.morphology_ex((_IMG1 > 0.5).astype(np.uint8) * 255, 3, np.ones((9, 7), np.uint8), 2), + "to_log_density": lambda: negpy_core.to_log_density(np.ascontiguousarray(_IMG1 * _IMG1)), + "oetf_encode": lambda: negpy_core.oetf_encode(_IMG3, np.float32(256.0 / 563.0)), + "rgb_to_lab": lambda: negpy_core.rgb_to_lab( + _IMG3, _M, np.array([0.95047, 1.0, 1.08883], np.float32), np.float32(0.008856), np.float32(7.787) + ), + "lab_to_rgb": lambda: negpy_core.lab_to_rgb( + _LAB, _M.T.copy(), np.array([0.95047, 1.0, 1.08883], np.float32), np.float32(0.008856), np.float32(7.787) + ), + "skin_chroma_rein": lambda: negpy_core.skin_chroma_rein(_LAB, np.float32(0.6)), + "matmul_3x3": lambda: negpy_core.matmul_3x3(_IMG3, _M), + "gamut_aware_chroma_scale": lambda: negpy_core.gamut_aware_chroma_scale( + _LAB, np.float32(1.8), _M.T.copy(), np.array([0.95047, 1.0, 1.08883], np.float32), np.float32(0.008856), np.float32(7.787), 10 + ), + "print_curve": lambda: apply_characteristic_curve( + _IMG3 * 0.8 - 0.1, (0.45, 2.1), (0.5, 2.0), (0.55, 1.9), toe=0.3, shoulder=0.2, midtone_gamma=0.2, shadow_density=-0.1, bpc=True + ), + "chemical_toning": lambda: apply_chemical_toning(_IMG3, 0.3, 0.4, 0.3, 0.2, 0.3, 0.4), + "sensor_log_unmix": lambda: _filled( + lambda out: negpy_core.sensor_log_unmix(_IMG3, _M, np.array([0.01, -0.02, 0.03], np.float32), np.full(3, 1e-3, np.float32), out) + ), + "lut_f32": lambda: negpy_core.apply_lut_f32(_IMG3, _LUT), + "matrix_trc_u8": lambda: negpy_core.matrix_trc_u8((_IMG3 * 255).astype(np.uint8), np.ascontiguousarray(_RAMP[:, ::256]), _M, _RAMP), + "usm_sharpen": lambda: negpy_core.usm_sharpen( + _IMG3, np.float32(2.0), 0.4, np.array([0.0625, 0.25, 0.375, 0.25, 0.0625], np.float32), *WORKING_LAB + ), + "sabattier": lambda: negpy_core.sabattier( + _IMG3, + (np.float32(0.9), np.float32(0.08), np.float32(0.1), np.float32(1.3), np.float32(2.0)), + np.array([0.25, 0.5, 0.25], np.float32), + ), + "lith": lambda: negpy_core.lith( + _IMG3, + [np.float32(v) for v in (2.0, 0.3, 0.7, 0.6, 0.6, 1.7, 0.14)], + [np.float32(v) for v in (0.1, 0.35, 0.65, 1.0)], + [(np.float32(a), np.float32(b)) for a, b in ((4.0, 12.0), (6.0, 18.0), (2.0, 8.0), (0.0, 0.5))], + WORKING_LAB, + ), + "carrier": lambda: negpy_core.apply_carrier( + _IMG3, + 9.0, + 0.7, + 0.5, + 0.4, + (np.float32(0.98), np.float32(0.97), np.float32(0.95)), + np.ascontiguousarray(np.repeat(_LIN[::1040][:64, None], 3, axis=1)), + np.ascontiguousarray(np.tile(_LIN[::32] * np.float32(2.0) - np.float32(1.0), (8, 1))), + ), + "warp_affine": lambda: negpy_core.warp_affine(_IMG3, np.array([[0.998, 0.061, -3.1], [-0.061, 0.998, 4.2]]), (131, 97)), + "warp_perspective": lambda: negpy_core.warp_perspective( + _IMG1, np.array([[1.02, 0.03, -1.5], [-0.01, 0.97, 2.25], [1e-4, -2e-4, 1.0]]), (61, 83) + ), + "warp_affine_cubic_constant": lambda: negpy_core.warp_affine( + _IMG3, np.array([[0.998, 0.061, -3.1], [-0.061, 0.998, 4.2]]), (140, 90), interpolation="cubic", border="constant" + ), + "remap_nearest": lambda: negpy_core.remap( + _IMG1, *np.meshgrid(np.arange(70, dtype=np.float32) * 0.9 - 2, np.arange(90, dtype=np.float32) * 0.95 - 1), "nearest", "constant" + ), + "remap_radial": lambda: negpy_core.remap_radial(_IMG3, (65.0, 48.0), 0.93, -0.05, 1.0 / 6600.0), +} + +_HASHES = { + "percentiles": "0874152b6e9f91bd3a3b73e15a9d083d15b6e8c7c6f6a9279d9241df10a88464", + "resize_area_general": "dbe0fb9e51b0e8bee54cc2bb35756bbf0934077b458c1a44ea27c2e5dd96a16f", + "resize_linear_up": "9e6c1200f4f4f05bf46bbba858c67501044e514e47ce1afb50ac5a0e3e7cabee", + "resize_area_up": "1b0aa67c89167cb289f780e36bdd5465c5d306200b3f1413510b6cfcd608fcac", + "resize_nearest": "1b335732ac03cc2345941fba19ec591c930b03fd5abdfac82bdf5111984d9956", + "resize_area_2x2": "cae2830d9848ee6f270000d57b3dc448138cc99cb5a2303108ac3e095b060488", + "gaussian_5x5": "988a867049827fd5912f1950b8574b637d671c3565e0b7af87def261c9f0b084", + "gaussian_sigma2_replicate": "2b9be6f95b7f66a03493795d8c7a5e1bb2565a28087578f1981a9cd329c8a74f", + "gaussian_kernel_17": "5c2eb92d43931221106d80ca89a0b6dea1dca09b261b0fbf6ff51fdcae748d8b", + "sobel_x": "ba798e861db64c102fee6b08fd7aa34d488cce431ae0d95fc3e7836e1dedb44a", + "bgr_to_gray": "ea18764e8727ce823408e9962b86969c2de631c5f0246b1d33c3bc833101dac9", + "gaussian_blur_u8": "7817f7444ec9c843b3c529098d650d653dd0081250770eb96bb378da20de046e", + "otsu": "cff78130e93898fd1d8ff6080127920c71a5fa1bbecd734a35631b253866ffdb", + "canny": "1e3125ae47a5dd1719f97531ffebffc79a6461ba1fae024d26d4d7a85b89b968", + "find_external_contours": "ebb4302dd2bc7226a7ed24fbb5e16e14f407d5693ce47128a649cee9e4a098be", + "morph_close_u8": "4be9a2976881ff83eb0b92a8ec484e048c61612324afbde73b6e0c89aa92865e", + "to_log_density": "cc4d2086edf051826619f024a4f5d28c80d28528291d9df4610c1d3854144038", + "oetf_encode": "44508d28673c678fc0ec7b5262a06d0053ea6b4bed4bead7bc49556bc60eb070", + "rgb_to_lab": "02c583bf1f5fe9723e2cb0a3e1f34759d6b0778d3644e8e2cff2251e756b68f2", + "lab_to_rgb": "34016e7f9aebcfdf780b67b96b128041c6ba1ac2a427f4e87b5413dcc7345eef", + "skin_chroma_rein": "6def89b9f456f16abf406c351745bbb791f15bc69e5b03f2e478ed7391839b01", + "matmul_3x3": "d80c524d23c1bc6b7d9592b638a5fb84d4b51691efac9b71b40fbf529e67c019", + "gamut_aware_chroma_scale": "ef1a9474817c0bca19e3c847a4667ba47ba75829722a1c7939a77e135d8b7da4", + "print_curve": "c06516de4316987b8cb37dcf977915373302d36b0bd6c1018e3e4bfba0929b09", + "chemical_toning": "7ddd3949c04bd5af38177bfcd6576888092bf73ad173206c34b153d6cce5d648", + "sensor_log_unmix": "f7eb8efe1bbcfe884fd2e849e99d0546fac2f4829d3fb558d54a0128e681e53b", + "lut_f32": "467bb9fd495d4797d912f6aeabd34f9a61b56491105464a0c852ed524488b1c1", + "matrix_trc_u8": "acec1eadffb13dfef8473a02fb250b4ad8f57ac211a6265661b77d3cad49ab16", + "usm_sharpen": "7267d2a3976c9381eadde01fe11ea60d46345b4747283e6ac68a2aa4a025d0a8", + "sabattier": "f8956f67786c7c1d0e628af4b82fb12b6dde42e70da2504585f5bf20e7fbb461", + "lith": "98dc05c62e191da2ad00c1f2327c48beff5af7e882be64cac5302cb0ea5099e5", + "carrier": "97c9d045201ba345010f91b09ea07699fecc4e009d7738dc8591295b59e6081c", + "warp_affine": "83562b3183484dcbf80fc65c4fb2f241d3b13ec9793cf43fd22bf11ea44d9e1d", + "warp_perspective": "61e89302e793e24f1ad131b661e95186d7d9a82e8331a96164e338bec3ec1420", + "warp_affine_cubic_constant": "a3d3d1a3d6c5e0e77686cfd37850199aa880943936e83edf8ada48307501cd69", + "remap_nearest": "16ebb2a1c29640f25e4a490f6edd8817d8dc1f652b6aa001f72aa2ca34d33ac0", + "remap_radial": "a23f3b21f15cb5d9db26cc532ec920dc8b194d73515a786d9febc93ae17370e0", +} + + +@pytest.mark.parametrize("name", sorted(_CASES)) +def test_same_bits_on_every_platform(name): + out = np.ascontiguousarray(_CASES[name]()) + assert hashlib.sha256(out.tobytes()).hexdigest() == _HASHES[name] + + +def test_every_case_has_a_hash(): + assert _CASES.keys() == _HASHES.keys() diff --git a/tests/test_core_seam.py b/tests/test_core_seam.py new file mode 100644 index 000000000..40c7d95f5 --- /dev/null +++ b/tests/test_core_seam.py @@ -0,0 +1,152 @@ +import functools +import pickle +from pathlib import Path + +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.stitch import logic + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(31) +_CORPUS = Path("~/negpy-corpus-p5/features__stitch__logic.py--seam_blend").expanduser() + + +@functools.cache +def _recorded(): + """(warped, masks, seams, feather) of the recorded panorama blends; empty without the corpus.""" + return [pickle.loads(p.read_bytes())["args"] for p in sorted(_CORPUS.glob("*.pkl"))] + + +def _parts(h, w, n, shift, angle): + """`n` noisy, slightly rotated views of one scene, each covering a shifted window.""" + wide = w + (n - 1) * shift + scene = cv2.GaussianBlur(_RNG.random((h, wide, 3), dtype=np.float32), (0, 0), 4) + imgs, masks = [], [] + for i in range(n): + img = np.zeros((h, wide, 3), np.float32) + x0 = i * shift + img[:, x0 : x0 + w] = scene[:, x0 : x0 + w] + _RNG.normal(0, 0.01, (h, w, 3)).astype(np.float32) + m = np.zeros((h, wide), np.uint8) + m[:, x0 : x0 + w] = 1 + rot = cv2.getRotationMatrix2D((wide / 2, h / 2), float(_RNG.normal(0, angle)), 1.0) + imgs.append(np.ascontiguousarray(cv2.warpAffine(img, rot, (wide, h)))) + masks.append(np.ascontiguousarray(cv2.warpAffine(m, rot, (wide, h), flags=cv2.INTER_NEAREST))) + return imgs, masks + + +def _cut_cost(imgs, cut): + """DpSeamFinder's color cost summed over every step where the owning part changes.""" + owner = np.full(cut[0].shape, -1) + for i, c in enumerate(cut): + owner[(owner < 0) & (c > 0)] = i + stack = np.stack(imgs) + total = 0.0 + for a, b in ( + ((slice(None, -1), slice(None)), (slice(1, None), slice(None))), + ((slice(None), slice(None, -1)), (slice(None), slice(1, None))), + ): + oa, ob = owner[a], owner[b] + ys, xs = np.nonzero((oa >= 0) & (ob >= 0) & (oa != ob)) + ia, ib = oa[ys, xs], ob[ys, xs] + pa = stack[:, a[0], a[1]][:, ys, xs] + pb = stack[:, b[0], b[1]][:, ys, xs] + n = np.arange(len(ys)) + d1 = ((pa[ia, n] - pb[ib, n]) ** 2).sum(-1) + d2 = ((pb[ia, n] - pa[ib, n]) ** 2).sum(-1) + total += float(((d1 + d2) / 2).sum()) + return total + + +def _check_seams(imgs, masks): + want = [cv2.UMat.get(m) for m in cv2.detail_DpSeamFinder("COLOR").find(imgs, [(0, 0)] * len(imgs), [cv2.UMat(m.copy()) for m in masks])] + got = negpy_core.dp_seams(imgs, masks) + overlap = np.sum([m > 0 for m in masks], 0) >= 2 + for w, g in zip(want, got): + assert np.array_equal(w[~overlap] > 0, g[~overlap] > 0) + assert _cut_cost(imgs, got) <= 1.01 * _cut_cost(imgs, want) + 1e-6 + + +@pytest.mark.parametrize("n", [2, 3, 4]) +@pytest.mark.parametrize("angle", [0.0, 3.0]) +def test_dp_seams_match_cv2_on_synthetic_overlaps(n, angle): + for h, w, shift in ((40, 60, 25), (128, 128, 60), (300, 400, 250)): + _check_seams(*_parts(h, w, n, shift, angle)) + + +def test_dp_seams_match_cv2_on_small_random_cuts(): + for _ in range(100): + h, w = (int(v) for v in _RNG.integers(8, 40, 2)) + _check_seams(*_parts(h, w, 2, int(_RNG.integers(2, w)), 5.0)) + + +def test_dp_seams_match_cv2_on_the_recorded_panorama(): + for warped, masks, _, _ in _recorded(): + h, w = masks[0].shape + scale = min(1.0, logic._SEAM_MAX_EDGE / max(w, h)) + size = (max(1, round(w * scale)), max(1, round(h * scale))) + small = [logic._seam_cost_image(negpy_core.resize_area(np.ascontiguousarray(i, dtype=np.float32), size)) for i in warped] + _check_seams(small, [negpy_core.resize_nearest(m.astype(np.uint8), size) for m in masks]) + + +def test_find_seams_partitions_the_coverage(): + imgs, masks = _parts(90, 120, 3, 50, 2.0) + seams = logic.find_seams(imgs, [m > 0 for m in masks], (imgs[0].shape[1], imgs[0].shape[0])) + assert seams is not None + covered = np.any([m > 0 for m in masks], 0) + assert np.array_equal(np.sum(seams, 0) >= 1, covered) + + +def _seam_weight_cv2(seam, valid, feather_px): + span = 2.0 * feather_px + owned = seam.astype(np.uint8) + weight = cv2.distanceTransform(owned, cv2.DIST_L2, 3) / np.float32(span) + np.float32(0.5) + beyond = np.float32(0.5) - cv2.distanceTransform(1 - owned, cv2.DIST_L2, 3) / np.float32(span) + weight = np.where(seam, np.clip(weight, 0.0, 1.0), np.clip(beyond, 0.0, 1.0)) + edge = np.clip(cv2.distanceTransform(valid.astype(np.uint8), cv2.DIST_L2, 3) / np.float32(feather_px), 0.0, 1.0) + return weight * edge + + +def _mean_cv2(warped, weights): + acc = np.zeros_like(warped[0]) + weight_sum = np.zeros(warped[0].shape[:2], np.float32) + for img, weight in zip(warped, weights): + acc += img * weight[..., None] + weight_sum += weight + return acc / np.maximum(weight_sum, 1e-6)[..., None] + + +@functools.cache +def _blend_cases(): + cases = [(w, m, s, f) for w, m, s, f in _recorded()] + for n, size in ((2, (120, 160, 70)), (3, (200, 260, 150)), (4, (64, 90, 40))): + imgs, masks = _parts(*size, n, 3.0) + seams = [c > 0 for c in negpy_core.dp_seams(imgs, masks)] + cases.append((imgs, [m > 0 for m in masks], seams, 12.0)) + return cases + + +def test_distance_transform_stays_near_cv2(): + masks = [m for _, ms, ss, _ in _blend_cases() for m in (*ms, *ss)] + masks += [_RNG.random((1500, 1600)) > 0.0005, np.pad(np.ones((1400, 1500), bool), 50)] + for m in masks: + for src in (m.astype(np.uint8), (~m).astype(np.uint8)): + want = cv2.distanceTransform(src, cv2.DIST_L2, 3) + assert np.abs(negpy_core.distance_transform(src, cv2.DIST_L2, 3) - want).max() <= 0.005 + + +def test_seam_blend_matches_cv2_version(): + for warped, masks, seams, feather in _blend_cases(): + f = max(feather, 1.0) + want = _mean_cv2(warped, [_seam_weight_cv2(s, m, f) for s, m in zip(seams, masks)]) + assert np.abs(logic.seam_blend(warped, masks, seams, feather) - want).max() <= 1e-4 + + +def test_feather_blend_matches_cv2_version(): + for warped, masks, _, _ in _blend_cases(): + want = _mean_cv2(warped, [cv2.distanceTransform(m.astype(np.uint8), cv2.DIST_L2, 3) for m in masks]) + assert np.abs(logic.feather_blend(warped, masks) - want).max() <= 1e-4 diff --git a/tests/test_core_shapes.py b/tests/test_core_shapes.py new file mode 100644 index 000000000..59aef7961 --- /dev/null +++ b/tests/test_core_shapes.py @@ -0,0 +1,33 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(15) +_POINT_SETS = [_RNG.integers(-50, 500, (n, 2)).astype(np.int32) for n in (1, 2, 3, 4, 7, 12, 39)] + [ + np.array([[0, 0], [10, 0], [10, 10], [0, 10], [5, 5]], np.int32), + np.array([[0, 0], [5, 0], [10, 0]], np.int32), + np.array([[3, 3], [3, 3], [3, 3]], np.int32), +] + + +@pytest.mark.parametrize("index", range(len(_POINT_SETS))) +def test_hull_rect_and_box_match_cv2(index): + pts = _POINT_SETS[index] + assert np.array_equal(np.array(negpy_core.convex_hull(pts), np.int32).reshape(-1, 2), cv2.convexHull(pts).reshape(-1, 2)) + want = cv2.minAreaRect(pts) + got = negpy_core.min_area_rect(pts) + assert np.array_equal(np.float32([*want[0], *want[1], want[2]]), np.float32([*got[0], *got[1], got[2]])) + assert np.array_equal(np.array(negpy_core.box_points(want), np.float32), cv2.boxPoints(want)) + + +@pytest.mark.parametrize("index", range(len(_POINT_SETS))) +@pytest.mark.parametrize("shape", [(400, 500), (60, 70)]) +def test_fill_convex_poly_matches_cv2(index, shape): + quad = np.rint(cv2.boxPoints(cv2.minAreaRect(_POINT_SETS[index]))).astype(np.int32) + want = np.zeros(shape, np.uint8) + cv2.fillConvexPoly(want, quad, 1) + assert np.array_equal(negpy_core.fill_convex_poly(shape, quad), want) diff --git a/tests/test_core_sift.py b/tests/test_core_sift.py new file mode 100644 index 000000000..e2840ee10 --- /dev/null +++ b/tests/test_core_sift.py @@ -0,0 +1,146 @@ +"""The quality gate of the SIFT registration port: negpy_core.register_pair against the true +transform of synthetic pairs and against cv2's SIFT + BFMatcher + estimateAffinePartial2D.""" + +import os +import pickle +from pathlib import Path + +import cv2 +import numpy as np +import pytest +from core_oracle import cv2_oracle + +import negpy_core +from negpy.features.stitch import logic as L + +pytestmark = cv2_oracle + +ROOT = Path(__file__).resolve().parents[1] +_TIF = ROOT / "samples" / "panorama" / "Img428 - NX Studio Tiff.TIF" +_RECORDING = "features__stitch__logic.py--register_parts" +_CASES = [(1.5, 1.01, 0.2), (-2.8, 0.975, 0.25), (3.0, 1.03, 0.15), (-1.0, 0.99, 0.3)] + + +def _corpus_recordings(): + dirs = [Path(os.environ.get("NEGPY_CORPUS", Path.home() / "negpy-corpus")), Path.home() / "negpy-corpus-p5"] + return sorted(p for d in dirs for p in (d / _RECORDING).glob("*.pkl")) + + +def _synthetic(): + rng = np.random.default_rng(5) + base = cv2.GaussianBlur(rng.random((1100, 1600), dtype=np.float32), (0, 0), 2.0) + grad = np.linspace(0.0, 0.3, 1600, dtype=np.float32)[None, :] + mono = np.clip(0.1 + 0.6 * base + grad, 0.0, 1.0) + return np.stack([mono, mono * 0.9, mono * 1.1], axis=-1).clip(0.0, 1.0) + + +def _source(name): + if name == "synthetic": + return _synthetic() + if name == "preview": + recs = _corpus_recordings() + if not recs: + pytest.skip("no register_parts recording in the corpus") + return pickle.load(recs[0].open("rb"))["args"][0][0] + if not _TIF.exists(): + pytest.skip("panorama samples not present") + import tifffile + + return tifffile.imread(_TIF).astype(np.float32) / 65535.0 + + +def _similarity(angle, scale, tx, ty): + a = np.deg2rad(angle) + c, s = scale * np.cos(a), scale * np.sin(a) + return np.array([[c, -s, tx], [s, c, ty]]) + + +def _cut(src, angle, scale, shift): + """Part A at the top left of ``src``; part B moved along the long side by a known similarity. + Returns (A, B, t) with t mapping B coordinates to A coordinates.""" + big_h, big_w = src.shape[:2] + if big_w >= big_h: + h, w = int(big_h * 0.9), int(big_w * 0.62) + t = _similarity(angle, scale, shift * big_w, 0.02 * big_h) + else: + h, w = int(big_h * 0.62), int(big_w * 0.9) + t = _similarity(angle, scale, 0.02 * big_w, shift * big_h) + b = cv2.warpAffine(src, t, (w, h), flags=cv2.INTER_LINEAR | cv2.WARP_INVERSE_MAP) + return np.ascontiguousarray(src[:h, :w]), b, t + + +def _corner_error(m, t, w, h): + box = np.array([[0, 0], [w, 0], [w, h], [0, h]], np.float64) + return float(np.max(np.hypot(*((box @ m[:, :2].T + m[:, 2]) - (box @ t[:, :2].T + t[:, 2])).T))) + + +def _cv2_register(ref, mov): + sift = cv2.SIFT_create(nfeatures=6000) + ref_kp, ref_desc = sift.detectAndCompute(ref, None) + mov_kp, mov_desc = sift.detectAndCompute(mov, None) + pairs = cv2.BFMatcher().knnMatch(mov_desc, ref_desc, k=2) + good = [m for m, n in (p for p in pairs if len(p) == 2) if m.distance < 0.75 * n.distance] + mov_pts = np.float32([mov_kp[m.queryIdx].pt for m in good]) + ref_pts = np.float32([ref_kp[m.trainIdx].pt for m in good]) + matrix, mask = cv2.estimateAffinePartial2D(mov_pts, ref_pts, method=cv2.RANSAC, ransacReprojThreshold=3.0) + return matrix, int(mask.sum()) + + +@pytest.mark.parametrize("name", ["synthetic", "preview", "full"]) +def test_register_pair_recovers_a_known_similarity(name): + src = _source(name) + for angle, scale, shift in _CASES: + a, b, t = _cut(src, angle, scale, shift) + (pa, sa), (pb, sb) = L.build_proxy(a), L.build_proxy(b) + matrix, inliers = L.register_pair(pa, pb) + ref_matrix, ref_inliers = _cv2_register(pa, pb) + h, w = b.shape[:2] + err = _corner_error(L.scale_affine(matrix, sa, sb), t, w, h) + ref_err = _corner_error(L.scale_affine(ref_matrix, sa, sb), t, w, h) + print(f"{name} {angle:+.1f} deg x{scale}: corner error {err:.4f} px ({inliers} inliers), cv2 {ref_err:.4f} px ({ref_inliers})") + assert err < 0.1, (angle, scale, err, ref_err) + + +@pytest.mark.parametrize("name", ["synthetic", "preview", "full"]) +def test_keypoint_count_matches_cv2(name): + proxy, _ = L.build_proxy(_source(name)) + for nfeatures in (0, 6000): + want = len(cv2.SIFT_create(nfeatures=nfeatures).detect(proxy, None)) + got = negpy_core.sift_detect(proxy, nfeatures)[0].shape[0] + print(f"{name} nfeatures={nfeatures}: {got} keypoints, cv2 {want}") + assert abs(got - want) <= 0.1 * want + + +def test_descriptors_match_cv2(): + proxy, _ = L.build_proxy(_synthetic()) + kp, want = cv2.SIFT_create().detectAndCompute(proxy, None) + pts, got = negpy_core.sift_detect(proxy, 0) + by_pos = {} + for k, d in zip(kp, want.astype(np.int32)): + by_pos.setdefault((round(k.pt[0], 2), round(k.pt[1], 2)), []).append(d) + far = [np.full(128, 999, np.int32)] + diffs = np.array( + [ + min(np.abs(d - w).max() for w in by_pos.get((round(float(x), 2), round(float(y), 2)), far)) + for (x, y), d in zip(pts, got.astype(np.int32)) + ] + ) + print(f"descriptors within 2 of cv2's: {np.mean(diffs <= 2):.4f}, identical: {np.mean(diffs == 0):.4f}") + assert np.mean(diffs <= 2) >= 0.95 + + +def test_real_panorama_matches_cv2_recording(): + recs = _corpus_recordings() + if not recs: + pytest.skip("no register_parts recording in the corpus") + for path in recs: + rec = pickle.load(path.open("rb")) + parts = rec["args"][0] + want_transforms, want_canvas = rec["result"] + transforms, canvas = L.register_parts(parts) + assert canvas == tuple(want_canvas) + for part, m, want in zip(parts, transforms, want_transforms): + h, w = part.shape[:2] + err = _corner_error(m, want, w, h) + print(f"{path.name}: corner error against cv2 {err:.4f} px") + assert err < 0.25 diff --git a/tests/test_core_skew.py b/tests/test_core_skew.py new file mode 100644 index 000000000..8880d074f --- /dev/null +++ b/tests/test_core_skew.py @@ -0,0 +1,163 @@ +import math + +import negpy_core +import numpy as np +import pytest + + +def _profile_peaks(profile): + """The NumPy peak finder that negpy_core.skew_sample replaces.""" + if profile.size < 5: + return np.empty(0) + grad = np.abs(np.gradient(negpy_core.gaussian_blur(profile.reshape(-1, 1).astype(np.float32), (1, 0), 0, 1.0).ravel())) + peak = float(grad.max()) + if peak <= 3.0 * float(np.median(grad)) + 1e-6: + return np.empty(0) + i = np.arange(1, grad.size - 1) + i = i[(grad[i] >= grad[i - 1]) & (grad[i] > grad[i + 1]) & (grad[i] >= 0.4 * peak)] + a, b, c = grad[i - 1], grad[i], grad[i + 1] + denom = a - 2 * b + c + safe = np.where(np.abs(denom) > 1e-9, denom, 1.0) + return i + np.where(np.abs(denom) > 1e-9, 0.5 * (a - c) / safe, 0.0) + + +def _sample(lum, horizontal, stations, center, half, nearest): + img = lum if horizontal else lum.T + h, w = img.shape + xs, ys = [], [] + for x, c in zip(stations, center): + x0 = int(round(x)) + lo, hi = int(max(0, math.floor(c - half))), int(min(h, math.ceil(c + half) + 1)) + if hi - lo < 5 or x0 < 1 or x0 > w - 2: + continue + peaks = _profile_peaks(img[lo:hi, x0 - 1 : x0 + 2].mean(axis=1)) + if peaks.size == 0: + continue + if nearest: + peaks = peaks[[int(np.argmin(np.abs(peaks - (c - lo))))]] + xs.extend([x] * peaks.size) + ys.extend(lo + peaks) + return np.asarray(xs), np.asarray(ys) + + +@pytest.mark.parametrize("horizontal", [True, False]) +@pytest.mark.parametrize("nearest", [False, True]) +def test_skew_sample_matches_numpy(horizontal, nearest): + rng = np.random.default_rng(31) + lum = rng.random((180, 260), dtype=np.float32) * np.float32(0.05) + lum[60:, :] += np.float32(0.8) + lum[:, 200:] += np.float32(0.3) + lum[:, 7] = 1.0 + stations = np.linspace(4.0, (260 if horizontal else 180) - 5.0, 37) + center = np.full(stations.size, 61.0) + np.linspace(-3.0, 3.0, stations.size) + want = _sample(lum, horizontal, stations, center, 14.0, nearest) + got = negpy_core.skew_sample(lum, horizontal, stations, center, 14.0, nearest) + assert want[0].tobytes() == got[0].tobytes() + assert want[1].tobytes() == got[1].tobytes() + + +def _ransac_line_numpy(xs, ys, expected, half, span): + if xs.size < 12: + return None + rng = np.random.default_rng(0) + a, b = rng.integers(0, xs.size, (2, 300)) + ok = np.abs(xs[a] - xs[b]) >= 0.3 * span + a, b = a[ok], b[ok] + if a.size == 0: + return None + slope = (ys[b] - ys[a]) / (xs[b] - xs[a]) + ok = np.abs(slope) <= math.tan(math.radians(4.0)) + a, b, slope = a[ok], b[ok], slope[ok] + if a.size == 0: + return None + intercept = ys[a] - slope * xs[a] + inlier = np.abs(ys[None, :] - (slope[:, None] * xs[None, :] + intercept[:, None])) <= 1.5 + stations = np.array([np.unique(xs[row]).size for row in inlier]) + mid = slope * (0.5 * (xs.min() + xs.max())) + intercept + score = stations * (1.0 - 0.5 * np.minimum(1.0, np.abs(mid - expected) / half)) + best = int(np.argmax(score)) + if stations[best] < 12: + return None + return np.polyfit(xs[inlier[best]], ys[inlier[best]], 1) + + +def _fit_side_numpy(lum, horizontal, at, start, stop, outward=0): + """skew.py's NumPy _fit_side, the reference for negpy_core.skew_fit_side.""" + h, w = lum.shape if horizontal else lum.T.shape + length = stop - start + if length < 0.25 * w or not (at >= 2 and at <= h - 3): + return None + stations = np.linspace(start + 0.1 * length, stop - 0.1 * length, int(min(240, max(24, length / 4)))) + plane = np.ascontiguousarray(lum, dtype=np.float32) + + def sample(center, half, nearest): + return negpy_core.skew_sample(plane, horizontal, stations, np.ascontiguousarray(center, dtype=np.float64), float(half), nearest) + + inside = max(10.0, 0.035 * max(h, w)) + outside = inside if outward == 0 else max(16.0, 0.02 * h) + center = float(at) + 0.5 * outward * (outside - inside) + half = 0.5 * (inside + outside) + xs, ys = sample(np.full(stations.size, center), half, False) + fit = _ransac_line_numpy(xs, ys, float(at), half, length) + if fit is None: + return None + xs, ys = sample(np.polyval(fit, stations), max(6.0, 0.3 * inside), True) + if xs.size < 12: + return None + keep = np.ones(xs.size, bool) + for _ in range(8): + fit = np.polyfit(xs[keep], ys[keep], 1) + res = ys - np.polyval(fit, xs) + mad = 1.4826 * float(np.median(np.abs(res[keep] - np.median(res[keep])))) + new = np.abs(res) <= max(1.0, 2.5 * mad) + if new.sum() < 12 or np.array_equal(new, keep): + break + keep = new + inliers = float(keep.sum()) / stations.size + rms = float(np.sqrt(np.mean((ys[keep] - np.polyval(fit, xs[keep])) ** 2))) + if inliers < 0.5 or rms > 2.0: + return None + x1, x2 = float(xs[keep].min()), float(xs[keep].max()) + if x2 - x1 < 0.25 * w: + return None + y1, y2 = float(np.polyval(fit, x1)), float(np.polyval(fit, x2)) + p1, p2 = ((x1, y1), (x2, y2)) if horizontal else ((y1, x1), (y2, x2)) + return p1, p2, horizontal, inliers, rms + + +def _frame(angle=1.2, size=(420, 600), seed=5): + """A bright film box on a dark bed, turned by `angle` degrees, with grain.""" + h, w = size + y, x = np.mgrid[0:h, 0:w].astype(np.float64) + t = math.radians(angle) + u = (x - w / 2) * math.cos(t) + (y - h / 2) * math.sin(t) + v = -(x - w / 2) * math.sin(t) + (y - h / 2) * math.cos(t) + lum = np.where((np.abs(u) < 0.38 * w) & (np.abs(v) < 0.36 * h), 0.85, 0.08) + return (lum + np.random.default_rng(seed).normal(0, 0.01, lum.shape)).astype(np.float32) + + +def _close(a, b, tol=1e-6): + if a is None or b is None: + return a is None and b is None + flat = lambda line: np.array([*line[0], *line[1], line[3], line[4]]) # noqa: E731 + return a[2] == b[2] and np.allclose(flat(a), flat(b), rtol=0, atol=tol) + + +@pytest.mark.parametrize("angle", [-3.0, -0.4, 0.0, 0.9, 2.5]) +@pytest.mark.parametrize("horizontal", [True, False]) +def test_fit_side_matches_numpy(angle, horizontal): + lum = _frame(angle, seed=int(angle * 10) + 50) + h, w = lum.shape + sides = ( + [(0.14 * h, 0.12 * w, 0.88 * w, -1), (0.86 * h, 0.12 * w, 0.88 * w, 1)] + if horizontal + else [(0.12 * w, 0.14 * h, 0.86 * h, -1), (0.88 * w, 0.14 * h, 0.86 * h, 1)] + ) + found = 0 + for at, start, stop, outward in sides: + for out in (outward, 0): + want = _fit_side_numpy(lum, horizontal, at, start, stop, out) + got = negpy_core.skew_fit_side(lum, horizontal, at, start, stop, out) + assert _close(got, want), (at, out, got, want) + found += want is not None + assert found > 0 diff --git a/tests/test_core_warp.py b/tests/test_core_warp.py new file mode 100644 index 000000000..2d2f21159 --- /dev/null +++ b/tests/test_core_warp.py @@ -0,0 +1,93 @@ +import cv2 +import negpy_core +import numpy as np +import pytest +from core_oracle import cv2_oracle + +from negpy.features.geometry.logic import keystone_matrix + +pytestmark = cv2_oracle + +_RNG = np.random.default_rng(11) +_REPLICATE = {"flags": cv2.INTER_LINEAR, "borderMode": cv2.BORDER_REPLICATE} +_INTERP = {"nearest": cv2.INTER_NEAREST, "linear": cv2.INTER_LINEAR, "cubic": cv2.INTER_CUBIC} +_BORDER = {"replicate": cv2.BORDER_REPLICATE, "constant": cv2.BORDER_CONSTANT} +_MODES = [(i, b) for i in ("linear", "cubic") for b in _BORDER] + + +def _image(h, w, cn): + return _RNG.random((h, w, cn) if cn > 1 else (h, w), dtype=np.float32) * 3 - 1 + + +@pytest.mark.parametrize("mode", [*_MODES, ("nearest", "replicate"), ("nearest", "constant")]) +@pytest.mark.parametrize("cn", [1, 2, 3, 4, 5]) +@pytest.mark.parametrize("shape, dshape", [((97, 131), (80, 150)), ((1, 1), (3, 4)), ((3, 2), (9, 9)), ((300, 517), (300, 517))]) +def test_remap_matches_cv2(cn, shape, dshape, mode): + if mode[0] == "cubic" and cn > 4: + pytest.skip("cv2 interpolates bicubic up to 4 channels") + img = _image(*shape, cn) + mx = (_RNG.random(dshape) * (shape[1] + 20) - 10).astype(np.float32) + my = (_RNG.random(dshape) * (shape[0] + 20) - 10).astype(np.float32) + want = cv2.remap(img, mx, my, _INTERP[mode[0]], borderMode=_BORDER[mode[1]]) + assert negpy_core.remap(img, mx, my, *mode).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("mode", _MODES) +@pytest.mark.parametrize("cn", [1, 3, 4]) +@pytest.mark.parametrize("angle", [0.7, -3.3, 12.0]) +@pytest.mark.parametrize("shape", [(97, 131), (2, 3)]) +def test_warp_affine_modes_match_cv2(cn, angle, shape, mode): + img = _image(*shape, cn) + h, w = shape + m = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 0.9).astype(np.float32).astype(np.float64) + m[:, 2] += (5.5, -3.25) + want = cv2.warpAffine(img, m, (w + 9, h + 4), flags=_INTERP[mode[0]], borderMode=_BORDER[mode[1]]) + got = negpy_core.warp_affine(img, m, (w + 9, h + 4), interpolation=mode[0], border=mode[1]) + assert got.tobytes() == want.tobytes() + + +@pytest.mark.parametrize("cn", [1, 3, 4]) +@pytest.mark.parametrize("angle", [0.7, -3.3, 12.0, 90.0]) +@pytest.mark.parametrize("shape", [(97, 131), (513, 260), (2, 3)]) +def test_warp_affine_matches_cv2(cn, angle, shape): + img = _image(*shape, cn) + h, w = shape + m = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) + for dsize in ((w, h), (w + 7, h - 1)): + want = cv2.warpAffine(img, m, dsize, **_REPLICATE) + assert negpy_core.warp_affine(img, m, dsize).tobytes() == want.tobytes() + inv = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR | cv2.WARP_INVERSE_MAP, borderMode=cv2.BORDER_REPLICATE) + assert negpy_core.warp_affine(img, m, (w, h), inverse_map=True).tobytes() == inv.tobytes() + + +@pytest.mark.parametrize("cn", [1, 3]) +@pytest.mark.parametrize("converge", [(3.0, -2.0), (-7.5, 1.25), (0.0, 20.0)]) +@pytest.mark.parametrize("shape", [(97, 131), (700, 1030), (5, 9)]) +def test_warp_perspective_matches_cv2(cn, converge, shape): + img = _image(*shape, cn) + h, w = shape + m = keystone_matrix(*converge, w, h) + want = cv2.warpPerspective(img, m, (w, h), **_REPLICATE) + assert negpy_core.warp_perspective(img, m, (w, h)).tobytes() == want.tobytes() + + +@pytest.mark.parametrize("mode", _MODES) +def test_warp_perspective_modes_match_cv2(mode): + img = _image(97, 131, 3) + m = keystone_matrix(5.0, -3.0, 131, 97) + want = cv2.warpPerspective(img, m, (131, 97), flags=_INTERP[mode[0]], borderMode=_BORDER[mode[1]]) + assert negpy_core.warp_perspective(img, m, (131, 97), interpolation=mode[0], border=mode[1]).tobytes() == want.tobytes() + + +def test_warps_reject_bad_input(): + img = _image(10, 10, 3) + with pytest.raises(ValueError): + negpy_core.warp_affine(img[:, ::2], np.eye(2, 3), (5, 10)) + with pytest.raises(ValueError): + negpy_core.warp_perspective(img, np.eye(2, 3), (10, 10)) + with pytest.raises(ValueError): + negpy_core.remap(img, np.zeros((4, 4), np.float32), np.zeros((4, 5), np.float32)) + with pytest.raises(ValueError): + negpy_core.warp_affine(img, np.eye(2, 3), (10, 10), interpolation="nearest") + with pytest.raises(ValueError): + negpy_core.remap(img, np.zeros((4, 4), np.float32), np.zeros((4, 4), np.float32), border="reflect") diff --git a/tests/test_core_zones.py b/tests/test_core_zones.py new file mode 100644 index 000000000..74e4e9b90 --- /dev/null +++ b/tests/test_core_zones.py @@ -0,0 +1,96 @@ +"""The zone, loupe and vectorscope reads against the cv2 and NumPy code they replace.""" + +import cv2 +import numpy as np +from core_oracle import cv2_oracle + +import negpy_core +from negpy.features.exposure import analysis as A +from negpy.kernel.image.logic import get_luminance + +pytestmark = cv2_oracle + + +def _frame(seed, shape=(900, 1300)): + rng = np.random.default_rng(seed) + h, w = shape + y, x = np.mgrid[0:h, 0:w].astype(np.float32) + base = 0.5 + 0.45 * np.sin(x / (w / 3.0) + seed) * np.cos(y / (h / 2.5)) + rgb = np.stack([base, base * 0.9, base * 1.05], axis=2) + rng.normal(0, 0.02, (h, w, 3)) + return np.clip(rgb, 0, 1).astype(np.float32) + + +def _zone_field_cv2(rgb, max_edge=A.ZONE_FIELD_MAX_EDGE): + h, w = rgb.shape[:2] + step = max(1, max(h, w) // (2 * max_edge)) + small = np.ascontiguousarray(rgb[::step, ::step, :3], dtype=np.float32) + scale = min(1.0, max_edge / max(small.shape[:2])) + sh, sw = small.shape[:2] + lum = get_luminance(cv2.resize(small, (max(1, round(sw * scale)), max(1, round(sh * scale))), interpolation=cv2.INTER_AREA)) + lum = cv2.GaussianBlur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), 2.0) + zones = np.clip(np.rint(np.nan_to_num(A.zone_of_encoded(lum))), 0, A.ZONE_COUNT).astype(np.uint8) + return cv2.medianBlur(zones, 5) + + +def _anchors_cv2(zones, min_share=0.01): + out = [] + for zone in np.unique(zones): + count, comp, stats, _ = cv2.connectedComponentsWithStats((zones == zone).astype(np.uint8), connectivity=4) + for i in range(1, count): + if stats[i, cv2.CC_STAT_AREA] < min_share * zones.size: + continue + mask = (comp == i).astype(np.uint8) + mask[0, :] = mask[-1, :] = 0 + mask[:, 0] = mask[:, -1] = 0 + dist = cv2.distanceTransform(mask, cv2.DIST_L2, 3) + y, x = np.unravel_index(int(np.argmax(dist)), dist.shape) + out.append((int(x), int(y), int(zone), float(dist[y, x]), dist)) + return out + + +def test_zone_field_matches_cv2(): + for seed in range(3): + rgb = _frame(seed) + assert A.zone_field(rgb).tobytes() == _zone_field_cv2(rgb).tobytes() + + +def test_zone_anchors_sit_at_a_most_interior_pixel(): + """Quality gate: cv2's distances run through IPP here, so an anchor may take another pixel + of the same depth; it must sit within 1e-4 of cv2's deepest point, with the same regions.""" + for seed in range(4): + zones = A.zone_field(_frame(seed)) + want = _anchors_cv2(zones) + got = A.zone_anchors(zones) + assert [g[2] for g in got] == [w[2] for w in want] + for (x, y, _, inset), (_, _, _, depth, dist) in zip(got, want): + assert abs(inset - depth) <= 1e-4 * max(1.0, depth) + assert dist[y, x] >= depth - 1e-4 * max(1.0, depth) + + +def test_loupe_and_labels_match_numpy(): + for seed in range(3): + rgb = _frame(seed, (60, 90)) + lum = get_luminance(rgb) + want = float(np.sqrt(np.mean(cv2.Laplacian(lum, cv2.CV_32F) ** 2)) * 100.0) + assert A.loupe_acutance(rgb) == want + cells = A.zone_grid(_frame(seed)) + labels = [] + for zone in np.unique(cells): + count, comp, _, centroids = cv2.connectedComponentsWithStats((cells == zone).astype(np.uint8), connectivity=4) + for i in range(1, count): + ys, xs = np.nonzero(comp == i) + j = int(np.argmin((xs - centroids[i][0]) ** 2 + (ys - centroids[i][1]) ** 2)) + labels.append((int(xs[j]), int(ys[j]), int(zone))) + assert A.zone_region_labels(cells) == labels + + +def test_vectorscope_density_matches_numpy(): + counts = np.random.default_rng(2).integers(0, 50, (A.SCOPE_HUE_BINS, A.SCOPE_CHROMA_BINS)).astype(float) + for size, log in ((256, True), (180, False)): + idx, area, orphans, px = negpy_core.scope_lookup(size, log) + flat = counts.ravel() + want = np.zeros(idx.shape) + inside = idx >= 0 + want[inside] = flat[idx[inside]] / np.maximum(area[idx[inside]], 1.0) + np.add.at(want.reshape(-1), px, flat[orphans]) + assert A.vectorscope_density(counts, size, log).tobytes() == want.tobytes() diff --git a/tests/test_density_above_base.py b/tests/test_density_above_base.py index b3c1d91cb..fcda54066 100644 --- a/tests/test_density_above_base.py +++ b/tests/test_density_above_base.py @@ -47,7 +47,7 @@ def _band_frame(fill: list[tuple[float, float, float]]) -> tuple[np.ndarray, np. def test_the_largest_flat_group_is_the_base(): """A flat sliver of bare light, brighter than the rebate, is the smaller group.""" - from negpy.features.exposure.normalization import _base_blocks + from negpy_core import base_blocks as _base_blocks det, band = _band_frame([(0.84, 0.002, 0.30), (0.47, 0.004, 0.70)]) @@ -58,7 +58,7 @@ def test_the_largest_flat_group_is_the_base(): def test_clipped_light_is_never_the_base(): - from negpy.features.exposure.normalization import _base_blocks + from negpy_core import base_blocks as _base_blocks det, band = _band_frame([(0.995, 0.001, 0.70), (0.60, 0.004, 0.30)]) # holes at sensor white @@ -69,7 +69,7 @@ def test_clipped_light_is_never_the_base(): def test_a_band_with_no_flat_film_reads_nothing(): - from negpy.features.exposure.normalization import _base_blocks + from negpy_core import base_blocks as _base_blocks det, band = _band_frame([(0.5, 0.2, 1.0)]) # texture everywhere, no flat block diff --git a/tests/test_export_pipelining.py b/tests/test_export_pipelining.py index 9f8a70c36..3a0b97ed4 100644 --- a/tests/test_export_pipelining.py +++ b/tests/test_export_pipelining.py @@ -470,3 +470,60 @@ def test_overwrite_never_replaces_another_frame_of_the_same_batch(tmp_path) -> N worker.run_batch([_task(src, "DSC_0001.NEF", preset), _task(src, "DSC_0001.JPG", preset)]) assert sorted(p.read_bytes() for p in tmp_path.glob("*.jpg")) == [b"FIRST", b"SECOND"] + + +def test_encode_slots_follow_known_ram_above_the_reserve() -> None: + from negpy.desktop.workers import export as export_worker + + gib = 1 << 30 + reserve = export_worker.MIN_RAM_RESERVE_BYTES + assert export_worker.encode_slots(gib, None) == 1 + assert export_worker.encode_slots(gib, reserve) == 1 + assert export_worker.encode_slots(gib, reserve + 4 * gib) == min(2, export_worker._ENCODE_WORKERS) + assert export_worker.encode_slots(gib, 1 << 50) == export_worker._ENCODE_WORKERS + + +def _two_encoders(monkeypatch) -> None: + from negpy.desktop.workers import export as export_worker + + monkeypatch.setattr(export_worker, "_ENCODE_WORKERS", 2) + monkeypatch.setattr(export_worker, "available_system_memory_bytes", lambda: 1 << 50) + + +def test_two_encodes_at_once_never_share_an_output_name(tmp_path, monkeypatch) -> None: + _two_encoders(monkeypatch) + worker, proc = _worker() + both = threading.Barrier(2, timeout=10) + proc.encode_export.side_effect = lambda buffer, *_a, **_k: (both.wait(), (b"X", "jpg"))[1] + preset = _preset(tmp_path, overwrite=True) + src = tmp_path / "src" + src.mkdir() + + worker.run_batch([_task(src, "DSC_0001.NEF", preset), _task(src, "DSC_0001.JPG", preset)]) + + assert len(list(tmp_path.glob("*.jpg"))) == 2 + + +def test_errors_of_parallel_encodes_keep_the_task_order(tmp_path, monkeypatch) -> None: + _two_encoders(monkeypatch) + worker, proc = _worker() + renders = iter(np.full((4, 4, 3), i, np.float32) for i in range(2)) + proc.render_export.side_effect = lambda *_a, **_k: (next(renders), "sRGB") + second_done = threading.Event() + + def _encode(buffer, *_a, **_k): + index = int(buffer[0, 0, 0]) + if index == 0: + second_done.wait(timeout=10) + else: + second_done.set() + return None, f"boom {index}" + + proc.encode_export.side_effect = _encode + errors: list[str] = [] + worker.error.connect(errors.append) + + worker.run_batch([_task(tmp_path, "a.cr2"), _task(tmp_path, "b.cr2")]) + + assert second_done.is_set() + assert errors == ["boom 0", "boom 1"] diff --git a/tests/test_file_modes.py b/tests/test_file_modes.py index 8c117ce3b..3c31b5d82 100644 --- a/tests/test_file_modes.py +++ b/tests/test_file_modes.py @@ -3,6 +3,7 @@ import os import pathlib import stat +import threading from types import SimpleNamespace import numpy as np @@ -35,6 +36,7 @@ def test_an_export_takes_the_umask_mode(tmp_path, monkeypatch): worker = export.ExportWorker.__new__(export.ExportWorker) worker._processor = SimpleNamespace(encode_export=lambda *a, **k: (b"jpeg bytes", "")) worker._written = set() + worker._names_lock = threading.Lock() monkeypatch.setattr(export, "_export_resolution", lambda task: None) monkeypatch.setattr(export, "resolve_export_naming", lambda task: (str(tmp_path), "frame", "jpg")) monkeypatch.setattr(export, "_export_target_is_a_source", lambda path, task: False) diff --git a/tests/test_finish_logic.py b/tests/test_finish_logic.py index ec2e5b6a9..3c9025277 100644 --- a/tests/test_finish_logic.py +++ b/tests/test_finish_logic.py @@ -4,7 +4,6 @@ CARRIER_SAMPLES, apply_carrier, apply_vignette, - carrier_noise, carrier_profiles, linear_carrier_tone, ) @@ -259,16 +258,6 @@ def test_flare_deterministic(self) -> None: b = apply_carrier(img, width_px=8.0, rough=1.0, flare=0.7) np.testing.assert_array_equal(a, b) - def test_noise_is_two_dimensional_and_bounded(self) -> None: - x = np.linspace(0.0, 12.0, 64, dtype=np.float32)[None, :] - y = np.linspace(0.0, 12.0, 48, dtype=np.float32)[:, None] - n = carrier_noise(x, y) - self.assertEqual(n.shape, (48, 64)) - self.assertLessEqual(float(np.abs(n).max()), 1.0) - np.testing.assert_array_equal(n, carrier_noise(x, y)) - # Genuinely 2-D: rows differ, so the field is not a function of x alone. - self.assertGreater(float(np.abs(n[0] - n[20]).max()), 0.05) - def test_filed_edge_is_not_a_height_field(self) -> None: """The 2-D field must be able to overhang and shed flecks — a 1-D edge cannot.""" img = np.full((300, 400, 3), 0.5, dtype=np.float32) diff --git a/tests/test_geometry_distortion.py b/tests/test_geometry_distortion.py index d301e344b..7b5ed8897 100644 --- a/tests/test_geometry_distortion.py +++ b/tests/test_geometry_distortion.py @@ -4,8 +4,10 @@ import numpy as np import pytest +from core_oracle import cv2_oracle + from negpy.features.geometry.logic import ( - _radial_maps, + _radial_center, apply_radial_distortion, compute_distortion_scale, map_point_radial, @@ -16,6 +18,30 @@ _K1_RANGE = [-0.1, -0.02, -0.001, 0.001, 0.02, 0.1] +def _radial_maps(k1: float, w: int, h: int) -> tuple[np.ndarray, np.ndarray]: + """The cv2.remap source maps of the radial correction, built in NumPy.""" + cx, cy, halfdiag = _radial_center(w, h) + s = compute_distortion_scale(k1, w, h) + inv_hd2 = 1.0 / (halfdiag * halfdiag) + ys, xs = np.meshgrid(np.arange(h, dtype=np.float32), np.arange(w, dtype=np.float32), indexing="ij") + px = (xs - cx) * s + py = (ys - cy) * s + f = 1.0 + k1 * (px * px + py * py) * inv_hd2 + return (cx + px * f).astype(np.float32), (cy + py * f).astype(np.float32) + + +@cv2_oracle +@pytest.mark.parametrize("k1", _K1_RANGE) +@pytest.mark.parametrize("shape", [(60, 90, 3), (131, 77), (40, 50, 4)]) +def test_correction_is_cv2_remap_of_the_numpy_maps(k1, shape): + import cv2 + + img = np.random.default_rng(3).random(shape, dtype=np.float32) + map_x, map_y = _radial_maps(k1, shape[1], shape[0]) + want = cv2.remap(img, map_x, map_y, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + assert apply_radial_distortion(img, k1).tobytes() == want.tobytes() + + def test_zero_k1_is_identity(): img = np.random.rand(40, 60, 3).astype(np.float32) out = apply_radial_distortion(img, 0.0) @@ -209,28 +235,6 @@ def test_mapping_many_points_solves_the_distortion_scale_once(): assert logic.compute_distortion_scale.cache_info().misses == 1 -@pytest.mark.parametrize( - "warp", - [ - lambda img: logic_mod().apply_fine_rotation(img, 0.7), - lambda img: logic_mod().apply_radial_distortion(img, -0.08), - lambda img: logic_mod().apply_keystone(img, 6.0, -4.0), - ], -) -def test_a_tiled_warp_matches_the_whole_frame_warp(warp, monkeypatch): - logic = logic_mod() - import cv2 - - # Smooth, so OpenCV's 1/32 px sample quantization cannot dominate the comparison. - noise = np.random.default_rng(2).uniform(0, 1, (210, 330, 3)).astype(np.float32) - img = cv2.GaussianBlur(noise, (0, 0), 4.0) - whole = warp(img) - monkeypatch.setattr(logic, "_CV_WARP_MAX", 100) - monkeypatch.setattr(logic, "_WARP_TILE", 64) - tiled = warp(img) - np.testing.assert_allclose(tiled, whole, atol=2e-3) - - def test_a_frame_past_the_opencv_size_limit_still_warps(): logic = logic_mod() img = np.full((24, 33000, 3), 0.5, dtype=np.float32) diff --git a/tests/test_geometry_logic.py b/tests/test_geometry_logic.py index dadc03a4c..73e9bb476 100644 --- a/tests/test_geometry_logic.py +++ b/tests/test_geometry_logic.py @@ -1659,7 +1659,7 @@ def test_edge_walk_reads_a_wide_scanner_rebate_the_same_as_the_ring(): def test_film_surround_must_be_near_clipping_to_read_as_bed(): - from negpy.features.geometry.logic import _film_surround_is_plausible + from negpy_core import film_surround_is_plausible as _film_surround_is_plausible # A blown region on a thin negative clears the brightness bar but not the histogram # top, where the light bed sits. Accepting it cropped a frame to a third of itself. @@ -1673,7 +1673,7 @@ def test_film_surround_must_be_near_clipping_to_read_as_bed(): def test_a_film_box_must_cover_a_quarter_of_the_frame(): - from negpy.features.geometry.logic import _film_box_covers_enough + from negpy_core import film_box_covers_enough as _film_box_covers_enough # A positive whose bed clips to white leaves a dark subject satisfying every other # check on its own geometry. Film fills most of a scan, so a box this small is not it. diff --git a/tests/test_ir_dust.py b/tests/test_ir_dust.py index 20fa2093f..7b9f26909 100644 --- a/tests/test_ir_dust.py +++ b/tests/test_ir_dust.py @@ -2,6 +2,7 @@ import tempfile import cv2 +import negpy_core import numpy as np import pytest import tifffile @@ -1043,14 +1044,14 @@ def test_downsample_ir_bounds_large_rgb_work_blocks(monkeypatch): plane[100:104, 40:320] = 0.2 source_bytes = plane.nbytes seen = [] - erode = cv2.erode + morphology = logic.negpy_core.morphology_ex - def tracked(source, kernel): + def tracked(source, op, kernel, *args): seen.append(source.nbytes) - return erode(source, kernel) + return morphology(source, op, kernel, *args) monkeypatch.setattr(logic, "_IR_DOWNSAMPLE_WORK_BYTES", 32 * 1024) - monkeypatch.setattr(logic.cv2, "erode", tracked) + monkeypatch.setattr(logic.negpy_core, "morphology_ex", tracked) result = logic.downsample_ir(plane, 90) assert result.shape == (60, 90, 3) @@ -1165,7 +1166,7 @@ def test_ir_attenuation_is_the_upsampled_gain_product(): img = _noisy_frame(64, 96, seed=4) rng = np.random.default_rng(5) gain_det = rng.uniform(1.0, 2.0, (16, 24, 3)).astype(np.float32) - expected = img * cv2.resize(gain_det, (96, 64), interpolation=cv2.INTER_LINEAR) + expected = img * negpy_core.resize_linear(gain_det, (96, 64)) out = apply_ir_attenuation(img, gain_det) assert out.dtype == np.float32 assert np.array_equal(out, expected.astype(np.float32)) diff --git a/tests/test_lab_logic.py b/tests/test_lab_logic.py index 05143dd78..07436db77 100644 --- a/tests/test_lab_logic.py +++ b/tests/test_lab_logic.py @@ -55,18 +55,6 @@ def test_clahe_flat_image_near_identity(self) -> None: res = apply_clahe(img, 1.0) np.testing.assert_allclose(res, img, atol=0.02) - def test_clahe_cdf_invariants(self) -> None: - """Per-tile CDFs are monotone and end exactly at 1.0 — the excess - redistribution conserves the tile total, mirroring clahe_cdf.wgsl.""" - from negpy.features.lab.logic import _clahe_cdfs - - rng = np.random.default_rng(7) - bins = rng.integers(0, 256, (128, 128)).astype(np.int32) - cdfs = _clahe_cdfs(bins, 2.5) - self.assertEqual(cdfs.shape, (64, 256)) - self.assertTrue(np.all(np.diff(cdfs, axis=1) >= 0)) - self.assertTrue(np.all(cdfs[:, -1] == 1.0)) - def test_output_sharpening(self) -> None: """Sharpening should increase local variance.""" # Create a simple square diff --git a/tests/test_lab_row_blocks.py b/tests/test_lab_row_blocks.py index b4b9ced75..5eb61f6bd 100644 --- a/tests/test_lab_row_blocks.py +++ b/tests/test_lab_row_blocks.py @@ -7,7 +7,6 @@ _usm_sharpening_block, _saturation_block, ) -from negpy.kernel.system import parallel @pytest.mark.parametrize("radius,mask", [(0.1, 0.0), (1.0, 0.0), (3.0, 0.6)]) @@ -20,11 +19,9 @@ def test_sharpen_blocks_match_full_frame(radius, mask): np.testing.assert_array_equal(image, original) -@pytest.mark.parametrize("parallel_enabled", [False, True]) @pytest.mark.parametrize("tail_rows", [1, 21, 22]) @pytest.mark.parametrize("saturation,skin", [(1.0, 1.0), (0.6, 0.7)]) -def test_saturation_short_tail_matches_full_frame(monkeypatch, parallel_enabled, tail_rows, saturation, skin): - monkeypatch.setattr(parallel, "_parallel_enabled", parallel_enabled) +def test_saturation_short_tail_matches_full_frame(tail_rows, saturation, skin): image = np.random.default_rng(991).uniform(0.001, 0.999, (3072 + tail_rows, 1024, 3)).astype(np.float32) image[::7] = 0.0 image[1::11] = 1.0 diff --git a/tests/test_manual_bake_incremental.py b/tests/test_manual_bake_incremental.py index bd5d4f269..ffae54433 100644 --- a/tests/test_manual_bake_incremental.py +++ b/tests/test_manual_bake_incremental.py @@ -140,27 +140,22 @@ def counting(image, s, spots): assert seen_counts == [1, 1, 1], f"expected one new stroke scored per call, got {seen_counts}" def test_appending_a_stroke_only_fills_the_new_component(self): - """apply_score_repair does the actual pixel fill per connected component; a - previously-filled, untouched component must not be redone. repair_components - looks the name up in its own defining module (retouch.logic), not image_processor's - imported reference to it, so that is what has to be patched.""" + """repair_components re-fills only the components that hold a dirty pixel; an + untouched one keeps what base_out already holds there.""" from negpy.features.retouch import logic as retouch_logic img, strokes = _scene() - proc = ImageProcessor() - call_counts: list[int] = [] - real = retouch_logic.apply_score_repair - - def counting(*a, **k): - call_counts.append(1) - return real(*a, **k) - - with patch.object(retouch_logic, "apply_score_repair", side_effect=counting): - for n in range(1, len(strokes) + 1): - call_counts.clear() - settings = _config(strokes[:n]) - proc._manual_bake(img, settings, _source_key(settings)) - assert call_counts == [1], f"expected exactly one fill for the new component at {n} strokes, got {len(call_counts)}" + score = retouch_logic.strokes_to_score(img, strokes, []) + assert score is not None + base = np.full_like(img, 7.0) + dirty = np.zeros(img.shape[:2], dtype=bool) + dirty[188:196, 58:66] = True # the newest stroke only + out = np.asarray(retouch_logic.repair_components(img, score, floor=False, base_out=base, dirty=dirty)) + fresh = np.asarray(retouch_logic.repair_components(img, score, floor=False)) + for y, x in ((31, 31), (121, 121)): + np.testing.assert_array_equal(out[y, x], base[y, x]) + np.testing.assert_array_equal(out[191, 61], fresh[191, 61]) + assert not np.array_equal(out[191, 61], base[191, 61]) def test_dust_removal_on_detects_once_and_stays_incremental(self): img, strokes = _scene() diff --git a/tests/test_meter_fast_paths.py b/tests/test_meter_fast_paths.py index 371978a99..173148fd3 100644 --- a/tests/test_meter_fast_paths.py +++ b/tests/test_meter_fast_paths.py @@ -64,22 +64,15 @@ def test_sorted_grid_is_ignored_when_a_roi_still_has_to_be_applied(self): class TestToLogDensity(unittest.TestCase): - def test_matches_the_nan_to_num_clip_it_replaces(self): + def test_is_the_correctly_rounded_log_of_the_clamped_value(self): eps = 1e-6 - for dtype in (np.float32, np.float64): - x = (np.random.default_rng(1).random((97, 89, 3)) * 1.4 - 0.2).astype(dtype) - x[0, 0] = np.nan - x[1, 1] = np.inf - x[2, 2] = -np.inf - x[3, 3] = 0.0 - x[4, 4] = 1.0 - x[5, 5] = eps - x[6, 6] = -5.0 - x[7, 7] = 1e30 - expected = np.log10(np.clip(np.nan_to_num(x, nan=eps, posinf=1.0, neginf=eps), eps, 1.0)) - got = to_log_density(x) - self.assertEqual(expected.dtype, got.dtype) - self.assertTrue(np.array_equal(expected, got), msg=f"{dtype.__name__} differs") + x = (np.random.default_rng(1).random((97, 89, 3)) * 1.4 - 0.2).astype(np.float32) + x[:8, :8] = np.array([np.nan, np.inf, -np.inf, 0.0, 1.0, eps, -5.0, 1e30], dtype=np.float32)[:, None, None] + clamped = np.clip(np.nan_to_num(x, nan=eps, posinf=1.0, neginf=eps), np.float32(eps), 1.0) + expected = np.log10(clamped.astype(np.float64)).astype(np.float32) + got = to_log_density(x) + self.assertEqual(got.dtype, np.float32) + self.assertTrue(np.array_equal(expected, got)) if __name__ == "__main__": diff --git a/tests/test_no_cv2.py b/tests/test_no_cv2.py new file mode 100644 index 000000000..058b8692e --- /dev/null +++ b/tests/test_no_cv2.py @@ -0,0 +1,12 @@ +"""negpy never imports cv2: negpy_core holds every kernel.""" + +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parents[1] / "negpy" + + +def test_negpy_imports_no_cv2(): + pattern = re.compile(r"^\s*(import cv2|from cv2\b)", re.MULTILINE) + offenders = [str(p.relative_to(ROOT)) for p in ROOT.rglob("*.py") if pattern.search(p.read_text(encoding="utf-8"))] + assert not offenders, f"cv2 imported in {offenders}; port the call to negpy_core" diff --git a/tests/test_numba_cache.py b/tests/test_numba_cache.py deleted file mode 100644 index 488570c03..000000000 --- a/tests/test_numba_cache.py +++ /dev/null @@ -1,44 +0,0 @@ -import ast -from pathlib import Path - -_ROOT = Path(__file__).resolve().parents[1] / "negpy" - - -def _is_jit(decorator: ast.expr) -> bool: - target = decorator.func if isinstance(decorator, ast.Call) else decorator - name = target.attr if isinstance(target, ast.Attribute) else getattr(target, "id", "") - return name in ("njit", "jit", "parallel_njit") - - -def _inline_only(decorator: ast.expr) -> bool: - return ( - isinstance(decorator, ast.Call) - and _is_jit(decorator) - and any(k.arg == "inline" and getattr(k.value, "value", None) == "always" for k in decorator.keywords) - ) - - -def _module(path: Path) -> str: - return ".".join(path.relative_to(_ROOT.parent).with_suffix("").parts) - - -def test_python_code_never_calls_an_uncached_inline_kernel(): - """An inline helper has no cache, so a call from plain Python compiles it again on every launch.""" - trees = {_module(p): ast.parse(p.read_text(encoding="utf-8")) for p in _ROOT.rglob("*.py")} - inline = { - mod: {n.name for n in tree.body if isinstance(n, ast.FunctionDef) and any(_inline_only(d) for d in n.decorator_list)} - for mod, tree in trees.items() - } - offenders = [] - for mod, tree in trees.items(): - visible = set(inline[mod]) - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and node.module in inline: - visible |= {a.asname or a.name for a in node.names if a.name in inline[node.module]} - for fn in ast.walk(tree): - if not isinstance(fn, ast.FunctionDef) or any(_is_jit(d) for d in fn.decorator_list): - continue - for call in ast.walk(fn): - if isinstance(call, ast.Call) and getattr(call.func, "id", None) in visible: - offenders.append(f"{mod}:{call.lineno} {call.func.id}") - assert offenders == [] diff --git a/tests/test_numba_nogil.py b/tests/test_numba_nogil.py deleted file mode 100644 index 27af7e979..000000000 --- a/tests/test_numba_nogil.py +++ /dev/null @@ -1,32 +0,0 @@ -import importlib - -import pytest - -_KERNELS = [ - ("negpy.kernel.image.logic", name) - for name in ( - "_get_luminance_jit", - "_to_uint16_jit", - "_to_uint8_jit", - "uint8_to_float32", - "rgb_to_rgba_into", - "rgba_to_rgb_into", - "uint16_to_float32", - "_float_to_uint8_luma_jit", - "_float_to_uint16_luma_jit", - ) -] + [ - ("negpy.features.exposure.normalization", "_normalize_log_image_jit"), - ("negpy.features.exposure.logic", "_apply_print_curve_kernel"), - ("negpy.infrastructure.display.icc_lut", "_apply_lut_u16_jit"), - ("negpy.infrastructure.display.icc_lut", "_apply_lut_f32_jit"), - ("negpy.infrastructure.display.icc_lut", "_matrix_trc_u8_jit"), -] - - -@pytest.mark.parametrize("module, name", _KERNELS) -def test_full_image_kernels_release_the_gil(module, name): - kernel = getattr(importlib.import_module(module), name) - dispatchers = (kernel.serial, kernel.parallel) if hasattr(kernel, "serial") else (kernel,) - for d in dispatchers: - assert d.targetoptions.get("nogil") is True diff --git a/tests/test_parallel_dispatch.py b/tests/test_parallel_dispatch.py deleted file mode 100644 index 73a82ef21..000000000 --- a/tests/test_parallel_dispatch.py +++ /dev/null @@ -1,149 +0,0 @@ -"""The multi-core failsafe: parallel_njit dual dispatch, platform policy, override.""" - -import threading - -import numpy as np -import pytest -from numba import prange - -from negpy.kernel.system import parallel as par -from negpy.kernel.system.override import OverrideConfig, _parse -from negpy.kernel.system.parallel import default_cpu_parallel, parallel_njit - - -@pytest.fixture(autouse=True) -def _restore_flag(): - before = par.parallel_enabled() - yield - par.set_parallel_enabled(before) - - -@parallel_njit(fastmath=True) -def _double(arr): - out = np.empty_like(arr) - for i in prange(arr.shape[0]): - out[i] = arr[i] * 2.0 - return out - - -def test_serial_and_parallel_variants_never_share_a_cache_file(): - # Numba's disk cache keys on qualname and source line, so the serial variant takes its own - # qualname: a shared one could load the parallel binary and defeat the failsafe. - from negpy.features.exposure.logic import _apply_print_curve_kernel - from negpy.infrastructure.display.icc_lut import _apply_lut_f32_jit - - for kernel in (_apply_print_curve_kernel, _apply_lut_f32_jit): - serial, parallel = kernel.serial._cache, kernel.parallel._cache - assert type(serial).__name__ == type(parallel).__name__ == "FunctionCache" - assert serial._cache_file._index_name != parallel._cache_file._index_name - assert type(_double.serial._cache).__name__ == "NullCache" # no cache=True, no disk cache - - -def test_platform_policy(): - assert default_cpu_parallel("darwin") is False - assert default_cpu_parallel("win32") is True - assert default_cpu_parallel("linux") is True - - -def test_dispatch_uses_selected_variant(): - # At or above SERIAL_MAX_ELEMENTS; smaller inputs always take the serial variant. - arr = np.arange(par.SERIAL_MAX_ELEMENTS, dtype=np.float32) - - par.set_parallel_enabled(False) - np.testing.assert_array_equal(_double(arr), arr * 2.0) - assert _double.serial.signatures # serial variant compiled and used - assert not _double.parallel.signatures # parallel never touched while disabled - - par.set_parallel_enabled(True) - np.testing.assert_array_equal(_double(arr), arr * 2.0) - assert _double.parallel.signatures - - -def test_print_curve_parity_serial_vs_parallel(): - from negpy.features.exposure.logic import apply_characteristic_curve - - rng = np.random.default_rng(7) - img = rng.random((64, 96, 3), dtype=np.float32) - - par.set_parallel_enabled(True) - res_par = apply_characteristic_curve(img, (0.5, 2.0), (0.5, 2.0), (0.5, 2.0)) - par.set_parallel_enabled(False) - res_ser = apply_characteristic_curve(img, (0.5, 2.0), (0.5, 2.0), (0.5, 2.0)) - - np.testing.assert_allclose(res_par, res_ser, rtol=0, atol=1e-6) - - -def test_lab_roundtrip_parity_serial_vs_parallel(): - from negpy.kernel.image.logic import rgb_to_lab_working - - rng = np.random.default_rng(11) - img = rng.random((32, 48, 3), dtype=np.float32) - - par.set_parallel_enabled(True) - lab_par = rgb_to_lab_working(img) - par.set_parallel_enabled(False) - lab_ser = rgb_to_lab_working(img) - - np.testing.assert_allclose(lab_par, lab_ser, rtol=0, atol=1e-4) - - -def test_concurrent_parallel_invocation_is_serialized(): - # Regression for the workqueue concurrent-access abort: many threads invoking - # a parallel kernel simultaneously must be safe (the gate serializes entry). - par.set_parallel_enabled(True) - arr = np.arange(200_000, dtype=np.float32) - errors: list[Exception] = [] - - def hammer(): - try: - for _ in range(20): - _double(arr) - except Exception as e: # pragma: no cover - errors.append(e) - - threads = [threading.Thread(target=hammer) for _ in range(6)] - for t in threads: - t.start() - for t in threads: - t.join() - assert not errors - - -def test_override_parsing_and_apply(): - assert _parse({"performance": {"cpu_parallel": False}}).cpu_parallel is False - assert _parse({"performance": {"cpu_parallel": True}}).cpu_parallel is True - assert _parse({"performance": {}}).cpu_parallel is None - assert _parse({"performance": {"cpu_parallel": "yes"}}).cpu_parallel is None # non-bool ignored - - from negpy.kernel.system.override import apply as apply_override - from negpy.domain.types import AppConfig - - cfg = AppConfig( - thumbnail_size=1, - max_workers=1, - preview_render_size=1, - max_history_steps=1, - edits_db_path="", - settings_db_path="", - presets_dir="", - cache_dir="", - user_icc_dir="", - crosstalk_dir="", - sensor_dir="", - flatfield_dir="", - gear_dir="", - default_export_dir="", - adobe_rgb_profile="", - ) - assert cfg.cpu_parallel is None - apply_override(OverrideConfig(cpu_parallel=False), cfg) - assert cfg.cpu_parallel is False - - -def test_configure_cpu_parallel_override_wins(): - par.configure_cpu_parallel(False) - assert par.parallel_enabled() is False - par.configure_cpu_parallel(True) - assert par.parallel_enabled() is True - par.configure_cpu_parallel(None) - assert par.parallel_enabled() is default_cpu_parallel() diff --git a/tests/test_parallel_dispatch_size.py b/tests/test_parallel_dispatch_size.py deleted file mode 100644 index aad445cf9..000000000 --- a/tests/test_parallel_dispatch_size.py +++ /dev/null @@ -1,20 +0,0 @@ -import numpy as np - -from negpy.kernel.system import parallel as par - - -def test_small_arrays_take_serial_variant(monkeypatch): - @par.parallel_njit(cache=False) - def double(x): - out = np.empty_like(x) - for i in par.prange(x.shape[0]) if hasattr(par, "prange") else range(x.shape[0]): - out[i] = x[i] * 2 - return out - - calls = [] - monkeypatch.setattr(double, "serial", lambda *a, **k: calls.append("serial") or a[0] * 2) - monkeypatch.setattr(double, "parallel", lambda *a, **k: calls.append("parallel") or a[0] * 2) - monkeypatch.setattr(par, "_parallel_enabled", True) - double(np.ones(8, dtype=np.float32)) - double(np.ones(par.SERIAL_MAX_ELEMENTS, dtype=np.float32)) - assert calls == ["serial", "parallel"] diff --git a/tests/test_parallel_gate.py b/tests/test_parallel_gate.py deleted file mode 100644 index 5a76e92f3..000000000 --- a/tests/test_parallel_gate.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Every parallel kernel must go through the gate, and the platform default must not move. - -Numba's workqueue threading layer terminates the process outright when two threads enter it -at once — no exception, no dialog, nothing in the log. `parallel_njit` serialises every -parallel invocation behind one lock, which holds only while *every* parallel kernel is -dispatched through it. A kernel compiled `parallel=True` directly would bypass the lock and -reintroduce the abort, and it would show up as a rare crash nobody could attribute. -""" - -import ast -import pathlib - -import pytest - -from negpy.kernel.system.parallel import default_cpu_parallel, resolve_cpu_parallel - -ROOT = pathlib.Path(__file__).resolve().parent.parent / "negpy" -GATE = ROOT / "kernel" / "system" / "parallel.py" - - -def _python_files(): - return [p for p in ROOT.rglob("*.py") if p != GATE] - - -class TestNothingBypassesTheGate: - def test_no_kernel_is_compiled_parallel_directly(self): - """`@njit(parallel=True)` outside parallel_njit is the bypass.""" - offenders = [] - for path in _python_files(): - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - for kw in node.keywords: - if kw.arg == "parallel" and isinstance(kw.value, ast.Constant) and kw.value.value is True: - offenders.append(f"{path.relative_to(ROOT.parent)}:{node.lineno}") - assert not offenders, "compile these through parallel_njit, or they skip the workqueue lock: " + ", ".join(offenders) - - def test_prange_only_appears_in_gated_kernels(self): - """A file using prange but not parallel_njit is either a bypass or a kernel that - silently degraded to a serial loop — both worth failing on.""" - offenders = [ - str(p.relative_to(ROOT.parent)) - for p in _python_files() - if "prange" in (src := p.read_text(encoding="utf-8")) and "parallel_njit" not in src - ] - assert not offenders, "prange outside a parallel_njit kernel: " + ", ".join(offenders) - - def test_the_parallel_variant_is_never_called_directly(self): - offenders = [str(p.relative_to(ROOT.parent)) for p in _python_files() if ".parallel(" in p.read_text(encoding="utf-8")] - assert not offenders, "call the dispatcher, not the parallel variant: " + ", ".join(offenders) - - -class TestPlatformDefaultIsUnmoved: - """Surfacing the setting must not silently re-decide it for anyone who never touches it.""" - - @pytest.mark.parametrize("platform,expected", [("win32", True), ("linux", True), ("darwin", False)]) - def test_untouched_installs_keep_their_platform_behaviour(self, platform, expected): - assert default_cpu_parallel(platform) is expected - assert resolve_cpu_parallel(None, None) is None, "None must fall through to the platform default" - - def test_the_saved_setting_beats_the_platform_default(self): - assert resolve_cpu_parallel(None, True) is True - assert resolve_cpu_parallel(None, False) is False - - def test_the_override_file_beats_the_saved_setting(self): - """override.toml is the escape hatch for a machine that cannot boot far enough to - reach the menu.""" - assert resolve_cpu_parallel(False, True) is False - assert resolve_cpu_parallel(True, False) is True diff --git a/tests/test_percentiles.py b/tests/test_percentiles.py index ba454cab2..cc21102a0 100644 --- a/tests/test_percentiles.py +++ b/tests/test_percentiles.py @@ -1,7 +1,12 @@ import numpy as np import pytest -from negpy.kernel.image.logic import percentiles +import negpy_core + + +def percentiles(values, qs): + return negpy_core.percentiles(np.ascontiguousarray(values).ravel(), [float(q) for q in qs]) + _RNG = np.random.default_rng(3) _ARRAYS = { @@ -12,6 +17,9 @@ "len10001": _RNG.random(10_001, dtype=np.float32), "repeats": np.round(_RNG.random(5_000, dtype=np.float32), 2), "image": _RNG.random((37, 53), dtype=np.float32) ** 2.2, + "large_signed": (_RNG.standard_normal(1_000_003) * 3).astype(np.float32), + "large_repeats": np.round(_RNG.random(200_000, dtype=np.float32) * 8) / 8 - 0.5, + "strided": (_RNG.random((300, 400), dtype=np.float32))[::2, 1::3], } @@ -22,9 +30,8 @@ def test_percentiles_match_numpy_bit_for_bit(name, qs): got = percentiles(a, qs) for value, q in zip(got, qs): want = np.percentile(a, q) - assert value == want - assert type(value) is type(want) - assert value.dtype == want.dtype + assert np.float32(value) == want + assert want.dtype == np.float32 def test_percentiles_leave_the_input_unchanged(): @@ -33,6 +40,11 @@ def test_percentiles_leave_the_input_unchanged(): np.testing.assert_array_equal(a, _ARRAYS["len10001"]) +def test_percentiles_reject_other_dtypes(): + with pytest.raises(TypeError): + percentiles(np.arange(10, dtype=np.float64), (50,)) + + def test_percentiles_empty_raises(): with pytest.raises(IndexError): np.percentile(np.array([], dtype=np.float32), 50) diff --git a/tests/test_print_wedge.py b/tests/test_print_wedge.py index c531c3eda..ccb4c86ab 100644 --- a/tests/test_print_wedge.py +++ b/tests/test_print_wedge.py @@ -69,11 +69,3 @@ def test_the_render_runs_the_wedges_print_stages() -> None: with patch.object(engine, "print_stages", wraps=engine.print_stages) as spy: DarkroomEngine().process(np.ascontiguousarray(frame), _bw(), source_hash="print-stages") assert spy.called - - -def test_the_wedge_strip_stays_on_the_serial_kernels() -> None: - """The wedge runs on the GUI thread, where the parallel kernels' lock would make it wait behind a render.""" - from negpy.features.exposure.analysis import WEDGE_STEPS - from negpy.kernel.system.parallel import SERIAL_MAX_ELEMENTS - - assert engine._WEDGE_PATCH_PX**2 * WEDGE_STEPS * 3 < SERIAL_MAX_ELEMENTS diff --git a/tests/test_replay_same.py b/tests/test_replay_same.py new file mode 100644 index 000000000..1a41ea4ef --- /dev/null +++ b/tests/test_replay_same.py @@ -0,0 +1,33 @@ +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from replay import same # noqa: E402 + + +def test_same_is_bitwise(): + a = np.array([1.0, np.nan, 0.0], dtype=np.float32) + assert same(a, a.copy()) + assert not same(a, a.astype(np.float64)) + assert not same(a, np.array([1.0, np.nan, -0.0], dtype=np.float32)) + assert not same(np.float32(0.1), np.float64(0.1)) + assert same(float("nan"), float("nan")) + assert not same(0.0, -0.0) + + +def test_same_walks_containers(): + assert same((1, [np.uint8(3)], {"k": None}), (1, [np.uint8(3)], {"k": None})) + assert not same((1, 2), [1, 2]) + assert not same({"k": np.int32(1)}, {"k": np.int64(1)}) + + +def test_same_rows_and_ulp_modes(): + a = np.array([[1, 2, 3], [4, 5, 6]], np.float32) + assert same(a, a[::-1].copy(), "rows") + assert not same(a, a[::-1].copy()) + b = np.nextafter(a, np.float32(10)) + assert same(a, b, "ulp=1") + assert not same(a, b, "ulp=0") + assert same(np.array([np.nan, -0.0], np.float32), np.array([np.nan, 0.0], np.float32), "ulp=1") diff --git a/tests/test_thumbnail_refresh_controller.py b/tests/test_thumbnail_refresh_controller.py index 3e6115810..bedba46be 100644 --- a/tests/test_thumbnail_refresh_controller.py +++ b/tests/test_thumbnail_refresh_controller.py @@ -532,8 +532,8 @@ def test_frame_started_counts_the_frame_in_flight_as_left(self) -> None: self.controller.refresh_thumbnails_for(["other", "third"]) self.controller._thumbnail_render_timing = [4.0, 6.0, 2] self.controller._on_thumbnail_render_frame_started(3, 5, "c.dng") - # Frames 3, 4 and 5 are left at 5 s each. - assert lines[-1] == "Thumbnails 3/5 · ~15 s left" + # Frames 3, 4 and 5 are left at 5 s each, two at a time. + assert lines[-1] == "Thumbnails 3/5 · ~8 s left" self.controller._on_thumbnail_render_cancelled() def test_a_cancelled_refresh_clears_its_progress(self) -> None: @@ -1025,6 +1025,10 @@ def test_progress_text_formats_seconds_and_minutes() -> None: assert thumbnail_refresh_progress_text(2, 49, 1.0, 1.0) == "Thumbnails 2/49 · ~2 min left" +def test_progress_text_divides_the_time_left_by_the_frames_at_once() -> None: + assert thumbnail_refresh_progress_text(2, 5, 1.0, 1.0, frames_at_once=2) == "Thumbnails 2/5 · ~3 s left" + + def test_progress_text_has_no_time_left_on_the_last_frame() -> None: assert thumbnail_refresh_progress_text(5, 5, 1.0, 1.0) == "Thumbnails 5/5" diff --git a/tests/test_thumbnail_refresh_worker.py b/tests/test_thumbnail_refresh_worker.py index 877471c16..622b90f39 100644 --- a/tests/test_thumbnail_refresh_worker.py +++ b/tests/test_thumbnail_refresh_worker.py @@ -1,6 +1,10 @@ +import threading from dataclasses import replace import numpy as np +import pytest + +import negpy_core import negpy.desktop.workers.render as render_workers from negpy.desktop.workers.render import ( @@ -14,6 +18,17 @@ from negpy.features.stitch.models import StitchConfig +@pytest.fixture(autouse=True) +def _one_worker(monkeypatch): + """Frame order and timing ticks are exact with one worker; the parallel tests set two.""" + monkeypatch.setattr(render_workers, "_THUMBNAIL_WORKERS", 1) + + +def _two_workers(monkeypatch, room: bool = True) -> None: + monkeypatch.setattr(render_workers, "_THUMBNAIL_WORKERS", 2) + monkeypatch.setattr(render_workers, "_room_for_another_frame", lambda _frame: room) + + def _meta(tag: str) -> dict: return { "cam_xyz": [[tag, 0, 0], [0, 1, 0], [0, 0, 1]], @@ -420,3 +435,94 @@ def test_thumbnail_render_skips_the_live_peek_for_a_stitch(qapp, monkeypatch) -> assert live.peek_calls == [] assert len(private.stitch_calls) == 1 + + +def _counting_decode(monkeypatch, wait: threading.Barrier | None = None) -> dict: + """Wraps the decode to record the most decodes at once and the pool size each one sees.""" + seen = {"now": 0, "most": 0, "pool": set()} + lock = threading.Lock() + real_decode = render_workers._decode_asset_preview_with_meta + + def _decode(*args): + with lock: + seen["now"] += 1 + seen["most"] = max(seen["most"], seen["now"]) + seen["pool"].add(negpy_core.pool_threads()) + try: + if wait is not None: + wait.wait() + return real_decode(*args) + finally: + with lock: + seen["now"] -= 1 + + monkeypatch.setattr(render_workers, "_decode_asset_preview_with_meta", _decode) + return seen + + +def test_thumbnail_render_runs_two_frames_at_once_on_the_background_pool(qapp, monkeypatch) -> None: + _two_workers(monkeypatch) + worker, _processor = _worker(monkeypatch) + seen = _counting_decode(monkeypatch, threading.Barrier(2, timeout=10)) + rendered: list[str] = [] + progress: list[int] = [] + finished: list[int] = [] + worker.rendered.connect(lambda frame, _buf: rendered.append(frame.thumbnail_key)) + worker.progress.connect(lambda done, *_rest: progress.append(done)) + worker.finished.connect(finished.append) + base = WorkspaceConfig() + + worker.process(_task(_input("a", base), _input("b", base))) + + assert seen["most"] == 2 + cores = negpy_core.pool_threads() + assert seen["pool"] == {min(cores, max(2, cores // 4))} + assert rendered == ["key-a", "key-b"] + assert progress == [1, 2] + assert finished == [2] + + +def test_thumbnail_render_without_room_keeps_one_frame_in_flight(qapp, monkeypatch) -> None: + _two_workers(monkeypatch, room=False) + worker, _processor = _worker(monkeypatch) + seen = _counting_decode(monkeypatch) + finished: list[int] = [] + worker.finished.connect(finished.append) + base = WorkspaceConfig() + + worker.process(_task(_input("a", base), _input("b", base), _input("c", base))) + + assert seen["most"] == 1 + assert finished == [3] + + +def test_thumbnail_render_cancel_stops_frames_not_yet_started(qapp, monkeypatch) -> None: + _two_workers(monkeypatch) + worker, processor = _worker(monkeypatch) + rendered: list[str] = [] + cancelled: list[bool] = [] + worker.rendered.connect(lambda frame, _buf: (rendered.append(frame.thumbnail_key), worker.cancel())) + worker.cancelled.connect(lambda: cancelled.append(True)) + base = WorkspaceConfig() + + worker.process(_task(*(_input(name, base) for name in "abcd"))) + + assert rendered == ["key-a"] + assert len(processor.run_calls) <= 2 + assert cancelled == [True] + + +def test_room_for_another_frame_needs_known_ram_above_the_reserve(monkeypatch) -> None: + estimate = render_workers.loader_factory.estimate_preview_memory("/missing.dng", 100) + need = render_workers.MIN_RAM_RESERVE_BYTES + estimate.temporary_bytes + estimate.cached_bytes + monkeypatch.setattr(render_workers.loader_factory, "estimate_linear_preview_prefetch_memory", lambda *_args: estimate) + frame = _input("a", WorkspaceConfig()) + + monkeypatch.setattr(render_workers, "available_system_memory_bytes", lambda: None) + assert not render_workers._room_for_another_frame(frame) + monkeypatch.setattr(render_workers, "available_system_memory_bytes", lambda: need - 1) + assert not render_workers._room_for_another_frame(frame) + monkeypatch.setattr(render_workers, "available_system_memory_bytes", lambda: need) + assert render_workers._room_for_another_frame(frame) + stitch = replace(WorkspaceConfig(), stitch=StitchConfig(stitch_enabled=True, stitch_paths=("/a.dng", "/b.dng"))) + assert not render_workers._room_for_another_frame(_input("pano", stitch)) diff --git a/tests/test_vectorscope.py b/tests/test_vectorscope.py index a46c8ae09..2cbb384ff 100644 --- a/tests/test_vectorscope.py +++ b/tests/test_vectorscope.py @@ -18,9 +18,28 @@ vectorscope_histogram, ) from negpy.features.process.models import ProcessMode +from negpy.kernel.image.logic import rgb_to_lab_working, working_oetf_decode from negpy.infrastructure.gpu.device import GPUDevice +def _scope_bins(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """The NumPy polar binning vectorscope_histogram copies.""" + hue = np.floor((np.arctan2(b, a) + np.pi) / (2 * np.pi) * SCOPE_HUE_BINS).astype(np.int64) % SCOPE_HUE_BINS + radius = scope_radius(np.hypot(a, b), log=True) + chroma = np.clip(np.floor(radius * SCOPE_CHROMA_BINS), 0, SCOPE_CHROMA_BINS - 1).astype(np.int64) + return hue * SCOPE_CHROMA_BINS + chroma + + +def test_counts_match_the_numpy_binning() -> None: + rng = np.random.default_rng(3) + frame = rng.random((300, 400, 3), dtype=np.float32) * 1.2 - 0.1 + frame[0, :4] = np.array([np.nan, np.inf, -np.inf, 0.5], np.float32)[:, None] + lab = rgb_to_lab_working(working_oetf_decode(np.clip(np.nan_to_num(frame), 0.0, 1.0))) + flat = _scope_bins(lab[..., 1].ravel().astype(np.float64), lab[..., 2].ravel().astype(np.float64)) + want = np.bincount(flat, minlength=SCOPE_HUE_BINS * SCOPE_CHROMA_BINS).reshape(SCOPE_HUE_BINS, SCOPE_CHROMA_BINS) + assert np.abs(vectorscope_histogram(frame) - want).sum() <= 2 + + def test_a_gray_frame_lands_in_the_first_chroma_bin() -> None: counts = vectorscope_histogram(np.full((20, 30, 3), 0.5, dtype=np.float32)) diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index f7713be56..19dac495d 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -207,5 +207,5 @@ "vanadium_strength": "0.0" } }, - "render_version": 16 + "render_version": 17 } diff --git a/uv.lock b/uv.lock index 86e74843f..0fba01012 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.13" +[manifest] +members = [ + "negpy", + "negpy-core", +] + [[package]] name = "altgraph" version = "0.17.5" @@ -265,26 +271,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/4a/ff49bd77f33af05ca26fee29d601beb1e19ca791bb86efece82a3833885c/libusb_package-1.0.30.0-py3-none-win_amd64.whl", hash = "sha256:90808da724c8939a333d931d0c7226372ca5fefd98e2f2f3ef79fd918aa37522", size = 90711, upload-time = "2026-06-30T07:41:09.318Z" }, ] -[[package]] -name = "llvmlite" -version = "0.47.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, - { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, - { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, - { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, - { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, - { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, - { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, - { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, - { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, - { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, - { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, - { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, -] - [[package]] name = "macholib" version = "1.16.4" @@ -365,10 +351,9 @@ dependencies = [ { name = "imagecodecs" }, { name = "imageio" }, { name = "jinja2" }, - { name = "numba" }, + { name = "negpy-core" }, { name = "numpy" }, { name = "onnxruntime" }, - { name = "opencv-python-headless" }, { name = "piexif" }, { name = "pillow" }, { name = "pyqt6" }, @@ -388,6 +373,7 @@ nkscan = [ { name = "nkscan" }, ] plustek = [ + { name = "opencv-python-headless" }, { name = "pyopticfilm" }, ] sane = [ @@ -412,6 +398,7 @@ pieusb = [ { name = "pieusb" }, ] plustek = [ + { name = "opencv-python-headless" }, { name = "pyopticfilm" }, ] sane = [ @@ -424,11 +411,11 @@ requires-dist = [ { name = "imagecodecs", specifier = "==2026.6.6" }, { name = "imageio", specifier = "==2.37.3" }, { name = "jinja2", specifier = "==3.1.6" }, + { name = "negpy-core", editable = "negpy_core" }, { name = "nkscan", marker = "extra == 'nkscan'", specifier = ">=0.14.2" }, - { name = "numba", specifier = "==0.65.1" }, { name = "numpy", specifier = "==2.4.4" }, { name = "onnxruntime", specifier = "==1.23.2" }, - { name = "opencv-python-headless", specifier = "==4.13.0.92" }, + { name = "opencv-python-headless", marker = "extra == 'plustek'", specifier = "==4.13.0.92" }, { name = "piexif", specifier = "==1.1.3" }, { name = "pillow", specifier = "==12.2.0" }, { name = "pyopticfilm", marker = "extra == 'plustek'", specifier = ">=1.3.7" }, @@ -454,9 +441,23 @@ dev = [ ] nkscan = [{ name = "nkscan", specifier = ">=0.15" }] pieusb = [{ name = "pieusb", specifier = ">=0.3.7" }] -plustek = [{ name = "pyopticfilm", specifier = ">=1.3.7" }] +plustek = [ + { name = "opencv-python-headless", specifier = "==4.13.0.92" }, + { name = "pyopticfilm", specifier = ">=1.3.7" }, +] sane = [{ name = "python-sane", specifier = ">=2.9" }] +[[package]] +name = "negpy-core" +version = "0.1.0" +source = { editable = "negpy_core" } +dependencies = [ + { name = "numpy" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy" }] + [[package]] name = "nkscan" version = "0.15.0" @@ -469,30 +470,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/21/0836f4875f4448837b9d44e26ef57db4e646c2a78e17c4b1d0d1c207dad9/nkscan-0.15.0-cp313-abi3-win_amd64.whl", hash = "sha256:c3ab8dd6b6d074e688df4df3648b9fe1da1d064d6476cafafafa0b197ced8194", size = 3629610, upload-time = "2026-10-19T18:19:34.148Z" }, ] -[[package]] -name = "numba" -version = "0.65.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "llvmlite" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, - { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, - { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, - { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, - { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, - { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, - { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, - { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, -] - [[package]] name = "numpy" version = "2.4.4"