Skip to main content

Remove Isolated Speckles from a Binary Logo Mask with Node.js

Updated 6 min readMarkdown

A logo trace can turn two stray pixels into two extra paths. If your pipeline already produces a foreground mask, you can inspect those islands before handing the image to a tracer. This walkthrough uses a text fixture so you can reproduce the decision without installing an image library or uploading a logo.

1. Decide what counts as a separate mark

In this fixture, # is foreground and . is background. Two foreground cells belong to the same component when they share an edge. Cells touching only at a corner remain separate. That is four-neighbor connectivity. If your lettering relies on diagonal joins, test that choice before applying the filter.

Area means the number of foreground cells in a component. With a threshold of 3, components with 1 or 2 cells are removed and components with 3 or more stay. A threshold of 1 keeps every foreground cell.

2. Walk each component once

Save the function below as clean-mask.mjs. It validates a small rectangular mask, tracks visited cells, and copies the input so the original remains available for comparison. The component array doubles as a work queue. Marking a cell when it enters the queue prevents two neighbors from adding it twice.

export function cleanMask(rows, minArea) {
  const width = rows?.[0]?.length;
  if (!Array.isArray(rows) || rows.length > 1024 ||
      !Number.isInteger(width) || width < 1 || width > 1024 ||
      !Array.from(rows).every(row => typeof row === 'string' &&
        row.length === width && !/[^.#]/.test(row))) {
    throw new TypeError('Expected a rectangular mask of . and #, up to 1024 by 1024');
  }
  if (!Number.isSafeInteger(minArea) || minArea < 1) {
    throw new RangeError('Expected a positive integer area threshold');
  }

  const output = rows.map(row => [...row]);
  const seen = new Uint8Array(width * rows.length);
  const removed = [];
  for (let y = 0; y < rows.length; y++) {
    for (let x = 0; x < width; x++) {
      const start = y * width + x;
      if (rows[y][x] !== '#' || seen[start]) continue;
      const component = [start];
      seen[start] = 1;
      for (let i = 0; i < component.length; i++) {
        const index = component[i];
        const col = index % width;
        const row = Math.floor(index / width);
        for (const [nx, ny] of [[col-1,row], [col+1,row], [col,row-1], [col,row+1]]) {
          if (nx < 0 || nx >= width || ny < 0 || ny >= rows.length) continue;
          const neighbor = ny * width + nx;
          if (seen[neighbor] || rows[ny][nx] !== '#') continue;
          seen[neighbor] = 1;
          component.push(neighbor);
        }
      }
      if (component.length >= minArea) continue;
      removed.push({ x, y, area: component.length });
      for (const index of component) {
        output[Math.floor(index / width)][index % width] = '.';
      }
    }
  }
  return { rows: output.map(row => row.join('')), removed };
}

The report uses zero-based coordinates from the top left. The x and y for a removed component identify its first cell in row-major scan order. They describe where to start inspecting, rather than the center of the mark or its bounding box.

The scan and the total neighbor work are linear in the number of cells. The copies, visited map, and largest component queue also use at most linear space. The size limit applies to the parsed mask. This small local CLI reads the file into memory first, so give it fixtures from your own workflow.

3. Add a CLI that leaves the original alone

Save this as clean-logo.mjs beside the function. It reads a UTF-8 text file, accepts LF and CRLF, and removes at most one final line ending. Blank rows inside the mask are invalid. The result goes to standard output. Usage errors return 2 and read or validation errors return 1.

import { readFileSync } from 'node:fs';
import { cleanMask } from './clean-mask.mjs';

const args = process.argv.slice(2);
if (args.length !== 2 || !/^[1-9][0-9]*$/.test(args[1])) {
  console.error('Usage: node clean-logo.mjs <mask.txt> <min-area>');
  process.exitCode = 2;
} else {
  try {
    const text = readFileSync(args[0], 'utf8');
    const rows = text.replace(/\r?\n$/, '').split(/\r?\n/);
    console.log(JSON.stringify(cleanMask(rows, Number(args[1])), null, 2));
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}

4. Run a fixture with three components

Save the following as mask.txt. The rectangle is 6 cells, the dot is 1, and the horizontal stroke is 2. Run node clean-logo.mjs mask.txt 3. Keep the input file alongside the JSON so reviewers can reproduce the comparison.

.........
.###...#.
.###.....
.....##..
.........

Expect this output. At threshold 2, only the dot is removed. That smaller setting is worth checking when a mark is small enough that two cells could carry real meaning.

{
  "rows": [
    ".........",
    ".###.....",
    ".###.....",
    ".........",
    "........."
  ],
  "removed": [
    {
      "x": 7,
      "y": 1,
      "area": 1
    },
    {
      "x": 5,
      "y": 3,
      "area": 2
    }
  ]
}

5. Review punctuation and diagonals before tracing

Compare each removed location with the original logo. A detached letter dot, trademark mark, or decorative accent can be small and intentional. Lower the threshold or keep the original mask for an asset if the removed list contains one of those details.

This operation removes entire disconnected components. A speck attached to a larger stroke by one edge stays attached, and a hole is background so it remains a hole. A change to threshold does not repair jagged edges, recover missing detail, or smooth a trace.

When the same logo is supplied at different scales, evaluate each mask size. A fixed area threshold can erase a mark in a smaller bitmap while leaving it in a larger one. Record the size, threshold, and connectivity so a reviewer can repeat the decision.

6. Record what was tested

The example was run on Node.js 24.20.0. The function was also checked against an independent edge-merging reference for all 512 binary masks of size 3 by 3 at four thresholds. Additional checks covered invalid shapes and thresholds and CLI exit codes. These checks used synthetic text fixtures. Image decoding, raster-to-mask conversion, SVG tracing, and visual quality were outside the test.

The next pipeline step can convert the cleaned mask into the raster format your tracer accepts. Keep the original asset, the mask, and the report together so a reviewer can undo a threshold choice without asking for a fresh export.

This tutorial was prepared with AI assistance. Its source is PixMind’s guide to cleaning up logo artwork before vectorization. See the Node.js file system documentation for the CLI’s file read.

1 view

About the author

  • The PixMind team shares practical AI image and video workflows. PixMind brings text-to-image, reference-based creation, editing, and video tools into one workspace. Website: https://www.pixmind.io/

No comments yet. Be the first to comment.

More from this blog