color calibration

This commit is contained in:
firestar5683
2026-04-12 00:26:51 -05:00
parent eafe46a886
commit 63f417df3a
4 changed files with 324 additions and 0 deletions
+45
View File
@@ -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"
+105
View File
@@ -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())
+171
View File
@@ -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())
+3
View File
@@ -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