feat(v4): add docker-compose and production-ready application

This commit is contained in:
2026-07-09 10:15:44 +03:30
parent 2112e00982
commit ece63caa22
13 changed files with 482 additions and 30 deletions

View File

@@ -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