├── .babelrc ├── .eslintrc.js ├── .gitignore ├── README.md ├── cli.js ├── examples └── pee-wee.png ├── index.js ├── package.json └── src ├── nesPalette.js ├── nesify.js └── sort.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'airbnb-base', 3 | env: { 4 | node: true, 5 | }, 6 | parser: 'babel-eslint', 7 | rules: { 8 | 'indent': ['error', 2, { 9 | SwitchCase: 1, 10 | VariableDeclarator: 1, 11 | outerIIFEBody: 1, 12 | }], 13 | 'max-len': ['warn', { 14 | code: 100, 15 | comments: 100, 16 | ignorePattern: '^\\s*(\'.*\'|".*"|`.*`)[,;]?$', 17 | }], 18 | 'no-underscore-dangle': 'off', 19 | 'no-unused-expressions': ['error', { 20 | allowShortCircuit: true, 21 | allowTernary: false, 22 | }], 23 | 'space-before-function-paren': ['error', { 24 | anonymous: 'never', 25 | named: 'never', 26 | }], 27 | 'import/no-extraneous-dependencies': ['error', { 28 | devDependencies: true, 29 | }], 30 | 'import/extensions': ['error', { 31 | js: 'never', 32 | mjs: 'never', 33 | }], 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /npm-debug.log 3 | /coverage 4 | /out 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | nesify 2 | ====== 3 | 4 | Squishes an image to NES palette limitations. 5 | 6 | This library makes a reasonable effort to come up with a decent NES palette for an image and apply it. The palette sliced up into 4-color subpalettes, each of which shares a common "background" color. THe image is divided into 8x8 tiles, each of which can be assigned a different subpalette. 7 | 8 | ![](examples/pee-wee.png) 9 | 10 | Usage 11 | ----- 12 | 13 | ```bash 14 | nesify --srcUrl="http://whatever" 15 | nesify --srcFile="~/Downloads/cool.jpg" --customPalette="0f0116360f0f1a30" 16 | ``` 17 | 18 | [This tool](http://codepen.io/kmck/full/RKbodL/) is useful for creating and previewing palettes. 19 | 20 | @TODO 21 | ----- 22 | 23 | * More complete 24 | * Add pre-dither level filtering 25 | * A real demo page? Heh. 26 | * Impose a limit to the number of distinct tiles that the image can have, and do some clever "best fit" logic to share tiles and do palette swapping. 27 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import fs from 'fs'; 4 | import http from 'http'; 5 | import https from 'https'; 6 | import path from 'path'; 7 | import url from 'url'; 8 | 9 | import chalk from 'chalk'; 10 | import expandTilde from 'expand-tilde'; 11 | import minimist from 'minimist'; 12 | 13 | import nesify from '.'; 14 | 15 | const argv = minimist(process.argv.slice(2)); 16 | const { 17 | srcUrl = '', 18 | srcFile = '', 19 | outSrcFile = path.join(process.cwd(), 'out', `nesify-src-img${path.extname(srcUrl || srcFile)}`), 20 | outFile = path.join(process.cwd(), 'out', 'nesify.png'), 21 | ...restArgs 22 | } = argv; 23 | 24 | /** 25 | * Reads an image URL and converts it to a Buffer 26 | * 27 | * @param {string} imageUrl - URL of the image to read 28 | * @return {Promise} resolves with the Buffer 29 | */ 30 | const readImageToBuffer = imageUrl => new Promise((resolve, reject) => { 31 | const options = { 32 | ...url.parse(imageUrl), 33 | method: 'GET', 34 | headers: { 35 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36', 36 | }, 37 | }; 38 | 39 | (url.parse(imageUrl).protocol === 'https:' ? https : http) 40 | // .get(imageUrl, (response) => { 41 | .request(options, (response) => { 42 | if (response.statusCode !== 200) { 43 | reject('Non-200 response code'); 44 | return; 45 | } 46 | 47 | const data = []; 48 | response 49 | .on('data', (chunk) => { 50 | data.push(chunk); 51 | }) 52 | .on('end', () => { 53 | setTimeout(() => { 54 | resolve(Buffer.concat(data)); 55 | }, 3000); 56 | }) 57 | .on('error', (err) => { 58 | reject(err); 59 | }); 60 | }) 61 | .end(); 62 | }); 63 | 64 | /** 65 | * Reads an image from disk and converts it to a buffer 66 | * 67 | * @param {string} filePath - path to the file 68 | * @return {[type]} [description] 69 | */ 70 | const readFileToBuffer = filePath => new Promise((resolve, reject) => { 71 | fs.readFile(filePath, (err, data) => { 72 | if (err) { 73 | reject(err); 74 | return; 75 | } 76 | resolve(data); 77 | }); 78 | }); 79 | 80 | // Read the input URL and convert it inline 81 | let srcBuffer; 82 | if (srcUrl) { 83 | console.log(`Reading image URL from ${chalk.magenta(srcUrl)}`); 84 | srcBuffer = readImageToBuffer(srcUrl); 85 | } else if (srcFile) { 86 | console.log(`Reading image file from ${chalk.magenta(srcFile)}`); 87 | srcBuffer = readFileToBuffer(expandTilde(srcFile)); 88 | } else { 89 | srcBuffer = Promise.reject(`Please specify a ${chalk.cyan(srcUrl)} or ${chalk.cyan(srcFile)}`); 90 | } 91 | 92 | srcBuffer 93 | .then(buffer => new Promise((resolve) => { 94 | fs.writeFile(outSrcFile, buffer, (err) => { 95 | if (err) { 96 | console.error(err); 97 | } 98 | resolve(buffer); 99 | }); 100 | })) 101 | .then((buffer) => { 102 | const canvas = nesify(buffer, { 103 | ...restArgs, 104 | log: (...args) => { 105 | console.log(...args); 106 | }, 107 | }); 108 | 109 | console.log(`Writing output to ${chalk.blue(outFile)}`); 110 | fs.writeFile(outFile, canvas.toBuffer(), (err) => { 111 | if (err) { 112 | console.error('err', err); 113 | } 114 | }); 115 | }) 116 | .catch((err) => { 117 | console.error(err); 118 | }); 119 | -------------------------------------------------------------------------------- /examples/pee-wee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmck/nesify/8e5578bcf470c2dfeb6a9798b092d95dbec9fb9c/examples/pee-wee.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export default from './src/nesify'; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nesify", 3 | "version": "0.0.1", 4 | "description": "Squishes an image to NES palette limitations", 5 | "main": "index.js", 6 | "bin": { 7 | "nesify": "./cli.js" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "keywords": [ 13 | "nes", 14 | "8-bit", 15 | "canvas", 16 | "pixel", 17 | "image" 18 | ], 19 | "author": "Keith McKnight", 20 | "license": "ISC", 21 | "dependencies": { 22 | "canvas": "^1.6.2", 23 | "chalk": "^1.1.3", 24 | "color-functions": "^1.1.0", 25 | "ditherjs": "^0.9.1", 26 | "expand-tilde": "^2.0.2", 27 | "lodash": "^4.17.3", 28 | "minimist": "^1.2.0" 29 | }, 30 | "devDependencies": { 31 | "babel-core": "^6.21.0", 32 | "babel-eslint": "^7.1.1", 33 | "babel-preset-es2015": "^6.18.0", 34 | "babel-preset-stage-0": "^6.16.0", 35 | "eslint": "^3.12.2", 36 | "eslint-config-airbnb-base": "^11.0.0", 37 | "eslint-plugin-import": "^2.2.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/nesPalette.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable quote-props */ 2 | 3 | import memoize from 'lodash/memoize'; 4 | import invert from 'lodash/invert'; 5 | import { rgb2hex } from 'color-functions'; 6 | 7 | import { comparatorAscending } from './sort'; 8 | 9 | /** 10 | * The NES palette is divided into 4 subpalettes of 4 colors each, one of which is used for a common 11 | * background/transparent color. 12 | */ 13 | export const COLORS_PER_SUBPALETTE = 4; 14 | export const SUBPALETTES_PER_IMAGE = 4; 15 | 16 | /** 17 | * There are multiple identical (?) "blacks" in the NES palette, but most games use 0x0F as the 18 | * standard black 19 | */ 20 | export const CANONICAL_BLACK = '0f'; 21 | 22 | /** 23 | * This maps the internal color enumeration to hex colors. 24 | */ 25 | export const nesPalette = { 26 | '00': '#7c7c7c', 27 | '01': '#0923f8', 28 | '02': '#0417b9', 29 | '03': '#4430b9', 30 | '04': '#920f82', 31 | '05': '#a60424', 32 | '06': '#a6120d', 33 | '07': '#861508', 34 | '08': '#4f2f04', 35 | '09': '#0c770f', 36 | '0a': '#09670b', 37 | '0b': '#065707', 38 | '0c': '#034057', 39 | '0d': '#000000', 40 | '0e': '#000000', 41 | '0f': '#000000', // canonical black 42 | '10': '#bcbcbc', 43 | '11': '#147cf5', 44 | '12': '#0f5ef4', 45 | '13': '#684df8', 46 | '14': '#d61eca', 47 | '15': '#e20e5a', 48 | '16': '#f53a1b', 49 | '17': '#e25c22', 50 | '18': '#ab7b19', 51 | '19': '#1ab61e', 52 | '1a': '#17a61a', 53 | '1b': '#17a749', 54 | '1c': '#128887', 55 | '1d': '#000000', 56 | '1e': '#000000', 57 | '1f': '#000000', 58 | '20': '#f8f8f8', 59 | '21': '#44bdfa', 60 | '22': '#6a8bf9', 61 | '23': '#987cf5', 62 | '24': '#f67df6', 63 | '25': '#f65b98', 64 | '26': '#f6785d', 65 | '27': '#fa9f4e', 66 | '28': '#f7b72a', 67 | '29': '#baf638', 68 | '2a': '#5dd65b', 69 | '2b': '#60f69b', 70 | '2c': '#27e7d8', 71 | '2d': '#787878', 72 | '2e': '#000000', 73 | '2f': '#000000', 74 | '30': '#fcfcfc', 75 | '31': '#a6e4fb', 76 | '32': '#b8b9f6', 77 | '33': '#d8baf6', 78 | '34': '#f7baf7', 79 | '35': '#f7a5c0', 80 | '36': '#efd0b2', 81 | '37': '#fbdfab', 82 | '38': '#f7d77e', 83 | '39': '#d9f680', 84 | '3a': '#baf7ba', 85 | '3b': '#baf7d9', 86 | '3c': '#2cfcfb', 87 | '3d': '#d8d8d8', 88 | '3e': '#000000', 89 | '3f': '#000000', 90 | }; 91 | 92 | /** 93 | * This maps hex colors back to the NES enumeration, respecting the canonical black 94 | */ 95 | export const nesPaletteInverted = invert(nesPalette); 96 | nesPaletteInverted[nesPalette[CANONICAL_BLACK]] = CANONICAL_BLACK; 97 | 98 | /** 99 | * Converts a color in whatever format to the [r,g,b] format required by DitherJS 100 | * 101 | * @param {Array|Object|string|number} anyColor - source color 102 | * @return {Array} [r,g,b] color 103 | */ 104 | export function normalizeColor(anyColor) { 105 | let color = anyColor; 106 | 107 | if (Array.isArray(color)) { 108 | return color; 109 | } 110 | 111 | if (typeof color === 'object') { 112 | return [ 113 | color.r || color.red || 0, 114 | color.g || color.green || 0, 115 | color.b || color.blue || 0, 116 | ]; 117 | } 118 | 119 | if (typeof color === 'string') { 120 | color = color.toLowerCase().replace(/[^0-9a-f]/g, ''); 121 | if (color.length === 3) { 122 | color = color.split('').map(v => v + v).join(''); 123 | } 124 | color = parseInt(color, 16); 125 | } 126 | 127 | return [ 128 | /* eslint-disable no-bitwise */ 129 | (color >> 16) & 0xff, 130 | (color >> 8) & 0xff, 131 | color & 0xff, 132 | /* eslint-enable no-bitwise */ 133 | ]; 134 | } 135 | 136 | /** 137 | * Normalizes a color palette for use by DitherJS 138 | * 139 | * @param {Array} palette - a bunch of colors 140 | * @return {Array} array of colors in [r,g,b] format 141 | */ 142 | export function normalizeDitherPalette(palette) { 143 | return palette.map(color => normalizeColor(color)); 144 | } 145 | 146 | /** 147 | * Converts a color in whatever format to a #rrggbb hex color 148 | * 149 | * @param {Array|Object|string|number} anyColor - source color 150 | * @return {String} #rrggbb color 151 | */ 152 | export function normalizeColorHex(anyColor) { 153 | return `#${rgb2hex(...normalizeColor(anyColor))}`; 154 | } 155 | 156 | /** 157 | * Calculates the distance between two colors 158 | * 159 | * @param {[type]} colorA [description] 160 | * @param {[type]} colorB [description] 161 | * @return {number} distance between the two colors 162 | */ 163 | export function colorDistance(a, b) { 164 | const colorA = normalizeColor(a); 165 | const colorB = normalizeColor(b); 166 | return Math.sqrt(( 167 | ((colorA[0] - colorB[0]) ** 2) + 168 | ((colorA[1] - colorB[1]) ** 2) + 169 | ((colorB[2] - colorB[2]) ** 2) 170 | )); 171 | } 172 | 173 | /** 174 | * Standard palette of unique NES colors that DitherJS can use 175 | */ 176 | export const nesColors = Object.keys(nesPaletteInverted) 177 | .sort((a, b) => comparatorAscending(a !== '#000000', b !== '#000000')); 178 | 179 | /** 180 | * Gets the nearest NES palette match for a given color 181 | */ 182 | export const nesColorMatch = memoize((findColor) => { 183 | const hex = normalizeColorHex(findColor); 184 | 185 | // Exact match 186 | if (hex in nesPaletteInverted) { 187 | return nesPaletteInverted[hex]; 188 | } 189 | 190 | // Look for the closest color 191 | const distances = Object.keys(nesPaletteInverted) 192 | .map(nesHex => ({ 193 | key: nesPaletteInverted[nesHex], 194 | distance: colorDistance(hex, nesHex), 195 | })) 196 | .sort((a, b) => comparatorAscending(a.distance, b.distance)); 197 | 198 | return distances[0].key; 199 | }); 200 | 201 | export default nesPalette; 202 | -------------------------------------------------------------------------------- /src/nesify.js: -------------------------------------------------------------------------------- 1 | import Canvas, { 2 | Image, 3 | ImageData, 4 | } from 'canvas'; 5 | import DitherJS from 'ditherjs/dist/ditherjs.dist'; 6 | 7 | import { 8 | // SORT_A_B_EQUAL, 9 | SORT_A_FIRST, 10 | SORT_B_FIRST, 11 | comparatorAscending, 12 | comparatorDescending, 13 | } from './sort'; 14 | 15 | import { 16 | COLORS_PER_SUBPALETTE, 17 | SUBPALETTES_PER_IMAGE, 18 | nesPalette, 19 | normalizeColor, 20 | normalizeColorHex, 21 | normalizeDitherPalette, 22 | colorDistance, 23 | nesColors, 24 | nesColorMatch, 25 | } from './nesPalette'; 26 | 27 | export const SCALE_STRETCH = 'stretch'; 28 | export const SCALE_ASPECT = 'aspect'; 29 | export const SCALE_CONTAIN = 'contain'; 30 | export const SCALE_COVER = 'cover'; 31 | 32 | const ditherjs = new DitherJS(); 33 | const arrayProto = []; 34 | 35 | const RGBA_BYTES = 4; 36 | export function rgbaOffset(offset) { 37 | return offset * RGBA_BYTES; 38 | } 39 | 40 | export function colorKey(color) { 41 | return normalizeColorHex(color); 42 | } 43 | 44 | export function paletteKey(palette) { 45 | return palette.map(normalizeColorHex).sort(comparatorAscending).join(','); 46 | } 47 | 48 | export function createPaletteSorter(backgroundColor) { 49 | return (a, b) => { 50 | if (a === backgroundColor) { 51 | return SORT_A_FIRST; 52 | } 53 | if (b === backgroundColor) { 54 | return SORT_B_FIRST; 55 | } 56 | return comparatorAscending(parseInt(nesColorMatch(a), 16), parseInt(nesColorMatch(b), 16)); 57 | }; 58 | } 59 | 60 | /** 61 | * Gets a piece of image data 62 | * 63 | * @param {ImageData} imgData - source image data 64 | * @param {number} sx - source x position 65 | * @param {number} sy - source y position 66 | * @param {number} sw - source width 67 | * @param {number} sh - source height 68 | * 69 | * @return {ImageData} image data subsection 70 | */ 71 | export function getImageDataSubsection(imgData, sx, sy, sw, sh) { 72 | const subImgData = new ImageData(sw, sh); 73 | for (let y = 0; y < sh; y += 1) { 74 | const start = rgbaOffset(sx + ((sy + y) * imgData.width)); 75 | const length = rgbaOffset(sw); 76 | const offset = rgbaOffset(y * sw); 77 | subImgData.data.set(imgData.data.slice(start, start + length), offset); 78 | } 79 | return subImgData; 80 | } 81 | 82 | /** 83 | * Calculates the total color distance between each pixel in two images 84 | * 85 | * @param {ImageData} imgDataA - first image 86 | * @param {ImageData} imgDataB - second image 87 | * @return {number} total distance 88 | */ 89 | export function imageDataDistance(imgDataA, imgDataB) { 90 | const dataLength = imgDataA.data.length; 91 | let distance = 0; 92 | for (let i = 0; i < dataLength; i += RGBA_BYTES) { 93 | distance += colorDistance( 94 | arrayProto.slice.call(imgDataA.data, i, i + 3), 95 | arrayProto.slice.call(imgDataB.data, i, i + 3), 96 | ); 97 | } 98 | return distance; 99 | } 100 | 101 | /** 102 | * Generates an array of tiles from the given image data and tile size 103 | * 104 | * @param {ImageData} imgData - source image data 105 | * @param {number} tileWidth - width of a tile 106 | * @param {number} tileHeight - height of a tile 107 | * @return {Array} tile array 108 | */ 109 | export function splitIntoTiles(imgData, tileWidth, tileHeight) { 110 | const tilesX = Math.ceil(imgData.width / tileWidth); 111 | const tilesY = Math.ceil(imgData.height / tileHeight); 112 | const tiles = []; 113 | 114 | for (let y = 0; y < tilesY; y += 1) { 115 | for (let x = 0; x < tilesX; x += 1) { 116 | // Create the new tile 117 | const tile = { x, y }; 118 | tiles.push(tile); 119 | 120 | // Determine the coordinates relative to the source image data 121 | tile.srcPosition = [ 122 | x * tileWidth, 123 | y * tileHeight, 124 | tileWidth, 125 | tileHeight, 126 | ]; 127 | 128 | // Save the source image data, which we'll need to try out the different subpalettes 129 | // tile.srcImgData = ctx.getImageData(...tile.srcPosition); 130 | tile.srcImgData = getImageDataSubsection(imgData, ...tile.srcPosition); 131 | } 132 | } 133 | 134 | return tiles; 135 | } 136 | 137 | /** 138 | * Gets the unique colors used in the supplied image data 139 | * 140 | * @param {ImageData} imgData - source image data 141 | * @return {Array} colors used in the image 142 | */ 143 | export function getLocalPalette(imgData) { 144 | const colorsUsedByKey = {}; 145 | const dataLength = imgData.data.length; 146 | 147 | for (let i = 0; i < dataLength; i += RGBA_BYTES) { 148 | const color = arrayProto.slice.call(imgData.data, i, i + 3); 149 | const key = colorKey(color); 150 | const colorUsed = colorsUsedByKey[key] || { 151 | key, 152 | color, 153 | pixelsUsing: 0, 154 | }; 155 | colorUsed.pixelsUsing += 1; 156 | colorsUsedByKey[key] = colorUsed; 157 | } 158 | 159 | return Object.keys(colorsUsedByKey) 160 | .map(key => colorsUsedByKey[key]) 161 | .sort((a, b) => comparatorDescending(a.pixelsUsing, b.pixelsUsing)); 162 | } 163 | 164 | /** 165 | * Recursively enumerates all n-color palette variations 166 | * 167 | * @param {Array} palette - all available colors 168 | * @param {number} maxColors - max number of colors per subpalette 169 | * 170 | * @return {Array} subpalettes 171 | */ 172 | export function enumeratePaletteOptions(palette, maxColors) { 173 | if (maxColors === 0) { 174 | return []; 175 | } else if (maxColors === 1) { 176 | return palette.map(color => [color]); 177 | } else if (palette.length <= maxColors) { 178 | return [palette]; 179 | } 180 | 181 | return palette.slice(0, (palette.length + 1) - maxColors) 182 | .reduce((subpalettes, firstColor, i) => { 183 | const restPalette = palette.slice(i + 1); 184 | const restPaletteOptions = enumeratePaletteOptions(restPalette, maxColors - 1); 185 | restPaletteOptions 186 | .forEach((restColors) => { 187 | const allColors = restColors.slice(0); 188 | allColors.unshift(firstColor); 189 | subpalettes.push(allColors); 190 | }); 191 | return subpalettes; 192 | }, []); 193 | } 194 | 195 | /** 196 | * Calculate a score for the palette based 197 | * 198 | * @param {Object} paletteOption - palette option containing tile usage and colors 199 | * @param {Array} colorsUsedByKey - colors used by key, containing ranking info 200 | * @return {number} a score for the palette 201 | */ 202 | export function paletteScore(paletteOption, colorsUsedByKey, numColors) { 203 | let score = paletteOption.tilesUsing; 204 | paletteOption.palette.forEach((color) => { 205 | const key = colorKey(color); 206 | if (key in colorsUsedByKey) { 207 | score += 20 * ((1 - (colorsUsedByKey[key].ranking / numColors)) ** 2); 208 | } 209 | }); 210 | return score; 211 | } 212 | 213 | /** 214 | * Dithers some image data 215 | * 216 | * @param {ImageData} imgData - source image data 217 | * @param {Array} palette - palette to use for dithering 218 | * @param {Object} [ditherOptions] - additional dither options 219 | * 220 | * @return {ImageData} dithered image data 221 | */ 222 | export function ditherImageData(imgData, palette, ditherOptions = {}) { 223 | const ditheredImgData = new ImageData(imgData.width, imgData.height); 224 | ditheredImgData.data.set(new Uint8ClampedArray(imgData.data)); 225 | ditherjs.ditherImageData(ditheredImgData, { 226 | palette: normalizeDitherPalette(palette), 227 | ...ditherOptions, 228 | }); 229 | return ditheredImgData; 230 | } 231 | 232 | /** 233 | * Upscales a canvas by the given factor 234 | * 235 | * @param {HTMLCanvasElement} canvas - source canvas 236 | * @param {number} upscale - factor to upscale canvas 237 | * @return {HTMLCanvasElement} the upscaled canvas 238 | */ 239 | export function upscaleCanvas(srcCanvas, upscale) { 240 | const width = upscale * srcCanvas.width; 241 | const height = upscale * srcCanvas.height; 242 | const canvas = new Canvas(width, height); 243 | const ctx = canvas.getContext('2d'); 244 | !('antialias' in ctx) || (ctx.antialias = 'none'); // node-canvas 245 | !('imageSmoothingEnabled' in ctx) || (ctx.imageSmoothingEnabled = false); 246 | !('oImageSmoothingEnabled' in ctx) || (ctx.imageSmoothingEnabled = false); 247 | !('msImageSmoothingEnabled' in ctx) || (ctx.imageSmoothingEnabled = false); 248 | !('mozImageSmoothingEnabled' in ctx) || (ctx.imageSmoothingEnabled = false); 249 | !('webkitImageSmoothingEnabled' in ctx) || (ctx.imageSmoothingEnabled = false); 250 | ctx.drawImage(srcCanvas, 0, 0, width, height); 251 | return canvas; 252 | } 253 | 254 | /** 255 | * Creates a canvas with palette swatches 256 | * 257 | * @param {Array} palette - all available colors 258 | * @param {number} [swatchSize] - dimensions of each swatch 259 | * @param {number} [swatchColumns] - number of columns in the output 260 | * @return {HTMLCanvasElement} canvas with the color swatches drawn 261 | */ 262 | export function canvasImage( 263 | palette, 264 | swatchSize = 16, 265 | swatchColumns = Math.ceil(Math.sqrt(palette.length)), 266 | ) { 267 | const width = swatchColumns * swatchSize; 268 | const height = Math.ceil(palette.length / swatchColumns) * swatchSize; 269 | const canvas = new Canvas(width, height); 270 | const ctx = canvas.getContext('2d'); 271 | 272 | palette.forEach((color, i) => { 273 | const x = (i % swatchColumns) * swatchSize; 274 | const y = Math.floor(i / swatchColumns) * swatchSize; 275 | ctx.fillStyle = normalizeColorHex(color); 276 | ctx.fillRect(x, y, swatchSize, swatchSize); 277 | }); 278 | 279 | return canvas; 280 | } 281 | 282 | /** 283 | * Squishes an image to NES palette limitations 284 | * 285 | * This creates its own Canvas to do the drawing and returns it. 286 | * 287 | * @param {HTMLCanvasElement|HTMLImageElement|Buffer} src - image input 288 | * @param {Object} [options] - processing options 289 | * @param {number} [options.width] - output width 290 | * @param {number} [options.height] - output height 291 | * @param {string} [options.scaleMode] - scale mode to use when the source and destination aspect 292 | * ratios are mismatched 293 | * @param {Array} [options.globalPalette] - global color palette 294 | * @param {Array} [options.backgroundColor] - if set, forces a background color 295 | * @param {number} [options.quantizationStep] - quantization steps to use on the first pass 296 | * @param {number} [options.tileQuantizationStep] - quantization steps to use on the second pass 297 | * @param {string} [options.ditherAlgorithm] - type of dithering to use when applying the palette 298 | * @param {number} [options.tileWidth] - width of a tile that gets a subpalette 299 | * @param {number} [options.tileHeight] - height of a tile that gets a subpalette 300 | * @param {number} [options.tileSampleWidth] - width of a tile used for color sampling 301 | * @param {number} [options.tileSampleHeight] - height of a tile used for color sampling 302 | * @param {number} [options.upscale] - factor to upscale output 303 | * @param {boolean} [options.outputPalette] - if set, the image output will be a palette 304 | * 305 | * @return {HTMLCanvasElement} the canvas with the NESified image on it 306 | */ 307 | export function nesify(src, { 308 | width = 256, 309 | height = 240, 310 | scaleMode = SCALE_CONTAIN, 311 | globalPalette = nesColors, 312 | backgroundColor = false, 313 | customPalette = false, 314 | quantizationStep = 2, 315 | tileQuantizationStep = 1, 316 | ditherAlgorithm = 'ordered', 317 | tileWidth = 8, 318 | tileHeight = tileWidth, 319 | tileSampleWidth = tileWidth * 2, 320 | tileSampleHeight = tileSampleWidth, 321 | upscale = 1, 322 | outputPalette = false, 323 | log = () => {}, 324 | } = {}) { 325 | // Create a new image to load 326 | const srcImg = new Image(); 327 | srcImg.src = src; 328 | 329 | // Determine canvas dimensions 330 | const canvasWidth = width; 331 | const canvasHeight = scaleMode === SCALE_ASPECT ? 332 | canvasWidth * (srcImg.height / srcImg.width) : 333 | height; 334 | 335 | // Create a new Canvas 336 | const canvas = new Canvas(canvasWidth, canvasHeight); 337 | const ctx = canvas.getContext('2d'); 338 | 339 | 340 | // 341 | // First, we want to scale the source image to our target canvas size. 342 | // 343 | // If the source and destination aspect ratios don't match, we deal with it here, either scaling 344 | // or stretching until we have the right output. 345 | // 346 | 347 | // Set initial rectangles 348 | let srcRect = [0, 0, srcImg.width, srcImg.height]; 349 | let destRect = [0, 0, canvas.width, canvas.height]; 350 | 351 | // Modify rectangles based on scale mode 352 | log(`Using "${scaleMode}" as scale mode`); 353 | switch (scaleMode) { 354 | case SCALE_STRETCH: 355 | case SCALE_ASPECT: { 356 | break; 357 | } 358 | case SCALE_CONTAIN: { 359 | const srcRatio = srcImg.width / srcImg.height; 360 | const destRatio = canvas.width / canvas.height; 361 | if (srcRatio > destRatio) { 362 | // Source is too wide, letterbox 363 | const destHeight = canvas.width / srcRatio; 364 | destRect = [ 365 | 0, Math.round(0.5 * (canvas.height - destHeight)), 366 | canvas.width, destHeight, 367 | ]; 368 | } else if (srcRatio < destRatio) { 369 | // Source is too tall, pillarbox 370 | const destWidth = canvas.height * srcRatio; 371 | destRect = [ 372 | Math.round(0.5 * (canvas.width - destWidth)), 0, 373 | destWidth, canvas.height, 374 | ]; 375 | } 376 | break; 377 | } 378 | case SCALE_COVER: { 379 | const srcRatio = srcImg.width / srcImg.height; 380 | const destRatio = canvas.width / canvas.height; 381 | if (srcRatio > destRatio) { 382 | // Source is too wide, crop horizontally 383 | const srcWidth = srcImg.height * destRatio; 384 | srcRect = [ 385 | Math.round(0.5 * (srcImg.width - srcWidth)), 0, 386 | Math.round(srcWidth), srcImg.height, 387 | ]; 388 | } else if (srcRatio < destRatio) { 389 | // Source is too tall, crop vertically 390 | const srcHeight = srcImg.width / destRatio; 391 | srcRect = [ 392 | 0, Math.round(0.5 * (srcImg.height - srcHeight)), 393 | srcImg.width, Math.round(srcHeight), 394 | ]; 395 | } 396 | break; 397 | } 398 | default: { 399 | break; 400 | } 401 | } 402 | 403 | // Set the background color and draw the scaled source image to the canvas 404 | log('Drawing image to canvas'); 405 | ctx.fillStyle = normalizeColorHex(globalPalette[0]); 406 | ctx.fillRect(0, 0, canvas.width, canvas.height); 407 | ctx.drawImage(srcImg, ...srcRect, ...destRect); 408 | 409 | // Store the source ImageData of the scaled image and split into tiles 410 | const srcImgData = ctx.getImageData(0, 0, canvas.width, canvas.height); 411 | const tiles = splitIntoTiles(srcImgData, tileWidth, tileHeight); 412 | 413 | // 414 | // Next, we start reducing the colorspace of the image and dithering to match the NES palette. 415 | // 416 | // This happens in multiple phases. First, we get a general estimate of where the overall image 417 | // lands in the NES colorspace. Working from this image, we can determine the possibilities for 418 | // more restricted palette. Finally, we narrow down the palettes we actually want to use and apply 419 | // them to 8x8 tiles of the image. 420 | // 421 | 422 | // Dither the image and apply the global palette using a clone of the original image data 423 | log('Creating full image'); 424 | ctx.putImageData(ditherImageData(srcImgData, globalPalette, { 425 | step: quantizationStep, 426 | algorithm: ditherAlgorithm, 427 | }), 0, 0); 428 | 429 | // Determine the local palette for each tile 430 | const colorsUsedByKey = {}; 431 | const subpalettesByKey = {}; 432 | let sharedBackgroundColor; 433 | 434 | // Split the image into tiles 435 | if (customPalette) { 436 | log('Processing custom palette'); 437 | const chunkSize = backgroundColor ? 3 : 4; 438 | const customPaletteColors = customPalette 439 | .replace(/[^0-9a-f]/g, '') 440 | .match(/(.{2})/g) 441 | .map((key => nesPalette[key])); 442 | const customPaletteLength = customPaletteColors.length; 443 | sharedBackgroundColor = customPaletteColors[0]; 444 | 445 | // Fill out the colors used 446 | customPaletteColors.forEach((color) => { 447 | const key = colorKey(color); 448 | colorsUsedByKey[key] = { key, color, tilesUsing: 0, pixelsUsing: 0 }; 449 | }); 450 | 451 | // Fill out the subpalettes 452 | for (let i = 0; i < customPaletteLength; i += chunkSize) { 453 | const palette = customPaletteColors.slice(i, i + chunkSize); 454 | if (backgroundColor) { 455 | palette.unshift(backgroundColor); 456 | } 457 | const key = paletteKey(palette); 458 | subpalettesByKey[key] = { key, palette, tilesUsing: 0 }; 459 | } 460 | } else { 461 | log('Processing color information'); 462 | splitIntoTiles(srcImgData, tileSampleWidth, tileSampleHeight) 463 | .forEach((tile) => { 464 | // Determine the palette and subpalette options from the dithered image data 465 | const ditheredImgData = ctx.getImageData(...tile.srcPosition); 466 | const localPalette = getLocalPalette(ditheredImgData); 467 | const palette = localPalette.map(color => color.color); 468 | const paletteOptions = enumeratePaletteOptions(palette, COLORS_PER_SUBPALETTE); 469 | 470 | // Aggregate tile and pixel color data for the overall image 471 | localPalette.forEach((paletteColor) => { 472 | const { key, color } = paletteColor; 473 | const colorUsed = colorsUsedByKey[key] || { 474 | key, 475 | color, 476 | tilesUsing: 0, 477 | pixelsUsing: 0, 478 | }; 479 | colorUsed.tilesUsing += 1; 480 | colorUsed.pixelsUsing += paletteColor.pixelsUsing; 481 | colorsUsedByKey[key] = colorUsed; 482 | }); 483 | 484 | // Aggregate palette option data 485 | paletteOptions.forEach((paletteOption) => { 486 | const key = paletteKey(paletteOption); 487 | const subpalette = subpalettesByKey[key] || { 488 | key, 489 | palette: paletteOption, 490 | tilesUsing: 0, 491 | }; 492 | subpalette.tilesUsing += 1; 493 | subpalettesByKey[key] = subpalette; 494 | }); 495 | }); 496 | } 497 | 498 | // Rank all colors being used by the number of pixels and tiles using them 499 | log('Checking color usage'); 500 | const colorsUsed = Object.keys(colorsUsedByKey) 501 | .map(key => colorsUsedByKey[key]) 502 | .sort((a, b) => ( 503 | comparatorDescending(a.pixelsUsing, b.pixelsUsing) || 504 | comparatorDescending(a.tilesUsing, b.tilesUsing) 505 | )); 506 | 507 | // Add color rankings 508 | colorsUsed.forEach((color, ranking) => { 509 | Object.assign(color, { ranking }); 510 | }); 511 | 512 | // Pick a background color 513 | log('Selecting background color'); 514 | if (backgroundColor !== false) { 515 | sharedBackgroundColor = normalizeColor(backgroundColor); 516 | } else if (!sharedBackgroundColor) { 517 | sharedBackgroundColor = colorsUsed[0].color; 518 | } 519 | const paletteSorter = createPaletteSorter(sharedBackgroundColor); 520 | 521 | // Ensure that all subpalettes contain the background color 522 | if (!customPalette) { 523 | log('Background color sanity check'); 524 | Object.keys(subpalettesByKey) 525 | .forEach((key) => { 526 | const subpalette = subpalettesByKey[key]; 527 | if ( 528 | subpalette.palette.length < COLORS_PER_SUBPALETTE && 529 | subpalette.palette.indexOf(sharedBackgroundColor) < 0 530 | ) { 531 | subpalette.palette = subpalette.palette 532 | .concat([sharedBackgroundColor]) 533 | .sort((a, b) => comparatorAscending(a.color, b.color)); 534 | const newKey = paletteKey(subpalette.palette); 535 | if (newKey in subpalettesByKey) { 536 | subpalettesByKey[newKey].tilesUsing += subpalette.tilesUsing; 537 | } else { 538 | subpalettesByKey[newKey] = subpalette; 539 | } 540 | delete subpalettesByKey[key]; 541 | } 542 | }); 543 | } 544 | 545 | // Determine which colors to use 546 | log('Determining palette'); 547 | log(`${colorsUsed.length} NES colors used`); 548 | log(`Using ${normalizeColorHex(sharedBackgroundColor)} as background color`); 549 | 550 | // Get an array of subpalettes that we can rank and filter 551 | let subpalettes = Object.keys(subpalettesByKey).map(key => subpalettesByKey[key]); 552 | log(`${subpalettes.length} potential subpalettes`); 553 | 554 | // Filter subpalettes to ones that include the background color 555 | subpalettes = subpalettes.filter(subpalette => ( 556 | subpalette.palette.reduce((hasBackgroundColor, color) => ( 557 | hasBackgroundColor || normalizeColorHex(sharedBackgroundColor) === normalizeColorHex(color) 558 | ), false) 559 | )); 560 | log(`${subpalettes.length} valid subpalettes using the background color`); 561 | 562 | if (subpalettes.length === 0) { 563 | throw new Error('No color palettes have the background color!'); 564 | } 565 | 566 | // Rank and filter subpalettes 567 | subpalettes = subpalettes 568 | .sort(() => (Math.round(Math.random() * 2) - 1)) 569 | .slice(0, SUBPALETTES_PER_IMAGE * 8); 570 | // .sort((a, b) => comparatorDescending( 571 | // paletteScore(a, colorsUsedByKey, colorsUsed.length), 572 | // paletteScore(b, colorsUsedByKey, colorsUsed.length), 573 | // )) 574 | // .slice(0, SUBPALETTES_PER_IMAGE); 575 | 576 | // Assign a palette to each tile and draw it to the canvas 577 | log(`Assigning subpalettes to ${tiles.length} tiles from ${subpalettes.length} options`); 578 | const palettesUsedByKey = {}; 579 | tiles.forEach((tile) => { 580 | const ditherOptions = { 581 | step: tileQuantizationStep, 582 | algorithm: ditherAlgorithm, 583 | }; 584 | // Ideal tile image data using the full palette 585 | const tileImgData = ditherImageData(tile.srcImgData, globalPalette, ditherOptions); 586 | 587 | // Calculate dithered versions using each subpalette so we can choose the best one 588 | const tileImgDataOptions = subpalettes.map((subpalette) => { 589 | const subpaletteImgData = ditherImageData(tile.srcImgData, subpalette.palette, ditherOptions); 590 | const distance = imageDataDistance(tileImgData, subpaletteImgData); 591 | return { distance, subpalette, subpaletteImgData }; 592 | }) 593 | .sort((a, b) => comparatorAscending(a.distance, b.distance)); 594 | 595 | const { 596 | subpalette, 597 | subpaletteImgData, 598 | } = tileImgDataOptions[0]; 599 | 600 | palettesUsedByKey[paletteKey(subpalette.palette)] = subpalette.palette.sort(paletteSorter); 601 | 602 | ctx.putImageData(subpaletteImgData, tile.srcPosition[0], tile.srcPosition[1]); 603 | }); 604 | 605 | // Draw just the palette 606 | if (outputPalette) { 607 | log('Writing palette colors to canvas'); 608 | return canvasImage( 609 | arrayProto.concat.call(...Object.keys(palettesUsedByKey).map(key => palettesUsedByKey[key])), 610 | 16, 611 | 16, 612 | ); 613 | // return canvasImage(colorsUsed.map(c => c.color), 16, 16); 614 | } 615 | 616 | // Upscale final image 617 | if (upscale > 1) { 618 | log(`Upscaling ${upscale}x`); 619 | return upscaleCanvas(canvas, upscale); 620 | } 621 | 622 | // Have fun, kids 623 | return canvas; 624 | } 625 | 626 | export default nesify; 627 | -------------------------------------------------------------------------------- /src/sort.js: -------------------------------------------------------------------------------- 1 | export const SORT_A_B_EQUAL = 0; 2 | export const SORT_A_FIRST = -1; 3 | export const SORT_B_FIRST = 1; 4 | 5 | export function comparatorAscending(a, b) { 6 | if (a === b) { 7 | return SORT_A_B_EQUAL; 8 | } 9 | return a < b ? SORT_A_FIRST : SORT_B_FIRST; 10 | } 11 | 12 | export function comparatorDescending(a, b) { 13 | if (a === b) { 14 | return SORT_A_B_EQUAL; 15 | } 16 | return a > b ? SORT_A_FIRST : SORT_B_FIRST; 17 | } 18 | --------------------------------------------------------------------------------