From 3dd017b3383ec8432fc305c5b59bbbdf039304ee Mon Sep 17 00:00:00 2001 From: medeirosdev Date: Wed, 24 Dec 2025 19:16:57 -0300 Subject: [PATCH 1/4] feat: optimization attempts for image processing --- src/App.tsx | 275 +++++++++++------- .../LoadingSkeleton/LoadingSkeleton.tsx | 27 +- src/hooks/index.ts | 1 + src/hooks/useImageWorker.ts | 164 +++++++++++ src/workers/imageWorker.ts | 163 +++++++++++ 5 files changed, 520 insertions(+), 110 deletions(-) create mode 100644 src/hooks/useImageWorker.ts create mode 100644 src/workers/imageWorker.ts diff --git a/src/App.tsx b/src/App.tsx index 24508cb..fa1df01 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { ImageCanvas, Sidebar, PixelInspector, FormulaPanel, LoadingSkeleton, ProcessingIndicator } from './components'; -import { useHistory } from './hooks'; +import { useHistory, useImageWorker } from './hooks'; import type { FilterType, FilterParams } from './types'; import { applyNegative, @@ -66,6 +66,55 @@ const DEFAULT_FILTER_PARAMS: FilterParams = { customKernelSize: 3, }; +/** + * Synchronous image processing fallback when worker is not available. + */ +function processImageSync( + imageData: ImageData, + filter: FilterType, + params: FilterParams +): ImageData { + switch (filter) { + case 'negative': + return applyNegative(imageData); + case 'gamma': + return applyGamma(imageData, params.gamma, params.gammaConstant); + case 'log': + return applyLog(imageData, params.logConstant); + case 'quantization': + return applyQuantization(imageData, params.quantizationLevels); + case 'sampling': + return applySampling(imageData, params.samplingFactor); + case 'equalization': + return applyEqualization(imageData); + case 'boxBlur': + return applyBoxBlur(imageData, params.kernelSize); + case 'gaussianBlur': + return applyGaussianBlur(imageData, params.kernelSize, params.gaussianSigma); + case 'sharpen': + return applySharpen(imageData); + case 'laplacian': + return applyLaplacian(imageData); + case 'threshold': + return applyThreshold(imageData, params.threshold); + case 'erosion': + return applyErosion(applyThreshold(imageData, params.threshold)); + case 'dilation': + return applyDilation(applyThreshold(imageData, params.threshold)); + case 'opening': + return applyOpening(applyThreshold(imageData, params.threshold)); + case 'closing': + return applyClosing(applyThreshold(imageData, params.threshold)); + case 'customFormula': + return applyCustomFormula(imageData, params.customFormula); + case 'customKernel': + return applyCustomKernel(imageData, params.customKernel); + case 'none': + default: + return imageData; + } +} + // ============================================================================= // Main Application Component // ============================================================================= @@ -119,87 +168,68 @@ function App() { const fileInputRef = useRef(null); // --------------------------------------------------------------------------- - // Memoized: Processed Image + // Web Worker for Image Processing // --------------------------------------------------------------------------- + const { processImage: workerProcess, isReady: workerReady } = useImageWorker(); + const [processedImage, setProcessedImage] = useState(null); + const [processingTime, setProcessingTime] = useState(null); + + // Log processing time for debugging (can be used for UI later) + useEffect(() => { + if (processingTime !== null && processingTime > 0) { + console.debug(`[ImageVisLab] Filter processed in ${processingTime.toFixed(2)}ms`); + } + }, [processingTime]); /** - * Applies the active filter to the original image. - * Recalculates only when the image, filter type, or parameters change. + * Applies the active filter to the original image using Web Worker. + * Runs in a separate thread to keep UI responsive. */ - const processedImage = useMemo(() => { - if (!originalImage) return null; - - // Start processing indicator for potentially slow operations - const slowFilters = ['gaussianBlur', 'boxBlur', 'erosion', 'dilation', 'opening', 'closing']; - if (slowFilters.includes(activeFilter)) { - setIsProcessing(true); + useEffect(() => { + if (!originalImage) { + setProcessedImage(null); + return; } - let result: ImageData; - switch (activeFilter) { - case 'negative': - result = applyNegative(originalImage); - break; - case 'gamma': - result = applyGamma(originalImage, filterParams.gamma, filterParams.gammaConstant); - break; - case 'log': - result = applyLog(originalImage, filterParams.logConstant); - break; - case 'quantization': - result = applyQuantization(originalImage, filterParams.quantizationLevels); - break; - case 'sampling': - result = applySampling(originalImage, filterParams.samplingFactor); - break; - case 'equalization': - result = applyEqualization(originalImage); - break; - // Spatial Filters (Convolution) - case 'boxBlur': - result = applyBoxBlur(originalImage, filterParams.kernelSize); - break; - case 'gaussianBlur': - result = applyGaussianBlur(originalImage, filterParams.kernelSize, filterParams.gaussianSigma); - break; - case 'sharpen': - result = applySharpen(originalImage); - break; - case 'laplacian': - result = applyLaplacian(originalImage); - break; - // Morphology Operations - case 'threshold': - result = applyThreshold(originalImage, filterParams.threshold); - break; - case 'erosion': - result = applyErosion(applyThreshold(originalImage, filterParams.threshold)); - break; - case 'dilation': - result = applyDilation(applyThreshold(originalImage, filterParams.threshold)); - break; - case 'opening': - result = applyOpening(applyThreshold(originalImage, filterParams.threshold)); - break; - case 'closing': - result = applyClosing(applyThreshold(originalImage, filterParams.threshold)); - break; - // Custom Operations - case 'customFormula': - result = applyCustomFormula(originalImage, filterParams.customFormula); - break; - case 'customKernel': - result = applyCustomKernel(originalImage, filterParams.customKernel); - break; - case 'none': - default: - result = originalImage; + // For 'none' filter, just use original + if (activeFilter === 'none') { + setProcessedImage(originalImage); + setIsProcessing(false); + return; } - // End processing indicator - setTimeout(() => setIsProcessing(false), 0); - return result; - }, [originalImage, activeFilter, filterParams]); + // Start processing + setIsProcessing(true); + const startTime = performance.now(); + + // Clone the image data since worker will transfer the buffer + const clonedData = new Uint8ClampedArray(originalImage.data); + const clonedImage = new ImageData(clonedData, originalImage.width, originalImage.height); + + if (workerReady) { + // Use Web Worker for processing + workerProcess(clonedImage, activeFilter, filterParams) + .then((result) => { + setProcessedImage(result); + setProcessingTime(performance.now() - startTime); + setIsProcessing(false); + }) + .catch((error) => { + console.error('Worker error, falling back to main thread:', error); + // Fallback to synchronous processing + const result = processImageSync(originalImage, activeFilter, filterParams); + setProcessedImage(result); + setProcessingTime(performance.now() - startTime); + setIsProcessing(false); + }); + } else { + // Fallback: synchronous processing on main thread + const result = processImageSync(originalImage, activeFilter, filterParams); + setProcessedImage(result); + setProcessingTime(performance.now() - startTime); + setIsProcessing(false); + } + }, [originalImage, activeFilter, filterParams, workerReady, workerProcess]); /** * Calculates histogram data for the processed image. @@ -347,52 +377,79 @@ function App() { const img = new Image(); img.onload = () => { - // Check for very large images (> 4000px in any dimension) - const MAX_DIMENSION = 4000; - const MAX_PIXELS = 16000000; // 16 megapixels + const RECOMMENDED_MAX = 2000; // Recommended max dimension + const ABSOLUTE_MAX = 4000; // Hard limit + const MAX_PIXELS = 16000000; // 16 megapixels const totalPixels = img.width * img.height; + const maxDimension = Math.max(img.width, img.height); - if (img.width > MAX_DIMENSION || img.height > MAX_DIMENSION) { + // Hard limits - reject completely + if (img.width > ABSOLUTE_MAX || img.height > ABSOLUTE_MAX || totalPixels > MAX_PIXELS) { setIsLoading(false); setLoadingMessage(''); - alert(`Image too large (${img.width}x${img.height}). Maximum dimension is ${MAX_DIMENSION}px.`); + alert(`Image too large (${img.width}x${img.height}). Maximum is ${ABSOLUTE_MAX}px or ${MAX_PIXELS / 1000000}MP.`); return; } - if (totalPixels > MAX_PIXELS) { - setIsLoading(false); - setLoadingMessage(''); - alert(`Image has too many pixels (${(totalPixels / 1000000).toFixed(1)}MP). Maximum is ${MAX_PIXELS / 1000000}MP.`); - return; - } + // Helper function to process image with optional resize + const processImage = (sourceImg: HTMLImageElement, targetWidth: number, targetHeight: number) => { + setLoadingMessage('Processing pixels...'); - setLoadingMessage('Processing pixels...'); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const canvas = document.createElement('canvas'); + canvas.width = targetWidth; + canvas.height = targetHeight; - // Use setTimeout to allow UI to update before heavy processing - setTimeout(() => { - // Create temporary canvas to extract ImageData - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + setIsLoading(false); + return; + } - const ctx = canvas.getContext('2d'); - if (!ctx) { - setIsLoading(false); - return; + // Use high quality image smoothing for resize + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(sourceImg, 0, 0, targetWidth, targetHeight); + + const imageData = ctx.getImageData(0, 0, targetWidth, targetHeight); + + setOriginalImage(imageData); + setActiveFilter('none'); + setFilterParams(DEFAULT_FILTER_PARAMS); + setPixelInfo(null); + setNeighborhood([]); + setIsLoading(false); + setLoadingMessage(''); + handleResetAnimation(); + }); + }); + }; + + // Check if resize is recommended + if (maxDimension > RECOMMENDED_MAX) { + const scale = RECOMMENDED_MAX / maxDimension; + const newWidth = Math.round(img.width * scale); + const newHeight = Math.round(img.height * scale); + + const shouldResize = window.confirm( + `⚠️ Imagem grande detectada (${img.width}x${img.height})\n\n` + + `Imagens grandes podem causar lentidão ou travamento.\n\n` + + `Clique OK para redimensionar para ${newWidth}x${newHeight} (recomendado)\n` + + `Clique Cancelar para tentar carregar o original` + ); + + if (shouldResize) { + setLoadingMessage(`Resizing to ${newWidth}x${newHeight}...`); + processImage(img, newWidth, newHeight); + } else { + // User chose to load original - proceed with warning + processImage(img, img.width, img.height); } - - ctx.drawImage(img, 0, 0); - const imageData = ctx.getImageData(0, 0, img.width, img.height); - - setOriginalImage(imageData); - setActiveFilter('none'); - setFilterParams(DEFAULT_FILTER_PARAMS); - setPixelInfo(null); - setNeighborhood([]); - setIsLoading(false); - setLoadingMessage(''); - handleResetAnimation(); - }, 50); + } else { + // Image is small enough, process normally + processImage(img, img.width, img.height); + } }; img.onerror = () => { diff --git a/src/components/LoadingSkeleton/LoadingSkeleton.tsx b/src/components/LoadingSkeleton/LoadingSkeleton.tsx index b4c41f3..d7434a7 100644 --- a/src/components/LoadingSkeleton/LoadingSkeleton.tsx +++ b/src/components/LoadingSkeleton/LoadingSkeleton.tsx @@ -77,13 +77,38 @@ interface ProcessingIndicatorProps { isProcessing: boolean; /** Current filter being processed */ filterName?: string; + /** Minimum time to show indicator (ms) - prevents flicker */ + minDisplayTime?: number; } export const ProcessingIndicator: React.FC = ({ isProcessing, filterName, + minDisplayTime = 300, }) => { - if (!isProcessing) return null; + const [visible, setVisible] = React.useState(false); + const showTimeRef = React.useRef(0); + + React.useEffect(() => { + if (isProcessing) { + // Show immediately when processing starts + setVisible(true); + showTimeRef.current = Date.now(); + } else if (visible) { + // Calculate how long we've been visible + const elapsed = Date.now() - showTimeRef.current; + const remaining = Math.max(0, minDisplayTime - elapsed); + + // Only hide after minimum display time + const timeout = setTimeout(() => { + setVisible(false); + }, remaining); + + return () => clearTimeout(timeout); + } + }, [isProcessing, visible, minDisplayTime]); + + if (!visible) return null; return (
diff --git a/src/hooks/index.ts b/src/hooks/index.ts index eee6367..64c6b9d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,3 @@ export { useHistory } from './useHistory'; export type { HistoryState } from './useHistory'; +export { useImageWorker } from './useImageWorker'; diff --git a/src/hooks/useImageWorker.ts b/src/hooks/useImageWorker.ts new file mode 100644 index 0000000..83e1c2d --- /dev/null +++ b/src/hooks/useImageWorker.ts @@ -0,0 +1,164 @@ +/** + * ImageVisLab - useImageWorker Hook + * + * React hook for managing image processing Web Worker. + * Handles worker lifecycle, request queuing, and cleanup. + * + * @module useImageWorker + * @author ImageVisLab Contributors + * @license MIT + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { FilterType, FilterParams } from '../types'; +import type { WorkerRequest, WorkerResponse } from '../workers/imageWorker'; + +// ============================================================================= +// Types +// ============================================================================= + +interface UseImageWorkerOptions { + /** Whether to use the worker (can disable for debugging) */ + enabled?: boolean; +} + +interface UseImageWorkerResult { + /** Process an image with a filter */ + processImage: ( + imageData: ImageData, + filter: FilterType, + params: FilterParams + ) => Promise; + /** Whether processing is in progress */ + isProcessing: boolean; + /** Last processing time in ms */ + lastProcessingTime: number | null; + /** Whether worker is ready */ + isReady: boolean; + /** Any error that occurred */ + error: string | null; +} + +// ============================================================================= +// Hook Implementation +// ============================================================================= + +export function useImageWorker( + options: UseImageWorkerOptions = {} +): UseImageWorkerResult { + const { enabled = true } = options; + + const workerRef = useRef(null); + const requestIdRef = useRef(0); + const pendingRequestsRef = useRef void; + reject: (error: Error) => void; + }>>(new Map()); + + const [isProcessing, setIsProcessing] = useState(false); + const [lastProcessingTime, setLastProcessingTime] = useState(null); + const [isReady, setIsReady] = useState(false); + const [error, setError] = useState(null); + + // Initialize worker + useEffect(() => { + if (!enabled) { + setIsReady(true); + return; + } + + try { + // Create worker with Vite's worker syntax + workerRef.current = new Worker( + new URL('../workers/imageWorker.ts', import.meta.url), + { type: 'module' } + ); + + workerRef.current.onmessage = (event: MessageEvent) => { + const data = event.data; + + if (data.type === 'ready') { + setIsReady(true); + return; + } + + const pending = pendingRequestsRef.current.get(data.requestId); + if (!pending) return; + + pendingRequestsRef.current.delete(data.requestId); + + if (data.type === 'result' && data.imageData) { + setLastProcessingTime(data.processingTime); + setIsProcessing(pendingRequestsRef.current.size > 0); + pending.resolve(data.imageData); + } else if (data.type === 'error') { + setError(data.error || 'Unknown error'); + setIsProcessing(pendingRequestsRef.current.size > 0); + pending.reject(new Error(data.error || 'Worker error')); + } + }; + + workerRef.current.onerror = (err) => { + console.error('Worker error:', err); + setError(err.message); + setIsReady(false); + }; + } catch (err) { + console.error('Failed to create worker:', err); + setError(err instanceof Error ? err.message : 'Failed to create worker'); + setIsReady(true); // Allow fallback to main thread + } + + return () => { + workerRef.current?.terminate(); + workerRef.current = null; + pendingRequestsRef.current.clear(); + }; + }, [enabled]); + + // Process image function + const processImage = useCallback( + async ( + imageData: ImageData, + filter: FilterType, + params: FilterParams + ): Promise => { + setError(null); + + // If worker not available, we could fall back to main thread + // but for now just return original + if (!enabled || !workerRef.current) { + return imageData; + } + + const requestId = ++requestIdRef.current; + + return new Promise((resolve, reject) => { + pendingRequestsRef.current.set(requestId, { resolve, reject }); + setIsProcessing(true); + + const request: WorkerRequest = { + type: 'process', + imageData, + filter, + params, + requestId, + }; + + // Transfer the buffer for better performance + workerRef.current!.postMessage(request, [imageData.data.buffer]); + }); + }, + [enabled] + ); + + return { + processImage, + isProcessing, + lastProcessingTime, + isReady, + error, + }; +} + +export default useImageWorker; diff --git a/src/workers/imageWorker.ts b/src/workers/imageWorker.ts new file mode 100644 index 0000000..588ce27 --- /dev/null +++ b/src/workers/imageWorker.ts @@ -0,0 +1,163 @@ +/** + * ImageVisLab - Image Processing Web Worker + * + * Handles heavy image processing operations in a separate thread + * to keep the UI responsive. + * + * @module imageWorker + * @author ImageVisLab Contributors + * @license MIT + */ + +// Import filter functions (will be bundled with worker) +import { + applyNegative, + applyGamma, + applyLog, + applyQuantization, + applySampling, + applyEqualization, +} from '../utils/imageFilters'; + +import { + applyBoxBlur, + applyGaussianBlur, + applySharpen, + applyLaplacian, +} from '../utils/convolution'; + +import { + applyThreshold, + applyErosion, + applyDilation, + applyOpening, + applyClosing, +} from '../utils/morphology'; + +import { + applyCustomFormula, + applyCustomKernel, +} from '../utils/customFilters'; + +import type { FilterType, FilterParams } from '../types'; + +// ============================================================================= +// Worker Message Types +// ============================================================================= + +export interface WorkerRequest { + type: 'process'; + imageData: ImageData; + filter: FilterType; + params: FilterParams; + requestId: number; +} + +export interface WorkerResponse { + type: 'result' | 'error'; + imageData?: ImageData; + error?: string; + requestId: number; + processingTime: number; +} + +// ============================================================================= +// Process Image Function +// ============================================================================= + +function processImage( + imageData: ImageData, + filter: FilterType, + params: FilterParams +): ImageData { + switch (filter) { + // Point Operations + case 'negative': + return applyNegative(imageData); + case 'gamma': + return applyGamma(imageData, params.gamma, params.gammaConstant); + case 'log': + return applyLog(imageData, params.logConstant); + case 'quantization': + return applyQuantization(imageData, params.quantizationLevels); + case 'sampling': + return applySampling(imageData, params.samplingFactor); + case 'equalization': + return applyEqualization(imageData); + + // Spatial Filters + case 'boxBlur': + return applyBoxBlur(imageData, params.kernelSize); + case 'gaussianBlur': + return applyGaussianBlur(imageData, params.kernelSize, params.gaussianSigma); + case 'sharpen': + return applySharpen(imageData); + case 'laplacian': + return applyLaplacian(imageData); + + // Morphology Operations + case 'threshold': + return applyThreshold(imageData, params.threshold); + case 'erosion': + return applyErosion(applyThreshold(imageData, params.threshold)); + case 'dilation': + return applyDilation(applyThreshold(imageData, params.threshold)); + case 'opening': + return applyOpening(applyThreshold(imageData, params.threshold)); + case 'closing': + return applyClosing(applyThreshold(imageData, params.threshold)); + + // Custom Operations + case 'customFormula': + return applyCustomFormula(imageData, params.customFormula); + case 'customKernel': + return applyCustomKernel(imageData, params.customKernel); + + // No filter + case 'none': + default: + return imageData; + } +} + +// ============================================================================= +// Worker Message Handler +// ============================================================================= + +self.onmessage = (event: MessageEvent) => { + const { type, imageData, filter, params, requestId } = event.data; + + if (type !== 'process') { + return; + } + + const startTime = performance.now(); + + try { + const result = processImage(imageData, filter, params); + const processingTime = performance.now() - startTime; + + const response: WorkerResponse = { + type: 'result', + imageData: result, + requestId, + processingTime, + }; + + self.postMessage(response, { transfer: [result.data.buffer] }); + } catch (error) { + const processingTime = performance.now() - startTime; + + const response: WorkerResponse = { + type: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + requestId, + processingTime, + }; + + self.postMessage(response); + } +}; + +// Signal that worker is ready +self.postMessage({ type: 'ready' }); From 68ccca0bd88d097ac986c94813fdb9c65e7f3f74 Mon Sep 17 00:00:00 2001 From: medeirosdev Date: Wed, 24 Dec 2025 19:25:00 -0300 Subject: [PATCH 2/4] feat: Implement core image processing application structure and styling, including image loading, filters, history, and UI components. --- src/App.css | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 90 ++++++++++++++++++++++++++------ 2 files changed, 221 insertions(+), 17 deletions(-) diff --git a/src/App.css b/src/App.css index 3daa496..c2ce5b4 100644 --- a/src/App.css +++ b/src/App.css @@ -147,4 +147,152 @@ font-size: 1rem; color: var(--color-text-secondary); animation: pulse 1.5s ease-in-out infinite; +} + +/* ============================================================================= + Resize Modal + ============================================================================= */ + +.resize-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.2s ease; +} + +.resize-modal { + background: linear-gradient(145deg, + rgba(30, 30, 40, 0.95), + rgba(20, 20, 28, 0.98)); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + padding: 2rem 2.5rem; + max-width: 420px; + width: 90%; + text-align: center; + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.5), + 0 0 40px rgba(138, 99, 210, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + animation: modalSlideIn 0.3s ease; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.resize-modal-icon { + font-size: 3rem; + margin-bottom: 0.5rem; + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3)); +} + +.resize-modal-title { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 0.75rem 0; +} + +.resize-modal-size { + margin: 0 0 0.75rem 0; +} + +.size-original { + font-family: var(--font-mono); + font-size: 1.25rem; + font-weight: 600; + color: #f59e0b; + background: rgba(245, 158, 11, 0.1); + padding: 0.25rem 0.75rem; + border-radius: 6px; +} + +.resize-modal-warning { + font-size: 0.9rem; + color: var(--color-text-secondary); + margin: 0 0 1.25rem 0; + line-height: 1.5; +} + +.resize-modal-recommend { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 1.5rem; + padding: 0.75rem; + background: rgba(34, 197, 94, 0.1); + border-radius: 8px; + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.recommend-label { + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.recommend-size { + font-family: var(--font-mono); + font-size: 1.1rem; + font-weight: 600; + color: #22c55e; +} + +.resize-modal-buttons { + display: flex; + gap: 0.75rem; + justify-content: center; +} + +.resize-btn { + padding: 0.75rem 1.5rem; + font-size: 0.95rem; + font-weight: 500; + border-radius: 8px; + border: none; + cursor: pointer; + transition: all 0.2s ease; + min-width: 140px; +} + +.resize-btn-primary { + background: linear-gradient(135deg, #8a63d2, #6b4db3); + color: white; + box-shadow: 0 4px 12px rgba(138, 99, 210, 0.3); +} + +.resize-btn-primary:hover { + background: linear-gradient(135deg, #9b74e3, #7c5ec4); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(138, 99, 210, 0.4); +} + +.resize-btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: var(--color-text-secondary); + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.resize-btn-secondary:hover { + background: rgba(255, 255, 255, 0.12); + color: var(--color-text-primary); + border-color: rgba(255, 255, 255, 0.25); } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index fa1df01..629e1fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -162,6 +162,23 @@ function App() { // --------------------------------------------------------------------------- const [isProcessing, setIsProcessing] = useState(false); + // --------------------------------------------------------------------------- + // State: Resize Modal + // --------------------------------------------------------------------------- + const [resizeModal, setResizeModal] = useState<{ + show: boolean; + originalSize: { width: number; height: number }; + newSize: { width: number; height: number }; + onConfirm: (() => void) | null; + onCancel: (() => void) | null; + }>({ + show: false, + originalSize: { width: 0, height: 0 }, + newSize: { width: 0, height: 0 }, + onConfirm: null, + onCancel: null, + }); + // --------------------------------------------------------------------------- // Refs // --------------------------------------------------------------------------- @@ -377,9 +394,9 @@ function App() { const img = new Image(); img.onload = () => { - const RECOMMENDED_MAX = 2000; // Recommended max dimension - const ABSOLUTE_MAX = 4000; // Hard limit - const MAX_PIXELS = 16000000; // 16 megapixels + const RECOMMENDED_MAX = 1024; // Recommended max dimension + const ABSOLUTE_MAX = 2048; // Hard limit + const MAX_PIXELS = 4000000; // 4 megapixels const totalPixels = img.width * img.height; const maxDimension = Math.max(img.width, img.height); @@ -432,20 +449,25 @@ function App() { const newWidth = Math.round(img.width * scale); const newHeight = Math.round(img.height * scale); - const shouldResize = window.confirm( - `⚠️ Imagem grande detectada (${img.width}x${img.height})\n\n` + - `Imagens grandes podem causar lentidão ou travamento.\n\n` + - `Clique OK para redimensionar para ${newWidth}x${newHeight} (recomendado)\n` + - `Clique Cancelar para tentar carregar o original` - ); - - if (shouldResize) { - setLoadingMessage(`Resizing to ${newWidth}x${newHeight}...`); - processImage(img, newWidth, newHeight); - } else { - // User chose to load original - proceed with warning - processImage(img, img.width, img.height); - } + // Show custom modal instead of browser confirm + setIsLoading(false); + setResizeModal({ + show: true, + originalSize: { width: img.width, height: img.height }, + newSize: { width: newWidth, height: newHeight }, + onConfirm: () => { + setResizeModal(prev => ({ ...prev, show: false })); + setIsLoading(true); + setLoadingMessage(`Redimensionando para ${newWidth}x${newHeight}...`); + processImage(img, newWidth, newHeight); + }, + onCancel: () => { + setResizeModal(prev => ({ ...prev, show: false })); + setIsLoading(true); + setLoadingMessage('Processando tamanho original...'); + processImage(img, img.width, img.height); + }, + }); } else { // Image is small enough, process normally processImage(img, img.width, img.height); @@ -693,6 +715,40 @@ function App() {
)} + {/* Resize Modal */} + {resizeModal.show && ( +
+
+
⚠️
+

Imagem Grande Detectada

+

+ {resizeModal.originalSize.width} × {resizeModal.originalSize.height} +

+

+ Imagens grandes podem causar lentidão ou travamento do navegador. +

+
+ Tamanho recomendado: + {resizeModal.newSize.width} × {resizeModal.newSize.height} +
+
+ + +
+
+
+ )} + {/* Hidden File Input */} Date: Wed, 24 Dec 2025 19:29:22 -0300 Subject: [PATCH 3/4] just cleaning some code --- src/App.tsx | 8 +------ src/hooks/index.ts | 2 +- src/utils/convolution.ts | 32 ++------------------------ src/utils/customFilters.ts | 46 +------------------------------------- 4 files changed, 5 insertions(+), 83 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 629e1fa..384824f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -189,14 +189,8 @@ function App() { // --------------------------------------------------------------------------- const { processImage: workerProcess, isReady: workerReady } = useImageWorker(); const [processedImage, setProcessedImage] = useState(null); - const [processingTime, setProcessingTime] = useState(null); + const [, setProcessingTime] = useState(null); - // Log processing time for debugging (can be used for UI later) - useEffect(() => { - if (processingTime !== null && processingTime > 0) { - console.debug(`[ImageVisLab] Filter processed in ${processingTime.toFixed(2)}ms`); - } - }, [processingTime]); /** * Applies the active filter to the original image using Web Worker. diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 64c6b9d..06d734e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,3 @@ export { useHistory } from './useHistory'; -export type { HistoryState } from './useHistory'; export { useImageWorker } from './useImageWorker'; + diff --git a/src/utils/convolution.ts b/src/utils/convolution.ts index 720fb08..a1fad87 100644 --- a/src/utils/convolution.ts +++ b/src/utils/convolution.ts @@ -15,31 +15,9 @@ /** * Collection of commonly used convolution kernels. + * Only includes kernels that are actively used by the application. */ export const KERNELS = { - /** 3x3 Box blur (mean filter) */ - boxBlur3: [ - [1 / 9, 1 / 9, 1 / 9], - [1 / 9, 1 / 9, 1 / 9], - [1 / 9, 1 / 9, 1 / 9], - ], - - /** 5x5 Box blur */ - boxBlur5: [ - [1 / 25, 1 / 25, 1 / 25, 1 / 25, 1 / 25], - [1 / 25, 1 / 25, 1 / 25, 1 / 25, 1 / 25], - [1 / 25, 1 / 25, 1 / 25, 1 / 25, 1 / 25], - [1 / 25, 1 / 25, 1 / 25, 1 / 25, 1 / 25], - [1 / 25, 1 / 25, 1 / 25, 1 / 25, 1 / 25], - ], - - /** 3x3 Gaussian blur (approximation) */ - gaussian3: [ - [1 / 16, 2 / 16, 1 / 16], - [2 / 16, 4 / 16, 2 / 16], - [1 / 16, 2 / 16, 1 / 16], - ], - /** Sharpening kernel */ sharpen: [ [0, -1, 0], @@ -53,15 +31,9 @@ export const KERNELS = { [1, -4, 1], [0, 1, 0], ], - - /** Laplacian with diagonals */ - laplacian8: [ - [1, 1, 1], - [1, -8, 1], - [1, 1, 1], - ], }; + // ============================================================================= // Kernel Generation // ============================================================================= diff --git a/src/utils/customFilters.ts b/src/utils/customFilters.ts index 480e2a0..16b3c1c 100644 --- a/src/utils/customFilters.ts +++ b/src/utils/customFilters.ts @@ -123,51 +123,6 @@ export function applyCustomFormula(imageData: ImageData, formula: string): Image return new ImageData(result, width, height); } -// ============================================================================= -// Custom Kernel -// ============================================================================= - -/** - * Generates an empty kernel of specified size. - * - * @param size - Kernel size (3, 5, or 7) - * @returns 2D array of zeros - */ -export function createEmptyKernel(size: number): number[][] { - return Array.from({ length: size }, () => Array(size).fill(0)); -} - -/** - * Common preset kernels for quick selection. - */ -export const PRESET_KERNELS = { - identity: [ - [0, 0, 0], - [0, 1, 0], - [0, 0, 0], - ], - sharpen: [ - [0, -1, 0], - [-1, 5, -1], - [0, -1, 0], - ], - edgeDetect: [ - [-1, -1, -1], - [-1, 8, -1], - [-1, -1, -1], - ], - emboss: [ - [-2, -1, 0], - [-1, 1, 1], - [0, 1, 2], - ], - blur: [ - [1 / 9, 1 / 9, 1 / 9], - [1 / 9, 1 / 9, 1 / 9], - [1 / 9, 1 / 9, 1 / 9], - ], -}; - /** * Applies a custom convolution kernel to an image. * @@ -178,3 +133,4 @@ export const PRESET_KERNELS = { export function applyCustomKernel(imageData: ImageData, kernel: number[][]): ImageData { return applyConvolution(imageData, kernel); } + From 4479c37ba5e6aeef8e1030cb01ed7f4528864b30 Mon Sep 17 00:00:00 2001 From: medeirosdev Date: Thu, 25 Dec 2025 00:20:19 -0300 Subject: [PATCH 4/4] feat: add new image filters and UI improvements --- .gitignore | 3 + index.html | 4 +- public/favicon.svg | 17 ++ src/App.tsx | 28 ++++ src/components/FormulaPanel/FormulaPanel.tsx | 82 ++++++++++ src/components/Sidebar/Sidebar.tsx | 67 +++++++- src/types/index.ts | 10 ++ src/utils/colorFilters.ts | 99 ++++++++++++ src/utils/edgeDetection.ts | 155 +++++++++++++++++++ src/utils/noiseReduction.ts | 72 +++++++++ src/workers/imageWorker.ts | 34 ++++ 11 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 public/favicon.svg create mode 100644 src/utils/colorFilters.ts create mode 100644 src/utils/edgeDetection.ts create mode 100644 src/utils/noiseReduction.ts diff --git a/.gitignore b/.gitignore index a547bf3..04eafe4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Personal notes +FEATURES.md diff --git a/index.html b/index.html index 6f7bfb4..ccfb92b 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - imagevislab + ImageVisLab - PDI Simulator
diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..9ee01db --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx index 384824f..abfb70e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,6 +41,17 @@ import { applyCustomFormula, applyCustomKernel, } from './utils/customFilters'; +import { + applySobelX, + applySobelY, + applySobelMagnitude, +} from './utils/edgeDetection'; +import { applyMedian } from './utils/noiseReduction'; +import { + applyGrayscale, + applySepia, + applySwapChannels, +} from './utils/colorFilters'; import './App.css'; // ============================================================================= @@ -109,6 +120,23 @@ function processImageSync( return applyCustomFormula(imageData, params.customFormula); case 'customKernel': return applyCustomKernel(imageData, params.customKernel); + // Edge Detection + case 'sobelX': + return applySobelX(imageData); + case 'sobelY': + return applySobelY(imageData); + case 'sobelMagnitude': + return applySobelMagnitude(imageData); + // Noise Reduction + case 'median': + return applyMedian(imageData, params.kernelSize); + // Color Filters + case 'grayscale': + return applyGrayscale(imageData); + case 'sepia': + return applySepia(imageData); + case 'swapChannels': + return applySwapChannels(imageData); case 'none': default: return imageData; diff --git a/src/components/FormulaPanel/FormulaPanel.tsx b/src/components/FormulaPanel/FormulaPanel.tsx index 9e88b0f..8a49399 100644 --- a/src/components/FormulaPanel/FormulaPanel.tsx +++ b/src/components/FormulaPanel/FormulaPanel.tsx @@ -240,6 +240,88 @@ const getFormulaInfo = ( ], }; + // Edge Detection + case 'sobelX': + return { + name: 'Sobel X (Vertical Edges)', + latex: 'G_x = \\begin{bmatrix} -1 & 0 & 1 \\\\ -2 & 0 & 2 \\\\ -1 & 0 & 1 \\end{bmatrix} * f', + description: 'Detects vertical edges using horizontal gradient. Highlights left-right transitions.', + variables: [ + { symbol: 'G_x', meaning: 'Horizontal gradient' }, + { symbol: 'f', meaning: 'Input image' }, + ], + }; + + case 'sobelY': + return { + name: 'Sobel Y (Horizontal Edges)', + latex: 'G_y = \\begin{bmatrix} -1 & -2 & -1 \\\\ 0 & 0 & 0 \\\\ 1 & 2 & 1 \\end{bmatrix} * f', + description: 'Detects horizontal edges using vertical gradient. Highlights top-bottom transitions.', + variables: [ + { symbol: 'G_y', meaning: 'Vertical gradient' }, + { symbol: 'f', meaning: 'Input image' }, + ], + }; + + case 'sobelMagnitude': + return { + name: 'Sobel Magnitude', + latex: 'G = \\sqrt{G_x^2 + G_y^2}', + description: 'Combines horizontal and vertical gradients to find all edges.', + variables: [ + { symbol: 'G', meaning: 'Edge magnitude' }, + { symbol: 'G_x', meaning: 'Horizontal gradient' }, + { symbol: 'G_y', meaning: 'Vertical gradient' }, + ], + }; + + // Noise Reduction + case 'median': + return { + name: 'Median Filter', + latex: 's = \\text{median}\\{f(x+i, y+j) | i,j \\in N\\}', + description: 'Replaces each pixel with the median of its neighborhood. Excellent for salt-and-pepper noise.', + variables: [ + { symbol: 's', meaning: 'Output pixel' }, + { symbol: 'N', meaning: 'Neighborhood', value: `${params.kernelSize}×${params.kernelSize}` }, + ], + }; + + // Color Filters + case 'grayscale': + return { + name: 'Grayscale Conversion', + latex: 'Y = 0.299R + 0.587G + 0.114B', + description: 'Converts to grayscale using luminance formula (ITU-R BT.601). Weights reflect human eye sensitivity.', + variables: [ + { symbol: 'Y', meaning: 'Luminance output' }, + { symbol: 'R, G, B', meaning: 'Color channels' }, + ], + }; + + case 'sepia': + return { + name: 'Sepia Tone', + latex: '\\begin{bmatrix} R\' \\\\ G\' \\\\ B\' \\end{bmatrix} = \\begin{bmatrix} 0.393 & 0.769 & 0.189 \\\\ 0.349 & 0.686 & 0.168 \\\\ 0.272 & 0.534 & 0.131 \\end{bmatrix} \\begin{bmatrix} R \\\\ G \\\\ B \\end{bmatrix}', + description: 'Applies a vintage brownish tone reminiscent of old photographs.', + variables: [ + { symbol: "R', G', B'", meaning: 'Output channels' }, + { symbol: 'R, G, B', meaning: 'Input channels' }, + ], + }; + + case 'swapChannels': + return { + name: 'RGB Channel Swap', + latex: 'R \\rightarrow G \\rightarrow B \\rightarrow R', + description: 'Rotates color channels creating a psychedelic color shift effect.', + variables: [ + { symbol: 'R → G', meaning: 'Red becomes Green' }, + { symbol: 'G → B', meaning: 'Green becomes Blue' }, + { symbol: 'B → R', meaning: 'Blue becomes Red' }, + ], + }; + case 'none': default: return { diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index 551eb74..d9d31ee 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -207,6 +207,69 @@ const FILTER_CATEGORIES: FilterCategory[] = [ }, ], }, + { + id: 'edge', + name: 'Edge Detection', + filters: [ + { + id: 'sobelX', + name: 'Sobel X', + description: 'Detects vertical edges', + formula: 'G_x = \\begin{bmatrix} -1 & 0 & 1 \\\\ -2 & 0 & 2 \\\\ -1 & 0 & 1 \\end{bmatrix}', + }, + { + id: 'sobelY', + name: 'Sobel Y', + description: 'Detects horizontal edges', + formula: 'G_y = \\begin{bmatrix} -1 & -2 & -1 \\\\ 0 & 0 & 0 \\\\ 1 & 2 & 1 \\end{bmatrix}', + }, + { + id: 'sobelMagnitude', + name: 'Sobel Magnitude', + description: 'Combined edge magnitude', + formula: 'G = \\sqrt{G_x^2 + G_y^2}', + }, + ], + }, + { + id: 'noise', + name: 'Noise Reduction', + filters: [ + { + id: 'median', + name: 'Median Filter', + description: 'Removes salt-and-pepper noise', + formula: 's = \\text{median}(N)', + params: [ + { key: 'kernelSize', label: 'Size', min: 3, max: 7, step: 2 }, + ], + }, + ], + }, + { + id: 'color', + name: 'Color Filters', + filters: [ + { + id: 'grayscale', + name: 'Grayscale', + description: 'Converts to black and white', + formula: 'Y = 0.299R + 0.587G + 0.114B', + }, + { + id: 'sepia', + name: 'Sepia', + description: 'Vintage brownish tone', + formula: '\\begin{bmatrix} 0.393 & 0.769 & 0.189 \\\\ 0.349 & 0.686 & 0.168 \\\\ 0.272 & 0.534 & 0.131 \\end{bmatrix}', + }, + { + id: 'swapChannels', + name: 'Swap RGB', + description: 'Rotates color channels', + formula: 'R \\rightarrow G \\rightarrow B \\rightarrow R', + }, + ], + }, { id: 'custom', name: 'Custom Operations', @@ -246,9 +309,9 @@ export const Sidebar: React.FC = ({ canRedo, hasImage, }) => { - // Track which categories are expanded + // Track which categories are expanded (all collapsed by default) const [expandedCategories, setExpandedCategories] = useState>( - new Set(['point', 'spatial']) + new Set() ); // Track About modal visibility diff --git a/src/types/index.ts b/src/types/index.ts index e216c7d..08f399f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -96,6 +96,16 @@ export type FilterType = | 'gaussianBlur' | 'sharpen' | 'laplacian' + // Edge Detection + | 'sobelX' + | 'sobelY' + | 'sobelMagnitude' + // Noise Reduction + | 'median' + // Color Filters + | 'grayscale' + | 'sepia' + | 'swapChannels' // Morphology Operations | 'threshold' | 'erosion' diff --git a/src/utils/colorFilters.ts b/src/utils/colorFilters.ts new file mode 100644 index 0000000..6c92458 --- /dev/null +++ b/src/utils/colorFilters.ts @@ -0,0 +1,99 @@ +/** + * ImageVisLab - Color Filters + * + * Color manipulation filters: grayscale, sepia, and channel swapping. + * + * @module colorFilters + * @author ImageVisLab Contributors + * @license MIT + */ + +// ============================================================================= +// Grayscale +// ============================================================================= + +/** + * Converts an image to grayscale using the luminance formula. + * Formula: gray = 0.299*R + 0.587*G + 0.114*B + * + * @param imageData - Source ImageData + * @returns Grayscale ImageData + */ +export function applyGrayscale(imageData: ImageData): ImageData { + const { width, height, data } = imageData; + const result = new Uint8ClampedArray(data.length); + + for (let i = 0; i < data.length; i += 4) { + const gray = Math.round( + 0.299 * data[i] + + 0.587 * data[i + 1] + + 0.114 * data[i + 2] + ); + + result[i] = gray; + result[i + 1] = gray; + result[i + 2] = gray; + result[i + 3] = data[i + 3]; + } + + return new ImageData(result, width, height); +} + +// ============================================================================= +// Sepia +// ============================================================================= + +/** + * Applies a sepia (vintage brownish) tone to an image. + * + * Transformation matrix: + * newR = 0.393*R + 0.769*G + 0.189*B + * newG = 0.349*R + 0.686*G + 0.168*B + * newB = 0.272*R + 0.534*G + 0.131*B + * + * @param imageData - Source ImageData + * @returns Sepia-toned ImageData + */ +export function applySepia(imageData: ImageData): ImageData { + const { width, height, data } = imageData; + const result = new Uint8ClampedArray(data.length); + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + result[i] = Math.min(255, Math.round(0.393 * r + 0.769 * g + 0.189 * b)); + result[i + 1] = Math.min(255, Math.round(0.349 * r + 0.686 * g + 0.168 * b)); + result[i + 2] = Math.min(255, Math.round(0.272 * r + 0.534 * g + 0.131 * b)); + result[i + 3] = data[i + 3]; + } + + return new ImageData(result, width, height); +} + +// ============================================================================= +// Channel Swapping +// ============================================================================= + +/** + * Swaps color channels: R→G, G→B, B→R + * Creates a psychedelic color shift effect. + * + * @param imageData - Source ImageData + * @returns ImageData with swapped channels + */ +export function applySwapChannels(imageData: ImageData): ImageData { + const { width, height, data } = imageData; + const result = new Uint8ClampedArray(data.length); + + for (let i = 0; i < data.length; i += 4) { + // R → G, G → B, B → R + result[i] = data[i + 2]; // New R = old B + result[i + 1] = data[i]; // New G = old R + result[i + 2] = data[i + 1]; // New B = old G + result[i + 3] = data[i + 3]; // Keep alpha + } + + return new ImageData(result, width, height); +} diff --git a/src/utils/edgeDetection.ts b/src/utils/edgeDetection.ts new file mode 100644 index 0000000..1b3198d --- /dev/null +++ b/src/utils/edgeDetection.ts @@ -0,0 +1,155 @@ +/** + * ImageVisLab - Edge Detection Filters + * + * Sobel operator implementation for edge detection. + * + * @module edgeDetection + * @author ImageVisLab Contributors + * @license MIT + */ + +// ============================================================================= +// Sobel Kernels +// ============================================================================= + +/** + * Sobel kernel for horizontal edges (detects vertical edges). + */ +const SOBEL_X = [ + [-1, 0, 1], + [-2, 0, 2], + [-1, 0, 1], +]; + +/** + * Sobel kernel for vertical edges (detects horizontal edges). + */ +const SOBEL_Y = [ + [-1, -2, -1], + [0, 0, 0], + [1, 2, 1], +]; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Converts RGB to grayscale using luminance formula. + */ +function toGray(r: number, g: number, b: number): number { + return 0.299 * r + 0.587 * g + 0.114 * b; +} + +/** + * Applies a 3x3 convolution at a specific pixel position. + */ +function convolve3x3( + data: Uint8ClampedArray, + width: number, + height: number, + x: number, + y: number, + kernel: number[][] +): number { + let sum = 0; + + for (let ky = 0; ky < 3; ky++) { + for (let kx = 0; kx < 3; kx++) { + const px = Math.max(0, Math.min(width - 1, x + kx - 1)); + const py = Math.max(0, Math.min(height - 1, y + ky - 1)); + const idx = (py * width + px) * 4; + + const gray = toGray(data[idx], data[idx + 1], data[idx + 2]); + sum += gray * kernel[ky][kx]; + } + } + + return sum; +} + +// ============================================================================= +// Sobel Filter Functions +// ============================================================================= + +/** + * Applies Sobel X filter (detects vertical edges). + * + * @param imageData - Source ImageData + * @returns Edge-detected ImageData showing vertical edges + */ +export function applySobelX(imageData: ImageData): ImageData { + const { width, height, data } = imageData; + const result = new Uint8ClampedArray(data.length); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const gx = convolve3x3(data, width, height, x, y, SOBEL_X); + const value = Math.min(255, Math.abs(gx)); + + const idx = (y * width + x) * 4; + result[idx] = value; + result[idx + 1] = value; + result[idx + 2] = value; + result[idx + 3] = data[idx + 3]; + } + } + + return new ImageData(result, width, height); +} + +/** + * Applies Sobel Y filter (detects horizontal edges). + * + * @param imageData - Source ImageData + * @returns Edge-detected ImageData showing horizontal edges + */ +export function applySobelY(imageData: ImageData): ImageData { + const { width, height, data } = imageData; + const result = new Uint8ClampedArray(data.length); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const gy = convolve3x3(data, width, height, x, y, SOBEL_Y); + const value = Math.min(255, Math.abs(gy)); + + const idx = (y * width + x) * 4; + result[idx] = value; + result[idx + 1] = value; + result[idx + 2] = value; + result[idx + 3] = data[idx + 3]; + } + } + + return new ImageData(result, width, height); +} + +/** + * Applies Sobel Magnitude filter (combines X and Y gradients). + * Formula: magnitude = sqrt(Gx² + Gy²) + * + * @param imageData - Source ImageData + * @returns Edge-detected ImageData with full edge magnitude + */ +export function applySobelMagnitude(imageData: ImageData): ImageData { + const { width, height, data } = imageData; + const result = new Uint8ClampedArray(data.length); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const gx = convolve3x3(data, width, height, x, y, SOBEL_X); + const gy = convolve3x3(data, width, height, x, y, SOBEL_Y); + + const magnitude = Math.sqrt(gx * gx + gy * gy); + const value = Math.min(255, magnitude); + + const idx = (y * width + x) * 4; + result[idx] = value; + result[idx + 1] = value; + result[idx + 2] = value; + result[idx + 3] = data[idx + 3]; + } + } + + return new ImageData(result, width, height); +} diff --git a/src/utils/noiseReduction.ts b/src/utils/noiseReduction.ts new file mode 100644 index 0000000..a03769c --- /dev/null +++ b/src/utils/noiseReduction.ts @@ -0,0 +1,72 @@ +/** + * ImageVisLab - Noise Reduction Filters + * + * Median filter implementation for noise removal. + * + * @module noiseReduction + * @author ImageVisLab Contributors + * @license MIT + */ + +// ============================================================================= +// Median Filter +// ============================================================================= + +/** + * Applies median filter to an image. + * Effective for removing salt-and-pepper noise while preserving edges. + * + * For each pixel: + * 1. Collect NxN neighborhood values + * 2. Sort values + * 3. Select the median (middle value) + * + * @param imageData - Source ImageData + * @param size - Kernel size (3, 5, or 7) + * @returns Filtered ImageData with noise reduced + */ +export function applyMedian(imageData: ImageData, size: number = 3): ImageData { + const { width, height, data } = imageData; + const result = new Uint8ClampedArray(data.length); + const halfSize = Math.floor(size / 2); + const neighborhoodSize = size * size; + const medianIndex = Math.floor(neighborhoodSize / 2); + + // Pre-allocate arrays for each channel + const rValues: number[] = new Array(neighborhoodSize); + const gValues: number[] = new Array(neighborhoodSize); + const bValues: number[] = new Array(neighborhoodSize); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let count = 0; + + // Collect neighborhood values + for (let ky = -halfSize; ky <= halfSize; ky++) { + for (let kx = -halfSize; kx <= halfSize; kx++) { + const px = Math.max(0, Math.min(width - 1, x + kx)); + const py = Math.max(0, Math.min(height - 1, y + ky)); + const idx = (py * width + px) * 4; + + rValues[count] = data[idx]; + gValues[count] = data[idx + 1]; + bValues[count] = data[idx + 2]; + count++; + } + } + + // Sort and get median for each channel + rValues.sort((a, b) => a - b); + gValues.sort((a, b) => a - b); + bValues.sort((a, b) => a - b); + + const outIdx = (y * width + x) * 4; + result[outIdx] = rValues[medianIndex]; + result[outIdx + 1] = gValues[medianIndex]; + result[outIdx + 2] = bValues[medianIndex]; + result[outIdx + 3] = data[outIdx + 3]; + } + } + + return new ImageData(result, width, height); +} diff --git a/src/workers/imageWorker.ts b/src/workers/imageWorker.ts index 588ce27..21fb995 100644 --- a/src/workers/imageWorker.ts +++ b/src/workers/imageWorker.ts @@ -39,6 +39,20 @@ import { applyCustomKernel, } from '../utils/customFilters'; +import { + applySobelX, + applySobelY, + applySobelMagnitude, +} from '../utils/edgeDetection'; + +import { applyMedian } from '../utils/noiseReduction'; + +import { + applyGrayscale, + applySepia, + applySwapChannels, +} from '../utils/colorFilters'; + import type { FilterType, FilterParams } from '../types'; // ============================================================================= @@ -113,6 +127,26 @@ function processImage( case 'customKernel': return applyCustomKernel(imageData, params.customKernel); + // Edge Detection + case 'sobelX': + return applySobelX(imageData); + case 'sobelY': + return applySobelY(imageData); + case 'sobelMagnitude': + return applySobelMagnitude(imageData); + + // Noise Reduction + case 'median': + return applyMedian(imageData, params.kernelSize); + + // Color Filters + case 'grayscale': + return applyGrayscale(imageData); + case 'sepia': + return applySepia(imageData); + case 'swapChannels': + return applySwapChannels(imageData); + // No filter case 'none': default: