Rough Edges With Lanczos Resampling in Golang

716 views Asked by At

I've been writing some basic methods to resize images in Golang. I've seen several posts about resizing images, but for the life of me I can't figure out what I'm missing...

Essentially, my issue is that when resizing an image in Golang, my results seem to have a lot of aliasing.

I've tried iteratively downsampling the image, but that didn't yield much of an improvement.

Here's my code:

func resize(original image.Image,
    edgeSize int, filterSize int) image.Image {

    oldBounds := original.Bounds()
    if oldBounds.Dx() < edgeSize && oldBounds.Dy() < edgeSize {
        // No resize necessary
        return original
    }

    threshold := edgeSize * 4 / 3
    if oldBounds.Dx() > threshold || oldBounds.Dy() > threshold {
        fmt.Println("Upstream")
        original = customResizeImageToFitBounds(original, threshold, filterSize)
        oldBounds = original.Bounds()
    }

    newBounds := getNewBounds(oldBounds, edgeSize)

    resized := image.NewRGBA(newBounds)

    var ratioX = float64(oldBounds.Dx()) / float64(newBounds.Dx())
    var ratioY = float64(oldBounds.Dy()) / float64(newBounds.Dy())

    for x := 0; x < newBounds.Dx(); x++ {
        for y := 0; y < newBounds.Dy(); y++ {
            sourceX := ratioX * float64(x)
            minX := int(math.Floor(sourceX))

            sourceY := ratioY * float64(y)
            minY := int(math.Floor(sourceY))

            sampleSize := filterSize<<1 + 1
            var xCoeffs = make([]float64, sampleSize)
            var yCoeffs = make([]float64, sampleSize)

            var sumX = 0.0
            var sumY = 0.0
            for i := 0; i < sampleSize; i++ {
                xCoeffs[i] = lanczos(filterSize, sourceX-float64(minX+i-filterSize))
                yCoeffs[i] = lanczos(filterSize, sourceY-float64(minY+i-filterSize))

                sumX += xCoeffs[i]
                sumY += yCoeffs[i]
            }

            for i := 0; i < sampleSize; i++ {
                xCoeffs[i] /= sumX
                yCoeffs[i] /= sumY
            }

            rgba := make([]float64, 4)

            for i := 0; i < sampleSize; i++ {
                if yCoeffs[i] == 0.0 {
                    continue
                }
                currY := minY + i - filterSize

                rgbaRow := make([]float64, 4)
                for j := 0; j < sampleSize; j++ {
                    if xCoeffs[j] == 0.0 {
                        continue
                    }
                    currX := minX + i - filterSize

                    rij, gij, bij, aij := original.At(
                        clamp(currX, currY, oldBounds)).RGBA()

                    rgbaRow[0] += float64(rij) * xCoeffs[j]
                    rgbaRow[1] += float64(gij) * xCoeffs[j]
                    rgbaRow[2] += float64(bij) * xCoeffs[j]
                    rgbaRow[3] += float64(aij) * xCoeffs[j]
                }
                rgba[0] += float64(rgbaRow[0]) * yCoeffs[i]
                rgba[1] += float64(rgbaRow[1]) * yCoeffs[i]
                rgba[2] += float64(rgbaRow[2]) * yCoeffs[i]
                rgba[3] += float64(rgbaRow[3]) * yCoeffs[i]
            }

            rgba[0] = clampRangeFloat(0, rgba[0], 0xFFFF)
            rgba[1] = clampRangeFloat(0, rgba[1], 0xFFFF)
            rgba[2] = clampRangeFloat(0, rgba[2], 0xFFFF)
            rgba[3] = clampRangeFloat(0, rgba[3], 0xFFFF)

            var rgbaF [4]uint64
            rgbaF[0] = (uint64(math.Floor(rgba[0]+0.5)) * 0xFF) / 0xFFFF
            rgbaF[1] = (uint64(math.Floor(rgba[1]+0.5)) * 0xFF) / 0xFFFF
            rgbaF[2] = (uint64(math.Floor(rgba[2]+0.5)) * 0xFF) / 0xFFFF
            rgbaF[3] = (uint64(math.Floor(rgba[3]+0.5)) * 0xFF) / 0xFFFF

            rf := uint8(clampRangeUint(0, uint32(rgbaF[0]), 255))
            gf := uint8(clampRangeUint(0, uint32(rgbaF[1]), 255))
            bf := uint8(clampRangeUint(0, uint32(rgbaF[2]), 255))
            af := uint8(clampRangeUint(0, uint32(rgbaF[3]), 255))

            resized.Set(x, y, color.RGBA{R: rf, G: gf, B: bf, A: af})
        }
    }
    return resized
}


// Machine epsilon
var epsilon = math.Nextafter(1.0, 2.0) - 1

func lanczos(filterSize int, x float64) float64 {
    x = math.Abs(x)
    fs := float64(filterSize)
    if x < epsilon {
        return 1.0
    }

    if x > fs {
        return 0
    }

    piX := math.Pi * x
    piXOverFS := piX / fs
    return (math.Sin(piX) / piX) * (math.Sin(piXOverFS) / (piXOverFS))
}

It isn't particularly performant, because I want to get a good quality result before I look at optimization.

Does anyone who has experience with image resampling see anything potentially problematic?

For reference, here is my source image: Source Image

Here is my result: enter image description here

Here is my result if I remove the recursive call: enter image description here

Here is the result using RMagick/ImageMagick through Ruby (what I'm shooting for): enter image description here

Does anyone have advice for how I can get a smoother downscale result? This particular example is a pretty drastic downscale, but Rmagick was able to downscale it very quickly with great quality, so it must be possible.

I'm told that Lanczos3 Resampling yields good results, and that's what I'm trying to use here - I'm not sure if my implementation is correct though.

Also, as a side note: the 0xFF / 0xFFFF conversion is because golang's "At" function returns rgba values in the range [0, 0xFFFF] ([0, 65535]) but "Set" takes a color which is initialized with the range [0, 0xFF] ([0, 255])

For now, I'm more concerned with quality than performance.

1

There are 1 answers

0
Anish Goyal On BEST ANSWER

Alright, I think I've found one way to solve the aliasing problem. Instead of using lanczos3, I used bilinear interpolation to resample the source image at a size slightly higher than what I was going for (edgeSize = 1080), gaussian blurred the image, then scaled the image down to the target size (edgeSize = 600), this time with bicubic interpolation. This gave me results just about the same as the ones RMagick was giving me.