SnapToPano / main.py
HirCoir's picture
Upload 8 files
ade108c verified
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageTk
import cv2
import numpy as np
import os
import threading
# Desactivar OpenCL en OpenCV para evitar posibles errores
cv2.ocl.setUseOpenCL(False)
class PanoramaApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("Generador de Panorama")
self.geometry("950x650") # Aumentar un poco el tamaño inicial
self.minsize(700, 500) # Aumentar el tamaño mínimo
# Configurar grid para que todos los elementos crezcan con la ventana
self.grid_columnconfigure(0, weight=1) # Columna para el marco principal
self.grid_rowconfigure(0, weight=0) # barra superior
self.grid_rowconfigure(1, weight=1) # área de imagen
# --------- Barra superior con controles ---------
toolbar = ttk.Frame(self)
toolbar.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
# Configurar columnas de la toolbar
toolbar.grid_columnconfigure(0, weight=1) # Botón seleccionar
toolbar.grid_columnconfigure(1, weight=1) # Contador imágenes
toolbar.grid_columnconfigure(2, weight=1) # Label Modo
toolbar.grid_columnconfigure(3, weight=1) # Combobox Modo
toolbar.grid_columnconfigure(4, weight=1) # Botón Crear
self.btn_select = ttk.Button(toolbar, text="Seleccionar Imágenes", command=self.seleccionar_imagenes)
self.btn_select.grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.lbl_count = ttk.Label(toolbar, text="0 imágenes seleccionadas")
self.lbl_count.grid(row=0, column=1, padx=5, pady=5, sticky="w")
# --- Nuevo: Selector de Modo de Stitching ---
self.lbl_mode = ttk.Label(toolbar, text="Modo de Unión:")
self.lbl_mode.grid(row=0, column=2, padx=(20,2), pady=5, sticky="e")
self.stitching_mode_var = tk.StringVar(self)
# Opciones disponibles y valor por defecto
self.stitching_modes = {
"Panorama (giro fijo)": "panorama",
"Escaneo (ángulos varios)": "scans"
}
# Obtener las claves para mostrar en el combobox
mode_display_names = list(self.stitching_modes.keys())
self.stitching_mode_var.set(mode_display_names[0]) # Por defecto "Panorama (giro fijo)"
self.mode_combobox = ttk.Combobox(toolbar,
textvariable=self.stitching_mode_var,
values=mode_display_names,
state="readonly", # No permitir escribir
width=20)
self.mode_combobox.grid(row=0, column=3, padx=5, pady=5, sticky="w")
# ---------------------------------------------
self.btn_stitch = ttk.Button(toolbar, text="Crear Panorama", command=self.crear_panorama, state="disabled")
self.btn_stitch.grid(row=0, column=4, padx=5, pady=5, sticky="e")
# --------- Área para mostrar el resultado ---------
display_frame = ttk.Frame(self, relief="sunken")
display_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
display_frame.grid_rowconfigure(0, weight=1)
display_frame.grid_columnconfigure(0, weight=1)
self.canvas = tk.Canvas(display_frame, bg="#333333")
self.canvas.grid(row=0, column=0, sticky="nsew")
# --------- Menú contextual para el canvas ---------
self.context_menu = tk.Menu(self.canvas, tearoff=0)
self.context_menu.add_command(label="Guardar Panorama...", command=self.guardar_panorama)
# Bind events
self.canvas.bind("<Configure>", self._on_canvas_resize)
self.canvas.bind("<Button-3>", self._show_context_menu) # Clic derecho
# Variables internas
self.rutas_imagenes = []
self.panorama_tk = None
self.final_panorama_img_np = None
self.stitch_thread = None
def seleccionar_imagenes(self):
# Si hay un proceso de stitching corriendo, no permitir seleccionar nuevas imágenes
if self.stitch_thread and self.stitch_thread.is_alive():
messagebox.showwarning("Proceso en curso", "Por favor, espera a que termine el proceso actual.")
return
rutas = filedialog.askopenfilenames(
title="Selecciona las imágenes",
filetypes=[("Imágenes JPEG/PNG", "*.jpg *.jpeg *.png"), ("Todos los archivos", "*.*")]
)
if not rutas:
return
self.rutas_imagenes = list(rutas)
count = len(self.rutas_imagenes)
self.lbl_count.config(text=f"{count} imagen(es) seleccionada(s)")
# Limpiar el canvas y variables de imagen anterior
self.canvas.delete("all")
self.panorama_tk = None
self.final_panorama_img_np = None
# Habilitar el botón de crear panorama solo si hay al menos 2 imágenes
if count >= 2:
self.btn_stitch.config(state="normal")
else:
self.btn_stitch.config(state="disabled")
def crear_panorama(self):
if len(self.rutas_imagenes) < 2:
messagebox.showwarning("Atención", "Selecciona al menos 2 imágenes para crear un panorama.")
return
# Deshabilitar botones y mostrar estado
self.btn_stitch.config(text="Procesando...", state="disabled")
self.btn_select.config(state="disabled")
self.mode_combobox.config(state="disabled") # Deshabilitar selector de modo
self.update_idletasks() # Forzar actualización de la GUI
# Limpiar el canvas y variables de imagen anterior antes de empezar
self.canvas.delete("all")
self.panorama_tk = None
self.final_panorama_img_np = None
# Obtener el modo de stitching seleccionado
selected_mode_display = self.stitching_mode_var.get()
# Mapear el nombre mostrado al valor interno de OpenCV
stitching_mode_internal = self.stitching_modes.get(selected_mode_display, "panorama") # Por defecto "panorama"
# Iniciar el proceso de stitching en un hilo separado
self.stitch_thread = threading.Thread(
target=self._process_stitching_in_thread,
args=(self.rutas_imagenes, stitching_mode_internal)
)
self.stitch_thread.start()
def _process_stitching_in_thread(self, rutas_imagenes, stitching_mode):
"""
Método que contiene la lógica pesada de stitching, ejecutada en un hilo.
stitching_mode: "panorama" o "scans"
"""
imgs = []
for ruta in rutas_imagenes:
try:
img = cv2.imdecode(np.fromfile(ruta, dtype=np.uint8), cv2.IMREAD_COLOR)
if img is None:
self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_NEED_MORE_IMGS, None, None, f"No se pudo leer la imagen: {os.path.basename(ruta)}. Asegúrate de que es un archivo de imagen válido y no está corrupto.")
return
imgs.append(img)
except Exception as e:
self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_OTHER, None, None, f"Error al cargar {os.path.basename(ruta)}: {e}")
return
if len(imgs) < 2:
self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_NEED_MORE_IMGS, None, None, "Se necesitan al menos 2 imágenes válidas.")
return
# Determinar el modo de Stitcher basado en la selección del usuario
stitcher_mode_cv = cv2.Stitcher_PANORAMA # Por defecto
if stitching_mode == "scans":
try:
# Comprobar si cv2.Stitcher_SCANS existe
stitcher_mode_cv = cv2.Stitcher_SCANS
except AttributeError:
self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_OTHER, None, None, "Tu versión de OpenCV no soporta el modo 'SCANS'. Utilizando 'PANORAMA'.")
stitcher_mode_cv = cv2.Stitcher_PANORAMA # Vuelve a Panorama si no existe SCANS
# Crear el objeto Stitcher
try:
stitcher = cv2.Stitcher_create(mode=stitcher_mode_cv)
except AttributeError:
# Para versiones antiguas de OpenCV (<4.0) o si create(mode) no existe
try:
stitcher = cv2.createStitcher(False) # False indica no usar calibración de cámara
self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_OTHER, None, None, "Tu versión de OpenCV es antigua. Usando createStitcher() sin modo específico.")
except AttributeError:
self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_OTHER, None, None, "Tu versión de OpenCV no soporta Stitcher o está mal instalada.")
return
status, pano = stitcher.stitch(imgs)
if status != cv2.Stitcher_OK:
# Llamar a la función de completado con error en el hilo principal
self.after(0, self._on_stitching_complete, status, None, None, None)
return
# ======== Bloque para recortar los bordes negros ========
# Convertir de BGR (OpenCV) a RGB (para Pillow/visualización y procesamiento con numpy)
pano_rgb = cv2.cvtColor(pano, cv2.COLOR_BGR2RGB)
# Crear una máscara booleana: True donde el píxel no es (0,0,0) (negro)
# np.any(..., axis=2) verifica si CUALQUIER canal RGB no es 0 para ese píxel.
# Esto es más robusto que solo == [0,0,0] si el negro es (0,0,1) por ejemplo.
mask = np.any(pano_rgb != [0, 0, 0], axis=2)
# Encontrar las coordenadas mínimas y máximas de la región válida (no negra)
coords = np.column_stack(np.where(mask))
cropped = pano_rgb # Inicializar con la imagen completa por si no hay contenido válido
if coords.size > 0:
y_min, x_min = coords.min(axis=0)
y_max, x_max = coords.max(axis=0)
# Asegurarse de que las coordenadas son válidas
if y_min <= y_max and x_min <= x_max:
cropped = pano_rgb[y_min:y_max+1, x_min:x_max+1]
# else: Si las coordenadas no son válidas, `cropped` se queda como `pano_rgb`
# ===============================================================
# Llamar a la función de completado con el resultado en el hilo principal
self.after(0, self._on_stitching_complete, status, pano, cropped, None)
def _on_stitching_complete(self, status, pano, cropped, error_message=None):
"""
Método llamado en el hilo principal cuando el stitching termina.
"""
# Restaurar el estado de los botones y el selector de modo
self.btn_stitch.config(text="Crear Panorama", state="normal")
self.btn_select.config(state="normal")
self.mode_combobox.config(state="readonly") # Habilitar selector de modo
if status != cv2.Stitcher_OK:
msg = "No se pudo crear el panorama."
if error_message:
msg += f"\nDetalles: {error_message}"
else:
msg += f" (código de error {status})"
if status == cv2.Stitcher_ERR_NEED_MORE_IMGS:
msg += "\nAsegúrate de que las imágenes tienen suficiente solapamiento o características distintivas."
elif status == cv2.Stitcher_ERR_HOMOGRAPHY_EST_FAIL:
msg += "\nFallo en la estimación de la homografía. Asegúrate de que las imágenes se solapan bien y no hay distorsión excesiva, o prueba con el otro modo de unión."
elif status == cv2.Stitcher_ERR_CAMERA_PARAMS_ADJUST_FAIL:
msg += "\nFallo al ajustar parámetros de cámara."
elif status == cv2.Stitcher_ERR_NO_FEATURES:
msg += "\nNo se encontraron suficientes características distintivas en las imágenes. Prueba con imágenes más detalladas o con más solapamiento."
messagebox.showerror("Error de Panorama", msg)
self.final_panorama_img_np = None # Asegurar que no haya imagen inválida almacenada
else:
# Almacenar la imagen numpy recortada (en RGB) para redimensionar y guardar
self.final_panorama_img_np = cropped
self._display_panorama()
messagebox.showinfo("Proceso Completado", "Panorama creado exitosamente. Puedes guardarlo con clic derecho.")
self.stitch_thread = None # Liberar la referencia al hilo
def _display_panorama(self, event=None):
"""
Redimensiona y muestra self.final_panorama_img_np en el canvas.
Llamado después de completar el stitching y en cada redimensionamiento del canvas.
"""
if self.final_panorama_img_np is None:
self.canvas.delete("all")
self.panorama_tk = None
return
w_canvas = self.canvas.winfo_width()
h_canvas = self.canvas.winfo_height()
if w_canvas <= 1 or h_canvas <= 1:
return
pil_img = Image.fromarray(self.final_panorama_img_np)
original_w, original_h = pil_img.size
# Calcular el nuevo tamaño manteniendo la relación de aspecto, ajustándose al canvas
ratio_w = w_canvas / original_w
ratio_h = h_canvas / original_h
ratio = min(ratio_w, ratio_h) # Para que quepa completamente dentro del canvas
new_w = int(original_w * ratio)
new_h = int(original_h * ratio)
new_w = max(1, new_w)
new_h = max(1, new_h)
try:
pil_img_resized = pil_img.resize((new_w, new_h), Image.Resampling.LANCZOS)
except AttributeError:
pil_img_resized = pil_img.resize((new_w, new_h), Image.LANCZOS)
self.panorama_tk = ImageTk.PhotoImage(pil_img_resized)
self.canvas.delete("all")
x = (w_canvas - new_w) // 2
y = (h_canvas - new_h) // 2
self.canvas.create_image(x, y, anchor="nw", image=self.panorama_tk)
def _on_canvas_resize(self, event):
"""
Maneja el evento de redimensionamiento del canvas.
"""
self._display_panorama(event)
def _show_context_menu(self, event):
"""
Muestra el menú contextual si hay una imagen de panorama.
"""
if self.final_panorama_img_np is not None:
try:
self.context_menu.post(event.x_root, event.y_root)
finally:
self.context_menu.grab_release()
def guardar_panorama(self):
"""
Guarda la imagen del panorama (la versión recortada de numpy).
"""
if self.final_panorama_img_np is None:
messagebox.showinfo("Nada para guardar", "No hay un panorama generado para guardar.")
return
ruta_guardado = filedialog.asksaveasfilename(
defaultextension=".jpg",
filetypes=[("JPEG", "*.jpg"), ("PNG", "*.png"), ("Todos los archivos", "*.*")],
title="Guardar panorama como..."
)
if ruta_guardado:
try:
img_to_save_bgr = cv2.cvtColor(self.final_panorama_img_np, cv2.COLOR_RGB2BGR)
ext = os.path.splitext(ruta_guardado)[1].lower()
if ext in [".jpg", ".jpeg"]:
cv2.imwrite(ruta_guardado, img_to_save_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), 95])
elif ext == ".png":
cv2.imwrite(ruta_guardado, img_to_save_bgr)
else:
cv2.imwrite(ruta_guardado, img_to_save_bgr)
messagebox.showinfo("Guardado Exitoso", f"Panorama guardado en:\n{ruta_guardado}")
except Exception as e:
messagebox.showerror("Error al Guardar", f"No se pudo guardar el archivo:\n{e}")
if __name__ == "__main__":
app = PanoramaApp()
app.mainloop()