"""二维码识别工具。 多级管线,从快到慢逐级重试,命中即止: 1. zxing-cpp 原图(默认已开反色 / 旋转 / 缩放重试) 2. zxing-cpp 换二值化算法(救低对比度、带纹理背景) 3. zxing-cpp 放大图(救长图里的小码、低分辨率码) 4. OpenCV 检测器 + 反色重试(zxing-cpp 缺失时的主路径) 5. 先裁剪二维码区域、补静默区再放大解码(救贴边、占比极小的码) """ from __future__ import annotations from pathlib import Path import cv2 import numpy as np from app.core.logger import log from .cv import ( _HAS_ZXING, _load_image, _normalize_points, _warp_qr_image, pad_quiet_zone, zxing_read, zxing_variants, zxingcpp, ) # 裁剪区域重试时,把二维码放大到的目标边长 _CROP_TARGET_SIDE = 480 def _dedupe(texts: list[str]) -> list[str]: """去重且保持顺序。""" seen: set[str] = set() result: list[str] = [] for text in texts: if text and text not in seen: seen.add(text) result.append(text) return result def _texts_of(barcodes: list) -> list[str]: return _dedupe([getattr(item, "text", "") or "" for item in barcodes]) def _stage_zxing_plain(img: np.ndarray) -> list[str]: """原图直接交给 zxing-cpp。""" return _texts_of(zxing_read(img)) def _stage_zxing_binarizers(img: np.ndarray) -> list[str]: """换二值化算法重试,应对低对比度和带图案的背景。""" if not _HAS_ZXING: return [] for binarizer in ( zxingcpp.Binarizer.GlobalHistogram, zxingcpp.Binarizer.FixedThreshold, zxingcpp.Binarizer.BoolCast, ): texts = _texts_of(zxing_read(img, binarizer=binarizer)) if texts: log.debug(f"二维码解码命中二值化算法: {binarizer}") return texts return [] def _stage_zxing_upscaled(img: np.ndarray) -> list[str]: """放大后重试,救小尺寸二维码。""" for name, variant in zxing_variants(img): if name == "原图": continue texts = _texts_of(zxing_read(variant, try_downscale=False)) if texts: log.debug(f"二维码解码命中变体: {name}") return texts return [] def _decode_with_opencv(img: np.ndarray) -> list[str]: """OpenCV 检测器解码,含反色重试。""" texts: list[str] = [] for candidate in (img, cv2.bitwise_not(img)): for detector in (cv2.QRCodeDetector(), cv2.QRCodeDetectorAruco()): try: ok, infos, points, _ = detector.detectAndDecodeMulti(candidate) except cv2.error: ok, infos, points = False, None, None if ok and infos is not None: found = [item for item in infos if item] if found: texts.extend(found) if texts: return _dedupe(texts) try: text, points, _ = detector.detectAndDecode(candidate) except cv2.error: continue if text: return [text] # 检测到位置但没解出内容时,裁出区域再试一次 for quad in _normalize_points(points): crop = _warp_qr_image(candidate, np.array(quad, dtype=np.float32)) if crop.size == 0: continue try: fallback, _, _ = detector.detectAndDecode(crop) except cv2.error: continue if fallback: texts.append(fallback) if texts: return _dedupe(texts) return _dedupe(texts) def _stage_opencv(img: np.ndarray) -> list[str]: return _decode_with_opencv(img) def _stage_crop_retry(img: np.ndarray) -> list[str]: """先定位并裁出二维码,补静默区放大后再解码。 针对二维码在长图里占比极小、或紧贴边缘没有留白的情况。 """ from .cv import _detect_regions # 局部导入,避免循环依赖 texts: list[str] = [] for region in _detect_regions(img): if region.text: texts.append(region.text) continue crop = region.crop if crop is None or crop.size == 0: continue padded = pad_quiet_zone(crop) longest = max(padded.shape[:2]) if longest and longest < _CROP_TARGET_SIDE: scale = _CROP_TARGET_SIDE / longest padded = cv2.resize( padded, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC ) found = _texts_of(zxing_read(padded, try_downscale=False)) if not found: found = _decode_with_opencv(padded) texts.extend(found) return _dedupe(texts) # 管线顺序:命中即返回 _STAGES = ( ("zxing原图", _stage_zxing_plain), ("zxing二值化", _stage_zxing_binarizers), ("zxing放大", _stage_zxing_upscaled), ("opencv", _stage_opencv), ("裁剪重试", _stage_crop_retry), ) def decode_qr(image: bytes | np.ndarray | str | Path) -> list[str]: """识别图片中的二维码内容。 Args: image: 图片字节流、BGR 图像数组或图片路径。 Returns: 二维码内容列表,已去重;没识别出来时返回空列表。 """ img = _load_image(image) for name, stage in _STAGES: try: texts = stage(img) except Exception as exc: # 单级失败不影响后续重试 log.debug(f"二维码解码阶段异常({name}): {exc}") continue if texts: if name != "zxing原图": log.debug(f"二维码解码命中阶段: {name}") return texts return []