Initial commit

This commit is contained in:
monkeyforever2
2026-03-08 13:34:11 +01:00
commit 96f011bf60
9 changed files with 2090 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.env
.idea/
.vscode/
venv/
env/
+27
View File
@@ -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).
+28
View File
@@ -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"]
+319
View File
@@ -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())
+787
View File
@@ -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)
+404
View File
@@ -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 = `
<div class="cfr-header">
<div class="cfr-title">Camera Forensic Realism</div>
<div class="cfr-subtitle">iPhone 15 Pro • ISP Emulation • 9 Stages</div>
</div>
<div class="cfr-info">
<span class="cfr-info-badge">★ iPHONE ISP PIPELINE</span>
<div class="cfr-info-desc">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.</div>
<div class="cfr-info-tip">💡 Place after detailers, before SaveImage.</div>
</div>
`;
// Generate Sections & Controls
Object.entries(SECTIONS).forEach(([secId, sec]) => {
html += `<div class="cfr-section" id="sec_${secId}">${sec.icon} ${sec.label}</div>`;
html += `<div id="wrap_${secId}">`;
Object.entries(WIDGET_META).forEach(([wName, w]) => {
if (w.section !== secId) return;
html += `<div class="cfr-row" id="row_${wName}">
<span class="cfr-label cfr-tooltip" data-tip="${w.desc}">${w.label}</span>`;
if (w.type === "slider") {
html += `
<input type="range" class="cfr-slider" id="inp_${wName}" min="${w.min}" max="${w.max}" step="${w.step}">
<span class="cfr-slider-val" id="val_${wName}"></span>`;
} else if (w.type === "toggle") {
html += `
<label class="cfr-switch">
<input type="checkbox" id="inp_${wName}">
<span class="cfr-switch-text" id="val_${wName}">OFF</span>
<div class="cfr-switch-track"><div class="cfr-switch-thumb"></div></div>
</label>`;
} else if (w.type === "combo") {
html += `<select class="cfr-input" id="inp_${wName}"></select>`;
} else if (w.type === "number") {
html += `<input type="number" class="cfr-input" id="inp_${wName}">`;
}
html += `</div>`;
});
html += `</div>`;
});
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);
},
});
+327
View File
@@ -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,)
+3
View File
@@ -0,0 +1,3 @@
# ComfyUI-CameraForensicRealism dependencies
# numpy and torch are already provided by ComfyUI
numpy>=1.24.0
+188
View File
@@ -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)