DageBjorne
Package project as pip-installable augmenator.
7025ca1
Raw
History Blame Contribute Delete
9.06 kB
import random
import cv2
import numpy as np
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
from augmenator.spatial import apply_spatial_ops
from augmenator.style_transfer import STYLE_MODELS, STYLE_TAGS, apply_style
ALLOWED_TAGS = {
"brighten",
"darken",
"warmer",
"cooler",
"rotate",
"rotate_90_random",
"rotate_left",
"rotate_right",
"rotate_180",
"flip",
"flip_vertical",
"blur",
"sharpen",
"saturate",
"desaturate",
"crop_zoom",
"contrast_up",
"contrast_down",
"gamma_up",
"gamma_down",
"autocontrast",
"posterize",
"sepia",
"hue_shift",
"tint_red",
"tint_green",
"tint_blue",
"invert",
"perspective",
} | set(STYLE_TAGS)
def _clamp_strength(strength: float) -> float:
return max(0.5, min(1.5, strength))
def _warm_cool_pil(image: Image.Image, warmer: bool, amount: float) -> Image.Image:
r, g, b = image.split()
factor = 1.0 + (0.25 * amount if warmer else -0.15 * amount)
r = r.point(lambda p: min(255, int(p * factor)))
b = b.point(lambda p: max(0, int(p * (2.0 - factor))))
return Image.merge("RGB", (r, g, b))
def _apply_sepia(image: Image.Image, amount: float) -> Image.Image:
arr = np.array(image).astype(np.float32)
r, g, b = arr[..., 0], arr[..., 1], arr[..., 2]
tr = 0.393 * r + 0.769 * g + 0.189 * b
tg = 0.349 * r + 0.686 * g + 0.168 * b
tb = 0.272 * r + 0.534 * g + 0.131 * b
blend = amount
out = np.stack(
[
r * (1 - blend) + tr * blend,
g * (1 - blend) + tg * blend,
b * (1 - blend) + tb * blend,
],
axis=-1,
)
return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
def _apply_hue_shift(image: Image.Image, amount: float) -> Image.Image:
arr = np.array(image.convert("RGB"))
hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV).astype(np.float32)
shift = int(18 * amount)
hsv[..., 0] = (hsv[..., 0] + shift) % 180
rgb = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB)
return Image.fromarray(rgb)
def _apply_tint(image: Image.Image, channel: str, amount: float) -> Image.Image:
arr = np.array(image).astype(np.float32)
idx = {"red": 0, "green": 1, "blue": 2}[channel]
boost = 1.0 + 0.35 * amount
arr[..., idx] = np.clip(arr[..., idx] * boost, 0, 255)
return Image.fromarray(arr.astype(np.uint8))
def _rotate_cardinal(image: Image.Image, degrees: int) -> Image.Image:
mapping = {
90: Image.ROTATE_90,
180: Image.ROTATE_180,
270: Image.ROTATE_270,
}
key = degrees % 360
if key not in mapping:
raise ValueError(f"Unsupported cardinal rotation: {degrees}")
return image.transpose(mapping[key])
def _apply_gamma(image: Image.Image, gamma: float) -> Image.Image:
arr = np.array(image.convert("RGB")).astype(np.float32) / 255.0
corrected = np.power(arr, gamma)
return Image.fromarray(np.clip(corrected * 255.0, 0, 255).astype(np.uint8))
def _apply_tag(image: Image.Image, tag: str, strength: float) -> Image.Image:
s = _clamp_strength(strength)
if tag == "brighten":
return ImageEnhance.Brightness(image).enhance(1.0 + 0.3 * s)
if tag == "darken":
return ImageEnhance.Brightness(image).enhance(1.0 - 0.25 * s)
if tag == "warmer":
return _warm_cool_pil(image, warmer=True, amount=s)
if tag == "cooler":
return _warm_cool_pil(image, warmer=False, amount=s)
if tag == "contrast_up":
return ImageEnhance.Contrast(image).enhance(1.0 + 0.4 * s)
if tag == "contrast_down":
return ImageEnhance.Contrast(image).enhance(1.0 - 0.3 * s)
if tag == "gamma_up":
gamma = 1.0 / (1.0 + 0.3 * s)
return _apply_gamma(image, gamma)
if tag == "gamma_down":
gamma = 1.0 + 0.3 * s
return _apply_gamma(image, gamma)
if tag == "autocontrast":
return ImageOps.autocontrast(image.convert("RGB"), cutoff=int(2 * s))
if tag == "posterize":
bits = max(3, int(8 - s))
return ImageOps.posterize(image.convert("RGB"), bits)
if tag == "sepia":
return _apply_sepia(image, s)
if tag == "hue_shift":
return _apply_hue_shift(image, s)
if tag == "tint_red":
return _apply_tint(image, "red", s)
if tag == "tint_green":
return _apply_tint(image, "green", s)
if tag == "tint_blue":
return _apply_tint(image, "blue", s)
if tag == "invert":
return ImageOps.invert(image.convert("RGB"))
if tag == "rotate":
angle = random.uniform(0, 360)
fill = (128, 128, 128) if image.mode == "RGB" else (128, 128, 128, 255)
rotated = image.rotate(angle, expand=True, fillcolor=fill)
return rotated, f"rotate({angle:.1f}°)"
if tag == "rotate_90_random":
degrees = random.choice([90, 270])
direction = "CCW" if degrees == 90 else "CW"
return _rotate_cardinal(image, degrees), f"rotate_90({direction})"
if tag == "rotate_left":
return _rotate_cardinal(image, 90), "rotate_left(90° CCW)"
if tag == "rotate_right":
return _rotate_cardinal(image, 270), "rotate_right(90° CW)"
if tag == "rotate_180":
return _rotate_cardinal(image, 180), "rotate_180"
if tag == "flip":
return image.transpose(Image.FLIP_LEFT_RIGHT)
if tag == "flip_vertical":
return image.transpose(Image.FLIP_TOP_BOTTOM)
if tag == "blur":
return image.filter(ImageFilter.GaussianBlur(radius=1.5 * s))
if tag == "sharpen":
return image.filter(ImageFilter.UnsharpMask(radius=2, percent=int(120 * s)))
if tag == "saturate":
return ImageEnhance.Color(image).enhance(1.0 + 0.4 * s)
if tag == "desaturate":
return ImageEnhance.Color(image).enhance(1.0 - 0.35 * s)
if tag == "crop_zoom":
w, h = image.size
crop_ratio = max(0.6, 0.85 - 0.1 * (s - 1.0))
tw, th = int(w * crop_ratio), int(h * crop_ratio)
left = (w - tw) // 2
top = (h - th) // 2
cropped = image.crop((left, top, left + tw, top + th))
return cropped.resize((w, h), Image.Resampling.LANCZOS)
if tag == "perspective":
return _apply_perspective(image, s)
if tag in STYLE_TAGS:
styled = apply_style(image, tag, strength)
return styled, f"style({STYLE_MODELS[tag]['label']})"
return image
def _apply_perspective(image: Image.Image, strength: float) -> tuple[Image.Image, str]:
"""Perspective warp with output canvas fitted to include the full warped image."""
arr = np.array(image.convert("RGB"))
height, width = arr.shape[:2]
margin = 0.10 * strength * min(width, height)
src = np.float32([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]])
offsets = np.random.uniform(-margin, margin, size=(4, 2)).astype(np.float32)
dst = src + offsets
matrix = cv2.getPerspectiveTransform(src, dst)
warped_corners = cv2.perspectiveTransform(src.reshape(1, 4, 2), matrix).reshape(4, 2)
x_min, y_min = warped_corners.min(axis=0)
x_max, y_max = warped_corners.max(axis=0)
translate = np.array([[1, 0, -x_min], [0, 1, -y_min], [0, 0, 1]], dtype=np.float32)
full_matrix = translate @ matrix
out_w = max(1, int(np.ceil(x_max - x_min)))
out_h = max(1, int(np.ceil(y_max - y_min)))
warped = cv2.warpPerspective(
arr,
full_matrix,
(out_w, out_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
return Image.fromarray(warped), f"perspective(fitted {out_w}x{out_h})"
def apply_augmentations(
image: Image.Image,
tags: list[str],
strength: float = 1.0,
spatial_ops: list[dict] | None = None,
instruction: str = "",
) -> tuple[Image.Image, list[str], list[dict]]:
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGB")
applied = []
applied_spatial = []
result = image.copy()
# Spatial ops (cover/cutout) run first so OCR text boxes stay aligned
# with the original image before geometric transforms like rotate.
if spatial_ops:
result, applied_spatial = apply_spatial_ops(
result, spatial_ops, strength=strength, instruction=instruction
)
for tag in tags:
if tag not in ALLOWED_TAGS or tag in STYLE_TAGS:
continue
out = _apply_tag(result, tag, strength)
if isinstance(out, tuple):
result, label = out
applied.append(label)
else:
result = out
applied.append(tag)
for tag in tags:
if tag not in STYLE_TAGS:
continue
out = _apply_tag(result, tag, strength)
if isinstance(out, tuple):
result, label = out
applied.append(label)
else:
result = out
applied.append(tag)
if result.mode == "RGBA":
pass
else:
result = result.convert("RGB")
return result, applied, applied_spatial