Download src/app/page.tsx from TechAvenger/kernel-convolution-visualizer: direct link, hf CLI and curl.
- Browser
- Download file 22.5 kB
-
https://huggingface.co/spaces/TechAvenger/kernel-convolution-visualizer/resolve/main/src/app/page.tsx
- Command line
-
hf download hf://spaces/TechAvenger/kernel-convolution-visualizer/src/app/page.tsx
-
curl -L -o page.tsx https://huggingface.co/spaces/TechAvenger/kernel-convolution-visualizer/resolve/main/src/app/page.tsx
22.5 kB
| 'use client' | |
| import { useState, useCallback, useEffect } from 'react' | |
| import { useAppStore, useImageStore, useUIStore } from '@/store' | |
| import { KernelSelector } from '@/components/kernel/KernelSelector' | |
| import { PlaybackControls } from '@/components/controls/PlaybackControls' | |
| import { OverlayControls, PaddingControls, KernelInfoPanel } from '@/components/controls/OverlayControls' | |
| import { ConvolutionCanvas } from '@/components/canvas/ConvolutionCanvas' | |
| import { StaticConvolutionView } from '@/components/canvas/StaticConvolutionView' | |
| import { SplitView } from '@/components/canvas/SplitView' | |
| import { ImageCanvas } from '@/components/canvas/ImageCanvas' | |
| import { Button, Card, CardContent, Badge } from '@/components/ui' | |
| import { useImageLoader, useDragAndDrop } from '@/hooks/useImageLoader' | |
| import { useConvolution } from '@/hooks/useConvolution' | |
| import { cn, formatNumber } from '@/utils/helpers' | |
| import { | |
| Upload, | |
| Image as ImageIcon, | |
| Download, | |
| RotateCcw, | |
| HelpCircle, | |
| Settings, | |
| ChevronLeft, | |
| ChevronRight, | |
| Keyboard, | |
| } from 'lucide-react' | |
| export default function Home() { | |
| const { | |
| originalImageData, | |
| processedImageData, | |
| selectedKernel, | |
| convolution, | |
| settings, | |
| isLoading, | |
| error, | |
| showHelp, | |
| showSettings, | |
| setOriginalImage, | |
| setError, | |
| toggleHelp, | |
| toggleSettings, | |
| resetConvolution, | |
| } = useAppStore() | |
| const { runConvolution, pause, resume, stop, step, reset, isRunning, isPaused, canRun, toggleStepMode } = useConvolution() | |
| const { fileInputRef, openFileDialog, handleFileSelect } = useImageLoader() | |
| const { handleDragOver, handleDragLeave, handleDrop, isDragging } = useDragAndDrop((image, imageData) => { | |
| setOriginalImage(image, imageData) | |
| resetConvolution() | |
| }) | |
| const [showComparison, setShowComparison] = useState(false) | |
| const [sidebarWidth, setSidebarWidth] = useState(320) | |
| const [inspectedPosition, setInspectedPosition] = useState<{ x: number; y: number } | null>(null) | |
| const [showShortcuts, setShowShortcuts] = useState(false) | |
| const handleInspectClick = useCallback((x: number, y: number) => { | |
| setInspectedPosition({ x, y }) | |
| }, []) | |
| // Keyboard shortcuts | |
| useEffect(() => { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| // Don't trigger shortcuts when typing in inputs | |
| if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { | |
| return | |
| } | |
| switch (e.code) { | |
| case 'Space': | |
| e.preventDefault() | |
| if (isRunning) pause() | |
| else if (canRun) runConvolution() | |
| else if (isPaused) resume() | |
| break | |
| case 'ArrowRight': | |
| e.preventDefault() | |
| step() | |
| break | |
| case 'ArrowLeft': | |
| e.preventDefault() | |
| // Step back not implemented yet | |
| break | |
| case 'Equal': | |
| case 'NumpadAdd': | |
| if (e.ctrlKey || e.metaKey) { | |
| e.preventDefault() | |
| const newZoom = Math.min(20, settings.zoomLevel * 1.2) | |
| useAppStore.getState().setZoomLevel(newZoom) | |
| } | |
| break | |
| case 'Minus': | |
| case 'NumpadSubtract': | |
| if (e.ctrlKey || e.metaKey) { | |
| e.preventDefault() | |
| const newZoom = Math.max(0.1, settings.zoomLevel / 1.2) | |
| useAppStore.getState().setZoomLevel(newZoom) | |
| } | |
| break | |
| case 'Digit0': | |
| if (e.ctrlKey || e.metaKey) { | |
| e.preventDefault() | |
| useAppStore.getState().setZoomLevel(1) | |
| useAppStore.getState().setPanOffset({ x: 0, y: 0 }) | |
| } | |
| break | |
| case 'KeyR': | |
| if (e.ctrlKey || e.metaKey) { | |
| e.preventDefault() | |
| reset() | |
| resetConvolution() | |
| } | |
| break | |
| case 'KeyS': | |
| if (e.ctrlKey || e.metaKey) { | |
| e.preventDefault() | |
| toggleStepMode(!settings.stepMode) | |
| } | |
| break | |
| case 'Slash': | |
| if (e.shiftKey) { | |
| e.preventDefault() | |
| setShowShortcuts(!showShortcuts) | |
| } | |
| break | |
| } | |
| } | |
| window.addEventListener('keydown', handleKeyDown) | |
| return () => window.removeEventListener('keydown', handleKeyDown) | |
| }, [isRunning, isPaused, canRun, settings.zoomLevel, settings.stepMode, runConvolution, pause, resume, step, reset, resetConvolution, toggleStepMode]) | |
| const handleImageLoad = useCallback((data: { image: HTMLImageElement; imageData: ImageData }) => { | |
| setOriginalImage(data.image, data.imageData) | |
| resetConvolution() | |
| }, [setOriginalImage, resetConvolution]) | |
| const handleDownload = useCallback(() => { | |
| if (!processedImageData) return | |
| const canvas = document.createElement('canvas') | |
| canvas.width = processedImageData.width | |
| canvas.height = processedImageData.height | |
| const ctx = canvas.getContext('2d') | |
| if (!ctx) return | |
| ctx.putImageData(processedImageData, 0, 0) | |
| const link = document.createElement('a') | |
| link.download = 'convolved-image.png' | |
| link.href = canvas.toDataURL('image/png') | |
| link.click() | |
| }, [processedImageData]) | |
| const handleReset = useCallback(() => { | |
| reset() | |
| resetConvolution() | |
| }, [reset, resetConvolution]) | |
| if (isLoading) { | |
| return ( | |
| <div className="fixed inset-0 flex items-center justify-center bg-white dark:bg-gray-900 z-50"> | |
| <div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent" /> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div | |
| className="h-screen w-screen flex flex-col bg-gray-50 dark:bg-gray-900 overflow-hidden" | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| > | |
| {/* Header */} | |
| <header className="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border-b dark:border-gray-700 z-10"> | |
| <div className="flex items-center gap-4"> | |
| <h1 className="text-xl font-bold text-gray-900 dark:text-white"> | |
| Kernel Convolution Visualizer | |
| </h1> | |
| <Badge variant="outline" className="text-xs"> | |
| Educational Tool | |
| </Badge> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Button variant="ghost" size="icon" onClick={toggleHelp} className="relative"> | |
| <HelpCircle className="h-5 w-5" /> | |
| </Button> | |
| <Button variant="ghost" size="icon" onClick={toggleSettings}> | |
| <Settings className="h-5 w-5" /> | |
| </Button> | |
| </div> | |
| </header> | |
| {/* Main Content */} | |
| <main className="flex-1 flex overflow-hidden"> | |
| {/* Left Sidebar - Controls */} | |
| <aside | |
| className={cn( | |
| 'flex flex-col bg-white dark:bg-gray-800 border-r dark:border-gray-700 overflow-y-auto transition-all duration-200', | |
| 'min-w-[280px] max-w-[400px]' | |
| )} | |
| style={{ width: sidebarWidth }} | |
| > | |
| {/* Image Upload */} | |
| <Card className="m-3"> | |
| <CardContent className="p-4"> | |
| <div | |
| className={cn( | |
| 'relative border-2 dashed rounded-lg p-6 text-center transition-colors', | |
| 'cursor-pointer', | |
| isDragging | |
| ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' | |
| : 'border-gray-300 dark:border-gray-600 hover:border-blue-400' | |
| )} | |
| onClick={openFileDialog} | |
| > | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="image/*" | |
| className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" | |
| onChange={(e) => handleFileSelect(e, handleImageLoad)} | |
| /> | |
| <ImageIcon className="h-12 w-12 mx-auto text-gray-400 mb-3" /> | |
| <p className="text-gray-600 dark:text-gray-400"> | |
| {originalImageData ? 'Click to change image' : 'Drag & drop or click to upload'} | |
| </p> | |
| <p className="text-sm text-gray-400 mt-1">PNG, JPG, WebP, BMP</p> | |
| </div> | |
| {originalImageData && ( | |
| <div className="mt-3 flex items-center justify-between text-sm"> | |
| <span className="text-gray-600 dark:text-gray-400"> | |
| {originalImageData.width} × {originalImageData.height} | |
| </span> | |
| <div className="flex gap-2"> | |
| <Button variant="outline" size="sm" onClick={handleDownload}> | |
| <Download className="h-4 w-4" /> | |
| Save | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={handleReset}> | |
| <RotateCcw className="h-4 w-4" /> | |
| Reset | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {/* Kernel Selector */} | |
| <div className="flex-1 m-3 min-h-0"> | |
| <KernelSelector /> | |
| </div> | |
| {/* Playback Controls */} | |
| <div className="m-3"> | |
| <PlaybackControls /> | |
| </div> | |
| </aside> | |
| {/* Resizer */} | |
| <div | |
| className="w-1 cursor-col-resize bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 active:bg-blue-600 flex items-center justify-center" | |
| onMouseDown={(e) => { | |
| const startX = e.clientX | |
| const startWidth = sidebarWidth | |
| const onMouseMove = (e: MouseEvent) => { | |
| setSidebarWidth(Math.max(280, Math.min(400, startWidth + (e.clientX - startX)))) | |
| } | |
| const onMouseUp = () => { | |
| document.removeEventListener('mousemove', onMouseMove) | |
| document.removeEventListener('mouseup', onMouseUp) | |
| } | |
| document.addEventListener('mousemove', onMouseMove) | |
| document.addEventListener('mouseup', onMouseUp) | |
| }} | |
| > | |
| <div className="w-px h-8 bg-gray-300 dark:bg-gray-600 rounded" /> | |
| </div> | |
| {/* Center - Visualization */} | |
| <section className="flex-1 flex flex-col overflow-hidden p-4 min-w-0"> | |
| {originalImageData ? ( | |
| <> | |
| {/* Convolution Visualization */} | |
| <div className="flex-1 min-h-0 relative"> | |
| <ConvolutionCanvas | |
| width={originalImageData.width} | |
| height={originalImageData.height} | |
| className="w-full h-full" | |
| onInspectClick={handleInspectClick} | |
| /> | |
| </div> | |
| {/* Comparison View Toggle */} | |
| <div className="flex items-center justify-center gap-4 mt-4"> | |
| <Button | |
| variant={showComparison ? 'primary' : 'outline'} | |
| onClick={() => setShowComparison(!showComparison)} | |
| > | |
| {showComparison ? <ChevronLeft className="h-4 w-4 mr-2" /> : <ChevronRight className="h-4 w-4 mr-2" />} | |
| {showComparison ? 'Hide Comparison' : 'Show Comparison'} | |
| </Button> | |
| {selectedKernel && ( | |
| <Badge variant="secondary"> | |
| {selectedKernel.name} ({selectedKernel.size}×{selectedKernel.size}) | |
| </Badge> | |
| )} | |
| {convolution.status !== 'idle' && ( | |
| <Badge variant={convolution.status === 'completed' ? 'success' : 'default'}> | |
| {convolution.status} | |
| </Badge> | |
| )} | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg"> | |
| <div className="text-center text-gray-500"> | |
| <ImageIcon className="h-16 w-16 mx-auto mb-4 opacity-50" /> | |
| <p className="text-lg">Upload an image to begin</p> | |
| <p className="text-sm mt-2">Drag & drop or click the upload area</p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Comparison View */} | |
| {showComparison && processedImageData && ( | |
| <div className="mt-4 h-96 flex-1"> | |
| <SplitView | |
| width={originalImageData?.width || 800} | |
| height={originalImageData?.height || 600} | |
| className="w-full h-full" | |
| /> | |
| </div> | |
| )} | |
| </section> | |
| {/* Right Sidebar - Info Panels */} | |
| <aside className="w-80 flex flex-col bg-white dark:bg-gray-800 border-l dark:border-gray-700 overflow-y-auto"> | |
| <div className="p-4 space-y-4"> | |
| {inspectedPosition ? ( | |
| <StaticConvolutionView | |
| inspectedPosition={inspectedPosition} | |
| onClearInspection={() => setInspectedPosition(null)} | |
| /> | |
| ) : ( | |
| <> | |
| <KernelInfoPanel /> | |
| <OverlayControls /> | |
| <PaddingControls /> | |
| </> | |
| )} | |
| </div> | |
| </aside> | |
| </main> | |
| {/* Error Toast */} | |
| {error && ( | |
| <div className="fixed bottom-4 right-4 z-50 animate-slide-in"> | |
| <div className="bg-red-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 max-w-md"> | |
| <span className="flex-1 text-sm">{error}</span> | |
| <Button variant="ghost" size="sm" onClick={() => setError(null)}> | |
| Dismiss | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Help Modal */} | |
| {showHelp && ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> | |
| <div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"> | |
| <div className="p-6"> | |
| <h2 className="text-xl font-bold mb-4">How to Use</h2> | |
| <div className="space-y-4 text-sm text-gray-600 dark:text-gray-400"> | |
| <div> | |
| <h3 className="font-medium mb-2">1. Upload an Image</h3> | |
| <p>Drag & drop or click the upload area in the left sidebar.</p> | |
| </div> | |
| <div> | |
| <h3 className="font-medium mb-2">2. Select a Kernel</h3> | |
| <p>Choose from predefined kernels (Blur, Sharpen, Edge Detection, etc.) or create your own custom kernel.</p> | |
| </div> | |
| <div> | |
| <h3 className="font-medium mb-2">3. Watch the Convolution</h3> | |
| <p>Press Play to see the kernel move across the image pixel by pixel. The visualization shows:</p> | |
| <ul className="list-disc list-inside mt-1 space-y-1"> | |
| <li>Current image patch under the kernel</li> | |
| <li>Element-wise multiplication of kernel and pixel values</li> | |
| <li>Summation that produces the output pixel</li> | |
| </ul> | |
| </div> | |
| <div> | |
| <h3 className="font-medium mb-2">4. Control Playback</h3> | |
| <ul className="list-disc list-inside mt-1 space-y-1"> | |
| <li>Play/Pause/Step through the animation</li> | |
| <li>Adjust animation speed</li> | |
| <li>Zoom and pan for pixel-level inspection</li> | |
| </ul> | |
| </div> | |
| <div> | |
| <h3 className="font-medium mb-2">5. Compare Results</h3> | |
| <p>Click “Show Comparison” to see side-by-side or split view of original vs processed image.</p> | |
| </div> | |
| <div> | |
| <h3 className="font-medium mb-2">6. Experiment</h3> | |
| <ul className="list-disc list-inside mt-1 space-y-1"> | |
| <li>Try different padding strategies (Zero, Reflect, Replicate, Wrap)</li> | |
| <li>Toggle overlays to see different visualization aspects</li> | |
| <li>Create custom kernels with the editor</li> | |
| <li>Download the processed image</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div className="mt-6 flex justify-end"> | |
| <Button onClick={toggleHelp}>Got it</Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Settings Modal */} | |
| {showSettings && ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> | |
| <div className="bg-white dark:bg-gray-800 rounded-lg max-w-md w-full mx-4"> | |
| <div className="p-6"> | |
| <h2 className="text-xl font-bold mb-4">Settings</h2> | |
| <div className="space-y-4"> | |
| <label className="flex items-center gap-3 cursor-pointer"> | |
| <input | |
| type="checkbox" | |
| checked={settings.showOverlay.kernelBoundary} | |
| onChange={() => useAppStore.getState().toggleOverlay('kernelBoundary')} | |
| className="rounded border-gray-300" | |
| /> | |
| <span>Show Kernel Boundary</span> | |
| </label> | |
| <label className="flex items-center gap-3 cursor-pointer"> | |
| <input | |
| type="checkbox" | |
| checked={settings.showOverlay.pixelValues} | |
| onChange={() => useAppStore.getState().toggleOverlay('pixelValues')} | |
| className="rounded border-gray-300" | |
| /> | |
| <span>Show Pixel Values</span> | |
| </label> | |
| <label className="flex items-center gap-3 cursor-pointer"> | |
| <input | |
| type="checkbox" | |
| checked={settings.showOverlay.multiplications} | |
| onChange={() => useAppStore.getState().toggleOverlay('multiplications')} | |
| className="rounded border-gray-300" | |
| /> | |
| <span>Show Multiplications</span> | |
| </label> | |
| <label className="flex items-center gap-3 cursor-pointer"> | |
| <input | |
| type="checkbox" | |
| checked={settings.showOverlay.summation} | |
| onChange={() => useAppStore.getState().toggleOverlay('summation')} | |
| className="rounded border-gray-300" | |
| /> | |
| <span>Show Summation</span> | |
| </label> | |
| </div> | |
| <div className="mt-6 flex justify-end"> | |
| <Button onClick={toggleSettings}>Close</Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Shortcuts Modal */} | |
| {showShortcuts && ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> | |
| <div className="bg-white dark:bg-gray-800 rounded-lg max-w-md w-full mx-4 max-h-[80vh] overflow-y-auto"> | |
| <div className="p-6"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <h2 className="text-xl font-bold flex items-center gap-2"> | |
| <Keyboard className="h-5 w-5" /> | |
| Keyboard Shortcuts | |
| </h2> | |
| <Button variant="ghost" size="icon" onClick={() => setShowShortcuts(false)}> | |
| <ChevronLeft className="h-5 w-5" /> | |
| </Button> | |
| </div> | |
| <div className="space-y-3 text-sm text-gray-600 dark:text-gray-400"> | |
| <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded"> | |
| <h3 className="font-medium mb-2">Playback</h3> | |
| <dl className="grid grid-cols-2 gap-2 text-xs"> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">Space</dt> | |
| <dd>Play / Pause / Resume</dd> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">→</dt> | |
| <dd>Step Forward</dd> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">←</dt> | |
| <dd>Step Backward (future)</dd> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">Ctrl+S</dt> | |
| <dd>Toggle Step Mode</dd> | |
| </dl> | |
| </div> | |
| <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded"> | |
| <h3 className="font-medium mb-2">Zoom & Pan</h3> | |
| <dl className="grid grid-cols-2 gap-2 text-xs"> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">Ctrl++ / =</dt> | |
| <dd>Zoom In</dd> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">Ctrl+-</dt> | |
| <dd>Zoom Out</dd> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">Ctrl+0</dt> | |
| <dd>Reset Zoom & Pan</dd> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">Mouse Wheel</dt> | |
| <dd>Zoom at Cursor</dd> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">Drag</dt> | |
| <dd>Pan Image</dd> | |
| </dl> | |
| </div> | |
| <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded"> | |
| <h3 className="font-medium mb-2">General</h3> | |
| <dl className="grid grid-cols-2 gap-2 text-xs"> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">Ctrl+R</dt> | |
| <dd>Reset All</dd> | |
| <dt className="font-mono text-blue-600 dark:text-blue-400">Shift+/</dt> | |
| <dd>Show This Help</dd> | |
| </dl> | |
| </div> | |
| <div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded"> | |
| <p className="text-xs text-blue-800 dark:text-blue-300"> | |
| <strong>Tip:</strong> Shortcuts don't work when typing in input fields. Click canvas to focus. | |
| </p> | |
| </div> | |
| </div> | |
| <div className="mt-6 flex justify-end"> | |
| <Button onClick={() => setShowShortcuts(false)}>Got it</Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } |