Spaces:
Sleeping
Sleeping
| 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() |