evomeme 0.1.7 → 0.1.9-beta.14
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/dist/canvas-public/app.js +8517 -1692
- package/dist/canvas-public/assets/presets/catalog.json +47 -0
- package/dist/canvas-public/assets/presets/tiles/A_01_CaoDi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_02_ShaMo.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_03_TianKong.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_04_ShuiBo.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_05_HaiMian.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_06_MuDiBan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_07_ShiZhuan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_08_AnRongYan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_09_QingTaiShi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_10_ShenHai.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_11_HuiYanShi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_12_HongTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_13_GengDi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_14_HuoShanYan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_15_NiTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_16_HuangSha.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_17_CongLin.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_18_ZiYanShi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_19_MoGuTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_20_HuaDian.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_21_HaiLang.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_22_GanTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_23_QingTaiShiBan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_24_XingKong.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_25_MoFaFuWen.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_26_ShiTuYeDi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_27_LanZhuan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_28_HongZhuan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_29_YueQiu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_30_JinShuGe.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_31_JinShuJingGai.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_32_ShuZiChuang.png +0 -0
- package/dist/canvas-public/color-key-worker.js +280 -0
- package/dist/canvas-public/image-canvas-size-editor.js +254 -0
- package/dist/canvas-public/index.html +226 -119
- package/dist/canvas-public/perfect-pixel-worker.js +499 -0
- package/dist/canvas-public/smart-split.js +1 -1
- package/dist/canvas-public/styles.css +3902 -1225
- package/dist/canvas-public/tile-map-editor.js +2120 -0
- package/dist/canvas-public/ui-controls.js +95 -0
- package/dist/canvas-public/vendor/pixi-tilemap.js +126 -0
- package/dist/canvas-public/vendor/pixi.min.js +2340 -0
- package/dist/index.js +32263 -4187
- package/dist/stage-blender/bake_materials.py +102 -0
- package/dist/stage-blender/export_scene.py +275 -0
- package/dist/stage-blender/preview_camera.py +90 -0
- package/dist/stage-blender/repair_mesh.py +112 -0
- package/dist/stage-public/app.js +759 -0
- package/dist/stage-public/baked/bakePilot.js +44 -0
- package/dist/stage-public/baked/bakedScene.js +129 -0
- package/dist/stage-public/baked/sceneAppearance.js +225 -0
- package/dist/stage-public/baked/sceneCamera.js +45 -0
- package/dist/stage-public/comparison-contract.js +28 -0
- package/dist/stage-public/delivery.html +4 -0
- package/dist/stage-public/delivery.js +17 -0
- package/dist/stage-public/godot/appearance.gd +170 -0
- package/dist/stage-public/godot/preview.gd +79 -0
- package/dist/stage-public/index.html +141 -0
- package/dist/stage-public/review-app.js +289 -0
- package/dist/stage-public/review-runtime.js +63 -0
- package/dist/stage-public/review.css +24 -0
- package/dist/stage-public/review.html +47 -0
- package/dist/stage-public/scene-runtime.js +212 -0
- package/dist/stage-public/sceneContractRuntime.js +235 -0
- package/dist/stage-public/styles.css +328 -0
- package/dist/stage-public/three-appearance.js +104 -0
- package/dist/stage-public/vendor/three170/LICENSE +21 -0
- package/dist/stage-public/vendor/three170/build/three.module.js +54571 -0
- package/dist/stage-public/vendor/three170/examples/jsm/controls/OrbitControls.js +1523 -0
- package/dist/stage-public/vendor/three170/examples/jsm/environments/RoomEnvironment.js +144 -0
- package/dist/stage-public/vendor/three170/examples/jsm/libs/fflate.module.js +2672 -0
- package/dist/stage-public/vendor/three170/examples/jsm/loaders/EXRLoader.js +2563 -0
- package/dist/stage-public/vendor/three170/examples/jsm/loaders/GLTFLoader.js +4727 -0
- package/dist/stage-public/vendor/three170/examples/jsm/utils/BufferGeometryUtils.js +1373 -0
- package/dist/tile-transition-mask.svg +40 -0
- package/package.json +77 -69
- package/skills/evomeme-canvas/SKILL.md +52 -0
- package/skills/evomeme-canvas/agents/openai.yaml +7 -0
- package/skills/evomeme-cover/SKILL.md +1 -1
- package/skills/evomeme-game-maker/SKILL.md +79 -8
- package/skills/game-asset-align/SKILL.md +45 -0
- package/skills/game-asset-align/agents/openai.yaml +8 -0
- package/skills/game-asset-align/references/alignment-contract.md +61 -0
- package/skills/game-asset-align/references/blender-scene-pipeline.md +45 -0
- package/skills/game-asset-align/references/canonical-package.md +26 -0
- package/skills/game-asset-align/references/engine-capabilities.md +14 -0
- package/skills/game-asset-align/references/lightweight-review.md +32 -0
- package/skills/game-asset-align/references/repair-policy.md +30 -0
- package/skills/game-asset-align/references/tool-contract.md +39 -0
- package/skills/game-asset-align/references/visual-review.md +26 -0
- package/skills/game-asset-image-generator/SKILL.md +1 -1
- package/skills/godot-3d-game/SKILL.md +39 -0
- package/skills/godot-3d-game/agents/openai.yaml +8 -0
- package/skills/godot-3d-game/references/alignment-gates.md +26 -0
- package/skills/godot-3d-game/references/appearance-alignment.md +52 -0
- package/skills/godot-3d-game/references/build-and-qa.md +22 -0
- package/skills/godot-3d-game/references/canonical-asset-runtime.md +27 -0
- package/skills/godot-3d-game/references/import-and-wrapper-scenes.md +20 -0
- package/skills/godot-3d-game/references/physics-colliders.md +13 -0
- package/skills/godot-3d-game/references/project-structure.md +22 -0
- package/skills/godot-3d-game/references/skeleton-animation.md +16 -0
- package/skills/threejs-3d-game/SKILL.md +6 -2
- package/skills/threejs-3d-game/references/engine-asset-pipeline.md +52 -15
- package/dist/canvas-public/sequence-sheet.js +0 -59
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
let sourceBitmap = null
|
|
2
|
+
|
|
3
|
+
self.addEventListener('message', async (event) => {
|
|
4
|
+
const message = event.data || {}
|
|
5
|
+
try {
|
|
6
|
+
if (message.type === 'init') {
|
|
7
|
+
const response = await fetch(String(message.url || ''))
|
|
8
|
+
if (!response.ok) throw new Error(`图片载入失败:${response.status}`)
|
|
9
|
+
sourceBitmap?.close?.()
|
|
10
|
+
sourceBitmap = await createImageBitmap(await response.blob())
|
|
11
|
+
self.postMessage({
|
|
12
|
+
type: 'ready',
|
|
13
|
+
requestId: message.requestId,
|
|
14
|
+
width: sourceBitmap.width,
|
|
15
|
+
height: sourceBitmap.height,
|
|
16
|
+
})
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (message.type !== 'process') return
|
|
21
|
+
if (!sourceBitmap) throw new Error('完美像素 Worker 尚未初始化')
|
|
22
|
+
|
|
23
|
+
const settings = normalizeSettings(message.settings)
|
|
24
|
+
const sourceCanvas = new OffscreenCanvas(sourceBitmap.width, sourceBitmap.height)
|
|
25
|
+
const sourceContext = sourceCanvas.getContext('2d', { willReadFrequently: true })
|
|
26
|
+
sourceContext.drawImage(sourceBitmap, 0, 0)
|
|
27
|
+
const source = sourceContext.getImageData(0, 0, sourceBitmap.width, sourceBitmap.height)
|
|
28
|
+
const result = restorePixelGrid(source, settings)
|
|
29
|
+
|
|
30
|
+
const lowResolution = new OffscreenCanvas(result.width, result.height)
|
|
31
|
+
lowResolution.getContext('2d').putImageData(
|
|
32
|
+
new ImageData(result.pixels, result.width, result.height),
|
|
33
|
+
0,
|
|
34
|
+
0,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const outputWidth = result.width * settings.exportScale
|
|
38
|
+
const outputHeight = result.height * settings.exportScale
|
|
39
|
+
const output = new OffscreenCanvas(outputWidth, outputHeight)
|
|
40
|
+
const outputContext = output.getContext('2d')
|
|
41
|
+
outputContext.imageSmoothingEnabled = false
|
|
42
|
+
outputContext.clearRect(0, 0, outputWidth, outputHeight)
|
|
43
|
+
outputContext.drawImage(lowResolution, 0, 0, outputWidth, outputHeight)
|
|
44
|
+
const blob = await output.convertToBlob({ type: 'image/png' })
|
|
45
|
+
|
|
46
|
+
self.postMessage({
|
|
47
|
+
type: 'result',
|
|
48
|
+
requestId: message.requestId,
|
|
49
|
+
blob,
|
|
50
|
+
width: outputWidth,
|
|
51
|
+
height: outputHeight,
|
|
52
|
+
gridWidth: result.width,
|
|
53
|
+
gridHeight: result.height,
|
|
54
|
+
pixelSizeX: result.pixelSizeX,
|
|
55
|
+
pixelSizeY: result.pixelSizeY,
|
|
56
|
+
confidence: result.confidence,
|
|
57
|
+
automatic: settings.autoGrid,
|
|
58
|
+
})
|
|
59
|
+
} catch (error) {
|
|
60
|
+
self.postMessage({
|
|
61
|
+
type: 'error',
|
|
62
|
+
requestId: message.requestId,
|
|
63
|
+
message: String(error?.message || error),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
function normalizeSettings(value) {
|
|
69
|
+
const settings = value && typeof value === 'object' ? value : {}
|
|
70
|
+
const sampleMethod = ['majority', 'median', 'center'].includes(settings.sampleMethod)
|
|
71
|
+
? settings.sampleMethod
|
|
72
|
+
: 'median'
|
|
73
|
+
const alphaMode = settings.alphaMode === 'preserve' ? 'preserve' : 'hard'
|
|
74
|
+
return {
|
|
75
|
+
autoGrid: settings.autoGrid !== false,
|
|
76
|
+
pixelSize: clampInteger(settings.pixelSize, 2, 256, 8),
|
|
77
|
+
sampleMethod,
|
|
78
|
+
exportScale: clampInteger(settings.exportScale, 1, 16, 4),
|
|
79
|
+
alphaMode,
|
|
80
|
+
alphaThreshold: clampInteger(settings.alphaThreshold, 0, 255, 128),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function clampInteger(value, min, max, fallback) {
|
|
85
|
+
const number = Math.round(Number(value))
|
|
86
|
+
return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : fallback
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function restorePixelGrid(image, settings) {
|
|
90
|
+
const width = image.width
|
|
91
|
+
const height = image.height
|
|
92
|
+
if (!width || !height) throw new Error('来源图片尺寸无效')
|
|
93
|
+
|
|
94
|
+
const projections = buildAlphaAwareGradientProjections(image.data, width, height)
|
|
95
|
+
let pixelSizeX = settings.pixelSize
|
|
96
|
+
let pixelSizeY = settings.pixelSize
|
|
97
|
+
let gridWidth = Math.ceil(width / settings.pixelSize)
|
|
98
|
+
let gridHeight = Math.ceil(height / settings.pixelSize)
|
|
99
|
+
let confidence = 1
|
|
100
|
+
|
|
101
|
+
if (settings.autoGrid) {
|
|
102
|
+
const detectedX = detectPixelPeriod(projections.x)
|
|
103
|
+
const detectedY = detectPixelPeriod(projections.y)
|
|
104
|
+
if (!detectedX && !detectedY) {
|
|
105
|
+
throw new Error('无法可靠识别像素网格,请切换为手动网格')
|
|
106
|
+
}
|
|
107
|
+
let squarePixelSize
|
|
108
|
+
if (detectedX && detectedY) {
|
|
109
|
+
const ratio = Math.max(detectedX.period, detectedY.period) / Math.min(detectedX.period, detectedY.period)
|
|
110
|
+
if (ratio <= 1.5) {
|
|
111
|
+
const totalConfidence = Math.max(1e-6, detectedX.confidence + detectedY.confidence)
|
|
112
|
+
squarePixelSize = (
|
|
113
|
+
detectedX.period * detectedX.confidence +
|
|
114
|
+
detectedY.period * detectedY.confidence
|
|
115
|
+
) / totalConfidence
|
|
116
|
+
} else {
|
|
117
|
+
// Pixel-art cells are square. If one axis is dominated by illustration
|
|
118
|
+
// content instead of the grid, trust the stronger axis for both directions.
|
|
119
|
+
squarePixelSize = detectedX.confidence >= detectedY.confidence
|
|
120
|
+
? detectedX.period
|
|
121
|
+
: detectedY.period
|
|
122
|
+
}
|
|
123
|
+
confidence = Math.min(detectedX.confidence, detectedY.confidence)
|
|
124
|
+
} else {
|
|
125
|
+
const detected = detectedX || detectedY
|
|
126
|
+
squarePixelSize = detected.period
|
|
127
|
+
// A single reliable axis is enough for square pixels, but report that the
|
|
128
|
+
// estimate had less supporting evidence than a two-axis match.
|
|
129
|
+
confidence = detected.confidence * 0.78
|
|
130
|
+
}
|
|
131
|
+
pixelSizeX = squarePixelSize
|
|
132
|
+
pixelSizeY = squarePixelSize
|
|
133
|
+
gridWidth = Math.max(1, Math.round(width / pixelSizeX))
|
|
134
|
+
gridHeight = Math.max(1, Math.round(height / pixelSizeY))
|
|
135
|
+
pixelSizeX = width / gridWidth
|
|
136
|
+
pixelSizeY = height / gridHeight
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const xCoordinates = settings.autoGrid
|
|
140
|
+
? refineGridCoordinates(width, gridWidth, projections.x)
|
|
141
|
+
: fixedGridCoordinates(width, settings.pixelSize)
|
|
142
|
+
const yCoordinates = settings.autoGrid
|
|
143
|
+
? refineGridCoordinates(height, gridHeight, projections.y)
|
|
144
|
+
: fixedGridCoordinates(height, settings.pixelSize)
|
|
145
|
+
const outputWidth = xCoordinates.length - 1
|
|
146
|
+
const outputHeight = yCoordinates.length - 1
|
|
147
|
+
if (outputWidth < 1 || outputHeight < 1) throw new Error('识别出的像素网格为空')
|
|
148
|
+
if (outputWidth * outputHeight > 4_194_304) {
|
|
149
|
+
throw new Error('输出像素网格过大,请降低手动网格尺寸')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const pixels = sampleGrid(
|
|
153
|
+
image.data,
|
|
154
|
+
width,
|
|
155
|
+
height,
|
|
156
|
+
xCoordinates,
|
|
157
|
+
yCoordinates,
|
|
158
|
+
settings,
|
|
159
|
+
)
|
|
160
|
+
return {
|
|
161
|
+
pixels,
|
|
162
|
+
width: outputWidth,
|
|
163
|
+
height: outputHeight,
|
|
164
|
+
pixelSizeX,
|
|
165
|
+
pixelSizeY,
|
|
166
|
+
confidence,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Transparent pixels may contain arbitrary hidden RGB values. Work in premultiplied
|
|
172
|
+
* RGB and add the alpha edge itself to the signal, so those hidden colors can never
|
|
173
|
+
* create a fake grid while dark opaque sprites remain detectable.
|
|
174
|
+
*/
|
|
175
|
+
function buildAlphaAwareGradientProjections(source, width, height) {
|
|
176
|
+
const x = new Float64Array(width)
|
|
177
|
+
const y = new Float64Array(height)
|
|
178
|
+
for (let row = 0; row < height; row += 1) {
|
|
179
|
+
for (let column = 0; column < width; column += 1) {
|
|
180
|
+
const offset = (row * width + column) * 4
|
|
181
|
+
const alpha = source[offset + 3] / 255
|
|
182
|
+
const red = source[offset] * alpha
|
|
183
|
+
const green = source[offset + 1] * alpha
|
|
184
|
+
const blue = source[offset + 2] * alpha
|
|
185
|
+
|
|
186
|
+
if (column > 0) {
|
|
187
|
+
const previous = offset - 4
|
|
188
|
+
const previousAlpha = source[previous + 3] / 255
|
|
189
|
+
const delta =
|
|
190
|
+
Math.abs(red - source[previous] * previousAlpha) +
|
|
191
|
+
Math.abs(green - source[previous + 1] * previousAlpha) +
|
|
192
|
+
Math.abs(blue - source[previous + 2] * previousAlpha) +
|
|
193
|
+
Math.abs(alpha - previousAlpha) * 255
|
|
194
|
+
x[column] += delta
|
|
195
|
+
}
|
|
196
|
+
if (row > 0) {
|
|
197
|
+
const previous = offset - width * 4
|
|
198
|
+
const previousAlpha = source[previous + 3] / 255
|
|
199
|
+
const delta =
|
|
200
|
+
Math.abs(red - source[previous] * previousAlpha) +
|
|
201
|
+
Math.abs(green - source[previous + 1] * previousAlpha) +
|
|
202
|
+
Math.abs(blue - source[previous + 2] * previousAlpha) +
|
|
203
|
+
Math.abs(alpha - previousAlpha) * 255
|
|
204
|
+
y[row] += delta
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { x, y }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Grid edges share one residue modulo the source pixel-block size. Scoring that
|
|
213
|
+
* concentration together with short-lag autocorrelation recovers the same periodic
|
|
214
|
+
* grid targeted by Perfect Pixel's FFT/gradient stages without shipping OpenCV.
|
|
215
|
+
*/
|
|
216
|
+
function detectPixelPeriod(projection) {
|
|
217
|
+
const length = projection.length
|
|
218
|
+
if (length < 4) return null
|
|
219
|
+
const maximum = Math.min(64, Math.max(2, Math.floor(length / 3)))
|
|
220
|
+
let total = 0
|
|
221
|
+
for (let i = 1; i < length; i += 1) total += projection[i]
|
|
222
|
+
if (total <= 1e-6) return null
|
|
223
|
+
|
|
224
|
+
let best = null
|
|
225
|
+
for (let period = 2; period <= maximum; period += 1) {
|
|
226
|
+
const bins = new Float64Array(period)
|
|
227
|
+
for (let i = 1; i < length; i += 1) bins[i % period] += projection[i]
|
|
228
|
+
let maximumBin = 0
|
|
229
|
+
for (const value of bins) maximumBin = Math.max(maximumBin, value)
|
|
230
|
+
const concentration = (maximumBin - total / period) / total
|
|
231
|
+
|
|
232
|
+
let numerator = 0
|
|
233
|
+
let leftEnergy = 0
|
|
234
|
+
let rightEnergy = 0
|
|
235
|
+
for (let i = 1; i + period < length; i += 1) {
|
|
236
|
+
const left = projection[i]
|
|
237
|
+
const right = projection[i + period]
|
|
238
|
+
numerator += left * right
|
|
239
|
+
leftEnergy += left * left
|
|
240
|
+
rightEnergy += right * right
|
|
241
|
+
}
|
|
242
|
+
const correlation = numerator / Math.sqrt(Math.max(1e-12, leftEnergy * rightEnergy))
|
|
243
|
+
const score = concentration * 0.78 + correlation * 0.22
|
|
244
|
+
if (!best || score > best.score) best = { period, score, concentration, correlation }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const phaseResult = best && best.score >= 0.025 && best.concentration >= 0.01
|
|
248
|
+
? {
|
|
249
|
+
period: best.period,
|
|
250
|
+
confidence: Math.max(0, Math.min(1, best.score * 2.5)),
|
|
251
|
+
}
|
|
252
|
+
: null
|
|
253
|
+
const peakResult = detectPixelPeriodFromGradientPeaks(projection)
|
|
254
|
+
|
|
255
|
+
if (!phaseResult) return peakResult
|
|
256
|
+
if (!peakResult) return phaseResult
|
|
257
|
+
const ratio = Math.max(phaseResult.period, peakResult.period) /
|
|
258
|
+
Math.min(phaseResult.period, peakResult.period)
|
|
259
|
+
if (ratio <= 1.2) {
|
|
260
|
+
return phaseResult.confidence >= peakResult.confidence ? phaseResult : peakResult
|
|
261
|
+
}
|
|
262
|
+
return phaseResult.confidence >= peakResult.confidence * 1.2 ? phaseResult : peakResult
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function detectPixelPeriodFromGradientPeaks(projection) {
|
|
266
|
+
let maximum = 0
|
|
267
|
+
for (const value of projection) maximum = Math.max(maximum, value)
|
|
268
|
+
if (maximum <= 1e-6) return null
|
|
269
|
+
const thresholds = [0.2, 0.12, 0.07]
|
|
270
|
+
for (const relativeThreshold of thresholds) {
|
|
271
|
+
const threshold = maximum * relativeThreshold
|
|
272
|
+
const peaks = []
|
|
273
|
+
for (let index = 1; index < projection.length - 1; index += 1) {
|
|
274
|
+
if (
|
|
275
|
+
projection[index] >= threshold &&
|
|
276
|
+
projection[index] > projection[index - 1] &&
|
|
277
|
+
projection[index] >= projection[index + 1]
|
|
278
|
+
) {
|
|
279
|
+
const previous = peaks[peaks.length - 1]
|
|
280
|
+
if (previous == null || index - previous >= 2) peaks.push(index)
|
|
281
|
+
else if (projection[index] > projection[previous]) peaks[peaks.length - 1] = index
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (peaks.length < 3) continue
|
|
285
|
+
const intervals = []
|
|
286
|
+
for (let index = 1; index < peaks.length; index += 1) {
|
|
287
|
+
const interval = peaks[index] - peaks[index - 1]
|
|
288
|
+
if (interval >= 2 && interval <= 128) intervals.push(interval)
|
|
289
|
+
}
|
|
290
|
+
if (intervals.length < 2) continue
|
|
291
|
+
const period = median(intervals)
|
|
292
|
+
if (period < 2 || period > 128) continue
|
|
293
|
+
const deviations = intervals.map((interval) => {
|
|
294
|
+
const multiple = Math.max(1, Math.round(interval / period))
|
|
295
|
+
return Math.abs(interval - multiple * period) / period
|
|
296
|
+
})
|
|
297
|
+
const meanDeviation = deviations.reduce((sum, value) => sum + value, 0) / deviations.length
|
|
298
|
+
const support = Math.min(1, intervals.length / 10)
|
|
299
|
+
const confidence = Math.max(0.08, Math.min(0.9, (1 - meanDeviation) * (0.45 + support * 0.45)))
|
|
300
|
+
return { period, confidence }
|
|
301
|
+
}
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function refineGridCoordinates(length, gridCount, projection, intensity = 0.25) {
|
|
306
|
+
const count = Math.max(1, Math.min(length, Math.round(gridCount)))
|
|
307
|
+
const cellSize = length / count
|
|
308
|
+
const coordinates = new Int32Array(count + 1)
|
|
309
|
+
coordinates[0] = 0
|
|
310
|
+
coordinates[count] = length
|
|
311
|
+
|
|
312
|
+
for (let index = 1; index < count; index += 1) {
|
|
313
|
+
const expected = index * cellSize
|
|
314
|
+
const radius = Math.max(1, Math.round(cellSize * intensity))
|
|
315
|
+
const minimum = Math.max(coordinates[index - 1] + 1, Math.round(expected) - radius)
|
|
316
|
+
const maximum = Math.min(length - (count - index), Math.round(expected) + radius)
|
|
317
|
+
let best = Math.max(minimum, Math.min(maximum, Math.round(expected)))
|
|
318
|
+
let bestValue = projection[best] || 0
|
|
319
|
+
for (let position = minimum; position <= maximum; position += 1) {
|
|
320
|
+
const value = projection[position] || 0
|
|
321
|
+
if (value > bestValue) {
|
|
322
|
+
best = position
|
|
323
|
+
bestValue = value
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
coordinates[index] = best
|
|
327
|
+
}
|
|
328
|
+
return coordinates
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function fixedGridCoordinates(length, pixelSize) {
|
|
332
|
+
const size = Math.max(2, Math.round(pixelSize))
|
|
333
|
+
const coordinates = []
|
|
334
|
+
for (let position = 0; position < length; position += size) coordinates.push(position)
|
|
335
|
+
if (coordinates[coordinates.length - 1] !== length) coordinates.push(length)
|
|
336
|
+
return Int32Array.from(coordinates)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sampleGrid(source, width, height, xCoordinates, yCoordinates, settings) {
|
|
340
|
+
const outputWidth = xCoordinates.length - 1
|
|
341
|
+
const outputHeight = yCoordinates.length - 1
|
|
342
|
+
const output = new Uint8ClampedArray(outputWidth * outputHeight * 4)
|
|
343
|
+
for (let outputY = 0; outputY < outputHeight; outputY += 1) {
|
|
344
|
+
const y0 = Math.max(0, Math.min(height - 1, yCoordinates[outputY]))
|
|
345
|
+
const y1 = Math.max(y0 + 1, Math.min(height, yCoordinates[outputY + 1]))
|
|
346
|
+
for (let outputX = 0; outputX < outputWidth; outputX += 1) {
|
|
347
|
+
const x0 = Math.max(0, Math.min(width - 1, xCoordinates[outputX]))
|
|
348
|
+
const x1 = Math.max(x0 + 1, Math.min(width, xCoordinates[outputX + 1]))
|
|
349
|
+
const color = sampleCell(source, width, x0, y0, x1, y1, settings)
|
|
350
|
+
const outputOffset = (outputY * outputWidth + outputX) * 4
|
|
351
|
+
output[outputOffset] = color[0]
|
|
352
|
+
output[outputOffset + 1] = color[1]
|
|
353
|
+
output[outputOffset + 2] = color[2]
|
|
354
|
+
output[outputOffset + 3] = color[3]
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return output
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function sampleCell(source, width, x0, y0, x1, y1, settings) {
|
|
361
|
+
const pixelCount = Math.max(1, (x1 - x0) * (y1 - y0))
|
|
362
|
+
const sampleStep = Math.max(1, Math.ceil(pixelCount / 128))
|
|
363
|
+
const samples = []
|
|
364
|
+
const alphaSamples = []
|
|
365
|
+
let alphaSum = 0
|
|
366
|
+
let sampleIndex = 0
|
|
367
|
+
let centerSample = null
|
|
368
|
+
const centerX = Math.min(x1 - 1, Math.floor((x0 + x1) / 2))
|
|
369
|
+
const centerY = Math.min(y1 - 1, Math.floor((y0 + y1) / 2))
|
|
370
|
+
|
|
371
|
+
for (let y = y0; y < y1; y += 1) {
|
|
372
|
+
for (let x = x0; x < x1; x += 1) {
|
|
373
|
+
const offset = (y * width + x) * 4
|
|
374
|
+
const alpha = source[offset + 3]
|
|
375
|
+
alphaSum += alpha
|
|
376
|
+
if (x === centerX && y === centerY) {
|
|
377
|
+
centerSample = [source[offset], source[offset + 1], source[offset + 2], alpha]
|
|
378
|
+
}
|
|
379
|
+
if (sampleIndex % sampleStep === 0) {
|
|
380
|
+
alphaSamples.push(alpha)
|
|
381
|
+
if (alpha > 4) {
|
|
382
|
+
samples.push([source[offset], source[offset + 1], source[offset + 2], alpha])
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
sampleIndex += 1
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let outputAlpha
|
|
390
|
+
if (settings.alphaMode === 'preserve') {
|
|
391
|
+
outputAlpha = alphaSamples.length ? median(alphaSamples) : 0
|
|
392
|
+
} else {
|
|
393
|
+
const coverageAlpha = alphaSum / pixelCount
|
|
394
|
+
outputAlpha = coverageAlpha >= settings.alphaThreshold ? 255 : 0
|
|
395
|
+
}
|
|
396
|
+
if (outputAlpha <= 0 || !samples.length) return [0, 0, 0, 0]
|
|
397
|
+
|
|
398
|
+
let rgb
|
|
399
|
+
if (settings.sampleMethod === 'center' && centerSample?.[3] > 4) {
|
|
400
|
+
rgb = centerSample.slice(0, 3)
|
|
401
|
+
} else if (settings.sampleMethod === 'median') {
|
|
402
|
+
rgb = weightedMedianColor(samples)
|
|
403
|
+
} else if (settings.sampleMethod === 'center') {
|
|
404
|
+
rgb = weightedMeanColor(samples)
|
|
405
|
+
} else {
|
|
406
|
+
rgb = majorityClusterColor(samples)
|
|
407
|
+
}
|
|
408
|
+
return [rgb[0], rgb[1], rgb[2], Math.round(outputAlpha)]
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function sampleWeight(sample) {
|
|
412
|
+
const alpha = sample[3] / 255
|
|
413
|
+
return alpha * alpha
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function weightedMeanColor(samples) {
|
|
417
|
+
let red = 0
|
|
418
|
+
let green = 0
|
|
419
|
+
let blue = 0
|
|
420
|
+
let weightSum = 0
|
|
421
|
+
for (const sample of samples) {
|
|
422
|
+
const weight = sampleWeight(sample)
|
|
423
|
+
red += sample[0] * weight
|
|
424
|
+
green += sample[1] * weight
|
|
425
|
+
blue += sample[2] * weight
|
|
426
|
+
weightSum += weight
|
|
427
|
+
}
|
|
428
|
+
if (weightSum <= 1e-8) return [0, 0, 0]
|
|
429
|
+
return [Math.round(red / weightSum), Math.round(green / weightSum), Math.round(blue / weightSum)]
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function weightedMedianColor(samples) {
|
|
433
|
+
const channelMedian = (channel) => {
|
|
434
|
+
const values = samples
|
|
435
|
+
.map((sample) => ({ value: sample[channel], weight: sampleWeight(sample) }))
|
|
436
|
+
.sort((left, right) => left.value - right.value)
|
|
437
|
+
const total = values.reduce((sum, item) => sum + item.weight, 0)
|
|
438
|
+
let accumulated = 0
|
|
439
|
+
for (const item of values) {
|
|
440
|
+
accumulated += item.weight
|
|
441
|
+
if (accumulated >= total / 2) return item.value
|
|
442
|
+
}
|
|
443
|
+
return values[values.length - 1]?.value || 0
|
|
444
|
+
}
|
|
445
|
+
return [channelMedian(0), channelMedian(1), channelMedian(2)]
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function majorityClusterColor(samples) {
|
|
449
|
+
if (samples.length === 1) return samples[0].slice(0, 3)
|
|
450
|
+
let first = samples[0].slice(0, 3)
|
|
451
|
+
let second = first
|
|
452
|
+
let farthestDistance = -1
|
|
453
|
+
for (const sample of samples) {
|
|
454
|
+
const distance = colorDistanceSquared(sample, first)
|
|
455
|
+
if (distance > farthestDistance) {
|
|
456
|
+
farthestDistance = distance
|
|
457
|
+
second = sample.slice(0, 3)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (farthestDistance <= 0) return first
|
|
461
|
+
|
|
462
|
+
let firstWeight = 0
|
|
463
|
+
let secondWeight = 0
|
|
464
|
+
for (let iteration = 0; iteration < 6; iteration += 1) {
|
|
465
|
+
const firstSum = [0, 0, 0]
|
|
466
|
+
const secondSum = [0, 0, 0]
|
|
467
|
+
firstWeight = 0
|
|
468
|
+
secondWeight = 0
|
|
469
|
+
for (const sample of samples) {
|
|
470
|
+
const weight = sampleWeight(sample)
|
|
471
|
+
const useSecond = colorDistanceSquared(sample, second) < colorDistanceSquared(sample, first)
|
|
472
|
+
const sum = useSecond ? secondSum : firstSum
|
|
473
|
+
sum[0] += sample[0] * weight
|
|
474
|
+
sum[1] += sample[1] * weight
|
|
475
|
+
sum[2] += sample[2] * weight
|
|
476
|
+
if (useSecond) secondWeight += weight
|
|
477
|
+
else firstWeight += weight
|
|
478
|
+
}
|
|
479
|
+
if (firstWeight > 0) first = firstSum.map((value) => value / firstWeight)
|
|
480
|
+
if (secondWeight > 0) second = secondSum.map((value) => value / secondWeight)
|
|
481
|
+
}
|
|
482
|
+
const result = secondWeight >= firstWeight ? second : first
|
|
483
|
+
return result.map((value) => Math.max(0, Math.min(255, Math.round(value))))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function colorDistanceSquared(sample, color) {
|
|
487
|
+
const red = sample[0] - color[0]
|
|
488
|
+
const green = sample[1] - color[1]
|
|
489
|
+
const blue = sample[2] - color[2]
|
|
490
|
+
return red * red + green * green + blue * blue
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function median(values) {
|
|
494
|
+
const sorted = [...values].sort((left, right) => left - right)
|
|
495
|
+
const middle = Math.floor(sorted.length / 2)
|
|
496
|
+
return sorted.length % 2
|
|
497
|
+
? sorted[middle]
|
|
498
|
+
: (sorted[middle - 1] + sorted[middle]) / 2
|
|
499
|
+
}
|