-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgui.py
More file actions
328 lines (263 loc) · 16.7 KB
/
gui.py
File metadata and controls
328 lines (263 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
import tkinter as tk # Libreria per l'interfaccia grafica (GUI)
from tkinter import filedialog, messagebox # Moduli specifici di Tkinter per dialoghi
from PIL import Image, ImageTk # Libreria Pillow per manipolare immagini e integrarle in Tkinter
import cv2 # Libreria OpenCV per l'elaborazione avanzata di immagini
import numpy as np # Libreria NumPy per calcoli numerici e array multidimensionali
import os # Modulo per interagire con il sistema operativo (non usato attivamente ma buona pratica)
import webbrowser # Modulo per aprire il browser web
import sys # Modulo per interagire con l'interprete Python (non usato attivamente)
# Tenta di importare pdf2image. Se manca, l'app funzionerà solo con immagini.
try:
from pdf2image import convert_from_path
PDF_SUPPORT = True
except ImportError:
PDF_SUPPORT = False
print("Attenzione: libreria pdf2image non trovata o Poppler mancante. Supporto PDF disabilitato.")
# Classe principale dell'applicazione
class DocumentScannerApp:
def __init__(self, root):
self.root = root
self.root.title("Scanner & Raddrizza Documenti Python")
self.root.geometry("1000x800") # Risoluzione iniziale finestra
# Variabili di stato
self.filepath = None # Percorso del file caricato
self.original_image_cv = None # Immagine originale in formato OpenCV
self.display_image_pil = None # Immagine ridimensionata per display
self.points = [] # Punti selezionati (x, y)
self.scale_factor = 1.0 # Rapporto tra immagine originale e display
# --- Layout Interfaccia ---
# Frame controlli in alto
control_frame = tk.Frame(root, bg="#f0f0f0", pady=10)
control_frame.pack(side=tk.TOP, fill=tk.X)
btn_load = tk.Button(control_frame, text="Carica File (IMG/PDF)", command=self.load_image, bg="#4CAF50", fg="white", font=("Arial", 11, "bold"))
btn_load.pack(side=tk.LEFT, padx=10)
btn_reset = tk.Button(control_frame, text="Resetta Punti", command=self.reset_points, bg="#FF9800", fg="white", font=("Arial", 11))
btn_reset.pack(side=tk.LEFT, padx=10)
btn_process = tk.Button(control_frame, text="Raddrizza Immagine", command=self.process_image, bg="#2196F3", fg="white", font=("Arial", 11, "bold"))
btn_process.pack(side=tk.LEFT, padx=10)
self.lbl_info = tk.Label(control_frame, text="Carica un file per iniziare", bg="#f0f0f0", font=("Arial", 10))
self.lbl_info.pack(side=tk.LEFT, padx=20)
# Area Canvas per mostrare l'immagine
self.canvas_frame = tk.Frame(root, bg="gray")
self.canvas_frame.pack(fill=tk.BOTH, expand=True)
self.canvas = tk.Canvas(self.canvas_frame, bg="gray", cursor="cross")
self.canvas.pack(fill=tk.BOTH, expand=True)
# Associa l'evento del click sinistro del mouse sul canvas alla funzione on_canvas_click
self.canvas.bind("<Button-1>", self.on_canvas_click)
# --- Status Bar / Credits ---
status_frame = tk.Frame(root, bd=1, relief=tk.SUNKEN)
status_frame.pack(side=tk.BOTTOM, fill=tk.X)
credit_text = "Creata tramite Google Gemini da Daniele Lozzi - "
tk.Label(status_frame, text=credit_text, anchor='w').pack(side=tk.LEFT, padx=(5, 0))
link_label = tk.Label(status_frame, text="github.com/danielelozzi", fg="blue", cursor="hand2", anchor='w')
link_label.pack(side=tk.LEFT)
link_label.bind("<Button-1>", lambda e: self.open_link("http://www.github.com/danielelozzi"))
def load_image(self):
"""Apre il file dialog e carica l'immagine o il PDF."""
filetypes = [("Immagini", "*.jpg *.jpeg *.png *.bmp")]
if PDF_SUPPORT:
filetypes.append(("PDF Files", "*.pdf"))
path = filedialog.askopenfilename(title="Seleziona file", filetypes=filetypes)
# Se l'utente non seleziona nessun file, esce dalla funzione
if not path:
return
# Salva il percorso del file e resetta i punti precedenti
self.filepath = path
self.reset_points()
# Blocco try-except per gestire errori durante il caricamento del file
try:
# Gestione caricamento
if path.lower().endswith('.pdf'):
if not PDF_SUPPORT:
messagebox.showerror("Errore", "Supporto PDF non disponibile. Installa pdf2image e poppler.")
return
# Converte la prima pagina del PDF in immagine
pages = convert_from_path(path, first_page=1, last_page=1, poppler_path=None) # Aggiungere poppler_path se non è nel PATH di sistema
if pages:
# Converti da PIL a formato OpenCV (numpy array)
pil_image = pages[0].convert('RGB')
open_cv_image = np.array(pil_image)
# Converte lo spazio colore da RGB (Pillow) a BGR (OpenCV)
self.original_image_cv = open_cv_image[:, :, ::-1].copy()
else:
messagebox.showerror("Errore", "Impossibile leggere il PDF.")
return
else:
# Caricamento immagine standard con OpenCV
self.original_image_cv = cv2.imread(path)
# Se l'immagine non è stata caricata correttamente, mostra un errore
if self.original_image_cv is None:
messagebox.showerror("Errore", "Impossibile caricare l'immagine.")
return
# Mostra l'immagine sul canvas e aggiorna le istruzioni per l'utente
self.display_image()
self.lbl_info.config(text="Clicca sui 4 angoli del documento (ordine: Alto-SX, Alto-DX, Basso-DX, Basso-SX)")
except Exception as e:
messagebox.showerror("Errore", f"Errore durante il caricamento: {e}")
def display_image(self):
"""Ridimensiona l'immagine per adattarla alla finestra e la mostra sul canvas."""
if self.original_image_cv is None:
return
# Ottieni dimensioni canvas e immagine
canvas_width = self.canvas.winfo_width() # Larghezza attuale del widget canvas
canvas_height = self.canvas.winfo_height() # Altezza attuale del widget canvas
# Se il canvas non è ancora stato disegnato, le sue dimensioni sono 1. Usiamo valori di fallback.
if canvas_width < 100: canvas_width = 800
if canvas_height < 100: canvas_height = 600
h, w = self.original_image_cv.shape[:2] # Dimensioni dell'immagine originale
# Calcola il fattore di scala per adattare l'immagine al canvas mantenendo le proporzioni
scale_w = canvas_width / w # Rapporto di scala basato sulla larghezza
scale_h = canvas_height / h # Rapporto di scala basato sull'altezza
self.scale_factor = min(scale_w, scale_h) * 0.95 # Scegli il rapporto minore per far stare l'immagine e aggiungi un margine del 5%
# Calcola le nuove dimensioni dell'immagine da visualizzare
new_w = int(w * self.scale_factor)
new_h = int(h * self.scale_factor)
# Ridimensiona l'immagine originale usando OpenCV per la visualizzazione
resized_cv = cv2.resize(self.original_image_cv, (new_w, new_h))
# Converte l'immagine da BGR (standard OpenCV) a RGB (standard Pillow/Tkinter)
resized_rgb = cv2.cvtColor(resized_cv, cv2.COLOR_BGR2RGB)
# Crea un oggetto immagine compatibile con Tkinter
self.display_image_pil = ImageTk.PhotoImage(Image.fromarray(resized_rgb))
# Pulisce il canvas da disegni precedenti e mostra la nuova immagine al centro
self.canvas.delete("all")
self.canvas.create_image(canvas_width//2, canvas_height//2, anchor=tk.CENTER, image=self.display_image_pil)
# Calcola e salva l'offset (spazio vuoto) tra il bordo del canvas e l'immagine.
# Questo è fondamentale per tradurre le coordinate del click da "coordinate canvas" a "coordinate immagine".
self.img_offset_x = (canvas_width - new_w) // 2
self.img_offset_y = (canvas_height - new_h) // 2
def on_canvas_click(self, event):
"""Gestisce il click dell'utente per selezionare i punti."""
if self.original_image_cv is None:
return
if len(self.points) >= 4:
messagebox.showinfo("Info", "Hai già selezionato 4 punti. Premi 'Raddrizza Immagine' o 'Resetta'.")
return
# Ottiene le coordinate (x, y) del click rispetto all'angolo in alto a sinistra del canvas
x, y = event.x, event.y
# Disegna un feedback visivo sul canvas per mostrare dove l'utente ha cliccato
r = 5
self.canvas.create_oval(x-r, y-r, x+r, y+r, fill="red", outline="yellow", width=2) # Cerchio
self.canvas.create_text(x, y-15, text=str(len(self.points)+1), fill="yellow", font=("Arial", 12, "bold")) # Numero del punto
# Traduce le coordinate del click (relative al canvas) in coordinate reali (relative all'immagine originale)
# 1. Sottrae l'offset per ottenere le coordinate relative all'angolo dell'immagine visualizzata.
# 2. Divide per il fattore di scala per riportare le coordinate alla dimensione dell'immagine originale.
real_x = int((x - self.img_offset_x) / self.scale_factor)
real_y = int((y - self.img_offset_y) / self.scale_factor)
# Aggiunge il punto (in coordinate reali) alla lista dei punti
self.points.append((real_x, real_y))
# Se è stato selezionato più di un punto, disegna una linea di connessione per dare un feedback visivo della selezione
if len(self.points) > 1:
prev_pt = self.points[-2] # Punto precedente
# Riconverti le coordinate reali del punto precedente in coordinate del canvas per poter disegnare la linea
prev_canvas_x = int(prev_pt[0] * self.scale_factor) + self.img_offset_x
prev_canvas_y = int(prev_pt[1] * self.scale_factor) + self.img_offset_y
self.canvas.create_line(prev_canvas_x, prev_canvas_y, x, y, fill="red", width=2)
def reset_points(self):
"""Pulisce i punti e ridisegna l'immagine pulita."""
self.points = []
self.display_image()
self.lbl_info.config(text="Punti resettati. Seleziona nuovamente 4 angoli.")
def order_points(self, pts):
"""
Ordina un array di 4 punti in un ordine specifico e prevedibile:
[top-left, top-right, bottom-right, bottom-left].
Questo è un passaggio cruciale per la trasformazione prospettica.
"""
# Inizializza un array numpy 4x2 per contenere i punti ordinati
rect = np.zeros((4, 2), dtype="float32")
# Calcola la somma di x e y per ogni punto.
# Il punto top-left avrà la somma più piccola (x+y).
# Il punto bottom-right avrà la somma più grande (x+y).
s = pts.sum(axis=1)
rect[0] = pts[np.argmin(s)] # top-left
rect[2] = pts[np.argmax(s)] # bottom-right
# Calcola la differenza tra y e x per ogni punto.
# Il punto top-right avrà la differenza (y-x) più piccola (potenzialmente negativa).
# Il punto bottom-left avrà la differenza (y-x) più grande.
diff = np.diff(pts, axis=1) # Calcola y - x
rect[1] = pts[np.argmin(diff)] # top-right
rect[3] = pts[np.argmax(diff)] # bottom-left
return rect
def process_image(self):
"""Esegue il calcolo della matrice e il warping."""
if len(self.points) != 4:
messagebox.showwarning("Attenzione", f"Hai selezionato solo {len(self.points)} punti. Ne servono 4.")
return
try:
# Converte la lista di punti in un array NumPy per i calcoli
pts = np.array(self.points, dtype="float32")
# 1. Ordina i punti nell'ordine [top-left, top-right, bottom-right, bottom-left]
rect = self.order_points(pts)
(tl, tr, br, bl) = rect
# 2. Calcola la larghezza della nuova immagine raddrizzata.
# Sarà la distanza euclidea massima tra i punti orizzontali (bordo inferiore e bordo superiore).
widthA = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2)) # Distanza tra bottom-right e bottom-left
widthB = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2)) # Distanza tra top-right e top-left
maxWidth = max(int(widthA), int(widthB)) # La larghezza finale sarà la maggiore delle due
# 3. Calcola l'altezza della nuova immagine raddrizzata.
# Sarà la distanza euclidea massima tra i punti verticali (bordo destro e bordo sinistro).
heightA = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2)) # Distanza tra top-right e bottom-right
heightB = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2)) # Distanza tra top-left e bottom-left
maxHeight = max(int(heightA), int(heightB)) # L'altezza finale sarà la maggiore delle due
# 4. Definisce le coordinate di destinazione della trasformazione.
# Creiamo un rettangolo perfetto con le dimensioni calcolate, partendo dall'origine (0,0).
dst = np.array([
[0, 0], # Nuovo top-left
[maxWidth - 1, 0], # Nuovo top-right
[maxWidth - 1, maxHeight - 1], # Nuovo bottom-right
[0, maxHeight - 1]], # Nuovo bottom-left
dtype="float32")
# 5. Calcola la matrice di trasformazione prospettica 3x3 (M).
# Questa matrice mappa i punti del rettangolo di origine (rect) ai punti del rettangolo di destinazione (dst).
M = cv2.getPerspectiveTransform(rect, dst)
# 6. Applica la matrice di trasformazione all'immagine originale per ottenere l'immagine "raddrizzata".
warped = cv2.warpPerspective(self.original_image_cv, M, (maxWidth, maxHeight))
# 7. Mostra il risultato in una nuova finestra
self.show_result_window(warped)
except Exception as e:
messagebox.showerror("Errore", f"Errore durante l'elaborazione: {e}")
def show_result_window(self, img_cv):
"""Apre una finestra popup con il risultato."""
# Crea una nuova finestra "Toplevel" (una finestra figlia della principale)
top = tk.Toplevel(self.root)
top.title("Risultato")
top.geometry("800x600")
# Prepara l'immagine raddrizzata per la visualizzazione nell'anteprima
h, w = img_cv.shape[:2]
# Ridimensiona l'immagine per adattarla alla finestra di anteprima, mantenendo le proporzioni
disp_h = 550
scale = disp_h / h
disp_w = int(w * scale)
preview = cv2.resize(img_cv, (disp_w, disp_h))
preview_rgb = cv2.cvtColor(preview, cv2.COLOR_BGR2RGB) # Converte in RGB per Tkinter
img_tk = ImageTk.PhotoImage(Image.fromarray(preview_rgb)) # Crea l'oggetto immagine per Tkinter
lbl_preview = tk.Label(top, image=img_tk)
lbl_preview.image = img_tk # Mantiene un riferimento all'immagine per evitare che venga eliminata dal garbage collector di Python
lbl_preview.pack(pady=10)
# Funzione interna per gestire il salvataggio del file
def save_result():
# Apre un dialogo "Salva con nome"
save_path = filedialog.asksaveasfilename(defaultextension=".jpg",
filetypes=[("JPEG", "*.jpg"), ("PNG", "*.png"), ("PDF", "*.pdf")])
if save_path:
# Se il formato scelto è PDF, usa Pillow per salvare
if save_path.lower().endswith(".pdf"):
# Converte l'immagine finale (non l'anteprima) da OpenCV a Pillow
img_pil = Image.fromarray(cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB))
# Salva come PDF
img_pil.save(save_path, "PDF", resolution=100.0)
else:
# Altrimenti, salva come immagine usando OpenCV
cv2.imwrite(save_path, img_cv)
messagebox.showinfo("Salvato", "Immagine salvata con successo!")
top.destroy() # Chiude la finestra di anteprima dopo il salvataggio
btn_save = tk.Button(top, text="Salva Risultato", command=save_result, bg="#4CAF50", fg="white", font=("Arial", 12))
btn_save.pack(side=tk.BOTTOM, pady=10)
def open_link(self, url):
"""Apre un URL nel browser web predefinito."""
webbrowser.open_new_tab(url)
# Questo blocco viene eseguito solo se lo script è lanciato direttamente (non importato)
if __name__ == "__main__":
root = tk.Tk()
app = DocumentScannerApp(root)
root.mainloop()