Compare commits

...

3 Commits

Author SHA1 Message Date
27f96afb89 pridany AGENTS.md 2026-08-01 20:06:27 +02:00
dc7341c97f aktualizovany README.md 2026-08-01 20:02:00 +02:00
47a4c1195f zobrazenie pozicii cisel na mieste kliknutia,
pridane toleracnie 0 pre presny pixel a 1 pre okolie pixela
2026-08-01 19:59:14 +02:00
10 changed files with 276 additions and 60 deletions

45
AGENTS.md Normal file
View File

@ -0,0 +1,45 @@
# Repository Guidelines
## Project Structure & Module Organization
The application lives in `frontend/`; run Node and npm commands there. Vue source is under `frontend/src/`:
- `components/` contains the canvas editor, toolbar, settings, lists, and export UI.
- `views/` owns page-level layout; `stores/mosaic.ts` is the primary Pinia state and workflow coordinator.
- `utils/` contains framework-light color conversion, flood fill, RLE masks, persistence, and export logic.
- `types/` defines shared TypeScript models; `composables/` contains viewport behavior.
- `assets/` is for bundled assets, while `public/` contains favicons and static files copied unchanged.
Repository documentation is in `README.md`; screenshots belong in `doc/`. Generated `frontend/dist/` and root `dist/` outputs are not source files.
## Build, Test, and Development Commands
From `frontend/`:
```bash
npm install # install dependencies
npm run dev # start the Vite development server
npm run type-check # run vue-tsc
npm run lint # run Oxlint and ESLint with automatic fixes
npm run format # format src/ with Prettier
npm run build # type-check and create frontend/dist/
npm run preview # serve the production build locally
```
Node.js must satisfy `^22.18.0 || >=24.12.0`. The root `build.bat` creates the deployable root `dist/` on Windows.
## Coding Style & Naming Conventions
Use Vue 3 Composition API and `<script setup lang="ts">`. Indent with two spaces, omit semicolons, prefer single quotes, and keep lines near 100 characters. Use PascalCase for Vue components (`MosaicCanvas.vue`), camelCase for functions and variables, and `useXxx` for composables. Keep UI text Slovak and code identifiers English. Avoid `any`; share types from `src/types/`. Keep large `ImageData`, `Blob`, canvas, and typed-array values shallow or marked raw.
## Testing Guidelines
No test runner or coverage threshold is currently configured. Every change must pass `npm run type-check`, `npm run lint`, and `npm run build`. Keep algorithmic logic in `utils/` so it can be tested without browser automation. If a test framework is introduced, use focused `*.test.ts` files and cover color math, flood fill, RLE operations, serialization, and backward compatibility.
## Commit & Pull Request Guidelines
History uses short, descriptive Slovak commit subjects (for example, `aktualizovany README.md`) rather than Conventional Commits. Keep each commit focused and describe the outcome. Pull requests should include a concise summary, verification commands and results, linked issue when applicable, and screenshots for visible UI changes. Call out project-format or IndexedDB compatibility changes explicitly.
## Security & Deployment
Image processing must remain local to the browser; do not add uploads or external APIs without explicit approval. `deploy.bat` targets a specific remote host and deletes its deployment directory before syncing—never run it without repository-owner authorization.

View File

