Compare commits

..

2 Commits

5 changed files with 381 additions and 0 deletions
File diff suppressed because one or more lines are too long
+2
View File
@@ -44,8 +44,10 @@ from .trend_slope import trend_slope
from .run_eda_models import run_eda_models from .run_eda_models import run_eda_models
from .eda_llm_insights import eda_llm_insights from .eda_llm_insights import eda_llm_insights
from .build_eda_notebook import build_eda_notebook from .build_eda_notebook import build_eda_notebook
from .decode_qr_image import decode_qr_image
__all__ = [ __all__ = [
"decode_qr_image",
"summarize_table_duckdb", "summarize_table_duckdb",
"summarize_table_pg", "summarize_table_pg",
"spearman_corr", "spearman_corr",
@@ -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))
+3
View File
@@ -19,7 +19,9 @@ dependencies = [
"google-cloud-storage>=3.10.1", "google-cloud-storage>=3.10.1",
"httpx", "httpx",
"matplotlib>=3.10.9", "matplotlib>=3.10.9",
"opencv-contrib-python-headless>=4.13.0.92",
"openpyxl>=3.1.5", "openpyxl>=3.1.5",
"pillow>=12.2.0",
"polars>=1.40.1", "polars>=1.40.1",
"pymeshlab>=2025.7.post1", "pymeshlab>=2025.7.post1",
"pymssql>=2.3.13", "pymssql>=2.3.13",
@@ -27,6 +29,7 @@ dependencies = [
"pyproj>=3.7.2", "pyproj>=3.7.2",
"python-docx>=1.2.0", "python-docx>=1.2.0",
"pyyaml>=6.0.3", "pyyaml>=6.0.3",
"qrcode[pil]>=8.2",
"rapidfuzz>=3.14.5", "rapidfuzz>=3.14.5",
"reportlab>=4.5.0", "reportlab>=4.5.0",
"scikit-image>=0.26.0", "scikit-image>=0.26.0",
+41
View File
@@ -900,7 +900,9 @@ dependencies = [
{ name = "google-cloud-storage" }, { name = "google-cloud-storage" },
{ name = "httpx" }, { name = "httpx" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "opencv-contrib-python-headless" },
{ name = "openpyxl" }, { name = "openpyxl" },
{ name = "pillow" },
{ name = "polars" }, { name = "polars" },
{ name = "pymeshlab" }, { name = "pymeshlab" },
{ name = "pymssql" }, { name = "pymssql" },
@@ -908,6 +910,7 @@ dependencies = [
{ name = "pyproj" }, { name = "pyproj" },
{ name = "python-docx" }, { name = "python-docx" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "qrcode", extra = ["pil"] },
{ name = "rapidfuzz" }, { name = "rapidfuzz" },
{ name = "reportlab" }, { name = "reportlab" },
{ name = "scikit-image" }, { name = "scikit-image" },
@@ -956,7 +959,9 @@ requires-dist = [
{ name = "jupyter-mcp-server", marker = "extra == 'jupyter'" }, { name = "jupyter-mcp-server", marker = "extra == 'jupyter'" },
{ name = "jupyterlab", marker = "extra == 'jupyter'", specifier = ">=4.0" }, { name = "jupyterlab", marker = "extra == 'jupyter'", specifier = ">=4.0" },
{ name = "matplotlib", specifier = ">=3.10.9" }, { name = "matplotlib", specifier = ">=3.10.9" },
{ name = "opencv-contrib-python-headless", specifier = ">=4.13.0.92" },
{ name = "openpyxl", specifier = ">=3.1.5" }, { name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "polars", specifier = ">=1.40.1" }, { name = "polars", specifier = ">=1.40.1" },
{ name = "pymeshlab", specifier = ">=2025.7.post1" }, { name = "pymeshlab", specifier = ">=2025.7.post1" },
{ name = "pymssql", specifier = ">=2.3.13" }, { name = "pymssql", specifier = ">=2.3.13" },
@@ -964,6 +969,7 @@ requires-dist = [
{ name = "pyproj", specifier = ">=3.7.2" }, { name = "pyproj", specifier = ">=3.7.2" },
{ name = "python-docx", specifier = ">=1.2.0" }, { name = "python-docx", specifier = ">=1.2.0" },
{ name = "pyyaml", specifier = ">=6.0.3" }, { name = "pyyaml", specifier = ">=6.0.3" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
{ name = "rapidfuzz", specifier = ">=3.14.5" }, { name = "rapidfuzz", specifier = ">=3.14.5" },
{ name = "reportlab", specifier = ">=4.5.0" }, { name = "reportlab", specifier = ">=4.5.0" },
{ name = "scikit-image", specifier = ">=0.26.0" }, { name = "scikit-image", specifier = ">=0.26.0" },
@@ -2945,6 +2951,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/4e/1c9df57496409dc86b320bd38f29ad7a34b7115e4f35b8fca44a827568a7/onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e79fd5ce7db10ebcc24e020e2ed0159476e69e2326b9b7828e5aadcf6184212", size = 18021249, upload-time = "2026-04-27T22:00:18.954Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/1c9df57496409dc86b320bd38f29ad7a34b7115e4f35b8fca44a827568a7/onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e79fd5ce7db10ebcc24e020e2ed0159476e69e2326b9b7828e5aadcf6184212", size = 18021249, upload-time = "2026-04-27T22:00:18.954Z" },
] ]
[[package]]
name = "opencv-contrib-python-headless"
version = "4.13.0.92"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/b5/9af5b81d9279e9982e21dad52f8a6aec10f7c891ae1e3d3d1b3ce111f8e7/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:b0467988c2d56c283b00fb808e0b57f5db2e3ca7743164a3b3efc733bfa03d3a", size = 52041681, upload-time = "2026-02-05T07:01:39.651Z" },
{ url = "https://files.pythonhosted.org/packages/c5/42/f0aef27baf1f376007b018b00f6c304c42c20d31aa8491633c53b18912cb/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:79e503b77880d806a1b106ff8182c6f898347ccfd1db58ffc9a6369acc236c4c", size = 38830456, upload-time = "2026-02-05T07:01:56.47Z" },
{ url = "https://files.pythonhosted.org/packages/14/84/e6b3568f9147b4f114e881fb0e733fd97bdca15452feba78b510351584d1/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:449c1f00a685a3a7dff8d6fa93a70fbfe0de5537c24358ea03a1d996d12b33e8", size = 39355323, upload-time = "2026-02-05T10:17:31.671Z" },
{ url = "https://files.pythonhosted.org/packages/6e/09/89714580c617cf6e9f66eed9137759fc017ab6ab093c2a03227e8ee19578/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c9b028adc04f6579f37227eb1d648bead14fd6fefc58da86df37c8320351f7bd", size = 62147375, upload-time = "2026-02-05T10:20:03.076Z" },
{ url = "https://files.pythonhosted.org/packages/6f/29/abdd2ff2f8f07e9aa37c70edc9987b8aa63730ae70957c378f6f2e9d72d2/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:cdd974b34801f24735d18b1057cfaab1698d5cb02c9bba01dab7dc47201f2ef6", size = 40840722, upload-time = "2026-02-05T10:21:27.877Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0a/3194fdf035ef5123bd8cc3e3ad1a96c1ddeeedd0fdd12aaa0d2cfeb1649a/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9e26469baed9069f627ea56fa46819690c4545580362071dd09f1dcf47a40f2f", size = 66610130, upload-time = "2026-02-05T10:23:32.427Z" },
{ url = "https://files.pythonhosted.org/packages/ca/4b/afe9b43c02b86b675a3d3ac6fc220473a88016e1acb487f5138efd2d2630/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:696a6dd84d309a499efc63644e375f035447b1da777faa2954f2dea7626cc0e7", size = 36708602, upload-time = "2026-02-05T07:02:36.041Z" },
{ url = "https://files.pythonhosted.org/packages/23/22/9fdc70520eb915b46d816f9cc5415458b1bd114a65d7a7e657cbd9b863e5/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:dcbb12d04ae74f5dcd782e3b166e1894c6fbdfaaf30866588746205d2a0cde5a", size = 46345416, upload-time = "2026-02-05T07:02:33.446Z" },
]
[[package]] [[package]]
name = "openpyxl" name = "openpyxl"
version = "3.1.5" version = "3.1.5"
@@ -4020,6 +4044,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
] ]
[[package]]
name = "qrcode"
version = "8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[package.optional-dependencies]
pil = [
{ name = "pillow" },
]
[[package]] [[package]]
name = "rapidfuzz" name = "rapidfuzz"
version = "3.14.5" version = "3.14.5"