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("", self._on_canvas_resize) self.canvas.bind("", 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()