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

View File

@ -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>

View File

@ -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)

View File

@ -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>

View File

@ -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,

View File

@ -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[]
}

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) },
}
}

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) => ({

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,