|
|
@@ -0,0 +1,259 @@
|
|
|
+package org.jeecg.modules.app.util;
|
|
|
+
|
|
|
+import javax.imageio.IIOImage;
|
|
|
+import javax.imageio.ImageIO;
|
|
|
+import javax.imageio.ImageReader;
|
|
|
+import javax.imageio.ImageWriteParam;
|
|
|
+import javax.imageio.ImageWriter;
|
|
|
+import javax.imageio.stream.ImageInputStream;
|
|
|
+import javax.imageio.stream.ImageOutputStream;
|
|
|
+import java.awt.Color;
|
|
|
+import java.awt.Graphics2D;
|
|
|
+import java.awt.RenderingHints;
|
|
|
+import java.awt.geom.AffineTransform;
|
|
|
+import java.awt.image.BufferedImage;
|
|
|
+import java.io.ByteArrayInputStream;
|
|
|
+import java.io.ByteArrayOutputStream;
|
|
|
+import java.io.IOException;
|
|
|
+import java.util.Iterator;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Converts a user supplied face photo to the conservative JPEG format accepted
|
|
|
+ * by Hikvision face terminals.
|
|
|
+ */
|
|
|
+public final class FaceImageNormalizer {
|
|
|
+
|
|
|
+ private static final int MAX_EDGE = 1024;
|
|
|
+ private static final int MIN_SHORT_EDGE = 480;
|
|
|
+ private static final int MIN_LONG_EDGE = 640;
|
|
|
+ private static final long MAX_SOURCE_PIXELS = 25_000_000L;
|
|
|
+ private static final int MAX_OUTPUT_BYTES = 190 * 1024;
|
|
|
+ public static final int MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
|
+ private static final float[] JPEG_QUALITIES = {0.95f, 0.90f, 0.85f, 0.80f, 0.75f, 0.70f, 0.65f, 0.60f, 0.55f};
|
|
|
+
|
|
|
+ private FaceImageNormalizer() {
|
|
|
+ }
|
|
|
+
|
|
|
+ public static byte[] normalize(byte[] sourceBytes) throws IOException {
|
|
|
+ if (sourceBytes == null || sourceBytes.length == 0) {
|
|
|
+ throw new IOException("人脸图片为空");
|
|
|
+ }
|
|
|
+ if (sourceBytes.length > MAX_SOURCE_BYTES) {
|
|
|
+ throw new IOException("人脸图片不能超过2MB");
|
|
|
+ }
|
|
|
+
|
|
|
+ BufferedImage source;
|
|
|
+ int sourceWidth;
|
|
|
+ int sourceHeight;
|
|
|
+ try (ByteArrayInputStream input = new ByteArrayInputStream(sourceBytes);
|
|
|
+ ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
|
|
|
+ if (imageInput == null) {
|
|
|
+ throw new IOException("无法读取人脸图片");
|
|
|
+ }
|
|
|
+ Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInput);
|
|
|
+ if (!readers.hasNext()) {
|
|
|
+ throw new IOException("无法识别人脸图片格式");
|
|
|
+ }
|
|
|
+ ImageReader reader = readers.next();
|
|
|
+ try {
|
|
|
+ reader.setInput(imageInput, true, true);
|
|
|
+ sourceWidth = reader.getWidth(0);
|
|
|
+ sourceHeight = reader.getHeight(0);
|
|
|
+ validateDimensions(sourceWidth, sourceHeight);
|
|
|
+ source = reader.read(0);
|
|
|
+ } finally {
|
|
|
+ reader.dispose();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (source == null) {
|
|
|
+ throw new IOException("无法解码人脸图片");
|
|
|
+ }
|
|
|
+
|
|
|
+ int orientation = readExifOrientation(sourceBytes);
|
|
|
+ int orientedWidth = orientation >= 5 && orientation <= 8 ? sourceHeight : sourceWidth;
|
|
|
+ int orientedHeight = orientation >= 5 && orientation <= 8 ? sourceWidth : sourceHeight;
|
|
|
+ int longEdge = Math.max(orientedWidth, orientedHeight);
|
|
|
+ double scale = Math.min(1.0d, (double) MAX_EDGE / longEdge);
|
|
|
+ int width = Math.max(1, (int) Math.round(orientedWidth * scale));
|
|
|
+ int height = Math.max(1, (int) Math.round(orientedHeight * scale));
|
|
|
+ BufferedImage normalized = renderAsSrgbJpegImage(source, width, height, orientation);
|
|
|
+
|
|
|
+ for (float quality : JPEG_QUALITIES) {
|
|
|
+ byte[] attempt = writeBaselineJpeg(normalized, quality);
|
|
|
+ if (attempt.length <= MAX_OUTPUT_BYTES) {
|
|
|
+ return attempt;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new IOException("人脸图片压缩后仍超过190KB");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void validateDimensions(int sourceWidth, int sourceHeight) throws IOException {
|
|
|
+ int shortEdge = Math.min(sourceWidth, sourceHeight);
|
|
|
+ int longEdge = Math.max(sourceWidth, sourceHeight);
|
|
|
+ if (shortEdge < MIN_SHORT_EDGE || longEdge < MIN_LONG_EDGE) {
|
|
|
+ throw new IOException("人脸图片分辨率过低,至少需要640×480");
|
|
|
+ }
|
|
|
+ if ((long) sourceWidth * sourceHeight > MAX_SOURCE_PIXELS) {
|
|
|
+ throw new IOException("人脸图片分辨率过高");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static BufferedImage renderAsSrgbJpegImage(BufferedImage source, int width, int height, int orientation) {
|
|
|
+ BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
|
|
|
+ Graphics2D graphics = target.createGraphics();
|
|
|
+ try {
|
|
|
+ graphics.setColor(Color.WHITE);
|
|
|
+ graphics.fillRect(0, 0, width, height);
|
|
|
+ graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
|
|
+ graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
|
|
+ graphics.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY);
|
|
|
+ int orientedWidth = orientation >= 5 && orientation <= 8 ? source.getHeight() : source.getWidth();
|
|
|
+ int orientedHeight = orientation >= 5 && orientation <= 8 ? source.getWidth() : source.getHeight();
|
|
|
+ AffineTransform transform = AffineTransform.getScaleInstance(
|
|
|
+ (double) width / orientedWidth, (double) height / orientedHeight);
|
|
|
+ transform.concatenate(orientationTransform(orientation, source.getWidth(), source.getHeight()));
|
|
|
+ graphics.drawImage(source, transform, null);
|
|
|
+ } finally {
|
|
|
+ graphics.dispose();
|
|
|
+ }
|
|
|
+ return target;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static AffineTransform orientationTransform(int orientation, int width, int height) {
|
|
|
+ switch (orientation) {
|
|
|
+ case 2:
|
|
|
+ return new AffineTransform(-1, 0, 0, 1, width, 0);
|
|
|
+ case 3:
|
|
|
+ return new AffineTransform(-1, 0, 0, -1, width, height);
|
|
|
+ case 4:
|
|
|
+ return new AffineTransform(1, 0, 0, -1, 0, height);
|
|
|
+ case 5:
|
|
|
+ return new AffineTransform(0, 1, 1, 0, 0, 0);
|
|
|
+ case 6:
|
|
|
+ return new AffineTransform(0, 1, -1, 0, height, 0);
|
|
|
+ case 7:
|
|
|
+ return new AffineTransform(0, -1, -1, 0, height, width);
|
|
|
+ case 8:
|
|
|
+ return new AffineTransform(0, -1, 1, 0, 0, width);
|
|
|
+ default:
|
|
|
+ return new AffineTransform();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int readExifOrientation(byte[] jpeg) {
|
|
|
+ if (jpeg.length < 4 || (jpeg[0] & 0xFF) != 0xFF || (jpeg[1] & 0xFF) != 0xD8) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ int offset = 2;
|
|
|
+ while (offset + 4 <= jpeg.length) {
|
|
|
+ while (offset < jpeg.length && (jpeg[offset] & 0xFF) == 0xFF) {
|
|
|
+ offset++;
|
|
|
+ }
|
|
|
+ if (offset >= jpeg.length) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ int marker = jpeg[offset++] & 0xFF;
|
|
|
+ if (marker == 0xDA || marker == 0xD9) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ if (marker >= 0xD0 && marker <= 0xD7) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (offset + 2 > jpeg.length) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ int segmentLength = readUnsignedShort(jpeg, offset, false);
|
|
|
+ if (segmentLength < 2 || offset + segmentLength > jpeg.length) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ int content = offset + 2;
|
|
|
+ if (marker == 0xE1 && segmentLength >= 14
|
|
|
+ && jpeg[content] == 'E' && jpeg[content + 1] == 'x'
|
|
|
+ && jpeg[content + 2] == 'i' && jpeg[content + 3] == 'f'
|
|
|
+ && jpeg[content + 4] == 0 && jpeg[content + 5] == 0) {
|
|
|
+ int orientation = readTiffOrientation(jpeg, content + 6, offset + segmentLength);
|
|
|
+ if (orientation >= 1 && orientation <= 8) {
|
|
|
+ return orientation;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ offset += segmentLength;
|
|
|
+ }
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int readTiffOrientation(byte[] data, int tiffStart, int limit) {
|
|
|
+ if (tiffStart + 8 > limit) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ boolean littleEndian;
|
|
|
+ if (data[tiffStart] == 'I' && data[tiffStart + 1] == 'I') {
|
|
|
+ littleEndian = true;
|
|
|
+ } else if (data[tiffStart] == 'M' && data[tiffStart + 1] == 'M') {
|
|
|
+ littleEndian = false;
|
|
|
+ } else {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ if (readUnsignedShort(data, tiffStart + 2, littleEndian) != 42) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ long ifdOffset = readUnsignedInt(data, tiffStart + 4, littleEndian);
|
|
|
+ long ifdPosition = (long) tiffStart + ifdOffset;
|
|
|
+ if (ifdPosition < tiffStart || ifdPosition + 2 > limit) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ int ifd = (int) ifdPosition;
|
|
|
+ int entries = readUnsignedShort(data, ifd, littleEndian);
|
|
|
+ for (int i = 0; i < entries; i++) {
|
|
|
+ int entry = ifd + 2 + i * 12;
|
|
|
+ if (entry + 12 > limit) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ int tag = readUnsignedShort(data, entry, littleEndian);
|
|
|
+ if (tag == 0x0112
|
|
|
+ && readUnsignedShort(data, entry + 2, littleEndian) == 3
|
|
|
+ && readUnsignedInt(data, entry + 4, littleEndian) == 1) {
|
|
|
+ return readUnsignedShort(data, entry + 8, littleEndian);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int readUnsignedShort(byte[] data, int offset, boolean littleEndian) {
|
|
|
+ int first = data[offset] & 0xFF;
|
|
|
+ int second = data[offset + 1] & 0xFF;
|
|
|
+ return littleEndian ? first | (second << 8) : (first << 8) | second;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static long readUnsignedInt(byte[] data, int offset, boolean littleEndian) {
|
|
|
+ long first = data[offset] & 0xFFL;
|
|
|
+ long second = data[offset + 1] & 0xFFL;
|
|
|
+ long third = data[offset + 2] & 0xFFL;
|
|
|
+ long fourth = data[offset + 3] & 0xFFL;
|
|
|
+ return littleEndian
|
|
|
+ ? first | (second << 8) | (third << 16) | (fourth << 24)
|
|
|
+ : (first << 24) | (second << 16) | (third << 8) | fourth;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static byte[] writeBaselineJpeg(BufferedImage image, float quality) throws IOException {
|
|
|
+ Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
|
|
|
+ if (!writers.hasNext()) {
|
|
|
+ throw new IOException("当前环境不支持JPEG编码");
|
|
|
+ }
|
|
|
+
|
|
|
+ ImageWriter writer = writers.next();
|
|
|
+ try (ByteArrayOutputStream output = new ByteArrayOutputStream();
|
|
|
+ ImageOutputStream imageOutput = ImageIO.createImageOutputStream(output)) {
|
|
|
+ ImageWriteParam params = writer.getDefaultWriteParam();
|
|
|
+ params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
|
|
+ params.setCompressionQuality(quality);
|
|
|
+ params.setProgressiveMode(ImageWriteParam.MODE_DISABLED);
|
|
|
+ writer.setOutput(imageOutput);
|
|
|
+ writer.write(null, new IIOImage(image, null, null), params);
|
|
|
+ imageOutput.flush();
|
|
|
+ return output.toByteArray();
|
|
|
+ } finally {
|
|
|
+ writer.dispose();
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|