crabbly commited on
Commit
acefccf
·
verified ·
1 Parent(s): 20cc6f0

Upload 2 files

Browse files
Files changed (2) hide show
  1. color_calibration.py +140 -0
  2. test_color_eval.py +122 -0
color_calibration.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Color calibration via X-Rite ColorChecker Classic (24 patches).
3
+
4
+ Detects the checker in both a reference and target image, computes a
5
+ white-balance + CCM + luminance correction pipeline, and applies it to
6
+ the full target image.
7
+ """
8
+
9
+ import cv2 as cv
10
+ import numpy as np
11
+
12
+
13
+ # ── linear / sRGB helpers ──────────────────────────────────────────
14
+
15
+ def to_linear_srgb(u8_bgr):
16
+ rgb = cv.cvtColor(u8_bgr, cv.COLOR_BGR2RGB).astype(np.float32) / 255.0
17
+ a = 0.055
18
+ lin = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + a) / (1 + a)) ** 2.4)
19
+ return lin
20
+
21
+
22
+ def to_srgb_u8(lin_rgb):
23
+ a = 0.055
24
+ srgb = np.where(lin_rgb <= 0.0031308, 12.92 * lin_rgb, (1 + a) * np.power(lin_rgb, 1/2.4) - a)
25
+ srgb = np.clip(srgb, 0, 1)
26
+ return cv.cvtColor((srgb * 255.0).astype(np.uint8), cv.COLOR_RGB2BGR)
27
+
28
+
29
+ # ── checker geometry ───────────────────────────────────────────────
30
+
31
+ def warp_checker(img, corners, out_w=600, out_h=400):
32
+ dst = np.float32([[0, 0], [out_w-1, 0], [out_w-1, out_h-1], [0, out_h-1]])
33
+ H = cv.getPerspectiveTransform(np.float32(corners), dst)
34
+ warped = cv.warpPerspective(img, H, (out_w, out_h), flags=cv.INTER_CUBIC)
35
+ return warped
36
+
37
+
38
+ def detect_checker_corners(img_bgr):
39
+ det = cv.mcc.CCheckerDetector_create()
40
+ ok = det.process(img_bgr, cv.mcc.MCC24)
41
+ if not ok:
42
+ raise RuntimeError("ColorChecker not found")
43
+ lst = det.getListColorChecker()
44
+ cc = lst[0]
45
+ if hasattr(cc, "getBox"):
46
+ corners = np.array(cc.getBox(), dtype=np.float32)
47
+ else:
48
+ corners = np.array(cc.getCorners(), dtype=np.float32)
49
+ return corners
50
+
51
+
52
+ def sample_24_patches(warped, margin=12):
53
+ H, W = warped.shape[:2]
54
+ cell_w, cell_h = W / 6.0, H / 4.0
55
+ lin = to_linear_srgb(warped)
56
+ means = []
57
+ for r in range(4):
58
+ for c in range(6):
59
+ x0 = int(c * cell_w + margin); x1 = int((c+1) * cell_w - margin)
60
+ y0 = int(r * cell_h + margin); y1 = int((r+1) * cell_h - margin)
61
+ roi = lin[y0:y1, x0:x1]
62
+ means.append(np.median(roi.reshape(-1, 3), axis=0))
63
+ return np.stack(means, 0)
64
+
65
+
66
+ # ── calibration math ──────────────────────────────────────────────
67
+
68
+ def white_balance_neutrals(src24, ref24):
69
+ idx = np.arange(18, 24)
70
+ src_g = src24[idx].mean(0)
71
+ ref_g = ref24[idx].mean(0)
72
+ gains = ref_g / np.maximum(src_g, 1e-6)
73
+ return gains
74
+
75
+
76
+ def solve_ccm_no_bias(src24, ref24, use_indices):
77
+ A = src24[use_indices]
78
+ B = ref24[use_indices]
79
+ X, *_ = np.linalg.lstsq(A, B, rcond=None)
80
+ M = X.T
81
+ return M
82
+
83
+
84
+ def fit_monotone_luma_curve_midgrays(src24_lin, ref24_lin):
85
+ mid_idx = np.array([19, 20, 21, 22])
86
+ w = np.array([0.2126, 0.7152, 0.0722], np.float32)
87
+
88
+ Ls = (src24_lin[mid_idx] @ w).astype(np.float32)
89
+ Lt = (ref24_lin[mid_idx] @ w).astype(np.float32)
90
+
91
+ eps_lo, eps_hi = 0.01, 0.98
92
+ Ls = np.concatenate([[eps_lo], np.sort(Ls), [eps_hi]])
93
+ Lt = np.concatenate([[eps_lo], np.sort(Lt), [eps_hi]])
94
+
95
+ def map_luma(L):
96
+ L = np.clip(L, 0, 1)
97
+ j = np.searchsorted(Ls, L, side='right') - 1
98
+ j = np.clip(j, 0, len(Ls)-2)
99
+ t = (L - Ls[j]) / np.maximum(Ls[j+1] - Ls[j], 1e-6)
100
+ return (1 - t) * Lt[j] + t * Lt[j+1]
101
+ return map_luma
102
+
103
+
104
+ def soft_highlight_rolloff(L, knee=0.90, strength=0.6):
105
+ below = L < knee
106
+ out = np.empty_like(L, dtype=np.float32)
107
+ out[below] = L[below]
108
+ x = (L[~below] - knee) / max(1e-6, (1.0 - knee))
109
+ out[~below] = knee + (1.0 - knee) * (1.0 - np.exp(-strength * x))
110
+ return out
111
+
112
+
113
+ # ── full correction pipeline ─────────────────────────────────────
114
+
115
+ def apply_pipeline(target_bgr_u8, ref24, tgt24):
116
+ gains = white_balance_neutrals(tgt24, ref24)
117
+
118
+ lin = to_linear_srgb(target_bgr_u8)
119
+ lin_wb = lin * gains.reshape(1, 1, 3)
120
+
121
+ tgt24_wb = tgt24 * gains
122
+
123
+ chroma_idx = np.arange(0, 18)
124
+ M = solve_ccm_no_bias(tgt24_wb, ref24, chroma_idx)
125
+
126
+ H, W = lin_wb.shape[:2]
127
+ corrected = lin_wb.reshape(-1, 3) @ M.T
128
+ corrected = corrected.reshape(H, W, 3)
129
+
130
+ map_luma = fit_monotone_luma_curve_midgrays(tgt24_wb, ref24)
131
+ w = np.array([0.2126, 0.7152, 0.0722], np.float32)
132
+ L = np.clip(np.tensordot(corrected, w, axes=([2], [0])), 0, 1)
133
+ Lt = map_luma(L)
134
+ Lt = soft_highlight_rolloff(Lt, knee=0.90, strength=0.6)
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)
test_color_eval.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import csv
3
+ import cv2 as cv
4
+ import numpy as np
5
+ from skimage import color
6
+ import test_color_calibration
7
+
8
+ # ── 1. Patch-Level Pipeline Simulation ────────────────────────────
9
+ def apply_pipeline_to_patches(tgt24_lin, ref24_lin):
10
+ """
11
+ Applies the exact same math as `apply_pipeline`, but strictly to the
12
+ 24 patch array. This is much faster for calculating metrics.
13
+ """
14
+ # 1. White Balance
15
+ gains = test_color_calibration.white_balance_neutrals(tgt24_lin, ref24_lin)
16
+ tgt24_wb = tgt24_lin * gains
17
+
18
+ # 2. CCM
19
+ chroma_idx = np.arange(0, 18)
20
+ M = test_color_calibration.solve_ccm_no_bias(tgt24_wb, ref24_lin, chroma_idx)
21
+ tgt24_ccm = tgt24_wb @ M.T
22
+
23
+ # 3. Luma Curve
24
+ map_luma = test_color_calibration.fit_monotone_luma_curve_midgrays(tgt24_ccm, ref24_lin)
25
+ w = np.array([0.2126, 0.7152, 0.0722], np.float32)
26
+
27
+ L = np.clip(np.tensordot(tgt24_ccm, w, axes=([1], [0])), 0, 1)
28
+ Lt = map_luma(L)
29
+ Lt = test_color_calibration.soft_highlight_rolloff(Lt, knee=0.90, strength=0.6)
30
+
31
+ eps = 1e-6
32
+ scale = (Lt + eps) / (L + eps)
33
+ tgt24_corrected = tgt24_ccm * scale[:, None]
34
+
35
+ return np.clip(tgt24_corrected, 0, 1)
36
+
37
+ # ── 2. Delta E Calculation ────────────────────────────────────────
38
+ def compute_deltaE_00(lin_src, lin_ref):
39
+ """
40
+ Computes the CIEDE2000 color difference between two sets of linear RGB patches.
41
+ """
42
+ def lin_to_srgb(lin):
43
+ a = 0.055
44
+ srgb = np.where(lin <= 0.0031308, 12.92 * lin, (1 + a) * np.power(np.maximum(lin, 0), 1/2.4) - a)
45
+ return np.clip(srgb, 0, 1)
46
+
47
+ # Convert linear RGB to standard sRGB float (0-1)
48
+ srgb_src = lin_to_srgb(lin_src).reshape(1, -1, 3)
49
+ srgb_ref = lin_to_srgb(lin_ref).reshape(1, -1, 3)
50
+
51
+ # Convert sRGB to CIELAB color space
52
+ lab_src = color.rgb2lab(srgb_src)
53
+ lab_ref = color.rgb2lab(srgb_ref)
54
+
55
+ # Calculate Delta E 2000
56
+ de = color.deltaE_ciede2000(lab_src, lab_ref)
57
+ return de.flatten() # Returns array of 24 Delta E values
58
+
59
+ # ── 3. Dataset Batch Processor ────────────────────────────────────
60
+ def quantify_dataset(reference_image_path, target_images_dir, output_csv="color_metrics.csv"):
61
+ # 1. Extract the Golden Reference patches
62
+ ref_img = cv.imread(reference_image_path)
63
+ if ref_img is None: raise FileNotFoundError(f"Could not load reference {reference_image_path}")
64
+
65
+ ref_corners = test_color_calibration.detect_checker_corners(ref_img)
66
+ ref_warped = test_color_calibration.warp_checker(ref_img, ref_corners)
67
+ ref24 = test_color_calibration.sample_24_patches(ref_warped)
68
+
69
+ results =[]
70
+
71
+ # 2. Iterate through the dataset
72
+ for filename in sorted(os.listdir(target_images_dir)):
73
+ if not filename.lower().endswith(('.png', '.jpg', '.jpeg', '.tif')):
74
+ continue
75
+
76
+ filepath = os.path.join(target_images_dir, filename)
77
+ tgt_img = cv.imread(filepath)
78
+
79
+ try:
80
+ # Extract target patches
81
+ tgt_corners = test_color_calibration.detect_checker_corners(tgt_img)
82
+ tgt_warped = test_color_calibration.warp_checker(tgt_img, tgt_corners)
83
+ tgt24 = test_color_calibration.sample_24_patches(tgt_warped)
84
+
85
+ # Metric 1: Initial Error (Before Calibration)
86
+ de_initial = compute_deltaE_00(tgt24, ref24)
87
+
88
+ # Apply mathematical correction to patches
89
+ tgt24_corrected = apply_pipeline_to_patches(tgt24, ref24)
90
+
91
+ # Metric 2: Final Error (After Calibration)
92
+ de_final = compute_deltaE_00(tgt24_corrected, ref24)
93
+
94
+ # Save metrics
95
+ results.append({
96
+ 'Image': filename,
97
+ 'Initial_Mean_dE': np.mean(de_initial),
98
+ 'Initial_Max_dE': np.max(de_initial),
99
+ 'Final_Mean_dE': np.mean(de_final),
100
+ 'Final_Max_dE': np.max(de_final),
101
+ 'Final_Gray_dE': np.mean(de_final[18:24]), # Accuracy of grays specifically
102
+ 'Final_Color_dE': np.mean(de_final[0:18]) # Accuracy of chroma specifically
103
+ })
104
+ print(f"Processed {filename}: dE improved from {np.mean(de_initial):.2f} -> {np.mean(de_final):.2f}")
105
+
106
+ except Exception as e:
107
+ print(f"Skipping {filename} - Error: {e}")
108
+
109
+ # 3. Save to CSV
110
+ if results:
111
+ keys = results[0].keys()
112
+ with open(output_csv, 'w', newline='') as output_file:
113
+ dict_writer = csv.DictWriter(output_file, keys)
114
+ dict_writer.writeheader()
115
+ dict_writer.writerows(results)
116
+ print(f"\nSaved metrics for {len(results)} images to {output_csv}")
117
+
118
+ return results
119
+
120
+ if __name__ == "__main__":
121
+ # Example Usage:
122
+ quantify_dataset("reference.png", "full_data/")