CoolFace
Apppublic

Froxy75/halftone-parametric

sourceHugging Facemitupdated 3mo agoView on Hugging Face
0likes
app.py3456 linesDownload Raw Back to root
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)

Showing the first 1,200 of 3456 lines. Download the file for the rest.