Repository navigation
perf(core): negpy_core ports, exact 16-bit export color, parallel PNG - #1502
Merged
Merged
Conversation
apply_exif_orientation turned full-resolution buffers with NumPy rot90/flip plus a contiguous copy on one thread. negpy_core.orient copies pixels of any byte size in parallel, in 64x64 tiles for the 90-degree turns. Output is the same bytes as the NumPy path for every orientation and dtype. 23.5 MP float32 RGB, 24 threads: orientation 6 328 -> 27 ms, orientation 3 171 -> 24 ms. Runs on frame open, export decode and twice in lens correction for rotated sources.
_erode_resize_bounded ran its vertical INTER_AREA pass as a Python loop of small NumPy calls per output row. negpy_core.area_rows_into runs the same float64 weights and float32 products, summed down the rows in NumPy's order, in parallel; the output is bit-identical (NumPy oracle test). normalize_ir_to_float32 scales uint8/uint16 IR planes in one pass. 23.5 MP IR plane, 24 threads: downsample_ir to 2000 px 68 -> 23 ms, normalize 11.4 -> 9.5 ms. Both run on every IR frame open.
_export_pixels sent the quantized buffer through Image.fromarray and np.asarray twice around the Rust matrix/TRC kernel, and the kernel's decode/encode tables were rebuilt from the ICC bytes on every export. apply_color_management now takes and returns a uint8 array (PIL only for the lcms fallback) and the tables are cached per profile pair. Pixels and embedded profile are unchanged. 23.5 MP, Adobe RGB -> sRGB: _export_pixels 173 -> 49 ms per JPEG/WebP export.
16-bit RGB exports (TIFF, PNG, JXL) went through lcms, which resamples a 16-bit transform into a grid table. Against lcms with cmsFLAGS_NOOPTIMIZE that grid is off by up to 5386 levels (8%) for Adobe RGB -> sRGB and 3764 for P3 near the destination gamut edge, mean 25. 8-bit exports already use the exact matrix/TRC kernel, so 16-bit exports were the less accurate ones. matrix_trc_u16 mirrors matrix_trc_u8: per-code TRC decode, the PCS matrix with a gamut clip, and an encode table sampled at linear t^2 so the steep end near black interpolates finely. It is within one level of exact lcms on every bundled export space. LUT profiles keep lcms. 23.5 MP, Adobe RGB -> sRGB: 124 -> 43 ms per 16-bit export. 16-bit export pixels change by the lcms grid error above; thumbnails are not affected.
The greyscale export re-encoded every pixel with NumPy (working TRC decode, sRGB OETF, 16-bit, 8-bit), several full-size temporaries and np.power on each. The result depends only on the uint16 luma code, so the same formula now fills a 65536-entry table once per working space and negpy_core.lut_u16 reads it in parallel. The 8-bit path also drops its PIL round trip. Output is bit-identical (test against the per-pixel formula). 23.5 MP: 16-bit greyscale 207 -> 12 ms, 8-bit greyscale 246 -> 8.5 ms.
apply_vignette was the last NumPy pixel stage in the CPU finish: a full meshgrid, sqrt, cos and exp2 with about ten full-size temporaries. The CPU engine renders every thumbnail refresh, so it ran there as well as in the CPU preview and export. negpy_core.apply_vignette keeps NumPy's float32 order with cos and exp2 from libm in float64, rounded once: within 4 ULP of NumPy's SVML results, the same bits on every platform (same-bits pin). THUMBNAIL_RENDER_VERSION 17 -> 18 for those ULPs. 24 threads: 1600 px 31 -> 6 ms, 23.5 MP 386 -> 77 ms.
apply_flat_curve, the one pixel step of the Flat intent, ran in NumPy: an offset add, a BLAS product of the full image with the stock matrix and the affine code map, each a full-size temporary. The flat export is CPU-only. negpy_core.flat_curve does it in one parallel pass, the matrix in matmul_3x3's order; the output is bit-identical to the NumPy version here. 23.5 MP: 409 -> 24 ms per flat export.
apply_transfer_curve, the CPU render of every Slide and Positive frame, was the one render stage the port missed: NumPy with dozens of full-size temporaries, softplus, tanh and power per channel. It runs for every slide thumbnail refresh (always CPU), the CPU preview and CPU exports. negpy_core.apply_transfer_curve keeps NumPy's float32 order and runs each step as a flat loop over runs of 256 pixels, so the mathf float64 transcendentals vectorize; they round once to float32, within 3e-7 of NumPy's SVML results and the same bits on every platform (same-bits pin). The NumPy helpers it replaced (_softplus, _sigmoid, _black_taper, separation_damping_gain_np) are gone. Covered by the existing render version bump on this branch. 24 threads, 1600 px: defaults 57 -> 3.5 ms, toe/shoulder/contrast 251 -> 8.5 ms, with Dye Separation 725 -> 20 ms. 23.5 MP edited: 4.0 s -> 0.11 s.
A bounded TIFF preview mapped every decoded strip through the display LUT in NumPy (a full-resolution gather) and shrank it with PIL's BOX resize; the LinearRaw DNG preview ran its whole display curve, np.power included, on every tile. negpy_core.box_into_u8 area-averages a strip straight into its preview rows, reading each sample through the display table. The DNG curve becomes one 3 x 65536 table per file, built with the same formula. Previews stay within 2 levels of PIL's BOX output. 23.5 MP deflate TIFF to a 512 px preview: 338 -> 226 ms per frame; what is left is strip inflate on the decode workers.
PNG exports went through PIL (8-bit) and imagecodecs/libpng (16-bit), both filtering and deflating the whole image on one thread. negpy_core.encode_png filters bands of 64 rows (the minimum-sum-of-absolute-differences choice over the five PNG filters) and deflates each band with miniz_oxide on its own core; sync flushes join the bands into one zlib stream and the Adler-32 is combined from theirs. encode_png splices the iCCP, pHYs, eXIf and iTXt chunks for both depths. Pixels decode identically in libpng and PIL. New crates: miniz_oxide, adler2, crc32fast (pure Rust; NOTICE.md). 23.5 MP, level 6, 24 threads: 8-bit 16.4 s -> 0.74 s (size +0.8%), 16-bit 16.4 s -> 1.1 s (size +0.05%).
A linear 16-bit TIFF (and Pakon raw) was converted to float32 in the loader, back to uint16 by NonStandardFileWrapper.postprocess in NumPy, and to float32 again by the caller. uint16 -> float32 -> uint16 restores every one of the 65536 codes, so the wrapper now holds the uint16 source, makes float32 only on demand, and a linear 16-bit postprocess() returns the source as it is. Bits are unchanged; tagged sRGB/Adobe and 8-bit sources keep the float path. 23.5 MP TIFF, load_linear_preview without IR: ~330 -> ~295 ms; every export decode of such a scan skips the same two passes.
test_compute_dust_stats_pin failed on some CI runners. The density plane used NumPy's float32 log10, whose SIMD code differs between AVX-512 and AVX2 CPUs, so the pinned output depended on the runner. The density now comes from negpy_core.log_density_open (correctly rounded log10f). The new pin equals NumPy's AVX2 result and holds with NumPy's AVX-512 paths on or off.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports from the post-#1489 survey: NumPy and PIL work that still ran on the CPU paths outside negpy_core. One commit each.
log10, bits differ between AVX-512 and AVX2 runnerslog_density_open, same bits on every CPUcompute_dust_statspin updatedNotes:
prefer_gpu=False), so the vignette port reaches every refresh, not only the CPU fallback.cmsFLAGS_NOOPTIMIZE, P3 3764. 8-bit already used the exact kernel.test_16bit_matrix_trc_export_matches_exact_lcmspins the new behavior; LUT profiles keep lcms.test_compute_dust_stats_pinfailed on some CI runners because NumPy's float32log10takes a different SIMD path on AVX-512 CPUs. The density plane now comes fromnegpy_core.log_density_open.matrix_trc_u16,vignette,flat_curve,transfer_curve. New test files are in thenativeCI job.Checks:
make rust,make type, ruff on changed files, targeted pytest (5397 tests across export, loaders, thumbnails, transparency, finish, core, PNG, TIFF, preview).Live testing
Commits 1–7 (checked off after the first review):
Commits 8–12: