zakladna implementacia by Codex

This commit is contained in:
2026-08-01 07:28:21 +02:00
parent 1b71803407
commit a5973a5168
21 changed files with 1629 additions and 46 deletions

View File

@ -0,0 +1,141 @@
import type { BoundingBox, Point, RegionMask, RGBColor } from '@/types/mosaic'
import { colorDistance, relativeLuminance } from './colors'
import { createRegionMask } from './regionMask'
export interface FloodFillOptions {
tolerance: number
darkBoundaryThreshold: number
connectivity: 4 | 8
occupancy?: Uint8Array
}
export interface FloodFillResult {
mask: RegionMask
pixelCount: number
boundingBox: BoundingBox
labelPosition: Point
color: RGBColor
}
const pixelColor = (data: Uint8ClampedArray, index: number): RGBColor => ({
r: data[index * 4] ?? 0,
g: data[index * 4 + 1] ?? 0,
b: data[index * 4 + 2] ?? 0,
})
const median = (values: number[]): number => {
values.sort((first, second) => first - second)
return values[Math.floor(values.length / 2)] ?? 0
}
export function floodFillRegion(
image: ImageData,
startX: number,
startY: number,
options: FloodFillOptions,
): FloodFillResult | null {
const { width, height, data } = image
if (startX < 0 || startY < 0 || startX >= width || startY >= height) return null
const startIndex = startY * width + startX
if (options.occupancy?.[startIndex]) return null
const seed = pixelColor(data, startIndex)
const seedIsDark = relativeLuminance(seed) <= options.darkBoundaryThreshold
const visited = new Uint8Array(width * height)
const stack: number[] = [startIndex]
const runs: number[] = []
const reds: number[] = []
const greens: number[] = []
const blues: number[] = []
let pixelCount = 0
let minX = width
let minY = height
let maxX = 0
let maxY = 0
const isAccepted = (index: number) => {
if (visited[index] || options.occupancy?.[index]) return false
const color = pixelColor(data, index)
if (!seedIsDark && relativeLuminance(color) <= options.darkBoundaryThreshold) return false
return colorDistance(color, seed) <= options.tolerance
}
const sample = (index: number) => {
const color = pixelColor(data, index)
if (reds.length < 8000) {
reds.push(color.r)
greens.push(color.g)
blues.push(color.b)
return
}
const replacement = Math.floor(Math.random() * pixelCount)
if (replacement < 8000) {
reds[replacement] = color.r
greens[replacement] = color.g
blues[replacement] = color.b
}
}
while (stack.length) {
const index = stack.pop()
if (index === undefined || !isAccepted(index)) continue
const y = Math.floor(index / width)
let left = index % width
let right = left
while (left > 0 && isAccepted(y * width + left - 1)) left -= 1
while (right + 1 < width && isAccepted(y * width + right + 1)) right += 1
for (let x = left; x <= right; x += 1) {
const pixelIndex = y * width + x
visited[pixelIndex] = 1
pixelCount += 1
// Avoid both ends of the scanline, where outlines and JPEG artifacts are most likely.
if (x >= left + 2 && x <= right - 2) sample(pixelIndex)
}
runs.push(y, left, right)
minX = Math.min(minX, left)
maxX = Math.max(maxX, right)
minY = Math.min(minY, y)
maxY = Math.max(maxY, y)
for (const neighborY of [y - 1, y + 1]) {
if (neighborY < 0 || neighborY >= height) continue
const extra = options.connectivity === 8 ? 1 : 0
const scanStart = Math.max(0, left - extra)
const scanEnd = Math.min(width - 1, right + extra)
let insideCandidate = false
for (let x = scanStart; x <= scanEnd; x += 1) {
const neighborIndex = neighborY * width + x
const candidate = isAccepted(neighborIndex)
if (candidate && !insideCandidate) stack.push(neighborIndex)
insideCandidate = candidate
}
}
}
if (!pixelCount) return null
if (!reds.length) {
reds.push(seed.r)
greens.push(seed.g)
blues.push(seed.b)
}
const orderedRuns: Array<[number, number, number]> = []
for (let index = 0; index < runs.length; index += 3) {
orderedRuns.push([runs[index] ?? 0, runs[index + 1] ?? 0, runs[index + 2] ?? 0])
}
orderedRuns.sort((first, second) => first[0] - second[0] || first[1] - second[1])
const centerY = (minY + maxY) / 2
const labelRun = orderedRuns.reduce((best, run) => {
const score = run[2] - run[1] - Math.abs(run[0] - centerY) * 0.2
const bestScore = best[2] - best[1] - Math.abs(best[0] - centerY) * 0.2
return score > bestScore ? run : best
}, orderedRuns[0] ?? [startY, startX, startX])
return {
mask: createRegionMask(width, height, orderedRuns.flatMap((run) => run)),
pixelCount,
boundingBox: { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
labelPosition: { x: Math.round((labelRun[1] + labelRun[2]) / 2), y: labelRun[0] },
color: { r: median(reds), g: median(greens), b: median(blues) },
}
}