escaner-app 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: escaner-app
3
+ Version: 1.0.0
4
+ Summary: Aplicación de escaneo de fotos con auto-recorte
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: numpy
7
+ Requires-Dist: opencv-python
8
+ Requires-Dist: pillow
9
+ Requires-Dist: pywin32
File without changes
@@ -0,0 +1,1124 @@
1
+ """
2
+ EscanApp - Aplicación de escaneo moderna para Windows
3
+ Soporte para 1–5 fotos por escaneo con auto-recorte y diseño mejorado.
4
+
5
+ Requiere: pip install Pillow pywin32 opencv-python numpy
6
+ """
7
+
8
+ import tkinter as tk
9
+ from tkinter import ttk, filedialog, messagebox
10
+ import threading
11
+ import os
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ try:
16
+ from PIL import Image, ImageTk, ImageOps, ImageFilter, ImageEnhance
17
+ PIL_AVAILABLE = True
18
+ except ImportError:
19
+ PIL_AVAILABLE = False
20
+
21
+ try:
22
+ import cv2
23
+ import numpy as np
24
+ CV2_AVAILABLE = True
25
+ except ImportError:
26
+ CV2_AVAILABLE = False
27
+
28
+ # ── Paleta oscura monocromática ────────────────────────────────────────────────
29
+ BG_MAIN = "#1a1a1a" # fondo principal casi negro
30
+ BG_SIDEBAR = "#111111" # sidebar negro
31
+ BG_CARD = "#242424" # tarjetas gris muy oscuro
32
+ BG_CANVAS = "#1e1e1e" # fondo canvas
33
+ ACCENT = "#e0e0e0" # blanco/gris claro (acento principal)
34
+ ACCENT2 = "#c0c0c0" # gris medio
35
+ ACCENT_LIGHT = "#888888" # gris medio-claro
36
+ ACCENT_DIM = "#333333" # gris oscuro (hover/seleccion)
37
+ ACCENT_DARK = "#ffffff" # blanco puro (hover boton primario)
38
+ TEXT_DARK = "#f0f0f0" # texto principal casi blanco
39
+ TEXT_MID = "#cccccc" # texto medio gris claro
40
+ TEXT_GREY = "#888888" # gris neutro
41
+ TEXT_DIM = "#555555" # gris tenue
42
+ SUCCESS = "#aaaaaa" # gris claro (exito)
43
+ WARNING = "#999999" # gris medio (advertencia)
44
+ DANGER = "#bbbbbb" # gris (error)
45
+ BORDER = "#3a3a3a" # borde gris oscuro
46
+
47
+ FONT_HEAD = ("Segoe UI", 13, "bold")
48
+ FONT_BODY = ("Segoe UI", 10)
49
+ FONT_SMALL = ("Segoe UI", 9)
50
+ FONT_MONO = ("Consolas", 9)
51
+ FONT_BIG_BTN = ("Segoe UI", 12, "bold")
52
+ FONT_LOGO = ("Segoe UI", 17, "bold")
53
+
54
+
55
+ # ══════════════════════════════════════════════════════════════════════════════
56
+ # UTILIDADES CV2
57
+ # ══════════════════════════════════════════════════════════════════════════════
58
+
59
+ def pil_to_cv2(pil_img):
60
+ arr = np.array(pil_img.convert("RGB"))
61
+ return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
62
+
63
+ def cv2_to_pil(cv2_img):
64
+ rgb = cv2.cvtColor(cv2_img, cv2.COLOR_BGR2RGB)
65
+ return Image.fromarray(rgb)
66
+
67
+ def order_points(pts):
68
+ rect = np.zeros((4, 2), dtype="float32")
69
+ s = pts.sum(axis=1)
70
+ rect[0] = pts[np.argmin(s)]; rect[2] = pts[np.argmax(s)]
71
+ diff = np.diff(pts, axis=1)
72
+ rect[1] = pts[np.argmin(diff)]; rect[3] = pts[np.argmax(diff)]
73
+ return rect
74
+
75
+ def four_point_transform(image, pts):
76
+ rect = order_points(pts)
77
+ tl, tr, br, bl = rect
78
+ maxW = max(int(np.linalg.norm(br - bl)), int(np.linalg.norm(tr - tl)))
79
+ maxH = max(int(np.linalg.norm(tr - br)), int(np.linalg.norm(tl - bl)))
80
+ dst = np.array([[0,0],[maxW-1,0],[maxW-1,maxH-1],[0,maxH-1]], dtype="float32")
81
+ M = cv2.getPerspectiveTransform(rect, dst)
82
+ return cv2.warpPerspective(image, M, (maxW, maxH))
83
+
84
+ def _crop_one(cv_img, cnt, w, h, margin=0):
85
+ peri = cv2.arcLength(cnt, True)
86
+ approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)
87
+ if len(approx) == 4:
88
+ pts = approx.reshape(4, 2).astype("float32")
89
+ return cv2_to_pil(four_point_transform(cv_img, pts))
90
+ x, y, bw, bh = cv2.boundingRect(cnt)
91
+ x1 = max(0, x - margin); y1 = max(0, y - margin)
92
+ x2 = min(w, x + bw + margin); y2 = min(h, y + bh + margin)
93
+ return cv2_to_pil(cv_img[y1:y2, x1:x2])
94
+
95
+ def auto_white_balance(pil_img):
96
+ """
97
+ Corrección de balance de blancos percentil.
98
+ Elimina el tono gris/amarillo/azulado del cristal del escáner.
99
+ """
100
+ arr = np.array(pil_img.convert("RGB")).astype(np.float32)
101
+ for ch in range(3):
102
+ lo = np.percentile(arr[:, :, ch], 0.5)
103
+ hi = np.percentile(arr[:, :, ch], 99.5)
104
+ rng = max(hi - lo, 1.0)
105
+ arr[:, :, ch] = np.clip((arr[:, :, ch] - lo) / rng * 255.0, 0, 255)
106
+ return Image.fromarray(arr.astype(np.uint8))
107
+
108
+ def enhance_scanned_photo(pil_img,
109
+ do_white_balance=True,
110
+ do_auto_contrast=True,
111
+ do_sharpen=True,
112
+ do_denoise=False):
113
+ """
114
+ Pipeline de mejora de calidad equivalente al escáner de Windows / impresoras HP:
115
+ 1. Balance de blancos → elimina tono del cristal
116
+ 2. Autocontraste → niveles automáticos (sombras/luces)
117
+ 3. Nitidez (unsharp) → igual que el modo 'Texto y fotos' del driver
118
+ 4. Reducción de ruido → opcional, para 150 DPI
119
+ Devuelve una copia mejorada; nunca modifica el original.
120
+ """
121
+ img = pil_img.copy().convert("RGB")
122
+
123
+ # ── 1. Balance de blancos ─────────────────────────────────────────────────
124
+ if do_white_balance and CV2_AVAILABLE:
125
+ img = auto_white_balance(img)
126
+
127
+ # ── 2. Autocontraste (niveles) ────────────────────────────────────────────
128
+ if do_auto_contrast:
129
+ img = ImageOps.autocontrast(img, cutoff=0.5)
130
+
131
+ # ── 3. Nitidez (Unsharp Mask) ─────────────────────────────────────────────
132
+ # radius=1.5, percent=140, threshold=3 → similar al perfil "Fotos" de WIA
133
+ if do_sharpen:
134
+ img = img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=140, threshold=3))
135
+
136
+ # ── 4. Reducción de ruido suave (opcional) ────────────────────────────────
137
+ if do_denoise:
138
+ img = img.filter(ImageFilter.MedianFilter(size=3))
139
+
140
+ return img
141
+
142
+ def auto_crop_n_photos(pil_img, n_expected=1, margin_px=0):
143
+ """
144
+ Detecta hasta n_expected fotografías en el folio y las devuelve como lista.
145
+ Devuelve ([PIL, ...], mensaje_str)
146
+ """
147
+ if not CV2_AVAILABLE:
148
+ return [pil_img], "OpenCV no disponible — sin recorte automático"
149
+
150
+ cv_img = pil_to_cv2(pil_img)
151
+ h, w = cv_img.shape[:2]
152
+ total = h * w
153
+
154
+ gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
155
+ blur = cv2.GaussianBlur(gray, (5, 5), 0)
156
+ _, thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
157
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15))
158
+ closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
159
+
160
+ cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
161
+ if not cnts:
162
+ return [pil_img], "No se encontraron objetos — imagen original"
163
+
164
+ # Máx. área por foto: si hay 5 fotos en un A4, cada una ocupa ~20% máx
165
+ max_area_ratio = min(0.90, 1.0 / max(n_expected - 0.5, 1) + 0.1)
166
+ valid = [c for c in cnts
167
+ if total * 0.03 < cv2.contourArea(c) < total * max_area_ratio]
168
+ valid.sort(key=cv2.contourArea, reverse=True)
169
+ valid = valid[:n_expected] # tomar solo las n más grandes
170
+
171
+ if not valid:
172
+ return [pil_img], "No se detectaron fotos — imagen original"
173
+
174
+ results = [_crop_one(cv_img, c, w, h, margin_px) for c in valid]
175
+ n = len(results)
176
+ msg = f"✓ {n} foto{'s' if n>1 else ''} detectada{'s' if n>1 else ''} y recortada{'s' if n>1 else ''}"
177
+ return results, msg
178
+
179
+
180
+ # ══════════════════════════════════════════════════════════════════════════════
181
+ # WIA
182
+ # ══════════════════════════════════════════════════════════════════════════════
183
+
184
+ def get_wia_scanners():
185
+ try:
186
+ import win32com.client, pythoncom
187
+ pythoncom.CoInitialize()
188
+ try:
189
+ wia = win32com.client.Dispatch("WIA.DeviceManager")
190
+ devices = []
191
+ for info in wia.DeviceInfos:
192
+ if info.Type == 1:
193
+ devices.append((info.Properties("Name").Value, info.DeviceID))
194
+ return devices
195
+ finally:
196
+ pythoncom.CoUninitialize()
197
+ except Exception:
198
+ return []
199
+
200
+ def scan_with_wia(device_id=None, dpi=300, color_mode="Color"):
201
+ import win32com.client, tempfile, pythoncom
202
+ pythoncom.CoInitialize()
203
+ try:
204
+ wia = win32com.client.Dispatch("WIA.DeviceManager")
205
+ device_info = None
206
+ for info in wia.DeviceInfos:
207
+ if info.Type == 1:
208
+ if device_id is None or info.DeviceID == device_id:
209
+ device_info = info
210
+ break
211
+ if device_info is None:
212
+ raise RuntimeError("No se encontró ningún escáner WIA conectado.")
213
+ device = device_info.Connect()
214
+ item = device.Items(1)
215
+ color_map = {"Color": 4, "Grayscale": 2, "BlackWhite": 1}
216
+ def set_prop(it, pid, val):
217
+ try: it.Properties(pid).Value = val
218
+ except Exception: pass
219
+ set_prop(item, 6147, dpi)
220
+ set_prop(item, 6148, dpi)
221
+ set_prop(item, 4103, color_map.get(color_mode, 4))
222
+ img_file = item.Transfer("{B96B3CAB-0728-11D3-9D7B-0000F81EF32E}")
223
+ tmp = tempfile.mktemp(suffix=".png")
224
+ img_file.SaveFile(tmp)
225
+ pil_img = Image.open(tmp).copy()
226
+ os.remove(tmp)
227
+ return pil_img
228
+ finally:
229
+ pythoncom.CoUninitialize()
230
+
231
+
232
+ # ══════════════════════════════════════════════════════════════════════════════
233
+ # TOOLTIP
234
+ # ══════════════════════════════════════════════════════════════════════════════
235
+
236
+ class Tooltip:
237
+ def __init__(self, widget, text):
238
+ self.widget = widget; self.text = text; self.tip = None
239
+ widget.bind("<Enter>", self.show); widget.bind("<Leave>", self.hide)
240
+ def show(self, _=None):
241
+ x = self.widget.winfo_rootx() + 30
242
+ y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
243
+ self.tip = tk.Toplevel(self.widget)
244
+ self.tip.wm_overrideredirect(True)
245
+ self.tip.wm_geometry(f"+{x}+{y}")
246
+ tk.Label(self.tip, text=self.text, bg=ACCENT, fg="white",
247
+ font=FONT_SMALL, padx=8, pady=4, relief="flat").pack()
248
+ def hide(self, _=None):
249
+ if self.tip: self.tip.destroy(); self.tip = None
250
+
251
+
252
+ # ══════════════════════════════════════════════════════════════════════════════
253
+ # WIDGET AUXILIAR: RoundedFrame (canvas con bordes redondeados)
254
+ # ══════════════════════════════════════════════════════════════════════════════
255
+
256
+ def rounded_rect(canvas, x1, y1, x2, y2, r, **kwargs):
257
+ """Dibuja un rectángulo con esquinas redondeadas en un Canvas."""
258
+ points = [
259
+ x1+r, y1, x2-r, y1,
260
+ x2, y1, x2, y1+r,
261
+ x2, y2-r, x2, y2,
262
+ x2-r, y2, x1+r, y2,
263
+ x1, y2, x1, y2-r,
264
+ x1, y1+r, x1, y1,
265
+ ]
266
+ return canvas.create_polygon(points, smooth=True, **kwargs)
267
+
268
+
269
+ class Card(tk.Frame):
270
+ """Frame con borde redondeado simulado mediante padding y color."""
271
+ def __init__(self, parent, radius=10, bg=BG_CARD, border_color=BORDER,
272
+ border_width=2, **kwargs):
273
+ # Outer frame actúa de borde
274
+ outer = tk.Frame(parent, bg=border_color,
275
+ padx=border_width, pady=border_width)
276
+ super().__init__(outer, bg=bg, **kwargs)
277
+ self._outer = outer
278
+ self.pack(fill="both", expand=True)
279
+
280
+ def grid(self, **kwargs):
281
+ self._outer.grid(**kwargs)
282
+
283
+ def pack(self, **kwargs):
284
+ # Solo para el inner frame dentro del outer
285
+ super().pack(**kwargs)
286
+
287
+ def grid_outer(self, **kwargs):
288
+ self._outer.grid(**kwargs)
289
+
290
+ def pack_outer(self, **kwargs):
291
+ self._outer.pack(**kwargs)
292
+
293
+
294
+ # ══════════════════════════════════════════════════════════════════════════════
295
+ # APP PRINCIPAL
296
+ # ══════════════════════════════════════════════════════════════════════════════
297
+
298
+ MAX_PHOTOS = 5
299
+
300
+ class EscanApp(tk.Tk):
301
+ def __init__(self):
302
+ super().__init__()
303
+ self.title("EscanApp")
304
+ self.geometry("1400x800")
305
+ self.minsize(1100, 650)
306
+ self.configure(bg=BG_MAIN)
307
+
308
+ # Estado por foto (índices 0..MAX_PHOTOS-1)
309
+ self.scanned_raw = None
310
+ self.photos = [None] * MAX_PHOTOS # PIL images
311
+ self.tk_images = [None] * MAX_PHOTOS # ImageTk refs
312
+ self.zoom_levels = [1.0] * MAX_PHOTOS
313
+ self.canvases = [None] * MAX_PHOTOS
314
+ self.info_labels = [None] * MAX_PHOTOS
315
+ self.save_btns = [None] * MAX_PHOTOS
316
+
317
+ self.scan_history = []
318
+ self.history_idx = -1
319
+ self.scanner_list = []
320
+
321
+ self.scan_var_dpi = tk.IntVar(value=300)
322
+ self.scan_var_color = tk.StringVar(value="Color")
323
+ self.scan_var_device = tk.StringVar(value="")
324
+ self.scan_var_autocrop = tk.BooleanVar(value=True)
325
+ self.scan_var_nfotos = tk.IntVar(value=2) # ← nº de fotos
326
+ self.status_text = tk.StringVar(value="Listo para escanear")
327
+
328
+ # ── Variables de mejora de imagen ──────────────────────────────────────
329
+ self.scan_var_enhance_wb = tk.BooleanVar(value=True) # balance blancos
330
+ self.scan_var_enhance_ac = tk.BooleanVar(value=True) # autocontraste
331
+ self.scan_var_enhance_sh = tk.BooleanVar(value=True) # nitidez
332
+ self.scan_var_enhance_dn = tk.BooleanVar(value=False) # reducir ruido
333
+
334
+ if not PIL_AVAILABLE:
335
+ messagebox.showerror("Error", "Instala Pillow:\npip install Pillow")
336
+ self.destroy(); return
337
+
338
+ self._build_ui()
339
+ self._refresh_scanners()
340
+ self._animate_dot()
341
+
342
+ # ── Layout principal ──────────────────────────────────────────────────────
343
+ def _build_ui(self):
344
+ self.columnconfigure(1, weight=1)
345
+ self.rowconfigure(0, weight=1)
346
+ self._build_sidebar()
347
+ self._build_main()
348
+ self._build_statusbar()
349
+
350
+ # ══════════════════════════════════════════════════════════════════════════
351
+ # SIDEBAR
352
+ # ══════════════════════════════════════════════════════════════════════════
353
+ def _build_sidebar(self):
354
+ # ── Contenedor externo fijo ────────────────────────────────────────────
355
+ sb_outer = tk.Frame(self, bg=BG_SIDEBAR, width=285)
356
+ sb_outer.grid(row=0, column=0, sticky="nsew")
357
+ sb_outer.grid_propagate(False)
358
+ sb_outer.rowconfigure(1, weight=1)
359
+ sb_outer.columnconfigure(0, weight=1)
360
+
361
+ # ── Logo FIJO (fuera del scroll) ───────────────────────────────────────
362
+ logo_frame = tk.Frame(sb_outer, bg="#000000", pady=16)
363
+ logo_frame.grid(row=0, column=0, sticky="ew")
364
+ tk.Label(logo_frame, text="⬡ EscanApp",
365
+ font=FONT_LOGO, bg="#000000", fg=TEXT_DARK
366
+ ).pack()
367
+ tk.Label(logo_frame, text="Escaneo inteligente de fotos",
368
+ font=("Segoe UI", 8), bg="#000000", fg=TEXT_DIM
369
+ ).pack()
370
+
371
+ # ── Canvas scrollable ──────────────────────────────────────────────────
372
+ sb_canvas = tk.Canvas(sb_outer, bg=BG_SIDEBAR,
373
+ highlightthickness=0, bd=0)
374
+ sb_canvas.grid(row=1, column=0, sticky="nsew")
375
+
376
+ sb_scroll = ttk.Scrollbar(sb_outer, orient="vertical",
377
+ command=sb_canvas.yview)
378
+ sb_scroll.grid(row=1, column=1, sticky="ns")
379
+ sb_canvas.configure(yscrollcommand=sb_scroll.set)
380
+
381
+ # Frame interior donde va todo el contenido del sidebar
382
+ sb = tk.Frame(sb_canvas, bg=BG_SIDEBAR)
383
+ sb_canvas.create_window((0, 0), window=sb, anchor="nw")
384
+ sb.columnconfigure(0, weight=1)
385
+
386
+ def _on_sb_configure(e):
387
+ sb_canvas.configure(scrollregion=sb_canvas.bbox("all"))
388
+ sb_canvas.itemconfig(1, width=sb_canvas.winfo_width())
389
+
390
+ sb.bind("<Configure>", _on_sb_configure)
391
+ sb_canvas.bind("<Configure>",
392
+ lambda e: sb_canvas.itemconfig(1, width=e.width))
393
+
394
+ def _on_mousewheel(e):
395
+ sb_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")
396
+
397
+ sb_canvas.bind_all("<MouseWheel>", _on_mousewheel)
398
+
399
+ # ── Contenido del sidebar (en sb, que sí hace scroll) ─────────────────
400
+ r = 0
401
+ self._sep(sb, r); r += 1
402
+
403
+ # — Escáner —
404
+ self._sec(sb, r, "ESCANER"); r += 1
405
+ self.device_combo = ttk.Combobox(sb, textvariable=self.scan_var_device,
406
+ state="readonly", font=FONT_SMALL)
407
+ self.device_combo.grid(row=r, column=0, sticky="ew", padx=14, pady=(0,4))
408
+ r += 1
409
+ self._flat_btn(sb, r, "Actualizar lista",
410
+ self._refresh_scanners, small=True)
411
+ r += 1
412
+ self._sep(sb, r); r += 1
413
+
414
+ # — Nº de fotos —
415
+ self._sec(sb, r, "FOTOS EN EL ESCANER"); r += 1
416
+
417
+ nf_card = tk.Frame(sb, bg=BG_CARD, bd=0,
418
+ highlightthickness=1,
419
+ highlightbackground=BORDER)
420
+ nf_card.grid(row=r, column=0, sticky="ew", padx=14, pady=(0,10))
421
+ nf_card.columnconfigure(0, weight=1)
422
+ r += 1
423
+
424
+ tk.Label(nf_card,
425
+ text="Cuantas fotos hay encima\ndel cristal?",
426
+ font=FONT_SMALL, bg=BG_CARD, fg=TEXT_DARK,
427
+ justify="left", padx=10, pady=6
428
+ ).grid(row=0, column=0, sticky="w")
429
+
430
+ btn_row = tk.Frame(nf_card, bg=BG_CARD, pady=4)
431
+ btn_row.grid(row=1, column=0, sticky="ew", padx=8, pady=(0,8))
432
+
433
+ self._nfotos_btns = []
434
+ for i in range(1, MAX_PHOTOS + 1):
435
+ b = tk.Radiobutton(
436
+ btn_row, text=str(i),
437
+ variable=self.scan_var_nfotos, value=i,
438
+ bg=BG_CARD, fg=TEXT_DARK,
439
+ selectcolor=ACCENT, activebackground=BG_CARD,
440
+ activeforeground=TEXT_DARK,
441
+ font=("Segoe UI", 11, "bold"),
442
+ indicator=0, relief="flat",
443
+ padx=10, pady=6,
444
+ highlightthickness=0, cursor="hand2",
445
+ command=self._on_nfotos_change)
446
+ b.grid(row=0, column=i-1, padx=2)
447
+ btn_row.columnconfigure(i-1, weight=1)
448
+ self._nfotos_btns.append(b)
449
+
450
+ self._update_nfotos_colors()
451
+
452
+ self._sep(sb, r); r += 1
453
+
454
+ # — Resolución —
455
+ self._sec(sb, r, "RESOLUCION (DPI)"); r += 1
456
+ dpi_f = tk.Frame(sb, bg=BG_SIDEBAR)
457
+ dpi_f.grid(row=r, column=0, sticky="ew", padx=14, pady=(0,10))
458
+ dpi_f.columnconfigure((0,1,2), weight=1)
459
+ for i, d in enumerate([150, 300, 600]):
460
+ tk.Radiobutton(dpi_f, text=str(d), variable=self.scan_var_dpi,
461
+ value=d, bg=BG_SIDEBAR, fg=TEXT_DARK,
462
+ selectcolor=ACCENT_DIM,
463
+ activebackground=BG_SIDEBAR,
464
+ activeforeground=TEXT_DARK,
465
+ font=FONT_SMALL, indicator=0, relief="flat",
466
+ padx=6, pady=4, highlightthickness=0, cursor="hand2"
467
+ ).grid(row=0, column=i, padx=2)
468
+ r += 1
469
+
470
+ # — Modo color —
471
+ self._sec(sb, r, "MODO DE COLOR"); r += 1
472
+ for mode, lbl in [("Color","Color"),
473
+ ("Grayscale","Escala de grises"),
474
+ ("BlackWhite","Blanco y negro")]:
475
+ tk.Radiobutton(sb, text=lbl, variable=self.scan_var_color,
476
+ value=mode, bg=BG_SIDEBAR, fg=TEXT_DARK,
477
+ selectcolor=ACCENT_DIM,
478
+ activebackground=BG_SIDEBAR,
479
+ activeforeground=TEXT_DARK,
480
+ font=FONT_SMALL, anchor="w",
481
+ highlightthickness=0, cursor="hand2"
482
+ ).grid(row=r, column=0, sticky="ew", padx=20, pady=1)
483
+ r += 1
484
+ self._sep(sb, r); r += 1
485
+
486
+ # — Auto-recorte —
487
+ self._sec(sb, r, "AUTO-RECORTE"); r += 1
488
+ ac_card = tk.Frame(sb, bg=BG_CARD, bd=0,
489
+ highlightthickness=1,
490
+ highlightbackground=BORDER)
491
+ ac_card.grid(row=r, column=0, sticky="ew", padx=14, pady=(0,6))
492
+ ac_card.columnconfigure(0, weight=1)
493
+ r += 1
494
+
495
+ tr = tk.Frame(ac_card, bg=BG_CARD, pady=8)
496
+ tr.grid(row=0, column=0, sticky="ew", padx=10)
497
+ tr.columnconfigure(0, weight=1)
498
+ tk.Label(tr, text="Recorte automatico",
499
+ font=FONT_SMALL, bg=BG_CARD, fg=TEXT_DARK, anchor="w"
500
+ ).grid(row=0, column=0, sticky="w")
501
+ tk.Checkbutton(tr, variable=self.scan_var_autocrop,
502
+ bg=BG_CARD, fg=ACCENT,
503
+ selectcolor=ACCENT_DIM, activebackground=BG_CARD,
504
+ cursor="hand2", onvalue=True, offvalue=False,
505
+ command=self._on_crop_toggle
506
+ ).grid(row=0, column=1, padx=4)
507
+ tk.Label(ac_card,
508
+ text="Detecta cada foto y elimina\nel espacio blanco del folio.",
509
+ font=("Segoe UI", 8), bg=BG_CARD, fg=TEXT_GREY,
510
+ justify="left", padx=10, pady=0
511
+ ).grid(row=1, column=0, sticky="w", pady=(0,8))
512
+
513
+ self.crop_now_btn = tk.Button(sb,
514
+ text="Recortar ahora",
515
+ font=FONT_SMALL, bg=ACCENT_LIGHT, fg=BG_MAIN,
516
+ activebackground=ACCENT, activeforeground=BG_MAIN,
517
+ relief="flat", bd=0, pady=7, cursor="hand2",
518
+ command=self._manual_crop, state="disabled")
519
+ self.crop_now_btn.grid(row=r, column=0, sticky="ew", padx=14, pady=(0,3))
520
+ r += 1
521
+
522
+ self.restore_btn = tk.Button(sb,
523
+ text="Restaurar original",
524
+ font=FONT_SMALL, bg=BG_CARD, fg=TEXT_GREY,
525
+ activebackground=ACCENT_DIM, activeforeground=TEXT_DARK,
526
+ relief="flat", bd=0, pady=7, cursor="hand2",
527
+ command=self._restore_original, state="disabled")
528
+ self.restore_btn.grid(row=r, column=0, sticky="ew", padx=14, pady=(0,10))
529
+ r += 1
530
+ self._sep(sb, r); r += 1
531
+
532
+ # — Mejora de imagen —
533
+ self._sec(sb, r, "MEJORA DE IMAGEN"); r += 1
534
+ enh_card = tk.Frame(sb, bg=BG_CARD, bd=0,
535
+ highlightthickness=1,
536
+ highlightbackground=BORDER)
537
+ enh_card.grid(row=r, column=0, sticky="ew", padx=14, pady=(0,6))
538
+ enh_card.columnconfigure(0, weight=1)
539
+ r += 1
540
+
541
+ enh_opts = [
542
+ (self.scan_var_enhance_wb, "Balance de blancos",
543
+ "Elimina el tono gris/amarillo del cristal"),
544
+ (self.scan_var_enhance_ac, "Contraste automatico",
545
+ "Ajusta sombras y luces (niveles)"),
546
+ (self.scan_var_enhance_sh, "Nitidez (Unsharp Mask)",
547
+ "Definicion equivalente al driver de Windows"),
548
+ (self.scan_var_enhance_dn, "Reducir ruido",
549
+ "Util a 150 DPI; puede suavizar a 600 DPI"),
550
+ ]
551
+ for ei, (var, label, tip) in enumerate(enh_opts):
552
+ row_f = tk.Frame(enh_card, bg=BG_CARD)
553
+ row_f.grid(row=ei, column=0, sticky="ew", padx=10,
554
+ pady=(6 if ei == 0 else 2, 2 if ei < 3 else 8))
555
+ row_f.columnconfigure(0, weight=1)
556
+ lbl = tk.Label(row_f, text=label,
557
+ font=FONT_SMALL, bg=BG_CARD, fg=TEXT_DARK, anchor="w")
558
+ lbl.grid(row=0, column=0, sticky="w")
559
+ Tooltip(lbl, tip)
560
+ tk.Checkbutton(row_f, variable=var,
561
+ bg=BG_CARD, fg=ACCENT,
562
+ selectcolor=ACCENT_DIM, activebackground=BG_CARD,
563
+ cursor="hand2"
564
+ ).grid(row=0, column=1)
565
+
566
+ self._sep(sb, r); r += 1
567
+
568
+ # — Botón ESCANEAR —
569
+ scan_btn = tk.Button(sb,
570
+ text="ESCANEAR",
571
+ font=FONT_BIG_BTN, bg=ACCENT, fg=BG_MAIN,
572
+ activebackground=ACCENT_DARK, activeforeground=BG_MAIN,
573
+ relief="flat", bd=0, pady=15, cursor="hand2",
574
+ command=self._start_scan)
575
+ scan_btn.grid(row=r, column=0, sticky="ew", padx=14, pady=(0,6))
576
+ r += 1
577
+
578
+ # — Historial —
579
+ self._sec(sb, r, "HISTORIAL"); r += 1
580
+ hf = tk.Frame(sb, bg=BG_SIDEBAR)
581
+ hf.grid(row=r, column=0, sticky="ew", padx=14, pady=(0,14))
582
+ hf.columnconfigure((0,1), weight=1)
583
+ for col, (lbl, cmd) in enumerate([
584
+ ("<- Anterior", self._history_prev),
585
+ ("Siguiente ->", self._history_next)]):
586
+ tk.Button(hf, text=lbl, font=FONT_SMALL,
587
+ bg=BG_CARD, fg=TEXT_GREY,
588
+ activebackground=ACCENT_DIM, activeforeground=TEXT_DARK,
589
+ relief="flat", bd=0, pady=6, cursor="hand2",
590
+ command=cmd).grid(
591
+ row=0, column=col,
592
+ padx=(0,3) if col==0 else (3,0), sticky="ew")
593
+
594
+ def _on_nfotos_change(self):
595
+ self._update_nfotos_colors()
596
+ self._rebuild_photo_grid()
597
+
598
+ def _update_nfotos_colors(self):
599
+ n = self.scan_var_nfotos.get()
600
+ for i, b in enumerate(self._nfotos_btns):
601
+ if i + 1 == n:
602
+ b.config(bg=ACCENT, fg="white", selectcolor=ACCENT)
603
+ else:
604
+ b.config(bg=ACCENT_DIM, fg=TEXT_DARK, selectcolor=ACCENT_DIM)
605
+
606
+ def _sep(self, p, r, color=BORDER):
607
+ tk.Frame(p, bg=color, height=1
608
+ ).grid(row=r, column=0, sticky="ew", padx=14, pady=(0,8))
609
+
610
+ def _sec(self, p, r, text):
611
+ tk.Label(p, text=text, font=("Segoe UI", 8, "bold"),
612
+ bg=BG_SIDEBAR, fg=TEXT_GREY
613
+ ).grid(row=r, column=0, sticky="w", padx=18, pady=(8,3))
614
+
615
+ def _flat_btn(self, parent, row, text, cmd, small=False):
616
+ b = tk.Button(parent, text=text,
617
+ font=FONT_SMALL if small else FONT_BODY,
618
+ bg=BG_CARD, fg=TEXT_GREY,
619
+ activebackground=ACCENT_DIM, activeforeground=TEXT_DARK,
620
+ relief="flat", bd=0, pady=6, cursor="hand2", command=cmd)
621
+ b.grid(row=row, column=0, sticky="ew", padx=14, pady=(0,8))
622
+ return b
623
+
624
+ # ══════════════════════════════════════════════════════════════════════════
625
+ # PANEL CENTRAL
626
+ # ══════════════════════════════════════════════════════════════════════════
627
+ def _build_main(self):
628
+ self.main_frame = tk.Frame(self, bg=BG_MAIN)
629
+ self.main_frame.grid(row=0, column=1, sticky="nsew")
630
+ self.main_frame.rowconfigure(1, weight=1)
631
+ self.main_frame.columnconfigure(0, weight=1)
632
+
633
+ self._build_toolbar()
634
+ self._build_photo_grid_container()
635
+
636
+ def _build_toolbar(self):
637
+ tb = tk.Frame(self.main_frame, bg=BG_CARD, pady=8,
638
+ highlightthickness=1, highlightbackground=BORDER)
639
+ tb.grid(row=0, column=0, sticky="ew", padx=0, pady=0)
640
+ self._toolbar = tb
641
+
642
+ def tb_btn(text, cmd, tip=None):
643
+ b = tk.Button(tb, text=text, font=("Segoe UI", 11),
644
+ bg=BG_CARD, fg=TEXT_DARK,
645
+ activebackground=ACCENT_DIM,
646
+ activeforeground=ACCENT_DARK,
647
+ relief="flat", bd=0, padx=10, pady=4,
648
+ cursor="hand2", command=cmd)
649
+ b.pack(side="left", padx=2)
650
+ if tip: Tooltip(b, tip)
651
+ return b
652
+
653
+ tk.Label(tb, text="Zoom:", font=FONT_SMALL,
654
+ bg=BG_CARD, fg=TEXT_GREY).pack(side="left", padx=(14,4))
655
+ tb_btn("−", self._zoom_out)
656
+ self.zoom_label = tk.Label(tb, text="100%", font=FONT_MONO,
657
+ bg=BG_CARD, fg=TEXT_DARK, width=5)
658
+ self.zoom_label.pack(side="left")
659
+ tb_btn("+", self._zoom_in)
660
+ tb_btn("⊡ Ajustar", self._zoom_fit)
661
+
662
+ tk.Frame(tb, bg=BORDER, width=1).pack(side="left", fill="y", padx=10, pady=4)
663
+ tb_btn("↺", lambda: self._rotate_all(-90), "Rotar todas izquierda")
664
+ tb_btn("↻", lambda: self._rotate_all(90), "Rotar todas derecha")
665
+
666
+ # Info general
667
+ self.info_general = tk.Label(tb, text="", font=FONT_SMALL,
668
+ bg=BG_CARD, fg=TEXT_GREY)
669
+ self.info_general.pack(side="right", padx=14)
670
+
671
+ def _build_photo_grid_container(self):
672
+ self.grid_container = tk.Frame(self.main_frame, bg=BG_MAIN)
673
+ self.grid_container.grid(row=1, column=0, sticky="nsew",
674
+ padx=12, pady=12)
675
+ self._rebuild_photo_grid()
676
+
677
+ def _rebuild_photo_grid(self):
678
+ """Destruye y reconstruye la cuadrícula de paneles de foto."""
679
+ for w in self.grid_container.winfo_children():
680
+ w.destroy()
681
+
682
+ n = self.scan_var_nfotos.get()
683
+
684
+ # Calcular layout: máx 3 columnas
685
+ if n == 1:
686
+ cols, rows = 1, 1
687
+ elif n == 2:
688
+ cols, rows = 2, 1
689
+ elif n == 3:
690
+ cols, rows = 3, 1
691
+ elif n == 4:
692
+ cols, rows = 2, 2
693
+ else: # 5
694
+ cols, rows = 3, 2 # 3 arriba, 2 abajo centradas
695
+
696
+ for c in range(cols):
697
+ self.grid_container.columnconfigure(c, weight=1)
698
+ for r in range(rows):
699
+ self.grid_container.rowconfigure(r, weight=1)
700
+
701
+ # Resetear canvases
702
+ self.canvases = [None] * MAX_PHOTOS
703
+ self.info_labels = [None] * MAX_PHOTOS
704
+ self.save_btns = [None] * MAX_PHOTOS
705
+
706
+ for idx in range(n):
707
+ col = idx % cols
708
+ row = idx // cols
709
+
710
+ # Para n=5, la última fila tiene 2 celdas → centrar
711
+ if n == 5 and row == 1:
712
+ # Usar columnspan y columna de inicio para centrar 2 en 3
713
+ col_start = [0, 1][idx - 3] # índice 3→col0, 4→col1... centrar
714
+ col_start = idx - 3 # 0,1
715
+ # centrar: empieza en col 0 para idx=3, col=1 para idx=4... no, pon en col 0 y 2
716
+ col_start = [0, 2][idx - 3]
717
+ else:
718
+ col_start = col
719
+
720
+ self._make_photo_panel(idx, row, col_start)
721
+
722
+ def _make_photo_panel(self, idx, row, col):
723
+ """Crea un panel con canvas y botones para la foto idx."""
724
+ n_label = idx + 1
725
+
726
+ outer = tk.Frame(self.grid_container, bg=BORDER, padx=1, pady=1)
727
+ outer.grid(row=row, column=col, sticky="nsew", padx=5, pady=5)
728
+ outer.columnconfigure(0, weight=1)
729
+ outer.rowconfigure(1, weight=1)
730
+
731
+ inner = tk.Frame(outer, bg=BG_CARD)
732
+ inner.pack(fill="both", expand=True)
733
+ inner.columnconfigure(0, weight=1)
734
+ inner.rowconfigure(1, weight=1)
735
+
736
+ # Cabecera
737
+ hdr = tk.Frame(inner, bg=ACCENT_DIM, pady=5)
738
+ hdr.grid(row=0, column=0, columnspan=2, sticky="ew")
739
+ hdr.columnconfigure(0, weight=1)
740
+ tk.Label(hdr, text=f"FOTO {n_label}",
741
+ font=("Segoe UI", 9, "bold"),
742
+ bg=ACCENT_DIM, fg=TEXT_DARK, anchor="w", padx=10
743
+ ).grid(row=0, column=0, sticky="w")
744
+ info = tk.Label(hdr, text="Sin imagen", font=FONT_SMALL,
745
+ bg=ACCENT_DIM, fg=TEXT_GREY, anchor="e", padx=10)
746
+ info.grid(row=0, column=1, sticky="e")
747
+ self.info_labels[idx] = info
748
+
749
+ # Canvas
750
+ cv = tk.Canvas(inner, bg=BG_CANVAS,
751
+ highlightthickness=0, cursor="crosshair")
752
+ cv.grid(row=1, column=0, sticky="nsew")
753
+ vb = ttk.Scrollbar(inner, orient="vertical", command=cv.yview)
754
+ vb.grid(row=1, column=1, sticky="ns")
755
+ hb = ttk.Scrollbar(inner, orient="horizontal", command=cv.xview)
756
+ hb.grid(row=2, column=0, sticky="ew")
757
+ cv.configure(yscrollcommand=vb.set, xscrollcommand=hb.set)
758
+ cv.bind("<Configure>", lambda e, i=idx: self._on_resize(i))
759
+ self.canvases[idx] = cv
760
+
761
+ # Botón guardar
762
+ sb_btn = tk.Button(inner,
763
+ text=f"Guardar Foto {n_label}",
764
+ font=FONT_BODY, bg=BG_CARD, fg=TEXT_DARK,
765
+ activebackground=ACCENT_DIM, activeforeground=ACCENT_DARK,
766
+ relief="flat", bd=0, pady=7, cursor="hand2",
767
+ command=lambda i=idx: self._save_image(i),
768
+ state="disabled")
769
+ sb_btn.grid(row=3, column=0, columnspan=2, sticky="ew")
770
+ self.save_btns[idx] = sb_btn
771
+
772
+ self._draw_placeholder(idx)
773
+
774
+ # ── Status bar ────────────────────────────────────────────────────────────
775
+ def _build_statusbar(self):
776
+ bar = tk.Frame(self, bg="#000000", pady=6)
777
+ bar.grid(row=1, column=0, columnspan=2, sticky="ew")
778
+ bar.columnconfigure(1, weight=1)
779
+ self.dot_lbl = tk.Label(bar, text="●", font=FONT_SMALL,
780
+ bg="#000000", fg=BORDER)
781
+ self.dot_lbl.grid(row=0, column=0, padx=(14,4))
782
+ tk.Label(bar, textvariable=self.status_text, font=FONT_SMALL,
783
+ bg="#000000", fg=TEXT_DARK, anchor="w"
784
+ ).grid(row=0, column=1, sticky="ew")
785
+ self.progress = ttk.Progressbar(bar, mode="indeterminate", length=120)
786
+ self.progress.grid(row=0, column=2, padx=14)
787
+
788
+ # ══════════════════════════════════════════════════════════════════════════
789
+ # ESCANEO
790
+ # ══════════════════════════════════════════════════════════════════════════
791
+ def _refresh_scanners(self):
792
+ self.scanner_list = get_wia_scanners()
793
+ names = [n for n, _ in self.scanner_list]
794
+ self.device_combo["values"] = names or ["(ningún escáner detectado)"]
795
+ if names:
796
+ self.scan_var_device.set(names[0])
797
+ self.status_text.set(f"Escáner encontrado: {names[0]}")
798
+ else:
799
+ self.scan_var_device.set("(ningún escáner detectado)")
800
+ self.status_text.set("No se detectó ningún escáner WIA")
801
+
802
+ def _start_scan(self):
803
+ if not self.scanner_list:
804
+ messagebox.showwarning("Sin escáner",
805
+ "Comprueba que el escáner está encendido y pulsa 'Actualizar lista'.")
806
+ return
807
+ self.status_text.set("Escaneando…")
808
+ self.progress.start(10)
809
+ threading.Thread(target=self._do_scan, daemon=True).start()
810
+
811
+ def _do_scan(self):
812
+ try:
813
+ sel = self.scan_var_device.get()
814
+ did = next((d for n, d in self.scanner_list if n == sel), None)
815
+ img = scan_with_wia(device_id=did,
816
+ dpi=self.scan_var_dpi.get(),
817
+ color_mode=self.scan_var_color.get())
818
+ self.after(0, self._scan_done, img)
819
+ except Exception as e:
820
+ self.after(0, self._scan_error, str(e))
821
+
822
+ def _scan_done(self, img):
823
+ self.progress.stop()
824
+ self.scanned_raw = img
825
+ ts = datetime.now().strftime("%H:%M:%S")
826
+ dpi = self.scan_var_dpi.get()
827
+ mode = self.scan_var_color.get()
828
+ n = self.scan_var_nfotos.get()
829
+
830
+ if self.scan_var_autocrop.get():
831
+ self.status_text.set(f"Detectando {n} foto(s) y recortando…")
832
+ photos, msg = auto_crop_n_photos(img, n_expected=n)
833
+ else:
834
+ photos = [img] + [None] * (n - 1)
835
+ msg = "✓ Escaneo completado"
836
+
837
+ # ── Mejora de imagen (pipeline calidad escáner profesional) ───────────
838
+ do_wb = self.scan_var_enhance_wb.get()
839
+ do_ac = self.scan_var_enhance_ac.get()
840
+ do_sh = self.scan_var_enhance_sh.get()
841
+ do_dn = self.scan_var_enhance_dn.get()
842
+ if do_wb or do_ac or do_sh or do_dn:
843
+ enhanced = []
844
+ for ph in photos:
845
+ if ph is not None:
846
+ enhanced.append(enhance_scanned_photo(
847
+ ph,
848
+ do_white_balance=do_wb,
849
+ do_auto_contrast=do_ac,
850
+ do_sharpen=do_sh,
851
+ do_denoise=do_dn))
852
+ else:
853
+ enhanced.append(None)
854
+ photos = enhanced
855
+
856
+ # Rellenar con None si hay menos fotos que slots
857
+ while len(photos) < n:
858
+ photos.append(None)
859
+
860
+ # Asignar
861
+ for i in range(MAX_PHOTOS):
862
+ self.photos[i] = photos[i] if i < len(photos) else None
863
+
864
+ # Actualizar UI
865
+ for i in range(n):
866
+ img_i = self.photos[i]
867
+ if img_i:
868
+ w, h = img_i.size
869
+ self.info_labels[i].config(
870
+ text=f"{w}×{h}px · {dpi}DPI · {ts}", fg=SUCCESS)
871
+ self.save_btns[i].config(state="normal")
872
+ self.zoom_levels[i] = 1.0
873
+ self._zoom_fit_one(i)
874
+ else:
875
+ self.info_labels[i].config(text="No detectada", fg=WARNING)
876
+ self._draw_placeholder(i)
877
+
878
+ self.scan_history.append(list(self.photos))
879
+ self.history_idx = len(self.scan_history) - 1
880
+ self.status_text.set(msg)
881
+ self.crop_now_btn.config(state="normal")
882
+ self.restore_btn.config(state="normal")
883
+ self.info_general.config(
884
+ text=f"Escaneo {datetime.now().strftime('%H:%M:%S')} · {n} foto(s) · {dpi} DPI")
885
+
886
+ def _scan_error(self, msg):
887
+ self.progress.stop()
888
+ self.status_text.set("Error al escanear")
889
+ messagebox.showerror("Error de escaneo",
890
+ f"No se pudo completar el escaneo:\n\n{msg}\n\n"
891
+ "Comprueba que el escáner está listo.")
892
+
893
+ # ══════════════════════════════════════════════════════════════════════════
894
+ # RECORTE
895
+ # ══════════════════════════════════════════════════════════════════════════
896
+ def _on_crop_toggle(self):
897
+ if not CV2_AVAILABLE and self.scan_var_autocrop.get():
898
+ messagebox.showwarning("OpenCV no instalado",
899
+ "pip install opencv-python numpy\n\nEl recorte se ha desactivado.")
900
+ self.scan_var_autocrop.set(False)
901
+
902
+ def _manual_crop(self):
903
+ if not self.scanned_raw: return
904
+ if not CV2_AVAILABLE:
905
+ messagebox.showwarning("OpenCV no instalado", "pip install opencv-python numpy")
906
+ return
907
+ n = self.scan_var_nfotos.get()
908
+ self.status_text.set("Recortando…")
909
+ def _do():
910
+ photos, msg = auto_crop_n_photos(self.scanned_raw, n_expected=n)
911
+ self.after(0, self._crop_done, photos, msg)
912
+ threading.Thread(target=_do, daemon=True).start()
913
+
914
+ def _crop_done(self, photos, msg):
915
+ # ── Mejora de imagen tras recorte manual ──────────────────────────────
916
+ do_wb = self.scan_var_enhance_wb.get()
917
+ do_ac = self.scan_var_enhance_ac.get()
918
+ do_sh = self.scan_var_enhance_sh.get()
919
+ do_dn = self.scan_var_enhance_dn.get()
920
+ if do_wb or do_ac or do_sh or do_dn:
921
+ photos = [
922
+ enhance_scanned_photo(ph,
923
+ do_white_balance=do_wb,
924
+ do_auto_contrast=do_ac,
925
+ do_sharpen=do_sh,
926
+ do_denoise=do_dn)
927
+ if ph is not None else None
928
+ for ph in photos
929
+ ]
930
+ for i in range(MAX_PHOTOS):
931
+ self.photos[i] = photos[i] if i < len(photos) else None
932
+ n = self.scan_var_nfotos.get()
933
+ for i in range(n):
934
+ img_i = self.photos[i]
935
+ if img_i:
936
+ w, h = img_i.size
937
+ self.info_labels[i].config(text=f"{w}×{h}px (recortada)", fg=SUCCESS)
938
+ self.save_btns[i].config(state="normal")
939
+ self.zoom_levels[i] = 1.0
940
+ self._zoom_fit_one(i)
941
+ else:
942
+ self.info_labels[i].config(text="No detectada", fg=WARNING)
943
+ self._draw_placeholder(i)
944
+ self.status_text.set(msg)
945
+
946
+ def _restore_original(self):
947
+ if not self.scanned_raw: return
948
+ raw = self.scanned_raw
949
+ for i in range(MAX_PHOTOS):
950
+ self.photos[i] = None
951
+ self.photos[0] = raw
952
+ n = self.scan_var_nfotos.get()
953
+ w, h = raw.size
954
+ self.info_labels[0].config(text=f"{w}×{h}px (original)", fg=WARNING)
955
+ self.zoom_levels[0] = 1.0
956
+ self._zoom_fit_one(0)
957
+ for i in range(1, n):
958
+ self.info_labels[i].config(text="Sin imagen", fg=TEXT_GREY)
959
+ self._draw_placeholder(i)
960
+ self.status_text.set("Imagen original restaurada")
961
+
962
+ # ══════════════════════════════════════════════════════════════════════════
963
+ # CANVAS / ZOOM
964
+ # ══════════════════════════════════════════════════════════════════════════
965
+ def _draw_placeholder(self, idx):
966
+ cv = self.canvases[idx]
967
+ if cv is None: return
968
+ cv.delete("all")
969
+ w = cv.winfo_width() or 300
970
+ h = cv.winfo_height() or 200
971
+ cx, cy = w // 2, h // 2
972
+ # Fondo de puntos suaves
973
+ for x in range(0, w, 24):
974
+ for y in range(0, h, 24):
975
+ cv.create_oval(x-1, y-1, x+1, y+1, fill=ACCENT_DIM, outline="")
976
+ cv.create_text(cx, cy - 22, text="⬡",
977
+ font=("Segoe UI", 36), fill=ACCENT_LIGHT)
978
+ cv.create_text(cx, cy + 16,
979
+ text=f"Foto {idx+1} aparecerá aquí",
980
+ font=("Segoe UI", 10), fill=TEXT_DIM)
981
+
982
+ def _on_resize(self, idx):
983
+ if self.photos[idx]:
984
+ self._show_image(idx)
985
+ else:
986
+ self._draw_placeholder(idx)
987
+
988
+ def _show_image(self, idx):
989
+ img = self.photos[idx]
990
+ cv = self.canvases[idx]
991
+ if not img or cv is None: return
992
+ zl = self.zoom_levels[idx]
993
+ ow, oh = img.size
994
+ nw = max(1, int(ow * zl))
995
+ nh = max(1, int(oh * zl))
996
+ resized = img.resize((nw, nh), Image.LANCZOS)
997
+ tk_img = ImageTk.PhotoImage(resized)
998
+ self.tk_images[idx] = tk_img # mantener referencia
999
+ cv.delete("all")
1000
+ cv.config(scrollregion=(0, 0, nw, nh))
1001
+ cw = cv.winfo_width(); ch = cv.winfo_height()
1002
+ x = max(nw // 2, cw // 2); y = max(nh // 2, ch // 2)
1003
+ cv.create_image(x, y, image=tk_img, anchor="center")
1004
+ self.zoom_label.config(text=f"{int(zl*100)}%")
1005
+
1006
+ def _zoom_fit_one(self, idx):
1007
+ img = self.photos[idx]
1008
+ cv = self.canvases[idx]
1009
+ if not img or cv is None: return
1010
+ self.update_idletasks()
1011
+ cw = cv.winfo_width(); ch = cv.winfo_height()
1012
+ iw, ih = img.size
1013
+ if iw and ih and cw > 1 and ch > 1:
1014
+ self.zoom_levels[idx] = min((cw - 10) / iw, (ch - 10) / ih, 1.0)
1015
+ self._show_image(idx)
1016
+
1017
+ def _zoom_fit(self):
1018
+ n = self.scan_var_nfotos.get()
1019
+ for i in range(n):
1020
+ self._zoom_fit_one(i)
1021
+
1022
+ def _zoom_in(self):
1023
+ n = self.scan_var_nfotos.get()
1024
+ for i in range(n):
1025
+ if self.photos[i]:
1026
+ self.zoom_levels[i] = min(self.zoom_levels[i] * 1.25, 8.0)
1027
+ self._show_image(i)
1028
+
1029
+ def _zoom_out(self):
1030
+ n = self.scan_var_nfotos.get()
1031
+ for i in range(n):
1032
+ if self.photos[i]:
1033
+ self.zoom_levels[i] = max(self.zoom_levels[i] / 1.25, 0.05)
1034
+ self._show_image(i)
1035
+
1036
+ def _rotate_all(self, deg):
1037
+ n = self.scan_var_nfotos.get()
1038
+ for i in range(n):
1039
+ if self.photos[i]:
1040
+ self.photos[i] = self.photos[i].rotate(-deg, expand=True)
1041
+ self._show_image(i)
1042
+
1043
+ # ══════════════════════════════════════════════════════════════════════════
1044
+ # HISTORIAL
1045
+ # ══════════════════════════════════════════════════════════════════════════
1046
+ def _history_prev(self):
1047
+ if self.history_idx > 0:
1048
+ self.history_idx -= 1; self._load_history()
1049
+
1050
+ def _history_next(self):
1051
+ if self.history_idx < len(self.scan_history) - 1:
1052
+ self.history_idx += 1; self._load_history()
1053
+
1054
+ def _load_history(self):
1055
+ snap = self.scan_history[self.history_idx]
1056
+ for i in range(MAX_PHOTOS):
1057
+ self.photos[i] = snap[i] if i < len(snap) else None
1058
+ n = self.scan_var_nfotos.get()
1059
+ for i in range(n):
1060
+ if self.photos[i]:
1061
+ self._zoom_fit_one(i)
1062
+ else:
1063
+ self._draw_placeholder(i)
1064
+ self.status_text.set(
1065
+ f"Historial {self.history_idx+1}/{len(self.scan_history)}")
1066
+
1067
+ # ══════════════════════════════════════════════════════════════════════════
1068
+ # GUARDAR
1069
+ # ══════════════════════════════════════════════════════════════════════════
1070
+ def _save_image(self, idx):
1071
+ img = self.photos[idx]
1072
+ if not img:
1073
+ messagebox.showinfo("Sin imagen", f"No hay imagen en Foto {idx+1}.")
1074
+ return
1075
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
1076
+ path = filedialog.asksaveasfilename(
1077
+ defaultextension=".jpg",
1078
+ filetypes=[("JPEG","*.jpg"),("PNG","*.png"),
1079
+ ("PDF","*.pdf"),("TIFF","*.tiff")],
1080
+ initialfile=f"foto{idx+1}_{ts}",
1081
+ title=f"Guardar Foto {idx+1}")
1082
+ if not path: return
1083
+ ext = Path(path).suffix.lower()
1084
+ dpi_val = self.scan_var_dpi.get()
1085
+ if ext in (".jpg",".jpeg"):
1086
+ img.convert("RGB").save(
1087
+ path, "JPEG",
1088
+ quality=98,
1089
+ dpi=(dpi_val, dpi_val),
1090
+ optimize=True,
1091
+ subsampling=0) # 4:4:4 → máxima fidelidad de color
1092
+ elif ext == ".pdf":
1093
+ img.convert("RGB").save(path, "PDF",
1094
+ resolution=dpi_val)
1095
+ elif ext == ".tiff":
1096
+ img.save(path, "TIFF",
1097
+ dpi=(dpi_val, dpi_val),
1098
+ compression="tiff_lzw")
1099
+ else: # PNG
1100
+ img.save(path, "PNG",
1101
+ dpi=(dpi_val, dpi_val))
1102
+ self.status_text.set(f"✓ Foto {idx+1} guardada: {Path(path).name}")
1103
+ messagebox.showinfo("Guardado", f"Foto {idx+1} guardada en:\n{path}")
1104
+
1105
+ # ══════════════════════════════════════════════════════════════════════════
1106
+ # ANIMACIÓN DOT
1107
+ # ══════════════════════════════════════════════════════════════════════════
1108
+ def _animate_dot(self):
1109
+ colors = [BG_MAIN, BORDER, ACCENT_LIGHT]
1110
+ c = getattr(self, "_dot_c", 0)
1111
+ st = self.status_text.get()
1112
+ if "Listo" in st or "✓" in st:
1113
+ self.dot_lbl.config(fg=colors[c % len(colors)])
1114
+ self._dot_c = c + 1
1115
+ self.after(900, self._animate_dot)
1116
+
1117
+
1118
+ # ══════════════════════════════════════════════════════════════════════════════
1119
+ def main():
1120
+ app = EscanApp()
1121
+ app.mainloop()
1122
+
1123
+ if __name__ == "__main__":
1124
+ main()
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "escaner-app" # nombre con el que se instala: pip install escaner-app
7
+ version = "1.0.0"
8
+ description = "Aplicación de escaneo de fotos con auto-recorte"
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "Pillow",
12
+ "pywin32",
13
+ "opencv-python",
14
+ "numpy",
15
+ ]
16
+
17
+ [project.scripts]
18
+ escaner = "escaner.app:main" # comando CMD → función main() en escaner/app.py
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["escaner"]