@ -29,10 +29,11 @@ Výsledok možno upraviť, exportovať ako očíslovaný obrázok a uložiť spo
- **Načítanie obrázka:** podporované sú súbory PNG, JPEG a WebP vybrané cez tlačidlo alebo pretiahnuté do vyznačenej plochy. - **Načítanie obrázka:** podporované sú súbory PNG, JPEG a WebP vybrané cez tlačidlo alebo pretiahnuté do vyznačenej plochy.
- **Canvas editor:** obrázok sa zobrazí so zachovaným pomerom strán; koliesko myši mení priblíženie, ťahanie posúva pohľad a tlačidlo `Prispôsobiť` obnoví vhodné zobrazenie. - **Canvas editor:** obrázok sa zobrazí so zachovaným pomerom strán; koliesko myši mení priblíženie, ťahanie posúva pohľad a tlačidlo `Prispôsobiť` obnoví vhodné zobrazenie.
- **Označenie plochy kliknutím:** kliknutie do obrázka spustí iteratívny flood fill nad pixelmi pôvodného rozlíšenia. - **Označenie plochy kliknutím:** kliknutie do obrázka spustí iteratívny flood fill nad pixelmi pôvodného rozlíšenia.
- **Nastaviteľný výber:** používateľ môže meniť toleranciu výberu, 4- alebo 8-smerovú susednosť a prah tmavosti obrysov. - **Nastaviteľný výber:** používateľ môže meniť toleranciu výberu v rozsahu 0 až 100, 4- alebo 8-smerovú susednosť a prah tmavosti obrysov.
- **Reprezentatívna farba:** farba nevychádza iba z kliknutého pixelu; počíta sa medián vzoriek z vnútra nájdenej plochy. - **Reprezentatívna farba:** farba nevychádza iba z kliknutého pixelu; počíta sa medián vzoriek z vnútra nájdenej plochy.
- **Zoskupovanie podobných farieb:** plochy sa porovnávajú v priestore CIE Lab pomocou farebnej vzdialenosti Delta E 1976. Prah podobnosti je nastaviteľný. - **Zoskupovanie podobných farieb:** plochy sa porovnávajú v priestore CIE Lab pomocou farebnej vzdialenosti Delta E 1976. Prah podobnosti je nastaviteľný.
- **Zoradenie a číslovanie:** skupiny sa automaticky zoradia podľa hodnoty svetlosti `L` od najtmavšej po najsvetlejšiu a ich čísla sa zobrazia na označených plochách. - **Zoradenie a číslovanie:** skupiny sa automaticky zoradia podľa hodnoty svetlosti `L` od najtmavšej po najsvetlejšiu a ich čísla sa zobrazia na označených plochách.
- **Pozícia čísla:** v rozšírených nastaveniach možno zapnúť `Zobrazovať čísla na pozícii kliknutia`. Bez tejto voľby aplikácia naďalej používa automaticky vypočítanú vnútornú pozíciu.
- **Zvýraznenie skupiny:** vybraná skupina sa na obrázku zvýrazní; ostatné skupiny možno zobraziť normálne, stlmiť alebo skryť. - **Zvýraznenie skupiny:** vybraná skupina sa na obrázku zvýrazní; ostatné skupiny možno zobraziť normálne, stlmiť alebo skryť.
- **Manuálne opravy:** plochu možno odstrániť, presunúť do inej skupiny alebo oddeliť do novej skupiny. Skupiny možno zlúčiť a ich reprezentatívnu farbu zmeniť. - **Manuálne opravy:** plochu možno odstrániť, presunúť do inej skupiny alebo oddeliť do novej skupiny. Skupiny možno zlúčiť a ich reprezentatívnu farbu zmeniť.
- **Prepočítanie skupín:** tlačidlo `Prepočítať` znovu vytvorí skupiny podľa aktuálnej tolerancie podobnosti. - **Prepočítanie skupín:** tlačidlo `Prepočítať` znovu vytvorí skupiny podľa aktuálnej tolerancie podobnosti.
@ -46,8 +47,8 @@ Výsledok možno upraviť, exportovať ako očíslovaný obrázok a uložiť spo
1. **Otvorte aplikáciu.** Pri lokálnom vývoji ju sprístupní Vite na adrese uvedenej v termináli. 1. **Otvorte aplikáciu.** Pri lokálnom vývoji ju sprístupní Vite na adrese uvedenej v termináli.
2. **Načítajte obrázok.** Použite `Načítať obrázok`, `Vybrať obrázok` alebo presuňte súbor PNG, JPEG či WebP do editora. 2. **Načítajte obrázok.** Použite `Načítať obrázok`, `Vybrať obrázok` alebo presuňte súbor PNG, JPEG či WebP do editora.
3. **Nastavte toleranciu výberu.** Začnite s predvolenou hodnotou. Ak výber uniká cez hranice, hodnotu znížte; ak sa plocha vyberie iba čiastočne, zvýšte ju. V rozšírených nastaveniach možno upraviť aj prah tmavosti obrysu. 3. **Nastavte toleranciu výberu.** Hodnota `0` vyberá iba susedné pixely s presne rovnakou RGB farbou ako kliknutý pixel. Hodnota `1` používa ako referenciu medián farby z okolia 5 × 5 pixelov. Od hodnoty `2` sa používa pôvodný spôsob porovnania s farbou kliknutého pixelu. Ak výber uniká cez hranice, hodnotu znížte; ak sa plocha vyberie iba čiastočne, zvýšte ju.
4. **Označujte plochy.** Klikajte dovnútra jednotlivých uzavretých plôch. Ťahaním obrázok posúvajte a kolieskom ho približujte alebo odďaľujte. 4. **Označujte plochy.** Klikajte dovnútra jednotlivých uzavretých plôch. Ťahaním obrázok posúvajte a kolieskom ho približujte alebo odďaľujte. Ak chcete čísla ponechať priamo na miestach kliknutia, v sekcii `Rozšírené` zapnite `Zobrazovať čísla na pozícii kliknutia`.
5. **Skontrolujte farebné skupiny.** Pravý panel zobrazuje HEX, RGB, svetlosť a počet plôch každej skupiny. Zoradenie od najtmavšej po najsvetlejšiu prebieha automaticky. 5. **Skontrolujte farebné skupiny.** Pravý panel zobrazuje HEX, RGB, svetlosť a počet plôch každej skupiny. Zoradenie od najtmavšej po najsvetlejšiu prebieha automaticky.
6. **Upravte podobnosť farieb.** Posuvník `Podobnosť farieb (ΔE)` určuje, ktoré farby sa spoja. Po jeho zmene sa skupiny prepočítajú. 6. **Upravte podobnosť farieb.** Posuvník `Podobnosť farieb (ΔE)` určuje, ktoré farby sa spoja. Po jeho zmene sa skupiny prepočítajú.
7. **Zvýraznite pracovnú skupinu.** Kliknite na farebnú skupinu a v nastavení `Ostatné skupiny` vyberte normálne zobrazenie, stlmenie alebo skrytie. 7. **Zvýraznite pracovnú skupinu.** Kliknite na farebnú skupinu a v nastavení `Ostatné skupiny` vyberte normálne zobrazenie, stlmenie alebo skrytie.
@ -67,7 +68,9 @@ Projekt JSON možno neskôr otvoriť cez `Import JSON`. Ak súbor neobsahuje pô
## Ako aplikácia určuje farby ## Ako aplikácia určuje farby
Po kliknutí Mozaic začne farbou daného pixelu a hľadá susedné pixely s dostatočne podobnou RGB farbou. Tmavé pixely pod nastaveným prahom slúžia pri farebnom výbere ako bariéra, čo pomáha zabrániť pretečeniu cez tmavé obrysové čiary. Už označené pixely sa do ďalšej plochy nezaradia. Po kliknutí Mozaic určí referenčnú farbu a hľadá susedné pixely s dostatočne podobnou RGB farbou. Pri tolerancii `0` je referenciou presná farba kliknutého pixelu a farebná vzdialenosť musí byť nulová. Pri tolerancii `1` sa pre každý kanál R, G a B vypočíta medián z okolia `<-2, +2>` v oboch osiach, teda najviac z 25 pixelov. Medián obmedzuje vplyv jedného šumového alebo kompresného artefaktu. Pri tolerancii `2` a vyššej je referenciou opäť farba kliknutého pixelu.
Tmavé pixely pod nastaveným prahom slúžia pri farebnom výbere ako bariéra, čo pomáha zabrániť pretečeniu cez tmavé obrysové čiary. Ak pixel už patrí staršiemu regiónu, ale jeho farba sa podľa aktuálnej tolerancie líši, nová plocha sa môže zo starej masky oddeliť. Pri rovnakej farbe aplikácia zabráni duplicitnému označeniu.
Reprezentatívna farba sa neurčuje z jediného bodu. Algoritmus zbiera vzorky z vnútorných častí scanline segmentov, obmedzuje ich počet a pre každý RGB kanál vypočíta medián. Tým sa znižuje vplyv okrajov, drobného šumu a časti kompresných artefaktov. Reprezentatívna farba sa neurčuje z jediného bodu. Algoritmus zbiera vzorky z vnútorných častí scanline segmentov, obmedzuje ich počet a pre každý RGB kanál vypočíta medián. Tým sa znižuje vplyv okrajov, drobného šumu a časti kompresných artefaktov.
@ -97,7 +100,7 @@ Rozpracovaný stav sa môže ukladať iba do lokálneho IndexedDB úložiska dan
- Tmavá hranica sa rozpoznáva iba podľa nastavenej svetlosti; zložité alebo farebné obrysy môžu vyžadovať inú toleranciu. - Tmavá hranica sa rozpoznáva iba podľa nastavenej svetlosti; zložité alebo farebné obrysy môžu vyžadovať inú toleranciu.
- Analýza prebieha na hlavnom vlákne bez Web Workera. Veľké súvislé plochy preto môžu dočasne znížiť odozvu rozhrania. - Analýza prebieha na hlavnom vlákne bez Web Workera. Veľké súvislé plochy preto môžu dočasne znížiť odozvu rozhrania.
- Pri obrázkoch nad 24 miliónov pixelov aplikácia zobrazí výkonnostné upozornenie. Obrázky nad 80 miliónov pixelov odmietne. - Pri obrázkoch nad 24 miliónov pixelov aplikácia zobrazí výkonnostné upozornenie. Obrázky nad 80 miliónov pixelov odmietne.
- Výber pokrývajúci viac než 92 % obrázka a plochy menšie než 12 pixelov sa považujú za pravdepodobnú chybu a neuložia sa. - Výber pokrývajúci viac než 92 % obrázka sa považuje za pravdepodobnú chybu a neuloží sa. Pri toleranciách `0` a `1` je povolený aj jednopixelový región; od tolerancie `2` sa regióny menšie než 12 pixelov neuložia.
- Výsledná farba závisí od kvality a osvetlenia fotografie; nejde o fyzicky kalibrované meranie farby. - Výsledná farba závisí od kvality a osvetlenia fotografie; nejde o fyzicky kalibrované meranie farby.
- Projektový JSON obsahuje pôvodný obrázok iba vtedy, keď má najviac 5 MiB. Pri väčšom obrázku ho treba pri importe znovu vybrať v rovnakých rozmeroch. - Projektový JSON obsahuje pôvodný obrázok iba vtedy, keď má najviac 5 MiB. Pri väčšom obrázku ho treba pri importe znovu vybrať v rovnakých rozmeroch.
@ -208,12 +211,12 @@ Najdôležitejšie implementačné body:
1. Prehliadač dekóduje súbor pomocou `createImageBitmap` a vykreslí ho do interného canvasu v pôvodnom rozlíšení. 1. Prehliadač dekóduje súbor pomocou `createImageBitmap` a vykreslí ho do interného canvasu v pôvodnom rozlíšení.
2. Store načíta `ImageData` raz a uchová ho mimo hlbokej reaktivity. Zobrazený canvas používa samostatný viewport pre zoom a posun. 2. Store načíta `ImageData` raz a uchová ho mimo hlbokej reaktivity. Zobrazený canvas používa samostatný viewport pre zoom a posun.
3. Kliknutie sa prepočíta zo súradníc obrazovky na súradnice pôvodného obrázka. 3. Kliknutie sa prepočíta zo súradníc obrazovky na súradnice pôvodného obrázka.
4. Iteratívny scanline flood fill vyhľadá súvislú oblasť podľa RGB tolerancie, zvolenej susednosti, prahu tmavého obrysu a masiek už označených plôch. 4. Referenčná RGB farba závisí od tolerancie: presný kliknutý pixel pri `0`, medián 5 × 5 pri `1` a kliknutý pixel pri hodnotách od `2`. Iteratívny scanline flood fill potom vyhľadá súvislú oblasť podľa rovnakej euklidovskej RGB tolerancie, zvolenej susednosti, prahu tmavého obrysu a masiek už označených plôch.
5. Výsledok sa uloží ako RLE riadky v `Uint32Array`. Zároveň sa vypočíta počet pixelov, bounding box a vnútorná pozícia čísla. 5. Výsledok sa uloží ako RLE riadky v `Uint32Array`. Zároveň sa vypočíta počet pixelov, bounding box a automatická vnútorná `labelPosition`; samostatná `userClickPosition` uchová obrazové súradnice kliknutia.
6. Z najviac 8 000 vnútorných vzoriek sa vypočíta medián RGB kanálov reprezentatívnej farby. 6. Z najviac 8 000 vnútorných vzoriek sa vypočíta medián RGB kanálov reprezentatívnej farby.
7. RGB farba sa prevedie do CIE Lab a porovná s existujúcimi skupinami pomocou `ΔE76`. 7. RGB farba sa prevedie do CIE Lab a porovná s existujúcimi skupinami pomocou `ΔE76`.
8. Skupiny dostanú váženú reprezentatívnu farbu, zoradia sa podľa Lab `L` a prečíslujú sa. 8. Skupiny dostanú váženú reprezentatívnu farbu, zoradia sa podľa Lab `L` a prečíslujú sa.
9. Canvas vykreslí pôvodný obrázok, polopriehľadné RLE prekrytia, zvýraznenie výberu a čísla skupín. 9. Canvas aj PNG export vykreslia číslo na `labelPosition` alebo, pri zapnutom nastavení, na `userClickPosition`. Staršie regióny bez kliknutej pozície bezpečne použijú `labelPosition`.
10. Export vytvorí výsledné dáta priamo v prehliadači bez serverovej komunikácie. 10. Export vytvorí výsledné dáta priamo v prehliadači bez serverovej komunikácie.
## Dáta a ukladanie ## Dáta a ukladanie
@ -222,7 +225,7 @@ Aktívny stav spravuje Pinia store. Veľké objekty `HTMLCanvasElement`, `ImageD
Po načítaní obrázka a po zmenách výsledkov sa projekt s krátkym oneskorením uloží do IndexedDB databázy `mozaic-analyzer`, úložiska `projects`, pod kľúčom `last-project`. Automatická záloha obsahuje serializovaný projekt aj pôvodný obrazový Blob. Pri ďalšom otvorení aplikácia ponúkne obnovenie alebo zahodenie poslednej práce. Ak prehliadač IndexedDB nepovolí, chyba automatického uloženia neblokuje editor. Po načítaní obrázka a po zmenách výsledkov sa projekt s krátkym oneskorením uloží do IndexedDB databázy `mozaic-analyzer`, úložiska `projects`, pod kľúčom `last-project`. Automatická záloha obsahuje serializovaný projekt aj pôvodný obrazový Blob. Pri ďalšom otvorení aplikácia ponúkne obnovenie alebo zahodenie poslednej práce. Ak prehliadač IndexedDB nepovolí, chyba automatického uloženia neblokuje editor.
Exportovaný JSON obsahuje verziu formátu, dátum vytvorenia, názov a rozmery obrázka, nastavenia, farebné skupiny a plochy vrátane RLE masiek, farieb, počtu pixelov, bounding boxov a pozícií čísiel. Obrázok do 5 MiB sa vloží ako data URL. Pri väčšom obrázku JSON obsahuje iba projektové dáta a pri importe treba znovu vybrať pôvodný obrázok s rovnakými rozmermi. Exportovaný JSON obsahuje verziu formátu, dátum vytvorenia, názov a rozmery obrázka, nastavenia, farebné skupiny a plochy vrátane RLE masiek, farieb, počtu pixelov, bounding boxov, automatických pozícií čísiel a pozícií kliknutia. Ukladá sa aj nastavenie `useUserClickPositionForLabels`. Starší projekt bez týchto polí použije hodnotu `false` a automatickú `labelPosition`. Obrázok do 5 MiB sa vloží ako data URL. Pri väčšom obrázku JSON obsahuje iba projektové dáta a pri importe treba znovu vybrať pôvodný obrázok s rovnakými rozmermi.
História undo/redo je iba v pamäti aktuálnej relácie a nie je súčasťou exportovaného projektu. História undo/redo je iba v pamäti aktuálnej relácie a nie je súčasťou exportovaného projektu.

View File

@ -10,7 +10,14 @@ const baseName = () => (imageName.value.replace(/\.[^.]+$/, '').replace(/[^\p{L}
async function exportPng(): Promise<void> { async function exportPng(): Promise<void> {
if (!imageCanvas.value) return store.notify('Najprv načítajte obrázok.', 'warning') if (!imageCanvas.value) return store.notify('Najprv načítajte obrázok.', 'warning')
try { 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`) downloadBlob(await canvasToBlob(canvas), `${baseName()}-ocislovane.png`)
} catch (error) { store.notify(error instanceof Error ? error.message : 'Export PNG zlyhal.', 'error') } } 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> { async function exportJson(): Promise<void> {
if (!imageWidth.value) return store.notify('Nie je čo exportovať.', 'warning') if (!imageWidth.value) return store.notify('Nie je čo exportovať.', 'warning')
const project = await store.serializeProject(true) try {
downloadBlob(new Blob([JSON.stringify(project)], { type: 'application/json' }), `${baseName()}-projekt.json`) const project = await store.serializeProject(true)
if (!project.imageDataUrl) store.notify('Obrázok je väčší než 5 MB. Pri importe JSON ho bude potrebné znova vybrať.', 'info') 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> </script>

View File

@ -3,7 +3,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useMosaicStore } from '@/stores/mosaic' import { useMosaicStore } from '@/stores/mosaic'
import { rgbToHex, relativeLuminance } from '@/utils/colors' import { rgbToHex, relativeLuminance } from '@/utils/colors'
import { forEachMaskRun } from '@/utils/regionMask' import { forEachMaskRun, resolveRegionLabelPosition } from '@/utils/regionMask'
import { useCanvasViewport } from '@/composables/useCanvasViewport' import { useCanvasViewport } from '@/composables/useCanvasViewport'
import ImageDropzone from './ImageDropzone.vue' import ImageDropzone from './ImageDropzone.vue'
@ -76,8 +76,9 @@ function draw(): void {
const group = groupMap.get(region.groupId) const group = groupMap.get(region.groupId)
if (!group) continue if (!group) continue
if (selectedGroupId.value && settings.value.otherGroupsMode === 'hidden' && group.id !== selectedGroupId.value) continue if (selectedGroupId.value && settings.value.otherGroupsMode === 'hidden' && group.id !== selectedGroupId.value) continue
const x = viewport.x + region.labelPosition.x * viewport.scale const labelPosition = resolveRegionLabelPosition(region, settings.value.useUserClickPositionForLabels)
const y = viewport.y + region.labelPosition.y * viewport.scale const x = viewport.x + labelPosition.x * viewport.scale
const y = viewport.y + labelPosition.y * viewport.scale
const active = region.id === selectedRegionId.value const active = region.id === selectedRegionId.value
context.beginPath() context.beginPath()
context.arc(x, y, active ? 13 : 11, 0, Math.PI * 2) 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([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(() => { onMounted(() => {
observer = new ResizeObserver(resize) observer = new ResizeObserver(resize)

View File

@ -15,7 +15,7 @@ function updateColorTolerance(event: Event): void {
<h2>Nastavenia analýzy</h2> <h2>Nastavenia analýzy</h2>
<label class="range-field"> <label class="range-field">
<span><b>Tolerancia výberu</b><output>{{ settings.floodTolerance }}</output></span> <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> <small>Vyššia hodnota spojí väčšie farebné odchýlky v jednej ploche.</small>
</label> </label>
<label class="range-field"> <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> <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" /> <input v-model.number="settings.darkBoundaryThreshold" type="range" min="0" max="0.2" step="0.005" />
</label> </label>
<label class="check-field">
<input v-model="settings.useUserClickPositionForLabels" type="checkbox" />
<span>Zobrazovať čísla na pozícii kliknutia</span>
</label>
</details> </details>
</section> </section>
</template> </template>
@ -62,4 +66,5 @@ select { width: 100%; }
details { margin-top: 13px; color: #475569; font-size: .76rem; } details { margin-top: 13px; color: #475569; font-size: .76rem; }
summary { cursor: pointer; } summary { cursor: pointer; }
.compact { margin-bottom: 4px; } .compact { margin-bottom: 4px; }
.check-field { display: flex; align-items: center; gap: 7px; margin-top: 12px; color: #475569; cursor: pointer; }
</style> </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 { defineStore } from 'pinia'
import type { import type {
ColorGroup, ColorGroup,
@ -7,10 +7,16 @@ import type {
ProjectFile, ProjectFile,
ToastMessage, ToastMessage,
} from '@/types/mosaic' } from '@/types/mosaic'
import { floodFillRegion } from '@/utils/floodFill' import { floodFillRegion, getFloodFillReferenceColor } from '@/utils/floodFill'
import { groupRegionsByColor, refreshGroupColors, sortGroupsByLightness } from '@/utils/colorGroups' import { groupRegionsByColor, refreshGroupColors, sortGroupsByLightness } from '@/utils/colorGroups'
import { rgbToLab } from '@/utils/colors' import { colorDistance, rgbToLab } from '@/utils/colors'
import { buildOccupancy, createRegionMask } from '@/utils/regionMask' import {
buildOccupancy,
createRegionMask,
maskContains,
subtractRegionMask,
summarizeRegionMask,
} from '@/utils/regionMask'
import { createProjectFile, isProjectFile } from '@/utils/projectExport' import { createProjectFile, isProjectFile } from '@/utils/projectExport'
import { clearLastProject, loadLastProject, saveLastProject } from '@/utils/projectStorage' import { clearLastProject, loadLastProject, saveLastProject } from '@/utils/projectStorage'
@ -26,6 +32,7 @@ const DEFAULT_SETTINGS: MosaicSettings = {
connectivity: 4, connectivity: 4,
exportOverlay: false, exportOverlay: false,
otherGroupsMode: 'dim', otherGroupsMode: 'dim',
useUserClickPositionForLabels: false,
} }
const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((region) => markRaw({ const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((region) => markRaw({
@ -34,6 +41,7 @@ const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((r
labColor: { ...region.labColor }, labColor: { ...region.labColor },
boundingBox: { ...region.boundingBox }, boundingBox: { ...region.boundingBox },
labelPosition: { ...region.labelPosition }, labelPosition: { ...region.labelPosition },
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
// Masks are immutable; sharing their typed arrays keeps the 30-step history compact. // Masks are immutable; sharing their typed arrays keeps the 30-step history compact.
mask: region.mask, mask: region.mask,
})) }))
@ -170,21 +178,29 @@ export const useMosaicStore = defineStore('mosaic', () => {
function addRegionAt(x: number, y: number): void { function addRegionAt(x: number, y: number): void {
if (!imageData.value || isAnalyzing.value) return if (!imageData.value || isAnalyzing.value) return
const occupancy = buildOccupancy(imageWidth.value, imageHeight.value, regions.value.map((region) => region.mask)) const floodTolerance = settings.floodTolerance
if (occupancy[y * imageWidth.value + x]) { 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') notify('Táto plocha už bola označená.', 'warning')
return return
} }
const occupancy = buildOccupancy(
imageWidth.value,
imageHeight.value,
regions.value.filter((region) => region.id !== existingRegion?.id).map((region) => region.mask),
)
isAnalyzing.value = true isAnalyzing.value = true
window.setTimeout(() => { window.setTimeout(() => {
try { try {
const result = floodFillRegion(imageData.value as ImageData, x, y, { const result = floodFillRegion(imageData.value as ImageData, x, y, {
tolerance: settings.floodTolerance, tolerance: floodTolerance,
darkBoundaryThreshold: settings.darkBoundaryThreshold, darkBoundaryThreshold: settings.darkBoundaryThreshold,
connectivity: settings.connectivity, connectivity: settings.connectivity,
occupancy, 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') notify('Nájdená plocha je príliš malá. Skúste iný bod alebo vyššiu toleranciu.', 'warning')
return return
} }
@ -200,10 +216,27 @@ export const useMosaicStore = defineStore('mosaic', () => {
pixelCount: result.pixelCount, pixelCount: result.pixelCount,
boundingBox: result.boundingBox, boundingBox: result.boundingBox,
labelPosition: result.labelPosition, labelPosition: result.labelPosition,
userClickPosition: { x, y },
groupId: '', groupId: '',
mask: result.mask, 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) regroup(false)
selectedRegionId.value = region.id selectedRegionId.value = region.id
selectedGroupId.value = regions.value.find((item) => item.id === region.id)?.groupId ?? null 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> { 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 imageName.value = project.imageName
imageWidth.value = project.dimensions.width imageWidth.value = project.dimensions.width
imageHeight.value = project.dimensions.height imageHeight.value = project.dimensions.height
regions.value = project.regions.map((region) => markRaw({ regions.value = project.regions.map((region) => markRaw({
...region, ...region,
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
mask: createRegionMask(region.mask.width, region.mask.height, region.mask.runs), mask: createRegionMask(region.mask.width, region.mask.height, region.mask.runs),
})) }))
groups.value = cloneGroups(project.groups) groups.value = cloneGroups(project.groups)
@ -373,6 +409,8 @@ export const useMosaicStore = defineStore('mosaic', () => {
await clearLastProject().catch(() => undefined) await clearLastProject().catch(() => undefined)
} }
watch(settings, scheduleAutosave)
return { return {
imageCanvas, imageData, imageBlob, imageName, imageWidth, imageHeight, regions, groups, imageCanvas, imageData, imageBlob, imageName, imageWidth, imageHeight, regions, groups,
selectedGroupId, selectedRegionId, settings, toasts, isAnalyzing, hasRecovery, recoveryTimestamp, selectedGroupId, selectedRegionId, settings, toasts, isAnalyzing, hasRecovery, recoveryTimestamp,

View File

@ -36,6 +36,7 @@ export interface MosaicRegion {
pixelCount: number pixelCount: number
boundingBox: BoundingBox boundingBox: BoundingBox
labelPosition: Point labelPosition: Point
userClickPosition?: Point
groupId: string groupId: string
mask: RegionMask mask: RegionMask
} }
@ -56,6 +57,11 @@ export interface MosaicSettings {
connectivity: 4 | 8 connectivity: 4 | 8
exportOverlay: boolean exportOverlay: boolean
otherGroupsMode: 'normal' | 'dim' | 'hidden' otherGroupsMode: 'normal' | 'dim' | 'hidden'
useUserClickPositionForLabels: boolean
}
export type SerializedMosaicSettings = Omit<MosaicSettings, 'useUserClickPositionForLabels'> & {
useUserClickPositionForLabels?: boolean
} }
export interface ProjectDimensions { export interface ProjectDimensions {
@ -73,7 +79,7 @@ export interface ProjectFile {
imageName: string imageName: string
imageDataUrl?: string imageDataUrl?: string
dimensions: ProjectDimensions dimensions: ProjectDimensions
settings: MosaicSettings settings: SerializedMosaicSettings
regions: SerializedRegion[] regions: SerializedRegion[]
groups: ColorGroup[] groups: ColorGroup[]
} }

View File

@ -1,6 +1,6 @@
import type { BoundingBox, Point, RegionMask, RGBColor } from '@/types/mosaic' import type { BoundingBox, Point, RegionMask, RGBColor } from '@/types/mosaic'
import { colorDistance, relativeLuminance } from './colors' import { colorDistance, relativeLuminance } from './colors'
import { createRegionMask } from './regionMask' import { createRegionMask, summarizeRegionMask } from './regionMask'
export interface FloodFillOptions { export interface FloodFillOptions {
tolerance: number tolerance: number
@ -28,6 +28,30 @@ const median = (values: number[]): number => {
return values[Math.floor(values.length / 2)] ?? 0 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( export function floodFillRegion(
image: ImageData, image: ImageData,
startX: number, startX: number,
@ -39,8 +63,8 @@ export function floodFillRegion(
const startIndex = startY * width + startX const startIndex = startY * width + startX
if (options.occupancy?.[startIndex]) return null if (options.occupancy?.[startIndex]) return null
const seed = pixelColor(data, startIndex) const referenceColor = getFloodFillReferenceColor(image, startX, startY, options.tolerance)
const seedIsDark = relativeLuminance(seed) <= options.darkBoundaryThreshold const referenceIsDark = relativeLuminance(referenceColor) <= options.darkBoundaryThreshold
const visited = new Uint8Array(width * height) const visited = new Uint8Array(width * height)
const stack: number[] = [startIndex] const stack: number[] = [startIndex]
const runs: number[] = [] const runs: number[] = []
@ -48,16 +72,13 @@ export function floodFillRegion(
const greens: number[] = [] const greens: number[] = []
const blues: number[] = [] const blues: number[] = []
let pixelCount = 0 let pixelCount = 0
let minX = width
let minY = height
let maxX = 0
let maxY = 0
const isAccepted = (index: number) => { const isAccepted = (index: number) => {
if (visited[index] || options.occupancy?.[index]) return false if (visited[index] || options.occupancy?.[index]) return false
if (index === startIndex) return true
const color = pixelColor(data, index) const color = pixelColor(data, index)
if (!seedIsDark && relativeLuminance(color) <= options.darkBoundaryThreshold) return false if (!referenceIsDark && relativeLuminance(color) <= options.darkBoundaryThreshold) return false
return colorDistance(color, seed) <= options.tolerance return colorDistance(color, referenceColor) <= options.tolerance
} }
const sample = (index: number) => { const sample = (index: number) => {
@ -93,10 +114,6 @@ export function floodFillRegion(
if (x >= left + 2 && x <= right - 2) sample(pixelIndex) if (x >= left + 2 && x <= right - 2) sample(pixelIndex)
} }
runs.push(y, left, right) 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]) { for (const neighborY of [y - 1, y + 1]) {
if (neighborY < 0 || neighborY >= height) continue if (neighborY < 0 || neighborY >= height) continue
@ -114,9 +131,9 @@ export function floodFillRegion(
} }
if (!pixelCount) return null if (!pixelCount) return null
if (!reds.length) { if (!reds.length) {
reds.push(seed.r) reds.push(referenceColor.r)
greens.push(seed.g) greens.push(referenceColor.g)
blues.push(seed.b) blues.push(referenceColor.b)
} }
const orderedRuns: Array<[number, number, number]> = [] 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.push([runs[index] ?? 0, runs[index + 1] ?? 0, runs[index + 2] ?? 0])
} }
orderedRuns.sort((first, second) => first[0] - second[0] || first[1] - second[1]) orderedRuns.sort((first, second) => first[0] - second[0] || first[1] - second[1])
const centerY = (minY + maxY) / 2 const mask = createRegionMask(width, height, orderedRuns.flatMap((run) => run))
const labelRun = orderedRuns.reduce((best, run) => { const summary = summarizeRegionMask(mask)
const score = run[2] - run[1] - Math.abs(run[0] - centerY) * 0.2 if (!summary) return null
const bestScore = best[2] - best[1] - Math.abs(best[0] - centerY) * 0.2
return score > bestScore ? run : best
}, orderedRuns[0] ?? [startY, startX, startX])
return { return {
mask: createRegionMask(width, height, orderedRuns.flatMap((run) => run)), mask,
pixelCount, pixelCount,
boundingBox: { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 }, boundingBox: summary.boundingBox,
labelPosition: { x: Math.round((labelRun[1] + labelRun[2]) / 2), y: labelRun[0] }, labelPosition: summary.labelPosition,
color: { r: median(reds), g: median(greens), b: median(blues) }, color: { r: median(reds), g: median(greens), b: median(blues) },
} }
} }

View File

@ -6,15 +6,23 @@ import type {
ProjectFile, ProjectFile,
} from '@/types/mosaic' } from '@/types/mosaic'
import { relativeLuminance, rgbToHex } from './colors' import { relativeLuminance, rgbToHex } from './colors'
import { forEachMaskRun } from './regionMask' import { forEachMaskRun, resolveRegionLabelPosition } from './regionMask'
export function downloadBlob(blob: Blob, filename: string): void { export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = filename link.download = filename
link.click() link.hidden = true
window.setTimeout(() => URL.revokeObjectURL(url), 1000) document.body.append(link)
try {
link.click()
} finally {
window.setTimeout(() => {
link.remove()
URL.revokeObjectURL(url)
}, 1000)
}
} }
export async function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> { export async function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
@ -29,6 +37,7 @@ export function renderExportCanvas(
regions: MosaicRegion[], regions: MosaicRegion[],
groups: ColorGroup[], groups: ColorGroup[],
includeOverlay: boolean, includeOverlay: boolean,
useUserClickPositionForLabels = false,
): HTMLCanvasElement { ): HTMLCanvasElement {
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = dimensions.width canvas.width = dimensions.width
@ -57,12 +66,13 @@ export function renderExportCanvas(
for (const region of regions) { for (const region of regions) {
const group = groupMap.get(region.groupId) const group = groupMap.get(region.groupId)
if (!group) continue if (!group) continue
const labelPosition = resolveRegionLabelPosition(region, useUserClickPositionForLabels)
const label = String(group.number) const label = String(group.number)
context.lineWidth = Math.max(2, fontSize / 7) 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.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.fillStyle = relativeLuminance(group.color) > 0.45 ? '#111827' : '#FFFFFF'
context.strokeText(label, region.labelPosition.x, region.labelPosition.y) context.strokeText(label, labelPosition.x, labelPosition.y)
context.fillText(label, region.labelPosition.x, region.labelPosition.y) context.fillText(label, labelPosition.x, labelPosition.y)
} }
return canvas return canvas
} }
@ -121,6 +131,7 @@ export async function createProjectFile(input: {
labColor: { ...region.labColor }, labColor: { ...region.labColor },
boundingBox: { ...region.boundingBox }, boundingBox: { ...region.boundingBox },
labelPosition: { ...region.labelPosition }, labelPosition: { ...region.labelPosition },
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
mask: { width: region.mask.width, height: region.mask.height, runs: Array.from(region.mask.runs) }, mask: { width: region.mask.width, height: region.mask.height, runs: Array.from(region.mask.runs) },
})), })),
groups: input.groups.map((group) => ({ groups: input.groups.map((group) => ({

View File

@ -1,10 +1,85 @@
import { markRaw } from 'vue' 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 { export function createRegionMask(width: number, height: number, runs: number[]): RegionMask {
return markRaw({ width, height, runs: markRaw(Uint32Array.from(runs)) }) 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( export function forEachMaskRun(
mask: RegionMask, mask: RegionMask,
callback: (y: number, xStart: number, xEnd: number) => void, callback: (y: number, xStart: number, xEnd: number) => void,