feat(v1): add basic backend and frontend
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user