788 lines
30 KiB
Python
788 lines
30 KiB
Python
"""
|
|
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)
|