package com.ectrip.checkincar.plate; import android.graphics.Bitmap; import android.text.TextUtils; import android.util.Base64; import android.util.Log; import com.ectrip.checkincar.core.prefs.AppPrefs; import com.ectrip.checkincar.model.PlateInfo; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.ConnectException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; import java.util.concurrent.TimeUnit; import okhttp3.FormBody; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; /** * 百度智能云「车牌识别」在线 REST 引擎。 * access_token 缓存 30 天;token 失效(110/111)自动重取并重试一次。 * 网络/鉴权错误不抛给业务层,记录到 {@link #getLastError()},本次返回 null。 */ public class OnlinePlateRecognizer implements PlateRecognizer { private static final String TAG = "OnlinePlate"; private static final String OCR_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/license_plate"; private final OkHttpClient client; private volatile String lastError; private volatile long lastErrorAt; public OnlinePlateRecognizer() { client = new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build(); } /** 最近一次请求错误(中文),供界面提示;无错误为 null */ public String getLastError() { // 3 秒内的错误才算数,避免旧错误污染后续帧 return System.currentTimeMillis() - lastErrorAt < 3000 ? lastError : null; } @Override public boolean isReady() { return !TextUtils.isEmpty(AppPrefs.getCloudAk()) && !TextUtils.isEmpty(AppPrefs.getCloudSk()); } @Override public PlateInfo recognize(Bitmap bitmap) { lastError = null; if (bitmap == null) { return null; } String b64; try { b64 = encodeJpeg(bitmap, 1280, 80); } catch (Exception e) { setError("图片编码失败:" + e.getMessage()); return null; } try { String token = BaiduCloudToken.getToken(); JSONObject resp = postRecognize(token, b64); // token 失效:清缓存重取一次,仅重试一次 int code = resp.optInt("error_code", 0); if (code == 110 || code == 111) { Log.w(TAG, "token invalid(" + code + "),重新鉴权后重试"); AppPrefs.clearCloudToken(); token = BaiduCloudToken.refreshToken(); resp = postRecognize(token, b64); code = resp.optInt("error_code", 0); } if (code != 0) { // 282102 未检测到目标 / 282103 目标识别错误:画面中无车牌,属正常空结果,静默 if (code == 282102 || code == 282103) { return null; } setError(mapCloudError(code, resp.optString("error_msg", ""))); return null; } JSONObject words = resp.optJSONObject("words_result"); if (words == null) { return null; // 画面中无车牌,正常空结果 } String number = words.optString("number", "").trim(); if (TextUtils.isEmpty(number)) { return null; } return new PlateInfo(number, mapColor(words.optString("color", "")), minProbability(words.optJSONArray("probability")), false); } catch (IOException e) { setError(e.getMessage()); return null; } catch (Exception e) { setError("云端识别异常:" + e.getMessage()); return null; } } @Override public void release() { // 无持久资源 } // ---------------- 内部 ---------------- private JSONObject postRecognize(String token, String b64) throws IOException { RequestBody body = new FormBody.Builder() .add("image", b64) .add("multi_detect", "false") .add("multi_scale", "false") .build(); Request request = new Request.Builder() .url(OCR_URL + "?access_token=" + token) .post(body) .addHeader("Content-Type", "application/x-www-form-urlencoded") .addHeader("Accept", "application/json") .build(); try (Response response = client.newCall(request).execute()) { String rs = response.body() != null ? response.body().string() : ""; return new JSONObject(rs); } catch (UnknownHostException | ConnectException | SocketTimeoutException e) { throw new IOException("无法连接百度云,请检查外网/WiFi"); } catch (JSONException e) { throw new IOException("云端返回数据解析失败"); } } private void setError(String msg) { lastError = msg; lastErrorAt = System.currentTimeMillis(); Log.w(TAG, msg); } /** * 自动分析画面预筛:缩小灰度后统计平均亮度与梯度能量, * 过滤过暗/过曝/严重模糊的帧,避免无意义消耗云端调用次数。 * 手动拍照是操作员有意行为,不走此预筛。 */ public boolean frameWorthSending(Bitmap src) { try { int w = 160; int h = Math.max(1, src.getHeight() * w / src.getWidth()); Bitmap small = Bitmap.createScaledBitmap(src, w, h, true); int n = w * h; int[] px = new int[n]; small.getPixels(px, 0, w, 0, 0, w, h); if (small != src) { small.recycle(); } int[] gray = new int[n]; long sum = 0; for (int i = 0; i < n; i++) { int c = px[i]; int g = ((c >> 16) & 0xFF) * 30 + ((c >> 8) & 0xFF) * 59 + (c & 0xFF) * 11; g /= 100; gray[i] = g; sum += g; } int mean = (int) (sum / n); if (mean < 25 || mean > 245) { Log.d(TAG, "帧预筛跳过:亮度=" + mean); return false; } long edge = 0; for (int y = 1; y < h - 1; y++) { int row = y * w; for (int x = 1; x < w - 1; x++) { int i = row + x; int dx = gray[i] - gray[i - 1]; int dy = gray[i] - gray[i - w]; edge += Math.abs(dx) + Math.abs(dy); } } double edgeAvg = (double) edge / n; // 标定:车牌入框 0.75x 以上 ≥6.6,暗噪空景 4.3~4.6(见 15.3 节) boolean ok = edgeAvg >= 5.5; if (!ok) { Log.d(TAG, "帧预筛跳过:亮度=" + mean + " 边缘=" + String.format("%.1f", edgeAvg)); } return ok; } catch (Exception e) { return true; // 预筛异常不拦识别 } } /** 云端错误码 → 中文;17 日配额、18 QPS、19 并发 */ private static String mapCloudError(int code, String msg) { switch (code) { case 17: return "云端识别今日次数已超限"; case 18: return "云端识别请求过于频繁(QPS 超限)"; case 19: return "云端识别并发超限"; case 6: case 7: case 100: case 110: case 111: return "云端鉴权失败(" + code + "),请检查 API Key/Secret"; case 216200: return "图片为空或下载失败"; case 216201: return "图片格式错误(需 JPG/PNG)"; case 216202: case 282003: return "图片过大或识别服务繁忙"; case 282000: case 282001: case 282002: return "服务端识别异常,请重试"; default: return TextUtils.isEmpty(msg) ? ("云端错误码 " + code) : msg + "(" + code + ")"; } } /** blue/yellow/green/white/black/yellowgreen;黄绿双拼按新能源绿牌归类 */ private static String mapColor(String color) { if (color == null) { return "unknown"; } switch (color) { case "blue": case "yellow": case "green": case "white": case "black": return color; case "yellowgreen": return "green"; default: return "unknown"; } } /** 字符置信度数组取最小值;云端未返回时为 -1(界面显示「云识别」,不过阈值) */ private static float minProbability(JSONArray arr) { if (arr == null || arr.length() == 0) { return -1f; } float min = 1f; for (int i = 0; i < arr.length(); i++) { double p = arr.optDouble(i, 1d); if (p < min) { min = (float) p; } } return min; } /** Bitmap → 缩放 → JPEG → Base64 */ static String encodeJpeg(Bitmap src, int maxWidth, int quality) { Bitmap bmp = src; if (src.getWidth() > maxWidth) { float scale = maxWidth * 1f / src.getWidth(); bmp = Bitmap.createScaledBitmap(src, maxWidth, Math.round(src.getHeight() * scale), true); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); bmp.compress(Bitmap.CompressFormat.JPEG, quality, baos); if (bmp != src) { bmp.recycle(); } return Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP); } }