zobrazenie pozicii cisel na mieste kliknutia,
pridane toleracnie 0 pre presny pixel a 1 pre okolie pixela
This commit is contained in:
@ -10,7 +10,14 @@ const baseName = () => (imageName.value.replace(/\.[^.]+$/, '').replace(/[^\p{L}
|
||||
async function exportPng(): Promise<void> {
|
||||
if (!imageCanvas.value) return store.notify('Najprv načítajte obrázok.', 'warning')
|
||||
try {
|
||||
const canvas = renderExportCanvas(imageCanvas.value, { width: imageWidth.value, height: imageHeight.value }, regions.value, groups.value, settings.value.exportOverlay)
|
||||
const canvas = renderExportCanvas(
|
||||
imageCanvas.value,
|
||||
{ width: imageWidth.value, height: imageHeight.value },
|
||||
regions.value,
|
||||
groups.value,
|
||||
settings.value.exportOverlay,
|
||||
settings.value.useUserClickPositionForLabels,
|
||||
)
|
||||
downloadBlob(await canvasToBlob(canvas), `${baseName()}-ocislovane.png`)
|
||||
} catch (error) { store.notify(error instanceof Error ? error.message : 'Export PNG zlyhal.', 'error') }
|
||||
}
|
||||
@ -22,9 +29,13 @@ function exportCsv(): void {
|
||||
|
||||
async function exportJson(): Promise<void> {
|
||||
if (!imageWidth.value) return store.notify('Nie je čo exportovať.', 'warning')
|
||||
const project = await store.serializeProject(true)
|
||||
downloadBlob(new Blob([JSON.stringify(project)], { type: 'application/json' }), `${baseName()}-projekt.json`)
|
||||
if (!project.imageDataUrl) store.notify('Obrázok je väčší než 5 MB. Pri importe JSON ho bude potrebné znova vybrať.', 'info')
|
||||
try {
|
||||
const project = await store.serializeProject(true)
|
||||
downloadBlob(new Blob([JSON.stringify(project)], { type: 'application/json' }), `${baseName()}-projekt.json`)
|
||||
if (!project.imageDataUrl) store.notify('Obrázok je väčší než 5 MB. Pri importe JSON ho bude potrebné znova vybrať.', 'info')
|
||||
} catch (error) {
|
||||
store.notify(error instanceof Error ? error.message : 'Export projektu JSON zlyhal.', 'error')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMosaicStore } from '@/stores/mosaic'
|
||||
import { rgbToHex, relativeLuminance } from '@/utils/colors'
|
||||
import { forEachMaskRun } from '@/utils/regionMask'
|
||||
import { forEachMaskRun, resolveRegionLabelPosition } from '@/utils/regionMask'
|
||||
import { useCanvasViewport } from '@/composables/useCanvasViewport'
|
||||
import ImageDropzone from './ImageDropzone.vue'
|
||||
|
||||
@ -76,8 +76,9 @@ function draw(): void {
|
||||
const group = groupMap.get(region.groupId)
|
||||
if (!group) continue
|
||||
if (selectedGroupId.value && settings.value.otherGroupsMode === 'hidden' && group.id !== selectedGroupId.value) continue
|
||||
const x = viewport.x + region.labelPosition.x * viewport.scale
|
||||
const y = viewport.y + region.labelPosition.y * viewport.scale
|
||||
const labelPosition = resolveRegionLabelPosition(region, settings.value.useUserClickPositionForLabels)
|
||||
const x = viewport.x + labelPosition.x * viewport.scale
|
||||
const y = viewport.y + labelPosition.y * viewport.scale
|
||||
const active = region.id === selectedRegionId.value
|
||||
context.beginPath()
|
||||
context.arc(x, y, active ? 13 : 11, 0, Math.PI * 2)
|
||||
@ -133,7 +134,14 @@ function wheel(event: WheelEvent): void {
|
||||
}
|
||||
|
||||
watch([imageCanvas, imageWidth, imageHeight], async () => { await nextTick(); resetViewport() })
|
||||
watch([regions, groups, selectedGroupId, selectedRegionId, () => settings.value.otherGroupsMode], draw, { deep: true })
|
||||
watch([
|
||||
regions,
|
||||
groups,
|
||||
selectedGroupId,
|
||||
selectedRegionId,
|
||||
() => settings.value.otherGroupsMode,
|
||||
() => settings.value.useUserClickPositionForLabels,
|
||||
], draw, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
observer = new ResizeObserver(resize)
|
||||
|
||||
@ -15,7 +15,7 @@ function updateColorTolerance(event: Event): void {
|
||||
<h2>Nastavenia analýzy</h2>
|
||||
<label class="range-field">
|
||||
<span><b>Tolerancia výberu</b><output>{{ settings.floodTolerance }}</output></span>
|
||||
<input v-model.number="settings.floodTolerance" type="range" min="2" max="100" step="1" />
|
||||
<input v-model.number="settings.floodTolerance" type="range" min="0" max="100" step="1" />
|
||||
<small>Vyššia hodnota spojí väčšie farebné odchýlky v jednej ploche.</small>
|
||||
</label>
|
||||
<label class="range-field">
|
||||
@ -46,6 +46,10 @@ function updateColorTolerance(event: Event): void {
|
||||
<span><b>Prahová tmavosť obrysu</b><output>{{ settings.darkBoundaryThreshold.toFixed(3) }}</output></span>
|
||||
<input v-model.number="settings.darkBoundaryThreshold" type="range" min="0" max="0.2" step="0.005" />
|
||||
</label>
|
||||
<label class="check-field">
|
||||
<input v-model="settings.useUserClickPositionForLabels" type="checkbox" />
|
||||
<span>Zobrazovať čísla na pozícii kliknutia</span>
|
||||
</label>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
@ -62,4 +66,5 @@ select { width: 100%; }
|
||||
details { margin-top: 13px; color: #475569; font-size: .76rem; }
|
||||
summary { cursor: pointer; }
|
||||
.compact { margin-bottom: 4px; }
|
||||
.check-field { display: flex; align-items: center; gap: 7px; margin-top: 12px; color: #475569; cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { computed, markRaw, reactive, ref, shallowRef } from 'vue'
|
||||
import { computed, markRaw, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type {
|
||||
ColorGroup,
|
||||
@ -7,10 +7,16 @@ import type {
|
||||
ProjectFile,
|
||||
ToastMessage,
|
||||
} from '@/types/mosaic'
|
||||
import { floodFillRegion } from '@/utils/floodFill'
|
||||
import { floodFillRegion, getFloodFillReferenceColor } from '@/utils/floodFill'
|
||||
import { groupRegionsByColor, refreshGroupColors, sortGroupsByLightness } from '@/utils/colorGroups'
|
||||
import { rgbToLab } from '@/utils/colors'
|
||||
import { buildOccupancy, createRegionMask } from '@/utils/regionMask'
|
||||
import { colorDistance, rgbToLab } from '@/utils/colors'
|
||||
import {
|
||||
buildOccupancy,
|
||||
createRegionMask,
|
||||
maskContains,
|
||||
subtractRegionMask,
|
||||
summarizeRegionMask,
|
||||
} from '@/utils/regionMask'
|
||||
import { createProjectFile, isProjectFile } from '@/utils/projectExport'
|
||||
import { clearLastProject, loadLastProject, saveLastProject } from '@/utils/projectStorage'
|
||||
|
||||
@ -26,6 +32,7 @@ const DEFAULT_SETTINGS: MosaicSettings = {
|
||||
connectivity: 4,
|
||||
exportOverlay: false,
|
||||
otherGroupsMode: 'dim',
|
||||
useUserClickPositionForLabels: false,
|
||||
}
|
||||
|
||||
const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((region) => markRaw({
|
||||
@ -34,6 +41,7 @@ const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((r
|
||||
labColor: { ...region.labColor },
|
||||
boundingBox: { ...region.boundingBox },
|
||||
labelPosition: { ...region.labelPosition },
|
||||
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
|
||||
// Masks are immutable; sharing their typed arrays keeps the 30-step history compact.
|
||||
mask: region.mask,
|
||||
}))
|
||||
@ -170,21 +178,29 @@ export const useMosaicStore = defineStore('mosaic', () => {
|
||||
|
||||
function addRegionAt(x: number, y: number): void {
|
||||
if (!imageData.value || isAnalyzing.value) return
|
||||
const occupancy = buildOccupancy(imageWidth.value, imageHeight.value, regions.value.map((region) => region.mask))
|
||||
if (occupancy[y * imageWidth.value + x]) {
|
||||
const floodTolerance = settings.floodTolerance
|
||||
const minimumRegionPixelCount = floodTolerance <= 1 ? 1 : 12
|
||||
const referenceColor = getFloodFillReferenceColor(imageData.value, x, y, floodTolerance)
|
||||
const existingRegion = regions.value.find((region) => maskContains(region.mask, x, y))
|
||||
if (existingRegion && colorDistance(existingRegion.color, referenceColor) <= floodTolerance) {
|
||||
notify('Táto plocha už bola označená.', 'warning')
|
||||
return
|
||||
}
|
||||
const occupancy = buildOccupancy(
|
||||
imageWidth.value,
|
||||
imageHeight.value,
|
||||
regions.value.filter((region) => region.id !== existingRegion?.id).map((region) => region.mask),
|
||||
)
|
||||
isAnalyzing.value = true
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
const result = floodFillRegion(imageData.value as ImageData, x, y, {
|
||||
tolerance: settings.floodTolerance,
|
||||
tolerance: floodTolerance,
|
||||
darkBoundaryThreshold: settings.darkBoundaryThreshold,
|
||||
connectivity: settings.connectivity,
|
||||
occupancy,
|
||||
})
|
||||
if (!result || result.pixelCount < 12) {
|
||||
if (!result || result.pixelCount < minimumRegionPixelCount) {
|
||||
notify('Nájdená plocha je príliš malá. Skúste iný bod alebo vyššiu toleranciu.', 'warning')
|
||||
return
|
||||
}
|
||||
@ -200,10 +216,27 @@ export const useMosaicStore = defineStore('mosaic', () => {
|
||||
pixelCount: result.pixelCount,
|
||||
boundingBox: result.boundingBox,
|
||||
labelPosition: result.labelPosition,
|
||||
userClickPosition: { x, y },
|
||||
groupId: '',
|
||||
mask: result.mask,
|
||||
})
|
||||
regions.value = [...regions.value, region]
|
||||
const nextRegions = regions.value.flatMap((item) => {
|
||||
if (item.id !== existingRegion?.id) return [item]
|
||||
const remainingMask = subtractRegionMask(item.mask, result.mask)
|
||||
const summary = remainingMask ? summarizeRegionMask(remainingMask) : null
|
||||
if (!remainingMask || !summary || summary.pixelCount < minimumRegionPixelCount) return []
|
||||
return [markRaw({
|
||||
...item,
|
||||
mask: remainingMask,
|
||||
pixelCount: summary.pixelCount,
|
||||
boundingBox: summary.boundingBox,
|
||||
labelPosition: summary.labelPosition,
|
||||
userClickPosition: item.userClickPosition && maskContains(remainingMask, item.userClickPosition.x, item.userClickPosition.y)
|
||||
? item.userClickPosition
|
||||
: undefined,
|
||||
})]
|
||||
})
|
||||
regions.value = [...nextRegions, region]
|
||||
regroup(false)
|
||||
selectedRegionId.value = region.id
|
||||
selectedGroupId.value = regions.value.find((item) => item.id === region.id)?.groupId ?? null
|
||||
@ -308,12 +341,15 @@ export const useMosaicStore = defineStore('mosaic', () => {
|
||||
}
|
||||
|
||||
async function applyProject(project: ProjectFile, blob: Blob | null = null): Promise<void> {
|
||||
Object.assign(settings, project.settings)
|
||||
Object.assign(settings, DEFAULT_SETTINGS, project.settings, {
|
||||
useUserClickPositionForLabels: project.settings.useUserClickPositionForLabels ?? false,
|
||||
})
|
||||
imageName.value = project.imageName
|
||||
imageWidth.value = project.dimensions.width
|
||||
imageHeight.value = project.dimensions.height
|
||||
regions.value = project.regions.map((region) => markRaw({
|
||||
...region,
|
||||
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
|
||||
mask: createRegionMask(region.mask.width, region.mask.height, region.mask.runs),
|
||||
}))
|
||||
groups.value = cloneGroups(project.groups)
|
||||
@ -373,6 +409,8 @@ export const useMosaicStore = defineStore('mosaic', () => {
|
||||
await clearLastProject().catch(() => undefined)
|
||||
}
|
||||
|
||||
watch(settings, scheduleAutosave)
|
||||
|
||||
return {
|
||||
imageCanvas, imageData, imageBlob, imageName, imageWidth, imageHeight, regions, groups,
|
||||
selectedGroupId, selectedRegionId, settings, toasts, isAnalyzing, hasRecovery, recoveryTimestamp,
|
||||
|
||||
@ -36,6 +36,7 @@ export interface MosaicRegion {
|
||||
pixelCount: number
|
||||
boundingBox: BoundingBox
|
||||
labelPosition: Point
|
||||
userClickPosition?: Point
|
||||
groupId: string
|
||||
mask: RegionMask
|
||||
}
|
||||
@ -56,6 +57,11 @@ export interface MosaicSettings {
|
||||
connectivity: 4 | 8
|
||||
exportOverlay: boolean
|
||||
otherGroupsMode: 'normal' | 'dim' | 'hidden'
|
||||
useUserClickPositionForLabels: boolean
|
||||
}
|
||||
|
||||
export type SerializedMosaicSettings = Omit<MosaicSettings, 'useUserClickPositionForLabels'> & {
|
||||
useUserClickPositionForLabels?: boolean
|
||||
}
|
||||
|
||||
export interface ProjectDimensions {
|
||||
@ -73,7 +79,7 @@ export interface ProjectFile {
|
||||
imageName: string
|
||||
imageDataUrl?: string
|
||||
dimensions: ProjectDimensions
|
||||
settings: MosaicSettings
|
||||
settings: SerializedMosaicSettings
|
||||
regions: SerializedRegion[]
|
||||
groups: ColorGroup[]
|
||||
}
|
||||
|
||||
@ -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