zakladna implementacia by Codex
This commit is contained in:
@@ -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] }
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user