优化定二维码识别逻辑

This commit is contained in:
zk
2026-07-28 17:33:22 +08:00
parent c9f62563d6
commit 33b3686c25
3 changed files with 358 additions and 54 deletions
+178 -20
View File
@@ -1,4 +1,8 @@
"""二维码检测与裁剪工具。""" """二维码检测与裁剪工具。
识别引擎优先用 zxing-cpp(对反色、旋转、小尺寸、艺术化二维码的容错明显更好),
拿不到时退回 OpenCV 自带检测器,保证不装 zxing-cpp 也能跑。
"""
from __future__ import annotations from __future__ import annotations
@@ -8,6 +12,34 @@ from pathlib import Path
import cv2 import cv2
import numpy as np 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 @dataclass
class QrRegion: class QrRegion:
@@ -15,6 +47,7 @@ class QrRegion:
points: tuple[tuple[int, int], ...] points: tuple[tuple[int, int], ...]
crop: np.ndarray | None = None crop: np.ndarray | None = None
text: str = ""
@dataclass @dataclass
@@ -105,34 +138,159 @@ def _normalize_points(points: np.ndarray | None) -> list[tuple[tuple[int, int],
return result return result
def _detect_points(img: np.ndarray) -> list[tuple[tuple[int, int], ...]]: def _clip_points(
detector = cv2.QRCodeDetector() 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
)
if hasattr(detector, "detectMulti"):
ok, points = detector.detectMulti(img)
if ok:
normalized_points = _normalize_points(points)
if normalized_points:
return normalized_points
ok, points = detector.detect(img) def zxing_read(
if ok: img: np.ndarray,
normalized_points = _normalize_points(points) *,
if normalized_points: binarizer: object | None = None,
return normalized_points 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 [] 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: def scan_qr(image: bytes | np.ndarray | str | Path) -> QrDetectResult:
"""扫描图片中是否存在二维码,并返回二维码区域。""" """扫描图片中是否存在二维码,并返回二维码区域。"""
img = _load_image(image) img = _load_image(image)
points_list = _detect_points(img) items = _detect_regions(img)
items = [
QrRegion(points=points, crop=_warp_qr_image(img, np.array(points, dtype=np.float32)))
for points in points_list
]
return QrDetectResult(has_qr=bool(items), items=items) return QrDetectResult(has_qr=bool(items), items=items)
+179 -34
View File
@@ -1,4 +1,12 @@
"""二维码识别工具。""" """二维码识别工具。
多级管线,从快到慢逐级重试,命中即止:
1. zxing-cpp 原图(默认已开反色 / 旋转 / 缩放重试)
2. zxing-cpp 换二值化算法(救低对比度、带纹理背景)
3. zxing-cpp 放大图(救长图里的小码、低分辨率码)
4. OpenCV 检测器 + 反色重试(zxing-cpp 缺失时的主路径)
5. 先裁剪二维码区域、补静默区再放大解码(救贴边、占比极小的码)
"""
from __future__ import annotations from __future__ import annotations
@@ -7,48 +15,185 @@ from pathlib import Path
import cv2 import cv2
import numpy as np import numpy as np
from .cv import _load_image, _normalize_points, _warp_qr_image 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 _decode_with_detector(img: np.ndarray) -> list[str]: def _dedupe(texts: list[str]) -> list[str]:
detector = cv2.QRCodeDetector() """去重且保持顺序。"""
texts: 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
if hasattr(detector, "detectAndDecodeMulti"):
ok, decoded_info, points, _ = detector.detectAndDecodeMulti(img)
if ok and points is not None:
normalized_points = _normalize_points(points)
if isinstance(decoded_info, (list, tuple)):
decoded_iter = [item or "" for item in decoded_info]
else:
decoded_iter = [decoded_info or ""]
for idx, points_item in enumerate(normalized_points): def _texts_of(barcodes: list) -> list[str]:
text = decoded_iter[idx] if idx < len(decoded_iter) else "" return _dedupe([getattr(item, "text", "") or "" for item in barcodes])
if not text:
crop = _warp_qr_image(img, np.array(points_item, dtype=np.float32))
fallback_text, _, _ = detector.detectAndDecode(crop)
text = fallback_text or ""
if text:
texts.append(text)
if texts:
return texts
text, points, _ = detector.detectAndDecode(img)
if text:
return [text]
normalized_points = _normalize_points(points) def _stage_zxing_plain(img: np.ndarray) -> list[str]:
if normalized_points: """原图直接交给 zxing-cpp。"""
crop = _warp_qr_image(img, np.array(normalized_points[0], dtype=np.float32)) return _texts_of(zxing_read(img))
fallback_text, _, _ = detector.detectAndDecode(crop)
if fallback_text:
return [fallback_text]
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 [] 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]: def decode_qr(image: bytes | np.ndarray | str | Path) -> list[str]:
"""识别图片中的二维码内容。""" """识别图片中的二维码内容。
Args:
image: 图片字节流、BGR 图像数组或图片路径。
Returns:
二维码内容列表,已去重;没识别出来时返回空列表。
"""
img = _load_image(image) img = _load_image(image)
return _decode_with_detector(img)
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 []
+1
View File
@@ -3,6 +3,7 @@ httpx>=0.28
rapidocr>=3.9 rapidocr>=3.9
onnxruntime>=1.17 onnxruntime>=1.17
opencv-python>=4.5 opencv-python>=4.5
zxing-cpp>=3.1
playwright>=1.49 playwright>=1.49
pydantic-settings>=2.0 pydantic-settings>=2.0