feat(v1): add basic backend and frontend

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

8
backend/.dockerignore Normal file
View File

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

View File

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

23
backend/.env.sample Normal file
View File

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

17
backend/Dockerfile Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

14
backend/manage.py Normal file
View File

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
backend/requirements.txt Normal file
View File

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