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

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
CADDY_DOMAIN=localhost
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=replace-with-a-long-random-value
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,api
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost,https://localhost
CORS_ALLOWED_ORIGINS=http://localhost,http://localhost:5173
DJANGO_SECURE_SSL_REDIRECT=0
DJANGO_SESSION_COOKIE_SECURE=0
DJANGO_CSRF_COOKIE_SECURE=0
DJANGO_SECURE_HSTS_SECONDS=0
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=0
DJANGO_SECURE_HSTS_PRELOAD=0
POSTGRES_DB=enhancer
POSTGRES_USER=enhancer
POSTGRES_PASSWORD=enhancer
IMAGE_SESSION_TTL_HOURS=6

16
.env.sample Normal file
View File

@@ -0,0 +1,16 @@
CADDY_DOMAIN=example.com
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=example.com,www.example.com,api
DJANGO_CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
CORS_ALLOWED_ORIGINS=https://example.com,https://www.example.com
DJANGO_SECURE_SSL_REDIRECT=1
DJANGO_SESSION_COOKIE_SECURE=1
DJANGO_CSRF_COOKIE_SECURE=1
DJANGO_SECURE_HSTS_SECONDS=31536000
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=1
DJANGO_SECURE_HSTS_PRELOAD=0
POSTGRES_DB=enhancer
POSTGRES_USER=enhancer
POSTGRES_PASSWORD=replace-with-a-strong-password
IMAGE_SESSION_TTL_HOURS=6

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.coverage
htmlcov/
db.sqlite3
backend/media/
backend/staticfiles/
.env
node_modules/
dist/
coverage/
.vite/
*.log

