deckbuilder 1.0.0b1__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.
placekitten/core.py ADDED
@@ -0,0 +1,184 @@
1
+ """
2
+ PlaceKitten Core - Main PlaceKitten class for placeholder image generation.
3
+
4
+ This module provides the main PlaceKitten class that handles image generation
5
+ from existing kitten images with dimension management and basic functionality.
6
+ """
7
+
8
+ import random
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+ from .processor import ImageProcessor
13
+
14
+
15
+ class PlaceKitten:
16
+ """
17
+ Main PlaceKitten class for generating placeholder images from kitten photos.
18
+
19
+ Provides basic image generation with dimension handling, supporting both
20
+ auto-height 16:9 aspect ratio and custom dimensions.
21
+ """
22
+
23
+ def __init__(self, source_folder: str = "demo"):
24
+ """
25
+ Initialize PlaceKitten with source image folder.
26
+
27
+ Args:
28
+ source_folder: Folder name containing source images (relative to assets/images/)
29
+ """
30
+ self.source_folder = source_folder
31
+ self._image_cache = None
32
+ self._setup_image_paths()
33
+
34
+ def _setup_image_paths(self) -> None:
35
+ """Setup paths to kitten images."""
36
+ # Get placekitten module directory (this file's parent)
37
+ placekitten_module = Path(__file__).parent
38
+
39
+ if self.source_folder == "demo":
40
+ # Use the images folder within placekitten module for demo mode
41
+ self.images_path = placekitten_module / "images"
42
+ else:
43
+ # Use custom source folder within placekitten module
44
+ self.images_path = placekitten_module / "images" / self.source_folder
45
+
46
+ if not self.images_path.exists():
47
+ raise RuntimeError(f"Image source folder not found: {self.images_path}")
48
+
49
+ def _get_available_images(self) -> List[Path]:
50
+ """Get list of available image files."""
51
+ if self._image_cache is None:
52
+ # Cache the image list for performance
53
+ image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
54
+ self._image_cache = sorted(
55
+ [
56
+ img
57
+ for img in self.images_path.iterdir()
58
+ if img.is_file() and img.suffix.lower() in image_extensions
59
+ ]
60
+ )
61
+
62
+ if not self._image_cache:
63
+ raise RuntimeError(f"No images found in {self.images_path}")
64
+
65
+ return self._image_cache
66
+
67
+ def _calculate_height(self, width: int) -> int:
68
+ """Calculate height for 16:9 aspect ratio."""
69
+ return int(width * 9 / 16)
70
+
71
+ def generate(
72
+ self,
73
+ width: Optional[int] = None,
74
+ height: Optional[int] = None,
75
+ filter_type: Optional[str] = None,
76
+ image_id: Optional[int] = None,
77
+ random_selection: bool = False,
78
+ ) -> ImageProcessor:
79
+ """
80
+ Generate placeholder image with specified dimensions.
81
+
82
+ Args:
83
+ width: Image width in pixels (optional - scales from height if only height given)
84
+ height: Image height in pixels (optional - scales from width if only width given)
85
+ filter_type: Filter to apply (e.g., "sepia", "grayscale")
86
+ image_id: Specific image ID to use (1-based index, uses random if invalid/None)
87
+ random_selection: Force random image selection (overrides image_id)
88
+
89
+ Returns:
90
+ ImageProcessor instance for further manipulation
91
+ """
92
+
93
+ # Get available images
94
+ available_images = self._get_available_images()
95
+
96
+ # Select image (using 1-based indexing, random for invalid/None)
97
+ if random_selection:
98
+ selected_image = random.choice(available_images) # nosec
99
+ elif image_id is not None and 1 <= image_id <= len(available_images):
100
+ selected_image = available_images[image_id - 1] # Convert to 0-based index
101
+ else:
102
+ # Use random for None or out-of-range image_id
103
+ selected_image = random.choice(available_images) # nosec
104
+
105
+ # Create ImageProcessor with the selected image
106
+ processor = ImageProcessor(str(selected_image))
107
+
108
+ # Handle dimensions - preserve aspect ratio or use original size
109
+ if width is None and height is None:
110
+ # Return full size image - no resizing
111
+ pass
112
+ elif width is not None and height is not None:
113
+ # Both specified - resize to exact dimensions
114
+ processor = processor.resize(width, height)
115
+ elif width is not None:
116
+ # Only width specified - calculate height preserving aspect ratio
117
+ original_width, original_height = processor.get_size()
118
+ aspect_ratio = original_height / original_width
119
+ calculated_height = int(width * aspect_ratio)
120
+ processor = processor.resize(width, calculated_height)
121
+ elif height is not None:
122
+ # Only height specified - calculate width preserving aspect ratio
123
+ original_width, original_height = processor.get_size()
124
+ aspect_ratio = original_width / original_height
125
+ calculated_width = int(height * aspect_ratio)
126
+ processor = processor.resize(calculated_width, height)
127
+
128
+ # Apply filter if specified
129
+ if filter_type:
130
+ processor = processor.apply_filter(filter_type)
131
+
132
+ return processor
133
+
134
+ def list_available_images(self) -> List[str]:
135
+ """
136
+ Get list of available image filenames.
137
+
138
+ Returns:
139
+ List of image filenames
140
+ """
141
+ images = self._get_available_images()
142
+ return [img.name for img in images]
143
+
144
+ def get_image_count(self) -> int:
145
+ """
146
+ Get count of available images.
147
+
148
+ Returns:
149
+ Number of available images
150
+ """
151
+ return len(self._get_available_images())
152
+
153
+ def batch_process(self, configs: List[dict], output_folder: str = "output") -> List[str]:
154
+ """
155
+ Process multiple images in batch.
156
+
157
+ Args:
158
+ configs: List of configuration dictionaries with width, height, etc.
159
+ output_folder: Output folder for generated images
160
+
161
+ Returns:
162
+ List of generated file paths
163
+ """
164
+ results = []
165
+
166
+ # Ensure output folder exists
167
+ output_path = Path(output_folder)
168
+ output_path.mkdir(parents=True, exist_ok=True)
169
+
170
+ for i, config in enumerate(configs):
171
+ # Generate image with config
172
+ processor = self.generate(**config)
173
+
174
+ # Generate filename
175
+ width = config.get("width", 500)
176
+ height = config.get("height", self._calculate_height(width))
177
+ filename = f"placekitten_{width}x{height}_{i + 1}.jpg"
178
+
179
+ # Save image
180
+ output_file = output_path / filename
181
+ result = processor.save(str(output_file))
182
+ results.append(result)
183
+
184
+ return results
placekitten/filters.py ADDED
@@ -0,0 +1,183 @@
1
+ """
2
+ Filters - Image filter pipeline and effect implementations.
3
+
4
+ This module provides a comprehensive set of image filters and effects
5
+ for the PlaceKitten library with easy extensibility.
6
+ """
7
+
8
+ from typing import Callable, Dict
9
+
10
+ import PIL.ImageOps
11
+ from PIL import Image, ImageEnhance, ImageFilter
12
+
13
+
14
+ class FilterRegistry:
15
+ """Registry for image filters with extensible architecture."""
16
+
17
+ def __init__(self):
18
+ """Initialize filter registry with built-in filters."""
19
+ self._filters: Dict[str, Callable] = {}
20
+ self._register_builtin_filters()
21
+
22
+ def _register_builtin_filters(self) -> None:
23
+ """Register all built-in filters."""
24
+ # Basic filters
25
+ self.register("grayscale", self._grayscale)
26
+ self.register("greyscale", self._grayscale) # Alternative spelling
27
+ self.register("blur", self._blur)
28
+ self.register("sepia", self._sepia)
29
+ self.register("invert", self._invert)
30
+
31
+ # Enhancement filters
32
+ self.register("brightness", self._brightness)
33
+ self.register("contrast", self._contrast)
34
+ self.register("saturation", self._saturation)
35
+ self.register("sharpness", self._sharpness)
36
+
37
+ # Effect filters
38
+ self.register("pixelate", self._pixelate)
39
+ self.register("edge_detection", self._edge_detection)
40
+ self.register("emboss", self._emboss)
41
+ self.register("smooth", self._smooth)
42
+
43
+ def register(self, name: str, filter_func: Callable) -> None:
44
+ """
45
+ Register a new filter.
46
+
47
+ Args:
48
+ name: Filter name
49
+ filter_func: Filter function that takes (image, **kwargs)
50
+ """
51
+ self._filters[name] = filter_func
52
+
53
+ def apply(self, image: Image.Image, filter_name: str, **kwargs) -> Image.Image:
54
+ """
55
+ Apply filter to image.
56
+
57
+ Args:
58
+ image: PIL Image to process
59
+ filter_name: Name of filter to apply
60
+ **kwargs: Filter-specific parameters
61
+
62
+ Returns:
63
+ Processed PIL Image
64
+ """
65
+ if filter_name not in self._filters:
66
+ available = ", ".join(self._filters.keys())
67
+ raise ValueError(f"Unknown filter '{filter_name}'. Available: {available}")
68
+
69
+ return self._filters[filter_name](image, **kwargs)
70
+
71
+ def list_filters(self) -> list:
72
+ """Get list of available filter names."""
73
+ return list(self._filters.keys())
74
+
75
+ # Built-in filter implementations
76
+
77
+ def _grayscale(self, image: Image.Image, **kwargs) -> Image.Image:
78
+ """Convert image to grayscale."""
79
+ return image.convert("L").convert("RGB")
80
+
81
+ def _blur(self, image: Image.Image, **kwargs) -> Image.Image:
82
+ """Apply Gaussian blur."""
83
+ strength = kwargs.get("strength", 5)
84
+ return image.filter(ImageFilter.GaussianBlur(radius=strength))
85
+
86
+ def _sepia(self, image: Image.Image, **kwargs) -> Image.Image:
87
+ """Apply sepia tone effect."""
88
+ # Sepia transformation matrix
89
+ sepia_filter = (0.393, 0.769, 0.189, 0, 0.349, 0.686, 0.168, 0, 0.272, 0.534, 0.131, 0)
90
+ return image.convert("RGB", sepia_filter)
91
+
92
+ def _invert(self, image: Image.Image, **kwargs) -> Image.Image:
93
+ """Invert image colors."""
94
+ return PIL.ImageOps.invert(image)
95
+
96
+ def _brightness(self, image: Image.Image, **kwargs) -> Image.Image:
97
+ """Adjust image brightness."""
98
+ value = kwargs.get("value", 100) # 100 = no change
99
+ factor = value / 100.0
100
+ enhancer = ImageEnhance.Brightness(image)
101
+ return enhancer.enhance(factor)
102
+
103
+ def _contrast(self, image: Image.Image, **kwargs) -> Image.Image:
104
+ """Adjust image contrast."""
105
+ value = kwargs.get("value", 100) # 100 = no change
106
+ factor = value / 100.0
107
+ enhancer = ImageEnhance.Contrast(image)
108
+ return enhancer.enhance(factor)
109
+
110
+ def _saturation(self, image: Image.Image, **kwargs) -> Image.Image:
111
+ """Adjust image saturation."""
112
+ value = kwargs.get("value", 100) # 100 = no change
113
+ factor = value / 100.0
114
+ enhancer = ImageEnhance.Color(image)
115
+ return enhancer.enhance(factor)
116
+
117
+ def _sharpness(self, image: Image.Image, **kwargs) -> Image.Image:
118
+ """Adjust image sharpness."""
119
+ value = kwargs.get("value", 100) # 100 = no change
120
+ factor = value / 100.0
121
+ enhancer = ImageEnhance.Sharpness(image)
122
+ return enhancer.enhance(factor)
123
+
124
+ def _pixelate(self, image: Image.Image, **kwargs) -> Image.Image:
125
+ """Apply pixelation effect."""
126
+ strength = kwargs.get("strength", 5)
127
+ width, height = image.size
128
+
129
+ # Resize down and back up for pixelation
130
+ small_size = (max(1, width // strength), max(1, height // strength))
131
+ pixelated = image.resize(small_size, Image.Resampling.NEAREST)
132
+ return pixelated.resize((width, height), Image.Resampling.NEAREST)
133
+
134
+ def _edge_detection(self, image: Image.Image, **kwargs) -> Image.Image:
135
+ """Apply edge detection filter."""
136
+ return image.filter(ImageFilter.FIND_EDGES)
137
+
138
+ def _emboss(self, image: Image.Image, **kwargs) -> Image.Image:
139
+ """Apply emboss effect."""
140
+ return image.filter(ImageFilter.EMBOSS)
141
+
142
+ def _smooth(self, image: Image.Image, **kwargs) -> Image.Image:
143
+ """Apply smoothing filter."""
144
+ strength = kwargs.get("strength", 1)
145
+ for _ in range(strength):
146
+ image = image.filter(ImageFilter.SMOOTH)
147
+ return image
148
+
149
+
150
+ # Global filter registry instance
151
+ filter_registry = FilterRegistry()
152
+
153
+
154
+ # Convenience functions for direct filter access
155
+ def apply_filter(image: Image.Image, filter_name: str, **kwargs) -> Image.Image:
156
+ """
157
+ Apply filter to image using global registry.
158
+
159
+ Args:
160
+ image: PIL Image to process
161
+ filter_name: Name of filter to apply
162
+ **kwargs: Filter-specific parameters
163
+
164
+ Returns:
165
+ Processed PIL Image
166
+ """
167
+ return filter_registry.apply(image, filter_name, **kwargs)
168
+
169
+
170
+ def list_available_filters() -> list:
171
+ """Get list of all available filters."""
172
+ return filter_registry.list_filters()
173
+
174
+
175
+ def register_custom_filter(name: str, filter_func: Callable) -> None:
176
+ """
177
+ Register a custom filter.
178
+
179
+ Args:
180
+ name: Filter name
181
+ filter_func: Filter function that takes (image, **kwargs)
182
+ """
183
+ filter_registry.register(name, filter_func)
Binary file
Binary file
Binary file
@@ -0,0 +1,262 @@
1
+ """
2
+ ImageProcessor - Core image manipulation and processing functionality.
3
+
4
+ This module provides the ImageProcessor class for image loading, resizing,
5
+ filtering, and saving with method chaining support.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import numpy as np
12
+ from PIL import Image
13
+
14
+ from .filters import apply_filter
15
+ from .smart_crop import smart_crop_engine
16
+
17
+
18
+ class ImageProcessor:
19
+ """
20
+ Image processing class with method chaining support.
21
+
22
+ Handles image loading, manipulation, filtering, and saving operations
23
+ with a fluent interface for complex processing pipelines.
24
+ """
25
+
26
+ def __init__(self, image_path: Optional[str] = None, image_array: Optional[np.ndarray] = None):
27
+ """
28
+ Initialize ImageProcessor with image file or numpy array.
29
+
30
+ Args:
31
+ image_path: Path to image file
32
+ image_array: NumPy array containing image data
33
+ """
34
+ if image_path is not None:
35
+ self.image = Image.open(image_path)
36
+ self.source_path = image_path
37
+ elif image_array is not None:
38
+ self.image = Image.fromarray(image_array)
39
+ self.source_path = None
40
+ else:
41
+ raise ValueError("Must provide either image_path or image_array")
42
+
43
+ # Ensure RGB mode for consistent processing
44
+ if self.image.mode != "RGB":
45
+ self.image = self.image.convert("RGB")
46
+
47
+ def resize(self, width: int, height: Optional[int] = None) -> "ImageProcessor":
48
+ """
49
+ Resize image maintaining aspect ratio or to specific dimensions.
50
+
51
+ Args:
52
+ width: Target width in pixels
53
+ height: Target height in pixels (maintains aspect ratio if None)
54
+
55
+ Returns:
56
+ New ImageProcessor instance with resized image
57
+ """
58
+ if height is None:
59
+ # Maintain aspect ratio when height not specified
60
+ aspect_ratio = self.image.height / self.image.width
61
+ height = int(width * aspect_ratio)
62
+
63
+ # Use high-quality resampling with both dimensions
64
+ resized_image = self.image.resize((width, height), Image.Resampling.LANCZOS)
65
+
66
+ # Create new processor instance
67
+ new_processor = ImageProcessor.__new__(ImageProcessor)
68
+ new_processor.image = resized_image
69
+ new_processor.source_path = self.source_path
70
+
71
+ return new_processor
72
+
73
+ def apply_filter(self, filter_name: str, **kwargs) -> "ImageProcessor":
74
+ """
75
+ Apply image filter using the filter registry.
76
+
77
+ Args:
78
+ filter_name: Name of filter to apply
79
+ **kwargs: Filter-specific parameters
80
+
81
+ Returns:
82
+ New ImageProcessor instance with filter applied
83
+ """
84
+ # Use the centralized filter registry
85
+ filtered_image = apply_filter(self.image.copy(), filter_name, **kwargs)
86
+
87
+ # Create new processor instance
88
+ new_processor = ImageProcessor.__new__(ImageProcessor)
89
+ new_processor.image = filtered_image
90
+ new_processor.source_path = self.source_path
91
+
92
+ return new_processor
93
+
94
+ def smart_crop(
95
+ self,
96
+ width: int,
97
+ height: Optional[int] = None,
98
+ save_steps: bool = False,
99
+ output_prefix: str = "smart_crop",
100
+ output_folder: Optional[str] = None,
101
+ strategy: str = "haar-face",
102
+ ) -> "ImageProcessor":
103
+ """
104
+ Intelligent cropping with computer vision.
105
+
106
+ Uses advanced computer vision techniques including:
107
+ - Edge detection with Canny algorithm
108
+ - Contour analysis for subject identification
109
+ - Rule-of-thirds composition optimization
110
+ - Haar cascade face detection for face-priority cropping
111
+ - Step-by-step visualization (optional)
112
+
113
+ Args:
114
+ width: Target width
115
+ height: Target height (16:9 if None)
116
+ save_steps: Save intermediate processing steps for debugging
117
+ output_prefix: Prefix for debug step files
118
+ output_folder: Directory to save step files (optional)
119
+ strategy: Cropping strategy to use ("haar-face", "contour", etc.)
120
+
121
+ Returns:
122
+ New ImageProcessor instance with intelligently cropped image
123
+ """
124
+ if height is None:
125
+ height = int(width * 9 / 16)
126
+
127
+ try:
128
+ # Use the smart crop engine for intelligent processing
129
+ cropped_image, crop_info = smart_crop_engine.smart_crop(
130
+ self.image, width, height, save_steps, output_prefix, output_folder, strategy
131
+ )
132
+
133
+ # Create new processor instance
134
+ new_processor = ImageProcessor.__new__(ImageProcessor)
135
+ new_processor.image = cropped_image
136
+ new_processor.source_path = self.source_path
137
+ new_processor.crop_info = crop_info # Store crop metadata
138
+
139
+ return new_processor
140
+
141
+ except Exception as e:
142
+ # Fallback to simple center crop if OpenCV fails
143
+ print(f"⚠️ Smart crop failed ({e}), falling back to center crop")
144
+ return self._fallback_center_crop(width, height)
145
+
146
+ def _fallback_center_crop(self, width: int, height: int) -> "ImageProcessor":
147
+ """Fallback center crop implementation."""
148
+ img_width, img_height = self.image.size
149
+
150
+ # Calculate scaling to fit target aspect ratio
151
+ target_ratio = width / height
152
+ img_ratio = img_width / img_height
153
+
154
+ if img_ratio > target_ratio:
155
+ # Image is wider than target, crop width
156
+ new_width = int(img_height * target_ratio)
157
+ left = (img_width - new_width) // 2
158
+ crop_box = (left, 0, left + new_width, img_height)
159
+ else:
160
+ # Image is taller than target, crop height
161
+ new_height = int(img_width / target_ratio)
162
+ top = (img_height - new_height) // 2
163
+ crop_box = (0, top, img_width, top + new_height)
164
+
165
+ # Crop and resize
166
+ cropped_image = self.image.crop(crop_box)
167
+ final_image = cropped_image.resize((width, height), Image.Resampling.LANCZOS)
168
+
169
+ # Create new processor instance
170
+ new_processor = ImageProcessor.__new__(ImageProcessor)
171
+ new_processor.image = final_image
172
+ new_processor.source_path = self.source_path
173
+
174
+ return new_processor
175
+
176
+ def save(self, output_path: str, quality: str = "high") -> str:
177
+ """
178
+ Save processed image.
179
+
180
+ Args:
181
+ output_path: Output file path
182
+ quality: Quality level ("high", "medium", "low")
183
+
184
+ Returns:
185
+ Path to saved file
186
+ """
187
+ # Determine quality settings
188
+ if quality == "high":
189
+ jpeg_quality = 95
190
+ elif quality == "medium":
191
+ jpeg_quality = 85
192
+ else: # low
193
+ jpeg_quality = 70
194
+
195
+ # Ensure output directory exists
196
+ output_path = Path(output_path)
197
+ output_path.parent.mkdir(parents=True, exist_ok=True)
198
+
199
+ # Determine format from extension
200
+ file_extension = output_path.suffix.lower()
201
+
202
+ if file_extension in [".jpg", ".jpeg"]:
203
+ self.image.save(output_path, "JPEG", quality=jpeg_quality, optimize=True)
204
+ elif file_extension == ".png":
205
+ self.image.save(output_path, "PNG", optimize=True)
206
+ elif file_extension == ".webp":
207
+ self.image.save(output_path, "WEBP", quality=jpeg_quality, optimize=True)
208
+ else:
209
+ # Default to JPEG
210
+ output_path = output_path.with_suffix(".jpg")
211
+ self.image.save(output_path, "JPEG", quality=jpeg_quality, optimize=True)
212
+
213
+ return str(output_path)
214
+
215
+ def get_array(self) -> np.ndarray:
216
+ """
217
+ Get image as numpy array.
218
+
219
+ Returns:
220
+ Image as numpy array
221
+ """
222
+ return np.array(self.image)
223
+
224
+ def get_size(self) -> tuple:
225
+ """
226
+ Get image dimensions.
227
+
228
+ Returns:
229
+ Tuple of (width, height)
230
+ """
231
+ return self.image.size
232
+
233
+ def get_info(self) -> dict:
234
+ """
235
+ Get image information.
236
+
237
+ Returns:
238
+ Dictionary with image information
239
+ """
240
+ width, height = self.image.size
241
+ info = {
242
+ "width": width,
243
+ "height": height,
244
+ "mode": self.image.mode,
245
+ "format": self.image.format,
246
+ "source_path": self.source_path,
247
+ }
248
+
249
+ # Add crop information if available
250
+ if hasattr(self, "crop_info"):
251
+ info["crop_info"] = self.crop_info
252
+
253
+ return info
254
+
255
+ def get_crop_info(self) -> Optional[dict]:
256
+ """
257
+ Get crop information from smart crop operation.
258
+
259
+ Returns:
260
+ Dictionary with crop details or None if no smart crop was performed
261
+ """
262
+ return getattr(self, "crop_info", None)