Source code for cdl_convert.correction

#!/usr/bin/env python
"""CDL Convert Color Correction Module

This module contains the core ColorCorrection class and supporting node classes
that form the backbone of CDL Convert.

Classes:
    ColorValues: Dataclass for structured color correction data.

    ColorCorrection: The primary interface for ASC CDL color corrections.
        Contains SopNode and SatNode for organized value management.

    SatNode: Type-safe container for saturation values.

    SopNode: Type-safe container for slope, offset, and power values with
        RGB tuple management and validation

Example Usage:
    >>> from pathlib import Path
    >>> from decimal import Decimal
    >>> from cdl_convert import ColorCorrection, ColorValues
    >>> cc = ColorCorrection("shot_001", input_file=Path("input.ale"))
    >>> cc.slope = [1.2, 1.1, 1.0]
    >>> cc.sat = 0.9
    >>> # Use the ColorValues dataclass
    >>> values = cc.get_color_values()
    >>> print(f"Is unity: {values.is_unity()}")
    >>> # Enhanced error handling
    >>> try:
    ...     cc.slope = [-1.0, 1.0, 1.0]  # Negative slope
    ... except ValidationError as e:
    ...     print(f"Validation error: {e}")

## License

The MIT License (MIT)

cdl_convert
Copyright (c) 2015-2025 Sean Wallitsch
http://github.com/shidarin/cdl_convert/

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

"""

# ==============================================================================
# IMPORTS
# ==============================================================================


# Standard Imports

import re
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import Any, cast
from xml.etree import ElementTree

# cdl_convert imports
from cdl_convert import config
from cdl_convert.base import (
    AscColorSpaceBase,
    AscDescBase,
    AscXMLBase,
    ColorNodeBase,
)
from cdl_convert.exceptions import ValidationError

# ==============================================================================
# DATACLASSES
# ==============================================================================


