Skip to content

Integer resampling, vertical pass first - #68

Merged
matmen merged 1 commit into
masterfrom
integer-resampling
Oct 16, 2026
Merged

matmen merged 1 commit into
masterfrom
integer-resampling

Conversation

@matmen

@matmen matmen commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Resizing was the one benchmark row where ImageScript was slower than Jimp. On GitHub's runner the numbers were 30.6 vs. 87.1 ops/s for halving a 1920×1080 photo bilinearly.

Jimp's bilinear reads 2×2 pixels per output pixel, without antialiasing. Ours is a proper filter like Pillow's and reads 4×4. That makes it slower by design, but the bigger cost was the implementation: float arithmetic in a loop over the filter taps, with the horizontal pass first.

Changes

  • Integer arithmetic: weights have 14 fractional bits and are rounded so they still sum to one. Every output index gets the same number of taps, so the passes unroll by that count.
  • Vertical pass first: its weights are constant for a whole row, which makes it the cheaper pass to run on the larger image.
  • Opaque images skip premultiplying. With non-negative filters (bilinear, area) and up to 4 taps per output row, red and blue share one 32-bit multiplication (SWAR with 8-bit weights and 16-bit lanes). If the pass finds a pixel that isn't opaque, the image is resampled premultiplied instead.
  • clone() copies in the array's constructor instead of into a zeroed array, which makes it about 50% faster.
  • Fix: when enlarging with RESIZE_AREA, the box filter's weights could all be 0, so whole rows and columns came out transparent. Those pixels now take the pixel in range.

Speed (this LXC, CPU time)

1920×1080 photo before after
bilinear to half 49 ms 15 ms
area to half 33 ms 9 ms
bilinear 3.7× smaller 41 ms 24 ms
bilinear 2× larger 392 ms 89 ms
bicubic to half 105 ms 78 ms
bilinear to half, image with alpha — 20–40% faster

The benchmark run locally on the same machine:

resize clone
ImageScript (this PR) 51.2 ops/s 711 ops/s
ImageScript 3.2.0 14.9 ops/s 596 ops/s
Jimp 39.7 ops/s 474 ops/s

Results

The output is no longer bit-identical: it differs from 3.2.0 by at most 1 per channel. This was checked on:

  • 1313 corpus images, each at 27 combinations of mode and size (shrinking, enlarging, and changing only one axis)
  • noise, the worst case for rounded weights

Larger differences show up in only two places:

  • the color of pixels with alpha 0 or 1, where it can't be seen
  • the transparent lines from the area bug, which are now filled

Every other operation is unchanged (hashes compared with master over the corpus). tests/targets/{resize,effects,readme}.png are regenerated: resize differs by ≤1, and effects like sharpen and color-dodge amplify that to a few levels in some pixels.

The changelog entry is under 3.2.1.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ

Resizing used float arithmetic through loops over the filter taps, in
premultiplied alpha, horizontal pass first: 49 ms to halve a 1920x1080
photo, slower than Jimp's bilinear (which reads 2x2 pixels without
antialiasing, where ours reads 4x4).

- Weights are integers with 14 fractional bits (rounded so they still
  sum to one), every output index gets the same number of taps, and the
  passes unroll by it.
- The vertical pass runs first: its weights are the same for a whole
  row, so it's the cheaper one to run on the larger image.
- Opaque images skip premultiplying. With non-negative filters (bilinear,
  area) and up to 4 taps per output row, red and blue share a 32-bit
  multiplication (8-bit weights, 16-bit lanes). The pass notices pixels
  that aren't opaque and the image is done premultiplied instead.
- The box filter's weights could all be 0 when enlarging (a pixel center
  right on the filter's edge): those pixels came out transparent, now
  they take the pixel in range.

Results differ from before by at most 1 per channel, checked on 1313
corpus images x 27 sizes/modes and on noise (the worst case for rounded
weights). Larger differences are only in the color of pixels with alpha
0 or 1 and the transparent lines that are fixed. Every other operation is
unchanged (hashes compared with master), the test targets that resize
are regenerated.

Halving the photo: 15 ms bilinear, 9 ms area (3.5x faster), bicubic and
images with alpha 20-40% faster. clone() copies in the array's
constructor instead of into a zeroed array: about 50% faster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
@matmen
matmen merged commit 7a84f55 into master Oct 16, 2026
13 checks passed
@matmen
matmen deleted the integer-resampling branch October 16, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant