feat(v2): add multiple extra features from the pdf slides
This commit is contained in:
@@ -1,22 +1,50 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import CanvasPane from "./components/CanvasPane.jsx";
|
||||
import CanvasPane, { CanvasThumbnail } from "./components/CanvasPane.jsx";
|
||||
import Controls from "./components/Controls.jsx";
|
||||
import HistogramPanel from "./components/HistogramPanel.jsx";
|
||||
import { createBatch, getJob, processImage, uploadImage } from "./lib/api.js";
|
||||
import { useDebouncedEffect } from "./lib/debounce.js";
|
||||
import { applyStateOperation, combineStates, getOperations, listStates, uploadImage } from "./lib/api.js";
|
||||
|
||||
function defaultCropParams(state) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: state?.width || 256,
|
||||
height: state?.height || 256
|
||||
};
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState(null);
|
||||
const [processed, setProcessed] = useState(null);
|
||||
const [batchSessions, setBatchSessions] = useState([]);
|
||||
const [operation, setOperation] = useState("gamma");
|
||||
const [params, setParams] = useState({ gamma: 1 });
|
||||
const [states, setStates] = useState([]);
|
||||
const [activeState, setActiveState] = useState(null);
|
||||
const [operations, setOperations] = useState([]);
|
||||
const [selectedOperation, setSelectedOperation] = useState("");
|
||||
const [params, setParams] = useState({});
|
||||
const [selectedStateIds, setSelectedStateIds] = useState([]);
|
||||
const [status, setStatus] = useState("Upload an image to begin.");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
const originalHistogram = session?.original_histogram;
|
||||
const processedHistogram = processed?.processed_histogram || processed?.result_histogram;
|
||||
useEffect(() => {
|
||||
getOperations()
|
||||
.then((payload) => {
|
||||
setOperations(payload.operations || []);
|
||||
const first = payload.operations?.find((operation) => operation.id === "crop") || payload.operations?.[0];
|
||||
if (first) {
|
||||
setSelectedOperation(first.id);
|
||||
setParams(Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default])));
|
||||
}
|
||||
})
|
||||
.catch((error) => setStatus(error.message));
|
||||
}, []);
|
||||
|
||||
async function refreshStates(sessionId, nextActiveId = null) {
|
||||
const payload = await listStates(sessionId);
|
||||
setStates(payload.states || []);
|
||||
const nextActive = payload.states?.find((state) => state.state_id === nextActiveId) || payload.states?.at(-1) || null;
|
||||
setActiveState(nextActive);
|
||||
return nextActive;
|
||||
}
|
||||
|
||||
async function handleUpload(file) {
|
||||
if (!file) return;
|
||||
@@ -25,10 +53,13 @@ export default function App() {
|
||||
try {
|
||||
const payload = await uploadImage(file);
|
||||
setSession(payload);
|
||||
setProcessed(null);
|
||||
setBatchSessions([payload.session_id]);
|
||||
const initialStates = payload.states || [];
|
||||
setStates(initialStates);
|
||||
setActiveState(initialStates[0] || null);
|
||||
setSelectedStateIds(initialStates[0] ? [initialStates[0].state_id] : []);
|
||||
setTransform({ x: 0, y: 0, scale: 1 });
|
||||
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded.`);
|
||||
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded as S0.`);
|
||||
if (selectedOperation === "crop") setParams(defaultCropParams(initialStates[0]));
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
@@ -36,14 +67,24 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchUpload(file) {
|
||||
if (!file) return;
|
||||
function handleSelectOperation(operationId, nextParams) {
|
||||
setSelectedOperation(operationId);
|
||||
setParams(operationId === "crop" ? { ...nextParams, ...defaultCropParams(activeState) } : nextParams);
|
||||
}
|
||||
|
||||
function handleParamChange(key, value) {
|
||||
setParams((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
if (!activeState || !selectedOperation) return;
|
||||
setBusy(true);
|
||||
setStatus("Uploading batch image...");
|
||||
setStatus(`Applying ${selectedOperation} to ${activeState.label}...`);
|
||||
try {
|
||||
const payload = await uploadImage(file);
|
||||
setBatchSessions((current) => [...current, payload.session_id]);
|
||||
setStatus("Batch image added.");
|
||||
const state = await applyStateOperation(activeState.state_id, selectedOperation, params);
|
||||
await refreshStates(state.session_id, state.state_id);
|
||||
setSelectedStateIds([state.state_id]);
|
||||
setStatus(`${state.label} created.`);
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
@@ -51,14 +92,15 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runBatch(kind) {
|
||||
async function handleCombine(kind) {
|
||||
if (selectedStateIds.length < 2) return;
|
||||
setBusy(true);
|
||||
setStatus(`Starting ${kind} job...`);
|
||||
setStatus(`Combining ${selectedStateIds.length} states using ${kind}...`);
|
||||
try {
|
||||
const job = await createBatch(kind, batchSessions);
|
||||
const result = await pollJob(job.job_id);
|
||||
setProcessed(result);
|
||||
setStatus(`${kind} complete.`);
|
||||
const state = await combineStates(kind, selectedStateIds);
|
||||
await refreshStates(state.session_id, state.state_id);
|
||||
setSelectedStateIds([state.state_id]);
|
||||
setStatus(`${state.label} created.`);
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
@@ -66,86 +108,53 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function pollJob(jobId) {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const job = await getJob(jobId);
|
||||
setStatus(`Job ${job.status}: ${job.progress}%`);
|
||||
if (job.status === "complete") return job;
|
||||
if (job.status === "failed") throw new Error(job.error || "Batch job failed");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1000));
|
||||
}
|
||||
throw new Error("Batch job timed out.");
|
||||
function toggleCombineState(stateId) {
|
||||
setSelectedStateIds((current) => current.includes(stateId) ? current.filter((id) => id !== stateId) : [...current, stateId]);
|
||||
}
|
||||
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
if (!session?.session_id || !operation) return;
|
||||
let cancelled = false;
|
||||
async function run() {
|
||||
setBusy(true);
|
||||
setStatus(`Processing ${operation}...`);
|
||||
try {
|
||||
const payload = await processImage(session.session_id, operation, params);
|
||||
if (!cancelled) {
|
||||
setProcessed(payload);
|
||||
setStatus(`${operation} complete in ${payload.elapsed_ms} ms.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) setStatus(error.message);
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
},
|
||||
[session?.session_id, operation, JSON.stringify(params)],
|
||||
300
|
||||
);
|
||||
|
||||
const processedImage = processed?.image_data || session?.image_data;
|
||||
const originalImage = session?.image_data;
|
||||
|
||||
const originalState = states[0] || null;
|
||||
const viewportTitle = useMemo(() => {
|
||||
if (!session) return "No image";
|
||||
return `${session.width} x ${session.height} ${session.color_mode}`;
|
||||
}, [session]);
|
||||
if (!activeState) return "No active state";
|
||||
return `${activeState.label} · ${activeState.width} x ${activeState.height} ${activeState.color_mode}`;
|
||||
}, [activeState]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:flex-row">
|
||||
<Controls
|
||||
selected={operation}
|
||||
operations={operations}
|
||||
selectedOperation={selectedOperation}
|
||||
params={params}
|
||||
onOperationChange={(nextOperation, nextParams) => {
|
||||
setOperation(nextOperation);
|
||||
setParams(nextParams);
|
||||
}}
|
||||
onParamChange={(key, value) => setParams((current) => ({ ...current, [key]: value }))}
|
||||
onUpload={handleUpload}
|
||||
onBatchUpload={handleBatchUpload}
|
||||
batchCount={batchSessions.length}
|
||||
onBatchRun={runBatch}
|
||||
disabled={!session || busy}
|
||||
states={states}
|
||||
activeState={activeState}
|
||||
selectedStateIds={selectedStateIds}
|
||||
busy={busy}
|
||||
onUpload={handleUpload}
|
||||
onSelectOperation={handleSelectOperation}
|
||||
onParamChange={handleParamChange}
|
||||
onApply={handleApply}
|
||||
onSelectState={(state) => {
|
||||
setActiveState(state);
|
||||
if (selectedOperation === "crop") setParams(defaultCropParams(state));
|
||||
}}
|
||||
onToggleCombineState={toggleCombineState}
|
||||
onCombine={handleCombine}
|
||||
/>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-800 bg-zinc-950 px-5 py-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-cyan-300">Professional Dark Studio</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-cyan-300">Professor Slide Workspace</p>
|
||||
<h2 className="text-sm font-medium text-zinc-200">{viewportTitle}</h2>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-400">{busy ? "Working..." : status}</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-px bg-zinc-800 lg:grid-cols-2">
|
||||
<CanvasPane title="Original" imageData={originalImage} histogram={originalHistogram} transform={transform} onTransform={setTransform} />
|
||||
<CanvasPane title="Processed" imageData={processedImage} histogram={processedHistogram} transform={transform} onTransform={setTransform} />
|
||||
<div className="relative min-h-0 flex-1 bg-zinc-800">
|
||||
<CanvasThumbnail title="S0 Original" imageData={originalState?.image_data} />
|
||||
<CanvasPane title="Active State" imageData={activeState?.image_data} histogram={activeState?.histogram} transform={transform} onTransform={setTransform} />
|
||||
</div>
|
||||
|
||||
<HistogramPanel original={originalHistogram} processed={processedHistogram} />
|
||||
<HistogramPanel original={originalState?.histogram} processed={activeState?.histogram} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import App from "./App.jsx";
|
||||
|
||||
vi.mock("./lib/api.js", () => ({
|
||||
getOperations: () => Promise.resolve({ operations: [] }),
|
||||
listStates: () => Promise.resolve({ states: [] }),
|
||||
uploadImage: vi.fn(),
|
||||
applyStateOperation: vi.fn(),
|
||||
combineStates: vi.fn()
|
||||
}));
|
||||
|
||||
describe("App real render", () => {
|
||||
it("mounts without mocking third-party components", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText("Spatial Image Enhancer Pro")).toBeInTheDocument();
|
||||
expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,19 @@ vi.mock("react-quick-pinch-zoom", () => ({
|
||||
default: ({ children }) => <div>{children}</div>
|
||||
}));
|
||||
|
||||
vi.mock("./lib/api.js", () => ({
|
||||
getOperations: () => Promise.resolve({ operations: [] }),
|
||||
listStates: () => Promise.resolve({ states: [] }),
|
||||
uploadImage: vi.fn(),
|
||||
applyStateOperation: vi.fn(),
|
||||
combineStates: vi.fn()
|
||||
}));
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the processing studio immediately", () => {
|
||||
it("renders the academic workspace immediately", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText("Spatial Image Enhancer Pro")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Original").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Processed").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument();
|
||||
expect(screen.getByText("Image States")).toBeInTheDocument();
|
||||
expect(screen.getByText("Combine Selected States")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,3 +47,22 @@ export default function CanvasPane({ title, imageData, histogram, transform, onT
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasThumbnail({ title, imageData }) {
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
drawToCanvas(canvasRef.current, imageData);
|
||||
}, [imageData]);
|
||||
|
||||
if (!imageData) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute left-4 top-4 z-10 w-40 border border-zinc-700 bg-zinc-950/95 shadow-2xl shadow-black/50 md:w-52">
|
||||
<div className="border-b border-zinc-800 px-3 py-2 text-xs font-semibold text-zinc-100">{title}</div>
|
||||
<div className="max-h-40 overflow-hidden bg-black md:max-h-52">
|
||||
<canvas ref={canvasRef} className="block h-auto w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,93 +1,111 @@
|
||||
import { Activity, Aperture, Blend, Layers, SlidersHorizontal, Upload } from "lucide-react";
|
||||
import { Combine, Crop, Layers, SlidersHorizontal, Upload } from "lucide-react";
|
||||
|
||||
const groups = [
|
||||
{
|
||||
title: "Intensity",
|
||||
icon: SlidersHorizontal,
|
||||
operations: [
|
||||
{ id: "negative", label: "Negative", params: [] },
|
||||
{ id: "log", label: "Log", params: [{ key: "c", label: "c", min: 0.1, max: 3, step: 0.05, default: 1.44 }] },
|
||||
{ id: "gamma", label: "Gamma", params: [{ key: "gamma", label: "Gamma", min: 0.1, max: 4, step: 0.05, default: 1 }] },
|
||||
{
|
||||
id: "contrast_stretch",
|
||||
label: "Contrast Stretch",
|
||||
params: [
|
||||
{ key: "low", label: "Low", min: 0, max: 254, step: 1, default: 30 },
|
||||
{ key: "high", label: "High", min: 1, max: 255, step: 1, default: 220 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "gray_slice",
|
||||
label: "Gray Slice",
|
||||
params: [
|
||||
{ key: "start", label: "Start", min: 0, max: 255, step: 1, default: 96 },
|
||||
{ key: "end", label: "End", min: 0, max: 255, step: 1, default: 160 }
|
||||
]
|
||||
},
|
||||
{ id: "bit_plane", label: "Bit Plane", params: [{ key: "bit", label: "Bit", min: 0, max: 7, step: 1, default: 7 }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Histogram",
|
||||
icon: Activity,
|
||||
operations: [
|
||||
{ id: "hist_equalization", label: "Global Equalization", params: [] },
|
||||
{ id: "hist_match", label: "Match Uniform", params: [] },
|
||||
{ id: "local_equalization", label: "Local Equalization", params: [{ key: "size", label: "Window", min: 3, max: 31, step: 2, default: 7 }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Spatial Filters",
|
||||
icon: Aperture,
|
||||
operations: [
|
||||
{ id: "box_filter", label: "Box", params: [{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }] },
|
||||
{ id: "weighted_average", label: "Weighted Avg", params: [{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }] },
|
||||
{ id: "median_filter", label: "Median", params: [{ key: "size", label: "Size", min: 3, max: 15, step: 2, default: 3 }] },
|
||||
{ id: "laplacian", label: "Laplacian", params: [] },
|
||||
{
|
||||
id: "high_boost",
|
||||
label: "High Boost",
|
||||
params: [
|
||||
{ key: "amplification", label: "A", min: 1, max: 5, step: 0.1, default: 1.5 },
|
||||
{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }
|
||||
]
|
||||
},
|
||||
{ id: "sobel", label: "Sobel", params: [] },
|
||||
{ id: "roberts", label: "Roberts", params: [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Color",
|
||||
icon: Blend,
|
||||
operations: [
|
||||
{ id: "pseudo_color_slices", label: "Intensity Slices", params: [] },
|
||||
{ id: "gray_to_color_sinusoidal", label: "HSI Sinusoids", params: [{ key: "hue_frequency", label: "Hue Freq", min: 0.2, max: 4, step: 0.1, default: 1 }] },
|
||||
{ id: "hsi_intensity_filter", label: "HSI Smooth I", params: [{ key: "size", label: "Size", min: 3, max: 15, step: 2, default: 3 }] }
|
||||
]
|
||||
function defaultParams(operation) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(operation?.params || {}).map(([key, schema]) => [key, schema.default])
|
||||
);
|
||||
}
|
||||
|
||||
function groupOperations(operations) {
|
||||
return operations.reduce((acc, operation) => {
|
||||
acc[operation.chapter] ||= {};
|
||||
acc[operation.chapter][operation.slide_group] ||= [];
|
||||
acc[operation.chapter][operation.slide_group].push(operation);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function ParamControl({ name, schema, value, onChange }) {
|
||||
if (schema.type === "select") {
|
||||
return (
|
||||
<label className="mb-3 block text-xs text-zinc-300">
|
||||
<span className="mb-1 block">{name}</span>
|
||||
<select className="w-full border border-zinc-700 bg-zinc-950 px-2 py-2 text-sm" value={value ?? schema.default} onChange={(event) => onChange(name, event.target.value)}>
|
||||
{schema.choices.map((choice) => (
|
||||
<option key={choice} value={choice}>{choice}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
];
|
||||
|
||||
function initialParams(operation) {
|
||||
return Object.fromEntries(operation.params.map((param) => [param.key, param.default]));
|
||||
if (schema.type === "bool") {
|
||||
return (
|
||||
<label className="mb-3 flex items-center gap-2 text-xs text-zinc-300">
|
||||
<input type="checkbox" checked={Boolean(value ?? schema.default)} onChange={(event) => onChange(name, event.target.checked)} />
|
||||
{name}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label className="mb-4 block">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-zinc-300">
|
||||
<span>{name}{schema.odd ? " (odd)" : ""}</span>
|
||||
<input
|
||||
className="w-20 border border-zinc-700 bg-zinc-950 px-2 py-1 text-right tabular-nums"
|
||||
type="number"
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
step={schema.step}
|
||||
value={value ?? schema.default}
|
||||
onChange={(event) => onChange(name, schema.type === "int" ? parseInt(event.target.value || schema.default, 10) : Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
step={schema.step}
|
||||
value={value ?? schema.default}
|
||||
onChange={(event) => onChange(name, schema.type === "int" ? parseInt(event.target.value, 10) : Number(event.target.value))}
|
||||
className="w-full accent-cyan-400"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Controls({
|
||||
selected,
|
||||
operations,
|
||||
selectedOperation,
|
||||
params,
|
||||
onOperationChange,
|
||||
onParamChange,
|
||||
states,
|
||||
activeState,
|
||||
selectedStateIds,
|
||||
busy,
|
||||
onUpload,
|
||||
onBatchUpload,
|
||||
batchCount,
|
||||
onBatchRun,
|
||||
disabled,
|
||||
busy
|
||||
onSelectOperation,
|
||||
onParamChange,
|
||||
onApply,
|
||||
onSelectState,
|
||||
onToggleCombineState,
|
||||
onCombine,
|
||||
}) {
|
||||
const grouped = groupOperations(operations);
|
||||
const operation = operations.find((item) => item.id === selectedOperation);
|
||||
|
||||
function renderParameterDrawer(item) {
|
||||
if (selectedOperation !== item.id) return null;
|
||||
return (
|
||||
<div className="border border-emerald-700 bg-zinc-950 p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300">
|
||||
<SlidersHorizontal size={14} />
|
||||
Parameters
|
||||
</div>
|
||||
{Object.entries(item.params || {}).map(([name, schema]) => (
|
||||
<ParamControl key={name} name={name} schema={schema} value={params[name]} onChange={onParamChange} />
|
||||
))}
|
||||
{Object.keys(item.params || {}).length === 0 ? <p className="mb-3 text-sm text-zinc-500">No parameters.</p> : null}
|
||||
<button disabled={!activeState || busy} onClick={onApply} className="w-full border border-emerald-600 bg-emerald-950/60 px-3 py-2 text-sm font-semibold text-emerald-100 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
Apply to Active State
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col border-r border-zinc-800 bg-zinc-950 lg:w-[360px]">
|
||||
<aside className="flex h-full w-full flex-col border-r border-zinc-800 bg-zinc-950 lg:w-[430px]">
|
||||
<div className="border-b border-zinc-800 px-5 py-4">
|
||||
<h1 className="text-lg font-semibold tracking-normal text-zinc-50">Spatial Image Enhancer Pro</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">Vectorized spatial-domain processing studio</p>
|
||||
<h1 className="text-lg font-semibold text-zinc-50">Academic Image Processing Workspace</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">MATLAB-like states organized by lecture chapters</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-b border-zinc-800 p-4">
|
||||
@@ -96,72 +114,71 @@ export default function Controls({
|
||||
Upload Image
|
||||
<input type="file" accept="image/*" className="hidden" onChange={(event) => onUpload(event.target.files?.[0])} />
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center justify-center gap-2 border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm font-medium text-zinc-100 hover:bg-zinc-800">
|
||||
<Layers size={16} />
|
||||
Add Batch Image ({batchCount})
|
||||
<input type="file" accept="image/*" className="hidden" onChange={(event) => onBatchUpload(event.target.files?.[0])} />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button disabled={batchCount < 2 || busy} onClick={() => onBatchRun("average")} className="border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-40">
|
||||
Average
|
||||
</button>
|
||||
<button disabled={batchCount < 2 || busy} onClick={() => onBatchRun("subtract")} className="border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-40">
|
||||
Subtract
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="border-b border-zinc-800 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||
<Layers size={16} className="text-cyan-300" />
|
||||
Image States
|
||||
</div>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{states.length === 0 ? <p className="text-sm text-zinc-500">No states yet.</p> : states.map((state) => (
|
||||
<div key={state.state_id} className={`flex items-center gap-2 border p-2 ${activeState?.state_id === state.state_id ? "border-emerald-500 bg-emerald-950/30" : "border-zinc-800 bg-zinc-900"}`}>
|
||||
<input type="checkbox" checked={selectedStateIds.includes(state.state_id)} onChange={() => onToggleCombineState(state.state_id)} />
|
||||
<button className="min-w-0 flex-1 text-left" onClick={() => onSelectState(state)}>
|
||||
<div className="truncate text-sm text-zinc-100">{state.label}</div>
|
||||
<div className="truncate text-xs text-zinc-500">{state.width}x{state.height} - {state.operation}</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-zinc-800 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||
<Combine size={16} className="text-cyan-300" />
|
||||
Combine Selected States
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["add", "subtract", "dot_product", "average", "and", "or"].map((kind) => (
|
||||
<button key={kind} disabled={selectedStateIds.length < 2 || busy} onClick={() => onCombine(kind)} className="border border-zinc-700 bg-zinc-900 px-2 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40">
|
||||
{kind}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{groups.map((group) => {
|
||||
const Icon = group.icon;
|
||||
return (
|
||||
<details key={group.title} open className="mb-3 border border-zinc-800 bg-zinc-900/60">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-3 text-sm font-semibold text-zinc-100">
|
||||
<Icon size={16} className="text-cyan-300" />
|
||||
{group.title}
|
||||
</summary>
|
||||
<div className="space-y-2 border-t border-zinc-800 p-3">
|
||||
{group.operations.map((operation) => (
|
||||
<button
|
||||
key={operation.id}
|
||||
disabled={disabled}
|
||||
onClick={() => onOperationChange(operation.id, initialParams(operation))}
|
||||
className={`w-full border px-3 py-2 text-left text-sm transition ${
|
||||
selected === operation.id ? "border-emerald-500 bg-emerald-950/50 text-emerald-100" : "border-zinc-700 bg-zinc-950 text-zinc-200 hover:bg-zinc-800"
|
||||
} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
{operation.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-4 border border-zinc-800 bg-zinc-900/60 p-3">
|
||||
<h2 className="mb-3 text-sm font-semibold text-zinc-100">Parameters</h2>
|
||||
{groups
|
||||
.flatMap((group) => group.operations)
|
||||
.find((operation) => operation.id === selected)
|
||||
?.params.map((param) => (
|
||||
<label key={param.key} className="mb-4 block">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-zinc-300">
|
||||
<span>{param.label}</span>
|
||||
<span className="tabular-nums text-zinc-400">{params[param.key] ?? param.default}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
value={params[param.key] ?? param.default}
|
||||
onChange={(event) => onParamChange(param.key, Number(event.target.value))}
|
||||
className="w-full accent-cyan-400"
|
||||
/>
|
||||
</label>
|
||||
)) || <p className="text-sm text-zinc-500">No tunable parameters for this operation.</p>}
|
||||
</div>
|
||||
{Object.entries(grouped).map(([chapter, groups]) => (
|
||||
<details key={chapter} open={chapter.includes("Chapter 3")} className="mb-3 border border-zinc-800 bg-zinc-900/60">
|
||||
<summary className="cursor-pointer px-3 py-3 text-sm font-semibold text-zinc-100">{chapter}</summary>
|
||||
<div className="space-y-3 border-t border-zinc-800 p-3">
|
||||
{Object.entries(groups).map(([slideGroup, items]) => (
|
||||
<details key={slideGroup} className="border border-zinc-800 bg-zinc-950/70">
|
||||
<summary className="cursor-pointer px-3 py-2 text-xs font-semibold text-cyan-200">{slideGroup}</summary>
|
||||
<div className="grid grid-cols-1 gap-2 p-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="space-y-2">
|
||||
<button
|
||||
disabled={!activeState || busy}
|
||||
onClick={() => onSelectOperation(item.id, defaultParams(item))}
|
||||
className={`w-full border px-3 py-2 text-left text-sm ${selectedOperation === item.id ? "border-emerald-500 bg-emerald-950/50 text-emerald-100" : "border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800"} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
{item.id === "crop" ? <Crop size={14} className="mr-2 inline" /> : null}
|
||||
{item.label}
|
||||
</button>
|
||||
{renderParameterDrawer(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{operation ? null : <div className="border-t border-zinc-800 bg-zinc-950 p-4 text-sm text-zinc-500">Select an operation.</div>}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
function seriesFrom(histogram, fallbackKey = "intensity") {
|
||||
if (Array.isArray(histogram)) return histogram;
|
||||
return histogram?.[fallbackKey] || histogram?.intensity || [];
|
||||
}
|
||||
|
||||
function toChartData(original, processed) {
|
||||
const originalSeries = seriesFrom(original);
|
||||
const processedSeries = seriesFrom(processed);
|
||||
const r = processed?.r || [];
|
||||
const g = processed?.g || [];
|
||||
const b = processed?.b || [];
|
||||
return Array.from({ length: 256 }, (_, level) => ({
|
||||
level,
|
||||
original: original?.[level] ?? 0,
|
||||
processed: processed?.[level] ?? 0
|
||||
original: originalSeries?.[level] ?? 0,
|
||||
processed: processedSeries?.[level] ?? 0,
|
||||
r: r[level] ?? 0,
|
||||
g: g[level] ?? 0,
|
||||
b: b[level] ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -16,7 +29,7 @@ export default function HistogramPanel({ original, processed }) {
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Histogram Analytics</h2>
|
||||
<div className="flex gap-3 text-xs text-zinc-400">
|
||||
<span className="text-cyan-300">Original</span>
|
||||
<span className="text-emerald-300">Processed</span>
|
||||
<span className="text-emerald-300">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-40">
|
||||
@@ -28,6 +41,9 @@ export default function HistogramPanel({ original, processed }) {
|
||||
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", color: "#f4f4f5" }} />
|
||||
<Area type="monotone" dataKey="original" stroke="#67e8f9" fill="#0891b2" fillOpacity={0.22} dot={false} />
|
||||
<Area type="monotone" dataKey="processed" stroke="#6ee7b7" fill="#059669" fillOpacity={0.24} dot={false} />
|
||||
<Area type="monotone" dataKey="r" stroke="#f87171" fill="#ef4444" fillOpacity={0.08} dot={false} />
|
||||
<Area type="monotone" dataKey="g" stroke="#4ade80" fill="#22c55e" fillOpacity={0.08} dot={false} />
|
||||
<Area type="monotone" dataKey="b" stroke="#60a5fa" fill="#3b82f6" fillOpacity={0.08} dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,39 @@ export async function uploadImage(file) {
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function getOperations() {
|
||||
const response = await fetch(`${API_BASE}/api/operations/`);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function listStates(sessionId) {
|
||||
const response = await fetch(`${API_BASE}/api/sessions/${sessionId}/states/`);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function applyStateOperation(stateId, operation, params = {}) {
|
||||
const response = await fetch(`${API_BASE}/api/states/${stateId}/operations/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ operation, params })
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function combineStates(operation, stateIds, params = {}) {
|
||||
const response = await fetch(`${API_BASE}/api/states/combine/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ operation, state_ids: stateIds, params })
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function getStateHistogram(stateId) {
|
||||
const response = await fetch(`${API_BASE}/api/states/${stateId}/histogram/`);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function processImage(sessionId, operation, params) {
|
||||
const response = await fetch(`${API_BASE}/api/process/`, {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user