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()