|
|
@ -5,6 +5,7 @@ Default position is bottom-left. |
|
|
""" |
|
|
""" |
|
|
import io |
|
|
import io |
|
|
import os |
|
|
import os |
|
|
|
|
|
import math |
|
|
import logging |
|
|
import logging |
|
|
from pathlib import Path |
|
|
from pathlib import Path |
|
|
from typing import Optional, Union, Tuple |
|
|
from typing import Optional, Union, Tuple |
|
|
@ -14,17 +15,18 @@ from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile |
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
# Default watermark search paths |
|
|
|
|
|
DEFAULT_WATERMARK_PATHS = [ |
|
|
|
|
|
getattr(settings, "WATERMARK_IMAGE_PATH", None), |
|
|
|
|
|
os.path.join(settings.BASE_DIR, "static", "images", "watermark.png"), |
|
|
|
|
|
os.path.join(settings.BASE_DIR, "static", "images", "watermark.svg"), |
|
|
|
|
|
os.path.join(settings.BASE_DIR, "static", "images", "Frame.svg"), |
|
|
|
|
|
] |
|
|
|
|
|
|
|
|
def _get_default_watermark_path() -> Optional[str]: |
|
|
|
|
|
base_dir = getattr(settings, "BASE_DIR", None) or Path(__file__).resolve().parent.parent |
|
|
|
|
|
base_dir_str = str(base_dir) |
|
|
|
|
|
|
|
|
|
|
|
search_paths = [ |
|
|
|
|
|
getattr(settings, "WATERMARK_IMAGE_PATH", None), |
|
|
|
|
|
os.path.join(base_dir_str, "static", "images", "watermark.png"), |
|
|
|
|
|
os.path.join(base_dir_str, "static", "images", "watermark.svg"), |
|
|
|
|
|
os.path.join(base_dir_str, "static", "images", "Frame.svg"), |
|
|
|
|
|
] |
|
|
|
|
|
|
|
|
def _get_default_watermark_path() -> Optional[str]: |
|
|
|
|
|
for p in DEFAULT_WATERMARK_PATHS: |
|
|
|
|
|
|
|
|
for p in search_paths: |
|
|
if p and os.path.exists(p): |
|
|
if p and os.path.exists(p): |
|
|
return p |
|
|
return p |
|
|
return None |
|
|
return None |
|
|
@ -52,8 +54,8 @@ def apply_watermark_to_image( |
|
|
watermark_source: Optional[Union[str, Path, Image.Image]] = None, |
|
|
watermark_source: Optional[Union[str, Path, Image.Image]] = None, |
|
|
position: str = "bottom_left", |
|
|
position: str = "bottom_left", |
|
|
opacity: float = 0.85, |
|
|
opacity: float = 0.85, |
|
|
scale_ratio: float = 0.32, |
|
|
|
|
|
padding: int = 24, |
|
|
|
|
|
|
|
|
scale_ratio: float = 0.25, |
|
|
|
|
|
padding: Optional[int] = None, |
|
|
) -> Image.Image: |
|
|
) -> Image.Image: |
|
|
""" |
|
|
""" |
|
|
Apply a watermark image onto a base PIL Image. |
|
|
Apply a watermark image onto a base PIL Image. |
|
|
@ -63,8 +65,8 @@ def apply_watermark_to_image( |
|
|
watermark_source: Path to watermark image or PIL Image object. |
|
|
watermark_source: Path to watermark image or PIL Image object. |
|
|
position: 'bottom_left' (default) | 'bottom_right' | 'top_right' | 'top_left' | 'center'. |
|
|
position: 'bottom_left' (default) | 'bottom_right' | 'top_right' | 'top_left' | 'center'. |
|
|
opacity: Float 0.0 to 1.0. |
|
|
opacity: Float 0.0 to 1.0. |
|
|
scale_ratio: Watermark width relative to base image width (default 0.32). |
|
|
|
|
|
padding: Pixel padding from borders. |
|
|
|
|
|
|
|
|
scale_ratio: Watermark scale relative to image geometric mean area (default 0.25). |
|
|
|
|
|
padding: Pixel padding from borders. If None, dynamically calculated based on image size. |
|
|
|
|
|
|
|
|
Returns: |
|
|
Returns: |
|
|
New PIL Image with watermark blended. |
|
|
New PIL Image with watermark blended. |
|
|
@ -108,16 +110,38 @@ def apply_watermark_to_image( |
|
|
base_rgba = base_img.convert("RGBA") |
|
|
base_rgba = base_img.convert("RGBA") |
|
|
base_w, base_h = base_rgba.size |
|
|
base_w, base_h = base_rgba.size |
|
|
|
|
|
|
|
|
# Compute responsive dimensions for watermark (clear and prominent) |
|
|
|
|
|
target_wm_w = max(int(base_w * scale_ratio), 120) |
|
|
|
|
|
|
|
|
# 1. Compute responsive dimensions using Geometric Mean Area |
|
|
|
|
|
# This keeps watermark visual area consistent across wide, square, and portrait images |
|
|
|
|
|
ref_dim = math.sqrt(base_w * base_h) |
|
|
|
|
|
target_wm_w = int(ref_dim * scale_ratio) |
|
|
wm_aspect = wm_img.height / wm_img.width |
|
|
wm_aspect = wm_img.height / wm_img.width |
|
|
target_wm_h = max(int(target_wm_w * wm_aspect), 35) |
|
|
|
|
|
|
|
|
|
|
|
# Scale down if height exceeds 45% of base image height |
|
|
|
|
|
if target_wm_h > int(base_h * 0.45): |
|
|
|
|
|
target_wm_h = int(base_h * 0.45) |
|
|
|
|
|
|
|
|
target_wm_h = int(target_wm_w * wm_aspect) |
|
|
|
|
|
|
|
|
|
|
|
# 2. Dynamic aspect-ratio-aware clamping: |
|
|
|
|
|
# - Width constraint: between 14% and 38% of base width |
|
|
|
|
|
# - Height constraint: between 4% and 16% of base height |
|
|
|
|
|
max_w = max(int(base_w * 0.38), 60) |
|
|
|
|
|
min_w = max(int(base_w * 0.14), 40) |
|
|
|
|
|
max_h = max(int(base_h * 0.16), 20) |
|
|
|
|
|
min_h = max(int(base_h * 0.04), 12) |
|
|
|
|
|
|
|
|
|
|
|
if target_wm_w > max_w: |
|
|
|
|
|
target_wm_w = max_w |
|
|
|
|
|
target_wm_h = int(target_wm_w * wm_aspect) |
|
|
|
|
|
if target_wm_h > max_h: |
|
|
|
|
|
target_wm_h = max_h |
|
|
|
|
|
target_wm_w = int(target_wm_h / wm_aspect) |
|
|
|
|
|
if target_wm_w < min_w: |
|
|
|
|
|
target_wm_w = min_w |
|
|
|
|
|
target_wm_h = int(target_wm_w * wm_aspect) |
|
|
|
|
|
if target_wm_h < min_h: |
|
|
|
|
|
target_wm_h = min_h |
|
|
target_wm_w = int(target_wm_h / wm_aspect) |
|
|
target_wm_w = int(target_wm_h / wm_aspect) |
|
|
|
|
|
|
|
|
|
|
|
# Safe clamp to stay within physical bounds |
|
|
|
|
|
target_wm_w = max(1, min(target_wm_w, base_w - 4)) |
|
|
|
|
|
target_wm_h = max(1, min(target_wm_h, base_h - 4)) |
|
|
|
|
|
|
|
|
wm_resized = wm_img.resize((target_wm_w, target_wm_h), Image.Resampling.LANCZOS) |
|
|
wm_resized = wm_img.resize((target_wm_w, target_wm_h), Image.Resampling.LANCZOS) |
|
|
|
|
|
|
|
|
# Adjust watermark opacity |
|
|
# Adjust watermark opacity |
|
|
@ -126,26 +150,32 @@ def apply_watermark_to_image( |
|
|
alpha = ImageEnhance.Brightness(alpha).enhance(max(0.0, min(1.0, opacity))) |
|
|
alpha = ImageEnhance.Brightness(alpha).enhance(max(0.0, min(1.0, opacity))) |
|
|
wm_resized.putalpha(alpha) |
|
|
wm_resized.putalpha(alpha) |
|
|
|
|
|
|
|
|
|
|
|
# Dynamic responsive padding if not explicitly overridden |
|
|
|
|
|
if padding is None: |
|
|
|
|
|
actual_padding = max(8, min(int(min(base_w, base_h) * 0.025), 48)) |
|
|
|
|
|
else: |
|
|
|
|
|
actual_padding = padding |
|
|
|
|
|
|
|
|
# Determine coordinates |
|
|
# Determine coordinates |
|
|
pos = position.lower() |
|
|
pos = position.lower() |
|
|
if pos == "bottom_left": |
|
|
if pos == "bottom_left": |
|
|
x = padding |
|
|
|
|
|
y = base_h - target_wm_h - padding |
|
|
|
|
|
|
|
|
x = actual_padding |
|
|
|
|
|
y = base_h - target_wm_h - actual_padding |
|
|
elif pos == "bottom_right": |
|
|
elif pos == "bottom_right": |
|
|
x = base_w - target_wm_w - padding |
|
|
|
|
|
y = base_h - target_wm_h - padding |
|
|
|
|
|
|
|
|
x = base_w - target_wm_w - actual_padding |
|
|
|
|
|
y = base_h - target_wm_h - actual_padding |
|
|
elif pos == "top_right": |
|
|
elif pos == "top_right": |
|
|
x = base_w - target_wm_w - padding |
|
|
|
|
|
y = padding |
|
|
|
|
|
|
|
|
x = base_w - target_wm_w - actual_padding |
|
|
|
|
|
y = actual_padding |
|
|
elif pos == "top_left": |
|
|
elif pos == "top_left": |
|
|
x = padding |
|
|
|
|
|
y = padding |
|
|
|
|
|
|
|
|
x = actual_padding |
|
|
|
|
|
y = actual_padding |
|
|
elif pos == "center": |
|
|
elif pos == "center": |
|
|
x = (base_w - target_wm_w) // 2 |
|
|
x = (base_w - target_wm_w) // 2 |
|
|
y = (base_h - target_wm_h) // 2 |
|
|
y = (base_h - target_wm_h) // 2 |
|
|
else: |
|
|
else: |
|
|
x = padding |
|
|
|
|
|
y = base_h - target_wm_h - padding |
|
|
|
|
|
|
|
|
x = actual_padding |
|
|
|
|
|
y = base_h - target_wm_h - actual_padding |
|
|
|
|
|
|
|
|
# Keep inside image boundaries |
|
|
# Keep inside image boundaries |
|
|
x = max(0, min(x, base_w - target_wm_w)) |
|
|
x = max(0, min(x, base_w - target_wm_w)) |
|
|
@ -176,8 +206,8 @@ def apply_watermark_to_bytes( |
|
|
watermark_source: Optional[Union[str, Path, Image.Image]] = None, |
|
|
watermark_source: Optional[Union[str, Path, Image.Image]] = None, |
|
|
position: str = "bottom_left", |
|
|
position: str = "bottom_left", |
|
|
opacity: float = 0.85, |
|
|
opacity: float = 0.85, |
|
|
scale_ratio: float = 0.32, |
|
|
|
|
|
padding: int = 24, |
|
|
|
|
|
|
|
|
scale_ratio: float = 0.25, |
|
|
|
|
|
padding: Optional[int] = None, |
|
|
quality: int = 90, |
|
|
quality: int = 90, |
|
|
) -> bytes: |
|
|
) -> bytes: |
|
|
""" |
|
|
""" |
|
|
@ -220,8 +250,8 @@ def apply_watermark_to_upload( |
|
|
watermark_source: Optional[Union[str, Path, Image.Image]] = None, |
|
|
watermark_source: Optional[Union[str, Path, Image.Image]] = None, |
|
|
position: str = "bottom_left", |
|
|
position: str = "bottom_left", |
|
|
opacity: float = 0.85, |
|
|
opacity: float = 0.85, |
|
|
scale_ratio: float = 0.32, |
|
|
|
|
|
padding: int = 24, |
|
|
|
|
|
|
|
|
scale_ratio: float = 0.25, |
|
|
|
|
|
padding: Optional[int] = None, |
|
|
quality: int = 90, |
|
|
quality: int = 90, |
|
|
) -> Optional[SimpleUploadedFile]: |
|
|
) -> Optional[SimpleUploadedFile]: |
|
|
""" |
|
|
""" |
|
|
@ -273,8 +303,8 @@ def apply_watermark_to_field_file( |
|
|
field_file, |
|
|
field_file, |
|
|
position: str = "bottom_left", |
|
|
position: str = "bottom_left", |
|
|
opacity: float = 0.85, |
|
|
opacity: float = 0.85, |
|
|
scale_ratio: float = 0.32, |
|
|
|
|
|
padding: int = 24, |
|
|
|
|
|
|
|
|
scale_ratio: float = 0.25, |
|
|
|
|
|
padding: Optional[int] = None, |
|
|
quality: int = 90, |
|
|
quality: int = 90, |
|
|
): |
|
|
): |
|
|
""" |
|
|
""" |
|
|
@ -316,8 +346,8 @@ try: |
|
|
watermark_source=None, |
|
|
watermark_source=None, |
|
|
position="bottom_left", |
|
|
position="bottom_left", |
|
|
opacity=0.85, |
|
|
opacity=0.85, |
|
|
scale_ratio=0.32, |
|
|
|
|
|
padding=24, |
|
|
|
|
|
|
|
|
scale_ratio=0.25, |
|
|
|
|
|
padding=None, |
|
|
quality=90, |
|
|
quality=90, |
|
|
**kwargs, |
|
|
**kwargs, |
|
|
): |
|
|
): |
|
|
|