1

I have two all black SVG paths, consisting of [M]ove, [C]urve, [Q]uad, and [Z]close commands.

I'd like to figure out how similar they are, or what percentage of one curve overlaps with the other.

I know there's lots of tools for svg that can 'subtract' paths - if I can find the area of the SVG in a given plane, I can compare it to the area of the subtracted SVG, and compare the two. This would be sufficient for my purposes.

However, I can't seem to figure out how to compare SVG paths or subtract the paths in JavaScript.

Is there an easy way to do this, or is my best bet just to rasterize the paths and compare the pixel difference?

cadlac
  • 2,802
  • 3
  • 18
  • 34
  • Here is a pixel fill that might help: https://stackoverflow.com/questions/67873157/scaling-the-filling-portion-of-an-svg-path/68082501#68082501 – Danny '365CSI' Engelman Feb 11 '23 at 11:12
  • The project is getting a bit older, but [paper.js](http://paperjs.org/reference/pathitem/) has some boolean path operations and intersection methods. – ccprog Feb 11 '23 at 13:05

1 Answers1

1

There are different qualities of similarity

  1. 100% congruent e.g. if pathData d attribute is identical
  2. visually equal: path shapes appear visually identical, but have different layout properties e.g. x/y offsets or sizes
  3. visually quite similar: caused by sub-ideal optimizing e.g. coordinate rounding – so you would need to define a certain threshold

Fortunately we can make some assumptions. However, we always need some kind of threshold value to compensate rounding errors etc.

Basic requirements for similar paths

  1. must have the same aspect ratio (rounded)
  2. as well as the same pathLength (as calculated via getTotalLength()
  3. same number of sub paths – checked via simple regex like path.getAttribute('d').match(/m/gi).length

We can use these simple requirements to define exclusion criteria to avoid more expensive processing/calculation steps.

let compare1 = compareElements(g0, g1);
console.log(compare1);

let compare2 = compareElements(g0, g2);
console.log(compare2);

let compare3 = compareElements(g0, g3);
console.log(compare3);

let compare4 = compareElements(g0, g4);
console.log(compare4);


function compareElements(el1, el2, tolerance = 1.5, checkPoints = 24) {
  let svg = el2.closest('svg');
  let similarity = 0;
  let style = window.getComputedStyle(el1);
  let strokeWidth = style.strokeWidth;
  let stroke = style.stroke;
  let d1 = el1.getAttribute('d');
  let d2 = el2.getAttribute('d');

  /**
  * optional:
  * compare the total number of commands
    let commandCount1 = d1.match(/[macsqtlhvz]/gi).length;
    let commandCount2 = d2.match(/[macsqtlhvz]/gi).length;
  */

  let subPathCount1 = d1.match(/m/gi).length;
  let subPathCount2 = d2.match(/m/gi).length;

  let same = {
    size: false,
    sizeRel: false,
    identical: false,
    aspect: false,
    pathLength: false,
    pathLengthRel: false,
    pointOnPath: false,
    pointOnPathRel: false,
    subPathCount: false,
    pos: false,
    score: 0
  }

  /**
   * 0. Compare d attribute - worth a try ;)
   * if identical we can stop here
   *  */
  if (d1 === d2) {
    same.size = true,
      same.sizeRel = true,
      same.identical = true,
      same.aspect = true,
      same.pathLength = true,
      same.pathLengthRel = true,
      same.pointOnPath = true,
      same.pointOnPathRel = true,
      subPathCount = true,
      pos = true,
      same.score = 10;
    colorSimilarity(el2, same);
    return same;
  }

  /**
   * 0.2 different number of subpaths 
   * probably we should stop here
   */
  if (subPathCount1 !== subPathCount2) {
    colorSimilarity(el2, same);
    return same;
  } else {
    similarity++
    same.subPathCount = true;
  }

  /**
   * 1. compare sizes 
   */
  let bb1 = el1.getBBox();
  let bb2 = el2.getBBox();

  let scale = bb1.width / bb2.width;
  let simWidth = 1 / bb1.width * bb2.width;
  let simWidthRel = 1 / bb1.width * (bb2.width * scale);
  let simHeight = 1 / bb1.height * bb2.height;
  let simHeightRel = 1 / bb1.height * (bb2.height * scale);


  /**
   * 1.1 offsets: sizes might be equal
   * but compared elements might have different positions in layout
   */
  let offsetX = bb1.x - bb2.x;
  let offsetY = bb1.y - bb2.y;

  if (simWidth > 0.9 && simHeight > 0.9) {
    same.size = true;
    same.sizeRel = true
    similarity += 2
  } else if (simWidthRel > 0.9 && simHeightRel > 0.9) {
    same.sizeRel = true
    similarity++
  }

  if (Math.abs(offsetX) < 0.1 && Math.abs(offsetY) < 0.1) {
    same.pos = true;
    similarity++
  }

  /**
   * 2. Compare aspect ratios
   * visually similar elements must have 
   * similar aspect ratios
   */
  let aspect1 = bb1.width / bb1.height;
  let aspect2 = bb2.width / bb2.height;
  let simAspect = 1 / aspect1 * aspect2;


  /** 
   * 3. compare pathLength
   * visually similar elements must have a similar path length
   */
  if (simAspect > 0.9) {
    same.aspect = true;
    similarity++

    let pathLength1 = el1.getTotalLength();
    let pathLength2 = el2.getTotalLength();
    let pathLength2Rel = pathLength2 * scale;
    let simPathLength = 1 / pathLength1 * pathLength2;
    let simPathLengthRel = 1 / pathLength1 * pathLength2Rel;

    if (simPathLength > 0.9) {
      same.pathLength = true;
      similarity++
    }
    if (simPathLengthRel > 0.9) {
      same.pathLengthRel = true;
      similarity++
    }

    let intersects = false;
    let pointsInStroke = 0;
    let pointsInStrokeRel = 0;

    // 4. points on stroke
    // increase stroke width temporarily for better tolerance
    el1.style.strokeWidth = tolerance + '%';
    el1.style.stroke = '#ccc';

    for (let i = 0; i < checkPoints; i++) {
      let p = el2.getPointAtLength(pathLength2 / checkPoints * i);
      let pO = p;

      // 4.1 direct intersection
      if (same.size && same.pos) {
        intersects = el1.isPointInStroke(p)
        if (intersects) {
          pointsInStroke++;
          renderPoint(svg, p, 'green', '0.5%')
        }
      }
      // 4.2 same shape but different position or scale 
      else {
        let matrix = svg.createSVGMatrix();
        matrix = matrix.translate(bb2.x, bb2.y);
        matrix = matrix.scale(scale);
        matrix = matrix.translate(bb2.x * -1, bb2.y * -1);
        matrix = matrix.translate(offsetX / scale, offsetY / scale);
        p = p.matrixTransform(matrix);

        intersects = el1.isPointInStroke(p);
        if (intersects) {
          renderPoint(svg, pO, 'orange', '0.5%')
          pointsInStrokeRel++
        } else {
          renderPoint(svg, pO, 'red', '0.5%')
        }
      }
    }

    let pointsInStrokeRat = 1 / checkPoints * pointsInStroke;
    let pointsInStrokeRelRat = 1 / checkPoints * pointsInStrokeRel;


    if (pointsInStrokeRat > 0.75) {
      same.pointOnPath = true;
      same.pointOnPathRel = true;
      similarity += pointsInStrokeRat * 2;
    } else if (pointsInStrokeRelRat > 0.75) {
      same.pointOnPathRel = true;
      similarity += pointsInStrokeRelRat
    }

    // reset stroke
    el1.style.strokeWidth = strokeWidth;
    el1.style.stroke = stroke;

    // set score
    same.score = similarity;

    /**
     * just for display
     */
    colorSimilarity(el2, same);

  }

  return same;

}

function colorSimilarity(el, same) {
  if (same.score >= 9) {
    el.setAttribute('fill', 'hsl(100deg 100% 25%)');
  } else if (same.score >= 5) {
    el.setAttribute('fill', 'hsl(75deg 100% 25%)');
  } else if (same.score >= 3) {
    el.setAttribute('fill', 'hsl(50deg 100% 25%)');
  } else {
    el.setAttribute('fill', 'red');
  }
}


function renderPoint(
  svg,
  coords,
  fill = "red",
  r = "2",
  opacity = "1",
  id = "",
  className = ""
) {
  if (Array.isArray(coords)) {
    coords = {
      x: coords[0],
      y: coords[1]
    };
  }

  let marker = `<circle class="${className}" opacity="${opacity}" id="${id}" cx="${coords.x}" cy="${coords.y}" r="${r}" fill="${fill}">
  <title>${coords.x} ${coords.y}</title></circle>`;
  svg.insertAdjacentHTML("beforeend", marker);
}
svg {
  width: auto;
  height: 20em;
  border: 1px solid #ccc;
}

text,
tspan {
  font-family: sans-serif;
  text-anchor: middle;
}
<svg class="svgText" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 120">
    <text x="50%" y="5%" font-size="2.5">#g0
        <tspan x="50%" dy="1.2em"></tspan>
    </text>
    <path id="g0"
      d="M46.8 34.9 L49.5 43.2 Q46.5 44.2 42.9 44.5 Q39.3 44.8 34.1 44.8 L34.1 44.8 Q43.4 49 43.4 58.1 L43.4 58.1 Q43.4 66 38 71 Q32.6 76 23.3 76 L23.3 76 Q19.7 76 16.6 75 L16.6 75 Q15.4 75.8 14.7 77.2 Q14 78.5 14 79.9 L14 79.9 Q14 84.2 20.9 84.2 L20.9 84.2 L29.3 84.2 Q34.6 84.2 38.7 86.1 Q42.8 88 45.1 91.3 Q47.3 94.6 47.3 98.8 L47.3 98.8 Q47.3 106.5 41 110.7 Q34.7 114.8 22.6 114.8 L22.6 114.8 Q14.1 114.8 9.2 113 Q4.2 111.3 2.1 107.8 Q0 104.3 0 98.8 L0 98.8 L8.3 98.8 Q8.3 102 9.5 103.8 Q10.7 105.7 13.8 106.7 Q16.9 107.6 22.6 107.6 L22.6 107.6 Q30.9 107.6 34.5 105.5 Q38 103.5 38 99.4 L38 99.4 Q38 95.7 35.2 93.8 Q32.4 91.9 27.4 91.9 L27.4 91.9 L19.1 91.9 Q12.4 91.9 9 89 Q5.5 86.2 5.5 81.9 L5.5 81.9 Q5.5 79.3 7 76.9 Q8.5 74.5 11.3 72.6 L11.3 72.6 Q6.7 70.2 4.6 66.7 Q2.4 63.1 2.4 58 L2.4 58 Q2.4 52.7 5.1 48.5 Q7.7 44.3 12.4 41.9 Q17 39.6 22.7 39.6 L22.7 39.6 Q28.9 39.7 33.1 39.1 Q37.3 38.6 40.1 37.6 Q42.8 36.7 46.8 34.9 L46.8 34.9 ZM22.7 46.2 Q17.5 46.2 14.7 49.4 Q11.8 52.7 11.8 58 L11.8 58 Q11.8 63.4 14.7 66.7 Q17.6 69.9 22.9 69.9 L22.9 69.9 Q28.3 69.9 31.1 66.8 Q34 63.6 34 57.9 L34 57.9 Q34 46.2 22.7 46.2 L22.7 46.2 Z ">
    </path>
  </svg>


<svg class="svgText" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 120">
    <text x="50%" y="5%" font-size="2.5">#g1
        <tspan x="50%" dy="1.2em">Identical to #p0</tspan>
    </text>
    <path id="g1"
      d="M46.8 34.9 L49.5 43.2 Q46.5 44.2 42.9 44.5 Q39.3 44.8 34.1 44.8 L34.1 44.8 Q43.4 49 43.4 58.1 L43.4 58.1 Q43.4 66 38 71 Q32.6 76 23.3 76 L23.3 76 Q19.7 76 16.6 75 L16.6 75 Q15.4 75.8 14.7 77.2 Q14 78.5 14 79.9 L14 79.9 Q14 84.2 20.9 84.2 L20.9 84.2 L29.3 84.2 Q34.6 84.2 38.7 86.1 Q42.8 88 45.1 91.3 Q47.3 94.6 47.3 98.8 L47.3 98.8 Q47.3 106.5 41 110.7 Q34.7 114.8 22.6 114.8 L22.6 114.8 Q14.1 114.8 9.2 113 Q4.2 111.3 2.1 107.8 Q0 104.3 0 98.8 L0 98.8 L8.3 98.8 Q8.3 102 9.5 103.8 Q10.7 105.7 13.8 106.7 Q16.9 107.6 22.6 107.6 L22.6 107.6 Q30.9 107.6 34.5 105.5 Q38 103.5 38 99.4 L38 99.4 Q38 95.7 35.2 93.8 Q32.4 91.9 27.4 91.9 L27.4 91.9 L19.1 91.9 Q12.4 91.9 9 89 Q5.5 86.2 5.5 81.9 L5.5 81.9 Q5.5 79.3 7 76.9 Q8.5 74.5 11.3 72.6 L11.3 72.6 Q6.7 70.2 4.6 66.7 Q2.4 63.1 2.4 58 L2.4 58 Q2.4 52.7 5.1 48.5 Q7.7 44.3 12.4 41.9 Q17 39.6 22.7 39.6 L22.7 39.6 Q28.9 39.7 33.1 39.1 Q37.3 38.6 40.1 37.6 Q42.8 36.7 46.8 34.9 L46.8 34.9 ZM22.7 46.2 Q17.5 46.2 14.7 49.4 Q11.8 52.7 11.8 58 L11.8 58 Q11.8 63.4 14.7 66.7 Q17.6 69.9 22.9 69.9 L22.9 69.9 Q28.3 69.9 31.1 66.8 Q34 63.6 34 57.9 L34 57.9 Q34 46.2 22.7 46.2 L22.7 46.2 Z ">
    </path>
  </svg>


<svg class="svgText" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 495 1200" data-desc="24.083">
    <text x="50%" y="5%" font-size="25">#g2
        <tspan x="50%" dy="1.2em">X/Y Offsets; scaled; optimized</tspan>
        <tspan x="50%" dy="1.2em">visually equal</tspan>
    </text>
    <path id="g2"
      d="M568 149l27 83q-30 10-66 13t-88 3l0 0q93 42 93 133l0 0q0 79-54 129t-147 50l0 0q-36 0-67-10l0 0q-12 8-19 21.5t-7 27.5l0 0q0 43 69 43l0 0h84q53 0 94 19t63.5 52t22.5 75l0 0q0 77-63 118.5t-184 41.5l0 0q-85 0-134.5-17.5t-70.5-52.5t-21-90l0 0h83q0 32 12 50.5t43 28t88 9.5l0 0q83 0 118.5-20.5t35.5-61.5l0 0q0-37-28-56t-78-19l0 0h-83q-67 0-101.5-28.5t-34.5-71.5l0 0q0-26 15-50t43-43l0 0q-46-24-67.5-59.5t-21.5-86.5l0 0q0-53 26.5-95t73-65.5t103.5-23.5l0 0q62 1 104-4.5t69.5-15t67.5-27.5l0 0zm-241 113q-52 0-80.5 32.5t-28.5 85.5l0 0q0 54 29 86.5t82 32.5l0 0q54 0 82.5-31.5t28.5-88.5l0 0q0-117-113-117l0 0z">
    </path>
  </svg>


<svg class="svgText" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 12" data-desc="24.083">
    <text x="50%" y="5%" font-size="0.25">#g3
        <tspan x="50%" dy="1.2em">Downscaled; badly optimized</tspan>
        <tspan x="50%" dy="1.2em">... still quite similar</tspan>
    </text>
    <path id="g3"
      d="M4.7 3.5l0.3 0.8c-0.2 0.1-0.4 0.1-0.7 0.2s-0.6 0-0.9 0l0 0c0.6 0.3 0.9 0.7 0.9 1.3l0 0c0 0.5-0.2 1-0.5 1.3s-0.8 0.5-1.5 0.5l0 0c-0.2 0-0.4 0-0.6-0.1l0 0s-0.2 0.1-0.2 0.2c-0.1 0.1-0.1 0.2-0.1 0.3l0 0c0 0.3 0.2 0.4 0.7 0.4l0 0h0.8c0.4 0 0.7 0.1 1 0.2s0.5 0.3 0.6 0.5s0.2 0.5 0.2 0.8l0 0c0 0.5-0.2 0.9-0.6 1.2s-1 0.4-1.8 0.4l0 0c-0.6 0-1.1-0.1-1.4-0.2s-0.6-0.3-0.7-0.5s-0.2-0.6-0.2-0.9l0 0h0.8s0 0.4 0.1 0.5s0.3 0.2 0.5 0.3s0.5 0.1 0.9 0.1l0 0c0.5 0 0.9-0.1 1.1-0.2s0.4-0.4 0.4-0.7l0 0c0-0.2-0.1-0.4-0.3-0.5s-0.5-0.2-0.8-0.2l0 0h-0.8c-0.5 0-0.8-0.1-1-0.3s-0.3-0.4-0.3-0.7l0 0c0-0.2 0-0.4 0.1-0.5c0.1-0.1 0.3-0.3 0.4-0.4l0 0c-0.3-0.2-0.5-0.4-0.6-0.6c-0.2-0.3-0.3-0.6-0.3-0.9l0 0c0-0.3 0.1-0.6 0.3-0.9s0.4-0.6 0.7-0.7c0.3-0.1 0.7-0.2 1.1-0.2l0 0c0.4 0 0.7 0 1-0.1s0.5 0 0.7-0.1s0.4-0.2 0.7-0.3l0 0zm-2.4 1.1c-0.3 0-0.6 0.1-0.8 0.3c-0.2 0.3-0.3 0.6-0.3 0.9l0 0c0 0.3 0.1 0.6 0.3 0.9s0.5 0.3 0.8 0.3l0 0c0.3 0 0.6-0.1 0.8-0.3s0.3-0.5 0.3-0.9l0 0c0-0.8-0.4-1.2-1.1-1.2l0 0z">
    </path>
  </svg>


<svg class="svgText" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 495 1200" data-desc="24.083">
    <text x="50%" y="5%" font-size="25">#g4
        <tspan x="50%" dy="1.2em">X/Y Offsets; scaled; optimized</tspan>
        <tspan x="50%" dy="1.2em">Not same number of sub paths</tspan>
        <tspan x="50%" dy="1.2em">Disqualified!</tspan>
    </text>
    <path id="g4"
      d="M468 349l27 83q-30 10-66 13t-88 3l0 0q93 42 93 133l0 0q0 79-54 129t-147 50l0 0q-36 0-67-10l0 0q-12 8-19 21.5t-7 27.5l0 0q0 43 69 43l0 0h84q53 0 94 19t63.5 52t22.5 75l0 0q0 77-63 118.5t-184 41.5l0 0q-85 0-134.5-17.5t-70.5-52.5t-21-90l0 0h83q0 32 12 50.5t43 28t88 9.5l0 0q83 0 118.5-20.5t35.5-61.5l0 0q0-37-28-56t-78-19l0 0h-83q-67 0-101.5-28.5t-34.5-71.5l0 0q0-26 15-50t43-43l0 0q-46-24-67.5-59.5t-21.5-86.5l0 0q0-53 26.5-95t73-65.5t103.5-23.5l0 0q62 1 104-4.5t69.5-15t67.5-27.5l0 0">
    </path>
  </svg>

The above example will return an object containing several details about the compared similarity:

let same = {
    size: false,            // 1
    sizeRel: false,         // 2
    identical: false,       // 3
    aspect: false,          // 4
    pathLength: false,      // 5
    pathLengthRel: false,   // 6
    pointOnPath: false,     // 7
    pointOnPathRel: false,  // 8
    subPathCount: false,    // 9 
    pos: false,             // 10
    score: 0                // 11
}

1–2. exactly the same size or same size according to scaling
3. perfectly identical, due to identical d attribute
4. aspect ratio
5–6. pathlength or scaled pathlength is the same
7–8. points intersecting (directly or transformed)
9. number of subpaths
10. exact same x/y position
11. summarized score

The most expensive step is caused by the actual intersection check:
We're basically testing if a limited number of points in path2 (retrieved via getPointAtlength() is also intersecting with path1's stroke via isPointInStroke() method.

The benefit of using isPointInStroke() – we can also tweak the tolerance/precision threshold by temporarily increasing/decreasing the base element's stroke-width.

herrstrietzel
  • 11,541
  • 2
  • 12
  • 34
  • Hey, this is great! Thanks so much for all the detail. Using `isPointInStroke` in combination with `strokeWidth` for the tolerance is really clever! One thing to point out - the number of points is often irrelevant unless you're checking for exactness. The same exact path can be described using any number of points, but it indeed may be helpful for some, and can be otherwise useful information – cadlac Feb 15 '23 at 08:13
  • Got me! I've commented out the command count (the original script included this as an output value). Off course you're right the number of commands doesn't tell anything about the visual similarity. The number of criteria depends on the usecase – an [area check](https://stackoverflow.com/questions/10039679/how-can-i-calculate-the-area-of-a-bezier-curve) could also be useful to get mirrored/flipped shapes. BTW the most expensive method is `getPointAtlength()` negligible if run only 24 times. `isPointInStroke()` is pretty optimized (apparently using the pointer event interface) – herrstrietzel Feb 15 '23 at 23:50