From 63f417df3aa68e459ba3d8091f45ef16e7675aec Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:26:51 -0500 Subject: [PATCH] color calibration --- launch_chffrplus.sh | 45 +++++++ tools/color_cal.py | 105 ++++++++++++++++ tools/generate_color_cal_from_dwo.py | 171 +++++++++++++++++++++++++++ tools/reference_dwo_gamma_curves.txt | 3 + 4 files changed, 324 insertions(+) create mode 100644 tools/color_cal.py create mode 100644 tools/generate_color_cal_from_dwo.py create mode 100644 tools/reference_dwo_gamma_curves.txt diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index b7cd32472..d14fb3622 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -33,6 +33,51 @@ function agnos_init { # StarPilot variables sudo chmod 0777 /cache + # Weston loads display color correction from /data/misc/display/color_cal/color_cal. + # Prefer a factory /persist/comma/color_cal blob when present. Otherwise, derive a + # Weston-compatible calibration blob from the device's legacy dwo gamma tables. + COLOR_CAL_SRC="/persist/comma/color_cal" + DWO_GAMMA_SRC="/persist/comma/dwo_gamma_curves" + COLOR_CAL_DST_DIR="/data/misc/display/color_cal" + COLOR_CAL_DST="${COLOR_CAL_DST_DIR}/color_cal" + COLOR_CAL_HASH_PATH="${COLOR_CAL_DST_DIR}/source.sha256" + DWO_REFERENCE="$DIR/tools/reference_dwo_gamma_curves.txt" + DWO_GENERATOR="$DIR/tools/generate_color_cal_from_dwo.py" + COLOR_CAL_UPDATED=0 + COLOR_CAL_TMP="" + DWO_SOURCE_HASH="" + + if [ -f "$COLOR_CAL_SRC" ]; then + sudo mkdir -p "$COLOR_CAL_DST_DIR" + if [ ! -f "$COLOR_CAL_DST" ] || ! cmp -s "$COLOR_CAL_SRC" "$COLOR_CAL_DST"; then + sudo cp "$COLOR_CAL_SRC" "$COLOR_CAL_DST" + sudo chown -R comma:comma "$COLOR_CAL_DST_DIR" + sudo chmod 664 "$COLOR_CAL_DST" + sudo rm -f "$COLOR_CAL_HASH_PATH" + COLOR_CAL_UPDATED=1 + fi + elif [ -f "$DWO_GAMMA_SRC" ] && [ -f "$DWO_REFERENCE" ] && [ -f "$DWO_GENERATOR" ]; then + DWO_SOURCE_HASH="$(cat "$DWO_GAMMA_SRC" "$DWO_REFERENCE" "$DWO_GENERATOR" | sha256sum | awk '{print $1}')" + if [ ! -f "$COLOR_CAL_DST" ] || [ ! -f "$COLOR_CAL_HASH_PATH" ] || [ "$(cat "$COLOR_CAL_HASH_PATH" 2>/dev/null)" != "$DWO_SOURCE_HASH" ]; then + COLOR_CAL_TMP="$(mktemp)" + if python3 "$DWO_GENERATOR" --reference "$DWO_REFERENCE" --input "$DWO_GAMMA_SRC" --output "$COLOR_CAL_TMP"; then + sudo mkdir -p "$COLOR_CAL_DST_DIR" + if [ ! -f "$COLOR_CAL_DST" ] || ! cmp -s "$COLOR_CAL_TMP" "$COLOR_CAL_DST"; then + sudo cp "$COLOR_CAL_TMP" "$COLOR_CAL_DST" + COLOR_CAL_UPDATED=1 + fi + printf '%s' "$DWO_SOURCE_HASH" | sudo tee "$COLOR_CAL_HASH_PATH" >/dev/null + sudo chown -R comma:comma "$COLOR_CAL_DST_DIR" + sudo chmod 664 "$COLOR_CAL_DST" "$COLOR_CAL_HASH_PATH" + fi + rm -f "$COLOR_CAL_TMP" + fi + fi + + if [ "$COLOR_CAL_UPDATED" = "1" ] && systemctl is-active --quiet weston.service; then + sudo systemctl restart weston.service + fi + # Check if AGNOS update is required if [ $(< /VERSION) != "$AGNOS_VERSION" ]; then AGNOS_PY="$DIR/system/hardware/tici/agnos.py" diff --git a/tools/color_cal.py b/tools/color_cal.py new file mode 100644 index 000000000..644fdcc1b --- /dev/null +++ b/tools/color_cal.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +import argparse +import binascii +import os +import struct +from pathlib import Path + + +IGNORED_TAIL = b"\x00\x00\x00\x00\x00" +VALUES_COUNT = 13 +VALUES_FMT = f"<{VALUES_COUNT}e" +VALUES_SIZE = struct.calcsize(VALUES_FMT) + + +def read_color_cal(path: str) -> tuple[float, list[float], list[float], bytes]: + with open(path, "rb") as f: + data = f.read() + + if len(data) < VALUES_SIZE: + raise ValueError(f"{path} is too short: expected at least {VALUES_SIZE} bytes, got {len(data)}") + + values = struct.unpack(VALUES_FMT, data[:VALUES_SIZE]) + gamma = float(values[0]) + ccm = [float(v) for v in values[1:10]] + wb = [float(v) for v in values[10:13]] + return gamma, ccm, wb, data[VALUES_SIZE:] + + +def write_color_cal(path: str, gamma: float, ccm: list[float], wb: list[float], tail: bytes = IGNORED_TAIL) -> None: + if len(ccm) != 9: + raise ValueError("CCM must contain exactly 9 values") + if len(wb) != 3: + raise ValueError("WB gains must contain exactly 3 values") + + payload = struct.pack(VALUES_FMT, gamma, *ccm, *wb) + data = payload + tail + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(data) + + +def cmd_analyze(args: argparse.Namespace) -> int: + gamma, ccm, wb, tail = read_color_cal(args.path) + data = Path(args.path).read_bytes() + print(f"path: {args.path}") + print(f"size: {len(data)}") + print(f"hex: {binascii.hexlify(data).decode()}") + print(f"gamma: {gamma:.4f}") + print("ccm:") + print(f" [{ccm[0]:.4f}, {ccm[1]:.4f}, {ccm[2]:.4f}]") + print(f" [{ccm[3]:.4f}, {ccm[4]:.4f}, {ccm[5]:.4f}]") + print(f" [{ccm[6]:.4f}, {ccm[7]:.4f}, {ccm[8]:.4f}]") + print(f"wb: [R={wb[0]:.4f}, G={wb[1]:.4f}, B={wb[2]:.4f}]") + print(f"ignored_tail_hex: {tail.hex()}") + return 0 + + +def cmd_generate(args: argparse.Namespace) -> int: + tail = binascii.unhexlify(args.tail_hex) if args.tail_hex else IGNORED_TAIL + write_color_cal(args.output, args.gamma, args.ccm, args.wb, tail=tail) + print(f"wrote {args.output}") + return 0 + + +def cmd_from_hex(args: argparse.Namespace) -> int: + data = binascii.unhexlify(args.hex_string) + out = Path(args.output) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(data) + print(f"wrote {args.output}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Inspect and generate comma/Weston color_cal files.") + subparsers = parser.add_subparsers(dest="command", required=True) + + analyze = subparsers.add_parser("analyze", help="Decode an existing color_cal file") + analyze.add_argument("path") + analyze.set_defaults(func=cmd_analyze) + + generate = subparsers.add_parser("generate", help="Generate a color_cal file from gamma/CCM/WB values") + generate.add_argument("output") + generate.add_argument("--gamma", type=float, required=True) + generate.add_argument("--ccm", type=float, nargs=9, required=True) + generate.add_argument("--wb", type=float, nargs=3, required=True) + generate.add_argument("--tail-hex", default="", help="Optional trailing bytes as hex; Weston ignores these") + generate.set_defaults(func=cmd_generate) + + from_hex = subparsers.add_parser("from-hex", help="Write a raw color_cal blob from a hex string") + from_hex.add_argument("output") + from_hex.add_argument("hex_string") + from_hex.set_defaults(func=cmd_from_hex) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/generate_color_cal_from_dwo.py b/tools/generate_color_cal_from_dwo.py new file mode 100644 index 000000000..e6daf4892 --- /dev/null +++ b/tools/generate_color_cal_from_dwo.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import math +import struct +from pathlib import Path + + +EPS = 1e-9 +COLOR_CAL_FMT = "<13e" +IGNORED_TAIL = b"\x00\x00\x00\x00\x00" +SAMPLES = [i / 47.0 for i in range(1, 48)] +WEIGHTS = [0.6 + 1.8 * x + (8.0 if x >= 0.985 else 0.0) for x in SAMPLES] + + +def parse_dwo_gamma_curves(path: str) -> list[list[float]]: + rows: list[list[float]] = [] + with open(path) as f: + for line in f: + stripped = line.strip() + if not stripped: + continue + values = [int(x, 16) for x in stripped.split()] + if len(values) < 2: + raise ValueError(f"{path}: gamma row must contain at least two values") + if values[0] > values[-1]: + values.reverse() + max_value = max(values) + if max_value <= 0: + raise ValueError(f"{path}: gamma row max must be positive") + rows.append([v / max_value for v in values]) + + if len(rows) != 3: + raise ValueError(f"{path}: expected 3 gamma rows, got {len(rows)}") + + row_len = len(rows[0]) + if any(len(row) != row_len for row in rows): + raise ValueError(f"{path}: gamma rows must all have the same length") + + return rows + + +def interp(curve: list[float], x: float) -> float: + x = min(max(x, 0.0), 1.0) + pos = x * (len(curve) - 1) + lo = int(pos) + hi = min(lo + 1, len(curve) - 1) + frac = pos - lo + return curve[lo] * (1.0 - frac) + curve[hi] * frac + + +def invert_interp(curve: list[float], y: float) -> float: + y = min(max(y, 0.0), 1.0) + if y <= curve[0]: + return 0.0 + if y >= curve[-1]: + return 1.0 + lo = 0 + hi = len(curve) - 1 + while hi - lo > 1: + mid = (lo + hi) // 2 + if curve[mid] < y: + lo = mid + else: + hi = mid + y0 = curve[lo] + y1 = curve[hi] + if abs(y1 - y0) < EPS: + return lo / (len(curve) - 1) + frac = (y - y0) / (y1 - y0) + return (lo + frac) / (len(curve) - 1) + + +def apply_target_panel(curve: list[float], a: float, gamma: float, x: float) -> float: + corrected = min(max(a * (x ** gamma), 0.0), 1.0) + return interp(curve, corrected) + + +def fit_channel_amplitude(reference_curve: list[float], target_curve: list[float], gamma: float) -> tuple[float, float]: + # Search for the per-channel amplitude that makes the target panel resemble the + # reference panel once Weston applies a common gamma pre-distortion. + lo = 0.85 + hi = 1.15 + best_a = 1.0 + best_err = float("inf") + for _ in range(5): + steps = 60 + step = (hi - lo) / steps + for idx in range(steps + 1): + a = lo + idx * step + err = 0.0 + for x, w in zip(SAMPLES, WEIGHTS): + ref = interp(reference_curve, x) + actual = apply_target_panel(target_curve, a, gamma, x) + diff = actual - ref + err += w * diff * diff + if err < best_err: + best_err = err + best_a = a + lo = max(0.6, best_a - step * 1.5) + hi = min(1.4, best_a + step * 1.5) + return best_a, best_err + + +def fit_color_cal(reference_curves: list[list[float]], target_curves: list[list[float]]) -> tuple[float, list[float], list[float]]: + best_gamma = 1.0 + best_amps = [1.0, 1.0, 1.0] + best_err = float("inf") + + gamma_values = [0.82 + 0.004 * i for i in range(96)] # 0.82 .. 1.20 + for gamma in gamma_values: + amps: list[float] = [] + err = 0.0 + for ref_curve, target_curve in zip(reference_curves, target_curves): + amp, channel_err = fit_channel_amplitude(ref_curve, target_curve, gamma) + amps.append(amp) + err += channel_err + # Bias toward identity when multiple fits are similar so a "good" panel stays unchanged. + identity_penalty = sum((amp - 1.0) ** 2 for amp in amps) * 3.0 + (gamma - 1.0) ** 2 * 12.0 + err += identity_penalty + if err < best_err: + best_err = err + best_gamma = gamma + best_amps = amps + + # The calibration file stores white-balance gains; Weston multiplies by their inverse. + wb_gains = [] + for amp in best_amps: + amp = max(amp, 0.6) + gain = amp ** (-2.2 / best_gamma) + wb_gains.append(min(max(gain, 0.7), 1.45)) + + return best_gamma, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], wb_gains + + +def write_color_cal(path: str, gamma: float, ccm: list[float], wb_gains: list[float]) -> None: + payload = struct.pack(COLOR_CAL_FMT, gamma, *ccm, *wb_gains) + Path(path).write_bytes(payload + IGNORED_TAIL) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate a Weston color_cal blob from dwo_gamma_curves.") + parser.add_argument("--reference", required=True, help="Reference dwo_gamma_curves file from a good panel") + parser.add_argument("--input", required=True, help="Device dwo_gamma_curves file to adapt") + parser.add_argument("--output", required=True, help="Output color_cal path") + parser.add_argument("--print-only", action="store_true", help="Print the generated parameters instead of writing") + return parser + + +def main() -> int: + args = build_parser().parse_args() + reference_curves = parse_dwo_gamma_curves(args.reference) + input_curves = parse_dwo_gamma_curves(args.input) + gamma, ccm, wb_gains = fit_color_cal(reference_curves, input_curves) + + if args.print_only: + print(f"gamma={gamma:.6f}") + print("ccm=" + " ".join(f"{v:.6f}" for v in ccm)) + print("wb=" + " ".join(f"{v:.6f}" for v in wb_gains)) + else: + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + write_color_cal(str(output), gamma, ccm, wb_gains) + + digest = hashlib.sha256(Path(args.input).read_bytes()).hexdigest()[:12] + print(f"source_sha256={digest}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/reference_dwo_gamma_curves.txt b/tools/reference_dwo_gamma_curves.txt new file mode 100644 index 000000000..5dc2f9a82 --- /dev/null +++ b/tools/reference_dwo_gamma_curves.txt @@ -0,0 +1,3 @@ +0217 0210 020C 0208 0200 01F8 01F1 01EA 01DC 01CD 01BE 01AF 019F 018F 017E 016B 0157 013F 0131 0122 010E 00F5 00E6 00D1 0000 +0219 0212 020E 020A 0202 01F9 01F0 01E7 01D7 01C6 01B5 01A3 0191 0180 016D 0158 0142 0128 0119 010A 00F7 00E1 00D5 00C6 0000 +0219 0210 0208 0200 01F1 01E3 01D4 01C7 01AF 0197 017E 0166 014C 0134 0118 00FA 00D9 00B6 00A0 008B 0071 0059 0048 002E 0000