feat(v4): add docker-compose and production-ready application
This commit is contained in:
@@ -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}'.")
|
||||
|
||||
Reference in New Issue
Block a user