Trying to convert raw images to XYZ or linear rec. 2020 with only demosaic and D65 white balance applied in python, results do not match raw processors

Hello all,

I have been trying to process raw images in python (with libraw) such that the output looks the same as if I applied white/black point, daylight white balance, demosaic, and output to linear rec. 2020 in darktable (or even XYZ).

The goal is to do some processing on the raw image (possibly even demosaic) and then import it in darktable (or any image processor), select the right input color profile (eg XYZ or lin. rec. 2020), and develop it exactly as if it were raw (post-demosaic).

Unfortunately the result is always far from what it should be. I’ve tried various combinations and tricks and at best I get an off white balance. Most of the time I get something very red/purple/wrong.

My basic process is to apply the black/white point, multiply raw values by the normalized (s.t. green = x1) “daylight” white balance, debayer, then do profiledRGB_image = debayered_image @ invert(xyz_to_cam) @ xyz_to_profiledRGB.

What is obviously wrong with my process?

Below is the sample source code which will reproduce this and download a sample image:

import os
import requests
import numpy as np
import rawpy
import cv2

SAMPLE_RAW_URL = (
    "https://nc.trougnouf.com/index.php/s/zMA8QfgPoNoByex/download/DSC01568.ARW"
)
SAMPLE_RAW_FPATH = os.path.join("data", SAMPLE_RAW_URL.rpartition("/")[-1])


def raw_fpath_to_mono_img_and_metadata(fpath: str) -> tuple[np.ndarray, dict]:
    """Convert from raw fpath to (tuple) [1,h,w] numpy array and metadata dictionary."""
    # step 1: get raw data and metadata
    rawpy_img = rawpy.imread(fpath)
    metadata = dict()
    metadata["black_level_per_channel"] = rawpy_img.black_level_per_channel
    metadata["white_level"] = rawpy_img.white_level  # imgdata.rawdata.color.maximum
    metadata["daylight_whitebalance"] = rawpy_img.daylight_whitebalance
    metadata["rgb_xyz_matrix"] = rawpy_img.rgb_xyz_matrix
    metadata["daylight_whitebalance_norm"] = np.array(
        metadata["daylight_whitebalance"], dtype=np.float32
    )
    metadata["daylight_whitebalance_norm"][3] = metadata[
        "daylight_whitebalance_norm"
    ][1]
    metadata["daylight_whitebalance_norm"] /= metadata[
        "daylight_whitebalance_norm"
    ][1]
    mono_img = rawpy_img.raw_image
    mono_img = np.expand_dims(mono_img, axis=0)
    return mono_img, metadata

def scale_to_bw_points(img: np.ndarray, metadata: dict) -> None:
    """
    Scale image to black/white points described in metadata.

    compat: bool: use the conservative (higher) "white_level" value as in darktable.
    """
    scaled_img = img.astype(np.float32)
    for ch in range(img.shape[-3]):
        scaled_img[ch] -= metadata["black_level_per_channel"][ch]
        # step 4: normalize s.t. white level is 1: divide each value by (white level - black level)
        # darktable uses global max only, aka "white_level", which is higher.
        vrange = metadata["white_level"] - metadata["black_level_per_channel"][ch]
        scaled_img[ch] /= vrange
    return scaled_img


def apply_whitebalance(
    img: np.ndarray, metadata: dict, in_place: bool = True
) -> None:
    """
    Apply camera white balance.
    """
    # step 5: apply camera reference white balance
    if not in_place:
        img = img.copy()
    img[0, 0::2, 0::2] *= metadata["daylight_whitebalance_norm"][0]  # R
    img[0, 0::2, 1::2] *= metadata["daylight_whitebalance_norm"][1]  # G
    img[0, 1::2, 1::2] *= metadata["daylight_whitebalance_norm"][2]  # B
    img[0, 1::2, 0::2] *= metadata["daylight_whitebalance_norm"][3]  # G
    assert img.max() <= 1.0, img.max()
    return img


def demosaic(mono_img: np.ndarray):
    """
    Transform mono image to camRGB colors.
    """
    # convert to uint16 and scale to ensure we don't lose negative / large values
    black_offset = 0 if mono_img.min() >= 0 else -mono_img.min()
    mono_img += black_offset
    max_value = 1 if mono_img.max() <= 1 else mono_img.max()
    mono_img /= max_value
    # mono_img = mono_img.clip(0, 1)
    assert mono_img.min() >= 0 and mono_img.max() <= 1.0, (
        f"demosaic: image is out of bound; destructive operation. {mono_img.min()=}, "
        f"{mono_img.max()=}"
    )
    mono_img *= 65535
    mono_img = mono_img.astype(np.uint16).reshape(mono_img.shape[1:] + (1,))
    rgb_img = cv2.demosaicing(mono_img, cv2.COLOR_BayerRGGB2RGB)
    rgb_img = rgb_img.transpose(2, 0, 1)  #  opencv h, w, ch to numpy ch, h, w
    rgb_img = rgb_img.astype(np.float32) / 65535.0 * max_value - black_offset
    return rgb_img


