diff --git a/frontend/README.md b/frontend/README.md index f190e00..ec91b6e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,48 +1,24 @@ -# frontend +# Mozaic – lokálna analýza farebných plôch -This template should help get you started developing with Vue 3 in Vite. +Vue 3 aplikácia na označovanie uzavretých farebných plôch pomocou flood fill, zoskupovanie podobných farieb v CIE Lab a export očíslovaného výsledku. Podporuje PNG, JPEG a WebP, manuálne úpravy skupín, undo/redo, export PNG/CSV/JSON a obnovu poslednej práce z IndexedDB. -## Recommended IDE Setup +Všetky obrázky a výpočty zostávajú výhradne v prehliadači. Aplikácia nemá backend a obrázky nikam neodosiela. -[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). - -## Recommended Browser Setup - -- Chromium-based browsers (Chrome, Edge, Brave, etc.): - - [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd) - - [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters) -- Firefox: - - [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/) - - [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/) - -## Type Support for `.vue` Imports in TS - -TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. - -## Customize configuration - -See [Vite Configuration Reference](https://vite.dev/config/). - -## Project Setup +## Spustenie ```sh npm install -``` - -### Compile and Hot-Reload for Development - -```sh npm run dev ``` -### Type-Check, Compile and Minify for Production +Produkčný build a kontroly: ```sh npm run build -``` - -### Lint with [ESLint](https://eslint.org/) - -```sh +npm run type-check npm run lint ``` + +## Použitie + +Načítajte obrázok, klikajte dovnútra ohraničených farebných plôch a podľa potreby upravte toleranciu. Ťahaním sa obrázok posúva a kolieskom myši približuje. Výsledok možno exportovať ako očíslované PNG, paletu CSV alebo projekt JSON. diff --git a/frontend/index.html b/frontend/index.html index 9e5fc8f..85a0e0a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,12 @@ - + - Vite App + + + Mozaic – analýza farebných plôch
diff --git a/frontend/src/App.vue b/frontend/src/App.vue index abfd315..71a9b9a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,11 +1,5 @@ - + - - - + diff --git a/frontend/src/components/ColorGroupItem.vue b/frontend/src/components/ColorGroupItem.vue new file mode 100644 index 0000000..2c16759 --- /dev/null +++ b/frontend/src/components/ColorGroupItem.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/src/components/ColorGroupList.vue b/frontend/src/components/ColorGroupList.vue new file mode 100644 index 0000000..b6cc2b8 --- /dev/null +++ b/frontend/src/components/ColorGroupList.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/frontend/src/components/EditorToolbar.vue b/frontend/src/components/EditorToolbar.vue new file mode 100644 index 0000000..fcd9139 --- /dev/null +++ b/frontend/src/components/EditorToolbar.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend/src/components/ExportPanel.vue b/frontend/src/components/ExportPanel.vue new file mode 100644 index 0000000..3ebc434 --- /dev/null +++ b/frontend/src/components/ExportPanel.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/components/ImageDropzone.vue b/frontend/src/components/ImageDropzone.vue new file mode 100644 index 0000000..2aaa582 --- /dev/null +++ b/frontend/src/components/ImageDropzone.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/frontend/src/components/MosaicCanvas.vue b/frontend/src/components/MosaicCanvas.vue new file mode 100644 index 0000000..0e09e78 --- /dev/null +++ b/frontend/src/components/MosaicCanvas.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/frontend/src/components/RegionList.vue b/frontend/src/components/RegionList.vue new file mode 100644 index 0000000..e9e0c66 --- /dev/null +++ b/frontend/src/components/RegionList.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/frontend/src/components/SettingsPanel.vue b/frontend/src/components/SettingsPanel.vue new file mode 100644 index 0000000..ca35df1 --- /dev/null +++ b/frontend/src/components/SettingsPanel.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/composables/useCanvasViewport.ts b/frontend/src/composables/useCanvasViewport.ts new file mode 100644 index 0000000..c36b6e3 --- /dev/null +++ b/frontend/src/composables/useCanvasViewport.ts @@ -0,0 +1,24 @@ +import { reactive } from 'vue' + +export function useCanvasViewport() { + const viewport = reactive({ scale: 1, x: 0, y: 0 }) + + function fit(containerWidth: number, containerHeight: number, imageWidth: number, imageHeight: number): void { + if (!imageWidth || !imageHeight) return + viewport.scale = Math.min((containerWidth - 40) / imageWidth, (containerHeight - 40) / imageHeight, 1) + viewport.scale = Math.max(0.02, viewport.scale) + viewport.x = (containerWidth - imageWidth * viewport.scale) / 2 + viewport.y = (containerHeight - imageHeight * viewport.scale) / 2 + } + + function zoomAt(factor: number, x: number, y: number): void { + const nextScale = Math.max(0.03, Math.min(24, viewport.scale * factor)) + const imageX = (x - viewport.x) / viewport.scale + const imageY = (y - viewport.y) / viewport.scale + viewport.x = x - imageX * nextScale + viewport.y = y - imageY * nextScale + viewport.scale = nextScale + } + + return { viewport, fit, zoomAt } +} diff --git a/frontend/src/stores/mosaic.ts b/frontend/src/stores/mosaic.ts new file mode 100644 index 0000000..1c38fd6 --- /dev/null +++ b/frontend/src/stores/mosaic.ts @@ -0,0 +1,383 @@ +import { computed, markRaw, reactive, ref, shallowRef } from 'vue' +import { defineStore } from 'pinia' +import type { + ColorGroup, + MosaicRegion, + MosaicSettings, + ProjectFile, + ToastMessage, +} from '@/types/mosaic' +import { floodFillRegion } from '@/utils/floodFill' +import { groupRegionsByColor, refreshGroupColors, sortGroupsByLightness } from '@/utils/colorGroups' +import { rgbToLab } from '@/utils/colors' +import { buildOccupancy, createRegionMask } from '@/utils/regionMask' +import { createProjectFile, isProjectFile } from '@/utils/projectExport' +import { clearLastProject, loadLastProject, saveLastProject } from '@/utils/projectStorage' + +interface HistorySnapshot { + regions: MosaicRegion[] + groups: ColorGroup[] +} + +const DEFAULT_SETTINGS: MosaicSettings = { + floodTolerance: 34, + colorTolerance: 12, + darkBoundaryThreshold: 0.045, + connectivity: 4, + exportOverlay: false, + otherGroupsMode: 'dim', +} + +const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((region) => markRaw({ + ...region, + color: { ...region.color }, + labColor: { ...region.labColor }, + boundingBox: { ...region.boundingBox }, + labelPosition: { ...region.labelPosition }, + // Masks are immutable; sharing their typed arrays keeps the 30-step history compact. + mask: region.mask, +})) + +const cloneGroups = (groups: ColorGroup[]): ColorGroup[] => groups.map((group) => ({ + ...group, + color: { ...group.color }, + labColor: { ...group.labColor }, + regionIds: [...group.regionIds], +})) + +export const useMosaicStore = defineStore('mosaic', () => { + const imageCanvas = shallowRef(null) + const imageData = shallowRef(null) + const imageBlob = shallowRef(null) + const imageName = ref('') + const imageWidth = ref(0) + const imageHeight = ref(0) + const regions = shallowRef([]) + const groups = ref([]) + const selectedGroupId = ref(null) + const selectedRegionId = ref(null) + const settings = reactive({ ...DEFAULT_SETTINGS }) + const toasts = ref([]) + const isAnalyzing = ref(false) + const hasRecovery = ref(false) + const recoveryTimestamp = ref('') + const undoStack = shallowRef([]) + const redoStack = shallowRef([]) + let recoveryProject: ProjectFile | null = null + let recoveryBlob: Blob | null = null + let autosaveTimer = 0 + + const hasImage = computed(() => Boolean(imageCanvas.value && imageData.value)) + const canUndo = computed(() => undoStack.value.length > 0) + const canRedo = computed(() => redoStack.value.length > 0) + + function notify(text: string, type: ToastMessage['type'] = 'info'): void { + const id = crypto.randomUUID() + toasts.value.push({ id, text, type }) + window.setTimeout(() => dismissToast(id), 5000) + } + + function dismissToast(id: string): void { + toasts.value = toasts.value.filter((toast) => toast.id !== id) + } + + function snapshot(): HistorySnapshot { + return { regions: cloneRegions(regions.value), groups: cloneGroups(groups.value) } + } + + function recordHistory(): void { + undoStack.value = [...undoStack.value.slice(-29), snapshot()] + redoStack.value = [] + } + + function restoreSnapshot(value: HistorySnapshot): void { + regions.value = cloneRegions(value.regions) + groups.value = cloneGroups(value.groups) + selectedGroupId.value = null + selectedRegionId.value = null + scheduleAutosave() + } + + function undo(): void { + const previous = undoStack.value.at(-1) + if (!previous) return + redoStack.value = [...redoStack.value, snapshot()].slice(-30) + undoStack.value = undoStack.value.slice(0, -1) + restoreSnapshot(previous) + } + + function redo(): void { + const next = redoStack.value.at(-1) + if (!next) return + undoStack.value = [...undoStack.value, snapshot()].slice(-30) + redoStack.value = redoStack.value.slice(0, -1) + restoreSnapshot(next) + } + + async function decodeImage(blob: Blob): Promise { + const bitmap = await createImageBitmap(blob) + if (bitmap.width * bitmap.height > 80_000_000) { + bitmap.close() + throw new Error('Obrázok je príliš veľký (limit je 80 miliónov pixelov). Skúste menšiu verziu.') + } + const canvas = document.createElement('canvas') + canvas.width = bitmap.width + canvas.height = bitmap.height + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (!context) throw new Error('Prehliadač nepodporuje Canvas 2D.') + context.drawImage(bitmap, 0, 0) + bitmap.close() + return canvas + } + + async function loadImage(file: Blob, filename = 'obrazok'): Promise { + if (file instanceof File && !['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) { + notify('Podporované sú iba obrázky PNG, JPEG a WebP.', 'error') + return + } + try { + const canvas = await decodeImage(file) + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (!context) throw new Error('Prehliadač nepodporuje Canvas 2D.') + const pendingImportedProject = regions.value.length > 0 && !imageCanvas.value + if (pendingImportedProject && (imageWidth.value !== canvas.width || imageHeight.value !== canvas.height)) { + notify(`Projekt očakáva obrázok ${imageWidth.value} × ${imageHeight.value} px. Vybraný obrázok má ${canvas.width} × ${canvas.height} px.`, 'error') + return + } + const replacingMatchingImage = pendingImportedProject + imageCanvas.value = markRaw(canvas) + imageData.value = markRaw(context.getImageData(0, 0, canvas.width, canvas.height)) + imageBlob.value = markRaw(file) + imageName.value = filename + imageWidth.value = canvas.width + imageHeight.value = canvas.height + if (!replacingMatchingImage) { + regions.value = [] + groups.value = [] + undoStack.value = [] + redoStack.value = [] + } + if (canvas.width * canvas.height > 24_000_000) { + notify('Obrázok je veľmi veľký. Analýza rozsiahlych plôch môže chvíľu trvať.', 'warning') + } else { + notify('Obrázok bol načítaný iba lokálne v prehliadači.', 'success') + } + scheduleAutosave() + } catch (error) { + notify(error instanceof Error ? error.message : 'Obrázok je poškodený alebo ho nemožno načítať.', 'error') + } + } + + 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]) { + notify('Táto plocha už bola označená.', 'warning') + return + } + isAnalyzing.value = true + window.setTimeout(() => { + try { + const result = floodFillRegion(imageData.value as ImageData, x, y, { + tolerance: settings.floodTolerance, + darkBoundaryThreshold: settings.darkBoundaryThreshold, + connectivity: settings.connectivity, + occupancy, + }) + if (!result || result.pixelCount < 12) { + notify('Nájdená plocha je príliš malá. Skúste iný bod alebo vyššiu toleranciu.', 'warning') + return + } + if (result.pixelCount > imageWidth.value * imageHeight.value * 0.92) { + notify('Výber pokrýva takmer celý obrázok. Znížte toleranciu alebo kliknite dovnútra ohraničenej plochy.', 'warning') + return + } + recordHistory() + const region: MosaicRegion = markRaw({ + id: crypto.randomUUID(), + color: result.color, + labColor: rgbToLab(result.color), + pixelCount: result.pixelCount, + boundingBox: result.boundingBox, + labelPosition: result.labelPosition, + groupId: '', + mask: result.mask, + }) + regions.value = [...regions.value, region] + regroup(false) + selectedRegionId.value = region.id + selectedGroupId.value = regions.value.find((item) => item.id === region.id)?.groupId ?? null + scheduleAutosave() + } finally { + isAnalyzing.value = false + } + }, 20) + } + + function regroup(withHistory = true): void { + if (withHistory && regions.value.length) recordHistory() + groups.value = groupRegionsByColor(regions.value, settings.colorTolerance) + const regionToGroup = new Map(groups.value.flatMap((group) => group.regionIds.map((id) => [id, group.id]))) + regions.value = regions.value.map((region) => markRaw({ ...region, groupId: regionToGroup.get(region.id) ?? '' })) + selectedGroupId.value = null + scheduleAutosave() + } + + function setColorTolerance(value: number): void { + settings.colorTolerance = value + regroup() + } + + function removeRegion(id: string): void { + if (!regions.value.some((region) => region.id === id)) return + recordHistory() + regions.value = regions.value.filter((region) => region.id !== id) + groups.value = refreshGroupColors(groups.value.map((group) => ({ ...group, regionIds: group.regionIds.filter((regionId) => regionId !== id) })), regions.value) + selectedRegionId.value = null + scheduleAutosave() + } + + function moveRegion(regionId: string, targetGroupId: string): void { + const region = regions.value.find((item) => item.id === regionId) + if (!region || region.groupId === targetGroupId || !groups.value.some((group) => group.id === targetGroupId)) return + recordHistory() + regions.value = regions.value.map((item) => item.id === regionId ? markRaw({ ...item, groupId: targetGroupId }) : item) + groups.value = refreshGroupColors(groups.value.map((group) => ({ + ...group, + regionIds: group.id === targetGroupId + ? [...group.regionIds, regionId] + : group.regionIds.filter((id) => id !== regionId), + })), regions.value) + scheduleAutosave() + } + + function splitRegion(regionId: string): void { + const region = regions.value.find((item) => item.id === regionId) + const oldGroup = groups.value.find((group) => group.id === region?.groupId) + if (!region || !oldGroup || oldGroup.regionIds.length <= 1) return + recordHistory() + const newGroup: ColorGroup = { + id: crypto.randomUUID(), number: groups.value.length + 1, color: { ...region.color }, + labColor: { ...region.labColor }, regionIds: [region.id], + } + regions.value = regions.value.map((item) => item.id === region.id ? markRaw({ ...item, groupId: newGroup.id }) : item) + groups.value = refreshGroupColors([...groups.value.map((group) => ({ ...group, regionIds: group.regionIds.filter((id) => id !== region.id) })), newGroup], regions.value) + selectedGroupId.value = newGroup.id + scheduleAutosave() + } + + function mergeGroups(sourceId: string, targetId: string): void { + if (sourceId === targetId) return + const source = groups.value.find((group) => group.id === sourceId) + const target = groups.value.find((group) => group.id === targetId) + if (!source || !target) return + recordHistory() + regions.value = regions.value.map((region) => source.regionIds.includes(region.id) ? markRaw({ ...region, groupId: targetId }) : region) + groups.value = refreshGroupColors(groups.value + .filter((group) => group.id !== sourceId) + .map((group) => group.id === targetId ? { ...group, regionIds: [...group.regionIds, ...source.regionIds] } : group), regions.value) + selectedGroupId.value = targetId + scheduleAutosave() + } + + function setGroupColor(groupId: string, hex: string): void { + const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex) + if (!match) return + recordHistory() + const color = { r: Number.parseInt(match[1] ?? '0', 16), g: Number.parseInt(match[2] ?? '0', 16), b: Number.parseInt(match[3] ?? '0', 16) } + groups.value = sortGroupsByLightness(groups.value.map((group) => group.id === groupId ? { ...group, color, labColor: rgbToLab(color), customColor: true } : group)) + scheduleAutosave() + } + + function clearResults(): void { + if (!regions.value.length) return + recordHistory() + regions.value = [] + groups.value = [] + selectedGroupId.value = null + selectedRegionId.value = null + scheduleAutosave() + } + + function serializeProject(includeSmallImage = false): Promise { + return createProjectFile({ + imageName: imageName.value, imageBlob: imageBlob.value, + dimensions: { width: imageWidth.value, height: imageHeight.value }, + settings: { ...settings }, regions: regions.value, groups: groups.value, includeSmallImage, + }) + } + + async function applyProject(project: ProjectFile, blob: Blob | null = null): Promise { + Object.assign(settings, project.settings) + imageName.value = project.imageName + imageWidth.value = project.dimensions.width + imageHeight.value = project.dimensions.height + regions.value = project.regions.map((region) => markRaw({ + ...region, + mask: createRegionMask(region.mask.width, region.mask.height, region.mask.runs), + })) + groups.value = cloneGroups(project.groups) + undoStack.value = [] + redoStack.value = [] + imageCanvas.value = null + imageData.value = null + imageBlob.value = null + if (blob) await loadImage(blob, project.imageName) + else if (project.imageDataUrl) { + const response = await fetch(project.imageDataUrl) + await loadImage(await response.blob(), project.imageName) + } else notify('Projekt je načítaný. Vyberte pôvodný obrázok s rovnakými rozmermi.', 'warning') + } + + async function importProjectFile(file: File): Promise { + try { + const parsed: unknown = JSON.parse(await file.text()) + if (!isProjectFile(parsed)) throw new Error('Súbor nemá platný formát projektu Mozaic.') + await applyProject(parsed) + notify('Projekt bol úspešne importovaný.', 'success') + } catch (error) { + notify(error instanceof Error ? error.message : 'JSON projekt je poškodený.', 'error') + } + } + + function scheduleAutosave(): void { + window.clearTimeout(autosaveTimer) + autosaveTimer = window.setTimeout(async () => { + if (!imageWidth.value) return + try { await saveLastProject(await serializeProject(false), imageBlob.value) } catch { /* Private mode may block IndexedDB. */ } + }, 800) + } + + async function checkRecovery(): Promise { + try { + const stored = await loadLastProject() + if (!stored) return + recoveryProject = stored.project + recoveryBlob = stored.imageBlob + recoveryTimestamp.value = stored.savedAt + hasRecovery.value = true + } catch { /* IndexedDB is optional. */ } + } + + async function restoreRecovery(): Promise { + if (!recoveryProject) return + await applyProject(recoveryProject, recoveryBlob) + hasRecovery.value = false + notify('Posledná rozpracovaná práca bola obnovená.', 'success') + } + + async function dismissRecovery(): Promise { + hasRecovery.value = false + recoveryProject = null + recoveryBlob = null + await clearLastProject().catch(() => undefined) + } + + return { + imageCanvas, imageData, imageBlob, imageName, imageWidth, imageHeight, regions, groups, + selectedGroupId, selectedRegionId, settings, toasts, isAnalyzing, hasRecovery, recoveryTimestamp, + hasImage, canUndo, canRedo, loadImage, addRegionAt, regroup, setColorTolerance, removeRegion, + moveRegion, splitRegion, mergeGroups, setGroupColor, clearResults, undo, redo, notify, + dismissToast, serializeProject, importProjectFile, checkRecovery, restoreRecovery, dismissRecovery, + } +}) diff --git a/frontend/src/types/mosaic.ts b/frontend/src/types/mosaic.ts new file mode 100644 index 0000000..613f971 --- /dev/null +++ b/frontend/src/types/mosaic.ts @@ -0,0 +1,85 @@ +export interface RGBColor { + r: number + g: number + b: number +} + +export interface LabColor { + l: number + a: number + b: number +} + +export interface Point { + x: number + y: number +} + +export interface BoundingBox { + x: number + y: number + width: number + height: number +} + +/** Triplets of y, inclusive xStart and inclusive xEnd. */ +export interface RegionMask { + width: number + height: number + runs: Uint32Array +} + +export interface MosaicRegion { + id: string + color: RGBColor + labColor: LabColor + pixelCount: number + boundingBox: BoundingBox + labelPosition: Point + groupId: string + mask: RegionMask +} + +export interface ColorGroup { + id: string + number: number + color: RGBColor + labColor: LabColor + regionIds: string[] + customColor?: boolean +} + +export interface MosaicSettings { + floodTolerance: number + colorTolerance: number + darkBoundaryThreshold: number + connectivity: 4 | 8 + exportOverlay: boolean + otherGroupsMode: 'normal' | 'dim' | 'hidden' +} + +export interface ProjectDimensions { + width: number + height: number +} + +export interface SerializedRegion extends Omit { + mask: { width: number; height: number; runs: number[] } +} + +export interface ProjectFile { + version: 1 + createdAt: string + imageName: string + imageDataUrl?: string + dimensions: ProjectDimensions + settings: MosaicSettings + regions: SerializedRegion[] + groups: ColorGroup[] +} + +export interface ToastMessage { + id: string + type: 'info' | 'success' | 'warning' | 'error' + text: string +} diff --git a/frontend/src/utils/colorGroups.ts b/frontend/src/utils/colorGroups.ts new file mode 100644 index 0000000..63c84bf --- /dev/null +++ b/frontend/src/utils/colorGroups.ts @@ -0,0 +1,50 @@ +import type { ColorGroup, MosaicRegion, RGBColor } from '@/types/mosaic' +import { averageColors, deltaE76, rgbToLab } from './colors' + +export function sortGroupsByLightness(groups: ColorGroup[]): ColorGroup[] { + return [...groups] + .sort((first, second) => first.labColor.l - second.labColor.l) + .map((group, index) => ({ ...group, number: index + 1 })) +} + +export function groupRegionsByColor(regions: MosaicRegion[], tolerance: number): ColorGroup[] { + const groups: ColorGroup[] = [] + const ordered = [...regions].sort((first, second) => first.labColor.l - second.labColor.l) + for (const region of ordered) { + const group = groups.find((candidate) => deltaE76(candidate.labColor, region.labColor) <= tolerance) + if (group) { + group.regionIds.push(region.id) + const members = regionColors(group.regionIds, regions) + group.color = averageColors(members) + group.labColor = rgbToLab(group.color) + } else { + groups.push({ + id: crypto.randomUUID(), + number: groups.length + 1, + color: { ...region.color }, + labColor: { ...region.labColor }, + regionIds: [region.id], + }) + } + } + return sortGroupsByLightness(groups) +} + +function regionColors(regionIds: string[], regions: MosaicRegion[]): Array<{ color: RGBColor; weight: number }> { + const idSet = new Set(regionIds) + return regions + .filter((region) => idSet.has(region.id)) + .map((region) => ({ color: region.color, weight: region.pixelCount })) +} + +export function refreshGroupColors(groups: ColorGroup[], regions: MosaicRegion[]): ColorGroup[] { + return sortGroupsByLightness( + groups + .filter((group) => group.regionIds.length) + .map((group) => { + if (group.customColor) return { ...group, regionIds: [...group.regionIds] } + const color = averageColors(regionColors(group.regionIds, regions)) + return { ...group, color, labColor: rgbToLab(color), regionIds: [...group.regionIds] } + }), + ) +} diff --git a/frontend/src/utils/colors.ts b/frontend/src/utils/colors.ts new file mode 100644 index 0000000..fd9ee57 --- /dev/null +++ b/frontend/src/utils/colors.ts @@ -0,0 +1,63 @@ +import type { LabColor, RGBColor } from '@/types/mosaic' + +const clampByte = (value: number) => Math.max(0, Math.min(255, Math.round(value))) + +export function rgbToHex(color: RGBColor): string { + const channel = (value: number) => clampByte(value).toString(16).padStart(2, '0') + return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`.toUpperCase() +} + +export function rgbToXyz(color: RGBColor): { x: number; y: number; z: number } { + const linearize = (value: number) => { + const channel = clampByte(value) / 255 + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + } + const r = linearize(color.r) + const g = linearize(color.g) + const b = linearize(color.b) + return { + x: (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100, + y: (r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100, + z: (r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100, + } +} + +export function xyzToLab(xyz: { x: number; y: number; z: number }): LabColor { + const transform = (value: number) => { + const delta = 6 / 29 + return value > delta ** 3 ? Math.cbrt(value) : value / (3 * delta ** 2) + 4 / 29 + } + const x = transform(xyz.x / 95.047) + const y = transform(xyz.y / 100) + const z = transform(xyz.z / 108.883) + return { l: 116 * y - 16, a: 500 * (x - y), b: 200 * (y - z) } +} + +export function rgbToLab(color: RGBColor): LabColor { + return xyzToLab(rgbToXyz(color)) +} + +export function deltaE76(first: LabColor, second: LabColor): number { + return Math.hypot(first.l - second.l, first.a - second.a, first.b - second.b) +} + +export function relativeLuminance(color: RGBColor): number { + const linearize = (value: number) => { + const channel = clampByte(value) / 255 + return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * linearize(color.r) + 0.7152 * linearize(color.g) + 0.0722 * linearize(color.b) +} + +export function colorDistance(first: RGBColor, second: RGBColor): number { + return Math.hypot(first.r - second.r, first.g - second.g, first.b - second.b) +} + +export function averageColors(colors: Array<{ color: RGBColor; weight: number }>): RGBColor { + const total = colors.reduce((sum, item) => sum + item.weight, 0) || 1 + return { + r: Math.round(colors.reduce((sum, item) => sum + item.color.r * item.weight, 0) / total), + g: Math.round(colors.reduce((sum, item) => sum + item.color.g * item.weight, 0) / total), + b: Math.round(colors.reduce((sum, item) => sum + item.color.b * item.weight, 0) / total), + } +} diff --git a/frontend/src/utils/floodFill.ts b/frontend/src/utils/floodFill.ts new file mode 100644 index 0000000..f7925cc --- /dev/null +++ b/frontend/src/utils/floodFill.ts @@ -0,0 +1,141 @@ +import type { BoundingBox, Point, RegionMask, RGBColor } from '@/types/mosaic' +import { colorDistance, relativeLuminance } from './colors' +import { createRegionMask } from './regionMask' + +export interface FloodFillOptions { + tolerance: number + darkBoundaryThreshold: number + connectivity: 4 | 8 + occupancy?: Uint8Array +} + +export interface FloodFillResult { + mask: RegionMask + pixelCount: number + boundingBox: BoundingBox + labelPosition: Point + color: RGBColor +} + +const pixelColor = (data: Uint8ClampedArray, index: number): RGBColor => ({ + r: data[index * 4] ?? 0, + g: data[index * 4 + 1] ?? 0, + b: data[index * 4 + 2] ?? 0, +}) + +const median = (values: number[]): number => { + values.sort((first, second) => first - second) + return values[Math.floor(values.length / 2)] ?? 0 +} + +export function floodFillRegion( + image: ImageData, + startX: number, + startY: number, + options: FloodFillOptions, +): FloodFillResult | null { + const { width, height, data } = image + if (startX < 0 || startY < 0 || startX >= width || startY >= height) return null + const startIndex = startY * width + startX + if (options.occupancy?.[startIndex]) return null + + const seed = pixelColor(data, startIndex) + const seedIsDark = relativeLuminance(seed) <= options.darkBoundaryThreshold + const visited = new Uint8Array(width * height) + const stack: number[] = [startIndex] + const runs: number[] = [] + const reds: number[] = [] + 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 + const color = pixelColor(data, index) + if (!seedIsDark && relativeLuminance(color) <= options.darkBoundaryThreshold) return false + return colorDistance(color, seed) <= options.tolerance + } + + const sample = (index: number) => { + const color = pixelColor(data, index) + if (reds.length < 8000) { + reds.push(color.r) + greens.push(color.g) + blues.push(color.b) + return + } + const replacement = Math.floor(Math.random() * pixelCount) + if (replacement < 8000) { + reds[replacement] = color.r + greens[replacement] = color.g + blues[replacement] = color.b + } + } + + while (stack.length) { + const index = stack.pop() + if (index === undefined || !isAccepted(index)) continue + const y = Math.floor(index / width) + let left = index % width + let right = left + while (left > 0 && isAccepted(y * width + left - 1)) left -= 1 + while (right + 1 < width && isAccepted(y * width + right + 1)) right += 1 + + for (let x = left; x <= right; x += 1) { + const pixelIndex = y * width + x + visited[pixelIndex] = 1 + pixelCount += 1 + // Avoid both ends of the scanline, where outlines and JPEG artifacts are most likely. + 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 + const extra = options.connectivity === 8 ? 1 : 0 + const scanStart = Math.max(0, left - extra) + const scanEnd = Math.min(width - 1, right + extra) + let insideCandidate = false + for (let x = scanStart; x <= scanEnd; x += 1) { + const neighborIndex = neighborY * width + x + const candidate = isAccepted(neighborIndex) + if (candidate && !insideCandidate) stack.push(neighborIndex) + insideCandidate = candidate + } + } + } + if (!pixelCount) return null + if (!reds.length) { + reds.push(seed.r) + greens.push(seed.g) + blues.push(seed.b) + } + + const orderedRuns: Array<[number, number, number]> = [] + for (let index = 0; index < runs.length; index += 3) { + 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]) + + return { + mask: createRegionMask(width, height, orderedRuns.flatMap((run) => run)), + 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] }, + color: { r: median(reds), g: median(greens), b: median(blues) }, + } +} diff --git a/frontend/src/utils/projectExport.ts b/frontend/src/utils/projectExport.ts new file mode 100644 index 0000000..5305d9b --- /dev/null +++ b/frontend/src/utils/projectExport.ts @@ -0,0 +1,148 @@ +import type { + ColorGroup, + MosaicRegion, + MosaicSettings, + ProjectDimensions, + ProjectFile, +} from '@/types/mosaic' +import { relativeLuminance, rgbToHex } from './colors' +import { forEachMaskRun } 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) +} + +export async function canvasToBlob(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('PNG sa nepodarilo vytvoriť.'))), 'image/png') + }) +} + +export function renderExportCanvas( + source: CanvasImageSource, + dimensions: ProjectDimensions, + regions: MosaicRegion[], + groups: ColorGroup[], + includeOverlay: boolean, +): HTMLCanvasElement { + const canvas = document.createElement('canvas') + canvas.width = dimensions.width + canvas.height = dimensions.height + const context = canvas.getContext('2d') + if (!context) throw new Error('Prehliadač nepodporuje Canvas 2D.') + context.drawImage(source, 0, 0, dimensions.width, dimensions.height) + const groupMap = new Map(groups.map((group) => [group.id, group])) + + if (includeOverlay) { + context.save() + context.globalAlpha = 0.24 + for (const region of regions) { + const group = groupMap.get(region.groupId) + if (!group) continue + context.fillStyle = rgbToHex(group.color) + forEachMaskRun(region.mask, (y, xStart, xEnd) => context.fillRect(xStart, y, xEnd - xStart + 1, 1)) + } + context.restore() + } + + const fontSize = Math.max(14, Math.round(Math.min(dimensions.width, dimensions.height) / 55)) + context.textAlign = 'center' + context.textBaseline = 'middle' + context.font = `700 ${fontSize}px system-ui, sans-serif` + for (const region of regions) { + const group = groupMap.get(region.groupId) + if (!group) continue + 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) + } + return canvas +} + +const csvCell = (value: string | number) => `"${String(value).replaceAll('"', '""')}"` + +export function createPaletteCsv(groups: ColorGroup[], regions: MosaicRegion[]): string { + const header = ['Poradie', 'Číslo skupiny', 'HEX', 'R', 'G', 'B', 'Lab L', 'Lab a', 'Lab b', 'Počet plôch', 'Počet pixelov'] + const regionMap = new Map(regions.map((region) => [region.id, region])) + const rows = groups.map((group, index) => { + const pixels = group.regionIds.reduce((sum, id) => sum + (regionMap.get(id)?.pixelCount ?? 0), 0) + return [ + index + 1, + group.number, + rgbToHex(group.color), + group.color.r, + group.color.g, + group.color.b, + group.labColor.l.toFixed(2), + group.labColor.a.toFixed(2), + group.labColor.b.toFixed(2), + group.regionIds.length, + pixels, + ].map(csvCell).join(',') + }) + return `\uFEFF${[header.map(csvCell).join(','), ...rows].join('\r\n')}` +} + +async function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result)) + reader.onerror = () => reject(reader.error ?? new Error('Súbor sa nepodarilo načítať.')) + reader.readAsDataURL(blob) + }) +} + +export async function createProjectFile(input: { + imageName: string + imageBlob: Blob | null + dimensions: ProjectDimensions + settings: MosaicSettings + regions: MosaicRegion[] + groups: ColorGroup[] + includeSmallImage?: boolean +}): Promise { + const project: ProjectFile = { + version: 1, + createdAt: new Date().toISOString(), + imageName: input.imageName, + dimensions: { ...input.dimensions }, + settings: { ...input.settings }, + regions: input.regions.map((region) => ({ + ...region, + color: { ...region.color }, + labColor: { ...region.labColor }, + boundingBox: { ...region.boundingBox }, + labelPosition: { ...region.labelPosition }, + mask: { width: region.mask.width, height: region.mask.height, runs: Array.from(region.mask.runs) }, + })), + groups: input.groups.map((group) => ({ + ...group, + color: { ...group.color }, + labColor: { ...group.labColor }, + regionIds: [...group.regionIds], + })), + } + if (input.includeSmallImage && input.imageBlob && input.imageBlob.size <= 5 * 1024 * 1024) { + project.imageDataUrl = await blobToDataUrl(input.imageBlob) + } + return project +} + +export function isProjectFile(value: unknown): value is ProjectFile { + if (!value || typeof value !== 'object') return false + const project = value as Partial + return project.version === 1 && + typeof project.imageName === 'string' && + typeof project.dimensions?.width === 'number' && + typeof project.dimensions.height === 'number' && + Array.isArray(project.regions) && Array.isArray(project.groups) && + typeof project.settings?.floodTolerance === 'number' +} diff --git a/frontend/src/utils/projectStorage.ts b/frontend/src/utils/projectStorage.ts new file mode 100644 index 0000000..f3f9e29 --- /dev/null +++ b/frontend/src/utils/projectStorage.ts @@ -0,0 +1,52 @@ +import type { ProjectFile } from '@/types/mosaic' + +const DATABASE = 'mozaic-analyzer' +const STORE = 'projects' +const LAST_PROJECT = 'last-project' + +interface StoredProject { + project: ProjectFile + imageBlob: Blob | null + savedAt: string +} + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE, 1) + request.onupgradeneeded = () => request.result.createObjectStore(STORE) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} + +export async function saveLastProject(project: ProjectFile, imageBlob: Blob | null): Promise { + const database = await openDatabase() + await new Promise((resolve, reject) => { + const transaction = database.transaction(STORE, 'readwrite') + transaction.objectStore(STORE).put({ project, imageBlob, savedAt: new Date().toISOString() } satisfies StoredProject, LAST_PROJECT) + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + }) + database.close() +} + +export async function loadLastProject(): Promise { + const database = await openDatabase() + const value = await new Promise((resolve, reject) => { + const request = database.transaction(STORE, 'readonly').objectStore(STORE).get(LAST_PROJECT) + request.onsuccess = () => resolve(request.result as StoredProject | undefined) + request.onerror = () => reject(request.error) + }) + database.close() + return value ?? null +} + +export async function clearLastProject(): Promise { + const database = await openDatabase() + await new Promise((resolve, reject) => { + const request = database.transaction(STORE, 'readwrite').objectStore(STORE).delete(LAST_PROJECT) + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) + database.close() +} diff --git a/frontend/src/utils/regionMask.ts b/frontend/src/utils/regionMask.ts new file mode 100644 index 0000000..781656e --- /dev/null +++ b/frontend/src/utils/regionMask.ts @@ -0,0 +1,36 @@ +import { markRaw } from 'vue' +import type { RegionMask } from '@/types/mosaic' + +export function createRegionMask(width: number, height: number, runs: number[]): RegionMask { + return markRaw({ width, height, runs: markRaw(Uint32Array.from(runs)) }) +} + +export function forEachMaskRun( + mask: RegionMask, + callback: (y: number, xStart: number, xEnd: number) => void, +): void { + for (let index = 0; index < mask.runs.length; index += 3) { + callback(mask.runs[index] ?? 0, mask.runs[index + 1] ?? 0, mask.runs[index + 2] ?? 0) + } +} + +export function buildOccupancy(width: number, height: number, masks: RegionMask[]): Uint8Array { + const occupancy = new Uint8Array(width * height) + for (const mask of masks) { + forEachMaskRun(mask, (y, xStart, xEnd) => { + occupancy.fill(1, y * width + xStart, y * width + xEnd + 1) + }) + } + return occupancy +} + +export function maskContains(mask: RegionMask, x: number, y: number): boolean { + for (let index = 0; index < mask.runs.length; index += 3) { + const runY = mask.runs[index] ?? -1 + if (runY > y) return false + if (runY === y && x >= (mask.runs[index + 1] ?? 0) && x <= (mask.runs[index + 2] ?? -1)) { + return true + } + } + return false +} diff --git a/frontend/src/views/EditorView.vue b/frontend/src/views/EditorView.vue new file mode 100644 index 0000000..baf7a50 --- /dev/null +++ b/frontend/src/views/EditorView.vue @@ -0,0 +1,109 @@ + + + + +