smart-image-cropper 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ """Smart Image Cropper - An intelligent image cropping library."""
2
+
3
+ from .cropper import SmartImageCropper
4
+ from .exceptions import SmartCropperError, APIError, ImageProcessingError
5
+
6
+ __version__ = "1.0.0"
7
+ __author__ = "Giulio Manuzzi"
8
+ __email__ = "giuliomanuzzi@gmail.com"
9
+
10
+ __all__ = ["SmartImageCropper", "SmartCropperError",
11
+ "APIError", "ImageProcessingError"]
@@ -0,0 +1,602 @@
1
+ """Main Smart Image Cropper implementation."""
2
+
3
+ import base64
4
+ import logging
5
+ import time
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import List, Optional, Tuple, Union
9
+
10
+ import cv2
11
+ import numpy as np
12
+ import requests
13
+ from PIL import Image
14
+
15
+ from .exceptions import APIError, ImageProcessingError
16
+ from .utils import ImageUtils
17
+
18
+ logging.basicConfig(
19
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
20
+ )
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class AspectRatio(Enum):
25
+ """Supported target aspect ratios."""
26
+
27
+ PORTRAIT_4_5 = (4, 5) # 0.8
28
+ PORTRAIT_3_4 = (3, 4) # 0.75
29
+ SQUARE_1_1 = (1, 1) # 1.0
30
+ LANDSCAPE_4_3 = (4, 3) # 1.33
31
+
32
+ @property
33
+ def ratio(self) -> float:
34
+ return self.value[0] / self.value[1]
35
+
36
+
37
+ class CollageDirection(Enum):
38
+ """Directions for the collage."""
39
+
40
+ VERTICAL = "vertical"
41
+ HORIZONTAL = "horizontal"
42
+
43
+
44
+ @dataclass
45
+ class BoundingBox:
46
+ x1: int
47
+ y1: int
48
+ x2: int
49
+ y2: int
50
+
51
+ @property
52
+ def width(self) -> int:
53
+ return self.x2 - self.x1
54
+
55
+ @property
56
+ def height(self) -> int:
57
+ return self.y2 - self.y1
58
+
59
+ @property
60
+ def area(self) -> int:
61
+ return self.width * self.height
62
+
63
+ @property
64
+ def aspect_ratio(self) -> float:
65
+ return self.width / self.height
66
+
67
+ def get_shape_type(self) -> str:
68
+ """Determine if the bbox is square, horizontal rectangle, or vertical rectangle."""
69
+ width_height_diff = abs(self.width - self.height) / \
70
+ max(self.width, self.height)
71
+
72
+ if width_height_diff <= 0.05:
73
+ return "square"
74
+ elif self.width > self.height:
75
+ return "horizontal"
76
+ else:
77
+ return "vertical"
78
+
79
+
80
+ class AspectRatioCalculator:
81
+ """Handles aspect ratio calculations."""
82
+
83
+ @staticmethod
84
+ def find_closest_target_ratio(current_ratio: float) -> AspectRatio:
85
+ """Find the target format closest to the current ratio."""
86
+ min_diff = float("inf")
87
+ closest_ratio = AspectRatio.SQUARE_1_1
88
+
89
+ for ratio in AspectRatio:
90
+ diff = abs(current_ratio - ratio.ratio)
91
+ if diff < min_diff:
92
+ min_diff = diff
93
+ closest_ratio = ratio
94
+
95
+ logger.info(
96
+ f"Current ratio: {current_ratio:.2f}, selected target: {closest_ratio.value[0]}:{closest_ratio.value[1]}"
97
+ )
98
+ return closest_ratio
99
+
100
+ @staticmethod
101
+ def needs_expansion(
102
+ current_ratio: float, target_ratio: float, tolerance: float = 0.05
103
+ ) -> bool:
104
+ """Check if expansion is needed to reach the target ratio."""
105
+ return abs(current_ratio - target_ratio) > tolerance
106
+
107
+
108
+ class BoundingBoxExpander:
109
+ """Handles the expansion of bounding boxes."""
110
+
111
+ @staticmethod
112
+ def expand_to_width(
113
+ img: np.ndarray, bbox: BoundingBox, target_width: int
114
+ ) -> BoundingBox:
115
+ """Expand a bbox to reach the target width."""
116
+ img_height, img_width = img.shape[:2]
117
+ current_width = bbox.width
118
+
119
+ if current_width >= target_width:
120
+ return bbox
121
+
122
+ extra_width = target_width - current_width
123
+ left_expand = extra_width // 2
124
+ right_expand = extra_width - left_expand
125
+
126
+ new_x1 = max(0, bbox.x1 - left_expand)
127
+ new_x2 = min(img_width, bbox.x2 + right_expand)
128
+
129
+ # Adjust if we couldn't expand enough
130
+ if new_x2 - new_x1 < target_width:
131
+ if new_x1 > 0:
132
+ new_x1 = max(0, new_x2 - target_width)
133
+ else:
134
+ new_x2 = min(img_width, new_x1 + target_width)
135
+
136
+ return BoundingBox(new_x1, bbox.y1, new_x2, bbox.y2)
137
+
138
+ @staticmethod
139
+ def expand_to_height(
140
+ img: np.ndarray, bbox: BoundingBox, target_height: int
141
+ ) -> BoundingBox:
142
+ """Expand a bbox to reach the target height."""
143
+ img_height, img_width = img.shape[:2]
144
+ current_height = bbox.height
145
+
146
+ if current_height >= target_height:
147
+ return bbox
148
+
149
+ extra_height = target_height - current_height
150
+ top_expand = extra_height // 2
151
+ bottom_expand = extra_height - top_expand
152
+
153
+ new_y1 = max(0, bbox.y1 - top_expand)
154
+ new_y2 = min(img_height, bbox.y2 + bottom_expand)
155
+
156
+ # Adjust if we couldn't expand enough
157
+ if new_y2 - new_y1 < target_height:
158
+ if new_y1 > 0:
159
+ new_y1 = max(0, new_y2 - target_height)
160
+ else:
161
+ new_y2 = min(img_height, new_y1 + target_height)
162
+
163
+ return BoundingBox(bbox.x1, new_y1, bbox.x2, new_y2)
164
+
165
+ @staticmethod
166
+ def expand_to_aspect_ratio(
167
+ img: np.ndarray, bbox: BoundingBox, target_ratio: AspectRatio
168
+ ) -> BoundingBox:
169
+ """Expand a bbox to reach the target format."""
170
+ current_ratio = bbox.aspect_ratio
171
+ target_ratio_value = target_ratio.ratio
172
+
173
+ logger.info(
174
+ f"Expanding bbox from {bbox.width}x{bbox.height} (ratio: {current_ratio:.3f}) to target ratio {target_ratio.value[0]}:{target_ratio.value[1]} ({target_ratio_value:.3f})"
175
+ )
176
+
177
+ if not AspectRatioCalculator.needs_expansion(current_ratio, target_ratio_value):
178
+ logger.info(
179
+ "Bbox already has correct aspect ratio, no expansion needed")
180
+ return bbox
181
+
182
+ expanded_bbox = bbox
183
+ img_height, img_width = img.shape[:2]
184
+
185
+ if current_ratio < target_ratio_value:
186
+ # Too tall, expand width
187
+ ideal_width = int(bbox.height * target_ratio_value)
188
+ target_width = min(img_width, ideal_width)
189
+
190
+ if target_width > bbox.width:
191
+ expanded_bbox = BoundingBoxExpander.expand_to_width(
192
+ img, expanded_bbox, target_width
193
+ )
194
+ logger.info(f"Expanded width to {expanded_bbox.width}")
195
+ else:
196
+ # Too wide, expand height
197
+ ideal_height = int(bbox.width / target_ratio_value)
198
+ target_height = min(img_height, ideal_height)
199
+
200
+ if target_height > bbox.height:
201
+ expanded_bbox = BoundingBoxExpander.expand_to_height(
202
+ img, expanded_bbox, target_height
203
+ )
204
+ logger.info(f"Expanded height to {expanded_bbox.height}")
205
+
206
+ final_ratio = expanded_bbox.aspect_ratio
207
+ logger.info(
208
+ f"Final bbox: {expanded_bbox.width}x{expanded_bbox.height}, ratio: {final_ratio:.3f}"
209
+ )
210
+
211
+ if AspectRatioCalculator.needs_expansion(
212
+ final_ratio, target_ratio_value, tolerance=0.05
213
+ ):
214
+ logger.warning(
215
+ f"Could not reach target ratio {target_ratio_value:.3f}, achieved {final_ratio:.3f}"
216
+ )
217
+
218
+ return expanded_bbox
219
+
220
+
221
+ class ImageCropper:
222
+ """Handles image cropping and manipulation."""
223
+
224
+ @staticmethod
225
+ def crop_image(image_bytes: bytes, bbox: BoundingBox) -> bytes:
226
+ """Crop image based on bounding box."""
227
+ try:
228
+ nparr = np.frombuffer(image_bytes, np.uint8)
229
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
230
+
231
+ cropped = img[bbox.y1: bbox.y2, bbox.x1: bbox.x2]
232
+ _, buffer = cv2.imencode(".jpg", cropped)
233
+ return buffer.tobytes()
234
+ except Exception as e:
235
+ logger.error(f"Error cropping image: {str(e)}")
236
+ raise ImageProcessingError(f"Error cropping image: {str(e)}")
237
+
238
+
239
+ class CollageCreator:
240
+ """Handles the creation of collages from multiple bounding boxes."""
241
+
242
+ @staticmethod
243
+ def _equalize_dimensions(
244
+ img: np.ndarray,
245
+ bbox1: BoundingBox,
246
+ bbox2: BoundingBox,
247
+ direction: CollageDirection,
248
+ ) -> Tuple[BoundingBox, BoundingBox]:
249
+ """Equalize the dimensions of the bboxes for the collage."""
250
+ if direction == CollageDirection.VERTICAL:
251
+ target_width = max(bbox1.width, bbox2.width)
252
+ bbox1 = (
253
+ BoundingBoxExpander.expand_to_width(img, bbox1, target_width)
254
+ if bbox1.width < target_width
255
+ else bbox1
256
+ )
257
+ bbox2 = (
258
+ BoundingBoxExpander.expand_to_width(img, bbox2, target_width)
259
+ if bbox2.width < target_width
260
+ else bbox2
261
+ )
262
+ else:
263
+ target_height = max(bbox1.height, bbox2.height)
264
+ bbox1 = (
265
+ BoundingBoxExpander.expand_to_height(img, bbox1, target_height)
266
+ if bbox1.height < target_height
267
+ else bbox1
268
+ )
269
+ bbox2 = (
270
+ BoundingBoxExpander.expand_to_height(img, bbox2, target_height)
271
+ if bbox2.height < target_height
272
+ else bbox2
273
+ )
274
+
275
+ return bbox1, bbox2
276
+
277
+ @staticmethod
278
+ def _adjust_for_aspect_ratio(
279
+ img: np.ndarray,
280
+ bbox1: BoundingBox,
281
+ bbox2: BoundingBox,
282
+ direction: CollageDirection,
283
+ target_ratio: AspectRatio,
284
+ ) -> Tuple[BoundingBox, BoundingBox]:
285
+ """Adjust the dimensions to reach the target format."""
286
+ if direction == CollageDirection.VERTICAL:
287
+ collage_width = max(bbox1.width, bbox2.width)
288
+ collage_height = bbox1.height + bbox2.height
289
+ else:
290
+ collage_width = bbox1.width + bbox2.width
291
+ collage_height = max(bbox1.height, bbox2.height)
292
+
293
+ current_ratio = collage_width / collage_height
294
+ logger.info(
295
+ f"Initial collage: {collage_width}x{collage_height} (ratio: {current_ratio:.3f})"
296
+ )
297
+
298
+ target_width = int(
299
+ collage_height * target_ratio.value[0] / target_ratio.value[1]
300
+ )
301
+ target_height = int(
302
+ collage_width * target_ratio.value[1] / target_ratio.value[0]
303
+ )
304
+
305
+ # Choose the adjustment that requires fewer changes
306
+ width_diff = abs(target_width - collage_width)
307
+ height_diff = abs(target_height - collage_height)
308
+
309
+ logger.info(f"Target dimensions: {target_width}x{target_height}")
310
+ logger.info(f"Width diff: {width_diff}, Height diff: {height_diff}")
311
+
312
+ # Choose whether to expand width or height based on which requires fewer changes
313
+ if width_diff <= height_diff:
314
+ # Expand width (preferred when difference is less or equal)
315
+ logger.info("Choosing to expand width")
316
+ if target_width > collage_width:
317
+ if direction == CollageDirection.VERTICAL:
318
+ # For vertical collages, expand both bboxes in width
319
+ bbox1 = BoundingBoxExpander.expand_to_width(
320
+ img, bbox1, target_width
321
+ )
322
+ bbox2 = BoundingBoxExpander.expand_to_width(
323
+ img, bbox2, target_width
324
+ )
325
+ else:
326
+ # For horizontal collages, distribute expansion between the two bboxes
327
+ extra_width = target_width - collage_width
328
+ extra_per_bbox = extra_width // 2
329
+ new_width1 = bbox1.width + extra_per_bbox
330
+ new_width2 = bbox2.width + (extra_width - extra_per_bbox)
331
+ bbox1 = BoundingBoxExpander.expand_to_width(
332
+ img, bbox1, new_width1)
333
+ bbox2 = BoundingBoxExpander.expand_to_width(
334
+ img, bbox2, new_width2)
335
+ else:
336
+ # Expand height
337
+ logger.info("Choosing to expand height")
338
+ if target_height > collage_height:
339
+ if direction == CollageDirection.HORIZONTAL:
340
+ # For horizontal collages, expand both bboxes in height
341
+ bbox1 = BoundingBoxExpander.expand_to_height(
342
+ img, bbox1, target_height
343
+ )
344
+ bbox2 = BoundingBoxExpander.expand_to_height(
345
+ img, bbox2, target_height
346
+ )
347
+ else:
348
+ # For vertical collages, distribute expansion between the two bboxes
349
+ extra_height = target_height - collage_height
350
+ extra_per_bbox = extra_height // 2
351
+ new_height1 = bbox1.height + extra_per_bbox
352
+ new_height2 = bbox2.height + \
353
+ (extra_height - extra_per_bbox)
354
+ bbox1 = BoundingBoxExpander.expand_to_height(
355
+ img, bbox1, new_height1
356
+ )
357
+ bbox2 = BoundingBoxExpander.expand_to_height(
358
+ img, bbox2, new_height2
359
+ )
360
+
361
+ return bbox1, bbox2
362
+
363
+ @staticmethod
364
+ def create_collage(
365
+ image_bytes: bytes,
366
+ bbox1: BoundingBox,
367
+ bbox2: BoundingBox,
368
+ direction: CollageDirection,
369
+ target_ratio: AspectRatio,
370
+ ) -> bytes:
371
+ """Create a collage from two bounding boxes."""
372
+ try:
373
+ nparr = np.frombuffer(image_bytes, np.uint8)
374
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
375
+
376
+ # Equalize dimensions
377
+ bbox1, bbox2 = CollageCreator._equalize_dimensions(
378
+ img, bbox1, bbox2, direction
379
+ )
380
+
381
+ # Adjust for target format
382
+ bbox1, bbox2 = CollageCreator._adjust_for_aspect_ratio(
383
+ img, bbox1, bbox2, direction, target_ratio
384
+ )
385
+
386
+ # Crop regions
387
+ crop1 = img[bbox1.y1: bbox1.y2, bbox1.x1: bbox1.x2]
388
+ crop2 = img[bbox2.y1: bbox2.y2, bbox2.x1: bbox2.x2]
389
+
390
+ # Create collage
391
+ if direction == CollageDirection.VERTICAL:
392
+ collage = np.vstack([crop1, crop2])
393
+ else:
394
+ collage = np.hstack([crop1, crop2])
395
+
396
+ _, buffer = cv2.imencode(".jpg", collage)
397
+ return buffer.tobytes()
398
+
399
+ except Exception as e:
400
+ logger.error(f"Error creating {direction.value} collage: {str(e)}")
401
+ raise ImageProcessingError(f"Error creating collage: {str(e)}")
402
+
403
+
404
+ class CollageDirectionDecider:
405
+ """Decide the direction of the collage based on the shapes of the bboxes."""
406
+
407
+ @staticmethod
408
+ def decide_direction(bbox1: BoundingBox, bbox2: BoundingBox) -> CollageDirection:
409
+ """Decide if a vertical or horizontal collage should be created."""
410
+ shape1 = bbox1.get_shape_type()
411
+ shape2 = bbox2.get_shape_type()
412
+
413
+ logger.info(f"Bbox shapes: {shape1} and {shape2}")
414
+
415
+ if (
416
+ (shape1 == "square" and shape2 == "square")
417
+ or (shape1 == "horizontal" and shape2 == "horizontal")
418
+ or (shape1 == "square" and shape2 == "horizontal")
419
+ or (shape1 == "horizontal" and shape2 == "square")
420
+ ):
421
+ return CollageDirection.VERTICAL
422
+ else:
423
+ return CollageDirection.HORIZONTAL
424
+
425
+
426
+ class BoundingBoxAPIClient:
427
+ """Client for the bounding box detection API."""
428
+
429
+ def __init__(self, api_url: str, api_key: str):
430
+ self.api_url = api_url
431
+ self.api_key = api_key
432
+ self.headers = {
433
+ "Content-Type": "application/json",
434
+ "Authorization": f"Bearer {api_key}",
435
+ }
436
+
437
+ def _wait_for_job_completion(self, job_id: str) -> Optional[List[BoundingBox]]:
438
+ """Wait for the job to complete and return the bounding boxes."""
439
+ while True:
440
+ time.sleep(2)
441
+ status_url = f"{self.api_url.rsplit('/', 1)[0]}/status/{job_id}"
442
+
443
+ try:
444
+ response = requests.get(status_url, headers=self.headers)
445
+ response.raise_for_status()
446
+ data = response.json()
447
+
448
+ if data["status"] == "COMPLETED":
449
+ return self._parse_bboxes(data["output"])
450
+ elif data["status"] == "FAILED":
451
+ logger.error(f"Job failed: {data}")
452
+ raise APIError(f"API job failed: {data}")
453
+
454
+ except requests.RequestException as e:
455
+ logger.error(f"Error checking status: {str(e)}")
456
+ raise APIError(f"Error checking API status: {str(e)}")
457
+
458
+ def _parse_bboxes(self, bboxes_data: List[dict]) -> List[BoundingBox]:
459
+ """Convert the API data to BoundingBox objects."""
460
+ return [
461
+ BoundingBox(x1=bbox["x1"], y1=bbox["y1"],
462
+ x2=bbox["x2"], y2=bbox["y2"])
463
+ for bbox in bboxes_data
464
+ ]
465
+
466
+ def get_bounding_boxes(self, image_bytes: bytes) -> List[BoundingBox]:
467
+ """Get the bounding boxes for the given image."""
468
+ logger.info("Requesting bboxes from API")
469
+ image_b64 = base64.b64encode(image_bytes).decode("utf-8")
470
+
471
+ try:
472
+ payload = {"input": {"image": image_b64}}
473
+ response = requests.post(
474
+ self.api_url, json=payload, headers=self.headers)
475
+ response.raise_for_status()
476
+
477
+ job_id = response.json()["id"]
478
+ return self._wait_for_job_completion(job_id) or []
479
+
480
+ except requests.RequestException as e:
481
+ logger.error(f"API request failed: {str(e)}")
482
+ raise APIError(f"API request failed: {str(e)}")
483
+ except Exception as e:
484
+ logger.error(f"Unexpected error getting bounding boxes: {str(e)}")
485
+ raise APIError(f"Unexpected API error: {str(e)}")
486
+
487
+
488
+ class SmartImageCropper:
489
+ """Main Smart Image Cropper class."""
490
+
491
+ def __init__(self, api_url: str, api_key: str):
492
+ """
493
+ Initialize the Smart Image Cropper.
494
+
495
+ Args:
496
+ api_url: URL of the bounding box detection API
497
+ api_key: API key for authentication
498
+ """
499
+ self.api_client = BoundingBoxAPIClient(api_url, api_key)
500
+ logger.info("Initialized SmartImageCropper")
501
+
502
+ def _select_best_bboxes(self, bboxes: List[BoundingBox]) -> List[BoundingBox]:
503
+ """Select the best bounding boxes for processing."""
504
+ if not bboxes:
505
+ return []
506
+
507
+ sorted_bboxes = sorted(bboxes, key=lambda bb: bb.area, reverse=True)
508
+
509
+ if len(sorted_bboxes) == 1:
510
+ return sorted_bboxes
511
+
512
+ bbox1, bbox2 = sorted_bboxes[0], sorted_bboxes[1]
513
+
514
+ area_ratio = bbox1.area / \
515
+ bbox2.area if bbox2.area > 0 else float("inf")
516
+ if area_ratio >= 5:
517
+ logger.info(
518
+ f"Area difference too large ({area_ratio:.2f}x), using only largest bbox"
519
+ )
520
+ return [bbox1]
521
+
522
+ return [bbox1, bbox2]
523
+
524
+ def process_image(self, image_input: Union[str, bytes, Image.Image]) -> bytes:
525
+ """
526
+ Process an image and return the cropped result as bytes.
527
+
528
+ Args:
529
+ image_input: Can be a URL string, image bytes, or PIL Image
530
+
531
+ Returns:
532
+ bytes: The processed image as bytes
533
+
534
+ Raises:
535
+ SmartCropperError: If processing fails
536
+ """
537
+ # Normalize input to bytes
538
+ image_bytes = ImageUtils.normalize_input(image_input)
539
+
540
+ try:
541
+ bboxes = self.api_client.get_bounding_boxes(image_bytes)
542
+ if not bboxes:
543
+ logger.warning(
544
+ "No bounding boxes found, returning original image")
545
+ return image_bytes
546
+ else:
547
+ best_bboxes = self._select_best_bboxes(bboxes)
548
+ return self._process_bboxes(image_bytes, best_bboxes)
549
+
550
+ except Exception as e:
551
+ logger.error(f"Error processing image: {str(e)}")
552
+ raise
553
+
554
+ def _process_bboxes(self, image_bytes: bytes, bboxes: List[BoundingBox]) -> bytes:
555
+ """Process the bounding boxes to create the final result."""
556
+ if len(bboxes) == 1:
557
+ return self._process_single_bbox(image_bytes, bboxes[0])
558
+ elif len(bboxes) == 2:
559
+ return self._process_multiple_bboxes(image_bytes, bboxes[0], bboxes[1])
560
+ else:
561
+ logger.warning(
562
+ "Unexpected number of bboxes, returning original image")
563
+ return image_bytes
564
+
565
+ def _process_single_bbox(self, image_bytes: bytes, bbox: BoundingBox) -> bytes:
566
+ """Process a single bounding box."""
567
+ logger.info("Single bbox detected, expanding to target aspect ratio")
568
+
569
+ nparr = np.frombuffer(image_bytes, np.uint8)
570
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
571
+
572
+ target_ratio = AspectRatioCalculator.find_closest_target_ratio(
573
+ bbox.aspect_ratio
574
+ )
575
+ expanded_bbox = BoundingBoxExpander.expand_to_aspect_ratio(
576
+ img, bbox, target_ratio
577
+ )
578
+
579
+ return ImageCropper.crop_image(image_bytes, expanded_bbox)
580
+
581
+ def _process_multiple_bboxes(
582
+ self, image_bytes: bytes, bbox1: BoundingBox, bbox2: BoundingBox
583
+ ) -> bytes:
584
+ """Process two bounding boxes to create a collage."""
585
+ direction = CollageDirectionDecider.decide_direction(bbox1, bbox2)
586
+ logger.info(f"Creating {direction.value} collage")
587
+
588
+ # Calculate target format based on collage dimensions
589
+ if direction == CollageDirection.VERTICAL:
590
+ collage_width = max(bbox1.width, bbox2.width)
591
+ collage_height = bbox1.height + bbox2.height
592
+ else:
593
+ collage_width = bbox1.width + bbox2.width
594
+ collage_height = max(bbox1.height, bbox2.height)
595
+
596
+ collage_ratio = collage_width / collage_height
597
+ target_ratio = AspectRatioCalculator.find_closest_target_ratio(
598
+ collage_ratio)
599
+
600
+ return CollageCreator.create_collage(
601
+ image_bytes, bbox1, bbox2, direction, target_ratio
602
+ )
@@ -0,0 +1,21 @@
1
+ """Custom exceptions for Smart Image Cropper."""
2
+
3
+
4
+ class SmartCropperError(Exception):
5
+ """Base exception for Smart Image Cropper."""
6
+ pass
7
+
8
+
9
+ class APIError(SmartCropperError):
10
+ """Exception raised when API requests fail."""
11
+ pass
12
+
13
+
14
+ class ImageProcessingError(SmartCropperError):
15
+ """Exception raised when image processing fails."""
16
+ pass
17
+
18
+
19
+ class InvalidInputError(SmartCropperError):
20
+ """Exception raised when input is invalid."""
21
+ pass
@@ -0,0 +1,74 @@
1
+ """Utility functions for Smart Image Cropper."""
2
+
3
+ import io
4
+ import logging
5
+ from typing import Union
6
+
7
+ import cv2
8
+ import numpy as np
9
+ import requests
10
+ from PIL import Image
11
+
12
+ from .exceptions import ImageProcessingError, InvalidInputError
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ImageUtils:
18
+ """Utility functions for image handling."""
19
+
20
+ @staticmethod
21
+ def download_image_bytes(url: str, timeout: int = 30) -> bytes:
22
+ """Download an image from URL and return as bytes."""
23
+ try:
24
+ response = requests.get(url, timeout=timeout)
25
+ response.raise_for_status()
26
+ return response.content
27
+ except requests.RequestException as e:
28
+ logger.error(f"Failed to download image from {url}: {str(e)}")
29
+ raise ImageProcessingError(f"Failed to download image: {str(e)}")
30
+
31
+ @staticmethod
32
+ def pil_to_bytes(pil_image: Image.Image, format: str = "JPEG") -> bytes:
33
+ """Convert PIL Image to bytes."""
34
+ try:
35
+ img_buffer = io.BytesIO()
36
+ # Convert to RGB if necessary
37
+ if pil_image.mode in ("RGBA", "P"):
38
+ pil_image = pil_image.convert("RGB")
39
+ pil_image.save(img_buffer, format=format)
40
+ return img_buffer.getvalue()
41
+ except Exception as e:
42
+ logger.error(f"Failed to convert PIL image to bytes: {str(e)}")
43
+ raise ImageProcessingError(
44
+ f"Failed to convert PIL image: {str(e)}")
45
+
46
+ @staticmethod
47
+ def validate_image_bytes(image_bytes: bytes) -> bool:
48
+ """Validate that bytes represent a valid image."""
49
+ try:
50
+ nparr = np.frombuffer(image_bytes, np.uint8)
51
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
52
+ return img is not None
53
+ except Exception:
54
+ return False
55
+
56
+ @staticmethod
57
+ def normalize_input(image_input: Union[str, bytes, Image.Image]) -> bytes:
58
+ """Normalize different input types to bytes."""
59
+ if isinstance(image_input, str):
60
+ # Assume it's a URL
61
+ return ImageUtils.download_image_bytes(image_input)
62
+ elif isinstance(image_input, bytes):
63
+ # Already bytes, validate
64
+ if not ImageUtils.validate_image_bytes(image_input):
65
+ raise InvalidInputError("Invalid image bytes provided")
66
+ return image_input
67
+ elif isinstance(image_input, Image.Image):
68
+ # PIL Image
69
+ return ImageUtils.pil_to_bytes(image_input)
70
+ else:
71
+ raise InvalidInputError(
72
+ f"Unsupported input type: {type(image_input)}. "
73
+ "Supported types: str (URL), bytes, PIL.Image.Image"
74
+ )
@@ -0,0 +1,258 @@
1
+ Metadata-Version: 2.4
2
+ Name: smart-image-cropper
3
+ Version: 1.0.0
4
+ Summary: An intelligent image cropping library that creates smart collages
5
+ Home-page: https://github.com/giumanuz/image_cropper
6
+ Author: Giulio Manuzzi
7
+ Author-email: Your Name <your.email@example.com>
8
+ Maintainer-email: Your Name <your.email@example.com>
9
+ License: MIT
10
+ Project-URL: Homepage, https://github.com/yourusername/smart-image-cropper
11
+ Project-URL: Documentation, https://github.com/yourusername/smart-image-cropper#readme
12
+ Project-URL: Repository, https://github.com/yourusername/smart-image-cropper
13
+ Project-URL: Bug Tracker, https://github.com/yourusername/smart-image-cropper/issues
14
+ Keywords: image processing,cropping,collage,computer vision,opencv
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.8
21
+ Classifier: Programming Language :: Python :: 3.9
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Topic :: Multimedia :: Graphics
25
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
26
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
+ Requires-Python: >=3.8
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: opencv-python>=4.5.0
31
+ Requires-Dist: numpy>=1.20.0
32
+ Requires-Dist: Pillow>=8.0.0
33
+ Requires-Dist: requests>=2.25.0
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=6.0; extra == "dev"
36
+ Requires-Dist: pytest-cov>=2.0; extra == "dev"
37
+ Requires-Dist: black>=21.0; extra == "dev"
38
+ Requires-Dist: flake8>=3.8; extra == "dev"
39
+ Requires-Dist: mypy>=0.812; extra == "dev"
40
+ Dynamic: author
41
+ Dynamic: home-page
42
+ Dynamic: license-file
43
+ Dynamic: requires-python
44
+
45
+ # Smart Image Cropper
46
+
47
+ [![PyPI version](https://badge.fury.io/py/smart-image-cropper.svg)](https://badge.fury.io/py/smart-image-cropper)
48
+ [![Python Support](https://img.shields.io/pypi/pyversions/smart-image-cropper.svg)](https://pypi.org/project/smart-image-cropper/)
49
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
50
+
51
+ An intelligent image cropping library that automatically detects objects in
52
+ images and creates optimized crops or collages. The library uses AI-powered
53
+ bounding box detection to identify the most important regions in your images and
54
+ intelligently crops them to standard aspect ratios.
55
+
56
+ ## Features
57
+
58
+ - 🎯 **Smart Object Detection**: Automatically detects important objects in
59
+ images using AI
60
+ - 🖼️ **Intelligent Cropping**: Crops images to optimal aspect ratios (4:5, 3:4,
61
+ 1:1, 4:3)
62
+ - 🎨 **Automatic Collages**: Creates beautiful collages when multiple objects
63
+ are detected
64
+ - 📐 **Aspect Ratio Optimization**: Automatically expands crops to reach target
65
+ aspect ratios
66
+ - 🔧 **Flexible Input**: Supports URLs, bytes, and PIL Images as input
67
+ - ⚡ **Fast Processing**: Efficient image processing with OpenCV
68
+ - 🐍 **Pure Python**: Easy to integrate into any Python project
69
+
70
+ ## Installation
71
+
72
+ ```bash
73
+ pip install smart-image-cropper
74
+ ```
75
+
76
+ ## Quick Start
77
+
78
+ ```python
79
+ from smart_image_cropper import SmartImageCropper
80
+ from PIL import Image
81
+
82
+ # Initialize the cropper with your API credentials
83
+ cropper = SmartImageCropper(
84
+ api_url="your-api-endpoint",
85
+ api_key="your-api-key"
86
+ )
87
+
88
+ # Method 1: Process from URL
89
+ result_bytes = cropper.process_image("https://example.com/image.jpg")
90
+
91
+ # Method 2: Process from bytes
92
+ with open("image.jpg", "rb") as f:
93
+ image_bytes = f.read()
94
+ result_bytes = cropper.process_image(image_bytes)
95
+
96
+ # Method 3: Process from PIL Image
97
+ pil_image = Image.open("image.jpg")
98
+ result_bytes = cropper.process_image(pil_image)
99
+
100
+ # Save the result
101
+ with open("cropped_result.jpg", "wb") as f:
102
+ f.write(result_bytes)
103
+ ```
104
+
105
+ ## How It Works
106
+
107
+ 1. **Object Detection**: The library sends your image to an AI-powered bounding
108
+ box detection API
109
+ 2. **Smart Selection**: Identifies the most important objects based on size and
110
+ relevance
111
+ 3. **Intelligent Processing**:
112
+ - **Single Object**: Crops and expands to the nearest standard aspect ratio
113
+ - **Multiple Objects**: Creates a collage with optimal layout
114
+ (vertical/horizontal)
115
+ 4. **Aspect Ratio Optimization**: Ensures the final result matches standard
116
+ social media formats
117
+
118
+ ## Supported Aspect Ratios
119
+
120
+ - **Portrait 4:5** (0.8) - Instagram posts
121
+ - **Portrait 3:4** (0.75) - Traditional photo format
122
+ - **Square 1:1** (1.0) - Instagram square posts
123
+ - **Landscape 4:3** (1.33) - Traditional landscape format
124
+
125
+ ## API Requirements
126
+
127
+ This library requires access to a bounding box detection API. The API should:
128
+
129
+ - Accept POST requests with JSON payload containing base64-encoded images
130
+ - Return a job ID for asynchronous processing
131
+ - Provide a status endpoint to check job completion
132
+ - Return bounding box coordinates in the format:
133
+ `{"x1": int, "y1": int, "x2": int, "y2": int}`
134
+
135
+ ### Example API Integration
136
+
137
+ ```python
138
+ # Your API should accept this format:
139
+ {
140
+ "input": {
141
+ "image": "base64-encoded-image-string"
142
+ }
143
+ }
144
+
145
+ # And return:
146
+ {
147
+ "id": "job-id-string"
148
+ }
149
+
150
+ # Status check should return:
151
+ {
152
+ "status": "COMPLETED", # or "FAILED"
153
+ "output": [
154
+ {"x1": 100, "y1": 50, "x2": 300, "y2": 250},
155
+ {"x1": 400, "y1": 100, "x2": 600, "y2": 300}
156
+ ]
157
+ }
158
+ ```
159
+
160
+ ## Advanced Usage
161
+
162
+ ### Error Handling
163
+
164
+ ```python
165
+ from smart_image_cropper import SmartImageCropper, SmartCropperError, APIError
166
+
167
+ try:
168
+ cropper = SmartImageCropper(api_url="...", api_key="...")
169
+ result = cropper.process_image("image.jpg")
170
+ except APIError as e:
171
+ print(f"API Error: {e}")
172
+ except SmartCropperError as e:
173
+ print(f"Processing Error: {e}")
174
+ ```
175
+
176
+ ### Logging
177
+
178
+ The library uses Python's logging module. Enable debug logging to see detailed
179
+ processing information:
180
+
181
+ ```python
182
+ import logging
183
+
184
+ logging.basicConfig(level=logging.INFO)
185
+ # Now you'll see detailed processing logs
186
+ ```
187
+
188
+ ## Dependencies
189
+
190
+ - **OpenCV** (`opencv-python>=4.5.0`) - Image processing
191
+ - **NumPy** (`numpy>=1.20.0`) - Numerical operations
192
+ - **Pillow** (`Pillow>=8.0.0`) - PIL Image support
193
+ - **Requests** (`requests>=2.25.0`) - HTTP API calls
194
+
195
+ ## Development
196
+
197
+ ### Setting Up Development Environment
198
+
199
+ ```bash
200
+ # Clone the repository
201
+ git clone https://github.com/yourusername/smart-image-cropper.git
202
+ cd smart-image-cropper
203
+
204
+ # Install in development mode
205
+ pip install -e ".[dev]"
206
+
207
+ # Run tests
208
+ pytest
209
+
210
+ # Format code
211
+ black .
212
+
213
+ # Type checking
214
+ mypy smart_image_cropper/
215
+ ```
216
+
217
+ ### Running Tests
218
+
219
+ ```bash
220
+ pytest tests/ -v --cov=smart_image_cropper
221
+ ```
222
+
223
+ ## Contributing
224
+
225
+ Contributions are welcome! Please feel free to submit a Pull Request. For major
226
+ changes, please open an issue first to discuss what you would like to change.
227
+
228
+ 1. Fork the repository
229
+ 2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
230
+ 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
231
+ 4. Push to the branch (`git push origin feature/AmazingFeature`)
232
+ 5. Open a Pull Request
233
+
234
+ ## License
235
+
236
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
237
+ for details.
238
+
239
+ ## Changelog
240
+
241
+ ### v1.0.0
242
+
243
+ - Initial release
244
+ - Support for URL, bytes, and PIL Image inputs
245
+ - Automatic object detection and smart cropping
246
+ - Collage creation for multiple objects
247
+ - Aspect ratio optimization
248
+
249
+ ## Support
250
+
251
+ If you encounter any issues or have questions, please file an issue on the
252
+ [GitHub issue tracker](https://github.com/yourusername/smart-image-cropper/issues).
253
+
254
+ ## Acknowledgments
255
+
256
+ - OpenCV community for excellent image processing tools
257
+ - PIL/Pillow developers for image handling capabilities
258
+ - The Python packaging community for excellent tools and documentation
@@ -0,0 +1,9 @@
1
+ smart_image_cropper/__init__.py,sha256=D2CqGkdST7EhoCJZYQ-iasDiM_7h7yyfBhSG9RKj_s8,373
2
+ smart_image_cropper/cropper.py,sha256=r6rCqem8MVzzeHcN6ExnUHXB5Vv8RZKs1UhBXc37HoQ,21496
3
+ smart_image_cropper/exceptions.py,sha256=mAAL-82BQ2n0y3aFEstSbyZ-TEakWcQQujcGlQPd3sY,462
4
+ smart_image_cropper/utils.py,sha256=4NtIE5C2golb4_8jDcCRLL5a9J1ZNBoscReG0oh9AZ4,2688
5
+ smart_image_cropper-1.0.0.dist-info/licenses/LICENSE,sha256=3HI2BFqRtJB9-3TEFRlMEUIXSn7v1Wsxsi6dJ1etoXg,1076
6
+ smart_image_cropper-1.0.0.dist-info/METADATA,sha256=wyiKEpaaJW5AGGXcFxVGrO803qPSSHKLToftcsspHO8,7762
7
+ smart_image_cropper-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ smart_image_cropper-1.0.0.dist-info/top_level.txt,sha256=BJ54dVKhPwbe2foKDOF1JFwd2nGJkHg0UscQdkCtSno,20
9
+ smart_image_cropper-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Smart Image Cropper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ smart_image_cropper