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.
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"Version {APP_VERSION}
" f"Professional display calibration suite with:
" @@ -466,7 +473,8 @@ def _show_about(self): 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("