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