Skip to content

Use a sliding histogram for 8-bit rank filters - #10404

Open
cneuralnetwork wants to merge 2 commits into
python-pillow:mainfrom
cneuralnetwork:perf/rankfilter-histogram
Open

cneuralnetwork wants to merge 2 commits into
python-pillow:mainfrom
cneuralnetwork:perf/rankfilter-histogram

Conversation

@cneuralnetwork

@cneuralnetwork cneuralnetwork commented Nov 3, 2026 •

Copy link
Copy Markdown

Median, min, max and other rank filters on 8-bit images currently copy the whole size × size window for every output pixel and run a quickselect on it, so the cost per pixel grows with size².

This PR keeps a 256-bin histogram per row and updates it as the window slides right: one column out, one column in, so the cost per pixel grows with size instead (Huang, 1979). The output value is tracked with a running count of samples below it, which moves only slightly between neighbouring pixels. INT32 and FLOAT32 images keep the quickselect path.

Output is byte-identical to Pillow 11.3.0, and RankFilter.c hasn't changed on main since then.

Benchmark

1024×1024 random noise, Apple M5, best of 3 runs (1 run for sizes 15 and 31):

Filter L before L after Speedup RGB before RGB after Speedup
MedianFilter(3) 0.102 s 0.031 s 3.3x 0.301 s 0.111 s 2.7x
MedianFilter(7) 0.724 s 0.030 s 24x 2.17 s 0.096 s 23x
MedianFilter(15) 2.87 s 0.036 s 80x 8.63 s 0.116 s 74x
MedianFilter(31) 11.4 s 0.045 s 254x 34.5 s 0.148 s 233x
MinFilter(3) 0.070 s 0.023 s 3.1x 0.213 s 0.069 s 3.1x
MaxFilter(5) 0.248 s 0.018 s 14x 0.750 s 0.051 s 15x

Tests

Adds test_rankfilter_l_matches_reference, which checks the 8-bit path against a brute-force rank on edge-padded windows for sizes 1–25 and the lowest, middle and highest ranks. The full test suite, selftest.py, black, ruff, clang-format and mypy pass locally.

A release note is added under 13.1.0, "Other changes".

Relation to #10045

#10045 also touches RankFilter.c. It hoists row lookups and special-cases min/max, but doesn't change the algorithm. The two will conflict. For 8-bit images this PR makes the min/max special case unnecessary, while #10045's changes still help INT32/FLOAT32. I'm happy to rebase onto it or adapt to whichever lands first.

🤖 Generated with Claude Code

Median, min, max and other rank filters on 8-bit images copied the
whole size x size window for every output pixel and ran a quickselect
on it, costing O(size^2) per pixel. Keep a 256-bin histogram per row
instead and update it as the window slides right: one column out, one
column in, O(size) per pixel. The output value is tracked with a
running count of samples below it, which moves only slightly between
neighbouring pixels.

Output is byte-identical. On a 1024x1024 L image (Apple M5):

  MedianFilter(3)   0.102s -> 0.031s
  MedianFilter(7)   0.724s -> 0.030s
  MedianFilter(15)  2.868s -> 0.036s
  MedianFilter(31) 11.434s -> 0.045s

INT32 and FLOAT32 images keep the quickselect path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@radarhere radarhere added the 🤖-assisted AI-assisted label Nov 3, 2026
Break RankUINT8 (cyclomatic complexity 10) into FillHistogram,
SlideHistogram, SeekRank and RankRowUINT8, each with a complexity of
3 or less. Move the brute-force reference in the new test into a
helper, read pixels with tobytes() so it type-checks, and use
get_flattened_data() instead of the deprecated getdata().

Add a release note for the faster rank filters.

Output and timings are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@radarhere

Copy link
Copy Markdown
Member

@akx as the author of #10045, would you like to review?

@cneuralnetwork
cneuralnetwork marked this pull request as ready for review November 3, 2026 05:06
@akx

akx commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Measuring on random noise, especially for statistical filters like this, is not a great idea. 😅 We hit that with benchmarks and fixed it in #9831. We'll see what more representative numbers are when the CI benchmark finishes.

I measured locally, and the histogram approach from here is a clear win for the general rank case. However, by my local numbers #10045 looks to be up to 60x faster for min/max, so "For 8-bit images this PR makes the min/max special case unnecessary" is not true.

IOW, I'd say a combination of both my #10045 and this would be the best solution. As @cneuralnetwork here noted, they're happy to rebase this if it gets merged.

As an aside, #10403 seems to have had momentarily implemented the exact same code as here 🤖 😄

@codspeed

codspeed Bot commented Nov 3, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by ×4.1

2 warnings

⚠️ 3 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 3 benchmarks with a significant change ran in a different software environment

Their base and head results were measured with a different OS or toolchain, such as compiler, runtime or library versions, so the changes might come from this difference rather than from the code. Open an affected benchmark to see what changed.

See affected benchmarks

⚡ 3 improved benchmarks
✅ 665 untouched benchmarks
⏩ 352 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ test_rank_filter[1237x811-L-Median5] 500.9 ms 92.5 ms ×5.4
⚡ test_rank_filter[1237x811-L-Min3] 268.8 ms 75.4 ms ×3.6
⚡ test_rank_filter[1237x811-L-Median3] 267.6 ms 77.5 ms ×3.5

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing cneuralnetwork:perf/rankfilter-histogram (f0bc842) with main (345d059)

Open in CodSpeed

Footnotes

  1. 352 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@cneuralnetwork

Copy link
Copy Markdown
Author

Yes, I agree! Combining the best of both approaches seems like the way to go, especially given the performance difference for the min/max case.

Also, just to clarify the duplicate PRs: I accidentally opened the original PR from my work account, so I had to close it and resubmit it from my personal account. That's why there are two PRs covering the same changes. This is the one that should be considered going forward.

@akx

akx commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Btw, the non-split commit 1795abb reads better to me than the split one.

@cneuralnetwork

Copy link
Copy Markdown
Author

You can actually use that as well. I actually split it because I prefer writing code with less cyclomatic complexity. That's why I had split it but the earlier one works as well.

@akx

akx commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

I think cyclomatic complexity doesn't matter that much for an "do a single thing" function like this -- the split version is +61 lines on top of the +55 lines of the first edition, and needs more cycles from the reviewer/reader to understand how pointers and arrays are being tossed around, don't you think? 😄

I suspect compilers will be able to better optimize the function in the non-split form anyway.

@cneuralnetwork

Copy link
Copy Markdown
Author

Fair point!
I usually work with really complicated code at work. That's why I have autoset that it should try to reduce the cyclomatic complexity as well. And I kind of agree with you. For one function, it is not needed that much.
Happy to stick with the original structure :D

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants