feat(v1): add basic backend and frontend

This commit is contained in:
2026-07-09 00:37:41 +03:30
parent d9eedb3d8e
commit ae240e7ac1
54 changed files with 5829 additions and 0 deletions

7
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
coverage/
.vite/
.env
.env.local
*.log

1
frontend/.env.sample Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=http://localhost:8000

11
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spatial Image Enhancer Pro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

11
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}

3674
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "spatial-image-enhancer-pro",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"test": "vitest run"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"vite": "latest",
"react": "latest",
"react-dom": "latest",
"react-quick-pinch-zoom": "latest",
"recharts": "latest",
"lucide-react": "latest",
"prop-types": "latest"
},
"devDependencies": {
"tailwindcss": "3.4.17",
"postcss": "8.4.49",
"autoprefixer": "10.4.20",
"vitest": "latest",
"@testing-library/react": "latest",
"@testing-library/jest-dom": "latest",
"jsdom": "latest"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

152
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,152 @@
import { useEffect, useMemo, useState } from "react";
import CanvasPane 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";
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 [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;
async function handleUpload(file) {
if (!file) return;
setBusy(true);
setStatus("Uploading image...");
try {
const payload = await uploadImage(file);
setSession(payload);
setProcessed(null);
setBatchSessions([payload.session_id]);
setTransform({ x: 0, y: 0, scale: 1 });
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded.`);
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
async function handleBatchUpload(file) {
if (!file) return;
setBusy(true);
setStatus("Uploading batch image...");
try {
const payload = await uploadImage(file);
setBatchSessions((current) => [...current, payload.session_id]);
setStatus("Batch image added.");
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
async function runBatch(kind) {
setBusy(true);
setStatus(`Starting ${kind} job...`);
try {
const job = await createBatch(kind, batchSessions);
const result = await pollJob(job.job_id);
setProcessed(result);
setStatus(`${kind} complete.`);
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
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.");
}
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 viewportTitle = useMemo(() => {
if (!session) return "No image";
return `${session.width} x ${session.height} ${session.color_mode}`;
}, [session]);
return (
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:flex-row">
<Controls
selected={operation}
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}
busy={busy}
/>
<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>
<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>
<HistogramPanel original={originalHistogram} processed={processedHistogram} />
</main>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { render, screen } from "@testing-library/react";
import App from "./App.jsx";
describe("App real render", () => {
it("mounts without mocking third-party components", () => {
render(<App />);
expect(screen.getByText("Spatial Image Enhancer Pro")).toBeInTheDocument();
});
});

15
frontend/src/App.test.jsx Normal file
View File

@@ -0,0 +1,15 @@
import { render, screen } from "@testing-library/react";
import App from "./App.jsx";
vi.mock("react-quick-pinch-zoom", () => ({
default: ({ children }) => <div>{children}</div>
}));
describe("App", () => {
it("renders the processing studio 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);
});
});

View File

@@ -0,0 +1,49 @@
import { useEffect, useRef } from "react";
import QuickPinchZoom from "react-quick-pinch-zoom";
function drawToCanvas(canvas, imageData) {
if (!canvas || !imageData) return;
const context = canvas.getContext("2d");
const image = new Image();
image.onload = () => {
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0);
};
image.src = imageData;
}
export default function CanvasPane({ title, imageData, histogram, transform, onTransform }) {
const canvasRef = useRef(null);
const holderRef = useRef(null);
useEffect(() => {
drawToCanvas(canvasRef.current, imageData);
}, [imageData]);
useEffect(() => {
if (!holderRef.current) return;
holderRef.current.style.transform = `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`;
}, [transform]);
return (
<section className="flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-800 bg-zinc-950">
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
<span className="text-xs tabular-nums text-zinc-400">{histogram ? "p(r_k) ready" : "No histogram"}</span>
</div>
<div className="studio-grid flex min-h-[280px] flex-1 items-center justify-center overflow-hidden bg-zinc-950">
{imageData ? (
<QuickPinchZoom onUpdate={onTransform} inertia={false} wheelScaleFactor={180}>
<div ref={holderRef} className="origin-top-left will-change-transform">
<canvas ref={canvasRef} className="block max-h-[68vh] max-w-full shadow-2xl shadow-black/40" />
</div>
</QuickPinchZoom>
) : (
<div className="px-6 text-center text-sm text-zinc-500">Upload an image to start processing.</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,167 @@
import { Activity, Aperture, Blend, 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 initialParams(operation) {
return Object.fromEntries(operation.params.map((param) => [param.key, param.default]));
}
export default function Controls({
selected,
params,
onOperationChange,
onParamChange,
onUpload,
onBatchUpload,
batchCount,
onBatchRun,
disabled,
busy
}) {
return (
<aside className="flex h-full w-full flex-col border-r border-zinc-800 bg-zinc-950 lg:w-[360px]">
<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>
</div>
<div className="space-y-3 border-b border-zinc-800 p-4">
<label className="flex cursor-pointer items-center justify-center gap-2 border border-cyan-700 bg-cyan-950/60 px-3 py-2 text-sm font-medium text-cyan-100 hover:bg-cyan-900/60">
<Upload size={16} />
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>
<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>
</div>
</aside>
);
}

View File

@@ -0,0 +1,36 @@
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
function toChartData(original, processed) {
return Array.from({ length: 256 }, (_, level) => ({
level,
original: original?.[level] ?? 0,
processed: processed?.[level] ?? 0
}));
}
export default function HistogramPanel({ original, processed }) {
const data = toChartData(original, processed);
return (
<section className="border-t border-zinc-800 bg-zinc-950 px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<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>
</div>
</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ left: 0, right: 8, top: 8, bottom: 0 }}>
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis dataKey="level" stroke="#71717a" tick={{ fontSize: 10 }} interval={63} />
<YAxis stroke="#71717a" tick={{ fontSize: 10 }} width={44} />
<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} />
</AreaChart>
</ResponsiveContainer>
</div>
</section>
);
}

42
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,42 @@
const API_BASE = import.meta.env.VITE_API_BASE || "";
async function parseResponse(response) {
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.detail || "Request failed");
}
return payload;
}
export async function uploadImage(file) {
const body = new FormData();
body.append("image", file);
const response = await fetch(`${API_BASE}/api/images/`, {
method: "POST",
body
});
return parseResponse(response);
}
export async function processImage(sessionId, operation, params) {
const response = await fetch(`${API_BASE}/api/process/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, operation, params })
});
return parseResponse(response);
}
export async function createBatch(operation, sessionIds, params = {}) {
const response = await fetch(`${API_BASE}/api/batch/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ operation, session_ids: sessionIds, params })
});
return parseResponse(response);
}
export async function getJob(jobId) {
const response = await fetch(`${API_BASE}/api/jobs/${jobId}/`);
return parseResponse(response);
}

View File

@@ -0,0 +1,14 @@
import { useEffect } from "react";
export function useDebouncedEffect(effect, deps, delay = 300) {
useEffect(() => {
let cleanup;
const handle = window.setTimeout(() => {
cleanup = effect();
}, delay);
return () => {
window.clearTimeout(handle);
if (typeof cleanup === "function") cleanup();
};
}, deps);
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles/app.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
background: #09090b;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: #09090b;
color: #e4e4e7;
}
canvas {
max-width: 100%;
height: auto;
image-rendering: auto;
}
.studio-grid {
background-image:
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
background-size: 24px 24px;
}

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom";

View File

@@ -0,0 +1,11 @@
export default {
content: ["./index.html", "./src/**/*.{js,jsx}"],
theme: {
extend: {
fontFamily: {
sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"]
}
}
},
plugins: []
};

18
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8000",
"/media": "http://localhost:8000"
}
},
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test-setup.js"
}
});