creamygoat

Pix2SVG - Paletted image to (pixelated geometric) SVG converter

Nov 5th, 2025 (edited)
234
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 36.51 KB | None | 0 0
  1. #!/usr/bin/python3
  2.  
  3. """
  4. ; Pix2SVG - A paletted image to SVG converter
  5. ; Version: 1.1.0.0
  6. ; (c) Copyright 2025, Daniel Neville
  7.  
  8. ; This program is free software: you can redistribute it and/or modify
  9. ; it under the terms of the GNU General Public License as published by
  10. ; the Free Software Foundation, either version 3 of the License, or
  11. ; (at your option) any later version.
  12. ;
  13. ; This program is distributed in the hope that it will be useful,
  14. ; but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. ; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. ; GNU General Public License for more details.
  17. ;
  18. ; You should have received a copy of the GNU General Public License
  19. ; along with this program. If not, see <https://www.gnu.org/licenses/>.
  20. """
  21.  
  22. import os
  23. import sys
  24. import traceback
  25. import math
  26. import numpy as np
  27. import itertools
  28. import re
  29. import html
  30. import argparse
  31. from PIL import Image
  32.  
  33.  
  34. VERSION = '1.1.0.0'
  35.  
  36.  
  37. class Error (Exception):
  38.   pass
  39.  
  40. class ArgError (Error):
  41.   pass
  42.  
  43. class FileError(Error):
  44.   pass
  45.  
  46. class DataError(Error):
  47.   pass
  48.  
  49. class CmdError(Error):
  50.   pass
  51.  
  52.  
  53. #-----------------------------------------------------------------------------
  54. # Number formatting and text handling functions
  55. # (These should really be in a separate file.)
  56. #-----------------------------------------------------------------------------
  57.  
  58.  
  59. # Regular expressions
  60.  
  61. re_std_decimal_number = fp = re.compile(
  62.     r"^[+-]?([0-9]+\.?[0-9]*|[0-9]*\.?[0-9]+)([Ee][-+]?[0-9]+)?")
  63. re_trailing_zeros = re.compile(r".*?\.[0-9]+?(0+)(?:[Ee][^Ee]*)?$")
  64. re_decimal_zeros = re.compile(r".*?(\.0*)(?:[Ee][^Ee]*)?$")
  65. re_neg_zero = re.compile(
  66.     r"^([-\u2212])(0+\.0*|0*\.0+)([Ee][+-\u2212]?[0-9]+)?$")
  67. re_zero_point = re.compile(r"^[-\u2212+]?(0)\.[0-9].*")
  68.  
  69.  
  70. #-----------------------------------------------------------------------------
  71.  
  72.  
  73. def remove_trailing_zeros(x, keep_dp=False):
  74.   """Return a string representation of a number with trailing zeros stripped.
  75.  
  76.  The number may already be a string representation, in which case, any
  77.  spurious trailing decimal point will be removed.
  78.  
  79.  If keep_dp is False (the default), the removal of trailing zeros extends
  80.  to the removal of the decimal point as well.
  81.  
  82.  Numbers expressed in E notation are handled correctly.
  83.  
  84.  This function requires any decimal point to be preceded by a digit.
  85.  """
  86.   s = str(x)
  87.   if m := re_trailing_zeros.match(s):
  88.     span = m.span(1)
  89.     s = s[:span[0]] + s[span[1]:]
  90.   if not keep_dp:
  91.     if m := re_decimal_zeros.match(s):
  92.       span = m.span(1)
  93.       s = s[:span[0]] + s[span[1]:]
  94.   return s
  95.  
  96.  
  97. #-----------------------------------------------------------------------------
  98.  
  99.  
  100. def nnz_str(s):
  101.   """Strip any leading minus sign if the significand is zero.
  102.  
  103.  The Unicode minus sign U+2212 may be used as well as the usual U+0045.
  104.  """
  105.   s = str(s)
  106.   return s[1 if re_neg_zero.match(s) else 0:]
  107.  
  108.  
  109. #-----------------------------------------------------------------------------
  110.  
  111.  
  112. def nzbdp_str(s, keep_zbdp=False):
  113.   """Remove the leading zero before a decimal point for a small number.
  114.  
  115.  Negative numbers and numbers with negative exponents are handled
  116.  correctly, including those which use the Unicode minus sign U+2212
  117.  instead of the usual U+0045.
  118.  
  119.  If keep_zbdp is True, this function does no more than the usual
  120.  conversion to a string if the first argument was not a string already.
  121.  """
  122.   s = str(s)
  123.   if not keep_zbdp:
  124.     if m := re_zero_point.match(s):
  125.       span = m.span(1)
  126.       s = s[:span[0]] + s[span[1]:]
  127.   return s
  128.  
  129.  
  130. #-----------------------------------------------------------------------------
  131.  
  132.  
  133. def max_dp_str_fn(max_dp=6, keep_dp=False, keep_zbdp=True):
  134.   """Return a function which rounds to a given number of decimal places.
  135.  
  136.  The number of decimal places, whether to always keep the decimal
  137.  point and whether to keep the a leading zero before a decimal point
  138.  is given in this function, which returns a single-argument function
  139.  via partial application.
  140.  """
  141.   if max_dp is None: max_dp = 70
  142.   return lambda x: nzbdp_str(
  143.     remove_trailing_zeros(
  144.       nnz_str(f"{x:.{max_dp}f}"), keep_dp
  145.     ),
  146.     keep_zbdp
  147.   )
  148.  
  149.  
  150. #-----------------------------------------------------------------------------
  151.  
  152.  
  153. def lines_from_frags(
  154.   frags,
  155.   indent_str=None,
  156.   spacer=" ",
  157.   columns=78,
  158.   head=None,
  159.   tail=None
  160. ):
  161.   """Format text fragments into lines.
  162.  
  163.  This generator function allows the frags argument to be a generator.
  164.  
  165.  If neither the head nor the tail is specified, the first line will
  166.  begin with indent_str rather than head. In any case, head is expected
  167.  to incorporate its own indentation.
  168.  
  169.  If head is longer than indent_str, a long fragment which cannot fit
  170.  on the same line as head within the limit specified by columns will
  171.  be placed on the next line, indented. A long fragment which cannot
  172.  properly fit on a line on its own will still have the specified
  173.  indentation.
  174.  
  175.  if the tail is short (up to four characters), an attempt is made to
  176.  keep the final fragment and the tail together. If tail is on a line of
  177.  its own, that line begins with the same indent as the usual lines.
  178.  """
  179.   indent_str = "" if indent_str is None else str(indent_str)
  180.   default_head = indent_str if head is None and tail is None else ""
  181.   head = default_head if head is None else str(head)
  182.   tail = "" if tail is None else str(tail)
  183.   frag = None
  184.   started = False
  185.   spacer = "" if spacer is None else str(spacer)
  186.   L = []
  187.   line_start = head
  188.   num_chars = len(line_start)
  189.   for next_frag in itertools.chain(frags, (None, None)):
  190.     if started:
  191.       thres = columns
  192.       if frag is not None and next_frag is None:
  193.         if len(tail) < 5:
  194.           thres = min(
  195.             columns,
  196.             max(len(indent_str) + 1, columns - len(tail))
  197.           )
  198.       if frag is None:
  199.         line = line_start + spacer.join(L)
  200.         num_chars = len(line)
  201.         if num_chars + len(tail) <= thres:
  202.           yield line + tail
  203.         else:
  204.           yield line
  205.           yield indent_str + tail
  206.         break
  207.       else:
  208.         frag = str(frag)
  209.         s = len(spacer) if L else 0
  210.         if (num_chars + s + len(frag) <= thres
  211.             or (not L and len(line_start) <= len(indent_str))):
  212.           L.append(frag)
  213.           num_chars += s + len(frag)
  214.         else:
  215.           yield line_start + spacer.join(L)
  216.           L = [frag]
  217.           line_start = indent_str
  218.           num_chars = len(line_start) + len(frag)
  219.     started = True
  220.     frag = next_frag
  221.  
  222.  
  223. #-----------------------------------------------------------------------------
  224. # Generic SVG functions
  225. #-----------------------------------------------------------------------------
  226.  
  227.  
  228. def svg_path_polygon_frags(polygon, spacing_level=3, max_dp=None):
  229.   """Generate SVG code fragments For a closed polygon loop.
  230.  
  231.  A polygon is defined as a sequence of 2D absolute coordinates,
  232.  which may be supplied by a generator. The output of this generator
  233.  function is a series of path drawing commands, one command
  234.  per coordinate pair pluf a final "z" command to close the loop.
  235.  
  236.  The first command will be an absolute move-to command ("M").
  237.  Following commands (before the final "z") will be absolute or
  238.  relative 1D or 2D line-to commands, whichever is most compact.
  239.  
  240.  The Spacing Level determines the insertion of spaces between the
  241.  drawing command and its coordinate(s) and between the x and y
  242.  parts of the coordinates. A Spacing Level of -1 invokes a compact
  243.  coding permitted by the SVG specification: The leading zeros of
  244.  small decimal numbers are omitted and the comma separator is
  245.  omitted when the following number begins with anything but a digit.
  246.  
  247.           Between
  248.  Spacing  Command and    Between
  249.  Level    Coordinate(s)  Coordinates
  250.  
  251.  3+:      one space      one space
  252.  2:       one space      no space
  253.  0, 1:    no space       no space
  254.  -1:      Exclude comma separator and leading zero before a decimal point.
  255.  """
  256.   s = spacing_level if spacing_level is not None else 0
  257.   if max_dp is None: max_dp = 6
  258.   mdp = max_dp_str_fn(max_dp, keep_dp=False, keep_zbdp=True)
  259.   s1 = " " if s >= 2 else ""
  260.   if s < 0:
  261.     mdp1 = max_dp_str_fn(max_dp, keep_dp=False, keep_zbdp=False)
  262.     fmt1 = lambda cmd, x: f"{cmd}{mdp1(x)}"
  263.     fmt2 = lambda cmd, x, y: "".join((cmd, A := mdp1(x),
  264.         "," if ('.' not in A if (B := mdp1(y))[0] == '.'
  265.         else '0' <= B[0] <= '9') else "", B))
  266.   else:
  267.     s2fn = lambda y: ", "[:(s>=3)+(s>=0)|('0'<=(A:=mdp(y))[0]<='9')]+A
  268.     fmt1 = lambda cmd, x: f"{cmd}{s1}{mdp(x)}"
  269.     fmt2 = lambda cmd, x, y: f"{cmd}{s1}{mdp(x)}{s2fn(y)}"
  270.   P = None
  271.   for Q in polygon:
  272.     x = Q[0]
  273.     y = Q[1]
  274.     if P:
  275.       dx = x - P[0]
  276.       dy = y - P[1]
  277.       if dx and dy:
  278.         rel_frag = fmt2('l', dx, dy)
  279.         abs_frag = fmt2('L', x, y)
  280.       else:
  281.         rel_frag = fmt1('h', dx) if dx else fmt1('v', dy)
  282.         abs_frag = fmt1('H', x) if dx else fmt1('V', y)
  283.       yield rel_frag if len(rel_frag) < len(abs_frag) else abs_frag
  284.     else:
  285.       yield fmt2('M', x, y)
  286.     P = Q
  287.   yield 'z'
  288.  
  289.  
  290. #-----------------------------------------------------------------------------
  291.  
  292.  
  293. def svg_path_multipolygon_lines(
  294.   polygons,
  295.   spacing_level=3,
  296.   max_dp=None,
  297.   indent_str=None,
  298.   columns=78,
  299.   head="",
  300.   tail=""
  301. ):
  302.   """Generate SVG code lines For a closed polygon loop.
  303.  
  304.  A polygon is defined as a sequence of 2D absolute coordinates,
  305.  which may be supplied by a generator. The polygons argument may
  306.  also be a generator. The output of this generator function is a
  307.  series of path drawing commands, one command per coordinate pair
  308.  plus a final "z" command for each polygon.
  309.  
  310.  For each polygon, the first command will be an absolute move-to
  311.  command ("M"). Following commands (before that polygon's final "z")
  312.  will be absolute or relative 1D or 2D line-to commands, whichever
  313.  is most compact.
  314.  
  315.  The Spacing Level determines the insertion of spaces between the
  316.  drawing command and its coordinate(s), between the x and y parts of
  317.  the coordinates and between each drawing command.
  318.  
  319.  A Spacing Level of -1 invokes a compact coding permitted by the SVG
  320.  specification: The leading zeros of small decimal numbers are omitted
  321.  and the comma separator is omitted when the following number begins
  322.  with anything but a digit.
  323.  
  324.                        Between
  325.  Spacing  Between      Command and    Between
  326.  Level    Commands     Coordinate(s)  Coordinates
  327.  
  328.  4..10    2..8 spaces  one space      one space
  329.  3:       one space    one space      one space
  330.  2:       one space    one space      no  space
  331.  1:       one space    no space       no space
  332.  0:       no space     no space       no space
  333.  -1:      Exclude comma and leading zero before a decimal point.
  334.  """
  335.   spacer = " "*int(max(1, min(10, spacing_level - 2))*(spacing_level > 0))
  336.   frags = itertools.chain.from_iterable(
  337.     svg_path_polygon_frags(
  338.         polygon,
  339.         spacing_level=spacing_level,
  340.         max_dp=max_dp
  341.     )
  342.     for polygon in polygons
  343.   )
  344.   yield from lines_from_frags(
  345.     frags,
  346.     indent_str=indent_str,
  347.     spacer=spacer,
  348.     columns=columns,
  349.     head=head,
  350.     tail=tail
  351.   )
  352.  
  353.  
  354. #-----------------------------------------------------------------------------
  355. # Image functions
  356. #-----------------------------------------------------------------------------
  357.  
  358.  
  359. def load_image(filename):
  360.   """Load an image as a numpy array, a palette and a transparency key.
  361.  
  362.  The returned tuple contains:
  363.  * The image body as a numpy colour index array with the shape (h, w);
  364.  * A palette as a numpy array with the shape (n, 3) and
  365.  * The colour index used as the transparency key or None.
  366.  """
  367.   im = Image.open(filename)
  368.   if im.mode != 'P':
  369.     im = im.convert('P')
  370.     #print("Converted to index-colour image.")
  371.   num_colours = im.getextrema()[1] + 1
  372.   P0 = im.getpalette()[:3*num_colours]
  373.   P = np.array(P0, dtype=np.uint8).reshape((-1, 3))
  374.   key = im.info.get("transparency", None)
  375.   #if key: print(f"Transparency key index = {key}")
  376.   M = np.asarray(im)
  377.   return M, P, key
  378.  
  379.  
  380. #-----------------------------------------------------------------------------
  381.  
  382.  
  383. def find_novel_colour(palette):
  384.   """Return a RGB tuple of a colour not already in an RGB palette.
  385.  
  386.  The palette may be flat or in n by 3 format.
  387.  This function is useful for marking colours to be protected from the
  388.  effects of remapping a palette to remove redundancies.
  389.  """
  390.   S0 = (7, 6, 2, 5, 4, 3, 1, 0)
  391.   S1 = (2, 5, 4, 6, 1, 7, 3, 0)
  392.   S2 = (4, 5, 6, 2, 7, 3, 1, 0)
  393.   P1 = np.array(palette, dtype=int).reshape(-1, 3) >> 5
  394.   P2 = np.unique(64*P1[:, 0] + 8*P1[:, 1] + P1[:, 0])
  395.   for i in range(512):
  396.     u = 64*S0[(i>>6)&7] + 8*S1[(i>>3)&7] + S2[i&7]
  397.     if u not in P2:
  398.       break
  399.   col = (
  400.     ((u & 0x1C0)>>1) + 15,
  401.     ((u & 0x038)<<2) + 15,
  402.     ((u & 0x007)<<5) + 15)
  403.   return col
  404.  
  405.  
  406. #-----------------------------------------------------------------------------
  407.  
  408.  
  409. def loops_from_boolimg(M, evenodd):
  410.   """Return polygon loops tracing the edges of a Boolean image.
  411.  
  412.  Each loop represents the vertices of a polygon with sides that are
  413.  strictly horizontal or vertical. The polygons contain the elements
  414.  set to True. (If False regions are contained within a given loop,
  415.  other loops will generated to cut holes.)
  416.  
  417.  If evenodd is True, self-intersecting loops are permitted. This allows
  418.  chequered regions to be efficiently coded as two orthogonal overlapping
  419.  striped.
  420.  
  421.  If evenodd is False, the loops consist of clockwise loops which may
  422.  surround anticlockwise loops for holes.
  423.  """
  424.  
  425.   A = np.pad(M, 1)
  426.   NotA = np.logical_not(A)
  427.   Ev = np.logical_xor(A[1:, 1:], A[1:, :-1])
  428.   Eh = np.logical_xor(A[1:, 1:], A[:-1, 1:])
  429.  
  430.   # Edge flags are centred on pixel corners.
  431.   if evenodd:
  432.     Edn = np.logical_xor(A[1:, 1:], A[1:, :-1])
  433.     Eup = np.pad(Ev[:-1, :], [(1, 0), (0, 0)])
  434.     Eri = np.logical_xor(A[1:, 1:], A[:-1, 1:])
  435.     Ele = np.pad(Eh[:, :-1], [(0, 0), (1, 0)])
  436.   else:
  437.     NotA = np.logical_not(A)
  438.     Edn = np.logical_and(Ev, NotA[1:, 1:])
  439.     Eup = np.pad(np.logical_and(Ev[:-1, :], A[1:-1, 1:]), [(1, 0), (0, 0)])
  440.     Eri = np.logical_and(Eh, A[1:, 1:])
  441.     Ele = np.pad(np.logical_and(Eh[:, :-1], NotA[1:, 1:-1]), [(0, 0), (1, 0)])
  442.     del NotA
  443.   E = sum(Ed * d for Ed, d in ((Eri, 1), (Edn, 2), (Ele, 4), (Eup, 8)))
  444.   del Eri, Edn, Ele, Eup, Eh, Ev, A
  445.  
  446.   if evenodd:
  447.     search_dirs = [[x & 3 for x in (d, d + 1, d - 1)] for d in range(4)]
  448.   else:
  449.     search_dirs = [[(d + 1 - i) & 3 for i in range(3)] for d in range(4)]
  450.  
  451.   deltas = ((1, 0), (0, 1), (-1, 0), (0, -1))
  452.  
  453.   # Search for beginnings of rightward paths segments.
  454.   # The extreme bottom and right edges are excluded from
  455.   # the search but wall-following walks to find loops
  456.   # may include segments on those edges.
  457.   h = M.shape[0]
  458.   w = M.shape[1]
  459.   loops = []
  460.   loops_by_y = [list() for i in range(h)]
  461.   for sy in range(h):
  462.     right0 = False
  463.     for sx in range(w):
  464.       right = E[sy, sx] & 1
  465.       if right and not right0:
  466.         (x, y) = (sx, sy)
  467.         d = 0
  468.         loop = [(x, y)]
  469.         while True:
  470.           E[y, x] &= ~(1 << d)
  471.           dxy = deltas[d]
  472.           (x, y) = (x + dxy[0], y + dxy[1])
  473.           E[y, x] &= ~(1 << (d ^ 2))
  474.           d0 = d
  475.           d = None
  476.           if (x, y) != loop[0]:
  477.             dflags_at_xy = E[y,x]
  478.             for d in search_dirs[d0]:
  479.               if dflags_at_xy & (1 << d):
  480.                 break
  481.             if d is None:
  482.               raise Error("Dizzy at ({}, {})!".format(x, y))
  483.             if d != d0:
  484.               loop.append((x, y))
  485.           else:
  486.             break
  487.         if evenodd:
  488.           ax, ay = sx, sy
  489.         else:
  490.           # For the fill-rule of 'nonzero', ensure the vertices start at
  491.           # the leftmost part of the top extremity even for anticlockwise
  492.           # (cutting) loops. For the fill-rule of 'evenodd', all loops
  493.           # that are not self-intersecting are clockwise.
  494.           ay = min(p[1] for p in loop)
  495.           ax = min(p[0] for p in loop if p[1] == ay)
  496.           ai = loop.index((ax, ay))
  497.           loop = loop[ai:] + loop[:ai]
  498.         loops_by_y[ay].append(loop)
  499.       right0 = right
  500.     if not evenodd:
  501.       for loops in loops_by_y:
  502.         loops.sort()
  503.     loops = list(itertools.chain.from_iterable(loops_by_y))
  504.   return loops
  505.  
  506.  
  507. #-----------------------------------------------------------------------------
  508.  
  509.  
  510. def chamfer_polygon_corners(polygon, r=None):
  511.   """Chamfer the corners of one polygon tracing part of a bi-level image.
  512.  
  513.  The polygon must have strictly horizontal and vertical sides. No
  514.  chamfering is performed where the self-intersects.
  515.  
  516.  The length-by-length size of each chamfer is given by r, so that the
  517.  width of the chamfer is r*sqrt(2).
  518.  
  519.  The number of points will be doubled. As this function is a generator
  520.  function which can accept a generator for the polygon, the yielded
  521.  vertices will correspond to the original polygon starting at its
  522.  second vertex.
  523.  """
  524.   if r is None: r = 0.2
  525.   loop = []
  526.   F0 = F1 = None
  527.   V0 = V1 = V2 = None
  528.   for Z in itertools.chain(polygon, (None, None)):
  529.     V0, V1, V2 = V1, V2, Z
  530.     if Z is None:
  531.       V2, F0 = F0, F1
  532.     if V0 is None:
  533.       F0, F1 = F1, Z
  534.     else:
  535.       dx = r * max(-1, min(1, V0[0] - V1[0]))
  536.       dy = r * max(-1, min(1, V0[1] - V1[1]))
  537.       yield (V1[0] + dx, V1[1] + dy)
  538.       dx = r * max(-1, min(1, V2[0] - V1[0]))
  539.       dy = r * max(-1, min(1, V2[1] - V1[1]))
  540.       yield (V1[0] + dx, V1[1] + dy)
  541.  
  542.  
  543. #-----------------------------------------------------------------------------
  544.  
  545.  
  546. def chamfer_path_loops(loops, r=None):
  547.   """Chamfer the corners of multiple polygons tracing a bi-level image.
  548.  
  549.  The polygon must have strictly horizontal and vertical sides. No
  550.  chamfering is performed where the self-intersects.
  551.  
  552.  The length-by-length size of each chamfer is given by r, so that the
  553.  width of the chamfer is r*sqrt(2).
  554.  
  555.  The number of points will be doubled. As this function is a generator
  556.  function which can accept a generator for the polygons, the yielded
  557.  vertices for each polygon in loops will correspond to the original
  558.  polygon starting at its second vertex.
  559.  
  560.  The chamfering is a visual aid to allow one to easily see the paths
  561.  taken by the loops generating function.
  562.  """
  563.   yield from (
  564.     chamfer_polygon_corners(polygon, r)
  565.     for polygon in loops
  566.   )
  567.  
  568.  
  569. #-----------------------------------------------------------------------------
  570.  
  571.  
  572. def loops_complexity(loops, evenodd):
  573.   """Return a measure of the complexity of a bunch of polygon loops.
  574.  
  575.  This is used to help find the best (and usually the least complex)
  576.  regions of a particular colour to extract from an image in the hope
  577.  that the holes will be filled by remaining regions of ever-reducing
  578.  complexity.
  579.  """
  580.   return sum(1 + len(L) for L in loops)
  581.  
  582.  
  583. #-----------------------------------------------------------------------------
  584.  
  585.  
  586. def find_best_colour(img_array, indices, key, evenodd):
  587.   """Find the best colour index ro remove from an image array.
  588.  
  589.  The best colour to remove is one which has low complexity and
  590.  to a lesser extent one with fewer pixels. The removal of that
  591.  colour is likely to reduce the complexity of the remaining
  592.  colour regions when the non-transparent holes are healed.
  593.  
  594.  Arguments
  595.    img_array:  An array of colour indices
  596.    indices:    The list (or array) of candidate colour indices
  597.    key:        The transparency key or None
  598.    evenodd:    Allow self-intersecting polygon loops
  599.  """
  600.   M = img_array
  601.   Q = indices
  602.   best = None
  603.   result = None
  604.   for i, col_ix in enumerate(Q):
  605.     if key is None or col_ix != key:
  606.       B = np.equal(M, col_ix)
  607.       area = np.count_nonzero(B)
  608.       loops = loops_from_boolimg(B, evenodd)
  609.       complexity = loops_complexity(loops, evenodd) + area//4
  610.       if best is None or complexity < best:
  611.         best = complexity
  612.         result = i
  613.   return result
  614.  
  615.  
  616. #-----------------------------------------------------------------------------
  617.  
  618.  
  619. def remove_colour(img_array, target_ix, replacement_ix):
  620.   """Remove a selected colour from an image and try to heal the holes left.
  621.  
  622.  The colour indices to be removed from img_array are indicated with
  623.  target_ix, leaving non-transparent holes. As far as easily possible,
  624.  those holes are filled to match surrounding regions of colour indices.
  625.  As a last resort, remaining holes will be replaced with replacement_ix.
  626.  
  627.  This procedure, while expensive, hopes to reduce the geometric complexity
  628.  of regions of colour which remain after a colour index is removed and
  629.  converted to an overlapping filled SVG path.
  630.  """
  631.   M0 = np.asanyarray(img_array)
  632.   count = np.count_nonzero(M0 == target_ix)
  633.   prev_count = count + 1
  634.   while count < prev_count:
  635.     for M1 in (M0, M0.T):
  636.       M = M1
  637.       h, w = M.shape
  638.       for y in range(1, h - 1):
  639.         for x in range(w):
  640.           if M[y, x] == target_ix:
  641.             c = M[y-1, x]
  642.             if M[y+1, x] == c: M[y, x] = c
  643.       for M2 in (M1, np.fliplr(M1)):
  644.         for M in (M2, np.flipud(M2)):
  645.           h, w = M.shape
  646.           for y in range(1, h):
  647.             for x in range(1, w):
  648.               if M[y, x] == target_ix:
  649.                 c = M[y, x-1]
  650.                 if M[y-1, x] == c: M[y, x] = c
  651.     prev_count = count
  652.     count = np.count_nonzero(M0 == target_ix)
  653.   np.copyto(M0, replacement_ix, where=np.equal(M0, target_ix))
  654.   return M0
  655.  
  656.  
  657. #-----------------------------------------------------------------------------
  658.  
  659.  
  660. def hex_col_str(rgb, allow12b):
  661.   """Return a CSS-compatible hex colour string from an RGB tuple.
  662.  
  663.  If allow12b is True, the short, 12-bit representation is returned.
  664.  Thus "#E7A" is returned instead of "#EE77AA", given (0xEE, 0x77, 0xAA).
  665.  """
  666.   clamp = lambda x: max(0, min(255, x))
  667.   S24 = "".join(f"{clamp(round(v)):02X}" for v in rgb)
  668.   S12 = S24[0:5:2]
  669.   S = S12 if allow12b and S12 == S24[1:6:2] else S24
  670.   return f"#{S}"
  671.  
  672.  
  673. #-----------------------------------------------------------------------------
  674.  
  675.  
  676. def length_and_unit_from_str(length_str):
  677.   """Return a (length value, unit string) tuple from a string."""
  678.   S = str(length_str)
  679.   m = re_std_decimal_number.search(S)
  680.   if m:
  681.     x = m.span(0)[1]
  682.     length = float(S[:x])
  683.     unitstr = S[x:].lstrip()
  684.   else:
  685.     length = None
  686.     unitstr = None
  687.   return length, unitstr
  688.  
  689.  
  690. #-----------------------------------------------------------------------------
  691.  
  692.  
  693. def svg_colour_loops_lines(
  694.   loops,
  695.   evenodd,
  696.   col_str,
  697.   col_ix=None,
  698.   columns=None,
  699.   spacing_level=None
  700. ):
  701.   """Yield lines for the SVG code to represent regions of a single colour.
  702.  
  703.  Arguments
  704.    loops:    List (or generator) of polygons to fill with colouor
  705.    evenodd:  Indicates the "evenodd" fill rule required by loops
  706.    col_str:  CSS colour string for fill property
  707.    col_ix:   Palette colour index or None (to be output as a comment line)
  708.    columns:  The maximum lime length (sometimes exceeded)
  709.    spacing_level: Spacing methods from -1 to 10
  710.  """
  711.   eostr = 'evenodd' if evenodd else 'nonzero'
  712.   cs = html.escape(col_str)
  713.   if col_ix is not None:
  714.     cis = html.escape(str(col_ix))
  715.     yield f'<!-- Index {cis} -->'
  716.   head = f'<path fill="{cs}" d="'
  717.   yield from svg_path_multipolygon_lines(
  718.     loops,
  719.     spacing_level=spacing_level,
  720.     max_dp=6,
  721.     indent_str=" "*max(0, min(2, int(columns//20))),
  722.     columns=columns,
  723.     head=head,
  724.     tail='"/>'
  725.   )
  726.  
  727. #-----------------------------------------------------------------------------
  728.  
  729.  
  730. def svg_header_lines(
  731.   title,
  732.   w,
  733.   h,
  734.   pixel_shape=None,
  735.   widthstr=None,
  736.   heightstr=None,
  737.   background=None,
  738.   apply_bg=False,
  739.   evenodd=False,
  740.   chamfered_turns=False
  741. ):
  742.   """Yield lines for the SVG header and code preceding the SVG colour paths.
  743.  
  744.  Arguments
  745.    title:        SVG title
  746.    w:            Number of pixels wide
  747.    h:            Number of pixels high
  748.    pixel_shape:  (w, h) tuple of pixel aspect
  749.    widthstr:     SVG length and unit for the physical width
  750.    heightstr:    SVG length and unit for the physical height
  751.    apply_bg:     Output an opaque background rectangle
  752.    background:   CSS colour for background rectangle, if applied
  753.    evenodd:      Indicate the fill rule used is "evenodd".
  754.    chamfered_turns:  Show path courses by chamfering turns.
  755.  """
  756.  
  757.   mdp = max_dp_str_fn(6, keep_dp=False)
  758.   if title is None:
  759.     title = '(Untitled)'
  760.   if background is None or not background:
  761.     background = 'white'
  762.   if w is None: w = h
  763.   if h is None: h = w
  764.   if w is None: w = h = 16
  765.   w = max(1, w)
  766.   h = max(1, h)
  767.   if pixel_shape is None:
  768.     pixel_shape = (1, 1)
  769.   pixw, pixh = pixel_shape
  770.   mw = w*pixw
  771.   mh = h*pixh
  772.   wsv, wus = length_and_unit_from_str(widthstr)
  773.   hsv, hus = length_and_unit_from_str(heightstr)
  774.   if wsv:
  775.     if not hsv:
  776.       hsv = wsv * mh / mw
  777.       hus = wus
  778.   else:
  779.     if hsv:
  780.       wsv = hsv * mw / mh
  781.       wus = hus
  782.     else:
  783.       wsv = w
  784.       hsv = h
  785.       wus = hus = "px"
  786.   if wus and wus == hus:
  787.     wus = hus = html.escape(wus)
  788.   else:
  789.     wus = hus = "px"
  790.   metric_aspect = mh*pixh/(mw*pixw)
  791.   mectric_is_square = abs(metric_aspect - 1.0) < 1e-7
  792.   #print("Metric Aspect =", metric_aspect)
  793.  
  794.   if evenodd:
  795.     eodescstr = "Pixelated image as self-intersecting path loops"
  796.     eostr = 'evenodd'
  797.   else:
  798.     eodescstr = "Pixelated image as CW fill loops and CCW cut loops"
  799.     eostr = 'nonzero'
  800.  
  801.   ts = html.escape(title)
  802.   bgs = html.escape(background)
  803.  
  804.   yield '<?xml version="1.0" standalone="no"?>'
  805.   yield '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"'
  806.   yield '  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'
  807.   yield (f'<svg width="{mdp(wsv)}{wus}" height="{mdp(hsv)}{hus}"'
  808.       f' viewBox="0 0 {mw} {mh}"')
  809.   yield '    preserveAspectRatio="none"'
  810.   yield '    xmlns="http://www.w3.org/2000/svg" version="1.1"'
  811.   yield '    xmlns:xlink="http://www.w3.org/1999/xlink">'
  812.   yield f'<title>{ts}</title>'
  813.   if pixw == pixh == 1:
  814.     yield f'<g stroke="none" fill-rule="{eostr}">'
  815.   else:
  816.     yield (f'<g transform="scale({pixw}, {pixh}) '
  817.         f'stroke="none" fill-rule="{eostr}">')
  818.   if apply_bg:
  819.     yield '<!-- Background -->'
  820.     yield (f'<rect x="0" y="0" width="{w}" height="{h}" '
  821.         f'stroke="none" fill="{bgs}"/>')
  822.   yield f'<!-- {eodescstr} -->'
  823.  
  824.  
  825. #-----------------------------------------------------------------------------
  826.  
  827.  
  828. def svg_footer_lines():
  829.   """Yield the footer lines of SVG code."""
  830.   yield '</g>'
  831.   yield '</svg>'
  832.  
  833.  
  834. #-----------------------------------------------------------------------------
  835.  
  836.  
  837. def get_arguments():
  838.   """Parse the command line arguments.
  839.  
  840.  If --help or -V/--version is invoked, the program will exit early.
  841.  """
  842.  
  843.   cmd = os.path.basename(sys.argv[0])
  844.  
  845.   parser = argparse.ArgumentParser(
  846.     prog=cmd,
  847.     add_help=False,
  848.     description="Converts a low-res indexed colour image to an SVG."
  849.   )
  850.  
  851.   parser.add_argument(
  852.       "--help",
  853.       dest="help", action="store_true",
  854.       help="Display this message and exit.")
  855.   parser.add_argument(
  856.       "-t", "--title", metavar="TITLE",
  857.       dest="title", type=str, action="store",
  858.       help=("SVG title"))
  859.   parser.add_argument(
  860.       "-w", "--width", metavar="WIDTH",
  861.       dest="widthstr", type=str, action="store",
  862.       help=("Physical SVG width with unit"))
  863.   parser.add_argument(
  864.       "-h", "--height", metavar="HEIGHT",
  865.       dest="heightstr", type=str, action="store",
  866.       help=("Physical SVG height with unit"))
  867.   parser.add_argument(
  868.       "-k", "--key", metavar="INDEX",
  869.       dest="transparency_key", type=int, default=-1, action="store",
  870.       help="Select the colour index to serve as the transparency key.")
  871.   parser.add_argument(
  872.       "-a", "--apply-bg",
  873.       dest="apply_bg", action="store_true",
  874.       help="Include the filled background rectangle.")
  875.   parser.add_argument(
  876.       "-b", "--bgcol", metavar="BG",
  877.       dest="background", action="store",
  878.       help="CSS-style background colour")
  879.   parser.add_argument(
  880.       "-M", "--no-remap",
  881.       dest="no_remap", action="store_true",
  882.       help="Preserve redundant palette entries.")
  883.   parser.add_argument(
  884.       "-p", "--pixel", metavar="H:W",
  885.       dest="pixel_aspect", action="store",
  886.       help="Pixel aspect ratio (default 1:1)")
  887.   parser.add_argument(
  888.       "-Z", "--frnz",
  889.       dest="evenodd", action="store_false",
  890.       help="Use the (less efficient) SVG path fill rule \"nonzero\" "
  891.            "instead of the default \"evenodd\" rule, which gains its "
  892.            "efficiency from self-intersecting path loops.")
  893.   parser.add_argument(
  894.       "-H", "--holes",
  895.       dest="keep_holes", action="store_true",
  896.       help="Skip the laborious healing of holes, minimising overprinting "
  897.            "and compound aliasing in some SVG renderers at the cost of "
  898.            "increased SVG complexity.")
  899.   parser.add_argument(
  900.       "-C", "--chamfer",
  901.       dest="chamfer", action="store_true",
  902.       help="Chamfer corners to show path loops.")
  903.   parser.add_argument(
  904.       "-c", "--columns", metavar="N",
  905.       dest="columns", type=int, default=78, action="store",
  906.       help="Maximum line width for SVG path data")
  907.   parser.add_argument(
  908.       "-s", "--spacing", metavar="S",
  909.       dest="spacing_level", type=int, default=-1, action="store",
  910.       help="Spacing level (-1..10) for SVG path data")
  911.   parser.add_argument(
  912.       "-P", "--progress-images",
  913.       dest="save_progress_images", action="store_true",
  914.       help="Save numbered progress images.")
  915.   parser.add_argument(
  916.       "-V", "--version",
  917.       dest="version", action="store_true",
  918.       help="Display version and exit.")
  919.  
  920.   parser.add_argument(
  921.       "filename", metavar="PIMAGE",
  922.       type=str,
  923.       help=("Index-colour (paletted) image to read"))
  924.   parser.add_argument(
  925.       "outfilename", metavar="SVGFILE",
  926.       type=str,
  927.       help=("SVG file to create"))
  928.  
  929.   if "--help" in sys.argv:
  930.     parser.print_help()
  931.     sys.exit(0)
  932.  
  933.   if "-V" in sys.argv or "--version" in sys.argv:
  934.     print(VERSION)
  935.     sys.exit(0)
  936.  
  937.   args = parser.parse_args()
  938.  
  939.   return args
  940.  
  941.  
  942. #-----------------------------------------------------------------------------
  943.  
  944.  
  945. def get_pix_shape(shape_str):
  946.   """Parse the pixel aspect ratio argument.
  947.  
  948.  No attempt is to simplify the ratio (by using gcd(), say), since the
  949.  user may have a special use case which requires integer sub-pixel
  950.  addressing in the same transformation frame as the path data.
  951.  """
  952.   if shape_str is None:
  953.     return (1, 1)
  954.   x = 1
  955.   y = 1
  956.   s = shape_str.lower()
  957.   if ':' in s:
  958.     sep = ':'
  959.   elif ',' in s:
  960.     sep = ','
  961.   elif 'X' in s:
  962.     sep = 'X'
  963.   elif 'x' in s:
  964.     sep = 'x'
  965.   else:
  966.     sep = None
  967.   if sep is None:
  968.     try:
  969.       x = float(s)
  970.     except ValueError:
  971.       x = max(1.0/256.0, min(256.0, x))
  972.       y = 1.0
  973.   else:
  974.     if sep == ',' and len(s) >= 3 and s[0] == '(' and s[-1] == ')':
  975.       s = s[1 :  len(s) - 1]
  976.     fields = s.split(sep)
  977.     try:
  978.       x = int(fields[0])
  979.     except ValueError:
  980.       x = 1
  981.     try:
  982.       y = int(fields[1])
  983.     except ValueError:
  984.       y = 1
  985.     x = max(1, min(256, x))
  986.     y = max(1, min(256, y))
  987.   return (x, y)
  988.  
  989.  
  990. #-----------------------------------------------------------------------------
  991.  
  992.  
  993. def main():
  994.  
  995.   result = 0
  996.   err_msg = ''
  997.  
  998.   cmd = os.path.basename(sys.argv[0])
  999.  
  1000.   try:
  1001.  
  1002.     args = get_arguments()
  1003.  
  1004.     pixel_shape = get_pix_shape(args.pixel_aspect)
  1005.     bgstr = bg if (bg := args.background) is not None and bg else None
  1006.     apply_bg = args.apply_bg or bgstr is not None
  1007.  
  1008.     progress_img_fmt = f"{args.outfilename}_P{{:03}}.png"
  1009.     mask_img_fmt = f"{args.outfilename}_M{{:03}}.png"
  1010.  
  1011.     M, P, key = load_image(args.filename)
  1012.     if args.transparency_key >= 0:
  1013.       key = args.transparency_key
  1014.       if key >= len(P):
  1015.         key = None
  1016.  
  1017.     if args.apply_bg and key is not None and not bgstr:
  1018.       bgstr = hex_col_str(P[key], allow12b=True)
  1019.       bgstr = {'#000': "black", '#FFF': "white"}.get(bgstr, bgstr)
  1020.  
  1021.     kcint0 = 0
  1022.     kcint1 = 0
  1023.     if not args.no_remap:
  1024.       # Remap the palette to eliminate redundant palette entries
  1025.       # that would lead to needlessly complex paths.
  1026.       if key is not None:
  1027.         kc0 = P[key]
  1028.         kcint0 = kc0[0]*65536 + kc0[1]*256 + kc0[2]
  1029.         kc1 = find_novel_colour(P)
  1030.         kcint1 = kc1[0]*65536 + kc1[1]*256 + kc1[2]
  1031.       P1 = np.array(P, dtype=int)
  1032.       P2 = P1[:, 0]*65536 + P1[:, 1]*256 + P1[:, 2]
  1033.       if key is not None:
  1034.         P2[key] = kcint1
  1035.       P3, remap = np.unique(P2, return_inverse=True)
  1036.       M = np.take(remap, M)
  1037.       if key is not None:
  1038.         key = list(P3).index(kcint1)
  1039.         P3[key] = kcint0
  1040.       P = np.vstack([P3>>16, (P3>>8) & 255, P3 & 255]).T
  1041.  
  1042.     Q = sorted(np.unique(M))
  1043.     c0 = key
  1044.     count = 0
  1045.     if args.save_progress_images:
  1046.       img1 = Image.fromarray(M, 'P')
  1047.       img1.putpalette(P)
  1048.       img1.save(progress_img_fmt.format(count))
  1049.     count += 1
  1050.     Q1 = np.array(Q)
  1051.     done = False
  1052.     jobs = []
  1053.     while len(Q1) and not done:
  1054.       c = M[0, 0]
  1055.       if np.array_equiv(M, c):
  1056.         if key is None and not bgstr:
  1057.           h, w = M.shape
  1058.           loops = [[(0, 0), (w, 0), (w, h), (0, h)]]
  1059.           jobs.append((c, loops))
  1060.         if args.save_progress_images:
  1061.           img1 = Image.fromarray(M, 'P')
  1062.           img1.putpalette(P)
  1063.           img1.save(progress_img_fmt.format(count))
  1064.         done = True
  1065.       else:
  1066.         if args.keep_holes:
  1067.           ccii = len(Q1) - 1
  1068.           c = Q1[ccii]
  1069.           if key is None or c != key:
  1070.             B = np.logical_not(M - c);
  1071.             loops = loops_from_boolimg(B, args.evenodd)
  1072.         else:
  1073.           ccii = find_best_colour(M, Q1, c0, args.evenodd)
  1074.           c = Q1[ccii]
  1075.           if c0 is None:
  1076.             c0 = c
  1077.           B = np.logical_not(M - c);
  1078.           loops = loops_from_boolimg(B, args.evenodd)
  1079.           if args.save_progress_images and not args.keep_holes:
  1080.             Z = np.array(np.equal(M, c), dtype=np.byte)
  1081.             maskimg = Image.fromarray(Z, 'P')
  1082.             maskimg.putpalette([0,0,0, 255,255,255])
  1083.             maskimg.save(mask_img_fmt.format(count - 1))
  1084.           M = remove_colour(M, c, c0)
  1085.         Q1 = np.delete(Q1, ccii)
  1086.         jobs.append((c, loops))
  1087.         if args.save_progress_images and not args.keep_holes:
  1088.           img1 = Image.fromarray(M, 'P')
  1089.           img1.putpalette(P)
  1090.           img1.save(progress_img_fmt.format(count))
  1091.       count += 1
  1092.     jobs.reverse()
  1093.  
  1094.     with open(args.outfilename, 'w') as f:
  1095.       header_gen = svg_header_lines(
  1096.         title = args.title,
  1097.         w = M.shape[1],  # Array shapes are in (h, w) format.
  1098.         h = M.shape[0],
  1099.         pixel_shape = pixel_shape,  # Pixel shape is in (w, h) format.
  1100.         widthstr = args.widthstr,
  1101.         heightstr = args.heightstr,
  1102.         apply_bg = apply_bg,
  1103.         background = bgstr,
  1104.         evenodd = args.evenodd,
  1105.         chamfered_turns = args.chamfer
  1106.       )
  1107.       for line in header_gen:
  1108.         f.write(f"{line}\n")
  1109.       for job in jobs:
  1110.         col_ix, loops = job
  1111.         rgb = P[col_ix] if col_ix < len(P) else (255, 0, 255)
  1112.         cs = hex_col_str(rgb, allow12b=True)
  1113.         if args.chamfer:
  1114.           loops1 = chamfer_path_loops(loops)
  1115.           loops1 = [[V for V in L] for L in loops1]
  1116.         else:
  1117.           loops1 = loops
  1118.         lines = svg_colour_loops_lines(
  1119.           loops=loops1,
  1120.           evenodd=args.evenodd,
  1121.           col_str=cs,
  1122.           col_ix=None,
  1123.           columns=args.columns,
  1124.           spacing_level=args.spacing_level
  1125.         )
  1126.         for line in lines:
  1127.           f.write(f"{line}\n")
  1128.       for line in svg_footer_lines():
  1129.         f.write(f"{line}\n")
  1130.  
  1131.   except ArgError as E:
  1132.     err_msg = 'Error: ' + str(E)
  1133.     result = 2
  1134.   except FileError as E:
  1135.     err_msg = str(E)
  1136.     result = 3
  1137.   except CmdError as E:
  1138.     err_msg = str(E)
  1139.     result = 4
  1140.   except DataError as E:
  1141.     err_msg = str(E)
  1142.     result = 5
  1143.   except Exception as E:
  1144.     exc_type, exc_value, exc_traceback = sys.exc_info()
  1145.     err_lines = traceback.format_exc().splitlines()
  1146.     err_msg = 'Unhandled exception:\n' + '\n'.join(err_lines)
  1147.     result = 1
  1148.  
  1149.   if err_msg != '':
  1150.     print(cmd + ': ' + err_msg, file=sys.stderr)
  1151.  
  1152.   return result
  1153.  
  1154.  
  1155. #-----------------------------------------------------------------------------
  1156.  
  1157.  
  1158. if __name__ == '__main__':
  1159.   main()
  1160.  
  1161.  
  1162. #-----------------------------------------------------------------------------
  1163. # End
  1164. #-----------------------------------------------------------------------------
  1165.  
Add Comment
Please, Sign In to add comment