#!/usr/bin/python3 import os import sys import traceback import math import numpy as np import itertools import html import argparse from PIL import Image VERSION = '1.0.0.0' class Error (Exception): pass class ArgError (Error): pass class FileError(Error): pass class DataError(Error): pass class CmdError(Error): pass def bilevel_array(M): #threshold = np.uint8(np.rint(np.median(M))) # Oddly, the median doesn’t seem to work as well as expected # for severely distressed images. # Use a coarse histogram, resistent to spikes caused by quantisation. H, E = np.histogram(M, bins=10) H = H.tolist() # Find the highest peak. hmax = max(H) hmax_ix = H.index(hmax) # Find the highest most distant secondary peak. scores = [h * abs(i - hmax_ix) for i, h in enumerate(H)] othermax = max(scores) othermax_ix = scores.index(othermax) # Identify the histogram indices for the light and dark bin modes. dark_ix = othermax_ix light_ix = hmax_ix if dark_ix > light_ix: dark_ix, light_ix = light_ix, dark_ix # Set the threshold to be halfway between the outer edges # of the light and dark bin modes. threshold = np.uint8(np.rint(0.5 * (E[dark_ix] + E[light_ix + 1]))) M = 255 * np.array(np.greater_equal(M, threshold), dtype=np.uint8) #Image.fromarray(M).save("xxx.png") return M def trim_array(M, bgvalue=None): if bgvalue is None: bgvalue = M[0, 0] top = 0 bottom = M.shape[0] left = 0 right = 0 trimmed = dict(top=0, bottom=0, left=0, right=0) while bottom > top and np.all(np.equal(M[bottom - 1], bgvalue)): bottom -= 1 while top < bottom and np.all(np.equal(M[top], bgvalue)): top += 1 trimmed['top'] = top trimmed['bottom'] = M.shape[0] - bottom M = M[top : bottom] right = M.shape[1] while right > left and np.all(np.equal(M[:, right - 1], bgvalue)): right -= 1 while left < right and np.all(np.equal(M[:, left], bgvalue)): left += 1 trimmed['left'] = left trimmed['right'] = M.shape[1] - right M = M[:, left : right] return M, trimmed def runs_metrics(edges): e = np.asarray(edges) # Run lengths d = e[1:] - e[:-1] # Find the single runs. d1 = np.equal(d, 1) # Find the groups of single runs. # (The boundaries wlll also be used in the augmentation step later.) t1 = np.pad(np.logical_and(d1, ~np.pad(d1[1:], [(0, 1)])), [(1, 0)]) b1 = np.nonzero(t1)[0] # The array of lengths of runs of singles may include a spurious # zero, but that is fine for the max() function. What is important # is that b1 is still good for the augemtation step. s1 = [sum(seg) for seg in np.split(d1, b1)] max_srl = max(s1) num_singles = sum(s1) # Find the minimum multiple run length (of unaugmented runs). dm = np.delete(d, np.nonzero(d1)[0]) min_mrl = min(dm) if len(dm) else 0 # The augmented run lengths are the original run lengths of # two or more, expanded to swallow the adjacent single runs. # Augmentation can result in non-integer run lengths. z = [] u = 0 for span_d, span_s1 in zip(np.split(np.asfarray(d), b1), s1): s = span_d[:len(span_d) - span_s1] if len(s): s[0] += u s[-1] += 0.5 * span_s1 z.append(s) u = 0.5 * span_s1 else: u += span_s1 arls = np.array(list(itertools.chain.from_iterable(z))) if len(arls): arls[-1] += u # The multiple-run indices each reference the start # of a run of duplicate entries before augmentation. mrixs = np.delete(e, np.nonzero(d1)[0])[:-1] # (The final edge, representing the image extent, is ommited.) result = { 'num_singles': num_singles, 'max_srl': max_srl, 'min_mrl': min_mrl, 'mr_indices': mrixs, 'mr_lengths': dm, 'ar_lengths': arls, } return result def multiruns_scores(metrics): scores = [] rm = metrics mrixs = rm['mr_indices'] mrls = rm['mr_lengths'] arls = rm['ar_lengths'] h = int(round(sum(arls))) mindivs = len(arls) maxdivs = min(h // 2, math.ceil(5 * h / (min(arls)))) ares = np.pad(np.cumsum(arls), [(1, 0)]) singles = np.ones(h, dtype=np.bool) singles[list(itertools.chain.from_iterable( [range(ix, mrl) for ix, mrl in zip(mrixs, mrixs + mrls)] ))] = False for ndivs in range(mindivs, maxdivs + 1): m = ndivs * ares / h sum_e2 = np.linalg.norm(m - np.rint(m)) sr_clx_score = 0 for i in range(ndivs): x = int(round(h * (i + 0.5) / ndivs)) err = singles[x] if x < h else 1 sr_clx_score += err res_score = 0.001 * ndivs / maxdivs edge_score = round(sum_e2, 4) score = sr_clx_score + res_score + edge_score scores.append((score, ndivs)) return scores def descale_array(M): h = M.shape[0] w = M.shape[1] rowedges = [0] for y in range(h): if y + 1 >= h or np.any(np.not_equal(M[y + 1], M[y])): rowedges.append(y + 1) re = np.array(rowedges) coledges = [0] for x in range(w): if x + 1 >= w or np.any(np.not_equal(M[:, x + 1], M[:, x])): coledges.append(x + 1) ce = np.array(coledges) rm = runs_metrics(re) cm = runs_metrics(ce) row_ns = rm['num_singles'] row_max_srl = rm['max_srl'] row_min_mrl = rm['min_mrl'] col_ns = cm['num_singles'] col_max_srl = cm['max_srl'] col_min_mrl = cm['min_mrl'] mpr = 2 * row_ns < h and row_max_srl < row_min_mrl mpc = 2 * col_ns < w and col_max_srl < col_min_mrl if mpr and mpc: # The image appears to be scaled. # Analyse the muliple-run lengths to determine # the best sampling positions. rss = sorted(multiruns_scores(rm))[:5] css = sorted(multiruns_scores(cm))[:5] # Of the best in each axis, favour the combinations # which preserve the aspect ratio. aspect = w / h scores = [] for rs, nr in rss: for cs, nc in css: a = nc / nr ascore = abs(math.log(a / aspect)) score = rs + cs + ascore scores.append((score, nr, nc)) best, nr, nc = sorted(scores)[0] # Select single rows and columns. rixs = np.int32(np.minimum(h - 1, np.rint(h * (np.arange(nr) + 0.5) / nr))) cixs = np.int32(np.minimum(w - 1, np.rint(w * (np.arange(nc) + 0.5) / nc))) M = M[rixs][:, cixs] #Image.fromarray(M).save("xxx.png") return M def load_image_as_array(filename, bilevel=False): im = Image.open(filename) if bilevel: if im.mode == 'P': # Avoid the annoying warning about a transparent colour # in a paletted image. im = im.convert('LA') if im.mode != 'L': im = im.convert('L') M = np.asarray(im) M = bilevel_array(M) else: M = np.asarray(im) return M def descale_trimmed_array(M, trimmed=None): if trimmed is None: trimmed = dict(top=0, bottom=0, left=0, right=0) h0, w0 = M.shape[:2] M1 = descale_array(M) h1, w1 = M1.shape[:2] margins = { 'top': int(round(trimmed['top'] * h1 / h0)), 'bottom': int(round(trimmed['bottom'] * h1 / h0)), 'left': int(round(trimmed['left'] * w1 / w0)), 'right': int(round(trimmed['right'] * w1 / w0)), } return M1, margins def pad_array(M, margins, padvalue): if not isinstance(margins, dict): x = margins margins = dict(top=x, bottom=x, left=x, right=x) pad2d = np.expand_dims(np.asarray(padvalue), axis=(0, 1)) padcol = np.repeat(pad2d, M.shape[0], axis=0) padl = np.repeat(padcol, margins['left'], axis=1) padr = np.repeat(padcol, margins['right'], axis=1) M = np.hstack((padl, M, padr)) padrow = np.repeat(pad2d, M.shape[1], axis=1) padt = np.repeat(padrow, margins['top'], axis=0) padb = np.repeat(padrow, margins['bottom'], axis=0) M = np.vstack((padt, M, padb)) return M def get_arguments(): cmd = os.path.basename(sys.argv[0]) parser = argparse.ArgumentParser( prog=cmd, add_help=False, description="Reduces a nearest-neighbour scaled image." ) parser.add_argument( "-h", "--help", dest="help", action="store_true", help="Display this message and exit.") parser.add_argument( "-b", "--bilevel", dest="bilevel", action="store_true", help="Reduce the colours to just black and white.") parser.add_argument( "-i", "--invert", dest="invert", action="store_true", help="Invert colours.") parser.add_argument( "-V", "--version", dest="version", action="store_true", help="Display version and exit.") parser.add_argument( "filename", metavar="IMAGE-IN", type=str, help=("The scaled bitmapped image to reduce.")) parser.add_argument( "outfilename", metavar="IMG-OUT", type=str, help=("The reduced image file to create.")) if "-h" in sys.argv or "--help" in sys.argv: parser.print_help() sys.exit(0) if "-V" in sys.argv or "--version" in sys.argv: print(VERSION) sys.exit(0) args = parser.parse_args() return args def main(): result = 0 err_msg = '' cmd = os.path.basename(sys.argv[0]) try: args = get_arguments() M = load_image_as_array(args.filename, args.bilevel) bgpixel = M[0, 0] M, trimmed = trim_array(M, bgpixel) M, margins = descale_trimmed_array(M, trimmed) M = pad_array(M, margins, bgpixel) im = Image.fromarray(M) if args.bilevel: im = im.convert('1') im.save(args.outfilename) except ArgError as E: err_msg = 'Error: ' + str(E) result = 2 except FileError as E: err_msg = str(E) result = 3 except CmdError as E: err_msg = str(E) result = 4 except DataError as E: err_msg = str(E) result = 5 except Exception as E: exc_type, exc_value, exc_traceback = sys.exc_info() err_lines = traceback.format_exc().splitlines() err_msg = 'Unhandled exception:\n' + '\n'.join(err_lines) result = 1 if err_msg != '': print(cmd + ': ' + err_msg, file=sys.stderr) return result if __name__ == '__main__': main()