305 lines
9.3 KiB
Python
305 lines
9.3 KiB
Python
"""二维码检测与裁剪工具。
|
|
|
|
识别引擎优先用 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]
|