scan2doc-pro / scan2doc_gui_pro.py
Usabolfazl's picture
Upload scan2doc_gui_pro.py with huggingface_hub
54b97a3 verified
Raw
History Blame Contribute Delete
28.4 kB
#!/usr/bin/env python3
"""
Scan2Doc Pro β€” Professional GUI Application
============================================
Modern CustomTkinter-based desktop application for OCR-to-Word conversion.
Features: image preview, real-time logging, batch processing, quality scoring.
"""
import os
import sys
import threading
import time
import traceback
from pathlib import Path
# ── Headless-safe import for customtkinter ──
os.environ.setdefault("DISPLAY", ":0")
try:
import customtkinter as ctk
except ImportError:
print("ERROR: customtkinter not installed. Run: pip install customtkinter")
sys.exit(1)
# Set theme
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
try:
from PIL import Image, ImageTk
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
# Import OCR engine
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from scan2doc_pro_v6 import BatchProcessor, QualityScorer, PageStatistics
except ImportError:
# Try alternate path
try:
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scan2doc_pro_v6 import BatchProcessor, QualityScorer, PageStatistics
except ImportError:
print("ERROR: Cannot import scan2doc_pro_v5.py")
print("Make sure scan2doc_pro_v5.py is in the same directory as this script.")
sys.exit(1)
# ═══════════════════════════════════════════════════════════════════════════════
# Color Palette
# ═══════════════════════════════════════════════════════════════════════════════
COLORS = {
"bg_dark": "#1a1a2e",
"bg_medium": "#16213e",
"bg_light": "#0f3460",
"accent": "#e94560",
"accent2": "#533483",
"text": "#eaeaea",
"text_dim": "#8899aa",
"success": "#00c853",
"warning": "#ffab00",
"error": "#ff1744",
"sidebar": "#0f0f23",
"card": "#1a1a3e",
"blue": "#2979ff",
"blue_dark": "#1565c0",
}
# ═══════════════════════════════════════════════════════════════════════════════
# File List Item Widget
# ═══════════════════════════════════════════════════════════════════════════════
class FileListItem(ctk.CTkFrame):
"""Individual file item in the file list."""
def __init__(self, master, filepath, on_remove=None, **kwargs):
super().__init__(master, fg_color=COLORS["card"], corner_radius=6, **kwargs)
self.filepath = filepath
self.on_remove = on_remove
self.grid_columnconfigure(0, weight=1)
# Filename label
name = os.path.basename(filepath)
if len(name) > 30:
name = name[:27] + "..."
self.label = ctk.CTkLabel(
self, text=name, anchor="w",
text_color=COLORS["text"], font=ctk.CTkFont(size=11))
self.label.grid(row=0, column=0, padx=(8, 4), pady=4, sticky="ew")
# Remove button
self.remove_btn = ctk.CTkButton(
self, text="βœ•", width=24, height=24,
fg_color="transparent", hover_color=COLORS["error"],
text_color=COLORS["text_dim"], font=ctk.CTkFont(size=12, weight="bold"),
command=self._remove)
self.remove_btn.grid(row=0, column=1, padx=(0, 6), pady=4)
def _remove(self):
if self.on_remove:
self.on_remove(self.filepath)
self.destroy()
# ═══════════════════════════════════════════════════════════════════════════════
# Sidebar Panel
# ═══════════════════════════════════════════════════════════════════════════════
class Sidebar(ctk.CTkFrame):
"""Left sidebar with file management and settings."""
def __init__(self, master, app, **kwargs):
super().__init__(master, fg_color=COLORS["sidebar"], corner_radius=0, width=280, **kwargs)
self.app = app
self.pack_propagate(False)
self.grid_propagate(False)
self.files = []
self._build_ui()
def _build_ui(self):
# ── Logo / Title ──
title_frame = ctk.CTkFrame(self, fg_color="transparent")
title_frame.pack(fill="x", padx=15, pady=(15, 5))
ctk.CTkLabel(
title_frame, text="πŸ“„ Scan2Doc Pro",
font=ctk.CTkFont(size=20, weight="bold"),
text_color=COLORS["blue"]
).pack(anchor="w")
ctk.CTkLabel(
title_frame, text="Professional OCR β†’ Word",
font=ctk.CTkFont(size=11),
text_color=COLORS["text_dim"]
).pack(anchor="w")
# Separator
ctk.CTkFrame(self, fg_color=COLORS["bg_light"], height=1).pack(fill="x", padx=15, pady=10)
# ── File Selection ──
ctk.CTkLabel(self, text="πŸ“ Input Files", font=ctk.CTkFont(size=13, weight="bold"),
text_color=COLORS["text"]).pack(anchor="w", padx=15, pady=(5, 5))
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=15)
ctk.CTkButton(
btn_frame, text="πŸ“‚ Select Folder", height=32,
fg_color=COLORS["blue_dark"], hover_color=COLORS["blue"],
font=ctk.CTkFont(size=12), command=self._select_folder
).pack(side="left", expand=True, fill="x", padx=(0, 4))
ctk.CTkButton(
btn_frame, text="πŸ“Ž Add Files", height=32,
fg_color=COLORS["accent2"], hover_color=COLORS["accent"],
font=ctk.CTkFont(size=12), command=self._select_files
).pack(side="left", expand=True, fill="x", padx=(4, 0))
# ── File List ──
self.file_list_frame = ctk.CTkScrollableFrame(
self, fg_color=COLORS["bg_medium"], corner_radius=8,
height=120)
self.file_list_frame.pack(fill="x", padx=15, pady=(8, 5))
self.file_count_label = ctk.CTkLabel(
self, text="No files selected", font=ctk.CTkFont(size=10),
text_color=COLORS["text_dim"])
self.file_count_label.pack(anchor="w", padx=15)
# Clear all button
ctk.CTkButton(
self, text="πŸ—‘ Clear All", height=24, width=80,
fg_color="transparent", border_width=1, border_color=COLORS["error"],
text_color=COLORS["error"], hover_color="#330000",
font=ctk.CTkFont(size=10), command=self._clear_files
).pack(anchor="e", padx=15, pady=(2, 8))
# Separator
ctk.CTkFrame(self, fg_color=COLORS["bg_light"], height=1).pack(fill="x", padx=15, pady=5)
# ── Settings ──
ctk.CTkLabel(self, text="βš™οΈ Settings", font=ctk.CTkFont(size=13, weight="bold"),
text_color=COLORS["text"]).pack(anchor="w", padx=15, pady=(5, 8))
# DPI
self._setting_label("DPI:")
self.dpi_var = ctk.StringVar(value="300")
ctk.CTkOptionMenu(
self, variable=self.dpi_var,
values=["150", "200", "300", "400", "500", "600"],
fg_color=COLORS["bg_medium"], button_color=COLORS["blue_dark"],
width=120, height=28, font=ctk.CTkFont(size=11)
).pack(anchor="w", padx=15, pady=(0, 6))
# Language
self._setting_label("Language:")
self.lang_var = ctk.StringVar(value="Persian + English")
ctk.CTkOptionMenu(
self, variable=self.lang_var,
values=["Persian + English", "Persian", "English", "Arabic", "All Languages"],
fg_color=COLORS["bg_medium"], button_color=COLORS["blue_dark"],
width=160, height=28, font=ctk.CTkFont(size=11)
).pack(anchor="w", padx=15, pady=(0, 6))
# Title
self._setting_label("Document Title:")
self.title_entry = ctk.CTkEntry(
self, placeholder_text="Optional title...",
fg_color=COLORS["bg_medium"], border_color=COLORS["blue_dark"],
height=28, font=ctk.CTkFont(size=11))
self.title_entry.pack(fill="x", padx=15, pady=(0, 6))
# Checkboxes
self.tables_var = ctk.BooleanVar(value=True)
self.lines_var = ctk.BooleanVar(value=True)
self.images_var = ctk.BooleanVar(value=False)
checks_frame = ctk.CTkFrame(self, fg_color="transparent")
checks_frame.pack(fill="x", padx=15, pady=(0, 4))
ctk.CTkCheckBox(
checks_frame, text="Tables", variable=self.tables_var,
text_color=COLORS["text"], font=ctk.CTkFont(size=11),
fg_color=COLORS["blue_dark"], hover_color=COLORS["blue"],
checkmark_color=COLORS["text"]
).pack(side="left", padx=(0, 8))
ctk.CTkCheckBox(
checks_frame, text="Lines", variable=self.lines_var,
text_color=COLORS["text"], font=ctk.CTkFont(size=11),
fg_color=COLORS["blue_dark"], hover_color=COLORS["blue"],
checkmark_color=COLORS["text"]
).pack(side="left", padx=(0, 8))
ctk.CTkCheckBox(
checks_frame, text="Images", variable=self.images_var,
text_color=COLORS["text"], font=ctk.CTkFont(size=11),
fg_color=COLORS["blue_dark"], hover_color=COLORS["blue"],
checkmark_color=COLORS["text"]
).pack(side="left")
# Separator
ctk.CTkFrame(self, fg_color=COLORS["bg_light"], height=1).pack(fill="x", padx=15, pady=8)
# ── Output ──
ctk.CTkLabel(self, text="πŸ’Ύ Output", font=ctk.CTkFont(size=13, weight="bold"),
text_color=COLORS["text"]).pack(anchor="w", padx=15, pady=(0, 5))
self.output_var = ctk.StringVar(value="output.docx")
self.output_entry = ctk.CTkEntry(
self, textvariable=self.output_var,
fg_color=COLORS["bg_medium"], border_color=COLORS["blue_dark"],
height=28, font=ctk.CTkFont(size=11))
self.output_entry.pack(fill="x", padx=15, pady=(0, 4))
ctk.CTkButton(
self, text="πŸ“‚ Browse...", height=26,
fg_color=COLORS["bg_medium"], border_width=1,
border_color=COLORS["blue_dark"], hover_color=COLORS["blue_dark"],
font=ctk.CTkFont(size=11), command=self._select_output
).pack(fill="x", padx=15, pady=(0, 10))
# ── Start Button ──
self.start_btn = ctk.CTkButton(
self, text="β–Ά START PROCESSING", height=44,
fg_color=COLORS["blue"], hover_color=COLORS["blue_dark"],
font=ctk.CTkFont(size=15, weight="bold"),
corner_radius=10, command=self._start_processing)
self.start_btn.pack(fill="x", padx=15, pady=(0, 15))
def _setting_label(self, text):
ctk.CTkLabel(self, text=text, font=ctk.CTkFont(size=11),
text_color=COLORS["text_dim"]).pack(anchor="w", padx=15, pady=(0, 2))
def _select_folder(self):
try:
from tkinter import filedialog
folder = filedialog.askdirectory(title="Select Image Folder")
if folder:
exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.webp', '.gif'}
files = []
for f in sorted(Path(folder).iterdir()):
if f.suffix.lower() in exts:
files.append(str(f))
if files:
self.files.extend(files)
self._refresh_file_list()
self.app.log(f"Added {len(files)} files from folder")
else:
self.app.log("No image files found in selected folder", "warning")
except Exception as e:
self.app.log(f"Error selecting folder: {e}", "error")
def _select_files(self):
try:
from tkinter import filedialog
files = filedialog.askopenfilenames(
title="Select Image Files",
filetypes=[
("Image files", "*.jpg *.jpeg *.png *.bmp *.tiff *.tif *.webp *.gif"),
("All files", "*.*")
])
if files:
self.files.extend(list(files))
self._refresh_file_list()
self.app.log(f"Added {len(files)} file(s)")
except Exception as e:
self.app.log(f"Error selecting files: {e}", "error")
def _select_output(self):
try:
from tkinter import filedialog
path = filedialog.asksaveasfilename(
title="Save Output As",
defaultextension=".docx",
filetypes=[("Word Document", "*.docx")])
if path:
self.output_var.set(path)
except Exception as e:
self.app.log(f"Error: {e}", "error")
def _clear_files(self):
self.files.clear()
self._refresh_file_list()
self.app.log("File list cleared")
def _remove_file(self, filepath):
if filepath in self.files:
self.files.remove(filepath)
self._refresh_file_list()
def _refresh_file_list(self):
# Clear existing items
for widget in self.file_list_frame.winfo_children():
widget.destroy()
for fp in self.files:
item = FileListItem(self.file_list_frame, fp, on_remove=self._remove_file)
item.pack(fill="x", padx=2, pady=2)
count = len(self.files)
self.file_count_label.configure(
text=f"{count} file(s) selected" if count > 0 else "No files selected")
# Auto-set output filename
if count == 1:
stem = Path(self.files[0]).stem
self.output_var.set(f"{stem}.docx")
elif count > 1:
parent = os.path.basename(os.path.dirname(self.files[0]))
if parent:
self.output_var.set(f"{parent}.docx")
def get_settings(self):
"""Return current settings as a dict."""
lang_map = {
"Persian + English": "fas+eng",
"Persian": "fas",
"English": "eng",
"Arabic": "ara",
"All Languages": "fas+ara+eng"
}
return {
"files": self.files[:],
"dpi": int(self.dpi_var.get()),
"lang": lang_map.get(self.lang_var.get(), "fas+eng"),
"title": self.title_entry.get().strip() or None,
"output": self.output_var.get().strip() or "output.docx",
"tables": self.tables_var.get(),
"lines": self.lines_var.get(),
"images": self.images_var.get(),
}
def set_processing_state(self, processing):
"""Enable/disable UI during processing."""
state = "disabled" if processing else "normal"
self.start_btn.configure(
state=state,
text="⏳ Processing..." if processing else "β–Ά START PROCESSING")
# ═══════════════════════════════════════════════════════════════════════════════
# Main Content Area
# ═══════════════════════════════════════════════════════════════════════════════
class MainArea(ctk.CTkFrame):
"""Right main area with preview, log, and progress."""
def __init__(self, master, app, **kwargs):
super().__init__(master, fg_color=COLORS["bg_dark"], corner_radius=0, **kwargs)
self.app = app
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1)
self._build_ui()
def _build_ui(self):
# ── Preview Panel ──
preview_label = ctk.CTkLabel(
self, text="πŸ–ΌοΈ Preview", font=ctk.CTkFont(size=13, weight="bold"),
text_color=COLORS["text"])
preview_label.grid(row=0, column=0, padx=15, pady=(10, 5), sticky="w")
self.preview_frame = ctk.CTkFrame(
self, fg_color=COLORS["bg_medium"], corner_radius=10,
height=250)
self.preview_frame.grid(row=0, column=0, padx=15, pady=(30, 5), sticky="nsew")
self.preview_frame.grid_propagate(False)
self.preview_label = ctk.CTkLabel(
self.preview_frame, text="Select files to preview",
text_color=COLORS["text_dim"], font=ctk.CTkFont(size=12))
self.preview_label.pack(expand=True)
# ── Processing Log ──
log_label = ctk.CTkLabel(
self, text="πŸ“‹ Processing Log", font=ctk.CTkFont(size=13, weight="bold"),
text_color=COLORS["text"])
log_label.grid(row=1, column=0, padx=15, pady=(10, 5), sticky="nw")
self.log_text = ctk.CTkTextbox(
self, fg_color=COLORS["bg_medium"], text_color=COLORS["text"],
font=ctk.CTkFont(family="Consolas", size=11),
corner_radius=10, wrap="word")
self.log_text.grid(row=1, column=0, padx=15, pady=(30, 5), sticky="nsew")
self.log_text.configure(state="disabled")
# ── Footer: Progress + Status ──
footer_frame = ctk.CTkFrame(self, fg_color=COLORS["bg_medium"], corner_radius=10)
footer_frame.grid(row=2, column=0, padx=15, pady=(5, 10), sticky="ew")
footer_frame.grid_columnconfigure(0, weight=1)
# Progress bar
self.progress_bar = ctk.CTkProgressBar(
footer_frame, progress_color=COLORS["blue"],
fg_color=COLORS["bg_dark"], height=12)
self.progress_bar.grid(row=0, column=0, padx=10, pady=(8, 4), sticky="ew")
self.progress_bar.set(0)
# Status labels
status_frame = ctk.CTkFrame(footer_frame, fg_color="transparent")
status_frame.grid(row=1, column=0, padx=10, pady=(0, 8), sticky="ew")
status_frame.grid_columnconfigure(0, weight=1)
status_frame.grid_columnconfigure(1, weight=1)
status_frame.grid_columnconfigure(2, weight=1)
self.progress_label = ctk.CTkLabel(
status_frame, text="0%", font=ctk.CTkFont(size=12, weight="bold"),
text_color=COLORS["blue"])
self.progress_label.grid(row=0, column=0, sticky="w")
self.page_label = ctk.CTkLabel(
status_frame, text="0 / 0 pages", font=ctk.CTkFont(size=11),
text_color=COLORS["text_dim"])
self.page_label.grid(row=0, column=1, sticky="w")
self.quality_label = ctk.CTkLabel(
status_frame, text="Quality: --", font=ctk.CTkFont(size=11, weight="bold"),
text_color=COLORS["text_dim"])
self.quality_label.grid(row=0, column=2, sticky="e")
def log(self, message, level="info"):
"""Thread-safe log message."""
prefix = {"info": "ℹ️", "warning": "⚠️", "error": "❌", "success": "βœ…"}.get(level, "ℹ️")
color_map = {"info": COLORS["text"], "warning": COLORS["warning"],
"error": COLORS["error"], "success": COLORS["success"]}
timestamp = time.strftime("%H:%M:%S")
line = f"[{timestamp}] {prefix} {message}\n"
def _append():
self.log_text.configure(state="normal")
self.log_text.insert("end", line)
self.log_text.see("end")
self.log_text.configure(state="disabled")
try:
self.after(0, _append)
except Exception:
pass
def update_preview(self, filepath):
"""Update the preview panel with an image."""
if not PIL_AVAILABLE:
return
def _update():
try:
for widget in self.preview_frame.winfo_children():
widget.destroy()
img = Image.open(filepath)
# Calculate fit size
frame_w = self.preview_frame.winfo_width() - 20
frame_h = self.preview_frame.winfo_height() - 20
if frame_w < 10: frame_w = 400
if frame_h < 10: frame_h = 200
img.thumbnail((frame_w, frame_h), Image.Resampling.LANCZOS)
photo = ImageTk.PhotoImage(img)
label = ctk.CTkLabel(self.preview_frame, image=photo, text="")
label.image = photo # Keep reference
label.pack(expand=True)
except Exception as e:
self.log(f"Preview error: {e}", "warning")
try:
self.after(0, _update)
except Exception:
pass
def update_progress(self, current, total, message=""):
"""Update progress bar and labels."""
def _update():
pct = (current / total * 100) if total > 0 else 0
self.progress_bar.set(current / total if total > 0 else 0)
self.progress_label.configure(text=f"{pct:.0f}%")
self.page_label.configure(text=f"{current} / {total} pages")
try:
self.after(0, _update)
except Exception:
pass
def update_quality(self, score):
"""Update quality score display."""
def _update():
if score >= 9:
color = COLORS["success"]
elif score >= 7:
color = COLORS["blue"]
elif score >= 5:
color = COLORS["warning"]
else:
color = COLORS["error"]
self.quality_label.configure(text=f"Quality: {score}/10", text_color=color)
try:
self.after(0, _update)
except Exception:
pass
def reset(self):
"""Reset progress display."""
self.progress_bar.set(0)
self.progress_label.configure(text="0%", text_color=COLORS["blue"])
self.page_label.configure(text="0 / 0 pages")
self.quality_label.configure(text="Quality: --", text_color=COLORS["text_dim"])
# ═══════════════════════════════════════════════════════════════════════════════
# Main Application Window
# ═══════════════════════════════════════════════════════════════════════════════
class Scan2DocApp(ctk.CTk):
"""Main application window."""
def __init__(self):
super().__init__()
self.title("Scan2Doc Pro β€” Professional OCR to Word")
self.geometry("1280x800")
self.minsize(900, 600)
self.configure(fg_color=COLORS["bg_dark"])
self.processing = False
self._build_layout()
def _build_layout(self):
# Grid layout: sidebar (fixed) + main area (expandable)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
# Sidebar
self.sidebar = Sidebar(self, self)
self.sidebar.grid(row=0, column=0, sticky="ns")
# Main area
self.main_area = MainArea(self, self)
self.main_area.grid(row=0, column=1, sticky="nsew")
def log(self, message, level="info"):
"""Log a message to the processing log."""
self.main_area.log(message, level)
def _start_processing(self):
"""Start OCR processing in a background thread."""
settings = self.sidebar.get_settings()
if not settings["files"]:
self.log("No files selected! Please add images first.", "error")
return
if self.processing:
return
self.processing = True
self.sidebar.set_processing_state(True)
self.main_area.reset()
# Show first image preview
if settings["files"]:
self.main_area.update_preview(settings["files"][0])
thread = threading.Thread(target=self._process_worker, args=(settings,), daemon=True)
thread.start()
def _process_worker(self, settings):
"""Background processing worker."""
try:
self.log(f"Starting processing: {len(settings['files'])} file(s)", "info")
self.log(f"Settings: DPI={settings['dpi']}, Lang={settings['lang']}", "info")
t0 = time.time()
def progress_callback(current, total, msg):
self.main_area.update_progress(current, total, msg)
self.log(f"Page {current}/{total}: {msg}", "info")
processor = BatchProcessor(
lang=settings["lang"],
dpi=settings["dpi"],
title=settings["title"],
progress_callback=progress_callback
)
# Suppress print output, capture via log
import io
old_stdout = sys.stdout
sys.stdout = io.StringIO()
ok = processor.process_batch(
settings["files"],
settings["output"],
title=settings["title"]
)
sys.stdout = old_stdout
# Calculate quality score
if processor.stats:
ac = sum(s.overall_confidence for s in processor.stats) / len(processor.stats)
tb = sum(s.text_blocks for s in processor.stats)
tt = sum(s.tables for s in processor.stats)
tl = sum(s.lines for s in processor.stats)
tw = sum(s.total_words for s in processor.stats)
lc = sum(s.low_confidence_words for s in processor.stats)
agg_stats = {
'tables_found': tt, 'confidence': ac, 'blocks': tb,
'low_conf_words': lc, 'lines_found': tl,
'columns': max((s.columns for s in processor.stats), default=1),
'word_count': tw,
}
quality = QualityScorer.calculate(agg_stats)
self.main_area.update_quality(quality)
self.log(f"Quality Score: {quality}/10", "success")
self.log(f"Blocks: {tb}, Tables: {tt}, Words: {tw}, Confidence: {ac:.1f}%", "info")
elapsed = time.time() - t0
if ok:
self.log(f"Done! Output saved to: {settings['output']}", "success")
self.log(f"Total time: {elapsed:.1f}s", "info")
self.main_area.update_progress(len(settings["files"]), len(settings["files"]))
else:
self.log("Processing failed. Check the log for errors.", "error")
except Exception as e:
self.log(f"Processing error: {e}", "error")
self.log(traceback.format_exc(), "error")
sys.stdout = sys.__stdout__
finally:
self.processing = False
try:
self.after(0, lambda: self.sidebar.set_processing_state(False))
except Exception:
pass
# ═══════════════════════════════════════════════════════════════════════════════
# Entry Point
# ═══════════════════════════════════════════════════════════════════════════════
def main():
"""Launch the application."""
app = Scan2DocApp()
app.mainloop()
if __name__ == "__main__":
main()