Spaces:
Sleeping
Sleeping
Create test_color_calibration.py
Browse files- 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)
|