Files
guilan-multimedia-lab/backend/processing/algorithms.py

308 lines
10 KiB
Python

import base64
import math
from io import BytesIO
import cv2
import numpy as np
from PIL import Image
LAPLACIAN_MASK = np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]], dtype=np.float32)
SOBEL_GX = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32)
SOBEL_GY = SOBEL_GX.T
ROBERTS_GX = np.array([[1, 0], [0, -1]], dtype=np.float32)
ROBERTS_GY = np.array([[0, 1], [-1, 0]], dtype=np.float32)
class ProcessingError(ValueError):
pass
def ensure_uint8(image):
return np.clip(image, 0, 255).astype(np.uint8)
def normalize_to_uint8(image):
arr = image.astype(np.float32)
min_value = float(np.min(arr))
max_value = float(np.max(arr))
if math.isclose(min_value, max_value):
return np.zeros(arr.shape, dtype=np.uint8)
return np.round((arr - min_value) * 255.0 / (max_value - min_value)).astype(np.uint8)
def require_odd(value, name="size", minimum=3):
try:
value = int(value)
except (TypeError, ValueError) as exc:
raise ProcessingError(f"{name} must be an odd integer.") from exc
if value < minimum or value % 2 == 0:
raise ProcessingError(f"{name} must be an odd integer >= {minimum}.")
return value
def require_finite_positive(value, name):
try:
value = float(value)
except (TypeError, ValueError) as exc:
raise ProcessingError(f"{name} must be a finite positive number.") from exc
if not np.isfinite(value) or value <= 0:
raise ProcessingError(f"{name} must be a finite positive number.")
return value
def to_gray(image):
if image.ndim == 2:
return image
return cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
def gray_to_rgb(gray):
return cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB)
def histogram(image):
gray = to_gray(image)
counts = np.bincount(gray.ravel(), minlength=256).astype(np.float64)
probabilities = counts / max(gray.size, 1)
return probabilities.round(8).tolist()
def histogram_payload(image):
gray = to_gray(image)
payload = {"intensity": histogram(gray)}
if image.ndim == 3:
payload["r"] = (np.bincount(image[:, :, 0].ravel(), minlength=256).astype(np.float64) / image[:, :, 0].size).round(8).tolist()
payload["g"] = (np.bincount(image[:, :, 1].ravel(), minlength=256).astype(np.float64) / image[:, :, 1].size).round(8).tolist()
payload["b"] = (np.bincount(image[:, :, 2].ravel(), minlength=256).astype(np.float64) / image[:, :, 2].size).round(8).tolist()
return payload
def image_to_data_url(image):
pil_image = Image.fromarray(ensure_uint8(image))
buffer = BytesIO()
pil_image.save(buffer, format="PNG")
payload = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{payload}"
def data_url_to_bytes(value):
if "," in value:
value = value.split(",", 1)[1]
return base64.b64decode(value)
def decode_image(uploaded_file=None, base64_image=None):
if uploaded_file is None and not base64_image:
raise ProcessingError("Provide an image file or base64 image payload.")
if uploaded_file is not None:
raw = uploaded_file.read()
else:
raw = data_url_to_bytes(base64_image)
image = Image.open(BytesIO(raw))
image = image.convert("RGB")
return np.array(image, dtype=np.uint8)
def negative(image, params):
return 255 - image
def logarithmic(image, params):
c = require_finite_positive(params.get("c", 1.0 / math.log(2.0)), "c")
normalized = image.astype(np.float32) / 255.0
transformed = c * np.log1p(normalized)
return ensure_uint8(np.round(np.clip(transformed, 0.0, 1.0) * 255.0))
def gamma(image, params):
gamma_value = require_finite_positive(params.get("gamma", 1.0), "gamma")
c = require_finite_positive(params.get("c", 1.0), "c")
normalized = image.astype(np.float32) / 255.0
transformed = c * np.power(normalized, gamma_value)
return ensure_uint8(np.round(np.clip(transformed, 0.0, 1.0) * 255.0))
def contrast_stretch(image, params):
low = int(params.get("low", 0))
high = int(params.get("high", 255))
if low < 0 or high > 255 or low >= high:
raise ProcessingError("Contrast stretch requires 0 <= low < high <= 255.")
stretched = (image.astype(np.float32) - low) * (255.0 / (high - low))
return ensure_uint8(np.round(stretched))
def gray_slice(image, params):
start = int(params.get("start", 96))
end = int(params.get("end", 160))
if start < 0 or end > 255 or start > end:
raise ProcessingError("Gray-level slicing requires 0 <= start <= end <= 255.")
preserve = bool(params.get("preserve_background", True))
highlight = np.array(params.get("highlight", [255, 64, 64]), dtype=np.uint8)
if highlight.shape != (3,):
raise ProcessingError("highlight must be an RGB triplet.")
gray = to_gray(image)
mask = (gray >= start) & (gray <= end)
base = image.copy() if image.ndim == 3 else gray_to_rgb(gray if preserve else np.zeros_like(gray))
if not preserve:
base = np.zeros((*gray.shape, 3), dtype=np.uint8)
base[mask] = highlight
return base
def bit_plane(image, params):
bit = int(params.get("bit", 7))
if bit < 0 or bit > 7:
raise ProcessingError("bit must be between 0 and 7.")
plane = ((to_gray(image) >> bit) & 1) * 255
return gray_to_rgb(plane.astype(np.uint8))
def histogram_equalization(image, params):
gray = to_gray(image)
counts = np.bincount(gray.ravel(), minlength=256)
cdf = counts.cumsum().astype(np.float64)
nonzero = cdf[cdf > 0]
if nonzero.size == 0:
return gray_to_rgb(gray)
cdf_min = nonzero[0]
denom = gray.size - cdf_min
if denom <= 0:
equalized = np.zeros_like(gray)
else:
lut = np.round((cdf - cdf_min) / denom * 255.0).clip(0, 255).astype(np.uint8)
equalized = lut[gray]
return gray_to_rgb(equalized)
def apply_kernel(image, kernel, normalize_derivative=False):
source = image.astype(np.float32)
if image.ndim == 2:
filtered = cv2.filter2D(source, cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT)
else:
channels = [cv2.filter2D(source[:, :, idx], cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT) for idx in range(source.shape[2])]
filtered = np.stack(channels, axis=2)
if normalize_derivative:
return normalize_to_uint8(filtered)
return ensure_uint8(np.round(filtered))
def filter_float(image, kernel):
source = image.astype(np.float32)
if image.ndim == 2:
return cv2.filter2D(source, cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT)
channels = [cv2.filter2D(source[:, :, idx], cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT) for idx in range(source.shape[2])]
return np.stack(channels, axis=2)
def box_filter(image, params):
size = require_odd(params.get("size", 3), "size")
return cv2.blur(image, (size, size), borderType=cv2.BORDER_REFLECT)
def weighted_average(image, params):
size = require_odd(params.get("size", 3), "size")
if "kernel" in params:
kernel = np.array(params["kernel"], dtype=np.float32)
if kernel.shape != (size, size):
raise ProcessingError("kernel dimensions must match size.")
elif size == 3:
kernel = np.array([[1, 2, 1], [2, 4, 2], [1, 2, 1]], dtype=np.float32)
else:
sigma = max(size / 6.0, 0.1)
ax = np.arange(-(size // 2), size // 2 + 1, dtype=np.float32)
xx, yy = np.meshgrid(ax, ax)
kernel = np.exp(-(xx**2 + yy**2) / (2.0 * sigma**2))
total = float(np.sum(kernel))
if math.isclose(total, 0.0):
raise ProcessingError("weighted average kernel sum must not be zero.")
return apply_kernel(image, kernel / total)
def median_filter(image, params):
size = require_odd(params.get("size", 3), "size")
return cv2.medianBlur(image, size)
def laplacian(image, params):
mode = params.get("mode", "sharpen")
lap = filter_float(image, LAPLACIAN_MASK)
if mode == "edge":
return normalize_to_uint8(lap)
sign = params.get("sign", "add")
source = image.astype(np.float32)
sharpened = source + lap if sign == "add" else source - lap
return ensure_uint8(np.round(sharpened))
def high_boost(image, params):
amplification = float(params.get("amplification", 1.5))
if not np.isfinite(amplification) or amplification < 1.0:
raise ProcessingError("amplification must be >= 1.")
size = require_odd(params.get("size", 3), "size")
blurred = cv2.blur(image, (size, size), borderType=cv2.BORDER_REFLECT).astype(np.float32)
boosted = amplification * image.astype(np.float32) - blurred
return ensure_uint8(np.round(boosted))
def gradient_magnitude(image, gx_kernel, gy_kernel):
gray = to_gray(image).astype(np.float32)
gx = cv2.filter2D(gray, cv2.CV_32F, gx_kernel, borderType=cv2.BORDER_REFLECT)
gy = cv2.filter2D(gray, cv2.CV_32F, gy_kernel, borderType=cv2.BORDER_REFLECT)
magnitude = np.sqrt(gx**2 + gy**2)
return gray_to_rgb(normalize_to_uint8(magnitude))
def sobel(image, params):
return gradient_magnitude(image, SOBEL_GX, SOBEL_GY)
def roberts(image, params):
return gradient_magnitude(image, ROBERTS_GX, ROBERTS_GY)
OPERATIONS = {
"negative": negative,
"log": logarithmic,
"gamma": gamma,
"contrast_stretch": contrast_stretch,
"gray_slice": gray_slice,
"bit_plane": bit_plane,
"hist_equalization": histogram_equalization,
"box_filter": box_filter,
"weighted_average": weighted_average,
"median_filter": median_filter,
"laplacian": laplacian,
"high_boost": high_boost,
"sobel": sobel,
"roberts": roberts,
}
def process_image(image, operation, params=None):
params = params or {}
if operation not in OPERATIONS:
raise ProcessingError(f"Unsupported operation '{operation}'.")
return ensure_uint8(OPERATIONS[operation](ensure_uint8(image), params))
def subtract_images(left, right):
verify_registration([left, right])
diff = left.astype(np.float32) - right.astype(np.float32)
return normalize_to_uint8(np.abs(diff))
def average_images(images):
verify_registration(images)
stack = np.stack([image.astype(np.float32) for image in images], axis=0)
return ensure_uint8(np.round(np.mean(stack, axis=0)))
def verify_registration(images):
if len(images) < 2:
raise ProcessingError("At least two registered images are required.")
shape = images[0].shape
if any(image.shape != shape for image in images[1:]):
raise ProcessingError("Images must have identical width, height, and channel count.")