@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
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { MAX_GRID_CELL_SIZE, MAX_RECOVERED_GRID_SIZE, pixelGridCuts, } from "./grid.js";
|
|
2
|
+
import { gridFrequencySpectrum } from "./grid-frequency-spectrum.js";
|
|
3
|
+
import { PixelationAlgorithm } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Whole-frame lattices. An upscale of a whole image maps native cell i of n onto
|
|
6
|
+
* [i, i + 1) * length / n. A soft resample (bilinear, blurred, AI) kinks at each cell's sample
|
|
7
|
+
* point (i + 0.5) * length / n - 0.5; a nearest one steps on each boundary i * length / n. The
|
|
8
|
+
* curvature energy (soft) or the step energy (hard) along each axis is projected onto that comb
|
|
9
|
+
* for every integer count n. The projection is phase-locked to the frame, so it needs no pitch or
|
|
10
|
+
* phase search, integrates every line of the image, and stays exact for cells below two pixels,
|
|
11
|
+
* where the comb lies above the Nyquist rate and an autocorrelation only sees its alias.
|
|
12
|
+
*
|
|
13
|
+
* Sampled at whole pixels, soft kinks at count n and hard steps at count length - n are the
|
|
14
|
+
* same signal. Below a two-pixel pitch a lattice therefore also has to explain the image better
|
|
15
|
+
* under its model than the alias does under the other. Hard steps at counts n and length - n
|
|
16
|
+
* project alike too; the boundary edges the two counts do not share decide between them.
|
|
17
|
+
*
|
|
18
|
+
* A count is accepted when the projection stands out from the profile's own fluctuation on both
|
|
19
|
+
* axes, a reconstruction from the lattice's sample points beats the same reconstruction half a
|
|
20
|
+
* cell out of step across the frame, and the lattice is no harmonic or subdivision of a coarser
|
|
21
|
+
* grid. A hard lattice's boundaries must also gather the steps.
|
|
22
|
+
*/
|
|
23
|
+
const CHANNELS = 4;
|
|
24
|
+
const ALPHA_CHANNEL = 3;
|
|
25
|
+
const ALPHA_SCALE = 255;
|
|
26
|
+
/** Below this pitch a lattice is no longer told from a blurred native image. */
|
|
27
|
+
const MIN_PITCH = 1.5;
|
|
28
|
+
/** As the soft arbiter: fewer cells per axis can line up with large smooth features. */
|
|
29
|
+
const MIN_CELLS = 8;
|
|
30
|
+
const MAX_SAMPLED_LINES = 1024;
|
|
31
|
+
/** Relative pitch difference (plus one cell) the two axes of one square-celled lattice may show. */
|
|
32
|
+
const SQUARE_TOLERANCE = 0.02;
|
|
33
|
+
/** Joint projection, in standard deviations of a structureless profile, a lattice must reach. */
|
|
34
|
+
const MIN_SIGNIFICANCE = 5;
|
|
35
|
+
const MAX_HARMONICS = 8;
|
|
36
|
+
/** Share of the out-of-step reconstruction error the lattice must remove. */
|
|
37
|
+
const MIN_GAIN = 0.1;
|
|
38
|
+
/** Cells the out-of-step lattices gain or lose across the frame. */
|
|
39
|
+
const OUT_OF_STEP = 0.5;
|
|
40
|
+
const RECONSTRUCTION_ROWS = 256;
|
|
41
|
+
/** Pitch below which the comb lies above the Nyquist rate and has an alias. */
|
|
42
|
+
const ALIAS_PITCH = 2;
|
|
43
|
+
/** Mean step inside the groups of a k-fold split, against the step between groups. */
|
|
44
|
+
const SPLIT_STEP_RATIO = 0.25;
|
|
45
|
+
/** Step energy on the finer alias's own boundaries, against the shared ones, that keeps it. */
|
|
46
|
+
const ALIAS_CONTRAST = 0.4;
|
|
47
|
+
/**
|
|
48
|
+
* Share of the step energy a hard lattice's boundaries must gather beyond an even spread: a soft
|
|
49
|
+
* image's broad features also step periodically at a few coarse counts.
|
|
50
|
+
*/
|
|
51
|
+
const MIN_STEP_EXCESS = 0.15;
|
|
52
|
+
/** How the native cells were resampled onto the frame. */
|
|
53
|
+
export var FrameModel;
|
|
54
|
+
(function (FrameModel) {
|
|
55
|
+
/** Bilinear-like: the profile kinks at each cell's sample point. */
|
|
56
|
+
FrameModel["Soft"] = "soft";
|
|
57
|
+
/** Nearest-like: colour steps on each cell boundary. */
|
|
58
|
+
FrameModel["Hard"] = "hard";
|
|
59
|
+
})(FrameModel || (FrameModel = {}));
|
|
60
|
+
var EdgeKind;
|
|
61
|
+
(function (EdgeKind) {
|
|
62
|
+
EdgeKind[EdgeKind["Shared"] = 0] = "Shared";
|
|
63
|
+
EdgeKind[EdgeKind["Exclusive"] = 1] = "Exclusive";
|
|
64
|
+
EdgeKind[EdgeKind["Neither"] = 2] = "Neither";
|
|
65
|
+
})(EdgeKind || (EdgeKind = {}));
|
|
66
|
+
const EDGE_KINDS = 3;
|
|
67
|
+
var Resample;
|
|
68
|
+
(function (Resample) {
|
|
69
|
+
Resample[Resample["Bilinear"] = 0] = "Bilinear";
|
|
70
|
+
Resample[Resample["Nearest"] = 1] = "Nearest";
|
|
71
|
+
})(Resample || (Resample = {}));
|
|
72
|
+
const resampleOf = (model) => model === FrameModel.Soft ? Resample.Bilinear : Resample.Nearest;
|
|
73
|
+
const premultiplied = (data, o, c) => c === ALPHA_CHANNEL ? data[o + c] : (data[o + c] * data[o + ALPHA_CHANNEL]) / ALPHA_SCALE;
|
|
74
|
+
/**
|
|
75
|
+
* Mean energy along sampled lines of the squared difference of every channel: the second
|
|
76
|
+
* difference centred on pixel i (soft), or the step from pixel i - 1 to pixel i (hard).
|
|
77
|
+
*/
|
|
78
|
+
function energyProfile(image, horizontal, model) {
|
|
79
|
+
const { width, height, data } = image;
|
|
80
|
+
const length = horizontal ? width : height;
|
|
81
|
+
const cross = horizontal ? height : width;
|
|
82
|
+
const stride = Math.max(1, Math.ceil(cross / MAX_SAMPLED_LINES));
|
|
83
|
+
const unit = (horizontal ? 1 : width) * CHANNELS;
|
|
84
|
+
const soft = model === FrameModel.Soft;
|
|
85
|
+
const profile = new Float64Array(length);
|
|
86
|
+
let lines = 0;
|
|
87
|
+
for (let line = 0; line < cross; line += stride, lines++) {
|
|
88
|
+
const start = (horizontal ? line * width : line) * CHANNELS;
|
|
89
|
+
for (let i = 1; i < length - (soft ? 1 : 0); i++) {
|
|
90
|
+
const o = start + i * unit;
|
|
91
|
+
let energy = 0;
|
|
92
|
+
for (let c = 0; c < CHANNELS; c++) {
|
|
93
|
+
const d = soft
|
|
94
|
+
? premultiplied(data, o - unit, c) -
|
|
95
|
+
2 * premultiplied(data, o, c) +
|
|
96
|
+
premultiplied(data, o + unit, c)
|
|
97
|
+
: premultiplied(data, o, c) - premultiplied(data, o - unit, c);
|
|
98
|
+
energy += d * d;
|
|
99
|
+
}
|
|
100
|
+
profile[i] += energy;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (let i = 0; i < length; i++)
|
|
104
|
+
profile[i] /= lines;
|
|
105
|
+
return profile;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Projection of the profile onto the comb of every count from `first` to `last`, in units of its
|
|
109
|
+
* standard deviation were the profile structureless: positive when the energy gathers on the
|
|
110
|
+
* sample points (soft) or on the cell boundaries (hard).
|
|
111
|
+
*/
|
|
112
|
+
export function combSignificance(profile, first, last, model) {
|
|
113
|
+
const length = profile.length;
|
|
114
|
+
const soft = model === FrameModel.Soft;
|
|
115
|
+
// The soft profile lives on pixels 1..length-2, the hard one on the edges 1..length-1.
|
|
116
|
+
const end = soft ? length - 1 : length;
|
|
117
|
+
const centred = new Float64Array(length);
|
|
118
|
+
let mean = 0;
|
|
119
|
+
for (let i = 1; i < end; i++)
|
|
120
|
+
mean += profile[i];
|
|
121
|
+
mean /= end - 1;
|
|
122
|
+
let energy = 0;
|
|
123
|
+
for (let i = 1; i < end; i++) {
|
|
124
|
+
centred[i] = profile[i] - mean;
|
|
125
|
+
energy += centred[i] ** 2;
|
|
126
|
+
}
|
|
127
|
+
const noise = Math.sqrt(energy / 2);
|
|
128
|
+
const significance = new Float64Array(Math.max(0, last - first + 1));
|
|
129
|
+
if (noise <= 0)
|
|
130
|
+
return { first, significance };
|
|
131
|
+
const spectrum = gridFrequencySpectrum(centred);
|
|
132
|
+
for (let count = first; count <= last; count++) {
|
|
133
|
+
// F[k] uses exp(-i theta). Re(F[k] exp(-i phi)) therefore keeps the signed
|
|
134
|
+
// cosine projection, including the soft comb's half-pixel phase phi = pi k / length.
|
|
135
|
+
const phase = soft ? (Math.PI * count) / length : 0;
|
|
136
|
+
const sum = spectrum.real[count] * Math.cos(phase) + spectrum.imaginary[count] * Math.sin(phase);
|
|
137
|
+
significance[count - first] = ((soft ? -1 : 1) * sum) / noise;
|
|
138
|
+
}
|
|
139
|
+
return { first, significance };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Harmonic sum of the comb at `count`: the pulses also project onto every multiple k * count,
|
|
143
|
+
* with sign (-1)^(k + 1) for kinks on the sample points and +1 for steps on the boundaries. The
|
|
144
|
+
* best normalised partial sum over the first harmonics prefers the fundamental over its
|
|
145
|
+
* multiples (which miss harmonics) and rejects its fractions (whose harmonics fall between the
|
|
146
|
+
* pulses).
|
|
147
|
+
*/
|
|
148
|
+
function harmonicSum(comb, count, model) {
|
|
149
|
+
const { first, significance } = comb;
|
|
150
|
+
const last = first + significance.length - 1;
|
|
151
|
+
let partial = 0, best = -Infinity;
|
|
152
|
+
for (let k = 1; k <= MAX_HARMONICS && k * count <= last; k++) {
|
|
153
|
+
const sign = model === FrameModel.Hard || k % 2 ? 1 : -1;
|
|
154
|
+
partial += sign * significance[k * count - first];
|
|
155
|
+
best = Math.max(best, partial / Math.sqrt(k));
|
|
156
|
+
}
|
|
157
|
+
return best;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Whether the counts are a whole multiple of counts whose own comb is significant, of either
|
|
161
|
+
* sign: the lattice then repeats a coarser grid's harmonic, which the edge detectors and the
|
|
162
|
+
* arbiter judge instead.
|
|
163
|
+
*/
|
|
164
|
+
function isHarmonic(x, y, columns, rows) {
|
|
165
|
+
for (let k = 2; k <= MAX_HARMONICS; k++) {
|
|
166
|
+
if (columns % k || rows % k || columns / k < x.first || rows / k < y.first)
|
|
167
|
+
continue;
|
|
168
|
+
const fundamental = (Math.abs(x.significance[columns / k - x.first]) +
|
|
169
|
+
Math.abs(y.significance[rows / k - y.first])) /
|
|
170
|
+
Math.SQRT2;
|
|
171
|
+
if (fundamental >= MIN_SIGNIFICANCE)
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
/** Sample positions of `cells` (possibly fractional) frame-aligned cells, clamped to the axis. */
|
|
177
|
+
function samplePoints(length, cells) {
|
|
178
|
+
const points = new Float64Array(Math.ceil(cells));
|
|
179
|
+
for (let i = 0; i < points.length; i++)
|
|
180
|
+
points[i] = Math.min(length - 1, Math.max(0, ((i + 0.5) * length) / cells - 0.5));
|
|
181
|
+
return points;
|
|
182
|
+
}
|
|
183
|
+
/** The image read bilinearly at the sample points of `columns` x `rows` frame-aligned cells. */
|
|
184
|
+
function latticeSamples(image, columns, rows) {
|
|
185
|
+
const { width, height, data } = image;
|
|
186
|
+
const xs = samplePoints(width, columns), ys = samplePoints(height, rows);
|
|
187
|
+
const values = new Float64Array(xs.length * ys.length * CHANNELS);
|
|
188
|
+
const at = (x, y) => (y * width + x) * CHANNELS;
|
|
189
|
+
for (let j = 0; j < ys.length; j++) {
|
|
190
|
+
const y0 = Math.floor(ys[j]), y1 = Math.min(height - 1, y0 + 1), ty = ys[j] - y0;
|
|
191
|
+
for (let i = 0; i < xs.length; i++) {
|
|
192
|
+
const x0 = Math.floor(xs[i]), x1 = Math.min(width - 1, x0 + 1), tx = xs[i] - x0;
|
|
193
|
+
for (let c = 0; c < CHANNELS; c++)
|
|
194
|
+
values[(j * xs.length + i) * CHANNELS + c] =
|
|
195
|
+
(premultiplied(data, at(x0, y0), c) * (1 - tx) +
|
|
196
|
+
premultiplied(data, at(x1, y0), c) * tx) *
|
|
197
|
+
(1 - ty) +
|
|
198
|
+
(premultiplied(data, at(x0, y1), c) * (1 - tx) +
|
|
199
|
+
premultiplied(data, at(x1, y1), c) * tx) *
|
|
200
|
+
ty;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { columns, rows, across: xs.length, down: ys.length, values };
|
|
204
|
+
}
|
|
205
|
+
/** Lower sample index, and weight of the next one, that each pixel of an axis reads. */
|
|
206
|
+
function spread(length, cells, points, resample) {
|
|
207
|
+
const index = new Int32Array(length), weight = new Float64Array(length);
|
|
208
|
+
for (let p = 0; p < length; p++) {
|
|
209
|
+
const u = Math.min(points - 1, Math.max(0, ((p + 0.5) * cells) / length - 0.5));
|
|
210
|
+
if (resample === Resample.Nearest) {
|
|
211
|
+
index[p] = Math.round(u);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
index[p] = Math.min(points - 2, Math.floor(u));
|
|
215
|
+
weight[p] = u - index[p];
|
|
216
|
+
}
|
|
217
|
+
return { index, weight };
|
|
218
|
+
}
|
|
219
|
+
/** Mean squared error, on sampled rows, of the image against its rebuild from the samples. */
|
|
220
|
+
function reconstructionError(image, lattice, resample) {
|
|
221
|
+
const { width, height, data } = image;
|
|
222
|
+
const { across, down, values } = lattice;
|
|
223
|
+
const sx = spread(width, lattice.columns, across, resample), sy = spread(height, lattice.rows, down, resample);
|
|
224
|
+
const stride = Math.max(1, Math.floor(height / RECONSTRUCTION_ROWS));
|
|
225
|
+
let error = 0, count = 0;
|
|
226
|
+
for (let y = 0; y < height; y += stride) {
|
|
227
|
+
const top = sy.index[y] * across, bottom = Math.min(down - 1, sy.index[y] + 1) * across, ty = sy.weight[y];
|
|
228
|
+
for (let x = 0; x < width; x++) {
|
|
229
|
+
const left = sx.index[x], right = Math.min(across - 1, left + 1), tx = sx.weight[x];
|
|
230
|
+
const o = (y * width + x) * CHANNELS;
|
|
231
|
+
for (let c = 0; c < CHANNELS; c++) {
|
|
232
|
+
const s = (cell) => values[cell * CHANNELS + c];
|
|
233
|
+
const value = (s(top + left) * (1 - tx) + s(top + right) * tx) * (1 - ty) +
|
|
234
|
+
(s(bottom + left) * (1 - tx) + s(bottom + right) * tx) * ty;
|
|
235
|
+
error += (value - premultiplied(data, o, c)) ** 2;
|
|
236
|
+
}
|
|
237
|
+
count++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return count ? error / count : 0;
|
|
241
|
+
}
|
|
242
|
+
/** The out-of-step reconstruction error share that the lattice itself avoids. */
|
|
243
|
+
function reconstructionGain(image, lattice, model) {
|
|
244
|
+
const { columns, rows } = lattice;
|
|
245
|
+
const resample = resampleOf(model);
|
|
246
|
+
const aligned = reconstructionError(image, lattice, resample);
|
|
247
|
+
const outOfStep = (cells) => reconstructionError(image, latticeSamples(image, cells, (rows * cells) / columns), resample);
|
|
248
|
+
const off = (outOfStep(columns + OUT_OF_STEP) + outOfStep(columns - OUT_OF_STEP)) / 2;
|
|
249
|
+
return off > 0 ? 1 - aligned / off : 0;
|
|
250
|
+
}
|
|
251
|
+
/** Whether the other model at the alias counts explains the image at least as well. */
|
|
252
|
+
function aliasExplains(image, lattice, model) {
|
|
253
|
+
const { width, height } = image;
|
|
254
|
+
const { columns, rows } = lattice;
|
|
255
|
+
const aliasColumns = width / columns < ALIAS_PITCH ? width - columns : columns, aliasRows = height / rows < ALIAS_PITCH ? height - rows : rows;
|
|
256
|
+
if (aliasColumns === columns && aliasRows === rows)
|
|
257
|
+
return false;
|
|
258
|
+
const other = model === FrameModel.Soft ? FrameModel.Hard : FrameModel.Soft;
|
|
259
|
+
return (reconstructionError(image, latticeSamples(image, aliasColumns, aliasRows), resampleOf(other)) <=
|
|
260
|
+
reconstructionError(image, lattice, resampleOf(model)));
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Whether the samples group into k x k blocks (2 <= k <= MAX_HARMONICS) on both axes: a blurred
|
|
264
|
+
* edge kinks on both sides of a coarse boundary, so the comb can also lock to a subdivision of a
|
|
265
|
+
* coarse grid. Steps between neighbouring samples then stay small inside each group and gather
|
|
266
|
+
* on one residue; on a true lattice every residue steps alike.
|
|
267
|
+
*/
|
|
268
|
+
function splitsCoarserCells(lattice) {
|
|
269
|
+
const { across, down, values } = lattice;
|
|
270
|
+
const step = (a, b) => {
|
|
271
|
+
let sum = 0;
|
|
272
|
+
for (let c = 0; c < CHANNELS; c++)
|
|
273
|
+
sum += Math.abs(values[a + c] - values[b + c]);
|
|
274
|
+
return sum;
|
|
275
|
+
};
|
|
276
|
+
const splits = (residues) => {
|
|
277
|
+
const sorted = Array.from(residues).sort((a, b) => a - b);
|
|
278
|
+
const inside = sorted.slice(0, -1).reduce((a, b) => a + b, 0) / (sorted.length - 1);
|
|
279
|
+
return inside < SPLIT_STEP_RATIO * sorted[sorted.length - 1];
|
|
280
|
+
};
|
|
281
|
+
for (let k = 2; k <= MAX_HARMONICS; k++) {
|
|
282
|
+
if (across % k || down % k)
|
|
283
|
+
continue;
|
|
284
|
+
const horizontal = new Float64Array(k), vertical = new Float64Array(k);
|
|
285
|
+
for (let j = 0; j < down; j++)
|
|
286
|
+
for (let i = 0; i < across; i++) {
|
|
287
|
+
const o = (j * across + i) * CHANNELS;
|
|
288
|
+
if (i + 1 < across)
|
|
289
|
+
horizontal[i % k] += step(o, o + CHANNELS);
|
|
290
|
+
if (j + 1 < down)
|
|
291
|
+
vertical[j % k] += step(o, o + across * CHANNELS);
|
|
292
|
+
}
|
|
293
|
+
if (splits(horizontal) && splits(vertical))
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
const isSquare = (width, height, columns, rows) => Math.abs(rows - (columns * height) / width) <=
|
|
299
|
+
((columns * height) / width) * SQUARE_TOLERANCE + 1;
|
|
300
|
+
const countRange = (length) => ({
|
|
301
|
+
first: Math.max(MIN_CELLS, Math.ceil(length / MAX_GRID_CELL_SIZE)),
|
|
302
|
+
last: Math.min(MAX_RECOVERED_GRID_SIZE, Math.floor(length / MIN_PITCH)),
|
|
303
|
+
});
|
|
304
|
+
const inRange = (count, range) => count >= range.first && count <= range.last;
|
|
305
|
+
/** Pixel edges where a nearest resample of `count` frame-aligned cells starts each next cell. */
|
|
306
|
+
function boundaryEdges(length, count) {
|
|
307
|
+
const edges = new Uint8Array(length);
|
|
308
|
+
for (let i = 1; i < count; i++)
|
|
309
|
+
edges[Math.ceil((i * length) / count - 0.5)] = 1;
|
|
310
|
+
return edges;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Step energy on the boundary edges of `fine` cells that are no boundary of `coarse` cells, as a
|
|
314
|
+
* share of the energy on the boundaries both share, each above the energy on edges neither has:
|
|
315
|
+
* blur or jitter spreads steps onto the edges beside them alike, whichever lattice is true.
|
|
316
|
+
*/
|
|
317
|
+
function exclusiveContrast(profile, fine, coarse) {
|
|
318
|
+
const length = profile.length;
|
|
319
|
+
const own = boundaryEdges(length, fine), rival = boundaryEdges(length, coarse);
|
|
320
|
+
const sum = new Float64Array(EDGE_KINDS), edges = new Float64Array(EDGE_KINDS);
|
|
321
|
+
for (let e = 1; e < length; e++) {
|
|
322
|
+
const kind = own[e] ? (rival[e] ? EdgeKind.Shared : EdgeKind.Exclusive) : EdgeKind.Neither;
|
|
323
|
+
sum[kind] += profile[e];
|
|
324
|
+
edges[kind]++;
|
|
325
|
+
}
|
|
326
|
+
if (!edges[EdgeKind.Exclusive] || !edges[EdgeKind.Shared] || !edges[EdgeKind.Neither])
|
|
327
|
+
return 0;
|
|
328
|
+
const mean = (kind) => sum[kind] / edges[kind];
|
|
329
|
+
const floor = mean(EdgeKind.Neither);
|
|
330
|
+
const shared = mean(EdgeKind.Shared) - floor;
|
|
331
|
+
return shared > 0 ? (mean(EdgeKind.Exclusive) - floor) / shared : 0;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Steps on the boundaries of count n and of count length - n fall on whole pixels alike, so their
|
|
335
|
+
* combs are identical. The boundaries of the coarser count are nearly all boundaries of the finer
|
|
336
|
+
* one, so the finer count stands only when the steps also fill its own boundaries.
|
|
337
|
+
*/
|
|
338
|
+
function stepAlias(image, profiles, counts) {
|
|
339
|
+
const { width, height } = image;
|
|
340
|
+
const alias = { columns: width - counts.columns, rows: height - counts.rows };
|
|
341
|
+
if (!inRange(alias.columns, countRange(width)) ||
|
|
342
|
+
!inRange(alias.rows, countRange(height)) ||
|
|
343
|
+
!isSquare(width, height, alias.columns, alias.rows))
|
|
344
|
+
return counts;
|
|
345
|
+
const [coarse, fine] = alias.columns < counts.columns ? [alias, counts] : [counts, alias];
|
|
346
|
+
const contrast = (exclusiveContrast(profiles.x, fine.columns, coarse.columns) +
|
|
347
|
+
exclusiveContrast(profiles.y, fine.rows, coarse.rows)) /
|
|
348
|
+
2;
|
|
349
|
+
return contrast >= ALIAS_CONTRAST ? fine : coarse;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Mean step energy on the boundary edges of `count` cells above the mean of all edges, as a share
|
|
353
|
+
* of the excess a nearest resample reaches when every step lies on a boundary.
|
|
354
|
+
*/
|
|
355
|
+
function stepExcess(profile, count) {
|
|
356
|
+
const length = profile.length;
|
|
357
|
+
const boundaries = boundaryEdges(length, count);
|
|
358
|
+
let on = 0, all = 0;
|
|
359
|
+
for (let e = 1; e < length; e++) {
|
|
360
|
+
all += profile[e];
|
|
361
|
+
if (boundaries[e])
|
|
362
|
+
on += profile[e];
|
|
363
|
+
}
|
|
364
|
+
const pitch = length / count;
|
|
365
|
+
return all > 0 ? (((on / (count - 1)) * (length - 1)) / all - 1) / (pitch - 1) : 0;
|
|
366
|
+
}
|
|
367
|
+
function fitFrame(image, model) {
|
|
368
|
+
const { width, height } = image;
|
|
369
|
+
const rangeX = countRange(width), rangeY = countRange(height);
|
|
370
|
+
if (rangeX.last < rangeX.first || rangeY.last < rangeY.first)
|
|
371
|
+
return null;
|
|
372
|
+
const profiles = { x: energyProfile(image, true, model), y: energyProfile(image, false, model) };
|
|
373
|
+
const x = combSignificance(profiles.x, rangeX.first, rangeX.last, model), y = combSignificance(profiles.y, rangeY.first, rangeY.last, model);
|
|
374
|
+
const harmonicsY = Array.from({ length: rangeY.last - rangeY.first + 1 }, (_, i) => harmonicSum(y, rangeY.first + i, model));
|
|
375
|
+
let best = { joint: -Infinity, columns: 0, rows: 0 };
|
|
376
|
+
for (let columns = rangeX.first; columns <= rangeX.last; columns++) {
|
|
377
|
+
const ideal = (columns * height) / width;
|
|
378
|
+
const low = Math.max(rangeY.first, Math.floor(ideal * (1 - SQUARE_TOLERANCE)) - 1), high = Math.min(rangeY.last, Math.ceil(ideal * (1 + SQUARE_TOLERANCE)) + 1);
|
|
379
|
+
const along = harmonicSum(x, columns, model);
|
|
380
|
+
for (let rows = low; rows <= high; rows++) {
|
|
381
|
+
const joint = (along + harmonicsY[rows - rangeY.first]) / Math.SQRT2;
|
|
382
|
+
if (joint > best.joint)
|
|
383
|
+
best = { joint, columns, rows };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (best.joint < MIN_SIGNIFICANCE)
|
|
387
|
+
return null;
|
|
388
|
+
const hard = model === FrameModel.Hard;
|
|
389
|
+
const { columns, rows } = hard ? stepAlias(image, profiles, best) : best;
|
|
390
|
+
if (isHarmonic(x, y, columns, rows))
|
|
391
|
+
return null;
|
|
392
|
+
if (hard &&
|
|
393
|
+
(stepExcess(profiles.x, columns) + stepExcess(profiles.y, rows)) / 2 < MIN_STEP_EXCESS)
|
|
394
|
+
return null;
|
|
395
|
+
const lattice = latticeSamples(image, columns, rows);
|
|
396
|
+
const gain = reconstructionGain(image, lattice, model);
|
|
397
|
+
if (gain < MIN_GAIN || aliasExplains(image, lattice, model) || splitsCoarserCells(lattice))
|
|
398
|
+
return null;
|
|
399
|
+
const cellWidth = width / columns, cellHeight = height / rows;
|
|
400
|
+
return {
|
|
401
|
+
width: columns,
|
|
402
|
+
height: rows,
|
|
403
|
+
cellWidth,
|
|
404
|
+
cellHeight,
|
|
405
|
+
offsetX: 0,
|
|
406
|
+
offsetY: 0,
|
|
407
|
+
cutsX: pixelGridCuts(width, cellWidth),
|
|
408
|
+
cutsY: pixelGridCuts(height, cellHeight),
|
|
409
|
+
confidence: Math.min(1, gain),
|
|
410
|
+
reliable: true,
|
|
411
|
+
algorithm: PixelationAlgorithm.UniformGrid,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The frame-aligned lattice of each of `models` that passes the significance, reconstruction,
|
|
416
|
+
* alias and subdivision tests. Cells are square up to SQUARE_TOLERANCE.
|
|
417
|
+
*/
|
|
418
|
+
export function detectFrameLattices(image, models = [FrameModel.Soft, FrameModel.Hard]) {
|
|
419
|
+
return models.flatMap((model) => fitFrame(image, model) ?? []);
|
|
420
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface Spectrum {
|
|
2
|
+
real: Float64Array;
|
|
3
|
+
imaginary: Float64Array;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Unnormalized DFT, F[k] = sum input[j] exp(-2 pi i j k / length), at the original
|
|
7
|
+
* integer frequencies. Bluestein pads only its convolution, so prime and odd image sizes keep
|
|
8
|
+
* the same frequency-to-cell-count relationship as a direct projection.
|
|
9
|
+
*/
|
|
10
|
+
export declare function gridFrequencySpectrum(input: Float64Array): Spectrum;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const TAU = 2 * Math.PI;
|
|
2
|
+
/** In-place radix-2 transform; inverse includes the convolution's normalization. */
|
|
3
|
+
function radix2(real, imaginary, inverse = false) {
|
|
4
|
+
const length = real.length;
|
|
5
|
+
for (let index = 1, reversed = 0; index < length; index++) {
|
|
6
|
+
let bit = length / 2;
|
|
7
|
+
while (reversed & bit) {
|
|
8
|
+
reversed ^= bit;
|
|
9
|
+
bit /= 2;
|
|
10
|
+
}
|
|
11
|
+
reversed ^= bit;
|
|
12
|
+
if (index < reversed) {
|
|
13
|
+
const r = real[index], i = imaginary[index];
|
|
14
|
+
real[index] = real[reversed];
|
|
15
|
+
imaginary[index] = imaginary[reversed];
|
|
16
|
+
real[reversed] = r;
|
|
17
|
+
imaginary[reversed] = i;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const direction = inverse ? 1 : -1;
|
|
21
|
+
for (let size = 2; size <= length; size *= 2) {
|
|
22
|
+
const half = size / 2;
|
|
23
|
+
for (let offset = 0; offset < half; offset++) {
|
|
24
|
+
const angle = (direction * TAU * offset) / size;
|
|
25
|
+
const cosine = Math.cos(angle), sine = Math.sin(angle);
|
|
26
|
+
for (let index = offset; index < length; index += size) {
|
|
27
|
+
const other = index + half;
|
|
28
|
+
const r = real[other] * cosine - imaginary[other] * sine;
|
|
29
|
+
const i = real[other] * sine + imaginary[other] * cosine;
|
|
30
|
+
real[other] = real[index] - r;
|
|
31
|
+
imaginary[other] = imaginary[index] - i;
|
|
32
|
+
real[index] += r;
|
|
33
|
+
imaginary[index] += i;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (inverse)
|
|
38
|
+
for (let index = 0; index < length; index++) {
|
|
39
|
+
real[index] /= length;
|
|
40
|
+
imaginary[index] /= length;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Unnormalized DFT, F[k] = sum input[j] exp(-2 pi i j k / length), at the original
|
|
45
|
+
* integer frequencies. Bluestein pads only its convolution, so prime and odd image sizes keep
|
|
46
|
+
* the same frequency-to-cell-count relationship as a direct projection.
|
|
47
|
+
*/
|
|
48
|
+
export function gridFrequencySpectrum(input) {
|
|
49
|
+
const length = input.length;
|
|
50
|
+
if (length === 0)
|
|
51
|
+
return { real: new Float64Array(), imaginary: new Float64Array() };
|
|
52
|
+
if ((length & (length - 1)) === 0) {
|
|
53
|
+
const real = input.slice(), imaginary = new Float64Array(length);
|
|
54
|
+
radix2(real, imaginary);
|
|
55
|
+
return { real, imaginary };
|
|
56
|
+
}
|
|
57
|
+
let padded = 1;
|
|
58
|
+
while (padded < 2 * length - 1)
|
|
59
|
+
padded *= 2;
|
|
60
|
+
const real = new Float64Array(padded), imaginary = new Float64Array(padded), kernelReal = new Float64Array(padded), kernelImaginary = new Float64Array(padded), cosine = new Float64Array(length), sine = new Float64Array(length);
|
|
61
|
+
for (let index = 0; index < length; index++) {
|
|
62
|
+
// The chirp is periodic in index² modulo 2 * length. Reduce before evaluating trig.
|
|
63
|
+
const angle = (Math.PI * ((index * index) % (2 * length))) / length;
|
|
64
|
+
cosine[index] = Math.cos(angle);
|
|
65
|
+
sine[index] = Math.sin(angle);
|
|
66
|
+
real[index] = input[index] * cosine[index];
|
|
67
|
+
imaginary[index] = -input[index] * sine[index];
|
|
68
|
+
kernelReal[index] = cosine[index];
|
|
69
|
+
kernelImaginary[index] = sine[index];
|
|
70
|
+
if (index) {
|
|
71
|
+
kernelReal[padded - index] = cosine[index];
|
|
72
|
+
kernelImaginary[padded - index] = sine[index];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
radix2(real, imaginary);
|
|
76
|
+
radix2(kernelReal, kernelImaginary);
|
|
77
|
+
for (let index = 0; index < padded; index++) {
|
|
78
|
+
const r = real[index] * kernelReal[index] - imaginary[index] * kernelImaginary[index];
|
|
79
|
+
imaginary[index] = real[index] * kernelImaginary[index] + imaginary[index] * kernelReal[index];
|
|
80
|
+
real[index] = r;
|
|
81
|
+
}
|
|
82
|
+
radix2(real, imaginary, true);
|
|
83
|
+
const resultReal = new Float64Array(length), resultImaginary = new Float64Array(length);
|
|
84
|
+
for (let index = 0; index < length; index++) {
|
|
85
|
+
resultReal[index] = real[index] * cosine[index] + imaginary[index] * sine[index];
|
|
86
|
+
resultImaginary[index] = imaginary[index] * cosine[index] - real[index] * sine[index];
|
|
87
|
+
}
|
|
88
|
+
return { real: resultReal, imaginary: resultImaginary };
|
|
89
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PixelBuffer } from "./base.ts";
|
|
2
|
+
import { type PixelGridAnalysis } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Detect a grid using boundary run-length comb period search. Returns multiple
|
|
5
|
+
* candidates (one per top-scoring period combination) for the arbiter to evaluate.
|
|
6
|
+
*/
|
|
7
|
+
export declare function detectBoundaryPeriodGrids(image: PixelBuffer): PixelGridAnalysis[];
|
|
8
|
+
/**
|
|
9
|
+
* Returns the top-scored boundary period grid, or unreliable when none found.
|
|
10
|
+
* This is the single-hypothesis interface for registry entries; the multi-hypothesis
|
|
11
|
+
* version above feeds the arbiter's candidate pool.
|
|
12
|
+
*/
|
|
13
|
+
export declare function detectBoundaryPeriodGrid(image: PixelBuffer): PixelGridAnalysis;
|