Engine rewrite: - Remove fake Display P3 stage (sRGB->XYZ->P3 round-trip was a no-op hiding a plain saturation multiply) and redundant strength multipliers - Fix box blur: was a trailing window shifted diagonally with zero-padded borders; now centered with edge replication - Fix non-monotonic highlight soft-clip (banding above white) - White balance and tone mapping in linear light (luminance-preserving) - Smart HDR: Durand-style log-luminance base/detail local tone mapping - Color science in Oklab: hue-preserving vibrance, real skin-tone protection mask, subtle split-tone signature - Single two-scale detail stage (texture + clarity) with tanh halo suppression, replacing three overlapping sharpeners - Photon-weighted grain, natural-falloff vignette in linear light - 15 iPhone 17 Photographic Styles presets (moods + undertones + B&W) - Delete dead camera_profiles.py LUT engine: tetrahedral interpolation (industry standard) with chunked memory-safe application; document that the Apple Log LUT expects log input UI: rebuild for new params; sizing via getMinHeight/getMaxHeight + computeSize (no more guessed heights), remount safety net for workflow reload store clears, sanitize values restored by position from old saves Tests: 16-case suite covering blur centering, Oklab round-trip, styles, skin protection, tetrahedral exactness, node API Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
375 lines
16 KiB
Python
375 lines
16 KiB
Python
"""
|
|
Camera Forensic Realism Engine - ComfyUI Node Definition v4
|
|
iPhone 17 ISP color science + Photographic Styles + custom themed UI.
|
|
"""
|
|
|
|
import numpy as np
|
|
import torch
|
|
import os
|
|
|
|
try:
|
|
from .forensic_engine import process_iphone_realism, PHOTOGRAPHIC_STYLES
|
|
from .lut_engine import parse_cube_file, apply_lut_with_strength
|
|
except ImportError:
|
|
from forensic_engine import process_iphone_realism, PHOTOGRAPHIC_STYLES
|
|
from lut_engine import parse_cube_file, apply_lut_with_strength
|
|
|
|
|
|
class CameraForensicRealismEngine:
|
|
"""
|
|
Camera Forensic Realism Engine
|
|
Makes AI images look like iPhone 17 photos — honest color science only.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
|
|
# ===================== MASTER =====================
|
|
"photographic_style": (list(PHOTOGRAPHIC_STYLES.keys()), {
|
|
"default": "Standard",
|
|
"tooltip": "iPhone 17 Photographic Style. Undertones (Amber/Gold/Rose Gold/Cool Rose/Neutral) steer warmth; moods (Vibrant/Natural/Luminous/Dramatic/Quiet/Cozy/Ethereal/B&W) steer tone+color. Applied as offsets on top of your sliders."
|
|
}),
|
|
"master_strength": ("FLOAT", {
|
|
"default": 0.85, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Final blend between original and processed image. One honest mix control — per-stage sliders are absolute."
|
|
}),
|
|
"seed": ("INT", {
|
|
"default": 0, "min": 0, "max": 0xFFFFFFFF,
|
|
"tooltip": "Random seed for grain reproducibility"
|
|
}),
|
|
|
|
# ===================== WHITE BALANCE =====================
|
|
"enable_white_balance": ("BOOLEAN", {
|
|
"default": True,
|
|
"label_on": "WHITE BAL: ON", "label_off": "WHITE BAL: OFF",
|
|
"tooltip": "Channel gains in linear light, luminance-preserving (warming doesn't brighten)"
|
|
}),
|
|
"wb_temperature": ("FLOAT", {
|
|
"default": 0.12, "min": -1.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Cool (blue) <----> Warm (orange). iPhone AWB sits slightly warm: ~0.1-0.15"
|
|
}),
|
|
"wb_tint": ("FLOAT", {
|
|
"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Green <----> Magenta. 0 = neutral"
|
|
}),
|
|
|
|
# ===================== GLOBAL TONE =====================
|
|
"enable_tone": ("BOOLEAN", {
|
|
"default": True,
|
|
"label_on": "TONE: ON", "label_off": "TONE: OFF",
|
|
"tooltip": "Linear-light tone: exposure, shadow lift, midtone contrast, filmic highlight rolloff"
|
|
}),
|
|
"exposure": ("FLOAT", {
|
|
"default": 0.05, "min": -1.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Exposure in EV. iPhone meters slightly bright: ~0.05"
|
|
}),
|
|
"contrast": ("FLOAT", {
|
|
"default": 0.15, "min": -1.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Midtone contrast around the 0.18 photographic pivot. iPhone punch ~0.15"
|
|
}),
|
|
"shadows": ("FLOAT", {
|
|
"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Luminance-masked shadow lift + tiny black-point lift. iPhone never crushes blacks"
|
|
}),
|
|
"highlights": ("FLOAT", {
|
|
"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Reinhard soft-knee highlight rolloff — smooth, monotonic, never blows out"
|
|
}),
|
|
|
|
# ===================== SMART HDR =====================
|
|
"enable_smart_hdr": ("BOOLEAN", {
|
|
"default": True,
|
|
"label_on": "SMART HDR: ON", "label_off": "SMART HDR: OFF",
|
|
"tooltip": "Local tone mapping in log-luminance: compresses the base layer, keeps detail. Like Smart HDR 5 fusion"
|
|
}),
|
|
"hdr_strength": ("FLOAT", {
|
|
"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "How much local dynamic range compression (shadows up, highlights down — locally)"
|
|
}),
|
|
|
|
# ===================== COLOR SCIENCE =====================
|
|
"enable_color": ("BOOLEAN", {
|
|
"default": True,
|
|
"label_on": "COLOR: ON", "label_off": "COLOR: OFF",
|
|
"tooltip": "Oklab color rendering: hue-preserving vibrance, skin protection, iPhone split-tone"
|
|
}),
|
|
"vibrance": ("FLOAT", {
|
|
"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Smart saturation: boosts muted colors more, leaves saturated ones alone. Hue-preserving (Oklab)"
|
|
}),
|
|
"skin_protection": ("FLOAT", {
|
|
"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Real skin mask (Oklab hue ~50°) that shields skin from vibrance and caps skin chroma — no orange faces"
|
|
}),
|
|
"shadow_tint": ("FLOAT", {
|
|
"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Cool/blue tint in shadows — the subtle iPhone blue-black signature"
|
|
}),
|
|
"highlight_warmth": ("FLOAT", {
|
|
"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Golden warmth in highlights — complements the cool shadows"
|
|
}),
|
|
|
|
# ===================== DETAIL =====================
|
|
"enable_detail": ("BOOLEAN", {
|
|
"default": True,
|
|
"label_on": "DETAIL: ON", "label_off": "DETAIL: OFF",
|
|
"tooltip": "Single coherent detail stage: fine texture + mid clarity, halo-suppressed, luminance-only"
|
|
}),
|
|
"texture": ("FLOAT", {
|
|
"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Fine-scale detail (~1px): pores, fabric, hair. The 'computational crunch'"
|
|
}),
|
|
"clarity": ("FLOAT", {
|
|
"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Mid-scale local punch (~2% of frame). Keep low for natural look"
|
|
}),
|
|
|
|
# ===================== OPTICS & SENSOR =====================
|
|
"enable_optics": ("BOOLEAN", {
|
|
"default": True,
|
|
"label_on": "OPTICS: ON", "label_off": "OPTICS: OFF",
|
|
"tooltip": "Lens vignette (linear light) + photon-weighted sensor grain"
|
|
}),
|
|
"grain": ("FLOAT", {
|
|
"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Sensor noise, stronger in shadows like a real sensor. iPhone base ISO ~0.15-0.25"
|
|
}),
|
|
"vignette": ("FLOAT", {
|
|
"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"display": "slider",
|
|
"tooltip": "Natural 1/(1+kr²)² illumination falloff applied in linear light. iPhone is well-corrected: keep low"
|
|
}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("image",)
|
|
FUNCTION = "apply_iphone_realism"
|
|
CATEGORY = "image/forensic"
|
|
|
|
DESCRIPTION = (
|
|
"Camera Forensic Realism Engine v4\n\n"
|
|
"iPhone 17 ISP emulation — honest color science:\n"
|
|
"- White balance in linear light (luminance-preserving)\n"
|
|
"- Filmic global tone (0.18 pivot, Reinhard rolloff)\n"
|
|
"- Smart HDR local tone mapping (log-luminance base/detail)\n"
|
|
"- Oklab color rendering (vibrance, skin protection, split-tone)\n"
|
|
"- Two-scale halo-suppressed detail\n"
|
|
"- Linear-light vignette + photon-weighted grain\n"
|
|
"- 14 iPhone 17 Photographic Styles presets"
|
|
)
|
|
|
|
def apply_iphone_realism(self, image: torch.Tensor,
|
|
photographic_style: str,
|
|
master_strength: float,
|
|
seed: int,
|
|
enable_white_balance: bool,
|
|
wb_temperature: float,
|
|
wb_tint: float,
|
|
enable_tone: bool,
|
|
exposure: float,
|
|
contrast: float,
|
|
shadows: float,
|
|
highlights: float,
|
|
enable_smart_hdr: bool,
|
|
hdr_strength: float,
|
|
enable_color: bool,
|
|
vibrance: float,
|
|
skin_protection: float,
|
|
shadow_tint: float,
|
|
highlight_warmth: float,
|
|
enable_detail: bool,
|
|
texture: float,
|
|
clarity: float,
|
|
enable_optics: bool,
|
|
grain: float,
|
|
vignette: float):
|
|
"""Main processing function."""
|
|
|
|
batch_size = image.shape[0]
|
|
results = []
|
|
|
|
for i in range(batch_size):
|
|
img_np = image[i].cpu().numpy().astype(np.float32)
|
|
|
|
processed = process_iphone_realism(
|
|
image=img_np,
|
|
photographic_style=photographic_style,
|
|
master_strength=master_strength,
|
|
seed=seed + i,
|
|
enable_white_balance=enable_white_balance,
|
|
wb_temperature=wb_temperature,
|
|
wb_tint=wb_tint,
|
|
enable_tone=enable_tone,
|
|
exposure=exposure,
|
|
contrast=contrast,
|
|
shadows=shadows,
|
|
highlights=highlights,
|
|
enable_smart_hdr=enable_smart_hdr,
|
|
hdr_strength=hdr_strength,
|
|
enable_color=enable_color,
|
|
vibrance=vibrance,
|
|
skin_protection=skin_protection,
|
|
shadow_tint=shadow_tint,
|
|
highlight_warmth=highlight_warmth,
|
|
enable_detail=enable_detail,
|
|
texture=texture,
|
|
clarity=clarity,
|
|
enable_optics=enable_optics,
|
|
grain=grain,
|
|
vignette=vignette,
|
|
)
|
|
|
|
results.append(torch.from_numpy(processed))
|
|
|
|
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")
|
|
|
|
@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. Note: 'AppleLog2_to_Rec709' is a technical conversion LUT — it expects Apple Log footage as input, not regular sRGB images."
|
|
}),
|
|
},
|
|
}
|
|
|
|
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 LUTs:\n"
|
|
"- AppleLog2_to_Rec709: official Apple Log -> Rec.709 conversion.\n"
|
|
" Only correct on Apple Log footage, NOT on regular sRGB images.\n"
|
|
"- Portra 800: creative film-emulation look for sRGB images.\n\n"
|
|
"Drop your own .cube files into luts/ and restart ComfyUI."
|
|
)
|
|
|
|
def load_lut(self, lut_name: str):
|
|
"""Load and parse the selected .cube LUT 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.
|
|
"""
|
|
|
|
@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."
|
|
}),
|
|
},
|
|
}
|
|
|
|
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"
|
|
"tetrahedral interpolation — the industry-standard method\n"
|
|
"(Resolve, camera ISPs), more accurate than trilinear.\n\n"
|
|
"Strength controls the blend between original and LUT-graded."
|
|
)
|
|
|
|
def apply_lut(self, image: torch.Tensor, lut_data: dict, strength: float):
|
|
"""Apply the loaded LUT to each image in the batch."""
|
|
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,)
|