From bdd3f16cd6bf622d5d671f165f343e91a3e3988c Mon Sep 17 00:00:00 2001 From: quyixiao <2621048238@qq.com> Date: Wed, 10 Jun 2026 01:33:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../car/RecognizeLicensePlateController.java | 497 +++++++++++++++++- .../resp/car/RecognizeLicensePlateResp.java | 61 +-- 2 files changed, 507 insertions(+), 51 deletions(-) diff --git a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeLicensePlateController.java b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeLicensePlateController.java index 3f4e994..75a66c6 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeLicensePlateController.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeLicensePlateController.java @@ -1,41 +1,504 @@ package com.heyu.api.controller.car; -import com.heyu.api.baidu.handle.traffic.BLicensePlateHandle; -import com.heyu.api.baidu.request.traffic.BLicensePlateRequest; -import com.heyu.api.controller.BaseController; -import com.heyu.api.controller.ocr.BaiduOcrResult; +import com.heyu.api.controller.AbstractRecognizeController; +import com.heyu.api.controller.BaiduOcrError; import com.heyu.api.data.annotation.EbAuthentication; import com.heyu.api.data.annotation.NotIntercept; import com.heyu.api.data.constants.ApiConstants; +import com.heyu.api.data.utils.ImageInputUtils; +import com.heyu.api.data.utils.ImageInputUtils.ResolvedImageInput; import com.heyu.api.data.utils.R; +import com.heyu.api.data.utils.StringUtils; import com.heyu.api.request.car.LicensePlateRecognizeRequest; -import org.springframework.beans.factory.annotation.Autowired; +import com.heyu.api.resp.car.RecognizeLicensePlateResp; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 车牌识别控制器 + *

+ * 接口背景:用于交通运输、停车场、物流车队等场景,将照片中的车牌号码自动识别为结构化文本, + * 替代人工录入。底层对接百度智能云文字识别「车牌识别」能力,由本服务完成参数校验、请求转发与字段映射。 + *

+ *

+ * 支持识别:中国大陆机动车蓝牌、黄牌(单双行)、绿牌、大型新能源(黄绿)、领使馆车牌、警牌、武警牌(单双行)、 + * 军牌(单双行)、港澳出入境车牌、农用车牌、民航车牌。 + *

+ * + *

百度官方文档

+ * + * + *

本服务接口

+ * + * + *

请求参数({@link LicensePlateRecognizeRequest})

+ * + * + *

响应结构({@link RecognizeLicensePlateResp})

+ * + * + *

返回约定(重要)

