├── tests ├── __init__.py ├── test_utils.py └── test_core.py ├── example_01.jpeg ├── example_02.jpeg ├── test_images_unit.png ├── .pre-commit-config.yaml ├── LICENSE ├── examples ├── README.md ├── simple_example.py └── showcase.py ├── progressive_blur ├── __init__.py ├── utils.py ├── cli.py └── core.py ├── .gitignore ├── CONTRIBUTING.md ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Tests package for progressive-blur -------------------------------------------------------------------------------- /example_01.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/almmaasoglu/python-progressive-blur/HEAD/example_01.jpeg -------------------------------------------------------------------------------- /example_02.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/almmaasoglu/python-progressive-blur/HEAD/example_02.jpeg -------------------------------------------------------------------------------- /test_images_unit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/almmaasoglu/python-progressive-blur/HEAD/test_images_unit.png -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-added-large-files 9 | - id: check-merge-conflict 10 | - id: debug-statements 11 | 12 | - repo: https://github.com/psf/black 13 | rev: 23.3.0 14 | hooks: 15 | - id: black 16 | language_version: python3 17 | 18 | - repo: https://github.com/pycqa/isort 19 | rev: 5.12.0 20 | hooks: 21 | - id: isort 22 | args: ["--profile", "black"] 23 | 24 | - repo: https://github.com/pycqa/flake8 25 | rev: 6.0.0 26 | hooks: 27 | - id: flake8 28 | args: [--max-line-length=88, --extend-ignore=E203] 29 | 30 | - repo: https://github.com/pre-commit/mirrors-mypy 31 | rev: v1.3.0 32 | hooks: 33 | - id: mypy 34 | additional_dependencies: [types-all] 35 | args: [--ignore-missing-imports] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Alim Maasoglu 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. 22 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Progressive Blur Examples 2 | 3 | This folder contains example scripts showing how to use the Progressive Blur library. 4 | 5 | ## 🚀 Quick Start 6 | 7 | ### `simple_example.py` 8 | A beginner-friendly script that shows: 9 | - Basic blur application 10 | - Using presets 11 | - Custom blur parameters 12 | 13 | Run it: 14 | ```bash 15 | python examples/simple_example.py 16 | ``` 17 | 18 | ### `showcase.py` 19 | A comprehensive demonstration of all features: 20 | - All blur directions (top-to-bottom, center-to-edges, etc.) 21 | - Different algorithms (Gaussian, Box, Motion) 22 | - Easing functions 23 | - Custom masks 24 | - Batch processing 25 | - Performance comparisons 26 | - Web optimization 27 | 28 | Run it: 29 | ```bash 30 | python examples/showcase.py 31 | ``` 32 | 33 | ## 📸 Output 34 | 35 | Both scripts create an `output/` or `output_images/` directory with the processed images, so you can see the results visually. 36 | 37 | ## 💡 Tips 38 | 39 | - Start with `simple_example.py` if you're new to the library 40 | - Check `showcase.py` to discover advanced features 41 | - Feel free to modify these examples for your own use! -------------------------------------------------------------------------------- /progressive_blur/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Progressive Blur - A high-quality Python library for applying progressive blur effects to images. 3 | 4 | This library provides advanced progressive blur functionality with multiple algorithms, 5 | directions, easing functions, and batch processing capabilities. 6 | """ 7 | 8 | from .core import ( 9 | BlurDirection, 10 | BlurAlgorithm, 11 | EasingFunction, 12 | apply_progressive_blur, 13 | create_custom_blur_mask, 14 | apply_mask_based_blur, 15 | ) 16 | from .utils import ( 17 | batch_process_images, 18 | apply_preset, 19 | BLUR_PRESETS, 20 | get_image_info, 21 | optimize_image_for_web, 22 | get_supported_formats, 23 | is_supported_format, 24 | find_images, 25 | ) 26 | 27 | # Legacy import for backward compatibility 28 | from .core import apply_progressive_blur as apply_progressive_blur_legacy 29 | 30 | __version__ = "1.0.0" 31 | __author__ = "Ali Maasoglu" 32 | __email__ = "ali@example.com" 33 | __description__ = "A high-quality Python library for applying progressive blur effects to images" 34 | 35 | __all__ = [ 36 | # Core functionality 37 | "apply_progressive_blur", 38 | "create_custom_blur_mask", 39 | "apply_mask_based_blur", 40 | 41 | # Enums 42 | "BlurDirection", 43 | "BlurAlgorithm", 44 | "EasingFunction", 45 | 46 | # Utility functions 47 | "batch_process_images", 48 | "apply_preset", 49 | "get_image_info", 50 | "optimize_image_for_web", 51 | "get_supported_formats", 52 | "is_supported_format", 53 | "find_images", 54 | 55 | # Presets 56 | "BLUR_PRESETS", 57 | 58 | # Legacy compatibility 59 | "apply_progressive_blur_legacy", 60 | 61 | # Package metadata 62 | "__version__", 63 | "__author__", 64 | "__email__", 65 | "__description__", 66 | ] 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .nox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | *.py,cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | cover/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | db.sqlite3-journal 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | .pybuilder/ 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | Pipfile.lock 88 | 89 | # poetry 90 | poetry.lock 91 | 92 | # pdm 93 | .pdm.toml 94 | 95 | # PEP 582 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # pytype static type analyzer 133 | .pytype/ 134 | 135 | # Cython debug symbols 136 | cython_debug/ 137 | 138 | # PyCharm 139 | .idea/ 140 | 141 | # VS Code 142 | .vscode/ 143 | 144 | # macOS 145 | .DS_Store 146 | 147 | # Generated output directories from examples 148 | output/ 149 | output_images/ 150 | test_images/ 151 | 152 | # Temporary files 153 | *.tmp 154 | *.bak 155 | *.swp 156 | *~ 157 | 158 | # Test coverage 159 | .coverage 160 | htmlcov/ 161 | 162 | # Pre-commit 163 | .pre-commit-config.yaml.lock 164 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Progressive Blur 2 | 3 | Hey there! Thanks for considering contributing to Progressive Blur! We're excited to have you here. 🎉 4 | 5 | ## Getting Started 6 | 7 | ### Setting Up Your Dev Environment 8 | 9 | 1. **Fork and clone the repo**: 10 | ```bash 11 | git clone https://github.com/your-username/python-progressive-blur.git 12 | cd python-progressive-blur 13 | ``` 14 | 15 | 2. **Create a virtual environment** (recommended): 16 | ```bash 17 | python -m venv venv 18 | source venv/bin/activate # On Windows: venv\Scripts\activate 19 | ``` 20 | 21 | 3. **Install the package in dev mode**: 22 | ```bash 23 | pip install -e ".[dev]" 24 | ``` 25 | 26 | That's it! You're ready to start contributing. 27 | 28 | ## Making Changes 29 | 30 | ### Quick Workflow 31 | 32 | 1. Create a new branch: `git checkout -b my-cool-feature` 33 | 2. Make your changes 34 | 3. Run tests: `pytest` 35 | 4. Push and create a PR! 36 | 37 | ### Code Style 38 | 39 | We use a few tools to keep the code consistent, but don't worry - they're mostly automatic: 40 | 41 | - **Black** for formatting (just run `black .`) 42 | - **Type hints** where they make sense (but don't stress about it) 43 | 44 | ## Testing 45 | 46 | If you're adding new features, it'd be great if you could add some tests. Look at the existing tests in `tests/` for examples. Run tests with: 47 | 48 | ```bash 49 | pytest 50 | ``` 51 | 52 | ## Submitting a Pull Request 53 | 54 | 1. Push your changes to your fork 55 | 2. Create a Pull Request 56 | 3. Describe what you changed and why 57 | 4. That's it! We'll review it and work with you to get it merged 58 | 59 | ## Types of Contributions We Love 60 | 61 | - 🐛 **Bug fixes** - Found something broken? Fix it! 62 | - ✨ **New features** - Have an idea? Let's discuss it! 63 | - 📝 **Documentation** - Help others understand how to use the library 64 | - 🎨 **Examples** - Show cool ways to use progressive blur 65 | - 💡 **Ideas** - Even if you can't code it, share your thoughts! 66 | 67 | ## Questions? 68 | 69 | Feel free to: 70 | - Open an issue to discuss your idea 71 | - Ask questions in discussions 72 | - Reach out if you need help 73 | 74 | ## Code Style Guide (The Basics) 75 | 76 | ### Python Style 77 | 78 | ```python 79 | # We like clear variable names 80 | blur_radius = 50.0 # Good 81 | br = 50.0 # Less clear 82 | 83 | # Add docstrings to help others understand 84 | def apply_blur(image, radius): 85 | """Apply blur effect to an image.""" 86 | # Your code here 87 | ``` 88 | 89 | ### Commit Messages 90 | 91 | Keep them simple and clear: 92 | - `fix: correct blur calculation for RGBA images` 93 | - `feat: add motion blur algorithm` 94 | - `docs: update README examples` 95 | 96 | ## Running Quality Checks 97 | 98 | If you want to run the same checks we do: 99 | 100 | ```bash 101 | # Format code 102 | black progressive_blur tests examples 103 | 104 | # Run tests 105 | pytest 106 | 107 | # Check types (optional) 108 | mypy progressive_blur 109 | ``` 110 | 111 | ## Don't Worry About Being Perfect 112 | 113 | - **Your first PR doesn't need to be perfect** - We'll help you improve it 114 | - **Ask questions** - We're here to help 115 | - **Small contributions are welcome** - Even fixing a typo helps! 116 | 117 | ## Community 118 | 119 | We're building a friendly community around this project. Everyone is welcome, regardless of experience level. If you're new to open source, this is a great place to start! 120 | 121 | Remember: **There are no stupid questions!** We all started somewhere. 122 | 123 | Thanks again for contributing! We're looking forward to seeing what you create. 🚀 -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "progressive-blur" 7 | version = "1.0.0" 8 | authors = [ 9 | {name = "Ali Maasoglu", email = "ali@example.com"}, 10 | ] 11 | description = "A high-quality Python library for applying progressive blur effects to images" 12 | readme = "README.md" 13 | license = {text = "MIT"} 14 | requires-python = ">=3.8" 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Intended Audience :: Developers", 18 | "Intended Audience :: End Users/Desktop", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Topic :: Multimedia :: Graphics", 28 | "Topic :: Multimedia :: Graphics :: Graphics Conversion", 29 | "Topic :: Scientific/Engineering :: Image Processing", 30 | "Topic :: Software Development :: Libraries :: Python Modules", 31 | ] 32 | keywords = ["image", "blur", "progressive", "gradient", "image-processing", "PIL", "computer-vision"] 33 | dependencies = [ 34 | "Pillow>=9.0.0", 35 | "numpy>=1.21.0", 36 | "typing-extensions>=4.0.0; python_version<'3.10'", 37 | ] 38 | 39 | [project.optional-dependencies] 40 | dev = [ 41 | "pytest>=7.0.0", 42 | "pytest-cov>=4.0.0", 43 | "black>=22.0.0", 44 | "isort>=5.10.0", 45 | "flake8>=5.0.0", 46 | "mypy>=1.0.0", 47 | "pre-commit>=2.20.0", 48 | ] 49 | docs = [ 50 | "sphinx>=5.0.0", 51 | "sphinx-rtd-theme>=1.0.0", 52 | "myst-parser>=0.18.0", 53 | ] 54 | benchmark = [ 55 | "matplotlib>=3.5.0", 56 | "seaborn>=0.11.0", 57 | "memory-profiler>=0.60.0", 58 | ] 59 | 60 | [project.urls] 61 | Homepage = "https://github.com/almmaasoglu/python-progressive-blur" 62 | Documentation = "https://github.com/almmaasoglu/python-progressive-blur#readme" 63 | Repository = "https://github.com/almmaasoglu/python-progressive-blur.git" 64 | "Bug Tracker" = "https://github.com/almmaasoglu/python-progressive-blur/issues" 65 | 66 | [project.scripts] 67 | progressive-blur = "progressive_blur.cli:main" 68 | 69 | [tool.setuptools.packages.find] 70 | where = ["."] 71 | include = ["progressive_blur*"] 72 | exclude = ["tests*", "docs*", "examples*"] 73 | 74 | [tool.black] 75 | line-length = 88 76 | target-version = ['py38'] 77 | include = '\.pyi?$' 78 | extend-exclude = ''' 79 | /( 80 | # directories 81 | \.eggs 82 | | \.git 83 | | \.hg 84 | | \.mypy_cache 85 | | \.tox 86 | | \.venv 87 | | build 88 | | dist 89 | )/ 90 | ''' 91 | 92 | [tool.isort] 93 | profile = "black" 94 | multi_line_output = 3 95 | line_length = 88 96 | known_first_party = ["progressive_blur"] 97 | 98 | [tool.mypy] 99 | python_version = "3.8" 100 | warn_return_any = true 101 | warn_unused_configs = true 102 | disallow_untyped_defs = true 103 | disallow_incomplete_defs = true 104 | check_untyped_defs = true 105 | disallow_untyped_decorators = true 106 | no_implicit_optional = true 107 | warn_redundant_casts = true 108 | warn_unused_ignores = true 109 | warn_no_return = true 110 | warn_unreachable = true 111 | strict_equality = true 112 | 113 | [tool.pytest.ini_options] 114 | minversion = "7.0" 115 | addopts = "-ra -q --strict-markers --strict-config" 116 | testpaths = ["tests"] 117 | python_files = ["test_*.py", "*_test.py"] 118 | python_classes = ["Test*"] 119 | python_functions = ["test_*"] 120 | 121 | [tool.coverage.run] 122 | source = ["progressive_blur"] 123 | omit = ["*/tests/*", "*/test_*"] 124 | 125 | [tool.coverage.report] 126 | exclude_lines = [ 127 | "pragma: no cover", 128 | "def __repr__", 129 | "if self.debug:", 130 | "if settings.DEBUG", 131 | "raise AssertionError", 132 | "raise NotImplementedError", 133 | "if 0:", 134 | "if __name__ == .__main__.:", 135 | "class .*\\bProtocol\\):", 136 | "@(abc\\.)?abstractmethod", 137 | ] -------------------------------------------------------------------------------- /examples/simple_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple example demonstrating basic progressive blur usage. 4 | 5 | This script shows how to: 6 | - Apply progressive blur to images 7 | - Use different presets 8 | - Handle various image formats 9 | - Process multiple images 10 | """ 11 | 12 | from pathlib import Path 13 | from PIL import Image 14 | from progressive_blur import apply_progressive_blur, apply_preset, BLUR_PRESETS 15 | 16 | 17 | def create_test_image(): 18 | """Create a simple test image if none exists.""" 19 | img = Image.new('RGB', (800, 600), color='lightblue') 20 | 21 | # Add some simple shapes for visual effect 22 | from PIL import ImageDraw 23 | draw = ImageDraw.Draw(img) 24 | 25 | # Draw rectangles 26 | draw.rectangle([100, 100, 300, 200], fill='red') 27 | draw.rectangle([400, 200, 600, 300], fill='green') 28 | draw.rectangle([200, 350, 500, 450], fill='blue') 29 | 30 | # Add text 31 | draw.text((50, 50), "Progressive Blur Test", fill='black') 32 | 33 | return img 34 | 35 | 36 | def test_basic_blur(): 37 | """Test basic progressive blur functionality.""" 38 | print("Testing basic progressive blur...") 39 | 40 | # Create or use test image 41 | test_images_dir = Path("test_images") 42 | output_dir = Path("output_images") 43 | output_dir.mkdir(exist_ok=True) 44 | 45 | # If no test images directory exists, create a test image 46 | if not test_images_dir.exists(): 47 | test_images_dir.mkdir() 48 | test_img = create_test_image() 49 | test_img.save(test_images_dir / "test_image.jpg", quality=95) 50 | print("Created test image: test_images/test_image.jpg") 51 | 52 | # Supported formats 53 | supported_formats = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'} 54 | 55 | # Process each image in the test directory 56 | for image_path in test_images_dir.iterdir(): 57 | if image_path.suffix.lower() in supported_formats: 58 | try: 59 | print(f"Processing {image_path.name}...") 60 | 61 | # Load image 62 | original_image = Image.open(image_path) 63 | 64 | # Apply default progressive blur 65 | blurred_image = apply_progressive_blur(original_image) 66 | 67 | # Save result 68 | output_path = output_dir / f"blurred_{image_path.name}" 69 | 70 | # Handle different formats appropriately 71 | if output_path.suffix.lower() in ('.jpg', '.jpeg'): 72 | blurred_image.save(output_path, quality=95, optimize=True) 73 | elif output_path.suffix.lower() == '.webp': 74 | blurred_image.save(output_path, quality=90, method=6) 75 | else: 76 | blurred_image.save(output_path) 77 | 78 | print(f" Saved: {output_path}") 79 | 80 | except Exception as e: 81 | print(f" Error processing {image_path.name}: {e}") 82 | 83 | 84 | def test_presets(): 85 | """Test different blur presets.""" 86 | print("\nTesting blur presets...") 87 | 88 | # Create test image 89 | img = create_test_image() 90 | output_dir = Path("output_images/presets") 91 | output_dir.mkdir(parents=True, exist_ok=True) 92 | 93 | print(f"Available presets: {list(BLUR_PRESETS.keys())}") 94 | 95 | # Apply each preset 96 | for preset_name in BLUR_PRESETS.keys(): 97 | try: 98 | result = apply_preset(img, preset_name) 99 | output_path = output_dir / f"preset_{preset_name}.jpg" 100 | result.save(output_path, quality=95) 101 | print(f" Applied preset '{preset_name}': {output_path}") 102 | except Exception as e: 103 | print(f" Error with preset '{preset_name}': {e}") 104 | 105 | 106 | def test_custom_parameters(): 107 | """Test custom blur parameters.""" 108 | print("\nTesting custom parameters...") 109 | 110 | img = create_test_image() 111 | output_dir = Path("output_images/custom") 112 | output_dir.mkdir(parents=True, exist_ok=True) 113 | 114 | # Test different parameter combinations 115 | test_configs = [ 116 | { 117 | 'name': 'gentle_blur', 118 | 'max_blur': 20.0, 119 | 'clear_until': 0.3, 120 | 'blur_start': 0.4, 121 | 'end_position': 0.9 122 | }, 123 | { 124 | 'name': 'strong_blur', 125 | 'max_blur': 60.0, 126 | 'clear_until': 0.1, 127 | 'blur_start': 0.2, 128 | 'end_position': 0.7 129 | }, 130 | { 131 | 'name': 'center_focus', 132 | 'max_blur': 40.0, 133 | 'clear_until': 0.0, 134 | 'blur_start': 0.2, 135 | 'end_position': 0.8, 136 | 'direction': 'edges_to_center' 137 | } 138 | ] 139 | 140 | for config in test_configs: 141 | try: 142 | name = config.pop('name') 143 | result = apply_progressive_blur(img, **config) 144 | output_path = output_dir / f"{name}.jpg" 145 | result.save(output_path, quality=95) 146 | print(f" Applied custom config '{name}': {output_path}") 147 | except Exception as e: 148 | print(f" Error with config '{name}': {e}") 149 | 150 | 151 | def main(): 152 | """Run all tests.""" 153 | print("Progressive Blur - Simple Test Script") 154 | print("=" * 40) 155 | 156 | test_basic_blur() 157 | test_presets() 158 | test_custom_parameters() 159 | 160 | print("\n✅ All tests completed!") 161 | print("Check the 'output_images' directory for results.") 162 | 163 | 164 | if __name__ == "__main__": 165 | main() 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Progressive Blur 2 | 3 |
4 | Progressive Blur Example 5 | 6 | [![PyPI version](https://badge.fury.io/py/progressive-blur.svg)](https://badge.fury.io/py/progressive-blur) 7 | [![Python Support](https://img.shields.io/pypi/pyversions/progressive-blur.svg)](https://pypi.org/project/progressive-blur/) 8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 9 | [![Tests](https://img.shields.io/badge/tests-passing-brightgreen)](https://github.com/almmaasoglu/python-progressive-blur) 10 |
11 | 12 | A **high-quality Python library** for applying progressive blur effects to images. Create stunning visual effects with smooth, customizable blur transitions that can enhance your images with professional-grade results. 13 | 14 | ## ✨ Features 15 | 16 | - 🎯 **Multiple Blur Directions**: Top-to-bottom, left-to-right, center-to-edges, and more 17 | - 🔧 **Advanced Algorithms**: Gaussian, Box, and Motion blur algorithms 18 | - 📈 **Easing Functions**: Linear, ease-in/out, exponential, and sine transitions 19 | - 🎨 **Custom Masks**: Create your own blur patterns with custom functions 20 | - 🚀 **Batch Processing**: Process multiple images efficiently 21 | - 📱 **CLI Tool**: Command-line interface for quick operations 22 | - 🎛️ **Presets**: Ready-to-use blur configurations 23 | - 🔍 **Alpha Channel Support**: Preserve transparency in images 24 | - 📊 **Type Hints**: Full type annotation support 25 | - 🧪 **Comprehensive Tests**: Thoroughly tested codebase 26 | 27 | ## 🚀 Installation 28 | 29 | ### From PyPI (Recommended) 30 | 31 | ```bash 32 | pip install progressive-blur 33 | ``` 34 | 35 | ### From Source 36 | 37 | ```bash 38 | git clone https://github.com/almmaasoglu/python-progressive-blur.git 39 | cd python-progressive-blur 40 | pip install -e . 41 | ``` 42 | 43 | ### Development Installation 44 | 45 | ```bash 46 | git clone https://github.com/almmaasoglu/python-progressive-blur.git 47 | cd python-progressive-blur 48 | pip install -e ".[dev]" 49 | ``` 50 | 51 | ## 📖 Quick Start 52 | 53 | ### Basic Usage 54 | 55 | ```python 56 | from PIL import Image 57 | from progressive_blur import apply_progressive_blur 58 | 59 | # Load your image 60 | image = Image.open("your_image.jpg") 61 | 62 | # Apply default progressive blur 63 | blurred = apply_progressive_blur(image) 64 | blurred.save("blurred_image.jpg") 65 | ``` 66 | 67 | ### Advanced Usage 68 | 69 | ```python 70 | from progressive_blur import ( 71 | apply_progressive_blur, 72 | BlurDirection, 73 | BlurAlgorithm, 74 | EasingFunction 75 | ) 76 | 77 | # Custom blur with advanced options 78 | result = apply_progressive_blur( 79 | image, 80 | max_blur=60.0, # Maximum blur intensity 81 | clear_until=0.2, # Keep top 20% clear 82 | blur_start=0.3, # Start blur at 30% 83 | end_position=0.9, # Reach max blur at 90% 84 | direction=BlurDirection.LEFT_TO_RIGHT, 85 | algorithm=BlurAlgorithm.GAUSSIAN, 86 | easing=EasingFunction.EASE_IN_OUT, 87 | preserve_alpha=True 88 | ) 89 | ``` 90 | 91 | ### Using Presets 92 | 93 | ```python 94 | from progressive_blur import apply_preset, BLUR_PRESETS 95 | 96 | # Apply a predefined preset 97 | dramatic_blur = apply_preset(image, "dramatic") 98 | subtle_blur = apply_preset(image, "subtle") 99 | center_focus = apply_preset(image, "center_focus") 100 | 101 | # List available presets 102 | print("Available presets:", list(BLUR_PRESETS.keys())) 103 | ``` 104 | 105 | ### Batch Processing 106 | 107 | ```python 108 | from progressive_blur import batch_process_images 109 | 110 | # Process all images in a directory 111 | processed_files = batch_process_images( 112 | input_dir="./input_images", 113 | output_dir="./output_images", 114 | preset="dramatic", 115 | recursive=True, 116 | overwrite=False 117 | ) 118 | 119 | print(f"Processed {len(processed_files)} images") 120 | ``` 121 | 122 | ### Custom Blur Masks 123 | 124 | ```python 125 | from progressive_blur import create_custom_blur_mask, apply_mask_based_blur 126 | import numpy as np 127 | 128 | # Create a custom circular blur mask 129 | def circular_mask(x: int, y: int) -> float: 130 | center_x, center_y = 250, 250 # Image center 131 | distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) 132 | max_distance = 200 133 | return min(1.0, distance / max_distance) 134 | 135 | # Apply custom mask 136 | mask = create_custom_blur_mask(500, 500, circular_mask) 137 | result = apply_mask_based_blur(image, mask, max_blur=40.0) 138 | ``` 139 | 140 | ## 🖥️ Command Line Interface 141 | 142 | The library includes a powerful CLI tool for quick image processing: 143 | 144 | ### Basic Commands 145 | 146 | ```bash 147 | # Apply default blur to a single image 148 | progressive-blur input.jpg output.jpg 149 | 150 | # Use a preset 151 | progressive-blur input.jpg output.jpg --preset dramatic 152 | 153 | # Custom parameters 154 | progressive-blur input.jpg output.jpg --max-blur 30 --direction left_to_right --easing ease_in_out 155 | 156 | # Batch process a directory 157 | progressive-blur --batch input_dir/ output_dir/ --preset subtle --recursive 158 | 159 | # Get image information 160 | progressive-blur --info image.jpg 161 | 162 | # List available presets 163 | progressive-blur --list-presets 164 | ``` 165 | 166 | ### Advanced CLI Options 167 | 168 | ```bash 169 | # Full customization 170 | progressive-blur input.jpg output.jpg \ 171 | --max-blur 45 \ 172 | --clear-until 0.1 \ 173 | --blur-start 0.2 \ 174 | --end-position 0.8 \ 175 | --direction center_to_edges \ 176 | --algorithm gaussian \ 177 | --easing exponential \ 178 | --quality 95 179 | ``` 180 | 181 | ## 📚 API Reference 182 | 183 | ### Core Functions 184 | 185 | #### `apply_progressive_blur()` 186 | 187 | Apply progressive blur effect to an image. 188 | 189 | **Parameters:** 190 | - `image` (ImageInput): Input image (PIL.Image, bytes, or file path) 191 | - `max_blur` (float): Maximum blur radius (default: 50.0) 192 | - `clear_until` (float): Percentage to keep completely clear (default: 0.15) 193 | - `blur_start` (float): Percentage where blur starts (default: 0.25) 194 | - `end_position` (float): Percentage where maximum blur is reached (default: 0.85) 195 | - `direction` (BlurDirection): Direction of blur effect 196 | - `algorithm` (BlurAlgorithm): Blur algorithm to use 197 | - `easing` (EasingFunction): Easing function for transition 198 | - `preserve_alpha` (bool): Whether to preserve alpha channel 199 | 200 | **Returns:** `PIL.Image` 201 | 202 | ### Enums 203 | 204 | #### `BlurDirection` 205 | - `TOP_TO_BOTTOM`: Blur from top to bottom 206 | - `BOTTOM_TO_TOP`: Blur from bottom to top 207 | - `LEFT_TO_RIGHT`: Blur from left to right 208 | - `RIGHT_TO_LEFT`: Blur from right to left 209 | - `CENTER_TO_EDGES`: Blur from center outward 210 | - `EDGES_TO_CENTER`: Blur from edges inward 211 | 212 | #### `BlurAlgorithm` 213 | - `GAUSSIAN`: Gaussian blur (smooth, natural) 214 | - `BOX`: Box blur (faster, more geometric) 215 | - `MOTION`: Motion blur effect 216 | 217 | #### `EasingFunction` 218 | - `LINEAR`: Constant rate of change 219 | - `EASE_IN`: Slow start, fast end 220 | - `EASE_OUT`: Fast start, slow end 221 | - `EASE_IN_OUT`: Slow start and end 222 | - `EXPONENTIAL`: Exponential curve 223 | - `SINE`: Sine wave curve 224 | 225 | ### Utility Functions 226 | 227 | #### `batch_process_images()` 228 | Process multiple images with the same settings. 229 | 230 | #### `apply_preset()` 231 | Apply predefined blur configurations. 232 | 233 | #### `get_image_info()` 234 | Get detailed information about an image file. 235 | 236 | #### `optimize_image_for_web()` 237 | Optimize images for web use with resizing and compression. 238 | 239 | ## 🎨 Available Presets 240 | 241 | | Preset | Description | Max Blur | Direction | Algorithm | 242 | |--------|-------------|----------|-----------|-----------| 243 | | `subtle` | Gentle blur effect | 20.0 | Top to Bottom | Gaussian | 244 | | `dramatic` | Strong blur effect | 80.0 | Top to Bottom | Gaussian | 245 | | `center_focus` | Focus on center | 60.0 | Edges to Center | Gaussian | 246 | | `horizontal_fade` | Left to right fade | 40.0 | Left to Right | Gaussian | 247 | | `motion_blur` | Motion effect | 30.0 | Top to Bottom | Motion | 248 | 249 | ## 🔧 Advanced Examples 250 | 251 | ### Creating a Vignette Effect 252 | 253 | ```python 254 | from progressive_blur import apply_progressive_blur, BlurDirection 255 | 256 | vignette = apply_progressive_blur( 257 | image, 258 | max_blur=25.0, 259 | clear_until=0.0, 260 | blur_start=0.3, 261 | end_position=1.0, 262 | direction=BlurDirection.EDGES_TO_CENTER, 263 | easing="ease_out" 264 | ) 265 | ``` 266 | 267 | ### Depth of Field Effect 268 | 269 | ```python 270 | # Simulate camera depth of field 271 | depth_effect = apply_progressive_blur( 272 | image, 273 | max_blur=35.0, 274 | clear_until=0.4, 275 | blur_start=0.5, 276 | end_position=0.8, 277 | direction=BlurDirection.TOP_TO_BOTTOM, 278 | easing="exponential" 279 | ) 280 | ``` 281 | 282 | ### Custom Gradient Blur 283 | 284 | ```python 285 | import numpy as np 286 | 287 | def diagonal_gradient(x: int, y: int) -> float: 288 | """Create a diagonal blur gradient.""" 289 | width, height = 1000, 800 # Your image dimensions 290 | diagonal_progress = (x / width + y / height) / 2 291 | return min(1.0, diagonal_progress) 292 | 293 | mask = create_custom_blur_mask(1000, 800, diagonal_gradient) 294 | result = apply_mask_based_blur(image, mask, max_blur=50.0) 295 | ``` 296 | 297 | ## 🧪 Testing 298 | 299 | Run the test suite: 300 | 301 | ```bash 302 | # Install development dependencies 303 | pip install -e ".[dev]" 304 | 305 | # Run tests 306 | pytest 307 | 308 | # Run tests with coverage 309 | pytest --cov=progressive_blur --cov-report=html 310 | ``` 311 | 312 | ## 📊 Performance 313 | 314 | The library is optimized for performance: 315 | 316 | - **Vectorized operations** using NumPy for fast mask generation 317 | - **Efficient memory usage** with in-place operations where possible 318 | - **Batch processing** capabilities for handling multiple images 319 | - **Multiple algorithms** to choose speed vs. quality trade-offs 320 | 321 | ### Benchmarks 322 | 323 | | Image Size | Algorithm | Processing Time | 324 | |------------|-----------|-----------------| 325 | | 1920x1080 | Gaussian | ~0.8s | 326 | | 1920x1080 | Box | ~0.4s | 327 | | 4K (3840x2160) | Gaussian | ~2.1s | 328 | 329 | *Benchmarks run on MacBook Pro M1, times may vary based on hardware and blur intensity.* 330 | 331 | ## 🤝 Contributing 332 | 333 | We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. 334 | 335 | ### Development Setup 336 | 337 | ```bash 338 | git clone https://github.com/almmaasoglu/python-progressive-blur.git 339 | cd python-progressive-blur 340 | pip install -e ".[dev]" 341 | pre-commit install 342 | ``` 343 | 344 | ### Running Quality Checks 345 | 346 | ```bash 347 | # Format code 348 | black progressive_blur tests examples 349 | isort progressive_blur tests examples 350 | 351 | # Type checking 352 | mypy progressive_blur 353 | 354 | # Linting 355 | flake8 progressive_blur tests 356 | ``` 357 | 358 | ## 📄 License 359 | 360 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 361 | 362 | ## 🙏 Acknowledgments 363 | 364 | - Built with [Pillow](https://pillow.readthedocs.io/) for image processing 365 | - Uses [NumPy](https://numpy.org/) for efficient array operations 366 | - Inspired by modern image editing tools and techniques 367 | 368 | ## 📞 Support 369 | 370 | - 📖 [Documentation](https://github.com/almmaasoglu/python-progressive-blur#readme) 371 | - 🐛 [Issue Tracker](https://github.com/almmaasoglu/python-progressive-blur/issues) 372 | - 💬 [Discussions](https://github.com/almmaasoglu/python-progressive-blur/discussions) 373 | 374 | ## 🔗 Links 375 | 376 | - [PyPI Package](https://pypi.org/project/progressive-blur/) 377 | - [GitHub Repository](https://github.com/almmaasoglu/python-progressive-blur) 378 | - [Change Log](CHANGELOG.md) 379 | 380 | --- 381 | 382 |
383 | Made with ❤️ by Alim Maasoglu 384 |
385 | -------------------------------------------------------------------------------- /progressive_blur/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions for progressive blur operations. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import os 8 | from pathlib import Path 9 | from typing import Dict, Generator, List, Optional, Tuple, Union 10 | 11 | from PIL import Image 12 | 13 | from .core import ImageInput, apply_progressive_blur, BlurDirection, BlurAlgorithm, EasingFunction 14 | 15 | 16 | def get_supported_formats() -> Tuple[str, ...]: 17 | """Get tuple of supported image formats.""" 18 | return ('.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff', '.tif') 19 | 20 | 21 | def is_supported_format(file_path: Union[str, Path]) -> bool: 22 | """Check if a file has a supported image format.""" 23 | return Path(file_path).suffix.lower() in get_supported_formats() 24 | 25 | 26 | def find_images( 27 | directory: Union[str, Path], 28 | recursive: bool = False 29 | ) -> Generator[Path, None, None]: 30 | """ 31 | Find all supported image files in a directory. 32 | 33 | Args: 34 | directory: Directory to search 35 | recursive: Whether to search subdirectories 36 | 37 | Yields: 38 | Path: Image file paths 39 | """ 40 | directory = Path(directory) 41 | if not directory.exists(): 42 | raise FileNotFoundError(f"Directory not found: {directory}") 43 | 44 | pattern = "**/*" if recursive else "*" 45 | 46 | for file_path in directory.glob(pattern): 47 | if file_path.is_file() and is_supported_format(file_path): 48 | yield file_path 49 | 50 | 51 | def batch_process_images( 52 | input_dir: Union[str, Path], 53 | output_dir: Union[str, Path], 54 | max_blur: float = 50.0, 55 | clear_until: float = 0.15, 56 | blur_start: float = 0.25, 57 | end_position: float = 0.85, 58 | direction: Union[BlurDirection, str] = BlurDirection.TOP_TO_BOTTOM, 59 | algorithm: Union[BlurAlgorithm, str] = BlurAlgorithm.GAUSSIAN, 60 | easing: Union[EasingFunction, str] = EasingFunction.LINEAR, 61 | preserve_alpha: bool = True, 62 | recursive: bool = False, 63 | overwrite: bool = False, 64 | quality: int = 95, 65 | prefix: str = "blurred_", 66 | progress_callback: Optional[callable] = None, 67 | ) -> List[Tuple[Path, Path]]: 68 | """ 69 | Process multiple images with progressive blur effect. 70 | 71 | Args: 72 | input_dir: Input directory containing images 73 | output_dir: Output directory for processed images 74 | max_blur: Maximum blur radius 75 | clear_until: Percentage to keep completely clear 76 | blur_start: Percentage where blur starts to appear 77 | end_position: Percentage where maximum blur is reached 78 | direction: Direction of the blur effect 79 | algorithm: Blur algorithm to use 80 | easing: Easing function for blur transition 81 | preserve_alpha: Whether to preserve alpha channel 82 | recursive: Whether to search subdirectories 83 | overwrite: Whether to overwrite existing files 84 | quality: JPEG quality (1-100) 85 | prefix: Prefix for output filenames 86 | progress_callback: Optional callback function for progress updates 87 | 88 | Returns: 89 | List of (input_path, output_path) tuples for processed files 90 | 91 | Raises: 92 | FileNotFoundError: If input directory doesn't exist 93 | ValueError: If quality is not in valid range 94 | """ 95 | input_dir = Path(input_dir) 96 | output_dir = Path(output_dir) 97 | 98 | if not 1 <= quality <= 100: 99 | raise ValueError(f"Quality must be between 1 and 100, got {quality}") 100 | 101 | if not input_dir.exists(): 102 | raise FileNotFoundError(f"Input directory not found: {input_dir}") 103 | 104 | # Create output directory 105 | output_dir.mkdir(parents=True, exist_ok=True) 106 | 107 | # Find all images 108 | image_files = list(find_images(input_dir, recursive=recursive)) 109 | processed_files = [] 110 | 111 | for i, input_path in enumerate(image_files): 112 | try: 113 | # Generate output path 114 | output_filename = f"{prefix}{input_path.name}" 115 | if recursive: 116 | # Preserve directory structure 117 | relative_path = input_path.relative_to(input_dir) 118 | output_path = output_dir / relative_path.parent / output_filename 119 | output_path.parent.mkdir(parents=True, exist_ok=True) 120 | else: 121 | output_path = output_dir / output_filename 122 | 123 | # Skip if file exists and overwrite is False 124 | if output_path.exists() and not overwrite: 125 | continue 126 | 127 | # Process image 128 | blurred_image = apply_progressive_blur( 129 | str(input_path), 130 | max_blur=max_blur, 131 | clear_until=clear_until, 132 | blur_start=blur_start, 133 | end_position=end_position, 134 | direction=direction, 135 | algorithm=algorithm, 136 | easing=easing, 137 | preserve_alpha=preserve_alpha, 138 | ) 139 | 140 | # Save with appropriate format and quality 141 | save_kwargs = {} 142 | if output_path.suffix.lower() in ('.jpg', '.jpeg'): 143 | save_kwargs['quality'] = quality 144 | save_kwargs['optimize'] = True 145 | elif output_path.suffix.lower() == '.webp': 146 | save_kwargs['quality'] = quality 147 | save_kwargs['method'] = 6 # Better compression 148 | elif output_path.suffix.lower() == '.png': 149 | save_kwargs['optimize'] = True 150 | 151 | blurred_image.save(output_path, **save_kwargs) 152 | processed_files.append((input_path, output_path)) 153 | 154 | # Call progress callback if provided 155 | if progress_callback: 156 | progress_callback(i + 1, len(image_files), input_path, output_path) 157 | 158 | except Exception as e: 159 | print(f"Error processing {input_path}: {e}") 160 | continue 161 | 162 | return processed_files 163 | 164 | 165 | def create_blur_preset( 166 | name: str, 167 | max_blur: float, 168 | clear_until: float, 169 | blur_start: float, 170 | end_position: float, 171 | direction: Union[BlurDirection, str] = BlurDirection.TOP_TO_BOTTOM, 172 | algorithm: Union[BlurAlgorithm, str] = BlurAlgorithm.GAUSSIAN, 173 | easing: Union[EasingFunction, str] = EasingFunction.LINEAR, 174 | ) -> Dict[str, any]: 175 | """ 176 | Create a blur preset configuration. 177 | 178 | Args: 179 | name: Preset name 180 | max_blur: Maximum blur radius 181 | clear_until: Percentage to keep completely clear 182 | blur_start: Percentage where blur starts to appear 183 | end_position: Percentage where maximum blur is reached 184 | direction: Direction of the blur effect 185 | algorithm: Blur algorithm to use 186 | easing: Easing function for blur transition 187 | 188 | Returns: 189 | Dictionary containing preset configuration 190 | """ 191 | return { 192 | 'name': name, 193 | 'max_blur': max_blur, 194 | 'clear_until': clear_until, 195 | 'blur_start': blur_start, 196 | 'end_position': end_position, 197 | 'direction': direction, 198 | 'algorithm': algorithm, 199 | 'easing': easing, 200 | } 201 | 202 | 203 | # Predefined presets 204 | BLUR_PRESETS = { 205 | 'subtle': create_blur_preset( 206 | 'Subtle Blur', 207 | max_blur=20.0, 208 | clear_until=0.2, 209 | blur_start=0.3, 210 | end_position=0.9, 211 | easing=EasingFunction.EASE_OUT, 212 | ), 213 | 'dramatic': create_blur_preset( 214 | 'Dramatic Blur', 215 | max_blur=80.0, 216 | clear_until=0.1, 217 | blur_start=0.2, 218 | end_position=0.7, 219 | easing=EasingFunction.EASE_IN, 220 | ), 221 | 'center_focus': create_blur_preset( 222 | 'Center Focus', 223 | max_blur=60.0, 224 | clear_until=0.0, 225 | blur_start=0.1, 226 | end_position=0.8, 227 | direction=BlurDirection.EDGES_TO_CENTER, 228 | easing=EasingFunction.EASE_IN_OUT, 229 | ), 230 | 'horizontal_fade': create_blur_preset( 231 | 'Horizontal Fade', 232 | max_blur=40.0, 233 | clear_until=0.15, 234 | blur_start=0.25, 235 | end_position=0.85, 236 | direction=BlurDirection.LEFT_TO_RIGHT, 237 | easing=EasingFunction.SINE, 238 | ), 239 | 'motion_blur': create_blur_preset( 240 | 'Motion Blur Effect', 241 | max_blur=30.0, 242 | clear_until=0.1, 243 | blur_start=0.2, 244 | end_position=0.8, 245 | algorithm=BlurAlgorithm.MOTION, 246 | easing=EasingFunction.LINEAR, 247 | ), 248 | } 249 | 250 | 251 | def apply_preset( 252 | image: ImageInput, 253 | preset_name: str, 254 | preserve_alpha: bool = True, 255 | ) -> Image.Image: 256 | """ 257 | Apply a predefined blur preset to an image. 258 | 259 | Args: 260 | image: Input image 261 | preset_name: Name of the preset to apply 262 | preserve_alpha: Whether to preserve alpha channel 263 | 264 | Returns: 265 | PIL.Image: Processed image 266 | 267 | Raises: 268 | ValueError: If preset name is not found 269 | """ 270 | if preset_name not in BLUR_PRESETS: 271 | available_presets = ', '.join(BLUR_PRESETS.keys()) 272 | raise ValueError( 273 | f"Unknown preset '{preset_name}'. Available presets: {available_presets}" 274 | ) 275 | 276 | preset = BLUR_PRESETS[preset_name] 277 | 278 | return apply_progressive_blur( 279 | image, 280 | max_blur=preset['max_blur'], 281 | clear_until=preset['clear_until'], 282 | blur_start=preset['blur_start'], 283 | end_position=preset['end_position'], 284 | direction=preset['direction'], 285 | algorithm=preset['algorithm'], 286 | easing=preset['easing'], 287 | preserve_alpha=preserve_alpha, 288 | ) 289 | 290 | 291 | def get_image_info(image_path: Union[str, Path]) -> Dict[str, any]: 292 | """ 293 | Get information about an image file. 294 | 295 | Args: 296 | image_path: Path to the image file 297 | 298 | Returns: 299 | Dictionary containing image information 300 | """ 301 | image_path = Path(image_path) 302 | 303 | if not image_path.exists(): 304 | raise FileNotFoundError(f"Image file not found: {image_path}") 305 | 306 | with Image.open(image_path) as img: 307 | return { 308 | 'filename': image_path.name, 309 | 'format': img.format, 310 | 'mode': img.mode, 311 | 'size': img.size, 312 | 'width': img.width, 313 | 'height': img.height, 314 | 'has_transparency': img.mode in ('RGBA', 'LA') or 'transparency' in img.info, 315 | 'file_size': image_path.stat().st_size, 316 | } 317 | 318 | 319 | def optimize_image_for_web( 320 | image: Image.Image, 321 | max_width: int = 1920, 322 | max_height: int = 1080, 323 | quality: int = 85, 324 | ) -> Image.Image: 325 | """ 326 | Optimize an image for web use by resizing and compressing. 327 | 328 | Args: 329 | image: Input image 330 | max_width: Maximum width in pixels 331 | max_height: Maximum height in pixels 332 | quality: JPEG quality (1-100) 333 | 334 | Returns: 335 | PIL.Image: Optimized image 336 | """ 337 | # Calculate new size while maintaining aspect ratio 338 | width, height = image.size 339 | 340 | if width > max_width or height > max_height: 341 | ratio = min(max_width / width, max_height / height) 342 | new_width = int(width * ratio) 343 | new_height = int(height * ratio) 344 | image = image.resize((new_width, new_height), Image.Resampling.LANCZOS) 345 | 346 | return image -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for progressive blur utility functions. 3 | """ 4 | 5 | import tempfile 6 | from pathlib import Path 7 | from unittest.mock import patch 8 | 9 | import pytest 10 | from PIL import Image 11 | 12 | from progressive_blur.utils import ( 13 | get_supported_formats, 14 | is_supported_format, 15 | find_images, 16 | batch_process_images, 17 | create_blur_preset, 18 | BLUR_PRESETS, 19 | apply_preset, 20 | get_image_info, 21 | optimize_image_for_web, 22 | ) 23 | from progressive_blur.core import BlurDirection, BlurAlgorithm, EasingFunction 24 | 25 | 26 | class TestFormatSupport: 27 | """Test format support utilities.""" 28 | 29 | def test_get_supported_formats(self): 30 | """Test getting supported formats.""" 31 | formats = get_supported_formats() 32 | assert isinstance(formats, tuple) 33 | assert '.jpg' in formats 34 | assert '.png' in formats 35 | assert '.webp' in formats 36 | 37 | def test_is_supported_format_valid(self): 38 | """Test format validation with valid formats.""" 39 | assert is_supported_format('image.jpg') 40 | assert is_supported_format('image.PNG') # Case insensitive 41 | assert is_supported_format(Path('image.webp')) 42 | 43 | def test_is_supported_format_invalid(self): 44 | """Test format validation with invalid formats.""" 45 | assert not is_supported_format('document.txt') 46 | assert not is_supported_format('video.mp4') 47 | assert not is_supported_format('image') # No extension 48 | 49 | 50 | class TestImageDiscovery: 51 | """Test image discovery functionality.""" 52 | 53 | def test_find_images_basic(self): 54 | """Test basic image finding.""" 55 | with tempfile.TemporaryDirectory() as tmpdir: 56 | tmpdir = Path(tmpdir) 57 | 58 | # Create test images 59 | img = Image.new('RGB', (10, 10), color='red') 60 | (tmpdir / 'test1.jpg').touch() 61 | (tmpdir / 'test2.png').touch() 62 | (tmpdir / 'document.txt').touch() # Should be ignored 63 | 64 | images = list(find_images(tmpdir)) 65 | image_names = [img.name for img in images] 66 | 67 | assert 'test1.jpg' in image_names 68 | assert 'test2.png' in image_names 69 | assert 'document.txt' not in image_names 70 | 71 | def test_find_images_recursive(self): 72 | """Test recursive image finding.""" 73 | with tempfile.TemporaryDirectory() as tmpdir: 74 | tmpdir = Path(tmpdir) 75 | 76 | # Create nested structure 77 | subdir = tmpdir / 'subdir' 78 | subdir.mkdir() 79 | 80 | (tmpdir / 'root.jpg').touch() 81 | (subdir / 'nested.png').touch() 82 | 83 | # Non-recursive 84 | images_non_recursive = list(find_images(tmpdir, recursive=False)) 85 | assert len(images_non_recursive) == 1 86 | assert images_non_recursive[0].name == 'root.jpg' 87 | 88 | # Recursive 89 | images_recursive = list(find_images(tmpdir, recursive=True)) 90 | assert len(images_recursive) == 2 91 | image_names = [img.name for img in images_recursive] 92 | assert 'root.jpg' in image_names 93 | assert 'nested.png' in image_names 94 | 95 | def test_find_images_nonexistent_directory(self): 96 | """Test error handling for nonexistent directory.""" 97 | with pytest.raises(FileNotFoundError, match="Directory not found"): 98 | list(find_images('/nonexistent/directory')) 99 | 100 | 101 | class TestBatchProcessing: 102 | """Test batch processing functionality.""" 103 | 104 | def test_batch_process_basic(self): 105 | """Test basic batch processing.""" 106 | with tempfile.TemporaryDirectory() as tmpdir: 107 | input_dir = Path(tmpdir) / 'input' 108 | output_dir = Path(tmpdir) / 'output' 109 | input_dir.mkdir() 110 | 111 | # Create test images 112 | img = Image.new('RGB', (50, 50), color='blue') 113 | img.save(input_dir / 'test1.jpg') 114 | img.save(input_dir / 'test2.png') 115 | 116 | # Process images 117 | processed = batch_process_images( 118 | input_dir, 119 | output_dir, 120 | max_blur=10.0, # Small blur for speed 121 | ) 122 | 123 | assert len(processed) == 2 124 | assert output_dir.exists() 125 | assert (output_dir / 'blurred_test1.jpg').exists() 126 | assert (output_dir / 'blurred_test2.png').exists() 127 | 128 | def test_batch_process_with_prefix(self): 129 | """Test batch processing with custom prefix.""" 130 | with tempfile.TemporaryDirectory() as tmpdir: 131 | input_dir = Path(tmpdir) / 'input' 132 | output_dir = Path(tmpdir) / 'output' 133 | input_dir.mkdir() 134 | 135 | img = Image.new('RGB', (30, 30), color='green') 136 | img.save(input_dir / 'test.jpg') 137 | 138 | batch_process_images( 139 | input_dir, 140 | output_dir, 141 | prefix='custom_', 142 | max_blur=5.0, 143 | ) 144 | 145 | assert (output_dir / 'custom_test.jpg').exists() 146 | 147 | def test_batch_process_overwrite_protection(self): 148 | """Test overwrite protection.""" 149 | with tempfile.TemporaryDirectory() as tmpdir: 150 | input_dir = Path(tmpdir) / 'input' 151 | output_dir = Path(tmpdir) / 'output' 152 | input_dir.mkdir() 153 | output_dir.mkdir() 154 | 155 | img = Image.new('RGB', (30, 30), color='red') 156 | img.save(input_dir / 'test.jpg') 157 | 158 | # Create existing output file 159 | (output_dir / 'blurred_test.jpg').touch() 160 | 161 | # Should skip existing file 162 | processed = batch_process_images( 163 | input_dir, 164 | output_dir, 165 | overwrite=False, 166 | max_blur=5.0, 167 | ) 168 | 169 | assert len(processed) == 0 170 | 171 | # Should overwrite with overwrite=True 172 | processed = batch_process_images( 173 | input_dir, 174 | output_dir, 175 | overwrite=True, 176 | max_blur=5.0, 177 | ) 178 | 179 | assert len(processed) == 1 180 | 181 | def test_batch_process_invalid_quality(self): 182 | """Test error handling for invalid quality.""" 183 | with tempfile.TemporaryDirectory() as tmpdir: 184 | input_dir = Path(tmpdir) / 'input' 185 | output_dir = Path(tmpdir) / 'output' 186 | 187 | with pytest.raises(ValueError, match="Quality must be between 1 and 100"): 188 | batch_process_images(input_dir, output_dir, quality=150) 189 | 190 | 191 | class TestPresets: 192 | """Test preset functionality.""" 193 | 194 | def test_create_blur_preset(self): 195 | """Test creating custom blur presets.""" 196 | preset = create_blur_preset( 197 | 'test_preset', 198 | max_blur=25.0, 199 | clear_until=0.1, 200 | blur_start=0.2, 201 | end_position=0.9, 202 | direction=BlurDirection.LEFT_TO_RIGHT, 203 | ) 204 | 205 | assert preset['name'] == 'test_preset' 206 | assert preset['max_blur'] == 25.0 207 | assert preset['direction'] == BlurDirection.LEFT_TO_RIGHT 208 | 209 | def test_predefined_presets_exist(self): 210 | """Test that predefined presets exist and are valid.""" 211 | assert 'subtle' in BLUR_PRESETS 212 | assert 'dramatic' in BLUR_PRESETS 213 | assert 'center_focus' in BLUR_PRESETS 214 | 215 | for preset_name, preset in BLUR_PRESETS.items(): 216 | assert 'name' in preset 217 | assert 'max_blur' in preset 218 | assert isinstance(preset['direction'], BlurDirection) 219 | assert isinstance(preset['algorithm'], BlurAlgorithm) 220 | assert isinstance(preset['easing'], EasingFunction) 221 | 222 | def test_apply_preset_valid(self): 223 | """Test applying valid presets.""" 224 | img = Image.new('RGB', (50, 50), color='yellow') 225 | 226 | for preset_name in BLUR_PRESETS.keys(): 227 | result = apply_preset(img, preset_name) 228 | assert isinstance(result, Image.Image) 229 | assert result.size == img.size 230 | 231 | def test_apply_preset_invalid(self): 232 | """Test error handling for invalid preset names.""" 233 | img = Image.new('RGB', (50, 50), color='purple') 234 | 235 | with pytest.raises(ValueError, match="Unknown preset"): 236 | apply_preset(img, 'nonexistent_preset') 237 | 238 | 239 | class TestImageInfo: 240 | """Test image information utilities.""" 241 | 242 | def test_get_image_info_basic(self): 243 | """Test getting basic image information.""" 244 | with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: 245 | img = Image.new('RGB', (100, 200), color='orange') 246 | img.save(tmp.name) 247 | 248 | info = get_image_info(tmp.name) 249 | 250 | assert info['width'] == 100 251 | assert info['height'] == 200 252 | assert info['size'] == (100, 200) 253 | assert info['mode'] == 'RGB' 254 | assert info['format'] == 'PNG' 255 | assert not info['has_transparency'] 256 | assert info['file_size'] > 0 257 | 258 | Path(tmp.name).unlink() 259 | 260 | def test_get_image_info_with_alpha(self): 261 | """Test getting info for images with transparency.""" 262 | with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: 263 | img = Image.new('RGBA', (50, 50), color=(255, 0, 0, 128)) 264 | img.save(tmp.name) 265 | 266 | info = get_image_info(tmp.name) 267 | 268 | assert info['mode'] == 'RGBA' 269 | assert info['has_transparency'] 270 | 271 | Path(tmp.name).unlink() 272 | 273 | def test_get_image_info_nonexistent(self): 274 | """Test error handling for nonexistent files.""" 275 | with pytest.raises(FileNotFoundError, match="Image file not found"): 276 | get_image_info('/nonexistent/image.jpg') 277 | 278 | 279 | class TestImageOptimization: 280 | """Test image optimization utilities.""" 281 | 282 | def test_optimize_no_resize_needed(self): 283 | """Test optimization when no resize is needed.""" 284 | img = Image.new('RGB', (800, 600), color='cyan') 285 | optimized = optimize_image_for_web(img, max_width=1920, max_height=1080) 286 | 287 | assert optimized.size == (800, 600) 288 | 289 | def test_optimize_resize_by_width(self): 290 | """Test optimization when width exceeds limit.""" 291 | img = Image.new('RGB', (2000, 1000), color='magenta') 292 | optimized = optimize_image_for_web(img, max_width=1920, max_height=1080) 293 | 294 | # Should be resized to maintain aspect ratio 295 | assert optimized.width == 1920 296 | assert optimized.height == 960 # Maintains 2:1 ratio 297 | 298 | def test_optimize_resize_by_height(self): 299 | """Test optimization when height exceeds limit.""" 300 | img = Image.new('RGB', (1000, 2000), color='lime') 301 | optimized = optimize_image_for_web(img, max_width=1920, max_height=1080) 302 | 303 | # Should be resized to maintain aspect ratio 304 | assert optimized.width == 540 # Maintains 1:2 ratio 305 | assert optimized.height == 1080 306 | 307 | def test_optimize_resize_both_dimensions(self): 308 | """Test optimization when both dimensions exceed limits.""" 309 | img = Image.new('RGB', (3000, 2000), color='navy') 310 | optimized = optimize_image_for_web(img, max_width=1920, max_height=1080) 311 | 312 | # Should be resized by the more restrictive dimension 313 | assert optimized.width == 1620 # Limited by height: 1080 * (3000/2000) 314 | assert optimized.height == 1080 -------------------------------------------------------------------------------- /examples/showcase.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Advanced examples demonstrating the capabilities of the Progressive Blur library. 4 | 5 | This script showcases various features including: 6 | - Different blur directions and algorithms 7 | - Custom easing functions 8 | - Preset usage 9 | - Custom mask creation 10 | - Batch processing 11 | - Performance optimization 12 | """ 13 | 14 | import time 15 | from pathlib import Path 16 | from typing import Tuple 17 | 18 | import numpy as np 19 | from PIL import Image, ImageDraw, ImageFont 20 | 21 | from progressive_blur import ( 22 | apply_progressive_blur, 23 | apply_preset, 24 | batch_process_images, 25 | create_custom_blur_mask, 26 | apply_mask_based_blur, 27 | BlurDirection, 28 | BlurAlgorithm, 29 | EasingFunction, 30 | BLUR_PRESETS, 31 | optimize_image_for_web, 32 | ) 33 | 34 | 35 | def create_sample_image(size: Tuple[int, int] = (800, 600)) -> Image.Image: 36 | """Create a sample image for testing.""" 37 | img = Image.new('RGB', size, color='white') 38 | draw = ImageDraw.Draw(img) 39 | 40 | # Draw some geometric shapes 41 | draw.rectangle([50, 50, 200, 200], fill='red', outline='darkred', width=3) 42 | draw.ellipse([250, 100, 400, 250], fill='blue', outline='darkblue', width=3) 43 | draw.polygon([(500, 50), (600, 150), (450, 200)], fill='green', outline='darkgreen', width=3) 44 | 45 | # Add some text 46 | try: 47 | # Try to use a system font 48 | font = ImageFont.truetype("arial.ttf", 36) 49 | except (OSError, IOError): 50 | # Fallback to default font 51 | font = ImageFont.load_default() 52 | 53 | draw.text((50, 300), "Progressive Blur Demo", fill='black', font=font) 54 | draw.text((50, 350), "High Quality Image Processing", fill='gray', font=font) 55 | 56 | # Add some gradient background 57 | for y in range(size[1]): 58 | color_value = int(255 * (y / size[1]) * 0.1) 59 | draw.line([(0, y), (size[0], y)], fill=(255-color_value, 255-color_value, 255)) 60 | 61 | return img 62 | 63 | 64 | def demo_basic_usage(): 65 | """Demonstrate basic progressive blur usage.""" 66 | print("🎯 Basic Usage Demo") 67 | print("-" * 50) 68 | 69 | # Create sample image 70 | img = create_sample_image() 71 | output_dir = Path("output/basic") 72 | output_dir.mkdir(parents=True, exist_ok=True) 73 | 74 | # Save original 75 | img.save(output_dir / "original.jpg", quality=95) 76 | 77 | # Apply default blur 78 | start_time = time.time() 79 | blurred = apply_progressive_blur(img) 80 | processing_time = time.time() - start_time 81 | 82 | blurred.save(output_dir / "default_blur.jpg", quality=95) 83 | print(f"✅ Default blur applied in {processing_time:.2f}s") 84 | 85 | # Apply custom blur 86 | custom_blur = apply_progressive_blur( 87 | img, 88 | max_blur=30.0, 89 | clear_until=0.2, 90 | blur_start=0.3, 91 | end_position=0.8 92 | ) 93 | custom_blur.save(output_dir / "custom_blur.jpg", quality=95) 94 | print("✅ Custom blur parameters applied") 95 | 96 | 97 | def demo_directions_and_algorithms(): 98 | """Demonstrate different blur directions and algorithms.""" 99 | print("\n🧭 Directions & Algorithms Demo") 100 | print("-" * 50) 101 | 102 | img = create_sample_image() 103 | output_dir = Path("output/directions_algorithms") 104 | output_dir.mkdir(parents=True, exist_ok=True) 105 | 106 | # Test different directions 107 | directions = [ 108 | (BlurDirection.TOP_TO_BOTTOM, "top_to_bottom"), 109 | (BlurDirection.LEFT_TO_RIGHT, "left_to_right"), 110 | (BlurDirection.CENTER_TO_EDGES, "center_to_edges"), 111 | (BlurDirection.BOTTOM_TO_TOP, "bottom_to_top"), 112 | ] 113 | 114 | for direction, name in directions: 115 | result = apply_progressive_blur( 116 | img, 117 | max_blur=40.0, 118 | direction=direction, 119 | clear_until=0.1, 120 | blur_start=0.2, 121 | end_position=0.8 122 | ) 123 | result.save(output_dir / f"direction_{name}.jpg", quality=95) 124 | print(f"✅ {name.replace('_', ' ').title()} direction") 125 | 126 | # Test different algorithms 127 | algorithms = [ 128 | (BlurAlgorithm.GAUSSIAN, "gaussian"), 129 | (BlurAlgorithm.BOX, "box"), 130 | (BlurAlgorithm.MOTION, "motion"), 131 | ] 132 | 133 | for algorithm, name in algorithms: 134 | result = apply_progressive_blur( 135 | img, 136 | max_blur=35.0, 137 | algorithm=algorithm, 138 | direction=BlurDirection.TOP_TO_BOTTOM 139 | ) 140 | result.save(output_dir / f"algorithm_{name}.jpg", quality=95) 141 | print(f"✅ {name.title()} algorithm") 142 | 143 | 144 | def demo_easing_functions(): 145 | """Demonstrate different easing functions.""" 146 | print("\n📈 Easing Functions Demo") 147 | print("-" * 50) 148 | 149 | img = create_sample_image() 150 | output_dir = Path("output/easing") 151 | output_dir.mkdir(parents=True, exist_ok=True) 152 | 153 | easing_functions = [ 154 | (EasingFunction.LINEAR, "linear"), 155 | (EasingFunction.EASE_IN, "ease_in"), 156 | (EasingFunction.EASE_OUT, "ease_out"), 157 | (EasingFunction.EASE_IN_OUT, "ease_in_out"), 158 | (EasingFunction.EXPONENTIAL, "exponential"), 159 | (EasingFunction.SINE, "sine"), 160 | ] 161 | 162 | for easing, name in easing_functions: 163 | result = apply_progressive_blur( 164 | img, 165 | max_blur=50.0, 166 | easing=easing, 167 | clear_until=0.1, 168 | blur_start=0.2, 169 | end_position=0.9 170 | ) 171 | result.save(output_dir / f"easing_{name}.jpg", quality=95) 172 | print(f"✅ {name.replace('_', ' ').title()} easing") 173 | 174 | 175 | def demo_presets(): 176 | """Demonstrate preset usage.""" 177 | print("\n🎛️ Presets Demo") 178 | print("-" * 50) 179 | 180 | img = create_sample_image() 181 | output_dir = Path("output/presets") 182 | output_dir.mkdir(parents=True, exist_ok=True) 183 | 184 | print(f"Available presets: {list(BLUR_PRESETS.keys())}") 185 | 186 | for preset_name in BLUR_PRESETS.keys(): 187 | result = apply_preset(img, preset_name) 188 | result.save(output_dir / f"preset_{preset_name}.jpg", quality=95) 189 | print(f"✅ Applied preset: {preset_name}") 190 | 191 | 192 | def demo_custom_masks(): 193 | """Demonstrate custom mask creation.""" 194 | print("\n🎨 Custom Masks Demo") 195 | print("-" * 50) 196 | 197 | img = create_sample_image() 198 | output_dir = Path("output/custom_masks") 199 | output_dir.mkdir(parents=True, exist_ok=True) 200 | 201 | width, height = img.size 202 | 203 | # Circular mask 204 | def circular_mask(x: int, y: int) -> float: 205 | center_x, center_y = width // 2, height // 2 206 | distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) 207 | max_distance = min(width, height) // 3 208 | return min(1.0, distance / max_distance) 209 | 210 | mask = create_custom_blur_mask(width, height, circular_mask) 211 | result = apply_mask_based_blur(img, mask, max_blur=40.0) 212 | result.save(output_dir / "circular_mask.jpg", quality=95) 213 | print("✅ Circular mask applied") 214 | 215 | # Diagonal gradient mask 216 | def diagonal_mask(x: int, y: int) -> float: 217 | return min(1.0, (x + y) / (width + height)) 218 | 219 | mask = create_custom_blur_mask(width, height, diagonal_mask) 220 | result = apply_mask_based_blur(img, mask, max_blur=35.0) 221 | result.save(output_dir / "diagonal_mask.jpg", quality=95) 222 | print("✅ Diagonal gradient mask applied") 223 | 224 | # Checkerboard pattern 225 | def checkerboard_mask(x: int, y: int) -> float: 226 | block_size = 50 227 | checker_x = (x // block_size) % 2 228 | checker_y = (y // block_size) % 2 229 | return 1.0 if (checker_x + checker_y) % 2 else 0.0 230 | 231 | mask = create_custom_blur_mask(width, height, checkerboard_mask) 232 | result = apply_mask_based_blur(img, mask, max_blur=30.0) 233 | result.save(output_dir / "checkerboard_mask.jpg", quality=95) 234 | print("✅ Checkerboard pattern mask applied") 235 | 236 | 237 | def demo_batch_processing(): 238 | """Demonstrate batch processing capabilities.""" 239 | print("\n🚀 Batch Processing Demo") 240 | print("-" * 50) 241 | 242 | # Create sample images 243 | input_dir = Path("output/batch_input") 244 | output_dir = Path("output/batch_output") 245 | input_dir.mkdir(parents=True, exist_ok=True) 246 | 247 | # Generate different sample images 248 | for i in range(3): 249 | img = create_sample_image((600 + i*100, 400 + i*50)) 250 | img.save(input_dir / f"sample_{i+1}.jpg", quality=95) 251 | 252 | print(f"Created {len(list(input_dir.glob('*.jpg')))} sample images") 253 | 254 | # Process batch with progress callback 255 | def progress_callback(current, total, input_path, output_path): 256 | print(f" [{current}/{total}] {input_path.name} -> {output_path.name}") 257 | 258 | start_time = time.time() 259 | processed_files = batch_process_images( 260 | input_dir, 261 | output_dir, 262 | preset="dramatic", 263 | overwrite=True, 264 | progress_callback=progress_callback 265 | ) 266 | processing_time = time.time() - start_time 267 | 268 | print(f"✅ Processed {len(processed_files)} images in {processing_time:.2f}s") 269 | 270 | 271 | def demo_performance_comparison(): 272 | """Demonstrate performance comparison between algorithms.""" 273 | print("\n📊 Performance Comparison") 274 | print("-" * 50) 275 | 276 | # Create a larger test image 277 | img = create_sample_image((1920, 1080)) 278 | 279 | algorithms = [ 280 | (BlurAlgorithm.BOX, "Box Blur"), 281 | (BlurAlgorithm.GAUSSIAN, "Gaussian Blur"), 282 | (BlurAlgorithm.MOTION, "Motion Blur"), 283 | ] 284 | 285 | print(f"Testing with {img.size[0]}x{img.size[1]} image:") 286 | 287 | for algorithm, name in algorithms: 288 | start_time = time.time() 289 | result = apply_progressive_blur( 290 | img, 291 | max_blur=30.0, 292 | algorithm=algorithm 293 | ) 294 | processing_time = time.time() - start_time 295 | print(f" {name}: {processing_time:.2f}s") 296 | 297 | 298 | def demo_web_optimization(): 299 | """Demonstrate web optimization features.""" 300 | print("\n🌐 Web Optimization Demo") 301 | print("-" * 50) 302 | 303 | # Create a large image 304 | img = create_sample_image((3000, 2000)) 305 | output_dir = Path("output/web_optimization") 306 | output_dir.mkdir(parents=True, exist_ok=True) 307 | 308 | print(f"Original size: {img.size}") 309 | 310 | # Apply blur and optimize for web 311 | blurred = apply_progressive_blur(img, max_blur=40.0) 312 | optimized = optimize_image_for_web(blurred, max_width=1920, max_height=1080) 313 | 314 | print(f"Optimized size: {optimized.size}") 315 | 316 | # Save with different quality settings 317 | qualities = [95, 85, 75] 318 | for quality in qualities: 319 | optimized.save( 320 | output_dir / f"optimized_q{quality}.jpg", 321 | quality=quality, 322 | optimize=True 323 | ) 324 | file_size = (output_dir / f"optimized_q{quality}.jpg").stat().st_size 325 | print(f" Quality {quality}: {file_size / 1024:.1f} KB") 326 | 327 | 328 | def demo_alpha_channel(): 329 | """Demonstrate alpha channel preservation.""" 330 | print("\n🔍 Alpha Channel Demo") 331 | print("-" * 50) 332 | 333 | # Create image with transparency 334 | img = Image.new('RGBA', (600, 400), color=(255, 255, 255, 0)) 335 | draw = ImageDraw.Draw(img) 336 | 337 | # Draw semi-transparent shapes 338 | draw.ellipse([100, 100, 300, 300], fill=(255, 0, 0, 128)) 339 | draw.rectangle([200, 50, 500, 350], fill=(0, 255, 0, 100)) 340 | 341 | output_dir = Path("output/alpha_channel") 342 | output_dir.mkdir(parents=True, exist_ok=True) 343 | 344 | # Save original 345 | img.save(output_dir / "original_alpha.png") 346 | 347 | # Apply blur with alpha preservation 348 | blurred_with_alpha = apply_progressive_blur(img, preserve_alpha=True) 349 | blurred_with_alpha.save(output_dir / "blurred_with_alpha.png") 350 | print("✅ Alpha channel preserved") 351 | 352 | # Apply blur without alpha preservation 353 | blurred_without_alpha = apply_progressive_blur(img, preserve_alpha=False) 354 | blurred_without_alpha.save(output_dir / "blurred_without_alpha.jpg", quality=95) 355 | print("✅ Alpha channel removed") 356 | 357 | 358 | def main(): 359 | """Run all demonstration examples.""" 360 | print("🎨 Progressive Blur - Advanced Examples") 361 | print("=" * 50) 362 | 363 | # Create output directory 364 | Path("output").mkdir(exist_ok=True) 365 | 366 | # Run all demos 367 | demo_basic_usage() 368 | demo_directions_and_algorithms() 369 | demo_easing_functions() 370 | demo_presets() 371 | demo_custom_masks() 372 | demo_batch_processing() 373 | demo_performance_comparison() 374 | demo_web_optimization() 375 | demo_alpha_channel() 376 | 377 | print("\n🎉 All examples completed!") 378 | print("Check the 'output' directory for results.") 379 | 380 | 381 | if __name__ == "__main__": 382 | main() -------------------------------------------------------------------------------- /progressive_blur/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command-line interface for progressive blur operations. 3 | """ 4 | 5 | import argparse 6 | import sys 7 | from pathlib import Path 8 | from typing import Optional 9 | 10 | from .core import BlurDirection, BlurAlgorithm, EasingFunction, apply_progressive_blur 11 | from .utils import ( 12 | batch_process_images, 13 | apply_preset, 14 | BLUR_PRESETS, 15 | get_image_info, 16 | is_supported_format, 17 | ) 18 | 19 | 20 | def create_parser() -> argparse.ArgumentParser: 21 | """Create the argument parser for the CLI.""" 22 | parser = argparse.ArgumentParser( 23 | prog="progressive-blur", 24 | description="Apply progressive blur effects to images", 25 | formatter_class=argparse.RawDescriptionHelpFormatter, 26 | epilog=""" 27 | Examples: 28 | # Apply default blur to a single image 29 | progressive-blur input.jpg output.jpg 30 | 31 | # Use a preset 32 | progressive-blur input.jpg output.jpg --preset dramatic 33 | 34 | # Custom blur parameters 35 | progressive-blur input.jpg output.jpg --max-blur 30 --direction left_to_right 36 | 37 | # Batch process a directory 38 | progressive-blur --batch input_dir/ output_dir/ 39 | 40 | # Get image information 41 | progressive-blur --info image.jpg 42 | """, 43 | ) 44 | 45 | # Main operation mode 46 | group = parser.add_mutually_exclusive_group(required=True) 47 | group.add_argument( 48 | "input", 49 | nargs="?", 50 | help="Input image file or directory (for batch processing)", 51 | ) 52 | group.add_argument( 53 | "--info", 54 | metavar="IMAGE", 55 | help="Show information about an image file", 56 | ) 57 | group.add_argument( 58 | "--list-presets", 59 | action="store_true", 60 | help="List available blur presets", 61 | ) 62 | 63 | # Output 64 | parser.add_argument( 65 | "output", 66 | nargs="?", 67 | help="Output image file or directory (for batch processing)", 68 | ) 69 | 70 | # Batch processing 71 | parser.add_argument( 72 | "--batch", 73 | action="store_true", 74 | help="Enable batch processing mode", 75 | ) 76 | parser.add_argument( 77 | "--recursive", 78 | action="store_true", 79 | help="Process subdirectories recursively (batch mode only)", 80 | ) 81 | parser.add_argument( 82 | "--overwrite", 83 | action="store_true", 84 | help="Overwrite existing output files", 85 | ) 86 | parser.add_argument( 87 | "--prefix", 88 | default="blurred_", 89 | help="Prefix for output filenames in batch mode (default: blurred_)", 90 | ) 91 | 92 | # Blur parameters 93 | parser.add_argument( 94 | "--preset", 95 | choices=list(BLUR_PRESETS.keys()), 96 | help="Use a predefined blur preset", 97 | ) 98 | parser.add_argument( 99 | "--max-blur", 100 | type=float, 101 | default=50.0, 102 | help="Maximum blur radius (default: 50.0)", 103 | ) 104 | parser.add_argument( 105 | "--clear-until", 106 | type=float, 107 | default=0.15, 108 | help="Percentage to keep completely clear (default: 0.15)", 109 | ) 110 | parser.add_argument( 111 | "--blur-start", 112 | type=float, 113 | default=0.25, 114 | help="Percentage where blur starts (default: 0.25)", 115 | ) 116 | parser.add_argument( 117 | "--end-position", 118 | type=float, 119 | default=0.85, 120 | help="Percentage where maximum blur is reached (default: 0.85)", 121 | ) 122 | parser.add_argument( 123 | "--direction", 124 | choices=[d.value for d in BlurDirection], 125 | default=BlurDirection.TOP_TO_BOTTOM.value, 126 | help="Direction of the blur effect (default: top_to_bottom)", 127 | ) 128 | parser.add_argument( 129 | "--algorithm", 130 | choices=[a.value for a in BlurAlgorithm], 131 | default=BlurAlgorithm.GAUSSIAN.value, 132 | help="Blur algorithm to use (default: gaussian)", 133 | ) 134 | parser.add_argument( 135 | "--easing", 136 | choices=[e.value for e in EasingFunction], 137 | default=EasingFunction.LINEAR.value, 138 | help="Easing function for blur transition (default: linear)", 139 | ) 140 | parser.add_argument( 141 | "--no-preserve-alpha", 142 | action="store_true", 143 | help="Don't preserve alpha channel", 144 | ) 145 | 146 | # Output options 147 | parser.add_argument( 148 | "--quality", 149 | type=int, 150 | default=95, 151 | help="JPEG/WebP quality (1-100, default: 95)", 152 | ) 153 | 154 | # Verbosity 155 | parser.add_argument( 156 | "--verbose", 157 | "-v", 158 | action="store_true", 159 | help="Enable verbose output", 160 | ) 161 | parser.add_argument( 162 | "--quiet", 163 | "-q", 164 | action="store_true", 165 | help="Suppress all output except errors", 166 | ) 167 | 168 | return parser 169 | 170 | 171 | def validate_args(args: argparse.Namespace) -> None: 172 | """Validate command-line arguments.""" 173 | if args.list_presets or args.info: 174 | return 175 | 176 | if not args.input: 177 | raise ValueError("Input file or directory is required") 178 | 179 | if not args.batch and not args.output: 180 | raise ValueError("Output file is required for single image processing") 181 | 182 | if args.batch and not args.output: 183 | raise ValueError("Output directory is required for batch processing") 184 | 185 | # Validate percentage values 186 | for param, value in [ 187 | ("clear-until", args.clear_until), 188 | ("blur-start", args.blur_start), 189 | ("end-position", args.end_position), 190 | ]: 191 | if not 0.0 <= value <= 1.0: 192 | raise ValueError(f"{param} must be between 0.0 and 1.0") 193 | 194 | # Validate parameter relationships 195 | if args.clear_until >= args.blur_start: 196 | raise ValueError("clear-until must be less than blur-start") 197 | if args.blur_start >= args.end_position: 198 | raise ValueError("blur-start must be less than end-position") 199 | 200 | # Validate quality 201 | if not 1 <= args.quality <= 100: 202 | raise ValueError("Quality must be between 1 and 100") 203 | 204 | # Validate max blur 205 | if args.max_blur <= 0: 206 | raise ValueError("max-blur must be positive") 207 | 208 | 209 | def progress_callback(current: int, total: int, input_path: Path, output_path: Path) -> None: 210 | """Progress callback for batch processing.""" 211 | print(f"[{current}/{total}] {input_path.name} -> {output_path.name}") 212 | 213 | 214 | def show_image_info(image_path: str) -> None: 215 | """Show information about an image file.""" 216 | try: 217 | info = get_image_info(image_path) 218 | print(f"Image Information: {info['filename']}") 219 | print(f" Format: {info['format']}") 220 | print(f" Mode: {info['mode']}") 221 | print(f" Size: {info['width']} x {info['height']} pixels") 222 | print(f" Has transparency: {info['has_transparency']}") 223 | print(f" File size: {info['file_size']:,} bytes") 224 | except Exception as e: 225 | print(f"Error reading image info: {e}", file=sys.stderr) 226 | sys.exit(1) 227 | 228 | 229 | def list_presets() -> None: 230 | """List available blur presets.""" 231 | print("Available blur presets:") 232 | print() 233 | for name, preset in BLUR_PRESETS.items(): 234 | print(f" {name}:") 235 | print(f" Name: {preset['name']}") 236 | print(f" Max blur: {preset['max_blur']}") 237 | print(f" Direction: {preset['direction'].value}") 238 | print(f" Algorithm: {preset['algorithm'].value}") 239 | print(f" Easing: {preset['easing'].value}") 240 | print() 241 | 242 | 243 | def process_single_image(args: argparse.Namespace) -> None: 244 | """Process a single image.""" 245 | input_path = Path(args.input) 246 | output_path = Path(args.output) 247 | 248 | if not input_path.exists(): 249 | print(f"Error: Input file not found: {input_path}", file=sys.stderr) 250 | sys.exit(1) 251 | 252 | if not is_supported_format(input_path): 253 | print(f"Error: Unsupported image format: {input_path.suffix}", file=sys.stderr) 254 | sys.exit(1) 255 | 256 | if not args.overwrite and output_path.exists(): 257 | print(f"Error: Output file already exists: {output_path}", file=sys.stderr) 258 | print("Use --overwrite to overwrite existing files") 259 | sys.exit(1) 260 | 261 | try: 262 | if args.verbose: 263 | print(f"Processing: {input_path}") 264 | 265 | if args.preset: 266 | # Use preset 267 | result = apply_preset( 268 | str(input_path), 269 | args.preset, 270 | preserve_alpha=not args.no_preserve_alpha, 271 | ) 272 | else: 273 | # Use custom parameters 274 | result = apply_progressive_blur( 275 | str(input_path), 276 | max_blur=args.max_blur, 277 | clear_until=args.clear_until, 278 | blur_start=args.blur_start, 279 | end_position=args.end_position, 280 | direction=args.direction, 281 | algorithm=args.algorithm, 282 | easing=args.easing, 283 | preserve_alpha=not args.no_preserve_alpha, 284 | ) 285 | 286 | # Save result 287 | save_kwargs = {} 288 | if output_path.suffix.lower() in ('.jpg', '.jpeg'): 289 | save_kwargs['quality'] = args.quality 290 | save_kwargs['optimize'] = True 291 | elif output_path.suffix.lower() == '.webp': 292 | save_kwargs['quality'] = args.quality 293 | save_kwargs['method'] = 6 294 | elif output_path.suffix.lower() == '.png': 295 | save_kwargs['optimize'] = True 296 | 297 | result.save(output_path, **save_kwargs) 298 | 299 | if not args.quiet: 300 | print(f"Saved: {output_path}") 301 | 302 | except Exception as e: 303 | print(f"Error processing image: {e}", file=sys.stderr) 304 | sys.exit(1) 305 | 306 | 307 | def process_batch(args: argparse.Namespace) -> None: 308 | """Process multiple images in batch mode.""" 309 | input_dir = Path(args.input) 310 | output_dir = Path(args.output) 311 | 312 | if not input_dir.exists(): 313 | print(f"Error: Input directory not found: {input_dir}", file=sys.stderr) 314 | sys.exit(1) 315 | 316 | if not input_dir.is_dir(): 317 | print(f"Error: Input path is not a directory: {input_dir}", file=sys.stderr) 318 | sys.exit(1) 319 | 320 | try: 321 | if args.verbose: 322 | print(f"Processing directory: {input_dir}") 323 | print(f"Output directory: {output_dir}") 324 | 325 | # Prepare parameters 326 | if args.preset: 327 | preset = BLUR_PRESETS[args.preset] 328 | kwargs = { 329 | 'max_blur': preset['max_blur'], 330 | 'clear_until': preset['clear_until'], 331 | 'blur_start': preset['blur_start'], 332 | 'end_position': preset['end_position'], 333 | 'direction': preset['direction'], 334 | 'algorithm': preset['algorithm'], 335 | 'easing': preset['easing'], 336 | } 337 | else: 338 | kwargs = { 339 | 'max_blur': args.max_blur, 340 | 'clear_until': args.clear_until, 341 | 'blur_start': args.blur_start, 342 | 'end_position': args.end_position, 343 | 'direction': args.direction, 344 | 'algorithm': args.algorithm, 345 | 'easing': args.easing, 346 | } 347 | 348 | # Process images 349 | processed_files = batch_process_images( 350 | input_dir, 351 | output_dir, 352 | preserve_alpha=not args.no_preserve_alpha, 353 | recursive=args.recursive, 354 | overwrite=args.overwrite, 355 | quality=args.quality, 356 | prefix=args.prefix, 357 | progress_callback=progress_callback if args.verbose else None, 358 | **kwargs, 359 | ) 360 | 361 | if not args.quiet: 362 | print(f"Processed {len(processed_files)} images") 363 | 364 | except Exception as e: 365 | print(f"Error in batch processing: {e}", file=sys.stderr) 366 | sys.exit(1) 367 | 368 | 369 | def main() -> None: 370 | """Main entry point for the CLI.""" 371 | parser = create_parser() 372 | args = parser.parse_args() 373 | 374 | try: 375 | # Handle special modes 376 | if args.list_presets: 377 | list_presets() 378 | return 379 | 380 | if args.info: 381 | show_image_info(args.info) 382 | return 383 | 384 | # Validate arguments 385 | validate_args(args) 386 | 387 | # Process images 388 | if args.batch: 389 | process_batch(args) 390 | else: 391 | process_single_image(args) 392 | 393 | except ValueError as e: 394 | print(f"Error: {e}", file=sys.stderr) 395 | sys.exit(1) 396 | except KeyboardInterrupt: 397 | print("\nOperation cancelled by user", file=sys.stderr) 398 | sys.exit(1) 399 | 400 | 401 | if __name__ == "__main__": 402 | main() -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for progressive blur core functionality. 3 | """ 4 | 5 | import math 6 | import tempfile 7 | from pathlib import Path 8 | from typing import Any 9 | 10 | import numpy as np 11 | import pytest 12 | from PIL import Image 13 | 14 | from progressive_blur.core import ( 15 | BlurDirection, 16 | BlurAlgorithm, 17 | EasingFunction, 18 | apply_progressive_blur, 19 | create_custom_blur_mask, 20 | apply_mask_based_blur, 21 | _validate_percentage, 22 | _validate_positive, 23 | _load_image, 24 | _apply_easing, 25 | _create_blur_mask, 26 | _calculate_blur_intensity, 27 | ) 28 | 29 | 30 | class TestValidationFunctions: 31 | """Test validation helper functions.""" 32 | 33 | def test_validate_percentage_valid(self): 34 | """Test percentage validation with valid values.""" 35 | _validate_percentage(0.0, "test") 36 | _validate_percentage(0.5, "test") 37 | _validate_percentage(1.0, "test") 38 | 39 | def test_validate_percentage_invalid(self): 40 | """Test percentage validation with invalid values.""" 41 | with pytest.raises(ValueError, match="must be between 0.0 and 1.0"): 42 | _validate_percentage(-0.1, "test") 43 | 44 | with pytest.raises(ValueError, match="must be between 0.0 and 1.0"): 45 | _validate_percentage(1.1, "test") 46 | 47 | def test_validate_positive_valid(self): 48 | """Test positive validation with valid values.""" 49 | _validate_positive(0.1, "test") 50 | _validate_positive(1.0, "test") 51 | _validate_positive(100.0, "test") 52 | 53 | def test_validate_positive_invalid(self): 54 | """Test positive validation with invalid values.""" 55 | with pytest.raises(ValueError, match="must be positive"): 56 | _validate_positive(0.0, "test") 57 | 58 | with pytest.raises(ValueError, match="must be positive"): 59 | _validate_positive(-1.0, "test") 60 | 61 | 62 | class TestImageLoading: 63 | """Test image loading functionality.""" 64 | 65 | def test_load_pil_image(self): 66 | """Test loading PIL Image objects.""" 67 | original = Image.new('RGB', (100, 100), color='red') 68 | loaded = _load_image(original) 69 | 70 | assert loaded.size == original.size 71 | assert loaded.mode == original.mode 72 | assert loaded is not original # Should be a copy 73 | 74 | def test_load_from_bytes(self): 75 | """Test loading images from bytes.""" 76 | # Create a test image and convert to bytes 77 | img = Image.new('RGB', (50, 50), color='blue') 78 | with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: 79 | img.save(tmp.name) 80 | with open(tmp.name, 'rb') as f: 81 | img_bytes = f.read() 82 | 83 | loaded = _load_image(img_bytes) 84 | assert loaded.size == (50, 50) 85 | assert loaded.mode == 'RGB' 86 | 87 | # Cleanup 88 | Path(tmp.name).unlink() 89 | 90 | def test_load_from_file_path(self): 91 | """Test loading images from file paths.""" 92 | img = Image.new('RGB', (75, 75), color='green') 93 | with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp: 94 | img.save(tmp.name) 95 | 96 | loaded = _load_image(tmp.name) 97 | assert loaded.size == (75, 75) 98 | 99 | # Cleanup 100 | Path(tmp.name).unlink() 101 | 102 | def test_load_invalid_type(self): 103 | """Test loading with invalid input type.""" 104 | with pytest.raises(TypeError, match="Unsupported image input type"): 105 | _load_image(123) 106 | 107 | 108 | class TestEasingFunctions: 109 | """Test easing function implementations.""" 110 | 111 | def test_linear_easing(self): 112 | """Test linear easing function.""" 113 | assert _apply_easing(0.0, EasingFunction.LINEAR) == 0.0 114 | assert _apply_easing(0.5, EasingFunction.LINEAR) == 0.5 115 | assert _apply_easing(1.0, EasingFunction.LINEAR) == 1.0 116 | 117 | def test_ease_in(self): 118 | """Test ease-in function.""" 119 | assert _apply_easing(0.0, EasingFunction.EASE_IN) == 0.0 120 | assert _apply_easing(1.0, EasingFunction.EASE_IN) == 1.0 121 | # Should be slower at the beginning 122 | assert _apply_easing(0.5, EasingFunction.EASE_IN) < 0.5 123 | 124 | def test_ease_out(self): 125 | """Test ease-out function.""" 126 | assert _apply_easing(0.0, EasingFunction.EASE_OUT) == 0.0 127 | assert _apply_easing(1.0, EasingFunction.EASE_OUT) == 1.0 128 | # Should be faster at the beginning 129 | assert _apply_easing(0.5, EasingFunction.EASE_OUT) > 0.5 130 | 131 | def test_ease_in_out(self): 132 | """Test ease-in-out function.""" 133 | assert _apply_easing(0.0, EasingFunction.EASE_IN_OUT) == 0.0 134 | assert _apply_easing(1.0, EasingFunction.EASE_IN_OUT) == 1.0 135 | assert _apply_easing(0.5, EasingFunction.EASE_IN_OUT) == 0.5 136 | 137 | def test_exponential_easing(self): 138 | """Test exponential easing function.""" 139 | assert _apply_easing(0.0, EasingFunction.EXPONENTIAL) == 0.0 140 | assert _apply_easing(1.0, EasingFunction.EXPONENTIAL) == 1.0 141 | # Should be much slower at the beginning 142 | assert _apply_easing(0.5, EasingFunction.EXPONENTIAL) < 0.25 143 | 144 | def test_sine_easing(self): 145 | """Test sine easing function.""" 146 | assert _apply_easing(0.0, EasingFunction.SINE) == 0.0 147 | assert abs(_apply_easing(1.0, EasingFunction.SINE) - 1.0) < 1e-10 148 | # Should be approximately sqrt(2)/2 at 0.5 149 | expected = math.sin(0.5 * math.pi / 2) 150 | assert abs(_apply_easing(0.5, EasingFunction.SINE) - expected) < 1e-10 151 | 152 | 153 | class TestBlurIntensityCalculation: 154 | """Test blur intensity calculation.""" 155 | 156 | def test_clear_region(self): 157 | """Test intensity in clear region.""" 158 | intensity = _calculate_blur_intensity( 159 | 0.1, 0.15, 0.25, 0.85, EasingFunction.LINEAR 160 | ) 161 | assert intensity == 0.0 162 | 163 | def test_transition_region(self): 164 | """Test intensity in transition region.""" 165 | intensity = _calculate_blur_intensity( 166 | 0.2, 0.15, 0.25, 0.85, EasingFunction.LINEAR 167 | ) 168 | assert 0.0 < intensity < 0.3 169 | 170 | def test_progressive_region(self): 171 | """Test intensity in progressive blur region.""" 172 | intensity = _calculate_blur_intensity( 173 | 0.5, 0.15, 0.25, 0.85, EasingFunction.LINEAR 174 | ) 175 | assert 0.3 <= intensity <= 1.0 176 | 177 | def test_max_blur_region(self): 178 | """Test intensity in maximum blur region.""" 179 | intensity = _calculate_blur_intensity( 180 | 0.9, 0.15, 0.25, 0.85, EasingFunction.LINEAR 181 | ) 182 | assert intensity == 1.0 183 | 184 | 185 | class TestBlurMaskCreation: 186 | """Test blur mask creation for different directions.""" 187 | 188 | def test_top_to_bottom_mask(self): 189 | """Test top-to-bottom blur mask.""" 190 | mask = _create_blur_mask( 191 | 100, 100, BlurDirection.TOP_TO_BOTTOM, 0.1, 0.2, 0.8, EasingFunction.LINEAR 192 | ) 193 | 194 | assert mask.shape == (100, 100) 195 | # Top should be clear 196 | assert np.all(mask[0, :] == 0.0) 197 | # Bottom should be blurred 198 | assert np.all(mask[-1, :] == 1.0) 199 | # Should be monotonically increasing 200 | assert mask[10, 0] <= mask[50, 0] <= mask[90, 0] 201 | 202 | def test_left_to_right_mask(self): 203 | """Test left-to-right blur mask.""" 204 | mask = _create_blur_mask( 205 | 100, 100, BlurDirection.LEFT_TO_RIGHT, 0.1, 0.2, 0.8, EasingFunction.LINEAR 206 | ) 207 | 208 | assert mask.shape == (100, 100) 209 | # Left should be clear 210 | assert np.all(mask[:, 0] == 0.0) 211 | # Right should be blurred 212 | assert np.all(mask[:, -1] == 1.0) 213 | 214 | def test_center_to_edges_mask(self): 215 | """Test center-to-edges blur mask.""" 216 | mask = _create_blur_mask( 217 | 100, 100, BlurDirection.CENTER_TO_EDGES, 0.0, 0.1, 0.8, EasingFunction.LINEAR 218 | ) 219 | 220 | assert mask.shape == (100, 100) 221 | # Center should be clearer than edges 222 | center_intensity = mask[50, 50] 223 | corner_intensity = mask[0, 0] 224 | assert center_intensity <= corner_intensity 225 | 226 | 227 | class TestProgressiveBlur: 228 | """Test the main progressive blur function.""" 229 | 230 | def test_basic_blur(self): 231 | """Test basic progressive blur functionality.""" 232 | img = Image.new('RGB', (100, 100), color='red') 233 | result = apply_progressive_blur(img) 234 | 235 | assert isinstance(result, Image.Image) 236 | assert result.size == img.size 237 | assert result.mode == img.mode 238 | 239 | def test_different_directions(self): 240 | """Test blur with different directions.""" 241 | img = Image.new('RGB', (50, 50), color='blue') 242 | 243 | for direction in BlurDirection: 244 | result = apply_progressive_blur(img, direction=direction) 245 | assert isinstance(result, Image.Image) 246 | assert result.size == img.size 247 | 248 | def test_different_algorithms(self): 249 | """Test blur with different algorithms.""" 250 | img = Image.new('RGB', (50, 50), color='green') 251 | 252 | for algorithm in BlurAlgorithm: 253 | result = apply_progressive_blur(img, algorithm=algorithm) 254 | assert isinstance(result, Image.Image) 255 | assert result.size == img.size 256 | 257 | def test_different_easing_functions(self): 258 | """Test blur with different easing functions.""" 259 | img = Image.new('RGB', (50, 50), color='yellow') 260 | 261 | for easing in EasingFunction: 262 | result = apply_progressive_blur(img, easing=easing) 263 | assert isinstance(result, Image.Image) 264 | assert result.size == img.size 265 | 266 | def test_alpha_preservation(self): 267 | """Test alpha channel preservation.""" 268 | img = Image.new('RGBA', (50, 50), color=(255, 0, 0, 128)) 269 | 270 | # With alpha preservation 271 | result_with_alpha = apply_progressive_blur(img, preserve_alpha=True) 272 | assert result_with_alpha.mode == 'RGBA' 273 | 274 | # Without alpha preservation 275 | result_without_alpha = apply_progressive_blur(img, preserve_alpha=False) 276 | assert result_without_alpha.mode == 'RGB' 277 | 278 | def test_parameter_validation(self): 279 | """Test parameter validation.""" 280 | img = Image.new('RGB', (50, 50), color='white') 281 | 282 | # Invalid max_blur 283 | with pytest.raises(ValueError, match="must be positive"): 284 | apply_progressive_blur(img, max_blur=0) 285 | 286 | # Invalid percentages 287 | with pytest.raises(ValueError, match="must be between 0.0 and 1.0"): 288 | apply_progressive_blur(img, clear_until=1.5) 289 | 290 | # Invalid parameter relationships 291 | with pytest.raises(ValueError, match="clear_until must be less than blur_start"): 292 | apply_progressive_blur(img, clear_until=0.5, blur_start=0.3) 293 | 294 | def test_string_enum_conversion(self): 295 | """Test conversion of string parameters to enums.""" 296 | img = Image.new('RGB', (50, 50), color='black') 297 | 298 | result = apply_progressive_blur( 299 | img, 300 | direction="left_to_right", 301 | algorithm="box", 302 | easing="ease_in" 303 | ) 304 | 305 | assert isinstance(result, Image.Image) 306 | assert result.size == img.size 307 | 308 | 309 | class TestCustomBlurMask: 310 | """Test custom blur mask creation.""" 311 | 312 | def test_custom_mask_creation(self): 313 | """Test creating custom blur masks.""" 314 | def mask_function(x: int, y: int) -> float: 315 | # Simple gradient from left to right 316 | return x / 100.0 317 | 318 | mask = create_custom_blur_mask(100, 50, mask_function) 319 | 320 | assert mask.shape == (50, 100) 321 | assert mask[0, 0] == 0.0 322 | assert mask[0, 99] == 0.99 323 | 324 | def test_mask_value_clamping(self): 325 | """Test that mask values are clamped to [0, 1].""" 326 | def mask_function(x: int, y: int) -> float: 327 | return x / 50.0 - 0.5 # Will produce values from -0.5 to 1.5 328 | 329 | mask = create_custom_blur_mask(100, 50, mask_function) 330 | 331 | assert np.all(mask >= 0.0) 332 | assert np.all(mask <= 1.0) 333 | 334 | 335 | class TestMaskBasedBlur: 336 | """Test mask-based blur functionality.""" 337 | 338 | def test_mask_based_blur_numpy(self): 339 | """Test mask-based blur with numpy array mask.""" 340 | img = Image.new('RGB', (50, 50), color='red') 341 | mask = np.ones((50, 50), dtype=np.float32) * 0.5 342 | 343 | result = apply_mask_based_blur(img, mask) 344 | 345 | assert isinstance(result, Image.Image) 346 | assert result.size == img.size 347 | 348 | def test_mask_based_blur_pil(self): 349 | """Test mask-based blur with PIL Image mask.""" 350 | img = Image.new('RGB', (50, 50), color='blue') 351 | mask_img = Image.new('L', (50, 50), color=128) # 50% gray 352 | 353 | result = apply_mask_based_blur(img, mask_img) 354 | 355 | assert isinstance(result, Image.Image) 356 | assert result.size == img.size 357 | 358 | def test_mask_dimension_mismatch(self): 359 | """Test error handling for mismatched mask dimensions.""" 360 | img = Image.new('RGB', (50, 50), color='green') 361 | mask = np.ones((30, 30), dtype=np.float32) 362 | 363 | with pytest.raises(ValueError, match="Mask dimensions.*don't match"): 364 | apply_mask_based_blur(img, mask) -------------------------------------------------------------------------------- /progressive_blur/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core progressive blur functionality with advanced algorithms and customization options. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import math 8 | from enum import Enum 9 | from io import BytesIO 10 | from typing import Any, Callable, Optional, Tuple, Union 11 | 12 | import numpy as np 13 | from PIL import Image, ImageFilter 14 | 15 | try: 16 | from typing import Literal 17 | except ImportError: 18 | from typing_extensions import Literal 19 | 20 | 21 | class BlurDirection(Enum): 22 | """Direction of the progressive blur effect.""" 23 | 24 | TOP_TO_BOTTOM = "top_to_bottom" 25 | BOTTOM_TO_TOP = "bottom_to_top" 26 | LEFT_TO_RIGHT = "left_to_right" 27 | RIGHT_TO_LEFT = "right_to_left" 28 | CENTER_TO_EDGES = "center_to_edges" 29 | EDGES_TO_CENTER = "edges_to_center" 30 | 31 | 32 | class BlurAlgorithm(Enum): 33 | """Available blur algorithms.""" 34 | 35 | GAUSSIAN = "gaussian" 36 | BOX = "box" 37 | MOTION = "motion" 38 | 39 | 40 | class EasingFunction(Enum): 41 | """Easing functions for blur transition.""" 42 | 43 | LINEAR = "linear" 44 | EASE_IN = "ease_in" 45 | EASE_OUT = "ease_out" 46 | EASE_IN_OUT = "ease_in_out" 47 | EXPONENTIAL = "exponential" 48 | SINE = "sine" 49 | 50 | 51 | ImageInput = Union[Image.Image, bytes, str] 52 | 53 | 54 | def _validate_percentage(value: float, name: str) -> None: 55 | """Validate that a value is a valid percentage (0.0 to 1.0).""" 56 | if not 0.0 <= value <= 1.0: 57 | raise ValueError(f"{name} must be between 0.0 and 1.0, got {value}") 58 | 59 | 60 | def _validate_positive(value: float, name: str) -> None: 61 | """Validate that a value is positive.""" 62 | if value <= 0: 63 | raise ValueError(f"{name} must be positive, got {value}") 64 | 65 | 66 | def _load_image(image_input: ImageInput) -> Image.Image: 67 | """Load an image from various input types.""" 68 | if isinstance(image_input, Image.Image): 69 | return image_input.copy() 70 | elif isinstance(image_input, bytes): 71 | return Image.open(BytesIO(image_input)) 72 | elif isinstance(image_input, str): 73 | return Image.open(image_input) 74 | else: 75 | raise TypeError( 76 | f"Unsupported image input type: {type(image_input)}. " 77 | "Expected PIL.Image, bytes, or file path string." 78 | ) 79 | 80 | 81 | def _apply_easing(progress: float, easing: EasingFunction) -> float: 82 | """Apply easing function to progress value.""" 83 | if easing == EasingFunction.LINEAR: 84 | return progress 85 | elif easing == EasingFunction.EASE_IN: 86 | return progress * progress 87 | elif easing == EasingFunction.EASE_OUT: 88 | return 1 - (1 - progress) * (1 - progress) 89 | elif easing == EasingFunction.EASE_IN_OUT: 90 | if progress < 0.5: 91 | return 2 * progress * progress 92 | else: 93 | return 1 - 2 * (1 - progress) * (1 - progress) 94 | elif easing == EasingFunction.EXPONENTIAL: 95 | return progress * progress * progress 96 | elif easing == EasingFunction.SINE: 97 | return math.sin(progress * math.pi / 2) 98 | else: 99 | return progress 100 | 101 | 102 | def _create_blur_mask( 103 | width: int, 104 | height: int, 105 | direction: BlurDirection, 106 | clear_until: float, 107 | blur_start: float, 108 | end_position: float, 109 | easing: EasingFunction, 110 | ) -> np.ndarray: 111 | """Create a blur intensity mask based on direction and parameters.""" 112 | mask = np.zeros((height, width), dtype=np.float32) 113 | 114 | if direction == BlurDirection.TOP_TO_BOTTOM: 115 | for y in range(height): 116 | y_percent = y / height 117 | intensity = _calculate_blur_intensity( 118 | y_percent, clear_until, blur_start, end_position, easing 119 | ) 120 | mask[y, :] = intensity 121 | 122 | elif direction == BlurDirection.BOTTOM_TO_TOP: 123 | for y in range(height): 124 | y_percent = 1.0 - (y / height) 125 | intensity = _calculate_blur_intensity( 126 | y_percent, clear_until, blur_start, end_position, easing 127 | ) 128 | mask[y, :] = intensity 129 | 130 | elif direction == BlurDirection.LEFT_TO_RIGHT: 131 | for x in range(width): 132 | x_percent = x / width 133 | intensity = _calculate_blur_intensity( 134 | x_percent, clear_until, blur_start, end_position, easing 135 | ) 136 | mask[:, x] = intensity 137 | 138 | elif direction == BlurDirection.RIGHT_TO_LEFT: 139 | for x in range(width): 140 | x_percent = 1.0 - (x / width) 141 | intensity = _calculate_blur_intensity( 142 | x_percent, clear_until, blur_start, end_position, easing 143 | ) 144 | mask[:, x] = intensity 145 | 146 | elif direction == BlurDirection.CENTER_TO_EDGES: 147 | center_x, center_y = width // 2, height // 2 148 | max_distance = math.sqrt(center_x**2 + center_y**2) 149 | 150 | for y in range(height): 151 | for x in range(width): 152 | distance = math.sqrt((x - center_x)**2 + (y - center_y)**2) 153 | distance_percent = distance / max_distance 154 | intensity = _calculate_blur_intensity( 155 | distance_percent, clear_until, blur_start, end_position, easing 156 | ) 157 | mask[y, x] = intensity 158 | 159 | elif direction == BlurDirection.EDGES_TO_CENTER: 160 | center_x, center_y = width // 2, height // 2 161 | max_distance = math.sqrt(center_x**2 + center_y**2) 162 | 163 | for y in range(height): 164 | for x in range(width): 165 | distance = math.sqrt((x - center_x)**2 + (y - center_y)**2) 166 | distance_percent = 1.0 - (distance / max_distance) 167 | intensity = _calculate_blur_intensity( 168 | distance_percent, clear_until, blur_start, end_position, easing 169 | ) 170 | mask[y, x] = intensity 171 | 172 | return mask 173 | 174 | 175 | def _calculate_blur_intensity( 176 | position: float, 177 | clear_until: float, 178 | blur_start: float, 179 | end_position: float, 180 | easing: EasingFunction, 181 | ) -> float: 182 | """Calculate blur intensity at a given position.""" 183 | if position < clear_until: 184 | return 0.0 185 | elif position < blur_start: 186 | # Smooth transition from clear to blur 187 | progress = (position - clear_until) / (blur_start - clear_until) 188 | eased_progress = _apply_easing(progress, easing) 189 | return 0.3 * eased_progress 190 | elif position > end_position: 191 | return 1.0 192 | else: 193 | # Progressive blur intensity 194 | progress = (position - blur_start) / (end_position - blur_start) 195 | eased_progress = _apply_easing(progress, easing) 196 | return 0.3 + (0.7 * eased_progress) 197 | 198 | 199 | def _apply_blur_algorithm( 200 | image: Image.Image, 201 | radius: float, 202 | algorithm: BlurAlgorithm 203 | ) -> Image.Image: 204 | """Apply the specified blur algorithm to an image.""" 205 | if algorithm == BlurAlgorithm.GAUSSIAN: 206 | return image.filter(ImageFilter.GaussianBlur(radius=radius)) 207 | elif algorithm == BlurAlgorithm.BOX: 208 | return image.filter(ImageFilter.BoxBlur(radius=radius)) 209 | elif algorithm == BlurAlgorithm.MOTION: 210 | # Motion blur is approximated using multiple directional blurs 211 | blurred = image 212 | for angle in [0, 45, 90, 135]: 213 | kernel_size = max(1, int(radius)) 214 | if kernel_size > 1: 215 | blurred = blurred.filter(ImageFilter.BoxBlur(radius=radius/4)) 216 | return blurred 217 | else: 218 | raise ValueError(f"Unsupported blur algorithm: {algorithm}") 219 | 220 | 221 | def apply_progressive_blur( 222 | image: ImageInput, 223 | max_blur: float = 50.0, 224 | clear_until: float = 0.15, 225 | blur_start: float = 0.25, 226 | end_position: float = 0.85, 227 | direction: Union[BlurDirection, str] = BlurDirection.TOP_TO_BOTTOM, 228 | algorithm: Union[BlurAlgorithm, str] = BlurAlgorithm.GAUSSIAN, 229 | easing: Union[EasingFunction, str] = EasingFunction.LINEAR, 230 | preserve_alpha: bool = True, 231 | ) -> Image.Image: 232 | """ 233 | Apply a progressive blur effect to an image with advanced customization options. 234 | 235 | Args: 236 | image: Input image (PIL.Image, bytes, or file path) 237 | max_blur: Maximum blur radius (default: 50.0) 238 | clear_until: Percentage to keep completely clear (default: 0.15) 239 | blur_start: Percentage where blur starts to appear (default: 0.25) 240 | end_position: Percentage where maximum blur is reached (default: 0.85) 241 | direction: Direction of the blur effect (default: TOP_TO_BOTTOM) 242 | algorithm: Blur algorithm to use (default: GAUSSIAN) 243 | easing: Easing function for blur transition (default: LINEAR) 244 | preserve_alpha: Whether to preserve alpha channel (default: True) 245 | 246 | Returns: 247 | PIL.Image: The processed image with progressive blur effect 248 | 249 | Raises: 250 | ValueError: If parameters are out of valid range 251 | TypeError: If image input type is not supported 252 | """ 253 | # Validate parameters 254 | _validate_positive(max_blur, "max_blur") 255 | _validate_percentage(clear_until, "clear_until") 256 | _validate_percentage(blur_start, "blur_start") 257 | _validate_percentage(end_position, "end_position") 258 | 259 | if clear_until >= blur_start: 260 | raise ValueError("clear_until must be less than blur_start") 261 | if blur_start >= end_position: 262 | raise ValueError("blur_start must be less than end_position") 263 | 264 | # Convert string enums to enum objects 265 | if isinstance(direction, str): 266 | direction = BlurDirection(direction) 267 | if isinstance(algorithm, str): 268 | algorithm = BlurAlgorithm(algorithm) 269 | if isinstance(easing, str): 270 | easing = EasingFunction(easing) 271 | 272 | # Load and prepare image 273 | img = _load_image(image) 274 | width, height = img.size 275 | 276 | # Handle alpha channel 277 | has_alpha = img.mode in ('RGBA', 'LA') 278 | alpha_channel = None 279 | if has_alpha and preserve_alpha: 280 | alpha_channel = img.split()[-1] 281 | img = img.convert('RGB') 282 | elif has_alpha and not preserve_alpha: 283 | img = img.convert('RGB') 284 | 285 | # Create blur mask 286 | blur_mask = _create_blur_mask( 287 | width, height, direction, clear_until, blur_start, end_position, easing 288 | ) 289 | 290 | # Create maximally blurred version 291 | blurred_img = _apply_blur_algorithm(img, max_blur, algorithm) 292 | 293 | # Apply progressive blur using the mask 294 | img_array = np.array(img, dtype=np.float32) 295 | blurred_array = np.array(blurred_img, dtype=np.float32) 296 | 297 | # Expand mask to match image channels 298 | if len(img_array.shape) == 3: 299 | blur_mask = np.expand_dims(blur_mask, axis=2) 300 | blur_mask = np.repeat(blur_mask, img_array.shape[2], axis=2) 301 | 302 | # Blend images based on mask 303 | result_array = img_array * (1 - blur_mask) + blurred_array * blur_mask 304 | result_array = np.clip(result_array, 0, 255).astype(np.uint8) 305 | 306 | # Convert back to PIL Image 307 | result = Image.fromarray(result_array) 308 | 309 | # Restore alpha channel if needed 310 | if has_alpha and preserve_alpha and alpha_channel is not None: 311 | result = result.convert('RGBA') 312 | result.putalpha(alpha_channel) 313 | 314 | return result 315 | 316 | 317 | def create_custom_blur_mask( 318 | width: int, 319 | height: int, 320 | mask_function: Callable[[int, int], float], 321 | ) -> np.ndarray: 322 | """ 323 | Create a custom blur mask using a user-defined function. 324 | 325 | Args: 326 | width: Image width 327 | height: Image height 328 | mask_function: Function that takes (x, y) coordinates and returns blur intensity (0.0-1.0) 329 | 330 | Returns: 331 | numpy.ndarray: Blur mask array 332 | """ 333 | mask = np.zeros((height, width), dtype=np.float32) 334 | 335 | for y in range(height): 336 | for x in range(width): 337 | intensity = mask_function(x, y) 338 | mask[y, x] = max(0.0, min(1.0, intensity)) 339 | 340 | return mask 341 | 342 | 343 | def apply_mask_based_blur( 344 | image: ImageInput, 345 | mask: Union[np.ndarray, Image.Image], 346 | max_blur: float = 50.0, 347 | algorithm: Union[BlurAlgorithm, str] = BlurAlgorithm.GAUSSIAN, 348 | ) -> Image.Image: 349 | """ 350 | Apply blur to an image using a custom mask. 351 | 352 | Args: 353 | image: Input image 354 | mask: Blur intensity mask (0.0-1.0 values) 355 | max_blur: Maximum blur radius 356 | algorithm: Blur algorithm to use 357 | 358 | Returns: 359 | PIL.Image: Blurred image 360 | """ 361 | img = _load_image(image) 362 | 363 | if isinstance(algorithm, str): 364 | algorithm = BlurAlgorithm(algorithm) 365 | 366 | # Convert mask to numpy array if needed 367 | if isinstance(mask, Image.Image): 368 | mask = np.array(mask.convert('L'), dtype=np.float32) / 255.0 369 | 370 | # Ensure mask dimensions match image 371 | if mask.shape[:2] != img.size[::-1]: 372 | raise ValueError( 373 | f"Mask dimensions {mask.shape[:2]} don't match image dimensions {img.size[::-1]}" 374 | ) 375 | 376 | # Create blurred version 377 | blurred_img = _apply_blur_algorithm(img, max_blur, algorithm) 378 | 379 | # Apply mask-based blending 380 | img_array = np.array(img, dtype=np.float32) 381 | blurred_array = np.array(blurred_img, dtype=np.float32) 382 | 383 | if len(img_array.shape) == 3: 384 | mask = np.expand_dims(mask, axis=2) 385 | mask = np.repeat(mask, img_array.shape[2], axis=2) 386 | 387 | result_array = img_array * (1 - mask) + blurred_array * mask 388 | result_array = np.clip(result_array, 0, 255).astype(np.uint8) 389 | 390 | return Image.fromarray(result_array) --------------------------------------------------------------------------------