1 """

2 Centralized image processing utilities for pyPhotoAlbum.

3

4 This module consolidates common image operations to avoid code duplication

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

6 """

7

8 from typing import Tuple

9 from PIL import Image

10

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

12 # Image Processing Utilities

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

14

15

16 def 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

41 def 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

56 def 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

116 def 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

138 def 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

167 def 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

238 def 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

314 def 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

367 def 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