Coverage for /home/ubuntu/cv-depot/python/cv_depot/core/color.py: 100%
164 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 20:26 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 20:26 +0000
1from typing import Any, Optional, Union # noqa F401
2from numpy.typing import NDArray # noqa F401
4import json
5import math
6from copy import copy
7from enum import Enum
9from lunchbox.enforce import Enforce
10import numpy as np
12from cv_depot.core.enum import BitDepth
13# ------------------------------------------------------------------------------
16'''
17Color is a fundamentally a vector of length c, where c is number of channels.
18Colors can only be of bit depths supported by the BitDepth class.
19'''
22class BasicColor(Enum):
23 '''
24 A convenience enum for basic, common color vectors.
25 Legal colors include:
27 * BLACK
28 * WHITE
29 * GREY
30 * RED
31 * GREEN
32 * BLUE
33 * YELLOW
34 * MAGENTA
35 * CYAN
36 '''
37 BLACK = ('#000000', [0.0], [0.0, 0.0, 0.0])
38 WHITE = ('#FFFFFF', [1.0], [1.0, 1.0, 1.0])
39 GREY = ('#808080', [0.5], [0.5, 0.5, 0.5])
40 RED = ('#FF0000', None, [1.0, 0.0, 0.0])
41 GREEN = ('#00FF00', None, [0.0, 1.0, 0.0])
42 BLUE = ('#0000FF', None, [0.0, 0.0, 1.0])
43 YELLOW = ('#FFFF00', None, [1.0, 1.0, 0.0])
44 MAGENTA = ('#FF00FF', None, [1.0, 0.0, 1.0])
45 CYAN = ('#00FFFF', None, [0.0, 1.0, 1.0])
47 # henanigans
48 BG = ('#242424', [0.141], [0.141, 0.141, 0.141])
49 BLUE1 = ('#5F95DE', None, [0.373, 0.584, 0.871])
50 BLUE2 = ('#93B6E6', None, [0.576, 0.714, 0.902])
51 CYAN1 = ('#7EC4CF', None, [0.494, 0.769, 0.812])
52 CYAN2 = ('#B6ECF3', None, [0.714, 0.925, 0.953])
53 DARK1 = ('#040404', [0.016], [0.016, 0.016, 0.016])
54 DARK2 = ('#141414', [0.078], [0.078, 0.078, 0.078])
55 DIALOG1 = ('#444459', None, [0.267, 0.267, 0.349])
56 DIALOG2 = ('#5D5D7A', None, [0.365, 0.365, 0.478])
57 GREEN1 = ('#8BD155', None, [0.545, 0.82, 0.333])
58 GREEN2 = ('#A0D17B', None, [0.627, 0.82, 0.482])
59 GREY1 = ('#343434', [0.204], [0.204, 0.204, 0.204])
60 GREY2 = ('#444444', [0.267], [0.267, 0.267, 0.267])
61 LIGHT1 = ('#A4A4A4', [0.643], [0.643, 0.643, 0.643])
62 LIGHT2 = ('#F4F4F4', [0.957], [0.957, 0.957, 0.957])
63 ORANGE1 = ('#EB9E58', None, [0.922, 0.62, 0.345])
64 ORANGE2 = ('#EBB483', None, [0.922, 0.706, 0.514])
65 PURPLE1 = ('#C98FDE', None, [0.788, 0.561, 0.871])
66 PURPLE2 = ('#AC92DE', None, [0.675, 0.573, 0.871])
67 RED1 = ('#F77E70', None, [0.969, 0.494, 0.439])
68 RED2 = ('#DE958E', None, [0.871, 0.584, 0.557])
69 YELLOW1 = ('#E8EA7E', None, [0.91, 0.918, 0.494])
70 YELLOW2 = ('#E9EABE', None, [0.914, 0.918, 0.745])
72 def __init__(self, hexidecimal, one_channel, three_channel):
73 # type: (str, list[float], list[float]) -> None
74 '''
75 Args:
76 hexidecimal (str): Hexidecimal representation of color.
77 one_channel (list[float]): List with single float.
78 three_channel (list[float]): List with three floats.
80 Returns:
81 BasicColor: BasicColor instance.
82 '''
83 self._hexidecimal = hexidecimal
84 self._one_channel = one_channel
85 self._three_channel = three_channel
87 @staticmethod
88 def _get_color(
89 value, # type: Union[str, BasicColor, list[int], list[float]]
90 attr # type: str
91 ): # type: (...) -> BasicColor
92 '''
93 Finds BasicColor instance given a value and an attribute name.
95 Args:
96 value (str or BasicColor): BasicColor value to be looked up.
97 attr (str): Attribute name to be used in lookup.
99 Raises:
100 ValueError: If no BasicColor corresponds to given value.
102 Returns:
103 BasicColor: BasicColor instance.
104 '''
105 lut = {
106 json.dumps(getattr(x, attr)): x for x in BasicColor
107 } # type: dict[str, BasicColor]
109 if attr == 'string':
110 value = str(value).lower()
111 elif attr == 'hexidecimal':
112 value = str(value).upper()
114 key = json.dumps(value)
115 if key not in lut.keys():
116 msg = f'{value} is not a legal color.'
117 raise ValueError(msg)
118 return lut[key]
120 @staticmethod
121 def from_string(value):
122 # type: (str) -> BasicColor
123 '''
124 Contructs a BasicColor instance from a given string.
126 Args:
127 value (str): Name of color.
129 Returns:
130 BasicColor: BasicColor instance.
131 '''
132 return BasicColor._get_color(value, 'string')
134 @staticmethod
135 def from_list(value):
136 # type: (list) -> BasicColor
137 '''
138 Contructs a BasicColor instance from a given list of floats or ints.
140 Args:
141 value (list): BasicColor as list of numbers.
143 Returns:
144 BasicColor: BasicColor instance.
145 '''
146 value = list(map(float, value))
147 if len(value) == 1:
148 return BasicColor._get_color(value, 'one_channel')
149 elif len(value) == 3:
150 return BasicColor._get_color(value, 'three_channel')
152 msg = f'Invalid color value {value}. Must be 1 or 3 channels.'
153 raise ValueError(msg)
155 @staticmethod
156 def from_list_8_bit(value):
157 # type: (list[int]) -> BasicColor
158 '''
159 Contructs a BasicColor instance from a given list of ints between 0 and
160 255.
162 Args:
163 value (list): BasicColor as list of numbers.
165 Returns:
166 BasicColor: BasicColor instance.
167 '''
168 value_ = [x / 255 for x in value]
169 return BasicColor.from_list(value_)
171 @staticmethod
172 def from_hexidecimal(value):
173 # type: (str) -> BasicColor
174 '''
175 Contructs a BasicColor instance from a given hexidecimal string.
177 Args:
178 value (str): Hexidecimal value of color.
180 Returns:
181 BasicColor: BasicColor instance.
182 '''
183 return BasicColor._get_color(value, 'hexidecimal')
185 def __repr__(self):
186 # type: () -> str
187 return f'''
188<BasicColor.{self.name.upper()}>
189 string: {self.string}
190 hexidecimal: {self.hexidecimal}
191 one_channel: {self.one_channel}
192 three_channel: {self.three_channel}
193 one_channel_8_bit: {self.one_channel_8_bit}
194three_channel_8_bit: {self.three_channel_8_bit}'''[1:]
196 @property
197 def hexidecimal(self):
198 # type: () -> str
199 '''
200 str: Hexidecimal representation of color.
201 '''
202 return copy(self._hexidecimal)
204 @property
205 def one_channel(self):
206 # type: () -> list[float]
207 '''
208 list[float]: One channel, floating point representation of color.
209 '''
210 return copy(self._one_channel)
212 @property
213 def three_channel(self):
214 # type: () -> list[float]
215 '''
216 list[float]: Three channel, floating point representation of color.
217 '''
218 return copy(self._three_channel)
220 @property
221 def one_channel_8_bit(self):
222 # type: () -> list[int]
223 '''
224 list[int]: One channel, 8 bit representation of color.
225 '''
226 if self.one_channel is None:
227 return None
228 return [int(math.ceil(self.one_channel[0] * 255))]
230 @property
231 def three_channel_8_bit(self):
232 # type: () -> list[int]
233 '''
234 list[int]: Three channel, 8 bit representation of color.
235 '''
236 return [int(math.ceil(x * 255)) for x in self.three_channel]
238 @property
239 def string(self):
240 # type: () -> str
241 '''
242 string: String representation of color.
243 '''
244 return self.name.lower()
245# ------------------------------------------------------------------------------
248class Color:
249 '''
250 Makes working with color vectors easy.
251 '''
252 bit_depth = BitDepth.FLOAT32
254 def __init__(self, data):
255 # type: (NDArray) -> None
256 '''
257 Constructs a Color instance.
259 Args:
260 data (numpy.NDArray): A 1 dimensional numpy array of shape (n,).
262 Raises:
263 TypeError: If data is not a numpy array.
264 AttributeError: If data's number of dimensions is not 1.
266 Returns:
267 Color: Color instance.
268 '''
269 if not isinstance(data, np.ndarray):
270 msg = 'Data must be a numpy array.'
271 raise TypeError(msg)
273 if data.ndim != 1:
274 msg = 'Arrays must be one dimensional, so that its shape is (n,). '
275 msg += f'Given array has {data.ndim} dimensions and a shape of '
276 msg += f'{data.shape}.'
277 raise AttributeError(msg)
279 # checks for legal bit depth
280 bit_depth = BitDepth.from_dtype(data.dtype)
281 data = data.astype(self.bit_depth.dtype)
283 # 8 bit conversion
284 if bit_depth is BitDepth.INT8:
285 data = (data + 127) / 255
286 elif bit_depth is BitDepth.UINT8:
287 data = data / 255
289 self._data = data
291 @staticmethod
292 def from_array(data, num_channels=None, fill_value=0):
293 # type: (NDArray, Optional[int], float) -> Color
294 '''
295 Contructs a Color instance from a given numpy array.
297 Args:
298 data (numpy.NDArray): Numpy array.
299 num_channels (int, optional): Number of desired channels in the
300 Color instance. Number of channels equals len(data) if set to
301 None. Default: None.
302 fill_value (float, optional): Value used to fill additional
303 channels. Default: 0.
305 Returns:
306 Color: Color instance of given data.
307 '''
308 if num_channels is not None:
309 Enforce(num_channels, 'instance of', int)
310 Enforce(num_channels, '>=', 1)
312 diff = num_channels - len(data)
313 if diff > 0:
314 buff = np.ones(diff, data.dtype) * fill_value
315 data = np.append(data, buff)
316 data = data[:num_channels]
317 return Color(data)
319 @staticmethod
320 def from_list(
321 data, num_channels=None, fill_value=0, bit_depth=BitDepth.FLOAT32
322 ):
323 # type: (NDArray, Optional[int], float, BitDepth) -> Color
324 '''
325 Contructs a Color instance from a given list of ints or floats.
327 Args:
328 data (list): List of ints or floats.
329 num_channels (int, optional): Number of desired channels in the
330 Color instance. Number of channels equals len(data) if set to
331 None. Default: None.
332 fill_value (float, optional): Value used to fill additional
333 channels. Default: 0.
334 bit_depth (BitDepth, optional): Bit depth of given list.
335 Default: BitDepth.FLOAT32.
337 Returns:
338 Color: Color instance of given data.
339 '''
340 data = np.array(data, dtype=bit_depth.dtype)
341 return Color.from_array(
342 data, num_channels=num_channels, fill_value=fill_value
343 )
345 @staticmethod
346 def from_basic_color(data, num_channels=3, fill_value=0.0):
347 # type: (BasicColor, int, float) -> Color
348 '''
349 Contructs a Color instance from a BasicColor enum.
351 Args:
352 data (BasicColor): BasicColor enum.
353 num_channels (int, optional): Number of desired channels in the
354 Color instance. Default: 3.
355 fill_value (float, optional): Value used to fill additional
356 channels. Default: 0.
358 Raises:
359 EnforceError: If num_channels is less than 1.
360 EnforceError: If no shape is one channel and no one channel
361 equivalent of the given color could be found.
363 Returns:
364 Color: Color instance of given data.
365 '''
366 msg = 'num_channels must be greater than or equal to 1. {a} < {b}.'
367 Enforce(num_channels, '>=', 1, message=msg)
369 output = data # type: Any
370 if num_channels == 1:
371 msg = f'No one channel equivalent found for given color: {data}.'
372 Enforce(data.one_channel, '!=', None, message=msg)
373 output = data.one_channel
374 else:
375 output = data.three_channel
377 return Color.from_list(
378 output,
379 num_channels=num_channels,
380 fill_value=fill_value,
381 bit_depth=BitDepth.FLOAT32
382 )
384 def __repr__(self):
385 # type: () -> str
386 '''
387 Represention of Color instance includes:
389 * values - actual numpy array
390 * bit_depth - always FLOAT32
391 * num_channels - number of color channels
392 * name - displays only if the color values corresponds to a
393 BasicColor
394 '''
395 output = [
396 '<Color>',
397 f' values: {self._data}',
398 f' bit_depth: {self.bit_depth.name.upper()}',
399 f'num_channels: {self.num_channels}',
400 ] # type: Any
402 try:
403 items = self.to_array().tolist()
404 name = BasicColor.from_list(items).string # type: ignore
405 output.append(f' name: {name}')
406 except Exception:
407 pass
409 output = '\n'.join(output)
410 return output
412 @property
413 def num_channels(self):
414 # type: () -> int
415 '''
416 Number of channels in color vector.
417 '''
418 return len(self._data)
420 def to_array(self, bit_depth=BitDepth.FLOAT32):
421 # type: (BitDepth) -> NDArray
422 '''
423 Returns color as a numpy array at a given bit depth.
425 Args:
426 bit_depth (BitDepth, optional): Bit depth of output.
427 Default: BitDepth.FLOAT32.
429 Returns:
430 numpy.NDArray: Color vector as numpy array.
431 '''
432 output = self._data
433 if bit_depth is BitDepth.INT8:
434 output = np.ceil(output * 255) - 128
435 elif bit_depth is BitDepth.UINT8:
436 output = np.ceil(output * 255)
437 return output.astype(bit_depth.dtype)