diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3000f81
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,110 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
diff --git a/README.md b/README.md
index 0207ad6..4cc4baa 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
- Aplicación de procesamiento de fotos desarrollada en PyQt5 que permite aplicar efectos a imágenes. La aplicación es altamente modular, permitiendo a los usuarios crear y agregar sus propios efectos personalizados.
+ Aplicación de procesamiento de fotos desarrollada en PyQt5 que permite aplicar efectos a imágenes. La aplicación es altamente modular, permitiendo a los usuarios crear y agregar sus propios efectos personalizados mediante una interfaz gráfica o editando directamente el archivo JSON de configuración.
@@ -27,10 +27,12 @@ $${\color{red}Screenshots}$$
$${\color{yellow}Características}$$
-- **Interfaz de usuario muy amigable**
-- **Gran variedad de efectos**: programos con opencv y numpy
-- **Modularidad**: Los usuarios pueden crear sus propios efectos y agregarlos fácilmente.
-- **Libertad**: tienes la libertad de editar los efectos y configurar los parametros a tu gusto
+- **Interfaz de usuario muy amigable** con pestañas para edición de fotos y creación de filtros
+- **Gran variedad de efectos**: programados con opencv y numpy
+- **Modularidad**: Los usuarios pueden crear sus propios filtros desde la interfaz gráfica
+- **Configuración JSON**: Todos los filtros se almacenan en `filters.json` para fácil edición
+- **Persistencia**: Los filtros creados se guardan automáticamente y persisten entre sesiones
+- **Libertad**: Tienes la libertad de editar los filtros y configurar los parámetros a tu gusto
@@ -69,40 +71,130 @@ $${\color{lightgreen}Instalación}$$
3. Instala las dependencias:
`pip install -r requirements.txt`
-4. Ejecuta el script de configuración de efectos:
- `python generate_effects_settings.py`
-
-5. Disfruta:
+4. Ejecuta la aplicación:
`python main.py`
+$${\color{lightblue}Arquitectura \space del \space proyecto}$$
-$${\color{lightblue}Crea \space tus \space propios \space efectos❗}$$
+```
+MODT/
+├── main.py # Aplicación principal con interfaz PyQt5
+├── filter_engine.py # Motor de filtros con todas las operaciones disponibles
+├── filter_manager.py # Gestor de configuración de filtros (carga/guarda JSON)
+├── filters.json # Configuración de todos los filtros disponibles
+├── assets/ # Recursos gráficos (iconos, etc.)
+├── tests/ # Pruebas unitarias
+│ └── test_filter_manager.py
+├── requirements.txt # Dependencias del proyecto
+└── README.md # Este archivo
+```
-1. Crea un archivo Python en la carpeta `effects`. Este archivo debe definir una función que aplique el efecto y si es necesario otra que envie los parametros de los QSliders y QComboBox.
+
+
+$${\color{orange}Crea \space tus \space propios \space filtros❗}$$
+
+### Opción 1: Desde la interfaz gráfica (Recomendado)
+
+1. Abre la aplicación con `python main.py`
+2. Ve a la pestaña **"Editor de Filtros"**
+3. Rellena los campos:
+ - **Nombre**: El nombre de tu filtro
+ - **Categoría**: Selecciona una existente o escribe una nueva
+ - **Operación**: Selecciona la operación base (blur, pixelate, etc.)
+ - **Descripción**: Una breve descripción del filtro
+4. Añade parámetros si es necesario con el botón **"+ Añadir parámetro"**
+5. Haz clic en **"Guardar filtro"**
+6. ¡Tu filtro aparecerá inmediatamente en la pestaña "Editor de Fotos"!
+
+### Opción 2: Editando el archivo JSON
+
+Puedes editar directamente el archivo `filters.json`. Cada filtro tiene la siguiente estructura:
+
+```json
+{
+ "NombreCategoria": {
+ "nombre_filtro": {
+ "operation": "nombre_operacion",
+ "description": "Descripción del filtro",
+ "parameters": {
+ "parametro_slider": {
+ "min": 0,
+ "max": 100,
+ "init": 50,
+ "interval": 1
+ },
+ "parametro_opciones": {
+ "options": ["opcion1", "opcion2", "opcion3"],
+ "init": "opcion1"
+ }
+ }
+ }
+ }
+}
+```
-2. La función debe tener la siguiente estructura:
+
-```python
-def apply_effect(image, param1, param2...):
- # Implementa tu efecto aquí
- return modified_image
+### Operaciones disponibles
+
+| Operación | Descripción |
+|-----------|-------------|
+| `grayscale` | Convierte a escala de grises |
+| `hue_shift` | Cambia el matiz de los colores |
+| `saturate_color` | Aumenta la saturación de un color específico |
+| `indie_effect` | Efecto retro con bordes y ruido |
+| `gaussian_blur` | Desenfoque gaussiano suave |
+| `box_blur` | Desenfoque de caja |
+| `bilateral_filter` | Filtro bilateral que preserva bordes |
+| `median_blur` | Desenfoque de mediana |
+| `horizontal_blur` | Desenfoque de movimiento horizontal |
+| `vertical_blur` | Desenfoque de movimiento vertical |
+| `glitch` | Efecto glitch con líneas desplazadas |
+| `channel_shift` | Desplazamiento de canal de color |
+| `channel_shift_pro` | Desplazamiento avanzado por canal RGB |
+| `pixelate` | Pixelado básico |
+| `pixelate_pro` | Pixelado avanzado con métodos múltiples |
+| `gaussian_noise` | Añade ruido gaussiano |
+| `dilate` | Dilatación morfológica |
+| `dilate_pro` | Dilatación avanzada |
+| `erode` | Erosión morfológica |
+| `super_effect` | Combinación de blur, morfología y pirámide |
-def get_filter_data():
- return {
- "parameters": {
- "parametro1": {"min": valor_minimo, "max": valor_maximo, "init": valor_inicial, "interval": intervalo},
- "parametroOpcion": {"options": ["opcion1", "opcion2"], "init": "valor_inicial"},
- }
- }
+
+### Tipos de parámetros
+**Slider (deslizador):**
+```json
+"nombre_param": {
+ "min": 0, // Valor mínimo
+ "max": 100, // Valor máximo
+ "init": 50, // Valor inicial
+ "interval": 1 // Incremento del slider
+}
```
-3. Ejecuta el script `generate_effects_settings.py` para actualizar el archivo de configuración `effects_settings.py`.
+**Options (opciones):**
+```json
+"nombre_param": {
+ "options": ["opcion1", "opcion2", "opcion3"], // Lista de opciones
+ "init": "opcion1" // Opción seleccionada por defecto
+}
+```
+
+
+
+
+
+$${\color{cyan}Ejecutar \space pruebas}$$
+
+```bash
+python -m unittest tests.test_filter_manager -v
+```
@@ -110,12 +202,10 @@ def get_filter_data():
$${\color{pink}Contribuye🎉}$$
-Las contribuciones son super bienvenidas, si creas algun efecto y quieres compartirlo con el mundo no dudes en mandarmelo!
+Las contribuciones son super bienvenidas, si creas algún efecto y quieres compartirlo con el mundo no dudes en mandármelo!
-
-
diff --git a/effects_settings.py b/effects_settings.py
deleted file mode 100644
index eacab45..0000000
--- a/effects_settings.py
+++ /dev/null
@@ -1,197 +0,0 @@
-from filters import *
-
-FILTERS = {}
-
-from filters.Color import escala_grises
-from filters.Color import inidie
-from filters.Color import matiz
-from filters.Color import saturador
-from filters.Desenfoque import bilateral
-from filters.Desenfoque import box
-from filters.Desenfoque import desenfoque_gausiano
-from filters.Desenfoque import desenfoque_horizontal
-from filters.Desenfoque import desenfoque_vertical
-from filters.Desenfoque import mediana
-from filters.espejo import espejo
-from filters.glitch import glitch
-from filters.glitch import shift
-from filters.glitch import shift_pro
-from filters.pixel import pixelar
-from filters.pixel import pixelar_pro
-from filters.Ruido import ruido_gausiano
-from filters.varios import dilatar
-from filters.varios import dilatar_pro
-from filters.varios import erode
-from filters.varios import super_queee
-
-FILTERS = {
- "Color": {
- "escala_grises": {
- "apply": escala_grises.apply_effect,
- },
- "inidie": {
- "apply": inidie.apply_effect,
- "parameters": {
- "blur_kernel_size": {'min': 3, 'max': 15, 'init': 5, 'interval': 2},
- "canny_threshold1": {'min': 50, 'max': 150, 'init': 100, 'interval': 10},
- "canny_threshold2": {'min': 100, 'max': 250, 'init': 200, 'interval': 10},
- "color_adjustment": {'min': 0.5, 'max': 2.0, 'init': 1.5, 'interval': 0.1},
- "noise_level": {'min': 10, 'max': 30, 'init': 20, 'interval': 5},
- }
- },
- "matiz": {
- "apply": matiz.apply_effect,
- "parameters": {
- "hue_shift": {'min': -180, 'max': 180, 'init': 0, 'interval': 1},
- }
- },
- "saturador": {
- "apply": saturador.apply_effect,
- "parameters": {
- "color": {'options': ['red', 'green', 'blue', 'yellow', 'cyan', 'magenta'], 'init': 'red'},
- "saturation_scale": {'min': 0, 'max': 100, 'init': 50, 'interval': 1},
- "brightness_boost": {'min': 0, 'max': 100, 'init': 50, 'interval': 1},
- }
- },
- },
- "Desenfoque": {
- "bilateral": {
- "apply": bilateral.apply_effect,
- "parameters": {
- "d": {'min': 1, 'max': 10, 'init': 3, 'interval': 1},
- "sigmaColor": {'min': 1, 'max': 100, 'init': 20, 'interval': 1},
- "sigmaSpace": {'min': 1, 'max': 100, 'init': 15, 'interval': 1},
- }
- },
- "box": {
- "apply": box.apply_effect,
- "parameters": {
- "ksize_x": {'min': 3, 'max': 15, 'init': 5, 'interval': 2},
- "ksize_y": {'min': 3, 'max': 15, 'init': 5, 'interval': 2},
- }
- },
- "desenfoque_gausiano": {
- "apply": desenfoque_gausiano.apply_effect,
- "parameters": {
- "kernel_size": {'min': 1, 'max': 31, 'init': 15, 'interval': 2},
- "sigma": {'min': 1, 'max': 10, 'init': 5, 'interval': 1},
- }
- },
- "desenfoque_horizontal": {
- "apply": desenfoque_horizontal.apply_effect,
- "parameters": {
- "kernel_size": {'min': 1, 'max': 101, 'init': 3, 'interval': 2},
- }
- },
- "desenfoque_vertical": {
- "apply": desenfoque_vertical.apply_effect,
- "parameters": {
- "multi": {'min': 1, 'max': 100, 'init': 30, 'interval': 1},
- }
- },
- "mediana": {
- "apply": mediana.apply_effect,
- "parameters": {
- "kernel_size": {'min': 1, 'max': 101, 'init': 3, 'interval': 2},
- }
- },
- },
- "espejo": {
- "espejo": {
- "apply": espejo.apply_effect,
- "parameters": {
- "angle": {'min': -45, 'max': 45, 'init': 0, 'interval': 1},
- "direction": {'options': ['Horizontal', 'Vertical'], 'init': 'Horizontal'},
- "flip_type": {'options': ['Full', 'Partial'], 'init': 'Full'},
- "split_ratio": {'min': 1, 'max': 9, 'init': 5, 'interval': 1},
- }
- },
- },
- "glitch": {
- "glitch": {
- "apply": glitch.apply_effect,
- "parameters": {
- "glitch_intensity": {'min': 1, 'max': 50, 'init': 10, 'interval': 1},
- "glitch_frequency": {'min': 1, 'max': 50, 'init': 5, 'interval': 1},
- }
- },
- "shift": {
- "apply": shift.apply_effect,
- "parameters": {
- "shift_value": {'min': -50, 'max': 50, 'init': 5, 'interval': 1},
- }
- },
- "shift_pro": {
- "apply": shift_pro.apply_effect,
- "parameters": {
- "shift_value_b": {'min': -50, 'max': 50, 'init': 5, 'interval': 1},
- "shift_value_g": {'min': -50, 'max': 50, 'init': -5, 'interval': 1},
- "shift_value_r": {'min': -50, 'max': 50, 'init': 2, 'interval': 1},
- "direction_b": {'options': ['horizontal', 'vertical'], 'init': 'horizontal'},
- "direction_g": {'options': ['horizontal', 'vertical'], 'init': 'horizontal'},
- "direction_r": {'options': ['horizontal', 'vertical'], 'init': 'vertical'},
- }
- },
- },
- "pixel": {
- "pixelar": {
- "apply": pixelar.apply_effect,
- "parameters": {
- "block_size": {'min': 2, 'max': 50, 'init': 5, 'interval': 1},
- }
- },
- "pixelar_pro": {
- "apply": pixelar_pro.apply_effect,
- "parameters": {
- "block_size": {'min': 2, 'max': 50, 'init': 5, 'interval': 1},
- "method": {'options': ['mean', 'median', 'mode'], 'init': 'mean'},
- "intensity": {'min': 0.1, 'max': 2.0, 'init': 1.0, 'interval': 0.1},
- "block_shape": {'options': ['square', 'rectangular'], 'init': 'square'},
- }
- },
- },
- "Ruido": {
- "ruido_gausiano": {
- "apply": ruido_gausiano.apply_effect,
- "parameters": {
- "mean": {'min': -100, 'max': 100, 'init': 0, 'interval': 10},
- "std_dev": {'min': 1, 'max': 10, 'init': 5, 'interval': 1},
- }
- },
- },
- "varios": {
- "dilatar": {
- "apply": dilatar.apply_effect,
- "parameters": {
- "kernel_size": {'min': 2, 'max': 50, 'init': 5, 'interval': 1},
- }
- },
- "dilatar_pro": {
- "apply": dilatar_pro.apply_effect,
- "parameters": {
- "kernel_size": {'min': 2, 'max': 50, 'init': 5, 'interval': 1},
- "anchor": {'min': -1, 'max': -1, 'init': -1, 'interval': 1},
- "iterations": {'min': 1, 'max': 10, 'init': 1, 'interval': 1},
- "border_value": {'min': 0, 'max': 255, 'init': 0, 'interval': 1},
- }
- },
- "erode": {
- "apply": erode.apply_effect,
- "parameters": {
- "kernel_size": {'min': 2, 'max': 50, 'init': 5, 'interval': 1},
- "anchor": {'min': -1, 'max': -1, 'init': -1, 'interval': 1},
- "iterations": {'min': 1, 'max': 10, 'init': 1, 'interval': 1},
- "border_type": {'min': 0, 'max': 4, 'init': 0, 'interval': 1},
- "border_value": {'min': 0, 'max': 255, 'init': 0, 'interval': 1},
- }
- },
- "super_queee": {
- "apply": super_queee.apply_effect,
- "parameters": {
- "blur_radius": {'min': 1, 'max': 10, 'init': 5, 'interval': 1},
- "morphology_iterations": {'min': 1, 'max': 5, 'init': 2, 'interval': 1},
- "pyramid_levels": {'min': 1, 'max': 5, 'init': 3, 'interval': 1},
- }
- },
- },
-}
diff --git a/filter_engine.py b/filter_engine.py
new file mode 100644
index 0000000..ee90426
--- /dev/null
+++ b/filter_engine.py
@@ -0,0 +1,440 @@
+"""
+Filter Engine Module
+
+This module contains all the built-in filter operations that can be combined
+to create custom filters. Each operation is a function that takes an image
+and parameters, returning the modified image.
+"""
+
+import cv2
+import numpy as np
+from typing import Any, Dict, Callable
+
+
+class FilterEngine:
+ """
+ Engine that applies filter operations to images.
+
+ Operations are predefined functions that can be configured via JSON.
+ Filters can be single operations or pipelines of multiple operations.
+ """
+
+ def __init__(self):
+ """Initialize the filter engine with all available operations."""
+ self._operations: Dict[str, Callable] = {
+ # Color operations
+ "grayscale": self._grayscale,
+ "hue_shift": self._hue_shift,
+ "saturate_color": self._saturate_color,
+ "indie_effect": self._indie_effect,
+
+ # Blur operations
+ "gaussian_blur": self._gaussian_blur,
+ "box_blur": self._box_blur,
+ "bilateral_filter": self._bilateral_filter,
+ "median_blur": self._median_blur,
+ "horizontal_blur": self._horizontal_blur,
+ "vertical_blur": self._vertical_blur,
+
+ # Glitch operations
+ "glitch": self._glitch,
+ "channel_shift": self._channel_shift,
+ "channel_shift_pro": self._channel_shift_pro,
+
+ # Pixel operations
+ "pixelate": self._pixelate,
+ "pixelate_pro": self._pixelate_pro,
+
+ # Noise operations
+ "gaussian_noise": self._gaussian_noise,
+
+ # Morphology operations
+ "dilate": self._dilate,
+ "dilate_pro": self._dilate_pro,
+ "erode": self._erode,
+ "super_effect": self._super_effect,
+ }
+
+ def get_available_operations(self) -> list:
+ """Get list of all available operations."""
+ return list(self._operations.keys())
+
+ def apply_operation(self, image: np.ndarray, operation: str, **params) -> np.ndarray:
+ """
+ Apply a single operation to an image.
+
+ Args:
+ image: The input image
+ operation: Name of the operation to apply
+ **params: Parameters for the operation
+
+ Returns:
+ The modified image
+ """
+ if operation not in self._operations:
+ raise ValueError(f"Unknown operation: {operation}")
+
+ return self._operations[operation](image, **params)
+
+ def apply_filter(self, image: np.ndarray, filter_config: Dict[str, Any]) -> np.ndarray:
+ """
+ Apply a filter (which may be a pipeline of operations) to an image.
+
+ Args:
+ image: The input image
+ filter_config: Filter configuration from JSON
+
+ Returns:
+ The modified image
+ """
+ operation = filter_config.get("operation")
+ if not operation:
+ raise ValueError("Filter must have an 'operation' field")
+
+ # Extract parameter values (use 'init' as default)
+ params = {}
+ for param_name, param_config in filter_config.get("parameters", {}).items():
+ if isinstance(param_config, dict):
+ params[param_name] = param_config.get("init", param_config.get("value"))
+ else:
+ params[param_name] = param_config
+
+ return self.apply_operation(image, operation, **params)
+
+ def apply_filter_with_params(self, image: np.ndarray, filter_config: Dict[str, Any],
+ user_params: Dict[str, Any]) -> np.ndarray:
+ """
+ Apply a filter with user-provided parameter values.
+
+ Args:
+ image: The input image
+ filter_config: Filter configuration from JSON
+ user_params: Parameter values provided by the user
+
+ Returns:
+ The modified image
+ """
+ operation = filter_config.get("operation")
+ if not operation:
+ raise ValueError("Filter must have an 'operation' field")
+
+ return self.apply_operation(image, operation, **user_params)
+
+ # ==================== Color Operations ====================
+
+ def _grayscale(self, image: np.ndarray, **kwargs) -> np.ndarray:
+ """Convert image to grayscale."""
+ return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+
+ def _hue_shift(self, image: np.ndarray, hue_shift: int = 0, **kwargs) -> np.ndarray:
+ """Shift the hue of the image."""
+ hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
+ hsv_image[:, :, 0] = (hsv_image[:, :, 0].astype(int) + hue_shift) % 180
+ return cv2.cvtColor(hsv_image, cv2.COLOR_HSV2BGR)
+
+ def _saturate_color(self, image: np.ndarray, color: str = "red",
+ saturation_scale: int = 50, brightness_boost: int = 50,
+ **kwargs) -> np.ndarray:
+ """Enhance saturation and brightness for a specific color."""
+ hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
+ h, s, v = cv2.split(hsv_image)
+
+ color_ranges = {
+ 'red': [(0, 10), (160, 180)],
+ 'green': [(35, 85)],
+ 'blue': [(100, 140)],
+ 'yellow': [(25, 35)],
+ 'cyan': [(85, 100)],
+ 'magenta': [(140, 160)]
+ }
+
+ ranges = color_ranges.get(color, [(0, 180)])
+ masks = []
+ for (lower, upper) in ranges:
+ mask = cv2.inRange(h, lower, upper)
+ masks.append(mask)
+
+ color_mask = masks[0]
+ for mask in masks[1:]:
+ color_mask = cv2.bitwise_or(color_mask, mask)
+
+ s = cv2.add(s, (saturation_scale * (color_mask // 255)).astype(np.uint8))
+ v = cv2.add(v, (brightness_boost * (color_mask // 255)).astype(np.uint8))
+
+ s = np.clip(s, 0, 255)
+ v = np.clip(v, 0, 255)
+
+ enhanced_hsv = cv2.merge([h, s, v])
+ return cv2.cvtColor(enhanced_hsv, cv2.COLOR_HSV2BGR)
+
+ def _indie_effect(self, image: np.ndarray, blur_kernel_size: int = 5,
+ canny_threshold1: int = 100, canny_threshold2: int = 200,
+ color_adjustment: float = 1.5, noise_level: int = 20,
+ **kwargs) -> np.ndarray:
+ """Apply indie/retro effect."""
+ if blur_kernel_size % 2 == 0:
+ blur_kernel_size += 1
+
+ blurred = cv2.GaussianBlur(image, (blur_kernel_size, blur_kernel_size), 0)
+ edges = cv2.Canny(blurred, canny_threshold1, canny_threshold2)
+ edges_3ch = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
+
+ indie_image = np.clip(
+ color_adjustment * blurred - (1 - color_adjustment) * edges_3ch,
+ 0, 255
+ ).astype(np.uint8)
+
+ noise = np.random.normal(0, noise_level, image.shape).astype(np.uint8)
+ indie_image = np.clip(indie_image + noise, 0, 255).astype(np.uint8)
+
+ return indie_image
+
+ # ==================== Blur Operations ====================
+
+ def _gaussian_blur(self, image: np.ndarray, kernel_size: int = 15,
+ sigma: int = 5, **kwargs) -> np.ndarray:
+ """Apply Gaussian blur."""
+ if kernel_size % 2 == 0:
+ kernel_size += 1
+ return cv2.GaussianBlur(image, (kernel_size, kernel_size), sigma)
+
+ def _box_blur(self, image: np.ndarray, ksize_x: int = 5,
+ ksize_y: int = 5, **kwargs) -> np.ndarray:
+ """Apply box blur."""
+ if ksize_x % 2 == 0:
+ ksize_x += 1
+ if ksize_y % 2 == 0:
+ ksize_y += 1
+ return cv2.boxFilter(image, -1, (ksize_x, ksize_y))
+
+ def _bilateral_filter(self, image: np.ndarray, d: int = 3,
+ sigmaColor: int = 20, sigmaSpace: int = 15,
+ **kwargs) -> np.ndarray:
+ """Apply bilateral filter."""
+ return cv2.bilateralFilter(image, d, sigmaColor, sigmaSpace)
+
+ def _median_blur(self, image: np.ndarray, kernel_size: int = 3,
+ **kwargs) -> np.ndarray:
+ """Apply median blur."""
+ if kernel_size % 2 == 0:
+ kernel_size += 1
+ return cv2.medianBlur(image, kernel_size)
+
+ def _horizontal_blur(self, image: np.ndarray, kernel_size: int = 3,
+ **kwargs) -> np.ndarray:
+ """Apply horizontal motion blur."""
+ if kernel_size % 2 == 0:
+ kernel_size += 1
+ kernel = np.zeros((kernel_size, kernel_size))
+ kernel[int((kernel_size - 1) / 2), :] = np.ones(kernel_size)
+ kernel /= kernel_size
+ return cv2.filter2D(image, -1, kernel)
+
+ def _vertical_blur(self, image: np.ndarray, multi: int = 30,
+ **kwargs) -> np.ndarray:
+ """Apply vertical motion blur."""
+ kernel_size = max(3, multi)
+ if kernel_size % 2 == 0:
+ kernel_size += 1
+ kernel = np.zeros((kernel_size, kernel_size))
+ kernel[:, int((kernel_size - 1) / 2)] = np.ones(kernel_size)
+ kernel /= kernel_size
+ return cv2.filter2D(image, -1, kernel)
+
+ # ==================== Glitch Operations ====================
+
+ def _glitch(self, image: np.ndarray, glitch_intensity: int = 10,
+ glitch_frequency: int = 5, **kwargs) -> np.ndarray:
+ """Apply glitch effect."""
+ rows, cols = image.shape[:2]
+ glitch_image = image.copy()
+
+ step = max(1, glitch_frequency)
+ for i in range(0, rows, step):
+ shift = np.random.randint(-glitch_intensity, glitch_intensity + 1)
+ glitch_image[i] = np.roll(image[i], shift, axis=0)
+
+ return glitch_image
+
+ def _channel_shift(self, image: np.ndarray, shift_value: int = 5,
+ **kwargs) -> np.ndarray:
+ """Shift color channels."""
+ result = image.copy()
+ result[:, :, 0] = np.roll(image[:, :, 0], shift_value, axis=1)
+ return result
+
+ def _channel_shift_pro(self, image: np.ndarray,
+ shift_value_b: int = 5, shift_value_g: int = -5,
+ shift_value_r: int = 2,
+ direction_b: str = "horizontal",
+ direction_g: str = "horizontal",
+ direction_r: str = "vertical",
+ **kwargs) -> np.ndarray:
+ """Advanced channel shifting with per-channel control."""
+ result = image.copy()
+
+ axis_b = 1 if direction_b == "horizontal" else 0
+ axis_g = 1 if direction_g == "horizontal" else 0
+ axis_r = 1 if direction_r == "horizontal" else 0
+
+ result[:, :, 0] = np.roll(image[:, :, 0], shift_value_b, axis=axis_b)
+ result[:, :, 1] = np.roll(image[:, :, 1], shift_value_g, axis=axis_g)
+ result[:, :, 2] = np.roll(image[:, :, 2], shift_value_r, axis=axis_r)
+
+ return result
+
+ # ==================== Pixel Operations ====================
+
+ def _pixelate(self, image: np.ndarray, block_size: int = 5,
+ **kwargs) -> np.ndarray:
+ """Apply pixelation effect."""
+ height, width = image.shape[:2]
+ block_size = max(2, block_size)
+
+ num_blocks_x = width // block_size
+ num_blocks_y = height // block_size
+
+ if num_blocks_x == 0 or num_blocks_y == 0:
+ return image
+
+ pixelated = np.zeros(
+ (num_blocks_y * block_size, num_blocks_x * block_size, 3),
+ dtype=np.uint8
+ )
+
+ for y in range(num_blocks_y):
+ for x in range(num_blocks_x):
+ start_x = x * block_size
+ end_x = (x + 1) * block_size
+ start_y = y * block_size
+ end_y = (y + 1) * block_size
+ block = image[start_y:end_y, start_x:end_x]
+ pixelated[start_y:end_y, start_x:end_x] = block.mean(axis=(0, 1))
+
+ return pixelated
+
+ def _pixelate_pro(self, image: np.ndarray, block_size: int = 5,
+ method: str = "mean", intensity: float = 1.0,
+ block_shape: str = "square", **kwargs) -> np.ndarray:
+ """Advanced pixelation with multiple methods."""
+ height, width = image.shape[:2]
+ block_size = max(2, block_size)
+
+ if block_shape == "rectangular":
+ block_w = block_size
+ block_h = max(2, block_size // 2)
+ else:
+ block_w = block_h = block_size
+
+ num_blocks_x = width // block_w
+ num_blocks_y = height // block_h
+
+ if num_blocks_x == 0 or num_blocks_y == 0:
+ return image
+
+ pixelated = np.zeros(
+ (num_blocks_y * block_h, num_blocks_x * block_w, 3),
+ dtype=np.uint8
+ )
+
+ for y in range(num_blocks_y):
+ for x in range(num_blocks_x):
+ start_x = x * block_w
+ end_x = (x + 1) * block_w
+ start_y = y * block_h
+ end_y = (y + 1) * block_h
+ block = image[start_y:end_y, start_x:end_x]
+
+ if method == "median":
+ color = np.median(block, axis=(0, 1))
+ elif method == "mode":
+ # Approximate mode using histogram
+ color = block.mean(axis=(0, 1))
+ else: # mean
+ color = block.mean(axis=(0, 1))
+
+ color = np.clip(color * intensity, 0, 255).astype(np.uint8)
+ pixelated[start_y:end_y, start_x:end_x] = color
+
+ return pixelated
+
+ # ==================== Noise Operations ====================
+
+ def _gaussian_noise(self, image: np.ndarray, mean: int = 0,
+ std_dev: int = 5, **kwargs) -> np.ndarray:
+ """Add Gaussian noise to image."""
+ noise = np.random.normal(mean, std_dev, image.shape).astype(np.int16)
+ noisy = np.clip(image.astype(np.int16) + noise, 0, 255).astype(np.uint8)
+ return noisy
+
+ # ==================== Morphology Operations ====================
+
+ def _dilate(self, image: np.ndarray, kernel_size: int = 5,
+ **kwargs) -> np.ndarray:
+ """Apply dilation."""
+ kernel = np.ones((kernel_size, kernel_size), np.uint8)
+ return cv2.dilate(image, kernel, iterations=1)
+
+ def _dilate_pro(self, image: np.ndarray, kernel_size: int = 5,
+ anchor: int = -1, iterations: int = 1,
+ border_value: int = 0, **kwargs) -> np.ndarray:
+ """Advanced dilation with more control."""
+ kernel = np.ones((kernel_size, kernel_size), np.uint8)
+ anchor_pt = (anchor, anchor) if anchor >= 0 else (-1, -1)
+ return cv2.dilate(
+ image, kernel,
+ anchor=anchor_pt,
+ iterations=iterations,
+ borderType=cv2.BORDER_CONSTANT,
+ borderValue=border_value
+ )
+
+ def _erode(self, image: np.ndarray, kernel_size: int = 5,
+ anchor: int = -1, iterations: int = 1,
+ border_type: int = 0, border_value: int = 0,
+ **kwargs) -> np.ndarray:
+ """Apply erosion."""
+ kernel = np.ones((kernel_size, kernel_size), np.uint8)
+ anchor_pt = (anchor, anchor) if anchor >= 0 else (-1, -1)
+ border_types = [
+ cv2.BORDER_CONSTANT, cv2.BORDER_REPLICATE,
+ cv2.BORDER_REFLECT, cv2.BORDER_WRAP, cv2.BORDER_REFLECT_101
+ ]
+ bt = border_types[min(border_type, len(border_types) - 1)]
+ return cv2.erode(
+ image, kernel,
+ anchor=anchor_pt,
+ iterations=iterations,
+ borderType=bt,
+ borderValue=border_value
+ )
+
+ def _super_effect(self, image: np.ndarray, blur_radius: int = 5,
+ morphology_iterations: int = 2, pyramid_levels: int = 3,
+ **kwargs) -> np.ndarray:
+ """Apply super effect (blur + morphology + pyramid)."""
+ blurred = cv2.boxFilter(image, -1, (blur_radius, blur_radius))
+
+ kernel = np.ones((5, 5), np.uint8)
+ morphed = cv2.morphologyEx(
+ blurred, cv2.MORPH_GRADIENT, kernel,
+ iterations=morphology_iterations
+ )
+
+ height, width = image.shape[:2]
+ result = cv2.pyrDown(morphed, dstsize=(width // 2, height // 2))
+ result = cv2.pyrUp(result, dstsize=(width, height))
+
+ return result
+
+
+# Singleton instance
+_filter_engine = None
+
+
+def get_filter_engine() -> FilterEngine:
+ """Get the singleton FilterEngine instance."""
+ global _filter_engine
+ if _filter_engine is None:
+ _filter_engine = FilterEngine()
+ return _filter_engine
diff --git a/filter_manager.py b/filter_manager.py
new file mode 100644
index 0000000..23fe49f
--- /dev/null
+++ b/filter_manager.py
@@ -0,0 +1,243 @@
+"""
+Filter Manager Module
+
+This module handles loading, saving, and managing filter configurations
+from the filters.json file. It provides a clean interface for the UI
+to interact with filter definitions.
+"""
+
+import json
+import os
+from typing import Dict, Any, Optional, Callable
+
+from filter_engine import get_filter_engine, FilterEngine
+
+
+FILTERS_JSON_PATH = os.path.join(os.path.dirname(__file__), "filters.json")
+
+
+class FilterManager:
+ """Manages filter configurations from JSON file."""
+
+ def __init__(self, json_path: str = FILTERS_JSON_PATH):
+ """
+ Initialize the FilterManager.
+
+ Args:
+ json_path: Path to the filters.json file
+ """
+ self.json_path = json_path
+ self._filters_config: Dict[str, Dict[str, Any]] = {}
+ self._engine: FilterEngine = get_filter_engine()
+ self.load_filters()
+
+ def load_filters(self) -> Dict[str, Dict[str, Any]]:
+ """
+ Load filter configurations from the JSON file.
+
+ Returns:
+ Dictionary containing all filter configurations
+ """
+ try:
+ with open(self.json_path, 'r', encoding='utf-8') as f:
+ self._filters_config = json.load(f)
+ except FileNotFoundError:
+ self._filters_config = {}
+ except json.JSONDecodeError as e:
+ raise ValueError(f"Error parsing filters.json: {e}")
+
+ return self._filters_config
+
+ def save_filters(self) -> None:
+ """Save current filter configurations to the JSON file."""
+ with open(self.json_path, 'w', encoding='utf-8') as f:
+ json.dump(self._filters_config, f, indent=4, ensure_ascii=False)
+
+ def get_categories(self) -> list:
+ """
+ Get all available filter categories.
+
+ Returns:
+ List of category names
+ """
+ return list(self._filters_config.keys())
+
+ def get_filters_in_category(self, category: str) -> Dict[str, Any]:
+ """
+ Get all filters in a specific category.
+
+ Args:
+ category: The category name
+
+ Returns:
+ Dictionary of filter configurations in the category
+ """
+ return self._filters_config.get(category, {})
+
+ def get_filter(self, category: str, filter_name: str) -> Optional[Dict[str, Any]]:
+ """
+ Get a specific filter configuration.
+
+ Args:
+ category: The category name
+ filter_name: The filter name
+
+ Returns:
+ Filter configuration dictionary or None if not found
+ """
+ return self._filters_config.get(category, {}).get(filter_name)
+
+ def get_filter_info(self, category: str, filter_name: str) -> Optional[Dict[str, Any]]:
+ """
+ Get filter info including the apply function.
+
+ Args:
+ category: The category name
+ filter_name: The filter name
+
+ Returns:
+ Filter info dictionary with 'apply' and 'parameters' keys
+ """
+ filter_config = self.get_filter(category, filter_name)
+ if not filter_config:
+ return None
+
+ operation = filter_config.get("operation")
+ if not operation:
+ return None
+
+ # Create an apply function that uses the filter engine
+ def apply_func(image, **params):
+ return self._engine.apply_operation(image, operation, **params)
+
+ return {
+ "apply": apply_func,
+ "parameters": filter_config.get("parameters", {}),
+ "description": filter_config.get("description", "")
+ }
+
+ def get_available_operations(self) -> list:
+ """
+ Get list of all available filter operations.
+
+ Returns:
+ List of operation names
+ """
+ return self._engine.get_available_operations()
+
+ def add_filter(self, category: str, filter_name: str,
+ operation: str, description: str = "",
+ parameters: Optional[Dict[str, Any]] = None) -> bool:
+ """
+ Add a new filter to the configuration.
+
+ Args:
+ category: The category name (created if doesn't exist)
+ filter_name: The name of the new filter
+ operation: The operation type (must be a valid operation)
+ description: Description of the filter
+ parameters: Dictionary of parameter configurations
+
+ Returns:
+ True if successful, False otherwise
+ """
+ # Validate operation exists
+ if operation not in self._engine.get_available_operations():
+ raise ValueError(f"Unknown operation: {operation}")
+
+ if category not in self._filters_config:
+ self._filters_config[category] = {}
+
+ self._filters_config[category][filter_name] = {
+ "operation": operation,
+ "description": description,
+ "parameters": parameters or {}
+ }
+
+ self.save_filters()
+ return True
+
+ def remove_filter(self, category: str, filter_name: str) -> bool:
+ """
+ Remove a filter from the configuration.
+
+ Args:
+ category: The category name
+ filter_name: The filter name
+
+ Returns:
+ True if successful, False if filter not found
+ """
+ if category in self._filters_config and filter_name in self._filters_config[category]:
+ del self._filters_config[category][filter_name]
+ if not self._filters_config[category]:
+ del self._filters_config[category]
+ self.save_filters()
+ return True
+ return False
+
+ def validate_parameters(self, parameters: Dict[str, Any]) -> tuple:
+ """
+ Validate parameter configurations.
+
+ Args:
+ parameters: Dictionary of parameter configurations
+
+ Returns:
+ Tuple of (is_valid: bool, error_message: str)
+ """
+ if not isinstance(parameters, dict):
+ return False, "Parameters must be a dictionary"
+
+ for param_name, param_config in parameters.items():
+ if not isinstance(param_name, str) or not param_name:
+ return False, "Parameter name must be a non-empty string"
+
+ if not isinstance(param_config, dict):
+ return False, f"Parameter '{param_name}' config must be a dictionary"
+
+ if "options" in param_config:
+ if not isinstance(param_config["options"], list) or len(param_config["options"]) == 0:
+ return False, f"Parameter '{param_name}': 'options' must be a non-empty list"
+ if "init" not in param_config:
+ return False, f"Parameter '{param_name}': missing 'init' value"
+ if param_config["init"] not in param_config["options"]:
+ return False, f"Parameter '{param_name}': 'init' must be one of the options"
+ else:
+ required_keys = ["min", "max", "init", "interval"]
+ for key in required_keys:
+ if key not in param_config:
+ return False, f"Parameter '{param_name}': missing required key '{key}'"
+
+ try:
+ min_val = float(param_config["min"])
+ max_val = float(param_config["max"])
+ init_val = float(param_config["init"])
+ interval = float(param_config["interval"])
+ except (TypeError, ValueError):
+ return False, f"Parameter '{param_name}': values must be numeric"
+
+ if min_val > max_val:
+ return False, f"Parameter '{param_name}': 'min' cannot be greater than 'max'"
+ if init_val < min_val or init_val > max_val:
+ return False, f"Parameter '{param_name}': 'init' must be between 'min' and 'max'"
+ if interval <= 0:
+ return False, f"Parameter '{param_name}': 'interval' must be positive"
+
+ return True, ""
+
+
+_filter_manager: Optional[FilterManager] = None
+
+
+def get_filter_manager() -> FilterManager:
+ """
+ Get the singleton FilterManager instance.
+
+ Returns:
+ The FilterManager instance
+ """
+ global _filter_manager
+ if _filter_manager is None:
+ _filter_manager = FilterManager()
+ return _filter_manager
diff --git a/filters.json b/filters.json
new file mode 100644
index 0000000..3083c8e
--- /dev/null
+++ b/filters.json
@@ -0,0 +1,181 @@
+{
+ "Color": {
+ "escala_grises": {
+ "operation": "grayscale",
+ "description": "Convierte la imagen a escala de grises",
+ "parameters": {}
+ },
+ "indie": {
+ "operation": "indie_effect",
+ "description": "Efecto retro/indie con bordes y ruido",
+ "parameters": {
+ "blur_kernel_size": {"min": 3, "max": 15, "init": 5, "interval": 2},
+ "canny_threshold1": {"min": 50, "max": 150, "init": 100, "interval": 10},
+ "canny_threshold2": {"min": 100, "max": 250, "init": 200, "interval": 10},
+ "color_adjustment": {"min": 0.5, "max": 2.0, "init": 1.5, "interval": 0.1},
+ "noise_level": {"min": 10, "max": 30, "init": 20, "interval": 5}
+ }
+ },
+ "matiz": {
+ "operation": "hue_shift",
+ "description": "Cambia el matiz/tono de los colores",
+ "parameters": {
+ "hue_shift": {"min": -180, "max": 180, "init": 0, "interval": 1}
+ }
+ },
+ "saturador": {
+ "operation": "saturate_color",
+ "description": "Aumenta la saturación de un color específico",
+ "parameters": {
+ "color": {"options": ["red", "green", "blue", "yellow", "cyan", "magenta"], "init": "red"},
+ "saturation_scale": {"min": 0, "max": 100, "init": 50, "interval": 1},
+ "brightness_boost": {"min": 0, "max": 100, "init": 50, "interval": 1}
+ }
+ }
+ },
+ "Desenfoque": {
+ "bilateral": {
+ "operation": "bilateral_filter",
+ "description": "Filtro bilateral que preserva bordes",
+ "parameters": {
+ "d": {"min": 1, "max": 10, "init": 3, "interval": 1},
+ "sigmaColor": {"min": 1, "max": 100, "init": 20, "interval": 1},
+ "sigmaSpace": {"min": 1, "max": 100, "init": 15, "interval": 1}
+ }
+ },
+ "box": {
+ "operation": "box_blur",
+ "description": "Desenfoque de caja",
+ "parameters": {
+ "ksize_x": {"min": 3, "max": 15, "init": 5, "interval": 2},
+ "ksize_y": {"min": 3, "max": 15, "init": 5, "interval": 2}
+ }
+ },
+ "desenfoque_gausiano": {
+ "operation": "gaussian_blur",
+ "description": "Desenfoque gaussiano suave",
+ "parameters": {
+ "kernel_size": {"min": 1, "max": 31, "init": 15, "interval": 2},
+ "sigma": {"min": 1, "max": 10, "init": 5, "interval": 1}
+ }
+ },
+ "desenfoque_horizontal": {
+ "operation": "horizontal_blur",
+ "description": "Desenfoque de movimiento horizontal",
+ "parameters": {
+ "kernel_size": {"min": 1, "max": 101, "init": 3, "interval": 2}
+ }
+ },
+ "desenfoque_vertical": {
+ "operation": "vertical_blur",
+ "description": "Desenfoque de movimiento vertical",
+ "parameters": {
+ "multi": {"min": 1, "max": 100, "init": 30, "interval": 1}
+ }
+ },
+ "mediana": {
+ "operation": "median_blur",
+ "description": "Desenfoque de mediana para eliminar ruido",
+ "parameters": {
+ "kernel_size": {"min": 1, "max": 101, "init": 3, "interval": 2}
+ }
+ }
+ },
+ "Glitch": {
+ "glitch": {
+ "operation": "glitch",
+ "description": "Efecto glitch con líneas desplazadas",
+ "parameters": {
+ "glitch_intensity": {"min": 1, "max": 50, "init": 10, "interval": 1},
+ "glitch_frequency": {"min": 1, "max": 50, "init": 5, "interval": 1}
+ }
+ },
+ "shift": {
+ "operation": "channel_shift",
+ "description": "Desplazamiento de canal de color",
+ "parameters": {
+ "shift_value": {"min": -50, "max": 50, "init": 5, "interval": 1}
+ }
+ },
+ "shift_pro": {
+ "operation": "channel_shift_pro",
+ "description": "Desplazamiento avanzado de canales RGB",
+ "parameters": {
+ "shift_value_b": {"min": -50, "max": 50, "init": 5, "interval": 1},
+ "shift_value_g": {"min": -50, "max": 50, "init": -5, "interval": 1},
+ "shift_value_r": {"min": -50, "max": 50, "init": 2, "interval": 1},
+ "direction_b": {"options": ["horizontal", "vertical"], "init": "horizontal"},
+ "direction_g": {"options": ["horizontal", "vertical"], "init": "horizontal"},
+ "direction_r": {"options": ["horizontal", "vertical"], "init": "vertical"}
+ }
+ }
+ },
+ "Pixel": {
+ "pixelar": {
+ "operation": "pixelate",
+ "description": "Efecto de pixelado básico",
+ "parameters": {
+ "block_size": {"min": 2, "max": 50, "init": 5, "interval": 1}
+ }
+ },
+ "pixelar_pro": {
+ "operation": "pixelate_pro",
+ "description": "Pixelado avanzado con múltiples métodos",
+ "parameters": {
+ "block_size": {"min": 2, "max": 50, "init": 5, "interval": 1},
+ "method": {"options": ["mean", "median", "mode"], "init": "mean"},
+ "intensity": {"min": 0.1, "max": 2.0, "init": 1.0, "interval": 0.1},
+ "block_shape": {"options": ["square", "rectangular"], "init": "square"}
+ }
+ }
+ },
+ "Ruido": {
+ "ruido_gausiano": {
+ "operation": "gaussian_noise",
+ "description": "Añade ruido gaussiano a la imagen",
+ "parameters": {
+ "mean": {"min": -100, "max": 100, "init": 0, "interval": 10},
+ "std_dev": {"min": 1, "max": 10, "init": 5, "interval": 1}
+ }
+ }
+ },
+ "Morfología": {
+ "dilatar": {
+ "operation": "dilate",
+ "description": "Operación de dilatación morfológica",
+ "parameters": {
+ "kernel_size": {"min": 2, "max": 50, "init": 5, "interval": 1}
+ }
+ },
+ "dilatar_pro": {
+ "operation": "dilate_pro",
+ "description": "Dilatación avanzada con más control",
+ "parameters": {
+ "kernel_size": {"min": 2, "max": 50, "init": 5, "interval": 1},
+ "anchor": {"min": -1, "max": 10, "init": -1, "interval": 1},
+ "iterations": {"min": 1, "max": 10, "init": 1, "interval": 1},
+ "border_value": {"min": 0, "max": 255, "init": 0, "interval": 1}
+ }
+ },
+ "erosionar": {
+ "operation": "erode",
+ "description": "Operación de erosión morfológica",
+ "parameters": {
+ "kernel_size": {"min": 2, "max": 50, "init": 5, "interval": 1},
+ "anchor": {"min": -1, "max": 10, "init": -1, "interval": 1},
+ "iterations": {"min": 1, "max": 10, "init": 1, "interval": 1},
+ "border_type": {"min": 0, "max": 4, "init": 0, "interval": 1},
+ "border_value": {"min": 0, "max": 255, "init": 0, "interval": 1}
+ }
+ },
+ "super_efecto": {
+ "operation": "super_effect",
+ "description": "Combinación de blur, morfología y pirámide",
+ "parameters": {
+ "blur_radius": {"min": 1, "max": 10, "init": 5, "interval": 1},
+ "morphology_iterations": {"min": 1, "max": 5, "init": 2, "interval": 1},
+ "pyramid_levels": {"min": 1, "max": 5, "init": 3, "interval": 1}
+ }
+ }
+ }
+}
diff --git a/filters/Color/__init__.py b/filters/Color/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/filters/Color/__pycache__/__init__.cpython-312.pyc b/filters/Color/__pycache__/__init__.cpython-312.pyc
deleted file mode 100644
index 05b9697..0000000
Binary files a/filters/Color/__pycache__/__init__.cpython-312.pyc and /dev/null differ
diff --git a/filters/Color/__pycache__/color.cpython-312.pyc b/filters/Color/__pycache__/color.cpython-312.pyc
deleted file mode 100644
index 54376b6..0000000
Binary files a/filters/Color/__pycache__/color.cpython-312.pyc and /dev/null differ
diff --git a/filters/Color/__pycache__/escala_grises.cpython-312.pyc b/filters/Color/__pycache__/escala_grises.cpython-312.pyc
deleted file mode 100644
index b6989fc..0000000
Binary files a/filters/Color/__pycache__/escala_grises.cpython-312.pyc and /dev/null differ
diff --git a/filters/Color/__pycache__/inidie.cpython-312.pyc b/filters/Color/__pycache__/inidie.cpython-312.pyc
deleted file mode 100644
index 330e32e..0000000
Binary files a/filters/Color/__pycache__/inidie.cpython-312.pyc and /dev/null differ
diff --git a/filters/Color/__pycache__/matiz.cpython-312.pyc b/filters/Color/__pycache__/matiz.cpython-312.pyc
deleted file mode 100644
index 7ae3e64..0000000
Binary files a/filters/Color/__pycache__/matiz.cpython-312.pyc and /dev/null differ
diff --git a/filters/Color/__pycache__/saturador.cpython-312.pyc b/filters/Color/__pycache__/saturador.cpython-312.pyc
deleted file mode 100644
index f3b1af3..0000000
Binary files a/filters/Color/__pycache__/saturador.cpython-312.pyc and /dev/null differ
diff --git a/filters/Color/escala_grises.py b/filters/Color/escala_grises.py
deleted file mode 100644
index 3837802..0000000
--- a/filters/Color/escala_grises.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import cv2
-
-def apply_effect(image):
- return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
-
-def get_filter_data():
- return None
diff --git a/filters/Color/inidie.py b/filters/Color/inidie.py
deleted file mode 100644
index aa62ae3..0000000
--- a/filters/Color/inidie.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, blur_kernel_size, canny_threshold1, canny_threshold2, color_adjustment, noise_level):
-
- if blur_kernel_size % 2 == 0:
- blur_kernel_size += 1
-
- blurred = cv2.GaussianBlur(image, (blur_kernel_size, blur_kernel_size), 0)
-
- edges = cv2.Canny(blurred, canny_threshold1, canny_threshold2)
-
- edges_3ch = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
-
- indie_image = np.clip(color_adjustment * blurred - (1 - color_adjustment) * edges_3ch, 0, 255).astype(np.uint8)
-
- noise = np.random.normal(0, noise_level, image.shape).astype(np.uint8)
- indie_image = np.clip(indie_image + noise, 0, 255)
-
- return indie_image
-
-def get_filter_data():
- return {
- "parameters": {
- "blur_kernel_size": {"min": 3, "max": 15, "init": 5, "interval": 2},
- "canny_threshold1": {"min": 50, "max": 150, "init": 100, "interval": 10},
- "canny_threshold2": {"min": 100, "max": 250, "init": 200, "interval": 10},
- "color_adjustment": {"min": 0.5, "max": 2.0, "init": 1.5, "interval": 0.1},
- "noise_level": {"min": 10, "max": 30, "init": 20, "interval": 5}
- }
- }
diff --git a/filters/Color/matiz.py b/filters/Color/matiz.py
deleted file mode 100644
index 5e9af8e..0000000
--- a/filters/Color/matiz.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, hue_shift):
-
- hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
-
- hsv_image[:, :, 0] = (hsv_image[:, :, 0] + hue_shift) % 180
-
- modified_image = cv2.cvtColor(hsv_image, cv2.COLOR_HSV2BGR)
-
- return modified_image
-
-def get_filter_data():
-
- return {
- "parameters": {
- "hue_shift": {"min": -180, "max": 180, "init": 0, "interval": 1}
- }
- }
diff --git a/filters/Color/saturador.py b/filters/Color/saturador.py
deleted file mode 100644
index 3a8a8f5..0000000
--- a/filters/Color/saturador.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, color, saturation_scale, brightness_boost):
- hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
-
- h, s, v = cv2.split(hsv_image)
-
- color_ranges = {
- 'red': [(0, 10), (160, 180)],
- 'green': [(35, 85)],
- 'blue': [(100, 140)],
- 'yellow': [(25, 35)],
- 'cyan': [(85, 100)],
- 'magenta': [(140, 160)]
- }
-
- masks = []
- for (lower, upper) in color_ranges[color]:
- mask = cv2.inRange(h, lower, upper)
- masks.append(mask)
-
- color_mask = cv2.bitwise_or(*masks) if len(masks) > 1 else masks[0]
-
- s = cv2.add(s, (saturation_scale * (color_mask // 255)).astype(np.uint8))
- v = cv2.add(v, (brightness_boost * (color_mask // 255)).astype(np.uint8))
-
- s = np.clip(s, 0, 255)
- v = np.clip(v, 0, 255)
-
- enhanced_hsv_image = cv2.merge([h, s, v])
-
- enhanced_image = cv2.cvtColor(enhanced_hsv_image, cv2.COLOR_HSV2BGR)
-
- return enhanced_image
-
-def get_filter_data():
- return {
- "parameters": {
- "color": {"options": ["red", "green", "blue", "yellow", "cyan", "magenta"], "init": "red"},
- "saturation_scale": {"min": 0, "max": 100, "init": 50, "interval": 1},
- "brightness_boost": {"min": 0, "max": 100, "init": 50, "interval": 1}
- }
- }
\ No newline at end of file
diff --git a/filters/Desenfoque/__init__.py b/filters/Desenfoque/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/filters/Desenfoque/__pycache__/__init__.cpython-312.pyc b/filters/Desenfoque/__pycache__/__init__.cpython-312.pyc
deleted file mode 100644
index 039ad32..0000000
Binary files a/filters/Desenfoque/__pycache__/__init__.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/bilateral.cpython-312.pyc b/filters/Desenfoque/__pycache__/bilateral.cpython-312.pyc
deleted file mode 100644
index 74e7c23..0000000
Binary files a/filters/Desenfoque/__pycache__/bilateral.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/box.cpython-312.pyc b/filters/Desenfoque/__pycache__/box.cpython-312.pyc
deleted file mode 100644
index d0ef240..0000000
Binary files a/filters/Desenfoque/__pycache__/box.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/desenfoque.cpython-312.pyc b/filters/Desenfoque/__pycache__/desenfoque.cpython-312.pyc
deleted file mode 100644
index 04fdc03..0000000
Binary files a/filters/Desenfoque/__pycache__/desenfoque.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/desenfoque_gausiano.cpython-312.pyc b/filters/Desenfoque/__pycache__/desenfoque_gausiano.cpython-312.pyc
deleted file mode 100644
index b212be1..0000000
Binary files a/filters/Desenfoque/__pycache__/desenfoque_gausiano.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/desenfoque_horizontal.cpython-312.pyc b/filters/Desenfoque/__pycache__/desenfoque_horizontal.cpython-312.pyc
deleted file mode 100644
index f5f74fe..0000000
Binary files a/filters/Desenfoque/__pycache__/desenfoque_horizontal.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/desenfoque_vertical.cpython-312.pyc b/filters/Desenfoque/__pycache__/desenfoque_vertical.cpython-312.pyc
deleted file mode 100644
index 02b6ed6..0000000
Binary files a/filters/Desenfoque/__pycache__/desenfoque_vertical.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/mediana.cpython-312.pyc b/filters/Desenfoque/__pycache__/mediana.cpython-312.pyc
deleted file mode 100644
index e757075..0000000
Binary files a/filters/Desenfoque/__pycache__/mediana.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/morphology.cpython-312.pyc b/filters/Desenfoque/__pycache__/morphology.cpython-312.pyc
deleted file mode 100644
index 9060e43..0000000
Binary files a/filters/Desenfoque/__pycache__/morphology.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/shift.cpython-312.pyc b/filters/Desenfoque/__pycache__/shift.cpython-312.pyc
deleted file mode 100644
index deb9c31..0000000
Binary files a/filters/Desenfoque/__pycache__/shift.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/stack.cpython-312.pyc b/filters/Desenfoque/__pycache__/stack.cpython-312.pyc
deleted file mode 100644
index d6a3a70..0000000
Binary files a/filters/Desenfoque/__pycache__/stack.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/__pycache__/super_queee.cpython-312.pyc b/filters/Desenfoque/__pycache__/super_queee.cpython-312.pyc
deleted file mode 100644
index c8b7c30..0000000
Binary files a/filters/Desenfoque/__pycache__/super_queee.cpython-312.pyc and /dev/null differ
diff --git a/filters/Desenfoque/bilateral.py b/filters/Desenfoque/bilateral.py
deleted file mode 100644
index fc05a28..0000000
--- a/filters/Desenfoque/bilateral.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import cv2 as cv
-
-def apply_effect(image, d=3, sigmaColor=20, sigmaSpace=15):
- return cv.bilateralFilter(image, d, sigmaColor, sigmaSpace)
-
-def get_filter_data():
- return {
- "parameters": {
- "d": {"min": 1, "max": 10, "init": 3, "interval": 1},
- "sigmaColor": {"min": 1, "max": 100, "init": 20, "interval": 1},
- "sigmaSpace": {"min": 1, "max": 100, "init": 15, "interval": 1}
- }
- }
diff --git a/filters/Desenfoque/box.py b/filters/Desenfoque/box.py
deleted file mode 100644
index 094c5a8..0000000
--- a/filters/Desenfoque/box.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import cv2
-
-def apply_effect(image, ksize_x, ksize_y):
- blurred_image = cv2.boxFilter(image, -1, (ksize_x, ksize_y))
- return blurred_image
-
-def get_filter_data():
- return {
- "parameters": {
- "ksize_x": {"min": 3, "max": 15, "init": 5, "interval": 2},
- "ksize_y": {"min": 3, "max": 15, "init": 5, "interval": 2}
- }
- }
diff --git a/filters/Desenfoque/desenfoque_gausiano.py b/filters/Desenfoque/desenfoque_gausiano.py
deleted file mode 100644
index 31cdcdf..0000000
--- a/filters/Desenfoque/desenfoque_gausiano.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import cv2
-
-def apply_effect(image, kernel_size, sigma):
- kernel_size = kernel_size + (kernel_size % 2 == 0)
- return cv2.GaussianBlur(image, (kernel_size, kernel_size), sigma)
-
-def get_filter_data():
- return {
- "parameters": {
- "kernel_size": {"min": 1, "max": 31, "init": 15, "interval": 2},
- "sigma": {"min": 1, "max": 10, "init": 5, "interval": 1}
- }
- }
-
\ No newline at end of file
diff --git a/filters/Desenfoque/desenfoque_horizontal.py b/filters/Desenfoque/desenfoque_horizontal.py
deleted file mode 100644
index 983a5b6..0000000
--- a/filters/Desenfoque/desenfoque_horizontal.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import cv2
-import numpy as np
-def apply_effect(image, kernel_size):
- kernel_h = np.zeros((kernel_size, kernel_size))
- kernel_h[int((kernel_size - 1)/2), :] = np.ones(kernel_size)
- kernel_h /= kernel_size
- return cv2.filter2D(image, -1, kernel_h)
-
-def get_filter_data():
- return {
- "parameters": {
- "kernel_size": {"min": 1, "max": 101, "init": 3, "interval": 2}
- }
- }
\ No newline at end of file
diff --git a/filters/Desenfoque/desenfoque_vertical.py b/filters/Desenfoque/desenfoque_vertical.py
deleted file mode 100644
index 74fb315..0000000
--- a/filters/Desenfoque/desenfoque_vertical.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, multi):
- kernel_v = np.zeros((multi, multi), dtype=np.float64)
- kernel_v[:, int((multi - 1)/2)] = np.ones(multi)
- kernel_v /= multi
- return cv2.filter2D(image, -1, kernel_v)
-
-def get_filter_data():
- return {
- "parameters": {
- "multi": {"min": 1, "max": 100, "init": 30, "interval": 1}
- }
- }
diff --git a/filters/Desenfoque/mediana.py b/filters/Desenfoque/mediana.py
deleted file mode 100644
index 7fcae1f..0000000
--- a/filters/Desenfoque/mediana.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import cv2
-
-def apply_effect(image, kernel_size):
- kernel_size = max(1, kernel_size)
- kernel_size = kernel_size + (kernel_size % 2 == 0)
- return cv2.medianBlur(image, kernel_size)
-
-def get_filter_data():
- return {
- "parameters": {
- "kernel_size": {"min": 1, "max": 101, "init": 3, "interval": 2}
- }
- }
diff --git a/filters/Ruido/__init__.py b/filters/Ruido/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/filters/Ruido/__pycache__/__init__.cpython-312.pyc b/filters/Ruido/__pycache__/__init__.cpython-312.pyc
deleted file mode 100644
index 156ffe8..0000000
Binary files a/filters/Ruido/__pycache__/__init__.cpython-312.pyc and /dev/null differ
diff --git a/filters/Ruido/__pycache__/ruido.cpython-312.pyc b/filters/Ruido/__pycache__/ruido.cpython-312.pyc
deleted file mode 100644
index 32628bf..0000000
Binary files a/filters/Ruido/__pycache__/ruido.cpython-312.pyc and /dev/null differ
diff --git a/filters/Ruido/__pycache__/ruido_gausiano.cpython-312.pyc b/filters/Ruido/__pycache__/ruido_gausiano.cpython-312.pyc
deleted file mode 100644
index b905fa4..0000000
Binary files a/filters/Ruido/__pycache__/ruido_gausiano.cpython-312.pyc and /dev/null differ
diff --git a/filters/Ruido/__pycache__/sal_y_pimienta.cpython-312.pyc b/filters/Ruido/__pycache__/sal_y_pimienta.cpython-312.pyc
deleted file mode 100644
index 72c264c..0000000
Binary files a/filters/Ruido/__pycache__/sal_y_pimienta.cpython-312.pyc and /dev/null differ
diff --git a/filters/Ruido/ruido_gausiano.py b/filters/Ruido/ruido_gausiano.py
deleted file mode 100644
index f8f71d2..0000000
--- a/filters/Ruido/ruido_gausiano.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import numpy as np
-
-def apply_effect(image, mean, std_dev):
- h, w, c = image.shape
- noise = np.random.normal(mean, std_dev, (h, w, c))
- noisy_image = np.clip(image + noise, 0, 255).astype(np.uint8)
- return noisy_image
-
-def get_filter_data():
- return{
- "parameters":
- {
-
- "mean": {"min": -100, "max": 100, "init": 0, "interval": 10},
- "std_dev": {"min": 1, "max": 10, "init": 5, "interval": 1}
- } }
-
diff --git a/filters/__init__.py b/filters/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/filters/__pycache__/__init__.cpython-312.pyc b/filters/__pycache__/__init__.cpython-312.pyc
deleted file mode 100644
index 0d24a98..0000000
Binary files a/filters/__pycache__/__init__.cpython-312.pyc and /dev/null differ
diff --git a/filters/__pycache__/bilateral.cpython-312.pyc b/filters/__pycache__/bilateral.cpython-312.pyc
deleted file mode 100644
index c324ec1..0000000
Binary files a/filters/__pycache__/bilateral.cpython-312.pyc and /dev/null differ
diff --git a/filters/__pycache__/color.cpython-312.pyc b/filters/__pycache__/color.cpython-312.pyc
deleted file mode 100644
index 2c28bb7..0000000
Binary files a/filters/__pycache__/color.cpython-312.pyc and /dev/null differ
diff --git a/filters/__pycache__/desenfoque.cpython-312.pyc b/filters/__pycache__/desenfoque.cpython-312.pyc
deleted file mode 100644
index feda40d..0000000
Binary files a/filters/__pycache__/desenfoque.cpython-312.pyc and /dev/null differ
diff --git a/filters/__pycache__/ruido.cpython-312.pyc b/filters/__pycache__/ruido.cpython-312.pyc
deleted file mode 100644
index cdb0d4d..0000000
Binary files a/filters/__pycache__/ruido.cpython-312.pyc and /dev/null differ
diff --git a/filters/glitch/__pycache__/glitch.cpython-312.pyc b/filters/glitch/__pycache__/glitch.cpython-312.pyc
deleted file mode 100644
index 61bce75..0000000
Binary files a/filters/glitch/__pycache__/glitch.cpython-312.pyc and /dev/null differ
diff --git a/filters/glitch/__pycache__/shift.cpython-312.pyc b/filters/glitch/__pycache__/shift.cpython-312.pyc
deleted file mode 100644
index c1f1cb3..0000000
Binary files a/filters/glitch/__pycache__/shift.cpython-312.pyc and /dev/null differ
diff --git a/filters/glitch/__pycache__/shift_pro.cpython-312.pyc b/filters/glitch/__pycache__/shift_pro.cpython-312.pyc
deleted file mode 100644
index c03ef8a..0000000
Binary files a/filters/glitch/__pycache__/shift_pro.cpython-312.pyc and /dev/null differ
diff --git a/filters/glitch/glitch.py b/filters/glitch/glitch.py
deleted file mode 100644
index 20c2779..0000000
--- a/filters/glitch/glitch.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, glitch_intensity, glitch_frequency):
- rows, cols, _ = image.shape
- glitch_image = image.copy()
-
- for i in range(0, rows, glitch_frequency):
- shift = np.random.randint(-glitch_intensity, glitch_intensity)
- glitch_image[i] = np.roll(image[i], shift, axis=0)
-
- return glitch_image
-
-def get_filter_data():
- return {
- "parameters": {
- "glitch_intensity": {"min": 1, "max": 50, "init": 10, "interval": 1},
- "glitch_frequency": {"min": 1, "max": 50, "init": 5, "interval": 1}
- }
- }
diff --git a/filters/glitch/shift.py b/filters/glitch/shift.py
deleted file mode 100644
index b5bebda..0000000
--- a/filters/glitch/shift.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, shift_value):
- b_channel, g_channel, r_channel = cv2.split(image)
-
- zeros = np.zeros_like(b_channel)
-
- b_shifted = np.roll(b_channel, shift_value, axis=1)
- g_shifted = np.roll(g_channel, -shift_value, axis=1)
- r_shifted = np.roll(r_channel, shift_value // 2, axis=0)
-
- merged = cv2.merge([b_shifted, g_shifted, r_shifted])
-
- return merged
-
-def get_filter_data():
- return {
- "parameters": {
- "shift_value": {"min": -50, "max": 50, "init": 5, "interval": 1}
- }
- }
diff --git a/filters/glitch/shift_pro.py b/filters/glitch/shift_pro.py
deleted file mode 100644
index 8f83911..0000000
--- a/filters/glitch/shift_pro.py
+++ /dev/null
@@ -1,32 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, shift_value_b, shift_value_g, shift_value_r, direction_b, direction_g, direction_r):
-
- b_channel, g_channel, r_channel = cv2.split(image)
-
- zeros = np.zeros_like(b_channel)
-
- axis_b = 1 if direction_b == 'horizontal' else 0
- axis_g = 1 if direction_g == 'horizontal' else 0
- axis_r = 1 if direction_r == 'horizontal' else 0
-
- b_shifted = np.roll(b_channel, shift_value_b, axis=axis_b)
- g_shifted = np.roll(g_channel, shift_value_g, axis=axis_g)
- r_shifted = np.roll(r_channel, shift_value_r, axis=axis_r)
-
- merged = cv2.merge([b_shifted, g_shifted, r_shifted])
-
- return merged
-
-def get_filter_data():
- return {
- "parameters": {
- "shift_value_b": {"min": -50, "max": 50, "init": 5, "interval": 1},
- "shift_value_g": {"min": -50, "max": 50, "init": -5, "interval": 1},
- "shift_value_r": {"min": -50, "max": 50, "init": 2, "interval": 1},
- "direction_b": {"options": ["horizontal", "vertical"], "init": "horizontal"},
- "direction_g": {"options": ["horizontal", "vertical"], "init": "horizontal"},
- "direction_r": {"options": ["horizontal", "vertical"], "init": "vertical"}
- }
- }
\ No newline at end of file
diff --git a/filters/pixel/__pycache__/pixelar.cpython-312.pyc b/filters/pixel/__pycache__/pixelar.cpython-312.pyc
deleted file mode 100644
index da43dc3..0000000
Binary files a/filters/pixel/__pycache__/pixelar.cpython-312.pyc and /dev/null differ
diff --git a/filters/pixel/__pycache__/pixelar_pro.cpython-312.pyc b/filters/pixel/__pycache__/pixelar_pro.cpython-312.pyc
deleted file mode 100644
index 54825ec..0000000
Binary files a/filters/pixel/__pycache__/pixelar_pro.cpython-312.pyc and /dev/null differ
diff --git a/filters/pixel/pixelar.py b/filters/pixel/pixelar.py
deleted file mode 100644
index 3cd78b5..0000000
--- a/filters/pixel/pixelar.py
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-import cv2
-import numpy as np
-
-def apply_effect(image, block_size):
- # Calculate the number of blocks needed in each dimension
- num_blocks_x = int(image.shape[1] / block_size)
- num_blocks_y = int(image.shape[0] / block_size)
-
- # Create a new image with the desired pixelation
- pixelated_image = np.zeros((num_blocks_y * block_size, num_blocks_x * block_size, 3), dtype=np.uint8)
-
- # Loop through each block and copy the corresponding pixels from the original image
- for y in range(num_blocks_y):
- for x in range(num_blocks_x):
- start_x = x * block_size
- end_x = (x + 1) * block_size
- start_y = y * block_size
- end_y = (y + 1) * block_size
- block = image[start_y:end_y, start_x:end_x]
- pixelated_image[start_y:end_y, start_x:end_x] = block.mean(axis=(0, 1))
-
- return pixelated_image
-def get_filter_data():
- return {
- "parameters": {
- "block_size": {"min": 2, "max": 50, "init": 5, "interval": 1}, }
- }
diff --git a/filters/pixel/pixelar_pro.py b/filters/pixel/pixelar_pro.py
deleted file mode 100644
index 46463ce..0000000
--- a/filters/pixel/pixelar_pro.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, block_size, method='mean', intensity=1.0, block_shape='square'):
- num_blocks_x = int(image.shape[1] / block_size)
- num_blocks_y = int(image.shape[0] / block_size)
-
- pixelated_image = np.zeros_like(image)
-
- for y in range(num_blocks_y):
- for x in range(num_blocks_x):
- start_x = x * block_size
- end_x = (x + 1) * block_size
- start_y = y * block_size
- end_y = (y + 1) * block_size
- block = image[start_y:end_y, start_x:end_x]
- if method == 'mean':
- pixel_value = block.mean(axis=(0, 1))
- elif method == 'median':
- pixel_value = np.median(block, axis=(0, 1))
- elif method == 'mode':
- pixel_value = np.squeeze(cv2.calcHist([block], [0], None, [256], [0, 256])).argmax()
- else:
- raise ValueError("Invalid pixelation method")
- pixelated_image[start_y:end_y, start_x:end_x] = pixel_value
-
- pixelated_image = (pixelated_image * intensity).astype(np.uint8)
-
- return pixelated_image
-
-def get_filter_data():
- return {
- "parameters": {
- "block_size": {"min": 2, "max": 50, "init": 5, "interval": 1},
- "method": {"options": ["mean", "median", "mode"], "init": "mean"},
- "intensity": {"min": 0.1, "max": 2.0, "init": 1.0, "interval": 0.1},
- "block_shape": {"options": ["square", "rectangular"], "init": "square"}
- }
- }
diff --git a/filters/varios/__pycache__/dilatar.cpython-312.pyc b/filters/varios/__pycache__/dilatar.cpython-312.pyc
deleted file mode 100644
index 3464efb..0000000
Binary files a/filters/varios/__pycache__/dilatar.cpython-312.pyc and /dev/null differ
diff --git a/filters/varios/__pycache__/dilatar_pro.cpython-312.pyc b/filters/varios/__pycache__/dilatar_pro.cpython-312.pyc
deleted file mode 100644
index bb0abf1..0000000
Binary files a/filters/varios/__pycache__/dilatar_pro.cpython-312.pyc and /dev/null differ
diff --git a/filters/varios/__pycache__/erode.cpython-312.pyc b/filters/varios/__pycache__/erode.cpython-312.pyc
deleted file mode 100644
index 75498bf..0000000
Binary files a/filters/varios/__pycache__/erode.cpython-312.pyc and /dev/null differ
diff --git a/filters/varios/__pycache__/piramide.cpython-312.pyc b/filters/varios/__pycache__/piramide.cpython-312.pyc
deleted file mode 100644
index a90ff77..0000000
Binary files a/filters/varios/__pycache__/piramide.cpython-312.pyc and /dev/null differ
diff --git a/filters/varios/__pycache__/super_queee.cpython-312.pyc b/filters/varios/__pycache__/super_queee.cpython-312.pyc
deleted file mode 100644
index c662f6f..0000000
Binary files a/filters/varios/__pycache__/super_queee.cpython-312.pyc and /dev/null differ
diff --git a/filters/varios/dilatar.py b/filters/varios/dilatar.py
deleted file mode 100644
index c8936b2..0000000
--- a/filters/varios/dilatar.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, kernel_size):
-
- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
- kernel = np.ones((kernel_size, kernel_size), np.uint8)
- dilated = cv2.dilate(gray, kernel, iterations=1)
-
- return cv2.cvtColor(dilated, cv2.COLOR_GRAY2BGR)
-
-def get_filter_data():
- return {
- "parameters": {
- "kernel_size": {"min": 2, "max": 50, "init": 5, "interval": 1},
- }
- }
\ No newline at end of file
diff --git a/filters/varios/dilatar_pro.py b/filters/varios/dilatar_pro.py
deleted file mode 100644
index d3a19c0..0000000
--- a/filters/varios/dilatar_pro.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, kernel_size, anchor, iterations, border_value):
-
- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
- kernel = np.ones((kernel_size, kernel_size), np.uint8)
-
- return cv2.dilate(gray, kernel, anchor=(-1, -1), iterations=iterations, borderType=cv2.BORDER_CONSTANT, borderValue=border_value)
-
-def get_filter_data():
- return {
- "parameters": {
- "kernel_size": {"min": 2, "max": 50, "init": 5, "interval": 1},
- "anchor": {"min": -1, "max": -1, "init": -1, "interval": 1},
- "iterations": {"min": 1, "max": 10, "init": 1, "interval": 1},
- "border_value": {"min": 0, "max": 255, "init": 0, "interval": 1},
- }
- }
\ No newline at end of file
diff --git a/filters/varios/erode.py b/filters/varios/erode.py
deleted file mode 100644
index 06f4824..0000000
--- a/filters/varios/erode.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, kernel_size, anchor, iterations, border_type, border_value):
-
- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
- kernel = np.ones((kernel_size, kernel_size), np.uint8)
-
- return cv2.erode(gray, kernel, anchor=(-1, -1), iterations=iterations, borderType=border_type, borderValue=border_value)
-def get_filter_data():
- return {
- "parameters": {
- "kernel_size": {"min": 2, "max": 50, "init": 5, "interval": 1},
- "anchor": {"min": -1, "max": -1, "init": -1, "interval": 1},
- "iterations": {"min": 1, "max": 10, "init": 1, "interval": 1},
- "border_type": {"min": 0, "max": 4, "init": 0, "interval": 1},
- "border_value": {"min": 0, "max": 255, "init": 0, "interval": 1},
- }
- }
\ No newline at end of file
diff --git a/filters/varios/super_queee.py b/filters/varios/super_queee.py
deleted file mode 100644
index 0022565..0000000
--- a/filters/varios/super_queee.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import cv2
-import numpy as np
-
-def apply_effect(image, blur_radius, morphology_iterations, pyramid_levels):
- blurred_image = cv2.boxFilter(image, -1, (blur_radius, blur_radius))
-
- kernel = np.ones((5,5),np.uint8)
- morphology_image = cv2.morphologyEx(blurred_image, cv2.MORPH_GRADIENT, kernel, iterations=morphology_iterations)
-
- pyr_down_image = cv2.pyrDown(morphology_image, dstsize=(image.shape[1]//2, image.shape[0]//2))
- pyr_up_image = cv2.pyrUp(pyr_down_image, dstsize=(image.shape[1], image.shape[0]))
-
- return pyr_up_image
-
-def get_filter_data():
- return {
- "parameters": {
- "blur_radius": {"min": 1, "max": 10, "init": 5, "interval": 1},
- "morphology_iterations": {"min": 1, "max": 5, "init": 2, "interval": 1},
- "pyramid_levels": {"min": 1, "max": 5, "init": 3, "interval": 1}
- }
- }
\ No newline at end of file
diff --git a/filters_config_generator.py b/filters_config_generator.py
deleted file mode 100644
index 33e085b..0000000
--- a/filters_config_generator.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import os
-import importlib.util
-import inspect
-
-def load_module(filepath):
- spec = importlib.util.spec_from_file_location("module", filepath)
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return module
-
-def get_filter_info(filepath):
- module = load_module(filepath)
- filter_data_func = getattr(module, "get_filter_data", None)
- if filter_data_func and inspect.isfunction(filter_data_func):
- return filter_data_func()
- else:
- return None
-
-
-def generate_py_code(directory):
- py_code = "from filters import *\n\nFILTERS = {}\n\n"
- for root, dirs, files in os.walk(directory):
- for file in files:
- if file.endswith(".py") and file != "__init__.py":
- filepath = os.path.join(root, file)
- folder_name = os.path.basename(os.path.dirname(filepath))
- module_name = os.path.splitext(os.path.basename(filepath))[0]
- py_code += f"from filters.{folder_name} import {module_name}\n"
-
- py_code += "\nFILTERS = {\n"
-
- for root, dirs, files in os.walk(directory):
- for dir_name in dirs:
- if dir_name != "__pycache__":
- py_code += f' "{dir_name}": {{\n'
- for file in os.listdir(os.path.join(directory, dir_name)):
- if file.endswith(".py") and file != "__init__.py":
- module_name = os.path.splitext(file)[0]
- py_code += f' "{module_name}": {{\n'
- py_code += f' "apply": {module_name}.apply_effect,\n'
- filter_info = get_filter_info(os.path.join(directory, dir_name, file))
- if filter_info:
- py_code += ' "parameters": {\n'
- for param, value in filter_info["parameters"].items():
- py_code += f' "{param}": {value},\n'
- py_code += ' }\n'
- py_code += ' },\n'
- py_code += ' },\n'
-
- py_code += "}\n"
-
- return py_code
-
-directory_path = "filters"
-generated_py_code = generate_py_code(directory_path)
-
-with open("effects_settings.py", "w") as py_file:
- py_file.write(generated_py_code)
-
-print("Archivo 'effects_settings.py' creado con éxito.")
diff --git a/main.py b/main.py
index 1d52a7c..7416e03 100644
--- a/main.py
+++ b/main.py
@@ -1,193 +1,277 @@
+"""
+MODT - Photo Editor Application
+
+A modular photo editor with filter effects that can be configured via JSON.
+"""
+
import sys
+import json
import cv2
from PyQt5.QtWidgets import (
- QApplication, QComboBox, QMainWindow, QSizePolicy, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QWidget, QFileDialog, QSlider, QMessageBox
+ QApplication, QComboBox, QMainWindow, QSizePolicy, QLabel, QPushButton,
+ QVBoxLayout, QHBoxLayout, QWidget, QFileDialog, QSlider, QMessageBox,
+ QTabWidget, QLineEdit, QFormLayout, QScrollArea, QGroupBox, QSpinBox,
+ QDoubleSpinBox, QTextEdit, QFrame
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap, QImage, QIcon
-from effects_settings import FILTERS
-
-class PhotoEditorApp(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("MODT - Photo Editor")
- self.setGeometry(100, 100, 600, 400)
- self.setWindowIcon(QIcon("assets/calavera.gif"))
+from filter_manager import get_filter_manager, FilterManager
+
+
+class Styles:
+ """Centralized styles for the application."""
+
+ MAIN_WINDOW = """
+ QMainWindow {
+ background-color: #000;
+ }
+ QWidget {
+ background-color: #222;
+ color: #fff;
+ }
+ QLabel {
+ color: #fff;
+ }
+ QComboBox, QSlider, QPushButton {
+ background-color: #333;
+ color: #fff;
+ border: 1px solid #666;
+ border-radius: 5px;
+ }
+ QPushButton:hover {
+ background-color: #555;
+ }
+ """
+
+ SLIDER = """
+ QSlider::groove:horizontal {
+ height: 6px;
+ background: #444;
+ border: 1px solid #666;
+ border-radius: 3px;
+ }
+ QSlider::handle:horizontal {
+ background: #4CAF50;
+ border: 1px solid #4CAF50;
+ width: 12px;
+ margin: -6px 0;
+ border-radius: 6px;
+ }
+ """
+
+ COMBOBOX = """
+ QComboBox {
+ max-width: 200px;
+ padding: 3px;
+ border: 1px solid #666;
+ border-radius: 2px;
+ }
+ QComboBox::drop-down {
+ width: 20px;
+ }
+ QComboBox::down-arrow {
+ width: 10px;
+ height: 10px;
+ }
+ """
+
+ FILTER_BUTTON = """
+ QPushButton {
+ padding: 8px 16px;
+ border: 2px solid #4CAF50;
+ border-radius: 25px;
+ background-color: #4CAF50;
+ color: #fff;
+ font-weight: bold;
+ }
+ QPushButton:hover {
+ background-color: #45a049;
+ }
+ """
+
+ FILE_BUTTON = """
+ QPushButton {
+ padding: 8px 16px;
+ border: 2px solid #008CBA;
+ border-radius: 25px;
+ background-color: #008CBA;
+ color: #fff;
+ font-weight: bold;
+ }
+ QPushButton:hover {
+ background-color: #005f78;
+ }
+ """
+
+ SLIDER_LABEL = """
+ QLabel {
+ color: #fff;
+ font-weight: bold;
+ }
+ """
+
+ TAB_WIDGET = """
+ QTabWidget::pane {
+ border: 1px solid #444;
+ background-color: #222;
+ }
+ QTabBar::tab {
+ background-color: #333;
+ color: #fff;
+ padding: 10px 20px;
+ border: 1px solid #444;
+ border-bottom: none;
+ }
+ QTabBar::tab:selected {
+ background-color: #4CAF50;
+ }
+ QTabBar::tab:hover {
+ background-color: #555;
+ }
+ """
+
+ INPUT_FIELD = """
+ QLineEdit, QTextEdit, QSpinBox, QDoubleSpinBox {
+ background-color: #333;
+ color: #fff;
+ border: 1px solid #666;
+ border-radius: 3px;
+ padding: 5px;
+ }
+ QLineEdit:focus, QTextEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus {
+ border: 1px solid #4CAF50;
+ }
+ """
+
+ GROUP_BOX = """
+ QGroupBox {
+ border: 1px solid #666;
+ border-radius: 5px;
+ margin-top: 10px;
+ padding-top: 10px;
+ color: #fff;
+ font-weight: bold;
+ }
+ QGroupBox::title {
+ subcontrol-origin: margin;
+ left: 10px;
+ padding: 0 5px;
+ }
+ """
+
+ @classmethod
+ def get_combined_style(cls):
+ """Get all styles combined."""
+ return (cls.COMBOBOX + cls.SLIDER + cls.SLIDER_LABEL +
+ cls.TAB_WIDGET + cls.INPUT_FIELD + cls.GROUP_BOX)
+
+
+class PhotoEditorTab(QWidget):
+ """Tab for photo editing with filters."""
+
+ def __init__(self, filter_manager: FilterManager, parent=None):
+ super().__init__(parent)
+ self.filter_manager = filter_manager
self.image = None
self.original_image = None
self.active_filter = None
self.active_category = None
self.sliders = []
self.comboboxes = []
- self.initUI()
-
- def initUI(self):
- self.setStyleSheet("""
- QMainWindow {
- background-color: #000;
- }
- QWidget {
- background-color: #222;
- color: #fff;
- }
- QLabel {
- color: #fff;
- }
- QComboBox, QSlider, QPushButton {
- background-color: #333;
- color: #fff;
- border: 1px solid #666;
- border-radius: 5px;
- }
- QPushButton:hover {
- background-color: #555;
- }
- """)
-
- slider_style = """
- QSlider::groove:horizontal {
- height: 6px;
- background: #444;
- border: 1px solid #666;
- border-radius: 3px;
- }
- QSlider::handle:horizontal {
- background: #4CAF50;
- border: 1px solid #4CAF50;
- width: 12px;
- margin: -6px 0;
- border-radius: 6px;
- }
- """
-
- combobox_style = """
- QComboBox {
- max-width: 200px;
- padding: 3px;
- border: 1px solid #666;
- border-radius: 2px;
- }
- QComboBox::drop-down {
- width: 20px;
- }
- QComboBox::down-arrow {
- width: 10px;
- height: 10px;
- }
- """
-
- button_style = """
- QPushButton {
- padding: 8px 16px;
- border: 2px solid #4CAF50;
- border-radius: 25px;
- background-color: #4CAF50;
- color: #fff;
- font-weight: bold;
- }
- QPushButton:hover {
- background-color: #45a049;
- }
- """
-
- self.filter_button_style = button_style
-
- file_button_style = """
- QPushButton {
- padding: 8px 16px;
- border: 2px solid #008CBA;
- border-radius: 25px;
- background-color: #008CBA;
- color: #fff;
- font-weight: bold;
- }
- QPushButton:hover {
- background-color: #005f78;
- }
- """
-
- slider_label_style = """
- QLabel {
- color: #fff;
- font-weight: bold;
- }
- """
-
- central_widget = QWidget(self)
- self.setCentralWidget(central_widget)
- main_layout = QHBoxLayout(central_widget)
-
+ self.init_ui()
+
+ def init_ui(self):
+ """Initialize the photo editor UI."""
+ main_layout = QHBoxLayout(self)
+
+ # Left panel - filter selection
self.filter_buttons_layout = QVBoxLayout()
main_layout.addLayout(self.filter_buttons_layout)
-
+
self.category_combo = QComboBox(self)
- self.category_combo.addItems(FILTERS.keys())
+ self.category_combo.addItems(self.filter_manager.get_categories())
self.category_combo.currentTextChanged.connect(self.update_filter_buttons)
self.filter_buttons_layout.addWidget(self.category_combo)
-
+
self.filter_buttons_row_layout = QVBoxLayout()
self.filter_buttons_layout.addLayout(self.filter_buttons_row_layout)
-
+
+ # Right panel - image display and controls
right_layout = QVBoxLayout()
right_layout.setSizeConstraint(QVBoxLayout.SetMinimumSize)
+
self.image_label = QLabel(self)
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
right_layout.addWidget(self.image_label)
-
+
load_btn = QPushButton("Cargar Imagen", self)
load_btn.clicked.connect(self.load_image)
- load_btn.setStyleSheet(file_button_style)
+ load_btn.setStyleSheet(Styles.FILE_BUTTON)
right_layout.addWidget(load_btn)
-
+
self.slider_layout = QVBoxLayout()
self.slider_layout.setSizeConstraint(QVBoxLayout.SetMinimumSize)
right_layout.addLayout(self.slider_layout)
-
+
save_btn = QPushButton("Guardar Imagen", self)
save_btn.clicked.connect(self.save_image)
- save_btn.setStyleSheet(file_button_style)
+ save_btn.setStyleSheet(Styles.FILE_BUTTON)
right_layout.addWidget(save_btn)
-
- combined_style = combobox_style + slider_style + slider_label_style
- app.setStyleSheet(combined_style)
-
+
main_layout.addLayout(right_layout)
- central_widget.setLayout(main_layout)
-
self.update_filter_buttons()
-
+
+ def refresh_filters(self):
+ """Refresh the filter list from the filter manager."""
+ current_category = self.category_combo.currentText()
+ self.category_combo.clear()
+ self.category_combo.addItems(self.filter_manager.get_categories())
+
+ # Try to restore the previous category
+ index = self.category_combo.findText(current_category)
+ if index >= 0:
+ self.category_combo.setCurrentIndex(index)
+
+ self.update_filter_buttons()
+
def update_filter_buttons(self):
+ """Update the filter buttons based on selected category."""
for i in reversed(range(self.filter_buttons_row_layout.count())):
widget = self.filter_buttons_row_layout.itemAt(i).widget()
- widget.deleteLater()
-
+ if widget:
+ widget.deleteLater()
+
category = self.category_combo.currentText()
- if category in FILTERS:
- for filter_name in FILTERS[category].keys():
- btn = QPushButton(filter_name, self)
- btn.clicked.connect(lambda _, c=category, f=filter_name: self.apply_filter(c, f))
- btn.setStyleSheet(self.filter_button_style)
- self.filter_buttons_row_layout.addWidget(btn)
-
+ filters_in_category = self.filter_manager.get_filters_in_category(category)
+
+ for filter_name in filters_in_category.keys():
+ btn = QPushButton(filter_name, self)
+ btn.clicked.connect(lambda _, c=category, f=filter_name: self.apply_filter(c, f))
+ btn.setStyleSheet(Styles.FILTER_BUTTON)
+ self.filter_buttons_row_layout.addWidget(btn)
+
def load_image(self):
- file_path, _ = QFileDialog.getOpenFileName(self, "Cargar Imagen", "", "Images (*.png *.jpg *.jpeg *.bmp)")
+ """Load an image from file."""
+ file_path, _ = QFileDialog.getOpenFileName(
+ self, "Cargar Imagen", "", "Images (*.png *.jpg *.jpeg *.bmp)"
+ )
if file_path:
self.original_image = cv2.imread(file_path)
self.image = self.original_image.copy()
self.display_image(self.image)
-
+
def display_image(self, img):
+ """Display an image in the image label."""
widget_width = self.width()
widget_height = self.height()
-
+
max_width = int(widget_width * 0.7)
max_height = int(widget_height * 0.7)
-
+
img_height, img_width = img.shape[:2]
-
aspect_ratio = img_width / img_height
-
+
if img_width > max_width or img_height > max_height:
if img_width / max_width > img_height / max_height:
new_width = max_width
@@ -198,60 +282,75 @@ def display_image(self, img):
else:
new_width = img_width
new_height = img_height
-
+
resized_img = cv2.resize(img, (new_width, new_height), interpolation=cv2.INTER_AREA)
-
+
if len(resized_img.shape) == 2:
- q_img = QImage(resized_img.data, resized_img.shape[1], resized_img.shape[0], resized_img.strides[0], QImage.Format_Grayscale8)
+ q_img = QImage(
+ resized_img.data, resized_img.shape[1], resized_img.shape[0],
+ resized_img.strides[0], QImage.Format_Grayscale8
+ )
else:
- q_img = QImage(resized_img.data, resized_img.shape[1], resized_img.shape[0], resized_img.strides[0], QImage.Format_RGB888).rgbSwapped()
-
+ q_img = QImage(
+ resized_img.data, resized_img.shape[1], resized_img.shape[0],
+ resized_img.strides[0], QImage.Format_RGB888
+ ).rgbSwapped()
+
pixmap = QPixmap.fromImage(q_img)
self.image_label.setPixmap(pixmap)
-
- def resizeEvent(self, event):
- if self.image is not None:
- self.display_image(self.image)
- super().resizeEvent(event)
-
+
def apply_filter(self, category, filter_name):
+ """Apply a filter to the image."""
if self.original_image is None:
self.show_message("Advertencia", "Primero carga una imagen.")
return
self.active_category = category
self.active_filter = filter_name
self.update_effect()
-
+
def update_effect(self):
+ """Update the effect on the image."""
if self.original_image is None:
self.show_message("Advertencia", "Primero carga una imagen.")
return
+
if self.active_filter is not None:
- filter_info = FILTERS[self.active_category][self.active_filter]
- if "parameters" in filter_info and filter_info["parameters"]:
- self.update_sliders()
- else:
- self.image = filter_info["apply"](self.original_image)
- self.display_image(self.image)
-
+ filter_info = self.filter_manager.get_filter_info(
+ self.active_category, self.active_filter
+ )
+ if filter_info:
+ if "parameters" in filter_info and filter_info["parameters"]:
+ self.update_sliders()
+ else:
+ self.image = filter_info["apply"](self.original_image)
+ self.display_image(self.image)
+
def save_image(self):
+ """Save the current image to file."""
if self.image is not None:
- file_path, _ = QFileDialog.getSaveFileName(self, "Guardar Imagen", "", "Images (*.png *.jpg *.jpeg *.bmp)")
+ file_path, _ = QFileDialog.getSaveFileName(
+ self, "Guardar Imagen", "", "Images (*.png *.jpg *.jpeg *.bmp)"
+ )
if file_path:
cv2.imwrite(file_path, self.image)
QMessageBox.information(self, "Éxito", "La imagen se ha guardado correctamente.")
else:
QMessageBox.warning(self, "Advertencia", "No hay ninguna imagen para guardar.")
-
+
def update_sliders(self):
+ """Update the parameter sliders for the current filter."""
if self.original_image is None:
self.show_message("Advertencia", "Primero carga una imagen.")
return
-
+
self.clear_sliders()
-
- filter_info = FILTERS[self.active_category][self.active_filter]
-
+
+ filter_info = self.filter_manager.get_filter_info(
+ self.active_category, self.active_filter
+ )
+ if not filter_info:
+ return
+
for param_name, param_info in filter_info["parameters"].items():
if "options" in param_info:
combobox = QComboBox(self)
@@ -264,9 +363,13 @@ def update_sliders(self):
self.slider_layout.addWidget(QLabel(param_name))
self.slider_layout.addWidget(combobox)
else:
- slider = QSlider(Qt.Horizontal, minimum=int(param_info["min"]), maximum=int(param_info["max"]), value=int(param_info["init"]), singleStep
-
-=int(param_info["interval"]))
+ slider = QSlider(
+ Qt.Horizontal,
+ minimum=int(param_info["min"]),
+ maximum=int(param_info["max"]),
+ value=int(param_info["init"]),
+ singleStep=int(param_info["interval"])
+ )
slider.valueChanged.connect(self.update_slider_value)
slider.setFixedHeight(20)
slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
@@ -276,21 +379,28 @@ def update_sliders(self):
self.image = filter_info["apply"](self.original_image, **self.get_slider_values())
self.display_image(self.image)
-
+
def show_message(self, title, message):
+ """Show a warning message dialog."""
QMessageBox.warning(self, title, message)
-
+
def get_slider_values(self):
+ """Get the current values from all sliders and comboboxes."""
values = {param_name: slider.value() for param_name, slider in self.sliders}
values.update({param_name: combobox.currentText() for param_name, combobox in self.comboboxes})
return values
-
+
def update_slider_value(self):
- filter_info = FILTERS[self.active_category][self.active_filter]
- self.image = filter_info["apply"](self.original_image, **self.get_slider_values())
- self.display_image(self.image)
-
+ """Update the image when a slider value changes."""
+ filter_info = self.filter_manager.get_filter_info(
+ self.active_category, self.active_filter
+ )
+ if filter_info:
+ self.image = filter_info["apply"](self.original_image, **self.get_slider_values())
+ self.display_image(self.image)
+
def clear_sliders(self):
+ """Clear all parameter sliders and comboboxes."""
for param_name, slider in self.sliders:
slider.deleteLater()
for param_name, combobox in self.comboboxes:
@@ -303,6 +413,386 @@ def clear_sliders(self):
self.sliders = []
self.comboboxes = []
+
+class ParameterWidget(QWidget):
+ """Widget for configuring a single filter parameter."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ """Initialize the parameter widget UI."""
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Parameter name
+ self.name_edit = QLineEdit()
+ self.name_edit.setPlaceholderText("Nombre del parámetro")
+ layout.addWidget(self.name_edit)
+
+ # Parameter type
+ self.type_combo = QComboBox()
+ self.type_combo.addItems(["slider", "options"])
+ self.type_combo.currentTextChanged.connect(self.on_type_changed)
+ layout.addWidget(self.type_combo)
+
+ # Slider configuration
+ self.slider_widget = QWidget()
+ slider_layout = QHBoxLayout(self.slider_widget)
+ slider_layout.setContentsMargins(0, 0, 0, 0)
+
+ self.min_spin = QDoubleSpinBox()
+ self.min_spin.setRange(-10000, 10000)
+ self.min_spin.setPrefix("Min: ")
+ slider_layout.addWidget(self.min_spin)
+
+ self.max_spin = QDoubleSpinBox()
+ self.max_spin.setRange(-10000, 10000)
+ self.max_spin.setValue(100)
+ self.max_spin.setPrefix("Max: ")
+ slider_layout.addWidget(self.max_spin)
+
+ self.init_spin = QDoubleSpinBox()
+ self.init_spin.setRange(-10000, 10000)
+ self.init_spin.setValue(50)
+ self.init_spin.setPrefix("Init: ")
+ slider_layout.addWidget(self.init_spin)
+
+ self.interval_spin = QDoubleSpinBox()
+ self.interval_spin.setRange(0.001, 1000)
+ self.interval_spin.setValue(1)
+ self.interval_spin.setPrefix("Intervalo: ")
+ slider_layout.addWidget(self.interval_spin)
+
+ layout.addWidget(self.slider_widget)
+
+ # Options configuration
+ self.options_widget = QWidget()
+ options_layout = QHBoxLayout(self.options_widget)
+ options_layout.setContentsMargins(0, 0, 0, 0)
+
+ self.options_edit = QLineEdit()
+ self.options_edit.setPlaceholderText("Opciones (separadas por coma)")
+ options_layout.addWidget(self.options_edit)
+
+ self.init_option_edit = QLineEdit()
+ self.init_option_edit.setPlaceholderText("Valor inicial")
+ options_layout.addWidget(self.init_option_edit)
+
+ layout.addWidget(self.options_widget)
+ self.options_widget.hide()
+
+ # Remove button
+ self.remove_btn = QPushButton("X")
+ self.remove_btn.setFixedWidth(30)
+ self.remove_btn.setStyleSheet("background-color: #f44336; color: white;")
+ layout.addWidget(self.remove_btn)
+
+ def on_type_changed(self, param_type):
+ """Handle parameter type change."""
+ if param_type == "slider":
+ self.slider_widget.show()
+ self.options_widget.hide()
+ else:
+ self.slider_widget.hide()
+ self.options_widget.show()
+
+ def get_config(self):
+ """Get the parameter configuration."""
+ name = self.name_edit.text().strip()
+ if not name:
+ return None, None
+
+ if self.type_combo.currentText() == "slider":
+ config = {
+ "min": self.min_spin.value(),
+ "max": self.max_spin.value(),
+ "init": self.init_spin.value(),
+ "interval": self.interval_spin.value()
+ }
+ else:
+ options_text = self.options_edit.text().strip()
+ options = [opt.strip() for opt in options_text.split(",") if opt.strip()]
+ config = {
+ "options": options,
+ "init": self.init_option_edit.text().strip()
+ }
+
+ return name, config
+
+
+class FilterEditorTab(QWidget):
+ """Tab for creating and editing filters."""
+
+ def __init__(self, filter_manager: FilterManager, on_filter_added=None, parent=None):
+ super().__init__(parent)
+ self.filter_manager = filter_manager
+ self.on_filter_added = on_filter_added
+ self.parameter_widgets = []
+ self.init_ui()
+
+ def init_ui(self):
+ """Initialize the filter editor UI."""
+ main_layout = QVBoxLayout(self)
+
+ # Scroll area for the form
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll_content = QWidget()
+ form_layout = QVBoxLayout(scroll_content)
+
+ # Basic info group
+ basic_group = QGroupBox("Información básica del filtro")
+ basic_layout = QFormLayout()
+
+ self.filter_name_edit = QLineEdit()
+ self.filter_name_edit.setPlaceholderText("Nombre del filtro")
+ basic_layout.addRow("Nombre:", self.filter_name_edit)
+
+ self.category_combo = QComboBox()
+ self.category_combo.setEditable(True)
+ self.category_combo.addItems(self.filter_manager.get_categories())
+ basic_layout.addRow("Categoría:", self.category_combo)
+
+ self.operation_combo = QComboBox()
+ self.operation_combo.addItems(self.filter_manager.get_available_operations())
+ basic_layout.addRow("Operación:", self.operation_combo)
+
+ self.description_edit = QLineEdit()
+ self.description_edit.setPlaceholderText("Descripción del filtro")
+ basic_layout.addRow("Descripción:", self.description_edit)
+
+ basic_group.setLayout(basic_layout)
+ form_layout.addWidget(basic_group)
+
+ # Parameters group
+ params_group = QGroupBox("Parámetros del filtro")
+ self.params_layout = QVBoxLayout()
+
+ add_param_btn = QPushButton("+ Añadir parámetro")
+ add_param_btn.clicked.connect(self.add_parameter)
+ add_param_btn.setStyleSheet(Styles.FILTER_BUTTON)
+ self.params_layout.addWidget(add_param_btn)
+
+ self.params_container = QVBoxLayout()
+ self.params_layout.addLayout(self.params_container)
+
+ params_group.setLayout(self.params_layout)
+ form_layout.addWidget(params_group)
+
+ # JSON preview group
+ preview_group = QGroupBox("Vista previa JSON")
+ preview_layout = QVBoxLayout()
+
+ self.json_preview = QTextEdit()
+ self.json_preview.setReadOnly(True)
+ self.json_preview.setMaximumHeight(150)
+ preview_layout.addWidget(self.json_preview)
+
+ preview_btn = QPushButton("Actualizar vista previa")
+ preview_btn.clicked.connect(self.update_preview)
+ preview_layout.addWidget(preview_btn)
+
+ preview_group.setLayout(preview_layout)
+ form_layout.addWidget(preview_group)
+
+ # Spacer
+ form_layout.addStretch()
+
+ scroll.setWidget(scroll_content)
+ main_layout.addWidget(scroll)
+
+ # Action buttons
+ buttons_layout = QHBoxLayout()
+
+ save_btn = QPushButton("Guardar filtro")
+ save_btn.clicked.connect(self.save_filter)
+ save_btn.setStyleSheet(Styles.FILTER_BUTTON)
+ buttons_layout.addWidget(save_btn)
+
+ clear_btn = QPushButton("Limpiar formulario")
+ clear_btn.clicked.connect(self.clear_form)
+ clear_btn.setStyleSheet(Styles.FILE_BUTTON)
+ buttons_layout.addWidget(clear_btn)
+
+ main_layout.addLayout(buttons_layout)
+
+ def add_parameter(self):
+ """Add a new parameter widget."""
+ param_widget = ParameterWidget()
+ param_widget.remove_btn.clicked.connect(lambda: self.remove_parameter(param_widget))
+ self.parameter_widgets.append(param_widget)
+ self.params_container.addWidget(param_widget)
+
+ def remove_parameter(self, widget):
+ """Remove a parameter widget."""
+ if widget in self.parameter_widgets:
+ self.parameter_widgets.remove(widget)
+ widget.deleteLater()
+
+ def get_parameters_config(self):
+ """Get the configuration for all parameters."""
+ parameters = {}
+ for widget in self.parameter_widgets:
+ name, config = widget.get_config()
+ if name and config:
+ parameters[name] = config
+ return parameters
+
+ def update_preview(self):
+ """Update the JSON preview."""
+ filter_config = {
+ "operation": self.operation_combo.currentText(),
+ "description": self.description_edit.text().strip(),
+ "parameters": self.get_parameters_config()
+ }
+
+ preview_text = json.dumps(filter_config, indent=4, ensure_ascii=False)
+ self.json_preview.setPlainText(preview_text)
+
+ def validate_form(self):
+ """Validate the form data."""
+ filter_name = self.filter_name_edit.text().strip()
+ if not filter_name:
+ return False, "El nombre del filtro es obligatorio."
+
+ category = self.category_combo.currentText().strip()
+ if not category:
+ return False, "La categoría es obligatoria."
+
+ operation = self.operation_combo.currentText().strip()
+ if not operation:
+ return False, "La operación es obligatoria."
+
+ parameters = self.get_parameters_config()
+ is_valid, error_msg = self.filter_manager.validate_parameters(parameters)
+ if not is_valid:
+ return False, error_msg
+
+ return True, ""
+
+ def save_filter(self):
+ """Save the filter to the JSON file."""
+ is_valid, error_msg = self.validate_form()
+ if not is_valid:
+ QMessageBox.warning(self, "Error de validación", error_msg)
+ return
+
+ filter_name = self.filter_name_edit.text().strip()
+ category = self.category_combo.currentText().strip()
+ operation = self.operation_combo.currentText()
+ description = self.description_edit.text().strip()
+ parameters = self.get_parameters_config()
+
+ # Check if filter already exists
+ existing = self.filter_manager.get_filter(category, filter_name)
+ if existing:
+ reply = QMessageBox.question(
+ self, "Filtro existente",
+ f"El filtro '{filter_name}' ya existe en la categoría '{category}'. ¿Desea sobrescribirlo?",
+ QMessageBox.Yes | QMessageBox.No
+ )
+ if reply != QMessageBox.Yes:
+ return
+
+ try:
+ self.filter_manager.add_filter(
+ category=category,
+ filter_name=filter_name,
+ operation=operation,
+ description=description,
+ parameters=parameters
+ )
+
+ QMessageBox.information(
+ self, "Éxito",
+ f"El filtro '{filter_name}' se ha guardado correctamente en la categoría '{category}'."
+ )
+
+ # Refresh category list
+ current_categories = [self.category_combo.itemText(i) for i in range(self.category_combo.count())]
+ if category not in current_categories:
+ self.category_combo.addItem(category)
+
+ # Notify parent to refresh filters
+ if self.on_filter_added:
+ self.on_filter_added()
+
+ self.clear_form()
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Error al guardar el filtro: {str(e)}")
+
+ def clear_form(self):
+ """Clear all form fields."""
+ self.filter_name_edit.clear()
+ self.description_edit.clear()
+ self.operation_combo.setCurrentIndex(0)
+ self.json_preview.clear()
+
+ # Remove all parameter widgets
+ for widget in self.parameter_widgets[:]:
+ self.remove_parameter(widget)
+
+
+class PhotoEditorApp(QMainWindow):
+ """Main application window."""
+
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("MODT - Photo Editor")
+ self.setGeometry(100, 100, 900, 600)
+ self.setWindowIcon(QIcon("assets/calavera.gif"))
+
+ # Initialize filter manager
+ self.filter_manager = get_filter_manager()
+
+ self.init_ui()
+
+ def init_ui(self):
+ """Initialize the main UI."""
+ self.setStyleSheet(Styles.MAIN_WINDOW)
+
+ # Create central widget with tabs
+ central_widget = QWidget(self)
+ self.setCentralWidget(central_widget)
+ main_layout = QVBoxLayout(central_widget)
+
+ # Create tab widget
+ self.tab_widget = QTabWidget()
+ self.tab_widget.setStyleSheet(Styles.TAB_WIDGET)
+
+ # Photo Editor tab
+ self.photo_editor_tab = PhotoEditorTab(self.filter_manager)
+ self.tab_widget.addTab(self.photo_editor_tab, "Editor de Fotos")
+
+ # Filter Editor tab
+ self.filter_editor_tab = FilterEditorTab(
+ self.filter_manager,
+ on_filter_added=self.on_filter_added
+ )
+ self.tab_widget.addTab(self.filter_editor_tab, "Editor de Filtros")
+
+ main_layout.addWidget(self.tab_widget)
+
+ # Apply combined styles
+ QApplication.instance().setStyleSheet(Styles.get_combined_style())
+
+ def on_filter_added(self):
+ """Callback when a new filter is added."""
+ # Reload filters from JSON
+ self.filter_manager.load_filters()
+ # Refresh the photo editor tab
+ self.photo_editor_tab.refresh_filters()
+
+ def resizeEvent(self, event):
+ """Handle window resize."""
+ if self.photo_editor_tab.image is not None:
+ self.photo_editor_tab.display_image(self.photo_editor_tab.image)
+ super().resizeEvent(event)
+
+
if __name__ == '__main__':
app = QApplication(sys.argv)
editor_app = PhotoEditorApp()
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..d4839a6
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+# Tests package
diff --git a/tests/test_filter_manager.py b/tests/test_filter_manager.py
new file mode 100644
index 0000000..9448b04
--- /dev/null
+++ b/tests/test_filter_manager.py
@@ -0,0 +1,328 @@
+"""
+Tests for the FilterManager module.
+"""
+
+import json
+import os
+import tempfile
+import unittest
+
+import sys
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+
+class TestFilterManager(unittest.TestCase):
+ """Test cases for FilterManager class."""
+
+ def setUp(self):
+ """Set up test fixtures."""
+ # Import here to avoid circular imports
+ from filter_manager import FilterManager
+
+ self.test_filters = {
+ "TestCategory": {
+ "test_filter": {
+ "operation": "gaussian_blur",
+ "description": "Test gaussian blur filter",
+ "parameters": {
+ "kernel_size": {"min": 1, "max": 31, "init": 15, "interval": 2},
+ "sigma": {"min": 1, "max": 10, "init": 5, "interval": 1}
+ }
+ },
+ "test_filter_options": {
+ "operation": "saturate_color",
+ "description": "Test color saturation filter",
+ "parameters": {
+ "color": {"options": ["red", "green", "blue"], "init": "red"}
+ }
+ }
+ },
+ "AnotherCategory": {
+ "another_filter": {
+ "operation": "grayscale",
+ "description": "Test grayscale filter",
+ "parameters": {}
+ }
+ }
+ }
+
+ # Create a temporary JSON file
+ self.temp_file = tempfile.NamedTemporaryFile(
+ mode='w', suffix='.json', delete=False
+ )
+ json.dump(self.test_filters, self.temp_file)
+ self.temp_file.close()
+
+ self.manager = FilterManager(self.temp_file.name)
+
+ def tearDown(self):
+ """Clean up test fixtures."""
+ os.unlink(self.temp_file.name)
+
+ def test_load_filters(self):
+ """Test loading filters from JSON file."""
+ filters = self.manager.load_filters()
+ self.assertEqual(len(filters), 2)
+ self.assertIn("TestCategory", filters)
+ self.assertIn("AnotherCategory", filters)
+
+ def test_get_categories(self):
+ """Test getting all categories."""
+ categories = self.manager.get_categories()
+ self.assertEqual(len(categories), 2)
+ self.assertIn("TestCategory", categories)
+ self.assertIn("AnotherCategory", categories)
+
+ def test_get_filters_in_category(self):
+ """Test getting filters in a specific category."""
+ filters = self.manager.get_filters_in_category("TestCategory")
+ self.assertEqual(len(filters), 2)
+ self.assertIn("test_filter", filters)
+ self.assertIn("test_filter_options", filters)
+
+ def test_get_filters_in_nonexistent_category(self):
+ """Test getting filters from non-existent category."""
+ filters = self.manager.get_filters_in_category("NonExistent")
+ self.assertEqual(filters, {})
+
+ def test_get_filter(self):
+ """Test getting a specific filter."""
+ filter_config = self.manager.get_filter("TestCategory", "test_filter")
+ self.assertIsNotNone(filter_config)
+ self.assertEqual(filter_config["operation"], "gaussian_blur")
+ self.assertEqual(filter_config["description"], "Test gaussian blur filter")
+
+ def test_get_nonexistent_filter(self):
+ """Test getting a non-existent filter."""
+ filter_config = self.manager.get_filter("TestCategory", "nonexistent")
+ self.assertIsNone(filter_config)
+
+ def test_add_filter(self):
+ """Test adding a new filter."""
+ result = self.manager.add_filter(
+ category="NewCategory",
+ filter_name="new_filter",
+ operation="pixelate",
+ description="A new pixelation filter",
+ parameters={"block_size": {"min": 2, "max": 50, "init": 5, "interval": 1}}
+ )
+
+ self.assertTrue(result)
+ self.assertIn("NewCategory", self.manager.get_categories())
+
+ new_filter = self.manager.get_filter("NewCategory", "new_filter")
+ self.assertIsNotNone(new_filter)
+ self.assertEqual(new_filter["operation"], "pixelate")
+
+ def test_add_filter_to_existing_category(self):
+ """Test adding a filter to an existing category."""
+ result = self.manager.add_filter(
+ category="TestCategory",
+ filter_name="new_test_filter",
+ operation="median_blur",
+ description="A new median blur filter",
+ parameters={}
+ )
+
+ self.assertTrue(result)
+ filters = self.manager.get_filters_in_category("TestCategory")
+ self.assertEqual(len(filters), 3)
+ self.assertIn("new_test_filter", filters)
+
+ def test_remove_filter(self):
+ """Test removing a filter."""
+ result = self.manager.remove_filter("TestCategory", "test_filter")
+ self.assertTrue(result)
+
+ filter_config = self.manager.get_filter("TestCategory", "test_filter")
+ self.assertIsNone(filter_config)
+
+ def test_remove_last_filter_in_category(self):
+ """Test removing the last filter in a category removes the category."""
+ self.manager.remove_filter("AnotherCategory", "another_filter")
+ self.assertNotIn("AnotherCategory", self.manager.get_categories())
+
+ def test_remove_nonexistent_filter(self):
+ """Test removing a non-existent filter."""
+ result = self.manager.remove_filter("TestCategory", "nonexistent")
+ self.assertFalse(result)
+
+ def test_validate_parameters_valid_slider(self):
+ """Test validation of valid slider parameters."""
+ params = {
+ "intensity": {"min": 0, "max": 100, "init": 50, "interval": 1}
+ }
+ is_valid, error_msg = self.manager.validate_parameters(params)
+ self.assertTrue(is_valid)
+ self.assertEqual(error_msg, "")
+
+ def test_validate_parameters_valid_options(self):
+ """Test validation of valid options parameters."""
+ params = {
+ "color": {"options": ["red", "green", "blue"], "init": "red"}
+ }
+ is_valid, error_msg = self.manager.validate_parameters(params)
+ self.assertTrue(is_valid)
+ self.assertEqual(error_msg, "")
+
+ def test_validate_parameters_invalid_min_max(self):
+ """Test validation with min > max."""
+ params = {
+ "intensity": {"min": 100, "max": 0, "init": 50, "interval": 1}
+ }
+ is_valid, error_msg = self.manager.validate_parameters(params)
+ self.assertFalse(is_valid)
+ self.assertIn("min", error_msg.lower())
+
+ def test_validate_parameters_invalid_init(self):
+ """Test validation with init out of range."""
+ params = {
+ "intensity": {"min": 0, "max": 100, "init": 150, "interval": 1}
+ }
+ is_valid, error_msg = self.manager.validate_parameters(params)
+ self.assertFalse(is_valid)
+ self.assertIn("init", error_msg.lower())
+
+ def test_validate_parameters_invalid_interval(self):
+ """Test validation with non-positive interval."""
+ params = {
+ "intensity": {"min": 0, "max": 100, "init": 50, "interval": 0}
+ }
+ is_valid, error_msg = self.manager.validate_parameters(params)
+ self.assertFalse(is_valid)
+ self.assertIn("interval", error_msg.lower())
+
+ def test_validate_parameters_missing_key(self):
+ """Test validation with missing required key."""
+ params = {
+ "intensity": {"min": 0, "max": 100, "init": 50} # missing interval
+ }
+ is_valid, error_msg = self.manager.validate_parameters(params)
+ self.assertFalse(is_valid)
+ self.assertIn("interval", error_msg.lower())
+
+ def test_validate_parameters_empty_options(self):
+ """Test validation with empty options list."""
+ params = {
+ "color": {"options": [], "init": "red"}
+ }
+ is_valid, error_msg = self.manager.validate_parameters(params)
+ self.assertFalse(is_valid)
+ self.assertIn("options", error_msg.lower())
+
+ def test_validate_parameters_init_not_in_options(self):
+ """Test validation with init not in options."""
+ params = {
+ "color": {"options": ["red", "green", "blue"], "init": "yellow"}
+ }
+ is_valid, error_msg = self.manager.validate_parameters(params)
+ self.assertFalse(is_valid)
+ self.assertIn("init", error_msg.lower())
+
+ def test_save_filters(self):
+ """Test saving filters to JSON file."""
+ from filter_manager import FilterManager
+
+ self.manager.add_filter(
+ category="SaveTest",
+ filter_name="save_test_filter",
+ operation="glitch",
+ description="Test save filter",
+ parameters={}
+ )
+
+ # Create a new manager to reload from file
+ new_manager = FilterManager(self.temp_file.name)
+ self.assertIn("SaveTest", new_manager.get_categories())
+ self.assertIsNotNone(new_manager.get_filter("SaveTest", "save_test_filter"))
+
+ def test_load_nonexistent_file(self):
+ """Test loading from non-existent file creates empty config."""
+ from filter_manager import FilterManager
+ manager = FilterManager("/nonexistent/path/filters.json")
+ self.assertEqual(manager.get_categories(), [])
+
+ def test_get_available_operations(self):
+ """Test getting available operations."""
+ operations = self.manager.get_available_operations()
+ self.assertIsInstance(operations, list)
+ self.assertIn("grayscale", operations)
+ self.assertIn("gaussian_blur", operations)
+ self.assertIn("pixelate", operations)
+
+ def test_get_filter_info(self):
+ """Test getting filter info with apply function."""
+ filter_info = self.manager.get_filter_info("TestCategory", "test_filter")
+
+ self.assertIsNotNone(filter_info)
+ self.assertIn("apply", filter_info)
+ self.assertIn("parameters", filter_info)
+ self.assertTrue(callable(filter_info["apply"]))
+
+
+class TestFilterEngine(unittest.TestCase):
+ """Test cases for FilterEngine class."""
+
+ def setUp(self):
+ """Set up test fixtures."""
+ from filter_engine import FilterEngine
+ import numpy as np
+
+ self.engine = FilterEngine()
+ # Create a simple test image (100x100 BGR)
+ self.test_image = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8)
+
+ def test_get_available_operations(self):
+ """Test getting list of available operations."""
+ operations = self.engine.get_available_operations()
+ self.assertIsInstance(operations, list)
+ self.assertGreater(len(operations), 0)
+ self.assertIn("grayscale", operations)
+ self.assertIn("gaussian_blur", operations)
+
+ def test_grayscale_operation(self):
+ """Test grayscale operation."""
+ result = self.engine.apply_operation(self.test_image, "grayscale")
+ self.assertEqual(len(result.shape), 2) # Grayscale has 2 dimensions
+
+ def test_gaussian_blur_operation(self):
+ """Test Gaussian blur operation."""
+ result = self.engine.apply_operation(
+ self.test_image, "gaussian_blur",
+ kernel_size=5, sigma=1
+ )
+ self.assertEqual(result.shape, self.test_image.shape)
+
+ def test_pixelate_operation(self):
+ """Test pixelate operation."""
+ result = self.engine.apply_operation(
+ self.test_image, "pixelate",
+ block_size=10
+ )
+ # Result should be smaller due to block alignment
+ self.assertIsNotNone(result)
+
+ def test_unknown_operation_raises_error(self):
+ """Test that unknown operation raises ValueError."""
+ with self.assertRaises(ValueError):
+ self.engine.apply_operation(self.test_image, "unknown_operation")
+
+ def test_hue_shift_operation(self):
+ """Test hue shift operation."""
+ result = self.engine.apply_operation(
+ self.test_image, "hue_shift",
+ hue_shift=30
+ )
+ self.assertEqual(result.shape, self.test_image.shape)
+
+ def test_glitch_operation(self):
+ """Test glitch operation."""
+ result = self.engine.apply_operation(
+ self.test_image, "glitch",
+ glitch_intensity=5, glitch_frequency=10
+ )
+ self.assertEqual(result.shape, self.test_image.shape)
+
+
+if __name__ == '__main__':
+ unittest.main()