demo_progressive_saliency_encoding.py (6944B)
1 #!/usr/bin/env python3 2 3 # Copyright (c) the JPEG XL Project Authors. All rights reserved. 4 # 5 # Use of this source code is governed by a BSD-style 6 # license that can be found in the LICENSE file. 7 8 """Produces demos for how progressive-saliency encoding would look like. 9 10 As long as we do not have a progressive decoder that allows showing images 11 generated from partially-available data, we can resort to building 12 animated gifs that show how progressive loading would look like. 13 14 Method: 15 16 1. JPEG-XL encode the image, but stop at the pre-final (2nd) step. 17 2. Use separate tool to compute a heatmap which shows where differences between 18 the pre-final and final image are expected to be perceptually worst. 19 3. Use this heatmap to JPEG-XL encode the image with the final step split into 20 'salient parts only' and 'non-salient parts'. Generate a sequence of images 21 that stop decoding after the 1st, 2nd, 3rd, 4th step. JPEG-XL decode these 22 truncated images back to PNG. 23 4. Measure byte sizes of the truncated-encoded images. 24 5. Build an animated GIF with variable delays by calling ImageMagick's 25 `convert` command. 26 27 """ 28 29 from __future__ import absolute_import 30 from __future__ import division 31 from __future__ import print_function 32 from six.moves import zip 33 import ast # For ast.literal_eval() only. 34 import os 35 import re 36 import shlex 37 import subprocess 38 import sys 39 40 _BLOCKSIZE = 8 41 42 _CONF_PARSERS = dict( 43 keep_tempfiles=lambda s: bool(ast.literal_eval(s)), 44 heatmap_command=shlex.split, 45 simulated_progressive_loading_time_sec=float, 46 simulated_progressive_loading_delay_until_looparound_sec=float, 47 jpegxl_encoder=shlex.split, 48 jpegxl_decoder=shlex.split, 49 blurring=lambda s: s.split(), 50 ) 51 52 53 def parse_config(config_filename): 54 """Parses the configuration file.""" 55 conf = {} 56 re_comment = re.compile(r'^\s*(?:#.*)?$') 57 re_param = re.compile(r'^(?P<option>\w+)\s*:\s*(?P<value>.*?)\s*$') 58 try: 59 with open(config_filename) as h: 60 for line in h: 61 if re_comment.match(line): 62 continue 63 m = re_param.match(line) 64 if not m: 65 raise ValueError('Syntax error') 66 conf[m.group('option')] = ( 67 _CONF_PARSERS[m.group('option')](m.group('value'))) 68 except Exception as exn: 69 raise ValueError('Bad Configuration line ({}): {}'.format(exn, line)) 70 missing_options = set(_CONF_PARSERS) - set(conf) 71 if missing_options: 72 raise ValueError('Missing configuration options: ' + ', '.join( 73 sorted(missing_options))) 74 return conf 75 76 77 def generate_demo_image(config, input_filename, output_filename): 78 tempfiles = [] 79 # 80 def encode_img(input_filename, output_filename, num_steps, 81 heatmap_filename=None): 82 replacements = { 83 '${INPUT}': input_filename, 84 '${OUTPUT}': output_filename, 85 '${STEPS}': str(num_steps), 86 # Heatmap argument will be provided in --param=value form. 87 '${HEATMAP_ARG}': ('--saliency_map_filename=' + heatmap_filename 88 if heatmap_filename is not None else '') 89 } 90 # Remove empty args. This removes the heatmap-argument if no heatmap 91 # is provided.. 92 cmd = [ 93 _f for _f in 94 [replacements.get(arg, arg) for arg in config['jpegxl_encoder']] if _f 95 ] 96 tempfiles.append(output_filename) 97 subprocess.call(cmd) 98 # 99 def decode_img(input_filename, output_filename): 100 replacements = {'${INPUT}': input_filename, '${OUTPUT}': output_filename} 101 cmd = [replacements.get(arg, arg) for arg in config['jpegxl_decoder']] 102 tempfiles.append(output_filename) 103 subprocess.call(cmd) 104 # 105 def generate_heatmap(orig_image_filename, coarse_grained_filename, 106 heatmap_filename): 107 cmd = config['heatmap_command'] + [ 108 str(_BLOCKSIZE), orig_image_filename, coarse_grained_filename, 109 heatmap_filename] 110 tempfiles.append(heatmap_filename) 111 subprocess.call(cmd) 112 # 113 try: 114 encode_img(input_filename, output_filename + '._step1.pik', 1) 115 decode_img(output_filename + '._step1.pik', output_filename + '._step1.png') 116 encode_img(input_filename, output_filename + '._step2.pik', 2) 117 decode_img(output_filename + '._step2.pik', output_filename + '._step2.png') 118 generate_heatmap(input_filename, output_filename + '._step2.png', 119 output_filename + '._heatmap.png') 120 encode_img(input_filename, 121 output_filename + '._step3.pik', 3, 122 output_filename + '._heatmap.png') 123 encode_img(input_filename, 124 output_filename + '._step4.pik', 4, 125 output_filename + '._heatmap.png') 126 decode_img(output_filename + '._step3.pik', output_filename + '._step3.png') 127 decode_img(output_filename + '._step4.pik', output_filename + '._step4.png') 128 data_sizes = [ 129 os.stat('{}._step{}.pik'.format(output_filename, num_step)).st_size 130 for num_step in (1, 2, 3, 4)] 131 time_offsets = [0] + [ 132 # Imagemagick's `convert` accepts delays in units of 1/100 sec. 133 round(100 * config['simulated_progressive_loading_time_sec'] * size / 134 data_sizes[-1]) for size in data_sizes] 135 time_delays = [t_next - t_prev 136 for t_next, t_prev in zip(time_offsets[1:], time_offsets)] 137 # Add a fake white initial image. As long as no usable image data is 138 # available, the user will see a white background. 139 subprocess.call(['convert', 140 output_filename + '._step1.png', 141 '-fill', 'white', '-colorize', '100%', 142 output_filename + '._step0.png']) 143 tempfiles.append(output_filename + '._step0.png') 144 subprocess.call( 145 ['convert', '-loop', '0', output_filename + '._step0.png'] + 146 [arg for args in [ 147 ['-delay', str(time_delays[n - 1]), 148 '-blur', config['blurring'][n - 1], 149 '{}._step{}.png'.format(output_filename, n)] 150 for n in (1, 2, 3, 4)] for arg in args] + 151 ['-delay', str(round(100 * config[ 152 'simulated_progressive_loading_delay_until_looparound_sec'])), 153 output_filename + '._step4.png', 154 output_filename]) 155 finally: 156 if not config['keep_tempfiles']: 157 for filename in tempfiles: 158 try: 159 os.unlink(filename) 160 except OSError: 161 pass # May already have been deleted otherwise. 162 163 164 def main(): 165 if sys.version.startswith('2.'): 166 sys.exit('This is a python3-only script.') 167 if (len(sys.argv) != 4 or not sys.argv[-1].endswith('.gif') 168 or not sys.argv[-2].endswith('.png')): 169 sys.exit( 170 'Usage: {} [config_options_file] [input.png] [output.gif]'.format( 171 sys.argv[0])) 172 try: 173 _, config_filename, input_filename, output_filename = sys.argv 174 config = parse_config(config_filename) 175 generate_demo_image(config, input_filename, output_filename) 176 except ValueError as exn: 177 sys.exit(exn) 178 179 180 181 if __name__ == '__main__': 182 main()