sharp icon indicating copy to clipboard operation
sharp copied to clipboard

Unexpected rotation behavior for different size outputs after upgrading Sharp

Open timturnerwhcc opened this issue 1 year ago • 1 comments

Question about an existing feature

What are you trying to achieve?

We're upgrading from Sharp 0.25.0 to 0.33.4 and experienced an unexpected change in behavior in how rotate(), resize() and *Metadata() functions interact with different size outputs.

The changelog alludes to changes with the ordering of rotate() and resize(), which makes sense.

However, we're seeing different behavior on the same input image based on different output sizes.

It's unclear if this happens all the time with certain orientations or not.

When you searched for similar issues, what did you find that might be related?

  • https://github.com/lovell/sharp/issues/4047
  • https://github.com/lovell/sharp/issues/4108
  • https://github.com/lovell/sharp/issues/2297

Please provide a minimal, standalone code sample, without other dependencies, that demonstrates this question

// the guts of "processImage" function referenced below
const sourceBuffer = await readFile(...) // Read input from FS
const resizeOptions = { width: 256, height: 256, fit: 'inside' } // Width/Height are always the same - 256 or 2048 in this example
const outputBuffer = await sharp(sourceBuffer)
    .resize(resizeOptions) // Changing the order of resize/rotate has different behavior. This order is what worked on 0.25.0, the inverse works correctly on 0.33.4
    .rotate() 
    .keepMetadata()
    .toFormat('jpeg')
    .toBuffer()

I wrote a Jest suite to validate the behavior I believe I want (normalize orientation to 1, normalize aspect ratio). The set of photos are from: https://github.com/recurser/exif-orientation-examples/

This suite will pass with rotate/resize, but not resize/rotate on Sharp 0.33.4

const round = (n: number, dp: number) => { // toFixed() wasn't working the way I wanted
    const h = +('1'.padEnd(dp+1, '0')) // 10 or 100 or 1000 or etc
    return Math.round(n * h) / h
}

const getNormalSize = ({ width, height, orientation }) => {
    return (orientation || 0) >= 5
      ? { width: height, height: width }
      : { width, height };
}

describe('Rotation Behavior', () => {
    const cases = [
        'Landscape_0',
        'Landscape_1',
        'Landscape_2',
        'Landscape_3',
        'Landscape_4',
        'Landscape_5',
        'Landscape_6',
        'Landscape_7',
        'Landscape_8',
        'Portrait_0',
        'Portrait_1',
        'Portrait_2',
        'Portrait_3',
        'Portrait_4',
        'Portrait_5',
        'Portrait_6',
        'Portrait_7',
        'Portrait_8'
    ]
    it.each(cases)('should rotate an image: Case %s', async (sourceFileName) => {
        const sourceBuffer = await readFile(`${samplesPath}/rotation/${sourceFileName}.jpg`)
        const sourceMetadata = await sharp(sourceBuffer).metadata()
        const renditionReq256 = {
            resize: {
                width: 256,
                height: 256,
                fit: 'inside'
            },
            outputMimeType: 'image/jpeg'
        }

        const renditionReq2048 = {
            resize: {
                width: 2048,
                height: 2048,
                fit: 'inside'
            },
            outputMimeType: 'image/jpeg'
        }
        const result256 = await processImage(sourceBuffer, renditionReq256)
        expect(result256).toBeDefined()
        const metadata256 = await sharp(result256).metadata()

        const result2048 = await processImage(sourceBuffer, renditionReq2048)
        expect(result2048).toBeDefined()
        const metadata2048 = await sharp(result2048).metadata()

        const originalNormalizedSize = getNormalSize(sourceMetadata as any)
        const originalAspectRatio = round(originalNormalizedSize.height! / originalNormalizedSize.width!, 1000)
        const result256AspectRatio = round(metadata256.height! / metadata256.width!, 1000)
        expect(metadata256.orientation).toEqual(1)
        expect(originalAspectRatio).toEqual(result256AspectRatio)

        const result2048AspectRatio = round(metadata2048.height! / metadata2048.width!, 1000)
        expect(metadata2048.orientation).toEqual(1)
        expect(originalAspectRatio).toEqual(result2048AspectRatio)
    })
})

Please provide sample image(s) that help explain this question

Input Image: door_input_ios

256px (Resize/Rotate) Output Image (WRONG): output_resize_rotate_keepMetadata_256

2048px (Resize/Rotate) Output Image: output_resize_rotate_keepMetadata_2048

256px (Rotate/Resize) Output Image: output_rotate_resize_keepMetadata_256

2048px (Rotate/Resize) Output Image: output_rotate_resize_keepMetadata_2048

timturnerwhcc avatar Jul 26 '24 21:07 timturnerwhcc

You probably need to use either .rotate() to auto-orient or .keepMetadata() to retain Orientation EXIF value but not both.

More fine-grained control over output metadata is now available via e.g. keepIccProfile - see https://sharp.pixelplumbing.com/api-output

There is also discussion about a future possible enhancement relating to this at https://github.com/lovell/sharp/issues/4144

lovell avatar Aug 11 '24 11:08 lovell

I hope this information helped. Please feel free to re-open with more details if further assistance is required.

lovell avatar Sep 08 '24 07:09 lovell