@dataclass
class ColorValues:
    """Container for ASC CDL color correction values with validation.

    This dataclass provides a structured way to store and validate the 10
    ASC CDL color correction numbers. Values are automatically validated during
    initialization to ensure they conform to CDL requirements.

    Attributes:
        slope (Tuple[Decimal, Decimal, Decimal]): RGB slope values. Must be
            non-negative. Defaults to (1.0, 1.0, 1.0) for unity.
        offset (Tuple[Decimal, Decimal, Decimal]): RGB offset values. Can be
            negative. Defaults to (0.0, 0.0, 0.0) for unity.
        power (Tuple[Decimal, Decimal, Decimal]): RGB power values. Must be
            non-negative. Defaults to (1.0, 1.0, 1.0) for unity.
        saturation (Decimal): Saturation value. Must be non-negative.
            Defaults to 1.0 for unity.

    Example:
        >>> # Create with default unity values
        >>> values = ColorValues()
        >>> print(values.is_unity())  # True

        >>> # Create with custom values
        >>> from decimal import Decimal
        >>> values = ColorValues(
        ...     slope=(Decimal("1.2"), Decimal("1.1"), Decimal("1.0")),
        ...     saturation=Decimal("0.9"),
        ... )
        >>> print(f"Slope: {values.slope}")

        >>> # Validation occurs automatically during initialization
        >>> try:
        ...     ColorValues(slope=(-1.0, 1.0, 1.0))  # Negative slope
        ... except ValidationError as e:
        ...     print(f"Validation error: {e}")

    Raises:
        ValidationError: If any values fail validation during initialization.
            This includes negative slope/power values, incorrect tuple lengths,
            or invalid data types.

    """

    slope: tuple[Decimal, Decimal, Decimal] = (
        Decimal("1.0"),
        Decimal("1.0"),
        Decimal("1.0"),
    )
    offset: tuple[Decimal, Decimal, Decimal] = (
        Decimal("0.0"),
        Decimal("0.0"),
        Decimal("0.0"),
    )
    power: tuple[Decimal, Decimal, Decimal] = (
        Decimal("1.0"),
        Decimal("1.0"),
        Decimal("1.0"),
    )
    saturation: Decimal = Decimal("1.0")

    def __post_init__(self) -> None:
        """Validate values after initialization."""
        self._validate_values()

    def _validate_values(self) -> None:
        """Validate all color correction values according to CDL requirements.

        Checks that RGB tuples contain exactly 3 values, that slope and power
        values are non-negative, and that saturation is non-negative.
        Validation behavior depends on the global halt_on_error configuration
        setting.

        Raises:
            ValidationError: If any values fail validation checks, including
                incorrect tuple lengths, negative slope/power values, or
                negative saturation values (when halt_on_error is enabled).

        """
        # Validate RGB tuples have exactly 3 values
        for name, values in [
            ("slope", self.slope),
            ("offset", self.offset),
            ("power", self.power),
        ]:
            if len(values) != 3:
                raise ValidationError(
                    f"Invalid {name} values: expected 3 RGB values, got {len(values)}. "
                    f"Provided values: {values}. "
                    f"{name.title()} must specify exactly 3 values for Red, Green, and Blue channels."
                )

        # Validate that slope and power values are non-negative
        for name, values in [("slope", self.slope), ("power", self.power)]:
            for i, value in enumerate(values):
                if value < 0:
                    if config.config.halt_on_error:
                        channel = ["Red", "Green", "Blue"][i]
                        raise ValidationError(
                            f"Invalid {name} value for {channel} channel: {value}. "
                            f"{name.title()} values must be non-negative (>= 0)."
                        )

        # Validate saturation is non-negative
        if self.saturation < 0:
            if config.config.halt_on_error:
                raise ValidationError(
                    f"Invalid saturation value: {self.saturation}. "
                    f"Saturation must be non-negative (>= 0)."
                )

    def to_unity(self) -> "ColorValues":
        """Return a new ColorValues instance with unity/identity values.

        Unity values represent no color correction applied:
        slope=1.0, offset=0.0, power=1.0, saturation=1.0.

        Returns:
            ColorValues: A new instance with all values set to unity defaults.

        Example:
            >>> unity = ColorValues().to_unity()
            >>> print(unity.slope)
            >>> print(unity.is_unity())  # True

        """
        return ColorValues()

    def is_unity(self) -> bool:
        """Check if all values are at unity/identity (no correction applied).

        Unity values represent no color correction: slope=1.0, offset=0.0,
        power=1.0, saturation=1.0. This is useful for determining if a
        ColorCorrection actually applies any changes.

        Returns:
            bool: True if all values are at unity (no correction applied),
                False if any values differ from unity defaults.

        Example:
            >>> unity_values = ColorValues()
            >>> print(unity_values.is_unity())  # True

            >>> modified_values = ColorValues(saturation=Decimal("0.8"))
            >>> print(modified_values.is_unity())  # False

        """
        unity = self.to_unity()
        return (
            self.slope == unity.slope
            and self.offset == unity.offset
            and self.power == unity.power
            and self.saturation == unity.saturation
        )


# ==============================================================================
# EXPORTS
# ==============================================================================

__all__ = [
    "ColorCorrection",
    "ColorValues",
    "SatNode",
    "SopNode",
]

# ==============================================================================
# CLASSES
# ==============================================================================


