iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💻

Reducing Image File Size

に公開

How are you spending the 2023 Golden Week? I accidentally started using Bluesky and tried playing around with the following official Go package:

https://github.com/bluesky-social/indigo

I got stuck when uploading image files. It seems that the current official PDS cannot upload image files larger than 1MB to the server (and it doesn't even return an upload failure). I went through some trial and error to keep the image data under 1MB. Let me share that experience here.

Now, let's change the tone and get to the main point.

The following methods can be considered for reducing the size of image data (accepting that it will involve lossy compression):

  1. Convert to JPEG format (especially effective for PNG).
  2. Lower the image quality (in the case of JPEG).
  3. Reduce the image dimensions.

So, following this policy, let's actually write a function to reduce image data.

Final Version

For now, the final version looks like this.

github.com/goark/toolbox/images/images.go
const (
    imageMaxSize     = 1000
    imageFileMaxSize = 1024 * 1024
)

func AjustImage(src []byte) (io.Reader, error) {
    // check file size
    if len(src) < imageFileMaxSize {
        return bytes.NewReader(src), nil
    }

    // decode image
    imgSrc, t, err := image.Decode(bytes.NewReader(src))
    if err != nil {
        return nil, errs.Wrap(err)
    }
    // convert JPEG
    quality := 90
    if t != "jpeg" {
        b, err := convertJPEG(imgSrc, quality)
        if err != nil {
            return nil, errs.Wrap(err)
        }
        if len(b) < imageFileMaxSize {
            return bytes.NewReader(b), nil
        }
    }
    // quality down
    for _, q := range []int{85, 55, 25} {
        b, err := convertJPEG(imgSrc, q)
        if err != nil {
            return nil, errs.Wrap(err)
        }
        quality = q
        if len(b) < imageFileMaxSize {
            return bytes.NewReader(b), nil
        }
    }

    // rectange of image
    rctSrc := imgSrc.Bounds()
    rate := 1.0
    if rctSrc.Dx() > rctSrc.Dy() {
        if rctSrc.Dx() > imageMaxSize {
            rate = imageMaxSize / float64(rctSrc.Dx())
        }
    } else {
        if rctSrc.Dy() > imageMaxSize {
            rate = imageMaxSize / float64(rctSrc.Dy())
        }
    }
    if rate >= 1.0 {
        return nil, errs.Wrap(ecode.ErrTooLargeImage)
    }

    // scale down
    dstX := int(float64(rctSrc.Dx()) * rate)
    dstY := int(float64(rctSrc.Dy()) * rate)
    imgDst := image.NewRGBA(image.Rect(0, 0, dstX, dstY))
    draw.BiLinear.Scale(imgDst, imgDst.Bounds(), imgSrc, rctSrc, draw.Over, nil)
    b, err := convertJPEG(imgDst, quality)
    if err != nil {
        return nil, errs.Wrap(err)
    }
    if len(b) > imageFileMaxSize {
        return nil, errs.Wrap(ecode.ErrTooLargeImage)
    }
    return bytes.NewReader(b), nil
}

func convertJPEG(src image.Image, quality int) ([]byte, error) {
    dst := &bytes.Buffer{}
    if err := jpeg.Encode(dst, src, &jpeg.Options{Quality: quality}); err != nil {
        return nil, errs.Wrap(err)
    }
    return dst.Bytes(), nil
}

As a prerequisite, the image data is assumed to be stored in a []byte slice. This is then passed to the AjustImage() function.

Decoding into image.Image type

Let's look at it in detail. First, check the size. If the data size is under 1MB, return it as is.

if len(src) < imageFileMaxSize {
    return bytes.NewReader(src), nil
}

Next, decode the image data into an image.Image type.

imgSrc, t, err := image.Decode(bytes.NewReader(src))
if err != nil {
    return nil, errs.Wrap(err)
}

Trying to Convert to JPEG Format

Converting the created imgSrc into JPEG binary format is simple. It can be done with the following function:

func convertJPEG(src image.Image, quality int) ([]byte, error) {
    dst := &bytes.Buffer{}
    if err := jpeg.Encode(dst, src, &jpeg.Options{Quality: quality}); err != nil {
        return nil, errs.Wrap(err)
    }
    return dst.Bytes(), nil
}

Using this, we convert non-JPEG image data and check the size.

quality := 90
if t != "jpeg" {
    b, err := convertJPEG(imgSrc, quality)
    if err != nil {
        return nil, errs.Wrap(err)
    }
    if len(b) < imageFileMaxSize {
        return bytes.NewReader(b), nil
    }
}

If this conversion results in a size of 1MB or less, that data is returned. The quality is set to 90 (I hear JPEG quality doesn't change much above 90).

Trying to Lower the Quality

Next, we check the data size by lowering the quality to 85, 55, and 25, including for JPEG data.

for _, q := range []int{85, 55, 25} {
    b, err := convertJPEG(imgSrc, q)
    if err != nil {
        return nil, errs.Wrap(err)
    }
    quality = q
    if len(b) < imageFileMaxSize {
        return bytes.NewReader(b), nil
    }
}

It seems that dropping the quality to 85 can dramatically reduce the file size, so I'm hoping it falls under 1MB here. The rest is just a bonus (lol).

Trying to Reduce the Image Dimensions

If the size still exceeds 1MB even after dropping the quality to 25, the last resort is to reduce the dimensions of the image.

First, calculate the ratio to scale down the larger dimension (width or height) to 1,000 pixels.

rctSrc := imgSrc.Bounds()
rate := 1.0
if rctSrc.Dx() > rctSrc.Dy() {
    if rctSrc.Dx() > imageMaxSize {
        rate = imageMaxSize / float64(rctSrc.Dx())
    }
} else {
    if rctSrc.Dy() > imageMaxSize {
        rate = imageMaxSize / float64(rctSrc.Dy())
    }
}
if rate >= 1.0 {
    return nil, errs.Wrap(ecode.ErrTooLargeImage)
}

If the original size is already smaller than 1,000 pixels, it gives up and returns an error (shrinking it even further would be... well, you know).

Next, scale down using the calculated ratio.

dstX := int(float64(rctSrc.Dx()) * rate)
dstY := int(float64(rctSrc.Dy()) * rate)
imgDst := image.NewRGBA(image.Rect(0, 0, dstX, dstY))
draw.BiLinear.Scale(imgDst, imgDst.Bounds(), imgSrc, rctSrc, draw.Over, nil)
b, err := convertJPEG(imgDst, quality)
if err != nil {
    return nil, errs.Wrap(err)
}
if len(b) > imageFileMaxSize {
    return nil, errs.Wrap(ecode.ErrTooLargeImage)
}
return bytes.NewReader(b), nil

If the file size still exceeds 1MB even after reducing the image dimensions, it gives up and returns an error.

Results

With the function I wrote this time, I was finally able to upload large image files to Bluesky.

It seems that preview images shown when displaying link cards from URLs in messages also often run into this 1MB limit, so after applying the same treatment, they now display correctly.

If you have any ideas on how to do this more smartly rather than such a brute-force method, please let me know 🙇

Please note that the tool I made this time is strictly for my own use (mainly for batch processing). For a CLI tool that implements almost all ATP (Authenticated Transfer Protocol) features, I recommend the one by mattn.

https://github.com/mattn/bsky

I've referred to the code there quite a bit. Much appreciated.

References

https://gihyo.jp/article/2023/04/bluesky-atprotocol
https://qiita.com/miyanaga/items/a616261de490cc342d08
https://text.baldanders.info/golang/resize-image/
https://text.baldanders.info/golang/resize-image-2/

GitHubで編集を提案

Discussion