crabbly commited on
Commit
103df15
·
verified ·
1 Parent(s): cb281c8

Create test_color_calibration.py

Browse files
Files changed (1) hide show
  1. test_color_calibration.py +140 -0
test_color_calibration.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2 as cv
2
+ import numpy as np
3
+
4
+ # ── linear / sRGB helpers ──────────────────────────────────────────
5
+
6
+ def to_linear_srgb(u8_bgr):
7
+ rgb = cv.cvtColor(u8_bgr, cv.COLOR_BGR2RGB).astype(np.float32) / 255.0
8
+ a = 0.055
9
+ lin = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + a) / (1 + a)) ** 2.4)
10
+ return lin
11
+
12
+ def to_srgb_u8(lin_rgb):
13
+ a = 0.055
14
+ srgb = np.where(lin_rgb <= 0.0031308, 12.92 * lin_rgb, (1 + a) * np.power(lin_rgb, 1/2.4) - a)
15
+ srgb = np.clip(srgb, 0, 1)
16
+ return cv.cvtColor((srgb * 255.0).astype(np.uint8), cv.COLOR_RGB2BGR)
17
+
18
+ # ── checker geometry ───────────────────────────────────────────────
19
+
20
+ def warp_checker(img, corners, out_w=600, out_h=400):
21
+ # FIX: cv.mcc returns [Bottom-Left, Top-Left, Top-Right, Bottom-Right]
22
+ # We must match this order to prevent 90-degree rotation.
23
+ dst = np.float32([
24
+ [0, out_h - 1], # Bottom-Left
25
+ [0, 0], # Top-Left
26
+ [out_w - 1, 0], # Top-Right
27
+ [out_w - 1, out_h - 1]# Bottom-Right
28
+ ])
29
+ H_mat = cv.getPerspectiveTransform(np.float32(corners), dst)
30
+ warped = cv.warpPerspective(img, H_mat, (out_w, out_h), flags=cv.INTER_CUBIC)
31
+ return warped
32
+
33
+ def detect_checker_corners(img_bgr):
34
+ det = cv.mcc.CCheckerDetector_create()
35
+ ok = det.process(img_bgr, cv.mcc.MCC24)
36
+ if not ok:
37
+ raise RuntimeError("ColorChecker not found")
38
+ lst = det.getListColorChecker()
39
+ cc = lst[0]
40
+ if hasattr(cc, "getBox"):
41
+ corners = np.array(cc.getBox(), dtype=np.float32)
42
+ else:
43
+ corners = np.array(cc.getCorners(), dtype=np.float32)
44
+ return corners
45
+
46
+ def sample_24_patches(warped, margin=12):
47
+ H, W = warped.shape[:2]
48
+ cell_w, cell_h = W / 6.0, H / 4.0
49
+ lin = to_linear_srgb(warped)
50
+ means =[]
51
+ for r in range(4):
52
+ for c in range(6):
53
+ x0 = int(c * cell_w + margin); x1 = int((c+1) * cell_w - margin)
54
+ y0 = int(r * cell_h + margin); y1 = int((r+1) * cell_h - margin)
55
+ roi = lin[y0:y1, x0:x1]
56
+ means.append(np.median(roi.reshape(-1, 3), axis=0))
57
+ return np.stack(means, 0)
58
+
59
+ # ── calibration math ──────────────────────────────────────────────
60
+
61
+ def white_balance_neutrals(src24, ref24):
62
+ # FIX: Use median to prevent a clipped white patch from dominating the math
63
+ idx = np.arange(18, 24)
64
+ src_g = np.median(src24[idx], axis=0)
65
+ ref_g = np.median(ref24[idx], axis=0)
66
+ gains = ref_g / np.maximum(src_g, 1e-6)
67
+ return gains
68
+
69
+ def solve_ccm_no_bias(src24, ref24, use_indices):
70
+ A = src24[use_indices]
71
+ B = ref24[use_indices]
72
+ X, *_ = np.linalg.lstsq(A, B, rcond=None)
73
+ M = X.T
74
+ return M
75
+
76
+ def fit_monotone_luma_curve_midgrays(src24_lin, ref24_lin):
77
+ mid_idx = np.array([19, 20, 21, 22])
78
+ w = np.array([0.2126, 0.7152, 0.0722], np.float32)
79
+
80
+ Ls = (src24_lin[mid_idx] @ w).astype(np.float32)
81
+ Lt = (ref24_lin[mid_idx] @ w).astype(np.float32)
82
+
83
+ # FIX: Sort by Ls, and apply the SAME index sort to Lt to preserve pairing
84
+ sort_idx = np.argsort(Ls)
85
+ Ls = Ls[sort_idx]
86
+ Lt = Lt[sort_idx]
87
+
88
+ eps_lo, eps_hi = 0.01, 0.98
89
+ Ls = np.concatenate([[eps_lo], Ls, [eps_hi]])
90
+ Lt = np.concatenate([[eps_lo], Lt,[eps_hi]])
91
+
92
+ # FIX: Use numpy's highly optimized vectorized interpolation
93
+ def map_luma(L):
94
+ L_clip = np.clip(L, 0, 1)
95
+ return np.interp(L_clip, Ls, Lt)
96
+
97
+ return map_luma
98
+
99
+ def soft_highlight_rolloff(L, knee=0.90, strength=0.6):
100
+ below = L < knee
101
+ out = np.empty_like(L, dtype=np.float32)
102
+ out[below] = L[below]
103
+ x = (L[~below] - knee) / max(1e-6, (1.0 - knee))
104
+ out[~below] = knee + (1.0 - knee) * (1.0 - np.exp(-strength * x))
105
+ return out
106
+
107
+ # ── full correction pipeline ─────────────────────────────────────
108
+
109
+ def apply_pipeline(target_bgr_u8, ref24, tgt24):
110
+ # 1. White Balance
111
+ gains = white_balance_neutrals(tgt24, ref24)
112
+ lin = to_linear_srgb(target_bgr_u8)
113
+ lin_wb = lin * gains.reshape(1, 1, 3)
114
+ tgt24_wb = tgt24 * gains
115
+
116
+ # 2. Color Correction Matrix
117
+ chroma_idx = np.arange(0, 18)
118
+ M = solve_ccm_no_bias(tgt24_wb, ref24, chroma_idx)
119
+
120
+ H, W = lin_wb.shape[:2]
121
+ corrected = lin_wb.reshape(-1, 3) @ M.T
122
+ corrected = corrected.reshape(H, W, 3)
123
+
124
+ # FIX: Calculate Luma curve against the CCM-corrected patches!
125
+ tgt24_ccm = tgt24_wb @ M.T
126
+
127
+ # 3. Luma Curve
128
+ map_luma = fit_monotone_luma_curve_midgrays(tgt24_ccm, ref24)
129
+ w = np.array([0.2126, 0.7152, 0.0722], np.float32)
130
+
131
+ L = np.clip(np.tensordot(corrected, w, axes=([2], [0])), 0, 1)
132
+ Lt = map_luma(L)
133
+ Lt = soft_highlight_rolloff(Lt, knee=0.90, strength=0.6)
134
+
135
+ eps = 1e-6
136
+ scale = (Lt + eps) / (L + eps)
137
+ corrected = corrected * scale[..., None]
138
+
139
+ corrected = np.clip(corrected, 0, 1)
140
+ return to_srgb_u8(corrected)