[docs] class ColorCorrection(AscDescBase, AscColorSpaceBase, AscXMLBase): # pylint: disable=R0902,R0904 """Container for ASC CDL color correction values and metadata. This class contains attributes for all 10 color correction numbers needed for an ASC CDL, as well as other metadata like shot names that typically accompanies a CDL. These names are standardized by the ASC and where possible the attribute names will follow the ASC schema. Descriptions for some of these attributes are paraphrasing the ASC CDL documentation. For more information on the ASC CDL standard and the operations described below, you can obtain the ASC CDL implementor-oriented documentation by sending an email to: asc-cdl at theasc dot com Order of operations is Slope, Offset, Power, then Saturation. Example: >>> cc = ColorCorrection("shot_001") >>> cc.slope = [1.2, 1.1, 1.0] >>> cc.sat = 0.9 >>> print(cc.id) # "shot_001" """ members: dict[str, "ColorCorrection"] = {} """Class-level dictionary tracking all ColorCorrection instances by their ID. Used to enforce unique IDs and enable lookup of corrections by name. Automatically populated when ColorCorrection instances are created."""
[docs] def __init__(self, id: str, input_file: str | Path | None = None) -> None: # pylint: disable=W0622 """Initialize ColorCorrection with unique ID and optional input file. The ID must be unique among all ColorCorrection instances. If a duplicate ID is provided, behavior depends on the halt_on_error configuration setting. Args: id (str): Unique identifier for this color correction. Often a shot or sequence name. Will be sanitized to remove invalid characters. input_file (Optional[Union[str, Path]]): Optional path to the input file used to create this ColorCorrection. Can be string or Path. Raises: ValidationError: If ID is empty or duplicate (when halt_on_error is enabled). """ super().__init__() # File Attributes self._file_in: Path | None = ( Path(input_file).resolve() if input_file else None ) self._file_out: Path | None = None # If we're under a ColorCorrectionCollection or ColorDecision node: self.parent: Any | None = None # The id is really the only required part of a ColorCorrection node # Each ID should be unique id = _sanitize(id) if id in ColorCorrection.members.keys(): if config.config.halt_on_error: list(ColorCorrection.members.keys()) raise ValidationError( f'Duplicate ColorCorrection ID: "{id}" is already registered. ' f"Each ColorCorrection must have a unique ID." ) else: id = f"{id}{len([cc for cc in ColorCorrection.members if cc.startswith(id)]):0>3}" elif not id: if config.config.halt_on_error: raise ValidationError( "Empty ColorCorrection ID provided. " "ColorCorrections require a non-empty ID for identification." ) else: # Generate a unique numeric ID by finding the first available number counter = 1 while True: candidate_id = str(counter).rjust(3, "0") if candidate_id not in ColorCorrection.members: id = candidate_id break counter += 1 self._id = id # Register with member dictionary ColorCorrection.members[self._id] = self # ASC_SAT attribute self._sat_node: SatNode | None = None # ASC_SOP attributes self._sop_node: SopNode | None = None
# Properties ============================================================== @property def file_in(self) -> Path | None: """Return absolute path to the input file. Returns: Optional[Path]: Absolute path to input file, or None if no file was specified. """ return self._file_in @file_in.setter def file_in(self, value: str | Path | None) -> None: """Set input file path, converting to absolute path. Args: value (Optional[Union[str, Path]]): File path as string or Path object. Will be converted to absolute path. None clears the file path. """ if value: self._file_in = Path(value).resolve() @property def file_out(self) -> Path | None: """Return output file path for writing this ColorCorrection. Returns: Optional[Path]: Path where this ColorCorrection will be written, or None if no output path has been set. """ return self._file_out @property def has_sat(self) -> bool: """Return True if saturation node has been created. Returns: bool: True if a SatNode instance exists, False otherwise. """ if self._sat_node: return True else: return False @property def has_sop(self) -> bool: """Return True if SOP (slope/offset/power) node has been created. Returns: bool: True if a SopNode instance exists, False otherwise. """ if self._sop_node: return True else: return False @property def id(self) -> str: # pylint: disable=C0103 """Return unique identifier for this color correction. Returns: str: Unique ID string, often a shot or sequence name. """ return self._id @id.setter def id(self, value: str) -> None: # pylint: disable=C0103 """Set unique identifier after checking for duplicates. Args: value (str): New ID string. Must be unique among all ColorCorrections. Raises: ValidationError: If the new ID already exists in the members dictionary. """ self._set_id(value) @property def offset(self) -> tuple[Decimal, Decimal, Decimal]: """Return RGB offset values from the SOP node. Offset values raise or lower input brightness while holding slope constant. Returns: Tuple[Decimal, Decimal, Decimal]: RGB offset values as (R, G, B) tuple. """ return self.sop_node.offset @offset.setter def offset( self, offset_rgb: Decimal | float | int | str | list[Decimal | float | int | str] | tuple[Decimal | float | int | str, ...], ) -> None: """Set RGB offset values after validation and conversion. Args: offset_rgb: Single numeric value (applied to all RGB channels) or list/tuple of 3 numeric values for individual RGB channels. Accepts Decimal, float, int, or numeric string types. """ self.sop_node.offset = offset_rgb @property def power(self) -> tuple[Decimal, Decimal, Decimal]: """Return RGB power values from the SOP node. Power values change the response curve of the function. Note that this has the opposite response to adjustments than a traditional gamma operator. Returns: Tuple[Decimal, Decimal, Decimal]: RGB power values as (R, G, B) tuple. """ return self.sop_node.power @power.setter def power( self, power_rgb: Decimal | float | int | str | list[Decimal | float | int | str] | tuple[Decimal | float | int | str, ...], ) -> None: """Set RGB power values after validation and conversion. Args: power_rgb: Single numeric value (applied to all RGB channels) or list/tuple of 3 numeric values for individual RGB channels. Accepts Decimal, float, int, or numeric string types. Values must be non-negative. """ self.sop_node.power = power_rgb @property def sat_node(self) -> "SatNode": """Return SatNode instance, creating one if it doesn't exist. Returns: SatNode: The saturation node containing saturation value and descriptions. """ if not self._sat_node: self._sat_node = SatNode(self) return self._sat_node @property def slope(self) -> tuple[Decimal, Decimal, Decimal]: """Return RGB slope values from the SOP node. Slope values change the slope of the input without shifting the black level established by the offset. Returns: Tuple[Decimal, Decimal, Decimal]: RGB slope values as (R, G, B) tuple. """ return self.sop_node.slope @slope.setter def slope( self, slope_rgb: Decimal | float | int | str | list[Decimal | float | int | str] | tuple[Decimal | float | int | str, ...], ) -> None: """Set RGB slope values after validation and conversion. Args: slope_rgb: Single numeric value (applied to all RGB channels) or list/tuple of 3 numeric values for individual RGB channels. Accepts Decimal, float, int, or numeric string types. Values must be non-negative. """ self.sop_node.slope = slope_rgb @property def sop_node(self) -> "SopNode": """Return SopNode instance, creating one if it doesn't exist. Returns: SopNode: The SOP node containing slope, offset, and power values. """ if not self._sop_node: self._sop_node = SopNode(self) return self._sop_node @property def sat(self) -> Decimal: """Return saturation value from the saturation node. Returns: Decimal """ return self.sat_node.sat @sat.setter def sat(self, sat_value: Decimal | float | int | str) -> None: """Set saturation value after validation and conversion. Args: sat_value: Saturation value as Decimal, float, int, or numeric string. Must be non-negative. """ self.sat_node.sat = sat_value # Private Methods ========================================================= def _set_id(self, new_id: str) -> None: """Change ID after verifying the new ID is unique. Updates all references when ID changes: - ColorCorrection.members dictionary - ColorDecision.members dictionary for any ColorDecisions containing this CC - ColorCorrectionRef instances that reference this CC Args: new_id (str): New ID string to set. Raises: ValidationError: If the new ID already exists in the members dictionary. """ cc_id = _sanitize(new_id) # Check if this id is already registered if cc_id in ColorCorrection.members.keys(): list(ColorCorrection.members.keys()) raise ValidationError( f'Cannot change ID to "{cc_id}": ID already exists. ' f"Each ColorCorrection must have a unique ID. " f'Current ID: "{self._id}".' ) else: old_id = self._id # Clear the current id from the dictionary ColorCorrection.members.pop(self._id) self._id = cc_id # Register the new id with the dictionary ColorCorrection.members[self._id] = self # Update ColorDecision.members dictionary # Import here to avoid circular dependency from cdl_convert.decision import ColorCorrectionRef, ColorDecision if old_id in ColorDecision.members: # Move all ColorDecisions from old ID to new ID color_decisions = ColorDecision.members.pop(old_id) ColorDecision.members[cc_id] = color_decisions # Update all ColorCorrectionRef instances that reference this CC if old_id in ColorCorrectionRef.members: # Get all refs pointing to the old ID refs = ColorCorrectionRef.members[old_id].copy() # Update each ref's ID using _set_id to properly update the members dict # We use _set_id instead of the id setter to bypass validation that # checks if the ColorCorrection exists (it does, but under the new ID now) for ref in refs: ref._set_id(cc_id) # Public Methods ==========================================================
[docs] def get_color_values(self) -> ColorValues: """Get all color correction values as a ColorValues dataclass. Provides a convenient way to access all color correction values in a structured format with validation and utility methods. Returns: ColorValues: Dataclass instance containing current slope, offset, power, and saturation values. """ return ColorValues( slope=self.slope, offset=self.offset, power=self.power, saturation=self.sat, )
[docs] def set_color_values(self, values: ColorValues) -> None: """Set all color correction values from a ColorValues dataclass. Provides a convenient way to set all color correction values at once from a validated ColorValues instance. Args: values (ColorValues): ColorValues dataclass instance containing the slope, offset, power, and saturation values to set. Raises: ValidationError: If any values in the ColorValues instance fail validation (e.g., negative slope values). Example: >>> from decimal import Decimal >>> cc = ColorCorrection("test_id") >>> values = ColorValues( ... slope=(Decimal("1.2"), Decimal("1.1"), Decimal("1.0")), ... saturation=Decimal("0.9"), ... ) >>> cc.set_color_values(values) >>> print(cc.slope) """ self.slope = values.slope self.offset = values.offset self.power = values.power self.sat = values.saturation
[docs] def is_unity(self) -> bool: """Check if this color correction represents unity/identity values. Unity values represent no color correction applied. This is useful for determining if a ColorCorrection actually modifies the image. Returns: bool: True if all values are at unity (slope=1.0, offset=0.0, power=1.0, saturation=1.0), False otherwise. """ return self.get_color_values().is_unity()
[docs] def build_element(self) -> ElementTree.Element: """Build XML ElementTree Element representing this ColorCorrection. Creates a ColorCorrection XML element with ID attribute and includes any input/viewing descriptions, general descriptions, and SOP/SAT nodes. Returns: ElementTree.Element: XML element representing this ColorCorrection. """ cc_xml = ElementTree.Element("ColorCorrection") cc_xml.attrib = {"id": self.id} if self.input_desc: input_desc = ElementTree.SubElement(cc_xml, "InputDescription") input_desc.text = self.input_desc if self.viewing_desc: viewing_desc = ElementTree.SubElement(cc_xml, "ViewingDescription") viewing_desc.text = self.viewing_desc for description in self.desc: desc = ElementTree.SubElement(cc_xml, "Description") desc.text = description # We need to make sure we call the private attributes here, since # we don't want to trigger a virgin sop or sat being initialized. if self._sop_node: sop_element = self.sop_node.element if sop_element is not None: cc_xml.append(sop_element) if self._sat_node: sat_element = self.sat_node.element if sat_element is not None: cc_xml.append(sat_element) return cc_xml
# =========================================================================
[docs] def determine_dest(self, output: str, directory: str | Path) -> None: """Set output file path based on ID and output format. Constructs the output filename using the ColorCorrection ID and the specified output format extension. Args: output (str): File extension for output format (e.g., 'cc', 'ccc'). directory (Union[str, Path]): Directory path where output file will be written. """ filename = f"{self.id}.{output}" self._file_out = Path(directory) / filename
# =========================================================================
[docs] @classmethod def reset_members(cls) -> None: """Clear the class-level members dictionary. Removes all ColorCorrection instances from the members dictionary. Useful for testing or when starting with a clean state. """ cls.members = {}
# ==============================================================================
[docs] class SatNode(ColorNodeBase): """Container for saturation value and descriptions. The SatNode stores the saturation value which is the last operation applied in the CDL color correction process. Example: >>> cc = ColorCorrection("test") >>> cc.sat = 0.8 >>> print(cc.sat_node.sat) # Decimal('0.8') >>> print(cc.sat_node.parent.id) """ # XML Fields for SatNodes can be one of these names: element_names: list[str] = ["ASC_SAT", "SATNode", "SatNode"] """XML element names that map to this class during parsing ('ASC_SAT', 'SATNode', 'SatNode')."""
[docs] def __init__(self, parent: "ColorCorrection") -> None: super().__init__() self._parent: ColorCorrection = parent self._sat: Decimal = Decimal("1.0")
# Properties ============================================================== @property def parent(self) -> "ColorCorrection": """Return parent ColorCorrection instance that created this SatNode. Returns: ColorCorrection: The ColorCorrection instance that owns this SatNode. """ return self._parent @property def sat(self) -> Decimal: """Return saturation value. Returns: Decimal: Saturation value applied with Rec 709 coefficients. """ return self._sat @sat.setter def sat(self, value: Decimal | float | int | str) -> None: """Set saturation value after validation and conversion. Args: value (Union[Decimal, float, int, str]): Saturation value as numeric type or numeric string. Must be non-negative. Raises: ValidationError: If value is not numeric or fails validation checks. """ # If given as a string, the string must be convertible to a Decimal if type(value) in [Decimal, float, int, str]: try: value = self._check_single_value(value, "saturation") except (TypeError, ValueError): raise else: self._sat = Decimal(value) else: raise ValidationError( f"Invalid saturation value type: {type(value).__name__}. " f'Provided value: "{value}". ' f"Saturation must be a numeric value (int, float, str, or Decimal)." ) # Public Methods ==========================================================
[docs] def build_element(self) -> ElementTree.Element: """Build XML ElementTree Element representing this SatNode. Creates a Saturation XML element using the configured tag name from config.sat_tag_name. The element contains any descriptions and the saturation value. Returns: ElementTree.Element: XML element representing this SatNode. """ sat = ElementTree.Element(config.config.sat_tag_name) for description in self.desc: desc = ElementTree.SubElement(sat, "Description") desc.text = description op_node = ElementTree.SubElement(sat, "Saturation") op_node.text = _de_exponent(self.sat) return sat
# ==============================================================================
[docs] class SopNode(ColorNodeBase): """Container for slope, offset, and power values. The SopNode stores the slope, offset, and power values that form the core of the CDL color correction. Values are stored internally as lists but returned as tuples to prevent direct index modification and maintain data integrity. Example: >>> cc = ColorCorrection("test") >>> cc.slope = [1.2, 1.1, 1.0] >>> print(cc.sop_node.slope) >>> cc.offset = 0.1 # Applied to all RGB channels >>> print(cc.sop_node.offset) """ # XML Fields for SopNodes can be one of these names: element_names: list[str] = ["ASC_SOP", "SOPNode", "SopNode"] """XML element names that map to this class during parsing ('ASC_SOP', 'SOPNode', 'SopNode')."""
[docs] def __init__(self, parent: "ColorCorrection") -> None: super().__init__() self._parent: ColorCorrection = parent self._slope: list[Decimal] = [Decimal("1.0")] * 3 self._offset: list[Decimal] = [Decimal("0.0")] * 3 self._power: list[Decimal] = [Decimal("1.0")] * 3
# Properties ============================================================== @property def parent(self) -> "ColorCorrection": """Return parent ColorCorrection instance that created this SopNode. Returns: ColorCorrection: The ColorCorrection instance that owns this SopNode. """ return self._parent @property def slope(self) -> tuple[Decimal, Decimal, Decimal]: """Return RGB slope values as tuple. Returns: Tuple[Decimal, Decimal, Decimal]: RGB slope values as (R, G, B) tuple. Values change input slope without shifting black level. """ return cast(tuple[Decimal, Decimal, Decimal], tuple(self._slope)) @slope.setter def slope( self, value: Decimal | float | int | str | list[Decimal | float | int | str] | tuple[Decimal | float | int | str, ...], ) -> None: """Set RGB slope values after validation and conversion. Args: value: Single numeric value (applied to all RGB channels) or list/tuple of 3 numeric values for individual RGB channels. Accepts Decimal, float, int, or numeric string types. Values must be non-negative. """ self._slope = self._check_setter_value(value, "slope") @property def offset(self) -> tuple[Decimal, Decimal, Decimal]: """Return RGB offset values as tuple. Returns: Tuple[Decimal, Decimal, Decimal]: RGB offset values as (R, G, B) tuple. Values raise or lower input brightness while holding slope constant. """ return cast(tuple[Decimal, Decimal, Decimal], tuple(self._offset)) @offset.setter def offset( self, value: Decimal | float | int | str | list[Decimal | float | int | str] | tuple[Decimal | float | int | str, ...], ) -> None: """Set RGB offset values after validation and conversion. Args: value: Single numeric value (applied to all RGB channels) or list/tuple of 3 numeric values for individual RGB channels. Accepts Decimal, float, int, or numeric string types. Can be negative. """ self._offset = self._check_setter_value(value, "offset", True) @property def power(self) -> tuple[Decimal, Decimal, Decimal]: """Return RGB power values as tuple. Returns: Tuple[Decimal, Decimal, Decimal]: RGB power values as (R, G, B) tuple. Values change response curve with opposite behavior to traditional gamma. """ return cast(tuple[Decimal, Decimal, Decimal], tuple(self._power)) @power.setter def power( self, value: Decimal | float | int | str | list[Decimal | float | int | str] | tuple[Decimal | float | int | str, ...], ) -> None: """Set RGB power values after validation and conversion. Args: value: Single numeric value (applied to all RGB channels) or list/tuple of 3 numeric values for individual RGB channels. Accepts Decimal, float, int, or numeric string types. Values must be non-negative. """ self._power = self._check_setter_value(value, "power") # Private Methods ========================================================= def _check_rgb_values( self, values: list[Decimal | str | float | int] | tuple[Decimal | str | float | int, ...], name: str, negative_allow: bool = False, ) -> list[Decimal]: """Validate list or tuple containing exactly 3 RGB values. Ensures the provided values list contains exactly 3 numeric values and validates each value according to CDL requirements. Args: values (Union[List, Tuple]): List or tuple of 3 numeric values to validate. name (str): Type of values being checked (slope, offset, power). negative_allow (bool): Whether to allow negative values. Defaults to False. Returns: List[Decimal]: List of 3 validated values converted to Decimal type. Raises: ValidationError: If list length is not 3 or any values fail validation. """ if len(values) != 3: raise ValidationError( f"Invalid {name} values: expected 3 RGB values, got {len(values)}. " f"Provided values: {values}. " f"{name.title()} must specify exactly 3 values for Red, Green, and Blue channels." ) result: list[Decimal] = [] for value in values: try: checked_value = self._check_single_value( value, name, negative_allow ) result.append(checked_value) except (TypeError, ValueError): raise return result # ========================================================================= def _check_setter_value( self, value: Decimal | float | int | str | list[Decimal | float | int | str] | tuple[Decimal | float | int | str, ...], name: str, negative_allow: bool = False, ) -> list[Decimal]: """Validate and convert single value or RGB list for property setting. Handles both single values (applied to all RGB channels) and RGB lists/tuples. Coordinates validation between single value and RGB list validation methods. Args: value: Single numeric value (applied to all RGB channels) or list/tuple of 3 numeric values for individual RGB channels. name (str): Type of values being checked (slope, offset, power). negative_allow (bool): Whether to allow negative values. Defaults to False. Returns: List[Decimal]: List of 3 validated Decimal values ready for assignment. Raises: ValidationError: If value type is invalid or validation fails. """ if type(value) in [Decimal, float, int, str]: try: checked_value = self._check_single_value( cast(Decimal | float | int | str, value), name, negative_allow, ) except (TypeError, ValueError): raise else: return [checked_value] * 3 elif type(value) in [list, tuple]: try: return self._check_rgb_values( cast( list[Decimal | str | float | int] | tuple[Decimal | str | float | int, ...], value, ), name, negative_allow, ) except (TypeError, ValueError): raise else: raise ValidationError( f"Invalid {name} value type: {type(value).__name__}. " f'Provided value: "{value}". ' f"{name.title()} must be a numeric value or list of 3 numeric values. " f"Supported types: int, float, str, Decimal, list, or tuple." ) # Public Methods ==========================================================
[docs] def build_element(self) -> ElementTree.Element: """Build XML ElementTree Element representing this SopNode. Creates a SOP XML element using the configured tag name from config.sop_tag_name. The element contains any descriptions and the slope, offset, and power values formatted as space-separated strings. Returns: ElementTree.Element: XML element representing this SopNode. """ sop = ElementTree.Element(config.config.sop_tag_name) fields = ["Slope", "Offset", "Power"] for description in self.desc: desc = ElementTree.SubElement(sop, "Description") desc.text = description for i, grade in enumerate([self.slope, self.offset, self.power]): op_node = ElementTree.SubElement(sop, fields[i]) op_node.text = f"{_de_exponent(grade[0])} {_de_exponent(grade[1])} {_de_exponent(grade[2])}" return sop
# ============================================================================== # PRIVATE FUNCTIONS # ============================================================================== def _de_exponent(notation: Decimal | str | int | float) -> str: """Convert scientific notation to non-normalized decimal string. Converts numeric values that may be in scientific notation (e.g., 1.5e-3) to regular decimal string format (e.g., 0.0015). Unlike standard Decimal quantization methods, this function handles all cases reliably. Args: notation (Union[Decimal, str, int, float]): Numeric value that may or may not be in scientific notation format. Returns: str: Numeric value as string without scientific notation formatting. """ notation_str = str(notation).lower() if "e" not in notation_str: return notation_str parts = notation_str.split("e") # Grab the exponent value digits = int(parts[-1]) # Grab the value we'll be adding 0s to value = parts[0] if value.startswith("-"): negative = "-" value = value.removeprefix("-") else: negative = "" value = value.replace(".", "") if digits < 0: new_value = negative + "0.0" + "0" * (abs(digits) - 2) + value else: zeros = len(value) new_value = negative + value + "0" * (abs(digits) - zeros) + "0.0" return new_value # ============================================================================== def _sanitize(name: str) -> str: """Remove invalid characters from name string for use as CDL ID. Sanitizes strings to be valid CDL identifiers by replacing spaces with underscores, removing leading underscores/periods, and filtering out invalid XML characters. Preserves Unicode characters as they are valid in XML URIs per the ASC CDL specification. Args: name (str): String to sanitize for use as CDL identifier. Returns: str: Sanitized name string safe for use as CDL ID, or original string if it was empty. """ if not name: # If not name, it's probably an empty string, but let's throw back # exactly what we got. return name # Replace any spaces with underscores name = name.replace(" ", "_") # If we start our string with an underscore or period, remove it name = name.removeprefix("_").removeprefix(".") # Remove control characters and other invalid XML characters # This preserves Unicode letters, numbers, and common punctuation # while removing only truly problematic characters return re.sub(r"[\x00-\x1F\x7F<>&\"']+", "", name)