Conversor de imágenes a formato binario


Presentación

En el trabajo que vengo realizando en el desarrollo de la librería PantallaE213 surgio la necesidad de contar con archivos de imagenes binarios para poder subirlos a la placa Vision Master E213 (HT-VME213).

Con ese objetivo se desarrollo el programa que les presento a continuacion el cual preve ademas de la conversión a binario manteniendo el tamaño original de la imagen, convertir al tamaño de la pantalla Vision Master E213 (HT-VME213) (122 x 250 píxeles) y tambien convertirla a un tamaño personalizado (puedes indicar ancho y alto) .


Descripción del programa

Es una herramienta de escritorio con interfaz gráfica que permite convertir imágenes comunes (PNG, JPG, BMP, etc.) en archivos binarios de 1 bit por píxel, listos para ser enviados directamente a pantallas e-ink monocromas.
Está especialmente pensada para dispositivos con pantallas de 122 × 250 píxeles, como ciertos módulos e-ink utilizados en aplicaciones de bajo consumo.

¿Cómo funciona?

Selección de imagen:
– El usuario carga cualquier imagen desde su computadora mediante un explorador de archivos intuitivo.

Opciones de tamaño:
Mantener tamaño original: conserva las dimensiones de la imagen fuente.
122 × 250 píxeles: resolución optimizada para pantallas e-ink típicas.
Tamaño personalizado: permite definir ancho y alto manualmente.

Procesamiento automático:
– La imagen pasa por una serie de mejoras automáticas:
    • Conversión a escala de grises.
    • Ajuste automático de contraste.
    • Ligero realce de brillo y contraste.
    • Suavizado para reducir ruido.
    • Dithering Stucki: un algoritmo avanzado que simula niveles de gris usando solo blanco y negro, ideal para pantallas e-ink.

Generación del binario:
– Cada píxel se codifica como un bit: 0 = blanco, 1 = negro.
– Los bits se empaquetan en bytes, ordenados de izquierda a derecha, con el bit más significativo primero.
– Cuando se elige la resolución 122 × 250, el programa genera un archivo de exactamente 4000 bytes, compatible con buffers de memoria que exigen este tamaño fijo (incluyendo el padding necesario para alineación).

Previsualización integrada:
– Muestra en tiempo real cómo se verá la imagen convertida en la pantalla e-ink, junto con información técnica: dimensiones y tamaño del archivo binario resultante.

Código del programa

A continuacion coloco el archivo convertir.py que contiene el codigo del programa.

