Skip to content

Instantly share code, notes, and snippets.

@petebankhead
Created October 10, 2024 16:53
Show Gist options
  • Save petebankhead/af432bea93627f4775c9680a23459339 to your computer and use it in GitHub Desktop.
Save petebankhead/af432bea93627f4775c9680a23459339 to your computer and use it in GitHub Desktop.
QuPath script to help convert one or more images to PNG or JPEG, while also reducing the dimensions and file size
/**
* QuPath script to help convert one or more images to PNG or JPEG,
* while also reducing the dimensions and file size.
*
* The purpose is to help convert images for websites in a way that balances file size with quality.
*
* Given an image file or directory containing multiple files (usually screenshots), this will:
* - Strip away any unnecessary alpha channel
* - Resize so that the width and height are <= a specified maximum dimension
* - Iteratively try to convert the image to PNG and/or JPEG by
* - Attempting different quality settings
* - Decreasing the size further
* until a small enough image can be written
* - Write the output image(s) to a subdirectory beside the original image
*
* Written for QuPath v0.6.0 (may work in earlier versions)
*/
// ADD YOUR PATH HERE!
// This can be to a single file or a directory
def inputPath = ""
// SPECIFY MAX BYTES PER FILE
int maxBytes = 500_000
// SPECIFY MAX WIDTH OR HEIGHT PER IMAGE
int maxDim = 1200
// SPECIFY ONE OR MORE EXTENSIONS FOR EXPORT IMAGES
List<String> extensions = ["jpg", "png"]
//------
import org.bytedeco.javacpp.PointerScope
import org.bytedeco.opencv.global.opencv_imgproc
import org.bytedeco.opencv.opencv_core.Size
import qupath.lib.awt.common.BufferedImageTools
import qupath.lib.common.GeneralTools
import qupath.opencv.tools.OpenCVTools
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageWriteParam
import java.awt.image.BufferedImage
import java.util.stream.IntStream
// Get all the images to process
def file = new File(inputPath)
if (inputPath.isEmpty() || !file.exists()) {
println "No input file given!"
return
}
List<File> allFiles
if (file.isDirectory())
allFiles = file.listFiles().findAll {f -> f.isFile() && GeneralTools.checkExtensions(f.getName(), "png", "jpg", "jpeg", "tif", "gif")}
else
allFiles = [file]
// Do the file conversion... in parallel (why not?)
allFiles.parallelStream().forEach(f -> convertFile(f, maxBytes, maxDim, "compressed", extensions))
/**
* Convert the image file, compressing it as needed.
* @param file the input image
* @param maxBytes maximum bytes in the output file
* @param maxDim maximum width or height for the output image
* @param subDirName subdirectory where the export image should be written
* @param extensions the file extensions to use for compression
*/
void convertFile(File file, int maxBytes, int maxDim, String subDirName = "compressed", List<String> extensions = ["png", "jpg"]) {
var img = ImageIO.read(file)
if (img == null) {
println "No image for " + file
return
}
img = stripEmptyAlpha(img)
var parent = new File(file.getParentFile(), subDirName)
if (!parent.exists())
parent.mkdirs()
// Write JPEG and PNG - we'll decide later which to use
for (def ext in extensions) {
byte[] bytes= fitToSize(img, maxBytes, maxDim, ext)
String name = GeneralTools.getNameWithoutExtension(file)
new File(parent, "${name}.${ext}").bytes = bytes
}
}
/**
* Iteratively try to convert an image to the specified format, aiming to have fewer bytes than the requested max.
* @param img
* @param maxBytes
* @param maxDim
* @param fmt
* @return
*/
byte[] fitToSize(BufferedImage img, int maxBytes = 500_000, int maxDim = 800, String fmt = "PNG") {
while (true) {
var imgScaled = scaleToMaxDim(img, maxDim)
for (float q in getQuality(fmt)) {
byte[] bytes = writeWithQuality(imgScaled, quality=q, format=fmt)
if (bytes.length <= maxBytes)
return bytes
}
// Decrease the max dimension by about 10%
maxDim = (int)Math.round(maxDim * 0.9)
}
throw new RuntimeException("I could not figure out how to match the image criteria!")
}
/**
* Strip an alpha channel if it is found, and if it has all values of 255.
* @param img
* @return
*/
BufferedImage stripEmptyAlpha(BufferedImage img) {
var raster = img.getAlphaRaster()
if (raster == null)
return img
int[] alpha = raster.getSamples(0, 0, raster.getWidth(), raster.getHeight(), 0, (int[])null)
if (IntStream.of(alpha).allMatch(i -> i != 255))
return img
return BufferedImageTools.ensureBufferedImageType(img, BufferedImage.TYPE_INT_RGB)
}
/**
* Scale an image so that its largest dimension is <= maxDim
* @param img
* @param maxDim
* @return
*/
BufferedImage scaleToMaxDim(BufferedImage img, int maxDim) {
if (img.getWidth() <= maxDim && img.getHeight() <= maxDim)
return img
int width
int height
if (img.getWidth() >= img.getHeight()) {
width = computeNewDim(img.getWidth(), maxDim)
height = (int)Math.round(img.getHeight() * (width / (double)img.getWidth()))
} else {
height = computeNewDim(img.getHeight(), maxDim)
width = (int)Math.round(img.getWidth() * (height / (double)img.getHeight()))
}
return resize(img, width, height)
}
static BufferedImage resize(BufferedImage img, int width, int height) {
return resizeQuPath(img, width, height)
}
static BufferedImage resizeOpenCV(BufferedImage img, int width, int height) {
try (var scope = new PointerScope()) {
var mat = OpenCVTools.imageToMat(img)
opencv_imgproc.resize(mat, mat, new Size(width, height), -1, -1, opencv_imgproc.INTER_CUBIC)
return OpenCVTools.matToBufferedImage(mat)
}
}
static BufferedImage resizeQuPath(BufferedImage img, int width, int height) {
return BufferedImageTools.resize(img, width, height, true)
}
BufferedImage resizeBufferedImage(img, int width, int height) {
var imgScaled = img.getScaledInstance(width, height, BufferedImage.SCALE_AREA_AVERAGING | BufferedImage.SCALE_SMOOTH)
return stripEmptyAlpha(BufferedImageTools.ensureBufferedImage(imgScaled))
}
/**
* Compute a new dimension length, which is <= the current length and also <= a maximum length.
* This implementation ensures we use integer scaling factors (this may change).
* @param currentDim the current dimension
* @param maxDim the maximum dimension
* @return
*/
static int computeNewDim(int currentDim, int maxDim) {
if (currentDim <= maxDim)
return currentDim
return maxDim
// return currentDim / Math.ceil(currentDim / (double)maxDim)
}
/**
* Write a PNG or JPEG image with the specified quality setting (0-1)
* @param img
* @param quality
* @param format
* @return
*/
byte[] writeWithQuality(BufferedImage img, Float quality = null, String format="PNG") {
var writer = ImageIO.getImageWritersByFormatName(format.toUpperCase()).next()
var param = writer.getDefaultWriteParam()
if (quality != null && param.canWriteCompressed()) {
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT)
param.setCompressionQuality(quality)
}
try (var stream = new ByteArrayOutputStream(img.getWidth() * img.getHeight())) {
try (var out = ImageIO.createImageOutputStream(stream)) {
writer.setOutput(out)
writer.addIIOWriteWarningListener {(a, b, c) -> println c }
writer.write(null, new IIOImage(img, null, null), param)
return stream.toByteArray()
}
}
}
static float[] getQuality(String format) {
if (format.equalsIgnoreCase("png")) {
// Reasonable PNG quality
return [0.4f, 0f] as float[]
} else {
// Acceptable JPEG quality
return [1f, 0.95f, 0.9f, 0.8f] as float[]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment