commit 96f011bf60d1f49e75e279e9aeb11b1df6dab501 Author: monkeyforever2 Date: Sun Mar 8 13:34:11 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ab8658 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.env +.idea/ +.vscode/ +venv/ +env/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..18f87f2 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Camera Forensic Realism Engine v2 + +Makes AI-generated images look like they were shot on an **iPhone 15 Pro** by emulating Apple's ISP color science pipeline. + +## Processing Stages + +| # | Stage | What It Does | +|---|-------|-------------| +| 1 | **White Balance** | iPhone warm daylight bias (temperature + tint) | +| 2 | **Tone Mapping** | S-curve with filmic highlight rolloff + shadow lifting | +| 3 | **Display P3 Color** | Wider gamut rendering via CIE XYZ transforms (richer reds/greens) | +| 4 | **Smart HDR** | Local tone mapping for micro-contrast and detail pop | +| 5 | **Real Tone** | Skin-targeted rendering with HSL detection (warm, not orange) | +| 6 | **ISP Sharpening** | Luminance-only unsharp mask (Apple crispness) | +| 7 | **Sensor FX** | Subtle luminance noise + gentle f/1.78 vignette | + +## Installation + +Copy into `ComfyUI/custom_nodes/ComfyUI-CameraForensicRealism/` and restart ComfyUI. + +Node appears under: **image/forensic** -> **Camera Forensic Realism Engine** + +## Quick Start + +Place after detailers/post-processing, before SaveImage. Defaults are tuned for iPhone 15 Pro Standard style. + +**No extra dependencies** - uses only numpy + torch (built into ComfyUI). diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..1d7f4fb --- /dev/null +++ b/__init__.py @@ -0,0 +1,28 @@ +""" +ComfyUI-CameraForensicRealism +Camera Forensic Realism Engine β€” Custom Node for ComfyUI + +Injects real camera sensor fingerprints into AI-generated images to make +them forensically indistinguishable from real photographs. + +Stages: +1. Bayer CFA demosaicing artifacts +2. Sensor noise (shot + read + dark current + hot pixels) +3. Camera-specific JPEG compression artifacts +4. cos⁴ lens vignetting with asymmetry +5. ISP color bias +""" + +from .nodes import CameraForensicRealismEngine + +NODE_CLASS_MAPPINGS = { + "CameraForensicRealismEngine": CameraForensicRealismEngine, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "CameraForensicRealismEngine": "πŸ”¬ Camera Forensic Realism Engine", +} + +WEB_DIRECTORY = "./js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/camera_profiles.py b/camera_profiles.py new file mode 100644 index 0000000..f2a211f --- /dev/null +++ b/camera_profiles.py @@ -0,0 +1,319 @@ +""" +Camera Forensic Realism Engine β€” Camera Profiles +Real-world sensor data, JPEG quantization tables, and vignetting curves +for multiple camera models. + +Author: Custom-built for Z-Image-Turbo workflow +""" + +import numpy as np + +# ============================================================================ +# Standard JPEG Luminance Quantization Table (IJG baseline, Q=50) +# Used as the basis for camera-specific scaling +# ============================================================================ +STANDARD_LUMINANCE_QT = np.array([ + [16, 11, 10, 16, 24, 40, 51, 61], + [12, 12, 14, 19, 26, 58, 60, 55], + [14, 13, 16, 24, 40, 57, 69, 56], + [14, 17, 22, 29, 51, 87, 80, 62], + [18, 22, 37, 56, 68, 109, 103, 77], + [24, 35, 55, 64, 81, 104, 113, 92], + [49, 64, 78, 87, 103, 121, 120, 101], + [72, 92, 95, 98, 112, 100, 103, 99], +], dtype=np.float32) + +STANDARD_CHROMINANCE_QT = np.array([ + [17, 18, 24, 47, 99, 99, 99, 99], + [18, 21, 26, 66, 99, 99, 99, 99], + [24, 26, 56, 99, 99, 99, 99, 99], + [47, 66, 99, 99, 99, 99, 99, 99], + [99, 99, 99, 99, 99, 99, 99, 99], + [99, 99, 99, 99, 99, 99, 99, 99], + [99, 99, 99, 99, 99, 99, 99, 99], + [99, 99, 99, 99, 99, 99, 99, 99], +], dtype=np.float32) + + +def _scale_qt(base_qt: np.ndarray, quality: int) -> np.ndarray: + """Scale a quantization table by JPEG quality factor (1-100). + Uses the IJG standard scaling formula.""" + if quality <= 0: + quality = 1 + if quality > 100: + quality = 100 + if quality < 50: + scale = 5000 / quality + else: + scale = 200 - quality * 2 + qt = np.floor((base_qt * scale + 50) / 100) + qt = np.clip(qt, 1, 255) + return qt.astype(np.float32) + + +def _make_camera_qt(lum_offsets: np.ndarray, chrom_offsets: np.ndarray, + base_quality: int) -> dict: + """Create camera-specific quantization tables by applying per-frequency + offsets to the scaled standard tables. Real camera ISPs have proprietary + tables that deviate from the IJG standard β€” these offsets simulate that.""" + lum = _scale_qt(STANDARD_LUMINANCE_QT, base_quality) + lum_offsets + chrom = _scale_qt(STANDARD_CHROMINANCE_QT, base_quality) + chrom_offsets + lum = np.clip(lum, 1, 255).astype(np.float32) + chrom = np.clip(chrom, 1, 255).astype(np.float32) + return {"luminance": lum, "chrominance": chrom} + + +# ============================================================================ +# Camera Profiles +# ============================================================================ + +CAMERA_PROFILES = { + "iPhone 15 Pro": { + "description": "Apple iPhone 15 Pro β€” Sony IMX803 48MP sensor", + "sensor": { + "read_noise_e": 1.6, # electrons RMS + "dark_current_e_per_s": 0.5, # electrons/second at 25Β°C + "quantum_efficiency": 0.56, + "bit_depth": 12, + "full_well_capacity": 8200, # electrons + "hot_pixel_probability": 0.00008, + "base_iso": 50, + }, + "bayer_pattern": "RGGB", + "jpeg_tables": _make_camera_qt( + # Apple ISP: aggressive mid-frequency preservation, slightly + # softer high-frequency quantization for "smooth" Apple look + lum_offsets=np.array([ + [ 0, 0, 0, -1, -1, 0, 1, 2], + [ 0, 0, 0, -1, -1, 1, 1, 1], + [ 0, 0, 0, -1, 0, 1, 2, 1], + [-1, -1, -1, 0, 1, 2, 2, 1], + [-1, -1, 0, 1, 2, 3, 3, 2], + [ 0, 0, 1, 1, 2, 2, 3, 2], + [ 1, 1, 2, 2, 2, 3, 3, 2], + [ 2, 2, 2, 2, 3, 2, 2, 2], + ], dtype=np.float32), + chrom_offsets=np.array([ + [ 0, 0, -1, -2, 0, 0, 0, 0], + [ 0, 0, -1, -1, 0, 0, 0, 0], + [-1, -1, -2, -1, 0, 0, 0, 0], + [-2, -1, -1, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + ], dtype=np.float32), + base_quality=92, + ), + "vignetting": { + "cos4_strength": 0.35, # how strongly cos⁴ law applies + "asymmetry_x": 0.02, # lens decentering X + "asymmetry_y": -0.01, # lens decentering Y + "radial_falloff": 2.2, # power curve exponent + }, + "color_bias": { # subtle ISP color shift + "r_gain": 1.01, + "g_gain": 1.00, + "b_gain": 0.985, + }, + }, + + "Samsung Galaxy S24": { + "description": "Samsung Galaxy S24 Ultra β€” Samsung GN2 50MP sensor", + "sensor": { + "read_noise_e": 2.1, + "dark_current_e_per_s": 0.8, + "quantum_efficiency": 0.51, + "bit_depth": 12, + "full_well_capacity": 10500, + "hot_pixel_probability": 0.00012, + "base_iso": 50, + }, + "bayer_pattern": "GRBG", + "jpeg_tables": _make_camera_qt( + # Samsung ISP: more aggressive sharpening in tables, + # preserves more high-frequency detail + lum_offsets=np.array([ + [ 0, 0, -1, -1, -2, -1, 0, 1], + [ 0, 0, -1, -2, -2, -1, 0, 0], + [-1, -1, -1, -2, -1, 0, 1, 0], + [-1, -2, -2, -1, 0, 1, 1, 1], + [-2, -2, -1, 0, 1, 2, 2, 1], + [-1, -1, 0, 0, 1, 1, 2, 1], + [ 0, 0, 1, 1, 1, 2, 2, 1], + [ 1, 1, 1, 1, 2, 1, 1, 1], + ], dtype=np.float32), + chrom_offsets=np.array([ + [ 0, 0, 0, -1, 0, 0, 0, 0], + [ 0, 0, 0, -1, 0, 0, 0, 0], + [ 0, 0, -1, 0, 0, 0, 0, 0], + [-1, -1, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + ], dtype=np.float32), + base_quality=95, + ), + "vignetting": { + "cos4_strength": 0.30, + "asymmetry_x": -0.015, + "asymmetry_y": 0.008, + "radial_falloff": 2.0, + }, + "color_bias": { + "r_gain": 1.015, + "g_gain": 1.00, + "b_gain": 0.98, + }, + }, + + "Sony A7IV": { + "description": "Sony A7 IV β€” Sony IMX554 33MP full-frame sensor", + "sensor": { + "read_noise_e": 1.2, + "dark_current_e_per_s": 0.3, + "quantum_efficiency": 0.62, + "bit_depth": 14, + "full_well_capacity": 44000, + "hot_pixel_probability": 0.00005, + "base_iso": 100, + }, + "bayer_pattern": "RGGB", + "jpeg_tables": _make_camera_qt( + # Sony ISP: very clean, preserves fine detail across all freqs + lum_offsets=np.array([ + [ 0, 0, 0, 0, -1, -1, 0, 0], + [ 0, 0, 0, 0, -1, -1, 0, 0], + [ 0, 0, 0, 0, 0, -1, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [-1, -1, 0, 0, 0, 1, 1, 0], + [-1, -1, -1, 0, 0, 0, 1, 0], + [ 0, 0, 0, 0, 0, 1, 1, 0], + [ 0, 0, 0, 0, 1, 0, 0, 0], + ], dtype=np.float32), + chrom_offsets=np.zeros((8, 8), dtype=np.float32), + base_quality=97, + ), + "vignetting": { + "cos4_strength": 0.45, # full-frame = more vignetting + "asymmetry_x": 0.005, + "asymmetry_y": -0.003, + "radial_falloff": 2.4, + }, + "color_bias": { + "r_gain": 1.005, + "g_gain": 1.00, + "b_gain": 0.995, + }, + }, + + "Canon EOS R5": { + "description": "Canon EOS R5 β€” Canon 45MP full-frame CMOS sensor", + "sensor": { + "read_noise_e": 2.8, + "dark_current_e_per_s": 0.6, + "quantum_efficiency": 0.49, + "bit_depth": 14, + "full_well_capacity": 37000, + "hot_pixel_probability": 0.00007, + "base_iso": 100, + }, + "bayer_pattern": "RGGB", + "jpeg_tables": _make_camera_qt( + # Canon ISP: warmer processing, slightly more chroma smoothing + lum_offsets=np.array([ + [ 0, 0, 0, 0, 0, 1, 1, 1], + [ 0, 0, 0, 0, 0, 1, 1, 1], + [ 0, 0, 0, 0, 1, 1, 2, 1], + [ 0, 0, 0, 0, 1, 2, 2, 1], + [ 0, 0, 1, 1, 2, 3, 3, 2], + [ 1, 1, 1, 1, 2, 2, 3, 2], + [ 1, 1, 2, 2, 2, 3, 3, 2], + [ 1, 1, 1, 1, 2, 2, 2, 2], + ], dtype=np.float32), + chrom_offsets=np.array([ + [ 0, 1, 1, 0, 0, 0, 0, 0], + [ 1, 1, 1, 0, 0, 0, 0, 0], + [ 1, 1, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + ], dtype=np.float32), + base_quality=94, + ), + "vignetting": { + "cos4_strength": 0.42, + "asymmetry_x": -0.008, + "asymmetry_y": 0.005, + "radial_falloff": 2.3, + }, + "color_bias": { + "r_gain": 1.02, + "g_gain": 1.00, + "b_gain": 0.975, + }, + }, + + "Google Pixel 8": { + "description": "Google Pixel 8 Pro β€” Samsung GNK 50MP sensor", + "sensor": { + "read_noise_e": 1.8, + "dark_current_e_per_s": 0.7, + "quantum_efficiency": 0.53, + "bit_depth": 12, + "full_well_capacity": 9500, + "hot_pixel_probability": 0.0001, + "base_iso": 57, + }, + "bayer_pattern": "BGGR", + "jpeg_tables": _make_camera_qt( + # Google ISP (HDRNet): heavy computational photography, + # unique mid-frequency boosting in tables + lum_offsets=np.array([ + [ 0, 0, -1, -2, -2, -1, 0, 1], + [ 0, 0, -1, -2, -2, -1, 0, 0], + [-1, -1, -2, -2, -1, 0, 1, 0], + [-2, -2, -2, -1, 0, 1, 1, 1], + [-2, -2, -1, 0, 1, 2, 2, 1], + [-1, -1, 0, 1, 1, 2, 2, 1], + [ 0, 0, 1, 1, 2, 2, 2, 1], + [ 1, 1, 1, 1, 2, 1, 1, 1], + ], dtype=np.float32), + chrom_offsets=np.array([ + [ 0, 0, -1, -2, 0, 0, 0, 0], + [ 0, 0, -1, -1, 0, 0, 0, 0], + [-1, -1, -2, 0, 0, 0, 0, 0], + [-2, -1, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + [ 0, 0, 0, 0, 0, 0, 0, 0], + ], dtype=np.float32), + base_quality=93, + ), + "vignetting": { + "cos4_strength": 0.32, + "asymmetry_x": 0.01, + "asymmetry_y": -0.012, + "radial_falloff": 2.1, + }, + "color_bias": { + "r_gain": 1.008, + "g_gain": 1.005, + "b_gain": 0.99, + }, + }, +} + + +def get_profile(name: str) -> dict: + """Get a camera profile by name. Returns None if not found.""" + return CAMERA_PROFILES.get(name, None) + + +def get_profile_names() -> list: + """Return list of all available camera profile names.""" + return list(CAMERA_PROFILES.keys()) diff --git a/forensic_engine.py b/forensic_engine.py new file mode 100644 index 0000000..f089f61 --- /dev/null +++ b/forensic_engine.py @@ -0,0 +1,787 @@ +""" +Camera Forensic Realism Engine - Core Processing Pipeline v2 +Complete rewrite focused on accurate iPhone ISP color science emulation. + +Instead of cheap "filter" effects, this implements the actual processing +stages of Apple's Image Signal Processor: + + 1. iPhone Tone Mapping (S-curve with highlight rolloff + shadow lift) + 2. Display P3 Color Rendering (wider gamut β†’ natural rich colors) + 3. Smart HDR Local Tone Mapping (adaptive micro-contrast) + 4. Real Tone Skin Rendering (warm, natural skin preservation) + 5. ISP Sharpening (unsharp mask like Apple's ISP) + 6. Subtle Sensor Characteristics (minimal, tasteful noise + vignette) + +All processing operates on numpy float32 arrays in [0, 1] range. +""" + +import numpy as np +from typing import Optional + + +# ============================================================================ +# UTILITY: Color Space Conversions +# ============================================================================ + +def _srgb_to_linear(img: np.ndarray) -> np.ndarray: + """Decode sRGB gamma to linear light (IEC 61966-2-1).""" + out = np.where( + img <= 0.04045, + img / 12.92, + np.power(np.clip((img + 0.055) / 1.055, 0, None), 2.4) + ) + return out.astype(np.float32) + + +def _linear_to_srgb(img: np.ndarray) -> np.ndarray: + """Encode linear light to sRGB gamma.""" + out = np.where( + img <= 0.0031308, + img * 12.92, + 1.055 * np.power(np.clip(img, 0, None), 1.0 / 2.4) - 0.055 + ) + return np.clip(out, 0.0, 1.0).astype(np.float32) + + +def _rgb_to_hsl(img: np.ndarray) -> np.ndarray: + """Convert RGB [0,1] to HSL [0,1]. Vectorized.""" + r, g, b = img[:, :, 0], img[:, :, 1], img[:, :, 2] + + cmax = np.maximum(np.maximum(r, g), b) + cmin = np.minimum(np.minimum(r, g), b) + delta = cmax - cmin + + # Lightness + l = (cmax + cmin) / 2.0 + + # Saturation + s = np.where(delta < 1e-7, 0.0, + delta / (1.0 - np.abs(2.0 * l - 1.0) + 1e-7)) + + # Hue + h = np.zeros_like(l) + mask_r = (cmax == r) & (delta > 1e-7) + mask_g = (cmax == g) & (delta > 1e-7) + mask_b = (cmax == b) & (delta > 1e-7) + + h[mask_r] = ((g[mask_r] - b[mask_r]) / delta[mask_r]) % 6 + h[mask_g] = ((b[mask_g] - r[mask_g]) / delta[mask_g]) + 2 + h[mask_b] = ((r[mask_b] - g[mask_b]) / delta[mask_b]) + 4 + h = h / 6.0 + + return np.stack([h, s, l], axis=-1).astype(np.float32) + + +def _hsl_to_rgb(hsl: np.ndarray) -> np.ndarray: + """Convert HSL [0,1] to RGB [0,1]. Vectorized.""" + h, s, l = hsl[:, :, 0], hsl[:, :, 1], hsl[:, :, 2] + + c = (1.0 - np.abs(2.0 * l - 1.0)) * s + x = c * (1.0 - np.abs((h * 6.0) % 2 - 1.0)) + m = l - c / 2.0 + + h6 = h * 6.0 + r = np.zeros_like(h) + g = np.zeros_like(h) + b = np.zeros_like(h) + + # Sector 0: [0, 1) + mask = (h6 >= 0) & (h6 < 1) + r[mask] = c[mask]; g[mask] = x[mask] + # Sector 1: [1, 2) + mask = (h6 >= 1) & (h6 < 2) + r[mask] = x[mask]; g[mask] = c[mask] + # Sector 2: [2, 3) + mask = (h6 >= 2) & (h6 < 3) + g[mask] = c[mask]; b[mask] = x[mask] + # Sector 3: [3, 4) + mask = (h6 >= 3) & (h6 < 4) + g[mask] = x[mask]; b[mask] = c[mask] + # Sector 4: [4, 5) + mask = (h6 >= 4) & (h6 < 5) + r[mask] = x[mask]; b[mask] = c[mask] + # Sector 5: [5, 6) + mask = (h6 >= 5) & (h6 < 6) + r[mask] = c[mask]; b[mask] = x[mask] + + result = np.stack([r + m, g + m, b + m], axis=-1) + return np.clip(result, 0.0, 1.0).astype(np.float32) + + +# ============================================================================ +# STAGE 1: iPhone Tone Mapping +# ============================================================================ + +def apply_iphone_tone_curve(image: np.ndarray, strength: float = 0.7, + highlight_rolloff: float = 0.6, + shadow_lift: float = 0.4, + contrast: float = 0.5) -> np.ndarray: + """Apply Apple-style S-curve tone mapping. + + iPhone ISP characteristics: + - Smooth highlight compression (prevents clipping, preserves sky detail) + - Shadow lifting (brightens dark areas for visibility without looking flat) + - Mid-tone contrast boost (punchy but not harsh) + - Filmic rolloff in highlights (smooth transition to white) + + All adjustable. The defaults emulate the iPhone 15 Pro "Standard" style. + """ + if strength <= 0: + return image + + # Work in linear light for physically correct processing + linear = _srgb_to_linear(image) + + # --- S-Curve via rational function (smoother than sigmoid) --- + # This approximates Apple's proprietary tone curve: + # - Lifts shadows gently (iPhone never crushes blacks) + # - Boosts mid-tones for "punchy" look + # - Rolls off highlights smoothly (filmic, not digital clip) + + # Shadow lift: raise the floor of the curve + shadow_offset = shadow_lift * 0.03 * strength + linear = linear + shadow_offset + + # Contrast S-curve using a smooth power function + # Pivot around mid-gray (0.18 in linear = middle gray) + pivot = 0.18 + contrast_amount = 1.0 + contrast * 0.6 * strength + + # Apply per-channel contrast around pivot + result = pivot * np.power(linear / (pivot + 1e-6), contrast_amount) + + # Highlight rolloff: compress highlights smoothly + # Uses a soft-knee compression curve + knee = 1.0 - highlight_rolloff * 0.3 * strength + above_knee = result > knee + if np.any(above_knee): + excess = result[above_knee] - knee + # Soft compression: maps excess through a log curve + compressed = knee + (1.0 - knee) * (1.0 - np.exp(-excess / ((1.0 - knee) + 1e-6))) + result[above_knee] = compressed + + # Convert back to sRGB + result = _linear_to_srgb(np.clip(result, 0.0, 1.0)) + + # Blend with original + output = image * (1.0 - strength) + result * strength + return np.clip(output, 0.0, 1.0).astype(np.float32) + + +# ============================================================================ +# STAGE 2: Display P3 Color Rendering +# ============================================================================ + +# Transform matrices (CIE XYZ as intermediate) +# sRGB -> XYZ (D65) +_SRGB_TO_XYZ = np.array([ + [0.4124564, 0.3575761, 0.1804375], + [0.2126729, 0.7151522, 0.0721750], + [0.0193339, 0.1191920, 0.9503041], +], dtype=np.float32) + +# XYZ -> Display P3 (D65) +_XYZ_TO_P3 = np.array([ + [ 2.4934969, -0.9313836, -0.4027108], + [-0.8294890, 1.7626641, 0.0236247], + [ 0.0358458, -0.0761724, 0.9568845], +], dtype=np.float32) + +# Display P3 -> XYZ (D65) +_P3_TO_XYZ = np.array([ + [0.4865709, 0.2656677, 0.1982173], + [0.2289746, 0.6917385, 0.0792869], + [0.0000000, 0.0451134, 1.0439444], +], dtype=np.float32) + +# XYZ -> sRGB (D65) +_XYZ_TO_SRGB = np.array([ + [ 3.2404542, -1.5371385, -0.4985314], + [-0.9692660, 1.8760108, 0.0415560], + [ 0.0556434, -0.2040259, 1.0572252], +], dtype=np.float32) + + +def apply_p3_color_rendering(image: np.ndarray, strength: float = 0.5, + saturation_boost: float = 0.3, + warmth: float = 0.3) -> np.ndarray: + """Simulate iPhone's Display P3 wider gamut color rendering. + + iPhones capture and display in Display P3, which has ~25% more colors + than sRGB, especially in reds and greens. This makes photos look + richer and more vibrant without being garish. + + This stage: + 1. Converts to linear light + 2. Transforms from sRGB gamut to P3 gamut (expanding color range) + 3. Applies subtle saturation and warmth adjustments + 4. Converts back to sRGB with soft gamut clipping + + The result: richer reds, deeper greens, more vibrant skin tones β€” + the signature "iPhone color pop" without oversaturation. + """ + if strength <= 0: + return image + + # Convert to linear + linear = _srgb_to_linear(image) + + h, w, _ = linear.shape + pixels = linear.reshape(-1, 3) + + # sRGB -> XYZ -> P3 + xyz = (pixels @ _SRGB_TO_XYZ.T) + p3 = (xyz @ _XYZ_TO_P3.T) + + # In P3 space, apply subtle saturation boost + # (This simulates the wider gamut's richer color reproduction) + p3_mean = p3.mean(axis=1, keepdims=True) + sat_factor = 1.0 + saturation_boost * 0.8 * strength + p3_saturated = p3_mean + (p3 - p3_mean) * sat_factor + + # Apply warmth shift (iPhone's warm bias) + # Slightly boost red, slightly reduce blue + warmth_factor = warmth * strength + p3_saturated[:, 0] *= (1.0 + 0.15 * warmth_factor) # Red warmer + p3_saturated[:, 1] *= (1.0 + 0.04 * warmth_factor) # Green slight warm + p3_saturated[:, 2] *= (1.0 - 0.12 * warmth_factor) # Blue cooler + + # P3 -> XYZ -> sRGB (with soft gamut clipping) + xyz_back = (p3_saturated @ _P3_TO_XYZ.T) + srgb_linear = (xyz_back @ _XYZ_TO_SRGB.T) + + # Soft gamut clip: instead of hard [0,1] clip, use smooth compression + # This prevents color shifts at gamut boundaries + srgb_linear = np.clip(srgb_linear, 0, None) + over_mask = srgb_linear > 1.0 + if np.any(over_mask): + excess = srgb_linear[over_mask] - 1.0 + srgb_linear[over_mask] = 1.0 - np.exp(-excess) * 0.05 + + srgb_linear = np.clip(srgb_linear, 0.0, 1.0).reshape(h, w, 3) + + # Back to sRGB gamma + result = _linear_to_srgb(srgb_linear) + + output = image * (1.0 - strength) + result * strength + return np.clip(output, 0.0, 1.0).astype(np.float32) + + +# ============================================================================ +# STAGE 3: Smart HDR Local Tone Mapping +# ============================================================================ + +def _box_blur(img: np.ndarray, radius: int) -> np.ndarray: + """Fast box blur using cumulative sum. Works on 2D arrays.""" + if radius <= 0: + return img + # Horizontal pass + cs = np.cumsum(img, axis=1) + cs = np.pad(cs, ((0, 0), (radius, 0)), mode='constant') + blurred = (cs[:, radius:] - cs[:, :-radius]) / radius + + # Vertical pass + cs = np.cumsum(blurred, axis=0) + cs = np.pad(cs, ((radius, 0), (0, 0)), mode='constant') + blurred = (cs[radius:, :] - cs[:-radius, :]) / radius + + # Handle shape mismatch from integer division + h, w = img.shape + blurred = blurred[:h, :w] + return blurred.astype(np.float32) + + +def apply_local_tone_mapping(image: np.ndarray, strength: float = 0.4, + detail_boost: float = 0.5, + radius_pct: float = 0.08) -> np.ndarray: + """Apply Smart HDR-inspired local tone mapping. + + Apple's Smart HDR captures multiple exposures and fuses them. + We simulate the visual result: + - Local contrast enhancement (makes textures pop) + - Brings out detail in both shadows and highlights + - Creates that "HDR but natural" iPhone look + - Adds micro-contrast that makes the image feel sharp and alive + + Uses a fast approximation of unsharp-mask local tone mapping. + """ + if strength <= 0: + return image + + h, w = image.shape[:2] + radius = max(3, int(min(h, w) * radius_pct)) + + # Convert to luminance for local adaptation + lum = 0.2126 * image[:, :, 0] + 0.7152 * image[:, :, 1] + 0.0722 * image[:, :, 2] + + # Compute local mean luminance (what Smart HDR adapts to) + local_mean = _box_blur(lum, radius) + + # Local detail = difference between pixel and local average + # This is the micro-contrast/texture information + detail = lum - local_mean + + # Enhance detail (boost local contrast) + detail_factor = 1.0 + detail_boost * strength * 2.5 + enhanced_detail = detail * detail_factor + + # Build luminance correction map + # Where local_mean is dark -> lighten (shadow lift) + # Where local_mean is bright -> darken slightly (highlight recovery) + target_mean = 0.5 # Compress toward middle gray + correction = (target_mean - local_mean) * 0.7 * strength + + # Apply luminance correction per channel (preserves color ratios) + lum_ratio = np.clip(lum, 1e-6, None) + + # New luminance = corrected local mean + enhanced detail + new_lum = local_mean + correction + enhanced_detail + new_lum = np.clip(new_lum, 0.0, 1.0) + + # Scale RGB channels by luminance change ratio + scale = (new_lum / lum_ratio)[:, :, np.newaxis] + scale = np.clip(scale, 0.5, 2.0) # Prevent extreme scaling + + result = image * scale + + # Blend with original + output = image * (1.0 - strength) + result * strength + return np.clip(output, 0.0, 1.0).astype(np.float32) + + +# ============================================================================ +# STAGE 4: Real Tone Skin Rendering +# ============================================================================ + +def apply_skin_tone_rendering(image: np.ndarray, strength: float = 0.5, + warmth: float = 0.5, + smoothness: float = 0.3) -> np.ndarray: + """Apply Apple's Real Tone skin rendering. + + Apple specifically optimizes skin tone reproduction: + - Detects skin-tone hue ranges (orange-ish, 15-45 degree hue) + - Applies warmth while preventing oversaturation + - Slightly reduces chroma in very saturated skin to prevent "orange look" + - Preserves natural variation across skin tones + + This works in HSL space for precise skin tone targeting. + """ + if strength <= 0: + return image + + hsl = _rgb_to_hsl(image) + h, s, l = hsl[:, :, 0], hsl[:, :, 1], hsl[:, :, 2] + + # Skin tone detection mask (hue range ~15-50 degrees = 0.04-0.14 in [0,1]) + # with saturation > 0.15 and luminance in reasonable range + skin_hue_center = 0.08 # ~30 degrees (warm skin tone center) + skin_hue_width = 0.12 # ~43 degrees on each side + + hue_dist = np.minimum(np.abs(h - skin_hue_center), + np.abs(h - skin_hue_center + 1.0)) + hue_dist = np.minimum(hue_dist, np.abs(h - skin_hue_center - 1.0)) + + # Smooth falloff skin mask (0 = not skin, 1 = definitely skin) + skin_mask = np.exp(-0.5 * (hue_dist / (skin_hue_width + 1e-6)) ** 2) + skin_mask *= np.clip(s * 4.0, 0, 1) # Need some saturation + skin_mask *= np.clip(1.0 - np.abs(l - 0.5) * 3.0, 0, 1) # Not too dark/bright + skin_mask = skin_mask.astype(np.float32) + + # Apply effects ONLY to skin regions + effect_strength = skin_mask * strength + + # 1. Warmth: shift skin hue slightly toward warm (lower hue = more red-orange) + h_shift = -0.06 * warmth # Subtle warmth push + h_new = h + h_shift * effect_strength + + # 2. Saturation control: prevent oversaturated orange skin + # Reduce saturation for overly-saturated skin tones + s_target = np.clip(s, 0, 0.45) # iPhone caps skin saturation to prevent "orange face" + s_new = s + (s_target - s) * effect_strength * 0.7 + + # 3. Luminance: slight brighten for skin (Real Tone lifts skin slightly) + l_boost = 0.12 * strength + l_new = l + l_boost * effect_strength + + # Recombine + hsl_new = np.stack([h_new % 1.0, np.clip(s_new, 0, 1), + np.clip(l_new, 0, 1)], axis=-1).astype(np.float32) + result = _hsl_to_rgb(hsl_new) + + return np.clip(result, 0.0, 1.0).astype(np.float32) + + +# ============================================================================ +# STAGE 5: Deep Fusion Texture Crunch (Edge-Preserving Frequency Separation) +# ============================================================================ + +def apply_deep_fusion_crunch(image: np.ndarray, strength: float = 0.5, + texture_frequency: float = 0.5) -> np.ndarray: + """Simulate Apple's Deep Fusion algorithm. + + Rather than a basic unsharp mask which creates halos around high-contrast + edges (like a tree against a sky) and amplifies noise in flat areas, + Deep Fusion isolates mid-to-high frequency *textures* (fabric, skin pores) + and massively amplifies them, using an edge-preserving variance mask. + """ + if strength <= 0: + return image + + h, w = image.shape[:2] + + # Extract luminance + lum = 0.2126 * image[:, :, 0] + 0.7152 * image[:, :, 1] + 0.0722 * image[:, :, 2] + + # 1. Fast Edge-Preserving Low-Pass Filter (Guided/Bilateral Approximation) + # We compute local variance to identify hard edges vs textures vs flat areas. + radius = max(2, int(min(h, w) * 0.015 * texture_frequency)) # Base structure radius + + # E[X] and E[X^2] for variance + mean_lum = _box_blur(lum, radius) + mean_lum_sq = _box_blur(lum * lum, radius) + + # Local Variance: V(X) = E[X^2] - (E[X])^2 + var_lum = np.clip(mean_lum_sq - mean_lum * mean_lum, 0.0, None) + + # Scale variance to use as a mask + # Flat areas have var ~0. Hard edges have high var. + # Textures exist in the middle variance band. + edge_threshold = 0.05 + edge_mask = np.clip(var_lum / edge_threshold, 0.0, 1.0) + + # The detail layer (high frequencies) + detail = lum - mean_lum + + # We want to amplify details where it is NOT a hard edge (where edge_mask is low) + # but we also don't want to amplify pure flat noise. + # So we enhance based on an inverse edge mask. + texture_mask = 1.0 - edge_mask + + # Deep Fusion is aggressive. At max strength we're multiplying texture by 3x. + crunch_factor = strength * 2.5 + amplified_detail = detail * (1.0 + crunch_factor * texture_mask) + + # Recombine luminance + new_lum = mean_lum + amplified_detail + new_lum = np.clip(new_lum, 0.0, 1.0) + + # Scale RGB by luminance ratio + lum_safe = np.clip(lum, 1e-6, None) + scale = (new_lum / lum_safe)[:, :, np.newaxis] + scale = np.clip(scale, 0.5, 2.0) + + result = image * scale + return np.clip(result, 0.0, 1.0).astype(np.float32) + +# ============================================================================ +# STAGE 6: ISP Sharpening (Unsharp Mask) +# ============================================================================ + +def apply_isp_sharpening(image: np.ndarray, strength: float = 0.3, + radius_pct: float = 0.005) -> np.ndarray: + """Apply iPhone ISP-style unsharp masking. + + Apple's ISP applies subtle, intelligent sharpening: + - Operates primarily on luminance (avoids color artifacts) + - Small radius (captures fine detail like skin pores, hair) + - Moderate strength (visible but not crunchy) + - Slightly halos permitted (signature iPhone "crispness") + """ + if strength <= 0: + return image + + h, w = image.shape[:2] + radius = max(1, int(min(h, w) * radius_pct)) + + # Extract luminance + lum = 0.2126 * image[:, :, 0] + 0.7152 * image[:, :, 1] + 0.0722 * image[:, :, 2] + + # Blur for unsharp mask + blurred = _box_blur(lum, radius) + + # High-frequency detail + detail = lum - blurred + + # Apply sharpening via luminance scaling + sharp_amount = strength * 1.5 + sharpened_lum = lum + detail * sharp_amount + sharpened_lum = np.clip(sharpened_lum, 0.0, 1.0) + + # Scale RGB channels by luminance change + lum_safe = np.clip(lum, 1e-6, None) + scale = (sharpened_lum / lum_safe)[:, :, np.newaxis] + scale = np.clip(scale, 0.7, 1.5) # Prevent extreme scaling + + result = image * scale + + return np.clip(result, 0.0, 1.0).astype(np.float32) + + +# ============================================================================ +# STAGE 7: Subtle Sensor Characteristics +# ============================================================================ + +def apply_sensor_character(image: np.ndarray, strength: float = 0.3, + noise_amount: float = 0.3, + vignette_amount: float = 0.4, + seed: int = 42) -> np.ndarray: + """Apply subtle real-camera sensor characteristics. + + Minimal, tasteful, not a filter: + - Very subtle luminance-only noise (like iPhone at ISO 200-400) + - Gentle vignette matching iPhone's f/1.78 lens + - Not the main effect β€” just finishing touches + """ + if strength <= 0: + return image + + h, w = image.shape[:2] + result = image.copy() + rng = np.random.default_rng(seed) + + # --- Subtle luminance noise (iPhone-like, barely visible) --- + if noise_amount > 0: + # iPhone noise is very fine-grained, mostly in shadows + lum = 0.2126 * image[:, :, 0] + 0.7152 * image[:, :, 1] + 0.0722 * image[:, :, 2] + + # More noise in shadows, less in highlights (like real sensors) + noise_scale = (1.0 - lum) * 0.015 * noise_amount * strength + noise = rng.normal(0, 1, (h, w)).astype(np.float32) * noise_scale + + # Apply noise to all channels equally (luminance-style noise) + result = result + noise[:, :, np.newaxis] + + # --- Gentle vignette (iPhone f/1.78 lens) --- + if vignette_amount > 0: + y = np.linspace(-1, 1, h, dtype=np.float32) + x = np.linspace(-1, 1, w, dtype=np.float32) + xx, yy = np.meshgrid(x, y) + r = np.sqrt(xx ** 2 + yy ** 2) + r_norm = r / np.sqrt(2.0) + + # iPhone vignette is very gentle, almost imperceptible + vig = 1.0 - vignette_amount * 0.12 * strength * np.power(r_norm, 2.5) + vig = np.clip(vig, 0.0, 1.0).astype(np.float32) + result = result * vig[:, :, np.newaxis] + + return np.clip(result, 0.0, 1.0).astype(np.float32) + + +# ============================================================================ +# STAGE 8: Color Temperature & White Balance +# ============================================================================ + +def apply_white_balance(image: np.ndarray, strength: float = 0.5, + temperature: float = 0.3, + tint: float = 0.0) -> np.ndarray: + """Apply iPhone-style automatic white balance correction. + + iPhone AWB tends toward: + - Slightly warm (daylight bias) + - Neutral tint (no green/magenta shift) + - Accurate gray point targeting + + Temperature: -1 (cool/blue) to +1 (warm/orange). Default 0.3 = iPhone warm. + Tint: -1 (green) to +1 (magenta). Default 0 = neutral. + """ + if strength <= 0: + return image + + result = image.copy() + + # Temperature: warm = boost red/yellow, cool = boost blue + temp_effect = temperature * 0.25 * strength + result[:, :, 0] = result[:, :, 0] * (1.0 + temp_effect * 1.0) # Red + result[:, :, 1] = result[:, :, 1] * (1.0 + temp_effect * 0.3) # Green (slight) + result[:, :, 2] = result[:, :, 2] * (1.0 - temp_effect * 0.8) # Blue + + # Tint: green-magenta axis + tint_effect = tint * 0.2 * strength + result[:, :, 1] = result[:, :, 1] * (1.0 + tint_effect) # Green + + return np.clip(result, 0.0, 1.0).astype(np.float32) + + +# ============================================================================ +# STAGE 9: iPhone Blue Shadow Tint (HDR Artifact) +# ============================================================================ + +def apply_blue_shadow_tint(image: np.ndarray, strength: float = 0.4, + shadow_threshold: float = 0.35, + blue_intensity: float = 0.5) -> np.ndarray: + """Apply iPhone's characteristic blue-ish tint in dark areas. + + iPhones are known for lifting shadows with a subtle blue/purple cast. + This comes from HDR processing: when the ISP lifts shadow detail, + it shifts the blacks toward blue instead of pure neutral. + + This is a very recognizable iPhone trait β€” dark clothes, hair, and + shadows all have this subtle blue tint in iPhone photos. + + Uses luminance-based masking with smooth cubic falloff so only + genuinely dark areas receive the tint. + """ + if strength <= 0: + return image + + # Compute luminance + lum = 0.2126 * image[:, :, 0] + 0.7152 * image[:, :, 1] + 0.0722 * image[:, :, 2] + + # Shadow mask: 1.0 in deepest shadows, smooth falloff to 0.0 + # Uses cubic falloff for natural transition + shadow_mask = np.clip((shadow_threshold - lum) / (shadow_threshold + 1e-6), 0.0, 1.0) + shadow_mask = shadow_mask ** 2 # Smooth cubic-like falloff + shadow_mask = (shadow_mask * strength)[:, :, np.newaxis].astype(np.float32) + + # Blue tint color shift: + # Slightly reduce red + green in shadows, boost blue + # This creates that characteristic iPhone blue-black look + tint = np.zeros_like(image) + tint[:, :, 0] = -0.15 * blue_intensity # Reduce red in shadows + tint[:, :, 1] = -0.05 * blue_intensity # Slightly reduce green + tint[:, :, 2] = +0.25 * blue_intensity # Boost blue in shadows + + result = image + tint * shadow_mask + + return np.clip(result, 0.0, 1.0).astype(np.float32) + + +# ============================================================================ +# STAGE 10: Highlight Warmth (iPhone golden highlights) +# ============================================================================ + +def apply_highlight_warmth(image: np.ndarray, strength: float = 0.3, + highlight_threshold: float = 0.7, + warmth: float = 0.5) -> np.ndarray: + """Apply warm golden tint to highlights (iPhone characteristic). + + iPhone photos have subtly warm highlights β€” sunlit areas, bright skin, + and light sources all have a gentle golden quality. This complements + the blue shadows to create the full iPhone color signature: + warm highlights + cool/blue shadows = the iPhone look. + """ + if strength <= 0: + return image + + lum = 0.2126 * image[:, :, 0] + 0.7152 * image[:, :, 1] + 0.0722 * image[:, :, 2] + + # Highlight mask: smooth ramp in bright areas + hl_mask = np.clip((lum - highlight_threshold) / (1.0 - highlight_threshold + 1e-6), 0.0, 1.0) + hl_mask = (hl_mask * strength)[:, :, np.newaxis].astype(np.float32) + + # Golden warmth: boost red/yellow in highlights + tint = np.zeros_like(image) + tint[:, :, 0] = +0.18 * warmth # Warm red + tint[:, :, 1] = +0.06 * warmth # Slight green-yellow + tint[:, :, 2] = -0.12 * warmth # Reduce blue in highlights + + result = image + tint * hl_mask + return np.clip(result, 0.0, 1.0).astype(np.float32) + + +# ============================================================================ +# MASTER PIPELINE +# ============================================================================ + +def process_iphone_realism(image: np.ndarray, + master_strength: float = 0.7, + # Tone mapping + enable_tone_mapping: bool = True, + tone_strength: float = 0.6, + highlight_rolloff: float = 0.5, + shadow_lift: float = 0.4, + contrast: float = 0.5, + # P3 Color + enable_p3_color: bool = True, + color_strength: float = 0.5, + color_saturation: float = 0.3, + color_warmth: float = 0.3, + # Local tone mapping + enable_local_tone: bool = True, + local_tone_strength: float = 0.35, + detail_boost: float = 0.4, + # Skin rendering + enable_skin_rendering: bool = True, + skin_strength: float = 0.5, + skin_warmth: float = 0.4, + # Deep Fusion Texture Crunch + enable_deep_fusion: bool = True, + fusion_strength: float = 0.6, + fusion_texture_freq: float = 0.5, + # White balance + enable_white_balance: bool = True, + wb_strength: float = 0.5, + wb_temperature: float = 0.25, + wb_tint: float = 0.0, + # Color grading (blue shadows + warm highlights) + enable_color_grading: bool = True, + blue_shadows: float = 0.4, + warm_highlights: float = 0.3, + # Sharpening + enable_sharpening: bool = True, + sharpen_strength: float = 0.3, + # Sensor characteristics + enable_sensor: bool = True, + sensor_strength: float = 0.25, + sensor_noise: float = 0.3, + sensor_vignette: float = 0.4, + seed: int = 42) -> np.ndarray: + """Run the full iPhone ISP Realism pipeline. + + Processing order matches Apple's actual ISP pipeline: + 1. White balance correction (first in real ISP) + 2. Tone mapping (exposure + S-curve) + 3. Display P3 color rendering (gamut + color science) + 4. Smart HDR local tone mapping (multi-frame fusion emulation) + 5. Real Tone skin rendering (skin-specific optimization) + 6. Deep Fusion texture crunch (edge-preserving high-frequency boost) + 7. Color grading (blue shadows + warm highlights) + 8. ISP sharpening (crispness) + 9. Sensor characteristics (subtle noise + vignette) + """ + result = image.copy() + + # Stage 1: White Balance + if enable_white_balance: + result = apply_white_balance(result, wb_strength * master_strength, + wb_temperature, wb_tint) + + # Stage 2: Tone Mapping + if enable_tone_mapping: + result = apply_iphone_tone_curve(result, tone_strength * master_strength, + highlight_rolloff, shadow_lift, contrast) + + # Stage 3: P3 Color Rendering + if enable_p3_color: + result = apply_p3_color_rendering(result, color_strength * master_strength, + color_saturation, color_warmth) + + # Stage 4: Local Tone Mapping + if enable_local_tone: + result = apply_local_tone_mapping(result, local_tone_strength * master_strength, + detail_boost) + + # Stage 5: Skin Rendering + if enable_skin_rendering: + result = apply_skin_tone_rendering(result, skin_strength * master_strength, + skin_warmth) + + # Stage 6: Deep Fusion Texture Crunch + if enable_deep_fusion: + result = apply_deep_fusion_crunch(result, fusion_strength * master_strength, fusion_texture_freq) + + # Stage 7: Color Grading (iPhone signature: blue shadows + warm highlights) + if enable_color_grading: + result = apply_blue_shadow_tint(result, blue_shadows * master_strength) + result = apply_highlight_warmth(result, warm_highlights * master_strength) + + # Stage 8: Sharpening + if enable_sharpening: + result = apply_isp_sharpening(result, sharpen_strength * master_strength) + + # Stage 9: Sensor Character + if enable_sensor: + result = apply_sensor_character(result, sensor_strength * master_strength, + sensor_noise, sensor_vignette, seed) + + return np.clip(result, 0.0, 1.0).astype(np.float32) diff --git a/js/camera_forensic_ui.js b/js/camera_forensic_ui.js new file mode 100644 index 0000000..f414546 --- /dev/null +++ b/js/camera_forensic_ui.js @@ -0,0 +1,404 @@ +import { app } from "../../scripts/app.js"; + +/** + * πŸ“Έ Camera Forensic Realism Engine β€” Premium UI Dashboard + * ========================================================== + * Gold/Black themed DOM widget replacing ALL default drawing. + * Includes tooltips, styled sliders, toggles, and dynamic layout + * mimicking the Advanced Image Denoiser style. + */ + +const NODE_NAME = "CameraForensicRealismEngine"; +const NODE_MIN_WIDTH = 420; +const NODE_TITLE_HEIGHT = 40; + +const SECTIONS = { + master: { icon: "⚑", label: "Master Settings" }, + tone: { icon: "πŸŒ“", label: "Tone Curve" }, + color: { icon: "🎨", label: "Display P3 Color" }, + hdr: { icon: "✨", label: "Smart HDR" }, + skin: { icon: "πŸ‘©πŸ½β€πŸ¦±", label: "Real Tone" }, + fusion: { icon: "🧠", label: "Deep Fusion" }, + wb: { icon: "β˜€οΈ", label: "White Balance" }, + grade: { icon: "πŸ”΅", label: "Color Grading" }, + sharp: { icon: "πŸ”ͺ", label: "ISP Sharpening" }, + sensor: { icon: "πŸ“·", label: "Sensor FX" }, +}; + +const WIDGET_META = { + master_strength: { type: "slider", section: "master", label: "Master Strength", min: 0, max: 2, step: 0.01, desc: "Global intensity of all forensic realism effects." }, + seed: { type: "number", section: "master", label: "Seed", desc: "Random seed for noise generation." }, + control_after_generate: { type: "combo", section: "master", label: "Seed Mode", desc: "Change seed behavior after generation." }, + + enable_tone_mapping: { type: "toggle", section: "tone", label: "Enable", desc: "Apply Apple-style tone mapping curve." }, + tone_strength: { type: "slider", section: "tone", label: "Strength", min: 0, max: 1, step: 0.01, desc: "Overall strength of the tone curve effect." }, + highlight_rolloff: { type: "slider", section: "tone", label: "Highlight Rolloff", min: 0, max: 1, step: 0.01, desc: "Smooth compression of bright highlights to prevent clipping." }, + shadow_lift: { type: "slider", section: "tone", label: "Shadow Lift", min: 0, max: 1, step: 0.01, desc: "Raises shadow black point for a slightly softer, photographic look." }, + contrast: { type: "slider", section: "tone", label: "Contrast", min: 0, max: 1, step: 0.01, desc: "Midtone contrast intensity (S-curve steepness)." }, + + enable_p3_color: { type: "toggle", section: "color", label: "Enable", desc: "Transform gamut to Display P3 space (richer reds/greens)." }, + color_strength: { type: "slider", section: "color", label: "Strength", min: 0, max: 1, step: 0.01, desc: "Intensity of the P3 color transformation." }, + color_saturation: { type: "slider", section: "color", label: "Saturation", min: 0, max: 1, step: 0.01, desc: "Overall image saturation boost." }, + color_warmth: { type: "slider", section: "color", label: "Warmth", min: 0, max: 1, step: 0.01, desc: "Apple's signature slight warmth bias in reds/yellows." }, + + enable_local_tone: { type: "toggle", section: "hdr", label: "Enable", desc: "Smart HDR local contrast emulation." }, + local_tone_strength: { type: "slider", section: "hdr", label: "Strength", min: 0, max: 1, step: 0.01, desc: "Intensity of local contrast enhancements." }, + detail_boost: { type: "slider", section: "hdr", label: "Detail Boost", min: 0, max: 1, step: 0.01, desc: "Micro-contrast extraction in mid-frequency details." }, + + enable_skin_rendering: { type: "toggle", section: "skin", label: "Enable", desc: "Real Tone skin color protection and rendering." }, + skin_strength: { type: "slider", section: "skin", label: "Strength", min: 0, max: 1, step: 0.01, desc: "How aggressively skin tones are isolated and processed." }, + skin_warmth: { type: "slider", section: "skin", label: "Skin Warmth", min: 0, max: 1, step: 0.01, desc: "Reduces orange bias and adds natural rosy warmth to skin." }, + + enable_deep_fusion: { type: "toggle", section: "fusion", label: "Enable", desc: "Isolate and crunch high-frequency textures (fabric, pores) without halos." }, + fusion_strength: { type: "slider", section: "fusion", label: "Strength", min: 0, max: 1, step: 0.01, desc: "Texture amplification strength (the iPhone computational crunch)." }, + fusion_texture_freq: { type: "slider", section: "fusion", label: "Texture Freq", min: 0, max: 1, step: 0.01, desc: "Determines which texture thicknesses are crunched." }, + + enable_white_balance: { type: "toggle", section: "wb", label: "Enable", desc: "Apply signature iPhone warm auto-white-balance bias." }, + wb_strength: { type: "slider", section: "wb", label: "Strength", min: 0, max: 1, step: 0.01, desc: "Overall intensity of the AWB correction." }, + wb_temperature: { type: "slider", section: "wb", label: "Temperature", min: -1, max: 1, step: 0.01, desc: "Blue-Yellow axis balance." }, + wb_tint: { type: "slider", section: "wb", label: "Tint", min: -1, max: 1, step: 0.01, desc: "Green-Magenta axis balance." }, + + enable_color_grading: { type: "toggle", section: "grade", label: "Enable", desc: "Cinematic color grading (blue shadows, warm highlights)." }, + blue_shadows: { type: "slider", section: "grade", label: "Blue Shadows", min: 0, max: 1, step: 0.01, desc: "Injects deep inky blue tint into the darkest shadows." }, + warm_highlights: { type: "slider", section: "grade", label: "Warm Highlights", min: 0, max: 1, step: 0.01, desc: "Pushes bright highlights towards a soft golden hue." }, + + enable_sharpening: { type: "toggle", section: "sharp", label: "Enable", desc: "ISP-style luminance unsharp mask computation." }, + sharpen_strength: { type: "slider", section: "sharp", label: "Strength", min: 0, max: 1, step: 0.01, desc: "Intensity of the sharpening halo effect." }, + + enable_sensor: { type: "toggle", section: "sensor", label: "Enable", desc: "Physical sensor imperfections (noise, vignette)." }, + sensor_strength: { type: "slider", section: "sensor", label: "Strength", min: 0, max: 1, step: 0.01, desc: "Overall intensity of sensor effects combined." }, + sensor_noise: { type: "slider", section: "sensor", label: "Luma Noise", min: 0, max: 1, step: 0.01, desc: "Amount of fine digital grain added to midtones/shadows." }, + sensor_vignette: { type: "slider", section: "sensor", label: "Vignette", min: 0, max: 1, step: 0.01, desc: "f/1.78 lens shading light falloff at the corners." }, +}; + +/* ── CSS injection ───────────────────────────────────────────────────── */ +function injectCSS() { + if (document.getElementById("cfr-styles")) return; + const style = document.createElement("style"); + style.id = "cfr-styles"; + style.textContent = ` +.cfr-root { + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + background: #0d0d08; + border-radius: 8px; + padding: 12px; + color: #d0d0c0; + user-select: none; + width: 100%; + box-sizing: border-box; + overflow: hidden; +} +.cfr-header { + text-align: center; + padding: 4px 0 12px; + border-bottom: 1px solid rgba(255, 215, 0, 0.15); + margin-bottom: 12px; +} +.cfr-title { + font-size: 16px; font-weight: 800; letter-spacing: 2px; + text-transform: uppercase; + background: linear-gradient(135deg, #FFD700, #b89b00); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; margin: 0; +} +.cfr-subtitle { + font-size: 9px; color: #887; letter-spacing: 2px; + text-transform: uppercase; margin-top: 4px; font-weight: 600; +} +.cfr-info { + background: rgba(255, 215, 0, 0.04); + border: 1px solid rgba(255, 215, 0, 0.1); + border-radius: 8px; padding: 10px 12px; + margin-bottom: 12px; font-size: 10px; line-height: 1.4; +} +.cfr-info-badge { + display: inline-block; font-size: 8px; font-weight: 800; + letter-spacing: 1.5px; padding: 3px 6px; border-radius: 4px; + background: rgba(255, 215, 0, 0.15); color: #FFD700; margin-bottom: 6px; +} +.cfr-info-desc { color: #bbb; margin: 4px 0; } +.cfr-info-tip { color: #887; font-size: 9px; font-style: italic; margin-top: 6px; } + +.cfr-section { + display: flex; align-items: center; gap: 8px; + margin: 16px 0 8px; font-size: 10px; font-weight: 700; + letter-spacing: 1.5px; text-transform: uppercase; color: #FFD700; +} +.cfr-section::after { + content: ""; flex: 1; height: 1px; + background: linear-gradient(90deg, rgba(255,215,0,0.2), transparent); +} +.cfr-row { + display: flex; align-items: center; gap: 10px; margin: 6px 0; + height: 24px; transition: opacity 0.2s, max-height 0.2s; +} +.cfr-row.hidden { opacity: 0; max-height: 0; margin: 0; pointer-events: none; overflow: hidden; } + +.cfr-label { + min-width: 100px; font-size: 11px; color: #aaa; + text-align: right; cursor: help; font-weight: 500; +} +.cfr-label:hover { color: #FFD700; } + +.cfr-slider { + -webkit-appearance: none; appearance: none; + flex: 1; height: 6px; border-radius: 3px; + background: linear-gradient(90deg, #1f1f14, #333322); + outline: none; cursor: pointer; + box-shadow: inset 0 1px 2px rgba(0,0,0,0.5); +} +.cfr-slider:hover { background: linear-gradient(90deg, #1f1f14, #444422); } +.cfr-slider::-webkit-slider-thumb { + -webkit-appearance: none; width: 14px; height: 14px; + border-radius: 50%; + background: radial-gradient(circle, #fff 10%, #FFD700 80%); + border: 1px solid #b89b00; cursor: grab; + box-shadow: 0 0 6px rgba(255, 215, 0, 0.4); + margin-top: -4px; transition: transform 0.1s; +} +.cfr-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); box-shadow: 0 0 10px rgba(255, 215, 0, 0.6); +} +.cfr-slider-val { + min-width: 40px; text-align: right; font-size: 11px; + font-family: 'Consolas','Monaco',monospace; + color: #FFD700; font-weight: 700; +} + +.cfr-tooltip { position: relative; } +.cfr-tooltip::after { + content: attr(data-tip); + position: absolute; bottom: 125%; left: 0%; + background: #1a1a14; color: #ccc; + border: 1px solid rgba(255,215,0,0.3); + padding: 6px 10px; border-radius: 6px; + font-size: 10px; width: 220px; + white-space: normal; pointer-events: none; text-align: left; + opacity: 0; transition: opacity 0.2s; z-index: 999; line-height: 1.4; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); font-weight: normal; font-family: sans-serif; +} +.cfr-tooltip:hover::after { opacity: 1; } + +.cfr-switch { display: flex; align-items: center; gap: 8px; cursor: pointer; flex: 1; } +.cfr-switch-text { font-size: 10px; font-weight: 700; color: #666; width: 26px; text-align: right; letter-spacing: 0.5px; } +.cfr-switch input { display: none; } +.cfr-switch-track { width: 32px; height: 16px; background: #222; border-radius: 8px; position: relative; transition: 0.2s; border: 1px solid #333; box-shadow: inset 0 1px 3px rgba(0,0,0,0.5); } +.cfr-switch-thumb { width: 12px; height: 12px; background: #777; border-radius: 50%; position: absolute; top: 1px; left: 2px; transition: 0.2s; } +.cfr-switch input:checked + .cfr-switch-text { color: #FFD700; } +.cfr-switch input:checked ~ .cfr-switch-track { background: rgba(255,215,0,0.15); border-color: rgba(255,215,0,0.4); } +.cfr-switch input:checked ~ .cfr-switch-track .cfr-switch-thumb { background: #FFD700; transform: translateX(14px); box-shadow: 0 0 8px rgba(255,215,0,0.5); } + +.cfr-input { flex: 1; background: #1a1a14; border: 1px solid rgba(255,215,0,0.2); color: #FFD700; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-family: inherit; font-weight: 600; outline: none; } +.cfr-input:focus { border-color: #FFD700; } +`; + document.head.appendChild(style); +} + +/* ── Build DOM ───────────────────────────────────────────────────────── */ +function buildUI() { + const el = document.createElement("div"); + el.className = "cfr-root"; + + // Header & Description + let html = ` +
+
Camera Forensic Realism
+
iPhone 15 Pro β€’ ISP Emulation β€’ 9 Stages
+
+
+ β˜… iPHONE ISP PIPELINE +
Emulates Apple’s Image Signal Processor color science. Transforms AI images to match iPhone photography with accurate tone mapping, P3 color, and blue-black shadows.
+
πŸ’‘ Place after detailers, before SaveImage.
+
+ `; + + // Generate Sections & Controls + Object.entries(SECTIONS).forEach(([secId, sec]) => { + html += `
${sec.icon} ${sec.label}
`; + html += `
`; + + Object.entries(WIDGET_META).forEach(([wName, w]) => { + if (w.section !== secId) return; + + html += `
+ ${w.label}`; + + if (w.type === "slider") { + html += ` + + `; + } else if (w.type === "toggle") { + html += ` + `; + } else if (w.type === "combo") { + html += ``; + } else if (w.type === "number") { + html += ``; + } + html += `
`; + }); + html += `
`; + }); + + el.innerHTML = html; + return el; +} + +function getWidget(node, name) { + return node.widgets?.find(w => w.name === name); +} + +function hideDefaultWidget(w) { + if (!w) return; + w.hidden = true; w.type = "hidden"; w.computeSize = () => [0, -4]; + w.draw = function () { }; w.mouse = function () { return false; }; +} + +function updateSectionVisibility(el, node) { + // Hide controls if section is toggled off + Object.keys(SECTIONS).forEach(secId => { + if (secId === "master") return; + + const toggleName = "enable_" + (secId === "hdr" ? "local_tone" : secId === "skin" ? "skin_rendering" : secId === "fusion" ? "deep_fusion" : secId === "sharp" ? "sharpening" : secId === "tone" ? "tone_mapping" : secId === "grade" ? "color_grading" : secId === "color" ? "p3_color" : secId === "wb" ? "white_balance" : secId); + + const toggleW = getWidget(node, toggleName); + if (!toggleW) return; + + const isEnabled = !!toggleW.value; + + // Find all rows in this section except the toggle itself + Object.entries(WIDGET_META).forEach(([name, meta]) => { + if (meta.section === secId && meta.type !== "toggle") { + const row = el.querySelector(`#row_${name}`); + if (row) row.classList.toggle("hidden", !isEnabled); + } + }); + }); +} + +function calcNodeSize(el, node) { + const visibleRows = el.querySelectorAll(".cfr-row:not(.hidden)").length; + const numSections = Object.keys(SECTIONS).length; + + const headerH = 120; // Title bar + info box + const rowH = 30; // height + margins + const secH = 36; // section headers + + const totalH = NODE_TITLE_HEIGHT + headerH + (numSections * secH) + (visibleRows * rowH); + const currentW = Math.max(node.size?.[0] || 0, NODE_MIN_WIDTH); + return [currentW, totalH]; +} + +/* ── Extension ───────────────────────────────────────────────────────── */ +app.registerExtension({ + name: "CameraForensicRealism.PremiumUI", + + nodeCreated(node) { + if (node.comfyClass !== NODE_NAME) return; + injectCSS(); + + const paramNames = Object.keys(WIDGET_META); + + // Hide all default widgets + for (const name of paramNames) hideDefaultWidget(getWidget(node, name)); + + // Build native DOM + const el = buildUI(); + node.addDOMWidget("cfr_premium_ui", "customwidget", el, { serialize: false }); + + if (!node.size || node.size[0] < NODE_MIN_WIDTH) node.size = [NODE_MIN_WIDTH, 600]; + + node.color = "#1A1A00"; node.bgcolor = "#0A0A0A"; + + // Bind DOM elements to LiteGraph Widgets + Object.entries(WIDGET_META).forEach(([name, meta]) => { + const domInp = el.querySelector(`#inp_${name}`); + const domVal = el.querySelector(`#val_${name}`); + const lgW = getWidget(node, name); + if (!domInp || !lgW) return; + + if (meta.type === "slider") { + domInp.addEventListener("input", () => { + lgW.value = parseFloat(domInp.value); + if (lgW.callback) lgW.callback(lgW.value); + if (domVal) domVal.textContent = Number(lgW.value).toFixed(2); + }); + } else if (meta.type === "toggle") { + domInp.addEventListener("change", (e) => { + lgW.value = e.target.checked; + if (lgW.callback) lgW.callback(lgW.value); + if (domVal) domVal.textContent = lgW.value ? "ON" : "OFF"; + + updateSectionVisibility(el, node); + requestAnimationFrame(() => { + node.setSize(calcNodeSize(el, node)); + node.setDirtyCanvas(true, true); + }); + }); + } else if (meta.type === "combo") { + (lgW.options?.values || []).forEach(opt => { + const o = document.createElement("option"); o.value = opt; o.innerText = opt; + domInp.appendChild(o); + }); + domInp.addEventListener("change", (e) => { + lgW.value = e.target.value; + if (lgW.callback) lgW.callback(lgW.value); + }); + } else if (meta.type === "number") { + domInp.addEventListener("input", (e) => { + lgW.value = parseFloat(e.target.value); + if (lgW.callback) lgW.callback(lgW.value); + }); + } + }); + + function syncFromWidgets() { + Object.entries(WIDGET_META).forEach(([name, meta]) => { + const domInp = el.querySelector(`#inp_${name}`); + const domVal = el.querySelector(`#val_${name}`); + const lgW = getWidget(node, name); + if (!domInp || !lgW) return; + + if (meta.type === "slider") { + domInp.value = lgW.value; + if (domVal) domVal.textContent = Number(lgW.value).toFixed(2); + } else if (meta.type === "toggle") { + domInp.checked = !!lgW.value; + if (domVal) domVal.textContent = lgW.value ? "ON" : "OFF"; + } else { + domInp.value = lgW.value; + } + }); + updateSectionVisibility(el, node); + } + + const origConfigure = node.onConfigure; + node.onConfigure = function (info) { + origConfigure?.apply(this, arguments); + setTimeout(() => { + syncFromWidgets(); + node.setSize(calcNodeSize(el, node)); + node.setDirtyCanvas?.(true, true); + }, 80); + }; + + const origBG = node.onDrawBackground; + node.onDrawBackground = function (ctx) { + if (origBG) origBG.apply(this, arguments); + if (this.flags?.collapsed) return; + ctx.save(); + ctx.fillStyle = "#1A1A00"; ctx.fillRect(0, -NODE_TITLE_HEIGHT, this.size[0], NODE_TITLE_HEIGHT); + ctx.restore(); + }; + + setTimeout(() => { + syncFromWidgets(); + node.setSize(calcNodeSize(el, node)); + node.setDirtyCanvas?.(true, true); + }, 150); + }, +}); diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..a93987a --- /dev/null +++ b/nodes.py @@ -0,0 +1,327 @@ +""" +Camera Forensic Realism Engine - ComfyUI Node Definition v3 +iPhone ISP color science with blue shadow tint + custom themed UI. +""" + +import numpy as np +import torch +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from forensic_engine import process_iphone_realism + + +class CameraForensicRealismEngine: + """ + Camera Forensic Realism Engine + Makes AI images look like iPhone 15 Pro photos. + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + + # ===================== MASTER ===================== + "master_strength": ("FLOAT", { + "default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "MASTER CONTROL - Scales all effects. 0.6-0.8 recommended." + }), + "seed": ("INT", { + "default": 0, "min": 0, "max": 0xFFFFFFFF, + "tooltip": "Random seed for noise reproducibility" + }), + + # ===================== TONE MAPPING ===================== + "enable_tone_mapping": ("BOOLEAN", { + "default": True, + "label_on": "TONE CURVE: ON", + "label_off": "TONE CURVE: OFF", + "tooltip": "iPhone S-curve: lifts shadows, compresses highlights, punchy mids" + }), + "tone_strength": ("FLOAT", { + "default": 0.6, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "How much tone curve to apply. 0.5-0.7 = natural iPhone" + }), + "highlight_rolloff": ("FLOAT", { + "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Smooth highlight compression - prevents blown-out whites" + }), + "shadow_lift": ("FLOAT", { + "default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Raises black level - iPhone never crushes pure black" + }), + "contrast": ("FLOAT", { + "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Mid-tone contrast punch" + }), + + # ===================== P3 COLOR ===================== + "enable_p3_color": ("BOOLEAN", { + "default": True, + "label_on": "P3 COLOR: ON", + "label_off": "P3 COLOR: OFF", + "tooltip": "Display P3 gamut - richer reds/greens via CIE XYZ transform" + }), + "color_strength": ("FLOAT", { + "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "P3 color rendering intensity" + }), + "color_saturation": ("FLOAT", { + "default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Wider gamut saturation boost - keep low for realism" + }), + "color_warmth": ("FLOAT", { + "default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Apple warm color bias - signature iPhone warmth" + }), + + # ===================== SMART HDR ===================== + "enable_local_tone": ("BOOLEAN", { + "default": True, + "label_on": "SMART HDR: ON", + "label_off": "SMART HDR: OFF", + "tooltip": "Local contrast like Apple's multi-frame HDR fusion" + }), + "local_tone_strength": ("FLOAT", { + "default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Local contrast/HDR intensity" + }), + "detail_boost": ("FLOAT", { + "default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Micro-detail enhancement - texture pop" + }), + + # ===================== SKIN ===================== + "enable_skin_rendering": ("BOOLEAN", { + "default": True, + "label_on": "REAL TONE: ON", + "label_off": "REAL TONE: OFF", + "tooltip": "Apple Real Tone - warm natural skin, prevents orange" + }), + "skin_strength": ("FLOAT", { + "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Skin rendering intensity" + }), + "skin_warmth": ("FLOAT", { + "default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Skin warmth - natural glow without oversaturation" + }), + + # ===================== DEEP FUSION ===================== + "enable_deep_fusion": ("BOOLEAN", { + "default": True, + "label_on": "DEEP FUSION: ON", + "label_off": "DEEP FUSION: OFF", + "tooltip": "Isolate and crunch high-frequency textures (fabric, pores) without halos." + }), + "fusion_strength": ("FLOAT", { + "default": 0.6, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Texture amplification strength (the iPhone computational crunch)." + }), + "fusion_texture_freq": ("FLOAT", { + "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Determines which texture thicknesses are crunched." + }), + + # ===================== WHITE BALANCE ===================== + "enable_white_balance": ("BOOLEAN", { + "default": True, + "label_on": "WHITE BAL: ON", + "label_off": "WHITE BAL: OFF", + "tooltip": "iPhone AWB - warm daylight bias" + }), + "wb_strength": ("FLOAT", { + "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "White balance correction strength" + }), + "wb_temperature": ("FLOAT", { + "default": 0.25, "min": -1.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Cool(blue) <----> Warm(orange). iPhone = ~0.2-0.3" + }), + "wb_tint": ("FLOAT", { + "default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Green <----> Magenta tint. 0 = neutral" + }), + + # ===================== COLOR GRADING ===================== + "enable_color_grading": ("BOOLEAN", { + "default": True, + "label_on": "COLOR GRADE: ON", + "label_off": "COLOR GRADE: OFF", + "tooltip": "iPhone signature: blue-tinted blacks + warm golden highlights" + }), + "blue_shadows": ("FLOAT", { + "default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Blue tint in dark areas - the iPhone blue-black look" + }), + "warm_highlights": ("FLOAT", { + "default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Golden warmth in bright areas - complements blue shadows" + }), + + # ===================== SHARPENING ===================== + "enable_sharpening": ("BOOLEAN", { + "default": True, + "label_on": "ISP SHARP: ON", + "label_off": "ISP SHARP: OFF", + "tooltip": "Apple-style luminance sharpening - crisp not crunchy" + }), + "sharpen_strength": ("FLOAT", { + "default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Sharpening intensity" + }), + + # ===================== SENSOR ===================== + "enable_sensor": ("BOOLEAN", { + "default": True, + "label_on": "SENSOR FX: ON", + "label_off": "SENSOR FX: OFF", + "tooltip": "Subtle luminance noise + lens vignette" + }), + "sensor_strength": ("FLOAT", { + "default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Overall sensor effect intensity" + }), + "sensor_noise": ("FLOAT", { + "default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Luminance noise - very subtle on iPhone" + }), + "sensor_vignette": ("FLOAT", { + "default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + "tooltip": "Lens vignette - f/1.78 falloff" + }), + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "apply_iphone_realism" + CATEGORY = "image/forensic" + + DESCRIPTION = ( + "Camera Forensic Realism Engine v3\n\n" + "iPhone 15 Pro ISP emulation:\n" + "- S-curve tone mapping (filmic highlight rolloff)\n" + "- Display P3 color (CIE XYZ gamut transform)\n" + "- Smart HDR local tone mapping\n" + "- Real Tone skin rendering\n" + "- Deep Fusion edge-preserving texture crunch\n" + "- Blue shadow tint (iPhone blue-black look)\n" + "- Warm golden highlights\n" + "- ISP sharpening + subtle sensor FX" + ) + + def apply_iphone_realism(self, image: torch.Tensor, + master_strength: float, + seed: int, + enable_tone_mapping: bool, + tone_strength: float, + highlight_rolloff: float, + shadow_lift: float, + contrast: float, + enable_p3_color: bool, + color_strength: float, + color_saturation: float, + color_warmth: float, + enable_local_tone: bool, + local_tone_strength: float, + detail_boost: float, + enable_skin_rendering: bool, + skin_strength: float, + skin_warmth: float, + enable_deep_fusion: bool, + fusion_strength: float, + fusion_texture_freq: float, + enable_white_balance: bool, + wb_strength: float, + wb_temperature: float, + wb_tint: float, + enable_color_grading: bool, + blue_shadows: float, + warm_highlights: float, + enable_sharpening: bool, + sharpen_strength: float, + enable_sensor: bool, + sensor_strength: float, + sensor_noise: float, + sensor_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) + img_np = np.clip(img_np, 0.0, 1.0) + + processed = process_iphone_realism( + image=img_np, + master_strength=master_strength, + enable_tone_mapping=enable_tone_mapping, + tone_strength=tone_strength, + highlight_rolloff=highlight_rolloff, + shadow_lift=shadow_lift, + contrast=contrast, + enable_p3_color=enable_p3_color, + color_strength=color_strength, + color_saturation=color_saturation, + color_warmth=color_warmth, + enable_local_tone=enable_local_tone, + local_tone_strength=local_tone_strength, + detail_boost=detail_boost, + enable_skin_rendering=enable_skin_rendering, + skin_strength=skin_strength, + skin_warmth=skin_warmth, + enable_deep_fusion=enable_deep_fusion, + fusion_strength=fusion_strength, + fusion_texture_freq=fusion_texture_freq, + enable_white_balance=enable_white_balance, + wb_strength=wb_strength, + wb_temperature=wb_temperature, + wb_tint=wb_tint, + enable_color_grading=enable_color_grading, + blue_shadows=blue_shadows, + warm_highlights=warm_highlights, + enable_sharpening=enable_sharpening, + sharpen_strength=sharpen_strength, + enable_sensor=enable_sensor, + sensor_strength=sensor_strength, + sensor_noise=sensor_noise, + sensor_vignette=sensor_vignette, + seed=seed + i, + ) + + results.append(torch.from_numpy(processed)) + + output = torch.stack(results, dim=0) + return (output,) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ac737ff --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# ComfyUI-CameraForensicRealism dependencies +# numpy and torch are already provided by ComfyUI +numpy>=1.24.0 diff --git a/test_node.py b/test_node.py new file mode 100644 index 0000000..5bd8b6e --- /dev/null +++ b/test_node.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +"""Camera Forensic Realism Engine v3 - Test Suite""" + +import sys, os, io, time + +if sys.platform == "win32": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import numpy as np + + +def make_test_image(h=256, w=256): + img = np.zeros((h, w, 3), dtype=np.float32) + img[:, :, 0] = np.linspace(0.3, 0.8, w)[np.newaxis, :] + img[:, :, 1] = np.linspace(0.25, 0.65, w)[np.newaxis, :] + img[:, :, 2] = np.linspace(0.2, 0.5, w)[np.newaxis, :] + img[:h//4, :, :] = [0.7, 0.8, 0.95] # Sky highlight + img[3*h//4:, :, :] = [0.08, 0.06, 0.05] # Dark shadow + cy, cx = h // 2, w // 2 + img[cy-30:cy+30, cx-30:cx+30, :] = [0.76, 0.57, 0.45] # Skin + return np.clip(img, 0.0, 1.0) + + +def check(out, shape, name): + ok = True + if out.shape != shape: print(f" [FAIL] {name}: shape"); ok = False + if out.dtype != np.float32: print(f" [FAIL] {name}: dtype {out.dtype}"); ok = False + if np.any(np.isnan(out)) or np.any(np.isinf(out)): print(f" [FAIL] {name}: NaN/Inf"); ok = False + if out.min() < 0 or out.max() > 1: print(f" [FAIL] {name}: range [{out.min():.4f},{out.max():.4f}]"); ok = False + if ok: print(f" [PASS] {name}") + return ok + + +def test_tone(): + print("\n--- Test 1: Tone Mapping ---") + from forensic_engine import apply_iphone_tone_curve + img = make_test_image() + r = apply_iphone_tone_curve(img, 0.7, 0.5, 0.4, 0.5) + return check(r, img.shape, "Tone curve") + +def test_p3(): + print("\n--- Test 2: P3 Color ---") + from forensic_engine import apply_p3_color_rendering + img = make_test_image() + r = apply_p3_color_rendering(img, 0.6, 0.3, 0.3) + return check(r, img.shape, "P3 color") + +def test_local(): + print("\n--- Test 3: Smart HDR ---") + from forensic_engine import apply_local_tone_mapping + img = make_test_image() + r = apply_local_tone_mapping(img, 0.5, 0.5) + return check(r, img.shape, "Local tone mapping") + +def test_skin(): + print("\n--- Test 4: Real Tone ---") + from forensic_engine import apply_skin_tone_rendering + img = make_test_image() + r = apply_skin_tone_rendering(img, 0.6, 0.5) + ok = check(r, img.shape, "Skin rendering") + skin_diff = np.mean(np.abs(r[98:158, 98:158, :] - img[98:158, 98:158, :])) + bg_diff = np.mean(np.abs(r[:40, :40, :] - img[:40, :40, :])) + print(f" Skin diff: {skin_diff:.4f}, BG diff: {bg_diff:.4f}") + return ok + +def test_blue_shadows(): + print("\n--- Test 5: Blue Shadow Tint ---") + from forensic_engine import apply_blue_shadow_tint + img = make_test_image() + r = apply_blue_shadow_tint(img, 0.5, 0.35, 0.5) + ok = check(r, img.shape, "Blue shadows") + # Dark area (bottom) should have more blue + dark_orig_b = np.mean(img[200:, :, 2]) + dark_new_b = np.mean(r[200:, :, 2]) + dark_orig_r = np.mean(img[200:, :, 0]) + dark_new_r = np.mean(r[200:, :, 0]) + if dark_new_b > dark_orig_b and dark_new_r < dark_orig_r: + print(f" [PASS] Blue tint in shadows (B: {dark_orig_b:.4f}->{dark_new_b:.4f}, R: {dark_orig_r:.4f}->{dark_new_r:.4f})") + else: + print(f" [INFO] B: {dark_orig_b:.4f}->{dark_new_b:.4f}, R: {dark_orig_r:.4f}->{dark_new_r:.4f}") + return ok + +def test_warm_highlights(): + print("\n--- Test 6: Warm Highlights ---") + from forensic_engine import apply_highlight_warmth + img = make_test_image() + r = apply_highlight_warmth(img, 0.5, 0.7, 0.5) + ok = check(r, img.shape, "Warm highlights") + # Bright area (top) should have more red warmth + hl_orig_r = np.mean(img[:40, :, 0]) + hl_new_r = np.mean(r[:40, :, 0]) + hl_orig_b = np.mean(img[:40, :, 2]) + hl_new_b = np.mean(r[:40, :, 2]) + if hl_new_r > hl_orig_r and hl_new_b < hl_orig_b: + print(f" [PASS] Warm highlights (R: {hl_orig_r:.4f}->{hl_new_r:.4f}, B: {hl_orig_b:.4f}->{hl_new_b:.4f})") + else: + print(f" [INFO] R: {hl_orig_r:.4f}->{hl_new_r:.4f}, B: {hl_orig_b:.4f}->{hl_new_b:.4f}") + return ok + +def test_wb(): + print("\n--- Test 7: White Balance ---") + from forensic_engine import apply_white_balance + img = np.ones((64, 64, 3), dtype=np.float32) * 0.5 + r = apply_white_balance(img, 0.7, 0.3, 0.0) + ok = check(r, img.shape, "White balance") + rd = r[32,32,0] - img[32,32,0]; bd = r[32,32,2] - img[32,32,2] + print(f" R: {rd:+.4f}, B: {bd:+.4f}") + if rd > 0 and bd < 0: print(f" [PASS] Warm WB") + return ok + +def test_pipeline(): + print("\n--- Test 8: Full Pipeline ---") + from forensic_engine import process_iphone_realism + img = make_test_image() + t0 = time.time() + r = process_iphone_realism(img, master_strength=0.7, seed=42) + elapsed = time.time() - t0 + ok = check(r, img.shape, f"Pipeline ({elapsed:.2f}s)") + print(f" Total diff: {np.mean(np.abs(r-img)):.4f}") + return ok + +def test_node(): + print("\n--- Test 9: ComfyUI Node ---") + try: import torch + except ImportError: print(" [SKIP] No PyTorch"); return True + from nodes import CameraForensicRealismEngine + node = CameraForensicRealismEngine() + inputs = node.INPUT_TYPES()["required"] + print(f" {len(inputs)} widgets") + # Check new color grading inputs exist + for name in ["enable_color_grading", "blue_shadows", "warm_highlights"]: + if name in inputs: + print(f" [PASS] '{name}' present") + else: + print(f" [FAIL] '{name}' MISSING"); return False + test_img = torch.rand(1, 128, 128, 3) + result = node.apply_iphone_realism( + image=test_img, master_strength=0.7, seed=42, + enable_tone_mapping=True, tone_strength=0.6, + highlight_rolloff=0.5, shadow_lift=0.4, contrast=0.5, + enable_p3_color=True, color_strength=0.5, + 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_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, + enable_sensor=True, sensor_strength=0.25, sensor_noise=0.3, sensor_vignette=0.4, + ) + out = result[0] + if out.shape == test_img.shape: print(f" [PASS] Shape: {tuple(out.shape)}") + else: print(f" [FAIL] Shape"); return False + if 0 <= out.min() and out.max() <= 1: print(f" [PASS] Range: [{out.min():.4f}, {out.max():.4f}]") + else: print(f" [FAIL] Range"); return False + return True + +def test_js_exists(): + print("\n--- Test 10: Custom UI ---") + js_path = os.path.join(os.path.dirname(__file__), "js", "camera_forensic_ui.js") + if os.path.exists(js_path): + size = os.path.getsize(js_path) + print(f" [PASS] JS UI file exists ({size} bytes)") + return True + else: + print(f" [FAIL] JS UI file missing") + return False + + +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] + results = [] + for t in tests: + try: results.append(t()) + except Exception as e: + print(f" [EXCEPTION] {e}") + import traceback; traceback.print_exc() + results.append(False) + print("\n" + "=" * 60) + p = sum(1 for r in results if r) + print(f"Results: {p}/{len(results)} tests passed") + print("ALL PASSED!" if p == len(results) else "Some failed") + print("=" * 60)