+ * + * + * @author heyu + * @since 1.0.0 + * @see LicensePlateRecognizeRequest + * @see RecognizeLicensePlateResp + */ +@Slf4j @RestController @RequestMapping("/car/license/plate") @NotIntercept -public class RecognizeLicensePlateController extends BaseController { +public class RecognizeLicensePlateController extends AbstractRecognizeController { - @Autowired - private BLicensePlateHandle bLicensePlateHandle; + /** 百度车牌识别 API 路径,完整地址见类注释 */ + private static final String LICENSE_PLATE_URI = "/rest/2.0/ocr/v1/license_plate"; - @PostMapping("/recognize") + /** 车牌识别无 side 概念,统一用此占位符填充上下文,便于日志格式与父类一致 */ + private static final String SIDE_PLACEHOLDER = "plate"; + + /** + * 车牌识别 + * + * @param request 车牌识别请求,字段见类注释「请求参数」 + * @return {@link RecognizeLicensePlateResp} + */ @EbAuthentication(tencent = ApiConstants.TENCENT_AUTH) + @PostMapping("/recognize") public R recognize(@RequestBody LicensePlateRecognizeRequest request) { - return BaiduOcrResult.raw(bLicensePlateHandle.handle(toBaiduRequest(request))); + long start = System.currentTimeMillis(); + RecognizeContext ctx = null; + try { + // ---------- 步骤①:参数校验(不调百度、不扣费) ---------- + String validateError = validateRequest(request); + if (validateError != null) { + ResolvedImageInput validateImage = request != null + ? ImageInputUtils.resolve(request.getImageUrlOrBase64()) : null; + log.info("车牌识别:参数检查没通过,接口仍返回成功并附带提示(还没调识别、不扣费)。{} 返回给客户:{}", + buildInputLogContext(request, validateImage), + abbreviate(validateError, 120)); + return okResult(SIDE_PLACEHOLDER, validateError, null); + } + + // ---------- 步骤②:解析影像入参 & 构建上下文 ---------- + ResolvedImageInput imageInput = ImageInputUtils.resolve(request.getImageUrlOrBase64()); + ctx = new RecognizeContext( + SIDE_PLACEHOLDER, + imageInput, + buildInputLogContext(request, imageInput)); + + // ---------- 步骤③:组装百度 API 请求体 ---------- + String content = buildRequestContent(imageInput); + if (isBlank(content)) { + log.error("车牌识别:组装请求失败,请求里没带有效图片。{}。{}", + ctx.imageInput.getType().getDesc(), ctx.inputLog); + return okResult(SIDE_PLACEHOLDER, formatHint( + "报文组装异常", + "识别请求体在序列化后未包含任何影像载荷,平台侧无法受理本次识别", + "请核对 imageUrlOrBase64 是否非空且为有效 Base64 或 HTTP(S) 链接", + "影像模式=" + ctx.imageInput.getType().name() + ), null); + } + + // ---------- 步骤④:调用百度平台识别 ---------- + Map platformResult = callPlatform(content, ctx); + if (platformResult == null) { + return okResult(SIDE_PLACEHOLDER, formatHint( + "服务无回执", + "识别指令已下发,但在约定时间内未收到平台可解析的 JSON 回执", + "建议间隔 3~5 秒重试;若连续失败,请记录 traceId、调用时刻并联系技术支持排查链路", + "影像模式=" + ctx.imageInput.getType().name() + ), null); + } + + // ---------- 步骤⑤:解析平台结果 → 构建车牌响应 ---------- + Map primary = pickPrimaryPlate(platformResult); + RecognizeLicensePlateResp data = buildPlateResp(primary); + String hint = resolvePlatformHint(platformResult, primary, ctx, data); + + // ---------- 步骤⑥:日志记录 & 返回 ---------- + logRecognizeResult(ctx, platformResult, data, hint, start); + return okResult(SIDE_PLACEHOLDER, hint, data); + + } catch (Exception e) { + long cost = System.currentTimeMillis() - start; + ResolvedImageInput fallbackImage = request != null + ? ImageInputUtils.resolve(request.getImageUrlOrBase64()) : null; + String mode = ctx != null ? ctx.imageInput.getType().getDesc() + : (fallbackImage != null ? fallbackImage.getType().getDesc() : "未知"); + log.error("车牌识别:程序运行出错,耗时 {} ms。{}。客户传的:{}。异常:{} - {}", + cost, mode, buildInputLogContext(request, fallbackImage), + e.getClass().getSimpleName(), + e.getMessage() != null ? e.getMessage() : "无具体说明", e); + return okResult(SIDE_PLACEHOLDER, formatHint( + "运行时故障", + "服务端在处理识别流程时抛出未预期异常,识别结果不可用", + "请勿重复高频重试;请保存 traceId、异常发生时间,由技术支持结合堆栈进一步定位", + "异常类型=" + e.getClass().getSimpleName() + + (e.getMessage() != null ? ",摘要=" + e.getMessage() : "") + ), null); + } } - private BLicensePlateRequest toBaiduRequest(LicensePlateRecognizeRequest request) { - BLicensePlateRequest bRequest = new BLicensePlateRequest(); - if (request == null) { - return bRequest; + /** + * 子类钩子:车牌识别只有一种响应类型,无 side 区分。 + */ + @Override + protected Object defaultEmptyResp(String side) { + return new RecognizeLicensePlateResp(); + } + + // ===================== 流程拆分方法 ===================== + + /** + * 调用百度云车牌识别接口。 + * + * @param content 已拼装好的 POST 请求体 + * @param ctx 上下文(影像模式、入参摘要) + * @return 平台返回的 JSON 解析后的 Map;超时/鉴权/解析失败时返回 null + */ + private Map callPlatform(String content, RecognizeContext ctx) { + int len = content.length(); + log.info("车牌识别:开始调用平台识别。{},请求大小约 {} 字节。{}", + ctx.imageInput.getType().getDesc(), len, ctx.inputLog); + Map result = requestBaidu(LICENSE_PLATE_URI, content); + if (result == null) { + log.error("车牌识别:平台没有返回结果(可能网络超时、鉴权失败或响应无法解析)。{},请求约 {} 字节。{}", + ctx.imageInput.getType().getDesc(), len, ctx.inputLog); } - bRequest.setImageBase64(request.getImageBase64()); - bRequest.setImageUrl(request.getImageUrl()); - return bRequest; + return result; + } + + /** + * 解析平台返回结果,判断是否需要向客户返回提示信息。 + *

判断顺序:

+ *
    + *
  1. error_code 存在 → 平台业务拒绝
  2. + *
  3. words_result 为空 / 非数组 → 平台未识别出车牌
  4. + *
  5. 响应对象所有字段均为空 → 字段映射失败
  6. + *
  7. 以上均不命中 → 返回 null(识别正常)
  8. + *
+ */ + private String resolvePlatformHint(Map platformResult, + Map primary, + RecognizeContext ctx, + RecognizeLicensePlateResp data) { + Object errorCodeObj = platformResult.get("error_code"); + if (errorCodeObj != null) { + String errorCode = String.valueOf(errorCodeObj); + Object errorMsgObj = platformResult.get("error_msg"); + String errorMsg = errorMsgObj != null ? errorMsgObj.toString() : "平台未返回文字描述"; + String category = BaiduOcrError.categoryOf(errorCode); + log.error("车牌识别:平台拒绝了本次识别。[{}] 错误码 {},原因:{}。{}。客户传的:{}。{}", + category, errorCode, errorMsg, + ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + category, + BaiduOcrError.reasonOf(errorCode), + BaiduOcrError.suggestionOf(errorCode, "车牌"), + "错误码=" + errorCode + ",错误描述=" + errorMsg + ); + } + if (primary == null) { + log.info("车牌识别:平台返回了,但 words_result 为空或非数组(可能不是车牌、画面无车牌、或图片不清晰)。{}。客户传的:{}。{}", + ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + "结构化结果缺失", + "平台回执未包含可解析的车牌识别结果,无法进入字段映射环节", + "优先排查:① 画面是否包含完整车牌;② 车牌是否被遮挡 / 反光 / 严重倾斜;" + + "③ 使用 URL 时确保平台抓取节点可访问且无 403/302 拦截", + "解析状态=结果集为空" + ); + } + if (isAllStringFieldsBlank(data)) { + log.info("车牌识别:平台有返回,但 plateNumber / color 等字段一个都没识别出来。{}。客户传的:{}。平台回执:{}", + ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + "字段映射为空", + "平台回执已通过结构校验,但车牌号码、颜色等结构化字段均未命中", + "请确认上传图片为车辆照片且车牌占画面比例合理(建议占画面 1/8 以上);" + + "推荐重拍:垂直拍摄、避开反光、保证四角完整", + "已映射字段数=0" + ); + } + return null; + } + + /** + * 记录本次识别的最终结果日志(含耗时、识别字段数量)。 + */ + private void logRecognizeResult(RecognizeContext ctx, Map platformResult, + Object data, String hint, long start) { + long cost = System.currentTimeMillis() - start; + int mapped = countNonBlankStringFields(data); + if (StringUtils.isNotBlank(hint)) { + log.info("车牌识别:处理结束(接口仍返回成功,但带了提示信息)。耗时 {} ms,识别出 {} 个字段。{}。客户传的:{}。平台回执:{}。给客户的提示:{}", + cost, mapped, ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult), abbreviate(hint, 120)); + } else { + log.info("车牌识别:识别成功。耗时 {} ms,共识别出 {} 个字段。{}。客户传的:{}。平台回执:{}", + cost, mapped, ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + } + } + + // ===================== 校验 ===================== + + private String validateRequest(LicensePlateRecognizeRequest request) { + if (request == null) { + log.info("车牌识别:没收到任何请求参数(表单未绑定成功),直接拒绝,未调用识别、不扣费"); + return formatHint( + "入参绑定失败", + "控制器未接收到可绑定的请求对象,所有业务字段均为空", + "请确认使用 POST 提交;Content-Type 为 application/json;" + + "字段名与接口文档一致(imageUrlOrBase64)", + "绑定结果=LicensePlateRecognizeRequest 为 null" + ); + } + ImageInputUtils.ValidationResult imageValidation = + ImageInputUtils.validate(request.getImageUrlOrBase64()); + if (!imageValidation.isValid()) { + log.info("车牌识别:imageUrlOrBase64 校验未通过({}),未调用识别、不扣费。原因:{}。{}", + imageValidation.getCategory(), + imageValidation.getReason(), + buildInputLogContext(request, null)); + return formatHint( + imageValidation.getCategory(), + imageValidation.getReason(), + imageValidation.getSuggestion(), + imageValidation.getDetail() + ); + } + return null; + } + + // ===================== 请求构造 ===================== + + /** + * 构造调用百度车牌识别的 POST 请求体(application/x-www-form-urlencoded 格式)。 + *

本接口默认走单车牌识别,不开启遮挡 / PS / 多张检测开关。

+ */ + private String buildRequestContent(ResolvedImageInput imageInput) { + StringBuilder sb = new StringBuilder(); + if (imageInput == null || StringUtils.isBlank(imageInput.getFormValue())) { + return sb.toString(); + } + if (ImageInputUtils.ImageInputType.URL == imageInput.getType()) { + sb.append("&url=").append(imageInput.getFormValue()); + } else { + sb.append("&image=").append(imageInput.getFormValue()); + } + sb.append("&multi_detect=false"); + sb.append("&multi_scale=false"); + sb.append("&detect_complete=false"); + sb.append("&detect_risk=false"); + return sb.toString(); + } + + // ===================== 平台结果映射 ===================== + + /** + * 从百度返回的 {@code words_result} 数组中挑出"主车牌"。 + *

策略:

+ *
    + *
  1. 无 words_result 或为空 → 返回 null
  2. + *
  3. 只有一条 → 直接返回
  4. + *
  5. 多条 → 取号码非空且 probability 平均值最高的一条
  6. + *
+ */ + @SuppressWarnings("unchecked") + private Map pickPrimaryPlate(Map platformResult) { + Object wordsResult = platformResult.get("words_result"); + if (!(wordsResult instanceof List)) { + return null; + } + List> list = (List>) wordsResult; + if (list.isEmpty()) { + return null; + } + Map best = null; + double bestScore = -1; + for (Map item : list) { + if (item == null) { + continue; + } + Object number = item.get("number"); + if (number == null || StringUtils.isBlank(number.toString())) { + continue; + } + double score = averageProbability(item); + if (score > bestScore) { + bestScore = score; + best = item; + } + } + return best; + } + + /** + * 将"主车牌"映射为对外响应对象。 + */ + private RecognizeLicensePlateResp buildPlateResp(Map primary) { + RecognizeLicensePlateResp resp = new RecognizeLicensePlateResp(); + if (primary == null) { + return resp; + } + Object number = primary.get("number"); + if (number != null) { + resp.setPlateNumber(number.toString()); + } + double avgConfidence = averageProbability(primary); + if (avgConfidence >= 0) { + resp.setConfidence((float) avgConfidence); + resp.setPlateTypeConfidence((float) avgConfidence); + } + Object color = primary.get("color"); + if (color != null) { + String colorRaw = color.toString(); + resp.setPlateColor(translateColor(colorRaw)); + resp.setPlateType(inferPlateType(colorRaw, resp.getPlateNumber())); + } + return resp; + } + + /** + * 把百度返回的颜色英文标识翻译为中文(对外不暴露英文 enum)。 + */ + private String translateColor(String colorRaw) { + if (StringUtils.isBlank(colorRaw)) { + return null; + } + Map map = colorTranslationMap(); + String hit = map.get(colorRaw.trim().toLowerCase()); + return hit != null ? hit : colorRaw; + } + + /** + * 基于颜色 + 号码长度推断车牌类型,对齐阿里云 RecognizeLicensePlate 规范。 + */ + private String inferPlateType(String colorRaw, String plateNumber) { + if (StringUtils.isBlank(colorRaw)) { + return null; + } + String c = colorRaw.trim().toLowerCase(); + switch (c) { + case "blue": + return "小型汽车"; + case "yellow": + return "大型汽车"; + case "green": + case "gradual_green": + return "新能源车"; + case "yellow_green": + return "大型新能源车"; + case "white": + if (plateNumber != null && plateNumber.contains("警")) { + return "警车"; + } + if (plateNumber != null && (plateNumber.startsWith("WJ") || plateNumber.contains("WJ"))) { + return "武警车"; + } + return "军车"; + case "black": + if (plateNumber != null && (plateNumber.startsWith("使") || plateNumber.startsWith("领"))) { + return "使领馆车"; + } + return "港澳车"; + default: + return "其他"; + } + } + + private static Map colorTranslationMap() { + Map m = new HashMap<>(); + m.put("blue", "蓝"); + m.put("yellow", "黄"); + m.put("green", "绿"); + m.put("gradual_green", "渐变绿"); + m.put("yellow_green", "黄绿"); + m.put("white", "白"); + m.put("black", "黑"); + return Collections.unmodifiableMap(m); + } + + /** + * 计算百度 {@code probability} 数组的平均值;数组缺失或非数字时返回 -1。 + */ + @SuppressWarnings("unchecked") + private double averageProbability(Map item) { + Object probObj = item.get("probability"); + if (!(probObj instanceof List)) { + return -1; + } + List list = (List) probObj; + if (list.isEmpty()) { + return -1; + } + double sum = 0; + int count = 0; + for (Object o : list) { + if (o instanceof Number) { + sum += ((Number) o).doubleValue(); + count++; + } + } + return count == 0 ? -1 : sum / count; + } + + // ===================== 私有工具 ===================== + + /** + * 格式化为统一的提示文案。 + */ + private String formatHint(String category, String reason, String suggestion, String detail) { + StringBuilder sb = new StringBuilder(); + sb.append("【车牌识别·").append(category).append("】").append(reason); + if (StringUtils.isNotBlank(detail)) { + sb.append("|定位信息:").append(detail); + } + sb.append("|处置指引:").append(suggestion); + return sb.toString(); + } + + /** 入参日志说明(不打印影像正文,只说类型与长度) */ + private String buildInputLogContext(LicensePlateRecognizeRequest request, ResolvedImageInput imageInput) { + if (request == null) { + return "未收到请求体"; + } + String modeDesc = imageInput != null ? imageInput.getType().getDesc() : "影像未解析"; + int rawLen = imageInput != null + ? textLength(imageInput.getRawValue()) + : textLength(request.getImageUrlOrBase64()); + return String.format("%s,影像原始长度 %d 字符", modeDesc, rawLen); } } diff --git a/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeLicensePlateResp.java b/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeLicensePlateResp.java index 0dc9eb9..c3c5d98 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeLicensePlateResp.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/resp/car/RecognizeLicensePlateResp.java @@ -1,53 +1,46 @@ package com.heyu.api.resp.car; - -import com.heyu.api.baidu.response.convert.BTextreviewQueryResp; import lombok.Data; -/*** - * https://next.api.aliyun.com/api/ocr/2019-12-30/RecognizeLicensePlate?useCommon=true&tab=DOC&lang=JAVA&sdkStyle=dara +/** + * 车牌识别响应 * - * 车牌识别 + * 字段对齐阿里云 RecognizeLicensePlate 规范: + * https://next.api.aliyun.com/api/ocr/2019-12-30/RecognizeLicensePlate * - * RecognizeLicensePlate + * 仅返回画面中"主车牌"(百度多车牌场景下默认取第一个,按置信度降序)。 */ @Data public class RecognizeLicensePlateResp { + /** + * 车牌号码。 + * 示例值:粤BP57E7 + */ + private String plateNumber; - /*** + /** + * 车牌号码的置信度,取值范围 0~1。 + * 示例值:0.997 + */ + private Float confidence; + + /** + * 车牌类型。包括:小型汽车、新能源车、大型汽车、挂车、教练车、警车、军车、使领馆车、港澳车。 + * 示例值:小型汽车 + */ + private String plateType; + + /** * 车牌类型的置信度,取值范围 0~1。 - * - * 示例值: - * 1 + * 示例值:0.95 */ private Float plateTypeConfidence; /** - * 车牌类型。包括:小型汽车、新能源车、大型汽车、挂车、教练车、警车、军车、使领馆车、港澳车。 - * - * 示例值: - * 小型汽车 + * 车牌颜色(蓝 / 绿 / 黄 / 黄绿 / 白 / 黑)。 + * 示例值:蓝 */ - private String plateType; - - - /*** - * 车牌号码的置信度,取值范围 0~1。 - * - * 示例值: - * 0.99745339155197144 - */ - private Float confidence; - - - /*** - * 车牌号码。 - * - * 示例值: - * 粤BP57E7 - */ - private String plateNumber; - + private String plateColor; }