feat(v4): add docker-compose and production-ready application
This commit is contained in:
16
.env.example
16
.env.example
@@ -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
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
CADDY_DOMAIN=example.com
|
CADDY_DOMAIN=example.com
|
||||||
|
BACKEND_ENV_FILE=./backend/.env
|
||||||
DJANGO_DEBUG=0
|
DJANGO_DEBUG=0
|
||||||
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
||||||
DJANGO_ALLOWED_HOSTS=example.com,www.example.com,api
|
DJANGO_ALLOWED_HOSTS=example.com,www.example.com,api
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,6 +8,9 @@ db.sqlite3
|
|||||||
backend/media/
|
backend/media/
|
||||||
backend/staticfiles/
|
backend/staticfiles/
|
||||||
.env
|
.env
|
||||||
|
.env.production
|
||||||
|
backend/.env.production
|
||||||
|
frontend/.env.production
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
44
README.md
44
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.
|
Production-grade public SPA for spatial-domain image enhancement using Django REST Framework, OpenCV, NumPy, React, Tailwind CSS, Celery, Redis, PostgreSQL, Docker Compose, and Caddy.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
Backend:
|
Backend:
|
||||||
@@ -48,6 +50,48 @@ The Django app follows the HackSoftware Django Styleguide pattern:
|
|||||||
- `processing/selectors.py` contains database fetch helpers.
|
- `processing/selectors.py` contains database fetch helpers.
|
||||||
- Settings are environment-driven through `backend/.env`.
|
- 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
|
## API
|
||||||
|
|
||||||
- `POST /api/images/`
|
- `POST /api/images/`
|
||||||
|
|||||||
@@ -15,14 +15,26 @@ ROBERTS_GY = np.array([[0, 1], [-1, 0]], dtype=np.float32)
|
|||||||
|
|
||||||
|
|
||||||
class ProcessingError(ValueError):
|
class ProcessingError(ValueError):
|
||||||
|
"""Raised when an image operation receives invalid input or parameters."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def ensure_uint8(image):
|
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)
|
return np.clip(image, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
def normalize_to_uint8(image):
|
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)
|
arr = image.astype(np.float32)
|
||||||
min_value = float(np.min(arr))
|
min_value = float(np.min(arr))
|
||||||
max_value = float(np.max(arr))
|
max_value = float(np.max(arr))
|
||||||
@@ -32,6 +44,8 @@ def normalize_to_uint8(image):
|
|||||||
|
|
||||||
|
|
||||||
def require_odd(value, name="size", minimum=3):
|
def require_odd(value, name="size", minimum=3):
|
||||||
|
"""Validate that a mask size is an odd integer greater than or equal to minimum."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = int(value)
|
value = int(value)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
@@ -42,6 +56,8 @@ def require_odd(value, name="size", minimum=3):
|
|||||||
|
|
||||||
|
|
||||||
def require_finite_positive(value, name):
|
def require_finite_positive(value, name):
|
||||||
|
"""Validate that a parameter is finite and strictly positive."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = float(value)
|
value = float(value)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
@@ -52,16 +68,31 @@ def require_finite_positive(value, name):
|
|||||||
|
|
||||||
|
|
||||||
def to_gray(image):
|
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:
|
if image.ndim == 2:
|
||||||
return image
|
return image
|
||||||
return cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
return cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
||||||
|
|
||||||
|
|
||||||
def gray_to_rgb(gray):
|
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)
|
return cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB)
|
||||||
|
|
||||||
|
|
||||||
def histogram(image):
|
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)
|
gray = to_gray(image)
|
||||||
counts = np.bincount(gray.ravel(), minlength=256).astype(np.float64)
|
counts = np.bincount(gray.ravel(), minlength=256).astype(np.float64)
|
||||||
probabilities = counts / max(gray.size, 1)
|
probabilities = counts / max(gray.size, 1)
|
||||||
@@ -69,6 +100,11 @@ def histogram(image):
|
|||||||
|
|
||||||
|
|
||||||
def histogram_payload(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)
|
gray = to_gray(image)
|
||||||
payload = {"intensity": histogram(gray)}
|
payload = {"intensity": histogram(gray)}
|
||||||
if image.ndim == 3:
|
if image.ndim == 3:
|
||||||
@@ -79,6 +115,8 @@ def histogram_payload(image):
|
|||||||
|
|
||||||
|
|
||||||
def image_to_data_url(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))
|
pil_image = Image.fromarray(ensure_uint8(image))
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
pil_image.save(buffer, format="PNG")
|
pil_image.save(buffer, format="PNG")
|
||||||
@@ -87,12 +125,19 @@ def image_to_data_url(image):
|
|||||||
|
|
||||||
|
|
||||||
def data_url_to_bytes(value):
|
def data_url_to_bytes(value):
|
||||||
|
"""Decode a base64 data URL or raw base64 string into image bytes."""
|
||||||
|
|
||||||
if "," in value:
|
if "," in value:
|
||||||
value = value.split(",", 1)[1]
|
value = value.split(",", 1)[1]
|
||||||
return base64.b64decode(value)
|
return base64.b64decode(value)
|
||||||
|
|
||||||
|
|
||||||
def decode_image(uploaded_file=None, base64_image=None):
|
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:
|
if uploaded_file is None and not base64_image:
|
||||||
raise ProcessingError("Provide an image file or base64 image payload.")
|
raise ProcessingError("Provide an image file or base64 image payload.")
|
||||||
if uploaded_file is not None:
|
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)
|
raw = data_url_to_bytes(base64_image)
|
||||||
|
|
||||||
image = Image.open(BytesIO(raw))
|
image = Image.open(BytesIO(raw))
|
||||||
image = image.convert("RGB")
|
if image.mode in {"1", "L", "I;16", "I", "F"}:
|
||||||
return np.array(image, dtype=np.uint8)
|
return np.array(image.convert("L"), dtype=np.uint8)
|
||||||
|
return np.array(image.convert("RGB"), dtype=np.uint8)
|
||||||
|
|
||||||
|
|
||||||
def negative(image, params):
|
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
|
return 255 - image
|
||||||
|
|
||||||
|
|
||||||
def logarithmic(image, params):
|
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")
|
c = require_finite_positive(params.get("c", 1.0 / math.log(2.0)), "c")
|
||||||
normalized = image.astype(np.float32) / 255.0
|
normalized = image.astype(np.float32) / 255.0
|
||||||
transformed = c * np.log1p(normalized)
|
transformed = c * np.log1p(normalized)
|
||||||
@@ -117,6 +173,11 @@ def logarithmic(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def gamma(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")
|
gamma_value = require_finite_positive(params.get("gamma", 1.0), "gamma")
|
||||||
c = require_finite_positive(params.get("c", 1.0), "c")
|
c = require_finite_positive(params.get("c", 1.0), "c")
|
||||||
normalized = image.astype(np.float32) / 255.0
|
normalized = image.astype(np.float32) / 255.0
|
||||||
@@ -125,6 +186,11 @@ def gamma(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def contrast_stretch(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))
|
low = int(params.get("low", 0))
|
||||||
high = int(params.get("high", 255))
|
high = int(params.get("high", 255))
|
||||||
if low < 0 or high > 255 or low >= high:
|
if low < 0 or high > 255 or low >= high:
|
||||||
@@ -134,6 +200,11 @@ def contrast_stretch(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def gray_slice(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))
|
start = int(params.get("start", 96))
|
||||||
end = int(params.get("end", 160))
|
end = int(params.get("end", 160))
|
||||||
if start < 0 or end > 255 or start > end:
|
if start < 0 or end > 255 or start > end:
|
||||||
@@ -152,6 +223,11 @@ def gray_slice(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def bit_plane(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))
|
bit = int(params.get("bit", 7))
|
||||||
if bit < 0 or bit > 7:
|
if bit < 0 or bit > 7:
|
||||||
raise ProcessingError("bit must be between 0 and 7.")
|
raise ProcessingError("bit must be between 0 and 7.")
|
||||||
@@ -160,12 +236,18 @@ def bit_plane(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def histogram_equalization(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)
|
gray = to_gray(image)
|
||||||
counts = np.bincount(gray.ravel(), minlength=256)
|
counts = np.bincount(gray.ravel(), minlength=256)
|
||||||
cdf = counts.cumsum().astype(np.float64)
|
cdf = counts.cumsum().astype(np.float64)
|
||||||
nonzero = cdf[cdf > 0]
|
nonzero = cdf[cdf > 0]
|
||||||
if nonzero.size == 0:
|
if nonzero.size == 0:
|
||||||
return gray_to_rgb(gray)
|
return gray if grayscale_input else gray_to_rgb(gray)
|
||||||
cdf_min = nonzero[0]
|
cdf_min = nonzero[0]
|
||||||
denom = gray.size - cdf_min
|
denom = gray.size - cdf_min
|
||||||
if denom <= 0:
|
if denom <= 0:
|
||||||
@@ -173,10 +255,15 @@ def histogram_equalization(image, params):
|
|||||||
else:
|
else:
|
||||||
lut = np.round((cdf - cdf_min) / denom * 255.0).clip(0, 255).astype(np.uint8)
|
lut = np.round((cdf - cdf_min) / denom * 255.0).clip(0, 255).astype(np.uint8)
|
||||||
equalized = lut[gray]
|
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):
|
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)
|
source = image.astype(np.float32)
|
||||||
if image.ndim == 2:
|
if image.ndim == 2:
|
||||||
filtered = cv2.filter2D(source, cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT)
|
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):
|
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)
|
source = image.astype(np.float32)
|
||||||
if image.ndim == 2:
|
if image.ndim == 2:
|
||||||
return cv2.filter2D(source, cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT)
|
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):
|
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")
|
size = require_odd(params.get("size", 3), "size")
|
||||||
return cv2.blur(image, (size, size), borderType=cv2.BORDER_REFLECT)
|
return cv2.blur(image, (size, size), borderType=cv2.BORDER_REFLECT)
|
||||||
|
|
||||||
|
|
||||||
def weighted_average(image, params):
|
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")
|
size = require_odd(params.get("size", 3), "size")
|
||||||
if "kernel" in params:
|
if "kernel" in params:
|
||||||
kernel = np.array(params["kernel"], dtype=np.float32)
|
kernel = np.array(params["kernel"], dtype=np.float32)
|
||||||
@@ -221,11 +323,21 @@ def weighted_average(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def median_filter(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")
|
size = require_odd(params.get("size", 3), "size")
|
||||||
return cv2.medianBlur(image, size)
|
return cv2.medianBlur(image, size)
|
||||||
|
|
||||||
|
|
||||||
def laplacian(image, params):
|
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")
|
mode = params.get("mode", "sharpen")
|
||||||
lap = filter_float(image, LAPLACIAN_MASK)
|
lap = filter_float(image, LAPLACIAN_MASK)
|
||||||
if mode == "edge":
|
if mode == "edge":
|
||||||
@@ -237,6 +349,11 @@ def laplacian(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def high_boost(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))
|
amplification = float(params.get("amplification", 1.5))
|
||||||
if not np.isfinite(amplification) or amplification < 1.0:
|
if not np.isfinite(amplification) or amplification < 1.0:
|
||||||
raise ProcessingError("amplification must be >= 1.")
|
raise ProcessingError("amplification must be >= 1.")
|
||||||
@@ -247,6 +364,11 @@ def high_boost(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def gradient_magnitude(image, gx_kernel, gy_kernel):
|
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)
|
gray = to_gray(image).astype(np.float32)
|
||||||
gx = cv2.filter2D(gray, cv2.CV_32F, gx_kernel, borderType=cv2.BORDER_REFLECT)
|
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)
|
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):
|
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)
|
return gradient_magnitude(image, SOBEL_GX, SOBEL_GY)
|
||||||
|
|
||||||
|
|
||||||
def roberts(image, params):
|
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)
|
return gradient_magnitude(image, ROBERTS_GX, ROBERTS_GY)
|
||||||
|
|
||||||
|
|
||||||
@@ -281,6 +413,8 @@ OPERATIONS = {
|
|||||||
|
|
||||||
|
|
||||||
def process_image(image, operation, params=None):
|
def process_image(image, operation, params=None):
|
||||||
|
"""Run one named legacy operation against an image."""
|
||||||
|
|
||||||
params = params or {}
|
params = params or {}
|
||||||
if operation not in OPERATIONS:
|
if operation not in OPERATIONS:
|
||||||
raise ProcessingError(f"Unsupported operation '{operation}'.")
|
raise ProcessingError(f"Unsupported operation '{operation}'.")
|
||||||
@@ -288,18 +422,33 @@ def process_image(image, operation, params=None):
|
|||||||
|
|
||||||
|
|
||||||
def subtract_images(left, right):
|
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])
|
verify_registration([left, right])
|
||||||
diff = left.astype(np.float32) - right.astype(np.float32)
|
diff = left.astype(np.float32) - right.astype(np.float32)
|
||||||
return normalize_to_uint8(np.abs(diff))
|
return normalize_to_uint8(np.abs(diff))
|
||||||
|
|
||||||
|
|
||||||
def average_images(images):
|
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)
|
verify_registration(images)
|
||||||
stack = np.stack([image.astype(np.float32) for image in images], axis=0)
|
stack = np.stack([image.astype(np.float32) for image in images], axis=0)
|
||||||
return ensure_uint8(np.round(np.mean(stack, axis=0)))
|
return ensure_uint8(np.round(np.mean(stack, axis=0)))
|
||||||
|
|
||||||
|
|
||||||
def verify_registration(images):
|
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:
|
if len(images) < 2:
|
||||||
raise ProcessingError("At least two registered images are required.")
|
raise ProcessingError("At least two registered images are required.")
|
||||||
shape = images[0].shape
|
shape = images[0].shape
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ CH6 = "Color Image Processing"
|
|||||||
|
|
||||||
|
|
||||||
def with_meta(schema, *, label=None, description=None, show_when=None):
|
def with_meta(schema, *, label=None, description=None, show_when=None):
|
||||||
|
"""Attach frontend display metadata to a parameter schema."""
|
||||||
|
|
||||||
if label:
|
if label:
|
||||||
schema["label"] = label
|
schema["label"] = label
|
||||||
if description:
|
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):
|
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)
|
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):
|
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)
|
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):
|
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)
|
return with_meta({"type": "int", "default": default, "min": min_value, "max": max_value, "step": step}, **meta)
|
||||||
|
|
||||||
|
|
||||||
def select_param(default, choices, **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)
|
return with_meta({"type": "select", "default": default, "choices": choices}, **meta)
|
||||||
|
|
||||||
|
|
||||||
def bool_param(default=False, **meta):
|
def bool_param(default=False, **meta):
|
||||||
|
"""Build a schema for a boolean/toggle parameter."""
|
||||||
|
|
||||||
return with_meta({"type": "bool", "default": default}, **meta)
|
return with_meta({"type": "bool", "default": default}, **meta)
|
||||||
|
|
||||||
|
|
||||||
def kernel_preview(title, matrix, scale=None):
|
def kernel_preview(title, matrix, scale=None):
|
||||||
|
"""Describe a single matrix preview shown beside an operation."""
|
||||||
|
|
||||||
return {"title": title, "matrix": matrix, "scale": scale}
|
return {"title": title, "matrix": matrix, "scale": scale}
|
||||||
|
|
||||||
|
|
||||||
def kernel_pair_preview(title, gx, gy):
|
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}]}
|
return {"title": title, "kernels": [{"label": "Gx", "matrix": gx}, {"label": "Gy", "matrix": gy}]}
|
||||||
|
|
||||||
|
|
||||||
def mask_param(default=3, max_value=35, label="Mask size"):
|
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 = odd_param(default, max_value)
|
||||||
schema["label"] = label
|
schema["label"] = label
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def histeq(image, params):
|
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:
|
if image.ndim == 2:
|
||||||
return histogram_equalization(image, params)
|
return histogram_equalization(image, params)
|
||||||
channels = [histogram_equalization(image[:, :, idx], params)[:, :, 0] for idx in range(3)]
|
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):
|
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))
|
red_weight = float(params.get("red_weight", 0.299))
|
||||||
green_weight = float(params.get("green_weight", 0.587))
|
green_weight = float(params.get("green_weight", 0.587))
|
||||||
blue_weight = float(params.get("blue_weight", 0.114))
|
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.")
|
raise ProcessingError("Grayscale weights must have a non-zero finite sum.")
|
||||||
weights = np.array([red_weight, green_weight, blue_weight], dtype=np.float32) / total
|
weights = np.array([red_weight, green_weight, blue_weight], dtype=np.float32) / total
|
||||||
gray = np.tensordot(image.astype(np.float32), weights, axes=([2], [0]))
|
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):
|
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))
|
mean = float(params.get("mean", 0))
|
||||||
variance = float(params.get("variance", 0.01))
|
variance = float(params.get("variance", 0.01))
|
||||||
sigma = math.sqrt(max(variance, 0.0)) * 255.0
|
sigma = math.sqrt(max(variance, 0.0)) * 255.0
|
||||||
@@ -101,6 +136,11 @@ def gaussian_noise(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def salt_pepper_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))
|
amount = float(params.get("amount", 0.03))
|
||||||
salt_ratio = float(params.get("salt_ratio", 0.5))
|
salt_ratio = float(params.get("salt_ratio", 0.5))
|
||||||
output = image.copy()
|
output = image.copy()
|
||||||
@@ -114,13 +154,50 @@ def salt_pepper_noise(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def noise_filter(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")
|
kind = params.get("kind", "gaussian")
|
||||||
if kind == "salt_pepper":
|
if kind == "salt_pepper":
|
||||||
return salt_pepper_noise(image, params)
|
return salt_pepper_noise(image, params)
|
||||||
return gaussian_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):
|
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)))
|
size = int(params.get("K", params.get("size", 3)))
|
||||||
variance = float(params.get("Q", params.get("variance", 1.0)))
|
variance = float(params.get("Q", params.get("variance", 1.0)))
|
||||||
if size < 3 or size % 2 == 0:
|
if size < 3 or size % 2 == 0:
|
||||||
@@ -130,6 +207,11 @@ def gaussian_filter(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def max_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))))
|
size = int(params.get("mask_size", params.get("N", params.get("size", 3))))
|
||||||
if size < 3 or size % 2 == 0:
|
if size < 3 or size % 2 == 0:
|
||||||
raise ProcessingError("Mask size must be an odd integer >= 3.")
|
raise ProcessingError("Mask size must be an odd integer >= 3.")
|
||||||
@@ -137,6 +219,11 @@ def max_filter(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def min_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))))
|
size = int(params.get("mask_size", params.get("N", params.get("size", 3))))
|
||||||
if size < 3 or size % 2 == 0:
|
if size < 3 or size % 2 == 0:
|
||||||
raise ProcessingError("Mask size must be an odd integer >= 3.")
|
raise ProcessingError("Mask size must be an odd integer >= 3.")
|
||||||
@@ -144,28 +231,58 @@ def min_filter(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def box_denoise(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)))})
|
return box_filter(image, {"size": params.get("K", params.get("mask_size", params.get("size", 3)))})
|
||||||
|
|
||||||
|
|
||||||
def weighted_denoise(image, params):
|
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})
|
return weighted_average(image, {"size": 3})
|
||||||
|
|
||||||
|
|
||||||
def median_denoise(image, params):
|
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)))})
|
return median_filter(image, {"size": params.get("mask_size", params.get("N", params.get("size", 3)))})
|
||||||
|
|
||||||
|
|
||||||
def gaussian_denoise(image, params):
|
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)))
|
size = params.get("K", params.get("mask_size", params.get("size", 3)))
|
||||||
variance = params.get("Q", params.get("variance", 1.0))
|
variance = params.get("Q", params.get("variance", 1.0))
|
||||||
return gaussian_filter(image, {"K": size, "Q": variance})
|
return gaussian_filter(image, {"K": size, "Q": variance})
|
||||||
|
|
||||||
|
|
||||||
def high_boost_slide(image, params):
|
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))})
|
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):
|
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")
|
mask_name = params.get("mask", "cross")
|
||||||
kernels = {
|
kernels = {
|
||||||
"cross": np.array([[0, 1, 0], [1, -5, 1], [0, 1, 0]], dtype=np.float32),
|
"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)
|
kernel = kernels.get(mask_name)
|
||||||
if kernel is None:
|
if kernel is None:
|
||||||
raise ProcessingError("Unknown Laplacian mask.")
|
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])]
|
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)
|
result = np.stack(channels, axis=2)
|
||||||
return ensure_uint8(result)
|
return ensure_uint8(result)
|
||||||
|
|
||||||
|
|
||||||
def gradient_abs_sum(image, params):
|
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")
|
operator = params.get("operator", "sobel")
|
||||||
gray = to_gray(image).astype(np.float32)
|
gray = to_gray(image).astype(np.float32)
|
||||||
if operator == "roberts":
|
if operator == "roberts":
|
||||||
@@ -194,12 +318,24 @@ def gradient_abs_sum(image, params):
|
|||||||
|
|
||||||
|
|
||||||
def rgb_channel(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")
|
channel = params.get("channel", "r")
|
||||||
index = {"r": 0, "g": 1, "b": 2}.get(channel, 0)
|
index = {"r": 0, "g": 1, "b": 2}.get(channel, 0)
|
||||||
return gray_to_rgb(image[:, :, index])
|
return gray_to_rgb(image[:, :, index])
|
||||||
|
|
||||||
|
|
||||||
def fft_spectrum(image, params):
|
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)
|
gray = to_gray(image).astype(np.float32)
|
||||||
spectrum = np.fft.fftshift(np.fft.fft2(gray))
|
spectrum = np.fft.fftshift(np.fft.fft2(gray))
|
||||||
mode = params.get("mode", "log_magnitude")
|
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):
|
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 {
|
return {
|
||||||
"id": id,
|
"id": id,
|
||||||
"label": label,
|
"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"}),
|
"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"}),
|
"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."),
|
}, 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("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("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."),
|
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():
|
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]
|
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):
|
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)
|
item = OPERATION_MAP.get(operation_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise ProcessingError(f"Unsupported operation '{operation_id}'.")
|
raise ProcessingError(f"Unsupported operation '{operation_id}'.")
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ from .tasks import run_batch_job
|
|||||||
|
|
||||||
|
|
||||||
def image_session_create(*, uploaded_file=None, image_base64=None):
|
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:
|
if uploaded_file and uploaded_file.size > settings.MAX_UPLOAD_MB * 1024 * 1024:
|
||||||
raise ProcessingError(f"Upload exceeds {settings.MAX_UPLOAD_MB} MB.")
|
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):
|
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))
|
max_dimension = int(getattr(settings, "IMAGE_WORKSPACE_MAX_DIMENSION", 1400))
|
||||||
if max_dimension <= 0:
|
if max_dimension <= 0:
|
||||||
return image
|
return image
|
||||||
@@ -68,6 +78,11 @@ def compact_workspace_image(image):
|
|||||||
|
|
||||||
|
|
||||||
def image_state_create(*, session, parent, image, operation, params, label=None, prefix="state"):
|
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)
|
relative_path = save_image_array(image, prefix)
|
||||||
max_sequence = session.states.aggregate(value=Max("sequence"))["value"]
|
max_sequence = session.states.aggregate(value=Max("sequence"))["value"]
|
||||||
sequence = 0 if max_sequence is None else max_sequence + 1
|
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):
|
def image_state_payload(*, state, include_image=True):
|
||||||
|
"""Serialize an image state for the frontend workspace."""
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"state_id": str(state.id),
|
"state_id": str(state.id),
|
||||||
"session_id": str(state.session_id),
|
"session_id": str(state.session_id),
|
||||||
@@ -114,10 +131,17 @@ def image_state_payload(*, state, include_image=True):
|
|||||||
|
|
||||||
|
|
||||||
def image_states_payload(*, states):
|
def image_states_payload(*, states):
|
||||||
|
"""Serialize a list of image states."""
|
||||||
|
|
||||||
return [image_state_payload(state=state, include_image=True) for state in states]
|
return [image_state_payload(state=state, include_image=True) for state in states]
|
||||||
|
|
||||||
|
|
||||||
def image_state_delete(*, state):
|
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":
|
if state.sequence == 0 or state.operation == "upload":
|
||||||
raise ProcessingError("The original S0 upload state cannot be deleted.")
|
raise ProcessingError("The original S0 upload state cannot be deleted.")
|
||||||
image_path = state.image
|
image_path = state.image
|
||||||
@@ -127,6 +151,11 @@ def image_state_delete(*, state):
|
|||||||
|
|
||||||
|
|
||||||
def image_state_apply_operation(*, state, operation, params):
|
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:
|
if state.session.expired:
|
||||||
raise ProcessingError("Image session has expired.")
|
raise ProcessingError("Image session has expired.")
|
||||||
source = load_image_array(state.image)
|
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):
|
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 {}
|
params = params or {}
|
||||||
if len(states) < 2:
|
if len(states) < 2:
|
||||||
raise ProcessingError("At least two states are required.")
|
raise ProcessingError("At least two states are required.")
|
||||||
@@ -185,6 +219,11 @@ def combine_states(*, states, operation, params=None):
|
|||||||
|
|
||||||
|
|
||||||
def np_clip_sum(images):
|
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")
|
total = np.zeros_like(images[0], dtype="float32")
|
||||||
for image in images:
|
for image in images:
|
||||||
total += image.astype("float32")
|
total += image.astype("float32")
|
||||||
@@ -192,6 +231,11 @@ def np_clip_sum(images):
|
|||||||
|
|
||||||
|
|
||||||
def image_session_process(*, session, operation, params):
|
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:
|
if session.expired:
|
||||||
raise ProcessingError("Image session has 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):
|
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 {})
|
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])
|
run_batch_job.delay(str(job.id), operation, [str(session_id) for session_id in session_ids])
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
def processing_job_payload(*, job):
|
def processing_job_payload(*, job):
|
||||||
|
"""Serialize a processing job, including result image data when available."""
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"job_id": str(job.id),
|
"job_id": str(job.id),
|
||||||
"operation": job.operation,
|
"operation": job.operation,
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ def save_image_array(image, prefix="image"):
|
|||||||
filename = f"sessions/{prefix}-{uuid4().hex}.png"
|
filename = f"sessions/{prefix}-{uuid4().hex}.png"
|
||||||
path = Path(settings.MEDIA_ROOT) / filename
|
path = Path(settings.MEDIA_ROOT) / filename
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
rgb = ensure_uint8(image)
|
array = ensure_uint8(image)
|
||||||
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
if array.ndim == 2:
|
||||||
ok, encoded = cv2.imencode(".png", bgr)
|
encoded_source = array
|
||||||
|
else:
|
||||||
|
encoded_source = cv2.cvtColor(array, cv2.COLOR_RGB2BGR)
|
||||||
|
ok, encoded = cv2.imencode(".png", encoded_source)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise ProcessingError("Unable to encode image for temporary storage.")
|
raise ProcessingError("Unable to encode image for temporary storage.")
|
||||||
path.write_bytes(encoded.tobytes())
|
path.write_bytes(encoded.tobytes())
|
||||||
@@ -32,9 +35,13 @@ def load_image_array(relative_path):
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise ProcessingError("Temporary image file is missing or unreadable.")
|
raise ProcessingError("Temporary image file is missing or unreadable.")
|
||||||
raw = np.frombuffer(path.read_bytes(), dtype=np.uint8)
|
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:
|
if image is None:
|
||||||
raise ProcessingError("Temporary image file is missing or unreadable.")
|
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)
|
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ class AlgorithmTests(SimpleTestCase):
|
|||||||
def test_histogram_equalization_spreads_two_levels(self):
|
def test_histogram_equalization_spreads_two_levels(self):
|
||||||
image = np.array([[0, 0], [255, 255]], dtype=np.uint8)
|
image = np.array([[0, 0], [255, 255]], dtype=np.uint8)
|
||||||
result = histogram_equalization(image, {})
|
result = histogram_equalization(image, {})
|
||||||
expected = np.dstack([image, image, image])
|
np.testing.assert_array_equal(result, image)
|
||||||
np.testing.assert_array_equal(result, expected)
|
|
||||||
|
|
||||||
def test_median_removes_impulse_noise(self):
|
def test_median_removes_impulse_noise(self):
|
||||||
image = np.full((3, 3, 3), 100, dtype=np.uint8)
|
image = np.full((3, 3, 3), 100, dtype=np.uint8)
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ from django.test import TestCase, override_settings
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
from rest_framework.test import APIClient
|
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"):
|
def png_upload(color=(32, 64, 128), size=(4, 4), name="sample.png"):
|
||||||
buffer = BytesIO()
|
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")
|
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):
|
class ApiTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.tmp = tempfile.TemporaryDirectory()
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
@@ -59,9 +68,13 @@ class ApiTests(TestCase):
|
|||||||
self.assertIn("box_filter", operation_ids)
|
self.assertIn("box_filter", operation_ids)
|
||||||
self.assertIn("median_filter", operation_ids)
|
self.assertIn("median_filter", operation_ids)
|
||||||
self.assertIn("noise_filter", operation_ids)
|
self.assertIn("noise_filter", operation_ids)
|
||||||
|
self.assertIn("average_noisy_copies", operation_ids)
|
||||||
self.assertIn("rgb_to_gray", operation_ids)
|
self.assertIn("rgb_to_gray", operation_ids)
|
||||||
self.assertEqual(operations["histeq"]["params"], {})
|
self.assertEqual(operations["histeq"]["params"], {})
|
||||||
self.assertEqual(operations["noise_filter"]["label"], "Noise Filter")
|
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["box_filter"]["label"], "Average / Box Filter")
|
||||||
self.assertEqual(operations["gaussian_filter"]["label"], "Gaussian Filter")
|
self.assertEqual(operations["gaussian_filter"]["label"], "Gaussian Filter")
|
||||||
self.assertFalse(operations["negative"]["repeatable"])
|
self.assertFalse(operations["negative"]["repeatable"])
|
||||||
@@ -173,6 +186,45 @@ class ApiTests(TestCase):
|
|||||||
self.assertEqual(response.data["operation"], "noise_filter")
|
self.assertEqual(response.data["operation"], "noise_filter")
|
||||||
self.assertEqual(response.data["params"]["kind"], "salt_pepper")
|
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):
|
def test_grayscale_operation_creates_state(self):
|
||||||
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
|
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
|
||||||
s0_id = upload.data["states"][0]["state_id"]
|
s0_id = upload.data["states"][0]["state_id"]
|
||||||
@@ -183,6 +235,17 @@ class ApiTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(gray.status_code, 201)
|
self.assertEqual(gray.status_code, 201)
|
||||||
self.assertEqual(gray.data["operation"], "rgb_to_gray")
|
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")
|
@patch("processing.services.run_batch_job.delay")
|
||||||
def test_batch_returns_job_id(self, delay):
|
def test_batch_returns_job_id(self, delay):
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
build: ./backend
|
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"
|
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:
|
env_file:
|
||||||
- ./backend/.env
|
- ${BACKEND_ENV_FILE:-./backend/.env}
|
||||||
environment:
|
environment:
|
||||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
|
||||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me}
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me}
|
||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
build: ./backend
|
build: ./backend
|
||||||
command: celery -A enhancer_project worker --loglevel=info
|
command: celery -A enhancer_project worker --loglevel=info
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ${BACKEND_ENV_FILE:-./backend/.env}
|
||||||
environment:
|
environment:
|
||||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
|
||||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me}
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function CanvasPane({ title, imageData, histogram, transform, onT
|
|||||||
}, [transform]);
|
}, [transform]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-800 bg-zinc-950">
|
<section className="flex min-h-full flex-1 flex-col overflow-hidden border border-zinc-800 bg-zinc-950">
|
||||||
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
|
||||||
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
|
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
|
||||||
<span className="text-xs tabular-nums text-zinc-400">{histogram ? "p(r_k) ready" : "No histogram"}</span>
|
<span className="text-xs tabular-nums text-zinc-400">{histogram ? "p(r_k) ready" : "No histogram"}</span>
|
||||||
|
|||||||
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
Reference in New Issue
Block a user