feat(v1): add basic backend and frontend
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal 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
16
.env.sample
Normal 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
15
.gitignore
vendored
Normal 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
15
Caddyfile
Normal 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
58
README.md
Normal 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
8
backend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
db.sqlite3
|
||||
media/
|
||||
staticfiles/
|
||||
.env
|
||||
.env.local
|
||||
23
backend/.env.production.sample
Normal file
23
backend/.env.production.sample
Normal 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
23
backend/.env.sample
Normal 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
17
backend/Dockerfile
Normal 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"]
|
||||
3
backend/enhancer_project/__init__.py
Normal file
3
backend/enhancer_project/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
7
backend/enhancer_project/asgi.py
Normal file
7
backend/enhancer_project/asgi.py
Normal 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()
|
||||
9
backend/enhancer_project/celery.py
Normal file
9
backend/enhancer_project/celery.py
Normal 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()
|
||||
34
backend/enhancer_project/env.py
Normal file
34
backend/enhancer_project/env.py
Normal 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()]
|
||||
145
backend/enhancer_project/settings.py
Normal file
145
backend/enhancer_project/settings.py
Normal 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"),
|
||||
},
|
||||
}
|
||||
13
backend/enhancer_project/urls.py
Normal file
13
backend/enhancer_project/urls.py
Normal 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)
|
||||
7
backend/enhancer_project/wsgi.py
Normal file
7
backend/enhancer_project/wsgi.py
Normal 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
14
backend/manage.py
Normal 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()
|
||||
1
backend/processing/__init__.py
Normal file
1
backend/processing/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
436
backend/processing/algorithms.py
Normal file
436
backend/processing/algorithms.py
Normal 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.")
|
||||
6
backend/processing/apps.py
Normal file
6
backend/processing/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProcessingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "processing"
|
||||
43
backend/processing/migrations/0001_initial.py
Normal file
43
backend/processing/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
1
backend/processing/migrations/__init__.py
Normal file
1
backend/processing/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
53
backend/processing/models.py
Normal file
53
backend/processing/models.py
Normal 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)
|
||||
9
backend/processing/selectors.py
Normal file
9
backend/processing/selectors.py
Normal 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()
|
||||
84
backend/processing/services.py
Normal file
84
backend/processing/services.py
Normal 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
|
||||
57
backend/processing/storage.py
Normal file
57
backend/processing/storage.py
Normal 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),
|
||||
}
|
||||
56
backend/processing/tasks.py
Normal file
56
backend/processing/tasks.py
Normal 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()
|
||||
61
backend/processing/tests/test_algorithms.py
Normal file
61
backend/processing/tests/test_algorithms.py
Normal 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)
|
||||
64
backend/processing/tests/test_api.py
Normal file
64
backend/processing/tests/test_api.py
Normal 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()
|
||||
12
backend/processing/urls.py
Normal file
12
backend/processing/urls.py
Normal 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"),
|
||||
]
|
||||
89
backend/processing/views.py
Normal file
89
backend/processing/views.py
Normal 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
12
backend/requirements.txt
Normal 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
116
docker-compose.yml
Normal 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
7
frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.vite/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
1
frontend/.env.sample
Normal file
1
frontend/.env.sample
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE=http://localhost:8000
|
||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
11
frontend/nginx.conf
Normal 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
3674
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
152
frontend/src/App.jsx
Normal file
152
frontend/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
frontend/src/App.real-render.test.jsx
Normal file
9
frontend/src/App.real-render.test.jsx
Normal 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
15
frontend/src/App.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
49
frontend/src/components/CanvasPane.jsx
Normal file
49
frontend/src/components/CanvasPane.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
frontend/src/components/Controls.jsx
Normal file
167
frontend/src/components/Controls.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/HistogramPanel.jsx
Normal file
36
frontend/src/components/HistogramPanel.jsx
Normal 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
42
frontend/src/lib/api.js
Normal 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);
|
||||
}
|
||||
14
frontend/src/lib/debounce.js
Normal file
14
frontend/src/lib/debounce.js
Normal 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
10
frontend/src/main.jsx
Normal 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>
|
||||
);
|
||||
29
frontend/src/styles/app.css
Normal file
29
frontend/src/styles/app.css
Normal 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;
|
||||
}
|
||||
1
frontend/src/test-setup.js
Normal file
1
frontend/src/test-setup.js
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal 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
18
frontend/vite.config.js
Normal 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"
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user