CoolFace
Apppublic

fmatituy/selectospromanager

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
scan_designer.py421 linesDownload Raw Back to sst
1from nicegui import ui, app
2import os
3import asyncio
4import base64
5from datetime import datetime
6from services.auth import require_auth, get_current_user
7from modules.layout import SelectosLayout
8from services.document_engine import DocumentEngine
9from .services_sst import save_plantilla
10
11# Montar ruta estática específica para el escáner
12os.makedirs('static/temp_scans', exist_ok=True)
13app.add_static_files('/static_scans', 'static/temp_scans')
14
15@ui.page('/sst/scan-designer')
16@require_auth
17def scan_designer_page():
18    user = get_current_user()
19    
20    # UI State
21    class ScanState:
22        def __init__(self):
23            self.image_b64 = None
24            self.extracted_fields = []
25            self.processing = False
26            self.template_name = ""
27            self.template_type = "ARO"
28            self.engineering_mode = False
29
30    state = ScanState()
31
32    with SelectosLayout('CamScanner Pro - Extractor de Formatos'):
33        # Styles for a premium look
34        ui.add_head_html("""
35            <style>
36                .glass-card {
37                    background: rgba(255, 255, 255, 0.7);
38                    backdrop-filter: blur(15px);
39                    border: 1px solid rgba(255, 255, 255, 0.4);
40                    border-radius: 2.5rem;
41                }
42                .scanner-line {
43                    position: absolute;
44                    width: 100%;
45                    height: 6px;
46                    background: linear-gradient(90deg, transparent, #3b82f6, transparent);
47                    box-shadow: 0 0 25px #3b82f6;
48                    top: 0;
49                    left: 0;
50                    animation: scan 4s infinite linear;
51                    z-index: 100;
52                    display: none;
53                }
54                @keyframes scan {
55                    0% { top: 0; }
56                    100% { top: 100%; }
57                }
58                .btn-premium {
59                    background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%);
60                    color: white;
61                    border-radius: 1.5rem;
62                    font-weight: 900;
63                    letter-spacing: 0.15em;
64                    text-transform: uppercase;
65                    box-shadow: 0 15px 30px -10px rgba(59, 130, 246, 0.5);
66                    transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
67                }
68                .btn-premium:hover {
69                    transform: translateY(-4px) scale(1.02);
70                    box-shadow: 0 20px 40px -10px rgba(59, 130, 246, 0.7);
71                }
72                .field-card {
73                    transition: all 0.3s ease;
74                }
75                .field-card:hover {
76                    box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
77                    transform: translateX(4px);
78                }
79            </style>
80        """)
81
82        with ui.column().classes('w-full items-center p-8 gap-8 bg-slate-50 min-h-screen'):
83            # Header
84            with ui.row().classes('w-full max-w-7xl justify-between items-center bg-white p-6 rounded-3xl shadow-sm border border-slate-100'):
85                with ui.row().classes('items-center gap-6'):
86                    with ui.element('div').classes('p-4 bg-blue-600 rounded-2xl shadow-lg shadow-blue-200'):
87                        ui.icon('document_scanner', color='white', size='2rem')
88                    with ui.column().classes('gap-0'):
89                        ui.label('SST ARTIFICIAL INTELLIGENCE').classes('text-blue-600 font-black tracking-[0.3em] text-[10px]')
90                        ui.label('Scanner 3.6 RELOADED').classes('text-4xl font-black text-slate-800 tracking-tighter')
91                
92                with ui.row().classes('gap-3 items-center'):
93                    ui.switch('Modo Ingeniería').bind_value(state, 'engineering_mode').classes('mr-4 font-bold text-xs text-slate-600')
94                    ui.button('HUB SST', on_click=lambda: ui.navigate.to('/sst/operacion')).props('flat color=slate-400 font-bold')
95                    ui.button('CONSTRUCTOR', on_click=lambda: ui.navigate.to('/sst/constructor')).props('outline color=blue rounded-xl font-bold')
96
97            with ui.row().classes('w-full max-w-7xl gap-10 items-start mt-4'):
98                # Left Side: Scanner Preview
99                with ui.column().classes('flex-1 gap-8'):
100                    with ui.card().classes('w-full p-0 overflow-hidden glass-card shadow-2xl relative border-none'):
101                        # El contenedor tiene posición relativa y fondo blanco para contraste
102                        scanner_container = ui.column().classes('w-full h-[75vh] min-h-[600px] items-center justify-center bg-white cursor-pointer relative') \
103                            .on('click', lambda: upload.run_method('pickFiles'))
104                        
105                        with scanner_container:
106                            # 1. Imagen de previsualización (Z-INDEX ALTO)
107                            # Usamos fit=contain para responsividad perfecta sin deformar y un ligero padding (p-4)
108                            preview_img = ui.image(visible=False).classes('w-full h-full absolute inset-0 z-40 p-4').props('fit=contain no-spinner').style('background: transparent;')
109                            
110                            # NUEVO: Capa de previsualización interactiva sobre el mismo frame (Z-INDEX MAXIMO)
111                            preview_layer = ui.column().classes('w-full h-full absolute inset-0 z-50 bg-white/95 p-8 overflow-y-auto custom-scrollbar hidden items-center')
112                            with preview_layer:
113                                with ui.row().classes('w-full max-w-3xl items-center justify-between mb-8 pb-4 border-b border-slate-200'):
114                                    with ui.column().classes('gap-0'):
115                                        ui.label('VERSIÓN INTERACTIVA').classes('text-blue-600 font-black tracking-[0.3em] text-[10px]')
116                                        ui.label('Así quedó tu formato').classes('text-2xl font-black text-slate-800')
117                                    ui.button('VOLVER A LA IMAGEN', icon='image', on_click=lambda: toggle_preview_layer(False)) \
118                                        .props('unelevated color=slate-900 rounded-xl').classes('font-bold shadow-lg')
119                                
120                                # Aquí se insertan los campos dinámicamente simulando un documento A4
121                                interactive_container = ui.column().classes('w-full max-w-3xl gap-5 bg-white p-10 rounded-3xl shadow-2xl border border-slate-100 mb-20')
122
123                            # 2. Placeholder (Z-INDEX MEDIO)
124                            upload_placeholder = ui.column().classes('items-center z-10 w-full justify-center')
125                            with upload_placeholder:
126                                ui.icon('cloud_upload', size='160px', color='blue-300').classes('mb-6 opacity-40')
127                                ui.label('CARGA TU FORMATO ANÁLOGO').classes('text-slate-500 font-black text-xs tracking-[0.4em]')
128                                ui.label('Soportamos PDF, PNG de alta resolución y JPEG').classes('text-slate-400 text-[10px] font-bold mt-2')
129                            
130                            # 3. Línea de escaneo (Z-INDEX OVERLAY)
131                            scanner_line = ui.element('div').classes('scanner-line').style('z-index: 100;')
132
133                            # 4. CAPA DE PROGRESO IN-PLACE (Oculta por defecto)
134                            progress_layer = ui.column().classes('absolute inset-0 z-[150] bg-slate-900/90 backdrop-blur-md items-center justify-center flex-col hidden')
135                            with progress_layer:
136                                progress_circle = ui.circular_progress(min=0, max=100, value=0, show_value=True, size='160px', color='blue-500').props('thickness=0.1').classes('text-3xl font-black text-white m-4')
137                                progress_label = ui.label('INICIALIZANDO MOTOR ESTRUCTURAL...').classes('text-blue-400 font-bold tracking-widest text-[10px] mt-4 uppercase text-center w-full block')
138
139                    upload = ui.upload(on_upload=lambda e: handle_upload(e), auto_upload=True).classes('hidden')
140
141                # Right Side: Extracted Structure
142                with ui.column().classes('w-[450px] gap-8'):
143                    with ui.card().classes('w-full p-10 glass-card bg-white/80 shadow-2xl border-none'):
144                        with ui.row().classes('items-center gap-3 mb-8'):
145                            ui.icon('settings_suggest', color='blue-600', size='sm')
146                            ui.label('MOTOR DE DIGITALIZACIÓN').classes('text-[10px] font-black text-slate-400 tracking-[0.3em] uppercase')
147                        
148                        ui.input('Nombre de la Plantilla').bind_value(state, 'template_name').props('outlined rounded-2xl bg-white shadow-inner').classes('w-full mb-6 font-bold')
149                        
150                        with ui.row().classes('w-full grid grid-cols-1 mb-10'):
151                             ui.select(['ARO', 'JSA', 'Inspección', 'Permiso'], label='Categoría Documental').bind_value(state, 'template_type').props('outlined rounded-2xl bg-white shadow-inner').classes('w-full')
152                        
153                        with ui.row().classes('w-full justify-between items-center mb-6 pt-4 border-t border-slate-100'):
154                            ui.label('CAMPOS DINÁMICOS DETECTADOS').classes('text-[10px] font-black text-slate-400 tracking-[0.3em] uppercase')
155                            ui.badge('0', color='blue-600').bind_text_from(state, 'extracted_fields', lambda x: str(len(x)))
156                            
157                        field_list = ui.column().classes('w-full gap-4 max-h-[450px] overflow-y-auto pr-3 custom-scrollbar')
158                        
159                        with field_list:
160                            with ui.column().classes('w-full items-center justify-center py-20 bg-slate-50/50 rounded-3xl border-2 border-dashed border-slate-200'):
161                                ui.icon('precision_manufacturing', size='4rem', color='blue-100')
162                                ui.label('Esperando arquitectura...').classes('text-slate-300 font-bold mt-4 text-xs tracking-widest')
163
164                        ui.button('AGREGAR CAMPO MANUAL', icon='add', on_click=lambda: add_manual_field()) \
165                            .props('flat color=blue-600').classes('w-full mt-4 font-bold text-xs')
166
167                        with ui.row().classes('w-full gap-3 mt-6'):
168                            ui.button('VER FORMATO DIGITAL', icon='preview', on_click=lambda: toggle_preview_layer(True)) \
169                                .props('outline color=slate-600').classes('flex-1 font-bold rounded-2xl h-14')
170
171                            ui.button('PUBLICAR PLANTILLA MAESTRA', on_click=lambda: save_scan()) \
172                                .classes('flex-1 h-14 btn-premium rounded-2xl') \
173                                .props('icon=rocket_launch')
174
175        def toggle_preview_layer(show: bool):
176            if show:
177                if not state.extracted_fields:
178                    ui.notify('Aún no hay coordenadas mapeadas', type='warning')
179                    return
180                
181                # Diseño de Blueprint UX: 
182                # Si es modo Ingeniería -> Fondo escala de grises / Blueprint Técnico.
183                # Si es Operativo -> Capa limpia transparente
184                if state.engineering_mode:
185                    preview_img.style('filter: grayscale(1) invert(0.1) sepia(1) hue-rotate(180deg) opacity(0.8); transition: filter 0.5s;')
186                    preview_layer.classes(remove='hidden bg-white/95 bg-slate-900/50 mb-20 gap-5', add='bg-slate-900/80')
187                else:
188                    preview_img.style('filter: none; transition: filter 0.5s;')
189                    preview_layer.classes(remove='hidden bg-white/95 bg-slate-900/50 mb-20 gap-5', add='bg-transparent')
190                
191                interactive_container.clear()
192                interactive_container.classes(remove='bg-white p-10 shadow-2xl rounded-3xl border border-slate-100 p-8', add='relative w-full h-[80vh] p-4 bg-transparent')
193                
194                with interactive_container:
195                    for i, field in enumerate(state.extracted_fields):
196                        tipo = field['tipo']
197                        label = field['label'] or 'Campo OCR'
198                        x, y = field.get('x', 0), field.get('y', 0)
199                        w, h = field.get('w', 30), field.get('h', 5)
200                        
201                        # Reload 3.6-T: Posicionamiento absoluto preciso
202                        pos_style = f"position: absolute; left: {x}%; top: {y}%; width: {w}%; height: {h}%; transform: translateY(-50%);"
203                        
204                        if state.engineering_mode:
205                            # 3.6-T ENGINEERING MESH
206                            with ui.element('div').style(pos_style).classes('z-50 group hover:z-[60]'):
207                                with ui.column().classes('w-full h-full border border-cyan-500/50 backdrop-blur-sm bg-slate-900/60 p-0 hover:border-cyan-400 transition-colors'):
208                                    with ui.row().classes('w-full bg-cyan-500/20 items-center justify-between px-1 h-3'):
209                                        ui.label(f"ID:{i} | {tipo}").classes('text-[7px] text-cyan-400 font-mono font-bold leading-none uppercase')
210                                        ui.label(f"{x:.1f}% x {y:.1f}%").classes('text-[6px] text-slate-500 font-mono hidden group-hover:block')
211                                    with ui.row().classes('w-full flex-1 px-1 pb-1 -mt-1 items-center relative'):
212                                        ui.input(value=label, on_change=lambda e, idx=i: update_field(idx, 'label', e.value)) \
213                                            .props('borderless dense').classes('w-full text-[10px] text-white bg-transparent h-full font-mono font-semibold')
214                        else:
215                            # 3.6-T CORPORATE OVERLAY
216                            # Colores basados en tipo para jerarquía industrial
217                            border_color = 'emerald' if tipo == 'Firma' else 'blue' if tipo == 'Titulo' else 'slate'
218                            with ui.element('div').style(pos_style).classes(f'z-50 shadow-sm rounded-md bg-white/95 border border-{border_color}-500/50 hover:shadow-md transition-all p-[2px]'):
219                                if tipo == 'Firma':
220                                    with ui.row().classes('w-full h-full items-center justify-center bg-emerald-50/50 rounded-sm'):
221                                        ui.icon('draw', size='1rem', color='emerald-300')
222                                        ui.label(label).classes('text-[8px] text-emerald-600 font-bold uppercase truncate px-1')
223                                elif tipo == 'Checklist':
224                                    with ui.row().classes('w-full h-full items-center px-2 gap-2'):
225                                        ui.element('div').classes('w-3 h-3 border border-slate-300 rounded-sm bg-white')
226                                        ui.label(label).classes('text-[9px] text-slate-600 font-bold truncate')
227                                else:
228                                    ui.input(value=label, on_change=lambda e, idx=i: update_field(idx, 'label', e.value)) \
229                                        .props('borderless dense').classes('w-full h-full text-[11px] text-slate-800 font-bold px-1')
230            else:
231                preview_layer.classes('hidden bg-white/95', remove='bg-slate-900/80 bg-transparent')
232                preview_img.style('filter: none;')
233
234    async def handle_upload(e):
235        try:
236            # 1. LECTURA INMEDIATA
237            ui.notify('Procesando archivo en memoria...', type='info', position='top')
238            content = await e.file.read()
239            filename_orig = e.file.name
240            
241            if not content:
242                ui.notify('Archivo vacío o lectura fallida', type='negative')
243                return
244                
245            # Identificar MIME
246            mime = "image/png"
247            if filename_orig.lower().endswith((".jpg", ".jpeg")): mime = "image/jpeg"
248            elif filename_orig.lower().endswith(".pdf"): mime = "application/pdf"
249            
250            # Guardado Físico
251            filename = f"scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename_orig}"
252            temp_dir = 'static/temp_scans'
253            os.makedirs(temp_dir, exist_ok=True)
254            temp_path = os.path.join(temp_dir, filename)
255            
256            with open(temp_path, 'wb') as f:
257                f.write(content)
258                
259            # 2. CARGA VISUAL (Base64 garantizado para no fallar por rutas web)
260            b64 = base64.b64encode(content).decode('utf-8')
261            
262            # Limpiar estado superpuesto anterior
263            preview_layer.classes('hidden')
264            preview_img.classes(remove='hidden')
265            
266            if mime == "application/pdf":
267                ui.notify('PDF recibido. Extrayendo imagen...', type='info')
268                # No podemos poner PDF en ui.image, pero el motor lo convertirá. 
269            else:
270                preview_img.set_source(f'data:{mime};base64,{b64}')
271                preview_img.set_visibility(True)
272                upload_placeholder.set_visibility(False)
273                scanner_container.classes(remove='bg-white', add='bg-slate-900 shadow-inner')
274            
275            # Pausa visual
276            await asyncio.sleep(0.5)
277            
278            # 3. ANIMACIÓN Y ESCANEO TÉCNICO VÍA PIPELINE
279            state.processing = True
280            scanner_line.style('display: block;')
281            
282            # Mostrar UI de Progreso in-place
283            progress_layer.classes(remove='hidden', add='flex')
284            progress_circle.value = 0
285            progress_label.set_text('GEOMETRIC ENGINE: EXTRAYENDO TOPOLOGÍA')
286            
287            # Tarea asíncrona que simula el progreso heurístico del Motor de Documentos
288            async def update_progress():
289                try:
290                    for i in range(1, 95):
291                        if not state.processing: break
292                        progress_circle.value = i
293                        if i == 25: progress_label.set_text('STRUCTURAL ENGINE: EJECUTANDO DBSCAN')
294                        if i == 50: progress_label.set_text('STRUCTURAL ENGINE: MAPEO DE TABLAS')
295                        if i == 75: progress_label.set_text('SEMANTIC ENGINE: CLASIFICACIÓN NLP')
296                        await asyncio.sleep(0.12)  # Simula un barrido
297                except asyncio.CancelledError:
298                    pass
299                    
300            prog_task = asyncio.create_task(update_progress())
301            
302            # 4. PROCESAMIENTO OCR ASÍNCRONO MULTI-MOTOR
303            loop = asyncio.get_event_loop()
304            result = await loop.run_in_executor(None, DocumentEngine.process_document, temp_path)
305            
306            # Finalizar Proceso y limpiar el Task
307            prog_task.cancel()
308            progress_circle.value = 100
309            progress_label.set_text('ENSAMBLANDO ÁRBOL JERÁRQUICO')
310            await asyncio.sleep(0.6)  # Breve pausa para que el usuario aprecie el 100%
311            progress_layer.classes(remove='flex', add='hidden')
312            
313            if result.get('success'):
314                state.extracted_fields = result.get('structure', [])
315                update_field_list()
316                
317                # No sobreescribimos la imagen original para que el usuario siempre vea su fuente limpia
318                # Solo usamos la lógica si necesitábamos rescatar un thumbnail de un PDF
319                if mime == "application/pdf" and result.get('processed_image') and os.path.exists(result['processed_image']):
320                    with open(result['processed_image'], 'rb') as fp:
321                        b64_p = base64.b64encode(fp.read()).decode('utf-8')
322                        preview_img.set_source(f'data:image/png;base64,{b64_p}')
323                        preview_img.set_visibility(True)
324                        upload_placeholder.set_visibility(False)
325
326                ui.notify('¡Estructura mapeada con éxito!', type='positive', icon='fact_check', position='top')
327                if not state.template_name:
328                    state.template_name = f"Formato_Digital_{datetime.now().strftime('%H%M%S')}"
329            else:
330                ui.notify(f"Error Técnico: {result.get('error')}", type='negative', position='top', multi_line=True)
331                
332            scanner_line.style('display: none;')
333            state.processing = False
334            
335        except Exception as exc:
336            import traceback
337            err_msg = traceback.format_exc()
338            print(f"ERROR CRÍTICO EN UPLOAD: {err_msg}")
339            ui.notify(f"Fallo grave procesando imagen: {str(exc)}", type='negative', multi_line=True)
340            scanner_line.style('display: none;')
341            state.processing = False
342    def update_field_list():
343        field_list.clear()
344        if not state.extracted_fields:
345            with field_list:
346                with ui.column().classes('w-full items-center justify-center py-20 bg-slate-50/50 rounded-3xl border-2 border-dashed border-slate-200'):
347                    ui.icon('grid_on', size='4rem', color='slate-200')
348                    ui.label('ESPERANDO ESCANEO INDUSTRIAL...').classes('text-slate-400 font-bold mt-4 text-[10px] tracking-widest')
349            return
350            
351        with field_list:
352            # TABLA DINÁMICA DE ARQUITECTURA (Antigravity Style)
353            columns = [
354                {'name': 'label', 'label': 'NOMBRE DEL CAMPO', 'field': 'label', 'align': 'left'},
355                {'name': 'tipo', 'label': 'TIPO', 'field': 'tipo', 'align': 'center'},
356                {'name': 'actions', 'label': '', 'field': 'label', 'align': 'right'}
357            ]
358            
359            with ui.column().classes('w-full gap-2'):
360                for i, field in enumerate(state.extracted_fields):
361                    with ui.row().classes('w-full items-center bg-white p-3 rounded-2xl border border-slate-100 shadow-sm hover:translate-x-2 transition-transform'):
362                        # Icono por tipo
363                        icon_map = {'Titulo': 'title', 'Texto': 'short_text', 'Fecha': 'event', 'Firma': 'draw', 'Checklist': 'fact_check', 'Area': 'notes'}
364                        ui.icon(icon_map.get(field['tipo'], 'label'), color='blue-600').classes('bg-blue-50 p-2 rounded-lg')
365                        
366                        with ui.column().classes('flex-1 gap-0'):
367                            ui.input(value=field['label'], on_change=lambda e, idx=i: update_field(idx, 'label', e.value)) \
368                                .props('borderless dense input-style="font-weight: 700; color: #1e293b; font-size: 11px;"').classes('w-full')
369                            
370                            with ui.row().classes('items-center gap-2'):
371                                ui.label(f"COORD: {field['x']:.1f}% , {field['y']:.1f}%").classes('text-[8px] text-slate-400 font-mono uppercase')
372                                ui.badge(field['tipo'], color='blue-100').classes('text-blue-700 text-[8px] font-black rounded-md shadow-none px-2 py-0')
373                        
374                        ui.button(icon='delete', on_click=lambda idx=i: remove_field(idx)).props('flat round size=sm color=rose-300')
375
376    def update_field(idx, key, value):
377        state.extracted_fields[idx][key] = value
378        # Sincronización inmediata con el overlay interactivo si está abierto
379        if preview_layer.visible:
380            toggle_preview_layer(True)
381
382    def add_manual_field():
383        new_f = {"tipo": "Texto", "label": "NUEVO CAMPO", "x": 0, "y": 0, "w": 50, "obligatorio": False}
384        state.extracted_fields.append(new_f)
385        update_field_list()
386
387    def remove_field(idx):
388        state.extracted_fields.pop(idx)
389        update_field_list()
390
391    def save_scan():
392        if not state.template_name:
393            ui.notify('Asigna un nombre técnico a la plantilla', type='warning', icon='warning', position='top')
394            return
395        if not state.extracted_fields:
396            ui.notify('No se han detectado campos para exportar', type='warning', icon='error', position='top')
397            return
398            
399        # CONVERSIÓN DE FORMATO: ScanDesigner(%) -> Constructor(PX en Y)
400        # El constructor usa un lienzo de ~1400px de alto para su previsualización A4.
401        # Para que se vea IGUAL al ejemplo, convertimos los porcentajes de Y a esa escala.
402        A4_HEIGHT = 1400
403        compatible_fields = []
404        
405        for field in state.extracted_fields:
406            f_copy = field.copy()
407            # Convertimos Y y H de % a PX absolutos (Constructor standard)
408            f_copy['y'] = round((field['y'] / 100) * A4_HEIGHT)
409            f_copy['h'] = round((field['h'] / 100) * A4_HEIGHT)
410            # El constructor usa 'opciones' para Checklist, aseguramos que existan
411            if f_copy['tipo'] == 'Checklist' and 'opciones' not in f_copy:
412                f_copy['opciones'] = "SI, NO, N/A"
413            compatible_fields.append(f_copy)
414
415        save_plantilla(state.template_name, state.template_type, compatible_fields, user.get('nombre', 'Admin'))
416        ui.notify('PLANTILLA PUBLICADA EXITOSAMENTE EN GOBERNANZA', type='positive', icon='verified', position='top')
417        
418        # Redirigir al constructor para ver la arquitectura terminada
419        ui.navigate.to('/sst/constructor')
420
421