diff --git a/.claude/hooks/block-secrets.py b/.claude/hooks/block-secrets.py index 6c86fe5..3bbc2e3 100644 --- a/.claude/hooks/block-secrets.py +++ b/.claude/hooks/block-secrets.py @@ -4,30 +4,41 @@ Prevents Claude from reading or editing sensitive files. Exit code 2 = block operation and tell Claude why. """ + import json import sys from pathlib import Path SENSITIVE_FILENAMES = { - '.env', '.env.local', '.env.production', '.env.staging', '.env.development', - 'secrets.json', 'secrets.yaml', 'id_rsa', 'id_ed25519', - '.npmrc', '.pypirc', 'credentials.json', 'service-account.json', - '.docker/config.json', + ".env", + ".env.local", + ".env.production", + ".env.staging", + ".env.development", + "secrets.json", + "secrets.yaml", + "id_rsa", + "id_ed25519", + ".npmrc", + ".pypirc", + "credentials.json", + "service-account.json", + ".docker/config.json", } -SENSITIVE_PATTERNS = ['aws/credentials', '.ssh/', 'private_key', 'secret_key'] +SENSITIVE_PATTERNS = ["aws/credentials", ".ssh/", "private_key", "secret_key"] try: data = json.load(sys.stdin) - tool_name = data.get('tool_name', '') - file_path = data.get('tool_input', {}).get('file_path', '') + tool_name = data.get("tool_name", "") + file_path = data.get("tool_input", {}).get("file_path", "") if not file_path: sys.exit(0) path = Path(file_path) - if tool_name == 'Write' and path.name.startswith('.env'): + if tool_name == "Write" and path.name.startswith(".env"): sys.exit(0) if path.name in SENSITIVE_FILENAMES: @@ -36,7 +47,9 @@ for pattern in SENSITIVE_PATTERNS: if pattern in str(path): - print(f"BLOCKED: Access to '{file_path}' denied. Path matches sensitive pattern '{pattern}'.", file=sys.stderr) + print( + f"BLOCKED: Access to '{file_path}' denied. Path matches sensitive pattern '{pattern}'.", file=sys.stderr + ) sys.exit(2) sys.exit(0) diff --git a/calibrate_pro/__init__.py b/calibrate_pro/__init__.py index 75f2635..765ae96 100644 --- a/calibrate_pro/__init__.py +++ b/calibrate_pro/__init__.py @@ -14,31 +14,40 @@ __version__ = "1.0.0" __author__ = "Zain Dana Harper" + # Lazy imports to avoid circular dependencies def __getattr__(name): if name == "color_math": from calibrate_pro.core import color_math + return color_math elif name == "icc_profile": from calibrate_pro.core import icc_profile + return icc_profile elif name == "lut_engine": from calibrate_pro.core import lut_engine + return lut_engine elif name == "calibration_engine": from calibrate_pro.core import calibration_engine + return calibration_engine elif name == "database": from calibrate_pro.panels import database + return database elif name == "detection": from calibrate_pro.panels import detection + return detection elif name == "neuralux": from calibrate_pro.sensorless import neuralux + return neuralux raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + __all__ = [ "color_math", "icc_profile", diff --git a/calibrate_pro/advanced/__init__.py b/calibrate_pro/advanced/__init__.py index 5e22b24..1547b47 100644 --- a/calibrate_pro/advanced/__init__.py +++ b/calibrate_pro/advanced/__init__.py @@ -188,7 +188,6 @@ "grade_to_string", "create_test_measurements", "print_uniformity_summary", - # Ambient Light Adaptation "AdaptationMode", "ProfileType", @@ -208,7 +207,6 @@ "condition_to_string", "create_default_schedule", "print_adaptation_status", - # Network/Fleet Calibration "NodeStatus", "JobStatus", @@ -225,7 +223,6 @@ "ProfileSyncManager", "create_test_nodes", "print_fleet_status", - # 3D LUT Optimization "SmoothingMethod", "GamutMappingMethod", @@ -246,7 +243,6 @@ "create_identity_lut", "create_test_lut", "print_optimization_summary", - # Automation API "TaskStatus", "TaskType", @@ -263,7 +259,6 @@ "run_cli", "create_cli_parser", "print_workflow_status", - # Convenience Aliases "Uniformity", "AmbientLight", diff --git a/calibrate_pro/advanced/ambient_light.py b/calibrate_pro/advanced/ambient_light.py index 873d82d..dc31c2c 100644 --- a/calibrate_pro/advanced/ambient_light.py +++ b/calibrate_pro/advanced/ambient_light.py @@ -21,47 +21,53 @@ # Enums # ============================================================================= + class AmbientCondition(Enum): """Ambient lighting conditions.""" - DARK = auto() # <50 lux (home theater, night) - DIM = auto() # 50-200 lux (dim room, evening) - OFFICE = auto() # 200-500 lux (typical office) - BRIGHT = auto() # 500-1000 lux (bright office, daylight) - VERY_BRIGHT = auto() # >1000 lux (direct sunlight) + + DARK = auto() # <50 lux (home theater, night) + DIM = auto() # 50-200 lux (dim room, evening) + OFFICE = auto() # 200-500 lux (typical office) + BRIGHT = auto() # 500-1000 lux (bright office, daylight) + VERY_BRIGHT = auto() # >1000 lux (direct sunlight) class AdaptationMode(Enum): """Adaptation behavior mode.""" - MANUAL = auto() # User manually switches profiles - AUTO_SENSOR = auto() # Automatic based on light sensor - SCHEDULED = auto() # Time-based scheduling - CIRCADIAN = auto() # Follows circadian rhythm - HYBRID = auto() # Sensor + schedule combined + + MANUAL = auto() # User manually switches profiles + AUTO_SENSOR = auto() # Automatic based on light sensor + SCHEDULED = auto() # Time-based scheduling + CIRCADIAN = auto() # Follows circadian rhythm + HYBRID = auto() # Sensor + schedule combined class ProfileType(Enum): """Display profile type for different conditions.""" - DAY = auto() # Daytime/bright conditions - NIGHT = auto() # Nighttime/dark conditions - HDR = auto() # HDR content - SDR = auto() # SDR content - CINEMA = auto() # Dark room movie watching - PHOTO = auto() # Photo editing (D50/D65) - VIDEO = auto() # Video editing (D65) - GAMING = auto() # Gaming optimized - READING = auto() # Reduced blue light - CUSTOM = auto() # User-defined + + DAY = auto() # Daytime/bright conditions + NIGHT = auto() # Nighttime/dark conditions + HDR = auto() # HDR content + SDR = auto() # SDR content + CINEMA = auto() # Dark room movie watching + PHOTO = auto() # Photo editing (D50/D65) + VIDEO = auto() # Video editing (D65) + GAMING = auto() # Gaming optimized + READING = auto() # Reduced blue light + CUSTOM = auto() # User-defined # ============================================================================= # Data Classes # ============================================================================= + @dataclass class AmbientReading: """Single ambient light sensor reading.""" + timestamp: datetime - lux: float # Illuminance in lux + lux: float # Illuminance in lux cct: float | None = None # Color temperature if available condition: AmbientCondition = AmbientCondition.OFFICE @@ -72,14 +78,15 @@ def __post_init__(self): @dataclass class DisplayProfile: """Display calibration profile.""" + name: str profile_type: ProfileType # Target parameters - whitepoint_cct: float = 6500 # Target CCT in Kelvin - gamma: float = 2.2 # Target gamma - luminance: float = 120 # Target peak luminance cd/m² - black_level: float = 0.5 # Target black level cd/m² + whitepoint_cct: float = 6500 # Target CCT in Kelvin + gamma: float = 2.2 # Target gamma + luminance: float = 120 # Target peak luminance cd/m² + black_level: float = 0.5 # Target black level cd/m² # ICC profile path icc_path: str | None = None @@ -89,7 +96,7 @@ class DisplayProfile: # Conditions when this profile should be active min_lux: float = 0 - max_lux: float = float('inf') + max_lux: float = float("inf") start_time: time | None = None end_time: time | None = None @@ -107,6 +114,7 @@ def is_time_based(self) -> bool: @dataclass class ScheduleEntry: """Time-based profile schedule entry.""" + start_time: time end_time: time profile_name: str @@ -129,6 +137,7 @@ def is_active(self, dt: datetime) -> bool: @dataclass class CircadianSettings: """Circadian rhythm adaptation settings.""" + enabled: bool = True # Transition times @@ -152,6 +161,7 @@ class CircadianSettings: @dataclass class AdaptationState: """Current adaptation system state.""" + mode: AdaptationMode active_profile: DisplayProfile | None = None current_lux: float = 300 @@ -180,7 +190,7 @@ class AdaptationState: AmbientCondition.DIM: (50, 200), AmbientCondition.OFFICE: (200, 500), AmbientCondition.BRIGHT: (500, 1000), - AmbientCondition.VERY_BRIGHT: (1000, float('inf')), + AmbientCondition.VERY_BRIGHT: (1000, float("inf")), } @@ -215,7 +225,7 @@ def condition_to_string(condition: AmbientCondition) -> str: gamma=2.2, luminance=120, min_lux=200, - max_lux=float('inf'), + max_lux=float("inf"), blue_light_filter=0, priority=10, ), @@ -281,6 +291,7 @@ def condition_to_string(condition: AmbientCondition) -> str: # Ambient Light Sensor Interface # ============================================================================= + class AmbientSensor: """ Abstract ambient light sensor interface. @@ -343,6 +354,7 @@ def __init__(self, base_lux: float = 300): def read(self) -> AmbientReading: """Read simulated ambient light.""" import random + # Add some variation lux = self.base_lux + random.gauss(0, self.base_lux * 0.1) lux = max(1, lux) @@ -424,6 +436,7 @@ def read(self) -> AmbientReading: # Adaptation Controller # ============================================================================= + class AdaptationController: """ Controls automatic display adaptation based on ambient conditions. @@ -432,9 +445,7 @@ class AdaptationController: between different display states. """ - def __init__(self, - mode: AdaptationMode = AdaptationMode.MANUAL, - sensor: AmbientSensor | None = None): + def __init__(self, mode: AdaptationMode = AdaptationMode.MANUAL, sensor: AmbientSensor | None = None): """ Initialize adaptation controller. @@ -460,7 +471,7 @@ def __init__(self, # Smoothing parameters self.smoothing_window = 5 # Number of readings to average - self.hysteresis_lux = 50 # Lux change required to switch + self.hysteresis_lux = 50 # Lux change required to switch # Callbacks self._profile_changed_callbacks: list[Callable[[DisplayProfile], None]] = [] @@ -524,8 +535,7 @@ def get_scheduled_profile(self, dt: datetime | None = None) -> DisplayProfile | # Circadian Adaptation # ========================================================================= - def calculate_circadian_settings(self, - dt: datetime | None = None) -> dict[str, float]: + def calculate_circadian_settings(self, dt: datetime | None = None) -> dict[str, float]: """ Calculate display settings based on circadian rhythm. @@ -643,7 +653,7 @@ def _find_best_profile(self) -> DisplayProfile | None: def _get_profile_center_lux(self, profile: DisplayProfile) -> float: """Get center lux value for a profile's range.""" - if profile.max_lux == float('inf'): + if profile.max_lux == float("inf"): return profile.min_lux + 500 return (profile.min_lux + profile.max_lux) / 2 @@ -674,13 +684,11 @@ def _on_sensor_reading(self, reading: AmbientReading) -> None: # Callbacks # ========================================================================= - def add_profile_changed_callback(self, - callback: Callable[[DisplayProfile], None]) -> None: + def add_profile_changed_callback(self, callback: Callable[[DisplayProfile], None]) -> None: """Add callback for profile changes.""" self._profile_changed_callbacks.append(callback) - def remove_profile_changed_callback(self, - callback: Callable[[DisplayProfile], None]) -> None: + def remove_profile_changed_callback(self, callback: Callable[[DisplayProfile], None]) -> None: """Remove callback.""" if callback in self._profile_changed_callbacks: self._profile_changed_callbacks.remove(callback) @@ -732,21 +740,23 @@ def save_config(self, path: str) -> None: "icc_path": profile.icc_path, "lut_path": profile.lut_path, "min_lux": profile.min_lux, - "max_lux": profile.max_lux if profile.max_lux != float('inf') else None, + "max_lux": profile.max_lux if profile.max_lux != float("inf") else None, "blue_light_filter": profile.blue_light_filter, "priority": profile.priority, } # Save schedule for entry in self.schedule: - config["schedule"].append({ - "start_time": entry.start_time.isoformat(), - "end_time": entry.end_time.isoformat(), - "profile_name": entry.profile_name, - "days": entry.days, - }) - - with open(path, 'w') as f: + config["schedule"].append( + { + "start_time": entry.start_time.isoformat(), + "end_time": entry.end_time.isoformat(), + "profile_name": entry.profile_name, + "days": entry.days, + } + ) + + with open(path, "w") as f: json.dump(config, f, indent=2) def load_config(self, path: str) -> None: @@ -786,7 +796,7 @@ def load_config(self, path: str) -> None: icc_path=pdata.get("icc_path"), lut_path=pdata.get("lut_path"), min_lux=pdata.get("min_lux", 0), - max_lux=pdata.get("max_lux") or float('inf'), + max_lux=pdata.get("max_lux") or float("inf"), blue_light_filter=pdata.get("blue_light_filter", 0), priority=pdata.get("priority", 0), ) @@ -812,6 +822,7 @@ def load_config(self, path: str) -> None: # Utility Functions # ============================================================================= + def create_default_schedule() -> list[ScheduleEntry]: """Create a default day/night schedule.""" return [ @@ -863,10 +874,7 @@ def print_adaptation_status(controller: AdaptationController) -> None: if __name__ == "__main__": # Test adaptation controller - controller = AdaptationController( - mode=AdaptationMode.AUTO_SENSOR, - sensor=SimulatedSensor(base_lux=300) - ) + controller = AdaptationController(mode=AdaptationMode.AUTO_SENSOR, sensor=SimulatedSensor(base_lux=300)) # Add schedule for entry in create_default_schedule(): @@ -884,7 +892,9 @@ def print_adaptation_status(controller: AdaptationController) -> None: if profile: print(f"Lux {lux} -> Switched to: {profile.name}") else: - print(f"Lux {lux} -> No change (current: {controller.active_profile.name if controller.active_profile else 'None'})") + print( + f"Lux {lux} -> No change (current: {controller.active_profile.name if controller.active_profile else 'None'})" + ) print_adaptation_status(controller) @@ -893,6 +903,8 @@ def print_adaptation_status(controller: AdaptationController) -> None: for hour in [0, 6, 7, 12, 19, 20, 21]: dt = datetime.now().replace(hour=hour, minute=0) settings = controller.calculate_circadian_settings(dt) - print(f" {hour:02d}:00 - CCT: {settings['cct']:.0f}K, " - f"Lum: {settings['luminance']:.0f}, " - f"Blend: {settings['blend']:.1%}") + print( + f" {hour:02d}:00 - CCT: {settings['cct']:.0f}K, " + f"Lum: {settings['luminance']:.0f}, " + f"Blend: {settings['blend']:.1%}" + ) diff --git a/calibrate_pro/advanced/automation.py b/calibrate_pro/advanced/automation.py index 35c2638..a3b7786 100644 --- a/calibrate_pro/advanced/automation.py +++ b/calibrate_pro/advanced/automation.py @@ -27,8 +27,10 @@ # Enums # ============================================================================= + class TaskStatus(Enum): """Automation task status.""" + PENDING = auto() RUNNING = auto() COMPLETED = auto() @@ -39,6 +41,7 @@ class TaskStatus(Enum): class TaskType(Enum): """Type of automation task.""" + CALIBRATE = auto() VERIFY = auto() PROFILE = auto() @@ -51,6 +54,7 @@ class TaskType(Enum): class WorkflowState(Enum): """Workflow execution state.""" + IDLE = auto() RUNNING = auto() PAUSED = auto() @@ -60,6 +64,7 @@ class WorkflowState(Enum): class EventType(Enum): """Automation event types.""" + TASK_STARTED = auto() TASK_COMPLETED = auto() TASK_FAILED = auto() @@ -75,9 +80,11 @@ class EventType(Enum): # Data Classes # ============================================================================= + @dataclass class TaskResult: """Result of a task execution.""" + success: bool data: dict[str, Any] = field(default_factory=dict) error: str | None = None @@ -88,6 +95,7 @@ class TaskResult: @dataclass class AutomationTask: """Single automation task definition.""" + task_id: str task_type: TaskType name: str @@ -115,6 +123,7 @@ class AutomationTask: @dataclass class Workflow: """Workflow definition containing multiple tasks.""" + workflow_id: str name: str description: str = "" @@ -144,6 +153,7 @@ class Workflow: @dataclass class AutomationEvent: """Automation system event.""" + event_type: EventType timestamp: datetime = field(default_factory=datetime.now) data: dict[str, Any] = field(default_factory=dict) @@ -153,6 +163,7 @@ class AutomationEvent: @dataclass class ScheduledTask: """Scheduled task for recurring execution.""" + schedule_id: str workflow_id: str name: str @@ -172,15 +183,14 @@ class ScheduledTask: # Task Handlers # ============================================================================= + class TaskHandler: """Base class for task handlers.""" def __init__(self): self.name = "base" - async def execute(self, - task: AutomationTask, - context: dict[str, Any]) -> TaskResult: + async def execute(self, task: AutomationTask, context: dict[str, Any]) -> TaskResult: """Execute the task. Override in subclasses.""" raise NotImplementedError @@ -196,9 +206,7 @@ def __init__(self): super().__init__() self.name = "calibrate" - async def execute(self, - task: AutomationTask, - context: dict[str, Any]) -> TaskResult: + async def execute(self, task: AutomationTask, context: dict[str, Any]) -> TaskResult: """Execute calibration.""" params = task.parameters display_id = params.get("display_id", 0) @@ -218,7 +226,7 @@ async def execute(self, "delta_e_mean": 1.2, "delta_e_max": 2.8, "profile_path": f"calibration_{display_id}.icc", - } + }, ) def validate(self, task: AutomationTask) -> bool: @@ -234,9 +242,7 @@ def __init__(self): super().__init__() self.name = "verify" - async def execute(self, - task: AutomationTask, - context: dict[str, Any]) -> TaskResult: + async def execute(self, task: AutomationTask, context: dict[str, Any]) -> TaskResult: """Execute verification.""" params = task.parameters display_id = params.get("display_id", 0) @@ -253,7 +259,7 @@ async def execute(self, "verification_type": verification_type, "delta_e_mean": 1.5, "passed": True, - } + }, ) @@ -264,9 +270,7 @@ def __init__(self): super().__init__() self.name = "profile" - async def execute(self, - task: AutomationTask, - context: dict[str, Any]) -> TaskResult: + async def execute(self, task: AutomationTask, context: dict[str, Any]) -> TaskResult: """Generate ICC profile.""" params = task.parameters output_path = params.get("output_path", "profile.icc") @@ -280,7 +284,7 @@ async def execute(self, data={ "profile_path": output_path, "profile_version": "4.4", - } + }, ) @@ -291,9 +295,7 @@ def __init__(self): super().__init__() self.name = "lut" - async def execute(self, - task: AutomationTask, - context: dict[str, Any]) -> TaskResult: + async def execute(self, task: AutomationTask, context: dict[str, Any]) -> TaskResult: """Generate or apply LUT.""" params = task.parameters action = params.get("action", "generate") @@ -309,7 +311,7 @@ async def execute(self, "action": action, "lut_path": lut_path, "lut_size": 33, - } + }, ) @@ -320,9 +322,7 @@ def __init__(self): super().__init__() self.name = "export" - async def execute(self, - task: AutomationTask, - context: dict[str, Any]) -> TaskResult: + async def execute(self, task: AutomationTask, context: dict[str, Any]) -> TaskResult: """Export data/reports.""" params = task.parameters export_type = params.get("type", "report") @@ -337,7 +337,7 @@ async def execute(self, data={ "export_type": export_type, "output_path": output_path, - } + }, ) @@ -348,9 +348,7 @@ def __init__(self): super().__init__() self.name = "custom" - async def execute(self, - task: AutomationTask, - context: dict[str, Any]) -> TaskResult: + async def execute(self, task: AutomationTask, context: dict[str, Any]) -> TaskResult: """Execute custom script.""" params = task.parameters script = params.get("script", "") @@ -386,6 +384,7 @@ async def execute(self, # Workflow Engine # ============================================================================= + class WorkflowEngine: """ Executes automation workflows. @@ -412,19 +411,15 @@ def __init__(self): self._context: dict[str, Any] = {} self._executor = ThreadPoolExecutor(max_workers=4) - def register_handler(self, - task_type: TaskType, - handler: TaskHandler) -> None: + def register_handler(self, task_type: TaskType, handler: TaskHandler) -> None: """Register a custom task handler.""" self.handlers[task_type] = handler - def add_event_listener(self, - listener: Callable[[AutomationEvent], None]) -> None: + def add_event_listener(self, listener: Callable[[AutomationEvent], None]) -> None: """Add event listener.""" self._event_listeners.append(listener) - def remove_event_listener(self, - listener: Callable[[AutomationEvent], None]) -> None: + def remove_event_listener(self, listener: Callable[[AutomationEvent], None]) -> None: """Remove event listener.""" if listener in self._event_listeners: self._event_listeners.remove(listener) @@ -437,9 +432,7 @@ def _emit_event(self, event: AutomationEvent) -> None: except Exception as e: logger.error(f"Event listener error: {e}") - async def execute_workflow(self, - workflow: Workflow, - context: dict[str, Any] | None = None) -> Workflow: + async def execute_workflow(self, workflow: Workflow, context: dict[str, Any] | None = None) -> Workflow: """ Execute a complete workflow. @@ -454,10 +447,12 @@ async def execute_workflow(self, workflow.state = WorkflowState.RUNNING workflow.started_at = datetime.now() - self._emit_event(AutomationEvent( - event_type=EventType.WORKFLOW_STARTED, - data={"workflow_id": workflow.workflow_id, "name": workflow.name}, - )) + self._emit_event( + AutomationEvent( + event_type=EventType.WORKFLOW_STARTED, + data={"workflow_id": workflow.workflow_id, "name": workflow.name}, + ) + ) try: # Build dependency graph @@ -475,10 +470,7 @@ async def execute_workflow(self, continue # Check dependencies - deps_met = all( - dep_id in completed_tasks - for dep_id in task.depends_on - ) + deps_met = all(dep_id in completed_tasks for dep_id in task.depends_on) if deps_met: ready_tasks.append(task) @@ -505,16 +497,20 @@ async def execute_workflow(self, # Determine final state if workflow.errors: workflow.state = WorkflowState.FAILED - self._emit_event(AutomationEvent( - event_type=EventType.WORKFLOW_FAILED, - data={"workflow_id": workflow.workflow_id, "errors": workflow.errors}, - )) + self._emit_event( + AutomationEvent( + event_type=EventType.WORKFLOW_FAILED, + data={"workflow_id": workflow.workflow_id, "errors": workflow.errors}, + ) + ) else: workflow.state = WorkflowState.COMPLETED - self._emit_event(AutomationEvent( - event_type=EventType.WORKFLOW_COMPLETED, - data={"workflow_id": workflow.workflow_id}, - )) + self._emit_event( + AutomationEvent( + event_type=EventType.WORKFLOW_COMPLETED, + data={"workflow_id": workflow.workflow_id}, + ) + ) except Exception as e: workflow.state = WorkflowState.FAILED @@ -529,10 +525,12 @@ async def _execute_task(self, task: AutomationTask) -> TaskResult: task.status = TaskStatus.RUNNING task.started_at = datetime.now() - self._emit_event(AutomationEvent( - event_type=EventType.TASK_STARTED, - data={"task_id": task.task_id, "name": task.name}, - )) + self._emit_event( + AutomationEvent( + event_type=EventType.TASK_STARTED, + data={"task_id": task.task_id, "name": task.name}, + ) + ) handler = self.handlers.get(task.task_type) if not handler: @@ -559,10 +557,7 @@ async def _execute_task(self, task: AutomationTask) -> TaskResult: for attempt in range(task.max_retries + 1): try: - result = await asyncio.wait_for( - handler.execute(task, self._context), - timeout=task.timeout - ) + result = await asyncio.wait_for(handler.execute(task, self._context), timeout=task.timeout) break except asyncio.TimeoutError: result = TaskResult( @@ -587,25 +582,27 @@ async def _execute_task(self, task: AutomationTask) -> TaskResult: if result.success: task.status = TaskStatus.COMPLETED - self._emit_event(AutomationEvent( - event_type=EventType.TASK_COMPLETED, - data={"task_id": task.task_id, "result": result.data}, - )) + self._emit_event( + AutomationEvent( + event_type=EventType.TASK_COMPLETED, + data={"task_id": task.task_id, "result": result.data}, + ) + ) else: task.status = TaskStatus.FAILED - self._emit_event(AutomationEvent( - event_type=EventType.TASK_FAILED, - data={"task_id": task.task_id, "error": result.error}, - )) + self._emit_event( + AutomationEvent( + event_type=EventType.TASK_FAILED, + data={"task_id": task.task_id, "error": result.error}, + ) + ) # Store result in context for downstream tasks self._context[f"task_{task.task_id}"] = result.data return result - async def execute_task(self, - task: AutomationTask, - context: dict[str, Any] | None = None) -> TaskResult: + async def execute_task(self, task: AutomationTask, context: dict[str, Any] | None = None) -> TaskResult: """Execute a single task standalone.""" self._context = context or {} return await self._execute_task(task) @@ -615,6 +612,7 @@ async def execute_task(self, # Automation API # ============================================================================= + class AutomationAPI: """ High-level automation API for scripting and CI/CD integration. @@ -633,11 +631,9 @@ def __init__(self): # Task Creation # ========================================================================= - def create_task(self, - task_type: TaskType, - name: str, - parameters: dict[str, Any] | None = None, - **kwargs) -> AutomationTask: + def create_task( + self, task_type: TaskType, name: str, parameters: dict[str, Any] | None = None, **kwargs + ) -> AutomationTask: """Create a new automation task.""" self._task_counter += 1 task_id = f"task_{self._task_counter:04d}" @@ -650,11 +646,7 @@ def create_task(self, **kwargs, ) - def calibrate(self, - display_id: int = 0, - whitepoint: str = "D65", - gamma: float = 2.2, - **kwargs) -> AutomationTask: + def calibrate(self, display_id: int = 0, whitepoint: str = "D65", gamma: float = 2.2, **kwargs) -> AutomationTask: """Create a calibration task.""" return self.create_task( TaskType.CALIBRATE, @@ -664,13 +656,10 @@ def calibrate(self, "whitepoint": whitepoint, "gamma": gamma, **kwargs, - } + }, ) - def verify(self, - display_id: int = 0, - verification_type: str = "colorchecker", - **kwargs) -> AutomationTask: + def verify(self, display_id: int = 0, verification_type: str = "colorchecker", **kwargs) -> AutomationTask: """Create a verification task.""" return self.create_task( TaskType.VERIFY, @@ -679,12 +668,10 @@ def verify(self, "display_id": display_id, "type": verification_type, **kwargs, - } + }, ) - def generate_profile(self, - output_path: str, - **kwargs) -> AutomationTask: + def generate_profile(self, output_path: str, **kwargs) -> AutomationTask: """Create a profile generation task.""" return self.create_task( TaskType.PROFILE, @@ -692,13 +679,10 @@ def generate_profile(self, parameters={ "output_path": output_path, **kwargs, - } + }, ) - def generate_lut(self, - lut_path: str, - lut_size: int = 33, - **kwargs) -> AutomationTask: + def generate_lut(self, lut_path: str, lut_size: int = 33, **kwargs) -> AutomationTask: """Create a LUT generation task.""" return self.create_task( TaskType.LUT_GENERATE, @@ -708,13 +692,10 @@ def generate_lut(self, "lut_path": lut_path, "lut_size": lut_size, **kwargs, - } + }, ) - def apply_lut(self, - lut_path: str, - display_id: int = 0, - **kwargs) -> AutomationTask: + def apply_lut(self, lut_path: str, display_id: int = 0, **kwargs) -> AutomationTask: """Create a LUT application task.""" return self.create_task( TaskType.LUT_APPLY, @@ -724,13 +705,10 @@ def apply_lut(self, "lut_path": lut_path, "display_id": display_id, **kwargs, - } + }, ) - def export_report(self, - output_path: str, - report_type: str = "pdf", - **kwargs) -> AutomationTask: + def export_report(self, output_path: str, report_type: str = "pdf", **kwargs) -> AutomationTask: """Create an export task.""" return self.create_task( TaskType.EXPORT, @@ -740,13 +718,10 @@ def export_report(self, "output_path": output_path, "format": report_type, **kwargs, - } + }, ) - def run_script(self, - script: str = "", - script_path: str = "", - **kwargs) -> AutomationTask: + def run_script(self, script: str = "", script_path: str = "", **kwargs) -> AutomationTask: """Create a custom script task.""" return self.create_task( TaskType.CUSTOM, @@ -755,17 +730,14 @@ def run_script(self, "script": script, "script_path": script_path, **kwargs, - } + }, ) # ========================================================================= # Workflow Creation # ========================================================================= - def create_workflow(self, - name: str, - tasks: list[AutomationTask] | None = None, - description: str = "") -> Workflow: + def create_workflow(self, name: str, tasks: list[AutomationTask] | None = None, description: str = "") -> Workflow: """Create a new workflow.""" self._workflow_counter += 1 workflow_id = f"workflow_{self._workflow_counter:04d}" @@ -780,9 +752,7 @@ def create_workflow(self, self._workflows[workflow_id] = workflow return workflow - def create_calibration_workflow(self, - display_id: int = 0, - name: str = "Full Calibration") -> Workflow: + def create_calibration_workflow(self, display_id: int = 0, name: str = "Full Calibration") -> Workflow: """Create a complete calibration workflow.""" tasks = [ self.calibrate(display_id=display_id), @@ -795,13 +765,11 @@ def create_calibration_workflow(self, # Set dependencies for i in range(1, len(tasks)): - tasks[i].depends_on = [tasks[i-1].task_id] + tasks[i].depends_on = [tasks[i - 1].task_id] return self.create_workflow(name, tasks) - def create_verification_workflow(self, - display_id: int = 0, - name: str = "Verification") -> Workflow: + def create_verification_workflow(self, display_id: int = 0, name: str = "Verification") -> Workflow: """Create a verification-only workflow.""" tasks = [ self.verify(display_id=display_id, verification_type="colorchecker"), @@ -837,11 +805,9 @@ async def run_task_async(self, task: AutomationTask) -> TaskResult: # Scheduling # ========================================================================= - def schedule(self, - workflow: Workflow, - interval_hours: float | None = None, - cron: str | None = None, - name: str = "") -> ScheduledTask: + def schedule( + self, workflow: Workflow, interval_hours: float | None = None, cron: str | None = None, name: str = "" + ) -> ScheduledTask: """Schedule a workflow for recurring execution.""" schedule_id = f"schedule_{len(self._scheduled_tasks):04d}" @@ -870,8 +836,7 @@ def unschedule(self, schedule_id: str) -> bool: # Event Handling # ========================================================================= - def on_event(self, - callback: Callable[[AutomationEvent], None]) -> None: + def on_event(self, callback: Callable[[AutomationEvent], None]) -> None: """Register an event callback.""" self.engine.add_event_listener(callback) @@ -901,7 +866,7 @@ def save_workflow(self, workflow: Workflow, path: str) -> None: "tags": workflow.tags, } - with open(path, 'w') as f: + with open(path, "w") as f: json.dump(data, f, indent=2) def load_workflow(self, path: str) -> Workflow: @@ -939,6 +904,7 @@ def load_workflow(self, path: str) -> Workflow: # CLI Interface # ============================================================================= + def create_cli_parser(): """Create command-line argument parser.""" import argparse @@ -1031,6 +997,7 @@ def log_event(event: AutomationEvent): # Utility Functions # ============================================================================= + def print_workflow_status(workflow: Workflow) -> None: """Print workflow execution status.""" print("\n" + "=" * 60) diff --git a/calibrate_pro/advanced/demo.py b/calibrate_pro/advanced/demo.py index 7fde81d..bcd1904 100644 --- a/calibrate_pro/advanced/demo.py +++ b/calibrate_pro/advanced/demo.py @@ -62,7 +62,9 @@ def demo_uniformity(): correction = compensator.generate_correction_lut(result) print(f" Correction Mode: {correction.mode.name}") - print(f" Luminance Correction Range: {correction.luminance_corrections.min():.3f}x - {correction.luminance_corrections.max():.3f}x") + print( + f" Luminance Correction Range: {correction.luminance_corrections.min():.3f}x - {correction.luminance_corrections.max():.3f}x" + ) print("\n[OK] Uniformity compensation demo complete!") @@ -87,7 +89,7 @@ def demo_ambient_light(): # Show lux thresholds print("\n[1] Ambient light classification thresholds:") for condition, (low, high) in LUX_THRESHOLDS.items(): - high_str = f"{high:.0f}" if high != float('inf') else "inf" + high_str = f"{high:.0f}" if high != float("inf") else "inf" print(f" {condition.name}: {low:.0f} - {high_str} lux") # Create simulated sensor @@ -145,7 +147,7 @@ def demo_network_calibration(): # Create calibration server print("\n[2] Starting calibration server...") - server = CalibrationServer(host='127.0.0.1', port=9999, server_id='demo-server') + server = CalibrationServer(host="127.0.0.1", port=9999, server_id="demo-server") print(f" Server ID: {server.server_id}") print(f" Endpoint: {server.host}:{server.port}") @@ -160,12 +162,10 @@ def demo_network_calibration(): job1 = server.create_job( job_type=JobType.FULL_CALIBRATION, target_nodes=[nodes[0].node_id], - parameters={'whitepoint': 'D65', 'gamma': 2.2} + parameters={"whitepoint": "D65", "gamma": 2.2}, ) job2 = server.create_job( - job_type=JobType.VERIFICATION_ONLY, - target_nodes=[n.node_id for n in nodes[1:3]], - parameters={'patches': 24} + job_type=JobType.VERIFICATION_ONLY, target_nodes=[n.node_id for n in nodes[1:3]], parameters={"patches": 24} ) print(f" Job 1: {job1.job_type.name} for {len(job1.target_nodes)} node(s)") print(f" Job 2: {job2.job_type.name} for {len(job2.target_nodes)} node(s)") @@ -245,7 +245,9 @@ def demo_lut_optimization(): for goal in [OptimizationGoal.MIN_DELTA_E, OptimizationGoal.SMOOTH, OptimizationGoal.BALANCED]: opt = LUTOptimizer(goal=goal) res = opt.optimize(test_lut, reference=identity_lut) - print(f" {goal.name}: Delta E = {res.optimized_metrics.delta_e_mean:.4f}, Improvement = {res.improvement_percent:.1f}%") + print( + f" {goal.name}: Delta E = {res.optimized_metrics.delta_e_mean:.4f}, Improvement = {res.improvement_percent:.1f}%" + ) print("\n[OK] LUT optimization demo complete!") @@ -267,19 +269,16 @@ def demo_automation(): # Create custom workflow print("\n[2] Creating custom workflow...") - workflow = api.create_workflow( - name="Custom Demo Workflow", - description="Demonstrates workflow creation" - ) + workflow = api.create_workflow(name="Custom Demo Workflow", description="Demonstrates workflow creation") print(f" Workflow: {workflow.name}") print(f" ID: {workflow.workflow_id[:8]}...") # Add tasks print("\n[3] Adding tasks to workflow...") - task1 = api.create_task("Measure Display", TaskType.CALIBRATE, {'display_id': 0}) - task2 = api.create_task("Generate Profile", TaskType.PROFILE, {'format': 'icc'}) - task3 = api.create_task("Generate LUT", TaskType.LUT_GENERATE, {'size': 33}) - task4 = api.create_task("Verify Results", TaskType.VERIFY, {'patches': 24}) + task1 = api.create_task("Measure Display", TaskType.CALIBRATE, {"display_id": 0}) + task2 = api.create_task("Generate Profile", TaskType.PROFILE, {"format": "icc"}) + task3 = api.create_task("Generate LUT", TaskType.LUT_GENERATE, {"size": 33}) + task4 = api.create_task("Verify Results", TaskType.VERIFY, {"patches": 24}) workflow.tasks.append(task1) workflow.tasks.append(task2) @@ -349,6 +348,7 @@ def main(): except Exception as e: print(f"\n[ERROR] Demo failed: {e}") import traceback + traceback.print_exc() return 1 diff --git a/calibrate_pro/advanced/lut_optimization.py b/calibrate_pro/advanced/lut_optimization.py index 46eb9ef..4126183 100644 --- a/calibrate_pro/advanced/lut_optimization.py +++ b/calibrate_pro/advanced/lut_optimization.py @@ -19,6 +19,7 @@ from scipy.interpolate import RegularGridInterpolator from scipy.ndimage import gaussian_filter, uniform_filter from scipy.optimize import minimize + SCIPY_AVAILABLE = True except ImportError: SCIPY_AVAILABLE = False @@ -31,38 +32,44 @@ # Enums # ============================================================================= + class SmoothingMethod(Enum): """LUT smoothing methods.""" - GAUSSIAN = auto() # Gaussian blur - BILATERAL = auto() # Edge-preserving bilateral - ANISOTROPIC = auto() # Anisotropic diffusion - PERCEPTUAL = auto() # Perceptual smoothing in Lab + + GAUSSIAN = auto() # Gaussian blur + BILATERAL = auto() # Edge-preserving bilateral + ANISOTROPIC = auto() # Anisotropic diffusion + PERCEPTUAL = auto() # Perceptual smoothing in Lab class GamutMappingMethod(Enum): """Gamut mapping methods for out-of-gamut colors.""" - CLIP = auto() # Simple RGB clipping - COMPRESS = auto() # Chroma compression - PERCEPTUAL = auto() # Perceptual intent mapping - ABSOLUTE = auto() # Absolute colorimetric - SATURATION = auto() # Saturation preserving + + CLIP = auto() # Simple RGB clipping + COMPRESS = auto() # Chroma compression + PERCEPTUAL = auto() # Perceptual intent mapping + ABSOLUTE = auto() # Absolute colorimetric + SATURATION = auto() # Saturation preserving class OptimizationGoal(Enum): """LUT optimization goal.""" - MIN_DELTA_E = auto() # Minimize average Delta E + + MIN_DELTA_E = auto() # Minimize average Delta E MIN_MAX_ERROR = auto() # Minimize maximum error - SMOOTH = auto() # Prioritize smoothness - BALANCED = auto() # Balance accuracy and smoothness + SMOOTH = auto() # Prioritize smoothness + BALANCED = auto() # Balance accuracy and smoothness # ============================================================================= # Data Classes # ============================================================================= + @dataclass class LUTQualityMetrics: """Quality metrics for a 3D LUT.""" + # Size lut_size: int @@ -93,6 +100,7 @@ class LUTQualityMetrics: @dataclass class OptimizationResult: """Result of LUT optimization.""" + optimized_lut: np.ndarray original_metrics: LUTQualityMetrics optimized_metrics: LUTQualityMetrics @@ -107,20 +115,22 @@ class OptimizationResult: @dataclass class SmoothingConfig: """Configuration for LUT smoothing.""" + method: SmoothingMethod = SmoothingMethod.PERCEPTUAL - sigma: float = 0.5 # Smoothing strength - preserve_edges: bool = True # Edge preservation - edge_threshold: float = 0.1 # Edge detection threshold + sigma: float = 0.5 # Smoothing strength + preserve_edges: bool = True # Edge preservation + edge_threshold: float = 0.1 # Edge detection threshold iterations: int = 1 @dataclass class GamutConfig: """Configuration for gamut mapping.""" + method: GamutMappingMethod = GamutMappingMethod.PERCEPTUAL - source_gamut: str = "wide" # Source gamut (wide, p3, bt2020) - target_gamut: str = "srgb" # Target gamut - compression_factor: float = 0.8 # Chroma compression factor + source_gamut: str = "wide" # Source gamut (wide, p3, bt2020) + target_gamut: str = "srgb" # Target gamut + compression_factor: float = 0.8 # Chroma compression factor black_point_compensation: bool = True @@ -128,29 +138,27 @@ class GamutConfig: # Color Conversion Functions # ============================================================================= + def rgb_to_xyz(rgb: np.ndarray, gamma: float = 2.2) -> np.ndarray: """Convert RGB to XYZ (sRGB primaries).""" # Linearize rgb_linear = np.power(np.clip(rgb, 0, 1), gamma) # sRGB to XYZ matrix - matrix = np.array([ - [0.4124564, 0.3575761, 0.1804375], - [0.2126729, 0.7151522, 0.0721750], - [0.0193339, 0.1191920, 0.9503041] - ]) + matrix = np.array( + [[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]] + ) return np.dot(rgb_linear, matrix.T) -def xyz_to_lab(xyz: np.ndarray, - white: tuple[float, float, float] = (0.95047, 1.0, 1.08883)) -> np.ndarray: +def xyz_to_lab(xyz: np.ndarray, white: tuple[float, float, float] = (0.95047, 1.0, 1.08883)) -> np.ndarray: """Convert XYZ to Lab.""" xyz_normalized = xyz / np.array(white) def f(t): delta = 6 / 29 - return np.where(t > delta**3, np.cbrt(t), t / (3 * delta**2) + 4/29) + return np.where(t > delta**3, np.cbrt(t), t / (3 * delta**2) + 4 / 29) fx = f(xyz_normalized[..., 0]) fy = f(xyz_normalized[..., 1]) @@ -163,8 +171,7 @@ def f(t): return np.stack([L, a, b], axis=-1) -def lab_to_xyz(lab: np.ndarray, - white: tuple[float, float, float] = (0.95047, 1.0, 1.08883)) -> np.ndarray: +def lab_to_xyz(lab: np.ndarray, white: tuple[float, float, float] = (0.95047, 1.0, 1.08883)) -> np.ndarray: """Convert Lab to XYZ.""" L, a, b = lab[..., 0], lab[..., 1], lab[..., 2] @@ -174,7 +181,7 @@ def lab_to_xyz(lab: np.ndarray, def f_inv(t): delta = 6 / 29 - return np.where(t > delta, t**3, 3 * delta**2 * (t - 4/29)) + return np.where(t > delta, t**3, 3 * delta**2 * (t - 4 / 29)) x = f_inv(fx) y = f_inv(fy) @@ -187,17 +194,15 @@ def f_inv(t): def xyz_to_rgb(xyz: np.ndarray, gamma: float = 2.2) -> np.ndarray: """Convert XYZ to RGB (sRGB primaries).""" # XYZ to sRGB matrix - matrix = np.array([ - [3.2404542, -1.5371385, -0.4985314], - [-0.9692660, 1.8760108, 0.0415560], - [0.0556434, -0.2040259, 1.0572252] - ]) + matrix = np.array( + [[3.2404542, -1.5371385, -0.4985314], [-0.9692660, 1.8760108, 0.0415560], [0.0556434, -0.2040259, 1.0572252]] + ) rgb_linear = np.dot(xyz, matrix.T) rgb_linear = np.clip(rgb_linear, 0, 1) # Apply gamma - return np.power(rgb_linear, 1/gamma) + return np.power(rgb_linear, 1 / gamma) def rgb_to_lab(rgb: np.ndarray) -> np.ndarray: @@ -236,9 +241,7 @@ def delta_e_2000(lab1: np.ndarray, lab2: np.ndarray) -> np.ndarray: delta_C_prime = C2_prime - C1_prime delta_h_prime = h2_prime - h1_prime - delta_h_prime = np.where(np.abs(delta_h_prime) > 180, - delta_h_prime - np.sign(delta_h_prime) * 360, - delta_h_prime) + delta_h_prime = np.where(np.abs(delta_h_prime) > 180, delta_h_prime - np.sign(delta_h_prime) * 360, delta_h_prime) delta_H_prime = 2 * np.sqrt(C1_prime * C2_prime) * np.sin(np.radians(delta_h_prime / 2)) @@ -246,31 +249,30 @@ def delta_e_2000(lab1: np.ndarray, lab2: np.ndarray) -> np.ndarray: C_prime_avg = (C1_prime + C2_prime) / 2 h_prime_sum = h1_prime + h2_prime - h_prime_avg = np.where( - np.abs(h1_prime - h2_prime) > 180, - (h_prime_sum + 360) / 2, - h_prime_sum / 2 + h_prime_avg = np.where(np.abs(h1_prime - h2_prime) > 180, (h_prime_sum + 360) / 2, h_prime_sum / 2) + + T = ( + 1 + - 0.17 * np.cos(np.radians(h_prime_avg - 30)) + + 0.24 * np.cos(np.radians(2 * h_prime_avg)) + + 0.32 * np.cos(np.radians(3 * h_prime_avg + 6)) + - 0.20 * np.cos(np.radians(4 * h_prime_avg - 63)) ) - T = (1 - 0.17 * np.cos(np.radians(h_prime_avg - 30)) + - 0.24 * np.cos(np.radians(2 * h_prime_avg)) + - 0.32 * np.cos(np.radians(3 * h_prime_avg + 6)) - - 0.20 * np.cos(np.radians(4 * h_prime_avg - 63))) - - delta_theta = 30 * np.exp(-((h_prime_avg - 275) / 25)**2) + delta_theta = 30 * np.exp(-(((h_prime_avg - 275) / 25) ** 2)) R_C = 2 * np.sqrt(C_prime_avg**7 / (C_prime_avg**7 + 25**7)) - S_L = 1 + (0.015 * (L_prime_avg - 50)**2) / np.sqrt(20 + (L_prime_avg - 50)**2) + S_L = 1 + (0.015 * (L_prime_avg - 50) ** 2) / np.sqrt(20 + (L_prime_avg - 50) ** 2) S_C = 1 + 0.045 * C_prime_avg S_H = 1 + 0.015 * C_prime_avg * T R_T = -np.sin(np.radians(2 * delta_theta)) * R_C delta_E = np.sqrt( - (delta_L_prime / S_L)**2 + - (delta_C_prime / S_C)**2 + - (delta_H_prime / S_H)**2 + - R_T * (delta_C_prime / S_C) * (delta_H_prime / S_H) + (delta_L_prime / S_L) ** 2 + + (delta_C_prime / S_C) ** 2 + + (delta_H_prime / S_H) ** 2 + + R_T * (delta_C_prime / S_C) * (delta_H_prime / S_H) ) return delta_E @@ -280,8 +282,8 @@ def delta_e_2000(lab1: np.ndarray, lab2: np.ndarray) -> np.ndarray: # LUT Analysis Functions # ============================================================================= -def analyze_lut_quality(lut: np.ndarray, - reference: np.ndarray | None = None) -> LUTQualityMetrics: + +def analyze_lut_quality(lut: np.ndarray, reference: np.ndarray | None = None) -> LUTQualityMetrics: """ Analyze quality metrics of a 3D LUT. @@ -339,8 +341,7 @@ def analyze_lut_quality(lut: np.ndarray, return metrics -def analyze_interpolation_quality(lut: np.ndarray, - test_points: int = 1000) -> tuple[float, float]: +def analyze_interpolation_quality(lut: np.ndarray, test_points: int = 1000) -> tuple[float, float]: """ Analyze interpolation quality of a LUT. @@ -354,10 +355,7 @@ def analyze_interpolation_quality(lut: np.ndarray, # Create interpolator x = np.linspace(0, 1, size) - interpolator = RegularGridInterpolator( - (x, x, x), lut, - method='linear', bounds_error=False, fill_value=None - ) + interpolator = RegularGridInterpolator((x, x, x), lut, method="linear", bounds_error=False, fill_value=None) # Generate random test points np.random.seed(42) @@ -383,6 +381,7 @@ def analyze_interpolation_quality(lut: np.ndarray, # LUT Smoothing Functions # ============================================================================= + def smooth_lut_gaussian(lut: np.ndarray, sigma: float = 0.5) -> np.ndarray: """Apply Gaussian smoothing to LUT.""" smoothed = np.zeros_like(lut) @@ -391,9 +390,7 @@ def smooth_lut_gaussian(lut: np.ndarray, sigma: float = 0.5) -> np.ndarray: return np.clip(smoothed, 0, 1) -def smooth_lut_perceptual(lut: np.ndarray, - sigma: float = 0.5, - preserve_edges: bool = True) -> np.ndarray: +def smooth_lut_perceptual(lut: np.ndarray, sigma: float = 0.5, preserve_edges: bool = True) -> np.ndarray: """ Apply perceptual smoothing in Lab space. @@ -410,13 +407,9 @@ def smooth_lut_perceptual(lut: np.ndarray, for channel in range(3): if preserve_edges and channel > 0: # Preserve edges in a,b channels # Use smaller sigma for chromatic channels - smoothed_lab[..., channel] = gaussian_filter( - lut_lab[..., channel], sigma=sigma * 0.5 - ) + smoothed_lab[..., channel] = gaussian_filter(lut_lab[..., channel], sigma=sigma * 0.5) else: - smoothed_lab[..., channel] = gaussian_filter( - lut_lab[..., channel], sigma=sigma - ) + smoothed_lab[..., channel] = gaussian_filter(lut_lab[..., channel], sigma=sigma) # Convert back to RGB smoothed_rgb = lab_to_rgb(smoothed_lab.reshape(-1, 3)).reshape(size, size, size, 3) @@ -424,9 +417,7 @@ def smooth_lut_perceptual(lut: np.ndarray, return np.clip(smoothed_rgb, 0, 1) -def smooth_lut_bilateral(lut: np.ndarray, - sigma_spatial: float = 1.0, - sigma_range: float = 0.1) -> np.ndarray: +def smooth_lut_bilateral(lut: np.ndarray, sigma_spatial: float = 1.0, sigma_range: float = 0.1) -> np.ndarray: """ Apply bilateral smoothing to LUT (edge-preserving). @@ -452,16 +443,13 @@ def smooth_lut_bilateral(lut: np.ndarray, # Spatial weights ii, jj, kk = np.meshgrid( - np.arange(i_min, i_max) - i, - np.arange(j_min, j_max) - j, - np.arange(k_min, k_max) - k, - indexing='ij' + np.arange(i_min, i_max) - i, np.arange(j_min, j_max) - j, np.arange(k_min, k_max) - k, indexing="ij" ) spatial_weight = np.exp(-(ii**2 + jj**2 + kk**2) / (2 * sigma_spatial**2)) # Range weights - diff = np.sqrt(np.sum((neighborhood - center)**2, axis=-1)) - range_weight = np.exp(-diff**2 / (2 * sigma_range**2)) + diff = np.sqrt(np.sum((neighborhood - center) ** 2, axis=-1)) + range_weight = np.exp(-(diff**2) / (2 * sigma_range**2)) # Combined weights weight = spatial_weight * range_weight @@ -478,13 +466,13 @@ def smooth_lut_bilateral(lut: np.ndarray, # Gamut Mapping Functions # ============================================================================= + def map_gamut_clip(lut: np.ndarray) -> np.ndarray: """Simple RGB clipping for out-of-gamut colors.""" return np.clip(lut, 0, 1) -def map_gamut_compress(lut: np.ndarray, - compression_factor: float = 0.8) -> np.ndarray: +def map_gamut_compress(lut: np.ndarray, compression_factor: float = 0.8) -> np.ndarray: """ Compress chroma for out-of-gamut colors. @@ -517,8 +505,7 @@ def map_gamut_compress(lut: np.ndarray, return np.clip(result, 0, 1) -def map_gamut_perceptual(lut: np.ndarray, - target_gamut: str = "srgb") -> np.ndarray: +def map_gamut_perceptual(lut: np.ndarray, target_gamut: str = "srgb") -> np.ndarray: """ Perceptual gamut mapping. @@ -572,6 +559,7 @@ def map_gamut_perceptual(lut: np.ndarray, # LUT Optimizer Class # ============================================================================= + class LUTOptimizer: """ Advanced 3D LUT optimization engine. @@ -580,10 +568,12 @@ class LUTOptimizer: LUT accuracy and smoothness. """ - def __init__(self, - goal: OptimizationGoal = OptimizationGoal.BALANCED, - smoothing_config: SmoothingConfig | None = None, - gamut_config: GamutConfig | None = None): + def __init__( + self, + goal: OptimizationGoal = OptimizationGoal.BALANCED, + smoothing_config: SmoothingConfig | None = None, + gamut_config: GamutConfig | None = None, + ): """ Initialize optimizer. @@ -596,10 +586,9 @@ def __init__(self, self.smoothing = smoothing_config or SmoothingConfig() self.gamut = gamut_config or GamutConfig() - def optimize(self, - lut: np.ndarray, - reference: np.ndarray | None = None, - target_delta_e: float = 1.0) -> OptimizationResult: + def optimize( + self, lut: np.ndarray, reference: np.ndarray | None = None, target_delta_e: float = 1.0 + ) -> OptimizationResult: """ Optimize a 3D LUT. @@ -612,6 +601,7 @@ def optimize(self, OptimizationResult with optimized LUT and metrics """ import time + start_time = time.time() # Analyze original @@ -657,17 +647,12 @@ def _optimize_smooth(self, lut: np.ndarray) -> np.ndarray: if self.smoothing.method == SmoothingMethod.GAUSSIAN: return smooth_lut_gaussian(lut, self.smoothing.sigma) elif self.smoothing.method == SmoothingMethod.PERCEPTUAL: - return smooth_lut_perceptual( - lut, self.smoothing.sigma, self.smoothing.preserve_edges - ) + return smooth_lut_perceptual(lut, self.smoothing.sigma, self.smoothing.preserve_edges) elif self.smoothing.method == SmoothingMethod.BILATERAL: return smooth_lut_bilateral(lut, self.smoothing.sigma) return lut - def _optimize_accuracy(self, - lut: np.ndarray, - reference: np.ndarray | None, - target_delta_e: float) -> np.ndarray: + def _optimize_accuracy(self, lut: np.ndarray, reference: np.ndarray | None, target_delta_e: float) -> np.ndarray: """Optimize for minimum Delta E.""" if reference is None: return lut @@ -697,9 +682,7 @@ def _optimize_accuracy(self, return result - def _optimize_max_error(self, - lut: np.ndarray, - reference: np.ndarray | None) -> np.ndarray: + def _optimize_max_error(self, lut: np.ndarray, reference: np.ndarray | None) -> np.ndarray: """Optimize to minimize maximum error.""" if reference is None: return lut @@ -730,10 +713,7 @@ def _optimize_max_error(self, return result - def _optimize_balanced(self, - lut: np.ndarray, - reference: np.ndarray | None, - target_delta_e: float) -> np.ndarray: + def _optimize_balanced(self, lut: np.ndarray, reference: np.ndarray | None, target_delta_e: float) -> np.ndarray: """Balance accuracy and smoothness.""" # First optimize accuracy result = self._optimize_accuracy(lut, reference, target_delta_e) @@ -761,6 +741,7 @@ def _apply_gamut_mapping(self, lut: np.ndarray) -> np.ndarray: # Utility Functions # ============================================================================= + def create_identity_lut(size: int = 17) -> np.ndarray: """Create an identity 3D LUT.""" lut = np.zeros((size, size, size, 3), dtype=np.float64) @@ -768,18 +749,12 @@ def create_identity_lut(size: int = 17) -> np.ndarray: for r in range(size): for g in range(size): for b in range(size): - lut[r, g, b] = [ - r / (size - 1), - g / (size - 1), - b / (size - 1) - ] + lut[r, g, b] = [r / (size - 1), g / (size - 1), b / (size - 1)] return lut -def create_test_lut(size: int = 17, - contrast: float = 1.1, - saturation: float = 1.05) -> np.ndarray: +def create_test_lut(size: int = 17, contrast: float = 1.1, saturation: float = 1.05) -> np.ndarray: """Create a test LUT with contrast/saturation adjustments.""" identity = create_identity_lut(size) diff --git a/calibrate_pro/advanced/network_calibration.py b/calibrate_pro/advanced/network_calibration.py index 4ff88cd..15fc2a6 100644 --- a/calibrate_pro/advanced/network_calibration.py +++ b/calibrate_pro/advanced/network_calibration.py @@ -26,8 +26,10 @@ # Enums # ============================================================================= + class NodeStatus(Enum): """Calibration node status.""" + OFFLINE = auto() ONLINE = auto() BUSY = auto() @@ -37,6 +39,7 @@ class NodeStatus(Enum): class JobStatus(Enum): """Calibration job status.""" + PENDING = auto() QUEUED = auto() RUNNING = auto() @@ -47,6 +50,7 @@ class JobStatus(Enum): class JobType(Enum): """Type of calibration job.""" + FULL_CALIBRATION = auto() VERIFICATION_ONLY = auto() PROFILE_APPLY = auto() @@ -56,8 +60,9 @@ class JobType(Enum): class ProfileSyncMode(Enum): """Profile synchronization mode.""" - PUSH = auto() # Server pushes to clients - PULL = auto() # Clients pull from server + + PUSH = auto() # Server pushes to clients + PULL = auto() # Clients pull from server BIDIRECTIONAL = auto() @@ -65,9 +70,11 @@ class ProfileSyncMode(Enum): # Data Classes # ============================================================================= + @dataclass class DisplayNode: """Remote display node information.""" + node_id: str hostname: str ip_address: str @@ -104,9 +111,10 @@ def is_available(self) -> bool: @dataclass class CalibrationJob: """Calibration job definition.""" + job_id: str job_type: JobType - target_nodes: list[str] # List of node IDs + target_nodes: list[str] # List of node IDs # Job parameters parameters: dict = field(default_factory=dict) @@ -133,6 +141,7 @@ class CalibrationJob: @dataclass class ProfilePackage: """Distributable calibration profile package.""" + package_id: str name: str version: str @@ -165,6 +174,7 @@ def calculate_checksum(self) -> str: @dataclass class SyncState: """Profile synchronization state.""" + last_sync: datetime | None = None pending_updates: int = 0 sync_errors: list[str] = field(default_factory=list) @@ -175,8 +185,10 @@ class SyncState: # Network Protocol Messages # ============================================================================= + class MessageType(Enum): """Network message types.""" + PING = 0 PONG = 1 STATUS_REQUEST = 2 @@ -198,6 +210,7 @@ class MessageType(Enum): @dataclass class NetworkMessage: """Network protocol message.""" + msg_type: MessageType sender_id: str payload: dict = field(default_factory=dict) @@ -211,15 +224,15 @@ def to_bytes(self) -> bytes: "payload": self.payload, "timestamp": self.timestamp.isoformat(), } - json_data = json.dumps(data).encode('utf-8') + json_data = json.dumps(data).encode("utf-8") # Prefix with length - return struct.pack('>I', len(json_data)) + json_data + return struct.pack(">I", len(json_data)) + json_data @classmethod - def from_bytes(cls, data: bytes) -> 'NetworkMessage': + def from_bytes(cls, data: bytes) -> "NetworkMessage": """Deserialize message from bytes.""" # Skip length prefix - json_data = data[4:].decode('utf-8') + json_data = data[4:].decode("utf-8") obj = json.loads(json_data) return cls( msg_type=MessageType(obj["type"]), @@ -233,6 +246,7 @@ def from_bytes(cls, data: bytes) -> 'NetworkMessage': # Calibration Server # ============================================================================= + class CalibrationServer: """ Central calibration server for fleet management. @@ -241,10 +255,7 @@ class CalibrationServer: and synchronizes profiles across the network. """ - def __init__(self, - host: str = "0.0.0.0", - port: int = 5678, - server_id: str | None = None): + def __init__(self, host: str = "0.0.0.0", port: int = 5678, server_id: str | None = None): """ Initialize calibration server. @@ -314,12 +325,14 @@ def update_node_status(self, node_id: str, status: NodeStatus) -> None: # Job Management # ========================================================================= - def create_job(self, - job_type: JobType, - target_nodes: list[str], - parameters: dict | None = None, - priority: int = 0, - created_by: str = "") -> CalibrationJob: + def create_job( + self, + job_type: JobType, + target_nodes: list[str], + parameters: dict | None = None, + priority: int = 0, + created_by: str = "", + ) -> CalibrationJob: """ Create a new calibration job. @@ -394,9 +407,7 @@ def get_profile(self, package_id: str) -> ProfilePackage | None: """Get profile package by ID.""" return self.profiles.get(package_id) - def push_profile_to_nodes(self, - package_id: str, - node_ids: list[str]) -> dict[str, bool]: + def push_profile_to_nodes(self, package_id: str, node_ids: list[str]) -> dict[str, bool]: """ Push profile package to specified nodes. @@ -434,7 +445,7 @@ def _send_profile_to_node(self, node: DisplayNode, package: ProfilePackage) -> b "has_icc": package.icc_profile is not None, "has_lut_3d": package.lut_3d is not None, "calibration_data": package.calibration_data, - } + }, ) # Actual send would go here return True @@ -445,9 +456,7 @@ def _send_profile_to_node(self, node: DisplayNode, package: ProfilePackage) -> b # Fleet Calibration # ========================================================================= - def calibrate_fleet(self, - group: str | None = None, - parameters: dict | None = None) -> CalibrationJob: + def calibrate_fleet(self, group: str | None = None, parameters: dict | None = None) -> CalibrationJob: """ Create calibration job for entire fleet or group. @@ -489,9 +498,7 @@ def verify_fleet(self, group: str | None = None) -> CalibrationJob: created_by="fleet_manager", ) - def apply_profile_to_fleet(self, - package_id: str, - group: str | None = None) -> CalibrationJob: + def apply_profile_to_fleet(self, package_id: str, group: str | None = None) -> CalibrationJob: """Apply profile to entire fleet or group.""" if group: nodes = self.get_nodes_by_group(group) @@ -557,9 +564,7 @@ async def process_jobs(self) -> None: self.job_queue.pop(0) self._notify_job_change(job) - async def _execute_job_on_node(self, - job: CalibrationJob, - node: DisplayNode) -> dict: + async def _execute_job_on_node(self, job: CalibrationJob, node: DisplayNode) -> dict: """Execute job on a specific node.""" self.update_node_status(node.node_id, NodeStatus.CALIBRATING) @@ -656,6 +661,7 @@ def stop(self) -> None: # Calibration Client # ============================================================================= + class CalibrationClient: """ Calibration client for remote nodes. @@ -663,10 +669,7 @@ class CalibrationClient: Connects to a calibration server and executes calibration commands. """ - def __init__(self, - server_host: str, - server_port: int = 5678, - node_id: str | None = None): + def __init__(self, server_host: str, server_port: int = 5678, node_id: str | None = None): """ Initialize calibration client. @@ -734,7 +737,7 @@ def _send_registration(self) -> None: "display_name": self.node_info.display_name, "display_model": self.node_info.display_model, "has_colorimeter": self.node_info.has_colorimeter, - } + }, ) self._send_message(msg) @@ -752,13 +755,11 @@ def send_status(self) -> None: "status": self.node_info.status.name, "active_profile": self.node_info.active_profile, "active_lut": self.node_info.active_lut, - } + }, ) self._send_message(msg) - def register_handler(self, - msg_type: MessageType, - handler: Callable[[NetworkMessage], None]) -> None: + def register_handler(self, msg_type: MessageType, handler: Callable[[NetworkMessage], None]) -> None: """Register handler for message type.""" self._command_handlers[msg_type] = handler @@ -773,6 +774,7 @@ def process_message(self, msg: NetworkMessage) -> None: # Profile Sync Manager # ============================================================================= + class ProfileSyncManager: """ Manages profile synchronization across the fleet. @@ -810,9 +812,7 @@ def sync_all(self) -> SyncState: if package_id not in self.sync_state.synced_profiles: self.sync_state.synced_profiles.append(package_id) else: - self.sync_state.sync_errors.append( - f"Failed to sync {package.name} to {node_id}" - ) + self.sync_state.sync_errors.append(f"Failed to sync {package.name} to {node_id}") self.sync_state.last_sync = datetime.now() return self.sync_state @@ -833,6 +833,7 @@ def sync_to_group(self, group: str) -> SyncState: # Utility Functions # ============================================================================= + def create_test_nodes(count: int = 5) -> list[DisplayNode]: """Create test display nodes.""" nodes = [] @@ -917,7 +918,7 @@ def print_fleet_status(server: CalibrationServer) -> None: "whitepoint": "D65", "gamma": 2.2, "gamut": "sRGB", - } + }, ) server.add_profile(package) diff --git a/calibrate_pro/advanced/uniformity.py b/calibrate_pro/advanced/uniformity.py index f74e94c..bb95f58 100644 --- a/calibrate_pro/advanced/uniformity.py +++ b/calibrate_pro/advanced/uniformity.py @@ -18,6 +18,7 @@ # Optional scipy import try: from scipy.interpolate import RectBivariateSpline, RegularGridInterpolator + SCIPY_AVAILABLE = True except ImportError: SCIPY_AVAILABLE = False @@ -28,8 +29,10 @@ # Enums # ============================================================================= + class UniformityGrid(Enum): """Uniformity measurement grid sizes.""" + GRID_3X3 = (3, 3) GRID_5X5 = (5, 5) GRID_7X7 = (7, 7) @@ -39,47 +42,52 @@ class UniformityGrid(Enum): class UniformityGrade(Enum): """Uniformity quality grade.""" - REFERENCE = auto() # <2% deviation - EXCELLENT = auto() # <5% deviation - GOOD = auto() # <10% deviation - ACCEPTABLE = auto() # <15% deviation - POOR = auto() # >=15% deviation + + REFERENCE = auto() # <2% deviation + EXCELLENT = auto() # <5% deviation + GOOD = auto() # <10% deviation + ACCEPTABLE = auto() # <15% deviation + POOR = auto() # >=15% deviation class CompensationMode(Enum): """Uniformity compensation mode.""" - LUMINANCE_ONLY = auto() # Only correct luminance - COLOR_ONLY = auto() # Only correct color (xy chromaticity) - FULL = auto() # Correct both luminance and color + + LUMINANCE_ONLY = auto() # Only correct luminance + COLOR_ONLY = auto() # Only correct color (xy chromaticity) + FULL = auto() # Correct both luminance and color # ============================================================================= # Data Classes # ============================================================================= + @dataclass class UniformityMeasurement: """Single uniformity measurement point.""" - grid_x: int # Grid column (0-indexed) - grid_y: int # Grid row (0-indexed) - screen_x: float # Normalized screen X (0-1) - screen_y: float # Normalized screen Y (0-1) + + grid_x: int # Grid column (0-indexed) + grid_y: int # Grid row (0-indexed) + screen_x: float # Normalized screen X (0-1) + screen_y: float # Normalized screen Y (0-1) # Measured values - luminance: float # cd/m² (nits) - chromaticity_x: float # CIE xy x - chromaticity_y: float # CIE xy y + luminance: float # cd/m² (nits) + chromaticity_x: float # CIE xy x + chromaticity_y: float # CIE xy y xyz: tuple[float, float, float] = (0, 0, 0) # Deviation from center/reference - luminance_deviation: float = 0.0 # Percentage deviation - delta_uv: float = 0.0 # Chromaticity deviation + luminance_deviation: float = 0.0 # Percentage deviation + delta_uv: float = 0.0 # Chromaticity deviation @dataclass class UniformityRegion: """Analysis for a screen region.""" - region_name: str # e.g., "top-left", "center", "bottom-right" + + region_name: str # e.g., "top-left", "center", "bottom-right" grid_positions: list[tuple[int, int]] luminance_mean: float @@ -98,6 +106,7 @@ class UniformityRegion: @dataclass class UniformityResult: """Complete uniformity measurement result.""" + grid_size: UniformityGrid measurements: list[UniformityMeasurement] measurement_grid: np.ndarray # 2D array of measurements @@ -111,7 +120,7 @@ class UniformityResult: luminance_mean: float luminance_min: float luminance_max: float - luminance_uniformity: float # Percentage (100 = perfect) + luminance_uniformity: float # Percentage (100 = perfect) chromaticity_uniformity: float # Based on max delta_uv delta_uv_mean: float @@ -126,7 +135,7 @@ class UniformityResult: region_analysis: dict[str, UniformityRegion] = field(default_factory=dict) # Edge fall-off analysis - left_falloff: float = 0.0 # Percentage drop at left edge + left_falloff: float = 0.0 # Percentage drop at left edge right_falloff: float = 0.0 top_falloff: float = 0.0 bottom_falloff: float = 0.0 @@ -139,8 +148,9 @@ class UniformityResult: @dataclass class UniformityCorrectionLUT: """Per-region uniformity correction LUT.""" + grid_size: UniformityGrid - correction_grid: np.ndarray # 2D array of correction factors + correction_grid: np.ndarray # 2D array of correction factors # Luminance correction (multiplicative) luminance_corrections: np.ndarray @@ -162,6 +172,7 @@ class UniformityCorrectionLUT: # Grade Functions # ============================================================================= + def grade_from_uniformity(deviation: float) -> UniformityGrade: """Determine grade from uniformity deviation percentage.""" if deviation < 2: @@ -191,6 +202,7 @@ def grade_to_string(grade: UniformityGrade) -> str: # Utility Functions # ============================================================================= + def xy_to_uv(x: float, y: float) -> tuple[float, float]: """Convert CIE xy to CIE u'v'.""" denom = -2 * x + 12 * y + 3 @@ -205,13 +217,12 @@ def delta_uv(x1: float, y1: float, x2: float, y2: float) -> float: """Calculate Δu'v' between two chromaticities.""" u1, v1 = xy_to_uv(x1, y1) u2, v2 = xy_to_uv(x2, y2) - return np.sqrt((u2 - u1)**2 + (v2 - v1)**2) + return np.sqrt((u2 - u1) ** 2 + (v2 - v1) ** 2) -def generate_grid_positions(grid_size: UniformityGrid, - screen_width: int = 1920, - screen_height: int = 1080, - margin: float = 0.05) -> list[tuple[int, int, float, float]]: +def generate_grid_positions( + grid_size: UniformityGrid, screen_width: int = 1920, screen_height: int = 1080, margin: float = 0.05 +) -> list[tuple[int, int, float, float]]: """ Generate measurement positions for uniformity grid. @@ -283,6 +294,7 @@ def get_region_name(col: int, row: int, cols: int, rows: int) -> str: # Uniformity Analyzer Class # ============================================================================= + class UniformityAnalyzer: """ Display uniformity analysis engine. @@ -291,11 +303,7 @@ class UniformityAnalyzer: the display surface using configurable grid patterns. """ - REGION_NAMES = [ - "top-left", "top", "top-right", - "left", "center", "right", - "bottom-left", "bottom", "bottom-right" - ] + REGION_NAMES = ["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"] def __init__(self, grid_size: UniformityGrid = UniformityGrid.GRID_5X5): """ @@ -307,9 +315,7 @@ def __init__(self, grid_size: UniformityGrid = UniformityGrid.GRID_5X5): self.grid_size = grid_size self.cols, self.rows = grid_size.value - def analyze(self, - measurements: list[UniformityMeasurement], - display_name: str = "") -> UniformityResult: + def analyze(self, measurements: list[UniformityMeasurement], display_name: str = "") -> UniformityResult: """ Analyze uniformity measurements. @@ -370,22 +376,12 @@ def analyze(self, center_grade = center_region.grade if center_region else UniformityGrade.GOOD corner_regions = ["top-left", "top-right", "bottom-left", "bottom-right"] - corner_deviations = [ - region_analysis[r].luminance_deviation - for r in corner_regions if r in region_analysis - ] - corner_grade = grade_from_uniformity( - max(abs(d) for d in corner_deviations) if corner_deviations else 0 - ) + corner_deviations = [region_analysis[r].luminance_deviation for r in corner_regions if r in region_analysis] + corner_grade = grade_from_uniformity(max(abs(d) for d in corner_deviations) if corner_deviations else 0) edge_regions = ["top", "bottom", "left", "right"] - edge_deviations = [ - region_analysis[r].luminance_deviation - for r in edge_regions if r in region_analysis - ] - edge_grade = grade_from_uniformity( - max(abs(d) for d in edge_deviations) if edge_deviations else 0 - ) + edge_deviations = [region_analysis[r].luminance_deviation for r in edge_regions if r in region_analysis] + edge_grade = grade_from_uniformity(max(abs(d) for d in edge_deviations) if edge_deviations else 0) # Overall grade (based on worst uniformity) max_deviation = max(abs(m.luminance_deviation) for m in measurements) @@ -422,15 +418,11 @@ def analyze(self, display_name=display_name, ) - def _analyze_regions(self, - measurements: list[UniformityMeasurement], - ref_lum: float, - ref_x: float, - ref_y: float) -> dict[str, UniformityRegion]: + def _analyze_regions( + self, measurements: list[UniformityMeasurement], ref_lum: float, ref_x: float, ref_y: float + ) -> dict[str, UniformityRegion]: """Analyze measurements by screen region.""" - regions: dict[str, list[UniformityMeasurement]] = { - name: [] for name in self.REGION_NAMES - } + regions: dict[str, list[UniformityMeasurement]] = {name: [] for name in self.REGION_NAMES} # Assign measurements to regions for m in measurements: @@ -532,6 +524,7 @@ def _calculate_vertical_falloff(self, grid: np.ndarray) -> tuple[float, float]: # Uniformity Compensation Class # ============================================================================= + class UniformityCompensator: """ Generates uniformity correction LUTs. @@ -549,9 +542,9 @@ def __init__(self, mode: CompensationMode = CompensationMode.FULL): """ self.mode = mode - def generate_correction_lut(self, - result: UniformityResult, - target_luminance: float | None = None) -> UniformityCorrectionLUT: + def generate_correction_lut( + self, result: UniformityResult, target_luminance: float | None = None + ) -> UniformityCorrectionLUT: """ Generate uniformity correction LUT from measurements. @@ -607,11 +600,9 @@ def generate_correction_lut(self, mode=self.mode, ) - def apply_correction(self, - lut: UniformityCorrectionLUT, - rgb: tuple[float, float, float], - screen_x: float, - screen_y: float) -> tuple[float, float, float]: + def apply_correction( + self, lut: UniformityCorrectionLUT, rgb: tuple[float, float, float], screen_x: float, screen_y: float + ) -> tuple[float, float, float]: """ Apply uniformity correction to RGB value. @@ -648,11 +639,13 @@ def apply_correction(self, return (r, g, b) - def generate_3d_lut_with_uniformity(self, - lut: UniformityCorrectionLUT, - base_lut: np.ndarray | None = None, - lut_size: int = 17, - screen_regions: int = 9) -> dict[str, np.ndarray]: + def generate_3d_lut_with_uniformity( + self, + lut: UniformityCorrectionLUT, + base_lut: np.ndarray | None = None, + lut_size: int = 17, + screen_regions: int = 9, + ) -> dict[str, np.ndarray]: """ Generate per-region 3D LUTs with uniformity correction. @@ -712,11 +705,7 @@ def _create_identity_lut(self, size: int) -> np.ndarray: for r in range(size): for g in range(size): for b in range(size): - lut[r, g, b] = [ - r / (size - 1), - g / (size - 1), - b / (size - 1) - ] + lut[r, g, b] = [r / (size - 1), g / (size - 1), b / (size - 1)] return lut @@ -725,9 +714,10 @@ def _create_identity_lut(self, size: int) -> np.ndarray: # Utility Functions # ============================================================================= -def create_test_measurements(grid_size: UniformityGrid = UniformityGrid.GRID_5X5, - center_luminance: float = 100.0, - edge_falloff: float = 0.15) -> list[UniformityMeasurement]: + +def create_test_measurements( + grid_size: UniformityGrid = UniformityGrid.GRID_5X5, center_luminance: float = 100.0, edge_falloff: float = 0.15 +) -> list[UniformityMeasurement]: """Create simulated uniformity measurements for testing.""" np.random.seed(42) measurements = [] @@ -740,7 +730,7 @@ def create_test_measurements(grid_size: UniformityGrid = UniformityGrid.GRID_5X5 for col, row, x_norm, y_norm in positions: # Distance from center - dist = np.sqrt((x_norm - 0.5)**2 + (y_norm - 0.5)**2) + dist = np.sqrt((x_norm - 0.5) ** 2 + (y_norm - 0.5) ** 2) # Luminance falls off towards edges lum = center_luminance * (1 - edge_falloff * dist * 2) @@ -750,16 +740,18 @@ def create_test_measurements(grid_size: UniformityGrid = UniformityGrid.GRID_5X5 x = center_x + np.random.normal(0, 0.002) + dist * 0.005 y = center_y + np.random.normal(0, 0.002) - dist * 0.003 - measurements.append(UniformityMeasurement( - grid_x=col, - grid_y=row, - screen_x=x_norm, - screen_y=y_norm, - luminance=max(0, lum), - chromaticity_x=x, - chromaticity_y=y, - xyz=(0, lum, 0), # Simplified - )) + measurements.append( + UniformityMeasurement( + grid_x=col, + grid_y=row, + screen_x=x_norm, + screen_y=y_norm, + luminance=max(0, lum), + chromaticity_x=x, + chromaticity_y=y, + xyz=(0, lum, 0), # Simplified + ) + ) return measurements @@ -807,11 +799,7 @@ def print_uniformity_summary(result: UniformityResult) -> None: if __name__ == "__main__": # Test uniformity analysis analyzer = UniformityAnalyzer(UniformityGrid.GRID_5X5) - test_measurements = create_test_measurements( - UniformityGrid.GRID_5X5, - center_luminance=100.0, - edge_falloff=0.12 - ) + test_measurements = create_test_measurements(UniformityGrid.GRID_5X5, center_luminance=100.0, edge_falloff=0.12) result = analyzer.analyze(test_measurements, "Test Display") print_uniformity_summary(result) @@ -823,5 +811,7 @@ def print_uniformity_summary(result: UniformityResult) -> None: print("\nCorrection LUT generated:") print(f" Grid size: {correction_lut.grid_size.value}") print(f" Mode: {correction_lut.mode.name}") - print(f" Luminance range: {correction_lut.luminance_corrections.min():.3f} - " - f"{correction_lut.luminance_corrections.max():.3f}") + print( + f" Luminance range: {correction_lut.luminance_corrections.min():.3f} - " + f"{correction_lut.luminance_corrections.max():.3f}" + ) diff --git a/calibrate_pro/app.py b/calibrate_pro/app.py index be248b5..2e940d3 100644 --- a/calibrate_pro/app.py +++ b/calibrate_pro/app.py @@ -18,7 +18,7 @@ from pathlib import Path # Ensure we can find our modules -if getattr(sys, 'frozen', False): +if getattr(sys, "frozen", False): # Running as compiled executable APP_DIR = Path(sys.executable).parent else: @@ -94,7 +94,11 @@ def cmd_calibrate(args) -> int: display = displays[0] # Determine model name - model_string = args.model if args.model else (display.monitor_name or display.model or f"Display{display.get_display_number()}") + model_string = ( + args.model + if args.model + else (display.monitor_name or display.model or f"Display{display.get_display_number()}") + ) print(f"Calibrating: {model_string}") print(f"Resolution: {display.width}x{display.height} @ {display.refresh_rate}Hz") @@ -133,7 +137,7 @@ def progress_callback(message: str, progress: float): lut_size=args.lut_size, generate_icc=not args.no_icc, generate_lut=not args.no_lut, - hdr_mode=args.hdr + hdr_mode=args.hdr, ) print("\n") @@ -157,6 +161,7 @@ def progress_callback(message: str, progress: float): # Save calibration for persistence from calibrate_pro.utils.startup_manager import get_startup_manager + startup_mgr = get_startup_manager() display_id = display.get_display_number() - 1 @@ -168,7 +173,7 @@ def progress_callback(message: str, progress: float): icc_path=str(result.icc_profile_path) if result.icc_profile_path else None, hdr_mode=args.hdr, delta_e_avg=result.delta_e_avg, - delta_e_max=result.delta_e_max + delta_e_max=result.delta_e_max, ) print() print("[SAVED] Calibration saved for auto-restore on startup.") @@ -273,10 +278,10 @@ def cmd_load_lut(args) -> int: # Determine file type and load success = False - if file_path.suffix.lower() in ['.icc', '.icm']: + if file_path.suffix.lower() in [".icc", ".icm"]: print("Loading ICC profile...") success = loader.load_icc_profile(display_id, str(file_path)) - elif file_path.suffix.lower() in ['.cube', '.3dl', '.mga']: + elif file_path.suffix.lower() in [".cube", ".3dl", ".mga"]: print("Loading 3D LUT...") success = loader.load_lut_file(display_id, str(file_path)) else: @@ -311,7 +316,7 @@ def cmd_start_service(args) -> int: from calibrate_pro.lut_system.color_loader import get_color_loader from calibrate_pro.utils.startup_manager import get_startup_manager - silent = getattr(args, 'silent', False) + silent = getattr(args, "silent", False) if not silent: print(get_banner()) @@ -339,7 +344,7 @@ def cmd_start_service(args) -> int: status = loader.get_status() - if not status['calibrations']: + if not status["calibrations"]: if not silent: print("No calibrations configured.") print("Use 'calibrate' or 'load-lut' first to configure calibration.") @@ -350,11 +355,11 @@ def cmd_start_service(args) -> int: print("Starting color loader service...") print() print("Configured displays:") - for display_id, cal in status['calibrations'].items(): + for display_id, cal in status["calibrations"].items(): print(f" Display {int(display_id) + 1}: {cal['name']}") - if cal['lut']: + if cal["lut"]: print(f" LUT: {cal['lut']}") - if cal['icc']: + if cal["icc"]: print(f" ICC: {cal['icc']}") print() @@ -479,20 +484,20 @@ def cmd_status(args) -> int: print("Displays:") for display in displays: - display_id = display['id'] - cal = status['calibrations'].get(str(display_id)) + display_id = display["id"] + cal = status["calibrations"].get(str(display_id)) - primary = " (Primary)" if display['primary'] else "" + primary = " (Primary)" if display["primary"] else "" print(f"\n Display {display_id + 1}{primary}: {display['monitor']}") if cal: print(" Status: CALIBRATED") - if cal['lut']: + if cal["lut"]: print(f" LUT: {Path(cal['lut']).name}") - if cal['icc']: + if cal["icc"]: print(f" ICC: {Path(cal['icc']).name}") - if cal['last_applied']: - age = time.time() - cal['last_applied'] + if cal["last_applied"]: + age = time.time() - cal["last_applied"] print(f" Last Applied: {age:.0f}s ago") else: print(" Status: Not calibrated") @@ -550,6 +555,7 @@ def cmd_gui(args) -> int: """Launch the graphical user interface.""" try: from calibrate_pro.gui.main_window import main as gui_main + return gui_main() except ImportError as e: print(f"Error: GUI not available - {e}") @@ -560,76 +566,76 @@ def cmd_gui(args) -> int: def create_parser() -> argparse.ArgumentParser: """Create command-line argument parser.""" parser = argparse.ArgumentParser( - prog='calibrate_pro', - description=f'{__app_name__} v{__version__} - Professional Display Calibration', - formatter_class=argparse.RawDescriptionHelpFormatter + prog="calibrate_pro", + description=f"{__app_name__} v{__version__} - Professional Display Calibration", + formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument('--version', action='version', version=f'{__app_name__} v{__version__}') + parser.add_argument("--version", action="version", version=f"{__app_name__} v{__version__}") - subparsers = parser.add_subparsers(dest='command', help='Available commands') + subparsers = parser.add_subparsers(dest="command", help="Available commands") # detect - detect_parser = subparsers.add_parser('detect', help='Detect connected displays') + detect_parser = subparsers.add_parser("detect", help="Detect connected displays") detect_parser.set_defaults(func=cmd_detect) # calibrate - cal_parser = subparsers.add_parser('calibrate', help='Calibrate a display') - cal_parser.add_argument('--display', '-d', type=int, help='Display number (1-based)') - cal_parser.add_argument('--model', type=str, help='Monitor model name (e.g., PG27UCDM, G85SB)') - cal_parser.add_argument('--output', '-o', type=str, default='calibration_output', help='Output directory') - cal_parser.add_argument('--mode', '-m', choices=['sensorless', 'colorimeter', 'hybrid'], default='sensorless') - cal_parser.add_argument('--hdr', action='store_true', help='Enable HDR calibration mode') - cal_parser.add_argument('--lut-size', type=int, choices=[17, 33, 65], default=33, help='3D LUT size') - cal_parser.add_argument('--no-icc', action='store_true', help='Skip ICC profile generation') - cal_parser.add_argument('--no-lut', action='store_true', help='Skip 3D LUT generation') - cal_parser.add_argument('--apply', '-a', action='store_true', help='Apply calibration immediately') + cal_parser = subparsers.add_parser("calibrate", help="Calibrate a display") + cal_parser.add_argument("--display", "-d", type=int, help="Display number (1-based)") + cal_parser.add_argument("--model", type=str, help="Monitor model name (e.g., PG27UCDM, G85SB)") + cal_parser.add_argument("--output", "-o", type=str, default="calibration_output", help="Output directory") + cal_parser.add_argument("--mode", "-m", choices=["sensorless", "colorimeter", "hybrid"], default="sensorless") + cal_parser.add_argument("--hdr", action="store_true", help="Enable HDR calibration mode") + cal_parser.add_argument("--lut-size", type=int, choices=[17, 33, 65], default=33, help="3D LUT size") + cal_parser.add_argument("--no-icc", action="store_true", help="Skip ICC profile generation") + cal_parser.add_argument("--no-lut", action="store_true", help="Skip 3D LUT generation") + cal_parser.add_argument("--apply", "-a", action="store_true", help="Apply calibration immediately") cal_parser.set_defaults(func=cmd_calibrate) # verify - verify_parser = subparsers.add_parser('verify', help='Verify calibration accuracy') - verify_parser.add_argument('--display', '-d', type=int, help='Display number') + verify_parser = subparsers.add_parser("verify", help="Verify calibration accuracy") + verify_parser.add_argument("--display", "-d", type=int, help="Display number") verify_parser.set_defaults(func=cmd_verify) # load-lut - load_parser = subparsers.add_parser('load-lut', help='Load and apply LUT or ICC profile') - load_parser.add_argument('file', type=str, help='Path to .cube, .icc, or .icm file') - load_parser.add_argument('--display', '-d', type=int, default=1, help='Display number') - load_parser.add_argument('--persist', '-p', action='store_true', help='Keep running to maintain calibration') + load_parser = subparsers.add_parser("load-lut", help="Load and apply LUT or ICC profile") + load_parser.add_argument("file", type=str, help="Path to .cube, .icc, or .icm file") + load_parser.add_argument("--display", "-d", type=int, default=1, help="Display number") + load_parser.add_argument("--persist", "-p", action="store_true", help="Keep running to maintain calibration") load_parser.set_defaults(func=cmd_load_lut) # start-service - start_parser = subparsers.add_parser('start-service', help='Start background color loader') - start_parser.add_argument('--silent', '-s', action='store_true', help='Run silently (no output)') + start_parser = subparsers.add_parser("start-service", help="Start background color loader") + start_parser.add_argument("--silent", "-s", action="store_true", help="Run silently (no output)") start_parser.set_defaults(func=cmd_start_service) # stop-service - stop_parser = subparsers.add_parser('stop-service', help='Stop color loader and reset displays') + stop_parser = subparsers.add_parser("stop-service", help="Stop color loader and reset displays") stop_parser.set_defaults(func=cmd_stop_service) # enable-startup - enable_startup_parser = subparsers.add_parser('enable-startup', help='Enable auto-start at Windows boot') + enable_startup_parser = subparsers.add_parser("enable-startup", help="Enable auto-start at Windows boot") enable_startup_parser.set_defaults(func=cmd_enable_startup) # disable-startup - disable_startup_parser = subparsers.add_parser('disable-startup', help='Disable auto-start') + disable_startup_parser = subparsers.add_parser("disable-startup", help="Disable auto-start") disable_startup_parser.set_defaults(func=cmd_disable_startup) # list-panels - panels_parser = subparsers.add_parser('list-panels', help='List supported panel profiles') + panels_parser = subparsers.add_parser("list-panels", help="List supported panel profiles") panels_parser.set_defaults(func=cmd_list_panels) # info - info_parser = subparsers.add_parser('info', help='Show panel information') - info_parser.add_argument('panel', type=str, help='Panel key (e.g., PG27UCDM)') + info_parser = subparsers.add_parser("info", help="Show panel information") + info_parser.add_argument("panel", type=str, help="Panel key (e.g., PG27UCDM)") info_parser.set_defaults(func=cmd_info) # status - status_parser = subparsers.add_parser('status', help='Show current calibration status') + status_parser = subparsers.add_parser("status", help="Show current calibration status") status_parser.set_defaults(func=cmd_status) # gui - gui_parser = subparsers.add_parser('gui', help='Launch graphical interface') + gui_parser = subparsers.add_parser("gui", help="Launch graphical interface") gui_parser.set_defaults(func=cmd_gui) return parser @@ -652,11 +658,12 @@ def main() -> int: return 130 except Exception as e: print(f"\nError: {e}") - if os.environ.get('DEBUG'): + if os.environ.get("DEBUG"): import traceback + traceback.print_exc() return 1 -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/calibrate_pro/calibration/ccss_import.py b/calibrate_pro/calibration/ccss_import.py index 5c3e781..5c62ae9 100644 --- a/calibrate_pro/calibration/ccss_import.py +++ b/calibrate_pro/calibration/ccss_import.py @@ -43,6 +43,7 @@ # CGATS parsing helpers # --------------------------------------------------------------------------- + def _parse_cgats_keywords(text: str) -> dict[str, str]: """ Extract KEYWORD/value pairs from CGATS header lines. @@ -66,7 +67,7 @@ def _parse_cgats_keywords(text: str) -> dict[str, str]: if m: keywords[m.group(1).upper()] = m.group(2) else: - m = re.match(r'^(\w+)\s+(.+)', line) + m = re.match(r"^(\w+)\s+(.+)", line) if m: key = m.group(1).upper() val = m.group(2).strip().strip('"') @@ -120,6 +121,7 @@ def _extract_data_block(text: str) -> tuple[list[str], list[list[str]]]: # CCMX loading # --------------------------------------------------------------------------- + def load_ccmx(path: str | Path) -> np.ndarray: """ Load a CCMX (Colorimeter Correction Matrix) file. @@ -145,9 +147,7 @@ def load_ccmx(path: str | Path) -> np.ndarray: _, rows = _extract_data_block(text) if len(rows) < 3: - raise ValueError( - f"CCMX file must contain at least 3 data rows, got {len(rows)}" - ) + raise ValueError(f"CCMX file must contain at least 3 data rows, got {len(rows)}") matrix_rows: list[list[float]] = [] for row in rows[:3]: @@ -160,9 +160,7 @@ def load_ccmx(path: str | Path) -> np.ndarray: except ValueError: continue if len(floats) < 3: - raise ValueError( - f"Expected at least 3 numeric values per row, got {len(floats)}: {row}" - ) + raise ValueError(f"Expected at least 3 numeric values per row, got {len(floats)}: {row}") # Take the last 3 values (handles optional leading index column) matrix_rows.append(floats[-3:]) @@ -173,6 +171,7 @@ def load_ccmx(path: str | Path) -> np.ndarray: # CCSS loading # --------------------------------------------------------------------------- + def load_ccss(path: str | Path) -> dict: """ Load a CCSS (Colorimeter Calibration Spectral Sample) file. @@ -213,10 +212,7 @@ def load_ccss(path: str | Path) -> dict: spec_indices.append((i, float(m.group(1)))) if not spec_indices: - raise ValueError( - "No SPEC_* columns found in CCSS file. " - f"Fields: {fields}" - ) + raise ValueError(f"No SPEC_* columns found in CCSS file. Fields: {fields}") # Sort by wavelength spec_indices.sort(key=lambda x: x[1]) @@ -252,6 +248,7 @@ def load_ccss(path: str | Path) -> dict: # Applying corrections # --------------------------------------------------------------------------- + def apply_ccmx(xyz: np.ndarray, ccmx: np.ndarray) -> np.ndarray: """ Apply a CCMX correction matrix to XYZ measurements. @@ -274,7 +271,6 @@ def apply_ccmx(xyz: np.ndarray, ccmx: np.ndarray) -> np.ndarray: if ccmx.shape != (3, 3): raise ValueError(f"CCMX must be (3, 3), got {ccmx.shape}") - if xyz.ndim == 1: return ccmx @ xyz elif xyz.ndim == 2: @@ -293,6 +289,7 @@ def apply_ccmx(xyz: np.ndarray, ccmx: np.ndarray) -> np.ndarray: # Built-in corrections # --------------------------------------------------------------------------- + def _get_qdoled_ccmx() -> np.ndarray: """ Return the built-in QD-OLED CCMX from the native calibration loop. @@ -302,6 +299,7 @@ def _get_qdoled_ccmx() -> np.ndarray: primaries. """ from calibrate_pro.calibration.native_loop import QDOLED_CCMX + return QDOLED_CCMX.copy() @@ -332,13 +330,15 @@ def list_builtin_corrections() -> list: """ result = [] for name, info in _BUILTIN_CORRECTIONS.items(): - result.append({ - "name": name, - "display": info["display"], - "technology": info["technology"], - "reference": info["reference"], - "colorimeter": info["colorimeter"], - }) + result.append( + { + "name": name, + "display": info["display"], + "technology": info["technology"], + "reference": info["reference"], + "colorimeter": info["colorimeter"], + } + ) return result @@ -357,8 +357,5 @@ def get_builtin_ccmx(name: str) -> np.ndarray: """ if name not in _BUILTIN_CORRECTIONS: available = list(_BUILTIN_CORRECTIONS.keys()) - raise KeyError( - f"Unknown built-in correction '{name}'. " - f"Available: {available}" - ) + raise KeyError(f"Unknown built-in correction '{name}'. Available: {available}") return _BUILTIN_CORRECTIONS[name]["loader"]() diff --git a/calibrate_pro/calibration/hardware_first.py b/calibrate_pro/calibration/hardware_first.py index 5a57354..70de1d0 100644 --- a/calibrate_pro/calibration/hardware_first.py +++ b/calibrate_pro/calibration/hardware_first.py @@ -28,6 +28,7 @@ @dataclass class HardwareFirstResult: """Result of a hardware-first calibration.""" + success: bool = False message: str = "" @@ -97,6 +98,7 @@ def report(msg, frac): try: from calibrate_pro.hardware.ddc_ci import DDCCIController, VCPCode + ddc = DDCCIController() monitors = ddc.enumerate_monitors() @@ -126,8 +128,11 @@ def report(msg, frac): except Exception: result.initial_rgb_gains = (100, 100, 100) - report(f"Initial: brightness={result.initial_brightness}, " - f"RGB=({result.initial_rgb_gains[0]},{result.initial_rgb_gains[1]},{result.initial_rgb_gains[2]})", 0.05) + report( + f"Initial: brightness={result.initial_brightness}, " + f"RGB=({result.initial_rgb_gains[0]},{result.initial_rgb_gains[1]},{result.initial_rgb_gains[2]})", + 0.05, + ) # Auto-setup monitor OSD via DDC/CI ddc_rec = None @@ -144,13 +149,14 @@ def report(msg, frac): if panel: result.panel_type = panel.panel_type result.display_name = panel.name - ddc_rec = panel.ddc if hasattr(panel, 'ddc') else None + ddc_rec = panel.ddc if hasattr(panel, "ddc") else None except Exception as e: logger.debug("Panel identification failed: %s", e) report(f"Auto-configuring {result.display_name} for calibration...", 0.08) changes = ddc.auto_setup_for_calibration( - monitor, ddc_recommendations=ddc_rec, + monitor, + ddc_recommendations=ddc_rec, log_fn=lambda msg: report(f" {msg}", 0.10), ) for change in changes: @@ -182,7 +188,10 @@ def report(msg, frac): break luminance = xyz[1] - report(f" Brightness={current_brightness}: Y={luminance:.1f} cd/m2 (target {target_luminance:.0f})", 0.15 + iteration * 0.02) + report( + f" Brightness={current_brightness}: Y={luminance:.1f} cd/m2 (target {target_luminance:.0f})", + 0.15 + iteration * 0.02, + ) if abs(luminance - target_luminance) / max(target_luminance, 1) < 0.05: report(f" Brightness converged at {current_brightness}", 0.30) @@ -222,13 +231,16 @@ def report(msg, frac): dx = target_whitepoint[0] - mx dy = target_whitepoint[1] - my - error = (dx**2 + dy**2)**0.5 + error = (dx**2 + dy**2) ** 0.5 - report(f" Iter {iteration+1}: xy=({mx:.4f},{my:.4f}), error={error:.4f}, " - f"RGB=({current_r},{current_g},{current_b})", 0.30 + iteration * 0.015) + report( + f" Iter {iteration + 1}: xy=({mx:.4f},{my:.4f}), error={error:.4f}, " + f"RGB=({current_r},{current_g},{current_b})", + 0.30 + iteration * 0.015, + ) if error < 0.003: - report(f" White balance converged after {iteration+1} iterations", 0.58) + report(f" White balance converged after {iteration + 1} iterations", 0.58) result.white_point_achieved = (mx, my) break @@ -267,7 +279,7 @@ def report(msg, frac): result.luminance_achieved = xyz[1] s = sum(xyz) if s > 0: - result.white_point_achieved = (xyz[0]/s, xyz[1]/s) + result.white_point_achieved = (xyz[0] / s, xyz[1] / s) # ===================================================================== # Phase 3: PROFILE — Measure the hardware-calibrated display @@ -287,8 +299,10 @@ def profile_progress(msg, frac): progress_fn=profile_progress, ) - report(f"Profile: WP ({profile.white_xy[0]:.4f}, {profile.white_xy[1]:.4f}), " - f"Y={profile.white_Y:.1f} cd/m2", 0.85) + report( + f"Profile: WP ({profile.white_xy[0]:.4f}, {profile.white_xy[1]:.4f}), Y={profile.white_Y:.1f} cd/m2", + 0.85, + ) # Build residual correction LUT report("Building residual correction LUT...", 0.85) @@ -303,6 +317,7 @@ def profile_progress(msg, frac): # Apply via DWM try: from calibrate_pro.lut_system.dwm_lut import DwmLutController + dwm_ctrl = DwmLutController() if dwm_ctrl.is_available: dwm_ctrl.load_lut_file(display_index, lut_path) diff --git a/calibrate_pro/calibration/hybrid.py b/calibrate_pro/calibration/hybrid.py index 99f88e3..e00e48a 100644 --- a/calibrate_pro/calibration/hybrid.py +++ b/calibrate_pro/calibration/hybrid.py @@ -43,9 +43,10 @@ @dataclass class RefinementResult: """Result from one refinement iteration.""" + iteration: int delta_e_before: float # Average dE before this iteration's correction - delta_e_after: float # Average dE after (predicted from residual correction) + delta_e_after: float # Average dE after (predicted from residual correction) patches_measured: int residual_corrections: np.ndarray | None = None # 3x3 residual matrix @@ -53,6 +54,7 @@ class RefinementResult: @dataclass class HybridCalibrationResult: """Complete result from hybrid calibration.""" + success: bool = False message: str = "" @@ -77,18 +79,18 @@ class HybridCalibrationResult: # Standard verification patches — subset of ColorChecker for fast measurement # These 12 patches cover: neutrals, primaries, secondaries, skin tones QUICK_VERIFY_PATCHES = [ - ("White", (0.95, 0.95, 0.95)), + ("White", (0.95, 0.95, 0.95)), ("Neutral 80", (0.80, 0.80, 0.80)), ("Neutral 50", (0.50, 0.50, 0.50)), ("Neutral 20", (0.20, 0.20, 0.20)), - ("Red", (0.75, 0.15, 0.15)), - ("Green", (0.15, 0.60, 0.15)), - ("Blue", (0.15, 0.15, 0.75)), - ("Cyan", (0.15, 0.70, 0.70)), - ("Magenta", (0.70, 0.15, 0.70)), - ("Yellow", (0.80, 0.80, 0.15)), + ("Red", (0.75, 0.15, 0.15)), + ("Green", (0.15, 0.60, 0.15)), + ("Blue", (0.15, 0.15, 0.75)), + ("Cyan", (0.15, 0.70, 0.70)), + ("Magenta", (0.70, 0.15, 0.70)), + ("Yellow", (0.80, 0.80, 0.15)), ("Skin Light", (0.78, 0.58, 0.50)), - ("Skin Dark", (0.45, 0.32, 0.26)), + ("Skin Dark", (0.45, 0.32, 0.26)), ] @@ -115,7 +117,7 @@ def __init__( measure_fn: MeasureFn | None = None, max_iterations: int = 3, convergence_threshold: float = 0.5, # Stop when dE improvement < this - progress_fn: Callable[[str, float], None] | None = None + progress_fn: Callable[[str, float], None] | None = None, ): self.measure_fn = measure_fn self.max_iterations = max_iterations @@ -127,11 +129,7 @@ def _progress(self, message: str, pct: float): self.progress_fn(message, pct) def calibrate( - self, - panel, - output_dir: Path, - target: str = "native", - hdr_mode: bool = False + self, panel, output_dir: Path, target: str = "native", hdr_mode: bool = False ) -> HybridCalibrationResult: """ Run hybrid calibration. @@ -167,10 +165,7 @@ def calibrate( sensorless_verify = engine.verify_calibration(panel) result.sensorless_delta_e = sensorless_verify.get("delta_e_avg", 0.0) - self._progress( - f"Sensorless baseline: predicted dE {result.sensorless_delta_e:.2f}", - 0.2 - ) + self._progress(f"Sensorless baseline: predicted dE {result.sensorless_delta_e:.2f}", 0.2) # Step 2: If no colorimeter, stop here if self.measure_fn is None: @@ -209,13 +204,11 @@ def calibrate( for iteration in range(self.max_iterations): self._progress( f"Refinement iteration {iteration + 1}/{self.max_iterations}...", - 0.4 + 0.5 * (iteration / self.max_iterations) + 0.4 + 0.5 * (iteration / self.max_iterations), ) # Compute residual correction from measurements - residual_matrix = self._compute_residual_correction( - QUICK_VERIFY_PATCHES, measured_data - ) + residual_matrix = self._compute_residual_correction(QUICK_VERIFY_PATCHES, measured_data) if residual_matrix is None: break @@ -245,14 +238,13 @@ def calibrate( delta_e_before=prev_de, delta_e_after=float(new_avg_de), patches_measured=len(QUICK_VERIFY_PATCHES), - residual_corrections=residual_matrix + residual_corrections=residual_matrix, ) result.iterations.append(iter_result) self._progress( - f"Iteration {iteration + 1}: dE {prev_de:.2f} -> {new_avg_de:.2f} " - f"(improvement: {improvement:.2f})", - 0.4 + 0.5 * ((iteration + 1) / self.max_iterations) + f"Iteration {iteration + 1}: dE {prev_de:.2f} -> {new_avg_de:.2f} (improvement: {improvement:.2f})", + 0.4 + 0.5 * ((iteration + 1) / self.max_iterations), ) result.final_measured_delta_e = float(new_avg_de) @@ -264,10 +256,7 @@ def calibrate( # Check convergence if improvement < self.convergence_threshold: - self._progress( - f"Converged after {iteration + 1} iterations (dE {new_avg_de:.2f})", - 0.9 - ) + self._progress(f"Converged after {iteration + 1} iterations (dE {new_avg_de:.2f})", 0.9) break # Generate ICC profile @@ -306,9 +295,7 @@ def _measure_patches( return measurements def _compute_measured_delta_e( - self, - patches: list[tuple[str, tuple[float, float, float]]], - measured_xyz: list[tuple[float, float, float]] + self, patches: list[tuple[str, tuple[float, float, float]]], measured_xyz: list[tuple[float, float, float]] ) -> list[dict]: """Compute Delta E between expected and measured XYZ for each patch.""" results = [] @@ -327,22 +314,22 @@ def _compute_measured_delta_e( de = delta_e_2000(lab_meas, lab_exp) - results.append({ - "name": name, - "srgb": srgb, - "expected_xyz": tuple(xyz_expected), - "measured_xyz": xyz_measured, - "expected_lab": tuple(lab_exp), - "measured_lab": tuple(lab_meas), - "delta_e": float(de) - }) + results.append( + { + "name": name, + "srgb": srgb, + "expected_xyz": tuple(xyz_expected), + "measured_xyz": xyz_measured, + "expected_lab": tuple(lab_exp), + "measured_lab": tuple(lab_meas), + "delta_e": float(de), + } + ) return results def _compute_residual_correction( - self, - patches: list[tuple[str, tuple[float, float, float]]], - measured_xyz: list[tuple[float, float, float]] + self, patches: list[tuple[str, tuple[float, float, float]]], measured_xyz: list[tuple[float, float, float]] ) -> np.ndarray | None: """ Compute a 3x3 residual correction matrix from measurement error. @@ -365,29 +352,21 @@ def _compute_residual_correction( # Stack into matrices expected_mat = np.array(expected_list).T # 3 x N - measured_mat = np.array(measured_list).T # 3 x N + measured_mat = np.array(measured_list).T # 3 x N # Least-squares: find M such that expected ≈ M @ measured # M = expected @ measured^T @ (measured @ measured^T)^-1 try: - M = expected_mat @ measured_mat.T @ np.linalg.inv( - measured_mat @ measured_mat.T - ) + M = expected_mat @ measured_mat.T @ np.linalg.inv(measured_mat @ measured_mat.T) return M except np.linalg.LinAlgError: return None - def _apply_residual_to_lut( - self, lut, residual_matrix: np.ndarray - ): + def _apply_residual_to_lut(self, lut, residual_matrix: np.ndarray): """Apply a 3x3 residual correction to an existing 3D LUT.""" from calibrate_pro.core.lut_engine import LUT3D - refined = LUT3D( - size=lut.size, - data=lut.data.copy(), - title=lut.title + " (refined)" - ) + refined = LUT3D(size=lut.size, data=lut.data.copy(), title=lut.title + " (refined)") # Apply residual matrix to every LUT entry shape = refined.data.shape diff --git a/calibrate_pro/calibration/multi_display.py b/calibrate_pro/calibration/multi_display.py index 77ff820..3b3817f 100644 --- a/calibrate_pro/calibration/multi_display.py +++ b/calibrate_pro/calibration/multi_display.py @@ -15,28 +15,30 @@ @dataclass class DisplayTarget: """Matched calibration target for one display.""" + display_index: int display_name: str panel_type: str # Matched targets (common across all displays) target_white_xy: tuple[float, float] - target_luminance: float # cd/m2 — limited to weakest display's capability + target_luminance: float # cd/m2 — limited to weakest display's capability target_gamma: float # Per-display adjustments needed brightness_adjustment: float # DDC-CI brightness (0-100) - rgb_gain_r: float # DDC-CI red gain - rgb_gain_g: float # DDC-CI green gain - rgb_gain_b: float # DDC-CI blue gain + rgb_gain_r: float # DDC-CI red gain + rgb_gain_g: float # DDC-CI green gain + rgb_gain_b: float # DDC-CI blue gain @dataclass class MatchingResult: """Result from multi-display matching analysis.""" - matched_white: tuple[float, float] # Common white point (typically D65) - matched_luminance: float # Common achievable brightness - matched_gamma: float # Common gamma target + + matched_white: tuple[float, float] # Common white point (typically D65) + matched_luminance: float # Common achievable brightness + matched_gamma: float # Common gamma target per_display: list[DisplayTarget] notes: list[str] @@ -64,7 +66,7 @@ def analyze_matching(panels: list[dict]) -> MatchingResult: matched_luminance=120.0, matched_gamma=2.2, per_display=[], - notes=["No displays provided"] + notes=["No displays provided"], ) notes = [] @@ -120,7 +122,7 @@ def analyze_matching(panels: list[dict]) -> MatchingResult: brightness_adjustment=brightness_pct, rgb_gain_r=gains[0], rgb_gain_g=gains[1], - rgb_gain_b=gains[2] + rgb_gain_b=gains[2], ) per_display.append(dt) @@ -138,14 +140,11 @@ def analyze_matching(panels: list[dict]) -> MatchingResult: matched_luminance=matched_luminance, matched_gamma=target_gamma, per_display=per_display, - notes=notes + notes=notes, ) -def _compute_wp_gains( - panel_x: float, panel_y: float, - target_x: float, target_y: float -) -> tuple[float, float, float]: +def _compute_wp_gains(panel_x: float, panel_y: float, target_x: float, target_y: float) -> tuple[float, float, float]: """ Compute RGB gain adjustments to shift white point from panel to target. @@ -170,11 +169,7 @@ def _compute_wp_gains( g_gain /= max_gain b_gain /= max_gain - return ( - max(0.5, min(1.0, r_gain)), - max(0.5, min(1.0, g_gain)), - max(0.5, min(1.0, b_gain)) - ) + return (max(0.5, min(1.0, r_gain)), max(0.5, min(1.0, g_gain)), max(0.5, min(1.0, b_gain))) def print_matching_plan(result: MatchingResult): diff --git a/calibrate_pro/calibration/native_loop.py b/calibrate_pro/calibration/native_loop.py index ee35630..74c64fa 100644 --- a/calibrate_pro/calibration/native_loop.py +++ b/calibrate_pro/calibration/native_loop.py @@ -37,25 +37,27 @@ @dataclass class DisplayProfile: """Measured display characterization.""" - levels: np.ndarray # Signal levels used for TRC measurement - trc_r: np.ndarray # Red TRC (signal -> normalized linear) - trc_g: np.ndarray # Green TRC - trc_b: np.ndarray # Blue TRC - M_display: np.ndarray # 3x3 measured primaries-to-XYZ matrix (absolute) - white_Y: float # Peak white luminance (cd/m2) - black_xyz: np.ndarray # Black point XYZ (absolute) + + levels: np.ndarray # Signal levels used for TRC measurement + trc_r: np.ndarray # Red TRC (signal -> normalized linear) + trc_g: np.ndarray # Green TRC + trc_b: np.ndarray # Blue TRC + M_display: np.ndarray # 3x3 measured primaries-to-XYZ matrix (absolute) + white_Y: float # Peak white luminance (cd/m2) + black_xyz: np.ndarray # Black point XYZ (absolute) white_xy: tuple[float, float] # White point chromaticity - red_xy: tuple[float, float] # Red primary chromaticity + red_xy: tuple[float, float] # Red primary chromaticity green_xy: tuple[float, float] # Green primary chromaticity - blue_xy: tuple[float, float] # Blue primary chromaticity - gamma_r: float # Estimated red gamma - gamma_g: float # Estimated green gamma - gamma_b: float # Estimated blue gamma + blue_xy: tuple[float, float] # Blue primary chromaticity + gamma_r: float # Estimated red gamma + gamma_g: float # Estimated green gamma + gamma_b: float # Estimated blue gamma @dataclass class CalibrationResult: """Result of a calibration verification.""" + patch_name: str de_before: float de_after: float @@ -65,6 +67,7 @@ class CalibrationResult: @dataclass class PatchMeasurement: """Single patch measurement.""" + name: str srgb: tuple[float, float, float] xyz: np.ndarray @@ -74,52 +77,64 @@ class PatchMeasurement: # ColorChecker Classic reference Lab values (D50-adapted) COLORCHECKER_REF_LAB = { - "Dark Skin": (37.986, 13.555, 14.059), "Light Skin": (65.711, 18.130, 17.810), - "Blue Sky": (49.927, -4.880, -21.925), "Foliage": (43.139, -13.095, 21.905), - "Blue Flower": (55.112, 8.844, -25.399), "Bluish Green": (70.719, -33.397, -0.199), - "Orange": (62.661, 36.067, 57.096), "Purplish Blue": (40.020, 10.410, -45.964), - "Moderate Red": (51.124, 48.239, 16.248), "Purple": (30.325, 22.976, -21.587), - "Yellow Green": (72.532, -23.709, 57.255), "Orange Yellow": (71.941, 19.363, 67.857), - "Blue": (28.778, 14.179, -50.297), "Green": (55.261, -38.342, 31.370), - "Red": (42.101, 53.378, 28.190), "Yellow": (81.733, 4.039, 79.819), - "Magenta": (51.935, 49.986, -14.574), "Cyan": (51.038, -28.631, -28.638), - "White": (96.539, -0.425, 1.186), "Neutral 8": (81.257, -0.638, -0.335), - "Neutral 6.5": (66.766, -0.734, -0.504), "Neutral 5": (50.867, -0.153, -0.270), - "Neutral 3.5": (35.656, -0.421, -1.231), "Black": (20.461, -0.079, -0.973), + "Dark Skin": (37.986, 13.555, 14.059), + "Light Skin": (65.711, 18.130, 17.810), + "Blue Sky": (49.927, -4.880, -21.925), + "Foliage": (43.139, -13.095, 21.905), + "Blue Flower": (55.112, 8.844, -25.399), + "Bluish Green": (70.719, -33.397, -0.199), + "Orange": (62.661, 36.067, 57.096), + "Purplish Blue": (40.020, 10.410, -45.964), + "Moderate Red": (51.124, 48.239, 16.248), + "Purple": (30.325, 22.976, -21.587), + "Yellow Green": (72.532, -23.709, 57.255), + "Orange Yellow": (71.941, 19.363, 67.857), + "Blue": (28.778, 14.179, -50.297), + "Green": (55.261, -38.342, 31.370), + "Red": (42.101, 53.378, 28.190), + "Yellow": (81.733, 4.039, 79.819), + "Magenta": (51.935, 49.986, -14.574), + "Cyan": (51.038, -28.631, -28.638), + "White": (96.539, -0.425, 1.186), + "Neutral 8": (81.257, -0.638, -0.335), + "Neutral 6.5": (66.766, -0.734, -0.504), + "Neutral 5": (50.867, -0.153, -0.270), + "Neutral 3.5": (35.656, -0.421, -1.231), + "Black": (20.461, -0.079, -0.973), } # ColorChecker Classic sRGB values COLORCHECKER_SRGB = [ - ("Dark Skin", 0.453, 0.317, 0.264), - ("Light Skin", 0.779, 0.577, 0.505), - ("Blue Sky", 0.355, 0.480, 0.611), - ("Foliage", 0.352, 0.422, 0.253), - ("Blue Flower", 0.508, 0.502, 0.691), + ("Dark Skin", 0.453, 0.317, 0.264), + ("Light Skin", 0.779, 0.577, 0.505), + ("Blue Sky", 0.355, 0.480, 0.611), + ("Foliage", 0.352, 0.422, 0.253), + ("Blue Flower", 0.508, 0.502, 0.691), ("Bluish Green", 0.362, 0.745, 0.675), - ("Orange", 0.879, 0.485, 0.183), - ("Purplish Blue",0.266, 0.358, 0.667), + ("Orange", 0.879, 0.485, 0.183), + ("Purplish Blue", 0.266, 0.358, 0.667), ("Moderate Red", 0.778, 0.321, 0.381), - ("Purple", 0.367, 0.227, 0.414), + ("Purple", 0.367, 0.227, 0.414), ("Yellow Green", 0.623, 0.741, 0.246), - ("Orange Yellow",0.904, 0.634, 0.154), - ("Blue", 0.139, 0.248, 0.577), - ("Green", 0.262, 0.584, 0.291), - ("Red", 0.752, 0.197, 0.178), - ("Yellow", 0.938, 0.857, 0.159), - ("Magenta", 0.752, 0.313, 0.577), - ("Cyan", 0.121, 0.544, 0.659), - ("White", 0.961, 0.961, 0.961), - ("Neutral 8", 0.784, 0.784, 0.784), - ("Neutral 6.5", 0.584, 0.584, 0.584), - ("Neutral 5", 0.420, 0.420, 0.420), - ("Neutral 3.5", 0.258, 0.258, 0.258), - ("Black", 0.085, 0.085, 0.085), + ("Orange Yellow", 0.904, 0.634, 0.154), + ("Blue", 0.139, 0.248, 0.577), + ("Green", 0.262, 0.584, 0.291), + ("Red", 0.752, 0.197, 0.178), + ("Yellow", 0.938, 0.857, 0.159), + ("Magenta", 0.752, 0.313, 0.577), + ("Cyan", 0.121, 0.544, 0.659), + ("White", 0.961, 0.961, 0.961), + ("Neutral 8", 0.784, 0.784, 0.784), + ("Neutral 6.5", 0.584, 0.584, 0.584), + ("Neutral 5", 0.420, 0.420, 0.420), + ("Neutral 3.5", 0.258, 0.258, 0.258), + ("Black", 0.085, 0.085, 0.085), ] def compute_ccmx( - sensor_primaries: tuple[tuple[float,float], ...], - true_primaries: tuple[tuple[float,float], ...], + sensor_primaries: tuple[tuple[float, float], ...], + true_primaries: tuple[tuple[float, float], ...], ) -> np.ndarray: """ Compute a Colorimeter Correction Matrix (CCMX) from sensor-reported @@ -137,9 +152,11 @@ def compute_ccmx( Returns: 3x3 CCMX matrix. Usage: corrected_XYZ = CCMX @ sensor_XYZ """ + def xy_to_XYZ(x, y, Y=1.0): - if y == 0: return np.array([0.0, 0.0, 0.0]) - return np.array([(Y/y)*x, Y, (Y/y)*(1-x-y)]) + if y == 0: + return np.array([0.0, 0.0, 0.0]) + return np.array([(Y / y) * x, Y, (Y / y) * (1 - x - y)]) def build_matrix(r_xy, g_xy, b_xy, w_xy): R = xy_to_XYZ(*r_xy) @@ -226,7 +243,10 @@ def measure_ramp(make_color): black = white_xyz[0].copy() for arr in [white_xyz, red_xyz, green_xyz, blue_xyz]: arr -= black - white_xyz[0] = 0; red_xyz[0] = 0; green_xyz[0] = 0; blue_xyz[0] = 0 + white_xyz[0] = 0 + red_xyz[0] = 0 + green_xyz[0] = 0 + blue_xyz[0] = 0 white_Y = white_xyz[-1][1] R_xyz = red_xyz[-1] @@ -239,7 +259,8 @@ def normalize_trc(xyz_arr, primary_Y): trc = np.maximum(xyz_arr[:, 1], 0) if primary_Y > 0: trc /= primary_Y - trc[0] = 0.0; trc[-1] = 1.0 + trc[0] = 0.0 + trc[-1] = 1.0 for i in range(1, len(trc)): trc[i] = max(trc[i], trc[i - 1]) return trc @@ -257,7 +278,9 @@ def est_gamma(trc): return DisplayProfile( levels=levels, - trc_r=trc_r, trc_g=trc_g, trc_b=trc_b, + trc_r=trc_r, + trc_g=trc_g, + trc_b=trc_b, M_display=M_display, white_Y=white_Y, black_xyz=black, @@ -311,7 +334,7 @@ def build_correction_lut( dw_xy = _chromaticity(dw_norm) sw_xy = _chromaticity(sw_norm) - wp_shift = ((dw_xy[0] - sw_xy[0])**2 + (dw_xy[1] - sw_xy[1])**2)**0.5 + wp_shift = ((dw_xy[0] - sw_xy[0]) ** 2 + (dw_xy[1] - sw_xy[1]) ** 2) ** 0.5 if wp_shift > 0.005: source_cone = BRADFORD_MATRIX @ sw_norm @@ -321,12 +344,9 @@ def build_correction_lut( adapt = np.eye(3) # Inverse TRC interpolators - inv_trc_r = interp1d(profile.trc_r, levels, kind='linear', - bounds_error=False, fill_value=(0, 1)) - inv_trc_g = interp1d(profile.trc_g, levels, kind='linear', - bounds_error=False, fill_value=(0, 1)) - inv_trc_b = interp1d(profile.trc_b, levels, kind='linear', - bounds_error=False, fill_value=(0, 1)) + inv_trc_r = interp1d(profile.trc_r, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) + inv_trc_g = interp1d(profile.trc_g, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) + inv_trc_b = interp1d(profile.trc_b, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) # Generate LUT lut = LUT3D.create_identity(size) @@ -343,11 +363,17 @@ def build_correction_lut( target_xyz = adapt @ (SRGB_TO_XYZ @ linear) display_linear = np.clip(inv_M @ target_xyz, 0.0, 1.0) - full_corrected = np.clip(np.array([ - float(inv_trc_r(display_linear[0])), - float(inv_trc_g(display_linear[1])), - float(inv_trc_b(display_linear[2])), - ]), 0.0, 1.0) + full_corrected = np.clip( + np.array( + [ + float(inv_trc_r(display_linear[0])), + float(inv_trc_g(display_linear[1])), + float(inv_trc_b(display_linear[2])), + ] + ), + 0.0, + 1.0, + ) # Chroma-based blending max_c = max(r, g, b) diff --git a/calibrate_pro/calibration/targets.py b/calibrate_pro/calibration/targets.py index 40ef190..b8f2e1b 100644 --- a/calibrate_pro/calibration/targets.py +++ b/calibrate_pro/calibration/targets.py @@ -30,6 +30,7 @@ class CalibrationTarget: All chromaticity values are CIE 1931 xy coordinates. """ + name: str description: str @@ -41,22 +42,22 @@ class CalibrationTarget: white_cct: int # Correlated color temperature # Transfer function - eotf: str # "gamma", "srgb", "bt1886", "pq", "hlg" - gamma: float # Power-law gamma (2.2, 2.4, 2.6, etc.) + eotf: str # "gamma", "srgb", "bt1886", "pq", "hlg" + gamma: float # Power-law gamma (2.2, 2.4, 2.6, etc.) # Luminance - peak_luminance: float # cd/m2 (nits) - black_level: float # cd/m2 + peak_luminance: float # cd/m2 (nits) + black_level: float # cd/m2 sdr_reference_white: float = 100.0 # cd/m2 # Tolerances white_point_tolerance: float = 0.005 # Delta xy - gamma_tolerance: float = 0.1 # +/- from target - delta_e_target: float = 2.0 # Max acceptable dE2000 + gamma_tolerance: float = 0.1 # +/- from target + delta_e_target: float = 2.0 # Max acceptable dE2000 # Metadata - standard: str = "" # ITU/SMPTE/IEC standard number - category: str = "" # "broadcast", "cinema", "photography", "hdr", "web" + standard: str = "" # ITU/SMPTE/IEC standard number + category: str = "" # "broadcast", "cinema", "photography", "hdr", "web" notes: str = "" @@ -96,43 +97,57 @@ class CalibrationTarget: REC709_BT1886 = CalibrationTarget( name="Rec.709 / BT.1886", description="Broadcast SDR reference (100 nits, gamma 2.4, D65)", - red_xy=BT709_RED, green_xy=BT709_GREEN, blue_xy=BT709_BLUE, - white_xy=D65, white_cct=6504, - eotf="bt1886", gamma=2.4, - peak_luminance=100.0, black_level=0.05, + red_xy=BT709_RED, + green_xy=BT709_GREEN, + blue_xy=BT709_BLUE, + white_xy=D65, + white_cct=6504, + eotf="bt1886", + gamma=2.4, + peak_luminance=100.0, + black_level=0.05, white_point_tolerance=0.003, gamma_tolerance=0.10, delta_e_target=1.0, standard="ITU-R BT.1886", category="broadcast", notes="Standard for SDR broadcast grading. BT.1886 is not pure gamma 2.4 — " - "it models black level offset for CRT-like response.", + "it models black level offset for CRT-like response.", ) SRGB = CalibrationTarget( name="sRGB", description="Web/desktop standard (80 nits, sRGB TRC, D65)", - red_xy=BT709_RED, green_xy=BT709_GREEN, blue_xy=BT709_BLUE, - white_xy=D65, white_cct=6504, - eotf="srgb", gamma=2.2, - peak_luminance=80.0, black_level=0.2, + red_xy=BT709_RED, + green_xy=BT709_GREEN, + blue_xy=BT709_BLUE, + white_xy=D65, + white_cct=6504, + eotf="srgb", + gamma=2.2, + peak_luminance=80.0, + black_level=0.2, sdr_reference_white=80.0, white_point_tolerance=0.005, gamma_tolerance=0.15, delta_e_target=2.0, standard="IEC 61966-2-1", category="web", - notes="sRGB uses a piecewise TRC (linear toe + power), not pure gamma 2.2. " - "Effective gamma is ~2.2.", + notes="sRGB uses a piecewise TRC (linear toe + power), not pure gamma 2.2. Effective gamma is ~2.2.", ) ADOBE_RGB = CalibrationTarget( name="Adobe RGB (1998)", description="Wide-gamut photography standard (D65, gamma 2.2)", - red_xy=ADOBERGB_RED, green_xy=ADOBERGB_GREEN, blue_xy=ADOBERGB_BLUE, - white_xy=D65, white_cct=6504, - eotf="gamma", gamma=2.19921875, # Exact: 563/256 - peak_luminance=160.0, black_level=0.5, + red_xy=ADOBERGB_RED, + green_xy=ADOBERGB_GREEN, + blue_xy=ADOBERGB_BLUE, + white_xy=D65, + white_cct=6504, + eotf="gamma", + gamma=2.19921875, # Exact: 563/256 + peak_luminance=160.0, + black_level=0.5, white_point_tolerance=0.005, delta_e_target=2.0, standard="Adobe RGB (1998)", @@ -143,16 +158,20 @@ class CalibrationTarget: PRINT_PROOFING_D50 = CalibrationTarget( name="Print Proofing (D50)", description="ISO 3664 soft-proofing (D50, sRGB gamut, 120 nits)", - red_xy=BT709_RED, green_xy=BT709_GREEN, blue_xy=BT709_BLUE, - white_xy=D50, white_cct=5003, - eotf="srgb", gamma=2.2, - peak_luminance=120.0, black_level=0.5, + red_xy=BT709_RED, + green_xy=BT709_GREEN, + blue_xy=BT709_BLUE, + white_xy=D50, + white_cct=5003, + eotf="srgb", + gamma=2.2, + peak_luminance=120.0, + black_level=0.5, white_point_tolerance=0.003, delta_e_target=1.5, standard="ISO 3664", category="photography", - notes="D50 white point for ICC Profile Connection Space consistency. " - "Used when soft-proofing print output.", + notes="D50 white point for ICC Profile Connection Space consistency. Used when soft-proofing print output.", ) # ============================================================================= @@ -162,10 +181,15 @@ class CalibrationTarget: DCI_P3 = CalibrationTarget( name="DCI-P3", description="Digital Cinema Initiative (DCI white, gamma 2.6)", - red_xy=P3_RED, green_xy=P3_GREEN, blue_xy=P3_BLUE, - white_xy=D63_DCI, white_cct=6300, - eotf="gamma", gamma=2.6, - peak_luminance=48.0, black_level=0.005, # 48 cd/m2 = 14 fL + red_xy=P3_RED, + green_xy=P3_GREEN, + blue_xy=P3_BLUE, + white_xy=D63_DCI, + white_cct=6300, + eotf="gamma", + gamma=2.6, + peak_luminance=48.0, + black_level=0.005, # 48 cd/m2 = 14 fL white_point_tolerance=0.002, delta_e_target=1.0, standard="SMPTE 431-2", @@ -176,15 +200,19 @@ class CalibrationTarget: DISPLAY_P3 = CalibrationTarget( name="Display P3", description="Apple Display P3 (D65, sRGB TRC)", - red_xy=P3_RED, green_xy=P3_GREEN, blue_xy=P3_BLUE, - white_xy=D65, white_cct=6504, - eotf="srgb", gamma=2.2, - peak_luminance=500.0, black_level=0.05, + red_xy=P3_RED, + green_xy=P3_GREEN, + blue_xy=P3_BLUE, + white_xy=D65, + white_cct=6504, + eotf="srgb", + gamma=2.2, + peak_luminance=500.0, + black_level=0.05, delta_e_target=1.0, standard="Display P3 (Apple)", category="cinema", - notes="P3 primaries with D65 white and sRGB TRC. " - "Used by Apple devices and as the practical HDR gamut.", + notes="P3 primaries with D65 white and sRGB TRC. Used by Apple devices and as the practical HDR gamut.", ) # ============================================================================= @@ -194,25 +222,35 @@ class CalibrationTarget: HDR10_1000 = CalibrationTarget( name="HDR10 (1000 nits)", description="HDR10 mastering at 1000 nits peak (PQ, P3-D65 limited)", - red_xy=P3_RED, green_xy=P3_GREEN, blue_xy=P3_BLUE, - white_xy=D65, white_cct=6504, - eotf="pq", gamma=0.0, # PQ is not gamma-based - peak_luminance=1000.0, black_level=0.005, + red_xy=P3_RED, + green_xy=P3_GREEN, + blue_xy=P3_BLUE, + white_xy=D65, + white_cct=6504, + eotf="pq", + gamma=0.0, # PQ is not gamma-based + peak_luminance=1000.0, + black_level=0.005, sdr_reference_white=203.0, delta_e_target=1.0, standard="SMPTE ST.2084 + BT.2020", category="hdr", notes="Most common HDR mastering format. Container is Rec.2020 but content " - "is typically P3-D65 limited. Reference white at 203 nits per ITU-R BT.2408.", + "is typically P3-D65 limited. Reference white at 203 nits per ITU-R BT.2408.", ) HDR10_4000 = CalibrationTarget( name="HDR10 (4000 nits)", description="HDR10 premium mastering at 4000 nits peak", - red_xy=P3_RED, green_xy=P3_GREEN, blue_xy=P3_BLUE, - white_xy=D65, white_cct=6504, - eotf="pq", gamma=0.0, - peak_luminance=4000.0, black_level=0.005, + red_xy=P3_RED, + green_xy=P3_GREEN, + blue_xy=P3_BLUE, + white_xy=D65, + white_cct=6504, + eotf="pq", + gamma=0.0, + peak_luminance=4000.0, + black_level=0.005, sdr_reference_white=203.0, delta_e_target=1.0, standard="SMPTE ST.2084 + BT.2020", @@ -223,16 +261,21 @@ class CalibrationTarget: HLG = CalibrationTarget( name="HLG (Hybrid Log-Gamma)", description="Broadcast HDR (relative, self-scaling)", - red_xy=BT709_RED, green_xy=BT709_GREEN, blue_xy=BT709_BLUE, - white_xy=D65, white_cct=6504, - eotf="hlg", gamma=1.2, # System gamma - peak_luminance=1000.0, black_level=0.005, + red_xy=BT709_RED, + green_xy=BT709_GREEN, + blue_xy=BT709_BLUE, + white_xy=D65, + white_cct=6504, + eotf="hlg", + gamma=1.2, # System gamma + peak_luminance=1000.0, + black_level=0.005, sdr_reference_white=75.0, # Nominal reference delta_e_target=2.0, standard="ITU-R BT.2100", category="hdr", notes="Relative HDR standard. Self-scales to display capability. " - "Backwards compatible with SDR. Used by BBC, NHK, broadcasters.", + "Backwards compatible with SDR. Used by BBC, NHK, broadcasters.", ) # ============================================================================= @@ -242,65 +285,83 @@ class CalibrationTarget: NETFLIX_SDR = CalibrationTarget( name="Netflix SDR", description="Netflix SDR delivery specification", - red_xy=BT709_RED, green_xy=BT709_GREEN, blue_xy=BT709_BLUE, - white_xy=D65, white_cct=6504, - eotf="bt1886", gamma=2.4, - peak_luminance=100.0, black_level=0.05, + red_xy=BT709_RED, + green_xy=BT709_GREEN, + blue_xy=BT709_BLUE, + white_xy=D65, + white_cct=6504, + eotf="bt1886", + gamma=2.4, + peak_luminance=100.0, + black_level=0.05, white_point_tolerance=0.003, gamma_tolerance=0.05, delta_e_target=1.0, standard="Netflix Partner Help Center", category="broadcast", - notes="Rec.709, D65, BT.1886 at 100 nits. Contrast >= 2000:1. " - "Display must be calibrated within last 6 months.", + notes="Rec.709, D65, BT.1886 at 100 nits. Contrast >= 2000:1. Display must be calibrated within last 6 months.", ) NETFLIX_HDR = CalibrationTarget( name="Netflix HDR (Dolby Vision)", description="Netflix HDR/DV delivery specification", - red_xy=P3_RED, green_xy=P3_GREEN, blue_xy=P3_BLUE, - white_xy=D65, white_cct=6504, - eotf="pq", gamma=0.0, - peak_luminance=1000.0, black_level=0.005, + red_xy=P3_RED, + green_xy=P3_GREEN, + blue_xy=P3_BLUE, + white_xy=D65, + white_cct=6504, + eotf="pq", + gamma=0.0, + peak_luminance=1000.0, + black_level=0.005, sdr_reference_white=203.0, white_point_tolerance=0.002, delta_e_target=1.0, standard="Netflix Partner Help Center", category="hdr", - notes="P3-D65 limited, PQ, 1000 nits peak, 0.005 nits black. " - "Contrast >= 200,000:1. Dolby Vision compatible.", + notes="P3-D65 limited, PQ, 1000 nits peak, 0.005 nits black. Contrast >= 200,000:1. Dolby Vision compatible.", ) EBU_GRADE1 = CalibrationTarget( name="EBU Grade 1", description="EBU Tech 3320 Grade 1 broadcast reference", - red_xy=BT709_RED, green_xy=BT709_GREEN, blue_xy=BT709_BLUE, - white_xy=D65, white_cct=6504, - eotf="bt1886", gamma=2.4, - peak_luminance=100.0, black_level=0.05, + red_xy=BT709_RED, + green_xy=BT709_GREEN, + blue_xy=BT709_BLUE, + white_xy=D65, + white_cct=6504, + eotf="bt1886", + gamma=2.4, + peak_luminance=100.0, + black_level=0.05, white_point_tolerance=0.003, gamma_tolerance=0.10, delta_e_target=1.0, standard="EBU Tech 3320", category="broadcast", notes="European Broadcasting Union Grade 1 reference. " - "Gamma within +/-0.10 from 10%-90% input. " - "White point within 0.003 xy of D65.", + "Gamma within +/-0.10 from 10%-90% input. " + "White point within 0.003 xy of D65.", ) REC2020 = CalibrationTarget( name="Rec.2020", description="UHDTV wide gamut (BT.2020 primaries)", - red_xy=BT2020_RED, green_xy=BT2020_GREEN, blue_xy=BT2020_BLUE, - white_xy=D65, white_cct=6504, - eotf="pq", gamma=0.0, - peak_luminance=1000.0, black_level=0.005, + red_xy=BT2020_RED, + green_xy=BT2020_GREEN, + blue_xy=BT2020_BLUE, + white_xy=D65, + white_cct=6504, + eotf="pq", + gamma=0.0, + peak_luminance=1000.0, + black_level=0.005, sdr_reference_white=203.0, delta_e_target=2.0, standard="ITU-R BT.2020", category="hdr", notes="Full BT.2020 gamut. No current consumer display covers 100%. " - "Typically used as container with P3-D65 limited content.", + "Typically used as container with P3-D65 limited content.", ) # ============================================================================= diff --git a/calibrate_pro/community/database.py b/calibrate_pro/community/database.py index d697e65..e34ada1 100644 --- a/calibrate_pro/community/database.py +++ b/calibrate_pro/community/database.py @@ -29,17 +29,19 @@ # Data model # --------------------------------------------------------------------------- + @dataclass class PanelSubmission: """Data format for community panel submissions.""" + panel_key: str manufacturer: str model: str panel_type: str - primaries: dict # red/green/blue/white xy - gamma: dict # red/green/blue values + primaries: dict # red/green/blue/white xy + gamma: dict # red/green/blue values capabilities: dict - measured_by: str # submitter name + measured_by: str # submitter name measurement_date: str measurement_device: str # e.g., "i1Display Pro" notes: str = "" @@ -83,6 +85,7 @@ def from_dict(cls, data: dict) -> "PanelSubmission": # Export # --------------------------------------------------------------------------- + def export_panel(panel: PanelCharacterization, output_path: Path) -> Path: """ Export a panel characterization as a shareable community JSON file. @@ -145,6 +148,7 @@ def export_panel(panel: PanelCharacterization, output_path: Path) -> Path: # Import # --------------------------------------------------------------------------- + def import_panel(json_path: Path) -> PanelCharacterization: """ Import a panel from a community JSON file. @@ -174,9 +178,7 @@ def import_panel(json_path: Path) -> PanelCharacterization: data = json.load(fh) if not data.get("calibrate_pro_community"): - raise ValueError( - f"File does not appear to be a Calibrate Pro community panel: {json_path}" - ) + raise ValueError(f"File does not appear to be a Calibrate Pro community panel: {json_path}") prims = data["primaries"] gamma = data.get("gamma", {}) @@ -215,6 +217,7 @@ def import_panel(json_path: Path) -> PanelCharacterization: # Interactive submission helper # --------------------------------------------------------------------------- + def submit_panel_cli(): """Interactive CLI for submitting panel data.""" print("\n--- Community Panel Submission ---\n") @@ -225,6 +228,7 @@ def submit_panel_cli(): panel_type = input("Panel type (QD-OLED / WOLED / IPS / VA): ").strip() print("\nPrimaries (CIE 1931 xy chromaticity):") + def _read_xy(label: str) -> dict[str, float]: raw = input(f" {label} (x y): ").strip().split() return {"x": float(raw[0]), "y": float(raw[1])} @@ -285,6 +289,7 @@ def _read_xy(label: str) -> dict[str, float]: # CLI command handlers # --------------------------------------------------------------------------- + def cmd_export_panel(args) -> int: """CLI handler for the ``export-panel`` subcommand.""" from calibrate_pro import __version__ diff --git a/calibrate_pro/core/__init__.py b/calibrate_pro/core/__init__.py index c181cb0..46961ee 100644 --- a/calibrate_pro/core/__init__.py +++ b/calibrate_pro/core/__init__.py @@ -141,7 +141,6 @@ "xyz_to_srgb", "bradford_adapt", "delta_e_2000", - # ------------------------------------------------------------------------- # Advanced Color Models # ------------------------------------------------------------------------- @@ -160,7 +159,6 @@ "xyz_to_jzazbz", "rgb_to_ictcp", "delta_e_hdr", - # ------------------------------------------------------------------------- # ACES 2.0 # ------------------------------------------------------------------------- @@ -184,7 +182,6 @@ # Convenience "aces_to_srgb", "aces_to_hdr", - # ------------------------------------------------------------------------- # Advanced LUT Engine # ------------------------------------------------------------------------- diff --git a/calibrate_pro/core/aces.py b/calibrate_pro/core/aces.py index 29a1310..94f0015 100644 --- a/calibrate_pro/core/aces.py +++ b/calibrate_pro/core/aces.py @@ -37,52 +37,43 @@ # ACES AP0 primaries (ACES 2065-1) ACES_AP0_PRIMARIES = { - 'red': (0.7347, 0.2653), - 'green': (0.0000, 1.0000), - 'blue': (0.0001, -0.0770), - 'white': (0.32168, 0.33767) # ACES white point (D60-ish) + "red": (0.7347, 0.2653), + "green": (0.0000, 1.0000), + "blue": (0.0001, -0.0770), + "white": (0.32168, 0.33767), # ACES white point (D60-ish) } # ACES AP1 primaries (ACEScg, ACEScct) ACES_AP1_PRIMARIES = { - 'red': (0.713, 0.293), - 'green': (0.165, 0.830), - 'blue': (0.128, 0.044), - 'white': (0.32168, 0.33767) + "red": (0.713, 0.293), + "green": (0.165, 0.830), + "blue": (0.128, 0.044), + "white": (0.32168, 0.33767), } # Standard output primaries SRGB_PRIMARIES = { - 'red': (0.64, 0.33), - 'green': (0.30, 0.60), - 'blue': (0.15, 0.06), - 'white': (0.3127, 0.3290) # D65 + "red": (0.64, 0.33), + "green": (0.30, 0.60), + "blue": (0.15, 0.06), + "white": (0.3127, 0.3290), # D65 } -P3_D65_PRIMARIES = { - 'red': (0.680, 0.320), - 'green': (0.265, 0.690), - 'blue': (0.150, 0.060), - 'white': (0.3127, 0.3290) -} +P3_D65_PRIMARIES = {"red": (0.680, 0.320), "green": (0.265, 0.690), "blue": (0.150, 0.060), "white": (0.3127, 0.3290)} -BT2020_PRIMARIES = { - 'red': (0.708, 0.292), - 'green': (0.170, 0.797), - 'blue': (0.131, 0.046), - 'white': (0.3127, 0.3290) -} +BT2020_PRIMARIES = {"red": (0.708, 0.292), "green": (0.170, 0.797), "blue": (0.131, 0.046), "white": (0.3127, 0.3290)} def primaries_to_matrix(primaries: dict) -> np.ndarray: """Convert primaries dictionary to RGB->XYZ matrix.""" + def xy_to_XYZ(x, y): - return np.array([x/y, 1.0, (1-x-y)/y]) if y != 0 else np.array([0, 0, 0]) + return np.array([x / y, 1.0, (1 - x - y) / y]) if y != 0 else np.array([0, 0, 0]) - R = xy_to_XYZ(*primaries['red']) - G = xy_to_XYZ(*primaries['green']) - B = xy_to_XYZ(*primaries['blue']) - W = xy_to_XYZ(*primaries['white']) + R = xy_to_XYZ(*primaries["red"]) + G = xy_to_XYZ(*primaries["green"]) + B = xy_to_XYZ(*primaries["blue"]) + W = xy_to_XYZ(*primaries["white"]) M = np.column_stack([R, G, B]) S = np.linalg.solve(M, W) @@ -115,8 +106,10 @@ def xy_to_XYZ(x, y): # ACES 2.0 Output Transforms # ============================================================================= + class OutputDevice(Enum): """Standard output device configurations.""" + SDR_100_NITS = "sdr_100" SDR_CINEMA = "sdr_48" HDR_1000_NITS = "hdr_1000" @@ -128,71 +121,72 @@ class OutputDevice(Enum): @dataclass class OutputConfig: """Configuration for ACES 2.0 output transform.""" + peak_luminance: float # Peak white in cd/m² min_luminance: float # Black level in cd/m² limiting_primaries: dict # Output gamut primaries encoding_primaries: dict # Display encoding primaries - surround: str = 'dim' # Viewing surround ('dark', 'dim', 'average') - eotf: str = 'srgb' # 'srgb', 'bt1886', 'pq', 'hlg' + surround: str = "dim" # Viewing surround ('dark', 'dim', 'average') + eotf: str = "srgb" # 'srgb', 'bt1886', 'pq', 'hlg' @classmethod - def sdr_100_srgb(cls) -> 'OutputConfig': + def sdr_100_srgb(cls) -> "OutputConfig": """Standard SDR sRGB monitor (100 nits).""" return cls( peak_luminance=100.0, min_luminance=0.0001, limiting_primaries=SRGB_PRIMARIES, encoding_primaries=SRGB_PRIMARIES, - surround='dim', - eotf='srgb' + surround="dim", + eotf="srgb", ) @classmethod - def sdr_100_p3(cls) -> 'OutputConfig': + def sdr_100_p3(cls) -> "OutputConfig": """SDR P3-D65 monitor (100 nits).""" return cls( peak_luminance=100.0, min_luminance=0.0001, limiting_primaries=P3_D65_PRIMARIES, encoding_primaries=P3_D65_PRIMARIES, - surround='dim', - eotf='srgb' + surround="dim", + eotf="srgb", ) @classmethod - def hdr_1000_p3(cls) -> 'OutputConfig': + def hdr_1000_p3(cls) -> "OutputConfig": """HDR P3-D65 monitor (1000 nits).""" return cls( peak_luminance=1000.0, min_luminance=0.0001, limiting_primaries=P3_D65_PRIMARIES, encoding_primaries=P3_D65_PRIMARIES, - surround='dim', - eotf='pq' + surround="dim", + eotf="pq", ) @classmethod - def hdr_1000_bt2020(cls) -> 'OutputConfig': + def hdr_1000_bt2020(cls) -> "OutputConfig": """HDR BT.2020 (1000 nits).""" return cls( peak_luminance=1000.0, min_luminance=0.0001, limiting_primaries=BT2020_PRIMARIES, encoding_primaries=BT2020_PRIMARIES, - surround='dim', - eotf='pq' + surround="dim", + eotf="pq", ) @classmethod - def hdr_4000_p3(cls) -> 'OutputConfig': + def hdr_4000_p3(cls) -> "OutputConfig": """HDR P3-D65 mastering monitor (4000 nits).""" return cls( peak_luminance=4000.0, min_luminance=0.0001, limiting_primaries=P3_D65_PRIMARIES, encoding_primaries=P3_D65_PRIMARIES, - surround='dark', - eotf='pq' + surround="dark", + eotf="pq", ) @@ -200,6 +194,7 @@ def hdr_4000_p3(cls) -> 'OutputConfig': # ACES 2.0 Tonescale # ============================================================================= + class ACES2Tonescale: """ ACES 2.0 Tonescale Function. @@ -216,12 +211,12 @@ class ACES2Tonescale: # Tonescale parameters (ACES 2.0 defaults) PARAMS = { - 'contrast': 1.5, - 'pivot': 0.18, # Middle gray - 'toe_power': 2.0, - 'shoulder_power': 1.5, - 'toe_gain': 0.0, - 'shoulder_gain': 0.9 + "contrast": 1.5, + "pivot": 0.18, # Middle gray + "toe_power": 2.0, + "shoulder_power": 1.5, + "toe_gain": 0.0, + "shoulder_gain": 0.9, } def __init__(self, peak_luminance: float = 100.0, min_luminance: float = 0.0001): @@ -244,8 +239,8 @@ def __init__(self, peak_luminance: float = 100.0, min_luminance: float = 0.0001) def _compute_curve_params(self): """Compute adaptive tonescale parameters based on output.""" # Base parameters - self.contrast = self.PARAMS['contrast'] - self.pivot = self.PARAMS['pivot'] + self.contrast = self.PARAMS["contrast"] + self.pivot = self.PARAMS["pivot"] # Adapt toe and shoulder based on dynamic range if self.peak >= 1000: # HDR @@ -303,8 +298,7 @@ def apply(self, J: np.ndarray) -> np.ndarray: x = J[shoulder_mask] / self.shoulder_start compressed = 1.0 - np.power(1.0 - self.highlight_gain, np.power(x, self.shoulder_power)) # Scale to output range - result[shoulder_mask] = self.shoulder_start + \ - (self.peak - self.shoulder_start) * compressed / self.peak + result[shoulder_mask] = self.shoulder_start + (self.peak - self.shoulder_start) * compressed / self.peak # Normalize to [0, 1] range for output result = result / self.peak @@ -334,7 +328,12 @@ def apply_inverse(self, J_out: np.ndarray) -> np.ndarray: result[toe_mask] = self.toe_start * np.power(t, 1.0 / self.toe_power) # Inverse linear - linear_end = self.shoulder_start / self.pivot * np.power(self.shoulder_start / self.pivot, 1.0 / self.contrast) * self.pivot + linear_end = ( + self.shoulder_start + / self.pivot + * np.power(self.shoulder_start / self.pivot, 1.0 / self.contrast) + * self.pivot + ) linear_mask = (J_out >= toe_end) & (J_out < linear_end) if np.any(linear_mask): result[linear_mask] = self.pivot * np.power(J_out[linear_mask] / self.pivot, self.contrast) @@ -354,6 +353,7 @@ def apply_inverse(self, J_out: np.ndarray) -> np.ndarray: # ACES 2.0 Gamut Mapper # ============================================================================= + class ACES2GamutMapper: """ ACES 2.0 Gamut Mapper using JMh color space. @@ -384,7 +384,7 @@ def __init__(self, output_primaries: dict, peak_luminance: float = 100.0): vc = CAM16ViewingConditions( L_A=peak_luminance * 0.2, # 20% of peak for adaptation Y_b=20.0, - surround='dim' + surround="dim", ) self.cam16 = CAM16(vc) @@ -413,8 +413,8 @@ def _compute_gamut_boundary(self): rgb = np.array(edge) xyz = output_matrix @ rgb result = self.cam16.xyz_to_cam16(xyz) - if abs(result['h'] - h) < 5 or abs(result['h'] - h - 360) < 5: - max_M = max(max_M, result['M']) + if abs(result["h"] - h) < 5 or abs(result["h"] - h - 360) < 5: + max_M = max(max_M, result["M"]) self.boundary_M[i] = max(max_M, 1.0) # Minimum boundary def _get_gamut_edges(self) -> list[tuple[float, float, float]]: @@ -425,15 +425,15 @@ def _get_gamut_edges(self) -> list[tuple[float, float, float]]: # Red to yellow edges.append((1, t, 0)) # Yellow to green - edges.append((1-t, 1, 0)) + edges.append((1 - t, 1, 0)) # Green to cyan edges.append((0, 1, t)) # Cyan to blue - edges.append((0, 1-t, 1)) + edges.append((0, 1 - t, 1)) # Blue to magenta edges.append((t, 0, 1)) # Magenta to red - edges.append((1, 0, 1-t)) + edges.append((1, 0, 1 - t)) return edges def _get_boundary_at_hue(self, h: float) -> float: @@ -517,6 +517,7 @@ def compress_chroma(self, J: float, M: float, h: float) -> tuple[float, float, f # ACES 2.0 Main Pipeline # ============================================================================= + class ACES2: """ Academy Color Encoding System 2.0 - Complete Rendering Pipeline. @@ -539,7 +540,7 @@ class ACES2: output = aces.render(aces_rgb, OutputConfig.hdr_1000_p3()) """ - def __init__(self, working_space: str = 'ap1'): + def __init__(self, working_space: str = "ap1"): """ Initialize ACES 2.0 pipeline. @@ -548,7 +549,7 @@ def __init__(self, working_space: str = 'ap1'): """ self.working_space = working_space - if working_space == 'ap0': + if working_space == "ap0": self.to_xyz = AP0_TO_XYZ self.from_xyz = XYZ_TO_AP0 else: @@ -575,8 +576,7 @@ def render(self, rgb: np.ndarray, output_config: OutputConfig) -> np.ndarray: # Initialize pipeline components tonescale = ACES2Tonescale(output_config.peak_luminance, output_config.min_luminance) - gamut_mapper = ACES2GamutMapper(output_config.limiting_primaries, - output_config.peak_luminance) + gamut_mapper = ACES2GamutMapper(output_config.limiting_primaries, output_config.peak_luminance) results = [] for i in range(len(rgb)): @@ -588,9 +588,9 @@ def render(self, rgb: np.ndarray, output_config: OutputConfig) -> np.ndarray: return results[0] return results - def _render_single(self, rgb: np.ndarray, config: OutputConfig, - tonescale: ACES2Tonescale, - gamut_mapper: ACES2GamutMapper) -> np.ndarray: + def _render_single( + self, rgb: np.ndarray, config: OutputConfig, tonescale: ACES2Tonescale, gamut_mapper: ACES2GamutMapper + ) -> np.ndarray: """Render single ACES RGB pixel.""" # Step 1: Convert to XYZ xyz = self.to_xyz @ rgb @@ -600,9 +600,9 @@ def _render_single(self, rgb: np.ndarray, config: OutputConfig, # Step 2: Convert to CAM16 JMh cam_result = gamut_mapper.cam16.xyz_to_cam16(xyz) - J = cam_result['J'] - M = cam_result['M'] - h = cam_result['h'] + J = cam_result["J"] + M = cam_result["M"] + h = cam_result["h"] # Step 3: Apply tonescale (J only) J_tonemapped = tonescale.apply(np.array([J]))[0] * 100.0 @@ -640,15 +640,15 @@ def apply_eotf(self, rgb: np.ndarray, eotf: str, peak_luminance: float = 100.0) """ rgb = np.asarray(rgb, dtype=np.float64) - if eotf == 'srgb': + if eotf == "srgb": return self._srgb_oetf(rgb) - elif eotf == 'bt1886': + elif eotf == "bt1886": return self._bt1886_oetf(rgb, gamma=2.4) - elif eotf == 'pq': + elif eotf == "pq": # Scale to absolute luminance then apply PQ rgb_abs = rgb * peak_luminance return pq_oetf(rgb_abs) - elif eotf == 'hlg': + elif eotf == "hlg": return self._hlg_oetf(rgb) else: return rgb # Linear passthrough @@ -658,7 +658,7 @@ def _srgb_oetf(self, rgb: np.ndarray) -> np.ndarray: result = np.zeros_like(rgb) mask = rgb <= 0.0031308 result[mask] = 12.92 * rgb[mask] - result[~mask] = 1.055 * np.power(rgb[~mask], 1.0/2.4) - 0.055 + result[~mask] = 1.055 * np.power(rgb[~mask], 1.0 / 2.4) - 0.055 return np.clip(result, 0, 1) def _bt1886_oetf(self, rgb: np.ndarray, gamma: float = 2.4) -> np.ndarray: @@ -672,7 +672,7 @@ def _hlg_oetf(self, rgb: np.ndarray) -> np.ndarray: c = 0.55991073 result = np.zeros_like(rgb) - mask = rgb <= 1/12 + mask = rgb <= 1 / 12 result[mask] = np.sqrt(3 * rgb[mask]) result[~mask] = a * np.log(12 * rgb[~mask] - b) + c @@ -683,6 +683,7 @@ def _hlg_oetf(self, rgb: np.ndarray) -> np.ndarray: # ACES 2.0 Look Modification Transform (LMT) # ============================================================================= + class ACES2LookTransform: """ ACES 2.0 Look Modification Transform. @@ -730,17 +731,17 @@ def apply(self, rgb: np.ndarray) -> np.ndarray: return np.maximum(rgb, 0) - def set_exposure(self, stops: float) -> 'ACES2LookTransform': + def set_exposure(self, stops: float) -> "ACES2LookTransform": """Set exposure adjustment in stops.""" self.exposure = stops return self - def set_saturation(self, multiplier: float) -> 'ACES2LookTransform': + def set_saturation(self, multiplier: float) -> "ACES2LookTransform": """Set saturation multiplier (1.0 = neutral).""" self.saturation = multiplier return self - def set_contrast(self, multiplier: float, pivot: float = 0.18) -> 'ACES2LookTransform': + def set_contrast(self, multiplier: float, pivot: float = 0.18) -> "ACES2LookTransform": """Set contrast multiplier around pivot point.""" self.contrast = multiplier self.pivot = pivot @@ -751,8 +752,8 @@ def set_contrast(self, multiplier: float, pivot: float = 0.18) -> 'ACES2LookTran # OCIO 2.4 Compatibility # ============================================================================= -def generate_ocio_config(output_configs: list[OutputConfig], - config_name: str = "ACES 2.0") -> str: + +def generate_ocio_config(output_configs: list[OutputConfig], config_name: str = "ACES 2.0") -> str: """ Generate OpenColorIO 2.4 configuration for ACES 2.0. @@ -829,7 +830,8 @@ def generate_ocio_config(output_configs: list[OutputConfig], # Convenience Functions # ============================================================================= -def aces_to_srgb(rgb: np.ndarray, input_space: str = 'ap1') -> np.ndarray: + +def aces_to_srgb(rgb: np.ndarray, input_space: str = "ap1") -> np.ndarray: """ Quick conversion from ACES to sRGB display. @@ -843,11 +845,12 @@ def aces_to_srgb(rgb: np.ndarray, input_space: str = 'ap1') -> np.ndarray: aces = ACES2(working_space=input_space) config = OutputConfig.sdr_100_srgb() linear = aces.render(rgb, config) - return aces.apply_eotf(linear, 'srgb') + return aces.apply_eotf(linear, "srgb") -def aces_to_hdr(rgb: np.ndarray, peak_luminance: float = 1000.0, - gamut: str = 'p3', input_space: str = 'ap1') -> np.ndarray: +def aces_to_hdr( + rgb: np.ndarray, peak_luminance: float = 1000.0, gamut: str = "p3", input_space: str = "ap1" +) -> np.ndarray: """ Quick conversion from ACES to HDR display. @@ -862,7 +865,7 @@ def aces_to_hdr(rgb: np.ndarray, peak_luminance: float = 1000.0, """ aces = ACES2(working_space=input_space) - if gamut == 'p3': + if gamut == "p3": config = OutputConfig.hdr_1000_p3() else: config = OutputConfig.hdr_1000_bt2020() @@ -870,4 +873,4 @@ def aces_to_hdr(rgb: np.ndarray, peak_luminance: float = 1000.0, config.peak_luminance = peak_luminance linear = aces.render(rgb, config) - return aces.apply_eotf(linear, 'pq', peak_luminance) + return aces.apply_eotf(linear, "pq", peak_luminance) diff --git a/calibrate_pro/core/calibration_engine.py b/calibrate_pro/core/calibration_engine.py index 37251f0..05b3fed 100644 --- a/calibrate_pro/core/calibration_engine.py +++ b/calibrate_pro/core/calibration_engine.py @@ -35,44 +35,54 @@ if TYPE_CHECKING: from calibrate_pro.hardware.colorimeter_base import ColorimeterBase + class CalibrationMode(Enum): """Calibration mode selection.""" - SENSORLESS = "sensorless" # Sensorless panel database - COLORIMETER = "colorimeter" # Hardware colorimeter + + SENSORLESS = "sensorless" # Sensorless panel database + COLORIMETER = "colorimeter" # Hardware colorimeter SPECTRO = "spectrophotometer" # Hardware spectrophotometer - HYBRID = "hybrid" # Sensorless + colorimeter verification + HYBRID = "hybrid" # Sensorless + colorimeter verification + class GammaTarget(Enum): """Gamma/EOTF target selection.""" - POWER_22 = "2.2" # Simple power law gamma 2.2 - POWER_24 = "2.4" # Simple power law gamma 2.4 - SRGB = "sRGB" # sRGB piecewise function - BT1886 = "BT.1886" # Broadcast standard - LSTAR = "L*" # CIE L* perceptual - CUSTOM = "custom" # User-defined + + POWER_22 = "2.2" # Simple power law gamma 2.2 + POWER_24 = "2.4" # Simple power law gamma 2.4 + SRGB = "sRGB" # sRGB piecewise function + BT1886 = "BT.1886" # Broadcast standard + LSTAR = "L*" # CIE L* perceptual + CUSTOM = "custom" # User-defined + class WhitepointTarget(Enum): """White point target selection.""" - D50 = "D50" # 5003K print standard - D55 = "D55" # 5503K daylight - D65 = "D65" # 6504K sRGB/broadcast - D75 = "D75" # 7504K north sky - DCI = "DCI" # 6300K DCI-P3 - NATIVE = "native" # Display native - CUSTOM = "custom" # User-defined CCT + + D50 = "D50" # 5003K print standard + D55 = "D55" # 5503K daylight + D65 = "D65" # 6504K sRGB/broadcast + D75 = "D75" # 7504K north sky + DCI = "DCI" # 6300K DCI-P3 + NATIVE = "native" # Display native + CUSTOM = "custom" # User-defined CCT + class GamutTarget(Enum): """Color gamut target selection.""" - SRGB = "sRGB" # Standard web/consumer - DCI_P3 = "DCI-P3" # Wide gamut cinema - BT2020 = "BT.2020" # Ultra-wide HDR - ADOBE_RGB = "Adobe RGB" # Wide gamut photography - NATIVE = "native" # Display native gamut - CUSTOM = "custom" # User-defined primaries + + SRGB = "sRGB" # Standard web/consumer + DCI_P3 = "DCI-P3" # Wide gamut cinema + BT2020 = "BT.2020" # Ultra-wide HDR + ADOBE_RGB = "Adobe RGB" # Wide gamut photography + NATIVE = "native" # Display native gamut + CUSTOM = "custom" # User-defined primaries + @dataclass class CalibrationTarget: """Target calibration parameters.""" + whitepoint: WhitepointTarget = WhitepointTarget.D65 whitepoint_xy: tuple[float, float] | None = None whitepoint_cct: int | None = None @@ -109,32 +119,18 @@ def get_gamut_primaries(self) -> tuple[tuple[float, float], ...]: return self.gamut_primaries gamuts = { - GamutTarget.SRGB: ( - (0.6400, 0.3300), - (0.3000, 0.6000), - (0.1500, 0.0600) - ), - GamutTarget.DCI_P3: ( - (0.6800, 0.3200), - (0.2650, 0.6900), - (0.1500, 0.0600) - ), - GamutTarget.BT2020: ( - (0.7080, 0.2920), - (0.1700, 0.7970), - (0.1310, 0.0460) - ), - GamutTarget.ADOBE_RGB: ( - (0.6400, 0.3300), - (0.2100, 0.7100), - (0.1500, 0.0600) - ), + GamutTarget.SRGB: ((0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600)), + GamutTarget.DCI_P3: ((0.6800, 0.3200), (0.2650, 0.6900), (0.1500, 0.0600)), + GamutTarget.BT2020: ((0.7080, 0.2920), (0.1700, 0.7970), (0.1310, 0.0460)), + GamutTarget.ADOBE_RGB: ((0.6400, 0.3300), (0.2100, 0.7100), (0.1500, 0.0600)), } return gamuts.get(self.gamut, gamuts[GamutTarget.SRGB]) + @dataclass class CalibrationResult: """Results from calibration process.""" + success: bool = False panel_name: str = "" panel_type: str = "" @@ -170,9 +166,10 @@ def to_dict(self) -> dict: "grade": self.grade, "icc_profile": str(self.icc_profile_path) if self.icc_profile_path else None, "lut": str(self.lut_path) if self.lut_path else None, - "timestamp": self.timestamp.isoformat() + "timestamp": self.timestamp.isoformat(), } + class CalibrationEngine: """ Main calibration engine. @@ -180,11 +177,7 @@ class CalibrationEngine: Orchestrates sensorless and hardware calibration workflows. """ - def __init__( - self, - mode: CalibrationMode = CalibrationMode.SENSORLESS, - panel_database: PanelDatabase | None = None - ): + def __init__(self, mode: CalibrationMode = CalibrationMode.SENSORLESS, panel_database: PanelDatabase | None = None): """ Initialize calibration engine. @@ -249,7 +242,7 @@ def calibrate_sensorless( output_dir: Path, generate_icc: bool = True, generate_lut: bool = True, - lut_size: int = 33 + lut_size: int = 33, ) -> CalibrationResult: """ Perform sensorless calibration. @@ -274,7 +267,7 @@ def calibrate_sensorless( mode=CalibrationMode.SENSORLESS, target=self.target, panel_name=f"{panel.manufacturer} {panel.model_pattern.split('|')[0]}", - panel_type=panel.panel_type + panel_type=panel.panel_type, ) output_dir = Path(output_dir) @@ -329,7 +322,7 @@ def calibrate_hardware( generate_lut: bool = True, lut_size: int = 33, patch_count: int = 729, - display_callback: Callable | None = None + display_callback: Callable | None = None, ) -> CalibrationResult: """ Perform hardware colorimeter calibration. @@ -350,14 +343,14 @@ def calibrate_hardware( # Try to auto-connect try: from calibrate_pro.hardware import auto_connect + self.colorimeter = auto_connect() except (ImportError, OSError, RuntimeError): pass if self.colorimeter is None: raise RuntimeError( - "No colorimeter connected. Call set_colorimeter() or " - "connect a device before hardware calibration." + "No colorimeter connected. Call set_colorimeter() or connect a device before hardware calibration." ) self._report_progress("Starting hardware calibration...", 0.05) @@ -369,7 +362,7 @@ def calibrate_hardware( mode=self.mode, target=self.target, panel_name=f"{panel.manufacturer} {panel.model_pattern.split('|')[0]}", - panel_type=panel.panel_type + panel_type=panel.panel_type, ) output_dir = Path(output_dir) @@ -384,15 +377,11 @@ def calibrate_hardware( # Measure white point self._report_progress("Measuring white point...", 0.15) - white_measurement = self._measure_with_display( - (1.0, 1.0, 1.0), display_callback - ) + white_measurement = self._measure_with_display((1.0, 1.0, 1.0), display_callback) # Measure black level self._report_progress("Measuring black level...", 0.2) - black_measurement = self._measure_with_display( - (0.0, 0.0, 0.0), display_callback - ) + black_measurement = self._measure_with_display((0.0, 0.0, 0.0), display_callback) # Measure primaries self._report_progress("Measuring primaries...", 0.25) @@ -405,9 +394,7 @@ def calibrate_hardware( # Generate profiling patches if needed for full profile if patch_count > 0 and generate_icc: self._report_progress("Measuring profiling patches...", 0.5) - profiling_data = self._measure_profiling_patches( - patch_count, display_callback - ) + profiling_data = self._measure_profiling_patches(patch_count, display_callback) else: profiling_data = None @@ -417,7 +404,7 @@ def calibrate_hardware( "black": black_measurement, "primaries": primaries, "grayscale": grayscale, - "profiling": profiling_data + "profiling": profiling_data, } # Generate ICC profile from measurements @@ -457,7 +444,7 @@ def calibrate_hybrid( generate_icc: bool = True, generate_lut: bool = True, lut_size: int = 33, - display_callback: Callable | None = None + display_callback: Callable | None = None, ) -> CalibrationResult: """ Perform hybrid calibration: sensorless + hardware verification. @@ -480,14 +467,13 @@ def calibrate_hybrid( # Step 1: Sensorless calibration self._report_progress("Phase 1: Sensorless calibration...", 0.1) - sensorless_result = self.calibrate_sensorless( - model_string, output_dir, generate_icc, generate_lut, lut_size - ) + sensorless_result = self.calibrate_sensorless(model_string, output_dir, generate_icc, generate_lut, lut_size) # If no colorimeter, return sensorless result if self.colorimeter is None: try: from calibrate_pro.hardware import auto_connect + self.colorimeter = auto_connect() except (ImportError, OSError, RuntimeError): pass @@ -517,7 +503,7 @@ def calibrate_hybrid( delta_e_max=verification["delta_e_max"], grade=verification["grade"], patch_results=verification["patches"], - success=True + success=True, ) # If hardware verification shows significant error, refine @@ -530,9 +516,7 @@ def calibrate_hybrid( return result def _measure_with_display( - self, - rgb: tuple[float, float, float], - display_callback: Callable | None = None + self, rgb: tuple[float, float, float], display_callback: Callable | None = None ) -> dict | None: """ Display a color and measure it. @@ -546,6 +530,7 @@ def _measure_with_display( """ if display_callback: from calibrate_pro.hardware.colorimeter_base import CalibrationPatch + patch = CalibrationPatch(r=rgb[0], g=rgb[1], b=rgb[2]) display_callback(patch) time.sleep(0.5) # Wait for display to settle @@ -557,14 +542,11 @@ def _measure_with_display( "XYZ": (measurement.X, measurement.Y, measurement.Z), "xy": (measurement.x, measurement.y), "luminance": measurement.luminance, - "cct": measurement.cct + "cct": measurement.cct, } return None - def _measure_primaries( - self, - display_callback: Callable | None = None - ) -> dict[str, dict]: + def _measure_primaries(self, display_callback: Callable | None = None) -> dict[str, dict]: """Measure display primaries.""" primaries = {} @@ -583,11 +565,7 @@ def _measure_primaries( return primaries - def _measure_grayscale( - self, - steps: int = 21, - display_callback: Callable | None = None - ) -> list[dict]: + def _measure_grayscale(self, steps: int = 21, display_callback: Callable | None = None) -> list[dict]: """Measure grayscale ramp.""" results = [] @@ -601,11 +579,7 @@ def _measure_grayscale( return results - def _measure_profiling_patches( - self, - count: int, - display_callback: Callable | None = None - ) -> list[dict]: + def _measure_profiling_patches(self, count: int, display_callback: Callable | None = None) -> list[dict]: """Measure profiling patch set.""" from calibrate_pro.hardware.colorimeter_base import generate_profiling_patches @@ -615,24 +589,15 @@ def _measure_profiling_patches( for i, patch in enumerate(patches): if i % 50 == 0: progress = 0.5 + (i / len(patches)) * 0.2 - self._report_progress( - f"Measuring patch {i+1}/{len(patches)}...", - progress - ) + self._report_progress(f"Measuring patch {i + 1}/{len(patches)}...", progress) - measurement = self._measure_with_display( - (patch.r, patch.g, patch.b), display_callback - ) + measurement = self._measure_with_display((patch.r, patch.g, patch.b), display_callback) if measurement: results.append(measurement) return results - def _create_icc_from_measurements( - self, - cal_data: dict, - panel: PanelCharacterization - ) -> ICCProfile: + def _create_icc_from_measurements(self, cal_data: dict, panel: PanelCharacterization) -> ICCProfile: """Create ICC profile from measurement data.""" # Extract measured primaries primaries = cal_data.get("primaries", {}) @@ -653,15 +618,12 @@ def _create_icc_from_measurements( green_xy=green_xy, blue_xy=blue_xy, white_xy=white_xy, - gamma=gamma + gamma=gamma, ) return profile - def _calculate_gamma_from_grayscale( - self, - grayscale: list[dict] - ) -> float: + def _calculate_gamma_from_grayscale(self, grayscale: list[dict]) -> float: """Calculate effective gamma from grayscale measurements.""" if len(grayscale) < 3: return 2.2 # Default @@ -693,19 +655,14 @@ def _calculate_gamma_from_grayscale( return max(1.8, min(3.0, gamma)) - def _create_lut_from_measurements( - self, - cal_data: dict, - panel: PanelCharacterization, - size: int = 33 - ) -> LUT3D: + def _create_lut_from_measurements(self, cal_data: dict, panel: PanelCharacterization, size: int = 33) -> LUT3D: """Create 3D LUT from measurement data.""" # Build correction LUT based on measurements generator = LUTGenerator( source_primaries=panel.native_primaries, target_primaries=None, # Will use measured source_gamma=(panel.gamma_red.gamma, panel.gamma_green.gamma, panel.gamma_blue.gamma), - target_gamma=self.target.gamma_value + target_gamma=self.target.gamma_value, ) # Apply measurement-based corrections @@ -713,10 +670,7 @@ def _create_lut_from_measurements( return lut - def _verify_hardware( - self, - display_callback: Callable | None = None - ) -> dict: + def _verify_hardware(self, display_callback: Callable | None = None) -> dict: """Verify calibration with hardware measurements.""" from calibrate_pro.hardware.colorimeter_base import generate_verification_patches @@ -728,9 +682,7 @@ def _verify_hardware( reference = get_colorchecker_reference() for i, patch in enumerate(patches): - measurement = self._measure_with_display( - (patch.r, patch.g, patch.b), display_callback - ) + measurement = self._measure_with_display((patch.r, patch.g, patch.b), display_callback) if measurement and i < len(reference): ref = reference[i] @@ -743,13 +695,15 @@ def _verify_hardware( de = delta_e_2000(lab_measured, lab_reference) delta_es.append(de) - results.append({ - "name": patch.name or f"Patch {i+1}", - "rgb": (patch.r, patch.g, patch.b), - "measured_XYZ": measurement["XYZ"], - "reference_Lab": ref["Lab_D50"], - "delta_e": de - }) + results.append( + { + "name": patch.name or f"Patch {i + 1}", + "rgb": (patch.r, patch.g, patch.b), + "measured_XYZ": measurement["XYZ"], + "reference_Lab": ref["Lab_D50"], + "delta_e": de, + } + ) # Calculate statistics if delta_es: @@ -768,12 +722,7 @@ def _verify_hardware( else: grade = "Uncalibrated" - return { - "delta_e_avg": avg_de, - "delta_e_max": max_de, - "grade": grade, - "patches": results - } + return {"delta_e_avg": avg_de, "delta_e_max": max_de, "grade": grade, "patches": results} def calibrate( self, @@ -782,7 +731,7 @@ def calibrate( generate_icc: bool = True, generate_lut: bool = True, lut_size: int = 33, - hdr_mode: bool = False + hdr_mode: bool = False, ) -> CalibrationResult: """ Perform calibration based on current mode. @@ -802,25 +751,15 @@ def calibrate( self.hdr_mode = hdr_mode if self.mode == CalibrationMode.SENSORLESS: - return self.calibrate_sensorless( - model_string, output_dir, generate_icc, generate_lut, lut_size - ) + return self.calibrate_sensorless(model_string, output_dir, generate_icc, generate_lut, lut_size) elif self.mode in [CalibrationMode.COLORIMETER, CalibrationMode.SPECTRO]: - return self.calibrate_hardware( - model_string, output_dir, generate_icc, generate_lut, lut_size - ) + return self.calibrate_hardware(model_string, output_dir, generate_icc, generate_lut, lut_size) elif self.mode == CalibrationMode.HYBRID: - return self.calibrate_hybrid( - model_string, output_dir, generate_icc, generate_lut, lut_size - ) + return self.calibrate_hybrid(model_string, output_dir, generate_icc, generate_lut, lut_size) else: raise ValueError(f"Unknown calibration mode: {self.mode}") - def verify( - self, - model_string: str, - reference_patches: list | None = None - ) -> dict: + def verify(self, model_string: str, reference_patches: list | None = None) -> dict: """ Verify calibration accuracy. @@ -834,9 +773,7 @@ def verify( panel = self.detect_display(model_string) self.engine.current_panel = panel - return self.engine.verify_calibration( - panel, reference_patches=reference_patches - ) + return self.engine.verify_calibration(panel, reference_patches=reference_patches) def get_available_panels(self) -> list[str]: """Get list of available panel profiles.""" @@ -851,27 +788,23 @@ def get_panel_info(self, panel_key: str) -> dict | None: return { "key": panel_key, "manufacturer": panel.manufacturer, - "model": panel.model_pattern.split('|')[0], + "model": panel.model_pattern.split("|")[0], "type": panel.panel_type, "primaries": { "red": panel.native_primaries.red.as_tuple(), "green": panel.native_primaries.green.as_tuple(), "blue": panel.native_primaries.blue.as_tuple(), - "white": panel.native_primaries.white.as_tuple() - }, - "gamma": { - "red": panel.gamma_red.gamma, - "green": panel.gamma_green.gamma, - "blue": panel.gamma_blue.gamma + "white": panel.native_primaries.white.as_tuple(), }, + "gamma": {"red": panel.gamma_red.gamma, "green": panel.gamma_green.gamma, "blue": panel.gamma_blue.gamma}, "capabilities": { "max_sdr": panel.capabilities.max_luminance_sdr, "max_hdr": panel.capabilities.max_luminance_hdr, "hdr": panel.capabilities.hdr_capable, "wide_gamut": panel.capabilities.wide_gamut, - "vrr": panel.capabilities.vrr_capable + "vrr": panel.capabilities.vrr_capable, }, - "notes": panel.notes + "notes": panel.notes, } @@ -879,10 +812,9 @@ def get_panel_info(self, panel_key: str) -> dict | None: # Convenience Functions # ============================================================================= + def quick_calibrate( - model_string: str, - output_dir: str | Path = ".", - mode: CalibrationMode = CalibrationMode.SENSORLESS + model_string: str, output_dir: str | Path = ".", mode: CalibrationMode = CalibrationMode.SENSORLESS ) -> CalibrationResult: """ Quick calibration with default settings. diff --git a/calibrate_pro/core/color_math.py b/calibrate_pro/core/color_math.py index aeb2ce4..35ae018 100644 --- a/calibrate_pro/core/color_math.py +++ b/calibrate_pro/core/color_math.py @@ -17,23 +17,27 @@ from quanta_color import spaces as _qc_spaces -def _illuminant_to_array(ill: 'Illuminant') -> np.ndarray: +def _illuminant_to_array(ill: "Illuminant") -> np.ndarray: """Convert Calibrate Pro Illuminant to numpy array for quanta_color.""" return np.array([ill.X, ill.Y, ill.Z]) + # ============================================================================= # Standard Illuminants (CIE 1931 2-degree observer) # ============================================================================= + @dataclass(frozen=True) class Illuminant: """Standard illuminant with XYZ tristimulus values (Y=1.0 normalized).""" + name: str X: float Y: float Z: float cct: int # Correlated Color Temperature in Kelvin + # D50 - ICC Profile Connection Space reference white D50_WHITE = Illuminant("D50", 0.96422, 1.0, 0.82521, 5003) @@ -54,58 +58,49 @@ class Illuminant: # ============================================================================= # sRGB to XYZ (D65) - IEC 61966-2-1 -SRGB_TO_XYZ = np.array([ - [0.4124564, 0.3575761, 0.1804375], - [0.2126729, 0.7151522, 0.0721750], - [0.0193339, 0.1191920, 0.9503041] -], dtype=np.float64) +SRGB_TO_XYZ = np.array( + [[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]], + dtype=np.float64, +) # XYZ (D65) to sRGB -XYZ_TO_SRGB = np.array([ - [ 3.2404542, -1.5371385, -0.4985314], - [-0.9692660, 1.8760108, 0.0415560], - [ 0.0556434, -0.2040259, 1.0572252] -], dtype=np.float64) +XYZ_TO_SRGB = np.array( + [[3.2404542, -1.5371385, -0.4985314], [-0.9692660, 1.8760108, 0.0415560], [0.0556434, -0.2040259, 1.0572252]], + dtype=np.float64, +) # Adobe RGB (1998) to XYZ (D65) -ADOBE_RGB_TO_XYZ = np.array([ - [0.5767309, 0.1855540, 0.1881852], - [0.2973769, 0.6273491, 0.0752741], - [0.0270343, 0.0706872, 0.9911085] -], dtype=np.float64) +ADOBE_RGB_TO_XYZ = np.array( + [[0.5767309, 0.1855540, 0.1881852], [0.2973769, 0.6273491, 0.0752741], [0.0270343, 0.0706872, 0.9911085]], + dtype=np.float64, +) # DCI-P3 (D65) to XYZ -DCI_P3_TO_XYZ = np.array([ - [0.4865709, 0.2656677, 0.1982173], - [0.2289746, 0.6917385, 0.0792869], - [0.0000000, 0.0451134, 1.0439444] -], dtype=np.float64) +DCI_P3_TO_XYZ = np.array( + [[0.4865709, 0.2656677, 0.1982173], [0.2289746, 0.6917385, 0.0792869], [0.0000000, 0.0451134, 1.0439444]], + dtype=np.float64, +) # BT.2020 to XYZ (D65) -BT2020_TO_XYZ = np.array([ - [0.6369580, 0.1446169, 0.1688810], - [0.2627002, 0.6779981, 0.0593017], - [0.0000000, 0.0280727, 1.0609851] -], dtype=np.float64) +BT2020_TO_XYZ = np.array( + [[0.6369580, 0.1446169, 0.1688810], [0.2627002, 0.6779981, 0.0593017], [0.0000000, 0.0280727, 1.0609851]], + dtype=np.float64, +) # ============================================================================= # Bradford Chromatic Adaptation # ============================================================================= # Bradford transformation matrix -BRADFORD_MATRIX = np.array([ - [ 0.8951000, 0.2664000, -0.1614000], - [-0.7502000, 1.7135000, 0.0367000], - [ 0.0389000, -0.0685000, 1.0296000] -], dtype=np.float64) +BRADFORD_MATRIX = np.array( + [[0.8951000, 0.2664000, -0.1614000], [-0.7502000, 1.7135000, 0.0367000], [0.0389000, -0.0685000, 1.0296000]], + dtype=np.float64, +) BRADFORD_INVERSE = np.linalg.inv(BRADFORD_MATRIX) -def bradford_adapt( - xyz: np.ndarray, - source_white: Illuminant, - dest_white: Illuminant -) -> np.ndarray: + +def bradford_adapt(xyz: np.ndarray, source_white: Illuminant, dest_white: Illuminant) -> np.ndarray: """ Perform Bradford chromatic adaptation transform. @@ -122,23 +117,27 @@ def bradford_adapt( """ if source_white == dest_white: return xyz.copy() - return _qc_adapt.adapt(np.asarray(xyz, dtype=np.float64), _illuminant_to_array(source_white), _illuminant_to_array(dest_white), method="bradford") + return _qc_adapt.adapt( + np.asarray(xyz, dtype=np.float64), + _illuminant_to_array(source_white), + _illuminant_to_array(dest_white), + method="bradford", + ) -def get_adaptation_matrix( - source_white: Illuminant, - dest_white: Illuminant -) -> np.ndarray: + +def get_adaptation_matrix(source_white: Illuminant, dest_white: Illuminant) -> np.ndarray: """Get the 3x3 Bradford adaptation matrix for the given white points.""" - return _qc_adapt.get_adaptation_matrix(_illuminant_to_array(source_white), _illuminant_to_array(dest_white), method="bradford") + return _qc_adapt.get_adaptation_matrix( + _illuminant_to_array(source_white), _illuminant_to_array(dest_white), method="bradford" + ) + # ============================================================================= # XYZ <-> Lab Conversions # ============================================================================= -def xyz_to_lab( - xyz: np.ndarray, - illuminant: Illuminant = D50_WHITE -) -> np.ndarray: + +def xyz_to_lab(xyz: np.ndarray, illuminant: Illuminant = D50_WHITE) -> np.ndarray: """ Convert XYZ to CIELAB. @@ -151,10 +150,8 @@ def xyz_to_lab( """ return _qc_spaces.xyz_to_lab(np.asarray(xyz, dtype=np.float64), white=_illuminant_to_array(illuminant)) -def lab_to_xyz( - lab: np.ndarray, - illuminant: Illuminant = D50_WHITE -) -> np.ndarray: + +def lab_to_xyz(lab: np.ndarray, illuminant: Illuminant = D50_WHITE) -> np.ndarray: """ Convert CIELAB to XYZ. @@ -167,10 +164,12 @@ def lab_to_xyz( """ return _qc_spaces.lab_to_xyz(np.asarray(lab, dtype=np.float64), white=_illuminant_to_array(illuminant)) + # ============================================================================= # sRGB <-> XYZ Conversions # ============================================================================= + def srgb_gamma_expand(rgb: np.ndarray) -> np.ndarray: """ Convert sRGB (gamma-compressed) to linear RGB. @@ -179,6 +178,7 @@ def srgb_gamma_expand(rgb: np.ndarray) -> np.ndarray: """ return _qc_spaces.srgb_to_linear(np.asarray(rgb, dtype=np.float64)) + def srgb_gamma_compress(linear: np.ndarray) -> np.ndarray: """ Convert linear RGB to sRGB (gamma-compressed). @@ -187,6 +187,7 @@ def srgb_gamma_compress(linear: np.ndarray) -> np.ndarray: """ return _qc_spaces.linear_to_srgb(np.asarray(linear, dtype=np.float64)) + def srgb_to_xyz(rgb: np.ndarray) -> np.ndarray: """ Convert sRGB to XYZ (D65). @@ -199,6 +200,7 @@ def srgb_to_xyz(rgb: np.ndarray) -> np.ndarray: """ return _qc_spaces.srgb_to_xyz(np.asarray(rgb, dtype=np.float64)) + def xyz_to_srgb(xyz: np.ndarray, clip: bool = True) -> np.ndarray: """ Convert XYZ (D65) to sRGB. @@ -212,15 +214,13 @@ def xyz_to_srgb(xyz: np.ndarray, clip: bool = True) -> np.ndarray: """ return _qc_spaces.xyz_to_srgb(np.asarray(xyz, dtype=np.float64), clip=clip) + # ============================================================================= # Lab <-> sRGB Conversions (via XYZ) # ============================================================================= -def lab_to_srgb( - lab: np.ndarray, - illuminant: Illuminant = D50_WHITE, - clip: bool = True -) -> np.ndarray: + +def lab_to_srgb(lab: np.ndarray, illuminant: Illuminant = D50_WHITE, clip: bool = True) -> np.ndarray: """ Convert CIELAB to sRGB. @@ -242,10 +242,8 @@ def lab_to_srgb( return xyz_to_srgb(xyz, clip=clip) -def srgb_to_lab( - rgb: np.ndarray, - illuminant: Illuminant = D50_WHITE -) -> np.ndarray: + +def srgb_to_lab(rgb: np.ndarray, illuminant: Illuminant = D50_WHITE) -> np.ndarray: """ Convert sRGB to CIELAB. @@ -266,16 +264,14 @@ def srgb_to_lab( return xyz_to_lab(xyz, illuminant) + # ============================================================================= # CIEDE2000 Delta E Calculation # ============================================================================= + def delta_e_2000( - lab1: np.ndarray, - lab2: np.ndarray, - kL: float = 1.0, - kC: float = 1.0, - kH: float = 1.0 + lab1: np.ndarray, lab2: np.ndarray, kL: float = 1.0, kC: float = 1.0, kH: float = 1.0 ) -> float | np.ndarray: """ Calculate CIEDE2000 color difference. @@ -302,20 +298,24 @@ def delta_e_2000( result = _qc_diff.delta_e_2000(lab1, lab2, kL=kL, kC=kC, kH=kH) return float(result[0]) if single else result + # ============================================================================= # Gamma and Transfer Functions # ============================================================================= + def gamma_encode(linear: np.ndarray, gamma: float = 2.2) -> np.ndarray: """Apply power-law gamma encoding.""" linear = np.asarray(linear, dtype=np.float64) return np.power(np.clip(linear, 0.0, None), 1.0 / gamma) + def gamma_decode(encoded: np.ndarray, gamma: float = 2.2) -> np.ndarray: """Apply power-law gamma decoding (linearization).""" encoded = np.asarray(encoded, dtype=np.float64) return np.power(np.clip(encoded, 0.0, 1.0), gamma) + def bt1886_eotf(signal: np.ndarray, gamma: float = 2.4, Lw: float = 100.0, Lb: float = 0.0) -> np.ndarray: """ BT.1886 Electro-Optical Transfer Function. @@ -336,6 +336,7 @@ def bt1886_eotf(signal: np.ndarray, gamma: float = 2.4, Lw: float = 100.0, Lb: f b = Lb ** (1.0 / gamma) / (Lw ** (1.0 / gamma) - Lb ** (1.0 / gamma)) return a * np.power(np.maximum(signal + b, 0.0), gamma) + def bt1886_eotf_inv(luminance: np.ndarray, gamma: float = 2.4, Lw: float = 100.0, Lb: float = 0.0) -> np.ndarray: """Inverse BT.1886 EOTF (luminance to signal).""" luminance = np.asarray(luminance, dtype=np.float64) @@ -343,10 +344,12 @@ def bt1886_eotf_inv(luminance: np.ndarray, gamma: float = 2.4, Lw: float = 100.0 b = Lb ** (1.0 / gamma) / (Lw ** (1.0 / gamma) - Lb ** (1.0 / gamma)) return np.power(luminance / a, 1.0 / gamma) - b + # ============================================================================= # Chromaticity Conversions # ============================================================================= + def xyz_to_xyY(xyz: np.ndarray) -> np.ndarray: """ Convert XYZ to CIE xyY chromaticity. @@ -359,6 +362,7 @@ def xyz_to_xyY(xyz: np.ndarray) -> np.ndarray: """ return _qc_spaces.xyz_to_xyY(np.asarray(xyz, dtype=np.float64)) + def xyY_to_xyz(xyY: np.ndarray) -> np.ndarray: """ Convert CIE xyY to XYZ. @@ -371,10 +375,12 @@ def xyY_to_xyz(xyY: np.ndarray) -> np.ndarray: """ return _qc_spaces.xyY_to_xyz(np.asarray(xyY, dtype=np.float64)) + # ============================================================================= # CCT (Correlated Color Temperature) Calculations # ============================================================================= + def xy_to_cct(x: float, y: float) -> float: """ Calculate Correlated Color Temperature from CIE xy chromaticity. @@ -389,6 +395,7 @@ def xy_to_cct(x: float, y: float) -> float: """ return _qc_adapt.xy_to_cct_mccamy(x, y) + def cct_to_xy(cct: float) -> tuple[float, float]: """ Calculate CIE xy chromaticity from CCT (Planckian locus). @@ -403,10 +410,12 @@ def cct_to_xy(cct: float) -> tuple[float, float]: """ return _qc_adapt.cct_to_xy(cct) + # ============================================================================= # Gamut Utilities # ============================================================================= + def is_in_gamut(rgb: np.ndarray, tolerance: float = 0.0) -> bool | np.ndarray: """ Check if RGB values are within gamut [0, 1]. @@ -420,15 +429,13 @@ def is_in_gamut(rgb: np.ndarray, tolerance: float = 0.0) -> bool | np.ndarray: """ return _qc_gamut.is_in_gamut(np.asarray(rgb, dtype=np.float64), tolerance=tolerance) + def gamut_clip(rgb: np.ndarray) -> np.ndarray: """Simple RGB clipping to [0, 1] range.""" return _qc_gamut.clip(np.asarray(rgb, dtype=np.float64)) -def gamut_compress( - rgb: np.ndarray, - threshold: float = 0.8, - limit: float = 1.0 -) -> np.ndarray: + +def gamut_compress(rgb: np.ndarray, threshold: float = 0.8, limit: float = 1.0) -> np.ndarray: """ Soft gamut compression using a power curve. @@ -448,15 +455,17 @@ def gamut_compress( return np.clip(rgb, 0.0, 1.0) + # ============================================================================= # Matrix Utilities # ============================================================================= + def primaries_to_xyz_matrix( red_xy: tuple[float, float], green_xy: tuple[float, float], blue_xy: tuple[float, float], - white_xy: tuple[float, float] + white_xy: tuple[float, float], ) -> np.ndarray: """ Calculate RGB to XYZ matrix from primary chromaticities. @@ -470,9 +479,10 @@ def primaries_to_xyz_matrix( Returns: 3x3 RGB to XYZ matrix """ + # Convert xy to XYZ (assuming Y=1) def xy_to_XYZ(x, y): - return np.array([x/y, 1.0, (1-x-y)/y]) + return np.array([x / y, 1.0, (1 - x - y) / y]) R = xy_to_XYZ(*red_xy) G = xy_to_XYZ(*green_xy) @@ -488,6 +498,7 @@ def xy_to_XYZ(x, y): # Scale columns return M * S + def xyz_to_rgb_matrix(rgb_to_xyz: np.ndarray) -> np.ndarray: """Calculate XYZ to RGB matrix (inverse of RGB to XYZ).""" return np.linalg.inv(rgb_to_xyz) @@ -497,6 +508,7 @@ def xyz_to_rgb_matrix(rgb_to_xyz: np.ndarray) -> np.ndarray: # Oklab / Oklch — Perceptually Uniform Color Space (Björn Ottosson) # ============================================================================= + def linear_srgb_to_oklab(rgb: np.ndarray) -> np.ndarray: """ Convert linear sRGB to Oklab. @@ -636,9 +648,9 @@ def jzazbz_to_xyz_abs(jzazbz: np.ndarray) -> np.ndarray: mp = _pq_decode(m) sp = _pq_decode(s) - xp = 1.9242264357876067 * lp - 1.0047923125953657 * mp + 0.037651404030618 * sp - yp = 0.35031676209499907 * lp + 0.7264811939316552 * mp - 0.06538442294808501 * sp - z = -0.09098281098284752 * lp - 0.3127282905230739 * mp + 1.5227665613052603 * sp + xp = 1.9242264357876067 * lp - 1.0047923125953657 * mp + 0.037651404030618 * sp + yp = 0.35031676209499907 * lp + 0.7264811939316552 * mp - 0.06538442294808501 * sp + z = -0.09098281098284752 * lp - 0.3127282905230739 * mp + 1.5227665613052603 * sp x = (xp + (_JZ_B - 1.0) * z) / _JZ_B y = (yp + (_JZ_G - 1.0) * x) / _JZ_G @@ -655,7 +667,7 @@ def jzazbz_to_jzczhz(jzazbz: np.ndarray) -> np.ndarray: jzazbz = jzazbz.reshape(1, 3) Jz = jzazbz[:, 0] - Cz = np.sqrt(jzazbz[:, 1]**2 + jzazbz[:, 2]**2) + Cz = np.sqrt(jzazbz[:, 1] ** 2 + jzazbz[:, 2] ** 2) hz = np.degrees(np.arctan2(jzazbz[:, 2], jzazbz[:, 1])) % 360.0 result = np.column_stack([Jz, Cz, hz]) @@ -681,6 +693,7 @@ def jzczhz_to_jzazbz(jzczhz: np.ndarray) -> np.ndarray: # ICtCp — Dolby HDR Perceptual Space # ============================================================================= + def xyz_abs_to_ictcp(xyz: np.ndarray) -> np.ndarray: """ Convert absolute XYZ (cd/m²) to ICtCp (Dolby). @@ -701,9 +714,9 @@ def xyz_abs_to_ictcp(xyz: np.ndarray) -> np.ndarray: x, y, z = xyz[:, 0], xyz[:, 1], xyz[:, 2] # XYZ to LMS - L = 0.3592 * x + 0.6976 * y - 0.0358 * z + L = 0.3592 * x + 0.6976 * y - 0.0358 * z M = -0.1922 * x + 1.1004 * y + 0.0755 * z - S = 0.0070 * x + 0.0749 * y + 0.8434 * z + S = 0.0070 * x + 0.0749 * y + 0.8434 * z # PQ encode (normalize to 10000 nits) Lp = _pq_st2084_oetf(L / 10000.0) @@ -711,7 +724,7 @@ def xyz_abs_to_ictcp(xyz: np.ndarray) -> np.ndarray: Sp = _pq_st2084_oetf(S / 10000.0) # LMS' to ICtCp - I = 0.5 * Lp + 0.5 * Mp + I = 0.5 * Lp + 0.5 * Mp Ct = 1.613769531 * Lp - 3.323486328 * Mp + 1.709716797 * Sp Cp = 4.378173828 * Lp - 4.245605469 * Mp - 0.132568359 * Sp @@ -746,8 +759,8 @@ def ictcp_to_xyz_abs(ictcp: np.ndarray) -> np.ndarray: S = _pq_st2084_eotf(Sp) * 10000.0 # LMS to XYZ - x = 2.0702 * L - 1.3265 * M + 0.2067 * S - y = 0.3650 * L + 0.6806 * M - 0.0453 * S + x = 2.0702 * L - 1.3265 * M + 0.2067 * S + y = 0.3650 * L + 0.6806 * M - 0.0453 * S z = -0.0496 * L - 0.0494 * M + 1.1880 * S result = np.column_stack([x, y, z]) @@ -758,6 +771,7 @@ def ictcp_to_xyz_abs(ictcp: np.ndarray) -> np.ndarray: # PQ (ST.2084) and HLG (BT.2100) Transfer Functions # ============================================================================= + def _pq_st2084_oetf(v: np.ndarray) -> np.ndarray: """PQ OETF: linear [0,1] normalized to 10000 nits → PQ signal [0,1].""" v = np.asarray(v, dtype=np.float64) @@ -827,11 +841,7 @@ def hlg_eotf(v: np.ndarray) -> np.ndarray: return result -def hlg_ootf_rgb( - rgb: np.ndarray, - peak_luminance: float = 1000.0, - gamma: float = 1.2 -) -> np.ndarray: +def hlg_ootf_rgb(rgb: np.ndarray, peak_luminance: float = 1000.0, gamma: float = 1.2) -> np.ndarray: """ HLG OOTF: scene-referred linear → display-referred linear. @@ -859,43 +869,61 @@ def hlg_ootf_rgb( # ============================================================================= # ACES 2065-1 (AP0) ↔ XYZ -ACES2065_1_TO_XYZ = np.array([ - [0.9525523959, 0.0000000000, 0.0000936786], - [0.3439664498, 0.7281660966, -0.0721325464], - [0.0000000000, 0.0000000000, 1.0088251844] -], dtype=np.float64) - -XYZ_TO_ACES2065_1 = np.array([ - [ 1.0498110175, 0.0000000000, -0.0000974845], - [-0.4959030231, 1.3733130458, 0.0982400361], - [ 0.0000000000, 0.0000000000, 0.9912520182] -], dtype=np.float64) +ACES2065_1_TO_XYZ = np.array( + [ + [0.9525523959, 0.0000000000, 0.0000936786], + [0.3439664498, 0.7281660966, -0.0721325464], + [0.0000000000, 0.0000000000, 1.0088251844], + ], + dtype=np.float64, +) + +XYZ_TO_ACES2065_1 = np.array( + [ + [1.0498110175, 0.0000000000, -0.0000974845], + [-0.4959030231, 1.3733130458, 0.0982400361], + [0.0000000000, 0.0000000000, 0.9912520182], + ], + dtype=np.float64, +) # ACEScg (AP1) ↔ XYZ -ACESCG_TO_XYZ = np.array([ - [0.6624541811, 0.1340042065, 0.1561876870], - [0.2722287168, 0.6740817658, 0.0536895174], - [-0.0055746495, 0.0040607335, 1.0103391003] -], dtype=np.float64) - -XYZ_TO_ACESCG = np.array([ - [ 1.6410233797, -0.3248032942, -0.2364246952], - [-0.6636628587, 1.6153315917, 0.0167563477], - [ 0.0117218943, -0.0082844420, 0.9883948585] -], dtype=np.float64) +ACESCG_TO_XYZ = np.array( + [ + [0.6624541811, 0.1340042065, 0.1561876870], + [0.2722287168, 0.6740817658, 0.0536895174], + [-0.0055746495, 0.0040607335, 1.0103391003], + ], + dtype=np.float64, +) + +XYZ_TO_ACESCG = np.array( + [ + [1.6410233797, -0.3248032942, -0.2364246952], + [-0.6636628587, 1.6153315917, 0.0167563477], + [0.0117218943, -0.0082844420, 0.9883948585], + ], + dtype=np.float64, +) # ACEScg ↔ linear sRGB -ACESCG_TO_SRGB_LINEAR = np.array([ - [ 1.7050509879, -0.6217921206, -0.0832588234], - [-0.1302564175, 1.1408047365, -0.0105482626], - [-0.0240033568, -0.1289689761, 1.1529723252] -], dtype=np.float64) - -SRGB_LINEAR_TO_ACESCG = np.array([ - [0.6131178520, 0.3395231462, 0.0473590018], - [0.0701918649, 0.9163553837, 0.0134527514], - [0.0205798908, 0.1096578085, 0.8697623008] -], dtype=np.float64) +ACESCG_TO_SRGB_LINEAR = np.array( + [ + [1.7050509879, -0.6217921206, -0.0832588234], + [-0.1302564175, 1.1408047365, -0.0105482626], + [-0.0240033568, -0.1289689761, 1.1529723252], + ], + dtype=np.float64, +) + +SRGB_LINEAR_TO_ACESCG = np.array( + [ + [0.6131178520, 0.3395231462, 0.0473590018], + [0.0701918649, 0.9163553837, 0.0134527514], + [0.0205798908, 0.1096578085, 0.8697623008], + ], + dtype=np.float64, +) def acescg_to_xyz(rgb: np.ndarray) -> np.ndarray: @@ -1062,10 +1090,7 @@ def xyz_to_rec2020(xyz: np.ndarray, clip: bool = True) -> np.ndarray: _CIE_KAPPA = 24389.0 / 27.0 -def xyz_to_luv( - xyz: np.ndarray, - illuminant: Illuminant = D65_WHITE -) -> np.ndarray: +def xyz_to_luv(xyz: np.ndarray, illuminant: Illuminant = D65_WHITE) -> np.ndarray: """ Convert XYZ to CIE L*u*v*. @@ -1105,10 +1130,7 @@ def uv_prime(vals): return result[0] if single else result -def luv_to_xyz( - luv: np.ndarray, - illuminant: Illuminant = D65_WHITE -) -> np.ndarray: +def luv_to_xyz(luv: np.ndarray, illuminant: Illuminant = D65_WHITE) -> np.ndarray: """Convert CIE L*u*v* to XYZ.""" luv = np.asarray(luv, dtype=np.float64) single = luv.ndim == 1 @@ -1125,11 +1147,7 @@ def luv_to_xyz( u_p = np.where(L != 0, u_star / (13.0 * L) + un_p, 0.0) v_p = np.where(L != 0, v_star / (13.0 * L) + vn_p, 0.0) - Y = np.where( - L > _CIE_KAPPA * _CIE_EPSILON, - np.power((L + 16.0) / 116.0, 3.0), - L / _CIE_KAPPA - ) * ref[1] + Y = np.where(L > _CIE_KAPPA * _CIE_EPSILON, np.power((L + 16.0) / 116.0, 3.0), L / _CIE_KAPPA) * ref[1] X = np.where(v_p != 0, Y * 9.0 * u_p / (4.0 * v_p), 0.0) Z = np.where(v_p != 0, Y * (12.0 - 3.0 * u_p - 20.0 * v_p) / (4.0 * v_p), 0.0) @@ -1142,6 +1160,7 @@ def luv_to_xyz( # HSL / HSV / HWB # ============================================================================= + def srgb_to_hsl(rgb: np.ndarray) -> np.ndarray: """Convert sRGB [0,1] to HSL (H in degrees, S and L in [0,1]).""" rgb = np.asarray(rgb, dtype=np.float64) @@ -1178,16 +1197,21 @@ def hsl_to_srgb(hsl: np.ndarray) -> np.ndarray: p = 2.0 * l - q def hue2rgb(p, q, t): - if t < 0: t += 1 - if t > 1: t -= 1 - if t < 1/6: return p + (q - p) * 6 * t - if t < 1/2: return q - if t < 2/3: return p + (q - p) * (2/3 - t) * 6 + if t < 0: + t += 1 + if t > 1: + t -= 1 + if t < 1 / 6: + return p + (q - p) * 6 * t + if t < 1 / 2: + return q + if t < 2 / 3: + return p + (q - p) * (2 / 3 - t) * 6 return p - r = hue2rgb(p, q, h / 360.0 + 1/3) + r = hue2rgb(p, q, h / 360.0 + 1 / 3) g = hue2rgb(p, q, h / 360.0) - b = hue2rgb(p, q, h / 360.0 - 1/3) + b = hue2rgb(p, q, h / 360.0 - 1 / 3) return np.array([r, g, b]) @@ -1230,11 +1254,16 @@ def hsv_to_srgb(hsv: np.ndarray) -> np.ndarray: q = v * (1.0 - s * f) t = v * (1.0 - s * (1.0 - f)) - if i == 0: return np.array([v, t, p]) - if i == 1: return np.array([q, v, p]) - if i == 2: return np.array([p, v, t]) - if i == 3: return np.array([p, q, v]) - if i == 4: return np.array([t, p, v]) + if i == 0: + return np.array([v, t, p]) + if i == 1: + return np.array([q, v, p]) + if i == 2: + return np.array([p, v, t]) + if i == 3: + return np.array([p, q, v]) + if i == 4: + return np.array([t, p, v]) return np.array([v, p, q]) @@ -1266,11 +1295,10 @@ def hwb_to_srgb(hwb: np.ndarray) -> np.ndarray: # ============================================================================= # CAT16 forward matrix (XYZ to sharpened RGB) -_CAT16 = np.array([ - [ 0.401288, 0.650173, -0.051461], - [-0.250268, 1.204414, 0.045854], - [-0.002079, 0.048952, 0.953127] -], dtype=np.float64) +_CAT16 = np.array( + [[0.401288, 0.650173, -0.051461], [-0.250268, 1.204414, 0.045854], [-0.002079, 0.048952, 0.953127]], + dtype=np.float64, +) _CAT16_INV = np.linalg.inv(_CAT16) @@ -1283,6 +1311,7 @@ def hwb_to_srgb(hwb: np.ndarray) -> np.ndarray: @dataclass class CAM16Env: """Pre-computed CAM16 viewing condition parameters.""" + c: float Nc: float F_L: float @@ -1294,10 +1323,7 @@ class CAM16Env: def cam16_environment( - white_xyz: np.ndarray = None, - La: float = 64.0, - Yb: float = 20.0, - surround: tuple = CAM16_SURROUND_AVERAGE + white_xyz: np.ndarray = None, La: float = 64.0, Yb: float = 20.0, surround: tuple = CAM16_SURROUND_AVERAGE ) -> CAM16Env: """ Pre-compute CAM16 viewing condition parameters. @@ -1323,11 +1349,7 @@ def cam16_environment( # Adapted white rgb_w = _CAT16 @ white_xyz - D_RGB = np.array([ - D * (Yw / rgb_w[0]) + 1.0 - D, - D * (Yw / rgb_w[1]) + 1.0 - D, - D * (Yw / rgb_w[2]) + 1.0 - D - ]) + D_RGB = np.array([D * (Yw / rgb_w[0]) + 1.0 - D, D * (Yw / rgb_w[1]) + 1.0 - D, D * (Yw / rgb_w[2]) + 1.0 - D]) # Factors n = Yb / Yw @@ -1336,7 +1358,7 @@ def cam16_environment( # Luminance adaptation factor k = 1.0 / (5.0 * La + 1.0) - k4 = k ** 4 + k4 = k**4 F_L = k4 * La + 0.1 * (1.0 - k4) ** 2 * (5.0 * La) ** (1.0 / 3.0) # Achromatic response of white @@ -1365,10 +1387,7 @@ def _cam16_post_adapt_inv(rgb_a: np.ndarray, F_L: float) -> np.ndarray: return sign * 100.0 / F_L * np.power(27.13 * abs_t / denom, 1.0 / 0.42) -def xyz_to_cam16( - xyz: np.ndarray, - env: CAM16Env -) -> dict: +def xyz_to_cam16(xyz: np.ndarray, env: CAM16Env) -> dict: """ Convert XYZ to CAM16 appearance correlates. @@ -1408,26 +1427,22 @@ def xyz_to_cam16( J = 100.0 * (A / env.Aw) ** (env.c * env.z) # Brightness - Q = (4.0 / env.c) * math.sqrt(J / 100.0) * (env.Aw + 4.0) * env.F_L ** 0.25 + Q = (4.0 / env.c) * math.sqrt(J / 100.0) * (env.Aw + 4.0) * env.F_L**0.25 # Chroma - t_val = (50000.0 / 13.0 * env.Nc * env.Nbb * et * math.sqrt(a**2 + b**2)) / \ - (rgb_a[0] + rgb_a[1] + 21.0 * rgb_a[2] / 20.0 + 1e-10) - C = t_val ** 0.9 * math.sqrt(J / 100.0) * (1.64 - 0.29 ** env.n) ** 0.73 + t_val = (50000.0 / 13.0 * env.Nc * env.Nbb * et * math.sqrt(a**2 + b**2)) / ( + rgb_a[0] + rgb_a[1] + 21.0 * rgb_a[2] / 20.0 + 1e-10 + ) + C = t_val**0.9 * math.sqrt(J / 100.0) * (1.64 - 0.29**env.n) ** 0.73 # Colorfulness & saturation - M = C * env.F_L ** 0.25 + M = C * env.F_L**0.25 s_val = 100.0 * math.sqrt(M / max(Q, 1e-10)) return {"J": J, "C": C, "h": h, "Q": Q, "M": M, "s": s_val, "a": a, "b": b} -def cam16_to_xyz( - J: float, - C: float, - h: float, - env: CAM16Env -) -> np.ndarray: +def cam16_to_xyz(J: float, C: float, h: float, env: CAM16Env) -> np.ndarray: """ Convert CAM16 (J, C, h) back to XYZ. @@ -1447,7 +1462,7 @@ def cam16_to_xyz( A = env.Aw * (J / 100.0) ** (1.0 / (env.c * env.z)) - t = (C / (math.sqrt(J / 100.0) * (1.64 - 0.29 ** env.n) ** 0.73 + 1e-10)) ** (1.0 / 0.9) + t = (C / (math.sqrt(J / 100.0) * (1.64 - 0.29**env.n) ** 0.73 + 1e-10)) ** (1.0 / 0.9) p1 = 50000.0 / 13.0 * env.Nc * env.Nbb * et p2 = A / env.Nbb + 0.305 @@ -1462,11 +1477,13 @@ def cam16_to_xyz( a = gamma * cos_h b = gamma * sin_h - rgb_a = np.array([ - 460.0 * p2 / 1403.0 + 451.0 * a / 1403.0 + 288.0 * b / 1403.0, - 460.0 * p2 / 1403.0 - 891.0 * a / 1403.0 - 261.0 * b / 1403.0, - 460.0 * p2 / 1403.0 - 220.0 * a / 1403.0 - 6300.0 * b / 1403.0 - ]) + rgb_a = np.array( + [ + 460.0 * p2 / 1403.0 + 451.0 * a / 1403.0 + 288.0 * b / 1403.0, + 460.0 * p2 / 1403.0 - 891.0 * a / 1403.0 - 261.0 * b / 1403.0, + 460.0 * p2 / 1403.0 - 220.0 * a / 1403.0 - 6300.0 * b / 1403.0, + ] + ) rgb_c = _cam16_post_adapt_inv(rgb_a, env.F_L) rgb = rgb_c / env.D_RGB @@ -1497,18 +1514,16 @@ def cam16_to_ucs(J: float, M: float, h: float) -> np.ndarray: def cam16_ucs_delta_e(ucs1: np.ndarray, ucs2: np.ndarray) -> float: """Euclidean distance in CAM16-UCS (perceptual color difference).""" d = np.asarray(ucs1) - np.asarray(ucs2) - return float(np.sqrt(np.sum(d ** 2))) + return float(np.sqrt(np.sum(d**2))) # ============================================================================= # Gamut Boundary Descriptor # ============================================================================= + def compute_gamut_boundary( - gamut_xyz_to_rgb: np.ndarray, - lightness_steps: int = 101, - hue_steps: int = 360, - illuminant: Illuminant = D65_WHITE + gamut_xyz_to_rgb: np.ndarray, lightness_steps: int = 101, hue_steps: int = 360, illuminant: Illuminant = D65_WHITE ) -> np.ndarray: """ Compute the maximum chroma at each (lightness, hue) for a color gamut. @@ -1549,11 +1564,11 @@ def compute_gamut_boundary( fz = fy - b / 200.0 delta = 6.0 / 29.0 - delta_cu = delta ** 3 + delta_cu = delta**3 - xr = fx ** 3 if fx ** 3 > delta_cu else (fx - 4.0 / 29.0) * 3 * delta ** 2 - yr = fy ** 3 if fy ** 3 > delta_cu else (fy - 4.0 / 29.0) * 3 * delta ** 2 - zr = fz ** 3 if fz ** 3 > delta_cu else (fz - 4.0 / 29.0) * 3 * delta ** 2 + xr = fx**3 if fx**3 > delta_cu else (fx - 4.0 / 29.0) * 3 * delta**2 + yr = fy**3 if fy**3 > delta_cu else (fy - 4.0 / 29.0) * 3 * delta**2 + zr = fz**3 if fz**3 > delta_cu else (fz - 4.0 / 29.0) * 3 * delta**2 xyz_val = np.array([xr * ref[0], yr * ref[1], zr * ref[2]]) rgb_val = gamut_xyz_to_rgb @ xyz_val @@ -1568,11 +1583,7 @@ def compute_gamut_boundary( return boundary -def get_max_chroma( - boundary: np.ndarray, - L: float, - h_deg: float -) -> float: +def get_max_chroma(boundary: np.ndarray, L: float, h_deg: float) -> float: """ Look up maximum chroma from a pre-computed gamut boundary. @@ -1614,11 +1625,12 @@ def get_max_chroma( # BT.2390 EETF (Electrical-Electrical Transfer Function) # ============================================================================= + def bt2390_eetf( pq_signal: np.ndarray, source_peak_nits: float = 10000.0, target_peak_nits: float = 1000.0, - target_black_nits: float = 0.0 + target_black_nits: float = 0.0, ) -> np.ndarray: """ BT.2390 EETF — maps HDR PQ signals from a source peak luminance to a @@ -1682,11 +1694,7 @@ def bt2390_eetf( return np.clip(E2, 0.0, 1.0) -def gamut_map_chroma_compress( - lab: np.ndarray, - boundary: np.ndarray, - method: str = "compress" -) -> np.ndarray: +def gamut_map_chroma_compress(lab: np.ndarray, boundary: np.ndarray, method: str = "compress") -> np.ndarray: """ Map an out-of-gamut Lab color into gamut by reducing chroma. diff --git a/calibrate_pro/core/color_models.py b/calibrate_pro/core/color_models.py index 1533126..05070a5 100644 --- a/calibrate_pro/core/color_models.py +++ b/calibrate_pro/core/color_models.py @@ -27,20 +27,18 @@ D65_XYZ = np.array([0.95047, 1.0, 1.08883]) # sRGB to XYZ (D65) -SRGB_TO_XYZ = np.array([ - [0.4124564, 0.3575761, 0.1804375], - [0.2126729, 0.7151522, 0.0721750], - [0.0193339, 0.1191920, 0.9503041] -], dtype=np.float64) +SRGB_TO_XYZ = np.array( + [[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]], + dtype=np.float64, +) XYZ_TO_SRGB = np.linalg.inv(SRGB_TO_XYZ) # BT.2020 to XYZ (D65) -BT2020_TO_XYZ = np.array([ - [0.6369580, 0.1446169, 0.1688810], - [0.2627002, 0.6779981, 0.0593017], - [0.0000000, 0.0280727, 1.0609851] -], dtype=np.float64) +BT2020_TO_XYZ = np.array( + [[0.6369580, 0.1446169, 0.1688810], [0.2627002, 0.6779981, 0.0593017], [0.0000000, 0.0280727, 1.0609851]], + dtype=np.float64, +) XYZ_TO_BT2020 = np.linalg.inv(BT2020_TO_XYZ) @@ -107,6 +105,7 @@ def pq_oetf(Y: np.ndarray) -> np.ndarray: # CAM16 Color Appearance Model # ============================================================================= + @dataclass class CAM16ViewingConditions: """ @@ -118,24 +117,21 @@ class CAM16ViewingConditions: surround: Surround type ('average', 'dim', 'dark') discounting: Whether to discount illuminant (False for displays) """ + L_A: float = 64.0 # Adapting luminance Y_b: float = 20.0 # Background luminance - surround: str = 'average' # 'average', 'dim', 'dark' + surround: str = "average" # 'average', 'dim', 'dark' discounting: bool = False def __post_init__(self): # Surround parameters (c, Nc, F) - surround_params = { - 'average': (0.69, 1.0, 1.0), - 'dim': (0.59, 0.95, 0.9), - 'dark': (0.525, 0.8, 0.8) - } + surround_params = {"average": (0.69, 1.0, 1.0), "dim": (0.59, 0.95, 0.9), "dark": (0.525, 0.8, 0.8)} self.c, self.Nc, self.F = surround_params.get(self.surround, (0.69, 1.0, 1.0)) # Chromatic induction factor k = 1.0 / (5.0 * self.L_A + 1.0) - k4 = k ** 4 - self.F_L = 0.2 * k4 * (5.0 * self.L_A) + 0.1 * (1.0 - k4) ** 2 * (5.0 * self.L_A) ** (1.0/3.0) + k4 = k**4 + self.F_L = 0.2 * k4 * (5.0 * self.L_A) + 0.1 * (1.0 - k4) ** 2 * (5.0 * self.L_A) ** (1.0 / 3.0) # Background induction factor self.n = self.Y_b / 100.0 @@ -152,11 +148,10 @@ def __post_init__(self): # CAM16 transformation matrix (Hunt-Pointer-Estevez with D65 adaptation) -CAM16_M = np.array([ - [ 0.401288, 0.650173, -0.051461], - [-0.250268, 1.204414, 0.045854], - [-0.002079, 0.048952, 0.953127] -], dtype=np.float64) +CAM16_M = np.array( + [[0.401288, 0.650173, -0.051461], [-0.250268, 1.204414, 0.045854], [-0.002079, 0.048952, 0.953127]], + dtype=np.float64, +) CAM16_M_INV = np.linalg.inv(CAM16_M) @@ -205,8 +200,7 @@ def _compute_white_adaptation(self): self.RGB_aw = self._nonlinear_adaptation(RGB_wc) # Achromatic response of white - self.A_w = (2.0 * self.RGB_aw[0] + self.RGB_aw[1] + - 0.05 * self.RGB_aw[2] - 0.305) * self.vc.N_bb + self.A_w = (2.0 * self.RGB_aw[0] + self.RGB_aw[1] + 0.05 * self.RGB_aw[2] - 0.305) * self.vc.N_bb def _nonlinear_adaptation(self, RGB: np.ndarray) -> np.ndarray: """Apply CAM16 nonlinear adaptation.""" @@ -216,8 +210,7 @@ def _nonlinear_adaptation(self, RGB: np.ndarray) -> np.ndarray: sign = np.sign(RGB) RGB_abs = np.abs(RGB) - adapted = sign * 400.0 * (F_L * RGB_abs / 100.0) ** 0.42 / \ - (27.13 + (F_L * RGB_abs / 100.0) ** 0.42) + 0.1 + adapted = sign * 400.0 * (F_L * RGB_abs / 100.0) ** 0.42 / (27.13 + (F_L * RGB_abs / 100.0) ** 0.42) + 0.1 return adapted @@ -231,8 +224,7 @@ def _nonlinear_adaptation_inv(self, RGB_a: np.ndarray) -> np.ndarray: RGB_a_abs = np.abs(RGB_a_adj) # Inverse of the nonlinear function - RGB = sign * (100.0 / F_L) * \ - np.power(27.13 * RGB_a_abs / (400.0 - RGB_a_abs), 1.0 / 0.42) + RGB = sign * (100.0 / F_L) * np.power(27.13 * RGB_a_abs / (400.0 - RGB_a_abs), 1.0 / 0.42) return RGB @@ -297,14 +289,20 @@ def _forward_single(self, xyz: np.ndarray) -> dict: J = 100.0 * np.power(A / self.A_w, self.vc.c * self.vc.z) # Brightness - Q = (4.0 / self.vc.c) * np.sqrt(J / 100.0) * \ - (self.A_w + 4.0) * np.power(self.vc.F_L, 0.25) + Q = (4.0 / self.vc.c) * np.sqrt(J / 100.0) * (self.A_w + 4.0) * np.power(self.vc.F_L, 0.25) # Chroma - t = (50000.0 / 13.0 * self.vc.Nc * self.vc.N_cb * e_t * - np.sqrt(a**2 + b**2) / (RGB_a[0] + RGB_a[1] + 21.0 * RGB_a[2] / 20.0)) - - C = t ** 0.9 * np.sqrt(J / 100.0) * np.power(1.64 - 0.29 ** self.vc.n, 0.73) + t = ( + 50000.0 + / 13.0 + * self.vc.Nc + * self.vc.N_cb + * e_t + * np.sqrt(a**2 + b**2) + / (RGB_a[0] + RGB_a[1] + 21.0 * RGB_a[2] / 20.0) + ) + + C = t**0.9 * np.sqrt(J / 100.0) * np.power(1.64 - 0.29**self.vc.n, 0.73) # Colorfulness M = C * np.power(self.vc.F_L, 0.25) @@ -312,16 +310,7 @@ def _forward_single(self, xyz: np.ndarray) -> dict: # Saturation s = 100.0 * np.sqrt(M / Q) if Q > 0 else 0.0 - return { - 'J': J, - 'C': C, - 'h': h, - 'M': M, - 's': s, - 'Q': Q, - 'a': a, - 'b': b - } + return {"J": J, "C": C, "h": h, "M": M, "s": s, "Q": Q, "a": a, "b": b} def cam16_to_xyz(self, J: float, C: float, h: float) -> np.ndarray: """ @@ -359,10 +348,7 @@ def cam16_to_xyz(self, J: float, C: float, h: float) -> np.ndarray: b = 0.0 else: # Calculate t from C and J - t = np.power( - C / (np.sqrt(J / 100.0) * np.power(1.64 - 0.29 ** self.vc.n, 0.73)), - 1.0 / 0.9 - ) + t = np.power(C / (np.sqrt(J / 100.0) * np.power(1.64 - 0.29**self.vc.n, 0.73)), 1.0 / 0.9) # p1 for chroma calculation p1 = (50000.0 / 13.0) * self.vc.Nc * self.vc.N_cb * e_t / t @@ -382,11 +368,13 @@ def cam16_to_xyz(self, J: float, C: float, h: float) -> np.ndarray: b = gamma * sin_h # RGB_a from a, b, and p2 - RGB_a = np.array([ - (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0, - (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0, - (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0 - ]) + RGB_a = np.array( + [ + (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0, + (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0, + (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0, + ] + ) # Inverse nonlinear adaptation RGB_c = self._nonlinear_adaptation_inv(RGB_a) @@ -422,8 +410,7 @@ def to_ucs(self, J: float, M: float, h: float) -> tuple[float, float, float]: return (J_prime, a_prime, b_prime) - def delta_E_cam16(self, jmh1: tuple[float, float, float], - jmh2: tuple[float, float, float]) -> float: + def delta_E_cam16(self, jmh1: tuple[float, float, float], jmh2: tuple[float, float, float]) -> float: """ Calculate CAM16-UCS color difference. @@ -462,17 +449,14 @@ def delta_E_cam16(self, jmh1: tuple[float, float, float], JZAZBZ_D0 = 1.6295499532821566e-11 # Jzazbz matrices -JZAZBZ_M1 = np.array([ - [0.41478972, 0.579999, 0.0146480], - [-0.2015100, 1.120649, 0.0531008], - [-0.0166008, 0.264800, 0.6684799] -], dtype=np.float64) - -JZAZBZ_M2 = np.array([ - [0.5, 0.5, 0], - [3.524000, -4.066708, 0.542708], - [0.199076, 1.096799, -1.295875] -], dtype=np.float64) +JZAZBZ_M1 = np.array( + [[0.41478972, 0.579999, 0.0146480], [-0.2015100, 1.120649, 0.0531008], [-0.0166008, 0.264800, 0.6684799]], + dtype=np.float64, +) + +JZAZBZ_M2 = np.array( + [[0.5, 0.5, 0], [3.524000, -4.066708, 0.542708], [0.199076, 1.096799, -1.295875]], dtype=np.float64 +) JZAZBZ_M1_INV = np.linalg.inv(JZAZBZ_M1) JZAZBZ_M2_INV = np.linalg.inv(JZAZBZ_M2) @@ -684,17 +668,9 @@ def delta_Ez(self, jzazbz1: np.ndarray, jzazbz2: np.ndarray) -> float: # ============================================================================= # ICtCp matrices (BT.2100) -ICTCP_M1 = np.array([ - [0.3592, 0.6976, -0.0358], - [-0.1922, 1.1004, 0.0754], - [0.0070, 0.0749, 0.8434] -], dtype=np.float64) - -ICTCP_M2 = np.array([ - [2048, 2048, 0], - [6610, -13613, 7003], - [17933, -17390, -543] -], dtype=np.float64) / 4096.0 +ICTCP_M1 = np.array([[0.3592, 0.6976, -0.0358], [-0.1922, 1.1004, 0.0754], [0.0070, 0.0749, 0.8434]], dtype=np.float64) + +ICTCP_M2 = np.array([[2048, 2048, 0], [6610, -13613, 7003], [17933, -17390, -543]], dtype=np.float64) / 4096.0 ICTCP_M1_INV = np.linalg.inv(ICTCP_M1) ICTCP_M2_INV = np.linalg.inv(ICTCP_M2) @@ -731,7 +707,7 @@ def __init__(self, peak_luminance: float = 10000.0): """ self.peak_luminance = peak_luminance - def rgb_to_ictcp(self, rgb: np.ndarray, input_space: str = 'bt2020') -> np.ndarray: + def rgb_to_ictcp(self, rgb: np.ndarray, input_space: str = "bt2020") -> np.ndarray: """ Convert linear RGB to ICtCp. @@ -749,7 +725,7 @@ def rgb_to_ictcp(self, rgb: np.ndarray, input_space: str = 'bt2020') -> np.ndarr rgb = rgb.reshape(1, 3) # Convert to BT.2020 if needed - if input_space == 'srgb': + if input_space == "srgb": # sRGB linear -> XYZ -> BT.2020 linear xyz = (SRGB_TO_XYZ @ rgb.T).T rgb = (XYZ_TO_BT2020 @ xyz.T).T @@ -780,7 +756,7 @@ def _forward_single(self, rgb: np.ndarray) -> np.ndarray: return ICtCp - def ictcp_to_rgb(self, ictcp: np.ndarray, output_space: str = 'bt2020') -> np.ndarray: + def ictcp_to_rgb(self, ictcp: np.ndarray, output_space: str = "bt2020") -> np.ndarray: """ Convert ICtCp to linear RGB. @@ -804,7 +780,7 @@ def ictcp_to_rgb(self, ictcp: np.ndarray, output_space: str = 'bt2020') -> np.nd results = np.array(results) / self.peak_luminance # Convert from BT.2020 if needed - if output_space == 'srgb': + if output_space == "srgb": xyz = (BT2020_TO_XYZ @ results.T).T results = (XYZ_TO_SRGB @ xyz.T).T @@ -854,8 +830,8 @@ def delta_E_ITP(self, ictcp1: np.ndarray, ictcp2: np.ndarray) -> float: # Convenience Functions # ============================================================================= -def xyz_to_cam16_jmh(xyz: np.ndarray, - viewing_conditions: CAM16ViewingConditions = None) -> tuple[float, float, float]: + +def xyz_to_cam16_jmh(xyz: np.ndarray, viewing_conditions: CAM16ViewingConditions = None) -> tuple[float, float, float]: """ Quick conversion from XYZ to CAM16 JMh (Lightness, Colorfulness, Hue). @@ -868,7 +844,7 @@ def xyz_to_cam16_jmh(xyz: np.ndarray, """ cam16 = CAM16(viewing_conditions) result = cam16.xyz_to_cam16(xyz) - return (result['J'], result['M'], result['h']) + return (result["J"], result["M"], result["h"]) def xyz_to_jzazbz(xyz: np.ndarray, peak_luminance: float = 10000.0) -> np.ndarray: @@ -886,8 +862,7 @@ def xyz_to_jzazbz(xyz: np.ndarray, peak_luminance: float = 10000.0) -> np.ndarra return jz.xyz_to_jzazbz(xyz) -def rgb_to_ictcp(rgb: np.ndarray, input_space: str = 'bt2020', - peak_luminance: float = 10000.0) -> np.ndarray: +def rgb_to_ictcp(rgb: np.ndarray, input_space: str = "bt2020", peak_luminance: float = 10000.0) -> np.ndarray: """ Quick conversion from linear RGB to ICtCp. @@ -903,10 +878,13 @@ def rgb_to_ictcp(rgb: np.ndarray, input_space: str = 'bt2020', return ictcp.rgb_to_ictcp(rgb, input_space) -def delta_e_hdr(color1: np.ndarray, color2: np.ndarray, - color_space: str = 'xyz', - method: str = 'jzazbz', - peak_luminance: float = 10000.0) -> float: +def delta_e_hdr( + color1: np.ndarray, + color2: np.ndarray, + color_space: str = "xyz", + method: str = "jzazbz", + peak_luminance: float = 10000.0, +) -> float: """ Calculate perceptually uniform color difference for HDR content. @@ -922,19 +900,18 @@ def delta_e_hdr(color1: np.ndarray, color2: np.ndarray, Returns: Perceptually uniform color difference """ - if color_space == 'xyz': - if method == 'jzazbz': + if color_space == "xyz": + if method == "jzazbz": jz = Jzazbz(peak_luminance) c1 = jz.xyz_to_jzazbz(color1) c2 = jz.xyz_to_jzazbz(color2) return jz.delta_Ez(c1, c2) - elif method == 'cam16': + elif method == "cam16": cam = CAM16() r1 = cam.xyz_to_cam16(color1) r2 = cam.xyz_to_cam16(color2) - return cam.delta_E_cam16((r1['J'], r1['M'], r1['h']), - (r2['J'], r2['M'], r2['h'])) - elif method == 'ictcp': + return cam.delta_E_cam16((r1["J"], r1["M"], r1["h"]), (r2["J"], r2["M"], r2["h"])) + elif method == "ictcp": # Need to go through BT.2020 RGB rgb1 = (XYZ_TO_BT2020 @ color1).clip(0, 1) rgb2 = (XYZ_TO_BT2020 @ color2).clip(0, 1) @@ -942,13 +919,13 @@ def delta_e_hdr(color1: np.ndarray, color2: np.ndarray, c1 = ictcp.rgb_to_ictcp(rgb1) c2 = ictcp.rgb_to_ictcp(rgb2) return ictcp.delta_E_ITP(c1, c2) - elif color_space == 'jzazbz': + elif color_space == "jzazbz": jz = Jzazbz(peak_luminance) return jz.delta_Ez(color1, color2) - elif color_space == 'ictcp': + elif color_space == "ictcp": ictcp = ICtCp(peak_luminance) return ictcp.delta_E_ITP(color1, color2) - elif color_space == 'cam16_jmh': + elif color_space == "cam16_jmh": cam = CAM16() return cam.delta_E_cam16(color1, color2) diff --git a/calibrate_pro/core/icc_profile.py b/calibrate_pro/core/icc_profile.py index 48f669a..214dc03 100644 --- a/calibrate_pro/core/icc_profile.py +++ b/calibrate_pro/core/icc_profile.py @@ -26,30 +26,30 @@ # ============================================================================= # Profile signatures -ICC_MAGIC = b'acsp' +ICC_MAGIC = b"acsp" PROFILE_VERSION = 0x04400000 # v4.4 # Device classes -DEVICE_CLASS_INPUT = b'scnr' -DEVICE_CLASS_DISPLAY = b'mntr' -DEVICE_CLASS_OUTPUT = b'prtr' -DEVICE_CLASS_LINK = b'link' -DEVICE_CLASS_ABSTRACT = b'abst' -DEVICE_CLASS_COLORSPACE = b'spac' -DEVICE_CLASS_NAMED = b'nmcl' +DEVICE_CLASS_INPUT = b"scnr" +DEVICE_CLASS_DISPLAY = b"mntr" +DEVICE_CLASS_OUTPUT = b"prtr" +DEVICE_CLASS_LINK = b"link" +DEVICE_CLASS_ABSTRACT = b"abst" +DEVICE_CLASS_COLORSPACE = b"spac" +DEVICE_CLASS_NAMED = b"nmcl" # Color spaces -COLOR_SPACE_XYZ = b'XYZ ' -COLOR_SPACE_LAB = b'Lab ' -COLOR_SPACE_RGB = b'RGB ' -COLOR_SPACE_GRAY = b'GRAY' -COLOR_SPACE_CMYK = b'CMYK' +COLOR_SPACE_XYZ = b"XYZ " +COLOR_SPACE_LAB = b"Lab " +COLOR_SPACE_RGB = b"RGB " +COLOR_SPACE_GRAY = b"GRAY" +COLOR_SPACE_CMYK = b"CMYK" # Platform signatures -PLATFORM_MICROSOFT = b'MSFT' -PLATFORM_APPLE = b'APPL' -PLATFORM_SGI = b'SGI ' -PLATFORM_SUN = b'SUNW' +PLATFORM_MICROSOFT = b"MSFT" +PLATFORM_APPLE = b"APPL" +PLATFORM_SGI = b"SGI " +PLATFORM_SUN = b"SUNW" # Rendering intents INTENT_PERCEPTUAL = 0 @@ -58,37 +58,39 @@ INTENT_ABSOLUTE = 3 # Tag signatures -TAG_DESC = b'desc' -TAG_CPRT = b'cprt' -TAG_WTPT = b'wtpt' -TAG_BKPT = b'bkpt' -TAG_RXYY = b'rXYZ' -TAG_GXYY = b'gXYZ' -TAG_BXYY = b'bXYZ' -TAG_RTRC = b'rTRC' -TAG_GTRC = b'gTRC' -TAG_BTRC = b'bTRC' -TAG_CHAD = b'chad' -TAG_VCGT = b'vcgt' -TAG_MHC2 = b'MHC2' -TAG_A2B0 = b'A2B0' -TAG_B2A0 = b'B2A0' +TAG_DESC = b"desc" +TAG_CPRT = b"cprt" +TAG_WTPT = b"wtpt" +TAG_BKPT = b"bkpt" +TAG_RXYY = b"rXYZ" +TAG_GXYY = b"gXYZ" +TAG_BXYY = b"bXYZ" +TAG_RTRC = b"rTRC" +TAG_GTRC = b"gTRC" +TAG_BTRC = b"bTRC" +TAG_CHAD = b"chad" +TAG_VCGT = b"vcgt" +TAG_MHC2 = b"MHC2" +TAG_A2B0 = b"A2B0" +TAG_B2A0 = b"B2A0" # Type signatures -TYPE_DESC = b'desc' -TYPE_MLUC = b'mluc' -TYPE_TEXT = b'text' -TYPE_XYZ = b'XYZ ' -TYPE_CURV = b'curv' -TYPE_PARA = b'para' -TYPE_S15F16 = b'sf32' -TYPE_VCGT = b'vcgt' +TYPE_DESC = b"desc" +TYPE_MLUC = b"mluc" +TYPE_TEXT = b"text" +TYPE_XYZ = b"XYZ " +TYPE_CURV = b"curv" +TYPE_PARA = b"para" +TYPE_S15F16 = b"sf32" +TYPE_VCGT = b"vcgt" + @dataclass class ICCHeader: """ICC profile header (128 bytes).""" + profile_size: int = 0 - preferred_cmm: bytes = b'lcms' + preferred_cmm: bytes = b"lcms" version: int = PROFILE_VERSION device_class: bytes = DEVICE_CLASS_DISPLAY color_space: bytes = COLOR_SPACE_RGB @@ -97,14 +99,14 @@ class ICCHeader: signature: bytes = ICC_MAGIC platform: bytes = PLATFORM_MICROSOFT flags: int = 0 - manufacturer: bytes = b'QNTA' - model: bytes = b'CALB' + manufacturer: bytes = b"QNTA" + model: bytes = b"CALB" attributes: int = 0 intent: int = INTENT_RELATIVE illuminant_x: float = 0.96420 illuminant_y: float = 1.00000 illuminant_z: float = 0.82491 - creator: bytes = b'QNTA' + creator: bytes = b"QNTA" def __post_init__(self): if self.creation_date is None: @@ -114,37 +116,39 @@ def to_bytes(self) -> bytes: """Serialize header to 128 bytes.""" # Date/time encoding dt = self.creation_date - date_bytes = struct.pack('>HHHHHH', - dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) + date_bytes = struct.pack(">HHHHHH", dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) # Fixed-point illuminant values (s15Fixed16) def to_s15f16(v): return int(v * 65536) & 0xFFFFFFFF - header = struct.pack('>I', self.profile_size) # 0-3 - header += self.preferred_cmm[:4].ljust(4, b'\x00') # 4-7 - header += struct.pack('>I', self.version) # 8-11 - header += self.device_class[:4].ljust(4, b'\x00') # 12-15 - header += self.color_space[:4].ljust(4, b'\x00') # 16-19 - header += self.pcs[:4].ljust(4, b'\x00') # 20-23 - header += date_bytes # 24-35 - header += self.signature[:4] # 36-39 - header += self.platform[:4].ljust(4, b'\x00') # 40-43 - header += struct.pack('>I', self.flags) # 44-47 - header += self.manufacturer[:4].ljust(4, b'\x00') # 48-51 - header += self.model[:4].ljust(4, b'\x00') # 52-55 - header += struct.pack('>Q', self.attributes) # 56-63 - header += struct.pack('>I', self.intent) # 64-67 - header += struct.pack('>III', # 68-79 + header = struct.pack(">I", self.profile_size) # 0-3 + header += self.preferred_cmm[:4].ljust(4, b"\x00") # 4-7 + header += struct.pack(">I", self.version) # 8-11 + header += self.device_class[:4].ljust(4, b"\x00") # 12-15 + header += self.color_space[:4].ljust(4, b"\x00") # 16-19 + header += self.pcs[:4].ljust(4, b"\x00") # 20-23 + header += date_bytes # 24-35 + header += self.signature[:4] # 36-39 + header += self.platform[:4].ljust(4, b"\x00") # 40-43 + header += struct.pack(">I", self.flags) # 44-47 + header += self.manufacturer[:4].ljust(4, b"\x00") # 48-51 + header += self.model[:4].ljust(4, b"\x00") # 52-55 + header += struct.pack(">Q", self.attributes) # 56-63 + header += struct.pack(">I", self.intent) # 64-67 + header += struct.pack( + ">III", # 68-79 to_s15f16(self.illuminant_x), to_s15f16(self.illuminant_y), - to_s15f16(self.illuminant_z)) - header += self.creator[:4].ljust(4, b'\x00') # 80-83 - header += b'\x00' * 16 # 84-99 (MD5) - header += b'\x00' * 28 # 100-127 (reserved) + to_s15f16(self.illuminant_z), + ) + header += self.creator[:4].ljust(4, b"\x00") # 80-83 + header += b"\x00" * 16 # 84-99 (MD5) + header += b"\x00" * 28 # 100-127 (reserved) return header + class ICCProfile: """ ICC v4 Profile Builder. @@ -157,7 +161,7 @@ def __init__( description: str = "Calibrate Pro Display Profile", copyright: str = "Copyright Zain Dana Harper 2022-2026", manufacturer: str = "QNTA", - model: str = "CALB" + model: str = "CALB", ): """ Initialize ICC profile builder. @@ -170,10 +174,7 @@ def __init__( """ self.description = description self.copyright = copyright - self.header = ICCHeader( - manufacturer=manufacturer.encode()[:4], - model=model.encode()[:4] - ) + self.header = ICCHeader(manufacturer=manufacturer.encode()[:4], model=model.encode()[:4]) self.tags = {} # Default to D65 primaries (will be overwritten) @@ -200,7 +201,7 @@ def set_primaries( red: tuple[float, float], green: tuple[float, float], blue: tuple[float, float], - white: tuple[float, float] = (0.3127, 0.3290) + white: tuple[float, float] = (0.3127, 0.3290), ): """Set display primary chromaticities.""" self.red_primary = red @@ -214,12 +215,7 @@ def set_gamma(self, red: float, green: float, blue: float): self.gamma_green = green self.gamma_blue = blue - def set_trc_curves( - self, - red: np.ndarray, - green: np.ndarray, - blue: np.ndarray - ): + def set_trc_curves(self, red: np.ndarray, green: np.ndarray, blue: np.ndarray): """ Set per-channel TRC curves (overrides gamma). @@ -230,12 +226,7 @@ def set_trc_curves( self.trc_green = np.asarray(green, dtype=np.float64) self.trc_blue = np.asarray(blue, dtype=np.float64) - def set_vcgt( - self, - red: np.ndarray, - green: np.ndarray, - blue: np.ndarray - ): + def set_vcgt(self, red: np.ndarray, green: np.ndarray, blue: np.ndarray): """ Set VCGT (Video Card Gamma Table) for calibration. @@ -245,58 +236,58 @@ def set_vcgt( self.vcgt = ( np.asarray(red, dtype=np.float64), np.asarray(green, dtype=np.float64), - np.asarray(blue, dtype=np.float64) + np.asarray(blue, dtype=np.float64), ) def _build_desc_tag(self, text: str) -> bytes: """Build multi-localized Unicode description tag (mluc).""" # UTF-16BE encoded string - text_bytes = text.encode('utf-16-be') + text_bytes = text.encode("utf-16-be") # mluc tag structure tag = TYPE_MLUC - tag += b'\x00\x00\x00\x00' # Reserved - tag += struct.pack('>I', 1) # Number of records - tag += struct.pack('>I', 12) # Record size + tag += b"\x00\x00\x00\x00" # Reserved + tag += struct.pack(">I", 1) # Number of records + tag += struct.pack(">I", 12) # Record size # Language/country record - tag += b'enUS' # Language and country - tag += struct.pack('>I', len(text_bytes) + 2) # String length (with BOM) - tag += struct.pack('>I', 28) # Offset to string + tag += b"enUS" # Language and country + tag += struct.pack(">I", len(text_bytes) + 2) # String length (with BOM) + tag += struct.pack(">I", 28) # Offset to string # Padding and string - tag += b'\xfe\xff' # UTF-16 BOM + tag += b"\xfe\xff" # UTF-16 BOM tag += text_bytes # Pad to 4-byte boundary while len(tag) % 4 != 0: - tag += b'\x00' + tag += b"\x00" return tag def _build_text_tag(self, text: str) -> bytes: """Build simple text tag.""" - text_bytes = text.encode('ascii', errors='replace') + b'\x00' + text_bytes = text.encode("ascii", errors="replace") + b"\x00" tag = TYPE_TEXT - tag += b'\x00\x00\x00\x00' # Reserved + tag += b"\x00\x00\x00\x00" # Reserved tag += text_bytes # Pad to 4-byte boundary while len(tag) % 4 != 0: - tag += b'\x00' + tag += b"\x00" return tag def _build_xyz_tag(self, x: float, y: float, z: float) -> bytes: """Build XYZ tag with single XYZ value.""" + def to_s15f16(v): return int(v * 65536) & 0xFFFFFFFF tag = TYPE_XYZ - tag += b'\x00\x00\x00\x00' # Reserved - tag += struct.pack('>III', - to_s15f16(x), to_s15f16(y), to_s15f16(z)) + tag += b"\x00\x00\x00\x00" # Reserved + tag += struct.pack(">III", to_s15f16(x), to_s15f16(y), to_s15f16(z)) return tag @@ -312,23 +303,23 @@ def _build_curv_tag(self, gamma_or_curve: float | np.ndarray) -> bytes: count = 1 # Store gamma as u8Fixed8 (8.8 fixed point) gamma_fixed = int(gamma_or_curve * 256) & 0xFFFF - curve_data = struct.pack('>H', gamma_fixed) + curve_data = struct.pack(">H", gamma_fixed) else: # Table-based curve curve = np.asarray(gamma_or_curve, dtype=np.float64) count = len(curve) # Convert to 16-bit values values = np.clip(curve * 65535, 0, 65535).astype(np.uint16) - curve_data = struct.pack(f'>{count}H', *values) + curve_data = struct.pack(f">{count}H", *values) tag = TYPE_CURV - tag += b'\x00\x00\x00\x00' # Reserved - tag += struct.pack('>I', count) + tag += b"\x00\x00\x00\x00" # Reserved + tag += struct.pack(">I", count) tag += curve_data # Pad to 4-byte boundary while len(tag) % 4 != 0: - tag += b'\x00' + tag += b"\x00" return tag @@ -338,10 +329,10 @@ def _build_chad_tag(self) -> bytes: matrix = get_adaptation_matrix(D65_WHITE, D50_WHITE) def to_s15f16(v): - return struct.pack('>i', int(v * 65536)) + return struct.pack(">i", int(v * 65536)) tag = TYPE_S15F16 - tag += b'\x00\x00\x00\x00' # Reserved + tag += b"\x00\x00\x00\x00" # Reserved # Write 3x3 matrix as row-major s15Fixed16 values for row in matrix: @@ -353,37 +344,32 @@ def to_s15f16(v): def _build_vcgt_tag(self) -> bytes: """Build VCGT (Video Card Gamma Table) tag.""" if self.vcgt is None: - return b'' + return b"" red, green, blue = self.vcgt count = len(red) tag = TYPE_VCGT - tag += b'\x00\x00\x00\x00' # Reserved - tag += struct.pack('>I', 0) # Type: table - tag += struct.pack('>HHH', count, count, count) # Table sizes - tag += struct.pack('>H', 16) # Entry size (16-bit) + tag += b"\x00\x00\x00\x00" # Reserved + tag += struct.pack(">I", 0) # Type: table + tag += struct.pack(">HHH", count, count, count) # Table sizes + tag += struct.pack(">H", 16) # Entry size (16-bit) # Write tables as 16-bit values for curve in [red, green, blue]: values = np.clip(curve * 65535, 0, 65535).astype(np.uint16) - tag += struct.pack(f'>{count}H', *values) + tag += struct.pack(f">{count}H", *values) # Pad to 4-byte boundary while len(tag) % 4 != 0: - tag += b'\x00' + tag += b"\x00" return tag def _calculate_xyz_primaries(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Calculate XYZ values for primaries adapted to D50.""" # Build RGB to XYZ matrix from primaries - rgb_to_xyz = primaries_to_xyz_matrix( - self.red_primary, - self.green_primary, - self.blue_primary, - self.white_point - ) + rgb_to_xyz = primaries_to_xyz_matrix(self.red_primary, self.green_primary, self.blue_primary, self.white_point) # Get individual primary XYZ values red_xyz = rgb_to_xyz[:, 0] @@ -409,8 +395,7 @@ def build(self) -> bytes: self.tags[TAG_CPRT] = self._build_text_tag(self.copyright) # White point (D50 for PCS) - self.tags[TAG_WTPT] = self._build_xyz_tag( - D50_WHITE.X, D50_WHITE.Y, D50_WHITE.Z) + self.tags[TAG_WTPT] = self._build_xyz_tag(D50_WHITE.X, D50_WHITE.Y, D50_WHITE.Z) # Primary XYZ values (adapted to D50) red_xyz, green_xyz, blue_xyz = self._calculate_xyz_primaries() @@ -445,7 +430,7 @@ def build(self) -> bytes: # Calculate tag offsets current_offset = header_size + tag_table_size tag_offsets = {} - tag_data = b'' + tag_data = b"" for sig, data in self.tags.items(): tag_offsets[sig] = current_offset @@ -453,10 +438,10 @@ def build(self) -> bytes: current_offset += len(data) # Build tag table - tag_table = struct.pack('>I', tag_count) + tag_table = struct.pack(">I", tag_count) for sig, data in self.tags.items(): tag_table += sig - tag_table += struct.pack('>II', tag_offsets[sig], len(data)) + tag_table += struct.pack(">II", tag_offsets[sig], len(data)) # Calculate total profile size profile_size = header_size + len(tag_table) + len(tag_data) @@ -471,9 +456,9 @@ def build(self) -> bytes: # Calculate and insert MD5 (bytes 84-99) # Zero out MD5 area for calculation profile_for_hash = bytearray(profile) - profile_for_hash[44:48] = b'\x00\x00\x00\x00' # flags - profile_for_hash[64:68] = b'\x00\x00\x00\x00' # intent - profile_for_hash[84:100] = b'\x00' * 16 # MD5 + profile_for_hash[44:48] = b"\x00\x00\x00\x00" # flags + profile_for_hash[64:68] = b"\x00\x00\x00\x00" # intent + profile_for_hash[84:100] = b"\x00" * 16 # MD5 md5_hash = hashlib.md5(bytes(profile_for_hash)).digest() @@ -495,7 +480,7 @@ def save(self, filepath: str | Path) -> Path: filepath = Path(filepath) profile_data = self.build() - with open(filepath, 'wb') as f: + with open(filepath, "wb") as f: f.write(profile_data) return filepath @@ -510,7 +495,7 @@ def create_display_profile( gamma: float | tuple[float, float, float] = 2.2, trc_curves: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None, vcgt: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None, - copyright: str = "Copyright Zain Dana Harper 2022-2026 - Calibrate Pro" + copyright: str = "Copyright Zain Dana Harper 2022-2026 - Calibrate Pro", ) -> ICCProfile: """ Create a display calibration profile. @@ -537,11 +522,7 @@ def create_display_profile( if trc_curves is not None: profile.set_trc_curves(*trc_curves) elif isinstance(gamma, (tuple, list)): - profile.set_trc_curves( - generate_trc_curve(gamma[0]), - generate_trc_curve(gamma[1]), - generate_trc_curve(gamma[2]) - ) + profile.set_trc_curves(generate_trc_curve(gamma[0]), generate_trc_curve(gamma[1]), generate_trc_curve(gamma[2])) else: curve = generate_trc_curve(gamma) profile.set_trc_curves(curve, curve, curve) @@ -553,10 +534,7 @@ def create_display_profile( def generate_trc_curve( - gamma: float, - points: int = 256, - black_offset: float = 0.0, - white_offset: float = 0.0 + gamma: float, points: int = 256, black_offset: float = 0.0, white_offset: float = 0.0 ) -> np.ndarray: """ Generate a TRC curve with optional offsets. @@ -592,12 +570,7 @@ def generate_srgb_trc(points: int = 256) -> np.ndarray: return curve -def generate_bt1886_trc( - points: int = 256, - gamma: float = 2.4, - Lw: float = 1.0, - Lb: float = 0.0 -) -> np.ndarray: +def generate_bt1886_trc(points: int = 256, gamma: float = 2.4, Lw: float = 1.0, Lb: float = 0.0) -> np.ndarray: """ Generate BT.1886 EOTF curve. @@ -609,8 +582,8 @@ def generate_bt1886_trc( """ x = np.linspace(0, 1, points) - a = (Lw ** (1/gamma) - Lb ** (1/gamma)) ** gamma - b = Lb ** (1/gamma) / (Lw ** (1/gamma) - Lb ** (1/gamma)) + a = (Lw ** (1 / gamma) - Lb ** (1 / gamma)) ** gamma + b = Lb ** (1 / gamma) / (Lw ** (1 / gamma) - Lb ** (1 / gamma)) curve = a * np.power(np.maximum(x + b, 0), gamma) curve = curve / Lw # Normalize to [0, 1] diff --git a/calibrate_pro/core/lut_engine.py b/calibrate_pro/core/lut_engine.py index 19ddcf4..9576a68 100644 --- a/calibrate_pro/core/lut_engine.py +++ b/calibrate_pro/core/lut_engine.py @@ -32,12 +32,14 @@ class LUTFormat(Enum): """Supported 3D LUT file formats.""" - CUBE = "cube" # DaVinci Resolve / Adobe standard - DL3 = "3dl" # Autodesk Lustre / Flame - MGA = "mga" # Pandora - CSP = "csp" # Rising Sun Research Cinespace - ICC = "icc" # Embedded in ICC profile - CLF = "clf" # SMPTE ST 2136-1 Common LUT Format (ACES) + + CUBE = "cube" # DaVinci Resolve / Adobe standard + DL3 = "3dl" # Autodesk Lustre / Flame + MGA = "mga" # Pandora + CSP = "csp" # Rising Sun Research Cinespace + ICC = "icc" # Embedded in ICC profile + CLF = "clf" # SMPTE ST 2136-1 Common LUT Format (ACES) + @dataclass class LUT3D: @@ -46,6 +48,7 @@ class LUT3D: Stores RGB-to-RGB color transformation as a 3D grid. """ + size: int # Grid size (e.g., 17, 33, 65) data: np.ndarray # Shape: (size, size, size, 3) title: str = "Calibrate Pro 3D LUT" @@ -57,7 +60,7 @@ def create_identity(cls, size: int = 33) -> "LUT3D": """Create an identity (no-op) 3D LUT.""" # Create coordinate grids coords = np.linspace(0, 1, size) - r, g, b = np.meshgrid(coords, coords, coords, indexing='ij') + r, g, b = np.meshgrid(coords, coords, coords, indexing="ij") # Stack into (size, size, size, 3) array data = np.stack([r, g, b], axis=-1) @@ -96,7 +99,7 @@ def apply(self, rgb: np.ndarray) -> np.ndarray: self.data[:, :, :, c], coords.T, order=1, # Trilinear interpolation - mode='nearest' + mode="nearest", ) # Reshape to original @@ -131,8 +134,8 @@ def save(self, filepath: str | Path, format: LUTFormat = LUTFormat.CUBE): def _save_cube(self, filepath: Path): """Save in .cube format (Resolve/Adobe).""" - with open(filepath, 'w') as f: - f.write(f"TITLE \"{self.title}\"\n") + with open(filepath, "w") as f: + f.write(f'TITLE "{self.title}"\n') f.write(f"LUT_3D_SIZE {self.size}\n") f.write(f"DOMAIN_MIN {self.domain_min[0]:.6f} {self.domain_min[1]:.6f} {self.domain_min[2]:.6f}\n") f.write(f"DOMAIN_MAX {self.domain_max[0]:.6f} {self.domain_max[1]:.6f} {self.domain_max[2]:.6f}\n") @@ -150,7 +153,7 @@ def _save_3dl(self, filepath: Path): # 3dl uses integer values (0-4095 for 12-bit) max_val = 4095 - with open(filepath, 'w') as f: + with open(filepath, "w") as f: # Header: input shaper values for i in range(self.size): val = int(i / (self.size - 1) * max_val) @@ -169,7 +172,7 @@ def _save_3dl(self, filepath: Path): def _save_mga(self, filepath: Path): """Save in .mga format (Pandora).""" - with open(filepath, 'w') as f: + with open(filepath, "w") as f: f.write("LUT8\n") f.write(f"{self.size}\n") @@ -181,13 +184,13 @@ def _save_mga(self, filepath: Path): def _save_csp(self, filepath: Path): """Save in .csp format (Cinespace).""" - with open(filepath, 'w') as f: + with open(filepath, "w") as f: f.write("CSPLUTV100\n") f.write("3D\n\n") # Input ranges f.write("BEGIN METADATA\n") - f.write(f"TITLE \"{self.title}\"\n") + f.write(f'TITLE "{self.title}"\n') f.write("END METADATA\n\n") # Shaper (identity) @@ -252,15 +255,13 @@ def save_clf(self, filepath: str | Path): for g in range(self.size): for b in range(self.size): val = self.data[r, g, b] - lines.append( - f"{val[0]:.6f} {val[1]:.6f} {val[2]:.6f}" - ) + lines.append(f"{val[0]:.6f} {val[1]:.6f} {val[2]:.6f}") array_elem.text = "\n" + "\n".join(lines) + "\n" # Write XML with declaration tree = ElementTree(root) - with open(filepath, 'wb') as f: - tree.write(f, encoding='UTF-8', xml_declaration=True) + with open(filepath, "wb") as f: + tree.write(f, encoding="UTF-8", xml_declaration=True) def save_reshade_png(self, filepath: str | Path): """ @@ -313,29 +314,22 @@ def save_reshade_png(self, filepath: str | Path): # Write minimal PNG using struct + zlib def png_chunk(chunk_type: bytes, data: bytes) -> bytes: c = chunk_type + data - return ( - struct.pack('>I', len(data)) - + c - + struct.pack('>I', zlib.crc32(c) & 0xFFFFFFFF) - ) + return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF) - sig = b'\x89PNG\r\n\x1a\n' - ihdr = png_chunk( - b'IHDR', - struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0) - ) + sig = b"\x89PNG\r\n\x1a\n" + ihdr = png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) # Build raw scanlines with filter byte (0 = None) per row raw = bytearray() for y in range(height): raw.append(0) # filter type: None row_start = y * width * 3 - raw.extend(rgb_data[row_start:row_start + width * 3]) + raw.extend(rgb_data[row_start : row_start + width * 3]) - idat = png_chunk(b'IDAT', zlib.compress(bytes(raw))) - iend = png_chunk(b'IEND', b'') + idat = png_chunk(b"IDAT", zlib.compress(bytes(raw))) + iend = png_chunk(b"IEND", b"") - with open(filepath, 'wb') as f: + with open(filepath, "wb") as f: f.write(sig + ihdr + idat + iend) def save_specialk_png(self, filepath: str | Path): @@ -372,12 +366,12 @@ def save_madvr_3dlut(self, filepath: str | Path): input_range = 0 # 0 = full range max_val = (1 << bit_depth) - 1 # 65535 for 16-bit - with open(filepath, 'wb') as f: + with open(filepath, "wb") as f: # Header - f.write(b'3DLT') - f.write(struct.pack(' "LUT3D": with open(filepath) as f: for line in f: line = line.strip() - if not line or line.startswith('#'): + if not line or line.startswith("#"): continue - if line.startswith('TITLE'): + if line.startswith("TITLE"): title = line.split('"')[1] if '"' in line else line.split()[1] - elif line.startswith('LUT_3D_SIZE'): + elif line.startswith("LUT_3D_SIZE"): size = int(line.split()[1]) - elif line.startswith('DOMAIN_MIN'): + elif line.startswith("DOMAIN_MIN"): parts = line.split()[1:] domain_min = tuple(float(p) for p in parts) - elif line.startswith('DOMAIN_MAX'): + elif line.startswith("DOMAIN_MAX"): parts = line.split()[1:] domain_max = tuple(float(p) for p in parts) - elif line[0].isdigit() or line[0] == '-': + elif line[0].isdigit() or line[0] == "-": parts = line.split() if len(parts) >= 3: values.append([float(p) for p in parts[:3]]) if size is None: # Infer size from data count - size = int(round(len(values) ** (1/3))) + size = int(round(len(values) ** (1 / 3))) # .cube format: R varies fastest (innermost), B varies slowest (outermost) # After reshape: data[b, g, r] contains value for input (r, g, b) @@ -531,8 +525,7 @@ def load_cube(cls, filepath: str | Path) -> "LUT3D": data = np.array(values).reshape(size, size, size, 3) data = np.transpose(data, (2, 1, 0, 3)) # Swap B (axis 0) with R (axis 2) - return cls(size=size, data=data, title=title, - domain_min=domain_min, domain_max=domain_max) + return cls(size=size, data=data, title=title, domain_min=domain_min, domain_max=domain_max) @classmethod def load(cls, filepath: str | Path) -> "LUT3D": @@ -555,15 +548,15 @@ def load(cls, filepath: str | Path) -> "LUT3D": filepath = Path(filepath) ext = filepath.suffix.lower() - if ext == '.cube': + if ext == ".cube": return cls.load_cube(filepath) - elif ext == '.3dl': + elif ext == ".3dl": return cls._load_3dl(filepath) - elif ext == '.mga': + elif ext == ".mga": return cls._load_mga(filepath) - elif ext == '.csp': + elif ext == ".csp": return cls._load_csp(filepath) - elif ext in ('.clf', '.ctf'): + elif ext in (".clf", ".ctf"): return cls._load_clf(filepath) else: # Try cube format as default @@ -576,7 +569,7 @@ def load(cls, filepath: str | Path) -> "LUT3D": def _load_3dl(cls, filepath: Path) -> "LUT3D": """Load LUT from .3dl file.""" with open(filepath) as f: - lines = [l.strip() for l in f if l.strip() and not l.startswith('#')] + lines = [l.strip() for l in f if l.strip() and not l.startswith("#")] # First line is input shaper, skip it # Rest is LUT data @@ -587,7 +580,7 @@ def _load_3dl(cls, filepath: Path) -> "LUT3D": # 3dl uses 12-bit integers (0-4095) values.append([int(p) / 4095.0 for p in parts[:3]]) - size = int(round(len(values) ** (1/3))) + size = int(round(len(values) ** (1 / 3))) # 3dl order is BGR, need to reorder data = np.array(values).reshape(size, size, size, 3) # Transpose from BGR to RGB order @@ -621,7 +614,7 @@ def _load_csp(cls, filepath: Path) -> "LUT3D": with open(filepath) as f: content = f.read() - lines = [l.strip() for l in content.split('\n') if l.strip()] + lines = [l.strip() for l in content.split("\n") if l.strip()] # Find LUT size line (three numbers) size = None @@ -647,7 +640,7 @@ def _load_csp(cls, filepath: Path) -> "LUT3D": except ValueError: continue - data = np.array(values[:size**3]).reshape(size, size, size, 3) + data = np.array(values[: size**3]).reshape(size, size, size, 3) data = np.transpose(data, (2, 1, 0, 3)) return cls(size=size, data=data, title=filepath.stem) @@ -690,10 +683,8 @@ def _load_clf(cls, filepath: Path) -> "LUT3D": if len(parts) >= 3: values.append([float(p) for p in parts[:3]]) - if len(values) != size ** 3: - raise ValueError( - f"CLF Array has {len(values)} entries, expected {size**3}" - ) + if len(values) != size**3: + raise ValueError(f"CLF Array has {len(values)} entries, expected {size**3}") # CLF ordering matches .cube: R outermost, G middle, B innermost # data[r, g, b] = values[r * size*size + g * size + b] @@ -723,7 +714,7 @@ def create_from_matrix( matrix: np.ndarray, input_gamma: float = 2.2, output_gamma: float = 2.2, - title: str = "Matrix Correction LUT" + title: str = "Matrix Correction LUT", ) -> LUT3D: """ Create LUT from 3x3 color correction matrix. @@ -760,7 +751,7 @@ def create_from_primaries( dest_primaries: tuple[tuple[float, float], ...], source_white: tuple[float, float] = (0.3127, 0.3290), dest_white: tuple[float, float] = (0.3127, 0.3290), - title: str = "Gamut Mapping LUT" + title: str = "Gamut Mapping LUT", ) -> LUT3D: """ Create LUT for gamut mapping between color spaces. @@ -776,11 +767,9 @@ def create_from_primaries( # Build transformation matrices src_to_xyz = primaries_to_xyz_matrix( - source_primaries[0], source_primaries[1], - source_primaries[2], source_white) - dst_to_xyz = primaries_to_xyz_matrix( - dest_primaries[0], dest_primaries[1], - dest_primaries[2], dest_white) + source_primaries[0], source_primaries[1], source_primaries[2], source_white + ) + dst_to_xyz = primaries_to_xyz_matrix(dest_primaries[0], dest_primaries[1], dest_primaries[2], dest_white) xyz_to_dst = np.linalg.inv(dst_to_xyz) lut = LUT3D.create_identity(self.size) @@ -807,9 +796,7 @@ def create_from_primaries( return lut def create_from_function( - self, - transform_func: Callable[[np.ndarray], np.ndarray], - title: str = "Custom Transform LUT" + self, transform_func: Callable[[np.ndarray], np.ndarray], title: str = "Custom Transform LUT" ) -> LUT3D: """ Create LUT from arbitrary transformation function. @@ -842,7 +829,7 @@ def create_calibration_lut( gamma_blue: float = 2.2, color_matrix: np.ndarray | None = None, title: str = "Display Calibration LUT", - target_gamma: float = 2.2 + target_gamma: float = 2.2, ) -> LUT3D: """ Create comprehensive calibration LUT for display. @@ -871,18 +858,18 @@ def create_calibration_lut( target_primaries = ( (0.6400, 0.3300), # Red (0.3000, 0.6000), # Green - (0.1500, 0.0600) # Blue + (0.1500, 0.0600), # Blue ) # Build color correction matrix from primaries if not provided if color_matrix is None: # Calculate matrix to convert from target to panel primaries target_to_xyz = primaries_to_xyz_matrix( - target_primaries[0], target_primaries[1], - target_primaries[2], target_white) + target_primaries[0], target_primaries[1], target_primaries[2], target_white + ) panel_to_xyz = primaries_to_xyz_matrix( - panel_primaries[0], panel_primaries[1], - panel_primaries[2], panel_white) + panel_primaries[0], panel_primaries[1], panel_primaries[2], panel_white + ) xyz_to_panel = np.linalg.inv(panel_to_xyz) # This matrix converts target linear RGB to what the panel needs @@ -929,11 +916,13 @@ def create_calibration_lut( # Step 4: Apply inverse of panel gamma to encode for panel # output = linear^(1/panel_gamma) # Use safe power that preserves zeros - rgb_output = np.array([ - np.power(rgb_panel_linear[0], 1.0 / gamma_red) if rgb_panel_linear[0] > EPS else 0.0, - np.power(rgb_panel_linear[1], 1.0 / gamma_green) if rgb_panel_linear[1] > EPS else 0.0, - np.power(rgb_panel_linear[2], 1.0 / gamma_blue) if rgb_panel_linear[2] > EPS else 0.0 - ]) + rgb_output = np.array( + [ + np.power(rgb_panel_linear[0], 1.0 / gamma_red) if rgb_panel_linear[0] > EPS else 0.0, + np.power(rgb_panel_linear[1], 1.0 / gamma_green) if rgb_panel_linear[1] > EPS else 0.0, + np.power(rgb_panel_linear[2], 1.0 / gamma_blue) if rgb_panel_linear[2] > EPS else 0.0, + ] + ) # Clamp final output lut.data[r_idx, g_idx, b_idx] = np.clip(rgb_output, 0, 1) @@ -941,11 +930,7 @@ def create_calibration_lut( lut.title = title return lut - def optimize_lut( - self, - lut: LUT3D, - smoothing: float = 0.1 - ) -> LUT3D: + def optimize_lut(self, lut: LUT3D, smoothing: float = 0.1) -> LUT3D: """ Apply perceptual smoothing to LUT. @@ -958,17 +943,12 @@ def optimize_lut( """ from scipy.ndimage import gaussian_filter - smoothed = LUT3D( - size=lut.size, - data=lut.data.copy(), - title=lut.title + " (smoothed)" - ) + smoothed = LUT3D(size=lut.size, data=lut.data.copy(), title=lut.title + " (smoothed)") # Apply Gaussian smoothing per channel sigma = smoothing * (lut.size / 17) for c in range(3): - smoothed.data[:, :, :, c] = gaussian_filter( - lut.data[:, :, :, c], sigma=sigma, mode='nearest') + smoothed.data[:, :, :, c] = gaussian_filter(lut.data[:, :, :, c], sigma=sigma, mode="nearest") return smoothed @@ -1000,7 +980,6 @@ def concat_luts(self, lut1: LUT3D, lut2: LUT3D) -> LUT3D: result.title = f"{lut1.title} + {lut2.title}" return result - def create_native_gamut_lut( self, panel_primaries: tuple[tuple[float, float], ...], @@ -1014,7 +993,7 @@ def create_native_gamut_lut( oled_compensation: bool = False, panel_type: str = "", panel_key: str = "", - target_apl: float = 0.25 + target_apl: float = 0.25, ) -> LUT3D: """ Create a calibration LUT that corrects accuracy WITHIN the panel's @@ -1043,25 +1022,21 @@ def create_native_gamut_lut( """ from calibrate_pro.core.color_math import primaries_to_xyz_matrix - panel_to_xyz = primaries_to_xyz_matrix( - panel_primaries[0], panel_primaries[1], - panel_primaries[2], panel_white) + panel_to_xyz = primaries_to_xyz_matrix(panel_primaries[0], panel_primaries[1], panel_primaries[2], panel_white) xyz_to_panel = np.linalg.inv(panel_to_xyz) # White point adaptation matrix (if panel white != target white) panel_wp_x, panel_wp_y = panel_white target_wp_x, target_wp_y = target_white - need_wp_adapt = (abs(panel_wp_x - target_wp_x) > 0.001 or - abs(panel_wp_y - target_wp_y) > 0.001) + need_wp_adapt = abs(panel_wp_x - target_wp_x) > 0.001 or abs(panel_wp_y - target_wp_y) > 0.001 if need_wp_adapt: from calibrate_pro.core.color_math import get_adaptation_matrix - panel_ill = Illuminant("panel", panel_wp_x / panel_wp_y, - 1.0, (1 - panel_wp_x - panel_wp_y) / panel_wp_y, - 0) - target_ill = Illuminant("target", target_wp_x / target_wp_y, - 1.0, (1 - target_wp_x - target_wp_y) / target_wp_y, - 0) + + panel_ill = Illuminant("panel", panel_wp_x / panel_wp_y, 1.0, (1 - panel_wp_x - panel_wp_y) / panel_wp_y, 0) + target_ill = Illuminant( + "target", target_wp_x / target_wp_y, 1.0, (1 - target_wp_x - target_wp_y) / target_wp_y, 0 + ) adapt_matrix = get_adaptation_matrix(panel_ill, target_ill) # Combined: panel RGB -> XYZ -> adapt -> XYZ -> panel RGB wp_correction = xyz_to_panel @ adapt_matrix @ panel_to_xyz @@ -1076,7 +1051,7 @@ def create_native_gamut_lut( # Vectorized: build all grid points N = self.size - r_grid, g_grid, b_grid = np.meshgrid(coords, coords, coords, indexing='ij') + r_grid, g_grid, b_grid = np.meshgrid(coords, coords, coords, indexing="ij") all_rgb = np.stack([r_grid.ravel(), g_grid.ravel(), b_grid.ravel()], axis=1) all_rgb.shape[0] @@ -1095,6 +1070,7 @@ def create_native_gamut_lut( from calibrate_pro.display.oled import ( get_oled_characteristics, ) + oled = get_oled_characteristics(panel_type, panel_key) if oled: # ABL compensation: boost signal to counteract brightness reduction @@ -1108,11 +1084,7 @@ def create_native_gamut_lut( if oled.near_black_model: nb = oled.near_black_model max_vals = np.max(rgb_corrected, axis=1) - near_black_mask = ( - (max_vals < nb.threshold) & - (max_vals > nb.black_cutoff) & - ~is_black - ) + near_black_mask = (max_vals < nb.threshold) & (max_vals > nb.black_cutoff) & ~is_black if np.any(near_black_mask): t = max_vals[near_black_mask] / nb.threshold lift = 1.0 - nb.gamma_lift * (1.0 - t) @@ -1123,11 +1095,7 @@ def create_native_gamut_lut( # Step 4: Apply per-channel gamma correction # Encode for panel: output^panel_gamma should produce the corrected linear # So output = corrected_linear^(1/panel_gamma) - rgb_output = np.where( - rgb_corrected > EPS, - np.power(np.clip(rgb_corrected, 0.0, 1.0), inv_gammas), - 0.0 - ) + rgb_output = np.where(rgb_corrected > EPS, np.power(np.clip(rgb_corrected, 0.0, 1.0), inv_gammas), 0.0) rgb_output = np.clip(rgb_output, 0.0, 1.0) # Black stays black @@ -1147,7 +1115,7 @@ def create_oklab_perceptual_lut( target_primaries: tuple[tuple[float, float], ...] = None, target_white: tuple[float, float] = (0.3127, 0.3290), target_gamma: float = 2.2, - title: str = "Oklab Perceptual Calibration LUT" + title: str = "Oklab Perceptual Calibration LUT", ) -> LUT3D: """ Create a calibration LUT using Oklab perceptual gamut mapping. @@ -1181,19 +1149,13 @@ def create_oklab_perceptual_lut( ) if target_primaries is None: - target_primaries = ( - (0.6400, 0.3300), - (0.3000, 0.6000), - (0.1500, 0.0600) - ) + target_primaries = ((0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600)) # Build conversion matrices - panel_to_xyz = primaries_to_xyz_matrix( - panel_primaries[0], panel_primaries[1], - panel_primaries[2], panel_white) + panel_to_xyz = primaries_to_xyz_matrix(panel_primaries[0], panel_primaries[1], panel_primaries[2], panel_white) target_to_xyz = primaries_to_xyz_matrix( - target_primaries[0], target_primaries[1], - target_primaries[2], target_white) + target_primaries[0], target_primaries[1], target_primaries[2], target_white + ) xyz_to_panel = np.linalg.inv(panel_to_xyz) # Combined matrix: target linear RGB -> panel linear RGB @@ -1205,7 +1167,7 @@ def create_oklab_perceptual_lut( # --- Vectorized: build all grid points as flat (N, 3) array --- N = self.size - r_grid, g_grid, b_grid = np.meshgrid(coords, coords, coords, indexing='ij') + r_grid, g_grid, b_grid = np.meshgrid(coords, coords, coords, indexing="ij") # shape (N*N*N, 3) all_rgb = np.stack([r_grid.ravel(), g_grid.ravel(), b_grid.ravel()], axis=1) total = all_rgb.shape[0] @@ -1221,10 +1183,7 @@ def create_oklab_perceptual_lut( panel_linear_all = (target_to_panel @ rgb_linear_all.T).T # (N^3, 3) # 4. Determine which points are out of gamut - oog_mask = ( - np.any(panel_linear_all < -0.001, axis=1) | - np.any(panel_linear_all > 1.001, axis=1) - ) + oog_mask = np.any(panel_linear_all < -0.001, axis=1) | np.any(panel_linear_all > 1.001, axis=1) # Black points are never out of gamut (they're handled separately) oog_mask[is_black] = False @@ -1253,7 +1212,7 @@ def create_oklab_perceptual_lut( # Convert target linear RGB to Oklab (sRGB matrices as proxy) oklab = linear_srgb_to_oklab(np.clip(rgb_linear, 0, 1)) L, a_ok, b_ok = oklab[0], oklab[1], oklab[2] - C = np.sqrt(a_ok ** 2 + b_ok ** 2) + C = np.sqrt(a_ok**2 + b_ok**2) if C > EPS: h = np.arctan2(b_ok, a_ok) @@ -1281,11 +1240,7 @@ def create_oklab_perceptual_lut( panel_linear = panel_linear_all[idx] panel_linear = np.clip(panel_linear, 0.0, 1.0) - rgb_output = np.where( - panel_linear > EPS, - np.power(panel_linear, inv_gammas), - 0.0 - ) + rgb_output = np.where(panel_linear > EPS, np.power(panel_linear, inv_gammas), 0.0) result_all[idx] = np.clip(rgb_output, 0.0, 1.0) # Reshape flat result back into LUT grid (size, size, size, 3) @@ -1405,10 +1360,7 @@ def create_hdr_calibration_lut( # 4. Identify out-of-gamut points. is_black = np.all(all_pq < EPS, axis=1) - oog_mask = ( - np.any(panel_linear_all < -0.001, axis=1) - | np.any(panel_linear_all > 1.001, axis=1) - ) + oog_mask = np.any(panel_linear_all < -0.001, axis=1) | np.any(panel_linear_all > 1.001, axis=1) oog_mask[is_black] = False in_gamut_mask = ~is_black & ~oog_mask @@ -1418,9 +1370,7 @@ def create_hdr_calibration_lut( # ---- fast vectorised in-gamut path ---- ig_panel = np.clip(panel_linear_all[in_gamut_mask], 0.0, 1.0) - ig_output_linear = np.where( - ig_panel > EPS, np.power(ig_panel, inv_gammas), 0.0 - ) + ig_output_linear = np.where(ig_panel > EPS, np.power(ig_panel, inv_gammas), 0.0) # Scale so that reference-white maps correctly, then convert # back to absolute nits and PQ-encode. ig_nits = np.clip(ig_output_linear, 0.0, 1.0) * peak_luminance @@ -1454,10 +1404,7 @@ def create_hdr_calibration_lut( test_panel = xyz_to_panel @ test_xyz # Normalise to panel peak test_panel_norm = test_panel / peak_luminance - if ( - np.all(test_panel_norm >= -0.001) - and np.all(test_panel_norm <= 1.001) - ): + if np.all(test_panel_norm >= -0.001) and np.all(test_panel_norm <= 1.001): lo = mid else: hi = mid @@ -1471,9 +1418,7 @@ def create_hdr_calibration_lut( panel_lin = panel_linear_all[idx] panel_lin = np.clip(panel_lin, 0.0, 1.0) - encoded = np.where( - panel_lin > EPS, np.power(panel_lin, inv_gammas), 0.0 - ) + encoded = np.where(panel_lin > EPS, np.power(panel_lin, inv_gammas), 0.0) out_nits = np.clip(encoded, 0.0, 1.0) * peak_luminance result_all[idx] = pq_oetf(out_nits) @@ -1507,39 +1452,17 @@ def srgb_to_display_p3_lut(size: int = 33) -> LUT3D: """Create LUT for sRGB to Display P3 conversion.""" generator = LUTGenerator(size) - srgb_primaries = ( - (0.6400, 0.3300), - (0.3000, 0.6000), - (0.1500, 0.0600) - ) - p3_primaries = ( - (0.6800, 0.3200), - (0.2650, 0.6900), - (0.1500, 0.0600) - ) + srgb_primaries = ((0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600)) + p3_primaries = ((0.6800, 0.3200), (0.2650, 0.6900), (0.1500, 0.0600)) - return generator.create_from_primaries( - srgb_primaries, p3_primaries, - title="sRGB to Display P3" - ) + return generator.create_from_primaries(srgb_primaries, p3_primaries, title="sRGB to Display P3") def display_p3_to_srgb_lut(size: int = 33) -> LUT3D: """Create LUT for Display P3 to sRGB conversion.""" generator = LUTGenerator(size) - srgb_primaries = ( - (0.6400, 0.3300), - (0.3000, 0.6000), - (0.1500, 0.0600) - ) - p3_primaries = ( - (0.6800, 0.3200), - (0.2650, 0.6900), - (0.1500, 0.0600) - ) - - return generator.create_from_primaries( - p3_primaries, srgb_primaries, - title="Display P3 to sRGB" - ) + srgb_primaries = ((0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600)) + p3_primaries = ((0.6800, 0.3200), (0.2650, 0.6900), (0.1500, 0.0600)) + + return generator.create_from_primaries(p3_primaries, srgb_primaries, title="Display P3 to sRGB") diff --git a/calibrate_pro/core/lut_engine_advanced.py b/calibrate_pro/core/lut_engine_advanced.py index 5c1fb3e..3156383 100644 --- a/calibrate_pro/core/lut_engine_advanced.py +++ b/calibrate_pro/core/lut_engine_advanced.py @@ -36,8 +36,10 @@ # Advanced LUT Class with Extended Features # ============================================================================= + class LUTInterpolation(Enum): """LUT interpolation methods.""" + TRILINEAR = "trilinear" TETRAHEDRAL = "tetrahedral" PRISMATIC = "prismatic" @@ -54,6 +56,7 @@ class AdvancedLUT3D(LUT3D): - HDR metadata embedding - Perceptual optimization """ + interpolation: LUTInterpolation = LUTInterpolation.TETRAHEDRAL hdr_metadata: dict = field(default_factory=dict) is_hdr: bool = False @@ -100,35 +103,35 @@ def apply_tetrahedral(self, rgb: np.ndarray) -> np.ndarray: # Get the 8 corner values c000 = self.data[r0, g0, b0] - c001 = self.data[r0, g0, min(b0+1, self.size-1)] - c010 = self.data[r0, min(g0+1, self.size-1), b0] - c011 = self.data[r0, min(g0+1, self.size-1), min(b0+1, self.size-1)] - c100 = self.data[min(r0+1, self.size-1), g0, b0] - c101 = self.data[min(r0+1, self.size-1), g0, min(b0+1, self.size-1)] - c110 = self.data[min(r0+1, self.size-1), min(g0+1, self.size-1), b0] - c111 = self.data[min(r0+1, self.size-1), min(g0+1, self.size-1), min(b0+1, self.size-1)] + c001 = self.data[r0, g0, min(b0 + 1, self.size - 1)] + c010 = self.data[r0, min(g0 + 1, self.size - 1), b0] + c011 = self.data[r0, min(g0 + 1, self.size - 1), min(b0 + 1, self.size - 1)] + c100 = self.data[min(r0 + 1, self.size - 1), g0, b0] + c101 = self.data[min(r0 + 1, self.size - 1), g0, min(b0 + 1, self.size - 1)] + c110 = self.data[min(r0 + 1, self.size - 1), min(g0 + 1, self.size - 1), b0] + c111 = self.data[min(r0 + 1, self.size - 1), min(g0 + 1, self.size - 1), min(b0 + 1, self.size - 1)] # Tetrahedral interpolation - determine which tetrahedron if fr > fg: if fg > fb: # Tetrahedron 1: R > G > B - result[i] = (1-fr) * c000 + (fr-fg) * c100 + (fg-fb) * c110 + fb * c111 + result[i] = (1 - fr) * c000 + (fr - fg) * c100 + (fg - fb) * c110 + fb * c111 elif fr > fb: # Tetrahedron 2: R > B > G - result[i] = (1-fr) * c000 + (fr-fb) * c100 + (fb-fg) * c101 + fg * c111 + result[i] = (1 - fr) * c000 + (fr - fb) * c100 + (fb - fg) * c101 + fg * c111 else: # Tetrahedron 3: B > R > G - result[i] = (1-fb) * c000 + (fb-fr) * c001 + (fr-fg) * c101 + fg * c111 + result[i] = (1 - fb) * c000 + (fb - fr) * c001 + (fr - fg) * c101 + fg * c111 else: if fb > fg: # Tetrahedron 4: B > G > R - result[i] = (1-fb) * c000 + (fb-fg) * c001 + (fg-fr) * c011 + fr * c111 + result[i] = (1 - fb) * c000 + (fb - fg) * c001 + (fg - fr) * c011 + fr * c111 elif fb > fr: # Tetrahedron 5: G > B > R - result[i] = (1-fg) * c000 + (fg-fb) * c010 + (fb-fr) * c011 + fr * c111 + result[i] = (1 - fg) * c000 + (fg - fb) * c010 + (fb - fr) * c011 + fr * c111 else: # Tetrahedron 6: G > R > B - result[i] = (1-fg) * c000 + (fg-fr) * c010 + (fr-fb) * c110 + fb * c111 + result[i] = (1 - fg) * c000 + (fg - fr) * c010 + (fr - fb) * c110 + fb * c111 # Reshape to original if len(original_shape) == 1: @@ -144,7 +147,7 @@ def apply(self, rgb: np.ndarray) -> np.ndarray: else: return super().apply(rgb) - def to_hdr_pq(self, peak_luminance: float = 10000.0) -> 'AdvancedLUT3D': + def to_hdr_pq(self, peak_luminance: float = 10000.0) -> "AdvancedLUT3D": """ Convert SDR LUT to HDR with PQ encoding. @@ -160,7 +163,7 @@ def to_hdr_pq(self, peak_luminance: float = 10000.0) -> 'AdvancedLUT3D': title=f"{self.title} (HDR PQ)", interpolation=self.interpolation, is_hdr=True, - peak_luminance=peak_luminance + peak_luminance=peak_luminance, ) # Apply PQ encoding to output values @@ -174,9 +177,9 @@ def to_hdr_pq(self, peak_luminance: float = 10000.0) -> 'AdvancedLUT3D': new_lut.data[r, g, b] = pq_oetf(rgb_abs) new_lut.hdr_metadata = { - 'transfer_function': 'pq', - 'peak_luminance': peak_luminance, - 'min_luminance': self.min_luminance + "transfer_function": "pq", + "peak_luminance": peak_luminance, + "min_luminance": self.min_luminance, } return new_lut @@ -186,6 +189,7 @@ def to_hdr_pq(self, peak_luminance: float = 10000.0) -> 'AdvancedLUT3D': # Advanced LUT Generator # ============================================================================= + class AdvancedLUTGenerator: """ Professional-grade 3D LUT generation. @@ -199,11 +203,11 @@ class AdvancedLUTGenerator: """ # Recommended sizes for different use cases - SIZE_QUICK = 17 # Fast preview - SIZE_STANDARD = 33 # Standard quality - SIZE_HIGH = 65 # High quality - SIZE_ULTRA = 129 # Ultra quality - SIZE_MAXIMUM = 256 # Maximum accuracy (50MB) + SIZE_QUICK = 17 # Fast preview + SIZE_STANDARD = 33 # Standard quality + SIZE_HIGH = 65 # High quality + SIZE_ULTRA = 129 # Ultra quality + SIZE_MAXIMUM = 256 # Maximum accuracy (50MB) def __init__(self, size: int = 65, num_threads: int = None): """ @@ -231,11 +235,11 @@ def __init__(self, size: int = 65, num_threads: int = None): def create_calibration_lut_cam16( self, panel_profile: dict, - target_colorspace: str = 'srgb', - gamut_mapping: str = 'cam16', + target_colorspace: str = "srgb", + gamut_mapping: str = "cam16", preserve_black: bool = True, preserve_white: bool = True, - title: str = "CAM16 Calibration LUT" + title: str = "CAM16 Calibration LUT", ) -> AdvancedLUT3D: """ Create calibration LUT using CAM16-UCS gamut mapping. @@ -258,13 +262,13 @@ def create_calibration_lut_cam16( Calibration LUT """ # Extract panel characteristics - panel_primaries = panel_profile['primaries'] - panel_white = panel_profile['white_point'] + panel_primaries = panel_profile["primaries"] + panel_white = panel_profile["white_point"] - if isinstance(panel_profile.get('gamma'), tuple): - gamma_r, gamma_g, gamma_b = panel_profile['gamma'] + if isinstance(panel_profile.get("gamma"), tuple): + gamma_r, gamma_g, gamma_b = panel_profile["gamma"] else: - gamma = panel_profile.get('gamma', 2.2) + gamma = panel_profile.get("gamma", 2.2) gamma_r = gamma_g = gamma_b = gamma # Target colorspace primaries @@ -272,12 +276,10 @@ def create_calibration_lut_cam16( target_white = (0.3127, 0.3290) # D65 # Build transformation matrices - panel_to_xyz = primaries_to_xyz_matrix( - panel_primaries[0], panel_primaries[1], - panel_primaries[2], panel_white) + panel_to_xyz = primaries_to_xyz_matrix(panel_primaries[0], panel_primaries[1], panel_primaries[2], panel_white) target_to_xyz = primaries_to_xyz_matrix( - target_primaries[0], target_primaries[1], - target_primaries[2], target_white) + target_primaries[0], target_primaries[1], target_primaries[2], target_white + ) xyz_to_panel = np.linalg.inv(panel_to_xyz) color_matrix = xyz_to_panel @ target_to_xyz @@ -286,23 +288,32 @@ def create_calibration_lut_cam16( size=self.size, data=np.zeros((self.size, self.size, self.size, 3)), title=title, - interpolation=LUTInterpolation.TETRAHEDRAL + interpolation=LUTInterpolation.TETRAHEDRAL, ) # Generate LUT with parallel processing for large sizes if self.size >= 65: - self._generate_parallel(lut, color_matrix, gamma_r, gamma_g, gamma_b, - preserve_black, preserve_white, gamut_mapping) + self._generate_parallel( + lut, color_matrix, gamma_r, gamma_g, gamma_b, preserve_black, preserve_white, gamut_mapping + ) else: - self._generate_sequential(lut, color_matrix, gamma_r, gamma_g, gamma_b, - preserve_black, preserve_white, gamut_mapping) + self._generate_sequential( + lut, color_matrix, gamma_r, gamma_g, gamma_b, preserve_black, preserve_white, gamut_mapping + ) return lut - def _generate_sequential(self, lut: AdvancedLUT3D, matrix: np.ndarray, - gamma_r: float, gamma_g: float, gamma_b: float, - preserve_black: bool, preserve_white: bool, - gamut_mapping: str): + def _generate_sequential( + self, + lut: AdvancedLUT3D, + matrix: np.ndarray, + gamma_r: float, + gamma_g: float, + gamma_b: float, + preserve_black: bool, + preserve_white: bool, + gamut_mapping: str, + ): """Generate LUT sequentially (for smaller sizes).""" EPS = 1e-10 target_gamma = 2.2 @@ -324,15 +335,21 @@ def _generate_sequential(self, lut: AdvancedLUT3D, matrix: np.ndarray, # Apply transformation result = self._transform_color( - rgb, matrix, gamma_r, gamma_g, gamma_b, - target_gamma, gamut_mapping, EPS + rgb, matrix, gamma_r, gamma_g, gamma_b, target_gamma, gamut_mapping, EPS ) lut.data[r_idx, g_idx, b_idx] = result - def _generate_parallel(self, lut: AdvancedLUT3D, matrix: np.ndarray, - gamma_r: float, gamma_g: float, gamma_b: float, - preserve_black: bool, preserve_white: bool, - gamut_mapping: str): + def _generate_parallel( + self, + lut: AdvancedLUT3D, + matrix: np.ndarray, + gamma_r: float, + gamma_g: float, + gamma_b: float, + preserve_black: bool, + preserve_white: bool, + gamut_mapping: str, + ): """Generate LUT with parallel processing (for larger sizes).""" EPS = 1e-10 target_gamma = 2.2 @@ -355,8 +372,7 @@ def process_slice(r_idx: int): continue result = self._transform_color( - rgb, matrix, gamma_r, gamma_g, gamma_b, - target_gamma, gamut_mapping, EPS + rgb, matrix, gamma_r, gamma_g, gamma_b, target_gamma, gamut_mapping, EPS ) slice_data[g_idx, b_idx] = result @@ -370,10 +386,17 @@ def process_slice(r_idx: int): r_idx, slice_data = future.result() lut.data[r_idx] = slice_data - def _transform_color(self, rgb: np.ndarray, matrix: np.ndarray, - gamma_r: float, gamma_g: float, gamma_b: float, - target_gamma: float, gamut_mapping: str, - eps: float) -> np.ndarray: + def _transform_color( + self, + rgb: np.ndarray, + matrix: np.ndarray, + gamma_r: float, + gamma_g: float, + gamma_b: float, + target_gamma: float, + gamut_mapping: str, + eps: float, + ) -> np.ndarray: """Apply color transformation with gamut mapping.""" # Linearize rgb_linear = np.where(rgb > eps, np.power(rgb, target_gamma), 0.0) @@ -382,20 +405,22 @@ def _transform_color(self, rgb: np.ndarray, matrix: np.ndarray, rgb_panel_linear = matrix @ rgb_linear # Gamut mapping - if gamut_mapping == 'cam16': + if gamut_mapping == "cam16": rgb_panel_linear = self._gamut_map_cam16(rgb_panel_linear) - elif gamut_mapping == 'jzazbz': + elif gamut_mapping == "jzazbz": rgb_panel_linear = self._gamut_map_jzazbz(rgb_panel_linear) # Clamp rgb_panel_linear = np.clip(rgb_panel_linear, 0.0, 1.0) # Apply inverse gamma - rgb_output = np.array([ - np.power(rgb_panel_linear[0], 1.0 / gamma_r) if rgb_panel_linear[0] > eps else 0.0, - np.power(rgb_panel_linear[1], 1.0 / gamma_g) if rgb_panel_linear[1] > eps else 0.0, - np.power(rgb_panel_linear[2], 1.0 / gamma_b) if rgb_panel_linear[2] > eps else 0.0 - ]) + rgb_output = np.array( + [ + np.power(rgb_panel_linear[0], 1.0 / gamma_r) if rgb_panel_linear[0] > eps else 0.0, + np.power(rgb_panel_linear[1], 1.0 / gamma_g) if rgb_panel_linear[1] > eps else 0.0, + np.power(rgb_panel_linear[2], 1.0 / gamma_b) if rgb_panel_linear[2] > eps else 0.0, + ] + ) return np.clip(rgb_output, 0, 1) @@ -407,11 +432,12 @@ def _gamut_map_cam16(self, rgb_linear: np.ndarray) -> np.ndarray: # Convert to XYZ then CAM16 from .color_math import SRGB_TO_XYZ + xyz = SRGB_TO_XYZ @ np.clip(rgb_linear, 0, 10) # Allow some headroom try: cam_result = self.cam16.xyz_to_cam16(xyz) - J, M, h = cam_result['J'], cam_result['M'], cam_result['h'] + J, M, h = cam_result["J"], cam_result["M"], cam_result["h"] # Compress chroma to fit in gamut M_reduced = M * 0.9 # Simple compression @@ -431,6 +457,7 @@ def _gamut_map_jzazbz(self, rgb_linear: np.ndarray) -> np.ndarray: return rgb_linear from .color_math import SRGB_TO_XYZ + xyz = SRGB_TO_XYZ @ np.clip(rgb_linear, 0, 10) try: @@ -456,18 +483,14 @@ def _gamut_map_jzazbz(self, rgb_linear: np.ndarray) -> np.ndarray: def _get_colorspace_primaries(self, colorspace: str) -> tuple: """Get primaries for named colorspace.""" primaries = { - 'srgb': ((0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600)), - 'p3': ((0.6800, 0.3200), (0.2650, 0.6900), (0.1500, 0.0600)), - 'bt2020': ((0.7080, 0.2920), (0.1700, 0.7970), (0.1310, 0.0460)), - 'adobe_rgb': ((0.6400, 0.3300), (0.2100, 0.7100), (0.1500, 0.0600)) + "srgb": ((0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600)), + "p3": ((0.6800, 0.3200), (0.2650, 0.6900), (0.1500, 0.0600)), + "bt2020": ((0.7080, 0.2920), (0.1700, 0.7970), (0.1310, 0.0460)), + "adobe_rgb": ((0.6400, 0.3300), (0.2100, 0.7100), (0.1500, 0.0600)), } - return primaries.get(colorspace, primaries['srgb']) + return primaries.get(colorspace, primaries["srgb"]) - def single_pass_multi_target( - self, - panel_profile: dict, - targets: list[str] = None - ) -> dict[str, AdvancedLUT3D]: + def single_pass_multi_target(self, panel_profile: dict, targets: list[str] = None) -> dict[str, AdvancedLUT3D]: """ Generate multiple target calibrations from single profile. @@ -481,14 +504,12 @@ def single_pass_multi_target( Dictionary of {target_name: LUT} """ if targets is None: - targets = ['srgb', 'p3', 'bt2020'] + targets = ["srgb", "p3", "bt2020"] results = {} for target in targets: lut = self.create_calibration_lut_cam16( - panel_profile=panel_profile, - target_colorspace=target, - title=f"Calibration - {target.upper()}" + panel_profile=panel_profile, target_colorspace=target, title=f"Calibration - {target.upper()}" ) results[target] = lut @@ -499,9 +520,9 @@ def create_hdr_calibration_lut( panel_profile: dict, peak_luminance: float = 1000.0, min_luminance: float = 0.0001, - transfer_function: str = 'pq', - target_colorspace: str = 'p3', - title: str = "HDR Calibration LUT" + transfer_function: str = "pq", + target_colorspace: str = "p3", + title: str = "HDR Calibration LUT", ) -> AdvancedLUT3D: """ Create HDR calibration LUT. @@ -521,13 +542,11 @@ def create_hdr_calibration_lut( """ # Create base calibration base_lut = self.create_calibration_lut_cam16( - panel_profile=panel_profile, - target_colorspace=target_colorspace, - title=title + panel_profile=panel_profile, target_colorspace=target_colorspace, title=title ) # Convert to HDR - if transfer_function == 'pq': + if transfer_function == "pq": hdr_lut = base_lut.to_hdr_pq(peak_luminance) else: # HLG conversion @@ -538,7 +557,7 @@ def create_hdr_calibration_lut( interpolation=base_lut.interpolation, is_hdr=True, peak_luminance=peak_luminance, - min_luminance=min_luminance + min_luminance=min_luminance, ) # Apply HLG OETF @@ -549,9 +568,9 @@ def create_hdr_calibration_lut( hdr_lut.data[r, g, b] = self._hlg_oetf(rgb) hdr_lut.hdr_metadata = { - 'transfer_function': 'hlg', - 'peak_luminance': peak_luminance, - 'min_luminance': min_luminance + "transfer_function": "hlg", + "peak_luminance": peak_luminance, + "min_luminance": min_luminance, } return hdr_lut @@ -563,17 +582,14 @@ def _hlg_oetf(self, rgb: np.ndarray) -> np.ndarray: c = 0.55991073 result = np.zeros_like(rgb) - mask = rgb <= 1/12 + mask = rgb <= 1 / 12 result[mask] = np.sqrt(3 * rgb[mask]) result[~mask] = a * np.log(12 * rgb[~mask] - b) + c return np.clip(result, 0, 1) def optimize_lut_perceptual( - self, - lut: AdvancedLUT3D, - smoothing: float = 0.1, - preserve_edges: bool = True + self, lut: AdvancedLUT3D, smoothing: float = 0.1, preserve_edges: bool = True ) -> AdvancedLUT3D: """ Apply perceptual optimization to LUT. @@ -591,10 +607,7 @@ def optimize_lut_perceptual( from scipy.ndimage import gaussian_filter optimized = AdvancedLUT3D( - size=lut.size, - data=lut.data.copy(), - title=f"{lut.title} (optimized)", - interpolation=lut.interpolation + size=lut.size, data=lut.data.copy(), title=f"{lut.title} (optimized)", interpolation=lut.interpolation ) sigma = smoothing * (lut.size / 33.0) @@ -628,8 +641,7 @@ def optimize_lut_perceptual( else: # Simple Gaussian smoothing per channel for c in range(3): - optimized.data[:, :, :, c] = gaussian_filter( - lut.data[:, :, :, c], sigma=sigma) + optimized.data[:, :, :, c] = gaussian_filter(lut.data[:, :, :, c], sigma=sigma) return optimized @@ -653,14 +665,10 @@ def resize_lut(self, lut: AdvancedLUT3D, new_size: int) -> AdvancedLUT3D: new_data = np.zeros((new_size, new_size, new_size, 3)) for c in range(3): - interp = RegularGridInterpolator( - (old_coords, old_coords, old_coords), - lut.data[:, :, :, c], - method='cubic' - ) + interp = RegularGridInterpolator((old_coords, old_coords, old_coords), lut.data[:, :, :, c], method="cubic") # Generate new grid - r, g, b = np.meshgrid(new_coords, new_coords, new_coords, indexing='ij') + r, g, b = np.meshgrid(new_coords, new_coords, new_coords, indexing="ij") points = np.stack([r.ravel(), g.ravel(), b.ravel()], axis=-1) new_data[:, :, :, c] = interp(points).reshape(new_size, new_size, new_size) @@ -669,7 +677,7 @@ def resize_lut(self, lut: AdvancedLUT3D, new_size: int) -> AdvancedLUT3D: size=new_size, data=np.clip(new_data, 0, 1), title=f"{lut.title} ({new_size}³)", - interpolation=lut.interpolation + interpolation=lut.interpolation, ) @@ -677,6 +685,7 @@ def resize_lut(self, lut: AdvancedLUT3D, new_size: int) -> AdvancedLUT3D: # LUT Manipulation Tools # ============================================================================= + class LUTManipulator: """ Professional LUT editing tools. @@ -699,11 +708,7 @@ def combine(lut1: AdvancedLUT3D, lut2: AdvancedLUT3D) -> AdvancedLUT3D: size = max(lut1.size, lut2.size) coords = np.linspace(0, 1, size) - result = AdvancedLUT3D( - size=size, - data=np.zeros((size, size, size, 3)), - title=f"{lut1.title} + {lut2.title}" - ) + result = AdvancedLUT3D(size=size, data=np.zeros((size, size, size, 3)), title=f"{lut1.title} + {lut2.title}") for r_idx, r in enumerate(coords): for g_idx, g in enumerate(coords): @@ -731,9 +736,7 @@ def invert(lut: AdvancedLUT3D, iterations: int = 10) -> AdvancedLUT3D: coords = np.linspace(0, 1, lut.size) inverse = AdvancedLUT3D( - size=lut.size, - data=np.zeros((lut.size, lut.size, lut.size, 3)), - title=f"{lut.title} (inverse)" + size=lut.size, data=np.zeros((lut.size, lut.size, lut.size, 3)), title=f"{lut.title} (inverse)" ) # Initialize with identity @@ -755,8 +758,7 @@ def invert(lut: AdvancedLUT3D, iterations: int = 10) -> AdvancedLUT3D: return inverse @staticmethod - def blend(lut1: AdvancedLUT3D, lut2: AdvancedLUT3D, - factor: float = 0.5) -> AdvancedLUT3D: + def blend(lut1: AdvancedLUT3D, lut2: AdvancedLUT3D, factor: float = 0.5) -> AdvancedLUT3D: """ Blend two LUTs together. @@ -774,7 +776,7 @@ def blend(lut1: AdvancedLUT3D, lut2: AdvancedLUT3D, blended = AdvancedLUT3D( size=lut1.size, data=lut1.data * (1 - factor) + lut2.data * factor, - title=f"Blend({lut1.title}, {lut2.title}, {factor:.2f})" + title=f"Blend({lut1.title}, {lut2.title}, {factor:.2f})", ) return blended @@ -784,11 +786,8 @@ def blend(lut1: AdvancedLUT3D, lut2: AdvancedLUT3D, # Convenience Functions # ============================================================================= -def create_256_cube_lut( - panel_profile: dict, - target: str = 'srgb', - output_path: Path | None = None -) -> AdvancedLUT3D: + +def create_256_cube_lut(panel_profile: dict, target: str = "srgb", output_path: Path | None = None) -> AdvancedLUT3D: """ Create maximum accuracy 256³ calibration LUT. @@ -804,9 +803,7 @@ def create_256_cube_lut( """ generator = AdvancedLUTGenerator(size=256, num_threads=8) lut = generator.create_calibration_lut_cam16( - panel_profile=panel_profile, - target_colorspace=target, - title=f"Ultra Precision {target.upper()} LUT (256³)" + panel_profile=panel_profile, target_colorspace=target, title=f"Ultra Precision {target.upper()} LUT (256³)" ) if output_path: @@ -815,10 +812,7 @@ def create_256_cube_lut( return lut -def create_hdr_lut_suite( - panel_profile: dict, - peak_luminance: float = 1000.0 -) -> dict[str, AdvancedLUT3D]: +def create_hdr_lut_suite(panel_profile: dict, peak_luminance: float = 1000.0) -> dict[str, AdvancedLUT3D]: """ Create complete HDR LUT suite for professional mastering. @@ -840,21 +834,15 @@ def create_hdr_lut_suite( luts = {} # SDR LUTs - luts['srgb_sdr'] = generator.create_calibration_lut_cam16( - panel_profile, 'srgb', title="sRGB SDR" - ) - luts['p3_sdr'] = generator.create_calibration_lut_cam16( - panel_profile, 'p3', title="P3-D65 SDR" - ) + luts["srgb_sdr"] = generator.create_calibration_lut_cam16(panel_profile, "srgb", title="sRGB SDR") + luts["p3_sdr"] = generator.create_calibration_lut_cam16(panel_profile, "p3", title="P3-D65 SDR") # HDR LUTs - luts['p3_hdr_pq'] = generator.create_hdr_calibration_lut( - panel_profile, peak_luminance, transfer_function='pq', - target_colorspace='p3', title="P3-D65 HDR PQ" + luts["p3_hdr_pq"] = generator.create_hdr_calibration_lut( + panel_profile, peak_luminance, transfer_function="pq", target_colorspace="p3", title="P3-D65 HDR PQ" ) - luts['bt2020_hdr_pq'] = generator.create_hdr_calibration_lut( - panel_profile, peak_luminance, transfer_function='pq', - target_colorspace='bt2020', title="BT.2020 HDR PQ" + luts["bt2020_hdr_pq"] = generator.create_hdr_calibration_lut( + panel_profile, peak_luminance, transfer_function="pq", target_colorspace="bt2020", title="BT.2020 HDR PQ" ) return luts diff --git a/calibrate_pro/core/vcgt.py b/calibrate_pro/core/vcgt.py index 8db1815..5b5d119 100644 --- a/calibrate_pro/core/vcgt.py +++ b/calibrate_pro/core/vcgt.py @@ -41,6 +41,7 @@ class VCGTTable: size: Number of entries per channel bit_depth: Original bit depth (8, 10, 12, 16) """ + red: np.ndarray green: np.ndarray blue: np.ndarray @@ -61,11 +62,7 @@ def to_integers(self, bit_depth: int = 16) -> tuple[np.ndarray, np.ndarray, np.n return r, g, b -def lut3d_to_vcgt( - lut3d: np.ndarray, - output_size: int = 4096, - method: str = "neutral_axis" -) -> VCGTTable: +def lut3d_to_vcgt(lut3d: np.ndarray, output_size: int = 4096, method: str = "neutral_axis") -> VCGTTable: """ Convert a 3D LUT to a 1D VCGT table. @@ -156,11 +153,7 @@ def lut3d_to_vcgt( raise ValueError(f"Unknown method: {method}") return VCGTTable( - red=np.clip(red, 0, 1), - green=np.clip(green, 0, 1), - blue=np.clip(blue, 0, 1), - size=output_size, - bit_depth=16 + red=np.clip(red, 0, 1), green=np.clip(green, 0, 1), blue=np.clip(blue, 0, 1), size=output_size, bit_depth=16 ) @@ -168,7 +161,7 @@ def gamma_to_vcgt( gamma: float = 2.2, output_size: int = 256, rgb_gains: tuple[float, float, float] = (1.0, 1.0, 1.0), - black_level: float = 0.0 + black_level: float = 0.0, ) -> VCGTTable: """ Generate a VCGT table from gamma and gain parameters. @@ -200,11 +193,7 @@ def srgb_vcgt(output_size: int = 256) -> VCGTTable: x = np.linspace(0, 1, output_size) # sRGB EOTF (electrical to optical) - curve = np.where( - x <= 0.04045, - x / 12.92, - np.power((x + 0.055) / 1.055, 2.4) - ) + curve = np.where(x <= 0.04045, x / 12.92, np.power((x + 0.055) / 1.055, 2.4)) return VCGTTable(red=curve, green=curve, blue=curve, size=output_size) @@ -213,7 +202,7 @@ def bt1886_vcgt( output_size: int = 256, gamma: float = 2.4, Lw: float = 100.0, # White luminance - Lb: float = 0.1 # Black luminance + Lb: float = 0.1, # Black luminance ) -> VCGTTable: """ Generate VCGT for BT.1886 transfer function. @@ -224,8 +213,8 @@ def bt1886_vcgt( x = np.linspace(0, 1, output_size) # BT.1886 formula - a = np.power(np.power(Lw, 1/gamma) - np.power(Lb, 1/gamma), gamma) - b = np.power(Lb, 1/gamma) / (np.power(Lw, 1/gamma) - np.power(Lb, 1/gamma)) + a = np.power(np.power(Lw, 1 / gamma) - np.power(Lb, 1 / gamma), gamma) + b = np.power(Lb, 1 / gamma) / (np.power(Lw, 1 / gamma) - np.power(Lb, 1 / gamma)) curve = np.power(np.maximum(x + b, 0), gamma) / a curve = np.clip(curve, 0, 1) @@ -237,6 +226,7 @@ def bt1886_vcgt( # Export Functions # ============================================================================= + def export_vcgt_cal(vcgt: VCGTTable, filepath: str): """ Export VCGT as ArgyllCMS .cal file format. @@ -245,15 +235,15 @@ def export_vcgt_cal(vcgt: VCGTTable, filepath: str): """ path = Path(filepath) - with open(path, 'w') as f: + with open(path, "w") as f: f.write("CAL\n\n") - f.write("DESCRIPTOR \"Calibrate Pro VCGT Export\"\n") - f.write("ORIGINATOR \"Calibrate Pro\"\n") - f.write("CREATED \"\"\n") - f.write("KEYWORD \"DEVICE_CLASS\"\n") - f.write("DEVICE_CLASS \"DISPLAY\"\n") - f.write("KEYWORD \"COLOR_REP\"\n") - f.write("COLOR_REP \"RGB\"\n\n") + f.write('DESCRIPTOR "Calibrate Pro VCGT Export"\n') + f.write('ORIGINATOR "Calibrate Pro"\n') + f.write('CREATED ""\n') + f.write('KEYWORD "DEVICE_CLASS"\n') + f.write('DEVICE_CLASS "DISPLAY"\n') + f.write('KEYWORD "COLOR_REP"\n') + f.write('COLOR_REP "RGB"\n\n') f.write("NUMBER_OF_FIELDS 4\n") f.write("BEGIN_DATA_FORMAT\n") f.write("RGB_I RGB_R RGB_G RGB_B\n") @@ -272,7 +262,7 @@ def export_vcgt_csv(vcgt: VCGTTable, filepath: str, include_header: bool = True) """Export VCGT as CSV file.""" path = Path(filepath) - with open(path, 'w') as f: + with open(path, "w") as f: if include_header: f.write("Input,Red,Green,Blue\n") @@ -289,8 +279,8 @@ def export_vcgt_cube1d(vcgt: VCGTTable, filepath: str, title: str = "VCGT Export """ path = Path(filepath) - with open(path, 'w') as f: - f.write(f"TITLE \"{title}\"\n") + with open(path, "w") as f: + f.write(f'TITLE "{title}"\n') f.write(f"LUT_1D_SIZE {vcgt.size}\n") f.write("DOMAIN_MIN 0.0 0.0 0.0\n") f.write("DOMAIN_MAX 1.0 1.0 1.0\n\n") @@ -317,30 +307,30 @@ def export_vcgt_icc_bytes(vcgt: VCGTTable) -> bytes: data = bytearray() # Signature - data.extend(b'vcgt') + data.extend(b"vcgt") # Reserved - data.extend(struct.pack('>I', 0)) + data.extend(struct.pack(">I", 0)) # Tag type (0 = table) - data.extend(struct.pack('>I', 0)) + data.extend(struct.pack(">I", 0)) # Number of channels - data.extend(struct.pack('>H', 3)) + data.extend(struct.pack(">H", 3)) # Number of entries - data.extend(struct.pack('>H', vcgt.size)) + data.extend(struct.pack(">H", vcgt.size)) # Entry size (2 bytes = 16-bit) - data.extend(struct.pack('>H', 2)) + data.extend(struct.pack(">H", 2)) # Table data (16-bit values, big-endian) r_int, g_int, b_int = vcgt.to_integers(16) for i in range(vcgt.size): - data.extend(struct.pack('>H', r_int[i])) - data.extend(struct.pack('>H', g_int[i])) - data.extend(struct.pack('>H', b_int[i])) + data.extend(struct.pack(">H", r_int[i])) + data.extend(struct.pack(">H", g_int[i])) + data.extend(struct.pack(">H", b_int[i])) return bytes(data) @@ -349,6 +339,7 @@ def export_vcgt_icc_bytes(vcgt: VCGTTable) -> bytes: # Import Functions # ============================================================================= + def import_vcgt_cal(filepath: str) -> VCGTTable: """Import VCGT from ArgyllCMS .cal file.""" path = Path(filepath) @@ -374,12 +365,7 @@ def import_vcgt_cal(filepath: str) -> VCGTTable: green.append(float(parts[2])) blue.append(float(parts[3])) - return VCGTTable( - red=np.array(red), - green=np.array(green), - blue=np.array(blue), - size=len(red) - ) + return VCGTTable(red=np.array(red), green=np.array(green), blue=np.array(blue), size=len(red)) def import_vcgt_csv(filepath: str, has_header: bool = True) -> VCGTTable: @@ -395,29 +381,21 @@ def import_vcgt_csv(filepath: str, has_header: bool = True) -> VCGTTable: start = 1 if has_header else 0 for line in lines[start:]: - parts = line.strip().split(',') + parts = line.strip().split(",") if len(parts) >= 4: red.append(float(parts[1])) green.append(float(parts[2])) blue.append(float(parts[3])) - return VCGTTable( - red=np.array(red), - green=np.array(green), - blue=np.array(blue), - size=len(red) - ) + return VCGTTable(red=np.array(red), green=np.array(green), blue=np.array(blue), size=len(red)) # ============================================================================= # VCGT Application (Windows) # ============================================================================= -def apply_vcgt_windows( - vcgt: VCGTTable, - display_index: int = 0, - device_name: str = "" -) -> bool: + +def apply_vcgt_windows(vcgt: VCGTTable, display_index: int = 0, device_name: str = "") -> bool: """ Apply VCGT to Windows display gamma ramp. @@ -457,6 +435,7 @@ def apply_vcgt_windows( # CreateDCW gives us a DC for a specific display adapter hdc = user32.CreateDCW("DISPLAY", device_name, None, None) if hdc: + def release_fn(h): return user32.DeleteDC(h) @@ -465,6 +444,7 @@ def release_fn(h): hdc = user32.GetDC(None) if not hdc: return False + def release_fn(h): return user32.ReleaseDC(None, h) @@ -582,12 +562,7 @@ def get_current_vcgt_windows() -> VCGTTable | None: def reset_vcgt_windows() -> bool: """Reset Windows gamma ramp to linear (identity).""" - linear = VCGTTable( - red=np.linspace(0, 1, 256), - green=np.linspace(0, 1, 256), - blue=np.linspace(0, 1, 256), - size=256 - ) + linear = VCGTTable(red=np.linspace(0, 1, 256), green=np.linspace(0, 1, 256), blue=np.linspace(0, 1, 256), size=256) return apply_vcgt_windows(linear) diff --git a/calibrate_pro/display/color_volume.py b/calibrate_pro/display/color_volume.py index 08cc8a1..9176c54 100644 --- a/calibrate_pro/display/color_volume.py +++ b/calibrate_pro/display/color_volume.py @@ -22,6 +22,7 @@ @dataclass class ColorVolumeResult: """Results from 3D color volume analysis.""" + # Absolute volumes (arbitrary units, relative comparison only) panel_volume: float srgb_volume: float @@ -29,16 +30,16 @@ class ColorVolumeResult: bt2020_volume: float # Coverage percentages - srgb_volume_pct: float # Panel volume that covers sRGB - p3_volume_pct: float # Panel volume that covers DCI-P3 + srgb_volume_pct: float # Panel volume that covers sRGB + p3_volume_pct: float # Panel volume that covers DCI-P3 bt2020_volume_pct: float # Panel volume that covers BT.2020 # Relative to sRGB relative_to_srgb_pct: float # Panel volume / sRGB volume * 100 # Per-lightness gamut area (shows how gamut changes with brightness) - lightness_levels: list[float] # L* or Jz values - gamut_area_per_level: list[float] # Area at each lightness + lightness_levels: list[float] # L* or Jz values + gamut_area_per_level: list[float] # Area at each lightness def compute_color_volume( @@ -47,7 +48,7 @@ def compute_color_volume( lightness_steps: int = 21, hue_steps: int = 72, panel_type: str = "", - peak_luminance: float = 1000.0 + peak_luminance: float = 1000.0, ) -> ColorVolumeResult: """ Compute 3D color volume for a panel. @@ -101,7 +102,7 @@ def find_max_chroma_at_lh(xyz_to_rgb, L, h_deg, rolloff_factor=1.0): sin_h = math.sin(h_rad) delta = 6.0 / 29.0 - delta_cu = delta ** 3 + delta_cu = delta**3 lo, hi = 0.0, 180.0 for _ in range(25): @@ -114,9 +115,9 @@ def find_max_chroma_at_lh(xyz_to_rgb, L, h_deg, rolloff_factor=1.0): fx = a / 500.0 + fy fz = fy - b / 200.0 - xr = fx ** 3 if fx ** 3 > delta_cu else (fx - 4.0 / 29.0) * 3 * delta ** 2 - yr = fy ** 3 if fy ** 3 > delta_cu else (fy - 4.0 / 29.0) * 3 * delta ** 2 - zr = fz ** 3 if fz ** 3 > delta_cu else (fz - 4.0 / 29.0) * 3 * delta ** 2 + xr = fx**3 if fx**3 > delta_cu else (fx - 4.0 / 29.0) * 3 * delta**2 + yr = fy**3 if fy**3 > delta_cu else (fy - 4.0 / 29.0) * 3 * delta**2 + zr = fz**3 if fz**3 > delta_cu else (fz - 4.0 / 29.0) * 3 * delta**2 xyz = np.array([xr * ref_xyz[0], yr * ref_xyz[1], zr * ref_xyz[2]]) rgb = xyz_to_rgb @ xyz @@ -195,7 +196,9 @@ def get_rolloff_factor(L, panel_type_str): bt2020_coverage += min(pa, ba) srgb_coverage_vol = float(np.trapezoid([min(pa, sa) for pa, sa in zip(panel_areas, srgb_areas)], lightness_levels)) p3_coverage_vol = float(np.trapezoid([min(pa, p3a) for pa, p3a in zip(panel_areas, p3_areas)], lightness_levels)) - bt2020_coverage_vol = float(np.trapezoid([min(pa, ba) for pa, ba in zip(panel_areas, bt2020_areas)], lightness_levels)) + bt2020_coverage_vol = float( + np.trapezoid([min(pa, ba) for pa, ba in zip(panel_areas, bt2020_areas)], lightness_levels) + ) return ColorVolumeResult( panel_volume=panel_vol, @@ -207,7 +210,7 @@ def get_rolloff_factor(L, panel_type_str): bt2020_volume_pct=min(100.0, bt2020_coverage_vol / bt2020_vol * 100) if bt2020_vol > 0 else 0, relative_to_srgb_pct=panel_vol / srgb_vol * 100 if srgb_vol > 0 else 0, lightness_levels=list(lightness_levels), - gamut_area_per_level=panel_areas + gamut_area_per_level=panel_areas, ) diff --git a/calibrate_pro/display/hdr_content.py b/calibrate_pro/display/hdr_content.py index e78bf6f..a9a1aac 100644 --- a/calibrate_pro/display/hdr_content.py +++ b/calibrate_pro/display/hdr_content.py @@ -22,6 +22,7 @@ # Content information # --------------------------------------------------------------------------- + @dataclass class HDRContentInfo: """Description of an HDR (or SDR) content stream.""" @@ -140,12 +141,7 @@ def detect_content_type_from_metadata(metadata: dict[str, Any]) -> HDRContentInf md: dict[str, Any] = {k.lower().replace(" ", "_"): v for k, v in metadata.items()} # Transfer function - raw_tf = ( - md.get("transfer_function") - or md.get("eotf") - or md.get("tf") - or "" - ) + raw_tf = md.get("transfer_function") or md.get("eotf") or md.get("tf") or "" tf = _normalise_tf(str(raw_tf)) if raw_tf else "" # Color primaries @@ -153,12 +149,7 @@ def detect_content_type_from_metadata(metadata: dict[str, Any]) -> HDRContentInf primaries = _normalise_primaries(str(raw_pri)) if raw_pri else "" # Peak luminance - peak = float( - md.get("max_cll") - or md.get("peak_luminance") - or md.get("maxcll") - or 0 - ) + peak = float(md.get("max_cll") or md.get("peak_luminance") or md.get("maxcll") or 0) # MaxFALL max_fall = float(md.get("max_fall") or md.get("maxfall") or 0) @@ -232,6 +223,7 @@ def detect_content_type_from_metadata(metadata: dict[str, Any]) -> HDRContentInf # LUT recommendation # --------------------------------------------------------------------------- + def get_recommended_lut_for_content( content: HDRContentInfo, panel: Any | None = None, @@ -306,6 +298,7 @@ def get_recommended_lut_for_content( # Convenience: build HDRContentInfo from common shorthand # --------------------------------------------------------------------------- + def content_info_from_type( content_type: str, peak_luminance: float = 0.0, diff --git a/calibrate_pro/display/hdr_detect.py b/calibrate_pro/display/hdr_detect.py index 301336d..9473463 100644 --- a/calibrate_pro/display/hdr_detect.py +++ b/calibrate_pro/display/hdr_detect.py @@ -17,15 +17,16 @@ @dataclass class HDRDisplayState: """HDR state for a single display.""" + display_index: int display_name: str device_path: str hdr_enabled: bool hdr_capable: bool - peak_luminance: float # Current peak in cd/m2 - sdr_white_level: float # SDR content white level in cd/m2 (typically 80-480) - color_space: str # "sRGB", "scRGB", "BT2020_PQ", etc. - bit_depth: int # 8, 10, or 12 + peak_luminance: float # Current peak in cd/m2 + sdr_white_level: float # SDR content white level in cd/m2 (typically 80-480) + color_space: str # "sRGB", "scRGB", "BT2020_PQ", etc. + bit_depth: int # 8, 10, or 12 def detect_hdr_state() -> list[HDRDisplayState]: @@ -38,6 +39,7 @@ def detect_hdr_state() -> list[HDRDisplayState]: try: from calibrate_pro.panels.detection import enumerate_displays, get_display_name + displays = enumerate_displays() except Exception: return states @@ -58,6 +60,7 @@ def detect_hdr_state() -> list[HDRDisplayState]: try: from calibrate_pro.panels.database import PanelDatabase from calibrate_pro.panels.detection import identify_display + db = PanelDatabase() key = identify_display(display) if key: @@ -77,7 +80,7 @@ def detect_hdr_state() -> list[HDRDisplayState]: peak_luminance=peak_lum, sdr_white_level=sdr_white, color_space="BT2020_PQ" if hdr_on else "sRGB", - bit_depth=10 if hdr_on else display.bit_depth + bit_depth=10 if hdr_on else display.bit_depth, ) states.append(state) @@ -116,10 +119,7 @@ def _check_hdr_registry(device_name: str) -> bool: # Method 2: Check GraphicsDrivers for HDR support indication try: - gfx_key = winreg.OpenKey( - winreg.HKEY_LOCAL_MACHINE, - r"SYSTEM\CurrentControlSet\Control\GraphicsDrivers" - ) + gfx_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\GraphicsDrivers") try: hdr_val, _ = winreg.QueryValueEx(gfx_key, "EnableHDR") if hdr_val: @@ -176,11 +176,7 @@ class HDRModeWatcher: a display's HDR mode changes. """ - def __init__( - self, - on_hdr_change=None, - poll_interval: float = 5.0 - ): + def __init__(self, on_hdr_change=None, poll_interval: float = 5.0): """ Args: on_hdr_change: Callback(display_index, hdr_enabled, state) @@ -212,6 +208,7 @@ def stop(self): def _watch_loop(self): import time + while self._running: time.sleep(self.poll_interval) try: @@ -221,11 +218,7 @@ def _watch_loop(self): if prev is not None and prev != state.hdr_enabled: self._last_states[state.display_index] = state.hdr_enabled if self.on_hdr_change: - self.on_hdr_change( - state.display_index, - state.hdr_enabled, - state - ) + self.on_hdr_change(state.display_index, state.hdr_enabled, state) elif prev is None: self._last_states[state.display_index] = state.hdr_enabled except Exception: diff --git a/calibrate_pro/display/oled.py b/calibrate_pro/display/oled.py index 40fe3ff..0f57d82 100644 --- a/calibrate_pro/display/oled.py +++ b/calibrate_pro/display/oled.py @@ -19,6 +19,7 @@ # ABL (Auto Brightness Limiter) Models # ============================================================================= + @dataclass class ABLModel: """ @@ -38,9 +39,10 @@ class ABLModel: min_factor = luminance at 100% APL relative to peak (typically 0.15-0.35) rolloff = shape of the ABL curve (higher = more aggressive) """ - peak_luminance: float # Peak at 2-10% APL (cd/m2) - min_factor: float # Luminance ratio at 100% APL (0.15 = 15% of peak) - rolloff: float # Curve shape (1.0 = linear, 2.0 = aggressive) + + peak_luminance: float # Peak at 2-10% APL (cd/m2) + min_factor: float # Luminance ratio at 100% APL (0.15 = 15% of peak) + rolloff: float # Curve shape (1.0 = linear, 2.0 = aggressive) apl_threshold: float = 0.0 # APL below which no ABL applies (some panels) def get_luminance(self, apl: float) -> float: @@ -69,34 +71,26 @@ def get_abl_factor(self, apl: float) -> float: # Samsung QD-OLED (2024 generation - PG27UCDM, AW3225QF, G80SD) "QD-OLED-2024": ABLModel( peak_luminance=1000.0, # 2% window - min_factor=0.28, # ~280 nits at 100% white + min_factor=0.28, # ~280 nits at 100% white rolloff=1.5, - apl_threshold=0.03 + apl_threshold=0.03, ), - # Samsung QD-OLED (2023 generation - AW3423DW, G85SB) "QD-OLED-2023": ABLModel( peak_luminance=1000.0, - min_factor=0.25, # ~250 nits at 100% white + min_factor=0.25, # ~250 nits at 100% white rolloff=1.6, - apl_threshold=0.02 + apl_threshold=0.02, ), - # LG WOLED evo (C3/C4/G3/G4) "WOLED-EVO": ABLModel( - peak_luminance=800.0, # 2% window - min_factor=0.18, # ~150 nits at 100% white + peak_luminance=800.0, # 2% window + min_factor=0.18, # ~150 nits at 100% white rolloff=1.8, - apl_threshold=0.02 + apl_threshold=0.02, ), - # LG WOLED (older - C1/C2) - "WOLED": ABLModel( - peak_luminance=700.0, - min_factor=0.15, - rolloff=2.0, - apl_threshold=0.02 - ), + "WOLED": ABLModel(peak_luminance=700.0, min_factor=0.15, rolloff=2.0, apl_threshold=0.02), } @@ -110,8 +104,7 @@ def get_abl_model(panel_type: str, panel_key: str = "") -> ABLModel | None: """ if panel_type == "QD-OLED": # 2024 generation panels - if any(k in panel_key for k in ["PG27UCDM", "PG32UCDM", "AW3225QF", - "G80SD", "PG34WCDM", "FO32U2P", "S95D"]): + if any(k in panel_key for k in ["PG27UCDM", "PG32UCDM", "AW3225QF", "G80SD", "PG34WCDM", "FO32U2P", "S95D"]): return ABL_MODELS["QD-OLED-2024"] return ABL_MODELS["QD-OLED-2023"] elif panel_type == "WOLED": @@ -125,6 +118,7 @@ def get_abl_model(panel_type: str, panel_key: str = "") -> ABLModel | None: # Near-Black Handling # ============================================================================= + @dataclass class NearBlackModel: """ @@ -138,12 +132,13 @@ class NearBlackModel: This model allows LUT correction for these artifacts. """ + # Signal level below which near-black issues appear (0-1) threshold: float = 0.03 # Gamma deviation in the near-black region # Actual gamma may be higher or lower than target in this range - gamma_lift: float = 0.0 # Positive = raised blacks, negative = crushed + gamma_lift: float = 0.0 # Positive = raised blacks, negative = crushed # Color shift in near-black (Lab a*, b* offsets) # QD-OLED: typically small, WOLED: can be noticeable green shift @@ -159,33 +154,29 @@ class NearBlackModel: NEAR_BLACK_MODELS = { "QD-OLED": NearBlackModel( threshold=0.03, - gamma_lift=0.005, # Slight raised blacks + gamma_lift=0.005, # Slight raised blacks near_black_a_shift=0.0, near_black_b_shift=0.0, - black_cutoff=0.001 + black_cutoff=0.001, ), "WOLED": NearBlackModel( threshold=0.04, gamma_lift=0.003, near_black_a_shift=-0.5, # Slight green tint in near-black near_black_b_shift=0.3, - black_cutoff=0.001 + black_cutoff=0.001, ), "WOLED-EVO": NearBlackModel( threshold=0.03, gamma_lift=0.002, near_black_a_shift=-0.3, # Reduced with newer panels near_black_b_shift=0.2, - black_cutoff=0.001 + black_cutoff=0.001, ), } -def apply_near_black_correction( - rgb: np.ndarray, - model: NearBlackModel, - target_gamma: float = 2.2 -) -> np.ndarray: +def apply_near_black_correction(rgb: np.ndarray, model: NearBlackModel, target_gamma: float = 2.2) -> np.ndarray: """ Apply near-black correction to an RGB value. @@ -221,11 +212,13 @@ def apply_near_black_correction( # Panel Technology Characteristics # ============================================================================= + @dataclass class OLEDCharacteristics: """Complete OLED panel characteristic profile.""" - technology: str # "QD-OLED", "WOLED", "WOLED-EVO" - subpixel_layout: str # "triangle" (QD-OLED), "WRGB" (WOLED) + + technology: str # "QD-OLED", "WOLED", "WOLED-EVO" + subpixel_layout: str # "triangle" (QD-OLED), "WRGB" (WOLED) abl_model: ABLModel | None = None near_black_model: NearBlackModel | None = None @@ -235,7 +228,7 @@ class OLEDCharacteristics: # Power efficiency characteristics max_sustained_luminance: float = 0.0 # Full-screen sustainable (cd/m2) - thermal_throttle_time: float = 0.0 # Seconds before thermal throttle + thermal_throttle_time: float = 0.0 # Seconds before thermal throttle @property def is_qd_oled(self) -> bool: @@ -265,7 +258,7 @@ def get_oled_characteristics(panel_type: str, panel_key: str = "") -> OLEDCharac near_black_model=near_black, gamut_luminance_rolloff=0.05, # QD-OLED maintains gamut well max_sustained_luminance=abl.get_luminance(1.0), - thermal_throttle_time=300.0 # ~5 minutes to thermal throttle + thermal_throttle_time=300.0, # ~5 minutes to thermal throttle ) elif panel_type == "WOLED": is_evo = any(k in panel_key for k in ["C3", "C4", "G3", "G4", "32GS95UE"]) @@ -278,7 +271,7 @@ def get_oled_characteristics(panel_type: str, panel_key: str = "") -> OLEDCharac near_black_model=near_black, gamut_luminance_rolloff=0.15, # WOLED loses saturation at high lum max_sustained_luminance=abl.get_luminance(1.0), - thermal_throttle_time=600.0 # ~10 minutes + thermal_throttle_time=600.0, # ~10 minutes ) return None @@ -287,11 +280,8 @@ def get_oled_characteristics(panel_type: str, panel_key: str = "") -> OLEDCharac # OLED-Aware LUT Compensation # ============================================================================= -def compensate_abl_in_lut( - rgb: np.ndarray, - abl_model: ABLModel, - target_apl: float = 0.25 -) -> np.ndarray: + +def compensate_abl_in_lut(rgb: np.ndarray, abl_model: ABLModel, target_apl: float = 0.25) -> np.ndarray: """ Compensate for ABL in a LUT value. diff --git a/calibrate_pro/display/scrgb_pipeline.py b/calibrate_pro/display/scrgb_pipeline.py index de3f08c..1734881 100644 --- a/calibrate_pro/display/scrgb_pipeline.py +++ b/calibrate_pro/display/scrgb_pipeline.py @@ -49,11 +49,11 @@ # --------------------------------------------------------------------------- # PQ (ST.2084) constants -_PQ_M1 = 2610.0 / 16384.0 # 0.1593017578125 +_PQ_M1 = 2610.0 / 16384.0 # 0.1593017578125 _PQ_M2 = 2523.0 / 4096.0 * 128.0 # 78.84375 -_PQ_C1 = 3424.0 / 4096.0 # 0.8359375 -_PQ_C2 = 2413.0 / 4096.0 * 32.0 # 18.8515625 -_PQ_C3 = 2392.0 / 4096.0 * 32.0 # 18.6875 +_PQ_C1 = 3424.0 / 4096.0 # 0.8359375 +_PQ_C2 = 2413.0 / 4096.0 * 32.0 # 18.8515625 +_PQ_C3 = 2392.0 / 4096.0 * 32.0 # 18.6875 # Reference white for sRGB / Rec.709 _SRGB_WHITE_NITS = 80.0 @@ -66,6 +66,7 @@ # Low-level transfer functions # --------------------------------------------------------------------------- + def _srgb_eotf(v: np.ndarray) -> np.ndarray: """sRGB EOTF: gamma-encoded [0,1] -> linear-light [0,1].""" v = np.clip(v, 0.0, 1.0) @@ -110,7 +111,7 @@ def _pq_oetf(nits: np.ndarray, peak_nits: float = _PQ_PEAK_NITS) -> np.ndarray: """ nits = np.clip(nits, 0.0, peak_nits) y = nits / peak_nits # normalise to [0, 1] - yp = y ** _PQ_M1 + yp = y**_PQ_M1 num = _PQ_C1 + _PQ_C2 * yp den = 1.0 + _PQ_C3 * yp return (num / den) ** _PQ_M2 @@ -120,6 +121,7 @@ def _pq_oetf(nits: np.ndarray, peak_nits: float = _PQ_PEAK_NITS) -> np.ndarray: # Pipeline conversion helpers # --------------------------------------------------------------------------- + def sdr_to_scrgb( srgb: np.ndarray, sdr_white_nits: float = _SRGB_WHITE_NITS, @@ -267,6 +269,7 @@ def compute_hdr_headroom_stops( # ScRGBPipelineModel - end-to-end pipeline modelling # --------------------------------------------------------------------------- + @dataclass class ScRGBPipelineModel: """ diff --git a/calibrate_pro/display/uniformity.py b/calibrate_pro/display/uniformity.py index c394289..378e65e 100644 --- a/calibrate_pro/display/uniformity.py +++ b/calibrate_pro/display/uniformity.py @@ -22,23 +22,26 @@ # Data model # --------------------------------------------------------------------------- + @dataclass class UniformityGrid: """Measured uniformity data across the screen.""" - rows: int # Grid rows (e.g., 5) - cols: int # Grid columns (e.g., 5) - luminance: np.ndarray # Measured luminance at each point (rows x cols) - chrominance_x: np.ndarray # Measured chromaticity x at each point - chrominance_y: np.ndarray # Measured chromaticity y at each point - reference_luminance: float # Center point luminance (reference) - reference_x: float # Center point chromaticity x - reference_y: float # Center point chromaticity y + + rows: int # Grid rows (e.g., 5) + cols: int # Grid columns (e.g., 5) + luminance: np.ndarray # Measured luminance at each point (rows x cols) + chrominance_x: np.ndarray # Measured chromaticity x at each point + chrominance_y: np.ndarray # Measured chromaticity y at each point + reference_luminance: float # Center point luminance (reference) + reference_x: float # Center point chromaticity x + reference_y: float # Center point chromaticity y # --------------------------------------------------------------------------- # Uniformity compensation engine # --------------------------------------------------------------------------- + class UniformityCompensation: """ Computes per-position RGB correction factors from a measured @@ -70,9 +73,7 @@ def __init__(self, grid: UniformityGrid): # Public API # ----------------------------------------------------------------- - def get_correction_factor( - self, screen_x: float, screen_y: float - ) -> tuple[float, float, float]: + def get_correction_factor(self, screen_x: float, screen_y: float) -> tuple[float, float, float]: """ Get per-channel correction factor for a normalised screen position. @@ -149,8 +150,7 @@ def compute_uniformity_stats(self) -> dict: lum_max = float(np.max(grid.luminance)) chrom_dist = np.sqrt( - (grid.chrominance_x - grid.reference_x) ** 2 - + (grid.chrominance_y - grid.reference_y) ** 2 + (grid.chrominance_x - grid.reference_x) ** 2 + (grid.chrominance_y - grid.reference_y) ** 2 ) chrom_spread = float(np.max(chrom_dist)) @@ -243,6 +243,7 @@ def _bilinear(self, data: np.ndarray, norm_x: float, norm_y: float) -> float: # Measurement plan # --------------------------------------------------------------------------- + def create_uniformity_measurement_plan( rows: int = 5, cols: int = 5, @@ -280,6 +281,7 @@ def create_uniformity_measurement_plan( # Simulated data generator (for --simulated mode) # --------------------------------------------------------------------------- + def generate_simulated_uniformity( rows: int = 5, cols: int = 5, @@ -299,7 +301,7 @@ def generate_simulated_uniformity( center_r = (rows - 1) / 2.0 center_c = (cols - 1) / 2.0 - max_dist = math.sqrt(center_r ** 2 + center_c ** 2) + max_dist = math.sqrt(center_r**2 + center_c**2) ref_x, ref_y = 0.3127, 0.3290 # D65 @@ -309,7 +311,7 @@ def generate_simulated_uniformity( norm_dist = dist / max_dist if max_dist > 0 else 0.0 # Luminance: smooth fall-off + noise - falloff_factor = 1.0 - edge_falloff * (norm_dist ** 1.5) + falloff_factor = 1.0 - edge_falloff * (norm_dist**1.5) noise = rng.normal(0, 0.008) lum = center_luminance * max(0.5, falloff_factor + noise) @@ -326,6 +328,7 @@ def generate_simulated_uniformity( # CLI command # --------------------------------------------------------------------------- + def cmd_uniformity(args) -> int: """CLI handler for the ``uniformity`` subcommand.""" from calibrate_pro import __version__ @@ -365,8 +368,7 @@ def cmd_uniformity(args) -> int: print("--- Uniformity Statistics ---") print(f" Max deviation: {stats['max_deviation_pct']:.1f}%") print(f" Avg deviation: {stats['avg_deviation_pct']:.1f}%") - print(f" Worst area: {stats['worst_corner']} " - f"(row {stats['worst_row']}, col {stats['worst_col']})") + print(f" Worst area: {stats['worst_corner']} (row {stats['worst_row']}, col {stats['worst_col']})") lmin, lmax = stats["luminance_range"] print(f" Luminance range: {lmin:.1f} - {lmax:.1f} cd/m2") print(f" Chrominance spread: {stats['chrominance_spread']:.5f}") @@ -399,8 +401,7 @@ def cmd_uniformity(args) -> int: grade = "Poor" print(f"\nUniformity Grade: {grade}") - print(f"Max Deviation: {stats['max_deviation_pct']:.1f}% " - f"(worst: {stats['worst_corner']})") + print(f"Max Deviation: {stats['max_deviation_pct']:.1f}% (worst: {stats['worst_corner']})") print("=" * 60) return 0 @@ -410,6 +411,7 @@ def cmd_uniformity(args) -> int: # Helpers # --------------------------------------------------------------------------- + def _label_position(row: int, col: int, rows: int, cols: int) -> str: """Return a human-friendly label for a grid position.""" if rows <= 1 and cols <= 1: diff --git a/calibrate_pro/gui/__init__.py b/calibrate_pro/gui/__init__.py index e7ffeb4..6f3665d 100644 --- a/calibrate_pro/gui/__init__.py +++ b/calibrate_pro/gui/__init__.py @@ -177,7 +177,6 @@ "SoftwareColorControlPage", "DDCControlPage", "SettingsPage", - # Calibration Wizard "CalibrationWizard", "CalibrationConfig", @@ -192,7 +191,6 @@ "MeasurementStep", "ProfileGenerationStep", "VerificationStep", - # Display Selection "DisplaySelector", "DisplayLayoutPreview", @@ -201,7 +199,6 @@ "DisplayInfo", "DisplayTechnology", "CalibrationStatus", - # Pattern Window "PatternWindow", "PatternCanvas", @@ -209,7 +206,6 @@ "PatternSequencer", "PatternType", "PatternConfig", - # Measurement View "MeasurementView", "Measurement", @@ -217,14 +213,12 @@ "DeltaEDisplay", "ValuesPanel", "MeasurementHistoryTable", - # LUT Preview "LUTPreviewWidget", "LUTCubeView", "LUTSliceView", "BeforeAfterView", "LUT3D", - # Report Viewer "ReportViewer", "ReportSummaryPanel", @@ -233,14 +227,12 @@ "GrayscaleResult", "ColorCheckerResult", "GamutCoverage", - # CIE Diagram Widget "CIEDiagramWidget", "MeasuredPoint", "SPECTRAL_LOCUS", "WHITE_POINTS", "GAMUTS", - # Gamma Curve Widget "GammaCurveWidget", "GammaInfoPanel", @@ -250,7 +242,6 @@ "bt1886_eotf", "power_law_eotf", "l_star_eotf", - # Delta E Chart Widget "DeltaEBarChart", "DeltaEStatsPanel", @@ -258,7 +249,6 @@ "DeltaEQuality", "classify_delta_e", "get_delta_e_color", - # Color Swatch Widgets "ColorSwatch", "ComparisonSwatch", diff --git a/calibrate_pro/gui/app.py b/calibrate_pro/gui/app.py index 534b283..f23542a 100644 --- a/calibrate_pro/gui/app.py +++ b/calibrate_pro/gui/app.py @@ -71,12 +71,12 @@ def make_tray_icon(accent_color: str = "#92ad7e") -> QIcon: m = s * 0.08 # Monitor body - body_rect = (m, m, s - 2*m, s * 0.72) + body_rect = (m, m, s - 2 * m, s * 0.72) p.setPen(QPen(QColor(accent_color), max(1, s * 0.04))) p.setBrush(QColor("#f7f3ee")) - p.drawRoundedRect(int(body_rect[0]), int(body_rect[1]), - int(body_rect[2]), int(body_rect[3]), - s * 0.08, s * 0.08) + p.drawRoundedRect( + int(body_rect[0]), int(body_rect[1]), int(body_rect[2]), int(body_rect[3]), s * 0.08, s * 0.08 + ) # Screen area inset = s * 0.14 @@ -86,9 +86,7 @@ def make_tray_icon(accent_color: str = "#92ad7e") -> QIcon: screen_h = s * 0.52 p.setPen(Qt.PenStyle.NoPen) p.setBrush(QColor("#f0ebe4")) - p.drawRoundedRect(int(screen_x), int(screen_y), - int(screen_w), int(screen_h), - s * 0.04, s * 0.04) + p.drawRoundedRect(int(screen_x), int(screen_y), int(screen_w), int(screen_h), s * 0.04, s * 0.04) # Single-color calibration arc cx = s * 0.5 @@ -109,20 +107,20 @@ def make_tray_icon(accent_color: str = "#92ad7e") -> QIcon: stand_h = s * 0.08 p.setPen(Qt.PenStyle.NoPen) p.setBrush(QColor(accent_color)) - stand = QPolygonF([ - QPointF(cx - stand_w * 0.4, stand_top), - QPointF(cx + stand_w * 0.4, stand_top), - QPointF(cx + stand_w * 0.7, stand_top + stand_h), - QPointF(cx - stand_w * 0.7, stand_top + stand_h), - ]) + stand = QPolygonF( + [ + QPointF(cx - stand_w * 0.4, stand_top), + QPointF(cx + stand_w * 0.4, stand_top), + QPointF(cx + stand_w * 0.7, stand_top + stand_h), + QPointF(cx - stand_w * 0.7, stand_top + stand_h), + ] + ) p.drawPolygon(stand) # Base base_y = stand_top + stand_h base_w = s * 0.30 - p.drawRoundedRect(int(cx - base_w/2), int(base_y), - int(base_w), int(s * 0.04), - s * 0.02, s * 0.02) + p.drawRoundedRect(int(cx - base_w / 2), int(base_y), int(base_w), int(s * 0.04), s * 0.02, s * 0.02) # Check mark in accent color if size >= 24: @@ -133,10 +131,10 @@ def make_tray_icon(accent_color: str = "#92ad7e") -> QIcon: pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) p.setPen(pen) - p.drawLine(QPointF(check_x, check_y + check_s * 0.4), - QPointF(check_x + check_s * 0.35, check_y + check_s * 0.75)) - p.drawLine(QPointF(check_x + check_s * 0.35, check_y + check_s * 0.75), - QPointF(check_x + check_s, check_y)) + p.drawLine( + QPointF(check_x, check_y + check_s * 0.4), QPointF(check_x + check_s * 0.35, check_y + check_s * 0.75) + ) + p.drawLine(QPointF(check_x + check_s * 0.35, check_y + check_s * 0.75), QPointF(check_x + check_s, check_y)) p.end() icon.addPixmap(pm) @@ -164,12 +162,12 @@ def make_app_icon() -> QIcon: m = s * 0.08 # margin # Monitor body — rounded rectangle, warm brown - body_rect = (m, m, s - 2*m, s * 0.72) + body_rect = (m, m, s - 2 * m, s * 0.72) p.setPen(QPen(QColor("#b07878"), max(1, s * 0.04))) p.setBrush(QColor("#f7f3ee")) - p.drawRoundedRect(int(body_rect[0]), int(body_rect[1]), - int(body_rect[2]), int(body_rect[3]), - s * 0.08, s * 0.08) + p.drawRoundedRect( + int(body_rect[0]), int(body_rect[1]), int(body_rect[2]), int(body_rect[3]), s * 0.08, s * 0.08 + ) # Screen area — slightly inset, dark inset = s * 0.14 @@ -179,9 +177,7 @@ def make_app_icon() -> QIcon: screen_h = s * 0.52 p.setPen(Qt.PenStyle.NoPen) p.setBrush(QColor("#f0ebe4")) - p.drawRoundedRect(int(screen_x), int(screen_y), - int(screen_w), int(screen_h), - s * 0.04, s * 0.04) + p.drawRoundedRect(int(screen_x), int(screen_y), int(screen_w), int(screen_h), s * 0.04, s * 0.04) # Color calibration arc on screen — three subtle bands (R, G, B) cx = s * 0.5 @@ -203,20 +199,20 @@ def make_app_icon() -> QIcon: stand_h = s * 0.08 p.setPen(Qt.PenStyle.NoPen) p.setBrush(QColor("#d4a0a0")) - stand = QPolygonF([ - QPointF(cx - stand_w * 0.4, stand_top), - QPointF(cx + stand_w * 0.4, stand_top), - QPointF(cx + stand_w * 0.7, stand_top + stand_h), - QPointF(cx - stand_w * 0.7, stand_top + stand_h), - ]) + stand = QPolygonF( + [ + QPointF(cx - stand_w * 0.4, stand_top), + QPointF(cx + stand_w * 0.4, stand_top), + QPointF(cx + stand_w * 0.7, stand_top + stand_h), + QPointF(cx - stand_w * 0.7, stand_top + stand_h), + ] + ) p.drawPolygon(stand) # Base base_y = stand_top + stand_h base_w = s * 0.30 - p.drawRoundedRect(int(cx - base_w/2), int(base_y), - int(base_w), int(s * 0.04), - s * 0.02, s * 0.02) + p.drawRoundedRect(int(cx - base_w / 2), int(base_y), int(base_w), int(s * 0.04), s * 0.02, s * 0.02) # Small check mark — olive green, bottom right of screen if size >= 24: @@ -227,10 +223,10 @@ def make_app_icon() -> QIcon: pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) p.setPen(pen) - p.drawLine(QPointF(check_x, check_y + check_s * 0.4), - QPointF(check_x + check_s * 0.35, check_y + check_s * 0.75)) - p.drawLine(QPointF(check_x + check_s * 0.35, check_y + check_s * 0.75), - QPointF(check_x + check_s, check_y)) + p.drawLine( + QPointF(check_x, check_y + check_s * 0.4), QPointF(check_x + check_s * 0.35, check_y + check_s * 0.75) + ) + p.drawLine(QPointF(check_x + check_s * 0.35, check_y + check_s * 0.75), QPointF(check_x + check_s, check_y)) p.end() icon.addPixmap(pm) @@ -252,11 +248,11 @@ def make_app_icon() -> QIcon: # Dashboard Page + class GamutMiniWidget(QWidget): """Tiny CIE xy gamut triangle visualization.""" - def __init__(self, red_xy=(0.64, 0.33), green_xy=(0.30, 0.60), - blue_xy=(0.15, 0.06), size: int = 64, parent=None): + def __init__(self, red_xy=(0.64, 0.33), green_xy=(0.30, 0.60), blue_xy=(0.15, 0.06), size: int = 64, parent=None): super().__init__(parent) self.setFixedSize(size, size) self._r = red_xy @@ -309,8 +305,7 @@ def xy_to_px(x, y): class GamutBar(QWidget): """Compact horizontal gamut coverage bar.""" - def __init__(self, srgb: float = 0, p3: float = 0, bt2020: float = 0, - parent=None): + def __init__(self, srgb: float = 0, p3: float = 0, bt2020: float = 0, parent=None): super().__init__(parent) self.setFixedHeight(32) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) @@ -344,7 +339,9 @@ def paintEvent(self, event): # Label p.setPen(QColor(C.TEXT3)) p.setFont(QFont("Segoe UI", 7)) - p.drawText(0, int(y), label_w, bar_h + 2, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, label) + p.drawText( + 0, int(y), label_w, bar_h + 2, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, label + ) # Track p.setPen(Qt.PenStyle.NoPen) @@ -358,9 +355,14 @@ def paintEvent(self, event): # Percentage p.setPen(QColor(C.TEXT2)) - p.drawText(int(bar_x + bar_w + 4), int(y), 28, bar_h + 2, - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, - f"{pct:.0f}%") + p.drawText( + int(bar_x + bar_w + 4), + int(y), + 28, + bar_h + 2, + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + f"{pct:.0f}%", + ) p.end() @@ -370,14 +372,25 @@ class DisplayCard(Card): calibrate_clicked = pyqtSignal(int) # emits display index - def __init__(self, name: str, resolution: str, panel_type: str, - gamut_srgb: float = 0, gamut_p3: float = 0, gamut_bt2020: float = 0, - calibrated: bool = False, hdr: bool = False, - cal_age: str = "", delta_e: float = 0, - red_xy=(0.64, 0.33), green_xy=(0.30, 0.60), blue_xy=(0.15, 0.06), - peak_nits: float = 0, - display_index: int = 0, - parent=None): + def __init__( + self, + name: str, + resolution: str, + panel_type: str, + gamut_srgb: float = 0, + gamut_p3: float = 0, + gamut_bt2020: float = 0, + calibrated: bool = False, + hdr: bool = False, + cal_age: str = "", + delta_e: float = 0, + red_xy=(0.64, 0.33), + green_xy=(0.30, 0.60), + blue_xy=(0.15, 0.06), + peak_nits: float = 0, + display_index: int = 0, + parent=None, + ): super().__init__(parent) self._display_index = display_index self.setMinimumHeight(140) @@ -405,9 +418,11 @@ def __init__(self, name: str, resolution: str, panel_type: str, # Tags if hdr: hdr_tag = QLabel("HDR") - hdr_tag.setStyleSheet(f"background: {C.SURFACE2}; border: 1px solid {C.BORDER}; " - f"border-radius: 9px; padding: 2px 10px; font-size: 9px; " - f"color: {C.CYAN}; font-weight: 600;") + hdr_tag.setStyleSheet( + f"background: {C.SURFACE2}; border: 1px solid {C.BORDER}; " + f"border-radius: 9px; padding: 2px 10px; font-size: 9px; " + f"color: {C.CYAN}; font-weight: 600;" + ) hdr_tag.setFixedHeight(18) name_row.addWidget(hdr_tag) name_row.addStretch() @@ -436,9 +451,11 @@ def __init__(self, name: str, resolution: str, panel_type: str, if calibrated and delta_e > 0: de_color = C.GREEN_HI if delta_e < 2 else C.YELLOW if delta_e < 4 else C.RED de_badge = QLabel(f"dE {delta_e:.1f}") - de_badge.setStyleSheet(f"background: {C.SURFACE2}; border: 1px solid {de_color}; " - f"border-radius: 10px; padding: 4px 12px; font-size: 11px; " - f"color: {de_color}; font-weight: 600;") + de_badge.setStyleSheet( + f"background: {C.SURFACE2}; border: 1px solid {de_color}; " + f"border-radius: 10px; padding: 4px 12px; font-size: 11px; " + f"color: {de_color}; font-weight: 600;" + ) de_badge.setFixedHeight(26) de_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) right.addWidget(de_badge, alignment=Qt.AlignmentFlag.AlignRight) @@ -526,7 +543,9 @@ def __init__(self, parent=None): self._lum_stat = Stat("Luminance", "—", C.TEXT) self._cct_stat = Stat("CCT", "—", C.TEXT) self._xyz_label = QLabel("X — Y — Z —") - self._xyz_label.setStyleSheet(f"font-size: 11px; color: {C.TEXT2}; font-family: 'Cascadia Code', 'Consolas', monospace;") + self._xyz_label.setStyleSheet( + f"font-size: 11px; color: {C.TEXT2}; font-family: 'Cascadia Code', 'Consolas', monospace;" + ) readings.addWidget(self._lum_stat) readings.addWidget(self._cct_stat) @@ -544,6 +563,7 @@ def _toggle_live(self): def _start_live(self): try: from calibrate_pro.hardware.i1d3_native import I1D3Driver + self._driver = I1D3Driver() if not self._driver.open(): self._title.setText("Colorimeter — Failed to open") @@ -590,6 +610,7 @@ def _take_reading(self): # Add Display Profile Dialog + class AddDisplayDialog(QDialog): """Dialog for adding display profiles via EDID auto-detect or JSON import.""" @@ -623,9 +644,7 @@ def _build(self): layout.setSpacing(16) heading = QLabel("Add Display Profile") - heading.setStyleSheet( - f"font-size: 18px; font-weight: 500; color: {C.TEXT};" - ) + heading.setStyleSheet(f"font-size: 18px; font-weight: 500; color: {C.TEXT};") layout.addWidget(heading) tabs = QTabWidget() @@ -641,8 +660,7 @@ def _build_edid_tab(self): vbox.setSpacing(12) desc = QLabel( - "Detect connected displays via EDID and create panel profiles\n" - "from their reported chromaticity data." + "Detect connected displays via EDID and create panel profiles\nfrom their reported chromaticity data." ) desc.setStyleSheet(f"font-size: 11px; color: {C.TEXT2}; line-height: 1.4;") desc.setWordWrap(True) @@ -657,8 +675,7 @@ def _build_edid_tab(self): # Info card info_card = QFrame() info_card.setStyleSheet( - f"QFrame {{ background: {C.SURFACE2}; border: 1px solid {C.BORDER}; " - f"border-radius: 10px; padding: 12px; }}" + f"QFrame {{ background: {C.SURFACE2}; border: 1px solid {C.BORDER}; border-radius: 10px; padding: 12px; }}" ) info_layout = QVBoxLayout(info_card) info_layout.setSpacing(6) @@ -841,22 +858,25 @@ def _scan_displays(self): edid_gamma = edid_info.get("gamma", 2.2) or 2.2 # Extract chromaticity using the auto-cal engine method from calibrate_pro.sensorless.auto_calibration import AutoCalibrationEngine + edid_chromaticity = AutoCalibrationEngine._extract_edid_chromaticity(edid_bytes) in_db = " [in database]" if panel else " [unknown]" res = f"{display.width}x{display.height}" self._edid_combo.addItem(f"{name} ({res}){in_db}") - self._scanned_displays.append({ - "display": display, - "name": name, - "index": i, - "in_database": panel is not None, - "panel": panel, - "edid_chromaticity": edid_chromaticity, - "edid_gamma": edid_gamma, - "manufacturer": display.manufacturer or "Unknown", - }) + self._scanned_displays.append( + { + "display": display, + "name": name, + "index": i, + "in_database": panel is not None, + "panel": panel, + "edid_chromaticity": edid_chromaticity, + "edid_gamma": edid_gamma, + "manufacturer": display.manufacturer or "Unknown", + } + ) self._scanned_edid_data.append(edid_chromaticity) if not displays: @@ -880,8 +900,7 @@ def _on_edid_display_changed(self, index): if info["in_database"]: panel = info["panel"] self._edid_info_label.setText( - f"This display is already in the database as:\n" - f"{panel.name} ({panel.panel_type})" + f"This display is already in the database as:\n{panel.name} ({panel.panel_type})" ) self._edid_info_label.setStyleSheet(f"font-size: 11px; color: {C.GREEN};") self._create_btn.setEnabled(False) @@ -914,8 +933,7 @@ def _on_edid_display_changed(self, index): self._create_btn.setEnabled(True) else: self._edid_info_label.setText( - "No EDID chromaticity data available for this display.\n" - "A generic sRGB profile will be used." + "No EDID chromaticity data available for this display.\nA generic sRGB profile will be used." ) self._edid_info_label.setStyleSheet(f"font-size: 11px; color: {C.YELLOW};") self._primaries_label.setText("") @@ -964,8 +982,8 @@ def _create_edid_profile(self): if panel.ddc is None: panel.ddc = DDCRecommendations( notes=f"Auto-generated defaults for {info['name']}. " - "Adjust picture mode and color preset in your monitor's OSD " - "for best DDC/CI control." + "Adjust picture mode and color preset in your monitor's OSD " + "for best DDC/CI control." ) # Save to profiles directory @@ -976,12 +994,13 @@ def _create_edid_profile(self): filepath = db.save_panel(key, f"{safe_name.lower()}.json") QMessageBox.information( - self, "Profile Created", + self, + "Profile Created", f"Panel profile created successfully.\n\n" f"Name: {info['name']}\n" f"Type: {panel.panel_type}\n" f"Gamma: {gamma}\n" - f"Saved to: {filepath}" + f"Saved to: {filepath}", ) self.display_added.emit() @@ -994,8 +1013,7 @@ def _create_edid_profile(self): def _browse_import_file(self): """Open file dialog to select a .json panel profile.""" path, _ = QFileDialog.getOpenFileName( - self, "Select Panel Profile", "", - "JSON Panel Profiles (*.json);;All Files (*)" + self, "Select Panel Profile", "", "JSON Panel Profiles (*.json);;All Files (*)" ) if not path: return @@ -1010,6 +1028,7 @@ def _browse_import_file(self): # Preview the file try: import json + with open(path, encoding="utf-8") as f: data = json.load(f) @@ -1056,7 +1075,8 @@ def _import_profile(self): dest = profiles_dir / Path(self._import_file_path).name if dest.exists(): reply = QMessageBox.question( - self, "File Exists", + self, + "File Exists", f"{dest.name} already exists. Overwrite?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) @@ -1079,9 +1099,9 @@ def _import_profile(self): count = 1 QMessageBox.information( - self, "Import Successful", - f"Imported {count} panel profile(s) from:\n{Path(self._import_file_path).name}\n\n" - f"Saved to: {dest}" + self, + "Import Successful", + f"Imported {count} panel profile(s) from:\n{Path(self._import_file_path).name}\n\nSaved to: {dest}", ) self.display_added.emit() @@ -1209,6 +1229,7 @@ def _populate(self): calibrated = False try: from calibrate_pro.utils.startup_manager import StartupManager + mgr = StartupManager() cal = mgr.get_display_calibration(i) if cal and cal.lut_path and Path(cal.lut_path).exists(): @@ -1223,7 +1244,13 @@ def _populate(self): # Get gamut coverage srgb_pct = 100 if panel and panel.capabilities.wide_gamut else 99 - bt2020_pct = 79 if panel and panel.panel_type == "QD-OLED" else 61 if panel and panel.capabilities.wide_gamut else 45 + bt2020_pct = ( + 79 + if panel and panel.panel_type == "QD-OLED" + else 61 + if panel and panel.capabilities.wide_gamut + else 45 + ) # Peak luminance peak = panel.capabilities.max_luminance_hdr if panel else 0 @@ -1235,6 +1262,7 @@ def _populate(self): cal_state = mgr.get_display_calibration(i) if cal_state and cal_state.last_calibrated: from datetime import datetime + cal_dt = datetime.fromisoformat(cal_state.last_calibrated) age = datetime.now() - cal_dt if age.days == 0: @@ -1248,13 +1276,21 @@ def _populate(self): logger.debug("Could not read calibration age for display %d: %s", i, e) card = DisplayCard( - name, res, panel_type, - gamut_srgb=srgb_pct, gamut_p3=gamut_p3, gamut_bt2020=bt2020_pct, - calibrated=calibrated, hdr=hdr, - cal_age=cal_age, delta_e=delta_e, - red_xy=r_xy, green_xy=g_xy, blue_xy=b_xy, + name, + res, + panel_type, + gamut_srgb=srgb_pct, + gamut_p3=gamut_p3, + gamut_bt2020=bt2020_pct, + calibrated=calibrated, + hdr=hdr, + cal_age=cal_age, + delta_e=delta_e, + red_xy=r_xy, + green_xy=g_xy, + blue_xy=b_xy, peak_nits=peak, - display_index=i + display_index=i, ) card.calibrate_clicked.connect(self.navigate_to_calibrate.emit) self._cards_layout.addWidget(card) @@ -1264,6 +1300,7 @@ def _populate(self): # Check DWM LUT status try: from calibrate_pro.lut_system.dwm_lut import get_dwm_lut_directory + lut_dir = get_dwm_lut_directory() lut_files = list(lut_dir.glob("*.cube")) if lut_dir.exists() else [] if lut_files: @@ -1276,12 +1313,9 @@ def _populate(self): # Guard status try: main_window = self.window() - if hasattr(main_window, '_guard') and main_window._guard and main_window._guard.is_running: + if hasattr(main_window, "_guard") and main_window._guard and main_window._guard.is_running: restores = main_window._guard.restore_count - self._stat_guard.set_value( - f"Active ({restores} restores)" if restores else "Active", - C.GREEN_HI - ) + self._stat_guard.set_value(f"Active ({restores} restores)" if restores else "Active", C.GREEN_HI) else: self._stat_guard.set_value("Inactive", C.TEXT3) except (AttributeError, RuntimeError): @@ -1290,6 +1324,7 @@ def _populate(self): # Startup status try: from calibrate_pro.utils.startup_manager import StartupManager + mgr = StartupManager() if mgr.is_startup_enabled(): self._stat_startup.set_value("Enabled", C.GREEN_HI) @@ -1306,6 +1341,7 @@ def _populate(self): # Sensor detection try: from calibrate_pro.hardware.i1d3_native import I1D3Driver + devices = I1D3Driver.find_devices() if devices: sensor_name = devices[0].get("product", "Unknown Colorimeter") @@ -1330,6 +1366,7 @@ def _show_add_display_dialog(self): # Placeholder Pages (to be rebuilt individually) + class PlaceholderPage(QWidget): def __init__(self, title: str, parent=None): super().__init__(parent) @@ -1342,6 +1379,7 @@ def __init__(self, title: str, parent=None): # Main Window + class CalibrateProWindow(QMainWindow): """Main application window.""" @@ -1376,6 +1414,7 @@ def __init__(self): def _start_services(self): """Start calibration guard and other background services.""" import logging + logger = logging.getLogger(__name__) self._guard = None @@ -1393,10 +1432,11 @@ def on_restore(display_name, reason): # Guard all displays that have saved calibration state try: from calibrate_pro.panels.detection import enumerate_displays + displays = enumerate_displays() for i, d in enumerate(displays): - device_name = getattr(d, 'device_name', f"\\\\.\\DISPLAY{i+1}") - display_name = getattr(d, 'name', f"Display {i+1}") + device_name = getattr(d, "device_name", f"\\\\.\\DISPLAY{i + 1}") + display_name = getattr(d, "name", f"Display {i + 1}") gd = GuardedDisplay( device_name=device_name, display_name=display_name, @@ -1478,6 +1518,7 @@ def _check_first_run(self): try: from calibrate_pro.panels.detection import enumerate_displays, get_display_name + displays = enumerate_displays() if displays: for d in displays: @@ -1506,6 +1547,7 @@ def _check_first_run(self): sensor_color = C.TEXT3 try: from calibrate_pro.hardware.i1d3_native import I1D3Driver + devices = I1D3Driver.find_devices() if devices: sensor_name = devices[0].get("product", "i1Display3") @@ -1555,7 +1597,7 @@ def _shortcut_switch_page(self, index: int): def _escape_action(self): """Minimize to tray if available, otherwise minimize window.""" - if hasattr(self, '_tray') and self._tray.isVisible(): + if hasattr(self, "_tray") and self._tray.isVisible(): self.hide() else: self.showMinimized() @@ -1567,24 +1609,24 @@ def _build_menubar(self): # File file_menu = mb.addMenu("&File") - file_menu.addAction(QAction("&Calibrate All", self, shortcut="Ctrl+Shift+C", - triggered=self._calibrate_all)) + file_menu.addAction(QAction("&Calibrate All", self, shortcut="Ctrl+Shift+C", triggered=self._calibrate_all)) file_menu.addSeparator() export = file_menu.addMenu("&Export") - for fmt, label in [("cube", ".cube (Resolve / dwm_lut)"), - ("3dlut", ".3dlut (MadVR)"), - ("png", ".png (ReShade / SpecialK)"), - ("icc", ".icc (ICC Profile)"), - ("mpv", "mpv config"), - ("obs", "OBS LUT")]: + for fmt, label in [ + ("cube", ".cube (Resolve / dwm_lut)"), + ("3dlut", ".3dlut (MadVR)"), + ("png", ".png (ReShade / SpecialK)"), + ("icc", ".icc (ICC Profile)"), + ("mpv", "mpv config"), + ("obs", "OBS LUT"), + ]: act = QAction(label, self) act.triggered.connect(lambda checked, f=fmt: self._export(f)) export.addAction(act) file_menu.addSeparator() - file_menu.addAction(QAction("E&xit", self, shortcut="Alt+F4", - triggered=self.close)) + file_menu.addAction(QAction("E&xit", self, shortcut="Alt+F4", triggered=self.close)) # View — page navigation shortcuts view = mb.addMenu("&View") @@ -1596,8 +1638,7 @@ def _build_menubar(self): act.triggered.connect(lambda checked, idx=i: self._shortcut_switch_page(idx)) view.addAction(act) view.addSeparator() - view.addAction(QAction("&Refresh Dashboard", self, shortcut="F5", - triggered=self._refresh_dashboard)) + view.addAction(QAction("&Refresh Dashboard", self, shortcut="F5", triggered=self._refresh_dashboard)) # Display disp = mb.addMenu("&Display") @@ -1635,48 +1676,53 @@ def _build_central(self): self.dashboard = DashboardPage() self.dashboard.navigate_to_calibrate.connect(self._navigate_to_calibrate) self.dashboard.calibrate_all_requested.connect(self._calibrate_all) - self.stack.addWidget(self.dashboard) # 0 + self.stack.addWidget(self.dashboard) # 0 # Calibrate page try: from calibrate_pro.gui.pages.calibrate import CalibratePage + cal_page = CalibratePage() cal_page.calibration_completed.connect(self._update_tray_state) - self.stack.addWidget(cal_page) # 1 + self.stack.addWidget(cal_page) # 1 except (ImportError, AttributeError) as e: logger.warning("Failed to load CalibratePage: %s", e) - self.stack.addWidget(PlaceholderPage("Calibrate")) # 1 + self.stack.addWidget(PlaceholderPage("Calibrate")) # 1 # Verify page try: from calibrate_pro.gui.pages.verify import VerifyPage - self.stack.addWidget(VerifyPage()) # 2 + + self.stack.addWidget(VerifyPage()) # 2 except (ImportError, TypeError) as e: logger.warning("Failed to load VerifyPage: %s", e) - self.stack.addWidget(PlaceholderPage("Verify")) # 2 + self.stack.addWidget(PlaceholderPage("Verify")) # 2 # Profiles page try: from calibrate_pro.gui.pages.profiles import ProfilesPage - self.stack.addWidget(ProfilesPage()) # 3 + + self.stack.addWidget(ProfilesPage()) # 3 except ImportError as e: logger.warning("Failed to load ProfilesPage: %s", e) - self.stack.addWidget(PlaceholderPage("Profiles")) # 3 + self.stack.addWidget(PlaceholderPage("Profiles")) # 3 # DDC Control page try: from calibrate_pro.gui.pages.ddc_control import DDCControlPage - self.stack.addWidget(DDCControlPage()) # 4 + + self.stack.addWidget(DDCControlPage()) # 4 except (ImportError, RuntimeError) as e: logger.warning("Failed to load DDCControlPage: %s", e) - self.stack.addWidget(PlaceholderPage("DDC Control")) # 4 + self.stack.addWidget(PlaceholderPage("DDC Control")) # 4 # Settings page try: from calibrate_pro.gui.pages.settings import SettingsPage - self.stack.addWidget(SettingsPage()) # 5 + + self.stack.addWidget(SettingsPage()) # 5 except (ImportError, OSError) as e: logger.warning("Failed to load SettingsPage: %s", e) - self.stack.addWidget(PlaceholderPage("Settings")) # 5 + self.stack.addWidget(PlaceholderPage("Settings")) # 5 main_layout.addWidget(self.stack, stretch=1) self.setCentralWidget(central) @@ -1741,7 +1787,7 @@ def _tray_clicked(self, reason): def _update_tray_state(self): """Check calibration status across all displays and update the tray icon/tooltip.""" - if not hasattr(self, '_tray'): + if not hasattr(self, "_tray"): return try: @@ -1788,7 +1834,9 @@ def _update_tray_state(self): elif calibrated_count == total: icon_color = C.GREEN tooltip = f"{APP_NAME} - All displays calibrated" - elif stale_count > 0 and (calibrated_count + stale_count) == total or calibrated_count > 0 or stale_count > 0: + elif ( + stale_count > 0 and (calibrated_count + stale_count) == total or calibrated_count > 0 or stale_count > 0 + ): icon_color = C.YELLOW tooltip = f"{APP_NAME} - {', '.join(per_display_status)}" else: @@ -1806,7 +1854,7 @@ def _update_tray_state(self): def _rebuild_profile_submenu(self): """Populate the tray 'Switch Profile' submenu with available profiles.""" - if not hasattr(self, '_profile_submenu'): + if not hasattr(self, "_profile_submenu"): return self._profile_submenu.clear() @@ -1829,6 +1877,7 @@ def _rebuild_profile_submenu(self): active_stem = None try: from calibrate_pro.utils.startup_manager import StartupManager + mgr = StartupManager() cal = mgr.get_display_calibration(0) if cal and cal.lut_path: @@ -1842,21 +1891,21 @@ def _rebuild_profile_submenu(self): act.setCheckable(True) if active_stem and cube.stem == active_stem: act.setChecked(True) - act.triggered.connect( - lambda checked, p=str(cube): self._apply_tray_profile(p) - ) + act.triggered.connect(lambda checked, p=str(cube): self._apply_tray_profile(p)) self._profile_submenu.addAction(act) def _apply_tray_profile(self, cube_path: str): """Apply a calibration profile LUT from the tray submenu.""" try: from calibrate_pro.lut_system.dwm_lut import load_lut + load_lut(cube_path, display_index=0) # Also install matching ICC if present icc_path = Path(cube_path).with_suffix(".icc") if icc_path.exists(): from calibrate_pro.panels.detection import install_profile + install_profile(str(icc_path)) profile_name = Path(cube_path).stem.replace("_", " ") @@ -1868,9 +1917,9 @@ def _apply_tray_profile(self, cube_path: str): def _quit(self): self._stop_services() - if hasattr(self, '_tray_timer'): + if hasattr(self, "_tray_timer"): self._tray_timer.stop() - if hasattr(self, '_tray'): + if hasattr(self, "_tray"): self._tray.hide() QApplication.quit() @@ -1910,7 +1959,7 @@ def _navigate_to_calibrate(self, display_index: int): self.sidebar._on_click(1) # Select the display in the Calibrate page's combo box cal_page = self.stack.widget(1) - if hasattr(cal_page, 'display_combo'): + if hasattr(cal_page, "display_combo"): if display_index < cal_page.display_combo.count(): cal_page.display_combo.setCurrentIndex(display_index) @@ -1922,14 +1971,16 @@ def _calibrate_all(self): def _restore_defaults(self): reply = QMessageBox.question( - self, "Restore Defaults", + self, + "Restore Defaults", "Reset all displays to uncalibrated defaults?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: try: from calibrate_pro.lut_system.dwm_lut import remove_lut from calibrate_pro.panels.detection import enumerate_displays, reset_gamma_ramp + for i, d in enumerate(enumerate_displays()): reset_gamma_ramp(d.device_name) try: @@ -1942,27 +1993,33 @@ def _restore_defaults(self): QMessageBox.warning(self, "Error", str(e)) def _install_profile(self): - path, _ = QFileDialog.getOpenFileName( - self, "Install ICC Profile", "", "ICC Profiles (*.icc *.icm)") + path, _ = QFileDialog.getOpenFileName(self, "Install ICC Profile", "", "ICC Profiles (*.icc *.icm)") if path: try: from calibrate_pro.panels.detection import install_profile + install_profile(path) self._status.setText(f"Installed: {Path(path).name}") except (ImportError, OSError) as e: QMessageBox.warning(self, "Error", str(e)) def _export(self, fmt: str): - ext_map = {"cube": "*.cube", "3dlut": "*.3dlut", "png": "*.png", - "icc": "*.icc", "mpv": "*.conf", "obs": "*.cube"} - path, _ = QFileDialog.getSaveFileName( - self, f"Export {fmt}", "", f"{fmt.upper()} ({ext_map.get(fmt, '*.*')})") + ext_map = { + "cube": "*.cube", + "3dlut": "*.3dlut", + "png": "*.png", + "icc": "*.icc", + "mpv": "*.conf", + "obs": "*.cube", + } + path, _ = QFileDialog.getSaveFileName(self, f"Export {fmt}", "", f"{fmt.upper()} ({ext_map.get(fmt, '*.*')})") if path: self._status.setText(f"Exported: {Path(path).name}") def _test_patterns(self): try: from calibrate_pro.patterns.display import show_patterns + show_patterns() except (ImportError, OSError, RuntimeError) as e: QMessageBox.warning(self, "Error", str(e)) @@ -1970,24 +2027,26 @@ def _test_patterns(self): def _hdr_status(self): try: from calibrate_pro.display.hdr_detect import detect_hdr_state + states = detect_hdr_state() - msg = "\n".join( - f"{s.display_name}: {'HDR ON' if s.hdr_enabled else 'SDR'}" - for s in states - ) or "No displays detected" + msg = ( + "\n".join(f"{s.display_name}: {'HDR ON' if s.hdr_enabled else 'SDR'}" for s in states) + or "No displays detected" + ) QMessageBox.information(self, "HDR Status", msg) except (ImportError, OSError) as e: QMessageBox.warning(self, "Error", str(e)) def _about(self): QMessageBox.about( - self, "About Calibrate Pro", + self, + "About Calibrate Pro", f"

{APP_NAME}

" f"

Version {APP_VERSION}

" f"

Professional sensorless display calibration
" f"with native colorimeter support.

" f"

Color science: Oklab, JzAzBz, CAM16, PQ/HLG, ACES

" - f"

© 2022-2026 Zain Dana Harper

" + f"

© 2022-2026 Zain Dana Harper

", ) # --- Geometry persistence --- @@ -2000,12 +2059,14 @@ def _restore_geometry(self): def closeEvent(self, event): self.settings.setValue("window/geometry", self.saveGeometry()) # Minimize to tray instead of closing - if hasattr(self, '_tray') and self._tray.isVisible(): + if hasattr(self, "_tray") and self._tray.isVisible(): event.ignore() self.hide() self._tray.showMessage( - APP_NAME, "Running in the background. Right-click tray icon to exit.", - QSystemTrayIcon.MessageIcon.Information, 2000 + APP_NAME, + "Running in the background. Right-click tray icon to exit.", + QSystemTrayIcon.MessageIcon.Information, + 2000, ) else: event.accept() @@ -2013,11 +2074,13 @@ def closeEvent(self, event): # Entry Point + def launch(): """Launch the Calibrate Pro GUI.""" # Windows taskbar icon fix — set app user model ID try: import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("quanta.calibratepro.1") except (AttributeError, OSError): pass diff --git a/calibrate_pro/gui/calibration_details.py b/calibrate_pro/gui/calibration_details.py index 860b287..0f88a5a 100644 --- a/calibrate_pro/gui/calibration_details.py +++ b/calibrate_pro/gui/calibration_details.py @@ -59,8 +59,8 @@ def __init__(self, display_id: int, parent=None): def _setup_ui(self): self.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 12px; }} """) @@ -80,7 +80,7 @@ def _setup_ui(self): self.status_label = QLabel("Loading...") self.status_label.setStyleSheet(f""" - background-color: {COLORS['surface_alt']}; + background-color: {COLORS["surface_alt"]}; padding: 6px 12px; border-radius: 12px; font-weight: 600; @@ -138,8 +138,8 @@ def _setup_ui(self): self.correction_table.setMaximumHeight(150) self.correction_table.setStyleSheet(f""" QTableWidget {{ - background-color: {COLORS['surface_alt']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface_alt"]}; + border: 1px solid {COLORS["border"]}; border-radius: 6px; }} """) @@ -177,6 +177,7 @@ def _load_profile(self): """Load calibration profile data for this display.""" try: import sys + sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from calibrate_pro.lut_system.dwm_lut import DwmLutController @@ -203,7 +204,7 @@ def _load_profile(self): if profile.is_calibrated: self.status_label.setText("CALIBRATED") self.status_label.setStyleSheet(f""" - background-color: {COLORS['success']}; + background-color: {COLORS["success"]}; color: white; padding: 6px 12px; border-radius: 12px; @@ -212,7 +213,7 @@ def _load_profile(self): else: self.status_label.setText("NOT CALIBRATED") self.status_label.setStyleSheet(f""" - background-color: {COLORS['warning']}; + background-color: {COLORS["warning"]}; color: white; padding: 6px 12px; border-radius: 12px; @@ -275,7 +276,7 @@ def _show_no_profile(self): """Show message when no profile is available.""" self.status_label.setText("NO PROFILE") self.status_label.setStyleSheet(f""" - background-color: {COLORS['error']}; + background-color: {COLORS["error"]}; color: white; padding: 6px 12px; border-radius: 12px; @@ -286,19 +287,14 @@ def _populate_corrections_table(self, panel): """Fill the corrections table with color data.""" colors = ["Red", "Green", "Blue", "White"] - srgb = { - "Red": (0.64, 0.33), - "Green": (0.30, 0.60), - "Blue": (0.15, 0.06), - "White": (0.3127, 0.3290) - } + srgb = {"Red": (0.64, 0.33), "Green": (0.30, 0.60), "Blue": (0.15, 0.06), "White": (0.3127, 0.3290)} if panel: native = { "Red": (panel.native_primaries.red.x, panel.native_primaries.red.y), "Green": (panel.native_primaries.green.x, panel.native_primaries.green.y), "Blue": (panel.native_primaries.blue.x, panel.native_primaries.blue.y), - "White": (panel.native_primaries.white.x, panel.native_primaries.white.y) + "White": (panel.native_primaries.white.x, panel.native_primaries.white.y), } else: native = srgb @@ -313,10 +309,11 @@ def _populate_corrections_table(self, panel): self.correction_table.setItem(i, 2, QTableWidgetItem(f"({sx:.3f}, {sy:.3f})")) import math - delta = math.sqrt((sx - nx)**2 + (sy - ny)**2) + + delta = math.sqrt((sx - nx) ** 2 + (sy - ny) ** 2) delta_item = QTableWidgetItem(f"{delta:.4f}") if delta > 0.01: - delta_item.setForeground(QColor(COLORS['warning'])) + delta_item.setForeground(QColor(COLORS["warning"])) self.correction_table.setItem(i, 3, delta_item) def _reload_lut(self): @@ -332,15 +329,14 @@ def _reload_lut(self): if profile and profile.lut_path: success = dwm.load_lut_file(self.display_id, profile.lut_path) if success: - QMessageBox.information(self, "LUT Reloaded", - f"LUT successfully reloaded for Display {self.display_id}") + QMessageBox.information( + self, "LUT Reloaded", f"LUT successfully reloaded for Display {self.display_id}" + ) self._load_profile() else: - QMessageBox.warning(self, "Reload Failed", - "Failed to reload LUT. Check if file exists.") + QMessageBox.warning(self, "Reload Failed", "Failed to reload LUT. Check if file exists.") else: - QMessageBox.warning(self, "No LUT", - "No LUT file found for this display.") + QMessageBox.warning(self, "No LUT", "No LUT file found for this display.") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to reload LUT: {e}") @@ -353,12 +349,12 @@ def _recalibrate(self): success = manager.calibrate_display(self.display_id, CalibrationTarget.SRGB) if success: - QMessageBox.information(self, "Calibration Complete", - f"Display {self.display_id} calibrated successfully!") + QMessageBox.information( + self, "Calibration Complete", f"Display {self.display_id} calibrated successfully!" + ) self._load_profile() else: - QMessageBox.warning(self, "Calibration Failed", - "Calibration failed. Check display connection.") + QMessageBox.warning(self, "Calibration Failed", "Calibration failed. Check display connection.") except Exception as e: QMessageBox.critical(self, "Error", f"Calibration failed: {e}") @@ -379,8 +375,8 @@ def _setup_ui(self): header_frame = QFrame() header_frame.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border-bottom: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border-bottom: 1px solid {COLORS["border"]}; }} """) header_layout = QHBoxLayout(header_frame) @@ -429,6 +425,7 @@ def refresh_profiles(self): try: import sys + sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from calibrate_pro.lut_system.per_display_calibration import PerDisplayCalibrationManager @@ -448,7 +445,7 @@ def refresh_profiles(self): # Create card for each display for display in displays: - card = CalibrationProfileCard(display['id']) + card = CalibrationProfileCard(display["id"]) self.content_layout.addWidget(card) # Add spacer at bottom diff --git a/calibrate_pro/gui/calibration_test.py b/calibrate_pro/gui/calibration_test.py index 60aad14..062e0a9 100644 --- a/calibrate_pro/gui/calibration_test.py +++ b/calibrate_pro/gui/calibration_test.py @@ -29,22 +29,23 @@ # Windows Display & Gamma Controller # ============================================================================ + class DISPLAY_DEVICE(Structure): _fields_ = [ - ('cb', wintypes.DWORD), - ('DeviceName', wintypes.WCHAR * 32), - ('DeviceString', wintypes.WCHAR * 128), - ('StateFlags', wintypes.DWORD), - ('DeviceID', wintypes.WCHAR * 128), - ('DeviceKey', wintypes.WCHAR * 128), + ("cb", wintypes.DWORD), + ("DeviceName", wintypes.WCHAR * 32), + ("DeviceString", wintypes.WCHAR * 128), + ("StateFlags", wintypes.DWORD), + ("DeviceID", wintypes.WCHAR * 128), + ("DeviceKey", wintypes.WCHAR * 128), ] class GAMMA_RAMP(Structure): _fields_ = [ - ('Red', ctypes.c_ushort * 256), - ('Green', ctypes.c_ushort * 256), - ('Blue', ctypes.c_ushort * 256), + ("Red", ctypes.c_ushort * 256), + ("Green", ctypes.c_ushort * 256), + ("Blue", ctypes.c_ushort * 256), ] @@ -71,12 +72,14 @@ def _enumerate_displays(self): while self.user32.EnumDisplayDevicesW(None, i, byref(dd), 0): if dd.StateFlags & self.DISPLAY_DEVICE_ACTIVE: is_primary = bool(dd.StateFlags & self.DISPLAY_DEVICE_PRIMARY) - self.displays.append({ - 'name': dd.DeviceName, - 'string': dd.DeviceString, - 'primary': is_primary, - 'index': len(self.displays) - }) + self.displays.append( + { + "name": dd.DeviceName, + "string": dd.DeviceString, + "primary": is_primary, + "index": len(self.displays), + } + ) # Store original gamma ramp self._store_original_ramp(dd.DeviceName) i += 1 @@ -101,7 +104,7 @@ def get_gamma_ramp(self, display_idx: int) -> GAMMA_RAMP | None: if display_idx >= len(self.displays): return None - display_name = self.displays[display_idx]['name'] + display_name = self.displays[display_idx]["name"] hdc = self.gdi32.CreateDCW(display_name, None, None, None) if not hdc: return None @@ -117,7 +120,7 @@ def set_gamma_ramp(self, display_idx: int, ramp: GAMMA_RAMP) -> bool: if display_idx >= len(self.displays): return False - display_name = self.displays[display_idx]['name'] + display_name = self.displays[display_idx]["name"] hdc = self.gdi32.CreateDCW(display_name, None, None, None) if not hdc: return False @@ -126,10 +129,18 @@ def set_gamma_ramp(self, display_idx: int, ramp: GAMMA_RAMP) -> bool: self.gdi32.DeleteDC(hdc) return bool(result) - def create_calibration_ramp(self, brightness: int = 100, contrast: int = 100, - r_gain: int = 100, g_gain: int = 100, b_gain: int = 100, - r_offset: int = 0, g_offset: int = 0, b_offset: int = 0, - gamma: float = 2.2) -> GAMMA_RAMP: + def create_calibration_ramp( + self, + brightness: int = 100, + contrast: int = 100, + r_gain: int = 100, + g_gain: int = 100, + b_gain: int = 100, + r_offset: int = 0, + g_offset: int = 0, + b_offset: int = 0, + gamma: float = 2.2, + ) -> GAMMA_RAMP: """Create a calibrated gamma ramp with all adjustments.""" ramp = GAMMA_RAMP() @@ -172,14 +183,22 @@ def create_calibration_ramp(self, brightness: int = 100, contrast: int = 100, return ramp - def apply_calibration(self, display_idx: int, brightness: int = 100, contrast: int = 100, - r_gain: int = 100, g_gain: int = 100, b_gain: int = 100, - r_offset: int = 0, g_offset: int = 0, b_offset: int = 0, - gamma: float = 2.2) -> bool: + def apply_calibration( + self, + display_idx: int, + brightness: int = 100, + contrast: int = 100, + r_gain: int = 100, + g_gain: int = 100, + b_gain: int = 100, + r_offset: int = 0, + g_offset: int = 0, + b_offset: int = 0, + gamma: float = 2.2, + ) -> bool: """Apply calibration settings to a display.""" ramp = self.create_calibration_ramp( - brightness, contrast, r_gain, g_gain, b_gain, - r_offset, g_offset, b_offset, gamma + brightness, contrast, r_gain, g_gain, b_gain, r_offset, g_offset, b_offset, gamma ) return self.set_gamma_ramp(display_idx, ramp) @@ -188,7 +207,7 @@ def reset_display(self, display_idx: int) -> bool: if display_idx >= len(self.displays): return False - display_name = self.displays[display_idx]['name'] + display_name = self.displays[display_idx]["name"] if display_name in self.original_ramps: return self.set_gamma_ramp(display_idx, self.original_ramps[display_name]) @@ -201,6 +220,7 @@ def reset_display(self, display_idx: int) -> bool: # Color Widgets # ============================================================================ + class ColorSwatch(QFrame): """A colored rectangle widget.""" @@ -249,7 +269,7 @@ def paintEvent(self, event): for i in range(self.steps): linear = i / (self.steps - 1) - value = int(255 * (linear ** (1/self.gamma))) + value = int(255 * (linear ** (1 / self.gamma))) color = QColor(value, value, value) x = int(i * step_width) @@ -262,7 +282,7 @@ def paintEvent(self, event): class ColorGradient(QFrame): """RGB gradient bar.""" - def __init__(self, channel: str = 'R', parent=None): + def __init__(self, channel: str = "R", parent=None): super().__init__(parent) self.channel = channel self.gain = 100 @@ -282,13 +302,13 @@ def paintEvent(self, event): gain_factor = self.gain / 100.0 - if self.channel == 'R': + if self.channel == "R": gradient.setColorAt(0, QColor(0, 0, 0)) gradient.setColorAt(1, QColor(int(255 * gain_factor), 0, 0)) - elif self.channel == 'G': + elif self.channel == "G": gradient.setColorAt(0, QColor(0, 0, 0)) gradient.setColorAt(1, QColor(0, int(255 * gain_factor), 0)) - elif self.channel == 'B': + elif self.channel == "B": gradient.setColorAt(0, QColor(0, 0, 0)) gradient.setColorAt(1, QColor(0, 0, int(255 * gain_factor))) @@ -325,8 +345,9 @@ def paintEvent(self, event): text_color = Qt.GlobalColor.black if (r + g + b) / 3 > 128 else Qt.GlobalColor.white painter.setPen(text_color) painter.setFont(QFont("Segoe UI", 9)) - painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, - f"WHITE\nR:{self.r_gain} G:{self.g_gain} B:{self.b_gain}") + painter.drawText( + self.rect(), Qt.AlignmentFlag.AlignCenter, f"WHITE\nR:{self.r_gain} G:{self.g_gain} B:{self.b_gain}" + ) painter.end() @@ -335,14 +356,30 @@ class ColorChecker(QFrame): """Mini ColorChecker-style grid.""" COLORS = [ - (115, 82, 68), (194, 150, 130), (98, 122, 157), (87, 108, 67), - (133, 128, 177), (103, 189, 170), - (214, 126, 44), (80, 91, 166), (193, 90, 99), (94, 60, 108), - (157, 188, 64), (224, 163, 46), - (56, 61, 150), (70, 148, 73), (175, 54, 60), (231, 199, 31), - (187, 86, 149), (8, 133, 161), - (243, 243, 242), (200, 200, 200), (160, 160, 160), (122, 122, 121), - (85, 85, 85), (52, 52, 52), + (115, 82, 68), + (194, 150, 130), + (98, 122, 157), + (87, 108, 67), + (133, 128, 177), + (103, 189, 170), + (214, 126, 44), + (80, 91, 166), + (193, 90, 99), + (94, 60, 108), + (157, 188, 64), + (224, 163, 46), + (56, 61, 150), + (70, 148, 73), + (175, 54, 60), + (231, 199, 31), + (187, 86, 149), + (8, 133, 161), + (243, 243, 242), + (200, 200, 200), + (160, 160, 160), + (122, 122, 121), + (85, 85, 85), + (52, 52, 52), ] def __init__(self, parent=None): @@ -376,6 +413,7 @@ def paintEvent(self, event): # Main Application Window # ============================================================================ + class CalibrationTestWindow(QMainWindow): """Main calibration test window using Windows Gamma Ramp API.""" @@ -463,7 +501,7 @@ def setup_ui(self): self.monitor_combo = QComboBox() for disp in self.gamma_ctrl.displays: - primary = " (Primary)" if disp['primary'] else "" + primary = " (Primary)" if disp["primary"] else "" self.monitor_combo.addItem(f"{disp['name']} - {disp['string']}{primary}") self.monitor_combo.currentIndexChanged.connect(self.on_display_changed) monitor_layout.addWidget(self.monitor_combo) @@ -594,15 +632,15 @@ def setup_ui(self): preset_btn_layout1 = QHBoxLayout() self.preset_d65_btn = QPushButton("D65 (6500K)") - self.preset_d65_btn.clicked.connect(lambda: self.apply_preset('D65')) + self.preset_d65_btn.clicked.connect(lambda: self.apply_preset("D65")) preset_btn_layout1.addWidget(self.preset_d65_btn) self.preset_d55_btn = QPushButton("D55 (5500K)") - self.preset_d55_btn.clicked.connect(lambda: self.apply_preset('D55')) + self.preset_d55_btn.clicked.connect(lambda: self.apply_preset("D55")) preset_btn_layout1.addWidget(self.preset_d55_btn) self.preset_d50_btn = QPushButton("D50 (5000K)") - self.preset_d50_btn.clicked.connect(lambda: self.apply_preset('D50')) + self.preset_d50_btn.clicked.connect(lambda: self.apply_preset("D50")) preset_btn_layout1.addWidget(self.preset_d50_btn) preset_layout.addLayout(preset_btn_layout1) @@ -615,15 +653,15 @@ def setup_ui(self): preset_btn_layout2 = QHBoxLayout() self.preset_srgb_btn = QPushButton("sRGB (2.2)") - self.preset_srgb_btn.clicked.connect(lambda: self.apply_preset('sRGB')) + self.preset_srgb_btn.clicked.connect(lambda: self.apply_preset("sRGB")) preset_btn_layout2.addWidget(self.preset_srgb_btn) self.preset_bt1886_btn = QPushButton("BT.1886 (2.4)") - self.preset_bt1886_btn.clicked.connect(lambda: self.apply_preset('BT1886')) + self.preset_bt1886_btn.clicked.connect(lambda: self.apply_preset("BT1886")) preset_btn_layout2.addWidget(self.preset_bt1886_btn) self.preset_linear_btn = QPushButton("Linear (1.0)") - self.preset_linear_btn.clicked.connect(lambda: self.apply_preset('Linear')) + self.preset_linear_btn.clicked.connect(lambda: self.apply_preset("Linear")) preset_btn_layout2.addWidget(self.preset_linear_btn) preset_layout.addLayout(preset_btn_layout2) @@ -671,9 +709,9 @@ def setup_ui(self): gradients_group = QGroupBox("RGB Channel Gradients") gradients_layout = QVBoxLayout(gradients_group) - self.red_gradient = ColorGradient('R') - self.green_gradient = ColorGradient('G') - self.blue_gradient = ColorGradient('B') + self.red_gradient = ColorGradient("R") + self.green_gradient = ColorGradient("G") + self.blue_gradient = ColorGradient("B") gradients_layout.addWidget(QLabel("Red Channel")) gradients_layout.addWidget(self.red_gradient) @@ -716,10 +754,15 @@ def apply_calibration(self): """Apply current calibration settings to the display.""" result = self.gamma_ctrl.apply_calibration( self.current_display, - self.brightness, self.contrast, - self.r_gain, self.g_gain, self.b_gain, - self.r_offset, self.g_offset, self.b_offset, - self.gamma + self.brightness, + self.contrast, + self.r_gain, + self.g_gain, + self.b_gain, + self.r_offset, + self.g_offset, + self.b_offset, + self.gamma, ) if result: @@ -793,12 +836,12 @@ def on_blue_offset_changed(self, value: int): def apply_preset(self, preset_name: str): """Apply a calibration preset.""" presets = { - 'D65': {'r': 100, 'g': 100, 'b': 100, 'gamma': 2.2}, - 'D55': {'r': 100, 'g': 98, 'b': 90, 'gamma': 2.2}, - 'D50': {'r': 100, 'g': 96, 'b': 82, 'gamma': 2.2}, - 'sRGB': {'r': 100, 'g': 100, 'b': 100, 'gamma': 2.2}, - 'BT1886': {'r': 100, 'g': 100, 'b': 100, 'gamma': 2.4}, - 'Linear': {'r': 100, 'g': 100, 'b': 100, 'gamma': 1.0}, + "D65": {"r": 100, "g": 100, "b": 100, "gamma": 2.2}, + "D55": {"r": 100, "g": 98, "b": 90, "gamma": 2.2}, + "D50": {"r": 100, "g": 96, "b": 82, "gamma": 2.2}, + "sRGB": {"r": 100, "g": 100, "b": 100, "gamma": 2.2}, + "BT1886": {"r": 100, "g": 100, "b": 100, "gamma": 2.4}, + "Linear": {"r": 100, "g": 100, "b": 100, "gamma": 1.0}, } if preset_name not in presets: @@ -812,15 +855,15 @@ def apply_preset(self, preset_name: str): self.blue_slider.blockSignals(True) self.gamma_slider.blockSignals(True) - self.r_gain = p['r'] - self.g_gain = p['g'] - self.b_gain = p['b'] - self.gamma = p['gamma'] + self.r_gain = p["r"] + self.g_gain = p["g"] + self.b_gain = p["b"] + self.gamma = p["gamma"] - self.red_slider.setValue(p['r']) - self.green_slider.setValue(p['g']) - self.blue_slider.setValue(p['b']) - self.gamma_slider.setValue(int(p['gamma'] * 10)) + self.red_slider.setValue(p["r"]) + self.green_slider.setValue(p["g"]) + self.blue_slider.setValue(p["b"]) + self.gamma_slider.setValue(int(p["gamma"] * 10)) self.red_label.setText(f"{p['r']}%") self.green_label.setText(f"{p['g']}%") @@ -871,7 +914,7 @@ def closeEvent(self, event): def main(): app = QApplication(sys.argv) - app.setStyle('Fusion') + app.setStyle("Fusion") window = CalibrationTestWindow() window.show() @@ -879,5 +922,5 @@ def main(): sys.exit(app.exec()) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/calibrate_pro/gui/calibration_wizard.py b/calibrate_pro/gui/calibration_wizard.py index 0014b00..ff0965c 100644 --- a/calibrate_pro/gui/calibration_wizard.py +++ b/calibrate_pro/gui/calibration_wizard.py @@ -62,14 +62,17 @@ # Calibration Configuration # ============================================================================= + class CalibrationMode(Enum): """Calibration method.""" + SENSORLESS = auto() # Sensorless calibration - HARDWARE = auto() # Colorimeter + HARDWARE = auto() # Colorimeter class WhitepointTarget(Enum): """Standard whitepoint targets.""" + D50 = "D50 (5000K)" D55 = "D55 (5500K)" D65 = "D65 (6500K)" @@ -80,6 +83,7 @@ class WhitepointTarget(Enum): class GammaTarget(Enum): """Standard gamma targets.""" + SRGB = "sRGB (2.2 with toe)" BT1886 = "BT.1886 (2.4)" GAMMA_22 = "Power 2.2" @@ -90,6 +94,7 @@ class GammaTarget(Enum): class GamutTarget(Enum): """Standard color gamut targets.""" + SRGB = "sRGB" DCI_P3 = "DCI-P3" DISPLAY_P3 = "Display P3" @@ -101,6 +106,7 @@ class GamutTarget(Enum): @dataclass class CalibrationConfig: """Configuration for calibration session.""" + # Display display_id: int = 0 display_name: str = "" @@ -134,11 +140,12 @@ class CalibrationConfig: # Wizard Step Base # ============================================================================= + class WizardStep(QWidget): """Base class for wizard steps.""" step_complete = pyqtSignal(bool) # Emitted when step validity changes - config_changed = pyqtSignal() # Emitted when configuration changes + config_changed = pyqtSignal() # Emitted when configuration changes def __init__(self, config: CalibrationConfig, parent: QWidget | None = None): super().__init__(parent) @@ -177,6 +184,7 @@ def on_leave(self): # Step 1: Display Selection # ============================================================================= + class DisplaySelectionStep(WizardStep): """Step 1: Select display to calibrate.""" @@ -200,24 +208,24 @@ def _setup_ui(self): self.display_list = QListWidget() self.display_list.setStyleSheet(f""" QListWidget {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 8px; padding: 8px; }} QListWidget::item {{ - background-color: {COLORS['surface_alt']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface_alt"]}; + border: 1px solid {COLORS["border"]}; border-radius: 6px; padding: 16px; margin: 4px; }} QListWidget::item:selected {{ - background-color: {COLORS['accent']}; - border-color: {COLORS['accent']}; + background-color: {COLORS["accent"]}; + border-color: {COLORS["accent"]}; }} QListWidget::item:hover:!selected {{ - border-color: {COLORS['accent']}; + border-color: {COLORS["accent"]}; }} """) self.display_list.itemSelectionChanged.connect(self._on_selection_changed) @@ -227,8 +235,8 @@ def _setup_ui(self): info_frame = QFrame() info_frame.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 8px; padding: 16px; }} @@ -257,15 +265,13 @@ def _setup_ui(self): layout.addWidget(info_frame) # Warmup reminder - warmup_label = QLabel( - "💡 For accurate calibration, ensure your display has warmed up for at least 30 minutes." - ) + warmup_label = QLabel("💡 For accurate calibration, ensure your display has warmed up for at least 30 minutes.") warmup_label.setWordWrap(True) warmup_label.setStyleSheet(f""" - background-color: {COLORS['surface_alt']}; + background-color: {COLORS["surface_alt"]}; border-radius: 6px; padding: 12px; - color: {COLORS['text_secondary']}; + color: {COLORS["text_secondary"]}; """) layout.addWidget(warmup_label) @@ -280,15 +286,18 @@ def on_enter(self): geometry = screen.geometry() item = QListWidgetItem() item.setText(f"{screen.name()}\n{geometry.width()}x{geometry.height()} @ {screen.refreshRate():.0f}Hz") - item.setData(Qt.ItemDataRole.UserRole, { - 'id': i, - 'name': screen.name(), - 'resolution': f"{geometry.width()}x{geometry.height()}", - 'refresh': f"{screen.refreshRate():.0f}Hz", - 'depth': f"{screen.depth()}-bit", - 'technology': 'Unknown', - 'profile': 'None', - }) + item.setData( + Qt.ItemDataRole.UserRole, + { + "id": i, + "name": screen.name(), + "resolution": f"{geometry.width()}x{geometry.height()}", + "refresh": f"{screen.refreshRate():.0f}Hz", + "depth": f"{screen.depth()}-bit", + "technology": "Unknown", + "profile": "None", + }, + ) self.display_list.addItem(item) # Select first display by default @@ -300,12 +309,12 @@ def _on_selection_changed(self): items = self.display_list.selectedItems() if items: data = items[0].data(Qt.ItemDataRole.UserRole) - self.config.display_id = data['id'] - self.config.display_name = data['name'] + self.config.display_id = data["id"] + self.config.display_name = data["name"] # Update info panel for key, label in self.info_labels.items(): - label.setText(data.get(key, '--')) + label.setText(data.get(key, "--")) self._is_valid = True else: @@ -322,6 +331,7 @@ def validate(self) -> bool: # Step 2: Target Settings # ============================================================================= + class TargetSettingsStep(WizardStep): """Step 2: Configure calibration targets.""" @@ -492,29 +502,29 @@ def _on_black_changed(self, value: float): def _preset_photo(self): """Photo editing preset: D50, gamma 2.2, Adobe RGB.""" self.whitepoint_combo.setCurrentIndex(0) # D50 - self.gamma_combo.setCurrentIndex(2) # Power 2.2 - self.gamut_combo.setCurrentIndex(4) # Adobe RGB + self.gamma_combo.setCurrentIndex(2) # Power 2.2 + self.gamut_combo.setCurrentIndex(4) # Adobe RGB self.brightness_spin.setValue(120) def _preset_video(self): """Video production preset: D65, BT.1886, Rec.709.""" self.whitepoint_combo.setCurrentIndex(2) # D65 - self.gamma_combo.setCurrentIndex(1) # BT.1886 - self.gamut_combo.setCurrentIndex(0) # sRGB (Rec.709) + self.gamma_combo.setCurrentIndex(1) # BT.1886 + self.gamut_combo.setCurrentIndex(0) # sRGB (Rec.709) self.brightness_spin.setValue(100) def _preset_web(self): """Web/general preset: D65, sRGB.""" self.whitepoint_combo.setCurrentIndex(2) # D65 - self.gamma_combo.setCurrentIndex(0) # sRGB - self.gamut_combo.setCurrentIndex(0) # sRGB + self.gamma_combo.setCurrentIndex(0) # sRGB + self.gamut_combo.setCurrentIndex(0) # sRGB self.brightness_spin.setValue(120) def _preset_hdr(self): """HDR content preset: D65, PQ, P3.""" self.whitepoint_combo.setCurrentIndex(2) # D65 - self.gamma_combo.setCurrentIndex(0) # sRGB (will be overridden for HDR) - self.gamut_combo.setCurrentIndex(1) # DCI-P3 + self.gamma_combo.setCurrentIndex(0) # sRGB (will be overridden for HDR) + self.gamut_combo.setCurrentIndex(1) # DCI-P3 self.brightness_spin.setValue(200) @@ -522,6 +532,7 @@ def _preset_hdr(self): # Step 3: Calibration Mode # ============================================================================= + class CalibrationModeStep(WizardStep): """Step 3: Choose calibration method.""" @@ -557,15 +568,14 @@ def _setup_ui(self): "Supports all display types", ], CalibrationMode.SENSORLESS, - True # Default selection + True, # Default selection ) layout.addWidget(sensorless_card) # Hardware card hardware_card = self._create_mode_card( "Hardware Calibration", - "Use a colorimeter for maximum accuracy. " - "Supports all major devices via ArgyllCMS.", + "Use a colorimeter for maximum accuracy. Supports all major devices via ArgyllCMS.", [ "Delta E < 0.5 accuracy", "Direct measurement feedback", @@ -573,7 +583,7 @@ def _setup_ui(self): "Custom correction matrices", ], CalibrationMode.HARDWARE, - False + False, ) layout.addWidget(hardware_card) @@ -583,31 +593,35 @@ def _setup_ui(self): hw_layout.addWidget(QLabel("Device:"), 0, 0) self.device_combo = QComboBox() - self.device_combo.addItems([ - "Auto-detect", - "X-Rite i1Display Pro", - "X-Rite i1Display Pro Plus", - "Datacolor Spyder X", - "Calibrite ColorChecker Display", - "Photo Research PR-655", - ]) + self.device_combo.addItems( + [ + "Auto-detect", + "X-Rite i1Display Pro", + "X-Rite i1Display Pro Plus", + "Datacolor Spyder X", + "Calibrite ColorChecker Display", + "Photo Research PR-655", + ] + ) hw_layout.addWidget(self.device_combo, 0, 1) hw_layout.addWidget(QLabel("Correction:"), 1, 0) self.correction_combo = QComboBox() - self.correction_combo.addItems([ - "None", - "CCSS (Spectral)", - "CCMX (Matrix)", - "Custom...", - ]) + self.correction_combo.addItems( + [ + "None", + "CCSS (Spectral)", + "CCMX (Matrix)", + "Custom...", + ] + ) hw_layout.addWidget(self.correction_combo, 1, 1) hw_layout.addWidget(QLabel("Patch Count:"), 2, 0) self.patch_spin = QSpinBox() self.patch_spin.setRange(36, 4096) self.patch_spin.setValue(729) - self.patch_spin.valueChanged.connect(lambda v: setattr(self.config, 'patch_count', v)) + self.patch_spin.valueChanged.connect(lambda v: setattr(self.config, "patch_count", v)) hw_layout.addWidget(self.patch_spin, 2, 1) self.hardware_options.setVisible(False) @@ -615,20 +629,20 @@ def _setup_ui(self): layout.addStretch() - def _create_mode_card(self, title: str, description: str, - features: list[str], mode: CalibrationMode, - checked: bool) -> QFrame: + def _create_mode_card( + self, title: str, description: str, features: list[str], mode: CalibrationMode, checked: bool + ) -> QFrame: """Create a mode selection card.""" card = QFrame() card.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 2px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 2px solid {COLORS["border"]}; border-radius: 12px; padding: 20px; }} QFrame:hover {{ - border-color: {COLORS['accent']}; + border-color: {COLORS["accent"]}; }} """) @@ -682,6 +696,7 @@ def _on_mode_changed(self, mode: CalibrationMode, checked: bool): # Step 4: Measurement Process # ============================================================================= + class MeasurementStep(WizardStep): """Step 4: Perform calibration measurements.""" @@ -709,8 +724,8 @@ def _setup_ui(self): status_frame = QFrame() status_frame.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 12px; padding: 24px; }} @@ -733,14 +748,14 @@ def _setup_ui(self): self.progress.setValue(0) self.progress.setStyleSheet(f""" QProgressBar {{ - background-color: {COLORS['background_alt']}; + background-color: {COLORS["background_alt"]}; border: none; border-radius: 8px; height: 16px; text-align: center; }} QProgressBar::chunk {{ - background-color: {COLORS['accent']}; + background-color: {COLORS["accent"]}; border-radius: 8px; }} """) @@ -752,8 +767,8 @@ def _setup_ui(self): self.measurement_frame = QFrame() self.measurement_frame.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 12px; padding: 24px; }} @@ -765,7 +780,7 @@ def _setup_ui(self): self.color_swatch.setFixedSize(100, 100) self.color_swatch.setStyleSheet(f""" background-color: #808080; - border: 2px solid {COLORS['border']}; + border: 2px solid {COLORS["border"]}; border-radius: 8px; """) measurement_layout.addWidget(self.color_swatch, 0, 0, 2, 1) @@ -812,8 +827,8 @@ def _setup_ui(self): self.log_text.setMaximumHeight(150) self.log_text.setStyleSheet(f""" QTextEdit {{ - background-color: {COLORS['background']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["background"]}; + border: 1px solid {COLORS["border"]}; border-radius: 6px; font-family: monospace; font-size: 11px; @@ -864,11 +879,11 @@ def update_step(): gray = int((self._current_step / (self._total_steps - 1)) * 255) self.color_swatch.setStyleSheet(f""" background-color: rgb({gray}, {gray}, {gray}); - border: 2px solid {COLORS['border']}; + border: 2px solid {COLORS["border"]}; border-radius: 8px; """) self.target_rgb.setText(f"({gray}, {gray}, {gray})") - self.measured_xyz.setText(f"({gray/2.55:.1f}, {gray/2.55:.1f}, {gray/2.55:.1f})") + self.measured_xyz.setText(f"({gray / 2.55:.1f}, {gray / 2.55:.1f}, {gray / 2.55:.1f})") delta = abs(0.5 - self._current_step / self._total_steps) * 0.8 self.delta_e.setText(f"{delta:.2f}") @@ -903,6 +918,7 @@ def _log(self, message: str): # Step 5: Profile Generation # ============================================================================= + class ProfileGenerationStep(WizardStep): """Step 5: Generate and save calibration profile.""" @@ -1010,6 +1026,7 @@ def update_progress(): # Step 6: Verification # ============================================================================= + class VerificationStep(WizardStep): """Step 6: Verify calibration accuracy.""" @@ -1034,8 +1051,8 @@ def _setup_ui(self): results_frame = QFrame() results_frame.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 12px; padding: 24px; }} @@ -1106,11 +1123,13 @@ def _setup_ui(self): def _run_verification(self, mode: str): """Run verification in specified mode.""" from PyQt6.QtWidgets import QMessageBox + QMessageBox.information( - self, "Verification", + self, + "Verification", f"Verification mode '{mode}' will be available in a future update.\n\n" "For now, use the CLI command:\n" - " calibrate-pro verify --patches colorchecker" + " calibrate-pro verify --patches colorchecker", ) @@ -1118,6 +1137,7 @@ def _run_verification(self, mode: str): # Main Wizard Widget # ============================================================================= + class CalibrationWizard(QWidget): """Main calibration wizard container.""" @@ -1137,8 +1157,8 @@ def _setup_ui(self): header = QFrame() header.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border-bottom: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border-bottom: 1px solid {COLORS["border"]}; padding: 16px; }} """) @@ -1152,7 +1172,7 @@ def _setup_ui(self): indicator.setStyleSheet(f""" padding: 8px 16px; border-radius: 16px; - color: {COLORS['text_disabled']}; + color: {COLORS["text_disabled"]}; """) self.step_indicators.append(indicator) header_layout.addWidget(indicator) @@ -1182,8 +1202,8 @@ def _setup_ui(self): footer = QFrame() footer.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border-top: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border-top: 1px solid {COLORS["border"]}; padding: 16px; }} """) @@ -1216,7 +1236,7 @@ def _update_navigation(self): indicator.setStyleSheet(f""" padding: 8px 16px; border-radius: 16px; - background-color: {COLORS['success']}; + background-color: {COLORS["success"]}; color: white; """) elif i == self._current_step: @@ -1224,7 +1244,7 @@ def _update_navigation(self): indicator.setStyleSheet(f""" padding: 8px 16px; border-radius: 16px; - background-color: {COLORS['accent']}; + background-color: {COLORS["accent"]}; color: white; """) else: @@ -1232,7 +1252,7 @@ def _update_navigation(self): indicator.setStyleSheet(f""" padding: 8px 16px; border-radius: 16px; - color: {COLORS['text_disabled']}; + color: {COLORS["text_disabled"]}; """) # Update buttons diff --git a/calibrate_pro/gui/dialogs.py b/calibrate_pro/gui/dialogs.py index 6f33b1f..d37f860 100644 --- a/calibrate_pro/gui/dialogs.py +++ b/calibrate_pro/gui/dialogs.py @@ -27,11 +27,11 @@ # Consent Dialog - Hardware modification warnings + class ConsentDialog(QDialog): """Dialog for obtaining user consent before hardware modifications.""" - def __init__(self, parent=None, display_name: str = "Display", - changes: list = None, risk_level: str = "MEDIUM"): + def __init__(self, parent=None, display_name: str = "Display", changes: list = None, risk_level: str = "MEDIUM"): super().__init__(parent) self.setWindowTitle("Calibration Consent Required") self.setMinimumWidth(500) @@ -51,7 +51,7 @@ def _setup_ui(self, display_name: str, changes: list, risk_level: str): header = QLabel("DISPLAY CALIBRATION") header.setStyleSheet(f""" font-size: 18px; font-weight: 700; - color: {COLORS['warning']}; + color: {COLORS["warning"]}; """) layout.addWidget(header) @@ -61,19 +61,15 @@ def _setup_ui(self, display_name: str, changes: list, risk_level: str): layout.addWidget(display_label) # Risk level indicator - risk_colors = { - "LOW": COLORS['success'], - "MEDIUM": COLORS['warning'], - "HIGH": COLORS['error'] - } - risk_color = risk_colors.get(risk_level, COLORS['warning']) + risk_colors = {"LOW": COLORS["success"], "MEDIUM": COLORS["warning"], "HIGH": COLORS["error"]} + risk_color = risk_colors.get(risk_level, COLORS["warning"]) risk_label = QLabel(f"Risk Level: {risk_level}") risk_label.setStyleSheet(f""" font-size: 13px; font-weight: 600; color: {risk_color}; padding: 4px 8px; - background-color: {COLORS['surface_alt']}; + background-color: {COLORS["surface_alt"]}; border-radius: 4px; """) layout.addWidget(risk_label) @@ -103,8 +99,8 @@ def _setup_ui(self, display_name: str, changes: list, risk_level: str): safety_text.setReadOnly(True) safety_text.setMaximumHeight(150) safety_text.setStyleSheet(f""" - background-color: {COLORS['surface_alt']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface_alt"]}; + border: 1px solid {COLORS["border"]}; border-radius: 4px; padding: 8px; """) @@ -149,6 +145,7 @@ def _on_proceed(self): # Simulated Measurement Window - Hardware colorimeter simulation + class SimulatedMeasurementWindow(QWidget): """ Fullscreen window that simulates hardware colorimeter measurements. @@ -167,18 +164,38 @@ class SimulatedMeasurementWindow(QWidget): # Default measurement sequence - grayscale + primaries + ColorChecker subset DEFAULT_PATCHES = [ # Grayscale ramp - (0, 0, 0), (26, 26, 26), (51, 51, 51), (77, 77, 77), - (102, 102, 102), (128, 128, 128), (153, 153, 153), - (179, 179, 179), (204, 204, 204), (230, 230, 230), (255, 255, 255), + (0, 0, 0), + (26, 26, 26), + (51, 51, 51), + (77, 77, 77), + (102, 102, 102), + (128, 128, 128), + (153, 153, 153), + (179, 179, 179), + (204, 204, 204), + (230, 230, 230), + (255, 255, 255), # Primaries - (255, 0, 0), (0, 255, 0), (0, 0, 255), + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), # Secondaries - (255, 255, 0), (0, 255, 255), (255, 0, 255), + (255, 255, 0), + (0, 255, 255), + (255, 0, 255), # ColorChecker key patches - (115, 82, 68), (194, 150, 130), (98, 122, 157), - (87, 108, 67), (133, 128, 177), (214, 126, 44), - (56, 61, 150), (70, 148, 73), (175, 54, 60), - (231, 199, 31), (187, 86, 149), (8, 133, 161), + (115, 82, 68), + (194, 150, 130), + (98, 122, 157), + (87, 108, 67), + (133, 128, 177), + (214, 126, 44), + (56, 61, 150), + (70, 148, 73), + (175, 54, 60), + (231, 199, 31), + (187, 86, 149), + (8, 133, 161), ] def __init__(self, parent=None, screen: QScreen = None): @@ -305,6 +322,7 @@ def _setup_ui(self): # Keyboard shortcut to cancel from PyQt6.QtGui import QKeySequence, QShortcut + QShortcut(QKeySequence(Qt.Key.Key_Escape), self, self._cancel_measurement) def _draw_crosshair(self): @@ -341,6 +359,7 @@ def _setup_audio(self): self.beep_enabled = True try: import winsound + self.winsound = winsound except ImportError: self.winsound = None @@ -352,11 +371,8 @@ def _play_beep(self, frequency: int = 800, duration: int = 100): try: # Run async to not block UI import threading - threading.Thread( - target=self.winsound.Beep, - args=(frequency, duration), - daemon=True - ).start() + + threading.Thread(target=self.winsound.Beep, args=(frequency, duration), daemon=True).start() except Exception: pass @@ -367,14 +383,17 @@ def _play_measurement_beep(self): def _play_complete_beep(self): """Play completion beep sequence.""" import threading + def beep_sequence(): import time + if self.winsound: self.winsound.Beep(800, 100) time.sleep(0.1) self.winsound.Beep(1000, 100) time.sleep(0.1) self.winsound.Beep(1200, 150) + threading.Thread(target=beep_sequence, daemon=True).start() def set_patches(self, patches: list[tuple]): diff --git a/calibrate_pro/gui/display_selector.py b/calibrate_pro/gui/display_selector.py index 6686295..c306169 100644 --- a/calibrate_pro/gui/display_selector.py +++ b/calibrate_pro/gui/display_selector.py @@ -47,8 +47,10 @@ # Display Information # ============================================================================= + class DisplayTechnology(Enum): """Display panel technology.""" + UNKNOWN = "Unknown" LCD_TN = "LCD (TN)" LCD_IPS = "LCD (IPS)" @@ -61,6 +63,7 @@ class DisplayTechnology(Enum): class CalibrationStatus(Enum): """Display calibration status.""" + UNCALIBRATED = auto() CALIBRATED = auto() NEEDS_RECALIBRATION = auto() @@ -70,6 +73,7 @@ class CalibrationStatus(Enum): @dataclass class DisplayInfo: """Complete display information.""" + # Identification id: int = 0 name: str = "" @@ -120,6 +124,7 @@ def display_name(self) -> str: # Display Monitor Widget # ============================================================================= + class DisplayMonitorWidget(QFrame): """Visual representation of a single display.""" @@ -148,7 +153,7 @@ def _setup_ui(self): self.number_badge.setFixedSize(24, 24) self.number_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) self.number_badge.setStyleSheet(f""" - background-color: {COLORS['accent']}; + background-color: {COLORS["accent"]}; color: white; font-weight: 600; border-radius: 12px; @@ -159,7 +164,7 @@ def _setup_ui(self): if self.display_info.is_primary: primary_label = QLabel("Primary") primary_label.setStyleSheet(f""" - color: {COLORS['success']}; + color: {COLORS["success"]}; font-size: 10px; font-weight: 600; """) @@ -179,15 +184,13 @@ def _setup_ui(self): self.name_label.setStyleSheet(f""" font-weight: 600; font-size: 13px; - color: {COLORS['text_primary']}; + color: {COLORS["text_primary"]}; """) self.name_label.setWordWrap(True) layout.addWidget(self.name_label) # Resolution - self.resolution_label = QLabel( - f"{self.display_info.resolution} @ {self.display_info.refresh_rate:.0f}Hz" - ) + self.resolution_label = QLabel(f"{self.display_info.resolution} @ {self.display_info.refresh_rate:.0f}Hz") self.resolution_label.setStyleSheet(f"color: {COLORS['text_secondary']}; font-size: 11px;") layout.addWidget(self.resolution_label) @@ -209,19 +212,19 @@ def _update_status_indicator(self): status = self.display_info.calibration_status if status == CalibrationStatus.CALIBRATED: text = "✓" - color = COLORS['success'] + color = COLORS["success"] tooltip = f"Calibrated (ΔE: {self.display_info.delta_e:.2f})" elif status == CalibrationStatus.NEEDS_RECALIBRATION: text = "!" - color = COLORS['warning'] + color = COLORS["warning"] tooltip = "Needs recalibration" elif status == CalibrationStatus.CALIBRATING: text = "◐" - color = COLORS['accent'] + color = COLORS["accent"] tooltip = "Calibrating..." else: text = "○" - color = COLORS['text_disabled'] + color = COLORS["text_disabled"] tooltip = "Not calibrated" self.status_indicator.setText(text) @@ -231,14 +234,14 @@ def _update_status_indicator(self): def _update_style(self): """Update widget style based on state.""" if self._selected: - border_color = COLORS['accent'] - bg_color = COLORS['surface_alt'] + border_color = COLORS["accent"] + bg_color = COLORS["surface_alt"] elif self._hovered: - border_color = COLORS['accent'] - bg_color = COLORS['surface'] + border_color = COLORS["accent"] + bg_color = COLORS["surface"] else: - border_color = COLORS['border'] - bg_color = COLORS['surface'] + border_color = COLORS["border"] + bg_color = COLORS["surface"] self.setStyleSheet(f""" DisplayMonitorWidget {{ @@ -282,6 +285,7 @@ def mouseDoubleClickEvent(self, event): # Visual Layout Preview # ============================================================================= + class DisplayLayoutPreview(QGraphicsView): """Visual preview of display arrangement.""" @@ -304,8 +308,8 @@ def _setup_ui(self): self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setStyleSheet(f""" QGraphicsView {{ - background-color: {COLORS['background']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["background"]}; + border: 1px solid {COLORS["border"]}; border-radius: 8px; }} """) @@ -348,23 +352,23 @@ def _rebuild_scene(self): # Create rectangle rect = QGraphicsRectItem(x, y, w, h) - rect.setPen(QPen(QColor(COLORS['border']), 2)) - rect.setBrush(QBrush(QColor(COLORS['surface']))) + rect.setPen(QPen(QColor(COLORS["border"]), 2)) + rect.setBrush(QBrush(QColor(COLORS["surface"]))) rect.setData(0, display.id) self.scene.addItem(rect) self.display_rects[display.id] = rect # Add label label = QGraphicsTextItem(str(display.id + 1)) - label.setDefaultTextColor(QColor(COLORS['text_primary'])) + label.setDefaultTextColor(QColor(COLORS["text_primary"])) font = QFont("Segoe UI", 16, QFont.Weight.Bold) label.setFont(font) - label.setPos(x + w/2 - 8, y + h/2 - 12) + label.setPos(x + w / 2 - 8, y + h / 2 - 12) self.scene.addItem(label) # Add resolution text res_label = QGraphicsTextItem(display.resolution) - res_label.setDefaultTextColor(QColor(COLORS['text_secondary'])) + res_label.setDefaultTextColor(QColor(COLORS["text_secondary"])) res_font = QFont("Segoe UI", 8) res_label.setFont(res_font) res_label.setPos(x + 4, y + h - 16) @@ -378,11 +382,11 @@ def select_display(self, display_id: int): self._selected_id = display_id for did, rect in self.display_rects.items(): if did == display_id: - rect.setPen(QPen(QColor(COLORS['accent']), 3)) - rect.setBrush(QBrush(QColor(COLORS['surface_alt']))) + rect.setPen(QPen(QColor(COLORS["accent"]), 3)) + rect.setBrush(QBrush(QColor(COLORS["surface_alt"]))) else: - rect.setPen(QPen(QColor(COLORS['border']), 2)) - rect.setBrush(QBrush(QColor(COLORS['surface']))) + rect.setPen(QPen(QColor(COLORS["border"]), 2)) + rect.setBrush(QBrush(QColor(COLORS["surface"]))) def mousePressEvent(self, event): pos = self.mapToScene(event.pos()) @@ -409,6 +413,7 @@ def resizeEvent(self, event): # Display Selector Widget # ============================================================================= + class DisplaySelector(QWidget): """Complete display selector with list and visual preview.""" @@ -496,6 +501,7 @@ def refresh_displays(self): # Try to determine aspect ratio from math import gcd + g = gcd(display.width, display.height) display.aspect_ratio = f"{display.width // g}:{display.height // g}" @@ -538,7 +544,7 @@ def _select_display(self, display_id: int): # Update widgets for did, widget in self.display_widgets.items(): - widget.selected = (did == display_id) + widget.selected = did == display_id # Update preview self.layout_preview.select_display(display_id) @@ -567,6 +573,7 @@ def select_by_id(self, display_id: int): # Quick Display Info Panel # ============================================================================= + class DisplayInfoPanel(QWidget): """Detailed information panel for selected display.""" diff --git a/calibrate_pro/gui/hdr_calibration.py b/calibrate_pro/gui/hdr_calibration.py index c709902..d592218 100644 --- a/calibrate_pro/gui/hdr_calibration.py +++ b/calibrate_pro/gui/hdr_calibration.py @@ -32,17 +32,17 @@ def run_as_admin(): try: # Get the Python executable and script path script = os.path.abspath(sys.argv[0]) - params = ' '.join([f'"{arg}"' for arg in sys.argv[1:]]) + params = " ".join([f'"{arg}"' for arg in sys.argv[1:]]) # Use ShellExecuteW to request elevation # verb="runas" triggers UAC prompt result = ctypes.windll.shell32.ShellExecuteW( - None, # hwnd - "runas", # lpOperation (run as admin) - sys.executable, # lpFile (python.exe) + None, # hwnd + "runas", # lpOperation (run as admin) + sys.executable, # lpFile (python.exe) f'"{script}" {params}', # lpParameters - None, # lpDirectory - 1 # nShowCmd (SW_SHOWNORMAL) + None, # lpDirectory + 1, # nShowCmd (SW_SHOWNORMAL) ) # ShellExecuteW returns > 32 on success @@ -54,6 +54,7 @@ def run_as_admin(): print(f"Failed to elevate: {e}") return False + try: from PyQt6.QtCore import Qt, QTimer, pyqtSignal from PyQt6.QtGui import QColor, QPalette @@ -115,8 +116,7 @@ class GainSlider(QWidget): valueChanged = pyqtSignal(float) - def __init__(self, label: str, min_val: float = 0.5, max_val: float = 1.5, - default: float = 1.0, parent=None): + def __init__(self, label: str, min_val: float = 0.5, max_val: float = 1.5, default: float = 1.0, parent=None): super().__init__(parent) self.min_val = min_val self.max_val = max_val @@ -164,8 +164,7 @@ class OffsetSlider(QWidget): valueChanged = pyqtSignal(float) - def __init__(self, label: str, min_val: float = -0.1, max_val: float = 0.1, - default: float = 0.0, parent=None): + def __init__(self, label: str, min_val: float = -0.1, max_val: float = 0.1, default: float = 0.0, parent=None): super().__init__(parent) self.min_val = min_val self.max_val = max_val @@ -449,12 +448,9 @@ def _refresh_monitors(self): monitors = list_monitors() for m in monitors: - hdr_str = " [HDR]" if m['is_hdr'] else "" - primary_str = " (Primary)" if m['is_primary'] else "" - self.monitor_combo.addItem( - f"{m['friendly_name']}{primary_str}{hdr_str} - {m['size'][0]}x{m['size'][1]}", - m - ) + hdr_str = " [HDR]" if m["is_hdr"] else "" + primary_str = " (Primary)" if m["is_primary"] else "" + self.monitor_combo.addItem(f"{m['friendly_name']}{primary_str}{hdr_str} - {m['size'][0]}x{m['size'][1]}", m) self._log(f"Found {len(monitors)} monitors") @@ -463,8 +459,8 @@ def _on_monitor_changed(self, index: int): if index >= 0: data = self.monitor_combo.itemData(index) self.controller.get_monitors() - if data and 'index' in data: - self.current_monitor = self.controller.get_monitor_by_index(data['index']) + if data and "index" in data: + self.current_monitor = self.controller.get_monitor_by_index(data["index"]) self._log(f"Selected: {data['friendly_name']}") def _update_status(self): @@ -509,37 +505,21 @@ def _on_live_toggle(self, checked: bool): def _get_hdr_params(self) -> dict: """Get current HDR calibration parameters.""" return { - 'rgb_gains': ( - self.hdr_gain_r.value(), - self.hdr_gain_g.value(), - self.hdr_gain_b.value() - ), - 'rgb_offsets': ( - self.hdr_offset_r.value(), - self.hdr_offset_g.value(), - self.hdr_offset_b.value() - ), - 'whitepoint': (1.0, 1.0, 1.0), - 'peak_luminance': float(self.peak_luminance.value()), - 'lut_size': int(self.lut_size.currentText()) + "rgb_gains": (self.hdr_gain_r.value(), self.hdr_gain_g.value(), self.hdr_gain_b.value()), + "rgb_offsets": (self.hdr_offset_r.value(), self.hdr_offset_g.value(), self.hdr_offset_b.value()), + "whitepoint": (1.0, 1.0, 1.0), + "peak_luminance": float(self.peak_luminance.value()), + "lut_size": int(self.lut_size.currentText()), } def _get_sdr_params(self) -> dict: """Get current SDR calibration parameters.""" return { - 'rgb_gains': ( - self.sdr_gain_r.value(), - self.sdr_gain_g.value(), - self.sdr_gain_b.value() - ), - 'rgb_offsets': ( - self.sdr_offset_r.value(), - self.sdr_offset_g.value(), - self.sdr_offset_b.value() - ), - 'whitepoint': (1.0, 1.0, 1.0), - 'target_gamma': self.target_gamma.value(), - 'lut_size': int(self.lut_size.currentText()) + "rgb_gains": (self.sdr_gain_r.value(), self.sdr_gain_g.value(), self.sdr_gain_b.value()), + "rgb_offsets": (self.sdr_offset_r.value(), self.sdr_offset_g.value(), self.sdr_offset_b.value()), + "whitepoint": (1.0, 1.0, 1.0), + "target_gamma": self.target_gamma.value(), + "lut_size": int(self.lut_size.currentText()), } def _apply_lut(self): @@ -555,33 +535,28 @@ def _apply_lut(self): if is_hdr: params = self._get_hdr_params() lut = generate_hdr_calibration_lut( - size=params['lut_size'], - rgb_gains=params['rgb_gains'], - rgb_offsets=params['rgb_offsets'], - target_whitepoint=params['whitepoint'], - peak_luminance=params['peak_luminance'] + size=params["lut_size"], + rgb_gains=params["rgb_gains"], + rgb_offsets=params["rgb_offsets"], + target_whitepoint=params["whitepoint"], + peak_luminance=params["peak_luminance"], ) lut_type = LUTType.HDR title = f"HDR Calibration - Peak {params['peak_luminance']} nits" else: params = self._get_sdr_params() lut = generate_sdr_calibration_lut( - size=params['lut_size'], - target_gamma=params['target_gamma'], - rgb_gains=params['rgb_gains'], - rgb_offsets=params['rgb_offsets'], - target_whitepoint=params['whitepoint'] + size=params["lut_size"], + target_gamma=params["target_gamma"], + rgb_gains=params["rgb_gains"], + rgb_offsets=params["rgb_offsets"], + target_whitepoint=params["whitepoint"], ) lut_type = LUTType.SDR title = f"SDR Calibration - Gamma {params['target_gamma']}" # Apply LUT - success = self.controller.load_lut( - self.current_monitor, - lut, - lut_type, - title - ) + success = self.controller.load_lut(self.current_monitor, lut, lut_type, title) if success: self._log(f"Applied {lut_type.value.upper()} LUT: {params['lut_size']}³") @@ -658,7 +633,7 @@ def _start_dwm_lut(self): "DwmLutGUI.exe was not found.\n\n" "Please download dwm_lut from:\n" "https://github.com/ledoge/dwm_lut/releases\n\n" - "And extract it to the calibrate/dwm_lut folder." + "And extract it to the calibrate/dwm_lut folder.", ) return @@ -678,7 +653,7 @@ def _start_dwm_lut(self): "DwmLutGUI requires administrator privileges.\n\n" f"Please manually run as Administrator:\n" f"{self.controller.dwm_lut_exe}\n\n" - "After starting DwmLutGUI, click 'Apply LUT' to apply your calibration." + "After starting DwmLutGUI, click 'Apply LUT' to apply your calibration.", ) # Update status after delay diff --git a/calibrate_pro/gui/icons.py b/calibrate_pro/gui/icons.py index d53cae6..fcffbe5 100644 --- a/calibrate_pro/gui/icons.py +++ b/calibrate_pro/gui/icons.py @@ -20,7 +20,7 @@ class IconFactory: def create_icon(draw_func, size: int = 24, color: str = None) -> QIcon: """Create an icon from a drawing function.""" if color is None: - color = COLORS['text_primary'] + color = COLORS["text_primary"] pixmap = QPixmap(size, size) pixmap.fill(Qt.GlobalColor.transparent) @@ -39,7 +39,7 @@ def create_icon(draw_func, size: int = 24, color: str = None) -> QIcon: def dashboard(painter: QPainter, size: int, color: str): """Dashboard/home icon - grid of squares.""" m = size * 0.2 # margin - s = (size - 2*m - 2) / 2 # square size + s = (size - 2 * m - 2) / 2 # square size painter.setBrush(QBrush(QColor(color))) painter.drawRoundedRect(int(m), int(m), int(s), int(s), 2, 2) painter.drawRoundedRect(int(m + s + 2), int(m), int(s), int(s), 2, 2) @@ -85,7 +85,7 @@ def profiles(painter: QPainter, size: int, color: str): # Back document painter.drawRoundedRect(int(m + 4), int(m), int(w), int(h), 2, 2) # Front document - painter.setBrush(QBrush(QColor(COLORS['surface']))) + painter.setBrush(QBrush(QColor(COLORS["surface"]))) painter.drawRoundedRect(int(m), int(m + 4), int(w), int(h), 2, 2) # Lines on front doc painter.drawLine(int(m + 6), int(m + 14), int(m + w - 6), int(m + 14)) @@ -114,7 +114,7 @@ def settings(painter: QPainter, size: int, color: str): painter.setBrush(QBrush(QColor(color))) painter.drawPath(path) # Inner circle (hole) - painter.setBrush(QBrush(QColor(COLORS['background']))) + painter.setBrush(QBrush(QColor(COLORS["background"]))) painter.drawEllipse(QPoint(int(c), int(c)), int(inner_r), int(inner_r)) @staticmethod @@ -137,13 +137,13 @@ def vcgt_tools(painter: QPainter, size: int, color: str): t = i / steps x = margin + t * graph_size # Simulate gamma curve (power function) - y = size - margin - (t ** 2.2) * graph_size + y = size - margin - (t**2.2) * graph_size if i == 0: path.moveTo(x, y) else: path.lineTo(x, y) - painter.setPen(QPen(QColor(COLORS['accent']), 2)) + painter.setPen(QPen(QColor(COLORS["accent"]), 2)) painter.drawPath(path) @staticmethod @@ -160,8 +160,8 @@ def app_icon(size: int = 64) -> QIcon: # Outer ring gradient gradient = QLinearGradient(0, 0, size, size) - gradient.setColorAt(0, QColor(COLORS['accent'])) - gradient.setColorAt(1, QColor(COLORS['success'])) + gradient.setColorAt(0, QColor(COLORS["accent"])) + gradient.setColorAt(1, QColor(COLORS["success"])) painter.setPen(QPen(QBrush(gradient), size * 0.08)) painter.setBrush(Qt.BrushStyle.NoBrush) @@ -169,7 +169,7 @@ def app_icon(size: int = 64) -> QIcon: # Inner colored segments (RGB) inner_r = size * 0.25 - colors = [COLORS['error'], COLORS['success'], COLORS['accent']] + colors = [COLORS["error"], COLORS["success"], COLORS["accent"]] for i, color in enumerate(colors): angle = i * 2 * math.pi / 3 - math.pi / 2 x = c + inner_r * 0.4 * math.cos(angle) @@ -192,7 +192,7 @@ def tray_icon_active(size: int = 32) -> QIcon: # Green circle with check c = size / 2 - painter.setBrush(QBrush(QColor(COLORS['success']))) + painter.setBrush(QBrush(QColor(COLORS["success"]))) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(QPoint(int(c), int(c)), int(size * 0.4), int(size * 0.4)) @@ -218,7 +218,7 @@ def tray_icon_inactive(size: int = 32) -> QIcon: # Gray circle c = size / 2 - painter.setBrush(QBrush(QColor(COLORS['text_disabled']))) + painter.setBrush(QBrush(QColor(COLORS["text_disabled"]))) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(QPoint(int(c), int(c)), int(size * 0.4), int(size * 0.4)) diff --git a/calibrate_pro/gui/lut_preview.py b/calibrate_pro/gui/lut_preview.py index 20b958b..14c5152 100644 --- a/calibrate_pro/gui/lut_preview.py +++ b/calibrate_pro/gui/lut_preview.py @@ -47,14 +47,16 @@ # 3D LUT Data Structure # ============================================================================= + @dataclass class LUT3D: """3D Color Lookup Table.""" + size: int # Cube size (e.g., 17, 33, 65) data: np.ndarray # Shape: (size, size, size, 3) for RGB @classmethod - def identity(cls, size: int = 33) -> 'LUT3D': + def identity(cls, size: int = 33) -> "LUT3D": """Create an identity LUT (no color change).""" data = np.zeros((size, size, size, 3), dtype=np.float32) for r in range(size): @@ -64,7 +66,7 @@ def identity(cls, size: int = 33) -> 'LUT3D': return cls(size=size, data=data) @classmethod - def from_cube_file(cls, path: str) -> 'LUT3D': + def from_cube_file(cls, path: str) -> "LUT3D": """Load from .cube file.""" with open(path) as f: lines = f.readlines() @@ -74,11 +76,11 @@ def from_cube_file(cls, path: str) -> 'LUT3D': for line in lines: line = line.strip() - if line.startswith('#') or not line: + if line.startswith("#") or not line: continue - if line.startswith('LUT_3D_SIZE'): + if line.startswith("LUT_3D_SIZE"): size = int(line.split()[1]) - elif line.startswith('TITLE') or line.startswith('DOMAIN'): + elif line.startswith("TITLE") or line.startswith("DOMAIN"): continue else: # Data line @@ -145,11 +147,11 @@ def get_deviation_stats(self) -> dict: b_diff = np.mean(np.abs(self.data[:, :, :, 2] - identity.data[:, :, :, 2])) return { - 'max_deviation': max_diff, - 'avg_deviation': avg_diff, - 'r_deviation': r_diff, - 'g_deviation': g_diff, - 'b_deviation': b_diff, + "max_deviation": max_diff, + "avg_deviation": avg_diff, + "r_deviation": r_diff, + "g_deviation": g_diff, + "b_deviation": b_diff, } @@ -157,6 +159,7 @@ def get_deviation_stats(self) -> dict: # 3D Cube Preview Widget # ============================================================================= + class LUTCubeView(QWidget): """3D rotating cube view of LUT.""" @@ -237,10 +240,10 @@ def paintEvent(self, event): painter.setRenderHint(QPainter.RenderHint.Antialiasing) # Background - painter.fillRect(self.rect(), QColor(COLORS['background'])) + painter.fillRect(self.rect(), QColor(COLORS["background"])) if self.lut is None: - painter.setPen(QColor(COLORS['text_secondary'])) + painter.setPen(QColor(COLORS["text_secondary"])) painter.setFont(QFont("Segoe UI", 12)) painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No LUT loaded") return @@ -258,12 +261,18 @@ def paintEvent(self, event): def _draw_wireframe(self, painter: QPainter): """Draw cube wireframe.""" - painter.setPen(QPen(QColor(COLORS['border']), 1)) + painter.setPen(QPen(QColor(COLORS["border"]), 1)) # Cube vertices (-1 to 1 normalized) vertices = [ - (-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1), - (-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1), + (-1, -1, -1), + (1, -1, -1), + (1, 1, -1), + (-1, 1, -1), + (-1, -1, 1), + (1, -1, 1), + (1, 1, 1), + (-1, 1, 1), ] # Project vertices @@ -271,9 +280,18 @@ def _draw_wireframe(self, painter: QPainter): # Draw edges edges = [ - (0, 1), (1, 2), (2, 3), (3, 0), # Front - (4, 5), (5, 6), (6, 7), (7, 4), # Back - (0, 4), (1, 5), (2, 6), (3, 7), # Connecting + (0, 1), + (1, 2), + (2, 3), + (3, 0), # Front + (4, 5), + (5, 6), + (6, 7), + (7, 4), # Back + (0, 4), + (1, 5), + (2, 6), + (3, 7), # Connecting ] for i, j in edges: @@ -317,11 +335,7 @@ def _draw_lut_points(self, painter: QPainter): pos = self._project_point(x, y, z) # Color from LUT output - color = QColor( - int(out_r * 255), - int(out_g * 255), - int(out_b * 255) - ) + color = QColor(int(out_r * 255), int(out_g * 255), int(out_b * 255)) # Size based on depth size = max(2, int(4 * (1 + depth) / 2)) @@ -336,17 +350,17 @@ def _draw_axes_labels(self, painter: QPainter): # R axis (pointing right) r_pos = self._project_point(1.2, 0, 0) - painter.setPen(QColor(COLORS['red'])) + painter.setPen(QColor(COLORS["red"])) painter.drawText(r_pos, "R") # G axis (pointing up) g_pos = self._project_point(0, 1.2, 0) - painter.setPen(QColor(COLORS['green'])) + painter.setPen(QColor(COLORS["green"])) painter.drawText(g_pos, "G") # B axis (pointing forward) b_pos = self._project_point(0, 0, 1.2) - painter.setPen(QColor(COLORS['blue'])) + painter.setPen(QColor(COLORS["blue"])) painter.drawText(b_pos, "B") def mousePressEvent(self, event): @@ -378,6 +392,7 @@ def wheelEvent(self, event): # Slice View Widget # ============================================================================= + class LUTSliceView(QWidget): """2D slice view of LUT (R, G, or B plane).""" @@ -390,7 +405,7 @@ def __init__(self, parent: QWidget | None = None): self.lut: LUT3D | None = None # Slice settings - self.slice_axis = 'B' # R, G, or B + self.slice_axis = "B" # R, G, or B self.slice_position = 0.5 # 0.0 to 1.0 # Pre-rendered image @@ -428,11 +443,11 @@ def _render_slice(self): v = 1.0 - y / (size - 1) # Flip Y # Get LUT indices based on slice axis - if self.slice_axis == 'R': + if self.slice_axis == "R": r_idx = slice_idx g_idx = int(v * (lut_size - 1)) b_idx = int(u * (lut_size - 1)) - elif self.slice_axis == 'G': + elif self.slice_axis == "G": r_idx = int(v * (lut_size - 1)) g_idx = slice_idx b_idx = int(u * (lut_size - 1)) @@ -442,17 +457,9 @@ def _render_slice(self): b_idx = slice_idx # Get color from LUT - r, g, b = self.lut.data[ - min(r_idx, lut_size - 1), - min(g_idx, lut_size - 1), - min(b_idx, lut_size - 1) - ] - - color = QColor( - int(np.clip(r, 0, 1) * 255), - int(np.clip(g, 0, 1) * 255), - int(np.clip(b, 0, 1) * 255) - ) + r, g, b = self.lut.data[min(r_idx, lut_size - 1), min(g_idx, lut_size - 1), min(b_idx, lut_size - 1)] + + color = QColor(int(np.clip(r, 0, 1) * 255), int(np.clip(g, 0, 1) * 255), int(np.clip(b, 0, 1) * 255)) image.setPixelColor(x, y, color) self._slice_image = image @@ -462,14 +469,15 @@ def paintEvent(self, event): painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) # Background - painter.fillRect(self.rect(), QColor(COLORS['background'])) + painter.fillRect(self.rect(), QColor(COLORS["background"])) if self._slice_image: # Scale image to widget size scaled = self._slice_image.scaled( - self.width(), self.height(), + self.width(), + self.height(), Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation + Qt.TransformationMode.SmoothTransformation, ) # Center the image @@ -478,10 +486,10 @@ def paintEvent(self, event): painter.drawImage(x, y, scaled) # Draw border - painter.setPen(QPen(QColor(COLORS['border']), 1)) + painter.setPen(QPen(QColor(COLORS["border"]), 1)) painter.drawRect(x, y, scaled.width() - 1, scaled.height() - 1) else: - painter.setPen(QColor(COLORS['text_secondary'])) + painter.setPen(QColor(COLORS["text_secondary"])) painter.setFont(QFont("Segoe UI", 10)) painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No LUT loaded") @@ -489,15 +497,15 @@ def paintEvent(self, event): if self._slice_image: painter.setFont(QFont("Segoe UI", 9)) - if self.slice_axis == 'R': - h_label, v_label = 'B', 'G' - elif self.slice_axis == 'G': - h_label, v_label = 'B', 'R' + if self.slice_axis == "R": + h_label, v_label = "B", "G" + elif self.slice_axis == "G": + h_label, v_label = "B", "R" else: - h_label, v_label = 'G', 'R' + h_label, v_label = "G", "R" # Horizontal axis - painter.setPen(QColor(COLORS['text_secondary'])) + painter.setPen(QColor(COLORS["text_secondary"])) painter.drawText(self.width() // 2 - 5, self.height() - 5, h_label) # Vertical axis @@ -512,6 +520,7 @@ def paintEvent(self, event): # Before/After Comparison # ============================================================================= + class BeforeAfterView(QWidget): """Side-by-side or split comparison view.""" @@ -566,11 +575,7 @@ def apply_lut(self, lut: LUT3D): out_r, out_g, out_b = lut.lookup(r, g, b) - processed.setPixelColor(x, y, QColor( - int(out_r * 255), - int(out_g * 255), - int(out_b * 255) - )) + processed.setPixelColor(x, y, QColor(int(out_r * 255), int(out_g * 255), int(out_b * 255))) self._processed_image = processed self.update() @@ -580,10 +585,10 @@ def paintEvent(self, event): painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) # Background - painter.fillRect(self.rect(), QColor(COLORS['background'])) + painter.fillRect(self.rect(), QColor(COLORS["background"])) if not self._source_image or not self._processed_image: - painter.setPen(QColor(COLORS['text_secondary'])) + painter.setPen(QColor(COLORS["text_secondary"])) painter.setFont(QFont("Segoe UI", 10)) painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "Load image to compare") return @@ -593,14 +598,16 @@ def paintEvent(self, event): # Scale images to widget size source_scaled = self._source_image.scaled( - self.width(), self.height(), + self.width(), + self.height(), Qt.AspectRatioMode.IgnoreAspectRatio, - Qt.TransformationMode.SmoothTransformation + Qt.TransformationMode.SmoothTransformation, ) processed_scaled = self._processed_image.scaled( - self.width(), self.height(), + self.width(), + self.height(), Qt.AspectRatioMode.IgnoreAspectRatio, - Qt.TransformationMode.SmoothTransformation + Qt.TransformationMode.SmoothTransformation, ) # Draw left side (original) @@ -642,6 +649,7 @@ def mouseMoveEvent(self, event): # Main LUT Preview Widget # ============================================================================= + class LUTPreviewWidget(QWidget): """Complete LUT preview with multiple views.""" @@ -660,17 +668,17 @@ def _setup_ui(self): self.tabs = QTabWidget() self.tabs.setStyleSheet(f""" QTabWidget::pane {{ - border: 1px solid {COLORS['border']}; - background-color: {COLORS['background']}; + border: 1px solid {COLORS["border"]}; + background-color: {COLORS["background"]}; }} QTabBar::tab {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; padding: 8px 16px; margin-right: 2px; }} QTabBar::tab:selected {{ - background-color: {COLORS['accent']}; + background-color: {COLORS["accent"]}; color: white; }} """) @@ -721,8 +729,8 @@ def _setup_ui(self): self.stats_panel = QFrame() self.stats_panel.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; padding: 8px; }} """) diff --git a/calibrate_pro/gui/main_window.py b/calibrate_pro/gui/main_window.py index 16b3c63..b380832 100644 --- a/calibrate_pro/gui/main_window.py +++ b/calibrate_pro/gui/main_window.py @@ -165,8 +165,8 @@ def _setup_toolbar(self): self.toolbar_cm_status = QLabel() self.toolbar_cm_status.setStyleSheet(f""" QLabel {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 4px; padding: 4px 8px; font-size: 11px; @@ -254,7 +254,7 @@ def _update_tray_icon(self): self.tray_icon.setIcon(IconFactory.tray_icon_inactive()) def _update_tray_status(self): - if hasattr(self, 'tray_status_action'): + if hasattr(self, "tray_status_action"): if self.cm_status.is_active(): self.tray_status_action.setText(f"Active: {self.cm_status.get_status_text()}") self.tray_lut_action.setText("Disable LUT") @@ -265,15 +265,15 @@ def _update_tray_status(self): def _update_toolbar_cm_status(self): if self.cm_status.is_active(): status_text = self.cm_status.get_status_text() - color = COLORS['success'] + color = COLORS["success"] else: status_text = "No CM active" - color = COLORS['text_disabled'] + color = COLORS["text_disabled"] self.toolbar_cm_status.setText(f" {status_text}") self.toolbar_cm_status.setStyleSheet(f""" QLabel {{ - background-color: {COLORS['surface']}; + background-color: {COLORS["surface"]}; border: 1px solid {color}; border-radius: 4px; padding: 4px 8px; @@ -282,7 +282,7 @@ def _update_toolbar_cm_status(self): }} """) - if hasattr(self, 'statusbar_cm_indicator'): + if hasattr(self, "statusbar_cm_indicator"): self.statusbar_cm_indicator.setText(status_text) def _load_color_management_state(self): @@ -308,10 +308,10 @@ def _load_color_management_state(self): def _refresh_all_cm_displays(self): self._update_toolbar_cm_status() - if hasattr(self, 'tray_icon'): + if hasattr(self, "tray_icon"): self._update_tray_icon() self._update_tray_status() - if hasattr(self, 'dashboard_page'): + if hasattr(self, "dashboard_page"): self.dashboard_page.update_cm_status() def _show_from_tray(self): @@ -348,7 +348,7 @@ def _quit_application(self): self.settings.setValue("geometry", self.saveGeometry()) - if hasattr(self, 'tray_icon'): + if hasattr(self, "tray_icon"): self.tray_icon.hide() QApplication.quit() @@ -361,20 +361,19 @@ def _restore_geometry(self): screen = QGuiApplication.primaryScreen() if screen: sg = screen.availableGeometry() - self.move(sg.x() + (sg.width() - self.width()) // 2, - sg.y() + (sg.height() - self.height()) // 2) + self.move(sg.x() + (sg.width() - self.width()) // 2, sg.y() + (sg.height() - self.height()) // 2) def closeEvent(self, event): minimize_to_tray = self.settings.value("general/minimize_to_tray", True, type=bool) - if minimize_to_tray and hasattr(self, 'tray_icon') and self.tray_icon.isVisible(): + if minimize_to_tray and hasattr(self, "tray_icon") and self.tray_icon.isVisible(): event.ignore() self.hide() self.tray_icon.showMessage( APP_NAME, "Running in background. Right-click tray icon for options.", QSystemTrayIcon.MessageIcon.Information, - 2000 + 2000, ) else: self._quit_application() @@ -385,7 +384,9 @@ def _detect_displays(self): if displays: primary = displays[0] g = primary.geometry() - self.display_indicator.setText(f"{primary.name()} - {g.width()}x{g.height()} @ {primary.refreshRate():.0f}Hz") + self.display_indicator.setText( + f"{primary.name()} - {g.width()}x{g.height()} @ {primary.refreshRate():.0f}Hz" + ) self.status_label.setText(f"Detected {len(displays)} display(s)") def _new_calibration(self): @@ -393,15 +394,18 @@ def _new_calibration(self): def _open_profile(self): path, _ = QFileDialog.getOpenFileName( - self, "Open Profile", "", - "Calibration Files (*.icc *.cube *.3dlut);;ICC Profiles (*.icc);;3D LUTs (*.cube *.3dlut);;All Files (*)") + self, + "Open Profile", + "", + "Calibration Files (*.icc *.cube *.3dlut);;ICC Profiles (*.icc);;3D LUTs (*.cube *.3dlut);;All Files (*)", + ) if path: self.status_label.setText(f"Loaded: {path}") def _save_profile(self): path, _ = QFileDialog.getSaveFileName( - self, "Save Profile", "", - "ICC Profile (*.icc);;3D LUT (*.cube);;All Files (*)") + self, "Save Profile", "", "ICC Profile (*.icc);;3D LUT (*.cube);;All Files (*)" + ) if path: self.status_label.setText(f"Saved: {path}") @@ -414,17 +418,16 @@ def _export_lut(self, fmt): "mpv": "mpv Config (*.conf)", "obs": "OBS LUT (*.cube)", } - path, _ = QFileDialog.getSaveFileName( - self, f"Export {fmt.upper()}", "", extensions.get(fmt, "All Files (*)")) + path, _ = QFileDialog.getSaveFileName(self, f"Export {fmt.upper()}", "", extensions.get(fmt, "All Files (*)")) if path: self.status_label.setText(f"Exported: {path}") def _install_profile(self): - path, _ = QFileDialog.getOpenFileName( - self, "Install ICC Profile", "", "ICC Profiles (*.icc *.icm)") + path, _ = QFileDialog.getOpenFileName(self, "Install ICC Profile", "", "ICC Profiles (*.icc *.icm)") if path: try: from calibrate_pro.panels.detection import install_profile + install_profile(path) self.status_label.setText(f"Installed: {path}") QMessageBox.information(self, "Profile Installed", f"ICC profile installed:\n{path}") @@ -435,6 +438,7 @@ def _reset_gamma(self): try: from calibrate_pro.lut_system.dwm_lut import remove_lut from calibrate_pro.panels.detection import enumerate_displays, reset_gamma_ramp + displays = enumerate_displays() for i, d in enumerate(displays): reset_gamma_ramp(d.device_name) @@ -451,12 +455,15 @@ def _reset_gamma(self): def _show_test_patterns(self): try: from calibrate_pro.patterns.display import show_patterns + show_patterns() except (ImportError, OSError) as e: QMessageBox.warning(self, "Test Patterns", str(e)) def _show_about(self): - QMessageBox.about(self, f"About {APP_NAME}", + QMessageBox.about( + self, + f"About {APP_NAME}", f"

{APP_NAME}

" f"

Version {APP_VERSION}

" f"

Professional display calibration suite with:

" @@ -466,7 +473,8 @@ def _show_about(self): f"
  • System-wide 3D LUT (dwm_lut)
  • " f"
  • Full HDR calibration suite
  • " f"" - f"

    2024 {APP_ORGANIZATION}

    ") + f"

    2024 {APP_ORGANIZATION}

    ", + ) def run_application(): diff --git a/calibrate_pro/gui/measurement_view.py b/calibrate_pro/gui/measurement_view.py index 0004780..56861f5 100644 --- a/calibrate_pro/gui/measurement_view.py +++ b/calibrate_pro/gui/measurement_view.py @@ -48,9 +48,11 @@ # Measurement Data # ============================================================================= + @dataclass class Measurement: """A single calibration measurement.""" + index: int timestamp: datetime = field(default_factory=datetime.now) @@ -75,6 +77,7 @@ class Measurement: # Live Color Patch Display # ============================================================================= + class ColorPatchDisplay(QFrame): """Large color patch showing current target and measured colors.""" @@ -83,8 +86,8 @@ def __init__(self, parent: QWidget | None = None): self.setMinimumSize(200, 200) self.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 8px; }} """) @@ -136,6 +139,7 @@ def paintEvent(self, event): # Delta E Display # ============================================================================= + class DeltaEDisplay(QFrame): """Large Delta E value display with color coding.""" @@ -144,8 +148,8 @@ def __init__(self, parent: QWidget | None = None): self.setMinimumSize(150, 100) self.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 8px; }} """) @@ -178,19 +182,19 @@ def set_value(self, delta_e: float): # Color code if delta_e < 1.0: - color = COLORS['success'] + color = COLORS["success"] quality = "Imperceptible" elif delta_e < 2.0: color = "#8bc34a" quality = "Excellent" elif delta_e < 3.0: - color = COLORS['warning'] + color = COLORS["warning"] quality = "Good" elif delta_e < 5.0: color = "#ff5722" quality = "Acceptable" else: - color = COLORS['error'] + color = COLORS["error"] quality = "Poor" self.value_label.setText(f"{delta_e:.3f}") @@ -203,6 +207,7 @@ def set_value(self, delta_e: float): # Values Panel # ============================================================================= + class ValuesPanel(QFrame): """Display panel for color values (XYZ, Lab, xy).""" @@ -210,8 +215,8 @@ def __init__(self, title: str = "Values", parent: QWidget | None = None): super().__init__(parent) self.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 8px; padding: 12px; }} @@ -275,6 +280,7 @@ def set_lab(self, L: float, a: float, b: float): # Measurement History Table # ============================================================================= + class MeasurementHistoryTable(QWidget): """Table showing measurement history.""" @@ -296,20 +302,20 @@ def _setup_ui(self): self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) self.table.setStyleSheet(f""" QTableWidget {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; - gridline-color: {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; + gridline-color: {COLORS["border"]}; }} QTableWidget::item {{ padding: 4px; }} QTableWidget::item:selected {{ - background-color: {COLORS['accent']}; + background-color: {COLORS["accent"]}; }} QHeaderView::section {{ - background-color: {COLORS['surface_alt']}; + background-color: {COLORS["surface_alt"]}; border: none; - border-bottom: 1px solid {COLORS['border']}; + border-bottom: 1px solid {COLORS["border"]}; padding: 8px; }} """) @@ -345,11 +351,11 @@ def add_measurement(self, measurement: Measurement): de_item = QTableWidgetItem(f"{de:.3f}") if de < 1.0: - de_item.setForeground(QBrush(QColor(COLORS['success']))) + de_item.setForeground(QBrush(QColor(COLORS["success"]))) elif de < 3.0: - de_item.setForeground(QBrush(QColor(COLORS['warning']))) + de_item.setForeground(QBrush(QColor(COLORS["warning"]))) else: - de_item.setForeground(QBrush(QColor(COLORS['error']))) + de_item.setForeground(QBrush(QColor(COLORS["error"]))) self.table.setItem(row, 4, de_item) @@ -371,6 +377,7 @@ def _on_selection_changed(self): # Main Measurement View # ============================================================================= + class MeasurementView(QWidget): """Complete measurement view for calibration.""" @@ -401,8 +408,8 @@ def _setup_ui(self): progress_frame = QFrame() progress_frame.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 8px; padding: 12px; }} @@ -416,13 +423,13 @@ def _setup_ui(self): self.progress_bar = QProgressBar() self.progress_bar.setStyleSheet(f""" QProgressBar {{ - background-color: {COLORS['background']}; + background-color: {COLORS["background"]}; border: none; border-radius: 4px; height: 8px; }} QProgressBar::chunk {{ - background-color: {COLORS['accent']}; + background-color: {COLORS["accent"]}; border-radius: 4px; }} """) @@ -460,8 +467,8 @@ def _setup_ui(self): stats_frame = QFrame() stats_frame.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 8px; padding: 12px; }} @@ -493,15 +500,13 @@ def start_measurement_sequence(self, total_patches: int): self.progress_bar.setValue(0) self._update_progress_label(total_patches) - def set_target(self, rgb: tuple[int, int, int], xyz: tuple[float, float, float], - lab: tuple[float, float, float]): + def set_target(self, rgb: tuple[int, int, int], xyz: tuple[float, float, float], lab: tuple[float, float, float]): """Set current target values.""" self.color_patch.set_target(*rgb) self.target_values.set_xyz(*xyz) self.target_values.set_lab(*lab) - def set_measured(self, xyz: tuple[float, float, float], lab: tuple[float, float, float], - delta_e: float): + def set_measured(self, xyz: tuple[float, float, float], lab: tuple[float, float, float], delta_e: float): """Set measured values and Delta E.""" # Convert XYZ to RGB for display (simplified) r = int(min(255, max(0, xyz[0] * 2.55))) diff --git a/calibrate_pro/gui/pages/calibrate.py b/calibrate_pro/gui/pages/calibrate.py index b001ee7..10086c3 100644 --- a/calibrate_pro/gui/pages/calibrate.py +++ b/calibrate_pro/gui/pages/calibrate.py @@ -32,6 +32,7 @@ # Worker Thread + class CalibrationWorker(QThread): """Runs AutoCalibrationEngine.run_calibration() off the main thread. @@ -39,9 +40,9 @@ class CalibrationWorker(QThread): then runs the selected calibration mode. """ - progress = pyqtSignal(str, float, str) # message, 0-1, step name - finished = pyqtSignal(bool, str) # success, result message - log_line = pyqtSignal(str) # individual log lines + progress = pyqtSignal(str, float, str) # message, 0-1, step name + finished = pyqtSignal(bool, str) # success, result message + log_line = pyqtSignal(str) # individual log lines def __init__( self, @@ -86,7 +87,7 @@ def _run_ddc_setup(self): db = PanelDatabase() panel_key = identify_display(displays[self.display_index]) panel = db.get_panel(panel_key) if panel_key else None - if panel and hasattr(panel, 'ddc') and panel.ddc: + if panel and hasattr(panel, "ddc") and panel.ddc: ddc_rec = panel.ddc self.log_line.emit(f" Panel: {panel.name}") except Exception: @@ -94,7 +95,8 @@ def _run_ddc_setup(self): # Apply auto-setup changes = ddc.auto_setup_for_calibration( - monitor, ddc_recommendations=ddc_rec, + monitor, + ddc_recommendations=ddc_rec, log_fn=lambda msg: self.log_line.emit(f" DDC: {msg}"), ) @@ -104,6 +106,7 @@ def _run_ddc_setup(self): self.log_line.emit(" DDC: No settings applied (monitor may not support DDC/CI)") import time + time.sleep(1.0) # Let monitor settle after DDC changes except Exception as e: @@ -205,6 +208,7 @@ def _display_and_wait(self, r, g, b, settle=1.2): """Signal the main thread to show a patch, then wait for settle time.""" self.show_patch.emit(r, g, b) import time + time.sleep(settle) # Wait for OLED settle + signal propagation def run(self): @@ -216,14 +220,17 @@ def run(self): import hid import numpy as np - OLED_MATRIX = np.array([ - [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], - [-0.00004481, 0.00035042, 0.08032401], - ]) + OLED_MATRIX = np.array( + [ + [0.03836831, -0.02175997, 0.01696057], + [0.01449629, 0.01611903, 0.00057150], + [-0.00004481, 0.00035042, 0.08032401], + ] + ) # Apply CCMX spectral correction for QD-OLED from calibrate_pro.calibration.native_loop import QDOLED_CCMX + SENSOR_MATRIX = QDOLED_CCMX @ OLED_MATRIX M_MASK = 0xFFFFFFFF @@ -236,46 +243,67 @@ def run(self): device.open(0x0765, 0x5020) # Unlock (NEC OEM key) - k0, k1 = 0xa9119479, 0x5b168761 - cmd = bytearray(65); cmd[0] = 0; cmd[1] = 0x99 - device.write(cmd); time.sleep(0.2) + k0, k1 = 0xA9119479, 0x5B168761 + cmd = bytearray(65) + cmd[0] = 0 + cmd[1] = 0x99 + device.write(cmd) + time.sleep(0.2) c = bytes(device.read(64, timeout_ms=3000)) sc = bytearray(8) - for i in range(8): sc[i] = c[3] ^ c[35 + i] - ci0 = (sc[3]<<24)+(sc[0]<<16)+(sc[4]<<8)+sc[6] - ci1 = (sc[1]<<24)+(sc[7]<<16)+(sc[2]<<8)+sc[5] + for i in range(8): + sc[i] = c[3] ^ c[35 + i] + ci0 = (sc[3] << 24) + (sc[0] << 16) + (sc[4] << 8) + sc[6] + ci1 = (sc[1] << 24) + (sc[7] << 16) + (sc[2] << 8) + sc[5] nk0, nk1 = (-k0) & M_MASK, (-k1) & M_MASK - co = [(nk0-ci1)&M_MASK, (nk1-ci0)&M_MASK, (ci1*nk0)&M_MASK, (ci0*nk1)&M_MASK] + co = [(nk0 - ci1) & M_MASK, (nk1 - ci0) & M_MASK, (ci1 * nk0) & M_MASK, (ci0 * nk1) & M_MASK] s = sum(sc) - for sh in [0, 8, 16, 24]: s += (nk0>>sh)&0xFF; s += (nk1>>sh)&0xFF + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF s0, s1 = s & 0xFF, (s >> 8) & 0xFF sr = bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF; sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF; sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF; sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF; sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF; sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF; sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF; sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF; sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb = bytearray(65); rb[0] = 0; rb[1] = 0x9A - for i in range(16): rb[25+i] = c[2] ^ sr[i] - device.write(rb); time.sleep(0.3); device.read(64, timeout_ms=3000) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) self.log_line.emit("Sensor unlocked. CCMX spectral correction applied.") def measure_xyz_fn(r, g, b): intclks = int(1.0 * 12000000) - cmd2 = bytearray(65); cmd2[0] = 0x00; cmd2[1] = 0x01 - struct.pack_into(' 0.3: return SENSOR_MATRIX @ freq return None @@ -322,6 +350,7 @@ def progress_cb(msg, frac): self.progress.emit("Applying LUT...", 0.90, "Applying") try: from calibrate_pro.lut_system.dwm_lut import DwmLutController + dwm = DwmLutController() if dwm.is_available: dwm.load_lut_file(0, lut_path) @@ -337,8 +366,7 @@ def progress_cb(msg, frac): self.show_patch.emit(-1.0, -1.0, -1.0) # Signal to close patch window self.progress.emit("Complete!", 1.0, "Complete") - self.finished.emit(True, - f"Native calibration complete.\nLUT saved to {lut_path}") + self.finished.emit(True, f"Native calibration complete.\nLUT saved to {lut_path}") except Exception as exc: self.show_patch.emit(-1.0, -1.0, -1.0) # Close patch window on error @@ -362,6 +390,7 @@ def __init__(self, display_index: int = 0, parent=None): def _display_and_wait(self, r, g, b, settle=1.2): self.show_patch.emit(r, g, b) import time + time.sleep(settle) def run(self): @@ -372,13 +401,16 @@ def run(self): import hid import numpy as np - OLED_MATRIX = np.array([ - [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], - [-0.00004481, 0.00035042, 0.08032401], - ]) + OLED_MATRIX = np.array( + [ + [0.03836831, -0.02175997, 0.01696057], + [0.01449629, 0.01611903, 0.00057150], + [-0.00004481, 0.00035042, 0.08032401], + ] + ) from calibrate_pro.calibration.native_loop import QDOLED_CCMX + SENSOR = QDOLED_CCMX @ OLED_MATRIX M_MASK = 0xFFFFFFFF @@ -389,45 +421,66 @@ def run(self): device.open(0x0765, 0x5020) # Unlock - k0, k1 = 0xa9119479, 0x5b168761 - cmd = bytearray(65); cmd[0] = 0; cmd[1] = 0x99 - device.write(cmd); time.sleep(0.2) + k0, k1 = 0xA9119479, 0x5B168761 + cmd = bytearray(65) + cmd[0] = 0 + cmd[1] = 0x99 + device.write(cmd) + time.sleep(0.2) c = bytes(device.read(64, timeout_ms=3000)) sc = bytearray(8) - for i in range(8): sc[i] = c[3] ^ c[35+i] - ci0=(sc[3]<<24)+(sc[0]<<16)+(sc[4]<<8)+sc[6] - ci1=(sc[1]<<24)+(sc[7]<<16)+(sc[2]<<8)+sc[5] - nk0,nk1=(-k0)&M_MASK,(-k1)&M_MASK - co=[(nk0-ci1)&M_MASK,(nk1-ci0)&M_MASK,(ci1*nk0)&M_MASK,(ci0*nk1)&M_MASK] - s=sum(sc) - for sh in [0,8,16,24]: s+=(nk0>>sh)&0xFF; s+=(nk1>>sh)&0xFF - s0,s1=s&0xFF,(s>>8)&0xFF - sr=bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF;sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF;sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF;sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF;sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF;sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF;sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF;sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF;sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb=bytearray(65);rb[0]=0;rb[1]=0x9A - for i in range(16): rb[25+i]=c[2]^sr[i] - device.write(rb);time.sleep(0.3);device.read(64,timeout_ms=3000) + for i in range(8): + sc[i] = c[3] ^ c[35 + i] + ci0 = (sc[3] << 24) + (sc[0] << 16) + (sc[4] << 8) + sc[6] + ci1 = (sc[1] << 24) + (sc[7] << 16) + (sc[2] << 8) + sc[5] + nk0, nk1 = (-k0) & M_MASK, (-k1) & M_MASK + co = [(nk0 - ci1) & M_MASK, (nk1 - ci0) & M_MASK, (ci1 * nk0) & M_MASK, (ci0 * nk1) & M_MASK] + s = sum(sc) + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF + s0, s1 = s & 0xFF, (s >> 8) & 0xFF + sr = bytearray(16) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) self.log_line.emit("Sensor unlocked.") def measure(): intclks = int(1.0 * 12000000) - cmd2 = bytearray(65); cmd2[0] = 0x00; cmd2[1] = 0x01 - struct.pack_into(' 0.3: return SENSOR @ freq return None @@ -462,6 +515,7 @@ def progress_cb(msg, frac): # Mode Card + class ModeCard(Card): """Selectable mode card with icon area, title, and subtitle.""" @@ -489,27 +543,20 @@ def __init__( # Icon placeholder icon_label = QLabel(icon_text) icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - icon_label.setStyleSheet( - f"font-size: 24px; color: {C.ACCENT_TX if enabled else C.TEXT3};" - ) + icon_label.setStyleSheet(f"font-size: 24px; color: {C.ACCENT_TX if enabled else C.TEXT3};") layout.addWidget(icon_label) # Title title_label = QLabel(title) title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - title_label.setStyleSheet( - f"font-size: 13px; font-weight: 600; " - f"color: {C.TEXT if enabled else C.TEXT3};" - ) + title_label.setStyleSheet(f"font-size: 13px; font-weight: 600; color: {C.TEXT if enabled else C.TEXT3};") layout.addWidget(title_label) # Subtitle sub_label = QLabel(subtitle) sub_label.setAlignment(Qt.AlignmentFlag.AlignCenter) sub_label.setWordWrap(True) - sub_label.setStyleSheet( - f"font-size: 11px; color: {C.TEXT2 if enabled else C.TEXT3};" - ) + sub_label.setStyleSheet(f"font-size: 11px; color: {C.TEXT2 if enabled else C.TEXT3};") layout.addWidget(sub_label) self._apply_style() @@ -561,6 +608,7 @@ def mousePressEvent(self, event): # Calibrate Page + class CalibratePage(QWidget): """Full calibration workflow page.""" @@ -629,9 +677,11 @@ def _build(self): hw_icon = QLabel("\u2699") hw_icon.setStyleSheet(f"font-size: 18px; color: {C.GREEN};") hw_lay.addWidget(hw_icon) - hw_text = QLabel("Hardware calibration runs first in every mode. " - "DDC/CI adjusts brightness, contrast, and RGB gains " - "before software profiling begins.") + hw_text = QLabel( + "Hardware calibration runs first in every mode. " + "DDC/CI adjusts brightness, contrast, and RGB gains " + "before software profiling begins." + ) hw_text.setWordWrap(True) hw_text.setStyleSheet(f"font-size: 11px; color: {C.TEXT2};") hw_lay.addWidget(hw_text, stretch=1) @@ -643,15 +693,21 @@ def _build(self): mode_row.setSpacing(12) self._mode_sensorless = ModeCard( - "Sensorless", "Panel database, instant", "\u2588\u2588", + "Sensorless", + "Panel database, instant", + "\u2588\u2588", enabled=True, ) self._mode_measured = ModeCard( - "Measured", "Colorimeter profiling", "\u25c9", + "Measured", + "Colorimeter profiling", + "\u25c9", enabled=False, ) self._mode_hybrid = ModeCard( - "Hybrid", "Database + refinement", "\u2588\u25c9", + "Hybrid", + "Database + refinement", + "\u2588\u25c9", enabled=False, ) self._mode_cards = [self._mode_sensorless, self._mode_measured, self._mode_hybrid] @@ -796,9 +852,7 @@ def _build(self): btn = QPushButton(label) btn.setStyleSheet(preset_btn_style) btn.setFixedHeight(28) - btn.clicked.connect( - lambda checked, g=gamut, w=wp, gm=gamma, h=hdr: self._apply_preset(g, w, gm, h) - ) + btn.clicked.connect(lambda checked, g=gamut, w=wp, gm=gamma, h=hdr: self._apply_preset(g, w, gm, h)) preset_row.addWidget(btn) preset_row.addStretch() @@ -899,6 +953,7 @@ def _detect_environment(self): try: sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent)) from calibrate_pro.panels.detection import enumerate_displays, get_display_name + self._displays = enumerate_displays() for i, d in enumerate(self._displays): name = get_display_name(d) @@ -912,6 +967,7 @@ def _detect_environment(self): # Sensor detection try: from calibrate_pro.hardware.i1d3_native import I1D3Driver + devices = I1D3Driver.find_devices() self._sensor_detected = bool(devices) except Exception: @@ -920,15 +976,13 @@ def _detect_environment(self): # Enable/disable measured modes self._mode_measured._enabled = self._sensor_detected self._mode_measured.setCursor( - Qt.CursorShape.PointingHandCursor if self._sensor_detected - else Qt.CursorShape.ForbiddenCursor + Qt.CursorShape.PointingHandCursor if self._sensor_detected else Qt.CursorShape.ForbiddenCursor ) self._mode_measured._apply_style() self._mode_hybrid._enabled = self._sensor_detected self._mode_hybrid.setCursor( - Qt.CursorShape.PointingHandCursor if self._sensor_detected - else Qt.CursorShape.ForbiddenCursor + Qt.CursorShape.PointingHandCursor if self._sensor_detected else Qt.CursorShape.ForbiddenCursor ) self._mode_hybrid._apply_style() @@ -1028,20 +1082,19 @@ def _show_measurement_patch(self, r: float, g: float, b: float): """Display a fullscreen color patch for measurement (main thread).""" # Negative values = close the patch window if r < 0: - if hasattr(self, '_patch_window') and self._patch_window: + if hasattr(self, "_patch_window") and self._patch_window: self._patch_window.close() self._patch_window = None return # Create the patch window on first call - if not hasattr(self, '_patch_window') or self._patch_window is None: + if not hasattr(self, "_patch_window") or self._patch_window is None: from PyQt6.QtCore import Qt as QtConst from PyQt6.QtWidgets import QWidget self._patch_window = QWidget() self._patch_window.setWindowFlags( - QtConst.WindowType.FramelessWindowHint | - QtConst.WindowType.WindowStaysOnTopHint + QtConst.WindowType.FramelessWindowHint | QtConst.WindowType.WindowStaysOnTopHint ) self._patch_window.setCursor(QtConst.CursorShape.BlankCursor) @@ -1067,9 +1120,7 @@ def _show_measurement_patch(self, r: float, g: float, b: float): ri = max(0, min(255, int(r * 255 + 0.5))) gi = max(0, min(255, int(g * 255 + 0.5))) bi = max(0, min(255, int(b * 255 + 0.5))) - self._patch_window.setStyleSheet( - f"background-color: #{ri:02x}{gi:02x}{bi:02x};" - ) + self._patch_window.setStyleSheet(f"background-color: #{ri:02x}{gi:02x}{bi:02x};") self._patch_window.update() def _on_progress(self, message: str, value: float, step_name: str): @@ -1088,17 +1139,13 @@ def _on_finished(self, success: bool, message: str): if success: self._btn_calibrate.setText("Calibrate Display") self._step_label.setText("Complete") - self._step_label.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.GREEN_HI};" - ) + self._step_label.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.GREEN_HI};") self._progress_bar.setValue(1000) self.calibration_completed.emit() else: self._btn_calibrate.setText("Calibrate Display") self._step_label.setText("Failed") - self._step_label.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.RED};" - ) + self._step_label.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.RED};") self._show_error(message) self._on_log(message) diff --git a/calibrate_pro/gui/pages/calibration_page.py b/calibrate_pro/gui/pages/calibration_page.py index 7b79666..da32986 100644 --- a/calibrate_pro/gui/pages/calibration_page.py +++ b/calibrate_pro/gui/pages/calibration_page.py @@ -86,14 +86,16 @@ def _setup_ui(self): profile_layout = QFormLayout(profile_group) self.profile_combo = QComboBox() - self.profile_combo.addItems([ - "sRGB Web Standard", - "Rec.709 Broadcast", - "DCI-P3 Cinema", - "HDR10 Mastering", - "Photography (Adobe RGB)", - "Custom..." - ]) + self.profile_combo.addItems( + [ + "sRGB Web Standard", + "Rec.709 Broadcast", + "DCI-P3 Cinema", + "HDR10 Mastering", + "Photography (Adobe RGB)", + "Custom...", + ] + ) profile_layout.addRow("Preset:", self.profile_combo) settings_layout.addWidget(profile_group) @@ -175,8 +177,10 @@ def _setup_ui(self): self.hardware_first_radio.setChecked(True) mode_layout.addWidget(self.hardware_first_radio) - hw_first_desc = QLabel("Step 1: Adjust monitor OSD settings (RGB gain, gamma)\n" - "Step 2: Fine-tune with 3D LUT. Best quality, Delta E < 0.5") + hw_first_desc = QLabel( + "Step 1: Adjust monitor OSD settings (RGB gain, gamma)\n" + "Step 2: Fine-tune with 3D LUT. Best quality, Delta E < 0.5" + ) hw_first_desc.setStyleSheet(f"color: {COLORS['text_secondary']}; font-size: 11px; margin-left: 24px;") hw_first_desc.setWordWrap(True) mode_layout.addWidget(hw_first_desc) @@ -222,9 +226,9 @@ def _setup_ui(self): gamut_preview = QFrame() gamut_preview.setMinimumHeight(200) gamut_preview.setStyleSheet(f""" - background-color: {COLORS['surface_alt']}; + background-color: {COLORS["surface_alt"]}; border-radius: 8px; - border: 1px solid {COLORS['border']}; + border: 1px solid {COLORS["border"]}; """) # Add gamut info labels @@ -315,10 +319,7 @@ def _start_calibration(self): # Show consent dialog dialog = ConsentDialog( - self, - display_name=display_name, - changes=changes, - risk_level="MEDIUM" if not use_hardware_first else "HIGH" + self, display_name=display_name, changes=changes, risk_level="MEDIUM" if not use_hardware_first else "HIGH" ) if dialog.exec() != QDialog.DialogCode.Accepted: @@ -371,7 +372,7 @@ def _on_measurement_complete(self): display_index=self._current_display_index, apply_ddc=self._apply_ddc, profile_name=profile_name, - display_name=display_name + display_name=display_name, ) self._worker.progress.connect(self._on_calibration_progress) self._worker.finished.connect(self._on_calibration_finished) @@ -380,7 +381,7 @@ def _on_measurement_complete(self): def _on_measurement_closed(self): """Called when measurement window is closed (possibly cancelled).""" - if not hasattr(self, '_worker') or self._worker is None or not self._worker.isRunning(): + if not hasattr(self, "_worker") or self._worker is None or not self._worker.isRunning(): # Measurement was cancelled before calibration started self.start_btn.setEnabled(True) self.cancel_btn.setEnabled(False) @@ -404,13 +405,13 @@ def _on_calibration_finished(self, result): ) # Mark display as calibrated in settings - display_index = getattr(self, '_current_display_index', 0) + display_index = getattr(self, "_current_display_index", 0) DashboardPage.mark_display_calibrated(display_index, result.delta_e_predicted) # Try to refresh the dashboard if we can find it try: main_window = self.window() - if hasattr(main_window, '_pages'): + if hasattr(main_window, "_pages"): for page in main_window._pages.values(): if isinstance(page, DashboardPage): page.refresh_displays() @@ -424,7 +425,7 @@ def _on_calibration_finished(self, result): msg.setIcon(QMessageBox.Icon.Information) # Determine grade and confidence - grade = result.verification.get('grade', 'Unknown') + grade = result.verification.get("grade", "Unknown") delta_e = result.delta_e_predicted msg.setText( @@ -489,11 +490,12 @@ def _check_ddc_support(self): """Check DDC/CI support for the selected display.""" try: from calibrate_pro.hardware.ddc_ci import DDCCIController + controller = DDCCIController() if controller.available: monitors = controller.enumerate_monitors() if monitors: - caps = monitors[0].get('capabilities') + caps = monitors[0].get("capabilities") if caps and caps.has_rgb_gain: self.ddc_status.setText("DDC/CI: RGB gain control available") self.ddc_status.setStyleSheet(f"color: {COLORS['success']}; font-size: 11px; margin-top: 8px;") @@ -551,7 +553,7 @@ def _populate_displays(self): def _on_display_changed(self, index: int): """Handle display selection change.""" - if not hasattr(self, '_displays') or index >= len(self._displays): + if not hasattr(self, "_displays") or index >= len(self._displays): return display = self._displays[index] diff --git a/calibrate_pro/gui/pages/color_control_page.py b/calibrate_pro/gui/pages/color_control_page.py index b79ece7..733fdae 100644 --- a/calibrate_pro/gui/pages/color_control_page.py +++ b/calibrate_pro/gui/pages/color_control_page.py @@ -46,13 +46,13 @@ def __init__(self, parent=None): self.displays = [] # Current adjustment values - self._brightness = 1.0 # 0.5 to 2.0 (1.0 = normal) - self._contrast = 1.0 # 0.5 to 2.0 (1.0 = normal) - self._gamma = 2.2 # 1.0 to 3.0 (2.2 = sRGB) - self._red_gain = 1.0 # 0.5 to 1.5 (1.0 = normal) + self._brightness = 1.0 # 0.5 to 2.0 (1.0 = normal) + self._contrast = 1.0 # 0.5 to 2.0 (1.0 = normal) + self._gamma = 2.2 # 1.0 to 3.0 (2.2 = sRGB) + self._red_gain = 1.0 # 0.5 to 1.5 (1.0 = normal) self._green_gain = 1.0 self._blue_gain = 1.0 - self._black_level = 0.0 # 0.0 to 0.1 (lift shadows) + self._black_level = 0.0 # 0.0 to 0.1 (lift shadows) self._updating = False self._setup_ui() @@ -83,8 +83,7 @@ def _setup_ui(self): ) info_label.setWordWrap(True) info_label.setStyleSheet( - f"color: {COLORS['success']}; padding: 12px; " - f"background-color: rgba(100,255,100,0.1); border-radius: 6px;" + f"color: {COLORS['success']}; padding: 12px; background-color: rgba(100,255,100,0.1); border-radius: 6px;" ) layout.addWidget(info_label) @@ -120,31 +119,24 @@ def _setup_ui(self): bc_layout.addWidget(bc_info) self.brightness_slider = self._create_float_slider( - "Brightness", 0.5, 2.0, 1.0, - "Increases/decreases overall luminance (1.0 = no change)" + "Brightness", 0.5, 2.0, 1.0, "Increases/decreases overall luminance (1.0 = no change)" ) - bc_layout.addLayout(self.brightness_slider['layout']) - self.brightness_slider['slider'].valueChanged.connect( - lambda v: self._on_slider_changed('brightness', v / 100.0) + bc_layout.addLayout(self.brightness_slider["layout"]) + self.brightness_slider["slider"].valueChanged.connect( + lambda v: self._on_slider_changed("brightness", v / 100.0) ) self.contrast_slider = self._create_float_slider( - "Contrast", 0.5, 2.0, 1.0, - "Adjusts the difference between dark and light (1.0 = no change)" - ) - bc_layout.addLayout(self.contrast_slider['layout']) - self.contrast_slider['slider'].valueChanged.connect( - lambda v: self._on_slider_changed('contrast', v / 100.0) + "Contrast", 0.5, 2.0, 1.0, "Adjusts the difference between dark and light (1.0 = no change)" ) + bc_layout.addLayout(self.contrast_slider["layout"]) + self.contrast_slider["slider"].valueChanged.connect(lambda v: self._on_slider_changed("contrast", v / 100.0)) self.gamma_slider = self._create_float_slider( - "Gamma", 1.0, 3.0, 2.2, - "Display gamma curve (2.2 = sRGB, 2.4 = BT.1886)" - ) - bc_layout.addLayout(self.gamma_slider['layout']) - self.gamma_slider['slider'].valueChanged.connect( - lambda v: self._on_slider_changed('gamma', v / 100.0) + "Gamma", 1.0, 3.0, 2.2, "Display gamma curve (2.2 = sRGB, 2.4 = BT.1886)" ) + bc_layout.addLayout(self.gamma_slider["layout"]) + self.gamma_slider["slider"].valueChanged.connect(lambda v: self._on_slider_changed("gamma", v / 100.0)) scroll_layout.addWidget(bc_group) @@ -162,34 +154,24 @@ def _setup_ui(self): rgb_layout.addWidget(rgb_info) self.red_gain_slider = self._create_float_slider( - "Red", 0.5, 1.5, 1.0, - "Red channel gain (1.0 = no change)", - value_color="#ff6b6b" - ) - rgb_layout.addLayout(self.red_gain_slider['layout']) - self.red_gain_slider['slider'].valueChanged.connect( - lambda v: self._on_slider_changed('red_gain', v / 100.0) + "Red", 0.5, 1.5, 1.0, "Red channel gain (1.0 = no change)", value_color="#ff6b6b" ) + rgb_layout.addLayout(self.red_gain_slider["layout"]) + self.red_gain_slider["slider"].valueChanged.connect(lambda v: self._on_slider_changed("red_gain", v / 100.0)) self.green_gain_slider = self._create_float_slider( - "Green", 0.5, 1.5, 1.0, - "Green channel gain (1.0 = no change)", - value_color="#69db7c" + "Green", 0.5, 1.5, 1.0, "Green channel gain (1.0 = no change)", value_color="#69db7c" ) - rgb_layout.addLayout(self.green_gain_slider['layout']) - self.green_gain_slider['slider'].valueChanged.connect( - lambda v: self._on_slider_changed('green_gain', v / 100.0) + rgb_layout.addLayout(self.green_gain_slider["layout"]) + self.green_gain_slider["slider"].valueChanged.connect( + lambda v: self._on_slider_changed("green_gain", v / 100.0) ) self.blue_gain_slider = self._create_float_slider( - "Blue", 0.5, 1.5, 1.0, - "Blue channel gain (1.0 = no change)", - value_color="#74c0fc" - ) - rgb_layout.addLayout(self.blue_gain_slider['layout']) - self.blue_gain_slider['slider'].valueChanged.connect( - lambda v: self._on_slider_changed('blue_gain', v / 100.0) + "Blue", 0.5, 1.5, 1.0, "Blue channel gain (1.0 = no change)", value_color="#74c0fc" ) + rgb_layout.addLayout(self.blue_gain_slider["layout"]) + self.blue_gain_slider["slider"].valueChanged.connect(lambda v: self._on_slider_changed("blue_gain", v / 100.0)) scroll_layout.addWidget(rgb_group) @@ -198,12 +180,11 @@ def _setup_ui(self): shadow_layout = QVBoxLayout(shadow_group) self.black_level_slider = self._create_float_slider( - "Black Level", 0.0, 0.15, 0.0, - "Lifts shadow detail (0.0 = true black)" + "Black Level", 0.0, 0.15, 0.0, "Lifts shadow detail (0.0 = true black)" ) - shadow_layout.addLayout(self.black_level_slider['layout']) - self.black_level_slider['slider'].valueChanged.connect( - lambda v: self._on_slider_changed('black_level', v / 1000.0) + shadow_layout.addLayout(self.black_level_slider["layout"]) + self.black_level_slider["slider"].valueChanged.connect( + lambda v: self._on_slider_changed("black_level", v / 1000.0) ) scroll_layout.addWidget(shadow_group) @@ -261,8 +242,9 @@ def _setup_ui(self): self.status_label.setStyleSheet(f"color: {COLORS['text_secondary']}; padding: 8px;") layout.addWidget(self.status_label) - def _create_float_slider(self, label: str, min_val: float, max_val: float, - default: float, tooltip: str, value_color: str = None) -> dict: + def _create_float_slider( + self, label: str, min_val: float, max_val: float, default: float, tooltip: str, value_color: str = None + ) -> dict: """Create a slider for float values.""" layout = QHBoxLayout() layout.setSpacing(12) @@ -278,7 +260,7 @@ def _create_float_slider(self, label: str, min_val: float, max_val: float, slider.setToolTip(tooltip) layout.addWidget(slider, stretch=1) - color = value_color or COLORS['text_primary'] + color = value_color or COLORS["text_primary"] value_lbl = QLabel(f"{default:.2f}") value_lbl.setMinimumWidth(50) value_lbl.setAlignment(Qt.AlignmentFlag.AlignRight) @@ -290,12 +272,13 @@ def update_label(val): slider.valueChanged.connect(update_label) - return {'layout': layout, 'slider': slider, 'value_label': value_lbl} + return {"layout": layout, "slider": slider, "value_label": value_lbl} def _initialize(self): """Initialize color loader.""" try: from calibrate_pro.lut_system.color_loader import ColorLoader + self.color_loader = ColorLoader() self._refresh_displays() self.status_label.setText("\u2713 Ready - Adjust sliders and click Apply") @@ -313,7 +296,7 @@ def _refresh_displays(self): self.displays = self.color_loader.enumerate_displays() for _i, d in enumerate(self.displays): - primary = " (Primary)" if d.get('primary') else "" + primary = " (Primary)" if d.get("primary") else "" self.display_combo.addItem(f"{d.get('monitor', 'Display')} - {d.get('adapter', 'GPU')}{primary}") if self.displays: @@ -329,7 +312,7 @@ def _on_slider_changed(self, param: str, value: float): if self._updating: return - setattr(self, f'_{param}', value) + setattr(self, f"_{param}", value) # Auto-apply on slider change for immediate feedback self._apply_settings() @@ -373,12 +356,7 @@ def _apply_settings(self): ramp[i, c] = int(v * 65535) # Apply to display - success = self.color_loader.set_gamma_ramp( - self.current_display, - ramp[:, 0], - ramp[:, 1], - ramp[:, 2] - ) + success = self.color_loader.set_gamma_ramp(self.current_display, ramp[:, 0], ramp[:, 1], ramp[:, 2]) if success: self.status_label.setText( @@ -406,13 +384,13 @@ def _reset_to_default(self): self._blue_gain = 1.0 self._black_level = 0.0 - self.brightness_slider['slider'].setValue(100) - self.contrast_slider['slider'].setValue(100) - self.gamma_slider['slider'].setValue(220) - self.red_gain_slider['slider'].setValue(100) - self.green_gain_slider['slider'].setValue(100) - self.blue_gain_slider['slider'].setValue(100) - self.black_level_slider['slider'].setValue(0) + self.brightness_slider["slider"].setValue(100) + self.contrast_slider["slider"].setValue(100) + self.gamma_slider["slider"].setValue(220) + self.red_gain_slider["slider"].setValue(100) + self.green_gain_slider["slider"].setValue(100) + self.blue_gain_slider["slider"].setValue(100) + self.black_level_slider["slider"].setValue(0) self._updating = False @@ -494,38 +472,35 @@ def _preset_cool(self): def _update_sliders_from_values(self): """Update slider positions from current values.""" - self.brightness_slider['slider'].setValue(int(self._brightness * 100)) - self.contrast_slider['slider'].setValue(int(self._contrast * 100)) - self.gamma_slider['slider'].setValue(int(self._gamma * 100)) - self.red_gain_slider['slider'].setValue(int(self._red_gain * 100)) - self.green_gain_slider['slider'].setValue(int(self._green_gain * 100)) - self.blue_gain_slider['slider'].setValue(int(self._blue_gain * 100)) - self.black_level_slider['slider'].setValue(int(self._black_level * 1000)) + self.brightness_slider["slider"].setValue(int(self._brightness * 100)) + self.contrast_slider["slider"].setValue(int(self._contrast * 100)) + self.gamma_slider["slider"].setValue(int(self._gamma * 100)) + self.red_gain_slider["slider"].setValue(int(self._red_gain * 100)) + self.green_gain_slider["slider"].setValue(int(self._green_gain * 100)) + self.blue_gain_slider["slider"].setValue(int(self._blue_gain * 100)) + self.black_level_slider["slider"].setValue(int(self._black_level * 1000)) def _save_profile(self): """Save current settings as a profile.""" import json - filename, _ = QFileDialog.getSaveFileName( - self, "Save Color Profile", - "", "Color Profile (*.json)" - ) + filename, _ = QFileDialog.getSaveFileName(self, "Save Color Profile", "", "Color Profile (*.json)") if filename: - if not filename.endswith('.json'): - filename += '.json' + if not filename.endswith(".json"): + filename += ".json" profile = { - 'brightness': self._brightness, - 'contrast': self._contrast, - 'gamma': self._gamma, - 'red_gain': self._red_gain, - 'green_gain': self._green_gain, - 'blue_gain': self._blue_gain, - 'black_level': self._black_level, + "brightness": self._brightness, + "contrast": self._contrast, + "gamma": self._gamma, + "red_gain": self._red_gain, + "green_gain": self._green_gain, + "blue_gain": self._blue_gain, + "black_level": self._black_level, } - with open(filename, 'w') as f: + with open(filename, "w") as f: json.dump(profile, f, indent=2) self.status_label.setText(f"\u2713 Saved profile: {filename}") @@ -534,23 +509,20 @@ def _load_profile(self): """Load settings from a profile.""" import json - filename, _ = QFileDialog.getOpenFileName( - self, "Load Color Profile", - "", "Color Profile (*.json)" - ) + filename, _ = QFileDialog.getOpenFileName(self, "Load Color Profile", "", "Color Profile (*.json)") if filename: with open(filename) as f: profile = json.load(f) self._updating = True - self._brightness = profile.get('brightness', 1.0) - self._contrast = profile.get('contrast', 1.0) - self._gamma = profile.get('gamma', 2.2) - self._red_gain = profile.get('red_gain', 1.0) - self._green_gain = profile.get('green_gain', 1.0) - self._blue_gain = profile.get('blue_gain', 1.0) - self._black_level = profile.get('black_level', 0.0) + self._brightness = profile.get("brightness", 1.0) + self._contrast = profile.get("contrast", 1.0) + self._gamma = profile.get("gamma", 2.2) + self._red_gain = profile.get("red_gain", 1.0) + self._green_gain = profile.get("green_gain", 1.0) + self._blue_gain = profile.get("blue_gain", 1.0) + self._black_level = profile.get("black_level", 0.0) self._update_sliders_from_values() self._updating = False diff --git a/calibrate_pro/gui/pages/dashboard_page.py b/calibrate_pro/gui/pages/dashboard_page.py index d1b0a3c..115895d 100644 --- a/calibrate_pro/gui/pages/dashboard_page.py +++ b/calibrate_pro/gui/pages/dashboard_page.py @@ -2,7 +2,6 @@ Dashboard Page - Connected displays and calibration status overview. """ - from PyQt6.QtCore import QSettings, Qt from PyQt6.QtGui import QGuiApplication, QScreen from PyQt6.QtWidgets import ( @@ -77,10 +76,10 @@ def _setup_ui(self): stats_layout = QGridLayout(stats_group) stats_layout.setSpacing(12) - self.avg_delta_e = self._create_stat_widget("Avg Delta E", "0.65", COLORS['success']) - self.max_delta_e = self._create_stat_widget("Max Delta E", "2.93", COLORS['warning']) - self.profiles_count = self._create_stat_widget("ICC Profiles", "3", COLORS['accent']) - self.luts_count = self._create_stat_widget("3D LUTs", "2", COLORS['accent']) + self.avg_delta_e = self._create_stat_widget("Avg Delta E", "0.65", COLORS["success"]) + self.max_delta_e = self._create_stat_widget("Max Delta E", "2.93", COLORS["warning"]) + self.profiles_count = self._create_stat_widget("ICC Profiles", "3", COLORS["accent"]) + self.luts_count = self._create_stat_widget("3D LUTs", "2", COLORS["accent"]) stats_layout.addWidget(self.avg_delta_e, 0, 0) stats_layout.addWidget(self.max_delta_e, 0, 1) @@ -131,8 +130,8 @@ def _create_cm_status_card(self) -> QFrame: card = QFrame() card.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 8px; padding: 8px; }} @@ -164,9 +163,9 @@ def _create_cm_status_card(self) -> QFrame: def _update_cm_indicator(self): """Update the color management status indicator.""" if self.cm_status.is_active(): - color = COLORS['success'] + color = COLORS["success"] else: - color = COLORS['text_disabled'] + color = COLORS["text_disabled"] self.cm_indicator.setStyleSheet(f"background-color: {color}; border-radius: 6px;") def update_cm_status(self): @@ -196,13 +195,14 @@ def _create_stat_widget(self, label: str, value: str, color: str) -> QFrame: return frame - def _create_display_card(self, name: str, resolution: str, panel_type: str, - delta_e: float, calibrated: bool) -> QFrame: + def _create_display_card( + self, name: str, resolution: str, panel_type: str, delta_e: float, calibrated: bool + ) -> QFrame: card = QFrame() card.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 10px; }} """) @@ -214,7 +214,7 @@ def _create_display_card(self, name: str, resolution: str, panel_type: str, # Display icon/indicator indicator = QLabel() indicator.setFixedSize(48, 48) - color = COLORS['success'] if calibrated else COLORS['text_disabled'] + color = COLORS["success"] if calibrated else COLORS["text_disabled"] indicator.setStyleSheet(f""" background-color: {color}; border-radius: 8px; @@ -240,7 +240,7 @@ def _create_display_card(self, name: str, resolution: str, panel_type: str, # Delta E display if calibrated: - de_color = COLORS['success'] if delta_e < 1 else COLORS['warning'] if delta_e < 2 else COLORS['error'] + de_color = COLORS["success"] if delta_e < 1 else COLORS["warning"] if delta_e < 2 else COLORS["error"] de_frame = QFrame() de_frame.setStyleSheet(f"background-color: {COLORS['surface_alt']}; border-radius: 6px;") de_layout = QVBoxLayout(de_frame) @@ -284,19 +284,18 @@ def _populate_demo_data(self): try: from calibrate_pro.lut_system.per_display_calibration import PerDisplayCalibrationManager from calibrate_pro.panels.database import PanelDatabase + manager = PerDisplayCalibrationManager() db = PanelDatabase() for profile_data in manager.list_displays(): - display_id = profile_data['id'] + display_id = profile_data["id"] profile = manager.get_display_profile(display_id) - panel = db.get_panel(profile_data.get('database_match', '')) if profile_data.get('database_match') else None + panel = ( + db.get_panel(profile_data.get("database_match", "")) if profile_data.get("database_match") else None + ) - calibration_profiles[display_id] = { - 'profile': profile, - 'panel': panel, - 'data': profile_data - } + calibration_profiles[display_id] = {"profile": profile, "panel": panel, "data": profile_data} except Exception: pass @@ -307,13 +306,13 @@ def _populate_demo_data(self): geometry = screen.geometry() refresh = screen.refreshRate() screen.name() or f"Display {i + 1}" - is_primary = (screen == QGuiApplication.primaryScreen()) + is_primary = screen == QGuiApplication.primaryScreen() # Check for real calibration data cal_data = calibration_profiles.get(display_id, {}) - profile = cal_data.get('profile') - panel = cal_data.get('panel') - profile_data = cal_data.get('data', {}) + profile = cal_data.get("profile") + panel = cal_data.get("panel") + profile_data = cal_data.get("data", {}) # Build display name with manufacturer display_name = f"Display {display_id}" @@ -328,8 +327,8 @@ def _populate_demo_data(self): # Panel type from calibration profile or detection if profile and profile.panel_type: panel_type = profile.panel_type - elif profile_data.get('panel_type'): - panel_type = profile_data['panel_type'] + elif profile_data.get("panel_type"): + panel_type = profile_data["panel_type"] else: panel_type = self._detect_panel_type(screen, i) @@ -347,8 +346,7 @@ def _populate_demo_data(self): # Create enhanced card with profile details card = self._create_display_card_enhanced( - display_name, res_str, panel_type, delta_e, is_calibrated, - profile, panel, profile_data + display_name, res_str, panel_type, delta_e, is_calibrated, profile, panel, profile_data ) self.displays_container.addWidget(card) @@ -358,15 +356,23 @@ def _populate_demo_data(self): placeholder.setStyleSheet(f"color: {COLORS['text_disabled']}; padding: 20px;") self.displays_container.addWidget(placeholder) - def _create_display_card_enhanced(self, name: str, resolution: str, panel_type: str, - delta_e: float, calibrated: bool, - profile=None, panel=None, profile_data=None) -> QFrame: + def _create_display_card_enhanced( + self, + name: str, + resolution: str, + panel_type: str, + delta_e: float, + calibrated: bool, + profile=None, + panel=None, + profile_data=None, + ) -> QFrame: """Create an enhanced display card with calibration profile details.""" card = QFrame() card.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 12px; }} """) @@ -382,7 +388,7 @@ def _create_display_card_enhanced(self, name: str, resolution: str, panel_type: # Display icon/indicator indicator = QLabel() indicator.setFixedSize(48, 48) - color = COLORS['success'] if calibrated else COLORS['text_disabled'] + color = COLORS["success"] if calibrated else COLORS["text_disabled"] indicator.setStyleSheet(f""" background-color: {color}; border-radius: 8px; @@ -410,7 +416,7 @@ def _create_display_card_enhanced(self, name: str, resolution: str, panel_type: # Delta E display if calibrated: - de_color = COLORS['success'] if delta_e < 1 else COLORS['warning'] if delta_e < 2 else COLORS['error'] + de_color = COLORS["success"] if delta_e < 1 else COLORS["warning"] if delta_e < 2 else COLORS["error"] de_frame = QFrame() de_frame.setStyleSheet(f"background-color: {COLORS['surface_alt']}; border-radius: 6px;") de_layout = QVBoxLayout(de_frame) @@ -439,7 +445,7 @@ def _create_display_card_enhanced(self, name: str, resolution: str, panel_type: if calibrated and (profile or panel): details_frame = QFrame() details_frame.setStyleSheet(f""" - background-color: {COLORS['surface_alt']}; + background-color: {COLORS["surface_alt"]}; border-radius: 6px; padding: 8px; """) @@ -463,6 +469,7 @@ def _create_display_card_enhanced(self, name: str, resolution: str, panel_type: # LUT status if profile.lut_path: import os + lut_name = os.path.basename(profile.lut_path) self._add_detail_row(details_layout, row, "LUT:", lut_name) row += 1 @@ -496,6 +503,7 @@ def _detect_panel_type(self, screen: QScreen, index: int) -> str: # Try to get from panel database try: from calibrate_pro.panels.database import get_database + db = get_database() panel = db.detect_panel(index) if panel: @@ -505,7 +513,7 @@ def _detect_panel_type(self, screen: QScreen, index: int) -> str: # Try to detect from screen name (common patterns) name = (screen.name() or "").upper() - model = (screen.model() or "").upper() if hasattr(screen, 'model') else "" + model = (screen.model() or "").upper() if hasattr(screen, "model") else "" if any(x in name + model for x in ["OLED", "QD-OLED", "WOLED"]): return "OLED" diff --git a/calibrate_pro/gui/pages/ddc_control.py b/calibrate_pro/gui/pages/ddc_control.py index fe2ec95..7ac8409 100644 --- a/calibrate_pro/gui/pages/ddc_control.py +++ b/calibrate_pro/gui/pages/ddc_control.py @@ -82,6 +82,7 @@ # Helper: labeled slider row + def _make_slider_row( label_text: str, style: str, @@ -121,6 +122,7 @@ def _make_slider_row( # DDC Control Page + class DDCControlPage(QWidget): """DDC/CI hardware control page.""" @@ -150,7 +152,9 @@ def _build(self): # --- Display selector --- selector_card, selector_layout = Card.with_layout( - QHBoxLayout, margins=(16, 12, 16, 12), spacing=12, + QHBoxLayout, + margins=(16, 12, 16, 12), + spacing=12, ) sel_label = QLabel("Display") @@ -172,19 +176,21 @@ def _build(self): bc_card, bc_layout = Card.with_layout(spacing=14) bc_heading = QLabel("Brightness & Contrast") - bc_heading.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.TEXT};" - ) + bc_heading.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.TEXT};") bc_layout.addWidget(bc_heading) row, self._brightness_slider, _ = _make_slider_row( - "Brightness", SLIDER_STYLE, initial=50, + "Brightness", + SLIDER_STYLE, + initial=50, ) self._brightness_slider.valueChanged.connect(self._set_brightness) bc_layout.addLayout(row) row, self._contrast_slider, _ = _make_slider_row( - "Contrast", SLIDER_STYLE, initial=50, + "Contrast", + SLIDER_STYLE, + initial=50, ) self._contrast_slider.valueChanged.connect(self._set_contrast) bc_layout.addLayout(row) @@ -195,33 +201,34 @@ def _build(self): gain_card, gain_layout = Card.with_layout(spacing=14) gain_heading = QLabel("RGB Gain (highlights)") - gain_heading.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.TEXT};" - ) + gain_heading.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.TEXT};") gain_layout.addWidget(gain_heading) row, self._red_gain_slider, _ = _make_slider_row( - "Red", RED_SLIDER_STYLE, initial=50, label_color=C.RED, - ) - self._red_gain_slider.valueChanged.connect( - lambda v: self._set_vcp_safe("RED_GAIN", v) + "Red", + RED_SLIDER_STYLE, + initial=50, + label_color=C.RED, ) + self._red_gain_slider.valueChanged.connect(lambda v: self._set_vcp_safe("RED_GAIN", v)) gain_layout.addLayout(row) row, self._green_gain_slider, _ = _make_slider_row( - "Green", GREEN_SLIDER_STYLE, initial=50, label_color=C.GREEN, - ) - self._green_gain_slider.valueChanged.connect( - lambda v: self._set_vcp_safe("GREEN_GAIN", v) + "Green", + GREEN_SLIDER_STYLE, + initial=50, + label_color=C.GREEN, ) + self._green_gain_slider.valueChanged.connect(lambda v: self._set_vcp_safe("GREEN_GAIN", v)) gain_layout.addLayout(row) row, self._blue_gain_slider, _ = _make_slider_row( - "Blue", BLUE_SLIDER_STYLE, initial=50, label_color=C.CYAN, - ) - self._blue_gain_slider.valueChanged.connect( - lambda v: self._set_vcp_safe("BLUE_GAIN", v) + "Blue", + BLUE_SLIDER_STYLE, + initial=50, + label_color=C.CYAN, ) + self._blue_gain_slider.valueChanged.connect(lambda v: self._set_vcp_safe("BLUE_GAIN", v)) gain_layout.addLayout(row) layout.addWidget(gain_card) @@ -230,33 +237,34 @@ def _build(self): offset_card, offset_layout = Card.with_layout(spacing=14) offset_heading = QLabel("RGB Offset (shadows)") - offset_heading.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.TEXT};" - ) + offset_heading.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.TEXT};") offset_layout.addWidget(offset_heading) row, self._red_offset_slider, _ = _make_slider_row( - "Red", RED_SLIDER_STYLE, initial=50, label_color=C.RED, - ) - self._red_offset_slider.valueChanged.connect( - lambda v: self._set_vcp_safe("RED_BLACK_LEVEL", v) + "Red", + RED_SLIDER_STYLE, + initial=50, + label_color=C.RED, ) + self._red_offset_slider.valueChanged.connect(lambda v: self._set_vcp_safe("RED_BLACK_LEVEL", v)) offset_layout.addLayout(row) row, self._green_offset_slider, _ = _make_slider_row( - "Green", GREEN_SLIDER_STYLE, initial=50, label_color=C.GREEN, - ) - self._green_offset_slider.valueChanged.connect( - lambda v: self._set_vcp_safe("GREEN_BLACK_LEVEL", v) + "Green", + GREEN_SLIDER_STYLE, + initial=50, + label_color=C.GREEN, ) + self._green_offset_slider.valueChanged.connect(lambda v: self._set_vcp_safe("GREEN_BLACK_LEVEL", v)) offset_layout.addLayout(row) row, self._blue_offset_slider, _ = _make_slider_row( - "Blue", BLUE_SLIDER_STYLE, initial=50, label_color=C.CYAN, - ) - self._blue_offset_slider.valueChanged.connect( - lambda v: self._set_vcp_safe("BLUE_BLACK_LEVEL", v) + "Blue", + BLUE_SLIDER_STYLE, + initial=50, + label_color=C.CYAN, ) + self._blue_offset_slider.valueChanged.connect(lambda v: self._set_vcp_safe("BLUE_BLACK_LEVEL", v)) offset_layout.addLayout(row) layout.addWidget(offset_card) @@ -265,9 +273,7 @@ def _build(self): mode_card, mode_layout = Card.with_layout(spacing=14) mode_heading = QLabel("Display Mode") - mode_heading.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.TEXT};" - ) + mode_heading.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.TEXT};") mode_layout.addWidget(mode_heading) combo_style = ( @@ -287,15 +293,25 @@ def _build(self): pic_label.setStyleSheet(f"font-size: 12px; color: {C.TEXT2};") pic_row.addWidget(pic_label) self._picture_mode_combo = QComboBox() - self._picture_mode_combo.addItems([ - "Standard", "Custom 1", "Custom 2", "Custom 3", - "sRGB", "Cinema", "Game", "FPS", "RTS", - "Vivid", "Eco", "User", "Filmmaker", - ]) - self._picture_mode_combo.setStyleSheet(combo_style) - self._picture_mode_combo.currentIndexChanged.connect( - lambda idx: self._set_vcp_safe("IMAGE_MODE", idx) + self._picture_mode_combo.addItems( + [ + "Standard", + "Custom 1", + "Custom 2", + "Custom 3", + "sRGB", + "Cinema", + "Game", + "FPS", + "RTS", + "Vivid", + "Eco", + "User", + "Filmmaker", + ] ) + self._picture_mode_combo.setStyleSheet(combo_style) + self._picture_mode_combo.currentIndexChanged.connect(lambda idx: self._set_vcp_safe("IMAGE_MODE", idx)) pic_row.addWidget(self._picture_mode_combo, stretch=1) mode_layout.addLayout(pic_row) @@ -307,28 +323,39 @@ def _build(self): color_label.setStyleSheet(f"font-size: 12px; color: {C.TEXT2};") color_row.addWidget(color_label) self._color_preset_combo = QComboBox() - self._color_preset_combo.addItems([ - "Native", "sRGB", "4000K", "5000K", "5500K", - "6500K", "7500K", "8200K", "9300K", "11500K", - "User 1", "User 2", "User 3", - ]) - self._color_preset_combo.setStyleSheet(combo_style) - self._color_preset_combo.currentIndexChanged.connect( - lambda idx: self._set_vcp_safe("COLOR_PRESET", idx) + self._color_preset_combo.addItems( + [ + "Native", + "sRGB", + "4000K", + "5000K", + "5500K", + "6500K", + "7500K", + "8200K", + "9300K", + "11500K", + "User 1", + "User 2", + "User 3", + ] ) + self._color_preset_combo.setStyleSheet(combo_style) + self._color_preset_combo.currentIndexChanged.connect(lambda idx: self._set_vcp_safe("COLOR_PRESET", idx)) color_row.addWidget(self._color_preset_combo, stretch=1) mode_layout.addLayout(color_row) # Gamma slider gamma_row, self._gamma_slider, _ = _make_slider_row( - "Gamma", SLIDER_STYLE, initial=22, label_color=C.TEXT, + "Gamma", + SLIDER_STYLE, + initial=22, + label_color=C.TEXT, ) self._gamma_slider.setRange(10, 30) self._gamma_slider.setValue(22) self._gamma_slider.setToolTip("Gamma value x10 (22 = gamma 2.2)") - self._gamma_slider.valueChanged.connect( - lambda v: self._set_vcp_safe("GAMMA", v) - ) + self._gamma_slider.valueChanged.connect(lambda v: self._set_vcp_safe("GAMMA", v)) mode_layout.addLayout(gamma_row) # Factory reset button (specific resets) @@ -341,9 +368,7 @@ def _build(self): f"QPushButton:hover {{ border-color: {C.ACCENT}; }}" ) reset_color_btn.setToolTip("VCP 0x0A: Restore factory color settings only") - reset_color_btn.clicked.connect( - lambda: self._set_vcp_safe("RESTORE_FACTORY_COLOR", 1) - ) + reset_color_btn.clicked.connect(lambda: self._set_vcp_safe("RESTORE_FACTORY_COLOR", 1)) mode_layout.addWidget(reset_color_btn) layout.addWidget(mode_card) @@ -387,9 +412,7 @@ def _build(self): adv_card, adv_layout = Card.with_layout(spacing=14) adv_heading = QLabel("Advanced \u2014 Raw VCP Read/Write") - adv_heading.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.TEXT};" - ) + adv_heading.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.TEXT};") adv_layout.addWidget(adv_heading) adv_desc = QLabel( @@ -503,6 +526,7 @@ def _init_controller(self): """Initialize the DDC/CI controller and detect monitors.""" try: from calibrate_pro.hardware.ddc_ci import DDCCIController + self._controller = DDCCIController() if not self._controller.available: @@ -549,7 +573,6 @@ def _read_current(self): return try: - settings = self._controller.get_settings(self._current_monitor) # Block signals while updating sliders to avoid writing back @@ -570,10 +593,7 @@ def _read_current(self): self._status_dot.set_color(C.GREEN) except Exception as e: - QMessageBox.warning( - self, "Read Error", - f"Failed to read monitor settings:\n{e}" - ) + QMessageBox.warning(self, "Read Error", f"Failed to read monitor settings:\n{e}") self._status_dot.set_color(C.RED) def _set_brightness(self, value: int): @@ -591,13 +611,15 @@ def _set_vcp_safe(self, code_name: str, value: int): try: from calibrate_pro.hardware.ddc_ci import VCPCode + code = getattr(VCPCode, code_name) self._controller.set_vcp(self._current_monitor, code, value) except Exception as e: import logging + logging.getLogger(__name__).debug("DDC set %s=%d failed: %s", code_name, value, e) # Show brief status feedback - if hasattr(self, '_status_label'): + if hasattr(self, "_status_label"): self._status_label.setText(f"DDC command failed: {code_name}") self._status_label.setStyleSheet("font-size: 11px; color: #d08888;") @@ -615,12 +637,8 @@ def _raw_vcp_read(self): code = self._vcp_code_spin.value() try: - current, maximum = self._controller.get_vcp( - self._current_monitor, code - ) - self._vcp_result_label.setText( - f"VCP 0x{code:02X}: current = {current} | max = {maximum}" - ) + current, maximum = self._controller.get_vcp(self._current_monitor, code) + self._vcp_result_label.setText(f"VCP 0x{code:02X}: current = {current} | max = {maximum}") self._vcp_result_label.setStyleSheet( f"font-size: 11px; color: {C.GREEN}; " f"font-family: 'Cascadia Code', 'Consolas', monospace; " @@ -629,9 +647,7 @@ def _raw_vcp_read(self): # Pre-fill the write value with the current value self._vcp_value_spin.setValue(current) except Exception as e: - self._vcp_result_label.setText( - f"Read VCP 0x{code:02X} failed: {e}" - ) + self._vcp_result_label.setText(f"Read VCP 0x{code:02X} failed: {e}") self._vcp_result_label.setStyleSheet( f"font-size: 11px; color: {C.RED}; " f"font-family: 'Cascadia Code', 'Consolas', monospace; " @@ -655,18 +671,14 @@ def _raw_vcp_write(self): value = self._vcp_value_spin.value() try: self._controller.set_vcp(self._current_monitor, code, value) - self._vcp_result_label.setText( - f"VCP 0x{code:02X} set to {value} \u2714" - ) + self._vcp_result_label.setText(f"VCP 0x{code:02X} set to {value} \u2714") self._vcp_result_label.setStyleSheet( f"font-size: 11px; color: {C.GREEN}; " f"font-family: 'Cascadia Code', 'Consolas', monospace; " f"background: {C.SURFACE2}; border-radius: 6px; padding: 8px 12px;" ) except Exception as e: - self._vcp_result_label.setText( - f"Write VCP 0x{code:02X} = {value} failed: {e}" - ) + self._vcp_result_label.setText(f"Write VCP 0x{code:02X} = {value} failed: {e}") self._vcp_result_label.setStyleSheet( f"font-size: 11px; color: {C.RED}; " f"font-family: 'Cascadia Code', 'Consolas', monospace; " @@ -677,16 +689,13 @@ def _raw_vcp_write(self): def _reset_defaults(self): """Reset all controls to factory defaults.""" if not self._controller or not self._current_monitor: - QMessageBox.information( - self, "No Monitor", - "No DDC/CI monitor is selected." - ) + QMessageBox.information(self, "No Monitor", "No DDC/CI monitor is selected.") return reply = QMessageBox.question( - self, "Reset to Default", - "Reset all monitor settings to factory defaults?\n\n" - "This sends the DDC/CI factory-reset command.", + self, + "Reset to Default", + "Reset all monitor settings to factory defaults?\n\nThis sends the DDC/CI factory-reset command.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: @@ -694,6 +703,7 @@ def _reset_defaults(self): try: from calibrate_pro.hardware.ddc_ci import VCPCode + self._controller.set_vcp( self._current_monitor, VCPCode.RESTORE_FACTORY_DEFAULTS, @@ -702,7 +712,4 @@ def _reset_defaults(self): # Re-read after reset self._read_current() except Exception as e: - QMessageBox.warning( - self, "Reset Error", - f"Factory reset failed:\n{e}" - ) + QMessageBox.warning(self, "Reset Error", f"Factory reset failed:\n{e}") diff --git a/calibrate_pro/gui/pages/ddc_control_page.py b/calibrate_pro/gui/pages/ddc_control_page.py index 2be9a6d..97bd4bf 100644 --- a/calibrate_pro/gui/pages/ddc_control_page.py +++ b/calibrate_pro/gui/pages/ddc_control_page.py @@ -109,24 +109,24 @@ def _setup_ui(self): self.control_tabs = QTabWidget() self.control_tabs.setStyleSheet(f""" QTabWidget::pane {{ - border: 1px solid {COLORS['border']}; + border: 1px solid {COLORS["border"]}; border-radius: 4px; - background: {COLORS['surface']}; + background: {COLORS["surface"]}; }} QTabBar::tab {{ - background: {COLORS['background_alt']}; - color: {COLORS['text_secondary']}; + background: {COLORS["background_alt"]}; + color: {COLORS["text_secondary"]}; padding: 8px 16px; margin-right: 2px; border-top-left-radius: 4px; border-top-right-radius: 4px; }} QTabBar::tab:selected {{ - background: {COLORS['surface']}; - color: {COLORS['text_primary']}; + background: {COLORS["surface"]}; + color: {COLORS["text_primary"]}; }} QTabBar::tab:hover {{ - background: {COLORS['surface_alt']}; + background: {COLORS["surface_alt"]}; }} """) @@ -186,16 +186,12 @@ def _setup_common_controls_tab(self): basic_layout = QVBoxLayout(self.basic_group) self.brightness_slider = self._create_slider_row( - "Brightness", 0, 100, 50, - "Adjusts monitor backlight/OLED pixel brightness" + "Brightness", 0, 100, 50, "Adjusts monitor backlight/OLED pixel brightness" ) - basic_layout.addLayout(self.brightness_slider['layout']) + basic_layout.addLayout(self.brightness_slider["layout"]) - self.contrast_slider = self._create_slider_row( - "Contrast", 0, 100, 50, - "Adjusts display contrast ratio" - ) - basic_layout.addLayout(self.contrast_slider['layout']) + self.contrast_slider = self._create_slider_row("Contrast", 0, 100, 50, "Adjusts display contrast ratio") + basic_layout.addLayout(self.contrast_slider["layout"]) scroll_layout.addWidget(self.basic_group) @@ -209,37 +205,32 @@ def _setup_common_controls_tab(self): ) self.rgb_unsupported_label.setWordWrap(True) self.rgb_unsupported_label.setStyleSheet( - f"color: {COLORS['error']}; padding: 8px; " - f"background-color: rgba(255,100,100,0.1); border-radius: 4px;" + f"color: {COLORS['error']}; padding: 8px; background-color: rgba(255,100,100,0.1); border-radius: 4px;" ) self.rgb_unsupported_label.setVisible(False) rgb_layout.addWidget(self.rgb_unsupported_label) rgb_info = QLabel( - "Adjust these to achieve D65 (6504K) white point. " - "Values should be near 100 for neutral gray at all levels." + "Adjust these to achieve D65 (6504K) white point. Values should be near 100 for neutral gray at all levels." ) rgb_info.setWordWrap(True) rgb_info.setStyleSheet(f"color: {COLORS['text_secondary']}; padding: 4px;") rgb_layout.addWidget(rgb_info) self.red_gain_slider = self._create_slider_row( - "Red Gain", 0, 100, 100, "Increases red in highlights (warm)", - value_color="#ff6b6b" + "Red Gain", 0, 100, 100, "Increases red in highlights (warm)", value_color="#ff6b6b" ) - rgb_layout.addLayout(self.red_gain_slider['layout']) + rgb_layout.addLayout(self.red_gain_slider["layout"]) self.green_gain_slider = self._create_slider_row( - "Green Gain", 0, 100, 100, "Increases green in highlights", - value_color="#69db7c" + "Green Gain", 0, 100, 100, "Increases green in highlights", value_color="#69db7c" ) - rgb_layout.addLayout(self.green_gain_slider['layout']) + rgb_layout.addLayout(self.green_gain_slider["layout"]) self.blue_gain_slider = self._create_slider_row( - "Blue Gain", 0, 100, 100, "Increases blue in highlights (cool)", - value_color="#74c0fc" + "Blue Gain", 0, 100, 100, "Increases blue in highlights (cool)", value_color="#74c0fc" ) - rgb_layout.addLayout(self.blue_gain_slider['layout']) + rgb_layout.addLayout(self.blue_gain_slider["layout"]) scroll_layout.addWidget(self.rgb_group) @@ -247,41 +238,33 @@ def _setup_common_controls_tab(self): self.black_group = QGroupBox("RGB Black Level (Shadow Balance)") black_layout = QVBoxLayout(self.black_group) - self.black_unsupported_label = QLabel( - "\u274c RGB Black Level is NOT supported by this monitor via DDC/CI." - ) + self.black_unsupported_label = QLabel("\u274c RGB Black Level is NOT supported by this monitor via DDC/CI.") self.black_unsupported_label.setWordWrap(True) self.black_unsupported_label.setStyleSheet( - f"color: {COLORS['error']}; padding: 8px; " - f"background-color: rgba(255,100,100,0.1); border-radius: 4px;" + f"color: {COLORS['error']}; padding: 8px; background-color: rgba(255,100,100,0.1); border-radius: 4px;" ) self.black_unsupported_label.setVisible(False) black_layout.addWidget(self.black_unsupported_label) - black_info = QLabel( - "Adjusts color balance in shadows/blacks. Keep balanced for neutral grays." - ) + black_info = QLabel("Adjusts color balance in shadows/blacks. Keep balanced for neutral grays.") black_info.setWordWrap(True) black_info.setStyleSheet(f"color: {COLORS['text_secondary']}; padding: 4px;") black_layout.addWidget(black_info) self.red_black_slider = self._create_slider_row( - "Red Black", 0, 100, 50, "Red level in shadows", - value_color="#ff6b6b" + "Red Black", 0, 100, 50, "Red level in shadows", value_color="#ff6b6b" ) - black_layout.addLayout(self.red_black_slider['layout']) + black_layout.addLayout(self.red_black_slider["layout"]) self.green_black_slider = self._create_slider_row( - "Green Black", 0, 100, 50, "Green level in shadows", - value_color="#69db7c" + "Green Black", 0, 100, 50, "Green level in shadows", value_color="#69db7c" ) - black_layout.addLayout(self.green_black_slider['layout']) + black_layout.addLayout(self.green_black_slider["layout"]) self.blue_black_slider = self._create_slider_row( - "Blue Black", 0, 100, 50, "Blue level in shadows", - value_color="#74c0fc" + "Blue Black", 0, 100, 50, "Blue level in shadows", value_color="#74c0fc" ) - black_layout.addLayout(self.blue_black_slider['layout']) + black_layout.addLayout(self.blue_black_slider["layout"]) scroll_layout.addWidget(self.black_group) scroll_layout.addStretch() @@ -329,9 +312,7 @@ def _setup_vcp_scanner_tab(self): # Results table self.vcp_table = QTableWidget() self.vcp_table.setColumnCount(5) - self.vcp_table.setHorizontalHeaderLabels([ - "Code", "Name", "Current", "Maximum", "Actions" - ]) + self.vcp_table.setHorizontalHeaderLabels(["Code", "Name", "Current", "Maximum", "Actions"]) self.vcp_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) self.vcp_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.vcp_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) @@ -340,21 +321,21 @@ def _setup_vcp_scanner_tab(self): self.vcp_table.setAlternatingRowColors(True) self.vcp_table.setStyleSheet(f""" QTableWidget {{ - background-color: {COLORS['surface']}; - gridline-color: {COLORS['border']}; + background-color: {COLORS["surface"]}; + gridline-color: {COLORS["border"]}; }} QTableWidget::item {{ padding: 4px 8px; }} QTableWidget::item:alternate {{ - background-color: {COLORS['background_alt']}; + background-color: {COLORS["background_alt"]}; }} QHeaderView::section {{ - background-color: {COLORS['background_alt']}; - color: {COLORS['text_primary']}; + background-color: {COLORS["background_alt"]}; + color: {COLORS["text_primary"]}; padding: 6px; border: none; - border-bottom: 1px solid {COLORS['border']}; + border-bottom: 1px solid {COLORS["border"]}; }} """) layout.addWidget(self.vcp_table) @@ -464,30 +445,30 @@ def _setup_presets_tab(self): color_group = QGroupBox("Color Temperature / Preset (VCP 0x14)") color_layout = QVBoxLayout(color_group) - color_info = QLabel( - "Select a color temperature preset. Available presets depend on your monitor." - ) + color_info = QLabel("Select a color temperature preset. Available presets depend on your monitor.") color_info.setWordWrap(True) color_info.setStyleSheet(f"color: {COLORS['text_secondary']}; padding: 4px;") color_layout.addWidget(color_info) preset_row = QHBoxLayout() self.color_preset_combo = QComboBox() - self.color_preset_combo.addItems([ - "1 - Native/sRGB", - "2 - 4000K (Warm)", - "3 - 5000K (Warm)", - "4 - 5500K", - "5 - 6500K (D65)", - "6 - 7500K (Cool)", - "7 - 8200K (Cool)", - "8 - 9300K (Cool)", - "9 - 10000K", - "10 - 11500K", - "11 - User 1", - "12 - User 2", - "13 - User 3", - ]) + self.color_preset_combo.addItems( + [ + "1 - Native/sRGB", + "2 - 4000K (Warm)", + "3 - 5000K (Warm)", + "4 - 5500K", + "5 - 6500K (D65)", + "6 - 7500K (Cool)", + "7 - 8200K (Cool)", + "8 - 9300K (Cool)", + "9 - 10000K", + "10 - 11500K", + "11 - User 1", + "12 - User 2", + "13 - User 3", + ] + ) self.color_preset_combo.setCurrentIndex(4) # Default to 6500K preset_row.addWidget(self.color_preset_combo) @@ -512,25 +493,25 @@ def _setup_presets_tab(self): image_group = QGroupBox("Image Mode / Picture Preset (VCP 0xDB)") image_layout = QVBoxLayout(image_group) - image_info = QLabel( - "Picture mode presets like Standard, Movie, Game, Photo, etc." - ) + image_info = QLabel("Picture mode presets like Standard, Movie, Game, Photo, etc.") image_info.setWordWrap(True) image_info.setStyleSheet(f"color: {COLORS['text_secondary']}; padding: 4px;") image_layout.addWidget(image_info) image_row = QHBoxLayout() self.image_mode_combo = QComboBox() - self.image_mode_combo.addItems([ - "0 - Standard", - "1 - Movie/Cinema", - "2 - Game", - "3 - Photo/Graphics", - "4 - Text/Office", - "5 - Dynamic", - "6 - Custom 1", - "7 - Custom 2", - ]) + self.image_mode_combo.addItems( + [ + "0 - Standard", + "1 - Movie/Cinema", + "2 - Game", + "3 - Photo/Graphics", + "4 - Text/Office", + "5 - Dynamic", + "6 - Custom 1", + "7 - Custom 2", + ] + ) image_row.addWidget(self.image_mode_combo) apply_image_btn = QPushButton("Apply") @@ -554,23 +535,23 @@ def _setup_presets_tab(self): gamma_group = QGroupBox("Gamma Preset (VCP 0xF2)") gamma_layout = QVBoxLayout(gamma_group) - gamma_info = QLabel( - "Gamma curve preset. Values are manufacturer-specific." - ) + gamma_info = QLabel("Gamma curve preset. Values are manufacturer-specific.") gamma_info.setWordWrap(True) gamma_info.setStyleSheet(f"color: {COLORS['text_secondary']}; padding: 4px;") gamma_layout.addWidget(gamma_info) gamma_row = QHBoxLayout() self.gamma_combo = QComboBox() - self.gamma_combo.addItems([ - "0 - Native/Default", - "1 - 1.8", - "2 - 2.0", - "3 - 2.2 (sRGB)", - "4 - 2.4 (BT.1886)", - "5 - 2.6", - ]) + self.gamma_combo.addItems( + [ + "0 - Native/Default", + "1 - 1.8", + "2 - 2.0", + "3 - 2.2 (sRGB)", + "4 - 2.4 (BT.1886)", + "5 - 2.6", + ] + ) self.gamma_combo.setCurrentIndex(3) # Default to 2.2 gamma_row.addWidget(self.gamma_combo) @@ -633,13 +614,15 @@ def _setup_auto_calibration_tab(self): detect_btn_layout.addWidget(detect_colorimeter_btn) self.colorimeter_combo = QComboBox() - self.colorimeter_combo.addItems([ - "Auto-detect", - "i1Display Pro", - "Spyder X", - "ColorChecker Display", - "ArgyllCMS (any device)", - ]) + self.colorimeter_combo.addItems( + [ + "Auto-detect", + "i1Display Pro", + "Spyder X", + "ColorChecker Display", + "ArgyllCMS (any device)", + ] + ) detect_btn_layout.addWidget(self.colorimeter_combo) detect_btn_layout.addStretch() @@ -721,7 +704,7 @@ def _setup_auto_calibration_tab(self): self.auto_log.setMaximumHeight(150) self.auto_log.setStyleSheet(f""" QPlainTextEdit {{ - background-color: {COLORS['background_alt']}; + background-color: {COLORS["background_alt"]}; font-family: 'Consolas', monospace; font-size: 11px; }} @@ -779,9 +762,7 @@ def _detect_colorimeter(self): if devices: device = devices[0] - self.colorimeter_status.setText( - f"\u2713 Found: {device.name} ({device.manufacturer})" - ) + self.colorimeter_status.setText(f"\u2713 Found: {device.name} ({device.manufacturer})") self.colorimeter_status.setStyleSheet(f"color: {COLORS['success']}; padding: 8px;") self._colorimeter = backend return @@ -794,10 +775,7 @@ def _detect_colorimeter(self): self.colorimeter_status.setStyleSheet(f"color: {COLORS['warning']}; padding: 8px;") except Exception as e: - self.colorimeter_status.setText( - f"ArgyllCMS not found. Install from argyllcms.com\n" - f"Error: {e}" - ) + self.colorimeter_status.setText(f"ArgyllCMS not found. Install from argyllcms.com\nError: {e}") self.colorimeter_status.setStyleSheet(f"color: {COLORS['error']}; padding: 8px;") def _start_hardware_calibration(self): @@ -820,13 +798,13 @@ def _start_hardware_calibration(self): engine = HardwareCalibrationEngine() # Get colorimeter if available - colorimeter = getattr(self, '_colorimeter', None) + colorimeter = getattr(self, "_colorimeter", None) # Initialize if not engine.initialize( colorimeter=colorimeter, ddc_controller=self.ddc_controller, - display_index=self.monitor_combo.currentIndex() + display_index=self.monitor_combo.currentIndex(), ): self.auto_log.appendPlainText("ERROR: Failed to initialize calibration engine") return @@ -905,12 +883,13 @@ def _quick_white_balance(self): QMessageBox.warning(self, "No Monitor", "Select a DDC/CI monitor first.") return - colorimeter = getattr(self, '_colorimeter', None) + colorimeter = getattr(self, "_colorimeter", None) if not colorimeter: QMessageBox.information( - self, "Colorimeter Required", + self, + "Colorimeter Required", "Quick white balance requires a colorimeter to measure actual display output.\n\n" - "Click 'Detect Colorimeter' first, or use 'Sensorless Calibration' instead." + "Click 'Detect Colorimeter' first, or use 'Sensorless Calibration' instead.", ) return @@ -921,7 +900,7 @@ def _quick_white_balance(self): engine.initialize( colorimeter=colorimeter, ddc_controller=self.ddc_controller, - display_index=self.monitor_combo.currentIndex() + display_index=self.monitor_combo.currentIndex(), ) self.auto_log.appendPlainText("Starting quick white balance...") @@ -1017,16 +996,16 @@ def progress_callback(msg, progress): # Determine accuracy rating if result.estimated_delta_e_white < 1.0: rating = "REFERENCE GRADE" - rating_color = COLORS['success'] + rating_color = COLORS["success"] elif result.estimated_delta_e_white < 2.0: rating = "PROFESSIONAL GRADE" - rating_color = COLORS['success'] + rating_color = COLORS["success"] elif result.estimated_delta_e_white < 3.0: rating = "PHOTO EDITING GRADE" - rating_color = COLORS['warning'] + rating_color = COLORS["warning"] else: rating = "GENERAL USE" - rating_color = COLORS['warning'] + rating_color = COLORS["warning"] self.auto_results.setText( f"CALIBRATION COMPLETE!\n\n" @@ -1047,6 +1026,7 @@ def progress_callback(msg, progress): except Exception as e: import traceback + self.auto_log.appendPlainText(f"ERROR: {e}") self.auto_log.appendPlainText(traceback.format_exc()) self.auto_results.setText(f"Error: {e}") @@ -1057,8 +1037,9 @@ def _stop_calibration(self): self.auto_progress.setFormat("Stopped") self.start_calibration_btn.setEnabled(True) - def _create_slider_row(self, label: str, min_val: int, max_val: int, - default: int, tooltip: str, value_color: str = None) -> dict: + def _create_slider_row( + self, label: str, min_val: int, max_val: int, default: int, tooltip: str, value_color: str = None + ) -> dict: """Create a labeled slider with value display.""" layout = QHBoxLayout() layout.setSpacing(12) @@ -1077,7 +1058,7 @@ def _create_slider_row(self, label: str, min_val: int, max_val: int, layout.addWidget(slider, stretch=1) # Value label - color = value_color or COLORS['text_primary'] + color = value_color or COLORS["text_primary"] value_lbl = QLabel(str(default)) value_lbl.setMinimumWidth(40) value_lbl.setAlignment(Qt.AlignmentFlag.AlignRight) @@ -1092,18 +1073,18 @@ def on_value_changed(val): slider.valueChanged.connect(on_value_changed) - return {'layout': layout, 'slider': slider, 'value_label': value_lbl} + return {"layout": layout, "slider": slider, "value_label": value_lbl} def _initialize_ddc(self): """Initialize DDC/CI controller and enumerate monitors.""" try: from calibrate_pro.hardware.ddc_ci import DDCCIController + self.ddc_controller = DDCCIController() if not self.ddc_controller.available: self.status_label.setText( - "\u274c DDC/CI is not available on this system. " - "Monitor hardware control requires DDC/CI support." + "\u274c DDC/CI is not available on this system. Monitor hardware control requires DDC/CI support." ) self.status_label.setStyleSheet(f"color: {COLORS['error']}; padding: 8px;") return @@ -1131,14 +1112,13 @@ def _refresh_monitors(self): return for i, monitor in enumerate(self.monitors): - name = monitor.get('name', f'Monitor {i+1}') - caps = monitor.get('capabilities') + name = monitor.get("name", f"Monitor {i + 1}") + caps = monitor.get("capabilities") rgb_support = "\u2713 RGB" if caps and caps.has_rgb_gain else "\u25cb Basic" self.monitor_combo.addItem(f"{name} [{rgb_support}]") self.status_label.setText( - f"\u2713 Found {len(self.monitors)} DDC/CI monitor(s). " - "Adjust sliders to see live changes on your display." + f"\u2713 Found {len(self.monitors)} DDC/CI monitor(s). Adjust sliders to see live changes on your display." ) self.status_label.setStyleSheet(f"color: {COLORS['success']}; padding: 8px;") @@ -1151,14 +1131,14 @@ def _on_monitor_changed(self, index: int): return self.current_monitor = self.monitors[index] - caps = self.current_monitor.get('capabilities') + caps = self.current_monitor.get("capabilities") # Track supported features for this monitor self._supported_features = { - 'brightness': False, - 'contrast': False, - 'rgb_gain': False, - 'rgb_black': False, + "brightness": False, + "contrast": False, + "rgb_gain": False, + "rgb_black": False, } if caps: @@ -1168,32 +1148,32 @@ def _on_monitor_changed(self, index: int): # Check brightness (VCP 0x10) if 0x10 in caps.supported_vcp_codes: cap_text.append("Brightness \u2713") - self._supported_features['brightness'] = True + self._supported_features["brightness"] = True else: supported_unsupported.append("Brightness \u2717") # Check contrast (VCP 0x12) if 0x12 in caps.supported_vcp_codes: cap_text.append("Contrast \u2713") - self._supported_features['contrast'] = True + self._supported_features["contrast"] = True else: supported_unsupported.append("Contrast \u2717") # Check RGB Gain if caps.has_rgb_gain: cap_text.append("RGB Gain \u2713") - self._supported_features['rgb_gain'] = True + self._supported_features["rgb_gain"] = True else: supported_unsupported.append("RGB Gain \u2717") # Check RGB Black Level if caps.has_rgb_black_level: cap_text.append("RGB Black Level \u2713") - self._supported_features['rgb_black'] = True + self._supported_features["rgb_black"] = True else: supported_unsupported.append("RGB Black \u2717") - status = ', '.join(cap_text) if cap_text else 'None' + status = ", ".join(cap_text) if cap_text else "None" if supported_unsupported: status += f" | Not supported: {', '.join(supported_unsupported)}" self.capabilities_label.setText(f"Capabilities: {status}") @@ -1209,10 +1189,10 @@ def _on_monitor_changed(self, index: int): def _update_slider_states(self): """Enable/disable sliders based on monitor capabilities.""" # Brightness/Contrast - has_brightness = self._supported_features.get('brightness', False) - has_contrast = self._supported_features.get('contrast', False) - self.brightness_slider['slider'].setEnabled(has_brightness) - self.contrast_slider['slider'].setEnabled(has_contrast) + has_brightness = self._supported_features.get("brightness", False) + has_contrast = self._supported_features.get("contrast", False) + self.brightness_slider["slider"].setEnabled(has_brightness) + self.contrast_slider["slider"].setEnabled(has_contrast) if not has_brightness and not has_contrast: self.basic_group.setTitle("Brightness & Contrast (NOT SUPPORTED)") @@ -1220,11 +1200,11 @@ def _update_slider_states(self): self.basic_group.setTitle("Brightness & Contrast") # RGB Gain - has_rgb_gain = self._supported_features.get('rgb_gain', False) + has_rgb_gain = self._supported_features.get("rgb_gain", False) self.rgb_unsupported_label.setVisible(not has_rgb_gain) - self.red_gain_slider['slider'].setEnabled(has_rgb_gain) - self.green_gain_slider['slider'].setEnabled(has_rgb_gain) - self.blue_gain_slider['slider'].setEnabled(has_rgb_gain) + self.red_gain_slider["slider"].setEnabled(has_rgb_gain) + self.green_gain_slider["slider"].setEnabled(has_rgb_gain) + self.blue_gain_slider["slider"].setEnabled(has_rgb_gain) if has_rgb_gain: self.rgb_group.setTitle("RGB Gain (White Balance) - Adjusts D65 White Point") @@ -1232,11 +1212,11 @@ def _update_slider_states(self): self.rgb_group.setTitle("RGB Gain (White Balance) - NOT SUPPORTED") # RGB Black Level - has_rgb_black = self._supported_features.get('rgb_black', False) + has_rgb_black = self._supported_features.get("rgb_black", False) self.black_unsupported_label.setVisible(not has_rgb_black) - self.red_black_slider['slider'].setEnabled(has_rgb_black) - self.green_black_slider['slider'].setEnabled(has_rgb_black) - self.blue_black_slider['slider'].setEnabled(has_rgb_black) + self.red_black_slider["slider"].setEnabled(has_rgb_black) + self.green_black_slider["slider"].setEnabled(has_rgb_black) + self.blue_black_slider["slider"].setEnabled(has_rgb_black) if has_rgb_black: self.black_group.setTitle("RGB Black Level (Shadow Balance)") @@ -1254,21 +1234,21 @@ def _read_current_values(self): settings = self.ddc_controller.get_settings(self.current_monitor) if settings.brightness > 0: - self.brightness_slider['slider'].setValue(settings.brightness) + self.brightness_slider["slider"].setValue(settings.brightness) if settings.contrast > 0: - self.contrast_slider['slider'].setValue(settings.contrast) + self.contrast_slider["slider"].setValue(settings.contrast) if settings.red_gain > 0: - self.red_gain_slider['slider'].setValue(settings.red_gain) + self.red_gain_slider["slider"].setValue(settings.red_gain) if settings.green_gain > 0: - self.green_gain_slider['slider'].setValue(settings.green_gain) + self.green_gain_slider["slider"].setValue(settings.green_gain) if settings.blue_gain > 0: - self.blue_gain_slider['slider'].setValue(settings.blue_gain) + self.blue_gain_slider["slider"].setValue(settings.blue_gain) if settings.red_black_level > 0: - self.red_black_slider['slider'].setValue(settings.red_black_level) + self.red_black_slider["slider"].setValue(settings.red_black_level) if settings.green_black_level > 0: - self.green_black_slider['slider'].setValue(settings.green_black_level) + self.green_black_slider["slider"].setValue(settings.green_black_level) if settings.blue_black_level > 0: - self.blue_black_slider['slider'].setValue(settings.blue_black_level) + self.blue_black_slider["slider"].setValue(settings.blue_black_level) except Exception as e: self.status_label.setText(f"\u26a0\ufe0f Error reading values: {e}") @@ -1311,10 +1291,11 @@ def _send_ddc_value(self, setting_name: str, value: int): def _reset_to_defaults(self): """Reset all values to factory defaults.""" reply = QMessageBox.question( - self, "Reset to Defaults", + self, + "Reset to Defaults", "Reset all DDC/CI values to factory defaults?\n\n" "This will set brightness/contrast to 50 and RGB gains to 100.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: @@ -1334,7 +1315,7 @@ def _reset_to_defaults(self): ] for slider_dict, value in defaults: - slider_dict['slider'].setValue(value) + slider_dict["slider"].setValue(value) self._updating_sliders = False @@ -1349,19 +1330,17 @@ def _reset_to_defaults(self): def _test_ddc_connection(self): """Test DDC/CI by visibly flashing brightness.""" if not self.ddc_controller or not self.current_monitor: - QMessageBox.warning( - self, "No Monitor", - "No DDC/CI capable monitor is selected." - ) + QMessageBox.warning(self, "No Monitor", "No DDC/CI capable monitor is selected.") return # Check if brightness is supported - if not self._supported_features.get('brightness', False): + if not self._supported_features.get("brightness", False): QMessageBox.warning( - self, "Brightness Not Supported", + self, + "Brightness Not Supported", "This monitor does not support brightness control via DDC/CI.\n\n" "DDC/CI control may not work on this monitor.\n" - "Many monitors have DDC/CI disabled by default - check your monitor's OSD settings." + "Many monitors have DDC/CI disabled by default - check your monitor's OSD settings.", ) return @@ -1387,38 +1366,36 @@ def _test_ddc_connection(self): self.status_label.setText(f"Testing: {msg}") QApplication.processEvents() - success = self.ddc_controller.set_vcp( - self.current_monitor, VCPCode.BRIGHTNESS, brightness - ) + success = self.ddc_controller.set_vcp(self.current_monitor, VCPCode.BRIGHTNESS, brightness) if not success: QMessageBox.warning( - self, "DDC/CI Test Failed", + self, + "DDC/CI Test Failed", f"Failed to set brightness to {brightness}%.\n\n" "DDC/CI commands are being rejected by the monitor.\n" "This could mean:\n" "\u2022 DDC/CI is disabled in monitor OSD settings\n" "\u2022 Monitor doesn't fully support DDC/CI\n" "\u2022 Cable doesn't support DDC/CI (use HDMI or DisplayPort)\n" - "\u2022 GPU driver issue" + "\u2022 GPU driver issue", ) return time.sleep(0.8) # Visible delay - self.status_label.setText( - "\u2713 DDC/CI test complete! If you saw brightness changes, DDC is working." - ) + self.status_label.setText("\u2713 DDC/CI test complete! If you saw brightness changes, DDC is working.") self.status_label.setStyleSheet(f"color: {COLORS['success']}; padding: 8px;") QMessageBox.information( - self, "DDC/CI Test", + self, + "DDC/CI Test", "Did you see the screen brightness change?\n\n" "YES - DDC/CI is working correctly.\n" "NO - DDC/CI is not working. Check:\n" "\u2022 Monitor OSD: Enable DDC/CI option\n" "\u2022 Use HDMI or DisplayPort (not VGA)\n" - "\u2022 Some monitors ignore DDC brightness commands" + "\u2022 Some monitors ignore DDC brightness commands", ) except Exception as e: @@ -1428,14 +1405,15 @@ def _test_ddc_connection(self): def _auto_calibrate_d65(self): """Attempt automatic D65 white point calibration.""" QMessageBox.information( - self, "Auto-Calibrate to D65", + self, + "Auto-Calibrate to D65", "This feature requires a colorimeter (hardware sensor) to measure " "actual display output and iteratively adjust RGB gains.\n\n" "Without a colorimeter, you can manually adjust:\n" "\u2022 If image looks too warm (yellow/red): Reduce Red Gain, increase Blue Gain\n" "\u2022 If image looks too cool (blue): Reduce Blue Gain, increase Red Gain\n" "\u2022 If image looks green: Reduce Green Gain\n\n" - "Target: Neutral gray at all brightness levels" + "Target: Neutral gray at all brightness levels", ) # ========================================================================= @@ -1467,8 +1445,7 @@ def update_progress(code, total): try: # Perform the scan self._discovered_vcp_codes = self.ddc_controller.scan_all_vcp_codes( - self.current_monitor, - progress_callback=update_progress + self.current_monitor, progress_callback=update_progress ) # Populate table @@ -1535,21 +1512,17 @@ def _test_vcp_code(self, code: int, maximum: int): else: test_value = 50 if current != 50 else 0 - success, msg = self.ddc_controller.try_set_vcp( - self.current_monitor, code, test_value - ) + success, msg = self.ddc_controller.try_set_vcp(self.current_monitor, code, test_value) if success: QMessageBox.information( - self, "VCP Test", - f"VCP 0x{code:02X}: {msg}\n\n" - "If you saw a change on your monitor, this code is working!" + self, + "VCP Test", + f"VCP 0x{code:02X}: {msg}\n\nIf you saw a change on your monitor, this code is working!", ) else: QMessageBox.warning( - self, "VCP Test", - f"VCP 0x{code:02X}: {msg}\n\n" - "This code may be read-only or not fully supported." + self, "VCP Test", f"VCP 0x{code:02X}: {msg}\n\nThis code may be read-only or not fully supported." ) # Restore original value @@ -1579,9 +1552,7 @@ def _read_raw_vcp(self): code = self._parse_vcp_code(self.read_code_input.text()) current, maximum = self.ddc_controller.get_vcp(self.current_monitor, code) - self.read_result.setText( - f"Result: Current={current}, Max={maximum}" - ) + self.read_result.setText(f"Result: Current={current}, Max={maximum}") self.read_result.setStyleSheet(f"color: {COLORS['success']};") except ValueError: @@ -1601,9 +1572,7 @@ def _write_raw_vcp(self): code = self._parse_vcp_code(self.write_code_input.text()) value = int(self.write_value_input.text().strip()) - success, msg = self.ddc_controller.try_set_vcp( - self.current_monitor, code, value - ) + success, msg = self.ddc_controller.try_set_vcp(self.current_monitor, code, value) if success: self.write_result.setText(f"Result: {msg}") @@ -1636,9 +1605,7 @@ def _apply_color_preset(self): selection = self.color_preset_combo.currentText() value = int(selection.split(" - ")[0]) - success, msg = self.ddc_controller.try_set_vcp( - self.current_monitor, VCPCode.COLOR_PRESET, value - ) + success, msg = self.ddc_controller.try_set_vcp(self.current_monitor, VCPCode.COLOR_PRESET, value) if success: self.preset_status.setText(f"Status: \u2713 {msg}") @@ -1660,9 +1627,7 @@ def _read_color_preset(self): try: from calibrate_pro.hardware.ddc_ci import VCPCode - current, maximum = self.ddc_controller.get_vcp( - self.current_monitor, VCPCode.COLOR_PRESET - ) + current, maximum = self.ddc_controller.get_vcp(self.current_monitor, VCPCode.COLOR_PRESET) self.preset_status.setText(f"Status: Current preset = {current} (max: {maximum})") self.preset_status.setStyleSheet(f"color: {COLORS['text_secondary']};") @@ -1688,9 +1653,7 @@ def _apply_image_mode(self): selection = self.image_mode_combo.currentText() value = int(selection.split(" - ")[0]) - success, msg = self.ddc_controller.try_set_vcp( - self.current_monitor, VCPCode.IMAGE_MODE, value - ) + success, msg = self.ddc_controller.try_set_vcp(self.current_monitor, VCPCode.IMAGE_MODE, value) if success: self.image_mode_status.setText(f"Status: \u2713 {msg}") @@ -1712,9 +1675,7 @@ def _read_image_mode(self): try: from calibrate_pro.hardware.ddc_ci import VCPCode - current, maximum = self.ddc_controller.get_vcp( - self.current_monitor, VCPCode.IMAGE_MODE - ) + current, maximum = self.ddc_controller.get_vcp(self.current_monitor, VCPCode.IMAGE_MODE) self.image_mode_status.setText(f"Status: Current mode = {current} (max: {maximum})") self.image_mode_status.setStyleSheet(f"color: {COLORS['text_secondary']};") @@ -1740,9 +1701,7 @@ def _apply_gamma_preset(self): selection = self.gamma_combo.currentText() value = int(selection.split(" - ")[0]) - success, msg = self.ddc_controller.try_set_vcp( - self.current_monitor, VCPCode.GAMMA, value - ) + success, msg = self.ddc_controller.try_set_vcp(self.current_monitor, VCPCode.GAMMA, value) if success: self.gamma_status.setText(f"Status: \u2713 {msg}") @@ -1764,9 +1723,7 @@ def _read_gamma_preset(self): try: from calibrate_pro.hardware.ddc_ci import VCPCode - current, maximum = self.ddc_controller.get_vcp( - self.current_monitor, VCPCode.GAMMA - ) + current, maximum = self.ddc_controller.get_vcp(self.current_monitor, VCPCode.GAMMA) self.gamma_status.setText(f"Status: Current gamma = {current} (max: {maximum})") self.gamma_status.setStyleSheet(f"color: {COLORS['text_secondary']};") diff --git a/calibrate_pro/gui/pages/profiles.py b/calibrate_pro/gui/pages/profiles.py index 4fe3692..1439729 100644 --- a/calibrate_pro/gui/pages/profiles.py +++ b/calibrate_pro/gui/pages/profiles.py @@ -34,6 +34,7 @@ # Profile Data + def _scan_profiles() -> list[dict]: """ Scan the calibrations directory for .cube / .icc pairs. @@ -72,15 +73,17 @@ def _scan_profiles() -> list[dict]: if mod_time is None or icc_mod > mod_time: mod_time = icc_mod - profiles.append({ - "name": stem.replace("_", " ").replace("-", " — ", 1), - "stem": stem, - "cube_path": cube, - "icc_path": icc_path, - "cube_size": cube_stat.st_size if cube_stat else 0, - "icc_size": icc_stat.st_size if icc_stat else 0, - "modified": mod_time, - }) + profiles.append( + { + "name": stem.replace("_", " ").replace("-", " — ", 1), + "stem": stem, + "cube_path": cube, + "icc_path": icc_path, + "cube_size": cube_stat.st_size if cube_stat else 0, + "icc_size": icc_stat.st_size if icc_stat else 0, + "modified": mod_time, + } + ) # Also pick up .icc files that have no matching .cube for icc in sorted(CALIBRATIONS_DIR.glob("*.icc")): @@ -88,15 +91,17 @@ def _scan_profiles() -> list[dict]: seen_stems.add(icc.stem) icc_stat = icc.stat() mod_time = datetime.fromtimestamp(icc_stat.st_mtime) - profiles.append({ - "name": icc.stem.replace("_", " ").replace("-", " — ", 1), - "stem": icc.stem, - "cube_path": None, - "icc_path": icc, - "cube_size": 0, - "icc_size": icc_stat.st_size, - "modified": mod_time, - }) + profiles.append( + { + "name": icc.stem.replace("_", " ").replace("-", " — ", 1), + "stem": icc.stem, + "cube_path": None, + "icc_path": icc, + "cube_size": 0, + "icc_size": icc_stat.st_size, + "modified": mod_time, + } + ) return profiles @@ -114,6 +119,7 @@ def _format_size(size_bytes: int) -> str: # Profile Card Widget + class ProfileCard(Card): """Card showing a single calibration profile.""" @@ -135,9 +141,7 @@ def __init__(self, profile: dict, is_active: bool = False, parent=None): top.setSpacing(10) name_label = QLabel(profile["name"]) - name_label.setStyleSheet( - f"font-size: 14px; font-weight: 500; color: {C.TEXT};" - ) + name_label.setStyleSheet(f"font-size: 14px; font-weight: 500; color: {C.TEXT};") top.addWidget(name_label) if is_active: @@ -178,31 +182,19 @@ def __init__(self, profile: dict, is_active: bool = False, parent=None): files_row.setSpacing(4) if profile.get("cube_path"): - cube_label = QLabel( - f".cube {_format_size(profile['cube_size'])} \u2014 " - f"{profile['cube_path']}" - ) + cube_label = QLabel(f".cube {_format_size(profile['cube_size'])} \u2014 {profile['cube_path']}") cube_label.setStyleSheet( - f"font-size: 10px; color: {C.TEXT3}; " - f"font-family: 'Cascadia Code', 'Consolas', monospace;" - ) - cube_label.setTextInteractionFlags( - Qt.TextInteractionFlag.TextSelectableByMouse + f"font-size: 10px; color: {C.TEXT3}; font-family: 'Cascadia Code', 'Consolas', monospace;" ) + cube_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) files_row.addWidget(cube_label) if profile.get("icc_path"): - icc_label = QLabel( - f".icc {_format_size(profile['icc_size'])} \u2014 " - f"{profile['icc_path']}" - ) + icc_label = QLabel(f".icc {_format_size(profile['icc_size'])} \u2014 {profile['icc_path']}") icc_label.setStyleSheet( - f"font-size: 10px; color: {C.TEXT3}; " - f"font-family: 'Cascadia Code', 'Consolas', monospace;" - ) - icc_label.setTextInteractionFlags( - Qt.TextInteractionFlag.TextSelectableByMouse + f"font-size: 10px; color: {C.TEXT3}; font-family: 'Cascadia Code', 'Consolas', monospace;" ) + icc_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) files_row.addWidget(icc_label) if not profile.get("cube_path") and not profile.get("icc_path"): @@ -273,16 +265,15 @@ def _on_activate(self): if cube and cube.exists(): from calibrate_pro.lut_system.dwm_lut import load_lut + load_lut(str(cube), display_index=0) if icc and icc.exists(): from calibrate_pro.panels.detection import install_profile + install_profile(str(icc)) - QMessageBox.information( - self, "Profile Activated", - f"Activated: {self._profile['name']}" - ) + QMessageBox.information(self, "Profile Activated", f"Activated: {self._profile['name']}") except Exception as e: QMessageBox.warning(self, "Activation Error", str(e)) @@ -293,22 +284,21 @@ def _on_export(self): return try: import shutil + dest_path = Path(dest) for key in ("cube_path", "icc_path"): src = self._profile.get(key) if src and src.exists(): shutil.copy2(str(src), str(dest_path / src.name)) - QMessageBox.information( - self, "Exported", - f"Profile exported to {dest_path}" - ) + QMessageBox.information(self, "Exported", f"Profile exported to {dest_path}") except Exception as e: QMessageBox.warning(self, "Export Error", str(e)) def _on_delete(self): """Delete profile files after confirmation.""" reply = QMessageBox.question( - self, "Delete Profile", + self, + "Delete Profile", f"Delete '{self._profile['name']}' and its files?\n\nThis cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) @@ -328,6 +318,7 @@ def _on_delete(self): # Profiles Page + class ProfilesPage(QWidget): """Profile management page.""" @@ -411,6 +402,7 @@ def _populate(self): active_stem: str | None = None try: from calibrate_pro.utils.startup_manager import StartupManager + mgr = StartupManager() cal = mgr.get_display_calibration(0) if cal and cal.lut_path: @@ -419,7 +411,7 @@ def _populate(self): pass for profile in profiles: - is_active = (active_stem is not None and profile["stem"] == active_stem) + is_active = active_stem is not None and profile["stem"] == active_stem card = ProfileCard(profile, is_active=is_active) card.clicked.connect(self._show_detail) self._cards_layout.addWidget(card) @@ -446,9 +438,7 @@ def _show_detail(self, profile: dict): header_row.setSpacing(12) name_label = QLabel(profile["name"]) - name_label.setStyleSheet( - f"font-size: 16px; font-weight: 600; color: {C.TEXT};" - ) + name_label.setStyleSheet(f"font-size: 16px; font-weight: 600; color: {C.TEXT};") header_row.addWidget(name_label) header_row.addStretch() @@ -472,6 +462,7 @@ def _show_detail(self, profile: dict): # CIE Diagram try: from calibrate_pro.gui.widgets.cie_diagram import CIEDiagramWidget + cie = CIEDiagramWidget() cie.setFixedSize(350, 350) @@ -479,6 +470,7 @@ def _show_detail(self, profile: dict): try: from calibrate_pro.panels.database import PanelDatabase from calibrate_pro.panels.detection import enumerate_displays, identify_display + db = PanelDatabase() displays = enumerate_displays() if displays: @@ -533,9 +525,7 @@ def _show_detail(self, profile: dict): has_cube = profile.get("cube_path") is not None has_icc = profile.get("icc_path") is not None - files_str = ", ".join( - f for f, present in [(".cube", has_cube), (".icc", has_icc)] if present - ) or "None" + files_str = ", ".join(f for f, present in [(".cube", has_cube), (".icc", has_icc)] if present) or "None" stat_data.append(("Files", files_str)) for label, value in stat_data: @@ -550,7 +540,9 @@ def _show_detail(self, profile: dict): def _rename_profile(self, profile: dict): """Open a dialog to rename the profile files.""" new_name, ok = QInputDialog.getText( - self, "Rename Profile", "New name:", + self, + "Rename Profile", + "New name:", text=profile["name"], ) if not ok or not new_name.strip(): @@ -576,9 +568,7 @@ def _show_empty_state(self): msg.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(msg) - hint = QLabel( - "Run 'Calibrate' to create your first profile." - ) + hint = QLabel("Run 'Calibrate' to create your first profile.") hint.setStyleSheet(f"font-size: 12px; color: {C.TEXT3};") hint.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(hint) @@ -588,7 +578,8 @@ def _show_empty_state(self): def _generate_all(self): """Generate profiles for sRGB, P3, BT.709, and Adobe RGB.""" reply = QMessageBox.question( - self, "Generate All Profiles", + self, + "Generate All Profiles", "Generate calibration profiles for:\n\n" " - sRGB\n" " - Display P3\n" @@ -604,16 +595,14 @@ def _generate_all(self): CALIBRATIONS_DIR.mkdir(parents=True, exist_ok=True) from calibrate_pro.calibration.engine import CalibrationEngine + engine = CalibrationEngine() for gamut in TARGET_GAMUTS: try: engine.calibrate(target_gamut=gamut, output_dir=str(CALIBRATIONS_DIR)) except Exception as e: - QMessageBox.warning( - self, "Generation Error", - f"Failed to generate {gamut} profile:\n{e}" - ) + QMessageBox.warning(self, "Generation Error", f"Failed to generate {gamut} profile:\n{e}") self._populate() diff --git a/calibrate_pro/gui/pages/profiles_page.py b/calibrate_pro/gui/pages/profiles_page.py index 9c550a1..87f3191 100644 --- a/calibrate_pro/gui/pages/profiles_page.py +++ b/calibrate_pro/gui/pages/profiles_page.py @@ -53,6 +53,7 @@ def _init_color_loader(self): """Initialize the color loader for applying profiles.""" try: from calibrate_pro.lut_system.color_loader import get_color_loader + self.color_loader = get_color_loader() except Exception as e: print(f"Could not initialize color loader: {e}") @@ -250,9 +251,9 @@ def _populate_displays(self): self.display_combo.clear() screens = QGuiApplication.screens() for i, screen in enumerate(screens): - name = screen.name() or f"Display {i+1}" + name = screen.name() or f"Display {i + 1}" geo = screen.geometry() - self.display_combo.addItem(f"Display {i+1}: {name} ({geo.width()}x{geo.height()})") + self.display_combo.addItem(f"Display {i + 1}: {name} ({geo.width()}x{geo.height()})") def _update_display_status(self): """Update the per-display status widgets.""" @@ -270,7 +271,7 @@ def _update_display_status(self): frame = QFrame() frame.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; + background-color: {COLORS["surface"]}; border-radius: 8px; padding: 8px; }} @@ -285,15 +286,15 @@ def _update_display_status(self): if is_active: active_count += 1 - status_color = COLORS['success'] + status_color = COLORS["success"] status_text = "ACTIVE" profile_name = Path(active_profile).stem if active_profile else "" else: - status_color = COLORS['text_disabled'] + status_color = COLORS["text_disabled"] status_text = "Inactive" profile_name = "No profile" - name_label = QLabel(screen.name() or f"Display {i+1}") + name_label = QLabel(screen.name() or f"Display {i + 1}") name_label.setStyleSheet("font-weight: 600;") frame_layout.addWidget(name_label) @@ -330,6 +331,7 @@ def _on_selection_changed(self): return from pathlib import Path + path = Path(profile_path) self.detail_name.setText(path.name) @@ -347,11 +349,11 @@ def _on_selection_changed(self): self.detail_status.setStyleSheet(f"color: {COLORS['text_secondary']};") # Profile type - if path.suffix.lower() in ('.icc', '.icm'): + if path.suffix.lower() in (".icc", ".icm"): self.detail_type.setText("ICC Profile") - elif path.suffix.lower() == '.cube': + elif path.suffix.lower() == ".cube": self.detail_type.setText("3D LUT (.cube)") - elif path.suffix.lower() == '.3dl': + elif path.suffix.lower() == ".3dl": self.detail_type.setText("3D LUT (.3dl)") else: self.detail_type.setText("Unknown") @@ -369,7 +371,7 @@ def _on_selection_changed(self): self.detail_size.setText("-") # Check for associated LUT - lut_path = path.with_suffix('.cube') + lut_path = path.with_suffix(".cube") if lut_path.exists(): self.detail_lut.setText(f"Yes ({lut_path.name})") self.detail_lut.setStyleSheet(f"color: {COLORS['success']};") @@ -390,6 +392,7 @@ def _activate_profile(self): display_idx = self.display_combo.currentIndex() from pathlib import Path + path = Path(profile_path) try: @@ -398,15 +401,15 @@ def _activate_profile(self): success = False # Try ICC profile first - if path.suffix.lower() in ('.icc', '.icm'): + if path.suffix.lower() in (".icc", ".icm"): success = self.color_loader.load_icc_profile(display_idx, str(path)) # Also try to load associated .cube LUT - lut_path = path.with_suffix('.cube') + lut_path = path.with_suffix(".cube") if lut_path.exists(): self.color_loader.load_lut_file(display_idx, str(lut_path)) - elif path.suffix.lower() in ('.cube', '.3dl'): + elif path.suffix.lower() in (".cube", ".3dl"): success = self.color_loader.load_lut_file(display_idx, str(path)) if success: @@ -419,25 +422,26 @@ def _activate_profile(self): settings.sync() QMessageBox.information( - self, "Profile Activated", + self, + "Profile Activated", f"Color profile activated for Display {display_idx + 1}!\n\n" f"Profile: {path.name}\n\n" "You should see visible changes to your display colors.\n" - "The VCGT gamma curves have been applied." + "The VCGT gamma curves have been applied.", ) self._update_display_status() self._on_selection_changed() else: QMessageBox.warning( - self, "Activation Failed", + self, + "Activation Failed", "Could not activate profile. The profile may not contain VCGT data,\n" - "or the system could not apply the gamma ramp." + "or the system could not apply the gamma ramp.", ) else: QMessageBox.warning( - self, "Color Loader Unavailable", - "The color loader is not available. Cannot apply profiles." + self, "Color Loader Unavailable", "The color loader is not available. Cannot apply profiles." ) except Exception as e: @@ -448,10 +452,11 @@ def _deactivate_profile(self): display_idx = self.display_combo.currentIndex() reply = QMessageBox.question( - self, "Deactivate Profile", + self, + "Deactivate Profile", f"Remove color correction from Display {display_idx + 1}?\n\n" "This will reset the display to linear gamma (no correction).", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: @@ -467,9 +472,10 @@ def _deactivate_profile(self): settings.sync() QMessageBox.information( - self, "Profile Deactivated", + self, + "Profile Deactivated", f"Color management disabled for Display {display_idx + 1}.\n\n" - "Display is now using linear gamma (no correction)." + "Display is now using linear gamma (no correction).", ) self._update_display_status() @@ -481,10 +487,10 @@ def _deactivate_profile(self): def _reset_all_displays(self): """Reset all displays to linear gamma.""" reply = QMessageBox.question( - self, "Reset All Displays", - "Remove all color corrections from all displays?\n\n" - "This will reset all displays to linear gamma.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + self, + "Reset All Displays", + "Remove all color corrections from all displays?\n\nThis will reset all displays to linear gamma.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: @@ -502,9 +508,7 @@ def _reset_all_displays(self): settings.sync() QMessageBox.information( - self, "Reset Complete", - "All displays reset to linear gamma.\n\n" - "No color correction is active." + self, "Reset Complete", "All displays reset to linear gamma.\n\nNo color correction is active." ) self._update_display_status() @@ -520,9 +524,10 @@ def _reload_active_profiles(self): success_count = sum(1 for v in results.values() if v) QMessageBox.information( - self, "Profiles Reloaded", + self, + "Profiles Reloaded", f"Reloaded {success_count} active profile(s).\n\n" - "This is useful if another application has overridden your color settings." + "This is useful if another application has overridden your color settings.", ) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to reload:\n\n{str(e)}") @@ -533,7 +538,7 @@ def _import_profile(self): self, "Import Profile or LUT", str(Path.home()), - "Color Files (*.icc *.icm *.cube *.3dl);;ICC Profiles (*.icc *.icm);;3D LUTs (*.cube *.3dl)" + "Color Files (*.icc *.icm *.cube *.3dl);;ICC Profiles (*.icc *.icm);;3D LUTs (*.cube *.3dl)", ) if not file_path: @@ -542,16 +547,14 @@ def _import_profile(self): # Copy to profiles directory try: from shutil import copy2 + profiles_dir = Path.home() / ".calibrate_pro" / "profiles" profiles_dir.mkdir(parents=True, exist_ok=True) dest = profiles_dir / Path(file_path).name copy2(file_path, dest) - QMessageBox.information( - self, "Profile Imported", - f"Profile imported successfully:\n\n{dest.name}" - ) + QMessageBox.information(self, "Profile Imported", f"Profile imported successfully:\n\n{dest.name}") self._refresh_profiles() @@ -568,7 +571,7 @@ def _select_none_profiles(self): def _select_custom_profiles(self): """Select only custom (non-system) profiles.""" - SYSTEM_PROFILES = {'srgb color space profile.icm', 'rswop.icm', 'wscrgb.icc', 'wsrgb.icc'} + SYSTEM_PROFILES = {"srgb color space profile.icm", "rswop.icm", "wscrgb.icc", "wsrgb.icc"} self.profile_list.clearSelection() for i in range(self.profile_list.count()): @@ -576,6 +579,7 @@ def _select_custom_profiles(self): profile_path = item.data(Qt.ItemDataRole.UserRole) if profile_path: from pathlib import Path + name = Path(profile_path).name.lower() if name not in SYSTEM_PROFILES: item.setSelected(True) @@ -584,11 +588,15 @@ def _delete_selected_profiles(self): """Delete all selected profiles.""" selected_items = self.profile_list.selectedItems() if not selected_items: - QMessageBox.warning(self, "No Selection", "Please select profiles to delete.\n\nTip: Use Ctrl+Click or Shift+Click to select multiple profiles.") + QMessageBox.warning( + self, + "No Selection", + "Please select profiles to delete.\n\nTip: Use Ctrl+Click or Shift+Click to select multiple profiles.", + ) return # System profiles that should not be deleted - SYSTEM_PROFILES = {'srgb color space profile.icm', 'rswop.icm', 'wscrgb.icc', 'wsrgb.icc'} + SYSTEM_PROFILES = {"srgb color space profile.icm", "rswop.icm", "wscrgb.icc", "wsrgb.icc"} from pathlib import Path @@ -606,8 +614,9 @@ def _delete_selected_profiles(self): profiles_to_delete.append(path) if not profiles_to_delete: - QMessageBox.warning(self, "No Deletable Profiles", - "All selected profiles are system profiles and cannot be deleted.") + QMessageBox.warning( + self, "No Deletable Profiles", "All selected profiles are system profiles and cannot be deleted." + ) return # Confirm deletion @@ -626,9 +635,7 @@ def _delete_selected_profiles(self): msg += "\n\nThis cannot be undone." reply = QMessageBox.question( - self, "Delete Profiles", - msg, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + self, "Delete Profiles", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: @@ -644,7 +651,7 @@ def _delete_selected_profiles(self): deleted += 1 # Also delete associated LUT if present - lut_path = path.with_suffix('.cube') + lut_path = path.with_suffix(".cube") if lut_path.exists(): lut_path.unlink() @@ -695,30 +702,33 @@ def _refresh_profiles(self): for source_name, dir_path in profile_dirs: try: for file_path in dir_path.iterdir(): - if file_path.suffix.lower() in ('.icc', '.icm'): + if file_path.suffix.lower() in (".icc", ".icm"): # Get file info stat = file_path.stat() mod_time = stat.st_mtime from datetime import datetime + mod_date = datetime.fromtimestamp(mod_time).strftime("%b %d, %Y") - profiles_found.append({ - 'name': file_path.name, - 'path': file_path, - 'source': source_name, - 'date': mod_date, - 'size': stat.st_size - }) + profiles_found.append( + { + "name": file_path.name, + "path": file_path, + "source": source_name, + "date": mod_date, + "size": stat.st_size, + } + ) except (PermissionError, OSError): continue # Sort by modification time (newest first) - profiles_found.sort(key=lambda x: x['path'].stat().st_mtime, reverse=True) + profiles_found.sort(key=lambda x: x["path"].stat().st_mtime, reverse=True) # Add to list widget for profile in profiles_found: item = QListWidgetItem(f"{profile['name']}\n{profile['source']} - {profile['date']}") - item.setData(Qt.ItemDataRole.UserRole, str(profile['path'])) + item.setData(Qt.ItemDataRole.UserRole, str(profile["path"])) self.profile_list.addItem(item) if not profiles_found: @@ -739,6 +749,7 @@ def _rename_profile(self): return from pathlib import Path + profile_path = Path(profile_path) if not profile_path.exists(): @@ -749,11 +760,7 @@ def _rename_profile(self): # Get new name from user old_name = profile_path.stem new_name, ok = QInputDialog.getText( - self, - "Rename Profile", - f"Enter new name for '{old_name}':", - QLineEdit.EchoMode.Normal, - old_name + self, "Rename Profile", f"Enter new name for '{old_name}':", QLineEdit.EchoMode.Normal, old_name ) if not ok or not new_name.strip(): @@ -764,7 +771,7 @@ def _rename_profile(self): # Sanitize the name invalid_chars = '<>:"/\\|?*' for char in invalid_chars: - new_name = new_name.replace(char, '_') + new_name = new_name.replace(char, "_") if new_name == old_name: return @@ -775,8 +782,7 @@ def _rename_profile(self): # Check if target already exists if new_profile_path.exists(): QMessageBox.warning( - self, "File Exists", - f"A profile named '{new_name}{profile_path.suffix}' already exists." + self, "File Exists", f"A profile named '{new_name}{profile_path.suffix}' already exists." ) return @@ -785,20 +791,19 @@ def _rename_profile(self): profile_path.rename(new_profile_path) # Also rename associated .cube LUT file if it exists - lut_path = profile_path.with_suffix('.cube') + lut_path = profile_path.with_suffix(".cube") if lut_path.exists(): - new_lut_path = new_profile_path.with_suffix('.cube') + new_lut_path = new_profile_path.with_suffix(".cube") lut_path.rename(new_lut_path) # Also check for .3dl - lut_3dl_path = profile_path.with_suffix('.3dl') + lut_3dl_path = profile_path.with_suffix(".3dl") if lut_3dl_path.exists(): - new_lut_3dl_path = new_profile_path.with_suffix('.3dl') + new_lut_3dl_path = new_profile_path.with_suffix(".3dl") lut_3dl_path.rename(new_lut_3dl_path) QMessageBox.information( - self, "Profile Renamed", - f"Profile renamed successfully:\n\n{old_name} \u2192 {new_name}" + self, "Profile Renamed", f"Profile renamed successfully:\n\n{old_name} \u2192 {new_name}" ) # Refresh the list @@ -806,11 +811,9 @@ def _rename_profile(self): except PermissionError: QMessageBox.critical( - self, "Permission Denied", - "Cannot rename this profile. It may be in use or require administrator privileges." + self, + "Permission Denied", + "Cannot rename this profile. It may be in use or require administrator privileges.", ) except Exception as e: - QMessageBox.critical( - self, "Rename Failed", - f"Failed to rename profile:\n\n{str(e)}" - ) + QMessageBox.critical(self, "Rename Failed", f"Failed to rename profile:\n\n{str(e)}") diff --git a/calibrate_pro/gui/pages/settings.py b/calibrate_pro/gui/pages/settings.py index 6c436f1..bab2c72 100644 --- a/calibrate_pro/gui/pages/settings.py +++ b/calibrate_pro/gui/pages/settings.py @@ -37,17 +37,15 @@ APP_NAME = "Calibrate Pro" APP_VERSION = "1.0.0" -DEFAULT_OUTPUT_DIR = str( - Path.home() / "Documents" / "Calibrate Pro" / "Calibrations" -) +DEFAULT_OUTPUT_DIR = str(Path.home() / "Documents" / "Calibrate Pro" / "Calibrations") DEFAULT_APP_RULES = [ - {"pattern": "chrome.exe", "profile": "sRGB", "action": "apply"}, - {"pattern": "firefox.exe", "profile": "sRGB", "action": "apply"}, - {"pattern": "msedge.exe", "profile": "sRGB", "action": "apply"}, - {"pattern": "resolve*", "profile": "Native", "action": "apply"}, - {"pattern": "Photoshop*", "profile": "sRGB", "action": "apply"}, - {"pattern": "Lightroom*", "profile": "sRGB", "action": "apply"}, + {"pattern": "chrome.exe", "profile": "sRGB", "action": "apply"}, + {"pattern": "firefox.exe", "profile": "sRGB", "action": "apply"}, + {"pattern": "msedge.exe", "profile": "sRGB", "action": "apply"}, + {"pattern": "resolve*", "profile": "Native", "action": "apply"}, + {"pattern": "Photoshop*", "profile": "sRGB", "action": "apply"}, + {"pattern": "Lightroom*", "profile": "sRGB", "action": "apply"}, ] PROFILE_CHOICES = ["sRGB", "Native", "Display P3"] @@ -56,6 +54,7 @@ # Helpers + def _detect_argyll_path() -> str: """Try to find ArgyllCMS on the system PATH.""" argyll = shutil.which("spotread") or shutil.which("dispcal") @@ -75,10 +74,7 @@ def _detect_argyll_path() -> str: def _make_section_heading(text: str) -> QLabel: """Create a styled section heading label.""" label = QLabel(text) - label.setStyleSheet( - f"font-size: 14px; font-weight: 500; color: {C.ACCENT_TX}; " - f"padding-top: 6px;" - ) + label.setStyleSheet(f"font-size: 14px; font-weight: 500; color: {C.ACCENT_TX}; padding-top: 6px;") return label @@ -131,6 +127,7 @@ def _browse(): # Settings Page + class SettingsPage(QWidget): """Application settings page.""" @@ -169,15 +166,10 @@ def _build(self): # Start with Windows self._startup_cb = QCheckBox("Start with Windows") self._startup_cb.setToolTip( - "Launch Calibrate Pro at login to maintain calibration.\n" - "Reapplies your LUT and ICC profile automatically." - ) - self._startup_cb.setChecked( - self._settings.value("general/start_with_windows", False, type=bool) - ) - self._startup_cb.toggled.connect( - lambda v: self._settings.setValue("general/start_with_windows", v) + "Launch Calibrate Pro at login to maintain calibration.\nReapplies your LUT and ICC profile automatically." ) + self._startup_cb.setChecked(self._settings.value("general/start_with_windows", False, type=bool)) + self._startup_cb.toggled.connect(lambda v: self._settings.setValue("general/start_with_windows", v)) form_general.addRow("", self._startup_cb) # Minimize to tray @@ -186,12 +178,8 @@ def _build(self): "Keep Calibrate Pro running in the system tray when closed.\n" "The calibration guard continues protecting your settings." ) - self._tray_cb.setChecked( - self._settings.value("general/minimize_to_tray", True, type=bool) - ) - self._tray_cb.toggled.connect( - lambda v: self._settings.setValue("general/minimize_to_tray", v) - ) + self._tray_cb.setChecked(self._settings.value("general/minimize_to_tray", True, type=bool)) + self._tray_cb.toggled.connect(lambda v: self._settings.setValue("general/minimize_to_tray", v)) form_general.addRow("", self._tray_cb) # Default calibration target @@ -203,9 +191,7 @@ def _build(self): idx = self._target_combo.findText(saved_target) if idx >= 0: self._target_combo.setCurrentIndex(idx) - self._target_combo.currentTextChanged.connect( - lambda t: self._settings.setValue("general/default_target", t) - ) + self._target_combo.currentTextChanged.connect(lambda t: self._settings.setValue("general/default_target", t)) form_general.addRow(target_label, self._target_combo) general_layout.addLayout(form_general) @@ -236,9 +222,7 @@ def _build(self): idx = self._lut_combo.findText(saved_lut) if idx >= 0: self._lut_combo.setCurrentIndex(idx) - self._lut_combo.currentTextChanged.connect( - lambda t: self._settings.setValue("calibration/lut_size", t) - ) + self._lut_combo.currentTextChanged.connect(lambda t: self._settings.setValue("calibration/lut_size", t)) form_cal.addRow(lut_label, self._lut_combo) # OLED compensation @@ -248,12 +232,8 @@ def _build(self): "compensation for OLED and QD-OLED panels.\n" "Auto-enabled for detected OLED displays." ) - self._oled_cb.setChecked( - self._settings.value("calibration/oled_compensation", False, type=bool) - ) - self._oled_cb.toggled.connect( - lambda v: self._settings.setValue("calibration/oled_compensation", v) - ) + self._oled_cb.setChecked(self._settings.value("calibration/oled_compensation", False, type=bool)) + self._oled_cb.toggled.connect(lambda v: self._settings.setValue("calibration/oled_compensation", v)) form_cal.addRow("", self._oled_cb) # HDR mode @@ -263,12 +243,8 @@ def _build(self): "Uses JzAzBz perceptual space and BT.2390 EETF\n" "for tone mapping. Requires HDR enabled in Windows." ) - self._hdr_cb.setChecked( - self._settings.value("calibration/hdr_mode", False, type=bool) - ) - self._hdr_cb.toggled.connect( - lambda v: self._settings.setValue("calibration/hdr_mode", v) - ) + self._hdr_cb.setChecked(self._settings.value("calibration/hdr_mode", False, type=bool)) + self._hdr_cb.toggled.connect(lambda v: self._settings.setValue("calibration/hdr_mode", v)) form_cal.addRow("", self._hdr_cb) cal_layout.addLayout(form_cal) @@ -286,12 +262,8 @@ def _build(self): "foreground application changes (e.g. sRGB for browsers,\n" "Native for games, Display P3 for creative apps)." ) - self._app_switch_cb.setChecked( - self._settings.value("app_switcher/enabled", False, type=bool) - ) - self._app_switch_cb.toggled.connect( - lambda v: self._settings.setValue("app_switcher/enabled", v) - ) + self._app_switch_cb.setChecked(self._settings.value("app_switcher/enabled", False, type=bool)) + self._app_switch_cb.toggled.connect(lambda v: self._settings.setValue("app_switcher/enabled", v)) app_layout.addWidget(self._app_switch_cb) # --- Rules table --- @@ -299,23 +271,13 @@ def _build(self): self._rules_table.setColumnCount(3) self._rules_table.setHorizontalHeaderLabels(["App Pattern", "Profile", "Action"]) self._rules_table.horizontalHeader().setStretchLastSection(False) - self._rules_table.horizontalHeader().setSectionResizeMode( - 0, QHeaderView.ResizeMode.Stretch - ) - self._rules_table.horizontalHeader().setSectionResizeMode( - 1, QHeaderView.ResizeMode.Fixed - ) - self._rules_table.horizontalHeader().setSectionResizeMode( - 2, QHeaderView.ResizeMode.Fixed - ) + self._rules_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self._rules_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) + self._rules_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) self._rules_table.setColumnWidth(1, 120) self._rules_table.setColumnWidth(2, 100) - self._rules_table.setSelectionBehavior( - QAbstractItemView.SelectionBehavior.SelectRows - ) - self._rules_table.setSelectionMode( - QAbstractItemView.SelectionMode.SingleSelection - ) + self._rules_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self._rules_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self._rules_table.verticalHeader().setVisible(False) self._rules_table.setStyleSheet( f"QTableWidget {{" @@ -429,12 +391,11 @@ def _build(self): # Panel profiles directory profiles_label = QLabel("Panel profiles directory") profiles_label.setStyleSheet(f"font-size: 12px; color: {C.TEXT};") - profiles_default = str( - Path(__file__).parent.parent.parent / "calibrate_pro" / "panels" / "profiles" - ) + profiles_default = str(Path(__file__).parent.parent.parent / "calibrate_pro" / "panels" / "profiles") # Resolve to absolute path try: from calibrate_pro.panels.database import PanelDatabase + profiles_default = str(PanelDatabase().profiles_dir.resolve()) except Exception: pass @@ -449,9 +410,7 @@ def _build(self): profiles_container.setLayout(profiles_row) form_paths.addRow(profiles_label, profiles_container) - profiles_note = QLabel( - "Place community .json panel files here to add display support" - ) + profiles_note = QLabel("Place community .json panel files here to add display support") profiles_note.setStyleSheet(f"font-size: 10px; color: {C.TEXT3}; font-style: italic;") form_paths.addRow("", profiles_note) @@ -464,9 +423,7 @@ def _build(self): about_card, about_layout = Card.with_layout(spacing=10) version_label = QLabel(f"{APP_NAME} v{APP_VERSION}") - version_label.setStyleSheet( - f"font-size: 15px; font-weight: 600; color: {C.TEXT};" - ) + version_label.setStyleSheet(f"font-size: 15px; font-weight: 600; color: {C.TEXT};") about_layout.addWidget(version_label) subtitle = QLabel("Professional display calibration for Windows") @@ -538,9 +495,7 @@ def _load_app_rules(self): rule.get("action", "apply"), ) - def _insert_rule_row( - self, pattern: str = "*.exe", profile: str = "sRGB", action: str = "apply" - ): + def _insert_rule_row(self, pattern: str = "*.exe", profile: str = "sRGB", action: str = "apply"): """Insert a single rule row into the table.""" row = self._rules_table.rowCount() self._rules_table.insertRow(row) @@ -585,11 +540,13 @@ def _save_app_rules(self): if pattern_item is None or profile_widget is None or action_widget is None: continue - rules.append({ - "pattern": pattern_item.text(), - "profile": profile_widget.currentText(), - "action": action_widget.currentText().lower(), - }) + rules.append( + { + "pattern": pattern_item.text(), + "profile": profile_widget.currentText(), + "action": action_widget.currentText().lower(), + } + ) self._settings.setValue("app_switcher/rules", json.dumps(rules)) diff --git a/calibrate_pro/gui/pages/settings_page.py b/calibrate_pro/gui/pages/settings_page.py index a93669b..007ba2c 100644 --- a/calibrate_pro/gui/pages/settings_page.py +++ b/calibrate_pro/gui/pages/settings_page.py @@ -159,7 +159,9 @@ def _create_hardware_tab(self) -> QWidget: # Colorimeter colorimeter_combo = QComboBox() - colorimeter_combo.addItems(["Auto-detect", "i1Display Pro", "Spyder X", "ColorChecker Display", "None (Sensorless only)"]) + colorimeter_combo.addItems( + ["Auto-detect", "i1Display Pro", "Spyder X", "ColorChecker Display", "None (Sensorless only)"] + ) layout.addRow("Colorimeter:", colorimeter_combo) # Correction matrix diff --git a/calibrate_pro/gui/pages/vcgt_tools_page.py b/calibrate_pro/gui/pages/vcgt_tools_page.py index 563afb4..6073412 100644 --- a/calibrate_pro/gui/pages/vcgt_tools_page.py +++ b/calibrate_pro/gui/pages/vcgt_tools_page.py @@ -84,12 +84,14 @@ def _setup_ui(self): settings_layout = QFormLayout(settings_group) self.method_combo = QComboBox() - self.method_combo.addItems([ - "Neutral Axis (grayscale extraction)", - "Channel Maximum (preserve saturation)", - "Luminance Weighted (perceptual)", - "Diagonal Average" - ]) + self.method_combo.addItems( + [ + "Neutral Axis (grayscale extraction)", + "Channel Maximum (preserve saturation)", + "Luminance Weighted (perceptual)", + "Diagonal Average", + ] + ) settings_layout.addRow("Extraction Method:", self.method_combo) self.output_size = QComboBox() @@ -155,14 +157,16 @@ def _setup_ui(self): curve_preview = QFrame() curve_preview.setMinimumHeight(300) curve_preview.setStyleSheet(f""" - background-color: {COLORS['surface_alt']}; + background-color: {COLORS["surface_alt"]}; border-radius: 8px; - border: 1px solid {COLORS['border']}; + border: 1px solid {COLORS["border"]}; """) - curve_info = QLabel("Load a LUT file to preview the VCGT curves.\n\n" - "Red = Red channel\nGreen = Green channel\nBlue = Blue channel\n" - "Gray = Neutral diagonal") + curve_info = QLabel( + "Load a LUT file to preview the VCGT curves.\n\n" + "Red = Red channel\nGreen = Green channel\nBlue = Blue channel\n" + "Gray = Neutral diagonal" + ) curve_info.setStyleSheet(f"color: {COLORS['text_secondary']}; padding: 16px;") curve_info.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -197,10 +201,7 @@ def _setup_ui(self): def _browse_lut(self): """Browse for a LUT file.""" file_path, _ = QFileDialog.getOpenFileName( - self, - "Select 3D LUT File", - "", - "LUT Files (*.cube *.3dl *.mga);;All Files (*.*)" + self, "Select 3D LUT File", "", "LUT Files (*.cube *.3dl *.mga);;All Files (*.*)" ) if file_path: self.lut_path.setText(file_path) @@ -210,9 +211,10 @@ def _load_lut_info(self, file_path: str): """Load and display LUT information.""" try: from pathlib import Path + path = Path(file_path) - if path.suffix.lower() == '.cube': + if path.suffix.lower() == ".cube": # Parse CUBE file header with open(path) as f: lines = f.readlines()[:20] @@ -260,7 +262,7 @@ def _convert_to_vcgt(self): "Neutral Axis (grayscale extraction)": "neutral_axis", "Channel Maximum (preserve saturation)": "channel_max", "Luminance Weighted (perceptual)": "luminance", - "Diagonal Average": "diagonal" + "Diagonal Average": "diagonal", } method = method_map.get(self.method_combo.currentText(), "neutral_axis") @@ -288,19 +290,21 @@ def _convert_to_vcgt(self): # Update stats import numpy as np + linear = np.linspace(0, 1, vcgt.size) self.stats_max_r.setText(f"{np.max(np.abs(vcgt.red - linear)):.4f}") self.stats_max_g.setText(f"{np.max(np.abs(vcgt.green - linear)):.4f}") self.stats_max_b.setText(f"{np.max(np.abs(vcgt.blue - linear)):.4f}") - avg_dev = np.mean([np.mean(np.abs(vcgt.red - linear)), - np.mean(np.abs(vcgt.green - linear)), - np.mean(np.abs(vcgt.blue - linear))]) + avg_dev = np.mean( + [ + np.mean(np.abs(vcgt.red - linear)), + np.mean(np.abs(vcgt.green - linear)), + np.mean(np.abs(vcgt.blue - linear)), + ] + ) self.stats_avg.setText(f"{avg_dev:.4f}") - QMessageBox.information( - self, "Conversion Complete", - "VCGT curves exported to:\n\n" + "\n".join(exported) - ) + QMessageBox.information(self, "Conversion Complete", "VCGT curves exported to:\n\n" + "\n".join(exported)) except Exception as e: QMessageBox.critical(self, "Conversion Error", str(e)) @@ -309,28 +313,25 @@ def _apply_vcgt(self): """Apply VCGT to current display via Windows gamma ramp API.""" lut_path = self.lut_path.text() if not lut_path: - QMessageBox.warning( - self, "No LUT", - "Please select and convert a 3D LUT file first." - ) + QMessageBox.warning(self, "No LUT", "Please select and convert a 3D LUT file first.") return # Confirm with user reply = QMessageBox.question( - self, "Apply VCGT", + self, + "Apply VCGT", "This will apply the VCGT gamma correction to your primary display.\n\n" "The changes modify the Windows gamma ramp and will remain active " "until system restart or manual reset.\n\n" "Do you want to continue?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.Yes + QMessageBox.StandardButton.Yes, ) if reply != QMessageBox.StandardButton.Yes: return try: - from calibrate_pro.core.lut_engine import LUT3D from calibrate_pro.core.vcgt import apply_vcgt_windows, lut3d_to_vcgt @@ -342,7 +343,7 @@ def _apply_vcgt(self): "Neutral Axis (grayscale extraction)": "neutral_axis", "Channel Maximum (preserve saturation)": "channel_max", "Luminance Weighted (perceptual)": "luminance", - "Diagonal Average": "diagonal" + "Diagonal Average": "diagonal", } method = method_map.get(self.method_combo.currentText(), "neutral_axis") @@ -354,27 +355,26 @@ def _apply_vcgt(self): if success: QMessageBox.information( - self, "VCGT Applied", + self, + "VCGT Applied", "VCGT gamma correction has been applied to the primary display.\n\n" "The correction is now active. To remove it:\n" "- Use the 'Reset Gamma' button below, or\n" - "- Restart your computer" + "- Restart your computer", ) else: QMessageBox.warning( - self, "Application Failed", + self, + "Application Failed", "Failed to apply VCGT. This may be due to:\n" "- Insufficient permissions\n" "- Display driver limitations\n" "- Windows color management restrictions\n\n" - "Try running as Administrator." + "Try running as Administrator.", ) except Exception as e: - QMessageBox.critical( - self, "Error", - f"Failed to apply VCGT:\n\n{str(e)}" - ) + QMessageBox.critical(self, "Error", f"Failed to apply VCGT:\n\n{str(e)}") def _reset_vcgt(self): """Reset display gamma to linear (remove VCGT correction).""" @@ -385,13 +385,9 @@ def _reset_vcgt(self): if success: QMessageBox.information( - self, "Reset Complete", - "Display gamma has been reset to linear (no correction)." + self, "Reset Complete", "Display gamma has been reset to linear (no correction)." ) else: - QMessageBox.warning( - self, "Reset Failed", - "Failed to reset gamma ramp." - ) + QMessageBox.warning(self, "Reset Failed", "Failed to reset gamma ramp.") except Exception as e: QMessageBox.critical(self, "Error", str(e)) diff --git a/calibrate_pro/gui/pages/verification_page.py b/calibrate_pro/gui/pages/verification_page.py index 048d893..fbc3722 100644 --- a/calibrate_pro/gui/pages/verification_page.py +++ b/calibrate_pro/gui/pages/verification_page.py @@ -78,22 +78,58 @@ def _create_colorchecker_tab(self) -> QWidget: # ColorChecker patch names and simulated Delta E values patches = [ - ("Dark Skin", 0.69), ("Light Skin", 0.39), ("Blue Sky", 0.44), - ("Foliage", 0.62), ("Blue Flower", 0.41), ("Bluish Green", 0.42), - ("Orange", 0.83), ("Purplish Blue", 0.42), ("Moderate Red", 0.30), - ("Purple", 1.03), ("Yellow Green", 0.57), ("Orange Yellow", 0.78), - ("Blue", 0.80), ("Green", 0.60), ("Red", 0.72), - ("Yellow", 0.48), ("Magenta", 0.37), ("Cyan", 2.93), - ("White", 0.09), ("Neutral 8", 0.32), ("Neutral 6.5", 0.48), - ("Neutral 5", 0.32), ("Neutral 3.5", 0.32), ("Black", 1.15), + ("Dark Skin", 0.69), + ("Light Skin", 0.39), + ("Blue Sky", 0.44), + ("Foliage", 0.62), + ("Blue Flower", 0.41), + ("Bluish Green", 0.42), + ("Orange", 0.83), + ("Purplish Blue", 0.42), + ("Moderate Red", 0.30), + ("Purple", 1.03), + ("Yellow Green", 0.57), + ("Orange Yellow", 0.78), + ("Blue", 0.80), + ("Green", 0.60), + ("Red", 0.72), + ("Yellow", 0.48), + ("Magenta", 0.37), + ("Cyan", 2.93), + ("White", 0.09), + ("Neutral 8", 0.32), + ("Neutral 6.5", 0.48), + ("Neutral 5", 0.32), + ("Neutral 3.5", 0.32), + ("Black", 1.15), ] # Approximate colors for visualization colors = [ - "#735244", "#c29682", "#627a9d", "#576c43", "#8580b1", "#67bdaa", - "#d67e2c", "#505ba6", "#c15a63", "#5e3c6c", "#9dbc40", "#e0a32e", - "#383d96", "#469449", "#af363c", "#e7c71f", "#bb5695", "#0885a1", - "#f3f3f2", "#c8c8c8", "#a0a0a0", "#7a7a7a", "#555555", "#343434", + "#735244", + "#c29682", + "#627a9d", + "#576c43", + "#8580b1", + "#67bdaa", + "#d67e2c", + "#505ba6", + "#c15a63", + "#5e3c6c", + "#9dbc40", + "#e0a32e", + "#383d96", + "#469449", + "#af363c", + "#e7c71f", + "#bb5695", + "#0885a1", + "#f3f3f2", + "#c8c8c8", + "#a0a0a0", + "#7a7a7a", + "#555555", + "#343434", ] for i, ((name, de), color) in enumerate(zip(patches, colors)): @@ -102,7 +138,7 @@ def _create_colorchecker_tab(self) -> QWidget: patch = QFrame() patch.setMinimumSize(80, 70) - de_color = COLORS['success'] if de < 1 else COLORS['warning'] if de < 2 else COLORS['error'] + de_color = COLORS["success"] if de < 1 else COLORS["warning"] if de < 2 else COLORS["error"] patch.setStyleSheet(f""" QFrame {{ @@ -118,7 +154,9 @@ def _create_colorchecker_tab(self) -> QWidget: patch_layout.addStretch() de_label = QLabel(f"{de:.2f}") - de_label.setStyleSheet("color: white; font-weight: 700; font-size: 12px; background: rgba(0,0,0,0.5); border-radius: 3px; padding: 2px;") + de_label.setStyleSheet( + "color: white; font-weight: 700; font-size: 12px; background: rgba(0,0,0,0.5); border-radius: 3px; padding: 2px;" + ) de_label.setAlignment(Qt.AlignmentFlag.AlignCenter) patch_layout.addWidget(de_label) @@ -129,9 +167,9 @@ def _create_colorchecker_tab(self) -> QWidget: # Results summary results_layout = QHBoxLayout() - avg_frame = self._create_result_stat("Average Delta E", "0.65", COLORS['success']) - max_frame = self._create_result_stat("Maximum Delta E", "2.93", COLORS['warning']) - grade_frame = self._create_result_stat("Grade", "Professional", COLORS['accent']) + avg_frame = self._create_result_stat("Average Delta E", "0.65", COLORS["success"]) + max_frame = self._create_result_stat("Maximum Delta E", "2.93", COLORS["warning"]) + grade_frame = self._create_result_stat("Grade", "Professional", COLORS["accent"]) results_layout.addWidget(avg_frame) results_layout.addWidget(max_frame) @@ -176,7 +214,7 @@ def _create_grayscale_tab(self) -> QWidget: patch = QFrame() patch.setMinimumSize(40, 100) patch.setStyleSheet(f"background-color: {gray}; border-radius: 4px;") - patch.setToolTip(f"Level {i*5}%\nRGB: ({level}, {level}, {level})") + patch.setToolTip(f"Level {i * 5}%\nRGB: ({level}, {level}, {level})") ramp_layout.addWidget(patch) layout.addWidget(ramp_widget) @@ -262,11 +300,7 @@ def _run_verification(self): msg.setWindowTitle("Verification Results") msg.setIcon(QMessageBox.Icon.Information) - grade_color = ( - COLORS['success'] if avg_de < 1.0 else - COLORS['warning'] if avg_de < 2.0 else - COLORS['error'] - ) + grade_color = COLORS["success"] if avg_de < 1.0 else COLORS["warning"] if avg_de < 2.0 else COLORS["error"] msg.setText("

    Calibration Verification Complete

    ") msg.setInformativeText( @@ -302,7 +336,4 @@ def _run_verification(self): self._last_verification = result except Exception as e: - QMessageBox.critical( - self, "Verification Error", - f"Failed to run verification:\n\n{str(e)}" - ) + QMessageBox.critical(self, "Verification Error", f"Failed to run verification:\n\n{str(e)}") diff --git a/calibrate_pro/gui/pages/verify.py b/calibrate_pro/gui/pages/verify.py index f49fce2..528e622 100644 --- a/calibrate_pro/gui/pages/verify.py +++ b/calibrate_pro/gui/pages/verify.py @@ -32,11 +32,12 @@ # Worker Thread + class VerifyWorker(QThread): """Runs SensorlessEngine.verify_calibration() off the main thread.""" finished = pyqtSignal(bool, object) # success, results dict or error string - progress = pyqtSignal(int, int) # current patch index, total patches + progress = pyqtSignal(int, int) # current patch index, total patches def __init__(self, display_index: int = 0, parent=None): super().__init__(parent) @@ -122,12 +123,13 @@ def run(self): import hid import numpy as np - - OLED_MATRIX = np.array([ - [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], - [-0.00004481, 0.00035042, 0.08032401], - ]) + OLED_MATRIX = np.array( + [ + [0.03836831, -0.02175997, 0.01696057], + [0.01449629, 0.01611903, 0.00057150], + [-0.00004481, 0.00035042, 0.08032401], + ] + ) M_MASK = 0xFFFFFFFF @@ -137,31 +139,50 @@ def run(self): device.open(0x0765, 0x5020) # Unlock (NEC OEM key) - k0, k1 = 0xa9119479, 0x5b168761 - cmd = bytearray(65); cmd[0] = 0; cmd[1] = 0x99 - device.write(cmd); time.sleep(0.2) + k0, k1 = 0xA9119479, 0x5B168761 + cmd = bytearray(65) + cmd[0] = 0 + cmd[1] = 0x99 + device.write(cmd) + time.sleep(0.2) c = bytes(device.read(64, timeout_ms=3000)) sc = bytearray(8) - for i in range(8): sc[i] = c[3] ^ c[35 + i] - ci0 = (sc[3]<<24)+(sc[0]<<16)+(sc[4]<<8)+sc[6] - ci1 = (sc[1]<<24)+(sc[7]<<16)+(sc[2]<<8)+sc[5] + for i in range(8): + sc[i] = c[3] ^ c[35 + i] + ci0 = (sc[3] << 24) + (sc[0] << 16) + (sc[4] << 8) + sc[6] + ci1 = (sc[1] << 24) + (sc[7] << 16) + (sc[2] << 8) + sc[5] nk0, nk1 = (-k0) & M_MASK, (-k1) & M_MASK - co = [(nk0-ci1)&M_MASK, (nk1-ci0)&M_MASK, (ci1*nk0)&M_MASK, (ci0*nk1)&M_MASK] + co = [(nk0 - ci1) & M_MASK, (nk1 - ci0) & M_MASK, (ci1 * nk0) & M_MASK, (ci0 * nk1) & M_MASK] s = sum(sc) - for sh in [0, 8, 16, 24]: s += (nk0>>sh)&0xFF; s += (nk1>>sh)&0xFF + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF s0, s1 = s & 0xFF, (s >> 8) & 0xFF sr = bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF; sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF; sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF; sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF; sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF; sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF; sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF; sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF; sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb = bytearray(65); rb[0] = 0; rb[1] = 0x9A - for i in range(16): rb[25+i] = c[2] ^ sr[i] - device.write(rb); time.sleep(0.3); device.read(64, timeout_ms=3000) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) self.log_line.emit("Sensor unlocked. Place sensor against display.") self.log_line.emit("Measurement requires a fullscreen patch window.") @@ -170,16 +191,18 @@ def run(self): # Measure white for normalization self.progress.emit("Measuring white reference...", 0.0) intclks = int(1.0 * 12000000) - cmd2 = bytearray(65); cmd2[0] = 0x00; cmd2[1] = 0x01 - struct.pack_into(' tuple: import numpy as np from calibrate_pro.core.color_math import D50_WHITE, D65_WHITE, bradford_adapt, lab_to_xyz, xyz_to_srgb + lab_arr = np.array(lab, dtype=float) xyz_d50 = lab_to_xyz(lab_arr, D50_WHITE) xyz_d65 = bradford_adapt(xyz_d50, D50_WHITE, D65_WHITE) @@ -359,6 +383,7 @@ def _lab_to_approx_srgb(lab: tuple) -> tuple: # Grayscale Tracking Chart Widget + class GrayscaleTrackingChart(QWidget): """ Interactive gamma/EOTF tracking chart rendered with QPainter. @@ -413,7 +438,7 @@ def set_data( else: self._delta_es = [] for i, s in enumerate(self._steps): - target_y = s ** target_gamma + target_y = s**target_gamma meas_y = self._measured[i] if i < len(self._measured) else target_y # Approximate perceptual dE from luminance deviation # Using a simple L* difference scaled to dE-like units @@ -456,9 +481,11 @@ def paintEvent(self, event): p.setPen(QColor(C.TEXT)) title_font = QFont("Segoe UI", 11, QFont.Weight.DemiBold) p.setFont(title_font) - p.drawText(QRectF(0, 4, float(w), float(margin_t - 4)), - Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter, - "Grayscale Tracking") + p.drawText( + QRectF(0, 4, float(w), float(margin_t - 4)), + Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter, + "Grayscale Tracking", + ) # Helper: map normalized (0-1) data coords to pixel coords def to_px(nx: float, ny: float) -> QPointF: @@ -483,7 +510,7 @@ def to_px(nx: float, ny: float) -> QPointF: p.drawText( QRectF(vx - 16, chart_y + chart_h + 4, 32, 16), Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, - f"{int(frac * 100)}" + f"{int(frac * 100)}", ) # Horizontal grid line @@ -495,7 +522,7 @@ def to_px(nx: float, ny: float) -> QPointF: p.drawText( QRectF(0, hy - 8, margin_l - 6, 16), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, - f"{frac:.1f}" + f"{frac:.1f}", ) # Axis titles @@ -505,7 +532,7 @@ def to_px(nx: float, ny: float) -> QPointF: p.drawText( QRectF(chart_x, chart_y + chart_h + 18, chart_w, 16), Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, - "Input Signal Level (%)" + "Input Signal Level (%)", ) # Y-axis title (drawn rotated) @@ -515,7 +542,7 @@ def to_px(nx: float, ny: float) -> QPointF: p.drawText( QRectF(-chart_h / 2, -8, chart_h, 16), Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter, - "Output Luminance" + "Output Luminance", ) p.restore() @@ -525,7 +552,7 @@ def to_px(nx: float, ny: float) -> QPointF: num_seg = 100 for seg in range(num_seg + 1): nx = seg / float(num_seg) - ny = nx ** gamma + ny = nx**gamma pt = to_px(nx, ny) if seg == 0: curve_path.moveTo(pt) @@ -540,9 +567,9 @@ def to_px(nx: float, ny: float) -> QPointF: # --- Per-channel lines (R, G, B) --- if self._per_channel and self._steps: channel_colors = { - 'red': "#d08888", - 'green': "#92ad7e", - 'blue': "#95b3ba", + "red": "#d08888", + "green": "#92ad7e", + "blue": "#95b3ba", } for ch_name, ch_color in channel_colors.items(): ch_data = self._per_channel.get(ch_name, []) @@ -605,9 +632,7 @@ def to_px(nx: float, ny: float) -> QPointF: p.setBrush(QColor(color_str)) p.drawEllipse(QPointF(lx + 4, ly + 5), 3, 3) p.setPen(QColor(C.TEXT2)) - p.drawText(QRectF(lx + 12, ly, 120, 12), - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, - label) + p.drawText(QRectF(lx + 12, ly, 120, 12), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, label) ly += 14 p.end() @@ -615,6 +640,7 @@ def to_px(nx: float, ny: float) -> QPointF: # Gamut Coverage Bars Widget + class GamutCoverageSection(QWidget): """Three labeled gamut coverage bars: sRGB, P3, BT.2020.""" @@ -650,6 +676,7 @@ def set_values(self, srgb: float, p3: float, bt2020: float): # Verify Page + class VerifyPage(QWidget): """Verification results page with ColorChecker grid, stats, and gamut bars.""" @@ -732,18 +759,14 @@ def _build(self): # Prediction label self._method_label = QLabel("Predicted (sensorless)") - self._method_label.setStyleSheet( - f"font-size: 11px; color: {C.TEXT3}; font-style: italic;" - ) + self._method_label.setStyleSheet(f"font-size: 11px; color: {C.TEXT3}; font-style: italic;") left_col.addWidget(self._method_label) left_col.addStretch() body_row.addLayout(left_col, stretch=3) # Right: Stats panel - right_card, right_lay = Card.with_layout( - QVBoxLayout, margins=(20, 16, 20, 16), spacing=16 - ) + right_card, right_lay = Card.with_layout(QVBoxLayout, margins=(20, 16, 20, 16), spacing=16) right_card.setMinimumWidth(260) right_card.setMaximumWidth(360) right_card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) @@ -778,16 +801,12 @@ def _build(self): right_lay.addWidget(sep2) cie_heading = QLabel("CIE 1931 Chromaticity") - cie_heading.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.TEXT};" - ) + cie_heading.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.TEXT};") right_lay.addWidget(cie_heading) self._cie_diagram = CIEDiagramWidget() self._cie_diagram.setMinimumSize(240, 240) - self._cie_diagram.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) + self._cie_diagram.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) right_lay.addWidget(self._cie_diagram, stretch=1) right_lay.addStretch() @@ -808,15 +827,11 @@ def _build(self): gs_stats_row.setSpacing(24) self._gs_avg_label = QLabel("Avg grayscale dE: --") - self._gs_avg_label.setStyleSheet( - f"font-size: 12px; color: {C.TEXT2}; font-weight: 500;" - ) + self._gs_avg_label.setStyleSheet(f"font-size: 12px; color: {C.TEXT2}; font-weight: 500;") gs_stats_row.addWidget(self._gs_avg_label) self._gs_max_label = QLabel("Max grayscale dE: --") - self._gs_max_label.setStyleSheet( - f"font-size: 12px; color: {C.TEXT2}; font-weight: 500;" - ) + self._gs_max_label.setStyleSheet(f"font-size: 12px; color: {C.TEXT2}; font-weight: 500;") gs_stats_row.addWidget(self._gs_max_label) gs_stats_row.addStretch() @@ -888,9 +903,7 @@ def _build(self): prog_lay.setSpacing(10) self._step_label = QLabel("Ready") - self._step_label.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.ACCENT_TX};" - ) + self._step_label.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.ACCENT_TX};") prog_lay.addWidget(self._step_label) self._progress_bar = QProgressBar() @@ -933,15 +946,18 @@ def _seed_default_grid(self): """Show the ColorChecker with reference colors and dashes before verification.""" try: from calibrate_pro.sensorless.neuralux import COLORCHECKER_CLASSIC + patches = [] for cp in COLORCHECKER_CLASSIC: - patches.append({ - "name": cp.name, - "ref_srgb": cp.srgb, - "ref_lab": cp.lab_d50, - "displayed_lab": cp.lab_d50, - "delta_e": 0.0, - }) + patches.append( + { + "name": cp.name, + "ref_srgb": cp.srgb, + "ref_lab": cp.lab_d50, + "displayed_lab": cp.lab_d50, + "delta_e": 0.0, + } + ) self._checker_grid.set_results(patches) except Exception: pass @@ -952,6 +968,7 @@ def _seed_default_grid(self): def _seed_grayscale_chart(self): """Populate the grayscale chart with realistic simulated data.""" import random + random.seed(42) # Deterministic demo data # 11 steps from 0% to 100% in 10% increments @@ -962,7 +979,7 @@ def _seed_grayscale_chart(self): measured = [] delta_es = [] for s in steps: - target_y = s ** target_gamma + target_y = s**target_gamma if s == 0.0: # Black level — slight offset simulating backlight bleed deviation = random.uniform(0.001, 0.005) @@ -983,16 +1000,18 @@ def _seed_grayscale_chart(self): # Simulate per-channel data with slight inter-channel divergence per_channel = {} - for ch_name, bias in [('red', 0.006), ('green', -0.003), ('blue', 0.010)]: + for ch_name, bias in [("red", 0.006), ("green", -0.003), ("blue", 0.010)]: ch_data = [] for _i, s in enumerate(steps): - target_y = s ** target_gamma + target_y = s**target_gamma ch_dev = bias * s + random.uniform(-0.005, 0.005) ch_data.append(max(0.0, min(1.0, target_y + ch_dev))) per_channel[ch_name] = ch_data self._gs_chart.set_data( - steps, target_gamma, measured, + steps, + target_gamma, + measured, per_channel=per_channel, delta_es=delta_es, ) @@ -1004,13 +1023,9 @@ def _seed_grayscale_chart(self): avg_color = C.GREEN_HI if avg_de < 1.0 else C.YELLOW if avg_de < 3.0 else C.RED max_color = C.GREEN_HI if max_de < 1.0 else C.YELLOW if max_de < 3.0 else C.RED self._gs_avg_label.setText(f"Avg grayscale dE: {avg_de:.2f}") - self._gs_avg_label.setStyleSheet( - f"font-size: 12px; color: {avg_color}; font-weight: 500;" - ) + self._gs_avg_label.setStyleSheet(f"font-size: 12px; color: {avg_color}; font-weight: 500;") self._gs_max_label.setText(f"Max grayscale dE: {max_de:.2f}") - self._gs_max_label.setStyleSheet( - f"font-size: 12px; color: {max_color}; font-weight: 500;" - ) + self._gs_max_label.setStyleSheet(f"font-size: 12px; color: {max_color}; font-weight: 500;") # Display Detection @@ -1019,6 +1034,7 @@ def _detect_displays(self): try: sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent)) from calibrate_pro.panels.detection import enumerate_displays, get_display_name + self._displays = enumerate_displays() for i, d in enumerate(self._displays): name = get_display_name(d) @@ -1032,6 +1048,7 @@ def _detect_displays(self): self._sensor_detected = False try: from calibrate_pro.hardware.i1d3_native import I1D3Driver + devices = I1D3Driver.find_devices() self._sensor_detected = bool(devices) except Exception: @@ -1039,9 +1056,7 @@ def _detect_displays(self): if self._sensor_detected: self._method_label.setText("Sensor detected - measured verification available") - self._method_label.setStyleSheet( - f"font-size: 11px; color: {C.GREEN_HI}; font-style: italic;" - ) + self._method_label.setStyleSheet(f"font-size: 11px; color: {C.GREEN_HI}; font-style: italic;") # Verification @@ -1056,9 +1071,7 @@ def _run_verification(self): self._progress_card.setVisible(True) self._progress_bar.setValue(0) self._step_label.setText("Starting verification...") - self._step_label.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.ACCENT_TX};" - ) + self._step_label.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.ACCENT_TX};") display_index = max(0, self._display_combo.currentIndex()) self._worker = VerifyWorker(display_index) @@ -1083,9 +1096,7 @@ def _on_finished(self, success: bool, data): # Show completed progress self._step_label.setText("Verification complete") - self._step_label.setStyleSheet( - f"font-size: 13px; font-weight: 500; color: {C.GREEN_HI};" - ) + self._step_label.setStyleSheet(f"font-size: 13px; font-weight: 500; color: {C.GREEN_HI};") self._progress_bar.setValue(self._progress_bar.maximum()) results = data @@ -1154,9 +1165,7 @@ def _populate_results(self, results: dict): method_text = "Predicted (sensorless)" method_color = avg_color self._method_label.setText(f"{method_text} \u2014 avg dE {avg_de:.2f}") - self._method_label.setStyleSheet( - f"font-size: 11px; color: {method_color}; font-style: italic;" - ) + self._method_label.setStyleSheet(f"font-size: 11px; color: {method_color}; font-style: italic;") # Gamut coverage gamut = results.get("gamut_coverage", {}) @@ -1168,14 +1177,10 @@ def _populate_results(self, results: dict): # CIE 1931 chromaticity diagram — populate with display primaries dp = results.get("display_primaries") if dp: - self._cie_diagram.set_display_gamut( - dp["R"], dp["G"], dp["B"], dp.get("W") - ) + self._cie_diagram.set_display_gamut(dp["R"], dp["G"], dp["B"], dp.get("W")) else: # Clear previous overlay if no primaries available - self._cie_diagram.set_display_gamut( - (0.640, 0.330), (0.300, 0.600), (0.150, 0.060) - ) + self._cie_diagram.set_display_gamut((0.640, 0.330), (0.300, 0.600), (0.150, 0.060)) # Grayscale tracking chart — use data from results if available, # otherwise generate from the grayscale patches in the results. @@ -1202,17 +1207,14 @@ def _populate_results(self, results: dict): avg_c = C.GREEN_HI if gs_avg < 1.0 else C.YELLOW if gs_avg < 3.0 else C.RED max_c = C.GREEN_HI if gs_max < 1.0 else C.YELLOW if gs_max < 3.0 else C.RED self._gs_avg_label.setText(f"Avg grayscale dE: {gs_avg:.2f}") - self._gs_avg_label.setStyleSheet( - f"font-size: 12px; color: {avg_c}; font-weight: 500;" - ) + self._gs_avg_label.setStyleSheet(f"font-size: 12px; color: {avg_c}; font-weight: 500;") self._gs_max_label.setText(f"Max grayscale dE: {gs_max:.2f}") - self._gs_max_label.setStyleSheet( - f"font-size: 12px; color: {max_c}; font-weight: 500;" - ) + self._gs_max_label.setStyleSheet(f"font-size: 12px; color: {max_c}; font-weight: 500;") def _seed_grayscale_from_patches(self, patches: list): """Build grayscale chart data from the neutral patches in the results.""" import random + random.seed(7) # Use 11 steps; if we have real neutral patch data, interpolate @@ -1222,7 +1224,7 @@ def _seed_grayscale_from_patches(self, patches: list): delta_es = [] for s in steps: - target_y = s ** target_gamma + target_y = s**target_gamma # Add small noise to simulate measured tracking dev = random.uniform(-0.01, 0.015) * (1.0 + s) meas_y = max(0.0, min(1.0, target_y + dev)) @@ -1261,11 +1263,9 @@ def _build_html_report(self, results: dict) -> str: " background: #fdf9f5; color: #443933; }", " h1 { color: #b07878; }", " table { border-collapse: collapse; margin-top: 16px; }", - " th, td { border: 1px solid #ede4da; padding: 6px 14px; " - " text-align: left; }", + " th, td { border: 1px solid #ede4da; padding: 6px 14px; text-align: left; }", " th { background: #faf5f0; }", - " .good { color: #92ad7e; } .warn { color: #e0c87a; } " - " .bad { color: #d08888; }", + " .good { color: #92ad7e; } .warn { color: #e0c87a; } .bad { color: #d08888; }", " @media print { body { background: white; } }", "", "

    Calibrate Pro - Verification Report

    ", @@ -1281,10 +1281,7 @@ def _build_html_report(self, results: dict) -> str: de = p.get("delta_e", 0.0) css = "good" if de < 2.0 else "warn" if de < 3.0 else "bad" name = p.get("name", "?") - lines.append( - f"{name}" - f"{de:.2f}" - ) + lines.append(f"{name}{de:.2f}") lines.append("") lines.append("") return "\n".join(lines) @@ -1295,8 +1292,10 @@ def _export_report(self): return path, selected_filter = QFileDialog.getSaveFileName( - self, "Export Verification Report", "verification_report.html", - "HTML Report (*.html);;PDF Report (*.pdf);;Text Report (*.txt)" + self, + "Export Verification Report", + "verification_report.html", + "HTML Report (*.html);;PDF Report (*.pdf);;Text Report (*.txt)", ) if not path: return @@ -1319,15 +1318,15 @@ def _export_report(self): from calibrate_pro.verification.report_generator import ( generate_calibration_report, ) - with tempfile.NamedTemporaryFile( - suffix=".html", delete=False, mode="w", encoding="utf-8" - ) as tmp: + + with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") as tmp: tmp.write(html_content) tmp_path = tmp.name except Exception: tmp_path = None from calibrate_pro.verification.pdf_export import export_report_pdf + success = export_report_pdf(html_content, path) # Clean up temp file @@ -1341,25 +1340,20 @@ def _export_report(self): # Check if the PDF was actually created (WebEngine path) # or if we fell back to browser if Path(path).exists(): - QMessageBox.information( - self, "Report Exported", - f"PDF report saved to:\n{path}" - ) + QMessageBox.information(self, "Report Exported", f"PDF report saved to:\n{path}") else: html_fallback = Path(path).with_suffix(".html") QMessageBox.information( - self, "Report Exported", + self, + "Report Exported", f"PDF export requires a browser.\n\n" f"The HTML report has been opened in your browser.\n" f"Use your browser's Print > Save as PDF to create " f"the PDF.\n\n" - f"HTML saved at:\n{html_fallback}" + f"HTML saved at:\n{html_fallback}", ) else: - QMessageBox.warning( - self, "Export Error", - "Could not export PDF. Please try HTML format instead." - ) + QMessageBox.warning(self, "Export Error", "Could not export PDF. Please try HTML format instead.") return # Non-PDF export: HTML or TXT @@ -1367,6 +1361,7 @@ def _export_report(self): from calibrate_pro.verification.report_generator import ( generate_calibration_report, ) + generate_calibration_report(results, None, results, path) except (ImportError, Exception): if path.endswith(".html"): @@ -1411,10 +1406,7 @@ def _export_report(self): return if not path.lower().endswith(".pdf"): - QMessageBox.information( - self, "Report Exported", - f"Verification report saved to:\n{path}" - ) + QMessageBox.information(self, "Report Exported", f"Verification report saved to:\n{path}") # Helpers diff --git a/calibrate_pro/gui/pattern_window.py b/calibrate_pro/gui/pattern_window.py index 5bbe1d3..4555a59 100644 --- a/calibrate_pro/gui/pattern_window.py +++ b/calibrate_pro/gui/pattern_window.py @@ -32,8 +32,10 @@ # Pattern Types # ============================================================================= + class PatternType(Enum): """Available test pattern types.""" + SOLID_COLOR = auto() GRAYSCALE_RAMP = auto() RGB_PRIMARIES = auto() @@ -57,6 +59,7 @@ class PatternType(Enum): @dataclass class PatternConfig: """Configuration for a test pattern.""" + pattern_type: PatternType color: tuple[int, int, int] = (128, 128, 128) # For solid color steps: int = 21 # For ramps @@ -69,57 +72,58 @@ class PatternConfig: # Pattern Renderer # ============================================================================= + class PatternRenderer: """Renders various test patterns.""" # Standard color values SMPTE_COLORS = [ (192, 192, 192), # 75% White - (192, 192, 0), # 75% Yellow - (0, 192, 192), # 75% Cyan - (0, 192, 0), # 75% Green - (192, 0, 192), # 75% Magenta - (192, 0, 0), # 75% Red - (0, 0, 192), # 75% Blue + (192, 192, 0), # 75% Yellow + (0, 192, 192), # 75% Cyan + (0, 192, 0), # 75% Green + (192, 0, 192), # 75% Magenta + (192, 0, 0), # 75% Red + (0, 0, 192), # 75% Blue ] EBU_COLORS = [ (255, 255, 255), # White - (255, 255, 0), # Yellow - (0, 255, 255), # Cyan - (0, 255, 0), # Green - (255, 0, 255), # Magenta - (255, 0, 0), # Red - (0, 0, 255), # Blue - (0, 0, 0), # Black + (255, 255, 0), # Yellow + (0, 255, 255), # Cyan + (0, 255, 0), # Green + (255, 0, 255), # Magenta + (255, 0, 0), # Red + (0, 0, 255), # Blue + (0, 0, 0), # Black ] # ColorChecker 24-patch values (sRGB approximations) COLORCHECKER = [ - (115, 82, 68), # Dark skin + (115, 82, 68), # Dark skin (194, 150, 130), # Light skin - (98, 122, 157), # Blue sky - (87, 108, 67), # Foliage + (98, 122, 157), # Blue sky + (87, 108, 67), # Foliage (133, 128, 177), # Blue flower (103, 189, 170), # Bluish green - (214, 126, 44), # Orange - (80, 91, 166), # Purplish blue - (193, 90, 99), # Moderate red - (94, 60, 108), # Purple - (157, 188, 64), # Yellow green - (224, 163, 46), # Orange yellow - (56, 61, 150), # Blue - (70, 148, 73), # Green - (175, 54, 60), # Red - (231, 199, 31), # Yellow - (187, 86, 149), # Magenta - (8, 133, 161), # Cyan + (214, 126, 44), # Orange + (80, 91, 166), # Purplish blue + (193, 90, 99), # Moderate red + (94, 60, 108), # Purple + (157, 188, 64), # Yellow green + (224, 163, 46), # Orange yellow + (56, 61, 150), # Blue + (70, 148, 73), # Green + (175, 54, 60), # Red + (231, 199, 31), # Yellow + (187, 86, 149), # Magenta + (8, 133, 161), # Cyan (243, 243, 242), # White (200, 200, 200), # Neutral 8 (160, 160, 160), # Neutral 6.5 (122, 122, 121), # Neutral 5 - (85, 85, 85), # Neutral 3.5 - (52, 52, 52), # Black + (85, 85, 85), # Neutral 3.5 + (52, 52, 52), # Black ] @classmethod @@ -174,14 +178,14 @@ def _render_grayscale_ramp(cls, painter: QPainter, rect: QRect, config: PatternC def _render_rgb_primaries(cls, painter: QPainter, rect: QRect, config: PatternConfig): """Render RGB primary colors with secondaries.""" colors = [ - (255, 0, 0), # Red - (0, 255, 0), # Green - (0, 0, 255), # Blue + (255, 0, 0), # Red + (0, 255, 0), # Green + (0, 0, 255), # Blue (255, 255, 0), # Yellow (0, 255, 255), # Cyan (255, 0, 255), # Magenta (255, 255, 255), # White - (0, 0, 0), # Black + (0, 0, 0), # Black ] cols = 4 @@ -213,10 +217,7 @@ def _render_smpte_bars(cls, painter: QPainter, rect: QRect, config: PatternConfi bottom_h = rect.height() - bar_height # Mini bars section - mini_colors = [ - (0, 0, 192), (0, 0, 0), (192, 0, 192), (0, 0, 0), - (0, 192, 192), (0, 0, 0), (192, 192, 192) - ] + mini_colors = [(0, 0, 192), (0, 0, 0), (192, 0, 192), (0, 0, 0), (0, 192, 192), (0, 0, 0), (192, 192, 192)] for i, (r, g, b) in enumerate(mini_colors): x = rect.x() + i * bar_width w = bar_width if i < 6 else rect.width() - 6 * bar_width @@ -352,20 +353,16 @@ def _render_pluge(cls, painter: QPainter, rect: QRect, config: PatternConfig): painter.fillRect(center_x, rect.y(), center_w // 5, rect.height(), QColor(3, 3, 3)) # 0% (black) - painter.fillRect(center_x + center_w // 5, rect.y(), center_w // 5, rect.height(), - QColor(0, 0, 0)) + painter.fillRect(center_x + center_w // 5, rect.y(), center_w // 5, rect.height(), QColor(0, 0, 0)) # +4% (just above black) - painter.fillRect(center_x + 2 * center_w // 5, rect.y(), center_w // 5, rect.height(), - QColor(11, 11, 11)) + painter.fillRect(center_x + 2 * center_w // 5, rect.y(), center_w // 5, rect.height(), QColor(11, 11, 11)) # 7.5% (background reference) - painter.fillRect(center_x + 3 * center_w // 5, rect.y(), center_w // 5, rect.height(), - QColor(19, 19, 19)) + painter.fillRect(center_x + 3 * center_w // 5, rect.y(), center_w // 5, rect.height(), QColor(19, 19, 19)) # 11.5% (above background) - painter.fillRect(center_x + 4 * center_w // 5, rect.y(), center_w // 5, rect.height(), - QColor(29, 29, 29)) + painter.fillRect(center_x + 4 * center_w // 5, rect.y(), center_w // 5, rect.height(), QColor(29, 29, 29)) # Labels if config.show_labels: @@ -404,7 +401,7 @@ def _render_geometry(cls, painter: QPainter, rect: QRect, config: PatternConfig) (rect.x() + corner_r + 10, rect.bottom() - corner_r - 10), (rect.right() - corner_r - 10, rect.bottom() - corner_r - 10), ] - for (x, y) in corners: + for x, y in corners: painter.drawEllipse(QPointF(x, y), corner_r, corner_r) @classmethod @@ -417,8 +414,7 @@ def _render_white_point(cls, painter: QPainter, rect: QRect, config: PatternConf patch_size = min(rect.width(), rect.height()) // 3 cx = rect.center().x() cy = rect.center().y() - painter.fillRect(cx - patch_size // 2, cy - patch_size // 2, - patch_size, patch_size, QColor(*config.color)) + painter.fillRect(cx - patch_size // 2, cy - patch_size // 2, patch_size, patch_size, QColor(*config.color)) @classmethod def _render_black_level(cls, painter: QPainter, rect: QRect, config: PatternConfig): @@ -475,6 +471,7 @@ def _render_gamma_ramp(cls, painter: QPainter, rect: QRect, config: PatternConfi # Pattern Window Widget # ============================================================================= + class PatternCanvas(QWidget): """Canvas widget that displays test patterns.""" @@ -679,6 +676,7 @@ def closeEvent(self, event): # Pattern Sequence Player # ============================================================================= + class PatternSequencer: """Plays sequences of patterns for automated calibration.""" @@ -736,10 +734,7 @@ def create_grayscale_sequence(steps: int = 21) -> list[PatternConfig]: sequence = [] for i in range(steps): gray = int((i / (steps - 1)) * 255) if steps > 1 else 128 - config = PatternConfig( - pattern_type=PatternType.SOLID_COLOR, - color=(gray, gray, gray) - ) + config = PatternConfig(pattern_type=PatternType.SOLID_COLOR, color=(gray, gray, gray)) sequence.append(config) return sequence @@ -748,10 +743,7 @@ def create_colorchecker_sequence() -> list[PatternConfig]: """Create ColorChecker measurement sequence.""" sequence = [] for r, g, b in PatternRenderer.COLORCHECKER: - config = PatternConfig( - pattern_type=PatternType.SOLID_COLOR, - color=(r, g, b) - ) + config = PatternConfig(pattern_type=PatternType.SOLID_COLOR, color=(r, g, b)) sequence.append(config) return sequence @@ -760,12 +752,12 @@ def create_primary_sequence() -> list[PatternConfig]: """Create primary/secondary color sequence.""" colors = [ (255, 255, 255), # White - (255, 0, 0), # Red - (0, 255, 0), # Green - (0, 0, 255), # Blue - (255, 255, 0), # Yellow - (0, 255, 255), # Cyan - (255, 0, 255), # Magenta - (0, 0, 0), # Black + (255, 0, 0), # Red + (0, 255, 0), # Green + (0, 0, 255), # Blue + (255, 255, 0), # Yellow + (0, 255, 255), # Cyan + (255, 0, 255), # Magenta + (0, 0, 0), # Black ] return [PatternConfig(PatternType.SOLID_COLOR, color=c) for c in colors] diff --git a/calibrate_pro/gui/professional_calibration.py b/calibrate_pro/gui/professional_calibration.py index 0414702..b86ccdb 100644 --- a/calibrate_pro/gui/professional_calibration.py +++ b/calibrate_pro/gui/professional_calibration.py @@ -39,11 +39,8 @@ def run_as_admin(): return True try: script = os.path.abspath(sys.argv[0]) - params = ' '.join([f'"{arg}"' for arg in sys.argv[1:]]) - result = ctypes.windll.shell32.ShellExecuteW( - None, "runas", sys.executable, - f'"{script}" {params}', None, 1 - ) + params = " ".join([f'"{arg}"' for arg in sys.argv[1:]]) + result = ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, f'"{script}" {params}', None, 1) if result > 32: sys.exit(0) return False @@ -110,13 +107,13 @@ def run_as_admin(): # Custom Slider Widgets # ============================================================================= + class LabeledSlider(QWidget): """Slider with label and value display.""" valueChanged = pyqtSignal(int) - def __init__(self, label: str, min_val: int, max_val: int, - default: int, suffix: str = "", parent=None): + def __init__(self, label: str, min_val: int, max_val: int, default: int, suffix: str = "", parent=None): super().__init__(parent) self.suffix = suffix @@ -154,8 +151,7 @@ class FloatSlider(QWidget): valueChanged = pyqtSignal(float) - def __init__(self, label: str, min_val: float, max_val: float, - default: float, decimals: int = 3, parent=None): + def __init__(self, label: str, min_val: float, max_val: float, default: float, decimals: int = 3, parent=None): super().__init__(parent) self.min_val = min_val self.max_val = max_val @@ -203,6 +199,7 @@ def setValue(self, val: float): # Color Swatch Widget # ============================================================================= + class ColorSwatch(QWidget): """Display a color swatch.""" @@ -221,13 +218,14 @@ def paintEvent(self, event): painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setBrush(QBrush(self.color)) painter.setPen(QPen(QColor(60, 60, 60), 2)) - painter.drawRoundedRect(2, 2, self.width()-4, self.height()-4, 5, 5) + painter.drawRoundedRect(2, 2, self.width() - 4, self.height() - 4, 5, 5) # ============================================================================= # Main Calibration Window # ============================================================================= + class ProfessionalCalibrationWindow(QMainWindow): """Main Professional Calibration Window.""" @@ -245,15 +243,15 @@ def __init__(self): # Calibration state self.calibration_data = { - 'brightness': 50, - 'contrast': 80, - 'rgb_gains': [100, 100, 100], - 'rgb_offsets': [50, 50, 50], - 'gamma': 22, - 'lut_gains': [1.0, 1.0, 1.0], - 'lut_offsets': [0.0, 0.0, 0.0], - 'lut_gamma': 2.2, - 'peak_luminance': 1000, + "brightness": 50, + "contrast": 80, + "rgb_gains": [100, 100, 100], + "rgb_offsets": [50, 50, 50], + "gamma": 22, + "lut_gains": [1.0, 1.0, 1.0], + "lut_offsets": [0.0, 0.0, 0.0], + "lut_gamma": 2.2, + "peak_luminance": 1000, } self.setWindowTitle("Calibrate Pro - Professional Display Calibration") @@ -302,8 +300,9 @@ def _setup_ui(self): status_layout = QFormLayout(status_group) self.status_admin = QLabel("Yes ✓" if is_admin() else "No ✗") - self.status_admin.setStyleSheet("color: green; font-weight: bold;" if is_admin() - else "color: red; font-weight: bold;") + self.status_admin.setStyleSheet( + "color: green; font-weight: bold;" if is_admin() else "color: red; font-weight: bold;" + ) status_layout.addRow("Administrator:", self.status_admin) self.status_dwm = QLabel("Checking...") @@ -430,11 +429,11 @@ def _create_hardware_tab(self) -> QWidget: lum_layout = QVBoxLayout(lum_group) self.hw_brightness = LabeledSlider("Brightness:", 0, 100, 50) - self.hw_brightness.valueChanged.connect(lambda v: self._on_hw_change('brightness', v)) + self.hw_brightness.valueChanged.connect(lambda v: self._on_hw_change("brightness", v)) lum_layout.addWidget(self.hw_brightness) self.hw_contrast = LabeledSlider("Contrast:", 0, 100, 80) - self.hw_contrast.valueChanged.connect(lambda v: self._on_hw_change('contrast', v)) + self.hw_contrast.valueChanged.connect(lambda v: self._on_hw_change("contrast", v)) lum_layout.addWidget(self.hw_contrast) scroll_layout.addWidget(lum_group) @@ -443,21 +442,23 @@ def _create_hardware_tab(self) -> QWidget: gain_group = QGroupBox("RGB Gains (White Point Adjustment)") gain_layout = QVBoxLayout(gain_group) - gain_info = QLabel("Adjust RGB gains to correct white point color temperature.\n" - "Higher values = more of that color in highlights.") + gain_info = QLabel( + "Adjust RGB gains to correct white point color temperature.\n" + "Higher values = more of that color in highlights." + ) gain_info.setStyleSheet("color: #888; font-size: 11px;") gain_layout.addWidget(gain_info) self.hw_red_gain = LabeledSlider("Red Gain:", 0, 100, 100) - self.hw_red_gain.valueChanged.connect(lambda v: self._on_hw_change('red_gain', v)) + self.hw_red_gain.valueChanged.connect(lambda v: self._on_hw_change("red_gain", v)) gain_layout.addWidget(self.hw_red_gain) self.hw_green_gain = LabeledSlider("Green Gain:", 0, 100, 100) - self.hw_green_gain.valueChanged.connect(lambda v: self._on_hw_change('green_gain', v)) + self.hw_green_gain.valueChanged.connect(lambda v: self._on_hw_change("green_gain", v)) gain_layout.addWidget(self.hw_green_gain) self.hw_blue_gain = LabeledSlider("Blue Gain:", 0, 100, 100) - self.hw_blue_gain.valueChanged.connect(lambda v: self._on_hw_change('blue_gain', v)) + self.hw_blue_gain.valueChanged.connect(lambda v: self._on_hw_change("blue_gain", v)) gain_layout.addWidget(self.hw_blue_gain) scroll_layout.addWidget(gain_group) @@ -466,21 +467,22 @@ def _create_hardware_tab(self) -> QWidget: offset_group = QGroupBox("RGB Offsets (Black Point Adjustment)") offset_layout = QVBoxLayout(offset_group) - offset_info = QLabel("Adjust RGB offsets to correct black point and shadow tint.\n" - "Higher values = more of that color in shadows.") + offset_info = QLabel( + "Adjust RGB offsets to correct black point and shadow tint.\nHigher values = more of that color in shadows." + ) offset_info.setStyleSheet("color: #888; font-size: 11px;") offset_layout.addWidget(offset_info) self.hw_red_offset = LabeledSlider("Red Offset:", 0, 100, 50) - self.hw_red_offset.valueChanged.connect(lambda v: self._on_hw_change('red_offset', v)) + self.hw_red_offset.valueChanged.connect(lambda v: self._on_hw_change("red_offset", v)) offset_layout.addWidget(self.hw_red_offset) self.hw_green_offset = LabeledSlider("Green Offset:", 0, 100, 50) - self.hw_green_offset.valueChanged.connect(lambda v: self._on_hw_change('green_offset', v)) + self.hw_green_offset.valueChanged.connect(lambda v: self._on_hw_change("green_offset", v)) offset_layout.addWidget(self.hw_green_offset) self.hw_blue_offset = LabeledSlider("Blue Offset:", 0, 100, 50) - self.hw_blue_offset.valueChanged.connect(lambda v: self._on_hw_change('blue_offset', v)) + self.hw_blue_offset.valueChanged.connect(lambda v: self._on_hw_change("blue_offset", v)) offset_layout.addWidget(self.hw_blue_offset) scroll_layout.addWidget(offset_group) @@ -489,14 +491,14 @@ def _create_hardware_tab(self) -> QWidget: gamma_group = QGroupBox("Gamma") gamma_layout = QVBoxLayout(gamma_group) - gamma_info = QLabel("Select display gamma curve. Common values:\n" - "• 22 = 2.2 (sRGB standard)\n" - "• 24 = 2.4 (BT.1886 broadcast)") + gamma_info = QLabel( + "Select display gamma curve. Common values:\n• 22 = 2.2 (sRGB standard)\n• 24 = 2.4 (BT.1886 broadcast)" + ) gamma_info.setStyleSheet("color: #888; font-size: 11px;") gamma_layout.addWidget(gamma_info) self.hw_gamma = LabeledSlider("Gamma:", 18, 28, 22) - self.hw_gamma.valueChanged.connect(lambda v: self._on_hw_change('gamma', v)) + self.hw_gamma.valueChanged.connect(lambda v: self._on_hw_change("gamma", v)) gamma_layout.addWidget(self.hw_gamma) scroll_layout.addWidget(gamma_group) @@ -509,7 +511,7 @@ def _create_hardware_tab(self) -> QWidget: btn_layout = QHBoxLayout() self.hw_live_check = QCheckBox("Live Update") - self.hw_live_check.toggled.connect(lambda c: setattr(self, 'live_ddc', c)) + self.hw_live_check.toggled.connect(lambda c: setattr(self, "live_ddc", c)) btn_layout.addWidget(self.hw_live_check) apply_btn = QPushButton("Apply Hardware Settings") @@ -534,8 +536,10 @@ def _create_sdr_lut_tab(self) -> QWidget: scroll_content = QWidget() scroll_layout = QVBoxLayout(scroll_content) - info = QLabel("SDR 3D LUT provides software-level color correction.\n" - "Applied via DWM for system-wide effect on all applications.") + info = QLabel( + "SDR 3D LUT provides software-level color correction.\n" + "Applied via DWM for system-wide effect on all applications." + ) info.setStyleSheet("color: #888;") scroll_layout.addWidget(info) @@ -621,8 +625,9 @@ def _create_hdr_lut_tab(self) -> QWidget: scroll_content = QWidget() scroll_layout = QVBoxLayout(scroll_content) - info = QLabel("HDR 3D LUT for HDR10/PQ content calibration.\n" - "Uses ST.2084 PQ EOTF for accurate HDR color correction.") + info = QLabel( + "HDR 3D LUT for HDR10/PQ content calibration.\nUses ST.2084 PQ EOTF for accurate HDR color correction." + ) info.setStyleSheet("color: #888;") scroll_layout.addWidget(info) @@ -793,8 +798,7 @@ def _refresh_monitors(self): ddc_str = " [DDC/CI]" if ddc_mon else "" self.monitor_combo.addItem( - f"{lut_mon.friendly_name}{primary_str}{hdr_str}{ddc_str}", - {'lut': lut_mon, 'ddc': ddc_mon, 'index': i} + f"{lut_mon.friendly_name}{primary_str}{hdr_str}{ddc_str}", {"lut": lut_mon, "ddc": ddc_mon, "index": i} ) self._log(f"Found {len(lut_monitors)} monitor(s)") @@ -804,21 +808,23 @@ def _on_monitor_changed(self, index: int): if index >= 0: data = self.monitor_combo.itemData(index) if data: - self.current_lut_monitor = data.get('lut') - self.current_ddc_monitor = data.get('ddc') + self.current_lut_monitor = data.get("lut") + self.current_ddc_monitor = data.get("ddc") self._update_capabilities() self._read_current_settings() def _update_capabilities(self): """Update capability display for selected monitor.""" if self.current_ddc_monitor: - caps = self.current_ddc_monitor.get('capabilities') + caps = self.current_ddc_monitor.get("capabilities") if caps: self.cap_brightness.setText("✓" if 0x10 in caps.supported_vcp_codes else "✗") self.cap_contrast.setText("✓" if 0x12 in caps.supported_vcp_codes else "✗") self.cap_rgb_gain.setText("✓" if caps.has_rgb_gain else "✗") self.cap_rgb_offset.setText("✓" if caps.has_rgb_black_level else "✗") - self.cap_gamma.setText("✓" if 0xF2 in caps.supported_vcp_codes or 0x72 in caps.supported_vcp_codes else "✗") + self.cap_gamma.setText( + "✓" if 0xF2 in caps.supported_vcp_codes or 0x72 in caps.supported_vcp_codes else "✗" + ) return self.cap_brightness.setText("--") @@ -864,7 +870,9 @@ def _read_current_settings(self): self.cur_brightness.setText(str(settings.brightness)) self.cur_contrast.setText(str(settings.contrast)) self.cur_rgb.setText(f"R:{settings.red_gain} G:{settings.green_gain} B:{settings.blue_gain}") - self.cur_offset.setText(f"R:{settings.red_black_level} G:{settings.green_black_level} B:{settings.blue_black_level}") + self.cur_offset.setText( + f"R:{settings.red_black_level} G:{settings.green_black_level} B:{settings.blue_black_level}" + ) # Update sliders self.hw_brightness.setValue(settings.brightness) @@ -905,15 +913,15 @@ def _apply_single_hw_setting(self, setting: str, value: int): return code_map = { - 'brightness': VCPCode.BRIGHTNESS, - 'contrast': VCPCode.CONTRAST, - 'red_gain': VCPCode.RED_GAIN, - 'green_gain': VCPCode.GREEN_GAIN, - 'blue_gain': VCPCode.BLUE_GAIN, - 'red_offset': VCPCode.RED_BLACK_LEVEL, - 'green_offset': VCPCode.GREEN_BLACK_LEVEL, - 'blue_offset': VCPCode.BLUE_BLACK_LEVEL, - 'gamma': 0xF2, # Manufacturer-specific gamma + "brightness": VCPCode.BRIGHTNESS, + "contrast": VCPCode.CONTRAST, + "red_gain": VCPCode.RED_GAIN, + "green_gain": VCPCode.GREEN_GAIN, + "blue_gain": VCPCode.BLUE_GAIN, + "red_offset": VCPCode.RED_BLACK_LEVEL, + "green_offset": VCPCode.GREEN_BLACK_LEVEL, + "blue_offset": VCPCode.BLUE_BLACK_LEVEL, + "gamma": 0xF2, # Manufacturer-specific gamma } if setting in code_map: @@ -930,17 +938,15 @@ def _apply_hardware_settings(self): try: # Brightness and Contrast - self.ddc_controller.set_vcp(self.current_ddc_monitor, VCPCode.BRIGHTNESS, - self.hw_brightness.value()) - self.ddc_controller.set_vcp(self.current_ddc_monitor, VCPCode.CONTRAST, - self.hw_contrast.value()) + self.ddc_controller.set_vcp(self.current_ddc_monitor, VCPCode.BRIGHTNESS, self.hw_brightness.value()) + self.ddc_controller.set_vcp(self.current_ddc_monitor, VCPCode.CONTRAST, self.hw_contrast.value()) # RGB Gains self.ddc_controller.set_rgb_gain( self.current_ddc_monitor, self.hw_red_gain.value(), self.hw_green_gain.value(), - self.hw_blue_gain.value() + self.hw_blue_gain.value(), ) # RGB Offsets @@ -948,7 +954,7 @@ def _apply_hardware_settings(self): self.current_ddc_monitor, self.hw_red_offset.value(), self.hw_green_offset.value(), - self.hw_blue_offset.value() + self.hw_blue_offset.value(), ) # Gamma (try both common codes) @@ -1001,22 +1007,11 @@ def _apply_sdr_lut(self): lut = generate_sdr_calibration_lut( size=size, target_gamma=self.sdr_gamma.value(), - rgb_gains=( - self.sdr_r_gain.value(), - self.sdr_g_gain.value(), - self.sdr_b_gain.value() - ), - rgb_offsets=( - self.sdr_r_offset.value(), - self.sdr_g_offset.value(), - self.sdr_b_offset.value() - ) + rgb_gains=(self.sdr_r_gain.value(), self.sdr_g_gain.value(), self.sdr_b_gain.value()), + rgb_offsets=(self.sdr_r_offset.value(), self.sdr_g_offset.value(), self.sdr_b_offset.value()), ) - success = self.lut_controller.load_lut( - self.current_lut_monitor, lut, LUTType.SDR, - "SDR Calibration LUT" - ) + success = self.lut_controller.load_lut(self.current_lut_monitor, lut, LUTType.SDR, "SDR Calibration LUT") if success: self._log(f"Applied SDR LUT ({size}³)") @@ -1037,22 +1032,13 @@ def _apply_hdr_lut(self): lut = generate_hdr_calibration_lut( size=size, - rgb_gains=( - self.hdr_r_gain.value(), - self.hdr_g_gain.value(), - self.hdr_b_gain.value() - ), - rgb_offsets=( - self.hdr_r_offset.value(), - self.hdr_g_offset.value(), - self.hdr_b_offset.value() - ), - peak_luminance=float(self.hdr_peak.value()) + rgb_gains=(self.hdr_r_gain.value(), self.hdr_g_gain.value(), self.hdr_b_gain.value()), + rgb_offsets=(self.hdr_r_offset.value(), self.hdr_g_offset.value(), self.hdr_b_offset.value()), + peak_luminance=float(self.hdr_peak.value()), ) success = self.lut_controller.load_lut( - self.current_lut_monitor, lut, LUTType.HDR, - f"HDR Calibration LUT - Peak {self.hdr_peak.value()} nits" + self.current_lut_monitor, lut, LUTType.HDR, f"HDR Calibration LUT - Peak {self.hdr_peak.value()} nits" ) if success: @@ -1082,24 +1068,44 @@ def _apply_preset(self, name: str): """Apply a calibration preset.""" presets = { "D65 sRGB (Gamma 2.2)": { - 'hw': {'brightness': 50, 'contrast': 80, 'rgb_gains': (100, 100, 100), - 'rgb_offsets': (50, 50, 50), 'gamma': 22}, - 'sdr': {'gamma': 2.2, 'gains': (1.0, 1.0, 1.0), 'offsets': (0.0, 0.0, 0.0)} + "hw": { + "brightness": 50, + "contrast": 80, + "rgb_gains": (100, 100, 100), + "rgb_offsets": (50, 50, 50), + "gamma": 22, + }, + "sdr": {"gamma": 2.2, "gains": (1.0, 1.0, 1.0), "offsets": (0.0, 0.0, 0.0)}, }, "D65 BT.1886 (Gamma 2.4)": { - 'hw': {'brightness': 50, 'contrast': 80, 'rgb_gains': (100, 100, 100), - 'rgb_offsets': (50, 50, 50), 'gamma': 24}, - 'sdr': {'gamma': 2.4, 'gains': (1.0, 1.0, 1.0), 'offsets': (0.0, 0.0, 0.0)} + "hw": { + "brightness": 50, + "contrast": 80, + "rgb_gains": (100, 100, 100), + "rgb_offsets": (50, 50, 50), + "gamma": 24, + }, + "sdr": {"gamma": 2.4, "gains": (1.0, 1.0, 1.0), "offsets": (0.0, 0.0, 0.0)}, }, "D65 Linear": { - 'hw': {'brightness': 50, 'contrast': 80, 'rgb_gains': (100, 100, 100), - 'rgb_offsets': (50, 50, 50), 'gamma': 10}, - 'sdr': {'gamma': 1.0, 'gains': (1.0, 1.0, 1.0), 'offsets': (0.0, 0.0, 0.0)} + "hw": { + "brightness": 50, + "contrast": 80, + "rgb_gains": (100, 100, 100), + "rgb_offsets": (50, 50, 50), + "gamma": 10, + }, + "sdr": {"gamma": 1.0, "gains": (1.0, 1.0, 1.0), "offsets": (0.0, 0.0, 0.0)}, }, "Native (Identity)": { - 'hw': {'brightness': 50, 'contrast': 80, 'rgb_gains': (100, 100, 100), - 'rgb_offsets': (50, 50, 50), 'gamma': 22}, - 'sdr': {'gamma': 2.2, 'gains': (1.0, 1.0, 1.0), 'offsets': (0.0, 0.0, 0.0)} + "hw": { + "brightness": 50, + "contrast": 80, + "rgb_gains": (100, 100, 100), + "rgb_offsets": (50, 50, 50), + "gamma": 22, + }, + "sdr": {"gamma": 2.2, "gains": (1.0, 1.0, 1.0), "offsets": (0.0, 0.0, 0.0)}, }, } @@ -1107,27 +1113,27 @@ def _apply_preset(self, name: str): preset = presets[name] # Apply hardware settings - hw = preset.get('hw', {}) - self.hw_brightness.setValue(hw.get('brightness', 50)) - self.hw_contrast.setValue(hw.get('contrast', 80)) - gains = hw.get('rgb_gains', (100, 100, 100)) + hw = preset.get("hw", {}) + self.hw_brightness.setValue(hw.get("brightness", 50)) + self.hw_contrast.setValue(hw.get("contrast", 80)) + gains = hw.get("rgb_gains", (100, 100, 100)) self.hw_red_gain.setValue(gains[0]) self.hw_green_gain.setValue(gains[1]) self.hw_blue_gain.setValue(gains[2]) - offsets = hw.get('rgb_offsets', (50, 50, 50)) + offsets = hw.get("rgb_offsets", (50, 50, 50)) self.hw_red_offset.setValue(offsets[0]) self.hw_green_offset.setValue(offsets[1]) self.hw_blue_offset.setValue(offsets[2]) - self.hw_gamma.setValue(hw.get('gamma', 22)) + self.hw_gamma.setValue(hw.get("gamma", 22)) # Apply SDR LUT settings - sdr = preset.get('sdr', {}) - self.sdr_gamma.setValue(sdr.get('gamma', 2.2)) - sdr_gains = sdr.get('gains', (1.0, 1.0, 1.0)) + sdr = preset.get("sdr", {}) + self.sdr_gamma.setValue(sdr.get("gamma", 2.2)) + sdr_gains = sdr.get("gains", (1.0, 1.0, 1.0)) self.sdr_r_gain.setValue(sdr_gains[0]) self.sdr_g_gain.setValue(sdr_gains[1]) self.sdr_b_gain.setValue(sdr_gains[2]) - sdr_offsets = sdr.get('offsets', (0.0, 0.0, 0.0)) + sdr_offsets = sdr.get("offsets", (0.0, 0.0, 0.0)) self.sdr_r_offset.setValue(sdr_offsets[0]) self.sdr_g_offset.setValue(sdr_offsets[1]) self.sdr_b_offset.setValue(sdr_offsets[2]) @@ -1140,22 +1146,24 @@ def _apply_preset(self, name: str): def _save_preset(self): """Save current settings as a named preset.""" from PyQt6.QtWidgets import QInputDialog + name, ok = QInputDialog.getText(self, "Save Preset", "Preset name:") if not ok or not name.strip(): return name = name.strip() settings = { - 'brightness': self.hw_brightness.value(), - 'contrast': self.hw_contrast.value(), - 'red_gain': self.hw_red_gain.value(), - 'green_gain': self.hw_green_gain.value(), - 'blue_gain': self.hw_blue_gain.value(), - 'red_offset': self.hw_red_offset.value(), - 'green_offset': self.hw_green_offset.value(), - 'blue_offset': self.hw_blue_offset.value(), - 'gamma': self.hw_gamma.value(), + "brightness": self.hw_brightness.value(), + "contrast": self.hw_contrast.value(), + "red_gain": self.hw_red_gain.value(), + "green_gain": self.hw_green_gain.value(), + "blue_gain": self.hw_blue_gain.value(), + "red_offset": self.hw_red_offset.value(), + "green_offset": self.hw_green_offset.value(), + "blue_offset": self.hw_blue_offset.value(), + "gamma": self.hw_gamma.value(), } from PyQt6.QtCore import QSettings + qs = QSettings("CalibratePro", "Presets") qs.setValue(f"presets/{name}", settings) self._log(f"Saved preset: {name}") @@ -1164,6 +1172,7 @@ def _load_preset(self): """Load a previously saved preset.""" from PyQt6.QtCore import QSettings from PyQt6.QtWidgets import QInputDialog + qs = QSettings("CalibratePro", "Presets") qs.beginGroup("presets") names = qs.childKeys() @@ -1178,23 +1187,24 @@ def _load_preset(self): if not isinstance(settings, dict): self._log(f"Preset '{name}' is corrupted") return - self.hw_brightness.setValue(settings.get('brightness', 50)) - self.hw_contrast.setValue(settings.get('contrast', 50)) - self.hw_red_gain.setValue(settings.get('red_gain', 50)) - self.hw_green_gain.setValue(settings.get('green_gain', 50)) - self.hw_blue_gain.setValue(settings.get('blue_gain', 50)) - self.hw_red_offset.setValue(settings.get('red_offset', 50)) - self.hw_green_offset.setValue(settings.get('green_offset', 50)) - self.hw_blue_offset.setValue(settings.get('blue_offset', 50)) - self.hw_gamma.setValue(settings.get('gamma', 22)) + self.hw_brightness.setValue(settings.get("brightness", 50)) + self.hw_contrast.setValue(settings.get("contrast", 50)) + self.hw_red_gain.setValue(settings.get("red_gain", 50)) + self.hw_green_gain.setValue(settings.get("green_gain", 50)) + self.hw_blue_gain.setValue(settings.get("blue_gain", 50)) + self.hw_red_offset.setValue(settings.get("red_offset", 50)) + self.hw_green_offset.setValue(settings.get("green_offset", 50)) + self.hw_blue_offset.setValue(settings.get("blue_offset", 50)) + self.hw_gamma.setValue(settings.get("gamma", 22)) self._log(f"Loaded preset: {name}") def _factory_reset(self): """Reset monitor to factory defaults.""" reply = QMessageBox.question( - self, "Factory Reset", + self, + "Factory Reset", "This will reset the monitor to factory defaults.\nContinue?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes and self.current_ddc_monitor: diff --git a/calibrate_pro/gui/profile_manager.py b/calibrate_pro/gui/profile_manager.py index 0dc8f51..b2684dd 100644 --- a/calibrate_pro/gui/profile_manager.py +++ b/calibrate_pro/gui/profile_manager.py @@ -30,6 +30,7 @@ @dataclass class ProfileInfo: """Information about an ICC/ICM profile.""" + path: Path name: str size: int @@ -39,23 +40,24 @@ class ProfileInfo: class ProfileScanner(QThread): """Background thread to scan for profiles.""" + finished = pyqtSignal(list) progress = pyqtSignal(str) # System profiles that should not be deleted SYSTEM_PROFILES = { - 'srgb color space profile.icm', - 'rswop.icm', - 'wscrgb.icc', - 'wsrgb.icc', + "srgb color space profile.icm", + "rswop.icm", + "wscrgb.icc", + "wsrgb.icc", } def __init__(self): super().__init__() self.locations = [ - Path(os.environ.get('WINDIR', 'C:/Windows')) / 'System32' / 'spool' / 'drivers' / 'color', - Path(os.environ.get('APPDATA', '')) / 'CalibratePro', - Path(os.environ.get('LOCALAPPDATA', '')) / 'CalibratePro', + Path(os.environ.get("WINDIR", "C:/Windows")) / "System32" / "spool" / "drivers" / "color", + Path(os.environ.get("APPDATA", "")) / "CalibratePro", + Path(os.environ.get("LOCALAPPDATA", "")) / "CalibratePro", ] def run(self): @@ -67,19 +69,21 @@ def run(self): self.progress.emit(f"Scanning {location.name}...") - for ext in ['*.icc', '*.icm', '*.ICC', '*.ICM']: + for ext in ["*.icc", "*.icm", "*.ICC", "*.ICM"]: for f in location.glob(ext): try: stat = f.stat() is_system = f.name.lower() in self.SYSTEM_PROFILES - profiles.append(ProfileInfo( - path=f, - name=f.name, - size=stat.st_size, - modified=datetime.fromtimestamp(stat.st_mtime), - is_system=is_system - )) + profiles.append( + ProfileInfo( + path=f, + name=f.name, + size=stat.st_size, + modified=datetime.fromtimestamp(stat.st_mtime), + is_system=is_system, + ) + ) except Exception: pass @@ -303,11 +307,11 @@ def delete_selected(self): # Confirm reply = QMessageBox.question( - self, "Confirm Deletion", - f"Are you sure you want to delete {len(selected)} profile(s)?\n\n" - "This action cannot be undone.", + self, + "Confirm Deletion", + f"Are you sure you want to delete {len(selected)} profile(s)?\n\nThis action cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No + QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: diff --git a/calibrate_pro/gui/report_viewer.py b/calibrate_pro/gui/report_viewer.py index d5fbfdb..3bc94c4 100644 --- a/calibrate_pro/gui/report_viewer.py +++ b/calibrate_pro/gui/report_viewer.py @@ -54,9 +54,11 @@ # Report Data Structures # ============================================================================= + @dataclass class GamutCoverage: """Gamut coverage analysis.""" + srgb_coverage: float = 0.0 dci_p3_coverage: float = 0.0 bt2020_coverage: float = 0.0 @@ -67,6 +69,7 @@ class GamutCoverage: @dataclass class GrayscaleResult: """Grayscale calibration results.""" + steps: int = 21 avg_delta_e: float = 0.0 max_delta_e: float = 0.0 @@ -78,6 +81,7 @@ class GrayscaleResult: @dataclass class ColorCheckerResult: """ColorChecker verification results.""" + patches: int = 24 avg_delta_e: float = 0.0 max_delta_e: float = 0.0 @@ -89,6 +93,7 @@ class ColorCheckerResult: @dataclass class CalibrationReport: """Complete calibration report.""" + # Metadata report_id: str = "" created_at: datetime = field(default_factory=datetime.now) @@ -146,7 +151,7 @@ def to_dict(self) -> dict: "profile": { "name": self.profile_name, "path": self.profile_path, - } + }, } @@ -154,6 +159,7 @@ def to_dict(self) -> dict: # Summary Card Widget # ============================================================================= + class SummaryCard(QFrame): """Large summary statistic card.""" @@ -161,8 +167,8 @@ def __init__(self, title: str, parent: QWidget | None = None): super().__init__(parent) self.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; border-radius: 12px; padding: 16px; }} @@ -196,6 +202,7 @@ def set_value(self, value: str, color: str = None, subtitle: str = ""): # Report Summary Panel # ============================================================================= + class ReportSummaryPanel(QWidget): """Summary panel showing key calibration results.""" @@ -259,23 +266,23 @@ def set_report(self, report: CalibrationReport): # Summary cards if report.grayscale: avg = report.grayscale.avg_delta_e - color = COLORS['success'] if avg < 1.0 else COLORS['warning'] if avg < 2.0 else COLORS['error'] + color = COLORS["success"] if avg < 1.0 else COLORS["warning"] if avg < 2.0 else COLORS["error"] quality = "Excellent" if avg < 1.0 else "Good" if avg < 2.0 else "Needs improvement" self.avg_delta_e_card.set_value(f"{avg:.2f}", color, quality) max_de = report.grayscale.max_delta_e - color = COLORS['success'] if max_de < 2.0 else COLORS['warning'] if max_de < 4.0 else COLORS['error'] + color = COLORS["success"] if max_de < 2.0 else COLORS["warning"] if max_de < 4.0 else COLORS["error"] self.max_delta_e_card.set_value(f"{max_de:.2f}", color) gamma = report.grayscale.gamma_measured target = report.grayscale.gamma_target diff = abs(gamma - target) - color = COLORS['success'] if diff < 0.05 else COLORS['warning'] if diff < 0.1 else COLORS['error'] + color = COLORS["success"] if diff < 0.05 else COLORS["warning"] if diff < 0.1 else COLORS["error"] self.gamma_card.set_value(f"{gamma:.2f}", color, f"Target: {target:.2f}") if report.gamut: coverage = report.gamut.srgb_coverage - color = COLORS['success'] if coverage > 99 else COLORS['warning'] if coverage > 95 else COLORS['error'] + color = COLORS["success"] if coverage > 99 else COLORS["warning"] if coverage > 95 else COLORS["error"] self.gamut_card.set_value(f"{coverage:.1f}%", color) # Details @@ -291,6 +298,7 @@ def set_report(self, report: CalibrationReport): # Report Viewer Widget # ============================================================================= + class ReportViewer(QWidget): """Complete report viewer with export capabilities.""" @@ -309,8 +317,8 @@ def _setup_ui(self): header = QFrame() header.setStyleSheet(f""" QFrame {{ - background-color: {COLORS['surface']}; - border-bottom: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border-bottom: 1px solid {COLORS["border"]}; padding: 12px; }} """) @@ -342,16 +350,16 @@ def _setup_ui(self): self.tabs.setStyleSheet(f""" QTabWidget::pane {{ border: none; - background-color: {COLORS['background']}; + background-color: {COLORS["background"]}; }} QTabBar::tab {{ - background-color: {COLORS['surface']}; - border: 1px solid {COLORS['border']}; + background-color: {COLORS["surface"]}; + border: 1px solid {COLORS["border"]}; padding: 10px 20px; margin-right: 2px; }} QTabBar::tab:selected {{ - background-color: {COLORS['accent']}; + background-color: {COLORS["accent"]}; color: white; }} """) @@ -391,7 +399,7 @@ def _setup_ui(self): self.raw_text.setReadOnly(True) self.raw_text.setStyleSheet(f""" QTextEdit {{ - background-color: {COLORS['background']}; + background-color: {COLORS["background"]}; border: none; font-family: monospace; font-size: 12px; @@ -417,7 +425,7 @@ def _export_pdf(self): self, "Export PDF Report", f"calibration_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf", - "PDF Files (*.pdf)" + "PDF Files (*.pdf)", ) if file_path: @@ -438,13 +446,13 @@ def _export_html(self): self, "Export HTML Report", f"calibration_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html", - "HTML Files (*.html)" + "HTML Files (*.html)", ) if file_path: try: html = self._generate_html() - with open(file_path, 'w', encoding='utf-8') as f: + with open(file_path, "w", encoding="utf-8") as f: f.write(html) self.report_exported.emit(file_path) QMessageBox.information(self, "Export", f"Report exported to {file_path}") @@ -461,12 +469,12 @@ def _export_json(self): self, "Export JSON Report", f"calibration_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json", - "JSON Files (*.json)" + "JSON Files (*.json)", ) if file_path: try: - with open(file_path, 'w', encoding='utf-8') as f: + with open(file_path, "w", encoding="utf-8") as f: json.dump(self.report.to_dict(), f, indent=2) self.report_exported.emit(file_path) QMessageBox.information(self, "Export", f"Report exported to {file_path}") @@ -568,12 +576,12 @@ def _generate_html(self) -> str:

    Results Summary

    -

    Average Delta E: {avg_de}

    +

    Average Delta E: {avg_de}

    Maximum Delta E: {max_de}

    -

    Report generated on {report.created_at.strftime('%Y-%m-%d %H:%M:%S')}

    +

    Report generated on {report.created_at.strftime("%Y-%m-%d %H:%M:%S")}

    diff --git a/calibrate_pro/gui/theme.py b/calibrate_pro/gui/theme.py index aea8fbb..fb431d3 100644 --- a/calibrate_pro/gui/theme.py +++ b/calibrate_pro/gui/theme.py @@ -39,53 +39,53 @@ # Dark Theme Stylesheet DARK_STYLESHEET = f""" -QMainWindow {{ background-color: {COLORS['background']}; }} -QWidget {{ background-color: {COLORS['background']}; color: {COLORS['text_primary']}; font-family: "Segoe UI", sans-serif; font-size: 13px; }} -QMenuBar {{ background-color: {COLORS['surface']}; border-bottom: 1px solid {COLORS['border']}; padding: 4px; }} +QMainWindow {{ background-color: {COLORS["background"]}; }} +QWidget {{ background-color: {COLORS["background"]}; color: {COLORS["text_primary"]}; font-family: "Segoe UI", sans-serif; font-size: 13px; }} +QMenuBar {{ background-color: {COLORS["surface"]}; border-bottom: 1px solid {COLORS["border"]}; padding: 4px; }} QMenuBar::item {{ background-color: transparent; padding: 6px 12px; border-radius: 4px; }} -QMenuBar::item:selected {{ background-color: {COLORS['surface_alt']}; }} -QMenu {{ background-color: {COLORS['surface']}; border: 1px solid {COLORS['border']}; border-radius: 8px; padding: 4px; }} +QMenuBar::item:selected {{ background-color: {COLORS["surface_alt"]}; }} +QMenu {{ background-color: {COLORS["surface"]}; border: 1px solid {COLORS["border"]}; border-radius: 8px; padding: 4px; }} QMenu::item {{ padding: 8px 32px 8px 16px; border-radius: 4px; }} -QMenu::item:selected {{ background-color: {COLORS['accent']}; }} -QMenu::separator {{ height: 1px; background-color: {COLORS['border']}; margin: 4px 8px; }} -QToolBar {{ background-color: {COLORS['surface']}; border: none; border-bottom: 1px solid {COLORS['border']}; padding: 4px; spacing: 4px; }} -QToolButton {{ background-color: transparent; border: none; border-radius: 4px; padding: 6px 12px; color: {COLORS['text_primary']}; }} -QToolButton:hover {{ background-color: {COLORS['surface_alt']}; }} -QToolButton:checked {{ background-color: {COLORS['accent']}; color: white; }} -QPushButton {{ background-color: {COLORS['surface_alt']}; border: 1px solid {COLORS['border']}; border-radius: 6px; padding: 8px 16px; font-weight: 500; }} -QPushButton:hover {{ background-color: {COLORS['accent']}; border-color: {COLORS['accent']}; }} -QPushButton:disabled {{ background-color: {COLORS['surface']}; color: {COLORS['text_disabled']}; }} -QPushButton[primary="true"] {{ background-color: {COLORS['accent']}; border-color: {COLORS['accent']}; color: white; }} -QGroupBox {{ border: 1px solid {COLORS['border']}; border-radius: 8px; margin-top: 12px; padding: 12px; padding-top: 24px; font-weight: 600; }} -QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; left: 12px; padding: 0 6px; color: {COLORS['text_secondary']}; }} -QTabWidget::pane {{ border: 1px solid {COLORS['border']}; border-radius: 8px; background-color: {COLORS['surface']}; }} -QTabBar::tab {{ background-color: {COLORS['background_alt']}; border: 1px solid {COLORS['border']}; border-bottom: none; border-top-left-radius: 6px; border-top-right-radius: 6px; padding: 8px 16px; margin-right: 2px; }} -QTabBar::tab:selected {{ background-color: {COLORS['surface']}; }} -QComboBox {{ background-color: {COLORS['surface_alt']}; border: 1px solid {COLORS['border']}; border-radius: 4px; padding: 6px 12px; min-width: 120px; }} +QMenu::item:selected {{ background-color: {COLORS["accent"]}; }} +QMenu::separator {{ height: 1px; background-color: {COLORS["border"]}; margin: 4px 8px; }} +QToolBar {{ background-color: {COLORS["surface"]}; border: none; border-bottom: 1px solid {COLORS["border"]}; padding: 4px; spacing: 4px; }} +QToolButton {{ background-color: transparent; border: none; border-radius: 4px; padding: 6px 12px; color: {COLORS["text_primary"]}; }} +QToolButton:hover {{ background-color: {COLORS["surface_alt"]}; }} +QToolButton:checked {{ background-color: {COLORS["accent"]}; color: white; }} +QPushButton {{ background-color: {COLORS["surface_alt"]}; border: 1px solid {COLORS["border"]}; border-radius: 6px; padding: 8px 16px; font-weight: 500; }} +QPushButton:hover {{ background-color: {COLORS["accent"]}; border-color: {COLORS["accent"]}; }} +QPushButton:disabled {{ background-color: {COLORS["surface"]}; color: {COLORS["text_disabled"]}; }} +QPushButton[primary="true"] {{ background-color: {COLORS["accent"]}; border-color: {COLORS["accent"]}; color: white; }} +QGroupBox {{ border: 1px solid {COLORS["border"]}; border-radius: 8px; margin-top: 12px; padding: 12px; padding-top: 24px; font-weight: 600; }} +QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; left: 12px; padding: 0 6px; color: {COLORS["text_secondary"]}; }} +QTabWidget::pane {{ border: 1px solid {COLORS["border"]}; border-radius: 8px; background-color: {COLORS["surface"]}; }} +QTabBar::tab {{ background-color: {COLORS["background_alt"]}; border: 1px solid {COLORS["border"]}; border-bottom: none; border-top-left-radius: 6px; border-top-right-radius: 6px; padding: 8px 16px; margin-right: 2px; }} +QTabBar::tab:selected {{ background-color: {COLORS["surface"]}; }} +QComboBox {{ background-color: {COLORS["surface_alt"]}; border: 1px solid {COLORS["border"]}; border-radius: 4px; padding: 6px 12px; min-width: 120px; }} QComboBox::drop-down {{ border: none; width: 24px; }} -QComboBox::down-arrow {{ image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 6px solid {COLORS['text_secondary']}; }} -QComboBox QAbstractItemView {{ background-color: {COLORS['surface']}; border: 1px solid {COLORS['border']}; selection-background-color: {COLORS['accent']}; }} -QSpinBox, QDoubleSpinBox {{ background-color: {COLORS['surface_alt']}; border: 1px solid {COLORS['border']}; border-radius: 4px; padding: 6px; }} -QSlider::groove:horizontal {{ background-color: {COLORS['surface_alt']}; height: 6px; border-radius: 3px; }} -QSlider::handle:horizontal {{ background-color: {COLORS['accent']}; width: 16px; height: 16px; margin: -5px 0; border-radius: 8px; }} -QSlider::sub-page:horizontal {{ background-color: {COLORS['accent']}; border-radius: 3px; }} -QTableWidget {{ background-color: {COLORS['surface']}; border: 1px solid {COLORS['border']}; border-radius: 4px; gridline-color: {COLORS['border']}; }} +QComboBox::down-arrow {{ image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 6px solid {COLORS["text_secondary"]}; }} +QComboBox QAbstractItemView {{ background-color: {COLORS["surface"]}; border: 1px solid {COLORS["border"]}; selection-background-color: {COLORS["accent"]}; }} +QSpinBox, QDoubleSpinBox {{ background-color: {COLORS["surface_alt"]}; border: 1px solid {COLORS["border"]}; border-radius: 4px; padding: 6px; }} +QSlider::groove:horizontal {{ background-color: {COLORS["surface_alt"]}; height: 6px; border-radius: 3px; }} +QSlider::handle:horizontal {{ background-color: {COLORS["accent"]}; width: 16px; height: 16px; margin: -5px 0; border-radius: 8px; }} +QSlider::sub-page:horizontal {{ background-color: {COLORS["accent"]}; border-radius: 3px; }} +QTableWidget {{ background-color: {COLORS["surface"]}; border: 1px solid {COLORS["border"]}; border-radius: 4px; gridline-color: {COLORS["border"]}; }} QTableWidget::item {{ padding: 8px; }} -QTableWidget::item:selected {{ background-color: {COLORS['accent']}; }} -QHeaderView::section {{ background-color: {COLORS['surface_alt']}; border: none; border-bottom: 1px solid {COLORS['border']}; padding: 8px; font-weight: 600; }} -QListWidget {{ background-color: {COLORS['surface']}; border: 1px solid {COLORS['border']}; border-radius: 4px; }} -QListWidget::item {{ padding: 8px; border-bottom: 1px solid {COLORS['border']}; }} -QListWidget::item:selected {{ background-color: {COLORS['accent']}; }} -QLineEdit {{ background-color: {COLORS['surface_alt']}; border: 1px solid {COLORS['border']}; border-radius: 4px; padding: 8px; }} -QCheckBox::indicator {{ width: 18px; height: 18px; border-radius: 4px; border: 1px solid {COLORS['border']}; background-color: {COLORS['surface_alt']}; }} -QCheckBox::indicator:checked {{ background-color: {COLORS['accent']}; border-color: {COLORS['accent']}; }} -QRadioButton::indicator {{ width: 18px; height: 18px; border-radius: 9px; border: 1px solid {COLORS['border']}; background-color: {COLORS['surface_alt']}; }} -QRadioButton::indicator:checked {{ background-color: {COLORS['accent']}; border-color: {COLORS['accent']}; }} -QProgressBar {{ background-color: {COLORS['surface']}; border: none; border-radius: 4px; height: 8px; }} -QProgressBar::chunk {{ background-color: {COLORS['accent']}; border-radius: 4px; }} -QScrollBar:vertical {{ background-color: {COLORS['background']}; width: 10px; border-radius: 5px; }} -QScrollBar::handle:vertical {{ background-color: {COLORS['surface_alt']}; border-radius: 5px; min-height: 30px; }} +QTableWidget::item:selected {{ background-color: {COLORS["accent"]}; }} +QHeaderView::section {{ background-color: {COLORS["surface_alt"]}; border: none; border-bottom: 1px solid {COLORS["border"]}; padding: 8px; font-weight: 600; }} +QListWidget {{ background-color: {COLORS["surface"]}; border: 1px solid {COLORS["border"]}; border-radius: 4px; }} +QListWidget::item {{ padding: 8px; border-bottom: 1px solid {COLORS["border"]}; }} +QListWidget::item:selected {{ background-color: {COLORS["accent"]}; }} +QLineEdit {{ background-color: {COLORS["surface_alt"]}; border: 1px solid {COLORS["border"]}; border-radius: 4px; padding: 8px; }} +QCheckBox::indicator {{ width: 18px; height: 18px; border-radius: 4px; border: 1px solid {COLORS["border"]}; background-color: {COLORS["surface_alt"]}; }} +QCheckBox::indicator:checked {{ background-color: {COLORS["accent"]}; border-color: {COLORS["accent"]}; }} +QRadioButton::indicator {{ width: 18px; height: 18px; border-radius: 9px; border: 1px solid {COLORS["border"]}; background-color: {COLORS["surface_alt"]}; }} +QRadioButton::indicator:checked {{ background-color: {COLORS["accent"]}; border-color: {COLORS["accent"]}; }} +QProgressBar {{ background-color: {COLORS["surface"]}; border: none; border-radius: 4px; height: 8px; }} +QProgressBar::chunk {{ background-color: {COLORS["accent"]}; border-radius: 4px; }} +QScrollBar:vertical {{ background-color: {COLORS["background"]}; width: 10px; border-radius: 5px; }} +QScrollBar::handle:vertical {{ background-color: {COLORS["surface_alt"]}; border-radius: 5px; min-height: 30px; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0px; }} -QStatusBar {{ background-color: {COLORS['surface']}; border-top: 1px solid {COLORS['border']}; }} +QStatusBar {{ background-color: {COLORS["surface"]}; border-top: 1px solid {COLORS["border"]}; }} QLabel {{ background-color: transparent; }} """ diff --git a/calibrate_pro/gui/widgets/__init__.py b/calibrate_pro/gui/widgets/__init__.py index 7244364..005e797 100644 --- a/calibrate_pro/gui/widgets/__init__.py +++ b/calibrate_pro/gui/widgets/__init__.py @@ -71,7 +71,6 @@ "SPECTRAL_LOCUS", "WHITE_POINTS", "GAMUTS", - # Gamma Curves "GammaCurveWidget", "GammaInfoPanel", @@ -81,7 +80,6 @@ "bt1886_eotf", "power_law_eotf", "l_star_eotf", - # Delta E Charts "DeltaEBarChart", "DeltaEStatsPanel", @@ -89,7 +87,6 @@ "DeltaEQuality", "classify_delta_e", "get_delta_e_color", - # Color Swatches "ColorSwatch", "ComparisonSwatch", diff --git a/calibrate_pro/gui/widgets/cie_diagram.py b/calibrate_pro/gui/widgets/cie_diagram.py index 473c873..27a0ac8 100644 --- a/calibrate_pro/gui/widgets/cie_diagram.py +++ b/calibrate_pro/gui/widgets/cie_diagram.py @@ -24,28 +24,71 @@ # CIE 1931 spectral locus: (wavelength_nm, x, y) at every 5nm from 380-780nm SPECTRAL_LOCUS = [ - (380, 0.1741, 0.0050), (385, 0.1740, 0.0050), (390, 0.1738, 0.0049), - (395, 0.1736, 0.0049), (400, 0.1733, 0.0048), (405, 0.1730, 0.0048), - (410, 0.1726, 0.0048), (415, 0.1721, 0.0048), (420, 0.1714, 0.0051), - (425, 0.1703, 0.0058), (430, 0.1689, 0.0069), (435, 0.1669, 0.0086), - (440, 0.1644, 0.0109), (445, 0.1611, 0.0138), (450, 0.1566, 0.0177), - (455, 0.1510, 0.0227), (460, 0.1440, 0.0297), (465, 0.1355, 0.0399), - (470, 0.1241, 0.0578), (475, 0.1096, 0.0868), (480, 0.0913, 0.1327), - (485, 0.0687, 0.2007), (490, 0.0454, 0.2950), (495, 0.0235, 0.4127), - (500, 0.0082, 0.5384), (505, 0.0039, 0.6548), (510, 0.0139, 0.7502), - (515, 0.0389, 0.8120), (520, 0.0743, 0.8338), (525, 0.1142, 0.8262), - (530, 0.1547, 0.8059), (535, 0.1929, 0.7816), (540, 0.2296, 0.7543), - (545, 0.2658, 0.7243), (550, 0.3016, 0.6923), (555, 0.3373, 0.6589), - (560, 0.3731, 0.6245), (565, 0.4087, 0.5896), (570, 0.4441, 0.5547), - (575, 0.4788, 0.5202), (580, 0.5125, 0.4866), (585, 0.5448, 0.4544), - (590, 0.5752, 0.4242), (595, 0.6029, 0.3965), (600, 0.6270, 0.3725), - (605, 0.6482, 0.3514), (610, 0.6658, 0.3340), (615, 0.6801, 0.3197), - (620, 0.6915, 0.3083), (625, 0.7006, 0.2993), (630, 0.7079, 0.2920), - (635, 0.7140, 0.2859), (640, 0.7190, 0.2809), (645, 0.7230, 0.2770), - (650, 0.7260, 0.2740), (655, 0.7283, 0.2717), (660, 0.7300, 0.2700), - (665, 0.7311, 0.2689), (670, 0.7320, 0.2680), (675, 0.7327, 0.2673), - (680, 0.7334, 0.2666), (685, 0.7340, 0.2660), (690, 0.7344, 0.2656), - (695, 0.7346, 0.2654), (700, 0.7347, 0.2653), + (380, 0.1741, 0.0050), + (385, 0.1740, 0.0050), + (390, 0.1738, 0.0049), + (395, 0.1736, 0.0049), + (400, 0.1733, 0.0048), + (405, 0.1730, 0.0048), + (410, 0.1726, 0.0048), + (415, 0.1721, 0.0048), + (420, 0.1714, 0.0051), + (425, 0.1703, 0.0058), + (430, 0.1689, 0.0069), + (435, 0.1669, 0.0086), + (440, 0.1644, 0.0109), + (445, 0.1611, 0.0138), + (450, 0.1566, 0.0177), + (455, 0.1510, 0.0227), + (460, 0.1440, 0.0297), + (465, 0.1355, 0.0399), + (470, 0.1241, 0.0578), + (475, 0.1096, 0.0868), + (480, 0.0913, 0.1327), + (485, 0.0687, 0.2007), + (490, 0.0454, 0.2950), + (495, 0.0235, 0.4127), + (500, 0.0082, 0.5384), + (505, 0.0039, 0.6548), + (510, 0.0139, 0.7502), + (515, 0.0389, 0.8120), + (520, 0.0743, 0.8338), + (525, 0.1142, 0.8262), + (530, 0.1547, 0.8059), + (535, 0.1929, 0.7816), + (540, 0.2296, 0.7543), + (545, 0.2658, 0.7243), + (550, 0.3016, 0.6923), + (555, 0.3373, 0.6589), + (560, 0.3731, 0.6245), + (565, 0.4087, 0.5896), + (570, 0.4441, 0.5547), + (575, 0.4788, 0.5202), + (580, 0.5125, 0.4866), + (585, 0.5448, 0.4544), + (590, 0.5752, 0.4242), + (595, 0.6029, 0.3965), + (600, 0.6270, 0.3725), + (605, 0.6482, 0.3514), + (610, 0.6658, 0.3340), + (615, 0.6801, 0.3197), + (620, 0.6915, 0.3083), + (625, 0.7006, 0.2993), + (630, 0.7079, 0.2920), + (635, 0.7140, 0.2859), + (640, 0.7190, 0.2809), + (645, 0.7230, 0.2770), + (650, 0.7260, 0.2740), + (655, 0.7283, 0.2717), + (660, 0.7300, 0.2700), + (665, 0.7311, 0.2689), + (670, 0.7320, 0.2680), + (675, 0.7327, 0.2673), + (680, 0.7334, 0.2666), + (685, 0.7340, 0.2660), + (690, 0.7344, 0.2656), + (695, 0.7346, 0.2654), + (700, 0.7347, 0.2653), ] # Standard illuminant white points @@ -54,8 +97,8 @@ "D55": (0.3324, 0.3474), "D65": (0.3127, 0.3290), "D75": (0.2990, 0.3149), - "E": (0.3333, 0.3333), - "A": (0.4476, 0.4074), + "E": (0.3333, 0.3333), + "A": (0.4476, 0.4074), } # Standard color gamuts as (R, G, B) xy tuples @@ -96,6 +139,7 @@ @dataclass class MeasuredPoint: """A measured chromaticity point to display on the diagram.""" + x: float y: float label: str = "" @@ -106,6 +150,7 @@ class MeasuredPoint: # Planckian Locus Utilities # ============================================================================= + def _planckian_xy(T: float) -> tuple[float, float]: """ Compute CIE xy chromaticity of a blackbody radiator at temperature T (K). @@ -114,14 +159,11 @@ def _planckian_xy(T: float) -> tuple[float, float]: T2 = T * T T3 = T2 * T if T <= 4000: - x = (-0.2661239e9 / T3 - 0.2343589e6 / T2 - + 0.8776956e3 / T + 0.179910) + x = -0.2661239e9 / T3 - 0.2343589e6 / T2 + 0.8776956e3 / T + 0.179910 elif T <= 7000: - x = (-4.6070e9 / T3 + 2.9678e6 / T2 - + 0.09911e3 / T + 0.244063) + x = -4.6070e9 / T3 + 2.9678e6 / T2 + 0.09911e3 / T + 0.244063 else: - x = (-2.0064e9 / T3 + 1.9018e6 / T2 - - 0.24748e3 / T + 0.237040) + x = -2.0064e9 / T3 + 1.9018e6 / T2 - 0.24748e3 / T + 0.237040 x2 = x * x x3 = x2 * x @@ -156,6 +198,7 @@ def _nearest_cct(cx: float, cy: float) -> float | None: # xy to approximate sRGB # ============================================================================= + def _xy_to_srgb(cx: float, cy: float) -> tuple[int, int, int]: """ Convert CIE xy chromaticity (at Y=1) to approximate sRGB (0-255). @@ -168,9 +211,9 @@ def _xy_to_srgb(cx: float, cy: float) -> tuple[int, int, int]: Z = ((1.0 - cx - cy) / cy) * Y # XYZ to linear sRGB (D65) - r = 3.2406 * X - 1.5372 * Y - 0.4986 * Z + r = 3.2406 * X - 1.5372 * Y - 0.4986 * Z g = -0.9689 * X + 1.8758 * Y + 0.0415 * Z - b = 0.0557 * X - 0.2040 * Y + 1.0570 * Z + b = 0.0557 * X - 0.2040 * Y + 1.0570 * Z def gamma(v): v = max(0.0, v) @@ -209,6 +252,7 @@ def _is_inside_locus(cx: float, cy: float) -> bool: # CIE 1931 Chromaticity Diagram Widget # ============================================================================= + class CIEDiagramWidget(QWidget): """ Interactive CIE 1931 xy chromaticity diagram. @@ -233,8 +277,7 @@ def __init__(self, parent: QWidget | None = None): super().__init__(parent) self.setMinimumSize(350, 350) - self.setSizePolicy(QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Expanding) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.setMouseTracking(True) # View bounds (mutable for zoom / pan) @@ -249,9 +292,9 @@ def __init__(self, parent: QWidget | None = None): self._bg_cache_view: tuple | None = None # Overlays - self._display_gamut: tuple | None = None # (r_xy, g_xy, b_xy) - self._display_wp: tuple | None = None # white point xy - self._target_gamut: tuple | None = None # (r_xy, g_xy, b_xy) + self._display_gamut: tuple | None = None # (r_xy, g_xy, b_xy) + self._display_wp: tuple | None = None # white point xy + self._target_gamut: tuple | None = None # (r_xy, g_xy, b_xy) self._measured_points: list[MeasuredPoint] = [] # Pan state @@ -290,9 +333,7 @@ def set_target_gamut( def set_measured_points(self, points: list[tuple[float, float, str]]): """Set measured chromaticity points as list of (x, y, label).""" - self._measured_points = [ - MeasuredPoint(x, y, label) for x, y, label in points - ] + self._measured_points = [MeasuredPoint(x, y, label) for x, y, label in points] self.update() def reset_view(self): @@ -365,9 +406,7 @@ def _ensure_bg(self): """Build the background cache if stale.""" sz = self.size() view = (self._vx0, self._vx1, self._vy0, self._vy1) - if (self._bg_cache is not None - and self._bg_cache_size == sz - and self._bg_cache_view == view): + if self._bg_cache is not None and self._bg_cache_size == sz and self._bg_cache_view == view: return self._bg_cache = self._render_background() self._bg_cache_size = QSize(sz) @@ -406,7 +445,8 @@ def paintEvent(self, event): # noqa: N802 # Target gamut (dotted green) if self._target_gamut: self._paint_gamut_triangle( - p, self._target_gamut, + p, + self._target_gamut, color=QColor(C.GREEN), width=1.5, style=Qt.PenStyle.DotLine, @@ -415,7 +455,8 @@ def paintEvent(self, event): # noqa: N802 # Display gamut (solid accent) if self._display_gamut: self._paint_gamut_triangle( - p, self._display_gamut, + p, + self._display_gamut, color=QColor(C.ACCENT), width=2.0, style=Qt.PenStyle.SolidLine, @@ -490,8 +531,7 @@ def _paint_spectral_locus(self, p: QPainter): off = 12 lx = pt.x() + dx / d * off ly = pt.y() - dy / d * off - p.drawText(int(lx) - 10, int(ly) - 4, 28, 14, - Qt.AlignmentFlag.AlignCenter, f"{wl}") + p.drawText(int(lx) - 10, int(ly) - 4, 28, 14, Qt.AlignmentFlag.AlignCenter, f"{wl}") # -- Planckian locus ---------------------------------------------------- @@ -551,11 +591,11 @@ def _paint_srgb_triangle(self, p: QPainter): p.setPen(QColor(C.TEXT3)) offsets = [(6, 8), (-14, -6), (-6, 14)] for i, (label, off) in enumerate(zip(["R", "G", "B"], offsets)): - p.drawText(int(pts[i].x()) + off[0], - int(pts[i].y()) + off[1], label) + p.drawText(int(pts[i].x()) + off[0], int(pts[i].y()) + off[1], label) def _paint_gamut_triangle( - self, p: QPainter, + self, + p: QPainter, gamut: tuple, color: QColor, width: float = 2.0, @@ -602,12 +642,14 @@ def _paint_white_points(self, p: QPainter): p.setPen(pen) p.setBrush(Qt.BrushStyle.NoBrush) # Diamond - diamond = QPolygonF([ - QPointF(wp.x(), wp.y() - sz), - QPointF(wp.x() + sz, wp.y()), - QPointF(wp.x(), wp.y() + sz), - QPointF(wp.x() - sz, wp.y()), - ]) + diamond = QPolygonF( + [ + QPointF(wp.x(), wp.y() - sz), + QPointF(wp.x() + sz, wp.y()), + QPointF(wp.x(), wp.y() + sz), + QPointF(wp.x() - sz, wp.y()), + ] + ) p.drawPolygon(diamond) # Label p.setFont(QFont("Segoe UI", 7)) @@ -640,9 +682,14 @@ def _paint_axis_labels(self, p: QPainter): while x <= 0.8 + 1e-6: if self._vx0 <= x <= self._vx1: pt = self._xy_to_px(x, self._vy0) - p.drawText(int(pt.x()) - 12, int(pt.y()) + 4, 24, 16, - Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, - f"{x:.1f}") + p.drawText( + int(pt.x()) - 12, + int(pt.y()) + 4, + 24, + 16, + Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, + f"{x:.1f}", + ) x += step # Y axis tick labels @@ -650,9 +697,14 @@ def _paint_axis_labels(self, p: QPainter): while y <= 0.9 + 1e-6: if self._vy0 <= y <= self._vy1: pt = self._xy_to_px(self._vx0, y) - p.drawText(int(pt.x()) - 34, int(pt.y()) - 8, 30, 16, - Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, - f"{y:.1f}") + p.drawText( + int(pt.x()) - 34, + int(pt.y()) - 8, + 30, + 16, + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + f"{y:.1f}", + ) y += step # Axis titles @@ -712,10 +764,9 @@ def mouseReleaseEvent(self, event: QMouseEvent): # noqa: N802 if self._panning: # If barely moved, treat as a click if self._pan_start_pos: - d = (event.position() - self._pan_start_pos) + d = event.position() - self._pan_start_pos if d.manhattanLength() < 4: - cx, cy = self._px_to_xy(event.position().x(), - event.position().y()) + cx, cy = self._px_to_xy(event.position().x(), event.position().y()) self.point_clicked.emit(cx, cy) self._panning = False self._pan_start_pos = None diff --git a/calibrate_pro/gui/widgets/color_swatch.py b/calibrate_pro/gui/widgets/color_swatch.py index 651f429..4c7a14d 100644 --- a/calibrate_pro/gui/widgets/color_swatch.py +++ b/calibrate_pro/gui/widgets/color_swatch.py @@ -17,6 +17,7 @@ # Color Conversion Utilities # ============================================================================= + def rgb_to_xyz(r: int, g: int, b: int) -> tuple[float, float, float]: """Convert sRGB to XYZ (D65).""" # Normalize to 0-1 @@ -44,9 +45,9 @@ def xyz_to_lab(X: float, Y: float, Z: float) -> tuple[float, float, float]: def f(t): delta = 6 / 29 - if t > delta ** 3: - return t ** (1/3) - return t / (3 * delta ** 2) + 4 / 29 + if t > delta**3: + return t ** (1 / 3) + return t / (3 * delta**2) + 4 / 29 fx = f(X / Xn) fy = f(Y / Yn) @@ -65,8 +66,7 @@ def rgb_to_lab(r: int, g: int, b: int) -> tuple[float, float, float]: return xyz_to_lab(X, Y, Z) -def delta_e_2000(lab1: tuple[float, float, float], - lab2: tuple[float, float, float]) -> float: +def delta_e_2000(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float: """Calculate CIEDE2000 color difference.""" L1, a1, b1 = lab1 L2, a2, b2 = lab2 @@ -75,20 +75,20 @@ def delta_e_2000(lab1: tuple[float, float, float], L_bar = (L1 + L2) / 2 # C values - C1 = math.sqrt(a1 ** 2 + b1 ** 2) - C2 = math.sqrt(a2 ** 2 + b2 ** 2) + C1 = math.sqrt(a1**2 + b1**2) + C2 = math.sqrt(a2**2 + b2**2) C_bar = (C1 + C2) / 2 # G factor - G = 0.5 * (1 - math.sqrt(C_bar ** 7 / (C_bar ** 7 + 25 ** 7))) + G = 0.5 * (1 - math.sqrt(C_bar**7 / (C_bar**7 + 25**7))) # a' values a1_prime = a1 * (1 + G) a2_prime = a2 * (1 + G) # C' values - C1_prime = math.sqrt(a1_prime ** 2 + b1 ** 2) - C2_prime = math.sqrt(a2_prime ** 2 + b2 ** 2) + C1_prime = math.sqrt(a1_prime**2 + b1**2) + C2_prime = math.sqrt(a2_prime**2 + b2**2) C_bar_prime = (C1_prime + C2_prime) / 2 # h' values @@ -125,10 +125,13 @@ def h_prime(a, b): H_bar_prime = (h1_prime + h2_prime - 360) / 2 # T factor - T = (1 - 0.17 * math.cos(math.radians(H_bar_prime - 30)) - + 0.24 * math.cos(math.radians(2 * H_bar_prime)) - + 0.32 * math.cos(math.radians(3 * H_bar_prime + 6)) - - 0.20 * math.cos(math.radians(4 * H_bar_prime - 63))) + T = ( + 1 + - 0.17 * math.cos(math.radians(H_bar_prime - 30)) + + 0.24 * math.cos(math.radians(2 * H_bar_prime)) + + 0.32 * math.cos(math.radians(3 * H_bar_prime + 6)) + - 0.20 * math.cos(math.radians(4 * H_bar_prime - 63)) + ) # Delta L', C', H' delta_L_prime = L2 - L1 @@ -140,17 +143,17 @@ def h_prime(a, b): S_H = 1 + 0.015 * C_bar_prime * T # R_T - delta_theta = 30 * math.exp(-((H_bar_prime - 275) / 25) ** 2) - R_C = 2 * math.sqrt(C_bar_prime ** 7 / (C_bar_prime ** 7 + 25 ** 7)) + delta_theta = 30 * math.exp(-(((H_bar_prime - 275) / 25) ** 2)) + R_C = 2 * math.sqrt(C_bar_prime**7 / (C_bar_prime**7 + 25**7)) R_T = -R_C * math.sin(math.radians(2 * delta_theta)) # Final calculation kL = kC = kH = 1 # Unity for default delta_E = math.sqrt( - (delta_L_prime / (kL * S_L)) ** 2 + - (delta_C_prime / (kC * S_C)) ** 2 + - (delta_H_prime / (kH * S_H)) ** 2 + - R_T * (delta_C_prime / (kC * S_C)) * (delta_H_prime / (kH * S_H)) + (delta_L_prime / (kL * S_L)) ** 2 + + (delta_C_prime / (kC * S_C)) ** 2 + + (delta_H_prime / (kH * S_H)) ** 2 + + R_T * (delta_C_prime / (kC * S_C)) * (delta_H_prime / (kH * S_H)) ) return delta_E @@ -160,6 +163,7 @@ def h_prime(a, b): # Color Swatch Widget # ============================================================================= + class ColorSwatch(QWidget): """Single color swatch display.""" @@ -242,6 +246,7 @@ def mousePressEvent(self, event): # Comparison Swatch Widget # ============================================================================= + class ComparisonSwatch(QWidget): """Side-by-side target and measured color comparison.""" @@ -327,6 +332,7 @@ def paintEvent(self, event): # Color Info Panel # ============================================================================= + class ColorInfoPanel(QWidget): """Detailed color information display.""" @@ -388,6 +394,7 @@ def set_color(self, r: int, g: int, b: int, label: str = ""): # Color Grid Widget # ============================================================================= + class ColorGrid(QWidget): """Grid of color swatches (e.g., for ColorChecker display).""" diff --git a/calibrate_pro/gui/widgets/delta_e_chart.py b/calibrate_pro/gui/widgets/delta_e_chart.py index c27a45a..79139ac 100644 --- a/calibrate_pro/gui/widgets/delta_e_chart.py +++ b/calibrate_pro/gui/widgets/delta_e_chart.py @@ -18,14 +18,16 @@ # Delta E Classifications # ============================================================================= + class DeltaEQuality(Enum): """Quality classification based on Delta E value.""" - IMPERCEPTIBLE = auto() # < 1.0 - EXCELLENT = auto() # < 2.0 - GOOD = auto() # < 3.0 - ACCEPTABLE = auto() # < 5.0 - NOTICEABLE = auto() # < 10.0 - POOR = auto() # >= 10.0 + + IMPERCEPTIBLE = auto() # < 1.0 + EXCELLENT = auto() # < 2.0 + GOOD = auto() # < 3.0 + ACCEPTABLE = auto() # < 5.0 + NOTICEABLE = auto() # < 10.0 + POOR = auto() # >= 10.0 def classify_delta_e(value: float) -> DeltaEQuality: @@ -61,6 +63,7 @@ def get_delta_e_color(value: float) -> QColor: @dataclass class DeltaEMeasurement: """A single Delta E measurement.""" + label: str value: float target_color: tuple[int, int, int] = (128, 128, 128) # RGB @@ -71,6 +74,7 @@ class DeltaEMeasurement: # Delta E Bar Chart # ============================================================================= + class DeltaEBarChart(QWidget): """Bar chart showing Delta E values for multiple patches.""" @@ -121,15 +125,19 @@ def set_measurements(self, measurements: list[DeltaEMeasurement]): self.update() - def add_measurement(self, label: str, value: float, - target_rgb: tuple[int, int, int] = None, - measured_rgb: tuple[int, int, int] = None): + def add_measurement( + self, + label: str, + value: float, + target_rgb: tuple[int, int, int] = None, + measured_rgb: tuple[int, int, int] = None, + ): """Add a single measurement.""" m = DeltaEMeasurement( label=label, value=value, target_color=target_rgb or (128, 128, 128), - measured_color=measured_rgb or (128, 128, 128) + measured_color=measured_rgb or (128, 128, 128), ) self.measurements.append(m) self.update() @@ -270,15 +278,14 @@ def _draw_axes(self, painter: QPainter): """Draw axes and labels.""" # Y axis painter.setPen(QPen(QColor("#505050"), 2)) - painter.drawLine( - self.margin_left, self.margin_top, - self.margin_left, self.height() - self.margin_bottom - ) + painter.drawLine(self.margin_left, self.margin_top, self.margin_left, self.height() - self.margin_bottom) # X axis painter.drawLine( - self.margin_left, self.height() - self.margin_bottom, - self.width() - self.margin_right, self.height() - self.margin_bottom + self.margin_left, + self.height() - self.margin_bottom, + self.width() - self.margin_right, + self.height() - self.margin_bottom, ) # Y axis labels @@ -312,9 +319,7 @@ def _draw_axes(self, painter: QPainter): painter.restore() else: painter.drawText( - int(rect.center().x() - 15), - self.height() - self.margin_bottom + 15, - measurement.label[:6] + int(rect.center().x() - 15), self.height() - self.margin_bottom + 15, measurement.label[:6] ) def mouseMoveEvent(self, event): @@ -354,6 +359,7 @@ def mousePressEvent(self, event): # Delta E Statistics Panel # ============================================================================= + class DeltaEStatsPanel(QWidget): """Statistics summary for Delta E measurements.""" diff --git a/calibrate_pro/gui/widgets/gamma_curve.py b/calibrate_pro/gui/widgets/gamma_curve.py index 9b7e542..0f70456 100644 --- a/calibrate_pro/gui/widgets/gamma_curve.py +++ b/calibrate_pro/gui/widgets/gamma_curve.py @@ -19,6 +19,7 @@ # Gamma Functions # ============================================================================= + def srgb_eotf(x: float) -> float: """sRGB EOTF (display encoding to linear light).""" if x <= 0.04045: @@ -42,7 +43,7 @@ def bt1886_eotf(x: float, gamma: float = 2.4, black: float = 0.0, white: float = def power_law_eotf(x: float, gamma: float = 2.2) -> float: """Simple power law gamma.""" - return x ** gamma + return x**gamma def l_star_eotf(x: float) -> float: @@ -55,6 +56,7 @@ def l_star_eotf(x: float) -> float: @dataclass class CurveData: """Data for a gamma curve.""" + name: str color: QColor points: list[tuple[float, float]] # (input, output) normalized 0-1 @@ -65,6 +67,7 @@ class CurveData: # Gamma Curve Widget # ============================================================================= + class GammaCurveWidget(QWidget): """Gamma/EOTF curve display widget.""" @@ -129,26 +132,20 @@ def set_target_gamma(self, gamma_type: str, gamma_value: float = 2.2): self.target_curve = CurveData( name=f"{gamma_type} (γ={gamma_value:.1f})" if gamma_type == "power" else gamma_type, color=self.colors["target"], - points=points + points=points, ) self.update() def set_measured_grayscale(self, points: list[tuple[float, float]]): """Set measured grayscale response.""" - self.measured_curves["grayscale"] = CurveData( - name="Measured", - color=self.colors["grayscale"], - points=points - ) + self.measured_curves["grayscale"] = CurveData(name="Measured", color=self.colors["grayscale"], points=points) self.update() def set_measured_channel(self, channel: str, points: list[tuple[float, float]]): """Set measured response for a specific channel (R, G, B).""" color_map = {"R": "red", "G": "green", "B": "blue"} self.measured_curves[channel] = CurveData( - name=channel, - color=self.colors.get(color_map.get(channel, "grayscale")), - points=points + name=channel, color=self.colors.get(color_map.get(channel, "grayscale")), points=points ) self.update() @@ -239,8 +236,9 @@ def _draw_grid(self, painter: QPainter): p2 = self._value_to_pixel(1, 1) painter.drawLine(p1, p2) - def _draw_curve(self, painter: QPainter, curve: CurveData, - width: float = 2, style: Qt.PenStyle = Qt.PenStyle.SolidLine): + def _draw_curve( + self, painter: QPainter, curve: CurveData, width: float = 2, style: Qt.PenStyle = Qt.PenStyle.SolidLine + ): """Draw a single curve.""" if not curve.points or not curve.visible: return @@ -424,6 +422,7 @@ def leaveEvent(self, event): # Gamma Info Panel # ============================================================================= + class GammaInfoPanel(QWidget): """Information panel showing gamma statistics.""" @@ -458,9 +457,14 @@ def _setup_ui(self): layout.addStretch() - def update_stats(self, target_gamma: float, measured_gamma: float, - avg_deviation: float, max_deviation: float, - rgb_balance: tuple[float, float, float]): + def update_stats( + self, + target_gamma: float, + measured_gamma: float, + avg_deviation: float, + max_deviation: float, + rgb_balance: tuple[float, float, float], + ): """Update the statistics display.""" self.target_label.setText(f"Target: γ {target_gamma:.2f}") self.measured_label.setText(f"Measured γ: {measured_gamma:.2f}") diff --git a/calibrate_pro/gui/workers.py b/calibrate_pro/gui/workers.py index bdec161..b44c45d 100644 --- a/calibrate_pro/gui/workers.py +++ b/calibrate_pro/gui/workers.py @@ -12,14 +12,17 @@ # Calibration Worker Thread + class CalibrationWorker(QThread): """Background thread for running calibration.""" + progress = pyqtSignal(str, float) # message, progress (0-1) finished = pyqtSignal(object) # result object error = pyqtSignal(str) # error message - def __init__(self, display_index: int = 0, apply_ddc: bool = False, - profile_name: str = None, display_name: str = None): + def __init__( + self, display_index: int = 0, apply_ddc: bool = False, profile_name: str = None, display_name: str = None + ): super().__init__() self.display_index = display_index self.apply_ddc = apply_ddc @@ -41,17 +44,14 @@ def progress_callback(msg, prog, step): # Create consent if DDC approved consent = None if self.apply_ddc: - consent = UserConsent( - user_acknowledged_risks=True, - hardware_modification_approved=True - ) + consent = UserConsent(user_acknowledged_risks=True, hardware_modification_approved=True) result = engine.run_calibration( apply_ddc=self.apply_ddc, display_index=self.display_index, consent=consent, profile_name=self.profile_name, - display_name=self.display_name + display_name=self.display_name, ) self.finished.emit(result) @@ -62,6 +62,7 @@ def progress_callback(msg, prog, step): # Color Management Status Tracker + class ColorManagementStatus: """Tracks the current state of color management (ICC profiles and LUTs).""" @@ -74,21 +75,21 @@ def __init__(self): def set_icc_profile(self, display_id: str, profile_path: str): if display_id not in self.displays: self.displays[display_id] = {} - self.displays[display_id]['icc_profile'] = profile_path + self.displays[display_id]["icc_profile"] = profile_path self.active_icc_profile = profile_path def set_lut(self, display_id: str, lut_path: str, method: str = "dwm_lut"): if display_id not in self.displays: self.displays[display_id] = {} - self.displays[display_id]['lut'] = lut_path - self.displays[display_id]['lut_method'] = method + self.displays[display_id]["lut"] = lut_path + self.displays[display_id]["lut_method"] = method self.active_lut = lut_path self.lut_method = method def clear_lut(self, display_id: str): if display_id in self.displays: - self.displays[display_id].pop('lut', None) - self.displays[display_id].pop('lut_method', None) + self.displays[display_id].pop("lut", None) + self.displays[display_id].pop("lut_method", None) self.active_lut = None self.lut_method = None diff --git a/calibrate_pro/hardware/__init__.py b/calibrate_pro/hardware/__init__.py index 6f14cfb..5dd25fc 100644 --- a/calibrate_pro/hardware/__init__.py +++ b/calibrate_pro/hardware/__init__.py @@ -43,12 +43,14 @@ def _check_backends(): try: from calibrate_pro.hardware.usb_device import check_usb_available + native_ok, _ = check_usb_available() except ImportError: pass try: from calibrate_pro.hardware.argyll_backend import check_argyll_installation + argyll_ok = check_argyll_installation() except ImportError: pass @@ -61,150 +63,195 @@ def __getattr__(name): # Native drivers (preferred) if name == "NativeBackend": from calibrate_pro.hardware.native_backend import NativeBackend + return NativeBackend elif name == "I1DisplayNative": from calibrate_pro.hardware.i1display_native import I1DisplayNative + return I1DisplayNative elif name == "SpyderNative": from calibrate_pro.hardware.spyder_native import SpyderNative + return SpyderNative elif name == "detect_i1display_native": from calibrate_pro.hardware.i1display_native import detect_i1display_native + return detect_i1display_native elif name == "detect_spyder_native": from calibrate_pro.hardware.spyder_native import detect_spyder_native + return detect_spyder_native # ArgyllCMS backend (fallback) elif name == "ArgyllBackend": from calibrate_pro.hardware.argyll_backend import ArgyllBackend + return ArgyllBackend elif name == "check_argyll_installation": from calibrate_pro.hardware.argyll_backend import check_argyll_installation + return check_argyll_installation elif name == "get_argyll_config": from calibrate_pro.hardware.argyll_backend import get_argyll_config + return get_argyll_config elif name == "set_argyll_path": from calibrate_pro.hardware.argyll_backend import set_argyll_path + return set_argyll_path # ArgyllCMS-based drivers elif name == "I1DisplayDriver": from calibrate_pro.hardware.i1display import I1DisplayDriver + return I1DisplayDriver elif name == "detect_i1display": from calibrate_pro.hardware.i1display import detect_i1display + return detect_i1display elif name == "SpyderDriver": from calibrate_pro.hardware.spyder import SpyderDriver + return SpyderDriver elif name == "detect_spyder": from calibrate_pro.hardware.spyder import detect_spyder + return detect_spyder elif name == "SpectrophotometerDriver": from calibrate_pro.hardware.spectro import SpectrophotometerDriver + return SpectrophotometerDriver elif name == "ColorCheckerDisplay": from calibrate_pro.hardware.spectro import ColorCheckerDisplay + return ColorCheckerDisplay elif name == "I1Pro": from calibrate_pro.hardware.spectro import I1Pro + return I1Pro elif name == "detect_spectrophotometer": from calibrate_pro.hardware.spectro import detect_spectrophotometer + return detect_spectrophotometer elif name == "detect_colorchecker_display": from calibrate_pro.hardware.spectro import detect_colorchecker_display + return detect_colorchecker_display # USB layer elif name == "check_usb_available": from calibrate_pro.hardware.usb_device import check_usb_available + return check_usb_available elif name == "enumerate_all_colorimeters": from calibrate_pro.hardware.usb_device import enumerate_all_colorimeters + return enumerate_all_colorimeters # Hardware Calibration Engine elif name == "HardwareCalibrationEngine": from calibrate_pro.hardware.hardware_calibration import HardwareCalibrationEngine + return HardwareCalibrationEngine elif name == "CalibrationTargets": from calibrate_pro.hardware.hardware_calibration import CalibrationTargets + return CalibrationTargets elif name == "CalibrationPhase": from calibrate_pro.hardware.hardware_calibration import CalibrationPhase + return CalibrationPhase elif name == "MeasurementResult": from calibrate_pro.hardware.hardware_calibration import MeasurementResult + return MeasurementResult elif name == "HardwareCalibrationResult": from calibrate_pro.hardware.hardware_calibration import HardwareCalibrationResult as HWCalResult + return HWCalResult # Sensorless Calibration Engine elif name == "SensorlessCalibrationEngine": from calibrate_pro.hardware.sensorless_calibration import SensorlessCalibrationEngine + return SensorlessCalibrationEngine elif name == "CalibrationTarget": from calibrate_pro.hardware.sensorless_calibration import CalibrationTarget + return CalibrationTarget elif name == "run_sensorless_calibration": from calibrate_pro.hardware.sensorless_calibration import run_sensorless_calibration + return run_sensorless_calibration elif name == "auto_calibrate": from calibrate_pro.hardware.sensorless_calibration import auto_calibrate + return auto_calibrate elif name == "detect_displays": from calibrate_pro.hardware.sensorless_calibration import detect_displays + return detect_displays elif name == "DisplayInfo": from calibrate_pro.hardware.sensorless_calibration import DisplayInfo + return DisplayInfo elif name == "ILLUMINANTS": from calibrate_pro.hardware.sensorless_calibration import ILLUMINANTS + return ILLUMINANTS elif name == "GAMUT_PRIMARIES": from calibrate_pro.hardware.sensorless_calibration import GAMUT_PRIMARIES + return GAMUT_PRIMARIES elif name == "apply_lut": from calibrate_pro.hardware.sensorless_calibration import apply_lut + return apply_lut elif name == "remove_lut": from calibrate_pro.hardware.sensorless_calibration import remove_lut + return remove_lut elif name == "get_lut_status": from calibrate_pro.hardware.sensorless_calibration import get_lut_status + return get_lut_status # DDC/CI Hardware Control elif name == "DDCCIController": from calibrate_pro.hardware.ddc_ci import DDCCIController + return DDCCIController elif name == "VCPCode": from calibrate_pro.hardware.ddc_ci import VCPCode + return VCPCode elif name == "ColorPreset": from calibrate_pro.hardware.ddc_ci import ColorPreset + return ColorPreset elif name == "MonitorCapabilities": from calibrate_pro.hardware.ddc_ci import MonitorCapabilities + return MonitorCapabilities elif name == "MonitorSettings": from calibrate_pro.hardware.ddc_ci import MonitorSettings + return MonitorSettings elif name == "HardwareCalibrator": from calibrate_pro.hardware.ddc_ci import HardwareCalibrator + return HardwareCalibrator elif name == "HardwareCalibrationTarget": from calibrate_pro.hardware.ddc_ci import HardwareCalibrationTarget + return HardwareCalibrationTarget elif name == "HardwareCalibrationResult": from calibrate_pro.hardware.ddc_ci import HardwareCalibrationResult + return HardwareCalibrationResult elif name == "detect_ddc_monitors": from calibrate_pro.hardware.ddc_ci import detect_ddc_monitors + return detect_ddc_monitors raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -295,6 +342,7 @@ def detect_all_devices(): # Try native detection first try: from calibrate_pro.hardware.native_backend import detect_colorimeters + native_devices = detect_colorimeters() devices.extend(native_devices) except Exception: @@ -304,6 +352,7 @@ def detect_all_devices(): if not devices: try: from calibrate_pro.hardware.argyll_backend import ArgyllBackend + argyll = ArgyllBackend() argyll_devices = argyll.detect_devices() devices.extend(argyll_devices) @@ -331,6 +380,7 @@ def auto_connect(prefer_native: bool = True): # Try native drivers first try: from calibrate_pro.hardware.native_backend import auto_connect as native_auto + driver = native_auto() if driver: return driver @@ -340,6 +390,7 @@ def auto_connect(prefer_native: bool = True): # Fall back to ArgyllCMS try: from calibrate_pro.hardware.spectro import detect_spectrophotometer + driver = detect_spectrophotometer() if driver: return driver @@ -348,6 +399,7 @@ def auto_connect(prefer_native: bool = True): try: from calibrate_pro.hardware.i1display import detect_i1display + driver = detect_i1display() if driver: return driver @@ -356,6 +408,7 @@ def auto_connect(prefer_native: bool = True): try: from calibrate_pro.hardware.spyder import detect_spyder + driver = detect_spyder() if driver: return driver @@ -367,13 +420,11 @@ def auto_connect(prefer_native: bool = True): def get_backend_info(): """Get information about available backends.""" - info = { - "native_usb": {"available": False, "message": ""}, - "argyll": {"available": False, "message": ""} - } + info = {"native_usb": {"available": False, "message": ""}, "argyll": {"available": False, "message": ""}} try: from calibrate_pro.hardware.usb_device import check_usb_available + available, msg = check_usb_available() info["native_usb"]["available"] = available info["native_usb"]["message"] = msg @@ -382,6 +433,7 @@ def get_backend_info(): try: from calibrate_pro.hardware.argyll_backend import check_argyll_installation + available = check_argyll_installation() info["argyll"]["available"] = available info["argyll"]["message"] = "ArgyllCMS found" if available else "ArgyllCMS not found" diff --git a/calibrate_pro/hardware/argyll_backend.py b/calibrate_pro/hardware/argyll_backend.py index 69604e4..5cc4fba 100644 --- a/calibrate_pro/hardware/argyll_backend.py +++ b/calibrate_pro/hardware/argyll_backend.py @@ -26,13 +26,15 @@ # ArgyllCMS Configuration # ============================================================================= + @dataclass class ArgyllConfig: """ArgyllCMS installation configuration.""" - bin_path: Path | None = None # Path to ArgyllCMS bin directory - ccss_path: Path | None = None # Path to CCSS files - ccmx_path: Path | None = None # Path to CCMX files - ref_path: Path | None = None # Path to reference files + + bin_path: Path | None = None # Path to ArgyllCMS bin directory + ccss_path: Path | None = None # Path to CCSS files + ccmx_path: Path | None = None # Path to CCMX files + ref_path: Path | None = None # Path to reference files def find_argyll(self) -> bool: """ @@ -68,7 +70,7 @@ def find_argyll(self) -> bool: for path in search_paths: if not path or not path.exists(): continue - exe = "spotread.exe" if os.name == 'nt' else "spotread" + exe = "spotread.exe" if os.name == "nt" else "spotread" if (path / exe).exists(): self.bin_path = path return True @@ -86,7 +88,7 @@ def get_tool(self, name: str) -> Path: if self.bin_path is None: raise RuntimeError("ArgyllCMS not found. Please install ArgyllCMS.") - exe = f"{name}.exe" if os.name == 'nt' else name + exe = f"{name}.exe" if os.name == "nt" else name tool_path = self.bin_path / exe if not tool_path.exists(): @@ -115,6 +117,7 @@ def set_argyll_path(path: Path): # ArgyllCMS Backend # ============================================================================= + class ArgyllBackend(ColorimeterBase): """ ArgyllCMS-based colorimeter implementation. @@ -140,11 +143,7 @@ def __init__(self, argyll_config: ArgyllConfig | None = None): self._devices: list[DeviceInfo] = [] def _run_tool( - self, - tool_name: str, - args: list[str], - timeout: int = 60, - capture_output: bool = True + self, tool_name: str, args: list[str], timeout: int = 60, capture_output: bool = True ) -> subprocess.CompletedProcess: """ Run an ArgyllCMS tool. @@ -168,7 +167,7 @@ def _run_tool( text=True, timeout=timeout, input="\n", # Send newline to trigger interactive prompts - cwd=str(self.temp_dir) if self.temp_dir else None + cwd=str(self.temp_dir) if self.temp_dir else None, ) return result except subprocess.TimeoutExpired as e: @@ -249,14 +248,16 @@ def detect_devices(self) -> list[DeviceInfo]: if "pro" in name.lower(): capabilities.append("ambient") - self._devices.append(DeviceInfo( - name=name, - manufacturer=manufacturer, - model=name, - serial="", - device_type=device_type, - capabilities=capabilities - )) + self._devices.append( + DeviceInfo( + name=name, + manufacturer=manufacturer, + model=name, + serial="", + device_type=device_type, + capabilities=capabilities, + ) + ) except Exception as e: print(f"Warning: Could not detect devices: {e}") @@ -335,10 +336,10 @@ def measure_spot(self) -> ColorMeasurement | None: try: args = [ f"-d{self.current_device_index + 1}", # Device number (1-based) - f"-y{self.display_type}", # Display type - "-e", # Emission mode - "-x", # No auto-calibrate prompt - "-O", # High resolution + f"-y{self.display_type}", # Display type + "-e", # Emission mode + "-x", # No auto-calibrate prompt + "-O", # High resolution ] if self.high_res_mode: @@ -390,7 +391,7 @@ def profile_display( display_number: int = 1, patch_count: int = 729, quality: str = "high", - output_name: str = "display_profile" + output_name: str = "display_profile", ) -> Path | None: """ Generate full display profile using ArgyllCMS workflow. @@ -428,7 +429,7 @@ def profile_display( f"-f{patch_count}", # Number of patches "-e4", # White + primaries "-s100", # Saturation patches - str(ti1_path.with_suffix("")) + str(ti1_path.with_suffix("")), ] result = self._run_tool("targen", targen_args, timeout=60) @@ -464,16 +465,24 @@ def profile_display( # Step 3: Generate ICC profile (colprof) icc_path = self.temp_dir / f"{output_name}.icc" - colprof_args = [ - "-v", - "-D", f"Calibrate Pro: {output_name}", - "-C", "Copyright Zain Dana Harper 2022-2026", - "-A", "ASUS", # Will be updated based on display - "-M", "Display", - ] + quality_args + [ - "-aS", # Shaper + matrix - str(ti3_path.with_suffix("")) - ] + colprof_args = ( + [ + "-v", + "-D", + f"Calibrate Pro: {output_name}", + "-C", + "Copyright Zain Dana Harper 2022-2026", + "-A", + "ASUS", # Will be updated based on display + "-M", + "Display", + ] + + quality_args + + [ + "-aS", # Shaper + matrix + str(ti3_path.with_suffix("")), + ] + ) result = self._run_tool("colprof", colprof_args, timeout=300) if result.returncode != 0: @@ -492,7 +501,7 @@ def calibrate_display( whitepoint: str = "D65", gamma: float = 2.2, luminance: float | None = None, - output_name: str = "calibration" + output_name: str = "calibration", ) -> tuple[Path, Path] | None: """ Calibrate display using ArgyllCMS dispcal. @@ -576,11 +585,7 @@ def calibrate_display( return None - def verify_calibration( - self, - display_number: int = 1, - profile_path: Path | None = None - ) -> dict | None: + def verify_calibration(self, display_number: int = 1, profile_path: Path | None = None) -> dict | None: """ Verify display calibration using dispread. @@ -602,7 +607,7 @@ def verify_calibration( "-d3", "-f100", # 100 verification patches "-e4", - str(ti1_path) + str(ti1_path), ] result = self._run_tool("targen", targen_args, timeout=60) @@ -651,7 +656,7 @@ def _parse_ti3_results(self, ti3_path: Path) -> dict | None: "delta_e_avg": sum(delta_e_values) / len(delta_e_values), "delta_e_max": max(delta_e_values), "delta_e_min": min(delta_e_values), - "patch_count": len(delta_e_values) + "patch_count": len(delta_e_values), } except Exception as e: @@ -664,6 +669,7 @@ def _parse_ti3_results(self, ti3_path: Path) -> dict | None: # Convenience Functions # ============================================================================= + def check_argyll_installation() -> bool: """Check if ArgyllCMS is installed and accessible.""" config = get_argyll_config() diff --git a/calibrate_pro/hardware/colorimeter_base.py b/calibrate_pro/hardware/colorimeter_base.py index 2add791..7b0dfdf 100644 --- a/calibrate_pro/hardware/colorimeter_base.py +++ b/calibrate_pro/hardware/colorimeter_base.py @@ -16,36 +16,43 @@ class DeviceType(Enum): """Type of measurement device.""" - COLORIMETER = "colorimeter" # Tristimulus colorimeter + + COLORIMETER = "colorimeter" # Tristimulus colorimeter SPECTROPHOTOMETER = "spectrophotometer" # Spectral measurement UNKNOWN = "unknown" + class CalibrationMode(Enum): """Device calibration/correction mode.""" - NONE = "none" # No correction - FACTORY = "factory" # Factory calibration - CCSS = "ccss" # Colorimeter Calibration Spectral Set - CCMX = "ccmx" # Colorimeter Correction Matrix - EDR = "edr" # Extended Dynamic Range - REFRESH = "refresh" # Refresh display mode + + NONE = "none" # No correction + FACTORY = "factory" # Factory calibration + CCSS = "ccss" # Colorimeter Calibration Spectral Set + CCMX = "ccmx" # Colorimeter Correction Matrix + EDR = "edr" # Extended Dynamic Range + REFRESH = "refresh" # Refresh display mode + class MeasurementType(Enum): """Type of measurement to perform.""" - SPOT = "spot" # Single spot reading - AMBIENT = "ambient" # Ambient light measurement - EMISSION = "emission" # Display emission - REFRESH_RATE = "refresh" # Display refresh rate detection + + SPOT = "spot" # Single spot reading + AMBIENT = "ambient" # Ambient light measurement + EMISSION = "emission" # Display emission + REFRESH_RATE = "refresh" # Display refresh rate detection + @dataclass class DeviceInfo: """Information about a connected measurement device.""" - name: str # Device name - manufacturer: str # Manufacturer name - model: str # Model identifier - serial: str # Serial number - device_type: DeviceType # Device type - firmware_version: str = "" # Firmware version - driver_version: str = "" # Driver version + + name: str # Device name + manufacturer: str # Manufacturer name + model: str # Model identifier + serial: str # Serial number + device_type: DeviceType # Device type + firmware_version: str = "" # Firmware version + driver_version: str = "" # Driver version capabilities: list[str] = field(default_factory=list) # Supported features def supports_spectral(self) -> bool: @@ -64,12 +71,14 @@ def to_dict(self) -> dict: "serial": self.serial, "type": self.device_type.value, "firmware": self.firmware_version, - "capabilities": self.capabilities + "capabilities": self.capabilities, } + @dataclass class ColorMeasurement: """Result of a color measurement.""" + # XYZ tristimulus values (Y in cd/m2 for absolute, or normalized) X: float Y: float @@ -123,12 +132,14 @@ def to_dict(self) -> dict: "luminance_cdm2": self.luminance, "cct": self.cct, "delta_uv": self.delta_uv, - "has_spectral": self.spectral_data is not None + "has_spectral": self.spectral_data is not None, } + @dataclass class CalibrationPatch: """A color patch for display calibration.""" + # Target RGB values [0-1] r: float g: float @@ -145,11 +156,8 @@ def get_rgb(self) -> tuple[float, float, float]: return (self.r, self.g, self.b) def get_rgb_8bit(self) -> tuple[int, int, int]: - return ( - int(self.r * 255), - int(self.g * 255), - int(self.b * 255) - ) + return (int(self.r * 255), int(self.g * 255), int(self.b * 255)) + class ColorimeterBase(ABC): """ @@ -320,9 +328,7 @@ def get_spectral_data(self) -> dict[float, float] | None: # ========================================================================== def measure_patches( - self, - patches: list[CalibrationPatch], - display_callback: Callable[[CalibrationPatch], None] + self, patches: list[CalibrationPatch], display_callback: Callable[[CalibrationPatch], None] ) -> list[CalibrationPatch]: """ Measure a sequence of color patches. @@ -338,8 +344,8 @@ def measure_patches( for i, patch in enumerate(patches): self._report_progress( - f"Measuring patch {i+1}/{total}: {patch.name or f'RGB({patch.r:.2f},{patch.g:.2f},{patch.b:.2f})'}", - i / total + f"Measuring patch {i + 1}/{total}: {patch.name or f'RGB({patch.r:.2f},{patch.g:.2f},{patch.b:.2f})'}", + i / total, ) # Display the patch @@ -354,9 +360,7 @@ def measure_patches( return patches def measure_grayscale( - self, - steps: int = 21, - display_callback: Callable[[CalibrationPatch], None] = None + self, steps: int = 21, display_callback: Callable[[CalibrationPatch], None] = None ) -> list[CalibrationPatch]: """ Measure grayscale ramp. @@ -371,19 +375,14 @@ def measure_grayscale( patches = [] for i in range(steps): level = i / (steps - 1) - patches.append(CalibrationPatch( - r=level, g=level, b=level, - index=i, - name=f"Gray {int(level * 100)}%" - )) + patches.append(CalibrationPatch(r=level, g=level, b=level, index=i, name=f"Gray {int(level * 100)}%")) if display_callback: return self.measure_patches(patches, display_callback) return patches def measure_primaries( - self, - display_callback: Callable[[CalibrationPatch], None] = None + self, display_callback: Callable[[CalibrationPatch], None] = None ) -> dict[str, ColorMeasurement]: """ Measure display primary colors and white point. @@ -418,16 +417,13 @@ def measure_primaries( # Patch Set Generators # ============================================================================= + def generate_grayscale_patches(steps: int = 21) -> list[CalibrationPatch]: """Generate grayscale ramp patches.""" patches = [] for i in range(steps): level = i / (steps - 1) - patches.append(CalibrationPatch( - r=level, g=level, b=level, - index=i, - name=f"Gray {int(level * 100)}%" - )) + patches.append(CalibrationPatch(r=level, g=level, b=level, index=i, name=f"Gray {int(level * 100)}%")) return patches @@ -488,8 +484,8 @@ def generate_profiling_patches(size: int = 729) -> list[CalibrationPatch]: List of profiling patches """ # Calculate grid size - grid = int(round(size ** (1/3))) - grid ** 3 + grid = int(round(size ** (1 / 3))) + grid**3 patches = [] index = 0 @@ -501,11 +497,7 @@ def generate_profiling_patches(size: int = 729) -> list[CalibrationPatch]: g = g_idx / (grid - 1) b = b_idx / (grid - 1) - patches.append(CalibrationPatch( - r=r, g=g, b=b, - index=index, - name=f"P{index:04d}" - )) + patches.append(CalibrationPatch(r=r, g=g, b=b, index=index, name=f"P{index:04d}")) index += 1 return patches diff --git a/calibrate_pro/hardware/ddc_ci.py b/calibrate_pro/hardware/ddc_ci.py index 2673048..4976db4 100644 --- a/calibrate_pro/hardware/ddc_ci.py +++ b/calibrate_pro/hardware/ddc_ci.py @@ -30,6 +30,7 @@ # DDC/CI VCP (Virtual Control Panel) Codes - VESA MCCS Standard # ============================================================================= + class VCPCode(IntEnum): """ VESA Monitor Control Command Set (MCCS) VCP codes. @@ -37,6 +38,7 @@ class VCPCode(IntEnum): Comprehensive list of all standard and common manufacturer codes. Not all monitors support all codes - use get_vcp to test. """ + # ========================================================================= # Preset Operations (0x00-0x0F) # ========================================================================= @@ -55,11 +57,11 @@ class VCPCode(IntEnum): # ========================================================================= # Image Adjustment (0x10-0x1F) # ========================================================================= - BRIGHTNESS = 0x10 # Luminance + BRIGHTNESS = 0x10 # Luminance CONTRAST = 0x12 - BACKLIGHT = 0x13 # Backlight control (LED displays) - COLOR_PRESET = 0x14 # Color temperature preset - RED_GAIN = 0x16 # Video gain (drive) - highlights + BACKLIGHT = 0x13 # Backlight control (LED displays) + COLOR_PRESET = 0x14 # Color temperature preset + RED_GAIN = 0x16 # Video gain (drive) - highlights GREEN_GAIN = 0x18 BLUE_GAIN = 0x1A @@ -111,7 +113,7 @@ class VCPCode(IntEnum): AUDIO_MICROPHONE_VOLUME = 0x64 AMBIENT_LIGHT_SENSOR = 0x66 REMOTE_PROCEDURE_CALL = 0x6A - RED_BLACK_LEVEL = 0x6C # Video black level (offset) - shadows + RED_BLACK_LEVEL = 0x6C # Video black level (offset) - shadows GREEN_BLACK_LEVEL = 0x6E BLUE_BLACK_LEVEL = 0x70 @@ -134,12 +136,12 @@ class VCPCode(IntEnum): AUDIO_TREBLE = 0x8C AUDIO_BASS = 0x8E SHARPNESS = 0x87 - SATURATION = 0x8A # Color saturation + SATURATION = 0x8A # Color saturation TV_SHARPNESS = 0x8C TV_CONTRAST = 0x8E FLESH_TONE_ENHANCEMENT = 0x90 TV_BLACK_LEVEL = 0x92 - WINDOW_CONTROL = 0x9B # Window position/size + WINDOW_CONTROL = 0x9B # Window position/size WINDOW_SELECT = 0x9C WINDOW_SIZE = 0x9D WINDOW_TRANSPARENCY = 0x9E @@ -168,10 +170,10 @@ class VCPCode(IntEnum): # ========================================================================= ASSET_TAG = 0xD2 DISPLAY_USAGE_TIME = 0xC0 - POWER_MODE = 0xD6 # DPMS power state + POWER_MODE = 0xD6 # DPMS power state AUXILIARY_POWER_OUTPUT = 0xD7 - SCAN_MODE = 0xDA # Interlaced/Progressive - IMAGE_MODE = 0xDB # Picture mode preset + SCAN_MODE = 0xDA # Interlaced/Progressive + IMAGE_MODE = 0xDB # Picture mode preset DISPLAY_APPLICATION = 0xDC # Application mode # ========================================================================= @@ -194,7 +196,7 @@ class VCPCode(IntEnum): MANUFACTURER_SPECIFIC_ED = 0xED MANUFACTURER_SPECIFIC_EE = 0xEE MANUFACTURER_SPECIFIC_EF = 0xEF - GAMMA = 0xF2 # Manufacturer-specific gamma selection + GAMMA = 0xF2 # Manufacturer-specific gamma selection MANUFACTURER_SPECIFIC_F4 = 0xF4 MANUFACTURER_SPECIFIC_F5 = 0xF5 MANUFACTURER_SPECIFIC_F6 = 0xF6 @@ -259,6 +261,7 @@ class VCPCode(IntEnum): class ColorPreset(IntEnum): """Standard color temperature/mode presets.""" + NATIVE = 0x01 SRGB = 0x02 COLOR_TEMP_4000K = 0x03 @@ -278,6 +281,7 @@ class ColorPreset(IntEnum): # Windows Physical Monitor API # ============================================================================= + class PHYSICAL_MONITOR(ctypes.Structure): _fields_ = [ ("hPhysicalMonitor", wintypes.HANDLE), @@ -288,6 +292,7 @@ class PHYSICAL_MONITOR(ctypes.Structure): @dataclass class MonitorCapabilities: """DDC/CI capabilities for a monitor.""" + model: str = "" supported_vcp_codes: list[int] = field(default_factory=list) color_temp_range: tuple[int, int] = (0, 0) @@ -312,23 +317,35 @@ class MonitorCapabilities: def summary(self) -> str: """Human-readable capability summary.""" features = [] - if self.has_brightness: features.append("Brightness") - if self.has_contrast: features.append("Contrast") - if self.has_rgb_gain: features.append("RGB Gain") - if self.has_rgb_black_level: features.append("RGB Offset") - if self.has_image_mode: features.append("Picture Mode") - if self.has_color_preset: features.append("Color Preset") - if self.has_gamma: features.append("Gamma") - if self.has_saturation: features.append("Saturation") - if self.has_six_axis_saturation: features.append("6-Axis Saturation") - if self.has_six_axis_hue: features.append("6-Axis Hue") - if self.has_input_source: features.append("Input Select") + if self.has_brightness: + features.append("Brightness") + if self.has_contrast: + features.append("Contrast") + if self.has_rgb_gain: + features.append("RGB Gain") + if self.has_rgb_black_level: + features.append("RGB Offset") + if self.has_image_mode: + features.append("Picture Mode") + if self.has_color_preset: + features.append("Color Preset") + if self.has_gamma: + features.append("Gamma") + if self.has_saturation: + features.append("Saturation") + if self.has_six_axis_saturation: + features.append("6-Axis Saturation") + if self.has_six_axis_hue: + features.append("6-Axis Hue") + if self.has_input_source: + features.append("Input Select") return ", ".join(features) if features else "No DDC/CI features detected" @dataclass class MonitorSettings: """Current monitor hardware settings.""" + brightness: int = 0 contrast: int = 0 red_gain: int = 0 @@ -399,37 +416,29 @@ def enumerate_monitors(self) -> list[dict[str, Any]]: # Callback for EnumDisplayMonitors MONITORENUMPROC = ctypes.WINFUNCTYPE( - wintypes.BOOL, - wintypes.HMONITOR, - wintypes.HDC, - ctypes.POINTER(wintypes.RECT), - wintypes.LPARAM + wintypes.BOOL, wintypes.HMONITOR, wintypes.HDC, ctypes.POINTER(wintypes.RECT), wintypes.LPARAM ) def monitor_callback(hMonitor, hdcMonitor, lprcMonitor, dwData): try: # Get number of physical monitors num_physical = wintypes.DWORD() - if self.dxva2.GetNumberOfPhysicalMonitorsFromHMONITOR( - hMonitor, ctypes.byref(num_physical) - ): + if self.dxva2.GetNumberOfPhysicalMonitorsFromHMONITOR(hMonitor, ctypes.byref(num_physical)): # Get physical monitor handles physical_monitors = (PHYSICAL_MONITOR * num_physical.value)() - if self.dxva2.GetPhysicalMonitorsFromHMONITOR( - hMonitor, num_physical.value, physical_monitors - ): + if self.dxva2.GetPhysicalMonitorsFromHMONITOR(hMonitor, num_physical.value, physical_monitors): for pm in physical_monitors: monitor_info = { - 'handle': pm.hPhysicalMonitor, - 'name': pm.szPhysicalMonitorDescription, - 'hmonitor': hMonitor, - 'capabilities': None + "handle": pm.hPhysicalMonitor, + "name": pm.szPhysicalMonitorDescription, + "hmonitor": hMonitor, + "capabilities": None, } # Try to get capabilities try: caps = self._get_capabilities(pm.hPhysicalMonitor) - monitor_info['capabilities'] = caps + monitor_info["capabilities"] = caps except OSError: pass @@ -454,17 +463,16 @@ def _get_capabilities(self, handle: wintypes.HANDLE) -> MonitorCapabilities: # Get capabilities string caps_str = ctypes.create_string_buffer(caps_len.value + 1) - if not self.dxva2.CapabilitiesRequestAndCapabilitiesReply( - handle, caps_str, caps_len.value - ): + if not self.dxva2.CapabilitiesRequestAndCapabilitiesReply(handle, caps_str, caps_len.value): return caps - caps.raw_capabilities = caps_str.value.decode('ascii', errors='ignore') + caps.raw_capabilities = caps_str.value.decode("ascii", errors="ignore") # Parse VCP codes from capabilities string # Format: "(vcp(10 12 16 18 1A ...))" import re - vcp_match = re.search(r'vcp\(([^)]+)\)', caps.raw_capabilities, re.IGNORECASE) + + vcp_match = re.search(r"vcp\(([^)]+)\)", caps.raw_capabilities, re.IGNORECASE) if vcp_match: vcp_str = vcp_match.group(1) for code in vcp_str.split(): @@ -480,9 +488,7 @@ def _get_capabilities(self, handle: wintypes.HANDLE) -> MonitorCapabilities: caps.has_brightness = VCPCode.BRIGHTNESS in codes caps.has_contrast = VCPCode.CONTRAST in codes caps.has_backlight = VCPCode.BACKLIGHT in codes - caps.has_rgb_gain = all( - c in codes for c in [VCPCode.RED_GAIN, VCPCode.GREEN_GAIN, VCPCode.BLUE_GAIN] - ) + caps.has_rgb_gain = all(c in codes for c in [VCPCode.RED_GAIN, VCPCode.GREEN_GAIN, VCPCode.BLUE_GAIN]) caps.has_rgb_black_level = all( c in codes for c in [VCPCode.RED_BLACK_LEVEL, VCPCode.GREEN_BLACK_LEVEL, VCPCode.BLUE_BLACK_LEVEL] ) @@ -491,15 +497,25 @@ def _get_capabilities(self, handle: wintypes.HANDLE) -> MonitorCapabilities: caps.has_gamma = VCPCode.GAMMA in codes caps.has_saturation = VCPCode.SATURATION in codes caps.has_six_axis_saturation = all( - c in codes for c in [ - VCPCode.SIX_AXIS_SATURATION_RED, VCPCode.SIX_AXIS_SATURATION_GREEN, VCPCode.SIX_AXIS_SATURATION_BLUE, - VCPCode.SIX_AXIS_SATURATION_CYAN, VCPCode.SIX_AXIS_SATURATION_MAGENTA, VCPCode.SIX_AXIS_SATURATION_YELLOW, + c in codes + for c in [ + VCPCode.SIX_AXIS_SATURATION_RED, + VCPCode.SIX_AXIS_SATURATION_GREEN, + VCPCode.SIX_AXIS_SATURATION_BLUE, + VCPCode.SIX_AXIS_SATURATION_CYAN, + VCPCode.SIX_AXIS_SATURATION_MAGENTA, + VCPCode.SIX_AXIS_SATURATION_YELLOW, ] ) caps.has_six_axis_hue = all( - c in codes for c in [ - VCPCode.SIX_AXIS_HUE_RED, VCPCode.SIX_AXIS_HUE_GREEN, VCPCode.SIX_AXIS_HUE_BLUE, - VCPCode.SIX_AXIS_HUE_CYAN, VCPCode.SIX_AXIS_HUE_MAGENTA, VCPCode.SIX_AXIS_HUE_YELLOW, + c in codes + for c in [ + VCPCode.SIX_AXIS_HUE_RED, + VCPCode.SIX_AXIS_HUE_GREEN, + VCPCode.SIX_AXIS_HUE_BLUE, + VCPCode.SIX_AXIS_HUE_CYAN, + VCPCode.SIX_AXIS_HUE_MAGENTA, + VCPCode.SIX_AXIS_HUE_YELLOW, ] ) caps.has_input_source = VCPCode.INPUT_SOURCE in codes @@ -507,12 +523,12 @@ def _get_capabilities(self, handle: wintypes.HANDLE) -> MonitorCapabilities: caps.has_osd_control = VCPCode.OSD_ENABLED in codes # Parse model name - model_match = re.search(r'model\(([^)]+)\)', caps.raw_capabilities, re.IGNORECASE) + model_match = re.search(r"model\(([^)]+)\)", caps.raw_capabilities, re.IGNORECASE) if model_match: caps.model = model_match.group(1).strip() # Parse VCP version - vcp_ver_match = re.search(r'mccs_ver\(([^)]+)\)', caps.raw_capabilities, re.IGNORECASE) + vcp_ver_match = re.search(r"mccs_ver\(([^)]+)\)", caps.raw_capabilities, re.IGNORECASE) if vcp_ver_match: caps.vcp_version = vcp_ver_match.group(1).strip() @@ -525,6 +541,7 @@ def _get_capabilities(self, handle: wintypes.HANDLE) -> MonitorCapabilities: def _rate_limit(self): """Enforce minimum interval between DDC/CI commands.""" import time + now = time.time() * 1000 elapsed = now - self._last_command_time if elapsed < self._MIN_COMMAND_INTERVAL_MS: @@ -555,11 +572,7 @@ def get_vcp(self, monitor: dict, code: VCPCode, retries: int = 3) -> tuple[int, try: if self.dxva2.GetVCPFeatureAndVCPFeatureReply( - monitor['handle'], - code, - ctypes.byref(vcp_type), - ctypes.byref(current), - ctypes.byref(maximum) + monitor["handle"], code, ctypes.byref(vcp_type), ctypes.byref(current), ctypes.byref(maximum) ): return (current.value, maximum.value) except OSError: @@ -602,7 +615,7 @@ def set_vcp(self, monitor: dict, code: VCPCode, value: int, retries: int = 3) -> self._rate_limit() try: - if self.dxva2.SetVCPFeature(monitor['handle'], code, value): + if self.dxva2.SetVCPFeature(monitor["handle"], code, value): return True except OSError: pass @@ -659,27 +672,41 @@ def _get_connection_type(self, monitor: dict) -> str | None: """Detect the video connection type (HDMI, DisplayPort, etc.) via WMI.""" try: import subprocess + result = subprocess.run( - ["powershell", "-Command", - "Get-CimInstance -Namespace root/wmi -ClassName WmiMonitorConnectionParams " - "| Select-Object InstanceName, VideoOutputTechnology " - "| ConvertTo-Json"], - capture_output=True, text=True, timeout=5, + [ + "powershell", + "-Command", + "Get-CimInstance -Namespace root/wmi -ClassName WmiMonitorConnectionParams " + "| Select-Object InstanceName, VideoOutputTechnology " + "| ConvertTo-Json", + ], + capture_output=True, + text=True, + timeout=5, creationflags=subprocess.CREATE_NO_WINDOW, ) if result.returncode == 0 and result.stdout.strip(): import json + data = json.loads(result.stdout) if not isinstance(data, list): data = [data] # Map VideoOutputTechnology to human-readable names tech_map = { - 0: "VGA", 2: "S-Video", 3: "Composite", - 4: "Component", 5: "HDMI", 6: "LVDS", - 8: "D-Sub JPN", 10: "DisplayPort", - 11: "DisplayPort (embedded)", 12: "UDI", - 14: "SDI", 15: "DisplayPort (internal)", + 0: "VGA", + 2: "S-Video", + 3: "Composite", + 4: "Component", + 5: "HDMI", + 6: "LVDS", + 8: "D-Sub JPN", + 10: "DisplayPort", + 11: "DisplayPort (embedded)", + 12: "UDI", + 14: "SDI", + 15: "DisplayPort (internal)", } # Find matching monitor by name in InstanceName @@ -698,10 +725,16 @@ def _get_brightness_wmi(self, monitor: dict) -> int | None: """Fallback: read brightness via WMI (works when DXVA2 doesn't).""" try: import subprocess + result = subprocess.run( - ["powershell", "-Command", - "(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightness).CurrentBrightness"], - capture_output=True, text=True, timeout=5, + [ + "powershell", + "-Command", + "(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightness).CurrentBrightness", + ], + capture_output=True, + text=True, + timeout=5, creationflags=subprocess.CREATE_NO_WINDOW, ) if result.returncode == 0 and result.stdout.strip(): @@ -714,22 +747,24 @@ def _set_brightness_wmi(self, value: int) -> bool: """Fallback: set brightness via WMI.""" try: import subprocess + result = subprocess.run( - ["powershell", "-Command", - f"(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods)" - f".WmiSetBrightness(1,{value})"], - capture_output=True, text=True, timeout=5, + [ + "powershell", + "-Command", + f"(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods)" + f".WmiSetBrightness(1,{value})", + ], + capture_output=True, + text=True, + timeout=5, creationflags=subprocess.CREATE_NO_WINDOW, ) return result.returncode == 0 except (subprocess.SubprocessError, OSError): return False - def scan_all_vcp_codes( - self, - monitor: dict, - progress_callback=None - ) -> dict[int, tuple[int, int]]: + def scan_all_vcp_codes(self, monitor: dict, progress_callback=None) -> dict[int, tuple[int, int]]: """ Scan ALL VCP codes (0x00-0xFF) to discover what the monitor supports. @@ -759,11 +794,7 @@ def scan_all_vcp_codes( vcp_type = wintypes.DWORD() if self.dxva2.GetVCPFeatureAndVCPFeatureReply( - monitor['handle'], - code, - ctypes.byref(vcp_type), - ctypes.byref(current), - ctypes.byref(maximum) + monitor["handle"], code, ctypes.byref(vcp_type), ctypes.byref(current), ctypes.byref(maximum) ): # Some monitors return success but max=0 for unsupported if maximum.value > 0 or current.value > 0: @@ -790,11 +821,7 @@ def try_set_vcp(self, monitor: dict, code: int, value: int) -> tuple[bool, str]: vcp_type = wintypes.DWORD() can_read = self.dxva2.GetVCPFeatureAndVCPFeatureReply( - monitor['handle'], - code, - ctypes.byref(vcp_type), - ctypes.byref(current), - ctypes.byref(maximum) + monitor["handle"], code, ctypes.byref(vcp_type), ctypes.byref(current), ctypes.byref(maximum) ) if not can_read: @@ -807,21 +834,18 @@ def try_set_vcp(self, monitor: dict, code: int, value: int) -> tuple[bool, str]: value = maximum.value # Try to set - result = self.dxva2.SetVCPFeature(monitor['handle'], code, value) + result = self.dxva2.SetVCPFeature(monitor["handle"], code, value) if not result: return False, f"SetVCPFeature failed for 0x{code:02X}" # Read back to verify import time + time.sleep(0.1) # Give monitor time to apply if self.dxva2.GetVCPFeatureAndVCPFeatureReply( - monitor['handle'], - code, - ctypes.byref(vcp_type), - ctypes.byref(current), - ctypes.byref(maximum) + monitor["handle"], code, ctypes.byref(vcp_type), ctypes.byref(current), ctypes.byref(maximum) ): new_value = current.value if new_value == value: @@ -967,6 +991,7 @@ def auto_setup_for_calibration( List of applied changes """ import logging + logger = logging.getLogger(__name__) changes = [] @@ -1039,10 +1064,7 @@ def log(msg): log("Contrast -> 80") # Try to set RGB gains to max (neutral) - for code, name in [ - (VCPCode.RED_GAIN, "Red"), (VCPCode.GREEN_GAIN, "Green"), - (VCPCode.BLUE_GAIN, "Blue") - ]: + for code, name in [(VCPCode.RED_GAIN, "Red"), (VCPCode.GREEN_GAIN, "Green"), (VCPCode.BLUE_GAIN, "Blue")]: try: _, max_val = self.get_vcp(monitor, code) if max_val > 0: @@ -1058,7 +1080,7 @@ def close(self): if self._available: for monitor in self._monitors: try: - self.dxva2.DestroyPhysicalMonitor(monitor['handle']) + self.dxva2.DestroyPhysicalMonitor(monitor["handle"]) except OSError: pass self._monitors = [] @@ -1068,9 +1090,11 @@ def close(self): # Hardware Calibration Engine # ============================================================================= + @dataclass class HardwareCalibrationTarget: """Target values for hardware calibration.""" + white_point_x: float = 0.3127 # D65 white_point_y: float = 0.3290 target_brightness: float = 120.0 # cd/m² @@ -1081,6 +1105,7 @@ class HardwareCalibrationTarget: @dataclass class HardwareCalibrationResult: """Results from hardware calibration.""" + success: bool = False rgb_gain: tuple[int, int, int] = (100, 100, 100) rgb_black: tuple[int, int, int] = (50, 50, 50) @@ -1127,7 +1152,7 @@ def calibrate_white_point( monitor: dict, target: HardwareCalibrationTarget, max_iterations: int = 20, - tolerance: float = 0.002 # xy chromaticity tolerance + tolerance: float = 0.002, # xy chromaticity tolerance ) -> HardwareCalibrationResult: """ Iteratively adjust RGB gain to achieve target white point. @@ -1150,7 +1175,7 @@ def calibrate_white_point( result.message = "No measurement callback set - cannot calibrate" return result - caps = monitor.get('capabilities') + caps = monitor.get("capabilities") if not caps or not caps.has_rgb_gain: result.message = "Monitor does not support RGB gain adjustment via DDC/CI" return result @@ -1237,7 +1262,7 @@ def calibrate_white_point( u_meas = 4 * result.measured_white_x / (-2 * result.measured_white_x + 12 * result.measured_white_y + 3) v_meas = 9 * result.measured_white_y / (-2 * result.measured_white_x + 12 * result.measured_white_y + 3) - result.delta_e = ((u_target - u_meas)**2 + (v_target - v_meas)**2)**0.5 * 100 + result.delta_e = ((u_target - u_meas) ** 2 + (v_target - v_meas) ** 2) ** 0.5 * 100 return result @@ -1245,7 +1270,7 @@ def calibrate_brightness( self, monitor: dict, target_nits: float, - tolerance: float = 5.0 # cd/m² tolerance + tolerance: float = 5.0, # cd/m² tolerance ) -> tuple[bool, int]: """ Adjust monitor brightness to target luminance. @@ -1259,7 +1284,7 @@ def calibrate_brightness( # Binary search for correct brightness low, high = 0, 100 best_brightness = 50 - best_diff = float('inf') + best_diff = float("inf") for _ in range(10): # Max 10 iterations mid = (low + high) // 2 @@ -1292,6 +1317,7 @@ def calibrate_brightness( # Professional Monitor Hardware LUT Support # ============================================================================= + class HardwareLUTUploader: """ Upload calibration LUTs directly to professional monitors. @@ -1316,12 +1342,7 @@ def _detect_supported_monitors(self): # using USB HID or proprietary DDC extensions pass - def upload_lut( - self, - monitor_id: str, - lut_data: bytes, - lut_size: int = 4096 - ) -> bool: + def upload_lut(self, monitor_id: str, lut_data: bytes, lut_size: int = 4096) -> bool: """ Upload a 1D or 3D LUT to the monitor's hardware. @@ -1351,6 +1372,7 @@ def get_supported_monitors(self) -> list[str]: # Convenience Functions # ============================================================================= + def detect_ddc_monitors() -> list[dict]: """Quick detection of all DDC/CI capable monitors.""" controller = DDCCIController() @@ -1361,7 +1383,7 @@ def detect_ddc_monitors() -> list[dict]: def print_monitor_capabilities(monitor: dict): """Print monitor DDC/CI capabilities for debugging.""" print(f"\nMonitor: {monitor['name']}") - caps = monitor.get('capabilities') + caps = monitor.get("capabilities") if caps: print(f" Model: {caps.model}") print(f" RGB Gain: {'Yes' if caps.has_rgb_gain else 'No'}") diff --git a/calibrate_pro/hardware/drift_compensation.py b/calibrate_pro/hardware/drift_compensation.py index 830efd0..a5c1c8f 100644 --- a/calibrate_pro/hardware/drift_compensation.py +++ b/calibrate_pro/hardware/drift_compensation.py @@ -30,6 +30,7 @@ @dataclass class DriftReading: """A reference patch measurement for drift tracking.""" + timestamp: float xyz: np.ndarray patch_index: int # Index in the overall measurement sequence @@ -38,6 +39,7 @@ class DriftReading: @dataclass class DriftStats: """Drift statistics for a profiling session.""" + total_readings: int = 0 reference_count: int = 0 max_drift_pct: float = 0.0 @@ -75,11 +77,13 @@ def __init__( def set_initial_reference(self, xyz: np.ndarray): """Set the initial reference measurement (first white).""" self._initial_xyz = xyz.copy() - self._references.append(DriftReading( - timestamp=time.time(), - xyz=xyz.copy(), - patch_index=0, - )) + self._references.append( + DriftReading( + timestamp=time.time(), + xyz=xyz.copy(), + patch_index=0, + ) + ) logger.info("Drift compensation: initial reference Y=%.2f", xyz[1]) def should_measure_reference(self, patch_index: int) -> bool: @@ -88,18 +92,17 @@ def should_measure_reference(self, patch_index: int) -> bool: def add_reference(self, xyz: np.ndarray, patch_index: int): """Add a reference measurement during the session.""" - self._references.append(DriftReading( - timestamp=time.time(), - xyz=xyz.copy(), - patch_index=patch_index, - )) + self._references.append( + DriftReading( + timestamp=time.time(), + xyz=xyz.copy(), + patch_index=patch_index, + ) + ) if self._initial_xyz is not None: drift = np.abs(xyz - self._initial_xyz) / np.maximum(self._initial_xyz, 1e-6) * 100 - logger.debug( - "Drift at patch %d: X=%.2f%% Y=%.2f%% Z=%.2f%%", - patch_index, drift[0], drift[1], drift[2] - ) + logger.debug("Drift at patch %d: X=%.2f%% Y=%.2f%% Z=%.2f%%", patch_index, drift[0], drift[1], drift[2]) def compensate(self, xyz: np.ndarray, patch_index: int) -> np.ndarray: """ @@ -132,11 +135,7 @@ def compensate(self, xyz: np.ndarray, patch_index: int) -> np.ndarray: interp_ref = before.xyz * (1 - t) + after.xyz * t # Drift ratio: how much the reference has shifted from initial - drift_ratio = np.where( - interp_ref > 1e-6, - self._initial_xyz / interp_ref, - 1.0 - ) + drift_ratio = np.where(interp_ref > 1e-6, self._initial_xyz / interp_ref, 1.0) # Apply inverse drift compensated = xyz * drift_ratio diff --git a/calibrate_pro/hardware/hardware_calibration.py b/calibrate_pro/hardware/hardware_calibration.py index dae0a3b..97125e4 100644 --- a/calibrate_pro/hardware/hardware_calibration.py +++ b/calibrate_pro/hardware/hardware_calibration.py @@ -22,24 +22,27 @@ # Data Structures # ============================================================================= + class CalibrationPhase(Enum): """Phases of hardware calibration.""" + INITIALIZE = auto() - MEASURE_NATIVE = auto() # Measure display in native state - ADJUST_BRIGHTNESS = auto() # Set target luminance - ADJUST_CONTRAST = auto() # Set contrast/black level - ADJUST_WHITE_BALANCE = auto() # Adjust RGB gains for D65 - ADJUST_GRAYSCALE = auto() # Fine-tune grayscale tracking - MEASURE_PRIMARIES = auto() # Measure R, G, B primaries - GENERATE_PROFILE = auto() # Create ICC profile - GENERATE_LUT = auto() # Create 3D LUT for gamut mapping - VERIFY = auto() # Final verification measurements + MEASURE_NATIVE = auto() # Measure display in native state + ADJUST_BRIGHTNESS = auto() # Set target luminance + ADJUST_CONTRAST = auto() # Set contrast/black level + ADJUST_WHITE_BALANCE = auto() # Adjust RGB gains for D65 + ADJUST_GRAYSCALE = auto() # Fine-tune grayscale tracking + MEASURE_PRIMARIES = auto() # Measure R, G, B primaries + GENERATE_PROFILE = auto() # Create ICC profile + GENERATE_LUT = auto() # Create 3D LUT for gamut mapping + VERIFY = auto() # Final verification measurements COMPLETE = auto() @dataclass class CalibrationTargets: """Target values for calibration.""" + # White point whitepoint: str = "D65" whitepoint_x: float = 0.3127 @@ -58,17 +61,18 @@ class CalibrationTargets: gamut: str = "sRGB" # Tolerances - delta_e_target: float = 1.0 # Maximum acceptable Delta E + delta_e_target: float = 1.0 # Maximum acceptable Delta E luminance_tolerance: float = 0.02 # 2% luminance tolerance - cct_tolerance: int = 100 # +/- 100K CCT tolerance + cct_tolerance: int = 100 # +/- 100K CCT tolerance # Grayscale tracking - grayscale_steps: int = 21 # Number of grayscale test points + grayscale_steps: int = 21 # Number of grayscale test points @dataclass class MeasurementResult: """Result from a single color measurement.""" + # Input RGB (0-255) r: int = 0 g: int = 0 @@ -106,6 +110,7 @@ def __post_init__(self): @dataclass class GrayscaleAnalysis: """Analysis of grayscale tracking.""" + # Per-step measurements measurements: list[MeasurementResult] = field(default_factory=list) @@ -128,6 +133,7 @@ class GrayscaleAnalysis: @dataclass class CalibrationState: """Current state of DDC/CI settings.""" + brightness: int = 50 contrast: int = 50 red_gain: int = 100 @@ -143,6 +149,7 @@ class CalibrationState: @dataclass class HardwareCalibrationResult: """Results from hardware calibration process.""" + success: bool = False phase: CalibrationPhase = CalibrationPhase.INITIALIZE message: str = "" @@ -180,11 +187,13 @@ class HardwareCalibrationResult: D65_Y = 100.0 D65_Z = 108.883 + def xyz_to_lab(X: float, Y: float, Z: float) -> tuple[float, float, float]: """Convert XYZ to CIE Lab (D65 reference).""" + def f(t): if t > 0.008856: - return t ** (1/3) + return t ** (1 / 3) else: return (903.3 * t + 16) / 116 @@ -253,28 +262,26 @@ def delta_e_2000(lab1: tuple[float, float, float], lab2: tuple[float, float, flo else: h_avg = (h1_prime + h2_prime - 360) / 2 - T = (1 - 0.17 * np.cos(np.radians(h_avg - 30)) - + 0.24 * np.cos(np.radians(2 * h_avg)) - + 0.32 * np.cos(np.radians(3 * h_avg + 6)) - - 0.20 * np.cos(np.radians(4 * h_avg - 63))) + T = ( + 1 + - 0.17 * np.cos(np.radians(h_avg - 30)) + + 0.24 * np.cos(np.radians(2 * h_avg)) + + 0.32 * np.cos(np.radians(3 * h_avg + 6)) + - 0.20 * np.cos(np.radians(4 * h_avg - 63)) + ) - dTheta = 30 * np.exp(-((h_avg - 275) / 25)**2) + dTheta = 30 * np.exp(-(((h_avg - 275) / 25) ** 2)) R_C = 2 * np.sqrt(C_avg_prime**7 / (C_avg_prime**7 + 25**7)) - S_L = 1 + (0.015 * (L_avg - 50)**2) / np.sqrt(20 + (L_avg - 50)**2) + S_L = 1 + (0.015 * (L_avg - 50) ** 2) / np.sqrt(20 + (L_avg - 50) ** 2) S_C = 1 + 0.045 * C_avg_prime S_H = 1 + 0.015 * C_avg_prime * T R_T = -np.sin(np.radians(2 * dTheta)) * R_C # Final calculation (kL = kC = kH = 1 for standard conditions) - dE = np.sqrt( - (dL / S_L)**2 + - (dC / S_C)**2 + - (dH / S_H)**2 + - R_T * (dC / S_C) * (dH / S_H) - ) + dE = np.sqrt((dL / S_L) ** 2 + (dC / S_C) ** 2 + (dH / S_H) ** 2 + R_T * (dC / S_C) * (dH / S_H)) return dE @@ -289,13 +296,13 @@ def xy_to_cct(x: float, y: float) -> float: def cct_to_xy(cct: float) -> tuple[float, float]: """Calculate chromaticity from CCT (Planckian locus approximation).""" if cct < 4000: - x = -0.2661239e9/cct**3 - 0.2343589e6/cct**2 + 0.8776956e3/cct + 0.179910 + x = -0.2661239e9 / cct**3 - 0.2343589e6 / cct**2 + 0.8776956e3 / cct + 0.179910 elif cct < 7000: - x = -4.6070e9/cct**3 + 2.9678e6/cct**2 + 0.09911e3/cct + 0.244063 + x = -4.6070e9 / cct**3 + 2.9678e6 / cct**2 + 0.09911e3 / cct + 0.244063 else: - x = -2.0064e9/cct**3 + 1.9018e6/cct**2 + 0.24748e3/cct + 0.237040 + x = -2.0064e9 / cct**3 + 1.9018e6 / cct**2 + 0.24748e3 / cct + 0.237040 - y = -3.000*x**2 + 2.870*x - 0.275 + y = -3.000 * x**2 + 2.870 * x - 0.275 return (x, y) @@ -304,6 +311,7 @@ def cct_to_xy(cct: float) -> tuple[float, float]: # Hardware Calibration Engine # ============================================================================= + class HardwareCalibrationEngine: """ Iterative hardware calibration using colorimeter + DDC/CI. @@ -332,12 +340,7 @@ def _report(self, msg: str, progress: float, phase: CalibrationPhase): if self._progress_callback: self._progress_callback(msg, progress, phase) - def initialize( - self, - colorimeter=None, - ddc_controller=None, - display_index: int = 0 - ) -> bool: + def initialize(self, colorimeter=None, ddc_controller=None, display_index: int = 0) -> bool: """ Initialize calibration with measurement device and DDC controller. @@ -375,7 +378,6 @@ def _read_current_state(self) -> CalibrationState: return state try: - settings = self._ddc_controller.get_settings(self._monitor) state.brightness = settings.brightness state.contrast = settings.contrast @@ -396,6 +398,7 @@ def _set_brightness(self, value: int) -> bool: if not self._ddc_controller or not self._monitor: return False from calibrate_pro.hardware.ddc_ci import VCPCode + return self._ddc_controller.set_vcp(self._monitor, VCPCode.BRIGHTNESS, value) def _set_contrast(self, value: int) -> bool: @@ -403,6 +406,7 @@ def _set_contrast(self, value: int) -> bool: if not self._ddc_controller or not self._monitor: return False from calibrate_pro.hardware.ddc_ci import VCPCode + return self._ddc_controller.set_vcp(self._monitor, VCPCode.CONTRAST, value) def _set_rgb_gain(self, r: int, g: int, b: int) -> bool: @@ -430,19 +434,19 @@ def _measure_patch(self, r: int, g: int, b: int) -> MeasurementResult | None: if measurement: result = MeasurementResult( - r=r, g=g, b=b, + r=r, + g=g, + b=b, X=measurement.X, Y=measurement.Y, Z=measurement.Z, x=measurement.x, y=measurement.y, - cct=measurement.cct if hasattr(measurement, 'cct') else 0 + cct=measurement.cct if hasattr(measurement, "cct") else 0, ) # Calculate Lab - result.L, result.a, result.b = xyz_to_lab( - measurement.X, measurement.Y, measurement.Z - ) + result.L, result.a, result.b = xyz_to_lab(measurement.X, measurement.Y, measurement.Z) return result except Exception: @@ -451,11 +455,7 @@ def _measure_patch(self, r: int, g: int, b: int) -> MeasurementResult | None: return None def _calculate_white_balance_adjustment( - self, - measured_x: float, - measured_y: float, - target_x: float = 0.3127, - target_y: float = 0.3290 + self, measured_x: float, measured_y: float, target_x: float = 0.3127, target_y: float = 0.3290 ) -> tuple[int, int, int]: """ Calculate RGB gain adjustments needed to reach target white point. @@ -493,10 +493,7 @@ def _calculate_white_balance_adjustment( return (r_adj, g_adj, b_adj) def _calculate_brightness_for_luminance( - self, - current_luminance: float, - target_luminance: float, - current_brightness: int + self, current_luminance: float, target_luminance: float, current_brightness: int ) -> int: """ Calculate brightness setting needed to reach target luminance. @@ -514,9 +511,7 @@ def _calculate_brightness_for_luminance( return max(0, min(100, new_brightness)) def run_hardware_calibration( - self, - targets: CalibrationTargets | None = None, - output_dir: Path | None = None + self, targets: CalibrationTargets | None = None, output_dir: Path | None = None ) -> HardwareCalibrationResult: """ Run full hardware calibration with colorimeter feedback. @@ -557,7 +552,9 @@ def run_hardware_calibration( result.initial_state = self._read_current_state() result.phase = CalibrationPhase.INITIALIZE result.adjustments_log.append(f"Initial brightness: {result.initial_state.brightness}") - result.adjustments_log.append(f"Initial RGB gains: R={result.initial_state.red_gain}, G={result.initial_state.green_gain}, B={result.initial_state.blue_gain}") + result.adjustments_log.append( + f"Initial RGB gains: R={result.initial_state.red_gain}, G={result.initial_state.green_gain}, B={result.initial_state.blue_gain}" + ) # Phase 2: Measure native white point self._report("Measuring native white point...", 0.10, CalibrationPhase.MEASURE_NATIVE) @@ -620,7 +617,7 @@ def run_hardware_calibration( error = np.sqrt(dx**2 + dy**2) if error < 0.003: # Close enough to target - result.adjustments_log.append(f"White balance converged after {iteration+1} iterations") + result.adjustments_log.append(f"White balance converged after {iteration + 1} iterations") break # Calculate adjustment @@ -661,7 +658,7 @@ def run_hardware_calibration( target_lab = xyz_to_lab( targets.whitepoint_x * measurement.Y / targets.whitepoint_y, measurement.Y, - (1 - targets.whitepoint_x - targets.whitepoint_y) * measurement.Y / targets.whitepoint_y + (1 - targets.whitepoint_x - targets.whitepoint_y) * measurement.Y / targets.whitepoint_y, ) measured_lab = (measurement.L, measurement.a, measurement.b) measurement.delta_e = delta_e_2000(target_lab, measured_lab) @@ -689,16 +686,13 @@ def run_hardware_calibration( measurement = self._measure_patch(*rgb) if measurement: primaries[name] = (measurement.x, measurement.y, measurement.Y) - result.adjustments_log.append( - f"Primary {name}: x={measurement.x:.4f}, y={measurement.y:.4f}" - ) + result.adjustments_log.append(f"Primary {name}: x={measurement.x:.4f}, y={measurement.y:.4f}") # Phase 7: Generate ICC profile self._report("Generating ICC profile...", 0.80, CalibrationPhase.GENERATE_PROFILE) result.phase = CalibrationPhase.GENERATE_PROFILE try: - profile_path = output_dir / "HardwareCalibrated.icc" # Generate profile based on measurements or panel database result.icc_profile_path = str(profile_path) @@ -756,9 +750,7 @@ def run_hardware_calibration( return result def run_quick_white_balance( - self, - target_x: float = 0.3127, - target_y: float = 0.3290 + self, target_x: float = 0.3127, target_y: float = 0.3290 ) -> tuple[bool, str, tuple[int, int, int]]: """ Quick white balance adjustment using colorimeter. @@ -788,12 +780,14 @@ def run_quick_white_balance( error = np.sqrt(dx**2 + dy**2) if error < 0.003: - return True, f"White balance achieved after {iteration+1} iterations", (current_r, current_g, current_b) + return ( + True, + f"White balance achieved after {iteration + 1} iterations", + (current_r, current_g, current_b), + ) # Calculate and apply adjustment - r_adj, g_adj, b_adj = self._calculate_white_balance_adjustment( - white.x, white.y, target_x, target_y - ) + r_adj, g_adj, b_adj = self._calculate_white_balance_adjustment(white.x, white.y, target_x, target_y) current_r = max(0, min(100, current_r + r_adj)) current_g = max(0, min(100, current_g + g_adj)) @@ -809,6 +803,7 @@ def run_quick_white_balance( # Sensorless Estimation (when no colorimeter available) # ============================================================================= + class SensorlessEstimator: """ Estimates calibration settings when no colorimeter is available. @@ -818,10 +813,7 @@ class SensorlessEstimator: """ @staticmethod - def estimate_rgb_gains_for_d65( - panel_type: str = "IPS", - native_cct: int = 6500 - ) -> tuple[int, int, int]: + def estimate_rgb_gains_for_d65(panel_type: str = "IPS", native_cct: int = 6500) -> tuple[int, int, int]: """ Estimate RGB gains needed to achieve D65 white point. @@ -849,9 +841,7 @@ def estimate_rgb_gains_for_d65( @staticmethod def estimate_brightness_for_luminance( - panel_type: str, - target_luminance: float, - max_luminance: float = 400.0 + panel_type: str, target_luminance: float, max_luminance: float = 400.0 ) -> int: """ Estimate brightness setting for target luminance. diff --git a/calibrate_pro/hardware/i1d3_native.py b/calibrate_pro/hardware/i1d3_native.py index 0778c4c..e4d0e35 100644 --- a/calibrate_pro/hardware/i1d3_native.py +++ b/calibrate_pro/hardware/i1d3_native.py @@ -26,6 +26,7 @@ try: import hid + HID_AVAILABLE = True except ImportError: HID_AVAILABLE = False @@ -39,18 +40,18 @@ REPORT_SIZE = 64 # Command codes (from ArgyllCMS i1d3.c) -CMD_GET_INFO = 0x0000 # Get product info string -CMD_STATUS = 0x0001 # Get status -CMD_GET_PRODNAME = 0x0010 # Get product name -CMD_GET_PRODTYPE = 0x0011 # Get product type -CMD_GET_FIRMVER = 0x0012 # Get firmware version -CMD_GET_FIRMDATE = 0x0013 # Get firmware date -CMD_MEASURE1 = 0x0100 # Measure (locked mode) -CMD_MEASURE2 = 0x0200 # Measure (unlocked mode) -CMD_SET_INTTIME = 0x0300 # Set integration time -CMD_GET_INTTIME = 0x0301 # Get integration time -CMD_RD_EE = 0x0800 # Read EEPROM: offset(2B) + length(1B) → data -CMD_UNLOCK = 0x0000 # Unlock with key +CMD_GET_INFO = 0x0000 # Get product info string +CMD_STATUS = 0x0001 # Get status +CMD_GET_PRODNAME = 0x0010 # Get product name +CMD_GET_PRODTYPE = 0x0011 # Get product type +CMD_GET_FIRMVER = 0x0012 # Get firmware version +CMD_GET_FIRMDATE = 0x0013 # Get firmware date +CMD_MEASURE1 = 0x0100 # Measure (locked mode) +CMD_MEASURE2 = 0x0200 # Measure (unlocked mode) +CMD_SET_INTTIME = 0x0300 # Set integration time +CMD_GET_INTTIME = 0x0301 # Get integration time +CMD_RD_EE = 0x0800 # Read EEPROM: offset(2B) + length(1B) → data +CMD_UNLOCK = 0x0000 # Unlock with key # Status codes STATUS_OK = 0x00 @@ -62,18 +63,16 @@ UNLOCK_KEYS = { "i1Display3": bytes.fromhex( "47 52 45 54 41 4D 61 63 " # GRETAMac - "62 65 74 68 00 00 00 00" # beth - ), - "ColorMunki": bytes.fromhex( - "47 52 45 54 41 4D 61 63 " - "62 65 74 68 00 00 00 00" + "62 65 74 68 00 00 00 00" # beth ), + "ColorMunki": bytes.fromhex("47 52 45 54 41 4D 61 63 62 65 74 68 00 00 00 00"), } @dataclass class I1D3Info: """Device information.""" + product: str serial: str firmware_version: str @@ -85,6 +84,7 @@ class I1D3Info: @dataclass class I1D3Measurement: """Raw measurement result.""" + # Raw sensor counts (before calibration matrix) red_count: float green_count: float @@ -95,8 +95,8 @@ class I1D3Measurement: Y: float = 0.0 Z: float = 0.0 # Derived values - luminance: float = 0.0 # cd/m2 (= Y) - cct: float = 0.0 # Correlated Color Temperature + luminance: float = 0.0 # cd/m2 (= Y) + cct: float = 0.0 # Correlated Color Temperature class I1D3Driver: @@ -131,14 +131,16 @@ def find_devices() -> list[dict]: return [] devices = [] for d in hid.enumerate(I1D3_VID, I1D3_PID): - devices.append({ - "path": d.get("path", b""), - "product": d.get("product_string", ""), - "manufacturer": d.get("manufacturer_string", ""), - "serial": d.get("serial_number_string", ""), - "vid": d.get("vendor_id", 0), - "pid": d.get("product_id", 0), - }) + devices.append( + { + "path": d.get("path", b""), + "product": d.get("product_string", ""), + "manufacturer": d.get("manufacturer_string", ""), + "serial": d.get("serial_number_string", ""), + "vid": d.get("vendor_id", 0), + "pid": d.get("product_id", 0), + } + ) return devices def open(self, path: bytes = None) -> bool: @@ -232,7 +234,7 @@ def _send_command(self, cmd: int, data: bytes = b"") -> bytes | None: report = bytearray(REPORT_SIZE) report[0] = 0x00 # Report ID report[1] = (cmd >> 8) & 0xFF # Command high byte - report[2] = cmd & 0xFF # Command low byte + report[2] = cmd & 0xFF # Command low byte # Copy data for i, b in enumerate(data): @@ -271,8 +273,9 @@ def _get_device_info(self) -> I1D3Info: for p in parts: if p.startswith("v"): fw_ver = p - elif any(m in p for m in ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]): + elif any( + m in p for m in ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + ): fw_date = p serial = "" @@ -333,7 +336,7 @@ def _read_eeprom(self, offset: int, length: int) -> bytes | None: data = struct.pack(">HB", offset, length) resp = self._send_command(CMD_RD_EE, data) if resp and len(resp) >= 3 + length: - return resp[3:3 + length] + return resp[3 : 3 + length] return None def _parse_cal_matrix(self, raw: bytes) -> list[list[float]] | None: @@ -350,7 +353,7 @@ def _parse_cal_matrix(self, raw: bytes) -> list[list[float]] | None: r = [] for col in range(3): offset = (row * 3 + col) * 8 - val = struct.unpack(">d", raw[offset:offset + 8])[0] + val = struct.unpack(">d", raw[offset : offset + 8])[0] r.append(val) matrix.append(r) return matrix @@ -359,15 +362,15 @@ def _parse_cal_matrix(self, raw: bytes) -> list[list[float]] | None: # Each contains a 3x3 double matrix (72 bytes) preceded by a # header with the display technology label. CAL_OFFSETS = { - "Ambient": 0x0058, - "CCFL": 0x04D8, - "WideGamutCCFL": 0x0958, - "WhiteLED": 0x0DD8, - "RGBLED": 0x1258, - "OLED": 0x191C, + "Ambient": 0x0058, + "CCFL": 0x04D8, + "WideGamutCCFL": 0x0958, + "WhiteLED": 0x0DD8, + "RGBLED": 0x1258, + "OLED": 0x191C, "RGPhosphorBlueLED": 0x1B58, - "WideGamutLEDPA2": 0x1FD8, - "Last": 0x2458, + "WideGamutLEDPA2": 0x1FD8, + "Last": 0x2458, } # Offset from the start of each calibration block to the 3x3 matrix data. @@ -404,7 +407,7 @@ def _read_calibration(self): # This does NOT account for per-unit sensor variance. self._cal_matrix = [ [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], + [0.01449629, 0.01611903, 0.00057150], [-0.00004481, 0.00035042, 0.08032401], ] self._cal_source = "fallback_approximate" @@ -489,9 +492,7 @@ def _trigger_measurement(self) -> tuple[float, float, float] | None: except (struct.error, ValueError): return None - def _apply_calibration( - self, raw: tuple[float, float, float] - ) -> I1D3Measurement: + def _apply_calibration(self, raw: tuple[float, float, float]) -> I1D3Measurement: """Apply calibration matrix to convert raw counts to XYZ.""" r, g, b = raw @@ -545,6 +546,7 @@ def __exit__(self, *args): # Convenience functions # ============================================================================= + def detect_colorimeters() -> list[dict]: """Find all connected i1Display3 family colorimeters.""" return I1D3Driver.find_devices() diff --git a/calibrate_pro/hardware/i1display.py b/calibrate_pro/hardware/i1display.py index 50e00a9..eae0c3d 100644 --- a/calibrate_pro/hardware/i1display.py +++ b/calibrate_pro/hardware/i1display.py @@ -19,6 +19,7 @@ class I1DisplayType: """i1Display model variants.""" + I1DISPLAY_PRO = "i1Display Pro" I1DISPLAY_PRO_PLUS = "i1Display Pro Plus" I1DISPLAY_STUDIO = "i1Display Studio" @@ -50,28 +51,16 @@ class I1DisplayType: # APPROXIMATE — does not account for per-unit sensor variance "OLED": { "description": "OLED Display Correction (approximate, per-unit EEPROM needed)", - "matrix": [ - [1.0245, -0.0156, -0.0089], - [-0.0087, 1.0134, -0.0047], - [0.0021, -0.0098, 1.0077] - ] + "matrix": [[1.0245, -0.0156, -0.0089], [-0.0087, 1.0134, -0.0047], [0.0021, -0.0098, 1.0077]], }, "WideGamut": { "description": "Wide Gamut LCD Correction (approximate, per-unit EEPROM needed)", - "matrix": [ - [1.0089, -0.0067, -0.0022], - [-0.0045, 1.0078, -0.0033], - [0.0012, -0.0056, 1.0044] - ] + "matrix": [[1.0089, -0.0067, -0.0022], [-0.0045, 1.0078, -0.0033], [0.0012, -0.0056, 1.0044]], }, "LCD": { "description": "Standard LCD (identity — no correction)", - "matrix": [ - [1.0000, 0.0000, 0.0000], - [0.0000, 1.0000, 0.0000], - [0.0000, 0.0000, 1.0000] - ] - } + "matrix": [[1.0000, 0.0000, 0.0000], [0.0000, 1.0000, 0.0000], [0.0000, 0.0000, 1.0000]], + }, } @@ -112,10 +101,9 @@ def detect_devices(self) -> list[DeviceInfo]: def _is_i1display(self, device: DeviceInfo) -> bool: """Check if device is an i1Display.""" name_lower = device.name.lower() - return any(x in name_lower for x in [ - "i1display", "i1 display", "colormunki display", - "eye-one display", "xrite" - ]) + return any( + x in name_lower for x in ["i1display", "i1 display", "colormunki display", "eye-one display", "xrite"] + ) def _identify_model(self, device: DeviceInfo) -> None: """Identify specific i1Display model and capabilities.""" @@ -166,10 +154,7 @@ def set_oled_mode(self, enabled: bool) -> bool: OLED mode uses longer integration times for better black level measurements on OLED displays. """ - if self.model_type not in [ - I1DisplayType.I1DISPLAY_PRO, - I1DisplayType.I1DISPLAY_PRO_PLUS - ]: + if self.model_type not in [I1DisplayType.I1DISPLAY_PRO, I1DisplayType.I1DISPLAY_PRO_PLUS]: return False self.oled_mode = enabled @@ -190,6 +175,7 @@ def set_display_correction(self, display_type: str) -> bool: # Try per-unit EEPROM calibration via native driver try: from calibrate_pro.hardware.i1d3_native import I1D3Driver + native = I1D3Driver() if native.open(): # Map display_type names to EEPROM calibration block names @@ -232,12 +218,7 @@ def measure_ambient(self) -> ColorMeasurement | None: try: # Use spotread with ambient mode cmd = [str(self.spotread_path), "-a", "-x"] - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30 - ) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode == 0: # Parse ambient reading @@ -253,8 +234,8 @@ def measure_ambient(self) -> ColorMeasurement | None: def _parse_ambient_output(self, output: str) -> ColorMeasurement | None: """Parse ambient measurement output from spotread.""" # Look for illuminance value - lux_match = re.search(r'Lux:\s*([\d.]+)', output) - cct_match = re.search(r'CCT:\s*([\d.]+)', output) + lux_match = re.search(r"Lux:\s*([\d.]+)", output) + cct_match = re.search(r"CCT:\s*([\d.]+)", output) if lux_match: lux = float(lux_match.group(1)) @@ -262,11 +243,7 @@ def _parse_ambient_output(self, output: str) -> ColorMeasurement | None: # Create measurement with ambient data # For ambient, Y represents illuminance in lux - return ColorMeasurement( - X=0, Y=lux, Z=0, - cct=cct, - measurement_mode="ambient" - ) + return ColorMeasurement(X=0, Y=lux, Z=0, cct=cct, measurement_mode="ambient") return None @@ -301,7 +278,7 @@ def _apply_correction(self, measurement: ColorMeasurement) -> ColorMeasurement: Z=float(corrected[2]), spectral_data=measurement.spectral_data, integration_time=measurement.integration_time, - measurement_mode=measurement.measurement_mode + measurement_mode=measurement.measurement_mode, ) def measure_flicker(self) -> dict | None: diff --git a/calibrate_pro/hardware/i1display_native.py b/calibrate_pro/hardware/i1display_native.py index 8af0fbd..7d5c7b9 100644 --- a/calibrate_pro/hardware/i1display_native.py +++ b/calibrate_pro/hardware/i1display_native.py @@ -35,6 +35,7 @@ # i1Display command codes class I1Command: """i1Display Pro command codes.""" + GET_STATUS = 0x00 SET_LED = 0x01 GET_CALIBRATION = 0x02 @@ -54,23 +55,22 @@ class I1Command: # i1Display Pro sensor calibration matrices # These are device-specific and should be read from EEPROM # Default matrices for typical devices -I1DISPLAY_DEFAULT_MATRIX = np.array([ - [0.4124564, 0.3575761, 0.1804375], - [0.2126729, 0.7151522, 0.0721750], - [0.0193339, 0.1191920, 0.9503041] -]) +I1DISPLAY_DEFAULT_MATRIX = np.array( + [[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]] +) # Spectral sensitivities for i1Display Pro (approximate CIE 1931 response) I1DISPLAY_SPECTRAL_RESPONSE = { "red": {"peak": 610, "width": 50}, "green": {"peak": 545, "width": 60}, - "blue": {"peak": 460, "width": 40} + "blue": {"peak": 460, "width": 40}, } @dataclass class I1CalibrationData: """Calibration data read from device EEPROM.""" + serial: str firmware_version: str calibration_matrix: np.ndarray @@ -89,7 +89,7 @@ class I1DisplayNative(ColorimeterBase): # Supported device IDs SUPPORTED_DEVICES = { - (0x0765, 0x5001): ("i1Display Pro", True, True), # (name, has_ambient, has_edr) + (0x0765, 0x5001): ("i1Display Pro", True, True), # (name, has_ambient, has_edr) (0x0765, 0x5011): ("i1Display Pro Plus", True, True), (0x0765, 0x5020): ("i1Display Studio", True, False), (0x0765, 0x5010): ("ColorMunki Display", True, False), @@ -122,15 +122,17 @@ def detect_devices(self) -> list[DeviceInfo]: if has_edr: caps.append("edr") - devices.append(DeviceInfo( - name=name, - manufacturer="X-Rite", - model=name, - serial=usb_dev.serial_number or "Unknown", - device_type=DeviceType.COLORIMETER, - firmware_version="", - capabilities=caps - )) + devices.append( + DeviceInfo( + name=name, + manufacturer="X-Rite", + model=name, + serial=usb_dev.serial_number or "Unknown", + device_type=DeviceType.COLORIMETER, + firmware_version="", + capabilities=caps, + ) + ) return devices @@ -171,7 +173,7 @@ def disconnect(self) -> bool: self.is_connected = False return True - def _send_command(self, cmd: int, data: bytes = b'') -> bytes: + def _send_command(self, cmd: int, data: bytes = b"") -> bytes: """Send command and receive response.""" if not self._transport or not self._transport.is_open: raise CommunicationError("Device not connected") @@ -179,7 +181,7 @@ def _send_command(self, cmd: int, data: bytes = b'') -> bytes: # Build command packet # Format: [Report ID (0x00)] [Command] [Length] [Data...] packet = bytes([0x00, cmd, len(data)]) + data - packet = packet.ljust(64, b'\x00') + packet = packet.ljust(64, b"\x00") self._transport.write(packet) time.sleep(0.01) # Small delay for device processing @@ -197,21 +199,20 @@ def _read_device_info(self): minor = resp[3] self.device_info = DeviceInfo( name=self.SUPPORTED_DEVICES.get( - (self._usb_info.vendor_id, self._usb_info.product_id), - ("i1Display", True, False) + (self._usb_info.vendor_id, self._usb_info.product_id), ("i1Display", True, False) )[0], manufacturer="X-Rite", model=self._usb_info.product, serial=self._usb_info.serial_number or "", device_type=DeviceType.COLORIMETER, firmware_version=f"{major}.{minor}", - capabilities=["spot", "ambient", "emission"] + capabilities=["spot", "ambient", "emission"], ) # Get serial resp = self._send_command(I1Command.GET_SERIAL) if len(resp) >= 16: - serial = resp[2:18].decode('ascii', errors='ignore').strip('\x00') + serial = resp[2:18].decode("ascii", errors="ignore").strip("\x00") if serial and self.device_info: self.device_info.serial = serial @@ -224,7 +225,7 @@ def _read_device_info(self): model=self._usb_info.product, serial=self._usb_info.serial_number or "", device_type=DeviceType.COLORIMETER, - capabilities=["spot", "emission"] + capabilities=["spot", "emission"], ) def _read_calibration_data(self): @@ -238,21 +239,21 @@ def _read_calibration_data(self): for j in range(3): idx = 4 + (i * 3 + j) * 4 if idx + 4 <= len(resp): - matrix[i, j] = struct.unpack(' bool: @@ -295,7 +296,7 @@ def calibrate_device(self) -> bool: for i in range(3): idx = 4 + i * 4 if idx + 4 <= len(resp): - self._cal_data.dark_offsets[i] = struct.unpack(' bool: # Send to device (time in milliseconds) ms = int(seconds * 1000) - data = struct.pack(' bool: try: # Send refresh rate to device - data = struct.pack(' ColorMeasurement | None: try: # Set integration time ms = int(self._integration_time * 1000) - int_data = struct.pack(' ColorMeasurement | None: for i in range(3): idx = 4 + i * 4 if idx + 4 <= len(resp): - rgb[i] = struct.unpack(' ColorMeasurement | None: Y=float(xyz[1]), Z=float(xyz[2]), integration_time=self._integration_time, - measurement_mode="spot" + measurement_mode="spot", ) except Exception as e: @@ -429,13 +430,13 @@ def measure_ambient(self) -> ColorMeasurement | None: if len(resp) >= 8: # Parse illuminance (lux) - lux = struct.unpack('= 8 else 0 + lux = struct.unpack("= 8 else 0 return ColorMeasurement( X=0, Y=lux, # Y represents illuminance for ambient Z=0, - measurement_mode="ambient" + measurement_mode="ambient", ) except Exception: @@ -452,7 +453,7 @@ def detect_refresh_rate(self) -> float | None: resp = self._send_command(I1Command.GET_REFRESH_RATE) if len(resp) >= 6: # Parse refresh rate (Hz) - rate = struct.unpack(' bool: """Initialize ArgyllCMS backend.""" try: from calibrate_pro.hardware.argyll_backend import ArgyllBackend, ArgyllConfig + config = ArgyllConfig() if self.config.argyll_path: config.bin_path = Path(self.config.argyll_path) @@ -151,10 +153,7 @@ def _create_display_window(self): self._tk_root.attributes("-topmost", True) self._tk_root.deiconify() - self._tk_canvas = tk.Canvas( - self._tk_root, highlightthickness=0, - cursor="none" - ) + self._tk_canvas = tk.Canvas(self._tk_root, highlightthickness=0, cursor="none") self._tk_canvas.pack(fill=tk.BOTH, expand=True) self._tk_root.update() @@ -166,6 +165,7 @@ def _get_display_geometry(self) -> tuple[int, int, int, int] | None: """Get the geometry of the target display.""" try: from calibrate_pro.panels.detection import enumerate_displays + displays = enumerate_displays() if self.config.display_index < len(displays): d = displays[self.config.display_index] @@ -241,9 +241,7 @@ def __exit__(self, *args): self.close() -def create_measure_fn( - config: MeasurementConfig | None = None -) -> Callable | None: +def create_measure_fn(config: MeasurementConfig | None = None) -> Callable | None: """ Create a measure(r, g, b) -> (X, Y, Z) function from config. diff --git a/calibrate_pro/hardware/native_backend.py b/calibrate_pro/hardware/native_backend.py index e2a86df..aa94aff 100644 --- a/calibrate_pro/hardware/native_backend.py +++ b/calibrate_pro/hardware/native_backend.py @@ -10,7 +10,6 @@ - Calibrite ColorChecker Display """ - from calibrate_pro.hardware.colorimeter_base import ColorimeterBase, ColorMeasurement, DeviceInfo, DeviceType from calibrate_pro.hardware.usb_device import ( COLORIMETER_USB_IDS, @@ -47,10 +46,7 @@ def detect_devices(self) -> list[DeviceInfo]: for usb_dev in usb_devices: # Determine device type and create DeviceInfo - name = COLORIMETER_USB_IDS.get( - (usb_dev.vendor_id, usb_dev.product_id), - "Unknown Colorimeter" - ) + name = COLORIMETER_USB_IDS.get((usb_dev.vendor_id, usb_dev.product_id), "Unknown Colorimeter") # Determine device type if "i1Pro" in name or "ColorMunki" in name and "Display" not in name: @@ -65,14 +61,16 @@ def detect_devices(self) -> list[DeviceInfo]: if "OLED" in name or "Pro Plus" in name: caps.append("oled_mode") - devices.append(DeviceInfo( - name=name, - manufacturer=usb_dev.manufacturer or self._get_manufacturer(usb_dev.vendor_id), - model=name, - serial=usb_dev.serial_number or "", - device_type=dev_type, - capabilities=caps - )) + devices.append( + DeviceInfo( + name=name, + manufacturer=usb_dev.manufacturer or self._get_manufacturer(usb_dev.vendor_id), + model=name, + serial=usb_dev.serial_number or "", + device_type=dev_type, + capabilities=caps, + ) + ) return devices @@ -93,12 +91,14 @@ def _get_driver_for_device(self, usb_dev: USBDeviceInfo) -> ColorimeterBase | No # i1Display family if pid in [0x5001, 0x5011, 0x5020, 0x5010, 0x5021, 0x5022, 0x5023]: from calibrate_pro.hardware.i1display_native import I1DisplayNative + return I1DisplayNative() # i1Pro family would go here # Datacolor Spyder devices elif vid == 0x085C: from calibrate_pro.hardware.spyder_native import SpyderNative + return SpyderNative() return None @@ -157,7 +157,7 @@ def measure_ambient(self) -> ColorMeasurement | None: def set_integration_time(self, seconds: float) -> bool: """Set integration time.""" - if self._driver and hasattr(self._driver, 'set_integration_time'): + if self._driver and hasattr(self._driver, "set_integration_time"): return self._driver.set_integration_time(seconds) return False @@ -197,12 +197,19 @@ def auto_connect() -> NativeBackend | None: if devices: # Prefer spectrophotometer, then i1Display Pro Plus, then any priority_order = [ - "i1Pro 3", "i1Pro 2", "i1Pro", - "ColorChecker Display Plus", "ColorChecker Display Pro", - "i1Display Pro Plus", "i1Display Pro", - "SpyderX2", "SpyderX", - "ColorMunki Display", "i1Display Studio", - "Spyder5", "Spyder4" + "i1Pro 3", + "i1Pro 2", + "i1Pro", + "ColorChecker Display Plus", + "ColorChecker Display Pro", + "i1Display Pro Plus", + "i1Display Pro", + "SpyderX2", + "SpyderX", + "ColorMunki Display", + "i1Display Studio", + "Spyder5", + "Spyder4", ] # Sort devices by priority @@ -212,10 +219,7 @@ def get_priority(dev: DeviceInfo) -> int: return i return len(priority_order) - sorted_devices = sorted( - enumerate(devices), - key=lambda x: get_priority(x[1]) - ) + sorted_devices = sorted(enumerate(devices), key=lambda x: get_priority(x[1])) for idx, _ in sorted_devices: if backend.connect(idx): diff --git a/calibrate_pro/hardware/sensorless_calibration.py b/calibrate_pro/hardware/sensorless_calibration.py index 01710ee..b55fddb 100644 --- a/calibrate_pro/hardware/sensorless_calibration.py +++ b/calibrate_pro/hardware/sensorless_calibration.py @@ -30,9 +30,9 @@ "D55": (0.3324, 0.3474), "D65": (0.3127, 0.3290), "D75": (0.2990, 0.3149), - "A": (0.4476, 0.4074), # Incandescent - "F2": (0.3721, 0.3751), # Cool White Fluorescent - "F11": (0.3805, 0.3769), # Philips TL84 + "A": (0.4476, 0.4074), # Incandescent + "F2": (0.3721, 0.3751), # Cool White Fluorescent + "F11": (0.3805, 0.3769), # Philips TL84 } # Standard illuminant XYZ (normalized to Y=1) @@ -72,11 +72,7 @@ } # Bradford chromatic adaptation matrix -BRADFORD_M = np.array([ - [0.8951, 0.2664, -0.1614], - [-0.7502, 1.7135, 0.0367], - [0.0389, -0.0685, 1.0296] -]) +BRADFORD_M = np.array([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]) BRADFORD_M_INV = np.linalg.inv(BRADFORD_M) @@ -85,9 +81,11 @@ # Data Structures # ============================================================================= + @dataclass class CalibrationTarget: """Target colorimetry for calibration.""" + whitepoint: str = "D65" whitepoint_x: float = 0.3127 whitepoint_y: float = 0.3290 @@ -101,6 +99,7 @@ class CalibrationTarget: @dataclass class DisplayState: """Current display state from DDC/CI readings and panel model.""" + # From DDC/CI brightness: int = 50 contrast: int = 50 @@ -135,6 +134,7 @@ class DisplayState: @dataclass class CalibrationResult: """Result of sensorless calibration.""" + success: bool = False # DDC/CI adjustments applied @@ -172,6 +172,7 @@ class CalibrationResult: # Color Math Functions # ============================================================================= + def xy_to_XYZ(x: float, y: float, Y: float = 1.0) -> np.ndarray: """Convert CIE 1931 xy chromaticity + Y luminance to XYZ.""" if y == 0: @@ -200,11 +201,7 @@ def XYZ_to_Lab(XYZ: np.ndarray, illuminant: str = "D65") -> np.ndarray: epsilon = 216 / 24389 kappa = 24389 / 27 - f = np.where( - xyz_n > epsilon, - np.cbrt(xyz_n), - (kappa * xyz_n + 16) / 116 - ) + f = np.where(xyz_n > epsilon, np.cbrt(xyz_n), (kappa * xyz_n + 16) / 116) L = 116 * f[1] - 16 a = 500 * (f[0] - f[1]) @@ -227,7 +224,7 @@ def Lab_to_XYZ(Lab: np.ndarray, illuminant: str = "D65") -> np.ndarray: kappa = 24389 / 27 xr = fx**3 if fx**3 > epsilon else (116 * fx - 16) / kappa - yr = ((L + 16) / 116)**3 if kappa * epsilon < L else L / kappa + yr = ((L + 16) / 116) ** 3 if kappa * epsilon < L else L / kappa zr = fz**3 if fz**3 > epsilon else (116 * fz - 16) / kappa return np.array([xr, yr, zr]) * ref_XYZ @@ -285,35 +282,33 @@ def delta_E_2000(Lab1: np.ndarray, Lab2: np.ndarray) -> float: h_avg_prime = h_avg_prime % 360 # Calculate T - T = (1 - 0.17 * np.cos(np.radians(h_avg_prime - 30)) - + 0.24 * np.cos(np.radians(2 * h_avg_prime)) - + 0.32 * np.cos(np.radians(3 * h_avg_prime + 6)) - - 0.20 * np.cos(np.radians(4 * h_avg_prime - 63))) + T = ( + 1 + - 0.17 * np.cos(np.radians(h_avg_prime - 30)) + + 0.24 * np.cos(np.radians(2 * h_avg_prime)) + + 0.32 * np.cos(np.radians(3 * h_avg_prime + 6)) + - 0.20 * np.cos(np.radians(4 * h_avg_prime - 63)) + ) # Calculate weighting functions - SL = 1 + (0.015 * (L_avg - 50)**2) / np.sqrt(20 + (L_avg - 50)**2) + SL = 1 + (0.015 * (L_avg - 50) ** 2) / np.sqrt(20 + (L_avg - 50) ** 2) SC = 1 + 0.045 * C_avg_prime SH = 1 + 0.015 * C_avg_prime * T # Rotation term - dTheta = 30 * np.exp(-((h_avg_prime - 275) / 25)**2) + dTheta = 30 * np.exp(-(((h_avg_prime - 275) / 25) ** 2)) RC = 2 * np.sqrt(C_avg_prime**7 / (C_avg_prime**7 + 25**7)) RT = -RC * np.sin(np.radians(2 * dTheta)) # Final calculation dE = np.sqrt( - (dL_prime / SL)**2 - + (dC_prime / SC)**2 - + (dH_prime / SH)**2 - + RT * (dC_prime / SC) * (dH_prime / SH) + (dL_prime / SL) ** 2 + (dC_prime / SC) ** 2 + (dH_prime / SH) ** 2 + RT * (dC_prime / SC) * (dH_prime / SH) ) return float(dE) -def bradford_adapt(XYZ_source: np.ndarray, - white_source: np.ndarray, - white_dest: np.ndarray) -> np.ndarray: +def bradford_adapt(XYZ_source: np.ndarray, white_source: np.ndarray, white_dest: np.ndarray) -> np.ndarray: """ Apply Bradford chromatic adaptation transform. @@ -351,11 +346,7 @@ def primaries_to_matrix(primaries: dict[str, tuple[float, float]]) -> np.ndarray Xb, Yb, Zb = xy_to_XYZ(bx, by) # Primary matrix - P = np.array([ - [Xr, Xg, Xb], - [Yr, Yg, Yb], - [Zr, Zg, Zb] - ]) + P = np.array([[Xr, Xg, Xb], [Yr, Yg, Yb], [Zr, Zg, Zb]]) # White point XYZ W = xy_to_XYZ(wx, wy) @@ -404,6 +395,7 @@ def cct_to_xy(cct: int) -> tuple[float, float]: # EDID Parsing for Colorimetry # ============================================================================= + def parse_edid_colorimetry(edid_bytes: bytes) -> dict[str, Any] | None: """ Parse display colorimetry from EDID data. @@ -508,6 +500,7 @@ def get_edid_from_registry(display_index: int = 0) -> bytes | None: # Sensorless Calibration Engine # ============================================================================= + class SensorlessCalibrationEngine: """ Sensorless hardware calibration using panel characterization and colorimetric math. @@ -567,6 +560,7 @@ def initialize(self, ddc_controller, display_index: int = 0) -> bool: # Initialize VCGT calibrator for software-side correction try: from calibrate_pro.lut_system.vcgt_calibration import VCGTCalibrator + self._vcgt_calibrator = VCGTCalibrator(display_index=display_index) if self._vcgt_calibrator.available: self._vcgt_calibrator.backup_current_ramp() @@ -665,9 +659,7 @@ def _read_display_state(self): self._display_state.native_white_x = e["white"][0] self._display_state.native_white_y = e["white"][1] - def calibrate(self, - target: CalibrationTarget, - output_dir: Path | None = None) -> CalibrationResult: + def calibrate(self, target: CalibrationTarget, output_dir: Path | None = None) -> CalibrationResult: """ Perform sensorless hardware calibration. @@ -695,10 +687,7 @@ def calibrate(self, # Step 1: Analyze native display characteristics self._report_progress("Analyzing display characteristics...", 0.1) - native_white_xy = ( - self._display_state.native_white_x, - self._display_state.native_white_y - ) + native_white_xy = (self._display_state.native_white_x, self._display_state.native_white_y) native_cct = calculate_cct(*native_white_xy) result.messages.append(f"Native white point: x={native_white_xy[0]:.4f}, y={native_white_xy[1]:.4f}") result.messages.append(f"Native CCT: {native_cct}K") @@ -709,12 +698,12 @@ def calibrate(self, target_cct = calculate_cct(*target_white_xy) # Calculate RGB gain adjustments using colorimetric math - rgb_gains = self._calculate_rgb_gains_for_whitepoint( - native_white_xy, target_white_xy - ) + rgb_gains = self._calculate_rgb_gains_for_whitepoint(native_white_xy, target_white_xy) result.messages.append(f"Target white point: x={target_white_xy[0]:.4f}, y={target_white_xy[1]:.4f}") result.messages.append(f"Target CCT: {target_cct}K") - result.messages.append(f"RGB gain adjustments: R={rgb_gains[0]:.1f}, G={rgb_gains[1]:.1f}, B={rgb_gains[2]:.1f}") + result.messages.append( + f"RGB gain adjustments: R={rgb_gains[0]:.1f}, G={rgb_gains[1]:.1f}, B={rgb_gains[2]:.1f}" + ) # Step 3: Calculate brightness for target luminance self._report_progress("Calculating brightness adjustment...", 0.3) @@ -759,10 +748,7 @@ def calibrate(self, vcgt_r = vcgt_g = vcgt_b = 1.0 vcgt_applied = self._vcgt_calibrator.apply_white_balance( - red_gain=vcgt_r, - green_gain=vcgt_g, - blue_gain=vcgt_b, - gamma=target.gamma + red_gain=vcgt_r, green_gain=vcgt_g, blue_gain=vcgt_b, gamma=target.gamma ) self._vcgt_applied = vcgt_applied @@ -820,6 +806,7 @@ def calibrate(self, # Always generate profile (use output_dir or temp) import tempfile + if output_dir: profile_dir = Path(output_dir) else: @@ -866,9 +853,9 @@ def calibrate(self, return result - def _calculate_rgb_gains_for_whitepoint(self, - native_xy: tuple[float, float], - target_xy: tuple[float, float]) -> np.ndarray: + def _calculate_rgb_gains_for_whitepoint( + self, native_xy: tuple[float, float], target_xy: tuple[float, float] + ) -> np.ndarray: """ Calculate RGB gain adjustments to achieve target white point. @@ -878,11 +865,13 @@ def _calculate_rgb_gains_for_whitepoint(self, # Use iterative refinement for best accuracy return self._iterative_white_balance(native_xy, target_xy) - def _iterative_white_balance(self, - native_xy: tuple[float, float], - target_xy: tuple[float, float], - max_iterations: int = 50, - tolerance: float = 0.00001) -> np.ndarray: + def _iterative_white_balance( + self, + native_xy: tuple[float, float], + target_xy: tuple[float, float], + max_iterations: int = 50, + tolerance: float = 0.00001, + ) -> np.ndarray: """ Iteratively refine RGB gains to achieve target white point. @@ -980,9 +969,7 @@ def _iterative_white_balance(self, return gains_scaled - def _calculate_rgb_gains_direct(self, - native_xy: tuple[float, float], - target_xy: tuple[float, float]) -> np.ndarray: + def _calculate_rgb_gains_direct(self, native_xy: tuple[float, float], target_xy: tuple[float, float]) -> np.ndarray: """ Direct calculation of RGB gains using matrix math. """ @@ -1010,9 +997,9 @@ def _calculate_rgb_gains_direct(self, gains = target_rgb / native_rgb # Normalize to center around current gains (typically 50 or 100) - current_center = (self._display_state.red_gain + - self._display_state.green_gain + - self._display_state.blue_gain) / 3 + current_center = ( + self._display_state.red_gain + self._display_state.green_gain + self._display_state.blue_gain + ) / 3 # Scale gains to DDC/CI range (0-100) gains_normalized = gains * current_center @@ -1028,9 +1015,9 @@ def _calculate_rgb_gains_direct(self, # Fallback: simple CCT-based adjustment return self._calculate_rgb_gains_from_cct(native_xy, target_xy) - def _calculate_rgb_gains_from_cct(self, - native_xy: tuple[float, float], - target_xy: tuple[float, float]) -> np.ndarray: + def _calculate_rgb_gains_from_cct( + self, native_xy: tuple[float, float], target_xy: tuple[float, float] + ) -> np.ndarray: """ Fallback: Calculate RGB gains using CCT difference. @@ -1042,11 +1029,9 @@ def _calculate_rgb_gains_from_cct(self, cct_diff = target_cct - native_cct # Current gains as baseline - base = np.array([ - self._display_state.red_gain, - self._display_state.green_gain, - self._display_state.blue_gain - ], dtype=float) + base = np.array( + [self._display_state.red_gain, self._display_state.green_gain, self._display_state.blue_gain], dtype=float + ) # Adjust based on CCT difference # Higher CCT = cooler (more blue, less red) @@ -1090,8 +1075,7 @@ def _calculate_brightness_for_luminance(self, target_luminance: float) -> int: return brightness - def _estimate_white_point_after_adjustment(self, - rgb_gains: np.ndarray) -> tuple[float, float]: + def _estimate_white_point_after_adjustment(self, rgb_gains: np.ndarray) -> tuple[float, float]: """ Estimate the white point that will be achieved after RGB gain adjustment. @@ -1121,8 +1105,7 @@ def _estimate_white_point_after_adjustment(self, except (np.linalg.LinAlgError, ValueError): # Fallback: assume linear adjustment - return (self._display_state.native_white_x, - self._display_state.native_white_y) + return (self._display_state.native_white_x, self._display_state.native_white_y) def _estimate_grayscale_delta_e(self, target: CalibrationTarget) -> float: """ @@ -1141,9 +1124,7 @@ def _estimate_grayscale_delta_e(self, target: CalibrationTarget) -> float: target_gamma = target.gamma # Calculate average gamma error - gamma_error = (abs(gamma_r - target_gamma) + - abs(gamma_g - target_gamma) + - abs(gamma_b - target_gamma)) / 3 + gamma_error = (abs(gamma_r - target_gamma) + abs(gamma_g - target_gamma) + abs(gamma_b - target_gamma)) / 3 # Estimate Delta E contribution from gamma error # Roughly 0.5 Delta E per 0.05 gamma error @@ -1237,8 +1218,7 @@ def gamut_area(p): except (KeyError, ValueError): return True # Safe default - def _generate_icc_profile(self, target: CalibrationTarget, - output_dir: Path) -> Path | None: + def _generate_icc_profile(self, target: CalibrationTarget, output_dir: Path) -> Path | None: """ Generate ICC profile for the calibrated display. @@ -1255,8 +1235,9 @@ def _generate_icc_profile(self, target: CalibrationTarget, panel_name = "Unknown" if self._panel_profile: # Use model_pattern or extract from manufacturer - panel_name = getattr(self._panel_profile, 'model_pattern', '') or \ - getattr(self._panel_profile, 'manufacturer', 'Unknown') + panel_name = getattr(self._panel_profile, "model_pattern", "") or getattr( + self._panel_profile, "manufacturer", "Unknown" + ) elif self._edid_colorimetry: panel_name = self._edid_colorimetry.get("model", "Display") @@ -1264,10 +1245,7 @@ def _generate_icc_profile(self, target: CalibrationTarget, # Create ICC profile builder profile = ICCProfile( - description=description, - copyright="Generated by Calibrate Pro", - manufacturer="QNTA", - model="CALB" + description=description, copyright="Generated by Calibrate Pro", manufacturer="QNTA", model="CALB" ) # Set display primaries from calibration state @@ -1275,7 +1253,7 @@ def _generate_icc_profile(self, target: CalibrationTarget, red=(self._display_state.native_red_x, self._display_state.native_red_y), green=(self._display_state.native_green_x, self._display_state.native_green_y), blue=(self._display_state.native_blue_x, self._display_state.native_blue_y), - white=(target.whitepoint_x, target.whitepoint_y) + white=(target.whitepoint_x, target.whitepoint_y), ) # Set target gamma for all channels @@ -1285,16 +1263,13 @@ def _generate_icc_profile(self, target: CalibrationTarget, if self._vcgt_calibrator and self._vcgt_applied: current_curves = self._vcgt_calibrator.get_current_curves() if current_curves: - profile.set_vcgt( - red=current_curves[0], - green=current_curves[1], - blue=current_curves[2] - ) + profile.set_vcgt(red=current_curves[0], green=current_curves[1], blue=current_curves[2]) # Generate profile filename with panel info # Sanitize name - remove all characters invalid for filenames import re - safe_name = re.sub(r'[<>:"/\\|?*\.\[\]\(\)\^$+{}]', '', panel_name) + + safe_name = re.sub(r'[<>:"/\\|?*\.\[\]\(\)\^$+{}]', "", panel_name) safe_name = safe_name.replace(" ", "_")[:20] if not safe_name: safe_name = "Display" @@ -1307,12 +1282,12 @@ def _generate_icc_profile(self, target: CalibrationTarget, except (ImportError, OSError, ValueError) as e: import traceback + print(f"ICC profile generation failed: {e}") traceback.print_exc() return None - def _generate_3d_lut(self, target: CalibrationTarget, - output_dir: Path) -> Path | None: + def _generate_3d_lut(self, target: CalibrationTarget, output_dir: Path) -> Path | None: """ Generate 3D LUT for gamut mapping and color correction. @@ -1354,7 +1329,7 @@ def _generate_3d_lut(self, target: CalibrationTarget, # Build descriptive title panel_name = "Unknown" if self._panel_profile: - panel_name = getattr(self._panel_profile, 'manufacturer', 'Display') + panel_name = getattr(self._panel_profile, "manufacturer", "Display") elif self._edid_colorimetry: panel_name = self._edid_colorimetry.get("model", "Display") @@ -1371,12 +1346,13 @@ def _generate_3d_lut(self, target: CalibrationTarget, gamma_blue=self._display_state.gamma_blue, color_matrix=correction_matrix, title=lut_title, - target_gamma=target.gamma + target_gamma=target.gamma, ) # Generate safe filename import re - safe_name = re.sub(r'[<>:"/\\|?*\.\[\]\(\)\^$+{}]', '', panel_name) + + safe_name = re.sub(r'[<>:"/\\|?*\.\[\]\(\)\^$+{}]', "", panel_name) safe_name = safe_name.replace(" ", "_")[:20] if not safe_name: safe_name = "Display" @@ -1390,6 +1366,7 @@ def _generate_3d_lut(self, target: CalibrationTarget, except (ImportError, OSError, ValueError) as e: import traceback + print(f"3D LUT generation failed: {e}") traceback.print_exc() return None @@ -1427,11 +1404,7 @@ def _install_and_associate_profile(self, profile_path: Path) -> bool: profile_name = Path(profile_path).name # Associate profile with display - success, msg = associate_profile_with_display( - profile_name, - device_name, - make_default=True - ) + success, msg = associate_profile_with_display(profile_name, device_name, make_default=True) if not success: print(f"Profile association failed: {msg}") @@ -1451,6 +1424,7 @@ def _set_brightness(self, value: int): if self._ddc_controller and self._monitor: try: from calibrate_pro.hardware.ddc_ci import VCPCode + self._ddc_controller.set_vcp(self._monitor, VCPCode.BRIGHTNESS, value) except (RuntimeError, OSError): pass @@ -1512,7 +1486,7 @@ def apply_lut_system_wide(self, lut_path: Path | None = None) -> bool: # Use provided path or find the last generated LUT if lut_path is None: # Look in the default profile directory - profile_dir = Path(os.environ.get('TEMP', '/tmp')) / "calibrate_pro_profiles" + profile_dir = Path(os.environ.get("TEMP", "/tmp")) / "calibrate_pro_profiles" cube_files = list(profile_dir.glob("*.cube")) if not cube_files: print("No LUT files found") @@ -1527,9 +1501,7 @@ def apply_lut_system_wide(self, lut_path: Path | None = None) -> bool: # Load and apply LUT success = controller.load_lut_file( - display_id=self._display_index, - lut_path=lut_path, - color_space=LUTColorSpace.SRGB + display_id=self._display_index, lut_path=lut_path, color_space=LUTColorSpace.SRGB ) if success: @@ -1577,9 +1549,7 @@ def _get_accuracy_rating(self, delta_e: float) -> str: else: return "NEEDS IMPROVEMENT (Delta E > 5.0)" - def quick_white_balance(self, - target_x: float = 0.3127, - target_y: float = 0.3290) -> CalibrationResult: + def quick_white_balance(self, target_x: float = 0.3127, target_y: float = 0.3290) -> CalibrationResult: """ Perform quick white balance adjustment only. @@ -1593,15 +1563,10 @@ def quick_white_balance(self, return result # Get native white point - native_xy = ( - self._display_state.native_white_x, - self._display_state.native_white_y - ) + native_xy = (self._display_state.native_white_x, self._display_state.native_white_y) # Calculate RGB gains - rgb_gains = self._calculate_rgb_gains_for_whitepoint( - native_xy, (target_x, target_y) - ) + rgb_gains = self._calculate_rgb_gains_for_whitepoint(native_xy, (target_x, target_y)) # Apply gains r = int(np.clip(rgb_gains[0], 0, 100)) @@ -1650,13 +1615,14 @@ def quick_white_balance(self, # Convenience Functions # ============================================================================= + def run_sensorless_calibration( display_index: int = 0, whitepoint: str = "D65", luminance: float = 120.0, gamma: float = 2.2, gamut: str = "sRGB", - output_dir: str | None = None + output_dir: str | None = None, ) -> CalibrationResult: """ Convenience function to run sensorless calibration. @@ -1702,15 +1668,13 @@ def run_sensorless_calibration( ) # Run calibration - return engine.calibrate( - target, - Path(output_dir) if output_dir else None - ) + return engine.calibrate(target, Path(output_dir) if output_dir else None) @dataclass class DisplayInfo: """Information about a detected display.""" + index: int name: str edid_model: str @@ -1748,55 +1712,54 @@ def detect_displays() -> list[DisplayInfo]: # Get monitor name from DDC monitor = monitors[i] - name = monitor.get('name', f'Display {i}') if isinstance(monitor, dict) else f'Display {i}' + name = monitor.get("name", f"Display {i}") if isinstance(monitor, dict) else f"Display {i}" # Look up panel profile - panel = db.find_panel('PG27UCDM') # TODO: Match by EDID + panel = db.find_panel("PG27UCDM") # TODO: Match by EDID if colorimetry: - native_xy = (colorimetry['white'][0], colorimetry['white'][1]) - native_cct = colorimetry.get('cct', calculate_cct(*native_xy)) + native_xy = (colorimetry["white"][0], colorimetry["white"][1]) + native_cct = colorimetry.get("cct", calculate_cct(*native_xy)) primaries = { - 'red': colorimetry['red'], - 'green': colorimetry['green'], - 'blue': colorimetry['blue'], - 'white': colorimetry['white'], + "red": colorimetry["red"], + "green": colorimetry["green"], + "blue": colorimetry["blue"], + "white": colorimetry["white"], } elif panel: p = panel.native_primaries native_xy = (p.white.x, p.white.y) native_cct = calculate_cct(*native_xy) primaries = { - 'red': (p.red.x, p.red.y), - 'green': (p.green.x, p.green.y), - 'blue': (p.blue.x, p.blue.y), - 'white': (p.white.x, p.white.y), + "red": (p.red.x, p.red.y), + "green": (p.green.x, p.green.y), + "blue": (p.blue.x, p.blue.y), + "white": (p.white.x, p.white.y), } else: - native_xy = ILLUMINANTS['D65'] + native_xy = ILLUMINANTS["D65"] native_cct = 6500 primaries = {} - displays.append(DisplayInfo( - index=i, - name=name, - edid_model=name, - panel_type=panel.panel_type if panel else 'Unknown', - native_white_xy=native_xy, - native_cct=native_cct, - edid_primaries=primaries, - has_panel_profile=panel is not None, - manufacturer=panel.manufacturer if panel else '', - )) + displays.append( + DisplayInfo( + index=i, + name=name, + edid_model=name, + panel_type=panel.panel_type if panel else "Unknown", + native_white_xy=native_xy, + native_cct=native_cct, + edid_primaries=primaries, + has_panel_profile=panel is not None, + manufacturer=panel.manufacturer if panel else "", + ) + ) return displays def auto_calibrate( - display_index: int = 0, - whitepoint: str = "D65", - gamma: float = 2.2, - luminance: float = 120.0 + display_index: int = 0, whitepoint: str = "D65", gamma: float = 2.2, luminance: float = 120.0 ) -> tuple[bool, str, CalibrationResult | None]: """ Automatically detect display and apply calibration. @@ -1830,11 +1793,7 @@ def auto_calibrate( # Run calibration result = run_sensorless_calibration( - display_index=display_index, - whitepoint=whitepoint, - luminance=luminance, - gamma=gamma, - gamut="sRGB" + display_index=display_index, whitepoint=whitepoint, luminance=luminance, gamma=gamma, gamut="sRGB" ) if result.success: @@ -1861,6 +1820,7 @@ def auto_calibrate( # System-wide LUT Functions # ============================================================================= + def apply_lut(lut_path: str | Path, display_index: int = 0) -> tuple[bool, str]: """ Apply a 3D LUT file system-wide to a display. @@ -1892,11 +1852,7 @@ def apply_lut(lut_path: str | Path, display_index: int = 0) -> tuple[bool, str]: if not lut_path.exists(): return False, f"LUT file not found: {lut_path}" - success = controller.load_lut_file( - display_id=display_index, - lut_path=lut_path, - color_space=LUTColorSpace.SRGB - ) + success = controller.load_lut_file(display_id=display_index, lut_path=lut_path, color_space=LUTColorSpace.SRGB) if success: return True, f"3D LUT applied system-wide: {lut_path.name}" @@ -1959,11 +1915,11 @@ def get_lut_status() -> dict[int, dict]: for display_id, info in active.items(): result[display_id] = { - 'display_name': info.display_name, - 'lut_active': info.lut_active, - 'lut_path': info.lut_path, - 'lut_size': info.lut_size, - 'color_space': info.color_space.value, + "display_name": info.display_name, + "lut_active": info.lut_active, + "lut_path": info.lut_path, + "lut_size": info.lut_size, + "color_space": info.color_space.value, } return result diff --git a/calibrate_pro/hardware/spectro.py b/calibrate_pro/hardware/spectro.py index efcc4d6..64ef8e7 100644 --- a/calibrate_pro/hardware/spectro.py +++ b/calibrate_pro/hardware/spectro.py @@ -25,6 +25,7 @@ class SpectroType: """Spectrophotometer types.""" + I1PRO = "i1Pro" I1PRO2 = "i1Pro2" I1PRO3 = "i1Pro3" @@ -38,17 +39,9 @@ class SpectroType: # Standard observer functions for spectral calculations -CIE_1931_2DEG = { - "name": "CIE 1931 2° Standard Observer", - "wavelength_range": (380, 780), - "wavelength_step": 5 -} +CIE_1931_2DEG = {"name": "CIE 1931 2° Standard Observer", "wavelength_range": (380, 780), "wavelength_step": 5} -CIE_1964_10DEG = { - "name": "CIE 1964 10° Standard Observer", - "wavelength_range": (380, 780), - "wavelength_step": 5 -} +CIE_1964_10DEG = {"name": "CIE 1964 10° Standard Observer", "wavelength_range": (380, 780), "wavelength_step": 5} class SpectrophotometerDriver(ArgyllBackend): @@ -86,11 +79,21 @@ def detect_devices(self) -> list[DeviceInfo]: def _is_spectrophotometer(self, device: DeviceInfo) -> bool: """Check if device is a spectrophotometer.""" name_lower = device.name.lower() - return any(x in name_lower for x in [ - "i1pro", "i1 pro", "specbos", "spectro", - "colorchecker display pro", "colorchecker display plus", - "klein", "k-10", "photo research", "pr-" - ]) + return any( + x in name_lower + for x in [ + "i1pro", + "i1 pro", + "specbos", + "spectro", + "colorchecker display pro", + "colorchecker display plus", + "klein", + "k-10", + "photo research", + "pr-", + ] + ) def _identify_model(self, device: DeviceInfo) -> None: """Identify spectrophotometer model.""" @@ -130,8 +133,7 @@ def _identify_model(self, device: DeviceInfo) -> None: device.capabilities.append("emission") # Add model-specific capabilities - if self.model_type in [SpectroType.I1PRO, SpectroType.I1PRO2, - SpectroType.I1PRO3, SpectroType.I1PRO3_PLUS]: + if self.model_type in [SpectroType.I1PRO, SpectroType.I1PRO2, SpectroType.I1PRO3, SpectroType.I1PRO3_PLUS]: device.capabilities.extend(["reflective", "ambient", "scanning"]) def set_observer(self, observer: str) -> bool: @@ -175,11 +177,7 @@ def get_spectral_data(self) -> dict[float, float] | None: # Parse from ArgyllCMS output if available return None - def spectral_to_xyz( - self, - spectral_data: dict[float, float], - illuminant: str = "D65" - ) -> tuple[float, float, float]: + def spectral_to_xyz(self, spectral_data: dict[float, float], illuminant: str = "D65") -> tuple[float, float, float]: """ Convert spectral data to XYZ using color matching functions. @@ -231,9 +229,11 @@ def _cmf_at_wavelength(self, wavelength: float) -> tuple[float, float, float]: wl = wavelength # X bar (two peaks) - x = (1.056 * np.exp(-0.5 * ((wl - 599.8) / 37.9) ** 2) + - 0.362 * np.exp(-0.5 * ((wl - 442.0) / 16.0) ** 2) - - 0.065 * np.exp(-0.5 * ((wl - 501.1) / 20.4) ** 2)) + x = ( + 1.056 * np.exp(-0.5 * ((wl - 599.8) / 37.9) ** 2) + + 0.362 * np.exp(-0.5 * ((wl - 442.0) / 16.0) ** 2) + - 0.065 * np.exp(-0.5 * ((wl - 501.1) / 20.4) ** 2) + ) # Y bar y = 1.217 * np.exp(-0.5 * ((wl - 568.8) / 46.9) ** 2) @@ -310,8 +310,7 @@ def calibrate_device(self) -> bool: """ self._report_progress("Performing calibration...", 0) - if self.model_type in [SpectroType.I1PRO, SpectroType.I1PRO2, - SpectroType.I1PRO3, SpectroType.I1PRO3_PLUS]: + if self.model_type in [SpectroType.I1PRO, SpectroType.I1PRO2, SpectroType.I1PRO3, SpectroType.I1PRO3_PLUS]: self._report_progress("Place device on calibration tile", 0.1) else: self._report_progress("Ensure lens cap is on", 0.1) diff --git a/calibrate_pro/hardware/spyder.py b/calibrate_pro/hardware/spyder.py index 8539b7c..6760ab1 100644 --- a/calibrate_pro/hardware/spyder.py +++ b/calibrate_pro/hardware/spyder.py @@ -19,6 +19,7 @@ class SpyderType: """Spyder model variants.""" + SPYDER_X_ELITE = "SpyderX Elite" SPYDER_X_PRO = "SpyderX Pro" SPYDER_X2_ELITE = "SpyderX2 Elite" @@ -84,36 +85,20 @@ class SpyderType: SPYDER_CORRECTIONS = { "WOLED": { "description": "WOLED (LG/Sony) Correction", - "matrix": [ - [1.0312, -0.0198, -0.0114], - [-0.0112, 1.0189, -0.0077], - [0.0034, -0.0123, 1.0089] - ] + "matrix": [[1.0312, -0.0198, -0.0114], [-0.0112, 1.0189, -0.0077], [0.0034, -0.0123, 1.0089]], }, "QDOLED": { "description": "QD-OLED (Samsung) Correction", - "matrix": [ - [1.0156, -0.0089, -0.0067], - [-0.0078, 1.0134, -0.0056], - [0.0023, -0.0078, 1.0055] - ] + "matrix": [[1.0156, -0.0089, -0.0067], [-0.0078, 1.0134, -0.0056], [0.0023, -0.0078, 1.0055]], }, "WideGamut": { "description": "Wide Gamut LCD Correction", - "matrix": [ - [1.0067, -0.0045, -0.0022], - [-0.0034, 1.0056, -0.0022], - [0.0011, -0.0034, 1.0023] - ] + "matrix": [[1.0067, -0.0045, -0.0022], [-0.0034, 1.0056, -0.0022], [0.0011, -0.0034, 1.0023]], }, "LCD": { "description": "Standard LCD Correction", - "matrix": [ - [1.0000, 0.0000, 0.0000], - [0.0000, 1.0000, 0.0000], - [0.0000, 0.0000, 1.0000] - ] - } + "matrix": [[1.0000, 0.0000, 0.0000], [0.0000, 1.0000, 0.0000], [0.0000, 0.0000, 1.0000]], + }, } @@ -152,9 +137,7 @@ def detect_devices(self) -> list[DeviceInfo]: def _is_spyder(self, device: DeviceInfo) -> bool: """Check if device is a Spyder colorimeter.""" name_lower = device.name.lower() - return any(x in name_lower for x in [ - "spyder", "datacolor" - ]) + return any(x in name_lower for x in ["spyder", "datacolor"]) def _identify_model(self, device: DeviceInfo) -> None: """Identify specific Spyder model and capabilities.""" @@ -252,12 +235,7 @@ def measure_ambient(self) -> ColorMeasurement | None: try: cmd = [str(self.spotread_path), "-a", "-x"] - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30 - ) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode == 0: return self._parse_ambient_output(result.stdout) @@ -269,18 +247,14 @@ def measure_ambient(self) -> ColorMeasurement | None: def _parse_ambient_output(self, output: str) -> ColorMeasurement | None: """Parse ambient measurement output.""" - lux_match = re.search(r'Lux:\s*([\d.]+)', output) - cct_match = re.search(r'CCT:\s*([\d.]+)', output) + lux_match = re.search(r"Lux:\s*([\d.]+)", output) + cct_match = re.search(r"CCT:\s*([\d.]+)", output) if lux_match: lux = float(lux_match.group(1)) cct = float(cct_match.group(1)) if cct_match else 0 - return ColorMeasurement( - X=0, Y=lux, Z=0, - cct=cct, - measurement_mode="ambient" - ) + return ColorMeasurement(X=0, Y=lux, Z=0, cct=cct, measurement_mode="ambient") return None @@ -312,7 +286,7 @@ def _apply_correction(self, measurement: ColorMeasurement) -> ColorMeasurement: Z=float(corrected[2]), spectral_data=measurement.spectral_data, integration_time=measurement.integration_time, - measurement_mode=measurement.measurement_mode + measurement_mode=measurement.measurement_mode, ) def calibrate_device(self) -> bool: diff --git a/calibrate_pro/hardware/spyder_native.py b/calibrate_pro/hardware/spyder_native.py index b52df5b..f4eb21f 100644 --- a/calibrate_pro/hardware/spyder_native.py +++ b/calibrate_pro/hardware/spyder_native.py @@ -35,6 +35,7 @@ # Spyder command codes class SpyderCommand: """Spyder device command codes.""" + RESET = 0x00 GET_STATUS = 0x01 GET_SERIAL = 0x02 @@ -52,30 +53,19 @@ class SpyderCommand: # SpyderX default calibration matrix # Maps sensor RGB to CIE XYZ -SPYDER_X_MATRIX = np.array([ - [0.4361, 0.3851, 0.1431], - [0.2225, 0.7169, 0.0606], - [0.0139, 0.0971, 0.7141] -]) +SPYDER_X_MATRIX = np.array([[0.4361, 0.3851, 0.1431], [0.2225, 0.7169, 0.0606], [0.0139, 0.0971, 0.7141]]) # SpyderX2 has improved sensors -SPYDER_X2_MATRIX = np.array([ - [0.4243, 0.3836, 0.1570], - [0.2126, 0.7152, 0.0722], - [0.0193, 0.1192, 0.9503] -]) +SPYDER_X2_MATRIX = np.array([[0.4243, 0.3836, 0.1570], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9503]]) # Spyder5 matrix -SPYDER_5_MATRIX = np.array([ - [0.4124, 0.3576, 0.1805], - [0.2126, 0.7152, 0.0722], - [0.0193, 0.1192, 0.9505] -]) +SPYDER_5_MATRIX = np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]]) @dataclass class SpyderCalibrationData: """Spyder calibration data from EEPROM.""" + serial: str model: str firmware_version: str @@ -94,7 +84,7 @@ class SpyderNative(ColorimeterBase): # Supported device IDs SUPPORTED_DEVICES = { - (0x085C, 0x0700): ("SpyderX2 Ultra", 6, True), # (name, sensors, has_ambient) + (0x085C, 0x0700): ("SpyderX2 Ultra", 6, True), # (name, sensors, has_ambient) (0x085C, 0x0600): ("SpyderX Elite", 3, True), (0x085C, 0x0500): ("Spyder5 Elite", 7, True), (0x085C, 0x0400): ("Spyder4 Elite", 7, True), @@ -123,15 +113,17 @@ def detect_devices(self) -> list[DeviceInfo]: if has_ambient: caps.append("ambient") - devices.append(DeviceInfo( - name=name, - manufacturer="Datacolor", - model=name, - serial=usb_dev.serial_number or "Unknown", - device_type=DeviceType.COLORIMETER, - firmware_version="", - capabilities=caps - )) + devices.append( + DeviceInfo( + name=name, + manufacturer="Datacolor", + model=name, + serial=usb_dev.serial_number or "Unknown", + device_type=DeviceType.COLORIMETER, + firmware_version="", + capabilities=caps, + ) + ) return devices @@ -188,14 +180,14 @@ def disconnect(self) -> bool: self.is_connected = False return True - def _send_command(self, cmd: int, data: bytes = b'') -> bytes: + def _send_command(self, cmd: int, data: bytes = b"") -> bytes: """Send command and receive response.""" if not self._transport or not self._transport.is_open: raise CommunicationError("Device not connected") # Spyder command format: [0x00] [cmd] [len] [data...] packet = bytes([0x00, cmd, len(data)]) + data - packet = packet.ljust(64, b'\x00') + packet = packet.ljust(64, b"\x00") self._transport.write(packet) time.sleep(0.02) @@ -233,7 +225,7 @@ def _read_device_info(self): resp = self._send_command(SpyderCommand.GET_SERIAL) serial = "" if len(resp) >= 16: - serial = resp[3:19].decode('ascii', errors='ignore').strip('\x00') + serial = resp[3:19].decode("ascii", errors="ignore").strip("\x00") key = (self._usb_info.vendor_id, self._usb_info.product_id) name = self.SUPPORTED_DEVICES.get(key, ("Spyder", 3, True))[0] @@ -245,7 +237,7 @@ def _read_device_info(self): serial=serial or self._usb_info.serial_number or "", device_type=DeviceType.COLORIMETER, firmware_version=version, - capabilities=["spot", "ambient", "emission"] + capabilities=["spot", "ambient", "emission"], ) except Exception: @@ -256,7 +248,7 @@ def _read_device_info(self): model=self._usb_info.product, serial=self._usb_info.serial_number or "", device_type=DeviceType.COLORIMETER, - capabilities=["spot", "emission"] + capabilities=["spot", "emission"], ) def _read_calibration_data(self): @@ -271,14 +263,14 @@ def _read_calibration_data(self): for j in range(3): idx = 4 + (i * 3 + j) * 4 if idx + 4 <= len(resp): - matrix[i, j] = struct.unpack(' bool: for i in range(min(3, self._sensor_count)): idx = 40 + i * 4 if idx + 4 <= len(resp): - self._cal_data.dark_offsets[i] = struct.unpack(' bool: self._integration_time = seconds ms = int(seconds * 1000) - data = struct.pack(' ColorMeasurement | None: try: # Set integration time ms = int(self._integration_time * 1000) - int_data = struct.pack(' ColorMeasurement | None: for i in range(min(3, self._sensor_count)): idx = 4 + i * 4 if idx + 4 <= len(resp): - sensor_sum[i] += struct.unpack(' ColorMeasurement | None: Y=float(xyz[1]), Z=float(xyz[2]), integration_time=self._integration_time, - measurement_mode="spot" + measurement_mode="spot", ) except Exception as e: @@ -444,13 +436,8 @@ def measure_ambient(self) -> ColorMeasurement | None: time.sleep(1.0) if len(resp) >= 8: - lux = struct.unpack(' str: (0x0765, 0xD094): "i1Display 2", (0x0765, 0xD095): "i1Display LT", (0x0765, 0xD096): "ColorMunki Smile", - # Datacolor Spyder devices (0x085C, 0x0200): "Spyder2", (0x085C, 0x0300): "Spyder3", @@ -84,7 +90,6 @@ def vid_pid(self) -> str: (0x085C, 0x0500): "Spyder5", (0x085C, 0x0600): "SpyderX", (0x085C, 0x0700): "SpyderX2", - # Calibrite (rebranded X-Rite) (0x0765, 0x5021): "ColorChecker Display", (0x0765, 0x5022): "ColorChecker Display Pro", @@ -176,7 +181,7 @@ def read(self, size: int, timeout: int = 1000) -> bytes: raise CommunicationError("Device not open") try: data = self._device.read(size, timeout_ms=timeout) - return bytes(data) if data else b'' + return bytes(data) if data else b"" except OSError as e: raise CommunicationError(f"Read failed: {e}") from e @@ -200,10 +205,7 @@ def open(self, device_info: USBDeviceInfo) -> bool: """Open raw USB device.""" with self._lock: try: - self._device = usb.core.find( - idVendor=device_info.vendor_id, - idProduct=device_info.product_id - ) + self._device = usb.core.find(idVendor=device_info.vendor_id, idProduct=device_info.product_id) if self._device is None: raise DeviceNotFoundError("Device not found") @@ -223,11 +225,10 @@ def open(self, device_info: USBDeviceInfo) -> bool: self._ep_out = usb.util.find_descriptor( intf, - custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT + custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT, ) self._ep_in = usb.util.find_descriptor( - intf, - custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN + intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN ) return True @@ -280,18 +281,20 @@ def enumerate_hid_devices() -> list[USBDeviceInfo]: try: for dev in hid.enumerate(): - vid = dev.get('vendor_id', 0) - pid = dev.get('product_id', 0) + vid = dev.get("vendor_id", 0) + pid = dev.get("product_id", 0) if (vid, pid) in COLORIMETER_USB_IDS: - devices.append(USBDeviceInfo( - vendor_id=vid, - product_id=pid, - manufacturer=dev.get('manufacturer_string', '') or COLORIMETER_USB_IDS.get((vid, pid), ''), - product=dev.get('product_string', '') or COLORIMETER_USB_IDS.get((vid, pid), ''), - serial_number=dev.get('serial_number', '') or '', - path=dev.get('path') - )) + devices.append( + USBDeviceInfo( + vendor_id=vid, + product_id=pid, + manufacturer=dev.get("manufacturer_string", "") or COLORIMETER_USB_IDS.get((vid, pid), ""), + product=dev.get("product_string", "") or COLORIMETER_USB_IDS.get((vid, pid), ""), + serial_number=dev.get("serial_number", "") or "", + path=dev.get("path"), + ) + ) except OSError: pass @@ -310,21 +313,23 @@ def enumerate_usb_devices() -> list[USBDeviceInfo]: dev = usb.core.find(idVendor=vid, idProduct=pid) if dev: try: - manufacturer = usb.util.get_string(dev, dev.iManufacturer) if dev.iManufacturer else '' - product = usb.util.get_string(dev, dev.iProduct) if dev.iProduct else '' - serial = usb.util.get_string(dev, dev.iSerialNumber) if dev.iSerialNumber else '' + manufacturer = usb.util.get_string(dev, dev.iManufacturer) if dev.iManufacturer else "" + product = usb.util.get_string(dev, dev.iProduct) if dev.iProduct else "" + serial = usb.util.get_string(dev, dev.iSerialNumber) if dev.iSerialNumber else "" except (OSError, ValueError): - manufacturer = product = serial = '' - - devices.append(USBDeviceInfo( - vendor_id=vid, - product_id=pid, - manufacturer=manufacturer or COLORIMETER_USB_IDS.get((vid, pid), ''), - product=product or COLORIMETER_USB_IDS.get((vid, pid), ''), - serial_number=serial, - bus=dev.bus, - address=dev.address - )) + manufacturer = product = serial = "" + + devices.append( + USBDeviceInfo( + vendor_id=vid, + product_id=pid, + manufacturer=manufacturer or COLORIMETER_USB_IDS.get((vid, pid), ""), + product=product or COLORIMETER_USB_IDS.get((vid, pid), ""), + serial_number=serial, + bus=dev.bus, + address=dev.address, + ) + ) except (OSError, ValueError): pass diff --git a/calibrate_pro/hardware/warmup_monitor.py b/calibrate_pro/hardware/warmup_monitor.py index a7dca44..cf89b70 100644 --- a/calibrate_pro/hardware/warmup_monitor.py +++ b/calibrate_pro/hardware/warmup_monitor.py @@ -30,6 +30,7 @@ @dataclass class WarmupStatus: """Current warm-up status.""" + elapsed_seconds: float = 0.0 elapsed_min: float = 0.0 current_luminance: float = 0.0 @@ -51,6 +52,7 @@ def luminance_change_pct(self) -> float: @dataclass class WarmupReading: """Single luminance reading during warm-up.""" + timestamp: float luminance: float @@ -124,7 +126,7 @@ def get_status(self) -> WarmupStatus: # Calculate drift rate over the last 3 readings drift = 999.0 if len(self._readings) >= 2: - recent = self._readings[-min(4, len(self._readings)):] + recent = self._readings[-min(4, len(self._readings)) :] dt_min = (recent[-1].timestamp - recent[0].timestamp) / 60.0 if dt_min > 0: dlum = abs(recent[-1].luminance - recent[0].luminance) @@ -170,7 +172,7 @@ def reset(self): # Recommended warm-up times by technology WARMUP_ESTIMATES = { - "QD-OLED": 30, # minutes + "QD-OLED": 30, # minutes "WOLED": 30, "OLED": 30, "IPS": 30, diff --git a/calibrate_pro/hdr/__init__.py b/calibrate_pro/hdr/__init__.py index 923522e..bc80def 100644 --- a/calibrate_pro/hdr/__init__.py +++ b/calibrate_pro/hdr/__init__.py @@ -271,7 +271,6 @@ "HDR10Metadata", "HDR10_PRESETS", "PQDisplayAssessment", - # ------------------------------------------------------------------------- # HLG # ------------------------------------------------------------------------- @@ -300,7 +299,6 @@ "hlg_to_sdr", # Settings "HLGDisplaySettings", - # ------------------------------------------------------------------------- # HDR10+ # ------------------------------------------------------------------------- @@ -329,7 +327,6 @@ # Calibration "generate_hdr10plus_test_scenes", "create_hdr10plus_calibration_luts", - # ------------------------------------------------------------------------- # Dolby Vision # ------------------------------------------------------------------------- @@ -356,7 +353,6 @@ "DVCalibrationResult", "calibrate_for_dolby_vision", "generate_dv_verification_patches", - # ------------------------------------------------------------------------- # EOTF Calibration # ------------------------------------------------------------------------- @@ -381,7 +377,6 @@ "CalibrationTarget", "CalibrationResult", "EOTFCalibrator", - # ------------------------------------------------------------------------- # Tone Mapping # ------------------------------------------------------------------------- @@ -405,7 +400,6 @@ "HDRToSDRConverter", "hdr_to_sdr", "compare_operators", - # ------------------------------------------------------------------------- # Professional Mastering Standards # ------------------------------------------------------------------------- @@ -424,7 +418,6 @@ "validate_mastering_compliance", "get_recommended_targets", "generate_compliance_report", - # ------------------------------------------------------------------------- # Unified HDR Calibration Suite # ------------------------------------------------------------------------- diff --git a/calibrate_pro/hdr/dolby_vision.py b/calibrate_pro/hdr/dolby_vision.py index d695f2f..2718cfb 100644 --- a/calibrate_pro/hdr/dolby_vision.py +++ b/calibrate_pro/hdr/dolby_vision.py @@ -22,18 +22,21 @@ # Dolby Vision Constants # ============================================================================= + # Dolby Vision profiles class DVProfile(IntEnum): """Dolby Vision profile types.""" - PROFILE_4 = 4 # HDR10 compatible (deprecated) - PROFILE_5 = 5 # IPT-PQ, single layer (streaming) - PROFILE_7 = 7 # BC (Base + Enhancement layer) - PROFILE_8 = 8 # HDR10 compatible, SDR backwards compatible + + PROFILE_4 = 4 # HDR10 compatible (deprecated) + PROFILE_5 = 5 # IPT-PQ, single layer (streaming) + PROFILE_7 = 7 # BC (Base + Enhancement layer) + PROFILE_8 = 8 # HDR10 compatible, SDR backwards compatible # Color spaces class DVColorSpace(IntEnum): """Dolby Vision color spaces.""" + YCbCr = 0 RGB = 1 IPT = 2 # IPT-PQ color space (Intensity, Protan, Tritan) @@ -42,27 +45,31 @@ class DVColorSpace(IntEnum): # Transfer functions class DVTransferFunction(IntEnum): """Transfer function types.""" - PQ = 0 # SMPTE ST.2084 PQ - HLG = 1 # HLG - SDR = 2 # BT.1886 + + PQ = 0 # SMPTE ST.2084 PQ + HLG = 1 # HLG + SDR = 2 # BT.1886 LINEAR = 3 # Signal ranges class DVSignalRange(IntEnum): """Signal range types.""" - NARROW = 0 # Limited range (16-235/240) - FULL = 1 # Full range (0-255) + + NARROW = 0 # Limited range (16-235/240) + FULL = 1 # Full range (0-255) # ============================================================================= # Dolby Vision Metadata Structures # ============================================================================= + @dataclass class DVPrimaries: """Dolby Vision color primaries (CIE 1931 xy).""" - red: tuple[float, float] = (0.708, 0.292) # BT.2020 + + red: tuple[float, float] = (0.708, 0.292) # BT.2020 green: tuple[float, float] = (0.170, 0.797) blue: tuple[float, float] = (0.131, 0.046) white: tuple[float, float] = (0.3127, 0.3290) # D65 @@ -71,9 +78,10 @@ class DVPrimaries: @dataclass class DVContentRange: """Content light level range.""" - min_pq: int = 0 # Minimum PQ code value (12-bit) - max_pq: int = 4095 # Maximum PQ code value - min_luminance: float = 0.0 # cd/m² + + min_pq: int = 0 # Minimum PQ code value (12-bit) + max_pq: int = 4095 # Maximum PQ code value + min_luminance: float = 0.0 # cd/m² max_luminance: float = 10000.0 # cd/m² @@ -84,9 +92,10 @@ class DVTrimPass: Trim passes adjust the master grade for specific target displays. """ - target_max_pq: int = 2081 # Target display peak (PQ code) - target_min_pq: int = 62 # Target display black - target_primary_index: int = 0 # 0=P3, 1=BT.2020 + + target_max_pq: int = 2081 # Target display peak (PQ code) + target_min_pq: int = 62 # Target display black + target_primary_index: int = 0 # 0=P3, 1=BT.2020 # Trim adjustments trim_slope: float = 1.0 @@ -98,11 +107,12 @@ class DVTrimPass: trim_saturation_gain: float = 1.0 # Mid-tone adjustments - ms_weight: float = 1.0 # Mid-tones slope weight + ms_weight: float = 1.0 # Mid-tones slope weight def to_pq_luminance(self, pq_code: int) -> float: """Convert 12-bit PQ code to luminance.""" from calibrate_pro.hdr.pq_st2084 import pq_eotf + signal = pq_code / 4095.0 return float(pq_eotf(np.array([signal]))[0]) @@ -124,6 +134,7 @@ class DVPolynomialCurve: Used for custom tone mapping in Dolby Vision. """ + order: int = 0 coefficients: list[float] = field(default_factory=list) mmr_coefficients: list[list[float]] = field(default_factory=list) # Multi-model regression @@ -146,6 +157,7 @@ class DVRPU: Contains all metadata needed for tone mapping a frame. """ + # Profile info profile: DVProfile = DVProfile.PROFILE_8 level: int = 6 # Profile level (affects max resolution/fps) @@ -193,6 +205,7 @@ class DVRPU: def get_source_range(self) -> tuple[float, float]: """Get source luminance range in cd/m².""" from calibrate_pro.hdr.pq_st2084 import pq_eotf + min_sig = self.source_min_pq / 4095.0 max_sig = self.source_max_pq / 4095.0 min_lum = float(pq_eotf(np.array([min_sig]))[0]) @@ -202,6 +215,7 @@ def get_source_range(self) -> tuple[float, float]: def get_target_range(self) -> tuple[float, float]: """Get target luminance range in cd/m².""" from calibrate_pro.hdr.pq_st2084 import pq_eotf + min_sig = self.target_min_pq / 4095.0 max_sig = self.target_max_pq / 4095.0 min_lum = float(pq_eotf(np.array([min_sig]))[0]) @@ -224,8 +238,8 @@ def to_dict(self) -> dict[str, Any]: "red": self.primaries.red, "green": self.primaries.green, "blue": self.primaries.blue, - "white": self.primaries.white - } + "white": self.primaries.white, + }, } @@ -233,6 +247,7 @@ def to_dict(self) -> dict[str, Any]: # Dolby Vision Tone Mapping # ============================================================================= + class DolbyVisionToneMapper: """ Dolby Vision tone mapping engine. @@ -245,7 +260,7 @@ def __init__( self, target_peak: float = 1000.0, target_black: float = 0.005, - target_primaries: str = "P3" # "P3" or "BT2020" + target_primaries: str = "P3", # "P3" or "BT2020" ): """ Initialize Dolby Vision tone mapper. @@ -261,14 +276,11 @@ def __init__( # Compute target PQ codes from calibrate_pro.hdr.pq_st2084 import pq_oetf + self.target_max_pq = int(pq_oetf(np.array([target_peak]))[0] * 4095) self.target_min_pq = int(pq_oetf(np.array([target_black]))[0] * 4095) - def generate_tone_curve( - self, - rpu: DVRPU, - size: int = 4096 - ) -> np.ndarray: + def generate_tone_curve(self, rpu: DVRPU, size: int = 4096) -> np.ndarray: """ Generate tone mapping curve from RPU metadata. @@ -292,12 +304,7 @@ def generate_tone_curve( target_min, target_max = self.target_peak, self.target_black # Apply DV tone mapping algorithm - lum_out = self._apply_dv_tonemap( - lum_in, - source_min, source_max, - target_min, target_max, - rpu - ) + lum_out = self._apply_dv_tonemap(lum_in, source_min, source_max, target_min, target_max, rpu) # Convert back to PQ pq_out = pq_oetf(lum_out) @@ -305,13 +312,7 @@ def generate_tone_curve( return np.clip(pq_out, 0.0, 1.0) def _apply_dv_tonemap( - self, - lum: np.ndarray, - src_min: float, - src_max: float, - tgt_min: float, - tgt_max: float, - rpu: DVRPU + self, lum: np.ndarray, src_min: float, src_max: float, tgt_min: float, tgt_max: float, rpu: DVRPU ) -> np.ndarray: """Apply Dolby Vision tone mapping algorithm.""" # Normalize to source range @@ -343,9 +344,7 @@ def _apply_dv_tonemap( # Modified Reinhard with DV characteristics max_stretch = (src_max - src_min * knee) / (tgt_max - tgt_min * knee) - compressed = knee + (1.0 - knee) * ( - (above - knee) / (above - knee + (1.0 - knee) / max_stretch) - ) + compressed = knee + (1.0 - knee) * ((above - knee) / (above - knee + (1.0 - knee) / max_stretch)) lum_out[~below_knee] = compressed # Apply trim if available @@ -358,11 +357,7 @@ def _apply_dv_tonemap( return np.clip(lum_out, tgt_min, tgt_max) - def apply_to_frame( - self, - frame: np.ndarray, - rpu: DVRPU - ) -> np.ndarray: + def apply_to_frame(self, frame: np.ndarray, rpu: DVRPU) -> np.ndarray: """ Apply Dolby Vision processing to a frame. @@ -391,11 +386,9 @@ def apply_to_frame( # Profile-Specific Handling # ============================================================================= + def create_profile5_rpu( - source_peak: float = 1000.0, - source_black: float = 0.0001, - frame_max: float = 500.0, - frame_avg: float = 100.0 + source_peak: float = 1000.0, source_black: float = 0.0001, frame_max: float = 500.0, frame_avg: float = 100.0 ) -> DVRPU: """ Create Profile 5 RPU metadata. @@ -419,14 +412,12 @@ def create_profile5_rpu( source_max_pq=source_max_pq, max_pq=frame_max_pq, avg_pq=frame_avg_pq, - vdr_dm_metadata_present=True + vdr_dm_metadata_present=True, ) def create_profile8_rpu( - source_peak: float = 1000.0, - source_black: float = 0.0001, - hdr10_compatible: bool = True + source_peak: float = 1000.0, source_black: float = 0.0001, hdr10_compatible: bool = True ) -> DVRPU: """ Create Profile 8 RPU metadata. @@ -446,16 +437,16 @@ def create_profile8_rpu( transfer_function=DVTransferFunction.PQ, source_min_pq=source_min_pq, source_max_pq=source_max_pq, - signal_range=DVSignalRange.NARROW if hdr10_compatible else DVSignalRange.FULL + signal_range=DVSignalRange.NARROW if hdr10_compatible else DVSignalRange.FULL, ) # Add default trim for 1000 nit target target_1000 = DVTrimPass( target_max_pq=2081, # ~1000 nits - target_min_pq=62, # ~0.005 nits + target_min_pq=62, # ~0.005 nits trim_slope=1.0, trim_offset=0.0, - trim_power=1.0 + trim_power=1.0, ) rpu.trim_passes.append(target_1000) @@ -466,6 +457,7 @@ def create_profile8_rpu( # RPU Parsing/Serialization (Simplified) # ============================================================================= + def parse_rpu_header(data: bytes) -> dict[str, Any] | None: """ Parse RPU header to extract basic info. @@ -486,20 +478,14 @@ def parse_rpu_header(data: bytes) -> dict[str, Any] | None: rpu_type = data[0] >> 4 rpu_format = data[0] & 0x0F - return { - "rpu_type": rpu_type, - "rpu_format": rpu_format, - "data_length": len(data) - } + return {"rpu_type": rpu_type, "rpu_format": rpu_format, "data_length": len(data)} except Exception: return None def create_calibration_rpu( - display_peak: float, - display_black: float = 0.005, - profile: DVProfile = DVProfile.PROFILE_8 + display_peak: float, display_black: float = 0.005, profile: DVProfile = DVProfile.PROFILE_8 ) -> DVRPU: """ Create RPU metadata for display calibration. @@ -519,13 +505,11 @@ def create_calibration_rpu( # Add trim pass for this specific display from calibrate_pro.hdr.pq_st2084 import pq_oetf + target_max_pq = int(pq_oetf(np.array([display_peak]))[0] * 4095) target_min_pq = int(pq_oetf(np.array([display_black]))[0] * 4095) - trim = DVTrimPass( - target_max_pq=target_max_pq, - target_min_pq=target_min_pq - ) + trim = DVTrimPass(target_max_pq=target_max_pq, target_min_pq=target_min_pq) rpu.trim_passes = [trim] return rpu @@ -535,9 +519,11 @@ def create_calibration_rpu( # Dolby Vision Calibration # ============================================================================= + @dataclass class DVCalibrationResult: """Dolby Vision display calibration result.""" + profile: DVProfile measured_peak: float measured_black: float @@ -548,17 +534,11 @@ class DVCalibrationResult: def to_rpu(self) -> DVRPU: """Generate RPU from calibration.""" - return create_calibration_rpu( - self.measured_peak, - self.measured_black, - self.profile - ) + return create_calibration_rpu(self.measured_peak, self.measured_black, self.profile) def calibrate_for_dolby_vision( - measured_levels: np.ndarray, - measured_luminance: np.ndarray, - profile: DVProfile = DVProfile.PROFILE_8 + measured_levels: np.ndarray, measured_luminance: np.ndarray, profile: DVProfile = DVProfile.PROFILE_8 ) -> DVCalibrationResult: """ Calibrate display for Dolby Vision. @@ -604,7 +584,7 @@ def calibrate_for_dolby_vision( target_max_pq=target_max_pq, target_min_pq=target_min_pq, eotf_accuracy=avg_error, - grade=grade + grade=grade, ) diff --git a/calibrate_pro/hdr/eotf_calibration.py b/calibrate_pro/hdr/eotf_calibration.py index 77476e5..aa33066 100644 --- a/calibrate_pro/hdr/eotf_calibration.py +++ b/calibrate_pro/hdr/eotf_calibration.py @@ -20,28 +20,31 @@ # EOTF Types and Constants # ============================================================================= + class EOTFType(Enum): """Supported EOTF types.""" - PQ = "pq" # SMPTE ST.2084 - HLG = "hlg" # ARIB STD-B67 - GAMMA = "gamma" # Power law - SRGB = "srgb" # sRGB/BT.1886 + + PQ = "pq" # SMPTE ST.2084 + HLG = "hlg" # ARIB STD-B67 + GAMMA = "gamma" # Power law + SRGB = "srgb" # sRGB/BT.1886 LINEAR = "linear" class CalibrationStandard(Enum): """Calibration standards.""" - ITU_R_BT2100 = "bt2100" # ITU-R BT.2100 HDR - DOLBY_VISION = "dolby" # Dolby Vision - HDR10 = "hdr10" # HDR10 / HDR10+ + + ITU_R_BT2100 = "bt2100" # ITU-R BT.2100 HDR + DOLBY_VISION = "dolby" # Dolby Vision + HDR10 = "hdr10" # HDR10 / HDR10+ BROADCAST_HLG = "hlg_broadcast" # Reference levels for different standards REFERENCE_LEVELS = { - "pq_sdr_white": 203, # SDR reference white in nits (for PQ) - "hlg_sdr_white": 100, # SDR reference for HLG - "pq_reference": 100, # PQ reference white + "pq_sdr_white": 203, # SDR reference white in nits (for PQ) + "hlg_sdr_white": 100, # SDR reference for HLG + "pq_reference": 100, # PQ reference white } @@ -49,12 +52,14 @@ class CalibrationStandard(Enum): # Calibration Patch Generation # ============================================================================= + @dataclass class EOTFPatch: """Single EOTF calibration patch.""" - signal_level: float # Input signal [0, 1] - target_luminance: float # Expected output (cd/m²) - label: str # Description + + signal_level: float # Input signal [0, 1] + target_luminance: float # Expected output (cd/m²) + label: str # Description is_near_black: bool = False is_critical: bool = False @@ -63,7 +68,7 @@ def generate_pq_patches( num_patches: int = 21, include_near_black: bool = True, include_extended: bool = True, - max_luminance: float = 10000.0 + max_luminance: float = 10000.0, ) -> list[EOTFPatch]: """ Generate PQ EOTF verification patches. @@ -86,25 +91,29 @@ def generate_pq_patches( near_black_nits = [0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0] for nits in near_black_nits: signal = float(pq_oetf(np.array([nits]))[0]) - patches.append(EOTFPatch( - signal_level=signal, - target_luminance=nits, - label=f"{nits:.3f} nits", - is_near_black=True, - is_critical=nits <= 0.05 - )) + patches.append( + EOTFPatch( + signal_level=signal, + target_luminance=nits, + label=f"{nits:.3f} nits", + is_near_black=True, + is_critical=nits <= 0.05, + ) + ) # Standard grayscale ramp standard_nits = [2, 5, 10, 20, 50, 100, 200, 400, 600, 800, 1000] for nits in standard_nits: if nits <= max_luminance: signal = float(pq_oetf(np.array([nits]))[0]) - patches.append(EOTFPatch( - signal_level=signal, - target_luminance=nits, - label=f"{nits} nits", - is_critical=(nits == 100 or nits == 1000) # Reference points - )) + patches.append( + EOTFPatch( + signal_level=signal, + target_luminance=nits, + label=f"{nits} nits", + is_critical=(nits == 100 or nits == 1000), # Reference points + ) + ) # Extended range if include_extended: @@ -112,11 +121,7 @@ def generate_pq_patches( for nits in extended_nits: if nits <= max_luminance: signal = float(pq_oetf(np.array([nits]))[0]) - patches.append(EOTFPatch( - signal_level=signal, - target_luminance=nits, - label=f"{nits} nits (extended)" - )) + patches.append(EOTFPatch(signal_level=signal, target_luminance=nits, label=f"{nits} nits (extended)")) # Sort by signal level patches.sort(key=lambda p: p.signal_level) @@ -125,9 +130,7 @@ def generate_pq_patches( def generate_hlg_patches( - num_patches: int = 21, - system_gamma: float = 1.2, - peak_luminance: float = 1000.0 + num_patches: int = 21, system_gamma: float = 1.2, peak_luminance: float = 1000.0 ) -> list[EOTFPatch]: """ Generate HLG EOTF verification patches. @@ -150,12 +153,14 @@ def generate_hlg_patches( display_normalized = hlg_eotf(np.array([sig]), system_gamma)[0] luminance = display_normalized * peak_luminance - patches.append(EOTFPatch( - signal_level=float(sig), - target_luminance=float(luminance), - label=f"HLG {sig*100:.0f}%", - is_near_black=(sig < 0.1) - )) + patches.append( + EOTFPatch( + signal_level=float(sig), + target_luminance=float(luminance), + label=f"HLG {sig * 100:.0f}%", + is_near_black=(sig < 0.1), + ) + ) return patches @@ -164,9 +169,11 @@ def generate_hlg_patches( # Measurement and Analysis # ============================================================================= + @dataclass class EOTFMeasurement: """Single EOTF measurement result.""" + signal_level: float target_luminance: float measured_luminance: float @@ -178,6 +185,7 @@ class EOTFMeasurement: @dataclass class EOTFAnalysis: """Complete EOTF analysis results.""" + eotf_type: EOTFType measurements: list[EOTFMeasurement] @@ -220,17 +228,15 @@ def to_dict(self) -> dict[str, Any]: "signal": m.signal_level, "target_nits": m.target_luminance, "measured_nits": m.measured_luminance, - "error_percent": m.error_percent + "error_percent": m.error_percent, } for m in self.measurements - ] + ], } def analyze_pq_eotf( - signal_levels: np.ndarray, - measured_luminance: np.ndarray, - reference_white: float = 100.0 + signal_levels: np.ndarray, measured_luminance: np.ndarray, reference_white: float = 100.0 ) -> EOTFAnalysis: """ Analyze PQ EOTF tracking. @@ -261,13 +267,15 @@ def analyze_pq_eotf( error_nits = abs(measured - target) - measurements.append(EOTFMeasurement( - signal_level=float(signal_levels[i]), - target_luminance=float(target), - measured_luminance=float(measured), - error_percent=float(error_pct), - error_nits=float(error_nits) - )) + measurements.append( + EOTFMeasurement( + signal_level=float(signal_levels[i]), + target_luminance=float(target), + measured_luminance=float(measured), + error_percent=float(error_pct), + error_nits=float(error_nits), + ) + ) # Calculate overall metrics errors = np.array([m.error_percent for m in measurements]) @@ -333,15 +341,12 @@ def analyze_pq_eotf( dynamic_range_stops=float(dr_stops), gamma_tracking=gamma_tracking, grade=grade, - pass_fail=pass_fail + pass_fail=pass_fail, ) def analyze_hlg_eotf( - signal_levels: np.ndarray, - measured_luminance: np.ndarray, - system_gamma: float = 1.2, - peak_luminance: float = 1000.0 + signal_levels: np.ndarray, measured_luminance: np.ndarray, system_gamma: float = 1.2, peak_luminance: float = 1000.0 ) -> EOTFAnalysis: """ Analyze HLG EOTF tracking. @@ -372,13 +377,15 @@ def analyze_hlg_eotf( else: error_pct = 0.0 - measurements.append(EOTFMeasurement( - signal_level=float(signal_levels[i]), - target_luminance=float(target), - measured_luminance=float(measured), - error_percent=float(error_pct), - error_nits=float(abs(measured - target)) - )) + measurements.append( + EOTFMeasurement( + signal_level=float(signal_levels[i]), + target_luminance=float(target), + measured_luminance=float(measured), + error_percent=float(error_pct), + error_nits=float(abs(measured - target)), + ) + ) # Metrics errors = np.array([m.error_percent for m in measurements]) @@ -416,7 +423,7 @@ def analyze_hlg_eotf( dynamic_range_stops=float(dr_stops), gamma_tracking=100.0 - avg_err, grade=grade, - pass_fail=(avg_err < 12.0) + pass_fail=(avg_err < 12.0), ) @@ -424,11 +431,8 @@ def analyze_hlg_eotf( # Calibration LUT Generation # ============================================================================= -def generate_eotf_correction_lut( - analysis: EOTFAnalysis, - size: int = 1024, - smooth: bool = True -) -> np.ndarray: + +def generate_eotf_correction_lut(analysis: EOTFAnalysis, size: int = 1024, smooth: bool = True) -> np.ndarray: """ Generate EOTF correction LUT from analysis. @@ -490,14 +494,14 @@ def generate_eotf_correction_lut( # Smooth if requested if smooth: from scipy.ndimage import gaussian_filter1d - corrected = gaussian_filter1d(corrected, sigma=size/100) + + corrected = gaussian_filter1d(corrected, sigma=size / 100) return np.clip(corrected, 0, 1) def generate_grayscale_correction_matrix( - rgb_measurements: np.ndarray, - target_white: tuple[float, float] = (0.3127, 0.3290) + rgb_measurements: np.ndarray, target_white: tuple[float, float] = (0.3127, 0.3290) ) -> np.ndarray: """ Generate RGB correction matrix for grayscale balance. @@ -529,9 +533,11 @@ def generate_grayscale_correction_matrix( # Calibration Workflow # ============================================================================= + @dataclass class CalibrationTarget: """HDR calibration target settings.""" + eotf_type: EOTFType = EOTFType.PQ peak_luminance: float = 1000.0 black_level: float = 0.005 @@ -543,6 +549,7 @@ class CalibrationTarget: @dataclass class CalibrationResult: """Complete HDR calibration result.""" + target: CalibrationTarget eotf_analysis: EOTFAnalysis correction_lut: np.ndarray @@ -561,12 +568,12 @@ def to_dict(self) -> dict[str, Any]: "peak_nits": self.target.peak_luminance, "black_nits": self.target.black_level, "white_point": self.target.white_point, - "color_space": self.target.color_space + "color_space": self.target.color_space, }, "analysis": self.eotf_analysis.to_dict(), "pre_error_percent": self.pre_calibration_error, "post_error_percent": self.post_calibration_error, - "improvement_percent": self.improvement + "improvement_percent": self.improvement, } @@ -575,11 +582,7 @@ class EOTFCalibrator: HDR EOTF calibration workflow manager. """ - def __init__( - self, - target: CalibrationTarget, - measure_func: Callable[[float], float] | None = None - ): + def __init__(self, target: CalibrationTarget, measure_func: Callable[[float], float] | None = None): """ Initialize calibrator. @@ -595,13 +598,10 @@ def __init__( def generate_patches(self) -> list[EOTFPatch]: """Generate calibration patches for target EOTF.""" if self.target.eotf_type == EOTFType.PQ: - self._patches = generate_pq_patches( - include_extended=(self.target.peak_luminance > 1000) - ) + self._patches = generate_pq_patches(include_extended=(self.target.peak_luminance > 1000)) elif self.target.eotf_type == EOTFType.HLG: self._patches = generate_hlg_patches( - system_gamma=self.target.system_gamma, - peak_luminance=self.target.peak_luminance + system_gamma=self.target.system_gamma, peak_luminance=self.target.peak_luminance ) else: # Gamma patches @@ -609,8 +609,8 @@ def generate_patches(self) -> list[EOTFPatch]: self._patches = [ EOTFPatch( signal_level=float(s), - target_luminance=float(s ** 2.2 * self.target.peak_luminance), - label=f"{s*100:.0f}%" + target_luminance=float(s**2.2 * self.target.peak_luminance), + label=f"{s * 100:.0f}%", ) for s in signals ] @@ -632,19 +632,12 @@ def analyze(self) -> EOTFAnalysis: if self.target.eotf_type == EOTFType.PQ: return analyze_pq_eotf(signals, luminance) elif self.target.eotf_type == EOTFType.HLG: - return analyze_hlg_eotf( - signals, luminance, - self.target.system_gamma, - self.target.peak_luminance - ) + return analyze_hlg_eotf(signals, luminance, self.target.system_gamma, self.target.peak_luminance) else: # Generic analysis return analyze_pq_eotf(signals, luminance) - def generate_correction( - self, - analysis: EOTFAnalysis | None = None - ) -> CalibrationResult: + def generate_correction(self, analysis: EOTFAnalysis | None = None) -> CalibrationResult: """Generate calibration correction.""" if analysis is None: analysis = self.analyze() @@ -653,10 +646,7 @@ def generate_correction( lut = generate_eotf_correction_lut(analysis) return CalibrationResult( - target=self.target, - eotf_analysis=analysis, - correction_lut=lut, - pre_calibration_error=analysis.average_error + target=self.target, eotf_analysis=analysis, correction_lut=lut, pre_calibration_error=analysis.average_error ) def clear_measurements(self): diff --git a/calibrate_pro/hdr/hdr10plus.py b/calibrate_pro/hdr/hdr10plus.py index 0129ba7..e632e8b 100644 --- a/calibrate_pro/hdr/hdr10plus.py +++ b/calibrate_pro/hdr/hdr10plus.py @@ -39,6 +39,7 @@ class ProcessingWindowFlag(IntEnum): """Processing window types.""" + FULL_FRAME = 0 ELLIPTICAL = 1 RECTANGULAR = 2 @@ -48,6 +49,7 @@ class ProcessingWindowFlag(IntEnum): # HDR10+ Data Structures # ============================================================================= + @dataclass class BezierCurve: """ @@ -56,8 +58,9 @@ class BezierCurve: HDR10+ uses cubic Bezier curves to define the tone mapping function from mastering display to target display. """ - knee_point_x: float = 0.0 # Normalized [0, 1] - knee_point_y: float = 0.0 # Normalized [0, 1] + + knee_point_x: float = 0.0 # Normalized [0, 1] + knee_point_y: float = 0.0 # Normalized [0, 1] anchors: list[float] = field(default_factory=list) # Up to 15 anchor points def evaluate(self, t: np.ndarray) -> np.ndarray: @@ -84,12 +87,16 @@ def evaluate(self, t: np.ndarray) -> np.ndarray: # Simple linear interpolation through anchors for now # Full Bezier implementation would use de Casteljau's algorithm x_points = np.linspace(0, 1, n) - y_points = np.concatenate([ - [0.0], - [self.knee_point_y], - self.anchors[:n-2] if len(self.anchors) >= n-2 else self.anchors + [1.0] * (n-2-len(self.anchors)), - [1.0] - ])[:n] + y_points = np.concatenate( + [ + [0.0], + [self.knee_point_y], + self.anchors[: n - 2] + if len(self.anchors) >= n - 2 + else self.anchors + [1.0] * (n - 2 - len(self.anchors)), + [1.0], + ] + )[:n] # Interpolate result = np.interp(t, x_points, y_points) @@ -110,6 +117,7 @@ class DistributionData: Describes how brightness values are distributed in the scene using percentile values. """ + percentiles: list[int] = field(default_factory=lambda: DEFAULT_PERCENTILES.copy()) values: list[float] = field(default_factory=list) # Luminance at each percentile @@ -139,6 +147,7 @@ class ProcessingWindow: Defines a region of the frame with specific tone mapping parameters. """ + window_id: int = 0 window_type: ProcessingWindowFlag = ProcessingWindowFlag.FULL_FRAME @@ -168,6 +177,7 @@ class HDR10PlusMetadata: """ Complete HDR10+ dynamic metadata for a single frame/scene. """ + # Application info application_identifier: int = HDR10PLUS_APPLICATION_IDENTIFIER application_version: int = HDR10PLUS_APPLICATION_VERSION @@ -220,7 +230,7 @@ def to_dict(self) -> dict[str, Any]: "fraction_bright": w.fraction_bright_pixels, } for w in self.windows - ] + ], } @classmethod @@ -252,6 +262,7 @@ def from_dict(cls, data: dict[str, Any]) -> "HDR10PlusMetadata": # HDR10+ Tone Mapping # ============================================================================= + class HDR10PlusToneMapper: """ HDR10+ dynamic tone mapper. @@ -259,11 +270,7 @@ class HDR10PlusToneMapper: Applies scene-adaptive tone mapping based on HDR10+ metadata. """ - def __init__( - self, - target_max_luminance: float = 1000.0, - target_min_luminance: float = 0.005 - ): + def __init__(self, target_max_luminance: float = 1000.0, target_min_luminance: float = 0.005): """ Initialize tone mapper. @@ -277,11 +284,7 @@ def __init__( # Cache for generated LUTs self._lut_cache: dict[int, np.ndarray] = {} - def generate_tone_curve( - self, - metadata: HDR10PlusMetadata, - size: int = 1024 - ) -> np.ndarray: + def generate_tone_curve(self, metadata: HDR10PlusMetadata, size: int = 1024) -> np.ndarray: """ Generate tone mapping curve from HDR10+ metadata. @@ -307,11 +310,7 @@ def generate_tone_curve( # Generate adaptive curve return self._generate_adaptive_curve(effective_source, size) - def _generate_adaptive_curve( - self, - source_peak: float, - size: int - ) -> np.ndarray: + def _generate_adaptive_curve(self, source_peak: float, size: int) -> np.ndarray: """Generate adaptive tone curve based on source/target ratio.""" t = np.linspace(0, 1, size) @@ -347,11 +346,7 @@ def _generate_adaptive_curve( return np.clip(output, 0.0, 1.0) - def apply( - self, - rgb_linear: np.ndarray, - metadata: HDR10PlusMetadata - ) -> np.ndarray: + def apply(self, rgb_linear: np.ndarray, metadata: HDR10PlusMetadata) -> np.ndarray: """ Apply HDR10+ tone mapping to linear RGB. @@ -391,10 +386,9 @@ def clear_cache(self): # HDR10+ Scene Analysis # ============================================================================= + def analyze_frame( - rgb_linear: np.ndarray, - mastering_peak: float = 1000.0, - num_percentiles: int = NUM_PERCENTILES + rgb_linear: np.ndarray, mastering_peak: float = 1000.0, num_percentiles: int = NUM_PERCENTILES ) -> HDR10PlusMetadata: """ Analyze a frame to generate HDR10+ metadata. @@ -434,26 +428,19 @@ def analyze_frame( window_type=ProcessingWindowFlag.FULL_FRAME, max_scl=(max_scl_r, max_scl_g, max_scl_b), average_maxrgb=avg_maxrgb, - distribution=DistributionData( - percentiles=percentiles, - values=percentile_values - ), - fraction_bright_pixels=fraction_bright + distribution=DistributionData(percentiles=percentiles, values=percentile_values), + fraction_bright_pixels=fraction_bright, ) return HDR10PlusMetadata( mastering_display_actual_peak_luminance=mastering_peak, targeted_system_display_maximum_luminance=1000.0, windows=[window], - num_windows=1 + num_windows=1, ) -def detect_scene_change( - current: HDR10PlusMetadata, - previous: HDR10PlusMetadata, - threshold: float = 0.3 -) -> bool: +def detect_scene_change(current: HDR10PlusMetadata, previous: HDR10PlusMetadata, threshold: float = 0.3) -> bool: """ Detect if a scene change occurred between frames. @@ -484,6 +471,7 @@ def detect_scene_change( # HDR10+ Metadata Parsing # ============================================================================= + def parse_sei_payload(data: bytes) -> HDR10PlusMetadata | None: """ Parse HDR10+ SEI (Supplemental Enhancement Information) payload. @@ -520,8 +508,7 @@ def parse_sei_payload(data: bytes) -> HDR10PlusMetadata | None: # Parse remaining metadata # This is a simplified parser - full implementation would need bit-level parsing metadata = HDR10PlusMetadata( - application_identifier=application_identifier, - application_version=application_version + application_identifier=application_identifier, application_version=application_version ) return metadata @@ -541,13 +528,17 @@ def serialize_metadata(metadata: HDR10PlusMetadata) -> bytes: SEI payload bytes """ # ITU-T T.35 header - payload = bytearray([ - 0xB5, # Country code (USA) - 0x00, 0x3C, # Terminal provider code (Samsung) - 0x00, 0x01, # Terminal provider oriented code - metadata.application_identifier, - metadata.application_version - ]) + payload = bytearray( + [ + 0xB5, # Country code (USA) + 0x00, + 0x3C, # Terminal provider code (Samsung) + 0x00, + 0x01, # Terminal provider oriented code + metadata.application_identifier, + metadata.application_version, + ] + ) # Add simplified metadata payload # Full implementation would need proper bit-level serialization @@ -559,6 +550,7 @@ def serialize_metadata(metadata: HDR10PlusMetadata) -> bytes: # HDR10+ Calibration # ============================================================================= + def generate_hdr10plus_test_scenes() -> list[HDR10PlusMetadata]: """ Generate test scenes for HDR10+ calibration. @@ -569,37 +561,25 @@ def generate_hdr10plus_test_scenes() -> list[HDR10PlusMetadata]: scenes = [] # Dark scene - dark = HDR10PlusMetadata( - mastering_display_actual_peak_luminance=1000.0, - scene_id=1 - ) + dark = HDR10PlusMetadata(mastering_display_actual_peak_luminance=1000.0, scene_id=1) dark.windows[0].max_scl = (50.0, 50.0, 50.0) dark.windows[0].average_maxrgb = 10.0 scenes.append(dark) # Medium scene - medium = HDR10PlusMetadata( - mastering_display_actual_peak_luminance=1000.0, - scene_id=2 - ) + medium = HDR10PlusMetadata(mastering_display_actual_peak_luminance=1000.0, scene_id=2) medium.windows[0].max_scl = (400.0, 400.0, 400.0) medium.windows[0].average_maxrgb = 100.0 scenes.append(medium) # Bright scene - bright = HDR10PlusMetadata( - mastering_display_actual_peak_luminance=1000.0, - scene_id=3 - ) + bright = HDR10PlusMetadata(mastering_display_actual_peak_luminance=1000.0, scene_id=3) bright.windows[0].max_scl = (1000.0, 1000.0, 1000.0) bright.windows[0].average_maxrgb = 400.0 scenes.append(bright) # Specular highlights - specular = HDR10PlusMetadata( - mastering_display_actual_peak_luminance=4000.0, - scene_id=4 - ) + specular = HDR10PlusMetadata(mastering_display_actual_peak_luminance=4000.0, scene_id=4) specular.windows[0].max_scl = (4000.0, 3000.0, 2000.0) specular.windows[0].average_maxrgb = 200.0 specular.windows[0].fraction_bright_pixels = 0.05 @@ -609,9 +589,7 @@ def generate_hdr10plus_test_scenes() -> list[HDR10PlusMetadata]: def create_hdr10plus_calibration_luts( - target_peak: float, - target_black: float = 0.005, - size: int = 33 + target_peak: float, target_black: float = 0.005, size: int = 33 ) -> dict[str, np.ndarray]: """ Create calibration LUTs for different HDR10+ scene types. diff --git a/calibrate_pro/hdr/hdr_calibration.py b/calibrate_pro/hdr/hdr_calibration.py index 229554b..ca8c19f 100644 --- a/calibrate_pro/hdr/hdr_calibration.py +++ b/calibrate_pro/hdr/hdr_calibration.py @@ -38,6 +38,7 @@ class HDRFormat(Enum): """Supported HDR formats.""" + HDR10 = "hdr10" HDR10_PLUS = "hdr10plus" HLG = "hlg" @@ -46,8 +47,9 @@ class HDRFormat(Enum): class CalibrationMode(Enum): """Calibration workflow modes.""" - QUICK = "quick" # Basic grayscale only (~20 patches) - STANDARD = "standard" # Grayscale + primaries (~100 patches) + + QUICK = "quick" # Basic grayscale only (~20 patches) + STANDARD = "standard" # Grayscale + primaries (~100 patches) PROFESSIONAL = "professional" # Full profiling (~1000 patches) VERIFICATION = "verification" # Post-calibration check @@ -55,12 +57,13 @@ class CalibrationMode(Enum): @dataclass class HDRCalibrationConfig: """Configuration for HDR calibration.""" + format: HDRFormat = HDRFormat.HDR10 mode: CalibrationMode = CalibrationMode.STANDARD # Target specifications target_peak_luminance: float = 1000.0 # cd/m² - target_min_luminance: float = 0.0001 # cd/m² + target_min_luminance: float = 0.0001 # cd/m² target_primaries: str = "p3_d65" # p3_d65, bt2020, srgb # HLG specific @@ -78,6 +81,7 @@ class HDRCalibrationConfig: @dataclass class HDRMeasurement: """Single HDR measurement point.""" + signal_level: float # Input signal [0, 1] target_luminance: float # Expected luminance (cd/m²) measured_luminance: float # Actual luminance (cd/m²) @@ -90,6 +94,7 @@ class HDRMeasurement: @dataclass class HDRCalibrationResult: """Complete HDR calibration result.""" + config: HDRCalibrationConfig measurements: list[HDRMeasurement] @@ -135,15 +140,15 @@ def to_dict(self) -> dict: "signal": m.signal_level, "target": m.target_luminance, "measured": m.measured_luminance, - "error_pct": m.eotf_error_percent + "error_pct": m.eotf_error_percent, } for m in self.measurements - ] + ], } def save_report(self, path: Path): """Save calibration report to JSON.""" - with open(path, 'w') as f: + with open(path, "w") as f: json.dump(self.to_dict(), f, indent=2) @@ -151,6 +156,7 @@ def save_report(self, path: Path): # HDR10 Calibration # ============================================================================= + class HDR10Calibration: """ HDR10 (PQ EOTF) Calibration. @@ -194,18 +200,12 @@ def get_target_luminance(self, signal: np.ndarray) -> np.ndarray: # Soft rolloff for content above display peak knee = peak * 0.9 target = np.where( - target <= knee, - target, - knee + (peak - knee) * np.tanh((target - knee) / (10000 - knee) * 3) + target <= knee, target, knee + (peak - knee) * np.tanh((target - knee) / (10000 - knee) * 3) ) return target - def analyze_measurements( - self, - signals: np.ndarray, - luminances: np.ndarray - ) -> HDRCalibrationResult: + def analyze_measurements(self, signals: np.ndarray, luminances: np.ndarray) -> HDRCalibrationResult: """ Analyze PQ EOTF measurements. @@ -222,12 +222,14 @@ def analyze_measurements( for i in range(len(signals)): error_pct = abs(luminances[i] - targets[i]) / max(targets[i], 0.001) * 100 - measurements.append(HDRMeasurement( - signal_level=float(signals[i]), - target_luminance=float(targets[i]), - measured_luminance=float(luminances[i]), - eotf_error_percent=float(error_pct) - )) + measurements.append( + HDRMeasurement( + signal_level=float(signals[i]), + target_luminance=float(targets[i]), + measured_luminance=float(luminances[i]), + eotf_error_percent=float(error_pct), + ) + ) # Calculate statistics errors = np.array([m.eotf_error_percent for m in measurements]) @@ -245,29 +247,19 @@ def analyze_measurements( contrast_ratio=peak / max(black, 0.0001), avg_eotf_error=float(np.mean(errors)), max_eotf_error=float(np.max(errors)), - near_black_error=float(np.mean(near_black_errors)) + near_black_error=float(np.mean(near_black_errors)), ) # Check compliance if self.config.mastering_standard: - meas_dict = { - 'peak_luminance': peak, - 'min_luminance': black, - 'eotf_error': result.avg_eotf_error - } - level, issues, _ = validate_mastering_compliance( - meas_dict, self.config.mastering_standard - ) + meas_dict = {"peak_luminance": peak, "min_luminance": black, "eotf_error": result.avg_eotf_error} + level, issues, _ = validate_mastering_compliance(meas_dict, self.config.mastering_standard) result.compliance_level = level result.compliance_issues = issues return result - def generate_correction_lut( - self, - measurements: list[HDRMeasurement], - lut_size: int = 65 - ) -> np.ndarray: + def generate_correction_lut(self, measurements: list[HDRMeasurement], lut_size: int = 65) -> np.ndarray: """ Generate 1D correction LUT from measurements. @@ -303,8 +295,8 @@ def generate_correction_lut( corrections[i] = signals[-1] else: # Linear interpolation - t = (target - measured[idx-1]) / (measured[idx] - measured[idx-1]) - corrections[i] = signals[idx-1] + t * (signals[idx] - signals[idx-1]) + t = (target - measured[idx - 1]) / (measured[idx] - measured[idx - 1]) + corrections[i] = signals[idx - 1] + t * (signals[idx] - signals[idx - 1]) else: corrections[i] = 1.0 @@ -315,6 +307,7 @@ def generate_correction_lut( # HDR10+ Calibration # ============================================================================= + class HDR10PlusCalibration(HDR10Calibration): """ HDR10+ Dynamic Metadata Calibration. @@ -336,44 +329,30 @@ def generate_scene_test_patches(self, num_scenes: int = 5) -> list[dict]: scenes = [] # Scene 1: Dark scene (low MaxCLL) - scenes.append({ - 'name': 'dark_scene', - 'max_cll': 200, - 'max_fall': 50, - 'patches': np.array([0, 0.1, 0.2, 0.3, 0.4, 0.5]) - }) + scenes.append( + {"name": "dark_scene", "max_cll": 200, "max_fall": 50, "patches": np.array([0, 0.1, 0.2, 0.3, 0.4, 0.5])} + ) # Scene 2: Normal scene - scenes.append({ - 'name': 'normal_scene', - 'max_cll': 500, - 'max_fall': 150, - 'patches': np.linspace(0, 0.7, 15) - }) + scenes.append({"name": "normal_scene", "max_cll": 500, "max_fall": 150, "patches": np.linspace(0, 0.7, 15)}) # Scene 3: Bright scene - scenes.append({ - 'name': 'bright_scene', - 'max_cll': 800, - 'max_fall': 300, - 'patches': np.linspace(0, 0.85, 15) - }) + scenes.append({"name": "bright_scene", "max_cll": 800, "max_fall": 300, "patches": np.linspace(0, 0.85, 15)}) # Scene 4: High contrast - scenes.append({ - 'name': 'high_contrast', - 'max_cll': 1000, - 'max_fall': 200, - 'patches': np.array([0, 0.01, 0.05, 0.1, 0.5, 0.9, 0.95, 1.0]) - }) + scenes.append( + { + "name": "high_contrast", + "max_cll": 1000, + "max_fall": 200, + "patches": np.array([0, 0.01, 0.05, 0.1, 0.5, 0.9, 0.95, 1.0]), + } + ) # Scene 5: Peak highlights - scenes.append({ - 'name': 'peak_highlights', - 'max_cll': 1000, - 'max_fall': 400, - 'patches': np.linspace(0.5, 1.0, 20) - }) + scenes.append( + {"name": "peak_highlights", "max_cll": 1000, "max_fall": 400, "patches": np.linspace(0.5, 1.0, 20)} + ) return scenes[:num_scenes] @@ -382,6 +361,7 @@ def generate_scene_test_patches(self, num_scenes: int = 5) -> list[dict]: # HLG Calibration # ============================================================================= + class HLGCalibration: """ HLG (Hybrid Log-Gamma) Calibration. @@ -409,28 +389,24 @@ def get_target_luminance(self, signal: np.ndarray) -> np.ndarray: display = hlg_eotf(signal, self.config.hlg_system_gamma) return display * self.config.target_peak_luminance - def analyze_measurements( - self, - signals: np.ndarray, - luminances: np.ndarray - ) -> HDRCalibrationResult: + def analyze_measurements(self, signals: np.ndarray, luminances: np.ndarray) -> HDRCalibrationResult: """Analyze HLG EOTF measurements.""" targets = self.get_target_luminance(signals) errors, avg_error = calculate_hlg_eotf_error( - luminances, signals, - self.config.target_peak_luminance, - self.config.hlg_system_gamma + luminances, signals, self.config.target_peak_luminance, self.config.hlg_system_gamma ) measurements = [] for i in range(len(signals)): - measurements.append(HDRMeasurement( - signal_level=float(signals[i]), - target_luminance=float(targets[i]), - measured_luminance=float(luminances[i]), - eotf_error_percent=float(errors[i]) - )) + measurements.append( + HDRMeasurement( + signal_level=float(signals[i]), + target_luminance=float(targets[i]), + measured_luminance=float(luminances[i]), + eotf_error_percent=float(errors[i]), + ) + ) peak = float(np.max(luminances)) black = float(np.min(luminances[luminances > 0])) if np.any(luminances > 0) else 0.01 @@ -443,7 +419,7 @@ def analyze_measurements( contrast_ratio=peak / black, avg_eotf_error=avg_error, max_eotf_error=float(np.max(errors)), - near_black_error=float(np.mean(errors[signals < 0.1])) + near_black_error=float(np.mean(errors[signals < 0.1])), ) @@ -451,6 +427,7 @@ def analyze_measurements( # Unified HDR Calibration Suite # ============================================================================= + class HDRCalibrationSuite: """ Complete HDR Calibration Suite. @@ -482,30 +459,25 @@ def get_test_patches(self) -> dict[str, np.ndarray]: - near_black: Extra near-black patches - primaries: RGB primary patches (if applicable) """ - result = { - 'grayscale': self.calibrator.generate_patches() - } + result = {"grayscale": self.calibrator.generate_patches()} if self.config.mode in [CalibrationMode.STANDARD, CalibrationMode.PROFESSIONAL]: # Add near-black emphasis - result['near_black'] = np.linspace(0, 0.05, 11) + result["near_black"] = np.linspace(0, 0.05, 11) if self.config.mode == CalibrationMode.PROFESSIONAL: # Add primary patches at various luminance levels levels = [0.5, 0.75, 1.0] - result['primaries'] = { - 'red': [(l, 0, 0) for l in levels], - 'green': [(0, l, 0) for l in levels], - 'blue': [(0, 0, l) for l in levels] + result["primaries"] = { + "red": [(l, 0, 0) for l in levels], + "green": [(0, l, 0) for l in levels], + "blue": [(0, 0, l) for l in levels], } return result def analyze( - self, - grayscale_signals: np.ndarray, - grayscale_luminance: np.ndarray, - primary_measurements: dict | None = None + self, grayscale_signals: np.ndarray, grayscale_luminance: np.ndarray, primary_measurements: dict | None = None ) -> HDRCalibrationResult: """ Analyze calibration measurements. @@ -518,9 +490,7 @@ def analyze( Returns: Complete calibration result """ - result = self.calibrator.analyze_measurements( - grayscale_signals, grayscale_luminance - ) + result = self.calibrator.analyze_measurements(grayscale_signals, grayscale_luminance) # Add primary analysis if available if primary_measurements: @@ -530,9 +500,7 @@ def analyze( return result def generate_correction( - self, - result: HDRCalibrationResult, - output_format: str = "cube" + self, result: HDRCalibrationResult, output_format: str = "cube" ) -> tuple[np.ndarray, Path | None]: """ Generate correction LUT from calibration result. @@ -551,11 +519,7 @@ def generate_correction( return np.linspace(0, 1, 65), None def quick_assessment( - self, - peak_luminance: float, - black_level: float, - sample_luminances: np.ndarray, - sample_signals: np.ndarray + self, peak_luminance: float, black_level: float, sample_luminances: np.ndarray, sample_signals: np.ndarray ) -> dict: """ Quick HDR capability assessment without full calibration. @@ -579,7 +543,7 @@ def quick_assessment( "dynamic_range_stops": np.log2(peak_luminance / max(black_level, 0.0001)), "eotf_accuracy": assessment.eotf_accuracy, "grade": assessment.grade, - "recommendation": self._get_recommendation(assessment) + "recommendation": self._get_recommendation(assessment), } def _get_recommendation(self, assessment: PQDisplayAssessment) -> str: @@ -600,9 +564,9 @@ def _get_recommendation(self, assessment: PQDisplayAssessment) -> str: # Convenience Functions # ============================================================================= + def calibrate_hdr10( - measure_func: Callable[[float], float], - config: HDRCalibrationConfig = None + measure_func: Callable[[float], float], config: HDRCalibrationConfig = None ) -> HDRCalibrationResult: """ Complete HDR10 calibration workflow. @@ -620,16 +584,14 @@ def calibrate_hdr10( suite = HDRCalibrationSuite(config) patches = suite.get_test_patches() - signals = patches['grayscale'] + signals = patches["grayscale"] luminances = np.array([measure_func(s) for s in signals]) return suite.analyze(signals, luminances) def calibrate_hlg( - measure_func: Callable[[float], float], - system_gamma: float = 1.2, - config: HDRCalibrationConfig = None + measure_func: Callable[[float], float], system_gamma: float = 1.2, config: HDRCalibrationConfig = None ) -> HDRCalibrationResult: """ Complete HLG calibration workflow. @@ -649,7 +611,7 @@ def calibrate_hlg( suite = HDRCalibrationSuite(config) patches = suite.get_test_patches() - signals = patches['grayscale'] + signals = patches["grayscale"] luminances = np.array([measure_func(s) for s in signals]) return suite.analyze(signals, luminances) diff --git a/calibrate_pro/hdr/hlg.py b/calibrate_pro/hdr/hlg.py index 7cb0144..0914297 100644 --- a/calibrate_pro/hdr/hlg.py +++ b/calibrate_pro/hdr/hlg.py @@ -15,22 +15,23 @@ # HLG curve parameters HLG_A = 0.17883277 -HLG_B = 0.28466892 # 1 - 4*a -HLG_C = 0.55991073 # 0.5 - a*ln(4*a) +HLG_B = 0.28466892 # 1 - 4*a +HLG_C = 0.55991073 # 0.5 - a*ln(4*a) # Reference luminance HLG_REFERENCE_WHITE = 1000.0 # Nominal peak white (cd/m2) HLG_BLACK_LEVEL = 0.0 # System gamma values for different viewing environments -SYSTEM_GAMMA_NOMINAL = 1.2 # Reference viewing -SYSTEM_GAMMA_BRIGHT = 1.0 # Bright environment -SYSTEM_GAMMA_DARK = 1.4 # Dark environment +SYSTEM_GAMMA_NOMINAL = 1.2 # Reference viewing +SYSTEM_GAMMA_BRIGHT = 1.0 # Bright environment +SYSTEM_GAMMA_DARK = 1.4 # Dark environment # ============================================================================= # HLG Transfer Functions # ============================================================================= + def hlg_oetf(scene_linear: np.ndarray) -> np.ndarray: """ HLG Opto-Electronic Transfer Function (scene to signal). @@ -108,10 +109,7 @@ def hlg_eotf(signal: np.ndarray, system_gamma: float = SYSTEM_GAMMA_NOMINAL) -> return display -def hlg_eotf_inv( - display_linear: np.ndarray, - system_gamma: float = SYSTEM_GAMMA_NOMINAL -) -> np.ndarray: +def hlg_eotf_inv(display_linear: np.ndarray, system_gamma: float = SYSTEM_GAMMA_NOMINAL) -> np.ndarray: """ Inverse HLG EOTF (display light to signal). @@ -133,9 +131,7 @@ def hlg_eotf_inv( def hlg_ootf( - scene_linear: np.ndarray, - system_gamma: float = SYSTEM_GAMMA_NOMINAL, - peak_luminance: float = HLG_REFERENCE_WHITE + scene_linear: np.ndarray, system_gamma: float = SYSTEM_GAMMA_NOMINAL, peak_luminance: float = HLG_REFERENCE_WHITE ) -> np.ndarray: """ HLG Opto-Optical Transfer Function (scene to display light). @@ -164,13 +160,16 @@ def hlg_ootf( return display + # ============================================================================= # HLG Calibration # ============================================================================= + @dataclass class HLGDisplaySettings: """HLG display calibration settings.""" + system_gamma: float = SYSTEM_GAMMA_NOMINAL peak_luminance: float = HLG_REFERENCE_WHITE black_level: float = 0.0 @@ -195,10 +194,7 @@ def calculate_adaptive_gamma(self) -> float: def generate_hlg_calibration_lut( - display_peak: float, - display_black: float = 0.0, - system_gamma: float = SYSTEM_GAMMA_NOMINAL, - size: int = 33 + display_peak: float, display_black: float = 0.0, system_gamma: float = SYSTEM_GAMMA_NOMINAL, size: int = 33 ) -> np.ndarray: """ Generate HLG calibration 1D LUT. @@ -231,7 +227,7 @@ def calculate_hlg_eotf_error( measured_luminance: np.ndarray, signal_levels: np.ndarray, display_peak: float, - system_gamma: float = SYSTEM_GAMMA_NOMINAL + system_gamma: float = SYSTEM_GAMMA_NOMINAL, ) -> tuple[np.ndarray, float]: """ Calculate EOTF tracking error for HLG. @@ -259,14 +255,14 @@ def generate_hlg_verification_patches(num_patches: int = 21) -> np.ndarray: """Generate HLG signal levels for verification.""" return np.linspace(0, 1, num_patches) + # ============================================================================= # HLG to PQ Conversion # ============================================================================= + def hlg_to_pq( - hlg_signal: np.ndarray, - hlg_peak: float = 1000.0, - system_gamma: float = SYSTEM_GAMMA_NOMINAL + hlg_signal: np.ndarray, hlg_peak: float = 1000.0, system_gamma: float = SYSTEM_GAMMA_NOMINAL ) -> np.ndarray: """ Convert HLG signal to PQ signal. @@ -291,9 +287,7 @@ def hlg_to_pq( def pq_to_hlg( - pq_signal: np.ndarray, - hlg_peak: float = 1000.0, - system_gamma: float = SYSTEM_GAMMA_NOMINAL + pq_signal: np.ndarray, hlg_peak: float = 1000.0, system_gamma: float = SYSTEM_GAMMA_NOMINAL ) -> np.ndarray: """ Convert PQ signal to HLG signal. @@ -317,15 +311,13 @@ def pq_to_hlg( # Display to HLG signal return hlg_eotf_inv(display_normalized, system_gamma) + # ============================================================================= # SDR Compatibility # ============================================================================= -def hlg_to_sdr( - hlg_signal: np.ndarray, - sdr_gamma: float = 2.2, - desaturation: float = 0.0 -) -> np.ndarray: + +def hlg_to_sdr(hlg_signal: np.ndarray, sdr_gamma: float = 2.2, desaturation: float = 0.0) -> np.ndarray: """ Convert HLG to SDR for backwards-compatible display. diff --git a/calibrate_pro/hdr/mastering_standards.py b/calibrate_pro/hdr/mastering_standards.py index 0305441..28eeec2 100644 --- a/calibrate_pro/hdr/mastering_standards.py +++ b/calibrate_pro/hdr/mastering_standards.py @@ -21,14 +21,16 @@ class ComplianceLevel(Enum): """Compliance verification levels.""" - FULL = "full" # Meets all requirements - PARTIAL = "partial" # Meets most requirements - FAILED = "failed" # Does not meet requirements + + FULL = "full" # Meets all requirements + PARTIAL = "partial" # Meets most requirements + FAILED = "failed" # Does not meet requirements @dataclass class MasteringSpec: """Base mastering specification.""" + name: str description: str @@ -38,7 +40,7 @@ class MasteringSpec: # Luminance peak_luminance: float # cd/m² - min_luminance: float # cd/m² + min_luminance: float # cd/m² sdr_reference: float = 100.0 # SDR white reference # Gamma/EOTF @@ -68,30 +70,32 @@ def validate(self, measurements: dict) -> tuple[ComplianceLevel, list[str]]: issues = [] # Check luminance - if 'peak_luminance' in measurements: - peak = measurements['peak_luminance'] + if "peak_luminance" in measurements: + peak = measurements["peak_luminance"] target = self.peak_luminance error = abs(peak - target) / target * 100 if error > self.luminance_tolerance_percent: - issues.append(f"Peak luminance {peak:.0f} nits, expected {target:.0f} (±{self.luminance_tolerance_percent}%)") + issues.append( + f"Peak luminance {peak:.0f} nits, expected {target:.0f} (±{self.luminance_tolerance_percent}%)" + ) # Check white point - if 'white_point_de' in measurements: - de = measurements['white_point_de'] + if "white_point_de" in measurements: + de = measurements["white_point_de"] if de > self.white_point_tolerance_de: issues.append(f"White point Delta E {de:.2f}, maximum {self.white_point_tolerance_de}") # Check primaries - for color in ['red', 'green', 'blue']: - key = f'{color}_primary_de' + for color in ["red", "green", "blue"]: + key = f"{color}_primary_de" if key in measurements: de = measurements[key] if de > self.primary_tolerance_de: issues.append(f"{color.title()} primary Delta E {de:.2f}, maximum {self.primary_tolerance_de}") # Check gamma - if 'gamma' in measurements and self.eotf in ['gamma', 'bt1886']: - gamma = measurements['gamma'] + if "gamma" in measurements and self.eotf in ["gamma", "bt1886"]: + gamma = measurements["gamma"] if abs(gamma - self.gamma) > self.gamma_tolerance: issues.append(f"Gamma {gamma:.2f}, expected {self.gamma} (±{self.gamma_tolerance})") @@ -108,6 +112,7 @@ def validate(self, measurements: dict) -> tuple[ComplianceLevel, list[str]]: # Netflix Mastering Standards # ============================================================================= + @dataclass class NetflixMasteringProfile(MasteringSpec): """ @@ -126,32 +131,23 @@ def __init__(self): super().__init__( name="Netflix HDR", description="Netflix HDR/Dolby Vision Mastering Spec", - # P3-D65 primaries (NOT Rec.2020) - primaries={ - 'red': (0.680, 0.320), - 'green': (0.265, 0.690), - 'blue': (0.150, 0.060) - }, + primaries={"red": (0.680, 0.320), "green": (0.265, 0.690), "blue": (0.150, 0.060)}, white_point=(0.3127, 0.3290), # D65 - # Luminance peak_luminance=1000.0, # Minimum mastering peak - min_luminance=0.0001, # Target for OLED + min_luminance=0.0001, # Target for OLED sdr_reference=100.0, - # PQ EOTF eotf="pq", - # Tight tolerances for professional mastering primary_tolerance_de=2.0, white_point_tolerance_de=2.0, gamma_tolerance=0.05, luminance_tolerance_percent=3.0, - # Viewing environment surround_luminance=5.0, - viewing_distance_heights=3.0 + viewing_distance_heights=3.0, ) # Netflix viewing environment specs @@ -160,8 +156,8 @@ def __init__(self): "viewing_distance_hd": "3-3.2 picture heights", "viewing_distance_uhd": "1.5-1.6 picture heights", "horizontal_fov_min": 90, # degrees - "vertical_fov_min": 60, # degrees - "ambient_light_max": 10, # cd/m² reflected off screen + "vertical_fov_min": 60, # degrees + "ambient_light_max": 10, # cd/m² reflected off screen } # Required reference monitors @@ -180,6 +176,7 @@ def __init__(self): # EBU Grade 1 Standards # ============================================================================= + @dataclass class EBUGrade1Profile(MasteringSpec): """ @@ -205,56 +202,38 @@ def __init__(self, mode: str = "sdr"): super().__init__( name="EBU Grade 1 SDR", description="EBU Tech 3320 Grade 1 SDR Broadcast Monitor", - # BT.709/sRGB primaries - primaries={ - 'red': (0.640, 0.330), - 'green': (0.300, 0.600), - 'blue': (0.150, 0.060) - }, + primaries={"red": (0.640, 0.330), "green": (0.300, 0.600), "blue": (0.150, 0.060)}, white_point=(0.3127, 0.3290), - peak_luminance=100.0, min_luminance=0.05, # <0.05 nits black sdr_reference=100.0, - eotf="bt1886", gamma=2.4, - primary_tolerance_de=4.0, # In u'v' units originally white_point_tolerance_de=3.0, gamma_tolerance=0.10, luminance_tolerance_percent=5.0, - surround_luminance=5.0, - viewing_distance_heights=3.2 + viewing_distance_heights=3.2, ) else: # HDR mode super().__init__( name="EBU Grade 1 HDR", description="EBU Tech 3320 Grade 1 HDR Broadcast Monitor", - # BT.2020 primaries - primaries={ - 'red': (0.708, 0.292), - 'green': (0.170, 0.797), - 'blue': (0.131, 0.046) - }, + primaries={"red": (0.708, 0.292), "green": (0.170, 0.797), "blue": (0.131, 0.046)}, white_point=(0.3127, 0.3290), - peak_luminance=1000.0, min_luminance=0.005, sdr_reference=203.0, # HLG/PQ reference white - eotf="pq", # or "hlg" - primary_tolerance_de=4.0, white_point_tolerance_de=3.0, gamma_tolerance=0.05, luminance_tolerance_percent=3.0, - surround_luminance=5.0, - viewing_distance_heights=3.2 + viewing_distance_heights=3.2, ) # EBU Grade 1 specific requirements @@ -270,7 +249,7 @@ def __init__(self, mode: str = "sdr"): "contrast_ratio_min": 10000, "bt2020_coverage_min": 90, # percent "pq_tracking_error_max": 3, # percent - } + }, } @@ -278,6 +257,7 @@ def __init__(self, mode: str = "sdr"): # DCI Cinema Standards # ============================================================================= + @dataclass class DCIMasteringProfile(MasteringSpec): """ @@ -294,31 +274,22 @@ def __init__(self): super().__init__( name="DCI P3 Cinema", description="DCI Digital Cinema Projection Standard", - # DCI-P3 primaries - primaries={ - 'red': (0.680, 0.320), - 'green': (0.265, 0.690), - 'blue': (0.150, 0.060) - }, + primaries={"red": (0.680, 0.320), "green": (0.265, 0.690), "blue": (0.150, 0.060)}, # DCI white point (NOT D65!) white_point=(0.314, 0.351), - # Cinema luminance (14 foot-lamberts) peak_luminance=48.0, min_luminance=0.0, sdr_reference=48.0, - eotf="gamma", gamma=2.6, - primary_tolerance_de=4.0, white_point_tolerance_de=2.0, gamma_tolerance=0.05, luminance_tolerance_percent=10.0, - surround_luminance=0.0, # Dark cinema - viewing_distance_heights=1.5 + viewing_distance_heights=1.5, ) # DCI specific requirements @@ -335,6 +306,7 @@ def __init__(self): # Additional Streaming Standards # ============================================================================= + @dataclass class DisneyPlusProfile(MasteringSpec): """Disney+ HDR Mastering Requirements.""" @@ -343,27 +315,18 @@ def __init__(self): super().__init__( name="Disney+ HDR", description="Disney+ HDR10/Dolby Vision Mastering", - - primaries={ - 'red': (0.680, 0.320), - 'green': (0.265, 0.690), - 'blue': (0.150, 0.060) - }, + primaries={"red": (0.680, 0.320), "green": (0.265, 0.690), "blue": (0.150, 0.060)}, white_point=(0.3127, 0.3290), - peak_luminance=1000.0, min_luminance=0.0001, sdr_reference=100.0, - eotf="pq", - primary_tolerance_de=3.0, white_point_tolerance_de=2.0, gamma_tolerance=0.05, luminance_tolerance_percent=5.0, - surround_luminance=5.0, - viewing_distance_heights=3.0 + viewing_distance_heights=3.0, ) @@ -375,28 +338,19 @@ def __init__(self): super().__init__( name="Apple TV+ HDR", description="Apple TV+ Dolby Vision Mastering", - # P3-D65 - primaries={ - 'red': (0.680, 0.320), - 'green': (0.265, 0.690), - 'blue': (0.150, 0.060) - }, + primaries={"red": (0.680, 0.320), "green": (0.265, 0.690), "blue": (0.150, 0.060)}, white_point=(0.3127, 0.3290), - peak_luminance=1000.0, min_luminance=0.0001, sdr_reference=100.0, - eotf="pq", - primary_tolerance_de=2.5, white_point_tolerance_de=2.0, gamma_tolerance=0.05, luminance_tolerance_percent=3.0, - surround_luminance=5.0, - viewing_distance_heights=3.0 + viewing_distance_heights=3.0, ) @@ -408,29 +362,20 @@ def __init__(self): super().__init__( name="BBC HLG", description="BBC HLG Broadcast Mastering", - # BT.2020 primaries - primaries={ - 'red': (0.708, 0.292), - 'green': (0.170, 0.797), - 'blue': (0.131, 0.046) - }, + primaries={"red": (0.708, 0.292), "green": (0.170, 0.797), "blue": (0.131, 0.046)}, white_point=(0.3127, 0.3290), - peak_luminance=1000.0, min_luminance=0.01, sdr_reference=203.0, # HLG nominal white - eotf="hlg", gamma=1.2, # System gamma - primary_tolerance_de=4.0, white_point_tolerance_de=3.0, gamma_tolerance=0.10, luminance_tolerance_percent=5.0, - surround_luminance=5.0, - viewing_distance_heights=3.0 + viewing_distance_heights=3.0, ) @@ -438,9 +383,9 @@ def __init__(self): # Validation Functions # ============================================================================= + def validate_mastering_compliance( - measurements: dict, - standard: str = "netflix" + measurements: dict, standard: str = "netflix" ) -> tuple[ComplianceLevel, list[str], MasteringSpec]: """ Validate calibration measurements against a mastering standard. @@ -525,10 +470,7 @@ def get_recommended_targets(use_case: str) -> dict[str, MasteringSpec]: raise ValueError(f"Unknown use case: {use_case}") -def generate_compliance_report( - measurements: dict, - standards: list[str] = None -) -> dict: +def generate_compliance_report(measurements: dict, standards: list[str] = None) -> dict: """ Generate a comprehensive compliance report against multiple standards. @@ -545,23 +487,14 @@ def generate_compliance_report( report = { "measurements": measurements, "standards_checked": [], - "summary": { - "full_compliance": [], - "partial_compliance": [], - "failed": [] - } + "summary": {"full_compliance": [], "partial_compliance": [], "failed": []}, } for std in standards: try: level, issues, spec = validate_mastering_compliance(measurements, std) - result = { - "standard": std, - "name": spec.name, - "compliance": level.value, - "issues": issues - } + result = {"standard": std, "name": spec.name, "compliance": level.value, "issues": issues} report["standards_checked"].append(result) if level == ComplianceLevel.FULL: @@ -572,9 +505,6 @@ def generate_compliance_report( report["summary"]["failed"].append(std) except Exception as e: - report["standards_checked"].append({ - "standard": std, - "error": str(e) - }) + report["standards_checked"].append({"standard": std, "error": str(e)}) return report diff --git a/calibrate_pro/hdr/pq_st2084.py b/calibrate_pro/hdr/pq_st2084.py index 999f2b0..8036994 100644 --- a/calibrate_pro/hdr/pq_st2084.py +++ b/calibrate_pro/hdr/pq_st2084.py @@ -15,20 +15,21 @@ # ============================================================================= # PQ curve constants (SMPTE ST.2084) -PQ_M1 = 2610.0 / 16384.0 # 0.1593017578125 -PQ_M2 = 2523.0 / 32.0 * 128.0 # 78.84375 -PQ_C1 = 3424.0 / 4096.0 # 0.8359375 -PQ_C2 = 2413.0 / 128.0 # 18.8515625 -PQ_C3 = 2392.0 / 128.0 # 18.6875 +PQ_M1 = 2610.0 / 16384.0 # 0.1593017578125 +PQ_M2 = 2523.0 / 32.0 * 128.0 # 78.84375 +PQ_C1 = 3424.0 / 4096.0 # 0.8359375 +PQ_C2 = 2413.0 / 128.0 # 18.8515625 +PQ_C3 = 2392.0 / 128.0 # 18.6875 # Reference luminance PQ_REFERENCE_WHITE = 10000.0 # cd/m2 (nits) -SDR_REFERENCE_WHITE = 100.0 # cd/m2 (sRGB reference) +SDR_REFERENCE_WHITE = 100.0 # cd/m2 (sRGB reference) # ============================================================================= # PQ EOTF (Electro-Optical Transfer Function) # ============================================================================= + def pq_eotf(signal: np.ndarray, normalize: bool = False) -> np.ndarray: """ Convert PQ-encoded signal to linear luminance (cd/m2). @@ -92,13 +93,16 @@ def pq_oetf(luminance: np.ndarray, normalize_input: bool = False) -> np.ndarray: return np.clip(E, 0.0, 1.0) + # ============================================================================= # HDR10 Metadata # ============================================================================= + @dataclass class HDR10Metadata: """HDR10 static metadata (SMPTE ST.2086).""" + # Mastering display primaries (CIE 1931 xy) red_primary: tuple[float, float] = (0.680, 0.320) green_primary: tuple[float, float] = (0.265, 0.690) @@ -106,12 +110,12 @@ class HDR10Metadata: white_point: tuple[float, float] = (0.3127, 0.3290) # Luminance range - max_luminance: float = 1000.0 # cd/m2 - min_luminance: float = 0.0001 # cd/m2 + max_luminance: float = 1000.0 # cd/m2 + min_luminance: float = 0.0001 # cd/m2 # Content light level (CEA-861.3) - max_cll: float = 1000.0 # Maximum Content Light Level - max_fall: float = 400.0 # Maximum Frame-Average Light Level + max_cll: float = 1000.0 # Maximum Content Light Level + max_fall: float = 400.0 # Maximum Frame-Average Light Level def to_dict(self) -> dict: """Convert to dictionary for serialization.""" @@ -120,18 +124,13 @@ def to_dict(self) -> dict: "red": self.red_primary, "green": self.green_primary, "blue": self.blue_primary, - "white": self.white_point - }, - "luminance": { - "max": self.max_luminance, - "min": self.min_luminance + "white": self.white_point, }, - "content_light_level": { - "max_cll": self.max_cll, - "max_fall": self.max_fall - } + "luminance": {"max": self.max_luminance, "min": self.min_luminance}, + "content_light_level": {"max_cll": self.max_cll, "max_fall": self.max_fall}, } + # Common display metadata presets HDR10_PRESETS = { "DCI-P3_1000": HDR10Metadata( @@ -140,7 +139,7 @@ def to_dict(self) -> dict: blue_primary=(0.150, 0.060), white_point=(0.3127, 0.3290), max_luminance=1000.0, - min_luminance=0.0001 + min_luminance=0.0001, ), "BT2020_1000": HDR10Metadata( red_primary=(0.708, 0.292), @@ -148,7 +147,7 @@ def to_dict(self) -> dict: blue_primary=(0.131, 0.046), white_point=(0.3127, 0.3290), max_luminance=1000.0, - min_luminance=0.0001 + min_luminance=0.0001, ), "BT2020_4000": HDR10Metadata( red_primary=(0.708, 0.292), @@ -156,7 +155,7 @@ def to_dict(self) -> dict: blue_primary=(0.131, 0.046), white_point=(0.3127, 0.3290), max_luminance=4000.0, - min_luminance=0.0001 + min_luminance=0.0001, ), } @@ -164,10 +163,9 @@ def to_dict(self) -> dict: # HDR Calibration Functions # ============================================================================= + def calculate_pq_eotf_error( - measured_luminance: np.ndarray, - signal_levels: np.ndarray, - reference_white: float = 100.0 + measured_luminance: np.ndarray, signal_levels: np.ndarray, reference_white: float = 100.0 ) -> tuple[np.ndarray, float]: """ Calculate EOTF tracking error for PQ curve. @@ -198,10 +196,7 @@ def calculate_pq_eotf_error( def generate_pq_calibration_lut( - display_peak: float, - display_black: float = 0.0, - size: int = 33, - tone_map: bool = True + display_peak: float, display_black: float = 0.0, size: int = 33, tone_map: bool = True ) -> np.ndarray: """ Generate PQ calibration LUT for a specific display. @@ -227,9 +222,8 @@ def generate_pq_calibration_lut( target = np.where( target <= knee_start, target, - knee_start + (display_peak - knee_start) * np.tanh( - (target - knee_start) / (PQ_REFERENCE_WHITE - knee_start) - ) + knee_start + + (display_peak - knee_start) * np.tanh((target - knee_start) / (PQ_REFERENCE_WHITE - knee_start)), ) # Add black level offset @@ -241,10 +235,7 @@ def generate_pq_calibration_lut( return np.clip(output, 0, 1) -def generate_pq_verification_patches( - num_patches: int = 21, - include_near_black: bool = True -) -> np.ndarray: +def generate_pq_verification_patches(num_patches: int = 21, include_near_black: bool = True) -> np.ndarray: """ Generate PQ signal levels for EOTF verification. @@ -277,7 +268,7 @@ def pq_code_to_nits(code_value: int | float, bit_depth: int = 10) -> float: Returns: Luminance in cd/m2 (nits) """ - max_code = (2 ** bit_depth) - 1 + max_code = (2**bit_depth) - 1 signal = code_value / max_code return float(pq_eotf(np.array([signal]))[0]) @@ -293,7 +284,7 @@ def nits_to_pq_code(luminance: float, bit_depth: int = 10) -> int: Returns: Integer code value """ - max_code = (2 ** bit_depth) - 1 + max_code = (2**bit_depth) - 1 signal = pq_oetf(np.array([luminance]))[0] return int(round(signal * max_code)) @@ -302,16 +293,18 @@ def nits_to_pq_code(luminance: float, bit_depth: int = 10) -> int: # Display Capability Assessment # ============================================================================= + @dataclass class PQDisplayAssessment: """Assessment of display's HDR/PQ capabilities.""" - peak_luminance: float # Measured peak (cd/m2) - black_level: float # Measured black (cd/m2) - contrast_ratio: float # Peak / Black - dynamic_range_stops: float # log2(Peak / Black) - eotf_accuracy: float # Average EOTF tracking error (%) - near_black_accuracy: float # Near-black EOTF error (%) - grade: str # Performance grade + + peak_luminance: float # Measured peak (cd/m2) + black_level: float # Measured black (cd/m2) + contrast_ratio: float # Peak / Black + dynamic_range_stops: float # log2(Peak / Black) + eotf_accuracy: float # Average EOTF tracking error (%) + near_black_accuracy: float # Near-black EOTF error (%) + grade: str # Performance grade def to_dict(self) -> dict: return { @@ -321,14 +314,11 @@ def to_dict(self) -> dict: "dynamic_range_stops": self.dynamic_range_stops, "eotf_accuracy_percent": self.eotf_accuracy, "near_black_accuracy_percent": self.near_black_accuracy, - "grade": self.grade + "grade": self.grade, } -def assess_pq_display( - measured_luminance: np.ndarray, - signal_levels: np.ndarray -) -> PQDisplayAssessment: +def assess_pq_display(measured_luminance: np.ndarray, signal_levels: np.ndarray) -> PQDisplayAssessment: """ Assess display's PQ/HDR performance. @@ -346,9 +336,7 @@ def assess_pq_display( dr_stops = np.log2(contrast) # Calculate EOTF error - errors, avg_error = calculate_pq_eotf_error( - measured_luminance, signal_levels, reference_white=100.0 - ) + errors, avg_error = calculate_pq_eotf_error(measured_luminance, signal_levels, reference_white=100.0) # Near-black error (signal < 10%) near_black_mask = signal_levels < 0.1 @@ -376,5 +364,5 @@ def assess_pq_display( dynamic_range_stops=dr_stops, eotf_accuracy=avg_error, near_black_accuracy=near_black_error, - grade=grade + grade=grade, ) diff --git a/calibrate_pro/hdr/tone_mapping.py b/calibrate_pro/hdr/tone_mapping.py index b096dbd..90d18fb 100644 --- a/calibrate_pro/hdr/tone_mapping.py +++ b/calibrate_pro/hdr/tone_mapping.py @@ -22,63 +22,59 @@ # Tone Mapping Algorithms # ============================================================================= + class ToneMapOperator(Enum): """Available tone mapping operators.""" - LINEAR = "linear" # No tone mapping (clipping) - REINHARD = "reinhard" # Reinhard global + + LINEAR = "linear" # No tone mapping (clipping) + REINHARD = "reinhard" # Reinhard global REINHARD_EXT = "reinhard_extended" # Extended Reinhard - ACES = "aces" # ACES filmic - HABLE = "hable" # Hable/Uncharted 2 filmic - BT2390 = "bt2390" # ITU-R BT.2390 EETF - SPLINE = "spline" # Custom spline curve + ACES = "aces" # ACES filmic + HABLE = "hable" # Hable/Uncharted 2 filmic + BT2390 = "bt2390" # ITU-R BT.2390 EETF + SPLINE = "spline" # Custom spline curve EXPONENTIAL = "exponential" # Exponential roll-off @dataclass class ToneMapSettings: """Tone mapping configuration.""" + operator: ToneMapOperator = ToneMapOperator.BT2390 # Source/target characteristics - source_peak: float = 1000.0 # Source peak luminance (cd/m²) - source_black: float = 0.0 # Source black level - target_peak: float = 100.0 # Target peak luminance - target_black: float = 0.0 # Target black level + source_peak: float = 1000.0 # Source peak luminance (cd/m²) + source_black: float = 0.0 # Source black level + target_peak: float = 100.0 # Target peak luminance + target_black: float = 0.0 # Target black level # Curve parameters - knee_start: float = 0.5 # Where roll-off begins (0-1 of target) - shoulder_strength: float = 0.5 # Roll-off strength - mid_gray: float = 0.18 # Mid-gray reference - white_clip: float = 1.0 # Maximum output value + knee_start: float = 0.5 # Where roll-off begins (0-1 of target) + shoulder_strength: float = 0.5 # Roll-off strength + mid_gray: float = 0.18 # Mid-gray reference + white_clip: float = 1.0 # Maximum output value # Highlight handling highlight_desaturation: float = 0.3 # Desaturate bright areas - preserve_hue: bool = True # Maintain hue in highlights + preserve_hue: bool = True # Maintain hue in highlights # Shadow handling - shadow_contrast: float = 1.0 # Shadow contrast adjustment - black_rolloff: float = 0.0 # Soft black clipping + shadow_contrast: float = 1.0 # Shadow contrast adjustment + black_rolloff: float = 0.0 # Soft black clipping # ============================================================================= # Core Tone Mapping Functions # ============================================================================= -def tone_map_linear( - luminance: np.ndarray, - source_peak: float, - target_peak: float -) -> np.ndarray: + +def tone_map_linear(luminance: np.ndarray, source_peak: float, target_peak: float) -> np.ndarray: """Simple linear scaling with clipping.""" scale = target_peak / source_peak return np.clip(luminance * scale, 0, target_peak) -def tone_map_reinhard( - luminance: np.ndarray, - source_peak: float = 1000.0, - mid_gray: float = 0.18 -) -> np.ndarray: +def tone_map_reinhard(luminance: np.ndarray, source_peak: float = 1000.0, mid_gray: float = 0.18) -> np.ndarray: """ Reinhard global tone mapping. @@ -100,10 +96,7 @@ def tone_map_reinhard( def tone_map_reinhard_extended( - luminance: np.ndarray, - source_peak: float = 1000.0, - target_peak: float = 100.0, - white_point: float = None + luminance: np.ndarray, source_peak: float = 1000.0, target_peak: float = 100.0, white_point: float = None ) -> np.ndarray: """ Extended Reinhard with white point control. @@ -122,10 +115,7 @@ def tone_map_reinhard_extended( return Ld * target_peak / source_peak -def tone_map_aces( - rgb: np.ndarray, - source_peak: float = 1000.0 -) -> np.ndarray: +def tone_map_aces(rgb: np.ndarray, source_peak: float = 1000.0) -> np.ndarray: """ ACES filmic tone mapping. @@ -148,11 +138,7 @@ def tone_map_aces( return np.clip(result, 0.0, 1.0) -def tone_map_hable( - rgb: np.ndarray, - source_peak: float = 1000.0, - exposure_bias: float = 2.0 -) -> np.ndarray: +def tone_map_hable(rgb: np.ndarray, source_peak: float = 1000.0, exposure_bias: float = 2.0) -> np.ndarray: """ Hable/Uncharted 2 filmic tone mapping. @@ -183,7 +169,7 @@ def tone_map_bt2390( source_peak: float = 1000.0, target_peak: float = 100.0, source_black: float = 0.0, - target_black: float = 0.0 + target_black: float = 0.0, ) -> np.ndarray: """ ITU-R BT.2390 EETF (Electro-Electro Transfer Function). @@ -247,10 +233,7 @@ def tone_map_bt2390( def tone_map_exponential( - luminance: np.ndarray, - source_peak: float = 1000.0, - target_peak: float = 100.0, - exposure: float = 1.0 + luminance: np.ndarray, source_peak: float = 1000.0, target_peak: float = 100.0, exposure: float = 1.0 ) -> np.ndarray: """ Exponential tone mapping with soft roll-off. @@ -269,11 +252,8 @@ def tone_map_exponential( # RGB Tone Mapping with Hue Preservation # ============================================================================= -def tone_map_rgb( - rgb: np.ndarray, - settings: ToneMapSettings, - preserve_hue: bool = True -) -> np.ndarray: + +def tone_map_rgb(rgb: np.ndarray, settings: ToneMapSettings, preserve_hue: bool = True) -> np.ndarray: """ Apply tone mapping to RGB data with optional hue preservation. @@ -296,22 +276,17 @@ def tone_map_rgb( if settings.operator == ToneMapOperator.REINHARD: mapped_lum = tone_map_reinhard(luminance, settings.source_peak) elif settings.operator == ToneMapOperator.REINHARD_EXT: - mapped_lum = tone_map_reinhard_extended( - luminance, settings.source_peak, settings.target_peak - ) + mapped_lum = tone_map_reinhard_extended(luminance, settings.source_peak, settings.target_peak) elif settings.operator == ToneMapOperator.ACES: mapped_lum = tone_map_aces(luminance[..., np.newaxis], settings.source_peak)[..., 0] elif settings.operator == ToneMapOperator.HABLE: mapped_lum = tone_map_hable(luminance[..., np.newaxis], settings.source_peak)[..., 0] elif settings.operator == ToneMapOperator.BT2390: mapped_lum = tone_map_bt2390( - luminance, settings.source_peak, settings.target_peak, - settings.source_black, settings.target_black + luminance, settings.source_peak, settings.target_peak, settings.source_black, settings.target_black ) elif settings.operator == ToneMapOperator.EXPONENTIAL: - mapped_lum = tone_map_exponential( - luminance, settings.source_peak, settings.target_peak - ) + mapped_lum = tone_map_exponential(luminance, settings.source_peak, settings.target_peak) else: mapped_lum = tone_map_linear(luminance, settings.source_peak, settings.target_peak) @@ -322,9 +297,10 @@ def tone_map_rgb( # Apply highlight desaturation if settings.highlight_desaturation > 0: desat_amount = settings.highlight_desaturation * np.clip( - (mapped_lum - settings.knee_start * settings.target_peak) / - ((1.0 - settings.knee_start) * settings.target_peak + 0.0001), - 0, 1 + (mapped_lum - settings.knee_start * settings.target_peak) + / ((1.0 - settings.knee_start) * settings.target_peak + 0.0001), + 0, + 1, ) gray = mapped_lum[..., np.newaxis] * np.array([[[1, 1, 1]]]) result = result * (1 - desat_amount[..., np.newaxis]) + gray * desat_amount[..., np.newaxis] @@ -342,13 +318,9 @@ def tone_map_rgb( if settings.operator == ToneMapOperator.REINHARD: result[..., c] = tone_map_reinhard(rgb[..., c], settings.source_peak) elif settings.operator == ToneMapOperator.BT2390: - result[..., c] = tone_map_bt2390( - rgb[..., c], settings.source_peak, settings.target_peak - ) + result[..., c] = tone_map_bt2390(rgb[..., c], settings.source_peak, settings.target_peak) else: - result[..., c] = tone_map_linear( - rgb[..., c], settings.source_peak, settings.target_peak - ) + result[..., c] = tone_map_linear(rgb[..., c], settings.source_peak, settings.target_peak) # Clip to valid range result = np.clip(result, 0, settings.target_peak) @@ -364,10 +336,8 @@ def tone_map_rgb( # Tone Mapping LUT Generation # ============================================================================= -def generate_tonemap_1d_lut( - settings: ToneMapSettings, - size: int = 1024 -) -> np.ndarray: + +def generate_tonemap_1d_lut(settings: ToneMapSettings, size: int = 1024) -> np.ndarray: """ Generate 1D tone mapping LUT. @@ -385,9 +355,7 @@ def generate_tonemap_1d_lut( if settings.operator == ToneMapOperator.REINHARD: lum_out = tone_map_reinhard(lum_in, settings.source_peak) elif settings.operator == ToneMapOperator.REINHARD_EXT: - lum_out = tone_map_reinhard_extended( - lum_in, settings.source_peak, settings.target_peak - ) + lum_out = tone_map_reinhard_extended(lum_in, settings.source_peak, settings.target_peak) elif settings.operator == ToneMapOperator.ACES: lum_out = tone_map_aces(lum_in[:, np.newaxis], settings.source_peak)[:, 0] lum_out *= settings.target_peak @@ -396,8 +364,7 @@ def generate_tonemap_1d_lut( lum_out *= settings.target_peak elif settings.operator == ToneMapOperator.BT2390: lum_out = tone_map_bt2390( - lum_in, settings.source_peak, settings.target_peak, - settings.source_black, settings.target_black + lum_in, settings.source_peak, settings.target_peak, settings.source_black, settings.target_black ) elif settings.operator == ToneMapOperator.EXPONENTIAL: lum_out = tone_map_exponential(lum_in, settings.source_peak, settings.target_peak) @@ -408,10 +375,7 @@ def generate_tonemap_1d_lut( return np.clip(lum_out / settings.target_peak, 0, 1) -def generate_tonemap_3d_lut( - settings: ToneMapSettings, - size: int = 33 -) -> np.ndarray: +def generate_tonemap_3d_lut(settings: ToneMapSettings, size: int = 33) -> np.ndarray: """ Generate 3D tone mapping LUT. @@ -424,7 +388,7 @@ def generate_tonemap_3d_lut( """ # Create RGB grid coords = np.linspace(0, 1, size) - r, g, b = np.meshgrid(coords, coords, coords, indexing='ij') + r, g, b = np.meshgrid(coords, coords, coords, indexing="ij") # Stack and scale to source range rgb = np.stack([r, g, b], axis=-1) * settings.source_peak @@ -440,6 +404,7 @@ def generate_tonemap_3d_lut( # HDR to SDR Conversion # ============================================================================= + class HDRToSDRConverter: """ Complete HDR to SDR conversion with color management. @@ -449,7 +414,7 @@ def __init__( self, source_peak: float = 1000.0, target_peak: float = 100.0, - operator: ToneMapOperator = ToneMapOperator.BT2390 + operator: ToneMapOperator = ToneMapOperator.BT2390, ): """ Initialize converter. @@ -459,17 +424,9 @@ def __init__( target_peak: SDR target luminance operator: Tone mapping algorithm """ - self.settings = ToneMapSettings( - operator=operator, - source_peak=source_peak, - target_peak=target_peak - ) + self.settings = ToneMapSettings(operator=operator, source_peak=source_peak, target_peak=target_peak) - def convert_pq_to_sdr( - self, - pq_rgb: np.ndarray, - apply_gamma: bool = True - ) -> np.ndarray: + def convert_pq_to_sdr(self, pq_rgb: np.ndarray, apply_gamma: bool = True) -> np.ndarray: """ Convert PQ-encoded HDR to SDR. @@ -494,19 +451,12 @@ def convert_pq_to_sdr( # Apply gamma encoding if apply_gamma: # sRGB gamma - result = np.where( - result <= 0.0031308, - result * 12.92, - 1.055 * np.power(result, 1/2.4) - 0.055 - ) + result = np.where(result <= 0.0031308, result * 12.92, 1.055 * np.power(result, 1 / 2.4) - 0.055) return np.clip(result, 0, 1) def convert_hlg_to_sdr( - self, - hlg_rgb: np.ndarray, - system_gamma: float = 1.2, - apply_gamma: bool = True + self, hlg_rgb: np.ndarray, system_gamma: float = 1.2, apply_gamma: bool = True ) -> np.ndarray: """ Convert HLG-encoded HDR to SDR. @@ -532,11 +482,7 @@ def convert_hlg_to_sdr( # Apply gamma if apply_gamma: - result = np.where( - result <= 0.0031308, - result * 12.92, - 1.055 * np.power(result, 1/2.4) - 0.055 - ) + result = np.where(result <= 0.0031308, result * 12.92, 1.055 * np.power(result, 1 / 2.4) - 0.055) return np.clip(result, 0, 1) @@ -561,10 +507,9 @@ def get_lut(self, lut_type: str = "3d", size: int = 33) -> np.ndarray: # Convenience Functions # ============================================================================= + def hdr_to_sdr( - hdr_rgb: np.ndarray, - source_peak: float = 1000.0, - operator: ToneMapOperator = ToneMapOperator.BT2390 + hdr_rgb: np.ndarray, source_peak: float = 1000.0, operator: ToneMapOperator = ToneMapOperator.BT2390 ) -> np.ndarray: """ Quick HDR to SDR conversion. @@ -581,10 +526,7 @@ def hdr_to_sdr( return tone_map_rgb(hdr_rgb, converter.settings) / 100.0 -def compare_operators( - source_peak: float = 1000.0, - target_peak: float = 100.0 -) -> dict[str, np.ndarray]: +def compare_operators(source_peak: float = 1000.0, target_peak: float = 100.0) -> dict[str, np.ndarray]: """ Compare different tone mapping operators. @@ -593,11 +535,7 @@ def compare_operators( results = {} for op in ToneMapOperator: - settings = ToneMapSettings( - operator=op, - source_peak=source_peak, - target_peak=target_peak - ) + settings = ToneMapSettings(operator=op, source_peak=source_peak, target_peak=target_peak) results[op.value] = generate_tonemap_1d_lut(settings, 256) return results diff --git a/calibrate_pro/hdr/workflow.py b/calibrate_pro/hdr/workflow.py index 7a173d0..5b45195 100644 --- a/calibrate_pro/hdr/workflow.py +++ b/calibrate_pro/hdr/workflow.py @@ -26,9 +26,10 @@ # HDR Standards & Targets # ========================================================================= + class HDRStandard(Enum): - HDR10 = "hdr10" # PQ/ST.2084, Rec.2020, 10-bit - HLG = "hlg" # HLG/BT.2100, Rec.2020 + HDR10 = "hdr10" # PQ/ST.2084, Rec.2020, 10-bit + HLG = "hlg" # HLG/BT.2100, Rec.2020 DOLBY_VISION = "dv" # Future @@ -45,12 +46,13 @@ class HDRStandard(Enum): @dataclass class HDRTarget: """HDR calibration target specification.""" + standard: HDRStandard - peak_luminance: float = 1000.0 # cd/m2 (nits) - min_luminance: float = 0.005 # cd/m2 - max_cll: float = 1000.0 # Maximum Content Light Level - max_fall: float = 400.0 # Max Frame Average Light Level - gamut: str = "bt2020" # Target color gamut + peak_luminance: float = 1000.0 # cd/m2 (nits) + min_luminance: float = 0.005 # cd/m2 + max_cll: float = 1000.0 # Maximum Content Light Level + max_fall: float = 400.0 # Max Frame Average Light Level + gamut: str = "bt2020" # Target color gamut @classmethod def hdr10_1000(cls) -> "HDRTarget": @@ -77,14 +79,16 @@ def hlg_1000(cls) -> "HDRTarget": # Result Container # ========================================================================= + @dataclass class HDRCalibrationResult: """Results from an HDR calibration pass.""" + target: HDRTarget - eotf_error: float # Average EOTF deviation (%) - peak_measured: float # Measured peak luminance - gamut_coverage_bt2020: float # % of BT.2020 covered - tone_map_curve: np.ndarray # Applied EETF curve + eotf_error: float # Average EOTF deviation (%) + peak_measured: float # Measured peak luminance + gamut_coverage_bt2020: float # % of BT.2020 covered + tone_map_curve: np.ndarray # Applied EETF curve lut_data: np.ndarray | None = None # 3D LUT if generated @@ -92,6 +96,7 @@ class HDRCalibrationResult: # HDR Workflow # ========================================================================= + class HDRWorkflow: """ Complete HDR calibration workflow. @@ -156,10 +161,7 @@ def verify_eotf( expected = np.asarray(expected, dtype=np.float64) if measured.shape != expected.shape: - raise ValueError( - f"Shape mismatch: measured {measured.shape} " - f"vs expected {expected.shape}" - ) + raise ValueError(f"Shape mismatch: measured {measured.shape} vs expected {expected.shape}") # Avoid division by zero for the black-level patch safe_expected = np.where(expected > 0, expected, 1.0) @@ -275,29 +277,18 @@ def export_cube(self, path: str, lut: np.ndarray) -> None: lut: (size, size, size, 3) LUT data in [0, 1]. """ if lut.ndim != 4 or lut.shape[-1] != 3: - raise ValueError( - "LUT must be shape (size, size, size, 3), " - f"got {lut.shape}" - ) + raise ValueError(f"LUT must be shape (size, size, size, 3), got {lut.shape}") size = lut.shape[0] meta = self.generate_hdr_metadata() lines: list[str] = [] - lines.append(f"TITLE \"Calibrate Pro HDR {self.target.standard.value}\"") + lines.append(f'TITLE "Calibrate Pro HDR {self.target.standard.value}"') lines.append(f"# HDR Standard: {meta['standard']}") - lines.append( - f"# Peak luminance: {meta['luminance']['max_nits']} nits" - ) - lines.append( - f"# Min luminance: {meta['luminance']['min_nits']} nits" - ) - lines.append( - f"# MaxCLL: {meta['content_light_level']['max_cll']} nits" - ) - lines.append( - f"# MaxFALL: {meta['content_light_level']['max_fall']} nits" - ) + lines.append(f"# Peak luminance: {meta['luminance']['max_nits']} nits") + lines.append(f"# Min luminance: {meta['luminance']['min_nits']} nits") + lines.append(f"# MaxCLL: {meta['content_light_level']['max_cll']} nits") + lines.append(f"# MaxFALL: {meta['content_light_level']['max_fall']} nits") lines.append(f"LUT_3D_SIZE {size}") lines.append("DOMAIN_MIN 0.0 0.0 0.0") lines.append("DOMAIN_MAX 1.0 1.0 1.0") diff --git a/calibrate_pro/integrations/resolve.py b/calibrate_pro/integrations/resolve.py index 9aaa45c..79baf55 100644 --- a/calibrate_pro/integrations/resolve.py +++ b/calibrate_pro/integrations/resolve.py @@ -31,6 +31,7 @@ def find_resolve_lut_dir() -> Path | None: """Find DaVinci Resolve's LUT directory.""" import sys + platform = sys.platform for path in RESOLVE_LUT_PATHS.get(platform, []): @@ -45,10 +46,7 @@ def find_resolve_lut_dir() -> Path | None: return None -def install_lut_to_resolve( - lut_path: str, - subfolder: str = "Calibrate Pro" -) -> Path | None: +def install_lut_to_resolve(lut_path: str, subfolder: str = "Calibrate Pro") -> Path | None: """ Copy a calibration LUT into Resolve's LUT directory. diff --git a/calibrate_pro/lut_system/__init__.py b/calibrate_pro/lut_system/__init__.py index 55e4a34..aa0da32 100644 --- a/calibrate_pro/lut_system/__init__.py +++ b/calibrate_pro/lut_system/__init__.py @@ -54,16 +54,18 @@ class LUTBackend(Enum): """Available LUT application backends.""" - DWM = "dwm" # Desktop Window Manager (universal) - NVIDIA = "nvidia" # NVIDIA NVAPI - AMD = "amd" # AMD ADL - INTEL = "intel" # Intel IGCL + + DWM = "dwm" # Desktop Window Manager (universal) + NVIDIA = "nvidia" # NVIDIA NVAPI + AMD = "amd" # AMD ADL + INTEL = "intel" # Intel IGCL GAMMA_RAMP = "gamma" # Windows gamma ramp (fallback) @dataclass class DisplayInfo: """Display information for LUT targeting.""" + id: int name: str is_primary: bool @@ -76,6 +78,7 @@ class DisplayInfo: @dataclass class BackendStatus: """Status of a LUT backend.""" + available: bool name: str vendor: str @@ -113,6 +116,7 @@ def _initialize_backends(self): # Try DWM backend (universal, preferred) try: from calibrate_pro.lut_system.dwm_lut import DwmLutController + dwm = DwmLutController() if dwm.is_available: self._backends[LUTBackend.DWM] = dwm @@ -122,6 +126,7 @@ def _initialize_backends(self): # Try NVIDIA backend try: from calibrate_pro.lut_system.nvidia_api import NvidiaAPI + nvidia = NvidiaAPI() if nvidia.is_available: self._backends[LUTBackend.NVIDIA] = nvidia @@ -131,6 +136,7 @@ def _initialize_backends(self): # Try AMD backend try: from calibrate_pro.lut_system.amd_api import AMDAPI + amd = AMDAPI() if amd.is_available: self._backends[LUTBackend.AMD] = amd @@ -140,6 +146,7 @@ def _initialize_backends(self): # Try Intel backend try: from calibrate_pro.lut_system.intel_api import IntelAPI + intel = IntelAPI() if intel.is_available: self._backends[LUTBackend.INTEL] = intel @@ -149,6 +156,7 @@ def _initialize_backends(self): # Fallback: Windows gamma ramp try: from calibrate_pro.lut_system.dwm_lut import GammaRampController + gamma = GammaRampController() self._backends[LUTBackend.GAMMA_RAMP] = gamma except Exception: @@ -192,20 +200,22 @@ def _enumerate_displays(self): if self._active_backend: backend = self._backends[self._active_backend] - if hasattr(backend, 'displays'): + if hasattr(backend, "displays"): displays = backend.displays for i, d in enumerate(displays): - display_id = getattr(d, 'display_id', i) + display_id = getattr(d, "display_id", i) if display_id not in seen_displays: seen_displays.add(display_id) - self._displays.append(DisplayInfo( - id=display_id, - name=getattr(d, 'name', f"Display {display_id}"), - is_primary=getattr(d, 'is_primary', i == 0), - gpu_vendor=self._active_backend.value, - resolution=getattr(d, 'resolution', (0, 0)), - hdr_capable=getattr(d, 'is_hdr', False) or getattr(d, 'hdr_supported', False), - )) + self._displays.append( + DisplayInfo( + id=display_id, + name=getattr(d, "name", f"Display {display_id}"), + is_primary=getattr(d, "is_primary", i == 0), + gpu_vendor=self._active_backend.value, + resolution=getattr(d, "resolution", (0, 0)), + hdr_capable=getattr(d, "is_hdr", False) or getattr(d, "hdr_supported", False), + ) + ) # If no displays found, try Windows enumeration if not self._displays: @@ -246,14 +256,16 @@ class DISPLAY_DEVICE(ctypes.Structure): else: vendor = "unknown" - self._displays.append(DisplayInfo( - id=i, - name=device.DeviceString, - is_primary=bool(device.StateFlags & 0x00000004), - gpu_vendor=vendor, - resolution=(0, 0), - hdr_capable=False, - )) + self._displays.append( + DisplayInfo( + id=i, + name=device.DeviceString, + is_primary=bool(device.StateFlags & 0x00000004), + gpu_vendor=vendor, + resolution=(0, 0), + hdr_capable=False, + ) + ) i += 1 except Exception: @@ -289,30 +301,18 @@ def get_backend_status(self) -> dict[str, BackendStatus]: for backend, name, vendor in backends_info: if backend in self._backends: api = self._backends[backend] - display_count = len(api.displays) if hasattr(api, 'displays') else 0 + display_count = len(api.displays) if hasattr(api, "displays") else 0 status[backend.value] = BackendStatus( - available=True, - name=name, - vendor=vendor, - message="Available", - display_count=display_count + available=True, name=name, vendor=vendor, message="Available", display_count=display_count ) else: status[backend.value] = BackendStatus( - available=False, - name=name, - vendor=vendor, - message="Not available" + available=False, name=name, vendor=vendor, message="Not available" ) return status - def load_lut( - self, - display_id: int, - lut: LUT3D | np.ndarray, - persist: bool = False - ) -> bool: + def load_lut(self, display_id: int, lut: LUT3D | np.ndarray, persist: bool = False) -> bool: """ Load a 3D LUT to a display. @@ -338,7 +338,11 @@ def load_lut( # Different backends have different interfaces if self._active_backend == LUTBackend.DWM: return backend.load_lut(display_id, lut.data) - elif self._active_backend == LUTBackend.NVIDIA or self._active_backend == LUTBackend.AMD or self._active_backend == LUTBackend.INTEL: + elif ( + self._active_backend == LUTBackend.NVIDIA + or self._active_backend == LUTBackend.AMD + or self._active_backend == LUTBackend.INTEL + ): return backend.load_3d_lut(display_id, lut.data) elif self._active_backend == LUTBackend.GAMMA_RAMP: # Convert 3D LUT to 1D approximation for gamma ramp @@ -353,12 +357,7 @@ def load_lut( return False - def load_lut_file( - self, - display_id: int, - filepath: str | Path, - persist: bool = True - ) -> bool: + def load_lut_file(self, display_id: int, filepath: str | Path, persist: bool = True) -> bool: """ Load a 3D LUT from file to a display. @@ -411,9 +410,9 @@ def unload_lut(self, display_id: int) -> bool: backend = self._backends[self._active_backend] try: - if hasattr(backend, 'unload_lut'): + if hasattr(backend, "unload_lut"): result = backend.unload_lut(display_id) - elif hasattr(backend, 'reset_lut'): + elif hasattr(backend, "reset_lut"): result = backend.reset_lut(display_id) else: # Load identity LUT @@ -460,7 +459,7 @@ def cleanup(self): pass for backend in self._backends.values(): - if hasattr(backend, 'cleanup'): + if hasattr(backend, "cleanup"): try: backend.cleanup() except Exception: @@ -469,16 +468,13 @@ def cleanup(self): # Convenience functions + def get_lut_manager(preferred_backend: str | None = None) -> LUTManager: """Get a LUT manager instance.""" return LUTManager(preferred_backend) -def apply_lut_to_display( - display_id: int, - lut_path: str | Path, - backend: str | None = None -) -> bool: +def apply_lut_to_display(display_id: int, lut_path: str | Path, backend: str | None = None) -> bool: """ Quick function to apply a LUT file to a display. diff --git a/calibrate_pro/lut_system/amd_api.py b/calibrate_pro/lut_system/amd_api.py index 196960e..d4cf6f2 100644 --- a/calibrate_pro/lut_system/amd_api.py +++ b/calibrate_pro/lut_system/amd_api.py @@ -26,8 +26,10 @@ # ADL Constants and Types # ============================================================================= + class ADLStatus(IntEnum): """ADL return status codes.""" + OK = 0 ERR = -1 ERR_NOT_INIT = -2 @@ -47,6 +49,7 @@ class ADLStatus(IntEnum): class ADLColorType(IntEnum): """ADL color control types.""" + BRIGHTNESS = 1 CONTRAST = 2 SATURATION = 3 @@ -59,6 +62,7 @@ class ADLColorType(IntEnum): class ADLColorDepth(IntEnum): """Color depth options.""" + BPC_6 = 6 BPC_8 = 8 BPC_10 = 10 @@ -68,6 +72,7 @@ class ADLColorDepth(IntEnum): class ADLPixelFormat(IntEnum): """Pixel format options.""" + RGB = 0 YCRCB444 = 1 YCRCB422 = 2 @@ -82,91 +87,99 @@ class ADLPixelFormat(IntEnum): # ADL Structures # ============================================================================= + class ADLAdapterInfo(Structure): """ADL adapter information structure.""" + _fields_ = [ - ('iSize', c_int), - ('iAdapterIndex', c_int), - ('strUDID', c_char * 256), - ('iBusNumber', c_int), - ('iDeviceNumber', c_int), - ('iFunctionNumber', c_int), - ('iVendorID', c_int), - ('strAdapterName', c_char * 256), - ('strDisplayName', c_char * 256), - ('iPresent', c_int), - ('iExist', c_int), - ('strDriverPath', c_char * 256), - ('strDriverPathExt', c_char * 256), - ('strPNPString', c_char * 256), - ('iOSDisplayIndex', c_int), + ("iSize", c_int), + ("iAdapterIndex", c_int), + ("strUDID", c_char * 256), + ("iBusNumber", c_int), + ("iDeviceNumber", c_int), + ("iFunctionNumber", c_int), + ("iVendorID", c_int), + ("strAdapterName", c_char * 256), + ("strDisplayName", c_char * 256), + ("iPresent", c_int), + ("iExist", c_int), + ("strDriverPath", c_char * 256), + ("strDriverPathExt", c_char * 256), + ("strPNPString", c_char * 256), + ("iOSDisplayIndex", c_int), ] class ADLDisplayInfo(Structure): """ADL display information structure.""" + _fields_ = [ - ('displayID', c_int * 2), # ADLDisplayID - ('iDisplayControllerIndex', c_int), - ('strDisplayName', c_char * 256), - ('strDisplayManufacturerName', c_char * 256), - ('iDisplayType', c_int), - ('iDisplayOutputType', c_int), - ('iDisplayConnector', c_int), - ('iDisplayInfoMask', c_int), - ('iDisplayInfoValue', c_int), + ("displayID", c_int * 2), # ADLDisplayID + ("iDisplayControllerIndex", c_int), + ("strDisplayName", c_char * 256), + ("strDisplayManufacturerName", c_char * 256), + ("iDisplayType", c_int), + ("iDisplayOutputType", c_int), + ("iDisplayConnector", c_int), + ("iDisplayInfoMask", c_int), + ("iDisplayInfoValue", c_int), ] class ADLDisplayID(Structure): """ADL display ID structure.""" + _fields_ = [ - ('iDisplayLogicalIndex', c_int), - ('iDisplayPhysicalIndex', c_int), - ('iDisplayLogicalAdapterIndex', c_int), - ('iDisplayPhysicalAdapterIndex', c_int), + ("iDisplayLogicalIndex", c_int), + ("iDisplayPhysicalIndex", c_int), + ("iDisplayLogicalAdapterIndex", c_int), + ("iDisplayPhysicalAdapterIndex", c_int), ] class ADLGamma(Structure): """ADL gamma structure.""" + _fields_ = [ - ('fRed', c_float), - ('fGreen', c_float), - ('fBlue', c_float), + ("fRed", c_float), + ("fGreen", c_float), + ("fBlue", c_float), ] class ADLColorValue(Structure): """ADL color value structure (for brightness, contrast, saturation, hue).""" + _fields_ = [ - ('iCurrent', c_int), - ('iDefault', c_int), - ('iMin', c_int), - ('iMax', c_int), - ('iStep', c_int), + ("iCurrent", c_int), + ("iDefault", c_int), + ("iMin", c_int), + ("iMax", c_int), + ("iStep", c_int), ] class ADLDisplayColorCaps(Structure): """ADL display color capabilities.""" + _fields_ = [ - ('iColorType', c_int), # Type of color setting - ('iExpColorCaps', c_int), # Extended capabilities - ('iReserved1', c_int), - ('iReserved2', c_int), + ("iColorType", c_int), # Type of color setting + ("iExpColorCaps", c_int), # Extended capabilities + ("iReserved1", c_int), + ("iReserved2", c_int), ] class ADLCustomMode(Structure): """ADL custom color mode structure.""" + _fields_ = [ - ('iFlags', c_int), - ('iModeWidth', c_int), - ('iModeHeight', c_int), - ('iBaseModeWidth', c_int), - ('iBaseModeHeight', c_int), - ('iRefreshRate', c_int), + ("iFlags", c_int), + ("iModeWidth", c_int), + ("iModeHeight", c_int), + ("iBaseModeWidth", c_int), + ("iBaseModeHeight", c_int), + ("iRefreshRate", c_int), ] @@ -174,9 +187,11 @@ class ADLCustomMode(Structure): # Data Classes # ============================================================================= + @dataclass class AMDDisplay: """AMD display information.""" + adapter_index: int display_index: int logical_index: int @@ -196,6 +211,7 @@ class AMDDisplay: @dataclass class AMDAdapter: """AMD adapter (GPU) information.""" + index: int name: str display_name: str @@ -208,16 +224,18 @@ class AMDAdapter: @dataclass class ColorSettings: """Display color settings.""" - brightness: int = 50 # 0 to 100 - contrast: int = 50 # 0 to 100 + + brightness: int = 50 # 0 to 100 + contrast: int = 50 # 0 to 100 saturation: int = 100 # 0 to 200 - hue: int = 0 # 0 to 360 - color_temp: int = 6500 # Color temperature in Kelvin - gamma: float = 1.0 # 0.4 to 2.8 + hue: int = 0 # 0 to 360 + color_temp: int = 6500 # Color temperature in Kelvin + gamma: float = 1.0 # 0.4 to 2.8 class AMDAPIError(Exception): """AMD API error.""" + pass @@ -240,6 +258,7 @@ def _adl_malloc(size: int) -> int: # Main AMD API Class # ============================================================================= + class AMDAPI: """ AMD ADL wrapper for GPU-level color management. @@ -304,22 +323,18 @@ def _initialize(self) -> bool: def _try_init_adl2(self) -> bool: """Try to initialize ADL version 2.""" try: - if not hasattr(self._adl, 'ADL2_Main_Control_Create'): + if not hasattr(self._adl, "ADL2_Main_Control_Create"): return False ADL2_Main_Control_Create = self._adl.ADL2_Main_Control_Create ADL2_Main_Control_Create.restype = c_int - ADL2_Main_Control_Create.argtypes = [ - ADL_MAIN_MALLOC_CALLBACK, - c_int, - POINTER(c_void_p) - ] + ADL2_Main_Control_Create.argtypes = [ADL_MAIN_MALLOC_CALLBACK, c_int, POINTER(c_void_p)] context = c_void_p() status = ADL2_Main_Control_Create( self._malloc_callback, 1, # Retrieve adapter information for all adapters - byref(context) + byref(context), ) if status == ADLStatus.OK: @@ -335,7 +350,7 @@ def _try_init_adl2(self) -> bool: def _try_init_adl1(self) -> bool: """Try to initialize ADL version 1.""" try: - if not hasattr(self._adl, 'ADL_Main_Control_Create'): + if not hasattr(self._adl, "ADL_Main_Control_Create"): return False ADL_Main_Control_Create = self._adl.ADL_Main_Control_Create @@ -357,7 +372,7 @@ def _enumerate_adapters(self): # Get number of adapters num_adapters = c_int(0) - if hasattr(self._adl, 'ADL_Adapter_NumberOfAdapters_Get'): + if hasattr(self._adl, "ADL_Adapter_NumberOfAdapters_Get"): self._adl.ADL_Adapter_NumberOfAdapters_Get.restype = c_int self._adl.ADL_Adapter_NumberOfAdapters_Get.argtypes = [POINTER(c_int)] self._adl.ADL_Adapter_NumberOfAdapters_Get(byref(num_adapters)) @@ -368,16 +383,10 @@ def _enumerate_adapters(self): # Get adapter info adapter_info_array = (ADLAdapterInfo * num_adapters.value)() - if hasattr(self._adl, 'ADL_Adapter_AdapterInfo_Get'): + if hasattr(self._adl, "ADL_Adapter_AdapterInfo_Get"): self._adl.ADL_Adapter_AdapterInfo_Get.restype = c_int - self._adl.ADL_Adapter_AdapterInfo_Get.argtypes = [ - POINTER(ADLAdapterInfo * num_adapters.value), - c_int - ] - status = self._adl.ADL_Adapter_AdapterInfo_Get( - byref(adapter_info_array), - sizeof(adapter_info_array) - ) + self._adl.ADL_Adapter_AdapterInfo_Get.argtypes = [POINTER(ADLAdapterInfo * num_adapters.value), c_int] + status = self._adl.ADL_Adapter_AdapterInfo_Get(byref(adapter_info_array), sizeof(adapter_info_array)) if status == ADLStatus.OK: for i in range(num_adapters.value): @@ -385,7 +394,7 @@ def _enumerate_adapters(self): # Check if adapter is active is_active = False - if hasattr(self._adl, 'ADL_Adapter_Active_Get'): + if hasattr(self._adl, "ADL_Adapter_Active_Get"): active = c_int(0) self._adl.ADL_Adapter_Active_Get.restype = c_int self._adl.ADL_Adapter_Active_Get.argtypes = [c_int, POINTER(c_int)] @@ -393,15 +402,17 @@ def _enumerate_adapters(self): is_active = bool(active.value) if info.iPresent or is_active: - self._adapters.append(AMDAdapter( - index=info.iAdapterIndex, - name=info.strAdapterName.decode('utf-8', errors='ignore').strip(), - display_name=info.strDisplayName.decode('utf-8', errors='ignore').strip(), - is_present=bool(info.iPresent), - is_active=is_active, - vendor_id=info.iVendorID, - bus_number=info.iBusNumber - )) + self._adapters.append( + AMDAdapter( + index=info.iAdapterIndex, + name=info.strAdapterName.decode("utf-8", errors="ignore").strip(), + display_name=info.strDisplayName.decode("utf-8", errors="ignore").strip(), + is_present=bool(info.iPresent), + is_active=is_active, + vendor_id=info.iVendorID, + bus_number=info.iBusNumber, + ) + ) except (OSError, ctypes.ArgumentError): pass @@ -420,7 +431,7 @@ def _enumerate_displays(self): # Get number of displays for this adapter num_displays = c_int(0) - if hasattr(self._adl, 'ADL_Display_NumberOfDisplays_Get'): + if hasattr(self._adl, "ADL_Display_NumberOfDisplays_Get"): self._adl.ADL_Display_NumberOfDisplays_Get.restype = c_int self._adl.ADL_Display_NumberOfDisplays_Get.argtypes = [c_int, POINTER(c_int)] self._adl.ADL_Display_NumberOfDisplays_Get(adapter.index, byref(num_displays)) @@ -432,16 +443,19 @@ def _enumerate_displays(self): display_info_array = (ADLDisplayInfo * num_displays.value)() actual_displays = c_int(0) - if hasattr(self._adl, 'ADL_Display_DisplayInfo_Get'): + if hasattr(self._adl, "ADL_Display_DisplayInfo_Get"): self._adl.ADL_Display_DisplayInfo_Get.restype = c_int self._adl.ADL_Display_DisplayInfo_Get.argtypes = [ - c_int, POINTER(c_int), POINTER(ADLDisplayInfo * num_displays.value), c_int + c_int, + POINTER(c_int), + POINTER(ADLDisplayInfo * num_displays.value), + c_int, ] status = self._adl.ADL_Display_DisplayInfo_Get( adapter.index, byref(actual_displays), byref(display_info_array), - 0 # Force refresh + 0, # Force refresh ) if status == ADLStatus.OK: @@ -451,25 +465,29 @@ def _enumerate_displays(self): # Check if display is connected and active is_connected = bool(info.iDisplayInfoValue & 0x01) is_active = bool(info.iDisplayInfoValue & 0x02) - is_primary = (adapter.index == 0 and j == 0) + is_primary = adapter.index == 0 and j == 0 if is_connected: - self._displays.append(AMDDisplay( - adapter_index=adapter.index, - display_index=j, - logical_index=info.displayID[0], - name=info.strDisplayName.decode('utf-8', errors='ignore').strip(), - manufacturer=info.strDisplayManufacturerName.decode('utf-8', errors='ignore').strip(), - is_primary=is_primary, - is_connected=is_connected, - is_active=is_active, - resolution=(0, 0), - refresh_rate=60.0, - color_depth=8, - display_type=self._get_display_type(info.iDisplayType), - connector_type=self._get_connector_type(info.iDisplayConnector), - hdr_supported=False - )) + self._displays.append( + AMDDisplay( + adapter_index=adapter.index, + display_index=j, + logical_index=info.displayID[0], + name=info.strDisplayName.decode("utf-8", errors="ignore").strip(), + manufacturer=info.strDisplayManufacturerName.decode( + "utf-8", errors="ignore" + ).strip(), + is_primary=is_primary, + is_connected=is_connected, + is_active=is_active, + resolution=(0, 0), + refresh_rate=60.0, + color_depth=8, + display_type=self._get_display_type(info.iDisplayType), + connector_type=self._get_connector_type(info.iDisplayConnector), + hdr_supported=False, + ) + ) except (OSError, ctypes.ArgumentError): # Fall back to Windows enumeration @@ -498,22 +516,24 @@ class DISPLAY_DEVICE(Structure): if device.StateFlags & 0x00000001: # ACTIVE is_amd = any(x in device.DeviceID.lower() for x in ["amd", "ati", "radeon", "advanced micro"]) if is_amd: - self._displays.append(AMDDisplay( - adapter_index=0, - display_index=i, - logical_index=i, - name=device.DeviceString, - manufacturer="AMD", - is_primary=bool(device.StateFlags & 0x00000004), - is_connected=True, - is_active=True, - resolution=(0, 0), - refresh_rate=60.0, - color_depth=8, - display_type="Unknown", - connector_type="Unknown", - hdr_supported=False - )) + self._displays.append( + AMDDisplay( + adapter_index=0, + display_index=i, + logical_index=i, + name=device.DeviceString, + manufacturer="AMD", + is_primary=bool(device.StateFlags & 0x00000004), + is_connected=True, + is_active=True, + resolution=(0, 0), + refresh_rate=60.0, + color_depth=8, + display_type="Unknown", + connector_type="Unknown", + hdr_supported=False, + ) + ) i += 1 except OSError: @@ -600,11 +620,17 @@ def _get_color_via_adl(self, display_id: int) -> ColorSettings | None: try: # Get brightness - if hasattr(self._adl, 'ADL_Display_Color_Get'): + if hasattr(self._adl, "ADL_Display_Color_Get"): self._adl.ADL_Display_Color_Get.restype = c_int self._adl.ADL_Display_Color_Get.argtypes = [ - c_int, c_int, c_int, - POINTER(c_int), POINTER(c_int), POINTER(c_int), POINTER(c_int), POINTER(c_int) + c_int, + c_int, + c_int, + POINTER(c_int), + POINTER(c_int), + POINTER(c_int), + POINTER(c_int), + POINTER(c_int), ] current = c_int(0) @@ -615,55 +641,81 @@ def _get_color_via_adl(self, display_id: int) -> ColorSettings | None: # Brightness status = self._adl.ADL_Display_Color_Get( - display.adapter_index, display.logical_index, ADLColorType.BRIGHTNESS, - byref(current), byref(default), byref(min_val), byref(max_val), byref(step) + display.adapter_index, + display.logical_index, + ADLColorType.BRIGHTNESS, + byref(current), + byref(default), + byref(min_val), + byref(max_val), + byref(step), ) if status == ADLStatus.OK: settings.brightness = current.value # Contrast status = self._adl.ADL_Display_Color_Get( - display.adapter_index, display.logical_index, ADLColorType.CONTRAST, - byref(current), byref(default), byref(min_val), byref(max_val), byref(step) + display.adapter_index, + display.logical_index, + ADLColorType.CONTRAST, + byref(current), + byref(default), + byref(min_val), + byref(max_val), + byref(step), ) if status == ADLStatus.OK: settings.contrast = current.value # Saturation status = self._adl.ADL_Display_Color_Get( - display.adapter_index, display.logical_index, ADLColorType.SATURATION, - byref(current), byref(default), byref(min_val), byref(max_val), byref(step) + display.adapter_index, + display.logical_index, + ADLColorType.SATURATION, + byref(current), + byref(default), + byref(min_val), + byref(max_val), + byref(step), ) if status == ADLStatus.OK: settings.saturation = current.value # Hue status = self._adl.ADL_Display_Color_Get( - display.adapter_index, display.logical_index, ADLColorType.HUE, - byref(current), byref(default), byref(min_val), byref(max_val), byref(step) + display.adapter_index, + display.logical_index, + ADLColorType.HUE, + byref(current), + byref(default), + byref(min_val), + byref(max_val), + byref(step), ) if status == ADLStatus.OK: settings.hue = current.value # Color Temperature status = self._adl.ADL_Display_Color_Get( - display.adapter_index, display.logical_index, ADLColorType.COLORTEMP, - byref(current), byref(default), byref(min_val), byref(max_val), byref(step) + display.adapter_index, + display.logical_index, + ADLColorType.COLORTEMP, + byref(current), + byref(default), + byref(min_val), + byref(max_val), + byref(step), ) if status == ADLStatus.OK: settings.color_temp = current.value # Get gamma - if hasattr(self._adl, 'ADL_Display_Gamma_Get'): + if hasattr(self._adl, "ADL_Display_Gamma_Get"): self._adl.ADL_Display_Gamma_Get.restype = c_int - self._adl.ADL_Display_Gamma_Get.argtypes = [ - c_int, c_int, POINTER(ADLGamma) - ] + self._adl.ADL_Display_Gamma_Get.argtypes = [c_int, c_int, POINTER(ADLGamma)] gamma = ADLGamma() - status = self._adl.ADL_Display_Gamma_Get( - display.adapter_index, display.logical_index, byref(gamma) - ) + status = self._adl.ADL_Display_Gamma_Get(display.adapter_index, display.logical_index, byref(gamma)) if status == ADLStatus.OK: # Average the RGB gamma values settings.gamma = (gamma.fRed + gamma.fGreen + gamma.fBlue) / 3.0 @@ -720,7 +772,7 @@ def set_color_settings( saturation: int | None = None, hue: int | None = None, color_temp: int | None = None, - gamma: float | None = None + gamma: float | None = None, ) -> bool: """ Set color settings for a display. @@ -738,14 +790,10 @@ def set_color_settings( True if any setting was applied """ # Try ADL first - adl_success = self._set_color_via_adl( - display_id, brightness, contrast, saturation, hue, color_temp, gamma - ) + adl_success = self._set_color_via_adl(display_id, brightness, contrast, saturation, hue, color_temp, gamma) # Also try registry for persistence - registry_success = self._set_color_via_registry( - display_id, brightness, contrast, saturation - ) + registry_success = self._set_color_via_registry(display_id, brightness, contrast, saturation) return adl_success or registry_success @@ -757,7 +805,7 @@ def _set_color_via_adl( saturation: int | None, hue: int | None, color_temp: int | None, - gamma: float | None + gamma: float | None, ) -> bool: """Set color via ADL.""" if not self._initialized: @@ -770,52 +818,53 @@ def _set_color_via_adl( success = False try: - if hasattr(self._adl, 'ADL_Display_Color_Set'): + if hasattr(self._adl, "ADL_Display_Color_Set"): self._adl.ADL_Display_Color_Set.restype = c_int self._adl.ADL_Display_Color_Set.argtypes = [c_int, c_int, c_int, c_int] if brightness is not None: status = self._adl.ADL_Display_Color_Set( - display.adapter_index, display.logical_index, - ADLColorType.BRIGHTNESS, max(0, min(100, brightness)) + display.adapter_index, + display.logical_index, + ADLColorType.BRIGHTNESS, + max(0, min(100, brightness)), ) if status == ADLStatus.OK: success = True if contrast is not None: status = self._adl.ADL_Display_Color_Set( - display.adapter_index, display.logical_index, - ADLColorType.CONTRAST, max(0, min(100, contrast)) + display.adapter_index, display.logical_index, ADLColorType.CONTRAST, max(0, min(100, contrast)) ) if status == ADLStatus.OK: success = True if saturation is not None: status = self._adl.ADL_Display_Color_Set( - display.adapter_index, display.logical_index, - ADLColorType.SATURATION, max(0, min(200, saturation)) + display.adapter_index, + display.logical_index, + ADLColorType.SATURATION, + max(0, min(200, saturation)), ) if status == ADLStatus.OK: success = True if hue is not None: status = self._adl.ADL_Display_Color_Set( - display.adapter_index, display.logical_index, - ADLColorType.HUE, hue % 360 + display.adapter_index, display.logical_index, ADLColorType.HUE, hue % 360 ) if status == ADLStatus.OK: success = True if color_temp is not None: status = self._adl.ADL_Display_Color_Set( - display.adapter_index, display.logical_index, - ADLColorType.COLORTEMP, color_temp + display.adapter_index, display.logical_index, ADLColorType.COLORTEMP, color_temp ) if status == ADLStatus.OK: success = True # Set gamma separately - if gamma is not None and hasattr(self._adl, 'ADL_Display_Gamma_Set'): + if gamma is not None and hasattr(self._adl, "ADL_Display_Gamma_Set"): self._adl.ADL_Display_Gamma_Set.restype = c_int self._adl.ADL_Display_Gamma_Set.argtypes = [c_int, c_int, POINTER(ADLGamma)] @@ -837,11 +886,7 @@ def _set_color_via_adl( return False def _set_color_via_registry( - self, - display_id: int, - brightness: int | None, - contrast: int | None, - saturation: int | None + self, display_id: int, brightness: int | None, contrast: int | None, saturation: int | None ) -> bool: """Set color via Radeon Software registry (for persistence).""" try: @@ -849,8 +894,7 @@ def _set_color_via_registry( try: with winreg.OpenKey( - winreg.HKEY_LOCAL_MACHINE, key_path, 0, - winreg.KEY_SET_VALUE | winreg.KEY_WOW64_64KEY + winreg.HKEY_LOCAL_MACHINE, key_path, 0, winreg.KEY_SET_VALUE | winreg.KEY_WOW64_64KEY ) as key: if brightness is not None: winreg.SetValueEx(key, "Brightness", 0, winreg.REG_DWORD, brightness) @@ -876,26 +920,14 @@ def _set_color_via_registry( def reset_color_settings(self, display_id: int = 0) -> bool: """Reset color settings to defaults.""" return self.set_color_settings( - display_id, - brightness=50, - contrast=50, - saturation=100, - hue=0, - color_temp=6500, - gamma=1.0 + display_id, brightness=50, contrast=50, saturation=100, hue=0, color_temp=6500, gamma=1.0 ) # ========================================================================= # Gamma Ramp Methods # ========================================================================= - def load_gamma_ramp( - self, - display_id: int, - red: np.ndarray, - green: np.ndarray, - blue: np.ndarray - ) -> bool: + def load_gamma_ramp(self, display_id: int, red: np.ndarray, green: np.ndarray, blue: np.ndarray) -> bool: """ Load gamma ramp (1D LUT) via Windows API. @@ -925,12 +957,7 @@ def load_gamma_ramp( # 3D LUT Methods # ========================================================================= - def load_3d_lut( - self, - display_id: int, - lut_data: np.ndarray, - interpolation: str = "tetrahedral" - ) -> bool: + def load_3d_lut(self, display_id: int, lut_data: np.ndarray, interpolation: str = "tetrahedral") -> bool: """ Load 3D LUT to GPU. @@ -967,10 +994,10 @@ def load_3d_lut( adl_success = self.set_color_settings( display_id, - brightness=adjustments.get('brightness'), - contrast=adjustments.get('contrast'), - saturation=adjustments.get('saturation'), - gamma=adjustments.get('gamma') + brightness=adjustments.get("brightness"), + contrast=adjustments.get("contrast"), + saturation=adjustments.get("saturation"), + gamma=adjustments.get("gamma"), ) if adl_success: @@ -1022,12 +1049,12 @@ def _analyze_lut_adjustments(self, lut_data: np.ndarray) -> dict: # Estimate brightness (offset at black point) black_offset = avg_response[0] brightness = int(50 + black_offset * 50) # ADL uses 0-100 range - adjustments['brightness'] = max(0, min(100, brightness)) + adjustments["brightness"] = max(0, min(100, brightness)) # Estimate contrast actual_range = avg_response[-1] - avg_response[0] contrast = int(50 * actual_range / 0.5) if actual_range < 1 else 50 - adjustments['contrast'] = max(0, min(100, contrast)) + adjustments["contrast"] = max(0, min(100, contrast)) # Estimate gamma mid_idx = size // 2 @@ -1035,9 +1062,9 @@ def _analyze_lut_adjustments(self, lut_data: np.ndarray) -> dict: mid_expected = expected[mid_idx] if mid_expected > 0 and mid_actual > 0: gamma_ratio = np.log(mid_actual) / np.log(mid_expected) if mid_expected < 1 else 1.0 - adjustments['gamma'] = max(0.4, min(2.8, gamma_ratio)) + adjustments["gamma"] = max(0.4, min(2.8, gamma_ratio)) else: - adjustments['gamma'] = 1.0 + adjustments["gamma"] = 1.0 # Estimate saturation # Check color separation at saturated points @@ -1047,15 +1074,11 @@ def _analyze_lut_adjustments(self, lut_data: np.ndarray) -> dict: avg_sat = (np.max(red_sat) + np.max(green_sat) + np.max(blue_sat)) / 3 saturation = int(100 * (avg_sat + 1)) - adjustments['saturation'] = max(0, min(200, saturation)) + adjustments["saturation"] = max(0, min(200, saturation)) return adjustments - def load_lut_file( - self, - display_id: int, - lut_path: str | Path - ) -> bool: + def load_lut_file(self, display_id: int, lut_path: str | Path) -> bool: """ Load 3D LUT from file. @@ -1094,11 +1117,7 @@ def get_active_lut(self, display_id: int) -> np.ndarray | None: # Color Depth and Format # ========================================================================= - def set_color_depth( - self, - display_id: int, - depth: ADLColorDepth - ) -> bool: + def set_color_depth(self, display_id: int, depth: ADLColorDepth) -> bool: """ Set display color depth. @@ -1118,13 +1137,11 @@ def set_color_depth( display = self._displays[display_id] try: - if hasattr(self._adl, 'ADL_Display_ColorDepth_Set'): + if hasattr(self._adl, "ADL_Display_ColorDepth_Set"): self._adl.ADL_Display_ColorDepth_Set.restype = c_int self._adl.ADL_Display_ColorDepth_Set.argtypes = [c_int, c_int, c_int] - status = self._adl.ADL_Display_ColorDepth_Set( - display.adapter_index, display.logical_index, depth.value - ) + status = self._adl.ADL_Display_ColorDepth_Set(display.adapter_index, display.logical_index, depth.value) return status == ADLStatus.OK except (OSError, ctypes.ArgumentError): @@ -1143,10 +1160,14 @@ def get_color_depth(self, display_id: int = 0) -> int | None: display = self._displays[display_id] try: - if hasattr(self._adl, 'ADL_Display_ColorDepth_Get'): + if hasattr(self._adl, "ADL_Display_ColorDepth_Get"): self._adl.ADL_Display_ColorDepth_Get.restype = c_int self._adl.ADL_Display_ColorDepth_Get.argtypes = [ - c_int, c_int, POINTER(c_int), POINTER(c_int), POINTER(c_int) + c_int, + c_int, + POINTER(c_int), + POINTER(c_int), + POINTER(c_int), ] current = c_int(0) @@ -1154,8 +1175,7 @@ def get_color_depth(self, display_id: int = 0) -> int | None: supported = c_int(0) status = self._adl.ADL_Display_ColorDepth_Get( - display.adapter_index, display.logical_index, - byref(current), byref(default), byref(supported) + display.adapter_index, display.logical_index, byref(current), byref(default), byref(supported) ) if status == ADLStatus.OK: @@ -1173,34 +1193,34 @@ def get_color_depth(self, display_id: int = 0) -> int | None: def get_info(self) -> dict: """Get AMD API information.""" return { - 'available': self._initialized, - 'adapter_count': len(self._adapters), - 'display_count': len(self._displays), - 'adapters': [ + "available": self._initialized, + "adapter_count": len(self._adapters), + "display_count": len(self._displays), + "adapters": [ { - 'index': a.index, - 'name': a.name, - 'active': a.is_active, + "index": a.index, + "name": a.name, + "active": a.is_active, } for a in self._adapters ], - 'displays': [ + "displays": [ { - 'id': d.display_index, - 'name': d.name, - 'manufacturer': d.manufacturer, - 'primary': d.is_primary, - 'active': d.is_active, - 'connector': d.connector_type, - 'hdr': d.hdr_supported, + "id": d.display_index, + "name": d.name, + "manufacturer": d.manufacturer, + "primary": d.is_primary, + "active": d.is_active, + "connector": d.connector_type, + "hdr": d.hdr_supported, } for d in self._displays ], - 'features': { - 'color_control': self._initialized and hasattr(self._adl, 'ADL_Display_Color_Set'), - 'gamma_control': self._initialized and hasattr(self._adl, 'ADL_Display_Gamma_Set'), - 'color_depth': self._initialized and hasattr(self._adl, 'ADL_Display_ColorDepth_Set'), - } + "features": { + "color_control": self._initialized and hasattr(self._adl, "ADL_Display_Color_Set"), + "gamma_control": self._initialized and hasattr(self._adl, "ADL_Display_Gamma_Set"), + "color_depth": self._initialized and hasattr(self._adl, "ADL_Display_ColorDepth_Set"), + }, } def cleanup(self): @@ -1208,10 +1228,10 @@ def cleanup(self): if self._initialized: try: if self._adl2 and self._context: - if hasattr(self._adl, 'ADL2_Main_Control_Destroy'): + if hasattr(self._adl, "ADL2_Main_Control_Destroy"): self._adl.ADL2_Main_Control_Destroy(self._context) elif self._adl: - if hasattr(self._adl, 'ADL_Main_Control_Destroy'): + if hasattr(self._adl, "ADL_Main_Control_Destroy"): self._adl.ADL_Main_Control_Destroy() except (OSError, ctypes.ArgumentError): pass @@ -1227,6 +1247,7 @@ def __del__(self): # Convenience Functions # ============================================================================= + def check_amd_available() -> bool: """Check if AMD GPU is available.""" api = AMDAPI() @@ -1243,10 +1264,7 @@ def get_amd_info() -> dict: return info -def apply_amd_lut( - lut_data: np.ndarray, - display_id: int = 0 -) -> tuple[bool, str]: +def apply_amd_lut(lut_data: np.ndarray, display_id: int = 0) -> tuple[bool, str]: """ Apply 3D LUT via AMD API. @@ -1274,10 +1292,7 @@ def apply_amd_lut( return False, f"Error: {e}" -def apply_amd_lut_file( - lut_path: str | Path, - display_id: int = 0 -) -> tuple[bool, str]: +def apply_amd_lut_file(lut_path: str | Path, display_id: int = 0) -> tuple[bool, str]: """ Apply 3D LUT file via AMD API. @@ -1310,7 +1325,7 @@ def set_amd_color( brightness: int | None = None, contrast: int | None = None, saturation: int | None = None, - gamma: float | None = None + gamma: float | None = None, ) -> tuple[bool, str]: """ Set AMD display color settings. @@ -1332,11 +1347,7 @@ def set_amd_color( try: success = api.set_color_settings( - display_id, - brightness=brightness, - contrast=contrast, - saturation=saturation, - gamma=gamma + display_id, brightness=brightness, contrast=contrast, saturation=saturation, gamma=gamma ) if success: return True, "Color settings applied" diff --git a/calibrate_pro/lut_system/color_loader.py b/calibrate_pro/lut_system/color_loader.py index b5eab63..5cd5a64 100644 --- a/calibrate_pro/lut_system/color_loader.py +++ b/calibrate_pro/lut_system/color_loader.py @@ -34,6 +34,7 @@ class LoaderStatus(Enum): """Color loader status.""" + STOPPED = "stopped" RUNNING = "running" PAUSED = "paused" @@ -43,6 +44,7 @@ class LoaderStatus(Enum): @dataclass class DisplayCalibration: """Calibration data for a single display.""" + display_id: int device_name: str friendly_name: str @@ -56,15 +58,17 @@ class DisplayCalibration: @dataclass class LoaderConfig: """Color loader configuration.""" + refresh_interval: float = 5.0 # Seconds between re-applications - force_override: bool = True # Override other color management - apply_on_start: bool = True # Apply immediately on start + force_override: bool = True # Override other color management + apply_on_start: bool = True # Apply immediately on start persist_across_restart: bool = True # Save config for system restart config_file: str = "" class GammaRamp(ctypes.Structure): """Windows GAMMARAMP structure.""" + _fields_ = [ ("Red", wintypes.WORD * 256), ("Green", wintypes.WORD * 256), @@ -91,10 +95,8 @@ def __init__(self, config: LoaderConfig | None = None): # Set default config path if not self.config.config_file: - app_data = os.environ.get('APPDATA', os.path.expanduser('~')) - self.config.config_file = os.path.join( - app_data, 'CalibratePro', 'color_loader.json' - ) + app_data = os.environ.get("APPDATA", os.path.expanduser("~")) + self.config.config_file = os.path.join(app_data, "CalibratePro", "color_loader.json") # Load saved config if self.config.persist_across_restart: @@ -125,13 +127,15 @@ class DISPLAY_DEVICE(ctypes.Structure): monitor.cb = ctypes.sizeof(monitor) user32.EnumDisplayDevicesW(device.DeviceName, 0, ctypes.byref(monitor), 0) - displays.append({ - 'id': i, - 'device_name': device.DeviceName, - 'adapter': device.DeviceString, - 'monitor': monitor.DeviceString if monitor.DeviceString else "Unknown", - 'primary': bool(device.StateFlags & 0x00000004), - }) + displays.append( + { + "id": i, + "device_name": device.DeviceName, + "adapter": device.DeviceString, + "monitor": monitor.DeviceString if monitor.DeviceString else "Unknown", + "primary": bool(device.StateFlags & 0x00000004), + } + ) i += 1 return displays @@ -154,6 +158,7 @@ def load_lut_file(self, display_id: int, lut_path: str) -> bool: try: # Load LUT and extract 1D gamma ramp from calibrate_pro.lut_system import load_lut + lut = load_lut(lut_path) # Extract gamma ramp from LUT diagonal @@ -170,11 +175,11 @@ def load_lut_file(self, display_id: int, lut_path: str) -> bool: with self._lock: self.calibrations[display_id] = DisplayCalibration( display_id=display_id, - device_name=display['device_name'], - friendly_name=display['monitor'], + device_name=display["device_name"], + friendly_name=display["monitor"], lut_file=str(lut_path), gamma_ramp=gamma_ramp, - enabled=True + enabled=True, ) # Apply immediately @@ -230,11 +235,11 @@ def load_icc_profile(self, display_id: int, icc_path: str) -> bool: with self._lock: self.calibrations[display_id] = DisplayCalibration( display_id=display_id, - device_name=display['device_name'], - friendly_name=display['monitor'], + device_name=display["device_name"], + friendly_name=display["monitor"], icc_profile=str(icc_path), gamma_ramp=gamma_ramp, - enabled=True + enabled=True, ) # Apply immediately @@ -250,13 +255,7 @@ def load_icc_profile(self, display_id: int, icc_path: str) -> bool: print(f"Error loading ICC profile: {e}") return False - def set_gamma_ramp( - self, - display_id: int, - red: np.ndarray, - green: np.ndarray, - blue: np.ndarray - ) -> bool: + def set_gamma_ramp(self, display_id: int, red: np.ndarray, green: np.ndarray, blue: np.ndarray) -> bool: """ Set custom gamma ramp for a display. @@ -283,10 +282,10 @@ def set_gamma_ramp( with self._lock: self.calibrations[display_id] = DisplayCalibration( display_id=display_id, - device_name=display['device_name'], - friendly_name=display['monitor'], + device_name=display["device_name"], + friendly_name=display["monitor"], gamma_ramp=gamma_ramp, - enabled=True + enabled=True, ) return self._apply_calibration(display_id) @@ -307,7 +306,7 @@ def start(self): self._thread = threading.Thread(target=self._run_loop, daemon=True) self._thread.start() - self._notify_callbacks('started') + self._notify_callbacks("started") def stop(self): """Stop the color loader service.""" @@ -321,19 +320,19 @@ def stop(self): self._thread.join(timeout=2.0) self._thread = None - self._notify_callbacks('stopped') + self._notify_callbacks("stopped") def pause(self): """Pause color loading (keeps thread running but doesn't apply).""" self.status = LoaderStatus.PAUSED - self._notify_callbacks('paused') + self._notify_callbacks("paused") def resume(self): """Resume color loading after pause.""" if self.status == LoaderStatus.PAUSED: self.status = LoaderStatus.RUNNING self.apply_all() - self._notify_callbacks('resumed') + self._notify_callbacks("resumed") def apply_all(self) -> dict[int, bool]: """Apply all calibrations immediately.""" @@ -376,19 +375,19 @@ def add_callback(self, callback: Callable): def get_status(self) -> dict: """Get current loader status.""" return { - 'status': self.status.value, - 'displays': len(self.calibrations), - 'calibrations': { + "status": self.status.value, + "displays": len(self.calibrations), + "calibrations": { k: { - 'device': v.device_name, - 'name': v.friendly_name, - 'enabled': v.enabled, - 'icc': v.icc_profile, - 'lut': v.lut_file, - 'last_applied': v.last_applied + "device": v.device_name, + "name": v.friendly_name, + "enabled": v.enabled, + "icc": v.icc_profile, + "lut": v.lut_file, + "last_applied": v.last_applied, } for k, v in self.calibrations.items() - } + }, } # ------------------------------------------------------------------------- @@ -423,7 +422,7 @@ def _apply_gamma_ramp_raw(self, display_id: int, gamma_ramp: np.ndarray) -> bool if display_id >= len(displays): return False - device_name = displays[display_id]['device_name'] + device_name = displays[display_id]["device_name"] # Create DC for display hdc = gdi32.CreateDCW(device_name, device_name, None, None) @@ -476,16 +475,16 @@ def _extract_vcgt(self, icc_path: Path) -> np.ndarray | None: data = icc_path.read_bytes() # Find tag table - tag_count = struct.unpack('>I', data[128:132])[0] + tag_count = struct.unpack(">I", data[128:132])[0] for i in range(tag_count): offset = 132 + i * 12 - tag_sig = data[offset:offset+4] - tag_offset = struct.unpack('>I', data[offset+4:offset+8])[0] - tag_size = struct.unpack('>I', data[offset+8:offset+12])[0] + tag_sig = data[offset : offset + 4] + tag_offset = struct.unpack(">I", data[offset + 4 : offset + 8])[0] + tag_size = struct.unpack(">I", data[offset + 8 : offset + 12])[0] - if tag_sig == b'vcgt': - return self._parse_vcgt(data[tag_offset:tag_offset+tag_size]) + if tag_sig == b"vcgt": + return self._parse_vcgt(data[tag_offset : tag_offset + tag_size]) return None @@ -496,16 +495,16 @@ def _parse_vcgt(self, vcgt_data: bytes) -> np.ndarray | None: """Parse VCGT tag data.""" try: # VCGT type signature - if vcgt_data[0:4] != b'vcgt': + if vcgt_data[0:4] != b"vcgt": return None - gamma_type = struct.unpack('>I', vcgt_data[8:12])[0] + gamma_type = struct.unpack(">I", vcgt_data[8:12])[0] if gamma_type == 0: # Table type - channels = struct.unpack('>H', vcgt_data[12:14])[0] - entry_count = struct.unpack('>H', vcgt_data[14:16])[0] - entry_size = struct.unpack('>H', vcgt_data[16:18])[0] + channels = struct.unpack(">H", vcgt_data[12:14])[0] + entry_count = struct.unpack(">H", vcgt_data[14:16])[0] + entry_size = struct.unpack(">H", vcgt_data[16:18])[0] gamma_ramp = np.zeros((256, 3), dtype=np.uint16) offset = 18 @@ -513,7 +512,7 @@ def _parse_vcgt(self, vcgt_data: bytes) -> np.ndarray | None: for c in range(min(channels, 3)): for i in range(entry_count): if entry_size == 2: - value = struct.unpack('>H', vcgt_data[offset:offset+2])[0] + value = struct.unpack(">H", vcgt_data[offset : offset + 2])[0] offset += 2 else: value = vcgt_data[offset] * 257 @@ -534,13 +533,13 @@ def _parse_vcgt(self, vcgt_data: bytes) -> np.ndarray | None: for c in range(3): offset = 12 + c * 12 - gamma = struct.unpack('>I', vcgt_data[offset:offset+4])[0] / 65536.0 - min_val = struct.unpack('>I', vcgt_data[offset+4:offset+8])[0] / 65536.0 - max_val = struct.unpack('>I', vcgt_data[offset+8:offset+12])[0] / 65536.0 + gamma = struct.unpack(">I", vcgt_data[offset : offset + 4])[0] / 65536.0 + min_val = struct.unpack(">I", vcgt_data[offset + 4 : offset + 8])[0] / 65536.0 + max_val = struct.unpack(">I", vcgt_data[offset + 8 : offset + 12])[0] / 65536.0 for i in range(256): x = i / 255.0 - y = min_val + (max_val - min_val) * (x ** gamma) + y = min_val + (max_val - min_val) * (x**gamma) gamma_ramp[i, c] = int(np.clip(y, 0, 1) * 65535) return gamma_ramp @@ -556,20 +555,20 @@ def _extract_trc(self, icc_path: Path) -> np.ndarray | None: data = icc_path.read_bytes() # Find tag table - tag_count = struct.unpack('>I', data[128:132])[0] + tag_count = struct.unpack(">I", data[128:132])[0] curves = {} - trc_tags = {b'rTRC': 0, b'gTRC': 1, b'bTRC': 2} + trc_tags = {b"rTRC": 0, b"gTRC": 1, b"bTRC": 2} for i in range(tag_count): offset = 132 + i * 12 - tag_sig = data[offset:offset+4] - tag_offset = struct.unpack('>I', data[offset+4:offset+8])[0] - tag_size = struct.unpack('>I', data[offset+8:offset+12])[0] + tag_sig = data[offset : offset + 4] + tag_offset = struct.unpack(">I", data[offset + 4 : offset + 8])[0] + tag_size = struct.unpack(">I", data[offset + 8 : offset + 12])[0] if tag_sig in trc_tags: channel = trc_tags[tag_sig] - curves[channel] = self._parse_trc(data[tag_offset:tag_offset+tag_size]) + curves[channel] = self._parse_trc(data[tag_offset : tag_offset + tag_size]) if len(curves) < 3: return None @@ -589,8 +588,8 @@ def _parse_trc(self, trc_data: bytes) -> np.ndarray | None: try: type_sig = trc_data[0:4] - if type_sig == b'curv': - count = struct.unpack('>I', trc_data[8:12])[0] + if type_sig == b"curv": + count = struct.unpack(">I", trc_data[8:12])[0] if count == 0: # Identity @@ -598,14 +597,14 @@ def _parse_trc(self, trc_data: bytes) -> np.ndarray | None: elif count == 1: # Gamma value - gamma = struct.unpack('>H', trc_data[12:14])[0] / 256.0 + gamma = struct.unpack(">H", trc_data[12:14])[0] / 256.0 return np.array([int((i / 255.0) ** gamma * 65535) for i in range(256)], dtype=np.uint16) else: # Table curve = np.zeros(256, dtype=np.uint16) for i in range(min(count, 256)): - value = struct.unpack('>H', trc_data[12+i*2:14+i*2])[0] + value = struct.unpack(">H", trc_data[12 + i * 2 : 14 + i * 2])[0] if count == 256: curve[i] = value else: @@ -613,10 +612,10 @@ def _parse_trc(self, trc_data: bytes) -> np.ndarray | None: curve[idx] = value return curve - elif type_sig == b'para': + elif type_sig == b"para": # Parametric curve - struct.unpack('>H', trc_data[8:10])[0] - gamma = struct.unpack('>I', trc_data[12:16])[0] / 65536.0 + struct.unpack(">H", trc_data[8:10])[0] + gamma = struct.unpack(">I", trc_data[12:16])[0] / 65536.0 # Simple gamma for now return np.array([int((i / 255.0) ** gamma * 65535) for i in range(256)], dtype=np.uint16) @@ -633,18 +632,18 @@ def _save_config(self): config_path.parent.mkdir(parents=True, exist_ok=True) data = { - 'refresh_interval': self.config.refresh_interval, - 'force_override': self.config.force_override, - 'calibrations': {} + "refresh_interval": self.config.refresh_interval, + "force_override": self.config.force_override, + "calibrations": {}, } for display_id, cal in self.calibrations.items(): - data['calibrations'][str(display_id)] = { - 'device_name': cal.device_name, - 'friendly_name': cal.friendly_name, - 'icc_profile': cal.icc_profile, - 'lut_file': cal.lut_file, - 'enabled': cal.enabled + data["calibrations"][str(display_id)] = { + "device_name": cal.device_name, + "friendly_name": cal.friendly_name, + "icc_profile": cal.icc_profile, + "lut_file": cal.lut_file, + "enabled": cal.enabled, } config_path.write_text(json.dumps(data, indent=2)) @@ -661,17 +660,17 @@ def _load_config(self): data = json.loads(config_path.read_text()) - self.config.refresh_interval = data.get('refresh_interval', 5.0) - self.config.force_override = data.get('force_override', True) + self.config.refresh_interval = data.get("refresh_interval", 5.0) + self.config.force_override = data.get("force_override", True) - for display_id_str, cal_data in data.get('calibrations', {}).items(): + for display_id_str, cal_data in data.get("calibrations", {}).items(): display_id = int(display_id_str) # Reload the LUT or profile - if cal_data.get('lut_file') and Path(cal_data['lut_file']).exists(): - self.load_lut_file(display_id, cal_data['lut_file']) - elif cal_data.get('icc_profile') and Path(cal_data['icc_profile']).exists(): - self.load_icc_profile(display_id, cal_data['icc_profile']) + if cal_data.get("lut_file") and Path(cal_data["lut_file"]).exists(): + self.load_lut_file(display_id, cal_data["lut_file"]) + elif cal_data.get("icc_profile") and Path(cal_data["icc_profile"]).exists(): + self.load_icc_profile(display_id, cal_data["icc_profile"]) except Exception as e: print(f"Error loading config: {e}") @@ -698,10 +697,7 @@ def get_color_loader() -> ColorLoader: def apply_calibration( - display_id: int = 0, - lut_path: str | None = None, - icc_path: str | None = None, - start_service: bool = True + display_id: int = 0, lut_path: str | None = None, icc_path: str | None = None, start_service: bool = True ) -> bool: """ Quick function to apply calibration and start the loader. diff --git a/calibrate_pro/lut_system/dwm_lut.py b/calibrate_pro/lut_system/dwm_lut.py index 8ead84c..0bba8e0 100644 --- a/calibrate_pro/lut_system/dwm_lut.py +++ b/calibrate_pro/lut_system/dwm_lut.py @@ -56,6 +56,7 @@ class ColorPipelineStage(Enum): """Color pipeline stages where LUT can be applied.""" + PRE_BLEND = "pre_blend" POST_BLEND = "post_blend" DISPLAY_OUTPUT = "output" @@ -63,6 +64,7 @@ class ColorPipelineStage(Enum): class LUTColorSpace(Enum): """Color space for LUT interpretation.""" + SRGB = "sRGB" SCRGB = "scRGB" HDR10 = "HDR10" @@ -71,6 +73,7 @@ class LUTColorSpace(Enum): class LUTType(Enum): """Type of LUT (SDR or HDR).""" + SDR = "sdr" HDR = "hdr" @@ -78,6 +81,7 @@ class LUTType(Enum): @dataclass class MonitorInfo: """Information about a connected monitor.""" + device_name: str friendly_name: str left: int @@ -94,6 +98,7 @@ class MonitorInfo: @dataclass class DisplayLUTInfo: """Information about LUT applied to a display.""" + display_id: int display_name: str lut_active: bool @@ -105,6 +110,7 @@ class DisplayLUTInfo: class DwmLutError(Exception): """DWM LUT operation error.""" + pass @@ -112,6 +118,7 @@ class DwmLutError(Exception): # PQ (ST.2084) EOTF Functions for HDR # ============================================================================= + def pq_eotf(E: np.ndarray) -> np.ndarray: """ PQ Electro-Optical Transfer Function (ST.2084). @@ -155,11 +162,7 @@ def srgb_eotf(V: np.ndarray) -> np.ndarray: Converts sRGB-encoded signal (0-1) to linear light (0-1). """ V = np.clip(V, 0, 1) - return np.where( - V <= 0.04045, - V / 12.92, - np.power((V + 0.055) / 1.055, 2.4) - ) + return np.where(V <= 0.04045, V / 12.92, np.power((V + 0.055) / 1.055, 2.4)) def srgb_oetf(L: np.ndarray) -> np.ndarray: @@ -168,11 +171,7 @@ def srgb_oetf(L: np.ndarray) -> np.ndarray: Converts linear light (0-1) to sRGB-encoded signal (0-1). """ L = np.clip(L, 0, 1) - return np.where( - L <= 0.0031308, - L * 12.92, - 1.055 * np.power(L, 1/2.4) - 0.055 - ) + return np.where(L <= 0.0031308, L * 12.92, 1.055 * np.power(L, 1 / 2.4) - 0.055) def bt1886_eotf(V: np.ndarray, gamma: float = 2.4, Lw: float = 1.0, Lb: float = 0.0) -> np.ndarray: @@ -186,8 +185,8 @@ def bt1886_eotf(V: np.ndarray, gamma: float = 2.4, Lw: float = 1.0, Lb: float = Lb: Black luminance (normalized) """ V = np.clip(V, 0, 1) - a = (Lw ** (1/gamma) - Lb ** (1/gamma)) ** gamma - b = Lb ** (1/gamma) / (Lw ** (1/gamma) - Lb ** (1/gamma)) + a = (Lw ** (1 / gamma) - Lb ** (1 / gamma)) ** gamma + b = Lb ** (1 / gamma) / (Lw ** (1 / gamma) - Lb ** (1 / gamma)) return a * np.power(np.maximum(V + b, 0), gamma) @@ -196,32 +195,24 @@ def bt1886_eotf(V: np.ndarray, gamma: float = 2.4, Lw: float = 1.0, Lb: float = # ============================================================================= # sRGB/BT.709 to XYZ (D65) -SRGB_TO_XYZ = np.array([ - [0.4124564, 0.3575761, 0.1804375], - [0.2126729, 0.7151522, 0.0721750], - [0.0193339, 0.1191920, 0.9503041] -]) +SRGB_TO_XYZ = np.array( + [[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]] +) # XYZ to sRGB/BT.709 (D65) -XYZ_TO_SRGB = np.array([ - [ 3.2404542, -1.5371385, -0.4985314], - [-0.9692660, 1.8760108, 0.0415560], - [ 0.0556434, -0.2040259, 1.0572252] -]) +XYZ_TO_SRGB = np.array( + [[3.2404542, -1.5371385, -0.4985314], [-0.9692660, 1.8760108, 0.0415560], [0.0556434, -0.2040259, 1.0572252]] +) # BT.2020 to XYZ (D65) -BT2020_TO_XYZ = np.array([ - [0.6369580, 0.1446169, 0.1688810], - [0.2627002, 0.6779981, 0.0593017], - [0.0000000, 0.0280727, 1.0609851] -]) +BT2020_TO_XYZ = np.array( + [[0.6369580, 0.1446169, 0.1688810], [0.2627002, 0.6779981, 0.0593017], [0.0000000, 0.0280727, 1.0609851]] +) # XYZ to BT.2020 (D65) -XYZ_TO_BT2020 = np.array([ - [ 1.7166512, -0.3556708, -0.2533663], - [-0.6666844, 1.6164812, 0.0157685], - [ 0.0176399, -0.0427706, 0.9421031] -]) +XYZ_TO_BT2020 = np.array( + [[1.7166512, -0.3556708, -0.2533663], [-0.6666844, 1.6164812, 0.0157685], [0.0176399, -0.0427706, 0.9421031]] +) # Direct sRGB to BT.2020 conversion SRGB_TO_BT2020 = XYZ_TO_BT2020 @ SRGB_TO_XYZ @@ -242,6 +233,7 @@ def apply_matrix(rgb: np.ndarray, matrix: np.ndarray) -> np.ndarray: # LUT Generation # ============================================================================= + def generate_identity_lut(size: int = 33) -> np.ndarray: """Generate identity (pass-through) 3D LUT.""" lut = np.zeros((size, size, size, 3), dtype=np.float32) @@ -378,6 +370,7 @@ def generate_sdr_calibration_lut( # .cube File Format # ============================================================================= + def write_cube_file(path: Path, lut: np.ndarray, title: str = "Calibration LUT") -> None: """ Write 3D LUT to .cube file format. @@ -389,8 +382,8 @@ def write_cube_file(path: Path, lut: np.ndarray, title: str = "Calibration LUT") """ size = lut.shape[0] - with open(path, 'w') as f: - f.write(f"TITLE \"{title}\"\n") + with open(path, "w") as f: + f.write(f'TITLE "{title}"\n') f.write(f"LUT_3D_SIZE {size}\n") f.write("DOMAIN_MIN 0.0 0.0 0.0\n") f.write("DOMAIN_MAX 1.0 1.0 1.0\n") @@ -420,14 +413,14 @@ def read_cube_file(path: Path) -> tuple[np.ndarray, int]: with open(path) as f: for line in f: line = line.strip() - if not line or line.startswith('#'): + if not line or line.startswith("#"): continue - if line.startswith('TITLE'): + if line.startswith("TITLE"): continue - if line.startswith('LUT_3D_SIZE'): + if line.startswith("LUT_3D_SIZE"): size = int(line.split()[1]) continue - if line.startswith('DOMAIN'): + if line.startswith("DOMAIN"): continue # Parse RGB values @@ -442,7 +435,7 @@ def read_cube_file(path: Path) -> tuple[np.ndarray, int]: if size is None: # Try to infer size from data count count = len(data) - size = int(round(count ** (1/3))) + size = int(round(count ** (1 / 3))) # Reshape to 3D LUT lut = np.array(data, dtype=np.float32).reshape(size, size, size, 3) @@ -453,8 +446,10 @@ def read_cube_file(path: Path) -> tuple[np.ndarray, int]: # Monitor Detection and Position # ============================================================================= + class DEVMODE(ctypes.Structure): """Windows DEVMODE structure for display settings.""" + _fields_ = [ ("dmDeviceName", wintypes.WCHAR * 32), ("dmSpecVersion", wintypes.WORD), @@ -491,6 +486,7 @@ class DEVMODE(ctypes.Structure): class DISPLAY_DEVICE(ctypes.Structure): """Windows DISPLAY_DEVICE structure.""" + _fields_ = [ ("cb", wintypes.DWORD), ("DeviceName", wintypes.WCHAR * 32), @@ -541,7 +537,7 @@ def get_monitors() -> list[MonitorInfo]: height=height, is_primary=bool(device.StateFlags & DISPLAY_DEVICE_PRIMARY_DEVICE), is_hdr=is_hdr, - device_id=device.DeviceID + device_id=device.DeviceID, ) monitors.append(monitor) @@ -595,7 +591,7 @@ def get_lut_filename(monitor: MonitorInfo, lut_type: LUTType) -> str: def get_dwm_lut_directory() -> Path: """Get the dwm_lut LUT directory path.""" # dwm_lut looks for LUTs in %SYSTEMROOT%\Temp\luts - system_root = os.environ.get('SYSTEMROOT', 'C:\\Windows') + system_root = os.environ.get("SYSTEMROOT", "C:\\Windows") return Path(system_root) / "Temp" / "luts" @@ -603,6 +599,7 @@ def get_dwm_lut_directory() -> Path: # DWM LUT Controller # ============================================================================= + class DwmLutController: """ Controller for DWM-level 3D LUT application using ledoge/dwm_lut. @@ -641,15 +638,17 @@ def _find_dwm_lut(self) -> None: search_paths = [] # Frozen build: look next to the executable - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): search_paths.append(Path(sys.executable).parent / "dwm_lut") - search_paths.extend([ - Path(__file__).parent.parent.parent / "dwm_lut", # calibrate/dwm_lut - Path(__file__).parent / "bin", - Path("C:/Program Files/dwm_lut"), - Path.home() / "dwm_lut", - ]) + search_paths.extend( + [ + Path(__file__).parent.parent.parent / "dwm_lut", # calibrate/dwm_lut + Path(__file__).parent / "bin", + Path("C:/Program Files/dwm_lut"), + Path.home() / "dwm_lut", + ] + ) for path in search_paths: if path.exists() and (path / "DwmLutGUI.exe").exists(): @@ -658,6 +657,7 @@ def _find_dwm_lut(self) -> None: # Check PATH import shutil as sh + exe = sh.which("DwmLutGUI.exe") if exe: self._dwm_lut_path = Path(exe).parent @@ -668,9 +668,8 @@ def _ensure_lut_directory(self) -> None: self._lut_directory.mkdir(parents=True, exist_ok=True) except PermissionError: import logging - logging.getLogger(__name__).warning( - "Cannot create LUT directory %s (may need admin)", self._lut_directory - ) + + logging.getLogger(__name__).warning("Cannot create LUT directory %s (may need admin)", self._lut_directory) def _ensure_dwm_running(self) -> None: """Ensure DwmLutGUI is running after a LUT file is placed.""" @@ -682,9 +681,9 @@ def _ensure_dwm_running(self) -> None: self.start_dwm_lut_gui() except DwmLutError: import logging + logging.getLogger(__name__).warning( - "DwmLutGUI not running. LUT file placed but not active. " - "Start DwmLutGUI.exe manually as admin." + "DwmLutGUI not running. LUT file placed but not active. Start DwmLutGUI.exe manually as admin." ) @property @@ -729,7 +728,7 @@ def load_lut( monitor: int | MonitorInfo, lut_data: np.ndarray, lut_type: LUTType = LUTType.SDR, - title: str = "Calibration LUT" + title: str = "Calibration LUT", ) -> bool: """ Load a 3D LUT for a specific monitor. @@ -780,7 +779,7 @@ def load_lut( lut_path=str(lut_path), lut_size=size, color_space=LUTColorSpace.HDR10 if lut_type == LUTType.HDR else LUTColorSpace.SRGB, - lut_type=lut_type + lut_type=lut_type, ) # Ensure DwmLutGUI is running so the LUT is actually applied @@ -789,10 +788,7 @@ def load_lut( return True def load_lut_file( - self, - monitor: int | MonitorInfo, - source_path: str | Path, - lut_type: LUTType = LUTType.SDR + self, monitor: int | MonitorInfo, source_path: str | Path, lut_type: LUTType = LUTType.SDR ) -> bool: """ Load a LUT from file for a specific monitor. @@ -841,7 +837,7 @@ def load_lut_file( lut_path=str(dest_path), lut_size=size, color_space=LUTColorSpace.HDR10 if lut_type == LUTType.HDR else LUTColorSpace.SRGB, - lut_type=lut_type + lut_type=lut_type, ) # Ensure DwmLutGUI is running so the LUT is actually applied @@ -849,11 +845,7 @@ def load_lut_file( return True - def unload_lut( - self, - monitor: int | MonitorInfo, - lut_type: LUTType = LUTType.SDR - ) -> bool: + def unload_lut(self, monitor: int | MonitorInfo, lut_type: LUTType = LUTType.SDR) -> bool: """ Remove LUT from a monitor (restore identity). @@ -934,12 +926,17 @@ def start_dwm_lut_gui(self) -> bool: # Try elevated launch via ShellExecuteW (UAC prompt) import logging + logger = logging.getLogger(__name__) try: result = ctypes.windll.shell32.ShellExecuteW( - None, "runas", str(self.dwm_lut_exe), "", - str(self._dwm_lut_path), 0 # SW_HIDE + None, + "runas", + str(self.dwm_lut_exe), + "", + str(self._dwm_lut_path), + 0, # SW_HIDE ) # ShellExecuteW returns > 32 on success if result > 32: @@ -953,9 +950,7 @@ def start_dwm_lut_gui(self) -> bool: # Fallback: try without elevation (works if already admin) try: subprocess.Popen( - [str(self.dwm_lut_exe)], - cwd=str(self._dwm_lut_path), - creationflags=subprocess.CREATE_NO_WINDOW + [str(self.dwm_lut_exe)], cwd=str(self._dwm_lut_path), creationflags=subprocess.CREATE_NO_WINDOW ) time.sleep(1) if self._is_dwm_lut_running(): @@ -975,7 +970,7 @@ def stop_dwm_lut_gui(self) -> bool: subprocess.run( ["taskkill", "/F", "/IM", "DwmLutGUI.exe"], capture_output=True, - creationflags=subprocess.CREATE_NO_WINDOW + creationflags=subprocess.CREATE_NO_WINDOW, ) return True except (subprocess.SubprocessError, OSError): @@ -988,7 +983,7 @@ def _is_dwm_lut_running(self) -> bool: ["tasklist", "/FI", "IMAGENAME eq DwmLutGUI.exe"], capture_output=True, text=True, - creationflags=subprocess.CREATE_NO_WINDOW + creationflags=subprocess.CREATE_NO_WINDOW, ) return "DwmLutGUI.exe" in result.stdout except (subprocess.SubprocessError, OSError): @@ -1001,7 +996,7 @@ def apply_hdr_calibration( rgb_offsets: tuple[float, float, float] = (0.0, 0.0, 0.0), whitepoint: tuple[float, float, float] = (1.0, 1.0, 1.0), peak_luminance: float = 1000.0, - lut_size: int = 33 + lut_size: int = 33, ) -> bool: """ Apply HDR calibration to a monitor. @@ -1025,7 +1020,7 @@ def apply_hdr_calibration( target_whitepoint=whitepoint, rgb_gains=rgb_gains, rgb_offsets=rgb_offsets, - peak_luminance=peak_luminance + peak_luminance=peak_luminance, ) # Load LUT @@ -1038,7 +1033,7 @@ def apply_sdr_calibration( rgb_offsets: tuple[float, float, float] = (0.0, 0.0, 0.0), whitepoint: tuple[float, float, float] = (1.0, 1.0, 1.0), target_gamma: float = 2.2, - lut_size: int = 33 + lut_size: int = 33, ) -> bool: """ Apply SDR calibration to a monitor. @@ -1062,7 +1057,7 @@ def apply_sdr_calibration( target_gamma=target_gamma, target_whitepoint=whitepoint, rgb_gains=rgb_gains, - rgb_offsets=rgb_offsets + rgb_offsets=rgb_offsets, ) # Load LUT @@ -1073,6 +1068,7 @@ def apply_sdr_calibration( # Convenience Functions # ============================================================================= + def apply_lut(lut_path: str | Path, monitor_index: int = 0, lut_type: str = "sdr") -> bool: """ Quick function to apply a LUT file to a monitor. diff --git a/calibrate_pro/lut_system/intel_api.py b/calibrate_pro/lut_system/intel_api.py index c0699f6..a57d417 100644 --- a/calibrate_pro/lut_system/intel_api.py +++ b/calibrate_pro/lut_system/intel_api.py @@ -20,6 +20,7 @@ class CTLResult(IntEnum): """Intel Control Library result codes.""" + SUCCESS = 0 ERROR_UNKNOWN = -1 ERROR_NOT_INITIALIZED = -2 @@ -33,6 +34,7 @@ class CTLResult(IntEnum): @dataclass class IntelDisplay: """Intel display information.""" + adapter_handle: int display_handle: int name: str @@ -45,6 +47,7 @@ class IntelDisplay: class IntelAPIError(Exception): """Intel API error.""" + pass @@ -98,7 +101,7 @@ def _init_control_library(self): """Initialize the control library API.""" try: # ctlInit - Initialize the control library - if hasattr(self._ctl, 'ctlInit'): + if hasattr(self._ctl, "ctlInit"): init_args = ctypes.c_void_p() handle = ctypes.c_void_p() @@ -129,33 +132,27 @@ def _enumerate_displays(self): try: # ctlEnumerateDevices - if hasattr(self._ctl, 'ctlEnumerateDevices'): + if hasattr(self._ctl, "ctlEnumerateDevices"): device_count = ctypes.c_uint32() - self._ctl.ctlEnumerateDevices( - self._api_handle, - ctypes.byref(device_count), - None - ) + self._ctl.ctlEnumerateDevices(self._api_handle, ctypes.byref(device_count), None) # Get device handles devices = (ctypes.c_void_p * device_count.value)() - self._ctl.ctlEnumerateDevices( - self._api_handle, - ctypes.byref(device_count), - devices - ) + self._ctl.ctlEnumerateDevices(self._api_handle, ctypes.byref(device_count), devices) for i, device in enumerate(devices): - self._displays.append(IntelDisplay( - adapter_handle=int(device), - display_handle=i, - name=f"Intel Display {i}", - is_primary=(i == 0), - resolution=(0, 0), - refresh_rate=60.0, - is_hdr=False, - is_arc=False - )) + self._displays.append( + IntelDisplay( + adapter_handle=int(device), + display_handle=i, + name=f"Intel Display {i}", + is_primary=(i == 0), + resolution=(0, 0), + refresh_rate=60.0, + is_hdr=False, + is_arc=False, + ) + ) except Exception: self._detect_displays_windows() @@ -185,16 +182,18 @@ class DISPLAY_DEVICE(ctypes.Structure): # Check if Intel if any(x in device_id_lower for x in ["intel", "8086"]): is_arc = "arc" in device.DeviceString.lower() - self._displays.append(IntelDisplay( - adapter_handle=0, - display_handle=i, - name=device.DeviceString, - is_primary=bool(device.StateFlags & 0x00000004), - resolution=(0, 0), - refresh_rate=60.0, - is_hdr=False, - is_arc=is_arc - )) + self._displays.append( + IntelDisplay( + adapter_handle=0, + display_handle=i, + name=device.DeviceString, + is_primary=bool(device.StateFlags & 0x00000004), + resolution=(0, 0), + refresh_rate=60.0, + is_hdr=False, + is_arc=is_arc, + ) + ) i += 1 except Exception: @@ -210,13 +209,7 @@ def displays(self) -> list[IntelDisplay]: """Get list of Intel displays.""" return self._displays - def load_gamma_ramp( - self, - display_index: int, - r: np.ndarray, - g: np.ndarray, - b: np.ndarray - ) -> bool: + def load_gamma_ramp(self, display_index: int, r: np.ndarray, g: np.ndarray, b: np.ndarray) -> bool: """ Load gamma ramp. @@ -232,7 +225,7 @@ def load_gamma_ramp( try: # Try Intel API first - if self._ctl and hasattr(self._ctl, 'ctlSetGammaRamp'): + if self._ctl and hasattr(self._ctl, "ctlSetGammaRamp"): # Would use ctlSetGammaRamp or similar pass @@ -242,26 +235,17 @@ def load_gamma_ramp( except Exception: return False - def _apply_via_windows( - self, - display_id: int, - r: np.ndarray, - g: np.ndarray, - b: np.ndarray - ) -> bool: + def _apply_via_windows(self, display_id: int, r: np.ndarray, g: np.ndarray, b: np.ndarray) -> bool: """Apply via Windows gamma ramp.""" try: from calibrate_pro.lut_system.dwm_lut import GammaRampController + controller = GammaRampController() return controller.set_gamma_ramp(display_id, r, g, b) except Exception: return False - def load_3d_lut( - self, - display_index: int, - lut_data: np.ndarray - ) -> bool: + def load_3d_lut(self, display_index: int, lut_data: np.ndarray) -> bool: """ Load 3D LUT. @@ -288,17 +272,14 @@ def load_3d_lut( # Fall back to DWM LUT try: from calibrate_pro.lut_system.dwm_lut import DwmLutController + controller = DwmLutController() return controller.load_lut(display_index, lut_data) except Exception: return False def set_color_enhancement( - self, - display_index: int, - saturation: float = 1.0, - contrast: float = 1.0, - brightness: float = 0.0 + self, display_index: int, saturation: float = 1.0, contrast: float = 1.0, brightness: float = 0.0 ) -> bool: """ Set Intel display color enhancement. @@ -334,15 +315,9 @@ def get_gpu_info(self) -> dict: "display_count": len(self._displays), "has_arc": any(d.is_arc for d in self._displays), "displays": [ - { - "id": d.display_handle, - "name": d.name, - "primary": d.is_primary, - "arc": d.is_arc, - "hdr": d.is_hdr - } + {"id": d.display_handle, "name": d.name, "primary": d.is_primary, "arc": d.is_arc, "hdr": d.is_hdr} for d in self._displays - ] + ], } def reset_lut(self, display_index: int) -> bool: @@ -354,7 +329,7 @@ def cleanup(self): """Clean up Intel API resources.""" if self._initialized and self._ctl: try: - if hasattr(self._ctl, 'ctlClose') and self._api_handle: + if hasattr(self._ctl, "ctlClose") and self._api_handle: self._ctl.ctlClose(self._api_handle) except Exception: pass diff --git a/calibrate_pro/lut_system/lut_formats.py b/calibrate_pro/lut_system/lut_formats.py index 2db8d15..2f353c0 100644 --- a/calibrate_pro/lut_system/lut_formats.py +++ b/calibrate_pro/lut_system/lut_formats.py @@ -23,20 +23,22 @@ class LUTType(Enum): """LUT type enumeration.""" + LUT_1D = "1d" LUT_3D = "3d" class LUTFormat(Enum): """Supported LUT file formats.""" - CUBE = "cube" # DaVinci Resolve / Adobe - DL3 = "3dl" # Autodesk Lustre / Flame - MGA = "mga" # Pandora - CAL = "cal" # ArgyllCMS calibration - CSP = "csp" # Cinespace - SPI3D = "spi3d" # Sony Imageworks - CLF = "clf" # ACES Common LUT Format - ICC = "icc" # Embedded in ICC profile + + CUBE = "cube" # DaVinci Resolve / Adobe + DL3 = "3dl" # Autodesk Lustre / Flame + MGA = "mga" # Pandora + CAL = "cal" # ArgyllCMS calibration + CSP = "csp" # Cinespace + SPI3D = "spi3d" # Sony Imageworks + CLF = "clf" # ACES Common LUT Format + ICC = "icc" # Embedded in ICC profile @dataclass @@ -46,6 +48,7 @@ class LUT1D: Used for gamma/transfer function correction. """ + size: int data: np.ndarray # Shape: (size, 3) for RGB or (size,) for single channel title: str = "1D LUT" @@ -79,11 +82,11 @@ def apply(self, values: np.ndarray) -> np.ndarray: x = np.linspace(self.input_range[0], self.input_range[1], self.size) if self.data.ndim == 1: - interp = interp1d(x, self.data, kind='linear', bounds_error=False, fill_value='extrapolate') + interp = interp1d(x, self.data, kind="linear", bounds_error=False, fill_value="extrapolate") result = interp(values) else: for c in range(min(3, values.shape[-1] if values.ndim > 1 else 1)): - interp = interp1d(x, self.data[:, c], kind='linear', bounds_error=False, fill_value='extrapolate') + interp = interp1d(x, self.data[:, c], kind="linear", bounds_error=False, fill_value="extrapolate") if values.ndim == 1: result = interp(values) else: @@ -99,6 +102,7 @@ class LUT3D: Stores RGB-to-RGB color transformation as a 3D grid. """ + size: int data: np.ndarray # Shape: (size, size, size, 3) title: str = "3D LUT" @@ -110,7 +114,7 @@ class LUT3D: def create_identity(cls, size: int = 33) -> "LUT3D": """Create an identity (no-op) 3D LUT.""" coords = np.linspace(0, 1, size) - r, g, b = np.meshgrid(coords, coords, coords, indexing='ij') + r, g, b = np.meshgrid(coords, coords, coords, indexing="ij") data = np.stack([r, g, b], axis=-1).astype(np.float64) return cls(size=size, data=data) @@ -132,12 +136,7 @@ def apply(self, rgb: np.ndarray) -> np.ndarray: result = np.zeros_like(rgb) for c in range(3): - result[:, c] = map_coordinates( - self.data[:, :, :, c], - coords.T, - order=1, - mode='nearest' - ) + result[:, c] = map_coordinates(self.data[:, :, :, c], coords.T, order=1, mode="nearest") if len(original_shape) == 1: return result[0] @@ -157,8 +156,9 @@ def to_1d_approximation(self, size: int = 256) -> LUT1D: frac = idx - idx_floor # Interpolate along diagonal - val = (1 - frac) * self.data[idx_floor, idx_floor, idx_floor] + \ - frac * self.data[idx_ceil, idx_ceil, idx_ceil] + val = (1 - frac) * self.data[idx_floor, idx_floor, idx_floor] + frac * self.data[ + idx_ceil, idx_ceil, idx_ceil + ] lut1d.data[i] = val lut1d.title = f"{self.title} (1D approx)" @@ -178,13 +178,13 @@ def detect_format(filepath: Path) -> LUTFormat: suffix = filepath.suffix.lower() format_map = { - '.cube': LUTFormat.CUBE, - '.3dl': LUTFormat.DL3, - '.mga': LUTFormat.MGA, - '.cal': LUTFormat.CAL, - '.csp': LUTFormat.CSP, - '.spi3d': LUTFormat.SPI3D, - '.clf': LUTFormat.CLF, + ".cube": LUTFormat.CUBE, + ".3dl": LUTFormat.DL3, + ".mga": LUTFormat.MGA, + ".cal": LUTFormat.CAL, + ".csp": LUTFormat.CSP, + ".spi3d": LUTFormat.SPI3D, + ".clf": LUTFormat.CLF, } return format_map.get(suffix) @@ -231,40 +231,40 @@ def _read_cube(filepath: Path) -> LUT1D | LUT3D: values = [] comments = [] - with open(filepath, encoding='utf-8', errors='ignore') as f: + with open(filepath, encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() if not line: continue - if line.startswith('#'): + if line.startswith("#"): comments.append(line[1:].strip()) continue upper = line.upper() - if upper.startswith('TITLE'): + if upper.startswith("TITLE"): # Extract title from quotes or after space if '"' in line: title = line.split('"')[1] else: - title = line.split(maxsplit=1)[1] if ' ' in line else "" + title = line.split(maxsplit=1)[1] if " " in line else "" - elif upper.startswith('LUT_1D_SIZE'): + elif upper.startswith("LUT_1D_SIZE"): size_1d = int(line.split()[1]) - elif upper.startswith('LUT_3D_SIZE'): + elif upper.startswith("LUT_3D_SIZE"): size_3d = int(line.split()[1]) - elif upper.startswith('DOMAIN_MIN'): + elif upper.startswith("DOMAIN_MIN"): parts = line.split()[1:] domain_min = tuple(float(p) for p in parts[:3]) - elif upper.startswith('DOMAIN_MAX'): + elif upper.startswith("DOMAIN_MAX"): parts = line.split()[1:] domain_max = tuple(float(p) for p in parts[:3]) - elif line[0].lstrip('-').replace('.', '').isdigit(): + elif line[0].lstrip("-").replace(".", "").isdigit(): # Data line parts = line.split() if len(parts) >= 3: @@ -279,21 +279,18 @@ def _read_cube(filepath: Path) -> LUT1D | LUT3D: return LUT1D(size=size_1d, data=data, title=title) elif size_3d: - data = np.array(values[:size_3d**3]).reshape(size_3d, size_3d, size_3d, 3) + data = np.array(values[: size_3d**3]).reshape(size_3d, size_3d, size_3d, 3) return LUT3D( - size=size_3d, data=data, title=title, - domain_min=domain_min, domain_max=domain_max, - comments=comments + size=size_3d, data=data, title=title, domain_min=domain_min, domain_max=domain_max, comments=comments ) else: # Infer size from data n = len(values) - size = int(round(n ** (1/3))) - if size ** 3 == n: + size = int(round(n ** (1 / 3))) + if size**3 == n: data = np.array(values).reshape(size, size, size, 3) - return LUT3D(size=size, data=data, title=title, - domain_min=domain_min, domain_max=domain_max) + return LUT3D(size=size, data=data, title=title, domain_min=domain_min, domain_max=domain_max) else: data = np.array(values, dtype=np.float64) if data.ndim == 1: @@ -304,10 +301,10 @@ def _read_cube(filepath: Path) -> LUT1D | LUT3D: def _read_3dl(filepath: Path) -> LUT3D: """Read .3dl format (Autodesk Lustre/Flame).""" lines = [] - with open(filepath, encoding='utf-8', errors='ignore') as f: + with open(filepath, encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() - if line and not line.startswith('#'): + if line and not line.startswith("#"): lines.append(line) if not lines: @@ -351,10 +348,10 @@ def _read_3dl(filepath: Path) -> LUT3D: def _read_mga(filepath: Path) -> LUT3D: """Read .mga format (Pandora).""" lines = [] - with open(filepath, encoding='utf-8', errors='ignore') as f: + with open(filepath, encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() - if line and not line.startswith('#'): + if line and not line.startswith("#"): lines.append(line) if not lines: @@ -383,12 +380,12 @@ def _read_mga(filepath: Path) -> LUT3D: @staticmethod def _read_cal(filepath: Path) -> LUT1D: """Read .cal format (ArgyllCMS calibration curves).""" - with open(filepath, encoding='utf-8', errors='ignore') as f: + with open(filepath, encoding="utf-8", errors="ignore") as f: content = f.read() # Parse CAL format # Format: keyword value pairs and data section - lines = content.strip().split('\n') + lines = content.strip().split("\n") title = "ArgyllCMS Calibration" data = [] @@ -397,20 +394,20 @@ def _read_cal(filepath: Path) -> LUT1D: for line in lines: line = line.strip() - if not line or line.startswith('#'): + if not line or line.startswith("#"): continue - if line.upper().startswith('DESCRIPTOR'): + if line.upper().startswith("DESCRIPTOR"): title = line.split('"')[1] if '"' in line else "CAL LUT" - elif line.upper().startswith('NUMBER_OF_SETS') or line.upper().startswith('NUMBER_OF_FIELDS'): + elif line.upper().startswith("NUMBER_OF_SETS") or line.upper().startswith("NUMBER_OF_FIELDS"): int(line.split()[1]) - elif line.upper() == 'BEGIN_DATA': + elif line.upper() == "BEGIN_DATA": in_data = True continue - elif line.upper() == 'END_DATA': + elif line.upper() == "END_DATA": in_data = False continue @@ -437,7 +434,7 @@ def _read_cal(filepath: Path) -> LUT1D: @staticmethod def _read_csp(filepath: Path) -> LUT3D: """Read .csp format (Cinespace).""" - with open(filepath, encoding='utf-8', errors='ignore') as f: + with open(filepath, encoding="utf-8", errors="ignore") as f: lines = [line.strip() for line in f if line.strip()] idx = 0 @@ -448,17 +445,17 @@ def _read_csp(filepath: Path) -> LUT3D: # Skip header while idx < len(lines): line = lines[idx] - if line.upper() == 'CSPLUTV100': + if line.upper() == "CSPLUTV100": idx += 1 continue - elif line.upper() in ('1D', '3D'): + elif line.upper() in ("1D", "3D"): line.upper() idx += 1 continue - elif line.upper().startswith('BEGIN METADATA'): + elif line.upper().startswith("BEGIN METADATA"): idx += 1 - while idx < len(lines) and not lines[idx].upper().startswith('END METADATA'): - if 'TITLE' in lines[idx].upper(): + while idx < len(lines) and not lines[idx].upper().startswith("END METADATA"): + if "TITLE" in lines[idx].upper(): title = lines[idx].split('"')[1] if '"' in lines[idx] else "" idx += 1 idx += 1 @@ -469,7 +466,7 @@ def _read_csp(filepath: Path) -> LUT3D: int(lines[idx]) idx += 1 # Skip shaper lines - while idx < len(lines) and lines[idx] and lines[idx][0] != '\n': + while idx < len(lines) and lines[idx] and lines[idx][0] != "\n": parts = lines[idx].split() if len(parts) == 2: idx += 1 @@ -500,8 +497,8 @@ def _read_csp(filepath: Path) -> LUT3D: @staticmethod def _read_spi3d(filepath: Path) -> LUT3D: """Read .spi3d format (Sony Imageworks).""" - with open(filepath, encoding='utf-8', errors='ignore') as f: - lines = [line.strip() for line in f if line.strip() and not line.startswith('#')] + with open(filepath, encoding="utf-8", errors="ignore") as f: + lines = [line.strip() for line in f if line.strip() and not line.startswith("#")] # First line: "SPILUT 1.0" # Second line: "3 3" (dimensions) @@ -540,38 +537,38 @@ def _read_clf(filepath: Path) -> LUT3D: root = tree.getroot() # Handle namespace - ns = {'clf': 'urn:AMPAS:CLF:v3.0'} - if root.tag.startswith('{'): - ns_uri = root.tag.split('}')[0][1:] - ns = {'clf': ns_uri} + ns = {"clf": "urn:AMPAS:CLF:v3.0"} + if root.tag.startswith("{"): + ns_uri = root.tag.split("}")[0][1:] + ns = {"clf": ns_uri} title = "CLF LUT" # Find ProcessList - process_list = root.find('.//clf:ProcessList', ns) or root.find('.//ProcessList') + process_list = root.find(".//clf:ProcessList", ns) or root.find(".//ProcessList") if process_list is None: # Try without namespace - process_list = root.find('.//ProcessList') + process_list = root.find(".//ProcessList") if process_list is None: raise ValueError("No ProcessList found in CLF file") # Find LUT3D element - lut3d_elem = process_list.find('.//clf:LUT3D', ns) + lut3d_elem = process_list.find(".//clf:LUT3D", ns) if lut3d_elem is None: - lut3d_elem = process_list.find('.//LUT3D') + lut3d_elem = process_list.find(".//LUT3D") if lut3d_elem is None: raise ValueError("No LUT3D found in CLF file") # Get dimensions - grid_size = lut3d_elem.get('gridSize', '33 33 33') + grid_size = lut3d_elem.get("gridSize", "33 33 33") sizes = [int(x) for x in grid_size.split()] size = sizes[0] # Get array data - array_elem = lut3d_elem.find('.//clf:Array', ns) or lut3d_elem.find('.//Array') + array_elem = lut3d_elem.find(".//clf:Array", ns) or lut3d_elem.find(".//Array") if array_elem is not None: text = array_elem.text.strip() @@ -589,12 +586,7 @@ class LUTWriter: """ @classmethod - def write( - cls, - lut: LUT1D | LUT3D, - filepath: str | Path, - fmt: LUTFormat | None = None - ): + def write(cls, lut: LUT1D | LUT3D, filepath: str | Path, fmt: LUTFormat | None = None): """ Write LUT to file. @@ -633,52 +625,52 @@ def write( @staticmethod def _write_cube_1d(lut: LUT1D, filepath: Path): """Write 1D LUT in .cube format.""" - with open(filepath, 'w') as f: - f.write('# Calibrate Pro 1D LUT\n') + with open(filepath, "w") as f: + f.write("# Calibrate Pro 1D LUT\n") f.write(f'TITLE "{lut.title}"\n') - f.write(f'LUT_1D_SIZE {lut.size}\n') - f.write(f'DOMAIN_MIN {lut.input_range[0]:.6f} {lut.input_range[0]:.6f} {lut.input_range[0]:.6f}\n') - f.write(f'DOMAIN_MAX {lut.input_range[1]:.6f} {lut.input_range[1]:.6f} {lut.input_range[1]:.6f}\n') - f.write('\n') + f.write(f"LUT_1D_SIZE {lut.size}\n") + f.write(f"DOMAIN_MIN {lut.input_range[0]:.6f} {lut.input_range[0]:.6f} {lut.input_range[0]:.6f}\n") + f.write(f"DOMAIN_MAX {lut.input_range[1]:.6f} {lut.input_range[1]:.6f} {lut.input_range[1]:.6f}\n") + f.write("\n") for i in range(lut.size): if lut.data.ndim == 1: val = lut.data[i] - f.write(f'{val:.6f} {val:.6f} {val:.6f}\n') + f.write(f"{val:.6f} {val:.6f} {val:.6f}\n") else: - f.write(f'{lut.data[i, 0]:.6f} {lut.data[i, 1]:.6f} {lut.data[i, 2]:.6f}\n') + f.write(f"{lut.data[i, 0]:.6f} {lut.data[i, 1]:.6f} {lut.data[i, 2]:.6f}\n") @staticmethod def _write_cube_3d(lut: LUT3D, filepath: Path): """Write 3D LUT in .cube format.""" - with open(filepath, 'w') as f: - f.write('# Calibrate Pro 3D LUT\n') + with open(filepath, "w") as f: + f.write("# Calibrate Pro 3D LUT\n") for comment in lut.comments: - f.write(f'# {comment}\n') + f.write(f"# {comment}\n") f.write(f'TITLE "{lut.title}"\n') - f.write(f'LUT_3D_SIZE {lut.size}\n') - f.write(f'DOMAIN_MIN {lut.domain_min[0]:.6f} {lut.domain_min[1]:.6f} {lut.domain_min[2]:.6f}\n') - f.write(f'DOMAIN_MAX {lut.domain_max[0]:.6f} {lut.domain_max[1]:.6f} {lut.domain_max[2]:.6f}\n') - f.write('\n') + f.write(f"LUT_3D_SIZE {lut.size}\n") + f.write(f"DOMAIN_MIN {lut.domain_min[0]:.6f} {lut.domain_min[1]:.6f} {lut.domain_min[2]:.6f}\n") + f.write(f"DOMAIN_MAX {lut.domain_max[0]:.6f} {lut.domain_max[1]:.6f} {lut.domain_max[2]:.6f}\n") + f.write("\n") # Write in standard order (B changes fastest, then G, then R) for r in range(lut.size): for g in range(lut.size): for b in range(lut.size): val = lut.data[r, g, b] - f.write(f'{val[0]:.6f} {val[1]:.6f} {val[2]:.6f}\n') + f.write(f"{val[0]:.6f} {val[1]:.6f} {val[2]:.6f}\n") @staticmethod def _write_3dl(lut: LUT3D, filepath: Path): """Write 3D LUT in .3dl format (12-bit integers).""" max_val = 4095 # 12-bit - with open(filepath, 'w') as f: + with open(filepath, "w") as f: # Write input shaper for i in range(lut.size): val = int(i / (lut.size - 1) * max_val) - f.write(f'{val} ') - f.write('\n') + f.write(f"{val} ") + f.write("\n") # Write LUT data (BGR order) for b in range(lut.size): @@ -688,37 +680,37 @@ def _write_3dl(lut: LUT3D, filepath: Path): r_int = int(np.clip(val[0], 0, 1) * max_val) g_int = int(np.clip(val[1], 0, 1) * max_val) b_int = int(np.clip(val[2], 0, 1) * max_val) - f.write(f' {r_int} {g_int} {b_int}\n') + f.write(f" {r_int} {g_int} {b_int}\n") @staticmethod def _write_mga(lut: LUT3D, filepath: Path): """Write 3D LUT in .mga format (Pandora).""" - with open(filepath, 'w') as f: - f.write('LUT8\n') - f.write(f'{lut.size}\n') + with open(filepath, "w") as f: + f.write("LUT8\n") + f.write(f"{lut.size}\n") # BGR order for b in range(lut.size): for g in range(lut.size): for r in range(lut.size): val = lut.data[r, g, b] - f.write(f'{val[0]:.6f} {val[1]:.6f} {val[2]:.6f}\n') + f.write(f"{val[0]:.6f} {val[1]:.6f} {val[2]:.6f}\n") @staticmethod def _write_cal(lut: LUT1D, filepath: Path): """Write 1D LUT in .cal format (ArgyllCMS).""" - with open(filepath, 'w') as f: - f.write('CAL\n\n') + with open(filepath, "w") as f: + f.write("CAL\n\n") f.write(f'DESCRIPTOR "{lut.title}"\n') f.write('ORIGINATOR "Calibrate Pro"\n') f.write('DEVICE_CLASS "DISPLAY"\n') f.write('COLOR_REP "RGB"\n\n') - f.write('NUMBER_OF_FIELDS 4\n') - f.write('BEGIN_DATA_FORMAT\n') - f.write('RGB_I RGB_R RGB_G RGB_B\n') - f.write('END_DATA_FORMAT\n\n') - f.write(f'NUMBER_OF_SETS {lut.size}\n') - f.write('BEGIN_DATA\n') + f.write("NUMBER_OF_FIELDS 4\n") + f.write("BEGIN_DATA_FORMAT\n") + f.write("RGB_I RGB_R RGB_G RGB_B\n") + f.write("END_DATA_FORMAT\n\n") + f.write(f"NUMBER_OF_SETS {lut.size}\n") + f.write("BEGIN_DATA\n") for i in range(lut.size): t = i / (lut.size - 1) @@ -726,42 +718,42 @@ def _write_cal(lut: LUT1D, filepath: Path): r = g = b = lut.data[i] else: r, g, b = lut.data[i] - f.write(f'{t:.6f} {r:.6f} {g:.6f} {b:.6f}\n') + f.write(f"{t:.6f} {r:.6f} {g:.6f} {b:.6f}\n") - f.write('END_DATA\n') + f.write("END_DATA\n") @staticmethod def _write_csp(lut: LUT3D, filepath: Path): """Write 3D LUT in .csp format (Cinespace).""" - with open(filepath, 'w') as f: - f.write('CSPLUTV100\n') - f.write('3D\n\n') + with open(filepath, "w") as f: + f.write("CSPLUTV100\n") + f.write("3D\n\n") - f.write('BEGIN METADATA\n') + f.write("BEGIN METADATA\n") f.write(f'TITLE "{lut.title}"\n') - f.write('END METADATA\n\n') + f.write("END METADATA\n\n") # Identity shapers for RGB for _ in range(3): - f.write('2\n') - f.write('0.0 1.0\n') - f.write('0.0 1.0\n\n') + f.write("2\n") + f.write("0.0 1.0\n") + f.write("0.0 1.0\n\n") - f.write(f'{lut.size} {lut.size} {lut.size}\n') + f.write(f"{lut.size} {lut.size} {lut.size}\n") for b in range(lut.size): for g in range(lut.size): for r in range(lut.size): val = lut.data[r, g, b] - f.write(f'{val[0]:.6f} {val[1]:.6f} {val[2]:.6f}\n') + f.write(f"{val[0]:.6f} {val[1]:.6f} {val[2]:.6f}\n") @staticmethod def _write_spi3d(lut: LUT3D, filepath: Path): """Write 3D LUT in .spi3d format (Sony Imageworks).""" - with open(filepath, 'w') as f: - f.write('SPILUT 1.0\n') - f.write('3 3\n') - f.write(f'{lut.size} {lut.size} {lut.size}\n') + with open(filepath, "w") as f: + f.write("SPILUT 1.0\n") + f.write("3 3\n") + f.write(f"{lut.size} {lut.size} {lut.size}\n") for r in range(lut.size): for g in range(lut.size): @@ -770,70 +762,57 @@ def _write_spi3d(lut: LUT3D, filepath: Path): g_in = g / (lut.size - 1) b_in = b / (lut.size - 1) val = lut.data[r, g, b] - f.write(f'{r_in:.6f} {g_in:.6f} {b_in:.6f} {val[0]:.6f} {val[1]:.6f} {val[2]:.6f}\n') + f.write(f"{r_in:.6f} {g_in:.6f} {b_in:.6f} {val[0]:.6f} {val[1]:.6f} {val[2]:.6f}\n") @staticmethod def _write_clf(lut: LUT3D, filepath: Path): """Write 3D LUT in .clf format (ACES Common LUT Format).""" - ns = 'urn:AMPAS:CLF:v3.0' + ns = "urn:AMPAS:CLF:v3.0" - root = ET.Element('ProcessList', { - 'xmlns': ns, - 'id': lut.title.replace(' ', '_'), - 'compCLFversion': '3.0' - }) + root = ET.Element("ProcessList", {"xmlns": ns, "id": lut.title.replace(" ", "_"), "compCLFversion": "3.0"}) # Description - desc = ET.SubElement(root, 'Description') + desc = ET.SubElement(root, "Description") desc.text = lut.title # LUT3D element - lut3d = ET.SubElement(root, 'LUT3D', { - 'id': 'lut3d', - 'interpolation': 'trilinear', - 'gridSize': f'{lut.size} {lut.size} {lut.size}' - }) + lut3d = ET.SubElement( + root, + "LUT3D", + {"id": "lut3d", "interpolation": "trilinear", "gridSize": f"{lut.size} {lut.size} {lut.size}"}, + ) # Array data - array = ET.SubElement(lut3d, 'Array', { - 'dim': f'{lut.size} {lut.size} {lut.size} 3' - }) + array = ET.SubElement(lut3d, "Array", {"dim": f"{lut.size} {lut.size} {lut.size} 3"}) values = [] for r in range(lut.size): for g in range(lut.size): for b in range(lut.size): val = lut.data[r, g, b] - values.extend([f'{val[0]:.6f}', f'{val[1]:.6f}', f'{val[2]:.6f}']) + values.extend([f"{val[0]:.6f}", f"{val[1]:.6f}", f"{val[2]:.6f}"]) - array.text = '\n' + ' '.join(values) + '\n' + array.text = "\n" + " ".join(values) + "\n" tree = ET.ElementTree(root) - ET.indent(tree, space=' ') - tree.write(filepath, encoding='utf-8', xml_declaration=True) + ET.indent(tree, space=" ") + tree.write(filepath, encoding="utf-8", xml_declaration=True) # Convenience functions + def load_lut(filepath: str | Path) -> LUT1D | LUT3D: """Load a LUT file (auto-detect format).""" return LUTReader.read(filepath) -def save_lut( - lut: LUT1D | LUT3D, - filepath: str | Path, - fmt: LUTFormat | None = None -): +def save_lut(lut: LUT1D | LUT3D, filepath: str | Path, fmt: LUTFormat | None = None): """Save a LUT to file.""" LUTWriter.write(lut, filepath, fmt) -def convert_lut( - input_path: str | Path, - output_path: str | Path, - output_format: LUTFormat | None = None -): +def convert_lut(input_path: str | Path, output_path: str | Path, output_format: LUTFormat | None = None): """Convert LUT between formats.""" lut = load_lut(input_path) save_lut(lut, output_path, output_format) diff --git a/calibrate_pro/lut_system/nvidia_api.py b/calibrate_pro/lut_system/nvidia_api.py index 83c6ca6..930955c 100644 --- a/calibrate_pro/lut_system/nvidia_api.py +++ b/calibrate_pro/lut_system/nvidia_api.py @@ -35,8 +35,10 @@ # NVAPI Constants and Types # ============================================================================= + class NvStatus(IntEnum): """NVAPI return status codes.""" + OK = 0 ERROR = -1 LIBRARY_NOT_FOUND = -2 @@ -54,6 +56,7 @@ class NvStatus(IntEnum): class NvColorCommand(IntEnum): """Color control commands.""" + GET_CURRENT = 0x00000001 SET_CURRENT = 0x00000002 GET_DEFAULT = 0x00000010 @@ -62,6 +65,7 @@ class NvColorCommand(IntEnum): class NvColorType(IntFlag): """Color control types.""" + BRIGHTNESS = 0x00000001 CONTRAST = 0x00000002 GAMMA = 0x00000004 @@ -71,6 +75,7 @@ class NvColorType(IntFlag): class NvHdrMode(IntEnum): """HDR modes.""" + OFF = 0 UHDA = 2 # Ultra HD Alliance mode (HDR10) UHDA_PASSTHROUGH = 3 @@ -91,22 +96,22 @@ class NvHdrMode(IntEnum): # NVAPI function IDs (used with nvapi_QueryInterface) NVAPI_FUNCS = { - 'Initialize': 0x0150E828, - 'Unload': 0xD22BDD7E, - 'GetErrorMessage': 0x6C2D048C, - 'EnumNvidiaDisplayHandle': 0x9ABDD40D, - 'EnumPhysicalGPUs': 0xE5AC921F, - 'GetPhysicalGPUsFromDisplay': 0x34EF9506, - 'GPU_GetFullName': 0xCEEE8E9F, - 'GetAssociatedNvidiaDisplayHandle': 0x35C29134, - 'GetAssociatedDisplayOutputId': 0xD995937E, - 'Disp_ColorControl': 0x92F9D80D, - 'Disp_GetHdrCapabilities': 0x84F2A8E5, - 'Disp_HdrColorControl': 0x351DA224, - 'Disp_GetGDIPrimaryDisplayId': 0x1E9D8A31, - 'GPU_GetConnectedDisplayIds': 0x0078DBA2, - 'Disp_GetDisplayIdByDisplayName': 0xAE457190, - 'SYS_GetDisplayDriverVersion': 0xF951A4D1, + "Initialize": 0x0150E828, + "Unload": 0xD22BDD7E, + "GetErrorMessage": 0x6C2D048C, + "EnumNvidiaDisplayHandle": 0x9ABDD40D, + "EnumPhysicalGPUs": 0xE5AC921F, + "GetPhysicalGPUsFromDisplay": 0x34EF9506, + "GPU_GetFullName": 0xCEEE8E9F, + "GetAssociatedNvidiaDisplayHandle": 0x35C29134, + "GetAssociatedDisplayOutputId": 0xD995937E, + "Disp_ColorControl": 0x92F9D80D, + "Disp_GetHdrCapabilities": 0x84F2A8E5, + "Disp_HdrColorControl": 0x351DA224, + "Disp_GetGDIPrimaryDisplayId": 0x1E9D8A31, + "GPU_GetConnectedDisplayIds": 0x0078DBA2, + "Disp_GetDisplayIdByDisplayName": 0xAE457190, + "SYS_GetDisplayDriverVersion": 0xF951A4D1, } @@ -114,63 +119,68 @@ class NvHdrMode(IntEnum): # NVAPI Structures # ============================================================================= + class NV_COLOR_DATA_V5(Structure): """Color control data structure (version 5).""" + _fields_ = [ - ('version', c_uint), - ('size', c_uint), - ('cmd', c_int), - ('data', c_int), # NvColorType flags - ('colorBrightness', c_int), # -100 to 100 - ('colorContrast', c_int), # -100 to 100 - ('colorGamma', c_int), # -100 to 100 - ('colorSaturation', c_int), # -100 to 100 (vibrance) - ('colorHue', c_int), # 0 to 359 + ("version", c_uint), + ("size", c_uint), + ("cmd", c_int), + ("data", c_int), # NvColorType flags + ("colorBrightness", c_int), # -100 to 100 + ("colorContrast", c_int), # -100 to 100 + ("colorGamma", c_int), # -100 to 100 + ("colorSaturation", c_int), # -100 to 100 (vibrance) + ("colorHue", c_int), # 0 to 359 ] class NV_HDR_CAPABILITIES_V2(Structure): """HDR capabilities structure.""" + _fields_ = [ - ('version', c_uint), - ('isST2084EotfSupported', c_uint, 1), - ('isTraditionalHdrGammaSupported', c_uint, 1), - ('isEdrSupported', c_uint, 1), - ('driverExpandDefaultHdrParameters', c_uint, 1), - ('isTraditionalSdrGammaSupported', c_uint, 1), - ('isDolbyVisionSupported', c_uint, 1), - ('reserved', c_uint, 26), - ('display_data', c_char * 64), # Simplified - actual structure is more complex + ("version", c_uint), + ("isST2084EotfSupported", c_uint, 1), + ("isTraditionalHdrGammaSupported", c_uint, 1), + ("isEdrSupported", c_uint, 1), + ("driverExpandDefaultHdrParameters", c_uint, 1), + ("isTraditionalSdrGammaSupported", c_uint, 1), + ("isDolbyVisionSupported", c_uint, 1), + ("reserved", c_uint, 26), + ("display_data", c_char * 64), # Simplified - actual structure is more complex ] class NV_HDR_COLOR_DATA_V2(Structure): """HDR color control structure.""" + _fields_ = [ - ('version', c_uint), - ('cmd', c_int), - ('hdrMode', c_int), - ('static_metadata_descriptor_id', c_int), - ('mastering_display_data', c_char * 40), # Simplified + ("version", c_uint), + ("cmd", c_int), + ("hdrMode", c_int), + ("static_metadata_descriptor_id", c_int), + ("mastering_display_data", c_char * 40), # Simplified ] class NV_GPU_DISPLAYIDS(Structure): """Display ID structure.""" + _fields_ = [ - ('version', c_uint), - ('connectorType', c_uint), - ('displayId', c_uint), - ('isDynamic', c_uint, 1), - ('isMultiStreamRootNode', c_uint, 1), - ('isActive', c_uint, 1), - ('isCluster', c_uint, 1), - ('isOSVisible', c_uint, 1), - ('isWFD', c_uint, 1), - ('isConnected', c_uint, 1), - ('reserved', c_uint, 22), - ('isPhysicallyConnected', c_uint, 1), - ('reserved2', c_uint, 2), + ("version", c_uint), + ("connectorType", c_uint), + ("displayId", c_uint), + ("isDynamic", c_uint, 1), + ("isMultiStreamRootNode", c_uint, 1), + ("isActive", c_uint, 1), + ("isCluster", c_uint, 1), + ("isOSVisible", c_uint, 1), + ("isWFD", c_uint, 1), + ("isConnected", c_uint, 1), + ("reserved", c_uint, 22), + ("isPhysicallyConnected", c_uint, 1), + ("reserved2", c_uint, 2), ] @@ -178,9 +188,11 @@ class NV_GPU_DISPLAYIDS(Structure): # Data Classes # ============================================================================= + @dataclass class NvidiaDisplayInfo: """NVIDIA display information.""" + display_id: int display_handle: int gpu_handle: int @@ -197,6 +209,7 @@ class NvidiaDisplayInfo: @dataclass class NvidiaGpuInfo: """NVIDIA GPU information.""" + handle: int name: str driver_version: str @@ -206,15 +219,17 @@ class NvidiaGpuInfo: @dataclass class ColorSettings: """Display color settings.""" - brightness: int = 0 # -100 to 100 - contrast: int = 0 # -100 to 100 - gamma: int = 0 # -100 to 100 - saturation: int = 0 # -100 to 100 (vibrance) - hue: int = 0 # 0 to 359 + + brightness: int = 0 # -100 to 100 + contrast: int = 0 # -100 to 100 + gamma: int = 0 # -100 to 100 + saturation: int = 0 # -100 to 100 (vibrance) + hue: int = 0 # 0 to 359 class NvidiaAPIError(Exception): """NVIDIA API error.""" + pass @@ -222,6 +237,7 @@ class NvidiaAPIError(Exception): # Main NVIDIA API Class # ============================================================================= + class NvidiaAPI: """ NVIDIA NVAPI wrapper for GPU-level color management. @@ -261,14 +277,11 @@ def _initialize(self) -> bool: query_interface.argtypes = [c_uint] # Get NvAPI_Initialize - init_ptr = query_interface(NVAPI_FUNCS['Initialize']) + init_ptr = query_interface(NVAPI_FUNCS["Initialize"]) if not init_ptr: return False - NvAPI_Initialize = ctypes.cast( - init_ptr, - ctypes.CFUNCTYPE(c_int) - ) + NvAPI_Initialize = ctypes.cast(init_ptr, ctypes.CFUNCTYPE(c_int)) status = NvAPI_Initialize() if status != NvStatus.OK: @@ -292,70 +305,61 @@ def _initialize(self) -> bool: def _cache_functions(self, query_interface): """Cache NVAPI function pointers.""" # Color control - ptr = query_interface(NVAPI_FUNCS['Disp_ColorControl']) + ptr = query_interface(NVAPI_FUNCS["Disp_ColorControl"]) if ptr: - self._funcs['ColorControl'] = ctypes.cast( - ptr, - ctypes.CFUNCTYPE(c_int, c_int, POINTER(NV_COLOR_DATA_V5)) - ) + self._funcs["ColorControl"] = ctypes.cast(ptr, ctypes.CFUNCTYPE(c_int, c_int, POINTER(NV_COLOR_DATA_V5))) # HDR capabilities - ptr = query_interface(NVAPI_FUNCS['Disp_GetHdrCapabilities']) + ptr = query_interface(NVAPI_FUNCS["Disp_GetHdrCapabilities"]) if ptr: - self._funcs['GetHdrCapabilities'] = ctypes.cast( - ptr, - ctypes.CFUNCTYPE(c_int, c_uint, POINTER(NV_HDR_CAPABILITIES_V2)) + self._funcs["GetHdrCapabilities"] = ctypes.cast( + ptr, ctypes.CFUNCTYPE(c_int, c_uint, POINTER(NV_HDR_CAPABILITIES_V2)) ) # HDR color control - ptr = query_interface(NVAPI_FUNCS['Disp_HdrColorControl']) + ptr = query_interface(NVAPI_FUNCS["Disp_HdrColorControl"]) if ptr: - self._funcs['HdrColorControl'] = ctypes.cast( - ptr, - ctypes.CFUNCTYPE(c_int, c_uint, POINTER(NV_HDR_COLOR_DATA_V2)) + self._funcs["HdrColorControl"] = ctypes.cast( + ptr, ctypes.CFUNCTYPE(c_int, c_uint, POINTER(NV_HDR_COLOR_DATA_V2)) ) # Enum displays - ptr = query_interface(NVAPI_FUNCS['EnumNvidiaDisplayHandle']) + ptr = query_interface(NVAPI_FUNCS["EnumNvidiaDisplayHandle"]) if ptr: - self._funcs['EnumDisplayHandle'] = ctypes.cast( - ptr, - ctypes.CFUNCTYPE(c_int, c_uint, POINTER(NvDisplayHandle)) + self._funcs["EnumDisplayHandle"] = ctypes.cast( + ptr, ctypes.CFUNCTYPE(c_int, c_uint, POINTER(NvDisplayHandle)) ) # Enum GPUs - ptr = query_interface(NVAPI_FUNCS['EnumPhysicalGPUs']) + ptr = query_interface(NVAPI_FUNCS["EnumPhysicalGPUs"]) if ptr: - self._funcs['EnumPhysicalGPUs'] = ctypes.cast( - ptr, - ctypes.CFUNCTYPE(c_int, POINTER(NvPhysicalGpuHandle * 64), POINTER(c_uint)) + self._funcs["EnumPhysicalGPUs"] = ctypes.cast( + ptr, ctypes.CFUNCTYPE(c_int, POINTER(NvPhysicalGpuHandle * 64), POINTER(c_uint)) ) # GPU name - ptr = query_interface(NVAPI_FUNCS['GPU_GetFullName']) + ptr = query_interface(NVAPI_FUNCS["GPU_GetFullName"]) if ptr: - self._funcs['GetGpuFullName'] = ctypes.cast( - ptr, - ctypes.CFUNCTYPE(c_int, NvPhysicalGpuHandle, ctypes.c_char_p) + self._funcs["GetGpuFullName"] = ctypes.cast( + ptr, ctypes.CFUNCTYPE(c_int, NvPhysicalGpuHandle, ctypes.c_char_p) ) # Get GPU from display - ptr = query_interface(NVAPI_FUNCS['GetPhysicalGPUsFromDisplay']) + ptr = query_interface(NVAPI_FUNCS["GetPhysicalGPUsFromDisplay"]) if ptr: - self._funcs['GetPhysicalGPUsFromDisplay'] = ctypes.cast( - ptr, - ctypes.CFUNCTYPE(c_int, NvDisplayHandle, POINTER(NvPhysicalGpuHandle * 64), POINTER(c_uint)) + self._funcs["GetPhysicalGPUsFromDisplay"] = ctypes.cast( + ptr, ctypes.CFUNCTYPE(c_int, NvDisplayHandle, POINTER(NvPhysicalGpuHandle * 64), POINTER(c_uint)) ) def _enumerate_gpus(self): """Enumerate NVIDIA GPUs.""" - if 'EnumPhysicalGPUs' not in self._funcs: + if "EnumPhysicalGPUs" not in self._funcs: return gpu_handles = (NvPhysicalGpuHandle * 64)() gpu_count = c_uint(0) - status = self._funcs['EnumPhysicalGPUs'](byref(gpu_handles), byref(gpu_count)) + status = self._funcs["EnumPhysicalGPUs"](byref(gpu_handles), byref(gpu_count)) if status != NvStatus.OK: return @@ -366,21 +370,23 @@ def _enumerate_gpus(self): name_buffer = ctypes.create_string_buffer(NVAPI_SHORT_STRING_MAX) gpu_name = "Unknown NVIDIA GPU" - if 'GetGpuFullName' in self._funcs: - status = self._funcs['GetGpuFullName'](gpu_handle, name_buffer) + if "GetGpuFullName" in self._funcs: + status = self._funcs["GetGpuFullName"](gpu_handle, name_buffer) if status == NvStatus.OK: - gpu_name = name_buffer.value.decode('utf-8', errors='ignore') - - self._gpus.append(NvidiaGpuInfo( - handle=gpu_handle, - name=gpu_name, - driver_version="", # Would need separate API call - display_count=0 - )) + gpu_name = name_buffer.value.decode("utf-8", errors="ignore") + + self._gpus.append( + NvidiaGpuInfo( + handle=gpu_handle, + name=gpu_name, + driver_version="", # Would need separate API call + display_count=0, + ) + ) def _enumerate_displays(self): """Enumerate NVIDIA displays.""" - if 'EnumDisplayHandle' not in self._funcs: + if "EnumDisplayHandle" not in self._funcs: # Fall back to Windows enumeration self._enumerate_displays_windows() return @@ -388,7 +394,7 @@ def _enumerate_displays(self): i = 0 while True: display_handle = NvDisplayHandle() - status = self._funcs['EnumDisplayHandle'](i, byref(display_handle)) + status = self._funcs["EnumDisplayHandle"](i, byref(display_handle)) if status == NvStatus.END_ENUMERATION: break @@ -397,36 +403,36 @@ def _enumerate_displays(self): # Get GPU for this display gpu_handle = 0 - if 'GetPhysicalGPUsFromDisplay' in self._funcs: + if "GetPhysicalGPUsFromDisplay" in self._funcs: gpu_handles = (NvPhysicalGpuHandle * 64)() gpu_count = c_uint(0) - status = self._funcs['GetPhysicalGPUsFromDisplay']( - display_handle, byref(gpu_handles), byref(gpu_count) - ) + status = self._funcs["GetPhysicalGPUsFromDisplay"](display_handle, byref(gpu_handles), byref(gpu_count)) if status == NvStatus.OK and gpu_count.value > 0: gpu_handle = gpu_handles[0] # Check HDR capability is_hdr_capable = False is_hdr_enabled = False - if 'GetHdrCapabilities' in self._funcs: + if "GetHdrCapabilities" in self._funcs: caps = NV_HDR_CAPABILITIES_V2() caps.version = sizeof(NV_HDR_CAPABILITIES_V2) | (2 << 16) # Would need display ID, not handle - self._displays.append(NvidiaDisplayInfo( - display_id=i, - display_handle=display_handle, - gpu_handle=gpu_handle, - name=f"NVIDIA Display {i}", - is_primary=(i == 0), - is_active=True, - is_hdr_capable=is_hdr_capable, - is_hdr_enabled=is_hdr_enabled, - resolution=(0, 0), - refresh_rate=60.0, - connector_type="Unknown" - )) + self._displays.append( + NvidiaDisplayInfo( + display_id=i, + display_handle=display_handle, + gpu_handle=gpu_handle, + name=f"NVIDIA Display {i}", + is_primary=(i == 0), + is_active=True, + is_hdr_capable=is_hdr_capable, + is_hdr_enabled=is_hdr_enabled, + resolution=(0, 0), + refresh_rate=60.0, + connector_type="Unknown", + ) + ) i += 1 @@ -451,24 +457,23 @@ class DISPLAY_DEVICE(Structure): while user32.EnumDisplayDevicesW(None, i, byref(device), 0): if device.StateFlags & 0x00000001: # ACTIVE - is_nvidia = ( - "NVIDIA" in device.DeviceString or - "nvidia" in device.DeviceID.lower() - ) + is_nvidia = "NVIDIA" in device.DeviceString or "nvidia" in device.DeviceID.lower() if is_nvidia: - self._displays.append(NvidiaDisplayInfo( - display_id=i, - display_handle=i, - gpu_handle=0, - name=device.DeviceString, - is_primary=bool(device.StateFlags & 0x00000004), - is_active=True, - is_hdr_capable=False, - is_hdr_enabled=False, - resolution=(0, 0), - refresh_rate=60.0, - connector_type="Unknown" - )) + self._displays.append( + NvidiaDisplayInfo( + display_id=i, + display_handle=i, + gpu_handle=0, + name=device.DeviceString, + is_primary=bool(device.StateFlags & 0x00000004), + is_active=True, + is_hdr_capable=False, + is_hdr_enabled=False, + resolution=(0, 0), + refresh_rate=60.0, + connector_type="Unknown", + ) + ) i += 1 except OSError: pass @@ -516,7 +521,7 @@ def get_color_settings(self, display_id: int = 0) -> ColorSettings | None: def _get_color_via_nvapi(self, display_id: int) -> ColorSettings | None: """Get color settings via NVAPI (may not work on all driver versions).""" - if not self._initialized or 'ColorControl' not in self._funcs: + if not self._initialized or "ColorControl" not in self._funcs: return None if display_id >= len(self._displays): @@ -533,10 +538,11 @@ def _get_color_via_nvapi(self, display_id: int) -> ColorSettings | None: color_data.version = sizeof(NV_COLOR_DATA_V5) | (5 << 16) color_data.size = sizeof(NV_COLOR_DATA_V5) color_data.cmd = NvColorCommand.GET_CURRENT - color_data.data = (NvColorType.BRIGHTNESS | NvColorType.CONTRAST | - NvColorType.GAMMA | NvColorType.SATURATION | NvColorType.HUE) + color_data.data = ( + NvColorType.BRIGHTNESS | NvColorType.CONTRAST | NvColorType.GAMMA | NvColorType.SATURATION | NvColorType.HUE + ) - status = self._funcs['ColorControl'](actual_display_id, byref(color_data)) + status = self._funcs["ColorControl"](actual_display_id, byref(color_data)) if status == NvStatus.OK: return ColorSettings( @@ -544,7 +550,7 @@ def _get_color_via_nvapi(self, display_id: int) -> ColorSettings | None: contrast=color_data.colorContrast, gamma=color_data.colorGamma, saturation=color_data.colorSaturation, - hue=color_data.colorHue + hue=color_data.colorHue, ) return None @@ -560,7 +566,7 @@ def _get_nvapi_display_id(self, display_index: int) -> int | None: query_interface.argtypes = [c_uint] # NvAPI_Disp_GetGDIPrimaryDisplayId - ptr = query_interface(NVAPI_FUNCS['Disp_GetGDIPrimaryDisplayId']) + ptr = query_interface(NVAPI_FUNCS["Disp_GetGDIPrimaryDisplayId"]) if ptr and display_index == 0: func = ctypes.cast(ptr, ctypes.CFUNCTYPE(c_int, POINTER(c_uint))) display_id = c_uint(0) @@ -599,7 +605,7 @@ def set_color_settings( contrast: int | None = None, gamma: int | None = None, saturation: int | None = None, - hue: int | None = None + hue: int | None = None, ) -> bool: """ Set color settings for a display. @@ -635,10 +641,10 @@ def _set_color_via_nvapi( contrast: int | None, gamma: int | None, saturation: int | None, - hue: int | None + hue: int | None, ) -> bool: """Set color via NVAPI (may not work on all driver versions).""" - if not self._initialized or 'ColorControl' not in self._funcs: + if not self._initialized or "ColorControl" not in self._funcs: return False if display_id >= len(self._displays): @@ -696,7 +702,7 @@ def _set_color_via_nvapi( color_data.data = data_flags - status = self._funcs['ColorControl'](actual_display_id, byref(color_data)) + status = self._funcs["ColorControl"](actual_display_id, byref(color_data)) return status == NvStatus.OK def _set_vibrance_via_registry(self, display_id: int, saturation: int) -> bool: @@ -712,8 +718,7 @@ def _set_vibrance_via_registry(self, display_id: int, saturation: int) -> bool: key_path = r"SOFTWARE\NVIDIA Corporation\Global\NVTweak" try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, - winreg.KEY_SET_VALUE) as key: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key: winreg.SetValueEx(key, "DVibrance", 0, winreg.REG_DWORD, vibrance) except FileNotFoundError: # Create the key if it doesn't exist @@ -737,25 +742,13 @@ def reset_color_settings(self, display_id: int = 0) -> bool: Returns: True if successful """ - return self.set_color_settings( - display_id, - brightness=0, - contrast=0, - gamma=0, - saturation=0, - hue=0 - ) + return self.set_color_settings(display_id, brightness=0, contrast=0, gamma=0, saturation=0, hue=0) # ========================================================================= # 3D LUT Methods # ========================================================================= - def load_3d_lut( - self, - display_id: int, - lut_data: np.ndarray, - interpolation: str = "tetrahedral" - ) -> bool: + def load_3d_lut(self, display_id: int, lut_data: np.ndarray, interpolation: str = "tetrahedral") -> bool: """ Load 3D LUT to GPU. @@ -797,10 +790,10 @@ def load_3d_lut( return self.set_color_settings( display_id, - brightness=adjustments.get('brightness', 0), - contrast=adjustments.get('contrast', 0), - gamma=adjustments.get('gamma', 0), - saturation=adjustments.get('saturation', 0) + brightness=adjustments.get("brightness", 0), + contrast=adjustments.get("contrast", 0), + gamma=adjustments.get("gamma", 0), + saturation=adjustments.get("saturation", 0), ) except (ValueError, OSError): return False @@ -816,9 +809,7 @@ def _analyze_lut_adjustments(self, lut_data: np.ndarray) -> dict[str, int]: adjustments = {} # Sample diagonal (grayscale response) - diagonal = np.array([ - lut_data[i, i, i] for i in range(size) - ]) + diagonal = np.array([lut_data[i, i, i] for i in range(size)]) # Average RGB response avg_response = diagonal.mean(axis=1) @@ -829,14 +820,14 @@ def _analyze_lut_adjustments(self, lut_data: np.ndarray) -> dict[str, int]: # Estimate brightness (offset at black point) black_offset = avg_response[0] - expected[0] brightness = int(black_offset * 100) - adjustments['brightness'] = max(-100, min(100, brightness)) + adjustments["brightness"] = max(-100, min(100, brightness)) # Estimate contrast (difference between white and black) actual_range = avg_response[-1] - avg_response[0] expected_range = 1.0 contrast_factor = actual_range / expected_range if expected_range > 0 else 1.0 contrast = int((contrast_factor - 1.0) * 100) - adjustments['contrast'] = max(-100, min(100, contrast)) + adjustments["contrast"] = max(-100, min(100, contrast)) # Estimate gamma (midtone deviation) mid_idx = size // 2 @@ -846,23 +837,19 @@ def _analyze_lut_adjustments(self, lut_data: np.ndarray) -> dict[str, int]: # Gamma estimation: actual = expected^(1/gamma_factor) gamma_ratio = np.log(mid_actual) / np.log(mid_expected) if mid_expected < 1 else 1.0 gamma = int((gamma_ratio - 1.0) * 50) - adjustments['gamma'] = max(-100, min(100, gamma)) + adjustments["gamma"] = max(-100, min(100, gamma)) else: - adjustments['gamma'] = 0 + adjustments["gamma"] = 0 # Estimate saturation by comparing color channel separation # At white, check if channels are equal (desaturated) or separated white_rgb = lut_data[-1, -1, -1] np.std(white_rgb) - adjustments['saturation'] = 0 # Hard to estimate from LUT + adjustments["saturation"] = 0 # Hard to estimate from LUT return adjustments - def load_lut_file( - self, - display_id: int, - lut_path: str | Path - ) -> bool: + def load_lut_file(self, display_id: int, lut_path: str | Path) -> bool: """ Load 3D LUT from file. @@ -920,13 +907,13 @@ def get_hdr_capabilities(self, display_id: int = 0) -> dict: Dictionary with HDR capability info """ result = { - 'hdr_supported': False, - 'hdr10_supported': False, - 'dolby_vision_supported': False, - 'edr_supported': False, + "hdr_supported": False, + "hdr10_supported": False, + "dolby_vision_supported": False, + "edr_supported": False, } - if not self._initialized or 'GetHdrCapabilities' not in self._funcs: + if not self._initialized or "GetHdrCapabilities" not in self._funcs: return result if display_id >= len(self._displays): @@ -935,17 +922,12 @@ def get_hdr_capabilities(self, display_id: int = 0) -> dict: # Note: GetHdrCapabilities requires display ID, not handle # This is a simplified check if self._displays[display_id].is_hdr_capable: - result['hdr_supported'] = True - result['hdr10_supported'] = True + result["hdr_supported"] = True + result["hdr10_supported"] = True return result - def set_hdr_mode( - self, - display_id: int, - enabled: bool, - mode: NvHdrMode = NvHdrMode.UHDA - ) -> bool: + def set_hdr_mode(self, display_id: int, enabled: bool, mode: NvHdrMode = NvHdrMode.UHDA) -> bool: """ Enable/disable HDR mode. @@ -957,7 +939,7 @@ def set_hdr_mode( Returns: True if successful """ - if not self._initialized or 'HdrColorControl' not in self._funcs: + if not self._initialized or "HdrColorControl" not in self._funcs: return False if display_id >= len(self._displays): @@ -975,30 +957,30 @@ def set_hdr_mode( def get_info(self) -> dict: """Get NVIDIA API information.""" return { - 'available': self._initialized, - 'gpu_count': len(self._gpus), - 'display_count': len(self._displays), - 'gpus': [ + "available": self._initialized, + "gpu_count": len(self._gpus), + "display_count": len(self._displays), + "gpus": [ { - 'name': gpu.name, - 'handle': gpu.handle, + "name": gpu.name, + "handle": gpu.handle, } for gpu in self._gpus ], - 'displays': [ + "displays": [ { - 'id': d.display_id, - 'name': d.name, - 'primary': d.is_primary, - 'active': d.is_active, - 'hdr_capable': d.is_hdr_capable, + "id": d.display_id, + "name": d.name, + "primary": d.is_primary, + "active": d.is_active, + "hdr_capable": d.is_hdr_capable, } for d in self._displays ], - 'features': { - 'color_control': 'ColorControl' in self._funcs, - 'hdr_control': 'HdrColorControl' in self._funcs, - } + "features": { + "color_control": "ColorControl" in self._funcs, + "hdr_control": "HdrColorControl" in self._funcs, + }, } def cleanup(self): @@ -1008,12 +990,9 @@ def cleanup(self): query_interface = self._nvapi.nvapi_QueryInterface query_interface.restype = c_void_p - unload_ptr = query_interface(NVAPI_FUNCS['Unload']) + unload_ptr = query_interface(NVAPI_FUNCS["Unload"]) if unload_ptr: - NvAPI_Unload = ctypes.cast( - unload_ptr, - ctypes.CFUNCTYPE(c_int) - ) + NvAPI_Unload = ctypes.cast(unload_ptr, ctypes.CFUNCTYPE(c_int)) NvAPI_Unload() except OSError: pass @@ -1029,6 +1008,7 @@ def __del__(self): # Convenience Functions # ============================================================================= + def check_nvidia_available() -> bool: """Check if NVIDIA GPU is available.""" api = NvidiaAPI() @@ -1045,10 +1025,7 @@ def get_nvidia_info() -> dict: return info -def apply_nvidia_lut( - lut_data: np.ndarray, - display_id: int = 0 -) -> tuple[bool, str]: +def apply_nvidia_lut(lut_data: np.ndarray, display_id: int = 0) -> tuple[bool, str]: """ Apply 3D LUT via NVIDIA API. @@ -1079,10 +1056,7 @@ def apply_nvidia_lut( pass -def apply_nvidia_lut_file( - lut_path: str | Path, - display_id: int = 0 -) -> tuple[bool, str]: +def apply_nvidia_lut_file(lut_path: str | Path, display_id: int = 0) -> tuple[bool, str]: """ Apply 3D LUT file via NVIDIA API. @@ -1115,7 +1089,7 @@ def set_nvidia_color( brightness: int | None = None, contrast: int | None = None, gamma: int | None = None, - saturation: int | None = None + saturation: int | None = None, ) -> tuple[bool, str]: """ Set NVIDIA display color settings. @@ -1137,11 +1111,7 @@ def set_nvidia_color( try: success = api.set_color_settings( - display_id, - brightness=brightness, - contrast=contrast, - gamma=gamma, - saturation=saturation + display_id, brightness=brightness, contrast=contrast, gamma=gamma, saturation=saturation ) if success: return True, "Color settings applied" diff --git a/calibrate_pro/lut_system/per_display_calibration.py b/calibrate_pro/lut_system/per_display_calibration.py index 19fd9e6..466b097 100644 --- a/calibrate_pro/lut_system/per_display_calibration.py +++ b/calibrate_pro/lut_system/per_display_calibration.py @@ -24,29 +24,32 @@ class CalibrationSource(Enum): """Source of calibration data.""" - PANEL_DATABASE = "panel_database" # Built-in panel profiles - FORUM_DATA = "forum_data" # TFTCentral, Rtings, etc. - ICC_PROFILE = "icc_profile" # Existing ICC profile - COLORIMETER = "colorimeter" # Hardware measurement - SENSORLESS = "sensorless" # Sensorless calibration - CUSTOM = "custom" # User-provided settings + + PANEL_DATABASE = "panel_database" # Built-in panel profiles + FORUM_DATA = "forum_data" # TFTCentral, Rtings, etc. + ICC_PROFILE = "icc_profile" # Existing ICC profile + COLORIMETER = "colorimeter" # Hardware measurement + SENSORLESS = "sensorless" # Sensorless calibration + CUSTOM = "custom" # User-provided settings class CalibrationTarget(Enum): """Calibration target preset.""" - SRGB = "sRGB" # sRGB D65, gamma 2.2 - SRGB_FILM = "sRGB_Film" # sRGB with 2.4 gamma (cinema-like) - DCI_P3 = "DCI-P3" # DCI-P3 D65 - ADOBE_RGB = "Adobe RGB" # Adobe RGB 1998 - BT709 = "BT.709" # HD video - BT2020 = "BT.2020" # HDR video - NATIVE = "Native" # Use panel's native gamut - CUSTOM = "Custom" # Custom target + + SRGB = "sRGB" # sRGB D65, gamma 2.2 + SRGB_FILM = "sRGB_Film" # sRGB with 2.4 gamma (cinema-like) + DCI_P3 = "DCI-P3" # DCI-P3 D65 + ADOBE_RGB = "Adobe RGB" # Adobe RGB 1998 + BT709 = "BT.709" # HD video + BT2020 = "BT.2020" # HDR video + NATIVE = "Native" # Use panel's native gamut + CUSTOM = "Custom" # Custom target @dataclass class DisplayCalibrationProfile: """Calibration profile for a single display.""" + display_id: int display_name: str device_name: str @@ -54,8 +57,8 @@ class DisplayCalibrationProfile: # Panel identification manufacturer: str = "" model: str = "" - panel_type: str = "" # QD-OLED, WOLED, IPS, VA, etc. - panel_database_key: str = "" # Key in panel database + panel_type: str = "" # QD-OLED, WOLED, IPS, VA, etc. + panel_database_key: str = "" # Key in panel database # Calibration settings target: CalibrationTarget = CalibrationTarget.SRGB @@ -103,6 +106,7 @@ def to_dict(self) -> dict: @dataclass class PerDisplayCalibrationConfig: """Configuration for per-display calibration.""" + auto_detect: bool = True auto_calibrate: bool = True auto_apply: bool = True @@ -112,8 +116,8 @@ class PerDisplayCalibrationConfig: def __post_init__(self): if self.profiles_dir is None: - app_data = os.environ.get('APPDATA', os.path.expanduser('~')) - self.profiles_dir = os.path.join(app_data, 'CalibratePro', 'display_profiles') + app_data = os.environ.get("APPDATA", os.path.expanduser("~")) + self.profiles_dir = os.path.join(app_data, "CalibratePro", "display_profiles") class PerDisplayCalibrationManager: @@ -148,6 +152,7 @@ def lut_manager(self): """Get LUT manager (lazy load).""" if self._lut_manager is None: from calibrate_pro.lut_system import LUTManager + self._lut_manager = LUTManager() return self._lut_manager @@ -156,6 +161,7 @@ def panel_db(self): """Get panel database (lazy load).""" if self._panel_db is None: from calibrate_pro.panels.database import PanelDatabase + self._panel_db = PanelDatabase() return self._panel_db @@ -185,11 +191,11 @@ def detect_displays(self) -> list[DisplayCalibrationProfile]: # Load saved profile if exists saved_profile = self._load_profile(profile.display_id) if saved_profile: - profile.target = CalibrationTarget(saved_profile.get('target', 'sRGB')) - profile.target_gamma = saved_profile.get('target_gamma', 2.2) - profile.target_brightness = saved_profile.get('target_brightness', 100.0) - profile.lut_path = saved_profile.get('lut_path') - profile.is_calibrated = saved_profile.get('is_calibrated', False) + profile.target = CalibrationTarget(saved_profile.get("target", "sRGB")) + profile.target_gamma = saved_profile.get("target_gamma", 2.2) + profile.target_brightness = saved_profile.get("target_brightness", 100.0) + profile.lut_path = saved_profile.get("lut_path") + profile.is_calibrated = saved_profile.get("is_calibrated", False) self.profiles[profile.display_id] = profile @@ -219,7 +225,7 @@ def calibrate_display( self, display_id: int, target: CalibrationTarget = CalibrationTarget.SRGB, - source: CalibrationSource = CalibrationSource.PANEL_DATABASE + source: CalibrationSource = CalibrationSource.PANEL_DATABASE, ) -> bool: """ Calibrate a single display. @@ -279,10 +285,7 @@ def calibrate_display( return True - def calibrate_all( - self, - target: CalibrationTarget = CalibrationTarget.SRGB - ) -> dict[int, bool]: + def calibrate_all(self, target: CalibrationTarget = CalibrationTarget.SRGB) -> dict[int, bool]: """ Calibrate all detected displays. @@ -315,6 +318,7 @@ def apply_calibration(self, display_id: int) -> bool: lut_data = profile.lut_3d if lut_data is None and profile.lut_path: from calibrate_pro.lut_system import load_lut + try: lut = load_lut(profile.lut_path) lut_data = lut.data @@ -346,10 +350,7 @@ def reset_all(self) -> dict[int, bool]: return results def _generate_calibration_lut( - self, - profile: DisplayCalibrationProfile, - panel, - target: CalibrationTarget + self, profile: DisplayCalibrationProfile, panel, target: CalibrationTarget ) -> np.ndarray | None: """ Generate calibration 3D LUT for a display. @@ -372,10 +373,10 @@ def _generate_calibration_lut( panel_primaries = None if panel: panel_primaries = { - 'red': (panel.native_primaries.red.x, panel.native_primaries.red.y), - 'green': (panel.native_primaries.green.x, panel.native_primaries.green.y), - 'blue': (panel.native_primaries.blue.x, panel.native_primaries.blue.y), - 'white': (panel.native_primaries.white.x, panel.native_primaries.white.y), + "red": (panel.native_primaries.red.x, panel.native_primaries.red.y), + "green": (panel.native_primaries.green.x, panel.native_primaries.green.y), + "blue": (panel.native_primaries.blue.x, panel.native_primaries.blue.y), + "white": (panel.native_primaries.white.x, panel.native_primaries.white.y), } # Get color correction matrix @@ -402,11 +403,7 @@ def _generate_calibration_lut( # Apply gamut mapping if needed if target != CalibrationTarget.NATIVE and panel_primaries and target_primaries: - rgb_corrected = self._gamut_map( - rgb_corrected, - panel_primaries, - target_primaries - ) + rgb_corrected = self._gamut_map(rgb_corrected, panel_primaries, target_primaries) # Apply output gamma target_gamma = profile.target_gamma @@ -420,44 +417,39 @@ def _get_target_primaries(self, target: CalibrationTarget) -> dict | None: """Get primaries for calibration target.""" primaries = { CalibrationTarget.SRGB: { - 'red': (0.6400, 0.3300), - 'green': (0.3000, 0.6000), - 'blue': (0.1500, 0.0600), - 'white': (0.3127, 0.3290), + "red": (0.6400, 0.3300), + "green": (0.3000, 0.6000), + "blue": (0.1500, 0.0600), + "white": (0.3127, 0.3290), }, CalibrationTarget.DCI_P3: { - 'red': (0.6800, 0.3200), - 'green': (0.2650, 0.6900), - 'blue': (0.1500, 0.0600), - 'white': (0.3127, 0.3290), + "red": (0.6800, 0.3200), + "green": (0.2650, 0.6900), + "blue": (0.1500, 0.0600), + "white": (0.3127, 0.3290), }, CalibrationTarget.ADOBE_RGB: { - 'red': (0.6400, 0.3300), - 'green': (0.2100, 0.7100), - 'blue': (0.1500, 0.0600), - 'white': (0.3127, 0.3290), + "red": (0.6400, 0.3300), + "green": (0.2100, 0.7100), + "blue": (0.1500, 0.0600), + "white": (0.3127, 0.3290), }, CalibrationTarget.BT709: { - 'red': (0.6400, 0.3300), - 'green': (0.3000, 0.6000), - 'blue': (0.1500, 0.0600), - 'white': (0.3127, 0.3290), + "red": (0.6400, 0.3300), + "green": (0.3000, 0.6000), + "blue": (0.1500, 0.0600), + "white": (0.3127, 0.3290), }, CalibrationTarget.BT2020: { - 'red': (0.7080, 0.2920), - 'green': (0.1700, 0.7970), - 'blue': (0.1310, 0.0460), - 'white': (0.3127, 0.3290), + "red": (0.7080, 0.2920), + "green": (0.1700, 0.7970), + "blue": (0.1310, 0.0460), + "white": (0.3127, 0.3290), }, } return primaries.get(target) - def _gamut_map( - self, - rgb: np.ndarray, - source_primaries: dict, - target_primaries: dict - ) -> np.ndarray: + def _gamut_map(self, rgb: np.ndarray, source_primaries: dict, target_primaries: dict) -> np.ndarray: """ Map colors from source gamut to target gamut. @@ -546,11 +538,7 @@ def apply_forum_calibration(display_id: int) -> bool: True if successful """ manager = get_per_display_manager() - return manager.calibrate_display( - display_id, - target=CalibrationTarget.SRGB, - source=CalibrationSource.FORUM_DATA - ) + return manager.calibrate_display(display_id, target=CalibrationTarget.SRGB, source=CalibrationSource.FORUM_DATA) def list_detected_displays() -> list[dict]: diff --git a/calibrate_pro/lut_system/vcgt_calibration.py b/calibrate_pro/lut_system/vcgt_calibration.py index bf53775..e920fb7 100644 --- a/calibrate_pro/lut_system/vcgt_calibration.py +++ b/calibrate_pro/lut_system/vcgt_calibration.py @@ -34,15 +34,20 @@ # EnumDisplayDevices for getting display names class DISPLAY_DEVICE(ctypes.Structure): _fields_ = [ - ('cb', wintypes.DWORD), - ('DeviceName', wintypes.WCHAR * 32), - ('DeviceString', wintypes.WCHAR * 128), - ('StateFlags', wintypes.DWORD), - ('DeviceID', wintypes.WCHAR * 128), - ('DeviceKey', wintypes.WCHAR * 128), + ("cb", wintypes.DWORD), + ("DeviceName", wintypes.WCHAR * 32), + ("DeviceString", wintypes.WCHAR * 128), + ("StateFlags", wintypes.DWORD), + ("DeviceID", wintypes.WCHAR * 128), + ("DeviceKey", wintypes.WCHAR * 128), ] - user32.EnumDisplayDevicesW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(DISPLAY_DEVICE), wintypes.DWORD] + user32.EnumDisplayDevicesW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + ctypes.POINTER(DISPLAY_DEVICE), + wintypes.DWORD, + ] user32.EnumDisplayDevicesW.restype = wintypes.BOOL VCGT_AVAILABLE = True @@ -108,6 +113,7 @@ def _release_display_dc(hdc: wintypes.HDC) -> None: @dataclass class CalibrationCurves: """RGB calibration curves (256 points each, 0.0-1.0 range).""" + red: np.ndarray green: np.ndarray blue: np.ndarray diff --git a/calibrate_pro/main.py b/calibrate_pro/main.py index 4c77af4..b6112be 100644 --- a/calibrate_pro/main.py +++ b/calibrate_pro/main.py @@ -60,11 +60,8 @@ def run_as_admin(): return True try: script = os.path.abspath(sys.argv[0]) - params = ' '.join([f'"{arg}"' for arg in sys.argv[1:]]) - result = ctypes.windll.shell32.ShellExecuteW( - None, "runas", sys.executable, - f'"{script}" {params}', None, 1 - ) + params = " ".join([f'"{arg}"' for arg in sys.argv[1:]]) + result = ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, f'"{script}" {params}', None, 1) if result > 32: sys.exit(0) return False @@ -115,6 +112,7 @@ def cmd_detect(args): sensor_found = False try: from calibrate_pro.hardware.i1d3_native import I1D3Driver + devices = I1D3Driver.find_devices() if devices: sensor_found = True @@ -130,6 +128,7 @@ def cmd_detect(args): if not sensor_found: try: from calibrate_pro.hardware.argyll_backend import ArgyllBackend, ArgyllConfig + config = ArgyllConfig() if config.find_argyll(): backend = ArgyllBackend(config) @@ -151,6 +150,7 @@ def cmd_detect(args): # DDC/CI summary try: from calibrate_pro.hardware.ddc_ci import DDCCIController + ddc = DDCCIController() monitors = ddc.enumerate_monitors() if monitors: @@ -158,9 +158,9 @@ def cmd_detect(args): for i, m in enumerate(monitors): caps = m.get("capabilities") if caps and caps.supported_vcp_codes: - print(f" {m.get('name', f'Monitor {i+1}')}: {caps.summary()}") + print(f" {m.get('name', f'Monitor {i + 1}')}: {caps.summary()}") else: - print(f" {m.get('name', f'Monitor {i+1}')}: DDC/CI available") + print(f" {m.get('name', f'Monitor {i + 1}')}: DDC/CI available") print(" Run 'calibrate-pro ddc-info' for detailed capabilities") else: print("\nDDC/CI: no monitors detected") @@ -168,6 +168,7 @@ def cmd_detect(args): pass print() + def cmd_calibrate(args): """Calibrate a display.""" print(f"\nCalibrate Pro v{__version__}") @@ -195,13 +196,13 @@ def cmd_calibrate(args): display = displays[0] # Determine model name - use override if provided - if hasattr(args, 'model') and args.model: + if hasattr(args, "model") and args.model: model_string = args.model else: model_string = display.monitor_name or display.model or f"Display{display.get_display_number()}" print(f"\nCalibrating: {display.monitor_name}") - if hasattr(args, 'model') and args.model: + if hasattr(args, "model") and args.model: print(f"Model Override: {args.model}") print(f"Resolution: {display.width}x{display.height} @ {display.refresh_rate}Hz") @@ -212,7 +213,7 @@ def cmd_calibrate(args): gamut_target = GAMUT_SRGB # Check for profile preset (overrides individual settings) - if hasattr(args, 'profile') and args.profile: + if hasattr(args, "profile") and args.profile: profile_map = { "sRGB": ("sRGB Web Standard", WHITEPOINT_D65, LUMINANCE_CONSUMER_SDR, GAMMA_SRGB, GAMUT_SRGB), "Rec709": ("Rec.709 Broadcast", WHITEPOINT_D65, LUMINANCE_REC709, GAMMA_BT1886, GAMUT_SRGB), @@ -224,7 +225,7 @@ def cmd_calibrate(args): print(f"Profile: {name}") # Individual target overrides - if hasattr(args, 'whitepoint') and args.whitepoint: + if hasattr(args, "whitepoint") and args.whitepoint: wp_map = { "D50": WhitepointPreset.D50, "D55": WhitepointPreset.D55, @@ -235,17 +236,17 @@ def cmd_calibrate(args): if args.whitepoint in wp_map: whitepoint_target = WhitepointTarget(preset=wp_map[args.whitepoint]) - if hasattr(args, 'cct') and args.cct: + if hasattr(args, "cct") and args.cct: whitepoint_target = create_custom_whitepoint(cct=args.cct, name=f"{args.cct}K") - if hasattr(args, 'luminance') and args.luminance: + if hasattr(args, "luminance") and args.luminance: luminance_target = create_custom_luminance( peak=args.luminance, - black=args.black_level if hasattr(args, 'black_level') and args.black_level else 0.0, - hdr_mode=args.luminance > 400 + black=args.black_level if hasattr(args, "black_level") and args.black_level else 0.0, + hdr_mode=args.luminance > 400, ) - if hasattr(args, 'gamma') and args.gamma: + if hasattr(args, "gamma") and args.gamma: gamma_map = { "2.2": GammaPreset.POWER_22, "2.4": GammaPreset.POWER_24, @@ -263,7 +264,7 @@ def cmd_calibrate(args): except ValueError: pass - if hasattr(args, 'gamut') and args.gamut: + if hasattr(args, "gamut") and args.gamut: gamut_map = { "sRGB": GamutPreset.SRGB, "DCI-P3": GamutPreset.DCI_P3, @@ -314,7 +315,7 @@ def progress_callback(message: str, progress: float): generate_icc=not args.no_icc, generate_lut=not args.no_lut, lut_size=args.lut_size, - hdr_mode=luminance_target.is_hdr() + hdr_mode=luminance_target.is_hdr(), ) print("\n") @@ -353,7 +354,9 @@ def cmd_list_targets(args): print("\n--- White Point Presets ---") for wp in get_whitepoint_presets(): - print(f" {wp.preset.value:15s} ({wp.get_cct():.0f}K) - {wp.description if hasattr(wp, 'description') and wp.description else ''}") + print( + f" {wp.preset.value:15s} ({wp.get_cct():.0f}K) - {wp.description if hasattr(wp, 'description') and wp.description else ''}" + ) print("\n--- Luminance Presets ---") for lum in get_luminance_presets(): @@ -368,11 +371,16 @@ def cmd_list_targets(args): print("\n--- Gamut Presets ---") for gam in get_gamut_presets(): wide = " [Wide Gamut]" if gam.is_wide_gamut() else "" - print(f" {gam.preset.value:15s} - {gam.description if hasattr(gam, 'description') and gam.description else ''}{wide}") + print( + f" {gam.preset.value:15s} - {gam.description if hasattr(gam, 'description') and gam.description else ''}{wide}" + ) - print("\nUse these with: calibrate --profile or individual --whitepoint, --luminance, --gamma, --gamut flags") + print( + "\nUse these with: calibrate --profile or individual --whitepoint, --luminance, --gamma, --gamut flags" + ) return 0 + def cmd_verify(args): """Verify calibration accuracy.""" from calibrate_pro.panels.database import PanelDatabase @@ -407,7 +415,7 @@ def cmd_verify(args): # ------------------------------------------------------------------------- # Measured verification (--measured flag) # ------------------------------------------------------------------------- - use_measured = hasattr(args, 'measured') and args.measured + use_measured = hasattr(args, "measured") and args.measured if use_measured: from calibrate_pro.verification.measured_verify import ( @@ -437,8 +445,8 @@ def cmd_verify(args): print("-" * 56) - for patch in result['patches']: - de = patch['delta_e'] + for patch in result["patches"]: + de = patch["delta_e"] status = "PASS" if de < 2.0 else "WARN" if de < 3.0 else "FAIL" print(f" {patch['name']:20s} dE={de:5.2f} [{status}]") @@ -453,19 +461,22 @@ def cmd_verify(args): gs_result = mv.verify_grayscale(display_index=display_index, steps=21) - for step in gs_result['steps']: - level_pct = step['level'] * 100 - de = step['delta_e'] - gamma = step['measured_gamma'] - ge = step['gamma_error'] - print(f" {level_pct:5.1f}% Y={step['measured_luminance']:7.2f} " - f"gamma={gamma:.2f} dE={de:.2f} gamma_err={ge:.2f}") + for step in gs_result["steps"]: + level_pct = step["level"] * 100 + de = step["delta_e"] + gamma = step["measured_gamma"] + ge = step["gamma_error"] + print( + f" {level_pct:5.1f}% Y={step['measured_luminance']:7.2f} " + f"gamma={gamma:.2f} dE={de:.2f} gamma_err={ge:.2f}" + ) print("-" * 56) - print(f"\n Gamma tracking: avg error {gs_result['avg_gamma_error']:.3f} " - f"max error {gs_result['max_gamma_error']:.3f}") - print(f" Color accuracy: avg dE {gs_result['delta_e_avg']:.2f} " - f"max dE {gs_result['delta_e_max']:.2f}") + print( + f"\n Gamma tracking: avg error {gs_result['avg_gamma_error']:.3f} " + f"max error {gs_result['max_gamma_error']:.3f}" + ) + print(f" Color accuracy: avg dE {gs_result['delta_e_avg']:.2f} max dE {gs_result['delta_e_max']:.2f}") print(f" White luminance: {gs_result['white_luminance']:.1f} cd/m2") print(f" Grade: {gs_result['grade']}") @@ -492,9 +503,9 @@ def cmd_verify(args): print("\nColorChecker Verification:") print("-" * 56) - for patch in result['patches']: - de = patch['delta_e'] - cam_de = patch.get('cam16_delta_e', de) + for patch in result["patches"]: + de = patch["delta_e"] + cam_de = patch.get("cam16_delta_e", de) status = "PASS" if de < 2.0 else "WARN" if de < 3.0 else "FAIL" print(f" {patch['name']:20s} dE={de:5.2f} CAM16={cam_de:5.2f} [{status}]") @@ -505,37 +516,39 @@ def cmd_verify(args): print("\n Note: These values are predicted from the panel database,") print(" not measured. For verified accuracy, use --measured with a colorimeter.") - coverage = result.get('gamut_coverage', {}) + coverage = result.get("gamut_coverage", {}) if coverage: - print(f"\n Gamut (2D area): sRGB {coverage.get('srgb_pct', 0):.0f}% " - f"P3 {coverage.get('dci_p3_pct', 0):.0f}% " - f"BT.2020 {coverage.get('bt2020_pct', 0):.0f}%") + print( + f"\n Gamut (2D area): sRGB {coverage.get('srgb_pct', 0):.0f}% " + f"P3 {coverage.get('dci_p3_pct', 0):.0f}% " + f"BT.2020 {coverage.get('bt2020_pct', 0):.0f}%" + ) # 3D color volume (captures luminance-dependent gamut changes) try: from calibrate_pro.display.color_volume import compute_color_volume + primaries = panel.native_primaries vol = compute_color_volume( - panel_primaries=( - primaries.red.as_tuple(), - primaries.green.as_tuple(), - primaries.blue.as_tuple() - ), + panel_primaries=(primaries.red.as_tuple(), primaries.green.as_tuple(), primaries.blue.as_tuple()), panel_white=primaries.white.as_tuple(), lightness_steps=11, hue_steps=36, panel_type=panel.panel_type, - peak_luminance=panel.capabilities.max_luminance_hdr + peak_luminance=panel.capabilities.max_luminance_hdr, + ) + print( + f" Volume (3D): sRGB {vol.srgb_volume_pct:.0f}% " + f"P3 {vol.p3_volume_pct:.0f}% " + f"BT.2020 {vol.bt2020_volume_pct:.0f}% " + f"({vol.relative_to_srgb_pct:.0f}% of sRGB volume)" ) - print(f" Volume (3D): sRGB {vol.srgb_volume_pct:.0f}% " - f"P3 {vol.p3_volume_pct:.0f}% " - f"BT.2020 {vol.bt2020_volume_pct:.0f}% " - f"({vol.relative_to_srgb_pct:.0f}% of sRGB volume)") except Exception: pass return 0 + def cmd_list_panels(args): """List supported panel profiles.""" from calibrate_pro.panels.database import PanelDatabase @@ -555,6 +568,7 @@ def cmd_list_panels(args): print(f"\nTotal: {len(panel_keys)} profiles") print("\nUse 'info ' for detailed information") + def cmd_info(args): """Show information about a panel.""" print(f"\nCalibrate Pro v{__version__}") @@ -590,11 +604,12 @@ def cmd_info(args): print(f" Wide Gamut: {'Yes' if info['capabilities']['wide_gamut'] else 'No'}") print(f" VRR: {'Yes' if info['capabilities']['vrr'] else 'No'}") - if info['notes']: + if info["notes"]: print(f"\n Notes: {info['notes']}") return 0 + def cmd_enable_startup(args): """Enable auto-start at Windows startup.""" from calibrate_pro.utils.startup_manager import enable_auto_start, is_auto_start_enabled @@ -616,6 +631,7 @@ def cmd_enable_startup(args): print("Try running as administrator.") return 1 + def cmd_disable_startup(args): """Disable auto-start.""" from calibrate_pro.utils.startup_manager import disable_auto_start, is_auto_start_enabled @@ -658,7 +674,7 @@ def cmd_generate_profiles(args): print("No displays detected.") return 1 - display_index = (args.display - 1) if hasattr(args, 'display') and args.display else 0 + display_index = (args.display - 1) if hasattr(args, "display") and args.display else 0 if display_index >= len(displays): display_index = 0 @@ -673,7 +689,7 @@ def cmd_generate_profiles(args): print(f"\nPanel: {panel_name} ({panel.panel_type})") # Output directory - output_dir = Path(args.output) if hasattr(args, 'output') and args.output else Path("profiles") + output_dir = Path(args.output) if hasattr(args, "output") and args.output else Path("profiles") output_dir.mkdir(parents=True, exist_ok=True) # Target profiles to generate @@ -682,34 +698,30 @@ def cmd_generate_profiles(args): "primaries": ((0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600)), "white": (0.3127, 0.3290), "gamma": 2.2, - "desc": "sRGB / Rec.709 (web, general use)" + "desc": "sRGB / Rec.709 (web, general use)", }, "DCI-P3": { "primaries": ((0.6800, 0.3200), (0.2650, 0.6900), (0.1500, 0.0600)), "white": (0.3127, 0.3290), # Display P3 uses D65 "gamma": 2.2, - "desc": "Display P3 (Apple, HDR content, wide gamut)" + "desc": "Display P3 (Apple, HDR content, wide gamut)", }, "Rec709": { "primaries": ((0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600)), "white": (0.3127, 0.3290), "gamma": 2.4, # BT.1886 - "desc": "Rec.709 / BT.1886 (broadcast video)" + "desc": "Rec.709 / BT.1886 (broadcast video)", }, "AdobeRGB": { "primaries": ((0.6400, 0.3300), (0.2100, 0.7100), (0.1500, 0.0600)), "white": (0.3127, 0.3290), "gamma": 2.2, - "desc": "Adobe RGB (photography, print)" + "desc": "Adobe RGB (photography, print)", }, } primaries = panel.native_primaries - panel_prims = ( - primaries.red.as_tuple(), - primaries.green.as_tuple(), - primaries.blue.as_tuple() - ) + panel_prims = (primaries.red.as_tuple(), primaries.green.as_tuple(), primaries.blue.as_tuple()) safe_panel = panel_name.replace(" ", "_").replace("/", "_") generated = [] @@ -721,14 +733,13 @@ def cmd_generate_profiles(args): # Build correction matrix for this target target_to_xyz = primaries_to_xyz_matrix( - config["primaries"][0], config["primaries"][1], - config["primaries"][2], config["white"] + config["primaries"][0], config["primaries"][1], config["primaries"][2], config["white"] ) panel_to_xyz = primaries_to_xyz_matrix( - panel_prims[0], panel_prims[1], panel_prims[2], - primaries.white.as_tuple() + panel_prims[0], panel_prims[1], panel_prims[2], primaries.white.as_tuple() ) import numpy as np + xyz_to_panel = np.linalg.inv(panel_to_xyz) color_matrix = xyz_to_panel @ target_to_xyz @@ -742,7 +753,7 @@ def cmd_generate_profiles(args): gamma_blue=panel.gamma_blue.gamma, color_matrix=color_matrix, title=f"{safe_panel} {profile_name}", - target_gamma=config["gamma"] + target_gamma=config["gamma"], ) lut_path = output_dir / f"{safe_panel}_{profile_name}.cube" @@ -754,7 +765,7 @@ def cmd_generate_profiles(args): green_primary=primaries.green.as_tuple(), blue_primary=primaries.blue.as_tuple(), white_point=primaries.white.as_tuple(), - gamma=config["gamma"] + gamma=config["gamma"], ) icc_path = output_dir / f"{safe_panel}_{profile_name}.icc" icc.save(icc_path) @@ -786,7 +797,7 @@ def cmd_restore(args): print(f"\nCalibrate Pro v{__version__} - Restore Defaults") print("=" * 60) - display_index = args.display - 1 if hasattr(args, 'display') and args.display else None + display_index = args.display - 1 if hasattr(args, "display") and args.display else None displays = enumerate_displays() if not displays: @@ -808,6 +819,7 @@ def cmd_restore(args): dwm_removed = False try: from calibrate_pro.lut_system.dwm_lut import remove_lut as dwm_remove_lut + if dwm_remove_lut(idx): print(" DWM LUT removed") dwm_removed = True @@ -827,6 +839,7 @@ def cmd_restore(args): # 3. Clear saved calibration state try: from calibrate_pro.utils.startup_manager import StartupManager + manager = StartupManager() manager.clear_calibration(idx) except Exception: @@ -835,9 +848,10 @@ def cmd_restore(args): restored += 1 # 4. Optionally disable auto-start - if hasattr(args, 'disable_startup') and args.disable_startup: + if hasattr(args, "disable_startup") and args.disable_startup: try: from calibrate_pro.utils.startup_manager import StartupManager + manager = StartupManager() manager.disable_startup() print("\n[OK] Auto-start disabled") @@ -858,6 +872,7 @@ def cmd_gui(args): try: if sys.platform == "win32": import ctypes + ctypes.windll.kernel32.FreeConsole() except Exception: pass @@ -868,7 +883,7 @@ def cmd_gui(args): from calibrate_pro.gui.app import CalibrateProWindow except ImportError as e: print("Error: PyQt6 is required for GUI mode.") - print("Install with: pip install \".[gui]\"") + print('Install with: pip install ".[gui]"') print(f"Details: {e}") return 1 @@ -893,11 +908,13 @@ def cmd_native_calibrate(args): print("=" * 65) # OLED calibration matrix from EEPROM - OLED_MATRIX = np.array([ - [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], - [-0.00004481, 0.00035042, 0.08032401], - ]) + OLED_MATRIX = np.array( + [ + [0.03836831, -0.02175997, 0.01696057], + [0.01449629, 0.01611903, 0.00057150], + [-0.00004481, 0.00035042, 0.08032401], + ] + ) M_MASK = 0xFFFFFFFF @@ -912,51 +929,73 @@ def cmd_native_calibrate(args): return 1 # Unlock (NEC OEM key) - k0, k1 = 0xa9119479, 0x5b168761 - cmd = bytearray(65); cmd[0] = 0; cmd[1] = 0x99 - device.write(cmd); time.sleep(0.2) + k0, k1 = 0xA9119479, 0x5B168761 + cmd = bytearray(65) + cmd[0] = 0 + cmd[1] = 0x99 + device.write(cmd) + time.sleep(0.2) c = bytes(device.read(64, timeout_ms=3000)) sc = bytearray(8) - for i in range(8): sc[i] = c[3] ^ c[35 + i] - ci0 = (sc[3]<<24)+(sc[0]<<16)+(sc[4]<<8)+sc[6] - ci1 = (sc[1]<<24)+(sc[7]<<16)+(sc[2]<<8)+sc[5] + for i in range(8): + sc[i] = c[3] ^ c[35 + i] + ci0 = (sc[3] << 24) + (sc[0] << 16) + (sc[4] << 8) + sc[6] + ci1 = (sc[1] << 24) + (sc[7] << 16) + (sc[2] << 8) + sc[5] nk0, nk1 = (-k0) & M_MASK, (-k1) & M_MASK - co = [(nk0-ci1)&M_MASK, (nk1-ci0)&M_MASK, (ci1*nk0)&M_MASK, (ci0*nk1)&M_MASK] + co = [(nk0 - ci1) & M_MASK, (nk1 - ci0) & M_MASK, (ci1 * nk0) & M_MASK, (ci0 * nk1) & M_MASK] s = sum(sc) - for sh in [0, 8, 16, 24]: s += (nk0>>sh)&0xFF; s += (nk1>>sh)&0xFF + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF s0, s1 = s & 0xFF, (s >> 8) & 0xFF sr = bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF; sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF; sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF; sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF; sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF; sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF; sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF; sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF; sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb = bytearray(65); rb[0] = 0; rb[1] = 0x9A - for i in range(16): rb[25+i] = c[2] ^ sr[i] - device.write(rb); time.sleep(0.3); device.read(64, timeout_ms=3000) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) print(" Sensor unlocked.") def measure_xyz_native(r, g, b): intclks = int(1.0 * 12000000) - cmd = bytearray(65); cmd[0] = 0x00; cmd[1] = 0x01 - struct.pack_into(' 0.3: return OLED_MATRIX @ freq return None # Find display from calibrate_pro.panels.detection import enumerate_displays + displays = enumerate_displays() dx, dy, dw, dh = 0, 0, 3840, 2160 for d in displays: @@ -1007,6 +1046,7 @@ def display_fn(r, g, b): print("\nApplying LUT via dwm_lut...") try: from calibrate_pro.lut_system.dwm_lut import DwmLutController + dwm = DwmLutController() if dwm.is_available: dwm.load_lut_file(0, lut_path) @@ -1090,7 +1130,7 @@ def cmd_ddc_info(args): db = PanelDatabase() panel_key = identify_display(displays[i]) panel = db.get_panel(panel_key) if panel_key else None - if panel and hasattr(panel, 'ddc') and panel.ddc: + if panel and hasattr(panel, "ddc") and panel.ddc: ddc_rec = panel.ddc print(f"\n Calibration Recommendations ({panel.name}):") if ddc_rec.picture_mode: @@ -1114,7 +1154,7 @@ def cmd_ddc_info(args): for line in diag: print(f" {line}") - if hasattr(args, 'scan') and args.scan: + if hasattr(args, "scan") and args.scan: print("\n Full VCP scan (this takes a few seconds)...") found = ddc.scan_all_vcp_codes(monitor) print(f" Found {len(found)} supported VCP codes:") @@ -1190,7 +1230,7 @@ def cmd_ddc_calibrate(args): database = PanelDatabase() # Use command-line model override, or auto-identify from EDID/fingerprint - if hasattr(args, 'model') and args.model: + if hasattr(args, "model") and args.model: model_string = args.model panel = database.find_panel(model_string) else: @@ -1244,7 +1284,7 @@ def cmd_ddc_calibrate(args): (primaries.red.x, primaries.red.y), (primaries.green.x, primaries.green.y), (primaries.blue.x, primaries.blue.y), - (primaries.white.x, primaries.white.y) + (primaries.white.x, primaries.white.y), ) # D65 reference white in Lab (use D65 as reference illuminant - will be [100, 0, 0]) @@ -1292,7 +1332,7 @@ def simulate_white_point(r_gain, g_gain, b_gain): print("=" * 60) # Set initial values (start slightly off to show iteration) - target_brightness = 50 if not hasattr(args, 'brightness') or not args.brightness else args.brightness + target_brightness = 50 if not hasattr(args, "brightness") or not args.brightness else args.brightness target_contrast = 80 # Start with non-optimal values to demonstrate iteration @@ -1352,12 +1392,12 @@ def simulate_white_point(r_gain, g_gain, b_gain): time.sleep(0.2) # Track best solution and accumulated error for integral control - best_de = float('inf') + best_de = float("inf") best_rgb = (r_gain, g_gain, b_gain) accumulated_r = 0.0 accumulated_g = 0.0 accumulated_b = 0.0 - float('inf') + float("inf") stall_count = 0 while iteration < max_iterations: @@ -1376,8 +1416,10 @@ def simulate_white_point(r_gain, g_gain, b_gain): # Display current state status = "*" if delta_e == best_de else " " - print(f" Iter {iteration:2d}: RGB Gain [{r_gain:3d}, {g_gain:3d}, {b_gain:3d}] " - f"xy=({curr_x:.4f}, {curr_y:.4f}) dE={delta_e:.2f} {status}") + print( + f" Iter {iteration:2d}: RGB Gain [{r_gain:3d}, {g_gain:3d}, {b_gain:3d}] " + f"xy=({curr_x:.4f}, {curr_y:.4f}) dE={delta_e:.2f} {status}" + ) # Check if converged if delta_e < tolerance: @@ -1475,7 +1517,7 @@ def simulate_white_point(r_gain, g_gain, b_gain): print(" (Grayscale/gamma/white point handled by DDC/CI)") # Create a minimal LUT that only does gamut mapping - lut_size = args.lut_size if hasattr(args, 'lut_size') else 33 + lut_size = args.lut_size if hasattr(args, "lut_size") else 33 # Use sensorless engine for gamut-only LUT engine = SensorlessEngine() @@ -1485,7 +1527,7 @@ def simulate_white_point(r_gain, g_gain, b_gain): lut = engine.generate_gamut_only_lut(lut_size) # Save LUT - output_dir = Path(args.output) if hasattr(args, 'output') and args.output else Path(".") + output_dir = Path(args.output) if hasattr(args, "output") and args.output else Path(".") output_dir.mkdir(parents=True, exist_ok=True) lut_filename = f"{model_string.replace(' ', '_')}_gamut_only.cube" @@ -1601,7 +1643,7 @@ def cmd_refine(args): print("No displays detected.") return 1 - display_index = (args.display - 1) if hasattr(args, 'display') and args.display else 0 + display_index = (args.display - 1) if hasattr(args, "display") and args.display else 0 if display_index >= len(displays): display_index = 0 @@ -1617,16 +1659,12 @@ def cmd_refine(args): # Determine measurement mode mode = "argyll" - if hasattr(args, 'manual') and args.manual: + if hasattr(args, "manual") and args.manual: mode = "manual" - elif hasattr(args, 'simulated') and args.simulated: + elif hasattr(args, "simulated") and args.simulated: mode = "simulated" - config = MeasurementConfig( - display_index=display_index, - mode=mode, - argyll_path=getattr(args, 'argyll_path', None) - ) + config = MeasurementConfig(display_index=display_index, mode=mode, argyll_path=getattr(args, "argyll_path", None)) print(f"Measurement: {mode}") @@ -1634,6 +1672,7 @@ def cmd_refine(args): if measure_fn is None and mode == "argyll": # Determine why initialization failed from calibrate_pro.hardware.argyll_backend import ArgyllConfig as _AC + _ac = _AC() _found = _ac.find_argyll() if _found: @@ -1647,22 +1686,19 @@ def cmd_refine(args): print(" calibrate-pro refine --simulated (testing without hardware)") return 1 - output_dir = Path(args.output) if hasattr(args, 'output') and args.output else None + output_dir = Path(args.output) if hasattr(args, "output") and args.output else None if output_dir is None: output_dir = Path.home() / "Documents" / "Calibrate Pro" / "Calibrations" def progress(msg, pct): - print(f" [{int(pct*100):3d}%] {msg}") + print(f" [{int(pct * 100):3d}%] {msg}") - engine = HybridCalibrationEngine( - measure_fn=measure_fn, - progress_fn=progress - ) + engine = HybridCalibrationEngine(measure_fn=measure_fn, progress_fn=progress) result = engine.calibrate(panel, output_dir) # Cleanup - if measure_fn and hasattr(measure_fn, 'close'): + if measure_fn and hasattr(measure_fn, "close"): measure_fn.close() print(f"\n{'=' * 60}") @@ -1698,10 +1734,10 @@ def cmd_auto(args): print("No instruments required. No input needed.") print("Detecting and calibrating all connected displays...\n") - output_dir = Path(args.output) if hasattr(args, 'output') and args.output else None - no_ddc = hasattr(args, 'no_ddc') and args.no_ddc - no_persist = hasattr(args, 'no_persist') and args.no_persist - use_hdr = hasattr(args, 'hdr') and args.hdr + output_dir = Path(args.output) if hasattr(args, "output") and args.output else None + no_ddc = hasattr(args, "no_ddc") and args.no_ddc + no_persist = hasattr(args, "no_persist") and args.no_persist + use_hdr = hasattr(args, "hdr") and args.hdr if use_hdr: print("Mode: HDR (PQ/ST.2084)\n") @@ -1717,11 +1753,7 @@ def progress_callback(message: str, progress: float, display_name: str): sys.stdout.flush() results = auto_calibrate_all( - output_dir=output_dir, - callback=progress_callback, - use_ddc=not no_ddc, - persist=not no_persist, - hdr_mode=use_hdr + output_dir=output_dir, callback=progress_callback, use_ddc=not no_ddc, persist=not no_persist, hdr_mode=use_hdr ) # Clean output @@ -1740,9 +1772,11 @@ def progress_callback(message: str, progress: float, display_name: str): print(f" Delta E: {result.delta_e_predicted:.2f} (sensorless) | Grade: {grade}") if coverage: - print(f" Gamut: sRGB {coverage.get('srgb_pct', 0):.0f}% " - f"P3 {coverage.get('dci_p3_pct', 0):.0f}% " - f"BT.2020 {coverage.get('bt2020_pct', 0):.0f}%") + print( + f" Gamut: sRGB {coverage.get('srgb_pct', 0):.0f}% " + f"P3 {coverage.get('dci_p3_pct', 0):.0f}% " + f"BT.2020 {coverage.get('bt2020_pct', 0):.0f}%" + ) if result.lut_application_method: methods = { @@ -1781,9 +1815,10 @@ def progress_callback(message: str, progress: float, display_name: str): print("=" * 60) # Open first report in browser - if report_paths and not (hasattr(args, 'no_report') and args.no_report): + if report_paths and not (hasattr(args, "no_report") and args.no_report): try: import webbrowser + webbrowser.open(str(report_paths[0].absolute())) except Exception: pass @@ -1795,7 +1830,7 @@ def cmd_status(args): """Show calibration status for all displays.""" from calibrate_pro.services.drift_monitor import print_calibration_status - max_age = args.max_age if hasattr(args, 'max_age') and args.max_age else 30 + max_age = args.max_age if hasattr(args, "max_age") and args.max_age else 30 print_calibration_status(max_age_days=max_age) return 0 @@ -1805,7 +1840,7 @@ def cmd_plugins(args): from calibrate_pro.plugins.manager import print_discovered_plugins dirs = None - if hasattr(args, 'plugin_dir') and args.plugin_dir: + if hasattr(args, "plugin_dir") and args.plugin_dir: dirs = [args.plugin_dir] print_discovered_plugins(plugin_dirs=dirs) return 0 @@ -1814,6 +1849,7 @@ def cmd_plugins(args): def cmd_tray(args): """Launch the system tray application.""" from calibrate_pro.tray.tray_app import run_tray_app + return run_tray_app() @@ -1870,14 +1906,10 @@ def main(): parser = argparse.ArgumentParser( description="Calibrate Pro - Professional Display Calibration Suite", formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__ + epilog=__doc__, ) - parser.add_argument( - "--version", "-V", - action="version", - version=f"Calibrate Pro v{__version__}" - ) + parser.add_argument("--version", "-V", action="version", version=f"Calibrate Pro v{__version__}") subparsers = parser.add_subparsers(dest="command", help="Available commands") @@ -1886,101 +1918,60 @@ def main(): # calibrate command calibrate_parser = subparsers.add_parser("calibrate", help="Calibrate a display") + calibrate_parser.add_argument("--display", "-d", type=int, help="Display number (1-based)") + calibrate_parser.add_argument("--model", type=str, help="Override monitor model name (e.g., PG27UCDM, G85SB)") calibrate_parser.add_argument( - "--display", "-d", - type=int, - help="Display number (1-based)" - ) - calibrate_parser.add_argument( - "--model", - type=str, - help="Override monitor model name (e.g., PG27UCDM, G85SB)" - ) - calibrate_parser.add_argument( - "--output", "-o", - type=str, - default=".", - help="Output directory for calibration files" - ) - calibrate_parser.add_argument( - "--mode", "-m", - choices=["sensorless", "colorimeter", "hybrid"], - default="sensorless", - help="Calibration mode" - ) - calibrate_parser.add_argument( - "--lut-size", - type=int, - choices=[17, 33, 65], - default=33, - help="3D LUT grid size" + "--output", "-o", type=str, default=".", help="Output directory for calibration files" ) calibrate_parser.add_argument( - "--no-icc", - action="store_true", - help="Skip ICC profile generation" - ) - calibrate_parser.add_argument( - "--no-lut", - action="store_true", - help="Skip 3D LUT generation" + "--mode", "-m", choices=["sensorless", "colorimeter", "hybrid"], default="sensorless", help="Calibration mode" ) + calibrate_parser.add_argument("--lut-size", type=int, choices=[17, 33, 65], default=33, help="3D LUT grid size") + calibrate_parser.add_argument("--no-icc", action="store_true", help="Skip ICC profile generation") + calibrate_parser.add_argument("--no-lut", action="store_true", help="Skip 3D LUT generation") # Target settings - Profile preset calibrate_parser.add_argument( - "--profile", "-p", + "--profile", + "-p", choices=["sRGB", "Rec709", "DCI-P3", "HDR10"], - help="Calibration profile preset (overrides individual targets)" + help="Calibration profile preset (overrides individual targets)", ) # Target settings - White point calibrate_parser.add_argument( - "--whitepoint", "-w", + "--whitepoint", + "-w", choices=["D50", "D55", "D65", "D75", "DCI"], - help="White point target (D50=5000K, D65=6500K, DCI=6300K)" - ) - calibrate_parser.add_argument( - "--cct", - type=int, - help="Custom white point CCT in Kelvin (e.g., 6500)" + help="White point target (D50=5000K, D65=6500K, DCI=6300K)", ) + calibrate_parser.add_argument("--cct", type=int, help="Custom white point CCT in Kelvin (e.g., 6500)") # Target settings - Luminance calibrate_parser.add_argument( - "--luminance", "-l", - type=float, - help="Target peak luminance in cd/m2 (e.g., 120 for SDR, 1000 for HDR)" + "--luminance", "-l", type=float, help="Target peak luminance in cd/m2 (e.g., 120 for SDR, 1000 for HDR)" ) calibrate_parser.add_argument( - "--black-level", - type=float, - help="Target black level in cd/m2 (e.g., 0.05 for LCD, 0.0001 for OLED)" + "--black-level", type=float, help="Target black level in cd/m2 (e.g., 0.05 for LCD, 0.0001 for OLED)" ) # Target settings - Gamma calibrate_parser.add_argument( - "--gamma", "-g", - help="Gamma target: 2.2, 2.4, sRGB, BT1886, PQ, HLG, or custom value" + "--gamma", "-g", help="Gamma target: 2.2, 2.4, sRGB, BT1886, PQ, HLG, or custom value" ) # Target settings - Gamut calibrate_parser.add_argument( - "--gamut", - choices=["sRGB", "DCI-P3", "Display-P3", "BT2020", "AdobeRGB"], - help="Target color gamut" + "--gamut", choices=["sRGB", "DCI-P3", "Display-P3", "BT2020", "AdobeRGB"], help="Target color gamut" ) # verify command verify_parser = subparsers.add_parser("verify", help="Verify calibration accuracy") - verify_parser.add_argument( - "--display", "-d", - type=int, - help="Display number (1-based)" - ) + verify_parser.add_argument("--display", "-d", type=int, help="Display number (1-based)") verify_parser.add_argument( "--measured", action="store_true", - help="Use colorimeter-based measured verification (requires ArgyllCMS or manual entry)" + help="Use colorimeter-based measured verification (requires ArgyllCMS or manual entry)", ) # list-panels command @@ -1991,11 +1982,7 @@ def main(): # info command info_parser = subparsers.add_parser("info", help="Show panel information") - info_parser.add_argument( - "panel", - type=str, - help="Panel key (e.g., PG27UCDM)" - ) + info_parser.add_argument("panel", type=str, help="Panel key (e.g., PG27UCDM)") # enable-startup command subparsers.add_parser("enable-startup", help="Enable auto-start at Windows boot") @@ -2004,75 +1991,34 @@ def main(): subparsers.add_parser("disable-startup", help="Disable auto-start") # restore command - undo calibration - restore_parser = subparsers.add_parser( - "restore", - help="Restore display to defaults (undo calibration)" - ) - restore_parser.add_argument( - "--display", "-d", - type=int, - help="Display number (1-based). Omit to restore all displays." - ) + restore_parser = subparsers.add_parser("restore", help="Restore display to defaults (undo calibration)") restore_parser.add_argument( - "--disable-startup", - action="store_true", - help="Also disable auto-start" + "--display", "-d", type=int, help="Display number (1-based). Omit to restore all displays." ) + restore_parser.add_argument("--disable-startup", action="store_true", help="Also disable auto-start") # generate-profiles command - multi-profile generation profiles_gen_parser = subparsers.add_parser( - "generate-profiles", - help="Generate sRGB, P3, Rec.709, AdobeRGB profiles in one pass" - ) - profiles_gen_parser.add_argument( - "--display", "-d", - type=int, - help="Display number (1-based)" + "generate-profiles", help="Generate sRGB, P3, Rec.709, AdobeRGB profiles in one pass" ) + profiles_gen_parser.add_argument("--display", "-d", type=int, help="Display number (1-based)") profiles_gen_parser.add_argument( - "--output", "-o", - type=str, - default="profiles", - help="Output directory for profile files" + "--output", "-o", type=str, default="profiles", help="Output directory for profile files" ) # match command - multi-display matching # hdr-status command - subparsers.add_parser( - "hdr-status", - help="Show HDR mode status for all displays" - ) + subparsers.add_parser("hdr-status", help="Show HDR mode status for all displays") - subparsers.add_parser( - "match", - help="Analyze and match multiple displays for consistent appearance" - ) + subparsers.add_parser("match", help="Analyze and match multiple displays for consistent appearance") # refine command - hybrid calibration with colorimeter - refine_parser = subparsers.add_parser( - "refine", - help="Refine calibration using a colorimeter (ArgyllCMS)" - ) - refine_parser.add_argument( - "--display", "-d", type=int, - help="Display number (1-based)" - ) - refine_parser.add_argument( - "--output", "-o", type=str, - help="Output directory" - ) - refine_parser.add_argument( - "--manual", action="store_true", - help="Manual XYZ entry mode (no ArgyllCMS needed)" - ) - refine_parser.add_argument( - "--simulated", action="store_true", - help="Simulated measurement mode (for testing)" - ) - refine_parser.add_argument( - "--argyll-path", type=str, - help="Path to ArgyllCMS bin directory" - ) + refine_parser = subparsers.add_parser("refine", help="Refine calibration using a colorimeter (ArgyllCMS)") + refine_parser.add_argument("--display", "-d", type=int, help="Display number (1-based)") + refine_parser.add_argument("--output", "-o", type=str, help="Output directory") + refine_parser.add_argument("--manual", action="store_true", help="Manual XYZ entry mode (no ArgyllCMS needed)") + refine_parser.add_argument("--simulated", action="store_true", help="Simulated measurement mode (for testing)") + refine_parser.add_argument("--argyll-path", type=str, help="Path to ArgyllCMS bin directory") # gui command - launches professional calibration GUI subparsers.add_parser("gui", help="Launch Professional Calibration GUI (runs as admin)") @@ -2081,34 +2027,17 @@ def main(): subparsers.add_parser("hdr", help="Launch HDR Calibration GUI (runs as admin)") # auto command - fully automatic zero-input calibration - auto_parser = subparsers.add_parser( - "auto", - help="Fully automatic calibration (zero input required)" - ) - auto_parser.add_argument( - "--output", "-o", - type=str, - help="Output directory for calibration files (default: ~/Documents/Calibrate Pro)" - ) + auto_parser = subparsers.add_parser("auto", help="Fully automatic calibration (zero input required)") auto_parser.add_argument( - "--no-ddc", - action="store_true", - help="Skip DDC/CI hardware adjustments (software LUT only)" + "--output", "-o", type=str, help="Output directory for calibration files (default: ~/Documents/Calibrate Pro)" ) auto_parser.add_argument( - "--no-persist", - action="store_true", - help="Don't register for auto-start or save state" + "--no-ddc", action="store_true", help="Skip DDC/CI hardware adjustments (software LUT only)" ) + auto_parser.add_argument("--no-persist", action="store_true", help="Don't register for auto-start or save state") + auto_parser.add_argument("--no-report", action="store_true", help="Don't open calibration report in browser") auto_parser.add_argument( - "--no-report", - action="store_true", - help="Don't open calibration report in browser" - ) - auto_parser.add_argument( - "--hdr", - action="store_true", - help="Generate HDR (PQ/ST.2084) calibration LUT instead of SDR" + "--hdr", action="store_true", help="Generate HDR (PQ/ST.2084) calibration LUT instead of SDR" ) # tray command - system tray application @@ -2116,157 +2045,69 @@ def main(): # patterns command - fullscreen test pattern viewer patterns_parser = subparsers.add_parser("patterns", help="Display fullscreen test patterns") - patterns_parser.add_argument( - "--display", "-d", - type=int, - help="Display number (1-based, default: primary)" - ) + patterns_parser.add_argument("--display", "-d", type=int, help="Display number (1-based, default: primary)") # uniformity command - screen uniformity analysis - uniformity_parser = subparsers.add_parser( - "uniformity", - help="Measure and analyse screen uniformity" - ) - uniformity_parser.add_argument( - "--rows", type=int, default=5, - help="Grid rows (default: 5)" - ) - uniformity_parser.add_argument( - "--cols", type=int, default=5, - help="Grid columns (default: 5)" - ) - uniformity_parser.add_argument( - "--width", type=int, default=3840, - help="Display width in pixels (default: 3840)" - ) - uniformity_parser.add_argument( - "--height", type=int, default=2160, - help="Display height in pixels (default: 2160)" - ) + uniformity_parser = subparsers.add_parser("uniformity", help="Measure and analyse screen uniformity") + uniformity_parser.add_argument("--rows", type=int, default=5, help="Grid rows (default: 5)") + uniformity_parser.add_argument("--cols", type=int, default=5, help="Grid columns (default: 5)") + uniformity_parser.add_argument("--width", type=int, default=3840, help="Display width in pixels (default: 3840)") + uniformity_parser.add_argument("--height", type=int, default=2160, help="Display height in pixels (default: 2160)") uniformity_parser.add_argument( - "--simulated", action="store_true", - help="Generate simulated uniformity data for testing" + "--simulated", action="store_true", help="Generate simulated uniformity data for testing" ) # export-panel command - export panel profile as community JSON export_panel_parser = subparsers.add_parser( - "export-panel", - help="Export current display panel profile as shareable JSON" + "export-panel", help="Export current display panel profile as shareable JSON" ) + export_panel_parser.add_argument("--display", "-d", type=int, help="Display number (1-based)") export_panel_parser.add_argument( - "--display", "-d", type=int, - help="Display number (1-based)" - ) - export_panel_parser.add_argument( - "--output", "-o", type=str, - help="Output file path (default: _community.json)" + "--output", "-o", type=str, help="Output file path (default: _community.json)" ) # import-panel command - import a community panel JSON import_panel_parser = subparsers.add_parser( - "import-panel", - help="Import a community panel JSON into the local database" - ) - import_panel_parser.add_argument( - "file", type=str, - help="Path to the community panel JSON file" + "import-panel", help="Import a community panel JSON into the local database" ) + import_panel_parser.add_argument("file", type=str, help="Path to the community panel JSON file") # ddc-calibrate command - DDC/CI-first calibration ddc_parser = subparsers.add_parser("ddc-calibrate", help="DDC/CI-first calibration (hardware before LUT)") - ddc_parser.add_argument( - "--display", "-d", - type=int, - help="Display number (1-based)" - ) - ddc_parser.add_argument( - "--model", - type=str, - help="Override monitor model name" - ) - ddc_parser.add_argument( - "--output", "-o", - type=str, - default=".", - help="Output directory for LUT files" - ) - ddc_parser.add_argument( - "--brightness", "-b", - type=int, - default=50, - help="Target brightness (0-100)" - ) - ddc_parser.add_argument( - "--lut-size", - type=int, - choices=[17, 33, 65], - default=33, - help="3D LUT grid size" - ) - ddc_parser.add_argument( - "--no-lut", - action="store_true", - help="Skip LUT generation (DDC/CI only)" - ) + ddc_parser.add_argument("--display", "-d", type=int, help="Display number (1-based)") + ddc_parser.add_argument("--model", type=str, help="Override monitor model name") + ddc_parser.add_argument("--output", "-o", type=str, default=".", help="Output directory for LUT files") + ddc_parser.add_argument("--brightness", "-b", type=int, default=50, help="Target brightness (0-100)") + ddc_parser.add_argument("--lut-size", type=int, choices=[17, 33, 65], default=33, help="3D LUT grid size") + ddc_parser.add_argument("--no-lut", action="store_true", help="Skip LUT generation (DDC/CI only)") # ddc-info command - show DDC/CI capabilities - ddc_info_parser = subparsers.add_parser( - "ddc-info", - help="Show DDC/CI capabilities for connected monitors" - ) + ddc_info_parser = subparsers.add_parser("ddc-info", help="Show DDC/CI capabilities for connected monitors") ddc_info_parser.add_argument( - "--scan", action="store_true", - help="Brute-force scan all 256 VCP codes (slow but thorough)" + "--scan", action="store_true", help="Brute-force scan all 256 VCP codes (slow but thorough)" ) # native-calibrate command - native USB colorimeter calibration native_parser = subparsers.add_parser( - "native-calibrate", - help="Calibrate using native i1Display3 driver (no ArgyllCMS)" + "native-calibrate", help="Calibrate using native i1Display3 driver (no ArgyllCMS)" ) native_parser.add_argument( - "--lut-size", type=int, choices=[17, 33, 65], default=33, - help="3D LUT grid size (default: 33)" - ) - native_parser.add_argument( - "--steps", type=int, default=17, - help="TRC measurement steps per channel (default: 17)" - ) - native_parser.add_argument( - "--apply", action="store_true", - help="Apply LUT via dwm_lut after generation" - ) - native_parser.add_argument( - "--verify", action="store_true", - help="Run ColorChecker verification after calibration" - ) - native_parser.add_argument( - "--output", "-o", type=str, - help="Output directory for LUT file" + "--lut-size", type=int, choices=[17, 33, 65], default=33, help="3D LUT grid size (default: 33)" ) + native_parser.add_argument("--steps", type=int, default=17, help="TRC measurement steps per channel (default: 17)") + native_parser.add_argument("--apply", action="store_true", help="Apply LUT via dwm_lut after generation") + native_parser.add_argument("--verify", action="store_true", help="Run ColorChecker verification after calibration") + native_parser.add_argument("--output", "-o", type=str, help="Output directory for LUT file") # status command - calibration age / drift detection - status_parser = subparsers.add_parser( - "status", - help="Show calibration status and age for all displays" - ) + status_parser = subparsers.add_parser("status", help="Show calibration status and age for all displays") status_parser.add_argument( - "--max-age", - type=int, - default=30, - help="Days after which a calibration is considered stale (default: 30)" + "--max-age", type=int, default=30, help="Days after which a calibration is considered stale (default: 30)" ) # plugins command - list discovered plugins - plugins_parser = subparsers.add_parser( - "plugins", - help="List discovered plugins" - ) - plugins_parser.add_argument( - "--plugin-dir", - type=str, - help="Override plugin directory to scan" - ) + plugins_parser = subparsers.add_parser("plugins", help="List discovered plugins") + plugins_parser.add_argument("--plugin-dir", type=str, help="Override plugin directory to scan") args = parser.parse_args() @@ -2318,12 +2159,15 @@ def main(): return cmd_plugins(args) elif args.command == "uniformity": from calibrate_pro.display.uniformity import cmd_uniformity + return cmd_uniformity(args) elif args.command == "export-panel": from calibrate_pro.community.database import cmd_export_panel + return cmd_export_panel(args) elif args.command == "import-panel": from calibrate_pro.community.database import cmd_import_panel + return cmd_import_panel(args) else: # No command specified — launch the GUI diff --git a/calibrate_pro/panels/builtin_panels.py b/calibrate_pro/panels/builtin_panels.py index 16910c0..ae25a65 100644 --- a/calibrate_pro/panels/builtin_panels.py +++ b/calibrate_pro/panels/builtin_panels.py @@ -5,7 +5,6 @@ used for sensorless calibration when no external profile is available. """ - from calibrate_pro.panels.panel_types import ( ChromaticityCoord, DDCRecommendations, @@ -34,7 +33,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6795, 0.3095), green=ChromaticityCoord(0.2325, 0.7115), blue=ChromaticityCoord(0.1375, 0.0495), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2020, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1980, offset=0.0, linear_portion=0.0), @@ -48,7 +47,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom 1", @@ -64,11 +63,11 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom 1 picture mode for unlocked RGB gain controls. " - "Disable ELMB Sync for stable DDC communication. " - "VRR (Adaptive-Sync) can remain on. Uniform Brightness recommended off. " - "OSD gamma options: 1.8, 2.0, 2.2, 2.4, 2.6." + "Disable ELMB Sync for stable DDC communication. " + "VRR (Adaptive-Sync) can remain on. Uniform Brightness recommended off. " + "OSD gamma options: 1.8, 2.0, 2.2, 2.4, 2.6.", ), - notes="ASUS ROG Swift 27-inch 4K 240Hz QD-OLED. Samsung Display 2024 panel. 92% BT.2020." + notes="ASUS ROG Swift 27-inch 4K 240Hz QD-OLED. Samsung Display 2024 panel. 92% BT.2020.", ) # Samsung Odyssey OLED G85SB (Samsung QD-OLED panel) @@ -81,7 +80,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6780, 0.3080), green=ChromaticityCoord(0.2340, 0.7100), blue=ChromaticityCoord(0.1380, 0.0510), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2050, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1980, offset=0.0, linear_portion=0.0), @@ -95,7 +94,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -111,11 +110,11 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom picture mode for DDC/CI RGB gain access. " - "Disable game features (Game Mode, VRR) during calibration for stable DDC. " - "Dynamic Brightness must be off. Core Lighting sync may interfere - disable. " - "Color temperature presets: Custom, Warm, Normal, Cool." + "Disable game features (Game Mode, VRR) during calibration for stable DDC. " + "Dynamic Brightness must be off. Core Lighting sync may interfere - disable. " + "Color temperature presets: Custom, Warm, Normal, Cool.", ), - notes="Samsung QD-OLED with wider gamut than WOLED. May need slight chroma adjustment." + notes="Samsung QD-OLED with wider gamut than WOLED. May need slight chroma adjustment.", ) # Dell Alienware AW3423DW (Samsung QD-OLED) @@ -128,7 +127,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6780, 0.3080), green=ChromaticityCoord(0.2340, 0.7100), blue=ChromaticityCoord(0.1380, 0.0510), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2100, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1950, offset=0.0, linear_portion=0.0), @@ -142,7 +141,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom Color", @@ -158,11 +157,11 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom Color mode for full DDC/CI control over RGB gains. " - "Disable Smart HDR (Creator mode) as it interferes with DDC adjustments. " - "Gamma options in OSD: 2.2, 2.4, sRGB, BT.1886, PQ. " - "Set color space to Wide for full gamut, or sRGB to clamp." + "Disable Smart HDR (Creator mode) as it interferes with DDC adjustments. " + "Gamma options in OSD: 2.2, 2.4, sRGB, BT.1886, PQ. " + "Set color space to Wide for full gamut, or sRGB to clamp.", ), - notes="First consumer QD-OLED. Same panel as G85SB with Dell calibration." + notes="First consumer QD-OLED. Same panel as G85SB with Dell calibration.", ) # Dell Alienware AW3423DWF (Samsung QD-OLED - FreeSync Edition) @@ -176,7 +175,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6780, 0.3080), green=ChromaticityCoord(0.2340, 0.7100), blue=ChromaticityCoord(0.1380, 0.0510), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2080, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1960, offset=0.0, linear_portion=0.0), @@ -190,7 +189,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom Color", @@ -206,11 +205,11 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom Color mode for full DDC/CI control over RGB gains. " - "Disable Creator mode Smart HDR as it overrides DDC brightness/contrast. " - "Gamma options: 2.2, 2.4, sRGB, BT.1886, PQ. " - "FreeSync edition - no G-Sync module, DDC/CI more reliable over DP." + "Disable Creator mode Smart HDR as it overrides DDC brightness/contrast. " + "Gamma options: 2.2, 2.4, sRGB, BT.1886, PQ. " + "FreeSync edition - no G-Sync module, DDC/CI more reliable over DP.", ), - notes="FreeSync QD-OLED. Same Samsung panel as AW3423DW. Source: Rtings." + notes="FreeSync QD-OLED. Same Samsung panel as AW3423DW. Source: Rtings.", ) # Dell Alienware AW3225QF (Samsung QD-OLED 4K 32" - 2024) @@ -224,7 +223,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6792, 0.3098), green=ChromaticityCoord(0.2318, 0.7108), blue=ChromaticityCoord(0.1372, 0.0498), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2015, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1985, offset=0.0, linear_portion=0.0), @@ -238,7 +237,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom Color", @@ -254,14 +253,14 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom Color mode for full DDC/CI RGB gain access. " - "Enable DDC/CI in Others menu. Creator mode with sRGB or DCI-P3 gamut " - "is accurate but locks color controls. Smart HDR modes: Desktop, Movie HDR, " - "Game HDR, Custom Color HDR, DisplayHDR 400 True Black, HDR Peak 1000. " - "Preset modes: Standard, Creator (DCI-P3/sRGB selectable), FPS, MOBA/RTS, RPG, Sports. " - "Dark Stabilizer manipulates gamma - disable for calibration. " - "Color temp presets: Warm, Cool, Custom. Dell Display Manager works over DP and HDMI." - ), - notes="Samsung 2024 QD-OLED 4K panel. 99.3% DCI-P3, Delta E 1.8 out of box. Source: Rtings/TFTCentral." + "Enable DDC/CI in Others menu. Creator mode with sRGB or DCI-P3 gamut " + "is accurate but locks color controls. Smart HDR modes: Desktop, Movie HDR, " + "Game HDR, Custom Color HDR, DisplayHDR 400 True Black, HDR Peak 1000. " + "Preset modes: Standard, Creator (DCI-P3/sRGB selectable), FPS, MOBA/RTS, RPG, Sports. " + "Dark Stabilizer manipulates gamma - disable for calibration. " + "Color temp presets: Warm, Cool, Custom. Dell Display Manager works over DP and HDMI.", + ), + notes="Samsung 2024 QD-OLED 4K panel. 99.3% DCI-P3, Delta E 1.8 out of box. Source: Rtings/TFTCentral.", ) # ASUS ROG Swift PG32UCDM (Samsung QD-OLED 4K 32") @@ -275,7 +274,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6798, 0.3102), green=ChromaticityCoord(0.2322, 0.7112), blue=ChromaticityCoord(0.1378, 0.0502), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2025, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1990, offset=0.0, linear_portion=0.0), @@ -289,7 +288,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom 1", @@ -305,13 +304,13 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom 1 picture mode for unlocked RGB gain controls. " - "DDC/CI enable in OSD Setup menu. Disable ELMB Sync for stable DDC comm. " - "Picture modes: Scenery, Racing, Cinema, RTS/RPG, FPS, Night Vision, User. " - "Blue Light Filter affects white point - disable for calibration. " - "OSD gamma options: 1.8, 2.0, 2.2, 2.4, 2.6. " - "VRR (Adaptive-Sync) can remain on. Uniform Brightness recommended off." + "DDC/CI enable in OSD Setup menu. Disable ELMB Sync for stable DDC comm. " + "Picture modes: Scenery, Racing, Cinema, RTS/RPG, FPS, Night Vision, User. " + "Blue Light Filter affects white point - disable for calibration. " + "OSD gamma options: 1.8, 2.0, 2.2, 2.4, 2.6. " + "VRR (Adaptive-Sync) can remain on. Uniform Brightness recommended off.", ), - notes="32-inch sibling of PG27UCDM. Same Samsung QD-OLED panel. Source: Hardware Unboxed." + notes="32-inch sibling of PG27UCDM. Same Samsung QD-OLED panel. Source: Hardware Unboxed.", ) # Samsung Odyssey OLED G8 G80SD (QD-OLED 32" 4K) @@ -325,7 +324,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6785, 0.3095), green=ChromaticityCoord(0.2330, 0.7105), blue=ChromaticityCoord(0.1375, 0.0505), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2010, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1995, offset=0.0, linear_portion=0.0), @@ -339,7 +338,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -355,11 +354,11 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom picture mode for DDC/CI RGB gain access. " - "Disable Game Mode and Adaptive-Sync during calibration for stable DDC. " - "Dynamic Brightness must be off. Core Lighting sync may interfere. " - "Color temp presets: Custom, Warm, Normal, Cool." + "Disable Game Mode and Adaptive-Sync during calibration for stable DDC. " + "Dynamic Brightness must be off. Core Lighting sync may interfere. " + "Color temp presets: Custom, Warm, Normal, Cool.", ), - notes="Samsung's own 32-inch 4K QD-OLED. Very accurate out of box. Source: Rtings." + notes="Samsung's own 32-inch 4K QD-OLED. Very accurate out of box. Source: Rtings.", ) # Samsung Odyssey G95SC (QD-OLED 49" Super Ultrawide) @@ -373,7 +372,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6782, 0.3085), green=ChromaticityCoord(0.2338, 0.7098), blue=ChromaticityCoord(0.1382, 0.0512), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2080, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1960, offset=0.0, linear_portion=0.0), @@ -387,7 +386,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -403,15 +402,15 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=None, notes="WARNING: Samsung Odyssey OLED G9 G95SC has severely limited DDC/CI support. " - "DDC/CI does not work over DisplayPort on most Samsung monitors - HDMI only. " - "Many standard VCP codes are not implemented. Input switching via DDC is unsupported. " - "Use Custom picture mode in OSD manually. Preset modes: Eco (default), Movie, Custom. " - "Movie mode has best default grayscale/gamma (BT.1886). " - "Color space: Auto (sRGB clamp) or Native (wide). " - "Dynamic Brightness must be off. Eye Saver Mode must be off. " - "Calibration primarily requires manual OSD adjustment rather than DDC/CI automation." - ), - notes="49-inch 5120x1440 QD-OLED ultrawide. 32:9 aspect. Source: TFTCentral/Hardware Unboxed." + "DDC/CI does not work over DisplayPort on most Samsung monitors - HDMI only. " + "Many standard VCP codes are not implemented. Input switching via DDC is unsupported. " + "Use Custom picture mode in OSD manually. Preset modes: Eco (default), Movie, Custom. " + "Movie mode has best default grayscale/gamma (BT.1886). " + "Color space: Auto (sRGB clamp) or Native (wide). " + "Dynamic Brightness must be off. Eye Saver Mode must be off. " + "Calibration primarily requires manual OSD adjustment rather than DDC/CI automation.", + ), + notes="49-inch 5120x1440 QD-OLED ultrawide. 32:9 aspect. Source: TFTCentral/Hardware Unboxed.", ) # LG C3 OLED (WOLED TV used as monitor - 42/48/55 inch) @@ -425,7 +424,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6399, 0.3301), green=ChromaticityCoord(0.2998, 0.5998), blue=ChromaticityCoord(0.1502, 0.0601), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -439,7 +438,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Game Optimizer", @@ -455,12 +454,12 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="BT.1886", gamma_vcp_value=0x08, notes="Use Game Optimizer or Filmmaker mode for DDC/CI access. " - "Disable AI Brightness (AI Picture Pro) as it overrides DDC brightness. " - "Disable ASBL (Auto Static Brightness Limiter) via service menu if possible. " - "Enable HDMI ULTRA HD Deep Color for 10-bit. Set HDMI input label to PC. " - "Color temp presets: Warm 50, Warm 30, Medium, Cool. Use Warm 50 for D65." + "Disable AI Brightness (AI Picture Pro) as it overrides DDC brightness. " + "Disable ASBL (Auto Static Brightness Limiter) via service menu if possible. " + "Enable HDMI ULTRA HD Deep Color for 10-bit. Set HDMI input label to PC. " + "Color temp presets: Warm 50, Warm 30, Medium, Cool. Use Warm 50 for D65.", ), - notes="LG WOLED evo panel. Excellent for gaming. Use PC mode. Source: Rtings/HDTVTest." + notes="LG WOLED evo panel. Excellent for gaming. Use PC mode. Source: Rtings/HDTVTest.", ) # LG C4 OLED (2024 WOLED evo) @@ -474,7 +473,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6398, 0.3302), green=ChromaticityCoord(0.2999, 0.5997), blue=ChromaticityCoord(0.1501, 0.0602), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2005, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1998, offset=0.0, linear_portion=0.0), @@ -488,7 +487,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Filmmaker", @@ -504,12 +503,12 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="BT.1886", gamma_vcp_value=0x08, notes="Use Filmmaker or Game Optimizer mode for DDC/CI access. " - "Disable AI Brightness (AI Picture Pro) as it overrides DDC brightness. " - "Disable ASBL via service menu if possible for stable luminance. " - "Enable HDMI ULTRA HD Deep Color for 10-bit. Set HDMI label to PC. " - "Color temp presets: Warm 50, Warm 30, Medium, Cool. Use Warm 50 for D65." + "Disable AI Brightness (AI Picture Pro) as it overrides DDC brightness. " + "Disable ASBL via service menu if possible for stable luminance. " + "Enable HDMI ULTRA HD Deep Color for 10-bit. Set HDMI label to PC. " + "Color temp presets: Warm 50, Warm 30, Medium, Cool. Use Warm 50 for D65.", ), - notes="2024 LG WOLED evo with improved brightness. Excellent factory calibration. Source: Rtings/HDTVTest." + notes="2024 LG WOLED evo with improved brightness. Excellent factory calibration. Source: Rtings/HDTVTest.", ) # LG 27GP950-B (Nano IPS) @@ -523,7 +522,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2150, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2100, offset=0.0, linear_portion=0.0), @@ -537,7 +536,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Gamer 1", @@ -553,15 +552,15 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="Mode 3", gamma_vcp_value=0x04, notes="Use Gamer 1 or Gamer 2 picture mode for DDC/CI RGB gain access. " - "sRGB mode clamps gamut and locks brightness/contrast/color controls. " - "Picture modes: FPS, RTS, sRGB, Reader, Gamer 1, Gamer 2, Calibration 1, Calibration 2. " - "Gamma modes: Mode 1 (2.0), Mode 2 (2.2), Mode 3 (2.4), Mode 4 (2.6). " - "Color temp: Custom, 6500K, 7500K, 9300K. 6-axis hue/saturation available. " - "LG Calibration Studio supports hardware calibration into Calibration 1/2 slots. " - "HDMI ULTRA HD Deep Color must be enabled for 10-bit. " - "On-Screen Control app provides DDC/CI access from desktop." - ), - notes="Nano IPS with 98% DCI-P3. Good for HDR600 content. Source: TFTCentral/Rtings." + "sRGB mode clamps gamut and locks brightness/contrast/color controls. " + "Picture modes: FPS, RTS, sRGB, Reader, Gamer 1, Gamer 2, Calibration 1, Calibration 2. " + "Gamma modes: Mode 1 (2.0), Mode 2 (2.2), Mode 3 (2.4), Mode 4 (2.6). " + "Color temp: Custom, 6500K, 7500K, 9300K. 6-axis hue/saturation available. " + "LG Calibration Studio supports hardware calibration into Calibration 1/2 slots. " + "HDMI ULTRA HD Deep Color must be enabled for 10-bit. " + "On-Screen Control app provides DDC/CI access from desktop.", + ), + notes="Nano IPS with 98% DCI-P3. Good for HDR600 content. Source: TFTCentral/Rtings.", ) # BenQ PD3220U (IPS - Professional color work) @@ -575,7 +574,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6495, 0.3380), green=ChromaticityCoord(0.2680, 0.6550), blue=ChromaticityCoord(0.1490, 0.0550), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -589,9 +588,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), - notes="Professional 4K monitor. Factory calibrated Delta E < 2. Thunderbolt 3. Source: TFTCentral." + notes="Professional 4K monitor. Factory calibrated Delta E < 2. Thunderbolt 3. Source: TFTCentral.", ) # ASUS ProArt PA32UCG-K (Mini-LED) @@ -605,7 +604,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6780, 0.3100), green=ChromaticityCoord(0.2650, 0.6900), blue=ChromaticityCoord(0.1380, 0.0520), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -620,7 +619,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: wide_gamut=True, vrr_capable=False, local_dimming=True, - local_dimming_zones=1152 + local_dimming_zones=1152, ), ddc=DDCRecommendations( picture_mode="User Mode 1", @@ -636,17 +635,17 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use User Mode 1 or User Mode 2 for full DDC/CI RGB gain access. " - "Picture modes: Standard, sRGB, Adobe RGB, DCI-P3, Rec.2020, DICOM, " - "Rec.709, HDR_PQ DCI, HDR_PQ Rec.2020, HDR_HLG, HDR_HLG DCI, " - "Dolby Vision, User Mode 1, User Mode 2. " - "Color temp: 9300K, 6500K, 5500K, 5000K, P3-Theater (DCI-P3 only). " - "Gamma options: 1.8, 2.0, 2.2, 2.4, 2.6. " - "6-axis color adjustment (R/G/B/C/M/Y) available. " - "ProArt Calibration software stores profiles to User Mode 1/2. " - "1152 Mini-LED zones - local dimming may affect uniformity measurements. " - "Disable local dimming for flat-field calibration if possible." - ), - notes="Reference Mini-LED. 99% DCI-P3, 89% BT.2020. 1152 zones FALD. Source: TFTCentral/Rtings." + "Picture modes: Standard, sRGB, Adobe RGB, DCI-P3, Rec.2020, DICOM, " + "Rec.709, HDR_PQ DCI, HDR_PQ Rec.2020, HDR_HLG, HDR_HLG DCI, " + "Dolby Vision, User Mode 1, User Mode 2. " + "Color temp: 9300K, 6500K, 5500K, 5000K, P3-Theater (DCI-P3 only). " + "Gamma options: 1.8, 2.0, 2.2, 2.4, 2.6. " + "6-axis color adjustment (R/G/B/C/M/Y) available. " + "ProArt Calibration software stores profiles to User Mode 1/2. " + "1152 Mini-LED zones - local dimming may affect uniformity measurements. " + "Disable local dimming for flat-field calibration if possible.", + ), + notes="Reference Mini-LED. 99% DCI-P3, 89% BT.2020. 1152 zones FALD. Source: TFTCentral/Rtings.", ) # MSI MEG 342C QD-OLED @@ -660,7 +659,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6785, 0.3090), green=ChromaticityCoord(0.2335, 0.7105), blue=ChromaticityCoord(0.1380, 0.0508), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2040, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1975, offset=0.0, linear_portion=0.0), @@ -674,9 +673,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="34-inch 3440x1440 QD-OLED ultrawide. Same panel family as G85SB. Source: HUB/TFTCentral." + notes="34-inch 3440x1440 QD-OLED ultrawide. Same panel family as G85SB. Source: HUB/TFTCentral.", ) # Corsair Xeneon 34 QD-OLED @@ -690,7 +689,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6788, 0.3092), green=ChromaticityCoord(0.2332, 0.7102), blue=ChromaticityCoord(0.1378, 0.0505), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2035, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1980, offset=0.0, linear_portion=0.0), @@ -704,9 +703,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="34-inch QD-OLED with iCUE integration. Same Samsung panel. Source: Rtings/HUB." + notes="34-inch QD-OLED with iCUE integration. Same Samsung panel. Source: Rtings/HUB.", ) # Gigabyte AORUS FO32U2P (QD-OLED 4K 32") @@ -720,7 +719,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6790, 0.3095), green=ChromaticityCoord(0.2325, 0.7110), blue=ChromaticityCoord(0.1375, 0.0500), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2018, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1988, offset=0.0, linear_portion=0.0), @@ -734,9 +733,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="32-inch 4K 240Hz QD-OLED. Same Samsung panel as PG32UCDM. Source: Hardware Unboxed." + notes="32-inch 4K 240Hz QD-OLED. Same Samsung panel as PG32UCDM. Source: Hardware Unboxed.", ) # LG UltraGear OLED 27GR95QE (LG WOLED) @@ -749,7 +748,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6401, 0.3300), green=ChromaticityCoord(0.3000, 0.6000), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2010, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -763,9 +762,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="LG WOLED panel. Similar to PG27UCDM with LG OSD and features." + notes="LG WOLED panel. Similar to PG27UCDM with LG OSD and features.", ) # Sony INZONE M9 (IPS with Full-Array Local Dimming) @@ -778,7 +777,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6465, 0.3340), green=ChromaticityCoord(0.2700, 0.6350), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2200, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2200, offset=0.0, linear_portion=0.0), @@ -793,7 +792,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: wide_gamut=True, vrr_capable=True, local_dimming=True, - local_dimming_zones=96 + local_dimming_zones=96, ), ddc=DDCRecommendations( picture_mode="Game 1", @@ -809,16 +808,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Game 1 or Game 2 picture mode for full color control access. " - "Standard mode locks several color controls but has better default white balance. " - "Picture modes: Standard, FPS Game, Cinema, Game 1, Game 2. " - "DDC/CI accessible via OSD Others menu. Sony INZONE Hub app also provides control. " - "Color temperature presets: Warm, Neutral, Cool, Custom. " - "Gamma presets: 1.8, 2.0, 2.2, 2.4. Hue/saturation adjustments available. " - "Local dimming (96 zones FALD) should be set to Off for SDR calibration, " - "as it can cause brightness inconsistencies during measurement. " - "Each unit ships with individual factory calibration report." - ), - notes="IPS with FALD. Requires local dimming consideration for calibration." + "Standard mode locks several color controls but has better default white balance. " + "Picture modes: Standard, FPS Game, Cinema, Game 1, Game 2. " + "DDC/CI accessible via OSD Others menu. Sony INZONE Hub app also provides control. " + "Color temperature presets: Warm, Neutral, Cool, Custom. " + "Gamma presets: 1.8, 2.0, 2.2, 2.4. Hue/saturation adjustments available. " + "Local dimming (96 zones FALD) should be set to Off for SDR calibration, " + "as it can cause brightness inconsistencies during measurement. " + "Each unit ships with individual factory calibration report.", + ), + notes="IPS with FALD. Requires local dimming consideration for calibration.", ) # Samsung Odyssey G7 / G5 (VA curved ultrawide - 3440x1440) @@ -832,7 +831,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3310), green=ChromaticityCoord(0.2680, 0.6420), blue=ChromaticityCoord(0.1500, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2100, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2050, offset=0.0, linear_portion=0.0), @@ -846,7 +845,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -862,11 +861,11 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="Mode1", gamma_vcp_value=0x04, notes="Use Custom picture mode for DDC/CI RGB gain access. " - "Disable Adaptive-Sync in OSD before DDC calibration to avoid comm drops. " - "sRGB mode clamps gamut but locks brightness/contrast controls. " - "Dynamic Brightness must be off. Eye Saver Mode must be off." + "Disable Adaptive-Sync in OSD before DDC calibration to avoid comm drops. " + "sRGB mode clamps gamut but locks brightness/contrast controls. " + "Dynamic Brightness must be off. Eye Saver Mode must be off.", ), - notes="Samsung VA curved ultrawide. 125% sRGB gamut. Good contrast. Source: Rtings/TFTCentral." + notes="Samsung VA curved ultrawide. 125% sRGB gamut. Good contrast. Source: Rtings/TFTCentral.", ) # Dell U2723QE - 4K 60Hz IPS (sRGB professional) @@ -880,7 +879,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6400, 0.3300), green=ChromaticityCoord(0.3000, 0.6000), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -894,7 +893,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=False, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom Color", @@ -910,11 +909,11 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom Color mode for DDC/CI RGB gain access. " - "sRGB mode available but locks brightness/contrast/color controls. " - "ComfortView (low blue light) must be off. " - "Smart HDR not present on this model. USB-C connected - DDC works over USB-C DP Alt." + "sRGB mode available but locks brightness/contrast/color controls. " + "ComfortView (low blue light) must be off. " + "Smart HDR not present on this model. USB-C connected - DDC works over USB-C DP Alt.", ), - notes="Factory calibrated sRGB IPS. Delta E < 2 out of box. USB-C hub monitor. Source: Rtings/TFTCentral." + notes="Factory calibrated sRGB IPS. Delta E < 2 out of box. USB-C hub monitor. Source: Rtings/TFTCentral.", ) # BenQ SW271C - 4K 60Hz IPS (Photo editing, 99% AdobeRGB) @@ -928,7 +927,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -942,7 +941,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -958,16 +957,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom picture mode for DDC/CI RGB gain access. " - "Picture modes: Adobe RGB, sRGB, Rec.709, DCI-P3, Display P3, M-book, " - "B+W, HDR, Calibration 1/2/3, Custom, Paper Color Sync, DICOM. " - "Hardware calibration via Palette Master Ultimate is preferred over DDC/CI - " - "writes directly to internal 14-bit 3D LUT for superior accuracy. " - "Calibration 1/2/3 slots store hardware calibration profiles. " - "Color temp presets: 5000K, 6500K, 9300K, Custom, User Defined (100K increments). " - "Hotkey Puck G2 allows fast mode switching. USB-C one-cable calibration supported. " - "DDC/CI works but Palette Master Ultimate is the recommended path." - ), - notes="99% AdobeRGB photo editing monitor. Hardware calibration support. Delta E < 2. Source: TFTCentral." + "Picture modes: Adobe RGB, sRGB, Rec.709, DCI-P3, Display P3, M-book, " + "B+W, HDR, Calibration 1/2/3, Custom, Paper Color Sync, DICOM. " + "Hardware calibration via Palette Master Ultimate is preferred over DDC/CI - " + "writes directly to internal 14-bit 3D LUT for superior accuracy. " + "Calibration 1/2/3 slots store hardware calibration profiles. " + "Color temp presets: 5000K, 6500K, 9300K, Custom, User Defined (100K increments). " + "Hotkey Puck G2 allows fast mode switching. USB-C one-cable calibration supported. " + "DDC/CI works but Palette Master Ultimate is the recommended path.", + ), + notes="99% AdobeRGB photo editing monitor. Hardware calibration support. Delta E < 2. Source: TFTCentral.", ) # EIZO CG2700S - 2K 60Hz IPS (Professional reference) @@ -981,7 +980,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -995,9 +994,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), - notes="Professional reference monitor. 99% AdobeRGB, built-in colorimeter. Delta E < 1. Source: TFTCentral." + notes="Professional reference monitor. 99% AdobeRGB, built-in colorimeter. Delta E < 1. Source: TFTCentral.", ) # Dell U3423WE - 3440x1440 60Hz IPS (Ultrawide professional, 98% DCI-P3) @@ -1011,7 +1010,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -1025,9 +1024,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), - notes="Ultrawide professional IPS. 98% DCI-P3. USB-C hub. Factory calibrated. Source: Rtings." + notes="Ultrawide professional IPS. 98% DCI-P3. USB-C hub. Factory calibrated. Source: Rtings.", ) # ASUS VG27AQ1A - 2K 170Hz IPS (Gaming, 130% sRGB) @@ -1041,7 +1040,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2100, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2050, offset=0.0, linear_portion=0.0), @@ -1055,9 +1054,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="Gaming IPS with 130% sRGB coverage. ELMB Sync. HDR400. Source: Rtings/Hardware Unboxed." + notes="Gaming IPS with 130% sRGB coverage. ELMB Sync. HDR400. Source: Rtings/Hardware Unboxed.", ) # Samsung Odyssey G7 27" - 2K 240Hz VA (Gaming, curved) @@ -1066,12 +1065,12 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: manufacturer="Samsung", model_pattern=r"C27G7|LC27G7|Odyssey.*G7.*27|G7.*27", panel_type="VA", - display_name="Odyssey G7 27\"", + display_name='Odyssey G7 27"', native_primaries=PanelPrimaries( red=ChromaticityCoord(0.6480, 0.3310), green=ChromaticityCoord(0.2680, 0.6420), blue=ChromaticityCoord(0.1500, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2120, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2060, offset=0.0, linear_portion=0.0), @@ -1085,7 +1084,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -1101,11 +1100,11 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="Mode1", gamma_vcp_value=0x04, notes="Use Custom picture mode for DDC/CI RGB gain access. " - "Disable Adaptive-Sync in OSD before DDC calibration to avoid comm drops. " - "sRGB mode available but locks out adjustments. " - "Dynamic Brightness and Eye Saver Mode must be off." + "Disable Adaptive-Sync in OSD before DDC calibration to avoid comm drops. " + "sRGB mode available but locks out adjustments. " + "Dynamic Brightness and Eye Saver Mode must be off.", ), - notes="VA curved 1000R gaming monitor. 125% sRGB, HDR600. Source: Rtings/TFTCentral." + notes="VA curved 1000R gaming monitor. 125% sRGB, HDR600. Source: Rtings/TFTCentral.", ) # LG 27GP850-B - 2K 165Hz Nano IPS (Gaming, 98% DCI-P3) @@ -1119,7 +1118,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2100, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2050, offset=0.0, linear_portion=0.0), @@ -1133,9 +1132,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="Nano IPS with 98% DCI-P3. 165Hz gaming. HDR400. Source: Rtings/TFTCentral." + notes="Nano IPS with 98% DCI-P3. 165Hz gaming. HDR400. Source: Rtings/TFTCentral.", ) # Dell S2722DGM - 2K 165Hz VA (Budget gaming) @@ -1149,7 +1148,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6400, 0.3300), green=ChromaticityCoord(0.3000, 0.6000), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2150, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2100, offset=0.0, linear_portion=0.0), @@ -1163,9 +1162,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=False, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="Budget VA gaming monitor. ~99% sRGB. 165Hz curved. Source: Rtings." + notes="Budget VA gaming monitor. ~99% sRGB. 165Hz curved. Source: Rtings.", ) # Sony A95L - 4K 120Hz QD-OLED TV @@ -1179,7 +1178,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6795, 0.3095), green=ChromaticityCoord(0.2325, 0.7115), blue=ChromaticityCoord(0.1375, 0.0495), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2020, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1985, offset=0.0, linear_portion=0.0), @@ -1193,9 +1192,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="Sony QD-OLED TV with Samsung Display panel. Excellent processing. Source: Rtings/HDTVTest." + notes="Sony QD-OLED TV with Samsung Display panel. Excellent processing. Source: Rtings/HDTVTest.", ) # Samsung S95D - 4K 144Hz QD-OLED TV @@ -1209,7 +1208,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6792, 0.3098), green=ChromaticityCoord(0.2318, 0.7108), blue=ChromaticityCoord(0.1372, 0.0498), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2010, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1990, offset=0.0, linear_portion=0.0), @@ -1223,9 +1222,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="2024 Samsung QD-OLED TV with anti-glare. 144Hz VRR. Source: Rtings/HDTVTest." + notes="2024 Samsung QD-OLED TV with anti-glare. 144Hz VRR. Source: Rtings/HDTVTest.", ) # ASUS PG34WCDM - 3440x1440 240Hz WOLED (Ultrawide) @@ -1239,7 +1238,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6400, 0.3300), green=ChromaticityCoord(0.3000, 0.6000), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2015, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1990, offset=0.0, linear_portion=0.0), @@ -1253,7 +1252,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom 1", @@ -1269,14 +1268,14 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom 1 picture mode for unlocked RGB gain controls. " - "LG.Display WOLED panel (NOT QD-OLED). 800R curvature ultrawide. " - "Disable ELMB Sync for stable DDC communication. " - "sRGB emulation available via OSD preset. " - "OSD gamma options: 1.8, 2.0, 2.2, 2.4, 2.6. " - "VRR (Adaptive-Sync) can remain on. Uniform Brightness recommended off." - ), - notes="34-inch 3440x1440 240Hz WOLED ultrawide. LG.Display panel (first 34\" WOLED). " - "98% DCI-P3, 95.5% Adobe RGB. 800R curve. Source: TFTCentral." + "LG.Display WOLED panel (NOT QD-OLED). 800R curvature ultrawide. " + "Disable ELMB Sync for stable DDC communication. " + "sRGB emulation available via OSD preset. " + "OSD gamma options: 1.8, 2.0, 2.2, 2.4, 2.6. " + "VRR (Adaptive-Sync) can remain on. Uniform Brightness recommended off.", + ), + notes='34-inch 3440x1440 240Hz WOLED ultrawide. LG.Display panel (first 34" WOLED). ' + "98% DCI-P3, 95.5% Adobe RGB. 800R curve. Source: TFTCentral.", ) # Gigabyte M28U - 4K 144Hz IPS (Budget 4K gaming) @@ -1290,7 +1289,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2100, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2050, offset=0.0, linear_portion=0.0), @@ -1304,9 +1303,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="Budget 4K 144Hz IPS gaming. 90% DCI-P3. HDMI 2.1. Source: Rtings/Hardware Unboxed." + notes="Budget 4K 144Hz IPS gaming. 90% DCI-P3. HDMI 2.1. Source: Rtings/Hardware Unboxed.", ) # ViewSonic VP2786-4K - 4K 60Hz IPS (Professional) @@ -1320,7 +1319,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -1334,7 +1333,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -1350,16 +1349,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom picture mode for DDC/CI RGB gain access. " - "Picture modes: sRGB, Adobe RGB, DCI-P3, Rec.709, DICOM, Custom, User. " - "Hardware calibration via Colorbration+ software (ViewSonic's calibration tool). " - "Dual color engine allows different presets in PIP/PBP mode. " - "ColorPro Wheel provides integrated color calibration workflow. " - "Color temp: 5000K, 6500K, 7500K, 9300K, User Defined. " - "DDC/CI brightness control confirmed but 1ms Mode disables DDC/CI brightness. " - "Pantone Validated, Fogra certified. USB-C 90W PD. " - "Advanced DCR (dynamic contrast) must be off for calibration." - ), - notes="Professional 4K IPS. 100% sRGB, 98% DCI-P3, factory calibrated Delta E < 2. USB-C. Source: TFTCentral/Rtings." + "Picture modes: sRGB, Adobe RGB, DCI-P3, Rec.709, DICOM, Custom, User. " + "Hardware calibration via Colorbration+ software (ViewSonic's calibration tool). " + "Dual color engine allows different presets in PIP/PBP mode. " + "ColorPro Wheel provides integrated color calibration workflow. " + "Color temp: 5000K, 6500K, 7500K, 9300K, User Defined. " + "DDC/CI brightness control confirmed but 1ms Mode disables DDC/CI brightness. " + "Pantone Validated, Fogra certified. USB-C 90W PD. " + "Advanced DCR (dynamic contrast) must be off for calibration.", + ), + notes="Professional 4K IPS. 100% sRGB, 98% DCI-P3, factory calibrated Delta E < 2. USB-C. Source: TFTCentral/Rtings.", ) # LG 32GS95UE - 4K 240Hz WOLED 32" @@ -1373,7 +1372,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6398, 0.3302), green=ChromaticityCoord(0.2999, 0.5997), blue=ChromaticityCoord(0.1501, 0.0602), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2008, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1995, offset=0.0, linear_portion=0.0), @@ -1387,9 +1386,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="32-inch 4K 240Hz WOLED monitor. Similar to LG C4 primaries. Source: Rtings/Hardware Unboxed." + notes="32-inch 4K 240Hz WOLED monitor. Similar to LG C4 primaries. Source: Rtings/Hardware Unboxed.", ) # MSI MAG 274QRF-QD - 2K 165Hz Quantum Dot IPS @@ -1403,7 +1402,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2080, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2040, offset=0.0, linear_portion=0.0), @@ -1417,9 +1416,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), - notes="QD-enhanced IPS with ~97% DCI-P3. Excellent color for gaming. Source: Rtings/Hardware Unboxed." + notes="QD-enhanced IPS with ~97% DCI-P3. Excellent color for gaming. Source: Rtings/Hardware Unboxed.", ) # BenQ PD2706U - 4K 60Hz IPS (Professional, 99% sRGB) @@ -1433,7 +1432,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6400, 0.3300), green=ChromaticityCoord(0.3000, 0.6000), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -1447,7 +1446,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=False, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom 1", @@ -1463,12 +1462,12 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom 1 or Custom 2 picture mode for DDC/CI RGB gain access. " - "sRGB mode locks all controls - avoid for DDC calibration. " - "Disable B.I.+ (Brightness Intelligence Plus) auto-brightness sensor. " - "Disable auto-dimming/eco mode. Display Pilot software can conflict with DDC. " - "Color modes: Custom 1, Custom 2, sRGB, Rec.709, DICOM, Darkroom, M-Book, User." + "sRGB mode locks all controls - avoid for DDC calibration. " + "Disable B.I.+ (Brightness Intelligence Plus) auto-brightness sensor. " + "Disable auto-dimming/eco mode. Display Pilot software can conflict with DDC. " + "Color modes: Custom 1, Custom 2, sRGB, Rec.709, DICOM, Darkroom, M-Book, User.", ), - notes="Professional 4K IPS with 99% sRGB, factory calibrated Delta E < 3. USB-C 90W. Source: TFTCentral/Rtings." + notes="Professional 4K IPS with 99% sRGB, factory calibrated Delta E < 3. USB-C 90W. Source: TFTCentral/Rtings.", ) # EIZO CS2740 - 4K 60Hz IPS (Professional, hardware calibration via ColorNavigator) @@ -1482,7 +1481,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -1496,7 +1495,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="User", @@ -1512,12 +1511,12 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use User color mode for DDC/CI access. EIZO supports hardware LUT calibration " - "via ColorNavigator 7 - this is preferred over DDC/CI for this monitor. " - "Built-in calibration sensor (SelfCalibration on schedule) on CG series, not CS. " - "DDC/CI works but ColorNavigator provides direct 16-bit 3D LUT access. " - "Eco mode and Auto EcoView must be disabled for stable brightness." + "via ColorNavigator 7 - this is preferred over DDC/CI for this monitor. " + "Built-in calibration sensor (SelfCalibration on schedule) on CG series, not CS. " + "DDC/CI works but ColorNavigator provides direct 16-bit 3D LUT access. " + "Eco mode and Auto EcoView must be disabled for stable brightness.", ), - notes="Professional 4K IPS. 99% AdobeRGB. Hardware LUT calibration via ColorNavigator. Delta E < 2. Source: TFTCentral." + notes="Professional 4K IPS. 99% AdobeRGB. Hardware LUT calibration via ColorNavigator. Delta E < 2. Source: TFTCentral.", ) # ASUS ProArt PA279CRV - 4K 60Hz IPS (Professional, 99% DCI-P3) @@ -1531,7 +1530,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -1545,7 +1544,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="User Mode 1", @@ -1561,16 +1560,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use User Mode 1 or User Mode 2 for DDC/CI RGB gain access. " - "Picture modes: Native, sRGB, Adobe RGB, DCI-P3, Rec.2020, DICOM, " - "Rec.709, HDR (PQ Optimized/PQ Clip/PQ Basic), User Mode 1, User Mode 2. " - "Color temp presets: 9300K, 6500K, 5500K, 5000K, P3-Theater (DCI-P3 only). " - "Gamma options: 1.8, 2.0, 2.2, 2.4, 2.6. " - "RGB gain and offset adjustable in most presets. " - "ProArt Calibration software stores profiles to User Mode 1/2. " - "ASUS DisplayWidget Center app provides DDC/CI desktop control. " - "Calman Verified with Delta E < 2 factory calibration." - ), - notes="Professional 4K IPS. 99% DCI-P3, 99% Adobe RGB, 100% sRGB. Calman Verified. USB-C 96W PD. Source: ASUS/DisplayNinja." + "Picture modes: Native, sRGB, Adobe RGB, DCI-P3, Rec.2020, DICOM, " + "Rec.709, HDR (PQ Optimized/PQ Clip/PQ Basic), User Mode 1, User Mode 2. " + "Color temp presets: 9300K, 6500K, 5500K, 5000K, P3-Theater (DCI-P3 only). " + "Gamma options: 1.8, 2.0, 2.2, 2.4, 2.6. " + "RGB gain and offset adjustable in most presets. " + "ProArt Calibration software stores profiles to User Mode 1/2. " + "ASUS DisplayWidget Center app provides DDC/CI desktop control. " + "Calman Verified with Delta E < 2 factory calibration.", + ), + notes="Professional 4K IPS. 99% DCI-P3, 99% Adobe RGB, 100% sRGB. Calman Verified. USB-C 96W PD. Source: ASUS/DisplayNinja.", ) # MSI MPG 321URX QD-OLED - 4K 240Hz QD-OLED @@ -1584,7 +1583,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6795, 0.3095), green=ChromaticityCoord(0.2325, 0.7115), blue=ChromaticityCoord(0.1375, 0.0495), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2020, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1985, offset=0.0, linear_portion=0.0), @@ -1598,7 +1597,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="User", @@ -1614,16 +1613,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use User mode for DDC/CI RGB gain access. " - "Default mode is Eco which caps brightness - switch to User for full control. " - "Gaming color modes: 6 modes (less accurate, oversaturated). " - "Professional modes: sRGB (recommended for accurate work), DCI-P3, Adobe RGB. " - "sRGB mode provides excellent out-of-box accuracy (dE ~0.33 after calibration). " - "HDR modes: HDR 400 True Black (450 nits cap), Peak 1000 (full 1000 nits). " - "DDC/CI works via ControlMyMonitor and similar tools. " - "OSD via joystick toggle behind MSI logo. " - "Color temp presets: Warm, Normal, Cool, Custom." - ), - notes="32-inch 4K 240Hz QD-OLED. Samsung 2024 panel. 99% DCI-P3. Source: TFTCentral/Rtings/KitGuru." + "Default mode is Eco which caps brightness - switch to User for full control. " + "Gaming color modes: 6 modes (less accurate, oversaturated). " + "Professional modes: sRGB (recommended for accurate work), DCI-P3, Adobe RGB. " + "sRGB mode provides excellent out-of-box accuracy (dE ~0.33 after calibration). " + "HDR modes: HDR 400 True Black (450 nits cap), Peak 1000 (full 1000 nits). " + "DDC/CI works via ControlMyMonitor and similar tools. " + "OSD via joystick toggle behind MSI logo. " + "Color temp presets: Warm, Normal, Cool, Custom.", + ), + notes="32-inch 4K 240Hz QD-OLED. Samsung 2024 panel. 99% DCI-P3. Source: TFTCentral/Rtings/KitGuru.", ) # LG C2 OLED (WOLED TV used as monitor - 42/48/55/65 inch) @@ -1637,7 +1636,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6399, 0.3301), green=ChromaticityCoord(0.2998, 0.5998), blue=ChromaticityCoord(0.1502, 0.0601), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -1651,7 +1650,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Game Optimizer", @@ -1667,20 +1666,20 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="BT.1886", gamma_vcp_value=0x08, notes="WARNING: LG C2 has limited DDC/CI support - brightness control may not work via DDC. " - "Use Game Optimizer or ISF Expert (Dark Room) for calibration. " - "Picture modes: Vivid, Standard, Cinema, Cinema Home, Sports, " - "Game Optimizer, Filmmaker, ISF Expert (Bright), ISF Expert (Dark). " - "Disable AI Brightness (AI Picture Pro) - it overrides DDC brightness. " - "Disable ASBL (Auto Static Brightness Limiter) via service menu if possible. " - "Enable HDMI ULTRA HD Deep Color for 10-bit. Set HDMI input label to PC. " - "Color temp presets: Warm 50 (D65), Warm 30, Medium, Cool. " - "White Balance submenu provides 2-point and 20-point adjustment. " - "Professional calibration via Calman AutoCal requires network connection. " - "For Calman DDC, select '2021 Alpha 9' pattern. " - "OLED Shadow Detail adjustable (start at 23/200). " - "SDR ISF Expert Dark recommended with ALLM Off for movies." - ), - notes="LG WOLED evo panel (2022). ~99% sRGB, ~97% DCI-P3. Excellent for gaming. Source: Rtings/HDTVTest." + "Use Game Optimizer or ISF Expert (Dark Room) for calibration. " + "Picture modes: Vivid, Standard, Cinema, Cinema Home, Sports, " + "Game Optimizer, Filmmaker, ISF Expert (Bright), ISF Expert (Dark). " + "Disable AI Brightness (AI Picture Pro) - it overrides DDC brightness. " + "Disable ASBL (Auto Static Brightness Limiter) via service menu if possible. " + "Enable HDMI ULTRA HD Deep Color for 10-bit. Set HDMI input label to PC. " + "Color temp presets: Warm 50 (D65), Warm 30, Medium, Cool. " + "White Balance submenu provides 2-point and 20-point adjustment. " + "Professional calibration via Calman AutoCal requires network connection. " + "For Calman DDC, select '2021 Alpha 9' pattern. " + "OLED Shadow Detail adjustable (start at 23/200). " + "SDR ISF Expert Dark recommended with ALLM Off for movies.", + ), + notes="LG WOLED evo panel (2022). ~99% sRGB, ~97% DCI-P3. Excellent for gaming. Source: Rtings/HDTVTest.", ) # Dell S2722QC - 4K 60Hz IPS (Budget USB-C) @@ -1694,7 +1693,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6400, 0.3300), green=ChromaticityCoord(0.3000, 0.6000), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2100, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2050, offset=0.0, linear_portion=0.0), @@ -1708,7 +1707,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=False, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom Color", @@ -1724,16 +1723,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom Color mode for DDC/CI RGB gain access. " - "Enable DDC/CI in Others menu. Dell Display Manager provides desktop control. " - "Picture modes: Standard, Custom Color, Movie, Game, Warm, Cool, Color Temp. " - "Smart HDR modes (when HDR signal detected): Desktop, Movie HDR, Game HDR. " - "sRGB preset locks brightness/contrast/color controls - avoid for DDC calibration. " - "ComfortView (low blue light) must be off. " - "RGB gain steps are coarse - a change of 1 noticeably shifts color. " - "USB-C connected - DDC works over USB-C DP Alt mode. " - "97% sRGB, 88% DCI-P3 coverage." - ), - notes="Budget 4K IPS USB-C monitor. 97% sRGB, 88% DCI-P3. 65W USB-C PD. Source: Rtings/PC Monitors." + "Enable DDC/CI in Others menu. Dell Display Manager provides desktop control. " + "Picture modes: Standard, Custom Color, Movie, Game, Warm, Cool, Color Temp. " + "Smart HDR modes (when HDR signal detected): Desktop, Movie HDR, Game HDR. " + "sRGB preset locks brightness/contrast/color controls - avoid for DDC calibration. " + "ComfortView (low blue light) must be off. " + "RGB gain steps are coarse - a change of 1 noticeably shifts color. " + "USB-C connected - DDC works over USB-C DP Alt mode. " + "97% sRGB, 88% DCI-P3 coverage.", + ), + notes="Budget 4K IPS USB-C monitor. 97% sRGB, 88% DCI-P3. 65W USB-C PD. Source: Rtings/PC Monitors.", ) # Corsair Xeneon 27QHD240 OLED - 1440p 240Hz WOLED @@ -1747,7 +1746,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6398, 0.3302), green=ChromaticityCoord(0.2999, 0.5997), blue=ChromaticityCoord(0.1501, 0.0602), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2010, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1995, offset=0.0, linear_portion=0.0), @@ -1761,7 +1760,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Standard", @@ -1777,17 +1776,17 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Standard picture mode for full RGB gain/offset access with native gamut. " - "Picture modes: Standard, Movie, Text, sRGB, Creative, Game, HDR. " - "Standard mode is fully adjustable with native wide gamut. " - "sRGB mode clamps gamut but has independent color temp and gamma options. " - "HDR mode is non-adjustable. " - "Color temp and gamma independently adjustable per mode. " - "OSD via joystick press/navigate. iCUE integration available. " - "Orbit pixel-shift (1px/min) always active for burn-in prevention. " - "Image Retention Refresh runs automatically every 8 hours. " - "LG Display 3rd-gen WOLED panel. ~96% DCI-P3, 100% sRGB." - ), - notes="27-inch 1440p 240Hz WOLED. LG Display 3rd-gen panel. 96% DCI-P3, 100% sRGB. Source: Tom's HW/Rtings/KitGuru." + "Picture modes: Standard, Movie, Text, sRGB, Creative, Game, HDR. " + "Standard mode is fully adjustable with native wide gamut. " + "sRGB mode clamps gamut but has independent color temp and gamma options. " + "HDR mode is non-adjustable. " + "Color temp and gamma independently adjustable per mode. " + "OSD via joystick press/navigate. iCUE integration available. " + "Orbit pixel-shift (1px/min) always active for burn-in prevention. " + "Image Retention Refresh runs automatically every 8 hours. " + "LG Display 3rd-gen WOLED panel. ~96% DCI-P3, 100% sRGB.", + ), + notes="27-inch 1440p 240Hz WOLED. LG Display 3rd-gen panel. 96% DCI-P3, 100% sRGB. Source: Tom's HW/Rtings/KitGuru.", ) # Gigabyte M32U - 4K 144Hz IPS (Gaming) @@ -1801,7 +1800,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2100, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2050, offset=0.0, linear_portion=0.0), @@ -1815,7 +1814,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom 1", @@ -1831,16 +1830,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom 1/2/3 picture mode for DDC/CI RGB gain access. " - "Picture modes: Standard, FPS, RTS/RPG, Movie, Reader, sRGB, Custom 1/2/3. " - "sRGB mode locks most settings including gamma, overdrive, and color channels. " - "Custom modes are identical to Standard by default with separate settings memory. " - "Color temp: Warm, Normal, Cool, User Define (RGB gain sliders). " - "Gamma options: 1.8 (Off), 2.0, 2.2, 2.4, 2.6. " - "OSD Sidekick desktop app provides DDC/CI control via Realtek scaler. " - "sRGB mode restricts gamut to ~100% sRGB. Native is ~87-90% DCI-P3. " - "Aim Stabilizer Sync and Smart OD locked in sRGB mode." - ), - notes="32-inch 4K 144Hz IPS gaming. 90% DCI-P3, 100% sRGB. HDMI 2.1. Source: PC Monitors/Rtings/Display Ninja." + "Picture modes: Standard, FPS, RTS/RPG, Movie, Reader, sRGB, Custom 1/2/3. " + "sRGB mode locks most settings including gamma, overdrive, and color channels. " + "Custom modes are identical to Standard by default with separate settings memory. " + "Color temp: Warm, Normal, Cool, User Define (RGB gain sliders). " + "Gamma options: 1.8 (Off), 2.0, 2.2, 2.4, 2.6. " + "OSD Sidekick desktop app provides DDC/CI control via Realtek scaler. " + "sRGB mode restricts gamut to ~100% sRGB. Native is ~87-90% DCI-P3. " + "Aim Stabilizer Sync and Smart OD locked in sRGB mode.", + ), + notes="32-inch 4K 144Hz IPS gaming. 90% DCI-P3, 100% sRGB. HDMI 2.1. Source: PC Monitors/Rtings/Display Ninja.", ) # HP Z27k G3 - 4K 60Hz IPS (USB-C Professional) @@ -1854,7 +1853,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6400, 0.3300), green=ChromaticityCoord(0.3000, 0.6000), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -1868,7 +1867,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=False, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom RGB", @@ -1884,16 +1883,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom RGB mode for DDC/CI RGB gain access. " - "Picture presets: sRGB, BT.709, P3 D65 (post-Jan 2021 units), Custom RGB. " - "DDC/CI can be toggled on/off in Management menu of OSD. " - "HP Display Center software provides desktop display management. " - "Factory calibrated Delta E <= 2. PANTONE Validated. " - "User-assignable function buttons on OSD. " - "99% sRGB, 85% DCI-P3 coverage. 100W USB-C PD. " - "4-port USB-C hub built in. " - "sRGB and BT.709 modes lock color adjustments - use Custom RGB for calibration." - ), - notes="Professional 4K IPS. 99% sRGB, 85% DCI-P3. PANTONE Validated. 100W USB-C PD. Source: HP/StorageReview." + "Picture presets: sRGB, BT.709, P3 D65 (post-Jan 2021 units), Custom RGB. " + "DDC/CI can be toggled on/off in Management menu of OSD. " + "HP Display Center software provides desktop display management. " + "Factory calibrated Delta E <= 2. PANTONE Validated. " + "User-assignable function buttons on OSD. " + "99% sRGB, 85% DCI-P3 coverage. 100W USB-C PD. " + "4-port USB-C hub built in. " + "sRGB and BT.709 modes lock color adjustments - use Custom RGB for calibration.", + ), + notes="Professional 4K IPS. 99% sRGB, 85% DCI-P3. PANTONE Validated. 100W USB-C PD. Source: HP/StorageReview.", ) # Samsung Odyssey OLED G6 G60SD (Samsung QD-OLED 27" 1440p 360Hz - 2024) @@ -1907,7 +1906,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6790, 0.3095), green=ChromaticityCoord(0.2325, 0.7110), blue=ChromaticityCoord(0.1375, 0.0498), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2020, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1985, offset=0.0, linear_portion=0.0), @@ -1921,7 +1920,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -1937,14 +1936,14 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="WARNING: Samsung DDC/CI works over HDMI only - does NOT work over DisplayPort. " - "Use Custom picture mode for RGB gain access. " - "3rd gen Samsung QD-OLED with matte AG coating and heat-pipe cooling. " - "Dynamic Brightness must be off. Eye Saver Mode must be off. " - "Color temp presets: Custom, Warm, Normal, Cool. " - "Game mode and Adaptive-Sync should be disabled during calibration." + "Use Custom picture mode for RGB gain access. " + "3rd gen Samsung QD-OLED with matte AG coating and heat-pipe cooling. " + "Dynamic Brightness must be off. Eye Saver Mode must be off. " + "Color temp presets: Custom, Warm, Normal, Cool. " + "Game mode and Adaptive-Sync should be disabled during calibration.", ), notes="27-inch 1440p 360Hz QD-OLED. 3rd gen Samsung panel. 99% DCI-P3. " - "Matte AG coating. Source: Rtings/TFTCentral/Tom's Hardware." + "Matte AG coating. Source: Rtings/TFTCentral/Tom's Hardware.", ) # ASUS ROG Swift PG27AQDP (LG WOLED 27" 1440p 480Hz - 2024) @@ -1958,7 +1957,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6400, 0.3300), green=ChromaticityCoord(0.3000, 0.6000), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2010, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1995, offset=0.0, linear_portion=0.0), @@ -1972,7 +1971,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom 1", @@ -1988,14 +1987,14 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom 1 picture mode for unlocked RGB gain controls. " - "LG.Display WOLED panel - NOT QD-OLED. 480Hz native refresh rate. " - "Disable ELMB Sync for stable DDC communication. " - "OSD gamma options: 1.8, 2.0, 2.2, 2.4, 2.6. " - "HDR peak 1300 nits spec, 425 nits measured SDR max variable APL. " - "Custom heatsink cooling (fanless). VRR can remain on." + "LG.Display WOLED panel - NOT QD-OLED. 480Hz native refresh rate. " + "Disable ELMB Sync for stable DDC communication. " + "OSD gamma options: 1.8, 2.0, 2.2, 2.4, 2.6. " + "HDR peak 1300 nits spec, 425 nits measured SDR max variable APL. " + "Custom heatsink cooling (fanless). VRR can remain on.", ), notes="27-inch 1440p 480Hz WOLED. LG.Display latest gen panel. ~125% sRGB. " - "VESA DisplayHDR True Black 400. Source: TFTCentral." + "VESA DisplayHDR True Black 400. Source: TFTCentral.", ) # Dell Alienware AW2725DF (Samsung QD-OLED 27" 1440p 360Hz - 2024) @@ -2009,7 +2008,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6792, 0.3098), green=ChromaticityCoord(0.2320, 0.7108), blue=ChromaticityCoord(0.1372, 0.0498), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2015, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1990, offset=0.0, linear_portion=0.0), @@ -2023,7 +2022,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom Color", @@ -2039,17 +2038,17 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom Color mode for full DDC/CI RGB gain access. " - "3rd gen Samsung QD-OLED panel. First mainstream 27\" QD-OLED from Dell. " - "Enable DDC/CI in Others menu. No hardware calibration support. " - "Gamma options: 2.2, 2.4, sRGB, BT.1886, PQ. " - "Factory calibrated DeltaE <2 for sRGB and DCI-P3. " - "ComfortView Plus (low blue light) must be off. " - "Dark Stabilizer manipulates gamma - disable for calibration. " - "Connectivity: 2x DP 1.4, 1x HDMI (limited bandwidth). " - "Dell Display Manager works over DP and HDMI." + '3rd gen Samsung QD-OLED panel. First mainstream 27" QD-OLED from Dell. ' + "Enable DDC/CI in Others menu. No hardware calibration support. " + "Gamma options: 2.2, 2.4, sRGB, BT.1886, PQ. " + "Factory calibrated DeltaE <2 for sRGB and DCI-P3. " + "ComfortView Plus (low blue light) must be off. " + "Dark Stabilizer manipulates gamma - disable for calibration. " + "Connectivity: 2x DP 1.4, 1x HDMI (limited bandwidth). " + "Dell Display Manager works over DP and HDMI.", ), notes="27-inch 1440p 360Hz QD-OLED. 3rd gen Samsung panel. 99.3% DCI-P3. " - "Factory calibrated. Source: TFTCentral/Rtings." + "Factory calibrated. Source: TFTCentral/Rtings.", ) # Samsung Odyssey OLED G8 G81SD (Samsung QD-OLED 32" 4K - 2024 variant) @@ -2063,7 +2062,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6785, 0.3095), green=ChromaticityCoord(0.2330, 0.7105), blue=ChromaticityCoord(0.1375, 0.0505), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2010, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1995, offset=0.0, linear_portion=0.0), @@ -2077,7 +2076,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -2093,15 +2092,15 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="WARNING: Samsung DDC/CI works over HDMI only - does NOT work over DisplayPort. " - "Use Custom picture mode for DDC/CI RGB gain access. " - "Same Samsung QD-OLED panel as G80SD (regional SKU variant). " - "Disable Game Mode and Adaptive-Sync during calibration for stable DDC. " - "Dynamic Brightness must be off. Core Lighting sync may interfere. " - "Glare Free matte coating. NQ8 AI Gen3 processor (Smart TV features). " - "Color temp presets: Custom, Warm, Normal, Cool." + "Use Custom picture mode for DDC/CI RGB gain access. " + "Same Samsung QD-OLED panel as G80SD (regional SKU variant). " + "Disable Game Mode and Adaptive-Sync during calibration for stable DDC. " + "Dynamic Brightness must be off. Core Lighting sync may interfere. " + "Glare Free matte coating. NQ8 AI Gen3 processor (Smart TV features). " + "Color temp presets: Custom, Warm, Normal, Cool.", ), notes="32-inch 4K 240Hz QD-OLED. Same panel as G80SD (regional/SKU variant). " - "99% DCI-P3. Matte AG coating. Source: Samsung.com/Rtings." + "99% DCI-P3. Matte AG coating. Source: Samsung.com/Rtings.", ) # LG G4 OLED (2024 WOLED evo with MLA - TV used as monitor) @@ -2115,7 +2114,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6398, 0.3302), green=ChromaticityCoord(0.2999, 0.5997), blue=ChromaticityCoord(0.1501, 0.0602), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2005, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1998, offset=0.0, linear_portion=0.0), @@ -2129,7 +2128,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Filmmaker", @@ -2145,16 +2144,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="BT.1886", gamma_vcp_value=0x08, notes="Use Filmmaker or Game Optimizer mode for DDC/CI access. " - "MLA (Micro Lens Array) panel with significantly higher brightness than C4. " - "1489 nits measured at 10% window, 1483 nits at 2% window. " - "Disable AI Brightness (AI Picture Pro) as it overrides DDC brightness. " - "Disable ASBL via service menu if possible for stable luminance. " - "Enable HDMI ULTRA HD Deep Color for 10-bit. Set HDMI label to PC. " - "Color temp presets: Warm 50, Warm 30, Medium, Cool. Use Warm 50 for D65. " - "97.4% DCI-P3, 72.9% BT.2020 measured coverage." + "MLA (Micro Lens Array) panel with significantly higher brightness than C4. " + "1489 nits measured at 10% window, 1483 nits at 2% window. " + "Disable AI Brightness (AI Picture Pro) as it overrides DDC brightness. " + "Disable ASBL via service menu if possible for stable luminance. " + "Enable HDMI ULTRA HD Deep Color for 10-bit. Set HDMI label to PC. " + "Color temp presets: Warm 50, Warm 30, Medium, Cool. Use Warm 50 for D65. " + "97.4% DCI-P3, 72.9% BT.2020 measured coverage.", ), notes="2024 LG WOLED evo with MLA for higher brightness. Flagship TV as monitor. " - "97.4% DCI-P3, 72.9% BT.2020. Source: Rtings/HDTVTest/FlatpanelsHD." + "97.4% DCI-P3, 72.9% BT.2020. Source: Rtings/HDTVTest/FlatpanelsHD.", ) # LG UltraGear OLED 34GS95QE (LG WOLED 34" 1440p 240Hz ultrawide) @@ -2168,7 +2167,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6400, 0.3300), green=ChromaticityCoord(0.3000, 0.6000), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2010, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1995, offset=0.0, linear_portion=0.0), @@ -2182,7 +2181,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=True, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Gamer 1", @@ -2198,17 +2197,17 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="Mode 3", gamma_vcp_value=0x04, notes="Use Gamer 1 or Gamer 2 for DDC/CI RGB gain access. " - "LG.Display 2nd gen WOLED panel. 800R curvature. " - "Supports hardware calibration (LG unique in OLED monitor market). " - "LG Calibration Studio stores profiles to Calibration 1/2 slots. " - "Gamma modes: Mode 1 (2.0), Mode 2 (2.2), Mode 3 (2.4), Mode 4 (2.6). " - "Color temp: Custom, 6500K, 7500K, 9300K. 6-axis hue/sat available. " - "FreeSync Premium Pro and G-sync Compatible certified. " - "HDR measured: 957 nits (1% window), 886 nits (4%), 778 nits (10%). " - "On-Screen Control app provides DDC/CI access from desktop." + "LG.Display 2nd gen WOLED panel. 800R curvature. " + "Supports hardware calibration (LG unique in OLED monitor market). " + "LG Calibration Studio stores profiles to Calibration 1/2 slots. " + "Gamma modes: Mode 1 (2.0), Mode 2 (2.2), Mode 3 (2.4), Mode 4 (2.6). " + "Color temp: Custom, 6500K, 7500K, 9300K. 6-axis hue/sat available. " + "FreeSync Premium Pro and G-sync Compatible certified. " + "HDR measured: 957 nits (1% window), 886 nits (4%), 778 nits (10%). " + "On-Screen Control app provides DDC/CI access from desktop.", ), notes="34-inch 3440x1440 240Hz WOLED ultrawide. LG.Display 2nd gen panel. " - "99% DCI-P3. 800R curve. Hardware calibration supported. Source: Rtings/TFTCentral." + "99% DCI-P3. 800R curve. Hardware calibration supported. Source: Rtings/TFTCentral.", ) # Apple Pro Display XDR (IPS LCD Mini-LED 32" 6K reference monitor) @@ -2222,7 +2221,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6800, 0.3200), green=ChromaticityCoord(0.2650, 0.6900), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -2237,7 +2236,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: wide_gamut=True, vrr_capable=False, local_dimming=True, - local_dimming_zones=576 + local_dimming_zones=576, ), ddc=DDCRecommendations( picture_mode="Pro Display XDR (P3-1600 nits)", @@ -2253,18 +2252,18 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=None, notes="WARNING: Apple Pro Display XDR does NOT support standard DDC/CI for calibration. " - "Calibration is done through macOS reference modes and Apple's Pro Display Calibrator. " - "Reference modes: Pro Display XDR (P3-1600 nits), HDR Video, Digital Cinema, " - "Design and Print, sRGB, Photography. " - "Full Calibration requires compatible spectroradiometer. " - "Visual Fine Tune for quick adjustments to match other displays. " - "Apple-designed TCON chip controls 576 FALD zones. " - "6016x3384 resolution (6K) at 218 PPI. Oxide TFT IPS LCD. " - "Standard and Nano-texture glass options. Max 60Hz refresh." + "Calibration is done through macOS reference modes and Apple's Pro Display Calibrator. " + "Reference modes: Pro Display XDR (P3-1600 nits), HDR Video, Digital Cinema, " + "Design and Print, sRGB, Photography. " + "Full Calibration requires compatible spectroradiometer. " + "Visual Fine Tune for quick adjustments to match other displays. " + "Apple-designed TCON chip controls 576 FALD zones. " + "6016x3384 resolution (6K) at 218 PPI. Oxide TFT IPS LCD. " + "Standard and Nano-texture glass options. Max 60Hz refresh.", ), notes="Apple reference Mini-LED monitor. 6K resolution. P3 wide color. " - "576 FALD zones. 1000 nits sustained, 1600 nits peak HDR. " - "No standard DDC/CI - uses macOS reference modes. Source: Apple Support." + "576 FALD zones. 1000 nits sustained, 1600 nits peak HDR. " + "No standard DDC/CI - uses macOS reference modes. Source: Apple Support.", ) # ASUS ProArt PA32UCXR (Mini-LED 32" 4K reference with built-in colorimeter) @@ -2278,7 +2277,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6780, 0.3100), green=ChromaticityCoord(0.2650, 0.6900), blue=ChromaticityCoord(0.1380, 0.0520), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -2293,7 +2292,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: wide_gamut=True, vrr_capable=False, local_dimming=True, - local_dimming_zones=2304 + local_dimming_zones=2304, ), ddc=DDCRecommendations( picture_mode="User Mode 1", @@ -2309,17 +2308,17 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use User Mode 1 or User Mode 2 for full DDC/CI RGB gain access. " - "Built-in motorized colorimeter for automatic scheduled self-calibration. " - "ProArt Calibration software stores hardware LUT profiles. " - "Also supports Calman and Light Illusion ColourSpace CMS. " - "Picture modes: Standard, sRGB, Adobe RGB, DCI-P3, Rec.2020, DICOM, " - "Rec.709, HDR_PQ DCI, HDR_PQ Rec.2020, HDR_HLG, Dolby Vision, User Mode 1/2. " - "2304 Mini-LED zones. VESA DisplayHDR 1400 certified. " - "Factory calibrated DeltaE <1. Thunderbolt 4 connectivity. " - "Local dimming may affect uniformity - disable for flat-field calibration." + "Built-in motorized colorimeter for automatic scheduled self-calibration. " + "ProArt Calibration software stores hardware LUT profiles. " + "Also supports Calman and Light Illusion ColourSpace CMS. " + "Picture modes: Standard, sRGB, Adobe RGB, DCI-P3, Rec.2020, DICOM, " + "Rec.709, HDR_PQ DCI, HDR_PQ Rec.2020, HDR_HLG, Dolby Vision, User Mode 1/2. " + "2304 Mini-LED zones. VESA DisplayHDR 1400 certified. " + "Factory calibrated DeltaE <1. Thunderbolt 4 connectivity. " + "Local dimming may affect uniformity - disable for flat-field calibration.", ), notes="Reference Mini-LED with built-in colorimeter. 97% DCI-P3, 99% Adobe RGB. " - "2304 zones FALD. 1600 nits peak HDR. DeltaE <1 factory. Source: Tom's HW/ASUS." + "2304 zones FALD. 1600 nits peak HDR. DeltaE <1 factory. Source: Tom's HW/ASUS.", ) # NEC MultiSync PA271Q (IPS 27" 1440p professional - NEC/Sharp) @@ -2333,7 +2332,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -2347,7 +2346,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -2363,16 +2362,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="NEC SpectraView Engine provides hardware calibration via 14-bit LUT. " - "Use with SpectraView II or compatible calibration software. " - "98.5% Adobe RGB, 100% sRGB, 81.1% Rec.2020. " - "USB-C with 30W PD. DisplaySync Pro for multi-monitor color matching. " - "RJ-45 port for remote management. Memory port for USB sensor. " - "SpectraView Engine ensures color stability between calibrations. " - "Inputs: DP, Mini DP, HDMI (2x), USB-C. " - "4-year warranty with Advanced Exchange." + "Use with SpectraView II or compatible calibration software. " + "98.5% Adobe RGB, 100% sRGB, 81.1% Rec.2020. " + "USB-C with 30W PD. DisplaySync Pro for multi-monitor color matching. " + "RJ-45 port for remote management. Memory port for USB sensor. " + "SpectraView Engine ensures color stability between calibrations. " + "Inputs: DP, Mini DP, HDMI (2x), USB-C. " + "4-year warranty with Advanced Exchange.", ), notes="Professional 27-inch 1440p IPS. 98.5% Adobe RGB. SpectraView Engine hardware LUT. " - "14-bit processing. USB-C 30W PD. Source: NEC/B&H." + "14-bit processing. USB-C 30W PD. Source: NEC/B&H.", ) # EIZO ColorEdge CG2700X (IPS 27" 4K reference with built-in sensor) @@ -2386,7 +2385,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6780, 0.3100), green=ChromaticityCoord(0.2650, 0.6900), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -2400,7 +2399,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="User", @@ -2416,18 +2415,18 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="TRUE REFERENCE MONITOR. Built-in swing-out calibration sensor. " - "ColorNavigator 7 software for hardware calibration via 16-bit LUT. " - "Color modes: User, BT.2020, BT.709, DCI-P3, PQ_DCI-P3, HLG_BT.2100, " - "AdobeRGB, sRGB, Calibration (CAL), SYNC_SIGNAL. " - "99% Adobe RGB, 98% DCI-P3. HDR gamma: HLG and PQ curves supported. " - "Digital Uniformity Equalizer (DUE) for screen uniformity. " - "AI-based temperature drift correction in real-time. " - "Sensor can be correlated with external spectroradiometers. " - "Nearest Neighbor interpolation option for pixel-accurate scaling. " - "Schedule automatic recalibration. Calibrate all color modes simultaneously." + "ColorNavigator 7 software for hardware calibration via 16-bit LUT. " + "Color modes: User, BT.2020, BT.709, DCI-P3, PQ_DCI-P3, HLG_BT.2100, " + "AdobeRGB, sRGB, Calibration (CAL), SYNC_SIGNAL. " + "99% Adobe RGB, 98% DCI-P3. HDR gamma: HLG and PQ curves supported. " + "Digital Uniformity Equalizer (DUE) for screen uniformity. " + "AI-based temperature drift correction in real-time. " + "Sensor can be correlated with external spectroradiometers. " + "Nearest Neighbor interpolation option for pixel-accurate scaling. " + "Schedule automatic recalibration. Calibrate all color modes simultaneously.", ), notes="True reference 27-inch 4K IPS. 99% Adobe RGB, 98% DCI-P3. Built-in sensor. " - "16-bit LUT. ColorNavigator 7. DUE uniformity. Source: EIZO." + "16-bit LUT. ColorNavigator 7. DUE uniformity. Source: EIZO.", ) # BenQ SW272U (IPS 27" 4K photography monitor) @@ -2441,7 +2440,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -2455,7 +2454,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Adobe RGB", @@ -2471,17 +2470,17 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Photography-focused monitor with A.R.T. (Advanced Reflectionless Technology) panel. " - "Hardware calibration via Palette Master Ultimate and 16-bit 3D LUT. " - "Also supports Calman, Colorspace, and other 3rd-party calibration. " - "Color modes: sRGB, Rec.709, DCI-P3, Display P3, Adobe RGB, M-Book, Custom 1/2. " - "99% Adobe RGB, 99% DCI-P3, 100% sRGB. " - "Factory calibrated DeltaE <=1.5. Calman Verified. Pantone Validated. " - "Detachable shading hood bridge. VESA DisplayHDR 400. " - "3rd-gen Uniformity and Color Consistency technologies. " - "Paper-like matte finish reduces reflections. 60Hz only." + "Hardware calibration via Palette Master Ultimate and 16-bit 3D LUT. " + "Also supports Calman, Colorspace, and other 3rd-party calibration. " + "Color modes: sRGB, Rec.709, DCI-P3, Display P3, Adobe RGB, M-Book, Custom 1/2. " + "99% Adobe RGB, 99% DCI-P3, 100% sRGB. " + "Factory calibrated DeltaE <=1.5. Calman Verified. Pantone Validated. " + "Detachable shading hood bridge. VESA DisplayHDR 400. " + "3rd-gen Uniformity and Color Consistency technologies. " + "Paper-like matte finish reduces reflections. 60Hz only.", ), notes="Photography 27-inch 4K IPS. 99% Adobe RGB, 99% DCI-P3. A.R.T. panel. " - "16-bit 3D LUT hardware calibration. DeltaE <=1.5. Source: BenQ/PCWorld." + "16-bit 3D LUT hardware calibration. DeltaE <=1.5. Source: BenQ/PCWorld.", ) # Dell UltraSharp U3224KB (IPS Black 32" 6K Thunderbolt hub monitor) @@ -2495,7 +2494,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6780, 0.3100), green=ChromaticityCoord(0.2650, 0.6900), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -2510,7 +2509,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: wide_gamut=True, vrr_capable=False, local_dimming=True, - local_dimming_zones=0 + local_dimming_zones=0, ), ddc=DDCRecommendations( picture_mode="Custom Color", @@ -2526,18 +2525,18 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="Use Custom Color mode for DDC/CI RGB gain access. " - "IPS Black panel with 2000:1 contrast (measured ~1500:1). " - "6K resolution (6144x3456) - world's first 6K monitor. 224 PPI. " - "100% DCI-P3 coverage. VESA DisplayHDR 600 with edge-lit local dimming. " - "Thunderbolt 4, HDMI 2.1, DisplayPort 2.1 connectivity. " - "Built-in 4K HDR webcam with dual 14W speakers. " - "5x USB-C and 5x USB-A ports. Massive hub monitor. " - "No gaming features (60Hz, no VRR). Strictly productivity. " - "Dell Display Manager provides DDC/CI desktop control. " - "ComfortView must be off for accurate white balance." + "IPS Black panel with 2000:1 contrast (measured ~1500:1). " + "6K resolution (6144x3456) - world's first 6K monitor. 224 PPI. " + "100% DCI-P3 coverage. VESA DisplayHDR 600 with edge-lit local dimming. " + "Thunderbolt 4, HDMI 2.1, DisplayPort 2.1 connectivity. " + "Built-in 4K HDR webcam with dual 14W speakers. " + "5x USB-C and 5x USB-A ports. Massive hub monitor. " + "No gaming features (60Hz, no VRR). Strictly productivity. " + "Dell Display Manager provides DDC/CI desktop control. " + "ComfortView must be off for accurate white balance.", ), notes="6K IPS Black hub monitor. 100% DCI-P3. 2000:1 contrast ratio. " - "Thunderbolt 4. 4K webcam. Source: Tom's Hardware/TFTCentral." + "Thunderbolt 4. 4K webcam. Source: Tom's Hardware/TFTCentral.", ) # ASUS ProArt PA32DC (RGB OLED 32" 4K professional with built-in colorimeter) @@ -2551,7 +2550,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6780, 0.3100), green=ChromaticityCoord(0.2650, 0.6900), blue=ChromaticityCoord(0.1380, 0.0520), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -2565,7 +2564,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="User Mode 1", @@ -2581,17 +2580,17 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="RGB OLED panel (not WOLED, not QD-OLED). Self-emissive per-pixel dimming. " - "Built-in motorized RGB colorimeter with ProArt Calibration software. " - "Supports 3D LUT hardware calibration. Also supports Calman and Light Illusion. " - "ProArt Presets: Standard, sRGB, Adobe RGB, DCI-P3, Rec.2020, DICOM, " - "Rec.709, User Mode 1/2, plus HDR variants. " - "Factory calibrated DeltaE <1. 99% DCI-P3, 99% Adobe RGB. " - "Schedule automatic colorimeter calibration (weekly/monthly). " - "Can be as dim as 6 cd/m2 - exceptional for dark room work. " - "HDR peak: 545 nits (small patch), 269 nits sustained full white." + "Built-in motorized RGB colorimeter with ProArt Calibration software. " + "Supports 3D LUT hardware calibration. Also supports Calman and Light Illusion. " + "ProArt Presets: Standard, sRGB, Adobe RGB, DCI-P3, Rec.2020, DICOM, " + "Rec.709, User Mode 1/2, plus HDR variants. " + "Factory calibrated DeltaE <1. 99% DCI-P3, 99% Adobe RGB. " + "Schedule automatic colorimeter calibration (weekly/monthly). " + "Can be as dim as 6 cd/m2 - exceptional for dark room work. " + "HDR peak: 545 nits (small patch), 269 nits sustained full white.", ), notes="Professional RGB OLED with built-in colorimeter. 99% DCI-P3, 99% Adobe RGB. " - "3D LUT hardware calibration. DeltaE <1 factory. Source: PCMonitors.info." + "3D LUT hardware calibration. DeltaE <1 factory. Source: PCMonitors.info.", ) # Philips 27E1N8900 (JOLED RGB OLED 27" 4K 60Hz professional) @@ -2605,7 +2604,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6780, 0.3100), green=ChromaticityCoord(0.2650, 0.6900), blue=ChromaticityCoord(0.1380, 0.0520), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -2619,7 +2618,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=True, wide_gamut=True, vrr_capable=False, - local_dimming=False + local_dimming=False, ), ddc=DDCRecommendations( picture_mode="Custom", @@ -2635,16 +2634,16 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="2.2", gamma_vcp_value=0x04, notes="JOLED RGB OLED panel (printed OLED technology). " - "NOT QD-OLED or WOLED - true RGB subpixel OLED. " - "Color presets: sRGB (DeltaE <1 specified), DCI-P3, Adobe RGB, User Define. " - "99.7% DCI-P3, 99.6% Adobe RGB, ~150% sRGB relative coverage. " - "Close-to-glossy screen surface with mild AG treatment. " - "VESA DisplayHDR True Black 400. Per-pixel dimming, no blooming. " - "USB-C with power delivery. 60Hz only. " - "Professional-grade OLED at moderate price point (~$1070 USD)." + "NOT QD-OLED or WOLED - true RGB subpixel OLED. " + "Color presets: sRGB (DeltaE <1 specified), DCI-P3, Adobe RGB, User Define. " + "99.7% DCI-P3, 99.6% Adobe RGB, ~150% sRGB relative coverage. " + "Close-to-glossy screen surface with mild AG treatment. " + "VESA DisplayHDR True Black 400. Per-pixel dimming, no blooming. " + "USB-C with power delivery. 60Hz only. " + "Professional-grade OLED at moderate price point (~$1070 USD).", ), notes="JOLED RGB OLED 27-inch 4K. 99.7% DCI-P3, 99.6% Adobe RGB. " - "True Black 400. 60Hz professional. Source: TFTCentral/Philips." + "True Black 400. 60Hz professional. Source: TFTCentral/Philips.", ) # AOC AGON Pro AG274QXM (IPS Mini-LED 27" 1440p 170Hz gaming) @@ -2658,7 +2657,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6480, 0.3320), green=ChromaticityCoord(0.2750, 0.6400), blue=ChromaticityCoord(0.1495, 0.0580), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.1500, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.1500, offset=0.0, linear_portion=0.0), @@ -2673,7 +2672,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: wide_gamut=True, vrr_capable=True, local_dimming=True, - local_dimming_zones=576 + local_dimming_zones=576, ), ddc=DDCRecommendations( picture_mode="User", @@ -2689,19 +2688,19 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: gamma_preset="Gamma3", gamma_vcp_value=0x04, notes="Use User picture mode for DDC/CI RGB gain access. " - "Innolux M270KCJ-Q7E IPS panel with Mini-LED backlight. " - "576 local dimming zones. VESA DisplayHDR 1000 certified. " - "NOTE: Brightness is locked in sRGB mode - use User mode. " - "98% DCI-P3, 97% Adobe RGB, 145% sRGB (oversaturated in native). " - "Measured gamma runs slightly low at 2.15 default. " - "FreeSync Premium Pro (48-170Hz with LFC). " - "Shadow Shield (detachable hood) included. " - "USB-C with DP Alt mode and 65W PD. Built-in KVM. " - "Blue light reduction modes affect color - disable for calibration. " - "1x DP, 2x HDMI 2.0 inputs." + "Innolux M270KCJ-Q7E IPS panel with Mini-LED backlight. " + "576 local dimming zones. VESA DisplayHDR 1000 certified. " + "NOTE: Brightness is locked in sRGB mode - use User mode. " + "98% DCI-P3, 97% Adobe RGB, 145% sRGB (oversaturated in native). " + "Measured gamma runs slightly low at 2.15 default. " + "FreeSync Premium Pro (48-170Hz with LFC). " + "Shadow Shield (detachable hood) included. " + "USB-C with DP Alt mode and 65W PD. Built-in KVM. " + "Blue light reduction modes affect color - disable for calibration. " + "1x DP, 2x HDMI 2.0 inputs.", ), notes="Mini-LED gaming 27-inch 1440p. 576 zones. 98% DCI-P3. HDR 1000. " - "Measured gamma ~2.15 default. Source: TFTCentral." + "Measured gamma ~2.15 default. Source: TFTCentral.", ) # Generic sRGB IPS (fallback for unknown panels) @@ -2714,7 +2713,7 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: red=ChromaticityCoord(0.6400, 0.3300), green=ChromaticityCoord(0.3000, 0.6000), blue=ChromaticityCoord(0.1500, 0.0600), - white=ChromaticityCoord(0.3127, 0.3290) + white=ChromaticityCoord(0.3127, 0.3290), ), gamma_red=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=2.2000, offset=0.0, linear_portion=0.0), @@ -2728,9 +2727,9 @@ def get_builtin_panels() -> dict[str, PanelCharacterization]: hdr_capable=False, wide_gamut=False, vrr_capable=False, - local_dimming=False + local_dimming=False, ), - notes="Generic sRGB panel. Used as fallback when no specific profile exists." + notes="Generic sRGB panel. Used as fallback when no specific profile exists.", ) return panels diff --git a/calibrate_pro/panels/database.py b/calibrate_pro/panels/database.py index 9cf913b..49a03be 100644 --- a/calibrate_pro/panels/database.py +++ b/calibrate_pro/panels/database.py @@ -146,6 +146,7 @@ def save_panel(self, key: str, filename: str | None = None): # Global database instance _database: PanelDatabase | None = None + def get_database() -> PanelDatabase: """Get the global panel database instance.""" global _database @@ -153,13 +154,15 @@ def get_database() -> PanelDatabase: _database = PanelDatabase() return _database + def find_panel_for_display(model_string: str) -> PanelCharacterization | None: """Convenience function to find a panel by model string.""" return get_database().find_panel(model_string) -def create_from_edid(edid_chromaticity: dict, monitor_name: str = "Unknown", - manufacturer: str = "Unknown", gamma: float = 2.2) -> PanelCharacterization: +def create_from_edid( + edid_chromaticity: dict, monitor_name: str = "Unknown", manufacturer: str = "Unknown", gamma: float = 2.2 +) -> PanelCharacterization: """ Create a PanelCharacterization from EDID chromaticity data. @@ -210,7 +213,7 @@ def create_from_edid(edid_chromaticity: dict, monitor_name: str = "Unknown", ccm = [ [1.0 + r_shift * 0.5, -g_shift * 0.3, -b_shift * 0.2], [-r_shift * 0.1, 1.0 + g_shift * 0.3, -b_shift * 0.15], - [r_shift * 0.03, -g_shift * 0.5, 1.0 + b_shift * 0.45] + [r_shift * 0.03, -g_shift * 0.5, 1.0 + b_shift * 0.45], ] return PanelCharacterization( @@ -221,7 +224,7 @@ def create_from_edid(edid_chromaticity: dict, monitor_name: str = "Unknown", red=ChromaticityCoord(red[0], red[1]), green=ChromaticityCoord(green[0], green[1]), blue=ChromaticityCoord(blue[0], blue[1]), - white=ChromaticityCoord(white[0], white[1]) + white=ChromaticityCoord(white[0], white[1]), ), gamma_red=GammaCurve(gamma=gamma, offset=0.0, linear_portion=0.0), gamma_green=GammaCurve(gamma=gamma, offset=0.0, linear_portion=0.0), @@ -235,10 +238,10 @@ def create_from_edid(edid_chromaticity: dict, monitor_name: str = "Unknown", hdr_capable=is_wide_gamut, wide_gamut=is_wide_gamut, vrr_capable=False, - local_dimming=False + local_dimming=False, ), color_correction_matrix=ccm, notes=f"Auto-generated from EDID data. Primaries: R({red[0]:.4f},{red[1]:.4f}) " - f"G({green[0]:.4f},{green[1]:.4f}) B({blue[0]:.4f},{blue[1]:.4f}). " - f"Gamma assumed {gamma}." + f"G({green[0]:.4f},{green[1]:.4f}) B({blue[0]:.4f},{blue[1]:.4f}). " + f"Gamma assumed {gamma}.", ) diff --git a/calibrate_pro/panels/detection.py b/calibrate_pro/panels/detection.py index 6f88a8e..a159627 100644 --- a/calibrate_pro/panels/detection.py +++ b/calibrate_pro/panels/detection.py @@ -51,6 +51,7 @@ # Windows-specific ctypes structures (only defined on Windows) if sys.platform == "win32": + class DISPLAY_DEVICE(ctypes.Structure): _fields_ = [ ("cb", wintypes.DWORD), @@ -103,7 +104,9 @@ class RECT(ctypes.Structure): ("bottom", wintypes.LONG), ] + if sys.platform == "win32": + class MONITORINFO(ctypes.Structure): _fields_ = [ ("cbSize", wintypes.DWORD), @@ -125,13 +128,15 @@ class MONITORINFOEX(ctypes.Structure): # Display Information # ============================================================================= + @dataclass class DisplayInfo: """Information about a connected display.""" - device_name: str # Windows device name (e.g., "\\\\.\\DISPLAY1") - device_string: str # Friendly name (e.g., "NVIDIA GeForce RTX 4090") - monitor_name: str # Monitor model from EDID - device_id: str # PnP device ID + + device_name: str # Windows device name (e.g., "\\\\.\\DISPLAY1") + device_string: str # Friendly name (e.g., "NVIDIA GeForce RTX 4090") + monitor_name: str # Monitor model from EDID + device_id: str # PnP device ID is_primary: bool is_active: bool @@ -150,14 +155,14 @@ class DisplayInfo: year: int = 0 # Enhanced detection fields - panel_type: str = "" # OLED, QD-OLED, WOLED, IPS, VA, TN, Mini-LED - connection_type: str = "" # HDMI, DisplayPort, USB-C, DVI - hdr_capable: bool = False # HDR10/Dolby Vision support - wide_gamut: bool = False # DCI-P3 or wider gamut - native_gamma: float = 2.2 # Native panel gamma - max_luminance: float = 0.0 # Peak brightness (cd/m²) + panel_type: str = "" # OLED, QD-OLED, WOLED, IPS, VA, TN, Mini-LED + connection_type: str = "" # HDMI, DisplayPort, USB-C, DVI + hdr_capable: bool = False # HDR10/Dolby Vision support + wide_gamut: bool = False # DCI-P3 or wider gamut + native_gamma: float = 2.2 # Native panel gamma + max_luminance: float = 0.0 # Peak brightness (cd/m²) panel_size_inches: float = 0.0 # Diagonal size in inches - panel_database_key: str = "" # Matched panel database key + panel_database_key: str = "" # Matched panel database key # Current ICC profile current_profile: str | None = None @@ -168,12 +173,13 @@ def get_resolution_string(self) -> str: def get_display_number(self) -> int: """Extract display number from device name.""" - match = re.search(r'DISPLAY(\d+)', self.device_name) + match = re.search(r"DISPLAY(\d+)", self.device_name) return int(match.group(1)) if match else 0 def get_aspect_ratio(self) -> str: """Calculate aspect ratio string.""" from math import gcd + g = gcd(self.width, self.height) w, h = self.width // g, self.height // g # Normalize common ratios @@ -228,17 +234,20 @@ def to_dict(self) -> dict: "max_luminance": self.max_luminance, "panel_size_inches": self.panel_size_inches, "panel_database_key": self.panel_database_key, - "current_profile": self.current_profile + "current_profile": self.current_profile, } + # ============================================================================= # Cross-Platform Display Detection # ============================================================================= + def _enumerate_displays_cross_platform() -> list[DisplayInfo]: """Enumerate displays on macOS/Linux via the platform backend.""" try: from calibrate_pro.platform import get_platform_backend + backend = get_platform_backend() platform_displays = backend.enumerate_displays() @@ -265,6 +274,7 @@ def _enumerate_displays_cross_platform() -> list[DisplayInfo]: except Exception as e: import logging + logging.getLogger(__name__).error("Cross-platform display detection failed: %s", e) return [] @@ -273,6 +283,7 @@ def _enumerate_displays_cross_platform() -> list[DisplayInfo]: # Display Detection # ============================================================================= + def enumerate_displays() -> list[DisplayInfo]: """ Enumerate all connected displays. @@ -285,6 +296,7 @@ def enumerate_displays() -> list[DisplayInfo]: List of DisplayInfo for each active display """ import sys + if sys.platform != "win32": return _enumerate_displays_cross_platform() @@ -317,8 +329,7 @@ def enumerate_displays() -> list[DisplayInfo]: monitor.cb = ctypes.sizeof(monitor) if not user32.EnumDisplayDevicesW( - adapter.DeviceName, monitor_index, - ctypes.byref(monitor), EDD_GET_DEVICE_INTERFACE_NAME + adapter.DeviceName, monitor_index, ctypes.byref(monitor), EDD_GET_DEVICE_INTERFACE_NAME ): break @@ -344,7 +355,7 @@ def enumerate_displays() -> list[DisplayInfo]: position_x=devmode.dmPositionX, position_y=devmode.dmPositionY, manufacturer=manufacturer, - model=model + model=model, ) # Get current ICC profile @@ -371,7 +382,7 @@ def parse_device_id(device_id: str) -> tuple[str, str]: # Device ID formats: # - MONITOR\{vendor}{product}\{serial} (e.g., MONITOR\SAM0F9E\{...}) # - \\?\DISPLAY#{vendor}{product}#{serial} (e.g., \\?\DISPLAY#SAM72F2#...) - match = re.search(r'(?:MONITOR\\|DISPLAY#)([A-Z]{3})([A-F0-9]{4})', device_id, re.IGNORECASE) + match = re.search(r"(?:MONITOR\\|DISPLAY#)([A-Z]{3})([A-F0-9]{4})", device_id, re.IGNORECASE) if match: vendor_code = match.group(1).upper() product_code = match.group(2).upper() @@ -405,6 +416,7 @@ def parse_device_id(device_id: str) -> tuple[str, str]: # Enhanced EDID Detection (Reads raw EDID from registry) # ============================================================================= + def get_edid_from_registry(device_id: str) -> bytes | None: """ Read raw EDID data from Windows registry. @@ -423,7 +435,7 @@ def get_edid_from_registry(device_id: str) -> bytes | None: try: # Extract the monitor key from device ID # Format: MONITOR\XXX####\{guid}_## - match = re.search(r'MONITOR\\([A-Z]{3}[A-F0-9]{4})\\', device_id, re.IGNORECASE) + match = re.search(r"MONITOR\\([A-Z]{3}[A-F0-9]{4})\\", device_id, re.IGNORECASE) if not match: return None @@ -505,9 +517,9 @@ def parse_edid(edid: bytes) -> dict: # Manufacturer ID (bytes 8-9) - 3 letters encoded in 15 bits mfg_id = (edid[8] << 8) | edid[9] - char1 = ((mfg_id >> 10) & 0x1F) + ord('A') - 1 - char2 = ((mfg_id >> 5) & 0x1F) + ord('A') - 1 - char3 = (mfg_id & 0x1F) + ord('A') - 1 + char1 = ((mfg_id >> 10) & 0x1F) + ord("A") - 1 + char2 = ((mfg_id >> 5) & 0x1F) + ord("A") - 1 + char3 = (mfg_id & 0x1F) + ord("A") - 1 result["manufacturer_code"] = chr(char1) + chr(char2) + chr(char3) # Map manufacturer codes to names @@ -538,7 +550,7 @@ def parse_edid(edid: bytes) -> dict: result["product_code"] = edid[10] | (edid[11] << 8) # Serial number (bytes 12-15, little-endian) - result["serial_number"] = struct.unpack(' dict: # Preferred timing (bytes 54-71) - first detailed timing descriptor if len(edid) >= 71: - pixel_clock = struct.unpack(' 0: h_active = edid[56] | ((edid[58] & 0xF0) << 4) v_active = edid[59] | ((edid[61] & 0xF0) << 4) @@ -574,17 +586,17 @@ def parse_edid(edid: bytes) -> dict: if len(edid) < block_start + 18: break - block = edid[block_start:block_start + 18] + block = edid[block_start : block_start + 18] # Check if this is a text descriptor (starts with 00 00 00) if block[0:3] == bytes([0x00, 0x00, 0x00]): tag = block[3] - text = block[5:18].decode('ascii', errors='ignore').strip() + text = block[5:18].decode("ascii", errors="ignore").strip() if tag == 0xFC: # Monitor name - result["monitor_name"] = text.replace('\n', '').strip() + result["monitor_name"] = text.replace("\n", "").strip() elif tag == 0xFF: # Serial string - result["serial_string"] = text.replace('\n', '').strip() + result["serial_string"] = text.replace("\n", "").strip() return result @@ -614,83 +626,62 @@ def get_display_fingerprint(display: DisplayInfo) -> str: # =========================================== # QD-OLED Monitors (Samsung Display panels) # =========================================== - # ASUS PG27UCDM - 4K 240Hz QD-OLED 27" "3840x2160@240_ASUS": "PG27UCDM", "3840x2160@240_AUS": "PG27UCDM", - # ASUS PG32UCDM - 4K 240Hz QD-OLED 32" "3840x2160@240_ASUS_32": "PG32UCDM", - # Samsung Odyssey G8 G80SD - 4K 240Hz QD-OLED 32" "3840x2160@240_Samsung": "G80SD", "3840x2160@240_SAM": "G80SD", - # Samsung Odyssey G85SB - 4K 240Hz QD-OLED 34" "3440x1440@175_Samsung": "G85SB", "3440x1440@175_SAM": "G85SB", - # Dell Alienware AW3225QF - 4K 240Hz QD-OLED 32" "3840x2160@240_Dell": "AW3225QF", "3840x2160@240_DEL": "AW3225QF", - # Dell Alienware AW3423DW - 3440x1440 175Hz QD-OLED 34" "3440x1440@175_Dell": "AW3423DW", "3440x1440@175_DEL": "AW3423DW", "3440x1440@175": "AW3423DW", - # Gigabyte AORUS FO32U2P - 4K 240Hz QD-OLED 32" "3840x2160@240_Gigabyte": "FO32U2P", - # Samsung Odyssey G95SC - 5120x1440 240Hz QD-OLED 49" "5120x1440@240_Samsung": "G95SC", "5120x1440@240_SAM": "G95SC", - # MSI MEG 342C - 3440x1440 175Hz QD-OLED 34" "3440x1440@175_MSI": "MEG342C", - # Corsair Xeneon 34 - 3440x1440 175Hz QD-OLED 34" "3440x1440@175_Corsair": "XENEON34", - # Default for unidentified 4K 240Hz (assume QD-OLED) "3840x2160@240": "PG27UCDM", - # =========================================== # WOLED Monitors (LG Display panels) # =========================================== - # LG 27GR95QE - 2560x1440 240Hz WOLED 27" "2560x1440@240_LG": "27GR95QE", "2560x1440@240_GSM": "27GR95QE", - # LG C3 OLED TVs (42/48/55") "3840x2160@120_LG": "LG_C3", "3840x2160@120_GSM": "LG_C3", - # LG C4 OLED TVs (42/48/55/65") "3840x2160@144_LG": "LG_C4", "3840x2160@144_GSM": "LG_C4", - # =========================================== # IPS/Mini-LED Monitors # =========================================== - # Sony INZONE M9 - 4K 144Hz IPS+FALD "3840x2160@144_Sony": "INZONE_M9", "3840x2160@144_SNY": "INZONE_M9", - # LG 27GP950-B - 4K 160Hz Nano-IPS "3840x2160@160_LG": "27GP950", "3840x2160@160_GSM": "27GP950", - # BenQ PD3220U - 4K 60Hz IPS (Professional) "3840x2160@60_BenQ": "PD3220U", "3840x2160@60_BNQ": "PD3220U", - # ASUS ProArt PA32UCG - 4K 120Hz Mini-LED "3840x2160@120_ASUS": "PA32UCG", "3840x2160@120_AUS": "PA32UCG", - # Samsung Odyssey G7/G5 Ultrawides (VA) "3440x1440@120_Samsung": "ODYSSEY_G7_UW", "3440x1440@120_SAM": "ODYSSEY_G7_UW", @@ -698,85 +689,64 @@ def get_display_fingerprint(display: DisplayInfo) -> str: "3440x1440@144_SAM": "ODYSSEY_G7_UW", "3440x1440@165_Samsung": "ODYSSEY_G7_UW", "3440x1440@165_SAM": "ODYSSEY_G7_UW", - # =========================================== # Professional / Photo Editing Monitors # =========================================== - # Dell U2723QE - 4K 60Hz IPS (sRGB professional) "3840x2160@60_Dell": "U2723QE", "3840x2160@60_DEL": "U2723QE", - # BenQ SW271C - 4K 60Hz IPS (Photo editing) "3840x2160@60_BenQ_SW": "SW271C", "3840x2160@60_BNQ_SW": "SW271C", - # EIZO CG2700S - 2560x1440 60Hz IPS (Professional reference) "2560x1440@60_EIZO": "CG2700S", "2560x1440@60_EIZ": "CG2700S", - # Dell U3423WE - 3440x1440 60Hz IPS (Ultrawide professional) "3440x1440@60_Dell": "U3423WE", "3440x1440@60_DEL": "U3423WE", - # ViewSonic VP2786-4K - 4K 60Hz IPS (Professional) "3840x2160@60_ViewSonic": "VP2786", "3840x2160@60_VSC": "VP2786", - # =========================================== # Gaming IPS / Nano-IPS Monitors # =========================================== - # ASUS VG27AQ1A - 2K 170Hz IPS (Gaming) "2560x1440@170_ASUS": "VG27AQ1A", "2560x1440@170_AUS": "VG27AQ1A", - # LG 27GP850-B - 2K 165Hz Nano IPS (Gaming) "2560x1440@165_LG": "27GP850", "2560x1440@165_GSM": "27GP850", - # MSI MAG 274QRF-QD - 2K 165Hz QD-IPS "2560x1440@165_MSI": "274QRF_QD", - # Gigabyte M28U - 4K 144Hz IPS (Budget 4K gaming) "3840x2160@144_Gigabyte": "M28U", - # =========================================== # Gaming VA Monitors # =========================================== - # Samsung Odyssey G7 27" - 2K 240Hz VA (Gaming) "2560x1440@240_Samsung": "ODYSSEY_G7_27", "2560x1440@240_SAM": "ODYSSEY_G7_27", - # Dell S2722DGM - 2K 165Hz VA (Budget gaming) "2560x1440@165_Dell": "S2722DGM", "2560x1440@165_DEL": "S2722DGM", - # =========================================== # QD-OLED TVs # =========================================== - # Sony A95L - 4K 120Hz QD-OLED TV "3840x2160@120_Sony": "SONY_A95L", "3840x2160@120_SNY": "SONY_A95L", - # Samsung S95D - 4K 144Hz QD-OLED TV "3840x2160@144_Samsung": "S95D", "3840x2160@144_SAM": "S95D", - # =========================================== # QD-OLED Ultrawide Monitors # =========================================== - # ASUS PG34WCDM - 3440x1440 240Hz QD-OLED "3440x1440@240_ASUS": "PG34WCDM", "3440x1440@240_AUS": "PG34WCDM", - # =========================================== # WOLED Monitors # =========================================== - # LG 32GS95UE - 4K 240Hz WOLED 32" "3840x2160@240_LG_32": "32GS95UE", "3840x2160@240_GSM_32": "32GS95UE", @@ -789,16 +759,51 @@ def get_display_fingerprint(display: DisplayInfo) -> str: # Known OLED models (for panel type detection when EDID doesn't specify) KNOWN_OLED_MODELS = { # QD-OLED - "PG27UCDM", "PG32UCDM", "G80SD", "G85SB", "G95SC", "AW3423DW", "AW3225QF", - "MEG342C", "XENEON34", "FO32U2P", "PG34WCDM", "QD-OLED", + "PG27UCDM", + "PG32UCDM", + "G80SD", + "G85SB", + "G95SC", + "AW3423DW", + "AW3225QF", + "MEG342C", + "XENEON34", + "FO32U2P", + "PG34WCDM", + "QD-OLED", # WOLED - "27GR95QE", "45GR95QE", "32GS95UE", "C1", "C2", "C3", "C4", "G1", "G2", "G3", "G4", - "A80K", "A90K", "A95K", "A80L", "A95L", "S95B", "S95C", "S95D", + "27GR95QE", + "45GR95QE", + "32GS95UE", + "C1", + "C2", + "C3", + "C4", + "G1", + "G2", + "G3", + "G4", + "A80K", + "A90K", + "A95K", + "A80L", + "A95L", + "S95B", + "S95C", + "S95D", } KNOWN_MINI_LED_MODELS = { - "PA32UCG", "XDR", "Pro Display XDR", "INZONE_M9", "M32U", "PG32UCDP", - "PD32M", "U32R59", "M80C", "NEO G9", + "PA32UCG", + "XDR", + "Pro Display XDR", + "INZONE_M9", + "M32U", + "PG32UCDP", + "PD32M", + "U32R59", + "M80C", + "NEO G9", } @@ -832,7 +837,9 @@ def detect_panel_type(model_name: str, manufacturer: str, edid_info: dict = None for known in KNOWN_OLED_MODELS: if known.upper() in model_upper: # Determine QD-OLED vs WOLED - if any(x in mfg_upper for x in ["SAMSUNG", "SAM", "DELL", "DEL", "ASUS", "AUS", "MSI", "CORSAIR", "GIGABYTE"]): + if any( + x in mfg_upper for x in ["SAMSUNG", "SAM", "DELL", "DEL", "ASUS", "AUS", "MSI", "CORSAIR", "GIGABYTE"] + ): return "QD-OLED" elif any(x in mfg_upper for x in ["LG", "GSM", "SONY", "SNY"]): return "WOLED" @@ -906,9 +913,10 @@ def calculate_panel_size(h_cm: int, v_cm: int) -> float: Diagonal size in inches """ import math + if h_cm <= 0 or v_cm <= 0: return 0.0 - diagonal_cm = math.sqrt(h_cm ** 2 + v_cm ** 2) + diagonal_cm = math.sqrt(h_cm**2 + v_cm**2) return round(diagonal_cm / 2.54, 1) @@ -945,11 +953,7 @@ def enrich_display_info(display: DisplayInfo) -> DisplayInfo: display.panel_size_inches = calculate_panel_size(h_cm, v_cm) # Detect panel type - display.panel_type = detect_panel_type( - display.monitor_name, - display.manufacturer, - edid_info - ) + display.panel_type = detect_panel_type(display.monitor_name, display.manufacturer, edid_info) # Detect connection type display.connection_type = detect_connection_type(display.device_id) @@ -1128,6 +1132,7 @@ def get_display_by_number(number: int) -> DisplayInfo | None: return display return None + # ============================================================================= # ICC Profile Management # ============================================================================= @@ -1259,13 +1264,16 @@ def list_installed_profiles() -> list[Path]: return list(color_dir.glob("*.icc")) + list(color_dir.glob("*.icm")) return [] + # ============================================================================= # Gamma Ramp # ============================================================================= if sys.platform == "win32": + class GAMMA_RAMP(ctypes.Structure): """Windows gamma ramp structure (256 entries per channel).""" + _fields_ = [ ("Red", wintypes.WORD * 256), ("Green", wintypes.WORD * 256), @@ -1291,6 +1299,7 @@ def get_gamma_ramp(device_name: str) -> tuple | None: if result: import numpy as np + red = np.array(ramp.Red[:], dtype=np.uint16) green = np.array(ramp.Green[:], dtype=np.uint16) blue = np.array(ramp.Blue[:], dtype=np.uint16) @@ -1304,12 +1313,7 @@ def get_gamma_ramp(device_name: str) -> tuple | None: return None -def set_gamma_ramp( - device_name: str, - red: np.ndarray, - green: np.ndarray, - blue: np.ndarray -) -> bool: +def set_gamma_ramp(device_name: str, red: np.ndarray, green: np.ndarray, blue: np.ndarray) -> bool: """ Set the gamma ramp for a display. @@ -1354,6 +1358,7 @@ def set_gamma_ramp( def reset_gamma_ramp(device_name: str) -> bool: """Reset gamma ramp to linear (identity).""" import numpy as np + linear = np.linspace(0, 65535, 256, dtype=np.uint16) return set_gamma_ramp(device_name, linear, linear, linear) @@ -1362,6 +1367,7 @@ def reset_gamma_ramp(device_name: str) -> bool: # Convenience Functions # ============================================================================= + def get_display_name(display: DisplayInfo) -> str: """ Get a human-readable display name, resolving 'Generic PnP Monitor' @@ -1381,6 +1387,7 @@ def get_display_name(display: DisplayInfo) -> str: panel_key = identify_display(display) if panel_key: from calibrate_pro.panels.database import PanelDatabase + db = PanelDatabase() panel = db.get_panel(panel_key) if panel and panel.manufacturer != "Generic": @@ -1411,6 +1418,7 @@ def print_display_info(): panel_key = identify_display(display) if panel_key: from calibrate_pro.panels.database import PanelDatabase + db = PanelDatabase() panel = db.get_panel(panel_key) if panel: diff --git a/calibrate_pro/panels/panel_types.py b/calibrate_pro/panels/panel_types.py index f9e6b45..6afc6a7 100644 --- a/calibrate_pro/panels/panel_types.py +++ b/calibrate_pro/panels/panel_types.py @@ -14,30 +14,37 @@ @dataclass class ChromaticityCoord: """CIE 1931 xy chromaticity coordinate.""" + x: float y: float def as_tuple(self) -> tuple[float, float]: return (self.x, self.y) + @dataclass class PanelPrimaries: """Native panel primary colors and white point.""" + red: ChromaticityCoord green: ChromaticityCoord blue: ChromaticityCoord white: ChromaticityCoord + @dataclass class GammaCurve: """Per-channel gamma characteristics.""" + gamma: float = 2.2 # Native gamma offset: float = 0.0 # Black level offset linear_portion: float = 0.0 # Linear segment below this value + @dataclass class PanelCapabilities: """Panel hardware capabilities.""" + max_luminance_sdr: float = 100.0 # SDR peak brightness (cd/m2) max_luminance_hdr: float = 400.0 # HDR peak brightness (cd/m2) min_luminance: float = 0.0001 # Minimum black level (cd/m2) @@ -49,6 +56,7 @@ class PanelCapabilities: local_dimming: bool = False local_dimming_zones: int = 0 + @dataclass class DDCRecommendations: """DDC/CI hardware calibration recommendations for a specific panel. @@ -56,6 +64,7 @@ class DDCRecommendations: Contains recommended OSD settings and VCP code values to configure the monitor into a state suitable for DDC/CI-based calibration. """ + picture_mode: str | None = None # Recommended OSD picture mode name (e.g., "Custom 1", "User", "sRGB") picture_mode_vcp: int | None = None # VCP 0xDB value for the picture mode color_preset: str | None = None # Recommended color preset (e.g., "User 1", "Custom", "Warm") @@ -70,9 +79,11 @@ class DDCRecommendations: gamma_vcp_value: int | None = None # VCP 0xF2 value for gamma notes: str = "" # Calibration notes for this monitor + @dataclass class PanelCharacterization: """Complete panel characterization for calibration.""" + manufacturer: str model_pattern: str # Regex pattern to match EDID model panel_type: str # WOLED, QD-OLED, IPS, VA, etc. @@ -95,7 +106,7 @@ def name(self) -> str: # Fallback: use the first alternative in model_pattern if it looks like a name first = self.model_pattern.split("|")[0] # Strip regex characters - clean = re.sub(r'[\\.*+?^$\[\](){}]', '', first) + clean = re.sub(r"[\\.*+?^$\[\](){}]", "", first) return f"{self.manufacturer} {clean}" def to_dict(self) -> dict: @@ -108,7 +119,7 @@ def to_dict(self) -> dict: "red": {"x": self.native_primaries.red.x, "y": self.native_primaries.red.y}, "green": {"x": self.native_primaries.green.x, "y": self.native_primaries.green.y}, "blue": {"x": self.native_primaries.blue.x, "y": self.native_primaries.blue.y}, - "white": {"x": self.native_primaries.white.x, "y": self.native_primaries.white.y} + "white": {"x": self.native_primaries.white.x, "y": self.native_primaries.white.y}, }, "gamma_red": asdict(self.gamma_red), "gamma_green": asdict(self.gamma_green), @@ -117,7 +128,7 @@ def to_dict(self) -> dict: "color_correction_matrix": self.color_correction_matrix, "ddc": asdict(self.ddc) if self.ddc else None, "notes": self.notes, - "display_name": self.display_name + "display_name": self.display_name, } @classmethod @@ -127,7 +138,7 @@ def from_dict(cls, data: dict) -> "PanelCharacterization": red=ChromaticityCoord(**data["native_primaries"]["red"]), green=ChromaticityCoord(**data["native_primaries"]["green"]), blue=ChromaticityCoord(**data["native_primaries"]["blue"]), - white=ChromaticityCoord(**data["native_primaries"]["white"]) + white=ChromaticityCoord(**data["native_primaries"]["white"]), ) ddc_data = data.get("ddc") @@ -152,5 +163,5 @@ def from_dict(cls, data: dict) -> "PanelCharacterization": color_correction_matrix=data.get("color_correction_matrix"), ddc=ddc, notes=data.get("notes", ""), - display_name=data.get("display_name", "") + display_name=data.get("display_name", ""), ) diff --git a/calibrate_pro/patterns/display.py b/calibrate_pro/patterns/display.py index 1591270..899b250 100644 --- a/calibrate_pro/patterns/display.py +++ b/calibrate_pro/patterns/display.py @@ -37,6 +37,7 @@ # It should draw directly on the supplied canvas, which has already been # cleared to black. + def _draw_grayscale_ramp(canvas: tk.Canvas, w: int, h: int) -> None: """Horizontal gradient from black to white (256 steps).""" steps = 256 @@ -54,20 +55,22 @@ def _draw_rgb_primaries(canvas: tk.Canvas, w: int, h: int) -> None: colours = ["#ff0000", "#00ff00", "#0000ff"] bar_w = w / len(colours) for i, c in enumerate(colours): - canvas.create_rectangle(i * bar_w, 0, (i + 1) * bar_w, h, - fill=c, outline="") + canvas.create_rectangle(i * bar_w, 0, (i + 1) * bar_w, h, fill=c, outline="") def _draw_rgbcmy(canvas: tk.Canvas, w: int, h: int) -> None: """Six vertical bars: R, G, B, C, M, Y.""" colours = [ - "#ff0000", "#00ff00", "#0000ff", - "#00ffff", "#ff00ff", "#ffff00", + "#ff0000", + "#00ff00", + "#0000ff", + "#00ffff", + "#ff00ff", + "#ffff00", ] bar_w = w / len(colours) for i, c in enumerate(colours): - canvas.create_rectangle(i * bar_w, 0, (i + 1) * bar_w, h, - fill=c, outline="") + canvas.create_rectangle(i * bar_w, 0, (i + 1) * bar_w, h, fill=c, outline="") def _draw_gray_steps(canvas: tk.Canvas, w: int, h: int) -> None: @@ -77,8 +80,7 @@ def _draw_gray_steps(canvas: tk.Canvas, w: int, h: int) -> None: for i in range(steps): v = int(i * 255 / (steps - 1)) colour = f"#{v:02x}{v:02x}{v:02x}" - canvas.create_rectangle(i * patch_w, 0, (i + 1) * patch_w, h, - fill=colour, outline="") + canvas.create_rectangle(i * patch_w, 0, (i + 1) * patch_w, h, fill=colour, outline="") def _draw_white(canvas: tk.Canvas, w: int, h: int) -> None: @@ -144,6 +146,7 @@ def _draw_color_gradient(canvas: tk.Canvas, w: int, h: int) -> None: # Fullscreen pattern viewer # --------------------------------------------------------------------------- + class PatternViewer: """Fullscreen tkinter window that displays calibration test patterns.""" @@ -174,7 +177,9 @@ def __init__( # Canvas fills the entire window self.canvas = tk.Canvas( - self.root, bg="black", highlightthickness=0, + self.root, + bg="black", + highlightthickness=0, ) self.canvas.pack(fill=tk.BOTH, expand=True) @@ -208,6 +213,7 @@ def _position_on_display(self) -> None: try: # Try to use screeninfo (optional) for multi-monitor support from screeninfo import get_monitors # type: ignore + monitors = get_monitors() if self.display < len(monitors): m = monitors[self.display] @@ -225,12 +231,14 @@ def _position_on_display(self) -> None: def _callback(hMonitor, hdcMonitor, lprcMonitor, dwData): rct = lprcMonitor.contents - monitors_info.append({ - "x": rct.left, - "y": rct.top, - "w": rct.right - rct.left, - "h": rct.bottom - rct.top, - }) + monitors_info.append( + { + "x": rct.left, + "y": rct.top, + "w": rct.right - rct.left, + "h": rct.bottom - rct.top, + } + ) return True MONITORENUMPROC = ctypes.WINFUNCTYPE( @@ -241,7 +249,10 @@ def _callback(hMonitor, hdcMonitor, lprcMonitor, dwData): wintypes.LPARAM, ) ctypes.windll.user32.EnumDisplayMonitors( - None, None, MONITORENUMPROC(_callback), 0, + None, + None, + MONITORENUMPROC(_callback), + 0, ) if self.display < len(monitors_info): @@ -325,6 +336,7 @@ def run(self) -> None: # Public API # --------------------------------------------------------------------------- + def show_patterns(display: int = 0) -> None: """ Open the fullscreen pattern viewer on the specified display. @@ -347,7 +359,8 @@ def show_patterns(display: int = 0) -> None: description="Calibrate Pro - Fullscreen Test Patterns", ) parser.add_argument( - "--display", "-d", + "--display", + "-d", type=int, default=0, help="Display index (0-based, default: 0)", diff --git a/calibrate_pro/platform/__init__.py b/calibrate_pro/platform/__init__.py index 9487fda..f2f003b 100644 --- a/calibrate_pro/platform/__init__.py +++ b/calibrate_pro/platform/__init__.py @@ -43,18 +43,18 @@ def get_platform_backend() -> PlatformBackend: """ if sys.platform == "win32": from calibrate_pro.platform.windows import WindowsBackend + return WindowsBackend() elif sys.platform == "darwin": from calibrate_pro.platform.macos import MacOSBackend + return MacOSBackend() elif sys.platform.startswith("linux"): from calibrate_pro.platform.linux import LinuxBackend + return LinuxBackend() else: - raise RuntimeError( - f"Unsupported platform: {sys.platform}. " - "Calibrate Pro supports win32, darwin, and linux." - ) + raise RuntimeError(f"Unsupported platform: {sys.platform}. Calibrate Pro supports win32, darwin, and linux.") __all__ = ["get_platform_backend"] diff --git a/calibrate_pro/platform/base.py b/calibrate_pro/platform/base.py index 8427863..4ff7b93 100644 --- a/calibrate_pro/platform/base.py +++ b/calibrate_pro/platform/base.py @@ -19,9 +19,10 @@ class DisplayInfo: ``calibrate_pro.panels.detection.DisplayInfo`` but is kept independent so the platform layer has no upward dependency. """ + index: int - name: str # Human-readable name (e.g. "ASUS PG27UCDM") - device_path: str # OS-specific device identifier + name: str # Human-readable name (e.g. "ASUS PG27UCDM") + device_path: str # OS-specific device identifier is_primary: bool width: int height: int diff --git a/calibrate_pro/platform/linux.py b/calibrate_pro/platform/linux.py index 52ab99f..0e08d14 100644 --- a/calibrate_pro/platform/linux.py +++ b/calibrate_pro/platform/linux.py @@ -40,6 +40,7 @@ # Helpers # ===================================================================== + def _run_cmd(cmd: list[str], timeout: int = 10) -> str | None: """Run a shell command and return stdout, or None on failure.""" try: @@ -77,9 +78,9 @@ def _parse_edid_name(edid_bytes: bytes) -> tuple[str, str, str]: # Manufacturer ID: bytes 8-9 (compressed ASCII, 3 chars) try: mfg_raw = (edid_bytes[8] << 8) | edid_bytes[9] - c1 = chr(((mfg_raw >> 10) & 0x1F) + ord('A') - 1) - c2 = chr(((mfg_raw >> 5) & 0x1F) + ord('A') - 1) - c3 = chr((mfg_raw & 0x1F) + ord('A') - 1) + c1 = chr(((mfg_raw >> 10) & 0x1F) + ord("A") - 1) + c2 = chr(((mfg_raw >> 5) & 0x1F) + ord("A") - 1) + c3 = chr((mfg_raw & 0x1F) + ord("A") - 1) manufacturer = f"{c1}{c2}{c3}" except Exception: pass @@ -87,7 +88,7 @@ def _parse_edid_name(edid_bytes: bytes) -> tuple[str, str, str]: # Parse descriptor blocks (bytes 54-125, four 18-byte blocks) for i in range(4): offset = 54 + i * 18 - block = edid_bytes[offset:offset + 18] + block = edid_bytes[offset : offset + 18] if len(block) < 18: break @@ -95,7 +96,7 @@ def _parse_edid_name(edid_bytes: bytes) -> tuple[str, str, str]: if block[0] == 0 and block[1] == 0 and block[2] == 0: tag = block[3] # 0xFC = Monitor name, 0xFF = Serial string - text = block[5:18].split(b'\n')[0].decode('ascii', errors='replace').strip() + text = block[5:18].split(b"\n")[0].decode("ascii", errors="replace").strip() if tag == 0xFC: model = text elif tag == 0xFF: @@ -121,6 +122,7 @@ def _read_drm_edid(card_path: Path) -> bytes | None: # xrandr output parser # ===================================================================== + def _parse_xrandr_output(xrandr_text: str) -> list[dict]: """ Parse xrandr --query output into a list of connected display dicts. @@ -132,11 +134,11 @@ def _parse_xrandr_output(xrandr_text: str) -> list[dict]: # e.g.: "DP-1 connected primary 2560x1440+0+0 ..." # "HDMI-1 connected 1920x1080+2560+0 ..." pattern = re.compile( - r'^(\S+)\s+connected\s+' - r'(primary\s+)?' - r'(\d+)x(\d+)\+(\d+)\+(\d+)' - r'.*?' - r'$', + r"^(\S+)\s+connected\s+" + r"(primary\s+)?" + r"(\d+)x(\d+)\+(\d+)\+(\d+)" + r".*?" + r"$", re.MULTILINE, ) @@ -153,23 +155,25 @@ def _parse_xrandr_output(xrandr_text: str) -> list[dict]: refresh = 60 out_start = match.end() # Find the next output line or end of string - next_output = re.search(r'^\S+\s+(connected|disconnected)', xrandr_text[out_start:], re.MULTILINE) - mode_section = xrandr_text[out_start:out_start + next_output.start() if next_output else len(xrandr_text)] + next_output = re.search(r"^\S+\s+(connected|disconnected)", xrandr_text[out_start:], re.MULTILINE) + mode_section = xrandr_text[out_start : out_start + next_output.start() if next_output else len(xrandr_text)] # Match active mode: " 2560x1440 59.95*+ 143.97 ..." - rate_match = re.search(r'(\d+\.\d+)\*', mode_section) + rate_match = re.search(r"(\d+\.\d+)\*", mode_section) if rate_match: refresh = int(float(rate_match.group(1))) - displays.append({ - 'name': output_name, - 'width': width, - 'height': height, - 'refresh': refresh, - 'primary': is_primary, - 'pos_x': pos_x, - 'pos_y': pos_y, - }) + displays.append( + { + "name": output_name, + "width": width, + "height": height, + "refresh": refresh, + "primary": is_primary, + "pos_x": pos_x, + "pos_y": pos_y, + } + ) return displays @@ -178,6 +182,7 @@ def _parse_xrandr_output(xrandr_text: str) -> list[dict]: # Linux Backend # ===================================================================== + class LinuxBackend(PlatformBackend): """ Linux implementation using xrandr, /sys/class/drm, and colord. @@ -211,10 +216,7 @@ def enumerate_displays(self) -> list[PlatformDisplayInfo]: if displays: return displays - logger.warning( - "No display enumeration method available. " - "Install xrandr or ensure /sys/class/drm/ is readable." - ) + logger.warning("No display enumeration method available. Install xrandr or ensure /sys/class/drm/ is readable.") return [] def _enumerate_xrandr(self) -> list[PlatformDisplayInfo]: @@ -235,37 +237,39 @@ def _enumerate_xrandr(self) -> list[PlatformDisplayInfo]: manufacturer = "" model = "" serial = "" - edid_key = d['name'].lower().replace('-', '') + edid_key = d["name"].lower().replace("-", "") for drm_name, edid_bytes in drm_edid_map.items(): # DRM names like "card0-DP-1" map to xrandr "DP-1" - if edid_key in drm_name.lower().replace('-', ''): + if edid_key in drm_name.lower().replace("-", ""): manufacturer, model, serial = _parse_edid_name(edid_bytes) break - display_name = model or d['name'] + display_name = model or d["name"] if manufacturer and manufacturer not in display_name: display_name = f"{manufacturer} {display_name}" # Get current ICC profile via colord - icc_profile = self._get_colord_profile(d['name']) - - results.append(PlatformDisplayInfo( - index=idx, - name=display_name, - device_path=d['name'], - is_primary=d['primary'], - width=d['width'], - height=d['height'], - refresh_rate=d['refresh'], - bit_depth=8, - position_x=d['pos_x'], - position_y=d['pos_y'], - manufacturer=manufacturer, - model=model, - serial=serial, - current_icc_profile=icc_profile, - )) + icc_profile = self._get_colord_profile(d["name"]) + + results.append( + PlatformDisplayInfo( + index=idx, + name=display_name, + device_path=d["name"], + is_primary=d["primary"], + width=d["width"], + height=d["height"], + refresh_rate=d["refresh"], + bit_depth=8, + position_x=d["pos_x"], + position_y=d["pos_y"], + manufacturer=manufacturer, + model=model, + serial=serial, + current_icc_profile=icc_profile, + ) + ) return results @@ -314,27 +318,29 @@ def _enumerate_drm(self) -> list[PlatformDisplayInfo]: modes_file = card_dir / "modes" try: if modes_file.exists(): - first_mode = modes_file.read_text().strip().split('\n')[0] - mode_match = re.match(r'(\d+)x(\d+)', first_mode) + first_mode = modes_file.read_text().strip().split("\n")[0] + mode_match = re.match(r"(\d+)x(\d+)", first_mode) if mode_match: width = int(mode_match.group(1)) height = int(mode_match.group(2)) except (PermissionError, OSError): pass - results.append(PlatformDisplayInfo( - index=idx, - name=display_name, - device_path=output_name, - is_primary=(idx == 0), # First connected = primary heuristic - width=width, - height=height, - refresh_rate=refresh, - bit_depth=8, - manufacturer=manufacturer, - model=model, - serial=serial, - )) + results.append( + PlatformDisplayInfo( + index=idx, + name=display_name, + device_path=output_name, + is_primary=(idx == 0), # First connected = primary heuristic + width=width, + height=height, + refresh_rate=refresh, + bit_depth=8, + manufacturer=manufacturer, + model=model, + serial=serial, + ) + ) idx += 1 except (PermissionError, OSError) as e: @@ -414,6 +420,7 @@ def _apply_gamma_xlib( # Interpolate our 256-entry tables to match the driver's gamma size import numpy as np + r_arr = np.interp( np.linspace(0, 255, gamma_size), np.arange(256), @@ -466,6 +473,7 @@ def _estimate_gamma(lut: list[int]) -> float: if mid <= 0.0 or mid >= 1.0: return 1.0 import math + # For gamma encoding: output = input^gamma # At input=0.5: output = 0.5^gamma # So gamma = log(output) / log(0.5) @@ -475,15 +483,23 @@ def _estimate_gamma(lut: list[int]) -> float: g_gamma = _estimate_gamma(green) b_gamma = _estimate_gamma(blue) - result = _run_cmd([ - "xrandr", "--output", output_name, - "--gamma", f"{r_gamma:.3f}:{g_gamma:.3f}:{b_gamma:.3f}", - ]) + result = _run_cmd( + [ + "xrandr", + "--output", + output_name, + "--gamma", + f"{r_gamma:.3f}:{g_gamma:.3f}:{b_gamma:.3f}", + ] + ) if result is not None: logger.info( "Gamma approximation applied via xrandr --gamma %.3f:%.3f:%.3f on %s", - r_gamma, g_gamma, b_gamma, output_name, + r_gamma, + g_gamma, + b_gamma, + output_name, ) return True @@ -591,7 +607,7 @@ def _get_xrandr_outputs(self) -> list[str]: return [] parsed = _parse_xrandr_output(output) - return [d['name'] for d in parsed] + return [d["name"] for d in parsed] def _get_colord_profile(self, output_name: str) -> str | None: """Get the active ICC profile path for an xrandr output via colord.""" @@ -616,9 +632,14 @@ def _get_colord_profile(self, output_name: str) -> str | None: def _find_colord_device(self, output_name: str) -> str | None: """Find the colord device object path for an xrandr output.""" - output = _run_cmd([ - "colormgr", "find-device-by-property", "OutputName", output_name, - ]) + output = _run_cmd( + [ + "colormgr", + "find-device-by-property", + "OutputName", + output_name, + ] + ) if not output: return None @@ -674,6 +695,7 @@ def _register_colord_profile(self, profile_path: str, display_index: int): # Linux Startup Persistence (systemd user unit / XDG autostart) # ============================================================================= + def enable_linux_startup(silent: bool = True) -> bool: """Register Calibrate Pro as an XDG autostart entry.""" import sys @@ -682,7 +704,7 @@ def enable_linux_startup(silent: bool = True) -> bool: autostart_dir.mkdir(parents=True, exist_ok=True) desktop_path = autostart_dir / "calibrate-pro.desktop" - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): exec_line = str(Path(sys.executable)) else: args = f"{sys.executable} -m calibrate_pro.startup.calibration_loader start-service" diff --git a/calibrate_pro/platform/macos.py b/calibrate_pro/platform/macos.py index 7b125f4..48eaf7e 100644 --- a/calibrate_pro/platform/macos.py +++ b/calibrate_pro/platform/macos.py @@ -39,6 +39,7 @@ def _have_quartz() -> bool: """Check if Quartz (CoreGraphics) bindings are available.""" try: import Quartz # noqa: F401 + return True except ImportError: return False @@ -60,16 +61,11 @@ def enumerate_displays(self) -> list[PlatformDisplayInfo]: try: import Quartz except ImportError: - logger.error( - "pyobjc-framework-Quartz not installed. " - "Run: pip install pyobjc-framework-Quartz" - ) + logger.error("pyobjc-framework-Quartz not installed. Run: pip install pyobjc-framework-Quartz") return [] max_displays = 16 - (err, display_ids, count) = Quartz.CGGetActiveDisplayList( - max_displays, None, None - ) + (err, display_ids, count) = Quartz.CGGetActiveDisplayList(max_displays, None, None) if err != 0: logger.error("CGGetActiveDisplayList failed: error %d", err) return [] @@ -108,22 +104,24 @@ def enumerate_displays(self) -> list[PlatformDisplayInfo]: # Current ICC profile icc_path = self._get_colorsync_profile_path(did) - results.append(PlatformDisplayInfo( - index=i, - name=name, - device_path=str(did), - is_primary=(did == main_display), - width=width, - height=height, - refresh_rate=int(refresh), - bit_depth=Quartz.CGDisplayBitsPerPixel(did) if hasattr(Quartz, 'CGDisplayBitsPerPixel') else 8, - position_x=pos_x, - position_y=pos_y, - manufacturer=manufacturer, - model=model, - serial=serial, - current_icc_profile=icc_path, - )) + results.append( + PlatformDisplayInfo( + index=i, + name=name, + device_path=str(did), + is_primary=(did == main_display), + width=width, + height=height, + refresh_rate=int(refresh), + bit_depth=Quartz.CGDisplayBitsPerPixel(did) if hasattr(Quartz, "CGDisplayBitsPerPixel") else 8, + position_x=pos_x, + position_y=pos_y, + manufacturer=manufacturer, + model=model, + serial=serial, + current_icc_profile=icc_path, + ) + ) return results @@ -155,9 +153,7 @@ def apply_gamma_ramp( g_table = [g / 65535.0 for g in green] b_table = [b / 65535.0 for b in blue] - err = Quartz.CGSetDisplayTransferByTable( - did, table_size, r_table, g_table, b_table - ) + err = Quartz.CGSetDisplayTransferByTable(did, table_size, r_table, g_table, b_table) if err != 0: logger.error("CGSetDisplayTransferByTable failed: error %d", err) return False @@ -210,7 +206,8 @@ def install_icc_profile( except Exception as e: logger.warning( "Profile copied but could not auto-associate: %s. " - "Set it manually in System Settings > Displays > Color.", e + "Set it manually in System Settings > Displays > Color.", + e, ) return True @@ -230,10 +227,9 @@ def _get_display_id(self, display_index: int) -> int | None: """Get CGDirectDisplayID for a display index.""" try: import Quartz + max_displays = 16 - (err, display_ids, count) = Quartz.CGGetActiveDisplayList( - max_displays, None, None - ) + (err, display_ids, count) = Quartz.CGGetActiveDisplayList(max_displays, None, None) if err == 0 and display_index < count: return display_ids[display_index] except Exception as e: @@ -255,10 +251,18 @@ def _read_edid_info(self, display_id: int) -> tuple: # Map common vendor IDs (PNP ID encoded as big-endian) vendor_map = { - 1128: "Apple", 1262: "Samsung", 2513: "Acer", - 3502: "BenQ", 4098: "HP", 4137: "LG", - 4268: "Dell", 4743: "Lenovo", 5765: "Sony", - 6476: "ViewSonic", 7789: "ASUS", 8478: "Gigabyte", + 1128: "Apple", + 1262: "Samsung", + 2513: "Acer", + 3502: "BenQ", + 4098: "HP", + 4137: "LG", + 4268: "Dell", + 4743: "Lenovo", + 5765: "Sony", + 6476: "ViewSonic", + 7789: "ASUS", + 8478: "Gigabyte", 1189: "EIZO", } manufacturer = vendor_map.get(vendor_id, "") @@ -268,9 +272,7 @@ def _read_edid_info(self, display_id: int) -> tuple: # CGDisplayIOServicePort is deprecated but still works on macOS < 14 service = Quartz.CGDisplayIOServicePort(display_id) if service: - info = Quartz.IODisplayCreateInfoDictionary( - service, Quartz.kIODisplayOnlyPreferredName - ) + info = Quartz.IODisplayCreateInfoDictionary(service, Quartz.kIODisplayOnlyPreferredName) if info: names = info.get("DisplayProductName", {}) if names: @@ -293,6 +295,7 @@ def _get_colorsync_profile_path(self, display_id: int) -> str | None: """Get the current ColorSync profile path for a display.""" try: import Quartz + # CGDisplayCopyColorSpace returns a CGColorSpaceRef # We can get the ICC profile data from it colorspace = Quartz.CGDisplayCopyColorSpace(display_id) @@ -333,12 +336,11 @@ def _associate_profile_with_display(self, profile_path: Path, display_index: int return profile_url = NSURL.fileURLWithPath_(str(profile_path)) - profile_info = NSDictionary.dictionaryWithObject_forKey_( - profile_url, kColorSyncDeviceDefaultProfileID - ) + profile_info = NSDictionary.dictionaryWithObject_forKey_(profile_url, kColorSyncDeviceDefaultProfileID) # Create UUID from display ID import uuid + display_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"display-{did}")) ColorSyncDeviceSetCustomProfiles( @@ -358,6 +360,7 @@ def _associate_profile_with_display(self, profile_path: Path, display_index: int # macOS Startup Persistence (launchd) # ============================================================================= + def enable_macos_startup(silent: bool = True) -> bool: """Register Calibrate Pro as a macOS login item via launchd plist.""" import plistlib @@ -367,11 +370,10 @@ def enable_macos_startup(silent: bool = True) -> bool: plist_dir.mkdir(parents=True, exist_ok=True) plist_path = plist_dir / "com.quantauniverse.calibratepro.plist" - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): program = [str(Path(sys.executable))] else: - program = [sys.executable, "-m", "calibrate_pro.startup.calibration_loader", - "start-service"] + program = [sys.executable, "-m", "calibrate_pro.startup.calibration_loader", "start-service"] if silent: program.append("--silent") diff --git a/calibrate_pro/plugins/manager.py b/calibrate_pro/plugins/manager.py index e350c98..37c16dd 100644 --- a/calibrate_pro/plugins/manager.py +++ b/calibrate_pro/plugins/manager.py @@ -39,6 +39,7 @@ def register(manager): @dataclass class PluginInfo: """Metadata describing a discovered plugin.""" + name: str version: str author: str @@ -238,6 +239,7 @@ def _discover_entry_points(self) -> None: """Discover plugins registered via setuptools entry_points.""" try: from importlib.metadata import entry_points + eps = entry_points(group="calibrate_pro.plugins") for ep in eps: @@ -340,6 +342,7 @@ def _load_file(file_path: str, module_name: str): # CLI helper # --------------------------------------------------------------------------- + def print_discovered_plugins(plugin_dirs: list[str] | None = None) -> None: """ Print discovered plugins to stdout. diff --git a/calibrate_pro/profiles/__init__.py b/calibrate_pro/profiles/__init__.py index 99328a2..4667104 100644 --- a/calibrate_pro/profiles/__init__.py +++ b/calibrate_pro/profiles/__init__.py @@ -166,7 +166,6 @@ # Helpers "create_calibration_profile", "create_lut_profile", - # ------------------------------------------------------------------------- # VCGT # ------------------------------------------------------------------------- @@ -184,7 +183,6 @@ "generate_correction_vcgt", "generate_rgb_correction_vcgt", "generate_whitepoint_vcgt", - # ------------------------------------------------------------------------- # MHC2 (Windows HDR) # ------------------------------------------------------------------------- @@ -209,7 +207,6 @@ "install_mhc2_profile", "get_recommended_sdr_white", "calculate_hdr_headroom", - # ------------------------------------------------------------------------- # Profile Installation # ------------------------------------------------------------------------- diff --git a/calibrate_pro/profiles/icc_v4.py b/calibrate_pro/profiles/icc_v4.py index 7e12df2..c4f4438 100644 --- a/calibrate_pro/profiles/icc_v4.py +++ b/calibrate_pro/profiles/icc_v4.py @@ -25,48 +25,53 @@ # ============================================================================= # Profile signatures -ICC_MAGIC = b'acsp' +ICC_MAGIC = b"acsp" ICC_VERSION_4_4 = 0x04400000 + # Profile/Device class signatures class ProfileClass(IntEnum): """ICC profile classes.""" - INPUT = 0x73636E72 # 'scnr' - Scanner/Camera - DISPLAY = 0x6D6E7472 # 'mntr' - Display - OUTPUT = 0x70727472 # 'prtr' - Printer - LINK = 0x6C696E6B # 'link' - Device Link - SPACE = 0x73706163 # 'spac' - Color Space - ABSTRACT = 0x61627374 # 'abst' - Abstract + + INPUT = 0x73636E72 # 'scnr' - Scanner/Camera + DISPLAY = 0x6D6E7472 # 'mntr' - Display + OUTPUT = 0x70727472 # 'prtr' - Printer + LINK = 0x6C696E6B # 'link' - Device Link + SPACE = 0x73706163 # 'spac' - Color Space + ABSTRACT = 0x61627374 # 'abst' - Abstract NAMED_COLOR = 0x6E6D636C # 'nmcl' - Named Color # Color space signatures class ColorSpace(IntEnum): """ICC color space signatures.""" - XYZ = 0x58595A20 # 'XYZ ' - LAB = 0x4C616220 # 'Lab ' - LUV = 0x4C757620 # 'Luv ' - YCBCR = 0x59436272 # 'YCbr' - YXY = 0x59787920 # 'Yxy ' - RGB = 0x52474220 # 'RGB ' - GRAY = 0x47524159 # 'GRAY' - HSV = 0x48535620 # 'HSV ' - HLS = 0x484C5320 # 'HLS ' - CMYK = 0x434D594B # 'CMYK' + + XYZ = 0x58595A20 # 'XYZ ' + LAB = 0x4C616220 # 'Lab ' + LUV = 0x4C757620 # 'Luv ' + YCBCR = 0x59436272 # 'YCbr' + YXY = 0x59787920 # 'Yxy ' + RGB = 0x52474220 # 'RGB ' + GRAY = 0x47524159 # 'GRAY' + HSV = 0x48535620 # 'HSV ' + HLS = 0x484C5320 # 'HLS ' + CMYK = 0x434D594B # 'CMYK' # Platform signatures class Platform(IntEnum): """Platform signatures.""" - APPLE = 0x4150504C # 'APPL' - MICROSOFT = 0x4D534654 # 'MSFT' + + APPLE = 0x4150504C # 'APPL' + MICROSOFT = 0x4D534654 # 'MSFT' SILICON_GRAPHICS = 0x53474920 # 'SGI ' - SUN = 0x53554E57 # 'SUNW' + SUN = 0x53554E57 # 'SUNW' # Rendering intent class RenderingIntent(IntEnum): """Rendering intent values.""" + PERCEPTUAL = 0 RELATIVE_COLORIMETRIC = 1 SATURATION = 2 @@ -76,82 +81,82 @@ class RenderingIntent(IntEnum): # Tag signatures class TagSignature: """Common ICC tag signatures.""" + # Required tags - PROFILE_DESCRIPTION = b'desc' - COPYRIGHT = b'cprt' - MEDIA_WHITE_POINT = b'wtpt' - CHROMATIC_ADAPTATION = b'chad' + PROFILE_DESCRIPTION = b"desc" + COPYRIGHT = b"cprt" + MEDIA_WHITE_POINT = b"wtpt" + CHROMATIC_ADAPTATION = b"chad" # Colorimetric tags - RED_MATRIX_COLUMN = b'rXYZ' - GREEN_MATRIX_COLUMN = b'gXYZ' - BLUE_MATRIX_COLUMN = b'bXYZ' - RED_TRC = b'rTRC' - GREEN_TRC = b'gTRC' - BLUE_TRC = b'bTRC' + RED_MATRIX_COLUMN = b"rXYZ" + GREEN_MATRIX_COLUMN = b"gXYZ" + BLUE_MATRIX_COLUMN = b"bXYZ" + RED_TRC = b"rTRC" + GREEN_TRC = b"gTRC" + BLUE_TRC = b"bTRC" # LUT tags - A2B0 = b'A2B0' # Perceptual - A2B1 = b'A2B1' # Relative colorimetric - A2B2 = b'A2B2' # Saturation - B2A0 = b'B2A0' - B2A1 = b'B2A1' - B2A2 = b'B2A2' + A2B0 = b"A2B0" # Perceptual + A2B1 = b"A2B1" # Relative colorimetric + A2B2 = b"A2B2" # Saturation + B2A0 = b"B2A0" + B2A1 = b"B2A1" + B2A2 = b"B2A2" # Gamut tag - GAMUT = b'gamt' + GAMUT = b"gamt" # Measurement tags - MEASUREMENT = b'meas' + MEASUREMENT = b"meas" # Video Card Gamma Table - VCGT = b'vcgt' + VCGT = b"vcgt" # Windows HDR - MHC2 = b'MHC2' + MHC2 = b"MHC2" # Viewing conditions - VIEWING_CONDITIONS = b'view' - VIEWING_COND_DESC = b'vued' + VIEWING_CONDITIONS = b"view" + VIEWING_COND_DESC = b"vued" # Technology - TECHNOLOGY = b'tech' + TECHNOLOGY = b"tech" # Calibration - CALIBRATION_DATE_TIME = b'calt' - CHAR_TARGET = b'targ' + CALIBRATION_DATE_TIME = b"calt" + CHAR_TARGET = b"targ" # Multi-localized strings - DEVICE_MFG_DESC = b'dmnd' - DEVICE_MODEL_DESC = b'dmdd' + DEVICE_MFG_DESC = b"dmnd" + DEVICE_MODEL_DESC = b"dmdd" # ============================================================================= # Data Structures # ============================================================================= + @dataclass class XYZNumber: """CIE XYZ values in ICC format (s15Fixed16).""" + X: float = 0.0 Y: float = 0.0 Z: float = 0.0 def to_bytes(self) -> bytes: """Convert to ICC s15Fixed16 format.""" + def to_s15f16(val: float) -> int: return int(round(val * 65536)) & 0xFFFFFFFF - return struct.pack('>III', - to_s15f16(self.X), - to_s15f16(self.Y), - to_s15f16(self.Z) - ) + return struct.pack(">III", to_s15f16(self.X), to_s15f16(self.Y), to_s15f16(self.Z)) @classmethod - def from_bytes(cls, data: bytes) -> 'XYZNumber': + def from_bytes(cls, data: bytes) -> "XYZNumber": """Parse from ICC format.""" - x, y, z = struct.unpack('>III', data[:12]) + x, y, z = struct.unpack(">III", data[:12]) def from_s15f16(val: int) -> float: if val >= 0x80000000: @@ -164,6 +169,7 @@ def from_s15f16(val: int) -> float: @dataclass class DateTimeNumber: """ICC dateTimeNumber.""" + year: int = 2024 month: int = 1 day: int = 1 @@ -172,13 +178,10 @@ class DateTimeNumber: second: int = 0 def to_bytes(self) -> bytes: - return struct.pack('>HHHHHH', - self.year, self.month, self.day, - self.hour, self.minute, self.second - ) + return struct.pack(">HHHHHH", self.year, self.month, self.day, self.hour, self.minute, self.second) @classmethod - def now(cls) -> 'DateTimeNumber': + def now(cls) -> "DateTimeNumber": now = datetime.now() return cls(now.year, now.month, now.day, now.hour, now.minute, now.second) @@ -186,42 +189,43 @@ def now(cls) -> 'DateTimeNumber': @dataclass class MultiLocalizedString: """Multi-localized Unicode string (mluc type).""" + strings: dict[tuple[str, str], str] = field(default_factory=dict) def __post_init__(self): if not self.strings: - self.strings = {('en', 'US'): 'Default'} + self.strings = {("en", "US"): "Default"} - def set_string(self, text: str, language: str = 'en', country: str = 'US'): + def set_string(self, text: str, language: str = "en", country: str = "US"): """Set string for a locale.""" self.strings[(language, country)] = text def to_bytes(self) -> bytes: """Serialize to ICC mluc format.""" # Tag type signature - data = b'mluc' + b'\x00\x00\x00\x00' # Type + reserved + data = b"mluc" + b"\x00\x00\x00\x00" # Type + reserved # Number of records num_records = len(self.strings) - data += struct.pack('>I', num_records) + data += struct.pack(">I", num_records) # Record size (always 12) - data += struct.pack('>I', 12) + data += struct.pack(">I", 12) # Calculate string offsets header_size = 16 + 12 * num_records - string_data = b'' + string_data = b"" records = [] for (lang, country), text in self.strings.items(): # Encode as UTF-16BE - encoded = text.encode('utf-16-be') + encoded = text.encode("utf-16-be") offset = header_size + len(string_data) length = len(encoded) # Language and country codes - lang_code = lang.encode('ascii').ljust(2, b'\x00')[:2] - country_code = country.encode('ascii').ljust(2, b'\x00')[:2] + lang_code = lang.encode("ascii").ljust(2, b"\x00")[:2] + country_code = country.encode("ascii").ljust(2, b"\x00")[:2] records.append((lang_code, country_code, offset, length)) string_data += encoded @@ -229,14 +233,14 @@ def to_bytes(self) -> bytes: # Write records for lang_code, country_code, offset, length in records: data += lang_code + country_code - data += struct.pack('>II', length, offset) + data += struct.pack(">II", length, offset) # Write strings data += string_data # Pad to 4-byte boundary while len(data) % 4 != 0: - data += b'\x00' + data += b"\x00" return data @@ -244,6 +248,7 @@ def to_bytes(self) -> bytes: @dataclass class ParametricCurve: """Parametric curve (para type).""" + function_type: int = 0 gamma: float = 2.2 a: float = 1.0 @@ -254,38 +259,29 @@ class ParametricCurve: f: float = 0.0 @classmethod - def srgb(cls) -> 'ParametricCurve': + def srgb(cls) -> "ParametricCurve": """Create sRGB transfer function.""" - return cls( - function_type=3, - gamma=2.4, - a=1.0 / 1.055, - b=0.055 / 1.055, - c=1.0 / 12.92, - d=0.04045, - e=0.0, - f=0.0 - ) + return cls(function_type=3, gamma=2.4, a=1.0 / 1.055, b=0.055 / 1.055, c=1.0 / 12.92, d=0.04045, e=0.0, f=0.0) @classmethod - def gamma(cls, gamma: float) -> 'ParametricCurve': # noqa: F811 + def gamma(cls, gamma: float) -> "ParametricCurve": # noqa: F811 """Create simple gamma curve.""" return cls(function_type=0, gamma=gamma) @classmethod - def bt1886(cls, gamma: float = 2.4) -> 'ParametricCurve': + def bt1886(cls, gamma: float = 2.4) -> "ParametricCurve": """Create BT.1886 transfer function.""" return cls(function_type=0, gamma=gamma) def to_bytes(self) -> bytes: """Serialize to ICC para format.""" - data = b'para' + b'\x00\x00\x00\x00' # Type + reserved - data += struct.pack('>H', self.function_type) - data += b'\x00\x00' # Reserved + data = b"para" + b"\x00\x00\x00\x00" # Type + reserved + data += struct.pack(">H", self.function_type) + data += b"\x00\x00" # Reserved def to_s15f16(val: float) -> bytes: ival = int(round(val * 65536)) & 0xFFFFFFFF - return struct.pack('>I', ival) + return struct.pack(">I", ival) # Parameters based on function type data += to_s15f16(self.gamma) @@ -310,50 +306,51 @@ def to_s15f16(val: float) -> bytes: @dataclass class CurveData: """TRC curve data (curv type).""" + values: np.ndarray = field(default_factory=lambda: np.array([])) gamma: float | None = None # If set, use gamma instead of table @classmethod - def from_gamma(cls, gamma: float) -> 'CurveData': + def from_gamma(cls, gamma: float) -> "CurveData": """Create gamma curve.""" return cls(gamma=gamma) @classmethod - def from_table(cls, values: np.ndarray) -> 'CurveData': + def from_table(cls, values: np.ndarray) -> "CurveData": """Create from table values.""" return cls(values=values) @classmethod - def identity(cls) -> 'CurveData': + def identity(cls) -> "CurveData": """Create identity curve (gamma 1.0).""" return cls(gamma=1.0) def to_bytes(self) -> bytes: """Serialize to ICC curv format.""" - data = b'curv' + b'\x00\x00\x00\x00' # Type + reserved + data = b"curv" + b"\x00\x00\x00\x00" # Type + reserved if self.gamma is not None: # Gamma value encoded as u8Fixed8 - data += struct.pack('>I', 1) # Count = 1 + data += struct.pack(">I", 1) # Count = 1 gamma_u8f8 = int(round(self.gamma * 256)) & 0xFFFF - data += struct.pack('>H', gamma_u8f8) - data += b'\x00\x00' # Pad + data += struct.pack(">H", gamma_u8f8) + data += b"\x00\x00" # Pad elif len(self.values) == 0: # Identity - data += struct.pack('>I', 0) + data += struct.pack(">I", 0) else: # Table count = len(self.values) - data += struct.pack('>I', count) + data += struct.pack(">I", count) # Convert to 16-bit values_u16 = (np.clip(self.values, 0, 1) * 65535).astype(np.uint16) for v in values_u16: - data += struct.pack('>H', v) + data += struct.pack(">H", v) # Pad to 4-byte boundary if count % 2 != 0: - data += b'\x00\x00' + data += b"\x00\x00" return data @@ -361,6 +358,7 @@ def to_bytes(self) -> bytes: @dataclass class MeasurementData: """Measurement data for profile.""" + observer: int = 1 # 1 = CIE 1931 backing: XYZNumber = field(default_factory=lambda: XYZNumber(0, 0, 0)) geometry: int = 0 # 0 = unknown @@ -369,16 +367,16 @@ class MeasurementData: def to_bytes(self) -> bytes: """Serialize to ICC meas format.""" - data = b'meas' + b'\x00\x00\x00\x00' - data += struct.pack('>I', self.observer) + data = b"meas" + b"\x00\x00\x00\x00" + data += struct.pack(">I", self.observer) data += self.backing.to_bytes() - data += struct.pack('>I', self.geometry) + data += struct.pack(">I", self.geometry) # Flare as u16Fixed16 flare_u16f16 = int(round(self.flare * 65536)) & 0xFFFFFFFF - data += struct.pack('>I', flare_u16f16) + data += struct.pack(">I", flare_u16f16) - data += struct.pack('>I', self.illuminant) + data += struct.pack(">I", self.illuminant) return data @@ -387,9 +385,11 @@ def to_bytes(self) -> bytes: # 3D LUT Support (mft2/mAB/mBA types) # ============================================================================= + @dataclass class CLUT: """Color Look-Up Table for A2B/B2A tags.""" + data: np.ndarray # Shape: (size, size, size, channels) input_channels: int = 3 output_channels: int = 3 @@ -397,38 +397,38 @@ class CLUT: def to_bytes_mft2(self) -> bytes: """Serialize to mft2 format (16-bit precision).""" - data = b'mft2' + b'\x00\x00\x00\x00' + data = b"mft2" + b"\x00\x00\x00\x00" # Channels and grid - data += struct.pack('>BBB', self.input_channels, self.output_channels, self.grid_points) - data += b'\x00' # Reserved + data += struct.pack(">BBB", self.input_channels, self.output_channels, self.grid_points) + data += b"\x00" # Reserved # Identity matrix (for RGB to RGB) identity = [1, 0, 0, 0, 1, 0, 0, 0, 1] for val in identity: s15f16 = int(round(val * 65536)) & 0xFFFFFFFF - data += struct.pack('>I', s15f16) + data += struct.pack(">I", s15f16) # Input table entries (256 per channel) - data += struct.pack('>H', 256) + data += struct.pack(">H", 256) # Output table entries - data += struct.pack('>H', 256) + data += struct.pack(">H", 256) # Input tables (identity) for _ in range(self.input_channels): for i in range(256): - data += struct.pack('>H', i * 257) # 0-65535 + data += struct.pack(">H", i * 257) # 0-65535 # CLUT data clut_flat = self.data.flatten() clut_u16 = (np.clip(clut_flat, 0, 1) * 65535).astype(np.uint16) for v in clut_u16: - data += struct.pack('>H', v) + data += struct.pack(">H", v) # Output tables (identity) for _ in range(self.output_channels): for i in range(256): - data += struct.pack('>H', i * 257) + data += struct.pack(">H", i * 257) return data @@ -437,6 +437,7 @@ def to_bytes_mft2(self) -> bytes: # ICC Profile Class # ============================================================================= + @dataclass class ICCProfile: """ @@ -444,6 +445,7 @@ class ICCProfile: Supports display, input, and output profiles with full tag support. """ + # Header fields profile_class: ProfileClass = ProfileClass.DISPLAY color_space: ColorSpace = ColorSpace.RGB @@ -499,21 +501,21 @@ def _build_header(self, profile_size: int, tag_count: int) -> bytes: header = bytearray(128) # Profile size (offset 0) - struct.pack_into('>I', header, 0, profile_size) + struct.pack_into(">I", header, 0, profile_size) # Preferred CMM (offset 4) - leave as 0 # Version (offset 8) - v4.4 - struct.pack_into('>I', header, 8, ICC_VERSION_4_4) + struct.pack_into(">I", header, 8, ICC_VERSION_4_4) # Profile/Device class (offset 12) - struct.pack_into('>I', header, 12, self.profile_class) + struct.pack_into(">I", header, 12, self.profile_class) # Color space (offset 16) - struct.pack_into('>I', header, 16, self.color_space) + struct.pack_into(">I", header, 16, self.color_space) # PCS (offset 20) - struct.pack_into('>I', header, 20, self.pcs) + struct.pack_into(">I", header, 20, self.pcs) # Date/time (offset 24) header[24:36] = self.creation_date.to_bytes() @@ -522,26 +524,26 @@ def _build_header(self, profile_size: int, tag_count: int) -> bytes: header[36:40] = ICC_MAGIC # Platform (offset 40) - struct.pack_into('>I', header, 40, Platform.MICROSOFT) + struct.pack_into(">I", header, 40, Platform.MICROSOFT) # Flags (offset 44) - embedded, use with embedded data only - struct.pack_into('>I', header, 44, 0x00000001) + struct.pack_into(">I", header, 44, 0x00000001) # Device manufacturer (offset 48) # Device model (offset 52) # Device attributes (offset 56) - struct.pack_into('>Q', header, 56, 0) + struct.pack_into(">Q", header, 56, 0) # Rendering intent (offset 64) - struct.pack_into('>I', header, 64, self.rendering_intent) + struct.pack_into(">I", header, 64, self.rendering_intent) # PCS illuminant (offset 68) - D50 d50 = XYZNumber(0.9642, 1.0, 0.8249) header[68:80] = d50.to_bytes() # Creator signature (offset 80) - header[80:84] = b'CALP' # Calibrate Pro + header[80:84] = b"CALP" # Calibrate Pro # Profile ID (offset 84) - filled later with MD5 @@ -551,36 +553,32 @@ def _build_header(self, profile_size: int, tag_count: int) -> bytes: def _build_tag_table(self, tags: list[tuple[bytes, int, int]]) -> bytes: """Build tag table.""" - data = struct.pack('>I', len(tags)) + data = struct.pack(">I", len(tags)) for sig, offset, size in tags: data += sig - data += struct.pack('>II', offset, size) + data += struct.pack(">II", offset, size) return data def _build_xyz_tag(self, xyz: XYZNumber) -> bytes: """Build XYZ tag.""" - return b'XYZ ' + b'\x00\x00\x00\x00' + xyz.to_bytes() + return b"XYZ " + b"\x00\x00\x00\x00" + xyz.to_bytes() def _build_chad_tag(self) -> bytes: """Build chromatic adaptation tag.""" - data = b'sf32' + b'\x00\x00\x00\x00' + data = b"sf32" + b"\x00\x00\x00\x00" if self.chromatic_adaptation is not None: matrix = self.chromatic_adaptation else: # Bradford matrix for D65 to D50 - matrix = np.array([ - [1.0479, 0.0229, -0.0502], - [0.0296, 0.9904, -0.0171], - [-0.0092, 0.0151, 0.7519] - ]) + matrix = np.array([[1.0479, 0.0229, -0.0502], [0.0296, 0.9904, -0.0171], [-0.0092, 0.0151, 0.7519]]) for row in matrix: for val in row: s15f16 = int(round(val * 65536)) & 0xFFFFFFFF - data += struct.pack('>I', s15f16) + data += struct.pack(">I", s15f16) return data @@ -601,36 +599,36 @@ def _build_vcgt_tag(self) -> bytes | None: if self.vcgt_red is None: return None - data = b'vcgt' + b'\x00\x00\x00\x00' + data = b"vcgt" + b"\x00\x00\x00\x00" # Type 0 = table - data += struct.pack('>I', 0) + data += struct.pack(">I", 0) # Number of channels - data += struct.pack('>H', 3) + data += struct.pack(">H", 3) # Entries per channel num_entries = len(self.vcgt_red) - data += struct.pack('>H', num_entries) + data += struct.pack(">H", num_entries) # Entry size (2 = 16-bit) - data += struct.pack('>H', 2) + data += struct.pack(">H", 2) # Red channel for v in self.vcgt_red: - data += struct.pack('>H', int(np.clip(v, 0, 1) * 65535)) + data += struct.pack(">H", int(np.clip(v, 0, 1) * 65535)) # Green channel for v in self.vcgt_green: - data += struct.pack('>H', int(np.clip(v, 0, 1) * 65535)) + data += struct.pack(">H", int(np.clip(v, 0, 1) * 65535)) # Blue channel for v in self.vcgt_blue: - data += struct.pack('>H', int(np.clip(v, 0, 1) * 65535)) + data += struct.pack(">H", int(np.clip(v, 0, 1) * 65535)) # Pad to 4-byte boundary while len(data) % 4 != 0: - data += b'\x00' + data += b"\x00" return data @@ -709,12 +707,12 @@ def build(self) -> bytes: current_offset = header_size + tag_table_size tag_entries = [] - tag_data_block = b'' + tag_data_block = b"" for sig, data in tags_data: # Align to 4 bytes padding = (4 - (len(tag_data_block) % 4)) % 4 - tag_data_block += b'\x00' * padding + tag_data_block += b"\x00" * padding offset = current_offset + len(tag_data_block) tag_entries.append((sig, offset, len(data))) @@ -722,7 +720,7 @@ def build(self) -> bytes: # Final padding padding = (4 - (len(tag_data_block) % 4)) % 4 - tag_data_block += b'\x00' * padding + tag_data_block += b"\x00" * padding # Total size profile_size = header_size + tag_table_size + len(tag_data_block) @@ -734,8 +732,8 @@ def build(self) -> bytes: # Calculate and embed profile ID (MD5) profile_for_hash = bytearray(profile) - profile_for_hash[44:48] = b'\x00\x00\x00\x00' # Clear flags - profile_for_hash[84:100] = b'\x00' * 16 # Clear profile ID + profile_for_hash[44:48] = b"\x00\x00\x00\x00" # Clear flags + profile_for_hash[84:100] = b"\x00" * 16 # Clear profile ID md5_hash = hashlib.md5(bytes(profile_for_hash)).digest() @@ -751,7 +749,7 @@ def save(self, path: str | Path): path.write_bytes(data) @classmethod - def create_srgb(cls) -> 'ICCProfile': + def create_srgb(cls) -> "ICCProfile": """Create standard sRGB profile.""" return cls( description="sRGB IEC61966-2.1", @@ -767,7 +765,7 @@ def create_srgb(cls) -> 'ICCProfile': ) @classmethod - def create_display_p3(cls) -> 'ICCProfile': + def create_display_p3(cls) -> "ICCProfile": """Create Display P3 profile.""" return cls( description="Display P3", @@ -783,7 +781,7 @@ def create_display_p3(cls) -> 'ICCProfile': ) @classmethod - def create_bt2020(cls) -> 'ICCProfile': + def create_bt2020(cls) -> "ICCProfile": """Create BT.2020 profile.""" return cls( description="ITU-R BT.2020", @@ -803,6 +801,7 @@ def create_bt2020(cls) -> 'ICCProfile': # Profile Creation Helpers # ============================================================================= + def create_calibration_profile( red_xyz: tuple[float, float, float], green_xyz: tuple[float, float, float], @@ -812,7 +811,7 @@ def create_calibration_profile( trc_green: np.ndarray, trc_blue: np.ndarray, description: str = "Calibration Profile", - include_vcgt: bool = True + include_vcgt: bool = True, ) -> ICCProfile: """ Create calibration profile from measurements. @@ -850,11 +849,7 @@ def create_calibration_profile( return profile -def create_lut_profile( - lut_data: np.ndarray, - description: str = "3D LUT Profile", - grid_size: int = 17 -) -> ICCProfile: +def create_lut_profile(lut_data: np.ndarray, description: str = "3D LUT Profile", grid_size: int = 17) -> ICCProfile: """ Create profile with embedded 3D LUT. @@ -868,9 +863,6 @@ def create_lut_profile( """ profile = ICCProfile(description=description) - profile.a2b0 = CLUT( - data=lut_data, - grid_points=grid_size - ) + profile.a2b0 = CLUT(data=lut_data, grid_points=grid_size) return profile diff --git a/calibrate_pro/profiles/mhc2.py b/calibrate_pro/profiles/mhc2.py index c99abde..2ed7653 100644 --- a/calibrate_pro/profiles/mhc2.py +++ b/calibrate_pro/profiles/mhc2.py @@ -27,20 +27,20 @@ # ============================================================================= # MHC2 tag signature -MHC2_TAG_SIGNATURE = b'MHC2' +MHC2_TAG_SIGNATURE = b"MHC2" # MHC2 version MHC2_VERSION_1 = 1 MHC2_VERSION_2 = 2 # Default values -DEFAULT_SDR_WHITE_LEVEL = 80.0 # cd/m² (Windows default) -DEFAULT_MIN_LUMINANCE = 0.0 # cd/m² -DEFAULT_MAX_LUMINANCE = 1000.0 # cd/m² +DEFAULT_SDR_WHITE_LEVEL = 80.0 # cd/m² (Windows default) +DEFAULT_MIN_LUMINANCE = 0.0 # cd/m² +DEFAULT_MAX_LUMINANCE = 1000.0 # cd/m² # SDR white level range -SDR_WHITE_MIN = 40.0 # Minimum practical SDR white -SDR_WHITE_MAX = 480.0 # Maximum Windows allows +SDR_WHITE_MIN = 40.0 # Minimum practical SDR white +SDR_WHITE_MAX = 480.0 # Maximum Windows allows # HDR luminance range HDR_MIN_LUMINANCE = 0.0001 @@ -51,6 +51,7 @@ # MHC2 Data Structures # ============================================================================= + @dataclass class MHC2ColorMatrix: """ @@ -59,6 +60,7 @@ class MHC2ColorMatrix: Transforms from source color space to display color space. Typically used for gamut mapping in HDR. """ + matrix: np.ndarray = field(default_factory=lambda: np.eye(3)) def __post_init__(self): @@ -67,7 +69,7 @@ def __post_init__(self): raise ValueError("Matrix must be 3x3") @classmethod - def identity(cls) -> 'MHC2ColorMatrix': + def identity(cls) -> "MHC2ColorMatrix": """Create identity matrix.""" return cls(np.eye(3)) @@ -81,8 +83,8 @@ def from_primaries( dst_red: tuple[float, float], dst_green: tuple[float, float], dst_blue: tuple[float, float], - dst_white: tuple[float, float] - ) -> 'MHC2ColorMatrix': + dst_white: tuple[float, float], + ) -> "MHC2ColorMatrix": """ Create gamut mapping matrix from chromaticity coordinates. @@ -93,17 +95,15 @@ def from_primaries( Returns: MHC2ColorMatrix for gamut conversion """ + def xy_to_XYZ(x: float, y: float) -> np.ndarray: """Convert xy chromaticity to XYZ with Y=1.""" if y == 0: return np.array([0, 0, 0]) - return np.array([x/y, 1.0, (1-x-y)/y]) + return np.array([x / y, 1.0, (1 - x - y) / y]) def primaries_to_matrix( - red: tuple[float, float], - green: tuple[float, float], - blue: tuple[float, float], - white: tuple[float, float] + red: tuple[float, float], green: tuple[float, float], blue: tuple[float, float], white: tuple[float, float] ) -> np.ndarray: """Build RGB to XYZ matrix from primaries.""" # Primary XYZ @@ -115,11 +115,7 @@ def primaries_to_matrix( Xw, Yw, Zw = xy_to_XYZ(*white) # Solve for S (scaling factors) - M = np.array([ - [Xr, Xg, Xb], - [Yr, Yg, Yb], - [Zr, Zg, Zb] - ]) + M = np.array([[Xr, Xg, Xb], [Yr, Yg, Yb], [Zr, Zg, Zb]]) S = np.linalg.solve(M, np.array([Xw, Yw, Zw])) @@ -138,7 +134,7 @@ def primaries_to_matrix( return cls(combined) @classmethod - def bt2020_to_p3(cls) -> 'MHC2ColorMatrix': + def bt2020_to_p3(cls) -> "MHC2ColorMatrix": """Create BT.2020 to Display P3 matrix.""" return cls.from_primaries( src_red=(0.708, 0.292), @@ -148,11 +144,11 @@ def bt2020_to_p3(cls) -> 'MHC2ColorMatrix': dst_red=(0.680, 0.320), dst_green=(0.265, 0.690), dst_blue=(0.150, 0.060), - dst_white=(0.3127, 0.3290) + dst_white=(0.3127, 0.3290), ) @classmethod - def bt2020_to_srgb(cls) -> 'MHC2ColorMatrix': + def bt2020_to_srgb(cls) -> "MHC2ColorMatrix": """Create BT.2020 to sRGB matrix.""" return cls.from_primaries( src_red=(0.708, 0.292), @@ -162,30 +158,30 @@ def bt2020_to_srgb(cls) -> 'MHC2ColorMatrix': dst_red=(0.640, 0.330), dst_green=(0.300, 0.600), dst_blue=(0.150, 0.060), - dst_white=(0.3127, 0.3290) + dst_white=(0.3127, 0.3290), ) def to_bytes(self) -> bytes: """Serialize matrix to MHC2 format (s15Fixed16).""" - data = b'' + data = b"" for row in self.matrix: for val in row: # s15Fixed16 format ival = int(round(val * 65536)) if ival < 0: ival += 0x100000000 - data += struct.pack('>I', ival & 0xFFFFFFFF) + data += struct.pack(">I", ival & 0xFFFFFFFF) return data @classmethod - def from_bytes(cls, data: bytes) -> 'MHC2ColorMatrix': + def from_bytes(cls, data: bytes) -> "MHC2ColorMatrix": """Parse matrix from MHC2 format.""" matrix = np.zeros((3, 3)) offset = 0 for i in range(3): for j in range(3): - val = struct.unpack('>I', data[offset:offset+4])[0] + val = struct.unpack(">I", data[offset : offset + 4])[0] # Convert from s15Fixed16 if val >= 0x80000000: val -= 0x100000000 @@ -202,6 +198,7 @@ class MHC2ToneCurve: Defines the PQ-based tone curve for HDR to display mapping. """ + # Curve defined by control points (up to 256) points: np.ndarray = field(default_factory=lambda: np.linspace(0, 1, 256)) @@ -210,24 +207,20 @@ def __post_init__(self): self.points = np.clip(self.points, 0.0, 1.0) @classmethod - def identity(cls) -> 'MHC2ToneCurve': + def identity(cls) -> "MHC2ToneCurve": """Create identity (linear) curve.""" return cls(np.linspace(0, 1, 256)) @classmethod - def from_gamma(cls, gamma: float, size: int = 256) -> 'MHC2ToneCurve': + def from_gamma(cls, gamma: float, size: int = 256) -> "MHC2ToneCurve": """Create gamma curve.""" x = np.linspace(0, 1, size) return cls(np.power(x, gamma)) @classmethod def from_pq_to_display( - cls, - display_peak: float = 1000.0, - display_black: float = 0.0, - sdr_white: float = 80.0, - size: int = 256 - ) -> 'MHC2ToneCurve': + cls, display_peak: float = 1000.0, display_black: float = 0.0, sdr_white: float = 80.0, size: int = 256 + ) -> "MHC2ToneCurve": """ Create tone curve for PQ to display mapping. @@ -279,26 +272,26 @@ def from_pq_to_display( def to_bytes(self) -> bytes: """Serialize to MHC2 format (16-bit).""" - data = struct.pack('>I', len(self.points)) + data = struct.pack(">I", len(self.points)) for val in self.points: - data += struct.pack('>H', int(val * 65535)) + data += struct.pack(">H", int(val * 65535)) # Pad to 4-byte boundary while len(data) % 4 != 0: - data += b'\x00' + data += b"\x00" return data @classmethod - def from_bytes(cls, data: bytes) -> 'MHC2ToneCurve': + def from_bytes(cls, data: bytes) -> "MHC2ToneCurve": """Parse from MHC2 format.""" - count = struct.unpack('>I', data[:4])[0] + count = struct.unpack(">I", data[:4])[0] points = np.zeros(count) offset = 4 for i in range(count): - points[i] = struct.unpack('>H', data[offset:offset+2])[0] / 65535.0 + points[i] = struct.unpack(">H", data[offset : offset + 2])[0] / 65535.0 offset += 2 return cls(points) @@ -311,13 +304,14 @@ class MHC2Tag: Contains all parameters needed for Windows HDR color management. """ + # Version version: int = MHC2_VERSION_2 # Luminance settings - min_luminance: float = 0.0 # Display black level (cd/m²) - max_luminance: float = 1000.0 # Display peak luminance (cd/m²) - sdr_white_level: float = 80.0 # SDR reference white (cd/m²) + min_luminance: float = 0.0 # Display black level (cd/m²) + max_luminance: float = 1000.0 # Display peak luminance (cd/m²) + sdr_white_level: float = 80.0 # SDR reference white (cd/m²) # Color matrix (3x3) color_matrix: MHC2ColorMatrix = field(default_factory=MHC2ColorMatrix.identity) @@ -351,12 +345,8 @@ def __post_init__(self): @classmethod def for_display( - cls, - peak_luminance: float, - black_level: float = 0.0, - sdr_white: float = 80.0, - color_gamut: str = "sRGB" - ) -> 'MHC2Tag': + cls, peak_luminance: float, black_level: float = 0.0, sdr_white: float = 80.0, color_gamut: str = "sRGB" + ) -> "MHC2Tag": """ Create MHC2 tag for a specific display. @@ -379,9 +369,7 @@ def for_display( # Tone curve for this display curve = MHC2ToneCurve.from_pq_to_display( - display_peak=peak_luminance, - display_black=black_level, - sdr_white=sdr_white + display_peak=peak_luminance, display_black=black_level, sdr_white=sdr_white ) return cls( @@ -398,15 +386,15 @@ def to_bytes(self) -> bytes: """Serialize to ICC tag format.""" # Tag type signature data = MHC2_TAG_SIGNATURE - data += b'\x00\x00\x00\x00' # Reserved + data += b"\x00\x00\x00\x00" # Reserved # Version - data += struct.pack('>I', self.version) + data += struct.pack(">I", self.version) # Luminance values (as s15Fixed16) def to_s15f16(val: float) -> bytes: ival = int(round(val * 65536)) & 0xFFFFFFFF - return struct.pack('>I', ival) + return struct.pack(">I", ival) data += to_s15f16(self.min_luminance) data += to_s15f16(self.max_luminance) @@ -419,10 +407,10 @@ def to_s15f16(val: float) -> bytes: if self.use_color_matrix: flags |= 0x02 - data += struct.pack('>I', flags) + data += struct.pack(">I", flags) # Gamut mapping mode - data += struct.pack('>I', self.gamut_mapping_mode) + data += struct.pack(">I", self.gamut_mapping_mode) # Color matrix data += self.color_matrix.to_bytes() @@ -435,7 +423,7 @@ def to_s15f16(val: float) -> bytes: return data @classmethod - def from_bytes(cls, data: bytes) -> Optional['MHC2Tag']: + def from_bytes(cls, data: bytes) -> Optional["MHC2Tag"]: """Parse from ICC tag format.""" if len(data) < 64: return None @@ -447,36 +435,36 @@ def from_bytes(cls, data: bytes) -> Optional['MHC2Tag']: offset = 8 # Skip signature and reserved # Version - version = struct.unpack('>I', data[offset:offset+4])[0] + version = struct.unpack(">I", data[offset : offset + 4])[0] offset += 4 # Luminance values def from_s15f16(d: bytes) -> float: - val = struct.unpack('>I', d)[0] + val = struct.unpack(">I", d)[0] if val >= 0x80000000: val -= 0x100000000 return val / 65536.0 - min_lum = from_s15f16(data[offset:offset+4]) + min_lum = from_s15f16(data[offset : offset + 4]) offset += 4 - max_lum = from_s15f16(data[offset:offset+4]) + max_lum = from_s15f16(data[offset : offset + 4]) offset += 4 - sdr_white = from_s15f16(data[offset:offset+4]) + sdr_white = from_s15f16(data[offset : offset + 4]) offset += 4 # Flags - flags = struct.unpack('>I', data[offset:offset+4])[0] + flags = struct.unpack(">I", data[offset : offset + 4])[0] offset += 4 use_tone = bool(flags & 0x01) use_matrix = bool(flags & 0x02) # Gamut mode - gamut_mode = struct.unpack('>I', data[offset:offset+4])[0] + gamut_mode = struct.unpack(">I", data[offset : offset + 4])[0] offset += 4 # Color matrix (36 bytes) - matrix = MHC2ColorMatrix.from_bytes(data[offset:offset+36]) + matrix = MHC2ColorMatrix.from_bytes(data[offset : offset + 36]) offset += 36 # Tone curves @@ -501,7 +489,7 @@ def from_s15f16(d: bytes) -> float: blue_curve=blue_curve, gamut_mapping_mode=gamut_mode, use_tone_curve=use_tone, - use_color_matrix=use_matrix + use_color_matrix=use_matrix, ) @@ -509,6 +497,7 @@ def from_s15f16(d: bytes) -> float: # Profile Integration # ============================================================================= + def extract_mhc2_from_profile(profile_path: str | Path) -> MHC2Tag | None: """ Extract MHC2 tag from ICC profile. @@ -531,16 +520,16 @@ def extract_mhc2_from_profile(profile_path: str | Path) -> MHC2Tag | None: return None # Parse tag table - tag_count = struct.unpack('>I', data[128:132])[0] + tag_count = struct.unpack(">I", data[128:132])[0] for i in range(tag_count): offset = 132 + i * 12 - sig = data[offset:offset+4] - tag_offset = struct.unpack('>I', data[offset+4:offset+8])[0] - tag_size = struct.unpack('>I', data[offset+8:offset+12])[0] + sig = data[offset : offset + 4] + tag_offset = struct.unpack(">I", data[offset + 4 : offset + 8])[0] + tag_size = struct.unpack(">I", data[offset + 8 : offset + 12])[0] if sig == MHC2_TAG_SIGNATURE: - mhc2_data = data[tag_offset:tag_offset+tag_size] + mhc2_data = data[tag_offset : tag_offset + tag_size] return MHC2Tag.from_bytes(mhc2_data) except Exception: @@ -550,11 +539,7 @@ def extract_mhc2_from_profile(profile_path: str | Path) -> MHC2Tag | None: def create_hdr_profile_with_mhc2( - description: str, - peak_luminance: float, - black_level: float = 0.0, - sdr_white: float = 80.0, - color_gamut: str = "P3" + description: str, peak_luminance: float, black_level: float = 0.0, sdr_white: float = 80.0, color_gamut: str = "P3" ) -> bytes: """ Create HDR ICC profile with MHC2 tag. @@ -583,10 +568,7 @@ def create_hdr_profile_with_mhc2( # Create MHC2 tag mhc2 = MHC2Tag.for_display( - peak_luminance=peak_luminance, - black_level=black_level, - sdr_white=sdr_white, - color_gamut=color_gamut + peak_luminance=peak_luminance, black_level=black_level, sdr_white=sdr_white, color_gamut=color_gamut ) profile.mhc2_data = mhc2.to_bytes() @@ -598,6 +580,7 @@ def create_hdr_profile_with_mhc2( # Windows HDR Settings # ============================================================================= + @dataclass class WindowsHDRSettings: """ @@ -605,6 +588,7 @@ class WindowsHDRSettings: Represents the HDR configuration for a Windows display. """ + # Display identification display_name: str = "" display_id: int = 0 @@ -630,14 +614,11 @@ def to_mhc2(self) -> MHC2Tag: peak_luminance=self.peak_luminance, black_level=self.min_luminance, sdr_white=self.sdr_white_level, - color_gamut=self.color_gamut + color_gamut=self.color_gamut, ) -def get_recommended_sdr_white( - ambient_lux: float, - display_peak: float -) -> float: +def get_recommended_sdr_white(ambient_lux: float, display_peak: float) -> float: """ Calculate recommended SDR white level. @@ -670,10 +651,7 @@ def get_recommended_sdr_white( return min(base, max_sdr, SDR_WHITE_MAX) -def calculate_hdr_headroom( - sdr_white: float, - display_peak: float -) -> float: +def calculate_hdr_headroom(sdr_white: float, display_peak: float) -> float: """ Calculate HDR headroom in stops. @@ -694,6 +672,7 @@ def calculate_hdr_headroom( # MHC2 ICC Profile Generation (Phase 2.1) # ============================================================================= + def _xy_to_XYZ(x: float, y: float) -> np.ndarray: """Convert xy chromaticity to XYZ with Y=1.""" if y <= 0: @@ -721,11 +700,13 @@ def _build_rgb_to_xyz_matrix( Xb, Yb, Zb = _xy_to_XYZ(*blue_xy) Xw, Yw, Zw = _xy_to_XYZ(*white_xy) - M = np.array([ - [Xr, Xg, Xb], - [Yr, Yg, Yb], - [Zr, Zg, Zb], - ]) + M = np.array( + [ + [Xr, Xg, Xb], + [Yr, Yg, Yb], + [Zr, Zg, Zb], + ] + ) S = np.linalg.solve(M, np.array([Xw, Yw, Zw])) @@ -789,11 +770,13 @@ def generate_mhc2_profile( # ------------------------------------------------------------------------- # 2. Chromatic adaptation from panel white to target white (Bradford) # ------------------------------------------------------------------------- - BRADFORD = np.array([ - [ 0.8951000, 0.2664000, -0.1614000], - [-0.7502000, 1.7135000, 0.0367000], - [ 0.0389000, -0.0685000, 1.0296000], - ]) + BRADFORD = np.array( + [ + [0.8951000, 0.2664000, -0.1614000], + [-0.7502000, 1.7135000, 0.0367000], + [0.0389000, -0.0685000, 1.0296000], + ] + ) BRADFORD_INV = np.linalg.inv(BRADFORD) src_XYZ = _xy_to_XYZ(*panel_white) @@ -825,13 +808,13 @@ def generate_mhc2_profile( # 48 bytes - 12 s15Fixed16Number values (3x4 matrix, row-major) # Total: 60 bytes # ------------------------------------------------------------------------- - mhc2_tag = MHC2_TAG_SIGNATURE # 'MHC2' - mhc2_tag += b'\x00\x00\x00\x00' # reserved - mhc2_tag += struct.pack('>I', 1) # matrix type 1 = 3x4 + mhc2_tag = MHC2_TAG_SIGNATURE # 'MHC2' + mhc2_tag += b"\x00\x00\x00\x00" # reserved + mhc2_tag += struct.pack(">I", 1) # matrix type 1 = 3x4 for row in range(3): for col in range(4): - mhc2_tag += struct.pack('>I', _float_to_s15fixed16(mhc2_matrix_3x4[row, col])) + mhc2_tag += struct.pack(">I", _float_to_s15fixed16(mhc2_matrix_3x4[row, col])) # ------------------------------------------------------------------------- # 5. Build standard ICC tags @@ -868,38 +851,35 @@ def generate_mhc2_profile( def build_mluc_tag(text: str) -> bytes: """Multi-localized Unicode description tag.""" - text_bytes = text.encode('utf-16-be') - tag = b'mluc' - tag += b'\x00\x00\x00\x00' # reserved - tag += struct.pack('>I', 1) # 1 record - tag += struct.pack('>I', 12) # record size - tag += b'enUS' # language/country - tag += struct.pack('>I', len(text_bytes) + 2) # string length + BOM - tag += struct.pack('>I', 28) # offset to string - tag += b'\xfe\xff' # UTF-16 BOM + text_bytes = text.encode("utf-16-be") + tag = b"mluc" + tag += b"\x00\x00\x00\x00" # reserved + tag += struct.pack(">I", 1) # 1 record + tag += struct.pack(">I", 12) # record size + tag += b"enUS" # language/country + tag += struct.pack(">I", len(text_bytes) + 2) # string length + BOM + tag += struct.pack(">I", 28) # offset to string + tag += b"\xfe\xff" # UTF-16 BOM tag += text_bytes while len(tag) % 4 != 0: - tag += b'\x00' + tag += b"\x00" return tag def build_text_tag(text: str) -> bytes: """Simple ASCII text tag.""" - text_bytes = text.encode('ascii', errors='replace') + b'\x00' - tag = b'text' - tag += b'\x00\x00\x00\x00' + text_bytes = text.encode("ascii", errors="replace") + b"\x00" + tag = b"text" + tag += b"\x00\x00\x00\x00" tag += text_bytes while len(tag) % 4 != 0: - tag += b'\x00' + tag += b"\x00" return tag def build_xyz_tag(x: float, y: float, z: float) -> bytes: """XYZ data tag.""" - tag = b'XYZ ' - tag += b'\x00\x00\x00\x00' - tag += struct.pack('>III', - _float_to_s15fixed16(x), - _float_to_s15fixed16(y), - _float_to_s15fixed16(z)) + tag = b"XYZ " + tag += b"\x00\x00\x00\x00" + tag += struct.pack(">III", _float_to_s15fixed16(x), _float_to_s15fixed16(y), _float_to_s15fixed16(z)) return tag def build_curv_tag(gamma: float) -> bytes: @@ -909,38 +889,38 @@ def build_curv_tag(gamma: float) -> bytes: matrix operates on linear-light values. """ gamma_fixed = int(round(gamma * 256)) & 0xFFFF - tag = b'curv' - tag += b'\x00\x00\x00\x00' - tag += struct.pack('>I', 1) # count = 1 (parametric) - tag += struct.pack('>H', gamma_fixed) + tag = b"curv" + tag += b"\x00\x00\x00\x00" + tag += struct.pack(">I", 1) # count = 1 (parametric) + tag += struct.pack(">H", gamma_fixed) while len(tag) % 4 != 0: - tag += b'\x00' + tag += b"\x00" return tag def build_chad_tag(matrix_3x3: np.ndarray) -> bytes: """Chromatic adaptation tag (sf32 type, 3x3 matrix).""" - tag = b'sf32' - tag += b'\x00\x00\x00\x00' + tag = b"sf32" + tag += b"\x00\x00\x00\x00" for row in matrix_3x3: for val in row: ival = int(round(val * 65536)) - tag += struct.pack('>i', ival) + tag += struct.pack(">i", ival) return tag # Assemble tag dictionary tags = {} - tags[b'desc'] = build_mluc_tag(description) - tags[b'cprt'] = build_text_tag("Copyright Zain Dana Harper 2022-2026 - Calibrate Pro HDR") - tags[b'wtpt'] = build_xyz_tag(D50_X, D50_Y, D50_Z) - tags[b'rXYZ'] = build_xyz_tag(*red_xyz_d50) - tags[b'gXYZ'] = build_xyz_tag(*green_xyz_d50) - tags[b'bXYZ'] = build_xyz_tag(*blue_xyz_d50) + tags[b"desc"] = build_mluc_tag(description) + tags[b"cprt"] = build_text_tag("Copyright Zain Dana Harper 2022-2026 - Calibrate Pro HDR") + tags[b"wtpt"] = build_xyz_tag(D50_X, D50_Y, D50_Z) + tags[b"rXYZ"] = build_xyz_tag(*red_xyz_d50) + tags[b"gXYZ"] = build_xyz_tag(*green_xyz_d50) + tags[b"bXYZ"] = build_xyz_tag(*blue_xyz_d50) # Linear TRC for HDR (gamma 1.0) -- the MHC2 matrix works on linear values - tags[b'rTRC'] = build_curv_tag(1.0) - tags[b'gTRC'] = build_curv_tag(1.0) - tags[b'bTRC'] = build_curv_tag(1.0) - tags[b'chad'] = build_chad_tag(chad_matrix) - tags[b'MHC2'] = mhc2_tag + tags[b"rTRC"] = build_curv_tag(1.0) + tags[b"gTRC"] = build_curv_tag(1.0) + tags[b"bTRC"] = build_curv_tag(1.0) + tags[b"chad"] = build_chad_tag(chad_matrix) + tags[b"MHC2"] = mhc2_tag # ------------------------------------------------------------------------- # 6. Assemble ICC profile @@ -952,56 +932,56 @@ def build_chad_tag(matrix_3x3: np.ndarray) -> bytes: # Calculate offsets current_offset = header_size + tag_table_size tag_offsets = {} - tag_data = b'' + tag_data = b"" for sig, data in tags.items(): tag_offsets[sig] = current_offset tag_data += data current_offset += len(data) # Build tag table - tag_table = struct.pack('>I', tag_count) + tag_table = struct.pack(">I", tag_count) for sig, data in tags.items(): tag_table += sig - tag_table += struct.pack('>II', tag_offsets[sig], len(data)) + tag_table += struct.pack(">II", tag_offsets[sig], len(data)) profile_size = header_size + len(tag_table) + len(tag_data) # Build header (128 bytes) dt = datetime.now() - date_bytes = struct.pack('>HHHHHH', - dt.year, dt.month, dt.day, - dt.hour, dt.minute, dt.second) - - header = struct.pack('>I', profile_size) # 0-3 profile size - header += b'lcms' # 4-7 preferred CMM - header += struct.pack('>I', 0x04400000) # 8-11 version 4.4 - header += b'mntr' # 12-15 display class - header += b'RGB ' # 16-19 color space - header += b'XYZ ' # 20-23 PCS - header += date_bytes # 24-35 date/time - header += b'acsp' # 36-39 ICC magic - header += b'MSFT' # 40-43 platform - header += struct.pack('>I', 0) # 44-47 flags - header += b'QNTA' # 48-51 manufacturer - header += b'CALB' # 52-55 model - header += struct.pack('>Q', 0) # 56-63 attributes - header += struct.pack('>I', 0) # 64-67 intent (perceptual) - header += struct.pack('>III', # 68-79 PCS illuminant - _float_to_s15fixed16(D50_X), - _float_to_s15fixed16(D50_Y), - _float_to_s15fixed16(D50_Z)) - header += b'QNTA' # 80-83 creator - header += b'\x00' * 16 # 84-99 MD5 (filled later) - header += b'\x00' * 28 # 100-127 reserved + date_bytes = struct.pack(">HHHHHH", dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) + + header = struct.pack(">I", profile_size) # 0-3 profile size + header += b"lcms" # 4-7 preferred CMM + header += struct.pack(">I", 0x04400000) # 8-11 version 4.4 + header += b"mntr" # 12-15 display class + header += b"RGB " # 16-19 color space + header += b"XYZ " # 20-23 PCS + header += date_bytes # 24-35 date/time + header += b"acsp" # 36-39 ICC magic + header += b"MSFT" # 40-43 platform + header += struct.pack(">I", 0) # 44-47 flags + header += b"QNTA" # 48-51 manufacturer + header += b"CALB" # 52-55 model + header += struct.pack(">Q", 0) # 56-63 attributes + header += struct.pack(">I", 0) # 64-67 intent (perceptual) + header += struct.pack( + ">III", # 68-79 PCS illuminant + _float_to_s15fixed16(D50_X), + _float_to_s15fixed16(D50_Y), + _float_to_s15fixed16(D50_Z), + ) + header += b"QNTA" # 80-83 creator + header += b"\x00" * 16 # 84-99 MD5 (filled later) + header += b"\x00" * 28 # 100-127 reserved # Combine profile = header + tag_table + tag_data # Compute MD5 (per ICC spec, zero out fields 44-47, 64-67, 84-99) profile_for_hash = bytearray(profile) - profile_for_hash[44:48] = b'\x00\x00\x00\x00' - profile_for_hash[64:68] = b'\x00\x00\x00\x00' - profile_for_hash[84:100] = b'\x00' * 16 + profile_for_hash[44:48] = b"\x00\x00\x00\x00" + profile_for_hash[64:68] = b"\x00\x00\x00\x00" + profile_for_hash[84:100] = b"\x00" * 16 md5_hash = hashlib.md5(bytes(profile_for_hash)).digest() profile = profile[:84] + md5_hash + profile[100:] @@ -1040,7 +1020,7 @@ def install_mhc2_profile(profile_path: str, display_index: int = 0) -> bool: # Validate that the file is an ICC profile try: data = profile_path.read_bytes() - if len(data) < 128 or data[36:40] != b'acsp': + if len(data) < 128 or data[36:40] != b"acsp": return False except Exception: return False @@ -1048,8 +1028,9 @@ def install_mhc2_profile(profile_path: str, display_index: int = 0) -> bool: # Step 1: Copy to system color directory try: import os - system_root = os.environ.get('SystemRoot', r'C:\Windows') - color_dir = Path(system_root) / 'System32' / 'spool' / 'drivers' / 'color' + + system_root = os.environ.get("SystemRoot", r"C:\Windows") + color_dir = Path(system_root) / "System32" / "spool" / "drivers" / "color" if color_dir.exists(): dest = color_dir / profile_path.name shutil.copy2(str(profile_path), str(dest)) @@ -1064,6 +1045,7 @@ def install_mhc2_profile(profile_path: str, display_index: int = 0) -> bool: # Step 2: Register and associate with display using mscms.dll try: import ctypes + mscms_dll = ctypes.windll.mscms # InstallColorProfileW @@ -1111,11 +1093,11 @@ class DISPLAY_DEVICE(ctypes.Structure): # WcsSetDefaultColorProfile mscms_dll.WcsSetDefaultColorProfile( - 0, # scope (system) + 0, # scope (system) device_name, - 2, # display device type - 0, # subtype - 0, # profile ID + 2, # display device type + 0, # subtype + 0, # profile ID profile_name, ) diff --git a/calibrate_pro/profiles/profile_installer.py b/calibrate_pro/profiles/profile_installer.py index 38829db..ab9af81 100644 --- a/calibrate_pro/profiles/profile_installer.py +++ b/calibrate_pro/profiles/profile_installer.py @@ -55,7 +55,7 @@ class _DISPLAY_DEVICE(ctypes.Structure): wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(_DISPLAY_DEVICE), - wintypes.DWORD + wintypes.DWORD, ] user32.EnumDisplayDevicesW.restype = wintypes.BOOL @@ -69,14 +69,16 @@ class _DISPLAY_DEVICE(ctypes.Structure): # Profile scope class ProfileScope(IntEnum): """Profile installation scope.""" - SYSTEM = 0 # All users (requires admin) - USER = 1 # Current user only + + SYSTEM = 0 # All users (requires admin) + USER = 1 # Current user only # Profile association type class ProfileAssociation(IntEnum): """Profile association type.""" - DEFAULT = 0 # Default profile for device + + DEFAULT = 0 # Default profile for device PERCEPTUAL = 1 RELATIVE = 2 SATURATION = 3 @@ -86,6 +88,7 @@ class ProfileAssociation(IntEnum): # Color profile type class ColorProfileType(IntEnum): """Color profile type.""" + INPUT = 1 DISPLAY = 2 OUTPUT = 3 @@ -99,13 +102,15 @@ class ColorProfileType(IntEnum): # Display Information # ============================================================================= + @dataclass class DisplayDevice: """Information about a display device.""" - device_name: str # e.g., "\\\\.\\DISPLAY1" - device_string: str # Friendly name - device_id: str # Hardware ID - device_key: str # Registry key + + device_name: str # e.g., "\\\\.\\DISPLAY1" + device_string: str # Friendly name + device_id: str # Hardware ID + device_key: str # Registry key is_primary: bool is_active: bool is_attached: bool @@ -124,6 +129,7 @@ def display_number(self) -> int: @dataclass class MonitorInfo: """Extended monitor information.""" + device: DisplayDevice edid_manufacturer: str = "" edid_model: str = "" @@ -152,15 +158,17 @@ def enumerate_displays() -> list[DisplayDevice]: i = 0 while user32.EnumDisplayDevicesW(None, i, ctypes.byref(device), 0): if device.StateFlags & 0x00000001: # DISPLAY_DEVICE_ACTIVE - displays.append(DisplayDevice( - device_name=device.DeviceName, - device_string=device.DeviceString, - device_id=device.DeviceID, - device_key=device.DeviceKey, - is_primary=bool(device.StateFlags & 0x00000004), - is_active=bool(device.StateFlags & 0x00000001), - is_attached=bool(device.StateFlags & 0x00000002) - )) + displays.append( + DisplayDevice( + device_name=device.DeviceName, + device_string=device.DeviceString, + device_id=device.DeviceID, + device_key=device.DeviceKey, + is_primary=bool(device.StateFlags & 0x00000004), + is_active=bool(device.StateFlags & 0x00000001), + is_attached=bool(device.StateFlags & 0x00000002), + ) + ) # Get monitor info monitor = _DISPLAY_DEVICE() @@ -232,21 +240,22 @@ class DEVMODEW(ctypes.Structure): # Profile Installation # ============================================================================= + def get_profile_directory() -> Path: """Get the system color profile directory.""" import os # Get Windows system directory - system_root = os.environ.get('SystemRoot', r'C:\Windows') - color_dir = Path(system_root) / 'System32' / 'spool' / 'drivers' / 'color' + system_root = os.environ.get("SystemRoot", r"C:\Windows") + color_dir = Path(system_root) / "System32" / "spool" / "drivers" / "color" if color_dir.exists(): return color_dir # Fallback locations fallbacks = [ - Path(r'C:\Windows\System32\spool\drivers\color'), - Path(r'C:\WINDOWS\system32\spool\drivers\color'), + Path(r"C:\Windows\System32\spool\drivers\color"), + Path(r"C:\WINDOWS\system32\spool\drivers\color"), ] for fallback in fallbacks: @@ -254,13 +263,10 @@ def get_profile_directory() -> Path: return fallback # Return default even if not exists - return Path(r'C:\Windows\System32\spool\drivers\color') + return Path(r"C:\Windows\System32\spool\drivers\color") -def install_profile( - profile_path: str | Path, - scope: ProfileScope = ProfileScope.SYSTEM -) -> tuple[bool, str]: +def install_profile(profile_path: str | Path, scope: ProfileScope = ProfileScope.SYSTEM) -> tuple[bool, str]: """ Install ICC profile to system. @@ -279,7 +285,7 @@ def install_profile( # Validate profile try: data = profile_path.read_bytes() - if len(data) < 128 or data[36:40] != b'acsp': + if len(data) < 128 or data[36:40] != b"acsp": return False, "Invalid ICC profile" except Exception as e: return False, f"Cannot read profile: {e}" @@ -345,11 +351,8 @@ def uninstall_profile(profile_name: str) -> tuple[bool, str]: # Profile Association # ============================================================================= -def associate_profile_with_display( - profile_name: str, - device_name: str, - make_default: bool = True -) -> tuple[bool, str]: + +def associate_profile_with_display(profile_name: str, device_name: str, make_default: bool = True) -> tuple[bool, str]: """ Associate ICC profile with a display. @@ -374,8 +377,8 @@ def associate_profile_with_display( # Associate profile with device result = mscms.WcsAssociateColorProfileWithDevice( 0, # scope - profile_name.encode('utf-16-le') + b'\x00\x00', - device_name.encode('utf-16-le') + b'\x00\x00' + profile_name.encode("utf-16-le") + b"\x00\x00", + device_name.encode("utf-16-le") + b"\x00\x00", ) if not result: @@ -385,11 +388,11 @@ def associate_profile_with_display( # Set as default result = mscms.WcsSetDefaultColorProfile( 0, # scope - device_name.encode('utf-16-le') + b'\x00\x00', + device_name.encode("utf-16-le") + b"\x00\x00", 2, # display device type 0, # subtype 0, # profile ID - profile_name.encode('utf-16-le') + b'\x00\x00' + profile_name.encode("utf-16-le") + b"\x00\x00", ) return True, f"Profile {profile_name} associated with {device_name}" @@ -398,10 +401,7 @@ def associate_profile_with_display( return False, f"Association error: {e}" -def disassociate_profile_from_display( - profile_name: str, - device_name: str -) -> tuple[bool, str]: +def disassociate_profile_from_display(profile_name: str, device_name: str) -> tuple[bool, str]: """ Remove profile association from display. @@ -417,9 +417,7 @@ def disassociate_profile_from_display( try: result = mscms.WcsDisassociateColorProfileFromDevice( - 0, - profile_name.encode('utf-16-le') + b'\x00\x00', - device_name.encode('utf-16-le') + b'\x00\x00' + 0, profile_name.encode("utf-16-le") + b"\x00\x00", device_name.encode("utf-16-le") + b"\x00\x00" ) if result: @@ -450,12 +448,12 @@ def get_display_profile(device_name: str) -> str | None: result = mscms.WcsGetDefaultColorProfile( 0, # scope - device_name.encode('utf-16-le') + b'\x00\x00', + device_name.encode("utf-16-le") + b"\x00\x00", 2, # display device type 0, # subtype 0, # profile ID size, - buffer + buffer, ) if result: @@ -505,25 +503,21 @@ def get_associated_profiles(device_name: str) -> list[str]: # Profile Backup and Restore # ============================================================================= + @dataclass class ProfileBackup: """Profile backup data.""" + timestamp: str profiles: dict[str, str] # display_name -> profile_name profile_data: dict[str, bytes] # profile_name -> bytes def to_dict(self) -> dict: """Convert to dictionary (profiles only, not bytes).""" - return { - "timestamp": self.timestamp, - "profiles": self.profiles - } + return {"timestamp": self.timestamp, "profiles": self.profiles} -def backup_profiles( - backup_dir: str | Path, - include_data: bool = True -) -> tuple[bool, str]: +def backup_profiles(backup_dir: str | Path, include_data: bool = True) -> tuple[bool, str]: """ Backup current display profile assignments. @@ -557,10 +551,7 @@ def backup_profiles( profile_data[profile] = profile_path.read_bytes() # Save backup - backup_info = { - "timestamp": timestamp, - "profiles": profiles - } + backup_info = {"timestamp": timestamp, "profiles": profiles} info_path = backup_dir / f"{backup_name}.json" info_path.write_text(json.dumps(backup_info, indent=2)) @@ -575,10 +566,7 @@ def backup_profiles( return True, f"Backup created: {backup_name}" -def restore_profiles( - backup_path: str | Path, - restore_data: bool = True -) -> tuple[bool, str]: +def restore_profiles(backup_path: str | Path, restore_data: bool = True) -> tuple[bool, str]: """ Restore profile assignments from backup. @@ -640,7 +628,7 @@ def list_installed_profiles() -> list[str]: profiles = [] - for ext in ['*.icc', '*.icm']: + for ext in ["*.icc", "*.icm"]: profiles.extend([p.name for p in color_dir.glob(ext)]) return sorted(profiles) @@ -650,10 +638,8 @@ def list_installed_profiles() -> list[str]: # Profile Loader (VCGT/Gamma Ramp) # ============================================================================= -def load_profile_vcgt( - profile_path: str | Path, - display_id: int = 0 -) -> tuple[bool, str]: + +def load_profile_vcgt(profile_path: str | Path, display_id: int = 0) -> tuple[bool, str]: """ Load VCGT from profile and apply to display gamma ramp. @@ -709,11 +695,9 @@ def reset_display_gamma(display_id: int = 0) -> tuple[bool, str]: # Convenience Functions # ============================================================================= + def quick_calibrate_display( - profile_path: str | Path, - display_id: int = 0, - make_default: bool = True, - apply_vcgt: bool = True + profile_path: str | Path, display_id: int = 0, make_default: bool = True, apply_vcgt: bool = True ) -> tuple[bool, str]: """ Quick display calibration: install profile, set as default, apply VCGT. @@ -749,11 +733,7 @@ def quick_calibrate_display( # Associate with display if make_default: - success, msg = associate_profile_with_display( - profile_name, - device.device_name, - make_default=True - ) + success, msg = associate_profile_with_display(profile_name, device.device_name, make_default=True) messages.append(msg) # Apply VCGT @@ -785,7 +765,7 @@ def get_display_calibration_status() -> list[dict]: "refresh_rate": info.refresh_rate, "current_profile": info.current_profile, "calibrated": info.current_profile is not None, - "hdr_supported": info.hdr_supported + "hdr_supported": info.hdr_supported, } status.append(entry) diff --git a/calibrate_pro/profiles/vcgt.py b/calibrate_pro/profiles/vcgt.py index e7ec6de..9983ef2 100644 --- a/calibrate_pro/profiles/vcgt.py +++ b/calibrate_pro/profiles/vcgt.py @@ -26,9 +26,9 @@ # ============================================================================= # Standard table sizes -VCGT_SIZE_STANDARD = 256 # Windows gamma ramp size -VCGT_SIZE_EXTENDED = 1024 # High-precision -VCGT_SIZE_MAXIMUM = 4096 # Maximum precision +VCGT_SIZE_STANDARD = 256 # Windows gamma ramp size +VCGT_SIZE_EXTENDED = 1024 # High-precision +VCGT_SIZE_MAXIMUM = 4096 # Maximum precision # Gamma ramp limits (Windows API) GAMMA_RAMP_SIZE = 256 @@ -40,6 +40,7 @@ # VCGT Data Structures # ============================================================================= + @dataclass class VCGTTable: """ @@ -47,9 +48,10 @@ class VCGTTable: Stores per-channel calibration curves for display correction. """ - red: np.ndarray # Red channel LUT [0, 1] - green: np.ndarray # Green channel LUT [0, 1] - blue: np.ndarray # Blue channel LUT [0, 1] + + red: np.ndarray # Red channel LUT [0, 1] + green: np.ndarray # Green channel LUT [0, 1] + blue: np.ndarray # Blue channel LUT [0, 1] size: int = 256 def __post_init__(self): @@ -68,26 +70,20 @@ def __post_init__(self): self.blue = np.clip(self.blue, 0.0, 1.0) @classmethod - def identity(cls, size: int = 256) -> 'VCGTTable': + def identity(cls, size: int = 256) -> "VCGTTable": """Create identity (linear) VCGT table.""" linear = np.linspace(0, 1, size) return cls(red=linear.copy(), green=linear.copy(), blue=linear.copy()) @classmethod - def from_gamma(cls, gamma: float, size: int = 256) -> 'VCGTTable': + def from_gamma(cls, gamma: float, size: int = 256) -> "VCGTTable": """Create VCGT from gamma value.""" x = np.linspace(0, 1, size) curve = np.power(x, 1.0 / gamma) # Inverse gamma for correction return cls(red=curve.copy(), green=curve.copy(), blue=curve.copy()) @classmethod - def from_rgb_gamma( - cls, - gamma_r: float, - gamma_g: float, - gamma_b: float, - size: int = 256 - ) -> 'VCGTTable': + def from_rgb_gamma(cls, gamma_r: float, gamma_g: float, gamma_b: float, size: int = 256) -> "VCGTTable": """Create VCGT with per-channel gamma.""" x = np.linspace(0, 1, size) red = np.power(x, 1.0 / gamma_r) @@ -96,30 +92,22 @@ def from_rgb_gamma( return cls(red=red, green=green, blue=blue) @classmethod - def from_srgb_correction(cls, size: int = 256) -> 'VCGTTable': + def from_srgb_correction(cls, size: int = 256) -> "VCGTTable": """Create VCGT for sRGB correction.""" x = np.linspace(0, 1, size) # sRGB EOTF (decode) def srgb_eotf(v): - return np.where( - v <= 0.04045, - v / 12.92, - np.power((v + 0.055) / 1.055, 2.4) - ) + return np.where(v <= 0.04045, v / 12.92, np.power((v + 0.055) / 1.055, 2.4)) # For correction, we apply inverse (OETF) def srgb_oetf(v): - return np.where( - v <= 0.0031308, - v * 12.92, - 1.055 * np.power(v, 1/2.4) - 0.055 - ) + return np.where(v <= 0.0031308, v * 12.92, 1.055 * np.power(v, 1 / 2.4) - 0.055) curve = srgb_oetf(x) return cls(red=curve.copy(), green=curve.copy(), blue=curve.copy()) - def resize(self, new_size: int) -> 'VCGTTable': + def resize(self, new_size: int) -> "VCGTTable": """Resize table using linear interpolation.""" old_x = np.linspace(0, 1, self.size) new_x = np.linspace(0, 1, new_size) @@ -130,7 +118,7 @@ def resize(self, new_size: int) -> 'VCGTTable': return VCGTTable(red=red, green=green, blue=blue) - def apply_brightness(self, brightness: float) -> 'VCGTTable': + def apply_brightness(self, brightness: float) -> "VCGTTable": """ Apply brightness adjustment (0-2, 1.0 = no change). @@ -143,10 +131,10 @@ def apply_brightness(self, brightness: float) -> 'VCGTTable': return VCGTTable( red=np.clip(self.red * brightness, 0, 1), green=np.clip(self.green * brightness, 0, 1), - blue=np.clip(self.blue * brightness, 0, 1) + blue=np.clip(self.blue * brightness, 0, 1), ) - def apply_contrast(self, contrast: float) -> 'VCGTTable': + def apply_contrast(self, contrast: float) -> "VCGTTable": """ Apply contrast adjustment (0-2, 1.0 = no change). @@ -160,15 +148,10 @@ def apply_contrast(self, contrast: float) -> 'VCGTTable': return VCGTTable( red=np.clip((self.red - mid) * contrast + mid, 0, 1), green=np.clip((self.green - mid) * contrast + mid, 0, 1), - blue=np.clip((self.blue - mid) * contrast + mid, 0, 1) + blue=np.clip((self.blue - mid) * contrast + mid, 0, 1), ) - def apply_rgb_gain( - self, - r_gain: float = 1.0, - g_gain: float = 1.0, - b_gain: float = 1.0 - ) -> 'VCGTTable': + def apply_rgb_gain(self, r_gain: float = 1.0, g_gain: float = 1.0, b_gain: float = 1.0) -> "VCGTTable": """ Apply per-channel gain adjustment. @@ -183,15 +166,10 @@ def apply_rgb_gain( return VCGTTable( red=np.clip(self.red * r_gain, 0, 1), green=np.clip(self.green * g_gain, 0, 1), - blue=np.clip(self.blue * b_gain, 0, 1) + blue=np.clip(self.blue * b_gain, 0, 1), ) - def apply_black_point( - self, - black_r: float = 0.0, - black_g: float = 0.0, - black_b: float = 0.0 - ) -> 'VCGTTable': + def apply_black_point(self, black_r: float = 0.0, black_g: float = 0.0, black_b: float = 0.0) -> "VCGTTable": """ Apply black point lift. @@ -206,10 +184,10 @@ def apply_black_point( return VCGTTable( red=black_r + self.red * (1.0 - black_r), green=black_g + self.green * (1.0 - black_g), - blue=black_b + self.blue * (1.0 - black_b) + blue=black_b + self.blue * (1.0 - black_b), ) - def combine(self, other: 'VCGTTable') -> 'VCGTTable': + def combine(self, other: "VCGTTable") -> "VCGTTable": """ Combine with another VCGT (apply this first, then other). @@ -232,7 +210,7 @@ def combine(self, other: 'VCGTTable') -> 'VCGTTable': return VCGTTable(red=red, green=green, blue=blue) - def invert(self) -> 'VCGTTable': + def invert(self) -> "VCGTTable": """ Invert the VCGT curves. @@ -255,13 +233,9 @@ def invert_channel(y): return np.interp(x, y_unique, x_unique) - return VCGTTable( - red=invert_channel(self.red), - green=invert_channel(self.green), - blue=invert_channel(self.blue) - ) + return VCGTTable(red=invert_channel(self.red), green=invert_channel(self.green), blue=invert_channel(self.blue)) - def smooth(self, window_size: int = 5) -> 'VCGTTable': + def smooth(self, window_size: int = 5) -> "VCGTTable": """ Apply smoothing to curves. @@ -274,9 +248,9 @@ def smooth(self, window_size: int = 5) -> 'VCGTTable': from scipy.ndimage import uniform_filter1d return VCGTTable( - red=uniform_filter1d(self.red, window_size, mode='nearest'), - green=uniform_filter1d(self.green, window_size, mode='nearest'), - blue=uniform_filter1d(self.blue, window_size, mode='nearest') + red=uniform_filter1d(self.red, window_size, mode="nearest"), + green=uniform_filter1d(self.green, window_size, mode="nearest"), + blue=uniform_filter1d(self.blue, window_size, mode="nearest"), ) def to_uint16(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -284,53 +258,53 @@ def to_uint16(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: return ( (self.red * 65535).astype(np.uint16), (self.green * 65535).astype(np.uint16), - (self.blue * 65535).astype(np.uint16) + (self.blue * 65535).astype(np.uint16), ) def to_icc_bytes(self) -> bytes: """Serialize to ICC VCGT tag format.""" - data = b'vcgt' + b'\x00\x00\x00\x00' + data = b"vcgt" + b"\x00\x00\x00\x00" # Type 0 = table - data += struct.pack('>I', 0) + data += struct.pack(">I", 0) # Channels, entries, entry size - data += struct.pack('>HHH', 3, self.size, 2) + data += struct.pack(">HHH", 3, self.size, 2) # Channel data (16-bit) r_u16, g_u16, b_u16 = self.to_uint16() for v in r_u16: - data += struct.pack('>H', v) + data += struct.pack(">H", v) for v in g_u16: - data += struct.pack('>H', v) + data += struct.pack(">H", v) for v in b_u16: - data += struct.pack('>H', v) + data += struct.pack(">H", v) # Pad to 4-byte boundary while len(data) % 4 != 0: - data += b'\x00' + data += b"\x00" return data @classmethod - def from_icc_bytes(cls, data: bytes) -> Optional['VCGTTable']: + def from_icc_bytes(cls, data: bytes) -> Optional["VCGTTable"]: """Parse from ICC VCGT tag data.""" if len(data) < 18: return None # Check signature - if data[:4] != b'vcgt': + if data[:4] != b"vcgt": return None # Parse header - tag_type = struct.unpack('>I', data[8:12])[0] + tag_type = struct.unpack(">I", data[8:12])[0] if tag_type == 0: # Table type - channels = struct.unpack('>H', data[12:14])[0] - entries = struct.unpack('>H', data[14:16])[0] - entry_size = struct.unpack('>H', data[16:18])[0] + channels = struct.unpack(">H", data[12:14])[0] + entries = struct.unpack(">H", data[14:16])[0] + entry_size = struct.unpack(">H", data[16:18])[0] if channels != 3 or entry_size != 2: return None @@ -342,32 +316,32 @@ def from_icc_bytes(cls, data: bytes) -> Optional['VCGTTable']: blue = np.zeros(entries) for i in range(entries): - red[i] = struct.unpack('>H', data[offset:offset+2])[0] / 65535.0 + red[i] = struct.unpack(">H", data[offset : offset + 2])[0] / 65535.0 offset += 2 for i in range(entries): - green[i] = struct.unpack('>H', data[offset:offset+2])[0] / 65535.0 + green[i] = struct.unpack(">H", data[offset : offset + 2])[0] / 65535.0 offset += 2 for i in range(entries): - blue[i] = struct.unpack('>H', data[offset:offset+2])[0] / 65535.0 + blue[i] = struct.unpack(">H", data[offset : offset + 2])[0] / 65535.0 offset += 2 return cls(red=red, green=green, blue=blue) elif tag_type == 1: # Formula type - gamma_r = struct.unpack('>H', data[12:14])[0] / 256.0 - min_r = struct.unpack('>H', data[14:16])[0] / 65535.0 - max_r = struct.unpack('>H', data[16:18])[0] / 65535.0 + gamma_r = struct.unpack(">H", data[12:14])[0] / 256.0 + min_r = struct.unpack(">H", data[14:16])[0] / 65535.0 + max_r = struct.unpack(">H", data[16:18])[0] / 65535.0 - gamma_g = struct.unpack('>H', data[18:20])[0] / 256.0 - min_g = struct.unpack('>H', data[20:22])[0] / 65535.0 - max_g = struct.unpack('>H', data[22:24])[0] / 65535.0 + gamma_g = struct.unpack(">H", data[18:20])[0] / 256.0 + min_g = struct.unpack(">H", data[20:22])[0] / 65535.0 + max_g = struct.unpack(">H", data[22:24])[0] / 65535.0 - gamma_b = struct.unpack('>H', data[24:26])[0] / 256.0 - min_b = struct.unpack('>H', data[26:28])[0] / 65535.0 - max_b = struct.unpack('>H', data[28:30])[0] / 65535.0 + gamma_b = struct.unpack(">H", data[24:26])[0] / 256.0 + min_b = struct.unpack(">H", data[26:28])[0] / 65535.0 + max_b = struct.unpack(">H", data[28:30])[0] / 65535.0 x = np.linspace(0, 1, 256) @@ -384,6 +358,7 @@ def from_icc_bytes(cls, data: bytes) -> Optional['VCGTTable']: # Windows Gamma Ramp API # ============================================================================= + class GammaRampController: """ Windows gamma ramp controller using GDI32. @@ -419,9 +394,7 @@ def _get_dc(self, display_id: int = 0) -> int | None: if display_name: # Get DC for specific display - hdc = self._user32.CreateDCW( - display_name, display_name, None, None - ) + hdc = self._user32.CreateDCW(display_name, display_name, None, None) else: # Get DC for primary display hdc = self._user32.GetDC(0) @@ -490,11 +463,7 @@ def get_gamma_ramp(self, display_id: int = 0) -> VCGTTable | None: return None - def set_gamma_ramp( - self, - table: VCGTTable, - display_id: int = 0 - ) -> bool: + def set_gamma_ramp(self, table: VCGTTable, display_id: int = 0) -> bool: """ Set gamma ramp for display. @@ -544,11 +513,7 @@ def reset_gamma_ramp(self, display_id: int = 0) -> bool: """ return self.set_gamma_ramp(VCGTTable.identity(256), display_id) - def apply_vcgt_from_profile( - self, - profile_path: str | Path, - display_id: int = 0 - ) -> bool: + def apply_vcgt_from_profile(self, profile_path: str | Path, display_id: int = 0) -> bool: """ Apply VCGT from ICC profile. @@ -571,6 +536,7 @@ def apply_vcgt_from_profile( # Profile VCGT Extraction # ============================================================================= + def extract_vcgt_from_profile(profile_path: str | Path) -> VCGTTable | None: """ Extract VCGT table from ICC profile. @@ -593,16 +559,16 @@ def extract_vcgt_from_profile(profile_path: str | Path) -> VCGTTable | None: return None # Parse tag table - tag_count = struct.unpack('>I', data[128:132])[0] + tag_count = struct.unpack(">I", data[128:132])[0] for i in range(tag_count): offset = 132 + i * 12 - sig = data[offset:offset+4] - tag_offset = struct.unpack('>I', data[offset+4:offset+8])[0] - tag_size = struct.unpack('>I', data[offset+8:offset+12])[0] + sig = data[offset : offset + 4] + tag_offset = struct.unpack(">I", data[offset + 4 : offset + 8])[0] + tag_size = struct.unpack(">I", data[offset + 8 : offset + 12])[0] - if sig == b'vcgt': - vcgt_data = data[tag_offset:tag_offset+tag_size] + if sig == b"vcgt": + vcgt_data = data[tag_offset : tag_offset + tag_size] return VCGTTable.from_icc_bytes(vcgt_data) except Exception: @@ -611,11 +577,7 @@ def extract_vcgt_from_profile(profile_path: str | Path) -> VCGTTable | None: return None -def embed_vcgt_in_profile( - profile_path: str | Path, - vcgt: VCGTTable, - output_path: str | Path | None = None -) -> bool: +def embed_vcgt_in_profile(profile_path: str | Path, vcgt: VCGTTable, output_path: str | Path | None = None) -> bool: """ Embed or update VCGT in ICC profile. @@ -640,14 +602,14 @@ def embed_vcgt_in_profile( return False # Find existing vcgt tag - tag_count = struct.unpack('>I', data[128:132])[0] + tag_count = struct.unpack(">I", data[128:132])[0] vcgt_index = -1 for i in range(tag_count): offset = 132 + i * 12 - sig = data[offset:offset+4] + sig = data[offset : offset + 4] - if sig == b'vcgt': + if sig == b"vcgt": vcgt_index = i break @@ -656,12 +618,12 @@ def embed_vcgt_in_profile( if vcgt_index >= 0: # Update existing tag (simplified - assumes same size) offset = 132 + vcgt_index * 12 - tag_offset = struct.unpack('>I', data[offset+4:offset+8])[0] - old_size = struct.unpack('>I', data[offset+8:offset+12])[0] + tag_offset = struct.unpack(">I", data[offset + 4 : offset + 8])[0] + old_size = struct.unpack(">I", data[offset + 8 : offset + 12])[0] if len(vcgt_data) <= old_size: # Can replace in place - data[tag_offset:tag_offset+len(vcgt_data)] = vcgt_data + data[tag_offset : tag_offset + len(vcgt_data)] = vcgt_data output.write_bytes(bytes(data)) return True @@ -676,11 +638,8 @@ def embed_vcgt_in_profile( # Calibration Curve Generation # ============================================================================= -def generate_correction_vcgt( - measured_trc: np.ndarray, - target_gamma: float = 2.2, - size: int = 256 -) -> VCGTTable: + +def generate_correction_vcgt(measured_trc: np.ndarray, target_gamma: float = 2.2, size: int = 256) -> VCGTTable: """ Generate correction VCGT from measured TRC. @@ -719,11 +678,7 @@ def generate_correction_vcgt( def generate_rgb_correction_vcgt( - measured_r: np.ndarray, - measured_g: np.ndarray, - measured_b: np.ndarray, - target_gamma: float = 2.2, - size: int = 256 + measured_r: np.ndarray, measured_g: np.ndarray, measured_b: np.ndarray, target_gamma: float = 2.2, size: int = 256 ) -> VCGTTable: """ Generate per-channel correction VCGT. @@ -750,17 +705,11 @@ def correct_channel(measured): return np.interp(target, measured[sort_idx], x[sort_idx]) return VCGTTable( - red=correct_channel(measured_r), - green=correct_channel(measured_g), - blue=correct_channel(measured_b) + red=correct_channel(measured_r), green=correct_channel(measured_g), blue=correct_channel(measured_b) ) -def generate_whitepoint_vcgt( - current_kelvin: float, - target_kelvin: float, - size: int = 256 -) -> VCGTTable: +def generate_whitepoint_vcgt(current_kelvin: float, target_kelvin: float, size: int = 256) -> VCGTTable: """ Generate VCGT for white point adjustment. @@ -772,6 +721,7 @@ def generate_whitepoint_vcgt( Returns: VCGTTable for white point shift """ + def kelvin_to_rgb(temp: float) -> tuple[float, float, float]: """Approximate CCT to RGB multipliers.""" temp = temp / 100.0 @@ -809,8 +759,4 @@ def kelvin_to_rgb(temp: float) -> tuple[float, float, float]: x = np.linspace(0, 1, size) - return VCGTTable( - red=x * r_ratio, - green=x * g_ratio, - blue=x * b_ratio - ) + return VCGTTable(red=x * r_ratio, green=x * g_ratio, blue=x * b_ratio) diff --git a/calibrate_pro/sensorless/__init__.py b/calibrate_pro/sensorless/__init__.py index 6f96b96..8989696 100644 --- a/calibrate_pro/sensorless/__init__.py +++ b/calibrate_pro/sensorless/__init__.py @@ -87,14 +87,12 @@ "get_colorchecker_reference", "calibrate_display", "verify_display", - # Pattern Generator "PatternType", "PatternConfig", "TestPattern", "PatternGenerator", "create_pattern_generator", - # Visual Matcher "MatchingMethod", "AdjustmentType", @@ -104,7 +102,6 @@ "GrayscaleBalancer", "WhitepointMatcher", "create_visual_matcher", - # Auto-Calibration (Zero-Input) "CalibrationRisk", "CalibrationStep", diff --git a/calibrate_pro/sensorless/auto_calibration.py b/calibrate_pro/sensorless/auto_calibration.py index 758e58b..9965445 100644 --- a/calibrate_pro/sensorless/auto_calibration.py +++ b/calibrate_pro/sensorless/auto_calibration.py @@ -32,17 +32,20 @@ # Data Structures # ============================================================================= + class CalibrationRisk(Enum): """Risk level for calibration operations.""" - NONE = auto() # Read-only, no changes - LOW = auto() # Software LUT only, easily reversible - MEDIUM = auto() # ICC profile changes, reversible - HIGH = auto() # Hardware settings via DDC/CI + + NONE = auto() # Read-only, no changes + LOW = auto() # Software LUT only, easily reversible + MEDIUM = auto() # ICC profile changes, reversible + HIGH = auto() # Hardware settings via DDC/CI CRITICAL = auto() # Firmware/service menu changes (not implemented) class CalibrationStep(Enum): """Steps in the auto-calibration process.""" + DETECT_DISPLAY = auto() MATCH_PANEL = auto() READ_DDC_SETTINGS = auto() @@ -59,6 +62,7 @@ class CalibrationStep(Enum): @dataclass class UserConsent: """Records user consent for calibration operations.""" + timestamp: float = 0.0 risk_level: CalibrationRisk = CalibrationRisk.NONE display_name: str = "" @@ -77,6 +81,7 @@ def is_approved_for(self, level: CalibrationRisk) -> bool: @dataclass class CalibrationTarget: """Target color characteristics for calibration.""" + whitepoint: str = "D65" # D50, D55, D65, or CCT value whitepoint_xy: tuple[float, float] = (0.3127, 0.3290) gamma: float = 2.2 @@ -89,6 +94,7 @@ class CalibrationTarget: @dataclass class AutoCalibrationResult: """Results from automatic calibration.""" + success: bool = False display_name: str = "" panel_matched: str = "" @@ -148,24 +154,17 @@ class AutoCalibrationResult: """ -def generate_consent_warning( - display_name: str, - changes: list[str], - risk_level: CalibrationRisk -) -> str: +def generate_consent_warning(display_name: str, changes: list[str], risk_level: CalibrationRisk) -> str: """Generate consent warning text.""" changes_text = "\n".join(f" - {c}" for c in changes) - return CONSENT_WARNING.format( - display_name=display_name, - changes=changes_text, - risk_level=risk_level.name - ) + return CONSENT_WARNING.format(display_name=display_name, changes=changes_text, risk_level=risk_level.name) # ============================================================================= # Auto-Calibration Engine # ============================================================================= + class AutoCalibrationEngine: """ Automatic sensorless display calibration engine. @@ -183,10 +182,7 @@ def __init__(self): self._consent: UserConsent | None = None self._result: AutoCalibrationResult | None = None - def set_progress_callback( - self, - callback: Callable[[str, float, CalibrationStep], None] - ): + def set_progress_callback(self, callback: Callable[[str, float, CalibrationStep], None]): """ Set progress callback: callback(message, progress_0_to_1, current_step) """ @@ -196,11 +192,7 @@ def _report_progress(self, message: str, progress: float, step: CalibrationStep) if self._progress_callback: self._progress_callback(message, progress, step) - def request_consent( - self, - display_name: str, - apply_ddc: bool = False - ) -> UserConsent: + def request_consent(self, display_name: str, apply_ddc: bool = False) -> UserConsent: """ Create a consent request for calibration. @@ -216,7 +208,7 @@ def request_consent( changes = [ "Generate ICC profile for color accuracy", "Generate 3D LUT for precise correction", - "Install ICC profile to Windows color management" + "Install ICC profile to Windows color management", ] if apply_ddc: @@ -232,7 +224,7 @@ def request_consent( operation=operation, user_acknowledged_risks=False, hardware_modification_approved=False, - backup_created=False + backup_created=False, ) def run_calibration( @@ -246,7 +238,7 @@ def run_calibration( display_index: int = 0, profile_name: str | None = None, display_name: str | None = None, - hdr_mode: bool = False + hdr_mode: bool = False, ) -> AutoCalibrationResult: """ Run full automatic calibration. @@ -353,6 +345,7 @@ def run_calibration( # Save community LUT formats (ReShade / SpecialK PNG strips) try: from calibrate_pro.core.lut_engine import LUT3D + saved_lut = LUT3D.load(lut_path) saved_lut.save_reshade_png(output_dir / f"{safe_name}_reshade.png") saved_lut.save_specialk_png(output_dir / f"{safe_name}_specialk.png") @@ -362,6 +355,7 @@ def run_calibration( # Save MadVR .3dlut format try: from calibrate_pro.core.lut_engine import LUT3D as _LUT3D + _lut = _LUT3D.load(lut_path) _lut.save_madvr_3dlut(output_dir / f"{safe_name}.3dlut") except OSError: @@ -370,6 +364,7 @@ def run_calibration( # Save mpv configuration snippet try: from calibrate_pro.core.lut_engine import LUT3D as _LUT3D_mpv + _lut_mpv = _LUT3D_mpv.load(lut_path) _lut_mpv.save_mpv_config( lut_path=lut_path, @@ -382,6 +377,7 @@ def run_calibration( # Save OBS-compatible .cube LUT try: from calibrate_pro.core.lut_engine import LUT3D as _LUT3D_obs + _lut_obs = _LUT3D_obs.load(lut_path) _lut_obs.save_obs_lut(output_dir / f"{safe_name}_obs.cube") except OSError: @@ -390,10 +386,7 @@ def run_calibration( # Generate MHC2 HDR profile alongside regular files when --hdr if hdr_mode: try: - self._report_progress( - "Generating MHC2 HDR profile...", 0.70, - CalibrationStep.GENERATE_ICC_PROFILE - ) + self._report_progress("Generating MHC2 HDR profile...", 0.70, CalibrationStep.GENERATE_ICC_PROFILE) from calibrate_pro.profiles.mhc2 import generate_mhc2_profile primaries = panel.native_primaries @@ -404,8 +397,8 @@ def run_calibration( ) panel_wp = (primaries.white.x, primaries.white.y) - peak_lum = getattr(panel.capabilities, 'max_luminance_hdr', 1000.0) or 1000.0 - min_lum = getattr(panel.capabilities, 'min_luminance', 0.0001) or 0.0001 + peak_lum = getattr(panel.capabilities, "max_luminance_hdr", 1000.0) or 1000.0 + min_lum = getattr(panel.capabilities, "min_luminance", 0.0001) or 0.0001 mhc2_path = output_dir / f"{safe_name}_hdr_mhc2.icc" generate_mhc2_profile( @@ -418,8 +411,7 @@ def run_calibration( output_path=mhc2_path, ) self._report_progress( - f"MHC2 HDR profile saved: {mhc2_path}", 0.72, - CalibrationStep.GENERATE_ICC_PROFILE + f"MHC2 HDR profile saved: {mhc2_path}", 0.72, CalibrationStep.GENERATE_ICC_PROFILE ) except (ImportError, OSError, ValueError) as e: result.warnings.append(f"MHC2 HDR profile generation failed: {e}") @@ -434,8 +426,7 @@ def run_calibration( if apply_lut: self._report_progress("Applying 3D LUT...", 0.85, CalibrationStep.APPLY_LUT) lut_method = self._apply_lut( - lut_path, display_index, panel, target, - display_info.get("device_name", "") + lut_path, display_index, panel, target, display_info.get("device_name", "") ) result.lut_application_method = lut_method result.lut_applied = lut_method != "" @@ -467,10 +458,7 @@ def run_calibration( } lut_msg = "" if result.lut_applied: - desc = method_descriptions.get( - result.lut_application_method, - result.lut_application_method - ) + desc = method_descriptions.get(result.lut_application_method, result.lut_application_method) lut_msg = f" LUT applied via {desc}." elif apply_lut: lut_msg = " WARNING: LUT was not applied to the display." @@ -484,6 +472,7 @@ def run_calibration( # Generate HTML calibration report try: from calibrate_pro.verification.report_generator import generate_calibration_report + report_path = output_dir / f"{safe_name}_report.html" generate_calibration_report(result, panel, result.verification, report_path) result.message += f" Report: {report_path}" @@ -520,28 +509,28 @@ def _auto_ddc_setup(self, display_index: int): db = PanelDatabase() panel_key = identify_display(displays[display_index]) panel = db.get_panel(panel_key) if panel_key else None - if panel and hasattr(panel, 'ddc') and panel.ddc: + if panel and hasattr(panel, "ddc") and panel.ddc: ddc_rec = panel.ddc self._report_progress( - f"Applying {panel.name} DDC settings...", - 0.03, CalibrationStep.READ_DDC_SETTINGS + f"Applying {panel.name} DDC settings...", 0.03, CalibrationStep.READ_DDC_SETTINGS ) except (ImportError, OSError): pass changes = ddc.auto_setup_for_calibration( - monitor, ddc_recommendations=ddc_rec, - log_fn=lambda msg: self._report_progress( - f"DDC: {msg}", 0.04, CalibrationStep.READ_DDC_SETTINGS - ), + monitor, + ddc_recommendations=ddc_rec, + log_fn=lambda msg: self._report_progress(f"DDC: {msg}", 0.04, CalibrationStep.READ_DDC_SETTINGS), ) if changes: import time + time.sleep(1.0) # Let monitor settle except (ImportError, OSError, RuntimeError) as e: import logging + logging.getLogger(__name__).debug("DDC auto-setup skipped: %s", e) def _detect_display(self, display_index: int) -> dict[str, Any]: @@ -655,7 +644,7 @@ def _match_panel(self, display_info: dict[str, Any]): edid_chromaticity=edid_chromaticity, monitor_name=name or "EDID Display", manufacturer=display_info.get("manufacturer", "Unknown"), - gamma=edid_gamma + gamma=edid_gamma, ) logger.info( "EDID-based profiling used for '%s' (not in panel database). " @@ -663,16 +652,20 @@ def _match_panel(self, display_info: dict[str, Any]): "White(%.4f,%.4f), Gamma=%.2f. " "This provides significantly better calibration than generic sRGB fallback.", name or "Unknown", - edid_chromaticity["red"][0], edid_chromaticity["red"][1], - edid_chromaticity["green"][0], edid_chromaticity["green"][1], - edid_chromaticity["blue"][0], edid_chromaticity["blue"][1], - edid_chromaticity["white"][0], edid_chromaticity["white"][1], + edid_chromaticity["red"][0], + edid_chromaticity["red"][1], + edid_chromaticity["green"][0], + edid_chromaticity["green"][1], + edid_chromaticity["blue"][0], + edid_chromaticity["blue"][1], + edid_chromaticity["white"][0], + edid_chromaticity["white"][1], edid_gamma, ) self._report_progress( - f"Using EDID chromaticity for {name or 'display'} " - f"(panel type: {panel.panel_type})", - 0.18, CalibrationStep.MATCH_PANEL, + f"Using EDID chromaticity for {name or 'display'} (panel type: {panel.panel_type})", + 0.18, + CalibrationStep.MATCH_PANEL, ) else: logger.debug( @@ -682,12 +675,14 @@ def _match_panel(self, display_info: dict[str, Any]): else: logger.debug( "No EDID data available for '%s' (device_id: %s).", - name or "Unknown", device_id, + name or "Unknown", + device_id, ) except (ImportError, OSError, IndexError, ValueError) as e: logger.warning( "EDID-based profile creation failed for '%s': %s", - name or "Unknown", e, + name or "Unknown", + e, ) # Method 5: Generic sRGB fallback (last resort) @@ -752,7 +747,7 @@ def decode(high_byte: int, low_2_bits: int) -> float: "red": (red_x, red_y), "green": (green_x, green_y), "blue": (blue_x, blue_y), - "white": (white_x, white_y) + "white": (white_x, white_y), } except (IndexError, ValueError): return None @@ -826,10 +821,10 @@ def _calculate_corrections(self, panel, target: CalibrationTarget) -> dict[str, # Sensitivity matrix (derived from CIE xy chromaticity) # These factors relate xy error to RGB gain adjustments - r_from_x = 2.5 # Red increases x + r_from_x = 2.5 # Red increases x r_from_y = -0.3 # Red slightly decreases y g_from_x = -1.0 # Green decreases x - g_from_y = 2.8 # Green increases y + g_from_y = 2.8 # Green increases y b_from_x = -1.5 # Blue decreases x b_from_y = -2.5 # Blue decreases y @@ -880,7 +875,6 @@ def _calculate_corrections(self, panel, target: CalibrationTarget) -> dict[str, "target_gamma": target.gamma, "target_whitepoint": target.whitepoint_xy, "target_luminance": target.luminance, - # Panel native characteristics "panel_whitepoint": (panel_white_x, panel_white_y), "panel_gamma_r": gamma_r, @@ -888,27 +882,20 @@ def _calculate_corrections(self, panel, target: CalibrationTarget) -> dict[str, "panel_gamma_b": gamma_b, "panel_max_luminance": panel_max_lum, "panel_min_luminance": panel.capabilities.min_luminance, - # White point error "white_error_x": white_error_x, "white_error_y": white_error_y, - # DDC/CI hardware targets "ddc_brightness": int(target_brightness_pct), "ddc_contrast": target_contrast, "ddc_rgb_gain": (int(rgb_gain_r), int(rgb_gain_g), int(rgb_gain_b)), - # For software LUT (remaining correction after hardware) "residual_gamma_r": gamma_r / target.gamma, "residual_gamma_g": gamma_g / target.gamma, "residual_gamma_b": gamma_b / target.gamma, } - def _apply_ddc_corrections( - self, - display_index: int, - corrections: dict[str, Any] - ) -> dict[str, Any]: + def _apply_ddc_corrections(self, display_index: int, corrections: dict[str, Any]) -> dict[str, Any]: """ Apply comprehensive DDC/CI hardware pre-calibration. @@ -950,7 +937,7 @@ def _apply_ddc_corrections( return changes_made monitor = monitors[display_index] - caps = monitor.get('capabilities') + caps = monitor.get("capabilities") # Get current settings for comparison current = controller.get_settings(monitor) @@ -1058,8 +1045,7 @@ def _apply_ddc_corrections( controller.close() # Log summary - num_changes = len([k for k in changes_made - if k not in ("status", "original_settings")]) + num_changes = len([k for k in changes_made if k not in ("status", "original_settings")]) changes_made["status"] = f"Applied {num_changes} DDC/CI adjustments" except Exception as e: @@ -1079,8 +1065,7 @@ def _generate_icc_profile(self, panel, target: CalibrationTarget, output_path: P profile = engine.create_icc_profile(panel) profile.save(output_path) - def _generate_3d_lut(self, panel, target: CalibrationTarget, output_path: Path, - hdr_mode: bool = False): + def _generate_3d_lut(self, panel, target: CalibrationTarget, output_path: Path, hdr_mode: bool = False): """Generate calibration 3D LUT (SDR or HDR).""" from calibrate_pro.sensorless.neuralux import SensorlessEngine @@ -1132,7 +1117,7 @@ def _apply_lut( display_index: int, panel=None, target: CalibrationTarget | None = None, - device_name: str = "" + device_name: str = "", ) -> str: """ Apply calibration to the display via the best available method. @@ -1170,10 +1155,7 @@ def _apply_lut( dwm = DwmLutController() if dwm.is_available: if dwm.load_lut_file(display_index, lut_path): - self._report_progress( - "Applied via DWM 3D LUT (best quality)", 0.88, - CalibrationStep.APPLY_LUT - ) + self._report_progress("Applied via DWM 3D LUT (best quality)", 0.88, CalibrationStep.APPLY_LUT) return "dwm_lut" else: errors.append("DWM LUT: load_lut_file returned False") @@ -1195,18 +1177,11 @@ def _apply_lut( lut = LUT3D.load(lut_path) - vcgt = lut3d_to_vcgt( - lut.data, - method="neutral_axis", - output_size=256 - ) + vcgt = lut3d_to_vcgt(lut.data, method="neutral_axis", output_size=256) - if apply_vcgt_windows( - vcgt, display_index=display_index, device_name=device_name - ): + if apply_vcgt_windows(vcgt, display_index=display_index, device_name=device_name): self._report_progress( - "Applied via VCGT gamma ramp (1D approximation)", 0.88, - CalibrationStep.APPLY_LUT + "Applied via VCGT gamma ramp (1D approximation)", 0.88, CalibrationStep.APPLY_LUT ) return "vcgt_from_3dlut" else: @@ -1224,18 +1199,11 @@ def _apply_lut( vcgt = self._generate_vcgt_from_panel(panel, target) - if apply_vcgt_windows( - vcgt, display_index=display_index, device_name=device_name - ): - self._report_progress( - "Applied via direct VCGT from panel data", 0.88, - CalibrationStep.APPLY_LUT - ) + if apply_vcgt_windows(vcgt, display_index=display_index, device_name=device_name): + self._report_progress("Applied via direct VCGT from panel data", 0.88, CalibrationStep.APPLY_LUT) return "vcgt_direct" else: - errors.append( - "Direct VCGT: apply_vcgt_windows returned False" - ) + errors.append("Direct VCGT: apply_vcgt_windows returned False") except (ImportError, OSError, ValueError) as e: errors.append(f"Direct VCGT: {e}") @@ -1253,10 +1221,7 @@ def _apply_lut( if r_curve is not None: if set_gamma_ramp(device_name, r_curve, g_curve, b_curve): - self._report_progress( - "Applied via direct gamma ramp", 0.88, - CalibrationStep.APPLY_LUT - ) + self._report_progress("Applied via direct gamma ramp", 0.88, CalibrationStep.APPLY_LUT) return "gamma_ramp" else: errors.append("Direct gamma ramp: set_gamma_ramp returned False") @@ -1269,10 +1234,7 @@ def _apply_lut( # All methods failed -- report detailed diagnostics # ===================================================================== if errors: - self._report_progress( - "WARNING: LUT application failed on all methods", 0.88, - CalibrationStep.APPLY_LUT - ) + self._report_progress("WARNING: LUT application failed on all methods", 0.88, CalibrationStep.APPLY_LUT) # Log each failure for diagnostics for err in errors: self._report_progress(f" - {err}", 0.88, CalibrationStep.APPLY_LUT) @@ -1292,6 +1254,7 @@ def _resolve_device_name(display_index: int) -> str: """ try: from calibrate_pro.panels.detection import enumerate_displays + displays = enumerate_displays() if 0 <= display_index < len(displays): return displays[display_index].device_name @@ -1328,9 +1291,9 @@ def _generate_vcgt_from_panel(panel, target: CalibrationTarget): x = np.linspace(0.0, 1.0, 256) # Per-channel gamma from panel characterization - gamma_r = getattr(panel.gamma_red, 'gamma', 2.2) - gamma_g = getattr(panel.gamma_green, 'gamma', 2.2) - gamma_b = getattr(panel.gamma_blue, 'gamma', 2.2) + gamma_r = getattr(panel.gamma_red, "gamma", 2.2) + gamma_g = getattr(panel.gamma_green, "gamma", 2.2) + gamma_b = getattr(panel.gamma_blue, "gamma", 2.2) target_gamma = target.gamma @@ -1363,11 +1326,13 @@ def xy_to_XYZ(x_val, y_val): panel_XYZ = xy_to_XYZ(panel_x, panel_y) # sRGB/BT.709 XYZ-to-RGB matrix - xyz_to_rgb = np.array([ - [ 3.2404542, -1.5371385, -0.4985314], - [-0.9692660, 1.8760108, 0.0415560], - [ 0.0556434, -0.2040259, 1.0572252] - ]) + xyz_to_rgb = np.array( + [ + [3.2404542, -1.5371385, -0.4985314], + [-0.9692660, 1.8760108, 0.0415560], + [0.0556434, -0.2040259, 1.0572252], + ] + ) target_rgb = xyz_to_rgb @ target_XYZ panel_rgb = xyz_to_rgb @ panel_XYZ @@ -1400,19 +1365,11 @@ def xy_to_XYZ(x_val, y_val): green = np.clip(np.power(x, exp_g) * wp_g, 0.0, 1.0) blue = np.clip(np.power(x, exp_b) * wp_b, 0.0, 1.0) - return VCGTTable( - red=red, - green=green, - blue=blue, - size=256, - bit_depth=16 - ) + return VCGTTable(red=red, green=green, blue=blue, size=256, bit_depth=16) @staticmethod def _build_gamma_curves( - lut_path: Path | None, - panel=None, - target: CalibrationTarget | None = None + lut_path: Path | None, panel=None, target: CalibrationTarget | None = None ) -> tuple[np.ndarray | None, np.ndarray | None, np.ndarray | None]: """ Build 256-entry normalized (0-1) gamma correction curves for the @@ -1521,13 +1478,14 @@ def restore_original_settings(self, result: AutoCalibrationResult) -> bool: # One-Click Calibration Interface # ============================================================================= + def one_click_calibrate( output_dir: Path | None = None, callback: Callable[[str, float], None] | None = None, display_index: int = 0, use_ddc: bool = True, persist: bool = True, - hdr_mode: bool = False + hdr_mode: bool = False, ) -> AutoCalibrationResult: """ Perform one-click automatic calibration. @@ -1552,8 +1510,10 @@ def one_click_calibrate( engine = AutoCalibrationEngine() if callback: + def wrapped_callback(msg, prog, step): callback(msg, prog) + engine.set_progress_callback(wrapped_callback) # Auto-approve DDC for fully automatic mode @@ -1566,7 +1526,7 @@ def wrapped_callback(msg, prog, step): operation="Automatic calibration", user_acknowledged_risks=True, hardware_modification_approved=True, - backup_created=True + backup_created=True, ) result = engine.run_calibration( @@ -1576,7 +1536,7 @@ def wrapped_callback(msg, prog, step): install_profile=True, consent=consent, display_index=display_index, - hdr_mode=hdr_mode + hdr_mode=hdr_mode, ) # Persist calibration state for reboot survival @@ -1591,7 +1551,7 @@ def auto_calibrate_all( callback: Callable[[str, float, str], None] | None = None, use_ddc: bool = True, persist: bool = True, - hdr_mode: bool = False + hdr_mode: bool = False, ) -> list[AutoCalibrationResult]: """ Automatically calibrate ALL connected displays. @@ -1613,10 +1573,7 @@ def auto_calibrate_all( displays = enumerate_displays() if not displays: - return [AutoCalibrationResult( - success=False, - message="No displays detected" - )] + return [AutoCalibrationResult(success=False, message="No displays detected")] results = [] total = len(displays) @@ -1624,16 +1581,13 @@ def auto_calibrate_all( for i, display in enumerate(displays): try: from calibrate_pro.panels.detection import get_display_name + display_name = get_display_name(display) except (ImportError, OSError): display_name = display.monitor_name or f"Display {i + 1}" if callback: - callback( - f"Calibrating {display_name} ({i + 1}/{total})...", - i / total, - display_name - ) + callback(f"Calibrating {display_name} ({i + 1}/{total})...", i / total, display_name) def per_display_callback(msg, prog, _i=i, _display_name=display_name): if callback: @@ -1647,7 +1601,7 @@ def per_display_callback(msg, prog, _i=i, _display_name=display_name): display_index=i, use_ddc=use_ddc, persist=persist, - hdr_mode=hdr_mode + hdr_mode=hdr_mode, ) results.append(result) @@ -1655,6 +1609,7 @@ def per_display_callback(msg, prog, _i=i, _display_name=display_name): if persist and any(r.success for r in results): try: from calibrate_pro.utils.startup_manager import StartupManager + manager = StartupManager() if not manager.is_startup_enabled(): manager.enable_startup(silent=True) @@ -1678,7 +1633,7 @@ def _persist_calibration(result: AutoCalibrationResult, display_index: int): icc_path=result.icc_profile_path, hdr_mode=False, delta_e_avg=result.delta_e_predicted, - delta_e_max=result.verification.get("delta_e_max", 0.0) + delta_e_max=result.verification.get("delta_e_max", 0.0), ) except (ImportError, OSError) as e: logger.error("Failed to persist calibration for display %d: %s", display_index, e) @@ -1696,7 +1651,7 @@ def _persist_calibration(result: AutoCalibrationResult, display_index: int): def progress(msg, pct): bar = "#" * int(pct * 40) + "-" * int((1 - pct) * 40) - print(f"\r[{bar}] {pct*100:5.1f}% {msg:40s}", end="", flush=True) + print(f"\r[{bar}] {pct * 100:5.1f}% {msg:40s}", end="", flush=True) if pct >= 1.0: print() diff --git a/calibrate_pro/sensorless/camera_calibration.py b/calibrate_pro/sensorless/camera_calibration.py index d559147..5bd6f95 100644 --- a/calibrate_pro/sensorless/camera_calibration.py +++ b/calibrate_pro/sensorless/camera_calibration.py @@ -37,18 +37,21 @@ # Data Structures # ============================================================================= + class CalibrationRisk(Enum): """Risk level for calibration operations.""" - NONE = auto() # Read-only, no changes - LOW = auto() # Software LUT only, easily reversible - MEDIUM = auto() # ICC profile changes, reversible - HIGH = auto() # Hardware settings via DDC/CI + + NONE = auto() # Read-only, no changes + LOW = auto() # Software LUT only, easily reversible + MEDIUM = auto() # ICC profile changes, reversible + HIGH = auto() # Hardware settings via DDC/CI CRITICAL = auto() # Service menu / firmware changes @dataclass class UserConsent: """Records user consent for calibration operations.""" + timestamp: float risk_level: CalibrationRisk display_name: str @@ -61,6 +64,7 @@ class UserConsent: @dataclass class CameraCapture: """Single camera capture of a test pattern.""" + pattern_rgb: tuple[int, int, int] # What was displayed captured_rgb: tuple[float, float, float] # What camera saw (normalized) timestamp: float @@ -71,6 +75,7 @@ class CameraCapture: @dataclass class GammaPoint: """Single point on the measured gamma curve.""" + input_level: float # 0.0 - 1.0 red_output: float green_output: float @@ -80,6 +85,7 @@ class GammaPoint: @dataclass class CameraCalibrationResult: """Results from camera-based calibration.""" + success: bool = False delta_e_before: float = 0.0 delta_e_after: float = 0.0 @@ -94,6 +100,7 @@ class CameraCalibrationResult: # Camera Interface (Abstract) # ============================================================================= + class CameraInterface: """ Abstract camera interface. @@ -136,7 +143,7 @@ def get_roi_average(self, image: np.ndarray, roi: tuple[int, int, int, int]) -> (R, G, B) normalized to 0.0-1.0 """ x, y, w, h = roi - region = image[y:y+h, x:x+w] + region = image[y : y + h, x : x + w] # Average and normalize avg = np.mean(region, axis=(0, 1)) @@ -157,6 +164,7 @@ def _ensure_open(self): if self._cap is None: try: import cv2 + self._cap = cv2.VideoCapture(self.device_id) # Disable auto-exposure and auto-white-balance for consistency self._cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0) @@ -175,6 +183,7 @@ def capture(self, delay: float = 0.5) -> np.ndarray | None: if ret: import cv2 + # Convert BGR to RGB return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) return None @@ -197,7 +206,7 @@ def __init__( display_gamma: tuple[float, float, float] = (2.4, 2.35, 2.45), display_rgb_gain: tuple[float, float, float] = (0.95, 1.0, 1.05), camera_gamma: float = 2.2, - noise_level: float = 0.01 + noise_level: float = 0.01, ): self.display_gamma = display_gamma self.display_rgb_gain = display_rgb_gain @@ -255,6 +264,7 @@ def capture(self, delay: float = 0.5) -> np.ndarray | None: # Pattern Display Interface # ============================================================================= + class PatternDisplay: """ Interface for displaying test patterns on the target display. @@ -268,7 +278,7 @@ def show_pattern(self, rgb: tuple[int, int, int], fullscreen: bool = True): """Display a solid color pattern.""" raise NotImplementedError - def show_gradient(self, channel: str = 'all'): + def show_gradient(self, channel: str = "all"): """Display a gradient pattern for gamma measurement.""" raise NotImplementedError @@ -281,6 +291,7 @@ def close(self): # Camera Calibration Engine # ============================================================================= + class CameraCalibrationEngine: """ Core engine for camera-based display calibration. @@ -298,7 +309,7 @@ def __init__( self, camera: CameraInterface, display: PatternDisplay | None = None, - roi: tuple[int, int, int, int] = (200, 150, 240, 180) + roi: tuple[int, int, int, int] = (200, 150, 240, 180), ): self.camera = camera self.display = display @@ -314,12 +325,7 @@ def _report_progress(self, message: str, progress: float): if self._progress_callback: self._progress_callback(message, progress) - def request_consent( - self, - display_name: str, - risk_level: CalibrationRisk, - operation: str - ) -> UserConsent: + def request_consent(self, display_name: str, risk_level: CalibrationRisk, operation: str) -> UserConsent: """ Create a consent request for the user. @@ -332,7 +338,7 @@ def request_consent( operation=operation, user_acknowledged_risks=False, hardware_modification_approved=False, - backup_created=False + backup_created=False, ) return consent @@ -359,12 +365,7 @@ def measure_single_color(self, rgb: tuple[int, int, int]) -> CameraCapture: # Get average in ROI measured = self.camera.get_roi_average(image, self.roi) - return CameraCapture( - pattern_rgb=rgb, - captured_rgb=measured, - timestamp=time.time(), - region_of_interest=self.roi - ) + return CameraCapture(pattern_rgb=rgb, captured_rgb=measured, timestamp=time.time(), region_of_interest=self.roi) def measure_grayscale_ramp(self, steps: int = 17) -> list[GammaPoint]: """ @@ -381,14 +382,16 @@ def measure_grayscale_ramp(self, steps: int = 17) -> list[GammaPoint]: capture = self.measure_single_color(rgb) - points.append(GammaPoint( - input_level=level / 255.0, - red_output=capture.captured_rgb[0], - green_output=capture.captured_rgb[1], - blue_output=capture.captured_rgb[2] - )) + points.append( + GammaPoint( + input_level=level / 255.0, + red_output=capture.captured_rgb[0], + green_output=capture.captured_rgb[1], + blue_output=capture.captured_rgb[2], + ) + ) - self._report_progress(f"Measuring level {i+1}/{steps}", (i+1) / steps * 0.5) + self._report_progress(f"Measuring level {i + 1}/{steps}", (i + 1) / steps * 0.5) return points @@ -439,11 +442,7 @@ def fit_gamma(inputs, outputs): white_balance = (reds[-1], greens[-1], blues[-1]) white_mean = np.mean(white_balance) if white_mean > 0: - rgb_gain_error = ( - reds[-1] / white_mean, - greens[-1] / white_mean, - blues[-1] / white_mean - ) + rgb_gain_error = (reds[-1] / white_mean, greens[-1] / white_mean, blues[-1] / white_mean) else: rgb_gain_error = (1.0, 1.0, 1.0) @@ -452,22 +451,18 @@ def fit_gamma(inputs, outputs): mid_balance = (reds[mid_idx], greens[mid_idx], blues[mid_idx]) mid_mean = np.mean(mid_balance) if mid_mean > 0: - mid_rgb_error = ( - reds[mid_idx] / mid_mean, - greens[mid_idx] / mid_mean, - blues[mid_idx] / mid_mean - ) + mid_rgb_error = (reds[mid_idx] / mid_mean, greens[mid_idx] / mid_mean, blues[mid_idx] / mid_mean) else: mid_rgb_error = (1.0, 1.0, 1.0) return { - 'gamma_r': gamma_r, - 'gamma_g': gamma_g, - 'gamma_b': gamma_b, - 'gamma_average': (gamma_r + gamma_g + gamma_b) / 3, - 'rgb_gain_error': rgb_gain_error, - 'mid_rgb_error': mid_rgb_error, - 'measured_points': points + "gamma_r": gamma_r, + "gamma_g": gamma_g, + "gamma_b": gamma_b, + "gamma_average": (gamma_r + gamma_g + gamma_b) / 3, + "rgb_gain_error": rgb_gain_error, + "mid_rgb_error": mid_rgb_error, + "measured_points": points, } def calculate_rgb_correction(self, analysis: dict[str, Any]) -> tuple[float, float, float]: @@ -477,24 +472,16 @@ def calculate_rgb_correction(self, analysis: dict[str, Any]) -> tuple[float, flo These are the multipliers to apply to each channel. """ # Inverse of the measured RGB gain error - rgb_error = analysis['rgb_gain_error'] + rgb_error = analysis["rgb_gain_error"] # Normalize so max is 1.0 (we can only reduce, not boost past 1.0) max_val = max(rgb_error) - correction = ( - max_val / rgb_error[0], - max_val / rgb_error[1], - max_val / rgb_error[2] - ) + correction = (max_val / rgb_error[0], max_val / rgb_error[1], max_val / rgb_error[2]) # Scale so max is 1.0 max_corr = max(correction) - correction = ( - correction[0] / max_corr, - correction[1] / max_corr, - correction[2] / max_corr - ) + correction = (correction[0] / max_corr, correction[1] / max_corr, correction[2] / max_corr) return correction @@ -537,7 +524,7 @@ def run_calibration( max_iterations: int = 5, target_delta_e: float = 1.0, apply_to_hardware: bool = False, - consent: UserConsent | None = None + consent: UserConsent | None = None, ) -> CameraCalibrationResult: """ Run full camera-based calibration. @@ -569,9 +556,9 @@ def run_calibration( result.delta_e_before = self.calculate_grayscale_delta_e(initial_points) result.gamma_measured = ( - initial_analysis['gamma_r'], - initial_analysis['gamma_g'], - initial_analysis['gamma_b'] + initial_analysis["gamma_r"], + initial_analysis["gamma_g"], + initial_analysis["gamma_b"], ) self._report_progress(f"Initial Delta E (approx): {result.delta_e_before:.2f}", 0.3) @@ -639,9 +626,7 @@ def run_calibration( def generate_consent_text(consent: UserConsent, operation_details: str) -> str: """Generate the full consent warning text.""" return CONSENT_WARNING_TEXT.format( - operation_details=operation_details, - display_name=consent.display_name, - risk_level=consent.risk_level.name + operation_details=operation_details, display_name=consent.display_name, risk_level=consent.risk_level.name ) @@ -660,14 +645,14 @@ def generate_consent_text(consent: UserConsent, operation_details: str) -> str: display_gamma=(2.1, 2.4, 2.5), # R too low, B too high display_rgb_gain=(1.05, 1.0, 0.92), # R too bright, B too dim camera_gamma=2.2, - noise_level=0.005 + noise_level=0.005, ) engine = CameraCalibrationEngine(camera) # Progress callback def on_progress(msg, pct): - bar = "█" * int(pct * 30) + "░" * int((1-pct) * 30) + bar = "█" * int(pct * 30) + "░" * int((1 - pct) * 30) print(f"\r[{bar}] {msg:40s}", end="", flush=True) if pct >= 1.0: print() @@ -680,7 +665,7 @@ def on_progress(msg, pct): result = engine.run_calibration( target_gamma=2.2, - apply_to_hardware=False # No consent for demo + apply_to_hardware=False, # No consent for demo ) print() @@ -688,6 +673,10 @@ def on_progress(msg, pct): print(f" Success: {result.success}") print(f" Delta E before: {result.delta_e_before:.2f}") print(f" Delta E after: {result.delta_e_after:.2f}") - print(f" Measured gamma: R={result.gamma_measured[0]:.2f} G={result.gamma_measured[1]:.2f} B={result.gamma_measured[2]:.2f}") - print(f" RGB correction: R={result.rgb_correction[0]:.3f} G={result.rgb_correction[1]:.3f} B={result.rgb_correction[2]:.3f}") + print( + f" Measured gamma: R={result.gamma_measured[0]:.2f} G={result.gamma_measured[1]:.2f} B={result.gamma_measured[2]:.2f}" + ) + print( + f" RGB correction: R={result.rgb_correction[0]:.3f} G={result.rgb_correction[1]:.3f} B={result.rgb_correction[2]:.3f}" + ) print(f" Message: {result.message}") diff --git a/calibrate_pro/sensorless/neuralux.py b/calibrate_pro/sensorless/neuralux.py index c3a5a1d..6378a9e 100644 --- a/calibrate_pro/sensorless/neuralux.py +++ b/calibrate_pro/sensorless/neuralux.py @@ -38,12 +38,15 @@ # ColorChecker Reference Data # ============================================================================= + @dataclass class ColorPatch: """Reference color patch with Lab and sRGB values.""" + name: str lab_d50: tuple[float, float, float] # L*, a*, b* under D50 - srgb: tuple[float, float, float] # sRGB values [0, 1] + srgb: tuple[float, float, float] # sRGB values [0, 1] + # X-Rite ColorChecker Classic reference values # Lab values are CIE D50 illuminant (standard for color science) @@ -75,14 +78,17 @@ class ColorPatch: ColorPatch("Black", (20.461, -0.079, -0.973), (0.191, 0.194, 0.199)), ] + def get_colorchecker_reference() -> list[ColorPatch]: """Get ColorChecker reference patches.""" return COLORCHECKER_CLASSIC.copy() + # ============================================================================= # Sensorless Calibration Engine # ============================================================================= + class SensorlessEngine: """ Sensorless Calibration Engine. @@ -138,10 +144,7 @@ def set_panel(self, panel_key: str) -> PanelCharacterization | None: return panel return None - def calculate_correction_matrix( - self, - panel: PanelCharacterization | None = None - ) -> np.ndarray: + def calculate_correction_matrix(self, panel: PanelCharacterization | None = None) -> np.ndarray: """ Calculate 3x3 color correction matrix for panel. @@ -167,18 +170,12 @@ def calculate_correction_matrix( # Panel RGB to XYZ panel_to_xyz = primaries_to_xyz_matrix( - primaries.red.as_tuple(), - primaries.green.as_tuple(), - primaries.blue.as_tuple(), - primaries.white.as_tuple() + primaries.red.as_tuple(), primaries.green.as_tuple(), primaries.blue.as_tuple(), primaries.white.as_tuple() ) # sRGB to XYZ srgb_to_xyz_mat = primaries_to_xyz_matrix( - (0.6400, 0.3300), - (0.3000, 0.6000), - (0.1500, 0.0600), - (0.3127, 0.3290) + (0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600), (0.3127, 0.3290) ) # XYZ to panel RGB @@ -190,9 +187,7 @@ def calculate_correction_matrix( return correction_matrix def generate_trc_curves( - self, - panel: PanelCharacterization | None = None, - points: int = 1024 + self, panel: PanelCharacterization | None = None, points: int = 1024 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Generate per-channel TRC curves for panel. @@ -219,9 +214,7 @@ def generate_trc_curves( return red_curve, green_curve, blue_curve def generate_vcgt( - self, - panel: PanelCharacterization | None = None, - points: int = 256 + self, panel: PanelCharacterization | None = None, points: int = 256 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Generate VCGT (Video Card Gamma Table) for calibration loader. @@ -250,9 +243,7 @@ def generate_vcgt( return red_curve, green_curve, blue_curve def create_icc_profile( - self, - panel: PanelCharacterization | None = None, - profile_name: str | None = None + self, panel: PanelCharacterization | None = None, profile_name: str | None = None ) -> ICCProfile: """ Create calibrated ICC profile for panel. @@ -288,7 +279,7 @@ def create_icc_profile( gamma=(panel.gamma_red.gamma, panel.gamma_green.gamma, panel.gamma_blue.gamma), trc_curves=(trc_red, trc_green, trc_blue), vcgt=vcgt, - copyright="Copyright Zain Dana Harper 2022-2026 - Calibrate Pro" + copyright="Copyright Zain Dana Harper 2022-2026 - Calibrate Pro", ) return profile @@ -299,7 +290,7 @@ def create_3d_lut( size: int = 33, lut_name: str | None = None, hdr_mode: bool = False, - target: str = "native" + target: str = "native", ) -> LUT3D: """ Create calibration 3D LUT for panel. @@ -336,11 +327,7 @@ def create_3d_lut( generator = LUTGenerator(size) - panel_prims = ( - primaries.red.as_tuple(), - primaries.green.as_tuple(), - primaries.blue.as_tuple() - ) + panel_prims = (primaries.red.as_tuple(), primaries.green.as_tuple(), primaries.blue.as_tuple()) if hdr_mode: peak = panel.capabilities.max_luminance_hdr @@ -351,7 +338,7 @@ def create_3d_lut( gamma_green=panel.gamma_green.gamma, gamma_blue=panel.gamma_blue.gamma, peak_luminance=peak, - title=lut_name + title=lut_name, ) elif target == "native": # Native gamut: fix gamma and white point, keep full gamut @@ -366,7 +353,7 @@ def create_3d_lut( target_gamma=2.2, oled_compensation=is_oled, panel_type=panel.panel_type, - panel_key=panel.model_pattern.split("|")[0] + panel_key=panel.model_pattern.split("|")[0], ) elif target == "sRGB": is_wide_gamut = panel.capabilities.wide_gamut or primaries.red.x > 0.66 @@ -378,7 +365,7 @@ def create_3d_lut( gamma_green=panel.gamma_green.gamma, gamma_blue=panel.gamma_blue.gamma, title=lut_name, - target_gamma=2.2 + target_gamma=2.2, ) else: lut = generator.create_calibration_lut( @@ -389,7 +376,7 @@ def create_3d_lut( gamma_blue=panel.gamma_blue.gamma, color_matrix=self.calculate_correction_matrix(panel), title=lut_name, - target_gamma=2.2 + target_gamma=2.2, ) elif target == "p3": # Compress to DCI-P3 gamut @@ -402,7 +389,7 @@ def create_3d_lut( gamma_blue=panel.gamma_blue.gamma, target_primaries=p3_primaries, title=lut_name, - target_gamma=2.2 + target_gamma=2.2, ) else: # Unknown target, fall back to native @@ -417,15 +404,13 @@ def create_3d_lut( target_gamma=2.2, oled_compensation=is_oled, panel_type=panel.panel_type, - panel_key=panel.model_pattern.split("|")[0] + panel_key=panel.model_pattern.split("|")[0], ) return lut def verify_calibration( - self, - panel: PanelCharacterization | None = None, - reference_patches: list[ColorPatch] | None = None + self, panel: PanelCharacterization | None = None, reference_patches: list[ColorPatch] | None = None ) -> dict: """ Verify calibration accuracy using ColorChecker reference. @@ -463,7 +448,7 @@ def verify_calibration( "cam16_delta_e_values": [], "cam16_delta_e_avg": 0.0, "cam16_delta_e_max": 0.0, - "grade": "" + "grade": "", } # Pre-compute CAM16 environment for viewing-condition-aware Delta E @@ -471,10 +456,7 @@ def verify_calibration( # Build panel color transformation panel_to_xyz = primaries_to_xyz_matrix( - primaries.red.as_tuple(), - primaries.green.as_tuple(), - primaries.blue.as_tuple(), - primaries.white.as_tuple() + primaries.red.as_tuple(), primaries.green.as_tuple(), primaries.blue.as_tuple(), primaries.white.as_tuple() ) # Target gamma (sRGB) @@ -505,19 +487,23 @@ def verify_calibration( rgb_panel_linear = np.clip(rgb_panel_linear, 0.0001, 1.0) # Step 3: LUT encodes for panel (inverse panel gamma) - rgb_signal = np.array([ - np.power(rgb_panel_linear[0], 1.0 / panel.gamma_red.gamma), - np.power(rgb_panel_linear[1], 1.0 / panel.gamma_green.gamma), - np.power(rgb_panel_linear[2], 1.0 / panel.gamma_blue.gamma) - ]) + rgb_signal = np.array( + [ + np.power(rgb_panel_linear[0], 1.0 / panel.gamma_red.gamma), + np.power(rgb_panel_linear[1], 1.0 / panel.gamma_green.gamma), + np.power(rgb_panel_linear[2], 1.0 / panel.gamma_blue.gamma), + ] + ) rgb_signal = np.clip(rgb_signal, 0, 1) # Step 4: Panel applies its native gamma (undoes step 3) - rgb_panel_final = np.array([ - np.power(rgb_signal[0], panel.gamma_red.gamma), - np.power(rgb_signal[1], panel.gamma_green.gamma), - np.power(rgb_signal[2], panel.gamma_blue.gamma) - ]) + rgb_panel_final = np.array( + [ + np.power(rgb_signal[0], panel.gamma_red.gamma), + np.power(rgb_signal[1], panel.gamma_green.gamma), + np.power(rgb_signal[2], panel.gamma_blue.gamma), + ] + ) # Step 5: Panel RGB to XYZ using panel primaries xyz_displayed = panel_to_xyz @ rgb_panel_final @@ -545,21 +531,23 @@ def verify_calibration( cam_ref = xyz_to_cam16(ref_xyz_100, cam16_env) cam_disp = xyz_to_cam16(disp_xyz_100, cam16_env) - ucs_ref = cam16_to_ucs(cam_ref['J'], cam_ref['M'], cam_ref['h']) - ucs_disp = cam16_to_ucs(cam_disp['J'], cam_disp['M'], cam_disp['h']) + ucs_ref = cam16_to_ucs(cam_ref["J"], cam_ref["M"], cam_ref["h"]) + ucs_disp = cam16_to_ucs(cam_disp["J"], cam_disp["M"], cam_disp["h"]) cam16_de = cam16_ucs_delta_e(ucs_ref, ucs_disp) except Exception: cam16_de = de # Fallback to CIEDE2000 - results["patches"].append({ - "name": patch.name, - "ref_lab": patch.lab_d50, - "ref_srgb": patch.srgb, - "displayed_lab": tuple(lab_displayed), - "delta_e": float(de), - "cam16_delta_e": float(cam16_de) - }) + results["patches"].append( + { + "name": patch.name, + "ref_lab": patch.lab_d50, + "ref_srgb": patch.srgb, + "displayed_lab": tuple(lab_displayed), + "delta_e": float(de), + "cam16_delta_e": float(cam16_de), + } + ) results["delta_e_values"].append(de) results["cam16_delta_e_values"].append(cam16_de) @@ -585,7 +573,9 @@ def verify_calibration( else: results["grade"] = "Acceptable (predicted dE >= 3.0)" - results["accuracy_note"] = "Predicted from panel database. Actual accuracy depends on per-unit panel variation. Verify with a colorimeter for measured results." + results["accuracy_note"] = ( + "Predicted from panel database. Actual accuracy depends on per-unit panel variation. Verify with a colorimeter for measured results." + ) # Calculate gamut coverage percentages results["gamut_coverage"] = self._calculate_gamut_coverage(panel) @@ -593,18 +583,15 @@ def verify_calibration( # 3D color volume (captures luminance-dependent gamut changes) try: from calibrate_pro.display.color_volume import compute_color_volume + primaries = panel.native_primaries vol = compute_color_volume( - panel_primaries=( - primaries.red.as_tuple(), - primaries.green.as_tuple(), - primaries.blue.as_tuple() - ), + panel_primaries=(primaries.red.as_tuple(), primaries.green.as_tuple(), primaries.blue.as_tuple()), panel_white=primaries.white.as_tuple(), lightness_steps=11, hue_steps=36, panel_type=panel.panel_type, - peak_luminance=panel.capabilities.max_luminance_hdr + peak_luminance=panel.capabilities.max_luminance_hdr, ) results["color_volume"] = { "srgb_pct": vol.srgb_volume_pct, @@ -612,7 +599,7 @@ def verify_calibration( "bt2020_pct": vol.bt2020_volume_pct, "relative_to_srgb_pct": vol.relative_to_srgb_pct, "lightness_levels": vol.lightness_levels, - "gamut_area_per_level": vol.gamut_area_per_level + "gamut_area_per_level": vol.gamut_area_per_level, } except Exception: pass @@ -630,6 +617,7 @@ def _calculate_gamut_coverage(self, panel: PanelCharacterization) -> dict: Returns: Dict with keys: srgb_pct, dci_p3_pct, bt2020_pct, panel_area, srgb_area """ + def polygon_area(poly): """Shoelace formula for arbitrary polygon area.""" n = len(poly) @@ -648,10 +636,13 @@ def sutherland_hodgman_clip(subject, clip_polygon): Clips subject polygon against each edge of clip_polygon. Returns the intersection polygon (may be empty). """ + def inside_edge(point, edge_start, edge_end): """Check if point is on the inside (left) of directed edge.""" - return ((edge_end[0] - edge_start[0]) * (point[1] - edge_start[1]) - - (edge_end[1] - edge_start[1]) * (point[0] - edge_start[0])) >= 0 + return ( + (edge_end[0] - edge_start[0]) * (point[1] - edge_start[1]) + - (edge_end[1] - edge_start[1]) * (point[0] - edge_start[0]) + ) >= 0 def line_intersection(p1, p2, p3, p4): """Find intersection of line p1-p2 with line p3-p4.""" @@ -724,7 +715,7 @@ def line_intersection(p1, p2, p3, p4): "bt2020_pct": min(100.0, bt2020_overlap / bt2020_area * 100.0) if bt2020_area > 0 else 0, "panel_area": panel_area, "srgb_area": srgb_area, - "relative_to_srgb_pct": panel_area / srgb_area * 100.0 if srgb_area > 0 else 0 + "relative_to_srgb_pct": panel_area / srgb_area * 100.0 if srgb_area > 0 else 0, } def calibrate( @@ -733,7 +724,7 @@ def calibrate( output_dir: Path | None = None, generate_icc: bool = True, generate_lut: bool = True, - lut_size: int = 33 + lut_size: int = 33, ) -> dict: """ Perform full sensorless calibration for a display. @@ -755,12 +746,7 @@ def calibrate( panel = self.database.get_fallback() self.current_panel = panel - results = { - "panel": panel.name, - "panel_type": panel.panel_type, - "files": {}, - "verification": {} - } + results = {"panel": panel.name, "panel_type": panel.panel_type, "files": {}, "verification": {}} if output_dir is None: output_dir = Path(".") @@ -873,7 +859,7 @@ def generate_gamut_only_lut(self, size: int = 33) -> LUT3D: # Blend between identity and corrected based on saturation # Low saturation -> mostly identity (preserve near-grays) # High saturation -> fully corrected (compress gamut) - blend_factor = saturation ** 0.5 # Smooth transition + blend_factor = saturation**0.5 # Smooth transition rgb_output = rgb_signal * (1 - blend_factor) + rgb_corrected_signal * blend_factor lut.data[r_idx, g_idx, b_idx] = np.clip(rgb_output, 0, 1) @@ -900,10 +886,7 @@ def verify_lut_accuracy(self, lut: LUT3D) -> float: # Build panel color transformation panel_to_xyz = primaries_to_xyz_matrix( - primaries.red.as_tuple(), - primaries.green.as_tuple(), - primaries.blue.as_tuple(), - primaries.white.as_tuple() + primaries.red.as_tuple(), primaries.green.as_tuple(), primaries.blue.as_tuple(), primaries.white.as_tuple() ) delta_e_values = [] @@ -937,7 +920,7 @@ def calibrate_display( output_dir: Path | None = None, generate_icc: bool = True, generate_lut: bool = True, - lut_size: int = 33 + lut_size: int = 33, ) -> dict: """ Convenience function to calibrate a display. @@ -954,11 +937,7 @@ def calibrate_display( """ engine = SensorlessEngine() return engine.calibrate( - model_string, - output_dir=output_dir, - generate_icc=generate_icc, - generate_lut=generate_lut, - lut_size=lut_size + model_string, output_dir=output_dir, generate_icc=generate_icc, generate_lut=generate_lut, lut_size=lut_size ) diff --git a/calibrate_pro/sensorless/pattern_generator.py b/calibrate_pro/sensorless/pattern_generator.py index 2233464..9bafe83 100644 --- a/calibrate_pro/sensorless/pattern_generator.py +++ b/calibrate_pro/sensorless/pattern_generator.py @@ -18,6 +18,7 @@ class PatternType(Enum): """Types of calibration test patterns.""" + SOLID = "solid" GRAYSCALE_RAMP = "grayscale_ramp" RGB_PRIMARIES = "rgb_primaries" @@ -33,6 +34,7 @@ class PatternType(Enum): @dataclass class PatternConfig: """Configuration for pattern generation.""" + width: int = 1920 height: int = 1080 bit_depth: int = 8 @@ -43,6 +45,7 @@ class PatternConfig: @dataclass class TestPattern: """Generated test pattern.""" + pattern_type: PatternType data: np.ndarray rgb_value: tuple[int, int, int] | None = None @@ -61,7 +64,7 @@ def __init__(self, config: PatternConfig | None = None): config: Pattern configuration settings """ self.config = config or PatternConfig() - self.max_value = (2 ** self.config.bit_depth) - 1 + self.max_value = (2**self.config.bit_depth) - 1 def generate_solid(self, r: int, g: int, b: int) -> TestPattern: """ @@ -79,10 +82,7 @@ def generate_solid(self, r: int, g: int, b: int) -> TestPattern: data[:, :] = [r, g, b] return TestPattern( - pattern_type=PatternType.SOLID, - data=data, - rgb_value=(r, g, b), - name=f"Solid RGB({r},{g},{b})" + pattern_type=PatternType.SOLID, data=data, rgb_value=(r, g, b), name=f"Solid RGB({r},{g},{b})" ) def generate_grayscale_ramp(self, steps: int = 21) -> list[TestPattern]: @@ -138,8 +138,7 @@ def generate_rgbcmy(self) -> list[TestPattern]: patterns.append(pattern) return patterns - def generate_window(self, r: int, g: int, b: int, - window_percent: float = 10.0) -> TestPattern: + def generate_window(self, r: int, g: int, b: int, window_percent: float = 10.0) -> TestPattern: """ Generate a window pattern (colored rectangle on black background). @@ -169,7 +168,7 @@ def generate_window(self, r: int, g: int, b: int, data=data, rgb_value=(r, g, b), name=f"Window {window_percent}% RGB({r},{g},{b})", - metadata={"window_percent": window_percent} + metadata={"window_percent": window_percent}, ) def generate_gradient_horizontal(self) -> TestPattern: @@ -185,11 +184,7 @@ def generate_gradient_horizontal(self) -> TestPattern: level = int((x / (self.config.width - 1)) * 255) data[:, x] = [level, level, level] - return TestPattern( - pattern_type=PatternType.GRADIENT_H, - data=data, - name="Horizontal Gradient" - ) + return TestPattern(pattern_type=PatternType.GRADIENT_H, data=data, name="Horizontal Gradient") def generate_gradient_vertical(self) -> TestPattern: """ @@ -204,14 +199,9 @@ def generate_gradient_vertical(self) -> TestPattern: level = int((y / (self.config.height - 1)) * 255) data[y, :] = [level, level, level] - return TestPattern( - pattern_type=PatternType.GRADIENT_V, - data=data, - name="Vertical Gradient" - ) + return TestPattern(pattern_type=PatternType.GRADIENT_V, data=data, name="Vertical Gradient") - def generate_uniformity_grid(self, rows: int = 5, cols: int = 5, - level: int = 255) -> TestPattern: + def generate_uniformity_grid(self, rows: int = 5, cols: int = 5, level: int = 255) -> TestPattern: """ Generate uniformity test grid pattern. @@ -241,11 +231,10 @@ def generate_uniformity_grid(self, rows: int = 5, cols: int = 5, pattern_type=PatternType.UNIFORMITY_GRID, data=data, name=f"Uniformity Grid {rows}x{cols}", - metadata={"rows": rows, "cols": cols, "level": level} + metadata={"rows": rows, "cols": cols, "level": level}, ) - def generate_crosshatch(self, line_spacing: int = 100, - line_width: int = 1) -> TestPattern: + def generate_crosshatch(self, line_spacing: int = 100, line_width: int = 1) -> TestPattern: """ Generate crosshatch pattern for geometry testing. @@ -274,7 +263,7 @@ def generate_crosshatch(self, line_spacing: int = 100, pattern_type=PatternType.CROSSHATCH, data=data, name=f"Crosshatch {line_spacing}px", - metadata={"line_spacing": line_spacing, "line_width": line_width} + metadata={"line_spacing": line_spacing, "line_width": line_width}, ) def generate_colorchecker_patches(self) -> list[TestPattern]: @@ -319,11 +308,13 @@ def generate_colorchecker_patches(self) -> list[TestPattern]: patterns.append(pattern) return patterns - def generate_calibration_sequence(self, - include_grayscale: bool = True, - grayscale_steps: int = 21, - include_primaries: bool = True, - include_colorchecker: bool = False) -> Generator[TestPattern, None, None]: + def generate_calibration_sequence( + self, + include_grayscale: bool = True, + grayscale_steps: int = 21, + include_primaries: bool = True, + include_colorchecker: bool = False, + ) -> Generator[TestPattern, None, None]: """ Generate a complete calibration pattern sequence. @@ -349,8 +340,7 @@ def generate_calibration_sequence(self, yield pattern -def create_pattern_generator(width: int = 1920, height: int = 1080, - bit_depth: int = 8) -> PatternGenerator: +def create_pattern_generator(width: int = 1920, height: int = 1080, bit_depth: int = 8) -> PatternGenerator: """ Create a pattern generator with specified settings. diff --git a/calibrate_pro/sensorless/visual_matcher.py b/calibrate_pro/sensorless/visual_matcher.py index 5807d87..e468d7c 100644 --- a/calibrate_pro/sensorless/visual_matcher.py +++ b/calibrate_pro/sensorless/visual_matcher.py @@ -13,6 +13,7 @@ class MatchingMethod(Enum): """Visual matching methods.""" + FLICKER = "flicker" # Alternate between reference and display SIDE_BY_SIDE = "side_by_side" # Show both simultaneously SPLIT_SCREEN = "split_screen" # Split view comparison @@ -21,6 +22,7 @@ class MatchingMethod(Enum): class AdjustmentType(Enum): """Types of visual adjustments.""" + BRIGHTNESS = "brightness" CONTRAST = "contrast" RGB_BALANCE = "rgb_balance" @@ -32,6 +34,7 @@ class AdjustmentType(Enum): @dataclass class MatchResult: """Result from a visual matching session.""" + adjustment_type: AdjustmentType initial_value: float final_value: float @@ -43,6 +46,7 @@ class MatchResult: @dataclass class CalibrationAdjustment: """Adjustment to apply during calibration.""" + red_gain: float = 1.0 green_gain: float = 1.0 blue_gain: float = 1.0 @@ -77,8 +81,9 @@ def set_method(self, method: MatchingMethod) -> None: """Set the visual matching method.""" self.method = method - def create_reference_patch(self, target_rgb: tuple[int, int, int], - size: tuple[int, int] = (200, 200)) -> np.ndarray: + def create_reference_patch( + self, target_rgb: tuple[int, int, int], size: tuple[int, int] = (200, 200) + ) -> np.ndarray: """ Create a reference color patch. @@ -93,9 +98,12 @@ def create_reference_patch(self, target_rgb: tuple[int, int, int], patch[:, :] = target_rgb return patch - def create_display_patch(self, target_rgb: tuple[int, int, int], - adjustment: CalibrationAdjustment | None = None, - size: tuple[int, int] = (200, 200)) -> np.ndarray: + def create_display_patch( + self, + target_rgb: tuple[int, int, int], + adjustment: CalibrationAdjustment | None = None, + size: tuple[int, int] = (200, 200), + ) -> np.ndarray: """ Create an adjusted display patch for comparison. @@ -128,10 +136,9 @@ def create_display_patch(self, target_rgb: tuple[int, int, int], patch[:, :] = [r, g, b] return patch - def create_flicker_sequence(self, reference: np.ndarray, - display: np.ndarray, - frequency: float = 2.0, - duration: float = 5.0) -> list[tuple[np.ndarray, float]]: + def create_flicker_sequence( + self, reference: np.ndarray, display: np.ndarray, frequency: float = 2.0, duration: float = 5.0 + ) -> list[tuple[np.ndarray, float]]: """ Create a flicker sequence for comparison. @@ -154,9 +161,7 @@ def create_flicker_sequence(self, reference: np.ndarray, return sequence - def create_split_view(self, reference: np.ndarray, - display: np.ndarray, - split: str = "vertical") -> np.ndarray: + def create_split_view(self, reference: np.ndarray, display: np.ndarray, split: str = "vertical") -> np.ndarray: """ Create a split-screen comparison view. @@ -174,9 +179,9 @@ def create_split_view(self, reference: np.ndarray, combined = np.vstack([reference, display]) return combined - def calculate_adjustment_step(self, adjustment_type: AdjustmentType, - direction: int, - fine_mode: bool = False) -> CalibrationAdjustment: + def calculate_adjustment_step( + self, adjustment_type: AdjustmentType, direction: int, fine_mode: bool = False + ) -> CalibrationAdjustment: """ Calculate the next adjustment step. @@ -217,12 +222,15 @@ def apply_adjustment(self, adjustment: CalibrationAdjustment) -> None: """Apply a new adjustment.""" self.current_adjustment = adjustment - def record_match(self, adjustment_type: AdjustmentType, - initial_value: float, - final_value: float, - confidence: float, - iterations: int, - time_seconds: float) -> MatchResult: + def record_match( + self, + adjustment_type: AdjustmentType, + initial_value: float, + final_value: float, + confidence: float, + iterations: int, + time_seconds: float, + ) -> MatchResult: """ Record a completed visual match. @@ -243,7 +251,7 @@ def record_match(self, adjustment_type: AdjustmentType, final_value=final_value, confidence=confidence, iterations=iterations, - time_seconds=time_seconds + time_seconds=time_seconds, ) self.match_history.append(result) return result @@ -277,8 +285,7 @@ def __init__(self, matcher: VisualMatcher | None = None): self.matcher = matcher or VisualMatcher() self.gray_levels = [25, 50, 75, 100, 128, 150, 175, 200, 225, 255] - def create_gray_target(self, level: int, - size: tuple[int, int] = (300, 300)) -> np.ndarray: + def create_gray_target(self, level: int, size: tuple[int, int] = (300, 300)) -> np.ndarray: """Create a neutral gray target patch.""" patch = np.zeros((size[1], size[0], 3), dtype=np.uint8) patch[:, :] = [level, level, level] @@ -288,8 +295,9 @@ def get_adjustment_for_level(self, level: int) -> CalibrationAdjustment: """Get the current adjustment for a gray level.""" return self.matcher.current_adjustment - def calculate_rgb_correction(self, measured_rgb: tuple[int, int, int], - target_gray: int) -> tuple[float, float, float]: + def calculate_rgb_correction( + self, measured_rgb: tuple[int, int, int], target_gray: int + ) -> tuple[float, float, float]: """ Calculate RGB gain corrections to achieve neutral gray. @@ -383,9 +391,9 @@ def get_target_rgb(self) -> tuple[int, int, int]: cct = cct_map.get(self.target, 6500) return self.cct_to_rgb(cct) - def create_whitepoint_comparison(self, - current_rgb: tuple[int, int, int], - size: tuple[int, int] = (400, 200)) -> np.ndarray: + def create_whitepoint_comparison( + self, current_rgb: tuple[int, int, int], size: tuple[int, int] = (400, 200) + ) -> np.ndarray: """ Create a side-by-side whitepoint comparison. diff --git a/calibrate_pro/services/ambient_light.py b/calibrate_pro/services/ambient_light.py index dd4a624..01ce58a 100644 --- a/calibrate_pro/services/ambient_light.py +++ b/calibrate_pro/services/ambient_light.py @@ -31,9 +31,11 @@ # Lighting presets for manual mode # --------------------------------------------------------------------------- + @dataclass class LightingPreset: """A named ambient lighting environment.""" + name: str description: str lux_low: float @@ -86,6 +88,7 @@ def lux_midpoint(self) -> float: # Sensor backends # --------------------------------------------------------------------------- + class _WindowsSensorBackend: """Read ambient light from the Windows WMI light sensor.""" @@ -158,10 +161,7 @@ def read_lux(self) -> float | None: devices like the X-Rite i1Display Pro or Datacolor SpyderX via their USB HID interface (or via ArgyllCMS ``spotread``). """ - logger.warning( - "USB ambient light sensor support is not yet implemented. " - "Use 'manual' mode instead." - ) + logger.warning("USB ambient light sensor support is not yet implemented. Use 'manual' mode instead.") return None @@ -182,10 +182,7 @@ def set_preset(self, preset_name: str) -> None: ``"living_room"``, ``"dark_room"``. """ if preset_name not in LIGHTING_PRESETS: - raise ValueError( - f"Unknown preset '{preset_name}'. " - f"Valid presets: {list(LIGHTING_PRESETS.keys())}" - ) + raise ValueError(f"Unknown preset '{preset_name}'. Valid presets: {list(LIGHTING_PRESETS.keys())}") self._preset = LIGHTING_PRESETS[preset_name] logger.info("Manual ambient light set to '%s'", preset_name) @@ -200,6 +197,7 @@ def read_lux(self) -> float | None: # Public AmbientLightService # --------------------------------------------------------------------------- + class AmbientLightService: """ Service that reads ambient light levels and recommends calibration @@ -223,10 +221,7 @@ def __init__(self, sensor_type: str = "windows") -> None: elif self.sensor_type == "manual": self._backend = _ManualBackend() else: - raise ValueError( - f"Unknown sensor_type '{sensor_type}'. " - f"Valid types: 'windows', 'usb', 'manual'." - ) + raise ValueError(f"Unknown sensor_type '{sensor_type}'. Valid types: 'windows', 'usb', 'manual'.") # --- Manual-mode helpers --- @@ -243,9 +238,7 @@ def set_manual_preset(self, preset_name: str) -> None: ``"living_room"``, ``"dark_room"``. """ if not isinstance(self._backend, _ManualBackend): - raise RuntimeError( - "set_manual_preset() is only available in 'manual' mode." - ) + raise RuntimeError("set_manual_preset() is only available in 'manual' mode.") self._backend.set_preset(preset_name) # --- Core API --- @@ -413,7 +406,4 @@ def get_available_presets() -> dict[str, str]: dict ``{preset_name: description}`` """ - return { - name: preset.description - for name, preset in LIGHTING_PRESETS.items() - } + return {name: preset.description for name, preset in LIGHTING_PRESETS.items()} diff --git a/calibrate_pro/services/app_switcher.py b/calibrate_pro/services/app_switcher.py index 86e07a5..fa6d175 100644 --- a/calibrate_pro/services/app_switcher.py +++ b/calibrate_pro/services/app_switcher.py @@ -51,6 +51,7 @@ # Win32 helpers (ctypes) # --------------------------------------------------------------------------- + def _get_foreground_window() -> int: """Return the HWND of the current foreground window, or 0.""" try: @@ -132,6 +133,7 @@ def _exe_name(exe_path: str) -> str: # Core service class # --------------------------------------------------------------------------- + class AppProfileSwitcher: """ Monitors the foreground Windows application and switches the active @@ -302,6 +304,7 @@ def _get_lut(self, profile_name: str): try: from calibrate_pro.core.lut_engine import LUT3D + lut = LUT3D.load(lut_path) self._lut_cache[profile_name] = lut logger.info("Cached LUT for profile '%s' from %s", profile_name, lut_path) @@ -360,8 +363,7 @@ def _apply_profile(self, profile_name: str) -> bool: from calibrate_pro.core.vcgt import VCGTTable, apply_vcgt_windows identity = np.linspace(0.0, 1.0, 256) - vcgt = VCGTTable(red=identity, green=identity, blue=identity, - size=256, bit_depth=16) + vcgt = VCGTTable(red=identity, green=identity, blue=identity, size=256, bit_depth=16) if apply_vcgt_windows(vcgt, display_index=self._display_index): logger.info("Reset VCGT to identity for profile '%s'", profile_name) return True @@ -390,7 +392,10 @@ def _monitor_loop(self): old_profile = self._current_profile logger.info( "Foreground changed: %s -> %s (profile: %s -> %s)", - self._current_app, app_name, old_profile, new_profile, + self._current_app, + app_name, + old_profile, + new_profile, ) self._apply_profile(new_profile) self._current_profile = new_profile diff --git a/calibrate_pro/services/calibration_guard.py b/calibrate_pro/services/calibration_guard.py index 657ee04..79bbff7 100644 --- a/calibrate_pro/services/calibration_guard.py +++ b/calibrate_pro/services/calibration_guard.py @@ -29,8 +29,9 @@ @dataclass class GuardedDisplay: """A display whose calibration is being protected.""" - device_name: str # e.g., "\\\\.\\DISPLAY1" - display_name: str # Human-readable + + device_name: str # e.g., "\\\\.\\DISPLAY1" + display_name: str # Human-readable icc_profile_path: str | None = None lut_path: str | None = None vcgt_red: np.ndarray | None = None @@ -47,11 +48,7 @@ class CalibrationGuard: associations and DWM LUT state. """ - def __init__( - self, - check_interval: float = 10.0, - on_restore: Callable[[str, str], None] | None = None - ): + def __init__(self, check_interval: float = 10.0, on_restore: Callable[[str, str], None] | None = None): """ Args: check_interval: Seconds between checks (default 10) @@ -148,8 +145,7 @@ def _check_and_restore(self, display: GuardedDisplay) -> bool: if is_linear and not self._is_linear_ramp(display.vcgt_red): # Windows reset our calibration to linear — restore it - self._apply_vcgt(display.device_name, - display.vcgt_red, display.vcgt_green, display.vcgt_blue) + self._apply_vcgt(display.device_name, display.vcgt_red, display.vcgt_green, display.vcgt_blue) self._restore_count += 1 if self.on_restore: self.on_restore(display.display_name, "Windows reset gamma ramp to linear") @@ -157,8 +153,7 @@ def _check_and_restore(self, display: GuardedDisplay) -> bool: if not is_matching and not is_linear: # Something else changed the ramp — could be another tool, restore ours - self._apply_vcgt(display.device_name, - display.vcgt_red, display.vcgt_green, display.vcgt_blue) + self._apply_vcgt(display.device_name, display.vcgt_red, display.vcgt_green, display.vcgt_blue) self._restore_count += 1 if self.on_restore: self.on_restore(display.display_name, "Gamma ramp was modified by another process") @@ -189,6 +184,7 @@ def _read_current_vcgt(device_name: str) -> tuple | None: return None try: + class GAMMA_RAMP(ctypes.Structure): _fields_ = [ ("Red", wintypes.WORD * 256), @@ -220,6 +216,7 @@ def _apply_vcgt(device_name: str, red: np.ndarray, green: np.ndarray, blue: np.n return False try: + class GAMMA_RAMP(ctypes.Structure): _fields_ = [ ("Red", wintypes.WORD * 256), @@ -249,10 +246,8 @@ def detect_acm_enabled() -> bool: """ try: import winreg - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"SOFTWARE\Microsoft\Windows\CurrentVersion\AdvancedDisplay" - ) + + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\AdvancedDisplay") for i in range(20): try: subkey_name = winreg.EnumKey(key, i) diff --git a/calibrate_pro/services/drift_monitor.py b/calibrate_pro/services/drift_monitor.py index 0cf4fca..a86c121 100644 --- a/calibrate_pro/services/drift_monitor.py +++ b/calibrate_pro/services/drift_monitor.py @@ -16,6 +16,7 @@ @dataclass class CalibrationStatus: """Status of calibration for one display.""" + display_index: int display_name: str is_calibrated: bool @@ -73,18 +74,20 @@ def check_calibration_status(max_age_days: int = 30) -> list[CalibrationStatus]: needs_recal = age_days > max_age_days if last_cal is not None else False - results.append(CalibrationStatus( - display_index=state.display_id, - display_name=state.display_name or state.model or f"Display {state.display_id}", - is_calibrated=last_cal is not None, - last_calibrated=last_cal, - age_days=age_days, - needs_recalibration=needs_recal, - lut_applied=lut_exists, - lut_path=state.lut_path, - icc_installed=icc_exists, - icc_path=state.icc_path, - )) + results.append( + CalibrationStatus( + display_index=state.display_id, + display_name=state.display_name or state.model or f"Display {state.display_id}", + is_calibrated=last_cal is not None, + last_calibrated=last_cal, + age_days=age_days, + needs_recalibration=needs_recal, + lut_applied=lut_exists, + lut_path=state.lut_path, + icc_installed=icc_exists, + icc_path=state.icc_path, + ) + ) return results diff --git a/calibrate_pro/services/gamut_clamp.py b/calibrate_pro/services/gamut_clamp.py index fb7d9c3..4c682a0 100644 --- a/calibrate_pro/services/gamut_clamp.py +++ b/calibrate_pro/services/gamut_clamp.py @@ -59,6 +59,7 @@ def enable(self, panel_key: str = None) -> bool: # Apply via dwm_lut try: from calibrate_pro.lut_system.dwm_lut import DwmLutController + dwm = DwmLutController() if dwm.is_available: if dwm.load_lut_file(self.display_index, self._lut_path): @@ -73,6 +74,7 @@ def disable(self) -> bool: """Remove the sRGB gamut clamp.""" try: from calibrate_pro.lut_system.dwm_lut import remove_lut + remove_lut(self.display_index) self._active = False return True diff --git a/calibrate_pro/startup/__init__.py b/calibrate_pro/startup/__init__.py index a8346a8..fdbb4d5 100644 --- a/calibrate_pro/startup/__init__.py +++ b/calibrate_pro/startup/__init__.py @@ -17,11 +17,11 @@ ) __all__ = [ - 'load_calibration_luts', - 'create_startup_shortcut', - 'remove_startup', - 'check_startup_enabled', - 'run_service', - 'apply_saved_calibrations', - 'start_service_command', + "load_calibration_luts", + "create_startup_shortcut", + "remove_startup", + "check_startup_enabled", + "run_service", + "apply_saved_calibrations", + "start_service_command", ] diff --git a/calibrate_pro/startup/calibration_loader.py b/calibrate_pro/startup/calibration_loader.py index ffb3754..a566e46 100644 --- a/calibrate_pro/startup/calibration_loader.py +++ b/calibrate_pro/startup/calibration_loader.py @@ -49,9 +49,7 @@ def _get_logger() -> logging.Logger: log_dir.mkdir(parents=True, exist_ok=True) log_file = log_dir / "calibration_service.log" handler = logging.FileHandler(log_file, encoding="utf-8") - handler.setFormatter( - logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") - ) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(handler) logger.setLevel(logging.INFO) _LOG_INITIALIZED = True @@ -62,6 +60,7 @@ def _get_logger() -> logging.Logger: # Win32 structures & helpers # --------------------------------------------------------------------------- + class DISPLAY_DEVICE(ctypes.Structure): _fields_ = [ ("cb", wintypes.DWORD), @@ -117,6 +116,7 @@ def _enumerate_active_displays() -> list[dict]: # LUT application helpers # --------------------------------------------------------------------------- + def _get_dwm_lut_dir() -> Path: """Return the directory where dwm_lut expects .cube files.""" system_root = os.environ.get("SYSTEMROOT", r"C:\Windows") @@ -225,9 +225,15 @@ def _apply_vcgt_gamma_ramp(lut_path: str, display_state: DisplayCalibrationState frac = lut_idx - idx_low # Extract from the neutral diagonal (R=G=B), not single-channel axes - r_curve[i] = lut.data[idx_low, idx_low, idx_low, 0] * (1.0 - frac) + lut.data[idx_high, idx_high, idx_high, 0] * frac - g_curve[i] = lut.data[idx_low, idx_low, idx_low, 1] * (1.0 - frac) + lut.data[idx_high, idx_high, idx_high, 1] * frac - b_curve[i] = lut.data[idx_low, idx_low, idx_low, 2] * (1.0 - frac) + lut.data[idx_high, idx_high, idx_high, 2] * frac + r_curve[i] = ( + lut.data[idx_low, idx_low, idx_low, 0] * (1.0 - frac) + lut.data[idx_high, idx_high, idx_high, 0] * frac + ) + g_curve[i] = ( + lut.data[idx_low, idx_low, idx_low, 1] * (1.0 - frac) + lut.data[idx_high, idx_high, idx_high, 1] * frac + ) + b_curve[i] = ( + lut.data[idx_low, idx_low, idx_low, 2] * (1.0 - frac) + lut.data[idx_high, idx_high, idx_high, 2] * frac + ) # Apply gamma ramp via platform backend (works on Windows, macOS, Linux) red_int = [int(np.clip(r_curve[i], 0.0, 1.0) * 65535) for i in range(256)] @@ -236,6 +242,7 @@ def _apply_vcgt_gamma_ramp(lut_path: str, display_state: DisplayCalibrationState try: from calibrate_pro.platform import get_platform_backend + backend = get_platform_backend() ok = backend.apply_gamma_ramp(display_state.display_id, red_int, green_int, blue_int) if ok: @@ -278,6 +285,7 @@ def _apply_single_calibration(display_state: DisplayCalibrationState) -> bool: # Ensure DwmLutGUI is running so the placed LUT file is active try: from calibrate_pro.lut_system.dwm_lut import DwmLutController + dwm = DwmLutController() if dwm.is_available and not dwm._is_dwm_lut_running(): dwm.start_dwm_lut_gui() @@ -293,6 +301,7 @@ def _apply_single_calibration(display_state: DisplayCalibrationState) -> bool: # Public API # --------------------------------------------------------------------------- + def apply_saved_calibrations() -> bool: """ One-shot: load the saved CalibrationConfig and re-apply every display @@ -347,9 +356,7 @@ def run_service(silent: bool = True) -> None: if not silent: # Add a console handler so the user sees output console = logging.StreamHandler(sys.stdout) - console.setFormatter( - logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") - ) + console.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(console) logger.info("=" * 60) @@ -401,6 +408,7 @@ def run_service(silent: bool = True) -> None: # start-service command handler # --------------------------------------------------------------------------- + def start_service_command(args: list[str] | None = None) -> None: """ Entry point for the ``start-service`` CLI command. diff --git a/calibrate_pro/startup/lut_autoload.py b/calibrate_pro/startup/lut_autoload.py index cdbc79a..733b3d2 100644 --- a/calibrate_pro/startup/lut_autoload.py +++ b/calibrate_pro/startup/lut_autoload.py @@ -19,20 +19,17 @@ def setup_logging(): """Configure logging for the auto-load service.""" - log_dir = Path(os.environ.get('APPDATA', '')) / 'CalibratePro' / 'logs' + log_dir = Path(os.environ.get("APPDATA", "")) / "CalibratePro" / "logs" log_dir.mkdir(parents=True, exist_ok=True) - log_file = log_dir / 'autoload.log' + log_file = log_dir / "autoload.log" logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler(log_file), - logging.StreamHandler() - ] + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler(log_file), logging.StreamHandler()], ) - return logging.getLogger('CalibratePro.AutoLoad') + return logging.getLogger("CalibratePro.AutoLoad") def load_calibration_luts(): @@ -56,7 +53,7 @@ def load_calibration_luts(): loaded_count = 0 for display in displays: - display_id = display['id'] + display_id = display["id"] profile = manager.get_display_profile(display_id) if not profile: @@ -98,11 +95,11 @@ def create_startup_shortcut(): python_exe = sys.executable # Create a batch file that runs silently - startup_dir = Path(os.environ.get('APPDATA', '')) / 'CalibratePro' + startup_dir = Path(os.environ.get("APPDATA", "")) / "CalibratePro" startup_dir.mkdir(parents=True, exist_ok=True) - batch_file = startup_dir / 'autoload_luts.bat' - vbs_file = startup_dir / 'autoload_luts.vbs' + batch_file = startup_dir / "autoload_luts.bat" + vbs_file = startup_dir / "autoload_luts.vbs" # Create batch file batch_content = f'''@echo off @@ -121,10 +118,8 @@ def create_startup_shortcut(): # Add to Windows startup registry key_path = r"Software\Microsoft\Windows\CurrentVersion\Run" - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, - winreg.KEY_SET_VALUE) as key: - winreg.SetValueEx(key, "CalibratePro_LUT_AutoLoad", 0, - winreg.REG_SZ, f'wscript.exe "{vbs_file}"') + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key: + winreg.SetValueEx(key, "CalibratePro_LUT_AutoLoad", 0, winreg.REG_SZ, f'wscript.exe "{vbs_file}"') return True, str(vbs_file) @@ -139,8 +134,7 @@ def remove_startup(): key_path = r"Software\Microsoft\Windows\CurrentVersion\Run" - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, - winreg.KEY_SET_VALUE) as key: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key: try: winreg.DeleteValue(key, "CalibratePro_LUT_AutoLoad") except FileNotFoundError: @@ -159,8 +153,7 @@ def check_startup_enabled(): key_path = r"Software\Microsoft\Windows\CurrentVersion\Run" - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, - winreg.KEY_READ) as key: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_READ) as key: try: value, _ = winreg.QueryValueEx(key, "CalibratePro_LUT_AutoLoad") return True, value @@ -175,14 +168,10 @@ def check_startup_enabled(): import argparse parser = argparse.ArgumentParser(description="Calibrate Pro LUT Auto-Load") - parser.add_argument('--install', action='store_true', - help='Install auto-load to Windows startup') - parser.add_argument('--uninstall', action='store_true', - help='Remove auto-load from Windows startup') - parser.add_argument('--status', action='store_true', - help='Check if auto-load is enabled') - parser.add_argument('--load', action='store_true', - help='Load LUTs now') + parser.add_argument("--install", action="store_true", help="Install auto-load to Windows startup") + parser.add_argument("--uninstall", action="store_true", help="Remove auto-load from Windows startup") + parser.add_argument("--status", action="store_true", help="Check if auto-load is enabled") + parser.add_argument("--load", action="store_true", help="Load LUTs now") args = parser.parse_args() diff --git a/calibrate_pro/targets/__init__.py b/calibrate_pro/targets/__init__.py index fa1973a..f401f28 100644 --- a/calibrate_pro/targets/__init__.py +++ b/calibrate_pro/targets/__init__.py @@ -154,6 +154,7 @@ class CalibrationTargetProfile: This is the main class users should interact with to specify complete calibration targets for professional workflows. """ + name: str = "Default" description: str = "" @@ -175,25 +176,21 @@ def get_summary(self) -> dict: "whitepoint": { "preset": self.whitepoint.preset.value, "cct": self.whitepoint.get_cct(), - "xy": self.whitepoint.get_xy() + "xy": self.whitepoint.get_xy(), }, "luminance": { "preset": self.luminance.standard.value, "peak": self.luminance.get_peak_luminance(), "black": self.luminance.get_black_level(), "contrast": self.luminance.get_contrast_ratio(), - "hdr": self.luminance.is_hdr() - }, - "gamma": { - "preset": self.gamma.preset.value, - "value": self.gamma.gamma_value, - "hdr": self.gamma.is_hdr() + "hdr": self.luminance.is_hdr(), }, + "gamma": {"preset": self.gamma.preset.value, "value": self.gamma.gamma_value, "hdr": self.gamma.is_hdr()}, "gamut": { "preset": self.gamut.preset.value, "wide_gamut": self.gamut.is_wide_gamut(), - "area": self.gamut.get_gamut_area() - } + "area": self.gamut.get_gamut_area(), + }, } def to_dict(self) -> dict: @@ -204,7 +201,7 @@ def to_dict(self) -> dict: "whitepoint": self.whitepoint.to_dict(), "luminance": self.luminance.to_dict(), "gamma": self.gamma.to_dict(), - "gamut": self.gamut.to_dict() + "gamut": self.gamut.to_dict(), } @classmethod @@ -216,7 +213,7 @@ def from_dict(cls, data: dict) -> CalibrationTargetProfile: whitepoint=WhitepointTarget.from_dict(data.get("whitepoint", {})), luminance=LuminanceTarget.from_dict(data.get("luminance", {})), gamma=GammaTarget.from_dict(data.get("gamma", {})), - gamut=GamutTarget.from_dict(data.get("gamut", {})) + gamut=GamutTarget.from_dict(data.get("gamut", {})), ) @@ -231,7 +228,7 @@ def from_dict(cls, data: dict) -> CalibrationTargetProfile: whitepoint=WHITEPOINT_D65, luminance=LUMINANCE_CONSUMER_SDR, gamma=GAMMA_SRGB, - gamut=GAMUT_SRGB + gamut=GAMUT_SRGB, ) # Rec.709 Broadcast @@ -241,7 +238,7 @@ def from_dict(cls, data: dict) -> CalibrationTargetProfile: whitepoint=WHITEPOINT_D65, luminance=LUMINANCE_REC709, gamma=GAMMA_BT1886, - gamut=GAMUT_SRGB + gamut=GAMUT_SRGB, ) # DCI-P3 Cinema @@ -251,7 +248,7 @@ def from_dict(cls, data: dict) -> CalibrationTargetProfile: whitepoint=WHITEPOINT_DCI, luminance=LUMINANCE_DCI, gamma=GammaTarget(preset=GammaPreset.POWER_26), - gamut=GAMUT_DCI_P3_THEATER + gamut=GAMUT_DCI_P3_THEATER, ) # HDR10 Mastering @@ -261,7 +258,7 @@ def from_dict(cls, data: dict) -> CalibrationTargetProfile: whitepoint=WHITEPOINT_D65, luminance=LUMINANCE_HDR10, gamma=GAMMA_PQ, - gamut=GAMUT_BT2020 + gamut=GAMUT_BT2020, ) # Photography (Adobe RGB / D50) @@ -271,7 +268,7 @@ def from_dict(cls, data: dict) -> CalibrationTargetProfile: whitepoint=WHITEPOINT_D50, luminance=LUMINANCE_CONSUMER_SDR, gamma=GammaTarget(preset=GammaPreset.ADOBE_RGB), - gamut=GAMUT_ADOBE_RGB + gamut=GAMUT_ADOBE_RGB, ) # Film Grading @@ -281,7 +278,7 @@ def from_dict(cls, data: dict) -> CalibrationTargetProfile: whitepoint=WHITEPOINT_D65, luminance=LUMINANCE_FILM, gamma=GAMMA_24, - gamut=GAMUT_DCI_P3 + gamut=GAMUT_DCI_P3, ) @@ -299,30 +296,64 @@ def get_profile_presets() -> list: __all__ = [ # White Point - 'WhitepointPreset', 'WhitepointTarget', - 'WHITEPOINT_D50', 'WHITEPOINT_D65', 'WHITEPOINT_DCI', 'WHITEPOINT_ACES', - 'cct_to_xy', 'xy_to_cct', 'get_whitepoint_presets', 'create_custom_whitepoint', - + "WhitepointPreset", + "WhitepointTarget", + "WHITEPOINT_D50", + "WHITEPOINT_D65", + "WHITEPOINT_DCI", + "WHITEPOINT_ACES", + "cct_to_xy", + "xy_to_cct", + "get_whitepoint_presets", + "create_custom_whitepoint", # Luminance - 'LuminanceStandard', 'LuminanceTarget', 'GrayscaleTarget', - 'LUMINANCE_REC709', 'LUMINANCE_HDR10', 'LUMINANCE_DOLBY_VISION', - 'get_luminance_presets', 'create_custom_luminance', - + "LuminanceStandard", + "LuminanceTarget", + "GrayscaleTarget", + "LUMINANCE_REC709", + "LUMINANCE_HDR10", + "LUMINANCE_DOLBY_VISION", + "get_luminance_presets", + "create_custom_luminance", # Gamma - 'GammaPreset', 'GammaTarget', - 'GAMMA_22', 'GAMMA_24', 'GAMMA_SRGB', 'GAMMA_BT1886', 'GAMMA_PQ', 'GAMMA_HLG', - 'pq_eotf', 'pq_oetf', 'hlg_eotf', 'hlg_oetf', 'bt1886_eotf', 'bt1886_oetf', - 'get_gamma_presets', 'create_custom_gamma', 'create_bt1886_target', - + "GammaPreset", + "GammaTarget", + "GAMMA_22", + "GAMMA_24", + "GAMMA_SRGB", + "GAMMA_BT1886", + "GAMMA_PQ", + "GAMMA_HLG", + "pq_eotf", + "pq_oetf", + "hlg_eotf", + "hlg_oetf", + "bt1886_eotf", + "bt1886_oetf", + "get_gamma_presets", + "create_custom_gamma", + "create_bt1886_target", # Gamut - 'GamutPreset', 'GamutTarget', 'ColorPrimaries', - 'GAMUT_SRGB', 'GAMUT_DCI_P3', 'GAMUT_BT2020', 'GAMUT_ADOBE_RGB', - 'PRIMARIES_SRGB', 'PRIMARIES_DCI_P3', 'PRIMARIES_BT2020', - 'get_gamut_presets', 'create_custom_gamut', 'calculate_gamut_coverage', - + "GamutPreset", + "GamutTarget", + "ColorPrimaries", + "GAMUT_SRGB", + "GAMUT_DCI_P3", + "GAMUT_BT2020", + "GAMUT_ADOBE_RGB", + "PRIMARIES_SRGB", + "PRIMARIES_DCI_P3", + "PRIMARIES_BT2020", + "get_gamut_presets", + "create_custom_gamut", + "calculate_gamut_coverage", # Unified Profile - 'CalibrationTargetProfile', - 'PROFILE_SRGB', 'PROFILE_REC709', 'PROFILE_DCI_P3', 'PROFILE_HDR10', - 'PROFILE_PHOTOGRAPHY', 'PROFILE_FILM_GRADING', - 'get_profile_presets', + "CalibrationTargetProfile", + "PROFILE_SRGB", + "PROFILE_REC709", + "PROFILE_DCI_P3", + "PROFILE_HDR10", + "PROFILE_PHOTOGRAPHY", + "PROFILE_FILM_GRADING", + "get_profile_presets", ] diff --git a/calibrate_pro/targets/gamma.py b/calibrate_pro/targets/gamma.py index 336177a..834bd98 100644 --- a/calibrate_pro/targets/gamma.py +++ b/calibrate_pro/targets/gamma.py @@ -22,24 +22,25 @@ class GammaPreset(Enum): """Standard gamma/EOTF presets.""" + # Power law - POWER_18 = "Power 1.8" # Legacy Mac - POWER_20 = "Power 2.0" # Linear approximation - POWER_22 = "Power 2.2" # Common display - POWER_24 = "Power 2.4" # Professional video - POWER_26 = "Power 2.6" # Dark room viewing + POWER_18 = "Power 1.8" # Legacy Mac + POWER_20 = "Power 2.0" # Linear approximation + POWER_22 = "Power 2.2" # Common display + POWER_24 = "Power 2.4" # Professional video + POWER_26 = "Power 2.6" # Dark room viewing # Standard curves - SRGB = "sRGB" # IEC 61966-2-1 - BT1886 = "BT.1886" # ITU-R BT.1886 broadcast - ADOBE_RGB = "Adobe RGB" # Adobe RGB (1998) - L_STAR = "L*" # CIE L* perceptual + SRGB = "sRGB" # IEC 61966-2-1 + BT1886 = "BT.1886" # ITU-R BT.1886 broadcast + ADOBE_RGB = "Adobe RGB" # Adobe RGB (1998) + L_STAR = "L*" # CIE L* perceptual # HDR - PQ = "PQ (ST.2084)" # Perceptual Quantizer HDR - HLG = "HLG" # Hybrid Log-Gamma - SLOG3 = "S-Log3" # Sony S-Log3 - LOG_C = "Log C" # ARRI Log C + PQ = "PQ (ST.2084)" # Perceptual Quantizer HDR + HLG = "HLG" # Hybrid Log-Gamma + SLOG3 = "S-Log3" # Sony S-Log3 + LOG_C = "Log C" # ARRI Log C # Custom CUSTOM = "Custom" @@ -49,6 +50,7 @@ class GammaPreset(Enum): # Standard EOTF Functions # ============================================================================= + def power_eotf(x: np.ndarray, gamma: float = 2.2) -> np.ndarray: """ Simple power law EOTF. @@ -92,11 +94,7 @@ def srgb_eotf(x: np.ndarray) -> np.ndarray: Normalized linear light (0-1) """ x = np.clip(x, 0, 1) - return np.where( - x <= 0.04045, - x / 12.92, - np.power((x + 0.055) / 1.055, 2.4) - ) + return np.where(x <= 0.04045, x / 12.92, np.power((x + 0.055) / 1.055, 2.4)) def srgb_oetf(L: np.ndarray) -> np.ndarray: @@ -110,19 +108,10 @@ def srgb_oetf(L: np.ndarray) -> np.ndarray: Normalized signal (0-1) """ L = np.clip(L, 0, 1) - return np.where( - L <= 0.0031308, - L * 12.92, - 1.055 * np.power(L, 1/2.4) - 0.055 - ) + return np.where(L <= 0.0031308, L * 12.92, 1.055 * np.power(L, 1 / 2.4) - 0.055) -def bt1886_eotf( - x: np.ndarray, - L_W: float = 100.0, - L_B: float = 0.0, - gamma: float = 2.4 -) -> np.ndarray: +def bt1886_eotf(x: np.ndarray, L_W: float = 100.0, L_B: float = 0.0, gamma: float = 2.4) -> np.ndarray: """ BT.1886 EOTF (ITU-R BT.1886). @@ -141,8 +130,8 @@ def bt1886_eotf( x = np.clip(x, 0, 1) # BT.1886 parameters - a = (L_W ** (1/gamma) - L_B ** (1/gamma)) ** gamma - b = L_B ** (1/gamma) / (L_W ** (1/gamma) - L_B ** (1/gamma)) + a = (L_W ** (1 / gamma) - L_B ** (1 / gamma)) ** gamma + b = L_B ** (1 / gamma) / (L_W ** (1 / gamma) - L_B ** (1 / gamma)) # EOTF L = a * np.maximum(x + b, 0) ** gamma @@ -150,12 +139,7 @@ def bt1886_eotf( return L -def bt1886_oetf( - L: np.ndarray, - L_W: float = 100.0, - L_B: float = 0.0, - gamma: float = 2.4 -) -> np.ndarray: +def bt1886_oetf(L: np.ndarray, L_W: float = 100.0, L_B: float = 0.0, gamma: float = 2.4) -> np.ndarray: """ BT.1886 OETF (inverse of EOTF). @@ -171,11 +155,11 @@ def bt1886_oetf( L = np.clip(L, L_B, L_W) # Inverse parameters - a = (L_W ** (1/gamma) - L_B ** (1/gamma)) ** gamma - b = L_B ** (1/gamma) / (L_W ** (1/gamma) - L_B ** (1/gamma)) + a = (L_W ** (1 / gamma) - L_B ** (1 / gamma)) ** gamma + b = L_B ** (1 / gamma) / (L_W ** (1 / gamma) - L_B ** (1 / gamma)) # Inverse EOTF - x = np.power(L / a, 1/gamma) - b + x = np.power(L / a, 1 / gamma) - b return np.clip(x, 0, 1) @@ -202,11 +186,11 @@ def pq_eotf(x: np.ndarray) -> np.ndarray: c3 = 2392 / 128 # 18.6875 # EOTF - x_pow = np.power(x, 1/m2) + x_pow = np.power(x, 1 / m2) num = np.maximum(x_pow - c1, 0) den = c2 - c3 * x_pow - L = 10000 * np.power(num / den, 1/m1) + L = 10000 * np.power(num / den, 1 / m1) return L @@ -263,11 +247,7 @@ def hlg_eotf(x: np.ndarray, L_W: float = 1000.0, gamma: float = 1.2) -> np.ndarr c = 0.55991073 # 0.5 - a * ln(4a) # OETF^-1 (signal to scene light) - E = np.where( - x <= 0.5, - (x ** 2) / 3, - (np.exp((x - c) / a) + b) / 12 - ) + E = np.where(x <= 0.5, (x**2) / 3, (np.exp((x - c) / a) + b) / 12) # OOTF (scene to display, with system gamma) L = L_W * np.power(E, gamma) @@ -298,11 +278,7 @@ def hlg_oetf(L: np.ndarray, L_W: float = 1000.0, gamma: float = 1.2) -> np.ndarr c = 0.55991073 # OETF - x = np.where( - E <= 1/12, - np.sqrt(3 * E), - a * np.log(12 * E - b) + c - ) + x = np.where(E <= 1 / 12, np.sqrt(3 * E), a * np.log(12 * E - b) + c) return np.clip(x, 0, 1) @@ -322,11 +298,7 @@ def l_star_eotf(x: np.ndarray) -> np.ndarray: # L* to Y L_star = x * 100 - return np.where( - L_star > 8.0, - np.power((L_star + 16) / 116, 3), - L_star / 903.3 - ) + return np.where(L_star > 8.0, np.power((L_star + 16) / 116, 3), L_star / 903.3) def l_star_oetf(Y: np.ndarray) -> np.ndarray: @@ -341,11 +313,7 @@ def l_star_oetf(Y: np.ndarray) -> np.ndarray: """ Y = np.clip(Y, 0, 1) - L_star = np.where( - Y > 0.008856, - 116 * np.power(Y, 1/3) - 16, - 903.3 * Y - ) + L_star = np.where(Y > 0.008856, 116 * np.power(Y, 1 / 3) - 16, 903.3 * Y) return L_star / 100 @@ -365,7 +333,7 @@ def slog3_eotf(x: np.ndarray) -> np.ndarray: return np.where( x >= 171.2102946929 / 1023, np.power(10, (x * 1023 - 420) / 261.5) * (0.18 + 0.01) - 0.01, - (x * 1023 - 95) * 0.01125000 / (171.2102946929 - 95) + (x * 1023 - 95) * 0.01125000 / (171.2102946929 - 95), ) @@ -390,17 +358,14 @@ def log_c_eotf(x: np.ndarray) -> np.ndarray: e = 5.367655 f = 0.092809 - return np.where( - x > e * cut + f, - (np.power(10, (x - d) / c) - b) / a, - (x - f) / e - ) + return np.where(x > e * cut + f, (np.power(10, (x - d) / c) - b) / a, (x - f) / e) # ============================================================================= # Gamma Target Class # ============================================================================= + @dataclass class GammaTarget: """ @@ -416,10 +381,11 @@ class GammaTarget: hlg_system_gamma: System gamma for HLG tolerance_percent: Acceptable gamma deviation """ + preset: GammaPreset = GammaPreset.POWER_22 gamma_value: float = 2.2 peak_luminance: float = 100.0 # cd/m2 for BT.1886/HLG - black_luminance: float = 0.0 # cd/m2 for BT.1886 + black_luminance: float = 0.0 # cd/m2 for BT.1886 hlg_system_gamma: float = 1.2 # Tolerance @@ -449,18 +415,22 @@ def __post_init__(self): def get_eotf(self) -> Callable: """Get EOTF function for this target.""" - if self.preset in {GammaPreset.POWER_18, GammaPreset.POWER_20, - GammaPreset.POWER_22, GammaPreset.POWER_24, - GammaPreset.POWER_26, GammaPreset.CUSTOM, - GammaPreset.ADOBE_RGB}: + if self.preset in { + GammaPreset.POWER_18, + GammaPreset.POWER_20, + GammaPreset.POWER_22, + GammaPreset.POWER_24, + GammaPreset.POWER_26, + GammaPreset.CUSTOM, + GammaPreset.ADOBE_RGB, + }: return lambda x: power_eotf(x, self.gamma_value) elif self.preset == GammaPreset.SRGB: return srgb_eotf elif self.preset == GammaPreset.BT1886: - return lambda x: bt1886_eotf(x, self.peak_luminance, - self.black_luminance, 2.4) / self.peak_luminance + return lambda x: bt1886_eotf(x, self.peak_luminance, self.black_luminance, 2.4) / self.peak_luminance elif self.preset == GammaPreset.L_STAR: return l_star_eotf @@ -469,8 +439,7 @@ def get_eotf(self) -> Callable: return lambda x: pq_eotf(x) / 10000 elif self.preset == GammaPreset.HLG: - return lambda x: hlg_eotf(x, self.peak_luminance, - self.hlg_system_gamma) / self.peak_luminance + return lambda x: hlg_eotf(x, self.peak_luminance, self.hlg_system_gamma) / self.peak_luminance elif self.preset == GammaPreset.SLOG3: return slog3_eotf @@ -483,18 +452,22 @@ def get_eotf(self) -> Callable: def get_oetf(self) -> Callable: """Get OETF (inverse EOTF) function for this target.""" - if self.preset in {GammaPreset.POWER_18, GammaPreset.POWER_20, - GammaPreset.POWER_22, GammaPreset.POWER_24, - GammaPreset.POWER_26, GammaPreset.CUSTOM, - GammaPreset.ADOBE_RGB}: + if self.preset in { + GammaPreset.POWER_18, + GammaPreset.POWER_20, + GammaPreset.POWER_22, + GammaPreset.POWER_24, + GammaPreset.POWER_26, + GammaPreset.CUSTOM, + GammaPreset.ADOBE_RGB, + }: return lambda L: power_oetf(L, self.gamma_value) elif self.preset == GammaPreset.SRGB: return srgb_oetf elif self.preset == GammaPreset.BT1886: - return lambda L: bt1886_oetf(L * self.peak_luminance, - self.peak_luminance, self.black_luminance, 2.4) + return lambda L: bt1886_oetf(L * self.peak_luminance, self.peak_luminance, self.black_luminance, 2.4) elif self.preset == GammaPreset.L_STAR: return l_star_oetf @@ -503,8 +476,7 @@ def get_oetf(self) -> Callable: return lambda L: pq_oetf(L * 10000) elif self.preset == GammaPreset.HLG: - return lambda L: hlg_oetf(L * self.peak_luminance, - self.peak_luminance, self.hlg_system_gamma) + return lambda L: hlg_oetf(L * self.peak_luminance, self.peak_luminance, self.hlg_system_gamma) else: return lambda L: power_oetf(L, 2.2) @@ -526,8 +498,7 @@ def get_target_curve(self, steps: int = 256) -> tuple[np.ndarray, np.ndarray]: def is_hdr(self) -> bool: """Check if this is an HDR transfer function.""" - return self.preset in {GammaPreset.PQ, GammaPreset.HLG, - GammaPreset.SLOG3, GammaPreset.LOG_C} + return self.preset in {GammaPreset.PQ, GammaPreset.HLG, GammaPreset.SLOG3, GammaPreset.LOG_C} def calculate_effective_gamma(self, signal_level: float = 0.5) -> float: """ @@ -571,12 +542,7 @@ def verify(self, measured_curve: list[tuple[float, float]]) -> dict: error = abs(measured - target) / max(target, 0.001) * 100 errors.append(error) - results.append({ - "level": level, - "target": target, - "measured": measured, - "error_percent": error - }) + results.append({"level": level, "target": target, "measured": measured, "error_percent": error}) avg_error = np.mean(errors) max_error = np.max(errors) @@ -588,7 +554,7 @@ def verify(self, measured_curve: list[tuple[float, float]]) -> dict: "max_error_percent": max_error, "points": results, "passed": avg_error <= self.tolerance_percent, - "grade": self._grade_result(avg_error) + "grade": self._grade_result(avg_error), } def _grade_result(self, avg_error: float) -> str: @@ -612,7 +578,7 @@ def to_dict(self) -> dict: "hlg_system_gamma": self.hlg_system_gamma, "name": self.name, "description": self.description, - "is_hdr": self.is_hdr() + "is_hdr": self.is_hdr(), } @classmethod @@ -625,7 +591,7 @@ def from_dict(cls, data: dict) -> "GammaTarget": black_luminance=data.get("black_luminance", 0.0), hlg_system_gamma=data.get("hlg_system_gamma", 1.2), name=data.get("name", ""), - description=data.get("description", "") + description=data.get("description", ""), ) @@ -633,50 +599,27 @@ def from_dict(cls, data: dict) -> "GammaTarget": # Standard Presets # ============================================================================= -GAMMA_22 = GammaTarget( - preset=GammaPreset.POWER_22, - name="Gamma 2.2", - description="Standard display gamma" -) +GAMMA_22 = GammaTarget(preset=GammaPreset.POWER_22, name="Gamma 2.2", description="Standard display gamma") -GAMMA_24 = GammaTarget( - preset=GammaPreset.POWER_24, - name="Gamma 2.4", - description="Professional video, darker viewing" -) +GAMMA_24 = GammaTarget(preset=GammaPreset.POWER_24, name="Gamma 2.4", description="Professional video, darker viewing") -GAMMA_SRGB = GammaTarget( - preset=GammaPreset.SRGB, - name="sRGB", - description="IEC 61966-2-1 sRGB transfer function" -) +GAMMA_SRGB = GammaTarget(preset=GammaPreset.SRGB, name="sRGB", description="IEC 61966-2-1 sRGB transfer function") GAMMA_BT1886 = GammaTarget( preset=GammaPreset.BT1886, peak_luminance=100.0, black_luminance=0.0, name="BT.1886", - description="ITU-R BT.1886 broadcast EOTF" + description="ITU-R BT.1886 broadcast EOTF", ) -GAMMA_PQ = GammaTarget( - preset=GammaPreset.PQ, - name="PQ (ST.2084)", - description="HDR10 Perceptual Quantizer" -) +GAMMA_PQ = GammaTarget(preset=GammaPreset.PQ, name="PQ (ST.2084)", description="HDR10 Perceptual Quantizer") GAMMA_HLG = GammaTarget( - preset=GammaPreset.HLG, - peak_luminance=1000.0, - name="HLG", - description="Hybrid Log-Gamma broadcast HDR" + preset=GammaPreset.HLG, peak_luminance=1000.0, name="HLG", description="Hybrid Log-Gamma broadcast HDR" ) -GAMMA_L_STAR = GammaTarget( - preset=GammaPreset.L_STAR, - name="L* (Perceptual)", - description="CIE L* perceptually uniform" -) +GAMMA_L_STAR = GammaTarget(preset=GammaPreset.L_STAR, name="L* (Perceptual)", description="CIE L* perceptually uniform") def get_gamma_presets() -> list[GammaTarget]: @@ -704,26 +647,16 @@ def get_hdr_presets() -> list[GammaTarget]: return [p for p in get_gamma_presets() if p.is_hdr()] -def create_custom_gamma( - gamma: float, - name: str = "Custom" -) -> GammaTarget: +def create_custom_gamma(gamma: float, name: str = "Custom") -> GammaTarget: """Create a custom power law gamma target.""" - return GammaTarget( - preset=GammaPreset.CUSTOM, - gamma_value=gamma, - name=name - ) + return GammaTarget(preset=GammaPreset.CUSTOM, gamma_value=gamma, name=name) -def create_bt1886_target( - peak_luminance: float = 100.0, - black_luminance: float = 0.0 -) -> GammaTarget: +def create_bt1886_target(peak_luminance: float = 100.0, black_luminance: float = 0.0) -> GammaTarget: """Create a BT.1886 target with specific luminance levels.""" return GammaTarget( preset=GammaPreset.BT1886, peak_luminance=peak_luminance, black_luminance=black_luminance, - name=f"BT.1886 ({peak_luminance:.0f} cd/m2)" + name=f"BT.1886 ({peak_luminance:.0f} cd/m2)", ) diff --git a/calibrate_pro/targets/gamut.py b/calibrate_pro/targets/gamut.py index 1ac5027..0e29a63 100644 --- a/calibrate_pro/targets/gamut.py +++ b/calibrate_pro/targets/gamut.py @@ -22,27 +22,28 @@ class GamutPreset(Enum): """Standard color gamut presets.""" + # SDR Standards - SRGB = "sRGB" # Rec.709 / Web standard - REC709 = "Rec.709" # Same as sRGB primaries + SRGB = "sRGB" # Rec.709 / Web standard + REC709 = "Rec.709" # Same as sRGB primaries # Wide Gamut - DCI_P3 = "DCI-P3" # DCI-P3 (D65 white) - DCI_P3_THEATER = "DCI-P3 Theater" # DCI-P3 (theater white 6300K) - DISPLAY_P3 = "Display P3" # Apple Display P3 (D65) - ADOBE_RGB = "Adobe RGB" # Adobe RGB (1998) + DCI_P3 = "DCI-P3" # DCI-P3 (D65 white) + DCI_P3_THEATER = "DCI-P3 Theater" # DCI-P3 (theater white 6300K) + DISPLAY_P3 = "Display P3" # Apple Display P3 (D65) + ADOBE_RGB = "Adobe RGB" # Adobe RGB (1998) # Ultra Wide - BT2020 = "BT.2020" # Rec.2020 / Ultra HD - PROPHOTO = "ProPhoto RGB" # Very wide gamut photography - ACES_CG = "ACEScg" # ACES computer graphics + BT2020 = "BT.2020" # Rec.2020 / Ultra HD + PROPHOTO = "ProPhoto RGB" # Very wide gamut photography + ACES_CG = "ACEScg" # ACES computer graphics # Legacy - NTSC_1953 = "NTSC 1953" # Original NTSC - PAL_SECAM = "PAL/SECAM" # European broadcast + NTSC_1953 = "NTSC 1953" # Original NTSC + PAL_SECAM = "PAL/SECAM" # European broadcast # Native - NATIVE = "Native" # Display native gamut + NATIVE = "Native" # Display native gamut CUSTOM = "Custom" @@ -51,10 +52,11 @@ class ColorPrimaries: """ CIE xy chromaticity coordinates for RGB primaries and white point. """ - red: tuple[float, float] # (x, y) - green: tuple[float, float] # (x, y) - blue: tuple[float, float] # (x, y) - white: tuple[float, float] # (x, y) + + red: tuple[float, float] # (x, y) + green: tuple[float, float] # (x, y) + blue: tuple[float, float] # (x, y) + white: tuple[float, float] # (x, y) def to_matrix(self) -> np.ndarray: """ @@ -75,11 +77,7 @@ def to_matrix(self) -> np.ndarray: Xb, Yb, Zb = xb / yb, 1.0, (1 - xb - yb) / yb # Primaries matrix - P = np.array([ - [Xr, Xg, Xb], - [Yr, Yg, Yb], - [Zr, Zg, Zb] - ]) + P = np.array([[Xr, Xg, Xb], [Yr, Yg, Yb], [Zr, Zg, Zb]]) # White point XYZ Xw = xw / yw @@ -110,31 +108,19 @@ def get_gamut_area(self) -> float: x = [self.red[0], self.green[0], self.blue[0]] y = [self.red[1], self.green[1], self.blue[1]] - area = 0.5 * abs( - (x[0] * (y[1] - y[2])) + - (x[1] * (y[2] - y[0])) + - (x[2] * (y[0] - y[1])) - ) + area = 0.5 * abs((x[0] * (y[1] - y[2])) + (x[1] * (y[2] - y[0])) + (x[2] * (y[0] - y[1]))) return area def to_dict(self) -> dict: """Serialize to dictionary.""" - return { - "red": self.red, - "green": self.green, - "blue": self.blue, - "white": self.white - } + return {"red": self.red, "green": self.green, "blue": self.blue, "white": self.white} @classmethod def from_dict(cls, data: dict) -> "ColorPrimaries": """Create from dictionary.""" return cls( - red=tuple(data["red"]), - green=tuple(data["green"]), - blue=tuple(data["blue"]), - white=tuple(data["white"]) + red=tuple(data["red"]), green=tuple(data["green"]), blue=tuple(data["blue"]), white=tuple(data["white"]) ) @@ -146,7 +132,7 @@ def from_dict(cls, data: dict) -> "ColorPrimaries": red=(0.6400, 0.3300), green=(0.3000, 0.6000), blue=(0.1500, 0.0600), - white=(0.3127, 0.3290) # D65 + white=(0.3127, 0.3290), # D65 ) PRIMARIES_REC709 = PRIMARIES_SRGB # Identical primaries @@ -155,14 +141,14 @@ def from_dict(cls, data: dict) -> "ColorPrimaries": red=(0.6800, 0.3200), green=(0.2650, 0.6900), blue=(0.1500, 0.0600), - white=(0.3127, 0.3290) # D65 for Display P3 + white=(0.3127, 0.3290), # D65 for Display P3 ) PRIMARIES_DCI_P3_THEATER = ColorPrimaries( red=(0.6800, 0.3200), green=(0.2650, 0.6900), blue=(0.1500, 0.0600), - white=(0.3140, 0.3510) # DCI white (6300K) + white=(0.3140, 0.3510), # DCI white (6300K) ) PRIMARIES_DISPLAY_P3 = PRIMARIES_DCI_P3 # Same as DCI-P3 D65 @@ -171,42 +157,42 @@ def from_dict(cls, data: dict) -> "ColorPrimaries": red=(0.6400, 0.3300), green=(0.2100, 0.7100), blue=(0.1500, 0.0600), - white=(0.3127, 0.3290) # D65 + white=(0.3127, 0.3290), # D65 ) PRIMARIES_BT2020 = ColorPrimaries( red=(0.7080, 0.2920), green=(0.1700, 0.7970), blue=(0.1310, 0.0460), - white=(0.3127, 0.3290) # D65 + white=(0.3127, 0.3290), # D65 ) PRIMARIES_PROPHOTO = ColorPrimaries( red=(0.7347, 0.2653), green=(0.1596, 0.8404), blue=(0.0366, 0.0001), - white=(0.3457, 0.3585) # D50 + white=(0.3457, 0.3585), # D50 ) PRIMARIES_ACES_CG = ColorPrimaries( red=(0.713, 0.293), green=(0.165, 0.830), blue=(0.128, 0.044), - white=(0.32168, 0.33767) # D60 (ACES white) + white=(0.32168, 0.33767), # D60 (ACES white) ) PRIMARIES_NTSC_1953 = ColorPrimaries( red=(0.6700, 0.3300), green=(0.2100, 0.7100), blue=(0.1400, 0.0800), - white=(0.3100, 0.3160) # Illuminant C + white=(0.3100, 0.3160), # Illuminant C ) PRIMARIES_PAL_SECAM = ColorPrimaries( red=(0.6400, 0.3300), green=(0.2900, 0.6000), blue=(0.1500, 0.0600), - white=(0.3127, 0.3290) # D65 + white=(0.3127, 0.3290), # D65 ) # Lookup table @@ -234,10 +220,7 @@ def from_dict(cls, data: dict) -> "ColorPrimaries": } -def calculate_gamut_coverage( - display_primaries: ColorPrimaries, - reference_primaries: ColorPrimaries -) -> float: +def calculate_gamut_coverage(display_primaries: ColorPrimaries, reference_primaries: ColorPrimaries) -> float: """ Calculate how much of a reference gamut is covered by display primaries. @@ -258,10 +241,7 @@ def calculate_gamut_coverage( return (display_area / reference_area) * 100 -def calculate_gamut_volume_coverage( - display_primaries: ColorPrimaries, - reference_primaries: ColorPrimaries -) -> float: +def calculate_gamut_volume_coverage(display_primaries: ColorPrimaries, reference_primaries: ColorPrimaries) -> float: """ Calculate 3D gamut volume coverage. @@ -282,7 +262,7 @@ def calculate_gamut_volume_coverage( # Simplified approximation using area ratio raised to 1.5 # (accounts for 3D nature of gamut) area_ratio = calculate_gamut_coverage(display_primaries, reference_primaries) / 100 - return (area_ratio ** 1.5) * 100 + return (area_ratio**1.5) * 100 @dataclass @@ -301,6 +281,7 @@ class GamutTarget: target_coverage_bt2020: Target BT.2020 coverage % tolerance_delta_xy: Acceptable primary deviation """ + preset: GamutPreset = GamutPreset.SRGB primaries: ColorPrimaries | None = None @@ -358,17 +339,17 @@ def get_bt2020_coverage(self) -> float: def is_wide_gamut(self) -> bool: """Check if this is a wide gamut target (wider than sRGB).""" return self.preset in { - GamutPreset.DCI_P3, GamutPreset.DCI_P3_THEATER, - GamutPreset.DISPLAY_P3, GamutPreset.ADOBE_RGB, - GamutPreset.BT2020, GamutPreset.PROPHOTO, - GamutPreset.ACES_CG + GamutPreset.DCI_P3, + GamutPreset.DCI_P3_THEATER, + GamutPreset.DISPLAY_P3, + GamutPreset.ADOBE_RGB, + GamutPreset.BT2020, + GamutPreset.PROPHOTO, + GamutPreset.ACES_CG, } def verify_primaries( - self, - measured_red: tuple[float, float], - measured_green: tuple[float, float], - measured_blue: tuple[float, float] + self, measured_red: tuple[float, float], measured_green: tuple[float, float], measured_blue: tuple[float, float] ) -> dict: """ Verify measured primaries against target. @@ -385,7 +366,7 @@ def verify_primaries( # Calculate chromaticity errors def delta_xy(target: tuple[float, float], measured: tuple[float, float]) -> float: - return np.sqrt((target[0] - measured[0])**2 + (target[1] - measured[1])**2) + return np.sqrt((target[0] - measured[0]) ** 2 + (target[1] - measured[1]) ** 2) red_error = delta_xy(target.red, measured_red) green_error = delta_xy(target.green, measured_green) @@ -398,32 +379,17 @@ def delta_xy(target: tuple[float, float], measured: tuple[float, float]) -> floa passed = max_error <= self.tolerance_delta_xy return { - "target_primaries": { - "red": target.red, - "green": target.green, - "blue": target.blue - }, - "measured_primaries": { - "red": measured_red, - "green": measured_green, - "blue": measured_blue - }, - "errors": { - "red": red_error, - "green": green_error, - "blue": blue_error - }, + "target_primaries": {"red": target.red, "green": target.green, "blue": target.blue}, + "measured_primaries": {"red": measured_red, "green": measured_green, "blue": measured_blue}, + "errors": {"red": red_error, "green": green_error, "blue": blue_error}, "average_delta_xy": avg_error, "max_delta_xy": max_error, "tolerance": self.tolerance_delta_xy, "passed": passed, - "grade": self._grade_result(max_error) + "grade": self._grade_result(max_error), } - def verify_coverage( - self, - display_primaries: ColorPrimaries - ) -> dict: + def verify_coverage(self, display_primaries: ColorPrimaries) -> dict: """ Verify gamut coverage of display. @@ -448,7 +414,7 @@ def verify_coverage( "target_coverage": target_cov, "display_area": display_primaries.get_gamut_area(), "is_wide_gamut": srgb_cov > 100, - "grade": self._grade_coverage(srgb_cov, p3_cov) + "grade": self._grade_coverage(srgb_cov, p3_cov), } def _grade_result(self, max_error: float) -> str: @@ -484,7 +450,7 @@ def to_dict(self) -> dict: "description": self.description, "target_primaries": self.get_primaries().to_dict(), "gamut_area": self.get_gamut_area(), - "is_wide_gamut": self.is_wide_gamut() + "is_wide_gamut": self.is_wide_gamut(), } @classmethod @@ -498,7 +464,7 @@ def from_dict(cls, data: dict) -> "GamutTarget": preset=GamutPreset(data.get("preset", "sRGB")), primaries=primaries, name=data.get("name", ""), - description=data.get("description", "") + description=data.get("description", ""), ) @@ -510,7 +476,7 @@ def from_dict(cls, data: dict) -> "GamutTarget": preset=GamutPreset.SRGB, target_coverage_srgb=100.0, name="sRGB (Rec.709)", - description="Standard web and consumer content" + description="Standard web and consumer content", ) GAMUT_DCI_P3 = GamutTarget( @@ -518,7 +484,7 @@ def from_dict(cls, data: dict) -> "GamutTarget": target_coverage_srgb=100.0, target_coverage_p3=100.0, name="DCI-P3 (D65)", - description="Wide gamut for cinema and HDR content" + description="Wide gamut for cinema and HDR content", ) GAMUT_DCI_P3_THEATER = GamutTarget( @@ -526,7 +492,7 @@ def from_dict(cls, data: dict) -> "GamutTarget": target_coverage_srgb=100.0, target_coverage_p3=100.0, name="DCI-P3 (Theater)", - description="Digital cinema with 6300K white point" + description="Digital cinema with 6300K white point", ) GAMUT_DISPLAY_P3 = GamutTarget( @@ -534,14 +500,14 @@ def from_dict(cls, data: dict) -> "GamutTarget": target_coverage_srgb=100.0, target_coverage_p3=100.0, name="Display P3", - description="Apple Display P3 (wide gamut with D65)" + description="Apple Display P3 (wide gamut with D65)", ) GAMUT_ADOBE_RGB = GamutTarget( preset=GamutPreset.ADOBE_RGB, target_coverage_srgb=100.0, name="Adobe RGB (1998)", - description="Wide gamut for photography" + description="Wide gamut for photography", ) GAMUT_BT2020 = GamutTarget( @@ -550,19 +516,15 @@ def from_dict(cls, data: dict) -> "GamutTarget": target_coverage_p3=100.0, target_coverage_bt2020=100.0, name="BT.2020 (Rec.2020)", - description="Ultra-wide gamut for UHD/HDR" + description="Ultra-wide gamut for UHD/HDR", ) GAMUT_PROPHOTO = GamutTarget( - preset=GamutPreset.PROPHOTO, - name="ProPhoto RGB", - description="Very wide gamut for photography archival" + preset=GamutPreset.PROPHOTO, name="ProPhoto RGB", description="Very wide gamut for photography archival" ) GAMUT_ACES_CG = GamutTarget( - preset=GamutPreset.ACES_CG, - name="ACEScg", - description="ACES computer graphics working space" + preset=GamutPreset.ACES_CG, name="ACEScg", description="ACES computer graphics working space" ) @@ -598,7 +560,7 @@ def create_custom_gamut( green: tuple[float, float], blue: tuple[float, float], white: tuple[float, float] = (0.3127, 0.3290), - name: str = "Custom" + name: str = "Custom", ) -> GamutTarget: """ Create a custom gamut target. @@ -613,18 +575,9 @@ def create_custom_gamut( Returns: GamutTarget """ - primaries = ColorPrimaries( - red=red, - green=green, - blue=blue, - white=white - ) + primaries = ColorPrimaries(red=red, green=green, blue=blue, white=white) - return GamutTarget( - preset=GamutPreset.CUSTOM, - primaries=primaries, - name=name - ) + return GamutTarget(preset=GamutPreset.CUSTOM, primaries=primaries, name=name) def get_gamut_comparison(gamut1: GamutTarget, gamut2: GamutTarget) -> dict: @@ -656,5 +609,5 @@ def get_gamut_comparison(gamut1: GamutTarget, gamut2: GamutTarget) -> dict: "area_ratio": area1 / area2, "gamut1_covers_gamut2": cov_1_of_2, "gamut2_covers_gamut1": cov_2_of_1, - "larger": gamut1.name if area1 > area2 else gamut2.name + "larger": gamut1.name if area1 > area2 else gamut2.name, } diff --git a/calibrate_pro/targets/luminance.py b/calibrate_pro/targets/luminance.py index 39684b5..1c2b64b 100644 --- a/calibrate_pro/targets/luminance.py +++ b/calibrate_pro/targets/luminance.py @@ -20,37 +20,39 @@ class LuminanceStandard(Enum): """Industry luminance standards.""" + # SDR Standards - SDR_GENERAL = "SDR General" # 80-120 cd/m2 - REC709_BROADCAST = "Rec.709 Broadcast" # 100 cd/m2 (EBU Tech 3320) - SMPTE_RP166 = "SMPTE RP 166" # 35 cd/m2 (film grading) - EBU_GRADE1 = "EBU Grade 1" # 100 cd/m2 - DCI_P3_CINEMA = "DCI-P3 Cinema" # 48 cd/m2 (14 fL) + SDR_GENERAL = "SDR General" # 80-120 cd/m2 + REC709_BROADCAST = "Rec.709 Broadcast" # 100 cd/m2 (EBU Tech 3320) + SMPTE_RP166 = "SMPTE RP 166" # 35 cd/m2 (film grading) + EBU_GRADE1 = "EBU Grade 1" # 100 cd/m2 + DCI_P3_CINEMA = "DCI-P3 Cinema" # 48 cd/m2 (14 fL) # HDR Standards - HDR10 = "HDR10" # 1000+ cd/m2 peak - HDR10_PLUS = "HDR10+" # 1000-4000 cd/m2 - DOLBY_VISION = "Dolby Vision" # 1000-10000 cd/m2 - HLG = "HLG" # 1000+ cd/m2 - HDR_REFERENCE = "HDR Reference" # 1000 cd/m2 mastering + HDR10 = "HDR10" # 1000+ cd/m2 peak + HDR10_PLUS = "HDR10+" # 1000-4000 cd/m2 + DOLBY_VISION = "Dolby Vision" # 1000-10000 cd/m2 + HLG = "HLG" # 1000+ cd/m2 + HDR_REFERENCE = "HDR Reference" # 1000 cd/m2 mastering # Consumer Displays - CONSUMER_SDR = "Consumer SDR" # 200-350 cd/m2 - CONSUMER_HDR = "Consumer HDR" # 400-1000 cd/m2 + CONSUMER_SDR = "Consumer SDR" # 200-350 cd/m2 + CONSUMER_HDR = "Consumer HDR" # 400-1000 cd/m2 # Professional Reference - REFERENCE_GRADE = "Reference Grade" # Calibrated to standard - NATIVE = "Native" # Display maximum + REFERENCE_GRADE = "Reference Grade" # Calibrated to standard + NATIVE = "Native" # Display maximum CUSTOM = "Custom" class BlackLevelStandard(Enum): """Black level reference standards.""" - ABSOLUTE_BLACK = "Absolute Black" # 0.0001 cd/m2 (OLED ideal) - REFERENCE_BLACK = "Reference Black" # 0.005 cd/m2 (high-end reference) - BROADCAST_BLACK = "Broadcast Black" # 0.01 cd/m2 - CONSUMER_LCD = "Consumer LCD" # 0.05-0.1 cd/m2 - NATIVE = "Native" # Display native + + ABSOLUTE_BLACK = "Absolute Black" # 0.0001 cd/m2 (OLED ideal) + REFERENCE_BLACK = "Reference Black" # 0.005 cd/m2 (high-end reference) + BROADCAST_BLACK = "Broadcast Black" # 0.01 cd/m2 + CONSUMER_LCD = "Consumer LCD" # 0.05-0.1 cd/m2 + NATIVE = "Native" # Display native CUSTOM = "Custom" @@ -62,37 +64,36 @@ class BlackLevelStandard(Enum): "reference_white": 100.0, "min_black": 0.05, "contrast": 1000, - "description": "General SDR content viewing" + "description": "General SDR content viewing", }, "Rec.709 Broadcast": { "peak": 100.0, "reference_white": 100.0, "min_black": 0.01, "contrast": 10000, - "description": "EBU Tech 3320 broadcast reference" + "description": "EBU Tech 3320 broadcast reference", }, "SMPTE RP 166": { "peak": 35.0, "reference_white": 35.0, "min_black": 0.005, "contrast": 7000, - "description": "Film grading in dark room (14 fL ambient)" + "description": "Film grading in dark room (14 fL ambient)", }, "EBU Grade 1": { "peak": 100.0, "reference_white": 100.0, "min_black": 0.01, "contrast": 10000, - "description": "EBU Grade 1 reference monitor" + "description": "EBU Grade 1 reference monitor", }, "DCI-P3 Cinema": { "peak": 48.0, "reference_white": 48.0, "min_black": 0.001, "contrast": 2000, - "description": "Digital Cinema (14 fL / 48 cd/m2)" + "description": "Digital Cinema (14 fL / 48 cd/m2)", }, - # HDR Standards "HDR10": { "peak": 1000.0, @@ -101,51 +102,50 @@ class BlackLevelStandard(Enum): "contrast": 200000, "max_cll": 1000, "max_fall": 400, - "description": "HDR10 mastering standard" + "description": "HDR10 mastering standard", }, "HDR10+": { "peak": 4000.0, "reference_white": 203.0, "min_black": 0.0005, "contrast": 8000000, - "description": "HDR10+ with dynamic metadata" + "description": "HDR10+ with dynamic metadata", }, "Dolby Vision": { "peak": 4000.0, "reference_white": 203.0, "min_black": 0.0001, "contrast": 40000000, - "description": "Dolby Vision mastering" + "description": "Dolby Vision mastering", }, "HLG": { "peak": 1000.0, "reference_white": 203.0, # At 1000 nit display "min_black": 0.005, "contrast": 200000, - "description": "Hybrid Log-Gamma broadcast HDR" + "description": "Hybrid Log-Gamma broadcast HDR", }, "HDR Reference": { "peak": 1000.0, "reference_white": 203.0, "min_black": 0.005, "contrast": 200000, - "description": "Standard HDR reference mastering" + "description": "Standard HDR reference mastering", }, - # Consumer "Consumer SDR": { "peak": 300.0, "reference_white": 200.0, "min_black": 0.1, "contrast": 3000, - "description": "Typical consumer SDR display" + "description": "Typical consumer SDR display", }, "Consumer HDR": { "peak": 600.0, "reference_white": 203.0, "min_black": 0.05, "contrast": 12000, - "description": "Typical consumer HDR display" + "description": "Typical consumer HDR display", }, } @@ -163,7 +163,7 @@ def footlamberts_to_nits(fl: float) -> float: def calculate_contrast_ratio(peak: float, black: float) -> float: """Calculate contrast ratio from peak and black level.""" if black <= 0: - return float('inf') + return float("inf") return peak / black @@ -197,6 +197,7 @@ class LuminanceTarget: surround_luminance: Viewing environment (dark/dim/average) tolerance_percent: Acceptable deviation percentage """ + standard: LuminanceStandard = LuminanceStandard.SDR_GENERAL peak_luminance: float | None = None # cd/m2 reference_white: float | None = None # cd/m2 @@ -241,8 +242,7 @@ def get_reference_white(self) -> float: standard_name = self.standard.value if standard_name in LUMINANCE_STANDARDS: - return LUMINANCE_STANDARDS[standard_name].get("reference_white", - LUMINANCE_STANDARDS[standard_name]["peak"]) + return LUMINANCE_STANDARDS[standard_name].get("reference_white", LUMINANCE_STANDARDS[standard_name]["peak"]) return self.get_peak_luminance() @@ -287,7 +287,7 @@ def get_hdr_metadata(self) -> dict: "MaxFALL": max_fall, "MinLuminance": self.get_black_level(), "MaxLuminance": peak, - "ReferenceWhite": self.get_reference_white() + "ReferenceWhite": self.get_reference_white(), } def is_hdr(self) -> bool: @@ -300,7 +300,7 @@ def is_hdr(self) -> bool: LuminanceStandard.HDR10_PLUS, LuminanceStandard.DOLBY_VISION, LuminanceStandard.HLG, - LuminanceStandard.HDR_REFERENCE + LuminanceStandard.HDR_REFERENCE, } return self.standard in hdr_standards @@ -336,21 +336,17 @@ def verify(self, measured_peak: float, measured_black: float) -> dict: "measured_peak": measured_peak, "peak_error_percent": peak_error, "peak_passed": peak_pass, - "target_black": target_black, "measured_black": measured_black, "black_error_percent": black_error, - "target_contrast": target_contrast, "measured_contrast": measured_contrast, "contrast_error_percent": contrast_error, "contrast_passed": contrast_pass, - "dynamic_range_stops": self.get_dynamic_range_stops(), "measured_dr_stops": np.log2(measured_contrast) if measured_contrast > 1 else 0, - "overall_passed": peak_pass and contrast_pass, - "grade": self._grade_result(peak_error, contrast_error) + "grade": self._grade_result(peak_error, contrast_error), } def _grade_result(self, peak_error: float, contrast_error: float) -> str: @@ -381,7 +377,7 @@ def to_dict(self) -> dict: "computed_peak": self.get_peak_luminance(), "computed_black": self.get_black_level(), "computed_contrast": self.get_contrast_ratio(), - "is_hdr": self.is_hdr() + "is_hdr": self.is_hdr(), } @classmethod @@ -397,7 +393,7 @@ def from_dict(cls, data: dict) -> "LuminanceTarget": max_cll=data.get("max_cll"), max_fall=data.get("max_fall"), name=data.get("name", ""), - description=data.get("description", "") + description=data.get("description", ""), ) @@ -408,6 +404,7 @@ class GrayscaleTarget: Defines expected luminance at each grayscale level based on gamma/EOTF. """ + gamma: float = 2.2 peak_luminance: float = 100.0 black_level: float = 0.0 @@ -429,13 +426,13 @@ def get_luminance(self, level: float) -> float: lb = self.black_level # BT.1886 formula - a = (lw ** (1/2.4) - lb ** (1/2.4)) ** 2.4 - b = lb ** (1/2.4) / (lw ** (1/2.4) - lb ** (1/2.4)) + a = (lw ** (1 / 2.4) - lb ** (1 / 2.4)) ** 2.4 + b = lb ** (1 / 2.4) / (lw ** (1 / 2.4) - lb ** (1 / 2.4)) luminance = a * max(level + b, 0) ** 2.4 else: # Simple power law - luminance = self.black_level + (self.peak_luminance - self.black_level) * (level ** self.gamma) + luminance = self.black_level + (self.peak_luminance - self.black_level) * (level**self.gamma) return luminance @@ -444,11 +441,7 @@ def get_grayscale_ramp(self, steps: int = 21) -> list[dict]: ramp = [] for i in range(steps): level = i / (steps - 1) - ramp.append({ - "level": level, - "target_luminance": self.get_luminance(level), - "level_percent": level * 100 - }) + ramp.append({"level": level, "target_luminance": self.get_luminance(level), "level_percent": level * 100}) return ramp @@ -460,21 +453,21 @@ def get_grayscale_ramp(self, steps: int = 21) -> list[dict]: LUMINANCE_REC709 = LuminanceTarget( standard=LuminanceStandard.REC709_BROADCAST, name="Rec.709 Broadcast", - description="EBU Tech 3320 broadcast reference (100 cd/m2)" + description="EBU Tech 3320 broadcast reference (100 cd/m2)", ) # Film Grading LUMINANCE_FILM = LuminanceTarget( standard=LuminanceStandard.SMPTE_RP166, name="Film Grading (SMPTE RP 166)", - description="Dark room film grading (35 cd/m2 / 10 fL)" + description="Dark room film grading (35 cd/m2 / 10 fL)", ) # DCI Cinema LUMINANCE_DCI = LuminanceTarget( standard=LuminanceStandard.DCI_P3_CINEMA, name="DCI-P3 Cinema", - description="Digital Cinema Initiative (48 cd/m2 / 14 fL)" + description="Digital Cinema Initiative (48 cd/m2 / 14 fL)", ) # HDR10 Mastering @@ -482,7 +475,7 @@ def get_grayscale_ramp(self, steps: int = 21) -> list[dict]: standard=LuminanceStandard.HDR10, hdr_mode=True, name="HDR10 Mastering", - description="HDR10 mastering (1000 cd/m2 peak)" + description="HDR10 mastering (1000 cd/m2 peak)", ) # HDR10+ High-End @@ -491,7 +484,7 @@ def get_grayscale_ramp(self, steps: int = 21) -> list[dict]: peak_luminance=4000.0, hdr_mode=True, name="HDR10+ High-End", - description="HDR10+ with dynamic metadata (4000 cd/m2 peak)" + description="HDR10+ with dynamic metadata (4000 cd/m2 peak)", ) # Dolby Vision @@ -499,7 +492,7 @@ def get_grayscale_ramp(self, steps: int = 21) -> list[dict]: standard=LuminanceStandard.DOLBY_VISION, hdr_mode=True, name="Dolby Vision Mastering", - description="Dolby Vision reference (4000 cd/m2 peak)" + description="Dolby Vision reference (4000 cd/m2 peak)", ) # Consumer SDR @@ -507,7 +500,7 @@ def get_grayscale_ramp(self, steps: int = 21) -> list[dict]: standard=LuminanceStandard.CONSUMER_SDR, peak_luminance=250.0, name="Consumer SDR", - description="Typical consumer SDR viewing (250 cd/m2)" + description="Typical consumer SDR viewing (250 cd/m2)", ) # Consumer HDR @@ -516,7 +509,7 @@ def get_grayscale_ramp(self, steps: int = 21) -> list[dict]: peak_luminance=600.0, hdr_mode=True, name="Consumer HDR", - description="Typical consumer HDR viewing (600 cd/m2 peak)" + description="Typical consumer HDR viewing (600 cd/m2 peak)", ) @@ -545,11 +538,7 @@ def get_hdr_presets() -> list[LuminanceTarget]: def create_custom_luminance( - peak: float, - black: float = 0.0, - reference_white: float | None = None, - hdr_mode: bool = False, - name: str = "Custom" + peak: float, black: float = 0.0, reference_white: float | None = None, hdr_mode: bool = False, name: str = "Custom" ) -> LuminanceTarget: """ Create a custom luminance target. @@ -573,15 +562,12 @@ def create_custom_luminance( black_level=black, reference_white=reference_white, hdr_mode=hdr_mode, - name=name + name=name, ) def calculate_recommended_luminance( - viewing_distance_m: float, - screen_diagonal_inches: float, - ambient_lux: float = 50.0, - hdr: bool = False + viewing_distance_m: float, screen_diagonal_inches: float, ambient_lux: float = 50.0, hdr: bool = False ) -> dict: """ Calculate recommended luminance based on viewing conditions. @@ -628,5 +614,5 @@ def calculate_recommended_luminance( "recommended_black": 0.005 if hdr else 0.05, "viewing_angle_degrees": np.degrees(view_angle) * 2, "ambient_lux": ambient_lux, - "hdr_mode": hdr + "hdr_mode": hdr, } diff --git a/calibrate_pro/targets/whitepoint.py b/calibrate_pro/targets/whitepoint.py index e82bdc2..f5e2958 100644 --- a/calibrate_pro/targets/whitepoint.py +++ b/calibrate_pro/targets/whitepoint.py @@ -20,27 +20,28 @@ class WhitepointPreset(Enum): """Standard white point presets.""" + # CIE Standard Illuminants - D50 = "D50" # 5003K - Print/Photography standard (ICC PCS) - D55 = "D55" # 5503K - Daylight, motion picture - D60 = "D60" # 6000K - ACES reference - D65 = "D65" # 6504K - sRGB/Broadcast/Web standard - D75 = "D75" # 7504K - North sky daylight - D93 = "D93" # 9300K - Legacy CRT + D50 = "D50" # 5003K - Print/Photography standard (ICC PCS) + D55 = "D55" # 5503K - Daylight, motion picture + D60 = "D60" # 6000K - ACES reference + D65 = "D65" # 6504K - sRGB/Broadcast/Web standard + D75 = "D75" # 7504K - North sky daylight + D93 = "D93" # 9300K - Legacy CRT # Industry Standards - DCI = "DCI-P3" # 6300K - Digital Cinema - ACES = "ACES" # D60 (6000K) - Academy Color Encoding System + DCI = "DCI-P3" # 6300K - Digital Cinema + ACES = "ACES" # D60 (6000K) - Academy Color Encoding System # Tungsten/Studio - A = "Illuminant A" # 2856K - Incandescent/Tungsten - B = "Illuminant B" # 4874K - Direct sunlight - C = "Illuminant C" # 6774K - Average daylight + A = "Illuminant A" # 2856K - Incandescent/Tungsten + B = "Illuminant B" # 4874K - Direct sunlight + C = "Illuminant C" # 6774K - Average daylight # Custom - NATIVE = "Native" # Display native white point - CUSTOM_CCT = "Custom CCT" # User-defined CCT - CUSTOM_XY = "Custom xy" # User-defined chromaticity + NATIVE = "Native" # Display native white point + CUSTOM_CCT = "Custom CCT" # User-defined CCT + CUSTOM_XY = "Custom xy" # User-defined chromaticity # Standard illuminant chromaticity coordinates (x, y) @@ -52,13 +53,11 @@ class WhitepointPreset(Enum): "D65": (0.31270, 0.32900), "D75": (0.29902, 0.31485), "D93": (0.28315, 0.29711), - # Other CIE illuminants "A": (0.44757, 0.40745), "B": (0.34842, 0.35161), "C": (0.31006, 0.31616), "E": (0.33333, 0.33333), # Equal energy - # Industry "DCI-P3": (0.31400, 0.35100), "ACES": (0.32168, 0.33767), # Same as D60 @@ -98,10 +97,12 @@ def planckian_locus_xy(cct: float) -> tuple[float, float]: cct = 25000 # Krystek's algorithm - u = (0.860117757 + 1.54118254e-4 * cct + 1.28641212e-7 * cct**2) / \ - (1 + 8.42420235e-4 * cct + 7.08145163e-7 * cct**2) - v = (0.317398726 + 4.22806245e-5 * cct + 4.20481691e-8 * cct**2) / \ - (1 - 2.89741816e-5 * cct + 1.61456053e-7 * cct**2) + u = (0.860117757 + 1.54118254e-4 * cct + 1.28641212e-7 * cct**2) / ( + 1 + 8.42420235e-4 * cct + 7.08145163e-7 * cct**2 + ) + v = (0.317398726 + 4.22806245e-5 * cct + 4.20481691e-8 * cct**2) / ( + 1 - 2.89741816e-5 * cct + 1.61456053e-7 * cct**2 + ) # Convert CIE 1960 UCS to xy x = 3 * u / (2 * u - 8 * v + 4) @@ -221,7 +222,7 @@ def xy_to_cct(x: float, y: float) -> tuple[float, float]: u_locus = 4 * x_locus / (-2 * x_locus + 12 * y_locus + 3) v_locus = 6 * y_locus / (-2 * x_locus + 12 * y_locus + 3) - duv = np.sqrt((u - u_locus)**2 + (v - v_locus)**2) + duv = np.sqrt((u - u_locus) ** 2 + (v - v_locus) ** 2) # Determine sign (+ = above locus = green, - = below = magenta) if v < v_locus: @@ -273,6 +274,7 @@ class WhitepointTarget: use_daylight_locus: Use daylight locus for CCT conversion tolerance: Acceptable Delta uv for verification """ + preset: WhitepointPreset = WhitepointPreset.D65 cct: float | None = None duv: float = 0.0 @@ -346,7 +348,7 @@ def verify(self, measured_xy: tuple[float, float]) -> dict: u_measured = 4 * measured_xy[0] / (-2 * measured_xy[0] + 12 * measured_xy[1] + 3) v_measured = 6 * measured_xy[1] / (-2 * measured_xy[0] + 12 * measured_xy[1] + 3) - delta_uv = np.sqrt((u_target - u_measured)**2 + (v_target - v_measured)**2) + delta_uv = np.sqrt((u_target - u_measured) ** 2 + (v_target - v_measured) ** 2) # Calculate CCT and Duv of measurement measured_cct, measured_duv = xy_to_cct(measured_xy[0], measured_xy[1]) @@ -364,7 +366,7 @@ def verify(self, measured_xy: tuple[float, float]) -> dict: "delta_uv": delta_uv, "tolerance": self.tolerance, "passed": passed, - "grade": self._grade_result(delta_uv) + "grade": self._grade_result(delta_uv), } def _grade_result(self, delta_uv: float) -> str: @@ -388,7 +390,7 @@ def to_dict(self) -> dict: "name": self.name, "description": self.description, "target_xy": self.get_xy(), - "target_cct": self.get_cct() + "target_cct": self.get_cct(), } @classmethod @@ -400,7 +402,7 @@ def from_dict(cls, data: dict) -> "WhitepointTarget": duv=data.get("duv", 0.0), xy=data.get("xy"), name=data.get("name", ""), - description=data.get("description", "") + description=data.get("description", ""), ) @@ -412,35 +414,27 @@ def from_dict(cls, data: dict) -> "WhitepointTarget": WHITEPOINT_D50 = WhitepointTarget( preset=WhitepointPreset.D50, name="D50 (Print Standard)", - description="ICC Profile Connection Space, photography standard" + description="ICC Profile Connection Space, photography standard", ) # Broadcast/Web WHITEPOINT_D65 = WhitepointTarget( - preset=WhitepointPreset.D65, - name="D65 (sRGB/Broadcast)", - description="sRGB, Rec.709, web content standard" + preset=WhitepointPreset.D65, name="D65 (sRGB/Broadcast)", description="sRGB, Rec.709, web content standard" ) # Digital Cinema WHITEPOINT_DCI = WhitepointTarget( - preset=WhitepointPreset.DCI, - name="DCI-P3 (Digital Cinema)", - description="Digital Cinema Initiative standard" + preset=WhitepointPreset.DCI, name="DCI-P3 (Digital Cinema)", description="Digital Cinema Initiative standard" ) # ACES/VFX WHITEPOINT_ACES = WhitepointTarget( - preset=WhitepointPreset.ACES, - name="D60 (ACES)", - description="Academy Color Encoding System" + preset=WhitepointPreset.ACES, name="D60 (ACES)", description="Academy Color Encoding System" ) # North Sky WHITEPOINT_D75 = WhitepointTarget( - preset=WhitepointPreset.D75, - name="D75 (North Sky)", - description="North sky daylight, cooler than D65" + preset=WhitepointPreset.D75, name="D75 (North Sky)", description="North sky daylight, cooler than D65" ) @@ -459,10 +453,7 @@ def get_whitepoint_presets() -> list[WhitepointTarget]: def create_custom_whitepoint( - cct: float | None = None, - xy: tuple[float, float] | None = None, - duv: float = 0.0, - name: str = "Custom" + cct: float | None = None, xy: tuple[float, float] | None = None, duv: float = 0.0, name: str = "Custom" ) -> WhitepointTarget: """ Create a custom white point target. @@ -477,18 +468,8 @@ def create_custom_whitepoint( WhitepointTarget """ if xy is not None: - return WhitepointTarget( - preset=WhitepointPreset.CUSTOM_XY, - xy=xy, - duv=duv, - name=name - ) + return WhitepointTarget(preset=WhitepointPreset.CUSTOM_XY, xy=xy, duv=duv, name=name) elif cct is not None: - return WhitepointTarget( - preset=WhitepointPreset.CUSTOM_CCT, - cct=cct, - duv=duv, - name=name - ) + return WhitepointTarget(preset=WhitepointPreset.CUSTOM_CCT, cct=cct, duv=duv, name=name) else: raise ValueError("Must specify either cct or xy") diff --git a/calibrate_pro/tray/tray_app.py b/calibrate_pro/tray/tray_app.py index 1ef417c..c823bc1 100644 --- a/calibrate_pro/tray/tray_app.py +++ b/calibrate_pro/tray/tray_app.py @@ -28,6 +28,7 @@ # Helpers # --------------------------------------------------------------------------- + def _get_config_dir() -> Path: """Return ``%APPDATA%/CalibratePro``.""" appdata = os.environ.get("APPDATA", os.path.expanduser("~")) @@ -74,6 +75,7 @@ def _is_calibrated() -> bool: return False try: import json + with open(config_file) as fh: data = json.load(fh) displays = data.get("displays", {}) @@ -86,10 +88,12 @@ def _is_calibrated() -> bool: # pystray path # --------------------------------------------------------------------------- + def _needs_recalibration() -> bool: """Return True if any display's calibration is older than 30 days.""" try: from calibrate_pro.services.drift_monitor import any_needs_recalibration + return any_needs_recalibration(max_age_days=30) except Exception: return False @@ -110,13 +114,13 @@ def _create_icon_image(calibrated: bool, stale: bool = False): draw = ImageDraw.Draw(img) if calibrated and stale: - fill = (255, 193, 7, 255) # Material amber / yellow + fill = (255, 193, 7, 255) # Material amber / yellow border = (255, 160, 0, 255) elif calibrated: - fill = (76, 175, 80, 255) # Material green + fill = (76, 175, 80, 255) # Material green border = (56, 142, 60, 255) else: - fill = (158, 158, 158, 255) # Grey + fill = (158, 158, 158, 255) # Grey border = (117, 117, 117, 255) margin = 4 @@ -131,6 +135,7 @@ def _create_icon_image(calibrated: bool, stale: bool = False): # Draw a small "C" letter in the centre try: from PIL import ImageFont # type: ignore + font = ImageFont.truetype("arial.ttf", 32) except Exception: font = ImageFont.load_default() @@ -156,25 +161,30 @@ def _run_pystray(): def on_calibrate_all(icon, item): """Run ``auto_calibrate_all`` in a background thread.""" + def _work(): try: from calibrate_pro.sensorless.auto_calibration import auto_calibrate_all + auto_calibrate_all() # Refresh icon colour -- freshly calibrated, not stale icon.icon = _create_icon_image(True, stale=False) icon.title = f"Calibrate Pro v{__version__}" except Exception as exc: logger.error("Calibrate all failed: %s", exc) + threading.Thread(target=_work, daemon=True).start() def on_restore(icon, item): """Reset calibrations on all displays.""" + def _work(): try: from calibrate_pro.panels.detection import ( enumerate_displays, reset_gamma_ramp, ) + for display in enumerate_displays(): try: reset_gamma_ramp(display.device_name) @@ -184,12 +194,14 @@ def _work(): icon.title = f"Calibrate Pro v{__version__}" except Exception as exc: logger.error("Restore defaults failed: %s", exc) + threading.Thread(target=_work, daemon=True).start() def on_show_status(icon, item): """Print calibration status to console.""" try: from calibrate_pro.services.drift_monitor import print_calibration_status + print_calibration_status() except Exception as exc: logger.error("Status check failed: %s", exc) @@ -202,6 +214,7 @@ def on_open_report(icon, item): def _startup_enabled(): try: from calibrate_pro.utils.startup_manager import is_auto_start_enabled + return is_auto_start_enabled() except Exception: return False @@ -213,6 +226,7 @@ def on_toggle_startup(icon, item): enable_auto_start, is_auto_start_enabled, ) + if is_auto_start_enabled(): disable_auto_start() else: @@ -262,6 +276,7 @@ def startup_text(item): # Apply saved calibrations on launch try: from calibrate_pro.startup.calibration_loader import apply_saved_calibrations + apply_saved_calibrations() except Exception: pass @@ -273,6 +288,7 @@ def startup_text(item): # Console-service fallback # --------------------------------------------------------------------------- + def _run_console_service(): """ Fallback when pystray is not installed. @@ -317,6 +333,7 @@ def _run_console_service(): # Public entry point # --------------------------------------------------------------------------- + def run_tray_app(): """ Run the system tray application. diff --git a/calibrate_pro/utils/auto_calibration.py b/calibrate_pro/utils/auto_calibration.py index ec2cc8f..96b7774 100644 --- a/calibrate_pro/utils/auto_calibration.py +++ b/calibrate_pro/utils/auto_calibration.py @@ -19,10 +19,7 @@ from typing import Any # Setup logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) @@ -30,11 +27,9 @@ # DDC/CI Structures and Functions # ============================================================================ + class PHYSICAL_MONITOR(Structure): - _fields_ = [ - ('hPhysicalMonitor', wintypes.HANDLE), - ('szPhysicalMonitorDescription', wintypes.WCHAR * 128) - ] + _fields_ = [("hPhysicalMonitor", wintypes.HANDLE), ("szPhysicalMonitorDescription", wintypes.WCHAR * 128)] class DDCController: @@ -65,19 +60,17 @@ def callback(hMonitor, hdcMonitor, lprcMonitor, dwData): physical = (PHYSICAL_MONITOR * num.value)() if self.dxva2.GetPhysicalMonitorsFromHMONITOR(hMonitor, num, physical): for i in range(num.value): - self.monitors.append({ - 'hMonitor': hMonitor, - 'physical': physical[i], - 'description': physical[i].szPhysicalMonitorDescription - }) + self.monitors.append( + { + "hMonitor": hMonitor, + "physical": physical[i], + "description": physical[i].szPhysicalMonitorDescription, + } + ) return True MONITORENUMPROC = ctypes.WINFUNCTYPE( - wintypes.BOOL, - wintypes.HMONITOR, - wintypes.HDC, - POINTER(wintypes.RECT), - wintypes.LPARAM + wintypes.BOOL, wintypes.HMONITOR, wintypes.HDC, POINTER(wintypes.RECT), wintypes.LPARAM ) self.user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(callback), 0) @@ -86,7 +79,7 @@ def get_vcp(self, monitor_idx: int, vcp_code: int) -> int | None: if monitor_idx >= len(self.monitors): return None - handle = self.monitors[monitor_idx]['physical'].hPhysicalMonitor + handle = self.monitors[monitor_idx]["physical"].hPhysicalMonitor vcp_type = wintypes.DWORD() current = wintypes.DWORD() maximum = wintypes.DWORD() @@ -102,7 +95,7 @@ def set_vcp(self, monitor_idx: int, vcp_code: int, value: int) -> bool: if monitor_idx >= len(self.monitors): return False - handle = self.monitors[monitor_idx]['physical'].hPhysicalMonitor + handle = self.monitors[monitor_idx]["physical"].hPhysicalMonitor return bool(self.dxva2.SetVCPFeature(handle, vcp_code, value)) def apply_calibration(self, monitor_idx: int, settings: dict[str, Any]) -> bool: @@ -110,8 +103,8 @@ def apply_calibration(self, monitor_idx: int, settings: dict[str, Any]) -> bool: success = True # Apply color preset - if 'color_preset' in settings: - if not self.set_vcp(monitor_idx, self.VCP_COLOR_PRESET, settings['color_preset']): + if "color_preset" in settings: + if not self.set_vcp(monitor_idx, self.VCP_COLOR_PRESET, settings["color_preset"]): logger.warning(f"Failed to set color preset to {settings['color_preset']}") success = False else: @@ -121,24 +114,26 @@ def apply_calibration(self, monitor_idx: int, settings: dict[str, Any]) -> bool: time.sleep(0.3) # Apply RGB gains - if 'red_gain' in settings: - if not self.set_vcp(monitor_idx, self.VCP_RED_GAIN, settings['red_gain']): + if "red_gain" in settings: + if not self.set_vcp(monitor_idx, self.VCP_RED_GAIN, settings["red_gain"]): logger.warning(f"Failed to set red gain to {settings['red_gain']}") success = False - if 'green_gain' in settings: - if not self.set_vcp(monitor_idx, self.VCP_GREEN_GAIN, settings['green_gain']): + if "green_gain" in settings: + if not self.set_vcp(monitor_idx, self.VCP_GREEN_GAIN, settings["green_gain"]): logger.warning(f"Failed to set green gain to {settings['green_gain']}") success = False - if 'blue_gain' in settings: - if not self.set_vcp(monitor_idx, self.VCP_BLUE_GAIN, settings['blue_gain']): + if "blue_gain" in settings: + if not self.set_vcp(monitor_idx, self.VCP_BLUE_GAIN, settings["blue_gain"]): logger.warning(f"Failed to set blue gain to {settings['blue_gain']}") success = False if success: - logger.info(f"RGB gains set to R={settings.get('red_gain', 100)}, " - f"G={settings.get('green_gain', 100)}, B={settings.get('blue_gain', 100)}") + logger.info( + f"RGB gains set to R={settings.get('red_gain', 100)}, " + f"G={settings.get('green_gain', 100)}, B={settings.get('blue_gain', 100)}" + ) return success @@ -146,7 +141,7 @@ def cleanup(self): """Release monitor handles.""" for mon in self.monitors: try: - physical_array = (PHYSICAL_MONITOR * 1)(mon['physical']) + physical_array = (PHYSICAL_MONITOR * 1)(mon["physical"]) self.dxva2.DestroyPhysicalMonitors(1, physical_array) except Exception: pass # Best-effort cleanup of monitor handles @@ -156,12 +151,13 @@ def cleanup(self): # ICC Profile Management # ============================================================================ + class ICCProfileManager: """Manages ICC profile installation and association.""" def __init__(self): self.mscms = ctypes.windll.mscms - self.profile_dir = Path(os.environ.get('WINDIR', 'C:/Windows')) / 'System32' / 'spool' / 'drivers' / 'color' + self.profile_dir = Path(os.environ.get("WINDIR", "C:/Windows")) / "System32" / "spool" / "drivers" / "color" def get_monitor_device_id(self, display_index: int = 0) -> str | None: """Get the device ID for a display's monitor.""" @@ -169,12 +165,12 @@ def get_monitor_device_id(self, display_index: int = 0) -> str | None: class DISPLAY_DEVICEW(Structure): _fields_ = [ - ('cb', wintypes.DWORD), - ('DeviceName', wintypes.WCHAR * 32), - ('DeviceString', wintypes.WCHAR * 128), - ('StateFlags', wintypes.DWORD), - ('DeviceID', wintypes.WCHAR * 128), - ('DeviceKey', wintypes.WCHAR * 128), + ("cb", wintypes.DWORD), + ("DeviceName", wintypes.WCHAR * 32), + ("DeviceString", wintypes.WCHAR * 128), + ("StateFlags", wintypes.DWORD), + ("DeviceID", wintypes.WCHAR * 128), + ("DeviceKey", wintypes.WCHAR * 128), ] display = DISPLAY_DEVICEW() @@ -201,9 +197,7 @@ def associate_profile(self, profile_name: str, device_id: str) -> bool: try: result = self.mscms.WcsAssociateColorProfileWithDevice( - WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER, - device_id, - profile_name + WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER, device_id, profile_name ) return bool(result) except Exception as e: @@ -222,7 +216,7 @@ def set_default_profile(self, profile_name: str, device_id: str) -> bool: CPT_ICC, 0, # subtype 0, # index - profile_name + profile_name, ) return bool(result) except Exception as e: @@ -234,9 +228,11 @@ def set_default_profile(self, profile_name: str, device_id: str) -> bool: # Calibration Configuration # ============================================================================ + @dataclass class MonitorCalibration: """Calibration settings for a single monitor.""" + monitor_id: str # Device ID or model identifier description: str color_preset: int = 0x05 # sRGB mode @@ -251,6 +247,7 @@ class MonitorCalibration: @dataclass class CalibrationProfile: """Complete calibration profile for all monitors.""" + version: str = "1.0" name: str = "Default" monitors: dict[int, MonitorCalibration] = None @@ -272,7 +269,7 @@ def __init__(self): def _get_config_dir(self) -> Path: """Get configuration directory.""" - appdata = os.environ.get('APPDATA', os.path.expanduser('~')) + appdata = os.environ.get("APPDATA", os.path.expanduser("~")) config_dir = Path(appdata) / "CalibratePro" config_dir.mkdir(parents=True, exist_ok=True) return config_dir @@ -285,13 +282,11 @@ def _load_profile(self) -> CalibrationProfile: data = json.load(f) monitors = {} - for idx, mon_data in data.get('monitors', {}).items(): + for idx, mon_data in data.get("monitors", {}).items(): monitors[int(idx)] = MonitorCalibration(**mon_data) return CalibrationProfile( - version=data.get('version', '1.0'), - name=data.get('name', 'Default'), - monitors=monitors + version=data.get("version", "1.0"), name=data.get("name", "Default"), monitors=monitors ) except Exception as e: logger.warning(f"Could not load profile: {e}") @@ -301,15 +296,12 @@ def _load_profile(self) -> CalibrationProfile: def save_profile(self): """Save calibration profile to file.""" data = { - 'version': self.profile.version, - 'name': self.profile.name, - 'monitors': { - str(idx): asdict(mon) - for idx, mon in self.profile.monitors.items() - } + "version": self.profile.version, + "name": self.profile.name, + "monitors": {str(idx): asdict(mon) for idx, mon in self.profile.monitors.items()}, } - with open(self.profile_file, 'w') as f: + with open(self.profile_file, "w") as f: json.dump(data, f, indent=2) logger.info(f"Saved calibration profile to {self.profile_file}") @@ -325,7 +317,7 @@ def add_monitor_calibration( blue_gain: int = 100, icc_profile: str | None = None, white_point: str = "D65", - ddc_supported: bool = True + ddc_supported: bool = True, ): """Add or update calibration for a monitor.""" self.profile.monitors[monitor_idx] = MonitorCalibration( @@ -337,7 +329,7 @@ def add_monitor_calibration( blue_gain=blue_gain, icc_profile=icc_profile, white_point=white_point, - ddc_supported=ddc_supported + ddc_supported=ddc_supported, ) self.save_profile() @@ -356,10 +348,10 @@ def apply_all_calibrations(self) -> dict[int, bool]: ddc_success = True if calibration.ddc_supported: settings = { - 'color_preset': calibration.color_preset, - 'red_gain': calibration.red_gain, - 'green_gain': calibration.green_gain, - 'blue_gain': calibration.blue_gain, + "color_preset": calibration.color_preset, + "red_gain": calibration.red_gain, + "green_gain": calibration.green_gain, + "blue_gain": calibration.blue_gain, } ddc_success = self.ddc.apply_calibration(monitor_idx, settings) else: @@ -394,6 +386,7 @@ def cleanup(self): # Windows Startup Registration # ============================================================================ + def register_startup(script_path: str | None = None) -> bool: """Register auto-calibration to run at Windows startup.""" import winreg @@ -406,19 +399,14 @@ def register_startup(script_path: str | None = None) -> bool: script_path = os.path.abspath(__file__) # Build command - use pythonw for silent execution - python_path = sys.executable.replace('python.exe', 'pythonw.exe') + python_path = sys.executable.replace("python.exe", "pythonw.exe") if not os.path.exists(python_path): python_path = sys.executable cmd = f'"{python_path}" "{script_path}" --apply' try: - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - STARTUP_KEY, - 0, - winreg.KEY_SET_VALUE - ) + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_KEY, 0, winreg.KEY_SET_VALUE) winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, cmd) winreg.CloseKey(key) logger.info(f"Registered startup: {cmd}") @@ -436,12 +424,7 @@ def unregister_startup() -> bool: APP_NAME = "CalibratePro_AutoCalibration" try: - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - STARTUP_KEY, - 0, - winreg.KEY_SET_VALUE - ) + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_KEY, 0, winreg.KEY_SET_VALUE) try: winreg.DeleteValue(key, APP_NAME) except OSError: @@ -462,12 +445,7 @@ def is_startup_registered() -> bool: APP_NAME = "CalibratePro_AutoCalibration" try: - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - STARTUP_KEY, - 0, - winreg.KEY_READ - ) + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_KEY, 0, winreg.KEY_READ) try: winreg.QueryValueEx(key, APP_NAME) return True @@ -483,16 +461,17 @@ def is_startup_registered() -> bool: # Main Entry Point # ============================================================================ + def main(): """Main entry point for auto-calibration.""" import argparse - parser = argparse.ArgumentParser(description='CalibratePro Auto-Calibration') - parser.add_argument('--apply', action='store_true', help='Apply calibration') - parser.add_argument('--register', action='store_true', help='Register for Windows startup') - parser.add_argument('--unregister', action='store_true', help='Remove from Windows startup') - parser.add_argument('--status', action='store_true', help='Show current status') - parser.add_argument('--setup', action='store_true', help='Setup current monitor calibration') + parser = argparse.ArgumentParser(description="CalibratePro Auto-Calibration") + parser.add_argument("--apply", action="store_true", help="Apply calibration") + parser.add_argument("--register", action="store_true", help="Register for Windows startup") + parser.add_argument("--unregister", action="store_true", help="Remove from Windows startup") + parser.add_argument("--status", action="store_true", help="Show current status") + parser.add_argument("--setup", action="store_true", help="Setup current monitor calibration") args = parser.parse_args() @@ -536,7 +515,7 @@ def main(): print("No monitors found!") return - description = manager.ddc.monitors[0]['description'] + description = manager.ddc.monitors[0]["description"] device_id = manager.icc.get_monitor_device_id(0) or "Unknown" # Add default D65 sRGB calibration @@ -549,7 +528,7 @@ def main(): green_gain=100, blue_gain=100, icc_profile="CalibratePro_Display1.icc", - white_point="D65" + white_point="D65", ) print(f"Configured: {description}") @@ -581,5 +560,5 @@ def main(): manager.cleanup() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/calibrate_pro/utils/startup_manager.py b/calibrate_pro/utils/startup_manager.py index fd6cbd8..cc4f1f3 100644 --- a/calibrate_pro/utils/startup_manager.py +++ b/calibrate_pro/utils/startup_manager.py @@ -27,6 +27,7 @@ @dataclass class DisplayCalibrationState: """Saved calibration state for a display.""" + display_id: int display_name: str model: str @@ -41,6 +42,7 @@ class DisplayCalibrationState: @dataclass class CalibrationConfig: """Complete calibration configuration.""" + version: str = "1.0" auto_start: bool = False auto_apply: bool = True @@ -62,19 +64,19 @@ def __init__(self): def _get_config_dir(self) -> Path: """Get application configuration directory.""" - appdata = os.environ.get('APPDATA', os.path.expanduser('~')) + appdata = os.environ.get("APPDATA", os.path.expanduser("~")) config_dir = Path(appdata) / "CalibratePro" config_dir.mkdir(parents=True, exist_ok=True) return config_dir def _get_executable_path(self) -> str: """Get path to the executable.""" - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): # Running as compiled executable return sys.executable else: # Running as script - use pythonw to avoid console - return 'pythonw -m calibrate_pro.app startup-service' + return "pythonw -m calibrate_pro.app startup-service" def _load_config(self) -> CalibrationConfig: """Load configuration from file.""" @@ -85,15 +87,15 @@ def _load_config(self) -> CalibrationConfig: # Reconstruct display states displays = {} - for key, val in data.get('displays', {}).items(): + for key, val in data.get("displays", {}).items(): displays[key] = DisplayCalibrationState(**val) return CalibrationConfig( - version=data.get('version', '1.0'), - auto_start=data.get('auto_start', False), - auto_apply=data.get('auto_apply', True), - refresh_interval=data.get('refresh_interval', 300), - displays=displays + version=data.get("version", "1.0"), + auto_start=data.get("auto_start", False), + auto_apply=data.get("auto_apply", True), + refresh_interval=data.get("refresh_interval", 300), + displays=displays, ) except Exception as e: print(f"Warning: Could not load config: {e}") @@ -103,27 +105,20 @@ def _load_config(self) -> CalibrationConfig: def save_config(self): """Save configuration to file.""" data = { - 'version': self.config.version, - 'auto_start': self.config.auto_start, - 'auto_apply': self.config.auto_apply, - 'refresh_interval': self.config.refresh_interval, - 'displays': { - key: asdict(val) for key, val in self.config.displays.items() - } + "version": self.config.version, + "auto_start": self.config.auto_start, + "auto_apply": self.config.auto_apply, + "refresh_interval": self.config.refresh_interval, + "displays": {key: asdict(val) for key, val in self.config.displays.items()}, } - with open(self.config_file, 'w') as f: + with open(self.config_file, "w") as f: json.dump(data, f, indent=2) def is_startup_enabled(self) -> bool: """Check if application is set to run at startup.""" try: - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - STARTUP_KEY, - 0, - winreg.KEY_READ - ) + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_KEY, 0, winreg.KEY_READ) try: winreg.QueryValueEx(key, APP_NAME) return True @@ -144,22 +139,17 @@ def enable_startup(self, silent: bool = True) -> bool: exe_path = self._get_executable_path() if silent: - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): startup_cmd = f'"{exe_path}" start-service --silent' else: - startup_cmd = 'pythonw -m calibrate_pro.startup.calibration_loader start-service --silent' + startup_cmd = "pythonw -m calibrate_pro.startup.calibration_loader start-service --silent" else: - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): startup_cmd = f'"{exe_path}" start-service' else: - startup_cmd = 'pythonw -m calibrate_pro.startup.calibration_loader start-service' - - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - STARTUP_KEY, - 0, - winreg.KEY_SET_VALUE - ) + startup_cmd = "pythonw -m calibrate_pro.startup.calibration_loader start-service" + + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_KEY, 0, winreg.KEY_SET_VALUE) winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, startup_cmd) winreg.CloseKey(key) @@ -175,6 +165,7 @@ def _enable_startup_macos(self, silent: bool = True) -> bool: """Register as macOS login item via launchd.""" try: from calibrate_pro.platform.macos import enable_macos_startup + result = enable_macos_startup(silent) if result: self.config.auto_start = True @@ -187,12 +178,7 @@ def _enable_startup_macos(self, silent: bool = True) -> bool: def disable_startup(self) -> bool: """Remove application from Windows startup.""" try: - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - STARTUP_KEY, - 0, - winreg.KEY_SET_VALUE - ) + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_KEY, 0, winreg.KEY_SET_VALUE) try: winreg.DeleteValue(key, APP_NAME) except OSError: @@ -216,7 +202,7 @@ def save_display_calibration( icc_path: str | None = None, hdr_mode: bool = False, delta_e_avg: float = 0.0, - delta_e_max: float = 0.0 + delta_e_max: float = 0.0, ): """Save calibration state for a display.""" state = DisplayCalibrationState( @@ -228,7 +214,7 @@ def save_display_calibration( hdr_mode=hdr_mode, last_calibrated=datetime.now().isoformat(), delta_e_avg=delta_e_avg, - delta_e_max=delta_e_max + delta_e_max=delta_e_max, ) self.config.displays[str(display_id)] = state diff --git a/calibrate_pro/verification/__init__.py b/calibrate_pro/verification/__init__.py index 4bf56bf..a7d3201 100644 --- a/calibrate_pro/verification/__init__.py +++ b/calibrate_pro/verification/__init__.py @@ -198,6 +198,7 @@ # Common xyz_to_lab function xyz_to_lab = cc_xyz_to_lab + # Common grade_to_string (returns verification grade string) def grade_to_string(grade) -> str: """Convert any verification grade to string.""" @@ -238,7 +239,6 @@ def grade_to_string(grade) -> str: "cc_xyz_to_lab", "create_cc_test_measurements", "print_cc_summary", - # ------------------------------------------------------------------------- # Grayscale Verification # ------------------------------------------------------------------------- @@ -265,7 +265,6 @@ def grade_to_string(grade) -> str: "generate_grayscale_levels", "create_gs_test_measurements", "print_gs_summary", - # ------------------------------------------------------------------------- # Gamut Volume Analysis # ------------------------------------------------------------------------- @@ -297,7 +296,6 @@ def grade_to_string(grade) -> str: "gv_grade_to_string", "create_test_primaries", "print_gamut_summary", - # ------------------------------------------------------------------------- # Report Generation # ------------------------------------------------------------------------- @@ -311,7 +309,6 @@ def grade_to_string(grade) -> str: "generate_recommendations", "create_verification_summary", "REPORTLAB_AVAILABLE", - # ------------------------------------------------------------------------- # Common/Convenience # ------------------------------------------------------------------------- diff --git a/calibrate_pro/verification/colorchecker.py b/calibrate_pro/verification/colorchecker.py index 7f2dd82..fe65da7 100644 --- a/calibrate_pro/verification/colorchecker.py +++ b/calibrate_pro/verification/colorchecker.py @@ -53,10 +53,30 @@ # ColorChecker Classic patch names in order (left-to-right, top-to-bottom) COLORCHECKER_CLASSIC_ORDER = [ - "dark_skin", "light_skin", "blue_sky", "foliage", "blue_flower", "bluish_green", - "orange", "purplish_blue", "moderate_red", "purple", "yellow_green", "orange_yellow", - "blue", "green", "red", "yellow", "magenta", "cyan", - "white", "neutral_8", "neutral_6.5", "neutral_5", "neutral_3.5", "black", + "dark_skin", + "light_skin", + "blue_sky", + "foliage", + "blue_flower", + "bluish_green", + "orange", + "purplish_blue", + "moderate_red", + "purple", + "yellow_green", + "orange_yellow", + "blue", + "green", + "red", + "yellow", + "magenta", + "cyan", + "white", + "neutral_8", + "neutral_6.5", + "neutral_5", + "neutral_3.5", + "black", ] # ColorChecker Classic display names @@ -101,39 +121,44 @@ # Enums # ============================================================================= + class VerificationGrade(Enum): """Verification quality grade based on Delta E statistics.""" - REFERENCE = auto() # ΔE < 1.0 - Reference grade - EXCELLENT = auto() # ΔE < 2.0 - Excellent - GOOD = auto() # ΔE < 3.0 - Good - ACCEPTABLE = auto() # ΔE < 5.0 - Acceptable - POOR = auto() # ΔE >= 5.0 - Poor + + REFERENCE = auto() # ΔE < 1.0 - Reference grade + EXCELLENT = auto() # ΔE < 2.0 - Excellent + GOOD = auto() # ΔE < 3.0 - Good + ACCEPTABLE = auto() # ΔE < 5.0 - Acceptable + POOR = auto() # ΔE >= 5.0 - Poor class ColorCheckerType(Enum): """ColorChecker target type.""" - CLASSIC_24 = auto() # Standard 24-patch - DIGITAL_SG = auto() # 140-patch Digital SG - PASSPORT = auto() # ColorChecker Passport - VIDEO = auto() # ColorChecker Video + + CLASSIC_24 = auto() # Standard 24-patch + DIGITAL_SG = auto() # 140-patch Digital SG + PASSPORT = auto() # ColorChecker Passport + VIDEO = auto() # ColorChecker Video # ============================================================================= # Data Classes # ============================================================================= + @dataclass class PatchMeasurement: """Single patch measurement result.""" + patch_id: str patch_name: str reference_lab: tuple[float, float, float] measured_lab: tuple[float, float, float] delta_e_76: float delta_e_2000: float - delta_l: float # Lightness difference - delta_c: float # Chroma difference - delta_h: float # Hue difference + delta_l: float # Lightness difference + delta_c: float # Chroma difference + delta_h: float # Hue difference category: str = "" @property @@ -164,6 +189,7 @@ def measured_b(self) -> float: @dataclass class CategoryAnalysis: """Analysis results for a patch category.""" + category: str patch_count: int delta_e_mean: float @@ -179,6 +205,7 @@ class CategoryAnalysis: @dataclass class ColorCheckerResult: """Complete ColorChecker verification result.""" + target_type: ColorCheckerType patch_measurements: list[PatchMeasurement] category_analysis: dict[str, CategoryAnalysis] @@ -221,6 +248,7 @@ def passed(self) -> bool: # Color Math Functions # ============================================================================= + def lab_to_lch(lab: tuple[float, float, float]) -> tuple[float, float, float]: """Convert Lab to LCH (Lightness, Chroma, Hue).""" L, a, b = lab @@ -231,17 +259,20 @@ def lab_to_lch(lab: tuple[float, float, float]) -> tuple[float, float, float]: return (L, C, H) -def delta_e_1976(lab1: tuple[float, float, float], - lab2: tuple[float, float, float]) -> float: +def delta_e_1976(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float: """Calculate CIE76 Delta E (Euclidean distance in Lab).""" L1, a1, b1 = lab1 L2, a2, b2 = lab2 - return np.sqrt((L2 - L1)**2 + (a2 - a1)**2 + (b2 - b1)**2) + return np.sqrt((L2 - L1) ** 2 + (a2 - a1) ** 2 + (b2 - b1) ** 2) -def delta_e_2000(lab1: tuple[float, float, float], - lab2: tuple[float, float, float], - kL: float = 1.0, kC: float = 1.0, kH: float = 1.0) -> float: +def delta_e_2000( + lab1: tuple[float, float, float], + lab2: tuple[float, float, float], + kL: float = 1.0, + kC: float = 1.0, + kH: float = 1.0, +) -> float: """ Calculate CIEDE2000 Delta E. @@ -299,16 +330,19 @@ def delta_e_2000(lab1: tuple[float, float, float], h_prime_sum += 360 h_prime_avg = h_prime_sum / 2 - T = (1 - 0.17 * np.cos(np.radians(h_prime_avg - 30)) + - 0.24 * np.cos(np.radians(2 * h_prime_avg)) + - 0.32 * np.cos(np.radians(3 * h_prime_avg + 6)) - - 0.20 * np.cos(np.radians(4 * h_prime_avg - 63))) + T = ( + 1 + - 0.17 * np.cos(np.radians(h_prime_avg - 30)) + + 0.24 * np.cos(np.radians(2 * h_prime_avg)) + + 0.32 * np.cos(np.radians(3 * h_prime_avg + 6)) + - 0.20 * np.cos(np.radians(4 * h_prime_avg - 63)) + ) - delta_theta = 30 * np.exp(-((h_prime_avg - 275) / 25)**2) + delta_theta = 30 * np.exp(-(((h_prime_avg - 275) / 25) ** 2)) R_C = 2 * np.sqrt(C_prime_avg**7 / (C_prime_avg**7 + 25**7)) - S_L = 1 + (0.015 * (L_prime_avg - 50)**2) / np.sqrt(20 + (L_prime_avg - 50)**2) + S_L = 1 + (0.015 * (L_prime_avg - 50) ** 2) / np.sqrt(20 + (L_prime_avg - 50) ** 2) S_C = 1 + 0.045 * C_prime_avg S_H = 1 + 0.015 * C_prime_avg * T @@ -316,17 +350,18 @@ def delta_e_2000(lab1: tuple[float, float, float], # Final calculation delta_E = np.sqrt( - (delta_L_prime / (kL * S_L))**2 + - (delta_C_prime / (kC * S_C))**2 + - (delta_H_prime / (kH * S_H))**2 + - R_T * (delta_C_prime / (kC * S_C)) * (delta_H_prime / (kH * S_H)) + (delta_L_prime / (kL * S_L)) ** 2 + + (delta_C_prime / (kC * S_C)) ** 2 + + (delta_H_prime / (kH * S_H)) ** 2 + + R_T * (delta_C_prime / (kC * S_C)) * (delta_H_prime / (kH * S_H)) ) return delta_E -def calculate_delta_components(lab1: tuple[float, float, float], - lab2: tuple[float, float, float]) -> tuple[float, float, float]: +def calculate_delta_components( + lab1: tuple[float, float, float], lab2: tuple[float, float, float] +) -> tuple[float, float, float]: """ Calculate Delta L, Delta C, Delta H components. @@ -357,6 +392,7 @@ def calculate_delta_components(lab1: tuple[float, float, float], # Verification Grade Functions # ============================================================================= + def grade_from_delta_e(delta_e: float) -> VerificationGrade: """Determine verification grade from Delta E value.""" if delta_e < 1.0: @@ -386,6 +422,7 @@ def grade_to_string(grade: VerificationGrade) -> str: # ColorChecker Verification Class # ============================================================================= + class ColorCheckerVerifier: """ ColorChecker verification engine. @@ -411,10 +448,9 @@ def _get_reference_data(self) -> dict[str, tuple[float, float, float]]: # Add other target types as needed return COLORCHECKER_CLASSIC_D50 - def verify(self, - measured_lab: dict[str, tuple[float, float, float]], - display_name: str = "", - profile_name: str = "") -> ColorCheckerResult: + def verify( + self, measured_lab: dict[str, tuple[float, float, float]], display_name: str = "", profile_name: str = "" + ) -> ColorCheckerResult: """ Perform ColorChecker verification. @@ -491,10 +527,12 @@ def verify(self, # Determine grades overall_grade = grade_from_delta_e(delta_e_mean) - grayscale_grade = category_analysis.get("grayscale", - CategoryAnalysis("grayscale", 0, 0, 0, 0, 0, 0, 0, 0, VerificationGrade.POOR)).grade - skin_tone_grade = category_analysis.get("skin_tones", - CategoryAnalysis("skin_tones", 0, 0, 0, 0, 0, 0, 0, 0, VerificationGrade.POOR)).grade + grayscale_grade = category_analysis.get( + "grayscale", CategoryAnalysis("grayscale", 0, 0, 0, 0, 0, 0, 0, 0, VerificationGrade.POOR) + ).grade + skin_tone_grade = category_analysis.get( + "skin_tones", CategoryAnalysis("skin_tones", 0, 0, 0, 0, 0, 0, 0, 0, VerificationGrade.POOR) + ).grade return ColorCheckerResult( target_type=self.target_type, @@ -517,8 +555,7 @@ def verify(self, profile_name=profile_name, ) - def _analyze_categories(self, - measurements: list[PatchMeasurement]) -> dict[str, CategoryAnalysis]: + def _analyze_categories(self, measurements: list[PatchMeasurement]) -> dict[str, CategoryAnalysis]: """Analyze measurements by category.""" category_analysis: dict[str, CategoryAnalysis] = {} @@ -550,11 +587,13 @@ def _analyze_categories(self, return category_analysis - def verify_from_xyz(self, - measured_xyz: dict[str, tuple[float, float, float]], - illuminant: str = "D50", - display_name: str = "", - profile_name: str = "") -> ColorCheckerResult: + def verify_from_xyz( + self, + measured_xyz: dict[str, tuple[float, float, float]], + illuminant: str = "D50", + display_name: str = "", + profile_name: str = "", + ) -> ColorCheckerResult: """ Perform verification from XYZ measurements. @@ -575,8 +614,7 @@ def verify_from_xyz(self, return self.verify(measured_lab, display_name, profile_name) -def xyz_to_lab(xyz: tuple[float, float, float], - illuminant: str = "D50") -> tuple[float, float, float]: +def xyz_to_lab(xyz: tuple[float, float, float], illuminant: str = "D50") -> tuple[float, float, float]: """ Convert XYZ to Lab. @@ -605,7 +643,7 @@ def xyz_to_lab(xyz: tuple[float, float, float], def f(t): delta = 6 / 29 if t > delta**3: - return t ** (1/3) + return t ** (1 / 3) else: return t / (3 * delta**2) + 4 / 29 @@ -620,6 +658,7 @@ def f(t): # Utility Functions # ============================================================================= + def create_test_measurements() -> dict[str, tuple[float, float, float]]: """Create simulated measurements for testing (with small random errors).""" np.random.seed(42) @@ -661,8 +700,7 @@ def print_verification_summary(result: ColorCheckerResult) -> None: print() print("Category Analysis:") for cat_name, analysis in result.category_analysis.items(): - print(f" {cat_name.replace('_', ' ').title()}: " - f"Mean ΔE = {analysis.delta_e_mean:.2f} ({analysis.grade.name})") + print(f" {cat_name.replace('_', ' ').title()}: Mean ΔE = {analysis.delta_e_mean:.2f} ({analysis.grade.name})") print("=" * 60) @@ -674,9 +712,5 @@ def print_verification_summary(result: ColorCheckerResult) -> None: # Test verification verifier = ColorCheckerVerifier(ColorCheckerType.CLASSIC_24) test_measurements = create_test_measurements() - result = verifier.verify( - test_measurements, - display_name="Test Display", - profile_name="Test Profile" - ) + result = verifier.verify(test_measurements, display_name="Test Display", profile_name="Test Profile") print_verification_summary(result) diff --git a/calibrate_pro/verification/gamut_volume.py b/calibrate_pro/verification/gamut_volume.py index f1b6f11..01b7ee9 100644 --- a/calibrate_pro/verification/gamut_volume.py +++ b/calibrate_pro/verification/gamut_volume.py @@ -17,6 +17,7 @@ # Optional scipy import try: from scipy.spatial import ConvexHull, Delaunay + SCIPY_AVAILABLE = True except ImportError: SCIPY_AVAILABLE = False @@ -27,25 +28,28 @@ # Enums # ============================================================================= + class ColorSpace(Enum): """Standard color space definitions.""" - SRGB = auto() # IEC 61966-2-1 sRGB - DCI_P3 = auto() # DCI-P3 (D65 white) - DISPLAY_P3 = auto() # Apple Display P3 - BT2020 = auto() # ITU-R BT.2020 - ADOBE_RGB = auto() # Adobe RGB (1998) - PROPHOTO_RGB = auto() # ProPhoto RGB (ROMM) - ACES_AP0 = auto() # ACES Primaries 0 - ACES_AP1 = auto() # ACES Primaries 1 (ACEScg) + + SRGB = auto() # IEC 61966-2-1 sRGB + DCI_P3 = auto() # DCI-P3 (D65 white) + DISPLAY_P3 = auto() # Apple Display P3 + BT2020 = auto() # ITU-R BT.2020 + ADOBE_RGB = auto() # Adobe RGB (1998) + PROPHOTO_RGB = auto() # ProPhoto RGB (ROMM) + ACES_AP0 = auto() # ACES Primaries 0 + ACES_AP1 = auto() # ACES Primaries 1 (ACEScg) class GamutGrade(Enum): """Gamut coverage quality grade.""" - REFERENCE = auto() # >99% coverage - EXCELLENT = auto() # >95% coverage - GOOD = auto() # >90% coverage - ACCEPTABLE = auto() # >80% coverage - POOR = auto() # <80% coverage + + REFERENCE = auto() # >99% coverage + EXCELLENT = auto() # >95% coverage + GOOD = auto() # >90% coverage + ACCEPTABLE = auto() # >80% coverage + POOR = auto() # <80% coverage # ============================================================================= @@ -109,22 +113,25 @@ class GamutGrade(Enum): # Data Classes # ============================================================================= + @dataclass class GamutPrimary: """Measured primary chromaticity.""" - name: str # "R", "G", "B", "W" + + name: str # "R", "G", "B", "W" target_xy: tuple[float, float] measured_xy: tuple[float, float] - delta_xy: float # Distance in xy - delta_uv: float # Distance in u'v' + delta_xy: float # Distance in xy + delta_uv: float # Distance in u'v' @dataclass class GamutCoverage: """Coverage results for a single target color space.""" + color_space: ColorSpace coverage_percent: float # Percentage of target gamut covered - volume_ratio: float # Measured volume / target volume + volume_ratio: float # Measured volume / target volume exceeds_percent: float # Percentage outside target (can exceed) # Primary accuracy @@ -132,9 +139,9 @@ class GamutCoverage: primary_accuracy_mean: float # Mean ΔE of primaries # Detailed metrics - area_xy: float # 2D area in xy chromaticity - area_uv: float # 2D area in u'v' chromaticity - volume_lab: float # 3D volume in Lab space + area_xy: float # 2D area in xy chromaticity + area_uv: float # 2D area in u'v' chromaticity + volume_lab: float # 3D volume in Lab space grade: GamutGrade @@ -142,6 +149,7 @@ class GamutCoverage: @dataclass class GamutBoundary: """Gamut boundary representation.""" + # 2D boundary in xy chromaticity boundary_xy: list[tuple[float, float]] @@ -158,6 +166,7 @@ class GamutBoundary: @dataclass class OutOfGamutAnalysis: """Analysis of out-of-gamut colors.""" + target_space: ColorSpace total_samples: int in_gamut_count: int @@ -165,9 +174,9 @@ class OutOfGamutAnalysis: out_of_gamut_percent: float # Severity metrics - max_distance: float # Maximum distance outside gamut - mean_distance: float # Mean distance for OOG samples - severe_count: int # Samples with significant OOG + max_distance: float # Maximum distance outside gamut + mean_distance: float # Mean distance for OOG samples + severe_count: int # Samples with significant OOG # Problem areas (in Lab) problem_regions: list[tuple[float, float, float]] @@ -176,6 +185,7 @@ class OutOfGamutAnalysis: @dataclass class GamutAnalysisResult: """Complete gamut analysis result.""" + # Measured display gamut measured_boundary: GamutBoundary measured_primaries: dict[str, tuple[float, float]] # R, G, B, W xy @@ -208,6 +218,7 @@ class GamutAnalysisResult: # Color Conversion Functions # ============================================================================= + def xy_to_uv(x: float, y: float) -> tuple[float, float]: """Convert CIE 1931 xy to CIE 1976 u'v'.""" denom = -2 * x + 12 * y + 3 @@ -237,8 +248,9 @@ def xy_to_xyz(x: float, y: float, Y: float = 1.0) -> tuple[float, float, float]: return (X, Y, Z) -def xyz_to_lab(xyz: tuple[float, float, float], - white_xyz: tuple[float, float, float] = (0.95047, 1.0, 1.08883)) -> tuple[float, float, float]: +def xyz_to_lab( + xyz: tuple[float, float, float], white_xyz: tuple[float, float, float] = (0.95047, 1.0, 1.08883) +) -> tuple[float, float, float]: """Convert XYZ to Lab.""" X, Y, Z = xyz Xn, Yn, Zn = white_xyz @@ -250,7 +262,7 @@ def xyz_to_lab(xyz: tuple[float, float, float], def f(t): delta = 6 / 29 if t > delta**3: - return t ** (1/3) + return t ** (1 / 3) else: return t / (3 * delta**2) + 4 / 29 @@ -261,8 +273,9 @@ def f(t): return (L, a, b) -def rgb_to_xyz(rgb: tuple[float, float, float], - color_space: ColorSpace = ColorSpace.SRGB) -> tuple[float, float, float]: +def rgb_to_xyz( + rgb: tuple[float, float, float], color_space: ColorSpace = ColorSpace.SRGB +) -> tuple[float, float, float]: """ Convert RGB to XYZ. @@ -313,43 +326,29 @@ def _calculate_rgb_to_xyz_matrix(primaries: dict[str, tuple[float, float]]) -> n Zw = (1 - xw - yw) / yw # Create matrix and solve for scaling factors - M = np.array([ - [Xr, Xg, Xb], - [Yr, Yg, Yb], - [Zr, Zg, Zb] - ]) + M = np.array([[Xr, Xg, Xb], [Yr, Yg, Yb], [Zr, Zg, Zb]]) S = np.linalg.solve(M, [Xw, Yw, Zw]) # Final matrix - return np.array([ - [S[0] * Xr, S[1] * Xg, S[2] * Xb], - [S[0] * Yr, S[1] * Yg, S[2] * Yb], - [S[0] * Zr, S[1] * Zg, S[2] * Zb] - ]) + return np.array( + [[S[0] * Xr, S[1] * Xg, S[2] * Xb], [S[0] * Yr, S[1] * Yg, S[2] * Yb], [S[0] * Zr, S[1] * Zg, S[2] * Zb]] + ) # ============================================================================= # Gamut Geometry Functions # ============================================================================= -def calculate_triangle_area(p1: tuple[float, float], - p2: tuple[float, float], - p3: tuple[float, float]) -> float: + +def calculate_triangle_area(p1: tuple[float, float], p2: tuple[float, float], p3: tuple[float, float]) -> float: """Calculate area of triangle in 2D using cross product.""" - return 0.5 * abs( - (p2[0] - p1[0]) * (p3[1] - p1[1]) - - (p3[0] - p1[0]) * (p2[1] - p1[1]) - ) + return 0.5 * abs((p2[0] - p1[0]) * (p3[1] - p1[1]) - (p3[0] - p1[0]) * (p2[1] - p1[1])) def calculate_gamut_area_xy(primaries: dict[str, tuple[float, float]]) -> float: """Calculate gamut area in xy chromaticity (triangle).""" - return calculate_triangle_area( - primaries["R"], - primaries["G"], - primaries["B"] - ) + return calculate_triangle_area(primaries["R"], primaries["G"], primaries["B"]) def calculate_gamut_area_uv(primaries: dict[str, tuple[float, float]]) -> float: @@ -360,11 +359,11 @@ def calculate_gamut_area_uv(primaries: dict[str, tuple[float, float]]) -> float: return calculate_triangle_area(r_uv, g_uv, b_uv) -def point_in_triangle(point: tuple[float, float], - v1: tuple[float, float], - v2: tuple[float, float], - v3: tuple[float, float]) -> bool: +def point_in_triangle( + point: tuple[float, float], v1: tuple[float, float], v2: tuple[float, float], v3: tuple[float, float] +) -> bool: """Check if point is inside triangle using barycentric coordinates.""" + def sign(p1, p2, p3): return (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]) @@ -378,24 +377,24 @@ def sign(p1, p2, p3): return not (has_neg and has_pos) -def calculate_triangle_intersection_area(t1: list[tuple[float, float]], - t2: list[tuple[float, float]]) -> float: +def calculate_triangle_intersection_area(t1: list[tuple[float, float]], t2: list[tuple[float, float]]) -> float: """ Calculate intersection area of two triangles. Uses Sutherland-Hodgman polygon clipping algorithm. """ - def clip_polygon(polygon: list[tuple[float, float]], - edge_start: tuple[float, float], - edge_end: tuple[float, float]) -> list[tuple[float, float]]: + def clip_polygon( + polygon: list[tuple[float, float]], edge_start: tuple[float, float], edge_end: tuple[float, float] + ) -> list[tuple[float, float]]: """Clip polygon against a single edge.""" if not polygon: return [] def inside(p): - return (edge_end[0] - edge_start[0]) * (p[1] - edge_start[1]) > \ - (edge_end[1] - edge_start[1]) * (p[0] - edge_start[0]) + return (edge_end[0] - edge_start[0]) * (p[1] - edge_start[1]) > (edge_end[1] - edge_start[1]) * ( + p[0] - edge_start[0] + ) def intersection(p1, p2): dc = (edge_start[0] - edge_end[0], edge_start[1] - edge_end[1]) @@ -446,8 +445,7 @@ def intersection(p1, p2): return abs(area) / 2.0 -def calculate_gamut_coverage(measured_primaries: dict[str, tuple[float, float]], - target_space: ColorSpace) -> float: +def calculate_gamut_coverage(measured_primaries: dict[str, tuple[float, float]], target_space: ColorSpace) -> float: """ Calculate percentage of target gamut covered by measured gamut. @@ -485,8 +483,7 @@ def calculate_gamut_coverage(measured_primaries: dict[str, tuple[float, float]], return min(coverage, 100.0) -def calculate_gamut_exceeds(measured_primaries: dict[str, tuple[float, float]], - target_space: ColorSpace) -> float: +def calculate_gamut_exceeds(measured_primaries: dict[str, tuple[float, float]], target_space: ColorSpace) -> float: """ Calculate percentage of measured gamut that exceeds target gamut. @@ -522,8 +519,8 @@ def calculate_gamut_exceeds(measured_primaries: dict[str, tuple[float, float]], # 3D Volume Calculation # ============================================================================= -def generate_gamut_samples(color_space: ColorSpace, - samples_per_axis: int = 17) -> np.ndarray: + +def generate_gamut_samples(color_space: ColorSpace, samples_per_axis: int = 17) -> np.ndarray: """ Generate sample points throughout a color space gamut. @@ -561,8 +558,7 @@ def calculate_gamut_volume_lab(samples: np.ndarray) -> float: return 0.0 -def calculate_gamut_volume_ratio(measured_samples: np.ndarray, - target_space: ColorSpace) -> float: +def calculate_gamut_volume_ratio(measured_samples: np.ndarray, target_space: ColorSpace) -> float: """ Calculate ratio of measured volume to target color space volume. @@ -584,6 +580,7 @@ def calculate_gamut_volume_ratio(measured_samples: np.ndarray, # Grade Functions # ============================================================================= + def grade_from_coverage(coverage: float) -> GamutGrade: """Determine grade from coverage percentage.""" if coverage >= 99: @@ -613,6 +610,7 @@ def grade_to_string(grade: GamutGrade) -> str: # Gamut Analyzer Class # ============================================================================= + class GamutAnalyzer: """ Gamut analysis engine. @@ -637,11 +635,13 @@ def __init__(self, reference_white: str = "D65"): } self.white_xyz = white_points.get(reference_white, white_points["D65"]) - def analyze(self, - measured_primaries: dict[str, tuple[float, float]], - measured_samples: np.ndarray | None = None, - display_name: str = "", - profile_name: str = "") -> GamutAnalysisResult: + def analyze( + self, + measured_primaries: dict[str, tuple[float, float]], + measured_samples: np.ndarray | None = None, + display_name: str = "", + profile_name: str = "", + ) -> GamutAnalysisResult: """ Perform comprehensive gamut analysis. @@ -659,8 +659,7 @@ def analyze(self, # Calculate coverage for each standard space all_coverage: dict[ColorSpace, GamutCoverage] = {} - for color_space in [ColorSpace.SRGB, ColorSpace.DISPLAY_P3, - ColorSpace.BT2020, ColorSpace.ADOBE_RGB]: + for color_space in [ColorSpace.SRGB, ColorSpace.DISPLAY_P3, ColorSpace.BT2020, ColorSpace.ADOBE_RGB]: coverage = self._analyze_coverage(measured_primaries, color_space, measured_samples) all_coverage[color_space] = coverage @@ -701,10 +700,12 @@ def analyze(self, profile_name=profile_name, ) - def _analyze_coverage(self, - measured_primaries: dict[str, tuple[float, float]], - target_space: ColorSpace, - measured_samples: np.ndarray | None) -> GamutCoverage: + def _analyze_coverage( + self, + measured_primaries: dict[str, tuple[float, float]], + target_space: ColorSpace, + measured_samples: np.ndarray | None, + ) -> GamutCoverage: """Analyze coverage against single target space.""" target_primaries = COLORSPACE_PRIMARIES[target_space] @@ -731,25 +732,21 @@ def _analyze_coverage(self, target_xy = target_primaries[name] measured_xy = measured_primaries.get(name, target_xy) - delta_xy = np.sqrt( - (measured_xy[0] - target_xy[0])**2 + - (measured_xy[1] - target_xy[1])**2 - ) + delta_xy = np.sqrt((measured_xy[0] - target_xy[0]) ** 2 + (measured_xy[1] - target_xy[1]) ** 2) target_uv = xy_to_uv(*target_xy) measured_uv = xy_to_uv(*measured_xy) - delta_uv = np.sqrt( - (measured_uv[0] - target_uv[0])**2 + - (measured_uv[1] - target_uv[1])**2 + delta_uv = np.sqrt((measured_uv[0] - target_uv[0]) ** 2 + (measured_uv[1] - target_uv[1]) ** 2) + + primaries.append( + GamutPrimary( + name=name, + target_xy=target_xy, + measured_xy=measured_xy, + delta_xy=delta_xy, + delta_uv=delta_uv, + ) ) - - primaries.append(GamutPrimary( - name=name, - target_xy=target_xy, - measured_xy=measured_xy, - delta_xy=delta_xy, - delta_uv=delta_uv, - )) delta_sum += delta_uv primary_accuracy_mean = delta_sum / 3 @@ -767,9 +764,7 @@ def _analyze_coverage(self, grade=grade_from_coverage(coverage_percent), ) - def _create_boundary(self, - primaries: dict[str, tuple[float, float]], - samples: np.ndarray | None) -> GamutBoundary: + def _create_boundary(self, primaries: dict[str, tuple[float, float]], samples: np.ndarray | None) -> GamutBoundary: """Create gamut boundary representation.""" # 2D boundaries (triangles in xy and u'v') boundary_xy = [ @@ -799,9 +794,7 @@ def _create_boundary(self, sample_points_lab=samples, ) - def _analyze_out_of_gamut(self, - samples: np.ndarray, - target_space: ColorSpace) -> OutOfGamutAnalysis: + def _analyze_out_of_gamut(self, samples: np.ndarray, target_space: ColorSpace) -> OutOfGamutAnalysis: """Analyze out-of-gamut samples.""" # Generate target gamut hull target_samples = generate_gamut_samples(target_space, 17) @@ -837,9 +830,9 @@ def _analyze_out_of_gamut(self, for sample in oog_samples[:100]: # Limit for performance # Simple distance to hull (approximation) # Find closest point on hull - min_dist = float('inf') + min_dist = float("inf") for vertex in target_hull.points[target_hull.convex_hull.flatten()]: - dist = np.sqrt(np.sum((sample - vertex)**2)) + dist = np.sqrt(np.sum((sample - vertex) ** 2)) min_dist = min(min_dist, dist) distances.append(min_dist) @@ -876,7 +869,7 @@ def _calculate_cct(self, xy: tuple[float, float]) -> tuple[float, float]: # D65 reference u_d65, v_d65 = 0.1978, 0.4683 - duv = np.sqrt((u - u_d65)**2 + (v - v_d65)**2) + duv = np.sqrt((u - u_d65) ** 2 + (v - v_d65) ** 2) if v < v_d65: duv = -duv @@ -888,6 +881,7 @@ def _calculate_cct(self, xy: tuple[float, float]) -> tuple[float, float]: # Utility Functions # ============================================================================= + def create_test_primaries(coverage_srgb: float = 0.95) -> dict[str, tuple[float, float]]: """Create simulated display primaries for testing.""" np.random.seed(42) @@ -937,8 +931,12 @@ def print_gamut_summary(result: GamutAnalysisResult) -> None: print("Coverage Results:") print(f" sRGB: {result.srgb_coverage.coverage_percent:.1f}% - {grade_to_string(result.srgb_coverage.grade)}") print(f" Display P3: {result.p3_coverage.coverage_percent:.1f}% - {grade_to_string(result.p3_coverage.grade)}") - print(f" BT.2020: {result.bt2020_coverage.coverage_percent:.1f}% - {grade_to_string(result.bt2020_coverage.grade)}") - print(f" Adobe RGB: {result.adobe_rgb_coverage.coverage_percent:.1f}% - {grade_to_string(result.adobe_rgb_coverage.grade)}") + print( + f" BT.2020: {result.bt2020_coverage.coverage_percent:.1f}% - {grade_to_string(result.bt2020_coverage.grade)}" + ) + print( + f" Adobe RGB: {result.adobe_rgb_coverage.coverage_percent:.1f}% - {grade_to_string(result.adobe_rgb_coverage.grade)}" + ) print() if result.total_volume_lab > 0: print(f"Total Volume (Lab³): {result.total_volume_lab:.0f}") @@ -956,10 +954,5 @@ def print_gamut_summary(result: GamutAnalysisResult) -> None: test_primaries = create_test_primaries(0.98) test_samples = generate_gamut_samples(ColorSpace.SRGB, 9) - result = analyzer.analyze( - test_primaries, - test_samples, - display_name="Test Display", - profile_name="Test Profile" - ) + result = analyzer.analyze(test_primaries, test_samples, display_name="Test Display", profile_name="Test Profile") print_gamut_summary(result) diff --git a/calibrate_pro/verification/grayscale.py b/calibrate_pro/verification/grayscale.py index f10c077..fa604e1 100644 --- a/calibrate_pro/verification/grayscale.py +++ b/calibrate_pro/verification/grayscale.py @@ -18,33 +18,38 @@ # Enums # ============================================================================= + class GrayscaleGrade(Enum): """Grayscale verification quality grade.""" - REFERENCE = auto() # Broadcast reference (<1 ΔE, <50K CCT deviation) - EXCELLENT = auto() # Professional (<2 ΔE, <100K CCT deviation) - GOOD = auto() # Content creation (<3 ΔE, <200K CCT deviation) - ACCEPTABLE = auto() # General use (<5 ΔE, <300K CCT deviation) - POOR = auto() # Needs calibration + + REFERENCE = auto() # Broadcast reference (<1 ΔE, <50K CCT deviation) + EXCELLENT = auto() # Professional (<2 ΔE, <100K CCT deviation) + GOOD = auto() # Content creation (<3 ΔE, <200K CCT deviation) + ACCEPTABLE = auto() # General use (<5 ΔE, <300K CCT deviation) + POOR = auto() # Needs calibration class GammaType(Enum): """Target gamma/EOTF type.""" - POWER_LAW = auto() # Simple power law (gamma 2.2, 2.4, etc.) - SRGB = auto() # sRGB EOTF (IEC 61966-2-1) - BT1886 = auto() # BT.1886 EOTF (ITU-R BT.1886) - L_STAR = auto() # L* response (CIE L*) - HLG = auto() # Hybrid Log-Gamma (ITU-R BT.2100) - PQ = auto() # Perceptual Quantizer ST.2084 + + POWER_LAW = auto() # Simple power law (gamma 2.2, 2.4, etc.) + SRGB = auto() # sRGB EOTF (IEC 61966-2-1) + BT1886 = auto() # BT.1886 EOTF (ITU-R BT.1886) + L_STAR = auto() # L* response (CIE L*) + HLG = auto() # Hybrid Log-Gamma (ITU-R BT.2100) + PQ = auto() # Perceptual Quantizer ST.2084 # ============================================================================= # Data Classes # ============================================================================= + @dataclass class GrayscalePatch: """Single grayscale patch measurement result.""" - level: int # Input level (0-100%) + + level: int # Input level (0-100%) input_rgb: tuple[int, int, int] # Stimulus RGB (0-255) # Measured values @@ -59,20 +64,20 @@ class GrayscalePatch: # Color accuracy delta_e_2000: float - delta_l: float # Lightness error - delta_uv: float # Chromaticity error (Δu'v') + delta_l: float # Lightness error + delta_uv: float # Chromaticity error (Δu'v') # RGB balance rgb_balance_error: float # Max RGB deviation - r_ratio: float # R channel relative to Y - g_ratio: float # G channel relative to Y - b_ratio: float # B channel relative to Y + r_ratio: float # R channel relative to Y + g_ratio: float # G channel relative to Y + b_ratio: float # B channel relative to Y # Correlated Color Temperature measured_cct: float target_cct: float - cct_error: float # Delta CCT (Kelvin) - duv: float # Distance from Planckian locus + cct_error: float # Delta CCT (Kelvin) + duv: float # Distance from Planckian locus # Gamma tracking measured_gamma: float @@ -88,6 +93,7 @@ def normalized_level(self) -> float: @dataclass class GrayscaleRegionAnalysis: """Analysis for specific grayscale region (shadows, mids, highlights).""" + region_name: str level_range: tuple[int, int] patch_count: int @@ -112,6 +118,7 @@ class GrayscaleRegionAnalysis: @dataclass class GrayscaleResult: """Complete grayscale verification result.""" + patch_measurements: list[GrayscalePatch] region_analysis: dict[str, GrayscaleRegionAnalysis] @@ -120,7 +127,7 @@ class GrayscaleResult: target_gamma_type: GammaType target_gamma_value: float # 2.2, 2.4, etc. (for power law) target_luminance: float # Peak white in cd/m² - target_black: float # Black level in cd/m² + target_black: float # Black level in cd/m² # Overall statistics delta_e_mean: float @@ -169,6 +176,7 @@ def passed(self) -> bool: # EOTF Functions # ============================================================================= + def gamma_power_law(x: np.ndarray, gamma: float = 2.2) -> np.ndarray: """Simple power law gamma: Y = X^gamma""" return np.power(np.clip(x, 0, 1), gamma) @@ -177,15 +185,10 @@ def gamma_power_law(x: np.ndarray, gamma: float = 2.2) -> np.ndarray: def gamma_srgb(x: np.ndarray) -> np.ndarray: """sRGB EOTF (IEC 61966-2-1).""" x = np.clip(x, 0, 1) - return np.where( - x <= 0.04045, - x / 12.92, - np.power((x + 0.055) / 1.055, 2.4) - ) + return np.where(x <= 0.04045, x / 12.92, np.power((x + 0.055) / 1.055, 2.4)) -def gamma_bt1886(x: np.ndarray, gamma: float = 2.4, - Lw: float = 100.0, Lb: float = 0.0) -> np.ndarray: +def gamma_bt1886(x: np.ndarray, gamma: float = 2.4, Lw: float = 100.0, Lb: float = 0.0) -> np.ndarray: """ BT.1886 EOTF (ITU-R BT.1886). @@ -199,8 +202,8 @@ def gamma_bt1886(x: np.ndarray, gamma: float = 2.4, Linear light output """ x = np.clip(x, 0, 1) - a = np.power(Lw ** (1/gamma) - Lb ** (1/gamma), gamma) - b = Lb ** (1/gamma) / (Lw ** (1/gamma) - Lb ** (1/gamma)) + a = np.power(Lw ** (1 / gamma) - Lb ** (1 / gamma), gamma) + b = Lb ** (1 / gamma) / (Lw ** (1 / gamma) - Lb ** (1 / gamma)) return a * np.power(np.maximum(x + b, 0), gamma) @@ -208,11 +211,7 @@ def gamma_l_star(x: np.ndarray) -> np.ndarray: """L* perceptual response (CIE L*).""" x = np.clip(x, 0, 1) delta = 6 / 29 - return np.where( - x > delta**3, - np.power(x, 1/3), - x / (3 * delta**2) + 4/29 - ) + return np.where(x > delta**3, np.power(x, 1 / 3), x / (3 * delta**2) + 4 / 29) def calculate_gamma_at_level(input_level: float, output_level: float) -> float: @@ -237,6 +236,7 @@ def calculate_gamma_at_level(input_level: float, output_level: float) -> float: # Color Temperature Functions # ============================================================================= + def xy_to_cct(x: float, y: float) -> tuple[float, float]: """ Calculate Correlated Color Temperature from xy chromaticity. @@ -261,7 +261,7 @@ def xy_to_cct(x: float, y: float) -> tuple[float, float]: # Planckian locus approximation at CCT u_p, v_p = cct_to_uv(CCT) - duv = np.sqrt((u_prime - u_p)**2 + (v_prime - v_p)**2) + duv = np.sqrt((u_prime - u_p) ** 2 + (v_prime - v_p) ** 2) # Sign of Duv (above or below Planckian) if v_prime < v_p: @@ -289,18 +289,16 @@ def cct_to_uv(cct: float) -> tuple[float, float]: T = cct if T <= 4000: - x = (-0.2661239e9 / T**3 - 0.2343589e6 / T**2 + - 0.8776956e3 / T + 0.179910) + x = -0.2661239e9 / T**3 - 0.2343589e6 / T**2 + 0.8776956e3 / T + 0.179910 else: - x = (-3.0258469e9 / T**3 + 2.1070379e6 / T**2 + - 0.2226347e3 / T + 0.240390) + x = -3.0258469e9 / T**3 + 2.1070379e6 / T**2 + 0.2226347e3 / T + 0.240390 if T <= 2222: - y = (-1.1063814 * x**3 - 1.34811020 * x**2 + 2.18555832 * x - 0.20219683) + y = -1.1063814 * x**3 - 1.34811020 * x**2 + 2.18555832 * x - 0.20219683 elif T <= 4000: - y = (-0.9549476 * x**3 - 1.37418593 * x**2 + 2.09137015 * x - 0.16748867) + y = -0.9549476 * x**3 - 1.37418593 * x**2 + 2.09137015 * x - 0.16748867 else: - y = (3.0817580 * x**3 - 5.87338670 * x**2 + 3.75112997 * x - 0.37001483) + y = 3.0817580 * x**3 - 5.87338670 * x**2 + 3.75112997 * x - 0.37001483 # Convert xy to u'v' u_prime = 4 * x / (-2 * x + 12 * y + 3) @@ -331,13 +329,14 @@ def xyz_to_uv(xyz: tuple[float, float, float]) -> tuple[float, float]: def delta_uv(uv1: tuple[float, float], uv2: tuple[float, float]) -> float: """Calculate Δu'v' between two chromaticities.""" - return np.sqrt((uv2[0] - uv1[0])**2 + (uv2[1] - uv1[1])**2) + return np.sqrt((uv2[0] - uv1[0]) ** 2 + (uv2[1] - uv1[1]) ** 2) # ============================================================================= # Grayscale Grade Functions # ============================================================================= + def grade_from_grayscale(delta_e: float, cct_deviation: float) -> GrayscaleGrade: """Determine grayscale grade from ΔE and CCT deviation.""" if delta_e < 1.0 and cct_deviation < 50: @@ -367,8 +366,8 @@ def grade_to_string(grade: GrayscaleGrade) -> str: # Lab Conversion # ============================================================================= -def xyz_to_lab(xyz: tuple[float, float, float], - illuminant: str = "D65") -> tuple[float, float, float]: + +def xyz_to_lab(xyz: tuple[float, float, float], illuminant: str = "D65") -> tuple[float, float, float]: """Convert XYZ to Lab.""" white_points = { "D50": (96.422, 100.0, 82.521), @@ -385,7 +384,7 @@ def xyz_to_lab(xyz: tuple[float, float, float], def f(t): delta = 6 / 29 if t > delta**3: - return t ** (1/3) + return t ** (1 / 3) else: return t / (3 * delta**2) + 4 / 29 @@ -396,8 +395,7 @@ def f(t): return (L, a, b) -def delta_e_2000(lab1: tuple[float, float, float], - lab2: tuple[float, float, float]) -> float: +def delta_e_2000(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float: """Calculate CIEDE2000 Delta E.""" L1, a1, b1 = lab1 L2, a2, b2 = lab2 @@ -437,26 +435,29 @@ def delta_e_2000(lab1: tuple[float, float, float], h_prime_sum += 360 h_prime_avg = h_prime_sum / 2 - T = (1 - 0.17 * np.cos(np.radians(h_prime_avg - 30)) + - 0.24 * np.cos(np.radians(2 * h_prime_avg)) + - 0.32 * np.cos(np.radians(3 * h_prime_avg + 6)) - - 0.20 * np.cos(np.radians(4 * h_prime_avg - 63))) + T = ( + 1 + - 0.17 * np.cos(np.radians(h_prime_avg - 30)) + + 0.24 * np.cos(np.radians(2 * h_prime_avg)) + + 0.32 * np.cos(np.radians(3 * h_prime_avg + 6)) + - 0.20 * np.cos(np.radians(4 * h_prime_avg - 63)) + ) - delta_theta = 30 * np.exp(-((h_prime_avg - 275) / 25)**2) + delta_theta = 30 * np.exp(-(((h_prime_avg - 275) / 25) ** 2)) R_C = 2 * np.sqrt(C_prime_avg**7 / (C_prime_avg**7 + 25**7)) - S_L = 1 + (0.015 * (L_prime_avg - 50)**2) / np.sqrt(20 + (L_prime_avg - 50)**2) + S_L = 1 + (0.015 * (L_prime_avg - 50) ** 2) / np.sqrt(20 + (L_prime_avg - 50) ** 2) S_C = 1 + 0.045 * C_prime_avg S_H = 1 + 0.015 * C_prime_avg * T R_T = -np.sin(np.radians(2 * delta_theta)) * R_C delta_E = np.sqrt( - (delta_L_prime / S_L)**2 + - (delta_C_prime / S_C)**2 + - (delta_H_prime / S_H)**2 + - R_T * (delta_C_prime / S_C) * (delta_H_prime / S_H) + (delta_L_prime / S_L) ** 2 + + (delta_C_prime / S_C) ** 2 + + (delta_H_prime / S_H) ** 2 + + R_T * (delta_C_prime / S_C) * (delta_H_prime / S_H) ) return delta_E @@ -466,6 +467,7 @@ def delta_e_2000(lab1: tuple[float, float, float], # Grayscale Verification Class # ============================================================================= + class GrayscaleVerifier: """ Grayscale verification engine. @@ -496,12 +498,14 @@ class GrayscaleVerifier: "DCI-P3": (0.314, 0.351), } - def __init__(self, - target_whitepoint: str = "D65", - target_gamma_type: GammaType = GammaType.BT1886, - target_gamma_value: float = 2.4, - target_luminance: float = 100.0, - target_black: float = 0.05): + def __init__( + self, + target_whitepoint: str = "D65", + target_gamma_type: GammaType = GammaType.BT1886, + target_gamma_value: float = 2.4, + target_luminance: float = 100.0, + target_black: float = 0.05, + ): """ Initialize grayscale verifier. @@ -538,10 +542,7 @@ def _get_target_luminance(self, level: int) -> float: output = gamma_srgb(np.array([normalized]))[0] elif self.target_gamma_type == GammaType.BT1886: return gamma_bt1886( - np.array([normalized]), - self.target_gamma_value, - self.target_luminance, - self.target_black + np.array([normalized]), self.target_gamma_value, self.target_luminance, self.target_black )[0] elif self.target_gamma_type == GammaType.L_STAR: output = gamma_l_star(np.array([normalized]))[0] @@ -551,10 +552,9 @@ def _get_target_luminance(self, level: int) -> float: # Scale to luminance range return self.target_black + output * (self.target_luminance - self.target_black) - def verify(self, - measurements: list[tuple[int, tuple[float, float, float]]], - display_name: str = "", - profile_name: str = "") -> GrayscaleResult: + def verify( + self, measurements: list[tuple[int, tuple[float, float, float]]], display_name: str = "", profile_name: str = "" + ) -> GrayscaleResult: """ Perform grayscale verification. @@ -620,12 +620,17 @@ def verify(self, # Determine grades overall_grade = grade_from_grayscale(delta_e_mean, abs(cct_mean - self._target_cct)) - shadow_grade = region_analysis.get("shadows", - GrayscaleRegionAnalysis("shadows", (0, 20), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, GrayscaleGrade.POOR)).grade - midtone_grade = region_analysis.get("midtones", - GrayscaleRegionAnalysis("midtones", (25, 75), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, GrayscaleGrade.POOR)).grade - highlight_grade = region_analysis.get("highlights", - GrayscaleRegionAnalysis("highlights", (80, 100), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, GrayscaleGrade.POOR)).grade + shadow_grade = region_analysis.get( + "shadows", GrayscaleRegionAnalysis("shadows", (0, 20), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, GrayscaleGrade.POOR) + ).grade + midtone_grade = region_analysis.get( + "midtones", + GrayscaleRegionAnalysis("midtones", (25, 75), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, GrayscaleGrade.POOR), + ).grade + highlight_grade = region_analysis.get( + "highlights", + GrayscaleRegionAnalysis("highlights", (80, 100), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, GrayscaleGrade.POOR), + ).grade return GrayscaleResult( patch_measurements=patch_measurements, @@ -657,8 +662,9 @@ def verify(self, profile_name=profile_name, ) - def _analyze_patch(self, level: int, measured_xyz: tuple[float, float, float], - peak_luminance: float, black_level: float) -> GrayscalePatch: + def _analyze_patch( + self, level: int, measured_xyz: tuple[float, float, float], peak_luminance: float, black_level: float + ) -> GrayscalePatch: """Analyze single grayscale patch.""" # Input RGB (assuming equal R=G=B for grayscale) input_value = int(level * 255 / 100) @@ -741,8 +747,7 @@ def _analyze_patch(self, level: int, measured_xyz: tuple[float, float, float], gamma_error=gamma_error, ) - def _analyze_regions(self, - patches: list[GrayscalePatch]) -> dict[str, GrayscaleRegionAnalysis]: + def _analyze_regions(self, patches: list[GrayscalePatch]) -> dict[str, GrayscaleRegionAnalysis]: """Analyze patches by region (shadows, midtones, highlights).""" region_analysis: dict[str, GrayscaleRegionAnalysis] = {} @@ -787,13 +792,15 @@ def _analyze_regions(self, # Utility Functions # ============================================================================= + def generate_grayscale_levels(steps: int = 21) -> list[int]: """Generate grayscale levels for verification.""" return [int(100 * i / (steps - 1)) for i in range(steps)] -def create_test_measurements(peak_luminance: float = 100.0, - black_level: float = 0.05) -> list[tuple[int, tuple[float, float, float]]]: +def create_test_measurements( + peak_luminance: float = 100.0, black_level: float = 0.05 +) -> list[tuple[int, tuple[float, float, float]]]: """Create simulated grayscale measurements for testing.""" np.random.seed(42) measurements = [] @@ -808,7 +815,7 @@ def create_test_measurements(peak_luminance: float = 100.0, Y = gamma_bt1886(np.array([normalized]), 2.4, peak_luminance, black_level)[0] # Add small random error - Y *= (1 + np.random.normal(0, 0.01)) + Y *= 1 + np.random.normal(0, 0.01) # Calculate XYZ from Y and white point (with slight chromaticity error) x = x_white + np.random.normal(0, 0.002) @@ -831,8 +838,7 @@ def print_grayscale_summary(result: GrayscaleResult) -> None: print(f"Profile: {result.profile_name or 'Unknown'}") print(f"Timestamp: {result.timestamp}") print() - print(f"Target: {result.target_whitepoint}, " - f"Gamma {result.target_gamma_type.name} {result.target_gamma_value}") + print(f"Target: {result.target_whitepoint}, Gamma {result.target_gamma_type.name} {result.target_gamma_value}") print(f"Overall Grade: {grade_to_string(result.overall_grade)}") print() print("Delta E Statistics (CIEDE2000):") @@ -854,9 +860,11 @@ def print_grayscale_summary(result: GrayscaleResult) -> None: print() print("Region Analysis:") for region_name, analysis in result.region_analysis.items(): - print(f" {region_name.title()}: " - f"Mean ΔE = {analysis.delta_e_mean:.2f}, " - f"CCT = {analysis.cct_mean:.0f}K ({analysis.grade.name})") + print( + f" {region_name.title()}: " + f"Mean ΔE = {analysis.delta_e_mean:.2f}, " + f"CCT = {analysis.cct_mean:.0f}K ({analysis.grade.name})" + ) print("=" * 60) @@ -875,9 +883,5 @@ def print_grayscale_summary(result: GrayscaleResult) -> None: ) test_measurements = create_test_measurements() - result = verifier.verify( - test_measurements, - display_name="Test Display", - profile_name="Test Profile" - ) + result = verifier.verify(test_measurements, display_name="Test Display", profile_name="Test Profile") print_grayscale_summary(result) diff --git a/calibrate_pro/verification/measured_verify.py b/calibrate_pro/verification/measured_verify.py index 25407ef..5149bd6 100644 --- a/calibrate_pro/verification/measured_verify.py +++ b/calibrate_pro/verification/measured_verify.py @@ -35,58 +35,58 @@ # ============================================================================= COLORCHECKER_SRGB_PATCHES = [ - ("Dark Skin", (0.453, 0.317, 0.264)), - ("Light Skin", (0.779, 0.577, 0.505)), - ("Blue Sky", (0.355, 0.480, 0.611)), - ("Foliage", (0.352, 0.422, 0.253)), - ("Blue Flower", (0.508, 0.502, 0.691)), - ("Bluish Green", (0.362, 0.745, 0.675)), - ("Orange", (0.879, 0.485, 0.183)), - ("Purplish Blue", (0.266, 0.358, 0.667)), - ("Moderate Red", (0.778, 0.321, 0.381)), - ("Purple", (0.367, 0.227, 0.414)), - ("Yellow Green", (0.623, 0.741, 0.246)), - ("Orange Yellow", (0.904, 0.634, 0.154)), - ("Blue", (0.139, 0.248, 0.577)), - ("Green", (0.262, 0.584, 0.291)), - ("Red", (0.752, 0.197, 0.178)), - ("Yellow", (0.938, 0.857, 0.159)), - ("Magenta", (0.752, 0.313, 0.577)), - ("Cyan", (0.121, 0.544, 0.659)), - ("White", (0.961, 0.961, 0.961)), - ("Neutral 8", (0.784, 0.784, 0.784)), - ("Neutral 6.5", (0.584, 0.584, 0.584)), - ("Neutral 5", (0.420, 0.420, 0.420)), - ("Neutral 3.5", (0.258, 0.258, 0.258)), - ("Black", (0.085, 0.085, 0.085)), + ("Dark Skin", (0.453, 0.317, 0.264)), + ("Light Skin", (0.779, 0.577, 0.505)), + ("Blue Sky", (0.355, 0.480, 0.611)), + ("Foliage", (0.352, 0.422, 0.253)), + ("Blue Flower", (0.508, 0.502, 0.691)), + ("Bluish Green", (0.362, 0.745, 0.675)), + ("Orange", (0.879, 0.485, 0.183)), + ("Purplish Blue", (0.266, 0.358, 0.667)), + ("Moderate Red", (0.778, 0.321, 0.381)), + ("Purple", (0.367, 0.227, 0.414)), + ("Yellow Green", (0.623, 0.741, 0.246)), + ("Orange Yellow", (0.904, 0.634, 0.154)), + ("Blue", (0.139, 0.248, 0.577)), + ("Green", (0.262, 0.584, 0.291)), + ("Red", (0.752, 0.197, 0.178)), + ("Yellow", (0.938, 0.857, 0.159)), + ("Magenta", (0.752, 0.313, 0.577)), + ("Cyan", (0.121, 0.544, 0.659)), + ("White", (0.961, 0.961, 0.961)), + ("Neutral 8", (0.784, 0.784, 0.784)), + ("Neutral 6.5", (0.584, 0.584, 0.584)), + ("Neutral 5", (0.420, 0.420, 0.420)), + ("Neutral 3.5", (0.258, 0.258, 0.258)), + ("Black", (0.085, 0.085, 0.085)), ] # ColorChecker Classic D50 Lab reference values (for Delta E computation) COLORCHECKER_REFERENCE_LAB_D50 = { - "Dark Skin": (37.986, 13.555, 14.059), - "Light Skin": (65.711, 18.130, 17.810), - "Blue Sky": (49.927, -4.880, -21.925), - "Foliage": (43.139, -13.095, 21.905), - "Blue Flower": (55.112, 8.844, -25.399), - "Bluish Green": (70.719, -33.397, -0.199), - "Orange": (62.661, 36.067, 57.096), - "Purplish Blue": (40.020, 10.410, -45.964), - "Moderate Red": (51.124, 48.239, 16.248), - "Purple": (30.325, 22.976, -21.587), - "Yellow Green": (72.532, -23.709, 57.255), - "Orange Yellow": (71.941, 19.363, 67.857), - "Blue": (28.778, 14.179, -50.297), - "Green": (55.261, -38.342, 31.370), - "Red": (42.101, 53.378, 28.190), - "Yellow": (81.733, 4.039, 79.819), - "Magenta": (51.935, 49.986, -14.574), - "Cyan": (51.038, -28.631, -28.638), - "White": (96.539, -0.425, 1.186), - "Neutral 8": (81.257, -0.638, -0.335), - "Neutral 6.5": (66.766, -0.734, -0.504), - "Neutral 5": (50.867, -0.153, -0.270), - "Neutral 3.5": (35.656, -0.421, -1.231), - "Black": (20.461, -0.079, -0.973), + "Dark Skin": (37.986, 13.555, 14.059), + "Light Skin": (65.711, 18.130, 17.810), + "Blue Sky": (49.927, -4.880, -21.925), + "Foliage": (43.139, -13.095, 21.905), + "Blue Flower": (55.112, 8.844, -25.399), + "Bluish Green": (70.719, -33.397, -0.199), + "Orange": (62.661, 36.067, 57.096), + "Purplish Blue": (40.020, 10.410, -45.964), + "Moderate Red": (51.124, 48.239, 16.248), + "Purple": (30.325, 22.976, -21.587), + "Yellow Green": (72.532, -23.709, 57.255), + "Orange Yellow": (71.941, 19.363, 67.857), + "Blue": (28.778, 14.179, -50.297), + "Green": (55.261, -38.342, 31.370), + "Red": (42.101, 53.378, 28.190), + "Yellow": (81.733, 4.039, 79.819), + "Magenta": (51.935, 49.986, -14.574), + "Cyan": (51.038, -28.631, -28.638), + "White": (96.539, -0.425, 1.186), + "Neutral 8": (81.257, -0.638, -0.335), + "Neutral 6.5": (66.766, -0.734, -0.504), + "Neutral 5": (50.867, -0.153, -0.270), + "Neutral 3.5": (35.656, -0.421, -1.231), + "Black": (20.461, -0.079, -0.973), } @@ -94,6 +94,7 @@ # Color math helpers (self-contained to avoid circular imports) # ============================================================================= + def _xyz_to_lab( xyz: tuple[float, float, float], illuminant: str = "D50", @@ -112,10 +113,10 @@ def _xyz_to_lab( def f(t: float) -> float: delta = 6.0 / 29.0 - if t > delta ** 3: + if t > delta**3: return t ** (1.0 / 3.0) else: - return t / (3.0 * delta ** 2) + 4.0 / 29.0 + return t / (3.0 * delta**2) + 4.0 / 29.0 L = 116.0 * f(y) - 16.0 a = 500.0 * (f(x) - f(y)) @@ -132,17 +133,17 @@ def _delta_e_2000( L1, a1, b1 = lab1 L2, a2, b2 = lab2 - C1 = np.sqrt(a1 ** 2 + b1 ** 2) - C2 = np.sqrt(a2 ** 2 + b2 ** 2) + C1 = np.sqrt(a1**2 + b1**2) + C2 = np.sqrt(a2**2 + b2**2) C_avg = (C1 + C2) / 2.0 - G = 0.5 * (1.0 - np.sqrt(C_avg ** 7 / (C_avg ** 7 + 25.0 ** 7))) + G = 0.5 * (1.0 - np.sqrt(C_avg**7 / (C_avg**7 + 25.0**7))) a1p = a1 * (1.0 + G) a2p = a2 * (1.0 + G) - C1p = np.sqrt(a1p ** 2 + b1 ** 2) - C2p = np.sqrt(a2p ** 2 + b2 ** 2) + C1p = np.sqrt(a1p**2 + b1**2) + C2p = np.sqrt(a2p**2 + b2**2) h1p = np.degrees(np.arctan2(b1, a1p)) % 360.0 h2p = np.degrees(np.arctan2(b2, a2p)) % 360.0 @@ -167,26 +168,23 @@ def _delta_e_2000( hp_sum += 360.0 hp_avg = hp_sum / 2.0 - T = (1.0 - - 0.17 * np.cos(np.radians(hp_avg - 30.0)) - + 0.24 * np.cos(np.radians(2.0 * hp_avg)) - + 0.32 * np.cos(np.radians(3.0 * hp_avg + 6.0)) - - 0.20 * np.cos(np.radians(4.0 * hp_avg - 63.0))) + T = ( + 1.0 + - 0.17 * np.cos(np.radians(hp_avg - 30.0)) + + 0.24 * np.cos(np.radians(2.0 * hp_avg)) + + 0.32 * np.cos(np.radians(3.0 * hp_avg + 6.0)) + - 0.20 * np.cos(np.radians(4.0 * hp_avg - 63.0)) + ) - d_theta = 30.0 * np.exp(-((hp_avg - 275.0) / 25.0) ** 2) - R_C = 2.0 * np.sqrt(Cp_avg ** 7 / (Cp_avg ** 7 + 25.0 ** 7)) + d_theta = 30.0 * np.exp(-(((hp_avg - 275.0) / 25.0) ** 2)) + R_C = 2.0 * np.sqrt(Cp_avg**7 / (Cp_avg**7 + 25.0**7)) S_L = 1.0 + (0.015 * (Lp_avg - 50.0) ** 2) / np.sqrt(20.0 + (Lp_avg - 50.0) ** 2) S_C = 1.0 + 0.045 * Cp_avg S_H = 1.0 + 0.015 * Cp_avg * T R_T = -np.sin(np.radians(2.0 * d_theta)) * R_C - val = ( - (dLp / S_L) ** 2 - + (dCp / S_C) ** 2 - + (dHp / S_H) ** 2 - + R_T * (dCp / S_C) * (dHp / S_H) - ) + val = (dLp / S_L) ** 2 + (dCp / S_C) ** 2 + (dHp / S_H) ** 2 + R_T * (dCp / S_C) * (dHp / S_H) return float(np.sqrt(max(0.0, val))) @@ -195,6 +193,7 @@ def _delta_e_2000( # ArgyllCMS backend helper # ============================================================================= + def _find_argyll_spotread() -> str | None: """Locate the ArgyllCMS ``spotread`` binary using the shared ArgyllConfig.""" import os @@ -203,9 +202,10 @@ def _find_argyll_spotread() -> str | None: # Use the project's ArgyllConfig which knows about DisplayCAL's bundled install try: from calibrate_pro.hardware.argyll_backend import ArgyllConfig + config = ArgyllConfig() if config.find_argyll(): - exe = "spotread.exe" if os.name == 'nt' else "spotread" + exe = "spotread.exe" if os.name == "nt" else "spotread" spotread = config.bin_path / exe if spotread.exists(): return str(spotread) @@ -214,6 +214,7 @@ def _find_argyll_spotread() -> str | None: # Fallback: check PATH import shutil + found = shutil.which("spotread") if found: return found @@ -247,7 +248,7 @@ def _argyll_measure_xyz(r: float, g: float, b: float) -> tuple[float, float, flo # Use a module-level cached backend to avoid re-initializing for each patch global _argyll_backend_cache - if '_argyll_backend_cache' not in globals() or _argyll_backend_cache is None: + if "_argyll_backend_cache" not in globals() or _argyll_backend_cache is None: config = ArgyllConfig() if not config.find_argyll(): raise RuntimeError("ArgyllCMS not found") @@ -295,6 +296,7 @@ def _manual_measure_xyz(r: float, g: float, b: float) -> tuple[float, float, flo # MeasuredVerification # ============================================================================= + class MeasuredVerification: """ Colorimeter-based measured verification of display calibration. @@ -381,6 +383,7 @@ def display_and_measure( try: from calibrate_pro.panels.detection import enumerate_displays + displays = enumerate_displays() if display_index < len(displays): d = displays[display_index] @@ -397,9 +400,7 @@ def display_and_measure( root.geometry(f"{screen_w}x{screen_h}+{screen_x}+{screen_y}") except Exception: - root.geometry( - f"{root.winfo_screenwidth()}x{root.winfo_screenheight()}+0+0" - ) + root.geometry(f"{root.winfo_screenwidth()}x{root.winfo_screenheight()}+0+0") root.configure(background=hex_color) root.update() @@ -460,13 +461,15 @@ def verify_colorchecker(self, display_index: int = 0) -> dict: status = "PASS" if de < 2.0 else ("WARN" if de < 3.0 else "FAIL") print(f" dE={de:.2f} [{status}]") - patches_result.append({ - "name": name, - "delta_e": de, - "measured_lab": measured_lab, - "reference_lab": ref_lab, - "measured_xyz": (X, Y, Z), - }) + patches_result.append( + { + "name": name, + "delta_e": de, + "measured_lab": measured_lab, + "reference_lab": ref_lab, + "measured_xyz": (X, Y, Z), + } + ) de_arr = np.array(delta_e_values) if delta_e_values else np.array([0.0]) avg_de = float(np.mean(de_arr)) @@ -554,7 +557,7 @@ def verify_grayscale( measured_gamma = 0.0 # Target luminance at this step - target_lum = level ** target_gamma if level > 0 else 0.0 + target_lum = level**target_gamma if level > 0 else 0.0 gamma_err = abs(measured_gamma - target_gamma) if level > 0.01 else 0.0 gamma_errors.append(gamma_err) @@ -578,17 +581,19 @@ def verify_grayscale( de = _delta_e_2000(target_lab, measured_lab) delta_e_values.append(de) - step_results.append({ - "level": float(level), - "target_luminance": float(target_lum), - "measured_xyz": (X, Y, Z), - "measured_luminance": float(Y), - "relative_luminance": float(rel_lum), - "measured_gamma": float(measured_gamma), - "target_gamma": float(target_gamma), - "gamma_error": float(gamma_err), - "delta_e": float(de), - }) + step_results.append( + { + "level": float(level), + "target_luminance": float(target_lum), + "measured_xyz": (X, Y, Z), + "measured_luminance": float(Y), + "relative_luminance": float(rel_lum), + "measured_gamma": float(measured_gamma), + "target_gamma": float(target_gamma), + "gamma_error": float(gamma_err), + "delta_e": float(de), + } + ) ge_arr = np.array(gamma_errors) de_arr = np.array(delta_e_values) if delta_e_values else np.array([0.0]) diff --git a/calibrate_pro/verification/patch_sets.py b/calibrate_pro/verification/patch_sets.py index 76892b6..f640cdb 100644 --- a/calibrate_pro/verification/patch_sets.py +++ b/calibrate_pro/verification/patch_sets.py @@ -40,6 +40,7 @@ # Patch Dataclass # ============================================================================= + @dataclass(frozen=True, slots=True) class CalibrationPatch: """A single calibration patch with sRGB values and metadata. @@ -51,12 +52,13 @@ class CalibrationPatch: b: Blue channel, sRGB 0.0-1.0. category: Semantic category for grouping and analysis. """ + name: str r: float # sRGB 0-1 g: float # sRGB 0-1 b: float # sRGB 0-1 category: str # "grayscale", "primary", "secondary", "saturation", - # "colorchecker", "skin", "pluge", "broadcast" + # "colorchecker", "skin", "pluge", "broadcast" @property def rgb(self) -> tuple[float, float, float]: @@ -73,17 +75,14 @@ def rgb_8bit(self) -> tuple[int, int, int]: ) def __repr__(self) -> str: - return ( - f"CalibrationPatch({self.name!r}, " - f"r={self.r:.4f}, g={self.g:.4f}, b={self.b:.4f}, " - f"cat={self.category!r})" - ) + return f"CalibrationPatch({self.name!r}, r={self.r:.4f}, g={self.g:.4f}, b={self.b:.4f}, cat={self.category!r})" # ============================================================================= # Helper: sRGB gamma # ============================================================================= + def _srgb_gamma_compress(linear: float) -> float: """IEC 61966-2-1 forward transfer (linear -> sRGB).""" if linear <= 0.0031308: @@ -181,11 +180,11 @@ def _srgb_gamma_expand(srgb: float) -> float: # Hue angles (HSV): R=0, Y=60, G=120, C=180, B=240, M=300 _SWEEP_HUES = [ - ("Red", 0.0), + ("Red", 0.0), ("Yellow", 60.0), ("Green", 120.0), - ("Cyan", 180.0), - ("Blue", 240.0), + ("Cyan", 180.0), + ("Blue", 240.0), ("Magenta", 300.0), ] @@ -205,13 +204,15 @@ def _build_saturation_sweeps() -> list[CalibrationPatch]: cat = "primary" else: cat = "secondary" - patches.append(CalibrationPatch( - name=f"{hue_name} {sat_pct}% Sat", - r=round(r, 4), - g=round(g, 4), - b=round(b, 4), - category=cat, - )) + patches.append( + CalibrationPatch( + name=f"{hue_name} {sat_pct}% Sat", + r=round(r, 4), + g=round(g, 4), + b=round(b, 4), + category=cat, + ) + ) return patches @@ -224,27 +225,30 @@ def _build_saturation_sweeps() -> list[CalibrationPatch]: # Standard broadcast verification set. 75% bars are the traditional # broadcast standard; 100% bars stress-test peak gamut. + def _build_primaries_secondaries() -> list[CalibrationPatch]: patches: list[CalibrationPatch] = [] colors = [ # (name, category, r100, g100, b100) - ("Red", "primary", 1.0, 0.0, 0.0), - ("Green", "primary", 0.0, 1.0, 0.0), - ("Blue", "primary", 0.0, 0.0, 1.0), - ("Cyan", "secondary", 0.0, 1.0, 1.0), + ("Red", "primary", 1.0, 0.0, 0.0), + ("Green", "primary", 0.0, 1.0, 0.0), + ("Blue", "primary", 0.0, 0.0, 1.0), + ("Cyan", "secondary", 0.0, 1.0, 1.0), ("Magenta", "secondary", 1.0, 0.0, 1.0), - ("Yellow", "secondary", 1.0, 1.0, 0.0), + ("Yellow", "secondary", 1.0, 1.0, 0.0), ] for level_pct in (75, 100): scale = level_pct / 100.0 for name, cat, r, g, b in colors: - patches.append(CalibrationPatch( - name=f"{name} {level_pct}%", - r=round(r * scale, 4), - g=round(g * scale, 4), - b=round(b * scale, 4), - category=cat, - )) + patches.append( + CalibrationPatch( + name=f"{name} {level_pct}%", + r=round(r * scale, 4), + g=round(g * scale, 4), + b=round(b * scale, 4), + category=cat, + ) + ) return patches @@ -261,50 +265,67 @@ def _build_primaries_secondaries() -> list[CalibrationPatch]: # sRGB values for 75% bars: the active component is 0.75, inactive is 0.0. # sRGB values for 100% bars: the active component is 1.0, inactive is 0.0. + def _build_smpte_bars() -> list[CalibrationPatch]: patches: list[CalibrationPatch] = [] # 75% amplitude bars (standard SMPTE) bars_75 = [ - ("SMPTE 75% White", 0.75, 0.75, 0.75, "grayscale"), - ("SMPTE 75% Yellow", 0.75, 0.75, 0.00, "secondary"), - ("SMPTE 75% Cyan", 0.00, 0.75, 0.75, "secondary"), - ("SMPTE 75% Green", 0.00, 0.75, 0.00, "primary"), + ("SMPTE 75% White", 0.75, 0.75, 0.75, "grayscale"), + ("SMPTE 75% Yellow", 0.75, 0.75, 0.00, "secondary"), + ("SMPTE 75% Cyan", 0.00, 0.75, 0.75, "secondary"), + ("SMPTE 75% Green", 0.00, 0.75, 0.00, "primary"), ("SMPTE 75% Magenta", 0.75, 0.00, 0.75, "secondary"), - ("SMPTE 75% Red", 0.75, 0.00, 0.00, "primary"), - ("SMPTE 75% Blue", 0.00, 0.00, 0.75, "primary"), - ("SMPTE 75% Black", 0.00, 0.00, 0.00, "grayscale"), + ("SMPTE 75% Red", 0.75, 0.00, 0.00, "primary"), + ("SMPTE 75% Blue", 0.00, 0.00, 0.75, "primary"), + ("SMPTE 75% Black", 0.00, 0.00, 0.00, "grayscale"), ] for name, r, g, b, cat in bars_75: patches.append(CalibrationPatch(name=name, r=r, g=g, b=b, category=cat)) # 100% amplitude bars bars_100 = [ - ("SMPTE 100% White", 1.0, 1.0, 1.0, "grayscale"), - ("SMPTE 100% Yellow", 1.0, 1.0, 0.0, "secondary"), - ("SMPTE 100% Cyan", 0.0, 1.0, 1.0, "secondary"), - ("SMPTE 100% Green", 0.0, 1.0, 0.0, "primary"), + ("SMPTE 100% White", 1.0, 1.0, 1.0, "grayscale"), + ("SMPTE 100% Yellow", 1.0, 1.0, 0.0, "secondary"), + ("SMPTE 100% Cyan", 0.0, 1.0, 1.0, "secondary"), + ("SMPTE 100% Green", 0.0, 1.0, 0.0, "primary"), ("SMPTE 100% Magenta", 1.0, 0.0, 1.0, "secondary"), - ("SMPTE 100% Red", 1.0, 0.0, 0.0, "primary"), - ("SMPTE 100% Blue", 0.0, 0.0, 1.0, "primary"), - ("SMPTE 100% Black", 0.0, 0.0, 0.0, "grayscale"), + ("SMPTE 100% Red", 1.0, 0.0, 0.0, "primary"), + ("SMPTE 100% Blue", 0.0, 0.0, 1.0, "primary"), + ("SMPTE 100% Black", 0.0, 0.0, 0.0, "grayscale"), ] for name, r, g, b, cat in bars_100: patches.append(CalibrationPatch(name=name, r=r, g=g, b=b, category=cat)) # Sub-black / PLUGE region of SMPTE bars # -4% (below black, should be invisible), 0% (black), +4% (just visible) - patches.append(CalibrationPatch( - name="SMPTE -4% Sub-Black", r=0.0, g=0.0, b=0.0, category="pluge", - )) - patches.append(CalibrationPatch( - name="SMPTE 0% Black", r=0.0, g=0.0, b=0.0, category="pluge", - )) - patches.append(CalibrationPatch( - name="SMPTE +4% Super-Black", - r=round(4 / 100.0, 4), g=round(4 / 100.0, 4), b=round(4 / 100.0, 4), - category="pluge", - )) + patches.append( + CalibrationPatch( + name="SMPTE -4% Sub-Black", + r=0.0, + g=0.0, + b=0.0, + category="pluge", + ) + ) + patches.append( + CalibrationPatch( + name="SMPTE 0% Black", + r=0.0, + g=0.0, + b=0.0, + category="pluge", + ) + ) + patches.append( + CalibrationPatch( + name="SMPTE +4% Super-Black", + r=round(4 / 100.0, 4), + g=round(4 / 100.0, 4), + b=round(4 / 100.0, 4), + category="pluge", + ) + ) return patches @@ -320,14 +341,14 @@ def _build_smpte_bars() -> list[CalibrationPatch]: # In EBU 75/0 mode, the peak level is 0.75 and the floor is 0.0. EBU_BARS: list[CalibrationPatch] = [ - CalibrationPatch("EBU White", 0.75, 0.75, 0.75, "grayscale"), - CalibrationPatch("EBU Yellow", 0.75, 0.75, 0.00, "secondary"), - CalibrationPatch("EBU Cyan", 0.00, 0.75, 0.75, "secondary"), - CalibrationPatch("EBU Green", 0.00, 0.75, 0.00, "primary"), + CalibrationPatch("EBU White", 0.75, 0.75, 0.75, "grayscale"), + CalibrationPatch("EBU Yellow", 0.75, 0.75, 0.00, "secondary"), + CalibrationPatch("EBU Cyan", 0.00, 0.75, 0.75, "secondary"), + CalibrationPatch("EBU Green", 0.00, 0.75, 0.00, "primary"), CalibrationPatch("EBU Magenta", 0.75, 0.00, 0.75, "secondary"), - CalibrationPatch("EBU Red", 0.75, 0.00, 0.00, "primary"), - CalibrationPatch("EBU Blue", 0.00, 0.00, 0.75, "primary"), - CalibrationPatch("EBU Black", 0.00, 0.00, 0.00, "grayscale"), + CalibrationPatch("EBU Red", 0.75, 0.00, 0.00, "primary"), + CalibrationPatch("EBU Blue", 0.00, 0.00, 0.75, "primary"), + CalibrationPatch("EBU Black", 0.00, 0.00, 0.00, "grayscale"), ] @@ -339,63 +360,63 @@ def _build_smpte_bars() -> list[CalibrationPatch]: COLORCHECKER_CLASSIC: list[CalibrationPatch] = [ # Row 1 - Natural colors - CalibrationPatch("Dark Skin", 0.453, 0.317, 0.264, "colorchecker"), - CalibrationPatch("Light Skin", 0.779, 0.577, 0.505, "colorchecker"), - CalibrationPatch("Blue Sky", 0.355, 0.480, 0.611, "colorchecker"), - CalibrationPatch("Foliage", 0.352, 0.422, 0.253, "colorchecker"), - CalibrationPatch("Blue Flower", 0.508, 0.502, 0.691, "colorchecker"), + CalibrationPatch("Dark Skin", 0.453, 0.317, 0.264, "colorchecker"), + CalibrationPatch("Light Skin", 0.779, 0.577, 0.505, "colorchecker"), + CalibrationPatch("Blue Sky", 0.355, 0.480, 0.611, "colorchecker"), + CalibrationPatch("Foliage", 0.352, 0.422, 0.253, "colorchecker"), + CalibrationPatch("Blue Flower", 0.508, 0.502, 0.691, "colorchecker"), CalibrationPatch("Bluish Green", 0.362, 0.745, 0.675, "colorchecker"), # Row 2 - Miscellaneous colors - CalibrationPatch("Orange", 0.879, 0.485, 0.183, "colorchecker"), + CalibrationPatch("Orange", 0.879, 0.485, 0.183, "colorchecker"), CalibrationPatch("Purplish Blue", 0.266, 0.358, 0.667, "colorchecker"), CalibrationPatch("Moderate Red", 0.778, 0.321, 0.381, "colorchecker"), - CalibrationPatch("Purple", 0.367, 0.227, 0.414, "colorchecker"), + CalibrationPatch("Purple", 0.367, 0.227, 0.414, "colorchecker"), CalibrationPatch("Yellow Green", 0.623, 0.741, 0.246, "colorchecker"), CalibrationPatch("Orange Yellow", 0.904, 0.634, 0.154, "colorchecker"), # Row 3 - Primary and secondary colors - CalibrationPatch("Blue", 0.139, 0.248, 0.577, "colorchecker"), - CalibrationPatch("Green", 0.262, 0.584, 0.291, "colorchecker"), - CalibrationPatch("Red", 0.752, 0.197, 0.178, "colorchecker"), - CalibrationPatch("Yellow", 0.938, 0.857, 0.159, "colorchecker"), - CalibrationPatch("Magenta", 0.752, 0.313, 0.577, "colorchecker"), - CalibrationPatch("Cyan", 0.121, 0.544, 0.659, "colorchecker"), + CalibrationPatch("Blue", 0.139, 0.248, 0.577, "colorchecker"), + CalibrationPatch("Green", 0.262, 0.584, 0.291, "colorchecker"), + CalibrationPatch("Red", 0.752, 0.197, 0.178, "colorchecker"), + CalibrationPatch("Yellow", 0.938, 0.857, 0.159, "colorchecker"), + CalibrationPatch("Magenta", 0.752, 0.313, 0.577, "colorchecker"), + CalibrationPatch("Cyan", 0.121, 0.544, 0.659, "colorchecker"), # Row 4 - Grayscale - CalibrationPatch("White", 0.961, 0.961, 0.961, "colorchecker"), - CalibrationPatch("Neutral 8", 0.784, 0.784, 0.784, "colorchecker"), - CalibrationPatch("Neutral 6.5", 0.584, 0.584, 0.584, "colorchecker"), - CalibrationPatch("Neutral 5", 0.420, 0.420, 0.420, "colorchecker"), - CalibrationPatch("Neutral 3.5", 0.258, 0.258, 0.258, "colorchecker"), - CalibrationPatch("Black", 0.085, 0.085, 0.085, "colorchecker"), + CalibrationPatch("White", 0.961, 0.961, 0.961, "colorchecker"), + CalibrationPatch("Neutral 8", 0.784, 0.784, 0.784, "colorchecker"), + CalibrationPatch("Neutral 6.5", 0.584, 0.584, 0.584, "colorchecker"), + CalibrationPatch("Neutral 5", 0.420, 0.420, 0.420, "colorchecker"), + CalibrationPatch("Neutral 3.5", 0.258, 0.258, 0.258, "colorchecker"), + CalibrationPatch("Black", 0.085, 0.085, 0.085, "colorchecker"), ] # Reference Lab D50 values for ColorChecker Classic 24-patch. # Based on X-Rite published data (2014 revision), D50 illuminant. # Also available in calibrate_pro.verification.colorchecker.COLORCHECKER_CLASSIC_D50 COLORCHECKER_CLASSIC_LAB_D50: dict[str, tuple[float, float, float]] = { - "Dark Skin": (37.986, 13.555, 14.059), - "Light Skin": (65.711, 18.130, 17.810), - "Blue Sky": (49.927, -4.880, -21.925), - "Foliage": (43.139, -13.095, 21.905), - "Blue Flower": (55.112, 8.844, -25.399), - "Bluish Green": (70.719, -33.397, -0.199), - "Orange": (62.661, 36.067, 57.096), + "Dark Skin": (37.986, 13.555, 14.059), + "Light Skin": (65.711, 18.130, 17.810), + "Blue Sky": (49.927, -4.880, -21.925), + "Foliage": (43.139, -13.095, 21.905), + "Blue Flower": (55.112, 8.844, -25.399), + "Bluish Green": (70.719, -33.397, -0.199), + "Orange": (62.661, 36.067, 57.096), "Purplish Blue": (40.020, 10.410, -45.964), - "Moderate Red": (51.124, 48.239, 16.248), - "Purple": (30.325, 22.976, -21.587), - "Yellow Green": (72.532, -23.709, 57.255), - "Orange Yellow": (71.941, 19.363, 67.857), - "Blue": (28.778, 14.179, -50.297), - "Green": (55.261, -38.342, 31.370), - "Red": (42.101, 53.378, 28.190), - "Yellow": (81.733, 4.039, 79.819), - "Magenta": (51.935, 49.986, -14.574), - "Cyan": (51.038, -28.631, -28.638), - "White": (96.539, -0.425, 1.186), - "Neutral 8": (81.257, -0.638, -0.335), - "Neutral 6.5": (66.766, -0.734, -0.504), - "Neutral 5": (50.867, -0.153, -0.270), - "Neutral 3.5": (35.656, -0.421, -1.231), - "Black": (20.461, -0.079, -0.973), + "Moderate Red": (51.124, 48.239, 16.248), + "Purple": (30.325, 22.976, -21.587), + "Yellow Green": (72.532, -23.709, 57.255), + "Orange Yellow": (71.941, 19.363, 67.857), + "Blue": (28.778, 14.179, -50.297), + "Green": (55.261, -38.342, 31.370), + "Red": (42.101, 53.378, 28.190), + "Yellow": (81.733, 4.039, 79.819), + "Magenta": (51.935, 49.986, -14.574), + "Cyan": (51.038, -28.631, -28.638), + "White": (96.539, -0.425, 1.186), + "Neutral 8": (81.257, -0.638, -0.335), + "Neutral 6.5": (66.766, -0.734, -0.504), + "Neutral 5": (50.867, -0.153, -0.270), + "Neutral 3.5": (35.656, -0.421, -1.231), + "Black": (20.461, -0.079, -0.973), } @@ -411,23 +432,23 @@ def _build_smpte_bars() -> list[CalibrationPatch]: SKIN_TONES: list[CalibrationPatch] = [ # Light Caucasian skin - CalibrationPatch("Skin Light 1", 0.890, 0.733, 0.635, "skin"), - CalibrationPatch("Skin Light 2", 0.843, 0.694, 0.608, "skin"), + CalibrationPatch("Skin Light 1", 0.890, 0.733, 0.635, "skin"), + CalibrationPatch("Skin Light 2", 0.843, 0.694, 0.608, "skin"), # Medium Caucasian / Mediterranean - CalibrationPatch("Skin Medium 1", 0.792, 0.612, 0.502, "skin"), - CalibrationPatch("Skin Medium 2", 0.745, 0.569, 0.467, "skin"), + CalibrationPatch("Skin Medium 1", 0.792, 0.612, 0.502, "skin"), + CalibrationPatch("Skin Medium 2", 0.745, 0.569, 0.467, "skin"), # Olive / East Asian - CalibrationPatch("Skin Olive 1", 0.710, 0.553, 0.416, "skin"), - CalibrationPatch("Skin Olive 2", 0.659, 0.494, 0.369, "skin"), + CalibrationPatch("Skin Olive 1", 0.710, 0.553, 0.416, "skin"), + CalibrationPatch("Skin Olive 2", 0.659, 0.494, 0.369, "skin"), # Medium-dark / South Asian / Latin - CalibrationPatch("Skin Medium-Dark 1", 0.580, 0.408, 0.310, "skin"), - CalibrationPatch("Skin Medium-Dark 2", 0.502, 0.341, 0.259, "skin"), + CalibrationPatch("Skin Medium-Dark 1", 0.580, 0.408, 0.310, "skin"), + CalibrationPatch("Skin Medium-Dark 2", 0.502, 0.341, 0.259, "skin"), # Dark / African - CalibrationPatch("Skin Dark 1", 0.400, 0.275, 0.216, "skin"), - CalibrationPatch("Skin Dark 2", 0.318, 0.208, 0.165, "skin"), + CalibrationPatch("Skin Dark 1", 0.400, 0.275, 0.216, "skin"), + CalibrationPatch("Skin Dark 2", 0.318, 0.208, 0.165, "skin"), # ColorChecker reference skin patches - CalibrationPatch("CC Dark Skin", 0.453, 0.317, 0.264, "skin"), - CalibrationPatch("CC Light Skin", 0.779, 0.577, 0.505, "skin"), + CalibrationPatch("CC Dark Skin", 0.453, 0.317, 0.264, "skin"), + CalibrationPatch("CC Light Skin", 0.779, 0.577, 0.505, "skin"), ] @@ -445,15 +466,15 @@ def _build_smpte_bars() -> list[CalibrationPatch]: # Additional steps are included for fine adjustment. PLUGE: list[CalibrationPatch] = [ - CalibrationPatch("PLUGE 0% Black", 0.0, 0.0, 0.0, "pluge"), - CalibrationPatch("PLUGE 2% Sub-Black", 0.02, 0.02, 0.02, "pluge"), - CalibrationPatch("PLUGE 3.5% Below", 0.035, 0.035, 0.035, "pluge"), - CalibrationPatch("PLUGE 5%", 0.05, 0.05, 0.05, "pluge"), - CalibrationPatch("PLUGE 7.5% Reference", 0.075, 0.075, 0.075, "pluge"), - CalibrationPatch("PLUGE 10%", 0.10, 0.10, 0.10, "pluge"), - CalibrationPatch("PLUGE 11.4% Above", 0.114, 0.114, 0.114, "pluge"), - CalibrationPatch("PLUGE 15%", 0.15, 0.15, 0.15, "pluge"), - CalibrationPatch("PLUGE 20%", 0.20, 0.20, 0.20, "pluge"), + CalibrationPatch("PLUGE 0% Black", 0.0, 0.0, 0.0, "pluge"), + CalibrationPatch("PLUGE 2% Sub-Black", 0.02, 0.02, 0.02, "pluge"), + CalibrationPatch("PLUGE 3.5% Below", 0.035, 0.035, 0.035, "pluge"), + CalibrationPatch("PLUGE 5%", 0.05, 0.05, 0.05, "pluge"), + CalibrationPatch("PLUGE 7.5% Reference", 0.075, 0.075, 0.075, "pluge"), + CalibrationPatch("PLUGE 10%", 0.10, 0.10, 0.10, "pluge"), + CalibrationPatch("PLUGE 11.4% Above", 0.114, 0.114, 0.114, "pluge"), + CalibrationPatch("PLUGE 15%", 0.15, 0.15, 0.15, "pluge"), + CalibrationPatch("PLUGE 20%", 0.20, 0.20, 0.20, "pluge"), ] @@ -473,6 +494,7 @@ def _build_smpte_bars() -> list[CalibrationPatch]: # # Total: ~100 patches (exact count depends on deduplication of shared colors). + def _build_comprehensive_100() -> list[CalibrationPatch]: seen: set[tuple[float, float, float]] = set() patches: list[CalibrationPatch] = [] @@ -488,9 +510,9 @@ def _add_always(p: CalibrationPatch) -> None: patches.append(p) # Drift reference - start (always included, even if duplicated later) - _add_always(CalibrationPatch("Drift Ref White (Start)", 1.0, 1.0, 1.0, "grayscale")) - _add_always(CalibrationPatch("Drift Ref Gray (Start)", 0.5, 0.5, 0.5, "grayscale")) - _add_always(CalibrationPatch("Drift Ref Black (Start)", 0.0, 0.0, 0.0, "grayscale")) + _add_always(CalibrationPatch("Drift Ref White (Start)", 1.0, 1.0, 1.0, "grayscale")) + _add_always(CalibrationPatch("Drift Ref Gray (Start)", 0.5, 0.5, 0.5, "grayscale")) + _add_always(CalibrationPatch("Drift Ref Black (Start)", 0.0, 0.0, 0.0, "grayscale")) # 11-step grayscale (0%, 10%, 20% ... 100%) for pct in range(0, 110, 10): @@ -526,9 +548,9 @@ def _add_always(p: CalibrationPatch) -> None: _add(p) # Drift reference - end (always included for drift detection) - _add_always(CalibrationPatch("Drift Ref White (End)", 1.0, 1.0, 1.0, "grayscale")) - _add_always(CalibrationPatch("Drift Ref Gray (End)", 0.5, 0.5, 0.5, "grayscale")) - _add_always(CalibrationPatch("Drift Ref Black (End)", 0.0, 0.0, 0.0, "grayscale")) + _add_always(CalibrationPatch("Drift Ref White (End)", 1.0, 1.0, 1.0, "grayscale")) + _add_always(CalibrationPatch("Drift Ref Gray (End)", 0.5, 0.5, 0.5, "grayscale")) + _add_always(CalibrationPatch("Drift Ref Black (End)", 0.0, 0.0, 0.0, "grayscale")) return patches @@ -587,8 +609,7 @@ def _add_always(p: CalibrationPatch) -> None: ), "COMPREHENSIVE_100": ( COMPREHENSIVE_100, - "Combined ~100 patches: grayscale + primaries + sweeps + near-black " - "+ ColorChecker + drift references", + "Combined ~100 patches: grayscale + primaries + sweeps + near-black + ColorChecker + drift references", ), } @@ -597,6 +618,7 @@ def _add_always(p: CalibrationPatch) -> None: # Public API Functions # ============================================================================= + def get_patch_set(name: str) -> list[CalibrationPatch]: """Return a named patch set. @@ -619,9 +641,7 @@ def get_patch_set(name: str) -> list[CalibrationPatch]: key = name.upper().strip() if key not in _PATCH_SET_REGISTRY: available = ", ".join(sorted(_PATCH_SET_REGISTRY.keys())) - raise KeyError( - f"Unknown patch set {name!r}. Available sets: {available}" - ) + raise KeyError(f"Unknown patch set {name!r}. Available sets: {available}") return _PATCH_SET_REGISTRY[key][0] @@ -637,9 +657,7 @@ def list_patch_sets() -> list[tuple[str, str]]: patches = get_patch_set(name) print(f"{name} ({len(patches)} patches): {desc}") """ - return sorted( - (name, desc) for name, (_, desc) in _PATCH_SET_REGISTRY.items() - ) + return sorted((name, desc) for name, (_, desc) in _PATCH_SET_REGISTRY.items()) def get_colorchecker_lab_reference(patch_name: str) -> tuple[float, float, float]: @@ -657,10 +675,7 @@ def get_colorchecker_lab_reference(patch_name: str) -> tuple[float, float, float """ if patch_name not in COLORCHECKER_CLASSIC_LAB_D50: available = ", ".join(sorted(COLORCHECKER_CLASSIC_LAB_D50.keys())) - raise KeyError( - f"Unknown ColorChecker patch {patch_name!r}. " - f"Available: {available}" - ) + raise KeyError(f"Unknown ColorChecker patch {patch_name!r}. Available: {available}") return COLORCHECKER_CLASSIC_LAB_D50[patch_name] diff --git a/calibrate_pro/verification/pdf_export.py b/calibrate_pro/verification/pdf_export.py index 360c791..577df22 100644 --- a/calibrate_pro/verification/pdf_export.py +++ b/calibrate_pro/verification/pdf_export.py @@ -138,8 +138,7 @@ def export_report_pdf(html_content: str, output_path: str) -> bool: # Strategy 2: System browser if _fallback_browser_print(html_content, output_path): logger.info( - "Opened HTML in browser for manual PDF print. " - "HTML saved at: %s", + "Opened HTML in browser for manual PDF print. HTML saved at: %s", Path(output_path).with_suffix(".html"), ) return True diff --git a/calibrate_pro/verification/report_generator.py b/calibrate_pro/verification/report_generator.py index 172bd1a..10be0fd 100644 --- a/calibrate_pro/verification/report_generator.py +++ b/calibrate_pro/verification/report_generator.py @@ -135,6 +135,7 @@ # CCT Calculation # ============================================================================= + def _xy_to_cct(x: float, y: float) -> float: """ Approximate correlated color temperature from CIE xy chromaticity. @@ -144,7 +145,7 @@ def _xy_to_cct(x: float, y: float) -> float: if y == 0: return 0.0 n = (x - 0.3320) / (0.1858 - y) - cct = 449.0 * n ** 3 + 3525.0 * n ** 2 + 6823.3 * n + 5520.33 + cct = 449.0 * n**3 + 3525.0 * n**2 + 6823.3 * n + 5520.33 return max(0.0, cct) @@ -152,6 +153,7 @@ def _xy_to_cct(x: float, y: float) -> float: # Lab to sRGB approximation (for predicted colors in report) # ============================================================================= + def _lab_to_approx_srgb(lab: tuple[float, float, float]) -> tuple[float, float, float]: """ Convert CIE Lab (D50) to approximate sRGB values for display in the report. @@ -173,18 +175,18 @@ def _lab_to_approx_srgb(lab: tuple[float, float, float]) -> tuple[float, float, yr = 1.0000 zr = 0.8251 - if fx ** 3 > epsilon: - x_val = fx ** 3 + if fx**3 > epsilon: + x_val = fx**3 else: x_val = (116.0 * fx - 16.0) / kappa if kappa * epsilon < L: - y_val = fy ** 3 + y_val = fy**3 else: y_val = L / kappa - if fz ** 3 > epsilon: - z_val = fz ** 3 + if fz**3 > epsilon: + z_val = fz**3 else: z_val = (116.0 * fz - 16.0) / kappa @@ -418,6 +420,7 @@ def gamma_compress(v: float) -> float: # SVG Diagram Generators # ============================================================================= + def _generate_cie_diagram_svg( panel_red: tuple[float, float], panel_green: tuple[float, float], @@ -509,10 +512,7 @@ def xy_to_svg(x: float, y: float) -> tuple[float, float]: locus_points.append(f"{sx:.1f},{sy:.1f}") # Close the locus with the purple line locus_path = " ".join(locus_points) - lines.append( - f'' - ) + lines.append(f'') # sRGB gamut triangle (red) sr = xy_to_svg(*SRGB_RED) @@ -520,7 +520,7 @@ def xy_to_svg(x: float, y: float) -> tuple[float, float]: sb = xy_to_svg(*SRGB_BLUE) lines.append( f'' @@ -532,7 +532,7 @@ def xy_to_svg(x: float, y: float) -> tuple[float, float]: pb = xy_to_svg(*panel_blue) lines.append( f'' @@ -540,13 +540,8 @@ def xy_to_svg(x: float, y: float) -> tuple[float, float]: # D65 white point marker wp = xy_to_svg(*D65_WHITE) - lines.append( - f'' - ) - lines.append( - f'' - ) + lines.append(f'') + lines.append(f'') lines.append( f'D65' @@ -554,16 +549,12 @@ def xy_to_svg(x: float, y: float) -> tuple[float, float]: # Panel white point marker (if different from D65) pw = xy_to_svg(*panel_white) - dist = math.sqrt((panel_white[0] - D65_WHITE[0]) ** 2 - + (panel_white[1] - D65_WHITE[1]) ** 2) + dist = math.sqrt((panel_white[0] - D65_WHITE[0]) ** 2 + (panel_white[1] - D65_WHITE[1]) ** 2) if dist > 0.003: lines.append( - f'' - ) - lines.append( - f'' + f'' ) + lines.append(f'') # Legend legend_y = margin + 8 @@ -587,10 +578,7 @@ def xy_to_svg(x: float, y: float) -> tuple[float, float]: f'fill="#3b82f6" font-size="10" font-family="sans-serif">Panel' ) legend_y += 18 - lines.append( - f'' - ) + lines.append(f'') lines.append( f'D65' @@ -601,11 +589,11 @@ def xy_to_svg(x: float, y: float) -> tuple[float, float]: f'' - f'CIE 1931 Chromaticity Diagram' + f"CIE 1931 Chromaticity Diagram" ) - lines.append('') - return '\n'.join(lines) + lines.append("") + return "\n".join(lines) def _generate_gamma_curves_svg( @@ -696,14 +684,11 @@ def make_path(gamma: float, color: str, width: str = "1.5") -> str: points = [] for i in range(num_points + 1): t = i / num_points - out = t ** gamma + out = t**gamma sx, sy = val_to_svg(t, out) points.append(f"{sx:.1f},{sy:.1f}") path_data = "M" + " L".join(points) - return ( - f'' - ) + return f'' # Target gamma (gray reference) lines.append(make_path(target_gamma, "#6b7280", "1.5")) @@ -732,7 +717,7 @@ def make_path(gamma: float, color: str, width: str = "1.5") -> str: lines.append( f'' - f'{label}' + f"{label}" ) legend_y += 14 @@ -741,17 +726,18 @@ def make_path(gamma: float, color: str, width: str = "1.5") -> str: f'' - f'Per-Channel Gamma Curves' + f"Per-Channel Gamma Curves" ) - lines.append('') - return '\n'.join(lines) + lines.append("") + return "\n".join(lines) # ============================================================================= # HTML Section Builders # ============================================================================= + def _build_header( display_name: str, panel_info: str, @@ -834,24 +820,19 @@ def _build_summary_section( if key in ("status", "original_settings", "error"): continue if isinstance(val, (tuple, list)) and len(val) == 2: - ddc_items.append( - f"{_html_escape(key)}" - f"{val[0]} → {val[1]}" - ) + ddc_items.append(f"{_html_escape(key)}{val[0]} → {val[1]}") if ddc_items: ddc_html = f"""

    DDC/CI Adjustments

    {_html_escape(ddc_status)}

    - {''.join(ddc_items)} + {"".join(ddc_items)}
    SettingChange
    """ else: ddc_html = ( - f'

    DDC/CI Adjustments

    ' - f'

    ' - f'{_html_escape(ddc_status)}

    ' + f'

    DDC/CI Adjustments

    {_html_escape(ddc_status)}

    ' ) return f""" @@ -897,10 +878,7 @@ def _build_summary_section( """ -def _build_gamut_coverage_html( - coverage: dict[str, float] | None, - color_volume: dict | None = None -) -> str: +def _build_gamut_coverage_html(coverage: dict[str, float] | None, color_volume: dict | None = None) -> str: """Build gamut coverage and color volume section HTML.""" if not coverage: return "" @@ -922,19 +900,19 @@ def bar(pct, color):

    Gamut Coverage (2D Area)

    sRGB - {bar(srgb, '#4cc9f0')} + {bar(srgb, "#4cc9f0")} {srgb:.1f}% DCI-P3 - {bar(p3, '#f72585')} + {bar(p3, "#f72585")} {p3:.1f}% BT.2020 - {bar(bt2020, '#7209b7')} + {bar(bt2020, "#7209b7")} {bt2020:.1f}% vs sRGB - {bar(min(rel, 200) / 2, '#4361ee')} + {bar(min(rel, 200) / 2, "#4361ee")} {rel:.0f}%
    """ @@ -953,19 +931,19 @@ def bar(pct, color):

    sRGB - {bar(v_srgb, '#4cc9f0')} + {bar(v_srgb, "#4cc9f0")} {v_srgb:.1f}% DCI-P3 - {bar(v_p3, '#f72585')} + {bar(v_p3, "#f72585")} {v_p3:.1f}% BT.2020 - {bar(v_bt2020, '#7209b7')} + {bar(v_bt2020, "#7209b7")} {v_bt2020:.1f}% vs sRGB - {bar(min(v_rel, 200) / 2, '#4361ee')} + {bar(min(v_rel, 200) / 2, "#4361ee")} {v_rel:.0f}%
    """ @@ -984,8 +962,7 @@ def bar(pct, color): x = i * bar_w y = svg_h - 10 - h bars_svg += ( - f'' + f'' ) html += f""" @@ -1033,15 +1010,9 @@ def _build_patch_table_section( ref_hex = _srgb_to_hex(*ref_srgb) pred_hex = _srgb_to_hex(*pred_srgb) - ref_str = ( - f"{int(round(ref_srgb[0] * 255))}, " - f"{int(round(ref_srgb[1] * 255))}, " - f"{int(round(ref_srgb[2] * 255))}" - ) + ref_str = f"{int(round(ref_srgb[0] * 255))}, {int(round(ref_srgb[1] * 255))}, {int(round(ref_srgb[2] * 255))}" pred_str = ( - f"{int(round(pred_srgb[0] * 255))}, " - f"{int(round(pred_srgb[1] * 255))}, " - f"{int(round(pred_srgb[2] * 255))}" + f"{int(round(pred_srgb[0] * 255))}, {int(round(pred_srgb[1] * 255))}, {int(round(pred_srgb[2] * 255))}" ) # Status @@ -1084,7 +1055,7 @@ def _build_patch_table_section( - {''.join(rows)} + {"".join(rows)} @@ -1144,6 +1115,7 @@ def _build_whitepoint_section( # HTML Escaping # ============================================================================= + def _html_escape(text: str) -> str: """Escape HTML special characters.""" return ( @@ -1160,6 +1132,7 @@ def _html_escape(text: str) -> str: # Main Report Generator # ============================================================================= + def generate_calibration_report( result: Any, panel: Any, @@ -1220,9 +1193,7 @@ def generate_calibration_report( ddc_changes = getattr(result, "ddc_changes_made", {}) manufacturer = panel.manufacturer - model_name = panel_matched or ( - manufacturer + " " + panel.model_pattern.split("|")[0] - ) + model_name = panel_matched or (manufacturer + " " + panel.model_pattern.split("|")[0]) # Verification data patches = verification.get("patches", []) @@ -1237,17 +1208,20 @@ def generate_calibration_report( # Build HTML sections header = _build_header(display_name, panel_info, report_date) - cie_section = _build_cie_diagram_section( - panel_red, panel_green, panel_blue, panel_white - ) + cie_section = _build_cie_diagram_section(panel_red, panel_green, panel_blue, panel_white) gamut_coverage = verification.get("gamut_coverage") color_volume = verification.get("color_volume") cam16_de_avg = verification.get("cam16_delta_e_avg", 0.0) summary_section = _build_summary_section( - model_name, panel_type, manufacturer, - delta_e_avg, delta_e_max, grade, - lut_method, ddc_changes, + model_name, + panel_type, + manufacturer, + delta_e_avg, + delta_e_max, + grade, + lut_method, + ddc_changes, gamut_coverage=gamut_coverage, cam16_delta_e_avg=cam16_de_avg, color_volume=color_volume, diff --git a/calibrate_pro/verification/reports.py b/calibrate_pro/verification/reports.py index f70a9ac..e6aa945 100644 --- a/calibrate_pro/verification/reports.py +++ b/calibrate_pro/verification/reports.py @@ -29,6 +29,7 @@ Table, TableStyle, ) + REPORTLAB_AVAILABLE = True except ImportError: REPORTLAB_AVAILABLE = False @@ -59,8 +60,10 @@ # Enums # ============================================================================= + class ReportFormat(Enum): """Report output format.""" + PDF = auto() HTML = auto() JSON = auto() @@ -68,6 +71,7 @@ class ReportFormat(Enum): class ReportType(Enum): """Type of verification report.""" + COLORCHECKER = auto() GRAYSCALE = auto() GAMUT = auto() @@ -78,9 +82,11 @@ class ReportType(Enum): # Data Classes # ============================================================================= + @dataclass class ReportMetadata: """Report metadata.""" + title: str display_name: str profile_name: str @@ -94,6 +100,7 @@ class ReportMetadata: @dataclass class ReportConfig: """Report generation configuration.""" + format: ReportFormat = ReportFormat.PDF page_size: str = "letter" # letter, A4 include_charts: bool = True @@ -107,6 +114,7 @@ class ReportConfig: @dataclass class VerificationSummary: """Summary of all verification results.""" + colorchecker: ColorCheckerResult | None = None grayscale: GrayscaleResult | None = None gamut: GamutAnalysisResult | None = None @@ -152,6 +160,7 @@ class VerificationSummary: # Report Generator Class # ============================================================================= + class ReportGenerator: """ Professional calibration report generator. @@ -169,10 +178,7 @@ def __init__(self, config: ReportConfig | None = None): self.config = config or ReportConfig() self.colors = REPORT_COLORS.get(self.config.color_theme, REPORT_COLORS["professional"]) - def generate(self, - summary: VerificationSummary, - metadata: ReportMetadata, - output_path: str | None = None) -> str: + def generate(self, summary: VerificationSummary, metadata: ReportMetadata, output_path: str | None = None) -> str: """ Generate verification report. @@ -203,8 +209,7 @@ def generate(self, def _generate_pdf(self, summary: VerificationSummary, metadata: ReportMetadata) -> str: """Generate PDF report using ReportLab.""" if not REPORTLAB_AVAILABLE: - raise ImportError("ReportLab is required for PDF generation. " - "Install with: pip install reportlab") + raise ImportError("ReportLab is required for PDF generation. Install with: pip install reportlab") # Determine output path if self.config.output_path: @@ -220,10 +225,10 @@ def _generate_pdf(self, summary: VerificationSummary, metadata: ReportMetadata) doc = SimpleDocTemplate( str(output_path), pagesize=page_size, - rightMargin=0.75*inch, - leftMargin=0.75*inch, - topMargin=0.75*inch, - bottomMargin=0.75*inch, + rightMargin=0.75 * inch, + leftMargin=0.75 * inch, + topMargin=0.75 * inch, + bottomMargin=0.75 * inch, ) # Build story @@ -232,16 +237,16 @@ def _generate_pdf(self, summary: VerificationSummary, metadata: ReportMetadata) # Custom styles title_style = ParagraphStyle( - 'CustomTitle', - parent=styles['Heading1'], + "CustomTitle", + parent=styles["Heading1"], fontSize=24, spaceAfter=30, textColor=colors.HexColor(self.colors["primary"]), ) heading_style = ParagraphStyle( - 'CustomHeading', - parent=styles['Heading2'], + "CustomHeading", + parent=styles["Heading2"], fontSize=16, spaceBefore=20, spaceAfter=10, @@ -249,8 +254,8 @@ def _generate_pdf(self, summary: VerificationSummary, metadata: ReportMetadata) ) ParagraphStyle( - 'CustomSubheading', - parent=styles['Heading3'], + "CustomSubheading", + parent=styles["Heading3"], fontSize=12, spaceBefore=15, spaceAfter=8, @@ -258,8 +263,8 @@ def _generate_pdf(self, summary: VerificationSummary, metadata: ReportMetadata) ) body_style = ParagraphStyle( - 'CustomBody', - parent=styles['Normal'], + "CustomBody", + parent=styles["Normal"], fontSize=10, spaceAfter=6, textColor=colors.HexColor(self.colors["text"]), @@ -281,13 +286,17 @@ def _generate_pdf(self, summary: VerificationSummary, metadata: ReportMetadata) if metadata.organization: meta_data.append(["Organization:", metadata.organization]) - meta_table = Table(meta_data, colWidths=[1.5*inch, 4*inch]) - meta_table.setStyle(TableStyle([ - ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, -1), 10), - ('TEXTCOLOR', (0, 0), (-1, -1), colors.HexColor(self.colors["text"])), - ('BOTTOMPADDING', (0, 0), (-1, -1), 6), - ])) + meta_table = Table(meta_data, colWidths=[1.5 * inch, 4 * inch]) + meta_table.setStyle( + TableStyle( + [ + ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor(self.colors["text"])), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) story.append(meta_table) story.append(Spacer(1, 20)) @@ -359,8 +368,7 @@ def _get_overall_summary_text(self, summary: VerificationSummary) -> str: return "".join(lines) - def _create_colorchecker_section(self, result: ColorCheckerResult, - styles, body_style) -> list: + def _create_colorchecker_section(self, result: ColorCheckerResult, styles, body_style) -> list: """Create ColorChecker section for PDF.""" elements = [] @@ -382,30 +390,28 @@ def _create_colorchecker_section(self, result: ColorCheckerResult, for patch in result.patch_measurements[:12]: # First 12 patches ref = f"({patch.reference_l:.1f}, {patch.reference_a:.1f}, {patch.reference_b:.1f})" meas = f"({patch.measured_l:.1f}, {patch.measured_a:.1f}, {patch.measured_b:.1f})" - table_data.append([ - patch.patch_name, - ref, - meas, - f"{patch.delta_e_2000:.2f}" - ]) - - table = Table(table_data, colWidths=[1.5*inch, 1.8*inch, 1.8*inch, 0.8*inch]) - table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor(self.colors["primary"])), - ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, -1), 8), - ('ALIGN', (0, 0), (-1, -1), 'CENTER'), - ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor(self.colors["border"])), - ('BOTTOMPADDING', (0, 0), (-1, -1), 4), - ('TOPPADDING', (0, 0), (-1, -1), 4), - ])) + table_data.append([patch.patch_name, ref, meas, f"{patch.delta_e_2000:.2f}"]) + + table = Table(table_data, colWidths=[1.5 * inch, 1.8 * inch, 1.8 * inch, 0.8 * inch]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor(self.colors["primary"])), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 8), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor(self.colors["border"])), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ] + ) + ) elements.append(table) return elements - def _create_grayscale_section(self, result: GrayscaleResult, - styles, body_style) -> list: + def _create_grayscale_section(self, result: GrayscaleResult, styles, body_style) -> list: """Create Grayscale section for PDF.""" elements = [] @@ -430,35 +436,42 @@ def _create_grayscale_section(self, result: GrayscaleResult, return elements - def _create_gamut_section(self, result: GamutAnalysisResult, - styles, body_style) -> list: + def _create_gamut_section(self, result: GamutAnalysisResult, styles, body_style) -> list: """Create Gamut section for PDF.""" elements = [] # Coverage table table_data = [ ["Color Space", "Coverage", "Grade"], - ["sRGB", f"{result.srgb_coverage.coverage_percent:.1f}%", - gv_grade_to_string(result.srgb_coverage.grade)], - ["DCI-P3", f"{result.p3_coverage.coverage_percent:.1f}%", - gv_grade_to_string(result.p3_coverage.grade)], - ["BT.2020", f"{result.bt2020_coverage.coverage_percent:.1f}%", - gv_grade_to_string(result.bt2020_coverage.grade)], - ["Adobe RGB", f"{result.adobe_rgb_coverage.coverage_percent:.1f}%", - gv_grade_to_string(result.adobe_rgb_coverage.grade)], + ["sRGB", f"{result.srgb_coverage.coverage_percent:.1f}%", gv_grade_to_string(result.srgb_coverage.grade)], + ["DCI-P3", f"{result.p3_coverage.coverage_percent:.1f}%", gv_grade_to_string(result.p3_coverage.grade)], + [ + "BT.2020", + f"{result.bt2020_coverage.coverage_percent:.1f}%", + gv_grade_to_string(result.bt2020_coverage.grade), + ], + [ + "Adobe RGB", + f"{result.adobe_rgb_coverage.coverage_percent:.1f}%", + gv_grade_to_string(result.adobe_rgb_coverage.grade), + ], ] - table = Table(table_data, colWidths=[1.5*inch, 1.2*inch, 2*inch]) - table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor(self.colors["primary"])), - ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, -1), 10), - ('ALIGN', (0, 0), (-1, -1), 'CENTER'), - ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor(self.colors["border"])), - ('BOTTOMPADDING', (0, 0), (-1, -1), 6), - ('TOPPADDING', (0, 0), (-1, -1), 6), - ])) + table = Table(table_data, colWidths=[1.5 * inch, 1.2 * inch, 2 * inch]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor(self.colors["primary"])), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor(self.colors["border"])), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) elements.append(table) elements.append(Spacer(1, 10)) @@ -486,7 +499,7 @@ def _generate_html(self, summary: VerificationSummary, metadata: ReportMetadata) html = self._build_html_content(summary, metadata) - with open(output_path, 'w', encoding='utf-8') as f: + with open(output_path, "w", encoding="utf-8") as f: f.write(html) return str(output_path) @@ -690,30 +703,30 @@ def _build_html_content(self, summary: VerificationSummary, metadata: ReportMeta # Recommendations if summary.recommendations: - html += f''' + html += f"""

    Recommendations

      {"".join(f"
    • {rec}
    • " for rec in summary.recommendations)}
    -''' +""" # Footer - html += f''' + html += f""" -''' +""" return html def _build_colorchecker_html(self, result: ColorCheckerResult) -> str: """Build ColorChecker HTML section.""" grade_class = result.overall_grade.name.lower() - html = f''' + html = f"""

    ColorChecker Verification

    {cc_grade_to_string(result.overall_grade)} @@ -736,10 +749,10 @@ def _build_colorchecker_html(self, result: ColorCheckerResult) -> str:
    95th %ile
    -''' +""" if self.config.include_detailed_data: - html += ''' + html += """

    Patch Measurements

    @@ -748,30 +761,30 @@ def _build_colorchecker_html(self, result: ColorCheckerResult) -> str: -''' +""" for patch in result.patch_measurements: - html += f''' + html += f""" -''' - html += ''' +""" + html += """
    Measured L*a*b* ΔE00
    {patch.patch_name} ({patch.reference_l:.1f}, {patch.reference_a:.1f}, {patch.reference_b:.1f}) ({patch.measured_l:.1f}, {patch.measured_a:.1f}, {patch.measured_b:.1f}) {patch.delta_e_2000:.2f}
    -''' +""" - html += ''' + html += """ -''' +""" return html def _build_grayscale_html(self, result: GrayscaleResult) -> str: """Build Grayscale HTML section.""" grade_class = result.overall_grade.name.lower() - return f''' + return f"""

    Grayscale Verification

    {gs_grade_to_string(result.overall_grade)} @@ -807,11 +820,11 @@ def _build_grayscale_html(self, result: GrayscaleResult) -> str: {"".join(f"{name.title()}{analysis.delta_e_mean:.2f}{analysis.gamma_mean:.2f}{analysis.cct_mean:.0f}K{analysis.grade.name}" for name, analysis in result.region_analysis.items())}
    -''' +""" def _build_gamut_html(self, result: GamutAnalysisResult) -> str: """Build Gamut HTML section.""" - return f''' + return f"""

    Gamut Analysis

    @@ -875,7 +888,7 @@ def _build_gamut_html(self, result: GamutAnalysisResult) -> str: {"".join(f"{name}{xy[0]:.4f}{xy[1]:.4f}" for name, xy in result.measured_primaries.items())}
    -''' +""" # ========================================================================= # JSON Generation @@ -992,7 +1005,7 @@ def _generate_json(self, summary: VerificationSummary, metadata: ReportMetadata) }, } - with open(output_path, 'w', encoding='utf-8') as f: + with open(output_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) return str(output_path) @@ -1002,6 +1015,7 @@ def _generate_json(self, summary: VerificationSummary, metadata: ReportMetadata) # Utility Functions # ============================================================================= + def generate_recommendations(summary: VerificationSummary) -> list[str]: """Generate calibration recommendations based on verification results.""" recommendations = [] @@ -1015,13 +1029,9 @@ def generate_recommendations(summary: VerificationSummary) -> list[str]: "Consider re-calibrating with more measurement patches." ) if cc.grayscale_grade in [VerificationGrade.ACCEPTABLE, VerificationGrade.POOR]: - recommendations.append( - "Grayscale patches show color tint. Check RGB balance in grayscale calibration." - ) + recommendations.append("Grayscale patches show color tint. Check RGB balance in grayscale calibration.") if cc.skin_tone_grade == VerificationGrade.POOR: - recommendations.append( - "Skin tone accuracy is poor. This is critical for portrait/video work." - ) + recommendations.append("Skin tone accuracy is poor. This is critical for portrait/video work.") # Grayscale recommendations if summary.grayscale: @@ -1033,13 +1043,11 @@ def generate_recommendations(summary: VerificationSummary) -> list[str]: ) if abs(gs.cct_mean - 6500) > 200: # Assuming D65 target recommendations.append( - f"White point CCT ({gs.cct_mean:.0f}K) deviates from D65 (6500K). " - "Check white balance calibration." + f"White point CCT ({gs.cct_mean:.0f}K) deviates from D65 (6500K). Check white balance calibration." ) if gs.contrast_ratio < 500: recommendations.append( - f"Low contrast ratio ({gs.contrast_ratio:.0f}:1). " - "Consider adjusting backlight or checking black level." + f"Low contrast ratio ({gs.contrast_ratio:.0f}:1). Consider adjusting backlight or checking black level." ) # Gamut recommendations @@ -1052,8 +1060,7 @@ def generate_recommendations(summary: VerificationSummary) -> list[str]: ) if abs(gm.white_point_duv) > 0.005: recommendations.append( - f"White point Duv ({gm.white_point_duv:.4f}) indicates tint. " - "Aim for Duv < 0.005 for neutral whites." + f"White point Duv ({gm.white_point_duv:.4f}) indicates tint. Aim for Duv < 0.005 for neutral whites." ) return recommendations @@ -1129,10 +1136,7 @@ def create_verification_summary( gm_analyzer = GamutAnalyzer() gm_result = gm_analyzer.analyze( - create_test_primaries(0.98), - generate_gamut_samples(ColorSpace.SRGB, 9), - "Test Display", - "Test Profile" + create_test_primaries(0.98), generate_gamut_samples(ColorSpace.SRGB, 9), "Test Display", "Test Profile" ) # Create summary diff --git a/scripts/ccmx_calibration_loop.py b/scripts/ccmx_calibration_loop.py index 4bb13c7..3247ed6 100644 --- a/scripts/ccmx_calibration_loop.py +++ b/scripts/ccmx_calibration_loop.py @@ -12,6 +12,7 @@ 3. Build full correction LUT (TRC + gamut, no chroma-adaptive hack needed) 4. Verify via pre-corrected patches """ + import os import struct import sys @@ -36,52 +37,66 @@ xyz_to_lab, ) -OLED_MATRIX = np.array([ - [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], - [-0.00004481, 0.00035042, 0.08032401], -]) +OLED_MATRIX = np.array( + [ + [0.03836831, -0.02175997, 0.01696057], + [0.01449629, 0.01611903, 0.00057150], + [-0.00004481, 0.00035042, 0.08032401], + ] +) COLORCHECKER = [ - ("Dark Skin", 0.453, 0.317, 0.264), - ("Light Skin", 0.779, 0.577, 0.505), - ("Blue Sky", 0.355, 0.480, 0.611), - ("Foliage", 0.352, 0.422, 0.253), - ("Blue Flower", 0.508, 0.502, 0.691), + ("Dark Skin", 0.453, 0.317, 0.264), + ("Light Skin", 0.779, 0.577, 0.505), + ("Blue Sky", 0.355, 0.480, 0.611), + ("Foliage", 0.352, 0.422, 0.253), + ("Blue Flower", 0.508, 0.502, 0.691), ("Bluish Green", 0.362, 0.745, 0.675), - ("Orange", 0.879, 0.485, 0.183), - ("Purplish Blue",0.266, 0.358, 0.667), + ("Orange", 0.879, 0.485, 0.183), + ("Purplish Blue", 0.266, 0.358, 0.667), ("Moderate Red", 0.778, 0.321, 0.381), - ("Purple", 0.367, 0.227, 0.414), + ("Purple", 0.367, 0.227, 0.414), ("Yellow Green", 0.623, 0.741, 0.246), - ("Orange Yellow",0.904, 0.634, 0.154), - ("Blue", 0.139, 0.248, 0.577), - ("Green", 0.262, 0.584, 0.291), - ("Red", 0.752, 0.197, 0.178), - ("Yellow", 0.938, 0.857, 0.159), - ("Magenta", 0.752, 0.313, 0.577), - ("Cyan", 0.121, 0.544, 0.659), - ("White", 0.961, 0.961, 0.961), - ("Neutral 8", 0.784, 0.784, 0.784), - ("Neutral 6.5", 0.584, 0.584, 0.584), - ("Neutral 5", 0.420, 0.420, 0.420), - ("Neutral 3.5", 0.258, 0.258, 0.258), - ("Black", 0.085, 0.085, 0.085), + ("Orange Yellow", 0.904, 0.634, 0.154), + ("Blue", 0.139, 0.248, 0.577), + ("Green", 0.262, 0.584, 0.291), + ("Red", 0.752, 0.197, 0.178), + ("Yellow", 0.938, 0.857, 0.159), + ("Magenta", 0.752, 0.313, 0.577), + ("Cyan", 0.121, 0.544, 0.659), + ("White", 0.961, 0.961, 0.961), + ("Neutral 8", 0.784, 0.784, 0.784), + ("Neutral 6.5", 0.584, 0.584, 0.584), + ("Neutral 5", 0.420, 0.420, 0.420), + ("Neutral 3.5", 0.258, 0.258, 0.258), + ("Black", 0.085, 0.085, 0.085), ] REF_LAB = { - "Dark Skin": (37.986, 13.555, 14.059), "Light Skin": (65.711, 18.130, 17.810), - "Blue Sky": (49.927, -4.880, -21.925), "Foliage": (43.139, -13.095, 21.905), - "Blue Flower": (55.112, 8.844, -25.399), "Bluish Green": (70.719, -33.397, -0.199), - "Orange": (62.661, 36.067, 57.096), "Purplish Blue": (40.020, 10.410, -45.964), - "Moderate Red": (51.124, 48.239, 16.248), "Purple": (30.325, 22.976, -21.587), - "Yellow Green": (72.532, -23.709, 57.255), "Orange Yellow": (71.941, 19.363, 67.857), - "Blue": (28.778, 14.179, -50.297), "Green": (55.261, -38.342, 31.370), - "Red": (42.101, 53.378, 28.190), "Yellow": (81.733, 4.039, 79.819), - "Magenta": (51.935, 49.986, -14.574), "Cyan": (51.038, -28.631, -28.638), - "White": (96.539, -0.425, 1.186), "Neutral 8": (81.257, -0.638, -0.335), - "Neutral 6.5": (66.766, -0.734, -0.504), "Neutral 5": (50.867, -0.153, -0.270), - "Neutral 3.5": (35.656, -0.421, -1.231), "Black": (20.461, -0.079, -0.973), + "Dark Skin": (37.986, 13.555, 14.059), + "Light Skin": (65.711, 18.130, 17.810), + "Blue Sky": (49.927, -4.880, -21.925), + "Foliage": (43.139, -13.095, 21.905), + "Blue Flower": (55.112, 8.844, -25.399), + "Bluish Green": (70.719, -33.397, -0.199), + "Orange": (62.661, 36.067, 57.096), + "Purplish Blue": (40.020, 10.410, -45.964), + "Moderate Red": (51.124, 48.239, 16.248), + "Purple": (30.325, 22.976, -21.587), + "Yellow Green": (72.532, -23.709, 57.255), + "Orange Yellow": (71.941, 19.363, 67.857), + "Blue": (28.778, 14.179, -50.297), + "Green": (55.261, -38.342, 31.370), + "Red": (42.101, 53.378, 28.190), + "Yellow": (81.733, 4.039, 79.819), + "Magenta": (51.935, 49.986, -14.574), + "Cyan": (51.038, -28.631, -28.638), + "White": (96.539, -0.425, 1.186), + "Neutral 8": (81.257, -0.638, -0.335), + "Neutral 6.5": (66.766, -0.734, -0.504), + "Neutral 5": (50.867, -0.153, -0.270), + "Neutral 3.5": (35.656, -0.421, -1.231), + "Black": (20.461, -0.079, -0.973), } M_MASK = 0xFFFFFFFF @@ -89,9 +104,11 @@ def compute_ccmx(): """CCMX from sensor vs EDID primaries.""" + def xy_to_XYZ(x, y, Y=1.0): - if y == 0: return np.array([0, 0, 0]) - return np.array([(Y/y)*x, Y, (Y/y)*(1-x-y)]) + if y == 0: + return np.array([0, 0, 0]) + return np.array([(Y / y) * x, Y, (Y / y) * (1 - x - y)]) def build_matrix(r_xy, g_xy, b_xy, w_xy): R, G, B = xy_to_XYZ(*r_xy), xy_to_XYZ(*g_xy), xy_to_XYZ(*b_xy) @@ -100,55 +117,72 @@ def build_matrix(r_xy, g_xy, b_xy, w_xy): S = np.linalg.solve(M, W) return M * S[np.newaxis, :] - M_sensor = build_matrix( - (0.6835, 0.3060), (0.2622, 0.7006), - (0.1481, 0.0575), (0.3134, 0.3240)) - M_true = build_matrix( - (0.6835, 0.3164), (0.2373, 0.7080), - (0.1396, 0.0527), (0.3134, 0.3291)) + M_sensor = build_matrix((0.6835, 0.3060), (0.2622, 0.7006), (0.1481, 0.0575), (0.3134, 0.3240)) + M_true = build_matrix((0.6835, 0.3164), (0.2373, 0.7080), (0.1396, 0.0527), (0.3134, 0.3291)) return M_true @ np.linalg.inv(M_sensor) def unlock_device(device): - k0, k1 = 0xa9119479, 0x5b168761 - cmd = bytearray(65); cmd[0] = 0; cmd[1] = 0x99 - device.write(cmd); time.sleep(0.2) + k0, k1 = 0xA9119479, 0x5B168761 + cmd = bytearray(65) + cmd[0] = 0 + cmd[1] = 0x99 + device.write(cmd) + time.sleep(0.2) c = bytes(device.read(64, timeout_ms=3000)) sc = bytearray(8) - for i in range(8): sc[i] = c[3] ^ c[35 + i] - ci0 = (sc[3]<<24)+(sc[0]<<16)+(sc[4]<<8)+sc[6] - ci1 = (sc[1]<<24)+(sc[7]<<16)+(sc[2]<<8)+sc[5] + for i in range(8): + sc[i] = c[3] ^ c[35 + i] + ci0 = (sc[3] << 24) + (sc[0] << 16) + (sc[4] << 8) + sc[6] + ci1 = (sc[1] << 24) + (sc[7] << 16) + (sc[2] << 8) + sc[5] nk0, nk1 = (-k0) & M_MASK, (-k1) & M_MASK - co = [(nk0-ci1)&M_MASK, (nk1-ci0)&M_MASK, (ci1*nk0)&M_MASK, (ci0*nk1)&M_MASK] + co = [(nk0 - ci1) & M_MASK, (nk1 - ci0) & M_MASK, (ci1 * nk0) & M_MASK, (ci0 * nk1) & M_MASK] s = sum(sc) - for sh in [0, 8, 16, 24]: s += (nk0>>sh)&0xFF; s += (nk1>>sh)&0xFF + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF s0, s1 = s & 0xFF, (s >> 8) & 0xFF sr = bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF; sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF; sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF; sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF; sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF; sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF; sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF; sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF; sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb = bytearray(65); rb[0] = 0; rb[1] = 0x9A - for i in range(16): rb[25+i] = c[2] ^ sr[i] - device.write(rb); time.sleep(0.3); device.read(64, timeout_ms=3000) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) def measure_freq(device, integration=1.0): intclks = int(integration * 12000000) - cmd = bytearray(65); cmd[0] = 0x00; cmd[1] = 0x01 - struct.pack_into(' 0: trc /= primary_Y - trc[0] = 0.0; trc[-1] = 1.0 - for i in range(1, len(trc)): trc[i] = max(trc[i], trc[i-1]) + if primary_Y > 0: + trc /= primary_Y + trc[0] = 0.0 + trc[-1] = 1.0 + for i in range(1, len(trc)): + trc[i] = max(trc[i], trc[i - 1]) return trc trc_r = normalize_trc(red_ramp, R_xyz[1]) @@ -262,11 +305,11 @@ def normalize_trc(xyz_arr, primary_Y): def _xy(xyz): s = np.sum(xyz) - return (xyz[0]/s, xyz[1]/s) if s > 0 else (0, 0) + return (xyz[0] / s, xyz[1] / s) if s > 0 else (0, 0) def est_gamma(trc): - mid = trc[n_steps//2] - return np.log(mid)/np.log(0.5) if 0 < mid < 1 else 2.2 + mid = trc[n_steps // 2] + return np.log(mid) / np.log(0.5) if 0 < mid < 1 else 2.2 print(f" White Y: {profile_white_Y:.1f} cd/m2") print(f" White WP: ({_xy(white_ramp[-1])[0]:.4f}, {_xy(white_ramp[-1])[1]:.4f})") @@ -282,15 +325,17 @@ def est_gamma(trc): M_norm = M_display / display_white[1] inv_M = np.linalg.inv(M_norm) - inv_trc_r = interp1d(trc_r, levels, kind='linear', bounds_error=False, fill_value=(0, 1)) - inv_trc_g = interp1d(trc_g, levels, kind='linear', bounds_error=False, fill_value=(0, 1)) - inv_trc_b = interp1d(trc_b, levels, kind='linear', bounds_error=False, fill_value=(0, 1)) + inv_trc_r = interp1d(trc_r, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) + inv_trc_g = interp1d(trc_g, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) + inv_trc_b = interp1d(trc_b, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) srgb_white = SRGB_TO_XYZ @ np.array([1.0, 1.0, 1.0]) dw_norm = display_white / display_white[1] sw_norm = srgb_white / srgb_white[1] - wp_shift = ((dw_norm[0]/sum(dw_norm) - sw_norm[0]/sum(sw_norm))**2 + - (dw_norm[1]/sum(dw_norm) - sw_norm[1]/sum(sw_norm))**2)**0.5 + wp_shift = ( + (dw_norm[0] / sum(dw_norm) - sw_norm[0] / sum(sw_norm)) ** 2 + + (dw_norm[1] / sum(dw_norm) - sw_norm[1] / sum(sw_norm)) ** 2 + ) ** 0.5 if wp_shift > 0.003: source_cone = BRADFORD_MATRIX @ sw_norm @@ -306,11 +351,17 @@ def correct(r, g, b): linear = srgb_gamma_expand(rgb_in) target_xyz = adapt @ (SRGB_TO_XYZ @ linear) display_linear = np.clip(inv_M @ target_xyz, 0.0, 1.0) - corrected = np.clip(np.array([ - float(inv_trc_r(display_linear[0])), - float(inv_trc_g(display_linear[1])), - float(inv_trc_b(display_linear[2])), - ]), 0.0, 1.0) + corrected = np.clip( + np.array( + [ + float(inv_trc_r(display_linear[0])), + float(inv_trc_g(display_linear[1])), + float(inv_trc_b(display_linear[2])), + ] + ), + 0.0, + 1.0, + ) # Near-black protection lum = 0.2126 * r + 0.7152 * g + 0.0722 * b @@ -322,8 +373,12 @@ def correct(r, g, b): # Show correction examples print("\n Correction examples:") - for name, r, g, b in [("White", 0.961, 0.961, 0.961), ("Mid gray", 0.5, 0.5, 0.5), - ("Red", 0.752, 0.197, 0.178), ("Green", 0.262, 0.584, 0.291)]: + for name, r, g, b in [ + ("White", 0.961, 0.961, 0.961), + ("Mid gray", 0.5, 0.5, 0.5), + ("Red", 0.752, 0.197, 0.178), + ("Green", 0.262, 0.584, 0.291), + ]: cr, cg, cb = correct(r, g, b) print(f" {name:10s}: ({r:.3f},{g:.3f},{b:.3f}) -> ({cr:.3f},{cg:.3f},{cb:.3f})") diff --git a/scripts/ccmx_chroma_adaptive.py b/scripts/ccmx_chroma_adaptive.py index 0770b34..501bf21 100644 --- a/scripts/ccmx_chroma_adaptive.py +++ b/scripts/ccmx_chroma_adaptive.py @@ -8,6 +8,7 @@ Expected result: dE < 3.0 average across all 24 patches. """ + import os import struct import sys @@ -32,96 +33,151 @@ xyz_to_lab, ) -OLED_MATRIX = np.array([ - [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], - [-0.00004481, 0.00035042, 0.08032401], -]) +OLED_MATRIX = np.array( + [ + [0.03836831, -0.02175997, 0.01696057], + [0.01449629, 0.01611903, 0.00057150], + [-0.00004481, 0.00035042, 0.08032401], + ] +) COLORCHECKER = [ - ("Dark Skin", 0.453, 0.317, 0.264), ("Light Skin", 0.779, 0.577, 0.505), - ("Blue Sky", 0.355, 0.480, 0.611), ("Foliage", 0.352, 0.422, 0.253), - ("Blue Flower", 0.508, 0.502, 0.691), ("Bluish Green", 0.362, 0.745, 0.675), - ("Orange", 0.879, 0.485, 0.183), ("Purplish Blue",0.266, 0.358, 0.667), - ("Moderate Red", 0.778, 0.321, 0.381), ("Purple", 0.367, 0.227, 0.414), - ("Yellow Green", 0.623, 0.741, 0.246), ("Orange Yellow",0.904, 0.634, 0.154), - ("Blue", 0.139, 0.248, 0.577), ("Green", 0.262, 0.584, 0.291), - ("Red", 0.752, 0.197, 0.178), ("Yellow", 0.938, 0.857, 0.159), - ("Magenta", 0.752, 0.313, 0.577), ("Cyan", 0.121, 0.544, 0.659), - ("White", 0.961, 0.961, 0.961), ("Neutral 8", 0.784, 0.784, 0.784), - ("Neutral 6.5", 0.584, 0.584, 0.584), ("Neutral 5", 0.420, 0.420, 0.420), - ("Neutral 3.5", 0.258, 0.258, 0.258), ("Black", 0.085, 0.085, 0.085), + ("Dark Skin", 0.453, 0.317, 0.264), + ("Light Skin", 0.779, 0.577, 0.505), + ("Blue Sky", 0.355, 0.480, 0.611), + ("Foliage", 0.352, 0.422, 0.253), + ("Blue Flower", 0.508, 0.502, 0.691), + ("Bluish Green", 0.362, 0.745, 0.675), + ("Orange", 0.879, 0.485, 0.183), + ("Purplish Blue", 0.266, 0.358, 0.667), + ("Moderate Red", 0.778, 0.321, 0.381), + ("Purple", 0.367, 0.227, 0.414), + ("Yellow Green", 0.623, 0.741, 0.246), + ("Orange Yellow", 0.904, 0.634, 0.154), + ("Blue", 0.139, 0.248, 0.577), + ("Green", 0.262, 0.584, 0.291), + ("Red", 0.752, 0.197, 0.178), + ("Yellow", 0.938, 0.857, 0.159), + ("Magenta", 0.752, 0.313, 0.577), + ("Cyan", 0.121, 0.544, 0.659), + ("White", 0.961, 0.961, 0.961), + ("Neutral 8", 0.784, 0.784, 0.784), + ("Neutral 6.5", 0.584, 0.584, 0.584), + ("Neutral 5", 0.420, 0.420, 0.420), + ("Neutral 3.5", 0.258, 0.258, 0.258), + ("Black", 0.085, 0.085, 0.085), ] REF_LAB = { - "Dark Skin": (37.986, 13.555, 14.059), "Light Skin": (65.711, 18.130, 17.810), - "Blue Sky": (49.927, -4.880, -21.925), "Foliage": (43.139, -13.095, 21.905), - "Blue Flower": (55.112, 8.844, -25.399), "Bluish Green": (70.719, -33.397, -0.199), - "Orange": (62.661, 36.067, 57.096), "Purplish Blue": (40.020, 10.410, -45.964), - "Moderate Red": (51.124, 48.239, 16.248), "Purple": (30.325, 22.976, -21.587), - "Yellow Green": (72.532, -23.709, 57.255), "Orange Yellow": (71.941, 19.363, 67.857), - "Blue": (28.778, 14.179, -50.297), "Green": (55.261, -38.342, 31.370), - "Red": (42.101, 53.378, 28.190), "Yellow": (81.733, 4.039, 79.819), - "Magenta": (51.935, 49.986, -14.574), "Cyan": (51.038, -28.631, -28.638), - "White": (96.539, -0.425, 1.186), "Neutral 8": (81.257, -0.638, -0.335), - "Neutral 6.5": (66.766, -0.734, -0.504), "Neutral 5": (50.867, -0.153, -0.270), - "Neutral 3.5": (35.656, -0.421, -1.231), "Black": (20.461, -0.079, -0.973), + "Dark Skin": (37.986, 13.555, 14.059), + "Light Skin": (65.711, 18.130, 17.810), + "Blue Sky": (49.927, -4.880, -21.925), + "Foliage": (43.139, -13.095, 21.905), + "Blue Flower": (55.112, 8.844, -25.399), + "Bluish Green": (70.719, -33.397, -0.199), + "Orange": (62.661, 36.067, 57.096), + "Purplish Blue": (40.020, 10.410, -45.964), + "Moderate Red": (51.124, 48.239, 16.248), + "Purple": (30.325, 22.976, -21.587), + "Yellow Green": (72.532, -23.709, 57.255), + "Orange Yellow": (71.941, 19.363, 67.857), + "Blue": (28.778, 14.179, -50.297), + "Green": (55.261, -38.342, 31.370), + "Red": (42.101, 53.378, 28.190), + "Yellow": (81.733, 4.039, 79.819), + "Magenta": (51.935, 49.986, -14.574), + "Cyan": (51.038, -28.631, -28.638), + "White": (96.539, -0.425, 1.186), + "Neutral 8": (81.257, -0.638, -0.335), + "Neutral 6.5": (66.766, -0.734, -0.504), + "Neutral 5": (50.867, -0.153, -0.270), + "Neutral 3.5": (35.656, -0.421, -1.231), + "Black": (20.461, -0.079, -0.973), } M_MASK = 0xFFFFFFFF + def compute_ccmx(): def xy_to_XYZ(x, y, Y=1.0): - if y == 0: return np.array([0,0,0]) - return np.array([(Y/y)*x, Y, (Y/y)*(1-x-y)]) + if y == 0: + return np.array([0, 0, 0]) + return np.array([(Y / y) * x, Y, (Y / y) * (1 - x - y)]) + def build_matrix(r, g, b, w): R, G, B = xy_to_XYZ(*r), xy_to_XYZ(*g), xy_to_XYZ(*b) W = xy_to_XYZ(*w) M = np.column_stack([R, G, B]) S = np.linalg.solve(M, W) return M * S[np.newaxis, :] - Ms = build_matrix((0.6835,0.3060),(0.2622,0.7006),(0.1481,0.0575),(0.3134,0.3240)) - Mt = build_matrix((0.6835,0.3164),(0.2373,0.7080),(0.1396,0.0527),(0.3134,0.3291)) + + Ms = build_matrix((0.6835, 0.3060), (0.2622, 0.7006), (0.1481, 0.0575), (0.3134, 0.3240)) + Mt = build_matrix((0.6835, 0.3164), (0.2373, 0.7080), (0.1396, 0.0527), (0.3134, 0.3291)) return Mt @ np.linalg.inv(Ms) + def unlock_device(device): - k0, k1 = 0xa9119479, 0x5b168761 - cmd = bytearray(65); cmd[0] = 0; cmd[1] = 0x99 - device.write(cmd); time.sleep(0.2) + k0, k1 = 0xA9119479, 0x5B168761 + cmd = bytearray(65) + cmd[0] = 0 + cmd[1] = 0x99 + device.write(cmd) + time.sleep(0.2) c = bytes(device.read(64, timeout_ms=3000)) sc = bytearray(8) - for i in range(8): sc[i] = c[3] ^ c[35+i] - ci0=(sc[3]<<24)+(sc[0]<<16)+(sc[4]<<8)+sc[6]; ci1=(sc[1]<<24)+(sc[7]<<16)+(sc[2]<<8)+sc[5] - nk0,nk1=(-k0)&M_MASK,(-k1)&M_MASK - co=[(nk0-ci1)&M_MASK,(nk1-ci0)&M_MASK,(ci1*nk0)&M_MASK,(ci0*nk1)&M_MASK] - s=sum(sc) - for sh in [0,8,16,24]: s+=(nk0>>sh)&0xFF; s+=(nk1>>sh)&0xFF - s0,s1=s&0xFF,(s>>8)&0xFF - sr=bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF;sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF;sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF;sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF;sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF;sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF;sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF;sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF;sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb=bytearray(65);rb[0]=0;rb[1]=0x9A - for i in range(16): rb[25+i]=c[2]^sr[i] - device.write(rb);time.sleep(0.3);device.read(64,timeout_ms=3000) + for i in range(8): + sc[i] = c[3] ^ c[35 + i] + ci0 = (sc[3] << 24) + (sc[0] << 16) + (sc[4] << 8) + sc[6] + ci1 = (sc[1] << 24) + (sc[7] << 16) + (sc[2] << 8) + sc[5] + nk0, nk1 = (-k0) & M_MASK, (-k1) & M_MASK + co = [(nk0 - ci1) & M_MASK, (nk1 - ci0) & M_MASK, (ci1 * nk0) & M_MASK, (ci0 * nk1) & M_MASK] + s = sum(sc) + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF + s0, s1 = s & 0xFF, (s >> 8) & 0xFF + sr = bytearray(16) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) + def measure_freq(device, integration=1.0): - intclks=int(integration*12000000) - cmd=bytearray(65);cmd[0]=0x00;cmd[1]=0x01 - struct.pack_into('0.3 else None + f = measure_freq(device, integ) + return SENSOR @ f if f is not None and np.max(f) > 0.3 else None # Remove existing LUTs try: from calibrate_pro.lut_system.dwm_lut import remove_lut - remove_lut(0); time.sleep(1) + + remove_lut(0) + time.sleep(1) except Exception: pass # DWM LUT removal is optional # Baseline - show(1,1,1); wY = mxyz(1.0)[1]; norm = 100.0/wY + show(1, 1, 1) + wY = mxyz(1.0)[1] + norm = 100.0 / wY print(f" White Y = {wY:.1f} cd/m2, WP = D65 (CCMX-corrected)") print(" Measuring baseline...\n") base = [] - for name,r,g,b in COLORCHECKER: - show(r,g,b); xyz = mxyz(1.0) + for name, r, g, b in COLORCHECKER: + show(r, g, b) + xyz = mxyz(1.0) if xyz is not None: - lab = xyz_to_lab(bradford_adapt(xyz*norm/100, D65_WHITE, D50_WHITE), D50_WHITE) + lab = xyz_to_lab(bradford_adapt(xyz * norm / 100, D65_WHITE, D50_WHITE), D50_WHITE) base.append(float(delta_e_2000(lab, np.array(REF_LAB[name])))) - else: base.append(-1) + else: + base.append(-1) vb = [d for d in base if d >= 0] print(f" Baseline: avg dE = {np.mean(vb):.2f}, max = {np.max(vb):.2f}\n") # Profile (CCMX-corrected) print(" Profiling (17 steps x 4 channels)...") - n=17; levels=np.linspace(0,1,n) + n = 17 + levels = np.linspace(0, 1, n) + def ramp(mk): - out=[] + out = [] for v in levels: - r,g,b=mk(v); show(r,g,b,0.8 if v>0.1 else 1.5) - xyz=mxyz(0.8 if v>0.1 else 1.5) + r, g, b = mk(v) + show(r, g, b, 0.8 if v > 0.1 else 1.5) + xyz = mxyz(0.8 if v > 0.1 else 1.5) out.append(xyz if xyz is not None else np.zeros(3)) return np.array(out) - wr=ramp(lambda v:(v,v,v)); rr=ramp(lambda v:(v,0,0)) - gr=ramp(lambda v:(0,v,0)); br=ramp(lambda v:(0,0,v)) - blk=wr[0].copy() - for a in [wr,rr,gr,br]: a-=blk - wr[0]=0;rr[0]=0;gr[0]=0;br[0]=0 - Md=np.column_stack([rr[-1],gr[-1],br[-1]]) - def ntrc(a,pY): - t=np.maximum(a[:,1],0) - if pY>0: t/=pY - t[0]=0;t[-1]=1 - for i in range(1,len(t)): t[i]=max(t[i],t[i-1]) + + wr = ramp(lambda v: (v, v, v)) + rr = ramp(lambda v: (v, 0, 0)) + gr = ramp(lambda v: (0, v, 0)) + br = ramp(lambda v: (0, 0, v)) + blk = wr[0].copy() + for a in [wr, rr, gr, br]: + a -= blk + wr[0] = 0 + rr[0] = 0 + gr[0] = 0 + br[0] = 0 + Md = np.column_stack([rr[-1], gr[-1], br[-1]]) + + def ntrc(a, pY): + t = np.maximum(a[:, 1], 0) + if pY > 0: + t /= pY + t[0] = 0 + t[-1] = 1 + for i in range(1, len(t)): + t[i] = max(t[i], t[i - 1]) return t - tr=ntrc(rr,rr[-1][1]); tg=ntrc(gr,gr[-1][1]); tb=ntrc(br,br[-1][1]) - dw_xyz=Md@np.array([1,1,1]); Mn=Md/dw_xyz[1]; iM=np.linalg.inv(Mn) - itr=interp1d(tr,levels,kind='linear',bounds_error=False,fill_value=(0,1)) - itg=interp1d(tg,levels,kind='linear',bounds_error=False,fill_value=(0,1)) - itb=interp1d(tb,levels,kind='linear',bounds_error=False,fill_value=(0,1)) + + tr = ntrc(rr, rr[-1][1]) + tg = ntrc(gr, gr[-1][1]) + tb = ntrc(br, br[-1][1]) + dw_xyz = Md @ np.array([1, 1, 1]) + Mn = Md / dw_xyz[1] + iM = np.linalg.inv(Mn) + itr = interp1d(tr, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) + itg = interp1d(tg, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) + itb = interp1d(tb, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) # Bradford - sw=SRGB_TO_XYZ@np.array([1,1,1]); dwn=dw_xyz/dw_xyz[1]; swn=sw/sw[1] - sc_c=BRADFORD_MATRIX@swn; dc=BRADFORD_MATRIX@dwn - adapt=BRADFORD_INVERSE@np.diag(dc/sc_c)@BRADFORD_MATRIX + sw = SRGB_TO_XYZ @ np.array([1, 1, 1]) + dwn = dw_xyz / dw_xyz[1] + swn = sw / sw[1] + sc_c = BRADFORD_MATRIX @ swn + dc = BRADFORD_MATRIX @ dwn + adapt = BRADFORD_INVERSE @ np.diag(dc / sc_c) @ BRADFORD_MATRIX def _xy(x): - s=sum(x); return (x[0]/s,x[1]/s) if s>0 else (0,0) - gR=np.log(max(tr[n//2],0.001))/np.log(0.5) - gG=np.log(max(tg[n//2],0.001))/np.log(0.5) - gB=np.log(max(tb[n//2],0.001))/np.log(0.5) + s = sum(x) + return (x[0] / s, x[1] / s) if s > 0 else (0, 0) + + gR = np.log(max(tr[n // 2], 0.001)) / np.log(0.5) + gG = np.log(max(tg[n // 2], 0.001)) / np.log(0.5) + gB = np.log(max(tb[n // 2], 0.001)) / np.log(0.5) print(f" WP: ({_xy(wr[-1])[0]:.4f}, {_xy(wr[-1])[1]:.4f})") print(f" Gamma: R={gR:.2f} G={gG:.2f} B={gB:.2f}") # Chroma-adaptive correction - def correct(r,g,b): - rgb_in=np.array([r,g,b]) - lin=srgb_gamma_expand(rgb_in) - txyz=adapt@(SRGB_TO_XYZ@lin) - dl=np.clip(iM@txyz,0,1) - full=np.clip(np.array([float(itr(dl[0])),float(itg(dl[1])),float(itb(dl[2]))]),0,1) - - mx=max(r,g,b); mn=min(r,g,b) - chroma=(mx-mn)/max(mx,1e-6) - if chroma<=0.05: bl=0 - elif chroma>=0.3: bl=1 - else: t=(chroma-0.05)/0.25; bl=t*t*(3-2*t) - - result=rgb_in*(1-bl)+full*bl - lum=0.2126*r+0.7152*g+0.0722*b - if lum<0.03: result=rgb_in*(1-lum/0.03)+result*(lum/0.03) - return np.clip(result,0,1) + def correct(r, g, b): + rgb_in = np.array([r, g, b]) + lin = srgb_gamma_expand(rgb_in) + txyz = adapt @ (SRGB_TO_XYZ @ lin) + dl = np.clip(iM @ txyz, 0, 1) + full = np.clip(np.array([float(itr(dl[0])), float(itg(dl[1])), float(itb(dl[2]))]), 0, 1) + + mx = max(r, g, b) + mn = min(r, g, b) + chroma = (mx - mn) / max(mx, 1e-6) + if chroma <= 0.05: + bl = 0 + elif chroma >= 0.3: + bl = 1 + else: + t = (chroma - 0.05) / 0.25 + bl = t * t * (3 - 2 * t) + + result = rgb_in * (1 - bl) + full * bl + lum = 0.2126 * r + 0.7152 * g + 0.0722 * b + if lum < 0.03: + result = rgb_in * (1 - lum / 0.03) + result * (lum / 0.03) + return np.clip(result, 0, 1) # Verify print("\n Verifying with pre-corrected patches...\n") print(f" {'Patch':20s} {'Before':>6s} {'After':>6s} {'Change':>7s} Status") - print(" "+"="*62) + print(" " + "=" * 62) - cor=[] - for i,(name,r,g,b) in enumerate(COLORCHECKER): - cr,cg,cb=correct(r,g,b) - show(cr,cg,cb) - xyz=mxyz(1.0) + cor = [] + for i, (name, r, g, b) in enumerate(COLORCHECKER): + cr, cg, cb = correct(r, g, b) + show(cr, cg, cb) + xyz = mxyz(1.0) if xyz is not None: - lab=xyz_to_lab(bradford_adapt(xyz*norm/100,D65_WHITE,D50_WHITE),D50_WHITE) - de=float(delta_e_2000(lab,np.array(REF_LAB[name]))) + lab = xyz_to_lab(bradford_adapt(xyz * norm / 100, D65_WHITE, D50_WHITE), D50_WHITE) + de = float(delta_e_2000(lab, np.array(REF_LAB[name]))) cor.append(de) - bd=base[i] - if bd>=0: - ch=de-bd; st="PASS" if de<2 else "WARN" if de<3 else "FAIL" - ar="v" if ch<-0.5 else "^" if ch>0.5 else "~" + bd = base[i] + if bd >= 0: + ch = de - bd + st = "PASS" if de < 2 else "WARN" if de < 3 else "FAIL" + ar = "v" if ch < -0.5 else "^" if ch > 0.5 else "~" print(f" {name:20s} {bd:5.2f} {de:5.2f} {ch:+5.2f} {ar} [{st}]") - else: cor.append(-1) + else: + cor.append(-1) - root.destroy(); device.close() + root.destroy() + device.close() - vc=[d for d in cor if d>=0] - print(" "+"="*62) - avg_b,avg_c=np.mean(vb),np.mean(vc) - pct=(1-avg_c/avg_b)*100 if avg_b>0 else 0 - ps=sum(1 for d in vc if d<3) + vc = [d for d in cor if d >= 0] + print(" " + "=" * 62) + avg_b, avg_c = np.mean(vb), np.mean(vc) + pct = (1 - avg_c / avg_b) * 100 if avg_b > 0 else 0 + ps = sum(1 for d in vc if d < 3) print(f" Before: avg dE = {avg_b:.2f}, max = {np.max(vb):.2f}") print(f" After: avg dE = {avg_c:.2f}, max = {np.max(vc):.2f}") print(f" Passing: {ps}/{len(vc)} (<3.0)") - print(f"\n {'IMPROVEMENT' if avg_c>sh)&0xFF; s += (nk1>>sh)&0xFF + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF s0, s1 = s & 0xFF, (s >> 8) & 0xFF sr = bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF; sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF; sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF; sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF; sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF; sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF; sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF; sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF; sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb = bytearray(65); rb[0] = 0; rb[1] = 0x9A - for i in range(16): rb[25+i] = c[2] ^ sr[i] - device.write(rb); time.sleep(0.3); device.read(64, timeout_ms=3000) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) def measure_freq(device, integration=1.0): intclks = int(integration * 12000000) - cmd = bytearray(65); cmd[0] = 0x00; cmd[1] = 0x01 - struct.pack_into('= 7: - results.append( - (float(parts[4]), float(parts[5]), float(parts[6])) - ) + results.append((float(parts[4]), float(parts[5]), float(parts[6]))) return results if len(results) == len(PATCHES) else None @@ -199,7 +197,7 @@ def build_correction_lut(R_inv, size=33): def print_results(before_de, after_de): print() - print(f'{"Patch":20s} {"Before":>6s} {"After":>6s} {"Change":>7s} Status') + print(f"{'Patch':20s} {'Before':>6s} {'After':>6s} {'Change':>7s} Status") print("=" * 65) for i, (name, _r, _g, _b) in enumerate(PATCHES): b_de = before_de[i] @@ -227,6 +225,7 @@ def print_results(before_de, after_de): print("Step 1: Removing existing LUT...") try: from calibrate_pro.lut_system.dwm_lut import remove_lut + remove_lut(1) except Exception: pass @@ -260,6 +259,7 @@ def print_results(before_de, after_de): print("Step 5: Applying measured-correction LUT...") try: from calibrate_pro.lut_system.dwm_lut import DwmLutController + dwm = DwmLutController() if dwm.is_available: dwm.load_lut_file(1, lut_path) diff --git a/scripts/native_calibration_loop.py b/scripts/native_calibration_loop.py index efe2806..1c652a6 100644 --- a/scripts/native_calibration_loop.py +++ b/scripts/native_calibration_loop.py @@ -10,6 +10,7 @@ 3. Apply: Load via dwm_lut 4. Verify: Re-measure ColorChecker patches, compare before/after dE """ + import os import struct import sys @@ -36,55 +37,69 @@ # ============================================================================= # Sensor: OLED calibration matrix from device EEPROM at offset 0x191C # ============================================================================= -OLED_MATRIX = np.array([ - [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], - [-0.00004481, 0.00035042, 0.08032401], -]) +OLED_MATRIX = np.array( + [ + [0.03836831, -0.02175997, 0.01696057], + [0.01449629, 0.01611903, 0.00057150], + [-0.00004481, 0.00035042, 0.08032401], + ] +) # ============================================================================= # ColorChecker Classic reference data # ============================================================================= COLORCHECKER = [ - ("Dark Skin", 0.453, 0.317, 0.264), - ("Light Skin", 0.779, 0.577, 0.505), - ("Blue Sky", 0.355, 0.480, 0.611), - ("Foliage", 0.352, 0.422, 0.253), - ("Blue Flower", 0.508, 0.502, 0.691), + ("Dark Skin", 0.453, 0.317, 0.264), + ("Light Skin", 0.779, 0.577, 0.505), + ("Blue Sky", 0.355, 0.480, 0.611), + ("Foliage", 0.352, 0.422, 0.253), + ("Blue Flower", 0.508, 0.502, 0.691), ("Bluish Green", 0.362, 0.745, 0.675), - ("Orange", 0.879, 0.485, 0.183), - ("Purplish Blue",0.266, 0.358, 0.667), + ("Orange", 0.879, 0.485, 0.183), + ("Purplish Blue", 0.266, 0.358, 0.667), ("Moderate Red", 0.778, 0.321, 0.381), - ("Purple", 0.367, 0.227, 0.414), + ("Purple", 0.367, 0.227, 0.414), ("Yellow Green", 0.623, 0.741, 0.246), - ("Orange Yellow",0.904, 0.634, 0.154), - ("Blue", 0.139, 0.248, 0.577), - ("Green", 0.262, 0.584, 0.291), - ("Red", 0.752, 0.197, 0.178), - ("Yellow", 0.938, 0.857, 0.159), - ("Magenta", 0.752, 0.313, 0.577), - ("Cyan", 0.121, 0.544, 0.659), - ("White", 0.961, 0.961, 0.961), - ("Neutral 8", 0.784, 0.784, 0.784), - ("Neutral 6.5", 0.584, 0.584, 0.584), - ("Neutral 5", 0.420, 0.420, 0.420), - ("Neutral 3.5", 0.258, 0.258, 0.258), - ("Black", 0.085, 0.085, 0.085), + ("Orange Yellow", 0.904, 0.634, 0.154), + ("Blue", 0.139, 0.248, 0.577), + ("Green", 0.262, 0.584, 0.291), + ("Red", 0.752, 0.197, 0.178), + ("Yellow", 0.938, 0.857, 0.159), + ("Magenta", 0.752, 0.313, 0.577), + ("Cyan", 0.121, 0.544, 0.659), + ("White", 0.961, 0.961, 0.961), + ("Neutral 8", 0.784, 0.784, 0.784), + ("Neutral 6.5", 0.584, 0.584, 0.584), + ("Neutral 5", 0.420, 0.420, 0.420), + ("Neutral 3.5", 0.258, 0.258, 0.258), + ("Black", 0.085, 0.085, 0.085), ] REF_LAB = { - "Dark Skin": (37.986, 13.555, 14.059), "Light Skin": (65.711, 18.130, 17.810), - "Blue Sky": (49.927, -4.880, -21.925), "Foliage": (43.139, -13.095, 21.905), - "Blue Flower": (55.112, 8.844, -25.399), "Bluish Green": (70.719, -33.397, -0.199), - "Orange": (62.661, 36.067, 57.096), "Purplish Blue": (40.020, 10.410, -45.964), - "Moderate Red": (51.124, 48.239, 16.248), "Purple": (30.325, 22.976, -21.587), - "Yellow Green": (72.532, -23.709, 57.255), "Orange Yellow": (71.941, 19.363, 67.857), - "Blue": (28.778, 14.179, -50.297), "Green": (55.261, -38.342, 31.370), - "Red": (42.101, 53.378, 28.190), "Yellow": (81.733, 4.039, 79.819), - "Magenta": (51.935, 49.986, -14.574), "Cyan": (51.038, -28.631, -28.638), - "White": (96.539, -0.425, 1.186), "Neutral 8": (81.257, -0.638, -0.335), - "Neutral 6.5": (66.766, -0.734, -0.504), "Neutral 5": (50.867, -0.153, -0.270), - "Neutral 3.5": (35.656, -0.421, -1.231), "Black": (20.461, -0.079, -0.973), + "Dark Skin": (37.986, 13.555, 14.059), + "Light Skin": (65.711, 18.130, 17.810), + "Blue Sky": (49.927, -4.880, -21.925), + "Foliage": (43.139, -13.095, 21.905), + "Blue Flower": (55.112, 8.844, -25.399), + "Bluish Green": (70.719, -33.397, -0.199), + "Orange": (62.661, 36.067, 57.096), + "Purplish Blue": (40.020, 10.410, -45.964), + "Moderate Red": (51.124, 48.239, 16.248), + "Purple": (30.325, 22.976, -21.587), + "Yellow Green": (72.532, -23.709, 57.255), + "Orange Yellow": (71.941, 19.363, 67.857), + "Blue": (28.778, 14.179, -50.297), + "Green": (55.261, -38.342, 31.370), + "Red": (42.101, 53.378, 28.190), + "Yellow": (81.733, 4.039, 79.819), + "Magenta": (51.935, 49.986, -14.574), + "Cyan": (51.038, -28.631, -28.638), + "White": (96.539, -0.425, 1.186), + "Neutral 8": (81.257, -0.638, -0.335), + "Neutral 6.5": (66.766, -0.734, -0.504), + "Neutral 5": (50.867, -0.153, -0.270), + "Neutral 3.5": (35.656, -0.421, -1.231), + "Black": (20.461, -0.079, -0.973), } M = 0xFFFFFFFF @@ -93,48 +108,70 @@ # i1Display3 USB HID communication # ============================================================================= + def unlock_device(device): """Challenge-response unlock for NEC OEM i1Display3.""" - k0, k1 = 0xa9119479, 0x5b168761 - cmd = bytearray(65); cmd[0] = 0; cmd[1] = 0x99 - device.write(cmd); time.sleep(0.2) + k0, k1 = 0xA9119479, 0x5B168761 + cmd = bytearray(65) + cmd[0] = 0 + cmd[1] = 0x99 + device.write(cmd) + time.sleep(0.2) c = bytes(device.read(64, timeout_ms=3000)) sc = bytearray(8) - for i in range(8): sc[i] = c[3] ^ c[35 + i] - ci0 = (sc[3]<<24)+(sc[0]<<16)+(sc[4]<<8)+sc[6] - ci1 = (sc[1]<<24)+(sc[7]<<16)+(sc[2]<<8)+sc[5] + for i in range(8): + sc[i] = c[3] ^ c[35 + i] + ci0 = (sc[3] << 24) + (sc[0] << 16) + (sc[4] << 8) + sc[6] + ci1 = (sc[1] << 24) + (sc[7] << 16) + (sc[2] << 8) + sc[5] nk0, nk1 = (-k0) & M, (-k1) & M - co = [(nk0-ci1)&M, (nk1-ci0)&M, (ci1*nk0)&M, (ci0*nk1)&M] + co = [(nk0 - ci1) & M, (nk1 - ci0) & M, (ci1 * nk0) & M, (ci0 * nk1) & M] s = sum(sc) - for sh in [0, 8, 16, 24]: s += (nk0>>sh)&0xFF; s += (nk1>>sh)&0xFF + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF s0, s1 = s & 0xFF, (s >> 8) & 0xFF sr = bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF; sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF; sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF; sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF; sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF; sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF; sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF; sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF; sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb = bytearray(65); rb[0] = 0; rb[1] = 0x9A - for i in range(16): rb[25+i] = c[2] ^ sr[i] - device.write(rb); time.sleep(0.3); device.read(64, timeout_ms=3000) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) def measure_freq(device, integration=1.0): """Frequency measurement mode — returns raw RGB sensor counts/sec.""" intclks = int(integration * 12000000) - cmd = bytearray(65); cmd[0] = 0x00; cmd[1] = 0x01 - struct.pack_into(' 0 else (0, 0) + return (xyz[0] / s, xyz[1] / s) if s > 0 else (0, 0) rx, ry = _xy(R_xyz) gx, gy = _xy(G_xyz) @@ -320,12 +362,9 @@ def build_correction_lut(levels, trc_r, trc_g, trc_b, M_display, black_xyz, whit # Build interpolation functions for inverse TRC # Forward TRC: signal -> linear (measured) # Inverse TRC: linear -> signal (what signal to send for desired linear output) - inv_trc_r = interp1d(trc_r, levels, kind='linear', bounds_error=False, - fill_value=(0.0, 1.0)) - inv_trc_g = interp1d(trc_g, levels, kind='linear', bounds_error=False, - fill_value=(0.0, 1.0)) - inv_trc_b = interp1d(trc_b, levels, kind='linear', bounds_error=False, - fill_value=(0.0, 1.0)) + inv_trc_r = interp1d(trc_r, levels, kind="linear", bounds_error=False, fill_value=(0.0, 1.0)) + inv_trc_g = interp1d(trc_g, levels, kind="linear", bounds_error=False, fill_value=(0.0, 1.0)) + inv_trc_b = interp1d(trc_b, levels, kind="linear", bounds_error=False, fill_value=(0.0, 1.0)) # Invert the display primaries matrix # M_display maps linear [r,g,b] -> XYZ (minus black) @@ -350,6 +389,7 @@ def build_correction_lut(levels, trc_r, trc_g, trc_b, M_display, black_xyz, whit # Display produces the correct relative XYZ for each color # Use Bradford chromatic adaptation from D65 -> display white point from calibrate_pro.core.color_math import BRADFORD_INVERSE, BRADFORD_MATRIX + wp_source = srgb_white_xyz / srgb_white_xyz[1] # D65 (normalized) wp_dest = display_white_xyz / display_white_xyz[1] # Display white (normalized) @@ -491,6 +531,7 @@ def print_comparison(before, after): # --- Find display --- from calibrate_pro.panels.detection import enumerate_displays + displays = enumerate_displays() dx, dy, dw, dh = 0, 0, 3840, 2160 for d in displays: @@ -504,6 +545,7 @@ def print_comparison(before, after): print("\nStep 2: Removing existing calibration LUT...") try: from calibrate_pro.lut_system.dwm_lut import remove_lut + remove_lut(1) time.sleep(2) print(" Existing LUT removed.") @@ -519,15 +561,14 @@ def print_comparison(before, after): baseline = measure_colorchecker(device, display, white_Y_baseline) valid_baseline = [r[1] for r in baseline if r[1] >= 0] - print(f"\n Baseline: avg dE = {np.mean(valid_baseline):.2f}, " - f"max dE = {np.max(valid_baseline):.2f}") + print(f"\n Baseline: avg dE = {np.mean(valid_baseline):.2f}, max dE = {np.max(valid_baseline):.2f}") for i, (name, de, _) in enumerate(baseline): status = "PASS" if de < 2.0 else "WARN" if de < 3.0 else "FAIL" if de >= 0: - print(f" [{i+1:2d}/24] {name:20s} dE = {de:5.2f} [{status}]") + print(f" [{i + 1:2d}/24] {name:20s} dE = {de:5.2f} [{status}]") else: - print(f" [{i+1:2d}/24] {name:20s} (no reading)") + print(f" [{i + 1:2d}/24] {name:20s} (no reading)") # --- Profile the display --- print("\nStep 4: Profiling display (per-channel TRC measurement)...") @@ -536,8 +577,7 @@ def print_comparison(before, after): # --- Build correction LUT --- print("\nStep 5: Computing correction LUT from display profile...") - lut = build_correction_lut(levels, trc_r, trc_g, trc_b, M_display, - black_xyz, white_Y, size=33) + lut = build_correction_lut(levels, trc_r, trc_g, trc_b, M_display, black_xyz, white_Y, size=33) # Save LUT lut_dir = os.path.expanduser("~/Documents/Calibrate Pro/Calibrations") @@ -550,6 +590,7 @@ def print_comparison(before, after): print("\nStep 6: Applying correction LUT...") try: from calibrate_pro.lut_system.dwm_lut import DwmLutController + dwm = DwmLutController() if dwm.is_available: dwm.load_lut_file(1, lut_path) diff --git a/scripts/native_calibration_loop_v2.py b/scripts/native_calibration_loop_v2.py index c6b3111..de69e0c 100644 --- a/scripts/native_calibration_loop_v2.py +++ b/scripts/native_calibration_loop_v2.py @@ -10,6 +10,7 @@ 3. Verify: Display PRE-CORRECTED patches, re-measure, compare dE 4. If correction works: save as 3D LUT for system-wide use """ + import os import struct import sys @@ -38,55 +39,69 @@ # ============================================================================= # Sensor calibration matrix from EEPROM (Organic LED @ 0x191C) # ============================================================================= -OLED_MATRIX = np.array([ - [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], - [-0.00004481, 0.00035042, 0.08032401], -]) +OLED_MATRIX = np.array( + [ + [0.03836831, -0.02175997, 0.01696057], + [0.01449629, 0.01611903, 0.00057150], + [-0.00004481, 0.00035042, 0.08032401], + ] +) # ============================================================================= # ColorChecker Classic reference # ============================================================================= COLORCHECKER = [ - ("Dark Skin", 0.453, 0.317, 0.264), - ("Light Skin", 0.779, 0.577, 0.505), - ("Blue Sky", 0.355, 0.480, 0.611), - ("Foliage", 0.352, 0.422, 0.253), - ("Blue Flower", 0.508, 0.502, 0.691), + ("Dark Skin", 0.453, 0.317, 0.264), + ("Light Skin", 0.779, 0.577, 0.505), + ("Blue Sky", 0.355, 0.480, 0.611), + ("Foliage", 0.352, 0.422, 0.253), + ("Blue Flower", 0.508, 0.502, 0.691), ("Bluish Green", 0.362, 0.745, 0.675), - ("Orange", 0.879, 0.485, 0.183), - ("Purplish Blue",0.266, 0.358, 0.667), + ("Orange", 0.879, 0.485, 0.183), + ("Purplish Blue", 0.266, 0.358, 0.667), ("Moderate Red", 0.778, 0.321, 0.381), - ("Purple", 0.367, 0.227, 0.414), + ("Purple", 0.367, 0.227, 0.414), ("Yellow Green", 0.623, 0.741, 0.246), - ("Orange Yellow",0.904, 0.634, 0.154), - ("Blue", 0.139, 0.248, 0.577), - ("Green", 0.262, 0.584, 0.291), - ("Red", 0.752, 0.197, 0.178), - ("Yellow", 0.938, 0.857, 0.159), - ("Magenta", 0.752, 0.313, 0.577), - ("Cyan", 0.121, 0.544, 0.659), - ("White", 0.961, 0.961, 0.961), - ("Neutral 8", 0.784, 0.784, 0.784), - ("Neutral 6.5", 0.584, 0.584, 0.584), - ("Neutral 5", 0.420, 0.420, 0.420), - ("Neutral 3.5", 0.258, 0.258, 0.258), - ("Black", 0.085, 0.085, 0.085), + ("Orange Yellow", 0.904, 0.634, 0.154), + ("Blue", 0.139, 0.248, 0.577), + ("Green", 0.262, 0.584, 0.291), + ("Red", 0.752, 0.197, 0.178), + ("Yellow", 0.938, 0.857, 0.159), + ("Magenta", 0.752, 0.313, 0.577), + ("Cyan", 0.121, 0.544, 0.659), + ("White", 0.961, 0.961, 0.961), + ("Neutral 8", 0.784, 0.784, 0.784), + ("Neutral 6.5", 0.584, 0.584, 0.584), + ("Neutral 5", 0.420, 0.420, 0.420), + ("Neutral 3.5", 0.258, 0.258, 0.258), + ("Black", 0.085, 0.085, 0.085), ] REF_LAB = { - "Dark Skin": (37.986, 13.555, 14.059), "Light Skin": (65.711, 18.130, 17.810), - "Blue Sky": (49.927, -4.880, -21.925), "Foliage": (43.139, -13.095, 21.905), - "Blue Flower": (55.112, 8.844, -25.399), "Bluish Green": (70.719, -33.397, -0.199), - "Orange": (62.661, 36.067, 57.096), "Purplish Blue": (40.020, 10.410, -45.964), - "Moderate Red": (51.124, 48.239, 16.248), "Purple": (30.325, 22.976, -21.587), - "Yellow Green": (72.532, -23.709, 57.255), "Orange Yellow": (71.941, 19.363, 67.857), - "Blue": (28.778, 14.179, -50.297), "Green": (55.261, -38.342, 31.370), - "Red": (42.101, 53.378, 28.190), "Yellow": (81.733, 4.039, 79.819), - "Magenta": (51.935, 49.986, -14.574), "Cyan": (51.038, -28.631, -28.638), - "White": (96.539, -0.425, 1.186), "Neutral 8": (81.257, -0.638, -0.335), - "Neutral 6.5": (66.766, -0.734, -0.504), "Neutral 5": (50.867, -0.153, -0.270), - "Neutral 3.5": (35.656, -0.421, -1.231), "Black": (20.461, -0.079, -0.973), + "Dark Skin": (37.986, 13.555, 14.059), + "Light Skin": (65.711, 18.130, 17.810), + "Blue Sky": (49.927, -4.880, -21.925), + "Foliage": (43.139, -13.095, 21.905), + "Blue Flower": (55.112, 8.844, -25.399), + "Bluish Green": (70.719, -33.397, -0.199), + "Orange": (62.661, 36.067, 57.096), + "Purplish Blue": (40.020, 10.410, -45.964), + "Moderate Red": (51.124, 48.239, 16.248), + "Purple": (30.325, 22.976, -21.587), + "Yellow Green": (72.532, -23.709, 57.255), + "Orange Yellow": (71.941, 19.363, 67.857), + "Blue": (28.778, 14.179, -50.297), + "Green": (55.261, -38.342, 31.370), + "Red": (42.101, 53.378, 28.190), + "Yellow": (81.733, 4.039, 79.819), + "Magenta": (51.935, 49.986, -14.574), + "Cyan": (51.038, -28.631, -28.638), + "White": (96.539, -0.425, 1.186), + "Neutral 8": (81.257, -0.638, -0.335), + "Neutral 6.5": (66.766, -0.734, -0.504), + "Neutral 5": (50.867, -0.153, -0.270), + "Neutral 3.5": (35.656, -0.421, -1.231), + "Black": (20.461, -0.079, -0.973), } M_MASK = 0xFFFFFFFF @@ -95,46 +110,68 @@ # i1Display3 USB HID # ============================================================================= + def unlock_device(device): - k0, k1 = 0xa9119479, 0x5b168761 - cmd = bytearray(65); cmd[0] = 0; cmd[1] = 0x99 - device.write(cmd); time.sleep(0.2) + k0, k1 = 0xA9119479, 0x5B168761 + cmd = bytearray(65) + cmd[0] = 0 + cmd[1] = 0x99 + device.write(cmd) + time.sleep(0.2) c = bytes(device.read(64, timeout_ms=3000)) sc = bytearray(8) - for i in range(8): sc[i] = c[3] ^ c[35 + i] - ci0 = (sc[3]<<24)+(sc[0]<<16)+(sc[4]<<8)+sc[6] - ci1 = (sc[1]<<24)+(sc[7]<<16)+(sc[2]<<8)+sc[5] + for i in range(8): + sc[i] = c[3] ^ c[35 + i] + ci0 = (sc[3] << 24) + (sc[0] << 16) + (sc[4] << 8) + sc[6] + ci1 = (sc[1] << 24) + (sc[7] << 16) + (sc[2] << 8) + sc[5] nk0, nk1 = (-k0) & M_MASK, (-k1) & M_MASK - co = [(nk0-ci1)&M_MASK, (nk1-ci0)&M_MASK, (ci1*nk0)&M_MASK, (ci0*nk1)&M_MASK] + co = [(nk0 - ci1) & M_MASK, (nk1 - ci0) & M_MASK, (ci1 * nk0) & M_MASK, (ci0 * nk1) & M_MASK] s = sum(sc) - for sh in [0, 8, 16, 24]: s += (nk0>>sh)&0xFF; s += (nk1>>sh)&0xFF + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF s0, s1 = s & 0xFF, (s >> 8) & 0xFF sr = bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF; sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF; sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF; sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF; sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF; sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF; sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF; sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF; sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb = bytearray(65); rb[0] = 0; rb[1] = 0x9A - for i in range(16): rb[25+i] = c[2] ^ sr[i] - device.write(rb); time.sleep(0.3); device.read(64, timeout_ms=3000) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) def measure_freq(device, integration=1.0): intclks = int(integration * 12000000) - cmd = bytearray(65); cmd[0] = 0x00; cmd[1] = 0x01 - struct.pack_into(' 0: trc /= primary_Y - trc[0] = 0.0; trc[-1] = 1.0 + trc[0] = 0.0 + trc[-1] = 1.0 # Ensure monotonically increasing for i in range(1, len(trc)): - trc[i] = max(trc[i], trc[i-1]) + trc[i] = max(trc[i], trc[i - 1]) return trc trc_r = normalize_trc(red_xyz, R_xyz_100[1]) @@ -244,7 +287,7 @@ def normalize_trc(xyz_arr, primary_Y): # Report def _xy(xyz): s = np.sum(xyz) - return (xyz[0]/s, xyz[1]/s) if s > 0 else (0, 0) + return (xyz[0] / s, xyz[1] / s) if s > 0 else (0, 0) print(f"\n White Y: {white_Y:.1f} cd/m2") print(f" White point: ({_xy(white_xyz[-1])[0]:.4f}, {_xy(white_xyz[-1])[1]:.4f})") @@ -256,11 +299,13 @@ def _xy(xyz): for ch, trc in [("R", trc_r), ("G", trc_g), ("B", trc_b)]: mid = trc[n_steps // 2] if 0 < mid < 1: - print(f" {ch} gamma ~ {np.log(mid)/np.log(0.5):.2f}") + print(f" {ch} gamma ~ {np.log(mid) / np.log(0.5):.2f}") return { "levels": levels, - "trc_r": trc_r, "trc_g": trc_g, "trc_b": trc_b, + "trc_r": trc_r, + "trc_g": trc_g, + "trc_b": trc_b, "M_display": M_display, "white_Y": white_Y, "black_xyz": black, @@ -272,6 +317,7 @@ def _xy(xyz): # Correction computation # ============================================================================= + def build_correction_function(profile): """ Build a correction function that maps sRGB -> corrected sRGB. @@ -287,14 +333,14 @@ def build_correction_function(profile): M_display = profile["M_display"] # Inverse TRC: desired linear -> signal level to send - inv_trc_r = interp1d(trc_r, levels, kind='linear', bounds_error=False, fill_value=(0, 1)) - inv_trc_g = interp1d(trc_g, levels, kind='linear', bounds_error=False, fill_value=(0, 1)) - inv_trc_b = interp1d(trc_b, levels, kind='linear', bounds_error=False, fill_value=(0, 1)) + inv_trc_r = interp1d(trc_r, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) + inv_trc_g = interp1d(trc_g, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) + inv_trc_b = interp1d(trc_b, levels, kind="linear", bounds_error=False, fill_value=(0, 1)) # Forward TRC: signal level -> linear - fwd_trc_r = interp1d(levels, trc_r, kind='linear', bounds_error=False, fill_value=(0, 1)) - fwd_trc_g = interp1d(levels, trc_g, kind='linear', bounds_error=False, fill_value=(0, 1)) - fwd_trc_b = interp1d(levels, trc_b, kind='linear', bounds_error=False, fill_value=(0, 1)) + fwd_trc_r = interp1d(levels, trc_r, kind="linear", bounds_error=False, fill_value=(0, 1)) + fwd_trc_g = interp1d(levels, trc_g, kind="linear", bounds_error=False, fill_value=(0, 1)) + fwd_trc_b = interp1d(levels, trc_b, kind="linear", bounds_error=False, fill_value=(0, 1)) # Normalize M_display to relative (Y=1) scale # M_display is in absolute cd/m2: M @ [1,1,1] gives Y ~ white_Y @@ -313,7 +359,7 @@ def build_correction_function(profile): # Only adapt if white point shift > 0.005 in xy dw_xy = (dw_norm[0] / sum(dw_norm), dw_norm[1] / sum(dw_norm)) sw_xy = (sw_norm[0] / sum(sw_norm), sw_norm[1] / sum(sw_norm)) - wp_shift = ((dw_xy[0] - sw_xy[0])**2 + (dw_xy[1] - sw_xy[1])**2)**0.5 + wp_shift = ((dw_xy[0] - sw_xy[0]) ** 2 + (dw_xy[1] - sw_xy[1]) ** 2) ** 0.5 if wp_shift > 0.005: source_cone = BRADFORD_MATRIX @ sw_norm @@ -325,7 +371,7 @@ def build_correction_function(profile): print(f"\n White point shift: {wp_shift:.4f} -> within tolerance, skipping adaptation") print(" Display model check:") - print(f" M_norm @ [1,1,1] Y = {(M_norm @ np.array([1,1,1]))[1]:.4f} (should be ~1.0)") + print(f" M_norm @ [1,1,1] Y = {(M_norm @ np.array([1, 1, 1]))[1]:.4f} (should be ~1.0)") print(f" Display WP xy: ({dw_xy[0]:.4f}, {dw_xy[1]:.4f})") print(f" sRGB WP xy: ({sw_xy[0]:.4f}, {sw_xy[1]:.4f})") @@ -352,11 +398,13 @@ def correct(r, g, b): display_linear = inv_M @ adapted_xyz display_linear = np.clip(display_linear, 0.0, 1.0) - full_corrected = np.array([ - float(inv_trc_r(display_linear[0])), - float(inv_trc_g(display_linear[1])), - float(inv_trc_b(display_linear[2])), - ]) + full_corrected = np.array( + [ + float(inv_trc_r(display_linear[0])), + float(inv_trc_g(display_linear[1])), + float(inv_trc_b(display_linear[2])), + ] + ) full_corrected = np.clip(full_corrected, 0.0, 1.0) # === Chroma detection === @@ -390,6 +438,7 @@ def correct(r, g, b): # Measurement and comparison # ============================================================================= + def measure_patches(device, display, patches, white_Y, correct_fn=None, label=""): """Measure ColorChecker patches, optionally applying correction.""" norm = 100.0 / white_Y if white_Y > 0 else 1.0 @@ -417,10 +466,10 @@ def measure_patches(device, display, patches, white_Y, correct_fn=None, label="" correction_info = "" if correct_fn is not None: correction_info = f" sent=({r_c:.3f},{g_c:.3f},{b_c:.3f})" - print(f" [{i+1:2d}/24] {name:20s} dE={de:5.2f} [{status}]{correction_info}") + print(f" [{i + 1:2d}/24] {name:20s} dE={de:5.2f} [{status}]{correction_info}") results.append((name, de)) else: - print(f" [{i+1:2d}/24] {name:20s} (no reading)") + print(f" [{i + 1:2d}/24] {name:20s} (no reading)") results.append((name, -1)) valid = [de for _, de in results if de >= 0] @@ -482,6 +531,7 @@ def print_comparison(before, after): # --- Display --- from calibrate_pro.panels.detection import enumerate_displays + displays = enumerate_displays() dx, dy, dw, dh = 0, 0, 3840, 2160 for d in displays: @@ -495,6 +545,7 @@ def print_comparison(before, after): print("\nStep 2: Removing existing LUTs...") try: from calibrate_pro.lut_system.dwm_lut import remove_lut + remove_lut(0) remove_lut(1) time.sleep(1) @@ -523,11 +574,11 @@ def print_comparison(before, after): # Show what correction does to a few test values print("\n Correction examples:") test_colors = [ - ("White", 0.961, 0.961, 0.961), + ("White", 0.961, 0.961, 0.961), ("Mid gray", 0.5, 0.5, 0.5), - ("Red", 0.752, 0.197, 0.178), - ("Green", 0.262, 0.584, 0.291), - ("Blue", 0.139, 0.248, 0.577), + ("Red", 0.752, 0.197, 0.178), + ("Green", 0.262, 0.584, 0.291), + ("Blue", 0.139, 0.248, 0.577), ] for name, r, g, b in test_colors: cr, cg, cb = correct_fn(r, g, b) @@ -543,8 +594,7 @@ def print_comparison(before, after): # Use the original white_Y for normalization (the display's actual white hasn't changed) # But we measure through correction - corrected = measure_patches(device, display, COLORCHECKER, white_Y, - correct_fn=correct_fn, label="Corrected: ") + corrected = measure_patches(device, display, COLORCHECKER, white_Y, correct_fn=correct_fn, label="Corrected: ") # --- Results --- print("\n" + "=" * 70) @@ -571,7 +621,7 @@ def print_comparison(before, after): cr, cg, cb = correct_fn(r, g, b) lut.data[ri, gi, bi] = [cr, cg, cb] if ri % 8 == 0: - print(f" LUT generation: {ri*100//size}%...") + print(f" LUT generation: {ri * 100 // size}%...") lut.title = "Calibrate Pro - PG27UCDM (Native Measured)" lut_dir = os.path.expanduser("~/Documents/Calibrate Pro/Calibrations") diff --git a/scripts/native_colorchecker.py b/scripts/native_colorchecker.py index 4191af5..f957d52 100644 --- a/scripts/native_colorchecker.py +++ b/scripts/native_colorchecker.py @@ -2,6 +2,7 @@ Native ColorChecker measurement using the i1Display3 EEPROM OLED matrix. No ArgyllCMS required. Displays patches via tkinter, measures via USB HID. """ + import os import struct import sys @@ -16,97 +17,132 @@ from calibrate_pro.core.color_math import D50_WHITE, D65_WHITE, bradford_adapt, delta_e_2000, xyz_to_lab # OLED calibration matrix from device EEPROM at offset 0x191C -OLED_MATRIX = np.array([ - [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], - [-0.00004481, 0.00035042, 0.08032401], -]) +OLED_MATRIX = np.array( + [ + [0.03836831, -0.02175997, 0.01696057], + [0.01449629, 0.01611903, 0.00057150], + [-0.00004481, 0.00035042, 0.08032401], + ] +) PATCHES = [ - ("Dark Skin", 0.453, 0.317, 0.264), - ("Light Skin", 0.779, 0.577, 0.505), - ("Blue Sky", 0.355, 0.480, 0.611), - ("Foliage", 0.352, 0.422, 0.253), - ("Blue Flower", 0.508, 0.502, 0.691), + ("Dark Skin", 0.453, 0.317, 0.264), + ("Light Skin", 0.779, 0.577, 0.505), + ("Blue Sky", 0.355, 0.480, 0.611), + ("Foliage", 0.352, 0.422, 0.253), + ("Blue Flower", 0.508, 0.502, 0.691), ("Bluish Green", 0.362, 0.745, 0.675), - ("Orange", 0.879, 0.485, 0.183), - ("Purplish Blue",0.266, 0.358, 0.667), + ("Orange", 0.879, 0.485, 0.183), + ("Purplish Blue", 0.266, 0.358, 0.667), ("Moderate Red", 0.778, 0.321, 0.381), - ("Purple", 0.367, 0.227, 0.414), + ("Purple", 0.367, 0.227, 0.414), ("Yellow Green", 0.623, 0.741, 0.246), - ("Orange Yellow",0.904, 0.634, 0.154), - ("Blue", 0.139, 0.248, 0.577), - ("Green", 0.262, 0.584, 0.291), - ("Red", 0.752, 0.197, 0.178), - ("Yellow", 0.938, 0.857, 0.159), - ("Magenta", 0.752, 0.313, 0.577), - ("Cyan", 0.121, 0.544, 0.659), - ("White", 0.961, 0.961, 0.961), - ("Neutral 8", 0.784, 0.784, 0.784), - ("Neutral 6.5", 0.584, 0.584, 0.584), - ("Neutral 5", 0.420, 0.420, 0.420), - ("Neutral 3.5", 0.258, 0.258, 0.258), - ("Black", 0.085, 0.085, 0.085), + ("Orange Yellow", 0.904, 0.634, 0.154), + ("Blue", 0.139, 0.248, 0.577), + ("Green", 0.262, 0.584, 0.291), + ("Red", 0.752, 0.197, 0.178), + ("Yellow", 0.938, 0.857, 0.159), + ("Magenta", 0.752, 0.313, 0.577), + ("Cyan", 0.121, 0.544, 0.659), + ("White", 0.961, 0.961, 0.961), + ("Neutral 8", 0.784, 0.784, 0.784), + ("Neutral 6.5", 0.584, 0.584, 0.584), + ("Neutral 5", 0.420, 0.420, 0.420), + ("Neutral 3.5", 0.258, 0.258, 0.258), + ("Black", 0.085, 0.085, 0.085), ] REF_LAB = { - "Dark Skin": (37.986, 13.555, 14.059), "Light Skin": (65.711, 18.130, 17.810), - "Blue Sky": (49.927, -4.880, -21.925), "Foliage": (43.139, -13.095, 21.905), - "Blue Flower": (55.112, 8.844, -25.399), "Bluish Green": (70.719, -33.397, -0.199), - "Orange": (62.661, 36.067, 57.096), "Purplish Blue": (40.020, 10.410, -45.964), - "Moderate Red": (51.124, 48.239, 16.248), "Purple": (30.325, 22.976, -21.587), - "Yellow Green": (72.532, -23.709, 57.255), "Orange Yellow": (71.941, 19.363, 67.857), - "Blue": (28.778, 14.179, -50.297), "Green": (55.261, -38.342, 31.370), - "Red": (42.101, 53.378, 28.190), "Yellow": (81.733, 4.039, 79.819), - "Magenta": (51.935, 49.986, -14.574), "Cyan": (51.038, -28.631, -28.638), - "White": (96.539, -0.425, 1.186), "Neutral 8": (81.257, -0.638, -0.335), - "Neutral 6.5": (66.766, -0.734, -0.504), "Neutral 5": (50.867, -0.153, -0.270), - "Neutral 3.5": (35.656, -0.421, -1.231), "Black": (20.461, -0.079, -0.973), + "Dark Skin": (37.986, 13.555, 14.059), + "Light Skin": (65.711, 18.130, 17.810), + "Blue Sky": (49.927, -4.880, -21.925), + "Foliage": (43.139, -13.095, 21.905), + "Blue Flower": (55.112, 8.844, -25.399), + "Bluish Green": (70.719, -33.397, -0.199), + "Orange": (62.661, 36.067, 57.096), + "Purplish Blue": (40.020, 10.410, -45.964), + "Moderate Red": (51.124, 48.239, 16.248), + "Purple": (30.325, 22.976, -21.587), + "Yellow Green": (72.532, -23.709, 57.255), + "Orange Yellow": (71.941, 19.363, 67.857), + "Blue": (28.778, 14.179, -50.297), + "Green": (55.261, -38.342, 31.370), + "Red": (42.101, 53.378, 28.190), + "Yellow": (81.733, 4.039, 79.819), + "Magenta": (51.935, 49.986, -14.574), + "Cyan": (51.038, -28.631, -28.638), + "White": (96.539, -0.425, 1.186), + "Neutral 8": (81.257, -0.638, -0.335), + "Neutral 6.5": (66.766, -0.734, -0.504), + "Neutral 5": (50.867, -0.153, -0.270), + "Neutral 3.5": (35.656, -0.421, -1.231), + "Black": (20.461, -0.079, -0.973), } M = 0xFFFFFFFF def unlock_device(device): - k0, k1 = 0xa9119479, 0x5b168761 - cmd = bytearray(65); cmd[0] = 0; cmd[1] = 0x99 - device.write(cmd); time.sleep(0.2) + k0, k1 = 0xA9119479, 0x5B168761 + cmd = bytearray(65) + cmd[0] = 0 + cmd[1] = 0x99 + device.write(cmd) + time.sleep(0.2) c = bytes(device.read(64, timeout_ms=3000)) sc = bytearray(8) - for i in range(8): sc[i] = c[3] ^ c[35 + i] - ci0 = (sc[3]<<24)+(sc[0]<<16)+(sc[4]<<8)+sc[6] - ci1 = (sc[1]<<24)+(sc[7]<<16)+(sc[2]<<8)+sc[5] + for i in range(8): + sc[i] = c[3] ^ c[35 + i] + ci0 = (sc[3] << 24) + (sc[0] << 16) + (sc[4] << 8) + sc[6] + ci1 = (sc[1] << 24) + (sc[7] << 16) + (sc[2] << 8) + sc[5] nk0, nk1 = (-k0) & M, (-k1) & M - co = [(nk0-ci1)&M, (nk1-ci0)&M, (ci1*nk0)&M, (ci0*nk1)&M] + co = [(nk0 - ci1) & M, (nk1 - ci0) & M, (ci1 * nk0) & M, (ci0 * nk1) & M] s = sum(sc) - for sh in [0, 8, 16, 24]: s += (nk0>>sh)&0xFF; s += (nk1>>sh)&0xFF + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF s0, s1 = s & 0xFF, (s >> 8) & 0xFF sr = bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF; sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF; sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF; sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF; sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF; sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF; sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF; sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF; sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb = bytearray(65); rb[0] = 0; rb[1] = 0x9A - for i in range(16): rb[25+i] = c[2] ^ sr[i] - device.write(rb); time.sleep(0.3); device.read(64, timeout_ms=3000) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) def measure_freq(device, integration=1.0): intclks = int(integration * 12000000) - cmd = bytearray(65); cmd[0] = 0x00; cmd[1] = 0x01 - struct.pack_into(' 0 else 1.0 @@ -180,11 +217,10 @@ def show_color(r, g, b): de = delta_e_2000(lab_meas, lab_ref) status = "PASS" if de < 2.0 else "WARN" if de < 3.0 else "FAIL" - print(f" [{i+1:2d}/24] {name:20s} dE={de:5.2f} [{status}] " - f"Y={xyz_raw[1]:.1f}") + print(f" [{i + 1:2d}/24] {name:20s} dE={de:5.2f} [{status}] Y={xyz_raw[1]:.1f}") results.append((name, float(de))) else: - print(f" [{i+1:2d}/24] {name:20s} (low light / no reading)") + print(f" [{i + 1:2d}/24] {name:20s} (low light / no reading)") results.append((name, -1)) root.destroy() diff --git a/scripts/native_iterative_refine.py b/scripts/native_iterative_refine.py index 16c3082..606fd33 100644 --- a/scripts/native_iterative_refine.py +++ b/scripts/native_iterative_refine.py @@ -8,6 +8,7 @@ Also verifies whether the DWM LUT is actually being applied by checking if the correction changes the measured output. """ + import os import struct import sys @@ -29,97 +30,132 @@ xyz_to_lab, ) -OLED_MATRIX = np.array([ - [0.03836831, -0.02175997, 0.01696057], - [0.01449629, 0.01611903, 0.00057150], - [-0.00004481, 0.00035042, 0.08032401], -]) +OLED_MATRIX = np.array( + [ + [0.03836831, -0.02175997, 0.01696057], + [0.01449629, 0.01611903, 0.00057150], + [-0.00004481, 0.00035042, 0.08032401], + ] +) COLORCHECKER = [ - ("Dark Skin", 0.453, 0.317, 0.264), - ("Light Skin", 0.779, 0.577, 0.505), - ("Blue Sky", 0.355, 0.480, 0.611), - ("Foliage", 0.352, 0.422, 0.253), - ("Blue Flower", 0.508, 0.502, 0.691), + ("Dark Skin", 0.453, 0.317, 0.264), + ("Light Skin", 0.779, 0.577, 0.505), + ("Blue Sky", 0.355, 0.480, 0.611), + ("Foliage", 0.352, 0.422, 0.253), + ("Blue Flower", 0.508, 0.502, 0.691), ("Bluish Green", 0.362, 0.745, 0.675), - ("Orange", 0.879, 0.485, 0.183), - ("Purplish Blue",0.266, 0.358, 0.667), + ("Orange", 0.879, 0.485, 0.183), + ("Purplish Blue", 0.266, 0.358, 0.667), ("Moderate Red", 0.778, 0.321, 0.381), - ("Purple", 0.367, 0.227, 0.414), + ("Purple", 0.367, 0.227, 0.414), ("Yellow Green", 0.623, 0.741, 0.246), - ("Orange Yellow",0.904, 0.634, 0.154), - ("Blue", 0.139, 0.248, 0.577), - ("Green", 0.262, 0.584, 0.291), - ("Red", 0.752, 0.197, 0.178), - ("Yellow", 0.938, 0.857, 0.159), - ("Magenta", 0.752, 0.313, 0.577), - ("Cyan", 0.121, 0.544, 0.659), - ("White", 0.961, 0.961, 0.961), - ("Neutral 8", 0.784, 0.784, 0.784), - ("Neutral 6.5", 0.584, 0.584, 0.584), - ("Neutral 5", 0.420, 0.420, 0.420), - ("Neutral 3.5", 0.258, 0.258, 0.258), - ("Black", 0.085, 0.085, 0.085), + ("Orange Yellow", 0.904, 0.634, 0.154), + ("Blue", 0.139, 0.248, 0.577), + ("Green", 0.262, 0.584, 0.291), + ("Red", 0.752, 0.197, 0.178), + ("Yellow", 0.938, 0.857, 0.159), + ("Magenta", 0.752, 0.313, 0.577), + ("Cyan", 0.121, 0.544, 0.659), + ("White", 0.961, 0.961, 0.961), + ("Neutral 8", 0.784, 0.784, 0.784), + ("Neutral 6.5", 0.584, 0.584, 0.584), + ("Neutral 5", 0.420, 0.420, 0.420), + ("Neutral 3.5", 0.258, 0.258, 0.258), + ("Black", 0.085, 0.085, 0.085), ] REF_LAB = { - "Dark Skin": (37.986, 13.555, 14.059), "Light Skin": (65.711, 18.130, 17.810), - "Blue Sky": (49.927, -4.880, -21.925), "Foliage": (43.139, -13.095, 21.905), - "Blue Flower": (55.112, 8.844, -25.399), "Bluish Green": (70.719, -33.397, -0.199), - "Orange": (62.661, 36.067, 57.096), "Purplish Blue": (40.020, 10.410, -45.964), - "Moderate Red": (51.124, 48.239, 16.248), "Purple": (30.325, 22.976, -21.587), - "Yellow Green": (72.532, -23.709, 57.255), "Orange Yellow": (71.941, 19.363, 67.857), - "Blue": (28.778, 14.179, -50.297), "Green": (55.261, -38.342, 31.370), - "Red": (42.101, 53.378, 28.190), "Yellow": (81.733, 4.039, 79.819), - "Magenta": (51.935, 49.986, -14.574), "Cyan": (51.038, -28.631, -28.638), - "White": (96.539, -0.425, 1.186), "Neutral 8": (81.257, -0.638, -0.335), - "Neutral 6.5": (66.766, -0.734, -0.504), "Neutral 5": (50.867, -0.153, -0.270), - "Neutral 3.5": (35.656, -0.421, -1.231), "Black": (20.461, -0.079, -0.973), + "Dark Skin": (37.986, 13.555, 14.059), + "Light Skin": (65.711, 18.130, 17.810), + "Blue Sky": (49.927, -4.880, -21.925), + "Foliage": (43.139, -13.095, 21.905), + "Blue Flower": (55.112, 8.844, -25.399), + "Bluish Green": (70.719, -33.397, -0.199), + "Orange": (62.661, 36.067, 57.096), + "Purplish Blue": (40.020, 10.410, -45.964), + "Moderate Red": (51.124, 48.239, 16.248), + "Purple": (30.325, 22.976, -21.587), + "Yellow Green": (72.532, -23.709, 57.255), + "Orange Yellow": (71.941, 19.363, 67.857), + "Blue": (28.778, 14.179, -50.297), + "Green": (55.261, -38.342, 31.370), + "Red": (42.101, 53.378, 28.190), + "Yellow": (81.733, 4.039, 79.819), + "Magenta": (51.935, 49.986, -14.574), + "Cyan": (51.038, -28.631, -28.638), + "White": (96.539, -0.425, 1.186), + "Neutral 8": (81.257, -0.638, -0.335), + "Neutral 6.5": (66.766, -0.734, -0.504), + "Neutral 5": (50.867, -0.153, -0.270), + "Neutral 3.5": (35.656, -0.421, -1.231), + "Black": (20.461, -0.079, -0.973), } M_MASK = 0xFFFFFFFF def unlock_device(device): - k0, k1 = 0xa9119479, 0x5b168761 - cmd = bytearray(65); cmd[0] = 0; cmd[1] = 0x99 - device.write(cmd); time.sleep(0.2) + k0, k1 = 0xA9119479, 0x5B168761 + cmd = bytearray(65) + cmd[0] = 0 + cmd[1] = 0x99 + device.write(cmd) + time.sleep(0.2) c = bytes(device.read(64, timeout_ms=3000)) sc = bytearray(8) - for i in range(8): sc[i] = c[3] ^ c[35 + i] - ci0 = (sc[3]<<24)+(sc[0]<<16)+(sc[4]<<8)+sc[6] - ci1 = (sc[1]<<24)+(sc[7]<<16)+(sc[2]<<8)+sc[5] + for i in range(8): + sc[i] = c[3] ^ c[35 + i] + ci0 = (sc[3] << 24) + (sc[0] << 16) + (sc[4] << 8) + sc[6] + ci1 = (sc[1] << 24) + (sc[7] << 16) + (sc[2] << 8) + sc[5] nk0, nk1 = (-k0) & M_MASK, (-k1) & M_MASK - co = [(nk0-ci1)&M_MASK, (nk1-ci0)&M_MASK, (ci1*nk0)&M_MASK, (ci0*nk1)&M_MASK] + co = [(nk0 - ci1) & M_MASK, (nk1 - ci0) & M_MASK, (ci1 * nk0) & M_MASK, (ci0 * nk1) & M_MASK] s = sum(sc) - for sh in [0, 8, 16, 24]: s += (nk0>>sh)&0xFF; s += (nk1>>sh)&0xFF + for sh in [0, 8, 16, 24]: + s += (nk0 >> sh) & 0xFF + s += (nk1 >> sh) & 0xFF s0, s1 = s & 0xFF, (s >> 8) & 0xFF sr = bytearray(16) - sr[0]=(((co[0]>>16)&0xFF)+s0)&0xFF; sr[1]=(((co[2]>>8)&0xFF)-s1)&0xFF - sr[2]=((co[3]&0xFF)+s1)&0xFF; sr[3]=(((co[1]>>16)&0xFF)+s0)&0xFF - sr[4]=(((co[2]>>16)&0xFF)-s1)&0xFF; sr[5]=(((co[3]>>16)&0xFF)-s0)&0xFF - sr[6]=(((co[1]>>24)&0xFF)-s0)&0xFF; sr[7]=((co[0]&0xFF)-s1)&0xFF - sr[8]=(((co[3]>>8)&0xFF)+s0)&0xFF; sr[9]=(((co[2]>>24)&0xFF)-s1)&0xFF - sr[10]=(((co[0]>>8)&0xFF)+s0)&0xFF; sr[11]=(((co[1]>>8)&0xFF)-s1)&0xFF - sr[12]=((co[1]&0xFF)+s1)&0xFF; sr[13]=(((co[3]>>24)&0xFF)+s1)&0xFF - sr[14]=((co[2]&0xFF)+s0)&0xFF; sr[15]=(((co[0]>>24)&0xFF)-s0)&0xFF - rb = bytearray(65); rb[0] = 0; rb[1] = 0x9A - for i in range(16): rb[25+i] = c[2] ^ sr[i] - device.write(rb); time.sleep(0.3); device.read(64, timeout_ms=3000) + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF + rb = bytearray(65) + rb[0] = 0 + rb[1] = 0x9A + for i in range(16): + rb[25 + i] = c[2] ^ sr[i] + device.write(rb) + time.sleep(0.3) + device.read(64, timeout_ms=3000) def measure_freq(device, integration=1.0): intclks = int(integration * 12000000) - cmd = bytearray(65); cmd[0] = 0x00; cmd[1] = 0x01 - struct.pack_into(' 0.001, - xyz_target / xyz_meas_norm, - np.array([1.0, 1.0, 1.0])) - - residuals.append({ - "name": name, - "srgb": (r, g, b), - "measured_xyz": xyz_meas_norm, - "target_xyz": xyz_target, - "ratio": ratio, - "de": de, - }) + ratio = np.where(xyz_meas_norm > 0.001, xyz_target / xyz_meas_norm, np.array([1.0, 1.0, 1.0])) + + residuals.append( + { + "name": name, + "srgb": (r, g, b), + "measured_xyz": xyz_meas_norm, + "target_xyz": xyz_target, + "ratio": ratio, + "de": de, + } + ) if residuals: # Compute average residual correction (simple approach) @@ -324,7 +361,7 @@ def measure_colorchecker(device, display, white_Y): print(f" Z: {avg_ratio[2]:.4f}") # If ratios are close to 1.0, there's not much to gain from refinement - max_dev = max(abs(avg_ratio[0]-1), abs(avg_ratio[1]-1), abs(avg_ratio[2]-1)) + max_dev = max(abs(avg_ratio[0] - 1), abs(avg_ratio[1] - 1), abs(avg_ratio[2] - 1)) print(f" Max deviation from 1.0: {max_dev:.4f}") if max_dev > 0.05: diff --git a/scripts/unlock_measure.py b/scripts/unlock_measure.py index 29b3dee..a671bf1 100644 --- a/scripts/unlock_measure.py +++ b/scripts/unlock_measure.py @@ -1,4 +1,5 @@ """Unlock i1Display3 and take a measurement.""" + import struct import sys import time @@ -12,19 +13,20 @@ M = 0xFFFFFFFF KEY_TABLE = [ - (0xe9622e9f, 0x8d63e133, "retail i1Display3"), - (0xa9119479, 0x5b168761, "NEC SpectraSensor"), - (0xcaa62b2c, 0x30815b61, "OEM generic"), - (0xe01e6e0a, 0x257462de, "ColorMunki Display"), - (0x160eb6ae, 0x14440e70, "Quato Silver Haze"), - (0x291e41d7, 0x51937bdd, "HP DreamColor"), - (0x1abfae03, 0xf25ac8e8, "Wacom DC"), - (0x828c43e9, 0xcbb8a8ed, "Toshiba TPA-1"), - (0xe8d1a980, 0xd146f7ad, "Barco"), - (0x171ae295, 0x2e5c7664, "PhotoCrysta"), - (0x64d8c546, 0x4b24b4a7, "ViewSonic CS-XRi1"), + (0xE9622E9F, 0x8D63E133, "retail i1Display3"), + (0xA9119479, 0x5B168761, "NEC SpectraSensor"), + (0xCAA62B2C, 0x30815B61, "OEM generic"), + (0xE01E6E0A, 0x257462DE, "ColorMunki Display"), + (0x160EB6AE, 0x14440E70, "Quato Silver Haze"), + (0x291E41D7, 0x51937BDD, "HP DreamColor"), + (0x1ABFAE03, 0xF25AC8E8, "Wacom DC"), + (0x828C43E9, 0xCBB8A8ED, "Toshiba TPA-1"), + (0xE8D1A980, 0xD146F7AD, "Barco"), + (0x171AE295, 0x2E5C7664, "PhotoCrysta"), + (0x64D8C546, 0x4B24B4A7, "ViewSonic CS-XRi1"), ] + def unlock_attempt(k0, k1, name): # Request challenge cmd = bytearray(65) @@ -56,14 +58,22 @@ def unlock_attempt(k0, k1, name): s0, s1 = s & 0xFF, (s >> 8) & 0xFF sr = bytearray(16) - sr[0] = (((co[0]>>16)&0xFF)+s0)&0xFF; sr[1] = (((co[2]>>8)&0xFF)-s1)&0xFF - sr[2] = ((co[3]&0xFF)+s1)&0xFF; sr[3] = (((co[1]>>16)&0xFF)+s0)&0xFF - sr[4] = (((co[2]>>16)&0xFF)-s1)&0xFF; sr[5] = (((co[3]>>16)&0xFF)-s0)&0xFF - sr[6] = (((co[1]>>24)&0xFF)-s0)&0xFF; sr[7] = ((co[0]&0xFF)-s1)&0xFF - sr[8] = (((co[3]>>8)&0xFF)+s0)&0xFF; sr[9] = (((co[2]>>24)&0xFF)-s1)&0xFF - sr[10] = (((co[0]>>8)&0xFF)+s0)&0xFF; sr[11] = (((co[1]>>8)&0xFF)-s1)&0xFF - sr[12] = ((co[1]&0xFF)+s1)&0xFF; sr[13] = (((co[3]>>24)&0xFF)+s1)&0xFF - sr[14] = ((co[2]&0xFF)+s0)&0xFF; sr[15] = (((co[0]>>24)&0xFF)-s0)&0xFF + sr[0] = (((co[0] >> 16) & 0xFF) + s0) & 0xFF + sr[1] = (((co[2] >> 8) & 0xFF) - s1) & 0xFF + sr[2] = ((co[3] & 0xFF) + s1) & 0xFF + sr[3] = (((co[1] >> 16) & 0xFF) + s0) & 0xFF + sr[4] = (((co[2] >> 16) & 0xFF) - s1) & 0xFF + sr[5] = (((co[3] >> 16) & 0xFF) - s0) & 0xFF + sr[6] = (((co[1] >> 24) & 0xFF) - s0) & 0xFF + sr[7] = ((co[0] & 0xFF) - s1) & 0xFF + sr[8] = (((co[3] >> 8) & 0xFF) + s0) & 0xFF + sr[9] = (((co[2] >> 24) & 0xFF) - s1) & 0xFF + sr[10] = (((co[0] >> 8) & 0xFF) + s0) & 0xFF + sr[11] = (((co[1] >> 8) & 0xFF) - s1) & 0xFF + sr[12] = ((co[1] & 0xFF) + s1) & 0xFF + sr[13] = (((co[3] >> 24) & 0xFF) + s1) & 0xFF + sr[14] = ((co[2] & 0xFF) + s0) & 0xFF + sr[15] = (((co[0] >> 24) & 0xFF) - s0) & 0xFF resp_buf = bytearray(65) resp_buf[0] = 0x00 @@ -84,6 +94,7 @@ def unlock_attempt(k0, k1, name): print(" (rejected)") return False + print("\nTrying all unlock keys...") unlocked = False for k0, k1, name in KEY_TABLE: diff --git a/scripts/vcgt_measure.py b/scripts/vcgt_measure.py index c8a66aa..0cab106 100644 --- a/scripts/vcgt_measure.py +++ b/scripts/vcgt_measure.py @@ -1,4 +1,5 @@ """VCGT gamma ramp calibration + measured verification.""" + import os import subprocess import sys @@ -12,54 +13,80 @@ from calibrate_pro.core.color_math import D50_WHITE, D65_WHITE, bradford_adapt, delta_e_2000, xyz_to_lab from calibrate_pro.panels.detection import enumerate_displays, reset_gamma_ramp, set_gamma_ramp -DISPREAD = os.path.join( - os.path.expandvars("%APPDATA%"), "DisplayCAL", "dl", "Argyll_V2.3.1", "bin", "dispread.exe" -) +DISPREAD = os.path.join(os.path.expandvars("%APPDATA%"), "DisplayCAL", "dl", "Argyll_V2.3.1", "bin", "dispread.exe") PATCHES = [ - ("Dark Skin", 0.453, 0.317, 0.264), ("Light Skin", 0.779, 0.577, 0.505), - ("Blue Sky", 0.355, 0.480, 0.611), ("Foliage", 0.352, 0.422, 0.253), - ("Blue Flower", 0.508, 0.502, 0.691), ("Bluish Green", 0.362, 0.745, 0.675), - ("Orange", 0.879, 0.485, 0.183), ("Purplish Blue", 0.266, 0.358, 0.667), - ("Moderate Red", 0.778, 0.321, 0.381), ("Purple", 0.367, 0.227, 0.414), - ("Yellow Green", 0.623, 0.741, 0.246), ("Orange Yellow", 0.904, 0.634, 0.154), - ("Blue", 0.139, 0.248, 0.577), ("Green", 0.262, 0.584, 0.291), - ("Red", 0.752, 0.197, 0.178), ("Yellow", 0.938, 0.857, 0.159), - ("Magenta", 0.752, 0.313, 0.577), ("Cyan", 0.121, 0.544, 0.659), - ("White", 0.961, 0.961, 0.961), ("Neutral 8", 0.784, 0.784, 0.784), - ("Neutral 6.5", 0.584, 0.584, 0.584), ("Neutral 5", 0.420, 0.420, 0.420), - ("Neutral 3.5", 0.258, 0.258, 0.258), ("Black", 0.085, 0.085, 0.085), + ("Dark Skin", 0.453, 0.317, 0.264), + ("Light Skin", 0.779, 0.577, 0.505), + ("Blue Sky", 0.355, 0.480, 0.611), + ("Foliage", 0.352, 0.422, 0.253), + ("Blue Flower", 0.508, 0.502, 0.691), + ("Bluish Green", 0.362, 0.745, 0.675), + ("Orange", 0.879, 0.485, 0.183), + ("Purplish Blue", 0.266, 0.358, 0.667), + ("Moderate Red", 0.778, 0.321, 0.381), + ("Purple", 0.367, 0.227, 0.414), + ("Yellow Green", 0.623, 0.741, 0.246), + ("Orange Yellow", 0.904, 0.634, 0.154), + ("Blue", 0.139, 0.248, 0.577), + ("Green", 0.262, 0.584, 0.291), + ("Red", 0.752, 0.197, 0.178), + ("Yellow", 0.938, 0.857, 0.159), + ("Magenta", 0.752, 0.313, 0.577), + ("Cyan", 0.121, 0.544, 0.659), + ("White", 0.961, 0.961, 0.961), + ("Neutral 8", 0.784, 0.784, 0.784), + ("Neutral 6.5", 0.584, 0.584, 0.584), + ("Neutral 5", 0.420, 0.420, 0.420), + ("Neutral 3.5", 0.258, 0.258, 0.258), + ("Black", 0.085, 0.085, 0.085), ] REF_LAB = { - "Dark Skin": (37.986, 13.555, 14.059), "Light Skin": (65.711, 18.130, 17.810), - "Blue Sky": (49.927, -4.880, -21.925), "Foliage": (43.139, -13.095, 21.905), - "Blue Flower": (55.112, 8.844, -25.399), "Bluish Green": (70.719, -33.397, -0.199), - "Orange": (62.661, 36.067, 57.096), "Purplish Blue": (40.020, 10.410, -45.964), - "Moderate Red": (51.124, 48.239, 16.248), "Purple": (30.325, 22.976, -21.587), - "Yellow Green": (72.532, -23.709, 57.255), "Orange Yellow": (71.941, 19.363, 67.857), - "Blue": (28.778, 14.179, -50.297), "Green": (55.261, -38.342, 31.370), - "Red": (42.101, 53.378, 28.190), "Yellow": (81.733, 4.039, 79.819), - "Magenta": (51.935, 49.986, -14.574), "Cyan": (51.038, -28.631, -28.638), - "White": (96.539, -0.425, 1.186), "Neutral 8": (81.257, -0.638, -0.335), - "Neutral 6.5": (66.766, -0.734, -0.504), "Neutral 5": (50.867, -0.153, -0.270), - "Neutral 3.5": (35.656, -0.421, -1.231), "Black": (20.461, -0.079, -0.973), + "Dark Skin": (37.986, 13.555, 14.059), + "Light Skin": (65.711, 18.130, 17.810), + "Blue Sky": (49.927, -4.880, -21.925), + "Foliage": (43.139, -13.095, 21.905), + "Blue Flower": (55.112, 8.844, -25.399), + "Bluish Green": (70.719, -33.397, -0.199), + "Orange": (62.661, 36.067, 57.096), + "Purplish Blue": (40.020, 10.410, -45.964), + "Moderate Red": (51.124, 48.239, 16.248), + "Purple": (30.325, 22.976, -21.587), + "Yellow Green": (72.532, -23.709, 57.255), + "Orange Yellow": (71.941, 19.363, 67.857), + "Blue": (28.778, 14.179, -50.297), + "Green": (55.261, -38.342, 31.370), + "Red": (42.101, 53.378, 28.190), + "Yellow": (81.733, 4.039, 79.819), + "Magenta": (51.935, 49.986, -14.574), + "Cyan": (51.038, -28.631, -28.638), + "White": (96.539, -0.425, 1.186), + "Neutral 8": (81.257, -0.638, -0.335), + "Neutral 6.5": (66.766, -0.734, -0.504), + "Neutral 5": (50.867, -0.153, -0.270), + "Neutral 3.5": (35.656, -0.421, -1.231), + "Black": (20.461, -0.079, -0.973), } + def measure(): ti1 = 'CTI1\nDESCRIPTOR "M"\nORIGINATOR "CP"\nCOLOR_REP "RGB"\n' ti1 += "NUMBER_OF_FIELDS 4\nBEGIN_DATA_FORMAT\nSAMPLE_ID RGB_R RGB_G RGB_B\nEND_DATA_FORMAT\n" ti1 += f"NUMBER_OF_SETS {len(PATCHES)}\nBEGIN_DATA\n" for i, (_n, r, g, b) in enumerate(PATCHES, 1): - ti1 += f"{i} {r*100:.2f} {g*100:.2f} {b*100:.2f}\n" + ti1 += f"{i} {r * 100:.2f} {g * 100:.2f} {b * 100:.2f}\n" ti1 += "END_DATA\n" with tempfile.TemporaryDirectory() as td: base = os.path.join(td, "m") with open(base + ".ti1", "w") as f: f.write(ti1) proc = subprocess.Popen( - [DISPREAD, "-d", "1", "-c", "1", "-y", "o", - "-Y", "p", "-N", "-F", "-P", "0.5,0.5,0.4", base], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=td) + [DISPREAD, "-d", "1", "-c", "1", "-y", "o", "-Y", "p", "-N", "-F", "-P", "0.5,0.5,0.4", base], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=td, + ) stdout, _ = proc.communicate(timeout=180) ti3 = base + ".ti3" if not os.path.exists(ti3): @@ -68,7 +95,8 @@ def measure(): in_data = False for line in open(ti3).read().split("\n"): if "BEGIN_DATA" in line and "FORMAT" not in line: - in_data = True; continue + in_data = True + continue if "END_DATA" in line: in_data = False if in_data and line.strip(): @@ -77,11 +105,18 @@ def measure(): results.append((float(p[4]), float(p[5]), float(p[6]))) return results if len(results) == len(PATCHES) else None + def compute_de(meas): - return [float(delta_e_2000( - xyz_to_lab(bradford_adapt(np.array([X,Y,Z])/100.0, D65_WHITE, D50_WHITE), D50_WHITE), - np.array(REF_LAB[n]))) - for (n,r,g,b), (X,Y,Z) in zip(PATCHES, meas)] + return [ + float( + delta_e_2000( + xyz_to_lab(bradford_adapt(np.array([X, Y, Z]) / 100.0, D65_WHITE, D50_WHITE), D50_WHITE), + np.array(REF_LAB[n]), + ) + ) + for (n, r, g, b), (X, Y, Z) in zip(PATCHES, meas) + ] + print("=== VCGT GAMMA RAMP CALIBRATION ===") print("Display: ASUS PG27UCDM (QD-OLED)") @@ -91,6 +126,7 @@ def compute_de(meas): # Remove DWM LUT try: from calibrate_pro.lut_system.dwm_lut import remove_lut + remove_lut(1) except Exception: pass # DWM LUT removal is optional; continue without it @@ -104,20 +140,21 @@ def compute_de(meas): print("Step 1: Measuring uncalibrated...") uncal = measure() if not uncal: - print("FAILED"); sys.exit(1) + print("FAILED") + sys.exit(1) uncal_de = compute_de(uncal) print(f" avg dE {np.mean(uncal_de):.2f}") # White point from measurement wXYZ = np.array(uncal[18]) # White patch wsum = sum(wXYZ) -wx, wy = wXYZ[0]/wsum, wXYZ[1]/wsum +wx, wy = wXYZ[0] / wsum, wXYZ[1] / wsum print(f" White: x={wx:.4f} y={wy:.4f} (target: 0.3127, 0.3290)") # Compute per-channel correction to shift white to D65 # Target white in XYZ (Y=1 normalized) -target_white = np.array([0.3127/0.3290, 1.0, (1-0.3127-0.3290)/0.3290]) -measured_white = np.array([wx/wy, 1.0, (1-wx-wy)/wy]) +target_white = np.array([0.3127 / 0.3290, 1.0, (1 - 0.3127 - 0.3290) / 0.3290]) +measured_white = np.array([wx / wy, 1.0, (1 - wx - wy) / wy]) # Simple per-channel gain: ratio of target to measured white XYZ # converted through sRGB matrix @@ -132,7 +169,9 @@ def compute_de(meas): # Normalize so max = 1.0 mx = max(r_gain, g_gain, b_gain) -r_gain /= mx; g_gain /= mx; b_gain /= mx +r_gain /= mx +g_gain /= mx +b_gain /= mx print(f" WP correction: R={r_gain:.4f} G={g_gain:.4f} B={b_gain:.4f}") # Build VCGT with white point correction only @@ -154,14 +193,15 @@ def compute_de(meas): print("Step 3: Re-measuring with VCGT correction...") cal = measure() if not cal: - print("FAILED"); sys.exit(1) + print("FAILED") + sys.exit(1) cal_de = compute_de(cal) # Results print() print(f"{'Patch':20s} {'Before':>6s} {'After':>6s} {'Change':>7s} Status") print("=" * 65) -for i, (n,_r,_g,_b) in enumerate(PATCHES): +for i, (n, _r, _g, _b) in enumerate(PATCHES): bd, ad = uncal_de[i], cal_de[i] ch = ad - bd st = "PASS" if ad < 2.0 else "WARN" if ad < 3.0 else "FAIL" @@ -171,11 +211,11 @@ def compute_de(meas): ab, aa = np.mean(uncal_de), np.mean(cal_de) print(f" Before: avg dE {ab:.2f} max {np.max(uncal_de):.2f}") print(f" After: avg dE {aa:.2f} max {np.max(cal_de):.2f}") -pct = (1-aa/ab)*100 if ab > 0 else 0 -print(f" Change: {ab-aa:+.2f} dE ({pct:+.0f}%)") +pct = (1 - aa / ab) * 100 if ab > 0 else 0 +print(f" Change: {ab - aa:+.2f} dE ({pct:+.0f}%)") # New white point wXYZ2 = np.array(cal[18]) ws2 = sum(wXYZ2) -print(f"\n New white: x={wXYZ2[0]/ws2:.4f} y={wXYZ2[1]/ws2:.4f}") +print(f"\n New white: x={wXYZ2[0] / ws2:.4f} y={wXYZ2[1] / ws2:.4f}") print(" ALL VALUES MEASURED.") diff --git a/tests/test_auto_calibration.py b/tests/test_auto_calibration.py index 327bc01..9534aab 100644 --- a/tests/test_auto_calibration.py +++ b/tests/test_auto_calibration.py @@ -17,6 +17,7 @@ # AutoCalibrationEngine can be instantiated # ------------------------------------------------------------------------- + def test_engine_instantiation(): """AutoCalibrationEngine should instantiate without errors.""" engine = AutoCalibrationEngine() @@ -37,11 +38,16 @@ def test_engine_has_progress_callback(): # _extract_edid_chromaticity with synthetic EDID bytes # ------------------------------------------------------------------------- + def _build_synthetic_edid( - red_x=0.640, red_y=0.330, - green_x=0.300, green_y=0.600, - blue_x=0.150, blue_y=0.060, - white_x=0.3127, white_y=0.3290, + red_x=0.640, + red_y=0.330, + green_x=0.300, + green_y=0.600, + blue_x=0.150, + blue_y=0.060, + white_x=0.3127, + white_y=0.3290, ): """Build synthetic EDID bytes (at least 35 bytes) with chromaticity data. @@ -49,6 +55,7 @@ def _build_synthetic_edid( Bytes 25-26: packed low 2 bits for R, G, B, W (x and y) Bytes 27-34: high 8 bits for Rx, Ry, Gx, Gy, Bx, By, Wx, Wy """ + def encode_10bit(val): """Encode a chromaticity value [0, 1] as 10-bit integer.""" return int(round(val * 1024)) & 0x3FF @@ -64,32 +71,26 @@ def encode_10bit(val): # Low bits packed into bytes 25-26 # Byte 25: RxL(7:6) RyL(5:4) GxL(3:2) GyL(1:0) - byte25 = ( - ((rx & 0x03) << 6) | - ((ry & 0x03) << 4) | - ((gx & 0x03) << 2) | - (gy & 0x03) - ) + byte25 = ((rx & 0x03) << 6) | ((ry & 0x03) << 4) | ((gx & 0x03) << 2) | (gy & 0x03) # Byte 26: BxL(7:6) ByL(5:4) WxL(3:2) WyL(1:0) - byte26 = ( - ((bx & 0x03) << 6) | - ((by & 0x03) << 4) | - ((wx & 0x03) << 2) | - (wy & 0x03) - ) + byte26 = ((bx & 0x03) << 6) | ((by & 0x03) << 4) | ((wx & 0x03) << 2) | (wy & 0x03) # High bytes (upper 8 bits of each 10-bit value) high_bytes = [ - rx >> 2, ry >> 2, - gx >> 2, gy >> 2, - bx >> 2, by >> 2, - wx >> 2, wy >> 2, + rx >> 2, + ry >> 2, + gx >> 2, + gy >> 2, + bx >> 2, + by >> 2, + wx >> 2, + wy >> 2, ] # Build EDID: 25 padding bytes, then our chromaticity data edid = bytearray(25) # bytes 0-24: padding - edid.append(byte25) # byte 25 - edid.append(byte26) # byte 26 + edid.append(byte25) # byte 25 + edid.append(byte26) # byte 26 edid.extend(high_bytes) # bytes 27-34 return bytes(edid) @@ -113,9 +114,12 @@ def test_extract_edid_chromaticity_srgb(): def test_extract_edid_chromaticity_wide_gamut(): """Extracting QD-OLED-like chromaticity from synthetic EDID.""" edid = _build_synthetic_edid( - red_x=0.680, red_y=0.310, - green_x=0.233, green_y=0.711, - blue_x=0.138, blue_y=0.050, + red_x=0.680, + red_y=0.310, + green_x=0.233, + green_y=0.711, + blue_x=0.138, + blue_y=0.050, ) result = AutoCalibrationEngine._extract_edid_chromaticity(edid) assert result is not None @@ -133,6 +137,7 @@ def test_extract_edid_too_short(): # _match_panel finds known panels # ------------------------------------------------------------------------- + def test_match_panel_pg27ucdm(): """_match_panel should find PG27UCDM from display info.""" engine = AutoCalibrationEngine() @@ -157,6 +162,7 @@ def test_match_panel_fallback(): # run_calibration with apply_ddc=False, apply_lut=False # ------------------------------------------------------------------------- + def test_run_calibration_software_only(): """run_calibration with apply_ddc=False, apply_lut=False should produce a result with ICC and LUT file paths.""" @@ -202,6 +208,7 @@ def test_run_calibration_produces_verification(): # CalibrationTarget defaults # ------------------------------------------------------------------------- + def test_calibration_target_defaults(): """CalibrationTarget should have sensible defaults.""" target = CalibrationTarget() @@ -232,6 +239,7 @@ def test_calibration_target_custom(): # UserConsent logic # ------------------------------------------------------------------------- + def test_user_consent_not_approved_by_default(): """UserConsent should not be approved by default.""" consent = UserConsent() @@ -280,6 +288,7 @@ def test_request_consent_ddc(): # AutoCalibrationResult defaults # ------------------------------------------------------------------------- + def test_result_defaults(): """AutoCalibrationResult should have sensible defaults.""" result = AutoCalibrationResult() diff --git a/tests/test_color_math.py b/tests/test_color_math.py index 15dc391..cd8aa72 100644 --- a/tests/test_color_math.py +++ b/tests/test_color_math.py @@ -128,6 +128,7 @@ def test_jzczhz_roundtrip(Y_nits): # ICtCp roundtrip # ------------------------------------------------------------------------- + def test_ictcp_roundtrip(): """Absolute XYZ -> ICtCp -> absolute XYZ roundtrip.""" xyz = np.array([95.047, 100.0, 108.883]) # D65 at 100 cd/m2 @@ -174,6 +175,7 @@ def test_pq_peak(): # HLG encode/decode roundtrip # ------------------------------------------------------------------------- + @pytest.mark.parametrize("v", [0.0, 0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 1.0]) def test_hlg_roundtrip(v): """HLG OETF -> EOTF roundtrip.""" @@ -187,6 +189,7 @@ def test_hlg_roundtrip(v): # ACEScg / XYZ roundtrip # ------------------------------------------------------------------------- + def test_acescg_xyz_roundtrip(): """ACEScg -> XYZ -> ACEScg roundtrip.""" rgb = np.array([0.5, 0.3, 0.7]) @@ -199,6 +202,7 @@ def test_acescg_xyz_roundtrip(): # ACEScc encode/decode roundtrip # ------------------------------------------------------------------------- + @pytest.mark.parametrize("v", [0.001, 0.01, 0.1, 0.5, 1.0, 10.0]) def test_acescc_roundtrip(v): """ACEScc encode -> decode roundtrip.""" @@ -212,6 +216,7 @@ def test_acescc_roundtrip(v): # ACEScct encode/decode roundtrip # ------------------------------------------------------------------------- + @pytest.mark.parametrize("v", [0.001, 0.005, 0.01, 0.1, 0.5, 1.0, 10.0]) def test_acescct_roundtrip(v): """ACEScct encode -> decode roundtrip.""" @@ -225,6 +230,7 @@ def test_acescct_roundtrip(v): # Display P3 roundtrip # ------------------------------------------------------------------------- + def test_display_p3_roundtrip(): """Display P3 -> XYZ -> Display P3 roundtrip.""" rgb = np.array([0.6, 0.4, 0.2]) @@ -237,6 +243,7 @@ def test_display_p3_roundtrip(): # Rec.2020 roundtrip (OETF/EOTF) # ------------------------------------------------------------------------- + @pytest.mark.parametrize("v", [0.0, 0.01, 0.1, 0.5, 0.9, 1.0]) def test_rec2020_oetf_eotf_roundtrip(v): """Rec.2020 OETF -> EOTF roundtrip.""" @@ -258,6 +265,7 @@ def test_rec2020_xyz_roundtrip(): # CIE Luv roundtrip # ------------------------------------------------------------------------- + def test_luv_roundtrip(sample_xyz): """XYZ -> Luv -> XYZ roundtrip.""" xyz = sample_xyz["white_d65"] @@ -317,6 +325,7 @@ def test_hwb_roundtrip(name, rgb): # CAM16 roundtrip # ------------------------------------------------------------------------- + def test_cam16_white_has_high_J_low_C(): """D65 white should have J near 100 and C near 0.""" env = cam16_environment() @@ -339,6 +348,7 @@ def test_cam16_roundtrip(): # CAM16-UCS Delta E # ------------------------------------------------------------------------- + def test_cam16_ucs_delta_e_identical(): """Identical colors should have Delta E = 0.""" env = cam16_environment() @@ -364,6 +374,7 @@ def test_cam16_ucs_delta_e_different(): # Bradford chromatic adaptation # ------------------------------------------------------------------------- + def test_bradford_d65_d50_d65_roundtrip(): """D65 -> D50 -> D65 roundtrip should recover original XYZ.""" xyz = np.array([0.5, 0.4, 0.3]) @@ -383,6 +394,7 @@ def test_bradford_same_illuminant(): # CIEDE2000 # ------------------------------------------------------------------------- + def test_ciede2000_identical(): """Identical Lab colors should have Delta E = 0.""" lab = np.array([50.0, 20.0, -10.0]) @@ -403,6 +415,7 @@ def test_ciede2000_known_pair(): # Gamut boundary computation # ------------------------------------------------------------------------- + def test_gamut_boundary_mid_lightness(): """Max chroma at L=50 should be > 0 for sRGB.""" boundary = compute_gamut_boundary(XYZ_TO_SRGB, lightness_steps=11, hue_steps=36) @@ -422,6 +435,7 @@ def test_gamut_boundary_black(): # Gamut mapping # ------------------------------------------------------------------------- + def test_gamut_map_reduces_chroma(): """An out-of-gamut color should have reduced chroma after mapping.""" boundary = compute_gamut_boundary(XYZ_TO_SRGB, lightness_steps=11, hue_steps=36) @@ -429,8 +443,9 @@ def test_gamut_map_reduces_chroma(): lab_oog = np.array([50.0, 100.0, 50.0]) mapped = gamut_map_chroma_compress(lab_oog, boundary, method="clip") import math - original_c = math.sqrt(lab_oog[1]**2 + lab_oog[2]**2) - mapped_c = math.sqrt(mapped[1]**2 + mapped[2]**2) + + original_c = math.sqrt(lab_oog[1] ** 2 + lab_oog[2] ** 2) + mapped_c = math.sqrt(mapped[1] ** 2 + mapped[2] ** 2) assert mapped_c <= original_c diff --git a/tests/test_hdr_workflow.py b/tests/test_hdr_workflow.py index 519d703..863a50d 100644 --- a/tests/test_hdr_workflow.py +++ b/tests/test_hdr_workflow.py @@ -28,6 +28,7 @@ # Fixtures # ========================================================================= + @pytest.fixture def hdr10_target(): return HDRTarget.hdr10_1000() @@ -57,6 +58,7 @@ def hlg_wf(hlg_target): # HDRTarget presets # ========================================================================= + class TestHDRTarget: def test_hdr10_1000_preset(self): t = HDRTarget.hdr10_1000() @@ -79,6 +81,7 @@ def test_hlg_1000_preset(self): # EOTF Patch Generation # ========================================================================= + class TestGenerateEOTFPatches: def test_hdr10_patch_shape(self, hdr10_wf): patches = hdr10_wf.generate_eotf_patches(steps=21) @@ -138,6 +141,7 @@ def test_invalid_steps_raises(self, hdr10_wf): # EOTF Verification # ========================================================================= + class TestVerifyEOTF: def test_perfect_match_zero_error(self, hdr10_wf): patches = hdr10_wf.generate_eotf_patches(steps=21) @@ -167,6 +171,7 @@ def test_shape_mismatch_raises(self, hdr10_wf): # Tone Map Generation # ========================================================================= + class TestGenerateToneMap: def test_shape(self, hdr10_wf): tm = hdr10_wf.generate_tone_map(steps=256) @@ -190,7 +195,9 @@ def test_passthrough_below_target(self, hdr10_wf): # The bottom 10% of PQ range should be near-identity low = tm[:100] np.testing.assert_allclose( - low[:, 0], low[:, 1], atol=0.05, + low[:, 0], + low[:, 1], + atol=0.05, ) def test_output_compressed_above_target(self, hdr10_wf): @@ -208,6 +215,7 @@ def test_invalid_steps_raises(self, hdr10_wf): # 3-D LUT Generation # ========================================================================= + class TestGenerateHDRLUT: def test_shape(self, hdr10_wf): lut = hdr10_wf.generate_hdr_lut(size=5) @@ -240,6 +248,7 @@ def test_invalid_size_raises(self, hdr10_wf): # HDR Metadata # ========================================================================= + class TestGenerateHDRMetadata: def test_hdr10_metadata_fields(self, hdr10_wf): meta = hdr10_wf.generate_hdr_metadata() @@ -282,12 +291,11 @@ def test_hdr10_600_cll_matches_peak(self, hdr10_600_target): # .cube Export # ========================================================================= + class TestExportCube: def test_creates_file(self, hdr10_wf): lut = hdr10_wf.generate_hdr_lut(size=5) - with tempfile.NamedTemporaryFile( - suffix=".cube", delete=False, mode="w" - ) as tmp: + with tempfile.NamedTemporaryFile(suffix=".cube", delete=False, mode="w") as tmp: path = tmp.name try: hdr10_wf.export_cube(path, lut) @@ -298,9 +306,7 @@ def test_creates_file(self, hdr10_wf): def test_header_fields(self, hdr10_wf): lut = hdr10_wf.generate_hdr_lut(size=3) - with tempfile.NamedTemporaryFile( - suffix=".cube", delete=False, mode="w" - ) as tmp: + with tempfile.NamedTemporaryFile(suffix=".cube", delete=False, mode="w") as tmp: path = tmp.name try: hdr10_wf.export_cube(path, lut) @@ -318,9 +324,7 @@ def test_header_fields(self, hdr10_wf): def test_correct_line_count(self, hdr10_wf): size = 3 lut = hdr10_wf.generate_hdr_lut(size=size) - with tempfile.NamedTemporaryFile( - suffix=".cube", delete=False, mode="w" - ) as tmp: + with tempfile.NamedTemporaryFile(suffix=".cube", delete=False, mode="w") as tmp: path = tmp.name try: hdr10_wf.export_cube(path, lut) @@ -328,30 +332,28 @@ def test_correct_line_count(self, hdr10_wf): lines = fh.read().strip().split("\n") # Data lines = size^3 data_lines = [ - ln for ln in lines - if ln and not ln.startswith("#") + ln + for ln in lines + if ln + and not ln.startswith("#") and not ln.startswith("TITLE") and not ln.startswith("LUT_3D_SIZE") and not ln.startswith("DOMAIN") ] - assert len(data_lines) == size ** 3 + assert len(data_lines) == size**3 finally: os.unlink(path) def test_data_parseable(self, hdr10_wf): lut = hdr10_wf.generate_hdr_lut(size=3) - with tempfile.NamedTemporaryFile( - suffix=".cube", delete=False, mode="w" - ) as tmp: + with tempfile.NamedTemporaryFile(suffix=".cube", delete=False, mode="w") as tmp: path = tmp.name try: hdr10_wf.export_cube(path, lut) with open(path) as fh: for line in fh: line = line.strip() - if not line or line.startswith("#") or line.startswith( - ("TITLE", "LUT_3D_SIZE", "DOMAIN") - ): + if not line or line.startswith("#") or line.startswith(("TITLE", "LUT_3D_SIZE", "DOMAIN")): continue parts = line.split() assert len(parts) == 3 @@ -370,6 +372,7 @@ def test_bad_lut_shape_raises(self, hdr10_wf): # Full run() # ========================================================================= + class TestRun: def test_hdr10_run_returns_result(self, hdr10_wf): result = hdr10_wf.run(lut_size=5) diff --git a/tests/test_lut_engine.py b/tests/test_lut_engine.py index 301a03a..01b4c8f 100644 --- a/tests/test_lut_engine.py +++ b/tests/test_lut_engine.py @@ -12,6 +12,7 @@ # LUT3D.create_identity # ------------------------------------------------------------------------- + @pytest.mark.parametrize("size", [17, 33, 65]) def test_identity_lut_shape(size): """Identity LUT should have shape (size, size, size, 3).""" @@ -47,6 +48,7 @@ def test_identity_lut_corners(): # LUT3D.apply (trilinear interpolation) # ------------------------------------------------------------------------- + def test_apply_identity_preserves_input(): """Applying an identity LUT should return approximately the input.""" lut = LUT3D.create_identity(33) @@ -72,11 +74,13 @@ def test_apply_identity_white(): def test_apply_batch(): """LUT apply should handle (N, 3) batch input.""" lut = LUT3D.create_identity(17) - batch = np.array([ - [0.0, 0.0, 0.0], - [0.5, 0.5, 0.5], - [1.0, 1.0, 1.0], - ]) + batch = np.array( + [ + [0.0, 0.0, 0.0], + [0.5, 0.5, 0.5], + [1.0, 1.0, 1.0], + ] + ) result = lut.apply(batch) assert result.shape == (3, 3) np.testing.assert_allclose(result[0], [0, 0, 0], atol=1e-10) @@ -86,6 +90,7 @@ def test_apply_batch(): # LUT3D save/load roundtrip (.cube format) # ------------------------------------------------------------------------- + def test_save_load_cube_roundtrip(): """Save a LUT to .cube then load it back; data should match.""" lut = LUT3D.create_identity(17) @@ -119,6 +124,7 @@ def test_save_load_cube_non_identity(): # create_calibration_lut preserves neutral axis # ------------------------------------------------------------------------- + def test_calibration_lut_preserves_neutral_axis(srgb_panel): """A calibration LUT for the generic sRGB panel should preserve neutrals.""" gen = LUTGenerator(size=17) @@ -149,6 +155,7 @@ def test_calibration_lut_preserves_neutral_axis(srgb_panel): # create_oklab_perceptual_lut preserves neutral axis and black # ------------------------------------------------------------------------- + def test_oklab_lut_preserves_black(srgb_panel): """Oklab perceptual LUT must preserve (0,0,0) -> (0,0,0).""" gen = LUTGenerator(size=17) @@ -197,6 +204,7 @@ def test_oklab_lut_preserves_neutral(srgb_panel): # create_hdr_calibration_lut preserves black # ------------------------------------------------------------------------- + def test_hdr_lut_preserves_black(srgb_panel): """HDR calibration LUT must preserve black (PQ=0 -> PQ=0).""" gen = LUTGenerator(size=17) @@ -221,6 +229,7 @@ def test_hdr_lut_preserves_black(srgb_panel): # LUT size options # ------------------------------------------------------------------------- + @pytest.mark.parametrize("size", [17, 33, 65]) def test_lut_generator_sizes(size): """LUTGenerator should create LUTs of various sizes.""" diff --git a/tests/test_native_loop.py b/tests/test_native_loop.py index 497d883..5acef13 100644 --- a/tests/test_native_loop.py +++ b/tests/test_native_loop.py @@ -34,14 +34,17 @@ def _make_srgb_profile(self): levels = np.linspace(0, 1, n) # sRGB gamma ~2.2 trc = np.power(levels, 2.2) - trc[0] = 0.0; trc[-1] = 1.0 + trc[0] = 0.0 + trc[-1] = 1.0 # sRGB primaries matrix (exact) M = SRGB_TO_XYZ.copy() return DisplayProfile( levels=levels, - trc_r=trc.copy(), trc_g=trc.copy(), trc_b=trc.copy(), + trc_r=trc.copy(), + trc_g=trc.copy(), + trc_b=trc.copy(), M_display=M, white_Y=100.0, black_xyz=np.zeros(3), @@ -49,7 +52,9 @@ def _make_srgb_profile(self): red_xy=(0.6400, 0.3300), green_xy=(0.3000, 0.6000), blue_xy=(0.1500, 0.0600), - gamma_r=2.2, gamma_g=2.2, gamma_b=2.2, + gamma_r=2.2, + gamma_g=2.2, + gamma_b=2.2, ) def test_identity_lut_for_srgb_display(self): @@ -72,11 +77,13 @@ def test_wide_gamut_correction(self): """A wide-gamut display should produce a LUT that compresses gamut.""" profile = self._make_srgb_profile() # Make primaries wider (like QD-OLED) - profile.M_display = np.array([ - [0.5, 0.2, 0.2], - [0.25, 0.7, 0.05], - [0.0, 0.03, 1.0], - ]) + profile.M_display = np.array( + [ + [0.5, 0.2, 0.2], + [0.25, 0.7, 0.05], + [0.0, 0.03, 1.0], + ] + ) profile.red_xy = (0.68, 0.31) profile.green_xy = (0.26, 0.70) profile.blue_xy = (0.15, 0.06) @@ -93,9 +100,7 @@ def test_lut_preserves_black(self): """Black (0,0,0) should always pass through unchanged.""" profile = self._make_srgb_profile() lut = build_correction_lut(profile, size=5) - np.testing.assert_array_almost_equal( - lut.data[0, 0, 0], [0.0, 0.0, 0.0], decimal=6 - ) + np.testing.assert_array_almost_equal(lut.data[0, 0, 0], [0.0, 0.0, 0.0], decimal=6) class TestComputeDe: @@ -140,8 +145,10 @@ def test_qdoled_ccmx_is_diagonal_dominant(self): def test_ccmx_preserves_white(self): """CCMX should map sensor white to true white.""" + def xy_to_XYZ(x, y): - return np.array([x/y, 1.0, (1-x-y)/y]) + return np.array([x / y, 1.0, (1 - x - y) / y]) + sensor_w = xy_to_XYZ(0.3134, 0.3240) xy_to_XYZ(0.3134, 0.3291) corrected = QDOLED_CCMX @ sensor_w diff --git a/tests/test_panel_database.py b/tests/test_panel_database.py index f31f60c..086bd9a 100644 --- a/tests/test_panel_database.py +++ b/tests/test_panel_database.py @@ -13,6 +13,7 @@ # PG27UCDM existence and metadata # ------------------------------------------------------------------------- + def test_pg27ucdm_exists(panel_database): """PG27UCDM must exist in the database.""" panel = panel_database.get_panel("PG27UCDM") @@ -43,6 +44,7 @@ def test_pg27ucdm_hdr(qd_oled_panel): # GENERIC_SRGB fallback # ------------------------------------------------------------------------- + def test_generic_srgb_exists(panel_database): """GENERIC_SRGB fallback must exist.""" panel = panel_database.get_panel("GENERIC_SRGB") @@ -69,6 +71,7 @@ def test_get_fallback_returns_generic(panel_database): # find_panel with various model strings # ------------------------------------------------------------------------- + def test_find_panel_exact_model(panel_database): """find_panel should match PG27UCDM exactly.""" panel = panel_database.find_panel("PG27UCDM") @@ -121,6 +124,7 @@ def test_find_panel_empty_string(panel_database): # Database size # ------------------------------------------------------------------------- + def test_database_has_many_panels(panel_database): """Database should have more than 30 panels.""" # list_panels excludes GENERIC_SRGB @@ -132,6 +136,7 @@ def test_database_has_many_panels(panel_database): # create_from_edid # ------------------------------------------------------------------------- + def test_create_from_edid_valid(): """create_from_edid should produce a valid PanelCharacterization.""" edid = { @@ -172,6 +177,7 @@ def test_create_from_edid_wide_gamut_detection(): # JSON serialization roundtrip # ------------------------------------------------------------------------- + def test_json_roundtrip(qd_oled_panel): """to_dict -> JSON -> from_dict roundtrip should preserve key fields.""" d = qd_oled_panel.to_dict() @@ -181,12 +187,8 @@ def test_json_roundtrip(qd_oled_panel): assert recovered.manufacturer == qd_oled_panel.manufacturer assert recovered.panel_type == qd_oled_panel.panel_type - assert recovered.native_primaries.red.x == pytest.approx( - qd_oled_panel.native_primaries.red.x, abs=1e-6 - ) - assert recovered.gamma_red.gamma == pytest.approx( - qd_oled_panel.gamma_red.gamma, abs=1e-6 - ) + assert recovered.native_primaries.red.x == pytest.approx(qd_oled_panel.native_primaries.red.x, abs=1e-6) + assert recovered.gamma_red.gamma == pytest.approx(qd_oled_panel.gamma_red.gamma, abs=1e-6) assert recovered.capabilities.max_luminance_hdr == pytest.approx( qd_oled_panel.capabilities.max_luminance_hdr, abs=0.1 ) diff --git a/tests/test_platform.py b/tests/test_platform.py index 9f60b0b..2761bc9 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -28,6 +28,7 @@ # Fixtures # ===================================================================== + @pytest.fixture def linear_ramp_256(): """A linear identity ramp: 256 entries, 0 to 65535.""" @@ -38,7 +39,7 @@ def linear_ramp_256(): def gamma_22_ramp_256(): """A gamma-2.2 ramp: 256 entries, 0 to 65535.""" t = np.linspace(0, 1, 256) - return list((t ** 2.2 * 65535).astype(np.uint16)) + return list((t**2.2 * 65535).astype(np.uint16)) @pytest.fixture @@ -66,13 +67,19 @@ def mock_display_info(): # DisplayInfo dataclass # ===================================================================== + class TestDisplayInfo: """Tests for the DisplayInfo dataclass.""" def test_required_fields(self): info = DisplayInfo( - index=0, name="Monitor", device_path="/dev/0", - is_primary=True, width=1920, height=1080, refresh_rate=60, + index=0, + name="Monitor", + device_path="/dev/0", + is_primary=True, + width=1920, + height=1080, + refresh_rate=60, ) assert info.index == 0 assert info.name == "Monitor" @@ -83,8 +90,13 @@ def test_required_fields(self): def test_default_fields(self): info = DisplayInfo( - index=0, name="Monitor", device_path="/dev/0", - is_primary=False, width=3840, height=2160, refresh_rate=120, + index=0, + name="Monitor", + device_path="/dev/0", + is_primary=False, + width=3840, + height=2160, + refresh_rate=120, ) assert info.bit_depth == 8 assert info.position_x == 0 @@ -107,42 +119,55 @@ def test_all_fields_populated(self, mock_display_info): # Platform selection # ===================================================================== + class TestPlatformSelection: """Test that get_platform_backend() returns the correct class.""" def test_windows_selection(self): - with mock.patch.object(sys, "platform", "win32"), mock.patch( - "calibrate_pro.platform.windows.WindowsBackend", - create=True, - ) as mock_cls: + with ( + mock.patch.object(sys, "platform", "win32"), + mock.patch( + "calibrate_pro.platform.windows.WindowsBackend", + create=True, + ) as mock_cls, + ): mock_cls.return_value = mock.MagicMock(spec=PlatformBackend) get_platform_backend() mock_cls.assert_called_once() def test_darwin_selection(self): - with mock.patch.object(sys, "platform", "darwin"), mock.patch( - "calibrate_pro.platform.macos.MacOSBackend", - create=True, - ) as mock_cls: + with ( + mock.patch.object(sys, "platform", "darwin"), + mock.patch( + "calibrate_pro.platform.macos.MacOSBackend", + create=True, + ) as mock_cls, + ): mock_cls.return_value = mock.MagicMock(spec=PlatformBackend) get_platform_backend() mock_cls.assert_called_once() def test_linux_selection(self): - with mock.patch.object(sys, "platform", "linux"), mock.patch( - "calibrate_pro.platform.linux.LinuxBackend", - create=True, - ) as mock_cls: + with ( + mock.patch.object(sys, "platform", "linux"), + mock.patch( + "calibrate_pro.platform.linux.LinuxBackend", + create=True, + ) as mock_cls, + ): mock_cls.return_value = mock.MagicMock(spec=PlatformBackend) get_platform_backend() mock_cls.assert_called_once() def test_linux_variant_selection(self): """linux2, linux-armv7l, etc. should all resolve to LinuxBackend.""" - with mock.patch.object(sys, "platform", "linux-armv7l"), mock.patch( - "calibrate_pro.platform.linux.LinuxBackend", - create=True, - ) as mock_cls: + with ( + mock.patch.object(sys, "platform", "linux-armv7l"), + mock.patch( + "calibrate_pro.platform.linux.LinuxBackend", + create=True, + ) as mock_cls, + ): mock_cls.return_value = mock.MagicMock(spec=PlatformBackend) get_platform_backend() mock_cls.assert_called_once() @@ -162,6 +187,7 @@ def test_current_platform_returns_backend(self): # Abstract base class enforcement # ===================================================================== + class TestBaseClassContract: """Verify that PlatformBackend enforces the abstract interface.""" @@ -173,6 +199,7 @@ def test_incomplete_subclass_raises(self): class IncompleteBackend(PlatformBackend): def enumerate_displays(self): return [] + # Missing: apply_gamma_ramp, reset_gamma_ramp, # install_icc_profile, get_icc_profile @@ -183,12 +210,16 @@ def test_complete_subclass_instantiates(self): class CompleteBackend(PlatformBackend): def enumerate_displays(self) -> list[DisplayInfo]: return [] + def apply_gamma_ramp(self, di, r, g, b) -> bool: return True + def reset_gamma_ramp(self, di) -> bool: return True + def install_icc_profile(self, pp, di) -> bool: return True + def get_icc_profile(self, di) -> str | None: return None @@ -204,6 +235,7 @@ def get_icc_profile(self, di) -> str | None: # Gamma ramp validation # ===================================================================== + class TestGammaRampFormat: """Validate gamma ramp data format requirements.""" @@ -255,21 +287,25 @@ def test_ramp_uint16_round_trip(self, linear_ramp_256): # macOS backend tests # ===================================================================== + class TestMacOSBackend: """Tests for macOS backend (importable on any platform).""" def test_import_succeeds(self): from calibrate_pro.platform.macos import MacOSBackend + assert MacOSBackend is not None def test_instantiation(self): from calibrate_pro.platform.macos import MacOSBackend + backend = MacOSBackend() assert isinstance(backend, PlatformBackend) def test_enumerate_without_quartz_returns_empty(self): """When pyobjc is missing, enumerate_displays returns [].""" from calibrate_pro.platform.macos import MacOSBackend + backend = MacOSBackend() with mock.patch.dict(sys.modules, {"Quartz": None}): @@ -280,15 +316,17 @@ def test_enumerate_without_quartz_returns_empty(self): def test_apply_gamma_without_quartz_returns_false(self): """When pyobjc is missing, apply_gamma_ramp returns False.""" from calibrate_pro.platform.macos import MacOSBackend + backend = MacOSBackend() with mock.patch.dict(sys.modules, {"Quartz": None}): with mock.patch("builtins.__import__", side_effect=_import_blocker("Quartz")): - result = backend.apply_gamma_ramp(0, [0]*256, [0]*256, [0]*256) + result = backend.apply_gamma_ramp(0, [0] * 256, [0] * 256, [0] * 256) assert result is False def test_reset_gamma_without_quartz_returns_false(self): from calibrate_pro.platform.macos import MacOSBackend + backend = MacOSBackend() with mock.patch.dict(sys.modules, {"Quartz": None}): @@ -298,6 +336,7 @@ def test_reset_gamma_without_quartz_returns_false(self): def test_get_icc_without_quartz_returns_none(self): from calibrate_pro.platform.macos import MacOSBackend + backend = MacOSBackend() # _get_display_id will fail without Quartz, returning None @@ -312,6 +351,7 @@ def test_startup_helpers_importable(self): enable_macos_startup, is_macos_startup_enabled, ) + assert callable(enable_macos_startup) assert callable(disable_macos_startup) assert callable(is_macos_startup_enabled) @@ -321,21 +361,25 @@ def test_startup_helpers_importable(self): # Linux backend tests # ===================================================================== + class TestLinuxBackend: """Tests for Linux backend (importable on any platform).""" def test_import_succeeds(self): from calibrate_pro.platform.linux import LinuxBackend + assert LinuxBackend is not None def test_instantiation(self): from calibrate_pro.platform.linux import LinuxBackend + backend = LinuxBackend() assert isinstance(backend, PlatformBackend) def test_enumerate_without_xrandr_returns_empty(self): """When xrandr is unavailable, enumerate should fall back gracefully.""" from calibrate_pro.platform.linux import LinuxBackend + backend = LinuxBackend() with mock.patch("calibrate_pro.platform.linux._run_cmd", return_value=None): @@ -346,6 +390,7 @@ def test_enumerate_without_xrandr_returns_empty(self): def test_enumerate_xrandr_parses_output(self): """Test xrandr output parsing with mock data.""" from calibrate_pro.platform.linux import LinuxBackend + backend = LinuxBackend() xrandr_output = ( @@ -382,6 +427,7 @@ def mock_run_cmd(cmd, timeout=10): def test_apply_gamma_without_tools_returns_false(self): """When both python-xlib and xrandr are missing, returns False.""" from calibrate_pro.platform.linux import LinuxBackend + backend = LinuxBackend() ramp = list(range(0, 65536, 256))[:256] @@ -393,6 +439,7 @@ def test_apply_gamma_without_tools_returns_false(self): def test_reset_gamma_without_tools_returns_false(self): from calibrate_pro.platform.linux import LinuxBackend + backend = LinuxBackend() with mock.patch("calibrate_pro.platform.linux._run_cmd", return_value=None): @@ -402,6 +449,7 @@ def test_reset_gamma_without_tools_returns_false(self): def test_get_icc_without_colord_returns_none(self): from calibrate_pro.platform.linux import LinuxBackend + backend = LinuxBackend() with mock.patch("calibrate_pro.platform.linux._run_cmd", return_value=None): @@ -410,6 +458,7 @@ def test_get_icc_without_colord_returns_none(self): def test_install_icc_nonexistent_file_returns_false(self): from calibrate_pro.platform.linux import LinuxBackend + backend = LinuxBackend() result = backend.install_icc_profile("/nonexistent/profile.icc", 0) @@ -418,6 +467,7 @@ def test_install_icc_nonexistent_file_returns_false(self): def test_install_icc_copies_to_user_dir(self, tmp_path): """ICC profile install should copy to ~/.local/share/icc/.""" from calibrate_pro.platform.linux import LinuxBackend + backend = LinuxBackend() # Create a fake profile @@ -441,6 +491,7 @@ def test_startup_helpers_importable(self): enable_linux_startup, is_linux_startup_enabled, ) + assert callable(enable_linux_startup) assert callable(disable_linux_startup) assert callable(is_linux_startup_enabled) @@ -450,6 +501,7 @@ def test_startup_helpers_importable(self): # EDID parsing (Linux helper) # ===================================================================== + class TestLinuxEdidParsing: """Test the EDID parser used by the Linux backend.""" @@ -459,7 +511,7 @@ def test_parse_valid_edid(self): # Construct a minimal valid EDID block (128 bytes) edid = bytearray(128) # EDID header - edid[0:8] = b'\x00\xff\xff\xff\xff\xff\xff\x00' + edid[0:8] = b"\x00\xff\xff\xff\xff\xff\xff\x00" # Manufacturer ID "TST" -> T=0x14, S=0x13, T=0x14 # Compressed: ((0x14 << 10) | (0x13 << 5) | 0x14) = 0x5274 edid[8] = 0x52 @@ -471,8 +523,8 @@ def test_parse_valid_edid(self): edid[56] = 0x00 edid[57] = 0xFC # Monitor name tag edid[58] = 0x00 - name_bytes = b'Test Monitor\n' - edid[59:59 + len(name_bytes)] = name_bytes + name_bytes = b"Test Monitor\n" + edid[59 : 59 + len(name_bytes)] = name_bytes # Descriptor block 2 (offset 72): serial string (tag 0xFF) edid[72] = 0x00 @@ -480,8 +532,8 @@ def test_parse_valid_edid(self): edid[74] = 0x00 edid[75] = 0xFF # Serial string tag edid[76] = 0x00 - serial_bytes = b'SN00001\n' - edid[77:77 + len(serial_bytes)] = serial_bytes + serial_bytes = b"SN00001\n" + edid[77 : 77 + len(serial_bytes)] = serial_bytes manufacturer, model, serial = _parse_edid_name(bytes(edid)) assert manufacturer == "TST" @@ -490,14 +542,16 @@ def test_parse_valid_edid(self): def test_parse_short_edid_returns_empty(self): from calibrate_pro.platform.linux import _parse_edid_name - manufacturer, model, serial = _parse_edid_name(b'\x00' * 64) + + manufacturer, model, serial = _parse_edid_name(b"\x00" * 64) assert manufacturer == "" assert model == "" assert serial == "" def test_parse_empty_edid_returns_empty(self): from calibrate_pro.platform.linux import _parse_edid_name - manufacturer, model, serial = _parse_edid_name(b'') + + manufacturer, model, serial = _parse_edid_name(b"") assert manufacturer == "" assert model == "" assert serial == "" @@ -507,6 +561,7 @@ def test_parse_empty_edid_returns_empty(self): # xrandr output parser (Linux helper) # ===================================================================== + class TestXrandrParser: """Test the xrandr --query output parser.""" @@ -522,11 +577,11 @@ def test_single_display(self): result = _parse_xrandr_output(text) assert len(result) == 1 - assert result[0]['name'] == "DP-1" - assert result[0]['primary'] is True - assert result[0]['width'] == 2560 - assert result[0]['height'] == 1440 - assert result[0]['refresh'] == 143 + assert result[0]["name"] == "DP-1" + assert result[0]["primary"] is True + assert result[0]["width"] == 2560 + assert result[0]["height"] == 1440 + assert result[0]["refresh"] == 143 def test_multiple_displays(self): from calibrate_pro.platform.linux import _parse_xrandr_output @@ -542,28 +597,25 @@ def test_multiple_displays(self): result = _parse_xrandr_output(text) assert len(result) == 2 - assert result[0]['name'] == "eDP-1" - assert result[0]['primary'] is True - assert result[0]['width'] == 1920 - assert result[1]['name'] == "HDMI-1" - assert result[1]['primary'] is False - assert result[1]['width'] == 3840 - assert result[1]['pos_x'] == 1920 + assert result[0]["name"] == "eDP-1" + assert result[0]["primary"] is True + assert result[0]["width"] == 1920 + assert result[1]["name"] == "HDMI-1" + assert result[1]["primary"] is False + assert result[1]["width"] == 3840 + assert result[1]["pos_x"] == 1920 def test_no_connected_displays(self): from calibrate_pro.platform.linux import _parse_xrandr_output - text = ( - "Screen 0: minimum 8 x 8, current 0 x 0\n" - "DP-1 disconnected\n" - "HDMI-1 disconnected\n" - ) + text = "Screen 0: minimum 8 x 8, current 0 x 0\nDP-1 disconnected\nHDMI-1 disconnected\n" result = _parse_xrandr_output(text) assert len(result) == 0 def test_empty_input(self): from calibrate_pro.platform.linux import _parse_xrandr_output + assert _parse_xrandr_output("") == [] @@ -571,16 +623,19 @@ def test_empty_input(self): # Windows backend tests (smoke) # ===================================================================== + class TestWindowsBackend: """Basic import/instantiation tests for Windows backend.""" def test_import_succeeds(self): from calibrate_pro.platform.windows import WindowsBackend + assert WindowsBackend is not None @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_enumerate_on_windows(self): from calibrate_pro.platform.windows import WindowsBackend + backend = WindowsBackend() displays = backend.enumerate_displays() assert isinstance(displays, list) @@ -590,6 +645,7 @@ def test_enumerate_on_windows(self): @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_display_fields_valid(self): from calibrate_pro.platform.windows import WindowsBackend + backend = WindowsBackend() displays = backend.enumerate_displays() for d in displays: @@ -604,6 +660,7 @@ def test_display_fields_valid(self): # Integration: current platform backend # ===================================================================== + class TestCurrentPlatformIntegration: """Integration tests that run on whatever platform we're on.""" @@ -641,9 +698,10 @@ def test_reset_gamma_invalid_index_returns_false(self): # Helpers # ===================================================================== + def _import_blocker(blocked_module: str): """Return a side_effect for builtins.__import__ that blocks one module.""" - real_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__ + real_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__ def blocker(name, *args, **kwargs): if name == blocked_module or name.startswith(blocked_module + "."): diff --git a/tests/test_professional_features.py b/tests/test_professional_features.py index 0d316f6..9fa2884 100644 --- a/tests/test_professional_features.py +++ b/tests/test_professional_features.py @@ -6,10 +6,12 @@ class TestCalibrationTargets: def test_all_targets_exist(self): from calibrate_pro.calibration.targets import ALL_TARGETS + assert len(ALL_TARGETS) >= 13 def test_rec709_specs(self): from calibrate_pro.calibration.targets import REC709_BT1886 + assert REC709_BT1886.eotf == "bt1886" assert REC709_BT1886.gamma == 2.4 assert REC709_BT1886.peak_luminance == 100.0 @@ -17,29 +19,34 @@ def test_rec709_specs(self): def test_hdr10_specs(self): from calibrate_pro.calibration.targets import HDR10_1000 + assert HDR10_1000.eotf == "pq" assert HDR10_1000.peak_luminance == 1000.0 assert HDR10_1000.sdr_reference_white == 203.0 def test_netflix_sdr(self): from calibrate_pro.calibration.targets import NETFLIX_SDR + assert NETFLIX_SDR.delta_e_target == 1.0 assert NETFLIX_SDR.white_point_tolerance == 0.003 def test_get_target(self): from calibrate_pro.calibration.targets import get_target + assert get_target("rec709") is not None assert get_target("srgb") is not None assert get_target("nonexistent") is None def test_list_targets(self): from calibrate_pro.calibration.targets import list_targets + targets = list_targets() assert len(targets) >= 13 assert all("name" in t for t in targets) def test_category_filter(self): from calibrate_pro.calibration.targets import get_targets_by_category + broadcast = get_targets_by_category("broadcast") assert len(broadcast) >= 2 hdr = get_targets_by_category("hdr") @@ -49,16 +56,19 @@ def test_category_filter(self): class TestPatchSets: def test_all_sets_exist(self): from calibrate_pro.verification.patch_sets import list_patch_sets + sets = list_patch_sets() assert len(sets) >= 12 def test_colorchecker_24(self): from calibrate_pro.verification.patch_sets import get_patch_set + cc = get_patch_set("COLORCHECKER_CLASSIC") assert len(cc) == 24 def test_grayscale_21(self): from calibrate_pro.verification.patch_sets import get_patch_set + gs = get_patch_set("GRAYSCALE_21") assert len(gs) == 21 # First should be black, last should be white @@ -67,11 +77,13 @@ def test_grayscale_21(self): def test_comprehensive_100(self): from calibrate_pro.verification.patch_sets import get_patch_set + comp = get_patch_set("COMPREHENSIVE_100") assert len(comp) >= 100 def test_patch_values_in_range(self): from calibrate_pro.verification.patch_sets import get_patch_set, list_patch_sets + for name, _ in list_patch_sets(): for patch in get_patch_set(name): assert 0 <= patch.r <= 1, f"{name}/{patch.name}: r={patch.r}" @@ -85,6 +97,7 @@ def test_clf_export(self): import tempfile from calibrate_pro.core.lut_engine import LUT3D + lut = LUT3D.create_identity(5) path = os.path.join(tempfile.gettempdir(), "test_export.clf") lut.save_clf(path) @@ -100,12 +113,14 @@ def test_clf_export(self): class TestCCSSImport: def test_list_builtins(self): from calibrate_pro.calibration.ccss_import import list_builtin_corrections + builtins = list_builtin_corrections() assert len(builtins) >= 1 assert builtins[0]["technology"] == "QD-OLED" def test_get_builtin_ccmx(self): from calibrate_pro.calibration.ccss_import import get_builtin_ccmx + ccmx = get_builtin_ccmx("QD-OLED (i1Display3 - PG27UCDM)") assert ccmx.shape == (3, 3) # Diagonal dominant @@ -115,6 +130,7 @@ def test_get_builtin_ccmx(self): def test_apply_ccmx(self): from calibrate_pro.calibration.ccss_import import apply_ccmx + xyz = np.array([0.95, 1.0, 1.09]) identity = np.eye(3) result = apply_ccmx(xyz, identity) @@ -124,6 +140,7 @@ def test_apply_ccmx(self): class TestWarmupMonitor: def test_basic_flow(self): from calibrate_pro.hardware.warmup_monitor import WarmupMonitor + readings = [100.0, 102.0, 101.5, 101.2, 101.1, 101.05] idx = [0] @@ -142,6 +159,7 @@ def measure(): def test_recommended_warmup(self): from calibrate_pro.hardware.warmup_monitor import get_recommended_warmup + assert get_recommended_warmup("QD-OLED") == 30 assert get_recommended_warmup("CCFL") == 90 assert get_recommended_warmup("IPS") == 30 @@ -150,6 +168,7 @@ def test_recommended_warmup(self): class TestDriftCompensator: def test_no_drift(self): from calibrate_pro.hardware.drift_compensation import DriftCompensator + comp = DriftCompensator() ref = np.array([0.95, 1.0, 1.09]) comp.set_initial_reference(ref) @@ -159,6 +178,7 @@ def test_no_drift(self): def test_drift_correction(self): from calibrate_pro.hardware.drift_compensation import DriftCompensator + comp = DriftCompensator(reference_interval=10) initial = np.array([1.0, 1.0, 1.0]) comp.set_initial_reference(initial) @@ -175,21 +195,21 @@ def test_drift_correction(self): class TestPanelDatabase: def test_58_panels(self): from calibrate_pro.panels.database import PanelDatabase + db = PanelDatabase() assert len(db.list_panels()) >= 58 def test_ddc_recommendations(self): from calibrate_pro.panels.database import PanelDatabase + db = PanelDatabase() # At least 40 panels should have DDC recommendations - ddc_count = sum( - 1 for key in db.list_panels() - if (p := db.get_panel(key)) and hasattr(p, 'ddc') and p.ddc - ) + ddc_count = sum(1 for key in db.list_panels() if (p := db.get_panel(key)) and hasattr(p, "ddc") and p.ddc) assert ddc_count >= 40 def test_panel_types(self): from calibrate_pro.panels.database import PanelDatabase + db = PanelDatabase() types = set() for key in db.list_panels(): diff --git a/tests/test_verification.py b/tests/test_verification.py index cf689c9..ff449ba 100644 --- a/tests/test_verification.py +++ b/tests/test_verification.py @@ -1,12 +1,12 @@ """Tests for calibrate_pro.sensorless.neuralux — NeuralUXEngine.verify_calibration.""" - from calibrate_pro.sensorless.neuralux import NeuralUXEngine # ------------------------------------------------------------------------- # verify_calibration returns expected keys # ------------------------------------------------------------------------- + def test_verify_returns_expected_keys(panel_database, qd_oled_panel): """verify_calibration result dict should have the documented keys.""" engine = NeuralUXEngine(panel_database=panel_database) @@ -14,9 +14,16 @@ def test_verify_returns_expected_keys(panel_database, qd_oled_panel): result = engine.verify_calibration(qd_oled_panel) expected_keys = [ - "panel", "patches", "delta_e_values", "delta_e_avg", "delta_e_max", - "cam16_delta_e_values", "cam16_delta_e_avg", "cam16_delta_e_max", - "grade", "gamut_coverage", + "panel", + "patches", + "delta_e_values", + "delta_e_avg", + "delta_e_max", + "cam16_delta_e_values", + "cam16_delta_e_avg", + "cam16_delta_e_max", + "grade", + "gamut_coverage", ] for key in expected_keys: assert key in result, f"Missing key: {key}" @@ -26,6 +33,7 @@ def test_verify_returns_expected_keys(panel_database, qd_oled_panel): # CIEDE2000 and CAM16-UCS metrics are both present # ------------------------------------------------------------------------- + def test_verify_has_ciede2000_metrics(panel_database, qd_oled_panel): """Verification should include CIEDE2000 Delta E values.""" engine = NeuralUXEngine(panel_database=panel_database) @@ -52,6 +60,7 @@ def test_verify_has_cam16_metrics(panel_database, qd_oled_panel): # Gamut coverage (QD-OLED should be >95% sRGB) # ------------------------------------------------------------------------- + def test_qd_oled_srgb_coverage(panel_database, qd_oled_panel): """QD-OLED panel should have >95% sRGB gamut coverage.""" engine = NeuralUXEngine(panel_database=panel_database) @@ -60,9 +69,7 @@ def test_qd_oled_srgb_coverage(panel_database, qd_oled_panel): coverage = result["gamut_coverage"] assert "srgb_pct" in coverage - assert coverage["srgb_pct"] > 95.0, ( - f"QD-OLED sRGB coverage should be >95%, got {coverage['srgb_pct']:.1f}%" - ) + assert coverage["srgb_pct"] > 95.0, f"QD-OLED sRGB coverage should be >95%, got {coverage['srgb_pct']:.1f}%" def test_generic_srgb_coverage(panel_database, srgb_panel): @@ -79,6 +86,7 @@ def test_generic_srgb_coverage(panel_database, srgb_panel): # Grade assignment matches Delta E thresholds # ------------------------------------------------------------------------- + def test_grade_reference(panel_database, qd_oled_panel): """Grade should be based on Delta E thresholds.""" engine = NeuralUXEngine(panel_database=panel_database) @@ -115,6 +123,7 @@ def test_srgb_panel_grade(panel_database, srgb_panel): # Per-patch data # ------------------------------------------------------------------------- + def test_patches_have_correct_structure(panel_database, qd_oled_panel): """Each patch entry should have name, ref_lab, delta_e, cam16_delta_e.""" engine = NeuralUXEngine(panel_database=panel_database)