@freshcoat-js/for-print 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +114 -0
- package/package.json +36 -0
- package/src/analyze.d.ts +18 -0
- package/src/analyze.js +268 -0
- package/src/balance.d.ts +7 -0
- package/src/balance.js +104 -0
- package/src/calibration.d.ts +13 -0
- package/src/calibration.js +42 -0
- package/src/chart.d.ts +37 -0
- package/src/chart.js +255 -0
- package/src/geometry.d.ts +12 -0
- package/src/geometry.js +45 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +13 -0
- package/src/measure.d.ts +25 -0
- package/src/measure.js +274 -0
- package/src/plan.d.ts +17 -0
- package/src/plan.js +216 -0
- package/src/presets.d.ts +5 -0
- package/src/presets.js +26 -0
- package/src/profile.d.ts +37 -0
- package/src/profile.js +213 -0
- package/src/types.d.ts +41 -0
- package/src/types.js +6 -0
package/src/measure.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Reading a printed chart back out of a photograph.
|
|
2
|
+
//
|
|
3
|
+
// What this produces is CAMERA-SPACE, normalized to the bare card in the same
|
|
4
|
+
// shot — not colorimetry. That is deliberate and it is enough for the questions a
|
|
5
|
+
// card printer actually raises: is there a cast, where does a ramp stop
|
|
6
|
+
// responding, which hues come back turned. Each of those is a comparison between
|
|
7
|
+
// patches in one photograph, so the capture device's own response cancels and no
|
|
8
|
+
// reference target is needed. Absolute color would need one; these do not.
|
|
9
|
+
//
|
|
10
|
+
// The bare-stock patches are what make it work. On a dye-sub ribbon white means
|
|
11
|
+
// "lay down no dye", so those patches are the unprinted card, photographed under
|
|
12
|
+
// exactly the lighting everything else was. Dividing by them cancels the camera's
|
|
13
|
+
// white balance and the light's own color at once.
|
|
14
|
+
// Solve Ax = b by Gaussian elimination with partial pivoting. Small and dense —
|
|
15
|
+
// the system here is 8×8.
|
|
16
|
+
function solve(a, b) {
|
|
17
|
+
const n = b.length;
|
|
18
|
+
const m = a.map((row, i) => [...row, b[i]]);
|
|
19
|
+
for (let col = 0; col < n; col++) {
|
|
20
|
+
let pivot = col;
|
|
21
|
+
for (let r = col + 1; r < n; r++) {
|
|
22
|
+
if (Math.abs(m[r][col]) > Math.abs(m[pivot][col]))
|
|
23
|
+
pivot = r;
|
|
24
|
+
}
|
|
25
|
+
if (Math.abs(m[pivot][col]) < 1e-12)
|
|
26
|
+
return null; // degenerate
|
|
27
|
+
[m[col], m[pivot]] = [m[pivot], m[col]];
|
|
28
|
+
for (let r = 0; r < n; r++) {
|
|
29
|
+
if (r === col)
|
|
30
|
+
continue;
|
|
31
|
+
const f = m[r][col] / m[col][col];
|
|
32
|
+
for (let c = col; c <= n; c++)
|
|
33
|
+
m[r][c] -= f * m[col][c];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return m.map((row, i) => row[n] / row[i]);
|
|
37
|
+
}
|
|
38
|
+
// Whether four points actually describe a card: a convex quadrilateral with real
|
|
39
|
+
// area, taken in order.
|
|
40
|
+
//
|
|
41
|
+
// The linear solve is not enough on its own to reject a bad set of picks. Four
|
|
42
|
+
// points on a line have a perfectly good projective map onto them — the one that
|
|
43
|
+
// collapses the plane onto that line — so the system solves, every patch samples
|
|
44
|
+
// somewhere inside the image, and the reader returns confident nonsense. Same for
|
|
45
|
+
// corners clicked out of order: that maps the card onto a bow tie, folded through
|
|
46
|
+
// itself, and again every sample lands on real pixels.
|
|
47
|
+
//
|
|
48
|
+
// Both are checked here instead, where "is this a card" is a question about the
|
|
49
|
+
// four points rather than about a matrix.
|
|
50
|
+
function isProperQuad(p) {
|
|
51
|
+
const xs = p.map((q) => q.x);
|
|
52
|
+
const ys = p.map((q) => q.y);
|
|
53
|
+
const extent = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
|
|
54
|
+
if (extent <= 0)
|
|
55
|
+
return false;
|
|
56
|
+
// Shoelace area. Scaled against the extent so the test is about shape, not
|
|
57
|
+
// about how big in the frame the card happened to be.
|
|
58
|
+
let twiceArea = 0;
|
|
59
|
+
for (let i = 0; i < 4; i++) {
|
|
60
|
+
const a = p[i];
|
|
61
|
+
const b = p[(i + 1) % 4];
|
|
62
|
+
twiceArea += a.x * b.y - b.x * a.y;
|
|
63
|
+
}
|
|
64
|
+
if (Math.abs(twiceArea) / 2 < extent * extent * 0.01)
|
|
65
|
+
return false;
|
|
66
|
+
// Every corner turning the same way — convex, and in order.
|
|
67
|
+
let winding = 0;
|
|
68
|
+
for (let i = 0; i < 4; i++) {
|
|
69
|
+
const a = p[i];
|
|
70
|
+
const b = p[(i + 1) % 4];
|
|
71
|
+
const c = p[(i + 2) % 4];
|
|
72
|
+
const cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
|
|
73
|
+
if (cross === 0)
|
|
74
|
+
return false;
|
|
75
|
+
const turn = Math.sign(cross);
|
|
76
|
+
if (winding === 0)
|
|
77
|
+
winding = turn;
|
|
78
|
+
else if (turn !== winding)
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
// The transform taking the chart's four registration points to the four points
|
|
84
|
+
// picked in the photo, both clockwise from top-left. Null if the picks do not
|
|
85
|
+
// describe a card — collinear, out of order, or two of them the same.
|
|
86
|
+
export function homographyFrom(from, to) {
|
|
87
|
+
if (!isProperQuad(from) || !isProperQuad(to))
|
|
88
|
+
return null;
|
|
89
|
+
const a = [];
|
|
90
|
+
const b = [];
|
|
91
|
+
for (let i = 0; i < 4; i++) {
|
|
92
|
+
const { x, y } = from[i];
|
|
93
|
+
const { x: u, y: v } = to[i];
|
|
94
|
+
a.push([x, y, 1, 0, 0, 0, -x * u, -y * u]);
|
|
95
|
+
b.push(u);
|
|
96
|
+
a.push([0, 0, 0, x, y, 1, -x * v, -y * v]);
|
|
97
|
+
b.push(v);
|
|
98
|
+
}
|
|
99
|
+
const h = solve(a, b);
|
|
100
|
+
return h ? [...h, 1] : null;
|
|
101
|
+
}
|
|
102
|
+
export function applyHomography(h, p) {
|
|
103
|
+
const w = h[6] * p.x + h[7] * p.y + h[8];
|
|
104
|
+
return {
|
|
105
|
+
x: (h[0] * p.x + h[1] * p.y + h[2]) / w,
|
|
106
|
+
y: (h[3] * p.x + h[4] * p.y + h[5]) / w,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const median = (xs) => {
|
|
110
|
+
if (xs.length === 0)
|
|
111
|
+
return 0;
|
|
112
|
+
const s = [...xs].sort((p, q) => p - q);
|
|
113
|
+
const mid = s.length >> 1;
|
|
114
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
115
|
+
};
|
|
116
|
+
// Fraction of a patch's width/height actually sampled, centered. Keeps the
|
|
117
|
+
// reader off the edges, where a patch bleeds into its neighbour and a slight
|
|
118
|
+
// misregistration would otherwise mix two colors into one reading.
|
|
119
|
+
const SAMPLE_INSET = 0.6;
|
|
120
|
+
// Samples per axis inside that window.
|
|
121
|
+
const SAMPLE_GRID = 7;
|
|
122
|
+
// The median of a patch's samples. Median rather than mean because glossy PVC
|
|
123
|
+
// throws specular highlights: a few blown pixels drag a mean a long way and leave
|
|
124
|
+
// a median where it was.
|
|
125
|
+
function samplePatch(image, h, patch) {
|
|
126
|
+
const rs = [];
|
|
127
|
+
const gs = [];
|
|
128
|
+
const bs = [];
|
|
129
|
+
const x0 = patch.x + (patch.width * (1 - SAMPLE_INSET)) / 2;
|
|
130
|
+
const y0 = patch.y + (patch.height * (1 - SAMPLE_INSET)) / 2;
|
|
131
|
+
const w = patch.width * SAMPLE_INSET;
|
|
132
|
+
const hgt = patch.height * SAMPLE_INSET;
|
|
133
|
+
for (let iy = 0; iy < SAMPLE_GRID; iy++) {
|
|
134
|
+
for (let ix = 0; ix < SAMPLE_GRID; ix++) {
|
|
135
|
+
const p = applyHomography(h, {
|
|
136
|
+
x: x0 + (w * (ix + 0.5)) / SAMPLE_GRID,
|
|
137
|
+
y: y0 + (hgt * (iy + 0.5)) / SAMPLE_GRID,
|
|
138
|
+
});
|
|
139
|
+
const px = Math.round(p.x);
|
|
140
|
+
const py = Math.round(p.y);
|
|
141
|
+
if (px < 0 || py < 0 || px >= image.width || py >= image.height)
|
|
142
|
+
continue;
|
|
143
|
+
const o = (py * image.width + px) * 4;
|
|
144
|
+
rs.push(image.data[o]);
|
|
145
|
+
gs.push(image.data[o + 1]);
|
|
146
|
+
bs.push(image.data[o + 2]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (rs.length === 0)
|
|
150
|
+
return null;
|
|
151
|
+
return [median(rs), median(gs), median(bs)];
|
|
152
|
+
}
|
|
153
|
+
// Read every patch of a chart out of a photograph. `corners` are the four
|
|
154
|
+
// fiducial centers as picked in the image, clockwise from top-left.
|
|
155
|
+
export function readChart(image, spec, corners) {
|
|
156
|
+
const h = homographyFrom(spec.registration, corners);
|
|
157
|
+
if (!h)
|
|
158
|
+
return null;
|
|
159
|
+
const raw = new Map();
|
|
160
|
+
const missed = [];
|
|
161
|
+
for (const patch of spec.patches) {
|
|
162
|
+
if (patch.role === "fiducial")
|
|
163
|
+
continue;
|
|
164
|
+
const v = samplePatch(image, h, patch);
|
|
165
|
+
if (v)
|
|
166
|
+
raw.set(patch.id, v);
|
|
167
|
+
else
|
|
168
|
+
missed.push(patch.id);
|
|
169
|
+
}
|
|
170
|
+
// The white reference: the median of the bare-stock patches, so one blown or
|
|
171
|
+
// shadowed corner doesn't set the balance for the whole card.
|
|
172
|
+
const stockValues = spec.patches
|
|
173
|
+
.filter((p) => p.role === "stock")
|
|
174
|
+
.map((p) => raw.get(p.id))
|
|
175
|
+
.filter((v) => !!v);
|
|
176
|
+
const stock = stockValues.length
|
|
177
|
+
? [
|
|
178
|
+
median(stockValues.map((v) => v[0])),
|
|
179
|
+
median(stockValues.map((v) => v[1])),
|
|
180
|
+
median(stockValues.map((v) => v[2])),
|
|
181
|
+
]
|
|
182
|
+
: [255, 255, 255];
|
|
183
|
+
// Per-channel gain that lands the bare card on neutral, keeping its level. Any
|
|
184
|
+
// color left in a patch after this is the print's, not the light's.
|
|
185
|
+
const level = (stock[0] + stock[1] + stock[2]) / 3;
|
|
186
|
+
const gain = stock.map((c) => (c > 0 ? level / c : 1));
|
|
187
|
+
const stockClipped = stock.some((c) => c >= 254 || c <= 1);
|
|
188
|
+
// Measured before the gain is applied: the gain is one number for the whole
|
|
189
|
+
// card, so it shifts every stock patch together and cannot change how far apart
|
|
190
|
+
// they are.
|
|
191
|
+
const lightSpread = stockValues.length
|
|
192
|
+
? Math.max(...[0, 1, 2].map((c) => {
|
|
193
|
+
const vs = stockValues.map((v) => v[c]);
|
|
194
|
+
return Math.max(...vs) - Math.min(...vs);
|
|
195
|
+
}))
|
|
196
|
+
: 0;
|
|
197
|
+
const patches = [];
|
|
198
|
+
for (const patch of spec.patches) {
|
|
199
|
+
if (patch.role === "fiducial")
|
|
200
|
+
continue;
|
|
201
|
+
const v = raw.get(patch.id);
|
|
202
|
+
if (!v)
|
|
203
|
+
continue;
|
|
204
|
+
patches.push({
|
|
205
|
+
id: patch.id,
|
|
206
|
+
role: patch.role,
|
|
207
|
+
sent: patch.rgb,
|
|
208
|
+
raw: v,
|
|
209
|
+
measured: [v[0] * gain[0], v[1] * gain[1], v[2] * gain[2]],
|
|
210
|
+
...(patch.repeatOf ? { repeatOf: patch.repeatOf } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
chartId: spec.id,
|
|
215
|
+
patches,
|
|
216
|
+
stock,
|
|
217
|
+
stockClipped,
|
|
218
|
+
lightSpread,
|
|
219
|
+
missed,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// ── Reading the reading ────────────────────────────────────────────────────
|
|
223
|
+
// The color left in patches that were sent neutral, per channel, as a signed
|
|
224
|
+
// deviation from their own mean. A printer with no cast reads ~0 across the
|
|
225
|
+
// board; the sign says which way each channel runs. This is the number that
|
|
226
|
+
// answers "why does everything look magenta".
|
|
227
|
+
export function grayCast(reading) {
|
|
228
|
+
const grays = reading.patches.filter((p) => p.role === "measure" &&
|
|
229
|
+
p.sent[0] === p.sent[1] &&
|
|
230
|
+
p.sent[1] === p.sent[2]);
|
|
231
|
+
if (grays.length === 0)
|
|
232
|
+
return [0, 0, 0];
|
|
233
|
+
const sum = [0, 0, 0];
|
|
234
|
+
for (const p of grays) {
|
|
235
|
+
const mean = (p.measured[0] + p.measured[1] + p.measured[2]) / 3;
|
|
236
|
+
sum[0] += p.measured[0] - mean;
|
|
237
|
+
sum[1] += p.measured[1] - mean;
|
|
238
|
+
sum[2] += p.measured[2] - mean;
|
|
239
|
+
}
|
|
240
|
+
return [sum[0] / grays.length, sum[1] / grays.length, sum[2] / grays.length];
|
|
241
|
+
}
|
|
242
|
+
// The largest disagreement between any patch and a repeat of it, in levels. This
|
|
243
|
+
// is the sheet's evenness, and it bounds what a profile built from the card can
|
|
244
|
+
// be worth — a printer that varies more than it corrects is not ready to profile.
|
|
245
|
+
export function repeatSpread(reading) {
|
|
246
|
+
const byId = new Map(reading.patches.map((p) => [p.id, p]));
|
|
247
|
+
let worst = 0;
|
|
248
|
+
for (const p of reading.patches) {
|
|
249
|
+
if (!p.repeatOf)
|
|
250
|
+
continue;
|
|
251
|
+
const base = byId.get(p.repeatOf);
|
|
252
|
+
if (!base)
|
|
253
|
+
continue;
|
|
254
|
+
for (let c = 0; c < 3; c++) {
|
|
255
|
+
worst = Math.max(worst, Math.abs(p.measured[c] - base.measured[c]));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return worst;
|
|
259
|
+
}
|
|
260
|
+
// Readings as CSV — the handoff to a spreadsheet, or to whoever is building the
|
|
261
|
+
// LUT. One row per patch, what went in beside what came back.
|
|
262
|
+
export function readingToCsv(reading) {
|
|
263
|
+
const rows = [
|
|
264
|
+
"patch_id,role,sent_r,sent_g,sent_b,raw_r,raw_g,raw_b,measured_r,measured_g,measured_b",
|
|
265
|
+
...reading.patches.map((p) => [
|
|
266
|
+
p.id,
|
|
267
|
+
p.role,
|
|
268
|
+
...p.sent,
|
|
269
|
+
...p.raw.map((v) => v.toFixed(1)),
|
|
270
|
+
...p.measured.map((v) => v.toFixed(1)),
|
|
271
|
+
].join(",")),
|
|
272
|
+
];
|
|
273
|
+
return `${rows.join("\n")}\n`;
|
|
274
|
+
}
|
package/src/plan.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Adjust, ImageNode, Node } from "@freshcoat-js/engine";
|
|
2
|
+
import type { ChannelBalance, ImageAnalysis, PixelData, PrintOptimizeOptions } from "./types.js";
|
|
3
|
+
export type LayerIntent = "photo" | "graphic" | "text" | "code" | "container";
|
|
4
|
+
export type LayerIntentResolver = (node: Node) => LayerIntent | undefined;
|
|
5
|
+
export declare function classifyIntent(node: Node): LayerIntent;
|
|
6
|
+
export declare function printAdjust(o: PrintOptimizeOptions): Adjust;
|
|
7
|
+
export type PlanPolicy = {
|
|
8
|
+
intentFor?: LayerIntentResolver;
|
|
9
|
+
photo?: PrintOptimizeOptions | null;
|
|
10
|
+
graphic?: PrintOptimizeOptions | null;
|
|
11
|
+
text?: PrintOptimizeOptions | null;
|
|
12
|
+
code?: PrintOptimizeOptions | null;
|
|
13
|
+
balance?: ChannelBalance;
|
|
14
|
+
};
|
|
15
|
+
export declare function planScene(root: Node, policy?: PlanPolicy): Node;
|
|
16
|
+
export type ImageSampler = (image: ImageNode) => Promise<PixelData>;
|
|
17
|
+
export declare function analyzeScene(sample: ImageSampler, root: Node, policy?: PlanPolicy, onAnalysis?: (analysis: ImageAnalysis, node: ImageNode) => void): Promise<Node>;
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// The planner — for-print as a consumer of freshcoat's generic adjustment API.
|
|
2
|
+
//
|
|
3
|
+
// Instead of flattening a card and correcting one image, planScene walks a
|
|
4
|
+
// freshcoat Node tree, classifies each layer by print intent, and attaches a
|
|
5
|
+
// generic freshcoat `Adjust` so the layer is corrected PER-LAYER as freshcoat
|
|
6
|
+
// paints it. Photos get dye-sub compensation; crisp vector/text/QR are left
|
|
7
|
+
// pristine — the "protect text" that per-layer planning gives for free (no mask
|
|
8
|
+
// needed: you simply don't adjust those layers).
|
|
9
|
+
//
|
|
10
|
+
// The dependency points at freshcoat only: for-print maps its policy
|
|
11
|
+
// (PrintOptimizeOptions) down to freshcoat's math (color matrix + LUT + sharpen).
|
|
12
|
+
// freshcoat never learns what "dye-sub" or "K panel" means.
|
|
13
|
+
import { analyzePixels, correctionMatrix } from "./analyze.js";
|
|
14
|
+
import { NO_PROCESSING, YMCKO_PRESET } from "./presets.js";
|
|
15
|
+
// Pure channel helpers the tone LUT needs (the raster steps that once held these
|
|
16
|
+
// moved to freshcoat's output path).
|
|
17
|
+
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)));
|
|
18
|
+
// Photoshop Overlay blend of a base channel against `blend`.
|
|
19
|
+
const overlayBlendChannel = (base, blend) => base < 128
|
|
20
|
+
? (2 * base * blend) / 255
|
|
21
|
+
: 255 - (2 * (255 - base) * (255 - blend)) / 255;
|
|
22
|
+
// Classify a node by kind. Images are photographic (need dye-sub compensation);
|
|
23
|
+
// text and QR/pixel bitmaps are crisp intent that must stay untouched; rect/
|
|
24
|
+
// ellipse/path are flat vector graphics whose colors were chosen deliberately;
|
|
25
|
+
// group/mask are containers we only recurse into.
|
|
26
|
+
export function classifyIntent(node) {
|
|
27
|
+
switch (node.kind) {
|
|
28
|
+
case "image":
|
|
29
|
+
return "photo";
|
|
30
|
+
case "text":
|
|
31
|
+
return "text";
|
|
32
|
+
case "bitmap":
|
|
33
|
+
return "code";
|
|
34
|
+
case "rect":
|
|
35
|
+
case "ellipse":
|
|
36
|
+
case "path":
|
|
37
|
+
return "graphic";
|
|
38
|
+
default:
|
|
39
|
+
return "container"; // group | mask
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// One channel's tone LUT, folding gamma then the darkness (Overlay-with-black)
|
|
43
|
+
// step — both per-channel, so they compose into one 256-entry table. Mirrors the
|
|
44
|
+
// order and math of the gamma + darkness pixel steps.
|
|
45
|
+
//
|
|
46
|
+
// `balance` is this channel's cast exponent and applies LAST, after the tone
|
|
47
|
+
// steps: it corrects what the printer does to the finished signal, so it belongs
|
|
48
|
+
// at the end of the chain rather than folded into `gamma`. At 1 the table is
|
|
49
|
+
// bit-identical to what it was before balance existed.
|
|
50
|
+
function toneLut(gamma, darkness, balance = 1) {
|
|
51
|
+
const t = new Uint8Array(256);
|
|
52
|
+
for (let i = 0; i < 256; i++) {
|
|
53
|
+
// gamma is a rounded integer LUT (like gammaStep); darkness then operates on
|
|
54
|
+
// that rounded value — staging the two rounds so this matches the pixel steps
|
|
55
|
+
// run in sequence to the LSB.
|
|
56
|
+
let v = clamp(255 * (i / 255) ** gamma);
|
|
57
|
+
if (darkness > 0) {
|
|
58
|
+
// darkness = Overlay(base, black) at `darkness` opacity, per channel.
|
|
59
|
+
v = clamp(v * (1 - darkness) + overlayBlendChannel(v, 0) * darkness);
|
|
60
|
+
}
|
|
61
|
+
if (balance !== 1)
|
|
62
|
+
v = clamp(255 * (v / 255) ** balance);
|
|
63
|
+
t[i] = v;
|
|
64
|
+
}
|
|
65
|
+
return t;
|
|
66
|
+
}
|
|
67
|
+
const NEUTRAL_BALANCE = (b) => !b || (b.r === 1 && b.g === 1 && b.b === 1);
|
|
68
|
+
// Map print correction options to a generic freshcoat Adjust: saturation +
|
|
69
|
+
// contrast fold into the color matrix (cross-channel/linear), gamma + darkness
|
|
70
|
+
// into the per-channel LUT, sharpness passes through as sharpen. The conjunctive
|
|
71
|
+
// white-clamp / black-extraction ops are NOT here — they need all three channels
|
|
72
|
+
// together, so they live in freshcoat's whole-frame FrameFinish (YMCKO_FINISH).
|
|
73
|
+
export function printAdjust(o) {
|
|
74
|
+
const adjust = {};
|
|
75
|
+
const m = correctionMatrix(o);
|
|
76
|
+
if (m) {
|
|
77
|
+
adjust.colorMatrix = m;
|
|
78
|
+
// Card art is largely saturated brand color, where a boost drives the leading
|
|
79
|
+
// channel past full scale. Clipping it there would rotate the hue — an indigo
|
|
80
|
+
// pins blue and drifts magenta — and flatten the gradient it came from, so the
|
|
81
|
+
// correction gives up saturation instead.
|
|
82
|
+
adjust.gamut = "preserve-hue";
|
|
83
|
+
}
|
|
84
|
+
// One table per channel only when the balance asks for it. With no cast
|
|
85
|
+
// measured, all three are the same table and the same object — the shape
|
|
86
|
+
// freshcoat has always been handed.
|
|
87
|
+
const darkness = o.darkness ?? 0;
|
|
88
|
+
if (NEUTRAL_BALANCE(o.balance)) {
|
|
89
|
+
if (o.gamma !== 1 || darkness > 0) {
|
|
90
|
+
const lut = toneLut(o.gamma, darkness);
|
|
91
|
+
adjust.lut = { r: lut, g: lut, b: lut };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const b = o.balance;
|
|
96
|
+
adjust.lut = {
|
|
97
|
+
r: toneLut(o.gamma, darkness, b.r),
|
|
98
|
+
g: toneLut(o.gamma, darkness, b.g),
|
|
99
|
+
b: toneLut(o.gamma, darkness, b.b),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (o.sharpness > 0)
|
|
103
|
+
adjust.sharpen = o.sharpness;
|
|
104
|
+
return adjust;
|
|
105
|
+
}
|
|
106
|
+
function intentFor(node, policy) {
|
|
107
|
+
return policy.intentFor?.(node) ?? classifyIntent(node);
|
|
108
|
+
}
|
|
109
|
+
function policyFor(intent, policy) {
|
|
110
|
+
switch (intent) {
|
|
111
|
+
case "photo":
|
|
112
|
+
return policy.photo ?? null;
|
|
113
|
+
case "graphic":
|
|
114
|
+
return policy.graphic ?? null;
|
|
115
|
+
case "text":
|
|
116
|
+
return policy.text ?? null;
|
|
117
|
+
case "code":
|
|
118
|
+
return policy.code ?? null;
|
|
119
|
+
default:
|
|
120
|
+
return null; // container
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Attach an adjust to a leaf if the options produce a non-empty one; otherwise
|
|
124
|
+
// return the node untouched (no empty `adjust` field). A measured `balance`
|
|
125
|
+
// reaches a layer the intent left alone as the only correction it carries.
|
|
126
|
+
function withAdjust(node, opts, balance) {
|
|
127
|
+
const merged = opts
|
|
128
|
+
? balance
|
|
129
|
+
? { ...opts, balance }
|
|
130
|
+
: opts
|
|
131
|
+
: balance
|
|
132
|
+
? { ...NO_PROCESSING, balance }
|
|
133
|
+
: null;
|
|
134
|
+
if (!merged)
|
|
135
|
+
return node;
|
|
136
|
+
const adjust = printAdjust(merged);
|
|
137
|
+
return Object.keys(adjust).length > 0 ? { ...node, adjust } : node;
|
|
138
|
+
}
|
|
139
|
+
// Walk a tree, mapping every node through `leaf` (for drawables) while recursing
|
|
140
|
+
// into group/mask containers. Containers themselves are never adjusted — only
|
|
141
|
+
// their leaves — so corrections apply per drawable, not to a composited subtree.
|
|
142
|
+
function mapTree(node, leaf) {
|
|
143
|
+
if (node.kind === "group") {
|
|
144
|
+
return { ...node, children: node.children.map((c) => mapTree(c, leaf)) };
|
|
145
|
+
}
|
|
146
|
+
if (node.kind === "mask") {
|
|
147
|
+
return {
|
|
148
|
+
...node,
|
|
149
|
+
mask: mapTree(node.mask, leaf),
|
|
150
|
+
children: node.children.map((c) => mapTree(c, leaf)),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return leaf(node);
|
|
154
|
+
}
|
|
155
|
+
const DEFAULT_POLICY = { photo: YMCKO_PRESET };
|
|
156
|
+
// Sync planner: attach a policy-driven Adjust per layer, no image I/O. Photos get
|
|
157
|
+
// the policy's photo preset (YMCKO by default); text/QR/graphics are left pristine
|
|
158
|
+
// unless the policy opts them in. Returns a new tree; the input is not mutated.
|
|
159
|
+
export function planScene(root, policy = {}) {
|
|
160
|
+
const p = { ...DEFAULT_POLICY, ...policy };
|
|
161
|
+
return mapTree(root, (node) => withAdjust(node, policyFor(intentFor(node, p), p), p.balance));
|
|
162
|
+
}
|
|
163
|
+
// Async planner: like planScene, but each photo layer is ANALYZED (per-image
|
|
164
|
+
// brightness/saturation/contrast) and corrected with its own recommendation —
|
|
165
|
+
// the core win over correcting one flattened card. Non-photo intents follow
|
|
166
|
+
// `policy` (default: untouched). Pass `policy.photo` explicitly to override
|
|
167
|
+
// analysis: `null` leaves photos alone, a preset forces a fixed correction.
|
|
168
|
+
// Recommendations are cached per src.
|
|
169
|
+
export async function analyzeScene(sample, root, policy = {},
|
|
170
|
+
// Called once per analyzed photo with what analysis found — the gamut pressure
|
|
171
|
+
// especially, which nothing downstream can recover once the render has clamped.
|
|
172
|
+
// Not called for layers an explicit `policy.photo` opted out of analysis.
|
|
173
|
+
onAnalysis) {
|
|
174
|
+
const analyzePhotos = !("photo" in policy);
|
|
175
|
+
// Cache the in-flight PROMISE, not the resolved value: children walk
|
|
176
|
+
// concurrently (Promise.all), so identical layers would otherwise both miss a
|
|
177
|
+
// value-cache and sample twice. Key on the rendered appearance (src + fit +
|
|
178
|
+
// size), since the same src cropped differently analyzes differently.
|
|
179
|
+
const cache = new Map();
|
|
180
|
+
const sampleKey = (n) => `${n.src}|${n.fit}|${Math.round(n.size?.width ?? 0)}x${Math.round(n.size?.height ?? 0)}`;
|
|
181
|
+
function photoOptions(node) {
|
|
182
|
+
if (!analyzePhotos)
|
|
183
|
+
return Promise.resolve(policy.photo ?? null);
|
|
184
|
+
const key = sampleKey(node);
|
|
185
|
+
let analysis = cache.get(key);
|
|
186
|
+
if (!analysis) {
|
|
187
|
+
// Reported on the miss, so duplicated layers are one analysis and one
|
|
188
|
+
// report — the same pixels counted twice would overstate the pressure.
|
|
189
|
+
analysis = sample(node).then((px) => {
|
|
190
|
+
const result = analyzePixels(px);
|
|
191
|
+
onAnalysis?.(result, node);
|
|
192
|
+
return result;
|
|
193
|
+
});
|
|
194
|
+
cache.set(key, analysis);
|
|
195
|
+
}
|
|
196
|
+
return analysis.then((a) => a.recommendation);
|
|
197
|
+
}
|
|
198
|
+
async function walk(node) {
|
|
199
|
+
if (node.kind === "group") {
|
|
200
|
+
return { ...node, children: await Promise.all(node.children.map(walk)) };
|
|
201
|
+
}
|
|
202
|
+
if (node.kind === "mask") {
|
|
203
|
+
const [mask, children] = await Promise.all([
|
|
204
|
+
walk(node.mask),
|
|
205
|
+
Promise.all(node.children.map(walk)),
|
|
206
|
+
]);
|
|
207
|
+
return { ...node, mask, children };
|
|
208
|
+
}
|
|
209
|
+
const intent = intentFor(node, policy);
|
|
210
|
+
if (node.kind === "image" && intent === "photo") {
|
|
211
|
+
return withAdjust(node, await photoOptions(node), policy.balance);
|
|
212
|
+
}
|
|
213
|
+
return withAdjust(node, policyFor(intent, policy), policy.balance);
|
|
214
|
+
}
|
|
215
|
+
return walk(root);
|
|
216
|
+
}
|
package/src/presets.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { FrameFinish } from "@freshcoat-js/engine";
|
|
2
|
+
import type { PrintOptimizeOptions } from "./types.js";
|
|
3
|
+
export declare const YMCKO_PRESET: PrintOptimizeOptions;
|
|
4
|
+
export declare const NO_PROCESSING: PrintOptimizeOptions;
|
|
5
|
+
export declare const YMCKO_FINISH: FrameFinish;
|
package/src/presets.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Tuned for YMCKO dye-sublimation ribbon printers.
|
|
2
|
+
export const YMCKO_PRESET = {
|
|
3
|
+
saturation: 1.3,
|
|
4
|
+
contrast: 1.2,
|
|
5
|
+
sharpness: 0.3,
|
|
6
|
+
gamma: 0.85,
|
|
7
|
+
darkness: 0.25,
|
|
8
|
+
};
|
|
9
|
+
export const NO_PROCESSING = {
|
|
10
|
+
saturation: 1,
|
|
11
|
+
contrast: 1,
|
|
12
|
+
sharpness: 0,
|
|
13
|
+
gamma: 1,
|
|
14
|
+
darkness: 0,
|
|
15
|
+
};
|
|
16
|
+
// The whole-frame finishing for-print recommends alongside the per-layer
|
|
17
|
+
// adjustments — the conjunctive/spatial output ops that aren't a per-layer Adjust.
|
|
18
|
+
// Pass to freshcoat's compile/render `finish`. Mirrors the old buffer pipeline's
|
|
19
|
+
// defaults: snap near-black (<30) to resin-friendly black, near-white (>248) to
|
|
20
|
+
// white, and ±2 levels of deterministic monochrome dither to break dye-sub
|
|
21
|
+
// gradient banding without colored grain.
|
|
22
|
+
export const YMCKO_FINISH = {
|
|
23
|
+
blackExtract: 30,
|
|
24
|
+
whiteClamp: 248,
|
|
25
|
+
dither: { amount: 2, seed: 0, mode: "monochrome" },
|
|
26
|
+
};
|
package/src/profile.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type CalibrationAssessment } from "./calibration.js";
|
|
2
|
+
import type { ChartReading } from "./measure.js";
|
|
3
|
+
import type { ChannelBalance, PrintOptimizeOptions } from "./types.js";
|
|
4
|
+
export interface PrintProfileConditions {
|
|
5
|
+
printer?: string;
|
|
6
|
+
ribbon?: string;
|
|
7
|
+
stock?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PrintProfile {
|
|
10
|
+
version?: 1;
|
|
11
|
+
name: string;
|
|
12
|
+
balance?: ChannelBalance;
|
|
13
|
+
measuredAt?: string;
|
|
14
|
+
notes?: string;
|
|
15
|
+
conditions?: PrintProfileConditions;
|
|
16
|
+
assessment?: CalibrationAssessment;
|
|
17
|
+
}
|
|
18
|
+
export type PrintProfileDetails = {
|
|
19
|
+
name: string;
|
|
20
|
+
measuredAt?: string;
|
|
21
|
+
notes?: string;
|
|
22
|
+
conditions?: PrintProfileConditions;
|
|
23
|
+
};
|
|
24
|
+
export type PrintProfileCreation = {
|
|
25
|
+
ok: true;
|
|
26
|
+
profile: PrintProfile;
|
|
27
|
+
assessment: CalibrationAssessment;
|
|
28
|
+
} | {
|
|
29
|
+
ok: false;
|
|
30
|
+
reason: "unsafe-reading" | "missing-name" | "invalid-conditions";
|
|
31
|
+
assessment: CalibrationAssessment;
|
|
32
|
+
};
|
|
33
|
+
export declare const UNMEASURED_PROFILE: PrintProfile;
|
|
34
|
+
export declare function createPrintProfile(reading: ChartReading, details: PrintProfileDetails): PrintProfileCreation;
|
|
35
|
+
export declare function withProfile(options: PrintOptimizeOptions, profile: PrintProfile | undefined): PrintOptimizeOptions;
|
|
36
|
+
export declare function profileCacheKey(profile: PrintProfile | undefined): string;
|
|
37
|
+
export declare function parsePrintProfile(input: unknown): PrintProfile | Error;
|