Archivo: convertir.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Conversor de imágenes a formato 1-bit para e-ink con interfaz gráfica
Permite convertir imágenes a formato binario con diferentes opciones de tamaño
Incluye previsualización de la imagen convertida
"""

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, Canvas
from PIL import Image, ImageOps, ImageEnhance, ImageFilter, ImageTk
import os
from pathlib import Path

# -----------------------------
#  DITHERING STUCKI
# -----------------------------
STUCKI_KERNEL = [
    (1, 0,  8/42), (2, 0, 4/42),
    (-2, 1, 2/42), (-1, 1, 4/42), (0, 1, 8/42), (1, 1, 4/42), (2, 1, 2/42),
    (-2, 2, 1/42), (-1, 2, 2/42), (0, 2, 4/42), (1, 2, 2/42), (2, 2, 1/42),
]

def dither_stucki(img):
    """Aplica dithering Stucki manualmente"""
    w, h = img.size
    pix = img.load()
    for y in range(h):
        for x in range(w):
            old = pix[x, y]
            new = 255 if old > 127 else 0
            pix[x, y] = new
            err = old - new
            for dx, dy, factor in STUCKI_KERNEL:
                nx = x + dx
                ny = y + dy
                if 0 <= nx < w and 0 <= ny < h:
                    pix[nx, ny] = max(0, min(255, pix[nx, ny] + int(err * factor)))
    return img

def convertir_imagen(input_path, output_path, ancho=None, alto=None):
    """
    Convierte imagen a formato 1-bit para e-ink
    Si ancho y alto son None, mantiene dimensiones originales
    Retorna la imagen procesada además de las estadísticas
    """
    # Abrir imagen
    img = Image.open(input_path)
    
    # Determinar dimensiones finales
    if ancho is None or alto is None:
        ANCHO, ALTO = img.size
    else:
        ANCHO, ALTO = ancho, alto
    
    # Convertir a escala de grises
    img = ImageOps.grayscale(img)
    
    # Auto-contraste moderado
    img = ImageOps.autocontrast(img, cutoff=2)
    
    # Ajuste de brillo/contraste
    img = ImageEnhance.Brightness(img).enhance(1.05)
    img = ImageEnhance.Contrast(img).enhance(1.15)
    
    # Reescalar si es necesario
    if img.size != (ANCHO, ALTO):
        img = img.resize((ANCHO, ALTO), Image.LANCZOS)
    
    # Suavizado leve
    img = img.filter(ImageFilter.GaussianBlur(radius=0.4))
    
    # Dithering
    img = dither_stucki(img)
    
    # Convertir a 1-bit
    pix = img.load()
    data = bytearray()
    
    for y in range(ALTO):
        for x_byte in range((ANCHO + 7) // 8):
            byte = 0
            for bit in range(8):
                x = x_byte * 8 + bit
                if x < ANCHO:
                    if pix[x, y] < 128:
                        byte |= (0x80 >> bit)
            data.append(byte)
    
    # Guardar archivo binario
    with open(output_path, "wb") as f:
        f.write(data)
    
    return img, ANCHO, ALTO, len(data)

# -----------------------------
#  INTERFAZ GRÁFICA
# -----------------------------
class ConversorEInkGUI:
    def __init__(self, root):
        self.root = root
        self.root.title("Conversor de Imágenes E-Ink")
        self.root.geometry("1000x500")
        
        # Variables
        self.archivo_seleccionado = tk.StringVar()
        self.opcion_tamaño = tk.IntVar(value=1)
        self.ancho_custom = tk.StringVar(value="122")
        self.alto_custom = tk.StringVar(value="250")
        self.imagen_preview = None
        
        # Crear carpetas si no existen
        self.crear_carpetas()
        
        # Construir interfaz
        self.construir_interfaz()
    
    def crear_carpetas(self):
        """Crea las carpetas originales y binarios si no existen"""
        Path("originales").mkdir(exist_ok=True)
        Path("binarios").mkdir(exist_ok=True)
    
    def construir_interfaz(self):
        # Frame principal dividido en dos columnas
        frame_principal = ttk.Frame(self.root)
        frame_principal.pack(fill="both", expand=True, padx=5, pady=5)
        
        # Columna izquierda (controles)
        frame_izquierda = ttk.Frame(frame_principal)
        frame_izquierda.pack(side="left", fill="both", expand=False, padx=5)
        
        # Columna derecha (preview)
        frame_derecha = ttk.LabelFrame(frame_principal, text="Vista Previa", padding=10)
        frame_derecha.pack(side="right", fill="both", expand=True, padx=5)
        
        # === COLUMNA IZQUIERDA ===
        
        # Título
        titulo = tk.Label(frame_izquierda, text="Conversor E-Ink",
                         font=("Arial", 14, "bold"))
        titulo.pack(pady=10)
        
        # Frame de selección de archivo
        frame_archivo = ttk.LabelFrame(frame_izquierda, text="Archivo de Entrada", padding=10)
        frame_archivo.pack(pady=10, fill="x")
        
        self.label_archivo = ttk.Label(frame_archivo, text="Ningún archivo seleccionado",
                                       foreground="gray", wraplength=350)
        self.label_archivo.pack(pady=5)
        
        ttk.Button(frame_archivo, text="Seleccionar imagen",
                  command=self.seleccionar_archivo).pack(pady=5)
        
        # Frame de opciones de tamaño
        frame_opciones = ttk.LabelFrame(frame_izquierda, text="Opciones de Tamaño", padding=10)
        frame_opciones.pack(pady=10, fill="x")
        
        # Opción 1: Mantener tamaño original
        ttk.Radiobutton(frame_opciones, text="Mantener tamaño original",
                       variable=self.opcion_tamaño, value=1,
                       command=self.actualizar_estado_campos).pack(anchor="w", pady=3)
        
        # Opción 2: Tamaño fijo 122x250
        ttk.Radiobutton(frame_opciones, text="122 × 250 píxeles",
                       variable=self.opcion_tamaño, value=2,
                       command=self.actualizar_estado_campos).pack(anchor="w", pady=3)
        
        # Opción 3: Tamaño personalizado
        ttk.Radiobutton(frame_opciones, text="Tamaño personalizado:",
                       variable=self.opcion_tamaño, value=3,
                       command=self.actualizar_estado_campos).pack(anchor="w", pady=3)
        
        # Frame para entrada personalizada
        frame_custom = ttk.Frame(frame_opciones)
        frame_custom.pack(anchor="w", padx=20, pady=3)
        
        ttk.Label(frame_custom, text="Ancho:").grid(row=0, column=0, padx=3)
        self.entry_ancho = ttk.Entry(frame_custom, textvariable=self.ancho_custom, width=8)
        self.entry_ancho.grid(row=0, column=1, padx=3)
        
        ttk.Label(frame_custom, text="Alto:").grid(row=0, column=2, padx=3)
        self.entry_alto = ttk.Entry(frame_custom, textvariable=self.alto_custom, width=8)
        self.entry_alto.grid(row=0, column=3, padx=3)
        
        # Botón de conversión
        self.btn_convertir = ttk.Button(frame_izquierda, text="Convertir Imagen",
                                       command=self.convertir, state="disabled")
        self.btn_convertir.pack(pady=15)
        
        # Área de estado
        frame_estado = ttk.LabelFrame(frame_izquierda, text="Estado", padding=10)
        frame_estado.pack(pady=5, fill="x")
        
        self.label_estado = tk.Label(frame_estado, text="Seleccione una imagen",
                                     justify="left", anchor="w", wraplength=350)
        self.label_estado.pack(fill="x")
        
        # === COLUMNA DERECHA (PREVIEW) ===
        
        # Canvas para mostrar la imagen
        self.canvas = Canvas(frame_derecha, bg="white", highlightthickness=1, 
                            highlightbackground="gray")
        self.canvas.pack(fill="both", expand=True)
        
        # Label para información de la imagen
        self.label_info_preview = ttk.Label(frame_derecha, text="", 
                                           justify="center", font=("Arial", 9))
        self.label_info_preview.pack(pady=5)
        
        # Inicializar estado de campos
        self.actualizar_estado_campos()
    
    def actualizar_estado_campos(self):
        """Habilita/deshabilita campos según la opción seleccionada"""
        if self.opcion_tamaño.get() == 3:
            self.entry_ancho.config(state="normal")
            self.entry_alto.config(state="normal")
        else:
            self.entry_ancho.config(state="disabled")
            self.entry_alto.config(state="disabled")
    
    def seleccionar_archivo(self):
        """Abre diálogo para seleccionar archivo """
        archivo = filedialog.askopenfilename(
            title="Seleccionar imagen",
            initialdir="originales",
            filetypes=[
                      ("Imágenes", "*.png *.jpg *.jpeg *.bmp *.gif *.tiff *.webp"),
                      ("Archivos PNG", "*.png"),
                      ("Archivos JPG", "*.jpg *.jpeg"),
                      ("Todos los archivos", "*.*")
                      ]
        )
        
        if archivo:
            self.archivo_seleccionado.set(archivo)
            self.btn_convertir.config(state="normal")
            nombre_archivo = os.path.basename(archivo)
            self.label_archivo.config(text=nombre_archivo, foreground="blue")
            self.label_estado.config(text=f"Listo para convertir: {nombre_archivo}")
    
    def mostrar_preview(self, img, ancho, alto, tamaño_bytes):
        """Muestra la imagen convertida en el canvas"""
        # Limpiar canvas
        self.canvas.delete("all")
        
        # Calcular escala para ajustar al canvas
        canvas_ancho = self.canvas.winfo_width()
        canvas_alto = self.canvas.winfo_height()
        
        # Esperar a que el canvas tenga dimensiones válidas
        if canvas_ancho <= 1 or canvas_alto <= 1:
            self.root.update()
            canvas_ancho = self.canvas.winfo_width()
            canvas_alto = self.canvas.winfo_height()
        
        # Calcular escala manteniendo proporción
        escala_ancho = (canvas_ancho - 40) / ancho
        escala_alto = (canvas_alto - 40) / alto
        escala = min(escala_ancho, escala_alto, 1.0)  # No ampliar más allá del tamaño original
        
        # Redimensionar para preview si es necesario
        if escala < 1.0:
            nuevo_ancho = int(ancho * escala)
            nuevo_alto = int(alto * escala)
            img_preview = img.resize((nuevo_ancho, nuevo_alto), Image.NEAREST)
        else:
            img_preview = img
        
        # Convertir para mostrar en Tkinter
        self.imagen_preview = ImageTk.PhotoImage(img_preview)
        
        # Centrar en canvas
        x_pos = canvas_ancho // 2
        y_pos = canvas_alto // 2
        self.canvas.create_image(x_pos, y_pos, image=self.imagen_preview)
        
        # Actualizar información
        info = f"{ancho} × {alto} px | {tamaño_bytes} bytes"
        self.label_info_preview.config(text=info)
    
    def convertir(self):
        """Ejecuta la conversión de la imagen"""
        archivo_entrada = self.archivo_seleccionado.get()
        
        if not archivo_entrada:
            messagebox.showwarning("Advertencia", "Debe seleccionar un archivo primero")
            return
        
        try:
            # Determinar dimensiones según opción
            if self.opcion_tamaño.get() == 1:
                # Mantener original
                ancho, alto = None, None
            elif self.opcion_tamaño.get() == 2:
                # Tamaño fijo
                ancho, alto = 122, 250
            else:
                # Personalizado
                try:
                    ancho = int(self.ancho_custom.get())
                    alto = int(self.alto_custom.get())
                    if ancho <= 0 or alto <= 0:
                        raise ValueError
                except ValueError:
                    messagebox.showerror("Error", "Las dimensiones deben ser números enteros positivos")
                    return
            
            # Generar nombre de archivo de salida
            nombre_base = os.path.splitext(os.path.basename(archivo_entrada))[0]
            archivo_salida = os.path.join("binarios", f"{nombre_base}.bin")
            
            # Convertir
            self.label_estado.config(text="Convirtiendo imagen...")
            self.root.update()
            
            img_resultado, ancho_final, alto_final, tamaño_bytes = convertir_imagen(
                archivo_entrada, archivo_salida, ancho, alto
            )
            
            # Mostrar preview
            self.mostrar_preview(img_resultado, ancho_final, alto_final, tamaño_bytes)
            
            # Actualizar estado
            self.label_estado.config(
                text=f"✓ Conversión exitosa\n{archivo_salida}\n{ancho_final}×{alto_final} px, {tamaño_bytes} bytes"
            )
            
            messagebox.showinfo("Éxito", f"Imagen convertida correctamente:\n\n{archivo_salida}")
            
        except Exception as e:
            messagebox.showerror("Error", f"Error durante la conversión:\n{str(e)}")
            self.label_estado.config(text="Error en la conversión")

# -----------------------------
#  PUNTO DE ENTRADA
# -----------------------------
if __name__ == "__main__":
    root = tk.Tk()
    app = ConversorEInkGUI(root)
    root.mainloop()
📥 Descargar convertir.py

Observacion

Este programa se puede hacer funcionar en un entorno venv con python 3 instalando la librería Pillow (o PIL, Python Imaging Library) necesario para: abrir y manipular imágenes (Image, ImageOps, ImageEnhance, ImageFilter, etc.); aplicar dithering, redimensionamiento, conversión a escala de grises y a 1-bit; generar la previsualización en la interfaz.

Cerrar