""" 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 [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: [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))