提交修改

This commit is contained in:
quyixiao 2026-06-10 01:33:16 +08:00
parent 21c6e3f820
commit bdd3f16cd6
2 changed files with 507 additions and 51 deletions

View File

@ -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;
/**
* 车牌识别控制器
* <p>
* <b>接口背景</b>用于交通运输停车场物流车队等场景将照片中的车牌号码自动识别为结构化文本
* 替代人工录入底层对接百度智能云文字识别车牌识别能力由本服务完成参数校验请求转发与字段映射
* </p>
* <p>
* 支持识别中国大陆机动车蓝牌黄牌单双行绿牌大型新能源黄绿领使馆车牌警牌武警牌单双行
* 军牌单双行港澳出入境车牌农用车牌民航车牌
* </p>
*
* <h3>百度官方文档</h3>
* <ul>
* <li>产品文档<a href="https://cloud.baidu.com/doc/OCR/s/ck3h7y191">车牌识别</a></li>
* <li>接口地址{@code POST https://aip.baidubce.com/rest/2.0/ocr/v1/license_plate}</li>
* </ul>
*
* <h3>本服务接口</h3>
* <ul>
* <li>路径{@code POST /car/license/plate/recognize}</li>
* <li>Content-Type{@code application/json}{@code @RequestBody}</li>
* <li>鉴权{@link EbAuthentication}Tencent 鉴权头见项目网关配置</li>
* </ul>
*
* <h3>请求参数{@link LicensePlateRecognizeRequest}</h3>
* <ul>
* <li>imageUrlOrBase64HTTP(S) 链接或 Base64 字符串服务端自动识别支持 jpg/jpeg/png/bmp</li>
* </ul>
*
* <h3>响应结构{@link RecognizeLicensePlateResp}</h3>
* <ul>
* <li>plateNumber 车牌号码 / confidence 号码置信度</li>
* <li>plateType 车牌类型小型汽车 / 新能源车 / 大型汽车 / 警车 / ...</li>
* <li>plateTypeConfidence 类型置信度由号码置信度推导</li>
* <li>plateColor 车牌颜色 / 绿 / / 黄绿 / / </li>
* </ul>
*
* <h3>返回约定重要</h3>
* <ul>
* <li>一律 {@code R.ok()}入参校验失败平台异常识别结果为空等情况将说明写入 {@code msg}
* {@code data} 可能为空或字段不全对外不暴露百度字样日志内可排查</li>
* <li>入参校验失败时<strong>未调用百度不计费</strong></li>
* <li>多车牌场景默认取置信度最高的"主车牌"返回</li>
* </ul>
*
* @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<String, Object> platformResult = callPlatform(content, ctx);
if (platformResult == null) {
return okResult(SIDE_PLACEHOLDER, formatHint(
"服务无回执",
"识别指令已下发,但在约定时间内未收到平台可解析的 JSON 回执",
"建议间隔 3~5 秒重试;若连续失败,请记录 traceId、调用时刻并联系技术支持排查链路",
"影像模式=" + ctx.imageInput.getType().name()
), null);
}
// ---------- 步骤解析平台结果 构建车牌响应 ----------
Map<String, Object> 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<String, Object> callPlatform(String content, RecognizeContext ctx) {
int len = content.length();
log.info("车牌识别:开始调用平台识别。{},请求大小约 {} 字节。{}",
ctx.imageInput.getType().getDesc(), len, ctx.inputLog);
Map<String, Object> 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;
}
/**
* 解析平台返回结果判断是否需要向客户返回提示信息
* <p>判断顺序</p>
* <ol>
* <li>error_code 存在 平台业务拒绝</li>
* <li>words_result 为空 / 非数组 平台未识别出车牌</li>
* <li>响应对象所有字段均为空 字段映射失败</li>
* <li>以上均不命中 返回 null识别正常</li>
* </ol>
*/
private String resolvePlatformHint(Map<String, Object> platformResult,
Map<String, Object> 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<String, Object> 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 格式
* <p>本接口默认走单车牌识别不开启遮挡 / PS / 多张检测开关</p>
*/
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} 数组中挑出"主车牌"
* <p>策略</p>
* <ol>
* <li> words_result 或为空 返回 null</li>
* <li>只有一条 直接返回</li>
* <li>多条 取号码非空且 probability 平均值最高的一条</li>
* </ol>
*/
@SuppressWarnings("unchecked")
private Map<String, Object> pickPrimaryPlate(Map<String, Object> platformResult) {
Object wordsResult = platformResult.get("words_result");
if (!(wordsResult instanceof List)) {
return null;
}
List<Map<String, Object>> list = (List<Map<String, Object>>) wordsResult;
if (list.isEmpty()) {
return null;
}
Map<String, Object> best = null;
double bestScore = -1;
for (Map<String, Object> 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<String, Object> 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<String, String> 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<String, String> colorTranslationMap() {
Map<String, String> 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<String, Object> item) {
Object probObj = item.get("probability");
if (!(probObj instanceof List)) {
return -1;
}
List<Object> list = (List<Object>) 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);
}
}

View File

@ -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;
}