├── 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 |

5 |
6 | [](https://badge.fury.io/py/progressive-blur)
7 | [](https://pypi.org/project/progressive-blur/)
8 | [](https://opensource.org/licenses/MIT)
9 | [](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 |
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)
--------------------------------------------------------------------------------