阅读 976

ZXing源码解析四:如何识别图片中的二维码

不知道大家在用ZXing作为扫码库的时候,有没有想过“ZXing是怎么从相机捕获的每一帧图片中获取到二维码并解析的呢?”,如果你思考过并且已经从源码中知道了答案,那么这篇文章你就没必要读下去了,如果你思考过却不知道答案,那么这篇文章就是为你准备的,相信你读过后会有一个清晰的答案。

  为了不那么突兀,还是先跟着源码来一步步的讲解,先来看怎样获取到相机捕获到的图片的数据的。

获取相机捕获到的数据

  因为前面的文章已经分析过ZXing解码的步骤了,这里就重点看下,相机捕获到图像的后续步骤,源码如下

public void restartPreviewAndDecode() {
    if (state == State.SUCCESS) {
      state = State.PREVIEW;
      cameraManager.requestPreviewFrame(decodeThread.getHandler(), R.id.decode);
      activity.drawViewfinder();
    }
  }
复制代码

上面的代码是在CaptureActivityHandler构造方法中调用的,也就是在CaptureActivityHandler实例化的时候调用。然后,调用到了cameraManagerrequestPreviewFrame方法,代码如下

/**
   * A single preview frame will be returned to the handler supplied. The data will arrive as byte[]
   * in the message.obj field, with width and height encoded as message.arg1 and message.arg2,
   * respectively.
   *
   * @param handler The handler to send the message to.
   * @param message The what field of the message to be sent.
   */
  public synchronized void requestPreviewFrame(Handler handler, int message) {
    OpenCamera theCamera = camera;
    if (theCamera != null && previewing) {
      previewCallback.setHandler(handler, message);
      theCamera.getCamera().setOneShotPreviewCallback(previewCallback);
    }
  }
复制代码

现在来分析一下上面的代码,重点来看下这句

theCamera.getCamera().setOneShotPreviewCallback(previewCallback);
复制代码

这句代码的作用就是设置一个预览帧的回调,意思就是相机每捕获一帧数据就会调用,这里设置的previewCallback中的方法,经分析,最终调用previewCallback中的方法是public void onPreviewFrame(byte[] data, Camera camera),这里的第一个参数就是每一帧图像的数据即byte数组。Android 中Google支持的Camera Preview CallBack的YUV常用格式有两种:一种是NV21,一种是YV12,Android一般默认使用的是YCbCR_420_sp(NV21),当然,也可以通过下面的代码来设置自己需要的格式。

Camera.Parameters parameters = camera.getParameters();
parameters.setPreviewFormat(ImageFormat.NV21);
camera.setParameters(parameters);
复制代码

ZXing库中并没有设置格式,所以这里默认的是NV21格式。那么问题来了,NV21到底是什么意思呢?欲知详情,请继续阅读下文

YUV图片格式详解

YUV是一种颜色编码方法,和它等同的还有 RGB 颜色编码方法。

  RGB 图像中,每个像素点都有红、绿、蓝三个原色,其中每种原色都占用 8 bit,也就是一个字节,那么一个像素点也就占用 24 bit,也就是三个字节。一张 1280 * 720 大小的图片,就占用 1280 * 720 * 3 / 1024 / 1024 = 2.63 MB 存储空间。    YUV颜色编码采用的是 明亮度 和 色度 来指定像素的颜色。其中,Y 表示明亮度(Luminance、Luma),而U和V表示色度(Chrominance、Chroma)。而色度又定义了颜色的两个方面:色调和饱和度。

  上文的NV21YV12是YUV存储格式。

  • NV21格式属于YUV420SP类型。它也是先存储了Y分量,但接下来并不是再存储所有的U或者V分量,而是把UV 分量交替连续存储。
  • YV12格式属于YUV420P类型,即先存储Y分量,再存储U、V分量,YV12是先Y再V后U。

关于YUV格式的介绍,网上有一篇比较好的文章,点击这里查看。对YUV格式有一定的了解之后,继续来分析源码,看下,是怎样从图片中识别二维码的。

识别图片中的二维码

  上文已经知道,相机每获取一帧的数据都会回调PreviewCallback类中的onPreviewFrame方法,在此方法中,利用Handler的机制,将图片转换成的字节数组传递给了DecodeHandler类,然后调用了decode方法,代码如下

private void decode(byte[] data, int width, int height) {
    long start = System.nanoTime();
    //...省略部分代码

    Result rawResult = null;
    PlanarYUVLuminanceSource source = activity.getCameraManager().buildLuminanceSource(data, width, height);
    if (source != null) {
      BinaryBitmap bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));
      try {
        rawResult = multiFormatReader.decodeWithState(bitmap);
      } catch (ReaderException re) {
        // continue
        Log.e(TAG, "decode: 没有发现二维码" );
      } finally {
        multiFormatReader.reset();
      }
    }

    //...省略部分代码
  }
