@gongbaodd/qr-renderer 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/README.md +25 -0
- package/dist/node.js +3130 -0
- package/dist/types/core/artifacts.d.ts +2 -0
- package/dist/types/core/assemble.d.ts +36 -0
- package/dist/types/core/errors.d.ts +6 -0
- package/dist/types/core/export-sizes.d.ts +21 -0
- package/dist/types/core/image.d.ts +14 -0
- package/dist/types/core/imaging/browser.d.ts +23 -0
- package/dist/types/core/imaging/cloudflare.d.ts +2 -0
- package/dist/types/core/imaging/index.d.ts +5 -0
- package/dist/types/core/imaging/node.d.ts +4 -0
- package/dist/types/core/imaging/pixels.d.ts +28 -0
- package/dist/types/core/imaging/types.d.ts +45 -0
- package/dist/types/core/mask.d.ts +11 -0
- package/dist/types/core/module-cut.d.ts +107 -0
- package/dist/types/core/palette.d.ts +80 -0
- package/dist/types/core/pattern-cut.d.ts +96 -0
- package/dist/types/core/pattern.d.ts +119 -0
- package/dist/types/core/placement.d.ts +9 -0
- package/dist/types/core/qr.d.ts +57 -0
- package/dist/types/core/rotate.d.ts +149 -0
- package/dist/types/core/squircle.d.ts +2 -0
- package/dist/types/core/types.d.ts +475 -0
- package/dist/types/engine/engine.d.ts +68 -0
- package/dist/types/engine/index.d.ts +34 -0
- package/dist/types/engine/mapping.d.ts +14 -0
- package/dist/types/engine/pipeline.d.ts +77 -0
- package/dist/types/engine/types.d.ts +66 -0
- package/dist/types/node.d.ts +17 -0
- package/dist/types/png-guard.d.ts +17 -0
- package/dist/types/recipe.d.ts +132 -0
- package/dist/types/schema.d.ts +211 -0
- package/dist/types/worker-shim.d.ts +12 -0
- package/package.json +79 -0
- package/src/core/artifacts.ts +6 -0
- package/src/core/assemble.ts +1195 -0
- package/src/core/errors.ts +24 -0
- package/src/core/export-sizes.ts +179 -0
- package/src/core/image.ts +57 -0
- package/src/core/imaging/browser.ts +186 -0
- package/src/core/imaging/cloudflare.ts +15 -0
- package/src/core/imaging/index.ts +14 -0
- package/src/core/imaging/node.ts +95 -0
- package/src/core/imaging/pixels.ts +121 -0
- package/src/core/imaging/types.ts +56 -0
- package/src/core/mask.ts +295 -0
- package/src/core/module-cut.ts +523 -0
- package/src/core/palette.ts +227 -0
- package/src/core/pattern-cut.ts +522 -0
- package/src/core/pattern.ts +478 -0
- package/src/core/placement.ts +94 -0
- package/src/core/qr.ts +695 -0
- package/src/core/rotate.ts +267 -0
- package/src/core/squircle.ts +53 -0
- package/src/core/types.ts +477 -0
- package/src/engine/engine.ts +316 -0
- package/src/engine/index.ts +58 -0
- package/src/engine/mapping.ts +66 -0
- package/src/engine/pipeline.ts +501 -0
- package/src/engine/types.ts +61 -0
- package/src/node.ts +45 -0
- package/src/png-guard.ts +66 -0
- package/src/recipe.ts +161 -0
- package/src/schema.ts +137 -0
- package/src/wasm.d.ts +5 -0
- package/src/worker-shim.ts +15 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-TS pixel operations shared by pixel-writing backends. The sharp backend keeps its
|
|
3
|
+
* own native compositing for existing opaque paths so their bytes stay bit-identical. Shared
|
|
4
|
+
* pixel helpers cover operations that need identical semantics in both environments, including
|
|
5
|
+
* source-over into the transparent blank poster.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RawImage } from './types'
|
|
9
|
+
|
|
10
|
+
/** Source-over composites one unpremultiplied RGBA pixel into another in place. */
|
|
11
|
+
export function compositePixelOver(
|
|
12
|
+
source: Uint8Array,
|
|
13
|
+
sourceOffset: number,
|
|
14
|
+
destination: Uint8Array,
|
|
15
|
+
destinationOffset: number,
|
|
16
|
+
sourceAlphaOverride?: number,
|
|
17
|
+
): void {
|
|
18
|
+
const sourceAlpha = (sourceAlphaOverride ?? source[sourceOffset + 3]!) / 255
|
|
19
|
+
if (sourceAlpha === 0) return
|
|
20
|
+
const destinationAlpha = destination[destinationOffset + 3]! / 255
|
|
21
|
+
const outputAlpha = sourceAlpha + destinationAlpha * (1 - sourceAlpha)
|
|
22
|
+
if (outputAlpha === 0) {
|
|
23
|
+
destination.fill(0, destinationOffset, destinationOffset + 4)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
for (let channel = 0; channel < 3; channel++) {
|
|
27
|
+
const sourceValue = source[sourceOffset + channel]!
|
|
28
|
+
const destinationValue = destination[destinationOffset + channel]!
|
|
29
|
+
destination[destinationOffset + channel] = Math.round(
|
|
30
|
+
(sourceValue * sourceAlpha + destinationValue * destinationAlpha * (1 - sourceAlpha)) / outputAlpha,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
destination[destinationOffset + 3] = Math.round(outputAlpha * 255)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Flattens alpha over an opaque white background: `out = c*a/255 + 255*(1 - a/255)`, truncated. */
|
|
37
|
+
export function flattenOverWhite(data: Uint8Array): Uint8Array {
|
|
38
|
+
const output = new Uint8Array(data.length)
|
|
39
|
+
for (let offset = 0; offset < data.length; offset += 4) {
|
|
40
|
+
const alpha = data[offset + 3]!
|
|
41
|
+
output[offset] = Math.trunc((data[offset]! * alpha + 255 * (255 - alpha)) / 255)
|
|
42
|
+
output[offset + 1] = Math.trunc((data[offset + 1]! * alpha + 255 * (255 - alpha)) / 255)
|
|
43
|
+
output[offset + 2] = Math.trunc((data[offset + 2]! * alpha + 255 * (255 - alpha)) / 255)
|
|
44
|
+
output[offset + 3] = 255
|
|
45
|
+
}
|
|
46
|
+
return output
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Standard source-over blend of full-size unpremultiplied RGBA overlay rasters over an
|
|
51
|
+
* opaque base. Alpha compositing uses the same blend libvips performs; differences stay
|
|
52
|
+
* limited to SVG antialiasing, which the renderers already treat as free.
|
|
53
|
+
*/
|
|
54
|
+
export function compositeOver(base: RawImage, overlays: RawImage[]): Uint8Array {
|
|
55
|
+
const output = Uint8Array.from(base.data)
|
|
56
|
+
for (const overlay of overlays) {
|
|
57
|
+
if (overlay.width !== base.width || overlay.height !== base.height)
|
|
58
|
+
throw new RangeError(
|
|
59
|
+
`Overlay ${overlay.width}x${overlay.height} does not match base ${base.width}x${base.height}.`,
|
|
60
|
+
)
|
|
61
|
+
for (let offset = 0; offset < output.length; offset += 4) {
|
|
62
|
+
const sourceAlpha = overlay.data[offset + 3]!
|
|
63
|
+
if (sourceAlpha === 0) continue
|
|
64
|
+
const destinationAlpha = output[offset + 3]!
|
|
65
|
+
const weight = sourceAlpha * 255 + destinationAlpha * (255 - sourceAlpha)
|
|
66
|
+
output[offset + 3] = Math.trunc(weight / 255)
|
|
67
|
+
for (let channel = 0; channel < 3; channel++) {
|
|
68
|
+
const source = overlay.data[offset + channel]!
|
|
69
|
+
const destination = output[offset + channel]!
|
|
70
|
+
const value = (source * sourceAlpha * 255 + destination * destinationAlpha * (255 - sourceAlpha)) / weight
|
|
71
|
+
output[offset + channel] = weight > 0 ? Math.trunc(value) : 0
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return output
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Integer nearest-neighbor resample, matching the probed libvips(nearest) mapping rules:
|
|
80
|
+
* expansion anchors left (`floor(x * in / out)`), shrink centers the sample
|
|
81
|
+
* (`floor((x + 0.5) * in / out)`). Residual tie cases (exact-integer or half-integer
|
|
82
|
+
* sample positions) may pick one pixel lower in libvips' internal two-stage shrink —
|
|
83
|
+
* a documented deviation absorbed by the parity tolerance, never affecting intra-run
|
|
84
|
+
* verification.
|
|
85
|
+
*/
|
|
86
|
+
export function resizeNearest(raw: RawImage, width: number, height: number): Uint8Array {
|
|
87
|
+
if (raw.width === width && raw.height === height) return Uint8Array.from(raw.data)
|
|
88
|
+
const pickX = createPicker(raw.width, width)
|
|
89
|
+
const pickY = createPicker(raw.height, height)
|
|
90
|
+
const output = new Uint8Array(width * height * 4)
|
|
91
|
+
for (let y = 0; y < height; y++) {
|
|
92
|
+
const sourceRow = pickY(y) * raw.width
|
|
93
|
+
const targetRow = y * width
|
|
94
|
+
for (let x = 0; x < width; x++) {
|
|
95
|
+
const source = (sourceRow + pickX(x)) * 4
|
|
96
|
+
const target = (targetRow + x) * 4
|
|
97
|
+
for (let channel = 0; channel < 4; channel++) output[target + channel] = raw.data[source + channel]!
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return output
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createPicker(inputSize: number, outputSize: number): (x: number) => number {
|
|
104
|
+
const scale = inputSize / outputSize
|
|
105
|
+
if (outputSize > inputSize) {
|
|
106
|
+
return (x) => Math.min(Math.max(Math.floor(x * scale), 0), inputSize - 1)
|
|
107
|
+
}
|
|
108
|
+
return (x) => Math.min(Math.max(Math.floor((x + 0.5) * scale), 0), inputSize - 1)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Copies a subrectangle without resampling. */
|
|
112
|
+
export function cropRgba(raw: RawImage, x: number, y: number, width: number, height: number): Uint8Array {
|
|
113
|
+
if (x < 0 || y < 0 || x + width > raw.width || y + height > raw.height)
|
|
114
|
+
throw new RangeError(`Crop ${width}x${height}+${x}+${y} does not fit ${raw.width}x${raw.height}.`)
|
|
115
|
+
const output = new Uint8Array(width * height * 4)
|
|
116
|
+
for (let row = 0; row < height; row++) {
|
|
117
|
+
const source = ((y + row) * raw.width + x) * 4
|
|
118
|
+
output.set(raw.data.subarray(source, source + width * 4), row * width * 4)
|
|
119
|
+
}
|
|
120
|
+
return output
|
|
121
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-agnostic imaging seam. Core modules call these operations instead of
|
|
3
|
+
* using sharp (or any other rasterizer) directly, so a Node (sharp) and a browser
|
|
4
|
+
* backend can be swapped without touching pipeline logic.
|
|
5
|
+
*
|
|
6
|
+
* Semantics each backend must honor:
|
|
7
|
+
* - `decodePng` is strict and exact: RGBA pixels, failing loudly on undecodable input.
|
|
8
|
+
* - `rasterizeSvg` returns unpremultiplied RGBA; antialiased edges may differ between
|
|
9
|
+
* rasterizers (librsvg here, the browser rasterizer later), which the poster has always
|
|
10
|
+
* treated as renderer freedom.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A decoded raster of unpremultiplied RGBA pixels. */
|
|
14
|
+
export interface RawImage {
|
|
15
|
+
data: Uint8Array
|
|
16
|
+
width: number
|
|
17
|
+
height: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Result of rasterizing an SVG straight to PNG bytes. */
|
|
21
|
+
export interface RenderedSvgPng {
|
|
22
|
+
png: Uint8Array
|
|
23
|
+
width: number
|
|
24
|
+
height: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SvgRenderOptions {
|
|
28
|
+
/** Composites the raster over opaque white before encoding (plain alpha otherwise). */
|
|
29
|
+
flatten?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Imaging {
|
|
33
|
+
/** Strict PNG decode into exact RGBA. */
|
|
34
|
+
decodePng(file: Uint8Array): Promise<RawImage>
|
|
35
|
+
|
|
36
|
+
/** Encodes RGBA pixels into PNG bytes. */
|
|
37
|
+
encodePngRgba(data: Uint8Array, width: number, height: number): Promise<Uint8Array>
|
|
38
|
+
|
|
39
|
+
/** Encodes a one-byte-per-pixel grayscale raster into PNG bytes. */
|
|
40
|
+
encodePngGrayscale(data: Uint8Array, width: number, height: number): Promise<Uint8Array>
|
|
41
|
+
|
|
42
|
+
/** Rasterizes an SVG at the exact size into RGBA pixels. */
|
|
43
|
+
rasterizeSvg(svg: string, width: number, height: number): Promise<RawImage>
|
|
44
|
+
|
|
45
|
+
/** Rasterizes an SVG into PNG bytes, optionally flattened over opaque white. */
|
|
46
|
+
renderSvgToPng(svg: string, options: SvgRenderOptions): Promise<RenderedSvgPng>
|
|
47
|
+
|
|
48
|
+
/** Composites SVG overlays (rasterized at the base size) over a base PNG into PNG bytes. */
|
|
49
|
+
composeQrPng(basePng: Uint8Array, overlaySvgs: string[]): Promise<Uint8Array>
|
|
50
|
+
|
|
51
|
+
/** Flattens alpha over white, nearest-resamples to a square target size, returns PNG bytes. */
|
|
52
|
+
normalizeQrPng(file: Uint8Array, targetSize: number): Promise<Uint8Array>
|
|
53
|
+
|
|
54
|
+
/** SHA-256 hex of bytes or a UTF-8 string. Async so the browser's crypto.subtle fits. */
|
|
55
|
+
sha256Hex(input: string | Uint8Array): Promise<string>
|
|
56
|
+
}
|
package/src/core/mask.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type { LoadedPng } from './image'
|
|
2
|
+
import { luma } from './image'
|
|
3
|
+
import { QrPosterError } from './errors'
|
|
4
|
+
import type { BoundingBox, Point, RegionMask } from './types'
|
|
5
|
+
|
|
6
|
+
const STRONG_BLACK_THRESHOLD = 32
|
|
7
|
+
const TOLERANT_LUMA_THRESHOLD = 96
|
|
8
|
+
const DENSITY_THRESHOLD = 0.8
|
|
9
|
+
const MIN_COMPONENT_FRACTION = 0.01
|
|
10
|
+
|
|
11
|
+
/** Shared pixel rule for manual masks: opaque white selects the region. */
|
|
12
|
+
export function isSelectedMaskPixel(r: number, g: number, b: number, alpha: number): boolean {
|
|
13
|
+
return alpha >= 128 && luma(r, g, b) >= 128
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Component {
|
|
17
|
+
label: number
|
|
18
|
+
area: number
|
|
19
|
+
sumX: number
|
|
20
|
+
sumY: number
|
|
21
|
+
intersectsCenter: boolean
|
|
22
|
+
touchesOuterBand: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildManualRegionMask(mask: LoadedPng, width: number, height: number): RegionMask {
|
|
26
|
+
if (mask.width !== width || mask.height !== height) {
|
|
27
|
+
throw new QrPosterError(
|
|
28
|
+
'MASK_INVALID',
|
|
29
|
+
`Mask dimensions ${mask.width}x${mask.height} do not match poster dimensions ${width}x${height}.`,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const selected = new Uint8Array(width * height)
|
|
34
|
+
for (let index = 0; index < selected.length; index++) {
|
|
35
|
+
const offset = index * 4
|
|
36
|
+
selected[index] = isSelectedMaskPixel(
|
|
37
|
+
mask.data[offset]!,
|
|
38
|
+
mask.data[offset + 1]!,
|
|
39
|
+
mask.data[offset + 2]!,
|
|
40
|
+
mask.data[offset + 3]!,
|
|
41
|
+
)
|
|
42
|
+
? 255
|
|
43
|
+
: 0
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const stats = calculateMaskStats(selected, width, height)
|
|
47
|
+
if (stats.area === 0)
|
|
48
|
+
throw new QrPosterError('MASK_INVALID', 'The supplied mask does not contain any selected white pixels.')
|
|
49
|
+
|
|
50
|
+
return { data: selected, width, height, source: 'file', ...stats }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function detectRegionMask(image: LoadedPng): RegionMask {
|
|
54
|
+
const { data, width, height } = image
|
|
55
|
+
const pixelCount = width * height
|
|
56
|
+
const radius = clamp(Math.round(Math.min(width, height) / 120), 2, 16)
|
|
57
|
+
const strong = new Uint8Array(pixelCount)
|
|
58
|
+
const tolerant = new Uint8Array(pixelCount)
|
|
59
|
+
|
|
60
|
+
for (let index = 0; index < pixelCount; index++) {
|
|
61
|
+
const offset = index * 4
|
|
62
|
+
const r = data[offset]!
|
|
63
|
+
const g = data[offset + 1]!
|
|
64
|
+
const b = data[offset + 2]!
|
|
65
|
+
const alpha = data[offset + 3]!
|
|
66
|
+
if (alpha >= 128 && r <= STRONG_BLACK_THRESHOLD && g <= STRONG_BLACK_THRESHOLD && b <= STRONG_BLACK_THRESHOLD)
|
|
67
|
+
strong[index] = 1
|
|
68
|
+
if (alpha >= 128 && luma(r, g, b) <= TOLERANT_LUMA_THRESHOLD) tolerant[index] = 1
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const integral = buildIntegral(strong, width, height)
|
|
72
|
+
const core = new Uint8Array(pixelCount)
|
|
73
|
+
for (let y = 0; y < height; y++) {
|
|
74
|
+
const y0 = Math.max(0, y - radius)
|
|
75
|
+
const y1 = Math.min(height - 1, y + radius)
|
|
76
|
+
for (let x = 0; x < width; x++) {
|
|
77
|
+
const index = y * width + x
|
|
78
|
+
if (!strong[index]) continue
|
|
79
|
+
const x0 = Math.max(0, x - radius)
|
|
80
|
+
const x1 = Math.min(width - 1, x + radius)
|
|
81
|
+
const blackCount = integralSum(integral, width + 1, x0, y0, x1 + 1, y1 + 1)
|
|
82
|
+
const windowArea = (x1 - x0 + 1) * (y1 - y0 + 1)
|
|
83
|
+
if (blackCount / windowArea >= DENSITY_THRESHOLD) core[index] = 1
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { labels, components } = labelComponents(core, width, height)
|
|
88
|
+
const minimumArea = Math.ceil(pixelCount * MIN_COMPONENT_FRACTION)
|
|
89
|
+
const candidates = components
|
|
90
|
+
.filter((component) => component.intersectsCenter && component.area >= minimumArea)
|
|
91
|
+
.sort((a, b) => {
|
|
92
|
+
if (b.area !== a.area) return b.area - a.area
|
|
93
|
+
return centerDistance(a, width, height) - centerDistance(b, width, height)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (candidates.length === 0) {
|
|
97
|
+
throw new QrPosterError(
|
|
98
|
+
'MASK_AMBIGUOUS',
|
|
99
|
+
'Could not confidently find a dense painted region near the poster center. Supply --mask with a white selected region.',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const winner = candidates[0]!
|
|
104
|
+
const runnerUp = candidates[1]
|
|
105
|
+
const dominanceRatio = runnerUp ? winner.area / runnerUp.area : null
|
|
106
|
+
if (runnerUp && runnerUp.area >= winner.area * 0.25) {
|
|
107
|
+
throw new QrPosterError(
|
|
108
|
+
'MASK_AMBIGUOUS',
|
|
109
|
+
`Two central painted-region candidates are too similar (${winner.area} and ${runnerUp.area} pixels). Supply --mask.`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
if (winner.touchesOuterBand) {
|
|
113
|
+
throw new QrPosterError(
|
|
114
|
+
'MASK_AMBIGUOUS',
|
|
115
|
+
'The detected painted region reaches the outer 2% of the image. Supply --mask to avoid consuming the poster background.',
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let grown = new Uint8Array(pixelCount)
|
|
120
|
+
for (let index = 0; index < pixelCount; index++) {
|
|
121
|
+
if (labels[index] === winner.label) grown[index] = 1
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (let step = 0; step < radius + 1; step++) {
|
|
125
|
+
const next = grown.slice()
|
|
126
|
+
for (let y = 0; y < height; y++) {
|
|
127
|
+
for (let x = 0; x < width; x++) {
|
|
128
|
+
const index = y * width + x
|
|
129
|
+
if (grown[index] || !tolerant[index]) continue
|
|
130
|
+
if (hasSelectedNeighbour(grown, width, height, x, y)) next[index] = 1
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
grown = next
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const selected = new Uint8Array(pixelCount)
|
|
137
|
+
for (let index = 0; index < pixelCount; index++) selected[index] = grown[index] ? 255 : 0
|
|
138
|
+
|
|
139
|
+
const stats = calculateMaskStats(selected, width, height)
|
|
140
|
+
return {
|
|
141
|
+
data: selected,
|
|
142
|
+
width,
|
|
143
|
+
height,
|
|
144
|
+
source: 'auto',
|
|
145
|
+
...stats,
|
|
146
|
+
detection: {
|
|
147
|
+
strongBlackThreshold: STRONG_BLACK_THRESHOLD,
|
|
148
|
+
tolerantLumaThreshold: TOLERANT_LUMA_THRESHOLD,
|
|
149
|
+
separationRadius: radius,
|
|
150
|
+
densityThreshold: DENSITY_THRESHOLD,
|
|
151
|
+
candidateAreas: candidates.map((candidate) => candidate.area),
|
|
152
|
+
dominanceRatio,
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function calculateMaskStats(
|
|
158
|
+
data: Uint8Array,
|
|
159
|
+
width: number,
|
|
160
|
+
height: number,
|
|
161
|
+
): {
|
|
162
|
+
area: number
|
|
163
|
+
bounds: BoundingBox
|
|
164
|
+
centroid: Point
|
|
165
|
+
} {
|
|
166
|
+
let area = 0
|
|
167
|
+
let sumX = 0
|
|
168
|
+
let sumY = 0
|
|
169
|
+
let minX = width
|
|
170
|
+
let minY = height
|
|
171
|
+
let maxX = -1
|
|
172
|
+
let maxY = -1
|
|
173
|
+
for (let y = 0; y < height; y++) {
|
|
174
|
+
for (let x = 0; x < width; x++) {
|
|
175
|
+
if (!data[y * width + x]) continue
|
|
176
|
+
area++
|
|
177
|
+
sumX += x
|
|
178
|
+
sumY += y
|
|
179
|
+
minX = Math.min(minX, x)
|
|
180
|
+
minY = Math.min(minY, y)
|
|
181
|
+
maxX = Math.max(maxX, x)
|
|
182
|
+
maxY = Math.max(maxY, y)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (area === 0) throw new QrPosterError('MASK_INVALID', 'The region mask is empty.')
|
|
186
|
+
return {
|
|
187
|
+
area,
|
|
188
|
+
bounds: { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
|
|
189
|
+
centroid: { x: sumX / area, y: sumY / area },
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildIntegral(data: Uint8Array, width: number, height: number): Uint32Array {
|
|
194
|
+
const stride = width + 1
|
|
195
|
+
const integral = new Uint32Array(stride * (height + 1))
|
|
196
|
+
for (let y = 1; y <= height; y++) {
|
|
197
|
+
let rowSum = 0
|
|
198
|
+
for (let x = 1; x <= width; x++) {
|
|
199
|
+
rowSum += data[(y - 1) * width + x - 1]!
|
|
200
|
+
integral[y * stride + x] = integral[(y - 1) * stride + x]! + rowSum
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return integral
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function integralSum(data: Uint32Array, stride: number, x0: number, y0: number, x1: number, y1: number): number {
|
|
207
|
+
return data[y1 * stride + x1]! - data[y0 * stride + x1]! - data[y1 * stride + x0]! + data[y0 * stride + x0]!
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function labelComponents(
|
|
211
|
+
core: Uint8Array,
|
|
212
|
+
width: number,
|
|
213
|
+
height: number,
|
|
214
|
+
): {
|
|
215
|
+
labels: Int32Array
|
|
216
|
+
components: Component[]
|
|
217
|
+
} {
|
|
218
|
+
const labels = new Int32Array(core.length)
|
|
219
|
+
const queue = new Int32Array(core.length)
|
|
220
|
+
const components: Component[] = []
|
|
221
|
+
const centerMinX = width * 0.15
|
|
222
|
+
const centerMaxX = width * 0.85
|
|
223
|
+
const centerMinY = height * 0.15
|
|
224
|
+
const centerMaxY = height * 0.85
|
|
225
|
+
const outerX = width * 0.02
|
|
226
|
+
const outerY = height * 0.02
|
|
227
|
+
let label = 0
|
|
228
|
+
|
|
229
|
+
for (let start = 0; start < core.length; start++) {
|
|
230
|
+
if (!core[start] || labels[start]) continue
|
|
231
|
+
label++
|
|
232
|
+
let head = 0
|
|
233
|
+
let tail = 1
|
|
234
|
+
queue[0] = start
|
|
235
|
+
labels[start] = label
|
|
236
|
+
const component: Component = {
|
|
237
|
+
label,
|
|
238
|
+
area: 0,
|
|
239
|
+
sumX: 0,
|
|
240
|
+
sumY: 0,
|
|
241
|
+
intersectsCenter: false,
|
|
242
|
+
touchesOuterBand: false,
|
|
243
|
+
}
|
|
244
|
+
while (head < tail) {
|
|
245
|
+
const index = queue[head++]!
|
|
246
|
+
const x = index % width
|
|
247
|
+
const y = Math.floor(index / width)
|
|
248
|
+
component.area++
|
|
249
|
+
component.sumX += x
|
|
250
|
+
component.sumY += y
|
|
251
|
+
if (x >= centerMinX && x <= centerMaxX && y >= centerMinY && y <= centerMaxY) component.intersectsCenter = true
|
|
252
|
+
if (x < outerX || x >= width - outerX || y < outerY || y >= height - outerY) component.touchesOuterBand = true
|
|
253
|
+
|
|
254
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
255
|
+
const ny = y + dy
|
|
256
|
+
if (ny < 0 || ny >= height) continue
|
|
257
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
258
|
+
if (dx === 0 && dy === 0) continue
|
|
259
|
+
const nx = x + dx
|
|
260
|
+
if (nx < 0 || nx >= width) continue
|
|
261
|
+
const neighbour = ny * width + nx
|
|
262
|
+
if (core[neighbour] && !labels[neighbour]) {
|
|
263
|
+
labels[neighbour] = label
|
|
264
|
+
queue[tail++] = neighbour
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
components.push(component)
|
|
270
|
+
}
|
|
271
|
+
return { labels, components }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function hasSelectedNeighbour(data: Uint8Array, width: number, height: number, x: number, y: number): boolean {
|
|
275
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
276
|
+
const ny = y + dy
|
|
277
|
+
if (ny < 0 || ny >= height) continue
|
|
278
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
279
|
+
if (dx === 0 && dy === 0) continue
|
|
280
|
+
const nx = x + dx
|
|
281
|
+
if (nx >= 0 && nx < width && data[ny * width + nx]) return true
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return false
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function centerDistance(component: Component, width: number, height: number): number {
|
|
288
|
+
const x = component.sumX / component.area
|
|
289
|
+
const y = component.sumY / component.area
|
|
290
|
+
return Math.hypot(x - width / 2, y - height / 2)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
294
|
+
return Math.max(minimum, Math.min(maximum, value))
|
|
295
|
+
}
|