import { useEffect, useState, useRef } from "react"; import marioImage from "./assets/mario.png"; import racoonImage from "./assets/racoon.png"; import cameramanImage from "./assets/cameraman.png"; import mandrillImage from "./assets/mandrill.png"; import motocrossImage from "./assets/motocross.png"; import peppersImage from "./assets/peppers.png"; import useConvolutionProcessing from "./useConvolutionProcessing.ts"; import type { ConvolutionRange } from "./useConvolutionProcessing.ts"; import { Card, Dropdown, Radio } from "@elvis/ui"; import { DEFAULT_KERNEL, KERNEL_PRESETS } from "./kernels.ts"; import type { Kernel } from "./kernels.ts"; const DEFAULT_IMAGE: string = mandrillImage; const MIN_KERNEL_SIZE = 1; const MAX_KERNEL_SIZE = 20; const CUSTOM_2D_OPTION = "Custom2D..."; const CUSTOM_3D_OPTION = "Custom3D..."; export default function ConvolutionVisualizer() { const [rawInputImage, setRawInputImage] = useState(DEFAULT_IMAGE); const [useColor, setUseColor] = useState(true); const [kernel, setKernel] = useState(DEFAULT_KERNEL); const [stride, setStride] = useState(1); const [dilation, setDilation] = useState(1); const [padding, setPadding] = useState(0); const [inputImage, outputImage, outputRange] = useConvolutionProcessing( rawInputImage, kernel, useColor, stride, dilation, padding, ); return (
); } type InputOutputViewerProps = { input: string | null; output: string | null; outputRange: ConvolutionRange | null; } function InputOutputViewer({ input, output, outputRange }: InputOutputViewerProps) { const outputImageRef = useRef(null); const [outputDisplayWidth, setOutputDisplayWidth] = useState(null); useEffect(() => { const image = outputImageRef.current; if (!image) return; function updateColorBarWidth() { if (!image || image.naturalWidth === 0 || image.naturalHeight === 0) return; const scale = Math.min( image.clientWidth / image.naturalWidth, image.clientHeight / image.naturalHeight, ); setOutputDisplayWidth(image.naturalWidth * scale); } updateColorBarWidth(); const observer = new ResizeObserver(updateColorBarWidth); observer.observe(image); return () => observer.disconnect(); }, [output]); function formatScaleValue(value: number): string { if (Number.isInteger(value)) return value.toString(); return Number(value.toPrecision(4)).toString(); } return (

Input

{input ? ( Input ) : (

Loading...

)}

Output

{output ? ( <>
Output { const image = outputImageRef.current; if (!image || image.naturalWidth === 0 || image.naturalHeight === 0) return; const scale = Math.min( image.clientWidth / image.naturalWidth, image.clientHeight / image.naturalHeight, ); setOutputDisplayWidth(image.naturalWidth * scale); }} />
{outputRange ? formatScaleValue(outputRange.min) : ""} {outputRange ? formatScaleValue(outputRange.max) : ""}
) : (

Processing...

)}
); } type ImageControlsProps = { setImage: (imageUrl: string) => void; useColor: boolean; setUseColor: (useColor: boolean) => void; } function ImageControls({ setImage, useColor, setUseColor }: ImageControlsProps) { const imageFileInputRef = useRef(null); const imageOptions = ["Upload...", "Enter URL...", "Mandrill", "Cameraman", "Motocross", "Peppers", "Super Mario", "Racoon"] as const; const [selectedImage, setSelectedImage] = useState<(typeof imageOptions)[number]>("Mandrill"); const [imageUrl, setImageUrl] = useState(""); function loadImageUrl() { const trimmedUrl = imageUrl.trim(); if (trimmedUrl) setImage(trimmedUrl); } function handleImageChange(option: (typeof imageOptions)[number]) { if (option === "Upload...") { imageFileInputRef.current?.click(); return; } if (option === "Enter URL...") { setSelectedImage(option); return; } setSelectedImage(option); const presetImages = { Mandrill: mandrillImage, Cameraman: cameramanImage, Motocross: motocrossImage, Peppers: peppersImage, "Super Mario": marioImage, Racoon: racoonImage, }; setImage(presetImages[option]); } return (
{ const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { const result = reader.result; if (typeof result !== "string") { return; } setImage(result); }; reader.readAsDataURL(file); e.target.value = ""; }} style={{ display: "none" }} />
Image
{selectedImage === "Enter URL..." && (
)}
Color
setUseColor(option === "Color")} />
); } type KernelEditorProps = { kernel: Kernel; setKernel: (kernel: Kernel) => void; stride: number; setStride: (value: number) => void; dilation: number; setDilation: (value: number) => void; padding: number; setPadding: (value: number) => void; } function KernelEditor({ kernel, setKernel, stride, setStride, dilation, setDilation, padding, setPadding, }: KernelEditorProps) { const presetEntries = Object.entries(KERNEL_PRESETS); const presetGroups = [ { label: "2D kernels", options: [ CUSTOM_2D_OPTION, ...presetEntries .filter(([, preset]) => !Array.isArray(preset[0][0])) .map(([name]) => name), ], }, { label: "3D kernels", options: [ CUSTOM_3D_OPTION, ...presetEntries .filter(([, preset]) => Array.isArray(preset[0][0])) .map(([name]) => name), ], }, ]; const [selectedChannel, setSelectedChannel] = useState(0); const [selectedPreset, setSelectedPreset] = useState("Laplacian2D"); function colorKernelToRepr(nextKernel: number[][][]): string[][][] { return nextKernel.map((channel) => channel.map((row) => row.map((value) => value.toString())), ); } function grayKernelToRepr(nextKernel: number[][]): string[][] { return nextKernel.map((row) => row.map((value) => value.toString())); } const [draftColorKernel, setDraftColorKernel] = useState( colorKernelToRepr(KERNEL_PRESETS.Laplacian3D), ); const [draftGrayKernel, setDraftGrayKernel] = useState( grayKernelToRepr(KERNEL_PRESETS.Laplacian2D), ); useEffect(() => { if (Array.isArray(kernel[0][0])) { setDraftColorKernel(colorKernelToRepr(kernel as number[][][])); } else { setDraftGrayKernel(grayKernelToRepr(kernel as number[][])); } }, [kernel]); function parseCell(cell: string): number | null { const trimmed = cell.trim(); if (trimmed === "") return null; const parsed = Number(trimmed); return Number.isFinite(parsed) ? parsed : null; } function cloneColorKernel(nextKernel: number[][][]): number[][][] { return nextKernel.map((channel) => channel.map((row) => [...row])); } function cloneGrayKernel(nextKernel: number[][]): number[][] { return nextKernel.map((row) => [...row]); } function handlePresetChange(option: string) { setSelectedPreset(option); const sourceKernel = option === CUSTOM_2D_OPTION ? KERNEL_PRESETS.Laplacian2D : option === CUSTOM_3D_OPTION ? KERNEL_PRESETS.Laplacian3D : KERNEL_PRESETS[option as keyof typeof KERNEL_PRESETS]; if (Array.isArray(sourceKernel[0][0])) { const nextKernel = cloneColorKernel(sourceKernel as number[][][]); setDraftColorKernel(colorKernelToRepr(nextKernel)); setKernel(nextKernel); setSelectedChannel(0); } else { const nextKernel = cloneGrayKernel(sourceKernel as number[][]); setDraftGrayKernel(grayKernelToRepr(nextKernel)); setKernel(nextKernel); } } function handleKernelChange(channel: number, row: number, col: number, value: string) { if (Array.isArray(kernel[0][0])) { setSelectedPreset(CUSTOM_3D_OPTION); const nextDraft = draftColorKernel.map((c) => c.map((r) => [...r])); nextDraft[channel][row][col] = value; setDraftColorKernel(nextDraft); const parsedKernel = nextDraft.map((c) => c.map((r) => r.map(parseCell))); const isValid = parsedKernel.every((c) => c.every((r) => r.every((v) => v !== null))); if (isValid) { setKernel(parsedKernel as number[][][]); } return; } setSelectedPreset(CUSTOM_2D_OPTION); const nextDraft = draftGrayKernel.map((r) => [...r]); nextDraft[row][col] = value; setDraftGrayKernel(nextDraft); const parsedKernel = nextDraft.map((r) => r.map(parseCell)); const isValid = parsedKernel.every((r) => r.every((v) => v !== null)); if (isValid) { setKernel(parsedKernel as number[][]); } } function handleSizeChange(newWidth: number, newHeight: number) { const clampedWidth = Math.round(Math.min(MAX_KERNEL_SIZE, Math.max(MIN_KERNEL_SIZE, newWidth))); const clampedHeight = Math.round(Math.min(MAX_KERNEL_SIZE, Math.max(MIN_KERNEL_SIZE, newHeight))); function resizeMatrix(matrix: string[][], width: number, height: number): string[][] { return Array.from({ length: height }, (_, rowIndex) => Array.from({ length: width }, (_, colIndex) => matrix[rowIndex]?.[colIndex] ?? "0"), ); } if (Array.isArray(kernel[0][0])) { setSelectedPreset(CUSTOM_3D_OPTION); const resizedDraft = draftColorKernel.map((channelMatrix) => resizeMatrix(channelMatrix, clampedWidth, clampedHeight), ); setDraftColorKernel(resizedDraft); setKernel( resizedDraft.map((channelMatrix) => channelMatrix.map((row) => row.map((cell) => parseCell(cell) ?? 0)), ), ); return; } setSelectedPreset(CUSTOM_2D_OPTION); const resizedDraft = resizeMatrix(draftGrayKernel, clampedWidth, clampedHeight); setDraftGrayKernel(resizedDraft); setKernel(resizedDraft.map((row) => row.map((cell) => parseCell(cell) ?? 0))); } const channelLabels = ["R", "G", "B"]; const is3DKernel = Array.isArray(kernel[0][0]); const activeMatrix = is3DKernel ? draftColorKernel[selectedChannel] : draftGrayKernel; const currentHeight = activeMatrix.length; const currentWidth = activeMatrix[0]?.length ?? 0; const isCustomKernel = selectedPreset === CUSTOM_2D_OPTION || selectedPreset === CUSTOM_3D_OPTION; return (
Kernel
{ is3DKernel && (
Channel
{channelLabels.map((label, index) => ( ))}
)}
Size
{ if (Number.isFinite(event.currentTarget.valueAsNumber)) { handleSizeChange(event.currentTarget.valueAsNumber, currentHeight); } }} /> × { if (Number.isFinite(event.currentTarget.valueAsNumber)) { handleSizeChange(currentWidth, event.currentTarget.valueAsNumber); } }} />
{activeMatrix.map((row, rowIndex) => (
{row.map((cellValue, colIndex) => ( handleKernelChange( is3DKernel ? selectedChannel : 0, rowIndex, colIndex, event.target.value, ) } /> ))}
))}
); } function NumericOption({ label, value, min, onChange }: { label: string; value: number; min: number; onChange: (value: number) => void }) { return (
{label} { const nextValue = event.currentTarget.valueAsNumber; if (Number.isFinite(nextValue)) { onChange(Math.round(Math.min(MAX_KERNEL_SIZE, Math.max(min, nextValue)))); } }} />
); }