复制代码

这部分代码可以说是ZXing解码的核心代码了,现在一点点的来分析,先看

PlanarYUVLuminanceSource source = activity.getCameraManager().buildLuminanceSource(data, width, height);
复制代码

这句代码,实例化了PlanarYUVLuminanceSource对象,主要的目的是获取扫码框中的图像的数据。在将图像进行二值化的时候会调用此对象中的方法,稍后会在源码中介绍。 再看这句代码

BinaryBitmap bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));
复制代码

这句代码,嗯,先看new GlobalHistogramBinarizer(source)这句代码,GlobalHistogramBinarizer图像的数据就是在这个类中进行二值化的,当然还有一个HybridBinarizer类,这个类也是将图像二值化的,那主要的区别是什么呢?主要的区别就是HybridBinarizer类处理的比GlobalHistogramBinarizer精确,但是处理的速度较慢,推荐在性能比较好的手机上使用,而GlobalHistogramBinarizer处理的不太精确,如有阴影的化,可能处理的图片就会有问题,但是速度较快,推荐在性能不太好的手机上使用。 这里,我们用的是GlobalHistogramBinarizer来对图像进行二值化处理,因为,经过我测试发现,这个速度快点。

  再来看整句的代码,就是实例化了BinaryBitmap类,然后将GlobalHistogramBinarizer对象注入。

  下面的代码就是从图像中发现二维码并解析,代码如下

try {
        rawResult = multiFormatReader.decodeWithState(bitmap);
      } catch (ReaderException re) {
        // continue
        Log.e(TAG, "decode: 没有发现二维码" );
      } finally {
        multiFormatReader.reset();
      }
复制代码

跟踪下去,发现最终会调用QRCodeReader类中的decode(BinaryBitmap image, Map<DecodeHintType,?> hints)方法。代码如下

public final Result decode(BinaryBitmap image, Map<DecodeHintType,?> hints)
      throws NotFoundException, ChecksumException, FormatException {
    DecoderResult decoderResult;
    ResultPoint[] points;
    if (hints != null && hints.containsKey(DecodeHintType.PURE_BARCODE)) {
      BitMatrix bits = extractPureBits(image.getBlackMatrix());
      decoderResult = decoder.decode(bits, hints);
      points = NO_POINTS;
    } else {
      // 会进入这段代码
      DetectorResult detectorResult = new Detector(image.getBlackMatrix()).detect(hints);
      decoderResult = decoder.decode(detectorResult.getBits(), hints);
      points = detectorResult.getPoints();
    }

    // If the code was mirrored: swap the bottom-left and the top-right points.
    if (decoderResult.getOther() instanceof QRCodeDecoderMetaData) {
      ((QRCodeDecoderMetaData) decoderResult.getOther()).applyMirroredCorrection(points);
    }

    Result result = new Result(decoderResult.getText(), decoderResult.getRawBytes(), points, BarcodeFormat.QR_CODE);
    List<byte[]> byteSegments = decoderResult.getByteSegments();
    if (byteSegments != null) {
      result.putMetadata(ResultMetadataType.BYTE_SEGMENTS, byteSegments);
    }
    String ecLevel = decoderResult.getECLevel();
    if (ecLevel != null) {
      result.putMetadata(ResultMetadataType.ERROR_CORRECTION_LEVEL, ecLevel);
    }
    if (decoderResult.hasStructuredAppend()) {
      result.putMetadata(ResultMetadataType.STRUCTURED_APPEND_SEQUENCE,
                         decoderResult.getStructuredAppendSequenceNumber());
      result.putMetadata(ResultMetadataType.STRUCTURED_APPEND_PARITY,
                         decoderResult.getStructuredAppendParity());
    }
    return result;
  }
复制代码

来看

DetectorResult detectorResult = new Detector(image.getBlackMatrix()).detect(hints);
复制代码

这句代码。image.getBlackMatrix()就是调用GlobalHistogramBinarizer类中的getBlackMatrix方法,其中的代码就不看了,getBlackMatrix方法的主要作用就是将图片进行二值化的处理,二值化的关键就是定义出黑白的界限,我们的图像已经转化为了灰度图像,每个点都是由一个灰度值来表示,就需要定义出一个灰度值,大于这个值就为白(0),低于这个值就为黑(1)。具体的处理方法如下

在 GlobalHistogramBinarizer中,是从图像中均匀取5行(覆盖整个图像高度),每行取中间五分之四作为样本;以灰度值为X轴,每个灰度值的像素个数为Y轴建立一个直方图,从直方图中取点数最多的一个灰度值,然后再去给其他的灰度值进行分数计算,按照点数乘以与最多点数灰度值的距离的平方来进行打分,选分数最高的一个灰度值。接下来在这两个灰度值中间选取一个区分界限,取的原则是尽量靠近中间并且要点数越少越好。界限有了以后就容易了,与整幅图像的每个点进行比较,如果灰度值比界限小的就是黑,在新的矩阵中将该点置1,其余的就是白,为0。

上面一句的代码,调用了Detector中的detect(Map<DecodeHintType,?> hints)方法,代码如下

/**
   * <p>Detects a QR Code in an image.</p>
   *
   * @param hints optional hints to detector
   * @return {@link DetectorResult} encapsulating results of detecting a QR Code
   * @throws NotFoundException if QR Code cannot be found
   * @throws FormatException if a QR Code cannot be decoded
   */
  public final DetectorResult detect(Map<DecodeHintType,?> hints) throws NotFoundException, FormatException {

    resultPointCallback = hints == null ? null :
        (ResultPointCallback) hints.get(DecodeHintType.NEED_RESULT_POINT_CALLBACK);

    FinderPatternFinder finder = new FinderPatternFinder(image, resultPointCallback);
    FinderPatternInfo info = finder.find(hints);

    return processFinderPatternInfo(info);
  }
复制代码

从这段代码的注释中可以得知,这个方法的作用就是“封装检测二维码的结果”,如果没有发现二维码就会抛出NotFoundException异常,如果不能解析二维码就会抛出FormatException异常。现在,我们来看怎样找到图像中的二维码的。

二维码的特征

  在介绍发现图片中二维码的方法之前,先来看下二维码的特点,如下图

二维码在设计之初就考虑到了识别问题,所以二维码有一些特征是非常明显的。

二维码有三个“回“字形图案,这一点非常明显。中间的一个点位于图案的左上角,如果图像偏转,也可以根据二维码来纠正。

识别二维码,就是识别二维码的三个点,逐步分析一下这三个点的特性

  1. 每个点有两个轮廓。就是两个口,大“口”内部有一个小“口”,所以是两个轮廓。
  2. 如果把这个“回”放到一个白色的背景下,从左到右,或从上到下画一条线。这条线经过的图案黑白比例大约为:黑白比例为1:1:3:1:1。如下图
  3. 如何找到左上角的顶点?这个顶点与其他两个顶点的夹角为90度。

通过上面几个步骤,就能识别出二维码的三个顶点,并且识别出左上角的顶点。

ZXing识别图像中的二维码

  上面已经介绍了二维码的特征,也介绍了怎样发现二维码的“回”字,现在,我们来看下ZXing是怎么识别图片中的二维码的,主要的代码如下

final FinderPatternInfo find(Map<DecodeHintType,?> hints) throws NotFoundException {
    boolean tryHarder = hints != null && hints.containsKey(DecodeHintType.TRY_HARDER);
    int maxI = image.getHeight();
    int maxJ = image.getWidth();
    // 在图像中寻找黑白像素比例为1:1:3:1:1
    int iSkip = (3 * maxI) / (4 * MAX_MODULES);
    if (iSkip < MIN_SKIP || tryHarder) {
      iSkip = MIN_SKIP;
    }

    boolean done = false;
    int[] stateCount = new int[5];
    for (int i = iSkip - 1; i < maxI && !done; i += iSkip) {
      // 获取一行的黑白像素值
      clearCounts(stateCount);
      int currentState = 0;
      for (int j = 0; j < maxJ; j++) {
        if (image.get(j, i)) {
          // 黑色像素
          if ((currentState & 1) == 1) { // Counting white pixels
            currentState++;
          }
          stateCount[currentState]++;
        } else { // 白色像素
          if ((currentState & 1) == 0) { // Counting black pixels
            if (currentState == 4) { // A winner?
              if (foundPatternCross(stateCount)) { // Yes 是否是二维码左上角的回字
                boolean confirmed = handlePossibleCenter(stateCount, i, j);
                if (confirmed) {
                  // Start examining every other line. Checking each line turned out to be too
                  // expensive and didn't improve performance.
                  iSkip = 2;
                  if (hasSkipped) {
                    done = haveMultiplyConfirmedCenters();
                  } else {
                    int rowSkip = findRowSkip();
                    if (rowSkip > stateCount[2]) {
                      // Skip rows between row of lower confirmed center
                      // and top of presumed third confirmed center
                      // but back up a bit to get a full chance of detecting
                      // it, entire width of center of finder pattern
                      // Skip by rowSkip, but back off by stateCount[2] (size of last center
                      // of pattern we saw) to be conservative, and also back off by iSkip which
                      // is about to be re-added
                      i += rowSkip - stateCount[2] - iSkip;
                      j = maxJ - 1;
                    }
                  }
                } else {
                  shiftCounts2(stateCount);
                  currentState = 3;
                  continue;
                }
                // Clear state to start looking again
                currentState = 0;
                clearCounts(stateCount);
              } else { // No, shift counts back by two
                shiftCounts2(stateCount);
                currentState = 3;
              }
            } else {
              stateCount[++currentState]++;
            }
          } else { // Counting white pixels
            stateCount[currentState]++;
          }
        }
      }
      if (foundPatternCross(stateCount)) {
        boolean confirmed = handlePossibleCenter(stateCount, i, maxJ);
        if (confirmed) {
          iSkip = stateCount[0];
          if (hasSkipped) {
            // Found a third one
            done = haveMultiplyConfirmedCenters();
          }
        }
      }
    }
    FinderPattern[] patternInfo = selectBestPatterns();
    ResultPoint.orderBestPatterns(patternInfo);
    return new FinderPatternInfo(patternInfo);
  }
