You've already forked FrameTour-BE
feat(print): 实现照片自动裁剪与优先打印功能
- 人脸上传后自动将关联照片添加到优先打印列表 - 根据景区和设备配置自动处理type=2的照片 - 支持按设备分组处理并限制打印数量 - 实现智能图片裁剪功能,支持自动旋转以减少裁切损失 - 添加图片尺寸配置读取和默认值处理 - 完善异常处理确保不影响主流程执行 -优化打印服务中照片上传和裁剪逻辑 - 增加详细的日志记录便于问题追踪
This commit is contained in:
@@ -58,6 +58,7 @@ import com.ycwl.basic.repository.VideoTaskRepository;
|
||||
import com.ycwl.basic.service.mobile.GoodsService;
|
||||
import com.ycwl.basic.service.pc.FaceService;
|
||||
import com.ycwl.basic.service.pc.ScenicService;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.service.task.TaskFaceService;
|
||||
import com.ycwl.basic.service.task.TaskService;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
@@ -152,6 +153,8 @@ public class FaceServiceImpl implements FaceService {
|
||||
private MemberRelationRepository memberRelationRepository;
|
||||
@Autowired
|
||||
private TemplateRepository templateRepository;
|
||||
@Autowired
|
||||
private PrinterService printerService;
|
||||
|
||||
@Override
|
||||
public ApiResponse<PageInfo<FaceRespVO>> pageQuery(FaceReqQuery faceReqQuery) {
|
||||
@@ -215,16 +218,16 @@ public class FaceServiceImpl implements FaceService {
|
||||
SearchFaceRespVo userDbSearchResult = faceService.searchFace(faceBodyAdapter, USER_FACE_DB_NAME+scenicId, faceUrl, "判断是否为用户上传过的人脸");
|
||||
float strictScore = 0.6F;
|
||||
if (userDbSearchResult == null) {
|
||||
// 都是null了,那得是新的
|
||||
// 都是null了,那得是新的
|
||||
faceBodyAdapter.addFace(USER_FACE_DB_NAME+scenicId, newFaceId.toString(), faceUrl, newFaceId.toString());
|
||||
} else if (userDbSearchResult.getSampleListIds() == null || userDbSearchResult.getSampleListIds().isEmpty()) {
|
||||
// 没有匹配到过,也得是新的
|
||||
// 没有匹配到过,也得是新的
|
||||
faceBodyAdapter.addFace(USER_FACE_DB_NAME+scenicId, newFaceId.toString(), faceUrl, newFaceId.toString());
|
||||
} else if (userDbSearchResult.getFirstMatchRate() < strictScore) {
|
||||
// 有匹配结果,但是不匹配旧的
|
||||
// 有匹配结果,但是不匹配旧的
|
||||
faceBodyAdapter.addFace(USER_FACE_DB_NAME+scenicId, newFaceId.toString(), faceUrl, newFaceId.toString());
|
||||
} else {
|
||||
// 有匹配结果,且能匹配旧的数据
|
||||
// 有匹配结果,且能匹配旧的数据
|
||||
Optional<Long> faceAny = userDbSearchResult.getSampleListIds().stream().filter(_faceId -> {
|
||||
FaceEntity face = faceRepository.getFace(_faceId);
|
||||
if (face == null) {
|
||||
@@ -265,6 +268,11 @@ public class FaceServiceImpl implements FaceService {
|
||||
resp.setUrl(faceUrl);
|
||||
resp.setFaceId(newFaceId);
|
||||
matchFaceId(newFaceId, oldFaceId == null);
|
||||
|
||||
// 异步执行自动添加打印
|
||||
Long finalFaceId = newFaceId;
|
||||
new Thread(() -> autoAddPhotosToPreferPrint(finalFaceId), "auto-add-print-" + newFaceId).start();
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
@@ -1719,4 +1727,114 @@ public class FaceServiceImpl implements FaceService {
|
||||
log.error("记录低阈值检测人脸失败:faceId={}", faceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动将人脸关联的照片添加到优先打印列表
|
||||
* 根据景区和设备配置自动添加type=2的照片到用户打印列表
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
*/
|
||||
private void autoAddPhotosToPreferPrint(Long faceId) {
|
||||
try {
|
||||
// 1. 获取人脸信息
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
log.warn("人脸不存在,无法自动添加打印: faceId={}", faceId);
|
||||
return;
|
||||
}
|
||||
|
||||
Long scenicId = face.getScenicId();
|
||||
Long memberId = face.getMemberId();
|
||||
|
||||
// 2. 获取景区配置
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
|
||||
if (scenicConfig == null) {
|
||||
log.warn("景区配置不存在,跳过自动添加打印: scenicId={}", scenicId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 检查景区是否启用打印功能
|
||||
Boolean printEnable = scenicConfig.getBoolean("print_enable");
|
||||
if (printEnable == null || !printEnable) {
|
||||
log.debug("景区未启用打印功能,跳过自动添加: scenicId={}", scenicId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 查询该faceId关联的所有type=2的照片
|
||||
List<SourceEntity> imageSources = sourceMapper.listImageSourcesByFaceId(faceId);
|
||||
if (imageSources == null || imageSources.isEmpty()) {
|
||||
log.debug("该人脸没有关联的照片,跳过自动添加: faceId={}", faceId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 按照deviceId分组处理
|
||||
Map<Long, List<SourceEntity>> sourcesByDevice = imageSources.stream()
|
||||
.filter(source -> source.getDeviceId() != null)
|
||||
.collect(Collectors.groupingBy(SourceEntity::getDeviceId));
|
||||
|
||||
int totalAdded = 0;
|
||||
for (Map.Entry<Long, List<SourceEntity>> entry : sourcesByDevice.entrySet()) {
|
||||
Long deviceId = entry.getKey();
|
||||
List<SourceEntity> deviceSources = entry.getValue();
|
||||
|
||||
// 6. 获取设备配置
|
||||
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId);
|
||||
if (deviceConfig == null) {
|
||||
log.debug("设备配置不存在,跳过该设备: deviceId={}", deviceId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 7. 检查是否启用优先打印
|
||||
Boolean preferPrintEnable = deviceConfig.getBoolean("prefer_print_enable");
|
||||
if (preferPrintEnable == null || !preferPrintEnable) {
|
||||
log.debug("设备未启用优先打印,跳过: deviceId={}", deviceId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 8. 获取优先打印数量配置
|
||||
Integer preferPrintCount = deviceConfig.getInteger("prefer_print_count");
|
||||
if (preferPrintCount == null) {
|
||||
log.debug("设备未配置优先打印数量,跳过: deviceId={}", deviceId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 9. 根据配置添加照片到打印列表
|
||||
List<SourceEntity> sourcesToAdd;
|
||||
if (preferPrintCount > 0) {
|
||||
// 如果大于0,按照数量限制添加
|
||||
sourcesToAdd = deviceSources.stream()
|
||||
.limit(preferPrintCount)
|
||||
.collect(Collectors.toList());
|
||||
log.info("设备{}配置优先打印{}张,实际添加{}张",
|
||||
deviceId, preferPrintCount, sourcesToAdd.size());
|
||||
} else {
|
||||
// 如果小于等于0,添加该设备的所有照片
|
||||
sourcesToAdd = deviceSources;
|
||||
log.info("设备{}配置优先打印所有照片,实际添加{}张",
|
||||
deviceId, sourcesToAdd.size());
|
||||
}
|
||||
|
||||
// 10. 批量添加到打印列表
|
||||
for (SourceEntity source : sourcesToAdd) {
|
||||
try {
|
||||
printerService.addUserPhoto(memberId, scenicId, source.getUrl());
|
||||
totalAdded++;
|
||||
} catch (Exception e) {
|
||||
log.warn("添加照片到打印列表失败: sourceId={}, url={}, error={}",
|
||||
source.getId(), source.getUrl(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalAdded > 0) {
|
||||
log.info("自动添加打印完成: faceId={}, 成功添加{}张照片", faceId, totalAdded);
|
||||
} else {
|
||||
log.debug("自动添加打印完成: faceId={}, 无符合条件的照片", faceId);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
// 出现异常则放弃,不影响主流程
|
||||
log.error("自动添加打印失败,已忽略: faceId={}", faceId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,58 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
entity.setMemberId(memberId);
|
||||
entity.setScenicId(scenicId);
|
||||
entity.setOrigUrl(url);
|
||||
entity.setCropUrl(url);
|
||||
|
||||
// 获取打印尺寸
|
||||
String cropUrl = url; // 默认使用原图
|
||||
try {
|
||||
// 从打印机表获取尺寸
|
||||
Integer printWidth = null;
|
||||
Integer printHeight = null;
|
||||
|
||||
List<PrinterResp> printers = printerMapper.listByScenicId(scenicId);
|
||||
if (printers != null && !printers.isEmpty()) {
|
||||
PrinterResp firstPrinter = printers.get(0);
|
||||
printWidth = firstPrinter.getPreferW();
|
||||
printHeight = firstPrinter.getPreferH();
|
||||
log.debug("从打印机获取尺寸: scenicId={}, printerId={}, width={}, height={}",
|
||||
scenicId, firstPrinter.getId(), printWidth, printHeight);
|
||||
}
|
||||
|
||||
// 如果打印机没有配置或配置无效,使用默认值
|
||||
if (printWidth == null || printWidth <= 0) {
|
||||
printWidth = 1020;
|
||||
log.debug("打印机宽度未配置或无效,使用默认值: width={}", printWidth);
|
||||
}
|
||||
if (printHeight == null || printHeight <= 0) {
|
||||
printHeight = 1520;
|
||||
log.debug("打印机高度未配置或无效,使用默认值: height={}", printHeight);
|
||||
}
|
||||
|
||||
// 使用smartCropAndFill裁剪图片
|
||||
File croppedFile = ImageUtils.smartCropAndFill(url, printWidth, printHeight);
|
||||
|
||||
try {
|
||||
// 上传裁剪后的图片(使用File版本的uploadFile方法)
|
||||
String[] split = url.split("\\.");
|
||||
String ext = split.length > 0 ? split[split.length - 1] : "jpg";
|
||||
|
||||
cropUrl = StorageFactory.use().uploadFile(null, croppedFile, "printer", UUID.randomUUID() + "." + ext);
|
||||
|
||||
log.info("照片裁剪成功: memberId={}, scenicId={}, 原图={}, 裁剪后={}, 尺寸={}x{}",
|
||||
memberId, scenicId, url, cropUrl, printWidth, printHeight);
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
if (croppedFile != null && croppedFile.exists()) {
|
||||
croppedFile.delete();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("照片裁剪失败,使用原图: memberId={}, scenicId={}, url={}", memberId, scenicId, url, e);
|
||||
// 出现异常则使用原图
|
||||
cropUrl = url;
|
||||
}
|
||||
|
||||
entity.setCropUrl(cropUrl);
|
||||
entity.setStatus(0);
|
||||
printerMapper.addUserPhoto(entity);
|
||||
return entity.getId();
|
||||
|
||||
@@ -193,6 +193,235 @@ public class ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能裁切图片以填充目标尺寸,支持自动旋转以减少裁切损失
|
||||
*
|
||||
* @param imageSource 图片源,可以是URL字符串或File对象
|
||||
* @param targetWidth 目标宽度
|
||||
* @param targetHeight 目标高度
|
||||
* @return 处理后的临时文件
|
||||
* @throws IOException 读取或处理图片失败
|
||||
* @throws IllegalArgumentException 参数无效
|
||||
*/
|
||||
public static File smartCropAndFill(Object imageSource, int targetWidth, int targetHeight) throws IOException {
|
||||
if (targetWidth <= 0 || targetHeight <= 0) {
|
||||
throw new IllegalArgumentException("目标宽高必须大于0");
|
||||
}
|
||||
|
||||
BufferedImage originalImage = loadImage(imageSource);
|
||||
if (originalImage == null) {
|
||||
throw new IOException("无法加载图片: " + imageSource);
|
||||
}
|
||||
|
||||
File resultFile = null;
|
||||
BufferedImage processedImage = null;
|
||||
try {
|
||||
// 计算最优方案(是否需要旋转以及如何裁切)
|
||||
CropStrategy strategy = calculateOptimalCropStrategy(
|
||||
originalImage.getWidth(),
|
||||
originalImage.getHeight(),
|
||||
targetWidth,
|
||||
targetHeight
|
||||
);
|
||||
|
||||
log.info("图片处理策略: 原始尺寸={}x{}, 目标尺寸={}x{}, 旋转={}, 裁切损失={}像素",
|
||||
originalImage.getWidth(), originalImage.getHeight(),
|
||||
targetWidth, targetHeight,
|
||||
strategy.rotationDegrees, strategy.pixelsLost);
|
||||
|
||||
// 如果需要旋转,先旋转图片
|
||||
BufferedImage workingImage = originalImage;
|
||||
if (strategy.rotationDegrees != 0) {
|
||||
workingImage = rotateImage(originalImage, strategy.rotationDegrees);
|
||||
}
|
||||
|
||||
// 执行居中裁切并缩放
|
||||
processedImage = cropAndResize(workingImage, targetWidth, targetHeight);
|
||||
|
||||
// 保存到临时文件
|
||||
resultFile = File.createTempFile("smartcrop_", ".jpg");
|
||||
ImageIO.write(processedImage, "jpg", resultFile);
|
||||
|
||||
log.info("图片处理完成,输出文件: {}", resultFile.getAbsolutePath());
|
||||
return resultFile;
|
||||
|
||||
} finally {
|
||||
if (originalImage != null) {
|
||||
originalImage.flush();
|
||||
}
|
||||
if (processedImage != null) {
|
||||
processedImage.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL或File加载图片
|
||||
*/
|
||||
private static BufferedImage loadImage(Object imageSource) throws IOException {
|
||||
if (imageSource instanceof String) {
|
||||
String urlStr = (String) imageSource;
|
||||
if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) {
|
||||
// 从URL加载
|
||||
java.net.URL url = new java.net.URL(urlStr);
|
||||
return ImageIO.read(url);
|
||||
} else {
|
||||
// 作为文件路径处理
|
||||
return ImageIO.read(new File(urlStr));
|
||||
}
|
||||
} else if (imageSource instanceof File) {
|
||||
return ImageIO.read((File) imageSource);
|
||||
} else {
|
||||
throw new IllegalArgumentException("图片源必须是String(URL或路径)或File对象,实际类型: " +
|
||||
(imageSource != null ? imageSource.getClass().getName() : "null"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算最优裁切策略
|
||||
*/
|
||||
private static CropStrategy calculateOptimalCropStrategy(
|
||||
int srcWidth, int srcHeight, int targetWidth, int targetHeight) {
|
||||
|
||||
CropStrategy best = new CropStrategy();
|
||||
best.rotationDegrees = 0;
|
||||
best.pixelsLost = Integer.MAX_VALUE;
|
||||
|
||||
// 测试三种情况: 不旋转、旋转90度、旋转270度
|
||||
int[][] scenarios = {
|
||||
{0, srcWidth, srcHeight}, // 不旋转
|
||||
{90, srcHeight, srcWidth}, // 旋转90度
|
||||
{270, srcHeight, srcWidth} // 旋转270度
|
||||
};
|
||||
|
||||
for (int[] scenario : scenarios) {
|
||||
int rotation = scenario[0];
|
||||
int currentWidth = scenario[1];
|
||||
int currentHeight = scenario[2];
|
||||
|
||||
// 计算裁切损失
|
||||
double srcRatio = (double) currentWidth / currentHeight;
|
||||
double targetRatio = (double) targetWidth / targetHeight;
|
||||
|
||||
int pixelsLost;
|
||||
if (srcRatio > targetRatio) {
|
||||
// 源图更宽,需要裁掉左右两边
|
||||
int neededWidth = (int) Math.ceil(currentHeight * targetRatio);
|
||||
pixelsLost = (currentWidth - neededWidth) * currentHeight;
|
||||
} else {
|
||||
// 源图更高,需要裁掉上下两边
|
||||
int neededHeight = (int) Math.ceil(currentWidth / targetRatio);
|
||||
pixelsLost = (currentHeight - neededHeight) * currentWidth;
|
||||
}
|
||||
|
||||
if (pixelsLost < best.pixelsLost) {
|
||||
best.rotationDegrees = rotation;
|
||||
best.pixelsLost = pixelsLost;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旋转图片
|
||||
*/
|
||||
private static BufferedImage rotateImage(BufferedImage source, int degrees) {
|
||||
if (degrees == 0) {
|
||||
return source;
|
||||
}
|
||||
|
||||
int width = source.getWidth();
|
||||
int height = source.getHeight();
|
||||
|
||||
// 90度和270度会交换宽高
|
||||
BufferedImage rotated;
|
||||
Graphics2D g2d = null;
|
||||
|
||||
try {
|
||||
if (degrees == 90 || degrees == 270) {
|
||||
rotated = new BufferedImage(height, width, source.getType());
|
||||
g2d = rotated.createGraphics();
|
||||
|
||||
AffineTransform transform = new AffineTransform();
|
||||
if (degrees == 90) {
|
||||
transform.translate(height / 2.0, width / 2.0);
|
||||
transform.rotate(Math.PI / 2);
|
||||
transform.translate(-width / 2.0, -height / 2.0);
|
||||
} else { // 270度
|
||||
transform.translate(height / 2.0, width / 2.0);
|
||||
transform.rotate(-Math.PI / 2);
|
||||
transform.translate(-width / 2.0, -height / 2.0);
|
||||
}
|
||||
|
||||
g2d.setTransform(transform);
|
||||
g2d.drawImage(source, 0, 0, null);
|
||||
} else {
|
||||
throw new IllegalArgumentException("仅支持90度和270度旋转");
|
||||
}
|
||||
|
||||
return rotated;
|
||||
} finally {
|
||||
if (g2d != null) {
|
||||
g2d.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 居中裁切并缩放到目标尺寸
|
||||
*/
|
||||
private static BufferedImage cropAndResize(BufferedImage source, int targetWidth, int targetHeight) {
|
||||
int srcWidth = source.getWidth();
|
||||
int srcHeight = source.getHeight();
|
||||
|
||||
// 计算裁切区域(居中)
|
||||
double srcRatio = (double) srcWidth / srcHeight;
|
||||
double targetRatio = (double) targetWidth / targetHeight;
|
||||
|
||||
int cropX, cropY, cropWidth, cropHeight;
|
||||
|
||||
if (srcRatio > targetRatio) {
|
||||
// 源图更宽,裁掉左右
|
||||
cropHeight = srcHeight;
|
||||
cropWidth = (int) Math.round(srcHeight * targetRatio);
|
||||
cropX = (srcWidth - cropWidth) / 2;
|
||||
cropY = 0;
|
||||
} else {
|
||||
// 源图更高,裁掉上下
|
||||
cropWidth = srcWidth;
|
||||
cropHeight = (int) Math.round(srcWidth / targetRatio);
|
||||
cropX = 0;
|
||||
cropY = (srcHeight - cropHeight) / 2;
|
||||
}
|
||||
|
||||
// 裁切
|
||||
BufferedImage cropped = source.getSubimage(cropX, cropY, cropWidth, cropHeight);
|
||||
|
||||
// 如果裁切后的尺寸与目标尺寸不完全一致,进行缩放
|
||||
if (cropWidth != targetWidth || cropHeight != targetHeight) {
|
||||
BufferedImage resized = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = resized.createGraphics();
|
||||
try {
|
||||
g2d.drawImage(cropped, 0, 0, targetWidth, targetHeight, null);
|
||||
return resized;
|
||||
} finally {
|
||||
g2d.dispose();
|
||||
cropped.flush();
|
||||
}
|
||||
}
|
||||
|
||||
return cropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁切策略内部类
|
||||
*/
|
||||
private static class CropStrategy {
|
||||
int rotationDegrees; // 0, 90, 或 270
|
||||
int pixelsLost; // 损失的像素数
|
||||
}
|
||||
|
||||
public static class Base64DecodedMultipartFile implements MultipartFile {
|
||||
|
||||
private final byte[] imgContent;
|
||||
|
||||
Reference in New Issue
Block a user