OnlinePlateRecognizer.java 10 KB
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);
    }
}