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.
- deckbuilder/__init__.py +22 -0
- deckbuilder/cli.py +544 -0
- deckbuilder/cli_tools.py +739 -0
- deckbuilder/engine.py +1546 -0
- deckbuilder/image_handler.py +291 -0
- deckbuilder/layout_intelligence.json +288 -0
- deckbuilder/layout_intelligence.py +398 -0
- deckbuilder/naming_conventions.py +541 -0
- deckbuilder/placeholder_types.py +101 -0
- deckbuilder/placekitten_integration.py +280 -0
- deckbuilder/structured_frontmatter.py +862 -0
- deckbuilder/table_styles.py +37 -0
- deckbuilder-1.0.0b1.dist-info/METADATA +378 -0
- deckbuilder-1.0.0b1.dist-info/RECORD +37 -0
- deckbuilder-1.0.0b1.dist-info/WHEEL +5 -0
- deckbuilder-1.0.0b1.dist-info/entry_points.txt +3 -0
- deckbuilder-1.0.0b1.dist-info/licenses/LICENSE +201 -0
- deckbuilder-1.0.0b1.dist-info/top_level.txt +4 -0
- mcp_server/__init__.py +9 -0
- mcp_server/content_analysis.py +436 -0
- mcp_server/content_optimization.py +822 -0
- mcp_server/layout_recommendations.py +595 -0
- mcp_server/main.py +550 -0
- mcp_server/tools.py +492 -0
- placekitten/README.md +561 -0
- placekitten/__init__.py +44 -0
- placekitten/core.py +184 -0
- placekitten/filters.py +183 -0
- placekitten/images/ACuteKitten-1.png +0 -0
- placekitten/images/ACuteKitten-2.png +0 -0
- placekitten/images/ACuteKitten-3.png +0 -0
- placekitten/images/TwoKitttens Playing-1.png +0 -0
- placekitten/images/TwoKitttens Playing-2.png +0 -0
- placekitten/images/TwoKitttensSleeping-1.png +0 -0
- placekitten/processor.py +262 -0
- placekitten/smart_crop.py +314 -0
- shared/__init__.py +9 -0
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
|
Binary file
|
Binary file
|
Binary file
|
placekitten/processor.py
ADDED
@@ -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)
|