Files
Pixelcanvas.io-overlay/src/UI/importMural.tsx
T
2024-02-15 19:29:12 +01:00

482 lines
15 KiB
TypeScript

import React from "react";
import { Mural, RGB } from "../interfaces";
import { canvasToImageData, get2DArrHeight, get2DArrWidth, getColorScore, getExtension, imageDataToPaletteIndices, imageToCanvas, loadImageSource, processNumberEvent, readAsDataUrl, readAsString, resize, rgb, validateMural } from "../utils";
import styled from "styled-components";
import { Popup } from "./components/Popup";
import RgbQuant, { DitheringKernel, RGBQuantOptions } from "rgbquant";
import { Border, Btn, Input } from "./styles";
import { CanvasToCanvasJSX } from "./components/canvasToCanvasJSX";
import { Palette } from "../palette";
import { FileInput } from "./Fileinput";
import { Store } from "../store";
import { Coordinates } from "../coordinates";
export const TEXT_FORMATS = ["muraljson", "json"];
enum RetrieveType {
Uint8Array = 1,
IndexedArray = 2,
}
const LOW_ALPHA = 25;
const Flex = styled.div`
width: 100%;
//max-width: fit-content;
display: flex;
flex-direction: row;
flex-wrap: wrap;
`;
const Flex2 = styled.div`
display: flex;
flex-direction: column;
flex-wrap: wrap;
border: 1px solid white;
cursor: pointer;
margin: 2px;
padding: 2px;
text-align: center;
align-items: center;
`;
type DitheringKernelEx = DitheringKernel | "Flat";
interface QuantResult {
type: DitheringKernelEx;
canvas: HTMLCanvasElement;
indices: number[][];
}
type DitherSetting = DitheringKernel | "Flat" | "show-all";
const KERNELS: DitheringKernel[] = [
"FloydSteinberg",
"FalseFloydSteinberg",
"Stucki",
"Atkinson",
"Jarvis",
"Burkes",
"Sierra",
"TwoSierra",
"SierraLite",
];
const ditherSettings: DitherSetting[] = [
"Flat",
...KERNELS
];
export interface ImageToMuralOptions {
height: number;
width: number;
noShrinking: boolean;
quantizerSetting: DitherSetting;
}
export async function importArtWork(store: Store, cords: Coordinates, palette: Palette) {
const file = await importFile();
if (file) {
if (file.type === "mural") {
return file.data as Mural;
} else {
const img = file.data as HTMLImageElement;
const pixels = await imageToMural(img, palette);
return new Promise<Mural>((resolve, reject)=> {
store.setOverlayModify({
pixels,
muralObj: {
name: img.alt,
x: cords.ux - (get2DArrWidth(pixels) / 2),
y: cords.uy - (get2DArrHeight(pixels) / 2),
},
cb: (name, x, y, confirm) => {
if (confirm) {
resolve({ name, pixels, x, y });
} else {
reject(new Error("Canceled by user"));
}
}
});
});
}
}
}
export function getQuantizedObjFromUser(quantized: QuantResult[]) {
return new Promise<QuantResult | null>(r => {
Popup.custom(
<div>
<h4>Dither results</h4>
<div>Pick a result that fits best in your situation</div>
<Flex>
{quantized.map((q, i) => {
return (
<div key={i}>
<Flex2 style={{flexDirection :"column"}}
onClick={() => {
Popup.close();
r(q);
}}
>
{ditheringKernelToName(q.type)}
<Border>
<CanvasToCanvasJSX canvas={q.canvas} />
</Border>
</Flex2>
</div>
);
})}
</Flex>
</div>,
[{ content: "Cancel", click: () => {} }],
).finally(() => r(null));
});
}
async function imageToMural(image: HTMLImageElement, palette: Palette) {
const settings = await getImageSettingFromUser(image);
const quantized = await imageToQuantized(image, settings, palette);
let selector: QuantResult | null | undefined;
if (Array.isArray(quantized)) {
selector = await getQuantizedObjFromUser(quantized);
} else {
selector = quantized;
}
if (!selector) {
throw new Error("User did not pick anything");
}
const imageData = canvasToImageData(selector.canvas);
flatQuantizeImageData(imageData, palette);
return imageDataToPaletteIndices(imageData, palette.palette);
}
export async function getImageSettingFromUser(image: HTMLImageElement): Promise<ImageToMuralOptions> {
let width = image.width;
let height = image.height;
let useQuantizer: DitherSetting = "show-all";
let noShrinking = false;
let canceled = false;
await new Promise<void>((resolve, reject) => {
Popup.custom(
<div>
<h4>Image import</h4>
<div>
<small>{image.alt}</small>
</div>
<div style={{ whiteSpace: "pre-wrap" }}>
You are importing image with size {image.width}x{image.height}.{"\n"}
Do you want to preform any image manipulations?
</div>
</div>,
[
{
click: () => {
noShrinking = false;
resolve();
},
content: "Resize",
},
{
click: () => {
noShrinking = true;
resolve();
},
content: "Import as it is",
},
{
click: () => {
canceled = true;
resolve();
},
content: "Cancel",
},
],
).then(() => {
reject(new Error("User input has been interrupted"));
});
});
if (canceled) {
throw new Error("Operation canceled by user");
}
if (!noShrinking) {
await new Promise<void>((resolve, reject) => {
Popup.custom(
<div>
<h4>Image import</h4>
<div>
<small>{image.alt}</small>
</div>
<div>
<strong>
Original size: {image.width}x{image.height}
</strong>
</div>
<Input
type='number'
min={1}
onChange={ev => {processNumberEvent(ev, n => {width = n;});}}
/>
x
<Input
type='number'
min={1}
onChange={ev => {processNumberEvent(ev, n => {height = n;});}}
/>
</div>,
[
{
content: "Confirm",
click: () => {
resolve();
},
},
{
content: "Cancel",
click: () => {
canceled = true;
resolve();
},
},
],
).then(() => reject(new Error("User input has been interrupted")));
});
}
if (canceled) {
throw new Error("Operation canceled by user");
}
await new Promise<void>((resolve, reject) => {
Popup.custom(
<div>
<h4>Dither Settings</h4>
<div>Pickup dither algorithm</div>
<Flex>
{ditherSettings.map((q, i) => {
return (
<Btn
key={i}
onClick={() => {
useQuantizer = q;
Popup.close();
resolve();
}}
>
{ditheringKernelToName(q)}
</Btn>
);
})}
</Flex>
</div>,
[
{
content: "Show all",
click: () => {
useQuantizer = "show-all";
resolve();
},
},
{
content: "Cancel",
click: () => {
canceled = true;
resolve();
},
},
],
).then(() => reject(new Error("User input has been interrupted")));
});
if (canceled) {
throw new Error("Operation canceled by user");
}
return { height, width, noShrinking, quantizerSetting: useQuantizer };
}
export async function imageToQuantized(image: HTMLImageElement, options: ImageToMuralOptions, palette: Palette) {
if (!options.noShrinking) {
image = await resize(image, options.width, options.height);
}
const canvas = imageToCanvas(image);
if (options.quantizerSetting === "show-all") {
return quantizeAll(canvas, palette);
}
return quantizeOne(canvas, options.quantizerSetting, palette);
}
function prepareQuant(canvas: HTMLCanvasElement, palette: Palette) {
const data = new RgbQuant(createQuantOptions(palette)) as RgbQuant;
data.sample(canvas);
return data;
}
function createQuantOptions(palette: Palette): RGBQuantOptions {
return {
palette: palette.palette.map(c => [c.r, c.g, c.b]),
minHueCols: 0,
dithSerp: false,
};
}
export function quantizeAll(canvas: HTMLCanvasElement, palette: Palette) {
const images: QuantResult[] = [quantizeOne(canvas, "Flat", palette)];
const quant = prepareQuant(canvas, palette);
for (const kernel of KERNELS) {
const image = quantizeImage(canvas, quant, kernel);
const result = quantizeOne(image, "Flat", palette);
result.type = kernel;
images.push(result);
}
return images;
}
export function quantizeOne(canvas: HTMLCanvasElement, kernel: DitheringKernelEx, palette: Palette): QuantResult {
if (kernel === "Flat") {
const imageData = canvasToImageData(canvas);
flatQuantizeImageData(imageData, palette);
const redrawn = imageDataToCanvas(imageData);
const indices = imageDataToPaletteIndices(imageData, palette.palette);
return {
type: "Flat",
canvas: redrawn,
indices,
};
} else {
const quant = prepareQuant(canvas, palette);
const output = quantizeImage(canvas, quant, kernel);
const flatOutput = quantizeOne(output, "Flat", palette);
flatOutput.type = kernel;
return flatOutput;
}
}
export function ditheringKernelToName(kernel: string) {
const arr = kernel.split("");
let stringBuilder = "";
for (let i = 0; i < arr.length; i++) {
const char = arr[i];
if (char) {
if (i !== 0) {
if (char === char.toUpperCase()) {
stringBuilder += " ";
stringBuilder += char.toLowerCase();
} else {
stringBuilder += char;
}
} else {
stringBuilder += char;
}
}
}
return stringBuilder;
}
function quantizeImage(canvas: HTMLCanvasElement, rgbQuant: RgbQuant, ditheringKernel: DitheringKernel) {
// create canvas;
const drawCanvas = document.createElement("canvas");
drawCanvas.width = canvas.width;
drawCanvas.height = canvas.height;
const drawCtx = drawCanvas.getContext("2d")!;
const imageData = canvasToImageData(canvas);
const data = rgbQuant.reduce(canvas, RetrieveType.Uint8Array, ditheringKernel);
if (imageData.data.length !== data.length) {
throw new Error("Got unexpected data from regQuant");
}
for (let i = 0; i < imageData.data.length; i += 4) {
if (imageData.data[i + 3]! > LOW_ALPHA) {
imageData.data[i + 0] = data[i + 0] ?? 0;
imageData.data[i + 1] = data[i + 1] ?? 0;
imageData.data[i + 2] = data[i + 2] ?? 0;
imageData.data[i + 3] = 0xff;
}
}
drawCtx.putImageData(imageData, 0, 0);
return drawCanvas;
}
export function flatQuantizeImageData(imageData: ImageData, palette: Palette) {
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i + 0] ?? 0;
const g = imageData.data[i + 1] ?? 0;
const b = imageData.data[i + 2] ?? 0;
const a = imageData.data[i + 3] ?? 0;
const obj = rgb(r, g, b);
const index = findClosestIndexColor(obj, palette);
const color = palette.palette[index];
if (!color) throw new Error(`Unknown color index ${index}`);
imageData.data[i + 0] = color.r;
imageData.data[i + 1] = color.g;
imageData.data[i + 2] = color.b;
imageData.data[i + 3] = a;
}
}
export function findClosestIndexColor(rgbO: RGB, palette: Palette) {
const scores: number[] = [];
for (const rgb of palette.palette) {
const r = getColorScore(rgbO.r, rgb.r);
const g = getColorScore(rgbO.g, rgb.g);
const b = getColorScore(rgbO.b, rgb.b);
scores.push(r + g + b);
}
const lowest = Math.min(...scores);
const index = scores.indexOf(lowest);
return index;
}
export function imageDataToCanvas(imageData: ImageData) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
canvas.width = imageData.width;
canvas.height = imageData.height;
ctx.putImageData(imageData, 0, 0);
return canvas;
}
async function importFile() {
const fileInput = new FileInput();
fileInput.setAcceptType(["png", "jpg", "jpeg", ...TEXT_FORMATS]);
const files = await fileInput.show();
const fileData = files[0];
if (!fileData) {
throw new Error("Empty file");
}
const ex = getExtension(fileData.name);
const name = ex.text;
if (TEXT_FORMATS.includes(ex.ex)) {
const content = await readAsString(fileData);
const mural = JSON.parse(content) as Mural;
if (!mural.name) {
mural.name = await Popup.prompt("Missing name for this mural. Please enter it manually", name) || "";
}
validateMural(mural);
return {
type: "mural",
data: mural,
};
} else {
const readData = await readAsDataUrl(fileData);
return {
type: "image",
data: await loadImageSource(`${ex.text}.${ex.ex}`, readData),
};
}
}