Add aspect-preserving opaque image fitting

This commit is contained in:
2026-11-07 17:12:00 +01:00
parent bb90b2c060
commit 2620a17c64
3 changed files with 76 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Opaque image fitting
`ImageScaler.fitOpaque(source, maxWidth, maxHeight, background)` returns an
independent RGB BufferedImage. It fits within positive bounds without enlargement,
keeps aspect ratio to the nearest pixel (minimum one pixel per dimension), and
composites transparent pixels onto the supplied opaque AWT Color. Bilinear
interpolation is used. The source is unchanged and graphics resources are released.
This operation is independent of file format and wire encoding. Callers own image
decoding, any source subsampling, and target quantization. Existing PNG scaling
methods retain their original behavior.
`ImageScalerTest` verifies fitting, no enlargement, transparent/partial-alpha
composition, narrow images, source preservation, and invalid bounds.
@@ -17,6 +17,41 @@ import java.io.InputStream;
@Slf4j @Slf4j
public class ImageScaler { public class ImageScaler {
/**
* Fits an image within positive bounds without enlargement, retaining aspect ratio
* to the nearest pixel (at least one pixel per dimension). Composites alpha onto
* the supplied opaque background and returns an independent RGB image.
*
* @param source source pixels, never modified
* @param maxWidth maximum output width
* @param maxHeight maximum output height
* @param background opaque background colour
* @return fitted image with no alpha channel
* @throws IllegalArgumentException for non-positive bounds or translucent background
*/
public static BufferedImage fitOpaque(final BufferedImage source, final int maxWidth,
final int maxHeight, final Color background) {
java.util.Objects.requireNonNull(source, "source");
java.util.Objects.requireNonNull(background, "background");
if (maxWidth < 1 || maxHeight < 1 || background.getAlpha() != 255)
throw new IllegalArgumentException("Positive bounds and an opaque background are required");
double scale = Math.min(1.0, Math.min((double) maxWidth / source.getWidth(),
(double) maxHeight / source.getHeight()));
int width = Math.max(1, (int) Math.round(source.getWidth() * scale));
int height = Math.max(1, (int) Math.round(source.getHeight() * scale));
BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = result.createGraphics();
try {
graphics.setColor(background);
graphics.fillRect(0, 0, width, height);
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics.drawImage(source, 0, 0, width, height, null);
} finally {
graphics.dispose();
}
return result;
}
/** /**
* Specifies the target image format for scaling operations. * Specifies the target image format for scaling operations.
* <p> * <p>
@@ -0,0 +1,27 @@
package de.neitzel.core.image;
import java.awt.Color;
import java.awt.image.BufferedImage;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ImageScalerTest {
@Test void fitsWithoutUpscalingAndCompositesTransparency() {
BufferedImage source = new BufferedImage(128,64,BufferedImage.TYPE_INT_ARGB);
BufferedImage result = ImageScaler.fitOpaque(source,64,64,Color.BLUE);
assertEquals(64,result.getWidth());
assertEquals(32,result.getHeight());
assertEquals(Color.BLUE.getRGB(),result.getRGB(0,0));
assertEquals(0,source.getRGB(0,0));
BufferedImage tiny = new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB);
tiny.setRGB(0,0,0x80ff0000);
result = ImageScaler.fitOpaque(tiny,64,64,Color.BLUE);
assertEquals(1,result.getWidth());
Color mixed = new Color(result.getRGB(0,0));
assertEquals(128,mixed.getRed(),1);
assertEquals(127,mixed.getBlue(),1);
BufferedImage narrow = ImageScaler.fitOpaque(new BufferedImage(1,1000,BufferedImage.TYPE_INT_ARGB),64,64,Color.BLACK);
assertEquals(1,narrow.getWidth()); assertEquals(64,narrow.getHeight());
assertThrows(IllegalArgumentException.class, () -> ImageScaler.fitOpaque(tiny,0,64,Color.BLACK));
}
}