Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
137 commits
Select commit Hold shift + click to select a range
f70c72a
build(core): add the negpy_core Rust crate as a uv workspace member
marcinz606 Oct 22, 2026
c237f2d
perf(core): exact percentiles in Rust
marcinz606 Oct 22, 2026
34adea3
build(core): make, CI, release and nix targets for negpy_core
marcinz606 Oct 22, 2026
5066ad6
docs(core): negpy_core rules, setup and crate notices
marcinz606 Oct 22, 2026
82d324a
fix(core): review fixes for the percentile port and the build
marcinz606 Oct 22, 2026
232e4e1
ci(core): build llvmlite against LLVM 20 in the Intel macOS native job
marcinz606 Oct 22, 2026
b040bc0
ci: run the GPU tests on lavapipe
marcinz606 Oct 22, 2026
5bb65be
build(core): task manifest of the functions negpy_core replaces
marcinz606 Oct 22, 2026
1d40244
build(core): replay tool, make replay and make bench
marcinz606 Oct 22, 2026
c623325
perf(core): INTER_AREA downscale in Rust for autocrop detection
marcinz606 Oct 22, 2026
5e0125e
Revert "ci: run the GPU tests on lavapipe"
marcinz606 Oct 22, 2026
ecfbc93
feat(core): OpenCV's float32 separable filter and GaussianBlur in Rust
marcinz606 Oct 22, 2026
293f902
perf(core): phase 2 float32 GaussianBlur and Sobel go through negpy_core
marcinz606 Oct 22, 2026
d4982a1
feat(core): morphology for rectangular kernels in Rust
marcinz606 Oct 22, 2026
609c06b
feat(core): uint8 gray, GaussianBlur, Otsu and Canny in Rust
marcinz606 Oct 22, 2026
aa3cb73
fix(core): clippy manual_contains in the canny test
marcinz606 Oct 22, 2026
87d20cc
feat(core): findContours (external, simple chains) in Rust
marcinz606 Oct 22, 2026
3b6f560
perf(core): port get_luminance, _normalize_to_uint8 and _detection_luma
marcinz606 Oct 22, 2026
d1a1e06
perf(core): _base_blocks block statistics in Rust
marcinz606 Oct 22, 2026
76d3775
perf(core): _base_blocks fully in Rust; tolerance ledger
marcinz606 Oct 22, 2026
b40ce3d
perf(core): port _find_rebate_level
marcinz606 Oct 22, 2026
9ac157d
perf(core): port _walk_border_depth
marcinz606 Oct 22, 2026
eec900a
perf(core): port measure_film_edges and _trim_film_edges
marcinz606 Oct 22, 2026
e959d1c
perf(core): port measure_film_border and _roi_from_measured_border
marcinz606 Oct 22, 2026
863c1c9
perf(core): port the tier refinement and the edge snap
marcinz606 Oct 22, 2026
5ce88bf
perf(core): port _refine_frame_bounds and _refine_roi_to_image
marcinz606 Oct 22, 2026
6435459
feat(core): convexHull, minAreaRect, boxPoints, fillConvexPoly in Rust
marcinz606 Oct 22, 2026
218d0a2
perf(core): port detect_film_bounds_with_confidence
marcinz606 Oct 22, 2026
274c93a
docs(core): par_zip and move closures in hot loops
marcinz606 Oct 22, 2026
1f15017
perf(core): port normalize_log_image and _block_median_grid
marcinz606 Oct 22, 2026
13b2666
perf(core): faster radix percentile and normalize_to_uint8
marcinz606 Oct 22, 2026
27a0e75
perf(core): port density_histogram and density_parade
marcinz606 Oct 22, 2026
bb71c9e
perf(core): port the CPU scopes (output, color, waveforms, full scale)
marcinz606 Oct 22, 2026
cf79e64
perf(core): port the textured-cell meters
marcinz606 Oct 22, 2026
0acce3c
perf(core): port the bounds sampling and the same-pixel color pass
marcinz606 Oct 22, 2026
0802ece
refactor(core): drop the detector helpers the Rust port orphaned; rec…
marcinz606 Oct 22, 2026
cb86b13
perf(core): port _get_threshold_autocrop_coords; resolve_autocrop_rec…
marcinz606 Oct 22, 2026
709de4b
perf(core): port batch autocrop's _vertical_edge_profile
marcinz606 Oct 22, 2026
404e99e
perf(core): measure_base_log and film_and_picture are single Rust calls
marcinz606 Oct 22, 2026
9a01715
perf(core): port the pixel-format Numba kernels
marcinz606 Oct 23, 2026
c17cb52
docs(core): saturating casts keep loops scalar
marcinz606 Oct 23, 2026
46b18c8
feat(core): box filter (cv2.boxFilter / cv2.blur) for float32
marcinz606 Oct 23, 2026
f0ad4d8
chore(core): replay record --set overrides one config field for every…
marcinz606 Oct 23, 2026
d1d4f5f
feat(core): connected components with stats (cv2.connectedComponentsW…
marcinz606 Oct 23, 2026
9623364
feat(core): morphology with any structuring element
marcinz606 Oct 23, 2026
6285849
perf(core): downsample_ir erodes and resizes in Rust; faster shaped m…
marcinz606 Oct 23, 2026
969d9f8
feat(core): median blur for uint8 (cv2.medianBlur)
marcinz606 Oct 23, 2026
724cce0
feat(core): distance transform, with and without labels
marcinz606 Oct 23, 2026
6c9c885
perf(core): dust detection uses the Rust primitives; _mask_to_score i…
marcinz606 Oct 23, 2026
1fd28f1
perf(core): port the score-fill Numba kernels
marcinz606 Oct 23, 2026
b7120ed
perf(core): box filter inner loops take the AVX2 build
marcinz606 Oct 23, 2026
d6cc5d9
fix(core): trunc_bits returns the integer, not the raw float bits
marcinz606 Oct 23, 2026
f67711a
perf(core): own vectorized log10f; to_log_density in Rust
marcinz606 Oct 23, 2026
442a820
perf(core): neutral-axis meter in Rust; own vectorized atan2f
marcinz606 Oct 23, 2026
88a9f03
perf(core): working TRC and CIELAB kernels in Rust; own float64 pow
marcinz606 Oct 23, 2026
c476689
perf(core): skin chroma rein in Rust
marcinz606 Oct 23, 2026
813a896
perf(core): gamut-aware chroma scale and 3x3 matrix in Rust
marcinz606 Oct 23, 2026
1573fe9
fix(core): name the neutral-axis result type for clippy
marcinz606 Oct 23, 2026
f458cb9
perf(core): vectorscope histogram in Rust
marcinz606 Oct 23, 2026
3ee2cd3
perf(core): print curve and chemical toning in Rust; Phase 6 corpus
marcinz606 Oct 23, 2026
fd8d49b
perf(core): sensor unmix kernels in Rust; restore tone_key_weight_np
marcinz606 Oct 23, 2026
33d815d
perf(core): HDR merge kernels and hair encode in Rust
marcinz606 Oct 23, 2026
1086a6f
perf(core): display LUT and matrix/TRC kernels in Rust; no Numba kern…
marcinz606 Oct 23, 2026
6b1557d
refactor: drop Numba; Multi-core CPU Rendering sets the Rust thread c…
marcinz606 Oct 23, 2026
346f078
test(core): build the matrix/TRC same-bits ramp without np.power
marcinz606 Oct 23, 2026
ad57f8f
feat: Multi-core CPU Rendering starts on everywhere, macOS included
marcinz606 Oct 23, 2026
6da176f
perf(core): fine rotation, keystone and radial correction warp in Rust
marcinz606 Oct 23, 2026
cd45151
perf(core): bicubic, zero-border and nearest warps; stitch, HDR, lens…
marcinz606 Oct 23, 2026
857346e
perf(core): bilinear resize in Rust; INTER_AREA upscales
marcinz606 Oct 23, 2026
543156b
refactor(core): float32 area and bilinear resizes call negpy_core
marcinz606 Oct 23, 2026
2ff45fd
perf(core): nearest-neighbour resize in Rust for uint8 masks
marcinz606 Oct 23, 2026
ac8789a
perf(core): USM sharpening block in Rust
marcinz606 Oct 23, 2026
7f01c9e
perf(core): chroma denoise in Rust
marcinz606 Oct 23, 2026
be19869
perf(core): glow and halation in Rust
marcinz606 Oct 23, 2026
33df9a4
perf(core): CLAHE in Rust
marcinz606 Oct 23, 2026
bfaaa9f
perf(core): Richardson-Lucy sharpening in Rust; lab logic has no cv2 …
marcinz606 Oct 23, 2026
98d76a7
perf(core): Sabattier, cyanotype and lith in Rust; wide filters re-fi…
marcinz606 Oct 23, 2026
bcd7633
perf(core): split toning in Rust
marcinz606 Oct 23, 2026
1fafe56
perf(core): filed negative carrier in Rust
marcinz606 Oct 23, 2026
81002a9
perf(core): skew edge sampling in Rust
marcinz606 Oct 23, 2026
9a4fcac
perf(core): faster median blur: 3x3 network, small-window counting, f…
marcinz606 Oct 23, 2026
f7a775d
fix(replay): the recorder passes the IR preview to the pipeline
marcinz606 Oct 23, 2026
ed639ee
perf(core): fix multi-core slowdowns; the speed gate counts every core
marcinz606 Oct 23, 2026
b9daf2f
ci: run on mz/core on demand only
marcinz606 Oct 23, 2026
cd13d78
refactor: remove Multi-core CPU Rendering; negpy_core always uses eve…
marcinz606 Oct 23, 2026
3f0c6de
perf(core): port the IR dust path and the score repair to Rust
marcinz606 Oct 23, 2026
3e889b0
perf(core): repair_components in Rust; connected components over row …
marcinz606 Oct 23, 2026
22637d2
perf(core): rest of the IR path in Rust; NumPy's float32 log
marcinz606 Oct 23, 2026
9831931
perf(core): optical dust detection in Rust
marcinz606 Oct 23, 2026
0a929ae
style(core): name the detection result type for clippy
marcinz606 Oct 23, 2026
34ea3dd
perf(core): flat-field reference checks and gain in Rust
marcinz606 Oct 23, 2026
949e2f8
perf(core): local masks in Rust, with an exact port of cv2.fillPoly
marcinz606 Oct 23, 2026
da2b259
perf(core): scratch ridge and trace in Rust; one-pass narrow box filter
marcinz606 Oct 23, 2026
3781952
perf(core): hue trim, clone strokes, heal strokes and exclusions in Rust
marcinz606 Oct 23, 2026
729caaa
perf(core): ir_ratio_and_gain's visible logs and gain in Rust
marcinz606 Oct 23, 2026
ecfd03e
perf(core): film_base in Rust; the sensor unmix runs without cv2
marcinz606 Oct 23, 2026
a2bd49f
perf(core): flat-field remainders in Rust; one general multiply
marcinz606 Oct 23, 2026
f5b39af
perf(core): lens maps in Rust, with an exact cv2.undistortPointsIter
marcinz606 Oct 23, 2026
0a3e813
perf(core): HDR measurement, ratio and render exposure in Rust
marcinz606 Oct 23, 2026
906ac9d
perf(core): stitch gain compensation in Rust
marcinz606 Oct 23, 2026
2941303
build(core): replay compare mode "quality" for ports gated by their o…
marcinz606 Oct 23, 2026
33d089e
perf(core): line segment detector and _coarse_angles in Rust
marcinz606 Oct 23, 2026
b5a773f
perf(core): SIFT registration of stitch pairs in Rust
marcinz606 Oct 23, 2026
7ab4e6e
perf(core): hair inpaint in Rust
marcinz606 Oct 23, 2026
4362626
perf(core): panorama seam finder in Rust
marcinz606 Oct 23, 2026
13901cd
perf(core): skew fits, batch autocrop profile reads and rotation matr…
marcinz606 Oct 23, 2026
2eb5651
perf(core): seam and feather blends in Rust
marcinz606 Oct 23, 2026
05400e1
Merge branch 'worktree-agent-a9c61cfdd91d7ecc6' into mz/core
marcinz606 Oct 23, 2026
c889c99
Merge branch 'mz/sift' into mz/core
marcinz606 Oct 23, 2026
23a039d
perf(core): stitch canvas corners without cv2.transform
marcinz606 Oct 23, 2026
c4e34ae
perf(core): zone, loupe and vectorscope display reads in Rust
marcinz606 Oct 23, 2026
df0ca01
Merge branch 'worktree-agent-aff800d7b3f929474' into mz/core
marcinz606 Oct 23, 2026
9021e7a
fix(core): drop the stitch module's last cv2 import
marcinz606 Oct 23, 2026
3648ac0
fix(core): drop skew.py's cv2 import
marcinz606 Oct 23, 2026
9cee4a0
perf(core): normalization planes and clip metrics in Rust
marcinz606 Oct 23, 2026
d86b5ce
perf(core): Phase 3 loaders without cv2
marcinz606 Oct 23, 2026
1d990c3
perf(core): Phase 4 process stats, chart reads, mask overlap
marcinz606 Oct 23, 2026
648b800
perf(core): Phase 5 phase correlation in Rust, HDR/RGB Scan/stitch pi…
marcinz606 Oct 23, 2026
5aa7342
perf(core): Phase 6 render-stage pixel work in Rust
marcinz606 Oct 23, 2026
c0e6dcb
perf(core): Lanczos4 and cubic resize in Rust for export layout
marcinz606 Oct 23, 2026
291e719
perf(core): uint8 cubic and area resize in Rust for contact sheet tiles
marcinz606 Oct 23, 2026
2169b45
perf(core): negpy imports no cv2
marcinz606 Oct 23, 2026
d88815c
perf(core): scan preview stretch and capture metering in Rust
marcinz606 Oct 23, 2026
cc78b4a
perf(core): Phase 7 capture, thumbnail and half-frame pixel work in Rust
marcinz606 Oct 23, 2026
98ddd34
build: opencv leaves the core dependencies; bump bounds and render ve…
marcinz606 Oct 23, 2026
43a8059
test: guard that negpy imports no cv2
marcinz606 Oct 23, 2026
aa161be
build: opencv only for pyopticfilm
marcinz606 Oct 23, 2026
bb15b30
fix(core): review pass on the Rust port
marcinz606 Oct 23, 2026
cf5df18
perf(core): cut repeated detection work and serial passes
marcinz606 Oct 23, 2026
98f3db6
perf: parallel thumbnail refresh and export encode
marcinz606 Oct 24, 2026
8f7e427
perf(core): background pool keeps at least two threads
marcinz606 Oct 24, 2026
1c07940
perf(core): parallel baseline JPEG encode in Rust
marcinz606 Oct 24, 2026
321ac4e
Merge remote-tracking branch 'origin/main' into mz/core
marcinz606 Oct 24, 2026
20c4208
ci: drop the LLVM setup for llvmlite
marcinz606 Oct 24, 2026
d5c1c1c
ci: move actions to their Node 24 releases; pin the release's Linux r…
marcinz606 Oct 24, 2026
afa017f
test: background pool floor and the export name lock in two worker tests
marcinz606 Oct 24, 2026
4904fc9
docs: README names the Rust core and recommends running from source
marcinz606 Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 70 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,40 @@ on:
branches: [ main ]
pull_request:
branches: [ main ]
# mz/core runs on demand: gh workflow run ci.yml --ref mz/core
workflow_dispatch:

jobs:
test:
name: Test and Lint
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"

- 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: |
Expand All @@ -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 .

Expand All @@ -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/[email protected]
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
10 changes: 5 additions & 5 deletions .github/workflows/metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand Down
47 changes: 27 additions & 20 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,28 @@ on:
- windows
- linux
- macos
publish:
description: 'Create the GitHub release (off for a test build)'
required: true
default: true
type: boolean

jobs:
setup:
runs-on: ubuntu-latest
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
Expand All @@ -45,19 +52,28 @@ 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"

- 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
Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
29 changes: 25 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/<area>.rs` takes slices or `ndarray` views and plain numbers, never config. `src/py/<area>.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`).
Expand Down
13 changes: 9 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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/<area>.rs`), with its PyO3 wrappers in `src/py/`.
- `tests/`: unit and integration tests.

## Coding Standards
Expand Down Expand Up @@ -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)).
Expand Down
Loading
Loading