imagekit-python 0.0.1a1__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,15 @@
1
+ from .smart_image import (
2
+ ImageFormat,
3
+ SmartImage,
4
+ ImageEmpty,
5
+ ImageTooLarge,
6
+ InvalidImage,
7
+ )
8
+
9
+ __all__ = [
10
+ "ImageFormat",
11
+ "SmartImage",
12
+ "ImageEmpty",
13
+ "ImageTooLarge",
14
+ "InvalidImage",
15
+ ]
@@ -0,0 +1,474 @@
1
+ import io
2
+ import cv2
3
+ import base64
4
+ import numpy as np
5
+ from PIL import Image
6
+ from cv2.typing import MatLike
7
+ from dataclasses import dataclass
8
+ from typing import ClassVar, Literal, Optional
9
+
10
+
11
+ # Custom Errors
12
+ class ImageEmpty(Exception):
13
+ """Raised when image is empty or has no data."""
14
+
15
+ def __init__(self, message: str = "Image is empty"):
16
+ self.message = message
17
+ super().__init__(self.message)
18
+
19
+
20
+ class InvalidImage(Exception):
21
+ """Raised when image is invalid or corrupted."""
22
+
23
+ def __init__(self, message: str = "Image is invalid or corrupted"):
24
+ self.message = message
25
+ super().__init__(self.message)
26
+
27
+
28
+ class ImageTooLarge(Exception):
29
+ """Raised when image size exceeds maximum allowed size."""
30
+
31
+ def __init__(self, max_mb: float, message: str = None):
32
+ self.max_mb = max_mb
33
+ self.message = (
34
+ message or f"Image size exceeds maximum allowed size of {max_mb} MB"
35
+ )
36
+ super().__init__(self.message)
37
+
38
+
39
+ class InvalidAngle(Exception):
40
+ """Raised when image size exceeds maximum allowed size."""
41
+
42
+ def __init__(self, message: str = "Only 90, 180, 270 supported"):
43
+ self.message = message
44
+ super().__init__(self.message)
45
+
46
+
47
+ ImageFormat = Literal["JPEG", "PNG"]
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class SmartImage:
52
+ _image: MatLike
53
+ _sharpening_kernel: ClassVar[np.ndarray] = np.array(
54
+ [[-1, -1, -1], [-1, 9, -1], [-1, -1, -1]]
55
+ )
56
+
57
+ # -----------------------------
58
+ # Constructors
59
+ # -----------------------------
60
+ @classmethod
61
+ def from_bytes(cls, image_bytes: bytes):
62
+ if not image_bytes:
63
+ raise ImageEmpty()
64
+
65
+ cls.verify_bytes(image_bytes)
66
+
67
+ image = SmartImage.bytes_to_cv2(image_bytes)
68
+
69
+ if image is None:
70
+ raise InvalidImage()
71
+
72
+ return cls(_image=image)
73
+
74
+ @classmethod
75
+ def from_cv2(cls, image: MatLike) -> "SmartImage":
76
+ if image is None or image.size == 0:
77
+ raise ImageEmpty()
78
+ return cls(_image=image.copy())
79
+
80
+ @classmethod
81
+ def from_pillow(cls, image: Image.Image) -> "SmartImage":
82
+ if image is None:
83
+ raise ImageEmpty()
84
+ return cls.from_cv2(SmartImage.pillow_to_cv2(image))
85
+
86
+ # -----------------------------
87
+ # Calculated Property
88
+ # -----------------------------
89
+ @property
90
+ def shape(self):
91
+ return self._image.shape
92
+
93
+ # -----------------------------
94
+ # Conversions (On Demand)
95
+ # -----------------------------
96
+ def to_cv2(self) -> MatLike:
97
+ return self._image.copy()
98
+
99
+ def to_pillow(self) -> Image.Image:
100
+ return SmartImage.cv2_to_pillow(self._image)
101
+
102
+ def to_bytes(self, format: ImageFormat = "JPEG", quality: int = 80) -> bytes:
103
+ return SmartImage.cv2_to_bytes(self._image, format, quality)
104
+
105
+ def to_base64(self, format: ImageFormat = "JPEG", quality: int = 80) -> str:
106
+ return base64.b64encode(self.to_bytes(format=format, quality=quality)).decode(
107
+ "utf-8"
108
+ )
109
+
110
+ # -----------------------------
111
+ # Transformations (Pure)
112
+ # -----------------------------
113
+ def resize(self, width: int = None, height: int = None):
114
+ return SmartImage.from_cv2(SmartImage.resize_image(self._image, width, height))
115
+
116
+ def crop(self, bbox: tuple[int, int, int, int], padding_factor: float = 0):
117
+ return SmartImage.from_cv2(
118
+ SmartImage.crop_image(self._image, bbox, padding_factor)
119
+ )
120
+
121
+ def rotate(self, angle: int):
122
+ return SmartImage.from_cv2(SmartImage.rotate_image(self._image, angle))
123
+
124
+ def sharpen_v1(self):
125
+ return SmartImage.from_cv2(SmartImage.sharpened_image_v1(self._image))
126
+
127
+ def sharpen_v2(self):
128
+ return SmartImage.from_cv2(SmartImage.sharpened_image_v2(self._image))
129
+
130
+ def adjust_quality(self, format: ImageFormat, quality: int = 80):
131
+ return SmartImage.from_cv2(
132
+ SmartImage.adjust_quality_image(self._image, format, quality),
133
+ )
134
+
135
+ def apply(self, fn):
136
+ new_img = fn(self._image.copy())
137
+ return SmartImage.from_cv2(new_img)
138
+
139
+ # -----------------------------
140
+ # Document Related
141
+ # -----------------------------
142
+ def ensure_document_upright(self, tolerance: float = 10):
143
+ return SmartImage.from_cv2(
144
+ SmartImage.ensure_document_upright_image(self._image, tolerance),
145
+ )
146
+
147
+ def ensure_document_portrait(self):
148
+ return SmartImage.from_cv2(
149
+ SmartImage.ensure_document_portrait_image(self._image)
150
+ )
151
+
152
+ def ensure_document_landscape(self):
153
+ return SmartImage.from_cv2(
154
+ SmartImage.ensure_document_landscape_image(self._image)
155
+ )
156
+
157
+ def unwarp_document(self, pts: list[tuple[int, int]], padding_ratio: float = 0.01):
158
+ return SmartImage.from_cv2(
159
+ SmartImage.unwarp_document_image(self._image, pts, padding_ratio)
160
+ )
161
+
162
+ # -----------------------------
163
+ # Validation Helpers
164
+ # -----------------------------
165
+
166
+ @staticmethod
167
+ def verify_bytes(image_bytes: bytes):
168
+ try:
169
+ Image.open(io.BytesIO(image_bytes)).verify()
170
+ except Exception:
171
+ raise InvalidImage()
172
+
173
+ @staticmethod
174
+ def validate_size(image_bytes: bytes, max_size_mb: Optional[float]):
175
+ if max_size_mb:
176
+ size_mb = len(image_bytes) / (1024 * 1024)
177
+ if size_mb > max_size_mb:
178
+ raise ImageTooLarge(max_size_mb)
179
+ return True
180
+
181
+ # -----------------------------
182
+ # Static Methods
183
+ # -----------------------------
184
+
185
+ @staticmethod
186
+ def bytes_to_cv2(image: bytes):
187
+ np_arr = np.frombuffer(image, np.uint8)
188
+ return cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
189
+
190
+ @staticmethod
191
+ def bytes_to_base64(image: bytes):
192
+ return base64.b64encode(image).decode("utf-8")
193
+
194
+ @staticmethod
195
+ def bytes_to_pillow(image: bytes):
196
+ """Converts raw bytes into a PIL Image object."""
197
+ return Image.open(io.BytesIO(image))
198
+
199
+ @staticmethod
200
+ def cv2_to_bytes(image: MatLike, format: ImageFormat = "JPEG", quality: int = 80):
201
+ # Set JPEG quality (0 to 100, higher = better quality, larger size)
202
+ if format.upper() == "PNG":
203
+ # PNG is lossless, so quality is ignored (uses compression level 0-9 instead)
204
+ success, image_bytes = cv2.imencode(".png", image)
205
+ else:
206
+ encode_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
207
+ success, image_bytes = cv2.imencode(".jpg", image, encode_params)
208
+
209
+ if not success:
210
+ raise InvalidImage()
211
+ return image_bytes.tobytes()
212
+
213
+ @staticmethod
214
+ def cv2_to_base64(image: MatLike, format: ImageFormat = "JPEG", quality: int = 80):
215
+ image_bytes = SmartImage.cv2_to_bytes(image, format, quality)
216
+ return SmartImage.bytes_to_base64(image_bytes)
217
+
218
+ @staticmethod
219
+ def cv2_to_pillow(image: MatLike):
220
+ rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
221
+ return Image.fromarray(rgb_image)
222
+
223
+ @staticmethod
224
+ def pillow_to_bytes(
225
+ image: Image.Image, format: ImageFormat = "JPEG", quality: int = 80
226
+ ):
227
+ buffer = io.BytesIO()
228
+ # PNG doesn't use the quality kwarg
229
+ if format.upper() == "PNG":
230
+ image.save(buffer, format="PNG")
231
+ else:
232
+ image.save(buffer, format="JPEG", quality=quality)
233
+ return buffer.getvalue()
234
+
235
+ @staticmethod
236
+ def pillow_to_cv2(image: Image.Image):
237
+ rgb_arr = np.array(image)
238
+ return cv2.cvtColor(rgb_arr, cv2.COLOR_RGB2BGR)
239
+
240
+ @staticmethod
241
+ def pillow_to_base64(
242
+ image: Image.Image, format: ImageFormat = "JPEG", quality: int = 80
243
+ ) -> str:
244
+ image_bytes = SmartImage.pillow_to_bytes(image, format, quality)
245
+ return SmartImage.bytes_to_base64(image_bytes)
246
+
247
+ @staticmethod
248
+ def crop_image(
249
+ image: MatLike, bbox: tuple[int, int, int, int], padding_factor: float = 0
250
+ ):
251
+ result_image = image.copy()
252
+ try:
253
+ x1, y1, x2, y2 = bbox
254
+ img_h, img_w = result_image.shape[:2]
255
+
256
+ # Calculate dynamic padding as a percentage of the image dimensions
257
+ padding = int(min(img_h, img_w) * padding_factor)
258
+
259
+ # Apply padding and clamp to image boundaries
260
+ x1 = max(0, x1 - padding)
261
+ y1 = max(0, y1 - padding)
262
+ x2 = min(img_w, x2 + padding)
263
+ y2 = min(img_h, y2 + padding)
264
+
265
+ # Validate the cropped region
266
+ if x1 >= x2 or y1 >= y2:
267
+ return None
268
+
269
+ result_image = result_image[y1:y2, x1:x2]
270
+ if result_image.size == 0:
271
+ return None
272
+
273
+ return result_image
274
+
275
+ except Exception:
276
+ return result_image
277
+
278
+ @staticmethod
279
+ def resize_image(image: MatLike, width: int = None, height: int = None):
280
+
281
+ if not width and not height:
282
+ raise ValueError("Either width or height must be provided")
283
+
284
+ h, w = image.shape[:2]
285
+
286
+ if width and height:
287
+ resized = cv2.resize(image, (width, height))
288
+ elif width:
289
+ ratio = width / w
290
+ resized = cv2.resize(image, (width, int(h * ratio)))
291
+ elif height:
292
+ ratio = height / h
293
+ resized = cv2.resize(image, (int(w * ratio), height))
294
+ else:
295
+ return image
296
+
297
+ return resized
298
+
299
+ @staticmethod
300
+ def adjust_quality_image(image: MatLike, format: ImageFormat, quality: int = 80):
301
+ return SmartImage.bytes_to_cv2(SmartImage.cv2_to_bytes(image, format, quality))
302
+
303
+ @staticmethod
304
+ def sharpened_image_v1(image: MatLike):
305
+ blurred_image = cv2.GaussianBlur(image, (5, 5), 0)
306
+ sharpened_image = cv2.filter2D(blurred_image, -1, SmartImage._sharpening_kernel)
307
+ return sharpened_image
308
+
309
+ @staticmethod
310
+ def sharpened_image_v2(image: MatLike):
311
+ kernel = np.array([[-1, -1, -1], [-1, 9, -1], [-1, -1, -1]])
312
+ return cv2.filter2D(image, -1, kernel)
313
+
314
+ @staticmethod
315
+ def rotate_image(image: MatLike, angle: Literal[90, 180, 270]):
316
+ if angle == 90:
317
+ rotated = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
318
+ elif angle == 180:
319
+ rotated = cv2.rotate(image, cv2.ROTATE_180)
320
+ elif angle == 270:
321
+ rotated = cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE)
322
+ else:
323
+ raise InvalidAngle()
324
+
325
+ return rotated
326
+
327
+ @staticmethod
328
+ def __order_points(pts: list[tuple[int, int]]):
329
+ # Convert to numpy array if not already
330
+ np_pts = np.array(pts, dtype=np.float32)
331
+
332
+ # Sum and difference to sort the points
333
+ s = np_pts.sum(axis=1)
334
+ diff = np.diff(pts, axis=1)
335
+
336
+ ordered = np.zeros((4, 2), dtype=np.float32)
337
+ ordered[0] = np_pts[np.argmin(s)] # Top-left
338
+ ordered[2] = np_pts[np.argmax(s)] # Bottom-right
339
+ ordered[1] = np_pts[np.argmin(diff)] # Top-right
340
+ ordered[3] = np_pts[np.argmax(diff)] # Bottom-left
341
+
342
+ return ordered
343
+
344
+ @staticmethod
345
+ def __get_max_dimensions(np_pts: np.ndarray[tuple[int, int], np.dtype[np.float32]]):
346
+ (tl, tr, br, bl) = np_pts
347
+
348
+ widthA = np.linalg.norm(br - bl)
349
+ widthB = np.linalg.norm(tr - tl)
350
+ maxWidth = max(int(widthA), int(widthB))
351
+
352
+ heightA = np.linalg.norm(tr - br)
353
+ heightB = np.linalg.norm(tl - bl)
354
+ maxHeight = max(int(heightA), int(heightB))
355
+
356
+ return maxWidth, maxHeight
357
+
358
+ # @staticmethod
359
+ # def unwarp_document(image: np.ndarray, pts: list[tuple[int, int]]):
360
+ # rect = ImageService.__order_points(pts)
361
+ # maxWidth, maxHeight = ImageService.__get_max_dimensions(rect)
362
+
363
+ # # Destination points for the "rectified" image
364
+ # dst = np.array([
365
+ # [0, 0],
366
+ # [maxWidth - 1, 0],
367
+ # [maxWidth - 1, maxHeight - 1],
368
+ # [0, maxHeight - 1]], dtype=np.float32)
369
+
370
+ # # Compute the perspective transform matrix and apply it
371
+ # M = cv2.getPerspectiveTransform(rect, dst)
372
+ # warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))
373
+
374
+ # return warped
375
+
376
+ @staticmethod
377
+ def unwarp_document_image(
378
+ image: MatLike, pts: list[tuple[int, int]], padding_ratio: float = 0.01
379
+ ):
380
+ rect = SmartImage.__order_points(pts)
381
+ maxWidth, maxHeight = SmartImage.__get_max_dimensions(rect)
382
+
383
+ # Calculate proportional padding in pixels
384
+ padding = int(max(maxWidth, maxHeight) * padding_ratio)
385
+
386
+ # Destination points for the "rectified" image
387
+ dst = np.array(
388
+ [
389
+ [padding, padding],
390
+ [maxWidth - 1 + padding, padding],
391
+ [maxWidth - 1 + padding, maxHeight - 1 + padding],
392
+ [padding, maxHeight - 1 + padding],
393
+ ],
394
+ dtype=np.float32,
395
+ )
396
+
397
+ # Compute the perspective transform matrix and apply it
398
+ M = cv2.getPerspectiveTransform(rect, dst)
399
+ warped = cv2.warpPerspective(
400
+ image, M, (maxWidth + padding * 2, maxHeight + padding * 2)
401
+ )
402
+
403
+ return warped
404
+
405
+ @staticmethod
406
+ def ensure_document_landscape_image(image: MatLike):
407
+ """
408
+ Rotates the image 90 degrees counter-clockwise if it is in portrait orientation
409
+ (i.e., height > width), so that the output is in landscape orientation.
410
+
411
+ Args:
412
+ image (MatLike): Input image.
413
+
414
+ Returns:
415
+ MatLike Rotated (if needed) image in landscape orientation.
416
+ """
417
+ h, w = image.shape[:2]
418
+ result_image = image.copy()
419
+ if h > w:
420
+ result_image = cv2.rotate(result_image, cv2.ROTATE_90_COUNTERCLOCKWISE)
421
+ return result_image
422
+
423
+ @staticmethod
424
+ def ensure_document_portrait_image(image: MatLike):
425
+ """
426
+ Rotates the image 90 degrees counter-clockwise if it is in landscape orientation
427
+ (i.e., width > height), so that the output is in portrait orientation.
428
+
429
+ Args:
430
+ image (MatLike): Input image.
431
+
432
+ Returns:
433
+ MatLike Rotated (if needed) image in landscape orientation.
434
+ """
435
+ h, w = image.shape[:2]
436
+ result_image = image.copy()
437
+ if w > h:
438
+ result_image = cv2.rotate(result_image, cv2.ROTATE_90_COUNTERCLOCKWISE)
439
+ return result_image
440
+
441
+ @staticmethod
442
+ def ensure_document_upright_image(image: MatLike, tolerance: float = 10) -> MatLike:
443
+ """
444
+ Checks image orientation using pytesseract and rotates it by 180 degrees
445
+ if rotation angle is close to 180.
446
+
447
+ Args:
448
+ img (MatLike): Input image (as a NumPy array).
449
+
450
+ Returns:
451
+ MatLike: Rotated or original image.
452
+ """
453
+ try:
454
+ import pytesseract
455
+ except ImportError as e:
456
+ raise RuntimeError(
457
+ "pytesseract is required for OCR. "
458
+ "Install with `pip install imagekit-python[pytesseract]`"
459
+ ) from e
460
+ result_image = image.copy()
461
+ try:
462
+ orientation_data = pytesseract.image_to_osd(
463
+ result_image, output_type=pytesseract.Output.DICT
464
+ )
465
+ print(orientation_data)
466
+ angle = orientation_data["rotate"]
467
+
468
+ if abs(angle - 180) < tolerance:
469
+ result_image = cv2.rotate(result_image, cv2.ROTATE_180)
470
+
471
+ except pytesseract.pytesseract.TesseractError as e:
472
+ print("Tesseract OSD error:", e)
473
+
474
+ return result_image
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: imagekit-python
3
+ Version: 0.0.1a1
4
+ Summary: A Python library that provides a unified interface for handling images across multiple formats and performing common image processing tasks.
5
+ Project-URL: Repository, https://github.com/Hoopoes/imagekit
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 1 - Planning
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Natural Language :: English
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.7
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: numpy>=1.21.6
24
+ Requires-Dist: opencv-python-headless>=4.13.0.92
25
+ Requires-Dist: pillow>=9.5.0
26
+ Requires-Dist: pydantic>=2.5.3
27
+ Provides-Extra: all
28
+ Requires-Dist: pytesseract; extra == 'all'
29
+ Provides-Extra: pytesseract
30
+ Requires-Dist: pytesseract>=0.3.13; extra == 'pytesseract'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # Imagekit
34
+
35
+ `ImageKit` is a Python library that provides a unified interface for handling images across multiple formats (`bytes`, OpenCV `cv2`, Pillow `Image`) and performing common image processing tasks. It is designed for developers who want all-in-one image handling and processing, including conversion, cropping, resizing, rotation, and document unwarping.
36
+
37
+
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ coming soon
43
+ ```
44
+
45
+
46
+ ## Quick Start 🚀
47
+
48
+ ### Basic Usage
49
+
50
+ ```python
51
+ coming soon
52
+ ```
53
+
54
+
55
+ ## Parameters
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+ ## License
64
+
65
+ MIT License
@@ -0,0 +1,6 @@
1
+ imagekit_python/__init__.py,sha256=vekDkQ92P7wCwBEsISCufiwloXh50xNHNfriFrISefU,241
2
+ imagekit_python/smart_image.py,sha256=s1A5EEyE7vg6hVuCQOOmwsNw-C0W9GQ3EDq7MEmDIVM,15980
3
+ imagekit_python-0.0.1a1.dist-info/METADATA,sha256=zO2WuWnn3gSx8Z7YnodZrGiGHCoydTSNGRKjK5SbSKk,1880
4
+ imagekit_python-0.0.1a1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
5
+ imagekit_python-0.0.1a1.dist-info/licenses/LICENSE,sha256=vvtkYZ1iMd5ly1zrMSW1oX1iMgL4z1eVa9KT3ijlA5E,1085
6
+ imagekit_python-0.0.1a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hoopoes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, 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,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.