#!
/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([Link]):
def __init__(self):
super().__init__()
[Link](APP); [Link]("1000x680"); [Link](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 = [Link](self); [Link](fill="x", padx=10, pady=6)
[Link](top, text="Abrir...",
command=self.open_image).pack(side="left")
[Link](top, text="Guardar como...",
command=self.save_image).pack(side="left", padx=6)
[Link](top, text="Reset",
command=self.reset_image).pack(side="left", padx=(6,0))
[Link](top, orient="vertical").pack(side="left", fill="y",
padx=8)
self.lbl_info = [Link](top, text="Sin imagen");
self.lbl_info.pack(side="left")
self.fit_var = [Link](value=True)
[Link](top, text="Ajustar a ventana",
variable=self.fit_var,
command=[Link]).pack(side="right")
# Main split
main = [Link](self); [Link](fill="both", expand=True,
padx=10, pady=(0,10))
[Link] = [Link](main, bg="#111318", highlightthickness=0)
[Link](side="left", fill="both", expand=True)
[Link]("<Configure>", lambda e: [Link]())
[Link]("<Button-1>", self.on_canvas_down)
[Link]("<B1-Motion>", self.on_canvas_drag)
[Link]("<ButtonRelease-1>", self.on_canvas_up)
# Panel de controles
ctrl = [Link](main); [Link](side="left", fill="y",
padx=(10,0))
ctrl.enable_traversal()
# --- Pestaña Filtros rápidos
tab1 = [Link](ctrl); [Link](tab1, text="Filtros")
self.var_gray = [Link](); self.var_invert =
[Link]()
[Link](tab1, text="Escala de grises",
variable=self.var_gray, command=[Link]).pack(anchor="w", padx=10,
pady=4)
[Link](tab1, text="Invertir colores",
variable=self.var_invert, command=[Link]).pack(anchor="w", padx=10,
pady=4)
[Link](tab1, text="Bordes / Enfoque /
Desenfoque").pack(anchor="w", padx=10, pady=(8,2))
[Link](tab1, text="Detectar bordes (Sobel)",
command=lambda:[Link](filter="edges")).pack(fill="x", padx=10,
pady=2)
[Link](tab1, text="Enfocar (Sharpen)",
command=lambda:[Link](filter="sharpen")).pack(fill="x", padx=10,
pady=2)
[Link](tab1, text="Desenfoque Gaussiano",
command=lambda:[Link](filter="gauss")).pack(fill="x", padx=10,
pady=2)
[Link](tab1, text="Rotar / Voltear").pack(anchor="w", padx=10,
pady=(10,2))
btns = [Link](tab1); [Link](fill="x", padx=10)
[Link](btns, text="○ 90°", command=lambda:[Link](-
90)).pack(side="left", expand=True, fill="x", padx=2)
[Link](btns, text="○' 90°",
command=lambda:[Link](90)).pack(side="left", expand=True, fill="x",
padx=2)
[Link](btns, text="180°",
command=lambda:[Link](180)).pack(side="left", expand=True, fill="x",
padx=2)
btns2 = [Link](tab1); [Link](fill="x", padx=10,
pady=(4,0))
[Link](btns2, text="Voltear H",
command=lambda:[Link]("h")).pack(side="left", expand=True, fill="x",
padx=2)
[Link](btns2, text="Voltear V",
command=lambda:[Link]("v")).pack(side="left", expand=True, fill="x",
padx=2)
[Link](tab1).pack(fill="x", padx=10, pady=10)
[Link](tab1, text="Modo recorte (dibujar rectángulo)",
command=self.toggle_crop).pack(fill="x", padx=10, pady=(0,8))
# --- Pestaña Ajustes finos
tab2 = [Link](ctrl); [Link](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)
[Link](tab2, text="Restablecer ajustes",
command=self.reset_sliders).pack(fill="x", padx=10, pady=8)
# --- Pestaña Info
tab3 = [Link](ctrl); [Link](tab3, text="Info")
self.txt_info = [Link](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 = [Link](parent); [Link](fill="x", padx=10, pady=6)
[Link](frm, text=label).pack(anchor="w")
var = [Link](value=val)
s = [Link](frm, from_=mn, to=mx, variable=var,
orient="horizontal", command=lambda *_:[Link]())
[Link](fill="x")
box = [Link](frm); [Link](fill="x")
[Link](box, text=f"{mn:.1f}").pack(side="left")
lbl = [Link](box, text=f"{val:.2f}"); [Link](side="left",
expand=True)
[Link](box, text=f"{mx:.1f}").pack(side="right")
var.trace_add("write", lambda *_:
[Link](text=f"{[Link]():.2f}"))
return var
# --- Carga/Guardado ---
def open_image(self):
path =
[Link](filetypes=[("Imágenes","*.png;*.jpg;*.jpeg;*.b
mp;*.webp;*.tiff"),("Todos","*.*")])
if not path: return
try:
im = [Link](path).convert("RGB")
except Exception as e:
[Link]("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: {[Link](path)} |
{[Link]}x{[Link]} px, RGB")
self.lbl_info.config(text=f"{[Link]}x{[Link]}px -
{[Link](path)}")
[Link]()
def save_image(self):
if self.img_work is None:
[Link]("Sin imagen", "Nada que guardar.");
return
path = [Link](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: {[Link](path)}")
except Exception as e:
[Link]("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
[Link]()
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 = [Link](im).convert("RGB")
if self.var_invert.get():
# si está en L, invert produce L; normaliza a RGB
im = [Link]([Link]("RGB"))
if filter == "edges":
im = [Link](ImageFilter.FIND_EDGES)
elif filter == "sharpen":
im = [Link]([Link](radius=1.8,
percent=160, threshold=3))
elif filter == "gauss":
im = [Link]([Link](radius=2.0))
# Ajustes finos
im = [Link](im).enhance(self.s_bri.get())
im = [Link](im).enhance(self.s_con.get())
im = [Link](im).enhance(self.s_sat.get())
im = [Link](im).enhance(self.s_shp.get())
blur = self.s_blr.get()
if blur > 0.01:
im = [Link]([Link](radius=float(blur)))
self.img_work = im
[Link]()
def rotate(self, deg):
if self.img_orig is None: return
self.img_orig = self.img_orig.rotate(-deg, expand=True) # PIL
rota antihoraria
[Link]()
def flip(self, axis):
if self.img_orig is None: return
if axis == "h":
self.img_orig = [Link](self.img_orig)
else:
self.img_orig = [Link](self.img_orig)
[Link]()
# --- 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: [Link](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: [Link](self.crop_rect)
self.crop_rect = [Link].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: [Link](self.crop_rect);
self.crop_rect=None
[Link]()
except Exception as ex:
[Link]("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 = [Link].winfo_width(), [Link].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):
[Link]("all")
if self.img_work is None: return
im = self.img_work
cw, ch = [Link].winfo_width(), [Link].winfo_height()
if self.fit_var.get():
scale = min(cw/[Link], ch/[Link])
scale = max(scale, 0.05)
show = [Link]((int([Link]*scale), int([Link]*scale)),
[Link])
else:
show = [Link]()
self.img_disp = [Link](show)
x = (cw - [Link])/2
y = (ch - [Link])/2
[Link].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()