Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Colors in Posterizer output #15

Open
molandim opened this issue Feb 8, 2021 · 5 comments
Open

Colors in Posterizer output #15

molandim opened this issue Feb 8, 2021 · 5 comments

Comments

@molandim
Copy link

molandim commented Feb 8, 2021

Sorry, I don't know if the service was designed only to return grayscale or I if I am doing something wrong.

I tried different options, but still, the output from colourful png is always grayscale.

@jlarmstrongiv
Copy link

jlarmstrongiv commented Feb 11, 2021

@tooolbox with node-potrace and posterizer, I’m also trying calculate color images. I dug into the code a bit. If I understand it correctly, the posterizer gets a histogram of the luminances of all the pixels (light intensity as grey), and then calculates the steps based on that.

After researching a bit, I found that generating the histogram of an image does not necessarily give you the palette of that image. While a histogram would be pick out more neutral and background colors, a color palette would include bright highlights that the human eye would pick out (based on fuzzy difference and distance). However, it seems like you knew that and used thresholds and color intensity to fix it 👏

My thought is that I would hook into the color stops you generate, for example:

// color stops
[
  { value: 196, colorIntensity: 0.3764705882352941 },
  { value: 136, colorIntensity: 0.5333333333333333 },
  { value: 99, colorIntensity: 0.6352941176470588 },
  { value: 51, colorIntensity: 0.9176470588235294 }
]

And then use those to get the matching colors (unfortunately, I’m not quite sure how to do that 😅 yet)

Before I go and figure it out, I’m wondering if this is the direction you would take to create color images. Have you thought about this before? How would you go about making color images? I’d really appreciate your insight and any tips you have 🙏

@jlarmstrongiv
Copy link

So yea, it’s possible, but the results aren’t great. If you’re looking for color image tracing, there’s better solutions out there.

image
image
image

@molandim
Copy link
Author

molandim commented Feb 21, 2021

I think that is a wonderful output.

Maybe the results based on real pictures aren't great, but if the original image were a drawing or a vector image the outcome would be great.

@tooolbox
Copy link
Owner

Hey @jlarmstrongiv thanks for playing around with it. Honestly I just modified the potrace code enough to make it available to Node, so I'm not that deeply aware of the algorithm and its behavior. I don't particularly object to this package having the capability to output color, but I don't have time to dive in to do that right. I would be open to a PR that does get it right.

@jlarmstrongiv
Copy link

Here’s some old code I was experimenting with (and a more polished version):

Codeblock

This code block is licensed under GPL-2.0.

const potrace = require("potrace");
const fs = require("fs-extra");
const sharp = require("sharp");
const tinycolor = require("tinycolor2");
const quantize = require("quantize");
const SVGO = require("svgo");
const svgo = new SVGO();
const NearestColor = require("nearest-color");
const replaceAll = require("string.prototype.replaceall");
replaceAll.shim();

// https://stackoverflow.com/a/39077686
const hexToRgb = (hex) =>
  hex
    .replace(
      /^#?([a-f\d])([a-f\d])([a-f\d])$/i,
      (m, r, g, b) => "#" + r + r + g + g + b + b
    )
    .substring(1)
    .match(/.{2}/g)
    .map((x) => parseInt(x, 16));

// https://stackoverflow.com/a/35663683
function hexify(color) {
  var values = color
    .replace(/rgba?\(/, "")
    .replace(/\)/, "")
    .replace(/[\s+]/g, "")
    .split(",");
  var a = parseFloat(values[3] || 1),
    r = Math.floor(a * parseInt(values[0]) + (1 - a) * 255),
    g = Math.floor(a * parseInt(values[1]) + (1 - a) * 255),
    b = Math.floor(a * parseInt(values[2]) + (1 - a) * 255);
  return (
    "#" +
    ("0" + r.toString(16)).slice(-2) +
    ("0" + g.toString(16)).slice(-2) +
    ("0" + b.toString(16)).slice(-2)
  );
}

// https://graphicdesign.stackexchange.com/a/91018
function combineOpacity(a, b) {
  return 1 - (1 - a) * (1 - b);
}

function getSolid(svg) {
  svg = svg.replaceAll(`fill="black"`, "");
  const opacityRegex = /fill-opacity="[\d\.]+"/gi;
  const numberRegex = /[\d\.]+/;
  const matches = svg.match(opacityRegex);
  const colors = Array.from(new Set(matches))
    .map((fillOpacity) => ({
      fillOpacity,
      opacity: Number(fillOpacity.match(numberRegex)[0]),
    }))
    .sort((a, b) => b.opacity - a.opacity)
    .map(({ fillOpacity, opacity }, index, array) => {
      // combine all lighter opacities into dark opacity
      const lighterColors = array.slice(index);
      const trueOpacity = lighterColors.reduce(
        (acc, cur) => combineOpacity(acc, cur.opacity),
        0
      );
      // turn opacity into hex
      const hex = hexify(`rgba(0, 0, 0, ${trueOpacity})`);
      return {
        trueOpacity,
        fillOpacity,
        opacity,
        hex,
      };
    });
  for (const color of colors) {
    svg = svg.replaceAll(color.fillOpacity, `fill="${color.hex}"`);
  }
  return svg;
}

