zakladna implementacia by Codex

This commit is contained in:
2026-08-01 07:28:21 +02:00
parent 1b71803407
commit a5973a5168
21 changed files with 1629 additions and 46 deletions
+50
View File
@@ -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] }
}),
)
}
+63
View File
@@ -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),
}
}
+141
View File
@@ -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) },
}
}
+148
View File
@@ -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<Blob> {
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<string> {
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<ProjectFile> {
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<ProjectFile>
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'
}
+52
View File
@@ -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<IDBDatabase> {
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<void> {
const database = await openDatabase()
await new Promise<void>((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<StoredProject | null> {
const database = await openDatabase()
const value = await new Promise<StoredProject | undefined>((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<void> {
const database = await openDatabase()
await new Promise<void>((resolve, reject) => {
const request = database.transaction(STORE, 'readwrite').objectStore(STORE).delete(LAST_PROJECT)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
database.close()
}
+36
View File
@@ -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
}