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.
|
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"]
|