"""二维码检测与裁剪工具。 识别引擎优先用 zxing-cpp(对反色、旋转、小尺寸、艺术化二维码的容错明显更好), 拿不到时退回 OpenCV 自带检测器,保证不装 zxing-cpp 也能跑。 """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path import cv2 import numpy as np from app.core.logger import log try: # zxing-cpp 是可选依赖,缺失时自动退化为纯 OpenCV import zxingcpp _HAS_ZXING = True except ImportError: # pragma: no cover - 取决于部署环境 zxingcpp = None _HAS_ZXING = False log.warning("未安装 zxing-cpp,二维码识别将退化为 OpenCV 检测器,识别率会明显下降") # 要识别的二维码类型:标准 QR + 微型 QR + 矩形 QR _QR_FORMATS = ( [ zxingcpp.BarcodeFormat.QRCode, zxingcpp.BarcodeFormat.MicroQRCode, zxingcpp.BarcodeFormat.RMQRCode, ] if _HAS_ZXING else [] ) # 放大重试的尺寸上限,避免长图被放大到内存爆掉 _MAX_UPSCALE_SIDE = 2600 # 裁剪区域重试时补的静默区宽度(像素) _QUIET_ZONE = 16 @dataclass class QrRegion: """单个二维码区域。""" points: tuple[tuple[int, int], ...] crop: np.ndarray | None = None text: str = "" @dataclass class QrDetectResult: """二维码检测结果。""" has_qr: bool items: list[QrRegion] def _load_image(image: bytes | np.ndarray | str | Path) -> np.ndarray: """把输入转成 BGR 图像。""" if isinstance(image, np.ndarray): if image.ndim == 2: return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) return image.copy() if isinstance(image, (str, Path)): data = Path(image).read_bytes() else: data = image arr = np.frombuffer(data, dtype=np.uint8) img = cv2.imdecode(arr, cv2.IMREAD_COLOR) if img is None: raise ValueError("Failed to decode image data.") return img def _order_points(points: np.ndarray) -> np.ndarray: """把四个角点整理成左上、右上、右下、左下。""" pts = np.asarray(points, dtype=np.float32).reshape(4, 2) rect = np.zeros((4, 2), dtype=np.float32) s = pts.sum(axis=1) diff = np.diff(pts, axis=1) rect[0] = pts[np.argmin(s)] rect[2] = pts[np.argmax(s)] rect[1] = pts[np.argmin(diff)] rect[3] = pts[np.argmax(diff)] return rect def _warp_qr_image(img: np.ndarray, points: np.ndarray) -> np.ndarray: """按四边形点做透视矫正,截取二维码区域。""" rect = _order_points(points) (tl, tr, br, bl) = rect width_a = np.linalg.norm(br - bl) width_b = np.linalg.norm(tr - tl) height_a = np.linalg.norm(tr - br) height_b = np.linalg.norm(tl - bl) width = max(int(round(max(width_a, width_b))), 1) height = max(int(round(max(height_a, height_b))), 1) dst = np.array( [ [0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1], ], dtype=np.float32, ) matrix = cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(img, matrix, (width, height)) def _normalize_points(points: np.ndarray | None) -> list[tuple[tuple[int, int], ...]]: if points is None: return [] arr = np.asarray(points) if arr.ndim == 2 and arr.shape == (4, 2): arr = arr[None, ...] elif arr.ndim == 3 and arr.shape[-2:] == (4, 2): pass else: return [] result: list[tuple[tuple[int, int], ...]] = [] for item in arr: quad = tuple((int(round(x)), int(round(y))) for x, y in item) result.append(quad) return result def _clip_points( quad: tuple[tuple[int, int], ...], shape: tuple[int, ...] ) -> tuple[tuple[int, int], ...]: """把角点裁进图像范围内,避免透视变换取到界外。""" height, width = shape[:2] return tuple( (min(max(x, 0), width - 1), min(max(y, 0), height - 1)) for x, y in quad ) def zxing_read( img: np.ndarray, *, binarizer: object | None = None, try_downscale: bool = True, ) -> list: """用 zxing-cpp 识别图中所有二维码,失败返回空列表。 zxing-cpp 默认已开启 try_invert(反色)和 try_rotate(旋转), 这是它比 OpenCV 检测器兼容性好的主要原因。 """ if not _HAS_ZXING: return [] kwargs = { "formats": _QR_FORMATS, "try_rotate": True, "try_invert": True, "try_downscale": try_downscale, } if binarizer is not None: kwargs["binarizer"] = binarizer try: return list(zxingcpp.read_barcodes(img, **kwargs)) except Exception as exc: # zxing 内部异常不应该打断整个流程 log.debug(f"zxing-cpp 识别异常: {exc}") return [] def zxing_variants(img: np.ndarray) -> list[tuple[str, np.ndarray]]: """构造 zxing 的重试图像变体:原图之外再补一版放大图。 放大主要救「长图里的小二维码」和「低分辨率二维码」两类。 """ variants: list[tuple[str, np.ndarray]] = [("原图", img)] longest = max(img.shape[:2]) if longest and longest * 2 <= _MAX_UPSCALE_SIDE: variants.append( ("放大2倍", cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)) ) return variants def pad_quiet_zone(img: np.ndarray, border: int = _QUIET_ZONE) -> np.ndarray: """给裁剪出来的二维码补静默区。 有些海报把二维码贴边放,裁出来没有留白,补一圈能提高解码成功率。 边框颜色取图像四角的中位数,反色码补深色、正常码补浅色,避免破坏极性。 """ corners = np.array( [img[0, 0], img[0, -1], img[-1, 0], img[-1, -1]], dtype=np.float32 ) color = np.median(corners, axis=0) value = tuple(int(round(c)) for c in np.atleast_1d(color)) if len(value) == 1: value = value * 3 return cv2.copyMakeBorder( img, border, border, border, border, cv2.BORDER_CONSTANT, value=value ) def _points_from_zxing(barcode: object) -> tuple[tuple[int, int], ...] | None: """把 zxing 的 position 转成四角点。""" position = getattr(barcode, "position", None) if position is None: return None quad: list[tuple[int, int]] = [] for name in ("top_left", "top_right", "bottom_right", "bottom_left"): point = getattr(position, name, None) if point is None: return None quad.append((int(round(point.x)), int(round(point.y)))) return tuple(quad) def _detect_by_opencv(img: np.ndarray) -> list[tuple[tuple[int, int], ...]]: """OpenCV 检测器兜底,额外补一次反色重试。""" for candidate in (img, cv2.bitwise_not(img)): for detector in (cv2.QRCodeDetector(), cv2.QRCodeDetectorAruco()): try: if hasattr(detector, "detectMulti"): ok, points = detector.detectMulti(candidate) if ok: normalized = _normalize_points(points) if normalized: return normalized ok, points = detector.detect(candidate) if ok: normalized = _normalize_points(points) if normalized: return normalized except cv2.error: continue return [] def _detect_regions(img: np.ndarray) -> list[QrRegion]: """检测二维码区域:zxing 优先(顺带拿到内容),OpenCV 兜底。""" for name, variant in zxing_variants(img): barcodes = zxing_read(variant) if not barcodes: continue scale = variant.shape[1] / img.shape[1] if img.shape[1] else 1 regions: list[QrRegion] = [] for barcode in barcodes: quad = _points_from_zxing(barcode) if quad is None: continue if scale != 1: quad = tuple( (int(round(x / scale)), int(round(y / scale))) for x, y in quad ) quad = _clip_points(quad, img.shape) regions.append( QrRegion( points=quad, crop=_warp_qr_image(img, np.array(quad, dtype=np.float32)), text=getattr(barcode, "text", "") or "", ) ) if regions: if name != "原图": log.debug(f"二维码检测命中变体: {name}") return regions return [ QrRegion( points=quad, crop=_warp_qr_image(img, np.array(quad, dtype=np.float32)), ) for quad in _detect_by_opencv(img) ] def scan_qr(image: bytes | np.ndarray | str | Path) -> QrDetectResult: """扫描图片中是否存在二维码,并返回二维码区域。""" img = _load_image(image) items = _detect_regions(img) return QrDetectResult(has_qr=bool(items), items=items) def has_qr(image: bytes | np.ndarray | str | Path) -> bool: """判断图片里有没有二维码。""" return scan_qr(image).has_qr def crop_qr(image: bytes | np.ndarray | str | Path) -> list[np.ndarray]: """裁剪出图片中的二维码区域。""" return [item.crop for item in scan_qr(image).items if item.crop is not None]