#!
/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import io
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageTk, ImageFilter, ImageOps, ImageEnhance
APP = "Laboratorio de Mapas de Bits (Tkinter + Pillow)"
class BitmapLab(tk.Tk):
def __init__(self):
super().__init__()
self.title(APP); self.geometry("1000x680"); self.minsize(900,
600)
# Estado
self.img_orig = None # Image base (cargada)
self.img_work = None # Image tras pipeline
self.img_disp = None # Image para canvas (ajustada)
self.view_fit = True # Ajustar a ventana
self.crop_mode = False
self.crop_start = None
self.crop_rect = None
self._build_ui()
# --- UI ---
def _build_ui(self):
# Top bar
top = ttk.Frame(self); top.pack(fill="x", padx=10, pady=6)
ttk.Button(top, text="Abrir...",
command=self.open_image).pack(side="left")
ttk.Button(top, text="Guardar como...",
command=self.save_image).pack(side="left", padx=6)
ttk.Button(top, text="Reset",
command=self.reset_image).pack(side="left", padx=(6,0))
ttk.Separator(top, orient="vertical").pack(side="left", fill="y",
padx=8)
self.lbl_info = ttk.Label(top, text="Sin imagen");
self.lbl_info.pack(side="left")
self.fit_var = tk.BooleanVar(value=True)
ttk.Checkbutton(top, text="Ajustar a ventana",
variable=self.fit_var,
command=self.render).pack(side="right")
# Main split
main = ttk.Frame(self); main.pack(fill="both", expand=True,
padx=10, pady=(0,10))
self.canvas = tk.Canvas(main, bg="#111318", highlightthickness=0)
self.canvas.pack(side="left", fill="both", expand=True)
self.canvas.bind("<Configure>", lambda e: self.render())
self.canvas.bind("<Button-1>", self.on_canvas_down)
self.canvas.bind("<B1-Motion>", self.on_canvas_drag)
self.canvas.bind("<ButtonRelease-1>", self.on_canvas_up)
# Panel de controles
ctrl = ttk.Notebook(main); ctrl.pack(side="left", fill="y",
padx=(10,0))
ctrl.enable_traversal()
# --- Pestaña Filtros rápidos
tab1 = ttk.Frame(ctrl); ctrl.add(tab1, text="Filtros")
self.var_gray = tk.BooleanVar(); self.var_invert =
tk.BooleanVar()
ttk.Checkbutton(tab1, text="Escala de grises",
variable=self.var_gray, command=self.apply).pack(anchor="w", padx=10,
pady=4)
ttk.Checkbutton(tab1, text="Invertir colores",
variable=self.var_invert, command=self.apply).pack(anchor="w", padx=10,
pady=4)
ttk.Label(tab1, text="Bordes / Enfoque /
Desenfoque").pack(anchor="w", padx=10, pady=(8,2))
ttk.Button(tab1, text="Detectar bordes (Sobel)",
command=lambda:self.apply(filter="edges")).pack(fill="x", padx=10,
pady=2)
ttk.Button(tab1, text="Enfocar (Sharpen)",
command=lambda:self.apply(filter="sharpen")).pack(fill="x", padx=10,
pady=2)
ttk.Button(tab1, text="Desenfoque Gaussiano",
command=lambda:self.apply(filter="gauss")).pack(fill="x", padx=10,
pady=2)
ttk.Label(tab1, text="Rotar / Voltear").pack(anchor="w", padx=10,
pady=(10,2))
btns = ttk.Frame(tab1); btns.pack(fill="x", padx=10)
ttk.Button(btns, text="○ 90°", command=lambda:self.rotate(-
90)).pack(side="left", expand=True, fill="x", padx=2)
ttk.Button(btns, text="○' 90°",
command=lambda:self.rotate(90)).pack(side="left", expand=True, fill="x",
padx=2)
ttk.Button(btns, text="180°",
command=lambda:self.rotate(180)).pack(side="left", expand=True, fill="x",
padx=2)
btns2 = ttk.Frame(tab1); btns2.pack(fill="x", padx=10,
pady=(4,0))
ttk.Button(btns2, text="Voltear H",
command=lambda:self.flip("h")).pack(side="left", expand=True, fill="x",
padx=2)
ttk.Button(btns2, text="Voltear V",
command=lambda:self.flip("v")).pack(side="left", expand=True, fill="x",
padx=2)
ttk.Separator(tab1).pack(fill="x", padx=10, pady=10)
ttk.Button(tab1, text="Modo recorte (dibujar rectángulo)",
command=self.toggle_crop).pack(fill="x", padx=10, pady=(0,8))
# --- Pestaña Ajustes finos
tab2 = ttk.Frame(ctrl); ctrl.add(tab2, text="Ajustes")
self.s_bri = self._slider(tab2, "Brillo", 0.0, 3.0, 1.0)
self.s_con = self._slider(tab2, "Contraste", 0.0, 3.0, 1.0)
self.s_sat = self._slider(tab2, "Color (saturación)", 0.0, 3.0,
1.0)
self.s_shp = self._slider(tab2, "Nitidez", 0.0, 3.0, 1.0)
self.s_blr = self._slider(tab2, "Blur gaussiano (px)", 0.0, 10.0,
0.0, step=0.1)
ttk.Button(tab2, text="Restablecer ajustes",
command=self.reset_sliders).pack(fill="x", padx=10, pady=8)
# --- Pestaña Info
tab3 = ttk.Frame(ctrl); ctrl.add(tab3, text="Info")
self.txt_info = tk.Text(tab3, height=12, width=36, bg="#0f1218",
fg="#e6eaf2", bd=0)
self.txt_info.pack(fill="both", expand=True, padx=8, pady=8)
self._log("Abre una imagen (PNG/JPG/BMP/WebP...).")
def _slider(self, parent, label, mn, mx, val, step=0.05):
frm = ttk.Frame(parent); frm.pack(fill="x", padx=10, pady=6)
ttk.Label(frm, text=label).pack(anchor="w")
var = tk.DoubleVar(value=val)
s = ttk.Scale(frm, from_=mn, to=mx, variable=var,
orient="horizontal", command=lambda *_:self.apply())
s.pack(fill="x")
box = ttk.Frame(frm); box.pack(fill="x")
ttk.Label(box, text=f"{mn:.1f}").pack(side="left")
lbl = ttk.Label(box, text=f"{val:.2f}"); lbl.pack(side="left",
expand=True)
ttk.Label(box, text=f"{mx:.1f}").pack(side="right")
var.trace_add("write", lambda *_:
lbl.config(text=f"{var.get():.2f}"))
return var
# --- Carga/Guardado ---
def open_image(self):
path =
filedialog.askopenfilename(filetypes=[("Imágenes","*.png;*.jpg;*.jpeg;*.b
mp;*.webp;*.tiff"),("Todos","*.*")])
if not path: return
try:
im = Image.open(path).convert("RGB")
except Exception as e:
messagebox.showerror("Error", f"No se pudo abrir:\n{e}");
return
self.img_orig = im
self.reset_sliders()
self.var_gray.set(False); self.var_invert.set(False)
self.crop_mode = False; self.crop_start = None
self._log(f"Archivo: {os.path.basename(path)} |
{im.width}x{im.height} px, RGB")
self.lbl_info.config(text=f"{im.width}x{im.height}px -
{os.path.basename(path)}")
self.apply()
def save_image(self):
if self.img_work is None:
messagebox.showinfo("Sin imagen", "Nada que guardar.");
return
path = filedialog.asksaveasfilename(defaultextension=".png",
filetypes=[("PNG","*.png"),("JPEG","*.jpg"),("WebP","*.webp"),("BMP","*.b
mp")])
if not path: return
try:
self.img_work.save(path)
self._log(f"Guardado: {os.path.basename(path)}")
except Exception as e:
messagebox.showerror("Error", f"No se pudo guardar:\n{e}")
def reset_image(self):
if self.img_orig is None: return
self.reset_sliders()
self.var_gray.set(False); self.var_invert.set(False)
self.crop_mode = False; self.crop_start = None
self.apply()
def reset_sliders(self):
self.s_bri.set(1.0); self.s_con.set(1.0); self.s_sat.set(1.0);
self.s_shp.set(1.0); self.s_blr.set(0.0)
# --- Procesamiento ---
def apply(self, filter=None):
if self.img_orig is None: return
im = self.img_orig.copy()
# Filtros "rápidos"
if self.var_gray.get():
im = ImageOps.grayscale(im).convert("RGB")
if self.var_invert.get():
# si está en L, invert produce L; normaliza a RGB
im = ImageOps.invert(im.convert("RGB"))
if filter == "edges":
im = im.filter(ImageFilter.FIND_EDGES)
elif filter == "sharpen":
im = im.filter(ImageFilter.UnsharpMask(radius=1.8,
percent=160, threshold=3))
elif filter == "gauss":
im = im.filter(ImageFilter.GaussianBlur(radius=2.0))
# Ajustes finos
im = ImageEnhance.Brightness(im).enhance(self.s_bri.get())
im = ImageEnhance.Contrast(im).enhance(self.s_con.get())
im = ImageEnhance.Color(im).enhance(self.s_sat.get())
im = ImageEnhance.Sharpness(im).enhance(self.s_shp.get())
blur = self.s_blr.get()
if blur > 0.01:
im = im.filter(ImageFilter.GaussianBlur(radius=float(blur)))
self.img_work = im
self.render()
def rotate(self, deg):
if self.img_orig is None: return
self.img_orig = self.img_orig.rotate(-deg, expand=True) # PIL
rota antihoraria
self.apply()
def flip(self, axis):
if self.img_orig is None: return
if axis == "h":
self.img_orig = ImageOps.mirror(self.img_orig)
else:
self.img_orig = ImageOps.flip(self.img_orig)
self.apply()
# --- Recorte ---
def toggle_crop(self):
if self.img_orig is None: return
self.crop_mode = not self.crop_mode
self._log("Modo recorte: " + ("ON (arrastre para seleccionar)" if
self.crop_mode else "OFF"))
def on_canvas_down(self, e):
if not self.crop_mode or self.img_work is None: return
self.crop_start = (e.x, e.y)
if self.crop_rect: self.canvas.delete(self.crop_rect)
self.crop_rect = None
def on_canvas_drag(self, e):
if not self.crop_mode or self.crop_start is None: return
if self.crop_rect: self.canvas.delete(self.crop_rect)
self.crop_rect = self.canvas.create_rectangle(self.crop_start[0],
self.crop_start[1], e.x, e.y, outline="#ffd166", width=2, dash=(4,2))
def on_canvas_up(self, e):
if not self.crop_mode or self.crop_start is None: return
x0, y0 = self.crop_start; x1, y1 = e.x, e.y
self.crop_start = None
if abs(x1-x0) < 4 or abs(y1-y0) < 4:
return
# Convertir coords de canvas a coords de imagen
box = self._canvas_to_image_box(min(x0,x1), min(y0,y1),
max(x0,x1), max(y0,y1))
if box is None: return
L, U, R, D = box
try:
self.img_orig = self.img_work.crop([L, U, R, D])
self._log(f"Recorte: {R-L}×{D-U}px")
self.crop_mode = False
if self.crop_rect: self.canvas.delete(self.crop_rect);
self.crop_rect=None
self.apply()
except Exception as ex:
messagebox.showerror("Recorte", f"No se pudo recortar: {ex}")
def _canvas_to_image_box(self, x0, y0, x1, y1):
"""Mapea un rectángulo del canvas a la caja en la imagen work."""
if self.img_work is None: return None
cw, ch = self.canvas.winfo_width(), self.canvas.winfo_height()
iw, ih = self.img_work.width, self.img_work.height
# calcular destino de la imagen en el canvas (para mantener
relación de aspecto)
scale = min(cw/iw, ch/ih) if self.fit_var.get() else 1.0
dw, dh = int(iw*scale), int(ih*scale)
ox = (cw - dw)//2
oy = (ch - dh)//2
# limitar a área dibujada
x0 = max(ox, min(x0, ox+dw)); x1 = max(ox, min(x1, ox+dw))
y0 = max(oy, min(y0, oy+dh)); y1 = max(oy, min(y1, oy+dh))
if x1<=x0 or y1<=y0: return None
# convertir a coords de imagen
L = int((x0-ox)/scale); U = int((y0-oy)/scale); R = int((x1-
ox)/scale); D = int((y1-oy)/scale)
return (L, U, R, D)
# --- Render ---
def render(self):
self.canvas.delete("all")
if self.img_work is None: return
im = self.img_work
cw, ch = self.canvas.winfo_width(), self.canvas.winfo_height()
if self.fit_var.get():
scale = min(cw/im.width, ch/im.height)
scale = max(scale, 0.05)
show = im.resize((int(im.width*scale), int(im.height*scale)),
Image.LANCZOS)
else:
show = im.copy()
self.img_disp = ImageTk.PhotoImage(show)
x = (cw - show.width)/2
y = (ch - show.height)/2
self.canvas.create_image(x, y, anchor="nw", image=self.img_disp)
# --- Util ---
def _log(self, msg):
self.txt_info.insert("end", msg + "\n")
self.txt_info.see("end")
if __name__ == "__main__":
BitmapLab().mainloop()