diff --git a/.gitignore b/.gitignore index afe318a..38ee6d1 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ venv.bak/ # macOS .DS_Store +**/.DS_Store # Windows Thumbs.db @@ -92,6 +93,9 @@ softwareX/ # Local roadmap /ROADMAP.md +# Commit plan (local development only) +/COMMIT_PLAN.md + # Kilo local configuration /kilo.json # Image files (test output, not committed) @@ -107,3 +111,7 @@ softwareX/ # README reference images are tracked !/imgs/ !/imgs/** + +# GUI assets are tracked +!/colorcast/assets/ +!/colorcast/assets/** diff --git a/.zenodo.json b/.zenodo.json index 93bc299..5643434 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -41,6 +41,6 @@ ], "references": [], "communities": [], - "version": "2.5.0", + "version": "2.6.0", "language": "eng" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 294f09d..ac19bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [2.6.0] – 2026-08-05 + +### Added + +- Perceptually uniform appearance-space abstraction in `colorcast/analysis/appearance.py` with two backends: **CIELAB** (legacy, D65) and **ICtCp** (ITU-R BT.2100, HDR-aware, ΔE scaled ×720). The `AppearanceSpace` protocol exposes `from_rgb(rgb)` and `delta_E(a, b)` methods. +- `ErrorMap` carries two new optional fields — `appearance_delta` (per-pixel ΔE) and `appearance_delta_name` (space identifier) — populated when an `AppearanceSpace` is passed to `get_error_map()`. +- `get_error_map()` accepts an optional keyword `appearance=` parameter for computing appearance-based ΔE alongside the existing Euclidean and CIEDE2000 metrics. +- `compute_dashboard()` accepts an `appearance=` keyword and ranks deficiencies by the appearance metric when available; `_summarize` prefers `appearance_delta` over `chroma_error_dE00` over `chroma_error`. +- New CLI subcommand: `colorcast dashboard IMAGE [-o OUTPUT] [--appearance {cielab,ictcp}]` generates a full-resolution CVD accessibility report. +- GUI Dashboard dialog gained a "ΔE metric" dropdown (CIELAB / ICtCp) in the bottom button bar; changing it re-triggers computation. +- `make_appearance_space(name)` factory function centralizes appearance-space creation by name; CLI and GUI now route through it, eliminating duplicated selection logic and brittle combobox-index coupling. +- `ErrorMap.preferred_metric()` method encapsulates the metric-priority rule (appearance_delta > chroma_error_dE00 > chroma_error), so dashboard summaries no longer encode this domain judgment directly. +- Consolidated `srgb_to_linear` gamma-decoding utility into `colorcast/utils/color_utils.py`, removing duplicate implementations from `appearance.py` and `simulation.py`. +- `AppearanceSpace` ABC docstring now documents thread-safety requirements for concurrent `delta_E` calls on shared instances; JND threshold comment restored in `dashboard.py:136` explaining why 1.0 ΔE ≈ one just-noticeable-difference for both CIEDE2000 and ΔE_ITP. +- Report filename and title now include the ΔE metric label (e.g. ``dashboard_report_ictcp.png``, ``"CVD Accessibility Dashboard – ICtCp"``), derived from ``DashboardResult.metric_label``. + +### Changed + +- `CIELABSpace` default metric changed from `cie76` to `ciede2000` for better perceptual uniformity. +- Replaced the QDarkStyle dependency with a custom QSS stylesheet and redesigned the GUI with a dark canvas (#171717), white pill buttons, hairline borders (#3d3d3d), and no drop shadows. +- README test metrics updated: 378 passing, 1 skipped (up from 361 in v2.5.0). + ## [2.5.0] – 2026-08-02 ### Added diff --git a/CITATION.cff b/CITATION.cff index 68098ff..8dc3814 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.5.0 -date-released: 2026-08-02 +version: 2.6.0 +date-released: 2026-08-05 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/MANIFEST.in b/MANIFEST.in index dfbf378..d53c9b2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -17,6 +17,9 @@ recursive-include tests *.py # Include reference images (for README screenshots) graft imgs +# Include GUI assets (combo box chevron) +recursive-include colorcast/assets *.png + # Exclude unnecessary files global-exclude __pycache__ global-exclude *.py[cod] diff --git a/README.md b/README.md index 72f9bbe..6ec7c16 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,11 @@ - **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** - 75% test coverage with 361 passing tests -- **Documented** - API documentation and examples -- **Plugin Architecture** - Easy to add custom transfer methods +- **Tested** – 75% test coverage with 378 passing tests +- **Documented** – API documentation and examples +- **Perceptually Uniform ΔE Metrics** – CIELAB (defaults to CIEDE2000) and ICtCp (HDR, BT.2100) appearance spaces for ranking CVD deficiency severity +- **Plugin Architecture** – Add custom transfer methods through plugins +- **Minimal Dark Theme** – Dark interface with a neutral canvas, bordered cards, and white action buttons ### Transfer Methods @@ -51,7 +53,7 @@ _CVD Accessibility Dashboard: side-by-side simulations and error maps for all th _Method comparison: ranked results across multiple metrics and transfer algorithms_ -![Dashboard Report](imgs/dashboard_report.png) +![Dashboard Report](imgs/dashboard_report_ICtCp.png) _Full dashboard report summarizing simulation results and Daltonization efficacy_ @@ -166,6 +168,12 @@ Get package information: colorcast info --version ``` +Generate a CVD accessibility dashboard report: + +```bash +colorcast dashboard image.jpg -o report.png --appearance ictcp +``` + **CLI Options:** - `-m, --method`: Transfer method (histogram, meanstd, lab_reinhard, lut_linear, lut_scurve, lut_contrast, selective_shadows, selective_midtones, selective_highlights, simulate_protanopia, simulate_deuteranopia, simulate_tritanopia, daltonize_protanopia, daltonize_deuteranopia, daltonize_tritanopia) @@ -239,6 +247,30 @@ method = registry.get_method("histogram") result = method.transfer(content, style) ``` +#### Using Appearance Spaces for Perceptually Uniform Metrics + +```python +from colorcast.analysis import make_appearance_space, get_error_map +from colorcast.processing.simulation import ColorBlindSimulator +from colorcast import load_image + +# Load image and simulate color-vision deficiency +image = load_image("content.jpg") +simulator = ColorBlindSimulator() +simulated = simulator.transform_color_space(image, "deuteranopia") + +# Create appearance space by name (CIELAB defaults to CIEDE2000) +cielab = make_appearance_space("cielab") +ictcp = make_appearance_space("ictcp") # HDR-aware BT.2100 + +# Compute error map with appearance-based ΔE +error_map = get_error_map(image, simulated, appearance=ictcp) + +# Access the appearance delta +print(f"Space: {error_map.appearance_delta_name}") +print(f"Mean ΔE: {error_map.appearance_delta.mean():.2f}") +``` + #### Batch Processing ```python @@ -330,7 +362,7 @@ pytest tests/test_transfer_methods.py pytest -v ``` -**Test Coverage:** 75% (361 passing, 1 skipped) +**Test Coverage:** 75% (378 passing, 1 skipped) - Core transfer methods: 100% coverage - Full test suite including integration, performance, and property-based tests @@ -440,7 +472,7 @@ If you use ColorCast in academic work, please cite it as: title = {ColorCast: Color Transfer Toolkit for Python}, doi = {10.5281/zenodo.18550038}, url = {https://github.com/MichailSemoglou/ColorCast}, - version = {2.5.0}, + version = {2.6.0}, year = {2026}, } ``` diff --git a/colorcast/__main__.py b/colorcast/__main__.py index 8c0a424..b74ba6b 100644 --- a/colorcast/__main__.py +++ b/colorcast/__main__.py @@ -190,6 +190,36 @@ def parse_args() -> argparse.Namespace: help="Show version information", ) + # Dashboard command + dashboard_parser = subparsers.add_parser( + "dashboard", + help="Generate a CVD accessibility dashboard report", + ) + dashboard_parser.add_argument( + "--verbose", + action="store_true", + default=argparse.SUPPRESS, + help="Show traceback on errors", + ) + dashboard_parser.add_argument( + "image", + type=Path, + help="Path to the image to analyze", + ) + dashboard_parser.add_argument( + "-o", + "--output", + type=Path, + default=Path("dashboard_report.png"), + help="Output path for the dashboard report (default: dashboard_report.png)", + ) + dashboard_parser.add_argument( + "--appearance", + choices=["cielab", "ictcp"], + default="cielab", + help="Appearance space for ΔE ranking (default: cielab)", + ) + return parser.parse_args() @@ -332,6 +362,34 @@ def cmd_info(args: argparse.Namespace) -> None: print("Visit https://github.com/MichailSemoglou/ColorCast for more info.") +def cmd_dashboard(args: argparse.Namespace) -> None: + """Handle dashboard command.""" + from colorcast.analysis.appearance import make_appearance_space + from colorcast.analysis.dashboard import compute_dashboard, generate_dashboard_report + + image = load_image(str(args.image)) + appearance = make_appearance_space(args.appearance) + + print(f"Computing CVD dashboard (appearance space: {appearance.name})…") + result = compute_dashboard(image, appearance=appearance) + + print("Summary:") + for deficiency in ("protanopia", "deuteranopia", "tritanopia"): + stats = result.summary[deficiency] + print( + f" {deficiency:14} mean={stats['mean_error']:7.2f} " + f"median={stats['median_error']:7.2f} " + f"p95={stats['p95_error']:7.2f} " + f"affected={stats['percent_affected']:5.1f}%" + ) + + metric_label = result.metric_label + title = f"CVD Accessibility Dashboard – {metric_label}" + print(f"\nGenerating report: {args.output}") + generate_dashboard_report(result, str(args.output), title=title) + print(f"Dashboard saved to {args.output}") + + def main() -> None: """Main entry point for CLI.""" args = parse_args() @@ -350,6 +408,8 @@ def main() -> None: cmd_list_methods() elif args.command == "info": cmd_info(args) + elif args.command == "dashboard": + cmd_dashboard(args) else: print(f"Error: Unknown command '{args.command}'", file=sys.stderr) sys.exit(1) diff --git a/colorcast/_version.py b/colorcast/_version.py index 41a6ac9..87ac7c0 100644 --- a/colorcast/_version.py +++ b/colorcast/_version.py @@ -6,4 +6,4 @@ copy; update all of them in sync when cutting a release. """ -__version__ = "2.5.0" +__version__ = "2.6.0" diff --git a/colorcast/analysis/__init__.py b/colorcast/analysis/__init__.py index ff9230d..58e5e07 100644 --- a/colorcast/analysis/__init__.py +++ b/colorcast/analysis/__init__.py @@ -7,8 +7,16 @@ - Error map / difference analysis for colour-blindness simulation - Daltonization: re-encoding lost chromatic information - CVD accessibility dashboard: compare deficiencies at once +- Perceptually uniform appearance spaces (ICtCp, CIELAB) """ +from colorcast.analysis.appearance import ( + AppearanceDelta, + AppearanceSpace, + CIELABSpace, + ICtCpSpace, + make_appearance_space, +) from colorcast.analysis.comparison import MethodComparison from colorcast.analysis.daltonization import ( apply_daltonization, @@ -33,6 +41,11 @@ ) __all__ = [ + "AppearanceSpace", + "AppearanceDelta", + "CIELABSpace", + "ICtCpSpace", + "make_appearance_space", "MethodComparison", "visualize_transfer_result", "visualize_method_comparison", diff --git a/colorcast/analysis/appearance.py b/colorcast/analysis/appearance.py new file mode 100644 index 0000000..aca3595 --- /dev/null +++ b/colorcast/analysis/appearance.py @@ -0,0 +1,316 @@ +"""Perceptually uniform appearance spaces for color-difference metrics. + +This module provides an extensible abstraction over color appearance +spaces — CIELAB (legacy) and ICtCp (HDR-aware, ITU-R BT.2100) — so +that error-map and dashboard computations can report ΔE values that +account for viewing conditions and absolute luminance. + +Implemented standards: +- CIE 1976 L*a*b* (CIELAB) with CIE76 ΔE*ab and CIEDE2000 ΔE00 +- ITU-R BT.2100 ICtCp color space +- ITU-R BT.2124-0 (2018) ΔE_ITP color difference metric + +References: +- CIE (2004). Colorimetry (CIE Publication 15:2004). +- ITU-R (2018). BT.2100: Image parameter values for high dynamic range + television for use in production and international programme exchange. +- ITU-R (2018). BT.2124-0: Objective metric for the assessment of colour + volume in high dynamic range television. +- Sharma, G., Wu, W., & Dalal, E. N. (2005). The CIEDE2000 color-difference + formula: Implementation notes, supplementary test data, and mathematical + observations. Color Research & Application, 30(1), 21-30. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import numpy as np + +from colorcast.utils.color_utils import srgb_to_linear + +# --------------------------------------------------------------------------- +# linear sRGB → display-referred BT.2020 RGB +# --------------------------------------------------------------------------- + +_SRGB_TO_BT2020 = np.array( + [ + [0.62740389, 0.32928304, 0.04331308], + [0.06909729, 0.91954040, 0.01136232], + [0.01639144, 0.08801331, 0.89559525], + ], + dtype=np.float64, +) + +# --------------------------------------------------------------------------- +# BT.2020 RGB → LMS — BT.2100 / ICtCp transform +# --------------------------------------------------------------------------- + +# Column-major form: M[i,j] = coefficient of RGB channel *j* for LMS channel *i*. +# Transposed in _rgb_to_ictcp for row-vector multiplication (N,3) @ (3,3). +_BT2020_TO_LMS = np.array( + [ + [1688.0 / 4096.0, 2146.0 / 4096.0, 262.0 / 4096.0], + [683.0 / 4096.0, 2951.0 / 4096.0, 462.0 / 4096.0], + [99.0 / 4096.0, 309.0 / 4096.0, 3688.0 / 4096.0], + ], + dtype=np.float64, +) + +# --------------------------------------------------------------------------- +# LMS' → ICtCp — ITU-R BT.2100 +# --------------------------------------------------------------------------- + +_LMS_TO_ICTCP = np.array( + [ + [0.5000, 0.5000, 0.0000], + [1.6138, -3.3235, 1.7097], + [4.3782, -4.2456, -0.1326], + ], + dtype=np.float64, +) + +# --------------------------------------------------------------------------- +# PQ (ST 2084) transfer function constants +# --------------------------------------------------------------------------- + +_PQ_M1 = 0.1593017578125 # 2610 / 16384 +_PQ_M2 = 78.84375 # 2523 / 32 +_PQ_C1 = 0.8359375 # 3424 / 4096 +_PQ_C2 = 18.8515625 # 2413 / 128 +_PQ_C3 = 18.6875 # 2392 / 128 +_PQ_LUMINANCE_REF = 10000.0 # absolute luminance reference, cd/m² (BT.2100) +_PQ_NORM = 1.0 / _PQ_LUMINANCE_REF +_SRGB_PEAK_LUMINANCE = 100.0 # reference display peak white, cd/m² (sRGB) + +_ICTCP_SCALE = 720.0 # BT.2124 ICtCp ΔE scaling factor + + +def _pq_eotf_inv(linear: np.ndarray) -> np.ndarray: + """Apply the ST 2084 inverse EOTF (perceptual quantizer). + + Expects absolute linear luminance in cd/m² (range [0, 10000] for + BT.2100 reference). Returns PQ-encoded values in [0, 1]. + """ + y = np.maximum(linear * _PQ_NORM, 0.0) + ym1 = np.power(y, _PQ_M1) + numerator = _PQ_C1 + _PQ_C2 * ym1 + denominator = 1.0 + _PQ_C3 * ym1 + return np.power(numerator / denominator, _PQ_M2) + + +# --------------------------------------------------------------------------- +# Protocol +# --------------------------------------------------------------------------- + + +@dataclass +class AppearanceDelta: + """Per-pixel color-difference result from an appearance space.""" + + values: np.ndarray # (H, W) float32, per-pixel ΔE + space_name: str # e.g. "ICtCp", "CIELAB" + + +class AppearanceSpace(ABC): + """Abstract color space for per-pixel ΔE computation. + + Thread-safety: implementations must be safe for concurrent ``delta_E`` + calls on a shared instance. The dashboard runs three deficiency + simulations in parallel and may share one appearance space across workers. + """ + + @property + @abstractmethod + def name(self) -> str: ... + + @abstractmethod + def from_rgb(self, rgb: np.ndarray) -> np.ndarray: + """Convert a float32 RGB image (H, W, 3) in [0, 1] to this space.""" + + @abstractmethod + def delta_E(self, a: np.ndarray, b: np.ndarray) -> AppearanceDelta: + """Compute per-pixel ΔE between two images in this space.""" + + +# --------------------------------------------------------------------------- +# CIELAB (legacy) backend +# --------------------------------------------------------------------------- + + +class CIELABSpace(AppearanceSpace): + """CIE 1976 L*a*b* (D65) — legacy perceptually uniform space. + + Supports two color-difference metrics: + - CIE76 ΔE*ab: Euclidean distance in L*a*b* space (fast, less perceptually uniform) + - CIEDE2000 ΔE00: Improved formula with better perceptual uniformity (slower) + + The metric is selected via the ``metric`` parameter in the constructor. + Default is CIEDE2000 for better perceptual uniformity. + + Example: + >>> from colorcast.analysis.appearance import CIELABSpace + >>> space_cie76 = CIELABSpace(metric="cie76") + >>> space_ciede2000 = CIELABSpace(metric="ciede2000") + >>> delta = space_ciede2000.delta_E(a, b) + """ + + def __init__(self, metric: str = "ciede2000") -> None: + """Initialize the CIELAB space with a color-difference metric. + + Args: + metric: Color-difference metric. Supported values are ``"cie76"`` + (CIE 1976 ΔE*ab, Euclidean distance) and ``"ciede2000"`` + (CIEDE2000 ΔE00, improved perceptual uniformity). + """ + normalized = metric.lower() + if normalized not in {"cie76", "ciede2000"}: + raise ValueError(f"unsupported metric: {metric!r}") + self.metric = normalized + + @property + def name(self) -> str: + return f"CIELAB ({self.metric})" + + def from_rgb(self, rgb: np.ndarray) -> np.ndarray: + from skimage.color import rgb2lab + + return rgb2lab(np.asarray(rgb, dtype=np.float64)).astype(np.float32) + + def delta_E(self, a: np.ndarray, b: np.ndarray) -> AppearanceDelta: + lab1 = self.from_rgb(a) + lab2 = self.from_rgb(b) + + if self.metric == "cie76": + diff = lab1 - lab2 + values = np.sqrt(np.sum(diff**2, axis=-1)).astype(np.float32) + else: # ciede2000 + from skimage.color import deltaE_ciede2000 + + values = deltaE_ciede2000(lab1, lab2).astype(np.float32) + + return AppearanceDelta(values=values, space_name=self.name) + + +# --------------------------------------------------------------------------- +# ICtCp (ITU-R BT.2100, HDR-aware) backend +# --------------------------------------------------------------------------- + + +class ICtCpSpace(AppearanceSpace): + """ITU-R BT.2100 ICtCp — HDR-aware perceptually uniform color space. + + This implementation follows ITU-R BT.2100 and BT.2124-0 exactly. The + color-difference metric is ΔE_ITP as defined in BT.2124-0 (2018): + + ΔE_ITP = 720 × √(ΔI² + (0.5 × ΔCt)² + ΔCp²) + + The backend accepts normalized ``(H, W, 3)`` image values in ``[0, 1]``. + By default these are interpreted as nonlinear sRGB and are decoded with + IEC 61966-2-1 before being mapped to a peak luminance of 100 cd/m². + The resulting ``(H, W, 3)`` array has dtype ``float32`` and contains the + ICtCp coordinates. + + The appearance difference metric is reported as ``AppearanceDelta`` with + ``values`` shaped ``(H, W)`` and dtype ``float32``. + + Example: + >>> from colorcast.analysis.appearance import ICtCpSpace + >>> space = ICtCpSpace() + >>> ictcp = space.from_rgb(rgb_image) + >>> delta = space.delta_E(a, b) + """ + + def __init__( + self, + *, + transfer_function: str = "srgb", + peak_luminance: float = _SRGB_PEAK_LUMINANCE, + ) -> None: + """Initialize the appearance space with explicit input encoding. + + Args: + transfer_function: Input transfer function for normalized RGB data. + Supported values are ``"srgb"`` for nonlinear sRGB and + ``"linear"`` for already-linear data. + peak_luminance: Peak display luminance in cd/m² used to scale the + input values before BT.2100 conversion. + """ + normalized_tf = transfer_function.lower() + if normalized_tf not in {"srgb", "linear"}: + raise ValueError(f"unsupported transfer function: {transfer_function!r}") + if peak_luminance <= 0: + raise ValueError("peak_luminance must be positive") + + self.transfer_function = normalized_tf + self.peak_luminance = float(peak_luminance) + + def _rgb_to_ictcp(self, rgb: np.ndarray) -> np.ndarray: + if self.transfer_function == "srgb": + linear = srgb_to_linear(rgb) + else: + linear = np.array(rgb, dtype=np.float64, copy=True) + linear *= self.peak_luminance # [0, 1] → absolute cd/m² + h, w = linear.shape[:2] + flat = linear.reshape(-1, 3) + + bt2020 = flat @ _SRGB_TO_BT2020.T + lms = bt2020 @ _BT2020_TO_LMS.T + lms_pq = _pq_eotf_inv(lms) + ictcp = lms_pq @ _LMS_TO_ICTCP.T + + return ictcp.reshape(h, w, 3).astype(np.float32) + + def from_rgb(self, rgb: np.ndarray) -> np.ndarray: + return self._rgb_to_ictcp(rgb) + + @property + def name(self) -> str: + return "ICtCp" + + def delta_E(self, a: np.ndarray, b: np.ndarray) -> AppearanceDelta: + ic1 = self._rgb_to_ictcp(a) + ic2 = self._rgb_to_ictcp(b) + diff = ic1 - ic2 + # ITU-R BT.2124-0: ΔE_ITP = 720 × √(ΔI² + (0.5 × ΔCt)² + ΔCp²) + diff = np.stack( + [diff[..., 0], 0.5 * diff[..., 1], diff[..., 2]], + axis=-1, + ) + de = np.sqrt(np.sum(diff**2, axis=-1)) * _ICTCP_SCALE + return AppearanceDelta(values=de.astype(np.float32), space_name=self.name) + + +# --------------------------------------------------------------------------- +# Factory function +# --------------------------------------------------------------------------- + + +def make_appearance_space(name: str) -> AppearanceSpace: + """Create an appearance space by name. + + This factory centralizes space selection so CLI, GUI, and other callers + do not duplicate the mapping logic or rely on fragile index arithmetic. + + Args: + name: Space identifier. Supported values are ``"cielab"`` (CIEDE2000) + and ``"ictcp"`` (BT.2124 ΔE_ITP). + + Returns: + An ``AppearanceSpace`` instance configured with sensible defaults. + + Raises: + ValueError: If ``name`` is not recognized. + + Example: + >>> from colorcast.analysis.appearance import make_appearance_space + >>> space = make_appearance_space("ictcp") + >>> delta = space.delta_E(a, b) + """ + normalized = name.lower() + if normalized == "cielab": + return CIELABSpace() + if normalized == "ictcp": + return ICtCpSpace() + raise ValueError(f"unsupported appearance space: {name!r}. " f"Supported: 'cielab', 'ictcp'.") diff --git a/colorcast/analysis/dashboard.py b/colorcast/analysis/dashboard.py index ebe7c48..d7ed628 100644 --- a/colorcast/analysis/dashboard.py +++ b/colorcast/analysis/dashboard.py @@ -17,6 +17,7 @@ import numpy as np +from colorcast.analysis.appearance import AppearanceSpace from colorcast.analysis.error_map import ErrorMap, get_error_map from colorcast.processing.simulation import ColorBlindSimulator @@ -28,6 +29,11 @@ } +def _heatmap_title(deficiency: str, metric_label: str) -> str: + """Return the display title for a dashboard heatmap.""" + return f"{metric_label} ({deficiency[0].upper()})" + + @dataclass class DashboardResult: """Container returned by :func:`compute_dashboard`. @@ -50,10 +56,22 @@ class DashboardResult: error_maps: dict[str, ErrorMap] summary: dict[str, dict[str, float]] + @property + def metric_label(self) -> str: + """Human-readable label for the ΔE metric used in this dashboard. + + Reads ``appearance_delta_name`` from the first error map. Returns + ``"CIEDE2000"`` if no appearance space was set. + """ + first = next(iter(self.error_maps.values())) + return first.appearance_delta_name or "CIEDE2000" + def compute_dashboard( image_array: np.ndarray, max_workers: int | None = None, + *, + appearance: AppearanceSpace | None = None, ) -> DashboardResult: """Run all three CVD simulations and error maps in parallel. @@ -61,6 +79,10 @@ def compute_dashboard( image_array: Source image, any numeric dtype, shape (H, W, 3). max_workers: Passed to ``ThreadPoolExecutor``. Defaults to the number of deficiencies (3). + appearance: Optional perceptually uniform color space. When + provided, each error map will include an appearance-aware + per-pixel ΔE, and the summary table will rank deficiencies + by the appearance metric. Returns: :class:`DashboardResult` @@ -72,7 +94,15 @@ def compute_dashboard( 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) + # When appearance space is provided, we skip CIEDE2000 computation + # because the summary will prefer the appearance metric anyway. + # This avoids redundant computation. + em = get_error_map( + image_array, + sim, + compute_dE00=(appearance is None), + appearance=appearance, + ) stats = _summarize(em) return deficiency, sim, em, stats @@ -98,20 +128,27 @@ def _simulate_and_map(deficiency: str) -> tuple[str, np.ndarray, ErrorMap, dict[ def _summarize(em: ErrorMap) -> dict[str, float]: - """Derive scalar summary statistics from an ErrorMap.""" - ce = em.chroma_error_dE00 - if ce is None: + """Derive scalar summary statistics from an ErrorMap. + + Uses ``ErrorMap.preferred_metric()`` to select the best available + per-pixel color-difference array. + """ + ce = em.preferred_metric() + + if ce.size == 0: return { "mean_error": float("nan"), "median_error": float("nan"), "p95_error": float("nan"), "percent_affected": float("nan"), } + # Threshold of 1.0 ΔE is the classic just-noticeable-difference (JND). + # For CIEDE2000 and ΔE_ITP (scaled by 720), 1.0 ≈ one JND. + # For CIE76, the scale is different but 1.0 remains a reasonable threshold. 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), } @@ -156,11 +193,11 @@ def generate_dashboard_report( Layout:: Row 0 — [ Original (centred) ] Row 1 — [Protanopia] [Deuteranopia] [Tritanopia] - Row 2 — [Chroma Loss (P)] [Chroma Loss (D)] [Chroma Loss (T)] + Row 2 — [Metric (P)] [Metric (D)] [Metric (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. + metric heatmaps are placed below the grid. Args: result: Result from :func:`compute_dashboard`. @@ -195,12 +232,13 @@ def generate_dashboard_report( axes[1, col].set_title(label, fontfamily="monospace", fontsize=10) axes[1, col].axis("off") - # Row 2: three chroma-loss heatmaps + # Row 2: three metric heatmaps + metric_label = result.metric_label 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) + _show_heatmap(axes[2, col], em.preferred_metric()) + axes[2, col].set_title(_heatmap_title(deficiency, metric_label), fontsize=11) # Row 3: empty — reserved for the summary table and caption below for col in range(3): @@ -210,13 +248,13 @@ def generate_dashboard_report( 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 explaining how to read the metric heatmaps 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." + f"{metric_label} heatmaps show, per pixel, the selected metric value\n" + "for each simulated deficiency. Red or bright regions indicate\n" + "larger values, while dark regions indicate smaller values. The\n" + "hot colormap is normalised so the brightest pixel in each\n" + "heatmap represents the maximum metric value for that deficiency." ) fig.text(0.05, 0.025, caption, fontfamily="monospace", fontsize=10) @@ -225,11 +263,11 @@ def generate_dashboard_report( 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()) +def _show_heatmap(ax, metric: np.ndarray) -> None: + """Render a metric heatmap on a Matplotlib axis.""" + vmax = float(metric.max()) if vmax < 1e-6: - ax.imshow(np.zeros_like(chroma_error), cmap="hot", vmin=0, vmax=1) + ax.imshow(np.zeros_like(metric), cmap="hot", vmin=0, vmax=1) else: - ax.imshow(chroma_error / vmax, cmap="hot", vmin=0, vmax=1) + ax.imshow(metric / 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 fd95999..b6dcb71 100644 --- a/colorcast/analysis/error_map.py +++ b/colorcast/analysis/error_map.py @@ -36,6 +36,7 @@ import numpy as np from skimage import color as skcolor +from colorcast.analysis.appearance import AppearanceSpace from colorcast.processing.image_loader import normalize_to_float32 # --------------------------------------------------------------------------- @@ -85,6 +86,16 @@ class ErrorMap(NamedTuple): contributes. More perceptually uniform than the Euclidean ``chroma_error``. Range [0, ∞), typically ≤ 100. Set to ``None`` when ``compute_dE00=False``. + + appearance_delta_name : str or None + Name of the appearance space used to compute ``appearance_delta`` + (for example ``"ICtCp"``). ``None`` when no appearance space + was supplied. + + appearance_delta : np.ndarray or None + Per-pixel ΔE computed by an :class:`~colorcast.analysis.appearance.AppearanceSpace`, + shape (H, W), dtype float32. ``None`` when no appearance space + was supplied. """ signed: np.ndarray @@ -93,6 +104,29 @@ class ErrorMap(NamedTuple): signed_chroma_ab: np.ndarray orig_l_star: np.ndarray chroma_error_dE00: np.ndarray | None = None + appearance_delta_name: str | None = None + appearance_delta: np.ndarray | None = None + + def preferred_metric(self) -> np.ndarray: + """Return the best available per-pixel color-difference array. + + Priority order: + 1. ``appearance_delta`` (appearance-aware ΔE, e.g. ICtCp) + 2. ``chroma_error_dE00`` (CIEDE2000 chromaticity error) + 3. ``chroma_error`` (Euclidean chromaticity error) + + This method centralizes the domain judgment about which metric to + prefer, so downstream code (e.g. dashboard summaries) does not need + to encode the priority rule. + + Returns: + Per-pixel color-difference array, shape (H, W), dtype float32. + """ + if self.appearance_delta is not None: + return self.appearance_delta + if self.chroma_error_dE00 is not None: + return self.chroma_error_dE00 + return self.chroma_error # --------------------------------------------------------------------------- @@ -122,6 +156,8 @@ def get_error_map( original_img: np.ndarray, simulated_img: np.ndarray, compute_dE00: bool = False, + *, + appearance: AppearanceSpace | None = None, ) -> ErrorMap: """Compute the full error analysis between *original_img* and *simulated_img*. @@ -142,14 +178,22 @@ def get_error_map( 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. + field is left as ``None``. + + appearance : :class:`~colorcast.analysis.appearance.AppearanceSpace` or None + Optional perceptually uniform color space for computing an + appearance-aware ΔE. When provided, the returned + :class:`ErrorMap` will carry ``appearance_delta`` (per-pixel ΔE) + and ``appearance_delta_name``. For example, pass + :class:`~colorcast.analysis.appearance.ICtCpSpace` to get the + HDR-aware ICtCp ΔE scaled by 720. Returns ------- ErrorMap Named tuple with ``signed``, ``absolute``, ``chroma_error``, - ``chroma_error_dE00``, ``signed_chroma_ab``, and ``orig_l_star`` + ``chroma_error_dE00``, ``signed_chroma_ab``, ``orig_l_star``, + and optionally ``appearance_delta`` and ``appearance_delta_name`` arrays. See :class:`ErrorMap` for details. Raises @@ -224,11 +268,18 @@ def get_error_map( chroma_error = np.sqrt(a_diff**2 + b_diff**2) # (H, W) float32 # -- 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. + # Compute dE00 with L* set to 50 to isolate chromatic contribution. + # When compute_dE00 is False the field is left as None. chroma_error_dE00 = _compute_chroma_error_dE00(orig_lab, sim_lab) if compute_dE00 else None + # -- Appearance-based ΔE --------------------------------------------------- + appearance_delta: np.ndarray | None = None + appearance_delta_name: str | None = None + if appearance is not None: + result = appearance.delta_E(orig, sim) + appearance_delta = result.values + appearance_delta_name = result.space_name + # 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) @@ -240,6 +291,8 @@ def get_error_map( chroma_error_dE00=chroma_error_dE00, signed_chroma_ab=signed_chroma_ab, orig_l_star=orig_l_star, + appearance_delta_name=appearance_delta_name, + appearance_delta=appearance_delta, ) @@ -297,8 +350,8 @@ 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)) + orig_disp = normalize_to_float32(np.asarray(original_img)) + sim_disp = normalize_to_float32(np.asarray(simulated_img)) axes[col].imshow(orig_disp) axes[col].set_title("Original", fontsize=11) diff --git a/colorcast/assets/chevron-down.png b/colorcast/assets/chevron-down.png new file mode 100644 index 0000000..3749d63 Binary files /dev/null and b/colorcast/assets/chevron-down.png differ diff --git a/colorcast/gui.py b/colorcast/gui.py index 8d1b350..341ddbb 100644 --- a/colorcast/gui.py +++ b/colorcast/gui.py @@ -3,12 +3,15 @@ from __future__ import annotations import logging +import sys from collections.abc import Callable +from pathlib import Path +from threading import Thread import numpy as np from PyQt5 import QtCore, QtGui from PyQt5.QtCore import QTimer -from PyQt5.QtGui import QImage, QPixmap, QStandardItem, QStandardItemModel +from PyQt5.QtGui import QImage, QPalette, QPixmap, QStandardItem, QStandardItemModel from PyQt5.QtWidgets import ( QApplication, QComboBox, @@ -19,17 +22,22 @@ QLabel, QMessageBox, QPushButton, + QSizePolicy, QSlider, QVBoxLayout, QWidget, ) from colorcast import blend_images, registry, save_image +from colorcast.analysis.appearance import make_appearance_space from colorcast.analysis.dashboard import ( _DEFICIENCIES, _DEFICIENCY_LABELS, DashboardResult, + _heatmap_title, + compute_dashboard, format_summary_table, + generate_dashboard_report, ) from colorcast.processing.image_loader import ImageMeta, load_image_with_meta from colorcast.utils.config import ColorCastConfig @@ -39,6 +47,283 @@ logger = logging.getLogger(__name__) +def _sanitize_filename(name: str) -> str: + """Turn an appearance-space label into a safe filename fragment.""" + return name.lower().replace(" ", "_").replace("(", "").replace(")", "") + + +# Single source of truth for the application theme. Deep neutral canvas, +# card surfaces one step lighter, hairline borders, and a monochrome accent +# (white primary actions). Widget variants are driven by dynamic properties: +# caption, wordmark, value, imageCard, cellTitle, status, summaryCard on +# QLabel, and secondary on QPushButton. +_ASSETS_DIR = Path(__file__).resolve().parent / "assets" +_CHEVRON_PATH = (_ASSETS_DIR / "chevron-down.png").as_posix() + +_STYLESHEET_TEMPLATE = """ +/* Base canvas */ +QWidget { + background-color: #0E0E10; + color: #F2F2F2; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 13px; +} +QDialog, QMessageBox, QFileDialog { + background-color: #0E0E10; +} + +/* Labels */ +QLabel { + background-color: transparent; +} +QLabel[wordmark="true"] { + font-size: 19px; + font-weight: 700; +} +QLabel[caption="true"] { + color: #8B8B93; + font-size: 11px; + font-weight: 600; +} +QLabel[value="true"] { + font-weight: 600; +} +QLabel[imageCard="true"] { + background-color: #131316; + border: 1px solid #26262B; + border-radius: 12px; + color: #5C5C64; +} +QLabel[cellTitle="true"] { + color: #B9B9C0; + font-size: 12px; +} +QLabel[status="true"] { + color: #8B8B93; +} +QLabel[summaryCard="true"] { + background-color: #131316; + border: 1px solid #26262B; + border-radius: 8px; + padding: 10px; + font-family: "Menlo", "Consolas", "DejaVu Sans Mono", "Courier New", monospace; + font-size: 12px; +} + +/* Buttons: default is the primary action */ +QPushButton { + background-color: #F2F2F2; + color: #0E0E10; + border: 1px solid #F2F2F2; + border-radius: 8px; + padding: 9px 18px; + font-weight: 600; + min-height: 18px; +} +QPushButton:hover { + background-color: #FFFFFF; + border-color: #FFFFFF; +} +QPushButton:pressed { + background-color: #D9D9DE; + border-color: #D9D9DE; +} +QPushButton:focus { + border-color: #FFFFFF; +} +QPushButton:disabled { + background-color: #232327; + border-color: #232327; + color: #55555C; +} +QPushButton[secondary="true"] { + background-color: transparent; + color: #C9C9CF; + border: 1px solid #343439; + font-weight: 500; +} +QPushButton[secondary="true"]:hover { + color: #FFFFFF; + border-color: #4A4A52; + background-color: #19191D; +} +QPushButton[secondary="true"]:pressed { + background-color: #222227; +} +QPushButton[secondary="true"]:focus { + border-color: #56565E; +} +QPushButton[secondary="true"]:disabled { + color: #4A4A51; + border-color: #26262B; + background-color: transparent; +} + +/* Slider */ +QSlider::groove:horizontal { + height: 4px; + background: #2A2A2E; + border-radius: 2px; +} +QSlider::sub-page:horizontal { + background: #F2F2F2; + border-radius: 2px; +} +QSlider::add-page:horizontal { + background: #2A2A2E; + border-radius: 2px; +} +QSlider::handle:horizontal { + background: #FFFFFF; + width: 16px; + height: 16px; + margin: -7px 0; + border-radius: 4px; +} +QSlider::handle:horizontal:hover { + background: #E4E4E8; +} + +/* Combo box */ +QComboBox { + background-color: #1E1E22; + border: 1px solid #2F2F35; + border-radius: 8px; + padding: 8px 14px; + min-height: 18px; +} +QComboBox:hover { + border-color: #44444C; +} +QComboBox:focus { + border-color: #56565E; +} +QComboBox::drop-down { + border: none; + width: 26px; +} +QComboBox::down-arrow { + image: url("@CHEVRON_DOWN@"); + width: 12px; + height: 12px; +} +/* The popup container is a top-level window; paint it with the list + surface color so no unpainted frame shows around the rounded view. */ +QComboBoxPrivateContainer { + background-color: #17171A; +} +QComboBox QAbstractItemView { + background-color: #17171A; + border: 1px solid #2F2F35; + border-radius: 10px; + padding: 6px; + outline: none; + selection-background-color: #2C2C33; + selection-color: #FFFFFF; +} +QComboBox QAbstractItemView::item { + padding: 7px 10px; + border-radius: 6px; + color: #E8E8EC; +} +QComboBox QAbstractItemView::item:hover { + background-color: #222228; +} +QComboBox QAbstractItemView::item:disabled { + color: #55555C; + background-color: transparent; +} + +/* Tooltips */ +QToolTip { + background-color: #1E1E22; + color: #F2F2F2; + border: 1px solid #2F2F35; + padding: 6px 8px; + border-radius: 6px; +} +""" + +_APP_STYLESHEET = _STYLESHEET_TEMPLATE.replace("@CHEVRON_DOWN@", f'url("{_CHEVRON_PATH}")') + + +def _caption_label(text: str, *, align_right: bool = False) -> QLabel: + """Build a small section caption with letter spacing.""" + label = QLabel(text) + label.setProperty("caption", True) + font = label.font() + font.setLetterSpacing(QtGui.QFont.PercentageSpacing, 112) + label.setFont(font) + if align_right: + label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + return label + + +def _dark_palette() -> QPalette: + """Return a palette matching the stylesheet for chrome QSS cannot reach. + + Popup frames, native dialogs, and fallback rendering (for example the + combo box drop-down arrow area) read their colors from the application + palette, so it must agree with the dark stylesheet. + """ + palette = QPalette() + palette.setColor(QPalette.Window, QtGui.QColor("#0E0E10")) + palette.setColor(QPalette.WindowText, QtGui.QColor("#F2F2F2")) + palette.setColor(QPalette.Base, QtGui.QColor("#131316")) + palette.setColor(QPalette.AlternateBase, QtGui.QColor("#17171A")) + palette.setColor(QPalette.Text, QtGui.QColor("#F2F2F2")) + palette.setColor(QPalette.Button, QtGui.QColor("#1E1E22")) + palette.setColor(QPalette.ButtonText, QtGui.QColor("#F2F2F2")) + palette.setColor(QPalette.ToolTipBase, QtGui.QColor("#1E1E22")) + palette.setColor(QPalette.ToolTipText, QtGui.QColor("#F2F2F2")) + palette.setColor(QPalette.Highlight, QtGui.QColor("#2C2C33")) + palette.setColor(QPalette.HighlightedText, QtGui.QColor("#FFFFFF")) + palette.setColor(QPalette.PlaceholderText, QtGui.QColor("#8B8B93")) + palette.setColor(QPalette.Disabled, QPalette.Text, QtGui.QColor("#55555C")) + palette.setColor(QPalette.Disabled, QPalette.ButtonText, QtGui.QColor("#55555C")) + palette.setColor(QPalette.Disabled, QPalette.WindowText, QtGui.QColor("#55555C")) + return palette + + +def _apply_dark_window_chrome() -> None: + """Give macOS window title bars the dark appearance. + + Qt 5 does not expose NSAppearance, so a dark stylesheet and palette + still leave the native title bar light. Set the application appearance + through the Objective-C runtime instead. Best effort: on any failure + the default chrome stays, which only affects the title bar tint. + """ + if sys.platform != "darwin": + return + try: + import ctypes + import ctypes.util + + void_p = ctypes.c_void_p + objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc")) + appkit = ctypes.cdll.LoadLibrary(ctypes.util.find_library("AppKit")) + + objc.objc_getClass.restype = void_p + objc.objc_getClass.argtypes = [ctypes.c_char_p] + objc.sel_registerName.restype = void_p + objc.sel_registerName.argtypes = [ctypes.c_char_p] + + ns_app = objc.objc_getClass(b"NSApplication") + objc.objc_msgSend.restype = void_p + objc.objc_msgSend.argtypes = [void_p, void_p] + app = objc.objc_msgSend(ns_app, objc.sel_registerName(b"sharedApplication")) + + ns_appearance = objc.objc_getClass(b"NSAppearance") + dark_name = void_p.in_dll(appkit, "NSAppearanceNameDarkAqua") + objc.objc_msgSend.argtypes = [void_p, void_p, void_p] + dark = objc.objc_msgSend( + ns_appearance, objc.sel_registerName(b"appearanceNamed:"), dark_name + ) + objc.objc_msgSend(app, objc.sel_registerName(b"setAppearance:"), dark) + except Exception: # noqa: BLE001 - cosmetic best effort + logger.debug("Could not apply dark window chrome", exc_info=True) + + def _array_to_pixmap( array: np.ndarray, cell_size: int, @@ -68,24 +353,27 @@ class _WorkerSignals(QtCore.QObject): finished = QtCore.pyqtSignal() +# Qt can collect an unreferenced QObject before a queued signal is +# delivered, so in-flight bridges are held here until their slot has run. +# Both add and discard happen on the GUI thread, so no locking is needed. +_LIVE_WORKER_SIGNALS: set[_WorkerSignals] = set() + + 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: + _LIVE_WORKER_SIGNALS.add(signals) - def _on_done() -> None: - on_done() - _ref.clear() + def _on_done() -> None: + try: + if on_done is not None: + on_done() + finally: + _LIVE_WORKER_SIGNALS.discard(signals) - signals.finished.connect(_on_done) - else: - signals.finished.connect(_ref.clear) + signals.finished.connect(_on_done) def _worker() -> None: try: @@ -113,6 +401,7 @@ def __init__(self, config: ColorCastConfig | None = None) -> None: self._preview_size: int = cfg.preview_size self._intensity: float = cfg.default_intensity self._slider_debounce_ms: int = cfg.slider_debounce_ms + self._current_request_id: int = 0 self.content_image: np.ndarray | None = None self.style_image: np.ndarray | None = None @@ -123,12 +412,14 @@ def __init__(self, config: ColorCastConfig | None = None) -> None: self.update_timer: QTimer = QTimer() self.update_timer.setSingleShot(True) self.update_timer.timeout.connect(self.apply_intensity_blend) - self.setFixedSize(self._window_width, self._window_height) + self.resize(self._window_width, self._window_height) + self.setMinimumSize(3 * self._preview_size + 80, 660) self.initUI() + self.setStyleSheet(_APP_STYLESHEET) def initUI(self) -> None: """Initialize the user interface.""" - self.setWindowTitle("ColorCast - Style Transfer") + self.setWindowTitle("ColorCast") self.content_label: QLabel = QLabel(self) self.style_label: QLabel = QLabel(self) @@ -136,7 +427,8 @@ def initUI(self) -> None: for label in [self.content_label, self.style_label, self.result_label]: label.setMinimumSize(self._preview_size, self._preview_size) - label.setStyleSheet("border: 1px solid gray;") + label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + label.setProperty("imageCard", True) label.setAlignment(QtCore.Qt.AlignCenter) label.setText("No image loaded") @@ -144,31 +436,16 @@ def initUI(self) -> None: self.intensity_slider.setMinimum(0) self.intensity_slider.setMaximum(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(""" - QSlider::groove:horizontal { - height: 9px; - background: #E0E0E0; - margin: 0; - } - QSlider::handle:horizontal { - background: #505050; - width: 18px; - margin: 0; - } - QSlider::handle:horizontal:hover { - background: #606060; - } - """) self.intensity_label: QLabel = QLabel(f"{int(self._intensity * 100)}%", self) - self.intensity_label.setAlignment(QtCore.Qt.AlignCenter) + self.intensity_label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + self.intensity_label.setFixedWidth(48) + self.intensity_label.setProperty("value", True) self.method_combo: QComboBox = QComboBox(self) - # Build a categorised model with non-selectable section headers + # Build a categorized model with non-selectable section headers _combo_model = QStandardItemModel(self.method_combo) def _header(text: str) -> QStandardItem: @@ -179,7 +456,7 @@ def _header(text: str) -> QStandardItem: font = item.font() font.setBold(True) item.setFont(font) - item.setForeground(QtGui.QColor("#888888")) + item.setForeground(QtGui.QColor("#8B8B93")) return item def _entry(label: str, data: str) -> QStandardItem: @@ -209,6 +486,7 @@ def _entry(label: str, data: str) -> QStandardItem: self.method_combo.setModel(_combo_model) self.method_combo.setCurrentIndex(1) # start on "Histogram Match" + self.method_combo.setMinimumWidth(230) self.method_combo.currentIndexChanged.connect(self.on_method_changed) self.load_content_button: QPushButton = QPushButton("Load Content Image", self) @@ -219,6 +497,15 @@ def _entry(label: str, data: str) -> QStandardItem: self.dashboard_button: QPushButton = QPushButton("Dashboard", self) self.compare_button: QPushButton = QPushButton("Compare Methods", self) + for button in [ + self.load_content_button, + self.load_style_button, + self.clear_button, + self.dashboard_button, + self.compare_button, + ]: + button.setProperty("secondary", True) + 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) @@ -227,55 +514,80 @@ def _entry(label: str, data: str) -> QStandardItem: self.dashboard_button.clicked.connect(self.show_dashboard) self.compare_button.clicked.connect(self.show_comparison) - main_layout: QVBoxLayout = QVBoxLayout() - method_layout: QHBoxLayout = QHBoxLayout() - grid_layout: QGridLayout = QGridLayout() - intensity_container: QVBoxLayout = QVBoxLayout() - intensity_slider_layout: QHBoxLayout = QHBoxLayout() - intensity_labels_layout: QHBoxLayout = QHBoxLayout() - button_layout: QHBoxLayout = QHBoxLayout() - - method_layout.addWidget(QLabel("Transfer Method:")) - method_layout.addWidget(self.method_combo) - method_layout.addStretch() - - grid_layout.setSpacing(20) - grid_layout.setContentsMargins(10, 10, 10, 10) - - grid_layout.addWidget(self.content_label, 0, 0) - grid_layout.addWidget(self.style_label, 0, 1) - grid_layout.addWidget(self.result_label, 0, 2) - - grid_layout.addWidget(self.load_content_button, 1, 0) - grid_layout.addWidget(self.load_style_button, 1, 1) - grid_layout.addWidget(self.apply_button, 1, 2) - - intensity_slider_layout.addWidget(self.intensity_slider) - - self.intensity_heading_label: QLabel = QLabel("Style Intensity:") - intensity_labels_layout.addWidget(self.intensity_heading_label) - intensity_labels_layout.addStretch() - intensity_labels_layout.addWidget(self.intensity_label) - - intensity_container.setSpacing(0) - intensity_container.addLayout(intensity_slider_layout) - intensity_container.addLayout(intensity_labels_layout) - intensity_container.setContentsMargins(0, 0, 0, 10) - - 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) + # Header: brand on the left, method picker on the right. + wordmark = QLabel("ColorCast", self) + wordmark.setProperty("wordmark", True) + brand_column = QVBoxLayout() + brand_column.setSpacing(2) + brand_column.setContentsMargins(0, 0, 0, 0) + brand_column.addWidget(wordmark) + brand_column.addWidget(_caption_label("COLOR AND STYLE TRANSFER")) + + method_column = QVBoxLayout() + method_column.setSpacing(6) + method_column.setContentsMargins(0, 0, 0, 0) + method_column.addWidget(_caption_label("METHOD", align_right=True)) + method_column.addWidget(self.method_combo) + + header_layout = QHBoxLayout() + header_layout.addLayout(brand_column) + header_layout.addStretch() + header_layout.addLayout(method_column) + + # Image stage: three equal card columns. + stage_layout = QHBoxLayout() + stage_layout.setSpacing(16) + stage_layout.addLayout( + self._stage_column("CONTENT", self.content_label, self.load_content_button), 1 + ) + stage_layout.addLayout( + self._stage_column("STYLE", self.style_label, self.load_style_button), 1 + ) + stage_layout.addLayout( + self._stage_column("RESULT", self.result_label, self.apply_button), 1 + ) - main_layout.addLayout(method_layout) - main_layout.addLayout(grid_layout) - main_layout.addStretch() - main_layout.addLayout(intensity_container) - main_layout.addLayout(button_layout) + # Intensity: heading and readout on one row, slider full width below. + self.intensity_heading_label: QLabel = QLabel("Style Intensity:", self) + self.intensity_heading_label.setProperty("caption", True) + intensity_row = QHBoxLayout() + intensity_row.addWidget(self.intensity_heading_label) + intensity_row.addStretch() + intensity_row.addWidget(self.intensity_label) + + intensity_column = QVBoxLayout() + intensity_column.setSpacing(8) + intensity_column.addLayout(intensity_row) + intensity_column.addWidget(self.intensity_slider) + + # Footer: secondary actions left, primary save right. + footer_layout = QHBoxLayout() + footer_layout.setSpacing(10) + footer_layout.addWidget(self.clear_button) + footer_layout.addWidget(self.dashboard_button) + footer_layout.addWidget(self.compare_button) + footer_layout.addStretch() + footer_layout.addWidget(self.save_button) + + main_layout: QVBoxLayout = QVBoxLayout() + main_layout.setContentsMargins(24, 20, 24, 20) + main_layout.setSpacing(18) + main_layout.addLayout(header_layout) + main_layout.addLayout(stage_layout, 1) + main_layout.addSpacing(4) + main_layout.addLayout(intensity_column) + main_layout.addLayout(footer_layout) self.setLayout(main_layout) + def _stage_column(self, caption: str, label: QLabel, button: QPushButton) -> QVBoxLayout: + """Assemble one image-stage column: caption, card, action button.""" + column = QVBoxLayout() + column.setSpacing(10) + column.addWidget(_caption_label(caption)) + column.addWidget(label, 1, QtCore.Qt.AlignCenter) + column.addWidget(button) + return column + def _load_image_file( self, path: str, @@ -467,7 +779,7 @@ def save_result(self) -> None: options = QFileDialog.Options() file_types = ( "PNG Files (*.png);;JPEG Files (*.jpg);;" - "TIFF Files(*.tiff);;BMP Files (*.bmp);;All Files (*.*)" + "TIFF Files (*.tiff);;BMP Files (*.bmp);;All Files (*.*)" ) save_path, _ = QFileDialog.getSaveFileName( self, "Save Result Image", "Untitled", file_types, options=options @@ -541,8 +853,12 @@ def __init__(self, content_image: np.ndarray, parent: QWidget | None = None) -> self._content_image = content_image self._result: DashboardResult | None = None self._label_widgets: dict[str, QLabel] = {} + self._title_widgets: dict[str, QLabel] = {} self._summary_label: QLabel | None = None self._report_button: QPushButton | None = None + self._appearance_combo: QComboBox | None = None + self._error: str | None = None + self._current_request_id: int = 0 self.setWindowTitle("CVD Accessibility Dashboard") @@ -556,77 +872,115 @@ def __init__(self, content_image: np.ndarray, parent: QWidget | None = None) -> self._start_computation() def _build_ui(self) -> None: + self.setStyleSheet(_APP_STYLESHEET) main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(24, 20, 24, 20) + main_layout.setSpacing(14) - self._status_label = QLabel("Computing simulations — please wait…", self) + self._status_label = QLabel("Computing simulations. Please wait...", self) self._status_label.setAlignment(QtCore.Qt.AlignCenter) + self._status_label.setProperty("status", True) main_layout.addWidget(self._status_label) grid = QGridLayout() grid.setSpacing(12) main_layout.addLayout(grid) - # Row 0: [Original] — centred, alone + # Row 0: [Original] centered, alone # Row 1: [Protanopia] [Deuteranopia] [Tritanopia] - # Row 2: [Chroma Loss (P)] [Chroma Loss (D)] [Chroma Loss (T)] + # Row 2: [Error Metric (P)] [Error Metric (D)] [Error Metric (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)"), + (2, 0, "heatmap_protanopia", "Error Metric (P)"), + (2, 1, "heatmap_deuteranopia", "Error Metric (D)"), + (2, 2, "heatmap_tritanopia", "Error Metric (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;") + label.setProperty("imageCard", True) self._label_widgets[key] = label container = QVBoxLayout() + container.setSpacing(6) container.addWidget(label, alignment=QtCore.Qt.AlignCenter) title = QLabel(label_text, self) + self._title_widgets[key] = title title.setAlignment(QtCore.Qt.AlignCenter) title.setWordWrap(True) + title.setProperty("cellTitle", True) container.addWidget(title) grid.addLayout(container, row, col) - # Summary panel + # Summary panel: the monospace font comes from the summaryCard + # stylesheet rule, since QSS font properties override setFont(). 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;") + self._summary_label.setProperty("summaryCard", True) main_layout.addWidget(self._summary_label) # Bottom buttons button_layout = QHBoxLayout() + metric_caption = _caption_label("ΔE METRIC") + button_layout.addWidget(metric_caption) + self._appearance_combo = QComboBox(self) + self._appearance_combo.addItem("CIELAB (legacy)", "cielab") + self._appearance_combo.addItem("ICtCp (HDR, BT.2100)", "ictcp") + self._appearance_combo.setCurrentIndex(0) + self._appearance_combo.currentIndexChanged.connect(self._restart_computation) + button_layout.addWidget(self._appearance_combo) + button_layout.addStretch() 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.setProperty("secondary", True) 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) def _start_computation(self) -> None: - self._error: str | None = None + self._error = None + self._current_request_id += 1 + # Increment request ID so stale completions from previous computations + # are ignored if the user switches the appearance metric mid-computation. + request_id = self._current_request_id - def _compute() -> None: - from colorcast.analysis.dashboard import compute_dashboard + if self._report_button is not None: + self._report_button.setEnabled(False) + + # Read appearance space id from combo box item data. Falls back + # to "cielab" when no combo box is present (should not happen in + # practice, but is safe). + space_name: str = "cielab" + if self._appearance_combo is not None: + data = self._appearance_combo.currentData() + if data is not None: + space_name = str(data) + appearance = make_appearance_space(space_name) + + result: DashboardResult | None = None + error: str | None = None + def _compute() -> None: + nonlocal result, error try: - self._result = compute_dashboard(self._content_image) + result = compute_dashboard(self._content_image, appearance=appearance) except Exception as e: # noqa: BLE001 — surfaced in the dialog logger.exception("Dashboard computation failed") - self._error = str(e) - self._result = None + error = str(e) + result = None def _on_done() -> None: + nonlocal result, error + if request_id != self._current_request_id: + return + self._result = result + self._error = error if self._error is not None: self._status_label.setText(f"Computation failed: {self._error}") else: @@ -635,6 +989,10 @@ def _on_done() -> None: _run_in_thread(_compute, on_done=_on_done) + def _restart_computation(self) -> None: + self._status_label.setText("Computing simulations. Please wait...") + self._start_computation() + def _populate_results(self) -> None: if self._result is None: return @@ -652,8 +1010,8 @@ def _show(array: np.ndarray, key: str) -> None: for deficiency in _DEFICIENCIES: _show(result.simulated[deficiency], deficiency) - # Chroma-loss heatmaps — use the chroma_error field rendered as - # a grayscale hot image + # Error-metric heatmaps — renders preferred_metric() as a grayscale + # hot image for key, deficiency in [ ("heatmap_protanopia", "protanopia"), ("heatmap_deuteranopia", "deuteranopia"), @@ -661,7 +1019,10 @@ def _show(array: np.ndarray, key: str) -> None: ]: em = result.error_maps.get(deficiency) if em is not None: - _show(em.chroma_error, key) + _show(em.preferred_metric(), key) + title_widget = self._title_widgets.get(key) + if title_widget is not None: + title_widget.setText(_heatmap_title(deficiency, result.metric_label)) self._update_summary(result) if self._report_button is not None: @@ -674,18 +1035,22 @@ def _update_summary(self, result: DashboardResult) -> None: def _export_report(self) -> None: if self._result is None: return + + metric_name = self._result.metric_label + filename = f"dashboard_report_{_sanitize_filename(metric_name)}.png" + title = f"CVD Accessibility Dashboard – {metric_name}" + save_path, _ = QFileDialog.getSaveFileName( self, "Save Dashboard Report", - "dashboard_report.png", + filename, "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) + generate_dashboard_report(self._result, save_path, title=title) 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}") @@ -720,10 +1085,14 @@ def __init__( self._start_computation() def _build_ui(self) -> None: + self.setStyleSheet(_APP_STYLESHEET) main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(24, 20, 24, 20) + main_layout.setSpacing(14) - self._status_label = QLabel("Computing transfers — please wait…", self) + self._status_label = QLabel("Computing transfers. Please wait...", self) self._status_label.setAlignment(QtCore.Qt.AlignCenter) + self._status_label.setProperty("status", True) main_layout.addWidget(self._status_label) grid = QGridLayout() @@ -738,18 +1107,21 @@ def _build_ui(self) -> None: label = QLabel(self) label.setFixedSize(self._CELL_SIZE, self._CELL_SIZE) label.setAlignment(QtCore.Qt.AlignCenter) - label.setStyleSheet("border: 1px solid gray;") + label.setProperty("imageCard", True) self._label_widgets[method_id] = label container = QVBoxLayout() + container.setSpacing(6) container.addWidget(label, alignment=QtCore.Qt.AlignCenter) title = QLabel(method_id, self) title.setAlignment(QtCore.Qt.AlignCenter) title.setWordWrap(True) + title.setProperty("cellTitle", True) container.addWidget(title) grid.addLayout(container, row, col) button_layout = QHBoxLayout() close_button = QPushButton("Close", self) + close_button.setProperty("secondary", True) close_button.clicked.connect(self.accept) button_layout.addStretch() button_layout.addWidget(close_button) @@ -784,13 +1156,19 @@ def _show_one(self, method_id: str) -> None: 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.") + self._status_label.setText(f"Done: {done} of {total} methods completed.") def main() -> None: """Run the ColorCast graphical application.""" logging.basicConfig(level=logging.INFO) app = QApplication([]) + # Fusion keeps chrome (arrows, focus rings, native dialogs) consistent + # with the stylesheet across platforms. + app.setStyle("Fusion") + app.setPalette(_dark_palette()) + app.setStyleSheet(_APP_STYLESHEET) + _apply_dark_window_chrome() window = StyleTransferApp() window.show() app.exec() diff --git a/colorcast/processing/gpu_transfer.py b/colorcast/processing/gpu_transfer.py index 4247376..8960676 100644 --- a/colorcast/processing/gpu_transfer.py +++ b/colorcast/processing/gpu_transfer.py @@ -1,14 +1,11 @@ """ 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. +These entry points accept numpy arrays and return numpy arrays regardless +of whether CuPy is installed. When CuPy or a CUDA device is missing, +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. Use ``is_gpu_available()`` to check at runtime whether the accelerator will be used. diff --git a/colorcast/processing/image_loader.py b/colorcast/processing/image_loader.py index 97d4a08..5f23dbe 100644 --- a/colorcast/processing/image_loader.py +++ b/colorcast/processing/image_loader.py @@ -18,6 +18,7 @@ import logging import os +import threading from pathlib import Path from typing import NamedTuple @@ -27,6 +28,7 @@ from colorcast.utils.exceptions import ImageLoadError, InvalidImageFormatError, ValidationError from colorcast.utils.validators_enhanced import ( ALLOWED_IMAGE_EXTENSIONS, + MAX_IMAGE_PIXELS, validate_image_array, validate_image_file, ) @@ -34,32 +36,81 @@ logger = logging.getLogger(__name__) _MAX_FILE_BYTES = 200_000_000 # 200 MB +_PIL_IMAGE_LOCK = threading.Lock() -def _get_image_dimensions(filepath: str) -> tuple[int, int]: +def _read_image_array(filepath: str) -> np.ndarray: + """Read an image with skimage and reject unsupported multi-frame stacks. + + Args: + filepath: Path to the image file to read. + + Returns: + np.ndarray: The decoded image array. + """ + arr = io.imread(filepath) + if arr.ndim > 3: + raise InvalidImageFormatError( + "Multi-frame image stacks are not supported; provide a single image frame." + ) + if arr.ndim == 3 and arr.shape[-1] not in {1, 3, 4}: + raise InvalidImageFormatError( + "Multi-frame image stacks are not supported; provide a single image frame." + ) + return arr + + +def _get_image_dimensions(filepath: str, max_pixels: int | None = None) -> 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. + Uses PIL if available; on any PIL failure, decodes with + ``skimage.io.imread`` (which allocates the full array) and reads the + shape instead. ``DecompressionBombError`` is treated as a hard limit + violation and re-raised so the caller can reject oversized images before + a full decode. + + Args: + filepath: Path to the image file to inspect. + max_pixels: Optional pixel-limit override for the PIL header probe. Returns: - (width, height) in pixels + tuple[int, int]: The image dimensions as ``(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) + _PILImage = None # type: ignore[assignment] + + if _PILImage is not None: + pixel_limit = max_pixels if max_pixels is not None else MAX_IMAGE_PIXELS + with _PIL_IMAGE_LOCK: + original_limit = getattr(_PILImage, "MAX_IMAGE_PIXELS", None) + if pixel_limit is not None: + _PILImage.MAX_IMAGE_PIXELS = pixel_limit + try: + with _PILImage.open(filepath) as img: + return img.size # (width, height) + except _PILImage.DecompressionBombError: + raise + except _PILImage.DecompressionBombWarning as e: + raise _PILImage.DecompressionBombError(str(pixel_limit)) from e + except (OSError, ValueError): + pass + except Exception: + logger.warning( + "PIL header read failed for %r; falling back to full decode", filepath + ) + finally: + if pixel_limit is not None: + if original_limit is None: + delattr(_PILImage, "MAX_IMAGE_PIXELS") + else: + _PILImage.MAX_IMAGE_PIXELS = original_limit + + arr = _read_image_array(filepath) + if arr.ndim == 2: return (arr.shape[1], arr.shape[0]) + return (arr.shape[1], arr.shape[0]) class ImageMeta(NamedTuple): @@ -199,7 +250,7 @@ def load_image_with_meta( 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) + width, height = _get_image_dimensions(path, max_pixels=max_pixels) total_pixels = width * height if total_pixels > max_pixels: raise ValidationError( @@ -207,7 +258,9 @@ def load_image_with_meta( ) try: - img = img_as_float(io.imread(path)) + img = img_as_float(_read_image_array(path)) + except InvalidImageFormatError: + raise except OSError as e: raise ImageLoadError(f"Failed to read image file: {e}") from e except Exception as e: diff --git a/colorcast/processing/simulation.py b/colorcast/processing/simulation.py index e354cc5..7f2d721 100644 --- a/colorcast/processing/simulation.py +++ b/colorcast/processing/simulation.py @@ -56,6 +56,7 @@ import numpy as np from colorcast.processing.image_loader import normalize_to_float32 +from colorcast.utils.color_utils import srgb_to_linear # Type alias used in public method signatures. This Literal is the single # source of truth for the supported deficiencies; Python 3.10 cannot unpack a @@ -68,18 +69,6 @@ SUPPORTED_DEFICIENCIES: tuple[str, ...] = get_args(DeficiencyType) -def _srgb_to_linear(rgb: np.ndarray) -> np.ndarray: - """Convert nonlinear sRGB values in [0, 1] to linear RGB. - - Uses the IEC 61966-2-1 sRGB transfer function. - """ - return np.where( - rgb <= 0.04045, - rgb / 12.92, - np.power((rgb + 0.055) / 1.055, 2.4), - ) - - def _linear_to_srgb(linear: np.ndarray) -> np.ndarray: """Convert linear RGB values to nonlinear sRGB in [0, 1]. @@ -236,7 +225,7 @@ def transform_color_space( h, w, _ = img.shape pixels = img.reshape(-1, 3).astype(np.float64) # upcast for precision - linear_pixels = _srgb_to_linear(pixels) + linear_pixels = srgb_to_linear(pixels) strategy = self._PROJECTION[deficiency_type] if callable(strategy): diff --git a/colorcast/utils/color_utils.py b/colorcast/utils/color_utils.py new file mode 100644 index 0000000..e227e11 --- /dev/null +++ b/colorcast/utils/color_utils.py @@ -0,0 +1,36 @@ +"""Shared color-space utilities. + +This module provides low-level functions for color-space conversions that +are used across multiple analysis and processing modules. +""" + +from __future__ import annotations + +import numpy as np + + +def srgb_to_linear(rgb: np.ndarray) -> np.ndarray: + """Convert sRGB gamma-encoded values to linear light. + + Implements the IEC 61966-2-1 sRGB transfer function (inverse). + + Args: + rgb: Array of sRGB values in [0, 1] range. + + Returns: + Array of linear light values in [0, 1] range, same shape as input. + + Note: + This function is used by both CVD simulation (simulation.py) and + appearance-space conversion (appearance.py) to ensure consistent + color handling across the codebase. + """ + # sRGB uses a piecewise transfer function: + # - Linear segment for very dark values (≤ 0.04045) + # - Power curve for brighter values + linear = np.where( + rgb <= 0.04045, + rgb / 12.92, + ((rgb + 0.055) / 1.055) ** 2.4, + ) + return linear diff --git a/colorcast/utils/config.py b/colorcast/utils/config.py index 0e4c2d9..65cc784 100644 --- a/colorcast/utils/config.py +++ b/colorcast/utils/config.py @@ -6,7 +6,7 @@ import os from dataclasses import dataclass, field, fields from pathlib import Path -from typing import Any +from typing import Any, get_origin, get_type_hints @dataclass @@ -96,8 +96,6 @@ def load(cls, path: str | Path | None = None) -> ColorCastConfig: FileNotFoundError: If the file does not exist (surfaced directly for callers to handle). """ - 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): @@ -113,18 +111,18 @@ def load(cls, path: str | Path | None = None) -> ColorCastConfig: 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): + # bool subclasses int, so isinstance alone cannot reject it; + # accept a bool only where the field declares one. + valid = isinstance(value, origin) and (origin is bool or not isinstance(value, bool)) + if valid: + filtered[key] = value + elif origin is float and isinstance(value, int) and not isinstance(value, bool): + # JSON does not distinguish int from float; accept ints here. filtered[key] = float(value) - continue - if not isinstance(value, origin): + else: raise TypeError( - f"{key}: expected {origin.__name__}, " f"got {type(value).__name__} ({value!r})" + f"{key}: expected {origin.__name__}, got {type(value).__name__} ({value!r})" ) - filtered[key] = value return cls(**filtered) diff --git a/docs/index.rst b/docs/index.rst index 592b466..15db157 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,7 @@ ColorCast Documentation ====================== -.. image:: https://img.shields.io/badge/version-2.5.0-blue.svg +.. image:: https://img.shields.io/badge/version-2.6.0-blue.svg :target: https://github.com/MichailSemoglou/ColorCast :alt: Version diff --git a/imgs/CVD-Accessibility-Dashboard.png b/imgs/CVD-Accessibility-Dashboard.png index ac5e86f..22ebe6f 100644 Binary files a/imgs/CVD-Accessibility-Dashboard.png and b/imgs/CVD-Accessibility-Dashboard.png differ diff --git a/imgs/Compare-Transfer-Methods.png b/imgs/Compare-Transfer-Methods.png index a2d5e0d..919e981 100644 Binary files a/imgs/Compare-Transfer-Methods.png and b/imgs/Compare-Transfer-Methods.png differ diff --git a/imgs/dashboard_report.png b/imgs/dashboard_report.png deleted file mode 100644 index c6ef2de..0000000 Binary files a/imgs/dashboard_report.png and /dev/null differ diff --git a/imgs/dashboard_report_ICtCp.png b/imgs/dashboard_report_ICtCp.png new file mode 100644 index 0000000..437fd76 Binary files /dev/null and b/imgs/dashboard_report_ICtCp.png differ diff --git a/imgs/interface.png b/imgs/interface.png index c900858..8fa9e19 100644 Binary files a/imgs/interface.png and b/imgs/interface.png differ diff --git a/imgs/interface_2.png b/imgs/interface_2.png index a2d0308..4c6ac4e 100644 Binary files a/imgs/interface_2.png and b/imgs/interface_2.png differ diff --git a/pyproject.toml b/pyproject.toml index 4e1bf02..3878633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,9 @@ build-backend = "setuptools.build_meta" where = ["."] exclude = ["tests*"] +[tool.setuptools.package-data] +colorcast = ["assets/*.png"] + [tool.setuptools.dynamic] version = {attr = "colorcast._version.__version__"} diff --git a/tests/test_appearance.py b/tests/test_appearance.py new file mode 100644 index 0000000..1bca4f3 --- /dev/null +++ b/tests/test_appearance.py @@ -0,0 +1,137 @@ +import numpy as np +import pytest + +from colorcast.analysis.appearance import ( + CIELABSpace, + ICtCpSpace, + _pq_eotf_inv, +) +from colorcast.utils.color_utils import srgb_to_linear + + +def test_pq_eotf_inv_uses_absolute_luminance_reference() -> None: + values = np.array([0.0, 10000.0], dtype=np.float64) + + result = _pq_eotf_inv(values) + + assert result[0] < 1e-6 + np.testing.assert_allclose(result[1], 1.0, rtol=1e-6, atol=1e-6) + + +def test_srgb_to_linear_does_not_mutate_source_array() -> None: + rgb = np.array([[0.0, 0.5, 1.0]], dtype=np.float64) + original = rgb.copy() + + result = srgb_to_linear(rgb) + + np.testing.assert_array_equal(rgb, original) + assert result is not rgb + assert result.dtype == np.float64 + + +def test_cielab_identical_images_give_zero_delta() -> None: + rgb = np.random.rand(64, 64, 3).astype(np.float32) + space = CIELABSpace() + delta = space.delta_E(rgb, rgb) + assert delta.space_name == "CIELAB (ciede2000)" + assert delta.values.shape == (64, 64) + assert np.all(delta.values >= 0) + assert np.max(delta.values) < 1e-6 + + +def test_ictcp_identical_images_give_zero_delta() -> None: + rgb = np.random.rand(64, 64, 3).astype(np.float32) + space = ICtCpSpace() + delta = space.delta_E(rgb, rgb) + assert delta.space_name == "ICtCp" + assert delta.values.shape == (64, 64) + assert np.all(delta.values >= 0) + assert np.max(delta.values) < 1e-5 + + +def test_ictcp_delta_is_monotonic_with_distortion() -> None: + rgb = np.random.rand(32, 32, 3).astype(np.float32) + space = ICtCpSpace() + delta_small = space.delta_E(rgb, rgb * 0.95) + delta_large = space.delta_E(rgb, rgb * 0.80) + assert np.mean(delta_small.values) < np.mean(delta_large.values) + + +def test_cielab_from_rgb_returns_lab_shape() -> None: + rgb = np.random.rand(16, 32, 3).astype(np.float32) + space = CIELABSpace() + lab = space.from_rgb(rgb) + assert lab.shape == (16, 32, 3) + assert lab.dtype == np.float32 + + +def test_ictcp_from_rgb_returns_ictcp_shape() -> None: + rgb = np.random.rand(16, 32, 3).astype(np.float32) + space = ICtCpSpace() + ictcp = space.from_rgb(rgb) + assert ictcp.shape == (16, 32, 3) + assert ictcp.dtype == np.float32 + + +def test_ictcp_space_accepts_explicit_peak_luminance() -> None: + rgb = np.array([[[0.0, 0.5, 1.0]]], dtype=np.float32) + space = ICtCpSpace(peak_luminance=200.0) + ictcp = space.from_rgb(rgb) + + assert ictcp.shape == (1, 1, 3) + assert ictcp.dtype == np.float32 + assert not np.isnan(ictcp).any() + + +def test_ictcp_space_rejects_unknown_transfer_function() -> None: + with pytest.raises(ValueError, match="unsupported transfer function"): + ICtCpSpace(transfer_function="unknown") + + +def test_appearance_spaces_have_different_names() -> None: + assert CIELABSpace().name == "CIELAB (ciede2000)" + assert CIELABSpace(metric="cie76").name == "CIELAB (cie76)" + assert ICtCpSpace().name == "ICtCp" + assert CIELABSpace().name != ICtCpSpace().name + assert CIELABSpace().name != CIELABSpace(metric="cie76").name + + +def test_cielab_ciede2000_identical_images_give_zero_delta() -> None: + rgb = np.random.rand(64, 64, 3).astype(np.float32) + space = CIELABSpace(metric="ciede2000") + delta = space.delta_E(rgb, rgb) + assert delta.space_name == "CIELAB (ciede2000)" + assert delta.values.shape == (64, 64) + assert np.all(delta.values >= 0) + assert np.max(delta.values) < 1e-6 + + +def test_cielab_rejects_unknown_metric() -> None: + with pytest.raises(ValueError, match="unsupported metric"): + CIELABSpace(metric="unknown") + + +def test_make_appearance_space_returns_correct_types() -> None: + from colorcast.analysis.appearance import make_appearance_space + + assert isinstance(make_appearance_space("cielab"), CIELABSpace) + assert isinstance(make_appearance_space("ictcp"), ICtCpSpace) + assert make_appearance_space("cielab").name == "CIELAB (ciede2000)" + assert make_appearance_space("ictcp").name == "ICtCp" + + +def test_make_appearance_space_rejects_unknown() -> None: + from colorcast.analysis.appearance import make_appearance_space + + with pytest.raises(ValueError, match="unsupported appearance space"): + make_appearance_space("unknown") + + +def test_ictcp_linear_transfer_does_not_mutate_source() -> None: + rgb = np.random.rand(16, 16, 3).astype(np.float64) + original = rgb.copy() + + space = ICtCpSpace(transfer_function="linear") + _ = space.from_rgb(rgb) + + np.testing.assert_array_equal(rgb, original) diff --git a/tests/test_gui.py b/tests/test_gui.py index 1bb0301..dc30d7e 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -170,6 +170,37 @@ def _make_dashboard_result(h: int = 8, w: int = 8): ) +def test_heatmap_title_uses_selected_metric_label(): + from colorcast.analysis.dashboard import _heatmap_title + + assert _heatmap_title("protanopia", "ICtCp") == "ICtCp (P)" + assert _heatmap_title("deuteranopia", "CIEDE2000") == "CIEDE2000 (D)" + + +class TestDashboardDialogComputationRequests: + """DashboardDialog request lifecycle keeps the report button state in sync.""" + + def test_start_computation_disables_report_button(self, qt_app, monkeypatch): + """Verify that dashboard work disables the report button. + + The test uses the qt_app fixture to provide a Qt application context and + the monkeypatch fixture to replace the worker launcher and dashboard + computation with no-op functions. The test returns None because it only + asserts on the widget state. + """ + from colorcast.gui import DashboardDialog + + monkeypatch.setattr("colorcast.gui._run_in_thread", lambda target, *, on_done=None: None) + + dialog = DashboardDialog(np.random.rand(8, 8, 3).astype(np.float32)) + dialog._report_button.setEnabled(True) + + dialog._start_computation() + + assert dialog._report_button is not None + assert not dialog._report_button.isEnabled() + + class TestDashboardDialogPopulateResults: """DashboardDialog._populate_results with a precomputed DashboardResult.""" @@ -188,3 +219,49 @@ def test_summary_label_populated_and_report_button_enabled(self, qt_app, monkeyp assert "Protanopia" in text assert dialog._report_button is not None assert dialog._report_button.isEnabled() + + def test_stale_completion_does_not_mutate_current_result(self, qt_app, monkeypatch): + import colorcast.gui as gui_module + from colorcast.gui import DashboardDialog + + pending = [] + + def fake_run(target, *, on_done=None): + pending.append((target, on_done)) + + monkeypatch.setattr(gui_module, "_run_in_thread", fake_run) + monkeypatch.setattr( + gui_module, "compute_dashboard", lambda image, appearance=None: object() + ) + + # Suppress the initial _start_computation call from __init__ so the + # test controls exactly how many requests are queued. + monkeypatch.setattr(DashboardDialog, "_start_computation", lambda self: None) + + dialog = DashboardDialog(np.random.rand(8, 8, 3).astype(np.float32)) + monkeypatch.undo() + + # Re-apply the _run_in_thread patch (undo restores it to the real + # implementation, which would run the target in a real thread). + monkeypatch.setattr(gui_module, "_run_in_thread", fake_run) + monkeypatch.setattr(dialog, "_populate_results", lambda: None) + + dialog._start_computation() + dialog._start_computation() + assert len(pending) == 2 + + # Set a sentinel result to verify no stale completion mutates it. + sentinel = object() + dialog._result = sentinel + + pending[0][0]() + pending[0][1]() + + assert dialog._result is sentinel + assert dialog._error is None + + pending[1][0]() + pending[1][1]() + + assert dialog._result is not None + assert dialog._error is None diff --git a/tests/test_image_loading.py b/tests/test_image_loading.py index 0fbd309..ef66123 100644 --- a/tests/test_image_loading.py +++ b/tests/test_image_loading.py @@ -161,6 +161,52 @@ def test_load_image_with_max_pixels(self, tmp_path): assert loaded.shape[0] * loaded.shape[1] <= 50000 + def test_header_read_decompression_bomb_reraises(self, tmp_path, monkeypatch): + """Pillow decompression bombs should stop before a full decode fallback. + + Args: + tmp_path: Temporary directory fixture used to create a small image file. + monkeypatch: Fixture used to replace ``io.imread`` with a function that + would fail if the loader fell back to a full decode. + + Returns: + None: The test passes when the expected ``DecompressionBombError`` is raised. + """ + from PIL import Image + + from colorcast.processing import image_loader + + img_path = tmp_path / "bomb.png" + Image.fromarray(np.zeros((100, 100, 3), dtype=np.uint8)).save(img_path) + + def fail_imread(*args, **kwargs): + raise AssertionError("full decode should not be attempted") + + monkeypatch.setattr(image_loader.io, "imread", fail_imread) + + with pytest.raises(Image.DecompressionBombError): + image_loader._get_image_dimensions(str(img_path), max_pixels=10) + + def test_header_read_rejects_multiframe_stack(self, monkeypatch): + """Multi-frame TIFF-like arrays should not be treated as a single image. + + Args: + monkeypatch: Fixture used to replace ``io.imread`` with a stub that + returns a multi-frame array shape. + + Returns: + None: The test passes when the loader raises ``InvalidImageFormatError`` + for the unsupported multi-frame input. + """ + from colorcast.processing import image_loader + + monkeypatch.setattr( + image_loader.io, "imread", lambda path: np.zeros((2, 4, 6, 3), dtype=np.uint8) + ) + + with pytest.raises(InvalidImageFormatError, match="Multi-frame"): + image_loader._get_image_dimensions("dummy.tiff") + class TestSaveImage: """Tests for save_image dtype validation."""