Created
October 10, 2024 16:53
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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