cnn_visualizer / src /ConvolutionVisualizer.tsx
Joel Woodfield
Add styling to convolution visualizer
c507f8c
import { useEffect, useState, useRef } from "react";
import marioImage from "./assets/mario.png";
import useConvolutionProcessing from "./useConvolutionProcessing.ts";
import { Button, Card, Dropdown, Radio } from "@elvis/ui";
import {
DEFAULT_COLOR_KERNEL,
DEFAULT_GRAY_KERNEL,
GRAY_KERNEL_PRESETS,
COLOR_KERNEL_PRESETS,
} from "./kernels.ts";
const DEFAULT_IMAGE: string = marioImage;
const MIN_KERNEL_SIZE = 1;
const MAX_KERNEL_SIZE = 20;
export default function ConvolutionVisualizer() {
const [rawInputImage, setRawInputImage] = useState<string>(DEFAULT_IMAGE);
const [useColor, setUseColor] = useState<boolean>(true);
const [colorKernel, setColorKernel] = useState<number[][][]>(DEFAULT_COLOR_KERNEL);
const [grayscaleKernel, setGrayscaleKernel] = useState<number[][]>(DEFAULT_GRAY_KERNEL);
const kernel = useColor ? colorKernel : grayscaleKernel;
const [inputImage, outputImage] = useConvolutionProcessing(rawInputImage, kernel);
return (
<div className="grid grid-cols-2 gap-8 h-full min-h-0">
<InputOutputViewer
input={inputImage}
output={outputImage}
/>
<div className="flex flex-col gap-8 h-full min-h-0">
<ImageControls
setImage={setRawInputImage}
useColor={useColor}
setUseColor={setUseColor}
/>
<KernelEditor
useColor={useColor}
colorKernel={colorKernel}
setColorKernel={setColorKernel}
grayscaleKernel={grayscaleKernel}
setGrayscaleKernel={setGrayscaleKernel}
/>
</div>
</div>
);
}
type InputOutputViewerProps = {
input: string | null;
output: string | null;
}
function InputOutputViewer({ input, output }: InputOutputViewerProps) {
return (
<Card className="grid grid-rows-2 h-full min-h-0">
<div className="flex flex-col items-center justify-center min-h-0">
<h2 className="text-lg font-bold mb-2">Input Image</h2>
{input ? (
<img src={input} alt="Input" className="flex-1 max-w-full max-h-full min-h-0" />
) : (
<p>Loading...</p>
)}
</div>
<div className="flex flex-col items-center justify-center min-h-0">
<h2 className="text-lg font-bold mb-2">Output Image</h2>
{output ? (
<img src={output} alt="Output" className="flex-1 max-w-full max-h-full min-h-0" />
) : (
<p>Processing...</p>
)}
</div>
</Card>
);
}
type ImageControlsProps = {
setImage: (imageUrl: string) => void;
useColor: boolean;
setUseColor: (useColor: boolean) => void;
}
function ImageControls({ setImage, useColor, setUseColor }: ImageControlsProps) {
const imageFileInputRef = useRef<HTMLInputElement | null>(null);
return (
<Card className="flex justify-evenly items-center p-4 gap-4">
<input
ref={imageFileInputRef}
type="file"
accept="image/*"
onChange={(e) => {
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);
}}
style={{ display: "none" }}
/>
<Button
label="Upload Image"
onClick={() => imageFileInputRef.current?.click()}
/>
<Radio
label="Color option"
options={["Grayscale", "Color"] as const}
activeOption={useColor ? "Color" : "Grayscale"}
onChange={(option) => setUseColor(option === "Color")}
/>
</Card>
);
}
type KernelEditorProps = {
useColor: boolean;
colorKernel: number[][][];
setColorKernel: (kernel: number[][][]) => void;
grayscaleKernel: number[][];
setGrayscaleKernel: (kernel: number[][]) => void;
}
function KernelEditor({
useColor,
colorKernel,
setColorKernel,
grayscaleKernel,
setGrayscaleKernel,
}: KernelEditorProps) {
const colorPresetNames = Object.keys(COLOR_KERNEL_PRESETS) as string[];
const grayPresetNames = Object.keys(GRAY_KERNEL_PRESETS) as string[];
const [selectedChannel, setSelectedChannel] = useState<number>(0);
const [selectedColorPreset, setSelectedColorPreset] = useState<string>(colorPresetNames[0]);
const [selectedGrayPreset, setSelectedGrayPreset] = useState<string>(grayPresetNames[0]);
const [loadedColorPreset, setLoadedColorPreset] = useState<string | null>("Laplacian");
const [loadedGrayPreset, setLoadedGrayPreset] = useState<string | null>("Laplacian");
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<string[][][]>(colorKernelToRepr(colorKernel));
const [draftGrayKernel, setDraftGrayKernel] = useState<string[][]>(grayKernelToRepr(grayscaleKernel));
useEffect(() => {
setDraftColorKernel(colorKernelToRepr(colorKernel));
}, [colorKernel]);
useEffect(() => {
setDraftGrayKernel(grayKernelToRepr(grayscaleKernel));
}, [grayscaleKernel]);
useEffect(() => {
setSelectedChannel(0);
}, [useColor]);
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 handleLoadPreset() {
if (useColor) {
const presetKernel = cloneColorKernel(
COLOR_KERNEL_PRESETS[selectedColorPreset as keyof typeof COLOR_KERNEL_PRESETS],
);
setDraftColorKernel(colorKernelToRepr(presetKernel));
setColorKernel(presetKernel);
setLoadedColorPreset(selectedColorPreset);
setSelectedChannel(0);
return;
}
const presetKernel = cloneGrayKernel(
GRAY_KERNEL_PRESETS[selectedGrayPreset as keyof typeof GRAY_KERNEL_PRESETS],
);
setDraftGrayKernel(grayKernelToRepr(presetKernel));
setGrayscaleKernel(presetKernel);
setLoadedGrayPreset(selectedGrayPreset);
}
function kernelsEqual2D(a: number[][], b: number[][]): boolean {
if (a.length !== b.length) return false;
if ((a[0]?.length ?? 0) !== (b[0]?.length ?? 0)) return false;
return a.every((row, rowIndex) =>
row.every((value, colIndex) => value === b[rowIndex][colIndex]),
);
}
function kernelsEqual3D(a: number[][][], b: number[][][]): boolean {
if (a.length !== b.length) return false;
return a.every((channel, channelIndex) => kernelsEqual2D(channel, b[channelIndex]));
}
function handleKernelChange(channel: number, row: number, col: number, value: string) {
if (useColor) {
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) {
setColorKernel(parsedKernel as number[][][]);
}
return;
}
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) {
setGrayscaleKernel(parsedKernel as number[][]);
}
}
function handleSizeChange(newWidth: number, newHeight: number) {
const clampedWidth = Math.min(MAX_KERNEL_SIZE, Math.max(MIN_KERNEL_SIZE, newWidth));
const clampedHeight = 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 (useColor) {
const resizedDraft = draftColorKernel.map((channelMatrix) =>
resizeMatrix(channelMatrix, clampedWidth, clampedHeight),
);
setDraftColorKernel(resizedDraft);
setColorKernel(
resizedDraft.map((channelMatrix) =>
channelMatrix.map((row) => row.map((cell) => parseCell(cell) ?? 0)),
),
);
return;
}
const resizedDraft = resizeMatrix(draftGrayKernel, clampedWidth, clampedHeight);
setDraftGrayKernel(resizedDraft);
setGrayscaleKernel(resizedDraft.map((row) => row.map((cell) => parseCell(cell) ?? 0)));
}
const channelLabels = ["R", "G", "B"];
const activeMatrix = useColor
? draftColorKernel[selectedChannel]
: draftGrayKernel;
const currentHeight = activeMatrix.length;
const currentWidth = activeMatrix[0]?.length ?? 0;
const isModified = useColor
? loadedColorPreset !== null
&& !kernelsEqual3D(
colorKernel,
COLOR_KERNEL_PRESETS[loadedColorPreset as keyof typeof COLOR_KERNEL_PRESETS],
)
: loadedGrayPreset !== null
&& !kernelsEqual2D(
grayscaleKernel,
GRAY_KERNEL_PRESETS[loadedGrayPreset as keyof typeof GRAY_KERNEL_PRESETS],
);
const loadedPresetLabel = useColor ? loadedColorPreset : loadedGrayPreset;
return (
<Card className="flex flex-col items-center w-full min-h-0 h-full min-h-0 overflow-auto">
<div className="flex items-end gap-2 mb-3">
<Dropdown
label="Kernel Preset"
options={useColor ? colorPresetNames : grayPresetNames}
activeOption={useColor ? selectedColorPreset : selectedGrayPreset}
onChange={(option) => {
if (useColor) {
setSelectedColorPreset(option);
return;
}
setSelectedGrayPreset(option);
}}
/>
<Button label="Load preset" onClick={handleLoadPreset} />
</div>
{loadedPresetLabel && (
<div className="text-sm mb-3">
<span>Loaded: {loadedPresetLabel}</span>
{isModified && <span className="text-orange-700 ml-2">(modified)</span>}
</div>
)}
{ useColor && (
<div className="flex gap-2 mb-3">
{channelLabels.map((label, index) => (
<button
key={label}
type="button"
className={`px-3 py-1 text-sm rounded border ${
selectedChannel === index ? "bg-orange-200 border-orange-300" : "bg-white border-gray-300"
}`}
onClick={() => setSelectedChannel(index)}
>
{label}
</button>
))}
</div>
)}
<div className="flex flex-col gap-4 mb-3">
<div className="flex items-center gap-2">
<span className="text-sm">Width</span>
<button
type="button"
className="px-2 py-1 text-sm rounded border border-gray-300"
onClick={() => handleSizeChange(currentWidth - 1, currentHeight)}
>
-
</button>
<span className="text-sm w-6 text-center">{currentWidth}</span>
<button
type="button"
className="px-2 py-1 text-sm rounded border border-gray-300"
onClick={() => handleSizeChange(currentWidth + 1, currentHeight)}
>
+
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">Height</span>
<button
type="button"
className="px-2 py-1 text-sm rounded border border-gray-300"
onClick={() => handleSizeChange(currentWidth, currentHeight - 1)}
>
-
</button>
<span className="text-sm w-6 text-center">{currentHeight}</span>
<button
type="button"
className="px-2 py-1 text-sm rounded border border-gray-300"
onClick={() => handleSizeChange(currentWidth, currentHeight + 1)}
>
+
</button>
</div>
</div>
<div className="flex flex-col gap-2">
{activeMatrix.map((row, rowIndex) => (
<div key={rowIndex} className="flex gap-2">
{row.map((cellValue, colIndex) => (
<input
key={`${rowIndex}-${colIndex}`}
type="text"
className="w-14 px-2 py-1 border border-gray-300 rounded text-sm"
value={cellValue}
onChange={(event) =>
handleKernelChange(
useColor ? selectedChannel : 0,
rowIndex,
colIndex,
event.target.value,
)
}
/>
))}
</div>
))}
</div>
</Card>
);
}