change from rgbquant to image-q

This commit is contained in:
HF 2020-05-09 10:03:35 +02:00
parent 9493172034
commit d1502882c1
4 changed files with 290 additions and 171 deletions

20
package-lock.json generated
View File

@ -7839,6 +7839,21 @@
"minimatch": "^3.0.4" "minimatch": "^3.0.4"
} }
}, },
"image-q": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/image-q/-/image-q-2.1.2.tgz",
"integrity": "sha1-husyiz8bK7RiP5WjTdHOTLbOz+k=",
"requires": {
"@types/node": "9.4.6"
},
"dependencies": {
"@types/node": {
"version": "9.4.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-9.4.6.tgz",
"integrity": "sha512-CTUtLb6WqCCgp6P59QintjHWqzf4VL1uPA27bipLAPxFqrtK1gEYllePzTICGqQ8rYsCbpnsNypXjjDzGAAjEQ=="
}
}
},
"immediate": { "immediate": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@ -11285,11 +11300,6 @@
"any-promise": "^1.3.0" "any-promise": "^1.3.0"
} }
}, },
"rgbquant": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/rgbquant/-/rgbquant-1.1.2.tgz",
"integrity": "sha1-2V6Imo/LHmwaT6LMyL46QaL80YU="
},
"rimraf": { "rimraf": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",

View File

@ -41,6 +41,7 @@
"global": "^4.3.2", "global": "^4.3.2",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"http-proxy-agent": "^4.0.1", "http-proxy-agent": "^4.0.1",
"image-q": "^2.1.2",
"ip-address": "^6.3.0", "ip-address": "^6.3.0",
"isomorphic-fetch": "^2.2.1", "isomorphic-fetch": "^2.2.1",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
@ -72,7 +73,6 @@
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.2.0",
"rgbquant": "^1.1.2",
"sequelize": "^5.21.7", "sequelize": "^5.21.7",
"sharp": "^0.25.2", "sharp": "^0.25.2",
"startaudiocontext": "^1.2.1", "startaudiocontext": "^1.2.1",

View File

@ -6,7 +6,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import fileDownload from 'js-file-download'; import fileDownload from 'js-file-download';
import RgbQuant from 'rgbquant'; import { utils, applyPalette } from 'image-q';
import printGIMPPalette from '../core/exportGPL'; import printGIMPPalette from '../core/exportGPL';
import type { State } from '../reducers'; import type { State } from '../reducers';
@ -42,8 +42,7 @@ function downloadOutput() {
function readFile( function readFile(
file, file,
selectFile, selectFile,
selectScaleWidth, setScaleData,
selectScaleHeight,
) { ) {
if (!file) { if (!file) {
return; return;
@ -52,8 +51,12 @@ function readFile(
fr.onload = () => { fr.onload = () => {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
selectScaleWidth(img.width); setScaleData({
selectScaleHeight(img.height); enabled: false,
width: img.width,
height: img.height,
aa: true,
});
selectFile(img); selectFile(img);
}; };
img.src = fr.result; img.src = fr.result;
@ -106,82 +109,123 @@ function addGrid(img, lightGrid, offsetX, offsetY) {
function scaleImage(img, width, height, doAA) { function scaleImage(img, width, height, doAA) {
const can = document.createElement('canvas'); const can = document.createElement('canvas');
const ctx = can.getContext('2d'); const ctxo = can.getContext('2d');
can.width = width; can.width = width;
can.height = height; can.height = height;
const scaleX = width / img.width;
const scaleY = height / img.height;
if (doAA) { if (doAA) {
ctx.imageSmoothingEnabled = true; // scale with canvas for antialiasing
ctx.mozImageSmoothingEnabled = true; ctxo.save();
ctx.webkitImageSmoothingEnabled = true; ctxo.scale(scaleX, scaleY);
ctx.msImageSmoothingEnabled = true; ctxo.drawImage(img, 0, 0);
} else { ctxo.restore();
ctx.imageSmoothingEnabled = false; return can;
ctx.mozImageSmoothingEnabled = false;
ctx.webkitImageSmoothingEnabled = false;
ctx.msImageSmoothingEnabled = false;
} }
ctx.save(); // scale manually
ctx.scale(width / img.width, height / img.height); const imdo = ctxo.createImageData(width, height);
ctx.drawImage(img, 0, 0); const { data: datao } = imdo;
ctx.restore(); const cani = document.createElement('canvas');
cani.width = img.width;
cani.height = img.height;
const ctxi = cani.getContext('2d');
ctxi.drawImage(img, 0, 0);
const imdi = ctxi.getImageData(0, 0, img.width, img.height);
const { data: datai } = imdi;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let posi = (Math.round(x / scaleX) + Math.round(y / scaleY)
* img.width) * 4;
let poso = (x + y * width) * 4;
datao[poso++] = datai[posi++];
datao[poso++] = datai[posi++];
datao[poso++] = datai[posi++];
datao[poso] = datai[posi];
}
}
ctxo.putImageData(imdo, 0, 0);
return can; return can;
} }
function renderOutputImage( let newOpts = null;
colors, let rendering = false;
selectedFile, async function renderOutputImage(opts) {
selectedStrategy, if (!opts.file) {
selectedSerp,
selectedColorDist,
selectedDithDelta,
selectedAddGrid,
selectedLightGrid,
selectedGridOffsetX,
selectedGridOffsetY,
selectedDoScaling,
selectedScaleWidth,
selectedScaleHeight,
selectedScaleAA,
) {
if (!selectedFile) {
return; return;
} }
let image = selectedFile; if (rendering) {
if (selectedDoScaling) { newOpts = opts;
image = scaleImage( return;
image,
selectedScaleWidth,
selectedScaleHeight,
selectedScaleAA,
);
} }
const rgbQuant = new RgbQuant({ rendering = true;
colors: colors.length, let renderOpts = opts;
dithKern: selectedStrategy, while (renderOpts) {
dithDelta: selectedDithDelta / 100, newOpts = null;
dithSerp: selectedSerp, const {
palette: colors, file, dither, grid, scaling,
reIndex: false, } = renderOpts;
useCache: false, if (file) {
colorDist: selectedColorDist, let image = file;
}); let pointContainer = null;
rgbQuant.sample(image); if (scaling.enabled) {
rgbQuant.palette(); // scale
const pxls = rgbQuant.reduce(image); const { width, height, aa } = scaling;
image = drawPixels(pxls, image.width, image.height); image = scaleImage(
const output = document.getElementById('imgoutput'); file,
if (selectedAddGrid) { width,
image = addGrid( height,
image, aa,
selectedLightGrid, );
selectedGridOffsetX, pointContainer = utils.PointContainer.fromHTMLCanvasElement(image);
selectedGridOffsetY, } else {
); pointContainer = utils.PointContainer.fromHTMLImageElement(image);
}
// dither
const { colors, strategy, colorDist } = dither;
const palette = new utils.Palette();
palette.add(utils.Point.createByRGBA(0, 0, 0, 0));
colors.forEach((clr) => {
const [r, g, b] = clr;
const point = utils.Point.createByRGBA(r, g, b, 255);
palette.add(point);
});
const progEl = document.getElementById('qprog');
progEl.style.display = 'block';
// eslint-disable-next-line no-await-in-loop
pointContainer = await applyPalette(pointContainer, palette, {
colorDistanceFormula: colorDist,
imageQuantization: strategy,
onProgress: (progress) => {
progEl.innerHTML = `Loading... ${Math.round(progress)} %`;
},
});
progEl.style.display = 'none';
image = drawPixels(
pointContainer.toUint8Array(),
image.width,
image.height,
);
// grid
if (grid.enabled) {
const { light, offsetX, offsetY } = grid;
image = addGrid(
image,
light,
offsetX,
offsetY,
);
}
// draw
const output = document.getElementById('imgoutput');
output.width = image.width;
output.height = image.height;
const ctx = output.getContext('2d');
ctx.drawImage(image, 0, 0);
}
// render again if requested in the meantime
renderOpts = newOpts;
} }
output.width = image.width; rendering = false;
output.height = image.height;
const ctx = output.getContext('2d');
ctx.drawImage(image, 0, 0);
} }
@ -191,41 +235,39 @@ function Converter({
}) { }) {
const [selectedCanvas, selectCanvas] = useState(canvasId); const [selectedCanvas, selectCanvas] = useState(canvasId);
const [selectedFile, selectFile] = useState(null); const [selectedFile, selectFile] = useState(null);
const [selectedStrategy, selectStrategy] = useState(''); const [selectedStrategy, selectStrategy] = useState('nearest');
const [selectedSerp, selectSerp] = useState(false);
const [selectedColorDist, selectColorDist] = useState('euclidean'); const [selectedColorDist, selectColorDist] = useState('euclidean');
const [selectedDithDelta, selectDithDelta] = useState(0);
const [selectedAddGrid, selectAddGrid] = useState(true);
const [selectedLightGrid, selectLightGrid] = useState(false);
const [selectedGridOffsetX, selectGridOffsetX] = useState(0);
const [selectedGridOffsetY, selectGridOffsetY] = useState(0);
const [selectedDoScaling, selectDoScaling] = useState(false);
const [selectedScaleWidth, selectScaleWidth] = useState(0);
const [selectedScaleHeight, selectScaleHeight] = useState(0);
const [selectedScaleKeepRatio, selectScaleKeepRatio] = useState(true); const [selectedScaleKeepRatio, selectScaleKeepRatio] = useState(true);
const [selectedScaleAA, selectScaleAA] = useState(false); const [scaleData, setScaleData] = useState({
enabled: false,
width: 10,
height: 10,
aa: true,
});
const [gridData, setGridData] = useState({
enabled: true,
light: false,
offsetX: 0,
offsetY: 0,
});
const input = document.createElement('canvas'); const input = document.createElement('canvas');
useEffect(() => { useEffect(() => {
if (selectedFile) { if (selectedFile) {
const canvas = canvases[selectedCanvas]; const canvas = canvases[selectedCanvas];
renderOutputImage( const dither = {
canvas.colors.slice(canvas.cli), colors: canvas.colors.slice(canvas.cli),
selectedFile, strategy: selectedStrategy,
selectedStrategy, colorDist: selectedColorDist,
selectedSerp, };
selectedColorDist, renderOutputImage({
selectedDithDelta, file: selectedFile,
selectedAddGrid, dither,
selectedLightGrid, grid: gridData,
selectedGridOffsetX, scaling: scaleData,
selectedGridOffsetY, });
selectedDoScaling,
selectedScaleWidth,
selectedScaleHeight,
selectedScaleAA,
);
} else { } else {
// draw gray rectanglue if no file selected
const output = document.getElementById('imgoutput'); const output = document.getElementById('imgoutput');
const ctx = output.getContext('2d'); const ctx = output.getContext('2d');
output.width = 128; output.width = 128;
@ -235,6 +277,19 @@ function Converter({
} }
}); });
const {
enabled: gridEnabled,
light: gridLight,
offsetX: gridOffsetX,
offsetY: gridOffsetY,
} = gridData;
const {
enabled: scalingEnabled,
width: scalingWidth,
height: scalingHeight,
aa: scalingAA,
} = scaleData;
return ( return (
<p style={{ textAlign: 'center' }}> <p style={{ textAlign: 'center' }}>
<p style={textStyle}>Choose Canvas:&nbsp; <p style={textStyle}>Choose Canvas:&nbsp;
@ -291,7 +346,7 @@ function Converter({
const fileSel = evt.target; const fileSel = evt.target;
const file = (!fileSel.files || !fileSel.files[0]) const file = (!fileSel.files || !fileSel.files[0])
? null : fileSel.files[0]; ? null : fileSel.files[0];
readFile(file, selectFile, selectScaleWidth, selectScaleHeight); readFile(file, selectFile, setScaleData);
}} }}
/> />
<p style={textStyle}>Choose Strategy:&nbsp; <p style={textStyle}>Choose Strategy:&nbsp;
@ -301,20 +356,18 @@ function Converter({
selectStrategy(sel.options[sel.selectedIndex].value); selectStrategy(sel.options[sel.selectedIndex].value);
}} }}
> >
<option
value=""
selected={(selectedStrategy === '')}
>Default</option>
{ {
['FloydSteinberg', ['nearest',
'Stucki', 'riemersma',
'Atkinson', 'floyd-steinberg',
'Jarvis', 'false-floyd-steinberg',
'Burkes', 'stucki',
'Sierra', 'atkinson',
'TwoSierra', 'jarvis',
'SierraLite', 'burkes',
'FalseFloydSteinberg'].map((strat) => ( 'sierra',
'two-sierra',
'sierra-lite'].map((strat) => (
<option <option
value={strat} value={strat}
selected={(selectedStrategy === strat)} selected={(selectedStrategy === strat)}
@ -323,50 +376,47 @@ function Converter({
} }
</select> </select>
</p> </p>
<span style={{ ...textStyle, fontHeight: 16 }}> <p style={textStyle}>Choose Color Mode:&nbsp;
<input <select
type="checkbox"
onChange={(e) => { onChange={(e) => {
selectSerp(e.target.checked); const sel = e.target;
selectColorDist(sel.options[sel.selectedIndex].value);
}} }}
/> >
Serpentine Pattern Dithering {
</span>&nbsp; ['cie94-textiles',
<span style={{ ...textStyle, fontHeight: 16 }}> 'cie94-graphic-arts',
<input 'ciede2000',
type="checkbox" 'color-metric',
onClick={(e) => { 'euclidean',
const colorDist = (e.target.checked) 'euclidean-bt709-noalpha',
? 'euclidean' : 'manhatten'; 'euclidean-bt709',
selectColorDist(colorDist); 'manhattan',
}} 'manhattan-bt709',
/> 'manhattan-nommyde',
Manhatten Color Distance 'pngquant'].map((strat) => (
</span> <option
<p style={textStyle}>Dithering Delta:&nbsp; value={strat}
<input selected={(selectedColorDist === strat)}
type="number" >{strat}</option>
step="1" ))
min="0" }
max="100" </select>
style={{ width: '3em' }}
value={selectedDithDelta}
onChange={(e) => {
selectDithDelta(e.target.value);
}}
/>%
</p> </p>
<p style={{ ...textStyle, fontHeight: 16 }}> <p style={{ ...textStyle, fontHeight: 16 }}>
<input <input
type="checkbox" type="checkbox"
checked={selectedAddGrid} checked={gridEnabled}
onChange={(e) => { onChange={(e) => {
selectAddGrid(e.target.checked); setGridData({
...gridData,
enabled: e.target.checked,
});
}} }}
/> />
Add Grid (uncheck if you need a 1:1 template) Add Grid (uncheck if you need a 1:1 template)
</p> </p>
{(selectedAddGrid) {(gridEnabled)
? ( ? (
<div style={{ <div style={{
borderStyle: 'solid', borderStyle: 'solid',
@ -379,8 +429,12 @@ function Converter({
<p style={{ ...textStyle, fontHeight: 16 }}> <p style={{ ...textStyle, fontHeight: 16 }}>
<input <input
type="checkbox" type="checkbox"
checked={gridLight}
onChange={(e) => { onChange={(e) => {
selectLightGrid(e.target.checked); setGridData({
...gridData,
light: e.target.checked,
});
}} }}
/> />
Light Grid Light Grid
@ -392,9 +446,12 @@ function Converter({
min="0" min="0"
max="10" max="10"
style={{ width: '2em' }} style={{ width: '2em' }}
value={selectedGridOffsetX} value={gridOffsetX}
onChange={(e) => { onChange={(e) => {
selectGridOffsetX(e.target.value); setGridData({
...gridData,
offsetX: e.target.value,
});
}} }}
/>% />%
</span> </span>
@ -405,9 +462,12 @@ function Converter({
min="0" min="0"
max="10" max="10"
style={{ width: '2em' }} style={{ width: '2em' }}
value={selectedGridOffsetY} value={gridOffsetY}
onChange={(e) => { onChange={(e) => {
selectGridOffsetY(e.target.value); setGridData({
...gridData,
offsetY: e.target.value,
});
}} }}
/>% />%
</span> </span>
@ -417,14 +477,17 @@ function Converter({
<p style={{ ...textStyle, fontHeight: 16 }}> <p style={{ ...textStyle, fontHeight: 16 }}>
<input <input
type="checkbox" type="checkbox"
checked={selectedDoScaling} checked={scalingEnabled}
onChange={(e) => { onChange={(e) => {
selectDoScaling(e.target.checked); setScaleData({
...scaleData,
enabled: e.target.checked,
});
}} }}
/> />
Scale Image Scale Image
</p> </p>
{(selectedDoScaling) {(scalingEnabled)
? ( ? (
<div style={{ <div style={{
borderStyle: 'solid', borderStyle: 'solid',
@ -439,18 +502,27 @@ function Converter({
type="number" type="number"
step="1" step="1"
min="1" min="1"
max="16564" max="1024"
style={{ width: '3em' }} style={{ width: '3em' }}
value={selectedScaleWidth} value={scalingWidth}
onChange={(e) => { onChange={(e) => {
const newWidth = e.target.value; const newWidth = (e.target.value > 1024)
? 1024 : e.target.value;
if (!newWidth) return; if (!newWidth) return;
if (selectedScaleKeepRatio && selectedFile) { if (selectedScaleKeepRatio && selectedFile) {
const ratio = selectedFile.width / selectedFile.height; const ratio = selectedFile.width / selectedFile.height;
const newHeight = Math.round(newWidth / ratio); const newHeight = Math.round(newWidth / ratio);
selectScaleHeight(newHeight); setScaleData({
...scaleData,
width: newWidth,
height: newHeight,
});
return;
} }
selectScaleWidth(newWidth); setScaleData({
...scaleData,
width: newWidth,
});
}} }}
/>% />%
</span> </span>
@ -459,18 +531,27 @@ function Converter({
type="number" type="number"
step="1" step="1"
min="1" min="1"
max="16564" max="1024"
style={{ width: '3em' }} style={{ width: '3em' }}
value={selectedScaleHeight} value={scalingHeight}
onChange={(e) => { onChange={(e) => {
const nuHeight = e.target.value; const nuHeight = (e.target.value > 1024)
? 1024 : e.target.value;
if (!nuHeight) return; if (!nuHeight) return;
if (selectedScaleKeepRatio && selectedFile) { if (selectedScaleKeepRatio && selectedFile) {
const ratio = selectedFile.width / selectedFile.height; const ratio = selectedFile.width / selectedFile.height;
const nuWidth = Math.round(ratio * nuHeight); const nuWidth = Math.round(ratio * nuHeight);
selectScaleWidth(nuWidth); setScaleData({
...scaleData,
width: nuWidth,
height: nuHeight,
});
return;
} }
selectScaleHeight(nuHeight); setScaleData({
...scaleData,
height: nuHeight,
});
}} }}
/>% />%
</span> </span>
@ -487,8 +568,12 @@ function Converter({
<p style={{ ...textStyle, fontHeight: 16 }}> <p style={{ ...textStyle, fontHeight: 16 }}>
<input <input
type="checkbox" type="checkbox"
checked={scalingAA}
onChange={(e) => { onChange={(e) => {
selectScaleAA(e.target.checked); setScaleData({
...scaleData,
aa: e.target.checked,
});
}} }}
/> />
Anti Aliasing Anti Aliasing
@ -497,8 +582,11 @@ function Converter({
type="button" type="button"
onClick={() => { onClick={() => {
if (selectedFile) { if (selectedFile) {
selectScaleWidth(selectedFile.width); setScaleData({
selectScaleHeight(selectedFile.height); ...scaleData,
width: selectedFile.width,
height: selectedFile.height,
});
} }
}} }}
> >
@ -507,6 +595,7 @@ function Converter({
</div> </div>
) )
: null} : null}
<p id="qprog" />
<p> <p>
<canvas <canvas
id="imgoutput" id="imgoutput"

View File

@ -7,6 +7,7 @@
* LICENSE.txt file in the root directory of this source tree. * LICENSE.txt file in the root directory of this source tree.
*/ */
import fs from 'fs';
import webpack from 'webpack'; import webpack from 'webpack';
import webpackConfig from './webpack.config'; import webpackConfig from './webpack.config';
@ -14,6 +15,25 @@ import webpackConfig from './webpack.config';
* Creates application bundles from the source files. * Creates application bundles from the source files.
*/ */
function bundle() { function bundle() {
try {
/* fix image-q imports here
* Pretty dirty, but we did write an issue and they might
* update one day
*/
console.log('Pathing image-q set-immediate import');
const regex = /core-js\/fn\/set-immediate/g;
const files = [
'./node_modules/image-q/dist/esm/basicAPI.js',
'./node_modules/image-q/dist/esm/helper.js',
];
files.forEach((file) => {
let fileContent = fs.readFileSync(file,'utf8');
fileContent = fileContent.replace(regex, 'core-js/features/set-immediate');
fs.writeFileSync(file, fileContent);
});
} catch {
console.log('Error while patching image-q');
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
webpack(webpackConfig).run((err, stats) => { webpack(webpackConfig).run((err, stats) => {
if (err) { if (err) {