15
Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
{$CADDY_DOMAIN:localhost} {
encode zstd gzip
handle /api/* {
reverse_proxy api:8000
}
handle /media/* {
reverse_proxy api:8000
}
handle {
reverse_proxy web:80
}
}

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
# Spatial Image Enhancer Pro
Production-grade public SPA for spatial-domain image enhancement using Django REST Framework, OpenCV, NumPy, React, Tailwind CSS, Celery, Redis, PostgreSQL, Docker Compose, and Caddy.
## Local Development
Backend:
```bash
cd backend
python -m venv .venv
.venv\Scripts\activate
copy .env.sample .env
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
```
Frontend:
```bash
cd frontend
copy .env.sample .env
npm install
npm run dev
```
Open `http://localhost:5173`. The frontend `.env` uses `VITE_API_BASE=http://localhost:8000`, and the backend `.env` allows that origin via CORS/CSRF settings.
## Docker
```bash
copy .env.example .env
copy backend\.env.sample backend\.env
docker compose up --build
```
Caddy serves the SPA and proxies `/api/*` and `/media/*`. Set `CADDY_DOMAIN`, `DJANGO_ALLOWED_HOSTS`, `DJANGO_CSRF_TRUSTED_ORIGINS`, `CORS_ALLOWED_ORIGINS`, and a strong `DJANGO_SECRET_KEY` before production deployment.
For production, use `.env.sample` as the root Compose template and `backend/.env.production.sample` as the backend-only template.
## Backend Structure
The Django app follows the HackSoftware Django Styleguide pattern:
- API views validate request input and return responses.
- `processing/services.py` contains business workflows and writes.
- `processing/selectors.py` contains database fetch helpers.
- Settings are environment-driven through `backend/.env`.
## API
- `POST /api/images/`
- `POST /api/process/`
- `POST /api/batch/`
- `GET /api/jobs/{job_id}/`
Images are stored as ephemeral sessions and removed by the cleanup task after the configured TTL.

8
backend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
db.sqlite3
media/
staticfiles/
.env
.env.local

View File

@@ -0,0 +1,23 @@
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=example.com,www.example.com,api
DJANGO_CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
CORS_ALLOWED_ORIGINS=https://example.com,https://www.example.com
DJANGO_SECURE_SSL_REDIRECT=1
DJANGO_SESSION_COOKIE_SECURE=1
DJANGO_CSRF_COOKIE_SECURE=1
DJANGO_SECURE_HSTS_SECONDS=31536000
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=1
DJANGO_SECURE_HSTS_PRELOAD=0
DJANGO_LOG_LEVEL=INFO
POSTGRES_DB=enhancer
POSTGRES_USER=enhancer
POSTGRES_PASSWORD=replace-with-a-strong-password
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_CONN_MAX_AGE=60
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
CELERY_TASK_TIME_LIMIT=600
IMAGE_SESSION_TTL_HOURS=6
MAX_UPLOAD_MB=20

23
backend/.env.sample Normal file
View File

@@ -0,0 +1,23 @@
DJANGO_DEBUG=1
DJANGO_SECRET_KEY=dev-spatial-image-enhancer-change-before-production
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,api
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
DJANGO_SECURE_SSL_REDIRECT=0
DJANGO_SESSION_COOKIE_SECURE=0
DJANGO_CSRF_COOKIE_SECURE=0
DJANGO_SECURE_HSTS_SECONDS=0
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=0
DJANGO_SECURE_HSTS_PRELOAD=0
DJANGO_LOG_LEVEL=INFO
POSTGRES_DB=enhancer
POSTGRES_USER=enhancer
POSTGRES_PASSWORD=enhancer
POSTGRES_HOST=
POSTGRES_PORT=5432
POSTGRES_CONN_MAX_AGE=60
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
CELERY_TASK_TIME_LIMIT=600
IMAGE_SESSION_TTL_HOURS=6
MAX_UPLOAD_MB=20

17
backend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libglib2.0-0 libgl1 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "enhancer_project.wsgi:application", "--bind", "0.0.0.0:8000"]

View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)

View File

@@ -0,0 +1,7 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "enhancer_project.settings")
application = get_asgi_application()

View File

@@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "enhancer_project.settings")
app = Celery("enhancer_project")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

View File

@@ -0,0 +1,34 @@
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
def env(name, default=None):
return os.environ.get(name, default)
def env_bool(name, default=False):
value = env(name)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def env_int(name, default):
value = env(name)
if value is None or value == "":
return default
return int(value)
def env_list(name, default=""):
value = env(name, default)
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]

View File

@@ -0,0 +1,145 @@
from pathlib import Path
from .env import env, env_bool, env_int, env_list
BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = env_bool("DJANGO_DEBUG", False)
SECRET_KEY = env("DJANGO_SECRET_KEY")
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = "dev-only-spatial-image-enhancer"
else:
raise RuntimeError("DJANGO_SECRET_KEY must be set when DJANGO_DEBUG is false.")
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,api")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS", "")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"corsheaders",
"rest_framework",
"processing",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "enhancer_project.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "enhancer_project.wsgi.application"
if env("POSTGRES_HOST"):
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env("POSTGRES_DB", "enhancer"),
"USER": env("POSTGRES_USER", "enhancer"),
"PASSWORD": env("POSTGRES_PASSWORD", "enhancer"),
"HOST": env("POSTGRES_HOST", "db"),
"PORT": env("POSTGRES_PORT", "5432"),
"CONN_MAX_AGE": env_int("POSTGRES_CONN_MAX_AGE", 60),
}
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
"rest_framework.parsers.MultiPartParser",
"rest_framework.parsers.FormParser",
],
}
CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")
CORS_ALLOW_CREDENTIALS = False
CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://redis:6379/0")
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", CELERY_BROKER_URL)
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = env_int("CELERY_TASK_TIME_LIMIT", 600)
IMAGE_SESSION_TTL_HOURS = env_int("IMAGE_SESSION_TTL_HOURS", 6)
MAX_UPLOAD_MB = env_int("MAX_UPLOAD_MB", 20)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = env_bool("DJANGO_SECURE_SSL_REDIRECT", not DEBUG)
SESSION_COOKIE_SECURE = env_bool("DJANGO_SESSION_COOKIE_SECURE", not DEBUG)
CSRF_COOKIE_SECURE = env_bool("DJANGO_CSRF_COOKIE_SECURE", not DEBUG)
SECURE_HSTS_SECONDS = env_int("DJANGO_SECURE_HSTS_SECONDS", 0 if DEBUG else 31536000)
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool("DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", not DEBUG)
SECURE_HSTS_PRELOAD = env_bool("DJANGO_SECURE_HSTS_PRELOAD", False)
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
}
},
"root": {
"handlers": ["console"],
"level": env("DJANGO_LOG_LEVEL", "INFO"),
},
}

View File

@@ -0,0 +1,13 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("processing.urls")),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "enhancer_project.settings")
application = get_wsgi_application()

14
backend/manage.py Normal file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "enhancer_project.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,436 @@
import base64
import math
from io import BytesIO
import cv2
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from PIL import Image
LAPLACIAN_MASK = np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]], dtype=np.float32)
SOBEL_GX = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32)
SOBEL_GY = SOBEL_GX.T
ROBERTS_GX = np.array([[1, 0], [0, -1]], dtype=np.float32)
ROBERTS_GY = np.array([[0, 1], [-1, 0]], dtype=np.float32)
class ProcessingError(ValueError):
pass
def ensure_uint8(image):
return np.clip(image, 0, 255).astype(np.uint8)
def normalize_to_uint8(image):
arr = image.astype(np.float32)
min_value = float(np.min(arr))
max_value = float(np.max(arr))
if math.isclose(min_value, max_value):
return np.zeros(arr.shape, dtype=np.uint8)
return np.round((arr - min_value) * 255.0 / (max_value - min_value)).astype(np.uint8)
def require_odd(value, name="size", minimum=3):
try:
value = int(value)
except (TypeError, ValueError) as exc:
raise ProcessingError(f"{name} must be an odd integer.") from exc
if value < minimum or value % 2 == 0:
raise ProcessingError(f"{name} must be an odd integer >= {minimum}.")
return value
def require_finite_positive(value, name):
try:
value = float(value)
except (TypeError, ValueError) as exc:
raise ProcessingError(f"{name} must be a finite positive number.") from exc
if not np.isfinite(value) or value <= 0:
raise ProcessingError(f"{name} must be a finite positive number.")
return value
def to_gray(image):
if image.ndim == 2:
return image
return cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
def gray_to_rgb(gray):
return cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB)
def histogram(image):
gray = to_gray(image)
counts = np.bincount(gray.ravel(), minlength=256).astype(np.float64)
probabilities = counts / max(gray.size, 1)
return probabilities.round(8).tolist()
def image_to_data_url(image):
pil_image = Image.fromarray(ensure_uint8(image))
buffer = BytesIO()
pil_image.save(buffer, format="PNG")
payload = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{payload}"
def data_url_to_bytes(value):
if "," in value:
value = value.split(",", 1)[1]
return base64.b64decode(value)
def decode_image(uploaded_file=None, base64_image=None):
if uploaded_file is None and not base64_image:
raise ProcessingError("Provide an image file or base64 image payload.")
if uploaded_file is not None:
raw = uploaded_file.read()
else:
raw = data_url_to_bytes(base64_image)
image = Image.open(BytesIO(raw))
image = image.convert("RGB")
return np.array(image, dtype=np.uint8)
def negative(image, params):
return 255 - image
def logarithmic(image, params):
c = require_finite_positive(params.get("c", 1.0 / math.log(2.0)), "c")
normalized = image.astype(np.float32) / 255.0
transformed = c * np.log1p(normalized)
return ensure_uint8(np.round(np.clip(transformed, 0.0, 1.0) * 255.0))
def gamma(image, params):
gamma_value = require_finite_positive(params.get("gamma", 1.0), "gamma")
c = require_finite_positive(params.get("c", 1.0), "c")
normalized = image.astype(np.float32) / 255.0
transformed = c * np.power(normalized, gamma_value)
return ensure_uint8(np.round(np.clip(transformed, 0.0, 1.0) * 255.0))
def contrast_stretch(image, params):
low = int(params.get("low", 0))
high = int(params.get("high", 255))
if low < 0 or high > 255 or low >= high:
raise ProcessingError("Contrast stretch requires 0 <= low < high <= 255.")
stretched = (image.astype(np.float32) - low) * (255.0 / (high - low))
return ensure_uint8(np.round(stretched))
def gray_slice(image, params):
start = int(params.get("start", 96))
end = int(params.get("end", 160))
if start < 0 or end > 255 or start > end:
raise ProcessingError("Gray-level slicing requires 0 <= start <= end <= 255.")
preserve = bool(params.get("preserve_background", True))
highlight = np.array(params.get("highlight", [255, 64, 64]), dtype=np.uint8)
if highlight.shape != (3,):
raise ProcessingError("highlight must be an RGB triplet.")
gray = to_gray(image)
mask = (gray >= start) & (gray <= end)
base = image.copy() if image.ndim == 3 else gray_to_rgb(gray if preserve else np.zeros_like(gray))
if not preserve:
base = np.zeros((*gray.shape, 3), dtype=np.uint8)
base[mask] = highlight
return base
def bit_plane(image, params):
bit = int(params.get("bit", 7))
if bit < 0 or bit > 7:
raise ProcessingError("bit must be between 0 and 7.")
plane = ((to_gray(image) >> bit) & 1) * 255
return gray_to_rgb(plane.astype(np.uint8))
def histogram_equalization(image, params):
gray = to_gray(image)
counts = np.bincount(gray.ravel(), minlength=256)
cdf = counts.cumsum().astype(np.float64)
nonzero = cdf[cdf > 0]
if nonzero.size == 0:
return gray_to_rgb(gray)
cdf_min = nonzero[0]
denom = gray.size - cdf_min
if denom <= 0:
equalized = np.zeros_like(gray)
else:
lut = np.round((cdf - cdf_min) / denom * 255.0).clip(0, 255).astype(np.uint8)
equalized = lut[gray]
return gray_to_rgb(equalized)
def target_cdf_from_params(params):
if "cdf" in params:
cdf = np.array(params["cdf"], dtype=np.float64)
if cdf.shape != (256,) or np.any(np.diff(cdf) < 0):
raise ProcessingError("cdf must contain 256 non-decreasing values.")
if cdf[-1] <= 0:
raise ProcessingError("cdf must end with a positive value.")
return cdf / cdf[-1]
mode = params.get("target", "uniform")
levels = np.arange(256, dtype=np.float64)
if mode == "dark":
pdf = np.exp(-levels / 64.0)
elif mode == "bright":
pdf = np.exp(-(255.0 - levels) / 64.0)
elif mode == "bimodal":
pdf = np.exp(-((levels - 72.0) ** 2) / (2 * 22.0**2)) + np.exp(-((levels - 190.0) ** 2) / (2 * 28.0**2))
else:
pdf = np.ones(256, dtype=np.float64)
cdf = np.cumsum(pdf)
return cdf / cdf[-1]
def histogram_matching(image, params):
gray = to_gray(image)
source_counts = np.bincount(gray.ravel(), minlength=256).astype(np.float64)
source_cdf = np.cumsum(source_counts)
source_cdf /= source_cdf[-1]
target_cdf = target_cdf_from_params(params)
target_levels = np.arange(256)
mapping = np.interp(source_cdf, target_cdf, target_levels).round().clip(0, 255).astype(np.uint8)
return gray_to_rgb(mapping[gray])
def local_equalization(image, params):
size = require_odd(params.get("size", 7), "size")
gray = to_gray(image)
radius = size // 2
padded = np.pad(gray, radius, mode="edge")
windows = sliding_window_view(padded, (size, size))
centers = gray[..., None, None]
ranks = np.count_nonzero(windows <= centers, axis=(-1, -2))
equalized = np.round(ranks * 255.0 / (size * size)).astype(np.uint8)
return gray_to_rgb(equalized)
def apply_kernel(image, kernel, normalize_derivative=False):
source = image.astype(np.float32)
if image.ndim == 2:
filtered = cv2.filter2D(source, cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT)
else:
channels = [cv2.filter2D(source[:, :, idx], cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT) for idx in range(source.shape[2])]
filtered = np.stack(channels, axis=2)
if normalize_derivative:
return normalize_to_uint8(filtered)
return ensure_uint8(np.round(filtered))
def filter_float(image, kernel):
source = image.astype(np.float32)
if image.ndim == 2:
return cv2.filter2D(source, cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT)
channels = [cv2.filter2D(source[:, :, idx], cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT) for idx in range(source.shape[2])]
return np.stack(channels, axis=2)
def box_filter(image, params):
size = require_odd(params.get("size", 3), "size")
return cv2.blur(image, (size, size), borderType=cv2.BORDER_REFLECT)
def weighted_average(image, params):
size = require_odd(params.get("size", 3), "size")
if "kernel" in params:
kernel = np.array(params["kernel"], dtype=np.float32)
if kernel.shape != (size, size):
raise ProcessingError("kernel dimensions must match size.")
elif size == 3:
kernel = np.array([[1, 2, 1], [2, 4, 2], [1, 2, 1]], dtype=np.float32)
else:
sigma = max(size / 6.0, 0.1)
ax = np.arange(-(size // 2), size // 2 + 1, dtype=np.float32)
xx, yy = np.meshgrid(ax, ax)
kernel = np.exp(-(xx**2 + yy**2) / (2.0 * sigma**2))
total = float(np.sum(kernel))
if math.isclose(total, 0.0):
raise ProcessingError("weighted average kernel sum must not be zero.")
return apply_kernel(image, kernel / total)
def median_filter(image, params):
size = require_odd(params.get("size", 3), "size")
return cv2.medianBlur(image, size)
def laplacian(image, params):
mode = params.get("mode", "sharpen")
lap = filter_float(image, LAPLACIAN_MASK)
if mode == "edge":
return normalize_to_uint8(lap)
sign = params.get("sign", "add")
source = image.astype(np.float32)
sharpened = source + lap if sign == "add" else source - lap
return ensure_uint8(np.round(sharpened))
def high_boost(image, params):
amplification = float(params.get("amplification", 1.5))
if not np.isfinite(amplification) or amplification < 1.0:
raise ProcessingError("amplification must be >= 1.")
size = require_odd(params.get("size", 3), "size")
blurred = cv2.blur(image, (size, size), borderType=cv2.BORDER_REFLECT).astype(np.float32)
boosted = amplification * image.astype(np.float32) - blurred
return ensure_uint8(np.round(boosted))
def gradient_magnitude(image, gx_kernel, gy_kernel):
gray = to_gray(image).astype(np.float32)
gx = cv2.filter2D(gray, cv2.CV_32F, gx_kernel, borderType=cv2.BORDER_REFLECT)
gy = cv2.filter2D(gray, cv2.CV_32F, gy_kernel, borderType=cv2.BORDER_REFLECT)
magnitude = np.sqrt(gx**2 + gy**2)
return gray_to_rgb(normalize_to_uint8(magnitude))
def sobel(image, params):
return gradient_magnitude(image, SOBEL_GX, SOBEL_GY)
def roberts(image, params):
return gradient_magnitude(image, ROBERTS_GX, ROBERTS_GY)
def rgb_to_hsi(image):
rgb = image.astype(np.float32) / 255.0
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
numerator = 0.5 * ((r - g) + (r - b))
denominator = np.sqrt((r - g) ** 2 + (r - b) * (g - b)) + 1e-8
theta = np.arccos(np.clip(numerator / denominator, -1.0, 1.0))
h = np.where(b <= g, theta, 2.0 * np.pi - theta) / (2.0 * np.pi)
total = r + g + b
s = np.where(total <= 1e-8, 0.0, 1.0 - 3.0 * np.minimum(np.minimum(r, g), b) / total)
i = total / 3.0
return np.stack([h, s, i], axis=-1)
def hsi_to_rgb(hsi):
h = (hsi[..., 0] % 1.0) * 2.0 * np.pi
s = np.clip(hsi[..., 1], 0.0, 1.0)
i = np.clip(hsi[..., 2], 0.0, 1.0)
r = np.zeros_like(h)
g = np.zeros_like(h)
b = np.zeros_like(h)
sector0 = h < 2.0 * np.pi / 3.0
sector1 = (h >= 2.0 * np.pi / 3.0) & (h < 4.0 * np.pi / 3.0)
sector2 = ~sector0 & ~sector1
h0 = h[sector0]
b[sector0] = i[sector0] * (1.0 - s[sector0])
r[sector0] = i[sector0] * (1.0 + s[sector0] * np.cos(h0) / (np.cos(np.pi / 3.0 - h0) + 1e-8))
g[sector0] = 3.0 * i[sector0] - (r[sector0] + b[sector0])
h1 = h[sector1] - 2.0 * np.pi / 3.0
r[sector1] = i[sector1] * (1.0 - s[sector1])
g[sector1] = i[sector1] * (1.0 + s[sector1] * np.cos(h1) / (np.cos(np.pi / 3.0 - h1) + 1e-8))
b[sector1] = 3.0 * i[sector1] - (r[sector1] + g[sector1])
h2 = h[sector2] - 4.0 * np.pi / 3.0
g[sector2] = i[sector2] * (1.0 - s[sector2])
b[sector2] = i[sector2] * (1.0 + s[sector2] * np.cos(h2) / (np.cos(np.pi / 3.0 - h2) + 1e-8))
r[sector2] = 3.0 * i[sector2] - (g[sector2] + b[sector2])
return ensure_uint8(np.round(np.clip(np.stack([r, g, b], axis=-1), 0.0, 1.0) * 255.0))
def hsi_intensity_filter(image, params):
method = params.get("method", "smooth")
hsi = rgb_to_hsi(image)
intensity = np.round(hsi[..., 2] * 255.0).astype(np.uint8)
if method == "sharpen":
filtered = laplacian(intensity, {"mode": "sharpen", "sign": params.get("sign", "add")})
else:
filtered = box_filter(intensity, {"size": params.get("size", 3)})
hsi[..., 2] = filtered.astype(np.float32) / 255.0
return hsi_to_rgb(hsi)
def pseudo_color_slices(image, params):
gray = to_gray(image)
slices = params.get(
"slices",
[
{"start": 0, "end": 85, "color": [59, 130, 246]},
{"start": 86, "end": 170, "color": [34, 197, 94]},
{"start": 171, "end": 255, "color": [239, 68, 68]},
],
)
output = np.zeros((*gray.shape, 3), dtype=np.uint8)
for item in slices:
start = int(item["start"])
end = int(item["end"])
color = np.array(item["color"], dtype=np.uint8)
if start < 0 or end > 255 or start > end or color.shape != (3,):
raise ProcessingError("Each pseudo-color slice requires start/end in 0..255 and an RGB color.")
output[(gray >= start) & (gray <= end)] = color
return output
def gray_to_color_sinusoidal(image, params):
gray = to_gray(image).astype(np.float32) / 255.0
hue_frequency = float(params.get("hue_frequency", 1.0))
saturation_frequency = float(params.get("saturation_frequency", 0.5))
intensity_frequency = float(params.get("intensity_frequency", 0.25))
h = (0.5 + 0.5 * np.sin(2.0 * np.pi * hue_frequency * gray)) % 1.0
s = 0.55 + 0.4 * np.sin(2.0 * np.pi * saturation_frequency * gray + np.pi / 3.0)
i = 0.5 + 0.45 * np.sin(2.0 * np.pi * intensity_frequency * gray - np.pi / 2.0)
return hsi_to_rgb(np.stack([h, np.clip(s, 0, 1), np.clip(i, 0, 1)], axis=-1))
OPERATIONS = {
"negative": negative,
"log": logarithmic,
"gamma": gamma,
"contrast_stretch": contrast_stretch,
"gray_slice": gray_slice,
"bit_plane": bit_plane,
"hist_equalization": histogram_equalization,
"hist_match": histogram_matching,
"local_equalization": local_equalization,
"box_filter": box_filter,
"weighted_average": weighted_average,
"median_filter": median_filter,
"laplacian": laplacian,
"high_boost": high_boost,
"sobel": sobel,
"roberts": roberts,
"hsi_intensity_filter": hsi_intensity_filter,
"pseudo_color_slices": pseudo_color_slices,
"gray_to_color_sinusoidal": gray_to_color_sinusoidal,
}
def process_image(image, operation, params=None):
params = params or {}
if operation not in OPERATIONS:
raise ProcessingError(f"Unsupported operation '{operation}'.")
return ensure_uint8(OPERATIONS[operation](ensure_uint8(image), params))
def subtract_images(left, right):
verify_registration([left, right])
diff = left.astype(np.float32) - right.astype(np.float32)
return normalize_to_uint8(np.abs(diff))
def average_images(images):
verify_registration(images)
stack = np.stack([image.astype(np.float32) for image in images], axis=0)
return ensure_uint8(np.round(np.mean(stack, axis=0)))
def verify_registration(images):
if len(images) < 2:
raise ProcessingError("At least two registered images are required.")
shape = images[0].shape
if any(image.shape != shape for image in images[1:]):
raise ProcessingError("Images must have identical width, height, and channel count.")

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ProcessingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "processing"

View File

@@ -0,0 +1,43 @@
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="ImageSession",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("original_image", models.CharField(max_length=255)),
("processed_image", models.CharField(blank=True, max_length=255)),
("width", models.PositiveIntegerField()),
("height", models.PositiveIntegerField()),
("channels", models.PositiveSmallIntegerField()),
("color_mode", models.CharField(max_length=16)),
("original_histogram", models.JSONField(default=list)),
("processed_histogram", models.JSONField(blank=True, default=list)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField()),
],
),
migrations.CreateModel(
name="ProcessingJob",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("operation", models.CharField(max_length=64)),
("params", models.JSONField(blank=True, default=dict)),
("status", models.CharField(choices=[("pending", "Pending"), ("running", "Running"), ("complete", "Complete"), ("failed", "Failed")], default="pending", max_length=16)),
("progress", models.PositiveSmallIntegerField(default=0)),
("result_image", models.CharField(blank=True, max_length=255)),
("result_histogram", models.JSONField(blank=True, default=list)),
("error", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
),
]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,53 @@
import uuid
from django.conf import settings
from django.db import models
from django.utils import timezone
class ImageSession(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
original_image = models.CharField(max_length=255)
processed_image = models.CharField(max_length=255, blank=True)
width = models.PositiveIntegerField()
height = models.PositiveIntegerField()
channels = models.PositiveSmallIntegerField()
color_mode = models.CharField(max_length=16)
original_histogram = models.JSONField(default=list)
processed_histogram = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
def save(self, *args, **kwargs):
if not self.expires_at:
self.expires_at = timezone.now() + timezone.timedelta(hours=settings.IMAGE_SESSION_TTL_HOURS)
super().save(*args, **kwargs)
@property
def expired(self):
return timezone.now() >= self.expires_at
class ProcessingJob(models.Model):
STATUS_PENDING = "pending"
STATUS_RUNNING = "running"
STATUS_COMPLETE = "complete"
STATUS_FAILED = "failed"
STATUS_CHOICES = [
(STATUS_PENDING, "Pending"),
(STATUS_RUNNING, "Running"),
(STATUS_COMPLETE, "Complete"),
(STATUS_FAILED, "Failed"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
operation = models.CharField(max_length=64)
params = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
progress = models.PositiveSmallIntegerField(default=0)
result_image = models.CharField(max_length=255, blank=True)
result_histogram = models.JSONField(default=list, blank=True)
error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

View File

@@ -0,0 +1,9 @@
from .models import ImageSession, ProcessingJob
def image_session_get(*, session_id):
return ImageSession.objects.filter(id=session_id).first()
def processing_job_get(*, job_id):
return ProcessingJob.objects.filter(id=job_id).first()

View File

@@ -0,0 +1,84 @@
import time
from django.conf import settings
from django.utils import timezone
from .algorithms import ProcessingError, decode_image, histogram, process_image
from .models import ImageSession, ProcessingJob
from .storage import load_image_array, payload_for_image, save_image_array
from .tasks import run_batch_job
def image_session_create(*, uploaded_file=None, image_base64=None):
if uploaded_file and uploaded_file.size > settings.MAX_UPLOAD_MB * 1024 * 1024:
raise ProcessingError(f"Upload exceeds {settings.MAX_UPLOAD_MB} MB.")
image = decode_image(uploaded_file=uploaded_file, base64_image=image_base64)
relative_path = save_image_array(image, "original")
hist = histogram(image)
session = ImageSession.objects.create(
original_image=relative_path,
width=image.shape[1],
height=image.shape[0],
channels=image.shape[2] if image.ndim == 3 else 1,
color_mode="RGB" if image.ndim == 3 else "L",
original_histogram=hist,
expires_at=timezone.now() + timezone.timedelta(hours=settings.IMAGE_SESSION_TTL_HOURS),
)
payload = {
"session_id": str(session.id),
"width": session.width,
"height": session.height,
"channels": session.channels,
"color_mode": session.color_mode,
"original_histogram": hist,
"expires_at": session.expires_at.isoformat(),
}
payload.update(payload_for_image(image, relative_path))
return payload
def image_session_process(*, session, operation, params):
if session.expired:
raise ProcessingError("Image session has expired.")
started_at = time.perf_counter()
source = load_image_array(session.original_image)
result = process_image(source, operation, params)
relative_path = save_image_array(result, f"processed-{operation}")
hist = histogram(result)
session.processed_image = relative_path
session.processed_histogram = hist
session.save(update_fields=["processed_image", "processed_histogram"])
payload = {
"session_id": str(session.id),
"operation": operation,
"params": params,
"processed_histogram": hist,
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
}
payload.update(payload_for_image(result, relative_path))
return payload
def batch_job_create(*, operation, session_ids, params=None):
job = ProcessingJob.objects.create(operation=operation, params=params or {})
run_batch_job.delay(str(job.id), operation, [str(session_id) for session_id in session_ids])
return job
def processing_job_payload(*, job):
payload = {
"job_id": str(job.id),
"operation": job.operation,
"status": job.status,
"progress": job.progress,
"error": job.error,
"result_histogram": job.result_histogram,
}
if job.result_image:
image = load_image_array(job.result_image)
payload.update(payload_for_image(image, job.result_image))
return payload

View File

@@ -0,0 +1,57 @@
from pathlib import Path
from uuid import uuid4
import cv2
import numpy as np
from django.conf import settings
from .algorithms import ProcessingError, ensure_uint8, image_to_data_url
def session_dir():
path = Path(settings.MEDIA_ROOT) / "sessions"
path.mkdir(parents=True, exist_ok=True)
return path
def save_image_array(image, prefix="image"):
filename = f"sessions/{prefix}-{uuid4().hex}.png"
path = Path(settings.MEDIA_ROOT) / filename
path.parent.mkdir(parents=True, exist_ok=True)
rgb = ensure_uint8(image)
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
ok, encoded = cv2.imencode(".png", bgr)
if not ok:
raise ProcessingError("Unable to encode image for temporary storage.")
path.write_bytes(encoded.tobytes())
return filename
def load_image_array(relative_path):
path = Path(settings.MEDIA_ROOT) / relative_path
if not path.exists():
raise ProcessingError("Temporary image file is missing or unreadable.")
raw = np.frombuffer(path.read_bytes(), dtype=np.uint8)
image = cv2.imdecode(raw, cv2.IMREAD_COLOR)
if image is None:
raise ProcessingError("Temporary image file is missing or unreadable.")
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.uint8)
def delete_relative_file(relative_path):
if not relative_path:
return
path = (Path(settings.MEDIA_ROOT) / relative_path).resolve()
media_root = Path(settings.MEDIA_ROOT).resolve()
if media_root not in path.parents and path != media_root:
return
if path.exists():
path.unlink()
def payload_for_image(image, relative_path):
return {
"image_path": relative_path,
"image_url": f"{settings.MEDIA_URL}{relative_path}",
"image_data": image_to_data_url(image),
}

View File

@@ -0,0 +1,56 @@
from pathlib import Path
from celery import shared_task
from django.utils import timezone
from .algorithms import ProcessingError, average_images, histogram, subtract_images
from .models import ImageSession, ProcessingJob
from .storage import delete_relative_file, load_image_array, save_image_array
@shared_task(bind=True)
def run_batch_job(self, job_id, operation, session_ids):
job = ProcessingJob.objects.get(id=job_id)
job.status = ProcessingJob.STATUS_RUNNING
job.progress = 10
job.save(update_fields=["status", "progress", "updated_at"])
try:
sessions = list(ImageSession.objects.filter(id__in=session_ids))
if len(sessions) != len(session_ids):
raise ProcessingError("One or more sessions do not exist.")
images = [load_image_array(session.processed_image or session.original_image) for session in sessions]
job.progress = 45
job.save(update_fields=["progress", "updated_at"])
if operation == "average":
result = average_images(images)
elif operation == "subtract":
result = subtract_images(images[0], images[1])
else:
raise ProcessingError(f"Unsupported batch operation '{operation}'.")
relative_path = save_image_array(result, f"batch-{operation}")
job.status = ProcessingJob.STATUS_COMPLETE
job.progress = 100
job.result_image = relative_path
job.result_histogram = histogram(result)
job.error = ""
job.save(update_fields=["status", "progress", "result_image", "result_histogram", "error", "updated_at"])
except Exception as exc:
job.status = ProcessingJob.STATUS_FAILED
job.error = str(exc)
job.progress = 100
job.save(update_fields=["status", "error", "progress", "updated_at"])
raise
@shared_task
def cleanup_expired_sessions():
expired = ImageSession.objects.filter(expires_at__lt=timezone.now())
for session in expired:
delete_relative_file(session.original_image)
delete_relative_file(session.processed_image)
expired.delete()
old_jobs = ProcessingJob.objects.filter(created_at__lt=timezone.now() - timezone.timedelta(hours=24))
for job in old_jobs:
delete_relative_file(job.result_image)
old_jobs.delete()

View File

@@ -0,0 +1,61 @@
import numpy as np
from django.test import SimpleTestCase
from processing.algorithms import (
LAPLACIAN_MASK,
ProcessingError,
average_images,
gamma,
histogram_equalization,
median_filter,
negative,
roberts,
sobel,
subtract_images,
verify_registration,
)
class AlgorithmTests(SimpleTestCase):
def test_negative_transform_uses_l_minus_one(self):
image = np.array([[[0, 127, 255]]], dtype=np.uint8)
result = negative(image, {})
np.testing.assert_array_equal(result, np.array([[[255, 128, 0]]], dtype=np.uint8))
def test_gamma_identity(self):
image = np.array([[[0, 128, 255]]], dtype=np.uint8)
result = gamma(image, {"gamma": 1, "c": 1})
np.testing.assert_array_equal(result, image)
def test_laplacian_mask_sums_to_zero(self):
self.assertEqual(int(LAPLACIAN_MASK.sum()), 0)
def test_histogram_equalization_spreads_two_levels(self):
image = np.array([[0, 0], [255, 255]], dtype=np.uint8)
result = histogram_equalization(image, {})
expected = np.dstack([image, image, image])
np.testing.assert_array_equal(result, expected)
def test_median_removes_impulse_noise(self):
image = np.full((3, 3, 3), 100, dtype=np.uint8)
image[1, 1] = 255
result = median_filter(image, {"size": 3})
self.assertEqual(int(result[1, 1, 0]), 100)
def test_gradient_outputs_are_display_normalized(self):
image = np.zeros((5, 5, 3), dtype=np.uint8)
image[:, 3:] = 255
self.assertEqual(sobel(image, {}).dtype, np.uint8)
self.assertEqual(roberts(image, {}).dtype, np.uint8)
def test_arithmetic_requires_registered_shapes(self):
left = np.zeros((2, 2, 3), dtype=np.uint8)
right = np.zeros((3, 2, 3), dtype=np.uint8)
with self.assertRaises(ProcessingError):
verify_registration([left, right])
def test_average_and_subtraction(self):
left = np.zeros((2, 2, 3), dtype=np.uint8)
right = np.full((2, 2, 3), 100, dtype=np.uint8)
self.assertEqual(int(average_images([left, right])[0, 0, 0]), 50)
self.assertEqual(int(subtract_images(left, right)[0, 0, 0]), 0)

View File

@@ -0,0 +1,64 @@
import tempfile
from io import BytesIO
from pathlib import Path
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from PIL import Image
from rest_framework.test import APIClient
def png_upload(color=(32, 64, 128), size=(4, 4), name="sample.png"):
buffer = BytesIO()
Image.new("RGB", size, color).save(buffer, format="PNG")
return SimpleUploadedFile(name, buffer.getvalue(), content_type="image/png")
class ApiTests(TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.override = override_settings(MEDIA_ROOT=Path(self.tmp.name), IMAGE_SESSION_TTL_HOURS=1)
self.override.enable()
self.client = APIClient()
def tearDown(self):
self.override.disable()
self.tmp.cleanup()
def test_upload_and_process(self):
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
self.assertEqual(upload.status_code, 201)
session_id = upload.data["session_id"]
self.assertEqual(len(upload.data["original_histogram"]), 256)
processed = self.client.post(
"/api/process/",
{"session_id": session_id, "operation": "gamma", "params": {"gamma": 1, "c": 1}},
format="json",
)
self.assertEqual(processed.status_code, 200)
self.assertEqual(len(processed.data["processed_histogram"]), 256)
self.assertTrue(processed.data["image_data"].startswith("data:image/png;base64,"))
def test_invalid_kernel_rejected(self):
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
processed = self.client.post(
"/api/process/",
{"session_id": upload.data["session_id"], "operation": "median_filter", "params": {"size": 4}},
format="json",
)
self.assertEqual(processed.status_code, 400)
@patch("processing.services.run_batch_job.delay")
def test_batch_returns_job_id(self, delay):
first = self.client.post("/api/images/", {"image": png_upload(name="a.png")}, format="multipart")
second = self.client.post("/api/images/", {"image": png_upload(color=(96, 96, 96), name="b.png")}, format="multipart")
response = self.client.post(
"/api/batch/",
{"operation": "average", "session_ids": [first.data["session_id"], second.data["session_id"]]},
format="json",
)
self.assertEqual(response.status_code, 202)
self.assertIn("job_id", response.data)
delay.assert_called_once()

View File

@@ -0,0 +1,12 @@
from django.urls import path
from .views import BatchView, HealthView, ImageUploadView, JobDetailView, ProcessView
urlpatterns = [
path("health/", HealthView.as_view(), name="health"),
path("images/", ImageUploadView.as_view(), name="image-upload"),
path("process/", ProcessView.as_view(), name="process"),
path("batch/", BatchView.as_view(), name="batch"),
path("jobs/<uuid:job_id>/", JobDetailView.as_view(), name="job-detail"),
]

View File

@@ -0,0 +1,89 @@
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from .algorithms import ProcessingError
from .selectors import image_session_get, processing_job_get
from .services import batch_job_create, image_session_create, image_session_process, processing_job_payload
def error_response(message, code=status.HTTP_400_BAD_REQUEST):
return Response({"detail": message}, status=code)
class ImageUploadView(APIView):
class InputSerializer(serializers.Serializer):
image = serializers.ImageField(required=False)
image_base64 = serializers.CharField(required=False, allow_blank=False)
def validate(self, attrs):
if not attrs.get("image") and not attrs.get("image_base64"):
raise serializers.ValidationError("Provide image or image_base64.")
return attrs
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
payload = image_session_create(
uploaded_file=serializer.validated_data.get("image"),
image_base64=serializer.validated_data.get("image_base64"),
)
return Response(payload, status=status.HTTP_201_CREATED)
except ProcessingError as exc:
return error_response(str(exc))
class ProcessView(APIView):
class InputSerializer(serializers.Serializer):
session_id = serializers.UUIDField()
operation = serializers.CharField()
params = serializers.DictField(required=False, default=dict)
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
session = image_session_get(session_id=serializer.validated_data["session_id"])
if session is None:
return error_response("Image session does not exist.", status.HTTP_404_NOT_FOUND)
try:
payload = image_session_process(
session=session,
operation=serializer.validated_data["operation"],
params=serializer.validated_data.get("params", {}),
)
return Response(payload)
except ProcessingError as exc:
code = status.HTTP_410_GONE if str(exc) == "Image session has expired." else status.HTTP_400_BAD_REQUEST
return error_response(str(exc), code)
class BatchView(APIView):
class InputSerializer(serializers.Serializer):
operation = serializers.ChoiceField(choices=["average", "subtract"])
session_ids = serializers.ListField(child=serializers.UUIDField(), min_length=2)
params = serializers.DictField(required=False, default=dict)
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
job = batch_job_create(
operation=serializer.validated_data["operation"],
session_ids=serializer.validated_data["session_ids"],
params=serializer.validated_data.get("params", {}),
)
return Response({"job_id": str(job.id), "status": job.status}, status=status.HTTP_202_ACCEPTED)
class JobDetailView(APIView):
def get(self, request, job_id):
job = processing_job_get(job_id=job_id)
if job is None:
return error_response("Job does not exist.", status.HTTP_404_NOT_FOUND)
return Response(processing_job_payload(job=job))
class HealthView(APIView):
def get(self, request):
return Response({"status": "ok"})

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
Django==5.0.7
djangorestframework==3.15.2
django-cors-headers==4.4.0
python-dotenv==1.0.1
whitenoise==6.7.0
celery==5.4.0
redis==5.0.7
psycopg2-binary==2.9.9
opencv-python-headless==4.10.0.84
numpy==1.26.4
Pillow==10.4.0
gunicorn==22.0.0

116
docker-compose.yml Normal file
View File

@@ -0,0 +1,116 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-enhancer}
POSTGRES_USER: ${POSTGRES_USER:-enhancer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-enhancer}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
api:
build: ./backend
command: sh -c "python manage.py migrate && python manage.py collectstatic --noinput && gunicorn enhancer_project.wsgi:application --bind 0.0.0.0:8000"
env_file:
- ./backend/.env
environment:
DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,api}
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS:-http://localhost,https://localhost}
DJANGO_SECURE_SSL_REDIRECT: ${DJANGO_SECURE_SSL_REDIRECT:-0}
DJANGO_SESSION_COOKIE_SECURE: ${DJANGO_SESSION_COOKIE_SECURE:-0}
DJANGO_CSRF_COOKIE_SECURE: ${DJANGO_CSRF_COOKIE_SECURE:-0}
DJANGO_SECURE_HSTS_SECONDS: ${DJANGO_SECURE_HSTS_SECONDS:-0}
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS: ${DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS:-0}
DJANGO_SECURE_HSTS_PRELOAD: ${DJANGO_SECURE_HSTS_PRELOAD:-0}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173,http://localhost}
POSTGRES_HOST: db
POSTGRES_DB: ${POSTGRES_DB:-enhancer}
POSTGRES_USER: ${POSTGRES_USER:-enhancer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-enhancer}
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
IMAGE_SESSION_TTL_HOURS: ${IMAGE_SESSION_TTL_HOURS:-6}
volumes:
- media_data:/app/media
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
worker:
build: ./backend
command: celery -A enhancer_project worker --loglevel=info
env_file:
- ./backend/.env
environment:
DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,api}
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS:-http://localhost,https://localhost}
DJANGO_SECURE_SSL_REDIRECT: ${DJANGO_SECURE_SSL_REDIRECT:-0}
DJANGO_SESSION_COOKIE_SECURE: ${DJANGO_SESSION_COOKIE_SECURE:-0}
DJANGO_CSRF_COOKIE_SECURE: ${DJANGO_CSRF_COOKIE_SECURE:-0}
DJANGO_SECURE_HSTS_SECONDS: ${DJANGO_SECURE_HSTS_SECONDS:-0}
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS: ${DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS:-0}
DJANGO_SECURE_HSTS_PRELOAD: ${DJANGO_SECURE_HSTS_PRELOAD:-0}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173,http://localhost}
POSTGRES_HOST: db
POSTGRES_DB: ${POSTGRES_DB:-enhancer}
POSTGRES_USER: ${POSTGRES_USER:-enhancer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-enhancer}
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
IMAGE_SESSION_TTL_HOURS: ${IMAGE_SESSION_TTL_HOURS:-6}
volumes:
- media_data:/app/media
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
web:
build: ./frontend
depends_on:
- api
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
environment:
CADDY_DOMAIN: ${CADDY_DOMAIN:-localhost}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
- api
volumes:
postgres_data:
redis_data:
media_data:
caddy_data:
caddy_config:

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"
}
});