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.
- smart_image_cropper/__init__.py +11 -0
- smart_image_cropper/cropper.py +602 -0
- smart_image_cropper/exceptions.py +21 -0
- smart_image_cropper/utils.py +74 -0
- smart_image_cropper-1.0.0.dist-info/METADATA +258 -0
- smart_image_cropper-1.0.0.dist-info/RECORD +9 -0
- smart_image_cropper-1.0.0.dist-info/WHEEL +5 -0
- smart_image_cropper-1.0.0.dist-info/licenses/LICENSE +20 -0
- smart_image_cropper-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
[](https://badge.fury.io/py/smart-image-cropper)
|
|
48
|
+
[](https://pypi.org/project/smart-image-cropper/)
|
|
49
|
+
[](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,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
|