Skip to content

Include the half-sample boundary in the Box filter - #115

Merged
anthonynsimon merged 1 commit into
anthonynsimon:masterfrom
youdie006:box-filter-halfway-sample
Sep 7, 2026
Merged

anthonynsimon merged 1 commit into
anthonynsimon:masterfrom
youdie006:box-filter-halfway-sample

Conversation

@youdie006

Copy link
Copy Markdown
Contributor

transform.Resize with Box turns pixels transparent black. On a solid opaque white 2x2 upscaled
to 3x3:

{255 255 255 255} {0 0 0 0} {255 255 255 255}
{0 0 0 0}         {0 0 0 0} {0 0 0 0}
{255 255 255 255} {0 0 0 0} {255 255 255 255}

Not just a toy size. A photo-shaped upscale of a fully opaque white image, 640x480 -> 960x720,
comes out with 384,000 of 691,200 pixels (55.6%) transparent black. It is reachable from the
CLI too, via --filter box (cmd/helpers.go:144).

Cause

Box is the only filter whose window can contain zero non-zero weights. Its Fn
(transform/filters.go:50) is strictly < 0.5 on both sides, and Support: 0.5 gives
filterRadius == 1 when upscaling. When a destination centre lands exactly midway between two
source samples, both neighbours sit at |normPos| == 0.5 and score 0, so sum == 0 and r/sum
at transform/resize.go:93 is 0/0 = NaN. uint8(NaN) is 0.

From a window-arithmetic probe, source width 2 -> destination width 3:

x=0  ix=-0.1666  window=[0,0]  sum=1   [kx=0 normPos=0.1666  w=1]
x=1  ix= 0.5     window=[0,1]  sum=0   [kx=0 normPos=-0.5 w=0   kx=1 normPos=0.5 w=0]
x=2  ix= 1.1666  window=[0,1]  sum=1   [kx=1 normPos=-0.1666 w=1]

Sweeping a solid white source over every (srcW, dstW) pair from 1 to 16 and counting output
bytes that are not 0xFF:

filter wrong after
NearestNeighbor 0 0
Box 216 0
Linear 0 0
Gaussian 0 0
MitchellNetravali 0 0
CatmullRom 0 0
Lanczos 0 0

35 distinct pairs break, all upscales from an even source width: 2->3, 2->5, 4->6, 6->9, 8->12,
12->13, 14->15, and so on.

The change

One operator, < to <=. It only alters output where |normPos| == 0.5 exactly - the degenerate
case - and at a midpoint both straddled samples now weigh 1, so the pixel becomes their average,
which is what a box filter straddling a sample boundary should give.

disintegration/imaging, whose ResampleFilter{Support, Kernel} shape this package's
ResampleFilter{Support, Fn} follows, uses x <= 0.5 for its Box (resize.go:448).

The doc comment did say x < 0.5, so this could be read as documented intent. I do not think
it survives, because the documented behaviour has no defensible output - it divides by zero and
produces a transparent black pixel from an opaque white image. The comment reads as describing the
bug rather than sanctioning it, and I updated that line with the fix. Happy to be told otherwise.

Tests

TestResizeBoxHalfwaySample in transform/resize_test.go, table-driven with manual pixel data and
util.RGBAImageEqual, matching the convention CLAUDE.md documents. I used a solid-colour
invariant rather than a value table on purpose: the expected ratio is exactly 255 in exact
arithmetic and clamps to 255 on either side of rounding, so it will not join #72 / #108 as another
arm64 floating-point test failure.

Reverting only the operator makes all three cases fail with the visible transparent cross. The
existing TestResizeBox - both its x2 upscale and its x0.5 downscale expectations - passes
unchanged, so there is no blast radius on existing values.

Ran what .github/workflows/check.yml runs: make build and make test green, plus make race
green across all 18 packages. gofmt -l empty and go vet ./... clean, before and after.

Precedent

Issue #37 "effect.Grayscale returns black pixels for a pure white color" and PR #45 "Fix integer
overflow on white colors" are the same class - correct numeric handling producing black pixels
from white input - and #45 was merged.


Disclosure: found and prepared with AI assistance (Claude). Every figure above is from a run on
this branch.

Box was the only resample filter whose window can hold zero non-zero
weights: with Support 0.5 and a strict comparison, a destination centre
exactly midway between two source samples scores both neighbours at
zero, so the weighted sum is zero and r/sum is NaN, which converts to a
transparent black pixel.
@anthonynsimon
anthonynsimon merged commit 5b49173 into anthonynsimon:master Sep 7, 2026
4 checks passed
@anthonynsimon

Copy link
Copy Markdown
Owner

Thanks!

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.

2 participants