feat(datascience): auto-commit con 5 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Decodificación robusta de códigos QR desde una imagen en disco.
|
||||
|
||||
Función del registry (grupo de capacidad `qr`, dominio `datascience`). Pensada para el caso real
|
||||
en el que un lector básico (pyzbar, `cv2.QRCodeDetector` sobre la imagen cruda) NO capta el QR:
|
||||
screenshots de pantalla con QR pálidos (bajo contraste) o pequeños. En vez de un único intento,
|
||||
genera varias variantes preprocesadas de la imagen y prueba cada detector disponible sobre cada
|
||||
variante, parando al primer acierto.
|
||||
|
||||
Impura: lee un archivo de disco y depende de OpenCV (`opencv-contrib-python-headless`). Degrada
|
||||
limpio (devuelve `[]`) si la imagen no se puede leer o si ningún QR se decodifica; no lanza.
|
||||
|
||||
Detectores (se usan los que estén instalados; el import se envuelve en try/except para degradar):
|
||||
- `cv2.QRCodeDetectorAruco` (preferido — OpenCV puro, sin libs de sistema)
|
||||
- `cv2.QRCodeDetector` (fallback OpenCV puro)
|
||||
- `cv2.wechat_qrcode.WeChatQRCode` (excelente con bajo contraste; SOLO si los modelos cargan)
|
||||
- `pyzbar` (bonus opcional; requiere la lib de sistema `libzbar0`)
|
||||
|
||||
Cero dependencias de sistema obligatorias: con solo OpenCV la función ya funciona.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Detectores. Cada uno se normaliza a una función run(img) -> list[str] que nunca lanza.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
def _make_opencv_runner(detector):
|
||||
"""Envuelve un cv2.QRCodeDetector(Aruco) en run(img) -> list[str]."""
|
||||
|
||||
def run(img):
|
||||
out: list[str] = []
|
||||
# detectAndDecodeMulti: capta varios QR en la misma imagen.
|
||||
try:
|
||||
ok, decoded, _points, _ = detector.detectAndDecodeMulti(img)
|
||||
if ok and decoded:
|
||||
out = [s for s in decoded if s]
|
||||
except cv2.error:
|
||||
pass
|
||||
if not out:
|
||||
# Fallback al decodificador de un solo QR.
|
||||
try:
|
||||
s, _pts, _ = detector.detectAndDecode(img)
|
||||
if s:
|
||||
out = [s]
|
||||
except cv2.error:
|
||||
pass
|
||||
return out
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _make_wechat_runner(wd):
|
||||
"""Envuelve un cv2.wechat_qrcode.WeChatQRCode en run(img) -> list[str]."""
|
||||
|
||||
def run(img):
|
||||
try:
|
||||
texts, _points = wd.detectAndDecode(img)
|
||||
return [t for t in texts if t]
|
||||
except Exception:
|
||||
# Si los modelos no están cargados o el detector falla, degradar sin romper.
|
||||
return []
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _make_pyzbar_runner(zbar_decode):
|
||||
"""Envuelve pyzbar.decode en run(img) -> list[str]."""
|
||||
|
||||
def run(img):
|
||||
out: list[str] = []
|
||||
try:
|
||||
for sym in zbar_decode(img):
|
||||
try:
|
||||
out.append(sym.data.decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return []
|
||||
return out
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _build_detectors(debug=False):
|
||||
"""Construye la lista de (nombre, runner) de detectores disponibles, en orden de preferencia."""
|
||||
detectors = []
|
||||
|
||||
# OpenCV Aruco (preferido): no requiere libs de sistema ni descarga de modelos.
|
||||
if hasattr(cv2, "QRCodeDetectorAruco"):
|
||||
try:
|
||||
detectors.append(("opencv_aruco", _make_opencv_runner(cv2.QRCodeDetectorAruco())))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# OpenCV clásico (fallback puro).
|
||||
if hasattr(cv2, "QRCodeDetector"):
|
||||
try:
|
||||
detectors.append(("opencv", _make_opencv_runner(cv2.QRCodeDetector())))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# WeChat QR (excelente con bajo contraste) — SOLO si los modelos cargan; opcional.
|
||||
if hasattr(cv2, "wechat_qrcode"):
|
||||
try:
|
||||
wd = cv2.wechat_qrcode.WeChatQRCode()
|
||||
detectors.append(("wechat", _make_wechat_runner(wd)))
|
||||
except Exception:
|
||||
# Modelos no presentes / build sin soporte → saltar sin romper.
|
||||
pass
|
||||
|
||||
# pyzbar (bonus): requiere libzbar0 (lib de sistema). Degrada si falta.
|
||||
try:
|
||||
from pyzbar.pyzbar import decode as _zbar_decode # type: ignore
|
||||
|
||||
detectors.append(("pyzbar", _make_pyzbar_runner(_zbar_decode)))
|
||||
except (ImportError, OSError, Exception): # noqa: B014 - OSError = libzbar0 ausente
|
||||
pass
|
||||
|
||||
if debug:
|
||||
print(
|
||||
f"[decode_qr_image] detectores disponibles: {[n for n, _ in detectors]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return detectors
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Variantes preprocesadas de la imagen. Orden = prioridad; se para en el primer acierto.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
def _load_bgr(image_path):
|
||||
"""Carga la imagen como BGR (uint8). Devuelve None si no se puede leer."""
|
||||
bgr = cv2.imread(image_path, cv2.IMREAD_COLOR)
|
||||
if bgr is not None:
|
||||
return bgr
|
||||
# Fallback PIL para formatos que cv2.imread no maneja en esta build.
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
pil = Image.open(image_path).convert("RGB")
|
||||
return cv2.cvtColor(np.asarray(pil), cv2.COLOR_RGB2BGR)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_variants(image_path, upscale):
|
||||
"""Genera (nombre, ndarray) de variantes preprocesadas, en orden de prioridad."""
|
||||
bgr = _load_bgr(image_path)
|
||||
if bgr is None:
|
||||
return []
|
||||
|
||||
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Contrast stretch (NORM_MINMAX): clave para QR de bajo contraste (gris sobre gris).
|
||||
stretch = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
||||
|
||||
# CLAHE: realce de contraste local.
|
||||
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray)
|
||||
|
||||
# Upscale del stretch: QR pequeño es la causa #1 de fallo.
|
||||
if upscale and upscale > 1:
|
||||
up = cv2.resize(stretch, None, fx=upscale, fy=upscale, interpolation=cv2.INTER_CUBIC)
|
||||
else:
|
||||
up = stretch
|
||||
|
||||
# Binarizaciones sobre el stretch (mejor base que el gris crudo).
|
||||
_, otsu = cv2.threshold(stretch, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||
adaptive = cv2.adaptiveThreshold(
|
||||
stretch, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 5
|
||||
)
|
||||
|
||||
variants = [
|
||||
("original", bgr),
|
||||
("gray", gray),
|
||||
("contrast_stretch", stretch),
|
||||
("clahe", clahe),
|
||||
("upscale", up),
|
||||
("otsu", otsu),
|
||||
("adaptive_gaussian", adaptive),
|
||||
]
|
||||
|
||||
# Rotaciones sobre la mejor variante binarizada (Otsu).
|
||||
for name, rot in (
|
||||
("rot90", cv2.ROTATE_90_CLOCKWISE),
|
||||
("rot180", cv2.ROTATE_180),
|
||||
("rot270", cv2.ROTATE_90_COUNTERCLOCKWISE),
|
||||
):
|
||||
variants.append((f"otsu_{name}", cv2.rotate(otsu, rot)))
|
||||
|
||||
return variants
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# API pública.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
def decode_qr_image(image_path: str, upscale: int = 2, debug: bool = False) -> list[str]:
|
||||
"""Decodifica los códigos QR de una imagen, robusto a bajo contraste y QR pequeños.
|
||||
|
||||
Genera varias variantes preprocesadas de la imagen (escala de grises, contrast stretch,
|
||||
CLAHE, upscale, binarización Otsu/adaptativa, rotaciones) y prueba cada detector disponible
|
||||
(OpenCV Aruco/clásico, WeChat si hay modelos, pyzbar si hay libzbar0) sobre cada variante,
|
||||
parando al primer acierto.
|
||||
|
||||
Parámetros (`upscale` y `debug` pensados como opciones keyword):
|
||||
image_path: ruta del archivo de imagen a leer (png/jpg/...).
|
||||
upscale: factor de ampliación (INTER_CUBIC) aplicado a la variante de contraste estirado
|
||||
para rescatar QR pequeños. Default 2. <=1 desactiva el upscale.
|
||||
debug: si True, imprime a stderr qué variante/detector acertó (o que no se detectó nada).
|
||||
|
||||
Returns:
|
||||
Lista de payloads de texto de los QR detectados (deduplicada, preservando orden). Lista
|
||||
vacía si no se detecta ninguno o si la imagen no se puede leer. No lanza.
|
||||
"""
|
||||
try:
|
||||
variants = _build_variants(image_path, upscale)
|
||||
except Exception as exc: # pragma: no cover - defensa ante imágenes corruptas
|
||||
if debug:
|
||||
print(f"[decode_qr_image] fallo construyendo variantes: {exc}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
if not variants:
|
||||
if debug:
|
||||
print(f"[decode_qr_image] no se pudo leer la imagen: {image_path}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
detectors = _build_detectors(debug=debug)
|
||||
if not detectors:
|
||||
if debug:
|
||||
print("[decode_qr_image] ningún detector QR disponible", file=sys.stderr)
|
||||
return []
|
||||
|
||||
for vname, vimg in variants:
|
||||
for dname, drun in detectors:
|
||||
payloads = drun(vimg)
|
||||
uniq = list(dict.fromkeys(p for p in payloads if p))
|
||||
if uniq:
|
||||
if debug:
|
||||
print(
|
||||
f"[decode_qr_image] acierto variante={vname} detector={dname} "
|
||||
f"n={len(uniq)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return uniq
|
||||
|
||||
if debug:
|
||||
print("[decode_qr_image] ningún QR decodificado en ninguna variante", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo CLI para `python3 decode_qr_image.py <image_path> [upscale] [debug]`.
|
||||
# (fn run usa su propio runner generado; este bloque es para invocación manual directa.)
|
||||
import json
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"error": "uso: <image_path> [upscale] [debug]"}))
|
||||
sys.exit(1)
|
||||
|
||||
_path = sys.argv[1]
|
||||
_upscale = int(sys.argv[2]) if len(sys.argv) > 2 else 2
|
||||
_debug = (sys.argv[3].lower() in ("1", "true", "yes")) if len(sys.argv) > 3 else False
|
||||
|
||||
_result = decode_qr_image(_path, upscale=_upscale, debug=_debug)
|
||||
print(json.dumps(_result))
|
||||
Reference in New Issue
Block a user