creamygoat

descale.py - Clean a scanned bi-level image

Oct 28th, 2025
587
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 9.98 KB | Source Code | 0 0
  1. #!/usr/bin/python3
  2.  
  3. import os
  4. import sys
  5. import traceback
  6. import math
  7. import numpy as np
  8. import itertools
  9. import html
  10. import argparse
  11. from PIL import Image
  12.  
  13.  
  14. VERSION = '1.0.0.0'
  15.  
  16.  
  17. class Error (Exception):
  18.   pass
  19.  
  20. class ArgError (Error):
  21.   pass
  22.  
  23. class FileError(Error):
  24.   pass
  25.  
  26. class DataError(Error):
  27.   pass
  28.  
  29. class CmdError(Error):
  30.   pass
  31.  
  32.  
  33. def bilevel_array(M):
  34.   #threshold = np.uint8(np.rint(np.median(M)))
  35.   # Oddly, the median doesn’t seem to work as well as expected
  36.   # for severely distressed images.
  37.   # Use a coarse histogram, resistent to spikes caused by quantisation.
  38.   H, E = np.histogram(M, bins=10)
  39.   H = H.tolist()
  40.   # Find the highest peak.
  41.   hmax = max(H)
  42.   hmax_ix = H.index(hmax)
  43.   # Find the highest most distant secondary peak.
  44.   scores = [h * abs(i - hmax_ix) for i, h in enumerate(H)]
  45.   othermax = max(scores)
  46.   othermax_ix = scores.index(othermax)
  47.   # Identify the histogram indices for the light and dark bin modes.
  48.   dark_ix = othermax_ix
  49.   light_ix = hmax_ix
  50.   if dark_ix > light_ix:
  51.     dark_ix, light_ix = light_ix, dark_ix
  52.   # Set the threshold to be halfway between the outer edges
  53.   # of the light and dark bin modes.
  54.   threshold = np.uint8(np.rint(0.5 * (E[dark_ix] + E[light_ix + 1])))
  55.   M = 255 * np.array(np.greater_equal(M, threshold), dtype=np.uint8)
  56.   #Image.fromarray(M).save("xxx.png")
  57.   return M
  58.  
  59.  
  60. def trim_array(M, bgvalue=None):
  61.   if bgvalue is None:
  62.     bgvalue = M[0, 0]
  63.   top = 0
  64.   bottom = M.shape[0]
  65.   left = 0
  66.   right = 0
  67.   trimmed = dict(top=0, bottom=0, left=0, right=0)
  68.   while bottom > top and np.all(np.equal(M[bottom - 1], bgvalue)):
  69.     bottom -= 1
  70.   while top < bottom and np.all(np.equal(M[top], bgvalue)):
  71.     top += 1
  72.   trimmed['top'] = top
  73.   trimmed['bottom'] = M.shape[0] - bottom
  74.   M = M[top : bottom]
  75.   right = M.shape[1]
  76.   while right > left and np.all(np.equal(M[:, right - 1], bgvalue)):
  77.     right -= 1
  78.   while left < right and np.all(np.equal(M[:, left], bgvalue)):
  79.     left += 1
  80.   trimmed['left'] = left
  81.   trimmed['right'] = M.shape[1] - right
  82.   M = M[:, left : right]
  83.   return M, trimmed
  84.  
  85.  
  86. def runs_metrics(edges):
  87.   e = np.asarray(edges)
  88.   # Run lengths
  89.   d = e[1:] - e[:-1]
  90.   # Find the single runs.
  91.   d1 = np.equal(d, 1)
  92.   # Find the groups of single runs.
  93.   # (The boundaries wlll also be used in the augmentation step later.)
  94.   t1 = np.pad(np.logical_and(d1, ~np.pad(d1[1:], [(0, 1)])), [(1, 0)])
  95.   b1 = np.nonzero(t1)[0]
  96.   # The array of lengths of runs of singles may include a spurious
  97.   # zero, but that is fine for the max() function. What is important
  98.   # is that b1 is still good for the augemtation step.
  99.   s1 = [sum(seg) for seg in np.split(d1, b1)]
  100.   max_srl = max(s1)
  101.   num_singles = sum(s1)
  102.   # Find the minimum multiple run length (of unaugmented runs).
  103.   dm = np.delete(d, np.nonzero(d1)[0])
  104.   min_mrl = min(dm) if len(dm) else 0
  105.   # The augmented run lengths are the original run lengths of
  106.   # two or more, expanded to swallow the adjacent single runs.
  107.   # Augmentation can result in non-integer run lengths.
  108.   z = []
  109.   u = 0
  110.   for span_d, span_s1 in zip(np.split(np.asfarray(d), b1), s1):
  111.     s = span_d[:len(span_d) - span_s1]
  112.     if len(s):
  113.       s[0] += u
  114.       s[-1] += 0.5 * span_s1
  115.       z.append(s)
  116.       u = 0.5 * span_s1
  117.     else:
  118.       u += span_s1
  119.   arls = np.array(list(itertools.chain.from_iterable(z)))
  120.   if len(arls):
  121.     arls[-1] += u
  122.   # The multiple-run indices each reference the start
  123.   # of a run of duplicate entries before augmentation.
  124.   mrixs = np.delete(e, np.nonzero(d1)[0])[:-1]
  125.   # (The final edge, representing the image extent, is ommited.)
  126.   result = {
  127.     'num_singles': num_singles,
  128.     'max_srl': max_srl,
  129.     'min_mrl': min_mrl,
  130.     'mr_indices': mrixs,
  131.     'mr_lengths': dm,
  132.     'ar_lengths': arls,
  133.   }
  134.   return result
  135.  
  136.  
  137. def multiruns_scores(metrics):
  138.   scores = []
  139.   rm = metrics
  140.   mrixs = rm['mr_indices']
  141.   mrls = rm['mr_lengths']
  142.   arls = rm['ar_lengths']
  143.   h = int(round(sum(arls)))
  144.   mindivs = len(arls)
  145.   maxdivs = min(h // 2, math.ceil(5 * h / (min(arls))))
  146.   ares = np.pad(np.cumsum(arls), [(1, 0)])
  147.   singles = np.ones(h, dtype=np.bool)
  148.   singles[list(itertools.chain.from_iterable(
  149.       [range(ix, mrl) for ix, mrl in zip(mrixs, mrixs + mrls)]
  150.   ))] = False
  151.   for ndivs in range(mindivs, maxdivs + 1):
  152.     m = ndivs * ares / h
  153.     sum_e2 = np.linalg.norm(m - np.rint(m))
  154.     sr_clx_score = 0
  155.     for i in range(ndivs):
  156.       x = int(round(h * (i + 0.5) / ndivs))
  157.       err = singles[x] if x < h else 1
  158.       sr_clx_score += err
  159.     res_score = 0.001 * ndivs / maxdivs
  160.     edge_score = round(sum_e2, 4)
  161.     score = sr_clx_score + res_score + edge_score
  162.     scores.append((score, ndivs))
  163.   return scores
  164.  
  165.  
  166. def descale_array(M):
  167.  
  168.   h = M.shape[0]
  169.   w = M.shape[1]
  170.  
  171.   rowedges = [0]
  172.   for y in range(h):
  173.     if y + 1 >= h or np.any(np.not_equal(M[y + 1], M[y])):
  174.       rowedges.append(y + 1)
  175.   re = np.array(rowedges)
  176.   coledges = [0]
  177.   for x in range(w):
  178.     if x + 1 >= w or np.any(np.not_equal(M[:, x + 1], M[:, x])):
  179.       coledges.append(x + 1)
  180.   ce = np.array(coledges)
  181.  
  182.   rm = runs_metrics(re)
  183.   cm = runs_metrics(ce)
  184.   row_ns = rm['num_singles']
  185.   row_max_srl = rm['max_srl']
  186.   row_min_mrl = rm['min_mrl']
  187.   col_ns = cm['num_singles']
  188.   col_max_srl = cm['max_srl']
  189.   col_min_mrl = cm['min_mrl']
  190.  
  191.   mpr = 2 * row_ns < h and row_max_srl < row_min_mrl
  192.   mpc = 2 * col_ns < w and col_max_srl < col_min_mrl
  193.  
  194.   if mpr and mpc:
  195.     # The image appears to be scaled.
  196.     # Analyse the muliple-run lengths to determine
  197.     # the best sampling positions.
  198.     rss = sorted(multiruns_scores(rm))[:5]
  199.     css = sorted(multiruns_scores(cm))[:5]
  200.     # Of the best in each axis, favour the combinations
  201.     # which preserve the aspect ratio.
  202.     aspect = w / h
  203.     scores = []
  204.     for rs, nr in rss:
  205.       for cs, nc in css:
  206.         a = nc / nr
  207.         ascore = abs(math.log(a / aspect))
  208.         score = rs + cs + ascore
  209.         scores.append((score, nr, nc))
  210.     best, nr, nc = sorted(scores)[0]
  211.     # Select single rows and columns.
  212.     rixs = np.int32(np.minimum(h - 1,
  213.         np.rint(h * (np.arange(nr) + 0.5) / nr)))
  214.     cixs = np.int32(np.minimum(w - 1,
  215.         np.rint(w * (np.arange(nc) + 0.5) / nc)))
  216.     M = M[rixs][:, cixs]
  217.  
  218.   #Image.fromarray(M).save("xxx.png")
  219.  
  220.   return M
  221.  
  222.  
  223. def load_image_as_array(filename, bilevel=False):
  224.   im = Image.open(filename)
  225.   if bilevel:
  226.     if im.mode == 'P':
  227.       # Avoid the annoying warning about a transparent colour
  228.       # in a paletted image.
  229.       im = im.convert('LA')
  230.     if im.mode != 'L':
  231.       im = im.convert('L')
  232.     M = np.asarray(im)
  233.     M = bilevel_array(M)
  234.   else:
  235.     M = np.asarray(im)
  236.   return M
  237.  
  238.  
  239. def descale_trimmed_array(M, trimmed=None):
  240.   if trimmed is None:
  241.     trimmed = dict(top=0, bottom=0, left=0, right=0)
  242.   h0, w0 = M.shape[:2]
  243.   M1 = descale_array(M)
  244.   h1, w1 = M1.shape[:2]
  245.   margins = {
  246.     'top': int(round(trimmed['top'] * h1 / h0)),
  247.     'bottom': int(round(trimmed['bottom'] * h1 / h0)),
  248.     'left': int(round(trimmed['left'] * w1 / w0)),
  249.     'right': int(round(trimmed['right'] * w1 / w0)),
  250.   }
  251.   return M1, margins
  252.  
  253.  
  254. def pad_array(M, margins, padvalue):
  255.   if not isinstance(margins, dict):
  256.     x = margins
  257.     margins = dict(top=x, bottom=x, left=x, right=x)
  258.   pad2d = np.expand_dims(np.asarray(padvalue), axis=(0, 1))
  259.   padcol = np.repeat(pad2d, M.shape[0], axis=0)
  260.   padl = np.repeat(padcol, margins['left'], axis=1)
  261.   padr = np.repeat(padcol, margins['right'], axis=1)
  262.   M = np.hstack((padl, M, padr))
  263.   padrow = np.repeat(pad2d, M.shape[1], axis=1)
  264.   padt = np.repeat(padrow, margins['top'], axis=0)
  265.   padb = np.repeat(padrow, margins['bottom'], axis=0)
  266.   M = np.vstack((padt, M, padb))
  267.   return M
  268.  
  269.  
  270. def get_arguments():
  271.  
  272.   cmd = os.path.basename(sys.argv[0])
  273.  
  274.   parser = argparse.ArgumentParser(
  275.     prog=cmd,
  276.     add_help=False,
  277.     description="Reduces a nearest-neighbour scaled image."
  278.   )
  279.  
  280.   parser.add_argument(
  281.       "-h", "--help",
  282.       dest="help", action="store_true",
  283.       help="Display this message and exit.")
  284.   parser.add_argument(
  285.       "-b", "--bilevel",
  286.       dest="bilevel", action="store_true",
  287.       help="Reduce the colours to just black and white.")
  288.   parser.add_argument(
  289.       "-i", "--invert",
  290.       dest="invert", action="store_true",
  291.       help="Invert colours.")
  292.   parser.add_argument(
  293.       "-V", "--version",
  294.       dest="version", action="store_true",
  295.       help="Display version and exit.")
  296.  
  297.   parser.add_argument(
  298.       "filename", metavar="IMAGE-IN",
  299.       type=str,
  300.       help=("The scaled bitmapped image to reduce."))
  301.  
  302.   parser.add_argument(
  303.       "outfilename", metavar="IMG-OUT",
  304.       type=str,
  305.       help=("The reduced image file to create."))
  306.  
  307.   if "-h" in sys.argv or "--help" in sys.argv:
  308.     parser.print_help()
  309.     sys.exit(0)
  310.  
  311.   if "-V" in sys.argv or "--version" in sys.argv:
  312.     print(VERSION)
  313.     sys.exit(0)
  314.  
  315.   args = parser.parse_args()
  316.  
  317.   return args
  318.  
  319.  
  320. def main():
  321.  
  322.   result = 0
  323.   err_msg = ''
  324.  
  325.   cmd = os.path.basename(sys.argv[0])
  326.  
  327.   try:
  328.  
  329.     args = get_arguments()
  330.  
  331.     M = load_image_as_array(args.filename, args.bilevel)
  332.     bgpixel = M[0, 0]
  333.     M, trimmed = trim_array(M, bgpixel)
  334.     M, margins = descale_trimmed_array(M, trimmed)
  335.     M = pad_array(M, margins, bgpixel)
  336.     im = Image.fromarray(M)
  337.     if args.bilevel:
  338.       im = im.convert('1')
  339.     im.save(args.outfilename)
  340.  
  341.   except ArgError as E:
  342.     err_msg = 'Error: ' + str(E)
  343.     result = 2
  344.   except FileError as E:
  345.     err_msg = str(E)
  346.     result = 3
  347.   except CmdError as E:
  348.     err_msg = str(E)
  349.     result = 4
  350.   except DataError as E:
  351.     err_msg = str(E)
  352.     result = 5
  353.   except Exception as E:
  354.     exc_type, exc_value, exc_traceback = sys.exc_info()
  355.     err_lines = traceback.format_exc().splitlines()
  356.     err_msg = 'Unhandled exception:\n' + '\n'.join(err_lines)
  357.     result = 1
  358.  
  359.   if err_msg != '':
  360.     print(cmd + ': ' + err_msg, file=sys.stderr)
  361.  
  362.   return result
  363.  
  364.  
  365. if __name__ == '__main__':
  366.   main()
  367.  
Advertisement