Skip to content

Commit 21a8d43

Browse files
authored
feat: add flexible integer conversion (#68)
* feat: add flexible integer conversion * remove method, coverage * fix doc
1 parent 16a0937 commit 21a8d43

File tree

2 files changed

+169
-6
lines changed

2 files changed

+169
-6
lines changed

src/cmap/_color.py

Lines changed: 140 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
Any,
1010
Callable,
1111
Iterable,
12+
Literal,
1213
NamedTuple,
1314
Sequence,
1415
SupportsFloat,
@@ -34,6 +35,8 @@
3435
from pydantic_core import CoreSchema
3536
from typing_extensions import TypeAlias
3637

38+
rgba = Literal["r", "g", "b", "a"]
39+
3740
# not used internally... but available for typing
3841
RGBTuple: TypeAlias = "tuple[int, int, int] | tuple[float, float, float]"
3942
RGBATuple: TypeAlias = (
@@ -291,8 +294,100 @@ def _norm_name(name: str) -> str:
291294
return delim.sub("", name).lower()
292295

293296

297+
def _ensure_format(format: str) -> Sequence[rgba]:
298+
_format = "".join(format).lower()
299+
if not all(c in "rgba" for c in _format):
300+
raise ValueError("Format must be composed of 'r', 'g', 'b', and 'a'")
301+
return _format # type: ignore [return-value]
302+
303+
304+
def parse_int(
305+
value: int,
306+
format: str,
307+
bits_per_component: int | Sequence[int] = 8,
308+
) -> RGBA:
309+
"""Parse color from bit-shifted integer encoding.
310+
311+
Parameters
312+
----------
313+
value : int
314+
The integer value to parse.
315+
format : str
316+
The format of the integer value. Must be a string composed only of
317+
the characters 'r', 'g', 'b', and 'a'.
318+
bits_per_component : int | Sequence[int] | None
319+
The number of bits used to represent each color component. If a single
320+
integer is provided, it is used for all components. If a sequence of
321+
integers is provided, the length must match the length of `format`.
322+
"""
323+
fmt = _ensure_format(format)
324+
if isinstance(bits_per_component, int):
325+
bits_per_component = [bits_per_component] * len(fmt)
326+
elif len(bits_per_component) != len(fmt): # pragma: no cover
327+
raise ValueError("Length of 'bits_per_component' must match 'format'")
328+
329+
components: dict[str, float] = {"r": 0, "g": 0, "b": 0, "a": 1}
330+
shift = 0
331+
332+
# Calculate the starting shift amount
333+
for bits in reversed(bits_per_component):
334+
shift += bits
335+
336+
# Parse each component from the integer value
337+
for i, comp in enumerate(fmt):
338+
shift -= bits_per_component[i]
339+
mask = (1 << bits_per_component[i]) - 1
340+
components[comp] = ((value >> shift) & mask) / mask
341+
342+
return RGBA(**components)
343+
344+
345+
def to_int(
346+
color: RGBA,
347+
format: str,
348+
bits_per_component: int | Sequence[int] = 8,
349+
) -> int:
350+
"""Convert color to bit-shifted integer encoding.
351+
352+
Parameters
353+
----------
354+
color : RGBA
355+
The color to convert.
356+
format : str
357+
The format of the integer value. Must be a string composed only of
358+
the characters 'r', 'g', 'b', and 'a'.
359+
bits_per_component : int | Sequence[int] | None
360+
The number of bits used to represent each color component. If a single
361+
integer is provided, it is used for all components. If a sequence of
362+
integers is provided, the length must match the length of `format`.
363+
"""
364+
fmt = _ensure_format(format)
365+
if isinstance(bits_per_component, int):
366+
bits_per_component = [bits_per_component] * len(fmt)
367+
elif len(bits_per_component) != len(fmt): # pragma: no cover
368+
raise ValueError("Length of 'bits_per_component' must match 'format'")
369+
370+
value = 0
371+
shift = 0
372+
373+
# Calculate the starting shift amount
374+
for bits in reversed(bits_per_component):
375+
shift += bits
376+
377+
# Parse each component from the integer value
378+
for i, comp in enumerate(fmt):
379+
shift -= bits_per_component[i]
380+
mask = (1 << bits_per_component[i]) - 1
381+
value |= int(getattr(color, comp) * mask) << shift
382+
383+
return value
384+
385+
294386
def parse_rgba(value: Any) -> RGBA:
295387
"""Parse a color."""
388+
if isinstance(value, RGBA):
389+
return value
390+
296391
# parse hex, rgb, rgba, hsl, hsla, and color name strings
297392
if isinstance(value, str):
298393
key = _norm_name(value)
@@ -337,11 +432,8 @@ def parse_rgba(value: Any) -> RGBA:
337432
return value._rgba
338433

339434
if isinstance(value, int):
340-
# convert 24-bit integer to RGBA8 with bit shifting
341-
r = (value >> 16) & 0xFF
342-
g = (value >> 8) & 0xFF
343-
b = value & 0xFF
344-
return RGBA8(r, g, b).to_float()
435+
# assume RGB24, use parse_int to explicitly pass format and bits_per_component
436+
return parse_int(value, "rgb")
345437

346438
# support for pydantic.color.Color
347439
for mod in ("pydantic", "pydantic_extra_types"):
@@ -388,6 +480,49 @@ def __new__(cls, value: Any) -> Color:
388480
_COLOR_CACHE[rgba] = obj
389481
return _COLOR_CACHE[rgba]
390482

483+
@classmethod
484+
def from_int(
485+
cls,
486+
value: int,
487+
format: str,
488+
bits_per_component: int | Sequence[int] = 8,
489+
) -> Color:
490+
"""Parse color from bit-shifted integer encoding.
491+
492+
Parameters
493+
----------
494+
value : int
495+
The integer value to parse.
496+
format : str
497+
The format of the integer value. Must be a string composed only of
498+
the characters 'r', 'g', 'b', and 'a'.
499+
bits_per_component : int | Sequence[int] | None
500+
The number of bits used to represent each color component. If a single
501+
integer is provided, it is used for all components. If a sequence of
502+
integers is provided, the length must match the length of `format`.
503+
"""
504+
rgba = parse_int(value, format=format, bits_per_component=bits_per_component)
505+
return cls(rgba)
506+
507+
def to_int(
508+
self,
509+
format: str,
510+
bits_per_component: int | Sequence[int] = 8,
511+
) -> int:
512+
"""Convert color to bit-shifted integer encoding.
513+
514+
Parameters
515+
----------
516+
format : str
517+
The format of the integer value. Must be a string composed only of
518+
the characters 'r', 'g', 'b', and 'a'.
519+
bits_per_component : int | Sequence[int] | None
520+
The number of bits used to represent each color component. If a single
521+
integer is provided, it is used for all components. If a sequence of
522+
integers is provided, the length must match the length of `format`.
523+
"""
524+
return to_int(self._rgba, format=format, bits_per_component=bits_per_component)
525+
391526
# for mkdocstrings
392527
def __init__(self, value: ColorLike) -> None:
393528
pass

tests/test_color.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import numpy as np
44
import pytest
55

6-
from cmap._color import RGBA, RGBA8, Color
6+
from cmap._color import RGBA, RGBA8, Color, parse_int
77

88
try:
99
import colour
@@ -171,3 +171,31 @@ def test_to_array_in_list() -> None:
171171
def test_hashable():
172172
assert hash(Color("red")) == hash(Color("red"))
173173
assert hash(Color("red")) != hash(Color("blue"))
174+
175+
176+
def test_parse_int():
177+
# Test parsing a 24-bit integer value
178+
assert parse_int(0xFF00FF, "rgb") == RGBA(1.0, 0.0, 1.0, 1.0)
179+
180+
# Test parsing a 32-bit integer value with alpha
181+
assert parse_int(0x00FF00FF, "rgba") == RGBA(0.0, 1.0, 0.0, 1.0)
182+
183+
# Test parsing a 16-bit integer value with custom format
184+
assert parse_int(0x0FF, "bgr", bits_per_component=4) == RGBA(1.0, 1.0, 0.0, 1.0)
185+
186+
expect = RGBA8(123, 255, 0)
187+
assert parse_int(0x7FE0, "rgb", bits_per_component=[5, 6, 5]).to_8bit() == expect
188+
189+
# # Test parsing an invalid format
190+
with pytest.raises(ValueError):
191+
parse_int(0x7FE0, "rgbx")
192+
193+
# Test parsing an invalid number of bits per component
194+
with pytest.raises(ValueError):
195+
parse_int(0x7FE0, "rgb", bits_per_component=[5, 5])
196+
197+
198+
@pytest.mark.parametrize("input", [0xFF00FF, 0x00FF00FF, 0x0FF, 0x7FE0])
199+
@pytest.mark.parametrize("fmt", ["rgb", "rgba", "bgr"])
200+
def test_round_trip(input: int, fmt: str):
201+
assert Color.from_int(input, fmt).to_int(fmt) == input

0 commit comments

Comments
 (0)