Add LUT Loader + LUT Apply nodes with bundled iPhone 15 Pro LUT
This commit is contained in:
@@ -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
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user