Add LUT Loader + LUT Apply nodes with bundled iPhone 15 Pro LUT

This commit is contained in:
monkeyforever2
2026-03-14 08:57:43 +01:00
parent ae2446cd49
commit 96187c9a33
7 changed files with 36524 additions and 3 deletions
+37 -1
View File
@@ -170,6 +170,42 @@ Once installed, find the node in the ComfyUI menu:
---
## 🎨 LUT Color Grading (NEW)
This pack includes two dedicated LUT nodes for applying `.cube` 3D LUTs — plus a bundled **iPhone 15 Pro Standard** LUT baked from our color science pipeline.
### LUT Workflow
```
[🎨 LUT Loader] → [🎨 LUT Apply] → [SaveImage]
↑
[Your Image]
```
### 🎨 LUT Loader
| Parameter | Description |
|-----------|-------------|
| `lut_name` | Dropdown of `.cube` files from the bundled `luts/` folder |
**Output:** `LUT_DATA` — connect to LUT Apply
### 🎨 LUT Apply
| Parameter | Default | Description |
|-----------|---------|-------------|
| `image` | — | Your image input |
| `lut_data` | — | Connect from LUT Loader |
| `strength` | `0.85` | LUT intensity (0.0 = original, 1.0 = full effect) |
**Output:** `IMAGE`
### Adding Custom LUTs
Drop any `.cube` 3D LUT file into the `luts/` folder inside this node pack and restart ComfyUI. It will appear in the LUT Loader dropdown automatically.
---
## 📋 Requirements
- **ComfyUI** (latest version recommended)
@@ -181,7 +217,7 @@ Once installed, find the node in the ComfyUI menu:
## 🤝 Contributing
Pull requests are welcome! If you'd like to add new camera profiles or improve the processing pipeline, feel free to open an issue or PR.
Pull requests are welcome! If you'd like to add new camera profiles, custom LUTs, or improve the processing pipeline, feel free to open an issue or PR.
## 📄 License
+10 -1
View File
@@ -11,18 +11,27 @@ Stages:
3. Camera-specific JPEG compression artifacts
4. cos⁴ lens vignetting with asymmetry
5. ISP color bias
Also includes:
- LUT Loader: Load .cube 3D LUT files (bundled iPhone 15 Pro LUT)
- LUT Apply: Apply LUTs with adjustable strength
"""
from .nodes import CameraForensicRealismEngine
from .nodes import CameraForensicRealismEngine, LUTLoader, LUTApply
NODE_CLASS_MAPPINGS = {
"CameraForensicRealismEngine": CameraForensicRealismEngine,
"LUTLoader": LUTLoader,
"LUTApply": LUTApply,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"CameraForensicRealismEngine": "🔬 Camera Forensic Realism Engine",
"LUTLoader": "🎨 LUT Loader",
"LUTApply": "🎨 LUT Apply",
}
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+133
View File
@@ -0,0 +1,133 @@
"""
Generate iPhone 15 Pro Standard .cube LUT
Bakes the CameraForensicRealism color science pipeline into a portable
33x33x33 3D LUT (.cube format). This lets users apply the iPhone look
via a simple LUT instead of running the full processing pipeline.
Run once:
python generate_iphone_lut.py
Produces: luts/iPhone_15_Pro_Standard.cube
"""
import numpy as np
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from forensic_engine import (
apply_iphone_tone_curve,
apply_p3_color_rendering,
apply_white_balance,
apply_blue_shadow_tint,
apply_highlight_warmth,
)
def generate_iphone_lut(size: int = 33) -> tuple:
"""Generate a 3D LUT by passing a color grid through the iPhone pipeline.
We use the core color science stages (tone mapping, P3 color, white balance,
blue shadows, warm highlights) but skip spatial effects (sharpening, noise,
vignette, local tone mapping, skin detection, deep fusion) since those
depend on image structure, not just color values.
Args:
size: LUT grid size (33 is standard for high-quality LUTs)
Returns:
lut: np.ndarray (size, size, size, 3)
"""
print(f"Generating {size}x{size}x{size} iPhone 15 Pro LUT...")
t0 = time.time()
# Create a 1D ramp [0, 1] with `size` steps
ramp = np.linspace(0.0, 1.0, size, dtype=np.float32)
# Build the full 3D color grid: (size^3, 3)
# Order: R changes fastest, then G, then B (cube format standard)
r, g, b = np.meshgrid(ramp, ramp, ramp, indexing='ij')
# Reshape to (size, size, size, 3) then to a fake image (size^2, size, 3)
# so we can process it through the pipeline functions
grid = np.stack([r, g, b], axis=-1) # (size, size, size, 3)
# Flatten to a 2D "image" for processing: (size*size, size, 3)
flat_h = size * size
flat_w = size
img = grid.reshape(flat_h, flat_w, 3)
# --- Apply color science stages ---
# These are the stages that depend only on pixel color, not spatial context.
# Using iPhone 15 Pro Standard defaults at moderate strength.
# White Balance (iPhone warm daylight bias)
img = apply_white_balance(img, strength=0.35, temperature=0.25, tint=0.0)
# Tone Mapping (S-curve with highlight rolloff)
img = apply_iphone_tone_curve(img, strength=0.42, highlight_rolloff=0.5,
shadow_lift=0.4, contrast=0.5)
# Display P3 Color Rendering
img = apply_p3_color_rendering(img, strength=0.35, saturation_boost=0.3,
warmth=0.3)
# Blue Shadow Tint (iPhone blue-black look)
img = apply_blue_shadow_tint(img, strength=0.28, shadow_threshold=0.35,
blue_intensity=0.5)
# Warm Highlights (golden highlight tint)
img = apply_highlight_warmth(img, strength=0.21, highlight_threshold=0.7,
warmth=0.5)
# Reshape back to LUT grid
lut = img.reshape(size, size, size, 3)
lut = np.clip(lut, 0.0, 1.0).astype(np.float32)
elapsed = time.time() - t0
print(f"LUT generated in {elapsed:.2f}s")
return lut
def write_cube_file(filepath: str, lut: np.ndarray, title: str = "iPhone 15 Pro Standard"):
"""Write a 3D LUT to .cube format.
Args:
filepath: Output .cube file path
lut: np.ndarray (N, N, N, 3) with values in [0, 1]
title: LUT title string
"""
n = lut.shape[0]
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, 'w') as f:
f.write(f'# Generated by ComfyUI-CameraForensicRealism\n')
f.write(f'# iPhone 15 Pro ISP Color Science LUT\n')
f.write(f'TITLE "{title}"\n')
f.write(f'LUT_3D_SIZE {n}\n')
f.write(f'DOMAIN_MIN 0.0 0.0 0.0\n')
f.write(f'DOMAIN_MAX 1.0 1.0 1.0\n')
f.write(f'\n')
# Write data in R-fastest order (standard .cube ordering)
for bi in range(n):
for gi in range(n):
for ri in range(n):
r, g, b = lut[bi, gi, ri]
f.write(f'{r:.6f} {g:.6f} {b:.6f}\n')
size_kb = os.path.getsize(filepath) / 1024
print(f"Written: {filepath} ({size_kb:.1f} KB)")
if __name__ == "__main__":
lut_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "luts")
out_path = os.path.join(lut_dir, "iPhone_15_Pro_Standard.cube")
lut = generate_iphone_lut(size=33)
write_cube_file(out_path, lut, title="iPhone 15 Pro Standard")
print("\nDone! LUT file ready for use.")
+179
View File
@@ -0,0 +1,179 @@
"""
LUT Engine — .cube 3D LUT Parser & Applicator
Zero-dependency implementation using numpy trilinear interpolation.
Supports the Adobe/IRIDAS .cube format (1.0 spec):
- LUT_3D_SIZE
- DOMAIN_MIN / DOMAIN_MAX
- RGB data lines
Usage:
lut, domain_min, domain_max = parse_cube_file("path/to/lut.cube")
result = apply_lut_with_strength(image, lut, domain_min, domain_max, strength=0.85)
"""
import numpy as np
import os
from typing import Tuple
def parse_cube_file(filepath: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Parse a .cube LUT file into a numpy array.
Returns:
lut: np.ndarray of shape (N, N, N, 3) — the 3D LUT data
domain_min: np.ndarray of shape (3,) — input domain minimum
domain_max: np.ndarray of shape (3,) — input domain maximum
The .cube format stores data in R-major order:
R increments fastest, then G, then B (outermost).
"""
if not os.path.isfile(filepath):
raise FileNotFoundError(f"LUT file not found: {filepath}")
lut_size = None
domain_min = np.array([0.0, 0.0, 0.0], dtype=np.float32)
domain_max = np.array([1.0, 1.0, 1.0], dtype=np.float32)
data_lines = []
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# Parse keywords
if line.startswith('TITLE'):
continue
elif line.startswith('LUT_3D_SIZE'):
lut_size = int(line.split()[-1])
elif line.startswith('LUT_1D_SIZE'):
raise ValueError("1D LUTs are not supported. Please use a 3D .cube LUT.")
elif line.startswith('DOMAIN_MIN'):
parts = line.split()[1:]
domain_min = np.array([float(x) for x in parts], dtype=np.float32)
elif line.startswith('DOMAIN_MAX'):
parts = line.split()[1:]
domain_max = np.array([float(x) for x in parts], dtype=np.float32)
else:
# Try to parse as data line (3 floats)
try:
parts = line.split()
if len(parts) >= 3:
r, g, b = float(parts[0]), float(parts[1]), float(parts[2])
data_lines.append([r, g, b])
except ValueError:
continue # Skip unparseable lines
if lut_size is None:
raise ValueError("No LUT_3D_SIZE found in .cube file")
expected_count = lut_size ** 3
if len(data_lines) != expected_count:
raise ValueError(
f"Expected {expected_count} data lines for size {lut_size}, "
f"got {len(data_lines)}"
)
# Reshape into (N, N, N, 3) — R fastest, then G, then B
lut = np.array(data_lines, dtype=np.float32).reshape(lut_size, lut_size, lut_size, 3)
return lut, domain_min, domain_max
def apply_lut_3d(image: np.ndarray, lut: np.ndarray,
domain_min: np.ndarray, domain_max: np.ndarray) -> np.ndarray:
"""Apply a 3D LUT to an image using trilinear interpolation.
Args:
image: float32 array (H, W, 3) in [0, 1]
lut: float32 array (N, N, N, 3)
domain_min: float32 array (3,)
domain_max: float32 array (3,)
Returns:
float32 array (H, W, 3) in [0, 1]
"""
h, w, _ = image.shape
n = lut.shape[0] # LUT size
# Normalize input to LUT domain [0, N-1]
domain_range = domain_max - domain_min
domain_range = np.where(domain_range < 1e-10, 1.0, domain_range)
# Scale image values to LUT index space
coords = (image - domain_min) / domain_range * (n - 1)
coords = np.clip(coords, 0, n - 1)
# Integer indices for the 8 corners of the interpolation cube
idx0 = np.floor(coords).astype(np.int32)
idx1 = np.minimum(idx0 + 1, n - 1)
# Fractional parts for interpolation weights
frac = coords - idx0.astype(np.float32)
# Flatten for indexing
r0, g0, b0 = idx0[:, :, 0], idx0[:, :, 1], idx0[:, :, 2]
r1, g1, b1 = idx1[:, :, 0], idx1[:, :, 1], idx1[:, :, 2]
fr, fg, fb = frac[:, :, 0], frac[:, :, 1], frac[:, :, 2]
# Trilinear interpolation using the 8 corners
# c000 = lut[b0, g0, r0], c100 = lut[b0, g0, r1], etc.
c000 = lut[b0, g0, r0]
c100 = lut[b0, g0, r1]
c010 = lut[b0, g1, r0]
c110 = lut[b0, g1, r1]
c001 = lut[b1, g0, r0]
c101 = lut[b1, g0, r1]
c011 = lut[b1, g1, r0]
c111 = lut[b1, g1, r1]
# Expand fractional dims for broadcasting
fr = fr[:, :, np.newaxis]
fg = fg[:, :, np.newaxis]
fb = fb[:, :, np.newaxis]
# Interpolate along R axis
c00 = c000 * (1 - fr) + c100 * fr
c01 = c001 * (1 - fr) + c101 * fr
c10 = c010 * (1 - fr) + c110 * fr
c11 = c011 * (1 - fr) + c111 * fr
# Interpolate along G axis
c0 = c00 * (1 - fg) + c10 * fg
c1 = c01 * (1 - fg) + c11 * fg
# Interpolate along B axis
result = c0 * (1 - fb) + c1 * fb
return np.clip(result, 0.0, 1.0).astype(np.float32)
def apply_lut_with_strength(image: np.ndarray, lut: np.ndarray,
domain_min: np.ndarray, domain_max: np.ndarray,
strength: float = 1.0) -> np.ndarray:
"""Apply a 3D LUT with adjustable strength (opacity).
Args:
image: float32 array (H, W, 3) in [0, 1]
lut: float32 array (N, N, N, 3)
domain_min: float32 array (3,)
domain_max: float32 array (3,)
strength: 0.0 = original image, 1.0 = full LUT
Returns:
float32 array (H, W, 3) in [0, 1]
"""
if strength <= 0:
return image.copy()
lut_result = apply_lut_3d(image, lut, domain_min, domain_max)
if strength >= 1.0:
return lut_result
# Linear blend
blended = image * (1.0 - strength) + lut_result * strength
return np.clip(blended, 0.0, 1.0).astype(np.float32)
File diff suppressed because it is too large Load Diff
+137
View File
@@ -325,3 +325,140 @@ class CameraForensicRealismEngine:
output = torch.stack(results, dim=0)
return (output,)
# ============================================================================
# LUT Loader Node
# ============================================================================
class LUTLoader:
"""
LUT Loader — Load .cube 3D LUT files.
Scans the bundled luts/ folder and presents a dropdown selector.
"""
LUTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "luts")
def __init__(self):
pass
@classmethod
def _get_lut_list(cls):
"""Discover all .cube files in the luts/ directory."""
luts_dir = cls.LUTS_DIR
if not os.path.isdir(luts_dir):
return ["No LUTs found"]
files = [f for f in os.listdir(luts_dir)
if f.lower().endswith('.cube') and os.path.isfile(os.path.join(luts_dir, f))]
files.sort()
return files if files else ["No LUTs found"]
@classmethod
def INPUT_TYPES(cls):
lut_files = cls._get_lut_list()
return {
"required": {
"lut_name": (lut_files, {
"tooltip": "Select a .cube LUT file from the bundled luts/ folder"
}),
},
}
RETURN_TYPES = ("LUT_DATA",)
RETURN_NAMES = ("lut_data",)
FUNCTION = "load_lut"
CATEGORY = "image/forensic"
DESCRIPTION = (
"LUT Loader\n\n"
"Loads a .cube 3D LUT file from the bundled luts/ folder.\n"
"Connect the output to a LUT Apply node.\n\n"
"Bundled LUT: iPhone 15 Pro Standard — baked from the\n"
"Camera Forensic Realism Engine's color science pipeline."
)
def load_lut(self, lut_name: str):
"""Load and parse the selected .cube LUT file."""
from lut_engine import parse_cube_file
filepath = os.path.join(self.LUTS_DIR, lut_name)
if not os.path.isfile(filepath):
raise FileNotFoundError(f"LUT file not found: {filepath}")
lut, domain_min, domain_max = parse_cube_file(filepath)
lut_data = {
"lut": lut,
"domain_min": domain_min,
"domain_max": domain_max,
"name": lut_name,
"path": filepath,
}
print(f"🎨 LUT Loader: Loaded '{lut_name}' ({lut.shape[0]}³ grid)")
return (lut_data,)
# ============================================================================
# LUT Apply Node
# ============================================================================
class LUTApply:
"""
LUT Apply — Apply a loaded 3D LUT to an image with adjustable strength.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"lut_data": ("LUT_DATA",),
"strength": ("FLOAT", {
"default": 0.85, "min": 0.0, "max": 1.0, "step": 0.01,
"display": "slider",
"tooltip": "LUT intensity. 0.0 = original, 1.0 = full LUT effect. 0.85 recommended."
}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "apply_lut"
CATEGORY = "image/forensic"
DESCRIPTION = (
"LUT Apply\n\n"
"Applies a 3D LUT (from LUT Loader) to your image using\n"
"trilinear interpolation for smooth, accurate color grading.\n\n"
"Strength controls the blend between original and LUT-graded.\n"
"0.85 = recommended for natural iPhone look."
)
def apply_lut(self, image: torch.Tensor, lut_data: dict, strength: float):
"""Apply the loaded LUT to each image in the batch."""
from lut_engine import apply_lut_with_strength
lut = lut_data["lut"]
domain_min = lut_data["domain_min"]
domain_max = lut_data["domain_max"]
lut_name = lut_data.get("name", "Unknown")
batch_size = image.shape[0]
results = []
for i in range(batch_size):
img_np = image[i].cpu().numpy().astype(np.float32)
img_np = np.clip(img_np, 0.0, 1.0)
processed = apply_lut_with_strength(img_np, lut, domain_min, domain_max, strength)
results.append(torch.from_numpy(processed))
output = torch.stack(results, dim=0)
print(f"🎨 LUT Apply: Applied '{lut_name}' at {strength:.0%} strength")
return (output,)
+84 -1
View File
@@ -144,6 +144,7 @@ def test_node():
color_saturation=0.3, color_warmth=0.3,
enable_local_tone=True, local_tone_strength=0.35, detail_boost=0.4,
enable_skin_rendering=True, skin_strength=0.5, skin_warmth=0.4,
enable_deep_fusion=True, fusion_strength=0.6, fusion_texture_freq=0.5,
enable_white_balance=True, wb_strength=0.5, wb_temperature=0.25, wb_tint=0.0,
enable_color_grading=True, blue_shadows=0.4, warm_highlights=0.3,
enable_sharpening=True, sharpen_strength=0.3,
@@ -168,12 +169,93 @@ def test_js_exists():
return False
def test_lut_parse():
print("\n--- Test 11: LUT Parse ---")
from lut_engine import parse_cube_file
lut_path = os.path.join(os.path.dirname(__file__), "luts", "iPhone_15_Pro_Standard.cube")
if not os.path.exists(lut_path):
print(f" [FAIL] LUT file missing: {lut_path}"); return False
lut, dmin, dmax = parse_cube_file(lut_path)
ok = True
if lut.shape != (33, 33, 33, 3):
print(f" [FAIL] Shape: {lut.shape}"); ok = False
if lut.min() < 0 or lut.max() > 1:
print(f" [FAIL] Range: [{lut.min():.4f}, {lut.max():.4f}]"); ok = False
if np.any(np.isnan(lut)):
print(f" [FAIL] NaN in LUT"); ok = False
if ok: print(f" [PASS] LUT parsed: {lut.shape}, range [{lut.min():.4f}, {lut.max():.4f}]")
return ok
def test_lut_apply():
print("\n--- Test 12: LUT Apply ---")
from lut_engine import parse_cube_file, apply_lut_3d
lut_path = os.path.join(os.path.dirname(__file__), "luts", "iPhone_15_Pro_Standard.cube")
lut, dmin, dmax = parse_cube_file(lut_path)
img = make_test_image()
result = apply_lut_3d(img, lut, dmin, dmax)
ok = check(result, img.shape, "LUT apply")
diff = np.mean(np.abs(result - img))
if diff < 1e-6:
print(f" [FAIL] LUT had no effect (diff={diff:.6f})"); ok = False
else:
print(f" [PASS] LUT modified image (mean diff={diff:.4f})")
return ok
def test_lut_strength():
print("\n--- Test 13: LUT Strength ---")
from lut_engine import parse_cube_file, apply_lut_with_strength
lut_path = os.path.join(os.path.dirname(__file__), "luts", "iPhone_15_Pro_Standard.cube")
lut, dmin, dmax = parse_cube_file(lut_path)
img = make_test_image()
ok = True
# Strength 0 = original
r0 = apply_lut_with_strength(img, lut, dmin, dmax, 0.0)
diff0 = np.max(np.abs(r0 - img))
if diff0 > 1e-6:
print(f" [FAIL] Strength 0 changed image (max diff={diff0:.6f})"); ok = False
else:
print(f" [PASS] Strength 0.0 = original")
# Strength 0.5 = blend
r05 = apply_lut_with_strength(img, lut, dmin, dmax, 0.5)
r10 = apply_lut_with_strength(img, lut, dmin, dmax, 1.0)
diff05 = np.mean(np.abs(r05 - img))
diff10 = np.mean(np.abs(r10 - img))
if diff05 < diff10:
print(f" [PASS] Strength 0.5 ({diff05:.4f}) < 1.0 ({diff10:.4f})")
else:
print(f" [FAIL] Strength scaling broken"); ok = False
return ok
def test_lut_nodes():
print("\n--- Test 14: LUT Nodes ---")
from nodes import LUTLoader, LUTApply
ok = True
# LUTLoader
loader_inputs = LUTLoader.INPUT_TYPES()["required"]
if "lut_name" in loader_inputs:
print(f" [PASS] LUTLoader has 'lut_name' input")
else:
print(f" [FAIL] LUTLoader missing 'lut_name'"); ok = False
# LUTApply
apply_inputs = LUTApply.INPUT_TYPES()["required"]
for name in ["image", "lut_data", "strength"]:
if name in apply_inputs:
print(f" [PASS] LUTApply has '{name}' input")
else:
print(f" [FAIL] LUTApply missing '{name}'"); ok = False
return ok
if __name__ == "__main__":
print("=" * 60)
print("Camera Forensic Realism Engine v3 - Test Suite")
print("=" * 60)
tests = [test_tone, test_p3, test_local, test_skin, test_blue_shadows,
test_warm_highlights, test_wb, test_pipeline, test_node, test_js_exists]
test_warm_highlights, test_wb, test_pipeline, test_node, test_js_exists,
test_lut_parse, test_lut_apply, test_lut_strength, test_lut_nodes]
results = []
for t in tests:
try: results.append(t())
@@ -186,3 +268,4 @@ if __name__ == "__main__":
print(f"Results: {p}/{len(results)} tests passed")
print("ALL PASSED!" if p == len(results) else "Some failed")
print("=" * 60)