def get_XYZ_to_profiledRGB_matrix(profile: str) -> np.ndarray:
    if profile == "lin_rec2020":
        return np.array(
            [
                [1.71666343, -0.35567332, -0.25336809],
                [-0.66667384, 1.61645574, 0.0157683],
                [0.01764248, -0.04277698, 0.94224328],
            ],
            dtype=np.float32,
        )
    else:
        raise NotImplementedError(
            f"get_std_profile_matrix: {profile} not lin_rec2020."
        )


def get_camRGB_to_profiledRGB_matrix(
    metadata: dict, output_color_profile: str
) -> np.ndarray:
    """Get conversion matrix from camRGB to a given color profile."""
    cam_to_xyzd65 = np.linalg.inv(metadata["rgb_xyz_matrix"][:3])
    if output_color_profile.lower() == 'xyz':
        return cam_to_xyzd65
    xyz_to_profiledRGB = get_XYZ_to_profiledRGB_matrix(output_color_profile)
    color_matrix = cam_to_xyzd65 @ xyz_to_profiledRGB
    return color_matrix


def camRGB_to_profiledRGB(
    camRGB_img: np.ndarray, metadata: dict, output_color_profile: str
) -> np.ndarray:
    """Convert camRGB debayered image to a given RGB color profile."""
    color_matrix = get_camRGB_to_profiledRGB_matrix(metadata, output_color_profile)
    orig_dims = camRGB_img.shape
    #breakpoint()
    profiledRGB_img = (camRGB_img.swapaxes(0,2) @ color_matrix).swapaxes(0,2)
    #profiledRGB_img = (color_matrix @ camRGB_img.reshape(3, -1)).reshape(orig_dims)
    return profiledRGB_img


def rgb_img_to_file(img: np.ndarray, fpath: str) -> None:
    """Save (c,h,w) RGB image to file."""
    hwc_img = img.transpose(1, 2, 0)  # numpy ch, h, w to opencv h, w, ch
    hwc_img = cv2.cvtColor(hwc_img, cv2.COLOR_RGB2BGR)
    hwc_img = (hwc_img * 65535).clip(0,65535).astype(np.uint16)  # while debugging
    cv2.imwrite(fpath, hwc_img)#.clip(0, 1))


def get_sample_raw_file(url: str = SAMPLE_RAW_URL) -> str:
    """Get a testing image online."""
    fn = url.split("/")[-1]
    fpath = os.path.join("data", fn)
    if not os.path.exists(fpath):
        os.makedirs("data", exist_ok=True)
        r = requests.get(url, allow_redirects=True, verify=False)
        open(fpath, "wb").write(r.content)
    return fpath


if __name__ == "__main__":
    raw_fpath = get_sample_raw_file(url=SAMPLE_RAW_URL)
    out_base_path = os.path.join("tests", f"raw.py.main")
    mono_img, metadata = raw_fpath_to_mono_img_and_metadata(raw_fpath)
    print(f"raw.py: opened {raw_fpath} with {metadata=}")
    # prepare (common)
    mono_img = scale_to_bw_points(mono_img, metadata)
    mono_img_wb = apply_whitebalance(mono_img, metadata, in_place=False)
    camRGB_img = demosaic(mono_img_wb)
    # output representations
    xyz_img = camRGB_to_profiledRGB(camRGB_img, metadata, 'XYZ')
    lin_rec2020_img = camRGB_to_profiledRGB(camRGB_img, metadata, "lin_rec2020")

    os.makedirs("tests", exist_ok=True)

    rgb_img_to_file(lin_rec2020_img, out_base_path+'.lin_rec2020.tiff')
    rgb_img_to_file(
        img=camRGB_img,
        fpath=out_base_path + ".camRGB.tiff",
    )
    rgb_img_to_file(
        img=xyz_img,
        fpath=out_base_path + ".XYZ.tiff",
    )
    print(f"raw.py: output images saved to {out_base_path}.*")
2 Likes

Urk, python… :crazy_face:

Guessing, you have apply_whitebalance before demosaic in your main, just telling the routine that it’s a mono image might not be telling it the bayer pattern, which is important for the white_balance routine to know to apply the correct multiplier to the pixel at hand. Switching the order of white_balance and demosaic may correct this, at the expense of not passing white-balanced data to demosaic.

Other than that speculation, your workflow looks okay to me; I do basically the same thing in my hack (C++!) software rawproc.

1 Like

Random thoughts. Check whether the frame needs to be cropped beforehand.

GB
RG
GB

The edge may start with GB, which would mess with the pattern. Moreover, you may look into the method itself. There are so many colour conversion codes: easy to mix them up! Probably best to compare results step by step.

PS - I added an opencv tag.

1 Like

I had the whitebalance applied first because that’s what’s done in DT, some demosaic algorithms are sensitive to it. I’ve now tried doing demosaic first and the result is essentially the same.

If I understand correctly from the metadata

(Pdb++) rawpy_img.color_desc
b'RGBG'
(Pdb++) rawpy_img.raw_pattern
array([[0, 1],
       [3, 2]], dtype=uint8)

