From f8c377ec2caab0fddf215cf0ea2e50fa377abb8a Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Fri, 24 Apr 2026 10:01:31 +0100 Subject: [PATCH 1/2] chore: unify epdoptimize library across frontend and converter --- eink_layout_manager/converter/package.json | 5 - .../frontend/package-lock.json | 6 + eink_layout_manager/frontend/package.json | 1 + .../src/components/dialogs/image-dialog.ts | 2 +- .../scene-item-settings-dialog.test.ts | 2 +- .../dialogs/scene-item-settings-dialog.ts | 2 +- .../src/lib/epdoptimize/auto-processing.ts | 434 ---------- .../dither/data/default-options.json | 0 .../dither/data/default-palettes.json | 45 -- .../epdoptimize/dither/data/diffusion-maps.ts | 96 --- .../dither/data/threshold-maps.json | 67 -- .../src/lib/epdoptimize/dither/dither.ts | 604 -------------- .../dither/functions/bayer-matrix.ts | 92 --- .../dither/functions/color-helpers.ts | 20 - .../dither/functions/color-histogram.ts | 1 - .../functions/color-palette-from-image.ts | 163 ---- .../functions/find-closest-palette-color.ts | 70 -- .../dither/functions/palette-order.ts | 70 -- .../epdoptimize/dither/functions/utilities.ts | 7 - .../src/lib/epdoptimize/dither/processing.ts | 546 ------------- .../src/lib/epdoptimize/image-style.ts | 755 ------------------ .../frontend/src/lib/epdoptimize/index.ts | 122 --- .../replaceColors/replaceColors.ts | 109 --- eink_layout_manager/frontend/tsconfig.json | 2 +- 24 files changed, 11 insertions(+), 3210 deletions(-) delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/auto-processing.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/default-options.json delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/default-palettes.json delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/diffusion-maps.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/threshold-maps.json delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/dither.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/bayer-matrix.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-helpers.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-histogram.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-palette-from-image.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/find-closest-palette-color.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/palette-order.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/utilities.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/dither/processing.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/image-style.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/index.ts delete mode 100644 eink_layout_manager/frontend/src/lib/epdoptimize/replaceColors/replaceColors.ts diff --git a/eink_layout_manager/converter/package.json b/eink_layout_manager/converter/package.json index dbfc8be2..4d7cec8b 100644 --- a/eink_layout_manager/converter/package.json +++ b/eink_layout_manager/converter/package.json @@ -16,10 +16,5 @@ }, "engines": { "node": ">=24.15.0" - }, - "dependencies": { - "canvas": "^3.2.3", - "epdoptimize": "^1.0.2", - "yargs": "^18.0.0" } } diff --git a/eink_layout_manager/frontend/package-lock.json b/eink_layout_manager/frontend/package-lock.json index 17977059..5143c15e 100644 --- a/eink_layout_manager/frontend/package-lock.json +++ b/eink_layout_manager/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "eink-layout-manager-ui", "version": "0.1.0", "dependencies": { + "epdoptimize": "file:../epdoptimize", "interactjs": "^1.10.27", "js-yaml": "^4.1.0", "lit": "^3.3.1", @@ -978,6 +979,11 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/epdoptimize": { + "version": "1.0.1", + "resolved": "file:../epdoptimize", + "license": "Apache-2.0" + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", diff --git a/eink_layout_manager/frontend/package.json b/eink_layout_manager/frontend/package.json index 911e63cb..74abedfa 100644 --- a/eink_layout_manager/frontend/package.json +++ b/eink_layout_manager/frontend/package.json @@ -10,6 +10,7 @@ "test": "vitest" }, "dependencies": { + "epdoptimize": "file:../epdoptimize", "interactjs": "^1.10.27", "js-yaml": "^4.1.0", "lit": "^3.3.1", diff --git a/eink_layout_manager/frontend/src/components/dialogs/image-dialog.ts b/eink_layout_manager/frontend/src/components/dialogs/image-dialog.ts index 71bd0e0a..380df94a 100644 --- a/eink_layout_manager/frontend/src/components/dialogs/image-dialog.ts +++ b/eink_layout_manager/frontend/src/components/dialogs/image-dialog.ts @@ -9,7 +9,7 @@ import { ditherImage, suggestCanvasProcessingOptions, getDefaultPalettes -} from '../../lib/epdoptimize/index'; +} from 'epdoptimize'; /** * A dialog component for adding and processing new images. diff --git a/eink_layout_manager/frontend/src/components/dialogs/scene-item-settings-dialog.test.ts b/eink_layout_manager/frontend/src/components/dialogs/scene-item-settings-dialog.test.ts index 44276b7c..7050e828 100644 --- a/eink_layout_manager/frontend/src/components/dialogs/scene-item-settings-dialog.test.ts +++ b/eink_layout_manager/frontend/src/components/dialogs/scene-item-settings-dialog.test.ts @@ -20,7 +20,7 @@ vi.mock('../../services/HaApiClient', async () => { }; }); -vi.mock('../../lib/epdoptimize/index', () => ({ +vi.mock('epdoptimize', () => ({ ditherImage: vi.fn().mockResolvedValue(undefined), getDefaultPalettes: vi.fn().mockReturnValue(['#000', '#fff']) })); diff --git a/eink_layout_manager/frontend/src/components/dialogs/scene-item-settings-dialog.ts b/eink_layout_manager/frontend/src/components/dialogs/scene-item-settings-dialog.ts index 5195c135..1c47690b 100644 --- a/eink_layout_manager/frontend/src/components/dialogs/scene-item-settings-dialog.ts +++ b/eink_layout_manager/frontend/src/components/dialogs/scene-item-settings-dialog.ts @@ -8,7 +8,7 @@ import '../layout/layout-editor'; import { ditherImage, getDefaultPalettes -} from '../../lib/epdoptimize/index'; +} from 'epdoptimize'; /** * A dialog component for editing the settings of an item in a scene. diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/auto-processing.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/auto-processing.ts deleted file mode 100644 index 192d746a..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/auto-processing.ts +++ /dev/null @@ -1,434 +0,0 @@ -// @ts-nocheck -import type { - CanvasLike, - DitherImageOptions, - ImageDataLike, -} from "./dither/dither"; -import type { - ColorMatchingMode, - DynamicRangeCompressionOptions, - ProcessingPresetName, - ToneMappingOptions, -} from "./dither/processing"; -import type { PaletteColorEntry } from "./dither/functions/palette-order"; -import { - classifyCanvasImageStyle, - classifyImageStyle, - type ClassifyImageStyleOptions, - type ImageKind, - type ImageStyleClassification, -} from "./image-style"; - -export type AutoProcessingIntent = - | "natural" - | "vivid" - | "readable" - | "faithful" - | "lowNoise"; - -export interface SuggestProcessingOptionsInput - extends ClassifyImageStyleOptions { - intent?: AutoProcessingIntent; -} - -export interface ProcessingSuggestion { - classification: ImageStyleClassification; - imageKind: ImageKind; - intent: AutoProcessingIntent; - ditherOptions: Partial; - reasons: string[]; - scores: Record; -} - -interface PaletteProfile { - colorCount: number; - lumaRange: number; - saturationRange: number; - averageSaturation: number; -} - -interface RecommendationBase { - processingPreset: ProcessingPresetName; - colorMatching: ColorMatchingMode; - errorDiffusionMatrix: string; - ditheringType?: DitherImageOptions["ditheringType"]; - toneMapping?: ToneMappingOptions; - dynamicRangeCompression?: DynamicRangeCompressionOptions; -} - -export function suggestProcessingOptions( - image: ImageDataLike, - palette?: PaletteColorEntry[] | string[], - options: SuggestProcessingOptionsInput = {} -): ProcessingSuggestion { - const classification = classifyImageStyle(image, options); - return buildSuggestion(classification, getPaletteProfile(palette), options); -} - -export function suggestCanvasProcessingOptions( - canvas: CanvasLike, - palette?: PaletteColorEntry[] | string[], - options: SuggestProcessingOptionsInput = {} -): ProcessingSuggestion { - const classification = classifyCanvasImageStyle(canvas, options); - return buildSuggestion(classification, getPaletteProfile(palette), options); -} - -function buildSuggestion( - classification: ImageStyleClassification, - paletteProfile: PaletteProfile | null, - options: SuggestProcessingOptionsInput -): ProcessingSuggestion { - const intent = options.intent ?? "natural"; - const reasons: string[] = []; - const scores = getPresetScores(classification, paletteProfile, intent); - const recommendedPreset = getBestScore(scores); - const base = getBaseRecommendation(classification.kind, recommendedPreset); - - addClassificationReasons(classification, reasons); - addPaletteReasons(paletteProfile, reasons); - applyIntent(base, intent, reasons); - applyPaletteTuning(base, paletteProfile, reasons); - - return { - classification, - imageKind: classification.kind, - intent, - ditherOptions: { - processingPreset: base.processingPreset, - colorMatching: base.colorMatching, - errorDiffusionMatrix: base.errorDiffusionMatrix, - ditheringType: base.ditheringType ?? "errorDiffusion", - ...(base.toneMapping ? { toneMapping: base.toneMapping } : {}), - ...(base.dynamicRangeCompression - ? { dynamicRangeCompression: base.dynamicRangeCompression } - : {}), - }, - reasons, - scores, - }; -} - -function getBaseRecommendation( - kind: ImageKind, - fallbackPreset: ProcessingPresetName -): RecommendationBase { - switch (kind) { - case "textOrUi": - return { - processingPreset: "balanced", - colorMatching: "lab", - errorDiffusionMatrix: "floydSteinberg", - ditheringType: "quantizationOnly", - toneMapping: { - mode: "contrast", - exposure: 1.05, - saturation: 1, - contrast: 1.18, - }, - dynamicRangeCompression: { mode: "display", strength: 0.75 }, - }; - case "lineArt": - return { - processingPreset: "balanced", - colorMatching: "lab", - errorDiffusionMatrix: "floydSteinberg", - ditheringType: "quantizationOnly", - toneMapping: { - mode: "contrast", - exposure: 1, - saturation: 0.8, - contrast: 1.25, - }, - dynamicRangeCompression: { mode: "display", strength: 0.65 }, - }; - case "pixelArt": - return { - processingPreset: "vivid", - colorMatching: "rgb", - errorDiffusionMatrix: "floydSteinberg", - ditheringType: "quantizationOnly", - toneMapping: { mode: "off", exposure: 1, saturation: 1 }, - dynamicRangeCompression: { mode: "off" }, - }; - case "flatIllustration": - return { - processingPreset: "vivid", - colorMatching: "rgb", - errorDiffusionMatrix: "floydSteinberg", - ditheringType: "errorDiffusion", - }; - case "lowContrastPhoto": - return { - processingPreset: "dynamic", - colorMatching: "rgb", - errorDiffusionMatrix: "stucki", - ditheringType: "errorDiffusion", - toneMapping: { - mode: "scurve", - exposure: 1.08, - saturation: 1.25, - strength: 0.82, - shadowBoost: 0.06, - highlightCompress: 1.35, - midpoint: 0.48, - }, - dynamicRangeCompression: { mode: "display", strength: 0.85 }, - }; - case "highContrastPhoto": - return { - processingPreset: "soft", - colorMatching: "rgb", - errorDiffusionMatrix: "stucki", - ditheringType: "errorDiffusion", - dynamicRangeCompression: { mode: "display", strength: 0.9 }, - }; - case "photo": - return { - processingPreset: fallbackPreset, - colorMatching: "rgb", - errorDiffusionMatrix: - fallbackPreset === "soft" ? "stucki" : "floydSteinberg", - ditheringType: "errorDiffusion", - }; - case "unknown": - default: - return { - processingPreset: "balanced", - colorMatching: "rgb", - errorDiffusionMatrix: "floydSteinberg", - ditheringType: "errorDiffusion", - }; - } -} - -function getPresetScores( - classification: ImageStyleClassification, - paletteProfile: PaletteProfile | null, - intent: AutoProcessingIntent -): Record { - const { metrics } = classification; - const { kindScores } = classification; - const scores: Record = { - balanced: 0.52, - dynamic: 0.48, - vivid: 0.45, - soft: 0.44, - grayscale: 0.28, - }; - - if (classification.style === "photo") { - scores.dynamic += 0.18; - scores.balanced += 0.12; - scores.soft += metrics.lumaStdDev >= 68 ? 0.2 : 0.06; - } else if (classification.style === "illustration") { - scores.vivid += 0.28; - scores.balanced += 0.08; - } - - scores.dynamic += kindScores.lowContrastPhoto * 0.24; - scores.soft += kindScores.highContrastPhoto * 0.26; - scores.vivid += kindScores.flatIllustration * 0.24; - scores.vivid += kindScores.pixelArt * 0.18; - scores.balanced += (kindScores.textOrUi + kindScores.lineArt) * 0.18; - scores.grayscale += - (kindScores.textOrUi + kindScores.lineArt) * - (metrics.grayRatio >= 0.7 ? 0.24 : 0.08); - - if (metrics.saturationMean <= 0.1 && metrics.grayRatio >= 0.82) { - scores.grayscale += 0.22; - } - - if (paletteProfile && paletteProfile.colorCount <= 2) { - scores.grayscale += 0.3; - scores.vivid -= 0.1; - } - - if (intent === "vivid") scores.vivid += 0.18; - if (intent === "faithful") scores.balanced += 0.16; - if (intent === "lowNoise") scores.soft += 0.16; - if (intent === "readable") { - scores.balanced += 0.14; - scores.grayscale += 0.1; - } - - return scores; -} - -function addClassificationReasons( - classification: ImageStyleClassification, - reasons: string[] -) { - const { metrics } = classification; - reasons.push(`Detected ${classification.kind}.`); - - if (metrics.flatRatio >= 0.65) { - reasons.push("Large flat regions suggest graphic-style preservation."); - } - if (metrics.softChangeRatio >= 0.38) { - reasons.push("Soft tonal transitions suggest photo-oriented processing."); - } - if (metrics.lumaStdDev <= 28) { - reasons.push("Low luminance spread benefits from stronger tone shaping."); - } - if (metrics.lumaStdDev >= 72) { - reasons.push("High luminance spread benefits from softer compression."); - } - if (metrics.strongEdgeRatio >= 0.22) { - reasons.push("Strong edges favor edge-preserving quantization."); - } - if (metrics.topColorCoverage >= 0.55) { - reasons.push("Dominant repeated colors suggest palette-preserving settings."); - } - if (metrics.textTileRatio >= 0.12) { - reasons.push("Text-like tiles favor readable edge handling."); - } - if (metrics.photoTileRatio >= 0.4) { - reasons.push("Photo-like tiles favor smoother tonal processing."); - } - if (metrics.edgeDensity >= 0.14) { - reasons.push("High edge density affects dithering and matching choice."); - } -} - -function addPaletteReasons( - paletteProfile: PaletteProfile | null, - reasons: string[] -) { - if (!paletteProfile) return; - - if (paletteProfile.colorCount <= 2) { - reasons.push("Two-color palette favors LAB matching and grayscale-safe output."); - } else if (paletteProfile.averageSaturation >= 0.55) { - reasons.push("Colorful target palette can support vivid color mapping."); - } - - if (paletteProfile.lumaRange <= 150) { - reasons.push("Limited palette luminance range benefits from range compression."); - } -} - -function applyIntent( - recommendation: RecommendationBase, - intent: AutoProcessingIntent, - reasons: string[] -) { - if (intent === "vivid") { - recommendation.processingPreset = "vivid"; - recommendation.colorMatching = "rgb"; - recommendation.toneMapping = { - ...recommendation.toneMapping, - mode: "scurve", - saturation: Math.max(recommendation.toneMapping?.saturation ?? 1, 1.45), - strength: recommendation.toneMapping?.strength ?? 0.72, - shadowBoost: recommendation.toneMapping?.shadowBoost ?? 0.08, - highlightCompress: recommendation.toneMapping?.highlightCompress ?? 1.3, - midpoint: recommendation.toneMapping?.midpoint ?? 0.5, - }; - reasons.push("Vivid intent boosts saturation and color-priority matching."); - } else if (intent === "readable") { - recommendation.colorMatching = "lab"; - recommendation.ditheringType = "quantizationOnly"; - reasons.push("Readable intent favors clear edges over dithering texture."); - } else if (intent === "lowNoise") { - recommendation.errorDiffusionMatrix = "stucki"; - recommendation.processingPreset = "soft"; - reasons.push("Low-noise intent chooses smoother tone handling."); - } else if (intent === "faithful") { - recommendation.processingPreset = "balanced"; - reasons.push("Faithful intent keeps transformations restrained."); - } -} - -function applyPaletteTuning( - recommendation: RecommendationBase, - paletteProfile: PaletteProfile | null, - reasons: string[] -) { - if (!paletteProfile) return; - - if (paletteProfile.colorCount <= 2) { - recommendation.colorMatching = "lab"; - recommendation.processingPreset = "grayscale"; - recommendation.toneMapping = { - mode: "scurve", - exposure: 1, - saturation: 0, - strength: 0.8, - shadowBoost: 0.1, - highlightCompress: 1.4, - midpoint: 0.5, - }; - reasons.push("Monochrome palette switches to grayscale-oriented settings."); - } else if (paletteProfile.lumaRange <= 150) { - recommendation.dynamicRangeCompression = { - mode: "display", - strength: Math.max( - recommendation.dynamicRangeCompression?.strength ?? 0, - 0.8 - ), - }; - } -} - -function getBestScore(scores: Record): ProcessingPresetName { - return Object.entries(scores).reduce( - (best, current) => (current[1] > best[1] ? current : best), - ["balanced", -Infinity] - )[0] as ProcessingPresetName; -} - -function getPaletteProfile( - palette: PaletteColorEntry[] | string[] | undefined -): PaletteProfile | null { - if (!palette?.length) return null; - - const colors = palette - .map((entry) => (typeof entry === "string" ? entry : entry.color)) - .map(hexToRgb) - .filter((color): color is [number, number, number] => color !== null); - - if (!colors.length) return null; - - const lumas = colors.map(([r, g, b]) => getLuma(r, g, b)); - const saturations = colors.map(([r, g, b]) => getSaturation(r, g, b)); - - return { - colorCount: colors.length, - lumaRange: Math.max(...lumas) - Math.min(...lumas), - saturationRange: Math.max(...saturations) - Math.min(...saturations), - averageSaturation: - saturations.reduce((sum, saturation) => sum + saturation, 0) / - saturations.length, - }; -} - -function hexToRgb(hex: string): [number, number, number] | null { - const normalized = hex.replace(/^#/, ""); - const expanded = - normalized.length === 3 - ? normalized - .split("") - .map((char) => char + char) - .join("") - : normalized; - - if (!/^[0-9a-f]{6}$/i.test(expanded)) return null; - - return [ - parseInt(expanded.slice(0, 2), 16), - parseInt(expanded.slice(2, 4), 16), - parseInt(expanded.slice(4, 6), 16), - ]; -} - -function getLuma(red: number, green: number, blue: number) { - return red * 0.2126 + green * 0.7152 + blue * 0.0722; -} - -function getSaturation(red: number, green: number, blue: number): number { - const max = Math.max(red, green, blue) / 255; - const min = Math.min(red, green, blue) / 255; - - return max === 0 ? 0 : (max - min) / max; -} diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/default-options.json b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/default-options.json deleted file mode 100644 index e69de29b..00000000 diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/default-palettes.json b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/default-palettes.json deleted file mode 100644 index 8eb31d71..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/default-palettes.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "default": [ - { "name": "black", "color": "#000", "deviceColor": "#212121" }, - { "name": "white", "color": "#fff", "deviceColor": "#e6e6e6" } - ], - "aitjcize-spectra6": [ - { "name": "black", "color": "#020202", "deviceColor": "#000000" }, - { "name": "white", "color": "#BEC8C8", "deviceColor": "#FFFFFF" }, - { "name": "blue", "color": "#05409E", "deviceColor": "#0000FF" }, - { "name": "green", "color": "#27663C", "deviceColor": "#00FF00" }, - { "name": "red", "color": "#871300", "deviceColor": "#FF0000" }, - { "name": "yellow", "color": "#CDCA00", "deviceColor": "#FFFF00" } - ], - "gameboy": [ - { "name": "gameboy0", "color": "#0f380f", "deviceColor": "#0F0" }, - { "name": "gameboy1", "color": "#306230", "deviceColor": "#3F0" }, - { "name": "gameboy2", "color": "#8bac0f", "deviceColor": "#7F0" }, - { "name": "gameboy3", "color": "#9bbc0f", "deviceColor": "#FF0" } - ], - "spectra6legacy": [ - { "name": "black", "color": "#191E21", "deviceColor": "#000000" }, - { "name": "white", "color": "#e8e8e8", "deviceColor": "#FFFFFF" }, - { "name": "blue", "color": "#2157ba", "deviceColor": "#0000FF" }, - { "name": "green", "color": "#125f20", "deviceColor": "#00FF00" }, - { "name": "red", "color": "#b21318", "deviceColor": "#FF0000" }, - { "name": "yellow", "color": "#efde44", "deviceColor": "#FFFF00" } - ], - "spectra6": [ - { "name": "black", "color": "#1F2226", "deviceColor": "#000000" }, - { "name": "white", "color": "#B9C7C9", "deviceColor": "#FFFFFF" }, - { "name": "blue", "color": "#233F8E", "deviceColor": "#0000FF" }, - { "name": "green", "color": "#35563A", "deviceColor": "#00FF00" }, - { "name": "red", "color": "#62201E", "deviceColor": "#FF0000" }, - { "name": "yellow", "color": "#C1BB1E", "deviceColor": "#FFFF00" } - ], - "acep": [ - { "name": "black", "color": "#191E21", "deviceColor": "#000" }, - { "name": "white", "color": "#F1F1F1", "deviceColor": "#fff" }, - { "name": "blue", "color": "#31318F", "deviceColor": "#0000FF" }, - { "name": "green", "color": "#53A428", "deviceColor": "#00FF00" }, - { "name": "red", "color": "#D20E13", "deviceColor": "#FF0000" }, - { "name": "orange", "color": "#B85E1C", "deviceColor": "#FF8000" }, - { "name": "yellow", "color": "#F3CF11", "deviceColor": "#FFFF00" } - ] -} diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/diffusion-maps.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/diffusion-maps.ts deleted file mode 100644 index bc54ded2..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/diffusion-maps.ts +++ /dev/null @@ -1,96 +0,0 @@ -// @ts-nocheck -const maps = { - floydSteinberg: () => [ - { offset: [1, 0], factor: 7 / 16 }, - { offset: [-1, 1], factor: 3 / 16 }, - { offset: [0, 1], factor: 5 / 16 }, - { offset: [1, 1], factor: 1 / 16 }, - ], - falseFloydSteinberg: () => [ - { offset: [1, 0], factor: 3 / 8 }, - { offset: [0, 1], factor: 3 / 8 }, - { offset: [1, 1], factor: 2 / 8 }, - ], - atkinson: () => [ - { offset: [1, 0], factor: 1 / 8 }, - { offset: [2, 0], factor: 1 / 8 }, - { offset: [-1, 1], factor: 1 / 8 }, - { offset: [0, 1], factor: 1 / 8 }, - { offset: [1, 1], factor: 1 / 8 }, - { offset: [0, 2], factor: 1 / 8 }, - ], - jarvis: () => [ - { offset: [1, 0], factor: 7 / 48 }, - { offset: [2, 0], factor: 5 / 48 }, - - { offset: [-2, 1], factor: 3 / 48 }, - { offset: [-1, 1], factor: 5 / 48 }, - { offset: [0, 1], factor: 7 / 48 }, - { offset: [1, 1], factor: 5 / 48 }, - { offset: [2, 1], factor: 3 / 48 }, - - { offset: [-2, 2], factor: 1 / 48 }, - { offset: [-1, 2], factor: 3 / 48 }, - { offset: [0, 2], factor: 4 / 48 }, - { offset: [1, 2], factor: 3 / 48 }, - { offset: [2, 2], factor: 1 / 48 }, - ], - stucki: () => [ - { offset: [1, 0], factor: 8 / 42 }, - { offset: [2, 0], factor: 4 / 42 }, - - { offset: [-2, 1], factor: 2 / 42 }, - { offset: [-1, 1], factor: 4 / 42 }, - { offset: [0, 1], factor: 8 / 42 }, - { offset: [1, 1], factor: 4 / 42 }, - { offset: [2, 1], factor: 2 / 42 }, - - { offset: [-2, 2], factor: 1 / 42 }, - { offset: [-1, 2], factor: 2 / 42 }, - { offset: [0, 2], factor: 4 / 42 }, - { offset: [1, 2], factor: 2 / 42 }, - { offset: [2, 2], factor: 1 / 42 }, - ], - burkes: () => [ - { offset: [1, 0], factor: 8 / 32 }, - { offset: [2, 0], factor: 4 / 32 }, - - { offset: [-2, 1], factor: 2 / 32 }, - { offset: [-1, 1], factor: 4 / 32 }, - { offset: [0, 1], factor: 8 / 32 }, - { offset: [1, 1], factor: 4 / 32 }, - { offset: [2, 1], factor: 2 / 32 }, - ], - sierra3: () => [ - { offset: [1, 0], factor: 5 / 32 }, - { offset: [2, 0], factor: 3 / 32 }, - - { offset: [-2, 1], factor: 2 / 32 }, - { offset: [-1, 1], factor: 4 / 32 }, - { offset: [0, 1], factor: 5 / 32 }, - { offset: [1, 1], factor: 4 / 32 }, - { offset: [2, 1], factor: 2 / 32 }, - - { offset: [-1, 2], factor: 2 / 32 }, - { offset: [0, 2], factor: 3 / 32 }, - { offset: [1, 2], factor: 2 / 32 }, - ], - sierra2: () => [ - { offset: [1, 0], factor: 4 / 16 }, - { offset: [2, 0], factor: 3 / 16 }, - - { offset: [-2, 1], factor: 1 / 16 }, - { offset: [-1, 1], factor: 2 / 16 }, - { offset: [0, 1], factor: 3 / 16 }, - { offset: [1, 1], factor: 2 / 16 }, - { offset: [2, 1], factor: 1 / 16 }, - ], - "sierra2-4a": () => [ - { offset: [1, 0], factor: 2 / 4 }, - { offset: [-2, 1], factor: 1 / 4 }, - { offset: [-1, 1], factor: 1 / 4 }, - ], - "Sierra2-4A": () => maps["sierra2-4a"](), -}; - -export default maps; diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/threshold-maps.json b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/threshold-maps.json deleted file mode 100644 index 51bb257d..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/data/threshold-maps.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "4x4": [ - [ - 0, - 8, - 2, - 10 - ], - [ - 12, - 4, - 14, - 6 - ], - [ - 3, - 11, - 1, - 9 - ], - [ - 15, - 7, - 13, - 5 - ] - ], - "2x2": [ - [ - 0, - 3 - ], - [ - 1, - 2 - ] - ], - "3x3": [ - [ - 0, - 5, - 2 - ], - [ - 3, - 8, - 7 - ], - [ - 6, - 1, - 4 - ] - ], - "4x1": [ - [ - 0, - 3, - 1, - 2 - ] - ] -<<<<<<< ours -} -======= -} ->>>>>>> theirs diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/dither.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/dither.ts deleted file mode 100644 index 05c8b52d..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/dither.ts +++ /dev/null @@ -1,604 +0,0 @@ -// @ts-nocheck -import palettes from "./data/default-palettes.json"; -import diffusionMaps from "./data/diffusion-maps"; -//import thresholdMaps from "./data/threshold-maps.json"; - -/* Functions */ -import bayerMatrix from "./functions/bayer-matrix"; -import colorHelpers from "./functions/color-helpers"; -// import colorPaletteFromImage from "./functions/color-palette-from-image"; -import utilities from "./functions/utilities"; -import findClosestPaletteColor from "./functions/find-closest-palette-color"; -import { - applyImageProcessing, - clampByte, - getProcessingPreset, - luma709, - toRGB, - toScalar, - type ColorMatchingMode, - type DynamicRangeCompressionOptions, - type ImageProcessingOptions, - type LevelCompressionMode, - type LevelCompressionOptions, - type LevelRGB, - type PercentileClip, - type ProcessingPreset, - type ProcessingPresetName, - type RGB, - type RGBA, - type ToneMappingMode, - type ToneMappingOptions, -} from "./processing"; -import { - getNamedColors, - type PaletteColorEntry, - type PaletteRegistry, -} from "./functions/palette-order"; - -export type DitheringType = - | "errorDiffusion" - | "ordered" - | "random" - | "quantizationOnly" - | (string & {}); - -export interface DitherImageOptions { - /** - * Upstream-style processing preset. Presets fill in tone mapping, dynamic - * range compression, color matching, and diffusion defaults unless overridden. - */ - processingPreset?: ProcessingPresetName; - - /** Main dithering algorithm. */ - ditheringType?: DitheringType; - - /** Error diffusion kernel (e.g. `floydSteinberg`). */ - errorDiffusionMatrix?: string; - - /** - * Backwards-compatible alias for `errorDiffusionMatrix`. - * (The README historically used `algorithm`.) - */ - algorithm?: string; - - serpentine?: boolean; - - orderedDitheringType?: string; - /** Tuple preferred; `number[]` accepted for convenience. */ - orderedDitheringMatrix?: [number, number] | number[]; - - randomDitheringType?: "blackAndWhite" | "rgb" | (string & {}); - - /** Palette name, custom hex strings, or combined palette entries. */ - palette?: string | string[] | PaletteColorEntry[]; - - /** Color distance model for palette matching. */ - colorMatching?: ColorMatchingMode; - - sampleColorsFromImage?: boolean; - numberOfSampleColors?: number; - - /** Reserved/ignored by current implementation (kept for UI compatibility). */ - calibrate?: boolean; - - /** - * Optional preprocessing step to remap pixel values into the display’s effective black/white limits. - * - * Default: undefined (disabled) for backwards compatibility. - */ - levelCompression?: LevelCompressionOptions; - - /** - * Exposure/saturation plus contrast or S-curve tone mapping. - */ - toneMapping?: ToneMappingOptions; - - /** - * LAB lightness compression into the calibrated display black/white range. - */ - dynamicRangeCompression?: DynamicRangeCompressionOptions | boolean; -} - -export interface ImageDataLike { - width: number; - height: number; - data: Uint8ClampedArray; -} - -export interface Canvas2DContextLike { - getImageData(sx: number, sy: number, sw: number, sh: number): ImageDataLike; - putImageData(imageData: ImageDataLike, dx: number, dy: number): void; -} - -export interface CanvasLike { - width: number; - height: number; - getContext(contextId: "2d"): Canvas2DContextLike | null; -} - -export type { - ColorMatchingMode, - DynamicRangeCompressionOptions, - ImageProcessingOptions, - LevelCompressionMode, - LevelCompressionOptions, - LevelRGB, - PercentileClip, - ProcessingPreset, - ProcessingPresetName, - RGB, - RGBA, - ToneMappingMode, - ToneMappingOptions, -}; - -const defaultOptions: Required< - Pick< - DitherImageOptions, - | "ditheringType" - | "errorDiffusionMatrix" - | "serpentine" - | "orderedDitheringType" - | "orderedDitheringMatrix" - | "randomDitheringType" - | "palette" - | "colorMatching" - | "sampleColorsFromImage" - | "numberOfSampleColors" - > -> = { - ditheringType: "errorDiffusion", - - errorDiffusionMatrix: "floydSteinberg", - serpentine: false, - - orderedDitheringType: "bayer", - orderedDitheringMatrix: [4, 4], - - randomDitheringType: "blackAndWhite", - - palette: "default", - colorMatching: "rgb", - - sampleColorsFromImage: false, - numberOfSampleColors: 10, -}; - -const shouldEnableLevelCompression = ( - image: ImageDataLike, - mode: Exclude, - black: LevelRGB | undefined, - white: LevelRGB | undefined, - autoThreshold: number -) => { - const data = image.data; - const pixelCount = Math.floor(data.length / 4); - if (pixelCount <= 0) return false; - - let outOfRange = 0; - if (mode === "perChannel") { - const b = toRGB(black, 0); - const w = toRGB(white, 255); - const bR = b[0]; - const bG = b[1]; - const bB = b[2]; - const wR = w[0]; - const wG = w[1]; - const wB = w[2]; - - for (let i = 0; i < data.length; i += 4) { - const r = data[i]; - const g = data[i + 1]; - const bch = data[i + 2]; - if (r < bR || r > wR || g < bG || g > wG || bch < bB || bch > wB) { - outOfRange++; - } - } - } else { - const b = toScalar(black, 0); - const w = toScalar(white, 255); - for (let i = 0; i < data.length; i += 4) { - const y = luma709(data[i], data[i + 1], data[i + 2]); - if (y < b || y > w) outOfRange++; - } - } - - return outOfRange / pixelCount >= autoThreshold; -}; - -const applyLevelCompression = ( - image: ImageDataLike, - options: LevelCompressionOptions -) => { - const mode: LevelCompressionMode = options.mode ?? "perChannel"; - if (mode === "off") return; - - const auto = options.auto === true; - const autoThreshold = - typeof options.autoThreshold === "number" ? options.autoThreshold : 0.01; - - if (auto) { - const enabled = shouldEnableLevelCompression( - image, - mode, - options.black, - options.white, - autoThreshold - ); - if (!enabled) return; - } - - const data = image.data; - if (mode === "perChannel") { - const black = toRGB(options.black, 0); - const white = toRGB(options.white, 255); - - const bR = black[0]; - const bG = black[1]; - const bB = black[2]; - const wR = white[0]; - const wG = white[1]; - const wB = white[2]; - - const dR = wR - bR; - const dG = wG - bG; - const dB = wB - bB; - if (dR <= 0 || dG <= 0 || dB <= 0) return; - - for (let i = 0; i < data.length; i += 4) { - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - // Map [0..255] -> [black..white] to keep output within the display's usable range. - data[i] = clampByte(bR + (r * dR) / 255); - data[i + 1] = clampByte(bG + (g * dG) / 255); - data[i + 2] = clampByte(bB + (b * dB) / 255); - } - return; - } - - // mode === 'luma' - const blackL = toScalar(options.black, 0); - const whiteL = toScalar(options.white, 255); - const dL = whiteL - blackL; - if (dL <= 0) return; - - for (let i = 0; i < data.length; i += 4) { - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - const y = luma709(r, g, b); - - // Map [0..255] -> [black..white] - const yNew = blackL + (y * dL) / 255; - let ratio = y > 0 ? yNew / y : 0; - - // Prevent overflow clipping by capping the ratio based on the brightest channel. - const maxChannel = Math.max(r, g, b); - if (maxChannel > 0) { - ratio = Math.min(ratio, 255 / maxChannel); - } - - data[i] = clampByte(r * ratio); - data[i + 1] = clampByte(g * ratio); - data[i + 2] = clampByte(b * ratio); - } -}; - -const mergeImageProcessingOptions = ( - options: DitherImageOptions & typeof defaultOptions -): ImageProcessingOptions | undefined => { - const hasToneMapping = options.toneMapping !== undefined; - const hasDynamicRangeCompression = - options.dynamicRangeCompression !== undefined; - - if (!hasToneMapping && !hasDynamicRangeCompression) return undefined; - - return { - toneMapping: options.toneMapping, - dynamicRangeCompression: options.dynamicRangeCompression, - }; -}; - -const getPresetDefaults = (presetName: ProcessingPresetName | undefined) => { - if (!presetName) return {}; - const preset = getProcessingPreset(presetName); - if (!preset) return {}; - - return { - toneMapping: preset.toneMapping, - dynamicRangeCompression: preset.dynamicRangeCompression, - colorMatching: preset.colorMatching, - errorDiffusionMatrix: preset.errorDiffusionMatrix, - } satisfies Partial; -}; - -const ditherImage = async ( - sourceCanvas: CanvasLike, - canvas: CanvasLike, - opts: DitherImageOptions = {} -): Promise => { - if (!sourceCanvas || !canvas) { - return; - } - - const ctx = sourceCanvas.getContext("2d"); - if (!ctx) return; - const image = ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height); - - const options: DitherImageOptions & typeof defaultOptions = { - ...defaultOptions, - ...getPresetDefaults(opts.processingPreset), - ...opts, - }; - - // Backwards-compatible alias (README historically used `algorithm`). - if (opts.algorithm && !opts.errorDiffusionMatrix) { - options.errorDiffusionMatrix = opts.algorithm; - } - - const width = image.width; - const height = image.height; - let colorPalette: RGB[] = []; - - if (!options.palette || options.sampleColorsFromImage === true) { - // colorPalette = colorPaletteFromImage(image, options.numberOfSampleColors); - } else { - colorPalette = setColorPalette(options.palette); - } - - applyImageProcessing(image, mergeImageProcessingOptions(options), colorPalette); - - if (options.levelCompression) { - applyLevelCompression(image, options.levelCompression); - } - - function setPixel(pixelIndex, pixel) { - image.data[pixelIndex] = pixel[0]; - image.data[pixelIndex + 1] = pixel[1]; - image.data[pixelIndex + 2] = pixel[2]; - image.data[pixelIndex + 3] = pixel[3] ?? 255; - } - - const thresholdMap = bayerMatrix([ - options.orderedDitheringMatrix[0], - options.orderedDitheringMatrix[1], - ]); - - let current, newPixel, oldPixel; - - for (current = 0; current < image.data.length; current += 4) { - const currentPixel = current; - oldPixel = getPixelColorValues(currentPixel, image.data); - - if ( - !options.ditheringType || - options.ditheringType === "quantizationOnly" - ) { - newPixel = findClosestPaletteColor( - oldPixel, - colorPalette, - options.colorMatching - ); - setPixel(currentPixel, newPixel); - } - - if ( - options.ditheringType === "random" && - options.randomDitheringType === "rgb" - ) { - newPixel = randomDitherPixelValue(oldPixel); - setPixel(currentPixel, newPixel); - } - - if ( - options.ditheringType === "random" && - options.randomDitheringType === "blackAndWhite" - ) { - newPixel = randomDitherBlackAndWhitePixelValue(oldPixel); - setPixel(currentPixel, newPixel); - } - - if (options.ditheringType === "ordered") { - const orderedDitherThreshold = 256 / 4; - newPixel = orderedDitherPixelValue( - oldPixel, - pixelXY(currentPixel / 4, width), - thresholdMap, - orderedDitherThreshold - ); - newPixel = findClosestPaletteColor( - newPixel, - colorPalette, - options.colorMatching - ); - setPixel(currentPixel, newPixel); - } - - if (options.ditheringType === "errorDiffusion") { - break; - } - } - - if (options.ditheringType === "errorDiffusion") { - applyErrorDiffusion( - image, - width, - height, - colorPalette, - options.errorDiffusionMatrix, - options.colorMatching, - options.serpentine - ); - } - - return imageDataToCanvas(image, canvas); -}; - -const getPixelColorValues = ( - pixelIndex: number, - data: Uint8ClampedArray -): RGBA => { - return [ - data[pixelIndex], - data[pixelIndex + 1], - data[pixelIndex + 2], - data[pixelIndex + 3], - ]; -}; - -const getQuantError = (oldPixel: RGBA, newPixel: RGBA): RGBA => { - //const maxValue = 255 - const quant = oldPixel.map((color, i) => { - return color - newPixel[i]; - }); - - return quant as RGBA; -}; - -const addQuantError = ( - pixel: RGBA, - quantError: RGBA, - diffusionFactor: number -): RGBA => { - return pixel.map( - (color, i) => - i === 3 ? color : clampByte(color + quantError[i] * diffusionFactor) - ) as RGBA; -}; - -const getDiffusionMap = (matrixName: string) => { - const matrixFactory = diffusionMaps[matrixName] || diffusionMaps.floydSteinberg; - return matrixFactory(); -}; - -const applyErrorDiffusion = ( - image: ImageDataLike, - width: number, - height: number, - colorPalette: RGB[], - matrixName: string, - colorMatching: ColorMatchingMode, - serpentine: boolean -) => { - const diffusionMap = getDiffusionMap(matrixName); - - for (let y = 0; y < height; y++) { - const reverse = serpentine && y % 2 === 1; - const xStart = reverse ? width - 1 : 0; - const xEnd = reverse ? -1 : width; - const xStep = reverse ? -1 : 1; - - for (let x = xStart; x !== xEnd; x += xStep) { - const currentPixel = (y * width + x) * 4; - const oldPixel = getPixelColorValues(currentPixel, image.data); - const newPixel = findClosestPaletteColor( - oldPixel, - colorPalette, - colorMatching - ); - - setImageDataPixel(image, currentPixel, newPixel); - - const quantError = getQuantError(oldPixel, newPixel); - - diffusionMap.forEach((diffusion) => { - const dx = reverse ? -diffusion.offset[0] : diffusion.offset[0]; - const nx = x + dx; - const ny = y + diffusion.offset[1]; - if (nx < 0 || nx >= width || ny < 0 || ny >= height) return; - - const pixelIndex = (ny * width + nx) * 4; - const errorPixel = addQuantError( - getPixelColorValues(pixelIndex, image.data), - quantError, - diffusion.factor - ); - setImageDataPixel(image, pixelIndex, errorPixel); - }); - } - } -}; - -const setImageDataPixel = ( - image: ImageDataLike, - pixelIndex: number, - pixel: RGBA -) => { - image.data[pixelIndex] = pixel[0]; - image.data[pixelIndex + 1] = pixel[1]; - image.data[pixelIndex + 2] = pixel[2]; - image.data[pixelIndex + 3] = pixel[3] ?? 255; -}; - -const randomDitherPixelValue = (pixel: RGBA): RGBA => { - return [ - pixel[0] < utilities.randomInteger(0, 255) ? 0 : 255, - pixel[1] < utilities.randomInteger(0, 255) ? 0 : 255, - pixel[2] < utilities.randomInteger(0, 255) ? 0 : 255, - pixel[3], - ]; -}; - -const randomDitherBlackAndWhitePixelValue = (pixel: RGBA): RGBA => { - const averageRGB = (pixel[0] + pixel[1] + pixel[2]) / 3; - return averageRGB < utilities.randomInteger(0, 255) - ? [0, 0, 0, 255] - : [255, 255, 255, 255]; -}; - -const orderedDitherPixelValue = ( - pixel: RGBA, - coordinates: [number, number], - thresholdMap: number[][], - threshold: number -): RGBA => { - const factor = - thresholdMap[coordinates[1] % thresholdMap.length][ - coordinates[0] % thresholdMap[0].length - ] / - (thresholdMap.length * thresholdMap[0].length); - return [ - clampByte(pixel[0] + factor * threshold), - clampByte(pixel[1] + factor * threshold), - clampByte(pixel[2] + factor * threshold), - pixel[3], - ]; -}; - -const pixelXY = (index: number, width: number): [number, number] => { - return [index % width, Math.floor(index / width)]; -}; - -const isPaletteColorEntry = ( - color: string | PaletteColorEntry -): color is PaletteColorEntry => - typeof color === "object" && color !== null && "color" in color; - -const setColorPalette = ( - palette: string | string[] | PaletteColorEntry[] -): RGB[] => { - const paletteArray = - typeof palette === "string" - ? getNamedColors(palettes as PaletteRegistry, palette) - : palette; - return paletteArray - .map((color) => - colorHelpers.hexToRgb(isPaletteColorEntry(color) ? color.color : color) - ) - .filter((color): color is RGB => Array.isArray(color)); -}; - -const imageDataToCanvas = (imageData: ImageDataLike, canvas: CanvasLike) => { - canvas.width = imageData.width; - canvas.height = imageData.height; - - const ctx = canvas.getContext("2d"); - - ctx.putImageData(imageData, 0, 0); - - return canvas; -}; - -export { ditherImage }; diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/bayer-matrix.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/bayer-matrix.ts deleted file mode 100644 index 442e69aa..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/bayer-matrix.ts +++ /dev/null @@ -1,92 +0,0 @@ -// @ts-nocheck -const createBayerMatrix = (size /* [X, Y] */) => { - const width = size[0] < 8 ? size[0] : 8; - const height = size[1] < 8 ? size[1] : 8; - - const bigMatrix = [ - [0, 48, 12, 60, 3, 51, 15, 63], - [32, 16, 44, 28, 35, 19, 47, 31], - [8, 56, 4, 52, 11, 59, 7, 55], - [40, 24, 36, 20, 43, 27, 39, 32], - [2, 50, 14, 62, 1, 49, 13, 61], - [34, 18, 46, 30, 33, 17, 45, 29], - [10, 58, 6, 54, 9, 57, 5, 53], - [42, 26, 38, 22, 41, 25, 37, 21], - ]; - - if (width === 8 && height === 8) { - // If we're using an 8 by 8 matrix just return the big matrix - return bigMatrix; - } - - const matrix = []; - let currentY = 0; - for (currentY; currentY < height; currentY++) { - matrix.push([]); - } - - matrix.forEach((row, y) => { - let x = 0; - for (x; x < width; x++) { - row.push(bigMatrix[x][y]); - } - }); - - const index = {}; - - matrix - .flat() - .sort((a, b) => a - b) - .forEach((n, i) => (index[n] = i)); - - matrix.forEach((row, y) => { - row.forEach((cell, x) => { - matrix[y][x] = index[cell]; - }); - }); - - // function getPsuedoToroidalDistance (node1, node2 /* [x, y] */) { - // const xDistance = Math.abs(node1[0] - node2[0]) - // const yDistance = Math.abs(node1[1] - node2[1]) - // return Math.min(xDistance, width - xDistance) + Math.min(yDistance, height - yDistance) - // } - - // function findBestUnfilledSlot (previousNode, previousNode2) { - // let bestDistance = 0 - // let bestSlot = null - // matrix.forEach((row, y) => { - // row.forEach((cell, x) => { - // if (cell === null) { - // let distance1 = getPsuedoToroidalDistance(previousNode, [x, y]) - // let distance2 = previousNode2 ? getPsuedoToroidalDistance(previousNode2, [x, y]) : 1 - // let distance = (distance1 * distance2) - // if (distance > bestDistance) { - // bestDistance = distance - // bestSlot = [x, y] - // } - // } - // }) - // }) - // return bestSlot - // } - - // let previous = null - // let previous2 = null - // let currentNumber = 0 - // for (currentNumber; currentNumber < numberOfNodes; currentNumber++) { - // let cellXY = [[currentNumber % width], [currentNumber % height]] - // if (currentNumber === 0) { - // matrix[cellXY[1]][cellXY[0]] = 0 - // previous = cellXY - // } else { - // let bestSlot = findBestUnfilledSlot(previous, previous2) - // matrix[bestSlot[1]][bestSlot[0]] = currentNumber - // previous2 = previous - // previous = [bestSlot[0], bestSlot[1]] - // } - // } - - return matrix; -}; - -export default createBayerMatrix; diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-helpers.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-helpers.ts deleted file mode 100644 index c927108c..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-helpers.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-nocheck -export function hexToRgb(hex) { - const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; - hex = hex.replace(shorthandRegex, (m, r, g, b) => { - return r + r + g + g + b + b; - }); - - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? [ - parseInt(result[1], 16), - parseInt(result[2], 16), - parseInt(result[3], 16), - ] - : null; -} - -const colorHelper = { hexToRgb }; - -export default colorHelper; diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-histogram.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-histogram.ts deleted file mode 100644 index f6617924..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-histogram.ts +++ /dev/null @@ -1 +0,0 @@ -// @ts-nocheck diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-palette-from-image.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-palette-from-image.ts deleted file mode 100644 index fab981c9..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/color-palette-from-image.ts +++ /dev/null @@ -1,163 +0,0 @@ -// @ts-nocheck -//const { createCanvas, Image } = require('canvas') -import utilities from "./utilities"; - -const distanceInColorSpace = async (color1, color2) => { - // Currenlty ignores alpha - - const r = color1[0] - color2[0]; - const g = color1[1] - color2[1]; - const b = color1[2] - color2[2]; - - const distance = Math.sqrt(r * r + g * g + b * b); - return distance; -}; - -const colorPaletteFromImage = (image, numberOfColors) => { - // First we create a canvas and downsize the image - const sampleCanvasWidth = 300; - const sampleCanvasHeight = (image.width / image.height) * sampleCanvasWidth; - - // let canvas = createCanvas(sampleCanvasWidth, sampleCanvasHeight); - - const canvas = document.createElement("canvas"); - canvas.width = sampleCanvasWidth; - canvas.height = sampleCanvasHeight; - - const ctx = canvas.getContext("2d"); - ctx.putImageData(image, 0, 0, 0, 0, sampleCanvasWidth, sampleCanvasHeight); - const downsizedImageData = ctx.getImageData( - 0, - 0, - canvas.width, - canvas.height - ); - - const colors = []; - const imageData = downsizedImageData.data; - for ( - let currentPixel = 0; - currentPixel < downsizedImageData.data.length; - currentPixel += 4 - ) { - const color = [ - imageData[currentPixel], - imageData[currentPixel + 1], - imageData[currentPixel + 2], - ]; - colors.push(color); - } - - const palette = quantize(colors, numberOfColors); - return palette; -}; - -const randomItemsFromArray = (array, n) => { - const randomIndexes = []; - while (randomIndexes.length < n) { - const randomArrayIndex = utilities.randomInteger(0, array.length - 1); - if (!randomIndexes.includes(randomArrayIndex)) { - randomIndexes.push(randomArrayIndex); - } - } - - const itemsFromArray = randomIndexes.map((index) => array[index]); - return itemsFromArray; -}; - -const quantize = (colors, k) => { - if (k > colors) { - throw Error(`K (${k}) is greater than colors (${colors.length}).`); - } - - const centers = randomItemsFromArray(colors, k); - let oldCentroids = centers.map((center) => { - return { - position: center, - points: [], - }; - }); - - let newCentroids = []; - const maxRounds = 300; - let currentRound = 0; - - while (currentRound < maxRounds) { - const centroidsWithPoints = assignPixelsToCentroids(colors, oldCentroids); - newCentroids = moveCentroidsToAveragePosition(centroidsWithPoints); - if (centroidsMatch(oldCentroids, newCentroids)) { - break; - } - oldCentroids = newCentroids; - currentRound++; - } - - const colorPalette = newCentroids.map((centroid) => centroid.position); - return colorPalette; -}; - -const centroidsMatch = (oldCentroids, newCentroids) => { - if (oldCentroids.length !== newCentroids.length) { - return false; - } - - const oldC = oldCentroids.map((centroid) => centroid.position).flat(); - const newC = newCentroids.map((centroid) => centroid.position).flat(); - - let matching = true; - - oldC.forEach((c, i) => { - if (c !== newC[i]) { - matching = false; - } - }); - - return matching; -}; - -const assignPixelsToCentroids = (colors, centroids) => { - colors.forEach((color) => { - let nearestCentroidIndex = null; - let nearestCentroidDistance = null; - centroids.forEach((centroid, i) => { - const distance = distanceInColorSpace(centroid.position, color); - if ( - nearestCentroidIndex === null || - nearestCentroidDistance === null || - distance < nearestCentroidDistance - ) { - nearestCentroidIndex = i; - nearestCentroidDistance = distance; - } - }); - centroids[nearestCentroidIndex].points.push(color); - }); - - return centroids; -}; - -const moveCentroidsToAveragePosition = (centroids) => { - const averageCentroids = []; - centroids.forEach((centroid) => { - const numberOfPoints = centroid.points.length; - if (numberOfPoints > 0) { - const sumOfAllPoints = [0, 0, 0]; - centroid.points.forEach((point) => { - sumOfAllPoints[0] += point[0]; - sumOfAllPoints[1] += point[1]; - sumOfAllPoints[2] += point[2]; - }); - const averageOfAllPoints = [ - Math.round(sumOfAllPoints[0] / numberOfPoints), - Math.round(sumOfAllPoints[1] / numberOfPoints), - Math.round(sumOfAllPoints[2] / numberOfPoints), - ]; - averageCentroids.push({ position: averageOfAllPoints, points: [] }); - } else { - averageCentroids.push({ position: centroid.position, points: [] }); - } - }); - return averageCentroids; -}; - -export default colorPaletteFromImage; diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/find-closest-palette-color.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/find-closest-palette-color.ts deleted file mode 100644 index 73dfa79c..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/find-closest-palette-color.ts +++ /dev/null @@ -1,70 +0,0 @@ -// @ts-nocheck -import { - deltaE, - rgbToLab, - type ColorMatchingMode, - type RGB, - type RGBA, -} from "../processing"; - -const withAlpha = (color: RGB | RGBA): RGBA => [ - color[0], - color[1], - color[2], - (color as RGBA)[3] ?? 255, -]; - -const findClosestPaletteColor = ( - pixel: RGB | RGBA, - colorPalette: RGB[], - colorMatching: ColorMatchingMode = "rgb" -): RGBA => { - if (!colorPalette.length) return withAlpha(pixel); - const pixelLab = - colorMatching === "lab" ? rgbToLab(pixel[0], pixel[1], pixel[2]) : null; - - const colors = colorPalette.map((color) => { - return { - distance: - colorMatching === "lab" && pixelLab - ? deltaE(rgbToLab(...color), pixelLab) - : distanceInColorSpace(color, pixel), - color, - }; - }); - - let closestColor: { distance: number; color: RGB }; - colors.forEach((color) => { - if (!closestColor) { - closestColor = color; - } else { - if (color.distance < closestColor.distance) { - closestColor = color; - } - } - }); - - return withAlpha(closestColor.color); -}; - -const distanceInColorSpace = (color1: RGB, color2: RGB | RGBA) => { - // Currenlty ignores alpha - - // Luminosity needs to be accounted for, for better results. - // var lumR = .2126, - // lumG = .7152, - // lumB = .0722 - - // const max = 255 - - // const averageMax = Math.sqrt(lumR * max * max + lumG * max * max + lumB * max * max) // I Dont understand this - - const r = color1[0] - color2[0]; - const g = color1[1] - color2[1]; - const b = color1[2] - color2[2]; - - const distance = Math.sqrt(r * r + g * g + b * b); - return distance; -}; - -export default findClosestPaletteColor; diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/palette-order.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/palette-order.ts deleted file mode 100644 index 4b905516..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/palette-order.ts +++ /dev/null @@ -1,70 +0,0 @@ -// @ts-nocheck -const CANONICAL_COLOR_ORDER = [ - "black", - "white", - "blue", - "green", - "red", - "orange", - "yellow", - "gameboy0", - "gameboy1", - "gameboy2", - "gameboy3", -]; - -export interface PaletteColorEntry { - name: string; - color: string; - deviceColor: string; -} - -export type PaletteRegistry = Record; - -const roleRank = (role: string, index: number) => { - const rank = CANONICAL_COLOR_ORDER.indexOf(role); - return rank === -1 ? CANONICAL_COLOR_ORDER.length + index : rank; -}; - -export const normalizePaletteKey = (name: string | undefined) => - (name || "default").toLowerCase(); - -export const getNamedEntries = (registry: PaletteRegistry, name: string) => { - const key = normalizePaletteKey(name); - const entries = registry[key] || registry.default || []; - return entries - .map((entry, index) => ({ entry, index })) - .sort( - (a, b) => - roleRank(a.entry.name, a.index) - roleRank(b.entry.name, b.index) - ) - .map(({ entry }) => entry); -}; - -export const getNamedColors = (registry: PaletteRegistry, name: string) => - getNamedEntries(registry, name).map((entry) => entry.color); - -export const getNamedDeviceColors = ( - registry: PaletteRegistry, - name: string -) => getNamedEntries(registry, name).map((entry) => entry.deviceColor); - -export const getNamedRoles = (registry: PaletteRegistry, name: string) => - getNamedEntries(registry, name).map((entry) => entry.name); - -export const alignReplacementColors = ( - sourceColors: string[], - sourceRoles: string[], - replacementColors: string[], - replacementRoles: string[] -) => { - const replacementByRole = new Map(); - replacementRoles.forEach((role, index) => { - replacementByRole.set(role, replacementColors[index]); - }); - - return sourceRoles.map( - (role, index) => - replacementByRole.get(role) ?? sourceColors[index] ?? replacementColors[index] - ); -}; diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/utilities.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/utilities.ts deleted file mode 100644 index b7cbd81c..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/functions/utilities.ts +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-nocheck -export function randomInteger(min, max) { - return Math.floor(Math.random() * (max - min + 1)) + min; -} -const utilities = { randomInteger }; - -export default utilities; diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/processing.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/dither/processing.ts deleted file mode 100644 index b7b7d970..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/dither/processing.ts +++ /dev/null @@ -1,546 +0,0 @@ -// @ts-nocheck -export type RGB = [number, number, number]; -export type RGBA = [number, number, number, number]; - -export type ToneMappingMode = "off" | "contrast" | "scurve"; -export type ColorMatchingMode = "rgb" | "lab"; -export type DynamicRangeCompressionMode = "off" | "display" | "auto"; -export type LevelCompressionMode = "off" | "perChannel" | "luma"; - -export type LevelRGB = number | RGB; - -export interface PercentileClip { - low: number; - high: number; -} - -export interface LevelCompressionOptions { - mode?: LevelCompressionMode; - black?: LevelRGB; - white?: LevelRGB; - auto?: boolean; - autoThreshold?: number; - percentileClip?: PercentileClip; -} - -export interface ToneMappingOptions { - mode?: ToneMappingMode; - exposure?: number; - saturation?: number; - contrast?: number; - strength?: number; - shadowBoost?: number; - highlightCompress?: number; - midpoint?: number; -} - -export interface DynamicRangeCompressionOptions { - mode?: DynamicRangeCompressionMode; - black?: LevelRGB; - white?: LevelRGB; - strength?: number; - lowPercentile?: number; - highPercentile?: number; -} - -export interface ImageProcessingOptions { - toneMapping?: ToneMappingOptions; - dynamicRangeCompression?: DynamicRangeCompressionOptions | boolean; -} - -export type ProcessingPresetName = - | "balanced" - | "dynamic" - | "vivid" - | "soft" - | "grayscale" - | (string & {}); - -export interface ProcessingPreset { - name: ProcessingPresetName; - title: string; - description: string; - toneMapping: ToneMappingOptions; - dynamicRangeCompression?: DynamicRangeCompressionOptions; - colorMatching?: ColorMatchingMode; - errorDiffusionMatrix?: string; -} - -export interface ImageDataLike { - width: number; - height: number; - data: Uint8ClampedArray; -} - -export const PROCESSING_PRESETS: Record = { - balanced: { - name: "balanced", - title: "Balanced", - description: - "Compresses display luminance range for general photo conversion.", - toneMapping: { - mode: "contrast", - exposure: 1, - saturation: 1, - contrast: 1, - }, - dynamicRangeCompression: { - mode: "display", - strength: 1, - }, - colorMatching: "rgb", - errorDiffusionMatrix: "floydSteinberg", - }, - dynamic: { - name: "dynamic", - title: "Dynamic", - description: - "Uses S-curve tone mapping for brighter, punchier photographic output.", - toneMapping: { - mode: "scurve", - exposure: 1, - saturation: 1.3, - strength: 0.9, - shadowBoost: 0, - highlightCompress: 1.5, - midpoint: 0.5, - }, - dynamicRangeCompression: { - mode: "off", - }, - colorMatching: "rgb", - errorDiffusionMatrix: "floydSteinberg", - }, - vivid: { - name: "vivid", - title: "Vivid", - description: "Boosts color and applies a gentler S-curve for illustrations.", - toneMapping: { - mode: "scurve", - exposure: 1.1, - saturation: 1.6, - strength: 0.7, - shadowBoost: 0.1, - highlightCompress: 1.3, - midpoint: 0.5, - }, - dynamicRangeCompression: { - mode: "off", - }, - colorMatching: "rgb", - errorDiffusionMatrix: "floydSteinberg", - }, - soft: { - name: "soft", - title: "Soft", - description: "Reduces contrast and uses Stucki diffusion for smoother tones.", - toneMapping: { - mode: "contrast", - exposure: 1, - saturation: 1.1, - contrast: 0.9, - }, - dynamicRangeCompression: { - mode: "display", - strength: 1, - }, - colorMatching: "rgb", - errorDiffusionMatrix: "stucki", - }, - grayscale: { - name: "grayscale", - title: "Grayscale", - description: "Removes saturation and uses LAB matching for monochrome work.", - toneMapping: { - mode: "scurve", - exposure: 1, - saturation: 0, - strength: 0.8, - shadowBoost: 0.1, - highlightCompress: 1.4, - midpoint: 0.5, - }, - dynamicRangeCompression: { - mode: "display", - strength: 1, - }, - colorMatching: "lab", - errorDiffusionMatrix: "floydSteinberg", - }, -}; - -export const getProcessingPreset = ( - name: ProcessingPresetName -): ProcessingPreset | null => { - const preset = PROCESSING_PRESETS[String(name).toLowerCase()]; - return preset - ? { - ...preset, - toneMapping: { ...preset.toneMapping }, - dynamicRangeCompression: preset.dynamicRangeCompression - ? { ...preset.dynamicRangeCompression } - : undefined, - } - : null; -}; - -export const getProcessingPresetNames = () => Object.keys(PROCESSING_PRESETS); - -export const getProcessingPresetOptions = () => - Object.values(PROCESSING_PRESETS).map(({ name, title, description }) => ({ - value: name, - title, - description, - })); - -const clamp = (value: number, min: number, max: number) => - value < min ? min : value > max ? max : value; - -export const clampByte = (value: number) => { - if (!Number.isFinite(value)) return 0; - return Math.round(clamp(value, 0, 255)); -}; - -export const luma709 = (r: number, g: number, b: number) => - 0.2126 * r + 0.7152 * g + 0.0722 * b; - -export const toRGB = (value: LevelRGB | undefined, fallback: number): RGB => { - if (Array.isArray(value)) { - return [ - value[0] ?? fallback, - value[1] ?? fallback, - value[2] ?? fallback, - ]; - } - const v = typeof value === "number" ? value : fallback; - return [v, v, v]; -}; - -export const toScalar = (value: LevelRGB | undefined, fallback: number) => { - if (Array.isArray(value)) { - return luma709( - value[0] ?? fallback, - value[1] ?? fallback, - value[2] ?? fallback - ); - } - return typeof value === "number" ? value : fallback; -}; - -const rgbToXyz = (r: number, g: number, b: number) => { - let rn = r / 255; - let gn = g / 255; - let bn = b / 255; - - rn = rn > 0.04045 ? Math.pow((rn + 0.055) / 1.055, 2.4) : rn / 12.92; - gn = gn > 0.04045 ? Math.pow((gn + 0.055) / 1.055, 2.4) : gn / 12.92; - bn = bn > 0.04045 ? Math.pow((bn + 0.055) / 1.055, 2.4) : bn / 12.92; - - return [ - (rn * 0.4124564 + gn * 0.3575761 + bn * 0.1804375) * 100, - (rn * 0.2126729 + gn * 0.7151522 + bn * 0.072175) * 100, - (rn * 0.0193339 + gn * 0.119192 + bn * 0.9503041) * 100, - ] as RGB; -}; - -const xyzToLab = (x: number, y: number, z: number) => { - let xn = x / 95.047; - let yn = y / 100; - let zn = z / 108.883; - - xn = xn > 0.008856 ? Math.pow(xn, 1 / 3) : 7.787 * xn + 16 / 116; - yn = yn > 0.008856 ? Math.pow(yn, 1 / 3) : 7.787 * yn + 16 / 116; - zn = zn > 0.008856 ? Math.pow(zn, 1 / 3) : 7.787 * zn + 16 / 116; - - return [116 * yn - 16, 500 * (xn - yn), 200 * (yn - zn)] as RGB; -}; - -export const rgbToLab = (r: number, g: number, b: number) => { - const [x, y, z] = rgbToXyz(r, g, b); - return xyzToLab(x, y, z); -}; - -const labToXyz = (l: number, a: number, b: number) => { - let y = (l + 16) / 116; - let x = a / 500 + y; - let z = y - b / 200; - - x = x > 0.206897 ? Math.pow(x, 3) : (x - 16 / 116) / 7.787; - y = y > 0.206897 ? Math.pow(y, 3) : (y - 16 / 116) / 7.787; - z = z > 0.206897 ? Math.pow(z, 3) : (z - 16 / 116) / 7.787; - - return [x * 95.047, y * 100, z * 108.883] as RGB; -}; - -const xyzToRgb = (x: number, y: number, z: number) => { - const xn = x / 100; - const yn = y / 100; - const zn = z / 100; - - let r = xn * 3.2404542 + yn * -1.5371385 + zn * -0.4985314; - let g = xn * -0.969266 + yn * 1.8760108 + zn * 0.041556; - let b = xn * 0.0556434 + yn * -0.2040259 + zn * 1.0572252; - - r = r > 0.0031308 ? 1.055 * Math.pow(r, 1 / 2.4) - 0.055 : 12.92 * r; - g = g > 0.0031308 ? 1.055 * Math.pow(g, 1 / 2.4) - 0.055 : 12.92 * g; - b = b > 0.0031308 ? 1.055 * Math.pow(b, 1 / 2.4) - 0.055 : 12.92 * b; - - return [clampByte(r * 255), clampByte(g * 255), clampByte(b * 255)] as RGB; -}; - -export const labToRgb = (l: number, a: number, b: number) => { - const [x, y, z] = labToXyz(l, a, b); - return xyzToRgb(x, y, z); -}; - -export const deltaE = (lab1: RGB, lab2: RGB) => { - const dl = lab1[0] - lab2[0]; - const da = lab1[1] - lab2[1]; - const db = lab1[2] - lab2[2]; - return Math.sqrt(dl * dl + da * da + db * db); -}; - -const applyExposure = (image: ImageDataLike, exposure: number) => { - if (exposure === 1) return; - const data = image.data; - for (let i = 0; i < data.length; i += 4) { - data[i] = clampByte(data[i] * exposure); - data[i + 1] = clampByte(data[i + 1] * exposure); - data[i + 2] = clampByte(data[i + 2] * exposure); - } -}; - -const applyContrast = (image: ImageDataLike, contrast: number) => { - if (contrast === 1) return; - const data = image.data; - for (let i = 0; i < data.length; i += 4) { - data[i] = clampByte((data[i] - 128) * contrast + 128); - data[i + 1] = clampByte((data[i + 1] - 128) * contrast + 128); - data[i + 2] = clampByte((data[i + 2] - 128) * contrast + 128); - } -}; - -const applySaturation = (image: ImageDataLike, saturation: number) => { - if (saturation === 1) return; - const data = image.data; - - for (let i = 0; i < data.length; i += 4) { - const r = data[i] / 255; - const g = data[i + 1] / 255; - const b = data[i + 2] / 255; - - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const lightness = (max + min) / 2; - - if (max === min) continue; - - const delta = max - min; - const sat = - lightness > 0.5 - ? delta / (2 - max - min) - : delta / Math.max(max + min, 0.000001); - - let hue: number; - if (max === r) { - hue = ((g - b) / delta + (g < b ? 6 : 0)) / 6; - } else if (max === g) { - hue = ((b - r) / delta + 2) / 6; - } else { - hue = ((r - g) / delta + 4) / 6; - } - - const newSat = clamp(sat * saturation, 0, 1); - const c = (1 - Math.abs(2 * lightness - 1)) * newSat; - const x = c * (1 - Math.abs(((hue * 6) % 2) - 1)); - const m = lightness - c / 2; - - let rp = 0; - let gp = 0; - let bp = 0; - const sector = Math.floor(hue * 6); - - if (sector === 0) [rp, gp, bp] = [c, x, 0]; - else if (sector === 1) [rp, gp, bp] = [x, c, 0]; - else if (sector === 2) [rp, gp, bp] = [0, c, x]; - else if (sector === 3) [rp, gp, bp] = [0, x, c]; - else if (sector === 4) [rp, gp, bp] = [x, 0, c]; - else [rp, gp, bp] = [c, 0, x]; - - data[i] = clampByte((rp + m) * 255); - data[i + 1] = clampByte((gp + m) * 255); - data[i + 2] = clampByte((bp + m) * 255); - } -}; - -const applyScurveToneMap = ( - image: ImageDataLike, - strength: number, - shadowBoost: number, - highlightCompress: number, - midpoint: number -) => { - if (strength === 0) return; - const data = image.data; - const mid = clamp(midpoint, 0.01, 0.99); - - for (let i = 0; i < data.length; i += 4) { - for (let c = 0; c < 3; c++) { - const normalized = data[i + c] / 255; - let result: number; - - if (normalized <= mid) { - const shadowValue = normalized / mid; - result = Math.pow(shadowValue, 1 - strength * shadowBoost) * mid; - } else { - const highlightValue = (normalized - mid) / (1 - mid); - result = - mid + - Math.pow(highlightValue, 1 + strength * highlightCompress) * - (1 - mid); - } - - data[i + c] = clampByte(result * 255); - } - } -}; - -const percentile = (values: number[], p: number) => { - if (values.length === 0) return 0; - const sorted = values.slice().sort((a, b) => a - b); - const idx = clamp(Math.round((sorted.length - 1) * p), 0, sorted.length - 1); - return sorted[idx]; -}; - -const getPaletteEndpoints = ( - palette: RGB[] | undefined, - black: LevelRGB | undefined, - white: LevelRGB | undefined -) => { - if (black !== undefined && white !== undefined) { - return { - black: toRGB(black, 0), - white: toRGB(white, 255), - }; - } - - if (!palette || palette.length === 0) { - return { - black: toRGB(black, 0), - white: toRGB(white, 255), - }; - } - - let darkest = palette[0]; - let lightest = palette[0]; - for (const color of palette) { - if (luma709(...color) < luma709(...darkest)) darkest = color; - if (luma709(...color) > luma709(...lightest)) lightest = color; - } - - return { - black: black !== undefined ? toRGB(black, 0) : darkest, - white: white !== undefined ? toRGB(white, 255) : lightest, - }; -}; - -const normalizeDynamicRangeOptions = ( - options: DynamicRangeCompressionOptions | boolean | undefined -): DynamicRangeCompressionOptions | undefined => { - if (options === true) return { mode: "display", strength: 1 }; - if (!options || options.mode === "off") return undefined; - return options; -}; - -const applyDynamicRangeCompression = ( - image: ImageDataLike, - options: DynamicRangeCompressionOptions | boolean | undefined, - palette: RGB[] | undefined -) => { - const normalized = normalizeDynamicRangeOptions(options); - if (!normalized) return; - - const mode = normalized.mode ?? "display"; - const strength = clamp(normalized.strength ?? 1, 0, 1); - if (strength === 0) return; - - const { black, white } = getPaletteEndpoints( - palette, - normalized.black, - normalized.white - ); - const [blackL] = rgbToLab(...black); - const [whiteL] = rgbToLab(...white); - const targetRange = whiteL - blackL; - if (targetRange <= 0) return; - - const data = image.data; - let sourceBlackL = 0; - let sourceWhiteL = 100; - - if (mode === "auto") { - const lightnesses: number[] = []; - for (let i = 0; i < data.length; i += 4) { - const [l] = rgbToLab(data[i], data[i + 1], data[i + 2]); - lightnesses.push(l); - } - sourceBlackL = percentile(lightnesses, normalized.lowPercentile ?? 0.01); - sourceWhiteL = percentile(lightnesses, normalized.highPercentile ?? 0.99); - } - - const sourceRange = sourceWhiteL - sourceBlackL; - if (sourceRange <= 0.0001) return; - - for (let i = 0; i < data.length; i += 4) { - const [l, a, b] = rgbToLab(data[i], data[i + 1], data[i + 2]); - const normalizedL = clamp((l - sourceBlackL) / sourceRange, 0, 1); - const compressedL = blackL + normalizedL * targetRange; - const blendedL = l + (compressedL - l) * strength; - const [r, g, blue] = labToRgb(blendedL, a, b); - - data[i] = r; - data[i + 1] = g; - data[i + 2] = blue; - } -}; - -export const applyToneMapping = ( - image: ImageDataLike, - options: ToneMappingOptions | undefined -) => { - if (!options) return; - - const exposure = options.exposure ?? 1; - const saturation = options.saturation ?? 1; - const mode = options.mode ?? "contrast"; - - applyExposure(image, exposure); - applySaturation(image, saturation); - - if (mode === "contrast") { - applyContrast(image, options.contrast ?? 1); - } else if (mode === "scurve") { - applyScurveToneMap( - image, - options.strength ?? 0.9, - options.shadowBoost ?? 0, - options.highlightCompress ?? 1.5, - options.midpoint ?? 0.5 - ); - } -}; - -export const applyImageProcessing = ( - image: ImageDataLike, - options: ImageProcessingOptions | undefined, - palette?: RGB[] -) => { - if (!options) return; - applyToneMapping(image, options.toneMapping); - applyDynamicRangeCompression( - image, - options.dynamicRangeCompression, - palette - ); -}; - diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/image-style.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/image-style.ts deleted file mode 100644 index f3471dfe..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/image-style.ts +++ /dev/null @@ -1,755 +0,0 @@ -// @ts-nocheck -import type { CanvasLike, ImageDataLike } from "./dither/dither"; - -export type ImageStyle = "photo" | "illustration" | "unknown"; -export type ImageKind = - | "photo" - | "lowContrastPhoto" - | "highContrastPhoto" - | "flatIllustration" - | "lineArt" - | "textOrUi" - | "pixelArt" - | "unknown"; - -export interface ImageStyleMetrics { - sampleCount: number; - uniqueColorRatio: number; - topColorCoverage: number; - paletteEntropy: number; - flatRatio: number; - softChangeRatio: number; - strongEdgeRatio: number; - edgeDensity: number; - horizontalEdgeRatio: number; - verticalEdgeRatio: number; - lumaStdDev: number; - saturationMean: number; - saturationStdDev: number; - darkRatio: number; - lightRatio: number; - grayRatio: number; - highSaturationRatio: number; - photoTileRatio: number; - flatTileRatio: number; - textTileRatio: number; - gradientTileRatio: number; - transparentRatio: number; -} - -export interface ImageStyleClassification { - style: ImageStyle; - kind: ImageKind; - kindScores: Record; - confidence: number; - photoScore: number; - metrics: ImageStyleMetrics; -} - -export interface ClassifyImageStyleOptions { - /** - * Longest side of the internal sample grid. Larger values are slower but can - * preserve more detail for tiny repeated patterns. - */ - maxSampleDimension?: number; - - /** Pixels at or below this alpha value are ignored. */ - transparentAlphaThreshold?: number; - - /** - * Decision threshold for `photoScore`. Raise it to classify ambiguous images - * as illustrations more often, lower it to classify them as photos more often. - */ - photoThreshold?: number; -} - -interface Sample { - visible: boolean; - red: number; - green: number; - blue: number; - luma: number; - saturation: number; -} - -const DEFAULT_MAX_SAMPLE_DIMENSION = 160; -const DEFAULT_ALPHA_THRESHOLD = 16; -const DEFAULT_PHOTO_THRESHOLD = 0.5; - -const EMPTY_METRICS: ImageStyleMetrics = { - sampleCount: 0, - uniqueColorRatio: 0, - topColorCoverage: 0, - paletteEntropy: 0, - flatRatio: 0, - softChangeRatio: 0, - strongEdgeRatio: 0, - edgeDensity: 0, - horizontalEdgeRatio: 0, - verticalEdgeRatio: 0, - lumaStdDev: 0, - saturationMean: 0, - saturationStdDev: 0, - darkRatio: 0, - lightRatio: 0, - grayRatio: 0, - highSaturationRatio: 0, - photoTileRatio: 0, - flatTileRatio: 0, - textTileRatio: 0, - gradientTileRatio: 0, - transparentRatio: 0, -}; - -const EMPTY_KIND_SCORES: Record = { - photo: 0, - lowContrastPhoto: 0, - highContrastPhoto: 0, - flatIllustration: 0, - lineArt: 0, - textOrUi: 0, - pixelArt: 0, - unknown: 1, -}; - -/** - * Heuristically classify image data as a photo or an illustration. - * - * This does not use ML. It looks for signals that usually separate photos from - * illustrations: color diversity, soft tonal changes, flat-color regions, edge - * density, and saturation/luminance variation. - */ -export function classifyImageStyle( - image: ImageDataLike, - options: ClassifyImageStyleOptions = {} -): ImageStyleClassification { - validateImageData(image); - - const width = image.width; - const height = image.height; - const maxSampleDimension = Math.max( - 1, - Math.floor(options.maxSampleDimension ?? DEFAULT_MAX_SAMPLE_DIMENSION) - ); - const alphaThreshold = - options.transparentAlphaThreshold ?? DEFAULT_ALPHA_THRESHOLD; - const photoThreshold = options.photoThreshold ?? DEFAULT_PHOTO_THRESHOLD; - - const scale = Math.min(1, maxSampleDimension / Math.max(width, height)); - const sampleWidth = Math.max(1, Math.round(width * scale)); - const sampleHeight = Math.max(1, Math.round(height * scale)); - const samples = new Array(sampleWidth * sampleHeight); - const colorCounts = new Map(); - - let visibleCount = 0; - let transparentCount = 0; - let lumaSum = 0; - let lumaSquareSum = 0; - let saturationSum = 0; - let saturationSquareSum = 0; - let darkCount = 0; - let lightCount = 0; - let grayCount = 0; - let highSaturationCount = 0; - - for (let y = 0; y < sampleHeight; y += 1) { - const sourceY = Math.min( - height - 1, - Math.floor((y / sampleHeight) * height) - ); - - for (let x = 0; x < sampleWidth; x += 1) { - const sourceX = Math.min( - width - 1, - Math.floor((x / sampleWidth) * width) - ); - const sourceIndex = (sourceY * width + sourceX) * 4; - const alpha = image.data[sourceIndex + 3] ?? 255; - - if (alpha <= alphaThreshold) { - transparentCount += 1; - samples[y * sampleWidth + x] = { - visible: false, - red: 0, - green: 0, - blue: 0, - luma: 0, - saturation: 0, - }; - continue; - } - - const red = image.data[sourceIndex]; - const green = image.data[sourceIndex + 1]; - const blue = image.data[sourceIndex + 2]; - const luma = red * 0.2126 + green * 0.7152 + blue * 0.0722; - const saturation = getSaturation(red, green, blue); - - visibleCount += 1; - lumaSum += luma; - lumaSquareSum += luma * luma; - saturationSum += saturation; - saturationSquareSum += saturation * saturation; - if (luma <= 36) darkCount += 1; - if (luma >= 220) lightCount += 1; - if (saturation <= 0.08) grayCount += 1; - if (saturation >= 0.72) highSaturationCount += 1; - const colorKey = getQuantizedColorKey(red, green, blue); - colorCounts.set(colorKey, (colorCounts.get(colorKey) ?? 0) + 1); - - samples[y * sampleWidth + x] = { - visible: true, - red, - green, - blue, - luma, - saturation, - }; - } - } - - if (visibleCount === 0) { - return { - style: "unknown", - kind: "unknown", - kindScores: { ...EMPTY_KIND_SCORES }, - confidence: 0, - photoScore: 0, - metrics: { - ...EMPTY_METRICS, - transparentRatio: transparentCount / samples.length, - }, - }; - } - - const neighborMetrics = getNeighborMetrics(samples, sampleWidth, sampleHeight); - const colorMetrics = getColorDistributionMetrics(colorCounts, visibleCount); - const edgeMetrics = getEdgeMetrics(samples, sampleWidth, sampleHeight); - const tileMetrics = getTileMetrics(samples, sampleWidth, sampleHeight); - const lumaMean = lumaSum / visibleCount; - const saturationMean = saturationSum / visibleCount; - const metrics: ImageStyleMetrics = { - sampleCount: visibleCount, - uniqueColorRatio: colorCounts.size / visibleCount, - topColorCoverage: colorMetrics.topColorCoverage, - paletteEntropy: colorMetrics.paletteEntropy, - flatRatio: neighborMetrics.flatRatio, - softChangeRatio: neighborMetrics.softChangeRatio, - strongEdgeRatio: neighborMetrics.strongEdgeRatio, - edgeDensity: edgeMetrics.edgeDensity, - horizontalEdgeRatio: edgeMetrics.horizontalEdgeRatio, - verticalEdgeRatio: edgeMetrics.verticalEdgeRatio, - lumaStdDev: Math.sqrt( - Math.max(0, lumaSquareSum / visibleCount - lumaMean * lumaMean) - ), - saturationMean, - saturationStdDev: Math.sqrt( - Math.max( - 0, - saturationSquareSum / visibleCount - saturationMean * saturationMean - ) - ), - darkRatio: darkCount / visibleCount, - lightRatio: lightCount / visibleCount, - grayRatio: grayCount / visibleCount, - highSaturationRatio: highSaturationCount / visibleCount, - photoTileRatio: tileMetrics.photoTileRatio, - flatTileRatio: tileMetrics.flatTileRatio, - textTileRatio: tileMetrics.textTileRatio, - gradientTileRatio: tileMetrics.gradientTileRatio, - transparentRatio: transparentCount / samples.length, - }; - - const photoScore = getPhotoScore(metrics); - const confidence = clamp01(Math.abs(photoScore - photoThreshold) * 2); - const style = photoScore >= photoThreshold ? "photo" : "illustration"; - const kindScores = getImageKindScores(metrics, photoScore); - const kind = getBestKind(kindScores); - - return { - style, - kind, - kindScores, - confidence, - photoScore, - metrics, - }; -} - -export function classifyCanvasImageStyle( - canvas: CanvasLike, - options: ClassifyImageStyleOptions = {} -): ImageStyleClassification { - const ctx = canvas.getContext("2d"); - - if (!ctx) { - throw new Error("Unable to read image data from canvas."); - } - - return classifyImageStyle( - ctx.getImageData(0, 0, canvas.width, canvas.height), - options - ); -} - -export function isPhotoImage( - image: ImageDataLike, - options: ClassifyImageStyleOptions = {} -): boolean { - return classifyImageStyle(image, options).style === "photo"; -} - -export function isIllustrationImage( - image: ImageDataLike, - options: ClassifyImageStyleOptions = {} -): boolean { - return classifyImageStyle(image, options).style === "illustration"; -} - -function validateImageData(image: ImageDataLike) { - if (!image || image.width <= 0 || image.height <= 0) { - throw new Error("Image data must have a positive width and height."); - } - - if (!image.data || image.data.length < image.width * image.height * 4) { - throw new Error("Image data does not contain enough RGBA pixel data."); - } -} - -function getNeighborMetrics( - samples: Sample[], - width: number, - height: number -): Pick { - let neighborCount = 0; - let flatCount = 0; - let softChangeCount = 0; - let strongEdgeCount = 0; - - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const sample = samples[y * width + x]; - - if (!sample.visible) { - continue; - } - - if (x + 1 < width) { - const neighbor = samples[y * width + x + 1]; - if (neighbor.visible) { - neighborCount += 1; - const difference = getColorDifference(sample, neighbor); - if (difference <= 4) { - flatCount += 1; - } else if (difference <= 28) { - softChangeCount += 1; - } else { - strongEdgeCount += 1; - } - } - } - - if (y + 1 < height) { - const neighbor = samples[(y + 1) * width + x]; - if (neighbor.visible) { - neighborCount += 1; - const difference = getColorDifference(sample, neighbor); - if (difference <= 4) { - flatCount += 1; - } else if (difference <= 28) { - softChangeCount += 1; - } else { - strongEdgeCount += 1; - } - } - } - } - } - - if (neighborCount === 0) { - return { - flatRatio: 1, - softChangeRatio: 0, - strongEdgeRatio: 0, - }; - } - - return { - flatRatio: flatCount / neighborCount, - softChangeRatio: softChangeCount / neighborCount, - strongEdgeRatio: strongEdgeCount / neighborCount, - }; -} - -function getColorDistributionMetrics( - colorCounts: Map, - sampleCount: number -): Pick { - if (sampleCount === 0) { - return { - topColorCoverage: 0, - paletteEntropy: 0, - }; - } - - const counts = [...colorCounts.values()].sort((a, b) => b - a); - const topCount = counts - .slice(0, 8) - .reduce((total, count) => total + count, 0); - const entropy = counts.reduce((total, count) => { - const probability = count / sampleCount; - return total - probability * Math.log2(probability); - }, 0); - const maxEntropy = Math.log2(Math.max(2, colorCounts.size)); - - return { - topColorCoverage: topCount / sampleCount, - paletteEntropy: maxEntropy === 0 ? 0 : entropy / maxEntropy, - }; -} - -function getEdgeMetrics( - samples: Sample[], - width: number, - height: number -): Pick< - ImageStyleMetrics, - "edgeDensity" | "horizontalEdgeRatio" | "verticalEdgeRatio" -> { - let checkedCount = 0; - let edgeCount = 0; - let horizontalCount = 0; - let verticalCount = 0; - - for (let y = 1; y < height - 1; y += 1) { - for (let x = 1; x < width - 1; x += 1) { - const center = samples[y * width + x]; - const left = samples[y * width + x - 1]; - const right = samples[y * width + x + 1]; - const up = samples[(y - 1) * width + x]; - const down = samples[(y + 1) * width + x]; - - if ( - !center.visible || - !left.visible || - !right.visible || - !up.visible || - !down.visible - ) { - continue; - } - - checkedCount += 1; - const dx = Math.abs(right.luma - left.luma); - const dy = Math.abs(down.luma - up.luma); - const magnitude = Math.sqrt(dx * dx + dy * dy); - - if (magnitude >= 42) { - edgeCount += 1; - if (dy > dx * 1.2) { - horizontalCount += 1; - } else if (dx > dy * 1.2) { - verticalCount += 1; - } - } - } - } - - if (checkedCount === 0 || edgeCount === 0) { - return { - edgeDensity: 0, - horizontalEdgeRatio: 0, - verticalEdgeRatio: 0, - }; - } - - return { - edgeDensity: edgeCount / checkedCount, - horizontalEdgeRatio: horizontalCount / edgeCount, - verticalEdgeRatio: verticalCount / edgeCount, - }; -} - -function getTileMetrics( - samples: Sample[], - width: number, - height: number -): Pick< - ImageStyleMetrics, - "photoTileRatio" | "flatTileRatio" | "textTileRatio" | "gradientTileRatio" -> { - const tileSize = Math.max(8, Math.floor(Math.min(width, height) / 10)); - let tileCount = 0; - let photoTileCount = 0; - let flatTileCount = 0; - let textTileCount = 0; - let gradientTileCount = 0; - - for (let tileY = 0; tileY < height; tileY += tileSize) { - for (let tileX = 0; tileX < width; tileX += tileSize) { - const tile = getTileStats(samples, width, height, tileX, tileY, tileSize); - if (!tile) continue; - - tileCount += 1; - - if ( - tile.edgeDensity >= 0.16 && - tile.grayRatio >= 0.55 && - tile.lumaStdDev >= 38 - ) { - textTileCount += 1; - } - - if (tile.uniqueColorRatio <= 0.12 && tile.flatRatio >= 0.62) { - flatTileCount += 1; - } - - if ( - tile.uniqueColorRatio >= 0.18 && - tile.lumaStdDev >= 18 && - tile.flatRatio <= 0.68 - ) { - photoTileCount += 1; - } - - if ( - tile.softChangeRatio >= 0.38 && - tile.strongEdgeRatio <= 0.16 && - tile.lumaStdDev >= 12 - ) { - gradientTileCount += 1; - } - } - } - - if (tileCount === 0) { - return { - photoTileRatio: 0, - flatTileRatio: 0, - textTileRatio: 0, - gradientTileRatio: 0, - }; - } - - return { - photoTileRatio: photoTileCount / tileCount, - flatTileRatio: flatTileCount / tileCount, - textTileRatio: textTileCount / tileCount, - gradientTileRatio: gradientTileCount / tileCount, - }; -} - -function getTileStats( - samples: Sample[], - width: number, - height: number, - tileX: number, - tileY: number, - tileSize: number -) { - const colors = new Set(); - let visibleCount = 0; - let grayCount = 0; - let lumaSum = 0; - let lumaSquareSum = 0; - let neighborCount = 0; - let flatCount = 0; - let softChangeCount = 0; - let strongEdgeCount = 0; - - const maxY = Math.min(height, tileY + tileSize); - const maxX = Math.min(width, tileX + tileSize); - - for (let y = tileY; y < maxY; y += 1) { - for (let x = tileX; x < maxX; x += 1) { - const sample = samples[y * width + x]; - if (!sample.visible) continue; - - visibleCount += 1; - lumaSum += sample.luma; - lumaSquareSum += sample.luma * sample.luma; - if (sample.saturation <= 0.08) grayCount += 1; - colors.add(getQuantizedColorKey(sample.red, sample.green, sample.blue)); - - if (x + 1 < maxX) { - const neighbor = samples[y * width + x + 1]; - if (neighbor.visible) { - const difference = getColorDifference(sample, neighbor); - neighborCount += 1; - if (difference <= 4) flatCount += 1; - else if (difference <= 28) softChangeCount += 1; - else strongEdgeCount += 1; - } - } - - if (y + 1 < maxY) { - const neighbor = samples[(y + 1) * width + x]; - if (neighbor.visible) { - const difference = getColorDifference(sample, neighbor); - neighborCount += 1; - if (difference <= 4) flatCount += 1; - else if (difference <= 28) softChangeCount += 1; - else strongEdgeCount += 1; - } - } - } - } - - if (visibleCount < Math.max(12, (tileSize * tileSize) / 4)) return null; - - const lumaMean = lumaSum / visibleCount; - const strongEdgeRatio = neighborCount === 0 ? 0 : strongEdgeCount / neighborCount; - - return { - uniqueColorRatio: colors.size / visibleCount, - grayRatio: grayCount / visibleCount, - flatRatio: neighborCount === 0 ? 1 : flatCount / neighborCount, - softChangeRatio: neighborCount === 0 ? 0 : softChangeCount / neighborCount, - strongEdgeRatio, - edgeDensity: strongEdgeRatio, - lumaStdDev: Math.sqrt( - Math.max(0, lumaSquareSum / visibleCount - lumaMean * lumaMean) - ), - }; -} - -function getPhotoScore(metrics: ImageStyleMetrics): number { - const uniqueScore = normalize(metrics.uniqueColorRatio, 0.08, 0.35); - const softChangeScore = normalize(metrics.softChangeRatio, 0.18, 0.48); - const textureScore = normalize(1 - metrics.flatRatio, 0.2, 0.65); - const lumaScore = normalize(metrics.lumaStdDev, 24, 72); - const saturationSpreadScore = normalize(metrics.saturationStdDev, 0.08, 0.26); - const entropyScore = normalize(metrics.paletteEntropy, 0.55, 0.9); - const photoTileScore = normalize(metrics.photoTileRatio, 0.18, 0.62); - const desaturatedPhotoScore = Math.min( - normalize(metrics.grayRatio, 0.45, 0.75), - normalize(metrics.photoTileRatio, 0.34, 0.58), - normalize(metrics.lumaStdDev, 48, 76), - normalize(1 - metrics.flatRatio, 0.34, 0.58), - normalize(metrics.paletteEntropy, 0.62, 0.88) - ); - const flatIllustrationPenalty = normalize(metrics.flatRatio, 0.55, 0.88); - const topColorPenalty = normalize(metrics.topColorCoverage, 0.45, 0.86); - const hardEdgePenalty = - metrics.flatRatio > 0.35 - ? normalize(metrics.strongEdgeRatio, 0.28, 0.58) - : 0; - - return clamp01( - uniqueScore * 0.34 + - softChangeScore * 0.22 + - textureScore * 0.16 + - lumaScore * 0.1 + - saturationSpreadScore * 0.06 + - entropyScore * 0.07 + - photoTileScore * 0.13 + - desaturatedPhotoScore * 0.24 - - flatIllustrationPenalty * 0.12 - - topColorPenalty * 0.1 - - hardEdgePenalty * 0.08 - ); -} - -function getImageKindScores( - metrics: ImageStyleMetrics, - photoScore: number -): Record { - const contrastEndpointRatio = metrics.darkRatio + metrics.lightRatio; - const photoTileLineArtPenalty = normalize(metrics.photoTileRatio, 0.32, 0.58); - - return { - photo: clamp01( - photoScore * 0.55 + - normalize(metrics.photoTileRatio, 0.12, 0.62) * 0.25 + - normalize(metrics.paletteEntropy, 0.55, 0.9) * 0.12 + - normalize(metrics.softChangeRatio, 0.22, 0.48) * 0.08 - ), - lowContrastPhoto: clamp01( - photoScore * 0.38 + - normalize(34 - metrics.lumaStdDev, 0, 22) * 0.34 + - normalize(metrics.gradientTileRatio, 0.16, 0.55) * 0.18 + - normalize(metrics.softChangeRatio, 0.24, 0.5) * 0.1 - ), - highContrastPhoto: clamp01( - photoScore * 0.42 + - normalize(metrics.lumaStdDev, 58, 92) * 0.3 + - normalize(contrastEndpointRatio, 0.18, 0.42) * 0.18 + - normalize(metrics.photoTileRatio, 0.18, 0.58) * 0.1 - ), - flatIllustration: clamp01( - (1 - photoScore) * 0.32 + - normalize(metrics.flatRatio, 0.52, 0.9) * 0.2 + - normalize(metrics.topColorCoverage, 0.38, 0.85) * 0.22 + - normalize(metrics.flatTileRatio, 0.18, 0.72) * 0.18 + - normalize(metrics.highSaturationRatio, 0.08, 0.38) * 0.08 - ), - lineArt: clamp01( - normalize(metrics.grayRatio, 0.48, 0.9) * 0.28 + - normalize(metrics.edgeDensity, 0.05, 0.22) * 0.24 + - normalize(metrics.flatRatio, 0.5, 0.86) * 0.18 + - normalize(metrics.topColorCoverage, 0.45, 0.9) * 0.18 + - normalize(0.16 - metrics.highSaturationRatio, 0, 0.16) * 0.12 - - photoTileLineArtPenalty * 0.14 - ), - textOrUi: clamp01( - normalize(metrics.textTileRatio, 0.05, 0.35) * 0.32 + - normalize(metrics.edgeDensity, 0.06, 0.24) * 0.22 + - normalize(metrics.grayRatio, 0.42, 0.86) * 0.16 + - normalize(metrics.topColorCoverage, 0.42, 0.86) * 0.16 + - normalize(metrics.flatTileRatio, 0.12, 0.58) * 0.14 - ), - pixelArt: clamp01( - normalize(metrics.flatRatio, 0.62, 0.94) * 0.25 + - normalize(metrics.topColorCoverage, 0.5, 0.92) * 0.24 + - normalize(metrics.flatTileRatio, 0.25, 0.82) * 0.2 + - normalize(metrics.highSaturationRatio, 0.08, 0.45) * 0.16 + - normalize(0.22 - metrics.softChangeRatio, 0, 0.22) * 0.15 - ), - unknown: 0, - }; -} - -function getBestKind(scores: Record): ImageKind { - return Object.entries(scores).reduce( - (best, current) => (current[1] > best[1] ? current : best), - ["unknown", -Infinity] - )[0] as ImageKind; -} - -function getColorDifference(a: Sample, b: Sample): number { - const red = a.red - b.red; - const green = a.green - b.green; - const blue = a.blue - b.blue; - - return Math.sqrt(red * red + green * green + blue * blue); -} - -function getSaturation(red: number, green: number, blue: number): number { - const normalizedRed = red / 255; - const normalizedGreen = green / 255; - const normalizedBlue = blue / 255; - const max = Math.max(normalizedRed, normalizedGreen, normalizedBlue); - const min = Math.min(normalizedRed, normalizedGreen, normalizedBlue); - - if (max === 0) { - return 0; - } - - return (max - min) / max; -} - -function getQuantizedColorKey(red: number, green: number, blue: number): number { - return ((red >> 3) << 10) | ((green >> 3) << 5) | (blue >> 3); -} - -function normalize(value: number, min: number, max: number): number { - if (max <= min) { - return value >= max ? 1 : 0; - } - - return clamp01((value - min) / (max - min)); -} - -function clamp01(value: number): number { - return Math.min(1, Math.max(0, value)); -} diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/index.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/index.ts deleted file mode 100644 index f9ec4154..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/index.ts +++ /dev/null @@ -1,122 +0,0 @@ -// @ts-nocheck -import palettes from "./dither/data/default-palettes.json"; -import { - getNamedEntries, - getNamedColors, - getNamedDeviceColors, - type PaletteColorEntry, - type PaletteRegistry, -} from "./dither/functions/palette-order"; - -const paletteRegistry = palettes as PaletteRegistry; - -/** - * Retrieve a named default palette as combined color entries. - * - * `color` is the calibrated/display color used for dithering. - * `deviceColor` is the native device color that should replace it before export. - */ -function getDefaultPalette( - name: string, - deviceColorsName = name -): PaletteColorEntry[] { - const entries = getNamedEntries(paletteRegistry, name); - if (deviceColorsName === name) { - return entries.map((entry) => ({ ...entry })); - } - - const deviceEntries = getNamedEntries(paletteRegistry, deviceColorsName); - const deviceColorByRole = new Map( - deviceEntries.map((entry) => [entry.name, entry.deviceColor]) - ); - - return entries.map((entry) => ({ - ...entry, - deviceColor: deviceColorByRole.get(entry.name) ?? entry.deviceColor, - })); -} - -/** - * Retrieve a named default palette (hex codes). - * This is used for dithering images to fit the eInk display and uses the real colors of the display. - */ -export function getDefaultPalettes(name: string): string[] { - return getNamedColors(paletteRegistry, name); -} -/** - * Retrieve a named default device color set that is used for displaying the colors on the eInk display. - */ -export function getDeviceColors(name: string): string[] { - return getNamedDeviceColors(paletteRegistry, name); -} - -/** - * Retrieve device colors sorted to match the role order of a selected palette. - */ -export function getDeviceColorsForPalette( - paletteName: string, - deviceColorsName: string -): string[] { - return getDefaultPalette(paletteName, deviceColorsName).map( - (entry) => entry.deviceColor - ); -} - -export const defaultPalette = getDefaultPalette("default"); -export const gameboyPalette = getDefaultPalette("gameboy"); -export const spectra6legacyPalette = getDefaultPalette("spectra6legacy"); -export const spectra6Palette = getDefaultPalette("spectra6"); -export const aitjcizeSpectra6Palette = getDefaultPalette("aitjcize-spectra6"); -export const acepPalette = getDefaultPalette("acep"); - -export { replaceColors } from "./replaceColors/replaceColors"; -export type { - ReplaceColorsOptions, - ReplaceColorsPalette, -} from "./replaceColors/replaceColors"; - -export { ditherImage } from "./dither/dither"; -export { - getProcessingPreset, - getProcessingPresetNames, - getProcessingPresetOptions, - PROCESSING_PRESETS, -} from "./dither/processing"; -export { - classifyCanvasImageStyle, - classifyImageStyle, - isIllustrationImage, - isPhotoImage, -} from "./image-style"; -export { - suggestCanvasProcessingOptions, - suggestProcessingOptions, -} from "./auto-processing"; - -export type { - ColorMatchingMode, - CanvasLike, - DitherImageOptions, - DynamicRangeCompressionOptions, - ImageDataLike, - ImageProcessingOptions, - LevelCompressionOptions, - LevelCompressionMode, - ProcessingPreset, - ProcessingPresetName, - ToneMappingMode, - ToneMappingOptions, -} from "./dither/dither"; -export type { - ClassifyImageStyleOptions, - ImageKind, - ImageStyle, - ImageStyleClassification, - ImageStyleMetrics, -} from "./image-style"; -export type { - AutoProcessingIntent, - ProcessingSuggestion, - SuggestProcessingOptionsInput, -} from "./auto-processing"; -export type { PaletteColorEntry } from "./dither/functions/palette-order"; diff --git a/eink_layout_manager/frontend/src/lib/epdoptimize/replaceColors/replaceColors.ts b/eink_layout_manager/frontend/src/lib/epdoptimize/replaceColors/replaceColors.ts deleted file mode 100644 index 96265e59..00000000 --- a/eink_layout_manager/frontend/src/lib/epdoptimize/replaceColors/replaceColors.ts +++ /dev/null @@ -1,109 +0,0 @@ -// @ts-nocheck -import type { PaletteColorEntry } from "../dither/functions/palette-order"; - -type RGB = [number, number, number]; - -export interface ReplaceColorsOptions { - originalColors: string[]; - replaceColors: string[]; -} - -export type ReplaceColorsPalette = Pick< - PaletteColorEntry, - "color" | "deviceColor" ->[]; - -const hexToRgb = (h: string): RGB => { - const rgb = h - .replace( - /^#?([a-f\d])([a-f\d])([a-f\d])$/i, - (_, r, g, b) => "#" + r + r + g + g + b + b - ) - .substring(1) - .match(/.{2}/g) - ?.map((x) => parseInt(x, 16)); - - if (!rgb || rgb.length !== 3 || rgb.some((channel) => Number.isNaN(channel))) { - throw new Error(`Invalid hex color: ${h}`); - } - - return rgb as RGB; -}; - -const colorKey = (rgb: RGB) => rgb.join(","); - -const isPaletteEntryArray = ( - palette: ReplaceColorsPalette | ReplaceColorsOptions -): palette is ReplaceColorsPalette => - Array.isArray(palette) && - palette.every( - (entry) => - typeof entry === "object" && - entry !== null && - "color" in entry && - "deviceColor" in entry - ); - -const createReplacementMap = ( - palette: ReplaceColorsPalette | ReplaceColorsOptions -) => { - const entries = isPaletteEntryArray(palette) - ? palette - : palette.originalColors.map((color, index) => ({ - color, - deviceColor: palette.replaceColors[index], - })); - - return new Map( - entries - .filter((entry) => Boolean(entry.deviceColor)) - .map((entry) => [ - colorKey(hexToRgb(entry.color)), - hexToRgb(entry.deviceColor), - ]) - ); -}; - -export const replaceColors = ( - fromCanvas: HTMLCanvasElement, - destCanvas: HTMLCanvasElement, - palette: ReplaceColorsPalette | ReplaceColorsOptions -) => { - const fromCtx = fromCanvas.getContext("2d"); - if (!fromCtx) return; - - const width = fromCanvas.width; - const height = fromCanvas.height; - - const destCtx = destCanvas.getContext("2d"); - if (!destCtx) return; - - const imageData = fromCtx.getImageData(0, 0, width, height); - let errorColors = 0; - const replacementMap = createReplacementMap(palette); - - for (let i = 0; i < imageData.data.length; i += 4) { - const replacement = replacementMap.get( - `${imageData.data[i]},${imageData.data[i + 1]},${imageData.data[i + 2]}` - ); - - if (!replacement) { - errorColors++; - continue; - } - - imageData.data[i] = replacement[0]; - imageData.data[i + 1] = replacement[1]; - imageData.data[i + 2] = replacement[2]; - } - - if (errorColors > 0) { - console.warn( - `replaceColors: ${errorColors} pixels were not replaced. Check if the colors match exactly.` - ); - } - - destCanvas.width = width; - destCanvas.height = height; - destCtx.putImageData(imageData, 0, 0); -}; diff --git a/eink_layout_manager/frontend/tsconfig.json b/eink_layout_manager/frontend/tsconfig.json index 2117f3fe..38a639f8 100644 --- a/eink_layout_manager/frontend/tsconfig.json +++ b/eink_layout_manager/frontend/tsconfig.json @@ -23,6 +23,6 @@ } }, "include": ["src/**/*.ts", "src/**/*.js"], - "exclude": ["src/lib/epdoptimize/**/*"], + "exclude": [], "references": [{ "path": "./tsconfig.node.json" }] } From 846233b3ee1929ec6c0a1a56b27bcb596f7e5640 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Fri, 24 Apr 2026 11:42:08 +0100 Subject: [PATCH 2/2] chore: update Dockerfile to handle shared epdoptimize build --- eink_layout_manager/Dockerfile | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/eink_layout_manager/Dockerfile b/eink_layout_manager/Dockerfile index cc689908..64cbc2df 100644 --- a/eink_layout_manager/Dockerfile +++ b/eink_layout_manager/Dockerfile @@ -2,9 +2,11 @@ ARG BUILD_FROM="ghcr.io/home-assistant/base-python:3.13-alpine3.23" # Stage 0: Node.js Frontend Build FROM node:24.15-alpine AS node-builder -WORKDIR /frontend -COPY frontend . -RUN npm install && npm run build +WORKDIR /app +COPY epdoptimize ./epdoptimize +COPY frontend ./frontend +RUN cd epdoptimize && npm install && npm run build +RUN cd frontend && npm install && npm run build # Stage 1: Build stage FROM $BUILD_FROM AS builder @@ -30,7 +32,7 @@ RUN \ # Install converter dependencies WORKDIR /converter -COPY epdoptimize /epdoptimize +COPY --from=node-builder /app/epdoptimize /epdoptimize COPY converter/package.json converter/package-lock.json* ./ RUN npm install COPY converter . @@ -84,7 +86,7 @@ COPY backend /backend COPY --from=builder /converter /converter # Copy built frontend assets from node-builder -COPY --from=node-builder /frontend/dist /backend/static_dist +COPY --from=node-builder /app/frontend/dist /backend/static_dist # Cleanup development/test files to keep image small RUN rm -rf /backend/tests /backend/requirements_test.txt