From 47a4c1195f5f1b043d836babf010681c88a87027 Mon Sep 17 00:00:00 2001 From: igor Date: Sat, 1 Aug 2026 19:59:14 +0200 Subject: [PATCH] zobrazenie pozicii cisel na mieste kliknutia, pridane toleracnie 0 pre presny pixel a 1 pre okolie pixela --- frontend/src/components/ExportPanel.vue | 19 ++++-- frontend/src/components/MosaicCanvas.vue | 16 +++-- frontend/src/components/SettingsPanel.vue | 7 ++- frontend/src/stores/mosaic.ts | 58 ++++++++++++++--- frontend/src/types/mosaic.ts | 8 ++- frontend/src/utils/floodFill.ts | 64 +++++++++++-------- frontend/src/utils/projectExport.ts | 21 +++++-- frontend/src/utils/regionMask.ts | 77 ++++++++++++++++++++++- 8 files changed, 219 insertions(+), 51 deletions(-) diff --git a/frontend/src/components/ExportPanel.vue b/frontend/src/components/ExportPanel.vue index 3ebc434..8f9e2ca 100644 --- a/frontend/src/components/ExportPanel.vue +++ b/frontend/src/components/ExportPanel.vue @@ -10,7 +10,14 @@ const baseName = () => (imageName.value.replace(/\.[^.]+$/, '').replace(/[^\p{L} async function exportPng(): Promise { 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 { 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') + } } diff --git a/frontend/src/components/MosaicCanvas.vue b/frontend/src/components/MosaicCanvas.vue index 0e09e78..630b55d 100644 --- a/frontend/src/components/MosaicCanvas.vue +++ b/frontend/src/components/MosaicCanvas.vue @@ -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) diff --git a/frontend/src/components/SettingsPanel.vue b/frontend/src/components/SettingsPanel.vue index ca35df1..e314b8f 100644 --- a/frontend/src/components/SettingsPanel.vue +++ b/frontend/src/components/SettingsPanel.vue @@ -15,7 +15,7 @@ function updateColorTolerance(event: Event): void {

Nastavenia analýzy

+ @@ -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; } diff --git a/frontend/src/stores/mosaic.ts b/frontend/src/stores/mosaic.ts index 1c38fd6..77fbb3d 100644 --- a/frontend/src/stores/mosaic.ts +++ b/frontend/src/stores/mosaic.ts @@ -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 { - 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, diff --git a/frontend/src/types/mosaic.ts b/frontend/src/types/mosaic.ts index 613f971..f1e2e00 100644 --- a/frontend/src/types/mosaic.ts +++ b/frontend/src/types/mosaic.ts @@ -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 & { + 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[] } diff --git a/frontend/src/utils/floodFill.ts b/frontend/src/utils/floodFill.ts index f7925cc..c7efd17 100644 --- a/frontend/src/utils/floodFill.ts +++ b/frontend/src/utils/floodFill.ts @@ -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) }, } } diff --git a/frontend/src/utils/projectExport.ts b/frontend/src/utils/projectExport.ts index 5305d9b..bdb289c 100644 --- a/frontend/src/utils/projectExport.ts +++ b/frontend/src/utils/projectExport.ts @@ -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 { @@ -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) => ({ diff --git a/frontend/src/utils/regionMask.ts b/frontend/src/utils/regionMask.ts index 781656e..b8d79e0 100644 --- a/frontend/src/utils/regionMask.ts +++ b/frontend/src/utils/regionMask.ts @@ -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>() + 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,