复制代码

上面的代码主要做了下面的事

1、寻找定位符

  在图像中每隔iSkip就采样一行,

int iSkip = (3 * maxI) / (4 * MAX_MODULES);
复制代码

  在这一行中将连续的相同颜色的像素个数计入数组中,数组长度为5位,即去找黑\白\黑\白\黑的图像(如开始检测到黑色计入数组[0],直到检测到白色之前都将数组[0]的值+1;检测到白色了就开始在数组[1]中计数,以此类推)。填满5位后检测这5位中像素个数是否比例为1:1:3:1:1(可以有50%的误差范围),如果满足条件就说明找到了定位符的大概位置,将这个图像交给handlePossibleCenter方法去找到定位符的中心点,方法是先从垂直方向检测是否满足定位符的条件,如满足就定出Y轴的中心点坐标值,然后用这个坐标值去再次检测水平方向是否满足定位符条件,如满足就定出X轴的中心点坐标值。至此就找到了一个定位符的中心坐标。

  按照上面所说的步骤找出所有三个定位符的中心坐标,接下来开始定位三个定位符在符号中的位置,即左上(B点)、左下(A点)、右上(C点)三个位置。先通过两两之间的距离定出哪个是左上那一点(左上那点到其他两点的距离应该相差不远),然后通过计算BA、BC向量的叉乘定出A和C两点。

2、寻找校正符

  通过ABC三点的坐标计算出校正符的可能位置,然后交给AlignmentPatternFinder去寻找最靠近右下角的那个校正符,寻找方法与寻找定位符的方法基本相同,如果找到就返回校正符的中心坐标,如果没有找到也没关系,解码程序可以继续。

通过上面的两步就可以判断相机获取的图像帧中是否有二维码了,如果有二维码则进行二维码的解析,没有二维码就抛出异常,然后继续解析下一帧图像数据。

总结

  通过上文的讲解和源码的分析,我们可以知道判断图像帧中是否有二维码需要经过以下几步:

  1. 获取图像帧的数据,格式为YUV;
  2. 将二维码扫码框中的图像数据进行灰度化处理;
  3. 将灰度化后的图像进行二值化处理;
  4. 根据二维码的特征寻找定位符;
  5. 寻找二维码的校正符。

如果在步骤“4”中找到了校正符,则说明这一帧图片中含有二维码,可以进行二维码的解析,否则就抛出异常,继续解析下一帧图像的数据。

结束语

  没有看源码之前,我是比较迷茫的,不知道怎样才能判断图片中是否有二维码,虽然知道可以根据二维码中的“回”字来判断,但是不知道怎么找到“回”字呀!阅读源码后才知道,可以将图片进行“二值化”处理,再根据黑白像素的比例来找到“回”字,感觉学到了很多。所以呢,在我们不知道某个库的某个功能是怎样实现的时候,最好的解决办法就是阅读源码,答案都在源码中。

  在研究源码的时候删除了好多与解析二维码无关的代码,最后的代码在这里

  该系列文章:

ZXing源码解析一:让源码跑起来
ZXing源码解析二:掌握解码步骤
ZXing源码解析三:相机的配置与数据的处理

本文已由公众号“AndroidShared”首发

欢迎关注我的公众号
扫码关注公众号,回复“获取资料”有惊喜