File size: 3,586 Bytes
568914f
4247de9
568914f
 
 
 
 
4247de9
568914f
 
 
 
4247de9
568914f
4247de9
 
 
 
568914f
 
 
 
4247de9
 
 
 
 
568914f
 
 
 
 
 
 
 
4247de9
568914f
 
 
 
 
 
 
 
 
 
 
4247de9
 
568914f
 
 
 
 
 
 
 
4247de9
 
568914f
 
 
 
 
 
 
4247de9
568914f
 
4247de9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
"""RAW decode + camera-space white balance from an illuminant chromaticity.

White balance is applied the way the iOS app does it (`CIRAWFilter.neutralChromaticity`):
as per-channel multipliers in the camera's *native* space, derived from the target
illuminant through the camera's own XYZ->camera matrix. The earlier Bradford-CAT-in-sRGB
approach used the wrong operator in the wrong space and produced a desaturated,
magenta/hazy result; this neutralises the illuminant the way the app does.

This is deliberately a *white-balance* preview, not a full ISP: no auto-exposure, tone
curve, or contrast. 'before' and 'after' share identical decode settings and differ
only in the WB multipliers, so the comparison shows the correction alone.
"""

import numpy as np

HALF_SIZE = True
MAX_LONG_EDGE = 2000

# Shared postprocess: linear 16-bit, no auto-exposure, half-res. WB + output colour
# are supplied per call so before/after differ only by white balance.
_POST = dict(output_bps=16, gamma=(1, 1), no_auto_bright=True, half_size=HALF_SIZE)


def xy_to_XYZ(x, y, Y=1.0):
    return np.array([Y * x / y, Y, Y * (1.0 - x - y) / y])


def open_raw(path):
    """imread and return the RawPy object. The caller may delete the file right
    after — the unpacked sensor data stays in RAM, so depth re-runs re-render from
    memory (no second read) and the upload is still discarded for privacy."""
    import rawpy

    return rawpy.imread(str(path))


def wb_from_chromaticity(raw, illuminant_xy):
    """Raw WB multipliers [R, G, B, G2] that render `illuminant_xy` neutral, derived
    from the camera's XYZ->camera matrix (`raw.rgb_xyz_matrix`). This is the camera-
    space equivalent of CIRAWFilter.neutralChromaticity. Validated: feeding D65 here
    reproduces `raw.daylight_whitebalance` to 4 decimals."""
    M = np.asarray(raw.rgb_xyz_matrix, dtype=float)[:3]   # XYZ -> camera
    cam = M @ xy_to_XYZ(*illuminant_xy)
    cam = np.where(np.abs(cam) < 1e-9, 1e-9, cam)
    mul = 1.0 / cam
    mul = mul / mul[1]                                    # normalise green = 1
    return [float(mul[0]), 1.0, float(mul[2]), 1.0]


def baseline_wb(raw):
    """'before' WB — the camera's as-shot balance (the photo as captured), falling
    back to the daylight balance. Green-normalised."""
    cw = np.asarray(raw.camera_whitebalance, dtype=float)
    if cw.size < 3 or cw[1] <= 0 or not np.all(np.isfinite(cw[:3])):
        cw = np.asarray(raw.daylight_whitebalance, dtype=float)
    cw = cw / cw[1]
    return [float(cw[0]), 1.0, float(cw[2]), 1.0]


def render(raw, wb, max_long_edge=MAX_LONG_EDGE):
    """postprocess `raw` with WB multipliers `wb` -> uint8 sRGB display image."""
    import rawpy

    rgb16 = raw.postprocess(user_wb=list(wb), output_color=rawpy.ColorSpace.sRGB, **_POST)
    linear = _resize_max(rgb16.astype(np.float32) / 65535.0, max_long_edge)
    return _encode_display(linear)


def _encode_display(linear_rgb):
    lin = np.clip(linear_rgb, 0.0, 1.0)
    srgb = np.where(lin <= 0.0031308, lin * 12.92, 1.055 * np.power(lin, 1 / 2.4) - 0.055)
    return (np.clip(srgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)


def _resize_max(img, max_long_edge):
    from PIL import Image

    h, w = img.shape[:2]
    if max(h, w) <= max_long_edge:
        return img
    scale = max_long_edge / max(h, w)
    new_w, new_h = max(1, round(w * scale)), max(1, round(h * scale))
    chans = [
        np.asarray(Image.fromarray(img[..., c], mode="F").resize((new_w, new_h), Image.BILINEAR))
        for c in range(img.shape[2])
    ]
    return np.stack(chans, axis=-1)