zobrazenie pozicii cisel na mieste kliknutia,
pridane toleracnie 0 pre presny pixel a 1 pre okolie pixela
This commit is contained in:
@@ -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) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user