142 lines
4.7 KiB
TypeScript
142 lines
4.7 KiB
TypeScript
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) },
|
|
}
|
|
}
|