Files
facok-ComfyUI-LCS/core/calibration.py
T
facok 6cd650596d Extract shared _chromatic_plane_basis helper and _ANCHOR_HUES constant
Deduplicate the Gram-Schmidt basis construction that was copy-pasted
in decode_lcs_to_hsl, encode_hsl_to_lcs, and calibration.py. Extract
anchor hue values into a module-level constant to prevent silent
divergence. Also remove unnecessary tensor clone in _angle_to_hue and
use torch.empty instead of torch.zeros where all elements are overwritten.
2026-03-17 13:05:27 +08:00

180 lines
5.8 KiB
Python

"""PCA calibration from FLUX VAE: compute LCS basis, mean, and anchor positions."""
import hashlib
import math
import torch
import comfy.utils
from .patchify import patchify
from .lcs_data import LCSData
from .color_space import _chromatic_plane_basis
def vae_fingerprint(vae) -> str:
"""8-char hex fingerprint from VAE decoder weights.
Used to cache calibration data per-VAE so different VAE models
get separate calibration files automatically.
"""
sd = vae.get_sd()
# Use first decoder weight tensor as fingerprint source
for key in sorted(sd.keys()):
if "decoder" in key and "weight" in key:
w = sd[key]
return hashlib.sha256(w.cpu().float().numpy().tobytes()).hexdigest()[:8]
# Fallback: hash first weight found
first_key = sorted(sd.keys())[0]
w = sd[first_key]
return hashlib.sha256(w.cpu().float().numpy().tobytes()).hexdigest()[:8]
# 8 anchor colors: R, B, G, M, C, Y, Black, White
ANCHOR_COLORS_RGB = [
(1.0, 0.0, 0.0), # Red
(0.0, 0.0, 1.0), # Blue
(0.0, 1.0, 0.0), # Green
(1.0, 0.0, 1.0), # Magenta
(0.0, 1.0, 1.0), # Cyan
(1.0, 1.0, 0.0), # Yellow
(0.0, 0.0, 0.0), # Black
(1.0, 1.0, 1.0), # White
]
def calibrate(vae, num_colors=512, image_size=512, batch_size=8):
"""Compute LCS data (PCA basis, mean, anchors) from FLUX VAE.
1. Sample num_colors solid-color images uniformly from HSV
2. VAE encode each → latent
3. Patchify → average patches per image → vector in R^64
4. PCA on all vectors → basis B [64,3], mean μ [64]
5. Encode 8 anchor colors → compute LCS coords + hue angles
Returns: LCSData
"""
device = comfy.model_management.intermediate_device()
# Step 1: Sample colors uniformly from HSV (full saturation, full value for diversity)
colors = []
for i in range(num_colors):
# Uniform sampling in HSV
h = (i * 137.508) % 360.0 / 360.0 # Golden angle for uniform coverage
s = 0.3 + 0.7 * ((i * 73) % 100) / 100.0 # Vary saturation 0.3-1.0
v = 0.3 + 0.7 * ((i * 47) % 100) / 100.0 # Vary value 0.3-1.0
# HSV to RGB
r, g, b = _hsv_to_rgb(h, s, v)
colors.append((r, g, b))
# Step 2+3: Encode and average patches
vectors = []
pbar = comfy.utils.ProgressBar(num_colors)
for batch_start in range(0, num_colors, batch_size):
batch_end = min(batch_start + batch_size, num_colors)
batch_colors = colors[batch_start:batch_end]
actual_batch = len(batch_colors)
# Create solid color images [B, H, W, 3] (BHWC format for ComfyUI VAE)
imgs = torch.zeros(actual_batch, image_size, image_size, 3, dtype=torch.float32, device="cpu")
for j, (r, g, b) in enumerate(batch_colors):
imgs[j, :, :, 0] = r
imgs[j, :, :, 1] = g
imgs[j, :, :, 2] = b
# VAE encode → [B, 16, H/8, W/8]
latent = vae.encode(imgs[:, :, :, :3])
# Patchify → [B, L, 64]
patches, _, _ = patchify(latent)
# Average across patches → [B, 64]
avg = patches.mean(dim=1).cpu()
for j in range(actual_batch):
vectors.append(avg[j])
pbar.update(actual_batch)
# Stack all vectors: [N, 64]
X = torch.stack(vectors, dim=0).float()
# Step 4: PCA
mean = X.mean(dim=0) # [64]
X_centered = X - mean
# SVD for PCA
U, S, Vh = torch.linalg.svd(X_centered, full_matrices=False)
# Top 3 components: B = V[:, :3] (columns are principal directions)
basis = Vh[:3].T # [64, 3] (Vh rows are right singular vectors)
# Step 5: Encode 8 anchor colors → LCS coords
anchor_lcs_list = []
for r, g, b in ANCHOR_COLORS_RGB:
img = torch.zeros(1, image_size, image_size, 3, dtype=torch.float32, device="cpu")
img[0, :, :, 0] = r
img[0, :, :, 1] = g
img[0, :, :, 2] = b
latent = vae.encode(img[:, :, :, :3])
patches, _, _ = patchify(latent)
avg = patches.mean(dim=1).cpu().squeeze(0) # [64]
# Project to LCS
lcs_coord = (avg - mean) @ basis # [3]
anchor_lcs_list.append(lcs_coord)
anchor_lcs = torch.stack(anchor_lcs_list, dim=0) # [8, 3]
# Compute hue angles for 6 chromatic anchors
anchor_angles = _compute_anchor_angles(anchor_lcs, basis, mean)
return LCSData(
basis=basis,
mean=mean,
anchor_lcs=anchor_lcs,
anchor_angles=anchor_angles,
)
def _compute_anchor_angles(anchor_lcs, basis, mean):
"""Compute hue angles of the 6 chromatic anchors in the chromatic plane.
The chromatic plane is perpendicular to the achromatic axis (black→white).
Returns [6] tensor of angles in radians.
"""
black = anchor_lcs[6] # [3]
white = anchor_lcs[7] # [3]
chromatic = anchor_lcs[:6] # [6, 3]
# Achromatic axis
a = white - black
a_unit, e1, e2 = _chromatic_plane_basis(a)
# Project each chromatic anchor onto the plane and compute angle
angles = []
for i in range(6):
c = chromatic[i]
# Project onto achromatic axis
c_proj = black + ((c - black) * a).sum() / ((a * a).sum() + 1e-10) * a
# Chromatic residual
chroma = c - c_proj
x = (chroma * e1).sum()
y = (chroma * e2).sum()
angle = torch.atan2(y, x) % (2 * math.pi)
angles.append(angle)
return torch.stack(angles) # [6]
def _hsv_to_rgb(h, s, v):
"""Convert HSV to RGB (scalars in [0,1])."""
if s < 1e-10:
return v, v, v
h6 = h * 6.0
i = int(h6) % 6
f = h6 - int(h6)
p = v * (1.0 - s)
q = v * (1.0 - s * f)
t = v * (1.0 - s * (1.0 - f))
if i == 0: return v, t, p
if i == 1: return q, v, p
if i == 2: return p, v, t
if i == 3: return p, q, v
if i == 4: return t, p, v
return v, p, q