Blame view

app/src/main/java/com/ectrip/checkincar/plate/OnlinePlateRecognizer.java 10 KB
0fd093a2   李进   初始提交:手持车牌核销应用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
  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);
      }
  }