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

1""" 

2Centralized image processing utilities for pyPhotoAlbum. 

3 

4This module consolidates common image operations to avoid code duplication 

5across models.py, pdf_exporter.py, and async_backend.py. 

6""" 

7 

8from typing import Tuple 

9from PIL import Image 

10 

11# ============================================================================= 

12# Image Processing Utilities 

13# ============================================================================= 

14 

15 

16def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image: 

17 """ 

18 Apply 90-degree rotation increments to a PIL image. 

19 

20 Args: 

21 image: PIL Image to rotate 

22 pil_rotation_90: Number of 90-degree rotations (0, 1, 2, or 3) 

23 

24 Returns: 

25 Rotated PIL Image (or original if no rotation needed) 

26 """ 

27 if pil_rotation_90 <= 0: 

28 return image 

29 

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 

37 

38 return image 

39 

40 

41def convert_to_rgba(image: Image.Image) -> Image.Image: 

42 """ 

43 Convert image to RGBA mode if not already. 

44 

45 Args: 

46 image: PIL Image in any mode 

47 

48 Returns: 

49 PIL Image in RGBA mode 

50 """ 

51 if image.mode != "RGBA": 

52 return image.convert("RGBA") 

53 return image 

54 

55 

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. 

65 

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. 

69 

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 

77 

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 

82 

83 img_aspect = img_width / img_height 

84 target_aspect = target_width / target_height 

85 

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 

103 

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 

107 

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 

112 

113 return (tx_min, ty_min, tx_max, ty_max) 

114 

115 

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. 

119 

120 Args: 

121 image: PIL Image to crop 

122 coords: Tuple of (tx_min, ty_min, tx_max, ty_max) in 0-1 range 

123 

124 Returns: 

125 Cropped PIL Image 

126 """ 

127 tx_min, ty_min, tx_max, ty_max = coords 

128 img_width, img_height = image.size 

129 

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) 

134 

135 return image.crop((crop_left_px, crop_top_px, crop_right_px, crop_bottom_px)) 

136 

137 

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. 

143 

144 Args: 

145 image: PIL Image to resize 

146 max_size: Maximum dimension (width or height) 

147 resample: Resampling filter (default LANCZOS for quality) 

148 

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 

154 

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) 

158 

159 return image.resize((new_width, new_height), resample) 

160 

161 

162# ============================================================================= 

163# Image Styling Utilities 

164# ============================================================================= 

165 

166 

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. 

174 

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 

179 

180 Returns: 

181 PIL Image with rounded corners (transparent outside corners) 

182 """ 

183 from PIL import ImageDraw 

184 

185 if radius_percent <= 0: 

186 return image 

187 

188 # Ensure RGBA mode for transparency 

189 if image.mode != "RGBA": 

190 image = image.convert("RGBA") 

191 

192 width, height = image.size 

193 shorter_side = min(width, height) 

194 

195 # Clamp radius to 0-50% 

196 radius_percent = max(0, min(50, radius_percent)) 

197 radius = int(shorter_side * radius_percent / 100) 

198 

199 if radius <= 0: 

200 return image 

201 

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 

209 

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) 

213 

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) 

221 

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 

229 

230 new_alpha = ImageChops.multiply(a, mask) 

231 result = Image.merge("RGBA", (r, g, b, new_alpha)) 

232 else: 

233 result.putalpha(mask) 

234 

235 return result 

236 

237 

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. 

247 

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 

254 

255 Returns: 

256 PIL Image with drop shadow 

257 """ 

258 from PIL import ImageFilter 

259 

260 # Ensure RGBA 

261 if image.mode != "RGBA": 

262 image = image.convert("RGBA") 

263 

264 offset_x, offset_y = int(offset[0]), int(offset[1]) 

265 blur_radius = max(0, int(blur_radius)) 

266 

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 

281 

282 # Create shadow layer from alpha channel 

283 _, _, _, alpha = image.split() 

284 

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) 

288 

289 # Apply blur to shadow 

290 if blur_radius > 0: 

291 shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius)) 

292 

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

299 

300 # Create result canvas 

301 result = Image.new("RGBA", (new_width, new_height), (0, 0, 0, 0)) 

302 

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) 

307 

308 # Paste original image on top 

309 result.paste(image, (img_x, img_y), image) 

310 

311 return result 

312 

313 

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). 

323 

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) 

330 

331 Returns: 

332 PIL Image with border only (RGBA with transparent center) 

333 """ 

334 from PIL import ImageDraw 

335 

336 if border_width <= 0: 

337 return Image.new("RGBA", (width, height), (0, 0, 0, 0)) 

338 

339 result = Image.new("RGBA", (width, height), (0, 0, 0, 0)) 

340 draw = ImageDraw.Draw(result) 

341 

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 ) 

363 

364 return result 

365 

366 

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. 

380 

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 

391 

392 Returns: 

393 Styled PIL Image 

394 """ 

395 # Ensure RGBA 

396 result = convert_to_rgba(image) 

397 

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 

403 

404 # 1. Apply rounded corners first 

405 if corner_radius > 0: 

406 result = apply_rounded_corners(result, corner_radius) 

407 

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 

412 

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) 

421 

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 ) 

431 

432 return result