test
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Printer ICC A2B PCS CLUT Generator</title>
<style>
body {
margin: 0;
background: #111;
color: #eee;
font-family: Arial, sans-serif;
overflow: hidden;
}
#topBar {
position: fixed;
left: 0;
top: 0;
right: 0;
height: 54px;
background: rgba(0, 0, 0, 0.78);
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
box-sizing: border-box;
z-index: 20;
border-bottom: 1px solid rgba(255,255,255,0.12);
}
#topBar button {
padding: 8px 12px;
border: 0;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
background: #f2f2f2;
color: #111;
}
#topBar button:hover {
background: #ddd;
}
#topBar .title {
font-weight: bold;
color: #9dffb7;
margin-right: auto;
white-space: nowrap;
}
#main {
position: fixed;
top: 54px;
bottom: 0;
left: 0;
right: 0;
display: grid;
grid-template-columns: 410px 1fr;
}
#leftPanel {
background: #181818;
border-right: 1px solid rgba(255,255,255,0.12);
padding: 12px;
overflow: auto;
box-sizing: border-box;
}
#leftPanel h3 {
margin: 8px 0 8px;
color: #8ecbff;
font-size: 15px;
}
textarea {
width: 100%;
height: 320px;
background: #0d0d0d;
color: #ddd;
border: 1px solid rgba(255,255,255,0.18);
border-radius: 8px;
padding: 10px;
box-sizing: border-box;
font-family: Consolas, monospace;
font-size: 12px;
resize: vertical;
}
.control {
display: grid;
grid-template-columns: 132px 1fr 52px;
gap: 8px;
align-items: center;
margin: 8px 0;
font-size: 12px;
color: #ddd;
}
.control input {
width: 100%;
}
.note {
font-size: 12px;
line-height: 1.45;
color: #bbb;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 10px;
margin-top: 10px;
}
#status {
font-size: 12px;
line-height: 1.45;
color: #d7f5ff;
background: rgba(60,160,255,0.08);
border: 1px solid rgba(60,160,255,0.18);
border-radius: 8px;
padding: 10px;
margin-top: 10px;
white-space: pre-wrap;
}
#canvasWrap {
position: relative;
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: 100%;
background: #101010;
}
.label {
position: absolute;
top: 12px;
padding: 7px 10px;
border-radius: 8px;
background: rgba(0,0,0,0.55);
font-weight: bold;
font-size: 14px;
z-index: 5;
}
#labelA {
left: 18px;
color: #8ecbff;
}
#labelB {
right: 18px;
color: #9dffb7;
}
.legend {
position: absolute;
left: 18px;
bottom: 18px;
z-index: 5;
padding: 9px 11px;
border-radius: 8px;
background: rgba(0,0,0,0.55);
color: #ddd;
font-size: 12px;
line-height: 1.5;
}
</style>
</head>
<body>
<div id="topBar">
<div class="title">프린터 ICC A2B → PCS Lab CLUT 16×16×16 생성</div>
<button id="makeDemo">데모 측색값 생성</button>
<button id="buildClut">A2B CLUT 생성</button>
<button id="toggleRotation">회전 정지</button>
<button id="downloadJson">JSON 다운로드</button>
<button id="downloadCsv">CSV 다운로드</button>
</div>
<div id="main">
<div id="leftPanel">
<h3>입력: 프린터 측색 RGB / Lab</h3>
<textarea id="input"></textarea>
<h3>알고리즘 조절</h3>
<div class="control">
<label>CLUT size</label>
<input id="clutSize" type="range" min="8" max="33" step="1" value="16" />
<span id="clutSizeValue">16</span>
</div>
<div class="control">
<label>localSmooth</label>
<input id="localSmooth" type="range" min="8" max="42" step="1" value="22" />
<span id="localSmoothValue">22</span>
</div>
<div class="control">
<label>gamutGuard</label>
<input id="gamutGuard" type="range" min="0" max="1" step="0.05" value="0.85" />
<span id="gamutGuardValue">0.85</span>
</div>
<div class="control">
<label>antiShrink</label>
<input id="antiShrink" type="range" min="0" max="1" step="0.05" value="0.80" />
<span id="antiShrinkValue">0.80</span>
</div>
<div class="control">
<label>edgeProtect</label>
<input id="edgeProtect" type="range" min="1" max="8" step="1" value="4" />
<span id="edgeProtectValue">4</span>
</div>
<div class="control">
<label>vertexBoost</label>
<input id="vertexBoost" type="range" min="0" max="1" step="0.05" value="0.75" />
<span id="vertexBoostValue">0.75</span>
</div>
<div class="control">
<label>grayProtect</label>
<input id="grayProtect" type="range" min="0" max="1" step="0.05" value="0.85" />
<span id="grayProtectValue">0.85</span>
</div>
<div class="control">
<label>edgeFeather</label>
<input id="edgeFeather" type="range" min="0" max="1" step="0.05" value="0.30" />
<span id="edgeFeatherValue">0.30</span>
</div>
<div class="note">
입력 형식은 JSON 또는 CSV 모두 가능합니다.<br><br>
JSON 예:<br>
<code>[{"rgb":[100,0,0],"lab":[48,68,45]}]</code><br><br>
CSV 예:<br>
<code>R,G,B,L,a,b</code><br>
<code>100,0,0,48,68,45</code><br><br>
RGB는 0~100 범위입니다. 결과 CLUT도 RGB 0~100, PCS Lab 기준입니다.
</div>
<div id="status">대기 중</div>
</div>
<div id="canvasWrap">
<div id="labelA" class="label">측색 프린터 Lab 미리보기</div>
<div id="labelB" class="label">생성된 A2B CLUT Lab 미리보기</div>
<div class="legend">
흰 선: rgbcmy 허리라인 polygon<br>
회색 선: black / white point 연결<br>
붉은 점: 외곽 축소 의심 측색값<br>
오른쪽: 16³ CLUT PCS Lab
</div>
<canvas id="canvas"></canvas>
</div>
</div>
<script>
// ============================================================
// 0. 전역 상태
// ============================================================
const inputEl = document.getElementById("input");
const statusEl = document.getElementById("status");
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let measurementSamples = [];
let clutResult = null;
let model = null;
let rotationEnabled = true;
let rotationTime = 0;
let lastTime = performance.now();
const controls = {
clutSize: document.getElementById("clutSize"),
localSmooth: document.getElementById("localSmooth"),
gamutGuard: document.getElementById("gamutGuard"),
antiShrink: document.getElementById("antiShrink"),
edgeProtect: document.getElementById("edgeProtect"),
vertexBoost: document.getElementById("vertexBoost"),
grayProtect: document.getElementById("grayProtect"),
edgeFeather: document.getElementById("edgeFeather")
};
const controlValues = {
clutSize: document.getElementById("clutSizeValue"),
localSmooth: document.getElementById("localSmoothValue"),
gamutGuard: document.getElementById("gamutGuardValue"),
antiShrink: document.getElementById("antiShrinkValue"),
edgeProtect: document.getElementById("edgeProtectValue"),
vertexBoost: document.getElementById("vertexBoostValue"),
grayProtect: document.getElementById("grayProtectValue"),
edgeFeather: document.getElementById("edgeFeatherValue")
};
function getParams() {
const p = {
clutSize: Number(controls.clutSize.value),
localSmooth: Number(controls.localSmooth.value),
gamutGuard: Number(controls.gamutGuard.value),
antiShrink: Number(controls.antiShrink.value),
edgeProtect: Number(controls.edgeProtect.value),
vertexBoost: Number(controls.vertexBoost.value),
grayProtect: Number(controls.grayProtect.value),
edgeFeather: Number(controls.edgeFeather.value)
};
for (const key of Object.keys(p)) {
controlValues[key].textContent = String(p[key]);
}
return p;
}
for (const key of Object.keys(controls)) {
controls[key].addEventListener("input", () => {
getParams();
});
}
getParams();
function resizeCanvas() {
const wrap = document.getElementById("canvasWrap");
canvas.width = wrap.clientWidth * window.devicePixelRatio;
canvas.height = wrap.clientHeight * window.devicePixelRatio;
ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
}
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
// ============================================================
// 1. 기본 유틸
// ============================================================
function clamp(x, a, b) {
return Math.max(a, Math.min(b, x));
}
function lerp(a, b, t) {
return a * (1 - t) + b * t;
}
function smoothstep(edge0, edge1, x) {
if (Math.abs(edge1 - edge0) < 1e-12) {
return x >= edge1 ? 1 : 0;
}
const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function percentile(values, q) {
const arr = values
.filter(Number.isFinite)
.slice()
.sort((a, b) => a - b);
if (arr.length === 0) return NaN;
if (arr.length === 1) return arr[0];
const pos = (arr.length - 1) * q;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
const t = pos - lo;
return arr[lo] * (1 - t) + arr[hi] * t;
}
function median(values) {
return percentile(values, 0.5);
}
function dist3(a, b) {
const dx = a[0] - b[0];
const dy = a[1] - b[1];
const dz = a[2] - b[2];
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
function chroma(lab) {
return Math.sqrt(lab[1] * lab[1] + lab[2] * lab[2]);
}
function hueAngle(lab) {
let h = Math.atan2(lab[2], lab[1]);
if (h < 0) h += Math.PI * 2;
return h;
}
function angleDiff(a, b) {
let d = Math.abs(a - b) % (Math.PI * 2);
if (d > Math.PI) d = Math.PI * 2 - d;
return d;
}
function rgbDistance(a, b) {
const dr = a[0] - b[0];
const dg = a[1] - b[1];
const db = a[2] - b[2];
return Math.sqrt(dr * dr + dg * dg + db * db);
}
function rgbColor(rgb, alpha = 1) {
const r = Math.round(clamp(rgb[0], 0, 100) * 2.55);
const g = Math.round(clamp(rgb[1], 0, 100) * 2.55);
const b = Math.round(clamp(rgb[2], 0, 100) * 2.55);
return `rgba(${r},${g},${b},${alpha})`;
}
function downloadText(filename, text, mime) {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// ============================================================
// 2. Demo printer RGB/Lab 생성
// ============================================================
function srgbToLinear(c) {
c = c / 255;
return c <= 0.04045
? c / 12.92
: Math.pow((c + 0.055) / 1.055, 2.4);
}
function xyzToLab(x, y, z) {
const Xn = 95.047;
const Yn = 100.000;
const Zn = 108.883;
let xr = x / Xn;
let yr = y / Yn;
let zr = z / Zn;
function f(t) {
return t > 0.008856
? Math.pow(t, 1 / 3)
: 7.787 * t + 16 / 116;
}
const fx = f(xr);
const fy = f(yr);
const fz = f(zr);
return [
116 * fy - 16,
500 * (fx - fy),
200 * (fy - fz)
];
}
function rgb100ToSyntheticPrinterLab(rgb) {
const R = clamp(rgb[0], 0, 100) * 2.55;
const G = clamp(rgb[1], 0, 100) * 2.55;
const B = clamp(rgb[2], 0, 100) * 2.55;
const r = srgbToLinear(R);
const g = srgbToLinear(G);
const b = srgbToLinear(B);
let X = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) * 100;
let Y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b) * 100;
let Z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) * 100;
let lab = xyzToLab(X, Y, Z);
const maxc = Math.max(rgb[0], rgb[1], rgb[2]);
const minc = Math.min(rgb[0], rgb[1], rgb[2]);
const sat = (maxc - minc) / 100;
// 프린터답게 L과 chroma를 약간 줄이고, yellow/red는 비교적 강하게 둠.
let L = 5.5 + lab[0] * 0.88;
let a = lab[1] * (0.70 + 0.08 * sat);
let bb = lab[2] * (0.72 + 0.10 * sat);
// 종이 white / black 한계
L = clamp(L, 4.2, 94.5);
// 프린터 색역 비대칭성
const h = Math.atan2(bb, a);
const yellowBoost = Math.max(0, Math.cos(h - 1.62));
const blueLoss = Math.max(0, Math.cos(h + 1.62));
bb += yellowBoost * sat * 4.5;
a -= blueLoss * sat * 3.2;
return [L, a, bb];
}
function seededRandom(seed) {
const x = Math.sin(seed * 12.9898) * 43758.5453123;
return x - Math.floor(x);
}
function makeDemoSamples() {
const samples = [];
let seed = 1;
const must = [
[0, 0, 0],
[100, 100, 100],
[100, 0, 0],
[100, 100, 0],
[0, 100, 0],
[0, 100, 100],
[0, 0, 100],
[100, 0, 100],
[50, 50, 50],
[25, 25, 25],
[75, 75, 75]
];
for (const rgb of must) {
const lab = rgb100ToSyntheticPrinterLab(rgb);
samples.push({
rgb,
lab: lab.map(v => +v.toFixed(3))
});
}
// 실제 측색처럼 불규칙 샘플 생성
for (let i = 0; i < 180; i++) {
let r, g, b;
if (i < 80) {
// 외곽 중심 샘플
r = Math.round(seededRandom(seed++) * 100);
g = Math.round(seededRandom(seed++) * 100);
b = Math.round(seededRandom(seed++) * 100);
const face = Math.floor(seededRandom(seed++) * 6);
if (face === 0) r = 0;
if (face === 1) r = 100;
if (face === 2) g = 0;
if (face === 3) g = 100;
if (face === 4) b = 0;
if (face === 5) b = 100;
} else {
r = Math.round(seededRandom(seed++) * 100);
g = Math.round(seededRandom(seed++) * 100);
b = Math.round(seededRandom(seed++) * 100);
}
const rgb = [r, g, b];
let lab = rgb100ToSyntheticPrinterLab(rgb);
const sat = (Math.max(r, g, b) - Math.min(r, g, b)) / 100;
const edge = Math.max(
1 - Math.min(r, 100 - r) / 50,
1 - Math.min(g, 100 - g) / 50,
1 - Math.min(b, 100 - b) / 50
);
// 측정 노이즈
lab[0] += (seededRandom(seed++) - 0.5) * 1.2;
lab[1] += (seededRandom(seed++) - 0.5) * 1.6 * sat;
lab[2] += (seededRandom(seed++) - 0.5) * 1.6 * sat;
// 일부 외곽 out-of-gamut 압축 현상 데모
let outOfGamut = false;
const isProblem =
edge > 0.82 &&
sat > 0.60 &&
(
(r > 84 && g < 30 && b < 35) ||
(r < 25 && g > 75 && b > 75) ||
(r < 25 && g < 30 && b > 75)
);
if (isProblem) {
outOfGamut = true;
const C = chroma(lab);
const h = hueAngle(lab);
const newC = C * 0.72;
lab[1] = Math.cos(h) * newC;
lab[2] = Math.sin(h) * newC;
lab[0] -= 1.0;
}
samples.push({
rgb,
lab: lab.map(v => +v.toFixed(3)),
outOfGamut
});
}
return samples;
}
function loadDemo() {
const demo = makeDemoSamples();
inputEl.value = JSON.stringify(demo, null, 2);
statusEl.textContent =
"데모 측색값 생성 완료\n" +
"RGB/Lab 샘플 수: " + demo.length + "\n" +
"붉은 outOfGamut 샘플 일부 포함";
}
// ============================================================
// 3. 입력 파싱
// ============================================================
function normalizeSample(sample) {
let rgb;
let lab;
let outOfGamut = false;
if (Array.isArray(sample)) {
if (sample.length < 6) return null;
rgb = [
Number(sample[0]),
Number(sample[1]),
Number(sample[2])
];
lab = [
Number(sample[3]),
Number(sample[4]),
Number(sample[5])
];
const meta = sample[6];
if (meta === false) outOfGamut = true;
if (meta && typeof meta === "object") {
if (meta.outOfGamut === true) outOfGamut = true;
if (meta.inGamut === false) outOfGamut = true;
}
} else if (sample && typeof sample === "object") {
rgb = sample.rgb || sample.RGB;
lab = sample.lab || sample.Lab || sample.LAB;
if (!rgb || !lab) return null;
rgb = [Number(rgb[0]), Number(rgb[1]), Number(rgb[2])];
lab = [Number(lab[0]), Number(lab[1]), Number(lab[2])];
if (sample.outOfGamut === true) outOfGamut = true;
if (sample.inGamut === false) outOfGamut = true;
if (sample.gamut === "out" || sample.gamut === "oog") outOfGamut = true;
} else {
return null;
}
if (
!rgb.every(Number.isFinite) ||
!lab.every(Number.isFinite)
) {
return null;
}
rgb = rgb.map(v => clamp(v, 0, 100));
return {
rgb,
lab,
outOfGamut
};
}
function parseInput(text) {
text = text.trim();
if (!text) {
throw new Error("입력값이 비어 있습니다.");
}
let raw;
try {
raw = JSON.parse(text);
if (!Array.isArray(raw)) {
throw new Error("JSON은 배열이어야 합니다.");
}
const parsed = raw
.map(normalizeSample)
.filter(Boolean);
if (parsed.length < 8) {
throw new Error("유효한 샘플이 너무 적습니다. 최소 8개 이상 필요합니다.");
}
return parsed;
} catch (e) {
// CSV fallback
}
const lines = text
.split(/\r?\n/)
.map(v => v.trim())
.filter(v => v && !v.startsWith("#"));
const parsed = [];
for (const line of lines) {
if (/^[A-Za-z]/.test(line)) continue;
const parts = line
.split(/[,\t ]+/)
.map(Number)
.filter(Number.isFinite);
if (parts.length >= 6) {
parsed.push({
rgb: [
clamp(parts[0], 0, 100),
clamp(parts[1], 0, 100),
clamp(parts[2], 0, 100)
],
lab: [parts[3], parts[4], parts[5]],
outOfGamut: parts[6] === 1
});
}
}
if (parsed.length < 8) {
throw new Error("CSV 파싱 실패 또는 유효 샘플 부족.");
}
return parsed;
}
// ============================================================
// 4. 다항 회귀 + 잔차 보간용 수학
// ============================================================
function basisRGB(rgb) {
const r = rgb[0] / 100;
const g = rgb[1] / 100;
const b = rgb[2] / 100;
return [
1,
r, g, b,
r * g, r * b, g * b,
r * r, g * g, b * b,
r * g * b
];
}
function solveLinearSystem(A, b) {
const n = A.length;
const M = A.map((row, i) => row.concat([b[i]]));
for (let col = 0; col < n; col++) {
let pivot = col;
for (let row = col + 1; row < n; row++) {
if (Math.abs(M[row][col]) > Math.abs(M[pivot][col])) {
pivot = row;
}
}
if (Math.abs(M[pivot][col]) < 1e-12) {
return null;
}
[M[col], M[pivot]] = [M[pivot], M[col]];
const div = M[col][col];
for (let k = col; k <= n; k++) {
M[col][k] /= div;
}
for (let row = 0; row < n; row++) {
if (row === col) continue;
const factor = M[row][col];
for (let k = col; k <= n; k++) {
M[row][k] -= factor * M[col][k];
}
}
}
return M.map(row => row[n]);
}
function fitPolynomial(samples, channelIndex) {
const dim = basisRGB([0, 0, 0]).length;
const A = Array.from({ length: dim }, () => Array(dim).fill(0));
const b = Array(dim).fill(0);
for (const s of samples) {
const x = basisRGB(s.rgb);
const y = s.lab[channelIndex];
const w = s.outOfGamut ? 0.22 : 1.0;
for (let i = 0; i < dim; i++) {
b[i] += w * x[i] * y;
for (let j = 0; j < dim; j++) {
A[i][j] += w * x[i] * x[j];
}
}
}
for (let i = 0; i < dim; i++) {
A[i][i] += 1e-6;
}
const coef = solveLinearSystem(A, b);
if (!coef) {
return Array(dim).fill(0);
}
return coef;
}
function evalPolynomial(coef, rgb) {
const x = basisRGB(rgb);
let y = 0;
for (let i = 0; i < coef.length; i++) {
y += coef[i] * x[i];
}
return y;
}
function fitA2BBase(samples) {
const coefL = fitPolynomial(samples, 0);
const coefa = fitPolynomial(samples, 1);
const coefb = fitPolynomial(samples, 2);
const fitted = samples.map(s => {
const base = [
evalPolynomial(coefL, s.rgb),
evalPolynomial(coefa, s.rgb),
evalPolynomial(coefb, s.rgb)
];
return {
...s,
base,
residual: [
s.lab[0] - base[0],
s.lab[1] - base[1],
s.lab[2] - base[2]
]
};
});
return {
coefL,
coefa,
coefb,
samples: fitted
};
}
function localResidualPredict(baseModel, rgb, params) {
const sigma = params.localSmooth;
let sumW = 0;
const res = [0, 0, 0];
for (const s of baseModel.samples) {
const d = rgbDistance(rgb, s.rgb);
if (d > sigma * 2.8) continue;
let w = Math.exp(-0.5 * (d / sigma) * (d / sigma));
// out-of-gamut 측색값은 잔차 보간 영향 감소
if (s.outOfGamut) w *= 0.12;
sumW += w;
res[0] += w * s.residual[0];
res[1] += w * s.residual[1];
res[2] += w * s.residual[2];
}
if (sumW < 1e-9) {
return [0, 0, 0];
}
return [
res[0] / sumW,
res[1] / sumW,
res[2] / sumW
];
}
// ============================================================
// 5. Gamut model
//
// 목적:
// - rgbcmy 허리라인 꼭짓점 검출
// - black / white point 검출
// - Lab a/b 평면에서 허리라인 polygon 생성
// - out-of-gamut 압축값 때문에 외곽이 안쪽으로 끌리는 현상 방지
// ============================================================
const vertexTargets = [
{ name: "R", rgb: [100, 0, 0] },
{ name: "Y", rgb: [100, 100, 0] },
{ name: "G", rgb: [0, 100, 0] },
{ name: "C", rgb: [0, 100, 100] },
{ name: "B", rgb: [0, 0, 100] },
{ name: "M", rgb: [100, 0, 100] }
];
function findNearestByRGB(samples, target) {
let best = samples[0];
let bestScore = Infinity;
for (const s of samples) {
const d = rgbDistance(s.rgb, target);
const c = chroma(s.lab);
const score = d - c * 0.05 + (s.outOfGamut ? 15 : 0);
if (score < bestScore) {
bestScore = score;
best = s;
}
}
return best;
}
function convexHull2D(points) {
const pts = points
.map(p => ({ x: p[0], y: p[1], ref: p[2] }))
.sort((a, b) => a.x === b.x ? a.y - b.y : a.x - b.x);
if (pts.length <= 3) {
return pts.map(p => [p.x, p.y, p.ref]);
}
function cross(o, a, b) {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}
const lower = [];
for (const p of pts) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
lower.pop();
}
lower.push(p);
}
const upper = [];
for (let i = pts.length - 1; i >= 0; i--) {
const p = pts[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
upper.pop();
}
upper.push(p);
}
upper.pop();
lower.pop();
return lower.concat(upper).map(p => [p.x, p.y, p.ref]);
}
function raySegmentIntersectionRadius(theta, p1, p2) {
const dx = Math.cos(theta);
const dy = Math.sin(theta);
const x1 = p1[0];
const y1 = p1[1];
const x2 = p2[0];
const y2 = p2[1];
const sx = x2 - x1;
const sy = y2 - y1;
const det = dx * (-sy) - dy * (-sx);
if (Math.abs(det) < 1e-9) return null;
const t = (x1 * (-sy) - y1 * (-sx)) / det;
const u = (dx * y1 - dy * x1) / det;
if (t >= 0 && u >= 0 && u <= 1) {
return t;
}
return null;
}
function polygonRadiusAtAngle(poly, theta) {
let best = 0;
for (let i = 0; i < poly.length; i++) {
const p1 = poly[i];
const p2 = poly[(i + 1) % poly.length];
const r = raySegmentIntersectionRadius(theta, p1, p2);
if (r !== null && r > best) {
best = r;
}
}
return best;
}
function fillMissingCircular(arr) {
const n = arr.length;
const result = arr.slice();
for (let i = 0; i < n; i++) {
if (Number.isFinite(result[i])) continue;
let left = i - 1;
let right = i + 1;
for (let k = 0; k < n; k++) {
const idx = (i - 1 - k + n) % n;
if (Number.isFinite(arr[idx])) {
left = idx;
break;
}
}
for (let k = 0; k < n; k++) {
const idx = (i + 1 + k) % n;
if (Number.isFinite(arr[idx])) {
right = idx;
break;
}
}
if (Number.isFinite(arr[left]) && Number.isFinite(arr[right])) {
result[i] = (arr[left] + arr[right]) * 0.5;
} else {
result[i] = 0;
}
}
return result;
}
function smoothCircular(arr, passes) {
let a = arr.slice();
const n = a.length;
for (let pass = 0; pass < passes; pass++) {
const next = a.slice();
for (let i = 0; i < n; i++) {
const l = a[(i - 1 + n) % n];
const c = a[i];
const r = a[(i + 1) % n];
next[i] = l * 0.25 + c * 0.50 + r * 0.25;
}
a = next;
}
return a;
}
function rollingMaxCircular(arr, radius) {
const n = arr.length;
const out = [];
for (let i = 0; i < n; i++) {
let m = -Infinity;
for (let k = -radius; k <= radius; k++) {
const j = (i + k + n) % n;
m = Math.max(m, arr[j]);
}
out.push(m);
}
return out;
}
function buildGamutModel(samples, params) {
const safeSamples = samples.filter(s => !s.outOfGamut);
const gamutSamples = safeSamples.length >= 12 ? safeSamples : samples;
const blackPoint = samples.reduce((best, s) => {
return s.lab[0] < best.lab[0] ? s : best;
}, samples[0]);
const whitePoint = samples.reduce((best, s) => {
return s.lab[0] > best.lab[0] ? s : best;
}, samples[0]);
const vertices = vertexTargets.map(v => {
const found = findNearestByRGB(gamutSamples, v.rgb);
return {
name: v.name,
targetRGB: v.rgb,
rgb: found.rgb,
lab: found.lab,
chroma: chroma(found.lab),
hue: hueAngle(found.lab),
sample: found
};
});
// rgbcmy 꼭짓점 + 고채도 측색점으로 허리라인 hull 구성.
const chromas = gamutSamples.map(s => chroma(s.lab));
const c90 = percentile(chromas, 0.82);
const hullCandidates = [];
for (const v of vertices) {
hullCandidates.push([v.lab[1], v.lab[2], v]);
}
for (const s of gamutSamples) {
const C = chroma(s.lab);
const L = s.lab[0];
if (C >= c90 && L > blackPoint.lab[0] + 5 && L < whitePoint.lab[0] - 4) {
hullCandidates.push([s.lab[1], s.lab[2], s]);
}
}
const waistHull = convexHull2D(hullCandidates);
const hueBins = 96;
const lBins = 28;
let radiusTable = Array.from({ length: lBins }, () => Array(hueBins).fill(NaN));
let countTable = Array.from({ length: lBins }, () => Array(hueBins).fill(0));
let oogTable = Array.from({ length: lBins }, () => Array(hueBins).fill(0));
const blackL = blackPoint.lab[0];
const whiteL = whitePoint.lab[0];
const lRange = Math.max(1, whiteL - blackL);
for (const s of samples) {
const L = s.lab[0];
const C = chroma(s.lab);
if (C < 1e-6) continue;
const h = hueAngle(s.lab);
const hi = clamp(Math.floor(h / (Math.PI * 2) * hueBins), 0, hueBins - 1);
const li = clamp(Math.floor((L - blackL) / lRange * lBins), 0, lBins - 1);
if (!Number.isFinite(radiusTable[li][hi])) {
radiusTable[li][hi] = C;
} else {
// 외곽은 평균이 아니라 outer envelope 방향으로 누적
radiusTable[li][hi] = Math.max(radiusTable[li][hi], C);
}
countTable[li][hi] += 1;
if (s.outOfGamut) oogTable[li][hi] += 1;
}
// 비어 있는 hue bin을 waist hull radius로 채움
for (let li = 0; li < lBins; li++) {
const tL = li / (lBins - 1);
const lightShape =
Math.sin(Math.PI * clamp(tL, 0, 1));
const minShape = Math.pow(Math.max(0, lightShape), 0.78);
for (let hi = 0; hi < hueBins; hi++) {
const theta = (hi + 0.5) / hueBins * Math.PI * 2;
const waistR = polygonRadiusAtAngle(waistHull, theta);
const fallback = waistR * minShape;
if (!Number.isFinite(radiusTable[li][hi])) {
radiusTable[li][hi] = fallback;
} else {
const current = radiusTable[li][hi];
// vertexBoost: rgbcmy hull보다 너무 안쪽이면 허리라인 쪽으로 보강
const guardR = fallback * params.vertexBoost;
radiusTable[li][hi] = Math.max(current, guardR);
}
}
radiusTable[li] = fillMissingCircular(radiusTable[li]);
}
// hue 방향 오목한 눌림 방지
const protect = Math.max(1, Math.round(params.edgeProtect));
for (let li = 0; li < lBins; li++) {
const raw = radiusTable[li].slice();
let envelope = rollingMaxCircular(raw, protect);
envelope = smoothCircular(envelope, 2);
for (let hi = 0; hi < hueBins; hi++) {
const current = raw[hi];
const env = Math.max(current, envelope[hi]);
const count = countTable[li][hi];
const oogRatio = count > 0 ? oogTable[li][hi] / count : 0;
const shrinkAmount = clamp((env - current) / Math.max(env * 0.35, 1e-9), 0, 1);
const guard =
clamp(
params.antiShrink * shrinkAmount +
params.gamutGuard * oogRatio * 0.75,
0,
1
);
radiusTable[li][hi] = lerp(current, env, guard);
}
radiusTable[li] = smoothCircular(radiusTable[li], 1);
}
// L 방향 smoothing. 단, shrink 방향으로 무너지는 것은 방지.
for (let hi = 0; hi < hueBins; hi++) {
const col = [];
for (let li = 0; li < lBins; li++) {
col.push(radiusTable[li][hi]);
}
const sm = col.slice();
for (let pass = 0; pass < 2; pass++) {
const next = sm.slice();
for (let li = 1; li < lBins - 1; li++) {
const v = sm[li - 1] * 0.25 + sm[li] * 0.50 + sm[li + 1] * 0.25;
next[li] = Math.max(v, col[li] * 0.92);
}
for (let li = 0; li < lBins; li++) {
sm[li] = next[li];
}
}
for (let li = 0; li < lBins; li++) {
radiusTable[li][hi] = sm[li];
}
}
function radiusAt(L, hue) {
const lf = clamp((L - blackL) / lRange, 0, 1) * (lBins - 1);
const li0 = Math.floor(lf);
const li1 = Math.min(li0 + 1, lBins - 1);
const lt = lf - li0;
let hf = (hue % (Math.PI * 2)) / (Math.PI * 2) * hueBins;
if (hf < 0) hf += hueBins;
const hi0 = Math.floor(hf) % hueBins;
const hi1 = (hi0 + 1) % hueBins;
const ht = hf - Math.floor(hf);
const r00 = radiusTable[li0][hi0];
const r01 = radiusTable[li0][hi1];
const r10 = radiusTable[li1][hi0];
const r11 = radiusTable[li1][hi1];
const r0 = lerp(r00, r01, ht);
const r1 = lerp(r10, r11, ht);
return lerp(r0, r1, lt);
}
function isBoundaryRGB(rgb) {
const r = rgb[0];
const g = rgb[1];
const b = rgb[2];
const nearFace = Math.max(
1 - Math.min(r, 100 - r) / 50,
1 - Math.min(g, 100 - g) / 50,
1 - Math.min(b, 100 - b) / 50
);
const sat = (Math.max(r, g, b) - Math.min(r, g, b)) / 100;
return clamp(nearFace * sat, 0, 1);
}
function protectLab(rgb, lab) {
const L = lab[0];
const a = lab[1];
const bb = lab[2];
const C = Math.sqrt(a * a + bb * bb);
if (C < 1e-6) {
return lab.slice();
}
const h = Math.atan2(bb, a) < 0
? Math.atan2(bb, a) + Math.PI * 2
: Math.atan2(bb, a);
const boundary = isBoundaryRGB(rgb);
const envC = radiusAt(L, h);
// 회색축 보호
const rgbSat = (Math.max(rgb[0], rgb[1], rgb[2]) - Math.min(rgb[0], rgb[1], rgb[2])) / 100;
const grayKeep = 1 - params.grayProtect * (1 - rgbSat);
// 외곽에 가까운 RGB인데 chroma가 envelope보다 비정상적으로 작으면 복원
const minAllowed =
envC *
boundary *
params.gamutGuard *
grayKeep *
0.88;
let targetC = C;
if (C < minAllowed) {
const lift = clamp((minAllowed - C) / Math.max(minAllowed, 1e-9), 0, 1);
targetC = lerp(C, minAllowed, params.antiShrink * lift);
}
// 너무 바깥으로 폭주하면 부드럽게 제한
const maxAllowed = envC * (1.00 + 0.12 * params.edgeFeather);
if (targetC > maxAllowed) {
targetC = lerp(targetC, maxAllowed, 0.65);
}
return [
clamp(L, blackL, whiteL),
Math.cos(h) * targetC,
Math.sin(h) * targetC
];
}
return {
blackPoint,
whitePoint,
vertices,
waistHull,
radiusTable,
radiusAt,
protectLab,
blackL,
whiteL,
hueBins,
lBins
};
}
// ============================================================
// 6. A2B CLUT 생성
// ============================================================
function predictA2BLab(baseModel, gamutModel, rgb, params) {
const base = [
evalPolynomial(baseModel.coefL, rgb),
evalPolynomial(baseModel.coefa, rgb),
evalPolynomial(baseModel.coefb, rgb)
];
const residual = localResidualPredict(baseModel, rgb, params);
let lab = [
base[0] + residual[0],
base[1] + residual[1],
base[2] + residual[2]
];
lab = gamutModel.protectLab(rgb, lab);
return lab;
}
function buildCLUT(samples, params) {
const baseModel = fitA2BBase(samples);
const gamutModel = buildGamutModel(samples, params);
const size = params.clutSize;
const entries = [];
for (let ri = 0; ri < size; ri++) {
for (let gi = 0; gi < size; gi++) {
for (let bi = 0; bi < size; bi++) {
const r = size === 1 ? 0 : (ri / (size - 1)) * 100;
const g = size === 1 ? 0 : (gi / (size - 1)) * 100;
const b = size === 1 ? 0 : (bi / (size - 1)) * 100;
const rgb = [r, g, b];
const lab = predictA2BLab(baseModel, gamutModel, rgb, params);
entries.push({
ri,
gi,
bi,
rgb: rgb.map(v => +v.toFixed(6)),
lab: lab.map(v => +v.toFixed(6))
});
}
}
}
return {
size,
inputEncoding: "RGB_0_100",
pcs: "Lab",
layout: "entries ordered by ri, gi, bi",
blackPoint: {
rgb: gamutModel.blackPoint.rgb,
lab: gamutModel.blackPoint.lab
},
whitePoint: {
rgb: gamutModel.whitePoint.rgb,
lab: gamutModel.whitePoint.lab
},
vertices: gamutModel.vertices.map(v => ({
name: v.name,
targetRGB: v.targetRGB,
detectedRGB: v.rgb,
lab: v.lab
})),
entries,
baseModel,
gamutModel
};
}
function runBuild() {
try {
const params = getParams();
measurementSamples = parseInput(inputEl.value);
clutResult = buildCLUT(measurementSamples, params);
model = clutResult.gamutModel;
const safeCount = measurementSamples.filter(s => !s.outOfGamut).length;
const oogCount = measurementSamples.length - safeCount;
statusEl.textContent =
"A2B CLUT 생성 완료\n" +
"입력 측색 샘플: " + measurementSamples.length + "\n" +
"gamut-safe 샘플: " + safeCount + "\n" +
"out-of-gamut 의심/표시 샘플: " + oogCount + "\n" +
"CLUT size: " + clutResult.size + " × " + clutResult.size + " × " + clutResult.size + "\n" +
"CLUT entries: " + clutResult.entries.length + "\n\n" +
"Black point Lab: " + clutResult.blackPoint.lab.map(v => v.toFixed(3)).join(", ") + "\n" +
"White point Lab: " + clutResult.whitePoint.lab.map(v => v.toFixed(3)).join(", ");
console.clear();
console.log("measurementSamples", measurementSamples);
console.log("clutResult", clutResult);
console.log("CLUT entries", clutResult.entries);
} catch (err) {
statusEl.textContent = "오류: " + err.message;
console.error(err);
}
}
// ============================================================
// 7. 다운로드
// ============================================================
function makeJsonOutput() {
if (!clutResult) {
throw new Error("먼저 A2B CLUT를 생성하세요.");
}
return JSON.stringify({
size: clutResult.size,
inputEncoding: clutResult.inputEncoding,
pcs: clutResult.pcs,
blackPoint: clutResult.blackPoint,
whitePoint: clutResult.whitePoint,
vertices: clutResult.vertices,
entries: clutResult.entries
}, null, 2);
}
function makeCsvOutput() {
if (!clutResult) {
throw new Error("먼저 A2B CLUT를 생성하세요.");
}
const lines = [];
lines.push("ri,gi,bi,R,G,B,L,a,b");
for (const e of clutResult.entries) {
lines.push([
e.ri,
e.gi,
e.bi,
e.rgb[0],
e.rgb[1],
e.rgb[2],
e.lab[0],
e.lab[1],
e.lab[2]
].join(","));
}
return lines.join("\n");
}
// ============================================================
// 8. 3D Canvas 시각화
// ============================================================
function rotateX(p, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return [
p[0],
p[1] * c - p[2] * s,
p[1] * s + p[2] * c
];
}
function rotateY(p, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return [
p[0] * c + p[2] * s,
p[1],
-p[0] * s + p[2] * c
];
}
function transformLab(lab, time) {
// Lab: L vertical, a horizontal, b depth
let p = [
lab[1] / 45,
(lab[0] - 50) / 40,
lab[2] / 45
];
p = rotateX(p, -0.62);
p = rotateY(p, time * 0.00032);
return p;
}
function project(p, cx, cy, scale) {
const d = 5.0;
const k = d / (d + p[2]);
return {
x: cx + p[0] * scale * k,
y: cy - p[1] * scale * k,
z: p[2]
};
}
function drawAxes(cx, cy, scale, time) {
const axes = [
{ a: [0, 0, 0], b: [1.8, 0, 0], color: "rgba(255,120,120,0.6)", label: "+a" },
{ a: [0, 0, 0], b: [0, 1.6, 0], color: "rgba(255,255,255,0.55)", label: "L" },
{ a: [0, 0, 0], b: [0, 0, 1.8], color: "rgba(255,230,120,0.6)", label: "+b" }
];
for (const ax of axes) {
let a = ax.a;
let b = ax.b;
a = rotateX(a, -0.62);
a = rotateY(a, time * 0.00032);
b = rotateX(b, -0.62);
b = rotateY(b, time * 0.00032);
const sa = project(a, cx, cy, scale);
const sb = project(b, cx, cy, scale);
ctx.strokeStyle = ax.color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(sa.x, sa.y);
ctx.lineTo(sb.x, sb.y);
ctx.stroke();
ctx.fillStyle = ax.color;
ctx.font = "12px Arial";
ctx.fillText(ax.label, sb.x + 4, sb.y);
}
}
function drawLabPoint(lab, rgb, cx, cy, scale, time, radius, alpha, stroke) {
const p = transformLab(lab, time);
const s = project(p, cx, cy, scale);
ctx.beginPath();
ctx.arc(s.x, s.y, radius, 0, Math.PI * 2);
ctx.fillStyle = rgbColor(rgb, alpha);
ctx.fill();
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = 1.2;
ctx.stroke();
}
}
function drawLineLab(labA, labB, cx, cy, scale, time, color, width) {
const pa = project(transformLab(labA, time), cx, cy, scale);
const pb = project(transformLab(labB, time), cx, cy, scale);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.stroke();
}
function drawWaistPolygon(gamutModel, cx, cy, scale, time) {
if (!gamutModel) return;
const vertices = gamutModel.vertices
.slice()
.sort((a, b) => a.hue - b.hue);
if (vertices.length < 3) return;
ctx.strokeStyle = "rgba(255,255,255,0.72)";
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < vertices.length; i++) {
const v = vertices[i];
const p = project(transformLab(v.lab, time), cx, cy, scale);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.closePath();
ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.9)";
ctx.font = "12px Arial";
for (const v of vertices) {
const p = project(transformLab(v.lab, time), cx, cy, scale);
ctx.fillText(v.name, p.x + 4, p.y - 4);
}
// black / white point와 꼭짓점 연결
for (const v of vertices) {
drawLineLab(gamutModel.blackPoint.lab, v.lab, cx, cy, scale, time, "rgba(180,180,180,0.20)", 1);
drawLineLab(gamutModel.whitePoint.lab, v.lab, cx, cy, scale, time, "rgba(180,180,180,0.20)", 1);
}
drawLabPoint(gamutModel.blackPoint.lab, [0,0,0], cx, cy, scale, time, 5, 1, "rgba(255,255,255,0.9)");
drawLabPoint(gamutModel.whitePoint.lab, [100,100,100], cx, cy, scale, time, 5, 1, "rgba(0,0,0,0.9)");
}
function drawMeasurementView(cx, cy, scale, time) {
drawAxes(cx, cy, scale, time);
if (!measurementSamples.length) return;
const rendered = measurementSamples.map(s => {
const p = transformLab(s.lab, time);
const scr = project(p, cx, cy, scale);
return { sample: s, p, scr };
}).sort((a, b) => a.p[2] - b.p[2]);
for (const item of rendered) {
const s = item.sample;
const radius = s.outOfGamut ? 4.0 : 2.6;
const stroke = s.outOfGamut ? "rgba(255,60,45,0.95)" : null;
drawLabPoint(
s.lab,
s.rgb,
cx,
cy,
scale,
time,
radius,
s.outOfGamut ? 0.90 : 0.82,
stroke
);
}
if (model) {
drawWaistPolygon(model, cx, cy, scale, time);
}
}
function drawClutView(cx, cy, scale, time) {
drawAxes(cx, cy, scale, time);
if (!clutResult) return;
const entries = clutResult.entries;
const rendered = entries.map(e => {
const p = transformLab(e.lab, time);
const scr = project(p, cx, cy, scale);
return { e, p, scr };
}).sort((a, b) => a.p[2] - b.p[2]);
for (const item of rendered) {
const e = item.e;
const maxc = Math.max(e.rgb[0], e.rgb[1], e.rgb[2]);
const minc = Math.min(e.rgb[0], e.rgb[1], e.rgb[2]);
const sat = (maxc - minc) / 100;
const radius = 1.2 + sat * 1.2;
drawLabPoint(
e.lab,
e.rgb,
cx,
cy,
scale,
time,
radius,
0.64,
null
);
}
if (model) {
drawWaistPolygon(model, cx, cy, scale, time);
}
}
function drawDivider(width, height) {
ctx.strokeStyle = "rgba(255,255,255,0.14)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(width / 2, 0);
ctx.lineTo(width / 2, height);
ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.18)";
ctx.font = "38px Arial";
ctx.fillText("→", width / 2 - 12, height / 2);
}
function draw() {
const wrap = document.getElementById("canvasWrap");
const w = wrap.clientWidth;
const h = wrap.clientHeight;
const now = performance.now();
const dt = now - lastTime;
lastTime = now;
if (rotationEnabled) {
rotationTime += dt;
}
ctx.clearRect(0, 0, w, h);
drawDivider(w, h);
const scale = Math.min(w, h) * 0.26;
drawMeasurementView(w * 0.25, h * 0.55, scale, rotationTime);
drawClutView(w * 0.75, h * 0.55, scale, rotationTime);
requestAnimationFrame(draw);
}
// ============================================================
// 9. 이벤트
// ============================================================
document.getElementById("makeDemo").addEventListener("click", () => {
loadDemo();
runBuild();
});
document.getElementById("buildClut").addEventListener("click", () => {
runBuild();
});
document.getElementById("toggleRotation").addEventListener("click", () => {
rotationEnabled = !rotationEnabled;
document.getElementById("toggleRotation").textContent =
rotationEnabled ? "회전 정지" : "회전 시작";
});
document.getElementById("downloadJson").addEventListener("click", () => {
try {
downloadText(
"printer_a2b_clut_16_lab.json",
makeJsonOutput(),
"application/json"
);
} catch (e) {
statusEl.textContent = "오류: " + e.message;
}
});
document.getElementById("downloadCsv").addEventListener("click", () => {
try {
downloadText(
"printer_a2b_clut_16_lab.csv",
makeCsvOutput(),
"text/csv"
);
} catch (e) {
statusEl.textContent = "오류: " + e.message;
}
});
// 초기 실행
loadDemo();
runBuild();
draw();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Printer ICC A2B PCS CLUT Gamut Guard</title>
<style>
body {
margin: 0;
background: #101010;
color: #eeeeee;
font-family: Arial, sans-serif;
overflow: hidden;
}
canvas {
display: block;
background: #101010;
}
.panel {
position: fixed;
left: 12px;
top: 12px;
width: 430px;
max-height: calc(100vh - 24px);
overflow: auto;
padding: 12px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.72);
box-shadow: 0 0 18px rgba(0,0,0,0.45);
z-index: 10;
font-size: 13px;
}
.panel h2 {
margin: 0 0 8px 0;
font-size: 16px;
color: #ffffff;
}
.panel .desc {
color: #cfcfcf;
line-height: 1.45;
margin-bottom: 10px;
}
textarea {
width: 100%;
height: 210px;
box-sizing: border-box;
resize: vertical;
background: #181818;
color: #e8e8e8;
border: 1px solid #444;
border-radius: 10px;
padding: 8px;
font-family: Consolas, monospace;
font-size: 12px;
line-height: 1.35;
}
button {
padding: 8px 11px;
margin: 3px 3px 3px 0;
border: 0;
border-radius: 999px;
background: #ffffff;
color: #111111;
font-weight: bold;
cursor: pointer;
}
button:hover {
background: #dddddd;
}
.grid {
display: grid;
grid-template-columns: 130px 180px 55px;
gap: 6px 8px;
align-items: center;
margin-top: 8px;
}
.grid input {
width: 180px;
}
.status {
margin-top: 8px;
padding: 8px;
border-radius: 10px;
background: rgba(255,255,255,0.08);
color: #dddddd;
line-height: 1.5;
white-space: pre-line;
}
.legend {
position: fixed;
right: 16px;
top: 14px;
z-index: 8;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.58);
color: #dddddd;
font-size: 13px;
line-height: 1.6;
}
.titleLeft {
position: fixed;
left: 470px;
top: 16px;
z-index: 7;
color: #8ecbff;
font-weight: bold;
font-size: 17px;
background: rgba(0,0,0,0.40);
padding: 7px 10px;
border-radius: 10px;
}
.titleRight {
position: fixed;
right: 24px;
top: 86px;
z-index: 7;
color: #9dffb7;
font-weight: bold;
font-size: 17px;
background: rgba(0,0,0,0.40);
padding: 7px 10px;
border-radius: 10px;
}
</style>
</head>
<body>
<div class="panel">
<h2>프린터 ICC A2B → PCS Lab CLUT 생성</h2>
<div class="desc">
입력 형식: <b>R,G,B,L,a,b[,flag]</b><br />
RGB는 0~100 범위, Lab은 측색값입니다.<br />
flag는 선택사항이며 <b>oog</b>, <b>out</b>, <b>outOfGamut</b>이면 외곽 추정 영향도를 낮춥니다.
</div>
<textarea id="inputText"></textarea>
<div style="margin-top:8px;">
<button id="loadDemo">데모 측색값 생성</button>
<button id="build">A2B CLUT 생성</button>
<button id="toggleRotation">회전 정지</button>
<button id="downloadClut">CLUT CSV 다운로드</button>
<button id="downloadVertices">검출 꼭짓점 CSV 다운로드</button>
</div>
<div class="grid">
<label>CLUT size</label>
<input id="clutSize" type="range" min="9" max="25" step="1" value="16" />
<span id="clutSizeValue">16</span>
<label>fitSmooth</label>
<input id="fitSmooth" type="range" min="0.08" max="0.45" step="0.01" value="0.22" />
<span id="fitSmoothValue">0.22</span>
<label>gamutGuard</label>
<input id="gamutGuard" type="range" min="0.00" max="1.00" step="0.05" value="0.85" />
<span id="gamutGuardValue">0.85</span>
<label>antiShrink</label>
<input id="antiShrink" type="range" min="0.00" max="1.00" step="0.05" value="0.78" />
<span id="antiShrinkValue">0.78</span>
<label>outlineSmooth</label>
<input id="outlineSmooth" type="range" min="1" max="10" step="1" value="4" />
<span id="outlineSmoothValue">4</span>
<label>outerProtect</label>
<input id="outerProtect" type="range" min="1" max="12" step="1" value="5" />
<span id="outerProtectValue">5</span>
<label>protrusionAllow</label>
<input id="protrusionAllow" type="range" min="0.00" max="1.00" step="0.05" value="0.80" />
<span id="protrusionAllowValue">0.80</span>
<label>oogWeight</label>
<input id="oogWeight" type="range" min="0.02" max="0.50" step="0.02" value="0.12" />
<span id="oogWeightValue">0.12</span>
</div>
<div id="status" class="status">준비됨</div>
</div>
<div class="titleLeft">측정값 미리보기: Printer RGB → Lab</div>
<div class="titleRight">CLUT 미리보기: 16³ RGB → PCS Lab</div>
<div class="legend">
<b>색상 표시</b><br />
파란 점: 측색 gamut-safe 샘플<br />
붉은 점: out-of-gamut / 압축 의심 샘플<br />
흰 선: 검출된 RGBCMY 허리라인 polygon<br />
연두 점: 생성된 A2B CLUT Lab<br />
노란 점: RGBCMY 꼭짓점<br />
흰 점: White / Black point
</div>
<canvas id="canvas"></canvas>
<script>
// =====================================================
// Canvas
// =====================================================
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
// =====================================================
// UI
// =====================================================
const inputText = document.getElementById("inputText");
const statusBox = document.getElementById("status");
const controls = {
clutSize: document.getElementById("clutSize"),
fitSmooth: document.getElementById("fitSmooth"),
gamutGuard: document.getElementById("gamutGuard"),
antiShrink: document.getElementById("antiShrink"),
outlineSmooth: document.getElementById("outlineSmooth"),
outerProtect: document.getElementById("outerProtect"),
protrusionAllow: document.getElementById("protrusionAllow"),
oogWeight: document.getElementById("oogWeight")
};
const values = {
clutSize: document.getElementById("clutSizeValue"),
fitSmooth: document.getElementById("fitSmoothValue"),
gamutGuard: document.getElementById("gamutGuardValue"),
antiShrink: document.getElementById("antiShrinkValue"),
outlineSmooth: document.getElementById("outlineSmoothValue"),
outerProtect: document.getElementById("outerProtectValue"),
protrusionAllow: document.getElementById("protrusionAllowValue"),
oogWeight: document.getElementById("oogWeightValue")
};
function getParams() {
const p = {
clutSize: Number(controls.clutSize.value),
fitSmooth: Number(controls.fitSmooth.value),
gamutGuard: Number(controls.gamutGuard.value),
antiShrink: Number(controls.antiShrink.value),
outlineSmooth: Number(controls.outlineSmooth.value),
outerProtect: Number(controls.outerProtect.value),
protrusionAllow: Number(controls.protrusionAllow.value),
oogWeight: Number(controls.oogWeight.value)
};
for (const k of Object.keys(values)) {
values[k].textContent = String(p[k]);
}
return p;
}
for (const k of Object.keys(controls)) {
controls[k].addEventListener("input", () => {
getParams();
});
}
// =====================================================
// Utilities
// =====================================================
function clamp(x, a, b) {
return Math.max(a, Math.min(b, x));
}
function lerp(a, b, t) {
return a * (1 - t) + b * t;
}
function smoothstep(edge0, edge1, x) {
if (edge0 === edge1) return x < edge0 ? 0 : 1;
const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function hypot2(x, y) {
return Math.sqrt(x * x + y * y);
}
function percentile(values, q) {
const arr = values
.filter(Number.isFinite)
.slice()
.sort((a, b) => a - b);
if (arr.length === 0) return 0;
const pos = (arr.length - 1) * q;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
const t = pos - lo;
return arr[lo] * (1 - t) + arr[hi] * t;
}
function median(values) {
return percentile(values, 0.5);
}
function angleOf(a, b) {
let x = Math.atan2(b, a);
if (x < 0) x += Math.PI * 2;
return x;
}
function angleDiff(a, b) {
let d = Math.abs(a - b) % (Math.PI * 2);
if (d > Math.PI) d = Math.PI * 2 - d;
return d;
}
function cross2(ax, ay, bx, by) {
return ax * by - ay * bx;
}
function gaussianSmoothArray(values, passes) {
let arr = values.slice();
for (let pass = 0; pass < passes; pass++) {
const next = arr.slice();
for (let i = 1; i < arr.length - 1; i++) {
next[i] =
arr[i - 1] * 0.25 +
arr[i] * 0.50 +
arr[i + 1] * 0.25;
}
arr = next;
}
return arr;
}
function rollingMin(values, radius) {
const out = [];
for (let i = 0; i < values.length; i++) {
let m = Infinity;
for (let k = -radius; k <= radius; k++) {
const j = clamp(i + k, 0, values.length - 1);
m = Math.min(m, values[j]);
}
out.push(m);
}
return out;
}
function rollingMax(values, radius) {
const out = [];
for (let i = 0; i < values.length; i++) {
let m = -Infinity;
for (let k = -radius; k <= radius; k++) {
const j = clamp(i + k, 0, values.length - 1);
m = Math.max(m, values[j]);
}
out.push(m);
}
return out;
}
function solveLinearSystem(A, b) {
const n = A.length;
const M = A.map((row, i) => [...row, b[i]]);
for (let col = 0; col < n; col++) {
let pivot = col;
for (let row = col + 1; row < n; row++) {
if (Math.abs(M[row][col]) > Math.abs(M[pivot][col])) {
pivot = row;
}
}
[M[col], M[pivot]] = [M[pivot], M[col]];
const div = M[col][col];
if (Math.abs(div) < 1e-12) {
return null;
}
for (let k = col; k <= n; k++) {
M[col][k] /= div;
}
for (let row = 0; row < n; row++) {
if (row === col) continue;
const factor = M[row][col];
for (let k = col; k <= n; k++) {
M[row][k] -= factor * M[col][k];
}
}
}
return M.map(row => row[n]);
}
function downloadText(filename, text) {
const blob = new Blob([text], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// =====================================================
// Demo printer measurement data
//
// 실제 프린터 측색값처럼 보이도록 만든 데모입니다.
// 일부 외곽 샘플은 out-of-gamut 압축 현상을 흉내 내기 위해
// oog 플래그를 부여했습니다.
// =====================================================
function pseudoNoise(n) {
const x = Math.sin(n * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
function trilerpLab(r, g, b) {
const K = { L: 12.0, a: 0.4, b: -1.2 };
const R = { L: 47.5, a: 64.0, b: 42.0 };
const G = { L: 72.5, a:-54.0, b: 51.0 };
const B = { L: 28.0, a: 25.0, b:-61.0 };
const Y = { L: 88.0, a: -8.0, b: 78.0 };
const M = { L: 45.5, a: 56.0, b:-39.0 };
const C = { L: 76.0, a:-37.0, b:-28.0 };
const W = { L: 95.0, a: 1.1, b: -3.0 };
function mix(v000, v100, v010, v001, v110, v101, v011, v111, key) {
const c00 = lerp(v000[key], v100[key], r);
const c10 = lerp(v010[key], v110[key], r);
const c01 = lerp(v001[key], v101[key], r);
const c11 = lerp(v011[key], v111[key], r);
const c0 = lerp(c00, c10, g);
const c1 = lerp(c01, c11, g);
return lerp(c0, c1, b);
}
const lab = {
L: mix(K, R, G, B, Y, M, C, W, "L"),
a: mix(K, R, G, B, Y, M, C, W, "a"),
b: mix(K, R, G, B, Y, M, C, W, "b")
};
const maxv = Math.max(r, g, b);
const minv = Math.min(r, g, b);
const sat = maxv - minv;
const light = (r + g + b) / 3;
const chroma = hypot2(lab.a, lab.b);
const hue = angleOf(lab.a, lab.b);
const hueBoost =
1.0 +
0.04 * Math.sin(3 * hue + 0.3) -
0.03 * Math.cos(5 * hue);
const lMask =
0.82 +
0.18 * Math.sin(Math.PI * clamp((lab.L - 8) / 90, 0, 1));
const printChromaScale =
(0.86 + 0.12 * sat) *
hueBoost *
lMask;
if (chroma > 1e-6) {
lab.a *= printChromaScale;
lab.b *= printChromaScale;
}
lab.L +=
1.2 * Math.sin(Math.PI * light) -
0.8 * sat * (1 - light);
return lab;
}
function demoDataText() {
const rows = [];
rows.push("R,G,B,L,a,b,flag");
const levels = [0, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100];
let seed = 1;
for (const R of levels) {
for (const G of levels) {
for (const B of levels) {
const r = R / 100;
const g = G / 100;
const b = B / 100;
let lab = trilerpLab(r, g, b);
const maxv = Math.max(r, g, b);
const minv = Math.min(r, g, b);
const sat = maxv - minv;
const hue = angleOf(lab.a, lab.b);
const n1 = pseudoNoise(seed++);
const n2 = pseudoNoise(seed++);
const n3 = pseudoNoise(seed++);
lab.L += (n1 - 0.5) * 0.55;
lab.a += (n2 - 0.5) * 0.85;
lab.b += (n3 - 0.5) * 0.85;
let flag = "safe";
const isOuterDevice =
sat > 0.74 &&
(
R < 14 || R > 86 ||
G < 14 || G > 86 ||
B < 14 || B > 86
);
const problemHue =
Math.exp(-Math.pow(angleDiff(hue, angleOf(-45, 55)) / 0.45, 2)) > 0.22 ||
Math.exp(-Math.pow(angleDiff(hue, angleOf( 60,-38)) / 0.48, 2)) > 0.22 ||
Math.exp(-Math.pow(angleDiff(hue, angleOf(-40,-28)) / 0.55, 2)) > 0.25;
if (isOuterDevice && problemHue) {
const compression = 0.78;
lab.a *= compression;
lab.b *= compression;
lab.L += 0.4;
flag = "oog";
}
rows.push([
R.toFixed(1),
G.toFixed(1),
B.toFixed(1),
lab.L.toFixed(3),
lab.a.toFixed(3),
lab.b.toFixed(3),
flag
].join(","));
}
}
}
return rows.join("\n");
}
// =====================================================
// Input parsing
// =====================================================
function parseInput(text) {
const lines = text
.split(/\r?\n/)
.map(v => v.trim())
.filter(v => v.length > 0 && !v.startsWith("#"));
const samples = [];
for (const line of lines) {
const parts = line
.split(/[,\t; ]+/)
.map(v => v.trim())
.filter(v => v.length > 0);
if (parts.length < 6) continue;
const R = Number(parts[0]);
const G = Number(parts[1]);
const B = Number(parts[2]);
const L = Number(parts[3]);
const a = Number(parts[4]);
const bb = Number(parts[5]);
if (![R, G, B, L, a, bb].every(Number.isFinite)) {
continue;
}
const flag = parts.slice(6).join(" ").toLowerCase();
const outOfGamut =
flag.includes("oog") ||
flag.includes("out") ||
flag.includes("bad") ||
flag.includes("clip") ||
flag.includes("compressed") ||
flag.includes("true") ||
flag === "1";
samples.push({
R: clamp(R, 0, 100),
G: clamp(G, 0, 100),
B: clamp(B, 0, 100),
L,
a,
b: bb,
outOfGamut,
label: ""
});
}
return samples;
}
// =====================================================
// White / Black / RGBCMY vertex detection
// =====================================================
const VERTEX_IDEAL_RGB = [
{ name: "R", R: 100, G: 0, B: 0 },
{ name: "Y", R: 100, G: 100, B: 0 },
{ name: "G", R: 0, G: 100, B: 0 },
{ name: "C", R: 0, G: 100, B: 100 },
{ name: "B", R: 0, G: 0, B: 100 },
{ name: "M", R: 100, G: 0, B: 100 }
];
function rgbDistance(s, target) {
const dR = (s.R - target.R) / 100;
const dG = (s.G - target.G) / 100;
const dB = (s.B - target.B) / 100;
return Math.sqrt(dR * dR + dG * dG + dB * dB);
}
function chromaOf(s, center) {
const da = s.a - center.a;
const db = s.b - center.b;
return Math.sqrt(da * da + db * db);
}
function detectWhiteBlack(samples) {
let white = null;
let black = null;
let bestWhite = -Infinity;
let bestBlack = Infinity;
for (const s of samples) {
const rgbMean = (s.R + s.G + s.B) / 3;
const rgbBlack = (300 - s.R - s.G - s.B) / 3;
const C = Math.sqrt(s.a * s.a + s.b * s.b);
const whiteScore =
s.L * 1.35 +
rgbMean * 0.35 -
C * 0.08 -
(s.outOfGamut ? 12 : 0);
const blackScore =
s.L * 1.40 -
rgbBlack * 0.28 +
C * 0.04 +
(s.outOfGamut ? 10 : 0);
if (whiteScore > bestWhite) {
bestWhite = whiteScore;
white = s;
}
if (blackScore < bestBlack) {
bestBlack = blackScore;
black = s;
}
}
return { white, black };
}
function neutralCenterAtL(L, black, white) {
const t = clamp((L - black.L) / Math.max(1e-9, white.L - black.L), 0, 1);
return {
a: lerp(black.a, white.a, t),
b: lerp(black.b, white.b, t)
};
}
function detectRGBVertices(samples, black, white) {
const vertices = [];
const safe = samples.filter(s => !s.outOfGamut);
const useSamples = safe.length >= 10 ? safe : samples;
for (const ideal of VERTEX_IDEAL_RGB) {
let best = null;
let bestScore = -Infinity;
for (const s of useSamples) {
const d = rgbDistance(s, ideal);
const c0 = neutralCenterAtL(s.L, black, white);
const C = chromaOf(s, c0);
const proximity = Math.exp(-(d * d) / (2 * 0.30 * 0.30));
const deviceSat =
(Math.max(s.R, s.G, s.B) - Math.min(s.R, s.G, s.B)) / 100;
const score =
proximity * 4.0 +
C / 42.0 +
deviceSat * 1.4 -
(s.outOfGamut ? 2.0 : 0);
if (score > bestScore) {
bestScore = score;
best = s;
}
}
const center = neutralCenterAtL(best.L, black, white);
const hue = angleOf(best.a - center.a, best.b - center.b);
vertices.push({
name: ideal.name,
R: best.R,
G: best.G,
B: best.B,
L: best.L,
a: best.a,
b: best.b,
hue,
source: best
});
}
vertices.sort((p, q) => p.hue - q.hue);
return vertices;
}
// =====================================================
// Polygon waist model
//
// 위에서 본 a*b* 허리라인 polygon입니다.
// 이 polygon은 기본 안전 외곽입니다.
// 측정 표면이 polygon보다 더 튀어나온 경우는 outer envelope로 허용합니다.
// 반대로 안쪽으로 눌린 부분은 polygon보다 안쪽으로 들어가지 않게 보호합니다.
// =====================================================
function polygonRayIntersection(vertices, theta, black, white) {
const d = {
x: Math.cos(theta),
y: Math.sin(theta)
};
let best = null;
const meanL = vertices.reduce((sum, v) => sum + v.L, 0) / vertices.length;
const center = neutralCenterAtL(meanL, black, white);
for (let i = 0; i < vertices.length; i++) {
const j = (i + 1) % vertices.length;
const vi = vertices[i];
const vj = vertices[j];
const ax = vi.a - center.a;
const ay = vi.b - center.b;
const bx = vj.a - center.a;
const by = vj.b - center.b;
const ex = bx - ax;
const ey = by - ay;
const denom = cross2(d.x, d.y, ex, ey);
if (Math.abs(denom) < 1e-9) continue;
const t = cross2(ax, ay, ex, ey) / denom;
const u = cross2(ax, ay, d.x, d.y) / denom;
if (t >= 0 && u >= -1e-6 && u <= 1 + 1e-6) {
if (!best || t < best.radius) {
best = {
radius: t,
L: lerp(vi.L, vj.L, clamp(u, 0, 1)),
edgeIndex: i,
u: clamp(u, 0, 1)
};
}
}
}
if (!best) {
const radii = vertices.map(v => {
const c = neutralCenterAtL(v.L, black, white);
return hypot2(v.a - c.a, v.b - c.b);
});
return {
radius: percentile(radii, 0.85),
L: meanL,
edgeIndex: 0,
u: 0
};
}
return best;
}
function buildGamutModel(samples, params) {
const { white, black } = detectWhiteBlack(samples);
const vertices = detectRGBVertices(samples, black, white);
const Lmin = black.L;
const Lmax = white.L;
const hueBins = 96;
const lBins = 48;
const rawOuter = Array.from({ length: lBins }, () =>
Array(hueBins).fill(NaN)
);
const rawCount = Array.from({ length: lBins }, () =>
Array(hueBins).fill(0)
);
function baseConeRadiusAt(L, theta) {
const waist = polygonRayIntersection(vertices, theta, black, white);
const Lw = waist.L;
const Rw = waist.radius;
let scale;
if (L <= Lw) {
scale = (L - Lmin) / Math.max(1e-9, Lw - Lmin);
} else {
scale = (Lmax - L) / Math.max(1e-9, Lmax - Lw);
}
scale = clamp(scale, 0, 1);
return Rw * scale;
}
function binIndexL(L) {
return clamp(
Math.round(((L - Lmin) / Math.max(1e-9, Lmax - Lmin)) * (lBins - 1)),
0,
lBins - 1
);
}
function binIndexH(theta) {
const h = ((theta % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
return clamp(Math.floor((h / (Math.PI * 2)) * hueBins), 0, hueBins - 1);
}
for (const s of samples) {
const center = neutralCenterAtL(s.L, black, white);
const da = s.a - center.a;
const db = s.b - center.b;
const r = hypot2(da, db);
if (r < 1e-6) continue;
const theta = angleOf(da, db);
const li = binIndexL(s.L);
const hi = binIndexH(theta);
if (s.outOfGamut) {
continue;
}
if (!Number.isFinite(rawOuter[li][hi])) {
rawOuter[li][hi] = r;
} else {
rawOuter[li][hi] = Math.max(rawOuter[li][hi], r);
}
rawCount[li][hi]++;
}
for (let li = 0; li < lBins; li++) {
const L = lerp(Lmin, Lmax, li / (lBins - 1));
for (let hi = 0; hi < hueBins; hi++) {
const theta = (hi / hueBins) * Math.PI * 2;
const base = baseConeRadiusAt(L, theta);
if (!Number.isFinite(rawOuter[li][hi])) {
rawOuter[li][hi] = base;
} else {
rawOuter[li][hi] = Math.max(base, rawOuter[li][hi]);
}
}
}
let outer = rawOuter.map(row => row.slice());
for (let pass = 0; pass < params.outlineSmooth; pass++) {
const next = outer.map(row => row.slice());
for (let li = 0; li < lBins; li++) {
for (let hi = 0; hi < hueBins; hi++) {
let sum = 0;
let wsum = 0;
for (let dl = -1; dl <= 1; dl++) {
for (let dh = -1; dh <= 1; dh++) {
const lj = clamp(li + dl, 0, lBins - 1);
const hj = (hi + dh + hueBins) % hueBins;
const w = dl === 0 && dh === 0 ? 4 : 1;
sum += outer[lj][hj] * w;
wsum += w;
}
}
next[li][hi] = sum / wsum;
}
}
outer = next;
}
const protect = Math.max(1, Math.round(params.outerProtect));
for (let li = 0; li < lBins; li++) {
const row = outer[li];
const maxRow = rollingMax(row.concat(row, row), protect);
const middle = maxRow.slice(hueBins, hueBins * 2);
for (let hi = 0; hi < hueBins; hi++) {
outer[li][hi] = Math.max(row[hi], middle[hi] * params.protrusionAllow);
}
}
for (let li = 0; li < lBins; li++) {
const L = lerp(Lmin, Lmax, li / (lBins - 1));
for (let hi = 0; hi < hueBins; hi++) {
const theta = (hi / hueBins) * Math.PI * 2;
const base = baseConeRadiusAt(L, theta);
outer[li][hi] = Math.max(base, outer[li][hi]);
}
}
function interpolateOuter(L, theta) {
const lNorm = clamp((L - Lmin) / Math.max(1e-9, Lmax - Lmin), 0, 1);
const hNorm = (((theta % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2);
const lf = lNorm * (lBins - 1);
const l0 = Math.floor(lf);
const l1 = Math.min(l0 + 1, lBins - 1);
const lt = lf - l0;
const hf = hNorm * hueBins;
const h0 = Math.floor(hf) % hueBins;
const h1 = (h0 + 1) % hueBins;
const ht = hf - Math.floor(hf);
const v00 = outer[l0][h0];
const v01 = outer[l0][h1];
const v10 = outer[l1][h0];
const v11 = outer[l1][h1];
const v0 = lerp(v00, v01, ht);
const v1 = lerp(v10, v11, ht);
return lerp(v0, v1, lt);
}
function boundaryRadiusAt(L, theta) {
const base = baseConeRadiusAt(L, theta);
const observed = interpolateOuter(L, theta);
return Math.max(
base,
lerp(base, Math.max(base, observed), params.gamutGuard)
);
}
function gamutVolumeEstimate() {
const lSteps = 90;
const hSteps = 180;
let volume = 0;
const dL = (Lmax - Lmin) / (lSteps - 1);
const dTheta = (Math.PI * 2) / hSteps;
for (let li = 0; li < lSteps; li++) {
const L = lerp(Lmin, Lmax, li / (lSteps - 1));
for (let hi = 0; hi < hSteps; hi++) {
const theta = (hi / hSteps) * Math.PI * 2;
const r = boundaryRadiusAt(L, theta);
volume += 0.5 * r * r * dTheta * dL;
}
}
return volume;
}
return {
white,
black,
vertices,
Lmin,
Lmax,
hueBins,
lBins,
outer,
rawOuter,
boundaryRadiusAt,
baseConeRadiusAt,
neutralCenterAtL: L => neutralCenterAtL(L, black, white),
volume: gamutVolumeEstimate()
};
}
// =====================================================
// RGB -> Lab fitting
//
// 측색 데이터는 CLUT보다 적거나 많을 수 있으므로
// local quadratic weighted regression을 사용합니다.
// out-of-gamut 샘플은 fitting 영향도를 낮춥니다.
// =====================================================
function localQuadraticPredict(rgb, samples, params) {
const qR = rgb.R / 100;
const qG = rgb.G / 100;
const qB = rgb.B / 100;
const sigma = params.fitSmooth;
const kMax = Math.min(72, samples.length);
const near = samples.map(s => {
const dR = s.R / 100 - qR;
const dG = s.G / 100 - qG;
const dB = s.B / 100 - qB;
const d2 = dR * dR + dG * dG + dB * dB;
return { s, d2 };
});
near.sort((a, b) => a.d2 - b.d2);
const use = near.slice(0, kMax);
const nBasis = 10;
const ridge = 1e-5;
const A = Array.from({ length: nBasis }, () => Array(nBasis).fill(0));
const bL = Array(nBasis).fill(0);
const ba = Array(nBasis).fill(0);
const bb = Array(nBasis).fill(0);
let totalW = 0;
for (const item of use) {
const s = item.s;
const dR = s.R / 100 - qR;
const dG = s.G / 100 - qG;
const dB = s.B / 100 - qB;
const d2 = dR * dR + dG * dG + dB * dB;
let w = Math.exp(-0.5 * d2 / Math.max(1e-9, sigma * sigma));
if (s.outOfGamut) {
w *= params.oogWeight;
}
const basis = [
1,
dR,
dG,
dB,
dR * dR,
dG * dG,
dB * dB,
dR * dG,
dR * dB,
dG * dB
];
totalW += w;
for (let i = 0; i < nBasis; i++) {
bL[i] += w * basis[i] * s.L;
ba[i] += w * basis[i] * s.a;
bb[i] += w * basis[i] * s.b;
for (let j = 0; j < nBasis; j++) {
A[i][j] += w * basis[i] * basis[j];
}
}
}
if (totalW < 1e-9) {
return idwFallback(rgb, samples, params);
}
for (let i = 0; i < nBasis; i++) {
A[i][i] += ridge;
}
const cL = solveLinearSystem(A.map(row => row.slice()), bL);
const ca = solveLinearSystem(A.map(row => row.slice()), ba);
const cb = solveLinearSystem(A.map(row => row.slice()), bb);
if (!cL || !ca || !cb) {
return idwFallback(rgb, samples, params);
}
return {
L: cL[0],
a: ca[0],
b: cb[0]
};
}
function idwFallback(rgb, samples, params) {
let wsum = 0;
let L = 0;
let a = 0;
let b = 0;
for (const s of samples) {
const dR = (s.R - rgb.R) / 100;
const dG = (s.G - rgb.G) / 100;
const dB = (s.B - rgb.B) / 100;
const d2 = dR * dR + dG * dG + dB * dB;
let w = 1 / Math.pow(d2 + 0.0025, 1.35);
if (s.outOfGamut) {
w *= params.oogWeight;
}
wsum += w;
L += w * s.L;
a += w * s.a;
b += w * s.b;
}
return {
L: L / wsum,
a: a / wsum,
b: b / wsum
};
}
// =====================================================
// Gamut guard projection
//
// 목적:
// - 외곽이 out-of-gamut 압축값 때문에 안쪽으로 끌려 들어가는 현상 방지
// - polygon보다 튀어나온 실제 측색 outer envelope는 허용
// - 오목하게 눌린 부분은 base polygon / outer envelope로 복원
// =====================================================
function applyGamutGuard(rawLab, rgb, model, params) {
const L = clamp(rawLab.L, model.Lmin, model.Lmax);
const center = model.neutralCenterAtL(L);
let da = rawLab.a - center.a;
let db = rawLab.b - center.b;
let r = hypot2(da, db);
if (r < 1e-9) {
return {
L,
a: center.a,
b: center.b
};
}
const theta = angleOf(da, db);
const boundary = Math.max(1e-9, model.boundaryRadiusAt(L, theta));
const maxRGB = Math.max(rgb.R, rgb.G, rgb.B);
const minRGB = Math.min(rgb.R, rgb.G, rgb.B);
const deviceSat = (maxRGB - minRGB) / 100;
const nearCubeSurface =
1 -
Math.min(
rgb.R,
100 - rgb.R,
rgb.G,
100 - rgb.G,
rgb.B,
100 - rgb.B
) / 50;
const surfaceStrength = clamp(nearCubeSurface, 0, 1);
const expectedRatio =
Math.pow(deviceSat, 0.88) *
(0.58 + 0.42 * surfaceStrength);
const minProtectedRadius =
boundary *
expectedRatio *
params.antiShrink;
if (r < minProtectedRadius) {
r = lerp(r, minProtectedRadius, params.gamutGuard);
}
if (r > boundary) {
r = lerp(r, boundary, 0.96);
}
da = Math.cos(theta) * r;
db = Math.sin(theta) * r;
return {
L,
a: center.a + da,
b: center.b + db
};
}
// =====================================================
// CLUT generation
//
// RGB 0~100, size x size x size.
// 기본 size는 16, 즉 16/16/16입니다.
// =====================================================
function generateCLUT(samples, model, params) {
const size = params.clutSize;
const clut = [];
for (let ri = 0; ri < size; ri++) {
const R = (ri / (size - 1)) * 100;
for (let gi = 0; gi < size; gi++) {
const G = (gi / (size - 1)) * 100;
for (let bi = 0; bi < size; bi++) {
const B = (bi / (size - 1)) * 100;
const rgb = { R, G, B };
const raw = localQuadraticPredict(rgb, samples, params);
const guarded = applyGamutGuard(raw, rgb, model, params);
clut.push({
ri,
gi,
bi,
R,
G,
B,
rawL: raw.L,
rawa: raw.a,
rawb: raw.b,
L: guarded.L,
a: guarded.a,
b: guarded.b
});
}
}
}
return clut;
}
function clutToCSV(clut) {
const rows = [
"ri,gi,bi,R,G,B,L,a,b,rawL,rawa,rawb"
];
for (const p of clut) {
rows.push([
p.ri,
p.gi,
p.bi,
p.R.toFixed(6),
p.G.toFixed(6),
p.B.toFixed(6),
p.L.toFixed(6),
p.a.toFixed(6),
p.b.toFixed(6),
p.rawL.toFixed(6),
p.rawa.toFixed(6),
p.rawb.toFixed(6)
].join(","));
}
return rows.join("\n");
}
function verticesToCSV(model) {
const rows = [
"name,R,G,B,L,a,b,hue"
];
rows.push([
"WHITE",
model.white.R,
model.white.G,
model.white.B,
model.white.L.toFixed(6),
model.white.a.toFixed(6),
model.white.b.toFixed(6),
""
].join(","));
rows.push([
"BLACK",
model.black.R,
model.black.G,
model.black.B,
model.black.L.toFixed(6),
model.black.a.toFixed(6),
model.black.b.toFixed(6),
""
].join(","));
for (const v of model.vertices) {
rows.push([
v.name,
v.R.toFixed(6),
v.G.toFixed(6),
v.B.toFixed(6),
v.L.toFixed(6),
v.a.toFixed(6),
v.b.toFixed(6),
v.hue.toFixed(8)
].join(","));
}
return rows.join("\n");
}
// =====================================================
// Rendering
// =====================================================
let measuredSamples = [];
let gamutModel = null;
let clutData = [];
let rotationEnabled = true;
let rotationTime = 0;
let lastFrameTime = performance.now();
function labToWorld(L, a, b) {
return [
a / 75,
(L - 50) / 42,
b / 75
];
}
function rotateX(p, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return [
p[0],
p[1] * c - p[2] * s,
p[1] * s + p[2] * c
];
}
function rotateY(p, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return [
p[0] * c + p[2] * s,
p[1],
-p[0] * s + p[2] * c
];
}
function rotateZ(p, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return [
p[0] * c - p[1] * s,
p[0] * s + p[1] * c,
p[2]
];
}
function transform(p, time) {
let q = p;
q = rotateX(q, -0.55);
q = rotateZ(q, 0.15);
q = rotateY(q, time * 0.00032);
return q;
}
function project(p, cx, cy, scale) {
const d = 5.0;
const k = d / (d + p[2]);
return {
x: cx + p[0] * scale * k,
y: cy - p[1] * scale * k,
z: p[2]
};
}
function labColorApprox(L, a, b, alpha) {
const C = hypot2(a, b);
const h = angleOf(a, b);
const light = clamp(L / 100, 0, 1);
const sat = clamp(C / 90, 0, 1);
const r = Math.floor(80 + 160 * light + 60 * sat * Math.cos(h));
const g = Math.floor(80 + 160 * light + 60 * sat * Math.cos(h - 2.1));
const bl = Math.floor(80 + 160 * light + 60 * sat * Math.cos(h + 2.1));
return `rgba(${clamp(r,0,255)},${clamp(g,0,255)},${clamp(bl,0,255)},${alpha})`;
}
function drawAxis(cx, cy, scale, time) {
const axes = [
{ p1: [-1.4, 0, 0], p2: [1.4, 0, 0], color: "rgba(255,120,120,0.45)", label: "a*" },
{ p1: [0, -1.4, 0], p2: [0, 1.4, 0], color: "rgba(255,255,255,0.35)", label: "L*" },
{ p1: [0, 0, -1.4], p2: [0, 0, 1.4], color: "rgba(120,160,255,0.45)", label: "b*" }
];
for (const ax of axes) {
const p1 = project(transform(ax.p1, time), cx, cy, scale);
const p2 = project(transform(ax.p2, time), cx, cy, scale);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = ax.color;
ctx.lineWidth = 1.2;
ctx.stroke();
ctx.fillStyle = ax.color;
ctx.font = "13px Arial";
ctx.fillText(ax.label, p2.x + 4, p2.y + 4);
}
}
function drawMeasured(time) {
const cx = canvas.width * 0.43;
const cy = canvas.height * 0.55;
const scale = Math.min(canvas.width, canvas.height) * 0.24;
drawAxis(cx, cy, scale, time);
const items = measuredSamples.map(s => {
const w = labToWorld(s.L, s.a, s.b);
const q = transform(w, time);
const screen = project(q, cx, cy, scale);
return { s, q, screen };
});
items.sort((a, b) => a.q[2] - b.q[2]);
for (const item of items) {
const s = item.s;
const radius = s.outOfGamut ? 3.0 : 2.2;
ctx.beginPath();
ctx.arc(item.screen.x, item.screen.y, radius, 0, Math.PI * 2);
if (s.outOfGamut) {
ctx.fillStyle = "rgba(255,85,65,0.80)";
} else {
ctx.fillStyle = "rgba(110,185,255,0.78)";
}
ctx.fill();
}
if (gamutModel) {
drawModelWire(gamutModel, cx, cy, scale, time, true);
}
}
function drawCLUT(time) {
const cx = canvas.width * 0.76;
const cy = canvas.height * 0.55;
const scale = Math.min(canvas.width, canvas.height) * 0.24;
drawAxis(cx, cy, scale, time);
const skip = Math.max(1, Math.floor(clutData.length / 2500));
const items = [];
for (let i = 0; i < clutData.length; i += skip) {
const p = clutData[i];
const w = labToWorld(p.L, p.a, p.b);
const q = transform(w, time);
const screen = project(q, cx, cy, scale);
items.push({ p, q, screen });
}
items.sort((a, b) => a.q[2] - b.q[2]);
for (const item of items) {
const p = item.p;
ctx.beginPath();
ctx.arc(item.screen.x, item.screen.y, 2.0, 0, Math.PI * 2);
ctx.fillStyle = labColorApprox(p.L, p.a, p.b, 0.76);
ctx.fill();
}
if (gamutModel) {
drawModelWire(gamutModel, cx, cy, scale, time, false);
}
}
function drawModelWire(model, cx, cy, scale, time, showVertices) {
function drawLabLine(points, color, width) {
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const p = points[i];
const w = labToWorld(p.L, p.a, p.b);
const q = transform(w, time);
const s = project(q, cx, cy, scale);
if (i === 0) ctx.moveTo(s.x, s.y);
else ctx.lineTo(s.x, s.y);
}
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.stroke();
}
const poly = model.vertices.map(v => ({
L: v.L,
a: v.a,
b: v.b
}));
if (poly.length > 0) {
drawLabLine(poly.concat([poly[0]]), "rgba(255,255,255,0.76)", 1.8);
}
for (const v of model.vertices) {
drawLabLine([
{ L: model.black.L, a: model.black.a, b: model.black.b },
{ L: v.L, a: v.a, b: v.b },
{ L: model.white.L, a: model.white.a, b: model.white.b }
], "rgba(255,255,255,0.22)", 0.8);
}
if (showVertices) {
const special = [
{ name: "W", ...model.white },
{ name: "K", ...model.black },
...model.vertices
];
for (const p of special) {
const w = labToWorld(p.L, p.a, p.b);
const q = transform(w, time);
const s = project(q, cx, cy, scale);
ctx.beginPath();
ctx.arc(s.x, s.y, p.name === "W" || p.name === "K" ? 5 : 4, 0, Math.PI * 2);
ctx.fillStyle = p.name === "W" || p.name === "K"
? "rgba(255,255,255,0.95)"
: "rgba(255,225,80,0.95)";
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.font = "12px Arial";
ctx.fillText(p.name, s.x + 6, s.y - 4);
}
}
const hueSteps = 96;
const lSteps = 8;
for (let li = 0; li < lSteps; li++) {
const L = lerp(model.Lmin, model.Lmax, li / (lSteps - 1));
const ring = [];
for (let hi = 0; hi <= hueSteps; hi++) {
const theta = (hi / hueSteps) * Math.PI * 2;
const r = model.boundaryRadiusAt(L, theta);
const c = model.neutralCenterAtL(L);
ring.push({
L,
a: c.a + Math.cos(theta) * r,
b: c.b + Math.sin(theta) * r
});
}
drawLabLine(ring, "rgba(157,255,183,0.18)", 0.9);
}
}
function drawDivider() {
ctx.strokeStyle = "rgba(255,255,255,0.13)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(canvas.width * 0.60, 0);
ctx.lineTo(canvas.width * 0.60, canvas.height);
ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.16)";
ctx.font = "40px Arial";
ctx.fillText("→", canvas.width * 0.60 - 13, canvas.height * 0.50);
}
function draw() {
const now = performance.now();
const delta = now - lastFrameTime;
lastFrameTime = now;
if (rotationEnabled) {
rotationTime += delta;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawDivider();
if (measuredSamples.length > 0) {
drawMeasured(rotationTime);
}
if (clutData.length > 0) {
drawCLUT(rotationTime);
}
requestAnimationFrame(draw);
}
// =====================================================
// Build pipeline
// =====================================================
function buildPipeline() {
const params = getParams();
measuredSamples = parseInput(inputText.value);
if (measuredSamples.length < 12) {
statusBox.textContent = "샘플이 너무 적습니다. 최소 12개 이상 필요합니다.";
return;
}
gamutModel = buildGamutModel(measuredSamples, params);
clutData = generateCLUT(measuredSamples, gamutModel, params);
const safeCount = measuredSamples.filter(s => !s.outOfGamut).length;
const oogCount = measuredSamples.length - safeCount;
const vertexText = gamutModel.vertices
.map(v => `${v.name}=RGB(${v.R.toFixed(1)},${v.G.toFixed(1)},${v.B.toFixed(1)}) Lab(${v.L.toFixed(1)},${v.a.toFixed(1)},${v.b.toFixed(1)})`)
.join("\n");
statusBox.textContent =
`측색 샘플: ${measuredSamples.length}개\n` +
`gamut-safe: ${safeCount}개, out-of-gamut/압축 의심: ${oogCount}개\n` +
`CLUT: ${params.clutSize} x ${params.clutSize} x ${params.clutSize} = ${clutData.length}개\n` +
`예상 in-gamut Lab 부피: ${gamutModel.volume.toFixed(1)} Lab³\n` +
`White point: RGB(${gamutModel.white.R.toFixed(1)}, ${gamutModel.white.G.toFixed(1)}, ${gamutModel.white.B.toFixed(1)}) Lab(${gamutModel.white.L.toFixed(2)}, ${gamutModel.white.a.toFixed(2)}, ${gamutModel.white.b.toFixed(2)})\n` +
`Black point: RGB(${gamutModel.black.R.toFixed(1)}, ${gamutModel.black.G.toFixed(1)}, ${gamutModel.black.B.toFixed(1)}) Lab(${gamutModel.black.L.toFixed(2)}, ${gamutModel.black.a.toFixed(2)}, ${gamutModel.black.b.toFixed(2)})\n\n` +
vertexText;
console.clear();
console.log("Measured samples:", measuredSamples);
console.log("Gamut model:", gamutModel);
console.log("A2B CLUT RGB->Lab:", clutData);
}
document.getElementById("loadDemo").addEventListener("click", () => {
inputText.value = demoDataText();
buildPipeline();
});
document.getElementById("build").addEventListener("click", () => {
buildPipeline();
});
document.getElementById("toggleRotation").addEventListener("click", e => {
rotationEnabled = !rotationEnabled;
e.target.textContent = rotationEnabled ? "회전 정지" : "회전 시작";
});
document.getElementById("downloadClut").addEventListener("click", () => {
if (!clutData.length) {
buildPipeline();
}
if (!clutData.length) return;
const csv = clutToCSV(clutData);
downloadText("printer_a2b_rgb_to_lab_clut.csv", csv);
});
document.getElementById("downloadVertices").addEventListener("click", () => {
if (!gamutModel) {
buildPipeline();
}
if (!gamutModel) return;
const csv = verticesToCSV(gamutModel);
downloadText("printer_gamut_vertices_white_black.csv", csv);
});
inputText.value = demoDataText();
getParams();
buildPipeline();
draw();
</script>
</body>
</html><!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Printer ICC A2B PCS CLUT Generator RGB+Lab</title>
<style>
body {
margin: 0;
background: #101012;
color: #eee;
font-family: Arial, sans-serif;
overflow: hidden;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
background: #101012;
}
.panel {
position: fixed;
left: 12px;
top: 12px;
width: 420px;
max-height: calc(100vh - 24px);
overflow: auto;
z-index: 10;
padding: 12px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.74);
box-shadow: 0 0 18px rgba(0, 0, 0, 0.45);
}
.panel h2 {
margin: 0 0 8px 0;
font-size: 16px;
color: #9dffb7;
}
.panel p {
margin: 6px 0;
line-height: 1.45;
color: #ccc;
font-size: 12px;
}
textarea {
width: 100%;
height: 185px;
box-sizing: border-box;
resize: vertical;
background: #19191c;
color: #e8e8e8;
border: 1px solid #333;
border-radius: 8px;
padding: 8px;
font-family: Consolas, monospace;
font-size: 12px;
line-height: 1.4;
}
.buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 8px;
}
button {
border: 0;
border-radius: 8px;
padding: 8px 9px;
font-weight: bold;
cursor: pointer;
background: #f2f2f2;
color: #111;
}
button:hover {
background: #dcdcdc;
}
.controls {
margin-top: 10px;
display: grid;
grid-template-columns: 145px 160px 54px;
gap: 5px 8px;
align-items: center;
font-size: 12px;
}
input[type="range"] {
width: 160px;
}
.info {
margin-top: 10px;
padding: 8px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
font-size: 12px;
color: #ddd;
white-space: pre-line;
line-height: 1.45;
}
.legend {
position: fixed;
right: 14px;
top: 14px;
z-index: 10;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.62);
font-size: 13px;
line-height: 1.5;
color: #ddd;
}
.titleLeft,
.titleRight {
position: fixed;
top: 14px;
z-index: 6;
padding: 7px 10px;
border-radius: 9px;
background: rgba(0, 0, 0, 0.55);
font-weight: bold;
font-size: 15px;
}
.titleLeft {
left: 455px;
color: #8ecbff;
}
.titleRight {
right: 345px;
color: #9dffb7;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="titleLeft">측정 RGB/Lab 미리보기</div>
<div class="titleRight">생성된 A2B CLUT Lab 미리보기</div>
<div class="legend">
<b>표시</b><br />
흰색 선: 최종 gamut-guard 표면<br />
붉은 선: raw 측색 외곽 추정<br />
큰 흰 점: RGB/CMY 꼭짓점<br />
노란 점: White / Black point<br />
<br />
CLUT: RGB 16 × 16 × 16<br />
RGB 범위: 0 ~ 100<br />
출력: RGB, Lab CSV/JSON
</div>
<div class="panel">
<h2>프린터 ICC A2B → PCS Lab CLUT 생성</h2>
<p>
입력 형식은 한 줄마다 <b>R G B L a b</b> 입니다.
RGB는 0~100 범위, Lab은 측색 Lab입니다.
</p>
<textarea id="dataInput"></textarea>
<div class="buttons">
<button id="loadDemo">데모 RGB/Lab 생성</button>
<button id="generate">A2B CLUT 생성</button>
<button id="toggleRotation">회전 정지</button>
<button id="downloadCsv">CSV 다운로드</button>
<button id="downloadJson">JSON 다운로드</button>
<button id="copyJson">JSON 복사</button>
</div>
<div class="controls">
<label>rgbFitSmooth</label>
<input id="rgbFitSmooth" type="range" min="0.04" max="0.35" step="0.01" value="0.16" />
<span id="rgbFitSmoothValue">0.16</span>
<label>edgeSmooth</label>
<input id="edgeSmooth" type="range" min="1" max="12" step="1" value="5" />
<span id="edgeSmoothValue">5</span>
<label>antiShrink</label>
<input id="antiShrink" type="range" min="0" max="1" step="0.05" value="0.88" />
<span id="antiShrinkValue">0.88</span>
<label>gamutGuard</label>
<input id="gamutGuard" type="range" min="0" max="1" step="0.05" value="0.90" />
<span id="gamutGuardValue">0.90</span>
<label>edgeProtect</label>
<input id="edgeProtect" type="range" min="1" max="8" step="1" value="3" />
<span id="edgeProtectValue">3</span>
<label>outlineFollow</label>
<input id="outlineFollow" type="range" min="0" max="1" step="0.05" value="0.55" />
<span id="outlineFollowValue">0.55</span>
<label>measurementFollow</label>
<input id="measurementFollow" type="range" min="0" max="1" step="0.05" value="0.72" />
<span id="measurementFollowValue">0.72</span>
<label>radialPower</label>
<input id="radialPower" type="range" min="0.65" max="1.40" step="0.05" value="0.92" />
<span id="radialPowerValue">0.92</span>
</div>
<div id="info" class="info">준비 중...</div>
</div>
<script>
// ============================================================
// 0. Canvas
// ============================================================
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener("resize", resize);
resize();
// ============================================================
// 1. Basic utilities
// ============================================================
function clamp(x, a, b) {
return Math.max(a, Math.min(b, x));
}
function lerp(a, b, t) {
return a * (1 - t) + b * t;
}
function smoothstep(a, b, x) {
if (a === b) return x < a ? 0 : 1;
const t = clamp((x - a) / (b - a), 0, 1);
return t * t * (3 - 2 * t);
}
function normDeg(h) {
h = h % 360;
if (h < 0) h += 360;
return h;
}
function angleDiffDeg(a, b) {
let d = normDeg(a) - normDeg(b);
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
}
function angleDistanceDeg(a, b) {
return Math.abs(angleDiffDeg(a, b));
}
function circularLerpDeg(a, b, t) {
return normDeg(a + angleDiffDeg(b, a) * t);
}
function percentile(values, q) {
const arr = values
.filter(Number.isFinite)
.slice()
.sort((a, b) => a - b);
if (arr.length === 0) return NaN;
if (arr.length === 1) return arr[0];
const pos = (arr.length - 1) * q;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
const t = pos - lo;
return arr[lo] * (1 - t) + arr[hi] * t;
}
function median(values) {
return percentile(values, 0.5);
}
function rand(seed) {
const x = Math.sin(seed * 12.9898) * 43758.5453123;
return x - Math.floor(x);
}
function rgbDist2(a, b) {
const dr = (a.r - b.r) / 100;
const dg = (a.g - b.g) / 100;
const db = (a.b - b.b) / 100;
return dr * dr + dg * dg + db * db;
}
function solveLinearSystem(A, b) {
const n = A.length;
const M = A.map((row, i) => [...row, b[i]]);
for (let col = 0; col < n; col++) {
let pivot = col;
for (let row = col + 1; row < n; row++) {
if (Math.abs(M[row][col]) > Math.abs(M[pivot][col])) {
pivot = row;
}
}
if (Math.abs(M[pivot][col]) < 1e-12) {
return null;
}
[M[col], M[pivot]] = [M[pivot], M[col]];
const div = M[col][col];
for (let k = col; k <= n; k++) {
M[col][k] /= div;
}
for (let row = 0; row < n; row++) {
if (row === col) continue;
const factor = M[row][col];
for (let k = col; k <= n; k++) {
M[row][k] -= factor * M[col][k];
}
}
}
return M.map(row => row[n]);
}
// ============================================================
// 2. Lab / LCh
// ============================================================
function labToLch(lab, centerAB) {
const da = lab.a - centerAB.a;
const db = lab.b - centerAB.b;
const C = Math.sqrt(da * da + db * db);
const h = normDeg(Math.atan2(db, da) * 180 / Math.PI);
return {
L: lab.L,
a: lab.a,
b: lab.b,
C,
h
};
}
function lchToLab(L, C, h, centerAB) {
const rad = h * Math.PI / 180;
return {
L,
a: centerAB.a + C * Math.cos(rad),
b: centerAB.b + C * Math.sin(rad)
};
}
function labCss(lab, centerAB) {
const lch = labToLch(lab, centerAB || { a: 0, b: 0 });
const light = clamp(lab.L, 12, 90);
const sat = clamp(30 + lch.C * 0.72, 16, 92);
return `hsl(${lch.h.toFixed(1)}deg ${sat.toFixed(1)}% ${light.toFixed(1)}%)`;
}
// ============================================================
// 3. RGB / HSL
// ============================================================
function rgbToHsl(r, g, b) {
r /= 100;
g /= 100;
b /= 100;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h *= 60;
}
return {
h: normDeg(h),
s: clamp(s, 0, 1),
l: clamp(l, 0, 1)
};
}
// ============================================================
// 4. Demo RGB/Lab printer samples
// ============================================================
const DEMO_ANCHORS = [
{ name: "R", rgbHue: 0, labHue: 37, L: 50.5, C: 73 },
{ name: "Y", rgbHue: 60, labHue: 94, L: 86.0, C: 88 },
{ name: "G", rgbHue: 120, labHue: 142, L: 57.0, C: 66 },
{ name: "C", rgbHue: 180, labHue: 213, L: 62.0, C: 48 },
{ name: "B", rgbHue: 240, labHue: 284, L: 34.5, C: 56 },
{ name: "M", rgbHue: 300, labHue: 330, L: 52.0, C: 78 }
];
function interpolateByRgbHue(anchors, rgbHue, key) {
const h = normDeg(rgbHue);
const ordered = anchors.slice().sort((a, b) => a.rgbHue - b.rgbHue);
for (let i = 0; i < ordered.length; i++) {
const a = ordered[i];
const b = ordered[(i + 1) % ordered.length];
let ah = a.rgbHue;
let bh = b.rgbHue;
if (i === ordered.length - 1) bh += 360;
let hh = h;
if (hh < ah) hh += 360;
if (hh >= ah && hh <= bh) {
const t = (hh - ah) / Math.max(1e-9, bh - ah);
if (key === "labHue") {
let ha = a.labHue;
let hb = b.labHue;
while (hb <= ha) hb += 360;
return normDeg(lerp(ha, hb, t));
}
return lerp(a[key], b[key], t);
}
}
return ordered[0][key];
}
function demoLabFromRgb(r, g, b, seed) {
const blackL = 7.8;
const whiteL = 95.4;
const hsl = rgbToHsl(r, g, b);
const labHue = interpolateByRgbHue(DEMO_ANCHORS, hsl.h, "labHue");
const waistL = interpolateByRgbHue(DEMO_ANCHORS, hsl.h, "L");
const waistC = interpolateByRgbHue(DEMO_ANCHORS, hsl.h, "C");
const neutralL = lerp(blackL, whiteL, hsl.l);
let coloredL;
if (hsl.l <= 0.5) {
const t = smoothstep(0, 0.5, hsl.l);
coloredL = lerp(blackL, waistL, t);
} else {
const t = smoothstep(0.5, 1, hsl.l);
coloredL = lerp(waistL, whiteL, t);
}
let L = lerp(neutralL, coloredL, Math.pow(hsl.s, 0.72));
let Cmax;
if (L <= waistL) {
const t = smoothstep(blackL, waistL, L);
Cmax = waistC * Math.pow(t, 0.76);
} else {
const t = smoothstep(waistL, whiteL, L);
Cmax = waistC * Math.pow(1 - t, 0.70);
}
let C = Cmax * Math.pow(hsl.s, 0.90);
// 실제 프린터에서 존재할 법한 압축 / 오목 현상
const dentR =
Math.exp(-0.5 * Math.pow(angleDistanceDeg(labHue, 35) / 15, 2)) *
Math.exp(-0.5 * Math.pow((L - 45) / 11, 2));
const dentY =
Math.exp(-0.5 * Math.pow(angleDistanceDeg(labHue, 105) / 18, 2)) *
Math.exp(-0.5 * Math.pow((L - 78) / 12, 2));
const dentM =
Math.exp(-0.5 * Math.pow(angleDistanceDeg(labHue, 320) / 17, 2)) *
Math.exp(-0.5 * Math.pow((L - 45) / 10, 2));
const dent = Math.max(dentR, dentY, dentM);
if (hsl.s > 0.72) {
C *= 1 - 0.30 * dent;
}
// 일부 영역은 실제 gamut이 polygon보다 돌출
const bump =
Math.exp(-0.5 * Math.pow(angleDistanceDeg(labHue, 78) / 15, 2)) *
Math.exp(-0.5 * Math.pow((L - 82) / 10, 2));
C *= 1 + 0.12 * bump;
// 약간의 장치 비선형
L += (r - b) * 0.006 * hsl.s;
C *= 0.98 + 0.04 * Math.sin((r + g * 0.7 + b * 0.35) * 0.04);
// 측정 노이즈
const n1 = rand(seed + 1) - 0.5;
const n2 = rand(seed + 2) - 0.5;
const n3 = rand(seed + 3) - 0.5;
L += n1 * 0.75;
C += n2 * 1.0;
const h = labHue + n3 * 1.2;
const lab = lchToLab(L, Math.max(0, C), h, { a: 0, b: 0 });
return lab;
}
function makeDemoText() {
const levels = [0, 8, 18, 32, 48, 64, 80, 92, 100];
const lines = [];
let seed = 10;
for (const r of levels) {
for (const g of levels) {
for (const b of levels) {
const lab = demoLabFromRgb(r, g, b, seed++);
lines.push(
[
r.toFixed(1),
g.toFixed(1),
b.toFixed(1),
lab.L.toFixed(3),
lab.a.toFixed(3),
lab.b.toFixed(3)
].join(" ")
);
}
}
}
// 꼭짓점과 중성축 보강 샘플
const extras = [
[0, 0, 0],
[100, 100, 100],
[100, 0, 0],
[100, 100, 0],
[0, 100, 0],
[0, 100, 100],
[0, 0, 100],
[100, 0, 100]
];
for (const [r, g, b] of extras) {
for (let i = 0; i < 6; i++) {
const rr = clamp(r + (rand(seed++) - 0.5) * 2.0, 0, 100);
const gg = clamp(g + (rand(seed++) - 0.5) * 2.0, 0, 100);
const bb = clamp(b + (rand(seed++) - 0.5) * 2.0, 0, 100);
const lab = demoLabFromRgb(rr, gg, bb, seed++);
lines.push(
[
rr.toFixed(2),
gg.toFixed(2),
bb.toFixed(2),
lab.L.toFixed(3),
lab.a.toFixed(3),
lab.b.toFixed(3)
].join(" ")
);
}
}
return lines.join("\n");
}
// ============================================================
// 5. Parse input
// ============================================================
function parseInput(text) {
const samples = [];
const lines = text
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith("#"));
for (const line of lines) {
const nums = line
.replace(/[,\t;]+/g, " ")
.split(/\s+/)
.map(Number)
.filter(Number.isFinite);
if (nums.length >= 6) {
const r = clamp(nums[0], 0, 100);
const g = clamp(nums[1], 0, 100);
const b = clamp(nums[2], 0, 100);
samples.push({
r,
g,
b,
L: nums[3],
a: nums[4],
bLab: nums[5],
lab: {
L: nums[3],
a: nums[4],
b: nums[5]
}
});
}
}
return samples;
}
// ============================================================
// 6. Detect white, black, neutral, RGB/CMY vertices
// ============================================================
function sampleChroma(sample, centerAB) {
const da = sample.lab.a - centerAB.a;
const db = sample.lab.b - centerAB.b;
return Math.sqrt(da * da + db * db);
}
function nearestRgbSample(samples, target) {
return samples
.slice()
.sort((a, b) => rgbDist2(a, target) - rgbDist2(b, target))[0];
}
function detectWhiteBlack(samples) {
const whiteTarget = { r: 100, g: 100, b: 100 };
const blackTarget = { r: 0, g: 0, b: 0 };
let white = nearestRgbSample(samples, whiteTarget);
let black = nearestRgbSample(samples, blackTarget);
const whiteDist = Math.sqrt(rgbDist2(white, whiteTarget)) * 100;
const blackDist = Math.sqrt(rgbDist2(black, blackTarget)) * 100;
if (whiteDist > 18) {
white = samples.slice().sort((a, b) => b.L - a.L)[0];
}
if (blackDist > 18) {
black = samples.slice().sort((a, b) => a.L - b.L)[0];
}
return {
white,
black
};
}
function estimateNeutralAB(samples, white, black) {
const grayLike = samples.filter(s => {
const max = Math.max(s.r, s.g, s.b);
const min = Math.min(s.r, s.g, s.b);
return max - min <= 8;
});
const use = grayLike.length >= 5
? grayLike
: samples
.slice()
.sort((a, b) => {
const da = Math.abs(a.r - a.g) + Math.abs(a.g - a.b);
const db = Math.abs(b.r - b.g) + Math.abs(b.g - b.b);
return da - db;
})
.slice(0, Math.max(5, Math.floor(samples.length * 0.08)));
const aMedian = median(use.map(s => s.lab.a));
const bMedian = median(use.map(s => s.lab.b));
return {
a: Number.isFinite(aMedian) ? aMedian : (white.lab.a + black.lab.a) * 0.5,
b: Number.isFinite(bMedian) ? bMedian : (white.lab.b + black.lab.b) * 0.5
};
}
function detectVertices(samples, centerAB) {
const targets = [
{ name: "R", rgbHue: 0, r: 100, g: 0, b: 0 },
{ name: "Y", rgbHue: 60, r: 100, g: 100, b: 0 },
{ name: "G", rgbHue: 120, r: 0, g: 100, b: 0 },
{ name: "C", rgbHue: 180, r: 0, g: 100, b: 100 },
{ name: "B", rgbHue: 240, r: 0, g: 0, b: 100 },
{ name: "M", rgbHue: 300, r: 100, g: 0, b: 100 }
];
const vertices = [];
for (const t of targets) {
const ranked = samples
.map(s => {
const d = Math.sqrt(rgbDist2(s, t)) * 100;
const C = sampleChroma(s, centerAB);
return {
sample: s,
d,
C,
score: d - C * 0.08
};
})
.sort((a, b) => a.score - b.score);
let chosen = ranked[0];
const near = ranked.filter(x => x.d <= Math.max(10, chosen.d + 8));
if (near.length > 0) {
chosen = near.sort((a, b) => b.C - a.C)[0];
}
const lab = chosen.sample.lab;
const lch = labToLch(lab, centerAB);
vertices.push({
name: t.name,
rgbHue: t.rgbHue,
targetRGB: [t.r, t.g, t.b],
sourceRGB: [chosen.sample.r, chosen.sample.g, chosen.sample.b],
L: lab.L,
a: lab.a,
b: lab.b,
C: lch.C,
h: lch.h
});
}
return vertices;
}
// ============================================================
// 7. Polygon waist fallback
// ============================================================
function rayPolygonIntersectionC(hue, vertices, centerAB) {
const dir = {
x: Math.cos(hue * Math.PI / 180),
y: Math.sin(hue * Math.PI / 180)
};
const poly = vertices.map(v => ({
x: v.a - centerAB.a,
y: v.b - centerAB.b,
L: v.L,
C: v.C,
h: v.h,
name: v.name
}));
let bestT = Infinity;
let bestL = vertices[0].L;
let bestEdge = "";
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
const ex = q.x - p.x;
const ey = q.y - p.y;
const det = dir.x * (-ey) - dir.y * (-ex);
if (Math.abs(det) < 1e-9) continue;
const px = p.x;
const py = p.y;
const t = (px * (-ey) - py * (-ex)) / det;
const u = (dir.x * py - dir.y * px) / det;
if (t >= 0 && u >= -1e-6 && u <= 1 + 1e-6) {
if (t < bestT) {
bestT = t;
bestL = lerp(p.L, q.L, clamp(u, 0, 1));
bestEdge = p.name + "-" + q.name;
}
}
}
if (!Number.isFinite(bestT)) {
const nearest = vertices
.slice()
.sort((a, b) => angleDistanceDeg(a.h, hue) - angleDistanceDeg(b.h, hue))[0];
return {
C: nearest.C,
L: nearest.L,
edge: nearest.name
};
}
return {
C: bestT,
L: bestL,
edge: bestEdge
};
}
// ============================================================
// 8. Grid helpers
// ============================================================
function createGrid(rows, cols, fill = NaN) {
return Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => fill)
);
}
function fillMissingGrid(grid, fallbackFn) {
const rows = grid.length;
const cols = grid[0].length;
const out = createGrid(rows, cols, 0);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
out[r][c] = Number.isFinite(grid[r][c])
? grid[r][c]
: fallbackFn(r, c);
}
}
return out;
}
function smoothGridCyclic(grid, passes) {
const rows = grid.length;
const cols = grid[0].length;
let g = grid.map(row => row.slice());
for (let pass = 0; pass < passes; pass++) {
const n = createGrid(rows, cols, 0);
for (let r = 0; r < rows; r++) {
const r0 = Math.max(0, r - 1);
const r1 = Math.min(rows - 1, r + 1);
for (let c = 0; c < cols; c++) {
const c0 = (c - 1 + cols) % cols;
const c1 = (c + 1) % cols;
n[r][c] =
g[r][c] * 0.42 +
g[r0][c] * 0.14 +
g[r1][c] * 0.14 +
g[r][c0] * 0.15 +
g[r][c1] * 0.15;
}
}
g = n;
}
return g;
}
function maxGridNeighborhood(grid, radiusL, radiusH) {
const rows = grid.length;
const cols = grid[0].length;
const out = createGrid(rows, cols, 0);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
let m = -Infinity;
for (let dr = -radiusL; dr <= radiusL; dr++) {
const rr = clamp(r + dr, 0, rows - 1);
for (let dc = -radiusH; dc <= radiusH; dc++) {
const cc = (c + dc + cols) % cols;
m = Math.max(m, grid[rr][cc]);
}
}
out[r][c] = m;
}
}
return out;
}
function bilinearCyclic(grid, lNorm, hue) {
const rows = grid.length;
const cols = grid[0].length;
const y = clamp(lNorm, 0, 1) * (rows - 1);
const r0 = Math.floor(y);
const r1 = Math.min(rows - 1, r0 + 1);
const ty = y - r0;
const x = normDeg(hue) / 360 * cols;
const c0 = Math.floor(x) % cols;
const c1 = (c0 + 1) % cols;
const tx = x - Math.floor(x);
const a = lerp(grid[r0][c0], grid[r0][c1], tx);
const b = lerp(grid[r1][c0], grid[r1][c1], tx);
return lerp(a, b, ty);
}
// ============================================================
// 9. Gamut surface model
// ============================================================
function buildGamutModel(samples, params) {
const wb = detectWhiteBlack(samples);
const white = wb.white;
const black = wb.black;
const centerAB = estimateNeutralAB(samples, white, black);
const vertices = detectVertices(samples, centerAB);
const lchList = samples.map(s => ({
...labToLch(s.lab, centerAB),
r: s.r,
g: s.g,
bRgb: s.b,
sample: s
}));
const Lmin = Math.min(black.L, white.L);
const Lmax = Math.max(black.L, white.L);
const Lrange = Math.max(1e-6, Lmax - Lmin);
const L_BINS = 35;
const H_BINS = 72;
const bins = Array.from({ length: L_BINS }, () =>
Array.from({ length: H_BINS }, () => [])
);
for (const p of lchList) {
if (p.C < 2.0) continue;
const lNorm = clamp((p.L - Lmin) / Lrange, 0, 1);
const li = clamp(Math.round(lNorm * (L_BINS - 1)), 0, L_BINS - 1);
const hi = Math.floor(normDeg(p.h) / 360 * H_BINS) % H_BINS;
bins[li][hi].push(p.C);
}
const rawGrid = createGrid(L_BINS, H_BINS, NaN);
const countGrid = createGrid(L_BINS, H_BINS, 0);
for (let l = 0; l < L_BINS; l++) {
for (let h = 0; h < H_BINS; h++) {
const values = bins[l][h];
countGrid[l][h] = values.length;
if (values.length >= 2) {
rawGrid[l][h] = percentile(values, 0.96);
} else if (values.length === 1) {
rawGrid[l][h] = values[0];
}
}
}
function fallbackC(lIndex, hIndex) {
const lNorm = lIndex / (L_BINS - 1);
const L = Lmin + lNorm * Lrange;
const hue = hIndex / H_BINS * 360;
const poly = rayPolygonIntersectionC(hue, vertices, centerAB);
const waistC = poly.C;
const waistL = poly.L;
if (L <= waistL) {
const t = smoothstep(Lmin, waistL, L);
return waistC * Math.pow(t, 0.78);
}
const t = smoothstep(waistL, Lmax, L);
return waistC * Math.pow(1 - t, 0.72);
}
const fallbackGrid = createGrid(L_BINS, H_BINS, 0);
for (let l = 0; l < L_BINS; l++) {
for (let h = 0; h < H_BINS; h++) {
fallbackGrid[l][h] = fallbackC(l, h);
}
}
const filledRaw = fillMissingGrid(rawGrid, (l, h) => fallbackGrid[l][h]);
const guardedRaw = createGrid(L_BINS, H_BINS, 0);
for (let l = 0; l < L_BINS; l++) {
for (let h = 0; h < H_BINS; h++) {
const raw = filledRaw[l][h];
const fallback = fallbackGrid[l][h];
const hasData = countGrid[l][h] >= 1;
if (!hasData) {
guardedRaw[l][h] = fallback;
continue;
}
// polygon보다 더 튀어나온 측정값은 허용.
// polygon보다 안쪽으로 움푹 들어간 값은 antiShrink로 복원.
if (raw >= fallback) {
guardedRaw[l][h] = raw;
} else {
const dentRatio = (fallback - raw) / Math.max(fallback, 1e-6);
const restore = clamp(params.antiShrink * smoothstep(0.06, 0.30, dentRatio), 0, 1);
guardedRaw[l][h] = lerp(raw, fallback, restore);
}
}
}
const radius = Math.max(1, Math.round(params.edgeProtect));
let envelope = maxGridNeighborhood(guardedRaw, radius, radius);
envelope = smoothGridCyclic(envelope, 2);
const preSmooth = createGrid(L_BINS, H_BINS, 0);
for (let l = 0; l < L_BINS; l++) {
for (let h = 0; h < H_BINS; h++) {
const raw = guardedRaw[l][h];
const env = envelope[l][h];
const shrink = clamp(
(env - raw) / Math.max(env * 0.35, 1e-6),
0,
1
);
const localGuard = clamp(params.gamutGuard * shrink, 0, 1);
preSmooth[l][h] = lerp(raw, env, localGuard);
}
}
const smoothed = smoothGridCyclic(preSmooth, params.edgeSmooth);
const finalGrid = createGrid(L_BINS, H_BINS, 0);
for (let l = 0; l < L_BINS; l++) {
for (let h = 0; h < H_BINS; h++) {
const raw = preSmooth[l][h];
const smooth = smoothed[l][h];
const fallback = fallbackGrid[l][h];
// smoothing 때문에 외곽이 과도하게 줄어드는 것 방지
finalGrid[l][h] = Math.max(
lerp(smooth, raw, params.outlineFollow),
fallback * 0.88
);
}
}
// Black / White point는 chroma 0으로 수렴
for (let h = 0; h < H_BINS; h++) {
finalGrid[0][h] = 0;
finalGrid[L_BINS - 1][h] = 0;
}
const maxC = Math.max(...finalGrid.flat());
function surfaceCAt(L, hue) {
const lNorm = clamp((L - Lmin) / Lrange, 0, 1);
return clamp(bilinearCyclic(finalGrid, lNorm, hue), 0, maxC * 1.10);
}
const volume = estimateGamutVolume({
finalGrid,
Lmin,
Lmax,
Lrange
});
const model = {
white,
black,
centerAB,
vertices,
lchList,
Lmin,
Lmax,
Lrange,
rawGrid: filledRaw,
fallbackGrid,
finalGrid,
maxC,
surfaceCAt,
volume
};
model.rgbHueToLabHue = makeRgbHueMapper(vertices);
model.waistLAtRgbHue = makeWaistLMapper(vertices);
return model;
}
function estimateGamutVolume(model) {
const grid = model.finalGrid;
const rows = grid.length;
const cols = grid[0].length;
const dL = model.Lrange / Math.max(1, rows - 1);
const dTheta = 2 * Math.PI / cols;
let volume = 0;
for (let r = 0; r < rows; r++) {
let area = 0;
for (let c = 0; c < cols; c++) {
const c1 = (c + 1) % cols;
const R0 = grid[r][c];
const R1 = grid[r][c1];
area += 0.5 * R0 * R1 * Math.sin(dTheta);
}
volume += Math.max(0, area) * dL;
}
return volume;
}
// ============================================================
// 10. RGB hue mappers from detected vertices
// ============================================================
function makeRgbHueMapper(vertices) {
const ordered = vertices
.map(v => ({
rgbHue: v.rgbHue,
labHue: v.h
}))
.sort((a, b) => a.rgbHue - b.rgbHue);
const labUnwrapped = [];
for (let i = 0; i < ordered.length; i++) {
let h = ordered[i].labHue;
if (i === 0) {
labUnwrapped.push(h);
} else {
while (h <= labUnwrapped[i - 1]) h += 360;
labUnwrapped.push(h);
}
}
return function mapHue(rgbHue) {
const h = normDeg(rgbHue);
for (let i = 0; i < ordered.length; i++) {
const a = ordered[i];
const b = ordered[(i + 1) % ordered.length];
let ah = a.rgbHue;
let bh = b.rgbHue;
if (i === ordered.length - 1) bh += 360;
let hh = h;
if (hh < ah) hh += 360;
if (hh >= ah && hh <= bh) {
const t = (hh - ah) / Math.max(1e-9, bh - ah);
let ha = labUnwrapped[i];
let hb = labUnwrapped[(i + 1) % ordered.length];
if (i === ordered.length - 1) hb += 360;
return normDeg(lerp(ha, hb, t));
}
}
return normDeg(labUnwrapped[0]);
};
}
function makeWaistLMapper(vertices) {
const ordered = vertices
.map(v => ({
rgbHue: v.rgbHue,
L: v.L
}))
.sort((a, b) => a.rgbHue - b.rgbHue);
return function waistLAtRgbHue(rgbHue) {
const h = normDeg(rgbHue);
for (let i = 0; i < ordered.length; i++) {
const a = ordered[i];
const b = ordered[(i + 1) % ordered.length];
let ah = a.rgbHue;
let bh = b.rgbHue;
if (i === ordered.length - 1) bh += 360;
let hh = h;
if (hh < ah) hh += 360;
if (hh >= ah && hh <= bh) {
const t = (hh - ah) / Math.max(1e-9, bh - ah);
return lerp(a.L, b.L, t);
}
}
return ordered[0].L;
};
}
// ============================================================
// 11. Local RGB → Lab MLS
// ============================================================
function basisRgb(q) {
const r = q.r / 100;
const g = q.g / 100;
const b = q.b / 100;
return [
1,
r,
g,
b,
r * r,
g * g,
b * b,
r * g,
r * b,
g * b
];
}
function predictLabLocalMLS(q, samples, params) {
const sigma = params.rgbFitSmooth;
const ridge = 1e-4;
const ranked = samples
.map(s => {
const dr = (s.r - q.r) / 100;
const dg = (s.g - q.g) / 100;
const db = (s.b - q.b) / 100;
const d2 = dr * dr + dg * dg + db * db;
return {
sample: s,
d2
};
})
.sort((a, b) => a.d2 - b.d2)
.slice(0, Math.min(56, samples.length));
const nBasis = 10;
const A = Array.from({ length: nBasis }, () => Array(nBasis).fill(0));
const bL = Array(nBasis).fill(0);
const ba = Array(nBasis).fill(0);
const bb = Array(nBasis).fill(0);
let totalW = 0;
for (const item of ranked) {
const s = item.sample;
const phi = basisRgb(s);
const d = Math.sqrt(item.d2);
const w = Math.exp(-0.5 * Math.pow(d / Math.max(0.015, sigma), 2));
totalW += w;
for (let i = 0; i < nBasis; i++) {
bL[i] += w * phi[i] * s.L;
ba[i] += w * phi[i] * s.a;
bb[i] += w * phi[i] * s.bLab;
for (let j = 0; j < nBasis; j++) {
A[i][j] += w * phi[i] * phi[j];
}
}
}
if (totalW < 1e-8) {
return ranked[0].sample.lab;
}
for (let i = 0; i < nBasis; i++) {
A[i][i] += ridge;
}
const coefL = solveLinearSystem(A.map(row => row.slice()), bL);
const coefa = solveLinearSystem(A.map(row => row.slice()), ba);
const coefb = solveLinearSystem(A.map(row => row.slice()), bb);
if (!coefL || !coefa || !coefb) {
let sw = 0;
let L = 0;
let a = 0;
let b = 0;
for (const item of ranked.slice(0, 24)) {
const w = 1 / Math.max(1e-7, item.d2);
sw += w;
L += w * item.sample.L;
a += w * item.sample.a;
b += w * item.sample.bLab;
}
return {
L: L / sw,
a: a / sw,
b: b / sw
};
}
const qBasis = basisRgb(q);
function dot(coef) {
let v = 0;
for (let i = 0; i < nBasis; i++) {
v += coef[i] * qBasis[i];
}
return v;
}
return {
L: dot(coefL),
a: dot(coefa),
b: dot(coefb)
};
}
// ============================================================
// 12. Gamut guarded A2B CLUT generation
// ============================================================
function rgbToA2BLab(q, samples, model, params) {
const local = predictLabLocalMLS(q, samples, params);
const hsl = rgbToHsl(q.r, q.g, q.b);
const localLch = labToLch(local, model.centerAB);
// RGB hue에서 검출된 RGB/CMY 꼭짓점의 Lab hue로 매핑
const mappedHue = model.rgbHueToLabHue(hsl.h);
const hueBlend = clamp((1 - params.measurementFollow) * Math.pow(hsl.s, 0.85), 0, 0.45);
const h = circularLerpDeg(localLch.h, mappedHue, hueBlend);
const L = clamp(local.L, model.Lmin, model.Lmax);
const Cmax = model.surfaceCAt(L, h);
const radialTarget = Cmax * Math.pow(hsl.s, params.radialPower);
let C = localLch.C;
// 고채도 영역에서 측정 MLS가 오목하게 들어가면 gamut surface 쪽으로 복원
if (C < radialTarget) {
const edgeStrength = Math.pow(hsl.s, 1.65);
const restore = clamp(params.antiShrink * edgeStrength * (1 - params.measurementFollow * 0.35), 0, 1);
C = lerp(C, radialTarget, restore);
}
// 표면 밖으로 과도하게 나가는 경우에는 안쪽으로 제한
C = Math.min(C, Cmax);
// 중성축은 측정값 우선
const neutralKeep = smoothstep(0.02, 0.16, hsl.s);
C = lerp(localLch.C, C, neutralKeep);
const guarded = lchToLab(L, C, h, model.centerAB);
// L은 측정 MLS를 우선 유지
guarded.L = L;
return guarded;
}
function buildCLUT(samples, model, params) {
const size = 16;
const data = [];
for (let ri = 0; ri < size; ri++) {
for (let gi = 0; gi < size; gi++) {
for (let bi = 0; bi < size; bi++) {
const r = ri / (size - 1) * 100;
const g = gi / (size - 1) * 100;
const b = bi / (size - 1) * 100;
const lab = rgbToA2BLab({ r, g, b }, samples, model, params);
data.push({
r,
g,
b,
L: lab.L,
a: lab.a,
bLab: lab.b
});
}
}
}
return {
size,
data
};
}
// ============================================================
// 13. Download
// ============================================================
function downloadText(filename, text, mime) {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function clutToCSV(clut) {
const lines = ["R,G,B,L,a,b"];
for (const p of clut.data) {
lines.push([
p.r.toFixed(6),
p.g.toFixed(6),
p.b.toFixed(6),
p.L.toFixed(6),
p.a.toFixed(6),
p.bLab.toFixed(6)
].join(","));
}
return lines.join("\n");
}
function clutToJSON(state) {
return JSON.stringify({
description: "Printer ICC A2B PCS Lab CLUT generated from measured RGB/Lab data",
clutSize: state.clut.size,
rgbRange: [0, 100],
whitePoint: {
RGB: [state.model.white.r, state.model.white.g, state.model.white.b],
Lab: [state.model.white.L, state.model.white.a, state.model.white.bLab]
},
blackPoint: {
RGB: [state.model.black.r, state.model.black.g, state.model.black.b],
Lab: [state.model.black.L, state.model.black.a, state.model.black.bLab]
},
neutralAB: state.model.centerAB,
estimatedGamutVolumeLab3: state.model.volume,
vertices: state.model.vertices,
clut: state.clut.data.map(p => ({
RGB: [
Number(p.r.toFixed(6)),
Number(p.g.toFixed(6)),
Number(p.b.toFixed(6))
],
Lab: [
Number(p.L.toFixed(6)),
Number(p.a.toFixed(6)),
Number(p.bLab.toFixed(6))
]
}))
}, null, 2);
}
// ============================================================
// 14. UI state
// ============================================================
const dataInput = document.getElementById("dataInput");
const info = document.getElementById("info");
const controls = {
rgbFitSmooth: document.getElementById("rgbFitSmooth"),
edgeSmooth: document.getElementById("edgeSmooth"),
antiShrink: document.getElementById("antiShrink"),
gamutGuard: document.getElementById("gamutGuard"),
edgeProtect: document.getElementById("edgeProtect"),
outlineFollow: document.getElementById("outlineFollow"),
measurementFollow: document.getElementById("measurementFollow"),
radialPower: document.getElementById("radialPower")
};
const valueEls = {
rgbFitSmooth: document.getElementById("rgbFitSmoothValue"),
edgeSmooth: document.getElementById("edgeSmoothValue"),
antiShrink: document.getElementById("antiShrinkValue"),
gamutGuard: document.getElementById("gamutGuardValue"),
edgeProtect: document.getElementById("edgeProtectValue"),
outlineFollow: document.getElementById("outlineFollowValue"),
measurementFollow: document.getElementById("measurementFollowValue"),
radialPower: document.getElementById("radialPowerValue")
};
function getParams() {
const params = {
rgbFitSmooth: Number(controls.rgbFitSmooth.value),
edgeSmooth: Number(controls.edgeSmooth.value),
antiShrink: Number(controls.antiShrink.value),
gamutGuard: Number(controls.gamutGuard.value),
edgeProtect: Number(controls.edgeProtect.value),
outlineFollow: Number(controls.outlineFollow.value),
measurementFollow: Number(controls.measurementFollow.value),
radialPower: Number(controls.radialPower.value)
};
for (const key of Object.keys(params)) {
valueEls[key].textContent = String(params[key]);
}
return params;
}
let state = {
samples: [],
model: null,
clut: null
};
function generateAll() {
const params = getParams();
const samples = parseInput(dataInput.value);
if (samples.length < 20) {
info.textContent = "RGB/Lab 측정 데이터가 너무 적습니다. 최소 20개 이상을 권장합니다.";
return;
}
const model = buildGamutModel(samples, params);
const clut = buildCLUT(samples, model, params);
state = {
samples,
model,
clut
};
const vertexText = model.vertices.map(v => {
return `${v.name}: sourceRGB=[${v.sourceRGB.map(x => x.toFixed(1)).join(", ")}], ` +
`Lab=[${v.L.toFixed(2)}, ${v.a.toFixed(2)}, ${v.b.toFixed(2)}], ` +
`C=${v.C.toFixed(2)}, h=${v.h.toFixed(1)}`;
}).join("\n");
info.textContent =
`입력 RGB/Lab 개수: ${samples.length}\n` +
`CLUT 크기: ${clut.size} × ${clut.size} × ${clut.size} = ${clut.data.length}\n` +
`예상 Lab 색역 부피: ${model.volume.toFixed(0)} Lab³\n\n` +
`White RGB=[${model.white.r.toFixed(1)}, ${model.white.g.toFixed(1)}, ${model.white.b.toFixed(1)}] ` +
`Lab=[${model.white.L.toFixed(2)}, ${model.white.a.toFixed(2)}, ${model.white.bLab.toFixed(2)}]\n` +
`Black RGB=[${model.black.r.toFixed(1)}, ${model.black.g.toFixed(1)}, ${model.black.b.toFixed(1)}] ` +
`Lab=[${model.black.L.toFixed(2)}, ${model.black.a.toFixed(2)}, ${model.black.bLab.toFixed(2)}]\n\n` +
`RGB/CMY 꼭짓점:\n${vertexText}\n\n` +
`알고리즘:\n` +
`1. RGB 값으로 RGB/CMY 꼭짓점, White, Black 검출\n` +
`2. Lab a*b* 위에서 허리라인 육각 polygon 구성\n` +
`3. 측색값이 polygon보다 돌출되면 허용\n` +
`4. 오목하게 눌린 외곽은 antiShrink + outer envelope로 복원\n` +
`5. RGB→Lab은 local MLS로 예측 후 gamut surface 안에 부드럽게 할당`;
console.clear();
console.log("입력 samples:", samples);
console.log("gamut model:", model);
console.log("A2B CLUT:", clut);
}
document.getElementById("loadDemo").addEventListener("click", () => {
dataInput.value = makeDemoText();
generateAll();
});
document.getElementById("generate").addEventListener("click", generateAll);
document.getElementById("downloadCsv").addEventListener("click", () => {
if (!state.clut) return;
downloadText("a2b_rgb_lab_clut_16.csv", clutToCSV(state.clut), "text/csv");
});
document.getElementById("downloadJson").addEventListener("click", () => {
if (!state.clut) return;
downloadText("a2b_rgb_lab_clut_16.json", clutToJSON(state), "application/json");
});
document.getElementById("copyJson").addEventListener("click", async () => {
if (!state.clut) return;
await navigator.clipboard.writeText(clutToJSON(state));
info.textContent += "\n\nJSON이 클립보드에 복사되었습니다.";
});
for (const key of Object.keys(controls)) {
controls[key].addEventListener("input", () => {
if (dataInput.value.trim()) {
generateAll();
}
});
}
// ============================================================
// 15. 3D rendering
// ============================================================
let rotationEnabled = true;
let rotationTime = 0;
let lastFrame = performance.now();
document.getElementById("toggleRotation").addEventListener("click", () => {
rotationEnabled = !rotationEnabled;
document.getElementById("toggleRotation").textContent =
rotationEnabled ? "회전 정지" : "회전 시작";
});
function rotateX(p, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return [
p[0],
p[1] * c - p[2] * s,
p[1] * s + p[2] * c
];
}
function rotateY(p, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return [
p[0] * c + p[2] * s,
p[1],
-p[0] * s + p[2] * c
];
}
function transform3D(p, time) {
let q = p;
q = rotateX(q, -0.78);
q = rotateY(q, time * 0.00036);
return q;
}
function project3D(p, cx, cy, scale) {
const d = 7.5;
const k = d / (d + p[2]);
return {
x: cx + p[0] * scale * k,
y: cy - p[1] * scale * k,
z: p[2]
};
}
function labToPoint3D(lab, centerAB) {
return [
(lab.a - centerAB.a) / 52,
(lab.L - 50) / 28,
(lab.b - centerAB.b) / 52
];
}
function drawAxes(cx, cy, scale, time) {
const axes = [
{ a: [0, -1.7, 0], b: [0, 1.7, 0], color: "rgba(255,255,255,0.25)" },
{ a: [-1.8, 0, 0], b: [1.8, 0, 0], color: "rgba(255,120,120,0.25)" },
{ a: [0, 0, -1.8], b: [0, 0, 1.8], color: "rgba(120,180,255,0.25)" }
];
for (const ax of axes) {
const p0 = project3D(transform3D(ax.a, time), cx, cy, scale);
const p1 = project3D(transform3D(ax.b, time), cx, cy, scale);
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.strokeStyle = ax.color;
ctx.lineWidth = 1;
ctx.stroke();
}
}
function drawPointCloud(labs, centerAB, cx, cy, scale, time, radiusBase) {
const rendered = labs.map(lab => {
const p3 = labToPoint3D(lab, centerAB);
const q = transform3D(p3, time);
return {
lab,
world: q,
screen: project3D(q, cx, cy, scale)
};
});
rendered.sort((a, b) => a.world[2] - b.world[2]);
for (const item of rendered) {
const lch = labToLch(item.lab, centerAB);
const radius = radiusBase + Math.min(2.0, lch.C / 60);
ctx.beginPath();
ctx.arc(item.screen.x, item.screen.y, radius, 0, Math.PI * 2);
ctx.fillStyle = labCss(item.lab, centerAB);
ctx.globalAlpha = 0.82;
ctx.fill();
ctx.globalAlpha = 1;
}
}
function drawLineLab(points, centerAB, cx, cy, scale, time, color, width) {
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const p3 = labToPoint3D(points[i], centerAB);
const q = transform3D(p3, time);
const s = project3D(q, cx, cy, scale);
if (i === 0) ctx.moveTo(s.x, s.y);
else ctx.lineTo(s.x, s.y);
}
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.stroke();
}
function drawGamutSurface(model, cx, cy, scale, time) {
const centerAB = model.centerAB;
// final surface rings
for (let li = 3; li <= 31; li += 5) {
const L = model.Lmin + (li / 34) * model.Lrange;
const ring = [];
for (let hi = 0; hi <= 72; hi++) {
const hue = hi / 72 * 360;
const C = model.surfaceCAt(L, hue);
ring.push(lchToLab(L, C, hue, centerAB));
}
drawLineLab(ring, centerAB, cx, cy, scale, time, "rgba(255,255,255,0.38)", 1.0);
}
// vertical hue lines
for (let hi = 0; hi < 12; hi++) {
const hue = hi / 12 * 360;
const line = [];
for (let li = 0; li <= 34; li++) {
const L = model.Lmin + (li / 34) * model.Lrange;
const C = model.surfaceCAt(L, hue);
line.push(lchToLab(L, C, hue, centerAB));
}
drawLineLab(line, centerAB, cx, cy, scale, time, "rgba(255,255,255,0.28)", 1.0);
}
// raw waist ring
const rawRing = [];
for (let hi = 0; hi <= 72; hi++) {
const hue = hi / 72 * 360;
const poly = rayPolygonIntersectionC(hue, model.vertices, model.centerAB);
const L = poly.L;
const lNorm = clamp((L - model.Lmin) / model.Lrange, 0, 1);
const C = bilinearCyclic(model.rawGrid, lNorm, hue);
rawRing.push(lchToLab(L, C, hue, centerAB));
}
drawLineLab(rawRing, centerAB, cx, cy, scale, time, "rgba(255,80,65,0.48)", 1.4);
// vertices
for (const v of model.vertices) {
const p3 = labToPoint3D(v, centerAB);
const q = transform3D(p3, time);
const s = project3D(q, cx, cy, scale);
ctx.beginPath();
ctx.arc(s.x, s.y, 5.4, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,255,255,0.96)";
ctx.fill();
ctx.fillStyle = "#fff";
ctx.font = "12px Arial";
ctx.fillText(v.name, s.x + 7, s.y - 7);
}
// black / white
for (const item of [
{ sample: model.white, label: "W" },
{ sample: model.black, label: "K" }
]) {
const lab = item.sample.lab;
const p3 = labToPoint3D(lab, centerAB);
const q = transform3D(p3, time);
const s = project3D(q, cx, cy, scale);
ctx.beginPath();
ctx.arc(s.x, s.y, 5.4, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,220,90,0.96)";
ctx.fill();
ctx.fillStyle = "#ffdc5a";
ctx.font = "12px Arial";
ctx.fillText(item.label, s.x + 7, s.y - 7);
}
}
function drawDivider() {
ctx.fillStyle = "rgba(255,255,255,0.16)";
ctx.fillRect(canvas.width / 2, 0, 1, canvas.height);
ctx.fillStyle = "rgba(255,255,255,0.20)";
ctx.font = "42px Arial";
ctx.fillText("→", canvas.width / 2 - 16, canvas.height / 2);
}
function draw() {
const now = performance.now();
const delta = now - lastFrame;
lastFrame = now;
if (rotationEnabled) {
rotationTime += delta;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawDivider();
if (state.model && state.clut) {
const leftCx = canvas.width * 0.36;
const rightCx = canvas.width * 0.74;
const cy = canvas.height * 0.56;
const scale = Math.min(canvas.width, canvas.height) * 0.18;
drawAxes(leftCx, cy, scale, rotationTime);
drawPointCloud(
state.samples.map(s => s.lab),
state.model.centerAB,
leftCx,
cy,
scale,
rotationTime,
2.1
);
drawGamutSurface(state.model, leftCx, cy, scale, rotationTime);
const clutLabs = state.clut.data.map(p => ({
L: p.L,
a: p.a,
b: p.bLab
}));
drawAxes(rightCx, cy, scale, rotationTime);
drawPointCloud(
clutLabs.filter((_, i) => i % 2 === 0),
state.model.centerAB,
rightCx,
cy,
scale,
rotationTime,
1.45
);
drawGamutSurface(state.model, rightCx, cy, scale, rotationTime);
ctx.fillStyle = "#8ecbff";
ctx.font = "14px Arial";
ctx.fillText("측정 RGB/Lab 점군 + 검출된 gamut 표면", canvas.width * 0.27, canvas.height - 34);
ctx.fillStyle = "#9dffb7";
ctx.fillText("16³ RGB CLUT에 할당된 PCS Lab", canvas.width * 0.66, canvas.height - 34);
} else {
ctx.fillStyle = "#ddd";
ctx.font = "18px Arial";
ctx.fillText("왼쪽 패널에서 데모 생성 또는 RGB/Lab 입력 후 A2B CLUT 생성을 누르세요.", 470, canvas.height / 2);
}
requestAnimationFrame(draw);
}
// ============================================================
// 16. Init
// ============================================================
dataInput.value = makeDemoText();
generateAll();
draw();
</script>
</body>
</html>
측정 RGB/Lab 미리보기
생성된 A2B CLUT Lab 미리보기
표시
흰색 선: anti-concave gamut 표면
붉은 선: raw 측정 외곽
큰 흰점: RGB/CMY 꼭짓점
노란 점: White / Black
점선 느낌의 중심: gray spine
CLUT: RGB 16 × 16 × 16
RGB 범위: 0 ~ 100
흰색 선: anti-concave gamut 표면
붉은 선: raw 측정 외곽
큰 흰점: RGB/CMY 꼭짓점
노란 점: White / Black
점선 느낌의 중심: gray spine
CLUT: RGB 16 × 16 × 16
RGB 범위: 0 ~ 100
Printer ICC A2B → PCS Lab CLUT 생성
입력 형식: R G B L a b
RGB는 0~100 범위, Lab은 측색값입니다. RGB와 Lab이 함께 있으므로 측정 샘플 기반 RGB→Lab 모델을 직접 만들고,
외곽 Lab 표면은 RGB/CMY 꼭짓점 polygon과 anti-concave guard로 보호합니다.
22
0.55
4
0.85
0.90
3
0.55
0.95
0.16
0.92
초기화 중...
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Printer ICC A2B RGB→PCS Lab CLUT Generator</title>
<style>
body {
margin: 0;
background: #101012;
color: #eee;
font-family: Arial, sans-serif;
overflow: hidden;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
background: #101012;
}
.panel {
position: fixed;
left: 12px;
top: 12px;
width: 420px;
max-height: calc(100vh - 24px);
overflow: auto;
z-index: 20;
padding: 12px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.74);
box-shadow: 0 0 22px rgba(0, 0, 0, 0.45);
}
.panel h2 {
margin: 0 0 8px 0;
font-size: 16px;
color: #9dffb7;
}
.panel p {
margin: 6px 0;
color: #ccc;
font-size: 12px;
line-height: 1.45;
}
textarea {
width: 100%;
height: 185px;
box-sizing: border-box;
resize: vertical;
padding: 8px;
border-radius: 8px;
border: 1px solid #34343a;
background: #19191d;
color: #e8e8e8;
font-family: Consolas, monospace;
font-size: 12px;
line-height: 1.4;
}
.buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 8px;
}
button {
padding: 8px 9px;
border: 0;
border-radius: 8px;
background: #f1f1f1;
color: #111;
font-weight: bold;
cursor: pointer;
}
button:hover {
background: #dcdcdc;
}
.controls {
margin-top: 10px;
display: grid;
grid-template-columns: 142px 170px 54px;
gap: 5px 8px;
align-items: center;
font-size: 12px;
}
input[type="range"] {
width: 170px;
}
.info {
margin-top: 10px;
padding: 8px;
border-radius: 8px;
background: rgba(255,255,255,0.06);
color: #ddd;
font-size: 12px;
line-height: 1.45;
white-space: pre-line;
}
.titleLeft,
.titleRight {
position: fixed;
top: 14px;
z-index: 8;
padding: 7px 10px;
border-radius: 9px;
background: rgba(0,0,0,0.56);
font-weight: bold;
font-size: 15px;
}
.titleLeft {
left: 455px;
color: #8ecbff;
}
.titleRight {
right: 300px;
color: #9dffb7;
}
.legend {
position: fixed;
right: 14px;
top: 14px;
z-index: 20;
width: 260px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.66);
color: #ddd;
font-size: 13px;
line-height: 1.48;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="titleLeft">측정 RGB, Lab 미리보기</div>
<div class="titleRight">생성된 A2B CLUT Lab 미리보기</div>
<div class="legend">
<b>표시</b><br>
파란/컬러 점: 측정 Lab 또는 CLUT Lab<br>
흰색 선: 예상 gamut volume model<br>
붉은 선: 측정 raw 외곽<br>
큰 흰 점: RGB/CMY 꼭짓점<br>
노란 점: White / Black<br>
회색 선: anti-hook gray spine<br>
<br>
출력 CLUT: RGB 0~100, 16×16×16
</div>
<div class="panel">
<h2>프린터 ICC A2B → PCS Lab CLUT 생성</h2>
<p>
입력 형식: 한 줄에 <b>R G B L a b</b>. RGB는 0~100 범위입니다.
RGB 측정값을 실제 장치 좌표로 사용하고, Lab은 PCS 출력값으로 사용합니다.
</p>
<textarea id="inputText"></textarea>
<div class="buttons">
<button id="makeDemo">데모 RGB/Lab 생성</button>
<button id="generate">A2B CLUT 생성</button>
<button id="toggleRotation">회전 정지</button>
<button id="downloadCsv">CSV 다운로드</button>
<button id="downloadJson">JSON 다운로드</button>
<button id="copyJson">JSON 복사</button>
</div>
<div class="controls">
<label>mqK</label>
<input id="mqK" type="range" min="8" max="32" step="1" value="18">
<span id="mqKValue">18</span>
<label>mqEps</label>
<input id="mqEps" type="range" min="0.02" max="0.30" step="0.01" value="0.085">
<span id="mqEpsValue">0.085</span>
<label>rbfBlend</label>
<input id="rbfBlend" type="range" min="0.00" max="1.00" step="0.05" value="0.78">
<span id="rbfBlendValue">0.78</span>
<label>spineStrength</label>
<input id="spineStrength" type="range" min="0.00" max="1.00" step="0.05" value="0.95">
<span id="spineStrengthValue">0.95</span>
<label>grayTolerance</label>
<input id="grayTolerance" type="range" min="2" max="18" step="1" value="8">
<span id="grayToleranceValue">8</span>
<label>edgeSmooth</label>
<input id="edgeSmooth" type="range" min="1" max="12" step="1" value="5">
<span id="edgeSmoothValue">5</span>
<label>gamutGuard</label>
<input id="gamutGuard" type="range" min="0.00" max="1.00" step="0.05" value="0.86">
<span id="gamutGuardValue">0.86</span>
<label>antiShrink</label>
<input id="antiShrink" type="range" min="0.00" max="1.00" step="0.05" value="0.82">
<span id="antiShrinkValue">0.82</span>
<label>edgeProtect</label>
<input id="edgeProtect" type="range" min="1" max="8" step="1" value="3">
<span id="edgeProtectValue">3</span>
<label>outlineFollow</label>
<input id="outlineFollow" type="range" min="0.00" max="1.00" step="0.05" value="0.56">
<span id="outlineFollowValue">0.56</span>
<label>radialPower</label>
<input id="radialPower" type="range" min="0.65" max="1.45" step="0.05" value="0.92">
<span id="radialPowerValue">0.92</span>
</div>
<div id="info" class="info">준비 중...</div>
</div>
<script>
// ============================================================
// Canvas
// ============================================================
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener("resize", resize);
resize();
// ============================================================
// Basic utilities
// ============================================================
function clamp(x, a, b) {
return Math.max(a, Math.min(b, x));
}
function lerp(a, b, t) {
return a * (1 - t) + b * t;
}
function smoothstep(a, b, x) {
if (a === b) return x < a ? 0 : 1;
const t = clamp((x - a) / (b - a), 0, 1);
return t * t * (3 - 2 * t);
}
function rand(seed) {
const x = Math.sin(seed * 12.9898) * 43758.5453123;
return x - Math.floor(x);
}
function normDeg(h) {
h = h % 360;
if (h < 0) h += 360;
return h;
}
function angleDiffDeg(a, b) {
let d = normDeg(a) - normDeg(b);
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
}
function angleDistanceDeg(a, b) {
return Math.abs(angleDiffDeg(a, b));
}
function mixHueDeg(a, b, t) {
const d = angleDiffDeg(b, a);
return normDeg(a + d * t);
}
function percentile(values, q) {
const arr = values
.filter(Number.isFinite)
.slice()
.sort((a, b) => a - b);
if (arr.length === 0) return NaN;
if (arr.length === 1) return arr[0];
const pos = (arr.length - 1) * q;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
const f = pos - lo;
return arr[lo] * (1 - f) + arr[hi] * f;
}
function median(values) {
return percentile(values, 0.5);
}
function createGrid(rows, cols, fill = 0) {
return Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => fill)
);
}
function solveLinearSystem(A, b) {
const n = A.length;
const M = A.map((row, i) => [...row, b[i]]);
for (let col = 0; col < n; col++) {
let pivot = col;
for (let row = col + 1; row < n; row++) {
if (Math.abs(M[row][col]) > Math.abs(M[pivot][col])) {
pivot = row;
}
}
if (Math.abs(M[pivot][col]) < 1e-12) return null;
[M[col], M[pivot]] = [M[pivot], M[col]];
const div = M[col][col];
for (let k = col; k <= n; k++) {
M[col][k] /= div;
}
for (let row = 0; row < n; row++) {
if (row === col) continue;
const factor = M[row][col];
for (let k = col; k <= n; k++) {
M[row][k] -= factor * M[col][k];
}
}
}
return M.map(row => row[n]);
}
// ============================================================
// Color helpers
// ============================================================
function rgbToHsl(r, g, b) {
r /= 100;
g /= 100;
b /= 100;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return {
h: normDeg(h),
s: clamp(s, 0, 1),
l: clamp(l, 0, 1)
};
}
function labCss(lab) {
const h = normDeg(Math.atan2(lab.b, lab.a) * 180 / Math.PI);
const C = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
const sat = clamp(30 + C * 0.72, 18, 92);
const light = clamp(lab.L, 12, 88);
return `hsl(${h.toFixed(1)}deg ${sat.toFixed(1)}% ${light.toFixed(1)}%)`;
}
function labToLchWithNeutral(lab, neutralAtL) {
const n = neutralAtL(lab.L);
const da = lab.a - n.a;
const db = lab.b - n.b;
const C = Math.sqrt(da * da + db * db);
const h = normDeg(Math.atan2(db, da) * 180 / Math.PI);
return {
L: lab.L,
a: lab.a,
b: lab.b,
C,
h
};
}
function lchToLabWithNeutral(L, C, h, neutralAtL) {
const n = neutralAtL(L);
const rad = h * Math.PI / 180;
return {
L,
a: n.a + C * Math.cos(rad),
b: n.b + C * Math.sin(rad)
};
}
// ============================================================
// Demo RGB/Lab samples
// ============================================================
const demoAnchors = [
{ name: "R", rgb: [100, 0, 0], rgbHue: 0, labHue: 36, L: 50.5, C: 75 },
{ name: "Y", rgb: [100, 100, 0], rgbHue: 60, labHue: 94, L: 85.5, C: 88 },
{ name: "G", rgb: [0, 100, 0], rgbHue: 120, labHue: 142, L: 57.0, C: 66 },
{ name: "C", rgb: [0, 100, 100], rgbHue: 180, labHue: 218, L: 62.5, C: 52 },
{ name: "B", rgb: [0, 0, 100], rgbHue: 240, labHue: 285, L: 33.5, C: 58 },
{ name: "M", rgb: [100, 0, 100], rgbHue: 300, labHue: 334, L: 52.0, C: 76 }
];
function cyclicInterpolateByRgbHue(anchors, rgbHue, key) {
const h = normDeg(rgbHue);
const sorted = anchors.slice().sort((a, b) => a.rgbHue - b.rgbHue);
for (let i = 0; i < sorted.length; i++) {
const a = sorted[i];
const b = sorted[(i + 1) % sorted.length];
let ah = a.rgbHue;
let bh = b.rgbHue;
let hh = h;
if (i === sorted.length - 1) bh += 360;
if (hh < ah) hh += 360;
if (hh >= ah && hh <= bh) {
const t = (hh - ah) / Math.max(1e-9, bh - ah);
if (key === "labHue") {
return mixHueDeg(a.labHue, b.labHue, t);
}
return lerp(a[key], b[key], t);
}
}
return sorted[0][key];
}
function demoSurfaceC(L, rgbHue, blackL, whiteL) {
const waistL = cyclicInterpolateByRgbHue(demoAnchors, rgbHue, "L");
const waistC = cyclicInterpolateByRgbHue(demoAnchors, rgbHue, "C");
if (L <= waistL) {
const t = smoothstep(blackL, waistL, L);
return waistC * Math.pow(t, 0.78);
}
const t = smoothstep(waistL, whiteL, L);
return waistC * Math.pow(1 - t, 0.72);
}
function demoRgbToLab(r, g, b, seed) {
const black = { L: 7.2, a: 0.5, b: -1.6 };
const white = { L: 94.5, a: 0.2, b: -2.2 };
const hsl = rgbToHsl(r, g, b);
const grayT = (r + g + b) / 300;
const neutralL = lerp(black.L, white.L, Math.pow(grayT, 0.94));
const neutralA = lerp(black.a, white.a, grayT);
const neutralB = lerp(black.b, white.b, grayT);
const waistL = cyclicInterpolateByRgbHue(demoAnchors, hsl.h, "L");
let colorL;
if (hsl.l <= 0.5) {
const t = smoothstep(0, 0.5, hsl.l);
colorL = lerp(black.L, waistL, t);
} else {
const t = smoothstep(0.5, 1, hsl.l);
colorL = lerp(waistL, white.L, t);
}
let L = lerp(neutralL, colorL, hsl.s);
const labHue = cyclicInterpolateByRgbHue(demoAnchors, hsl.h, "labHue");
let Cmax = demoSurfaceC(L, hsl.h, black.L, white.L);
let C = Cmax * Math.pow(hsl.s, 0.92);
// 일부 실제 프린터에서 생길 법한 국소 압축
const dR = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 12) / 18, 2)) *
Math.exp(-0.5 * Math.pow((L - 45) / 12, 2));
const dY = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 72) / 15, 2)) *
Math.exp(-0.5 * Math.pow((L - 82) / 10, 2));
const dM = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 304) / 18, 2)) *
Math.exp(-0.5 * Math.pow((L - 46) / 12, 2));
const compression = Math.max(dR, dY, dM);
if (hsl.s > 0.65) {
C *= 1 - 0.22 * compression;
}
// 일부 영역은 실제 gamut 돌출
const protrude = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 150) / 18, 2)) *
Math.exp(-0.5 * Math.pow((L - 60) / 13, 2));
C *= 1 + 0.10 * protrude;
const noise = (rand(seed) - 0.5);
L += noise * 1.2;
C += (rand(seed + 11) - 0.5) * 1.6;
const h = labHue + (rand(seed + 21) - 0.5) * 1.8;
const rad = h * Math.PI / 180;
const a = neutralA + Math.max(0, C) * Math.cos(rad) + (rand(seed + 31) - 0.5) * 0.7;
const bb = neutralB + Math.max(0, C) * Math.sin(rad) + (rand(seed + 41) - 0.5) * 0.7;
return {
L: clamp(L, 0, 100),
a,
b: bb
};
}
function makeDemoText() {
const levels = [0, 16, 32, 50, 68, 84, 100];
const lines = ["R,G,B,L,a,b"];
let seed = 1;
for (const r of levels) {
for (const g of levels) {
for (const b of levels) {
const lab = demoRgbToLab(r, g, b, seed++);
lines.push(`${r.toFixed(3)},${g.toFixed(3)},${b.toFixed(3)},${lab.L.toFixed(3)},${lab.a.toFixed(3)},${lab.b.toFixed(3)}`);
}
}
}
// Gray spine samples
for (let i = 0; i <= 28; i++) {
const v = i / 28 * 100;
const lab = demoRgbToLab(v, v, v, seed++);
lines.push(`${v.toFixed(3)},${v.toFixed(3)},${v.toFixed(3)},${lab.L.toFixed(3)},${lab.a.toFixed(3)},${lab.b.toFixed(3)}`);
}
// RGB/CMY vertex reinforcement samples
const vertexRgbs = [
[100, 0, 0], [100, 100, 0], [0, 100, 0],
[0, 100, 100], [0, 0, 100], [100, 0, 100],
[0, 0, 0], [100, 100, 100]
];
for (const rgb of vertexRgbs) {
for (let i = 0; i < 6; i++) {
const r = clamp(rgb[0] + (rand(seed++) - 0.5) * 3.0, 0, 100);
const g = clamp(rgb[1] + (rand(seed++) - 0.5) * 3.0, 0, 100);
const b = clamp(rgb[2] + (rand(seed++) - 0.5) * 3.0, 0, 100);
const lab = demoRgbToLab(r, g, b, seed++);
lines.push(`${r.toFixed(3)},${g.toFixed(3)},${b.toFixed(3)},${lab.L.toFixed(3)},${lab.a.toFixed(3)},${lab.b.toFixed(3)}`);
}
}
return lines.join("\n");
}
// ============================================================
// Input parser
// ============================================================
function parseInput(text) {
const rows = [];
const lines = text
.split(/\r?\n/)
.map(x => x.trim())
.filter(x => x && !x.startsWith("#"));
for (const line of lines) {
const nums = line
.replace(/[,\t;]+/g, " ")
.split(/\s+/)
.map(Number)
.filter(Number.isFinite);
if (nums.length >= 6) {
rows.push({
r: clamp(nums[0], 0, 100),
g: clamp(nums[1], 0, 100),
b: clamp(nums[2], 0, 100),
L: nums[3],
a: nums[4],
labB: nums[5]
});
}
}
return rows;
}
// ============================================================
// White / Black / RGBCMY detection
// ============================================================
function rgbDistance(p, target) {
const dr = (p.r - target[0]) / 100;
const dg = (p.g - target[1]) / 100;
const db = (p.b - target[2]) / 100;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
function sampleLab(p) {
return {
L: p.L,
a: p.a,
b: p.labB
};
}
function findNearestRgbSample(samples, target) {
let best = samples[0];
let bestScore = Infinity;
for (const p of samples) {
const d = rgbDistance(p, target);
const hsl = rgbToHsl(p.r, p.g, p.b);
const colorBonus = hsl.s * 0.015;
const score = d - colorBonus;
if (score < bestScore) {
bestScore = score;
best = p;
}
}
return best;
}
function detectKeyPoints(samples, neutralAtLTemp) {
const blackSample = findNearestRgbSample(samples, [0, 0, 0]);
const whiteSample = findNearestRgbSample(samples, [100, 100, 100]);
const targets = [
{ name: "R", rgb: [100, 0, 0], rgbHue: 0 },
{ name: "Y", rgb: [100, 100, 0], rgbHue: 60 },
{ name: "G", rgb: [0, 100, 0], rgbHue: 120 },
{ name: "C", rgb: [0, 100, 100], rgbHue: 180 },
{ name: "B", rgb: [0, 0, 100], rgbHue: 240 },
{ name: "M", rgb: [100, 0, 100], rgbHue: 300 }
];
const vertices = targets.map(t => {
const p = findNearestRgbSample(samples, t.rgb);
const lab = sampleLab(p);
const lch = labToLchWithNeutral(lab, neutralAtLTemp);
return {
name: t.name,
rgb: t.rgb,
rgbHue: t.rgbHue,
r: p.r,
g: p.g,
b: p.b,
L: lab.L,
a: lab.a,
labB: lab.b,
C: lch.C,
h: lch.h
};
});
return {
black: sampleLab(blackSample),
white: sampleLab(whiteSample),
blackSample,
whiteSample,
vertices
};
}
// ============================================================
// Anti-hook gray spine
// ============================================================
function smoothArrayTriplets(arr, passes) {
let out = arr.map(p => ({ ...p }));
for (let pass = 0; pass < passes; pass++) {
const n = out.map(p => ({ ...p }));
for (let i = 1; i < out.length - 1; i++) {
n[i].L = out[i - 1].L * 0.25 + out[i].L * 0.50 + out[i + 1].L * 0.25;
n[i].a = out[i - 1].a * 0.25 + out[i].a * 0.50 + out[i + 1].a * 0.25;
n[i].b = out[i - 1].b * 0.25 + out[i].b * 0.50 + out[i + 1].b * 0.25;
}
out = n;
}
return out;
}
function buildGraySpine(samples, params) {
const blackSample = findNearestRgbSample(samples, [0, 0, 0]);
const whiteSample = findNearestRgbSample(samples, [100, 100, 100]);
const black = sampleLab(blackSample);
const white = sampleLab(whiteSample);
const bins = Array.from({ length: 21 }, () => []);
for (const p of samples) {
const maxRGB = Math.max(p.r, p.g, p.b);
const minRGB = Math.min(p.r, p.g, p.b);
if (maxRGB - minRGB <= params.grayTolerance) {
const t = clamp((p.r + p.g + p.b) / 300, 0, 1);
const idx = clamp(Math.round(t * 20), 0, 20);
bins[idx].push(sampleLab(p));
}
}
bins[0].push(black);
bins[20].push(white);
let spine = [];
for (let i = 0; i <= 20; i++) {
const t = i / 20;
const values = bins[i];
if (values.length > 0) {
spine.push({
t,
L: median(values.map(p => p.L)),
a: median(values.map(p => p.a)),
b: median(values.map(p => p.b))
});
} else {
spine.push({
t,
L: lerp(black.L, white.L, t),
a: lerp(black.a, white.a, t),
b: lerp(black.b, white.b, t)
});
}
}
spine = smoothArrayTriplets(spine, 4);
spine[0] = { t: 0, ...black };
spine[20] = { t: 1, ...white };
// Monotonic L enforcement to prevent gray axis hook.
for (let i = 1; i < spine.length; i++) {
if (spine[i].L < spine[i - 1].L + 0.02) {
spine[i].L = spine[i - 1].L + 0.02;
}
}
spine[20].L = white.L;
function spineAtT(t) {
const x = clamp(t, 0, 1) * 20;
const i = Math.floor(x);
const j = Math.min(20, i + 1);
const f = x - i;
return {
L: lerp(spine[i].L, spine[j].L, f),
a: lerp(spine[i].a, spine[j].a, f),
b: lerp(spine[i].b, spine[j].b, f)
};
}
function neutralAtL(L) {
if (L <= spine[0].L) {
return { a: spine[0].a, b: spine[0].b };
}
if (L >= spine[20].L) {
return { a: spine[20].a, b: spine[20].b };
}
for (let i = 0; i < 20; i++) {
const a = spine[i];
const b = spine[i + 1];
if (L >= a.L && L <= b.L) {
const t = (L - a.L) / Math.max(1e-6, b.L - a.L);
return {
a: lerp(a.a, b.a, t),
b: lerp(a.b, b.b, t)
};
}
}
return {
a: 0,
b: 0
};
}
return {
black,
white,
spine,
spineAtT,
neutralAtL
};
}
// ============================================================
// Hue mapping from RGB device hue to measured Lab hue
// ============================================================
function makeRgbHueToLabHue(vertices) {
const ordered = vertices.slice().sort((a, b) => a.rgbHue - b.rgbHue);
return function map(rgbHue) {
const h = normDeg(rgbHue);
for (let i = 0; i < ordered.length; i++) {
const a = ordered[i];
const b = ordered[(i + 1) % ordered.length];
let ah = a.rgbHue;
let bh = b.rgbHue;
let hh = h;
if (i === ordered.length - 1) bh += 360;
if (hh < ah) hh += 360;
if (hh >= ah && hh <= bh) {
const t = (hh - ah) / Math.max(1e-9, bh - ah);
return mixHueDeg(a.h, b.h, t);
}
}
return ordered[0].h;
};
}
function makeRgbHueToWaistL(vertices) {
const ordered = vertices.slice().sort((a, b) => a.rgbHue - b.rgbHue);
return function map(rgbHue) {
const h = normDeg(rgbHue);
for (let i = 0; i < ordered.length; i++) {
const a = ordered[i];
const b = ordered[(i + 1) % ordered.length];
let ah = a.rgbHue;
let bh = b.rgbHue;
let hh = h;
if (i === ordered.length - 1) bh += 360;
if (hh < ah) hh += 360;
if (hh >= ah && hh <= bh) {
const t = (hh - ah) / Math.max(1e-9, bh - ah);
return lerp(a.L, b.L, t);
}
}
return ordered[0].L;
};
}
// ============================================================
// Gamut volume model
// ============================================================
function rayPolygonC(hue, vertices) {
const dir = {
x: Math.cos(hue * Math.PI / 180),
y: Math.sin(hue * Math.PI / 180)
};
const poly = vertices.map(v => ({
x: v.C * Math.cos(v.h * Math.PI / 180),
y: v.C * Math.sin(v.h * Math.PI / 180),
L: v.L,
name: v.name
}));
let bestT = Infinity;
let bestL = vertices[0].L;
let bestEdge = "";
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
const ex = q.x - p.x;
const ey = q.y - p.y;
const det = dir.x * (-ey) - dir.y * (-ex);
if (Math.abs(det) < 1e-10) continue;
const px = p.x;
const py = p.y;
const t = (px * (-ey) - py * (-ex)) / det;
const u = (dir.x * py - dir.y * px) / det;
if (t >= 0 && u >= -1e-6 && u <= 1 + 1e-6) {
if (t < bestT) {
bestT = t;
bestL = lerp(p.L, q.L, clamp(u, 0, 1));
bestEdge = p.name + "-" + q.name;
}
}
}
if (!Number.isFinite(bestT)) {
const nearest = vertices
.slice()
.sort((a, b) => angleDistanceDeg(a.h, hue) - angleDistanceDeg(b.h, hue))[0];
return {
C: nearest.C,
L: nearest.L,
edge: nearest.name
};
}
return {
C: bestT,
L: bestL,
edge: bestEdge
};
}
function smoothGridCyclic(grid, passes) {
const rows = grid.length;
const cols = grid[0].length;
let g = grid.map(row => row.slice());
for (let pass = 0; pass < passes; pass++) {
const n = createGrid(rows, cols, 0);
for (let r = 0; r < rows; r++) {
const r0 = Math.max(0, r - 1);
const r1 = Math.min(rows - 1, r + 1);
for (let c = 0; c < cols; c++) {
const c0 = (c - 1 + cols) % cols;
const c1 = (c + 1) % cols;
n[r][c] =
g[r][c] * 0.42 +
g[r0][c] * 0.14 +
g[r1][c] * 0.14 +
g[r][c0] * 0.15 +
g[r][c1] * 0.15;
}
}
g = n;
}
return g;
}
function maxGridNeighborhood(grid, radiusL, radiusH) {
const rows = grid.length;
const cols = grid[0].length;
const out = createGrid(rows, cols, 0);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
let m = -Infinity;
for (let dr = -radiusL; dr <= radiusL; dr++) {
const rr = clamp(r + dr, 0, rows - 1);
for (let dc = -radiusH; dc <= radiusH; dc++) {
const cc = (c + dc + cols) % cols;
m = Math.max(m, grid[rr][cc]);
}
}
out[r][c] = m;
}
}
return out;
}
function bilinearCyclic(grid, lNorm, hue) {
const rows = grid.length;
const cols = grid[0].length;
const y = clamp(lNorm, 0, 1) * (rows - 1);
const r0 = Math.floor(y);
const r1 = Math.min(rows - 1, r0 + 1);
const fy = y - r0;
const x = normDeg(hue) / 360 * cols;
const c0 = Math.floor(x) % cols;
const c1 = (c0 + 1) % cols;
const fx = x - Math.floor(x);
const a = lerp(grid[r0][c0], grid[r0][c1], fx);
const b = lerp(grid[r1][c0], grid[r1][c1], fx);
return lerp(a, b, fy);
}
function buildGamutVolumeModel(samples, gray, key, params) {
const neutralAtL = gray.neutralAtL;
const tempKey = detectKeyPoints(samples, neutralAtL);
const vertices = tempKey.vertices;
const rgbHueToLabHue = makeRgbHueToLabHue(vertices);
const rgbHueToWaistL = makeRgbHueToWaistL(vertices);
const lchSamples = samples.map(p => {
const lab = sampleLab(p);
const lch = labToLchWithNeutral(lab, neutralAtL);
return {
...p,
lab,
L: lab.L,
a: lab.a,
labB: lab.b,
C: lch.C,
h: lch.h
};
});
const Lmin = gray.black.L;
const Lmax = gray.white.L;
const Lrange = Math.max(1e-6, Lmax - Lmin);
const L_BINS = 37;
const H_BINS = 72;
const bins = Array.from({ length: L_BINS }, () =>
Array.from({ length: H_BINS }, () => [])
);
for (const p of lchSamples) {
if (p.C < 1.5) continue;
const li = clamp(Math.round(((p.L - Lmin) / Lrange) * (L_BINS - 1)), 0, L_BINS - 1);
const hi = Math.floor(normDeg(p.h) / 360 * H_BINS) % H_BINS;
bins[li][hi].push(p.C);
}
const rawGrid = createGrid(L_BINS, H_BINS, NaN);
const countGrid = createGrid(L_BINS, H_BINS, 0);
for (let li = 0; li < L_BINS; li++) {
for (let hi = 0; hi < H_BINS; hi++) {
const values = bins[li][hi];
countGrid[li][hi] = values.length;
if (values.length >= 2) {
rawGrid[li][hi] = percentile(values, 0.965);
}
}
}
function fallbackC(li, hi) {
const lNorm = li / (L_BINS - 1);
const L = Lmin + lNorm * Lrange;
const hue = hi / H_BINS * 360;
const poly = rayPolygonC(hue, vertices);
const waistC = poly.C;
const waistL = poly.L;
if (L <= waistL) {
const t = smoothstep(Lmin, waistL, L);
return waistC * Math.pow(t, 0.78);
}
const t = smoothstep(waistL, Lmax, L);
return waistC * Math.pow(1 - t, 0.72);
}
const fallbackGrid = createGrid(L_BINS, H_BINS, 0);
for (let li = 0; li < L_BINS; li++) {
for (let hi = 0; hi < H_BINS; hi++) {
fallbackGrid[li][hi] = fallbackC(li, hi);
}
}
const filledRaw = createGrid(L_BINS, H_BINS, 0);
for (let li = 0; li < L_BINS; li++) {
for (let hi = 0; hi < H_BINS; hi++) {
filledRaw[li][hi] = Number.isFinite(rawGrid[li][hi])
? rawGrid[li][hi]
: fallbackGrid[li][hi];
}
}
const guarded = createGrid(L_BINS, H_BINS, 0);
for (let li = 0; li < L_BINS; li++) {
for (let hi = 0; hi < H_BINS; hi++) {
const raw = filledRaw[li][hi];
const fallback = fallbackGrid[li][hi];
const hasData = countGrid[li][hi] >= 2;
if (!hasData) {
guarded[li][hi] = fallback;
continue;
}
// 측정값이 polygon보다 돌출된 것은 허용.
// 측정값이 눌린 경우만 antiShrink.
if (raw >= fallback) {
guarded[li][hi] = raw;
} else {
const dentRatio = clamp((fallback - raw) / Math.max(fallback, 1e-6), 0, 1);
if (dentRatio > 0.08) {
guarded[li][hi] = lerp(raw, fallback, params.antiShrink * smoothstep(0.08, 0.45, dentRatio));
} else {
guarded[li][hi] = raw;
}
}
}
}
const radius = Math.max(1, Math.round(params.edgeProtect));
let envelope = maxGridNeighborhood(guarded, radius, radius);
envelope = smoothGridCyclic(envelope, 2);
const protectedGrid = createGrid(L_BINS, H_BINS, 0);
for (let li = 0; li < L_BINS; li++) {
for (let hi = 0; hi < H_BINS; hi++) {
const raw = guarded[li][hi];
const env = envelope[li][hi];
const shrink = clamp(
(env - raw) / Math.max(env * 0.36, 1e-6),
0,
1
);
const guard = params.gamutGuard * smoothstep(0.05, 0.90, shrink);
protectedGrid[li][hi] = lerp(raw, env, guard);
}
}
let smooth = smoothGridCyclic(protectedGrid, params.edgeSmooth);
const finalGrid = createGrid(L_BINS, H_BINS, 0);
for (let li = 0; li < L_BINS; li++) {
for (let hi = 0; hi < H_BINS; hi++) {
const smoothed = smooth[li][hi];
const protectedValue = protectedGrid[li][hi];
const fallback = fallbackGrid[li][hi];
finalGrid[li][hi] = Math.max(
lerp(smoothed, protectedValue, params.outlineFollow),
fallback * 0.84
);
}
}
for (let hi = 0; hi < H_BINS; hi++) {
finalGrid[0][hi] = 0;
finalGrid[L_BINS - 1][hi] = 0;
}
const maxC = Math.max(...finalGrid.flat());
function surfaceCAt(L, hue) {
const lNorm = clamp((L - Lmin) / Lrange, 0, 1);
return clamp(bilinearCyclic(finalGrid, lNorm, hue), 0, maxC * 1.12);
}
return {
Lmin,
Lmax,
Lrange,
L_BINS,
H_BINS,
vertices,
rgbHueToLabHue,
rgbHueToWaistL,
rawGrid: filledRaw,
fallbackGrid,
finalGrid,
maxC,
surfaceCAt
};
}
// ============================================================
// Local multiquadric RBF in RGB device space
// ============================================================
function mqRgbDistance(q, p) {
const dr = (q.r - p.r) / 100;
const dg = (q.g - p.g) / 100;
const db = (q.b - p.b) / 100;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
function weightedFallbackLab(q, samples, k) {
const nearest = samples
.map(p => ({ p, d: mqRgbDistance(q, p) }))
.sort((a, b) => a.d - b.d)
.slice(0, Math.max(4, k));
let sw = 0;
let L = 0;
let a = 0;
let b = 0;
for (const item of nearest) {
const w = 1 / Math.max(1e-6, item.d * item.d);
sw += w;
L += w * item.p.L;
a += w * item.p.a;
b += w * item.p.labB;
}
if (sw <= 0) {
return sampleLab(samples[0]);
}
return {
L: L / sw,
a: a / sw,
b: b / sw
};
}
function localMQRbfLab(q, samples, params) {
const k = clamp(Math.round(params.mqK), 4, 40);
const eps = params.mqEps;
const nugget = 0.0015;
const nearest = samples
.map(p => ({ p, d: mqRgbDistance(q, p) }))
.sort((a, b) => a.d - b.d)
.slice(0, Math.min(k, samples.length));
if (nearest.length === 0) {
return { L: 50, a: 0, b: 0 };
}
if (nearest[0].d < 1e-8) {
return sampleLab(nearest[0].p);
}
const n = nearest.length;
if (n < 4) {
return weightedFallbackLab(q, samples, k);
}
const A = createGrid(n, n, 0);
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const d = mqRgbDistance(nearest[i].p, nearest[j].p);
A[i][j] = Math.sqrt(d * d + eps * eps);
}
A[i][i] += nugget;
}
const yL = nearest.map(x => x.p.L);
const ya = nearest.map(x => x.p.a);
const yb = nearest.map(x => x.p.labB);
const wL = solveLinearSystem(A.map(row => row.slice()), yL);
const wa = solveLinearSystem(A.map(row => row.slice()), ya);
const wb = solveLinearSystem(A.map(row => row.slice()), yb);
if (!wL || !wa || !wb) {
return weightedFallbackLab(q, samples, k);
}
let L = 0;
let a = 0;
let b = 0;
for (let i = 0; i < n; i++) {
const d = mqRgbDistance(q, nearest[i].p);
const phi = Math.sqrt(d * d + eps * eps);
L += wL[i] * phi;
a += wa[i] * phi;
b += wb[i] * phi;
}
return {
L: clamp(L, 0, 100),
a,
b
};
}
// ============================================================
// A2B prediction
// ============================================================
function applySpineAndGamutGuard(q, baseLab, model, params) {
const hsl = rgbToHsl(q.r, q.g, q.b);
const grayT = clamp((q.r + q.g + q.b) / 300, 0, 1);
const spineLab = model.gray.spineAtT(grayT);
if (hsl.s < 0.002) {
return spineLab;
}
// Anti-hook gray spine.
const grayBlend =
params.spineStrength *
Math.pow(1 - hsl.s, 3.2);
let lab = {
L: lerp(baseLab.L, spineLab.L, grayBlend),
a: lerp(baseLab.a, spineLab.a, grayBlend),
b: lerp(baseLab.b, spineLab.b, grayBlend)
};
// RGB hue → measured Lab hue alignment.
const lch = labToLchWithNeutral(lab, model.gray.neutralAtL);
const mappedHue = model.volume.rgbHueToLabHue(hsl.h);
const hue = mixHueDeg(lch.h, mappedHue, 0.20 * hsl.s);
const Cmax = model.volume.surfaceCAt(lab.L, hue);
const expectedC = Cmax * Math.pow(hsl.s, params.radialPower);
let C = lch.C;
// out-of-gamut compression / edge shrink restore.
if (hsl.s > 0.45 && C < expectedC * 0.88) {
const shrink = clamp((expectedC * 0.88 - C) / Math.max(expectedC, 1e-6), 0, 1);
const restore = params.gamutGuard * params.antiShrink * smoothstep(0.03, 0.42, shrink) * hsl.s;
C = lerp(C, expectedC, restore);
}
// excessive overshoot clamp.
if (C > Cmax * 1.055) {
C = lerp(C, Cmax * 1.02, params.gamutGuard);
}
return lchToLabWithNeutral(lab.L, Math.max(0, C), hue, model.gray.neutralAtL);
}
function predictA2BLab(q, model, params) {
const rbfLab = localMQRbfLab(q, model.samples, params);
const fallbackLab = rgbHslFallbackLab(q, model, params);
const baseLab = {
L: lerp(fallbackLab.L, rbfLab.L, params.rbfBlend),
a: lerp(fallbackLab.a, rbfLab.a, params.rbfBlend),
b: lerp(fallbackLab.b, rbfLab.b, params.rbfBlend)
};
return applySpineAndGamutGuard(q, baseLab, model, params);
}
function rgbHslFallbackLab(q, model, params) {
const hsl = rgbToHsl(q.r, q.g, q.b);
const grayT = clamp((q.r + q.g + q.b) / 300, 0, 1);
const spineLab = model.gray.spineAtT(grayT);
if (hsl.s < 0.002) {
return spineLab;
}
const waistL = model.volume.rgbHueToWaistL(hsl.h);
const labHue = model.volume.rgbHueToLabHue(hsl.h);
let colorL;
if (hsl.l <= 0.5) {
const t = smoothstep(0, 0.5, hsl.l);
colorL = lerp(model.gray.black.L, waistL, t);
} else {
const t = smoothstep(0.5, 1, hsl.l);
colorL = lerp(waistL, model.gray.white.L, t);
}
const L = lerp(spineLab.L, colorL, hsl.s);
const Cmax = model.volume.surfaceCAt(L, labHue);
const C = Cmax * Math.pow(hsl.s, params.radialPower);
return lchToLabWithNeutral(L, C, labHue, model.gray.neutralAtL);
}
function buildA2BModel(samples, params) {
const gray = buildGraySpine(samples, params);
const key = detectKeyPoints(samples, gray.neutralAtL);
const volume = buildGamutVolumeModel(samples, gray, key, params);
return {
samples,
gray,
key,
volume
};
}
function buildCLUT(model, params) {
const size = 16;
const data = [];
for (let ri = 0; ri < size; ri++) {
for (let gi = 0; gi < size; gi++) {
for (let bi = 0; bi < size; bi++) {
const q = {
r: ri / (size - 1) * 100,
g: gi / (size - 1) * 100,
b: bi / (size - 1) * 100
};
const lab = predictA2BLab(q, model, params);
data.push({
r: q.r,
g: q.g,
b: q.b,
L: lab.L,
a: lab.a,
labB: lab.b
});
}
}
}
return {
size,
data
};
}
// ============================================================
// Download helpers
// ============================================================
function downloadText(filename, text, mime) {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function clutToCsv(clut) {
const lines = ["R,G,B,L,a,b"];
for (const p of clut.data) {
lines.push([
p.r.toFixed(6),
p.g.toFixed(6),
p.b.toFixed(6),
p.L.toFixed(6),
p.a.toFixed(6),
p.labB.toFixed(6)
].join(","));
}
return lines.join("\n");
}
function clutToJson(state) {
return JSON.stringify({
title: "Printer ICC A2B RGB to PCS Lab CLUT",
rgbRange: [0, 100],
clutSize: [16, 16, 16],
blackPoint: state.model.gray.black,
whitePoint: state.model.gray.white,
vertices: state.model.volume.vertices.map(v => ({
name: v.name,
measuredRGB: [v.r, v.g, v.b],
Lab: [v.L, v.a, v.labB],
C: v.C,
h: v.h
})),
graySpine: state.model.gray.spine,
clut: state.clut.data.map(p => ({
RGB: [
Number(p.r.toFixed(6)),
Number(p.g.toFixed(6)),
Number(p.b.toFixed(6))
],
Lab: [
Number(p.L.toFixed(6)),
Number(p.a.toFixed(6)),
Number(p.labB.toFixed(6))
]
}))
}, null, 2);
}
// ============================================================
// UI state
// ============================================================
const inputText = document.getElementById("inputText");
const info = document.getElementById("info");
const controls = {
mqK: document.getElementById("mqK"),
mqEps: document.getElementById("mqEps"),
rbfBlend: document.getElementById("rbfBlend"),
spineStrength: document.getElementById("spineStrength"),
grayTolerance: document.getElementById("grayTolerance"),
edgeSmooth: document.getElementById("edgeSmooth"),
gamutGuard: document.getElementById("gamutGuard"),
antiShrink: document.getElementById("antiShrink"),
edgeProtect: document.getElementById("edgeProtect"),
outlineFollow: document.getElementById("outlineFollow"),
radialPower: document.getElementById("radialPower")
};
const valueEls = {
mqK: document.getElementById("mqKValue"),
mqEps: document.getElementById("mqEpsValue"),
rbfBlend: document.getElementById("rbfBlendValue"),
spineStrength: document.getElementById("spineStrengthValue"),
grayTolerance: document.getElementById("grayToleranceValue"),
edgeSmooth: document.getElementById("edgeSmoothValue"),
gamutGuard: document.getElementById("gamutGuardValue"),
antiShrink: document.getElementById("antiShrinkValue"),
edgeProtect: document.getElementById("edgeProtectValue"),
outlineFollow: document.getElementById("outlineFollowValue"),
radialPower: document.getElementById("radialPowerValue")
};
function getParams() {
const params = {
mqK: Number(controls.mqK.value),
mqEps: Number(controls.mqEps.value),
rbfBlend: Number(controls.rbfBlend.value),
spineStrength: Number(controls.spineStrength.value),
grayTolerance: Number(controls.grayTolerance.value),
edgeSmooth: Number(controls.edgeSmooth.value),
gamutGuard: Number(controls.gamutGuard.value),
antiShrink: Number(controls.antiShrink.value),
edgeProtect: Number(controls.edgeProtect.value),
outlineFollow: Number(controls.outlineFollow.value),
radialPower: Number(controls.radialPower.value)
};
for (const key of Object.keys(params)) {
valueEls[key].textContent = String(params[key]);
}
return params;
}
let state = {
samples: [],
model: null,
clut: null
};
function generateAll() {
const params = getParams();
const samples = parseInput(inputText.value);
if (samples.length < 24) {
info.textContent = "입력 RGB/Lab 샘플이 너무 적습니다. 최소 24개 이상을 권장합니다.";
return;
}
const model = buildA2BModel(samples, params);
const clut = buildCLUT(model, params);
state = {
samples,
model,
clut
};
const vText = model.volume.vertices.map(v =>
`${v.name}: RGB≈[${v.r.toFixed(1)}, ${v.g.toFixed(1)}, ${v.b.toFixed(1)}], Lab=[${v.L.toFixed(2)}, ${v.a.toFixed(2)}, ${v.labB.toFixed(2)}], C=${v.C.toFixed(2)}, h=${v.h.toFixed(1)}`
).join("\n");
info.textContent =
`입력 샘플: ${samples.length}개\n` +
`생성 CLUT: 16×16×16 = ${clut.data.length}개\n\n` +
`Black: L=${model.gray.black.L.toFixed(2)}, a=${model.gray.black.a.toFixed(2)}, b=${model.gray.black.b.toFixed(2)}\n` +
`White: L=${model.gray.white.L.toFixed(2)}, a=${model.gray.white.a.toFixed(2)}, b=${model.gray.white.b.toFixed(2)}\n\n` +
`검출 RGB/CMY 꼭짓점:\n${vText}\n\n` +
`적용 알고리즘:\n` +
`- RGB 공간 local multiquadric RBF\n` +
`- anti-hook gray spine\n` +
`- RGB/CMY 허리라인 polygon\n` +
`- black/white point 기반 팽이형 volume model\n` +
`- in-gamut 돌출 허용\n` +
`- 눌림/압축 외곽 antiShrink 복원\n` +
`- CLUT RGB 0~100 균등 생성`;
console.clear();
console.log("samples", samples);
console.log("model", model);
console.log("clut", clut);
}
document.getElementById("makeDemo").addEventListener("click", () => {
inputText.value = makeDemoText();
generateAll();
});
document.getElementById("generate").addEventListener("click", generateAll);
document.getElementById("downloadCsv").addEventListener("click", () => {
if (!state.clut) return;
downloadText("printer_a2b_rgb_lab_clut_16.csv", clutToCsv(state.clut), "text/csv");
});
document.getElementById("downloadJson").addEventListener("click", () => {
if (!state.clut) return;
downloadText("printer_a2b_rgb_lab_clut_16.json", clutToJson(state), "application/json");
});
document.getElementById("copyJson").addEventListener("click", async () => {
if (!state.clut) return;
await navigator.clipboard.writeText(clutToJson(state));
info.textContent += "\n\nJSON이 클립보드에 복사되었습니다.";
});
for (const key of Object.keys(controls)) {
controls[key].addEventListener("input", () => {
getParams();
if (inputText.value.trim()) {
generateAll();
}
});
}
// ============================================================
// 3D rendering
// ============================================================
let rotationEnabled = true;
let rotationTime = 0;
let lastFrameTime = performance.now();
document.getElementById("toggleRotation").addEventListener("click", () => {
rotationEnabled = !rotationEnabled;
document.getElementById("toggleRotation").textContent =
rotationEnabled ? "회전 정지" : "회전 시작";
});
function rotateX(p, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return [
p[0],
p[1] * c - p[2] * s,
p[1] * s + p[2] * c
];
}
function rotateY(p, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return [
p[0] * c + p[2] * s,
p[1],
-p[0] * s + p[2] * c
];
}
function transform3D(p, time) {
let q = p;
q = rotateX(q, -0.78);
q = rotateY(q, time * 0.00036);
return q;
}
function project3D(p, cx, cy, scale) {
const d = 7.5;
const k = d / (d + p[2]);
return {
x: cx + p[0] * scale * k,
y: cy - p[1] * scale * k,
z: p[2]
};
}
function labToPoint3D(lab, neutralAtL) {
const n = neutralAtL(lab.L);
return [
(lab.a - n.a) / 50,
(lab.L - 50) / 28,
(lab.b - n.b) / 50
];
}
function drawAxes(cx, cy, scale, time) {
const axes = [
{ a: [0, -1.8, 0], b: [0, 1.8, 0], color: "rgba(255,255,255,0.25)" },
{ a: [-1.9, 0, 0], b: [1.9, 0, 0], color: "rgba(255,110,110,0.22)" },
{ a: [0, 0, -1.9], b: [0, 0, 1.9], color: "rgba(110,170,255,0.22)" }
];
for (const ax of axes) {
const p0 = project3D(transform3D(ax.a, time), cx, cy, scale);
const p1 = project3D(transform3D(ax.b, time), cx, cy, scale);
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.strokeStyle = ax.color;
ctx.lineWidth = 1;
ctx.stroke();
}
}
function drawPointCloud(points, neutralAtL, cx, cy, scale, time, radius) {
const rendered = points.map(p => {
const lab = {
L: p.L,
a: p.a,
b: p.labB !== undefined ? p.labB : p.b
};
const q = transform3D(labToPoint3D(lab, neutralAtL), time);
return {
lab,
world: q,
screen: project3D(q, cx, cy, scale)
};
});
rendered.sort((a, b) => a.world[2] - b.world[2]);
for (const item of rendered) {
ctx.beginPath();
ctx.arc(item.screen.x, item.screen.y, radius, 0, Math.PI * 2);
ctx.fillStyle = labCss(item.lab);
ctx.globalAlpha = 0.82;
ctx.fill();
ctx.globalAlpha = 1;
}
}
function drawLineLab(labs, neutralAtL, cx, cy, scale, time, color, width) {
ctx.beginPath();
for (let i = 0; i < labs.length; i++) {
const q = transform3D(labToPoint3D(labs[i], neutralAtL), time);
const s = project3D(q, cx, cy, scale);
if (i === 0) ctx.moveTo(s.x, s.y);
else ctx.lineTo(s.x, s.y);
}
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.stroke();
}
function drawVolumeModel(model, cx, cy, scale, time) {
const neutralAtL = model.gray.neutralAtL;
// volume rings
for (let li = 4; li <= 32; li += 5) {
const L = model.volume.Lmin + (li / 36) * model.volume.Lrange;
const ring = [];
for (let hi = 0; hi <= 96; hi++) {
const h = hi / 96 * 360;
const C = model.volume.surfaceCAt(L, h);
ring.push(lchToLabWithNeutral(L, C, h, neutralAtL));
}
drawLineLab(ring, neutralAtL, cx, cy, scale, time, "rgba(255,255,255,0.34)", 1.0);
}
// vertical hue lines
for (let hi = 0; hi < 12; hi++) {
const h = hi / 12 * 360;
const line = [];
for (let li = 0; li <= 36; li++) {
const L = model.volume.Lmin + (li / 36) * model.volume.Lrange;
const C = model.volume.surfaceCAt(L, h);
line.push(lchToLabWithNeutral(L, C, h, neutralAtL));
}
drawLineLab(line, neutralAtL, cx, cy, scale, time, "rgba(255,255,255,0.24)", 1.0);
}
// raw waist line
const rawLine = [];
for (let hi = 0; hi <= 96; hi++) {
const h = hi / 96 * 360;
const poly = rayPolygonC(h, model.volume.vertices);
const L = poly.L;
const lNorm = clamp((L - model.volume.Lmin) / model.volume.Lrange, 0, 1);
const C = bilinearCyclic(model.volume.rawGrid, lNorm, h);
rawLine.push(lchToLabWithNeutral(L, C, h, neutralAtL));
}
drawLineLab(rawLine, neutralAtL, cx, cy, scale, time, "rgba(255,80,70,0.46)", 1.2);
// anti-hook gray spine
const spineLabs = model.gray.spine.map(p => ({
L: p.L,
a: p.a,
b: p.b
}));
drawLineLab(spineLabs, neutralAtL, cx, cy, scale, time, "rgba(200,200,200,0.78)", 2.0);
// vertices
for (const v of model.volume.vertices) {
const lab = {
L: v.L,
a: v.a,
b: v.labB
};
const q = transform3D(labToPoint3D(lab, neutralAtL), time);
const s = project3D(q, cx, cy, scale);
ctx.beginPath();
ctx.arc(s.x, s.y, 5.3, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,255,255,0.96)";
ctx.fill();
ctx.fillStyle = "#fff";
ctx.font = "12px Arial";
ctx.fillText(v.name, s.x + 7, s.y - 7);
}
// black / white
for (const p of [
{ label: "K", lab: model.gray.black },
{ label: "W", lab: model.gray.white }
]) {
const q = transform3D(labToPoint3D(p.lab, neutralAtL), time);
const s = project3D(q, cx, cy, scale);
ctx.beginPath();
ctx.arc(s.x, s.y, 5.5, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,220,90,0.98)";
ctx.fill();
ctx.fillStyle = "#ffdc5a";
ctx.font = "12px Arial";
ctx.fillText(p.label, s.x + 7, s.y - 7);
}
}
function drawTopDownPolygonInset(model) {
const x0 = canvas.width - 280;
const y0 = canvas.height - 245;
const w = 250;
const h = 210;
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.50)";
ctx.fillRect(x0, y0, w, h);
ctx.strokeStyle = "rgba(255,255,255,0.18)";
ctx.strokeRect(x0, y0, w, h);
ctx.fillStyle = "#ddd";
ctx.font = "12px Arial";
ctx.fillText("2D 허리라인 polygon", x0 + 10, y0 + 18);
const vertices = model.volume.vertices;
const maxC = Math.max(...vertices.map(v => v.C), 1);
function mapPoint(C, hue) {
const rad = hue * Math.PI / 180;
const x = x0 + w / 2 + Math.cos(rad) * C / maxC * 88;
const y = y0 + h / 2 - Math.sin(rad) * C / maxC * 88;
return { x, y };
}
ctx.beginPath();
for (let i = 0; i < vertices.length; i++) {
const v = vertices[i];
const p = mapPoint(v.C, v.h);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.closePath();
ctx.strokeStyle = "rgba(255,255,255,0.80)";
ctx.lineWidth = 1.5;
ctx.stroke();
for (const v of vertices) {
const p = mapPoint(v.C, v.h);
ctx.beginPath();
ctx.arc(p.x, p.y, 4.2, 0, Math.PI * 2);
ctx.fillStyle = "#fff";
ctx.fill();
ctx.fillStyle = "#fff";
ctx.font = "11px Arial";
ctx.fillText(v.name, p.x + 5, p.y - 5);
}
ctx.restore();
}
function drawDivider() {
ctx.strokeStyle = "rgba(255,255,255,0.14)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(canvas.width / 2, 0);
ctx.lineTo(canvas.width / 2, canvas.height);
ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.18)";
ctx.font = "42px Arial";
ctx.fillText("→", canvas.width / 2 - 16, canvas.height / 2);
}
function draw() {
const now = performance.now();
const delta = now - lastFrameTime;
lastFrameTime = now;
if (rotationEnabled) {
rotationTime += delta;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawDivider();
if (state.model && state.clut) {
const scale = Math.min(canvas.width, canvas.height) * 0.18;
const leftCx = canvas.width * 0.36;
const rightCx = canvas.width * 0.74;
const cy = canvas.height * 0.56;
drawAxes(leftCx, cy, scale, rotationTime);
drawPointCloud(state.samples, state.model.gray.neutralAtL, leftCx, cy, scale, rotationTime, 2.3);
drawVolumeModel(state.model, leftCx, cy, scale, rotationTime);
drawAxes(rightCx, cy, scale, rotationTime);
const step = Math.max(1, Math.floor(state.clut.data.length / 2300));
const clutPreview = state.clut.data.filter((_, i) => i % step === 0);
drawPointCloud(clutPreview, state.model.gray.neutralAtL, rightCx, cy, scale, rotationTime, 1.65);
drawVolumeModel(state.model, rightCx, cy, scale, rotationTime);
drawTopDownPolygonInset(state.model);
ctx.fillStyle = "#8ecbff";
ctx.font = "14px Arial";
ctx.fillText("입력 측정 RGB/Lab + 예상 volume", canvas.width * 0.28, canvas.height - 28);
ctx.fillStyle = "#9dffb7";
ctx.fillText("생성된 16³ A2B CLUT Lab", canvas.width * 0.67, canvas.height - 28);
} else {
ctx.fillStyle = "#ddd";
ctx.font = "18px Arial";
ctx.fillText("왼쪽 패널에서 데모 생성 또는 RGB/Lab 입력 후 A2B CLUT 생성을 누르세요.", 470, canvas.height / 2);
}
requestAnimationFrame(draw);
}
// ============================================================
// Init
// ============================================================
inputText.value = makeDemoText();
generateAll();
draw();
</script>
</body>
</html><!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Printer ICC A2B RGB→PCS Lab CLUT Generator</title>
<style>
body {
margin: 0;
background: #0f1012;
color: #eee;
font-family: Arial, sans-serif;
overflow: hidden;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
background: #101114;
}
.panel {
position: fixed;
left: 12px;
top: 12px;
width: 420px;
max-height: calc(100vh - 24px);
overflow: auto;
z-index: 10;
padding: 12px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.74);
box-shadow: 0 0 18px rgba(0,0,0,0.45);
}
.panel h2 {
margin: 0 0 8px 0;
font-size: 16px;
color: #9dffb7;
}
.panel p {
margin: 6px 0;
color: #ccc;
font-size: 12px;
line-height: 1.45;
}
textarea {
width: 100%;
height: 185px;
box-sizing: border-box;
resize: vertical;
background: #191a1f;
color: #eaeaea;
border: 1px solid #333842;
border-radius: 8px;
padding: 8px;
font-family: Consolas, monospace;
font-size: 12px;
line-height: 1.4;
}
.buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 8px;
}
button {
border: 0;
border-radius: 8px;
padding: 8px 9px;
font-weight: bold;
cursor: pointer;
background: #f2f2f2;
color: #111;
}
button:hover {
background: #dcdcdc;
}
.controls {
margin-top: 10px;
display: grid;
grid-template-columns: 150px 155px 58px;
gap: 5px 8px;
align-items: center;
font-size: 12px;
}
input[type="range"] {
width: 155px;
}
.info {
margin-top: 10px;
padding: 8px;
border-radius: 8px;
background: rgba(255,255,255,0.065);
font-size: 12px;
color: #ddd;
white-space: pre-line;
line-height: 1.45;
}
.legend {
position: fixed;
right: 14px;
top: 14px;
z-index: 10;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.64);
font-size: 13px;
line-height: 1.55;
color: #ddd;
}
.titleLeft,
.titleRight {
position: fixed;
top: 14px;
z-index: 6;
padding: 7px 10px;
border-radius: 9px;
background: rgba(0,0,0,0.56);
font-weight: bold;
font-size: 15px;
}
.titleLeft {
left: 455px;
color: #8ecbff;
}
.titleRight {
right: 320px;
color: #9dffb7;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="titleLeft">측색 RGB/Lab 샘플 미리보기</div>
<div class="titleRight">생성된 16³ A2B CLUT Lab 미리보기</div>
<div class="legend">
<b>표시</b><br />
작은 점: 측색 샘플 / CLUT Lab<br />
흰색 링: 최종 gamut surface<br />
붉은 링: raw 측색 외곽<br />
큰 흰점: RGB/CMY 꼭짓점<br />
노란 점: White / Black<br />
<br />
CLUT: RGB 0~100, 16×16×16<br />
입력 형식: R G B L a b
</div>
<div class="panel">
<h2>Printer ICC A2B → PCS Lab CLUT 생성</h2>
<p>
RGB와 측색 Lab을 사용해 A2B RGB→PCS Lab 16³ CLUT를 생성합니다.
외곽이 out-of-gamut/과측정/압축값에 의해 안쪽으로 끌려 들어가지 않도록
anti-shrink volume guard, RGB/CMY 허리라인 polygon, anti-hook neutral spine,
local MQ-RBF residual 보정을 함께 사용합니다.
</p>
<textarea id="sampleInput"></textarea>
<div class="buttons">
<button id="loadDemo">데모 RGB/Lab 생성</button>
<button id="generate">A2B CLUT 생성</button>
<button id="toggleRotation">회전 정지</button>
<button id="downloadCsv">CSV 다운로드</button>
<button id="downloadJson">JSON 다운로드</button>
<button id="copyJson">JSON 복사</button>
</div>
<div class="controls">
<label>surfaceSmooth</label>
<input id="surfaceSmooth" type="range" min="1" max="10" step="1" value="4" />
<span id="surfaceSmoothValue">4</span>
<label>edgeSmooth</label>
<input id="edgeSmooth" type="range" min="1" max="12" step="1" value="5" />
<span id="edgeSmoothValue">5</span>
<label>antiShrink</label>
<input id="antiShrink" type="range" min="0" max="1" step="0.05" value="0.88" />
<span id="antiShrinkValue">0.88</span>
<label>gamutGuard</label>
<input id="gamutGuard" type="range" min="0" max="1" step="0.05" value="0.90" />
<span id="gamutGuardValue">0.90</span>
<label>edgeProtect</label>
<input id="edgeProtect" type="range" min="1" max="8" step="1" value="3" />
<span id="edgeProtectValue">3</span>
<label>outlineFollow</label>
<input id="outlineFollow" type="range" min="0" max="1" step="0.05" value="0.55" />
<span id="outlineFollowValue">0.55</span>
<label>rbfResidual</label>
<input id="rbfResidual" type="range" min="0" max="1" step="0.05" value="0.35" />
<span id="rbfResidualValue">0.35</span>
<label>radialPower</label>
<input id="radialPower" type="range" min="0.65" max="1.40" step="0.05" value="0.90" />
<span id="radialPowerValue">0.90</span>
<label>graySpineSmooth</label>
<input id="graySpineSmooth" type="range" min="1" max="10" step="1" value="5" />
<span id="graySpineSmoothValue">5</span>
<label>volumeAnchor</label>
<input id="volumeAnchor" type="range" min="0" max="1" step="0.05" value="0.75" />
<span id="volumeAnchorValue">0.75</span>
</div>
<div id="info" class="info">준비 중...</div>
</div>
<script>
// ============================================================
// 0. Canvas
// ============================================================
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener("resize", resize);
resize();
// ============================================================
// 1. Basic utilities
// ============================================================
function clamp(x, a, b) {
return Math.max(a, Math.min(b, x));
}
function lerp(a, b, t) {
return a * (1 - t) + b * t;
}
function smoothstep(a, b, x) {
if (a === b) return x < a ? 0 : 1;
const t = clamp((x - a) / (b - a), 0, 1);
return t * t * (3 - 2 * t);
}
function rand(seed) {
const x = Math.sin(seed * 12.9898) * 43758.5453123;
return x - Math.floor(x);
}
function normDeg(h) {
h = h % 360;
if (h < 0) h += 360;
return h;
}
function angleDiffDeg(a, b) {
let d = normDeg(a) - normDeg(b);
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
}
function angleDistanceDeg(a, b) {
return Math.abs(angleDiffDeg(a, b));
}
function percentile(values, q) {
const arr = values
.filter(Number.isFinite)
.slice()
.sort((a, b) => a - b);
if (arr.length === 0) return NaN;
if (arr.length === 1) return arr[0];
const pos = (arr.length - 1) * q;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
const t = pos - lo;
return arr[lo] * (1 - t) + arr[hi] * t;
}
function median(values) {
return percentile(values, 0.5);
}
function distanceRGB(a, b) {
const dr = (a.r - b.r) / 100;
const dg = (a.g - b.g) / 100;
const db = (a.b - b.b) / 100;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
function createGrid(rows, cols, fill = NaN) {
return Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => fill)
);
}
function fillMissing1D(values) {
const arr = values.slice();
for (let i = 0; i < arr.length; i++) {
if (Number.isFinite(arr[i])) continue;
let l = i - 1;
let r = i + 1;
while (l >= 0 && !Number.isFinite(arr[l])) l--;
while (r < arr.length && !Number.isFinite(arr[r])) r++;
if (l >= 0 && r < arr.length) {
arr[i] = (arr[l] + arr[r]) * 0.5;
} else if (l >= 0) {
arr[i] = arr[l];
} else if (r < arr.length) {
arr[i] = arr[r];
} else {
arr[i] = 0;
}
}
return arr;
}
function smooth1D(values, passes, keepEnds = true) {
let arr = values.slice();
const first = arr[0];
const last = arr[arr.length - 1];
for (let p = 0; p < passes; p++) {
const next = arr.slice();
for (let i = 1; i < arr.length - 1; i++) {
next[i] = arr[i - 1] * 0.25 + arr[i] * 0.50 + arr[i + 1] * 0.25;
}
if (keepEnds) {
next[0] = first;
next[next.length - 1] = last;
}
arr = next;
}
return arr;
}
function smoothGridCyclic(grid, passes) {
const rows = grid.length;
const cols = grid[0].length;
let g = grid.map(row => row.slice());
for (let pass = 0; pass < passes; pass++) {
const n = createGrid(rows, cols, 0);
for (let r = 0; r < rows; r++) {
const r0 = Math.max(0, r - 1);
const r1 = Math.min(rows - 1, r + 1);
for (let c = 0; c < cols; c++) {
const c0 = (c - 1 + cols) % cols;
const c1 = (c + 1) % cols;
n[r][c] =
g[r][c] * 0.42 +
g[r0][c] * 0.14 +
g[r1][c] * 0.14 +
g[r][c0] * 0.15 +
g[r][c1] * 0.15;
}
}
g = n;
}
return g;
}
function maxGridNeighborhood(grid, radiusL, radiusH) {
const rows = grid.length;
const cols = grid[0].length;
const out = createGrid(rows, cols, 0);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
let m = -Infinity;
for (let dr = -radiusL; dr <= radiusL; dr++) {
const rr = clamp(r + dr, 0, rows - 1);
for (let dc = -radiusH; dc <= radiusH; dc++) {
const cc = (c + dc + cols) % cols;
m = Math.max(m, grid[rr][cc]);
}
}
out[r][c] = m;
}
}
return out;
}
function bilinearCyclic(grid, lNorm, hue) {
const rows = grid.length;
const cols = grid[0].length;
const y = clamp(lNorm, 0, 1) * (rows - 1);
const r0 = Math.floor(y);
const r1 = Math.min(rows - 1, r0 + 1);
const ty = y - r0;
const x = normDeg(hue) / 360 * cols;
const c0 = Math.floor(x) % cols;
const c1 = (c0 + 1) % cols;
const tx = x - Math.floor(x);
const a = lerp(grid[r0][c0], grid[r0][c1], tx);
const b = lerp(grid[r1][c0], grid[r1][c1], tx);
return lerp(a, b, ty);
}
// ============================================================
// 2. Color utilities
// ============================================================
function rgbToHsl(r, g, b) {
r /= 100;
g /= 100;
b /= 100;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) * 0.5;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return {
h: normDeg(h),
s: clamp(s, 0, 1),
l: clamp(l, 0, 1)
};
}
function neutralAtL(spine, L) {
const x = clamp((L - spine.Lmin) / Math.max(1e-9, spine.Lmax - spine.Lmin), 0, 1);
const pos = x * (spine.count - 1);
const i = Math.floor(pos);
const j = Math.min(spine.count - 1, i + 1);
const t = pos - i;
return {
a: lerp(spine.aBins[i], spine.aBins[j], t),
b: lerp(spine.bBins[i], spine.bBins[j], t)
};
}
function labToLch(lab, spine) {
const n = neutralAtL(spine, lab.L);
const da = lab.a - n.a;
const db = lab.b - n.b;
const C = Math.sqrt(da * da + db * db);
const h = normDeg(Math.atan2(db, da) * 180 / Math.PI);
return {
L: lab.L,
a: lab.a,
b: lab.b,
C,
h
};
}
function lchToLab(L, C, h, spine) {
const n = neutralAtL(spine, L);
const rad = h * Math.PI / 180;
return {
L,
a: n.a + C * Math.cos(rad),
b: n.b + C * Math.sin(rad)
};
}
function labCss(lab) {
const h = normDeg(Math.atan2(lab.b, lab.a) * 180 / Math.PI);
const C = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
const light = clamp(lab.L, 14, 88);
const sat = clamp(28 + C * 0.75, 20, 90);
return `hsl(${h.toFixed(1)}deg ${sat.toFixed(1)}% ${light.toFixed(1)}%)`;
}
// ============================================================
// 3. Demo RGB/Lab samples
// ============================================================
const DEMO_ANCHORS = [
{ name: "R", rgbHue: 0, labHue: 36, L: 51, C: 74 },
{ name: "Y", rgbHue: 60, labHue: 94, L: 86, C: 89 },
{ name: "G", rgbHue: 120, labHue: 142, L: 58, C: 66 },
{ name: "C", rgbHue: 180, labHue: 213, L: 64, C: 49 },
{ name: "B", rgbHue: 240, labHue: 284, L: 34, C: 58 },
{ name: "M", rgbHue: 300, labHue: 330, L: 52, C: 78 }
];
function interpolateCyclicByRgbHue(anchors, hue, key) {
const h = normDeg(hue);
for (let i = 0; i < anchors.length; i++) {
const a = anchors[i];
const b = anchors[(i + 1) % anchors.length];
let ah = a.rgbHue;
let bh = b.rgbHue;
if (i === anchors.length - 1) bh += 360;
let hh = h;
if (hh < ah) hh += 360;
if (hh >= ah && hh <= bh) {
const t = (hh - ah) / Math.max(1e-9, bh - ah);
if (key === "labHue") {
let ha = a.labHue;
let hb = b.labHue;
if (i === anchors.length - 1) hb += 360;
return normDeg(lerp(ha, hb, t));
}
return lerp(a[key], b[key], t);
}
}
return anchors[0][key];
}
function demoRgbToLab(r, g, b) {
const hsl = rgbToHsl(r, g, b);
const black = { L: 7.8, a: 0.5, b: -1.1 };
const white = { L: 95.2, a: -0.7, b: -2.0 };
const labHue = interpolateCyclicByRgbHue(DEMO_ANCHORS, hsl.h, "labHue");
const waistL = interpolateCyclicByRgbHue(DEMO_ANCHORS, hsl.h, "L");
const waistC = interpolateCyclicByRgbHue(DEMO_ANCHORS, hsl.h, "C");
const neutralL = lerp(black.L, white.L, hsl.l);
let coloredL;
if (hsl.l <= 0.5) {
const t = smoothstep(0, 0.5, hsl.l);
coloredL = lerp(black.L, waistL, t);
} else {
const t = smoothstep(0.5, 1, hsl.l);
coloredL = lerp(waistL, white.L, t);
}
let L = lerp(neutralL, coloredL, hsl.s);
let Cshape;
if (L <= waistL) {
const t = smoothstep(black.L, waistL, L);
Cshape = waistC * Math.pow(t, 0.78);
} else {
const t = smoothstep(waistL, white.L, L);
Cshape = waistC * Math.pow(1 - t, 0.72);
}
let C = Cshape * Math.pow(hsl.s, 0.88);
// 실제 프린터에서 존재할 법한 돌출 영역
const bumpY = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 60) / 13, 2)) *
Math.exp(-0.5 * Math.pow((L - 82) / 9, 2));
const bumpM = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 300) / 15, 2)) *
Math.exp(-0.5 * Math.pow((L - 48) / 10, 2));
C *= 1 + 0.10 * bumpY + 0.07 * bumpM;
// out-of-gamut 또는 잉크 한계로 눌린 듯한 측정 영역
const dentR = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 10) / 16, 2)) *
Math.exp(-0.5 * Math.pow((L - 42) / 10, 2));
const dentG = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 118) / 18, 2)) *
Math.exp(-0.5 * Math.pow((L - 68) / 12, 2));
if (hsl.s > 0.72) {
C *= 1 - 0.34 * Math.max(dentR, dentG);
}
const rad = labHue * Math.PI / 180;
const grayA = lerp(black.a, white.a, clamp((L - black.L) / (white.L - black.L), 0, 1));
const grayB = lerp(black.b, white.b, clamp((L - black.L) / (white.L - black.L), 0, 1));
return {
L,
a: grayA + C * Math.cos(rad),
b: grayB + C * Math.sin(rad)
};
}
function makeDemoSampleText() {
const lines = [];
let seed = 1;
const levels = [0, 6, 12, 20, 30, 42, 55, 70, 85, 100];
for (const r of levels) {
for (const g of levels) {
for (const b of levels) {
const hsl = rgbToHsl(r, g, b);
// 너무 조밀한 내부 중복 일부는 제외
if (hsl.s < 0.12 && ![0, 20, 42, 70, 100].includes(r)) continue;
const lab = demoRgbToLab(r, g, b);
const noiseScale = 0.55 + hsl.s * 0.65;
lab.L += (rand(seed++) - 0.5) * 0.75;
lab.a += (rand(seed++) - 0.5) * noiseScale;
lab.b += (rand(seed++) - 0.5) * noiseScale;
lines.push([
r.toFixed(2),
g.toFixed(2),
b.toFixed(2),
lab.L.toFixed(3),
lab.a.toFixed(3),
lab.b.toFixed(3)
].join(" "));
}
}
}
// 정확한 RGB/CMY 꼭짓점과 그 주변 앵커 강화
const corners = [
[100, 0, 0],
[100, 100, 0],
[0, 100, 0],
[0, 100, 100],
[0, 0, 100],
[100, 0, 100],
[0, 0, 0],
[100, 100, 100]
];
for (const c of corners) {
for (let i = 0; i < 5; i++) {
const r = clamp(c[0] + (rand(seed++) - 0.5) * 2.2, 0, 100);
const g = clamp(c[1] + (rand(seed++) - 0.5) * 2.2, 0, 100);
const b = clamp(c[2] + (rand(seed++) - 0.5) * 2.2, 0, 100);
const lab = demoRgbToLab(r, g, b);
lab.L += (rand(seed++) - 0.5) * 0.55;
lab.a += (rand(seed++) - 0.5) * 0.55;
lab.b += (rand(seed++) - 0.5) * 0.55;
lines.push([
r.toFixed(2),
g.toFixed(2),
b.toFixed(2),
lab.L.toFixed(3),
lab.a.toFixed(3),
lab.b.toFixed(3)
].join(" "));
}
}
return lines.join("\n");
}
// ============================================================
// 4. Parse input
// ============================================================
function parseSampleText(text) {
const samples = [];
const lines = text
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith("#"));
for (const line of lines) {
const nums = line
.replace(/[,\t;]+/g, " ")
.split(/\s+/)
.map(Number)
.filter(Number.isFinite);
if (nums.length >= 6) {
samples.push({
r: clamp(nums[0], 0, 100),
g: clamp(nums[1], 0, 100),
b: clamp(nums[2], 0, 100),
L: nums[3],
a: nums[4],
bLab: nums[5]
});
}
}
return samples;
}
// ============================================================
// 5. Black / White / anti-hook neutral spine
// ============================================================
function detectBlackWhite(samples) {
const scoredBlack = samples.map(s => {
const rgbSum = s.r + s.g + s.b;
const C0 = Math.sqrt(s.a * s.a + s.bLab * s.bLab);
return {
s,
score: rgbSum * 0.75 + s.L * 0.25 + C0 * 0.03
};
});
const scoredWhite = samples.map(s => {
const rgbSum = s.r + s.g + s.b;
const C0 = Math.sqrt(s.a * s.a + s.bLab * s.bLab);
return {
s,
score: rgbSum * 0.75 + s.L * 1.20 - C0 * 0.04
};
});
scoredBlack.sort((a, b) => a.score - b.score);
scoredWhite.sort((a, b) => b.score - a.score);
const blackSet = scoredBlack.slice(0, Math.max(1, Math.min(6, scoredBlack.length)));
const whiteSet = scoredWhite.slice(0, Math.max(1, Math.min(6, scoredWhite.length)));
function avg(set) {
return {
L: median(set.map(x => x.s.L)),
a: median(set.map(x => x.s.a)),
b: median(set.map(x => x.s.bLab))
};
}
return {
black: avg(blackSet),
white: avg(whiteSet)
};
}
function buildNeutralSpine(samples, params) {
const wb = detectBlackWhite(samples);
const black = wb.black;
const white = wb.white;
const Lmin = Math.min(black.L, white.L);
const Lmax = Math.max(black.L, white.L);
const grayCandidates = samples.filter(s => {
const maxRGB = Math.max(s.r, s.g, s.b);
const minRGB = Math.min(s.r, s.g, s.b);
const hsl = rgbToHsl(s.r, s.g, s.b);
return (maxRGB - minRGB <= 8) || hsl.s < 0.08;
});
let useGray = grayCandidates;
if (useGray.length < 8) {
const sorted = samples
.map(s => ({
s,
C0: Math.sqrt(s.a * s.a + s.bLab * s.bLab)
}))
.sort((a, b) => a.C0 - b.C0);
useGray = sorted.slice(0, Math.max(8, Math.floor(samples.length * 0.12))).map(x => x.s);
}
const count = 25;
let aBins = Array(count).fill(NaN);
let bBins = Array(count).fill(NaN);
let lBins = Array.from({ length: count }, (_, i) => Lmin + (i / (count - 1)) * (Lmax - Lmin));
const bins = Array.from({ length: count }, () => []);
for (const s of useGray) {
const idx = clamp(
Math.round(((s.L - Lmin) / Math.max(1e-9, Lmax - Lmin)) * (count - 1)),
0,
count - 1
);
bins[idx].push(s);
}
for (let i = 0; i < count; i++) {
if (bins[i].length > 0) {
aBins[i] = median(bins[i].map(s => s.a));
bBins[i] = median(bins[i].map(s => s.bLab));
}
}
aBins[0] = black.a;
bBins[0] = black.b;
aBins[count - 1] = white.a;
bBins[count - 1] = white.b;
aBins = fillMissing1D(aBins);
bBins = fillMissing1D(bBins);
// anti-hook spine:
// 회색축이 끝에서 갈고리처럼 휘지 않도록 black/white endpoint를 고정하고 smoothing
aBins = smooth1D(aBins, params.graySpineSmooth, true);
bBins = smooth1D(bBins, params.graySpineSmooth, true);
// 과도한 국소 휨 제한
for (let pass = 0; pass < 2; pass++) {
for (let i = 1; i < count - 1; i++) {
const predA = (aBins[i - 1] + aBins[i + 1]) * 0.5;
const predB = (bBins[i - 1] + bBins[i + 1]) * 0.5;
aBins[i] = lerp(aBins[i], predA, 0.22);
bBins[i] = lerp(bBins[i], predB, 0.22);
}
aBins[0] = black.a;
bBins[0] = black.b;
aBins[count - 1] = white.a;
bBins[count - 1] = white.b;
}
return {
black,
white,
Lmin,
Lmax,
count,
lBins,
aBins,
bBins
};
}
// ============================================================
// 6. Detect RGB/CMY vertices
// ============================================================
const TARGET_VERTICES = [
{ name: "R", rgbHue: 0, rgb: { r: 100, g: 0, b: 0 } },
{ name: "Y", rgbHue: 60, rgb: { r: 100, g: 100, b: 0 } },
{ name: "G", rgbHue: 120, rgb: { r: 0, g: 100, b: 0 } },
{ name: "C", rgbHue: 180, rgb: { r: 0, g: 100, b: 100 } },
{ name: "B", rgbHue: 240, rgb: { r: 0, g: 0, b: 100 } },
{ name: "M", rgbHue: 300, rgb: { r: 100, g: 0, b: 100 } }
];
function detectVertices(samples, spine) {
const vertices = [];
const lchSamples = samples.map(s => {
const lch = labToLch(
{ L: s.L, a: s.a, b: s.bLab },
spine
);
return {
...s,
C: lch.C,
h: lch.h
};
});
for (const target of TARGET_VERTICES) {
let best = null;
let bestScore = -Infinity;
for (const s of lchSamples) {
const d = distanceRGB(
{ r: s.r, g: s.g, b: s.b },
target.rgb
);
const hsl = rgbToHsl(s.r, s.g, s.b);
const score =
s.C -
d * 62 +
hsl.s * 18 -
Math.abs((s.r + s.g + s.b) - (target.rgb.r + target.rgb.g + target.rgb.b)) * 0.035;
if (score > bestScore) {
bestScore = score;
best = s;
}
}
vertices.push({
name: target.name,
rgbHue: target.rgbHue,
rgb: target.rgb,
r: best.r,
g: best.g,
b: best.b,
L: best.L,
a: best.a,
bLab: best.bLab,
C: best.C,
h: best.h
});
}
return vertices;
}
function makeRgbHueToLabHueMapper(vertices) {
const ordered = vertices
.map(v => ({
rgbHue: v.rgbHue,
labHue: v.h
}))
.sort((a, b) => a.rgbHue - b.rgbHue);
const unwrapped = [];
for (let i = 0; i < ordered.length; i++) {
let h = ordered[i].labHue;
if (i > 0) {
while (h <= unwrapped[i - 1].labHueUnwrapped) {
h += 360;
}
}
unwrapped.push({
...ordered[i],
labHueUnwrapped: h
});
}
return function map(rgbHue) {
const h = normDeg(rgbHue);
for (let i = 0; i < unwrapped.length; i++) {
const a = unwrapped[i];
const b = unwrapped[(i + 1) % unwrapped.length];
let ah = a.rgbHue;
let bh = b.rgbHue;
if (i === unwrapped.length - 1) {
bh += 360;
}
let hh = h;
if (hh < ah) hh += 360;
if (hh >= ah && hh <= bh) {
const t = (hh - ah) / Math.max(1e-9, bh - ah);
let ha = a.labHueUnwrapped;
let hb = b.labHueUnwrapped;
if (i === unwrapped.length - 1) {
hb += 360;
}
return normDeg(lerp(ha, hb, t));
}
}
return normDeg(unwrapped[0].labHueUnwrapped);
};
}
function makeWaistMapper(vertices, key) {
const ordered = vertices
.map(v => ({
rgbHue: v.rgbHue,
value: key === "L" ? v.L : v.C
}))
.sort((a, b) => a.rgbHue - b.rgbHue);
return function at(rgbHue) {
const h = normDeg(rgbHue);
for (let i = 0; i < ordered.length; i++) {
const a = ordered[i];
const b = ordered[(i + 1) % ordered.length];
let ah = a.rgbHue;
let bh = b.rgbHue;
if (i === ordered.length - 1) {
bh += 360;
}
let hh = h;
if (hh < ah) hh += 360;
if (hh >= ah && hh <= bh) {
const t = (hh - ah) / Math.max(1e-9, bh - ah);
return lerp(a.value, b.value, t);
}
}
return ordered[0].value;
};
}
function makeLabHueWaistMapper(vertices, key) {
const ordered = vertices
.map(v => ({
h: v.h,
value: key === "L" ? v.L : v.C
}))
.sort((a, b) => a.h - b.h);
return function at(labHue) {
const h = normDeg(labHue);
for (let i = 0; i < ordered.length; i++) {
const a = ordered[i];
const b = ordered[(i + 1) % ordered.length];
let ah = a.h;
let bh = b.h;
if (i === ordered.length - 1) {
bh += 360;
}
let hh = h;
if (hh < ah) hh += 360;
if (hh >= ah && hh <= bh) {
const t = (hh - ah) / Math.max(1e-9, bh - ah);
return lerp(a.value, b.value, t);
}
}
return ordered[0].value;
};
}
// ============================================================
// 7. Build gamut volume model
// ============================================================
function baseBiconeC(L, hue, model) {
const waistC = model.waistCAtLabHue(hue);
const waistL = model.waistLAtLabHue(hue);
if (L <= waistL) {
const t = smoothstep(model.spine.Lmin, waistL, L);
return waistC * Math.pow(t, 0.78);
}
const t = smoothstep(waistL, model.spine.Lmax, L);
return waistC * Math.pow(1 - t, 0.72);
}
function addVolumeAnchor(grid, lNorm, hue, C, strength, radiusL, radiusH) {
const rows = grid.length;
const cols = grid[0].length;
const lc = clamp(Math.round(lNorm * (rows - 1)), 0, rows - 1);
const hc = Math.floor(normDeg(hue) / 360 * cols) % cols;
for (let dl = -radiusL; dl <= radiusL; dl++) {
const li = clamp(lc + dl, 0, rows - 1);
for (let dh = -radiusH; dh <= radiusH; dh++) {
const hi = (hc + dh + cols) % cols;
const ql = dl / Math.max(1, radiusL);
const qh = dh / Math.max(1, radiusH);
const w = Math.exp(-0.5 * (ql * ql + qh * qh));
grid[li][hi] = Math.max(grid[li][hi], C * lerp(0.92, 1.0, strength) * w + grid[li][hi] * (1 - w));
}
}
}
function buildGamutModel(samples, params) {
const spine = buildNeutralSpine(samples, params);
const vertices = detectVertices(samples, spine);
const rgbHueToLabHue = makeRgbHueToLabHueMapper(vertices);
const waistLAtRgbHue = makeWaistMapper(vertices, "L");
const waistCAtRgbHue = makeWaistMapper(vertices, "C");
const waistLAtLabHue = makeLabHueWaistMapper(vertices, "L");
const waistCAtLabHue = makeLabHueWaistMapper(vertices, "C");
const lchSamples = samples.map(s => {
const lab = { L: s.L, a: s.a, b: s.bLab };
const lch = labToLch(lab, spine);
const hsl = rgbToHsl(s.r, s.g, s.b);
return {
...s,
L: s.L,
lab,
C: lch.C,
h: lch.h,
rgbHue: hsl.h,
sat: hsl.s,
rgbLight: hsl.l
};
});
const modelBase = {
spine,
vertices,
rgbHueToLabHue,
waistLAtRgbHue,
waistCAtRgbHue,
waistLAtLabHue,
waistCAtLabHue
};
const L_BINS = 41;
const H_BINS = 96;
const rawBins = Array.from({ length: L_BINS }, () =>
Array.from({ length: H_BINS }, () => [])
);
for (const s of lchSamples) {
if (s.C < 1.5) continue;
const lNorm = clamp(
(s.L - spine.Lmin) / Math.max(1e-9, spine.Lmax - spine.Lmin),
0,
1
);
const li = clamp(Math.round(lNorm * (L_BINS - 1)), 0, L_BINS - 1);
const hi = Math.floor(normDeg(s.h) / 360 * H_BINS) % H_BINS;
rawBins[li][hi].push(s.C);
}
const rawGrid = createGrid(L_BINS, H_BINS, NaN);
const countGrid = createGrid(L_BINS, H_BINS, 0);
const fallbackGrid = createGrid(L_BINS, H_BINS, 0);
for (let li = 0; li < L_BINS; li++) {
const lNorm = li / (L_BINS - 1);
const L = spine.Lmin + lNorm * (spine.Lmax - spine.Lmin);
for (let hi = 0; hi < H_BINS; hi++) {
const hue = hi / H_BINS * 360;
fallbackGrid[li][hi] = baseBiconeC(L, hue, modelBase);
const arr = rawBins[li][hi];
countGrid[li][hi] = arr.length;
if (arr.length >= 1) {
rawGrid[li][hi] = percentile(arr, 0.985);
}
}
}
let guarded = createGrid(L_BINS, H_BINS, 0);
for (let li = 0; li < L_BINS; li++) {
for (let hi = 0; hi < H_BINS; hi++) {
const fallback = fallbackGrid[li][hi];
const raw = Number.isFinite(rawGrid[li][hi]) ? rawGrid[li][hi] : fallback;
if (!Number.isFinite(rawGrid[li][hi])) {
guarded[li][hi] = fallback;
continue;
}
const dent = fallback - raw;
if (dent > fallback * 0.08) {
guarded[li][hi] = lerp(raw, fallback, params.antiShrink);
} else {
guarded[li][hi] = Math.max(raw, fallback * 0.92);
}
}
}
// volume anchors:
// 매우 멀리 있는 특정 색상 샘플이 예측 부피를 작게 만들지 않도록
// 실제 돌출 샘플과 꼭짓점을 주변에 확장 앵커로 추가한다.
for (const v of vertices) {
const lNorm = clamp(
(v.L - spine.Lmin) / Math.max(1e-9, spine.Lmax - spine.Lmin),
0,
1
);
addVolumeAnchor(
guarded,
lNorm,
v.h,
v.C,
params.volumeAnchor,
2,
3
);
}
for (const s of lchSamples) {
if (s.sat < 0.65) continue;
const fallback = baseBiconeC(s.L, s.h, modelBase);
if (s.C >= fallback * 1.02 || s.sat > 0.92) {
const lNorm = clamp(
(s.L - spine.Lmin) / Math.max(1e-9, spine.Lmax - spine.Lmin),
0,
1
);
addVolumeAnchor(
guarded,
lNorm,
s.h,
Math.max(s.C, fallback),
params.volumeAnchor,
1,
2
);
}
}
// outer envelope:
// 외곽이 국소적으로 오목하게 안쪽으로 끌려 들어가는 것을 막는다.
const radius = Math.max(1, Math.round(params.edgeProtect));
let envelope = maxGridNeighborhood(guarded, radius, radius);
envelope = smoothGridCyclic(envelope, 2);
const preSmooth = createGrid(L_BINS, H_BINS, 0);
for (let li = 0; li < L_BINS; li++) {
for (let hi = 0; hi < H_BINS; hi++) {
const g = guarded[li][hi];
const e = envelope[li][hi];
const shrink = clamp(
(e - g) / Math.max(e * 0.34, 1e-9),
0,
1
);
const localGuard = clamp(params.gamutGuard * shrink, 0, 1);
preSmooth[li][hi] = lerp(g, e, localGuard);
}
}
let smooth = smoothGridCyclic(preSmooth, params.edgeSmooth);
const finalGrid = createGrid(L_BINS, H_BINS, 0);
for (let li = 0; li < L_BINS; li++) {
for (let hi = 0; hi < H_BINS; hi++) {
const protectedValue = preSmooth[li][hi] * 0.965;
const followValue = lerp(smooth[li][hi], preSmooth[li][hi], params.outlineFollow);
finalGrid[li][hi] = Math.max(
followValue,
protectedValue,
fallbackGrid[li][hi] * 0.90
);
}
}
for (let hi = 0; hi < H_BINS; hi++) {
finalGrid[0][hi] = 0;
finalGrid[L_BINS - 1][hi] = 0;
}
const finalModel = {
...modelBase,
lchSamples,
L_BINS,
H_BINS,
rawGrid,
fallbackGrid,
preSmoothGrid: preSmooth,
finalGrid
};
finalModel.surfaceCAt = function surfaceCAt(L, hue) {
const lNorm = clamp(
(L - spine.Lmin) / Math.max(1e-9, spine.Lmax - spine.Lmin),
0,
1
);
const base = bilinearCyclic(finalGrid, lNorm, hue);
// local MQ-like grid blend
if (params.surfaceSmooth <= 1) {
return base;
}
const rows = finalGrid.length;
const cols = finalGrid[0].length;
const y = lNorm * (rows - 1);
const cy = Math.round(y);
const ch = Math.round(normDeg(hue) / 360 * cols) % cols;
const radL = 2;
const radH = 3;
const eps = 0.10;
let sw = 0;
let sy = 0;
for (let dl = -radL; dl <= radL; dl++) {
const li = clamp(cy + dl, 0, rows - 1);
for (let dh = -radH; dh <= radH; dh++) {
const hi = (ch + dh + cols) % cols;
const ql = dl / Math.max(1, radL);
const qh = dh / Math.max(1, radH);
const d = Math.sqrt(ql * ql + qh * qh);
const w = 1 / Math.sqrt(d * d + eps * eps);
sw += w;
sy += w * finalGrid[li][hi];
}
}
const mq = sy / Math.max(1e-9, sw);
const blended = lerp(base, mq, 0.28);
return Math.max(blended, base * 0.96);
};
return finalModel;
}
// ============================================================
// 8. RGB→Lab base volume and residual correction
// ============================================================
function baseLabFromRgb(r, g, b, model, params) {
const hsl = rgbToHsl(r, g, b);
const labHue = model.rgbHueToLabHue(hsl.h);
const waistL = model.waistLAtRgbHue(hsl.h);
const neutralL = lerp(model.spine.black.L, model.spine.white.L, hsl.l);
let coloredL;
if (hsl.l <= 0.5) {
const t = smoothstep(0, 0.5, hsl.l);
coloredL = lerp(model.spine.black.L, waistL, t);
} else {
const t = smoothstep(0.5, 1, hsl.l);
coloredL = lerp(waistL, model.spine.white.L, t);
}
const L = lerp(neutralL, coloredL, hsl.s);
const Cmax = model.surfaceCAt(L, labHue);
const C = Cmax * Math.pow(hsl.s, params.radialPower);
return lchToLab(L, C, labHue, model.spine);
}
function radialSafeResidual(sample, base, model) {
const sLab = {
L: sample.L,
a: sample.a,
b: sample.bLab
};
const sampleLch = labToLch(sLab, model.spine);
const baseLch = labToLch(base, model.spine);
const hsl = rgbToHsl(sample.r, sample.g, sample.b);
let dL = sLab.L - base.L;
let da = sLab.a - base.a;
let db = sLab.b - base.b;
// 고채도 외곽에서 샘플이 base보다 훨씬 안쪽이면
// out-of-gamut 압축 또는 눌림으로 보고 radial negative residual을 약하게만 반영한다.
if (hsl.s > 0.70 && sampleLch.C < baseLch.C * 0.90) {
da *= 0.18;
db *= 0.18;
dL *= 0.45;
}
return {
r: sample.r,
g: sample.g,
b: sample.b,
dL,
da,
db,
weight: hsl.s > 0.70 ? 0.75 : 1.0
};
}
function buildResidualControls(samples, model, params) {
return samples.map(s => {
const base = baseLabFromRgb(s.r, s.g, s.b, model, params);
return radialSafeResidual(s, base, model);
});
}
function predictResidualRGB(r, g, b, controls) {
const query = { r, g, b };
const nearest = controls
.map(c => ({
c,
d: distanceRGB(query, c)
}))
.sort((a, b) => a.d - b.d)
.slice(0, 24);
let sw = 0;
let dL = 0;
let da = 0;
let db = 0;
for (const item of nearest) {
const d = item.d;
const w = item.c.weight / Math.sqrt(d * d + 0.018 * 0.018);
sw += w;
dL += w * item.c.dL;
da += w * item.c.da;
db += w * item.c.db;
}
if (sw <= 0) {
return { dL: 0, da: 0, db: 0 };
}
return {
dL: dL / sw,
da: da / sw,
db: db / sw
};
}
function guardedFinalLabFromRgb(r, g, b, model, params, residualControls) {
const base = baseLabFromRgb(r, g, b, model, params);
const residual = predictResidualRGB(r, g, b, residualControls);
let lab = {
L: base.L + residual.dL * params.rbfResidual,
a: base.a + residual.da * params.rbfResidual,
b: base.b + residual.db * params.rbfResidual
};
lab.L = clamp(lab.L, model.spine.Lmin, model.spine.Lmax);
const hsl = rgbToHsl(r, g, b);
let lch = labToLch(lab, model.spine);
const maxC = model.surfaceCAt(lab.L, lch.h);
// 외곽이 안쪽으로 끌려들어가는 것을 방지
if (hsl.s > 0.72) {
const baseLch = labToLch(base, model.spine);
const minimumC = baseLch.C * (1 - params.gamutGuard * 0.28);
if (lch.C < minimumC) {
lab = lchToLab(lab.L, minimumC, lch.h, model.spine);
lch = labToLch(lab, model.spine);
}
}
// 표면 밖으로 과도하게 튀는 값은 부드럽게 제한
if (lch.C > maxC * 1.035) {
lab = lchToLab(lab.L, maxC * 1.035, lch.h, model.spine);
}
return lab;
}
// ============================================================
// 9. Build 16³ CLUT
// ============================================================
function buildCLUT(samples, model, params) {
const residualControls = buildResidualControls(samples, model, params);
const size = 16;
const data = [];
for (let ri = 0; ri < size; ri++) {
for (let gi = 0; gi < size; gi++) {
for (let bi = 0; bi < size; bi++) {
const r = ri / (size - 1) * 100;
const g = gi / (size - 1) * 100;
const b = bi / (size - 1) * 100;
const lab = guardedFinalLabFromRgb(
r,
g,
b,
model,
params,
residualControls
);
data.push({
r,
g,
b,
L: lab.L,
a: lab.a,
bLab: lab.b
});
}
}
}
return {
size,
data,
residualControls
};
}
// ============================================================
// 10. Download
// ============================================================
function downloadText(filename, text, mime) {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function clutToCSV(clut) {
const lines = ["R,G,B,L,a,b"];
for (const p of clut.data) {
lines.push([
p.r.toFixed(6),
p.g.toFixed(6),
p.b.toFixed(6),
p.L.toFixed(6),
p.a.toFixed(6),
p.bLab.toFixed(6)
].join(","));
}
return lines.join("\n");
}
function clutToJSON(state) {
return JSON.stringify({
description: "Printer ICC A2B RGB→PCS Lab CLUT generated from measured RGB/Lab samples",
clutSize: state.clut.size,
rgbRange: [0, 100],
whitePoint: state.model.spine.white,
blackPoint: state.model.spine.black,
vertices: state.model.vertices.map(v => ({
name: v.name,
measuredRGB: [
Number(v.r.toFixed(6)),
Number(v.g.toFixed(6)),
Number(v.b.toFixed(6))
],
Lab: [
Number(v.L.toFixed(6)),
Number(v.a.toFixed(6)),
Number(v.bLab.toFixed(6))
],
chroma: Number(v.C.toFixed(6)),
hue: Number(v.h.toFixed(6))
})),
clut: state.clut.data.map(p => ({
RGB: [
Number(p.r.toFixed(6)),
Number(p.g.toFixed(6)),
Number(p.b.toFixed(6))
],
Lab: [
Number(p.L.toFixed(6)),
Number(p.a.toFixed(6)),
Number(p.bLab.toFixed(6))
]
}))
}, null, 2);
}
// ============================================================
// 11. UI state
// ============================================================
const sampleInput = document.getElementById("sampleInput");
const info = document.getElementById("info");
const controls = {
surfaceSmooth: document.getElementById("surfaceSmooth"),
edgeSmooth: document.getElementById("edgeSmooth"),
antiShrink: document.getElementById("antiShrink"),
gamutGuard: document.getElementById("gamutGuard"),
edgeProtect: document.getElementById("edgeProtect"),
outlineFollow: document.getElementById("outlineFollow"),
rbfResidual: document.getElementById("rbfResidual"),
radialPower: document.getElementById("radialPower"),
graySpineSmooth: document.getElementById("graySpineSmooth"),
volumeAnchor: document.getElementById("volumeAnchor")
};
const valueEls = {
surfaceSmooth: document.getElementById("surfaceSmoothValue"),
edgeSmooth: document.getElementById("edgeSmoothValue"),
antiShrink: document.getElementById("antiShrinkValue"),
gamutGuard: document.getElementById("gamutGuardValue"),
edgeProtect: document.getElementById("edgeProtectValue"),
outlineFollow: document.getElementById("outlineFollowValue"),
rbfResidual: document.getElementById("rbfResidualValue"),
radialPower: document.getElementById("radialPowerValue"),
graySpineSmooth: document.getElementById("graySpineSmoothValue"),
volumeAnchor: document.getElementById("volumeAnchorValue")
};
function getParams() {
const params = {};
for (const key of Object.keys(controls)) {
params[key] = Number(controls[key].value);
valueEls[key].textContent = String(params[key]);
}
return params;
}
let state = {
samples: [],
model: null,
clut: null
};
function generateAll() {
const params = getParams();
const samples = parseSampleText(sampleInput.value);
if (samples.length < 20) {
info.textContent = "RGB/Lab 샘플이 너무 적습니다. 최소 20개 이상을 권장합니다.";
return;
}
const model = buildGamutModel(samples, params);
const clut = buildCLUT(samples, model, params);
state = {
samples,
model,
clut
};
const vertexText = model.vertices
.map(v =>
`${v.name}: RGB≈[${v.r.toFixed(1)}, ${v.g.toFixed(1)}, ${v.b.toFixed(1)}], ` +
`Lab=[${v.L.toFixed(2)}, ${v.a.toFixed(2)}, ${v.bLab.toFixed(2)}], ` +
`C=${v.C.toFixed(2)}, h=${v.h.toFixed(1)}`
)
.join("\n");
const approxVolume = estimateVolume(model);
info.textContent =
`입력 RGB/Lab 샘플 수: ${samples.length}\n` +
`생성 CLUT: ${clut.size}×${clut.size}×${clut.size} = ${clut.data.length}\n` +
`예측 in-gamut 상대 부피: ${approxVolume.toFixed(2)}\n\n` +
`Black: L=${model.spine.black.L.toFixed(2)}, a=${model.spine.black.a.toFixed(2)}, b=${model.spine.black.b.toFixed(2)}\n` +
`White: L=${model.spine.white.L.toFixed(2)}, a=${model.spine.white.a.toFixed(2)}, b=${model.spine.white.b.toFixed(2)}\n\n` +
`검출 RGB/CMY 꼭짓점:\n${vertexText}\n\n` +
`적용 알고리즘:\n` +
`- RGB/CMY 허리라인 polygon 검출\n` +
`- Black/White anti-hook gray spine 생성\n` +
`- out-of-gamut 압축/오목 외곽 anti-shrink 복원\n` +
`- 튀어나온 실제 in-gamut 표면은 volume anchor로 허용\n` +
`- local MQ-RBF residual로 샘플 유사성 보정\n` +
`- 최종 RGB 0~100 A2B PCS Lab CLUT 생성`;
console.clear();
console.log("Samples:", samples);
console.log("Model:", model);
console.log("CLUT:", clut);
}
function estimateVolume(model) {
let sum = 0;
const L_STEPS = 64;
const H_STEPS = 144;
for (let li = 0; li < L_STEPS; li++) {
const t = li / (L_STEPS - 1);
const L = model.spine.Lmin + t * (model.spine.Lmax - model.spine.Lmin);
for (let hi = 0; hi < H_STEPS; hi++) {
const h = hi / H_STEPS * 360;
const C = model.surfaceCAt(L, h);
sum += C * C;
}
}
return sum / (L_STEPS * H_STEPS);
}
document.getElementById("loadDemo").addEventListener("click", () => {
sampleInput.value = makeDemoSampleText();
generateAll();
});
document.getElementById("generate").addEventListener("click", generateAll);
document.getElementById("downloadCsv").addEventListener("click", () => {
if (!state.clut) return;
downloadText("a2b_rgb_lab_clut_16.csv", clutToCSV(state.clut), "text/csv");
});
document.getElementById("downloadJson").addEventListener("click", () => {
if (!state.clut) return;
downloadText("a2b_rgb_lab_clut_16.json", clutToJSON(state), "application/json");
});
document.getElementById("copyJson").addEventListener("click", async () => {
if (!state.clut) return;
await navigator.clipboard.writeText(clutToJSON(state));
info.textContent += "\n\nJSON이 클립보드에 복사되었습니다.";
});
for (const key of Object.keys(controls)) {
controls[key].addEventListener("input", () => {
if (sampleInput.value.trim()) {
generateAll();
}
});
}
// ============================================================
// 12. 3D rendering
// ============================================================
let rotationEnabled = true;
let rotationTime = 0;
let lastFrame = performance.now();
document.getElementById("toggleRotation").addEventListener("click", () => {
rotationEnabled = !rotationEnabled;
document.getElementById("toggleRotation").textContent =
rotationEnabled ? "회전 정지" : "회전 시작";
});
function rotateX(p, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return [
p[0],
p[1] * c - p[2] * s,
p[1] * s + p[2] * c
];
}
function rotateY(p, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return [
p[0] * c + p[2] * s,
p[1],
-p[0] * s + p[2] * c
];
}
function transform3D(p, time) {
let q = p;
q = rotateX(q, -0.78);
q = rotateY(q, time * 0.00036);
return q;
}
function project3D(p, cx, cy, scale) {
const d = 7.5;
const k = d / (d + p[2]);
return {
x: cx + p[0] * scale * k,
y: cy - p[1] * scale * k,
z: p[2]
};
}
function labToPoint3D(lab, model) {
const n = neutralAtL(model.spine, lab.L);
return [
(lab.a - n.a) / 52,
(lab.L - 50) / 28,
(lab.b - n.b) / 52
];
}
function drawAxes(cx, cy, scale, time) {
const axes = [
{ a: [0, -1.75, 0], b: [0, 1.75, 0], color: "rgba(255,255,255,0.24)" },
{ a: [-1.85, 0, 0], b: [1.85, 0, 0], color: "rgba(255,120,120,0.24)" },
{ a: [0, 0, -1.85], b: [0, 0, 1.85], color: "rgba(120,180,255,0.24)" }
];
for (const ax of axes) {
const p0 = project3D(transform3D(ax.a, time), cx, cy, scale);
const p1 = project3D(transform3D(ax.b, time), cx, cy, scale);
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.strokeStyle = ax.color;
ctx.lineWidth = 1;
ctx.stroke();
}
}
function drawPointCloud(labs, model, cx, cy, scale, time, radiusBase) {
const rendered = labs.map(lab => {
const p = labToPoint3D(lab, model);
const q = transform3D(p, time);
return {
lab,
world: q,
screen: project3D(q, cx, cy, scale)
};
});
rendered.sort((a, b) => a.world[2] - b.world[2]);
for (const item of rendered) {
const lch = labToLch(item.lab, model.spine);
const radius = radiusBase + Math.min(2.0, lch.C / 70);
ctx.beginPath();
ctx.arc(item.screen.x, item.screen.y, radius, 0, Math.PI * 2);
ctx.fillStyle = labCss(item.lab);
ctx.globalAlpha = 0.82;
ctx.fill();
ctx.globalAlpha = 1;
}
}
function drawLineLab(points, model, cx, cy, scale, time, color, width) {
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const p3 = labToPoint3D(points[i], model);
const q = transform3D(p3, time);
const s = project3D(q, cx, cy, scale);
if (i === 0) ctx.moveTo(s.x, s.y);
else ctx.lineTo(s.x, s.y);
}
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.stroke();
}
function drawGamutSurface(model, cx, cy, scale, time) {
// final rings
for (let li = 4; li <= 36; li += 5) {
const L = model.spine.Lmin + (li / 40) * (model.spine.Lmax - model.spine.Lmin);
const ring = [];
for (let hi = 0; hi <= 144; hi++) {
const h = hi / 144 * 360;
const C = model.surfaceCAt(L, h);
ring.push(lchToLab(L, C, h, model.spine));
}
drawLineLab(ring, model, cx, cy, scale, time, "rgba(255,255,255,0.36)", 1.0);
}
// vertical hue lines
for (let hi = 0; hi < 12; hi++) {
const h = hi / 12 * 360;
const line = [];
for (let li = 0; li <= 40; li++) {
const L = model.spine.Lmin + (li / 40) * (model.spine.Lmax - model.spine.Lmin);
const C = model.surfaceCAt(L, h);
line.push(lchToLab(L, C, h, model.spine));
}
drawLineLab(line, model, cx, cy, scale, time, "rgba(255,255,255,0.24)", 1.0);
}
// raw waist ring
const rawRing = [];
for (let hi = 0; hi <= 144; hi++) {
const h = hi / 144 * 360;
const waistL = model.waistLAtLabHue(h);
const lNorm = clamp(
(waistL - model.spine.Lmin) / Math.max(1e-9, model.spine.Lmax - model.spine.Lmin),
0,
1
);
const C = bilinearCyclic(model.preSmoothGrid, lNorm, h);
rawRing.push(lchToLab(waistL, C, h, model.spine));
}
drawLineLab(rawRing, model, cx, cy, scale, time, "rgba(255,80,65,0.48)", 1.25);
// polygon waist vertices
const poly = model.vertices
.slice()
.sort((a, b) => a.h - b.h)
.map(v => ({
L: v.L,
a: v.a,
b: v.bLab
}));
poly.push(poly[0]);
drawLineLab(poly, model, cx, cy, scale, time, "rgba(255,255,255,0.75)", 1.8);
for (const v of model.vertices) {
const p3 = labToPoint3D(
{ L: v.L, a: v.a, b: v.bLab },
model
);
const q = transform3D(p3, time);
const s = project3D(q, cx, cy, scale);
ctx.beginPath();
ctx.arc(s.x, s.y, 5.2, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,255,255,0.96)";
ctx.fill();
ctx.fillStyle = "#fff";
ctx.font = "12px Arial";
ctx.fillText(v.name, s.x + 7, s.y - 7);
}
for (const item of [
{ label: "W", p: model.spine.white },
{ label: "K", p: model.spine.black }
]) {
const p3 = labToPoint3D(item.p, model);
const q = transform3D(p3, time);
const s = project3D(q, cx, cy, scale);
ctx.beginPath();
ctx.arc(s.x, s.y, 5.4, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,220,90,0.96)";
ctx.fill();
ctx.fillStyle = "#ffdc5a";
ctx.font = "12px Arial";
ctx.fillText(item.label, s.x + 7, s.y - 7);
}
// anti-hook gray spine
const spineLine = [];
for (let i = 0; i < model.spine.count; i++) {
spineLine.push({
L: model.spine.lBins[i],
a: model.spine.aBins[i],
b: model.spine.bBins[i]
});
}
drawLineLab(spineLine, model, cx, cy, scale, time, "rgba(255,220,90,0.72)", 1.7);
}
function drawCLUT(clut, model, cx, cy, scale, time) {
const skip = Math.max(1, Math.floor(clut.data.length / 2200));
const labs = [];
for (let i = 0; i < clut.data.length; i += skip) {
const p = clut.data[i];
labs.push({
L: p.L,
a: p.a,
b: p.bLab
});
}
drawPointCloud(labs, model, cx, cy, scale, time, 1.45);
}
function drawLabels() {
ctx.fillStyle = "rgba(255,255,255,0.14)";
ctx.fillRect(canvas.width / 2, 0, 1, canvas.height);
ctx.fillStyle = "rgba(255,255,255,0.20)";
ctx.font = "42px Arial";
ctx.fillText("→", canvas.width / 2 - 16, canvas.height / 2);
}
function draw() {
const now = performance.now();
const delta = now - lastFrame;
lastFrame = now;
if (rotationEnabled) {
rotationTime += delta;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawLabels();
if (state.model && state.clut) {
const leftCx = canvas.width * 0.37;
const rightCx = canvas.width * 0.75;
const cy = canvas.height * 0.56;
const scale = Math.min(canvas.width, canvas.height) * 0.18;
const sampleLabs = state.samples.map(s => ({
L: s.L,
a: s.a,
b: s.bLab
}));
drawAxes(leftCx, cy, scale, rotationTime);
drawPointCloud(sampleLabs, state.model, leftCx, cy, scale, rotationTime, 2.0);
drawGamutSurface(state.model, leftCx, cy, scale, rotationTime);
drawAxes(rightCx, cy, scale, rotationTime);
drawCLUT(state.clut, state.model, rightCx, cy, scale, rotationTime);
drawGamutSurface(state.model, rightCx, cy, scale, rotationTime);
ctx.fillStyle = "#8ecbff";
ctx.font = "14px Arial";
ctx.fillText("측색 RGB/Lab 샘플 + 검출 외곽/스파인", canvas.width * 0.27, canvas.height - 34);
ctx.fillStyle = "#9dffb7";
ctx.fillText("생성된 16³ RGB→PCS Lab CLUT", canvas.width * 0.66, canvas.height - 34);
} else {
ctx.fillStyle = "#ddd";
ctx.font = "18px Arial";
ctx.fillText("왼쪽 패널에서 데모 생성 또는 RGB/Lab 입력 후 A2B CLUT 생성을 누르세요.", 460, canvas.height / 2);
}
requestAnimationFrame(draw);
}
// ============================================================
// 13. Init
// ============================================================
sampleInput.value = makeDemoSampleText();
generateAll();
draw();
</script>
</body>
</html>haha