src/open_vp_cal/core/utils.py (191 lines of code) (raw):
"""
Copyright 2024 Netflix Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Utility functions for open_vp_cal
"""
import re
from typing import Tuple, Union, List
import numpy as np
import colour
from colour import SpectralShape
from open_vp_cal.core import constants
from open_vp_cal.core.constants import PQ, CAT, CameraColourSpace
def nits_to_pq(nits: int) -> float:
"""
Convert nits (luminance in cd/m^2) to Perceptual Quantizer (PQ) non-linear signal value.
Parameters:
nits (float): Luminance in nits (cd/m^2).
Returns:
float: Corresponding PQ non-linear signal value.
"""
FD = nits
Y = FD / PQ.PQ_MAX_NITS
E = ((PQ.PQ_C1 + PQ.PQ_C2 * Y ** PQ.PQ_M1) / (1 + PQ.PQ_C3 * Y ** PQ.PQ_M1)) ** PQ.PQ_M2
return E
def pq_to_nits(pq_value: float) -> float:
"""
Convert PQ non-linear signal value to nits (luminance in cd/m^2).
Parameters:
pq_value (float): PQ non-linear signal value.
Returns:
float: Corresponding luminance in nits (cd/m^2).
"""
E = pq_value
FD = PQ.PQ_MAX_NITS * ((max((E ** (1 / PQ.PQ_M2) - PQ.PQ_C1), 0)) /
(PQ.PQ_C2 - PQ.PQ_C3 * E ** (1 / PQ.PQ_M2))) ** (1 / PQ.PQ_M1)
return FD
def scale_value(input_value, input_min, input_max, output_min, output_max):
"""
Scales a given input value from one range to another.
Args:
input_value (float): The value to be scaled.
input_min (float): The minimum value of the original range.
input_max (float): The maximum value of the original range.
output_min (float): The minimum value of the target range.
output_max (float): The maximum value of the target range.
Returns:
float: The scaled value.
"""
span = input_max - input_min
scaled_value = output_min + ((input_value - input_min) / span) * (output_max - output_min)
return scaled_value
def normalize(value, min_val, max_val):
"""
Normalizes a value to a given range
Args:
value: The value to be normalized
min_val: The minimum value of the range
max_val: The maximum value of the range
Returns: The normalised value
"""
return (value - min_val) / (max_val - min_val)
def get_grey_signals(target_max_lum_nits, num_grey_patches) -> list[float]:
"""
Generates a list of grey signal values based on the target maximum luminance and the number of grey patches.
Args:
target_max_lum_nits (int): The target maximum luminance in nits (cd/m^2).
num_grey_patches (int): The number of grey patches.
Returns:
list[float]: A list of grey signal values from 0 to target_max_lum_nits, scaled so that 1 is 100 nits.
"""
grey_signals = []
max_nits_pq = nits_to_pq(target_max_lum_nits)
pq_value_per_patch = max_nits_pq / num_grey_patches
for idx in range(0, num_grey_patches + 1):
patch_pq_value = idx * pq_value_per_patch
patch_nits = pq_to_nits(patch_pq_value)
grey_signals.append(patch_nits * 0.01)
return grey_signals
def stack_numpy_array(img_np: "np.Array") -> ("np.Array", int, int, int):
""" Stack the numpy array to be 3 channels
Args:
img_np: The numpy array to stack
Returns: The stacked numpy array, the height & width of the image along with the number of channels
"""
height, width, channels = None, None, None
if len(img_np.shape) == 3:
height, width, channels = img_np.shape
elif len(img_np.shape) == 2:
height, width = img_np.shape
channels = 1
img_np = np.stack((img_np,) * 3, axis=-1) # make it a 3-channel image
return img_np, height, width, channels
def get_legal_and_extended_values(peak_lum: int,
image_bit_depth: int = 10,
use_pq_peak_luminance: bool = True) -> tuple[int, int, int, int]:
""" Get the legal and extended values for a given peak luminance and the bit depth
Args:
peak_lum: The peak luminance of the LED wall or display
image_bit_depth: The bit depth of the image
use_pq_peak_luminance: Whether to use the PQ peak luminance or not
Returns: A tuple containing the minimum legal code value,
the maximum legal code value, the minimum extended code
"""
min_code_value = 0
max_code_value = (2 ** image_bit_depth) - 1
minimum_legal_code_value = int((2 ** image_bit_depth) / 16.0)
maximum_legal_code_value = int((2 ** (image_bit_depth - 8)) * (16 + 219))
# If we are in a PQ HDR workflow, our maximum nits are limited by our LED panels
if use_pq_peak_luminance:
pq_v = nits_to_pq(peak_lum)
full_peak_white_at_given_bit_depth = pq_v * max_code_value
legal_white_at_given_bit_depth = (full_peak_white_at_given_bit_depth / max_code_value *
(maximum_legal_code_value - minimum_legal_code_value) +
minimum_legal_code_value)
legal_white_for_peak_luminance = legal_white_at_given_bit_depth
maximum_legal_code_value = legal_white_for_peak_luminance
minimum_legal = normalize(minimum_legal_code_value, min_code_value, max_code_value)
maximum_legal = normalize(maximum_legal_code_value, min_code_value, max_code_value)
minimum_extended = normalize(min_code_value, min_code_value, max_code_value)
maximum_extended = normalize(max_code_value, min_code_value, max_code_value)
return minimum_legal, maximum_legal, minimum_extended, maximum_extended
def get_target_colourspace_for_led_wall(led_wall: "LedWallSettings") -> colour.RGB_Colourspace:
""" Gets the target colour space for the given led wall based on the target gamut
If its standard gamut we return this directly from colour
If its custom gamut we create a custom colour space from the primaries and white point
Args:
led_wall: The led wall to get the target colour space for
Returns: The target colour space
"""
try:
color_space = colour.RGB_COLOURSPACES[led_wall.target_gamut]
except KeyError:
custom_primaries = led_wall.project_settings.project_custom_primaries[led_wall.target_gamut]
color_space = get_custom_colour_space_from_primaries_and_wp(led_wall.target_gamut, custom_primaries)
return color_space
def get_native_camera_colourspace_for_led_wall(led_wall: "LedWallSettings") -> colour.RGB_Colourspace:
""" Gets the native camera colour space for the given led wall based on the native camera gamut
If its standard gamut we return this directly from colour
If its custom gamut we create a custom colour space from the primaries and white point
Args:
led_wall: The led wall to get the target colour space for
Returns: The target colour space
"""
try:
color_space = colour.RGB_COLOURSPACES[led_wall.native_camera_gamut]
except KeyError:
custom_primaries = led_wall.project_settings.project_custom_primaries[led_wall.native_camera_gamut]
color_space = get_custom_colour_space_from_primaries_and_wp(led_wall.native_camera_gamut, custom_primaries)
return color_space
def get_custom_colour_space_from_primaries_and_wp(custom_name: str, values: List[List]) -> colour.RGB_Colourspace:
""" Creates a custom colour space from the given primaries and white point
Args:
custom_name: The name of the custom colour space
values: The values of the primaries and white point
Returns: The custom colour space
"""
if len(values) != 4:
raise ValueError("Must provide 4 tuples for 3 primaries and 1 white point")
white_point = values[-1]
primaries = values[:3]
return colour.RGB_Colourspace(custom_name, primaries, white_point)
def get_primaries_and_wp_for_XYZ_matrix(XYZ_matrix) -> Tuple[np.array, np.array]:
""" Get the primaries and white point for the given XYZ matrix """
primaries, wp = colour.primaries_whitepoint(XYZ_matrix)
return primaries, wp
def replace_non_alphanumeric(input_string, replace_char):
"""
Replace any non-alphanumeric characters in a string with a given character.
Args:
input_string (str): The input string.
replace_char (str): The character to replace non-alphanumeric characters with.
Returns:
The modified string.
"""
return re.sub(r'\W+', replace_char, input_string)
def generate_color(name) -> Tuple[int, int, int]:
""" Generates a colour based on the given name.
Args:
name: The name to generate a colour for.
Returns: A list of 3 ints representing the RGB colour.
"""
name_hash = hash(name)
red = abs((name_hash * 23) % 256)
green = abs((name_hash * 37) % 256)
blue = abs((name_hash * 51) % 256)
return red, green, blue
def led_wall_reference_wall_sort(led_walls: list["LedWallSettings"]) -> list["LedWallSettings"]:
""" Sorts the given list of led_walls so that they are processed in the correct order based on their references
Args:
led_walls: A list of led walls to sort
Returns: The same list of led walls in the correct order for processing based on their external references to other
walls
"""
visited = {led_wall.name: False for led_wall in led_walls}
stack = []
def visit(instance):
if not visited[instance.name]:
visited[instance.name] = True
if instance.reference_wall:
ref_wall = instance.project_settings.get_led_wall(instance.reference_wall)
visit(ref_wall)
stack.append(instance)
for led_wall in led_walls:
if not visited[led_wall.name]:
visit(led_wall)
return stack
def calculate_validation_status(current_status, result):
""" Calculates the validation status based on the current status and the result
Args:
current_status: The current status
result: The result status
Returns: The new status
"""
values = {
constants.ValidationStatus.PASS: 2,
constants.ValidationStatus.WARNING: 1,
constants.ValidationStatus.FAIL: 0
}
current_status_val = values[current_status]
result_val = values[result]
if result_val < current_status_val:
return result
return current_status
def get_spectral_locus_positions(scale: int) -> Tuple[np.array, np.array]:
""" Get the positions of the spectral locus in xy space, scaled by the given factor
Args:
scale: The scale to apply to the spectral locus
Returns: The x and y positions of the spectral locus
"""
spectral_locus_values = SpectralShape(390, 780, 0.1).range()
XYZ = colour.wavelength_to_XYZ(spectral_locus_values)
values = colour.XYZ_to_xy(XYZ)
values = values * scale
spectral_locus_x = values[:, 0]
spectral_locus_y = values[:, 1]
return np.append(spectral_locus_x, spectral_locus_x[0]), np.append(spectral_locus_y, spectral_locus_y[0])
def get_planckian_locus_positions(scale: int) -> tuple[list[float], list[float]]:
""" Get the positions of the planckian locus in xy space, scaled by the given factor
Args:
scale: The scale to apply to the planckian locus
Returns: The x and y positions of the planckian locus
"""
x_pos = []
y_pos = []
for i in range(2500, 10001, 100):
cie_xy = colour.temperature.CCT_to_xy_CIE_D(i)
scaled_x = float(cie_xy[0]) * scale
scaled_y = float(cie_xy[1]) * scale
x_pos.append(scaled_x)
y_pos.append(scaled_y)
return x_pos, y_pos
def is_point_inside_polygon(point: Tuple[float, float], polygon: np.array) -> bool:
"""
Checks if a given point of x & y coordinates is inside a given polygon, an array of points which form a closed shape
of connecting edges
Args:
point: The point to check
polygon: The polygon to check against
Returns: True if the point is inside the polygon, False otherwise
"""
x_pos, y_pos = point
odd_nodes = False
j = len(polygon) - 1 # Last vertex in the polygon
for idx in range(0, len(polygon)):
xi, yi = polygon[idx]
xj, yj = polygon[j]
if yi < y_pos <= yj or yj < y_pos <= yi:
if xi + (y_pos - yi) / (yj - yi) * (xj - xi) < x_pos:
odd_nodes = not odd_nodes
j = idx
return odd_nodes
def find_factors_pairs(input_num: int) -> List[Tuple[int, int]]:
""" Find the factor pairs for the given number
Args:
input_num: The number to find the factor pairs for
Returns: A list of factor pairs
"""
factor_pairs = []
for i in range(4, int(input_num ** 0.5) + 1): # start loop from 4
if input_num % i == 0:
factor_pairs.append((i, input_num // i))
return factor_pairs
def find_nearest_factors_for_ratio(n, ratio_width=16, ratio_height=9) -> Tuple[int, int]:
""" Find the nearest factors for the given ratio
Args:
n: The number to find the factors for
ratio_width: The width of the ratio
ratio_height: The height of the ratio
Returns: The nearest factors for the given ratio
"""
if n % 2 != 0:
n += 1
best_a = 4
best_b = n // 4
min_difference = float("inf")
for pair in find_factors_pairs(n):
a_item, b_item = pair
difference = abs(ratio_width / ratio_height - a_item / b_item)
if difference < min_difference:
best_a = a_item
best_b = b_item
min_difference = difference
return best_a, best_b
def split_list(input_list: List, split_factor: int) -> List[List]:
""" For the given input list, we split it into split_factor number of approximately equal parts
Args:
input_list: the list we want to split
split_factor: the amount we want to split the lists by
Returns: A list of lists each the size of split_factor, unless the input list is not divisible by split_factor,
in which case the last list will be smaller
"""
avg = len(input_list) // split_factor
leftovers = len(input_list) % split_factor
parts = []
start = 0
for i in range(split_factor):
end = start + avg + (1 if i < leftovers else 0)
parts.append(input_list[start:end])
start = end
return parts
def clamp(value: Union[int, float], min_value: Union[int, float], max_value: Union[int, float]) -> Union[int, float]:
""" Clamp a value between a min and max value
Args:
value: value to clamp
min_value: The minimum value
max_value: The maximum value
Returns: The clamped value
"""
return max(min_value, min(value, max_value))
def create_white_balance_matrix(input_rgb_sample: np.ndarray):
""" Creates a white balance matrix from the input RGB sample, by using the red and blue multiplies, based on the
green channel.
Args:
input_rgb_sample: An array of RGB values in the form [R,G,B]
Returns: A 3x3 matrix which can be used to white balance the input RGB values
"""
green_value = input_rgb_sample[1]
# Green Value / Red Value
red_mult_val = green_value / input_rgb_sample[0]
green_mult_val = green_value / input_rgb_sample[1]
# Green Value / Blue Value
blue_mult_val = green_value / input_rgb_sample[2]
white_balance_matrix = np.asarray([[red_mult_val, 0.0, 0.0], [0.0, green_mult_val, 0.0], [0.0, 0.0, blue_mult_val]])
return white_balance_matrix
def get_cat_for_camera_conversion(camera_colour_space_name: str) -> CAT:
""" For the given camera colour space, return the appropriate chromatic adaptation transform method
Args:
camera_colour_space_name: The name of the camera colour space
Returns: The chromatic adaptation transform method
"""
camera_conversion_cat = CAT.CAT_CAT02
if camera_colour_space_name == CameraColourSpace.RED_WIDE_GAMUT:
camera_conversion_cat = CAT.CAT_BRADFORD
return camera_conversion_cat