Coverage for pyPhotoAlbum/image_utils.py: 76%
150 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Centralized image processing utilities for pyPhotoAlbum.
4This module consolidates common image operations to avoid code duplication
5across models.py, pdf_exporter.py, and async_backend.py.
6"""
8from typing import Tuple
9from PIL import Image
11# =============================================================================
12# Image Processing Utilities
13# =============================================================================
16def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
17 """
18 Apply 90-degree rotation increments to a PIL image.
20 Args:
21 image: PIL Image to rotate
22 pil_rotation_90: Number of 90-degree rotations (0, 1, 2, or 3)
24 Returns:
25 Rotated PIL Image (or original if no rotation needed)
26 """
27 if pil_rotation_90 <= 0:
28 return image
30 angle = pil_rotation_90 * 90
31 if angle == 90:
32 return image.transpose(Image.Transpose.ROTATE_270) # CCW 90 = rotate right
33 elif angle == 180:
34 return image.transpose(Image.Transpose.ROTATE_180)
35 elif angle == 270:
36 return image.transpose(Image.Transpose.ROTATE_90) # CCW 270 = rotate left
38 return image
41def convert_to_rgba(image: Image.Image) -> Image.Image:
42 """
43 Convert image to RGBA mode if not already.
45 Args:
46 image: PIL Image in any mode
48 Returns:
49 PIL Image in RGBA mode
50 """
51 if image.mode != "RGBA":
52 return image.convert("RGBA")
53 return image
56def calculate_center_crop_coords(
57 img_width: int,
58 img_height: int,
59 target_width: float,
60 target_height: float,
61 crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1),
62) -> Tuple[float, float, float, float]:
63 """
64 Calculate texture/crop coordinates for center-crop fitting an image to a target aspect ratio.
66 This implements the center-crop algorithm used for fitting images into frames
67 while preserving aspect ratio. The image is scaled to cover the target area,
68 then the excess is cropped equally from both sides.
70 Args:
71 img_width: Source image width in pixels
72 img_height: Source image height in pixels
73 target_width: Target frame width (any unit, only ratio matters)
74 target_height: Target frame height (any unit, only ratio matters)
75 crop_info: Additional crop range as (x_min, y_min, x_max, y_max) in 0-1 range
76 Default (0, 0, 1, 1) means no additional cropping
78 Returns:
79 Tuple of (tx_min, ty_min, tx_max, ty_max) texture coordinates in 0-1 range
80 """
81 crop_x_min, crop_y_min, crop_x_max, crop_y_max = crop_info
83 img_aspect = img_width / img_height
84 target_aspect = target_width / target_height
86 # Calculate base texture coordinates for center crop
87 if img_aspect > target_aspect:
88 # Image is wider than target - crop horizontally
89 scale = target_aspect / img_aspect
90 tx_offset = (1.0 - scale) / 2.0
91 tx_min_base = tx_offset
92 tx_max_base = 1.0 - tx_offset
93 ty_min_base = 0.0
94 ty_max_base = 1.0
95 else:
96 # Image is taller than target - crop vertically
97 scale = img_aspect / target_aspect
98 ty_offset = (1.0 - scale) / 2.0
99 tx_min_base = 0.0
100 tx_max_base = 1.0
101 ty_min_base = ty_offset
102 ty_max_base = 1.0 - ty_offset
104 # Apply additional crop from crop_info (for spanning elements, user crops, etc.)
105 tx_range = tx_max_base - tx_min_base
106 ty_range = ty_max_base - ty_min_base
108 tx_min = tx_min_base + crop_x_min * tx_range
109 tx_max = tx_min_base + crop_x_max * tx_range
110 ty_min = ty_min_base + crop_y_min * ty_range
111 ty_max = ty_min_base + crop_y_max * ty_range
113 return (tx_min, ty_min, tx_max, ty_max)
116def crop_image_to_coords(image: Image.Image, coords: Tuple[float, float, float, float]) -> Image.Image:
117 """
118 Crop an image using normalized texture coordinates.
120 Args:
121 image: PIL Image to crop
122 coords: Tuple of (tx_min, ty_min, tx_max, ty_max) in 0-1 range
124 Returns:
125 Cropped PIL Image
126 """
127 tx_min, ty_min, tx_max, ty_max = coords
128 img_width, img_height = image.size
130 crop_left_px = int(tx_min * img_width)
131 crop_right_px = int(tx_max * img_width)
132 crop_top_px = int(ty_min * img_height)
133 crop_bottom_px = int(ty_max * img_height)
135 return image.crop((crop_left_px, crop_top_px, crop_right_px, crop_bottom_px))
138def resize_to_fit(
139 image: Image.Image, max_size: int, resample: Image.Resampling = Image.Resampling.LANCZOS
140) -> Image.Image:
141 """
142 Resize image to fit within max_size while preserving aspect ratio.
144 Args:
145 image: PIL Image to resize
146 max_size: Maximum dimension (width or height)
147 resample: Resampling filter (default LANCZOS for quality)
149 Returns:
150 Resized PIL Image, or original if already smaller
151 """
152 if image.width <= max_size and image.height <= max_size:
153 return image
155 scale = min(max_size / image.width, max_size / image.height)
156 new_width = int(image.width * scale)
157 new_height = int(image.height * scale)
159 return image.resize((new_width, new_height), resample)
162# =============================================================================
163# Image Styling Utilities
164# =============================================================================
167def apply_rounded_corners(
168 image: Image.Image,
169 radius_percent: float,
170 antialias: bool = True,
171) -> Image.Image:
172 """
173 Apply rounded corners to an image.
175 Args:
176 image: PIL Image (should be RGBA)
177 radius_percent: Corner radius as percentage of shorter side (0-50)
178 antialias: If True, use supersampling for smooth antialiased edges
180 Returns:
181 PIL Image with rounded corners (transparent outside corners)
182 """
183 from PIL import ImageDraw
185 if radius_percent <= 0:
186 return image
188 # Ensure RGBA mode for transparency
189 if image.mode != "RGBA":
190 image = image.convert("RGBA")
192 width, height = image.size
193 shorter_side = min(width, height)
195 # Clamp radius to 0-50%
196 radius_percent = max(0, min(50, radius_percent))
197 radius = int(shorter_side * radius_percent / 100)
199 if radius <= 0:
200 return image
202 # Use supersampling for antialiasing
203 if antialias:
204 # Create mask at higher resolution (4x), then downscale for smooth edges
205 supersample_factor = 4
206 ss_width = width * supersample_factor
207 ss_height = height * supersample_factor
208 ss_radius = radius * supersample_factor
210 mask_large = Image.new("L", (ss_width, ss_height), 0)
211 draw = ImageDraw.Draw(mask_large)
212 draw.rounded_rectangle([0, 0, ss_width - 1, ss_height - 1], radius=ss_radius, fill=255)
214 # Downscale with LANCZOS for smooth antialiased edges
215 mask = mask_large.resize((width, height), Image.Resampling.LANCZOS)
216 else:
217 # Original non-antialiased path
218 mask = Image.new("L", (width, height), 0)
219 draw = ImageDraw.Draw(mask)
220 draw.rounded_rectangle([0, 0, width - 1, height - 1], radius=radius, fill=255)
222 # Apply mask to alpha channel
223 result = image.copy()
224 if result.mode == "RGBA":
225 # Composite with existing alpha
226 r, g, b, a = result.split()
227 # Combine existing alpha with our mask
228 from PIL import ImageChops
230 new_alpha = ImageChops.multiply(a, mask)
231 result = Image.merge("RGBA", (r, g, b, new_alpha))
232 else:
233 result.putalpha(mask)
235 return result
238def apply_drop_shadow(
239 image: Image.Image,
240 offset: Tuple[float, float] = (2.0, 2.0),
241 blur_radius: float = 3.0,
242 shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
243 expand: bool = True,
244) -> Image.Image:
245 """
246 Apply a drop shadow effect to an image.
248 Args:
249 image: PIL Image (should be RGBA with transparency for best results)
250 offset: Shadow offset in pixels (x, y)
251 blur_radius: Shadow blur radius in pixels
252 shadow_color: Shadow color as RGBA tuple (0-255)
253 expand: If True, expand canvas to fit shadow; if False, shadow may be clipped
255 Returns:
256 PIL Image with drop shadow
257 """
258 from PIL import ImageFilter
260 # Ensure RGBA
261 if image.mode != "RGBA":
262 image = image.convert("RGBA")
264 offset_x, offset_y = int(offset[0]), int(offset[1])
265 blur_radius = max(0, int(blur_radius))
267 # Calculate canvas expansion needed
268 if expand:
269 # Account for blur spread and offset
270 padding = blur_radius * 2 + max(abs(offset_x), abs(offset_y))
271 new_width = image.width + padding * 2
272 new_height = image.height + padding * 2
273 img_x = padding
274 img_y = padding
275 else:
276 new_width = image.width
277 new_height = image.height
278 padding = 0
279 img_x = 0
280 img_y = 0
282 # Create shadow layer from alpha channel
283 _, _, _, alpha = image.split()
285 # Create shadow image (same shape as alpha, filled with shadow color)
286 shadow = Image.new("RGBA", (image.width, image.height), shadow_color[:3] + (0,))
287 shadow.putalpha(alpha)
289 # Apply blur to shadow
290 if blur_radius > 0:
291 shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius))
293 # Adjust shadow alpha based on shadow_color alpha
294 if shadow_color[3] < 255:
295 r, g, b, a = shadow.split()
296 # Scale alpha by shadow_color alpha
297 a = a.point(lambda x: int(x * shadow_color[3] / 255))
298 shadow = Image.merge("RGBA", (r, g, b, a))
300 # Create result canvas
301 result = Image.new("RGBA", (new_width, new_height), (0, 0, 0, 0))
303 # Paste shadow (offset from image position)
304 shadow_x = img_x + offset_x
305 shadow_y = img_y + offset_y
306 result.paste(shadow, (shadow_x, shadow_y), shadow)
308 # Paste original image on top
309 result.paste(image, (img_x, img_y), image)
311 return result
314def create_border_image(
315 width: int,
316 height: int,
317 border_width: int,
318 border_color: Tuple[int, int, int] = (0, 0, 0),
319 corner_radius: int = 0,
320) -> Image.Image:
321 """
322 Create an image with just a border (transparent center).
324 Args:
325 width: Image width in pixels
326 height: Image height in pixels
327 border_width: Border width in pixels
328 border_color: Border color as RGB tuple (0-255)
329 corner_radius: Corner radius in pixels (0 for square corners)
331 Returns:
332 PIL Image with border only (RGBA with transparent center)
333 """
334 from PIL import ImageDraw
336 if border_width <= 0:
337 return Image.new("RGBA", (width, height), (0, 0, 0, 0))
339 result = Image.new("RGBA", (width, height), (0, 0, 0, 0))
340 draw = ImageDraw.Draw(result)
342 # Draw outer rounded rectangle
343 outer_color = border_color + (255,) # Add full alpha
344 if corner_radius > 0:
345 draw.rounded_rectangle(
346 [0, 0, width - 1, height - 1],
347 radius=corner_radius,
348 fill=outer_color,
349 )
350 # Draw inner transparent area
351 inner_radius = max(0, corner_radius - border_width)
352 draw.rounded_rectangle(
353 [border_width, border_width, width - 1 - border_width, height - 1 - border_width],
354 radius=inner_radius,
355 fill=(0, 0, 0, 0),
356 )
357 else:
358 draw.rectangle([0, 0, width - 1, height - 1], fill=outer_color)
359 draw.rectangle(
360 [border_width, border_width, width - 1 - border_width, height - 1 - border_width],
361 fill=(0, 0, 0, 0),
362 )
364 return result
367def apply_style_to_image(
368 image: Image.Image,
369 corner_radius: float = 0.0,
370 border_width: float = 0.0,
371 border_color: Tuple[int, int, int] = (0, 0, 0),
372 shadow_enabled: bool = False,
373 shadow_offset: Tuple[float, float] = (2.0, 2.0),
374 shadow_blur: float = 3.0,
375 shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
376 dpi: float = 96.0,
377) -> Image.Image:
378 """
379 Apply all styling effects to an image in the correct order.
381 Args:
382 image: Source PIL Image
383 corner_radius: Corner radius as percentage (0-50)
384 border_width: Border width in mm
385 border_color: Border color as RGB (0-255)
386 shadow_enabled: Whether to apply drop shadow
387 shadow_offset: Shadow offset in mm (x, y)
388 shadow_blur: Shadow blur in mm
389 shadow_color: Shadow color as RGBA (0-255)
390 dpi: DPI for converting mm to pixels
392 Returns:
393 Styled PIL Image
394 """
395 # Ensure RGBA
396 result = convert_to_rgba(image)
398 # Convert mm to pixels
399 mm_to_px = dpi / 25.4
400 border_width_px = int(border_width * mm_to_px)
401 shadow_offset_px = (shadow_offset[0] * mm_to_px, shadow_offset[1] * mm_to_px)
402 shadow_blur_px = shadow_blur * mm_to_px
404 # 1. Apply rounded corners first
405 if corner_radius > 0:
406 result = apply_rounded_corners(result, corner_radius)
408 # 2. Apply border (composite border image on top)
409 if border_width_px > 0:
410 shorter_side = min(result.width, result.height)
411 corner_radius_px = int(shorter_side * min(50, corner_radius) / 100) if corner_radius > 0 else 0
413 border_img = create_border_image(
414 result.width,
415 result.height,
416 border_width_px,
417 border_color,
418 corner_radius_px,
419 )
420 result = Image.alpha_composite(result, border_img)
422 # 3. Apply shadow last (expands canvas)
423 if shadow_enabled:
424 result = apply_drop_shadow(
425 result,
426 offset=shadow_offset_px,
427 blur_radius=shadow_blur_px,
428 shadow_color=shadow_color,
429 expand=True,
430 )
432 return result