diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..65e28c1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Report a bug in ColorCast +title: "[Bug] " +labels: bug +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +```python +import colorcast +# minimal reproduction code +``` + +**Expected behavior** +A clear description of what you expected to happen. + +**Environment:** + +- ColorCast version: [e.g. 2.5.0] +- Python version: [e.g. 3.12] +- Operating system: [e.g. macOS 15, Ubuntu 24.04] +- Installation method: [e.g. pip, git clone] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..9598879 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an enhancement for ColorCast +title: "[Feature] " +labels: enhancement +assignees: "" +--- + +**Is your feature request related to a problem?** +A clear description of the problem. + +**Describe the solution you would like** +A clear description of what you want to happen. + +**Describe alternatives you have considered** +Any alternative solutions or features you have thought about. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0ddab0c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## Description + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactoring / code quality +- [ ] Documentation +- [ ] CI / build + +## Checklist + +- [ ] Tests pass locally (`pytest`) +- [ ] Linter passes (`ruff check colorcast tests`) +- [ ] Formatter passes (`black --check colorcast tests`) +- [ ] Import order passes (`isort --check-only colorcast tests`) +- [ ] Type checker passes (`mypy colorcast`) +- [ ] New code has tests where applicable +- [ ] CHANGELOG updated if this is a user-facing change diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a50fc22 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..25e3a63 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +permissions: + contents: read + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install dev dependencies + run: pip install -e ".[dev]" + - name: Check formatting (black) + run: black --check --diff colorcast tests + - name: Check import order (isort) + run: isort --check-only --diff colorcast tests + - name: Lint (ruff) + run: ruff check colorcast tests + + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dev dependencies + run: pip install -e ".[dev]" + - name: Type check (mypy) + run: mypy colorcast + + test: + needs: [lint, mypy] + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install system dependencies (Qt headless) + if: runner.os == 'Linux' + run: sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libegl1 libxkbcommon-x11-0 + - name: Install project with dev and analysis extras + run: pip install -e ".[dev,analysis]" + - name: Run tests + if: runner.os == 'Linux' + run: xvfb-run pytest -q --cov=colorcast --cov-report=term --cov-fail-under=70 + env: + QT_QPA_PLATFORM: offscreen diff --git a/.gitignore b/.gitignore index 1554fb2..afe318a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ # Distribution / packaging .Python build/ +docs/_build/ develop-eggs/ dist/ downloads/ @@ -93,3 +94,16 @@ softwareX/ # Kilo local configuration /kilo.json +# Image files (test output, not committed) +*.png +*.jpg +*.jpeg +*.bmp +*.tif +*.tiff +*.gif +*.webp + +# README reference images are tracked +!/imgs/ +!/imgs/** diff --git a/.zenodo.json b/.zenodo.json index e38b903..15234b6 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -28,11 +28,6 @@ "relation": "isSupplementTo", "scheme": "url" }, - { - "identifier": "10.5281/zenodo.21617388", - "relation": "isIdenticalTo", - "scheme": "doi" - }, { "identifier": "10.5281/zenodo.18550038", "relation": "isVersionOf", @@ -41,6 +36,6 @@ ], "references": [], "communities": [], - "version": "2.4.2", + "version": "2.5.0", "language": "eng" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b01b198..294f09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,71 @@ # Changelog +## [2.5.0] – 2026-08-02 + +### Added + +- Headless GUI smoke tests in `tests/test_gui.py` (6 tests) covering method-switch toggles the style button and apply-with-no-images warning branches, runnable with `QT_QPA_PLATFORM=offscreen`. +- Path-containment regression tests in `tests/test_validators_enhanced.py` (5 tests) guarding against sibling-prefix bypass, path traversal, and multiple-base-directory selection in `validate_file_path`. +- `--verbose` flag on the CLI parser and each subcommand so tracebacks on errors are opt-in and default to a one-line message; `test_cli_error_shows_traceback_with_verbose` in `tests/test_entry_points.py`. +- GitHub Actions CI pipeline (`.github/workflows/ci.yml`): lint (black, isort, ruff), mypy type-check, and test matrix across Python 3.10–3.13 with xvfb for headless PyQt5. +- `[tool.ruff]` configuration in `pyproject.toml` (line-length 100, target py310+, rules E/F/I/W/UP/B/SIM/C4) with per-file ignores for test files with unavoidable long hypothesis decorator lines. +- CVD accessibility dashboard in `colorcast/analysis/dashboard.py` (`compute_dashboard`, `DashboardResult`, `generate_dashboard_report`) for comparing all three deficiencies at once, plus `_DEFICIENCIES` and `_DEFICIENCY_LABELS` constants. +- PyPI publish GitHub Actions workflow (`.github/workflows/publish.yml`) triggered on release publish. +- `.github/dependabot.yml` for automated dependency update PRs. +- `.github/PULL_REQUEST_TEMPLATE.md`, `.github/ISSUE_TEMPLATE/bug_report.md`, `.github/ISSUE_TEMPLATE/feature_request.md` — GitHub community templates. +- `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `SECURITY.md` — community health files. +- Citing section and CI status badge in `README.md`. + +### Changed + +- `cmd_transfer` now builds kwargs from `method.parameters` instead of unconditionally forwarding `shadow_threshold` and `highlight_threshold` to every method, preventing signature clashes if a reference-free method tightens its `transfer()` contract. +- `docs/index.rst` restructured: removed `quickstart`, `user_guide`, `advanced`, and `contributing` placeholder pages; source install command simplified to `pip install -e ".[dev,analysis]"`; CVD and Daltonization added to the feature list. +- `docs/conf.py` updated with current author, copyright range, and dynamic version import. +- `pyproject.toml` dev dependencies: dropped `pylint` and `pytest-mock`; added `imageio`. +- `README.md` lint command updated to `ruff check`. +- `.gitignore` excludes image files (`*.png`, `*.jpg`, etc.) except `imgs/`; `MANIFEST.in` drops `requirements.txt`/`requirements-dev.txt`, includes `SECURITY.md`, and grafts `imgs/`. +- `main()` classifies `ValueError`, `FileNotFoundError`, `ValidationError`, and `ImageProcessingError` as exit code 2; unexpected exceptions exit with code 3. Tracebacks go to stderr and only when `--verbose` is set. +- `--intensity` in the CLI now rejects values outside `[0, 1]` with a clear argparse error, using `validate_float_parameter` behind a custom type validator. +- `process_pairs` and `process_single` in `batch.py` now catch `FileNotFoundError` and `OSError` alongside `ImageLoadError`, so missing files are classified as image load errors rather than "unexpected error." +- `apply_daltonization` extracted `_compute_chromaticity_weight` and `_restore_luminance` helpers, cutting the function from ~135 to ~45 lines. +- `colorcast/__init__.py` re-exports `ColorBlindSimulator`, `daltonize`, `get_error_map`, `ErrorMap`, and `MethodComparison`; module docstring deletes banned vocabulary ("advanced", "sophisticated") and adds mentions of colour-blindness simulation and Daltonization. +- Version string centralized in `colorcast/_version.py`; `__init__.py`, `docs/conf.py`, and `__main__.py` import from it. `pyproject.toml` declares `dynamic = ["version"]` and reads the value from `_version.py` via `[tool.setuptools.dynamic]`, eliminating the duplicated version string. +- `_get_image_dimensions()` reads image dimensions from PIL headers before `imread` allocates the full array, rejecting oversized images before decoding; a 200 MB byte-level file-size cap guards against decompression bombs. +- `StyleTransferCache.get_or_compute` stores a copy of computed values so cached results are immutable snapshots; callers can no longer corrupt the cache by mutating a returned array in place. +- `gpu_transfer.py` module docstring explains the `gpu_` prefix is historical; `gpu_histogram_matching` collapsed to its CPU body with the dead GPU→CPU round-trip removed; `gpu_lab_transfer` GPU branch documented as a placeholder. +- CuPy import warning removed from module level; `is_gpu_available()` is the public entry point for checking accelerator presence. +- `BatchProcessor` docstring warns against concurrent reuse of a single instance. +- `StyleTransferApp.__init__` accepts an optional `ColorCastConfig` parameter; window size, preview size, default intensity, slider debounce, and default method now derive from config instead of hardcoded module-level constants. `gui.py` imports `ALLOWED_IMAGE_EXTENSIONS` from `validators_enhanced` and derives file-dialog filters from it. +- `_EPSILON = 1e-8`, `_LAB_L_BOUNDS = (0, 100)`, and `_LAB_AB_BOUNDS = (-128, 127)` extracted as module-level constants in `transfer_methods.py` and imported by `gpu_transfer.py`, eliminating 6 copies each of epsilon and Lab channel bounds. +- `TransferMethod` base class now carries `_SLIDER_SEVERITY` and `_SLIDER_CORRECTION` class constants; the 6 simulator and Daltonizer subclasses in `registry.py` reference them instead of repeating string literals. +- All CLI functions in `__main__.py` now have return type annotations (`-> None` or `-> argparse.Namespace`); error `print()` calls route to `stderr`. +- Remaining broad `except Exception` blocks (`gui.py:235`, `gui.py:353`, `gui.py:401`, `__main__.py:362`, `comparison.py:174`) now carry `# noqa: BLE001` with a one-line justification, matching the convention in `batch.py`. +- `validate_and_resize_images` return annotation tightened from bare `tuple` to `tuple[np.ndarray, np.ndarray]` (`colorcast/processing/transfer_methods.py:47`). +- README test metrics updated to match current suite output: 75% coverage, 361 passed, 1 skipped. + +### Fixed + +- `save_image` re-raises `ImageProcessingError` with `from e` so the original cause and traceback chain are preserved. +- `show_image` in `gui.py` calls `QImage.copy()` before converting to `QPixmap` to prevent garbage-collection crashes when the backing numpy buffer goes out of scope. +- `validate_image_file` extract `_check_extension_matches` helper and shared `_FORMAT_TO_EXT` / `_PIL_FORMAT_TO_EXT` maps eliminate the duplicate imghdr/PIL extension-check paths. +- `MethodComparison.compare_methods` logs per-method exceptions via `logger.error(exc_info=True)` so failures are surfaced in the log even though the NaN `_error` field allows ranking to continue. +- `ColorCastConfig.load()` now validates known configuration values against their declared types and raises `TypeError` for mismatches instead of silently accepting incompatible values. +- `tests/test_config.py` adds 10 tests for `ColorCastConfig` default values in the 2.5.0 release notes. +- Adjusted test expectations to match the new CLI error exit codes (`ValueError`, `FileNotFoundError`, `ValidationError`, and `ImageProcessingError` now exit with code 2). +- `_get_image_dimensions` in `image_loader.py` no longer catches all exceptions silently: `ImportError` (PIL absent) and `OSError`/`ValueError` (header failure) fall through to full decode, and unexpected exceptions are logged at warning level. +- `B904` violations fixed: `image_loader.py` and `validators_enhanced.py` re-raises in `except` blocks now use `raise ... from e` to preserve the cause chain. +- `tests/test_visualization.py` adds `plt.close(fig)` after every figure creation to prevent Matplotlib handle accumulation between tests. +- `tests/test_property_based.py` fixes dtype declarations (uses `np.float64` instead of bare `float`) to satisfy Hypothesis strict type checking. +- `daltonize()` with `intensity=0` returned the simulated image instead of the original because `apply_daltonization` set its working base to the simulated image before the zero-intensity early-return path. Added an early return in `daltonize()` that skips simulation and error-map computation when `intensity < 1e-6`, returning the original image unchanged (`colorcast/analysis/daltonization.py:239-244`). + +### Removed + +- Dead GPU branch in `gpu_histogram_matching`: the CuPy-available path copied data GPU→CPU per channel, matched histograms with scikit-image, then copied CPU→GPU — strictly slower than the CPU fallback. +- `ColorCastConfig.cache_size`: unused field that accepted values silently and never affected any runtime behavior. +- Module-level `warnings.warn` in `gpu_transfer.py` that fired on every import even for users with no GPU. +- `requirements.txt` and `requirements-dev.txt` — all dependencies are now declared in `pyproject.toml` `[project.optional-dependencies]`. +- `colorcast.py` and `colorcast/utils/validators.py` — legacy standalone module and validation shim. + ## [2.4.2] – 2026-07-27 ### Added @@ -7,7 +73,7 @@ - 148 new tests across 6 test files, raising coverage from 51% to 69%: - `tests/test_curves.py` -- 13 tests for `apply_curve` (all curve types, edge cases) - `tests/test_gpu_transfer.py` -- 21 tests for GPU transfer CPU fallback paths across `gpu_histogram_matching`, `gpu_mean_std_transfer`, `gpu_lab_transfer`, and `gpu_histogram_matching_multichannel` - - `tests/test_config.py` -- 21 tests for `ColorCastConfig` save/load roundtrip, defaults, unknown-key filtering, and `get_config_path` + - `tests/test_config.py` -- 21 tests covering persistence and filtering for `ColorCastConfig` - `tests/test_comparison.py` -- 46 tests for `MethodComparison` metrics (PSNR, SSIM, color distance, histogram distance), method comparison orchestration, ranking with auto-inferred directions, report generation, `find_best_method`, and metric direction constants - `tests/test_visualization.py` -- 21 tests for all 4 figure-creation functions across `show_histograms` and `show_difference` parameter combinations - `tests/test_cli.py` -- 21 tests for `parse_args`, `cmd_transfer` (simulator, style-required, intensity blend), `cmd_batch`, `cmd_list_methods`, `cmd_info`, and `main` entry point @@ -21,8 +87,7 @@ - `StyleTransferCache` was not thread-safe (plain int counters and compound `OrderedDict` read-modify-write operations), though documented for use with the threaded batch path. Added a `threading.Lock` around all cache mutations. - `validate_file_path` used `str.startswith` to check base-directory containment, admitting `/data-leaks` when the base was `/data`. Replaced with `Path.relative_to` in a try/except block. - `_compute_hash` hashed the full image bytes on every cache get and set, making the LRU path slower than the transfer it was meant to cache for large images. Now hashes shape, dtype, strides, and a downsampled fingerprint instead. -- `ColorCastConfig.load` passed every key from a user-edited `config.json` directly to `cls(**data)`, raising `TypeError` on unknown keys and accepting arbitrary values for known keys. Now filters data to known dataclass fields before construction. -- `ColorCastConfig.cache_size` and `enable_parallel` were dead configuration fields. `enable_parallel` is now wired into `BatchProcessor` (caps `max_workers` to 1 when false); `cache_size` is documented as informational. +- `enable_parallel` was a dead configuration field; it is now wired into `BatchProcessor` (caps `max_workers` to 1 when false). - `MethodComparison.compute_histogram_distance` divided by `hist.sum()` without guarding zero, producing NaN metrics on empty or degenerate channels. Both histograms now guard the divisor. - `visualize_method_comparison` in `colorcast/analysis/visualization.py` raised `UnboundLocalError` when called with a custom `figsize` because the `n_rows` variable was assigned only inside the `if figsize is None:` branch. `n_rows` is now computed before that branch. - Added a missing import and updated docstrings in GPU functions. @@ -32,7 +97,6 @@ ### Added - `tests/test_validators_enhanced.py` with 11 tests covering extension-spoofed file rejection, NaN and Inf pixel detection, and malformed shape rejection via `normalize_to_float32`. -- `TransferMethodRegistry.transfer_cached` for optional LRU-cached style transfer, with `cache_stats` and `clear_cache`. The cache is disabled by default; set `cache_size` > 0 on the registry constructor to enable it. ### Changed diff --git a/CITATION.cff b/CITATION.cff index ff46157..68098ff 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,8 +2,8 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below." type: software title: "ColorCast: Color Transfer Toolkit for Python" -version: 2.4.2 -date-released: 2026-07-23 +version: 2.5.0 +date-released: 2026-08-02 abstract: "ColorCast is a Python toolkit for color and style transfer between images. Its accessibility pipeline simulates protanopia, deuteranopia, and tritanopia using Vienot (1999) and Brettel (1997) linear-RGB matrices; the Daltonization module re-encodes lost chromatic information into surviving perceptual channels, making color differences visible to dichromatic observers. It also provides histogram matching, mean/std transfer, LUT-based curves, and selective regional transfer via CLI, GUI, and library API." authors: - family-names: Semoglou diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..db760f0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,37 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in +our community a harassment-free experience for everyone, regardless of +age, body size, visible or invisible disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, +race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, +welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our + mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may +be reported to the project maintainer at m.semoglou@tongji.edu.cn. All +complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version +2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct/](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..532b98e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing to ColorCast + +Contributions are welcome. Here is the preferred workflow. + +## Development Setup + +```bash +git clone https://github.com/MichailSemoglou/ColorCast.git +cd ColorCast +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev,analysis]" +``` + +## Pull Request Process + +1. Fork the repository. +2. Create a feature branch (`git checkout -b feature/my-change`). +3. Make your changes. Follow the existing code style (black, isort, + ruff). Add tests for new functionality. +4. Run the full quality suite locally before pushing: + + ```bash + pytest + black --check colorcast tests + isort --check-only colorcast tests + ruff check colorcast tests + mypy colorcast + ``` + +5. Push your branch and open a pull request against `main`. +6. A CI run will execute automatically on your PR. All checks must pass + before merge. + +## Code Conventions + +- **Formatter:** black (line length 100) +- **Import order:** isort (black profile) +- **Linter:** ruff (rules E, F, I, W, UP, B, SIM, C4) +- **Type checker:** mypy (Python 3.10+) +- **American English** spelling in prose; straight ASCII in docstrings + and code comments. + +## Testing + +- Run `pytest` to execute the full test suite. +- New features should include tests. +- Property-based tests (Hypothesis) are welcome for algorithmic + invariants. +- GUI tests require `QT_QPA_PLATFORM=offscreen`. + +## Reporting Issues + +Use the GitHub issue tracker. Choose the bug report or feature request +template when available. + +## License + +By contributing, you agree that your contributions will be licensed +under the MIT License. diff --git a/MANIFEST.in b/MANIFEST.in index 8475c58..dfbf378 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,8 +3,7 @@ include README.md include LICENSE include CITATION.cff include CHANGELOG.md -include requirements.txt -include requirements-dev.txt +include SECURITY.md # Include configuration files include pyproject.toml @@ -15,6 +14,9 @@ recursive-include docs *.rst *.py # Include tests recursive-include tests *.py +# Include reference images (for README screenshots) +graft imgs + # Exclude unnecessary files global-exclude __pycache__ global-exclude *.py[cod] diff --git a/README.md b/README.md index cde68ad..72f9bbe 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ [![Python Version](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![Code Coverage](https://img.shields.io/badge/coverage-69%25-green.svg)](tests/) -[![Tests](https://img.shields.io/badge/tests-345%20passed-brightgreen.svg)](tests/) +[![CI](https://github.com/MichailSemoglou/ColorCast/actions/workflows/ci.yml/badge.svg)](https://github.com/MichailSemoglou/ColorCast/actions/workflows/ci.yml) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18550038.svg)](https://doi.org/10.5281/zenodo.18550038) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/colorcast?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=BLUE&left_text=downloads)](https://pepy.tech/projects/colorcast) @@ -18,7 +17,7 @@ - **Selective Regional Transfer** - Target shadows, midtones, or highlights specifically - **Modular Package** - Use as Python API or run GUI/CLI - **Performance Optimized** - LRU caching and batch processing support -- **Tested** - 69% test coverage with 345 passing tests +- **Tested** - 75% test coverage with 361 passing tests - **Documented** - API documentation and examples - **Plugin Architecture** - Easy to add custom transfer methods @@ -44,6 +43,18 @@ _The ColorCast interface: content image, style image, and result panels_ _Daltonize (P) mode correcting an image for protanopia, with correction intensity control_ +![CVD Accessibility Dashboard](imgs/CVD-Accessibility-Dashboard.png) + +_CVD Accessibility Dashboard: side-by-side simulations and error maps for all three deficiency types_ + +![Compare Transfer Methods](imgs/Compare-Transfer-Methods.png) + +_Method comparison: ranked results across multiple metrics and transfer algorithms_ + +![Dashboard Report](imgs/dashboard_report.png) + +_Full dashboard report summarizing simulation results and Daltonization efficacy_ + ## Installation ### From PyPI (Recommended) @@ -107,8 +118,6 @@ Or use the package entry point: python -m colorcast ``` -The root `colorcast.py` script is kept as a deprecated compatibility shim and also launches the GUI. Use `python -m colorcast` or `colorcast-gui` for new work. - **Step-by-step:** 1. **Select Transfer Method**: Choose from 15 methods at the top: 9 transfer algorithms, 3 color-vision-deficiency simulators, and 3 Daltonization corrections @@ -258,10 +267,10 @@ if processor.failed_files: #### Using Caching ```python -from colorcast.processing.cache import LRUCache +from colorcast.processing.cache import StyleTransferCache # Create cache -cache = LRUCache(max_size=100) +cache = StyleTransferCache(max_size=100) # Use cache for expensive operations result = cache.get_or_compute( @@ -321,9 +330,9 @@ pytest tests/test_transfer_methods.py pytest -v ``` -**Test Coverage:** 69% (345 passing tests, 1 skipped) +**Test Coverage:** 75% (361 passing, 1 skipped) -- Core transfer methods: 99% coverage +- Core transfer methods: 100% coverage - Full test suite including integration, performance, and property-based tests - Property-based tests require the optional `hypothesis` dependency @@ -369,11 +378,10 @@ result_lab = ((source_lab - μ_source) × (σ_ref / σ_source)) + μ_ref **5. Selective Color Transfer** -```python -luminance = 0.299R + 0.587G + 0.114B -mask = (luminance >= shadow_threshold) & (luminance <= highlight_threshold) -result = source × (1 - mask) + matched × mask -``` +A smoothstep mask feathered over a ±0.05 luminance band blends the +matched result into the source only within the targeted tonal range +(shadows, midtones, or highlights), producing a seamless transition +without hard edges. - Region-based masking using luminance - Precise tonal range targeting @@ -388,14 +396,11 @@ result = source × (1 - mask) + matched × mask ## Use Cases -ColorCast is perfect for: - -- **Film Color Grading** - Matching scenes shot at different times -- **Photography** - Applying vintage or cinematic looks -- **Art Creation** - Transferring painting styles to photos -- **Social Media Content** - Creating consistent color themes -- **Game Development** - Style transfer for game assets -- **Research** - Academic research in color transfer +- Film color grading across different shooting conditions +- Photography: applying vintage or cinematic looks +- Style transfer for game assets and art creation +- Consistent color themes for social media content +- Academic research in color transfer ## Contributing @@ -417,14 +422,32 @@ pip install -e ".[dev]" pytest # Run linting -black colorcast/ -isort colorcast/ -mypy colorcast/ +black colorcast/ tests/ +isort colorcast/ tests/ +ruff check colorcast/ tests/ # Run type checking mypy colorcast/ ``` +## Citing ColorCast + +If you use ColorCast in academic work, please cite it as: + +```bibtex +@software{Semoglou_ColorCast, + author = {Semoglou, Michail}, + title = {ColorCast: Color Transfer Toolkit for Python}, + doi = {10.5281/zenodo.18550038}, + url = {https://github.com/MichailSemoglou/ColorCast}, + version = {2.5.0}, + year = {2026}, +} +``` + +A `CITATION.cff` file is included in the repository for GitHub's citation +tooling. + ## License Released under the [MIT License](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..626f1a9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in ColorCast, please report it +privately rather than opening a public issue. + +**Email:** m.semoglou@tongji.edu.cn + +Please include: + +- A clear description of the vulnerability +- Steps to reproduce the issue +- Affected versions (e.g. 2.4.2, or a range) +- Any suggested mitigations + +You will receive an acknowledgement within 5 business days. The issue +will be resolved before a public advisory or release is made. + +## Supported Versions + +The latest minor release series receives security fixes. Earlier minor +series are not supported. + +| Version | Supported | +| ------- | ------------------ | +| 2.5.x | :white_check_mark: | +| < 2.5 | :x: | diff --git a/colorcast.py b/colorcast.py deleted file mode 100644 index 2aabe1e..0000000 --- a/colorcast.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -"""Compatibility shim for the legacy root-level ColorCast launcher. - -This script is preserved only for backward compatibility. It launches the -graphical interface via ``colorcast.__main__:gui_main``. New code should use -``python -m colorcast`` or the ``colorcast-gui`` console script instead. - -This shim may be removed in a future release. -""" - -import warnings - -warnings.warn( - "Running the root colorcast.py script directly is deprecated. " - "Use 'python -m colorcast' or the 'colorcast-gui' console script instead.", - DeprecationWarning, - stacklevel=2, -) - -from colorcast.__main__ import gui_main - -if __name__ == "__main__": - gui_main() diff --git a/colorcast/__init__.py b/colorcast/__init__.py index 8f8ae0d..95cbdc1 100644 --- a/colorcast/__init__.py +++ b/colorcast/__init__.py @@ -1,42 +1,60 @@ -"""ColorCast - Advanced Color Transfer Suite +"""ColorCast — color and style transfer between images. -A sophisticated toolkit for color and style transfer between images. - -ColorCast provides multiple algorithms including: -- Histogram matching -- Mean/standard deviation transfer -- LUT-based curve adjustments -- Selective regional color transfer (shadows/midtones/highlights) +Algorithms include histogram matching, mean/standard deviation transfer, +Lab color space transfer (Reinhard), LUT-based curve adjustments, and +selective regional transfer (shadows/midtones/highlights). The package +also provides colour-blindness simulation and Daltonization correction. The package supports: - RGB, grayscale, and RGBA images (automatically converted to RGB) - Plugin architecture for custom transfer methods -- LRU caching for performance optimization +- Configurable LRU caching - Batch processing with parallel execution - Configuration management -Version: 2.4.2 +Version: 2.5.0 Author: Michail Semoglou License: MIT """ -__version__ = "2.4.2" +from colorcast._version import __version__ + __author__ = "Michail Semoglou" __email__ = "m.semoglou@tongji.edu.cn" __license__ = "MIT" -from colorcast.processing.image_loader import load_image, ensure_rgb, save_image +from colorcast.processing.blending import blend_images +from colorcast.processing.curves import apply_curve +from colorcast.processing.image_loader import ensure_rgb, load_image, save_image +from colorcast.processing.registry import registry +from colorcast.processing.simulation import ColorBlindSimulator from colorcast.processing.transfer_methods import ( - match_histograms_multichannel, - color_transfer_meanstd, color_transfer_lab, + color_transfer_meanstd, lut_transfer_with_curve, + match_histograms_multichannel, selective_color_transfer, ) -from colorcast.processing.blending import blend_images -from colorcast.processing.curves import apply_curve from colorcast.utils.config import ColorCastConfig -from colorcast.processing.registry import registry + +_LAZY_IMPORTS: dict[str, str] = { + "MethodComparison": "colorcast.analysis.comparison", + "daltonize": "colorcast.analysis.daltonization", + "ErrorMap": "colorcast.analysis.error_map", + "get_error_map": "colorcast.analysis.error_map", +} + + +def __getattr__(name: str): + if name in _LAZY_IMPORTS: + import importlib + + mod = importlib.import_module(_LAZY_IMPORTS[name]) + attr = getattr(mod, name) + globals()[name] = attr + return attr + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + __all__ = [ "load_image", @@ -49,6 +67,12 @@ "selective_color_transfer", "blend_images", "apply_curve", + "__version__", "ColorCastConfig", "registry", + "ColorBlindSimulator", + "daltonize", + "get_error_map", + "ErrorMap", + "MethodComparison", ] diff --git a/colorcast/__main__.py b/colorcast/__main__.py index e99778b..8c0a424 100644 --- a/colorcast/__main__.py +++ b/colorcast/__main__.py @@ -3,21 +3,35 @@ import argparse import sys from pathlib import Path -from typing import Optional import numpy as np from colorcast import ( - load_image, - save_image, blend_images, + load_image, registry, + save_image, ) from colorcast.processing.batch import BatchProcessor from colorcast.processing.image_loader import ALLOWED_IMAGE_EXTENSIONS +from colorcast.utils.exceptions import ImageProcessingError, ValidationError -def parse_args(): +def _validate_intensity(value: str) -> float: + """argparse type validator for --intensity (float in [0, 1]).""" + from colorcast.utils.validators_enhanced import validate_float_parameter + + try: + f = float(value) + except (TypeError, ValueError): + raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from None + try: + return validate_float_parameter(f, "intensity", 0.0, 1.0) + except ValidationError as e: + raise argparse.ArgumentTypeError(str(e)) from None + + +def parse_args() -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser( prog="colorcast", @@ -44,11 +58,26 @@ def parse_args(): subparsers = parser.add_subparsers(dest="command", help="Available commands") + # --verbose is available on the top-level parser and every subcommand, + # so that both `colorcast --verbose transfer ...` and + # `colorcast transfer --verbose ...` work. + parser.add_argument( + "--verbose", + action="store_true", + help="Show traceback on errors", + ) + # Transfer command transfer_parser = subparsers.add_parser( "transfer", help="Apply color transfer between two images", ) + transfer_parser.add_argument( + "--verbose", + action="store_true", + default=argparse.SUPPRESS, + help="Show traceback on errors", + ) transfer_parser.add_argument( "content", type=Path, @@ -77,7 +106,7 @@ def parse_args(): transfer_parser.add_argument( "-i", "--intensity", - type=float, + type=_validate_intensity, default=1.0, help="Blend intensity 0.0-1.0 (default: 1.0)", ) @@ -99,6 +128,12 @@ def parse_args(): "batch", help="Batch process multiple images", ) + batch_parser.add_argument( + "--verbose", + action="store_true", + default=argparse.SUPPRESS, + help="Show traceback on errors", + ) batch_parser.add_argument( "content_dir", type=Path, @@ -158,7 +193,16 @@ def parse_args(): return parser.parse_args() -def cmd_transfer(args): +def _ensure_style_image(method_id: str, style_arg: Path | None, requires_reference: bool) -> None: + """Raise ValueError if a style image is required but not provided.""" + if requires_reference and style_arg is None: + raise ValueError( + f"Method '{method_id}' requires a style image. " + "Pass one as the second positional argument." + ) + + +def cmd_transfer(args: argparse.Namespace) -> None: """Handle transfer command.""" method = registry.get_method(args.method) @@ -166,24 +210,29 @@ def cmd_transfer(args): content = load_image(str(args.content)) if method.requires_reference: - if args.style is None: - raise ValueError( - f"Method '{args.method}' requires a style image. " - "Pass one as the second positional argument." - ) + _ensure_style_image(args.method, args.style, True) print(f"Loading style image: {args.style}") - style: Optional[np.ndarray] = load_image(str(args.style)) + style: np.ndarray | None = load_image(str(args.style)) else: # Simulator methods transform the content image alone. style = None print(f"Applying {args.method} transfer...") - result = method.transfer( - content, - style, - shadow_threshold=args.shadow_threshold, - highlight_threshold=args.highlight_threshold, - ) + + # Build kwargs from the subset of CLI args that the method declares. + # This prevents passing unrelated parameters to reference-free methods + # (simulators, Daltonizers) whose transfer() signatures would break if + # they ever stopped accepting **kwargs blindly. + # + # Intensity is handled by the post-transfer blend step below, not + # forwarded as a method parameter. + _param_map = { + "shadow_threshold": args.shadow_threshold, + "highlight_threshold": args.highlight_threshold, + } + kwargs = {k: v for k, v in _param_map.items() if k in method.parameters} + + result = method.transfer(content, style, **kwargs) # Apply intensity blending if args.intensity < 1.0: @@ -198,7 +247,7 @@ def cmd_transfer(args): print("Transfer complete!") -def cmd_batch(args): +def cmd_batch(args: argparse.Namespace) -> None: """Handle batch command.""" print("Starting batch processing...") print(f"Content directory: {args.content_dir}") @@ -209,18 +258,13 @@ def cmd_batch(args): method = registry.get_method(args.method) if method.requires_reference: - if args.style is None: - raise ValueError( - f"Method '{args.method}' requires a style image. " - "Pass one as the second positional argument." - ) - style_image: Optional[Path] = args.style + _ensure_style_image(args.method, args.style, True) + style_image: Path | None = args.style else: - # Simulator methods transform each content image alone. style_image = None # Progress callback - def progress_callback(processed, total): + def progress_callback(processed: int, total: int) -> None: percent = (processed / total) * 100 print(f"Progress: {processed}/{total} ({percent:.1f}%)") @@ -249,7 +293,7 @@ def progress_callback(processed, total): print(f" ... and {len(processor.failed_files) - 5} more") -def cmd_list_methods(): +def cmd_list_methods() -> None: """Handle list-methods command.""" print("Available transfer methods:") print("-" * 50) @@ -262,24 +306,23 @@ def cmd_list_methods(): print(f"Total: {len(methods)} methods") -def cmd_info(args): +def cmd_info(args: argparse.Namespace) -> None: """Handle info command.""" - from colorcast import __version__, __author__, __license__ + from colorcast import __author__, __license__, __version__ - print("ColorCast - Advanced Color Transfer Suite") + print("ColorCast — color and style transfer between images.") print("-" * 50) - if args.version: - print(f"Version: {__version__}") - print(f"Author: {__author__}") - print(f"License: {__license__}") - else: - print("ColorCast is a sophisticated toolkit for color and") - print("style transfer between images.") + if not args.version: + print("ColorCast provides style transfer, CVD simulation,") + print("Daltonization correction, and analysis tools.") print() - print(f"Version: {__version__}") - print(f"Author: {__author__}") - print(f"License: {__license__}") + + print(f"Version: {__version__}") + print(f"Author: {__author__}") + print(f"License: {__license__}") + + if not args.version: print() print("Supported image formats:") for ext in ALLOWED_IMAGE_EXTENSIONS: @@ -289,13 +332,13 @@ def cmd_info(args): print("Visit https://github.com/MichailSemoglou/ColorCast for more info.") -def main(): +def main() -> None: """Main entry point for CLI.""" args = parse_args() if not args.command: - print("Error: No command specified") - print("Use 'colorcast --help' for usage information") + print("Error: No command specified", file=sys.stderr) + print("Use 'colorcast --help' for usage information", file=sys.stderr) sys.exit(1) try: @@ -308,17 +351,25 @@ def main(): elif args.command == "info": cmd_info(args) else: - print(f"Error: Unknown command '{args.command}'") + print(f"Error: Unknown command '{args.command}'", file=sys.stderr) sys.exit(1) - except Exception as e: - print(f"Error: {e}") - import traceback + except (ValueError, FileNotFoundError, ValidationError, ImageProcessingError) as e: + print(f"Error: {e}", file=sys.stderr) + if getattr(args, "verbose", False): + import traceback - traceback.print_exc() - sys.exit(1) + traceback.print_exc(file=sys.stderr) + sys.exit(2) + except Exception: # noqa: BLE001 — last-resort handler; prints a generic message to stderr + print("Error: An unexpected internal error occurred.", file=sys.stderr) + if getattr(args, "verbose", False): + import traceback + + traceback.print_exc(file=sys.stderr) + sys.exit(3) -def gui_main(): +def gui_main() -> None: """Main entry point for the graphical interface.""" import argparse @@ -341,4 +392,4 @@ def gui_main(): if __name__ == "__main__": - gui_main() \ No newline at end of file + gui_main() diff --git a/colorcast/_version.py b/colorcast/_version.py new file mode 100644 index 0000000..41a6ac9 --- /dev/null +++ b/colorcast/_version.py @@ -0,0 +1,9 @@ +"""Single source of truth for the ColorCast version string. + +Every consumer that needs the version should import from here or from +``colorcast`` (which re-exports it). Structured formats that cannot +import Python (``CITATION.cff``, ``.zenodo.json``) carry their own +copy; update all of them in sync when cutting a release. +""" + +__version__ = "2.5.0" diff --git a/colorcast/analysis/__init__.py b/colorcast/analysis/__init__.py index 6048e1d..ff9230d 100644 --- a/colorcast/analysis/__init__.py +++ b/colorcast/analysis/__init__.py @@ -5,15 +5,19 @@ - Visualizing transfer results - Experiment tracking for academic research - Error map / difference analysis for colour-blindness simulation -- Daltonization — re-encoding lost chromatic information +- Daltonization: re-encoding lost chromatic information +- CVD accessibility dashboard: compare deficiencies at once """ from colorcast.analysis.comparison import MethodComparison -from colorcast.analysis.visualization import ( - visualize_transfer_result, - visualize_method_comparison, - visualize_color_channels, - create_side_by_side_comparison, +from colorcast.analysis.daltonization import ( + apply_daltonization, + daltonize, +) +from colorcast.analysis.dashboard import ( + DashboardResult, + compute_dashboard, + generate_dashboard_report, ) from colorcast.analysis.error_map import ( ErrorMap, @@ -21,21 +25,26 @@ plot_error_heatmap, summarize_error_map, ) -from colorcast.analysis.daltonization import ( - apply_daltonization, - daltonize, +from colorcast.analysis.visualization import ( + create_side_by_side_comparison, + visualize_color_channels, + visualize_method_comparison, + visualize_transfer_result, ) __all__ = [ - 'MethodComparison', - 'visualize_transfer_result', - 'visualize_method_comparison', - 'visualize_color_channels', - 'create_side_by_side_comparison', - 'ErrorMap', - 'get_error_map', - 'plot_error_heatmap', - 'summarize_error_map', - 'apply_daltonization', - 'daltonize', -] \ No newline at end of file + "MethodComparison", + "visualize_transfer_result", + "visualize_method_comparison", + "visualize_color_channels", + "create_side_by_side_comparison", + "ErrorMap", + "get_error_map", + "plot_error_heatmap", + "summarize_error_map", + "apply_daltonization", + "daltonize", + "DashboardResult", + "compute_dashboard", + "generate_dashboard_report", +] diff --git a/colorcast/analysis/comparison.py b/colorcast/analysis/comparison.py index 0b0b278..e30946b 100644 --- a/colorcast/analysis/comparison.py +++ b/colorcast/analysis/comparison.py @@ -6,107 +6,107 @@ from __future__ import annotations +import logging from collections.abc import Callable + import numpy as np from skimage.metrics import structural_similarity as ssim +logger = logging.getLogger(__name__) + # -- Metric direction: higher-is-better vs lower-is-better ----------------------- _METRIC_DIRECTION: dict[str, bool] = { - "psnr": True, # higher = better - "ssim": True, # higher = better - "color_distance": False, # lower = better - "histogram_distance": False, # lower = better + "psnr": True, # higher = better + "ssim": True, # higher = better + "color_distance": False, # lower = better + "histogram_distance": False, # lower = better } class MethodComparison: """Compare transfer methods using various quality metrics.""" - + @staticmethod def compute_psnr(img1: np.ndarray, img2: np.ndarray) -> float: """ Compute Peak Signal-to-Noise Ratio. - + PSNR measures the quality of reconstruction. Higher values indicate better quality. Typically 20-40 dB range for images. - + Args: img1: First image (H, W, C) in range [0, 1] img2: Second image (H, W, C) in range [0, 1] - + Returns: PSNR value in dB, infinity if images are identical - + Example: >>> psnr = MethodComparison.compute_psnr(img1, img2) >>> print(f"PSNR: {psnr:.2f} dB") """ mse = np.mean((img1 - img2) ** 2) if mse == 0: - return float('inf') + return float("inf") return 20 * np.log10(1.0 / np.sqrt(mse)) - + @staticmethod def compute_ssim(img1: np.ndarray, img2: np.ndarray) -> float: """ Compute Structural Similarity Index. - + SSIM measures perceived quality based on structural information. Values range from -1 to 1, with 1 indicating perfect match. Values above 0.9 typically indicate good perceptual quality. - + Args: img1: First image (H, W, C) in range [0, 1] img2: Second image (H, W, C) in range [0, 1] - + Returns: SSIM value in range [-1, 1] - + Example: >>> ssim_val = MethodComparison.compute_ssim(img1, img2) >>> print(f"SSIM: {ssim_val:.4f}") """ return ssim(img1, img2, channel_axis=-1, data_range=1.0) - + @staticmethod def compute_color_distance(source: np.ndarray, result: np.ndarray) -> float: """ Compute average Euclidean color distance. - + Measures how much colors have shifted from original. Lower values indicate more subtle color changes. - + Args: source: Original image (H, W, C) in range [0, 1] result: Transferred image (H, W, C) in range [0, 1] - + Returns: Average color distance (lower = more subtle) - + Example: >>> dist = MethodComparison.compute_color_distance(source, result) >>> print(f"Color distance: {dist:.4f}") """ diff = source - result - return np.mean(np.sqrt(np.sum(diff ** 2, axis=2))) - + return np.mean(np.sqrt(np.sum(diff**2, axis=2))) + @staticmethod - def compute_histogram_distance( - img1: np.ndarray, - img2: np.ndarray, - bins: int = 256 - ) -> float: + def compute_histogram_distance(img1: np.ndarray, img2: np.ndarray, bins: int = 256) -> float: """ Compute Earth Mover's Distance between histograms. - + Measures difference in color distributions. Lower values indicate more similar histograms. - + Args: img1: First image (H, W, C) in range [0, 1] img2: Second image (H, W, C) in range [0, 1] bins: Number of histogram bins - + Returns: Average histogram distance across channels """ @@ -114,26 +114,68 @@ def compute_histogram_distance( for i in range(img1.shape[2]): hist1, _ = np.histogram(img1[:, :, i], bins=bins, range=(0, 1)) hist2, _ = np.histogram(img2[:, :, i], bins=bins, range=(0, 1)) - + # Normalize histograms hist1_sum = hist1.sum() hist2_sum = hist2.sum() hist1 = hist1 / (hist1_sum if hist1_sum > 0 else 1) hist2 = hist2 / (hist2_sum if hist2_sum > 0 else 1) - + # Compute 1-D Earth Mover's Distance (L1 of cumulative histograms) distance = np.sum(np.abs(np.cumsum(hist1) - np.cumsum(hist2))) distances.append(distance) - + return np.mean(distances) - + + def _make_metrics( + self, + result: np.ndarray | None, + source_img: np.ndarray, + reference_img: np.ndarray, + error_msg: str | None = None, + ) -> dict[str, float | str]: + """ + Build the standard four-metric dictionary for a method result. + + Args: + result: Transferred image array (H, W, 3), or None when the + transfer method raised an error. + source_img: Source image array (H, W, 3) in range [0, 1]. + reference_img: Reference image array (H, W, 3) in range [0, 1]. + error_msg: Human-readable error string stored under ``_error`` + when ``result`` is None. + + Returns: + Dictionary with four numeric quality metrics: + ``psnr``, ``ssim``, ``color_distance``, and + ``histogram_distance``. When ``result`` is None every metric + is NaN; if ``error_msg`` was supplied an ``_error`` key is + also included. + """ + if result is None: + metrics: dict[str, float | str] = { + "psnr": float("nan"), + "ssim": float("nan"), + "color_distance": float("nan"), + "histogram_distance": float("nan"), + } + if error_msg is not None: + metrics["_error"] = error_msg + return metrics + return { + "psnr": self.compute_psnr(result, reference_img), + "ssim": self.compute_ssim(result, reference_img), + "color_distance": self.compute_color_distance(source_img, result), + "histogram_distance": self.compute_histogram_distance(result, reference_img), + } + def compare_methods( self, source: np.ndarray, reference: np.ndarray, methods: dict[str, Callable], include_baseline: bool = True, - ) -> dict[str, dict[str, float]]: + ) -> dict[str, dict[str, float | str]]: """ Compare multiple transfer methods with various metrics. @@ -156,40 +198,27 @@ def compare_methods( >>> for method, metrics in results.items(): ... print(f"{method}: PSNR={metrics['psnr']:.2f}") """ - results: dict[str, dict[str, float]] = {} + results: dict[str, dict[str, float | str]] = {} # Include baseline (source vs reference) if requested if include_baseline: - results["baseline"] = { - "psnr": self.compute_psnr(source, reference), - "ssim": self.compute_ssim(source, reference), - "color_distance": 0.0, # no transfer applied; result equals source - "histogram_distance": self.compute_histogram_distance(source, reference), - } + results["baseline"] = self._make_metrics(source, source, reference) # Compare each method for name, method in methods.items(): try: result = method(source, reference) - except Exception as exc: - results[name] = { - "psnr": float("nan"), - "ssim": float("nan"), - "color_distance": float("nan"), - "histogram_distance": float("nan"), - "_error": str(exc), - } + except ( + Exception + ) as exc: # noqa: BLE001 — isolate per-method failures so one bad method cannot abort the comparison + logger.error("Comparison method %r failed: %s", name, exc, exc_info=True) + results[name] = self._make_metrics(None, source, reference, error_msg=str(exc)) continue - results[name] = { - "psnr": self.compute_psnr(result, reference), - "ssim": self.compute_ssim(result, reference), - "color_distance": self.compute_color_distance(source, result), - "histogram_distance": self.compute_histogram_distance(result, reference), - } + results[name] = self._make_metrics(result, source, reference) return results - + def rank_methods( self, comparison_results: dict[str, dict[str, float]], @@ -224,12 +253,14 @@ def rank_methods( [ (name, metrics[primary_metric]) for name, metrics in comparison_results.items() - if name != "baseline" and not np.isnan(metrics.get(primary_metric, float("nan"))) + if name != "baseline" + and not np.isnan(metrics.get(primary_metric, float("nan"))) + and not np.isclose(metrics.get("color_distance", 1.0), 0.0, atol=1e-9) ], key=lambda x: x[1], reverse=not ascending, ) - + def generate_comparison_report( self, comparison_results: dict[str, dict[str, float]], @@ -287,7 +318,7 @@ def generate_comparison_report( report.append(f" {i}. {name}: {value:.4f}") return "\n".join(report) - + def find_best_method( self, comparison_results: dict[str, dict[str, float]], @@ -296,6 +327,12 @@ def find_best_method( """ Find the best method according to a specific metric. + ``psnr``, ``ssim``, and ``histogram_distance`` measure fidelity to + the reference image (higher PSNR/SSIM is better; lower histogram + distance is better). ``color_distance`` measures deviation from the + source image (lower is better) — an identity method that returns + the source unchanged is therefore excluded from ranking. + The direction (higher-is-better or lower-is-better) is inferred automatically from the metric name. @@ -316,7 +353,6 @@ def find_best_method( ranking = self.rank_methods(comparison_results, metric) if not ranking: raise ValueError( - f"No valid results for metric {metric!r} — " - "all methods failed or returned NaN." + f"No valid results for metric {metric!r} — " "all methods failed or returned NaN." ) - return ranking[0] \ No newline at end of file + return ranking[0] diff --git a/colorcast/analysis/daltonization.py b/colorcast/analysis/daltonization.py index f9369ea..fe2f65a 100644 --- a/colorcast/analysis/daltonization.py +++ b/colorcast/analysis/daltonization.py @@ -12,19 +12,21 @@ - :func:`daltonize` — convenience wrapper that runs simulation, error analysis, and correction in one call. -Shift matrices --------------- -The module stores a 3×3 shift matrix for each supported deficiency: - -- ``deuteranopia`` / ``protanopia`` — red-green error is routed into the Blue - channel, which is preserved in both conditions. -- ``tritanopia`` — blue error is routed equally into Red and Green, encoding - the correction as a luminance modulation. - -Correction is spatially weighted by the CIE-Lab* chromaticity error map so -low-error pixels remain largely unchanged, and the original luminance channel -is restored after correction (except for tritanopia; see below) to prevent -brightness drift. +Lab-space correction +-------------------- +The correction operates in the CIE Lab* (a*, b*) chromaticity plane rather +than gamma-encoded sRGB. For each deficiency type, the lost chromatic +information — measured by the signed (a*, b*) difference in the error map — +is rotated into the surviving chromatic axis: + +- ``deuteranopia`` / ``protanopia`` — red-green (a*) error is redirected + into the blue-yellow (b*) channel. +- ``tritanopia`` — blue-yellow (b*) error is redirected into the red-green + (a*) channel. + +Correction is spatially weighted by the chromaticity error map so +low-error pixels remain largely unchanged, and ``base_lab[:, :, 0]`` is +preserved to prevent brightness drift. For the color-vision science behind this module, see the project wiki: "Color-Vision Background". @@ -53,98 +55,101 @@ from colorcast.analysis.error_map import ErrorMap, get_error_map from colorcast.processing.image_loader import normalize_to_float32 - # --------------------------------------------------------------------------- -# Shift matrices (rows = [ΔR, ΔG, ΔB] output; cols = [eR, eG, eB] input) +# Lab-space shift coefficients # --------------------------------------------------------------------------- # -# Multiplication is: correction_flat = error_flat @ SHIFT.T -# where error_flat is (H*W, 3) and correction_flat is (H*W, 3). +# Each deficiency maps a 2D (a*, b*) error vector to a 2D correction vector +# that redirects lost chromaticity into the surviving axis. # -# Convention: -# • SHIFT[i, j] is the coefficient by which error channel j -# contributes to output channel i. -# • Rows that are zero → that output channel is left unchanged. -# • Row sums are ≤ 1.0 per output channel to avoid clipping. - -_SHIFT_MATRICES: dict[str, np.ndarray] = { - # ── Deuteranopia (M-cone missing) ───────────────────────────────────── - # Red-Green confusion → redirect the Red + Green error into Blue. - # B += 0.7·eR + 0.1·eG (R, G unchanged: luminance handles the rest) - "deuteranopia": np.array( - [ - [0.0, 0.0, 0.0], # ΔR = 0 - [0.0, 0.0, 0.0], # ΔG = 0 - [0.7, 0.1, 0.0], # ΔB = 0.7·eR + 0.1·eG - ], - dtype=np.float32, - ), - # ── Protanopia (L-cone missing) ─────────────────────────────────────── - # Same opponent axis as deuteranopia (red-green); same redistribution - # strategy. Uses the same shift matrix as deuteranopia because both - # conditions involve red-green confusion. - "protanopia": np.array( - [ - [0.0, 0.0, 0.0], # ΔR = 0 - [0.0, 0.0, 0.0], # ΔG = 0 - [0.7, 0.1, 0.0], # ΔB = 0.7·eR + 0.1·eG - ], - dtype=np.float32, - ), - # ── Tritanopia (S-cone missing) ─────────────────────────────────────── - # Blue-Yellow confusion → redirect the Blue error into both Red and Green. - # Adding the same amount to R and G encodes the correction as a luminance - # modulation, which is perceivable even without hue discrimination. - "tritanopia": np.array( - [ - [0.0, 0.0, 0.7], # ΔR = 0.7·eB - [0.0, 0.0, 0.7], # ΔG = 0.7·eB - [0.0, 0.0, 0.0], # ΔB = 0 - ], - dtype=np.float32, - ), +# correction_a = coeff[0] * error_a + coeff[1] * error_b +# correction_b = coeff[2] * error_a + coeff[3] * error_b +# +# For red-green deficiencies the a* error is the dominant loss; it is +# redirected into b*. For blue-yellow deficiency the b* error is +# redirected into a*. + +_LAB_SHIFT_COEFFICIENTS: dict[str, tuple[float, float, float, float]] = { + # Deuteranopia (M-cone missing): redirect a* → b* + "deuteranopia": (0.0, 0.0, 0.35, 0.0), + # Protanopia (L-cone missing): redirect a* → b* — slightly stronger + # coefficient because L-cone contributes more to the a* axis. + "protanopia": (0.0, 0.0, 0.40, 0.0), + # Tritanopia (S-cone missing): redirect b* → a* + "tritanopia": (0.0, 0.35, 0.0, 0.0), } +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _compute_chromaticity_weight( + error_map: ErrorMap, +) -> np.ndarray: + """Derive a per-pixel spatial weight from the chroma error map. + + The chroma_error array is subsampled to keep the percentile calculation + constant-time regardless of image size. Weight is ``clamp(ce / p95, 0, 1)`` + so that only pixels with high chromatic error receive the full correction. + + Returns (H, W, 1) float32 array. + """ + ce = error_map.chroma_error + stride = max(1, ce.size // 40_000) + p95 = float(np.percentile(ce.flat[::stride], 95)) + if p95 < 1e-6: + return np.zeros((*ce.shape, 1), dtype=np.float32) + weight = np.clip(ce / p95, 0.0, 1.0) + return weight[:, :, np.newaxis] + + +# --------------------------------------------------------------------------- +# Convenience end-to-end pipeline # --------------------------------------------------------------------------- # Core function # --------------------------------------------------------------------------- + def apply_daltonization( original_img: np.ndarray, error_map: ErrorMap, deficiency_type: str, intensity: float = 1.0, + *, + simulated_img: np.ndarray | None = None, ) -> np.ndarray: """Apply the Daltonization correction to *original_img*. - The function re-encodes the chromatic information that was lost in - dichromatic simulation, as measured by the error map, - into a surviving perceptual channel that the affected - observer can discriminate. + The correction operates in the CIE Lab* (a*, b*) chromaticity plane: + the lost chromatic information measured by the error map is rotated + into the surviving chromatic axis for the given deficiency type, and + applied to the *simulated* image so that the color-blind observer + perceives the re-encoded information. - The correction is spatially weighted by the CIE Lab* chromaticity - error: pixels where the simulation barely changed anything are left - nearly untouched; pixels where large chromatic information was lost - receive the strongest shift. After the shift, original luminance (L*) - is restored to prevent brightness drift. + When ``simulated_img`` is None the correction is applied to the + original image instead (backward-compatible behavior). + + Correction is spatially weighted by the chromaticity error map: + pixels where the simulation barely changed anything are left nearly + untouched; pixels where large chromatic information was lost receive + the strongest shift. L* is preserved throughout. Parameters ---------- original_img : - Source image, shape (H, W, 3), any numeric dtype. Values may be - uint8 [0, 255] or float [0, 1]; the function normalises internally. + Source image, shape (H, W, 3), any numeric dtype. error_map : - Result of :func:`~colorcast.analysis.error_map.get_error_map` - computed from ``original_img`` and its simulated counterpart. + Result of :func:`~colorcast.analysis.error_map.get_error_map`. deficiency_type : One of ``"deuteranopia"``, ``"protanopia"``, or ``"tritanopia"``. intensity : - Global correction strength in [0.0, 1.0]. At ``0.0`` the output - equals the original; at ``1.0`` the full correction is applied. - The per-pixel chromaticity weight is applied on top of this — so - even at ``intensity=1.0``, low-error pixels remain essentially - unchanged. + Global correction strength in [0.0, 1.0]. + simulated_img : + The dichromatic simulation of ``original_img``. When provided, + the correction is applied to this image so the color-blind + observer sees the re-encoded information. Returns ------- @@ -155,105 +160,46 @@ def apply_daltonization( ------ ValueError If ``deficiency_type`` is not supported. - - Example - ------- - >>> from colorcast.processing.simulation import ColorBlindSimulator - >>> from colorcast.analysis.error_map import get_error_map - >>> from colorcast.analysis.daltonization import apply_daltonization - >>> sim = ColorBlindSimulator().transform_color_space(original, "deuteranopia") - >>> em = get_error_map(original, sim) - >>> corrected = apply_daltonization(original, em, "deuteranopia", intensity=0.8) """ - if deficiency_type not in _SHIFT_MATRICES: + if deficiency_type not in _LAB_SHIFT_COEFFICIENTS: raise ValueError( f"Unknown deficiency type {deficiency_type!r}. " - f"Supported: {sorted(_SHIFT_MATRICES)}" + f"Supported: {sorted(_LAB_SHIFT_COEFFICIENTS)}" ) intensity = float(np.clip(intensity, 0.0, 1.0)) + base = normalize_to_float32(simulated_img if simulated_img is not None else original_img) + if intensity < 1e-6: + return base.copy() - # ── 1. Normalize original to float32 [0, 1] ───────────────────────────── - orig = normalize_to_float32(original_img) + base_lab = skcolor.rgb2lab(base) - H, W, _ = orig.shape + weight = _compute_chromaticity_weight(error_map) + if float(np.max(weight)) < 1e-6: + return base.copy() - # Short-circuit: identity pass when intensity is effectively zero - if intensity < 1e-6: - return orig.copy() - - # ── 2. Compute the raw channel correction via the shift matrix ────────── - # - # error_map.signed is (H, W, 3) float32, values ≈ [−1, 1]. - # The shift matrix M is (3, 3) where M[i, j] is the coefficient by which - # error channel j contributes to output channel i. - # - # We flatten to (H*W, 3) for a single vectorized matrix multiply, then - # reshape back. This avoids any Python-level loops. - shift = _SHIFT_MATRICES[deficiency_type] # (3, 3) - error_flat = error_map.signed.reshape(-1, 3) # (H*W, 3) - correction_flat = error_flat @ shift.T # (H*W, 3) - correction = correction_flat.reshape(H, W, 3) # (H, W, 3) - - # ── 3. Perceptual (chromaticity) spatial weight ───────────────────────── - # - # Subsample the chroma_error array for the percentile calculation when - # the image is large. A stride of ~1 element per 40k pixels keeps the - # sample size roughly constant around 40 000 points regardless of image - # size. - ce = error_map.chroma_error - stride = max(1, ce.size // 40_000) - p95 = float(np.percentile(ce.flat[::stride], 95)) - if p95 < 1e-6: - # The image is already safe under this deficiency; no correction needed. - return orig.copy() - - weight = np.clip(ce / p95, 0.0, 1.0) # (H, W) - weight = weight[:, :, np.newaxis] # (H, W, 1) → broadcast over RGB - - # ── 4. Apply global intensity scale and per-pixel weight ──────────────── - # - # The two scales multiply together: - # • intensity — user-facing slider: "how much Daltonization overall?" - # • weight — automatic spatial gate: "how much at this pixel?" - # - # At intensity=1.0, weight drives the correction. - # At intensity=0.5, the whole map is halved before the weight is applied. - correction *= weight * intensity - - # ── 5. Inject the weighted correction into the original ───────────────── - corrected = orig + correction # may temporarily exceed [0, 1] - - # ── 6. Luminance Preservation (L* round-trip) ─────────────────────────── - # - # Use the L* channel cached in error_map.orig_l_star — it was already - # computed during get_error_map() — instead of calling rgb2lab(orig) - # again. This eliminates one full Lab conversion (≈25% of total time - # on a 1080p image). - # - # skimage.rgb2lab accepts float32 and up-converts internally, so we - # avoid the explicit .astype(np.float64) copy on corrected_f. - corrected_f = np.clip(corrected, 0.0, 1.0) - corrected_lab = skcolor.rgb2lab(corrected_f) # float64 output - - # Restore original lightness; preserve shifted chromaticity. - # orig_l_star is float32; the float64 array accepts the implicit upcast. - # Skip for tritanopia: the shift matrix encodes the blue-error correction - # as equal R+G (luminance) shifts, which are the primary carrier of the - # correction signal. Restoring L* would erase those shifts entirely. - if deficiency_type != "tritanopia": - corrected_lab[:, :, 0] = error_map.orig_l_star - - corrected_rgb = skcolor.lab2rgb(corrected_lab) # float64, may have tiny excursions - corrected_rgb = np.clip(corrected_rgb, 0.0, 1.0).astype(np.float32) - - return corrected_rgb + # Signed (a*, b*) error: the chromatic information lost in simulation + ab_error = error_map.signed_chroma_ab # (H, W, 2) + + # Lab-space shift coefficients + caa, cab, cba, cbb = _LAB_SHIFT_COEFFICIENTS[deficiency_type] + correction_a = caa * ab_error[:, :, 0] + cab * ab_error[:, :, 1] + correction_b = cba * ab_error[:, :, 0] + cbb * ab_error[:, :, 1] + + # Apply spatial weight and intensity + w = weight[:, :, 0] * intensity + base_lab[:, :, 1] = base_lab[:, :, 1] + correction_a * w + base_lab[:, :, 2] = base_lab[:, :, 2] + correction_b * w + + corrected_rgb = skcolor.lab2rgb(base_lab) + return np.clip(corrected_rgb, 0.0, 1.0).astype(np.float32) # --------------------------------------------------------------------------- # Convenience end-to-end pipeline # --------------------------------------------------------------------------- + def daltonize( original_img: np.ndarray, deficiency_type: str, @@ -290,8 +236,20 @@ def daltonize( >>> from colorcast.analysis.daltonization import daltonize >>> corrected = daltonize(original, "deuteranopia", intensity=0.9) """ + from colorcast.processing.image_loader import normalize_to_float32 from colorcast.processing.simulation import ColorBlindSimulator - simulated = ColorBlindSimulator().transform_color_space(original_img, deficiency_type) + intensity = float(np.clip(intensity, 0.0, 1.0)) + if deficiency_type not in _LAB_SHIFT_COEFFICIENTS: + raise ValueError( + f"Unknown deficiency type {deficiency_type!r}. " + f"Supported: {sorted(_LAB_SHIFT_COEFFICIENTS)}" + ) + if intensity < 1e-6: + return normalize_to_float32(original_img) + + simulated = ColorBlindSimulator().transform_color_space(original_img, deficiency_type) # type: ignore[arg-type] em = get_error_map(original_img, simulated) - return apply_daltonization(original_img, em, deficiency_type, intensity=intensity) + return apply_daltonization( + original_img, em, deficiency_type, intensity=intensity, simulated_img=simulated + ) diff --git a/colorcast/analysis/dashboard.py b/colorcast/analysis/dashboard.py new file mode 100644 index 0000000..ebe7c48 --- /dev/null +++ b/colorcast/analysis/dashboard.py @@ -0,0 +1,235 @@ +"""CVD accessibility dashboard — compare all three deficiencies at once. + +Public API +---------- +- :class:`DashboardResult` — container for original, three simulations, three + error maps, and per-deficiency summary statistics. +- :func:`compute_dashboard` — run all three CVD simulations and error maps + in parallel and return a :class:`DashboardResult`. +- :func:`generate_dashboard_report` — render the dashboard as a full- + resolution Matplotlib figure and save to a file. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass + +import numpy as np + +from colorcast.analysis.error_map import ErrorMap, get_error_map +from colorcast.processing.simulation import ColorBlindSimulator + +_DEFICIENCIES: tuple[str, ...] = ("protanopia", "deuteranopia", "tritanopia") +_DEFICIENCY_LABELS: dict[str, str] = { + "protanopia": "Protanopia (red-blind)", + "deuteranopia": "Deuteranopia (green-blind)", + "tritanopia": "Tritanopia (blue-blind)", +} + + +@dataclass +class DashboardResult: + """Container returned by :func:`compute_dashboard`. + + Attributes + ---------- + original : np.ndarray + The source image, float32 (H, W, 3), values in [0, 1]. + simulated : dict[str, np.ndarray] + Simulated images keyed by deficiency name. + error_maps : dict[str, ErrorMap] + :class:`ErrorMap` instances keyed by deficiency name. + summary : dict[str, dict[str, float]] + Per-deficiency summary stats: ``mean_error``, ``median_error``, + ``p95_error``, ``percent_affected``. + """ + + original: np.ndarray + simulated: dict[str, np.ndarray] + error_maps: dict[str, ErrorMap] + summary: dict[str, dict[str, float]] + + +def compute_dashboard( + image_array: np.ndarray, + max_workers: int | None = None, +) -> DashboardResult: + """Run all three CVD simulations and error maps in parallel. + + Args: + image_array: Source image, any numeric dtype, shape (H, W, 3). + max_workers: Passed to ``ThreadPoolExecutor``. Defaults to the + number of deficiencies (3). + + Returns: + :class:`DashboardResult` + """ + from colorcast.processing.image_loader import normalize_to_float32 + + image_array = normalize_to_float32(image_array) + simulator = ColorBlindSimulator() + + def _simulate_and_map(deficiency: str) -> tuple[str, np.ndarray, ErrorMap, dict[str, float]]: + sim = simulator.transform_color_space(image_array, deficiency) # type: ignore[arg-type] + em = get_error_map(image_array, sim, compute_dE00=True) + stats = _summarize(em) + return deficiency, sim, em, stats + + simulated: dict[str, np.ndarray] = {} + error_maps: dict[str, ErrorMap] = {} + summary: dict[str, dict[str, float]] = {} + + workers = max_workers if max_workers is not None else len(_DEFICIENCIES) + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = [executor.submit(_simulate_and_map, d) for d in _DEFICIENCIES] + for future in futures: + deficiency, sim, em, stats = future.result() + simulated[deficiency] = sim + error_maps[deficiency] = em + summary[deficiency] = stats + + return DashboardResult( + original=image_array, + simulated=simulated, + error_maps=error_maps, + summary=summary, + ) + + +def _summarize(em: ErrorMap) -> dict[str, float]: + """Derive scalar summary statistics from an ErrorMap.""" + ce = em.chroma_error_dE00 + if ce is None: + return { + "mean_error": float("nan"), + "median_error": float("nan"), + "p95_error": float("nan"), + "percent_affected": float("nan"), + } + return { + "mean_error": float(np.mean(ce)), + "median_error": float(np.median(ce)), + "p95_error": float(np.percentile(ce, 95)), + # dE00 ≈ 1 is the classic just-noticeable-difference threshold. + "percent_affected": float(np.count_nonzero(ce > 1.0) / ce.size * 100), + } + + +def format_summary_table(result: DashboardResult) -> str: + """ + Format the deficiency summary as a monospace-aligned table string. + + Args: + result: A pre-computed :class:`DashboardResult` whose ``summary`` + dict holds per-deficiency scalar statistics. + + Returns: + Monospace-aligned plain-text table with one row per deficiency + (mean ΔE, median ΔE, p95 ΔE, and affected-area percentage). + """ + header = ( + f"{'Deficiency':<16} {'Mean ΔE':>10} " + f"{'Median ΔE':>12} {'p95 ΔE':>10} {'Affected %':>12}" + ) + lines = [header, "-" * 68] + for deficiency in _DEFICIENCIES: + s = result.summary.get(deficiency, {}) + label = deficiency.capitalize()[:13] + lines.append( + f"{label:<16} " + f"{s.get('mean_error', 0):>10.3f} " + f"{s.get('median_error', 0):>12.3f} " + f"{s.get('p95_error', 0):>10.3f} " + f"{s.get('percent_affected', 0):>11.1f}%" + ) + return "\n".join(lines) + + +def generate_dashboard_report( + result: DashboardResult, + output_path: str, + title: str = "CVD Accessibility Dashboard", +) -> str: + """Render the dashboard as a full-resolution Matplotlib PNG. + + Layout:: + Row 0 — [ Original (centred) ] + Row 1 — [Protanopia] [Deuteranopia] [Tritanopia] + Row 2 — [Chroma Loss (P)] [Chroma Loss (D)] [Chroma Loss (T)] + Row 3 — (empty — reserved for summary table and caption) + + A summary table and a short caption explaining how to read the + chroma-loss heatmaps are placed below the grid. + + Args: + result: Result from :func:`compute_dashboard`. + output_path: File path for the output PNG. + title: Overall figure title. + + Returns: + The ``output_path`` that was written. + """ + from matplotlib.backends.backend_agg import FigureCanvasAgg + from matplotlib.figure import Figure + + orig = np.clip(result.original, 0, 1) + + fig = Figure(figsize=(18, 16)) + FigureCanvasAgg(fig) + axes = fig.subplots(4, 3) + fig.suptitle(title, x=0.02, y=0.98, fontsize=15, fontfamily="monospace") + + # Row 0: Original — centred in the middle column + axes[0, 0].axis("off") + axes[0, 2].axis("off") + axes[0, 1].imshow(orig) + axes[0, 1].set_title("Original", fontfamily="monospace", fontsize=10) + axes[0, 1].axis("off") + + # Row 1: three simulations + for col, deficiency in enumerate(_DEFICIENCIES): + sim = np.clip(result.simulated[deficiency], 0, 1) + axes[1, col].imshow(sim) + label = _DEFICIENCY_LABELS.get(deficiency, deficiency.capitalize()) + axes[1, col].set_title(label, fontfamily="monospace", fontsize=10) + axes[1, col].axis("off") + + # Row 2: three chroma-loss heatmaps + for col, deficiency in enumerate(_DEFICIENCIES): + em = result.error_maps.get(deficiency) + if em is not None: + _show_heatmap(axes[2, col], em.chroma_error) + axes[2, col].set_title(f"Chroma Loss ({deficiency[0].upper()})", fontsize=11) + + # Row 3: empty — reserved for the summary table and caption below + for col in range(3): + axes[3, col].axis("off") + + # Summary table — built with explicit column widths so data aligns + summary_text = format_summary_table(result) + fig.text(0.05, 0.1, summary_text, fontfamily="monospace", fontsize=10) + + # Caption explaining how to read the chroma-loss images + caption = ( + "Chroma-loss heatmaps show, per pixel, how much chromatic information\n" + "the simulation removed — red / bright regions lost the most colour\n" + "contrast; dark regions were preserved. The hot colormap is\n" + "normalised so the brightest pixel in each heatmap represents the\n" + "maximum ΔE loss for that deficiency." + ) + fig.text(0.05, 0.025, caption, fontfamily="monospace", fontsize=10) + + fig.tight_layout() + fig.savefig(output_path, dpi=150, bbox_inches="tight") + return output_path + + +def _show_heatmap(ax, chroma_error: np.ndarray) -> None: + """Render a chroma-loss heatmap on a Matplotlib axis.""" + vmax = float(chroma_error.max()) + if vmax < 1e-6: + ax.imshow(np.zeros_like(chroma_error), cmap="hot", vmin=0, vmax=1) + else: + ax.imshow(chroma_error / vmax, cmap="hot", vmin=0, vmax=1) + ax.axis("off") diff --git a/colorcast/analysis/error_map.py b/colorcast/analysis/error_map.py index 2fc1e17..fd95999 100644 --- a/colorcast/analysis/error_map.py +++ b/colorcast/analysis/error_map.py @@ -35,13 +35,14 @@ import numpy as np from skimage import color as skcolor -from colorcast.processing.image_loader import normalize_to_float32 +from colorcast.processing.image_loader import normalize_to_float32 # --------------------------------------------------------------------------- # Public data class # --------------------------------------------------------------------------- + class ErrorMap(NamedTuple): """Container returned by :func:`get_error_map`. @@ -75,9 +76,15 @@ class ErrorMap(NamedTuple): orig_l_star : np.ndarray The L* (lightness) channel of the *original* image in CIE Lab* space, - shape (H, W), dtype float32. Cached here so that - :func:`~colorcast.analysis.daltonization.apply_daltonization` can - restore original luminance without recomputing ``rgb2lab(original)``. + shape (H, W), dtype float32. Cached here for consumers that need the + original lightness without recomputing ``rgb2lab(original)``. + + chroma_error_dE00 : np.ndarray or None + Per-pixel CIEDE2000 color difference, shape (H, W), dtype float32. + Computed with L* set to 50 (mid-gray) so only chromaticity + contributes. More perceptually uniform than the Euclidean + ``chroma_error``. Range [0, ∞), typically ≤ 100. + Set to ``None`` when ``compute_dE00=False``. """ signed: np.ndarray @@ -85,15 +92,36 @@ class ErrorMap(NamedTuple): chroma_error: np.ndarray signed_chroma_ab: np.ndarray orig_l_star: np.ndarray + chroma_error_dE00: np.ndarray | None = None # --------------------------------------------------------------------------- # Core analysis function # --------------------------------------------------------------------------- + +def _compute_chroma_error_dE00(orig_lab: np.ndarray, sim_lab: np.ndarray) -> np.ndarray: + """Compute CIEDE2000 chromaticity error from Lab* inputs. + + Both ``orig_lab`` and ``sim_lab`` are expected to be arrays with shape + ``(H, W, 3)`` containing Lab* values in channel order ``(L*, a*, b*)``. + The function pins the L* channel to ``50`` for both inputs and computes the + CIEDE2000 distance from the resulting a* and b* values. The returned + array has shape ``(H, W)`` and dtype ``float32``. + """ + lab1 = np.empty(orig_lab.shape, dtype=np.float64) + lab2 = np.empty(sim_lab.shape, dtype=np.float64) + lab1[:, :, 0] = 50.0 + lab2[:, :, 0] = 50.0 + lab1[:, :, 1:] = orig_lab[:, :, 1:] + lab2[:, :, 1:] = sim_lab[:, :, 1:] + return skcolor.deltaE_ciede2000(lab1, lab2).astype(np.float32) + + def get_error_map( original_img: np.ndarray, simulated_img: np.ndarray, + compute_dE00: bool = False, ) -> ErrorMap: """Compute the full error analysis between *original_img* and *simulated_img*. @@ -110,13 +138,19 @@ def get_error_map( The unmodified RGB image, shape (H, W, 3), any numeric dtype. simulated_img : The dichromat-simulated RGB image, shape (H, W, 3), any numeric dtype. + compute_dE00 : bool, default False + When ``True``, the returned :class:`ErrorMap` includes + ``chroma_error_dE00`` as a per-pixel ``(H, W)`` array of dtype + ``float32`` computed from the Lab* channels. When ``False``, that + field is left unavailable as ``np.nan`` values so callers can still + unpack the result without a second code path. Returns ------- ErrorMap Named tuple with ``signed``, ``absolute``, ``chroma_error``, - ``signed_chroma_ab``, and ``orig_l_star`` arrays. See - :class:`ErrorMap` for details. + ``chroma_error_dE00``, ``signed_chroma_ab``, and ``orig_l_star`` + arrays. See :class:`ErrorMap` for details. Raises ------ @@ -136,16 +170,12 @@ def get_error_map( """ # -- Input normalisation -------------------------------------------------- orig = normalize_to_float32(np.asarray(original_img)) - sim = normalize_to_float32(np.asarray(simulated_img)) + sim = normalize_to_float32(np.asarray(simulated_img)) if orig.shape != sim.shape: - raise ValueError( - f"Shape mismatch: original {orig.shape} vs simulated {sim.shape}." - ) + raise ValueError(f"Shape mismatch: original {orig.shape} vs simulated {sim.shape}.") if orig.ndim != 3 or orig.shape[2] != 3: - raise ValueError( - f"Expected (H, W, 3) images; got shape {orig.shape}." - ) + raise ValueError(f"Expected (H, W, 3) images; got shape {orig.shape}.") # -- 1. Signed difference (RGB) ------------------------------------------ # The direction is important: positive → original was stronger in that @@ -170,16 +200,16 @@ def get_error_map( # (a*, b*) chromaticity plane. # # Note: skimage.color.rgb2lab expects float32/float64 in [0, 1]. - orig_lab = skcolor.rgb2lab(orig) # (H, W, 3) float64 - sim_lab = skcolor.rgb2lab(sim) # (H, W, 3) float64 + orig_lab = skcolor.rgb2lab(orig) # (H, W, 3) float64 + sim_lab = skcolor.rgb2lab(sim) # (H, W, 3) float64 # Signed difference in Lab*: keep all three channels for signed_chroma_ab - diff_lab = orig_lab - sim_lab # (H, W, 3) + diff_lab = orig_lab - sim_lab # (H, W, 3) # Zero out L* — we explicitly ignore luminance change. # What remains in a* and b* is the pure chromatic information lost. - a_diff = diff_lab[:, :, 1].astype(np.float32) # red–green axis loss - b_diff = diff_lab[:, :, 2].astype(np.float32) # blue–yellow axis loss + a_diff = diff_lab[:, :, 1].astype(np.float32) # red–green axis loss + b_diff = diff_lab[:, :, 2].astype(np.float32) # blue–yellow axis loss signed_chroma_ab = np.stack([a_diff, b_diff], axis=-1) # (H, W, 2) @@ -191,16 +221,23 @@ def get_error_map( # no correction needed. # Daltonization uses this map (or signed_chroma_ab) to decide where and how # strongly to boost colour contrasts. - chroma_error = np.sqrt(a_diff ** 2 + b_diff ** 2) # (H, W) float32 + chroma_error = np.sqrt(a_diff**2 + b_diff**2) # (H, W) float32 - # Cache the original L* channel so downstream callers can - # restore luminance without recomputing rgb2lab on the original image. + # -- CIEDE2000 chromaticity error ----------------------------------------- + # Compute dE00 with L* pinned to 50 to isolate chromaticity contribution. + # By default this optional output is disabled, so the result contains + # NaN values instead of a computed metric. + chroma_error_dE00 = _compute_chroma_error_dE00(orig_lab, sim_lab) if compute_dE00 else None + + # Cache the original L* channel for callers that need it + # without recomputing rgb2lab on the original image. orig_l_star = orig_lab[:, :, 0].astype(np.float32) # (H, W) return ErrorMap( signed=signed, absolute=absolute, chroma_error=chroma_error, + chroma_error_dE00=chroma_error_dE00, signed_chroma_ab=signed_chroma_ab, orig_l_star=orig_l_star, ) @@ -210,6 +247,7 @@ def get_error_map( # Visualisation helpers # --------------------------------------------------------------------------- + def plot_error_heatmap( error_map: ErrorMap, original_img: np.ndarray | None = None, @@ -260,7 +298,7 @@ def plot_error_heatmap( # -- Optional: show original and simulated -------------------------------- if show_images: orig_disp = normalize_to_float32(np.asarray(original_img, dtype=np.float32)) - sim_disp = normalize_to_float32(np.asarray(simulated_img, dtype=np.float32)) + sim_disp = normalize_to_float32(np.asarray(simulated_img, dtype=np.float32)) axes[col].imshow(orig_disp) axes[col].set_title("Original", fontsize=11) @@ -315,8 +353,8 @@ def summarize_error_map(error_map: ErrorMap) -> dict[str, float]: ab = error_map.absolute.sum(axis=2) return { "mean_chroma_error": float(ce.mean()), - "max_chroma_error": float(ce.max()), - "p95_chroma_error": float(np.percentile(ce, 95)), - "mean_rgb_error": float(ab.mean()), - "max_rgb_error": float(ab.max()), + "max_chroma_error": float(ce.max()), + "p95_chroma_error": float(np.percentile(ce, 95)), + "mean_rgb_error": float(ab.mean()), + "max_rgb_error": float(ab.max()), } diff --git a/colorcast/analysis/visualization.py b/colorcast/analysis/visualization.py index 164bbd1..5fc9c34 100644 --- a/colorcast/analysis/visualization.py +++ b/colorcast/analysis/visualization.py @@ -4,10 +4,8 @@ results, including side-by-side comparisons, histograms, and difference maps. """ -from typing import Dict, Optional -import numpy as np -import matplotlib import matplotlib.pyplot as plt +import numpy as np from matplotlib.gridspec import GridSpec @@ -18,14 +16,14 @@ def visualize_transfer_result( method_name: str, show_histograms: bool = True, show_difference: bool = False, - figsize: Optional[tuple] = None, + figsize: tuple | None = None, ) -> plt.Figure: """ Create comprehensive visualization of transfer result. - + Shows source, reference, and result images side by side, plus optional histograms for each color channel and difference maps. - + Args: source: Source image (H, W, 3) in range [0, 1] reference: Reference image (H, W, 3) in range [0, 1] @@ -34,10 +32,10 @@ def visualize_transfer_result( show_histograms: Whether to show color histograms show_difference: Whether to show difference from reference figsize: Optional figure size (width, height) - + Returns: Matplotlib Figure object - + Example: >>> fig = visualize_transfer_result( ... source, style, result, "Histogram Matching" @@ -50,7 +48,7 @@ def visualize_transfer_result( figsize = (18, 14) if show_difference else (18, 12) else: figsize = (15, 6) if show_difference else (15, 5) - + # Create figure if show_histograms: n_rows = 5 if show_difference else 4 @@ -62,90 +60,86 @@ def visualize_transfer_result( if n_rows == 1: axes = np.array([axes]) gs = None - + # Helper to get subplot def subplot(row, col, rowspan=1, colspan=1): if gs is not None: - return fig.add_subplot(gs[row:row+rowspan, col:col+colspan]) + return fig.add_subplot(gs[row : row + rowspan, col : col + colspan]) else: return axes[row, col] - + # Images (top row) ax_source = subplot(0, 0) ax_ref = subplot(0, 1) ax_result = subplot(0, 2) - + ax_source.imshow(source) - ax_source.set_title('Source Image', fontsize=12, fontweight='bold') - ax_source.axis('off') - + ax_source.set_title("Source Image", fontsize=12, fontweight="bold") + ax_source.axis("off") + ax_ref.imshow(reference) - ax_ref.set_title('Reference (Style) Image', fontsize=12, fontweight='bold') - ax_ref.axis('off') - + ax_ref.set_title("Reference (Style) Image", fontsize=12, fontweight="bold") + ax_ref.axis("off") + ax_result.imshow(result) - ax_result.set_title(f'Result ({method_name})', fontsize=12, fontweight='bold') - ax_result.axis('off') - + ax_result.set_title(f"Result ({method_name})", fontsize=12, fontweight="bold") + ax_result.axis("off") + # Difference from reference (if requested) if show_difference: ax_diff = subplot(1, 0, colspan=3) diff = np.abs(result - reference) ax_diff.imshow(diff) - ax_diff.set_title('Difference from Reference', fontsize=12) - ax_diff.axis('off') + ax_diff.set_title("Difference from Reference", fontsize=12) + ax_diff.axis("off") fig.colorbar(ax_diff.images[0], ax=ax_diff, fraction=0.046, pad=0.04) - + # Histograms if show_histograms: - colors = ['r', 'g', 'b'] - channel_names = ['Red', 'Green', 'Blue'] + colors = ["r", "g", "b"] + channel_names = ["Red", "Green", "Blue"] start_row = 2 if show_difference else 1 - - for i, (color, name) in enumerate(zip(colors, channel_names)): + + for i, (color, name) in enumerate(zip(colors, channel_names, strict=False)): ax_hist = subplot(start_row + i, 0, colspan=3) - - for img, label in [ - (source, 'Source'), - (reference, 'Reference'), - (result, 'Result') - ]: + + for img, label in [(source, "Source"), (reference, "Reference"), (result, "Result")]: hist, bins = np.histogram(img[:, :, i], bins=50, range=(0, 1)) ax_hist.plot(bins[:-1], hist, color=color, alpha=0.7, label=label, linewidth=2) - - ax_hist.set_title(f'{name} Channel Histogram', fontsize=11, fontweight='bold') - ax_hist.set_xlabel('Intensity', fontsize=10) - ax_hist.set_ylabel('Count', fontsize=10) + + ax_hist.set_title(f"{name} Channel Histogram", fontsize=11, fontweight="bold") + ax_hist.set_xlabel("Intensity", fontsize=10) + ax_hist.set_ylabel("Count", fontsize=10) ax_hist.legend(fontsize=9) ax_hist.grid(True, alpha=0.3) - - fig.suptitle('Color Transfer Analysis', fontsize=16, fontweight='bold', y=0.995) + + fig.suptitle("Color Transfer Analysis", fontsize=16, fontweight="bold", y=0.995) return fig def visualize_method_comparison( - images: Dict[str, np.ndarray], + images: dict[str, np.ndarray], reference: np.ndarray, show_difference: bool = True, show_histograms: bool = False, - figsize: Optional[tuple] = None, + figsize: tuple | None = None, ) -> plt.Figure: """ Visualize comparison of multiple transfer methods. - + Displays results from multiple methods side by side for easy comparison, with optional difference maps from reference. - + Args: images: Dict of {method_name: result_image} reference: Reference image (H, W, 3) in range [0, 1] show_difference: Whether to show difference from reference show_histograms: Whether to show histograms (not recommended for many methods) figsize: Optional figure size - + Returns: Matplotlib Figure object - + Example: >>> results = { ... 'Histogram': hist_result, @@ -157,40 +151,63 @@ def visualize_method_comparison( >>> plt.close(fig) """ n_methods = len(images) - - n_rows = 2 if show_difference else 1 + + n_image_rows = 2 if show_difference else 1 + n_rows = n_image_rows + (3 if show_histograms else 0) if figsize is None: - figsize = (5 * (n_methods + 1), 5 * n_rows) - + figsize = (5 * (n_methods + 1), 5 * n_image_rows + 3 * (3 if show_histograms else 0)) + fig, axes = plt.subplots(n_rows, n_methods + 1, figsize=figsize) if n_rows == 1: axes = np.array([axes]) - + # First column: reference axes[0, 0].imshow(reference) - axes[0, 0].set_title('Reference Image', fontsize=12, fontweight='bold') - axes[0, 0].axis('off') - + axes[0, 0].set_title("Reference Image", fontsize=12, fontweight="bold") + axes[0, 0].axis("off") + # Add reference to bottom row if difference shown if show_difference: - axes[1, 0].axis('off') - + axes[1, 0].axis("off") + # Display each method for i, (name, img) in enumerate(images.items(), start=1): # Top row: results axes[0, i].imshow(img) - axes[0, i].set_title(name, fontsize=11, fontweight='bold') - axes[0, i].axis('off') - + axes[0, i].set_title(name, fontsize=11, fontweight="bold") + axes[0, i].axis("off") + # Bottom row: difference from reference if show_difference: diff = np.abs(img - reference) axes[1, i].imshow(diff) - axes[1, i].set_title(f'{name} (Difference)', fontsize=10) - axes[1, i].axis('off') - - fig.suptitle('Transfer Method Comparison', fontsize=14, fontweight='bold') + axes[1, i].set_title(f"{name} (Difference)", fontsize=10) + axes[1, i].axis("off") + + if show_histograms: + colors = ["r", "g", "b"] + channel_names = ["Red", "Green", "Blue"] + + for ch_idx, (ch_color, ch_name) in enumerate(zip(colors, channel_names, strict=False)): + row = n_image_rows + ch_idx + + hist, bins = np.histogram(reference[:, :, ch_idx], bins=50, range=(0, 1)) + axes[row, 0].plot(bins[:-1], hist, color=ch_color, alpha=0.7, linewidth=1.5) + axes[row, 0].set_title(f"Reference {ch_name}", fontsize=9) + axes[row, 0].set_xlabel("Intensity", fontsize=8) + axes[row, 0].set_ylabel("Count", fontsize=8) + axes[row, 0].grid(True, alpha=0.3) + + for i, (name, img) in enumerate(images.items(), start=1): + hist, bins = np.histogram(img[:, :, ch_idx], bins=50, range=(0, 1)) + axes[row, i].plot(bins[:-1], hist, color=ch_color, alpha=0.7, linewidth=1.5) + axes[row, i].set_title(f"{name} {ch_name}", fontsize=9) + axes[row, i].set_xlabel("Intensity", fontsize=8) + axes[row, i].set_ylabel("Count", fontsize=8) + axes[row, i].grid(True, alpha=0.3) + + fig.suptitle("Transfer Method Comparison", fontsize=14, fontweight="bold") fig.tight_layout() return fig @@ -202,37 +219,37 @@ def visualize_color_channels( ) -> plt.Figure: """ Visualize individual RGB channels of an image. - + Useful for understanding how color transfer affects each channel separately. - + Args: image: Image (H, W, 3) in range [0, 1] title: Figure title figsize: Figure size (width, height) - + Returns: Matplotlib Figure object - + Example: >>> fig = visualize_color_channels(result, "After Transfer") >>> fig.savefig("channels.png", dpi=150) >>> plt.close(fig) """ fig, axes = plt.subplots(1, 4, figsize=figsize) - + # Full RGB axes[0].imshow(image) - axes[0].set_title('RGB', fontweight='bold') - axes[0].axis('off') - + axes[0].set_title("RGB", fontweight="bold") + axes[0].axis("off") + # Individual channels - channel_names = ['Red', 'Green', 'Blue'] + channel_names = ["Red", "Green", "Blue"] for i, name in enumerate(channel_names): - axes[i+1].imshow(image[:, :, i], cmap='gray') - axes[i+1].set_title(name, fontweight='bold') - axes[i+1].axis('off') - - fig.suptitle(title, fontsize=14, fontweight='bold') + axes[i + 1].imshow(image[:, :, i], cmap="gray") + axes[i + 1].set_title(name, fontweight="bold") + axes[i + 1].axis("off") + + fig.suptitle(title, fontsize=14, fontweight="bold") fig.tight_layout() return fig @@ -246,31 +263,31 @@ def create_side_by_side_comparison( ) -> plt.Figure: """ Create simple side-by-side comparison of two images. - + Args: image1: First image (H, W, 3) in range [0, 1] image2: Second image (H, W, 3) in range [0, 1] label1: Label for first image label2: Label for second image figsize: Figure size - + Returns: Matplotlib Figure object - + Example: >>> fig = create_side_by_side_comparison(source, result) >>> fig.savefig("before_after.png", dpi=150) >>> plt.close(fig) """ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize) - + ax1.imshow(image1) - ax1.set_title(label1, fontsize=13, fontweight='bold') - ax1.axis('off') - + ax1.set_title(label1, fontsize=13, fontweight="bold") + ax1.axis("off") + ax2.imshow(image2) - ax2.set_title(label2, fontsize=13, fontweight='bold') - ax2.axis('off') - + ax2.set_title(label2, fontsize=13, fontweight="bold") + ax2.axis("off") + fig.tight_layout() - return fig \ No newline at end of file + return fig diff --git a/colorcast/gui.py b/colorcast/gui.py index 3870d83..8d1b350 100644 --- a/colorcast/gui.py +++ b/colorcast/gui.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Optional +from collections.abc import Callable import numpy as np from PyQt5 import QtCore, QtGui @@ -12,6 +12,7 @@ from PyQt5.QtWidgets import ( QApplication, QComboBox, + QDialog, QFileDialog, QGridLayout, QHBoxLayout, @@ -22,37 +23,107 @@ QVBoxLayout, QWidget, ) + from colorcast import blend_images, registry, save_image +from colorcast.analysis.dashboard import ( + _DEFICIENCIES, + _DEFICIENCY_LABELS, + DashboardResult, + format_summary_table, +) from colorcast.processing.image_loader import ImageMeta, load_image_with_meta +from colorcast.utils.config import ColorCastConfig from colorcast.utils.exceptions import ImageProcessingError +from colorcast.utils.validators_enhanced import ALLOWED_IMAGE_EXTENSIONS logger = logging.getLogger(__name__) -# Module-level constants -DEFAULT_WINDOW_WIDTH: int = 1000 -DEFAULT_WINDOW_HEIGHT: int = 700 -PREVIEW_SIZE: int = 300 -DEFAULT_INTENSITY: float = 0.85 -SLIDER_DEBOUNCE_MS: int = 50 -ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff") + +def _array_to_pixmap( + array: np.ndarray, + cell_size: int, + normalize_heatmap: bool = False, +) -> QPixmap: + """Convert a numpy image array to a QPixmap scaled to ``cell_size``.""" + if normalize_heatmap: + vmax = float(array.max()) + array = array / vmax if vmax > 1e-6 else np.zeros_like(array) + else: + array = np.clip(array, 0, 1) + img = (array * 255).astype(np.uint8) + img = np.ascontiguousarray(img) + h, w = img.shape[:2] + if img.ndim == 2 or img.shape[2] == 1: + qt_image = QImage(img.tobytes(), w, h, w, QImage.Format_Grayscale8) + else: + qt_image = QImage(img.tobytes(), w, h, img.shape[2] * w, QImage.Format_RGB888) + return QPixmap.fromImage(qt_image).scaled( + cell_size, cell_size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation + ) + + +class _WorkerSignals(QtCore.QObject): + """Queued signal bridge that marshals completion from a worker thread back to the GUI thread.""" + + finished = QtCore.pyqtSignal() + + +def _run_in_thread( + target: Callable[[], None], *, on_done: Callable[[], None] | None = None +) -> None: + """Run ``target`` off the GUI thread, calling ``on_done`` via a queued signal.""" + from threading import Thread + + signals = _WorkerSignals() + _ref: list[_WorkerSignals] = [signals] # keep alive until signal is delivered + + if on_done is not None: + + def _on_done() -> None: + on_done() + _ref.clear() + + signals.finished.connect(_on_done) + else: + signals.finished.connect(_ref.clear) + + def _worker() -> None: + try: + target() + finally: + signals.finished.emit() + + Thread(target=_worker, daemon=True).start() class StyleTransferApp(QWidget): """Main application window for ColorCast style transfer.""" - def __init__(self) -> None: - """Initialize the ColorCast application.""" + def __init__(self, config: ColorCastConfig | None = None) -> None: + """Initialize the ColorCast application. + + Args: + config: Optional configuration. When omitted, defaults are + taken from ``ColorCastConfig()``. + """ super().__init__() - self.content_image: Optional[np.ndarray] = None - self.style_image: Optional[np.ndarray] = None - self.result_image: Optional[np.ndarray] = None - self.styled_image: Optional[np.ndarray] = None - self.intensity: float = DEFAULT_INTENSITY - self.transfer_method: str = "histogram" + cfg = config or ColorCastConfig() + self._window_width: int = cfg.window_width + self._window_height: int = cfg.window_height + self._preview_size: int = cfg.preview_size + self._intensity: float = cfg.default_intensity + self._slider_debounce_ms: int = cfg.slider_debounce_ms + + self.content_image: np.ndarray | None = None + self.style_image: np.ndarray | None = None + self.result_image: np.ndarray | None = None + self.styled_image: np.ndarray | None = None + self.intensity: float = self._intensity + self.transfer_method: str = cfg.default_method self.update_timer: QTimer = QTimer() self.update_timer.setSingleShot(True) self.update_timer.timeout.connect(self.apply_intensity_blend) - self.setFixedSize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) + self.setFixedSize(self._window_width, self._window_height) self.initUI() def initUI(self) -> None: @@ -64,7 +135,7 @@ def initUI(self) -> None: self.result_label: QLabel = QLabel(self) for label in [self.content_label, self.style_label, self.result_label]: - label.setMinimumSize(PREVIEW_SIZE, PREVIEW_SIZE) + label.setMinimumSize(self._preview_size, self._preview_size) label.setStyleSheet("border: 1px solid gray;") label.setAlignment(QtCore.Qt.AlignCenter) label.setText("No image loaded") @@ -72,12 +143,11 @@ def initUI(self) -> None: self.intensity_slider: QSlider = QSlider(QtCore.Qt.Horizontal, self) self.intensity_slider.setMinimum(0) self.intensity_slider.setMaximum(100) - self.intensity_slider.setValue(int(DEFAULT_INTENSITY * 100)) + self.intensity_slider.setValue(int(self._intensity * 100)) self.intensity_slider.setTickPosition(QSlider.TicksBelow) self.intensity_slider.setTickInterval(10) self.intensity_slider.valueChanged.connect(self.update_intensity) - self.intensity_slider.setStyleSheet( - """ + self.intensity_slider.setStyleSheet(""" QSlider::groove:horizontal { height: 9px; background: #E0E0E0; @@ -91,10 +161,9 @@ def initUI(self) -> None: QSlider::handle:horizontal:hover { background: #606060; } - """ - ) + """) - self.intensity_label: QLabel = QLabel(f"{int(DEFAULT_INTENSITY * 100)}%", self) + self.intensity_label: QLabel = QLabel(f"{int(self._intensity * 100)}%", self) self.intensity_label.setAlignment(QtCore.Qt.AlignCenter) self.method_combo: QComboBox = QComboBox(self) @@ -142,19 +211,21 @@ def _entry(label: str, data: str) -> QStandardItem: self.method_combo.setCurrentIndex(1) # start on "Histogram Match" self.method_combo.currentIndexChanged.connect(self.on_method_changed) - self.load_content_button: QPushButton = QPushButton( - "Load Content Image", self - ) + self.load_content_button: QPushButton = QPushButton("Load Content Image", self) self.load_style_button: QPushButton = QPushButton("Load Style Image", self) self.apply_button: QPushButton = QPushButton("Apply Style Transfer", self) self.clear_button: QPushButton = QPushButton("Clear Images", self) self.save_button: QPushButton = QPushButton("Save Result", self) + self.dashboard_button: QPushButton = QPushButton("Dashboard", self) + self.compare_button: QPushButton = QPushButton("Compare Methods", self) self.load_content_button.clicked.connect(self.load_content_image) self.load_style_button.clicked.connect(self.load_style_image) self.apply_button.clicked.connect(self.apply_style_transfer) self.clear_button.clicked.connect(self.clear_images) self.save_button.clicked.connect(self.save_result) + self.dashboard_button.clicked.connect(self.show_dashboard) + self.compare_button.clicked.connect(self.show_comparison) main_layout: QVBoxLayout = QVBoxLayout() method_layout: QHBoxLayout = QHBoxLayout() @@ -193,6 +264,9 @@ def _entry(label: str, data: str) -> QStandardItem: button_layout.setSpacing(20) button_layout.addWidget(self.clear_button) + button_layout.addWidget(self.dashboard_button) + button_layout.addWidget(self.compare_button) + button_layout.addStretch() button_layout.addWidget(self.save_button) main_layout.addLayout(method_layout) @@ -205,17 +279,15 @@ def _entry(label: str, data: str) -> QStandardItem: def _load_image_file( self, path: str, - image_attr: str, - preview_label: QLabel, image_type: str, + preview_label: QLabel, ) -> None: """Load an image file, reset stale outputs, and update the UI. Args: path: Path to the image file. - image_attr: Name of the instance attribute to assign the array. - preview_label: Label widget to display the loaded image. image_type: Human-readable role ('content' or 'style'). + preview_label: Label widget to display the loaded image. """ try: img, meta = load_image_with_meta(path) @@ -224,15 +296,16 @@ def _load_image_file( self.result_image = None self.result_label.clear() self.result_label.setText("No image loaded") - setattr(self, image_attr, img) + if image_type == "content": + self.content_image = img + else: + self.style_image = img self.show_image(img, preview_label) self.show_conversion_info(image_type, meta) except (FileNotFoundError, ImageProcessingError) as e: - QMessageBox.critical( - self, "Error", f"Failed to load {image_type} image: {e!s}" - ) + QMessageBox.critical(self, "Error", f"Failed to load {image_type} image: {e!s}") logger.error("Failed to load %s image: %s", image_type, e) - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001 — user-facing catch; QMessageBox reports the error QMessageBox.critical( self, "Error", @@ -246,12 +319,10 @@ def load_content_image(self) -> None: self, "Open Content Image", "", - "Image Files (*.png *.jpg *.jpeg *.bmp *.tif *.tiff)", + f"Image Files ({' '.join('*' + ext for ext in ALLOWED_IMAGE_EXTENSIONS)})", ) if content_path: - self._load_image_file( - content_path, "content_image", self.content_label, "content" - ) + self._load_image_file(content_path, "content", self.content_label) def load_style_image(self) -> None: """Load style image from file dialog.""" @@ -259,12 +330,10 @@ def load_style_image(self) -> None: self, "Open Style Image", "", - "Image Files (*.png *.jpg *.jpeg *.bmp *.tif *.tiff)", + f"Image Files ({' '.join('*' + ext for ext in ALLOWED_IMAGE_EXTENSIONS)})", ) if style_path: - self._load_image_file( - style_path, "style_image", self.style_label, "style" - ) + self._load_image_file(style_path, "style", self.style_label) def show_conversion_info(self, image_type: str, meta: ImageMeta) -> None: """Show information about any image conversions that occurred. @@ -280,8 +349,7 @@ def show_conversion_info(self, image_type: str, meta: ImageMeta) -> None: QMessageBox.information( self, "Image Conversion", - f"Grayscale {image_type} image automatically " - f"converted to RGB for processing.", + f"Grayscale {image_type} image automatically " f"converted to RGB for processing.", ) elif meta.original_ndim == 3 and meta.original_channels == 4: QMessageBox.information( @@ -300,17 +368,12 @@ def update_intensity(self, value: int) -> None: self.intensity = value / 100.0 self.intensity_label.setText(f"{value}%") if self.content_image is not None and self.styled_image is not None: - self.update_timer.start(SLIDER_DEBOUNCE_MS) + self.update_timer.start(self._slider_debounce_ms) def apply_intensity_blend(self) -> None: """Apply intensity blending to result image.""" - if ( - self.content_image is not None - and self.styled_image is not None - ): - self.result_image = blend_images( - self.content_image, self.styled_image, self.intensity - ) + if self.content_image is not None and self.styled_image is not None: + self.result_image = blend_images(self.content_image, self.styled_image, self.intensity) self.show_image(self.result_image, self.result_label) def on_method_changed(self, index: int) -> None: @@ -361,17 +424,17 @@ def apply_style_transfer(self) -> None: try: # For simulators and Daltonizers the style image is unused, # so style_image may be None here. - self.styled_image = method.transfer( - self.content_image, self.style_image + self.styled_image = registry.transfer_cached( + self.transfer_method, self.content_image, self.style_image ) self.result_image = blend_images( self.content_image, self.styled_image, self.intensity ) self.show_image(self.result_image, self.result_label) - except Exception as e: - QMessageBox.critical( - self, "Error", f"Failed to apply style transfer: {str(e)}" - ) + except ( + Exception + ) as e: # noqa: BLE001 — user-facing catch; QMessageBox reports the error + QMessageBox.critical(self, "Error", f"Failed to apply style transfer: {str(e)}") logger.error(f"Style transfer failed: {e}") else: if not method.requires_reference: @@ -418,10 +481,10 @@ def save_result(self) -> None: f"Image successfully saved to: {save_path}", ) logger.info(f"Image saved to: {save_path}") - except Exception as e: - QMessageBox.critical( - self, "Error", f"Failed to save image: {str(e)}" - ) + except ( + Exception + ) as e: # noqa: BLE001 — user-facing catch; QMessageBox reports the error + QMessageBox.critical(self, "Error", f"Failed to save image: {str(e)}") logger.error(f"Failed to save image: {e}") else: QMessageBox.warning( @@ -430,39 +493,298 @@ def save_result(self) -> None: "No result image to save. Please apply style transfer first.", ) - def show_image(self, img_array: np.ndarray, label: QLabel) -> None: - """Display image array in QLabel. + def show_comparison(self) -> None: + """Launch the method comparison dialog for the current content and style images.""" + if self.content_image is None: + QMessageBox.warning( + self, + "No Content Image", + "Please load a content image before comparing methods.", + ) + return + if self.style_image is None: + QMessageBox.warning( + self, + "No Style Image", + "Please load a style image before comparing methods.", + ) + return - Args: - img_array: Image array to display (float [0,1] or uint8 [0,255]) - label: QLabel widget to display image in + dialog = CompareMethodsDialog(self.content_image, self.style_image, self) + dialog.exec_() - Raises: - ValueError: If image format is unsupported for display - """ - img_array = np.clip(img_array, 0, 1) - img_array = (img_array * 255).astype(np.uint8) - - if img_array.ndim == 2: - h, w = img_array.shape - bytes_per_line = w - qt_image = QImage( - img_array.data, w, h, bytes_per_line, QImage.Format_Grayscale8 - ) - elif img_array.ndim == 3 and img_array.shape[2] == 3: - h, w, ch = img_array.shape - bytes_per_line = ch * w - qt_image = QImage( - img_array.data, w, h, bytes_per_line, QImage.Format_RGB888 + def show_dashboard(self) -> None: + """Launch the CVD accessibility dashboard for the current content image.""" + if self.content_image is None: + QMessageBox.warning( + self, + "No Content Image", + "Please load a content image before opening the Dashboard.", ) + return + + dialog = DashboardDialog(self.content_image, self) + dialog.exec_() + + def show_image(self, img_array: np.ndarray, label: QLabel) -> None: + """Display image array in QLabel.""" + label.setPixmap(_array_to_pixmap(img_array, self._preview_size)) + + +class DashboardDialog(QDialog): + """Modal dialog displaying the CVD accessibility dashboard.""" + + _CELL_SIZE: int = 220 + + def __init__(self, content_image: np.ndarray, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._content_image = content_image + self._result: DashboardResult | None = None + self._label_widgets: dict[str, QLabel] = {} + self._summary_label: QLabel | None = None + self._report_button: QPushButton | None = None + + self.setWindowTitle("CVD Accessibility Dashboard") + + # Match the parent window's size if available, otherwise use a + # sensible default that fits the 2×3 grid comfortably. + if parent is not None: + self.resize(parent.width(), parent.height() + 100) else: - raise ValueError(f"Unsupported image format for display: {img_array.shape}") + self.resize(1000, 800) + self._build_ui() + self._start_computation() + + def _build_ui(self) -> None: + main_layout = QVBoxLayout(self) + + self._status_label = QLabel("Computing simulations — please wait…", self) + self._status_label.setAlignment(QtCore.Qt.AlignCenter) + main_layout.addWidget(self._status_label) + + grid = QGridLayout() + grid.setSpacing(12) + main_layout.addLayout(grid) + + # Row 0: [Original] — centred, alone + # Row 1: [Protanopia] [Deuteranopia] [Tritanopia] + # Row 2: [Chroma Loss (P)] [Chroma Loss (D)] [Chroma Loss (T)] + positions: list[tuple[int, int, str, str]] = [ + (0, 1, "original", "Original"), + (1, 0, "protanopia", _DEFICIENCY_LABELS["protanopia"]), + (1, 1, "deuteranopia", _DEFICIENCY_LABELS["deuteranopia"]), + (1, 2, "tritanopia", _DEFICIENCY_LABELS["tritanopia"]), + (2, 0, "heatmap_protanopia", "Chroma Loss (P)"), + (2, 1, "heatmap_deuteranopia", "Chroma Loss (D)"), + (2, 2, "heatmap_tritanopia", "Chroma Loss (T)"), + ] + for row, col, key, label_text in positions: + label = QLabel(self) + label.setFixedSize(self._CELL_SIZE, self._CELL_SIZE) + label.setAlignment(QtCore.Qt.AlignCenter) + label.setStyleSheet("border: 1px solid gray;") + self._label_widgets[key] = label + container = QVBoxLayout() + container.addWidget(label, alignment=QtCore.Qt.AlignCenter) + title = QLabel(label_text, self) + title.setAlignment(QtCore.Qt.AlignCenter) + title.setWordWrap(True) + container.addWidget(title) + grid.addLayout(container, row, col) + + # Summary panel + self._summary_label = QLabel("", self) + font = QtGui.QFont("Courier New", 11) + font.setStyleHint(QtGui.QFont.Monospace) + self._summary_label.setFont(font) + self._summary_label.setStyleSheet("margin: 8px 4px; padding: 6px;") + main_layout.addWidget(self._summary_label) + + # Bottom buttons + button_layout = QHBoxLayout() + self._report_button = QPushButton("Generate Report", self) + self._report_button.clicked.connect(self._export_report) + self._report_button.setEnabled(False) + close_button = QPushButton("Close", self) + close_button.clicked.connect(self.accept) + + button_layout.addStretch() + button_layout.addWidget(close_button) + button_layout.addWidget(self._report_button) + main_layout.addLayout(button_layout) - label.setPixmap( - QPixmap.fromImage(qt_image).scaled( - PREVIEW_SIZE, PREVIEW_SIZE, QtCore.Qt.KeepAspectRatio - ) + def _start_computation(self) -> None: + self._error: str | None = None + + def _compute() -> None: + from colorcast.analysis.dashboard import compute_dashboard + + try: + self._result = compute_dashboard(self._content_image) + except Exception as e: # noqa: BLE001 — surfaced in the dialog + logger.exception("Dashboard computation failed") + self._error = str(e) + self._result = None + + def _on_done() -> None: + if self._error is not None: + self._status_label.setText(f"Computation failed: {self._error}") + else: + self._status_label.setText("Done.") + self._populate_results() + + _run_in_thread(_compute, on_done=_on_done) + + def _populate_results(self) -> None: + if self._result is None: + return + + result = self._result + + def _show(array: np.ndarray, key: str) -> None: + widget = self._label_widgets.get(key) + if widget is None: + return + normalize = key.startswith("heatmap_") + widget.setPixmap(_array_to_pixmap(array, self._CELL_SIZE, normalize_heatmap=normalize)) + + _show(result.original, "original") + for deficiency in _DEFICIENCIES: + _show(result.simulated[deficiency], deficiency) + + # Chroma-loss heatmaps — use the chroma_error field rendered as + # a grayscale hot image + for key, deficiency in [ + ("heatmap_protanopia", "protanopia"), + ("heatmap_deuteranopia", "deuteranopia"), + ("heatmap_tritanopia", "tritanopia"), + ]: + em = result.error_maps.get(deficiency) + if em is not None: + _show(em.chroma_error, key) + + self._update_summary(result) + if self._report_button is not None: + self._report_button.setEnabled(True) + + def _update_summary(self, result: DashboardResult) -> None: + if self._summary_label is not None: + self._summary_label.setText(format_summary_table(result)) + + def _export_report(self) -> None: + if self._result is None: + return + save_path, _ = QFileDialog.getSaveFileName( + self, + "Save Dashboard Report", + "dashboard_report.png", + "PNG Files (*.png);;All Files (*.*)", ) + if not save_path: + return + from colorcast.analysis.dashboard import generate_dashboard_report + + try: + generate_dashboard_report(self._result, save_path) + except Exception as e: # noqa: BLE001 — user-facing catch + logger.exception("Failed to generate dashboard report") + QMessageBox.critical(self, "Error", f"Failed to save report: {e}") + return + QMessageBox.information(self, "Report Saved", f"Dashboard report saved to:\n{save_path}") + + +class CompareMethodsDialog(QDialog): + """Dialog that runs every style-transfer method and shows a 3×3 grid comparison.""" + + _CELL_SIZE: int = 280 + + def __init__( + self, + content_image: np.ndarray, + style_image: np.ndarray, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._content_image = content_image + self._style_image = style_image + self._label_widgets: dict[str, QLabel] = {} + self._results: dict[str, np.ndarray] = {} + + self.setWindowTitle("Compare Transfer Methods") + if parent is not None: + self.resize(parent.width(), parent.height()) + else: + self.resize(1000, 800) + + self._build_ui() + self._start_computation() + + def _build_ui(self) -> None: + main_layout = QVBoxLayout(self) + + self._status_label = QLabel("Computing transfers — please wait…", self) + self._status_label.setAlignment(QtCore.Qt.AlignCenter) + main_layout.addWidget(self._status_label) + + grid = QGridLayout() + grid.setSpacing(12) + main_layout.addLayout(grid) + + method_ids = [ + m for m in registry.list_methods() if registry.get_method(m).requires_reference + ] + for idx, method_id in enumerate(method_ids): + row, col = divmod(idx, 3) + label = QLabel(self) + label.setFixedSize(self._CELL_SIZE, self._CELL_SIZE) + label.setAlignment(QtCore.Qt.AlignCenter) + label.setStyleSheet("border: 1px solid gray;") + self._label_widgets[method_id] = label + container = QVBoxLayout() + container.addWidget(label, alignment=QtCore.Qt.AlignCenter) + title = QLabel(method_id, self) + title.setAlignment(QtCore.Qt.AlignCenter) + title.setWordWrap(True) + container.addWidget(title) + grid.addLayout(container, row, col) + + button_layout = QHBoxLayout() + close_button = QPushButton("Close", self) + close_button.clicked.connect(self.accept) + button_layout.addStretch() + button_layout.addWidget(close_button) + main_layout.addLayout(button_layout) + + def _start_computation(self) -> None: + def _run() -> None: + for method_id in self._label_widgets: + try: + instance = registry.get_method(method_id) + self._results[method_id] = instance.transfer( + self._content_image, self._style_image + ) + except Exception: # noqa: BLE001 — skip unsupported methods + logger.debug("Skipped method %s in comparison", method_id) + + _run_in_thread(_run, on_done=self._populate_results) + + def _populate_results(self) -> None: + for method_id in self._label_widgets: + self._show_one(method_id) + self._on_computation_done() + + def _show_one(self, method_id: str) -> None: + widget = self._label_widgets.get(method_id) + result = self._results.get(method_id) + if widget is None or result is None: + return + widget.setPixmap(_array_to_pixmap(result, self._CELL_SIZE)) + widget.repaint() + + def _on_computation_done(self) -> None: + done = sum(1 for v in self._results.values() if v is not None) + total = len(self._label_widgets) + self._status_label.setText(f"Done — {done} of {total} methods completed.") def main() -> None: diff --git a/colorcast/processing/__init__.py b/colorcast/processing/__init__.py index 9f942ac..562ccc1 100644 --- a/colorcast/processing/__init__.py +++ b/colorcast/processing/__init__.py @@ -1,16 +1,16 @@ """Image processing modules for ColorCast.""" -from colorcast.processing.image_loader import load_image, ensure_rgb +from colorcast.processing.blending import blend_images +from colorcast.processing.curves import apply_curve +from colorcast.processing.image_loader import ensure_rgb, load_image +from colorcast.processing.registry import registry +from colorcast.processing.simulation import ColorBlindSimulator from colorcast.processing.transfer_methods import ( - match_histograms_multichannel, color_transfer_meanstd, lut_transfer_with_curve, + match_histograms_multichannel, selective_color_transfer, ) -from colorcast.processing.curves import apply_curve -from colorcast.processing.blending import blend_images -from colorcast.processing.registry import registry -from colorcast.processing.simulation import ColorBlindSimulator __all__ = [ "load_image", @@ -23,4 +23,4 @@ "blend_images", "registry", "ColorBlindSimulator", -] \ No newline at end of file +] diff --git a/colorcast/processing/batch.py b/colorcast/processing/batch.py index 1132977..51f5e49 100644 --- a/colorcast/processing/batch.py +++ b/colorcast/processing/batch.py @@ -1,9 +1,10 @@ """Batch processing utilities for ColorCast.""" import logging -from pathlib import Path -from typing import List, Callable, Optional, Tuple +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + from colorcast.processing.image_loader import load_image, save_image from colorcast.utils.exceptions import ImageLoadError @@ -11,14 +12,20 @@ class BatchProcessor: - """Process multiple images in batch with error handling and logging.""" + """Process multiple images in batch with error handling and logging. + + Instances are safe for sequential reuse — call + :meth:`process_directory` as many times as needed. Do not share a single + instance across overlapping concurrent calls; ``self.failed_files`` is + reassigned (not appended) on each run. + """ def __init__( self, transfer_method: Callable, max_workers: int = 4, enable_parallel: bool = True, - progress_callback: Optional[Callable[[int, int], None]] = None, + progress_callback: Callable[[int, int], None] | None = None, ): """ Initialize batch processor. @@ -34,15 +41,15 @@ def __init__( self.transfer_method = transfer_method self.max_workers = 1 if not enable_parallel else max_workers self.progress_callback = progress_callback - self.failed_files: List[Tuple[Path, str]] = [] + self.failed_files: list[tuple[Path, str]] = [] def process_directory( self, content_dir: Path, - style_image: Optional[Path], + style_image: Path | None, output_dir: Path, pattern: str = "*.jpg", - ) -> List[Path]: + ) -> list[Path]: """ Process all images in directory with same style. @@ -56,6 +63,8 @@ def process_directory( Returns: List of output file paths """ + self.failed_files = [] + content_dir = Path(content_dir) output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) @@ -69,7 +78,7 @@ def process_directory( def process_single( content_path: Path, - ) -> Tuple[Optional[Path], Optional[Tuple[Path, str]]]: + ) -> tuple[Path | None, tuple[Path, str] | None]: """Process single image with error handling. Returns: @@ -88,7 +97,7 @@ def process_single( logger.info(f"Successfully processed: {content_path.name}") return output_path, None - except ImageLoadError as e: + except (ImageLoadError, FileNotFoundError, OSError) as e: error_msg = f"Image load error: {e!s}" logger.error(f"Failed to process {content_path.name}: {error_msg}") return None, (content_path, error_msg) @@ -122,76 +131,3 @@ def process_single( self.progress_callback(success_count, total) return results - - def process_pairs( - self, - image_pairs: List[tuple], - output_dir: Path, - ) -> List[Path]: - """ - Process content/style image pairs. - - Args: - image_pairs: List of (content_path, style_path) tuples - output_dir: Directory for results - - Returns: - List of output file paths - """ - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - results = [] - failed_pairs = [] - - # Process each pair sequentially. Unlike process_directory, every - # pair carries its own style image so the shared-style-image - # parallelisation pattern does not apply. - for i, (content_path, style_path) in enumerate(image_pairs): - try: - content = load_image(str(content_path)) - style = load_image(str(style_path)) - - result = self.transfer_method(content, style) - - output_path = output_dir / f"result_{i:04d}.jpg" - save_image(result, str(output_path)) - - logger.info( - f"Successfully processed pair {i+1}: " - f"{content_path.name} + {style_path.name}" - ) - - results.append(output_path) - except ImageLoadError as e: - error_msg = f"Image load error: {e!s}" - logger.error( - f"Failed to process pair {i+1} " - f"({content_path.name}, {style_path.name}): {error_msg}" - ) - failed_pairs.append(((content_path, style_path), error_msg)) - except Exception as e: # noqa: BLE001 - # Broad on purpose: each pair is isolated so one bad image - # cannot abort the rest of the batch. - error_msg = f"Unexpected error: {e!s}" - logger.error( - f"Failed to process pair {i+1} " - f"({content_path.name}, {style_path.name}): {error_msg}" - ) - failed_pairs.append(((content_path, style_path), error_msg)) - - if self.progress_callback: - self.progress_callback(i + 1, len(image_pairs)) - - # Log summary - success_count = len(results) - failure_count = len(failed_pairs) - logger.info( - f"Pair processing complete: {success_count} successful, " - f"{failure_count} failed out of {len(image_pairs)} total pairs" - ) - - # Store failed pairs for reporting - self.failed_files = failed_pairs - - return results diff --git a/colorcast/processing/blending.py b/colorcast/processing/blending.py index cb15d5a..1098324 100644 --- a/colorcast/processing/blending.py +++ b/colorcast/processing/blending.py @@ -18,4 +18,4 @@ def blend_images(original: np.ndarray, styled: np.ndarray, intensity: float) -> """ intensity = np.clip(intensity, 0.0, 1.0) blended = original * (1 - intensity) + styled * intensity - return np.clip(blended, 0.0, 1.0).astype(original.dtype) \ No newline at end of file + return np.clip(blended, 0.0, 1.0).astype(original.dtype) diff --git a/colorcast/processing/cache.py b/colorcast/processing/cache.py index 165970e..2a3ce90 100644 --- a/colorcast/processing/cache.py +++ b/colorcast/processing/cache.py @@ -3,7 +3,8 @@ import hashlib import threading from collections import OrderedDict -from typing import Callable, Optional +from collections.abc import Callable + import numpy as np #: Key component used for methods that do not take a reference (style) image. @@ -31,6 +32,31 @@ def __init__(self, max_size: int = 8): self.misses = 0 self._lock = threading.Lock() + def generate_key( + self, + content: np.ndarray, + style: np.ndarray | None, + method: str, + params: dict | None = None, + ) -> str: + """Create a cache key for the supplied image and method inputs. + + The content image is hashed from its full pixel values, while the style + image is hashed only when provided. A ``None`` style contributes the + fixed reference-free marker ``no-reference`` to the key, and omitted + parameters are treated as an empty mapping so they do not alter the + resulting key beyond the method and image content. + + Returns: + A colon-delimited cache key of the form + ``:::``. + """ + if params is None: + params = {} + + content_hash, style_hash = self._compute_fingerprints(content, style) + return self._generate_key(content_hash, style_hash, method, params) + def _generate_key( self, content_hash: str, @@ -54,14 +80,21 @@ def _generate_key( params_str = str(sorted(params.items())) return f"{content_hash}:{style_hash}:{method}:{params_str}" + def _compute_fingerprints( + self, + content: np.ndarray, + style: np.ndarray | None, + ) -> tuple[str, str]: + """Compute the content and style fingerprints for a cache lookup.""" + return self._compute_hash(content), self._style_hash(style) + def _compute_hash(self, img: np.ndarray) -> str: """ Compute hash of image array. - Hashes a coarse fingerprint (shape, dtype, strides, and a - downsampled view of the pixel data) rather than the full image - bytes. The collision risk is negligible for the cache sizes this - module targets. + Hash the full image contents, including every pixel value, so any + content change produces a different cache key while preserving the + existing cache semantics. Args: img: Image array to hash @@ -69,16 +102,13 @@ def _compute_hash(self, img: np.ndarray) -> str: Returns: First 16 characters of MD5 hash """ - stride = max(1, min(img.shape[0], img.shape[1]) // 64) - subsample = img[::stride, ::stride] - h = hashlib.md5() + h = hashlib.md5(usedforsecurity=False) h.update(str(img.shape).encode()) h.update(str(img.dtype).encode()) - h.update(str(img.strides).encode()) - h.update(subsample.tobytes()) + h.update(np.ascontiguousarray(img).tobytes(order="C")) return h.hexdigest()[:16] - def _style_hash(self, style: Optional[np.ndarray]) -> str: + def _style_hash(self, style: np.ndarray | None) -> str: """Hash the style image, or return the reference-free marker.""" if style is None: return _NO_REFERENCE @@ -87,10 +117,10 @@ def _style_hash(self, style: Optional[np.ndarray]) -> str: def get( self, content: np.ndarray, - style: Optional[np.ndarray], + style: np.ndarray | None, method: str, - params: Optional[dict] = None, - ) -> Optional[np.ndarray]: + params: dict | None = None, + ) -> np.ndarray | None: """ Retrieve cached styled image. @@ -106,18 +136,14 @@ def get( if params is None: params = {} - key = self._generate_key( - self._compute_hash(content), - self._style_hash(style), - method, - params, - ) + content_hash, style_hash = self._compute_fingerprints(content, style) + key = self._generate_key(content_hash, style_hash, method, params) with self._lock: if key in self.cache: self.cache.move_to_end(key) self.hits += 1 - return self.cache[key] + return self.cache[key].copy() self.misses += 1 return None @@ -125,10 +151,10 @@ def get( def set( self, content: np.ndarray, - style: Optional[np.ndarray], + style: np.ndarray | None, method: str, styled: np.ndarray, - params: Optional[dict] = None, + params: dict | None = None, ) -> None: """ Cache styled image with LRU eviction. @@ -143,12 +169,8 @@ def set( if params is None: params = {} - key = self._generate_key( - self._compute_hash(content), - self._style_hash(style), - method, - params, - ) + content_hash, style_hash = self._compute_fingerprints(content, style) + key = self._generate_key(content_hash, style_hash, method, params) with self._lock: if key in self.cache: @@ -156,12 +178,14 @@ def set( elif len(self.cache) >= self.max_size: self.cache.popitem(last=False) - self.cache[key] = styled + self.cache[key] = styled.copy() def clear(self) -> None: - """Clear all cached images.""" + """Clear all cached images and reset hit/miss statistics.""" with self._lock: self.cache.clear() + self.hits = 0 + self.misses = 0 def size(self) -> int: """ @@ -208,7 +232,7 @@ def get_or_compute( if key in self.cache: self.cache.move_to_end(key) self.hits += 1 - return self.cache[key] + return self.cache[key].copy() value: np.ndarray = compute_func() @@ -216,14 +240,13 @@ def get_or_compute( self.misses += 1 if key in self.cache: self.cache.move_to_end(key) - return self.cache[key] + return self.cache[key].copy() if len(self.cache) >= self.max_size: self.cache.popitem(last=False) - self.cache[key] = value + self.cache[key] = value.copy() return value -# Alias for backward compatibility LRUCache = StyleTransferCache diff --git a/colorcast/processing/curves.py b/colorcast/processing/curves.py index df2d661..b484995 100644 --- a/colorcast/processing/curves.py +++ b/colorcast/processing/curves.py @@ -1,10 +1,14 @@ """Tone curve utilities for color transfer.""" from typing import Literal + import numpy as np -def apply_curve(values: np.ndarray, curve_type: Literal["linear", "s-curve", "contrast"] = "linear") -> np.ndarray: +def apply_curve( + values: np.ndarray, + curve_type: Literal["linear", "s-curve", "contrast"] = "linear", +) -> np.ndarray: """ Apply tone curve to values. @@ -24,4 +28,4 @@ def apply_curve(values: np.ndarray, curve_type: Literal["linear", "s-curve", "co # Power curve for increased contrast return np.power(values, 0.8) else: - raise ValueError(f"Unknown curve type: {curve_type}") \ No newline at end of file + raise ValueError(f"Unknown curve type: {curve_type}") diff --git a/colorcast/processing/gpu_transfer.py b/colorcast/processing/gpu_transfer.py index 8bf6c5b..4247376 100644 --- a/colorcast/processing/gpu_transfer.py +++ b/colorcast/processing/gpu_transfer.py @@ -1,88 +1,78 @@ """ -GPU-accelerated color transfer using CuPy. - -This module provides GPU implementations of color transfer algorithms -for improved performance on large images and batch processing. +Color transfer functions with CuPy-backed acceleration where available. + +The ``gpu_`` prefix is historical: these entry points accept numpy arrays +and return numpy arrays regardless of whether CuPy is installed. When +CuPy is absent every function falls back to the CPU path transparently. +Only ``gpu_mean_std_transfer`` and ``gpu_lab_transfer`` exercise genuine GPU +kernels; the +remaining functions route through scikit-image on the CPU and are kept +under their existing names so callers do not need to change import paths +when GPU kernels land. + +Use ``is_gpu_available()`` to check at runtime whether the accelerator +will be used. """ import numpy as np +from colorcast.processing.transfer_methods import ( + _EPSILON, + _LAB_AB_BOUNDS, + _LAB_L_BOUNDS, + _meanstd_transfer, + validate_and_resize_images, +) + # Try to import CuPy, fallback gracefully if not available try: import cupy as cp + HAS_CUPY = True except ImportError: HAS_CUPY = False cp = None # Type stub for mypy - import warnings - warnings.warn("CuPy is not installed. GPU acceleration will be disabled.") -def gpu_histogram_matching( - source: np.ndarray, - reference: np.ndarray -) -> np.ndarray: +def is_gpu_available() -> bool: + """Check whether CuPy is installed and a usable CUDA device is present.""" + if not HAS_CUPY: + return False + try: + return cp.cuda.runtime.getDeviceCount() > 0 + except cp.cuda.runtime.CUDARuntimeError: + return False + + +def gpu_histogram_matching(source: np.ndarray, reference: np.ndarray) -> np.ndarray: """ - GPU-accelerated histogram matching using CuPy. + Histogram matching (CPU-only until a native CuPy kernel lands). - Note: The CuPy path currently copies channel data to the CPU for - histogram matching via scikit-image and transfers results back. - It is therefore no faster than the CPU fallback. A native GPU - histogram implementation is planned. + Both CuPy-available and fallback paths currently route through + scikit-image on the CPU. The function is kept as a named entry point + so callers do not need to change when GPU acceleration arrives. Args: - source: Source image array (H, W, 3) in range [0, 255] - reference: Reference image array (H, W, 3) in range [0, 255] + source: Source image array (H, W, 3), any numeric dtype. + reference: Reference image array (H, W, 3), any numeric dtype. Returns: - Matched image array (H, W, 3) in range [0, 255] + Matched image array (H, W, 3), float32 in range [0, 1]. """ - if not HAS_CUPY: - # Fallback to CPU implementation - from skimage import exposure - result = np.empty_like(source) - for i in range(3): - result[:, :, i] = exposure.match_histograms( - source[:, :, i], reference[:, :, i] - ) - return result.astype(source.dtype) - - # Convert to GPU arrays (float32) - source_gpu = cp.asarray(source, dtype=cp.float32) - reference_gpu = cp.asarray(reference, dtype=cp.float32) - - # Compute histogram matching per channel on GPU - matched_gpu = cp.empty_like(source_gpu) + from skimage import exposure + + source, reference = validate_and_resize_images(source, reference) + + result = np.empty_like(source) for i in range(3): - # Extract channels - source_channel = source_gpu[:, :, i] - reference_channel = reference_gpu[:, :, i] - - # Match histograms using scikit-image compatible implementation - # For now, use CPU for histogram matching as it's complex - # but move data back to CPU for the matching operation - source_cpu = cp.asnumpy(source_channel) - reference_cpu = cp.asnumpy(reference_channel) - - from skimage import exposure - matched_channel = exposure.match_histograms( - source_cpu, reference_cpu - ) - - matched_gpu[:, :, i] = cp.asarray(matched_channel, dtype=cp.float32) - - # Transfer result back to CPU - result = cp.asnumpy(matched_gpu).astype(source.dtype) - return result + result[:, :, i] = exposure.match_histograms(source[:, :, i], reference[:, :, i]) + return result.astype(source.dtype) -def gpu_mean_std_transfer( - source: np.ndarray, - reference: np.ndarray -) -> np.ndarray: +def gpu_mean_std_transfer(source: np.ndarray, reference: np.ndarray) -> np.ndarray: """ GPU-accelerated mean and standard deviation transfer using CuPy. - + Args: source: Source image array (H, W, 3) in range [0, 1] reference: Reference image array (H, W, 3) in range [0, 1] @@ -90,23 +80,12 @@ def gpu_mean_std_transfer( Returns: Transferred image array (H, W, 3) in range [0, 1] """ - if not HAS_CUPY: - # Fallback to CPU implementation - result = np.empty_like(source) - for i in range(3): - source_mean = np.mean(source[:, :, i]) - source_std = np.std(source[:, :, i]) - ref_mean = np.mean(reference[:, :, i]) - ref_std = np.std(reference[:, :, i]) - - epsilon = 1e-8 - result[:, :, i] = ( - (source[:, :, i] - source_mean) - * (ref_std / (source_std + epsilon)) - ) + ref_mean - + source, reference = validate_and_resize_images(source, reference) + + if not is_gpu_available(): + result = _meanstd_transfer(source, reference) return np.clip(result, 0, 1).astype(source.dtype) - + # Convert to GPU arrays (images are already float32 in [0, 1]) source_gpu = cp.asarray(source, dtype=cp.float32) reference_gpu = cp.asarray(reference, dtype=cp.float32) @@ -124,10 +103,8 @@ def gpu_mean_std_transfer( ref_std = cp.std(reference_channel) # Apply transfer - epsilon = 1e-8 result_gpu[:, :, i] = ( - (source_channel - source_mean) - * (ref_std / (source_std + epsilon)) + (source_channel - source_mean) * (ref_std / (source_std + _EPSILON)) ) + ref_mean # Clip to [0, 1] @@ -146,9 +123,10 @@ def gpu_lab_transfer( """ GPU-accelerated Lab color space transfer using CuPy. - Note: CuPy has no rgb2lab/lab2rgb equivalent, so both the GPU and - CPU branches route through scikit-image on the CPU. A native GPU - Lab pipeline is planned. + Lab conversion and back-conversion run on CPU (no CuPy equivalent in + scikit-image). The per-channel mean/standard-deviation transfer, + channel-wise clipping, and alpha blending are offloaded to CuPy when a + GPU is present. Args: source: Source image array (H, W, 3) in range [0, 1] @@ -158,112 +136,59 @@ def gpu_lab_transfer( Returns: Transferred image array (H, W, 3) in range [0, 1] """ - if not HAS_CUPY: - # Fallback to CPU implementation - from skimage import color - - # Clamp alpha - alpha = np.clip(alpha, 0.0, 1.0) - - # Convert RGB to Lab color space - source_lab = color.rgb2lab(source) - reference_lab = color.rgb2lab(reference) - - # Compute statistics in Lab space - result_lab = np.empty_like(source_lab) - for i in range(3): - # Compute mean and std for source and reference - source_mean = np.mean(source_lab[:, :, i]) - source_std = np.std(source_lab[:, :, i]) - ref_mean = np.mean(reference_lab[:, :, i]) - ref_std = np.std(reference_lab[:, :, i]) - - # Apply statistical transfer - epsilon = 1e-8 - result_lab[:, :, i] = ( - (source_lab[:, :, i] - source_mean) - * (ref_std / (source_std + epsilon)) - ) + ref_mean - - # Clip to valid Lab ranges - result_lab[:, :, 0] = np.clip(result_lab[:, :, 0], 0, 100) # L channel - result_lab[:, :, 1] = np.clip(result_lab[:, :, 1], -128, 127) # a channel - result_lab[:, :, 2] = np.clip(result_lab[:, :, 2], -128, 127) # b channel - - # Apply alpha blending for partial transfer - if alpha < 1.0: - result_lab = source_lab * (1 - alpha) + result_lab * alpha - - # Convert Lab back to RGB - result_rgb = color.lab2rgb(result_lab) + source, reference = validate_and_resize_images(source, reference) + alpha = float(np.clip(alpha, 0.0, 1.0)) - # Ensure result is in valid range [0, 1] - result_rgb = np.clip(result_rgb, 0, 1).astype(source.dtype) - - return result_rgb - - # CuPy has no rgb2lab/lab2rgb equivalent — both branches route through - # scikit-image on the CPU. This block exists to preserve the API shape - # until a native GPU Lab pipeline lands. from skimage import color - alpha = float(alpha) - alpha = max(0.0, min(1.0, alpha)) - source_lab = color.rgb2lab(source) reference_lab = color.rgb2lab(reference) - result_lab = np.empty_like(source_lab) - for i in range(3): - source_mean = np.mean(source_lab[:, :, i]) - source_std = np.std(source_lab[:, :, i]) - ref_mean = np.mean(reference_lab[:, :, i]) - ref_std = np.std(reference_lab[:, :, i]) - - epsilon = 1e-8 - result_lab[:, :, i] = ( - (source_lab[:, :, i] - source_mean) - * (ref_std / (source_std + epsilon)) - ) + ref_mean - - result_lab[:, :, 0] = np.clip(result_lab[:, :, 0], 0, 100) - result_lab[:, :, 1] = np.clip(result_lab[:, :, 1], -128, 127) - result_lab[:, :, 2] = np.clip(result_lab[:, :, 2], -128, 127) - - if alpha < 1.0: - result_lab = source_lab * (1 - alpha) + result_lab * alpha + if is_gpu_available(): + source_gpu = cp.asarray(source_lab, dtype=cp.float32) + reference_gpu = cp.asarray(reference_lab, dtype=cp.float32) + result_gpu = cp.empty_like(source_gpu) + for i in range(3): + s_ch = source_gpu[:, :, i] + r_ch = reference_gpu[:, :, i] + result_gpu[:, :, i] = ( + (s_ch - cp.mean(s_ch)) * (cp.std(r_ch) / (cp.std(s_ch) + _EPSILON)) + ) + cp.mean(r_ch) + result_gpu[:, :, 0] = cp.clip(result_gpu[:, :, 0], *_LAB_L_BOUNDS) + result_gpu[:, :, 1] = cp.clip(result_gpu[:, :, 1], *_LAB_AB_BOUNDS) + result_gpu[:, :, 2] = cp.clip(result_gpu[:, :, 2], *_LAB_AB_BOUNDS) + if alpha < 1.0: + result_gpu = source_gpu * (1.0 - alpha) + result_gpu * alpha + result_lab = cp.asnumpy(result_gpu) + else: + result_lab = _meanstd_transfer(source_lab, reference_lab) + result_lab[:, :, 0] = np.clip(result_lab[:, :, 0], *_LAB_L_BOUNDS) + result_lab[:, :, 1] = np.clip(result_lab[:, :, 1], *_LAB_AB_BOUNDS) + result_lab[:, :, 2] = np.clip(result_lab[:, :, 2], *_LAB_AB_BOUNDS) + if alpha < 1.0: + result_lab = source_lab * (1.0 - alpha) + result_lab * alpha result_rgb = color.lab2rgb(result_lab) return np.clip(result_rgb, 0, 1).astype(source.dtype) -def gpu_histogram_matching_multichannel( - source: np.ndarray, - reference: np.ndarray -) -> np.ndarray: +def gpu_histogram_matching_multichannel(source: np.ndarray, reference: np.ndarray) -> np.ndarray: """ - GPU-accelerated multichannel histogram matching using CuPy. - + Multichannel histogram matching (CPU-only until a native CuPy kernel lands). + + Convenience alias for :func:`gpu_histogram_matching` that accepts and + returns the same float32 [0, 1] contract as the rest of the package. + Args: - source: Source image array (H, W, 3) in range [0, 255] - reference: Reference image array (H, W, 3) in range [0, 255] + source: Source image array (H, W, 3), any numeric dtype. + reference: Reference image array (H, W, 3), any numeric dtype. Returns: - Matched image array (H, W, 3) in range [0, 255] + Matched image array (H, W, 3), float32 in range [0, 1]. """ - if not HAS_CUPY: - # Fallback to CPU implementation - from colorcast.processing.transfer_methods import match_histograms_multichannel - return match_histograms_multichannel(source, reference) - return gpu_histogram_matching(source, reference) -def is_gpu_available() -> bool: - """Check if GPU acceleration is available.""" - return HAS_CUPY - - # Export functions for module API __all__ = [ "gpu_histogram_matching", @@ -271,4 +196,4 @@ def is_gpu_available() -> bool: "gpu_lab_transfer", "gpu_histogram_matching_multichannel", "is_gpu_available", -] \ No newline at end of file +] diff --git a/colorcast/processing/image_loader.py b/colorcast/processing/image_loader.py index 62c8474..97d4a08 100644 --- a/colorcast/processing/image_loader.py +++ b/colorcast/processing/image_loader.py @@ -16,16 +16,50 @@ channel count before normalization. """ +import logging +import os from pathlib import Path -from typing import NamedTuple, Optional, Tuple +from typing import NamedTuple + import numpy as np -from skimage import io, img_as_float, transform +from skimage import img_as_float, io, transform # type: ignore[attr-defined] + +from colorcast.utils.exceptions import ImageLoadError, InvalidImageFormatError, ValidationError from colorcast.utils.validators_enhanced import ( - validate_image_file, - validate_image_array, ALLOWED_IMAGE_EXTENSIONS, + validate_image_array, + validate_image_file, ) -from colorcast.utils.exceptions import ImageLoadError, InvalidImageFormatError + +logger = logging.getLogger(__name__) + +_MAX_FILE_BYTES = 200_000_000 # 200 MB + + +def _get_image_dimensions(filepath: str) -> tuple[int, int]: + """Return ``(width, height)`` from the image header without decoding pixels. + + Uses PIL if available; falls back to ``skimage.io.imread`` (which allocates + the full array) and then discards it. + + Returns: + (width, height) in pixels + """ + try: + from PIL import Image as _PILImage + except ImportError: + arr = io.imread(filepath) + return (arr.shape[1], arr.shape[0]) + try: + with _PILImage.open(filepath) as img: + return img.size # (width, height) + except (OSError, ValueError): + arr = io.imread(filepath) + return (arr.shape[1], arr.shape[0]) + except Exception: + logger.warning("PIL header read failed for %r; falling back to full decode", filepath) + arr = io.imread(filepath) + return (arr.shape[1], arr.shape[0]) class ImageMeta(NamedTuple): @@ -83,9 +117,7 @@ def ensure_rgb(img: np.ndarray) -> np.ndarray: composited = np.round(composited * maxval) return composited.astype(img.dtype) else: - raise InvalidImageFormatError( - f"Unsupported number of channels: {img.shape[2]}" - ) + raise InvalidImageFormatError(f"Unsupported number of channels: {img.shape[2]}") else: raise InvalidImageFormatError(f"Unsupported image dimensions: {img.ndim}") @@ -138,8 +170,8 @@ def normalize_to_float32(array: np.ndarray) -> np.ndarray: def load_image_with_meta( - path: str, max_pixels: int = 50000000, max_dimension: Optional[int] = None -) -> Tuple[np.ndarray, "ImageMeta"]: + path: str, max_pixels: int = 50000000, max_dimension: int | None = None +) -> tuple[np.ndarray, "ImageMeta"]: """ Load and validate an image file, returning the array and original format metadata. @@ -163,12 +195,23 @@ def load_image_with_meta( """ validate_image_file(path, ALLOWED_IMAGE_EXTENSIONS) + file_bytes = os.path.getsize(path) + if file_bytes > _MAX_FILE_BYTES: + raise ValidationError(f"File too large: {file_bytes:,} bytes (max {_MAX_FILE_BYTES:,})") + + width, height = _get_image_dimensions(path) + total_pixels = width * height + if total_pixels > max_pixels: + raise ValidationError( + f"Image too large: {width}×{height} = {total_pixels:,} pixels " f"(max {max_pixels:,})" + ) + try: img = img_as_float(io.imread(path)) - except IOError as e: - raise ImageLoadError(f"Failed to read image file: {e}") + except OSError as e: + raise ImageLoadError(f"Failed to read image file: {e}") from e except Exception as e: - raise ImageLoadError(f"Unexpected error loading image: {e}") + raise ImageLoadError(f"Unexpected error loading image: {e}") from e meta = ImageMeta( original_ndim=img.ndim, @@ -182,16 +225,14 @@ def load_image_with_meta( if max(h, w) > max_dimension: scale = max_dimension / max(h, w) new_h, new_w = int(h * scale), int(w * scale) - img = transform.resize( - img, (new_h, new_w), anti_aliasing=True, preserve_range=True - ) + img = transform.resize(img, (new_h, new_w), anti_aliasing=True, preserve_range=True) validate_image_array(img, max_pixels=max_pixels) return img, meta def load_image( - path: str, max_pixels: int = 50000000, max_dimension: Optional[int] = None + path: str, max_pixels: int = 50000000, max_dimension: int | None = None ) -> np.ndarray: """ Load and validate an image file. @@ -235,6 +276,7 @@ def save_image(img_array: np.ndarray, path: str) -> None: path_obj = Path(path) if path_obj.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: from colorcast.utils.exceptions import ValidationError + raise ValidationError( f"Invalid file extension. Allowed: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}" ) @@ -257,4 +299,4 @@ def save_image(img_array: np.ndarray, path: str) -> None: try: io.imsave(path, save_img) except Exception as e: - raise ImageProcessingError(f"Failed to save image: {e}") + raise ImageProcessingError(f"Failed to save image: {e}") from e diff --git a/colorcast/processing/registry.py b/colorcast/processing/registry.py index 4b0510f..affc41a 100644 --- a/colorcast/processing/registry.py +++ b/colorcast/processing/registry.py @@ -1,14 +1,22 @@ """Plugin architecture for transfer methods.""" +import threading from abc import ABC, abstractmethod -from typing import Any, Dict, Optional, Type +from typing import Any + import numpy as np + from colorcast.processing.cache import StyleTransferCache class TransferMethod(ABC): """Abstract base class for transfer methods.""" + # --- Slider label constants for subclasses ------------------------------- + _SLIDER_STYLE = "Style Intensity:" + _SLIDER_SEVERITY = "Severity (0%=normal, 100%=full):" + _SLIDER_CORRECTION = "Correction Intensity (0%=original, 100%=fully corrected):" + @property @abstractmethod def name(self) -> str: @@ -22,7 +30,7 @@ def id(self) -> str: pass @property - def parameters(self) -> Dict[str, dict]: + def parameters(self) -> dict[str, dict]: """Configurable parameters and their defaults.""" return {} @@ -35,9 +43,9 @@ def parameters(self) -> Dict[str, dict]: #: Label for the GUI intensity slider. Transfer methods blend source #: and styled image; simulators treat the slider as severity and #: Daltonizers as correction strength. - slider_label: str = "Style Intensity:" + slider_label: str = _SLIDER_STYLE - def _require_reference(self, reference: Optional[np.ndarray]) -> np.ndarray: + def _require_reference(self, reference: np.ndarray | None) -> np.ndarray: """Validate that a reference image was provided. Args: @@ -57,7 +65,7 @@ def _require_reference(self, reference: Optional[np.ndarray]) -> np.ndarray: def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, **kwargs: Any, ) -> np.ndarray: """ @@ -76,21 +84,67 @@ def transfer( class TransferMethodRegistry: - """Registry for transfer methods with optional LRU caching. + """Registry for transfer methods.""" - Callers that want to avoid recomputing the same transfer on the same - image pair can use :meth:`transfer_cached` instead of creating a - method instance and calling :meth:`TransferMethod.transfer` directly. - The cache is disabled by default; set ``cache_size`` > 0 to enable it. - """ + def __init__(self): + self._methods: dict[str, type[TransferMethod]] = {} + self._cache: StyleTransferCache | None = None # lazy-initialized + self._cache_lock = threading.Lock() + + def _ensure_cache(self) -> "StyleTransferCache": + with self._cache_lock: + if self._cache is None: + from colorcast.processing.cache import LRUCache - def __init__(self, cache_size: int = 0): - self._methods: Dict[str, Type[TransferMethod]] = {} - self._cache: Optional[StyleTransferCache] = None - if cache_size > 0: - self._cache = StyleTransferCache(max_size=cache_size) + self._cache = LRUCache(max_size=32) + return self._cache - def register(self, method_class: Type[TransferMethod]) -> Type[TransferMethod]: + def transfer_cached( + self, + method_id: str, + source: np.ndarray, + reference: np.ndarray | None, + **kwargs: Any, + ) -> np.ndarray: + """ + Apply a transfer method, caching the result for repeated calls. + + Args: + method_id: Unique method identifier. + source: Source image (H, W, 3). + reference: Reference image or None. + **kwargs: Method-specific parameters forwarded to ``transfer``. + + Returns: + Transferred image (H, W, 3). + """ + cache = self._ensure_cache() + cache_key = cache.generate_key(source, reference, method_id, kwargs) + + def _compute() -> np.ndarray: + method = self.get_method(method_id) + return method.transfer(source, reference, **kwargs) + + return cache.get_or_compute(cache_key, _compute) + + def cache_stats(self) -> dict: + """ + Get cache hit/miss/size statistics. + + Returns: + Dictionary with ``hits``, ``misses``, and ``size`` keys, or + an empty dict when the cache has not been initialized. + """ + if self._cache is None: + return {} + return self._cache.stats() + + def clear_cache(self) -> None: + """Remove all cached entries.""" + if self._cache is not None: + self._cache.clear() + + def register(self, method_class: type[TransferMethod]) -> type[TransferMethod]: """ Register a transfer method. @@ -121,7 +175,7 @@ def get_method(self, method_id: str) -> TransferMethod: raise ValueError(f"Unknown transfer method: {method_id}") return self._methods[method_id]() - def list_methods(self) -> Dict[str, str]: + def list_methods(self) -> dict[str, str]: """ List all registered methods. @@ -129,78 +183,11 @@ def list_methods(self) -> Dict[str, str]: Dictionary mapping method IDs to display names """ methods = {} - for method_id, method_class in self._methods.items(): + for _method_id, method_class in self._methods.items(): instance = method_class() methods[instance.id] = instance.name return methods - def has_method(self, method_id: str) -> bool: - """ - Check if a method is registered. - - Args: - method_id: Method identifier to check - - Returns: - True if method is registered, False otherwise - """ - return method_id in self._methods - - def transfer_cached( - self, - method_id: str, - source: np.ndarray, - reference: Optional[np.ndarray], - **kwargs: Any, - ) -> np.ndarray: - """ - Apply a transfer method with optional LRU caching. - - If the registry was initialized with ``cache_size > 0``, the result - is cached by (content hash, style hash, method id, parameters) and - re-used when the same inputs are requested again. Otherwise this is - equivalent to ``registry.get_method(method_id).transfer(source, - reference, **kwargs)``. - - Args: - method_id: Unique identifier of the method - source: Source image (H, W, 3) - reference: Reference image (H, W, 3), or None for methods with - ``requires_reference = False`` - **kwargs: Method-specific parameters - - Returns: - Transferred image (H, W, 3) - """ - method = self.get_method(method_id) - if self._cache is None: - return method.transfer(source, reference, **kwargs) - return self._cache.get_or_compute( - self._cache._generate_key( - self._cache._compute_hash(source.copy()), - self._cache._style_hash(reference), - method_id, - kwargs, - ), - lambda: method.transfer(source, reference, **kwargs), - ) - - def cache_stats(self) -> dict | None: - """ - Return cache statistics, or None if caching is disabled. - - Returns: - Dict with ``hits``, ``misses``, ``size`` keys, or None - """ - if self._cache is None: - return None - return self._cache.stats() - - def clear_cache(self) -> None: - """Clear all cached transfer results.""" - if self._cache is not None: - self._cache.clear() - # Global registry instance for transfer methods # This registry is automatically populated with all built-in transfer methods @@ -225,7 +212,7 @@ def id(self) -> str: def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, **kwargs: Any, ) -> np.ndarray: reference = self._require_reference(reference) @@ -250,7 +237,7 @@ def id(self) -> str: def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, **kwargs: Any, ) -> np.ndarray: reference = self._require_reference(reference) @@ -273,7 +260,7 @@ def id(self) -> str: return "lab_reinhard" @property - def parameters(self) -> Dict[str, dict]: + def parameters(self) -> dict[str, dict]: return { "alpha": { "default": 1.0, @@ -286,7 +273,7 @@ def parameters(self) -> Dict[str, dict]: def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, alpha: float = 1.0, **kwargs: Any, ) -> np.ndarray: @@ -312,7 +299,7 @@ def id(self) -> str: def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, **kwargs: Any, ) -> np.ndarray: reference = self._require_reference(reference) @@ -337,7 +324,7 @@ def id(self) -> str: def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, **kwargs: Any, ) -> np.ndarray: reference = self._require_reference(reference) @@ -362,7 +349,7 @@ def id(self) -> str: def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, **kwargs: Any, ) -> np.ndarray: reference = self._require_reference(reference) @@ -385,7 +372,7 @@ def id(self) -> str: return "selective_shadows" @property - def parameters(self) -> Dict[str, dict]: + def parameters(self) -> dict[str, dict]: return { "shadow_threshold": { "default": 0.3, @@ -398,7 +385,7 @@ def parameters(self) -> Dict[str, dict]: def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, shadow_threshold: float = 0.3, **kwargs: Any, ) -> np.ndarray: @@ -424,7 +411,7 @@ def id(self) -> str: return "selective_midtones" @property - def parameters(self) -> Dict[str, dict]: + def parameters(self) -> dict[str, dict]: return { "shadow_threshold": { "default": 0.3, @@ -443,7 +430,7 @@ def parameters(self) -> Dict[str, dict]: def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, shadow_threshold: float = 0.3, highlight_threshold: float = 0.7, **kwargs: Any, @@ -474,7 +461,7 @@ def id(self) -> str: return "selective_highlights" @property - def parameters(self) -> Dict[str, dict]: + def parameters(self) -> dict[str, dict]: return { "highlight_threshold": { "default": 0.7, @@ -487,7 +474,7 @@ def parameters(self) -> Dict[str, dict]: def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, highlight_threshold: float = 0.7, **kwargs: Any, ) -> np.ndarray: @@ -519,12 +506,12 @@ def id(self) -> str: return "simulate_deuteranopia" requires_reference = False - slider_label = "Severity (0%=normal, 100%=full):" + slider_label = TransferMethod._SLIDER_SEVERITY def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, **kwargs: Any, ) -> np.ndarray: from colorcast.processing.simulation import ColorBlindSimulator @@ -545,12 +532,12 @@ def id(self) -> str: return "simulate_protanopia" requires_reference = False - slider_label = "Severity (0%=normal, 100%=full):" + slider_label = TransferMethod._SLIDER_SEVERITY def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, **kwargs: Any, ) -> np.ndarray: from colorcast.processing.simulation import ColorBlindSimulator @@ -571,12 +558,12 @@ def id(self) -> str: return "simulate_tritanopia" requires_reference = False - slider_label = "Severity (0%=normal, 100%=full):" + slider_label = TransferMethod._SLIDER_SEVERITY def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, **kwargs: Any, ) -> np.ndarray: from colorcast.processing.simulation import ColorBlindSimulator @@ -603,12 +590,12 @@ def id(self) -> str: return "daltonize_protanopia" requires_reference = False - slider_label = "Correction Intensity (0%=original, 100%=fully corrected):" + slider_label = TransferMethod._SLIDER_CORRECTION def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, intensity: float = 1.0, **kwargs: Any, ) -> np.ndarray: @@ -630,12 +617,12 @@ def id(self) -> str: return "daltonize_deuteranopia" requires_reference = False - slider_label = "Correction Intensity (0%=original, 100%=fully corrected):" + slider_label = TransferMethod._SLIDER_CORRECTION def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, intensity: float = 1.0, **kwargs: Any, ) -> np.ndarray: @@ -657,12 +644,12 @@ def id(self) -> str: return "daltonize_tritanopia" requires_reference = False - slider_label = "Correction Intensity (0%=original, 100%=fully corrected):" + slider_label = TransferMethod._SLIDER_CORRECTION def transfer( self, source: np.ndarray, - reference: Optional[np.ndarray], + reference: np.ndarray | None, intensity: float = 1.0, **kwargs: Any, ) -> np.ndarray: diff --git a/colorcast/processing/simulation.py b/colorcast/processing/simulation.py index 0213282..e354cc5 100644 --- a/colorcast/processing/simulation.py +++ b/colorcast/processing/simulation.py @@ -50,9 +50,11 @@ from __future__ import annotations -from typing import Callable, ClassVar, Literal, get_args +from collections.abc import Callable +from typing import ClassVar, Literal, get_args import numpy as np + from colorcast.processing.image_loader import normalize_to_float32 # Type alias used in public method signatures. This Literal is the single @@ -180,9 +182,7 @@ class ColorBlindSimulator: # single linear-RGB projection matrix or a callable that transforms an # (N, 3) linear-RGB array. A new deficiency needs one entry here plus its # name in DeficiencyType. - _PROJECTION: ClassVar[ - dict[str, np.ndarray | Callable[[np.ndarray], np.ndarray]] - ] = { + _PROJECTION: ClassVar[dict[str, np.ndarray | Callable[[np.ndarray], np.ndarray]]] = { "protanopia": _PROTAN_RGB, "deuteranopia": _DEUTAN_RGB, "tritanopia": _tritan_projection, diff --git a/colorcast/processing/transfer_methods.py b/colorcast/processing/transfer_methods.py index 8388daf..1e49216 100644 --- a/colorcast/processing/transfer_methods.py +++ b/colorcast/processing/transfer_methods.py @@ -1,12 +1,50 @@ """Color transfer methods and algorithms.""" from typing import Literal + import numpy as np -from skimage import exposure, transform, color +from skimage import color, exposure, transform + from colorcast.processing.image_loader import normalize_to_float32 +# --- Shared numerical constants ------------------------------------------- +_EPSILON = 1e-8 +_LAB_L_BOUNDS = (0.0, 100.0) +_LAB_AB_BOUNDS = (-128.0, 127.0) + + +def _meanstd_transfer(source: np.ndarray, reference: np.ndarray) -> np.ndarray: + """ + Per-channel mean and standard deviation transfer. + + The caller is responsible for validating and resizing inputs before + calling this function. Both arrays must be three-channel float32 + images with identical spatial dimensions. + + Args: + source: Pre-validated three-channel source array (H, W, 3). + reference: Pre-validated three-channel reference array (H, W, 3) + with the same spatial dimensions as ``source``. + + Returns: + Transferred image array with the same shape and dtype as + ``source``. + """ + result = np.empty_like(source) + for i in range(3): + source_mean = np.mean(source[:, :, i]) + source_std = np.std(source[:, :, i]) + ref_mean = np.mean(reference[:, :, i]) + ref_std = np.std(reference[:, :, i]) + result[:, :, i] = ( + (source[:, :, i] - source_mean) * (ref_std / (source_std + _EPSILON)) + ) + ref_mean + return result + -def validate_and_resize_images(source: np.ndarray, reference: np.ndarray) -> tuple: +def validate_and_resize_images( + source: np.ndarray, reference: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: """ Validate shape, normalize dtype and value range, and ensure both images are compatible for processing. @@ -62,7 +100,7 @@ def match_histograms_multichannel(source: np.ndarray, reference: np.ndarray) -> matched = np.empty_like(source) for i in range(3): matched[:, :, i] = exposure.match_histograms(source[:, :, i], reference[:, :, i]) - + return matched.astype(source.dtype) @@ -92,18 +130,7 @@ def color_transfer_meanstd(source: np.ndarray, reference: np.ndarray) -> np.ndar """ source, reference = validate_and_resize_images(source, reference) - result = np.empty_like(source) - for i in range(3): - source_mean = np.mean(source[:, :, i]) - source_std = np.std(source[:, :, i]) - ref_mean = np.mean(reference[:, :, i]) - ref_std = np.std(reference[:, :, i]) - - # Avoid division by zero - epsilon = 1e-8 - result[:, :, i] = ( - (source[:, :, i] - source_mean) * (ref_std / (source_std + epsilon)) - ) + ref_mean + result = _meanstd_transfer(source, reference) return np.clip(result, 0, 1).astype(source.dtype) @@ -186,7 +213,7 @@ def color_transfer_lab( ValueError: If images have incorrect dimensions """ source, reference = validate_and_resize_images(source, reference) - + # Clamp alpha to valid range [0, 1] alpha = np.clip(alpha, 0.0, 1.0) @@ -195,29 +222,12 @@ def color_transfer_lab( source_lab = color.rgb2lab(source) reference_lab = color.rgb2lab(reference) - # Compute statistics in Lab space for each channel - result_lab = np.empty_like(source_lab) - for i in range(3): - # Compute mean and std for source and reference - source_mean = np.mean(source_lab[:, :, i]) - source_std = np.std(source_lab[:, :, i]) - ref_mean = np.mean(reference_lab[:, :, i]) - ref_std = np.std(reference_lab[:, :, i]) - - # Avoid division by zero - epsilon = 1e-8 - - # Apply statistical transfer directly in Lab space - # This preserves color relationships while matching statistics - result_lab[:, :, i] = ( - (source_lab[:, :, i] - source_mean) - * (ref_std / (source_std + epsilon)) - ) + ref_mean + result_lab = _meanstd_transfer(source_lab, reference_lab) # Clip to valid Lab ranges - result_lab[:, :, 0] = np.clip(result_lab[:, :, 0], 0, 100) # L channel - result_lab[:, :, 1] = np.clip(result_lab[:, :, 1], -128, 127) # a channel - result_lab[:, :, 2] = np.clip(result_lab[:, :, 2], -128, 127) # b channel + result_lab[:, :, 0] = np.clip(result_lab[:, :, 0], *_LAB_L_BOUNDS) + result_lab[:, :, 1] = np.clip(result_lab[:, :, 1], *_LAB_AB_BOUNDS) + result_lab[:, :, 2] = np.clip(result_lab[:, :, 2], *_LAB_AB_BOUNDS) # Apply alpha blending for partial transfer if alpha < 1.0: @@ -228,10 +238,10 @@ def color_transfer_lab( # Ensure result is in valid range [0, 1] result_rgb = np.clip(result_rgb, 0, 1) - + # Preserve original dtype result_rgb = result_rgb.astype(source.dtype) - + return result_rgb @@ -272,9 +282,7 @@ def selective_color_transfer( source, reference = validate_and_resize_images(source, reference) # Calculate luminance using ITU-R BT.601 coefficients - source_lum = ( - 0.299 * source[:, :, 0] + 0.587 * source[:, :, 1] + 0.114 * source[:, :, 2] - ) + source_lum = 0.299 * source[:, :, 0] + 0.587 * source[:, :, 1] + 0.114 * source[:, :, 2] # Build continuous masks via smoothstep over a narrow band around each # threshold to avoid hard edges at the boundary. @@ -305,12 +313,10 @@ def _smoothstep(lo, hi, x): # Apply histogram matching to all channels matched = np.empty_like(source) for i in range(3): - matched[:, :, i] = exposure.match_histograms( - source[:, :, i], reference[:, :, i] - ) + matched[:, :, i] = exposure.match_histograms(source[:, :, i], reference[:, :, i]) # Blend original and matched images using mask # Original image shows where mask=0, matched where mask=1 result = source * (1 - mask) + matched * mask - return np.clip(result, 0, 1).astype(source.dtype) \ No newline at end of file + return np.clip(result, 0, 1).astype(source.dtype) diff --git a/colorcast/utils/__init__.py b/colorcast/utils/__init__.py index 6af6d94..4e4c6e8 100644 --- a/colorcast/utils/__init__.py +++ b/colorcast/utils/__init__.py @@ -1,15 +1,15 @@ """Utility modules for ColorCast.""" from colorcast.utils.exceptions import ( - ImageProcessingError, ImageLoadError, + ImageProcessingError, InvalidImageFormatError, ValidationError, ) from colorcast.utils.validators_enhanced import ( + ALLOWED_IMAGE_EXTENSIONS, validate_file_path, validate_image_size, - ALLOWED_IMAGE_EXTENSIONS, ) __all__ = [ @@ -20,4 +20,4 @@ "validate_file_path", "validate_image_size", "ALLOWED_IMAGE_EXTENSIONS", -] \ No newline at end of file +] diff --git a/colorcast/utils/config.py b/colorcast/utils/config.py index ce76b16..0e4c2d9 100644 --- a/colorcast/utils/config.py +++ b/colorcast/utils/config.py @@ -1,9 +1,12 @@ """Configuration management for ColorCast.""" +from __future__ import annotations + import json +import os +from dataclasses import dataclass, field, fields from pathlib import Path -from typing import Dict, Any -from dataclasses import dataclass, asdict, fields, field +from typing import Any @dataclass @@ -22,7 +25,6 @@ class ColorCastConfig: # Performance settings max_image_dimension: int = 4096 - cache_size: int = 3 # informational (global registry owns its own sizing) enable_parallel: bool = True # honoured by BatchProcessor.enable_parallel # File settings @@ -31,46 +33,99 @@ class ColorCastConfig: output_format: str = "png" # Custom transfer methods (plugins) - custom_methods: Dict[str, Any] = field(default_factory=dict) + custom_methods: dict[str, Any] = field(default_factory=dict) - def save(self, path: Path) -> None: + @staticmethod + def get_config_path() -> Path: """ - Save configuration to file. + Return the default configuration file path. - Args: - path: Path to save configuration file + Platform convention for user config directories: + - macOS: ``~/Library/Application Support/ColorCast/config.json`` + - Linux: ``~/.config/ColorCast/config.json`` + - Windows: ``%APPDATA%/ColorCast/config.json`` """ - with open(path, "w") as f: - json.dump(asdict(self), f, indent=2) + import sys - @classmethod - def load(cls, path: Path) -> "ColorCastConfig": + if sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" + elif sys.platform == "win32": + base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + else: + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return base / "ColorCast" / "config.json" + + def save(self, path: str | Path | None = None) -> Path: """ - Load configuration from file. + Serialize the current configuration to a JSON file. Args: - path: Path to configuration file + path: File path. When None, ``get_config_path()`` is used. Returns: - ColorCastConfig instance + The resolved path that was written. + + Raises: + OSError: If the parent directory cannot be created. """ - if not path.exists(): - return cls() + resolved = Path(path) if path is not None else self.get_config_path() + resolved.parent.mkdir(parents=True, exist_ok=True) + data = {f.name: getattr(self, f.name) for f in fields(self)} + resolved.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") + return resolved - with open(path, "r") as f: - data = json.load(f) + @classmethod + def load(cls, path: str | Path | None = None) -> ColorCastConfig: + """ + Load configuration from a JSON file, falling back to defaults. - known_fields = {f.name for f in fields(cls)} - filtered = {k: v for k, v in data.items() if k in known_fields} - return cls(**filtered) + Unknown keys in the file are silently ignored. Values for known + fields are validated against their declared types; a + :class:`TypeError` is raised on mismatch (e.g. ``window_width: + "abc"`` where ``window_width`` is an ``int`` field). - def get_config_path(self) -> Path: - """ - Get default configuration file path. + Args: + path: File path. When None, ``get_config_path()`` is used. Returns: - Path to default configuration file + A ``ColorCastConfig`` populated from the file, with defaults + for any field not present. + + Raises: + TypeError: If a known field has an incompatible value type. + FileNotFoundError: If the file does not exist (surfaced + directly for callers to handle). """ - config_dir = Path.home() / ".colorcast" - config_dir.mkdir(exist_ok=True) - return config_dir / "config.json" \ No newline at end of file + from typing import Any, get_origin, get_type_hints + + resolved = Path(path) if path is not None else cls.get_config_path() + raw = json.loads(resolved.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise TypeError(f"Expected a JSON object in {resolved}, " f"got {type(raw).__name__}") + known_fields = {f.name: f for f in fields(cls)} + field_types = get_type_hints(cls) + filtered: dict[str, Any] = {} + for key, value in raw.items(): + if key not in known_fields: + continue + expected = field_types.get(key, Any) + if expected is Any: + filtered[key] = value + continue + origin = get_origin(expected) or expected + if origin is int and isinstance(value, bool): + raise TypeError( + f"{key}: expected {origin.__name__}, " f"got {type(value).__name__} ({value!r})" + ) + if origin is float and isinstance(value, int) and not isinstance(value, bool): + filtered[key] = float(value) + continue + if not isinstance(value, origin): + raise TypeError( + f"{key}: expected {origin.__name__}, " f"got {type(value).__name__} ({value!r})" + ) + filtered[key] = value + return cls(**filtered) + + +__all__ = ["ColorCastConfig"] diff --git a/colorcast/utils/exceptions.py b/colorcast/utils/exceptions.py index 1163c4b..5d2f8db 100644 --- a/colorcast/utils/exceptions.py +++ b/colorcast/utils/exceptions.py @@ -30,4 +30,4 @@ class InvalidImageFormatError(ImageProcessingError): class ValidationError(ImageProcessingError): - """Raised when input validation fails.""" \ No newline at end of file + """Raised when input validation fails.""" diff --git a/colorcast/utils/validators.py b/colorcast/utils/validators.py deleted file mode 100644 index 4e664ad..0000000 --- a/colorcast/utils/validators.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Deprecated – import from ``colorcast.utils.validators_enhanced`` instead.""" -import warnings -from colorcast.utils.validators_enhanced import ( # noqa: F401 - ALLOWED_IMAGE_EXTENSIONS, - validate_file_path, - validate_image_size, -) - -warnings.warn( - "colorcast.utils.validators is deprecated; " - "import from colorcast.utils.validators_enhanced instead.", - DeprecationWarning, - stacklevel=2, -) diff --git a/colorcast/utils/validators_enhanced.py b/colorcast/utils/validators_enhanced.py index af7a778..94378a3 100644 --- a/colorcast/utils/validators_enhanced.py +++ b/colorcast/utils/validators_enhanced.py @@ -7,23 +7,27 @@ - Resource limit enforcement """ -import os import math +import os + try: import imghdr as _imghdr + _HAS_IMGHDR = True except ImportError: _imghdr = None # type: ignore[assignment] _HAS_IMGHDR = False try: from PIL import Image as _PILImage + _HAS_PIL = True except ImportError: _PILImage = None # type: ignore[assignment] _HAS_PIL = False from pathlib import Path -from typing import Tuple, Optional + import numpy as np + from colorcast.utils.exceptions import ValidationError ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff") @@ -50,43 +54,41 @@ def validate_image_size(img_array: np.ndarray, max_pixels: int = 50000000) -> No total_pixels = img_array.shape[0] * img_array.shape[1] if total_pixels > max_pixels: - raise ValidationError( - f"Image too large: {total_pixels:,} pixels (max: {max_pixels:,})" - ) + raise ValidationError(f"Image too large: {total_pixels:,} pixels (max: {max_pixels:,})") def validate_file_path( path: str, - allowed_extensions: Tuple[str, ...] = ALLOWED_IMAGE_EXTENSIONS, - allowed_base_dirs: Optional[Tuple[Path, ...]] = None, + allowed_extensions: tuple[str, ...] = ALLOWED_IMAGE_EXTENSIONS, + allowed_base_dirs: tuple[Path, ...] | None = None, check_existence: bool = True, ) -> Path: """ Validate file path is safe and has allowed extension. - + Comprehensive security validation including: - Path traversal prevention - Directory restriction - File extension validation - Existence and readability checks - + Args: path: File path to validate allowed_extensions: Tuple of allowed extensions allowed_base_dirs: Optional tuple of allowed base directories check_existence: Whether to check if file exists (skip for new files) - + Returns: Resolved Path object - + Raises: ValidationError: If path is invalid or unsafe FileNotFoundError: If file doesn't exist (when check_existence=True) - + Example: >>> path = validate_file_path("image.jpg", allowed_base_dirs=(Path("/safe"),)) >>> # Raises ValidationError if path is outside /safe - + Security: - Prevents path traversal attacks (../) - Restricts to allowed directories @@ -94,19 +96,17 @@ def validate_file_path( - Checks file permissions """ path_obj = Path(path) - + # Check for path traversal in original path string if ".." in path_obj.parts: - raise ValidationError( - "Path traversal attempt detected in path" - ) - + raise ValidationError("Path traversal attempt detected in path") + # Resolve to absolute path try: resolved_path = path_obj.resolve() except (OSError, RuntimeError) as e: - raise ValidationError(f"Invalid path: {e}") - + raise ValidationError(f"Invalid path: {e}") from e + # Check if path is within allowed directories if allowed_base_dirs is not None: is_allowed = False @@ -120,17 +120,16 @@ def validate_file_path( continue if not is_allowed: raise ValidationError( - f"Path not within allowed directories: " - f"{[str(d) for d in allowed_base_dirs]}" + f"Path not within allowed directories: " f"{[str(d) for d in allowed_base_dirs]}" ) - + # Check file extension if path_obj.suffix.lower() not in allowed_extensions: raise ValidationError( f"Invalid file extension '{path_obj.suffix}'. " f"Allowed: {', '.join(allowed_extensions)}" ) - + # Check file existence and readability if check_existence: if not resolved_path.exists(): @@ -139,47 +138,89 @@ def validate_file_path( raise ValidationError(f"Not a file: {path}") if not os.access(resolved_path, os.R_OK): raise ValidationError(f"File not readable: {path}") - + return resolved_path +def _check_extension_matches( + detected_ext: str, + path_ext: str, + fmt_name: str, +) -> None: + """Raise ValidationError if detected extension does not match path extension. + + Common aliases (``.jpg`` ↔ ``.jpeg``, ``.tif`` ↔ ``.tiff``) are treated as + equivalent. A detected_ext of ``""`` means the format was not recognised; + the call site decides whether that is an error. + """ + if not detected_ext: + return + alias_groups = ({".jpg", ".jpeg"}, {".tif", ".tiff"}) + for group in alias_groups: + if detected_ext in group and path_ext in group: + return + if detected_ext == path_ext: + return + raise ValidationError( + f"File type mismatch. Detected: {fmt_name}, Extension: {path_ext}. " + "File may be corrupted or renamed." + ) + + +_FORMAT_TO_EXT: dict[str, str] = { + "jpeg": ".jpg", + "png": ".png", + "bmp": ".bmp", + "tiff": ".tif", + "gif": ".gif", + "webp": ".webp", +} + +_PIL_FORMAT_TO_EXT: dict[str, str] = { + "JPEG": ".jpg", + "PNG": ".png", + "BMP": ".bmp", + "TIFF": ".tif", + "GIF": ".gif", + "WEBP": ".webp", +} + + def validate_image_file( path: str, - allowed_extensions: Tuple[str, ...] = ALLOWED_IMAGE_EXTENSIONS, + allowed_extensions: tuple[str, ...] = ALLOWED_IMAGE_EXTENSIONS, ) -> None: """ Validate that file is actually an image file using magic numbers. - + Uses magic numbers to detect actual file type, not just extension. This prevents file type spoofing attacks. - + Args: path: Path to image file allowed_extensions: Tuple of allowed extensions - + Raises: ValidationError: If file is not a valid image FileNotFoundError: If file doesn't exist - + Example: >>> validate_image_file("image.jpg") >>> # Passes if file is actually JPEG >>> validate_image_file("fake.jpg") >>> # Raises if file is not actually JPEG - + Security: - Validates actual file type, not just extension - Prevents file type spoofing - Uses magic number detection """ - # First check basic file existence path_obj = validate_file_path( path, allowed_extensions=allowed_extensions, - check_existence=True + check_existence=True, ) - - # Check actual file type using magic numbers + if not _HAS_IMGHDR: if not _HAS_PIL: raise ValidationError( @@ -188,78 +229,30 @@ def validate_image_file( ) try: with _PILImage.open(path_obj) as img: # type: ignore[union-attr] - fmt = img.format # capture before verify() consumes the stream + fmt = img.format img.verify() except ValidationError: raise except Exception as e: raise ValidationError(f"File is not a valid image: {e}") from e - _pil_format_to_ext = { - 'JPEG': '.jpg', - 'PNG': '.png', - 'BMP': '.bmp', - 'TIFF': '.tif', - 'GIF': '.gif', - 'WEBP': '.webp', - } path_ext = path_obj.suffix.lower() - expected_ext = _pil_format_to_ext.get(fmt or '') - if expected_ext == '.jpg' and path_ext in {'.jpg', '.jpeg'}: - return - if expected_ext == '.tif' and path_ext in {'.tif', '.tiff'}: - return - if expected_ext and expected_ext == path_ext: - return - if expected_ext: - raise ValidationError( - f"File type mismatch. Detected: {fmt}, " - f"Extension: {path_ext}. File may be corrupted or renamed." - ) - # fmt is None or an unrecognised Pillow format; the file parsed and - # verified successfully so treat it as valid. + expected_ext = _PIL_FORMAT_TO_EXT.get(fmt or "", "") + _check_extension_matches(expected_ext, path_ext, fmt or "unknown") return + try: detected_type = _imghdr.what(path) except Exception as e: - raise ValidationError(f"Failed to read file: {e}") - + raise ValidationError(f"Failed to read file: {e}") from e + if detected_type is None: - raise ValidationError( - f"File does not appear to be a valid image: {path}" - ) - - # Map imghdr types to our allowed extensions - type_to_ext = { - 'jpeg': '.jpg', - 'png': '.png', - 'bmp': '.bmp', - 'tiff': '.tif', - 'gif': '.gif', - 'webp': '.webp', - } - - # Verify detected type matches extension - expected_ext = type_to_ext.get(detected_type) + raise ValidationError(f"File does not appear to be a valid image: {path}") + + expected_ext = _FORMAT_TO_EXT.get(detected_type, "") path_ext = path_obj.suffix.lower() - - # Allow common variations - if expected_ext == '.jpg' and path_ext in ['.jpg', '.jpeg', '.JPG', '.JPEG']: - return - if expected_ext == '.tiff' and path_ext in ['.tif', '.tiff', '.TIF', '.TIFF']: - return - - # Check for exact match - if expected_ext and expected_ext == path_ext: - return - - # Type mismatch - if expected_ext: - raise ValidationError( - f"File type mismatch. Detected: {detected_type.upper()}, " - f"Extension: {path_ext}. File may be corrupted or renamed." - ) - else: + _check_extension_matches(expected_ext, path_ext, detected_type.upper()) + if not expected_ext: raise ValidationError( f"Unsupported image type: {detected_type}. " f"Supported types: JPEG, PNG, BMP, TIFF, GIF, WebP" @@ -269,16 +262,16 @@ def validate_image_file( def validate_float_parameter( value: float, param_name: str, - min_val: Optional[float] = None, - max_val: Optional[float] = None, + min_val: float | None = None, + max_val: float | None = None, allow_nan: bool = False, allow_inf: bool = False, ) -> float: """ Validate float parameter is within valid range and properly formed. - + Prevents NaN and Infinity attacks that could cause undefined behavior. - + Args: value: Parameter value to validate param_name: Name of parameter (for error messages) @@ -286,18 +279,18 @@ def validate_float_parameter( max_val: Optional maximum allowed value allow_nan: Whether NaN is allowed (default: False) allow_inf: Whether infinity is allowed (default: False) - + Returns: Validated float value - + Raises: ValidationError: If value is invalid - + Example: >>> alpha = validate_float_parameter(0.5, "alpha", 0.0, 1.0) >>> validate_float_parameter(float('nan'), "alpha", 0.0, 1.0) >>> ValidationError: alpha cannot be NaN - + Security: - Prevents NaN/Infinity injection - Validates numeric type @@ -305,86 +298,25 @@ def validate_float_parameter( """ # Check type if not isinstance(value, (int, float)): - raise ValidationError( - f"{param_name} must be a number, got {type(value).__name__}" - ) - + raise ValidationError(f"{param_name} must be a number, got {type(value).__name__}") + # Check for NaN if not allow_nan and math.isnan(value): - raise ValidationError( - f"{param_name} cannot be NaN (Not a Number)" - ) - + raise ValidationError(f"{param_name} cannot be NaN (Not a Number)") + # Check for Infinity if not allow_inf and math.isinf(value): - raise ValidationError( - f"{param_name} cannot be infinite" - ) - + raise ValidationError(f"{param_name} cannot be infinite") + # Check range value_float = float(value) if min_val is not None and value_float < min_val: - raise ValidationError( - f"{param_name} must be >= {min_val}, got {value_float}" - ) - - if max_val is not None and value_float > max_val: - raise ValidationError( - f"{param_name} must be <= {max_val}, got {value_float}" - ) - - return value_float + raise ValidationError(f"{param_name} must be >= {min_val}, got {value_float}") + if max_val is not None and value_float > max_val: + raise ValidationError(f"{param_name} must be <= {max_val}, got {value_float}") -def validate_int_parameter( - value: int, - param_name: str, - min_val: Optional[int] = None, - max_val: Optional[int] = None, -) -> int: - """ - Validate integer parameter is within valid range. - - Args: - value: Parameter value to validate - param_name: Name of parameter - min_val: Optional minimum allowed value - max_val: Optional maximum allowed value - - Returns: - Validated integer value - - Raises: - ValidationError: If value is invalid - - Example: - >>> workers = validate_int_parameter(4, "workers", 1, 16) - """ - # Check type (allow float that's actually integer) - if isinstance(value, float) and not value.is_integer(): - raise ValidationError( - f"{param_name} must be an integer, got float: {value}" - ) - - if not isinstance(value, (int, float)): - raise ValidationError( - f"{param_name} must be an integer, got {type(value).__name__}" - ) - - value_int = int(value) - - # Check range - if min_val is not None and value_int < min_val: - raise ValidationError( - f"{param_name} must be >= {min_val}, got {value_int}" - ) - - if max_val is not None and value_int > max_val: - raise ValidationError( - f"{param_name} must be <= {max_val}, got {value_int}" - ) - - return value_int + return value_float def validate_image_array( @@ -392,14 +324,14 @@ def validate_image_array( min_pixels: int = 1, max_pixels: int = MAX_IMAGE_PIXELS, max_dimension: int = MAX_IMAGE_DIMENSION, - required_channels: Optional[int] = 3, - dtype_range: Tuple[float, float] = (0.0, 1.0), + required_channels: int | None = 3, + dtype_range: tuple[float, float] = (0.0, 1.0), ) -> np.ndarray: """ Validate image array meets requirements. - + Prevents memory exhaustion attacks and ensures proper format. - + Args: img_array: Image array to validate min_pixels: Minimum number of pixels @@ -407,17 +339,17 @@ def validate_image_array( max_dimension: Maximum dimension (width or height) required_channels: Required number of channels (None for any) dtype_range: Expected value range (min, max) - + Returns: Validated image array (unchanged if valid) - + Raises: ValidationError: If image array is invalid - + Example: >>> img = validate_image_array(image, max_pixels=10000000) >>> # Raises if image has > 10 million pixels - + Security: - Prevents memory exhaustion with large images - Validates array dimensions @@ -426,58 +358,49 @@ def validate_image_array( """ # Check dimensions if img_array.ndim < 2: - raise ValidationError( - f"Image must have at least 2 dimensions, got {img_array.ndim}" - ) - + raise ValidationError(f"Image must have at least 2 dimensions, got {img_array.ndim}") + if img_array.ndim > 3: - raise ValidationError( - f"Image must have at most 3 dimensions, got {img_array.ndim}" - ) - + raise ValidationError(f"Image must have at most 3 dimensions, got {img_array.ndim}") + # Check channels if img_array.ndim == 3: channels = img_array.shape[2] if required_channels is not None and channels != required_channels: - raise ValidationError( - f"Image must have {required_channels} channels, got {channels}" - ) - + raise ValidationError(f"Image must have {required_channels} channels, got {channels}") + # Check dimensions height, width = img_array.shape[:2] - + if height < MIN_IMAGE_SIZE[0] or width < MIN_IMAGE_SIZE[1]: raise ValidationError( f"Image too small: {height}x{width} (minimum: {MIN_IMAGE_SIZE[0]}x{MIN_IMAGE_SIZE[1]})" ) - + if height > max_dimension or width > max_dimension: raise ValidationError( - f"Image dimension too large: {height}x{width} " - f"(maximum dimension: {max_dimension})" + f"Image dimension too large: {height}x{width} " f"(maximum dimension: {max_dimension})" ) - + # Check pixel count total_pixels = height * width if total_pixels < min_pixels: raise ValidationError( - f"Image too small: {total_pixels:,} pixels " - f"(minimum: {min_pixels:,})" + f"Image too small: {total_pixels:,} pixels " f"(minimum: {min_pixels:,})" ) - + if total_pixels > max_pixels: raise ValidationError( - f"Image too large: {total_pixels:,} pixels " - f"(maximum: {max_pixels:,})" + f"Image too large: {total_pixels:,} pixels " f"(maximum: {max_pixels:,})" ) - + # Check for invalid values (NaN, Inf) if np.any(np.isnan(img_array)): raise ValidationError("Image contains NaN (Not a Number) values") - + if np.any(np.isinf(img_array)): raise ValidationError("Image contains infinite values") - + # Check value range if dtype_range: min_val, max_val = dtype_range @@ -489,94 +412,5 @@ def validate_image_array( f"Image values out of range: [{actual_min:.4f}, {actual_max:.4f}] " f"(expected: [{min_val}, {max_val}])" ) - - return img_array - - -def validate_memory_limit( - estimated_bytes: int, - limit_mb: int = 4096, - operation: str = "operation", -) -> None: - """ - Validate memory usage is within limits. - - Args: - estimated_bytes: Estimated memory usage in bytes - limit_mb: Maximum allowed memory in MB - operation: Name of operation (for error message) - - Raises: - ValidationError: If memory limit would be exceeded - - Example: - >>> validate_memory_limit(img.nbytes, limit_mb=256) - >>> # Raises if image > 256MB - - Security: - - Prevents memory exhaustion attacks - - Enforces resource limits - """ - limit_bytes = limit_mb * 1024 * 1024 - - if estimated_bytes > limit_bytes: - estimated_mb = estimated_bytes / (1024 * 1024) - raise ValidationError( - f"{operation} requires too much memory: " - f"{estimated_mb:.1f}MB (maximum: {limit_mb}MB)" - ) - -def validate_output_path( - path: str, - allowed_extensions: Tuple[str, ...] = ALLOWED_IMAGE_EXTENSIONS, - create_parent: bool = True, -) -> Path: - """ - Validate and prepare output path. - - Args: - path: Output file path - allowed_extensions: Allowed file extensions - create_parent: Whether to create parent directories - - Returns: - Validated Path object - - Raises: - ValidationError: If path is invalid - - Example: - >>> output_path = validate_output_path("output.jpg") - >>> # Parent directories created if needed - """ - path_obj = Path(path) - - # Check extension - if path_obj.suffix.lower() not in allowed_extensions: - raise ValidationError( - f"Invalid output extension '{path_obj.suffix}'. " - f"Allowed: {', '.join(allowed_extensions)}" - ) - - # Check for path traversal - if ".." in path_obj.parts: - raise ValidationError("Path traversal attempt detected") - - # Create parent directory if needed - if create_parent: - try: - path_obj.parent.mkdir(parents=True, exist_ok=True) - except (OSError, PermissionError) as e: - raise ValidationError(f"Cannot create output directory: {e}") - - # Check if we can write to directory - try: - # Test write access by creating and deleting a temp file - test_file = path_obj.parent / f".colorcast_test_{os.getpid()}" - test_file.touch() - test_file.unlink() - except (OSError, PermissionError) as e: - raise ValidationError(f"No write permission for output directory: {e}") - - return path_obj \ No newline at end of file + return img_array diff --git a/docs/conf.py b/docs/conf.py index 9c60a21..15d87a5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,14 +10,16 @@ # Add the project root to the path sys.path.insert(0, str(Path(__file__).parent.parent)) +from colorcast._version import __version__ + # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'ColorCast' -copyright = '2024, ColorCast Contributors' -author = 'ColorCast Contributors' -release = '2.4.2' -version = '2.4.2' +copyright = '2025–2026, Michail Semoglou' +author = 'Michail Semoglou' +release = __version__ +version = __version__ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -49,7 +51,8 @@ html_theme = 'sphinx_rtd_theme' -html_static_path = ['_static'] +# No static files yet; remove when a `_static/` directory is created +# html_static_path = ['_static'] # Custom sidebar templates html_sidebars = { diff --git a/docs/index.rst b/docs/index.rst index 2916517..592b466 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,7 @@ ColorCast Documentation -===================== +====================== -.. image:: https://img.shields.io/badge/version-2.4.2-blue.svg +.. image:: https://img.shields.io/badge/version-2.5.0-blue.svg :target: https://github.com/MichailSemoglou/ColorCast :alt: Version @@ -10,33 +10,31 @@ ColorCast Documentation :alt: Python Version ColorCast is a Python library for color and style transfer between images, -providing multiple algorithms, GPU acceleration, and analysis tools. +providing multiple algorithms, CVD simulation, Daltonization, and analysis tools. **Features:** * Multiple color transfer algorithms (histogram matching, mean/std, Lab space, LUT-based) -* GPU acceleration with CuPy (10-50x speedup on supported hardware) +* Color-vision-deficiency simulation (protanopia, deuteranopia, tritanopia) +* Daltonization correction to re-encode lost chromatic information * Selective color transfer (shadows, midtones, highlights) -* Blending and intensity control -* Batch processing for multiple images -* Caching for performance optimization -* Analysis tools (PSNR, SSIM, color metrics) -* Visualization utilities for comparing methods -* Property-based testing for correctness +* Batch processing and LRU caching +* Analysis tools (PSNR, SSIM, color metrics, error maps) +* CLI, GUI, and Python library API **Quick Start** .. code-block:: python from colorcast import load_image, color_transfer_meanstd, save_image - + # Load images content = load_image("content.jpg") style = load_image("style.jpg") - + # Apply color transfer result = color_transfer_meanstd(content, style) - + # Save result save_image(result, "output.jpg") @@ -44,12 +42,6 @@ providing multiple algorithms, GPU acceleration, and analysis tools. :maxdepth: 2 :caption: Contents: - quickstart - user_guide - api/index - advanced/index - contributing - Installation ------------ @@ -60,59 +52,18 @@ Installation pip install colorcast -**Full Installation (with development tools):** +**From source:** .. code-block:: bash git clone https://github.com/MichailSemoglou/ColorCast.git cd ColorCast - pip install -e . - pip install -r requirements-dev.txt - -**GPU Support (optional):** - -.. code-block:: bash - - pip install cupy-cuda12x # For CUDA 12.x - # or - pip install cupy-cuda11x # For CUDA 11.x - - -Documentation Structure ---------------------- - -* :doc:`quickstart` - Get started with basic usage -* :doc:`user_guide` - Comprehensive user guide -* :doc:`api/index` - API reference -* :doc:`advanced/index` - Advanced topics and academic tools - -Academic Use -============ - -ColorCast is designed for both practical applications and academic research. -The library includes: - -* Quantitative metrics (PSNR, SSIM, color distance) -* Method comparison tools -* Visualization utilities -* Property-based testing for correctness verification - -See :doc:`advanced/comparison` for details. - -Performance -=========== - -* **CPU Processing:** Optimized NumPy operations -* **GPU Acceleration:** CuPy support for 10-50x speedup -* **Batch Processing:** Parallel processing for multiple images -* **Caching:** LRU cache for repeated operations - -See :doc:`advanced/performance` for benchmarks. + pip install -e ".[dev,analysis]" License ======= -This project is licensed under the MIT License - see the LICENSE file for details. +This project is licensed under the MIT License — see the LICENSE file for details. Indices and tables ================== diff --git a/docs/wiki/Color-Vision-Background.md b/docs/wiki/Color-Vision-Background.md deleted file mode 100644 index f785dfb..0000000 --- a/docs/wiki/Color-Vision-Background.md +++ /dev/null @@ -1,152 +0,0 @@ -# Color-Vision Background - -This page explains the color-vision science behind ColorCast's simulation, error-map, and Daltonization modules. For API details, see the module docstrings and the API reference. - ---- - -## Human Color Vision - -The human retina contains three classes of cone photoreceptors: - -- **L cones** (long-wavelength / "red" sensitive, ~560 nm peak) -- **M cones** (medium-wavelength / "green" sensitive, ~530 nm peak) -- **S cones** (short-wavelength / "blue" sensitive, ~420 nm peak) - -A _trichromat_ encodes incoming light as a three-dimensional signal. A _dichromat_ can only represent a two-dimensional projection of that signal because one cone class is absent or non-functional (Smith & Pokorny, 1975). - -| Condition | Missing cone | Common name | -| ------------ | ------------ | ----------- | -| Protanopia | L | Red-blind | -| Deuteranopia | M | Green-blind | -| Tritanopia | S | Blue-blind | - -In information-theoretic terms, dichromacy is a lossy dimensionality reduction of the color signal from three cone dimensions to two. Two colors that are perceptually distinct for a trichromat may map to the same point in the dichromat's reduced color space, making them indistinguishable. This is the core accessibility problem Daltonization addresses. - ---- - -## Simulation Pipeline - -`ColorBlindSimulator` in `colorcast/processing/simulation.py` converts an RGB image to approximate how a dichromat would perceive it. The pipeline is conceptually the standard sRGB → linear RGB → LMS → projected LMS → linear RGB → sRGB route (Brettel, Viénot, & Mollon, 1997; Viénot, Brettel, & Mollon, 1999), but the RGB → LMS → projection → LMS → RGB chain is pre-computed into a single linear RGB matrix per deficiency using the Smith-Pokorny (1975) cone fundamentals (DaltonLens, 2021b): - -1. Normalize input to float32 in [0, 1]. Input is assumed to be nonlinear sRGB (CIE, 2004). -2. Gamma-decode sRGB to linear RGB. -3. Flatten (H, W, 3) to (N, 3) for vectorized matrix operations. -4. Apply the deficiency-specific 3×3 matrix in linear RGB space. -5. Gamma-encode linear RGB to nonlinear sRGB. -6. Clip to [0, 1], reshape to (H, W, 3), and return a float32 array. - -Each matrix row sums to 1, so achromatic whites and grays are preserved by construction. Conceptually, the matrix still collapses 3-D LMS space onto a 2-D confusion plane: any two colors that differed only along the eliminated cone axis are mapped to a single point and become indistinguishable to the simulated observer. - -### Deficiency-specific notes - -- **Protanopia and Deuteranopia**: the linear RGB matrices follow the single-matrix method described by Viénot, Brettel, & Mollon (1999). -- **Tritanopia**: the implementation uses the two-half-plane construction described by Brettel, Viénot, & Mollon (1997). A pixel selects one of two pre-computed linear RGB projection matrices depending on which side of the neutral diagonal it falls in linear RGB space; the matrices already include the full RGB → LMS → projection → LMS → RGB chain. - -### Why Tritanopia Looks Different - -Tritanopia (S-cone loss) produces a visually distinct simulation compared with Protanopia or Deuteranopia because of the luminance contribution of each cone type. - -Photopic luminance is dominated by L and M cones, while the S cone contributes a negligible amount (MacLeod & Boynton, 1979). Vos and Walraven (1971) estimate the L:M luminance-weight ratio at roughly 2:1, meaning M contributes on the order of one-third of the combined L+M luminance signal, while S contributes essentially nothing. Because of this: - -- **Protanopia** removes L, which carries a large share of luminance, so the image loses contrast and saturated reds appear very dark. -- **Deuteranopia** removes M; greens shift, but perceived brightness is relatively preserved because L still carries most luminance. -- **Tritanopia** removes S, which contributes almost nothing to brightness, so overall luminance and contrast are almost unaffected. The perceptual shift is purely chromatic: short-wavelength blues and yellows become confused, while reds, greens, and overall lightness remain nearly intact. - ---- - -## Error Maps - -Phase 1 (simulation) produces an image showing what a dichromat sees. Phase 2 (`colorcast/analysis/error_map.py`) measures what was lost between the original and the simulation. - -The error map provides two complementary views: - -1. **Signed difference** (`original − simulated`, shape H×W×3): keeps direction. Positive values mean the original was brighter in that channel; negative values mean the simulation boosted it. This signed map is the direct input to Daltonization: - - ```python - corrected = np.clip(original + alpha * signed, 0, 1) - ``` - -2. **Absolute magnitude map** (`|original − simulated|`, same shape): unsigned “heat” showing how much color information was destroyed per pixel, regardless of direction. This is the basis of the visual heatmap and scalar summary statistics. - -### Luminance Masking - -A plain RGB difference conflates two independent effects: - -- **Luminance** changes – shifts in perceived lightness (`L*` in CIE `Lab*`; CIE, 2004). These are mostly irrelevant for accessibility analysis because a dichromat's brightness perception is usually intact. -- **Chromaticity** changes – shifts in hue and saturation (`a*` and `b*` in `Lab*`). This is the actual color confusion we care about. - -To isolate chromaticity, the error map converts the difference image to CIE `Lab*` and zeros out the `L*` channel, leaving only `a*` (red-green axis) and `b*` (blue-yellow axis). The Euclidean magnitude `sqrt(a*² + b*²)` then represents pure chromatic error – the blueprint for Daltonization. - ---- - -## Daltonization - -Daltonization (`colorcast/analysis/daltonization.py`) is a channel re-encoding strategy. Rather than trying to restore a missing cone class, it re-routes the lost chromatic information onto a surviving channel that the affected observer can perceive (Huang, Chen, Jen, & Wang, 2009; Rasche, Geist, & Westall, 2005). - -Phase 2 computes: - -```python -signed_error = original - simulated -``` - -This is the chromatic information that went missing when the original image was projected through the dichromat's reduced color space. The signed direction tells us which way the color shifted, which is critical for re-encoding. - -### Shift Matrices - -A 3×3 shift matrix routes each error channel into each output channel. The matrices in this implementation are design choices, not direct copies from a single published source. - -For Deuteranopia and Protanopia (red-green axis missing): - -```text -ΔR = 0 -ΔG = 0 -ΔB = 0.7·eR + 0.1·eG -``` - -The Blue channel is intact for both conditions. By adding the red-green error into blue, the confusable hue pair is shifted along the blue-yellow axis – an axis the dichromat can discriminate. - -For Tritanopia (blue-yellow axis missing): - -```text -ΔR = 0.7·eB -ΔG = 0.7·eB -ΔB = 0 -``` - -Here the Blue channel error is re-routed into the red-green axis, which is fully functional in tritanopes. Both R and G receive the same injection so the correction appears as a luminance modulation, which is detectable even without hue discrimination. - -### Perceptual Weighting - -Applying correction uniformly across the image would shift colors that are already discriminable and create an over-processed look. The Phase 2 `chroma_error` map encodes, per pixel, how much chromatic information was destroyed. It is normalized to [0, 1] and used as a spatial weight: - -```python -weight = chroma_error / percentile(chroma_error, 95) -correction *= weight -``` - -Pixels with low chroma_error are left nearly unchanged; pixels with high chroma_error receive the strongest shift. - -### Luminance Preservation - -After the RGB correction, a CIE `Lab*` round-trip restores the original luminance channel (`L*`): - -```python -corrected_lab[:, :, 0] = original_lab[:, :, 0] -``` - -This preserves global brightness and ensures the only change is in the (`a*`, `b*`) chromaticity plane. The result may look color-shifted to a standard trichromat, but the chromatic contrasts that were invisible to the affected observer are now encoded in a channel they can perceive. - ---- - -## References - -- Brettel, H., Viénot, F., & Mollon, J. D. (1997). Computerized simulation of color appearance for dichromats. _Journal of the Optical Society of America A_, 14(10), 2647-2655. DOI: 10.1364/josaa.14.002647. -- Commission Internationale de l'Éclairage. (2004). _Colorimetry_ (3rd ed.) (CIE Publication No. 15:2004). CIE. -- DaltonLens. (2021a). Understanding LMS-based color blindness simulations. https://daltonlens.org/understanding-cvd-simulation/. -- DaltonLens. (2021b). Accurate SVG filters for color blindness simulation. https://daltonlens.org/cvd-simulation-svg-filters/. -- Huang, J.-B., Chen, C.-S., Jen, T.-C., & Wang, S.-J. (2009). Image recolorization for the colorblind. In _Proceedings of the IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP)_, pp. 1161-1164. DOI: 10.1109/ICASSP.2009.4959795. -- MacLeod, D. I. A., & Boynton, R. M. (1979). Chromaticity diagram showing cone excitation by stimuli of equal luminance. _Journal of the Optical Society of America_, 69(8), 1183-1186. DOI: 10.1364/josa.69.001183. -- Rasche, K., Geist, R., & Westall, J. (2005). Re-coloring Images for gamuts of lower dimension. _Computer Graphics Forum_, 24(3), 423-432. DOI: 10.1111/j.1467-8659.2005.00867.x. -- Smith, V. C., & Pokorny, J. (1975). Spectral sensitivity of the foveal cone photopigments between 400 and 500 nm. _Vision Research_, 15(2), 161-171. DOI: 10.1016/0042-6989(75)90203-5. -- Viénot, F., Brettel, H., & Mollon, J. D. (1999). Digital video colourmaps for checking the legibility of displays by dichromats. _Color Research & Application_, 24(4), 243-252. DOI: 10.1002/(SICI)1520-6378(199908)24:4<243::AID-COL5>3.0.CO;2-3. -- Vos, J. J., & Walraven, P. L. (1971). On the derivation of the foveal receptor primaries. _Vision Research_, 11(8), 799-818. DOI: 10.1016/0042-6989(71)90003-4. diff --git a/imgs/CVD-Accessibility-Dashboard.png b/imgs/CVD-Accessibility-Dashboard.png new file mode 100644 index 0000000..ac5e86f Binary files /dev/null and b/imgs/CVD-Accessibility-Dashboard.png differ diff --git a/imgs/Compare-Transfer-Methods.png b/imgs/Compare-Transfer-Methods.png new file mode 100644 index 0000000..a2d5e0d Binary files /dev/null and b/imgs/Compare-Transfer-Methods.png differ diff --git a/imgs/dashboard_report.png b/imgs/dashboard_report.png new file mode 100644 index 0000000..c6ef2de Binary files /dev/null and b/imgs/dashboard_report.png differ diff --git a/imgs/interface.png b/imgs/interface.png index 4ef37af..c900858 100644 Binary files a/imgs/interface.png and b/imgs/interface.png differ diff --git a/pyproject.toml b/pyproject.toml index 7f4c652..4e1bf02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "colorcast" -version = "2.4.2" +dynamic = ["version"] description = "Color and style transfer toolkit for images" readme = "README.md" requires-python = ">=3.10" @@ -43,19 +43,18 @@ dev = [ "pytest-cov>=4.0.0", "pytest-qt>=4.0.0", "pytest-benchmark>=4.0.0", - "pytest-mock>=3.10.0", "hypothesis>=6.0.0", + "imageio>=2.31.0", "black>=23.0.0", "isort>=5.12.0", "mypy>=1.0.0", - "pylint>=2.17.0", "ruff>=0.1.0", "Pillow>=9.0.0", ] docs = [ "sphinx>=7.0.0", - "sphinx-rtd-theme>=2.0.0", + "sphinx-rtd-theme==3.1.0", "sphinx-autodoc-typehints>=1.24.0", "sphinxcontrib-jquery>=4.1.0", ] @@ -82,6 +81,9 @@ build-backend = "setuptools.build_meta" where = ["."] exclude = ["tests*"] +[tool.setuptools.dynamic] +version = {attr = "colorcast._version.__version__"} + [tool.black] line-length = 100 target-version = ['py310', 'py311', 'py312', 'py313'] @@ -91,11 +93,31 @@ profile = "black" line_length = 100 [tool.mypy] -python_version = "3.10" -warn_return_any = true +python_version = "3.12" +warn_return_any = false warn_unused_configs = true ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "colorcast.gui" +ignore_errors = true + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B", "SIM", "C4"] +ignore = ["B028"] + +[tool.ruff.lint.per-file-ignores] +"tests/test_property_based.py" = ["E501"] +"tests/test_comparison.py" = ["E501"] +"tests/test_cli.py" = ["E501"] +"tests/test_entry_points.py" = ["E501"] +"tests/test_integration_comprehensive.py" = ["E501"] +"tests/test_performance.py" = ["E501"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 3646da0..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,34 +0,0 @@ -# Development dependencies for colorcast - -# Core dependencies (installed via main requirements.txt) -scikit-image>=0.19.0 -numpy>=1.21.0 - -# Development tools -pytest>=7.0.0 -pytest-cov>=3.0.0 -pytest-benchmark>=4.0.0 -pytest-qt>=4.0.0 -hypothesis>=6.0.0 -pytest-mock>=3.10.0 - -# Code quality tools -black>=22.0.0 -isort>=5.10.0 -mypy>=1.0.0 -pylint>=2.14.0 -ruff>=0.1.0 - -# Documentation tools -sphinx>=4.5.0 -sphinx-autodoc-typehints>=1.19.0 -sphinx-rtd-theme>=1.0.0 -sphinxcontrib-jquery>=4.1.0 - -# Type checking -mypy>=1.0.0 - -# Optional GPU acceleration (uncomment if you have CUDA) -# cupy-cuda11x>=11.0.0 # Linux, Windows (CUDA 11.x) -# Note: GPU acceleration is optional and gracefully falls back to CPU -# if CuPy is not installed, CPU implementations are used \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f760210..0000000 --- a/requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -# Core dependencies for colorcast -numpy>=1.21.0 -scipy>=1.7.0 -scikit-image>=0.19.0 -Pillow>=9.0.0 -matplotlib>=3.5.0 -PyQt5>=5.15.0 - -# GUI and interface -PyQt5-Qt5>=5.15.0 - -# Optional GPU support (requires CUDA) -# cupy-cuda11x>=11.0.0 # Linux, Windows (CUDA 11.x) -# Note: GPU acceleration is optional and gracefully falls back to CPU -# if CuPy is not installed, CPU implementations are used \ No newline at end of file diff --git a/scripts/daltonization_efficacy.py b/scripts/daltonization_efficacy.py new file mode 100644 index 0000000..c4dda5e --- /dev/null +++ b/scripts/daltonization_efficacy.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Measure Daltonization efficacy: before/after mean chroma-error ratio. + +For each deficiency type the script: + +1. Simulates the deficiency on the original image. +2. Measures the baseline chroma error (original vs simulated). +3. Daltonizes the original and re-simulates the corrected output. +4. Measures the post-correction chroma error. +5. Reports the ratio ``after_mean / before_mean`` — values below 1.0 + indicate improvement (less error after correction). + +The script seeds the RNG for deterministic output and targets the samples +that the CI review used. + +Usage:: + + .venv/bin/python scripts/daltonization_efficacy.py +""" + +from __future__ import annotations + +import numpy as np + +from colorcast.analysis.daltonization import daltonize +from colorcast.analysis.error_map import get_error_map +from colorcast.processing.simulation import ColorBlindSimulator + +rng = np.random.default_rng(seed=2026_07_31) + +DEFICIENCIES: tuple[str, ...] = ("protanopia", "deuteranopia", "tritanopia") +NUM_SAMPLES: int = 10 +IMAGE_SIZE: int = 128 + + +def _generate_sample() -> np.ndarray: + """Generate a random RGB image in [0, 1] for testing.""" + return rng.random((IMAGE_SIZE, IMAGE_SIZE, 3), dtype=np.float32) + + +def _mean_chroma_error(original: np.ndarray, simulated: np.ndarray) -> float: + """Return the mean chroma error between *original* and *simulated*.""" + em = get_error_map(original, simulated) + return float(em.chroma_error.mean()) + + +def main() -> None: + simulator = ColorBlindSimulator() + print(f"{'Deficiency':<16} {'Before':>10} {'After':>10} {'Ratio (A/B)':>12}") + print("-" * 56) + + for deficiency in DEFICIENCIES: + before_means: list[float] = [] + after_means: list[float] = [] + + for _ in range(NUM_SAMPLES): + img = _generate_sample() + simulated = simulator.transform_color_space(img, deficiency) + before = _mean_chroma_error(img, simulated) + + corrected = daltonize(img, deficiency, intensity=1.0) + re_simulated = simulator.transform_color_space(corrected, deficiency) + after = _mean_chroma_error(corrected, re_simulated) + + before_means.append(before) + after_means.append(after) + + avg_before = float(np.mean(before_means)) + avg_after = float(np.mean(after_means)) + ratio = avg_after / avg_before if avg_before > 1e-9 else float("inf") + + print( + f" {deficiency:<14} " + f"{avg_before:>10.4f} " + f"{avg_after:>10.4f} " + f"{ratio:>12.4f}" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py index cec87cd..e6d2980 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Test suite for ColorCast.""" \ No newline at end of file +"""Test suite for ColorCast.""" diff --git a/tests/test_batch.py b/tests/test_batch.py index a7505ab..d78fded 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -1,13 +1,13 @@ """Tests for batch processing functionality.""" +from pathlib import Path + import numpy as np import pytest -from pathlib import Path from colorcast.processing.batch import BatchProcessor -from colorcast.processing.image_loader import load_image, save_image +from colorcast.processing.image_loader import save_image from colorcast.processing.transfer_methods import match_histograms_multichannel -from colorcast.utils.exceptions import ImageLoadError @pytest.fixture @@ -52,11 +52,8 @@ def callback(processed, total): assert processor.progress_callback == callback - def test_process_directory_success( - self, tmp_path, sample_image, sample_style_image - ): + def test_process_directory_success(self, tmp_path, sample_image, sample_style_image): """Test successful directory processing.""" - from colorcast.processing.image_loader import save_image # Create test directory content_dir = tmp_path / "content" @@ -90,11 +87,8 @@ def test_process_directory_success( # Check no failed files assert len(processor.failed_files) == 0 - def test_process_directory_with_callback( - self, tmp_path, sample_image, sample_style_image - ): + def test_process_directory_with_callback(self, tmp_path, sample_image, sample_style_image): """Test directory processing with progress callback.""" - from colorcast.processing.image_loader import save_image # Create test directory content_dir = tmp_path / "content" @@ -119,7 +113,7 @@ def callback(processed, total): max_workers=2, progress_callback=callback, ) - results = processor.process_directory( + processor.process_directory( content_dir=content_dir, style_image=tmp_path / "style.jpg", output_dir=output_dir, @@ -131,11 +125,8 @@ def callback(processed, total): assert final_call[0] == 5 # processed assert final_call[1] == 5 # total - def test_process_directory_pattern_filter( - self, tmp_path, sample_image, sample_style_image - ): + def test_process_directory_pattern_filter(self, tmp_path, sample_image, sample_style_image): """Test pattern filtering in directory processing.""" - from colorcast.processing.image_loader import save_image # Create test directory content_dir = tmp_path / "content" @@ -163,11 +154,8 @@ def test_process_directory_pattern_filter( # Should only process 2 JPG files assert len(results) == 2 - def test_process_directory_error_handling( - self, tmp_path, sample_image, sample_style_image - ): + def test_process_directory_error_handling(self, tmp_path, sample_image, sample_style_image): """Test error handling in directory processing.""" - from colorcast.processing.image_loader import save_image # Create test directory content_dir = tmp_path / "content" @@ -199,121 +187,8 @@ def test_process_directory_error_handling( assert len(processor.failed_files) == 1 assert "invalid.jpg" in str(processor.failed_files[0][0]) - def test_process_pairs_success( - self, tmp_path, sample_image, sample_style_image - ): - """Test successful pair processing.""" - # Create test directory - output_dir = tmp_path / "output" - output_dir.mkdir() - - # Create pairs - pairs = [ - (tmp_path / "content1.jpg", tmp_path / "style1.jpg"), - (tmp_path / "content2.jpg", tmp_path / "style2.jpg"), - (tmp_path / "content3.jpg", tmp_path / "style3.jpg"), - ] - - # Save images - for content_path, style_path in pairs: - save_image(sample_image, str(content_path)) - save_image(sample_style_image, str(style_path)) - - # Process pairs - processor = BatchProcessor( - transfer_method=match_histograms_multichannel, - ) - results = processor.process_pairs( - image_pairs=pairs, - output_dir=output_dir, - ) - - # Verify results - assert len(results) == 3 - for i, result_path in enumerate(results): - assert result_path.exists() - assert result_path.name == f"result_{i:04d}.jpg" - - # Check no failed pairs - assert len(processor.failed_files) == 0 - - def test_process_pairs_with_callback( - self, tmp_path, sample_image, sample_style_image - ): - """Test pair processing with progress callback.""" - # Create test directory - output_dir = tmp_path / "output" - output_dir.mkdir() - - # Create pairs - pairs = [ - (tmp_path / "content1.jpg", tmp_path / "style1.jpg"), - (tmp_path / "content2.jpg", tmp_path / "style2.jpg"), - ] - - # Save images - for content_path, style_path in pairs: - save_image(sample_image, str(content_path)) - save_image(sample_style_image, str(style_path)) - - # Track callback calls - callback_calls = [] - - def callback(processed, total): - callback_calls.append((processed, total)) - - # Process pairs - processor = BatchProcessor( - transfer_method=match_histograms_multichannel, - progress_callback=callback, - ) - results = processor.process_pairs( - image_pairs=pairs, - output_dir=output_dir, - ) - - # Verify callback was called - assert len(callback_calls) == 2 - - def test_process_pairs_error_handling( - self, tmp_path, sample_image, sample_style_image - ): - """Test error handling in pair processing.""" - # Create test directory - output_dir = tmp_path / "output" - output_dir.mkdir() - - # Create pairs (one valid, one invalid) - pairs = [ - (tmp_path / "valid.jpg", tmp_path / "valid_style.jpg"), - (tmp_path / "invalid.jpg", tmp_path / "invalid_style.jpg"), - ] - - # Save valid pair - save_image(sample_image, str(pairs[0][0])) - save_image(sample_style_image, str(pairs[0][1])) - # Don't save invalid files - - # Process pairs - processor = BatchProcessor( - transfer_method=match_histograms_multichannel, - ) - results = processor.process_pairs( - image_pairs=pairs, - output_dir=output_dir, - ) - - # Should process 1 valid pair - assert len(results) == 1 - - # Should have failed pairs - assert len(processor.failed_files) >= 1 - - def test_failed_files_tracking( - self, tmp_path, sample_image, sample_style_image - ): + def test_failed_files_tracking(self, tmp_path, sample_image, sample_style_image): """Test that failed files are properly tracked.""" - from colorcast.processing.image_loader import save_image # Create test directory content_dir = tmp_path / "content" @@ -331,7 +206,7 @@ def test_failed_files_tracking( processor = BatchProcessor( transfer_method=match_histograms_multichannel, ) - results = processor.process_directory( + processor.process_directory( content_dir=content_dir, style_image=tmp_path / "style.jpg", output_dir=output_dir, @@ -343,4 +218,4 @@ def test_failed_files_tracking( assert isinstance(failed_path, Path) assert "invalid.jpg" in str(failed_path) assert isinstance(error_msg, str) - assert len(error_msg) > 0 \ No newline at end of file + assert len(error_msg) > 0 diff --git a/tests/test_blending.py b/tests/test_blending.py index 4ad092a..fe82865 100644 --- a/tests/test_blending.py +++ b/tests/test_blending.py @@ -1,7 +1,7 @@ """Tests for image blending.""" -import pytest import numpy as np + from colorcast.processing.blending import blend_images @@ -69,4 +69,4 @@ def test_blend_various_intensities(self): for intensity in [0.0, 0.25, 0.5, 0.75, 1.0]: result = blend_images(original, styled, intensity) assert result.shape == original.shape - np.testing.assert_array_almost_equal(result, np.ones((100, 100, 3)) * intensity) \ No newline at end of file + np.testing.assert_array_almost_equal(result, np.ones((100, 100, 3)) * intensity) diff --git a/tests/test_cache.py b/tests/test_cache.py index f15fe68..38375a2 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -2,8 +2,8 @@ import threading -import pytest import numpy as np + from colorcast.processing.cache import StyleTransferCache @@ -47,7 +47,7 @@ def test_cache_lru_eviction(self): cache = StyleTransferCache(max_size=3) # Add 3 items - for i in range(3): + for _i in range(3): content = np.random.rand(100, 100, 3) style = np.random.rand(100, 100, 3) styled = np.random.rand(100, 100, 3) @@ -96,6 +96,82 @@ def test_cache_clear(self): cache.clear() assert cache.size() == 0 + def test_cache_clear_resets_stats(self): + """Test cache clearing resets stored entries and counters.""" + cache = StyleTransferCache(max_size=5) + + content = np.random.rand(50, 50, 3) + style = np.random.rand(50, 50, 3) + styled = np.random.rand(50, 50, 3) + + cache.set(content, style, "histogram", styled) + cache.get(content, style, "histogram") + cache.get(content, style, "meanstd") + + assert cache.size() == 1 + assert cache.stats()["hits"] == 1 + assert cache.stats()["misses"] == 1 + + cache.clear() + + assert cache.size() == 0 + assert cache.stats()["hits"] == 0 + assert cache.stats()["misses"] == 0 + + def test_generate_key_matches_internal_derivation(self): + """The public helper should mirror the internal key derivation.""" + cache = StyleTransferCache(max_size=5) + + content = np.zeros((4, 4, 3), dtype=np.float32) + style = np.ones((4, 4, 3), dtype=np.float32) + params = {"shadow_threshold": 0.3} + + expected = cache._generate_key( + cache._compute_hash(content), + cache._style_hash(style), + "histogram", + params, + ) + expected_without_params = cache._generate_key( + cache._compute_hash(content), + cache._style_hash(style), + "histogram", + {}, + ) + + assert cache.generate_key(content, style, "histogram", params) == expected + assert cache.generate_key(content, style, "histogram") == expected_without_params + + def test_generate_key_changes_for_any_pixel_change(self): + """Any pixel change should produce a different cache key.""" + cache = StyleTransferCache(max_size=5) + + style = np.zeros((256, 256, 3), dtype=np.float32) + content = np.zeros((256, 256, 3), dtype=np.float32) + modified_content = content.copy() + modified_content[5, 5, :] = 1.0 + + assert cache.generate_key(content, style, "histogram") != cache.generate_key( + modified_content, + style, + "histogram", + ) + + def test_compute_hash_handles_contiguous_sliced_and_transposed_arrays(self): + """Hashing should work for contiguous, sliced, and transposed arrays.""" + cache = StyleTransferCache(max_size=5) + base = np.arange(24, dtype=np.float32).reshape(2, 3, 4) + + contiguous_hash = cache._compute_hash(base) + sliced_hash = cache._compute_hash(base[:, :2, :]) + transposed_hash = cache._compute_hash(np.transpose(base, (1, 0, 2))) + + assert contiguous_hash + assert sliced_hash + assert transposed_hash + assert contiguous_hash != sliced_hash + assert contiguous_hash != transposed_hash + def test_cache_with_parameters(self): """Test caching with method parameters.""" cache = StyleTransferCache(max_size=5) @@ -204,4 +280,4 @@ def thread2_fn(): assert not t1.is_alive(), "thread 1 did not complete" assert not t2.is_alive(), "thread 2 did not complete" assert cache.size() == 1 - np.testing.assert_array_equal(results["second"], first_value) \ No newline at end of file + np.testing.assert_array_equal(results["second"], first_value) diff --git a/tests/test_cli.py b/tests/test_cli.py index 05933db..962bc62 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,17 +6,17 @@ import argparse import sys +from unittest import mock + import numpy as np import pytest -from unittest import mock from colorcast.__main__ import ( - parse_args, - cmd_transfer, - cmd_batch, - cmd_list_methods, cmd_info, + cmd_list_methods, + cmd_transfer, main, + parse_args, ) from colorcast.processing.image_loader import ALLOWED_IMAGE_EXTENSIONS @@ -24,6 +24,7 @@ def _make_args(**overrides): """Build an argparse.Namespace with sensisible defaults.""" import pathlib + defaults = { "command": "transfer", "content": pathlib.Path("fake_content.jpg"), @@ -53,7 +54,16 @@ def test_transfer_command(self): assert args.method == "histogram" def test_transfer_command_with_method(self): - argv = ["colorcast", "transfer", "content.jpg", "style.jpg", "-o", "output.jpg", "-m", "meanstd"] + argv = [ + "colorcast", + "transfer", + "content.jpg", + "style.jpg", + "-o", + "output.jpg", + "-m", + "meanstd", + ] with mock.patch.object(sys, "argv", argv): args = parse_args() assert args.command == "transfer" @@ -94,6 +104,7 @@ def test_transfer_with_simulator_requires_no_style(self, tmp_path): output_path = tmp_path / "output.png" import imageio.v3 as iio + iio.imwrite(str(content_path), (source * 255).astype(np.uint8)) args = _make_args( @@ -115,6 +126,7 @@ def test_transfer_with_style(self, tmp_path): output_path = tmp_path / "output.png" import imageio.v3 as iio + iio.imwrite(str(content_path), (source * 255).astype(np.uint8)) iio.imwrite(str(style_path), (reference * 255).astype(np.uint8)) @@ -137,6 +149,7 @@ def test_transfer_with_intensity_blend(self, tmp_path): output_path = tmp_path / "output.png" import imageio.v3 as iio + iio.imwrite(str(content_path), (source * 255).astype(np.uint8)) iio.imwrite(str(style_path), (reference * 255).astype(np.uint8)) @@ -157,6 +170,7 @@ def test_transfer_missing_style_for_style_method_raises(self, tmp_path): output_path = tmp_path / "output.png" import imageio.v3 as iio + iio.imwrite(str(content_path), (source * 255).astype(np.uint8)) args = _make_args( @@ -177,6 +191,7 @@ def test_transfer_creates_output_parent_dirs(self, tmp_path): output_path = tmp_path / "subdir" / "nested" / "output.png" import imageio.v3 as iio + iio.imwrite(str(content_path), (source * 255).astype(np.uint8)) iio.imwrite(str(style_path), (reference * 255).astype(np.uint8)) @@ -246,9 +261,8 @@ def test_main_list_methods_command(self, capsys): def test_main_unknown_command_exits(self): argv = ["colorcast", "bogus-command"] - with mock.patch.object(sys, "argv", argv): - with pytest.raises(SystemExit): - main() + with mock.patch.object(sys, "argv", argv), pytest.raises(SystemExit): + main() def test_main_transfer_simulate(self, tmp_path, capsys): source = np.random.rand(50, 50, 3).astype(np.float32) @@ -256,13 +270,17 @@ def test_main_transfer_simulate(self, tmp_path, capsys): output_path = tmp_path / "output.png" import imageio.v3 as iio + iio.imwrite(str(content_path), (source * 255).astype(np.uint8)) argv = [ - "colorcast", "transfer", + "colorcast", + "transfer", str(content_path), - "-o", str(output_path), - "-m", "simulate_protanopia", + "-o", + str(output_path), + "-m", + "simulate_protanopia", ] with mock.patch.object(sys, "argv", argv): main() diff --git a/tests/test_color_blindness.py b/tests/test_color_blindness.py index a382ae2..6240bf7 100644 --- a/tests/test_color_blindness.py +++ b/tests/test_color_blindness.py @@ -92,7 +92,9 @@ def test_white_and_gray_are_preserved(self, simulator, deficiency): for neutral in [white, gray]: simulated = simulator.transform_color_space(neutral, deficiency) np.testing.assert_allclose( - simulated, neutral, atol=1e-5, + simulated, + neutral, + atol=1e-5, err_msg=f"{deficiency} does not preserve achromatic input", ) @@ -148,6 +150,36 @@ def test_chroma_error_non_negative(self, rgb_image): assert np.all(error_map.chroma_error >= 0.0) assert np.all(error_map.absolute >= 0.0) + def test_compute_dE00_optional_output(self, rgb_image): + simulator = ColorBlindSimulator() + simulated = simulator.transform_color_space(rgb_image, "deuteranopia") + + error_map_default = get_error_map(rgb_image, simulated) + error_map_enabled = get_error_map(rgb_image, simulated, compute_dE00=True) + + assert error_map_default.chroma_error_dE00 is None + assert error_map_enabled.chroma_error_dE00.shape == rgb_image.shape[:2] + assert error_map_enabled.chroma_error_dE00.dtype == np.float32 + assert np.isfinite(error_map_enabled.chroma_error_dE00).all() + assert np.all(error_map_enabled.chroma_error_dE00 >= 0.0) + + identical = np.array([[[0.2, 0.1, 0.3]]], dtype=np.float32) + identical_error_map = get_error_map(identical, identical, compute_dE00=True) + np.testing.assert_array_equal( + identical_error_map.chroma_error_dE00, np.zeros((1, 1), dtype=np.float32) + ) + + original = np.array([[[0.2, 0.1, 0.3]]], dtype=np.float32) + simulated_pair = np.array([[[0.8, 0.2, 0.1]]], dtype=np.float32) + orig_lab = skcolor.rgb2lab(original) + sim_lab = skcolor.rgb2lab(simulated_pair) + orig_lab[:, :, 0] = 50.0 + sim_lab[:, :, 0] = 50.0 + expected = skcolor.deltaE_ciede2000(orig_lab, sim_lab).astype(np.float32) + + pair_error_map = get_error_map(original, simulated_pair, compute_dE00=True) + np.testing.assert_allclose(pair_error_map.chroma_error_dE00, expected, rtol=1e-5, atol=1e-5) + def test_shape_mismatch_raises(self, rgb_image): other = np.random.rand(16, 16, 3).astype(np.float32) @@ -204,6 +236,10 @@ def test_daltonize_output_range(self, rgb_image, deficiency): assert np.all(result <= 1.0) assert np.all(np.isfinite(result)) + def test_daltonize_zero_intensity_unknown_deficiency_raises(self, rgb_image): + with pytest.raises(ValueError, match="Unknown deficiency type"): + daltonize(rgb_image, "achromatopsia", intensity=0.0) + def test_daltonize_unknown_deficiency_raises(self, rgb_image): with pytest.raises(ValueError, match="Unknown deficiency type"): daltonize(rgb_image, "achromatopsia") diff --git a/tests/test_comparison.py b/tests/test_comparison.py index 515b2d3..445948c 100644 --- a/tests/test_comparison.py +++ b/tests/test_comparison.py @@ -2,7 +2,8 @@ import numpy as np import pytest -from colorcast.analysis.comparison import MethodComparison, _METRIC_DIRECTION + +from colorcast.analysis.comparison import _METRIC_DIRECTION, MethodComparison class TestComputePsnr: @@ -149,6 +150,7 @@ def identity_method(self): def failing_method(self): def _fail(src, ref): raise RuntimeError("test failure") + return _fail def test_baseline_included(self, source, reference, identity_method): @@ -277,19 +279,35 @@ class TestGenerateComparisonReport: def test_report_is_string(self): cm = MethodComparison() - results = {"method_a": {"psnr": 20.0, "ssim": 0.9, "color_distance": 0.1, "histogram_distance": 0.05}} + results = { + "method_a": { + "psnr": 20.0, + "ssim": 0.9, + "color_distance": 0.1, + "histogram_distance": 0.05, + } + } report = cm.generate_comparison_report(results) assert isinstance(report, str) def test_report_contains_method_name(self): cm = MethodComparison() - results = {"test_method": {"psnr": 20.0, "ssim": 0.9, "color_distance": 0.1, "histogram_distance": 0.05}} + results = { + "test_method": { + "psnr": 20.0, + "ssim": 0.9, + "color_distance": 0.1, + "histogram_distance": 0.05, + } + } report = cm.generate_comparison_report(results) assert "test_method" in report def test_report_contains_rankings_section(self): cm = MethodComparison() - results = {"A": {"psnr": 20.0, "ssim": 0.9, "color_distance": 0.1, "histogram_distance": 0.05}} + results = { + "A": {"psnr": 20.0, "ssim": 0.9, "color_distance": 0.1, "histogram_distance": 0.05} + } report = cm.generate_comparison_report(results) assert "Rankings" in report @@ -331,6 +349,15 @@ def test_excludes_baseline(self): name, _ = cm.find_best_method(results, "ssim") assert name == "A" + def test_excludes_identity_method(self): + cm = MethodComparison() + results = { + "identity": {"color_distance": 0.0, "psnr": float("inf")}, + "real_method": {"color_distance": 0.3, "psnr": 20.0}, + } + name, _ = cm.find_best_method(results, "color_distance") + assert name == "real_method" + class TestMetricDirection: """Tests for metric direction mapping.""" diff --git a/tests/test_config.py b/tests/test_config.py index 46e2309..751d66b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,8 +1,10 @@ """Tests for ColorCastConfig configuration management.""" import json -import pytest from pathlib import Path + +import pytest + from colorcast.utils.config import ColorCastConfig @@ -37,10 +39,6 @@ def test_default_max_image_dimension(self): cfg = ColorCastConfig() assert cfg.max_image_dimension == 4096 - def test_default_cache_size(self): - cfg = ColorCastConfig() - assert cfg.cache_size == 3 - def test_default_enable_parallel(self): cfg = ColorCastConfig() assert cfg.enable_parallel is True @@ -54,84 +52,62 @@ def test_default_custom_methods(self): assert cfg.custom_methods == {} -class TestColorCastConfigSaveLoad: - """Tests for save and load roundtrips.""" - - def test_save_and_load_roundtrip(self, tmp_path): - cfg = ColorCastConfig(window_width=800, window_height=600) - path = tmp_path / "config.json" - cfg.save(path) +class TestColorCastConfigPersistence: + """Tests for save/load round-trip and validation.""" + def test_save_and_load_roundtrip(self, tmp_path: Path): + cfg = ColorCastConfig(window_width=1920, default_method="meanstd") + path = cfg.save(tmp_path / "config.json") loaded = ColorCastConfig.load(path) - assert loaded.window_width == 800 - assert loaded.window_height == 600 - - def test_load_missing_file_returns_defaults(self, tmp_path): - path = tmp_path / "nonexistent.json" - cfg = ColorCastConfig.load(path) - assert isinstance(cfg, ColorCastConfig) - assert cfg.window_width == 1000 + assert loaded.window_width == 1920 + assert loaded.default_method == "meanstd" + assert loaded.window_height == 700 # unmodified default - def test_save_produces_valid_json(self, tmp_path): + def test_save_creates_parent_directories(self, tmp_path: Path): cfg = ColorCastConfig() - path = tmp_path / "config.json" + path = tmp_path / "deep" / "nested" / "config.json" cfg.save(path) + assert path.exists() - with open(path, "r") as f: - data = json.load(f) - assert json.loads(json.dumps(data)) == data - - def test_save_preserves_all_fields(self, tmp_path): - cfg = ColorCastConfig( - window_width=1024, - default_method="meanstd", - default_intensity=0.5, - cache_size=10, + def test_load_ignores_unknown_keys(self, tmp_path: Path): + path = tmp_path / "config.json" + path.write_text( + json.dumps({"window_width": 800, "nonexistent_field": "should_be_ignored"}), + encoding="utf-8", ) + cfg = ColorCastConfig.load(path) + assert cfg.window_width == 800 + + def test_load_raises_on_bad_type(self, tmp_path: Path): path = tmp_path / "config.json" - cfg.save(path) + path.write_text( + json.dumps({"window_width": "abc"}), + encoding="utf-8", + ) + with pytest.raises(TypeError, match="window_width"): + ColorCastConfig.load(path) - loaded = ColorCastConfig.load(path) - assert loaded.window_width == 1024 - assert loaded.default_method == "meanstd" - assert loaded.default_intensity == pytest.approx(0.5) - assert loaded.cache_size == 10 + def test_load_missing_file_raises(self, tmp_path: Path): + with pytest.raises(FileNotFoundError): + ColorCastConfig.load(tmp_path / "nonexistent.json") - def test_load_filters_unknown_keys(self, tmp_path): + def test_load_partial_overlay(self, tmp_path: Path): path = tmp_path / "config.json" path.write_text( - json.dumps({"window_width": 777, "bogus_field": "intruder", "another_junk": 42}) + json.dumps({"window_width": 1024, "output_format": "jpg"}), + encoding="utf-8", ) - cfg = ColorCastConfig.load(path) - assert cfg.window_width == 777 - assert not hasattr(cfg, "bogus_field") + assert cfg.window_width == 1024 + assert cfg.output_format == "jpg" + assert cfg.window_height == 700 # default - -class TestColorCastConfigGetConfigPath: - """Tests for get_config_path.""" - - @pytest.fixture(autouse=True) - def isolate_home(self, monkeypatch, tmp_path): - monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) - - def test_returns_path_object(self): - cfg = ColorCastConfig() - path = cfg.get_config_path() + def test_get_config_path_returns_path(self): + path = ColorCastConfig.get_config_path() assert isinstance(path, Path) - - def test_returns_expected_filename(self): - cfg = ColorCastConfig() - path = cfg.get_config_path() assert path.name == "config.json" - def test_returns_expected_directory(self): - cfg = ColorCastConfig() - path = cfg.get_config_path() - assert path.parent.name == ".colorcast" - - def test_directory_is_created(self): + def test_save_returns_written_path(self, tmp_path: Path): cfg = ColorCastConfig() - path = cfg.get_config_path() - assert path.parent.exists() - assert path.parent.is_dir() + returned = cfg.save(tmp_path / "cfg.json") + assert returned == tmp_path / "cfg.json" diff --git a/tests/test_curves.py b/tests/test_curves.py index 4cd49e4..b3df08c 100644 --- a/tests/test_curves.py +++ b/tests/test_curves.py @@ -2,6 +2,7 @@ import numpy as np import pytest + from colorcast.processing.curves import apply_curve diff --git a/tests/test_entry_points.py b/tests/test_entry_points.py index f9be543..e233d1a 100644 --- a/tests/test_entry_points.py +++ b/tests/test_entry_points.py @@ -2,9 +2,6 @@ import subprocess import sys -from pathlib import Path - -import pytest class TestEntryPoints: @@ -45,18 +42,6 @@ def test_module_help(self): assert "colorcast-gui" in result.stdout assert "graphical interface" in result.stdout.lower() - def test_root_script_help(self): - """`python colorcast.py --help` exits cleanly via the compatibility shim.""" - root_script = Path(__file__).parent.parent / "colorcast.py" - result = subprocess.run( - [sys.executable, str(root_script), "--help"], - capture_output=True, - text=True, - timeout=30, - ) - assert result.returncode == 0 - assert "colorcast-gui" in result.stdout - def test_main_functions_are_exposed(self): """Both CLI and GUI launchers are importable from the package.""" from colorcast.__main__ import gui_main, main @@ -80,9 +65,7 @@ def test_cli_transfer_simulator_without_style(self, tmp_path): content_path = tmp_path / "content.png" output_path = tmp_path / "out.png" - io.imsave( - content_path, (np.random.rand(16, 16, 3) * 255).astype(np.uint8) - ) + io.imsave(content_path, (np.random.rand(16, 16, 3) * 255).astype(np.uint8)) result = subprocess.run( [ @@ -108,10 +91,36 @@ def test_cli_transfer_requires_style_for_transfer_methods(self, tmp_path): from skimage import io content_path = tmp_path / "content.png" - io.imsave( - content_path, (np.random.rand(16, 16, 3) * 255).astype(np.uint8) + io.imsave(content_path, (np.random.rand(16, 16, 3) * 255).astype(np.uint8)) + + result = subprocess.run( + [ + "colorcast", + "transfer", + str(content_path), + "-m", + "histogram", + "-o", + str(tmp_path / "out.png"), + ], + capture_output=True, + text=True, + timeout=60, ) + assert result.returncode == 2 + assert ( + "requires a style image" in result.stdout or "requires a style image" in result.stderr + ) + + def test_cli_error_shows_traceback_with_verbose(self, tmp_path): + """A failing transfer with --verbose includes a traceback.""" + import numpy as np + from skimage import io + + content_path = tmp_path / "content.png" + io.imsave(content_path, (np.random.rand(16, 16, 3) * 255).astype(np.uint8)) + result = subprocess.run( [ "colorcast", @@ -121,14 +130,15 @@ def test_cli_transfer_requires_style_for_transfer_methods(self, tmp_path): "histogram", "-o", str(tmp_path / "out.png"), + "--verbose", ], capture_output=True, text=True, timeout=60, ) - assert result.returncode == 1 - assert "requires a style image" in result.stdout + assert result.returncode == 2 + assert "Traceback" in result.stderr def test_gui_import_does_not_configure_root_logger(self): """Importing colorcast.gui must not add handlers to the root logger.""" diff --git a/tests/test_gpu_transfer.py b/tests/test_gpu_transfer.py index a0c9b48..91b6724 100644 --- a/tests/test_gpu_transfer.py +++ b/tests/test_gpu_transfer.py @@ -6,11 +6,12 @@ import numpy as np import pytest + from colorcast.processing.gpu_transfer import ( gpu_histogram_matching, - gpu_mean_std_transfer, - gpu_lab_transfer, gpu_histogram_matching_multichannel, + gpu_lab_transfer, + gpu_mean_std_transfer, is_gpu_available, ) @@ -27,26 +28,26 @@ class TestGpuHistogramMatching: """Tests for GPU-accelerated histogram matching.""" def test_basic_transfer(self): - source = (np.random.rand(100, 100, 3) * 255).astype(np.uint8) - reference = (np.random.rand(100, 100, 3) * 255).astype(np.uint8) + source = np.random.rand(100, 100, 3).astype(np.float32) + reference = np.random.rand(100, 100, 3).astype(np.float32) result = gpu_histogram_matching(source, reference) assert result.shape == source.shape - assert result.dtype == source.dtype + assert result.dtype == np.float32 - def test_output_is_uint8(self): + def test_output_is_float32(self): source = (np.random.rand(50, 50, 3) * 255).astype(np.uint8) reference = (np.random.rand(50, 50, 3) * 255).astype(np.uint8) result = gpu_histogram_matching(source, reference) - assert result.dtype == np.uint8 + assert result.dtype == np.float32 def test_identical_images(self): - image = (np.random.rand(80, 80, 3) * 255).astype(np.uint8) + image = np.random.rand(80, 80, 3).astype(np.float32) result = gpu_histogram_matching(image, image) assert result.shape == image.shape def test_no_nan_no_inf(self): - source = (np.random.rand(50, 50, 3) * 255).astype(np.uint8) - reference = (np.random.rand(50, 50, 3) * 255).astype(np.uint8) + source = np.random.rand(50, 50, 3).astype(np.float32) + reference = np.random.rand(50, 50, 3).astype(np.float32) result = gpu_histogram_matching(source, reference) assert not np.any(np.isnan(result)) assert not np.any(np.isinf(result)) @@ -160,19 +161,20 @@ class TestGpuHistogramMatchingMultichannel: """Tests for GPU-accelerated multichannel histogram matching.""" def test_basic_transfer(self): - source = (np.random.rand(100, 100, 3) * 255).astype(np.uint8) - reference = (np.random.rand(100, 100, 3) * 255).astype(np.uint8) + source = np.random.rand(100, 100, 3).astype(np.float32) + reference = np.random.rand(100, 100, 3).astype(np.float32) result = gpu_histogram_matching_multichannel(source, reference) assert result.shape == source.shape + assert result.dtype == np.float32 def test_identical_images(self): - image = (np.random.rand(80, 80, 3) * 255).astype(np.uint8) + image = np.random.rand(80, 80, 3).astype(np.float32) result = gpu_histogram_matching_multichannel(image, image) assert result.shape == image.shape - assert result.dtype == np.float32 + assert result.dtype == image.dtype def test_no_crash_on_small_inputs(self): - source = (np.random.rand(10, 10, 3) * 255).astype(np.uint8) - reference = (np.random.rand(5, 5, 3) * 255).astype(np.uint8) + source = np.random.rand(10, 10, 3).astype(np.float32) + reference = np.random.rand(5, 5, 3).astype(np.float32) result = gpu_histogram_matching_multichannel(source, reference) assert result.shape == source.shape diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..1bb0301 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,190 @@ +"""Headless GUI smoke tests for ColorCast. + +Run with: QT_QPA_PLATFORM=offscreen pytest tests/test_gui.py +""" + +import os + +# Force offscreen rendering before any Qt import +os.environ["QT_QPA_PLATFORM"] = "offscreen" + +import numpy as np +import pytest +from PyQt5 import QtCore + + +@pytest.fixture(scope="module") +def qt_app(): + """Create a QApplication for headless tests.""" + from PyQt5.QtWidgets import QApplication + + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + +def _select_combo_method(combo, method_id): + """Select a method in the combo box by its registered ID.""" + for row in range(combo.count()): + item = combo.model().item(row) + if item.data(QtCore.Qt.UserRole) == method_id: + combo.setCurrentIndex(row) + return + raise ValueError(f"Method {method_id!r} not found in combo") + + +class TestMethodSwitchTogglesStyleButton: + """Method-change toggles the style button and label correctly.""" + + def test_reference_method_enables_style_button(self, qt_app): + from colorcast.gui import StyleTransferApp + + window = StyleTransferApp() + assert window.load_style_button.isEnabled() + + def test_simulator_disables_style_button(self, qt_app): + from colorcast.gui import StyleTransferApp + + window = StyleTransferApp() + _select_combo_method(window.method_combo, "simulate_protanopia") + + assert not window.load_style_button.isEnabled() + + def test_simulator_sets_placeholder_text(self, qt_app): + from colorcast.gui import StyleTransferApp + + window = StyleTransferApp() + _select_combo_method(window.method_combo, "simulate_deuteranopia") + + assert "Not needed for this mode" in window.style_label.text() + + def test_switch_back_to_reference_enables_button(self, qt_app): + from colorcast.gui import StyleTransferApp + + window = StyleTransferApp() + + _select_combo_method(window.method_combo, "simulate_protanopia") + assert not window.load_style_button.isEnabled() + + _select_combo_method(window.method_combo, "histogram") + assert window.load_style_button.isEnabled() + + +class TestApplyWithNoImages: + """apply_style_transfer with missing images shows warning.""" + + def test_apply_without_content_shows_warning(self, qt_app, monkeypatch): + from PyQt5.QtWidgets import QMessageBox + + from colorcast.gui import StyleTransferApp + + window = StyleTransferApp() + warning_calls = [] + + def fake_warning(parent, title, msg): + warning_calls.append((title, msg)) + + monkeypatch.setattr(QMessageBox, "warning", fake_warning) + + window.apply_style_transfer() + + assert len(warning_calls) == 1 + assert warning_calls[0][0] == "Missing Images" + + def test_apply_without_style_on_reference_method_shows_warning(self, qt_app, monkeypatch): + from PyQt5.QtWidgets import QMessageBox + + from colorcast.gui import StyleTransferApp + + window = StyleTransferApp() + window.content_image = np.random.rand(16, 16, 3).astype(np.float32) + window.style_image = None + + warning_calls = [] + + def fake_warning(parent, title, msg): + warning_calls.append((title, msg)) + + monkeypatch.setattr(QMessageBox, "warning", fake_warning) + + window.apply_style_transfer() + + assert len(warning_calls) == 1 + assert "style image" in warning_calls[0][1].lower() + + +class TestShowDashboard: + """show_dashboard guards against missing content image.""" + + def test_no_content_image_shows_warning(self, qt_app, monkeypatch): + from PyQt5.QtWidgets import QMessageBox + + from colorcast.gui import StyleTransferApp + + window = StyleTransferApp() + warning_calls = [] + + def fake_warning(parent, title, msg): + warning_calls.append((title, msg)) + + monkeypatch.setattr(QMessageBox, "warning", fake_warning) + + window.show_dashboard() + + assert len(warning_calls) == 1 + assert "content image" in warning_calls[0][1].lower() + + +def _make_dashboard_result(h: int = 8, w: int = 8): + """Build a minimal DashboardResult for unit tests.""" + from colorcast.analysis.dashboard import DashboardResult + from colorcast.analysis.error_map import ErrorMap + + rng = np.random.default_rng(0) + original = rng.random((h, w, 3)).astype(np.float32) + sim = rng.random((h, w, 3)).astype(np.float32) + chroma_err = (rng.random((h, w)) * 10).astype(np.float32) + em = ErrorMap( + signed=np.zeros((h, w, 3), np.float32), + absolute=np.zeros((h, w, 3), np.float32), + chroma_error=chroma_err, + chroma_error_dE00=chroma_err.copy(), + signed_chroma_ab=np.zeros((h, w, 2), np.float32), + orig_l_star=np.zeros((h, w), np.float32), + ) + deficiencies = ("protanopia", "deuteranopia", "tritanopia") + return original, DashboardResult( + original=original, + simulated=dict.fromkeys(deficiencies, sim), + error_maps=dict.fromkeys(deficiencies, em), + summary={ + d: { + "mean_error": 1.0, + "median_error": 0.5, + "p95_error": 2.0, + "percent_affected": 30.0, + } + for d in deficiencies + }, + ) + + +class TestDashboardDialogPopulateResults: + """DashboardDialog._populate_results with a precomputed DashboardResult.""" + + def test_summary_label_populated_and_report_button_enabled(self, qt_app, monkeypatch): + from colorcast.gui import DashboardDialog + + monkeypatch.setattr(DashboardDialog, "_start_computation", lambda self: None) + + original, result = _make_dashboard_result() + dialog = DashboardDialog(original) + dialog._result = result + dialog._populate_results() + + assert dialog._summary_label is not None + text = dialog._summary_label.text() + assert "Protanopia" in text + assert dialog._report_button is not None + assert dialog._report_button.isEnabled() diff --git a/tests/test_image_loading.py b/tests/test_image_loading.py index 219d24f..0fbd309 100644 --- a/tests/test_image_loading.py +++ b/tests/test_image_loading.py @@ -1,7 +1,8 @@ """Tests for image loading and preprocessing.""" -import pytest import numpy as np +import pytest + from colorcast.processing.image_loader import ensure_rgb, load_image, save_image from colorcast.utils.exceptions import InvalidImageFormatError @@ -115,7 +116,6 @@ class TestLoadImage: def test_load_image_creates_test_files(self, tmp_path): """Test loading actual image files.""" # Create test image files - import tempfile from skimage import io test_img = (np.random.rand(50, 50, 3) * 255).astype(np.uint8) @@ -189,4 +189,4 @@ def test_save_unsupported_dtype_raises(self, tmp_path, dtype): img = np.zeros((10, 10, 3), dtype=dtype) with pytest.raises(InvalidImageFormatError, match="Unsupported dtype"): - save_image(img, str(tmp_path / "out.png")) \ No newline at end of file + save_image(img, str(tmp_path / "out.png")) diff --git a/tests/test_integration.py b/tests/test_integration.py index 7c22c4f..42900f9 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,19 +2,17 @@ import numpy as np import pytest -from pathlib import Path from colorcast import ( - load_image, - match_histograms_multichannel, + blend_images, color_transfer_meanstd, lut_transfer_with_curve, - selective_color_transfer, - blend_images, + match_histograms_multichannel, registry, + selective_color_transfer, ) from colorcast.processing.batch import BatchProcessor -from colorcast.processing.cache import LRUCache +from colorcast.processing.cache import StyleTransferCache @pytest.fixture @@ -53,9 +51,7 @@ def test_complete_meanstd_workflow(self, sample_image_rgb, sample_style_image): def test_complete_lut_workflow(self, sample_image_rgb, sample_style_image): """Test complete LUT workflow with curves.""" for curve_type in ["linear", "s-curve", "contrast"]: - result = lut_transfer_with_curve( - sample_image_rgb, sample_style_image, curve_type - ) + result = lut_transfer_with_curve(sample_image_rgb, sample_style_image, curve_type) assert result.shape == sample_image_rgb.shape assert result.dtype == sample_image_rgb.dtype assert np.all(result >= 0) and np.all(result <= 1) @@ -64,9 +60,7 @@ def test_complete_selective_workflow(self, sample_image_rgb, sample_style_image) """Test complete selective transfer workflow.""" modes = ["shadows", "midtones", "highlights", "full"] for mode in modes: - result = selective_color_transfer( - sample_image_rgb, sample_style_image, mode=mode - ) + result = selective_color_transfer(sample_image_rgb, sample_style_image, mode=mode) assert result.shape == sample_image_rgb.shape assert result.dtype == sample_image_rgb.dtype assert np.all(result >= 0) and np.all(result <= 1) @@ -96,7 +90,7 @@ class TestIntegrationWithCache: def test_cached_workflow(self, sample_image_rgb, sample_style_image): """Test workflow with caching enabled.""" - cache = LRUCache(max_size=10) + cache = StyleTransferCache(max_size=10) # First call - should compute result1 = cache.get_or_compute( @@ -132,7 +126,7 @@ def test_registry_workflow(self, sample_image_rgb, sample_style_image): assert len(methods) > 0 # Test each method via registry - for method_id in methods.keys(): + for method_id in methods: method = registry.get_method(method_id) result = method.transfer(sample_image_rgb, sample_style_image) @@ -144,9 +138,7 @@ def test_registry_workflow(self, sample_image_rgb, sample_style_image): class TestBatchIntegration: """Test batch processing integration.""" - def test_batch_directory_processing( - self, tmp_path, sample_image_rgb, sample_style_image - ): + def test_batch_directory_processing(self, tmp_path, sample_image_rgb, sample_style_image): """Test batch processing of directory.""" from colorcast.processing.image_loader import save_image @@ -158,9 +150,7 @@ def test_batch_directory_processing( # Save sample images for i in range(3): - save_image( - sample_image_rgb, str(content_dir / f"test_{i}.jpg") - ) + save_image(sample_image_rgb, str(content_dir / f"test_{i}.jpg")) save_image(sample_style_image, str(tmp_path / "style.jpg")) # Process batch diff --git a/tests/test_integration_comprehensive.py b/tests/test_integration_comprehensive.py index 5b4d8e8..3fb7a1e 100644 --- a/tests/test_integration_comprehensive.py +++ b/tests/test_integration_comprehensive.py @@ -3,25 +3,26 @@ These tests verify end-to-end workflows and real-world usage scenarios. """ -import pytest -import numpy as np +import shutil +import tempfile from pathlib import Path + +import numpy as np +import pytest from PIL import Image -import tempfile -import shutil from colorcast import ( - load_image, - save_image, - match_histograms_multichannel, - color_transfer_meanstd, + blend_images, color_transfer_lab, + color_transfer_meanstd, + load_image, lut_transfer_with_curve, + match_histograms_multichannel, + save_image, selective_color_transfer, - blend_images, ) from colorcast.processing.batch import BatchProcessor -from colorcast.processing.cache import LRUCache +from colorcast.processing.cache import StyleTransferCache @pytest.fixture @@ -34,195 +35,195 @@ def temp_dir(): class TestIntegrationWorkflows: """Test complete end-to-end workflows.""" - + @pytest.fixture def sample_images(self, temp_dir): """Create sample test images.""" # Create test images - content_img = Image.new('RGB', (512, 512), color=(100, 150, 200)) - style_img = Image.new('RGB', (512, 512), color=(200, 100, 150)) - + content_img = Image.new("RGB", (512, 512), color=(100, 150, 200)) + style_img = Image.new("RGB", (512, 512), color=(200, 100, 150)) + content_path = temp_dir / "content.jpg" style_path = temp_dir / "style.jpg" - + content_img.save(content_path) style_img.save(style_path) - + return content_path, style_path - + def test_full_workflow_load_to_save(self, sample_images, temp_dir): """Test complete workflow from file loading to saving.""" content_path, style_path = sample_images output_path = temp_dir / "output.jpg" - + # Execute full workflow content = load_image(str(content_path)) style = load_image(str(style_path)) assert content.shape == (512, 512, 3) assert style.shape == (512, 512, 3) - + # Apply transfer result = color_transfer_meanstd(content, style) assert result.shape == content.shape assert np.all(result >= 0) and np.all(result <= 1) - + # Blend with intensity result = blend_images(content, result, intensity=0.7) assert np.all(result >= 0) and np.all(result <= 1) - + # Save result save_image(result, str(output_path)) assert output_path.exists() - + # Verify saved image loaded_result = load_image(str(output_path)) assert loaded_result.shape == content.shape # Use looser tolerance for JPEG compression artifacts assert np.allclose(loaded_result, result, rtol=0.01, atol=0.02) - + def test_multiple_methods_workflow(self, sample_images, temp_dir): """Test applying multiple transfer methods sequentially.""" content_path, style_path = sample_images - + content = load_image(str(content_path)) style = load_image(str(style_path)) - + # Apply different methods results = {} methods = { - 'histogram': match_histograms_multichannel, - 'meanstd': color_transfer_meanstd, - 'lab': lambda s, r: color_transfer_lab(s, r, alpha=0.8), - 'lut_linear': lambda s, r: lut_transfer_with_curve(s, r, 'linear'), - 'lut_scurve': lambda s, r: lut_transfer_with_curve(s, r, 's-curve'), + "histogram": match_histograms_multichannel, + "meanstd": color_transfer_meanstd, + "lab": lambda s, r: color_transfer_lab(s, r, alpha=0.8), + "lut_linear": lambda s, r: lut_transfer_with_curve(s, r, "linear"), + "lut_scurve": lambda s, r: lut_transfer_with_curve(s, r, "s-curve"), } - + for name, method in methods.items(): result = method(content, style) results[name] = result - + # Verify each result assert result.shape == content.shape assert np.all(result >= 0) and np.all(result <= 1) - + # Save each result output_path = temp_dir / f"result_{name}.jpg" save_image(result, str(output_path)) assert output_path.exists() - + # Verify all methods produced different results - unique_results = set(name for name, r in results.items() if not np.allclose(r, results['histogram'])) + unique_results = { + name for name, r in results.items() if not np.allclose(r, results["histogram"]) + } assert len(unique_results) > 1 - + def test_caching_workflow(self, sample_images): """Test caching integration with workflow.""" content_path, style_path = sample_images - + content = load_image(str(content_path)) style = load_image(str(style_path)) - + # Create cache - cache = LRUCache(max_size=5) - + cache = StyleTransferCache(max_size=5) + # First call - cache miss result1 = cache.get_or_compute( key="test_key", compute_func=lambda: color_transfer_meanstd(content, style), ) - + # Second call - cache hit result2 = cache.get_or_compute( key="test_key", compute_func=lambda: color_transfer_meanstd(content, style), ) - + # Verify cache worked assert np.allclose(result1, result2) - assert cache.stats()['hits'] == 1 - assert cache.stats()['misses'] == 1 - + assert cache.stats()["hits"] == 1 + assert cache.stats()["misses"] == 1 + def test_batch_processing_workflow(self, temp_dir): """Test complete batch processing workflow.""" # Create multiple content images content_dir = temp_dir / "content" content_dir.mkdir() - + for i in range(5): - img = Image.new('RGB', (256, 256), color=(100 + i*20, 150, 200 - i*20)) + img = Image.new("RGB", (256, 256), color=(100 + i * 20, 150, 200 - i * 20)) img.save(content_dir / f"img_{i}.jpg") - + # Create style image - style_img = Image.new('RGB', (256, 256), color=(200, 100, 150)) + style_img = Image.new("RGB", (256, 256), color=(200, 100, 150)) style_path = temp_dir / "style.jpg" style_img.save(style_path) - + # Process batch output_dir = temp_dir / "output" - + processor = BatchProcessor( transfer_method=color_transfer_meanstd, max_workers=2, ) - + results = processor.process_directory( content_dir=content_dir, style_image=style_path, output_dir=output_dir, pattern="*.jpg", ) - + # Verify results assert len(results) == 5 assert all(r.exists() for r in results) assert processor.failed_files == [] # No failures - + def test_selective_transfer_workflow(self, sample_images, temp_dir): """Test selective transfer with different modes.""" content_path, style_path = sample_images - + content = load_image(str(content_path)) style = load_image(str(style_path)) - + # Test all selective modes - modes = ['shadows', 'midtones', 'highlights', 'full'] - + modes = ["shadows", "midtones", "highlights", "full"] + for mode in modes: - result = selective_color_transfer( - content, style, mode=mode - ) - + result = selective_color_transfer(content, style, mode=mode) + assert result.shape == content.shape assert np.all(result >= 0) and np.all(result <= 1) - + # Save result output_path = temp_dir / f"selective_{mode}.jpg" save_image(result, str(output_path)) assert output_path.exists() - + def test_intensity_blending_workflow(self, sample_images, temp_dir): """Test intensity blending workflow.""" content_path, style_path = sample_images - + content = load_image(str(content_path)) style = load_image(str(style_path)) - + result = color_transfer_meanstd(content, style) - + # Test different intensities intensities = [0.0, 0.25, 0.5, 0.75, 1.0] - + for intensity in intensities: blended = blend_images(content, result, intensity=intensity) - + assert blended.shape == content.shape assert np.all(blended >= 0) and np.all(blended <= 1) - + # Verify blending behavior if intensity == 0.0: assert np.allclose(blended, content) elif intensity == 1.0: assert np.allclose(blended, result) - + # Save result output_path = temp_dir / f"blended_{intensity:.2f}.jpg" save_image(blended, str(output_path)) @@ -231,67 +232,67 @@ def test_intensity_blending_workflow(self, sample_images, temp_dir): class TestErrorRecovery: """Test error handling and recovery scenarios.""" - + def test_invalid_file_handling(self): """Test handling of invalid files.""" with pytest.raises((FileNotFoundError, Exception)): load_image("nonexistent_file.jpg") - + def test_different_size_images(self): """Test handling of images with different sizes.""" content = np.random.rand(256, 256, 3) reference = np.random.rand(512, 512, 3) - + # Should handle different sizes by resizing result = match_histograms_multichannel(content, reference) assert result.shape == content.shape - + def test_grayscale_to_rgb(self, temp_dir): """Test handling of grayscale images.""" # Create grayscale image - gray_img = Image.new('L', (256, 256), color=128) + gray_img = Image.new("L", (256, 256), color=128) gray_path = temp_dir / "gray.jpg" gray_img.save(gray_path) - + # Should convert to RGB automatically loaded = load_image(str(gray_path)) assert loaded.ndim == 3 assert loaded.shape[2] == 3 - + def test_batch_with_errors(self, temp_dir): """Test batch processing with some failing files.""" content_dir = temp_dir / "content" content_dir.mkdir() - + # Create valid images for i in range(3): - img = Image.new('RGB', (128, 128), color=(100, 150, 200)) + img = Image.new("RGB", (128, 128), color=(100, 150, 200)) img.save(content_dir / f"valid_{i}.jpg") - + # Create invalid file (corrupted JPEG) invalid_path = content_dir / "invalid.jpg" invalid_path.write_bytes(b"not a valid jpeg file") - + # Create style image - style_img = Image.new('RGB', (128, 128), color=(200, 100, 150)) + style_img = Image.new("RGB", (128, 128), color=(200, 100, 150)) style_path = temp_dir / "style.jpg" style_img.save(style_path) - + # Process batch output_dir = temp_dir / "output" - + processor = BatchProcessor( transfer_method=color_transfer_meanstd, max_workers=2, ) - + results = processor.process_directory( content_dir=content_dir, style_image=style_path, output_dir=output_dir, pattern="*.jpg", ) - + # Should process valid files despite one invalid assert len(results) == 3 # Only valid files assert len(processor.failed_files) >= 1 # At least the invalid file @@ -299,94 +300,94 @@ def test_batch_with_errors(self, temp_dir): class TestPerformanceIntegration: """Test performance-related integration scenarios.""" - + def test_large_image_processing(self): """Test processing of large images.""" # Create large image (4K) large_img = np.random.rand(4096, 4096, 3) style_img = np.random.rand(4096, 4096, 3) - + # Should process without errors result = color_transfer_meanstd(large_img, style_img) assert result.shape == large_img.shape - + def test_memory_efficiency(self): """Test memory efficiency with multiple operations.""" images = [np.random.rand(512, 512, 3) for _ in range(10)] style = np.random.rand(512, 512, 3) - + # Process multiple images for img in images: result = color_transfer_meanstd(img, style) assert result.shape == img.shape - + def test_cache_effectiveness(self): """Test cache effectiveness with repeated operations.""" - cache = LRUCache(max_size=10) + cache = StyleTransferCache(max_size=10) content = np.random.rand(1024, 1024, 3) style = np.random.rand(1024, 1024, 3) - + # First computation result1 = cache.get_or_compute( key="test", compute_func=lambda: color_transfer_meanstd(content, style), ) - + # Cache hit result2 = cache.get_or_compute( key="test", compute_func=lambda: color_transfer_meanstd(content, style), ) - + # Verify cache worked assert np.allclose(result1, result2) - assert cache.stats()['hits'] == 1 - assert cache.stats()['misses'] == 1 + assert cache.stats()["hits"] == 1 + assert cache.stats()["misses"] == 1 class TestEdgeCases: """Test edge cases and boundary conditions.""" - + def test_minimum_size_image(self): """Test processing of minimum size image.""" tiny = np.random.rand(1, 1, 3) style = np.random.rand(1, 1, 3) - + result = color_transfer_meanstd(tiny, style) assert result.shape == (1, 1, 3) - + def test_uniform_image(self): """Test processing of uniform (flat) image.""" uniform = np.ones((100, 100, 3)) * 0.5 varied = np.random.rand(100, 100, 3) - + result = color_transfer_meanstd(uniform, varied) assert result.shape == uniform.shape assert np.all(result >= 0) - + def test_extreme_contrast(self): """Test processing of extreme high contrast image.""" extreme = np.zeros((100, 100, 3)) extreme[:, :, 0] = np.random.choice([0, 1], size=(100, 100)) normal = np.random.rand(100, 100, 3) - + result = color_transfer_meanstd(extreme, normal) assert np.all(result >= 0) and np.all(result <= 1) - + def test_zero_intensity_blending(self): """Test blending with zero intensity.""" content = np.random.rand(256, 256, 3) style = np.random.rand(256, 256, 3) result = color_transfer_meanstd(content, style) - + blended = blend_images(content, result, intensity=0.0) assert np.allclose(blended, content) - + def test_full_intensity_blending(self): """Test blending with full intensity.""" content = np.random.rand(256, 256, 3) style = np.random.rand(256, 256, 3) result = color_transfer_meanstd(content, style) - + blended = blend_images(content, result, intensity=1.0) - assert np.allclose(blended, result) \ No newline at end of file + assert np.allclose(blended, result) diff --git a/tests/test_lab_transfer.py b/tests/test_lab_transfer.py index faeed31..1847846 100644 --- a/tests/test_lab_transfer.py +++ b/tests/test_lab_transfer.py @@ -1,7 +1,7 @@ """Tests for Lab color space transfer method.""" import numpy as np -import pytest + from colorcast.processing.transfer_methods import color_transfer_lab @@ -160,4 +160,4 @@ def test_real_world_scenario(self): assert result.shape == source.shape assert np.all(result >= 0) and np.all(result <= 1) - assert not np.array_equal(result, source) \ No newline at end of file + assert not np.array_equal(result, source) diff --git a/tests/test_performance.py b/tests/test_performance.py index a51e1da..899d0ea 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -1,16 +1,17 @@ """Performance benchmarks for ColorCast transfer methods.""" import time + import numpy as np import pytest from colorcast import ( - match_histograms_multichannel, color_transfer_meanstd, lut_transfer_with_curve, + match_histograms_multichannel, selective_color_transfer, ) -from colorcast.processing.cache import LRUCache +from colorcast.processing.cache import StyleTransferCache @pytest.fixture @@ -141,49 +142,41 @@ class TestCachePerformance: def test_cache_hit_small(self, small_image, style_image, benchmark): """Benchmark cache hit on small image.""" - cache = LRUCache(max_size=10) + cache = StyleTransferCache(max_size=10) # Pre-fill cache cache.get_or_compute( key="test", - compute_func=lambda: match_histograms_multichannel( - small_image, style_image - ), + compute_func=lambda: match_histograms_multichannel(small_image, style_image), ) # Benchmark cache hit benchmark( cache.get_or_compute, key="test", - compute_func=lambda: match_histograms_multichannel( - small_image, style_image - ), + compute_func=lambda: match_histograms_multichannel(small_image, style_image), ) def test_cache_miss_small(self, small_image, style_image, benchmark): """Benchmark cache miss on small image.""" - cache = LRUCache(max_size=10) + cache = StyleTransferCache(max_size=10) # Benchmark cache miss benchmark( cache.get_or_compute, key="test", - compute_func=lambda: match_histograms_multichannel( - small_image, style_image - ), + compute_func=lambda: match_histograms_multichannel(small_image, style_image), ) def test_cache_effectiveness(self, medium_image, style_image): """Measure cache effectiveness.""" - cache = LRUCache(max_size=5) + cache = StyleTransferCache(max_size=5) # First access - miss start = time.time() result1 = cache.get_or_compute( key="test1", - compute_func=lambda: match_histograms_multichannel( - medium_image, style_image - ), + compute_func=lambda: match_histograms_multichannel(medium_image, style_image), ) time_miss = time.time() - start @@ -191,9 +184,7 @@ def test_cache_effectiveness(self, medium_image, style_image): start = time.time() result2 = cache.get_or_compute( key="test1", - compute_func=lambda: match_histograms_multichannel( - medium_image, style_image - ), + compute_func=lambda: match_histograms_multichannel(medium_image, style_image), ) time_hit = time.time() - start @@ -227,12 +218,14 @@ def test_histogram_scaling(self, small_image, medium_image, large_image, style_i scaling = times["medium"] / times["small"] assert 1.2 < scaling < 10, f"Unexpected scaling: {scaling:.2f}x" - @pytest.mark.skip(reason="Micro-benchmarks with time.time() are unreliable; use pytest-benchmark instead") + @pytest.mark.skip( + reason="Micro-benchmarks with time.time() are unreliable; use pytest-benchmark instead" + ) def test_mean_std_scaling(self, small_image, medium_image, style_image): """Test performance scaling for mean/std transfer.""" # Warmup run to initialize NumPy/caches color_transfer_meanstd(small_image, style_image) - + times = {} for name, img in [("small", small_image), ("medium", medium_image)]: @@ -263,7 +256,7 @@ def test_multiple_operations_memory(self, medium_image, style_image): import gc # Perform many operations - for i in range(10): + for _i in range(10): result = match_histograms_multichannel(medium_image, style_image) assert result.shape == medium_image.shape @@ -272,4 +265,4 @@ def test_multiple_operations_memory(self, medium_image, style_image): # Should still work result = match_histograms_multichannel(medium_image, style_image) - assert result.shape == medium_image.shape \ No newline at end of file + assert result.shape == medium_image.shape diff --git a/tests/test_property_based.py b/tests/test_property_based.py index f161882..53fec9f 100644 --- a/tests/test_property_based.py +++ b/tests/test_property_based.py @@ -7,20 +7,22 @@ import numpy as np import pytest -from hypothesis import given, strategies as st, settings, HealthCheck +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st from hypothesis.extra.numpy import arrays + +from colorcast.processing.blending import blend_images from colorcast.processing.transfer_methods import ( - match_histograms_multichannel, color_transfer_meanstd, lut_transfer_with_curve, - selective_color_transfer + match_histograms_multichannel, + selective_color_transfer, ) -from colorcast.processing.blending import blend_images # Disable too large data health check for these tests custom_settings = settings( suppress_health_check=[HealthCheck.data_too_large], - max_examples=50 # Reduce examples for faster testing + max_examples=50, # Reduce examples for faster testing ) # Alias functions to more descriptive names for tests @@ -32,20 +34,36 @@ class TestHistogramMatchingProperties: """Tests for histogram matching properties.""" - + @given( - source=arrays(float, shape=(100, 100, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - reference=arrays(float, shape=(100, 100, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)) + source=arrays( + np.float64, + shape=(100, 100, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + reference=arrays( + np.float64, + shape=(100, 100, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), ) @settings(custom_settings) def test_preserves_shape(self, source, reference): """Histogram matching should preserve image shape.""" result = histogram_matching(source, reference) assert result.shape == source.shape - + @given( - source=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - reference=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)) + source=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + reference=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), ) @settings(custom_settings) def test_values_in_valid_range(self, source, reference): @@ -53,15 +71,23 @@ def test_values_in_valid_range(self, source, reference): result = histogram_matching(source, reference) assert np.all(result >= 0) assert np.all(result <= 255) - + @given( - source=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - reference=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)) + source=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + reference=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), ) @settings(custom_settings) def test_same_histogram_as_reference(self, source, reference): """Result histogram should match reference histogram approximately. - + Note: This is a property-based test that verifies the algorithm is working correctly. Due to the discrete nature of digital images and quantization in histogram matching, perfect matches are not @@ -69,23 +95,20 @@ def test_same_histogram_as_reference(self, source, reference): for this. """ result = histogram_matching(source, reference) - + # Calculate histograms for each channel - ref_hist = [np.histogram(reference[:,:,i], bins=256, range=(0, 256))[0] for i in range(3)] - result_hist = [np.histogram(result[:,:,i], bins=256, range=(0, 256))[0] for i in range(3)] - - # Check that histograms are similar (not exact due to discretization) - # Use relaxed tolerance since histogram matching is an approximation - for ref_hist, result_hist in zip(ref_hist, result_hist): - # Handle edge case where histograms might be all zeros - ref_sum = ref_hist.sum() - result_sum = result_hist.sum() + ref_hist = [np.histogram(reference[:, :, i], bins=256, range=(0, 256))[0] for i in range(3)] + result_hist = [np.histogram(result[:, :, i], bins=256, range=(0, 256))[0] for i in range(3)] + + for ref_h, res_h in zip(ref_hist, result_hist, strict=False): + ref_sum = ref_h.sum() + result_sum = res_h.sum() if ref_sum == 0 and result_sum == 0: continue # Both empty, skip - - ref_cdf = np.cumsum(ref_hist) / max(ref_sum, 1e-10) - result_cdf = np.cumsum(result_hist) / max(result_sum, 1e-10) - + + ref_cdf = np.cumsum(ref_h) / max(ref_sum, 1e-10) + result_cdf = np.cumsum(res_h) / max(result_sum, 1e-10) + # Check CDFs match reasonably (allow for quantization errors) # Increase atol to handle edge cases with sparse histograms assert np.allclose(ref_cdf, result_cdf, rtol=0.3, atol=1.0) @@ -93,20 +116,36 @@ def test_same_histogram_as_reference(self, source, reference): class TestMeanStdTransferProperties: """Tests for mean/std transfer properties.""" - + @given( - source=arrays(float, shape=(100, 100, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - reference=arrays(float, shape=(100, 100, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)) + source=arrays( + np.float64, + shape=(100, 100, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + reference=arrays( + np.float64, + shape=(100, 100, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), ) @settings(custom_settings) def test_preserves_shape(self, source, reference): """Mean/std transfer should preserve image shape.""" result = mean_std_transfer(source, reference) assert result.shape == source.shape - + @given( - source=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - reference=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)) + source=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + reference=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), ) @settings(custom_settings) def test_values_in_valid_range(self, source, reference): @@ -115,38 +154,46 @@ def test_values_in_valid_range(self, source, reference): # May have slight overflow due to std transfer, clip to [0, 255] assert np.all(result >= -1.0) # Allow small negative due to numerical issues assert np.all(result <= 256.0) # Allow small overflow due to numerical issues - + @given( - source=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - reference=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)) + source=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + reference=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), ) @settings(custom_settings) def test_matches_reference_statistics(self, source, reference): """Result should have similar mean and std as reference. - + Note: This test verifies that the mean/std transfer algorithm is working correctly. Due to numerical precision and clipping operation (which clips values to [0,1]), perfect matches are not always possible, especially for distributions with high variance or extreme values. - + We skip degenerate cases where source has very low variance (std < 0.1) as the transfer is not meaningful in those cases. In a degenerate case with all zeros, the result will also be all zeros regardless of the reference statistics, which is mathematically correct. """ result = mean_std_transfer(source, reference) - + # Only test if reference has meaningful variance and source has sufficient variance for i in range(3): - ref_std = np.std(reference[:,:,i]) - source_std = np.std(source[:,:,i]) - + ref_std = np.std(reference[:, :, i]) + source_std = np.std(source[:, :, i]) + # Skip degenerate cases where transfer is not meaningful if ref_std < 0.05 or source_std < 0.01: continue - - result_std = np.std(result[:,:,i]) + + result_std = np.std(result[:, :, i]) # Allow more tolerance due to clipping and numerical precision # The tolerance depends on the std value - higher std allows more error tolerance = max(1.5, ref_std * 1.2) @@ -155,22 +202,38 @@ def test_matches_reference_statistics(self, source, reference): class TestLUTTransferProperties: """Tests for LUT-based transfer properties.""" - + @given( - source=arrays(float, shape=(100, 100, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - reference=arrays(float, shape=(100, 100, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - method=st.sampled_from(['linear', 's-curve', 'contrast']) + source=arrays( + np.float64, + shape=(100, 100, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + reference=arrays( + np.float64, + shape=(100, 100, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + method=st.sampled_from(["linear", "s-curve", "contrast"]), ) @settings(custom_settings) def test_preserves_shape(self, source, reference, method): """LUT transfer should preserve image shape.""" result = lab_transfer(source, reference, curve_type=method) assert result.shape == source.shape - + @given( - source=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - reference=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - method=st.sampled_from(['linear', 's-curve', 'contrast']) + source=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + reference=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + method=st.sampled_from(["linear", "s-curve", "contrast"]), ) @settings(custom_settings) def test_values_in_valid_range(self, source, reference, method): @@ -183,22 +246,38 @@ def test_values_in_valid_range(self, source, reference, method): class TestSelectiveTransferProperties: """Tests for selective transfer properties.""" - + @given( - source=arrays(float, shape=(100, 100, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - reference=arrays(float, shape=(100, 100, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - transfer_mode=st.sampled_from(['shadows', 'midtones', 'highlights', 'full']) + source=arrays( + np.float64, + shape=(100, 100, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + reference=arrays( + np.float64, + shape=(100, 100, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + transfer_mode=st.sampled_from(["shadows", "midtones", "highlights", "full"]), ) @settings(custom_settings) def test_preserves_shape(self, source, reference, transfer_mode): """Selective transfer should preserve image shape.""" result = selective_transfer(source, reference, mode=transfer_mode) assert result.shape == source.shape - + @given( - source=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - reference=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 255, allow_nan=False, allow_infinity=False)), - transfer_mode=st.sampled_from(['shadows', 'midtones', 'highlights', 'full']) + source=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + reference=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 255, allow_nan=False, allow_infinity=False), + ), + transfer_mode=st.sampled_from(["shadows", "midtones", "highlights", "full"]), ) @settings(custom_settings) def test_values_in_valid_range(self, source, reference, transfer_mode): @@ -210,10 +289,14 @@ def test_values_in_valid_range(self, source, reference, transfer_mode): class TestBlendingProperties: """Tests for image blending properties.""" - + @given( - source=arrays(float, shape=(100, 100, 3), elements=st.floats(0, 1, allow_nan=False, allow_infinity=False)), - intensity=st.floats(0, 1, allow_nan=False, allow_infinity=False) + source=arrays( + np.float64, + shape=(100, 100, 3), + elements=st.floats(0, 1, allow_nan=False, allow_infinity=False), + ), + intensity=st.floats(0, 1, allow_nan=False, allow_infinity=False), ) @settings(custom_settings) def test_preserves_shape(self, source, intensity): @@ -221,10 +304,14 @@ def test_preserves_shape(self, source, intensity): modified = source.copy() result = blend_images(source, modified, intensity) assert result.shape == source.shape - + @given( - source=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 1, allow_nan=False, allow_infinity=False)), - intensity=st.floats(0, 1, allow_nan=False, allow_infinity=False) + source=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 1, allow_nan=False, allow_infinity=False), + ), + intensity=st.floats(0, 1, allow_nan=False, allow_infinity=False), ) @settings(custom_settings) def test_values_in_valid_range(self, source, intensity): @@ -233,9 +320,13 @@ def test_values_in_valid_range(self, source, intensity): result = blend_images(source, modified, intensity) assert np.all(result >= 0) assert np.all(result <= 1) - + @given( - source=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 1, allow_nan=False, allow_infinity=False)) + source=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 1, allow_nan=False, allow_infinity=False), + ) ) @settings(custom_settings) def test_zero_intensity_returns_source(self, source): @@ -243,9 +334,13 @@ def test_zero_intensity_returns_source(self, source): modified = source.copy() result = blend_images(source, modified, intensity=0.0) np.testing.assert_array_equal(result, source) - + @given( - source=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 1, allow_nan=False, allow_infinity=False)) + source=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 1, allow_nan=False, allow_infinity=False), + ) ) @settings(custom_settings) def test_full_intensity_returns_modified(self, source): @@ -253,15 +348,19 @@ def test_full_intensity_returns_modified(self, source): modified = 1.0 - source # Different, but still in [0, 1] result = blend_images(source, modified, intensity=1.0) np.testing.assert_array_equal(result, modified) - + @given( - source=arrays(float, shape=(50, 50, 3), elements=st.floats(0, 1, allow_nan=False, allow_infinity=False)) + source=arrays( + np.float64, + shape=(50, 50, 3), + elements=st.floats(0, 1, allow_nan=False, allow_infinity=False), + ) ) @settings(custom_settings) def test_blending_is_linear(self, source): """Blending should be linear with intensity.""" modified = source.copy() * 0.5 + 0.2 # Different, but stays in [0, 1] - + # Test at several intensity levels for intensity in [0.0, 0.5, 1.0]: result = blend_images(source, modified, intensity) @@ -269,5 +368,5 @@ def test_blending_is_linear(self, source): np.testing.assert_allclose(result, expected, rtol=1e-10) -if __name__ == '__main__': - pytest.main([__file__, '-v']) \ No newline at end of file +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_registry.py b/tests/test_registry.py index 570efb0..9e63906 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -47,7 +47,7 @@ class TestDaltonizerMethods: def test_all_daltonizers_registered(self): """All three daltonize_* methods are registered.""" for deficiency in ("protanopia", "deuteranopia", "tritanopia"): - assert registry.has_method(f"daltonize_{deficiency}") + assert f"daltonize_{deficiency}" in registry.list_methods() def test_daltonizer_accepts_none_reference(self): """Daltonizer methods run with reference=None.""" diff --git a/tests/test_transfer_methods.py b/tests/test_transfer_methods.py index 85509bb..fe1c948 100644 --- a/tests/test_transfer_methods.py +++ b/tests/test_transfer_methods.py @@ -1,11 +1,12 @@ """Tests for color transfer methods.""" -import pytest import numpy as np +import pytest + from colorcast.processing.transfer_methods import ( - match_histograms_multichannel, color_transfer_meanstd, lut_transfer_with_curve, + match_histograms_multichannel, selective_color_transfer, ) @@ -181,4 +182,4 @@ def test_custom_thresholds(self): ) assert result.shape == source.shape - assert np.all(result >= 0) and np.all(result <= 1) \ No newline at end of file + assert np.all(result >= 0) and np.all(result <= 1) diff --git a/tests/test_validators_enhanced.py b/tests/test_validators_enhanced.py index e3a106a..3e52a78 100644 --- a/tests/test_validators_enhanced.py +++ b/tests/test_validators_enhanced.py @@ -3,13 +3,15 @@ import numpy as np import pytest from skimage import io + from colorcast.processing.image_loader import normalize_to_float32 +from colorcast.utils.exceptions import ValidationError from colorcast.utils.validators_enhanced import ( + ALLOWED_IMAGE_EXTENSIONS, + validate_file_path, validate_image_array, validate_image_file, - ALLOWED_IMAGE_EXTENSIONS, ) -from colorcast.utils.exceptions import ValidationError class TestSpoofedFileRejection: @@ -116,3 +118,67 @@ def test_hw3_passes(self): assert result.shape == (4, 4, 3) assert result.dtype == np.float32 np.testing.assert_allclose(result, 128 / 255, atol=0.01) + + +class TestPathContainment: + """validate_file_path base-dir containment regression tests. + + The old startswith-based check admitted sibling-prefix directories + (e.g. /data would allow /data-leaks). The relative_to-based check + must reject those. + """ + + def test_path_inside_base_dir_passes(self, tmp_path): + base = tmp_path / "data" + base.mkdir() + inside = base / "img.png" + inside.touch() + + result = validate_file_path(str(inside), allowed_base_dirs=(base,)) + assert result == inside.resolve() + + def test_sibling_prefix_rejected(self, tmp_path): + """A sibling directory whose name is a prefix of the base is rejected.""" + base = tmp_path / "data" + base.mkdir() + sibling = tmp_path / "data-leaks" + sibling.mkdir() + inside_sibling = sibling / "img.png" + inside_sibling.touch() + + with pytest.raises(ValidationError, match="not within allowed"): + validate_file_path(str(inside_sibling), allowed_base_dirs=(base,)) + + def test_path_outside_base_rejected(self, tmp_path): + base = tmp_path / "data" + base.mkdir() + outside = tmp_path / "somewhere" / "img.png" + outside.parent.mkdir() + outside.touch() + + with pytest.raises(ValidationError, match="not within allowed"): + validate_file_path(str(outside), allowed_base_dirs=(base,)) + + def test_multiple_base_dirs_first_fails_second_passes(self, tmp_path): + base_a = tmp_path / "a" + base_b = tmp_path / "b" + base_a.mkdir() + base_b.mkdir() + inside_b = base_b / "img.png" + inside_b.touch() + + result = validate_file_path( + str(inside_b), + allowed_base_dirs=(base_a, base_b), + ) + assert result == inside_b.resolve() + + def test_path_traversal_rejected(self, tmp_path): + base = tmp_path / "data" + base.mkdir() + traversal = base / ".." / "outside.txt" + traversal.resolve().parent.mkdir(parents=True, exist_ok=True) + traversal.resolve().touch() + + with pytest.raises(ValidationError, match="Path traversal"): + validate_file_path(str(traversal), allowed_base_dirs=(base,)) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index a8f0307..34c229e 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -1,17 +1,18 @@ """Tests for visualization functions.""" import matplotlib + matplotlib.use("Agg") import matplotlib.pyplot as plt - import numpy as np import pytest from matplotlib.figure import Figure + from colorcast.analysis.visualization import ( - visualize_transfer_result, - visualize_method_comparison, - visualize_color_channels, create_side_by_side_comparison, + visualize_color_channels, + visualize_method_comparison, + visualize_transfer_result, ) @@ -41,39 +42,62 @@ def test_returns_figure(self, images): def test_default_no_histograms_no_difference(self, images): source, reference, result = images fig = visualize_transfer_result( - source, reference, result, "Test", + source, + reference, + result, + "Test", show_histograms=False, show_difference=False, ) + assert isinstance(fig, Figure) + plt.close(fig) def test_default_with_difference(self, images): source, reference, result = images fig = visualize_transfer_result( - source, reference, result, "Test", + source, + reference, + result, + "Test", show_histograms=False, show_difference=True, ) + assert isinstance(fig, Figure) + plt.close(fig) def test_with_histograms_no_difference(self, images): source, reference, result = images fig = visualize_transfer_result( - source, reference, result, "Test", + source, + reference, + result, + "Test", show_histograms=True, show_difference=False, ) + assert isinstance(fig, Figure) + plt.close(fig) def test_with_histograms_and_difference(self, images): source, reference, result = images fig = visualize_transfer_result( - source, reference, result, "Test", + source, + reference, + result, + "Test", show_histograms=True, show_difference=True, ) + assert isinstance(fig, Figure) + plt.close(fig) def test_custom_figsize(self, images): source, reference, result = images fig = visualize_transfer_result( - source, reference, result, "Test", + source, + reference, + result, + "Test", figsize=(10, 8), ) assert isinstance(fig, Figure) @@ -83,7 +107,10 @@ def test_custom_figsize(self, images): def test_all_combinations(self, images, show_histograms, show_difference): source, reference, result = images fig = visualize_transfer_result( - source, reference, result, "Test", + source, + reference, + result, + "Test", show_histograms=show_histograms, show_difference=show_difference, ) @@ -138,6 +165,7 @@ def test_returns_figure(self): def test_custom_title(self): image = np.random.rand(100, 100, 3).astype(np.float32) fig = visualize_color_channels(image, title="Custom Title") + assert isinstance(fig, Figure) def test_custom_figsize(self): image = np.random.rand(100, 100, 3).astype(np.float32) @@ -158,7 +186,10 @@ def test_custom_labels(self): img1 = np.random.rand(100, 100, 3).astype(np.float32) img2 = np.random.rand(100, 100, 3).astype(np.float32) fig = create_side_by_side_comparison( - img1, img2, label1="Original", label2="Styled", + img1, + img2, + label1="Original", + label2="Styled", ) assert isinstance(fig, Figure)