then it is RGGB, which is what I fed openCV (COLOR_BayerRGGB2RGB).

Good point :slight_smile: but it seems to be all there

(Pdb++) rawpy_img.raw_colors
array([[0, 1, 0, ..., 1, 0, 1],
       [3, 2, 3, ..., 2, 3, 2],
       [0, 1, 0, ..., 1, 0, 1],
       ...,
       [3, 2, 3, ..., 2, 3, 2],
       [0, 1, 0, ..., 1, 0, 1],
       [3, 2, 3, ..., 2, 3, 2]], dtype=uint8)
(Pdb++) rawpy_img.raw_pattern
array([[0, 1],
       [3, 2]], dtype=uint8)
(Pdb++) rawpy_img.raw_colors.shape
(4024, 6048)
(Pdb++) rawpy_img.raw_image.shape
(4024, 6048)

Which I think with the given indices is RGGB since libraw describes it (and I believe any bayer pattern) as RGBG.

fwiw this is the 3x3 i compute to transform this image from camera to rec2020:

cam to xyz matrix
2.44674 0.209539 -0.261498 -0.0183455 1.28934 -0.473181 0.0634572 -0.229852 1.53159

(row major as a matrix to be multiplied from the left to a column vector as colour)

i think python matrices should be multiplied from the left too, but i don’t speak python. anyhow i can’t get to values similar to these using your code and printing colour_matrix. not sure what matrix is delivered from your metadata here, may be different enough to the one in rawspeed.

1 Like
114c114,130
<     color_matrix = cam_to_xyzd65 @ xyz_to_profiledRGB
---
>     color_matrix = xyz_to_profiledRGB @ cam_to_xyzd65
125,126c141,142
<     profiledRGB_img = (camRGB_img.swapaxes(0,2) @ color_matrix).swapaxes(0,2)
<     #profiledRGB_img = (color_matrix @ camRGB_img.reshape(3, -1)).reshape(orig_dims)
---
>     # profiledRGB_img = (camRGB_img.swapaxes(0,2) @ color_matrix).swapaxes(0,2)
>     profiledRGB_img = (color_matrix @ camRGB_img.reshape(3, -1)).reshape(orig_dims)
157c173
<     camRGB_img = demosaic(mono_img_wb)
---
>     camRGB_img = demosaic(mono_img)

in fact i think the matrix you have might already include the white balance coefficients so you’re applying it twice effectively? with the above changes i get this:

1 Like

That is quite different from

(Pdb++) metadata["rgb_xyz_matrix"][:3]
array([[ 0.7374, -0.2389, -0.0551],
       [-0.5435,  1.3162,  0.2519],
       [-0.1006,  0.1795,  0.6552]], dtype=float32)
(Pdb++) np.linalg.inv(metadata["rgb_xyz_matrix"][:3])
array([[ 1.5665369 ,  0.28111082,  0.02366357],
       [ 0.6340851 ,  0.91558796, -0.29868516],
       [ 0.06681217, -0.20767444,  1.6117133 ]], dtype=float32)

, thank you!

Is it the cam_to_rec2020 or cam_to_xyz matrix?

this was my cam_to_rec2020.

1 Like

Yes! :slight_smile: You were right about the already applied white balance. With your fixes the result looks (nearly) exactly the same whether I open the rec. 2020 tiff or the raw. Thank you!



The color matrix you provided does match with the one computed then (and python/numpy follows the same format).

3 Likes

Interesting that rawpy’s matrix includes the white balance multipliers.

A sanity check here might be to compare rawpy’s matrix vs. the ForwardMatrix and ColorMatrix entries in a DNG - I would expect that if rawpy is indeed including white balance multipliers in the matrix it reports, it won’t match either the ForwardMatrix or the inverse of ColorMatrix.

Good for a weekend project - so far everything I’ve done with rawpy has been histogram analysis of a particularly fuuunky camera’s DNGs. (Which basically established that the manufacturer cooked their raws in some really undesirable ways…)

@Entropy512 It looks like rgb_xyz_matrix is the same as ColorMatrix2 (and also the matrix used in darktable/rawspeed).

$ exiftool DSC01526.dng | grep Forward
Forward Matrix 1                : 0.5202 0.3299 0.1142 0.2713 0.6939 0.0347 0.1178 0.0011 0.7062
Forward Matrix 2                : 0.5131 0.2978 0.1534 0.3018 0.652 0.0462 0.136 0.0023 0.6867
$ exiftool DSC01526.dng | grep "Color Matrix"
Color Matrix 1                  : 0.8664 -0.4056 0.0403 -0.4565 1.2378 0.2453 -0.0301 0.0889 0.7157
Color Matrix 2                  : 0.7374 -0.2389 -0.0551 -0.5435 1.3162 0.2519 -0.1006 0.1795 0.6552

I’d need to double check, but that is likely missing the chromatic adaptation step described in the DNG spec (see DNG Spec to the rescue: Photographic Science and Technology Forum: Digital Photography Review )

Indeed. If I understand correctly all of this magic was moved later in the pipeline into the color calibration module of darktable by @anon41087856.