zobrazenie pozicii cisel na mieste kliknutia,

pridane toleracnie 0 pre presny pixel a 1 pre okolie pixela
This commit is contained in:
2026-08-01 19:59:14 +02:00
parent d753697840
commit 47a4c1195f
8 changed files with 219 additions and 51 deletions
+39 -25
View File
@@ -1,6 +1,6 @@
import type { BoundingBox, Point, RegionMask, RGBColor } from '@/types/mosaic'
import { colorDistance, relativeLuminance } from './colors'
import { createRegionMask } from './regionMask'
import { createRegionMask, summarizeRegionMask } from './regionMask'
export interface FloodFillOptions {
tolerance: number
@@ -28,6 +28,30 @@ const median = (values: number[]): number => {
return values[Math.floor(values.length / 2)] ?? 0
}
export function getFloodFillReferenceColor(
image: ImageData,
startX: number,
startY: number,
tolerance: number,
): RGBColor {
const { width, height, data } = image
const startIndex = startY * width + startX
if (tolerance !== 1) return pixelColor(data, startIndex)
const reds: number[] = []
const greens: number[] = []
const blues: number[] = []
for (let y = Math.max(0, startY - 2); y <= Math.min(height - 1, startY + 2); y += 1) {
for (let x = Math.max(0, startX - 2); x <= Math.min(width - 1, startX + 2); x += 1) {
const color = pixelColor(data, y * width + x)
reds.push(color.r)
greens.push(color.g)
blues.push(color.b)
}
}
return { r: median(reds), g: median(greens), b: median(blues) }
}
export function floodFillRegion(
image: ImageData,
startX: number,
@@ -39,8 +63,8 @@ export function floodFillRegion(
const startIndex = startY * width + startX
if (options.occupancy?.[startIndex]) return null
const seed = pixelColor(data, startIndex)
const seedIsDark = relativeLuminance(seed) <= options.darkBoundaryThreshold
const referenceColor = getFloodFillReferenceColor(image, startX, startY, options.tolerance)
const referenceIsDark = relativeLuminance(referenceColor) <= options.darkBoundaryThreshold
const visited = new Uint8Array(width * height)
const stack: number[] = [startIndex]
const runs: number[] = []
@@ -48,16 +72,13 @@ export function floodFillRegion(
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
if (index === startIndex) return true
const color = pixelColor(data, index)
if (!seedIsDark && relativeLuminance(color) <= options.darkBoundaryThreshold) return false
return colorDistance(color, seed) <= options.tolerance
if (!referenceIsDark && relativeLuminance(color) <= options.darkBoundaryThreshold) return false
return colorDistance(color, referenceColor) <= options.tolerance
}
const sample = (index: number) => {
@@ -93,10 +114,6 @@ export function floodFillRegion(
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
@@ -114,9 +131,9 @@ export function floodFillRegion(
}
if (!pixelCount) return null
if (!reds.length) {
reds.push(seed.r)
greens.push(seed.g)
blues.push(seed.b)
reds.push(referenceColor.r)
greens.push(referenceColor.g)
blues.push(referenceColor.b)
}
const orderedRuns: Array<[number, number, number]> = []
@@ -124,18 +141,15 @@ export function floodFillRegion(
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])
const mask = createRegionMask(width, height, orderedRuns.flatMap((run) => run))
const summary = summarizeRegionMask(mask)
if (!summary) return null
return {
mask: createRegionMask(width, height, orderedRuns.flatMap((run) => run)),
mask,
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] },
boundingBox: summary.boundingBox,
labelPosition: summary.labelPosition,
color: { r: median(reds), g: median(greens), b: median(blues) },
}
}
+16 -5
View File
@@ -6,15 +6,23 @@ import type {
ProjectFile,
} from '@/types/mosaic'
import { relativeLuminance, rgbToHex } from './colors'
import { forEachMaskRun } from './regionMask'
import { forEachMaskRun, resolveRegionLabelPosition } from './regionMask'
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
link.hidden = true
document.body.append(link)
try {
link.click()
} finally {
window.setTimeout(() => {
link.remove()
URL.revokeObjectURL(url)
}, 1000)
}
}
export async function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
@@ -29,6 +37,7 @@ export function renderExportCanvas(
regions: MosaicRegion[],
groups: ColorGroup[],
includeOverlay: boolean,
useUserClickPositionForLabels = false,
): HTMLCanvasElement {
const canvas = document.createElement('canvas')
canvas.width = dimensions.width
@@ -57,12 +66,13 @@ export function renderExportCanvas(
for (const region of regions) {
const group = groupMap.get(region.groupId)
if (!group) continue
const labelPosition = resolveRegionLabelPosition(region, useUserClickPositionForLabels)
const label = String(group.number)
context.lineWidth = Math.max(2, fontSize / 7)
context.strokeStyle = relativeLuminance(group.color) > 0.45 ? 'rgba(255,255,255,.9)' : 'rgba(0,0,0,.8)'
context.fillStyle = relativeLuminance(group.color) > 0.45 ? '#111827' : '#FFFFFF'
context.strokeText(label, region.labelPosition.x, region.labelPosition.y)
context.fillText(label, region.labelPosition.x, region.labelPosition.y)
context.strokeText(label, labelPosition.x, labelPosition.y)
context.fillText(label, labelPosition.x, labelPosition.y)
}
return canvas
}
@@ -121,6 +131,7 @@ export async function createProjectFile(input: {
labColor: { ...region.labColor },
boundingBox: { ...region.boundingBox },
labelPosition: { ...region.labelPosition },
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
mask: { width: region.mask.width, height: region.mask.height, runs: Array.from(region.mask.runs) },
})),
groups: input.groups.map((group) => ({
+76 -1
View File
@@ -1,10 +1,85 @@
import { markRaw } from 'vue'
import type { RegionMask } from '@/types/mosaic'
import type { BoundingBox, MosaicRegion, Point, RegionMask } from '@/types/mosaic'
export interface RegionMaskSummary {
pixelCount: number
boundingBox: BoundingBox
labelPosition: Point
}
export function resolveRegionLabelPosition(
region: MosaicRegion,
useUserClickPositionForLabels: boolean,
): Point {
return useUserClickPositionForLabels
? region.userClickPosition ?? region.labelPosition
: region.labelPosition
}
export function createRegionMask(width: number, height: number, runs: number[]): RegionMask {
return markRaw({ width, height, runs: markRaw(Uint32Array.from(runs)) })
}
export function subtractRegionMask(source: RegionMask, removed: RegionMask): RegionMask | null {
const removedByRow = new Map<number, Array<[number, number]>>()
forEachMaskRun(removed, (y, xStart, xEnd) => {
const row = removedByRow.get(y) ?? []
row.push([xStart, xEnd])
removedByRow.set(y, row)
})
const remainingRuns: number[] = []
forEachMaskRun(source, (y, xStart, xEnd) => {
let segments: Array<[number, number]> = [[xStart, xEnd]]
for (const [cutStart, cutEnd] of removedByRow.get(y) ?? []) {
const nextSegments: Array<[number, number]> = []
for (const [segmentStart, segmentEnd] of segments) {
if (cutEnd < segmentStart || cutStart > segmentEnd) {
nextSegments.push([segmentStart, segmentEnd])
continue
}
if (cutStart > segmentStart) nextSegments.push([segmentStart, cutStart - 1])
if (cutEnd < segmentEnd) nextSegments.push([cutEnd + 1, segmentEnd])
}
segments = nextSegments
}
for (const [segmentStart, segmentEnd] of segments) remainingRuns.push(y, segmentStart, segmentEnd)
})
return remainingRuns.length ? createRegionMask(source.width, source.height, remainingRuns) : null
}
export function summarizeRegionMask(mask: RegionMask): RegionMaskSummary | null {
let pixelCount = 0
let minX = mask.width
let minY = mask.height
let maxX = -1
let maxY = -1
const runs: Array<[number, number, number]> = []
forEachMaskRun(mask, (y, xStart, xEnd) => {
runs.push([y, xStart, xEnd])
pixelCount += xEnd - xStart + 1
minX = Math.min(minX, xStart)
minY = Math.min(minY, y)
maxX = Math.max(maxX, xEnd)
maxY = Math.max(maxY, y)
})
if (!pixelCount) return null
const centerY = (minY + maxY) / 2
const labelRun = runs.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
}, runs[0] ?? [0, 0, 0])
return {
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] },
}
}
export function forEachMaskRun(
mask: RegionMask,
callback: (y: number, xStart: number, xEnd: number) => void,