200 lines
5.7 KiB
Python
200 lines
5.7 KiB
Python
"""二维码识别工具。
|
|
|
|
多级管线,从快到慢逐级重试,命中即止:
|
|
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 []
|