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

1from typing import Any, Optional, Union # noqa F401 

2from numpy.typing import NDArray # noqa F401 

3 

4import json 

5import math 

6from copy import copy 

7from enum import Enum 

8 

9from lunchbox.enforce import Enforce 

10import numpy as np 

11 

12from cv_depot.core.enum import BitDepth 

13# ------------------------------------------------------------------------------ 

14 

15 

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''' 

20 

21 

22class BasicColor(Enum): 

23 ''' 

24 A convenience enum for basic, common color vectors. 

25 Legal colors include: 

26 

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]) 

46 

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]) 

71 

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. 

79 

80 Returns: 

81 BasicColor: BasicColor instance. 

82 ''' 

83 self._hexidecimal = hexidecimal 

84 self._one_channel = one_channel 

85 self._three_channel = three_channel 

86 

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. 

94 

95 Args: 

96 value (str or BasicColor): BasicColor value to be looked up. 

97 attr (str): Attribute name to be used in lookup. 

98 

99 Raises: 

100 ValueError: If no BasicColor corresponds to given value. 

101 

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] 

108 

109 if attr == 'string': 

110 value = str(value).lower() 

111 elif attr == 'hexidecimal': 

112 value = str(value).upper() 

113 

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] 

119 

120 @staticmethod 

121 def from_string(value): 

122 # type: (str) -> BasicColor 

123 ''' 

124 Contructs a BasicColor instance from a given string. 

125 

126 Args: 

127 value (str): Name of color. 

128 

129 Returns: 

130 BasicColor: BasicColor instance. 

131 ''' 

132 return BasicColor._get_color(value, 'string') 

133 

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. 

139 

140 Args: 

141 value (list): BasicColor as list of numbers. 

142 

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') 

151 

152 msg = f'Invalid color value {value}. Must be 1 or 3 channels.' 

153 raise ValueError(msg) 

154 

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. 

161 

162 Args: 

163 value (list): BasicColor as list of numbers. 

164 

165 Returns: 

166 BasicColor: BasicColor instance. 

167 ''' 

168 value_ = [x / 255 for x in value] 

169 return BasicColor.from_list(value_) 

170 

171 @staticmethod 

172 def from_hexidecimal(value): 

173 # type: (str) -> BasicColor 

174 ''' 

175 Contructs a BasicColor instance from a given hexidecimal string. 

176 

177 Args: 

178 value (str): Hexidecimal value of color. 

179 

180 Returns: 

181 BasicColor: BasicColor instance. 

182 ''' 

183 return BasicColor._get_color(value, 'hexidecimal') 

184 

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:] 

195 

196 @property 

197 def hexidecimal(self): 

198 # type: () -> str 

199 ''' 

200 str: Hexidecimal representation of color. 

201 ''' 

202 return copy(self._hexidecimal) 

203 

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) 

211 

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) 

219 

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))] 

229 

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] 

237 

238 @property 

239 def string(self): 

240 # type: () -> str 

241 ''' 

242 string: String representation of color. 

243 ''' 

244 return self.name.lower() 

245# ------------------------------------------------------------------------------ 

246 

247 

248class Color: 

249 ''' 

250 Makes working with color vectors easy. 

251 ''' 

252 bit_depth = BitDepth.FLOAT32 

253 

254 def __init__(self, data): 

255 # type: (NDArray) -> None 

256 ''' 

257 Constructs a Color instance. 

258 

259 Args: 

260 data (numpy.NDArray): A 1 dimensional numpy array of shape (n,). 

261 

262 Raises: 

263 TypeError: If data is not a numpy array. 

264 AttributeError: If data's number of dimensions is not 1. 

265 

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) 

272 

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) 

278 

279 # checks for legal bit depth 

280 bit_depth = BitDepth.from_dtype(data.dtype) 

281 data = data.astype(self.bit_depth.dtype) 

282 

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 

288 

289 self._data = data 

290 

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. 

296 

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. 

304 

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) 

311 

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) 

318 

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. 

326 

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. 

336 

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 ) 

344 

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. 

350 

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. 

357 

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. 

362 

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) 

368 

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 

376 

377 return Color.from_list( 

378 output, 

379 num_channels=num_channels, 

380 fill_value=fill_value, 

381 bit_depth=BitDepth.FLOAT32 

382 ) 

383 

384 def __repr__(self): 

385 # type: () -> str 

386 ''' 

387 Represention of Color instance includes: 

388 

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 

401 

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 

408 

409 output = '\n'.join(output) 

410 return output 

411 

412 @property 

413 def num_channels(self): 

414 # type: () -> int 

415 ''' 

416 Number of channels in color vector. 

417 ''' 

418 return len(self._data) 

419 

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. 

424 

425 Args: 

426 bit_depth (BitDepth, optional): Bit depth of output. 

427 Default: BitDepth.FLOAT32. 

428 

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)