Froxy75/halftone-parametric
0
1"""2Parametric CNC3==============4Generador de arte paramétrico (Halftone) para máquinas Router CNC y Láser.5 6Flujo:7 1. El usuario sube una imagen.8 2. Se rota (0-315°), se recorta/ajusta a la proporción del formato físico y9 se convierte a escala de grises (con Brillo/Contraste).10 3. El "Motor Matemático" mapea el valor de cada píxel (0-255) a un tamaño11 físico real en milímetros (radio de puntos o grosor de líneas).12 4. Se genera un Gemelo Digital (preview) con reglas en mm y se exporta a DXF13 (geometría a escala real) y PDF (hoja visual para el operador CNC).14 15Autor: Parametric CNC16"""17 18from __future__ import annotations19 20import base6421import hashlib22import io23import json24import os25import time26from dataclasses import dataclass, replace27from pathlib import Path28from typing import Dict, List, Optional, Tuple29 30import cv231import ezdxf32import matplotlib33import numpy as np34import streamlit as st35import streamlit.components.v1 as components36from matplotlib import pyplot as plt37from matplotlib.backends.backend_pdf import PdfPages38from matplotlib.collections import (39 EllipseCollection,40 LineCollection,41 PatchCollection,42)43from matplotlib.patches import Polygon44from PIL import Image, ImageOps45 46try:47 import pillow_heif48 pillow_heif.register_heif_opener()49except Exception: # noqa: BLE00150 pass51 52matplotlib.use("Agg") # backend sin ventana, apto para servidor/Streamlit53 54_CROP_COMPONENT_DIR = Path(__file__).resolve().parent / "crop_component" / "frontend"55_crop_overlay_func = components.declare_component(56 "halftone_crop", path=str(_CROP_COMPONENT_DIR)57)58 59 60# =============================================================================61# 1. CONFIGURACIÓN Y CONSTANTES62# =============================================================================63 64# Formatos físicos disponibles -> (ancho_mm, alto_mm)65FORMATOS: Dict[str, Tuple[float, float]] = {66 "35 x 35 cm": (350.0, 350.0),67 "50 x 50 cm": (500.0, 500.0),68 "50 x 60 cm": (500.0, 600.0),69 "60 x 60 cm": (600.0, 600.0),70 "60 x 70 cm": (600.0, 700.0),71}72FORMATO_LIBRE = "Formato libre"73 74# Persistencia local: imagen + ajustes sobreviven al refresh del navegador.75PERSIST_DIR = Path(__file__).resolve().parent / ".streamlit" / "persist"76PERSIST_IMG = PERSIST_DIR / "image.png"77PERSIST_STATE = PERSIST_DIR / "state.json"78 79# Claves de widgets/estado que se guardan en disco.80_PERSIST_KEYS = (81 "sel_formato_fijo", "tg_formato_libre", "sel_patron",82 "tg_relleno", "tg_fondo", "tg_limpia",83 "sl_angulo", "sl_brillo", "sl_contraste", "sl_nitidez",84 "sl_tam_min", "sl_tam_max", "sl_espaciado", "tg_cuantizar", "txt_brocas",85 "sel_material", "sl_profundidad", "tg_invertir",86 "libre_unidad", "libre_total_w", "libre_total_h", "libre_sec_w", "libre_sec_h",87 "sel_modo", "img_name", "crop_box", "crop_id",88 "sl_zoom", "sl_cx", "sl_cy", "tg_preview_tiempo_real",89)90 91# Respaldo en JSON si falla escribir image.png (bytes crudos, sin base64).92_MAX_IMG_B64 = 4_000_00093# Tamaño máximo al subir (píxeles del lado mayor; fotos de móvil suelen ser enormes).94_MAX_PX_SUBIDA = 320095# En móvil/túnel: comprimir más (menos peso en WebSocket y Cloudflare).96_MAX_PX_SUBIDA_MOVIL = 120097 98 99def _en_huggingface_space() -> bool:100 """True en Hugging Face Spaces (sin persistencia en disco compartida)."""101 return bool(os.getenv("SPACE_ID"))102 103# Patrones CNC soportados104PATRON_CIRCULOS = "Círculos (Puntos)"105PATRON_ESPIRAL = "Círculos concéntricos (líneas continuas)"106PATRON_VERTICAL = "Líneas Verticales"107PATRON_HORIZONTAL = "Líneas Horizontales"108PATRON_ONDULADO = "Líneas Onduladas (Op-Art)"109 110PATRONES: List[str] = [111 PATRON_CIRCULOS,112 PATRON_ESPIRAL,113 PATRON_VERTICAL,114 PATRON_HORIZONTAL,115 PATRON_ONDULADO,116]117 118# Resolución de trabajo interna (lado mayor en px) para muestrear la imagen.119RESOLUCION_TRABAJO = 1200120 121# Estadísticas aproximadas por material/máquina para estimar el tiempo de CNC.122# Velocidades en mm/min; 'overhead' en segundos por perforación (posicionar,123# arranque de husillo/pecking, etc.). Son valores promedio orientativos.124MATERIALES: Dict[str, Dict[str, float]] = {125 "Madera-Aglomerado (Router)": {"rapido": 8000, "plunge": 1200, "corte": 2500, "overhead": 0.25},126 "Acrílico (Router)": {"rapido": 6000, "plunge": 700, "corte": 1500, "overhead": 0.30},127 "Aluminio (Metal)": {"rapido": 4000, "plunge": 200, "corte": 600, "overhead": 0.60},128 "Acero (Metal)": {"rapido": 3000, "plunge": 90, "corte": 250, "overhead": 1.00},129 "Láser (corte)": {"rapido": 20000, "plunge": 0, "corte": 900, "overhead": 0.05},130}131 132 133@dataclass134class ParametrosCNC:135 """Agrupa todos los parámetros que definen la generación de geometría."""136 137 ancho_mm: float138 alto_mm: float139 patron: str140 tam_min_mm: float # radio o grosor mínimo141 tam_max_mm: float # radio o grosor máximo142 espaciado_mm: float # separación entre puntos/líneas (detalle)143 invertir: bool144 cuantizar: bool = False # ajustar círculos a brocas disponibles145 brocas_mm: Tuple[float, ...] = () # diámetros de broca disponibles (mm)146 relleno: bool = False # solo patrones no circulares; círculos = aros147 fondo_oscuro: bool = False # vista material oscuro (puntos claros)148 mostrar_reglas: bool = True # reglas mm + cuadrícula (vs vista limpia)149 seccion_ancho_mm: float = 0.0 # 0 = sin secciones (formato libre off)150 seccion_alto_mm: float = 0.0151 152 153@dataclass154class Geometria:155 """Contenedor genérico de primitivas en milímetros (origen abajo-izq)."""156 157 circulos: List[Tuple[float, float, float]] # (cx, cy, r)158 poligonos: List[List[Tuple[float, float]]] # contornos cerrados (relleno)159 trazos: List[List[Tuple[float, float]]] # polilíneas abiertas160 grosor_trazo_mm: float = 0.8 # grosor de dibujo de trazos161 162 163# =============================================================================164# 1b. PERSISTENCIA LOCAL (sobrevive al refresh del navegador)165# =============================================================================166 167def _init_persist_dir() -> None:168 PERSIST_DIR.mkdir(parents=True, exist_ok=True)169 170 171def _fingerprint_imagen(img_bytes: bytes) -> str:172 """Huella estable de la imagen (sobrevive al refresh; hash() de Python no)."""173 return hashlib.sha256(img_bytes).hexdigest()174 175 176def _normalizar_estado_persistido() -> None:177 """Ajusta sliders guardados a los rangos actuales de los widgets."""178 usar_libre = bool(st.session_state.get("tg_formato_libre", False))179 _clamp_slider_cnc(usar_libre)180 limites = (181 ("sl_brillo", -100, 100),182 ("sl_contraste", 0.5, 3.0),183 ("sl_nitidez", 0.0, 3.0),184 ("sl_profundidad", 0.5, 15.0),185 ("sl_zoom", 1.0, 30.0),186 )187 for clave, lo, hi in limites:188 if clave in st.session_state:189 val = float(min(max(float(st.session_state[clave]), lo), hi))190 st.session_state[clave] = int(round(val)) if clave == "sl_brillo" else val191 angulos = (0, 45, 90, 135, 180, 225, 270, 315)192 if "sl_angulo" in st.session_state:193 a = int(st.session_state["sl_angulo"])194 st.session_state["sl_angulo"] = min(angulos, key=lambda x: abs(x - a))195 if st.session_state.get("sel_patron") not in PATRONES:196 st.session_state["sel_patron"] = PATRON_CIRCULOS197 if st.session_state.get("sel_material") not in MATERIALES:198 st.session_state["sel_material"] = list(MATERIALES.keys())[0]199 200 201def _persistir_imagen_bytes(img: bytes) -> None:202 """Guarda la imagen en disco (solo uso local en PC)."""203 if _en_huggingface_space():204 return205 """Escribe la imagen en disco (sin tocar el JSON de parámetros)."""206 if _es_dispositivo_movil():207 return208 _init_persist_dir()209 try:210 PERSIST_IMG.write_bytes(img)211 except Exception: # noqa: BLE001212 pass213 214 215def _normalizar_bytes_imagen(raw: bytes, nombre: str) -> Tuple[bytes, str]:216 """Convierte la subida a JPEG RGB (EXIF, HEIC/WebP/JPG). Reduce fotos de móvil."""217 movil = _es_dispositivo_movil()218 max_px = _MAX_PX_SUBIDA_MOVIL if movil else _MAX_PX_SUBIDA219 calidad = 82 if movil else 90220 pil = ImageOps.exif_transpose(Image.open(io.BytesIO(raw))).convert("RGB")221 ancho, alto = pil.size222 if max(ancho, alto) > max_px:223 escala = max_px / max(ancho, alto)224 pil = pil.resize(225 (max(1, int(ancho * escala)), max(1, int(alto * escala))),226 Image.Resampling.LANCZOS,227 )228 base = nombre.rsplit(".", 1)[0] if "." in nombre else nombre229 buf = io.BytesIO()230 pil.save(buf, format="JPEG", quality=calidad, optimize=True)231 return buf.getvalue(), f"{base}.jpg"232 233 234def _es_dispositivo_movil() -> bool:235 """Detecta celular/tablet (no restaurar imagen del PC en esos clientes)."""236 try:237 ua = (st.context.headers.get("User-Agent", "") or "").lower()238 except Exception: # noqa: BLE001239 ua = ""240 return any(t in ua for t in ("mobile", "android", "iphone", "ipad", "ipod"))241 242 243def _resolucion_trabajo() -> int:244 """Menor resolución en móvil para que la vista previa cargue antes."""245 return 800 if _es_dispositivo_movil() else RESOLUCION_TRABAJO246 247 248def _marcar_preview_cargando() -> None:249 """Activa el reloj de arena en la vista previa (formato, efecto, sliders…)."""250 st.session_state["_preview_cargando"] = True251 252 253def _solicitar_regenerar_preview() -> None:254 """Pide vista previa sin tocar _preview_fase (evita bucle infinito de reruns)."""255 if st.session_state.get("_preview_fase") == "calculando":256 return257 _marcar_preview_cargando()258 259 260def _leer_bytes_subida(fuente) -> Tuple[bytes, str]:261 """Lee bytes del UploadedFile (una sola pasada; no re-leer en cada rerun)."""262 if fuente is None:263 return b"", "imagen.jpg"264 nombre = getattr(fuente, "name", None) or "imagen.jpg"265 movil = _es_dispositivo_movil()266 lectores = []267 if movil:268 # En móvil getvalue() suele devolver vacío hasta que el widget se instancia.269 lectores.extend([270 lambda: _leer_bytes_seek(fuente),271 lambda: bytes(fuente) if hasattr(fuente, "__bytes__") else None,272 ])273 lectores.extend([274 lambda: fuente.getvalue() if hasattr(fuente, "getvalue") else None,275 lambda: fuente.getbuffer().tobytes() if hasattr(fuente, "getbuffer") else None,276 lambda: _leer_bytes_seek(fuente),277 ])278 for lector in lectores:279 try:280 raw = lector()281 if raw:282 return bytes(raw), nombre283 except Exception: # noqa: BLE001284 continue285 return b"", nombre286 287 288def _leer_bytes_seek(fuente) -> bytes:289 """Lee con seek/read (fallback para algunos navegadores móviles)."""290 if not hasattr(fuente, "seek") or not hasattr(fuente, "read"):291 return b""292 pos = fuente.tell() if hasattr(fuente, "tell") else 0293 fuente.seek(0)294 chunks: List[bytes] = []295 while True:296 bloque = fuente.read(1024 * 1024)297 if not bloque:298 break299 chunks.append(bloque if isinstance(bloque, bytes) else bytes(bloque))300 fuente.seek(pos)301 return b"".join(chunks)302 303 304def _id_subida(fuente, raw: bytes = b"") -> str:305 """Identificador estable del archivo elegido en el uploader."""306 nombre = getattr(fuente, "name", "") or "imagen"307 size = getattr(fuente, "size", None)308 if size is not None:309 return f"{nombre}|{size}"310 if raw:311 return f"{nombre}|{hashlib.md5(raw[:8192]).hexdigest()[:12]}"312 return f"{nombre}|?"313 314 315def _clave_uploader() -> str:316 """Clave del widget file_uploader (se reinicia al quitar la imagen)."""317 reset = st.session_state.get("_uploader_reset", 0)318 return "uploader_imagen" if reset == 0 else f"uploader_imagen_{reset}"319 320 321def _archivo_del_uploader():322 """Archivo subido: en móvil suele estar solo en session_state, no en el return."""323 return st.session_state.get(_clave_uploader())324 325 326def _on_subida_lista() -> None:327 """Callback: procesa en cuanto el usuario elige foto (crítico en móvil).328 329 Nunca borra la imagen si el widget queda vacío (rerun normal de Streamlit).330 """331 fuente = _archivo_del_uploader()332 if fuente is None:333 return334 raw, nombre = _leer_bytes_subida(fuente)335 if raw:336 st.session_state["_upload_raw_cache"] = raw337 st.session_state["_upload_raw_name"] = nombre338 _procesar_archivo_subido(fuente, raw=raw, nombre=nombre)339 340 341def _guardar_imagen_procesada(raw: bytes, nombre: str) -> bool:342 """Normaliza bytes crudos y los deja listos en session_state."""343 if not raw:344 return "img_bytes" in st.session_state345 try:346 norm, nombre_ok = _normalizar_bytes_imagen(raw, nombre)347 except Exception as exc: # noqa: BLE001348 st.error(f"No se pudo leer la imagen: {exc}")349 return False350 huella = _fingerprint_imagen(norm)351 if huella == st.session_state.get("_upload_fp") and st.session_state.get("img_bytes"):352 st.session_state["upload_ui_loaded"] = True353 if st.session_state.get("_clave_preview_run") is None:354 _solicitar_regenerar_preview()355 return True356 st.session_state["img_bytes"] = norm357 st.session_state["img_name"] = nombre_ok358 st.session_state["_upload_fp"] = huella359 st.session_state.pop("crop_box", None)360 st.session_state.pop("crop_id", None)361 st.session_state.pop("_clave_preview_run", None)362 st.session_state.pop("_preview_fase", None)363 _marcar_preview_cargando()364 _persistir_imagen_bytes(norm)365 st.session_state["upload_ui_loaded"] = True366 return True367 368 369def _miniatura_jpeg_b64(img_bytes: bytes, max_px: int = 96) -> str:370 """Miniatura en base64 para el cuadro visual de subida."""371 pil = ImageOps.exif_transpose(Image.open(io.BytesIO(img_bytes))).convert("RGB")372 pil.thumbnail((max_px, max_px))373 buf = io.BytesIO()374 pil.save(buf, format="JPEG", quality=88)375 return base64.b64encode(buf.getvalue()).decode()376 377 378def _html_cuadro_upload_cargado(379 img_bytes: bytes, nombre: str, pulso: bool = False380) -> str:381 """Cuadrito relleno que indica imagen cargada (cache, persistencia o recarga)."""382 import html as html_mod383 b64 = _miniatura_jpeg_b64(img_bytes)384 nombre_seg = html_mod.escape(nombre)385 clase = "upload-cuadro-cargado"386 if pulso:387 clase += " upload-cuadro-pulso"388 return f"""389 <div class="{clase}">390 <img src="data:image/jpeg;base64,{b64}" alt="Imagen cargada">391 <div class="upload-cuadro-texto">392 <strong>{nombre_seg}</strong>393 <span>Imagen cargada</span>394 </div>395 </div>396 """397 398 399def _aplicar_subida_desde_uploader() -> bool:400 """Procesa uploader en session_state (botón «Cargar imagen» en móvil)."""401 fuente = st.session_state.get(_clave_uploader())402 if fuente is None:403 st.warning("Primero elige una foto de la galería.")404 return False405 raw, nombre = _leer_bytes_subida(fuente)406 if not raw:407 tam = getattr(fuente, "size", None)408 det = f" (tamaño: {tam} bytes)" if tam else ""409 st.error(410 f"No se pudo leer la foto{det}. Prueba **JPG**, **PNG** o **HEIC**, o abre la app "411 "por **WiFi** con la Network URL del PC (sin túnel)."412 )413 return False414 st.session_state["_upload_raw_cache"] = raw415 st.session_state["_upload_raw_name"] = nombre416 ok = _procesar_archivo_subido(fuente, raw=raw, nombre=nombre)417 if ok:418 st.session_state.pop("_upload_pendiente", None)419 return ok420 421 422def _procesar_subida_tras_uploader(fuente) -> None:423 """Procesa subida en PC tras instanciar file_uploader (subida instantánea)."""424 if _es_dispositivo_movil():425 return426 if fuente is not None:427 _procesar_archivo_subido(fuente)428 elif st.session_state.get("_upload_raw_cache") and "img_bytes" not in st.session_state:429 _guardar_imagen_procesada(430 st.session_state["_upload_raw_cache"],431 st.session_state.get("_upload_raw_name", "imagen.jpg"),432 )433 434 435def _procesar_archivo_subido(436 fuente,437 raw: bytes = b"",438 nombre: str = "",439) -> bool:440 """Procesa un archivo nuevo del uploader (solo si cambió la selección)."""441 if fuente is None:442 cache = st.session_state.get("_upload_raw_cache")443 if cache and "img_bytes" not in st.session_state:444 nom = st.session_state.get("_upload_raw_name", "imagen.jpg")445 return _guardar_imagen_procesada(cache, nom)446 return "img_bytes" in st.session_state447 if not raw:448 raw, nombre = _leer_bytes_subida(fuente)449 if not raw:450 raw = st.session_state.get("_upload_raw_cache", b"")451 nombre = nombre or st.session_state.get("_upload_raw_name", "imagen.jpg")452 if not raw:453 return "img_bytes" in st.session_state454 st.session_state["_upload_raw_cache"] = raw455 st.session_state["_upload_raw_name"] = nombre or "imagen.jpg"456 uid = _id_subida(fuente, raw)457 if uid == st.session_state.get("_upload_uid") and st.session_state.get("img_bytes"):458 if st.session_state.get("_clave_preview_run") is None:459 _solicitar_regenerar_preview()460 return True461 if not _guardar_imagen_procesada(raw, nombre or "imagen.jpg"):462 return False463 st.session_state["_upload_uid"] = uid464 return True465 466 467def _forzar_recarga_foto(fuente=None) -> None:468 """Relee la foto del uploader o sesión y fuerza la regeneración del halftone."""469 st.session_state.pop("_upload_uid", None)470 st.session_state.pop("_upload_fp", None)471 st.session_state.pop("_clave_preview_run", None)472 st.session_state.pop("_preview_fase", None)473 st.session_state.pop("_preview_intentos", None)474 st.session_state.pop("crop_box", None)475 st.session_state.pop("crop_id", None)476 _limpiar_caches_preview()477 raw = b""478 nombre = st.session_state.get("img_name", "imagen.jpg")479 if fuente is not None:480 raw, nombre = _leer_bytes_subida(fuente)481 if not raw:482 raw = st.session_state.get("_upload_raw_cache", b"")483 nombre = st.session_state.get("_upload_raw_name", nombre)484 if not raw and st.session_state.get("img_bytes"):485 raw = st.session_state["img_bytes"]486 if raw:487 _guardar_imagen_procesada(raw, nombre)488 if fuente is not None:489 st.session_state["_upload_uid"] = _id_subida(fuente, raw)490 st.session_state["upload_ui_loaded"] = True491 st.session_state["upload_ui_pulse"] = time.time_ns()492 493 494def _seccion_subida_imagen() -> bool:495 """Controles de subida de imagen."""496 movil = _es_dispositivo_movil()497 clave_up = _clave_uploader()498 tiene = "img_bytes" in st.session_state499 with st.container(border=True):500 col_up, col_btn = st.columns(501 [4, 1.35], gap="small", vertical_alignment="center"502 )503 with col_up:504 st.markdown(505 """506 <style>507 [class*="st-key-uploader_imagen"] [data-testid="stFileUploaderDropzoneInstructions"],508 [class*="st-key-uploader_imagen"] [data-testid="stFileDropzoneInstructions"] {509 display: none !important;510 visibility: hidden !important;511 height: 0 !important;512 max-height: 0 !important;513 overflow: hidden !important;514 margin: 0 !important;515 padding: 0 !important;516 }517 </style>518 """,519 unsafe_allow_html=True,520 )521 fuente = None522 if tiene:523 col_thumb, col_x = st.columns(524 [5.5, 1], gap="small", vertical_alignment="center"525 )526 with col_thumb:527 pulso = st.session_state.pop("upload_ui_pulse", None) is not None528 st.markdown(529 _html_cuadro_upload_cargado(530 st.session_state["img_bytes"],531 st.session_state.get("img_name", "imagen.jpg"),532 pulso=pulso,533 ),534 unsafe_allow_html=True,535 )536 with col_x:537 if st.button(538 "✕",539 key="btn_quitar_imagen",540 help="Quitar imagen y elegir otra",541 ):542 _quitar_imagen_cargada()543 st.rerun()544 if not movil:545 fuente = st.file_uploader(546 "Sube tu imagen",547 type=["jpg", "jpeg", "png", "webp", "heic", "heif", "bmp"],548 key=clave_up,549 on_change=_on_subida_lista,550 label_visibility="collapsed",551 )552 _procesar_subida_tras_uploader(fuente)553 fuente = st.session_state.get(clave_up)554 elif movil:555 with st.form("form_subida_movil", clear_on_submit=False):556 st.file_uploader(557 "Elige tu foto",558 type=["jpg", "jpeg", "png", "webp", "heic", "heif"],559 key=clave_up,560 label_visibility="visible",561 )562 st.caption(563 "1. Toca arriba y elige una foto (JPG, PNG o HEIC). "564 "2. Espera a ver el **nombre del archivo**. "565 "3. Pulsa **Cargar imagen**."566 )567 enviado = st.form_submit_button(568 "Cargar imagen",569 type="primary",570 width="stretch",571 )572 if enviado:573 _aplicar_subida_desde_uploader()574 fuente = st.session_state.get(clave_up)575 else:576 fuente = st.file_uploader(577 "Sube tu imagen",578 type=["jpg", "jpeg", "png", "webp", "heic", "heif", "bmp"],579 key=clave_up,580 on_change=_on_subida_lista,581 label_visibility="collapsed",582 )583 _procesar_subida_tras_uploader(fuente)584 archivo = fuente if fuente is not None else _archivo_del_uploader()585 if archivo is not None and "img_bytes" not in st.session_state:586 st.warning(587 "No se pudo leer la imagen. Prueba JPG/PNG/HEIC o pulsa **Recargar imagen**."588 )589 if tiene:590 st.session_state.setdefault("upload_ui_loaded", True)591 st.markdown(592 '<p class="uploader-hint">50MB archivos JPG, PNG, HEIC</p>',593 unsafe_allow_html=True,594 )595 with col_btn:596 if tiene:597 if st.button(598 "Recargar imagen",599 key="btn_recargar_foto",600 type="secondary",601 help="Regenera la imagen cargada y el efecto halftone.",602 ):603 _forzar_recarga_foto(fuente)604 st.rerun()605 return "img_bytes" in st.session_state606 607 608def _restaurar_imagen_persistida(data: dict) -> None:609 """Carga img_bytes desde PNG en disco o respaldo base64 en state.json."""610 if "img_bytes" in st.session_state:611 return612 if PERSIST_IMG.exists():613 try:614 st.session_state["img_bytes"] = PERSIST_IMG.read_bytes()615 except Exception: # noqa: BLE001616 pass617 if "img_bytes" not in st.session_state and data.get("img_b64"):618 try:619 st.session_state["img_bytes"] = base64.b64decode(data["img_b64"])620 except Exception: # noqa: BLE001621 pass622 if "img_bytes" in st.session_state:623 st.session_state.setdefault("img_name", data.get("img_name", "imagen.png"))624 fp = _fingerprint_imagen(st.session_state["img_bytes"])625 if st.session_state.get("crop_box"):626 st.session_state["crop_id"] = fp627 if st.session_state.get("_clave_preview_run") is None:628 _solicitar_regenerar_preview()629 if not PERSIST_IMG.exists():630 try:631 PERSIST_IMG.write_bytes(st.session_state["img_bytes"])632 except Exception: # noqa: BLE001633 pass634 st.session_state["upload_ui_loaded"] = True635 636 637def cargar_persistencia() -> None:638 """Restaura imagen y ajustes desde disco al iniciar la app."""639 if _en_huggingface_space():640 st.session_state.setdefault("tg_preview_tiempo_real", True)641 if "img_bytes" not in st.session_state and st.session_state.get("_upload_raw_cache"):642 nom = st.session_state.get("_upload_raw_name", "imagen.jpg")643 try:644 norm, nombre_ok = _normalizar_bytes_imagen(645 st.session_state["_upload_raw_cache"], nom646 )647 st.session_state["img_bytes"] = norm648 st.session_state["img_name"] = nombre_ok649 _solicitar_regenerar_preview()650 st.session_state["upload_ui_loaded"] = True651 except Exception: # noqa: BLE001652 pass653 return654 _init_persist_dir()655 data: dict = {}656 if PERSIST_STATE.exists():657 try:658 with PERSIST_STATE.open(encoding="utf-8") as f:659 data = json.load(f)660 except Exception: # noqa: BLE001661 data = {}662 663 if data:664 try:665 for clave in _PERSIST_KEYS:666 if clave in data and clave not in st.session_state:667 st.session_state[clave] = data[clave]668 # Compatibilidad con estado guardado antes de separar formatos.669 if "sel_formato" in data and "sel_formato_fijo" not in st.session_state:670 prev = data["sel_formato"]671 if prev == FORMATO_LIBRE:672 st.session_state["tg_formato_libre"] = True673 elif prev in FORMATOS:674 st.session_state["sel_formato_fijo"] = prev675 # Valores antiguos guardados en metros → convertir a centímetros.676 if data.get("libre_unidad") == "metros":677 for clave in ("libre_total_w", "libre_total_h", "libre_sec_w", "libre_sec_h"):678 if clave in st.session_state and st.session_state[clave] < 50:679 st.session_state[clave] = int(round(st.session_state[clave] * 100))680 if "_clave_preview_run" in data and not _es_dispositivo_movil():681 st.session_state["_clave_preview_run"] = data["_clave_preview_run"]682 _normalizar_estado_persistido()683 # No sobrescribir sliders guardados: marcar preset ya aplicado.684 if "sel_patron" in st.session_state and "_preset_clave" not in st.session_state:685 pp = st.session_state["sel_patron"]686 if st.session_state.get("tg_formato_libre"):687 tw = float(st.session_state.get("libre_total_w", 500.0))688 th = float(st.session_state.get("libre_total_h", 200.0))689 sw = float(st.session_state.get("libre_sec_w", 100.0))690 sh = float(st.session_state.get("libre_sec_h", 100.0))691 st.session_state["_preset_clave"] = _clave_preset_libre(tw, th, sw, sh, pp)692 elif "sel_formato_fijo" in st.session_state:693 ff = st.session_state["sel_formato_fijo"]694 st.session_state["_preset_clave"] = _clave_preset_fijo(ff, pp)695 except Exception: # noqa: BLE001696 pass697 698 # Imagen: en PC restaurar desde disco; en móvil usar respaldo de sesión propia.699 if not _es_dispositivo_movil():700 _restaurar_imagen_persistida(data)701 else:702 for clave in ("crop_box", "crop_id", "_clave_preview_run"):703 st.session_state.pop(clave, None)704 # En móvil siempre vista previa en tiempo real (evita modo manual del PC).705 st.session_state["tg_preview_tiempo_real"] = True706 if "img_bytes" not in st.session_state and st.session_state.get("_upload_raw_cache"):707 nom = st.session_state.get("_upload_raw_name", "imagen.jpg")708 try:709 norm, nombre_ok = _normalizar_bytes_imagen(710 st.session_state["_upload_raw_cache"], nom711 )712 st.session_state["img_bytes"] = norm713 st.session_state["img_name"] = nombre_ok714 _solicitar_regenerar_preview()715 st.session_state["upload_ui_loaded"] = True716 except Exception: # noqa: BLE001717 pass718 719 720def guardar_persistencia() -> None:721 """Guarda imagen y ajustes actuales en disco."""722 _init_persist_dir()723 data = {k: st.session_state[k] for k in _PERSIST_KEYS if k in st.session_state}724 if st.session_state.get("_clave_preview_run"):725 data["_clave_preview_run"] = st.session_state["_clave_preview_run"]726 727 img = st.session_state.get("img_bytes")728 img_en_disco = False729 if img:730 data["_img_fp"] = _fingerprint_imagen(img)731 try:732 PERSIST_IMG.write_bytes(img)733 img_en_disco = True734 except Exception: # noqa: BLE001735 pass736 # Respaldo en JSON para imágenes pequeñas (sobrevive si falla el PNG).737 if len(img) <= _MAX_IMG_B64:738 data["img_b64"] = base64.b64encode(img).decode("ascii")739 elif not img_en_disco:740 data["img_b64"] = base64.b64encode(img).decode("ascii")741 else:742 data.pop("img_b64", None)743 data.pop("_img_fp", None)744 745 try:746 with PERSIST_STATE.open("w", encoding="utf-8") as f:747 json.dump(data, f, ensure_ascii=False)748 except Exception: # noqa: BLE001749 pass750 751 752def _firma_persistencia() -> str:753 """Huella del estado persistible (evita reescribir disco en cada rerun)."""754 data = {k: st.session_state[k] for k in _PERSIST_KEYS if k in st.session_state}755 if st.session_state.get("_clave_preview_run"):756 data["_clave_preview_run"] = st.session_state["_clave_preview_run"]757 img = st.session_state.get("img_bytes")758 if img:759 data["_img_fp"] = _fingerprint_imagen(img)760 payload = json.dumps(data, sort_keys=True, ensure_ascii=False)761 return hashlib.sha256(payload.encode("utf-8")).hexdigest()762 763 764def guardar_persistencia_si_cambio() -> None:765 """Persiste solo si el estado cambió respecto al último guardado."""766 if _es_dispositivo_movil() or _en_huggingface_space():767 return768 firma = _firma_persistencia()769 if firma == st.session_state.get("_persist_firma"):770 return771 st.session_state["_persist_firma"] = firma772 guardar_persistencia()773 774 775def limpiar_imagen_persistida() -> None:776 """Borra imagen y recorte guardados en disco y sesión."""777 for clave in (778 "img_bytes", "img_name", "crop_box", "crop_id", "exp_sig",779 "_upload_uid", "_upload_fp", "_clave_preview_run",780 "upload_ui_loaded", "upload_ui_pulse",781 ):782 st.session_state.pop(clave, None)783 for clave in ("exp_dxf", "exp_pdf"):784 st.session_state.pop(clave, None)785 if PERSIST_IMG.exists():786 PERSIST_IMG.unlink(missing_ok=True)787 guardar_persistencia()788 789 790def _quitar_imagen_cargada() -> None:791 """Quita la imagen y reinicia el uploader para elegir otra foto."""792 clave_up = _clave_uploader()793 for clave in (794 "img_bytes", "img_name", "crop_box", "crop_id", "exp_sig",795 "_upload_uid", "_upload_fp", "_upload_raw_cache", "_upload_raw_name",796 "_upload_pendiente", "_clave_preview_run", "_preview_fase",797 "_preview_intentos", "_preview_cargando", "_preview_gris", "_preview_geom",798 "upload_ui_loaded", "upload_ui_pulse",799 clave_up, "uploader_imagen",800 ):801 st.session_state.pop(clave, None)802 for clave in ("exp_dxf", "exp_pdf"):803 st.session_state.pop(clave, None)804 _limpiar_caches_preview()805 if PERSIST_IMG.exists():806 PERSIST_IMG.unlink(missing_ok=True)807 try:808 _init_persist_dir()809 data: dict = {}810 if PERSIST_STATE.exists():811 with PERSIST_STATE.open(encoding="utf-8") as f:812 data = json.load(f)813 for k in ("img_b64", "_img_fp", "img_name"):814 data.pop(k, None)815 with PERSIST_STATE.open("w", encoding="utf-8") as f:816 json.dump(data, f, ensure_ascii=False)817 except Exception: # noqa: BLE001818 pass819 st.session_state["_uploader_reset"] = st.session_state.get("_uploader_reset", 0) + 1820 st.session_state["_persist_firma"] = _firma_persistencia()821 822 823def _a_mm(valor: float, unidad: str) -> float:824 """Convierte metros o centímetros a milímetros."""825 return float(valor) * (1000.0 if unidad == "metros" else 10.0)826 827 828def resolver_formato(829 formato: str,830 libre_unidad: str,831 libre_total_w: float,832 libre_total_h: float,833 libre_sec_w: float,834 libre_sec_h: float,835) -> Tuple[float, float, float, float, int, int]:836 """Devuelve (ancho_mm, alto_mm, sec_w_mm, sec_h_mm, cols, rows)."""837 if formato != FORMATO_LIBRE:838 w, h = FORMATOS[formato]839 return w, h, 0.0, 0.0, 1, 1840 841 ancho = _a_mm(libre_total_w, libre_unidad)842 alto = _a_mm(libre_total_h, libre_unidad)843 sec_w = _a_mm(libre_sec_w, libre_unidad)844 sec_h = _a_mm(libre_sec_h, libre_unidad)845 cols = max(1, int(ancho / sec_w))846 rows = max(1, int(alto / sec_h))847 return ancho, alto, sec_w, sec_h, cols, rows848 849 850def _escala_cnc(ancho_mm: float, alto_mm: float, cols: int = 1, rows: int = 1) -> float:851 """Escala de densidad CNC respecto a la referencia 35×35 cm."""852 escala = (min(ancho_mm, alto_mm) / 350.0) ** 0.5853 n = cols * rows854 if n > 6:855 escala *= (n / 6.0) ** 0.25856 return escala857 858 859def _sliders_cnc_por_patron(860 patron: str, escala: float, *, gran_formato: bool = False861) -> Dict[str, float]:862 """Tamaño mín/máx y separación recomendados según efecto y escala."""863 cap_esp = 50.0 if gran_formato else None864 cap_tmax = 50.0 if gran_formato else None865 866 if patron == PATRON_CIRCULOS:867 esp = round(max(2.0, min(2.5 * escala, cap_esp or 5.0)), 1)868 tmax = round(max(0.8, min(esp * 0.42, cap_tmax or 1.8 * escala)), 1)869 return {"sl_tam_min": 0.2, "sl_tam_max": tmax, "sl_espaciado": esp}870 if patron == PATRON_ESPIRAL:871 esp = round(max(4.0, min(6.0 * escala, cap_esp or 12.0)), 1)872 return {873 "sl_tam_min": 0.3,874 "sl_tam_max": round(min(2.0 * escala, cap_tmax or 2.0 * escala), 1),875 "sl_espaciado": esp,876 }877 if patron in (PATRON_VERTICAL, PATRON_HORIZONTAL):878 esp = round(max(2.5, min(3.5 * escala, cap_esp or 8.0)), 1)879 return {880 "sl_tam_min": 0.3,881 "sl_tam_max": round(min(2.5 * escala, cap_tmax or 2.5 * escala), 1),882 "sl_espaciado": esp,883 }884 if patron == PATRON_ONDULADO:885 esp = round(max(5.0, min(6.5 * escala, cap_esp or 14.0)), 1)886 return {887 "sl_tam_min": 0.5,888 "sl_tam_max": round(889 min(max(3.5, 4.5 * escala), cap_tmax or max(3.5, 4.5 * escala)), 1890 ),891 "sl_espaciado": esp,892 }893 esp = round(min(2.5 * escala, cap_esp or 2.5 * escala), 1)894 return {"sl_tam_min": 0.2, "sl_tam_max": min(2.0, cap_tmax or 2.0), "sl_espaciado": esp}895 896 897def _clave_preset_fijo(formato: str, patron: str) -> str:898 return f"fijo|{formato}|{patron}"899 900 901def _clave_preset_libre(902 libre_total_w: float,903 libre_total_h: float,904 libre_sec_w: float,905 libre_sec_h: float,906 patron: str,907) -> str:908 return f"libre|{libre_total_w}x{libre_total_h}|{libre_sec_w}x{libre_sec_h}|{patron}"909 910 911def preset_formato_fijo(formato: str, patron: str) -> Dict[str, object]:912 """Valores recomendados de sliders según formato fijo + efecto CNC."""913 ancho, alto = FORMATOS[formato]914 escala = _escala_cnc(ancho, alto)915 base_img = {"sl_brillo": 0, "sl_contraste": 1.35, "sl_nitidez": 0.75, "sl_angulo": 0}916 vista = {"tg_relleno": False, "tg_fondo": False, "tg_limpia": True}917 return {**base_img, **_sliders_cnc_por_patron(patron, escala), **vista}918 919 920def preset_formato_libre(921 libre_total_w: float,922 libre_total_h: float,923 libre_sec_w: float,924 libre_sec_h: float,925 patron: str,926 libre_unidad: str = "centímetros",927) -> Dict[str, object]:928 """Valores recomendados para gran formato (área total + secciones internas)."""929 ancho_mm = _a_mm(libre_total_w, libre_unidad)930 alto_mm = _a_mm(libre_total_h, libre_unidad)931 sec_w_mm = _a_mm(libre_sec_w, libre_unidad)932 sec_h_mm = _a_mm(libre_sec_h, libre_unidad)933 cols = max(1, int(ancho_mm / sec_w_mm))934 rows = max(1, int(alto_mm / sec_h_mm))935 escala = _escala_cnc(ancho_mm, alto_mm, cols, rows)936 base_img = {"sl_brillo": 0, "sl_contraste": 1.35, "sl_nitidez": 0.75, "sl_angulo": 0}937 vista = {"tg_relleno": False, "tg_fondo": False, "tg_limpia": True}938 return {**base_img, **_sliders_cnc_por_patron(patron, escala, gran_formato=True), **vista}939 940 941def aplicar_preset_fijo(formato: str, patron: str) -> None:942 """Escribe en session_state los sliders recomendados (formato fijo)."""943 for clave, valor in preset_formato_fijo(formato, patron).items():944 st.session_state[clave] = valor945 st.session_state["_preset_clave"] = _clave_preset_fijo(formato, patron)946 947 948def aplicar_preset_libre(949 libre_total_w: float,950 libre_total_h: float,951 libre_sec_w: float,952 libre_sec_h: float,953 patron: str,954 libre_unidad: str = "centímetros",955) -> None:956 """Escribe en session_state los sliders recomendados (formato libre)."""957 for clave, valor in preset_formato_libre(958 libre_total_w, libre_total_h, libre_sec_w, libre_sec_h, patron, libre_unidad959 ).items():960 st.session_state[clave] = valor961 st.session_state["_preset_clave"] = _clave_preset_libre(962 libre_total_w, libre_total_h, libre_sec_w, libre_sec_h, patron963 )964 965 966def _clamp_slider_cnc(usar_libre: bool) -> None:967 """Ajusta valores guardados al rango del modo fijo o libre."""968 if usar_libre:969 limites = (970 ("sl_tam_min", 0.0, 50.0),971 ("sl_tam_max", 0.0, 50.0),972 ("sl_espaciado", 0.0, 50.0),973 )974 else:975 limites = (976 ("sl_tam_min", 0.0, 10.0),977 ("sl_tam_max", 0.5, 30.0),978 ("sl_espaciado", 1.0, 20.0),979 )980 for clave, lo, hi in limites:981 if clave in st.session_state:982 st.session_state[clave] = float(min(max(float(st.session_state[clave]), lo), hi))983 984 985# =============================================================================986# 2. PROCESAMIENTO DE IMAGEN987# =============================================================================988 989def recortar_a_proporcion(img: np.ndarray, ratio_objetivo: float) -> np.ndarray:990 """Recorta (center-crop tipo 'cover') la imagen a la proporción indicada.991 992 ratio_objetivo = ancho / alto.993 """994 h, w = img.shape[:2]995 ratio_actual = w / h996 997 if ratio_actual > ratio_objetivo:998 # Imagen demasiado ancha -> recortar los lados999 nuevo_w = int(round(h * ratio_objetivo))1000 x0 = (w - nuevo_w) // 21001 recorte = img[:, x0 : x0 + nuevo_w]1002 else:1003 # Imagen demasiado alta -> recortar arriba/abajo1004 nuevo_h = int(round(w / ratio_objetivo))1005 y0 = (h - nuevo_h) // 21006 recorte = img[y0 : y0 + nuevo_h, :]1007 1008 return recorte1009 1010 1011def rotar_imagen(gris: np.ndarray, angulo: int) -> np.ndarray:1012 """Rota la imagen en múltiplos de 45°, expandiendo el lienzo si hace falta.1013 1014 Los múltiplos rectos (90/180/270) usan rotación exacta sin pérdida; los1015 ángulos diagonales rellenan las esquinas con blanco (255) para no generar1016 geometría espuria en el patrón halftone.1017 """1018 a = int(angulo) % 3601019 if a == 0:1020 return gris1021 if a == 90:1022 return cv2.rotate(gris, cv2.ROTATE_90_COUNTERCLOCKWISE)1023 if a == 180:1024 return cv2.rotate(gris, cv2.ROTATE_180)1025 if a == 270:1026 return cv2.rotate(gris, cv2.ROTATE_90_CLOCKWISE)1027 1028 h, w = gris.shape[:2]1029 centro = (w / 2.0, h / 2.0)1030 M = cv2.getRotationMatrix2D(centro, a, 1.0)1031 cos, sin = abs(M[0, 0]), abs(M[0, 1])1032 nw = int(h * sin + w * cos)1033 nh = int(h * cos + w * sin)1034 M[0, 2] += nw / 2.0 - centro[0]1035 M[1, 2] += nh / 2.0 - centro[1]1036 return cv2.warpAffine(1037 gris, M, (nw, nh), flags=cv2.INTER_LINEAR, borderValue=2551038 )1039 1040 1041def procesar_imagen(1042 pil_img: Image.Image,1043 ancho_mm: float,1044 alto_mm: float,1045 brillo: int,1046 contraste: float,1047 angulo: int = 0,1048 nitidez: float = 0.0,1049) -> np.ndarray:1050 """Convierte la imagen a escala de grises 8-bit ajustada al formato.1051 1052 Devuelve un array uint8 (H, W) ya rotado, recortado a la proporción física1053 y con Brillo/Contraste/Nitidez aplicados vía OpenCV.1054 """1055 # PIL -> numpy (RGB)1056 rgb = np.array(pil_img.convert("RGB"))1057 gris = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)1058 1059 # Rotación (antes del recorte para respetar la nueva orientación)1060 gris = rotar_imagen(gris, angulo)1061 1062 # Recorte a proporción física1063 gris = recortar_a_proporcion(gris, ancho_mm / alto_mm)1064 1065 # Redimensionar a resolución de trabajo (menor en móvil para cargar antes)1066 res = _resolucion_trabajo()1067 h, w = gris.shape1068 if w >= h:1069 nw = res1070 nh = max(1, int(round(res * h / w)))1071 else:1072 nh = res1073 nw = max(1, int(round(res * w / h)))1074 gris = cv2.resize(gris, (nw, nh), interpolation=cv2.INTER_AREA)1075 1076 # Brillo (beta) y Contraste (alpha): out = alpha*px + beta1077 gris = cv2.convertScaleAbs(gris, alpha=float(contraste), beta=float(brillo))1078 1079 # Nitidez (unsharp mask): realza bordes para mejorar la definición1080 if nitidez > 0:1081 desenfoque = cv2.GaussianBlur(gris, (0, 0), sigmaX=2.0)1082 gris = cv2.addWeighted(gris, 1.0 + nitidez, desenfoque, -nitidez, 0)1083 1084 return gris1085 1086 1087# =============================================================================1088# 3. MOTOR MATEMÁTICO: MAPEO PÍXEL -> TAMAÑO FÍSICO1089# =============================================================================1090 1091def _factor_tono(valores: np.ndarray, invertir: bool) -> np.ndarray:1092 """Convierte valores de gris (0-255) en un factor 0..1 de 'presencia'.1093 1094 Por defecto los píxeles OSCUROS producen el tamaño máximo (factor -> 1).1095 Con 'invertir' se intercambia (los CLAROS producen el máximo), útil para1096 alternar entre material de superficie oscura y superficie clara.1097 """1098 norm = valores.astype(np.float64) / 255.0 # 0=negro, 1=blanco1099 factor = norm if invertir else (1.0 - norm)1100 return np.clip(factor, 0.0, 1.0)1101 1102 1103def _invertir_efectivo(p: "ParametrosCNC") -> bool:1104 """Dirección real del mapeo tono -> tamaño, según el modo de vista.1105 1106 Modelo físico: el NEGRO es material y el BLANCO es marca/ruteado superficial.1107 - Vista normal (fondo claro, marcas oscuras = 'tinta'): las zonas OSCURAS1108 de la foto producen las marcas grandes.1109 - Vista material oscuro (fondo negro = material, marcas claras = grabado):1110 se invierte, de modo que las grandes superficies NEGRAS queden como1111 material y solo las zonas claras se conviertan en marcas visibles.1112 El toggle 'Invertir Contraste' sigue permitiendo alternar manualmente.1113 """1114 return bool(p.invertir) != bool(p.fondo_oscuro)1115 1116 1117def _muestrear(gris: np.ndarray, x_mm, y_mm, ancho_mm, alto_mm) -> np.ndarray:1118 """Muestrea el valor de gris en coordenadas físicas (origen abajo-izq).1119 1120 Acepta escalares o arrays de numpy para x_mm / y_mm.1121 """1122 h, w = gris.shape1123 x_mm = np.asarray(x_mm, dtype=np.float64)1124 y_mm = np.asarray(y_mm, dtype=np.float64)1125 1126 col = np.clip((x_mm / ancho_mm) * (w - 1), 0, w - 1).astype(int)1127 # El eje Y físico crece hacia arriba; la fila 0 del array es la parte superior.1128 fila = np.clip((1.0 - (y_mm / alto_mm)) * (h - 1), 0, h - 1).astype(int)1129 return gris[fila, col]1130 1131 1132def parsear_brocas(texto: str) -> Tuple[float, ...]:1133 """Convierte '3, 4, 5.5' en una tupla ordenada de diámetros (mm)."""1134 diametros = []1135 for parte in texto.replace(";", ",").split(","):1136 parte = parte.strip().replace(",", ".")1137 if not parte:1138 continue1139 try:1140 d = float(parte)1141 except ValueError:1142 continue1143 if d > 0:1144 diametros.append(round(d, 3))1145 return tuple(sorted(set(diametros)))1146 1147 1148def _radios_broca_validos(p: ParametrosCNC, radio_tope: float) -> np.ndarray:1149 """Radios de broca (d/2) que caben dentro del tope de la separación."""1150 radios = [d / 2.0 for d in p.brocas_mm if (d / 2.0) <= radio_tope + 1e-9]1151 return np.array(sorted(radios), dtype=np.float64)1152 1153 1154def _mapear_radios(factor: np.ndarray, p: ParametrosCNC, radio_tope: float):1155 """Convierte el factor de tono (0..1) en radios (mm) + máscara de 'mantener'.1156 1157 Si la cuantización está activa, el rango lo definen las brocas disponibles;1158 en caso contrario se usa el rango de los sliders. Devuelve (radios, mantener).1159 """1160 radios_broca = _radios_broca_validos(p, radio_tope)1161 if p.cuantizar and radios_broca.size > 0:1162 r_max = radios_broca.max()1163 continuo = factor * r_max1164 idx = np.abs(continuo[:, None] - radios_broca[None, :]).argmin(axis=1)1165 radios = radios_broca[idx]1166 mantener = continuo >= radios_broca.min() * 0.61167 else:1168 tam_max = min(p.tam_max_mm, radio_tope)1169 radios = p.tam_min_mm + factor * (max(tam_max - p.tam_min_mm, 0.0))1170 mantener = radios > 0.12 # evita micro-puntos que parpadean en zonas claras1171 return radios, mantener1172 1173 1174def generar_circulos(gris: np.ndarray, p: ParametrosCNC) -> Geometria:1175 """Rejilla de marcas circulares cuyo radio varía con el tono del píxel.1176 1177 Cada marca es el borde exterior visible de un grabado cónico en la chapa1178 (broca tipo cono, p. ej. 6 mm en Z). No es un cilindro ni una perforación1179 pasante: en planta se ve como un aro sin espesor en la circunferencia.1180 1181 El radio se limita a poco menos de la mitad de la separación para que los1182 aros NUNCA se toquen ni se solapen.1183 """1184 paso = max(p.espaciado_mm, 0.5) # separación entre centros (detalle)1185 radio_tope = paso * 0.47 # evita que los círculos se solapen1186 1187 xs = np.arange(paso / 2.0, p.ancho_mm, paso)1188 ys = np.arange(paso / 2.0, p.alto_mm, paso)1189 gx, gy = np.meshgrid(xs, ys)1190 gx = gx.ravel()1191 gy = gy.ravel()1192 1193 valores = _muestrear(gris, gx, gy, p.ancho_mm, p.alto_mm)1194 # Área proporcional al tono (radio ∝ √tono): medios tonos fieles (estilo GH)1195 factor = np.sqrt(_factor_tono(valores, _invertir_efectivo(p)))1196 radios, mantener = _mapear_radios(factor, p, radio_tope)1197 1198 circulos = [1199 (float(x), float(y), float(r))1200 for x, y, r, ok in zip(gx, gy, radios, mantener)