zakladna implementacia by Codex
This commit is contained in:
141
frontend/src/utils/floodFill.ts
Normal file
141
frontend/src/utils/floodFill.ts
Normal 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) },
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user