No scattering makes life easier!

Simulation of color change over time requires a structural color physics model. In the general case of scattering colorants this is quite a challenge. First we need to know the optical absorption (K) and scattering (S) properties of all paints in the palette. Furthermore we need to know the distribution of all paints. Finally this stratigraphic model data then needs to be fed into the complicated mathematical machinery of Kubelka-Munk equations.

Pff, all together a rather complicated problem. We will deal with this later. Fortunately, the modeling of transparent (i.e. non-scattering) colors such as inks and dyes is much simpler. An object can be thought of as a background with a number of transparent colored layers on top. These layers act like filters. Their ordering does not matter. Such colors are called subtractive. The colors of a color photograph are the result of the combined filter action of specific amounts of cyan, magenta and yellow.

Creating a stratigraphic model

In a typical situation we are confronted with a drawing or photograph and we do not have information about the palette of inks or the set of dyes that constitute the primaries. Also we do not know how these colorants are distributed. We magically need to separate the optical properties of the colorants, and come up with a concentration or thickness map for each colorant layer.

Let's load the kaleidoscope() test image, and learn how we can separate the cyan, magenta and yellow primary colors.

from inktime import kaleidoscope 

test_img = kaleidoscope()

A first step in the construction of a stratigraphic model is the detection of primary colors as the corners of the normalized optical density palette histogram.

from inktime import palette_plot, primaries, optical_density, transmission   

palette_plot(test_img);

Similar colors in the NOD map represent a single colorant mixing ratio. The coordinates of the corners (cyan, magenta and yellow) of the triangle in the NOD histogram are the ink primaries that we seek. They can be computed with the primaries() function.

M = primaries(test_img)
M
array([[0.9 , 0.03, 0.07],
       [0.05, 0.9 , 0.05],
       [0.02, 0.06, 0.92]])

The rows in this matrix contain the normalized optical densities for the three primary inks. Now we can continue and construct a three layer stratigraphic model. In other words, we will compute separate transmission images for the three inks that were used.

The essential idea for this computation is work in optical density space because quantities then scale linearly with concentrations. This allows us make use of the inverse transformation matrix of the primaries. The inverse matrix can be calculated with the numpy function numpy.linalg.inv().

M_inv = np.linalg.inv(M)
M_inv
array([[ 1.11471423, -0.03161735, -0.08309688],
       [-0.06080259,  1.1168761 , -0.0560735 ],
       [-0.02026753, -0.07215241,  1.09241994]])

If we now compute optical density image, we can multiply with the inverse transformation matrix and get the concentrations image.

od_img = optical_density(test_img, normalize=False)

# temporarily flatten image into list 
img_shape = od_img.shape
od_list = od_img.reshape(-1, 3)

# matrix multiplication 
conc_list = od_list @ M_inv

# unflatten list into image 
concs_img = conc_list.reshape(img_shape)

# seperate images for cyan, magenta and yellow 
cyan_conc_img, magenta_conc_img, yellow_conc_img = concs_img.transpose(2, 0, 1)

Now we can compute the transmission images from concentration images and their corresponding primaries and plot them.

T_cyan = transmission(cyan_conc_img, M[0])
T_cyan = np.clip(T_cyan, a_min=0, a_max=1)

T_magenta = transmission(magenta_conc_img, M[1])
T_magenta = np.clip(T_magenta, a_min=0, a_max=1) 

T_yellow = transmission(yellow_conc_img, M[2])
T_yellow = np.clip(T_yellow, a_min=0, a_max=1)

fig, [ax0, ax1, ax2] = plt.subplots(ncols=3, figsize=[9, 3], sharex=True, sharey=True)

ax0.imshow(T_cyan)
ax0.set_title(f'Cyan layer')
ax1.imshow(T_magenta)
ax1.set_title('Magenta layer')
ax2.imshow(T_yellow);
ax2.set_title('Yellow layer');

That is it! We have separated the original test image into a stratigraphic model of three separate ink layers. Let's check if we can reconstruct the original test image by overlaying (multiplying transmissions) of the three ink layers.

cyan_x_magenta_x_yellow = T_cyan * T_magenta * T_yellow 

fig, [ax0, ax1] = plt.subplots(ncols=2, figsize=[9, 5], sharex=True, sharey=True)

ax0.imshow(test_img)
ax1.imshow(cyan_x_magenta_x_yellow)

ax0.set_title('Original')
ax1.set_title('Reconstruction');

These computations above are implemented in the Stratigraphy() class.

from inktime import Stratigraphy
s = Stratigraphy(test_img)
fig, ax = plt.subplots()
ax.imshow(s.T_cyan); 

We are now in a position to develop a spectral model and implement chemical kinetics of fading. To be continued...

class Stratigraphy[source]

Stratigraphy(rgb_img, M=None)

Stratigraphic model for transparent dyes.

transmission[source]

transmission(D, K)

Calculate the RGB or spectral transmission for a colored transparent layer.

Parameters
----------
D: array (height x width)
    Transparent colorant thickness/concentration map
K: tuple-like (n channels)
    Colorant absorption coefficients.
    For RGB images n=3, for spectral images n wavelength channels

Returns
-------
T: array ( height x width x n channels)
    transmission image (height x width x n channels)

kaleidoscope[source]

kaleidoscope(K_CMY=None, bg_color='white', return_layers=False)

Create 'kaleidoscope' test image.

Parameters
----------
K_CMY : None, or array (3 x 3)
    If None (default) the kaleidoscope test image is created with default (normalized)
    primaries `K_CMY` = [[0.9, 0.03, 0.07], [0.05, 0.9, 0.05], [0.02, 0.06, 0.92]]
    For testing purposes custom primaries can be chosen.

bg_color : 'white', 'yellowish' or RGB tuple
    For testing purposes custom primbackground color *bg_color*
can be chosen.

return_layers : bool
    If `return_layers`=True, then layer primaries and transmission images are returned.

Returns
-------
RGB_img : array (1300 x 1300 x 3)
    Kaleidoscope test image

optical_density[source]

optical_density(rgb_img, normalize=True, thresh=0)

Create normalized optical density image from `rgb_img`.

Parameters
----------
rgb_img : array (height x width x 3)
    Standard rgb image.
normalize : bool
    If True (default) normalize optical density tuple,
    i.e. make sum x+y+z = 1.
thresh : float
    Normalize only tuples with optical densities above threshold `thresh`.
    This avoids division by zero errors.

primaries[source]

primaries(rgb_img, method='nearest', thresh=0, verbose=False)

Find colorant normalized optical density `nod_primaries` from input `rgb_img`.

Parameters
----------
rgb_img : array (height x width x 3)
    Input RGB image
method : 'nearest', 'convex'
    Method for locating gamut triangle corners.
    If 'nearest' (default) a simple nearest neighbour search is done.
    If 'convex' a convex hull arond all nod points is computed. Still experimental.
verbose : bool
   If True (default=False) report which method is used.

palette_plot[source]

palette_plot(rgb_img)

Plot normalized optical density (NOD) map and histogram for `rgb_img`.


Similar colors in the NOD map represent similar mixture ratios of primaries.
Triangle corners in the NOD histogram represent the cyan, magenta and yellow primaries.

Parameters
----------
rgb_img : array (height x width x 3)
    Input RGB image

Returns
-------
fig, axs : plot figure and axes