diff --git a/Nodes/Rasterix.py b/Nodes/Rasterix.py index 3e31471..7852fb9 100644 --- a/Nodes/Rasterix.py +++ b/Nodes/Rasterix.py @@ -30,6 +30,8 @@ from ..components.images import img_filmic_curve as img_filmic_curve from ..components.images import img_lut3d as img_lut3d from ..components.images import img_edge_jitter as img_edge_jitter from ..components.images import img_depth_blur as img_depth_blur +from ..components.images import img_photo_paper as img_photo_paper +from ..components.images.img_photo_paper import PAPER_PRESETS from ..components import utility from .Dashboard import PrimereModelConceptSelector as PrimereModelConceptSelector @@ -69,7 +71,8 @@ class PrimereRasterix: {"after": "blend_mode", "name": "rasterix_local_laplacian", "title": "๐Ÿงฑ Tone: Edge-Aware Pyramid", "color": "#405985", "text_color": "#EAF1F8", "label": "Local Laplacian contrast/detail enhancement with strong edge preservation."}, {"after": "levels", "name": "rasterix_analog_film", "title": "๐ŸŽž Creative: Analog Film / CCD", "color": "#3B5E68", "text_color": "#EAF1F8", "label": "Stylized film and sensor-era rendering for mood, palette, and texture character. Inspired by DxO."}, - {"after": "photo_paper", "name": "rasterix_lut_reader", "title": "๐Ÿ“ท Creative: LUT .cube Reader", "color": "#3B5E68", "text_color": "#EAF1F8", "label": "Load and blend LUT looks for fast creative direction and consistent show style. Inspired by Blackmagic DaVinci Resolve and DxO."}, + {"after": "expiration_years", "name": "rasterix_photo_paper", "title": "๐Ÿงช Creative: Photo Paper Simulation", "color": "#3B5E68", "text_color": "#EAF1F8", "label": "Darkroom-inspired paper response with selectable grade, RC/FB base, color/B&W mode, and controlled print intensity."}, + {"after": "paper_intensity", "name": "rasterix_lut_reader", "title": "๐Ÿ“ท Creative: LUT .cube Reader", "color": "#3B5E68", "text_color": "#EAF1F8", "label": "Load and blend LUT looks for fast creative direction and consistent show style. Inspired by Blackmagic DaVinci Resolve and DxO."}, {"after": "color_space", "name": "rasterix_filmic_camera", "title": "๐ŸŽฅ Creative: Filmic Camera Curve", "color": "#3B5E68", "text_color": "#EAF1F8", "label": "Camera-like highlight roll-off and tonal response for cinematic dynamic range behavior. Inspired by Adobe Camera Raw."}, {"after": "pivot", "name": "rasterix_selective_tone", "title": "๐ŸŽ› Color: Selective Tone Zones", "color": "#6A5636", "text_color": "#EAF1F8", "label": "Zone-based tonal pushes for highlights, midtones, shadows, and blacks. Inspired by DxO Photolab"}, @@ -176,8 +179,12 @@ class PrimereRasterix: "halation": ("BOOLEAN", {"default": False, "label_off": "Ignore halation", "label_on": "Add halation"}), "expiration_years": ("INT", {"default": 0, "min": 0, "max": 30, "step": 1}), - "use_photo_paper": ("BOOLEAN", {"default": False, "label_off": "Ignore H/B photo paper", "label_on": "Apply H/B photo paper"}), - "photo_paper": ("BOOLEAN", {"default": False, "label_off": "Soft paper", "label_on": "Hard paper"}), + "use_photo_paper": ("BOOLEAN", {"default": False, "label_off": "Ignore photo paper", "label_on": "Apply photo paper"}), + "photo_paper": (list(PAPER_PRESETS.keys()), {"default": list(PAPER_PRESETS.keys())[0]}), + "color_paper": ("BOOLEAN", {"default": False, "label_off": "B&W paper", "label_on": "Color paper"}), + "paper_base": (["RC", "FB"], {"default": "RC"}), + "paper_expiration_years": ("FLOAT", {"default": 0, "min": 0, "max": 30, "step": 0.1}), + "paper_intensity": ("FLOAT", {"default": 100, "min": 0, "max": 200, "step": 1}), "use_lut": ("BOOLEAN", {"default": False, "label_off": "Ignore LUT", "label_on": "Apply LUT"}), "lut_file": (cls._list_luts(),), @@ -339,7 +346,11 @@ class PrimereRasterix: halation = kwargs.get('halation', False) expiration_years = kwargs.get('expiration_years', 0) use_photo_paper = kwargs.get('use_photo_paper', False) - photo_paper = kwargs.get('photo_paper', False) + photo_paper = kwargs.get('photo_paper', "N (ISO R 90, normal)") + color_paper = kwargs.get('color_paper', False) + paper_base = kwargs.get('paper_base', "RC") + paper_expiration_years = kwargs.get('paper_expiration_years', 0) + paper_intensity = kwargs.get('paper_intensity', 100) use_filmic = kwargs.get('use_filmic', False) curve_type = kwargs.get('curve_type', "filmic") filmic_contrast = kwargs.get('filmic_contrast', 1.0) @@ -427,8 +438,8 @@ class PrimereRasterix: if use_filmic: pil_img = img_filmic_curve.img_filmic_curve(image=pil_img, curve_type=curve_type, contrast=filmic_contrast, highlight_rolloff=highlight_rolloff, shadow_lift=shadow_lift, pivot=pivot) - if use_photo_paper: - pil_img = img_solarization_bw.img_solarization_bw(image=pil_img, color_mode=False, strength=0.00, pivot=0.00, sigma=0.01, edge_boost=0.00, edge_radius=0.5, contrast=1, precision=precision, hard_paper=photo_paper, grain_modulation=False, grain_strength=0, grain_scale=1, seed=1) + if use_photo_paper and paper_intensity != 0: + pil_img = img_photo_paper.img_photo_paper(image=pil_img, paper_type=photo_paper, color_paper=color_paper, paper_base=paper_base, paper_intensity=paper_intensity, expiration_years=paper_expiration_years) st_data = rasterix_data.get('selective_tone', {}) if use_selective_tone and st_data: @@ -1378,4 +1389,31 @@ class PrimereDepthBlur: if use_depth_blur: pil_img = img_depth_blur.img_depth_blur(image=pil_img, focus_depth=focus_depth, depth_range=depth_range, max_blur=max_blur, depth_gamma=depth_gamma, auto_optimize=auto_optimize, use_v3=use_DA_v3) + return (utility.image_to_tensor(pil_img),) + +class PrimerePhotoPaper: + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("IMAGE",) + FUNCTION = "primere_photo_paper" + CATEGORY = TREE_RASTERIX + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE", {"forceInput": True}), + "use_photo_paper": ("BOOLEAN", {"default": False, "label_off": "Ignore photo paper", "label_on": "Apply photo paper"}), + "photo_paper": (list(PAPER_PRESETS.keys()), {"default": list(PAPER_PRESETS.keys())[0]}), + "color_paper": ("BOOLEAN", {"default": False, "label_off": "B&W paper", "label_on": "Color paper"}), + "paper_base": (["RC", "FB"], {"default": "RC"}), + "paper_expiration_years": ("FLOAT", {"default": 0, "min": 0, "max": 30, "step": 0.1}), + "paper_intensity": ("FLOAT", {"default": 100, "min": 0, "max": 200, "step": 1}), + } + } + + def primere_photo_paper(self, image, use_photo_paper, photo_paper, color_paper, paper_base, paper_intensity, paper_expiration_years): + pil_img = utility.tensor_to_image(image) + if use_photo_paper and paper_intensity != 0: + pil_img = img_photo_paper.img_photo_paper(image=pil_img, paper_type=photo_paper, color_paper=color_paper, paper_base=paper_base, paper_intensity=paper_intensity, expiration_years=paper_expiration_years) + return (utility.image_to_tensor(pil_img),) \ No newline at end of file diff --git a/__init__.py b/__init__.py index fb17d88..8c23711 100644 --- a/__init__.py +++ b/__init__.py @@ -67,6 +67,7 @@ NODE_CLASS_MAPPINGS = { "PrimereBlur": Rasterix.PrimereBlur, "PrimereBrightnessContrast": Rasterix.PrimereBrightnessContrast, "PrimereFilmRendering": Rasterix.PrimereFilmRendering, + "PrimerePhotoPaper": Rasterix.PrimerePhotoPaper, "PrimereSelectiveTone": Rasterix.PrimereSelectiveTone, "PrimereColorBalance": Rasterix.PrimereColorBalance, "PrimereHSL": Rasterix.PrimereHSL, @@ -174,6 +175,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "PrimereBlur": "Primere Rasterix (Blur)", "PrimereBrightnessContrast": "Primere Rasterix (Brightness Contrast)", "PrimereFilmRendering": "Primere Rasterix (Film Rendering)", + "PrimerePhotoPaper": "Primere Rasterix (Photo Paper)", "PrimereSelectiveTone": "Primere Rasterix (Selective Tone)", "PrimereColorBalance": "Primere Rasterix (Color Balance)", "PrimereHSL": "Primere Rasterix (HSL)", diff --git a/components/images/img_photo_paper.py b/components/images/img_photo_paper.py new file mode 100644 index 0000000..b661027 --- /dev/null +++ b/components/images/img_photo_paper.py @@ -0,0 +1,95 @@ +import numpy as np +from PIL import Image + + +PAPER_PRESETS = { + "BN (ISO R 130, soft)": {"contrast": 0.88, "toe": 0.10, "shoulder": 0.04}, + "B (ISO R 110, medium-soft)": {"contrast": 0.96, "toe": 0.08, "shoulder": 0.05}, + "N (ISO R 90, normal)": {"contrast": 1.04, "toe": 0.06, "shoulder": 0.06}, + "H (ISO R 70, hard)": {"contrast": 1.18, "toe": 0.05, "shoulder": 0.08}, + "HD (ISO R 50, extra hard)": {"contrast": 1.32, "toe": 0.03, "shoulder": 0.10}, + "Grade 00 (very soft)": {"contrast": 0.84, "toe": 0.12, "shoulder": 0.04}, + "Grade 0 (soft)": {"contrast": 0.92, "toe": 0.09, "shoulder": 0.05}, + "Grade 2 (normal)": {"contrast": 1.00, "toe": 0.07, "shoulder": 0.06}, + "Grade 3 (normal-hard)": {"contrast": 1.12, "toe": 0.06, "shoulder": 0.07}, + "Grade 4 (hard)": {"contrast": 1.24, "toe": 0.05, "shoulder": 0.09}, + "Grade 5 (ultra hard)": {"contrast": 1.36, "toe": 0.03, "shoulder": 0.12}, +} + + +def _apply_tone_curve(x, contrast, toe, shoulder): + x = np.clip(0.5 + (x - 0.5) * contrast, 0.0, 1.0) + if toe > 0: + x = x * (1.0 - toe) + np.power(x, 1.25) * toe + if shoulder > 0: + inv = 1.0 - x + x = 1.0 - (inv * (1.0 - shoulder) + np.power(inv, 1.25) * shoulder) + return np.clip(x, 0.0, 1.0) + + +def _apply_paper_expiration(arr, years): + out = arr.copy() + + fog = np.clip(years * 0.006, 0, 0.18) + out = out + fog * (1.0 - out) + + blue_fade = np.clip(1.0 - (years * 0.012), 0.65, 1.0) + out[..., 2] *= blue_fade + + contrast = np.clip(1.0 - (years * 0.01), 0.7, 1.0) + out = np.clip((out - 0.5) * contrast + 0.5, 0.0, 1.0) + + return out + + +def img_photo_paper( + image: Image.Image, + paper_type: str = "N (ISO R 90, normal)", + color_paper: bool = False, + paper_base: str = "RC", + paper_intensity: float = 100.0, + expiration_years: float = 0.0, +) -> Image.Image: + src = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0 + preset = PAPER_PRESETS.get(paper_type, PAPER_PRESETS["N (ISO R 90, normal)"]) + + contrast = preset["contrast"] + toe = preset["toe"] + shoulder = preset["shoulder"] + + if paper_base == "FB": + toe += 0.02 + shoulder += 0.01 + contrast *= 0.98 + elif paper_base == "RC": + contrast *= 1.02 + + intensity = float(np.clip(paper_intensity, 0.0, 200.0)) + strength = (intensity - 100.0) / 100.0 + contrast = np.clip(contrast * (1.0 + 0.25 * strength), 0.5, 2.5) + toe = np.clip(toe * (1.0 + 0.35 * strength), 0.0, 0.35) + shoulder = np.clip(shoulder * (1.0 + 0.35 * strength), 0.0, 0.35) + + if color_paper: + out = _apply_tone_curve(src, contrast, toe, shoulder) + sat = 1.02 if paper_base == "RC" else 0.98 + mean = out.mean(axis=2, keepdims=True) + out = np.clip(mean + (out - mean) * sat, 0.0, 1.0) + else: + # In B&W mode, force grayscale source before blending to avoid color casts + # at any intensity value (including > 100). + luma = 0.299 * src[..., 0] + 0.587 * src[..., 1] + 0.114 * src[..., 2] + src = np.stack([luma, luma, luma], axis=-1) + tone = _apply_tone_curve(luma, contrast, toe, shoulder) + out = np.stack([tone, tone, tone], axis=-1) + + out = _apply_paper_expiration(out, expiration_years) + + if intensity <= 100.0: + amount = (intensity / 100.0) * 0.90 + else: + amount = 1.0 + ((intensity - 100.0) / 100.0) * 1.10 + amount = np.clip(amount, 0.0, 2.0) + + blended = np.clip(src + (out - src) * amount, 0.0, 1.0) + return Image.fromarray((blended * 255.0).astype(np.uint8), mode="RGB")