@xprite/pixelate 0.0.0-stage → 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 +21 -0
- package/NOTICE.md +12 -0
- package/README.md +238 -2
- package/README.zh-CN.md +209 -0
- package/dist/analyze.d.ts +8 -0
- package/dist/analyze.js +217 -0
- package/dist/base.d.ts +18 -0
- package/dist/base.js +42 -0
- package/dist/cell-color-picker.d.ts +14 -0
- package/dist/cell-color-picker.js +278 -0
- package/dist/color-lab.d.ts +3 -0
- package/dist/color-lab.js +31 -0
- package/dist/color-step.d.ts +33 -0
- package/dist/color-step.js +62 -0
- package/dist/contours.d.ts +4 -0
- package/dist/contours.js +66 -0
- package/dist/denoise.d.ts +3 -0
- package/dist/denoise.js +37 -0
- package/dist/deskew.d.ts +24 -0
- package/dist/deskew.js +202 -0
- package/dist/grid-ac-rl-consensus.d.ts +127 -0
- package/dist/grid-ac-rl-consensus.js +497 -0
- package/dist/grid-arbitrate.d.ts +52 -0
- package/dist/grid-arbitrate.js +366 -0
- package/dist/grid-cut-snap.d.ts +15 -0
- package/dist/grid-cut-snap.js +77 -0
- package/dist/grid-description-length.d.ts +47 -0
- package/dist/grid-description-length.js +697 -0
- package/dist/grid-detect.d.ts +8 -0
- package/dist/grid-detect.js +665 -0
- package/dist/grid-edge-coherence.d.ts +4 -0
- package/dist/grid-edge-coherence.js +158 -0
- package/dist/grid-edge-path.d.ts +9 -0
- package/dist/grid-edge-path.js +201 -0
- package/dist/grid-frame-lattice.d.ts +25 -0
- package/dist/grid-frame-lattice.js +420 -0
- package/dist/grid-frequency-spectrum.d.ts +11 -0
- package/dist/grid-frequency-spectrum.js +89 -0
- package/dist/grid-period-boundary.d.ts +13 -0
- package/dist/grid-period-boundary.js +255 -0
- package/dist/grid-period-comb.d.ts +4 -0
- package/dist/grid-period-comb.js +230 -0
- package/dist/grid-period-distance.d.ts +7 -0
- package/dist/grid-period-distance.js +233 -0
- package/dist/grid-phase-field.d.ts +11 -0
- package/dist/grid-phase-field.js +446 -0
- package/dist/grid-pitch-lattice.d.ts +4 -0
- package/dist/grid-pitch-lattice.js +255 -0
- package/dist/grid-pitch-search.d.ts +11 -0
- package/dist/grid-pitch-search.js +233 -0
- package/dist/grid-sample.d.ts +4 -0
- package/dist/grid-sample.js +164 -0
- package/dist/grid-sampling.d.ts +13 -0
- package/dist/grid-sampling.js +132 -0
- package/dist/grid-strip-shift.d.ts +44 -0
- package/dist/grid-strip-shift.js +223 -0
- package/dist/grid-test-images.d.ts +69 -0
- package/dist/grid-test-images.js +547 -0
- package/dist/grid-warp.d.ts +38 -0
- package/dist/grid-warp.js +252 -0
- package/dist/grid.d.ts +8 -0
- package/dist/grid.js +43 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +14 -0
- package/dist/line-guided/cv-lab.d.ts +4 -0
- package/dist/line-guided/cv-lab.js +134 -0
- package/dist/line-guided/cv-ops.d.ts +29 -0
- package/dist/line-guided/cv-ops.js +759 -0
- package/dist/line-guided/pixeloe-entry.d.ts +15 -0
- package/dist/line-guided/pixeloe-entry.js +122 -0
- package/dist/line-guided/pixeloe-legacy.d.ts +31 -0
- package/dist/line-guided/pixeloe-legacy.js +301 -0
- package/dist/line-guided/xdog.d.ts +7 -0
- package/dist/line-guided/xdog.js +84 -0
- package/dist/most-frequent.d.ts +3 -0
- package/dist/most-frequent.js +56 -0
- package/dist/outline-prepass.d.ts +37 -0
- package/dist/outline-prepass.js +56 -0
- package/dist/palette-snap.d.ts +4 -0
- package/dist/palette-snap.js +371 -0
- package/dist/palette.d.ts +21 -0
- package/dist/palette.js +280 -0
- package/dist/pixelate.d.ts +15 -0
- package/dist/pixelate.js +214 -0
- package/dist/pixelation-algorithm-registry.d.ts +34 -0
- package/dist/pixelation-algorithm-registry.js +716 -0
- package/dist/pixeloe-prepass.d.ts +12 -0
- package/dist/pixeloe-prepass.js +527 -0
- package/dist/quantization.d.ts +6 -0
- package/dist/quantization.js +145 -0
- package/dist/remove-background.d.ts +40 -0
- package/dist/remove-background.js +229 -0
- package/dist/resize.d.ts +3 -0
- package/dist/resize.js +86 -0
- package/dist/sampling.d.ts +7 -0
- package/dist/sampling.js +301 -0
- package/dist/settings.d.ts +36 -0
- package/dist/settings.js +181 -0
- package/dist/types.d.ts +255 -0
- package/dist/types.js +87 -0
- package/licenses/PixelOE-Apache-2.0.txt +201 -0
- package/licenses/pixel-art-fixer-MIT.txt +21 -0
- package/licenses/proper-pixel-art-MIT.txt +21 -0
- package/licenses/tensorhead-dithering-MIT.txt +21 -0
- package/package.json +62 -4
- package/wasm/ac-rl-detect-browser-worker.js +45 -0
- package/wasm/ac-rl-detect-worker.mjs +51 -0
- package/wasm/pixelfixer_detect.d.ts +65 -0
- package/wasm/pixelfixer_detect.js +334 -0
- package/wasm/pixelfixer_detect_bg.wasm +0 -0
package/dist/analyze.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { UINT8_MAX } from "./base.js";
|
|
2
|
+
import { assertPixelBuffer } from "./base.js";
|
|
3
|
+
import { detectWithAlgorithm, detectWithAlgorithmAsync, } from "./pixelation-algorithm-registry.js";
|
|
4
|
+
import { PixelArtClassification } from "./types.js";
|
|
5
|
+
import { clamp } from "./base.js";
|
|
6
|
+
const MAX_UNIQUE_SAMPLE_COLORS = 8_192;
|
|
7
|
+
const DEFAULT_MAX_SAMPLE_PIXELS = 131_072;
|
|
8
|
+
function classificationFor(score) {
|
|
9
|
+
if (score >= 0.24)
|
|
10
|
+
return PixelArtClassification.LikelyPixelArt;
|
|
11
|
+
if (score <= -0.24)
|
|
12
|
+
return PixelArtClassification.LikelyPhoto;
|
|
13
|
+
return PixelArtClassification.Uncertain;
|
|
14
|
+
}
|
|
15
|
+
function checkedMaxSamples(value) {
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
return DEFAULT_MAX_SAMPLE_PIXELS;
|
|
18
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
19
|
+
throw new RangeError("maxSamplePixels must be a positive integer");
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Estimate whether imported RGBA pixels resemble pixel art or a photograph.
|
|
24
|
+
* This is intentionally a suggestion: callers should expose the result and
|
|
25
|
+
* keep the user's override when the heuristic is wrong.
|
|
26
|
+
*/
|
|
27
|
+
function analyzePixelArtWithoutGrid(image, options = {}) {
|
|
28
|
+
assertPixelBuffer(image);
|
|
29
|
+
const maxSamples = checkedMaxSamples(options.maxSamplePixels);
|
|
30
|
+
const totalPixels = image.width * image.height;
|
|
31
|
+
const stride = Math.max(1, Math.ceil(Math.sqrt(totalPixels / maxSamples)));
|
|
32
|
+
const data = image.data;
|
|
33
|
+
const unique = new Set();
|
|
34
|
+
const histogram = new Uint32Array(32 * 32 * 32);
|
|
35
|
+
let sampledPixels = 0;
|
|
36
|
+
let transparentPixels = 0;
|
|
37
|
+
let opaquePixels = 0;
|
|
38
|
+
let entropyTotal = 0;
|
|
39
|
+
for (let y = 0; y < image.height; y += stride) {
|
|
40
|
+
for (let x = 0; x < image.width; x += stride) {
|
|
41
|
+
const offset = (y * image.width + x) * 4;
|
|
42
|
+
const r = data[offset];
|
|
43
|
+
const g = data[offset + 1];
|
|
44
|
+
const b = data[offset + 2];
|
|
45
|
+
const a = data[offset + 3];
|
|
46
|
+
sampledPixels += 1;
|
|
47
|
+
if (a === 0)
|
|
48
|
+
transparentPixels += 1;
|
|
49
|
+
if (a === UINT8_MAX)
|
|
50
|
+
opaquePixels += 1;
|
|
51
|
+
if (unique.size <= MAX_UNIQUE_SAMPLE_COLORS)
|
|
52
|
+
unique.add((r << 24) | (g << 16) | (b << 8) | a | 0);
|
|
53
|
+
histogram[(r >>> 3) | ((g >>> 3) << 5) | ((b >>> 3) << 10)] += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
let exactNeighbors = 0;
|
|
57
|
+
let nearNeighbors = 0;
|
|
58
|
+
let neighborComparisons = 0;
|
|
59
|
+
const isNear = (left, right) => {
|
|
60
|
+
const dr = Math.abs(data[left] - data[right]);
|
|
61
|
+
const dg = Math.abs(data[left + 1] - data[right + 1]);
|
|
62
|
+
const db = Math.abs(data[left + 2] - data[right + 2]);
|
|
63
|
+
const da = Math.abs(data[left + 3] - data[right + 3]);
|
|
64
|
+
return dr <= 12 && dg <= 12 && db <= 12 && da <= 24;
|
|
65
|
+
};
|
|
66
|
+
const isExact = (left, right) => data[left] === data[right] &&
|
|
67
|
+
data[left + 1] === data[right + 1] &&
|
|
68
|
+
data[left + 2] === data[right + 2] &&
|
|
69
|
+
data[left + 3] === data[right + 3];
|
|
70
|
+
for (let y = 0; y < image.height; y += stride) {
|
|
71
|
+
for (let x = 0; x < image.width; x += stride) {
|
|
72
|
+
const offset = (y * image.width + x) * 4;
|
|
73
|
+
if (x + stride < image.width) {
|
|
74
|
+
const right = (y * image.width + x + stride) * 4;
|
|
75
|
+
neighborComparisons += 1;
|
|
76
|
+
if (isExact(offset, right))
|
|
77
|
+
exactNeighbors += 1;
|
|
78
|
+
if (isNear(offset, right))
|
|
79
|
+
nearNeighbors += 1;
|
|
80
|
+
}
|
|
81
|
+
if (y + stride < image.height) {
|
|
82
|
+
const below = ((y + stride) * image.width + x) * 4;
|
|
83
|
+
neighborComparisons += 1;
|
|
84
|
+
if (isExact(offset, below))
|
|
85
|
+
exactNeighbors += 1;
|
|
86
|
+
if (isNear(offset, below))
|
|
87
|
+
nearNeighbors += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (let i = 0; i < histogram.length; i += 1) {
|
|
92
|
+
if (histogram[i] === 0)
|
|
93
|
+
continue;
|
|
94
|
+
const probability = histogram[i] / sampledPixels;
|
|
95
|
+
entropyTotal -= probability * Math.log2(probability);
|
|
96
|
+
}
|
|
97
|
+
const uniqueColorsCapped = unique.size > MAX_UNIQUE_SAMPLE_COLORS;
|
|
98
|
+
const uniqueColors = uniqueColorsCapped ? MAX_UNIQUE_SAMPLE_COLORS + 1 : unique.size;
|
|
99
|
+
const uniqueColorRatio = clamp(uniqueColors / sampledPixels, 0, 1);
|
|
100
|
+
const exactNeighborRatio = neighborComparisons === 0 ? 1 : exactNeighbors / neighborComparisons;
|
|
101
|
+
const nearNeighborRatio = neighborComparisons === 0 ? 1 : nearNeighbors / neighborComparisons;
|
|
102
|
+
const edgeDensity = 1 - nearNeighborRatio;
|
|
103
|
+
const transparentRatio = transparentPixels / sampledPixels;
|
|
104
|
+
const opaqueRatio = opaquePixels / sampledPixels;
|
|
105
|
+
const metrics = {
|
|
106
|
+
width: image.width,
|
|
107
|
+
height: image.height,
|
|
108
|
+
sampledPixels,
|
|
109
|
+
uniqueColors,
|
|
110
|
+
uniqueColorsCapped,
|
|
111
|
+
uniqueColorRatio,
|
|
112
|
+
exactNeighborRatio,
|
|
113
|
+
nearNeighborRatio,
|
|
114
|
+
edgeDensity,
|
|
115
|
+
colorEntropy: entropyTotal,
|
|
116
|
+
transparentRatio,
|
|
117
|
+
opaqueRatio,
|
|
118
|
+
};
|
|
119
|
+
let score = 0;
|
|
120
|
+
const evidence = [];
|
|
121
|
+
const maxDimension = Math.max(image.width, image.height);
|
|
122
|
+
if (maxDimension <= 64) {
|
|
123
|
+
score += 0.3;
|
|
124
|
+
evidence.push("Small canvas dimensions are common in sprites and tiles.");
|
|
125
|
+
}
|
|
126
|
+
else if (maxDimension <= 160) {
|
|
127
|
+
score += 0.16;
|
|
128
|
+
evidence.push("The canvas is small enough to be consistent with a sprite.");
|
|
129
|
+
}
|
|
130
|
+
else if (maxDimension >= 1024) {
|
|
131
|
+
score -= 0.14;
|
|
132
|
+
evidence.push("Large canvas dimensions are more common in photographic imports.");
|
|
133
|
+
}
|
|
134
|
+
if (uniqueColorRatio <= 0.05) {
|
|
135
|
+
score += 0.32;
|
|
136
|
+
evidence.push("Most sampled pixels come from a small discrete palette.");
|
|
137
|
+
}
|
|
138
|
+
else if (uniqueColorRatio <= 0.18) {
|
|
139
|
+
score += 0.13;
|
|
140
|
+
evidence.push("The sampled palette is moderately compact.");
|
|
141
|
+
}
|
|
142
|
+
else if (uniqueColorRatio >= 0.65) {
|
|
143
|
+
score -= 0.3;
|
|
144
|
+
evidence.push("Many sampled pixels have distinct colors, which is common in photos.");
|
|
145
|
+
}
|
|
146
|
+
if (exactNeighborRatio >= 0.45) {
|
|
147
|
+
score += 0.22;
|
|
148
|
+
evidence.push("Neighboring pixels often repeat exactly, a common pixel-art signal.");
|
|
149
|
+
}
|
|
150
|
+
else if (exactNeighborRatio <= 0.06 && edgeDensity > 0.55) {
|
|
151
|
+
score -= 0.16;
|
|
152
|
+
evidence.push("Neighbors change frequently without forming large repeated runs.");
|
|
153
|
+
}
|
|
154
|
+
if (nearNeighborRatio >= 0.72 && exactNeighborRatio < 0.2) {
|
|
155
|
+
score -= 0.12;
|
|
156
|
+
evidence.push("Many neighbors are only slightly different, suggesting smooth tone changes.");
|
|
157
|
+
}
|
|
158
|
+
else if (nearNeighborRatio <= 0.35 && uniqueColorRatio <= 0.25) {
|
|
159
|
+
score += 0.1;
|
|
160
|
+
evidence.push("Color changes are crisp while the palette remains compact.");
|
|
161
|
+
}
|
|
162
|
+
if (entropyTotal <= 3.5) {
|
|
163
|
+
score += 0.12;
|
|
164
|
+
evidence.push("Color entropy is low.");
|
|
165
|
+
}
|
|
166
|
+
else if (entropyTotal >= 7.5) {
|
|
167
|
+
score -= 0.1;
|
|
168
|
+
evidence.push("Color entropy is high.");
|
|
169
|
+
}
|
|
170
|
+
if (transparentRatio >= 0.05) {
|
|
171
|
+
score += 0.08;
|
|
172
|
+
evidence.push("Transparency is present, which is common for sprites and cutouts.");
|
|
173
|
+
}
|
|
174
|
+
score = clamp(score, -1, 1);
|
|
175
|
+
const suggestedClassification = classificationFor(score);
|
|
176
|
+
const requestedOverride = options.userOverride;
|
|
177
|
+
if (requestedOverride !== undefined &&
|
|
178
|
+
requestedOverride !== null &&
|
|
179
|
+
![
|
|
180
|
+
PixelArtClassification.LikelyPixelArt,
|
|
181
|
+
PixelArtClassification.LikelyPhoto,
|
|
182
|
+
PixelArtClassification.Uncertain,
|
|
183
|
+
].includes(requestedOverride)) {
|
|
184
|
+
throw new RangeError("userOverride must be a valid PixelArtClassification");
|
|
185
|
+
}
|
|
186
|
+
const userOverride = requestedOverride ?? null;
|
|
187
|
+
const overrideApplied = userOverride !== null;
|
|
188
|
+
const classification = userOverride ?? suggestedClassification;
|
|
189
|
+
if (overrideApplied)
|
|
190
|
+
evidence.push(`User override retained: ${userOverride}.`);
|
|
191
|
+
return {
|
|
192
|
+
classification,
|
|
193
|
+
suggestedClassification,
|
|
194
|
+
confidence: overrideApplied ? 1 : clamp(0.5 + Math.abs(score) * 0.5, 0, 0.99),
|
|
195
|
+
score,
|
|
196
|
+
evidence,
|
|
197
|
+
metrics,
|
|
198
|
+
userOverride,
|
|
199
|
+
overrideApplied,
|
|
200
|
+
isHeuristic: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export function analyzePixelArt(image, options = {}) {
|
|
204
|
+
return {
|
|
205
|
+
...analyzePixelArtWithoutGrid(image, options),
|
|
206
|
+
grid: detectWithAlgorithm(image, options.algorithm, options.size),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Async analyze for product import-worker. Same metrics/classification as analyzePixelArt;
|
|
211
|
+
* Auto grid uses detectWithAlgorithmAsync (WASM||proposers Promise overlap).
|
|
212
|
+
*/
|
|
213
|
+
export async function analyzePixelArtAsync(image, options = {}) {
|
|
214
|
+
const base = analyzePixelArtWithoutGrid(image, options);
|
|
215
|
+
const grid = await detectWithAlgorithmAsync(image, options.algorithm, options.size);
|
|
216
|
+
return { ...base, grid };
|
|
217
|
+
}
|
package/dist/base.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Pixel data and checks shared by the importer. Kept free of editor types. */
|
|
2
|
+
export type Rgba = readonly [number, number, number, number];
|
|
3
|
+
/** Straight-alpha RGBA pixels, row by row. */
|
|
4
|
+
export interface PixelBuffer {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
data: Uint8ClampedArray;
|
|
8
|
+
}
|
|
9
|
+
export declare const UINT8_MAX = 255;
|
|
10
|
+
/** Keep accidental allocations bounded while allowing ordinary 4K imports. */
|
|
11
|
+
export declare const MAX_IMAGE_DIMENSION = 32768;
|
|
12
|
+
export declare const MAX_IMAGE_PIXELS = 64000000;
|
|
13
|
+
/** Constrains a number to an inclusive interval. */
|
|
14
|
+
export declare function clamp(value: number, minimum: number, maximum: number): number;
|
|
15
|
+
export declare function clampByte(value: number): number;
|
|
16
|
+
export declare function assertDimension(value: number, label: string): number;
|
|
17
|
+
export declare function assertPixelCount(width: number, height: number, label?: string): void;
|
|
18
|
+
export declare function assertPixelBuffer(image: PixelBuffer, label?: string): void;
|
package/dist/base.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Pixel data and checks shared by the importer. Kept free of editor types. */
|
|
2
|
+
export const UINT8_MAX = 255;
|
|
3
|
+
/** Keep accidental allocations bounded while allowing ordinary 4K imports. */
|
|
4
|
+
export const MAX_IMAGE_DIMENSION = 32_768;
|
|
5
|
+
export const MAX_IMAGE_PIXELS = 64_000_000;
|
|
6
|
+
const CHANNELS = 4;
|
|
7
|
+
/** Constrains a number to an inclusive interval. */
|
|
8
|
+
export function clamp(value, minimum, maximum) {
|
|
9
|
+
return Math.max(minimum, Math.min(maximum, value));
|
|
10
|
+
}
|
|
11
|
+
export function clampByte(value) {
|
|
12
|
+
return clamp(Math.round(value), 0, UINT8_MAX);
|
|
13
|
+
}
|
|
14
|
+
export function assertDimension(value, label) {
|
|
15
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_IMAGE_DIMENSION) {
|
|
16
|
+
throw new RangeError(`${label} must be an integer from 1 to ${MAX_IMAGE_DIMENSION}`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export function assertPixelCount(width, height, label = "image") {
|
|
21
|
+
if (width * height > MAX_IMAGE_PIXELS) {
|
|
22
|
+
throw new RangeError(`${label} is too large; at most ${MAX_IMAGE_PIXELS} pixels are supported`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function assertPixelBuffer(image, label = "image") {
|
|
26
|
+
if (!image || typeof image !== "object") {
|
|
27
|
+
throw new TypeError(`${label} must be a PixelBuffer`);
|
|
28
|
+
}
|
|
29
|
+
assertDimension(image.width, `${label}.width`);
|
|
30
|
+
assertDimension(image.height, `${label}.height`);
|
|
31
|
+
const pixels = image.width * image.height;
|
|
32
|
+
if (!Number.isSafeInteger(pixels) || pixels > MAX_IMAGE_PIXELS) {
|
|
33
|
+
throw new RangeError(`${label} is too large; at most ${MAX_IMAGE_PIXELS} pixels are supported`);
|
|
34
|
+
}
|
|
35
|
+
if (!(image.data instanceof Uint8ClampedArray)) {
|
|
36
|
+
throw new TypeError(`${label}.data must be a Uint8ClampedArray`);
|
|
37
|
+
}
|
|
38
|
+
const expectedLength = pixels * CHANNELS;
|
|
39
|
+
if (image.data.length !== expectedLength) {
|
|
40
|
+
throw new RangeError(`${label}.data must contain exactly ${expectedLength} bytes`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PixelBuffer } from "./base.ts";
|
|
2
|
+
import { CellColorPicker } from "./types.ts";
|
|
3
|
+
type Rgba4 = [number, number, number, number];
|
|
4
|
+
export interface CellColorPickerEntry {
|
|
5
|
+
/** i18n key of the label shown in the import dialog and preferences. */
|
|
6
|
+
readonly label: string;
|
|
7
|
+
/** One RGBA colour for the cell [x0,x1) x [y0,y1), or null when it is mostly transparent. */
|
|
8
|
+
pick(image: PixelBuffer, x0: number, y0: number, x1: number, y1: number): Rgba4 | null;
|
|
9
|
+
/** Optional pass over the finished output (RGBA data), e.g. merging near-duplicate colours. */
|
|
10
|
+
finish?(data: Uint8ClampedArray): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function getColorPickerEntry(picker: CellColorPicker): CellColorPickerEntry;
|
|
13
|
+
export declare function getAllColorPickers(): readonly CellColorPicker[];
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { labToRgb, rgbToLab } from "./color-lab.js";
|
|
2
|
+
import { CellColorPicker } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Per-cell colour choice and output colour merging follow KennethJAllen/proper-pixel-art (MIT):
|
|
5
|
+
* the dominant colour of a cell is the median of the fullest bin of two offset RGB binnings, and
|
|
6
|
+
* near-duplicate output colours then collapse to the most frequent one. No quantise-first step.
|
|
7
|
+
*/
|
|
8
|
+
const CHANNELS = 4;
|
|
9
|
+
const BIN_SIZE = 52;
|
|
10
|
+
const BINS = Math.floor(255 / BIN_SIZE) + 1;
|
|
11
|
+
const BIN_OFFSET = Math.floor(BIN_SIZE / 2);
|
|
12
|
+
const MERGE_DISTANCE = 12;
|
|
13
|
+
const MIN_OPAQUE_SHARE = 0.5;
|
|
14
|
+
function median(values) {
|
|
15
|
+
values.sort((a, b) => a - b);
|
|
16
|
+
const mid = values.length >> 1;
|
|
17
|
+
return values.length % 2 ? values[mid] : Math.round((values[mid - 1] + values[mid]) / 2);
|
|
18
|
+
}
|
|
19
|
+
/** RGBA of one cell, or null when it is mostly transparent. */
|
|
20
|
+
function dominantColor(image, x0, y0, x1, y1) {
|
|
21
|
+
const pixels = [];
|
|
22
|
+
let total = 0;
|
|
23
|
+
for (let y = y0; y < y1; y++)
|
|
24
|
+
for (let x = x0; x < x1; x++) {
|
|
25
|
+
total++;
|
|
26
|
+
const o = (y * image.width + x) * CHANNELS;
|
|
27
|
+
if (image.data[o + 3] > 0)
|
|
28
|
+
pixels.push(o);
|
|
29
|
+
}
|
|
30
|
+
if (!total || pixels.length < MIN_OPAQUE_SHARE * total)
|
|
31
|
+
return null;
|
|
32
|
+
const channel = (list, c) => list.map((o) => image.data[o + c]);
|
|
33
|
+
const pick = (list) => [
|
|
34
|
+
median(channel(list, 0)),
|
|
35
|
+
median(channel(list, 1)),
|
|
36
|
+
median(channel(list, 2)),
|
|
37
|
+
Math.round(channel(list, 3).reduce((a, b) => a + b, 0) / list.length),
|
|
38
|
+
];
|
|
39
|
+
if (pixels.length <= 3)
|
|
40
|
+
return pick(pixels);
|
|
41
|
+
const counts = [new Uint32Array(BINS ** 3), new Uint32Array(BINS ** 3)];
|
|
42
|
+
const keys = [new Int32Array(pixels.length), new Int32Array(pixels.length)];
|
|
43
|
+
pixels.forEach((o, i) => {
|
|
44
|
+
for (let grid = 0; grid < 2; grid++) {
|
|
45
|
+
const shift = grid ? BIN_OFFSET : 0;
|
|
46
|
+
const bin = (c) => Math.floor(Math.min(255, image.data[o + c] + shift) / BIN_SIZE);
|
|
47
|
+
const key = (bin(0) * BINS + bin(1)) * BINS + bin(2);
|
|
48
|
+
keys[grid][i] = key;
|
|
49
|
+
counts[grid][key]++;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const best = counts.map((table) => {
|
|
53
|
+
let key = 0;
|
|
54
|
+
for (let i = 1; i < table.length; i++)
|
|
55
|
+
if (table[i] > table[key])
|
|
56
|
+
key = i;
|
|
57
|
+
return key;
|
|
58
|
+
});
|
|
59
|
+
const grid = counts[0][best[0]] >= counts[1][best[1]] ? 0 : 1;
|
|
60
|
+
return pick(pixels.filter((_, i) => keys[grid][i] === best[grid]));
|
|
61
|
+
}
|
|
62
|
+
/** Map every colour to the most frequent colour within MERGE_DISTANCE of it. */
|
|
63
|
+
function mergeColors(data) {
|
|
64
|
+
const counts = new Map();
|
|
65
|
+
for (let i = 0; i < data.length; i += CHANNELS) {
|
|
66
|
+
if (!data[i + 3])
|
|
67
|
+
continue;
|
|
68
|
+
const key = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2];
|
|
69
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
70
|
+
}
|
|
71
|
+
const ordered = [...counts].sort((a, b) => b[1] - a[1] || a[0] - b[0]).map(([key]) => key);
|
|
72
|
+
const reps = [];
|
|
73
|
+
const mapping = new Map();
|
|
74
|
+
for (const key of ordered) {
|
|
75
|
+
const r = key >> 16, g = (key >> 8) & 255, b = key & 255;
|
|
76
|
+
const rep = reps.find((candidate) => {
|
|
77
|
+
const dr = (candidate >> 16) - r, dg = ((candidate >> 8) & 255) - g, db = (candidate & 255) - b;
|
|
78
|
+
return dr * dr + dg * dg + db * db <= MERGE_DISTANCE * MERGE_DISTANCE;
|
|
79
|
+
});
|
|
80
|
+
if (rep === undefined)
|
|
81
|
+
reps.push(key);
|
|
82
|
+
mapping.set(key, rep ?? key);
|
|
83
|
+
}
|
|
84
|
+
for (let i = 0; i < data.length; i += CHANNELS) {
|
|
85
|
+
if (!data[i + 3])
|
|
86
|
+
continue;
|
|
87
|
+
const to = mapping.get((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
|
|
88
|
+
data[i] = to >> 16;
|
|
89
|
+
data[i + 1] = (to >> 8) & 255;
|
|
90
|
+
data[i + 2] = to & 255;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Opaque pixel offsets of a cell, or null when the cell is mostly transparent. */
|
|
94
|
+
function opaqueOffsets(image, x0, y0, x1, y1) {
|
|
95
|
+
const offsets = [];
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (let y = y0; y < y1; y++)
|
|
98
|
+
for (let x = x0; x < x1; x++) {
|
|
99
|
+
total++;
|
|
100
|
+
const o = (y * image.width + x) * CHANNELS;
|
|
101
|
+
if (image.data[o + 3] > 0)
|
|
102
|
+
offsets.push(o);
|
|
103
|
+
}
|
|
104
|
+
return total && offsets.length >= MIN_OPAQUE_SHARE * total ? offsets : null;
|
|
105
|
+
}
|
|
106
|
+
const channelsOf = (image, offsets, c) => offsets.map((o) => image.data[o + c]);
|
|
107
|
+
const meanOf = (values) => Math.round(values.reduce((a, b) => a + b, 0) / values.length);
|
|
108
|
+
const KMEANS_K = 3;
|
|
109
|
+
const KMEANS_ROUNDS = 8;
|
|
110
|
+
const rgbaAt = (image, o) => [
|
|
111
|
+
image.data[o],
|
|
112
|
+
image.data[o + 1],
|
|
113
|
+
image.data[o + 2],
|
|
114
|
+
image.data[o + 3],
|
|
115
|
+
];
|
|
116
|
+
const distance2 = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
|
|
117
|
+
/**
|
|
118
|
+
* Per-cell k-means (k=3) on RGB; the largest cluster's mean wins. Inspired by the k-centroid
|
|
119
|
+
* downscaling in PixelOE (Apache-2.0, https://github.com/KohakuBlueleaf/PixelOE); this is an
|
|
120
|
+
* independent reimplementation with deterministic seeding (darkest, lightest, farthest pixel).
|
|
121
|
+
*/
|
|
122
|
+
function kMeansColor(image, offsets) {
|
|
123
|
+
const points = offsets.map((o) => rgbaAt(image, o));
|
|
124
|
+
const luma = (p) => p[0] * 0.299 + p[1] * 0.587 + p[2] * 0.114;
|
|
125
|
+
const sorted = [...points].sort((a, b) => luma(a) - luma(b));
|
|
126
|
+
const centers = [sorted[0], sorted[sorted.length - 1]];
|
|
127
|
+
while (centers.length < KMEANS_K) {
|
|
128
|
+
let far = points[0];
|
|
129
|
+
let best = -1;
|
|
130
|
+
for (const p of points) {
|
|
131
|
+
const d = Math.min(...centers.map((c) => distance2(p, c)));
|
|
132
|
+
if (d > best) {
|
|
133
|
+
best = d;
|
|
134
|
+
far = p;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
centers.push(far);
|
|
138
|
+
}
|
|
139
|
+
let assign = new Int32Array(points.length);
|
|
140
|
+
for (let round = 0; round < KMEANS_ROUNDS; round++) {
|
|
141
|
+
assign = Int32Array.from(points, (p) => {
|
|
142
|
+
let k = 0;
|
|
143
|
+
for (let i = 1; i < centers.length; i++)
|
|
144
|
+
if (distance2(p, centers[i]) < distance2(p, centers[k]))
|
|
145
|
+
k = i;
|
|
146
|
+
return k;
|
|
147
|
+
});
|
|
148
|
+
for (let k = 0; k < centers.length; k++) {
|
|
149
|
+
const members = points.filter((_, i) => assign[i] === k);
|
|
150
|
+
if (members.length)
|
|
151
|
+
centers[k] = [0, 1, 2].map((c) => members.reduce((s, p) => s + p[c], 0) / members.length);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const sizes = centers.map((_, k) => assign.filter((a) => a === k).length);
|
|
155
|
+
const winner = sizes.indexOf(Math.max(...sizes));
|
|
156
|
+
const members = points.filter((_, i) => assign[i] === winner);
|
|
157
|
+
return [0, 1, 2, 3].map((c) => meanOf(members.map((p) => p[c])));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* PixelOE's contrast-based patch pick (Apache-2.0, KohakuBlueleaf/PixelOE `find_pixel_luminance`),
|
|
161
|
+
* reimplemented. A*, b* are the patch medians. L* is the centre pixel's, unless the patch is skewed:
|
|
162
|
+
* median below mean and the max farther from the median than the min -> the patch minimum;
|
|
163
|
+
* median above mean and the max nearer the median than the min -> the patch maximum.
|
|
164
|
+
* The reference runs this on the outline-expanded image; we run it on the source cells.
|
|
165
|
+
*/
|
|
166
|
+
function contrastColor(image, offsets, centre) {
|
|
167
|
+
const n = offsets.length;
|
|
168
|
+
const ls = new Float64Array(n);
|
|
169
|
+
const as = new Float64Array(n);
|
|
170
|
+
const bs = new Float64Array(n);
|
|
171
|
+
let sum = 0;
|
|
172
|
+
let lo = Infinity;
|
|
173
|
+
let hi = -Infinity;
|
|
174
|
+
let alpha = 0;
|
|
175
|
+
for (let i = 0; i < n; i++) {
|
|
176
|
+
const o = offsets[i];
|
|
177
|
+
const [l, a, b] = rgbToLab(image.data[o], image.data[o + 1], image.data[o + 2]);
|
|
178
|
+
ls[i] = l;
|
|
179
|
+
as[i] = a;
|
|
180
|
+
bs[i] = b;
|
|
181
|
+
sum += l;
|
|
182
|
+
if (l < lo)
|
|
183
|
+
lo = l;
|
|
184
|
+
if (l > hi)
|
|
185
|
+
hi = l;
|
|
186
|
+
alpha += image.data[o + 3];
|
|
187
|
+
}
|
|
188
|
+
const mean = sum / n;
|
|
189
|
+
const l = sortedMedian(ls);
|
|
190
|
+
let lightness = rgbToLab(image.data[centre], image.data[centre + 1], image.data[centre + 2])[0];
|
|
191
|
+
if (l < mean && hi - l > l - lo)
|
|
192
|
+
lightness = lo;
|
|
193
|
+
else if (l > mean && hi - l < l - lo)
|
|
194
|
+
lightness = hi;
|
|
195
|
+
return [
|
|
196
|
+
...labToRgb(lightness, sortedMedian(as), sortedMedian(bs)),
|
|
197
|
+
Math.round(alpha / n),
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
/** Median of a typed array; sorts it in place. */
|
|
201
|
+
function sortedMedian(values) {
|
|
202
|
+
values.sort();
|
|
203
|
+
const mid = values.length >> 1;
|
|
204
|
+
return values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2;
|
|
205
|
+
}
|
|
206
|
+
/** Colour picker table: every picker works on any grid, whichever detector found it. */
|
|
207
|
+
const PICKERS = {
|
|
208
|
+
[CellColorPicker.MostCommon]: {
|
|
209
|
+
label: "import.picker.most-common",
|
|
210
|
+
pick: dominantColor,
|
|
211
|
+
finish: mergeColors,
|
|
212
|
+
},
|
|
213
|
+
[CellColorPicker.Average]: {
|
|
214
|
+
label: "import.picker.average",
|
|
215
|
+
pick: (image, x0, y0, x1, y1) => {
|
|
216
|
+
const o = opaqueOffsets(image, x0, y0, x1, y1);
|
|
217
|
+
return o && [0, 1, 2, 3].map((c) => meanOf(channelsOf(image, o, c)));
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
[CellColorPicker.Median]: {
|
|
221
|
+
label: "import.picker.median",
|
|
222
|
+
pick: (image, x0, y0, x1, y1) => {
|
|
223
|
+
const o = opaqueOffsets(image, x0, y0, x1, y1);
|
|
224
|
+
return (o &&
|
|
225
|
+
[
|
|
226
|
+
median(channelsOf(image, o, 0)),
|
|
227
|
+
median(channelsOf(image, o, 1)),
|
|
228
|
+
median(channelsOf(image, o, 2)),
|
|
229
|
+
meanOf(channelsOf(image, o, 3)),
|
|
230
|
+
]);
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
[CellColorPicker.KMeans]: {
|
|
234
|
+
label: "import.picker.k-means",
|
|
235
|
+
pick: (image, x0, y0, x1, y1) => {
|
|
236
|
+
const o = opaqueOffsets(image, x0, y0, x1, y1);
|
|
237
|
+
return o && kMeansColor(image, o);
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
[CellColorPicker.Contrast]: {
|
|
241
|
+
label: "import.picker.contrast",
|
|
242
|
+
pick: (image, x0, y0, x1, y1) => {
|
|
243
|
+
const o = opaqueOffsets(image, x0, y0, x1, y1);
|
|
244
|
+
const cx = (x0 + x1) >> 1;
|
|
245
|
+
const cy = (y0 + y1) >> 1;
|
|
246
|
+
const centre = (cy * image.width + cx) * CHANNELS;
|
|
247
|
+
return o && contrastColor(image, o, image.data[centre + 3] ? centre : o[o.length >> 1]);
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
[CellColorPicker.Nearest]: {
|
|
251
|
+
label: "import.picker.nearest",
|
|
252
|
+
pick: (image, x0, y0, x1, y1) => opaqueOffsets(image, x0, y0, x1, y1) &&
|
|
253
|
+
rgbaAt(image, (((y0 + y1 - 1) >> 1) * image.width + ((x0 + x1 - 1) >> 1)) * CHANNELS),
|
|
254
|
+
},
|
|
255
|
+
// Structure, TwoStage and DualRtTwoStage are handled specially in recoverGrid via samplePixelation,
|
|
256
|
+
// but need placeholder entries here for completeness.
|
|
257
|
+
[CellColorPicker.Structure]: {
|
|
258
|
+
label: "import.picker.structure",
|
|
259
|
+
pick: () => null, // Not used; handled in recoverGrid
|
|
260
|
+
},
|
|
261
|
+
[CellColorPicker.TwoStage]: {
|
|
262
|
+
label: "import.picker.two-stage",
|
|
263
|
+
pick: () => null, // Not used; handled in recoverGrid
|
|
264
|
+
},
|
|
265
|
+
[CellColorPicker.DualRtTwoStage]: {
|
|
266
|
+
label: "import.picker.dual-rt-two-stage",
|
|
267
|
+
pick: () => null, // Not used; handled in recoverGrid
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
export function getColorPickerEntry(picker) {
|
|
271
|
+
const entry = PICKERS[picker];
|
|
272
|
+
if (!entry)
|
|
273
|
+
throw new RangeError(`Unknown cell color picker: ${picker}`);
|
|
274
|
+
return entry;
|
|
275
|
+
}
|
|
276
|
+
export function getAllColorPickers() {
|
|
277
|
+
return Object.keys(PICKERS);
|
|
278
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** sRGB <-> CIE L*a*b* (D65), channels 0-255 in and out. */
|
|
2
|
+
const WHITE = [0.95047, 1, 1.08883];
|
|
3
|
+
const EPSILON = 216 / 24389;
|
|
4
|
+
const KAPPA = 24389 / 27;
|
|
5
|
+
const toLinear = (v) => {
|
|
6
|
+
const c = v / 255;
|
|
7
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
8
|
+
};
|
|
9
|
+
/** 8-bit sRGB to linear light, tabulated: the pow() per channel dominated whole-image conversions. */
|
|
10
|
+
const LINEAR = Float64Array.from({ length: 256 }, (_, v) => toLinear(v));
|
|
11
|
+
const fromLinear = (c) => Math.round(255 * Math.min(1, Math.max(0, c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)));
|
|
12
|
+
const forward = (t) => (t > EPSILON ? Math.cbrt(t) : (KAPPA * t + 16) / 116);
|
|
13
|
+
const inverse = (f) => (f ** 3 > EPSILON ? f ** 3 : (116 * f - 16) / KAPPA);
|
|
14
|
+
export function rgbToLab(r, g, b) {
|
|
15
|
+
const lr = LINEAR[r] ?? toLinear(r), lg = LINEAR[g] ?? toLinear(g), lb = LINEAR[b] ?? toLinear(b);
|
|
16
|
+
const x = forward((0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb) / WHITE[0]);
|
|
17
|
+
const y = forward(0.2126729 * lr + 0.7151522 * lg + 0.072175 * lb);
|
|
18
|
+
const z = forward((0.0193339 * lr + 0.119192 * lg + 0.9503041 * lb) / WHITE[2]);
|
|
19
|
+
return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
|
|
20
|
+
}
|
|
21
|
+
export function labToRgb(l, a, b) {
|
|
22
|
+
const fy = (l + 16) / 116;
|
|
23
|
+
const x = inverse(fy + a / 500) * WHITE[0];
|
|
24
|
+
const y = inverse(fy);
|
|
25
|
+
const z = inverse(fy - b / 200) * WHITE[2];
|
|
26
|
+
return [
|
|
27
|
+
fromLinear(3.2404542 * x - 1.5371385 * y - 0.4985314 * z),
|
|
28
|
+
fromLinear(-0.969266 * x + 1.8760108 * y + 0.041556 * z),
|
|
29
|
+
fromLinear(0.0556434 * x - 0.2040259 * y + 1.0572252 * z),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PixelBuffer } from "./base.ts";
|
|
2
|
+
/** `m / 255` for every whole channel difference m, as doubles and as float32. */
|
|
3
|
+
export declare const OPAQUE_STEPS: Float64Array<ArrayBuffer>;
|
|
4
|
+
export declare const OPAQUE_STEPS_32: Float32Array<ArrayBuffer>;
|
|
5
|
+
/** Step code of a pair that is not opaque on both sides: its step is computed in full. */
|
|
6
|
+
export declare const TRANSLUCENT: number;
|
|
7
|
+
/**
|
|
8
|
+
* The colour step (0..1) between two RGBA pixels: the largest premultiplied channel or alpha
|
|
9
|
+
* difference.
|
|
10
|
+
*/
|
|
11
|
+
export declare function colorStep(data: Uint8ClampedArray, first: number, second: number): number;
|
|
12
|
+
/**
|
|
13
|
+
* Every pixel's step to its right and lower neighbour, as codes: for two opaque pixels the step is
|
|
14
|
+
* the largest channel difference m over 255 exactly, so m is kept; other pairs are marked
|
|
15
|
+
* {@link TRANSLUCENT}. Tilt estimation and grid detection read the same image's pairs, so the last
|
|
16
|
+
* image's codes are kept.
|
|
17
|
+
*/
|
|
18
|
+
export interface NeighbourSteps {
|
|
19
|
+
image: PixelBuffer;
|
|
20
|
+
/** Code of the step from pixel p to p + 1 (unused in the last column). */
|
|
21
|
+
right: Uint16Array;
|
|
22
|
+
/** Code of the step from pixel p to p + width (unused in the last row). */
|
|
23
|
+
down: Uint16Array;
|
|
24
|
+
}
|
|
25
|
+
export declare function neighbourSteps(image: PixelBuffer): NeighbourSteps;
|
|
26
|
+
/** Drop the kept codes once the image's edge analysis is done. */
|
|
27
|
+
export declare function releaseNeighbourSteps(image: PixelBuffer): void;
|
|
28
|
+
/** Drop the kept codes, whichever image they belong to. */
|
|
29
|
+
export declare function forgetNeighbourSteps(): void;
|
|
30
|
+
/** The step (a double, as {@link colorStep}) of the pair from pixel p to p + offset. */
|
|
31
|
+
export declare function stepOf(codes: Uint16Array, data: Uint8ClampedArray, p: number, offset: number): number;
|
|
32
|
+
/** The same step rounded to float32. */
|
|
33
|
+
export declare function step32Of(codes: Uint16Array, data: Uint8ClampedArray, p: number, offset: number): number;
|