Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- color_calibration.py +140 -0
- 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/")
|