Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.idea/
|
||||
.vscode/
|
||||
venv/
|
||||
env/
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,)
|
||||
@@ -0,0 +1,3 @@
|
||||
# ComfyUI-CameraForensicRealism dependencies
|
||||
# numpy and torch are already provided by ComfyUI
|
||||
numpy>=1.24.0
|
||||
+188
@@ -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)
|
||||
Reference in New Issue
Block a user