diff --git a/.env.example b/.env.example deleted file mode 100644 index 552299e..0000000 --- a/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -CADDY_DOMAIN=localhost -DJANGO_DEBUG=0 -DJANGO_SECRET_KEY=replace-with-a-long-random-value -DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,api -DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost,https://localhost -CORS_ALLOWED_ORIGINS=http://localhost,http://localhost:5173 -DJANGO_SECURE_SSL_REDIRECT=0 -DJANGO_SESSION_COOKIE_SECURE=0 -DJANGO_CSRF_COOKIE_SECURE=0 -DJANGO_SECURE_HSTS_SECONDS=0 -DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=0 -DJANGO_SECURE_HSTS_PRELOAD=0 -POSTGRES_DB=enhancer -POSTGRES_USER=enhancer -POSTGRES_PASSWORD=enhancer -IMAGE_SESSION_TTL_HOURS=6 diff --git a/.env.sample b/.env.sample index b4672d9..bc813c0 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1,5 @@ CADDY_DOMAIN=example.com +BACKEND_ENV_FILE=./backend/.env DJANGO_DEBUG=0 DJANGO_SECRET_KEY=replace-with-a-long-random-secret DJANGO_ALLOWED_HOSTS=example.com,www.example.com,api diff --git a/.gitignore b/.gitignore index f262369..dd08bbe 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ db.sqlite3 backend/media/ backend/staticfiles/ .env +.env.production +backend/.env.production +frontend/.env.production node_modules/ dist/ coverage/ diff --git a/README.md b/README.md index 00f9f31..869c32e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Production-grade public SPA for spatial-domain image enhancement using Django REST Framework, OpenCV, NumPy, React, Tailwind CSS, Celery, Redis, PostgreSQL, Docker Compose, and Caddy. +![screenshot](./screenshot.png) + ## Local Development Backend: @@ -48,6 +50,48 @@ The Django app follows the HackSoftware Django Styleguide pattern: - `processing/selectors.py` contains database fetch helpers. - Settings are environment-driven through `backend/.env`. +## Algorithms + +The app is organized as a small MATLAB-like image workspace. Each operation creates a new image state, so you can compare results, keep useful steps, and delete unwanted states. + +### Basic Workspace + +- **Histogram view** shows how pixel values are distributed. For gray images it uses one intensity histogram; for RGB images it also shows R, G, and B channels. Formula: `p(r_k) = n_k / n`. +- **Histogram equalization** improves contrast by spreading gray levels using the cumulative histogram. Formula: `s_k = round(255 * CDF(r_k))`. +- **Add images** combines registered images by summing pixels and clipping to display range. Formula: `g = f1 + f2`. +- **Subtract images** highlights differences between registered images. Formula: `g = normalize(|f1 - f2|)`. +- **Dot product** multiplies registered image pixels element by element. Formula: `g = normalize(f1 * f2)`. +- **Average K images** reduces independent noise by averaging registered states. Formula: `g = (1/K) * sum(f_i)`. + +### Chapter 3: Spatial Domain + +- **Negative** inverts intensities. Formula: `s = 255 - r`. +- **Log transform** expands darker values more than brighter values. Formula: `s = c log(1 + r)`. +- **Power-law / gamma** changes brightness and contrast with an exponent. Formula: `s = c r^gamma`. +- **Gray-level dynamic range** stretches a selected intensity range to the full display range. Formula: `[low, high] -> [0, 255]`. +- **Gray-level slicing** highlights pixels inside a chosen range. Formula: highlight where `A <= r <= B`. +- **Bit-plane slicing** displays one binary bit of each gray value. Formula: `bit_k(r)`. +- **Noise filter** adds test noise. Gaussian noise uses `g = f + n`; salt-and-pepper noise randomly sets pixels to `0` or `255`. +- **Average N noisy copies** generates `N` independent Gaussian-noisy copies of the current image and averages them into one result. Formula: `result = (1/N) * sum_i(f + n_i)`. +- **Average / box filter** smooths an image with a uniform mask. Formula: `g = imfilter(f, ones(K,K) / K^2)`. +- **Weighted average filter** smooths with the slide mask `1/16 * [[1,2,1],[2,4,2],[1,2,1]]`. +- **Gaussian filter** smooths using a Gaussian mask controlled by size `K` and variance `Q`. Formula: `G(x,y) = exp(-(x^2+y^2)/(2Q))`. +- **Median filter** replaces each pixel with the neighborhood median, useful for salt-and-pepper noise. Formula: `g(x,y) = median(S_xy)`. +- **Max filter** replaces each pixel with the local maximum. Formula: `g(x,y) = max(S_xy)`. +- **Min filter** replaces each pixel with the local minimum. Formula: `g(x,y) = min(S_xy)`. +- **Laplacian sharpening masks** use the taught cross or diagonal sharpening masks to emphasize fine detail. Formula: `g = imfilter(f, selected mask)`. +- **Gradient operators** use Sobel or Roberts mask pairs for edges. Formula: `g = |imfilter(f,Gx)| + |imfilter(f,Gy)|`. +- **High-boost / edge emphasis** sharpens by subtracting a blurred image from an amplified original. Formula: `f_hb = A f - blurred(f)`, where `A >= 1`. + +### Chapter 4: Frequency Domain + +- **FFT/DFT spectrum view** shows magnitude, log magnitude, or phase of the image in the frequency domain. Formula: `F(u,v) = DFT{f(x,y)}`. + +### Chapter 6: RGB Color Processing + +- **Convert to grayscale** uses configurable RGB weights, matching MATLAB-style luminance by default. Formula: `gray = 0.299R + 0.587G + 0.114B`. +- **RGB channel view** displays one color channel as grayscale. Formula: show `R`, `G`, or `B`. + ## API - `POST /api/images/` diff --git a/backend/processing/algorithms.py b/backend/processing/algorithms.py index b608177..adb753a 100644 --- a/backend/processing/algorithms.py +++ b/backend/processing/algorithms.py @@ -15,14 +15,26 @@ ROBERTS_GY = np.array([[0, 1], [-1, 0]], dtype=np.float32) class ProcessingError(ValueError): + """Raised when an image operation receives invalid input or parameters.""" + pass def ensure_uint8(image): + """Clip image values to the display range [0, 255] and return uint8 data. + + This is used after arithmetic or filtering so the result can be displayed as a normal 8-bit image. + """ + return np.clip(image, 0, 255).astype(np.uint8) def normalize_to_uint8(image): + """Linearly normalize any numeric image to the full 8-bit display range. + + This is useful for derivative, subtraction, and spectrum results that may contain negative or very large values. + """ + arr = image.astype(np.float32) min_value = float(np.min(arr)) max_value = float(np.max(arr)) @@ -32,6 +44,8 @@ def normalize_to_uint8(image): def require_odd(value, name="size", minimum=3): + """Validate that a mask size is an odd integer greater than or equal to minimum.""" + try: value = int(value) except (TypeError, ValueError) as exc: @@ -42,6 +56,8 @@ def require_odd(value, name="size", minimum=3): def require_finite_positive(value, name): + """Validate that a parameter is finite and strictly positive.""" + try: value = float(value) except (TypeError, ValueError) as exc: @@ -52,16 +68,31 @@ def require_finite_positive(value, name): def to_gray(image): + """Convert an RGB image to grayscale, leaving grayscale input unchanged. + + Many spatial-domain formulas work on intensity, so this gives them a single gray-level channel. + """ + if image.ndim == 2: return image return cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) def gray_to_rgb(gray): + """Convert a single-channel grayscale image to RGB for consistent display. + + The frontend expects displayable RGB images even when the algorithm result is grayscale. + """ + return cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB) def histogram(image): + """Return the normalized intensity histogram p(r_k) for gray levels 0..255. + + Histograms are used to inspect contrast, brightness distribution, and equalization results. + """ + gray = to_gray(image) counts = np.bincount(gray.ravel(), minlength=256).astype(np.float64) probabilities = counts / max(gray.size, 1) @@ -69,6 +100,11 @@ def histogram(image): def histogram_payload(image): + """Return intensity histogram and, for RGB images, separate R/G/B histograms. + + This lets the UI explain both overall intensity and per-channel color behavior. + """ + gray = to_gray(image) payload = {"intensity": histogram(gray)} if image.ndim == 3: @@ -79,6 +115,8 @@ def histogram_payload(image): def image_to_data_url(image): + """Encode a uint8 image as a PNG data URL for API responses.""" + pil_image = Image.fromarray(ensure_uint8(image)) buffer = BytesIO() pil_image.save(buffer, format="PNG") @@ -87,12 +125,19 @@ def image_to_data_url(image): def data_url_to_bytes(value): + """Decode a base64 data URL or raw base64 string into image bytes.""" + if "," in value: value = value.split(",", 1)[1] return base64.b64decode(value) def decode_image(uploaded_file=None, base64_image=None): + """Decode an uploaded file or base64 payload into a uint8 NumPy image. + + Grayscale inputs stay single-channel so intensity-only operations do not create fake RGB channels. + """ + 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: @@ -101,15 +146,26 @@ def decode_image(uploaded_file=None, base64_image=None): raw = data_url_to_bytes(base64_image) image = Image.open(BytesIO(raw)) - image = image.convert("RGB") - return np.array(image, dtype=np.uint8) + if image.mode in {"1", "L", "I;16", "I", "F"}: + return np.array(image.convert("L"), dtype=np.uint8) + return np.array(image.convert("RGB"), dtype=np.uint8) def negative(image, params): + """Apply the image negative transform, s = 255 - r. + + Use it to invert bright and dark structures, which can make some details easier to see. + """ + return 255 - image def logarithmic(image, params): + """Apply logarithmic intensity expansion, s = c log(1 + r), on normalized pixels. + + Use it to expand dark gray levels while compressing very bright regions. + """ + 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) @@ -117,6 +173,11 @@ def logarithmic(image, params): def gamma(image, params): + """Apply power-law correction, s = c r^gamma, on normalized pixels. + + Use it to brighten dark images with gamma < 1 or darken washed-out images with gamma > 1. + """ + 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 @@ -125,6 +186,11 @@ def gamma(image, params): def contrast_stretch(image, params): + """Stretch the selected gray-level interval [low, high] to the full [0, 255] range. + + Use it when useful image values occupy a narrow dynamic range and need stronger contrast. + """ + low = int(params.get("low", 0)) high = int(params.get("high", 255)) if low < 0 or high > 255 or low >= high: @@ -134,6 +200,11 @@ def contrast_stretch(image, params): def gray_slice(image, params): + """Highlight pixels whose grayscale intensity lies inside [start, end]. + + Use it to emphasize one gray-level band, such as a tissue, object, or intensity region of interest. + """ + start = int(params.get("start", 96)) end = int(params.get("end", 160)) if start < 0 or end > 255 or start > end: @@ -152,6 +223,11 @@ def gray_slice(image, params): def bit_plane(image, params): + """Extract one grayscale bit plane and display it as a binary image. + + Use it to study which bits carry the main visual information or fine/noisy details. + """ + bit = int(params.get("bit", 7)) if bit < 0 or bit > 7: raise ProcessingError("bit must be between 0 and 7.") @@ -160,12 +236,18 @@ def bit_plane(image, params): def histogram_equalization(image, params): + """Equalize a grayscale image using the discrete cumulative distribution function. + + Use it to improve global contrast when the histogram is concentrated in a small intensity range. + """ + + grayscale_input = image.ndim == 2 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) + return gray if grayscale_input else gray_to_rgb(gray) cdf_min = nonzero[0] denom = gray.size - cdf_min if denom <= 0: @@ -173,10 +255,15 @@ def histogram_equalization(image, params): else: lut = np.round((cdf - cdf_min) / denom * 255.0).clip(0, 255).astype(np.uint8) equalized = lut[gray] - return gray_to_rgb(equalized) + return equalized if grayscale_input else gray_to_rgb(equalized) def apply_kernel(image, kernel, normalize_derivative=False): + """Apply a 2D convolution mask to each channel using reflected borders. + + This is the shared imfilter-style step behind smoothing and sharpening masks. + """ + source = image.astype(np.float32) if image.ndim == 2: filtered = cv2.filter2D(source, cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT) @@ -189,6 +276,11 @@ def apply_kernel(image, kernel, normalize_derivative=False): def filter_float(image, kernel): + """Apply a 2D convolution mask and keep the float result for derivative math. + + Use it when intermediate negative edge/detail values must be preserved before display normalization. + """ + source = image.astype(np.float32) if image.ndim == 2: return cv2.filter2D(source, cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT) @@ -197,11 +289,21 @@ def filter_float(image, kernel): def box_filter(image, params): + """Blur an image with a K x K average mask, equivalent to ones(K,K) / K^2. + + Use it for simple smoothing or reducing Gaussian-like noise, accepting that edges become softer. + """ + size = require_odd(params.get("size", 3), "size") return cv2.blur(image, (size, size), borderType=cv2.BORDER_REFLECT) def weighted_average(image, params): + """Blur an image with a normalized weighted average mask. + + Use it for gentler smoothing that gives the center pixel more influence than a plain box filter. + """ + size = require_odd(params.get("size", 3), "size") if "kernel" in params: kernel = np.array(params["kernel"], dtype=np.float32) @@ -221,11 +323,21 @@ def weighted_average(image, params): def median_filter(image, params): + """Apply an order-statistic median filter for impulse-noise removal. + + Use it to remove salt-and-pepper noise while preserving edges better than linear averaging. + """ + size = require_odd(params.get("size", 3), "size") return cv2.medianBlur(image, size) def laplacian(image, params): + """Apply a zero-sum Laplacian detail mask and optionally add it back for sharpening. + + Use it to reveal fine second-derivative detail or sharpen small structures. + """ + mode = params.get("mode", "sharpen") lap = filter_float(image, LAPLACIAN_MASK) if mode == "edge": @@ -237,6 +349,11 @@ def laplacian(image, params): def high_boost(image, params): + """Apply high-boost filtering, f_hb = A f - blurred(f), with A >= 1. + + Use it to emphasize edges and details while retaining more of the original image than pure high-pass filtering. + """ + amplification = float(params.get("amplification", 1.5)) if not np.isfinite(amplification) or amplification < 1.0: raise ProcessingError("amplification must be >= 1.") @@ -247,6 +364,11 @@ def high_boost(image, params): def gradient_magnitude(image, gx_kernel, gy_kernel): + """Compute gradient magnitude from Gx and Gy derivative masks. + + Use it to find strong first-derivative changes, which usually correspond to object edges. + """ + 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) @@ -255,10 +377,20 @@ def gradient_magnitude(image, gx_kernel, gy_kernel): def sobel(image, params): + """Detect edges with Sobel horizontal and vertical derivative masks. + + Use it for edge detection with some built-in smoothing from the larger 3 x 3 masks. + """ + return gradient_magnitude(image, SOBEL_GX, SOBEL_GY) def roberts(image, params): + """Detect edges with Roberts cross-gradient masks. + + Use it for a simple 2 x 2 gradient operator that responds to diagonal intensity changes. + """ + return gradient_magnitude(image, ROBERTS_GX, ROBERTS_GY) @@ -281,6 +413,8 @@ OPERATIONS = { def process_image(image, operation, params=None): + """Run one named legacy operation against an image.""" + params = params or {} if operation not in OPERATIONS: raise ProcessingError(f"Unsupported operation '{operation}'.") @@ -288,18 +422,33 @@ def process_image(image, operation, params=None): def subtract_images(left, right): + """Subtract two registered images and normalize the absolute difference. + + Use it for change detection between two aligned images or processing states. + """ + verify_registration([left, right]) diff = left.astype(np.float32) - right.astype(np.float32) return normalize_to_uint8(np.abs(diff)) def average_images(images): + """Average a stack of registered images to reduce independent noise. + + Use it when multiple aligned captures of the same scene are available. + """ + 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): + """Ensure all images have identical dimensions and channel counts. + + This prevents invalid arithmetic between images that are not aligned pixel-for-pixel. + """ + if len(images) < 2: raise ProcessingError("At least two registered images are required.") shape = images[0].shape diff --git a/backend/processing/registry.py b/backend/processing/registry.py index 5b79746..6293a05 100644 --- a/backend/processing/registry.py +++ b/backend/processing/registry.py @@ -30,6 +30,8 @@ CH6 = "Color Image Processing" def with_meta(schema, *, label=None, description=None, show_when=None): + """Attach frontend display metadata to a parameter schema.""" + if label: schema["label"] = label if description: @@ -40,40 +42,61 @@ def with_meta(schema, *, label=None, description=None, show_when=None): def odd_param(default=3, max_value=35, **meta): + """Build a schema for odd-valued mask parameters such as K or N.""" + return with_meta({"type": "int", "default": default, "min": 3, "max": max_value, "step": 2, "odd": True}, **meta) def float_param(default, min_value, max_value, step=0.1, **meta): + """Build a schema for a floating-point slider/input parameter.""" + return with_meta({"type": "float", "default": default, "min": min_value, "max": max_value, "step": step}, **meta) def int_param(default, min_value, max_value, step=1, **meta): + """Build a schema for an integer slider/input parameter.""" + return with_meta({"type": "int", "default": default, "min": min_value, "max": max_value, "step": step}, **meta) def select_param(default, choices, **meta): + """Build a schema for a dropdown/select parameter.""" + return with_meta({"type": "select", "default": default, "choices": choices}, **meta) def bool_param(default=False, **meta): + """Build a schema for a boolean/toggle parameter.""" + return with_meta({"type": "bool", "default": default}, **meta) def kernel_preview(title, matrix, scale=None): + """Describe a single matrix preview shown beside an operation.""" + return {"title": title, "matrix": matrix, "scale": scale} def kernel_pair_preview(title, gx, gy): + """Describe related Gx/Gy derivative masks shown as one preview.""" + return {"title": title, "kernels": [{"label": "Gx", "matrix": gx}, {"label": "Gy", "matrix": gy}]} def mask_param(default=3, max_value=35, label="Mask size"): + """Build the common odd window-size parameter used by order-statistic filters.""" + schema = odd_param(default, max_value) schema["label"] = label return schema def histeq(image, params): + """Apply MATLAB-style histogram equalization to grayscale or RGB channels. + + Use it to automatically improve global contrast without manually choosing gray-level limits. + """ + if image.ndim == 2: return histogram_equalization(image, params) channels = [histogram_equalization(image[:, :, idx], params)[:, :, 0] for idx in range(3)] @@ -81,6 +104,13 @@ def histeq(image, params): def rgb_to_gray_matlab(image, params): + """Convert RGB to grayscale using configurable MATLAB-style channel weights. + + Use it before intensity-based processing when color is not needed or when matching MATLAB examples. + """ + + if image.ndim == 2: + return image red_weight = float(params.get("red_weight", 0.299)) green_weight = float(params.get("green_weight", 0.587)) blue_weight = float(params.get("blue_weight", 0.114)) @@ -89,10 +119,15 @@ def rgb_to_gray_matlab(image, params): raise ProcessingError("Grayscale weights must have a non-zero finite sum.") weights = np.array([red_weight, green_weight, blue_weight], dtype=np.float32) / total gray = np.tensordot(image.astype(np.float32), weights, axes=([2], [0])) - return gray_to_rgb(ensure_uint8(np.round(gray))) + return ensure_uint8(np.round(gray)) def gaussian_noise(image, params): + """Add Gaussian noise, g = f + n, where n has configurable mean and variance. + + Use it to simulate sensor-like random noise before testing smoothing filters. + """ + mean = float(params.get("mean", 0)) variance = float(params.get("variance", 0.01)) sigma = math.sqrt(max(variance, 0.0)) * 255.0 @@ -101,6 +136,11 @@ def gaussian_noise(image, params): def salt_pepper_noise(image, params): + """Add impulse noise by randomly replacing pixels with black or white values. + + Use it to test order-statistic denoising, especially the median filter. + """ + amount = float(params.get("amount", 0.03)) salt_ratio = float(params.get("salt_ratio", 0.5)) output = image.copy() @@ -114,13 +154,50 @@ def salt_pepper_noise(image, params): def noise_filter(image, params): + """Dispatch the selected noise model from the single public Noise Filter operation. + + Use it to keep noise experiments in one UI action while changing only the noise type. + """ + kind = params.get("kind", "gaussian") if kind == "salt_pepper": return salt_pepper_noise(image, params) return gaussian_noise(image, params) +def average_noisy_copies(image, params): + """Generate N Gaussian-noisy copies of one image and average them into one result. + + Use it to demonstrate how averaging many independent noisy observations reduces random Gaussian noise. + """ + + count = int(params.get("N", 100)) + if count < 1 or count > 500: + raise ProcessingError("N must be between 1 and 500.") + kind = params.get("kind", "gaussian") + if kind != "gaussian": + raise ProcessingError("Only gaussian noise is supported for noisy-copy averaging.") + mean = float(params.get("mean", 0)) + variance = float(params.get("variance", 0.01)) + if not np.isfinite(mean) or not np.isfinite(variance) or variance < 0: + raise ProcessingError("Gaussian mean must be finite and variance must be non-negative.") + + rng = np.random.default_rng() + sigma = math.sqrt(variance) * 255.0 + source = image.astype(np.float32) + total = np.zeros_like(source, dtype=np.float32) + for _ in range(count): + noise = rng.normal(mean * 255.0, sigma, size=image.shape) + total += np.clip(source + noise, 0, 255) + return ensure_uint8(np.round(total / count)) + + def gaussian_filter(image, params): + """Apply a Gaussian low-pass filter controlled by mask size K and variance Q. + + Use it to reduce Gaussian noise with a smoother, more natural blur than a box filter. + """ + size = int(params.get("K", params.get("size", 3))) variance = float(params.get("Q", params.get("variance", 1.0))) if size < 3 or size % 2 == 0: @@ -130,6 +207,11 @@ def gaussian_filter(image, params): def max_filter(image, params): + """Apply a max filter that replaces each pixel with the local neighborhood maximum. + + Use it to expand bright regions or reduce isolated dark pepper noise. + """ + size = int(params.get("mask_size", params.get("N", params.get("size", 3)))) if size < 3 or size % 2 == 0: raise ProcessingError("Mask size must be an odd integer >= 3.") @@ -137,6 +219,11 @@ def max_filter(image, params): def min_filter(image, params): + """Apply a min filter that replaces each pixel with the local neighborhood minimum. + + Use it to expand dark regions or reduce isolated bright salt noise. + """ + size = int(params.get("mask_size", params.get("N", params.get("size", 3)))) if size < 3 or size % 2 == 0: raise ProcessingError("Mask size must be an odd integer >= 3.") @@ -144,28 +231,58 @@ def min_filter(image, params): def box_denoise(image, params): + """Apply the average/box filter using a K x K normalized mask. + + Use it as the simplest low-pass filter for smoothing and basic noise reduction. + """ + return box_filter(image, {"size": params.get("K", params.get("mask_size", params.get("size", 3)))}) def weighted_denoise(image, params): + """Apply the fixed 3 x 3 weighted average filter with 1/16 normalization. + + Use it when you want mild smoothing that keeps the center pixel more important. + """ + return weighted_average(image, {"size": 3}) def median_denoise(image, params): + """Apply median filtering with an odd local window, useful for salt-and-pepper noise. + + Use it when impulse noise appears as random black and white pixels. + """ + return median_filter(image, {"size": params.get("mask_size", params.get("N", params.get("size", 3)))}) def gaussian_denoise(image, params): + """Apply Gaussian smoothing using K for mask size and Q for variance. + + Use it for denoising random Gaussian noise while avoiding the blocky look of a box filter. + """ + size = params.get("K", params.get("mask_size", params.get("size", 3))) variance = params.get("Q", params.get("variance", 1.0)) return gaussian_filter(image, {"K": size, "Q": variance}) def high_boost_slide(image, params): + """Apply high-boost filtering with slide-style parameters A and K. + + Use it to make edges and fine structures stronger after smoothing has removed the low-frequency background. + """ + return high_boost(image, {"amplification": params.get("A", params.get("amplification", 1.5)), "size": params.get("K", params.get("size", 3))}) def laplacian_slide(image, params): + """Sharpen with one of the two taught Laplacian sharpening masks. + + Use it to highlight fine detail with the same cross or diagonal masks shown in the slides. + """ + mask_name = params.get("mask", "cross") kernels = { "cross": np.array([[0, 1, 0], [1, -5, 1], [0, 1, 0]], dtype=np.float32), @@ -174,12 +291,19 @@ def laplacian_slide(image, params): kernel = kernels.get(mask_name) if kernel is None: raise ProcessingError("Unknown Laplacian mask.") + if image.ndim == 2: + return ensure_uint8(cv2.filter2D(image.astype(np.float32), cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT)) channels = [cv2.filter2D(image[:, :, idx], cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT) for idx in range(image.shape[2])] result = np.stack(channels, axis=2) return ensure_uint8(result) def gradient_abs_sum(image, params): + """Compute Sobel or Roberts edges as abs(imfilter(f,Gx)) + abs(imfilter(f,Gy)). + + Use it to emphasize prominent edges before combining them with a sharpened image. + """ + operator = params.get("operator", "sobel") gray = to_gray(image).astype(np.float32) if operator == "roberts": @@ -194,12 +318,24 @@ def gradient_abs_sum(image, params): def rgb_channel(image, params): + """Extract one RGB channel and show it as a grayscale image. + + Use it to inspect how much information each color component contributes. + """ + + if image.ndim == 2: + return image channel = params.get("channel", "r") index = {"r": 0, "g": 1, "b": 2}.get(channel, 0) return gray_to_rgb(image[:, :, index]) def fft_spectrum(image, params): + """Display the DFT magnitude, log magnitude, or phase spectrum of an image. + + Use it to understand whether image information is concentrated in low or high frequencies. + """ + gray = to_gray(image).astype(np.float32) spectrum = np.fft.fftshift(np.fft.fft2(gray)) mode = params.get("mode", "log_magnitude") @@ -212,6 +348,8 @@ def fft_spectrum(image, params): def operation(id, label, chapter, slide_group, func, params=None, supports="both", matrices=None, formula="", repeatable=True): + """Create one operation registry entry consumed by the API and frontend.""" + return { "id": id, "label": label, @@ -241,6 +379,12 @@ OPERATIONS = [ "amount": float_param(0.03, 0, 0.5, 0.01, description="Salt-and-pepper probability per pixel.", show_when={"param": "kind", "value": "salt_pepper"}), "salt_ratio": float_param(0.5, 0, 1, 0.05, description="Fraction of impulse noise assigned to salt.", show_when={"param": "kind", "value": "salt_pepper"}), }, formula="Gaussian: g=f+n. Salt-pepper: pixels become 0 or 255."), + operation("average_noisy_copies", "Average N Noisy Copies", CH3, "Noise and Denoising", average_noisy_copies, { + "N": int_param(100, 1, 500, description="Number of independent noisy copies to generate and average."), + "kind": select_param("gaussian", ["gaussian"], description="Noise type used for generated copies."), + "mean": float_param(0, -1, 1, 0.01, description="Gaussian mean in normalized intensity units."), + "variance": float_param(0.01, 0, 0.2, 0.005, description="Gaussian variance; lower values add weaker noise."), + }, formula="result = (1/N) sum_i (f + n_i), with gaussian n_i.", repeatable=False), operation("box_filter", "Average / Box Filter", CH3, "Linear Low-Pass Filters", box_denoise, {"K": odd_param(3, 35, description="Odd mask dimension K for the K x K average mask.")}, matrices=[kernel_preview("1 / K^2 box mask", [["1", "1", "1"], ["1", "1", "1"], ["1", "1", "1"]], "1 / K^2")], formula="g = imfilter(f, ones(K,K)/K^2)"), operation("weighted_average", "Weighted Average Filter", CH3, "Linear Low-Pass Filters", weighted_denoise, matrices=[kernel_preview("Weighted average mask", [[1, 2, 1], [2, 4, 2], [1, 2, 1]], "1 / 16")], formula="g = imfilter(f, weighted mask)"), operation("gaussian_filter", "Gaussian Filter", CH3, "Linear Low-Pass Filters", gaussian_denoise, {"K": odd_param(3, 35, description="Odd Gaussian mask dimension K."), "Q": float_param(1.0, 0.01, 25, 0.1, description="Variance Q of the Gaussian mask.")}, formula="Gaussian mask controlled by K and variance Q."), @@ -265,10 +409,17 @@ OPERATION_MAP = {item["id"]: item for item in OPERATIONS} def operation_metadata(): + """Return public operation definitions without executable Python callables.""" + return [{key: value for key, value in item.items() if key != "func"} for item in OPERATIONS] def apply_registered_operation(image, operation_id, params=None): + """Apply one registered operation, optionally repeating it with the hidden _repeat value. + + Use repetition to apply the same filter several times in one saved state. + """ + item = OPERATION_MAP.get(operation_id) if item is None: raise ProcessingError(f"Unsupported operation '{operation_id}'.") diff --git a/backend/processing/services.py b/backend/processing/services.py index 9bd3319..437d873 100644 --- a/backend/processing/services.py +++ b/backend/processing/services.py @@ -14,6 +14,11 @@ from .tasks import run_batch_job def image_session_create(*, uploaded_file=None, image_base64=None): + """Create a new image session and its initial S0 upload state. + + The session groups all later processing states so a user can present the full workflow. + """ + if uploaded_file and uploaded_file.size > settings.MAX_UPLOAD_MB * 1024 * 1024: raise ProcessingError(f"Upload exceeds {settings.MAX_UPLOAD_MB} MB.") @@ -55,6 +60,11 @@ def image_session_create(*, uploaded_file=None, image_base64=None): def compact_workspace_image(image): + """Resize large uploads to the configured maximum dimension for faster processing. + + This keeps classroom-sized experiments responsive even when the uploaded file is very large. + """ + max_dimension = int(getattr(settings, "IMAGE_WORKSPACE_MAX_DIMENSION", 1400)) if max_dimension <= 0: return image @@ -68,6 +78,11 @@ def compact_workspace_image(image): def image_state_create(*, session, parent, image, operation, params, label=None, prefix="state"): + """Persist one processed image state with its metadata, parent, and histogram. + + Saving every result makes it possible to compare steps and combine previous states later. + """ + relative_path = save_image_array(image, prefix) max_sequence = session.states.aggregate(value=Max("sequence"))["value"] sequence = 0 if max_sequence is None else max_sequence + 1 @@ -89,6 +104,8 @@ def image_state_create(*, session, parent, image, operation, params, label=None, def image_state_payload(*, state, include_image=True): + """Serialize an image state for the frontend workspace.""" + payload = { "state_id": str(state.id), "session_id": str(state.session_id), @@ -114,10 +131,17 @@ def image_state_payload(*, state, include_image=True): def image_states_payload(*, states): + """Serialize a list of image states.""" + return [image_state_payload(state=state, include_image=True) for state in states] def image_state_delete(*, state): + """Delete a non-S0 state while keeping child states available. + + Use it to remove unhelpful experiments without losing later useful results. + """ + if state.sequence == 0 or state.operation == "upload": raise ProcessingError("The original S0 upload state cannot be deleted.") image_path = state.image @@ -127,6 +151,11 @@ def image_state_delete(*, state): def image_state_apply_operation(*, state, operation, params): + """Apply one registered algorithm to a selected state and save the result as a new state. + + This is the main workspace action: every filter or transform becomes a reproducible step. + """ + if state.session.expired: raise ProcessingError("Image session has expired.") source = load_image_array(state.image) @@ -144,6 +173,11 @@ def image_state_apply_operation(*, state, operation, params): def combine_states(*, states, operation, params=None): + """Combine registered states using add, subtract, dot product, average, and/or. + + Use it for MATLAB-like image arithmetic, change detection, masking, and K-image denoising. + """ + params = params or {} if len(states) < 2: raise ProcessingError("At least two states are required.") @@ -185,6 +219,11 @@ def combine_states(*, states, operation, params=None): def np_clip_sum(images): + """Add several registered images and clip the result to [0, 255]. + + Use it to combine brightness/detail contributions while keeping the output displayable. + """ + total = np.zeros_like(images[0], dtype="float32") for image in images: total += image.astype("float32") @@ -192,6 +231,11 @@ def np_clip_sum(images): def image_session_process(*, session, operation, params): + """Run a legacy single-image operation against the original session image. + + This keeps the older API working while the state workspace handles the main presentation flow. + """ + if session.expired: raise ProcessingError("Image session has expired.") @@ -217,12 +261,19 @@ def image_session_process(*, session, operation, params): def batch_job_create(*, operation, session_ids, params=None): + """Create a Celery-backed processing job for heavier batch operations. + + Use it when an operation may take longer than an interactive request should block. + """ + 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): + """Serialize a processing job, including result image data when available.""" + payload = { "job_id": str(job.id), "operation": job.operation, diff --git a/backend/processing/storage.py b/backend/processing/storage.py index e4b5538..9d868c9 100644 --- a/backend/processing/storage.py +++ b/backend/processing/storage.py @@ -18,9 +18,12 @@ 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) + array = ensure_uint8(image) + if array.ndim == 2: + encoded_source = array + else: + encoded_source = cv2.cvtColor(array, cv2.COLOR_RGB2BGR) + ok, encoded = cv2.imencode(".png", encoded_source) if not ok: raise ProcessingError("Unable to encode image for temporary storage.") path.write_bytes(encoded.tobytes()) @@ -32,9 +35,13 @@ def load_image_array(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) + image = cv2.imdecode(raw, cv2.IMREAD_UNCHANGED) if image is None: raise ProcessingError("Temporary image file is missing or unreadable.") + if image.ndim == 2: + return image.astype(np.uint8) + if image.shape[2] == 4: + return cv2.cvtColor(image, cv2.COLOR_BGRA2RGB).astype(np.uint8) return cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.uint8) diff --git a/backend/processing/tests/test_algorithms.py b/backend/processing/tests/test_algorithms.py index c0a0da9..f3e3ef1 100644 --- a/backend/processing/tests/test_algorithms.py +++ b/backend/processing/tests/test_algorithms.py @@ -33,8 +33,7 @@ class AlgorithmTests(SimpleTestCase): 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) + np.testing.assert_array_equal(result, image) def test_median_removes_impulse_noise(self): image = np.full((3, 3, 3), 100, dtype=np.uint8) diff --git a/backend/processing/tests/test_api.py b/backend/processing/tests/test_api.py index 0653e05..c8ed6df 100644 --- a/backend/processing/tests/test_api.py +++ b/backend/processing/tests/test_api.py @@ -8,6 +8,9 @@ from django.test import TestCase, override_settings from PIL import Image from rest_framework.test import APIClient +from processing.models import ImageState +from processing.storage import load_image_array + def png_upload(color=(32, 64, 128), size=(4, 4), name="sample.png"): buffer = BytesIO() @@ -15,6 +18,12 @@ def png_upload(color=(32, 64, 128), size=(4, 4), name="sample.png"): return SimpleUploadedFile(name, buffer.getvalue(), content_type="image/png") +def grayscale_png_upload(value=96, size=(4, 4), name="gray.png"): + buffer = BytesIO() + Image.new("L", size, value).save(buffer, format="PNG") + return SimpleUploadedFile(name, buffer.getvalue(), content_type="image/png") + + class ApiTests(TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -59,9 +68,13 @@ class ApiTests(TestCase): self.assertIn("box_filter", operation_ids) self.assertIn("median_filter", operation_ids) self.assertIn("noise_filter", operation_ids) + self.assertIn("average_noisy_copies", operation_ids) self.assertIn("rgb_to_gray", operation_ids) self.assertEqual(operations["histeq"]["params"], {}) self.assertEqual(operations["noise_filter"]["label"], "Noise Filter") + self.assertEqual(operations["average_noisy_copies"]["label"], "Average N Noisy Copies") + self.assertEqual(operations["average_noisy_copies"]["params"]["N"]["default"], 100) + self.assertFalse(operations["average_noisy_copies"]["repeatable"]) self.assertEqual(operations["box_filter"]["label"], "Average / Box Filter") self.assertEqual(operations["gaussian_filter"]["label"], "Gaussian Filter") self.assertFalse(operations["negative"]["repeatable"]) @@ -173,6 +186,45 @@ class ApiTests(TestCase): self.assertEqual(response.data["operation"], "noise_filter") self.assertEqual(response.data["params"]["kind"], "salt_pepper") + def test_noise_filter_preserves_uploaded_grayscale_channel_count(self): + upload = self.client.post("/api/images/", {"image": grayscale_png_upload()}, format="multipart") + self.assertEqual(upload.status_code, 201) + self.assertEqual(upload.data["channels"], 1) + self.assertEqual(upload.data["color_mode"], "L") + + s0_id = upload.data["states"][0]["state_id"] + response = self.client.post( + f"/api/states/{s0_id}/operations/", + {"operation": "noise_filter", "params": {"kind": "gaussian", "mean": 0, "variance": 0.01}}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["channels"], 1) + self.assertEqual(response.data["color_mode"], "L") + state = ImageState.objects.get(id=response.data["state_id"]) + self.assertEqual(load_image_array(state.image).ndim, 2) + + def test_average_noisy_copies_creates_single_grayscale_result_state(self): + upload = self.client.post("/api/images/", {"image": grayscale_png_upload(value=96)}, format="multipart") + s0_id = upload.data["states"][0]["state_id"] + response = self.client.post( + f"/api/states/{s0_id}/operations/", + {"operation": "average_noisy_copies", "params": {"N": 10, "kind": "gaussian", "mean": 0, "variance": 0}}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["operation"], "average_noisy_copies") + self.assertEqual(response.data["params"]["N"], 10) + self.assertEqual(response.data["channels"], 1) + self.assertEqual(response.data["color_mode"], "L") + self.assertEqual(ImageState.objects.count(), 2) + state = ImageState.objects.get(id=response.data["state_id"]) + result = load_image_array(state.image) + self.assertEqual(result.ndim, 2) + self.assertEqual(int(result[0, 0]), 96) + def test_grayscale_operation_creates_state(self): upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart") s0_id = upload.data["states"][0]["state_id"] @@ -183,6 +235,17 @@ class ApiTests(TestCase): ) self.assertEqual(gray.status_code, 201) self.assertEqual(gray.data["operation"], "rgb_to_gray") + self.assertEqual(gray.data["channels"], 1) + self.assertEqual(gray.data["color_mode"], "L") + + noisy = self.client.post( + f"/api/states/{gray.data['state_id']}/operations/", + {"operation": "noise_filter", "params": {"kind": "salt_pepper", "amount": 0.1, "salt_ratio": 0.5}}, + format="json", + ) + self.assertEqual(noisy.status_code, 201) + self.assertEqual(noisy.data["channels"], 1) + self.assertEqual(noisy.data["color_mode"], "L") @patch("processing.services.run_batch_job.delay") def test_batch_returns_job_id(self, delay): diff --git a/docker-compose.yml b/docker-compose.yml index b3ff7eb..1c3d881 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: build: ./backend command: sh -c "python manage.py migrate && python manage.py collectstatic --noinput && gunicorn enhancer_project.wsgi:application --bind 0.0.0.0:8000" env_file: - - ./backend/.env + - ${BACKEND_ENV_FILE:-./backend/.env} environment: DJANGO_DEBUG: ${DJANGO_DEBUG:-0} DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me} @@ -61,7 +61,7 @@ services: build: ./backend command: celery -A enhancer_project worker --loglevel=info env_file: - - ./backend/.env + - ${BACKEND_ENV_FILE:-./backend/.env} environment: DJANGO_DEBUG: ${DJANGO_DEBUG:-0} DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me} diff --git a/frontend/src/components/CanvasPane.jsx b/frontend/src/components/CanvasPane.jsx index 36eae98..afda8d2 100644 --- a/frontend/src/components/CanvasPane.jsx +++ b/frontend/src/components/CanvasPane.jsx @@ -28,7 +28,7 @@ export default function CanvasPane({ title, imageData, histogram, transform, onT }, [transform]); return ( -
+

{title}

{histogram ? "p(r_k) ready" : "No histogram"} diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..cc626b8 Binary files /dev/null and b/screenshot.png differ