Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions conversions/cmyk_to_rgb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""
Convert a CMYK colour (percentages) back to RGB (0..255).

This is the inverse of ``rgb_cmyk_conversion.rgb_to_cmyk``. CMYK values are
percentages of paint, so each channel is in the range 0..100:

R = 255 * (1 - C/100) * (1 - K/100)
G = 255 * (1 - M/100) * (1 - K/100)
B = 255 * (1 - Y/100) * (1 - K/100)

``rgb_to_cmyk`` rounds to whole percentages, so the conversion is lossy and a
round trip rgb -> cmyk -> rgb can differ from the original by a few units per
channel. Like the forward algorithm, this is the simple linear formula and not a
colour-managed (ICC profile) conversion.

Reference: https://www.rapidtables.com/convert/color/cmyk-to-rgb.html
"""


def cmyk_to_rgb(
c_input: int, m_input: int, y_input: int, k_input: int
) -> tuple[int, int, int]:
"""
Simple CMYK to RGB conversion. Takes percentages (0..100) of CMYK paint and
returns the RGB channels as integers in 0..255.

>>> cmyk_to_rgb(0, 0, 0, 0) # white
(255, 255, 255)
>>> cmyk_to_rgb(0, 0, 0, 100) # black
(0, 0, 0)
>>> cmyk_to_rgb(0, 0, 0, 50) # gray
(128, 128, 128)
>>> cmyk_to_rgb(0, 100, 100, 0) # red
(255, 0, 0)
>>> cmyk_to_rgb(100, 0, 100, 0) # green
(0, 255, 0)
>>> cmyk_to_rgb(100, 100, 0, 0) # blue
(0, 0, 255)

Floats are accepted as long as they are in range:

>>> cmyk_to_rgb(0.0, 50.0, 100.0, 0.0)
(255, 128, 0)

The inverse of rgb_to_cmyk, within rounding (+-1 per channel for these colours):

>>> from conversions.rgb_cmyk_conversion import rgb_to_cmyk
>>> cmyk_to_rgb(*rgb_to_cmyk(255, 0, 0))
(255, 0, 0)
>>> cmyk_to_rgb(*rgb_to_cmyk(0, 255, 0))
(0, 255, 0)
>>> cmyk_to_rgb(*rgb_to_cmyk(255, 255, 255))
(255, 255, 255)
>>> cmyk_to_rgb(*rgb_to_cmyk(0, 0, 0))
(0, 0, 0)
>>> cmyk_to_rgb(*rgb_to_cmyk(128, 128, 128))
(128, 128, 128)
>>> all(
... abs(a - b) <= 1
... for rgb in [(255, 0, 0), (0, 255, 0), (0, 0, 255), (128, 128, 128)]
... for a, b in zip(rgb, cmyk_to_rgb(*rgb_to_cmyk(*rgb)))
... )
True

Invalid input raises ValueError:

>>> cmyk_to_rgb(0, 0, 0, "a")
Traceback (most recent call last):
...
ValueError: Expected int or float, found (<class 'int'>, <class 'int'>, \
<class 'int'>, <class 'str'>)
>>> cmyk_to_rgb(0, 0, 0, 101)
Traceback (most recent call last):
...
ValueError: Expected a value in the range 0..100
>>> cmyk_to_rgb(-1, 0, 0, 0)
Traceback (most recent call last):
...
ValueError: Expected a value in the range 0..100
"""
inputs = (c_input, m_input, y_input, k_input)
if not all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in inputs):
msg = f"Expected int or float, found {tuple(type(x) for x in inputs)}"
raise ValueError(msg)

if not all(0 <= x <= 100 for x in inputs):
raise ValueError("Expected a value in the range 0..100")

# changing range from 0..100 to 0..1
c, m, y, k = (x / 100 for x in inputs)

r = round(255 * (1 - c) * (1 - k))
g = round(255 * (1 - m) * (1 - k))
b = round(255 * (1 - y) * (1 - k))

return r, g, b


if __name__ == "__main__":
from doctest import testmod

testmod()
50 changes: 50 additions & 0 deletions work/R23-T052/VARIANT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# R23-T052: conversions/cmyk_to_rgb.py

## What it does
`cmyk_to_rgb(c, m, y, k)` is the inverse of `rgb_cmyk_conversion.rgb_to_cmyk`.
Inputs are percentages of paint (0..100); output is `(r, g, b)` in 0..255:

R = 255 * (1 - C/100) * (1 - K/100) (same for G with M, B with Y)

It raises `ValueError` for non-numeric input or values outside 0..100.

## How it differs from rgb_to_cmyk (time / space)
| | rgb_to_cmyk | cmyk_to_rgb |
|---|---|---|
| Time | O(1) | O(1) |
| Space | O(1) | O(1) |
| Measured (20,000 random colours, avg per call) | ~0.70 us | ~1.87 us |

Both are constant time and constant space. `cmyk_to_rgb` is slower per call
only because of its generic validation (`all(isinstance(...))` and
`all(0 <= x <= 100)` over a 4-tuple); the arithmetic itself is just 3 multiplications
and 3 roundings. Timings are from one run on a cloud machine, so treat them as
rough.

## Why the round trip is not exact (rounding)
`rgb_to_cmyk` rounds C, M, Y and K to **whole percentages**. That throws away
information: 256 levels per RGB channel are squeezed into 101 levels per CMYK
channel. Converting back cannot recover the lost detail, so rgb -> cmyk -> rgb
can be off by a few units.

Each CMYK value can be off by up to 0.5 percent, and R depends on two of them
(C and K), so the worst case error is about 2 per channel. Measured over a
grid of every 5th value per channel (140,608 colours):

| max error per channel | colours |
|---|---|
| 0 | 27,214 |
| 1 | 106,608 |
| 2 | 6,786 |

(A larger sample of every 3rd value, 636,056 colours, also had a maximum error
of 2.) So "within +-1 per channel" holds for most colours and for the colours
used in the doctests (red, green, blue, white, black and mid-gray), but not for
every possible colour. The doctest checks the cases that do hold; this file
documents the limit.

## Checks run
- `python3 scripts/validate_filenames.py` -> passed
- `ruff check conversions/cmyk_to_rgb.py` -> all checks passed
- `ruff format --check conversions/cmyk_to_rgb.py` -> already formatted
- `python -m pytest --doctest-modules conversions/cmyk_to_rgb.py conversions/rgb_cmyk_conversion.py` -> passed