feat(batch-4): N-12 HALDIdentity + N-13 LUTExport ComfyUI nodes + workflow + tests (S-14 LUT round-trip)
- core/lut.py: identity_hald (ImageMagick HALD convention) + export_cube
(Adobe Cube 1.0 format) — pure torch.arange + stdlib file I/O, zero Kornia.
- nodes/hald_identity_node.py: JHPixelProHALDIdentity COMBO {4,6,8,10,12}
level, outputs (IMAGE, INT level pass-through). CATEGORY /color.
- nodes/lut_export_node.py: JHPixelProLUTExport, OUTPUT_NODE=True, writes
to folder_paths.get_output_directory()/filename (.cube auto-append).
Absolute path bypass resolve. Lazy folder_paths import so tests pass
without a ComfyUI runtime available.
- workflows/S-14-lut-export.json: 3-node chain demo N-12 -> N-09 ToneCurve
s_curve_mild -> N-13 Export s14_demo.cube (+ Note block documenting
the color-only-chain constraint between N-12 and N-13).
- tests/test_lut.py: 8 AC (shape level=8 + level=12, black/white corners,
round-trip identity parse spot-check, header format + 262144 body lines,
shape mismatch ValueError, non-finite ValueError).
- README sec N-12 + N-13: 2 H2 sections numerical order sau sec N-11,
pattern sec N-01..sec N-11 (image ref AFTER **Sample workflow:** link
BEFORE ### Use cases H3).
- __init__.py register 13 class total (+2 new), CATEGORY groups comment
updated: color = Lum + ColorMatcher + ToneCurve + HALDIdentity + LUTExport.
- core/__init__.py re-exports identity_hald + export_cube (consistency
with existing 11 core function re-exports).
- Zero new dep (pyproject.toml + requirements.txt unchanged).
- pytest 214 passed / 10 CUDA-skip (206 baseline + 8 new, zero regression),
ruff clean.
One-time sec 2b exception: single-agent Claude Code full-stack (core/ +
nodes/) cho trivial-math node (no Kornia, no bench-critical). Cowork
ref impl verbatim trong S-14 sec 3 -- pack precedent new pattern.
Refs: T-20260420-20, S-14
This commit is contained in:
@@ -516,6 +516,73 @@ The sample workflow chains `LoadImage → S-06 → ImageInvert (mock AI edit)
|
||||
- **Round-trip bilinear softening.** Align (S-06) + edit + unwrap (S-11) goes through two bilinear resamples plus a feather blur, which softens the composite by ~10–34/255 in uint8. Good enough for a retouch chain, not near-lossless — do not stack multiple unwarp rounds.
|
||||
- **Simple alpha-over composite only.** For multiply / screen / overlay blend modes, wire the `mask_used` output into a downstream `ImageBlend` node.
|
||||
|
||||
## N-12 HALD Identity
|
||||
|
||||
Generate an identity HALD image to feed into any color-grade chain and round-trip the grade into a portable Adobe Cube 1.0 (`.cube`) 3D LUT via `N-13 LUT Export`. The HALD image encodes a cube of `N = L²` color samples as a single `L³ × L³` RGB image (ImageMagick HALD convention); whatever color-only operators you wire between N-12 and N-13 get baked into the exported LUT.
|
||||
|
||||
**Inputs:**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `level` | COMBO | `"8"` | HALD level L in `{4, 6, 8, 10, 12}`. Cube N = L² (L=8 → N=64, industry standard). Image side = L³ pixels: L=4 → 64×64, L=8 → 512×512, L=12 → 1728×1728. |
|
||||
|
||||
**Outputs:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| `image` | IMAGE | Identity HALD image `(1, L³, L³, 3)` float32 `[0, 1]`. Feed through your color-grade chain, then into `N-13 LUT Export`. |
|
||||
| `level` | INT | Pass-through level L — wire into `N-13 LUT Export`'s `level` input to guarantee matching cube size. |
|
||||
|
||||
**Sample workflow:** [workflows/S-14-lut-export.json](workflows/S-14-lut-export.json)
|
||||
|
||||

|
||||
|
||||
### Use cases
|
||||
|
||||
- **Bake a ComfyUI color grade into a portable LUT.** Replace `LoadImage` with `N-12 HALD Identity`, keep the rest of your color-only chain (Tone Curve, Color Matcher, Luminosity blends), and pipe the result into `N-13 LUT Export`.
|
||||
- **Ship creative looks to non-ComfyUI tools.** DaVinci Resolve, Premiere, Final Cut, OBS, OCIO, hardware LUT boxes — anything that reads Adobe Cube 1.0 can load the exported `.cube`.
|
||||
- **Iterate a look once, apply it many times.** Develop the grade inside ComfyUI, export the LUT, then apply it per-frame on a monitor box or in a color-grading suite without re-running the graph.
|
||||
|
||||
### Caveats
|
||||
|
||||
- **Color-only chain between N-12 and N-13.** Anything spatial (sharpening, denoise, detail masks, face pipeline, skin smoother, lens distortion) will NOT linearize into a 3D LUT — don't wire them between N-12 and N-13.
|
||||
- **Level vs memory.** Level 12 produces a `1728 × 1728` image (~35.8 MB float32). Downstream chains may 2–3× that briefly; keep that in mind on CPU-only or low-VRAM runners.
|
||||
|
||||
## N-13 LUT Export (.cube)
|
||||
|
||||
Export a graded HALD image as an Adobe Cube 1.0 (`.cube`) 3D-LUT file. Pairs with `N-12 HALD Identity` upstream — the graded HALD (after your color-only chain) carries the cube's worth of color samples, and N-13 serializes them to a format readable by DaVinci Resolve 18+, Premiere Pro 2023+, OBS Studio 29+, OCIO 2.2+, LUTCalc, and most pro color tooling.
|
||||
|
||||
**Inputs:**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `image` | IMAGE | — | Graded HALD image (BHWC float32 `[0, 1]`). First batch is taken. `H == W == L³` is validated. |
|
||||
| `level` | INT | `8` | HALD level L — MUST match the level used by the upstream `N-12 HALD Identity`. Range `[2, 16]`. Mismatch raises `ValueError`. |
|
||||
| `filename` | STRING | `"pack_lut.cube"` | Filename (relative to ComfyUI `output/`) or absolute path. `.cube` extension auto-appended if missing. Existing files are overwritten. |
|
||||
| `title` | STRING | `"JHPixelPro LUT"` | Adobe Cube `TITLE` header metadata — shown by DaVinci / Premiere when browsing `.cube` files. |
|
||||
|
||||
**Outputs:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| `path` | STRING | Absolute resolved path of the written `.cube` file. Pipe into a downstream `ShowText` / notify node to display in the UI. |
|
||||
|
||||
**Sample workflow:** [workflows/S-14-lut-export.json](workflows/S-14-lut-export.json)
|
||||
|
||||

|
||||
|
||||
### Use cases
|
||||
|
||||
- **Deliver a color-grade chain as a `.cube` file.** One export, usable in any OCIO / Resolve / Premiere workflow.
|
||||
- **On-set monitor LUT.** Bake the look into a hardware LUT box or DIT monitor for live preview that matches the final grade.
|
||||
- **OCIO pipeline integration.** Drop the `.cube` into an OCIO config as a `FileTransform` node for VFX studios.
|
||||
|
||||
### Caveats
|
||||
|
||||
- **Level must match upstream.** The `level` pin validates against image dims (`H == W == L³`). Always wire N-12's `level` output into N-13's `level` input to stay in sync.
|
||||
- **RGB-only, no alpha, no shaper.** Input is assumed linear `[0, 1]` float; ACES / LogC / HLG users should bake the transform upstream of N-12. Alpha is not exported (3D LUT format limitation).
|
||||
- **Parent directory must exist.** The wrapper does not silently `mkdir` — an `OSError` is raised if the parent directory is missing, to avoid writing LUTs into unexpected places.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 — see [LICENSE](./LICENSE).
|
||||
|
||||
+9
-1
@@ -18,7 +18,7 @@ NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {}
|
||||
# load the pack themselves via ``importlib.util.spec_from_file_location``.
|
||||
# Category groups exposed to ComfyUI Add Node menu (under root "ComfyUI-JH-PixelPro/"):
|
||||
# filters — pixel-domain filters (FS, ES)
|
||||
# color — color-grade layer (Lum, ColorMatcher, ToneCurve)
|
||||
# color — color-grade layer (Lum, ColorMatcher, ToneCurve, HALDIdentity, LUTExport)
|
||||
# mask — mask creation / refinement (MR, HFDM)
|
||||
# geometry — geometric transforms (Aligner, LensDistortion)
|
||||
# face — face-pipeline domain (FaceDetect, UnwrapFace)
|
||||
@@ -29,9 +29,11 @@ if __package__:
|
||||
JHPixelProFaceDetect,
|
||||
JHPixelProFacialAligner,
|
||||
JHPixelProFrequencySeparation,
|
||||
JHPixelProHALDIdentity,
|
||||
JHPixelProHighFreqDetailMasker,
|
||||
JHPixelProLensDistortion,
|
||||
JHPixelProLuminosityMasking,
|
||||
JHPixelProLUTExport,
|
||||
JHPixelProSubPixelMaskRefiner,
|
||||
JHPixelProToneCurve,
|
||||
JHPixelProUnwrapFace,
|
||||
@@ -70,6 +72,12 @@ if __package__:
|
||||
NODE_CLASS_MAPPINGS["JHPixelProToneCurve"] = JHPixelProToneCurve
|
||||
NODE_DISPLAY_NAME_MAPPINGS["JHPixelProToneCurve"] = "Tone Curve (RGB)"
|
||||
|
||||
NODE_CLASS_MAPPINGS["JHPixelProHALDIdentity"] = JHPixelProHALDIdentity
|
||||
NODE_DISPLAY_NAME_MAPPINGS["JHPixelProHALDIdentity"] = "HALD Identity"
|
||||
|
||||
NODE_CLASS_MAPPINGS["JHPixelProLUTExport"] = JHPixelProLUTExport
|
||||
NODE_DISPLAY_NAME_MAPPINGS["JHPixelProLUTExport"] = "LUT Export (.cube)"
|
||||
|
||||
# Web extensions (JS/CSS) — not used in Phase 1.
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from .facial_aligner import facial_align
|
||||
from .frequency import frequency_separation
|
||||
from .lens_distortion import lens_distortion
|
||||
from .luminosity import luminosity_masks
|
||||
from .lut import export_cube, identity_hald
|
||||
from .mask_refiner import subpixel_mask_refine
|
||||
from .smoother import edge_aware_smooth
|
||||
from .tone_curve import tone_curve
|
||||
@@ -24,4 +25,6 @@ __all__ = [
|
||||
"facial_align",
|
||||
"lens_distortion",
|
||||
"unwrap_face",
|
||||
"identity_hald",
|
||||
"export_cube",
|
||||
]
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
"""Identity HALD generator + Adobe Cube 1.0 (.cube) 3D-LUT exporter.
|
||||
|
||||
Pure ``torch.arange`` reshape + stdlib file I/O. No Kornia, no bench-critical
|
||||
GPU-parallel primitive. ImageMagick HALD convention for the identity image,
|
||||
blue-outer-loop ordering for the ``.cube`` body (DaVinci Resolve / Premiere /
|
||||
OCIO compatible).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def identity_hald(
|
||||
level: int,
|
||||
*,
|
||||
device: str | torch.device = "cpu",
|
||||
dtype: torch.dtype = torch.float32,
|
||||
) -> torch.Tensor:
|
||||
"""Generate an identity HALD image for 3D LUT round-trip (ImageMagick convention).
|
||||
|
||||
Args:
|
||||
level: HALD level L in [2, 16]. Cube grid N = L**2. Image side = L**3.
|
||||
device: torch device for output. CPU default — HALD generation is cheap.
|
||||
dtype: Output dtype. Pack downstream assumes float32 BHWC in [0, 1].
|
||||
|
||||
Returns:
|
||||
Tensor shape ``(1, L**3, L**3, 3)`` in ``[0, 1]``. Pixel ``(y, x)`` encodes::
|
||||
|
||||
r = (x mod N) / (N - 1)
|
||||
g = (y mod N) / (N - 1)
|
||||
b = ((x // N) + (y // N) * L) / (N - 1)
|
||||
|
||||
with ``N = L**2``.
|
||||
|
||||
Raises:
|
||||
ValueError: ``level`` not an int in ``[2, 16]``.
|
||||
"""
|
||||
if not isinstance(level, int) or level < 2 or level > 16:
|
||||
raise ValueError(f"HALD level must be int in [2, 16], got {level!r}")
|
||||
|
||||
n = level * level
|
||||
side = level ** 3 # n * level == level ** 3
|
||||
x = torch.arange(side, device=device).view(1, side).expand(side, side)
|
||||
y = torch.arange(side, device=device).view(side, 1).expand(side, side)
|
||||
r = (x % n).to(dtype) / (n - 1)
|
||||
g = (y % n).to(dtype) / (n - 1)
|
||||
b = ((x // n) + (y // n) * level).to(dtype) / (n - 1)
|
||||
hald = torch.stack([r, g, b], dim=-1) # (side, side, 3)
|
||||
return hald.unsqueeze(0) # (1, side, side, 3)
|
||||
|
||||
|
||||
def export_cube(
|
||||
hald: torch.Tensor,
|
||||
level: int,
|
||||
path: str,
|
||||
*,
|
||||
title: str = "JHPixelPro LUT",
|
||||
) -> str:
|
||||
"""Export a graded HALD image as an Adobe Cube 1.0 (``.cube``) 3D-LUT file.
|
||||
|
||||
Args:
|
||||
hald: Graded HALD tensor ``(B, H, W, 3)`` or ``(H, W, 3)`` float in ``[0, 1]``.
|
||||
First batch taken. ``H == W == level**3`` required (must match the
|
||||
identity HALD output for the same level).
|
||||
level: HALD level L used when the identity HALD was generated. Cube
|
||||
``N = L**2``. Must match ``hald`` spatial dims.
|
||||
path: Output file path (absolute or relative). Parent directory must exist.
|
||||
title: TITLE header string (Adobe Cube metadata).
|
||||
|
||||
Returns:
|
||||
Absolute resolved path of the written file.
|
||||
|
||||
Raises:
|
||||
ValueError: ``level`` out of ``[2, 16]``, ``hald`` shape mismatch, or
|
||||
non-finite values in ``hald``.
|
||||
OSError: Parent directory missing or write permission denied.
|
||||
"""
|
||||
if not isinstance(level, int) or level < 2 or level > 16:
|
||||
raise ValueError(f"HALD level must be int in [2, 16], got {level!r}")
|
||||
|
||||
n = level * level
|
||||
side = level ** 3
|
||||
|
||||
if hald.ndim == 4:
|
||||
hald = hald[0]
|
||||
if hald.shape != (side, side, 3):
|
||||
raise ValueError(
|
||||
f"hald shape must be ({side},{side},3) for level={level}, "
|
||||
f"got {tuple(hald.shape)}"
|
||||
)
|
||||
if not torch.isfinite(hald).all():
|
||||
raise ValueError(
|
||||
"hald contains non-finite values (NaN/Inf) — clamp or filter upstream"
|
||||
)
|
||||
|
||||
samples = hald.clamp(0.0, 1.0).detach().to("cpu", torch.float32).numpy()
|
||||
|
||||
resolved = os.path.abspath(path)
|
||||
parent = os.path.dirname(resolved)
|
||||
if parent and not os.path.isdir(parent):
|
||||
raise OSError(f"Parent directory missing: {parent}")
|
||||
|
||||
lines = [
|
||||
f'TITLE "{title}"',
|
||||
f"LUT_3D_SIZE {n}",
|
||||
"DOMAIN_MIN 0.0 0.0 0.0",
|
||||
"DOMAIN_MAX 1.0 1.0 1.0",
|
||||
"",
|
||||
]
|
||||
# Blue-outer / green-middle / red-innermost (Adobe Cube 1.0 standard).
|
||||
for b_idx in range(n):
|
||||
by = b_idx // level
|
||||
bx = b_idx % level
|
||||
for g_idx in range(n):
|
||||
y = g_idx + by * n
|
||||
for r_idx in range(n):
|
||||
x = r_idx + bx * n
|
||||
r, g, b = samples[y, x]
|
||||
lines.append(f"{r:.6f} {g:.6f} {b:.6f}")
|
||||
|
||||
with open(resolved, "w", encoding="utf-8", newline="\n") as fh:
|
||||
fh.write("\n".join(lines) + "\n")
|
||||
return resolved
|
||||
@@ -5,8 +5,10 @@ from .detail_masker_node import JHPixelProHighFreqDetailMasker
|
||||
from .face_detect_node import JHPixelProFaceDetect
|
||||
from .facial_aligner_node import JHPixelProFacialAligner
|
||||
from .frequency_node import JHPixelProFrequencySeparation
|
||||
from .hald_identity_node import JHPixelProHALDIdentity
|
||||
from .lens_distortion_node import JHPixelProLensDistortion
|
||||
from .luminosity_node import JHPixelProLuminosityMasking
|
||||
from .lut_export_node import JHPixelProLUTExport
|
||||
from .mask_refiner_node import JHPixelProSubPixelMaskRefiner
|
||||
from .smoother_node import JHPixelProEdgeAwareSmoother
|
||||
from .tone_curve_node import JHPixelProToneCurve
|
||||
@@ -18,7 +20,9 @@ __all__ = [
|
||||
"JHPixelProFaceDetect",
|
||||
"JHPixelProFacialAligner",
|
||||
"JHPixelProFrequencySeparation",
|
||||
"JHPixelProHALDIdentity",
|
||||
"JHPixelProHighFreqDetailMasker",
|
||||
"JHPixelProLUTExport",
|
||||
"JHPixelProLensDistortion",
|
||||
"JHPixelProLuminosityMasking",
|
||||
"JHPixelProSubPixelMaskRefiner",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""ComfyUI wrapper for ``core.lut.identity_hald``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from ..core.lut import identity_hald
|
||||
|
||||
_LEVEL_CHOICES = ["4", "6", "8", "10", "12"]
|
||||
|
||||
|
||||
class JHPixelProHALDIdentity:
|
||||
"""Generate an identity HALD image to feed into a color-grade chain + LUT export."""
|
||||
|
||||
CATEGORY = "ComfyUI-JH-PixelPro/color"
|
||||
RETURN_TYPES = ("IMAGE", "INT")
|
||||
RETURN_NAMES = ("image", "level")
|
||||
FUNCTION = "generate"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict: # noqa: N802 — ComfyUI node contract mandates UPPER_CASE.
|
||||
return {
|
||||
"required": {
|
||||
"level": (
|
||||
_LEVEL_CHOICES,
|
||||
{
|
||||
"default": "8",
|
||||
"tooltip": (
|
||||
"HALD level L. Cube N=L² (L=8→N=64 industry "
|
||||
"standard). Image side = L³ pixels: L=4→64×64, "
|
||||
"L=8→512×512, L=12→1728×1728. Higher L = finer "
|
||||
"LUT but larger intermediate image."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def generate(self, level: str) -> tuple[torch.Tensor, int]:
|
||||
level_int = int(level)
|
||||
image = identity_hald(level_int)
|
||||
return (image, level_int)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""ComfyUI wrapper for ``core.lut.export_cube`` — writes Adobe Cube 1.0 (.cube)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import torch
|
||||
|
||||
from ..core.lut import export_cube
|
||||
|
||||
|
||||
class JHPixelProLUTExport:
|
||||
"""Export a graded HALD image as a portable Adobe Cube 1.0 (``.cube``) 3D LUT."""
|
||||
|
||||
CATEGORY = "ComfyUI-JH-PixelPro/color"
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("path",)
|
||||
FUNCTION = "export"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict: # noqa: N802 — ComfyUI node contract mandates UPPER_CASE.
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"level": (
|
||||
"INT",
|
||||
{
|
||||
"default": 8,
|
||||
"min": 2,
|
||||
"max": 16,
|
||||
"step": 1,
|
||||
"tooltip": (
|
||||
"HALD level L — MUST match the level used by the "
|
||||
"upstream HALD Identity node. Image H==W==L³ is "
|
||||
"validated; mismatch raises ValueError."
|
||||
),
|
||||
},
|
||||
),
|
||||
"filename": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "pack_lut.cube",
|
||||
"tooltip": (
|
||||
"Filename (relative to ComfyUI output/) or "
|
||||
"absolute path. '.cube' extension auto-appended "
|
||||
"if missing. Existing files are overwritten."
|
||||
),
|
||||
},
|
||||
),
|
||||
"title": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "JHPixelPro LUT",
|
||||
"tooltip": (
|
||||
"Adobe Cube TITLE header metadata — shown by "
|
||||
"DaVinci Resolve, Premiere and other LUT tools "
|
||||
"when browsing cube files."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def export(
|
||||
self,
|
||||
image: torch.Tensor,
|
||||
level: int,
|
||||
filename: str,
|
||||
title: str,
|
||||
) -> tuple[str]:
|
||||
resolved = self._resolve_output_path(filename)
|
||||
written = export_cube(image, int(level), resolved, title=title)
|
||||
return (written,)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_output_path(filename: str) -> str:
|
||||
name = filename.strip()
|
||||
if not name.lower().endswith(".cube"):
|
||||
name = name + ".cube"
|
||||
if os.path.isabs(name):
|
||||
return name
|
||||
try:
|
||||
import folder_paths # ComfyUI runtime module (not installed in unit tests).
|
||||
|
||||
base = folder_paths.get_output_directory()
|
||||
except ImportError:
|
||||
base = os.path.abspath("output")
|
||||
return os.path.join(base, name)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Functional tests for ``core.lut`` (identity HALD + Adobe Cube 1.0 exporter)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from core.lut import export_cube, identity_hald
|
||||
|
||||
EPS_CORNER = 1e-6
|
||||
EPS_ROUNDTRIP = 1e-5
|
||||
|
||||
|
||||
def _parse_cube_body(path: str) -> tuple[list[str], list[tuple[float, float, float]]]:
|
||||
"""Split a .cube file into (header_lines, body_triplets).
|
||||
|
||||
Header = every line until the first line that parses as ``r g b`` floats.
|
||||
Body = every non-blank line after that parses as three floats.
|
||||
"""
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
raw = fh.read().splitlines()
|
||||
|
||||
header: list[str] = []
|
||||
body: list[tuple[float, float, float]] = []
|
||||
in_body = False
|
||||
for line in raw:
|
||||
stripped = line.strip()
|
||||
parts = stripped.split()
|
||||
is_triplet = False
|
||||
if len(parts) == 3:
|
||||
try:
|
||||
triplet = (float(parts[0]), float(parts[1]), float(parts[2]))
|
||||
is_triplet = True
|
||||
except ValueError:
|
||||
is_triplet = False
|
||||
if is_triplet:
|
||||
in_body = True
|
||||
body.append(triplet)
|
||||
else:
|
||||
if in_body:
|
||||
# Blank lines inside body are tolerated (some writers add them).
|
||||
if stripped:
|
||||
header.append(line)
|
||||
else:
|
||||
header.append(line)
|
||||
return header, body
|
||||
|
||||
|
||||
def test_identity_hald_shape_level_8() -> None:
|
||||
hald = identity_hald(8)
|
||||
assert hald.shape == (1, 512, 512, 3)
|
||||
assert hald.dtype == torch.float32
|
||||
assert float(hald.min()) >= 0.0
|
||||
assert float(hald.max()) <= 1.0
|
||||
|
||||
|
||||
def test_identity_hald_black_corner_level_8() -> None:
|
||||
hald = identity_hald(8)
|
||||
corner = hald[0, 0, 0]
|
||||
assert torch.allclose(corner, torch.tensor([0.0, 0.0, 0.0]), atol=EPS_CORNER)
|
||||
|
||||
|
||||
def test_identity_hald_white_corner_level_8() -> None:
|
||||
hald = identity_hald(8)
|
||||
corner = hald[0, 511, 511]
|
||||
assert torch.allclose(corner, torch.tensor([1.0, 1.0, 1.0]), atol=EPS_CORNER)
|
||||
|
||||
|
||||
def test_roundtrip_identity_cube_body(tmp_path) -> None:
|
||||
level = 8
|
||||
n = level * level # 64
|
||||
hald = identity_hald(level)
|
||||
path = str(tmp_path / "identity.cube")
|
||||
export_cube(hald, level, path, title="roundtrip")
|
||||
|
||||
_, body = _parse_cube_body(path)
|
||||
assert len(body) == n ** 3 == 262144
|
||||
|
||||
# Spot-check 4 entries distributed across the cube — full 262144 compare is slow.
|
||||
for idx in (0, 37, 64 * 64 - 1, 262143):
|
||||
b_idx, rem = divmod(idx, n * n)
|
||||
g_idx, r_idx = divmod(rem, n)
|
||||
expected = (r_idx / (n - 1), g_idx / (n - 1), b_idx / (n - 1))
|
||||
got = body[idx]
|
||||
for e, g in zip(expected, got, strict=True):
|
||||
assert math.isclose(g, e, abs_tol=EPS_ROUNDTRIP), (
|
||||
f"idx={idx} expected={expected} got={got}"
|
||||
)
|
||||
|
||||
|
||||
def test_identity_hald_shape_level_12() -> None:
|
||||
hald = identity_hald(12)
|
||||
assert hald.shape == (1, 1728, 1728, 3)
|
||||
corner = hald[0, 1727, 1727]
|
||||
assert torch.allclose(corner, torch.tensor([1.0, 1.0, 1.0]), atol=EPS_CORNER)
|
||||
|
||||
|
||||
def test_export_cube_header_and_body_count(tmp_path) -> None:
|
||||
level = 8
|
||||
hald = identity_hald(level) * 0.9 + 0.05 # arbitrary "graded" hald
|
||||
path = str(tmp_path / "graded.cube")
|
||||
export_cube(hald, level, path, title="My Title")
|
||||
|
||||
header, body = _parse_cube_body(path)
|
||||
assert any('TITLE "My Title"' in line for line in header)
|
||||
assert any("LUT_3D_SIZE 64" in line for line in header)
|
||||
assert any("DOMAIN_MIN 0.0 0.0 0.0" in line for line in header)
|
||||
assert any("DOMAIN_MAX 1.0 1.0 1.0" in line for line in header)
|
||||
assert len(body) == 64 ** 3 == 262144
|
||||
|
||||
|
||||
def test_export_cube_shape_mismatch_raises(tmp_path) -> None:
|
||||
bad = torch.zeros((1, 256, 256, 3), dtype=torch.float32)
|
||||
with pytest.raises(ValueError, match=r"hald shape must be \(512,512,3\)"):
|
||||
export_cube(bad, 8, str(tmp_path / "nope.cube"))
|
||||
|
||||
|
||||
def test_export_cube_non_finite_raises(tmp_path) -> None:
|
||||
hald = identity_hald(8).clone()
|
||||
hald[0, 0, 0, 0] = float("nan")
|
||||
with pytest.raises(ValueError, match="non-finite values"):
|
||||
export_cube(hald, 8, str(tmp_path / "nan.cube"))
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"id": "s14-lut-export-20260420",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "JHPixelProHALDIdentity",
|
||||
"pos": [80, 200],
|
||||
"size": [260, 120],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "level",
|
||||
"type": "INT",
|
||||
"slot_index": 1,
|
||||
"links": [3]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "ComfyUI-JH-PixelPro",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "JHPixelProHALDIdentity"
|
||||
},
|
||||
"widgets_values": ["8"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "JHPixelProToneCurve",
|
||||
"pos": [380, 200],
|
||||
"size": [345, 260],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image_toned",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "ComfyUI-JH-PixelPro",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "JHPixelProToneCurve"
|
||||
},
|
||||
"widgets_values": [
|
||||
"s_curve_mild",
|
||||
"rgb_master",
|
||||
"[[0.0, 0.0], [0.15, 0.10], [0.30, 0.25], [0.45, 0.43], [0.55, 0.57], [0.70, 0.75], [0.85, 0.90], [1.0, 1.0]]",
|
||||
1.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "JHPixelProLUTExport",
|
||||
"pos": [760, 200],
|
||||
"size": [320, 180],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "level",
|
||||
"type": "INT",
|
||||
"link": 3,
|
||||
"widget": {
|
||||
"name": "level"
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"type": "STRING",
|
||||
"slot_index": 0,
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "ComfyUI-JH-PixelPro",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "JHPixelProLUTExport"
|
||||
},
|
||||
"widgets_values": ["s14_demo.cube", "S-14 Demo"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "Note",
|
||||
"pos": [80, 360],
|
||||
"size": [640, 220],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"ℹ️ S-14 LUT Export — round-trip color-grade into portable .cube\n\nChain: N-12 HALD Identity (level=8, 512×512) → N-09 Tone Curve (s_curve_mild) → N-13 LUT Export (.cube).\n\nN-12 generates an identity HALD image encoding a 64×64×64 color cube. Everything you wire between N-12 and N-13 gets *baked into* the exported LUT — any chain that is color-grade-only (Tone Curve, Color Matcher, Luminosity Masking blends, etc.) becomes a portable .cube you can load into DaVinci Resolve / Premiere / OBS / OCIO / hardware LUT boxes.\n\nN-13's `level` pin MUST match the level from N-12 (wired here via the INT link). The `filename` defaults to 'pack_lut.cube' under ComfyUI's output/ directory; absolute paths are honored verbatim. The '.cube' extension is auto-appended if you leave it off.\n\nDO NOT wire anything pixel-structure-aware (sharpening, denoise, detail masks, face pipeline, skin smoother, lens distortion) between N-12 and N-13 — those operators are spatial, not color-only, and will NOT linearize into a 3D LUT."
|
||||
],
|
||||
"color": "#243",
|
||||
"bgcolor": "#365"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"],
|
||||
[2, 2, 0, 3, 0, "IMAGE"],
|
||||
[3, 1, 1, 3, 1, "INT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1.0,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
Reference in New Issue
Block a user