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 @@
[](https://github.com/marcinz606/NegPy/releases)
[](LICENSE)
[](pyproject.toml)
+ [](negpy_core)
[](https://github.com/astral-sh/ruff)
[](#getting-started)
[](https://github.com/marcinz606/NegPy/graphs/contributors)
[](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.

@@ -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