async function getPixels(input) {
  const image = sharp(input);
  const metadata = await image.metadata();
  const raw = await image.raw().toBuffer();

  const pixels = [];
  for (let i = 0; i < raw.length; i = i + metadata.channels) {
    const pixel = [];
    for (let j = 0; j < metadata.channels; j++) {
      pixel.push(raw.readUInt8(i + j));
    }
    pixels.push(pixel);
  }
  return { pixels, ...metadata };
}

async function replaceColors(svg, original) {
  // if greyscale image, return greyscale svg
  if ((await (await sharp(original).metadata()).channels) === 1) {
    return svg;
  }

  const hexRegex = /#([a-f0-9]{3}){1,2}\b/gi;
  const matches = svg.match(hexRegex);
  const colors = Array.from(new Set(matches));

  const pixelIndexesOfNearestColors = {}; // hex: [array of pixel indexes]
  colors.forEach((color) => (pixelIndexesOfNearestColors[color] = []));

  const svgPixels = await getPixels(Buffer.from(svg));

  const nearestColor = NearestColor.from(colors);

  svgPixels.pixels.forEach((pixel, index) => {
    // curly braces for scope https://stackoverflow.com/a/49350263
    switch (svgPixels.channels) {
      case 3: {
        const [r, g, b] = pixel;
        const rgb = `rgb(${r}, ${g}, ${b})`;
        const hex = hexify(rgb);
        pixelIndexesOfNearestColors[nearestColor(hex)].push(index);
        break;
      }
      case 4: {
        const [r, g, b, a] = pixel;
        const rgba = `rgba(${r}, ${g}, ${b}, ${a / 255})`;
        const hex = hexify(rgba);
        pixelIndexesOfNearestColors[nearestColor(hex)].push(index);
        break;
      }
      default:
        throw new Error("Unsupported number of channels");
    }
  });

  const originalPixels = await getPixels(original);
  const pixelsOfNearestColors = pixelIndexesOfNearestColors;
  Object.keys(pixelsOfNearestColors).forEach((hexKey) => {
    pixelsOfNearestColors[hexKey] = pixelsOfNearestColors[hexKey].map(
      (pixelIndex) => {
        const pixel = originalPixels.pixels[pixelIndex];
        switch (originalPixels.channels) {
          case 3: {
            const [r, g, b] = pixel;
            const rgb = `rgb(${r}, ${g}, ${b})`;
            return hexify(rgb);
          }
          case 4: {
            const [r, g, b, a] = pixel;
            const rgba = `rgba(${r}, ${g}, ${b}, ${a / 255})`;
            return hexify(rgba);
          }
          default:
            throw new Error("Unsupported number of channels");
        }
      }
    );
  });

  const colorsToReplace = pixelsOfNearestColors;
  // get palette of 5 https://github.com/lokesh/color-thief/blob/master/src/color-thief-node.js#L61
  Object.keys(pixelsOfNearestColors).forEach((hexKey) => {
    const pixelArray = colorsToReplace[hexKey].map(hexToRgb);
    const colorMap = quantize(pixelArray, 5);
    const [r, g, b] = colorMap.palette()[0];
    const rgb = `rgb(${r}, ${g}, ${b})`;
    colorsToReplace[hexKey] = hexify(rgb);
  });
  Object.entries(colorsToReplace).forEach(([oldColor, newColor]) => {
    svg = svg.replaceAll(oldColor, newColor);
  });

  return svg;
}

async function start() {
  let svg = await new Promise((resolve, reject) => {
    potrace.posterize(
      "./demo.png",
      {
        // number of colors
        // steps: 10,
      },
      function (err, svg) {
        if (err) return reject(err);
        resolve(svg);
      }
    );
  });
  svg = getSolid(svg);
  svg = await replaceColors(svg, await fs.readFile("./demo.png"));
  svg = (await svgo.optimize(svg)).data;
  fs.outputFileSync("./demo.svg", svg);
  sharp(Buffer.from(svg)).png({ colors: 4 }).toFile("./demo-from-svg.png");
}
start();

/**
 * One option is turning the image into pixels
 * Calculating the luminosity of each pixel
 * Grouping it to the nearest step
 * And using the color thief https://github.com/lokesh/color-thief/blob/master/src/color-thief-node.js#L82
 * To figure out which color is most common for that particular step
 *
 * Another option is to take the output svg
 * Turn it into a png
 * Unfortunately, due to a lack of crisp edges / anti aliasing
 * You still have to group the colors into the nearest step
 * Then you essentially "match" those colors to the coordinates in the color image
 * And run the same color thief for that particular step
 */

I think my version is a bit hacky, and there are better ways to implement it. Personally, I think it’s more fitting as a proof of concept, than a PR. While I do not have time to clean up and merge it, a future version should:

  • Choose a single image library (node-potrace uses jimp, I used sharp)
  • Integrate with the node-potrace library (I built mine on top of it)
  • Add optimizations (because I built it on top of node-potrace without access to internal variables and methods, I lost substantial performance)

The demo is currently broken, because I can’t afford $20/mo 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants