|
@@ -0,0 +1,457 @@
|
|
|
|
|
+package com.fs.course.service.impl;
|
|
|
|
|
+
|
|
|
|
|
+import cn.hutool.json.JSONUtil;
|
|
|
|
|
+import com.fs.common.core.domain.R;
|
|
|
|
|
+import com.fs.common.core.redis.RedisCache;
|
|
|
|
|
+import com.fs.common.utils.StringUtils;
|
|
|
|
|
+import com.fs.company.domain.Company;
|
|
|
|
|
+import com.fs.company.service.ICompanyService;
|
|
|
|
|
+import com.fs.core.config.WxMpDynamicConfigService;
|
|
|
|
|
+import com.fs.course.config.CourseConfig;
|
|
|
|
|
+import com.fs.course.service.IFsCourseMpFollowPosterService;
|
|
|
|
|
+import com.fs.system.oss.CloudStorageService;
|
|
|
|
|
+import com.fs.system.oss.OSSFactory;
|
|
|
|
|
+import com.fs.system.service.ISysConfigService;
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import me.chanjar.weixin.mp.api.WxMpService;
|
|
|
|
|
+import me.chanjar.weixin.mp.bean.result.WxMpQrCodeTicket;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import javax.imageio.ImageIO;
|
|
|
|
|
+import java.awt.*;
|
|
|
|
|
+import java.awt.image.BufferedImage;
|
|
|
|
|
+import java.io.ByteArrayOutputStream;
|
|
|
|
|
+import java.io.File;
|
|
|
|
|
+import java.nio.file.Files;
|
|
|
|
|
+import java.nio.file.Path;
|
|
|
|
|
+import java.nio.file.Paths;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 服务号关注海报合成:上传 OSS,返回短链;也可直接取 PNG 字节。
|
|
|
|
|
+ */
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+@Service
|
|
|
|
|
+public class FsCourseMpFollowPosterServiceImpl implements IFsCourseMpFollowPosterService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final String SCENE_STR = "mp_poster";
|
|
|
|
|
+ private static final int BOTTOM_HEIGHT = 260;
|
|
|
|
|
+ private static final int QR_SIZE = 180;
|
|
|
|
|
+ private static final String DEFAULT_TIP = "扫码关注服务号";
|
|
|
|
|
+ /** 海报 URL 缓存前缀:同一服务号 + 同一底图/布局可复用 */
|
|
|
|
|
+ private static final String CACHE_KEY_PREFIX = "course:mpFollowPoster:url:";
|
|
|
|
|
+ private static final int CACHE_DAYS = 90;
|
|
|
|
|
+
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private ICompanyService companyService;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private ISysConfigService configService;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private WxMpService wxMpService;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private WxMpDynamicConfigService wxMpDynamicConfigService;
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private RedisCache redisCache;
|
|
|
|
|
+
|
|
|
|
|
+ private final ThreadLocal<String> lastError = new ThreadLocal<>();
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public String getLastError() {
|
|
|
|
|
+ return lastError.get();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public R createMpFollowPoster(Long companyId, boolean force) {
|
|
|
|
|
+ if (companyId == null) {
|
|
|
|
|
+ return R.error("公司ID不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ Company company = companyService.selectCompanyById(companyId);
|
|
|
|
|
+ if (company == null) {
|
|
|
|
|
+ return R.error("公司不存在");
|
|
|
|
|
+ }
|
|
|
|
|
+ String appId = company.getCourseMaAppId();
|
|
|
|
|
+ if (StringUtils.isEmpty(appId)) {
|
|
|
|
|
+ return R.error("公司未绑定服务号,请联系管理员配置服务号点播");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ CourseConfig courseConfig = loadCourseConfig();
|
|
|
|
|
+ String posterDir = courseConfig != null ? courseConfig.getMpPosterDir() : null;
|
|
|
|
|
+ if (StringUtils.isEmpty(posterDir)) {
|
|
|
|
|
+ return R.error("未配置服务号海报目录 course.config.mpPosterDir");
|
|
|
|
|
+ }
|
|
|
|
|
+ File posterFile = resolvePosterFile(posterDir, appId);
|
|
|
|
|
+ if (posterFile == null || !posterFile.isFile()) {
|
|
|
|
|
+ return R.error("未找到服务号海报文件。请将「服务号海报目录」配成目录(如 F:/hdt/mp-poster),"
|
|
|
|
|
+ + "并把海报放到:" + Paths.get(posterDir).resolve(appId + ".png").toString().replace('\\', '/')
|
|
|
|
|
+ + "(或 .jpg);也可直接配置为该图片完整路径");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String cacheKey = buildCacheKey(appId, posterFile, courseConfig);
|
|
|
|
|
+ if (!force) {
|
|
|
|
|
+ String cachedUrl = redisCache.getCacheObject(cacheKey);
|
|
|
|
|
+ if (StringUtils.isNotEmpty(cachedUrl)) {
|
|
|
|
|
+ log.info("命中服务号关注海报缓存, appId={}, companyId={}", appId, companyId);
|
|
|
|
|
+ return R.ok().put("data", cachedUrl);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ byte[] png = createMpFollowPosterPng(companyId);
|
|
|
|
|
+ if (png == null) {
|
|
|
|
|
+ return R.error(StringUtils.isEmpty(getLastError()) ? "生成服务号关注海报失败" : getLastError());
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ CloudStorageService storage = OSSFactory.build();
|
|
|
|
|
+ if (storage == null) {
|
|
|
|
|
+ return R.error("未配置云存储 sys.oss.cloudStorage,无法上传海报");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 固定对象路径,同服务号覆盖写入,减少 OSS 碎片文件
|
|
|
|
|
+ String objectKey = "mp-follow-poster/" + appId + ".png";
|
|
|
|
|
+ String url = storage.upload(png, objectKey);
|
|
|
|
|
+ if (StringUtils.isEmpty(url) || url.startsWith("上传失败")) {
|
|
|
|
|
+ // 部分存储实现不支持自定义 path,回退随机路径
|
|
|
|
|
+ url = storage.uploadSuffix(png, ".png");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtils.isEmpty(url) || url.startsWith("上传失败")) {
|
|
|
|
|
+ return R.error("海报上传云存储失败");
|
|
|
|
|
+ }
|
|
|
|
|
+ redisCache.setCacheObject(cacheKey, url, CACHE_DAYS, TimeUnit.DAYS);
|
|
|
|
|
+ return R.ok().put("data", url);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("上传服务号关注海报失败, companyId={}", companyId, e);
|
|
|
|
|
+ return R.error("海报上传失败:" + (e.getMessage() == null ? "未知错误" : e.getMessage()));
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ lastError.remove();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String buildCacheKey(String appId, File posterFile, CourseConfig cfg) {
|
|
|
|
|
+ StringBuilder sb = new StringBuilder(CACHE_KEY_PREFIX);
|
|
|
|
|
+ sb.append(appId).append(':');
|
|
|
|
|
+ sb.append(posterFile.lastModified()).append(':');
|
|
|
|
|
+ if (cfg == null) {
|
|
|
|
|
+ return sb.append("default").toString();
|
|
|
|
|
+ }
|
|
|
|
|
+ sb.append(StringUtils.isEmpty(cfg.getMpPosterQrLayout()) ? "auto" : cfg.getMpPosterQrLayout()).append(':');
|
|
|
|
|
+ sb.append(cfg.getMpPosterQrX()).append(':');
|
|
|
|
|
+ sb.append(cfg.getMpPosterQrY()).append(':');
|
|
|
|
|
+ sb.append(cfg.getMpPosterQrSize()).append(':');
|
|
|
|
|
+ sb.append(StringUtils.isEmpty(cfg.getMpPosterTip()) ? DEFAULT_TIP : cfg.getMpPosterTip());
|
|
|
|
|
+ return sb.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public byte[] createMpFollowPosterPng(Long companyId) {
|
|
|
|
|
+ lastError.remove();
|
|
|
|
|
+ if (companyId == null) {
|
|
|
|
|
+ lastError.set("公司ID不能为空");
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ Company company = companyService.selectCompanyById(companyId);
|
|
|
|
|
+ if (company == null) {
|
|
|
|
|
+ lastError.set("公司不存在");
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ String appId = company.getCourseMaAppId();
|
|
|
|
|
+ if (StringUtils.isEmpty(appId)) {
|
|
|
|
|
+ lastError.set("公司未绑定服务号,请联系管理员配置服务号点播");
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ CourseConfig courseConfig = loadCourseConfig();
|
|
|
|
|
+ String posterDir = courseConfig != null ? courseConfig.getMpPosterDir() : null;
|
|
|
|
|
+ if (StringUtils.isEmpty(posterDir)) {
|
|
|
|
|
+ lastError.set("未配置服务号海报目录 course.config.mpPosterDir");
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ File posterFile = resolvePosterFile(posterDir, appId);
|
|
|
|
|
+ if (posterFile == null || !posterFile.isFile()) {
|
|
|
|
|
+ lastError.set("未找到服务号海报文件。请将「服务号海报目录」配成目录(如 F:/hdt/mp-poster),"
|
|
|
|
|
+ + "并把海报放到:" + Paths.get(posterDir).resolve(appId + ".png").toString().replace('\\', '/')
|
|
|
|
|
+ + "(或 .jpg);也可直接配置为该图片完整路径");
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ wxMpDynamicConfigService.ensureMpForRequest(wxMpService, appId);
|
|
|
|
|
+ WxMpQrCodeTicket ticket = wxMpService.getQrcodeService().qrCodeCreateLastTicket(SCENE_STR);
|
|
|
|
|
+ File qrFile = wxMpService.getQrcodeService().qrCodePicture(ticket);
|
|
|
|
|
+ if (qrFile == null || !qrFile.isFile()) {
|
|
|
|
|
+ lastError.set("获取服务号关注二维码失败");
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ BufferedImage background = ImageIO.read(posterFile);
|
|
|
|
|
+ if (background == null) {
|
|
|
|
|
+ lastError.set("海报图片读取失败");
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ BufferedImage qrImage = ImageIO.read(qrFile);
|
|
|
|
|
+ if (qrImage == null) {
|
|
|
|
|
+ lastError.set("关注二维码图片读取失败");
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String tipText = courseConfig.getMpPosterTip();
|
|
|
|
|
+ if (StringUtils.isEmpty(tipText)) {
|
|
|
|
|
+ tipText = DEFAULT_TIP;
|
|
|
|
|
+ }
|
|
|
|
|
+ return composePosterPng(background, qrImage, tipText, courseConfig);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("createMpFollowPosterPng failed, companyId={}, appId={}", companyId, appId, e);
|
|
|
|
|
+ lastError.set("生成服务号关注海报失败:"
|
|
|
|
|
+ + (e.getMessage() == null ? "未知错误" : e.getMessage()));
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CourseConfig loadCourseConfig() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ String json = configService.selectConfigByKey("course.config");
|
|
|
|
|
+ if (StringUtils.isEmpty(json)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return JSONUtil.toBean(json, CourseConfig.class);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("parse course.config failed", e);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 支持两种配置:
|
|
|
|
|
+ * 1) 目录:{mpPosterDir}/{appId}.png|jpg
|
|
|
|
|
+ * 2) 直接填图片完整路径(兼容误配成文件)
|
|
|
|
|
+ */
|
|
|
|
|
+ private File resolvePosterFile(String posterDir, String appId) {
|
|
|
|
|
+ if (StringUtils.isEmpty(posterDir)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ Path configured = Paths.get(posterDir.trim());
|
|
|
|
|
+ if (Files.isRegularFile(configured)) {
|
|
|
|
|
+ return configured.toFile();
|
|
|
|
|
+ }
|
|
|
|
|
+ Path dir = configured;
|
|
|
|
|
+ String name = configured.getFileName() != null ? configured.getFileName().toString().toLowerCase() : "";
|
|
|
|
|
+ if (name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg")) {
|
|
|
|
|
+ Path parent = configured.getParent();
|
|
|
|
|
+ if (parent != null) {
|
|
|
|
|
+ dir = parent;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ String[] exts = {".png", ".jpg", ".jpeg", ".PNG", ".JPG", ".JPEG"};
|
|
|
|
|
+ for (String ext : exts) {
|
|
|
|
|
+ Path path = dir.resolve(appId + ext);
|
|
|
|
|
+ if (Files.isRegularFile(path)) {
|
|
|
|
|
+ return path.toFile();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 合成海报。
|
|
|
|
|
+ * layout=auto:识别底图白框并把二维码放进去;
|
|
|
|
|
+ * layout=frame:用配置的 x/y/size;
|
|
|
|
|
+ * layout=bottom:旧逻辑,拼在底部。
|
|
|
|
|
+ */
|
|
|
|
|
+ private byte[] composePosterPng(BufferedImage background, BufferedImage qrImage, String tipText,
|
|
|
|
|
+ CourseConfig courseConfig) throws Exception {
|
|
|
|
|
+ String layout = courseConfig != null ? courseConfig.getMpPosterQrLayout() : null;
|
|
|
|
|
+ if (StringUtils.isEmpty(layout)) {
|
|
|
|
|
+ layout = "auto";
|
|
|
|
|
+ }
|
|
|
|
|
+ layout = layout.trim().toLowerCase();
|
|
|
|
|
+
|
|
|
|
|
+ if ("bottom".equals(layout)) {
|
|
|
|
|
+ return composeBottomPoster(background, qrImage, tipText);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int[] box = null;
|
|
|
|
|
+ if ("frame".equals(layout)
|
|
|
|
|
+ && courseConfig.getMpPosterQrX() != null
|
|
|
|
|
+ && courseConfig.getMpPosterQrY() != null
|
|
|
|
|
+ && courseConfig.getMpPosterQrSize() != null
|
|
|
|
|
+ && courseConfig.getMpPosterQrSize() > 0) {
|
|
|
|
|
+ int size = courseConfig.getMpPosterQrSize();
|
|
|
|
|
+ box = new int[]{courseConfig.getMpPosterQrX(), courseConfig.getMpPosterQrY(), size, size};
|
|
|
|
|
+ } else {
|
|
|
|
|
+ box = findLargestWhiteFrame(background);
|
|
|
|
|
+ if (box == null && courseConfig != null
|
|
|
|
|
+ && courseConfig.getMpPosterQrX() != null
|
|
|
|
|
+ && courseConfig.getMpPosterQrY() != null
|
|
|
|
|
+ && courseConfig.getMpPosterQrSize() != null
|
|
|
|
|
+ && courseConfig.getMpPosterQrSize() > 0) {
|
|
|
|
|
+ int size = courseConfig.getMpPosterQrSize();
|
|
|
|
|
+ box = new int[]{courseConfig.getMpPosterQrX(), courseConfig.getMpPosterQrY(), size, size};
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (box == null) {
|
|
|
|
|
+ log.warn("未识别到海报白框,回退底部拼接模式");
|
|
|
|
|
+ return composeBottomPoster(background, qrImage, tipText);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return composeOverlayPoster(background, qrImage, box);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private byte[] composeBottomPoster(BufferedImage background, BufferedImage qrImage, String tipText) throws Exception {
|
|
|
|
|
+ int width = background.getWidth();
|
|
|
|
|
+ int height = background.getHeight();
|
|
|
|
|
+ int totalHeight = height + BOTTOM_HEIGHT;
|
|
|
|
|
+
|
|
|
|
|
+ BufferedImage combined = new BufferedImage(width, totalHeight, BufferedImage.TYPE_INT_RGB);
|
|
|
|
|
+ Graphics2D g = combined.createGraphics();
|
|
|
|
|
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
|
|
|
|
+ g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
|
|
|
|
+ g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
|
|
|
|
+
|
|
|
|
|
+ g.setColor(Color.WHITE);
|
|
|
|
|
+ g.fillRect(0, 0, width, totalHeight);
|
|
|
|
|
+ g.drawImage(background, 0, 0, null);
|
|
|
|
|
+
|
|
|
|
|
+ g.setColor(Color.WHITE);
|
|
|
|
|
+ g.fillRect(0, height, width, BOTTOM_HEIGHT);
|
|
|
|
|
+
|
|
|
|
|
+ int qrX = (width - QR_SIZE) / 2;
|
|
|
|
|
+ int qrY = height + 20;
|
|
|
|
|
+ g.drawImage(qrImage, qrX, qrY, QR_SIZE, QR_SIZE, null);
|
|
|
|
|
+
|
|
|
|
|
+ g.setFont(new Font("Microsoft YaHei", Font.PLAIN, 18));
|
|
|
|
|
+ g.setColor(new Color(51, 51, 51));
|
|
|
|
|
+ FontMetrics fm = g.getFontMetrics();
|
|
|
|
|
+ int tipWidth = fm.stringWidth(tipText);
|
|
|
|
|
+ int tipX = Math.max(0, (width - tipWidth) / 2);
|
|
|
|
|
+ int tipY = qrY + QR_SIZE + 28;
|
|
|
|
|
+ g.drawString(tipText, tipX, tipY);
|
|
|
|
|
+
|
|
|
|
|
+ g.dispose();
|
|
|
|
|
+ return toPngBytes(combined);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @param box [x, y, w, h] 白框区域;二维码居中内嵌并留边距
|
|
|
|
|
+ */
|
|
|
|
|
+ private byte[] composeOverlayPoster(BufferedImage background, BufferedImage qrImage, int[] box) throws Exception {
|
|
|
|
|
+ int width = background.getWidth();
|
|
|
|
|
+ int height = background.getHeight();
|
|
|
|
|
+ BufferedImage combined = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
|
|
|
|
+ Graphics2D g = combined.createGraphics();
|
|
|
|
|
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
|
|
|
|
+ g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
|
|
|
|
+
|
|
|
|
|
+ g.drawImage(background, 0, 0, null);
|
|
|
|
|
+
|
|
|
|
|
+ int boxX = box[0];
|
|
|
|
|
+ int boxY = box[1];
|
|
|
|
|
+ int boxW = box[2];
|
|
|
|
|
+ int boxH = box[3];
|
|
|
|
|
+ int pad = Math.max(8, Math.min(boxW, boxH) / 12);
|
|
|
|
|
+ int qrSize = Math.max(40, Math.min(boxW, boxH) - pad * 2);
|
|
|
|
|
+ int qrX = boxX + (boxW - qrSize) / 2;
|
|
|
|
|
+ int qrY = boxY + (boxH - qrSize) / 2;
|
|
|
|
|
+ g.drawImage(qrImage, qrX, qrY, qrSize, qrSize, null);
|
|
|
|
|
+
|
|
|
|
|
+ g.dispose();
|
|
|
|
|
+ log.info("海报二维码已放入白框 x={}, y={}, size={}, box=[{},{},{},{}]",
|
|
|
|
|
+ qrX, qrY, qrSize, boxX, boxY, boxW, boxH);
|
|
|
|
|
+ return toPngBytes(combined);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static byte[] toPngBytes(BufferedImage image) throws Exception {
|
|
|
|
|
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
|
|
|
+ ImageIO.write(image, "png", baos);
|
|
|
|
|
+ return baos.toByteArray();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 在底图中寻找最大近白色连通区域(海报中间白框)。
|
|
|
|
|
+ * @return [x, y, w, h],找不到返回 null
|
|
|
|
|
+ */
|
|
|
|
|
+ private int[] findLargestWhiteFrame(BufferedImage img) {
|
|
|
|
|
+ int w = img.getWidth();
|
|
|
|
|
+ int h = img.getHeight();
|
|
|
|
|
+ if (w < 50 || h < 50) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ int step = Math.max(2, Math.min(w, h) / 300);
|
|
|
|
|
+ boolean[][] visited = new boolean[h][w];
|
|
|
|
|
+ int bestArea = 0;
|
|
|
|
|
+ int[] best = null;
|
|
|
|
|
+ int minArea = (int) (w * h * 0.015);
|
|
|
|
|
+ int maxArea = (int) (w * h * 0.45);
|
|
|
|
|
+
|
|
|
|
|
+ for (int y = 0; y < h; y += step) {
|
|
|
|
|
+ for (int x = 0; x < w; x += step) {
|
|
|
|
|
+ if (visited[y][x] || !isNearWhite(img, x, y)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ int minX = x, maxX = x, minY = y, maxY = y;
|
|
|
|
|
+ java.util.ArrayDeque<int[]> queue = new java.util.ArrayDeque<>();
|
|
|
|
|
+ queue.add(new int[]{x, y});
|
|
|
|
|
+ visited[y][x] = true;
|
|
|
|
|
+ int count = 0;
|
|
|
|
|
+ while (!queue.isEmpty()) {
|
|
|
|
|
+ int[] p = queue.poll();
|
|
|
|
|
+ int cx = p[0], cy = p[1];
|
|
|
|
|
+ count++;
|
|
|
|
|
+ if (cx < minX) minX = cx;
|
|
|
|
|
+ if (cx > maxX) maxX = cx;
|
|
|
|
|
+ if (cy < minY) minY = cy;
|
|
|
|
|
+ if (cy > maxY) maxY = cy;
|
|
|
|
|
+ // 四邻域步进,控制连通规模
|
|
|
|
|
+ int[][] dirs = {{step, 0}, {-step, 0}, {0, step}, {0, -step}};
|
|
|
|
|
+ for (int[] d : dirs) {
|
|
|
|
|
+ int nx = cx + d[0];
|
|
|
|
|
+ int ny = cy + d[1];
|
|
|
|
|
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h || visited[ny][nx]) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!isNearWhite(img, nx, ny)) {
|
|
|
|
|
+ visited[ny][nx] = true;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ visited[ny][nx] = true;
|
|
|
|
|
+ queue.add(new int[]{nx, ny});
|
|
|
|
|
+ }
|
|
|
|
|
+ // 防止超大白底把整张图吃掉
|
|
|
|
|
+ if (count > maxArea / (step * step) + 1000) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ int bw = maxX - minX + 1;
|
|
|
|
|
+ int bh = maxY - minY + 1;
|
|
|
|
|
+ int area = bw * bh;
|
|
|
|
|
+ if (area < minArea || area > maxArea) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ double ratio = bw * 1.0 / bh;
|
|
|
|
|
+ if (ratio < 0.35 || ratio > 2.8) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 白框应大致在画面中部,排除四角装饰白点
|
|
|
|
|
+ double cx = (minX + maxX) / 2.0 / w;
|
|
|
|
|
+ double cy = (minY + maxY) / 2.0 / h;
|
|
|
|
|
+ if (cx < 0.15 || cx > 0.85 || cy < 0.2 || cy > 0.92) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (area > bestArea) {
|
|
|
|
|
+ bestArea = area;
|
|
|
|
|
+ // 向内收缩一点,避开金边
|
|
|
|
|
+ int inset = Math.max(2, Math.min(bw, bh) / 25);
|
|
|
|
|
+ best = new int[]{minX + inset, minY + inset, Math.max(10, bw - inset * 2), Math.max(10, bh - inset * 2)};
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return best;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static boolean isNearWhite(BufferedImage img, int x, int y) {
|
|
|
|
|
+ int rgb = img.getRGB(x, y);
|
|
|
|
|
+ int r = (rgb >> 16) & 0xff;
|
|
|
|
|
+ int g = (rgb >> 8) & 0xff;
|
|
|
|
|
+ int b = rgb & 0xff;
|
|
|
|
|
+ return r >= 245 && g >= 245 && b >= 245;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|