Clemenz Portmann | 813d396 | 2020-03-06 15:15:29 -0800 | [diff] [blame] | 1 | # Copyright 2020 The Android Open Source Project |
| 2 | # |
| 3 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | # you may not use this file except in compliance with the License. |
| 5 | # You may obtain a copy of the License at |
| 6 | # |
| 7 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | # |
| 9 | # Unless required by applicable law or agreed to in writing, software |
| 10 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | # See the License for the specific language governing permissions and |
| 13 | |
| 14 | # limitations under the License. |
| 15 | |
| 16 | import math |
| 17 | import os.path |
| 18 | |
| 19 | import its.caps |
| 20 | import its.device |
| 21 | import its.image |
| 22 | import its.objects |
| 23 | |
| 24 | from matplotlib import pylab |
| 25 | import matplotlib.pyplot |
| 26 | import numpy as np |
| 27 | |
| 28 | JPEG_APPN_MARKERS = [[255, 224], [255, 225], [255, 226], [255, 227], [255, 228], |
Clemenz Portmann | a6a1745 | 2021-01-07 09:43:24 -0800 | [diff] [blame] | 29 | [255, 229], [255, 230], [255, 231], [255, 232], [255, 235]] |
Clemenz Portmann | 813d396 | 2020-03-06 15:15:29 -0800 | [diff] [blame] | 30 | JPEG_DHT_MARKER = [255, 196] # JPEG Define Huffman Table |
| 31 | JPEG_DQT_MARKER = [255, 219] # JPEG Define Quantization Table |
| 32 | JPEG_DQT_TOL = 0.8 # -20% for each +20 in jpeg.quality (empirical number) |
| 33 | JPEG_EOI_MARKER = [255, 217] # JPEG End of Image |
| 34 | JPEG_SOI_MARKER = [255, 216] # JPEG Start of Image |
| 35 | JPEG_SOS_MARKER = [255, 218] # JPEG Start of Scan |
| 36 | NAME = os.path.basename(__file__).split('.')[0] |
| 37 | QUALITIES = [25, 45, 65, 85] |
| 38 | SYMBOLS = ['o', 's', 'v', '^', '<', '>'] |
| 39 | |
| 40 | |
| 41 | def is_square(integer): |
| 42 | root = math.sqrt(integer) |
| 43 | return integer == int(root + 0.5) ** 2 |
| 44 | |
| 45 | |
| 46 | def strip_soi_marker(jpeg): |
| 47 | """strip off start of image marker. |
| 48 | |
| 49 | SOI is of form [xFF xD8] and JPEG needs to start with marker. |
| 50 | |
| 51 | Args: |
| 52 | jpeg: 1-D numpy int [0:255] array; values from JPEG capture |
| 53 | |
| 54 | Returns: |
| 55 | jpeg with SOI marker stripped off. |
| 56 | """ |
| 57 | |
| 58 | soi = jpeg[0:2] |
| 59 | assert list(soi) == JPEG_SOI_MARKER, 'JPEG has no Start Of Image marker' |
| 60 | return jpeg[2:] |
| 61 | |
| 62 | |
| 63 | def strip_appn_data(jpeg): |
| 64 | """strip off application specific data at beginning of JPEG. |
| 65 | |
| 66 | APPN markers are of form [xFF, xE*, size_msb, size_lsb] and should follow |
| 67 | SOI marker. |
| 68 | |
| 69 | Args: |
| 70 | jpeg: 1-D numpy int [0:255] array; values from JPEG capture |
| 71 | |
| 72 | Returns: |
| 73 | jpeg with APPN marker(s) and data stripped off. |
| 74 | """ |
| 75 | |
| 76 | length = 0 |
| 77 | i = 0 |
| 78 | # find APPN markers and strip off payloads at beginning of jpeg |
| 79 | while i < len(jpeg)-1: |
| 80 | if [jpeg[i], jpeg[i+1]] in JPEG_APPN_MARKERS: |
| 81 | length = jpeg[i+2] * 256 + jpeg[i+3] + 2 |
| 82 | print ' stripped APPN length:', length |
| 83 | jpeg = np.concatenate((jpeg[0:i], jpeg[length:]), axis=None) |
| 84 | elif ([jpeg[i], jpeg[i+1]] == JPEG_DQT_MARKER or |
| 85 | [jpeg[i], jpeg[i+1]] == JPEG_DHT_MARKER): |
| 86 | break |
| 87 | else: |
| 88 | i += 1 |
| 89 | |
| 90 | return jpeg |
| 91 | |
| 92 | |
| 93 | def find_dqt_markers(marker, jpeg): |
| 94 | """Find location(s) of marker list in jpeg. |
| 95 | |
| 96 | DQT marker is of form [xFF, xDB]. |
| 97 | |
| 98 | Args: |
| 99 | marker: list; marker values |
| 100 | jpeg: 1-D numpy int [0:255] array; JPEG capture w/ SOI & APPN stripped |
| 101 | |
| 102 | Returns: |
| 103 | locs: list; marker locations in jpeg |
| 104 | """ |
| 105 | locs = [] |
| 106 | marker_len = len(marker) |
| 107 | for i in xrange(len(jpeg)-marker_len+1): |
| 108 | if list(jpeg[i:i+marker_len]) == marker: |
| 109 | locs.append(i) |
| 110 | return locs |
| 111 | |
| 112 | |
| 113 | def extract_dqts(jpeg, debug=False): |
| 114 | """Find and extract the DQT info in the JPEG. |
| 115 | |
| 116 | SOI marker and APPN markers plus data are stripped off front of JPEG. |
| 117 | DQT marker is of form [xFF, xDB] followed by [size_msb, size_lsb]. |
| 118 | Size includes the size values, but not the marker values. |
| 119 | Luma DQT is prefixed by 0, Chroma DQT by 1. |
| 120 | DQTs can have both luma & chroma or each individually. |
| 121 | There can be more than one DQT table for luma and chroma. |
| 122 | |
| 123 | Args: |
| 124 | jpeg: 1-D numpy int [0:255] array; values from JPEG capture |
| 125 | debug: bool; command line flag to print debug data |
| 126 | |
| 127 | Returns: |
| 128 | lumas, chromas: lists of numpy means of luma & chroma DQT matrices. |
| 129 | Higher values represent higher compression. |
| 130 | """ |
| 131 | |
| 132 | dqt_markers = find_dqt_markers(JPEG_DQT_MARKER, jpeg) |
| 133 | print 'DQT header loc(s):', dqt_markers |
| 134 | lumas = [] |
| 135 | chromas = [] |
| 136 | for i, dqt in enumerate(dqt_markers): |
| 137 | if debug: |
| 138 | print '\n DQT %d start: %d, marker: %s, length: %s' % ( |
| 139 | i, dqt, jpeg[dqt:dqt+2], jpeg[dqt+2:dqt+4]) |
| 140 | dqt_size = jpeg[dqt+2]*256 + jpeg[dqt+3] - 2 # strip off size marker |
| 141 | if dqt_size % 2 == 0: # even payload means luma & chroma |
| 142 | print ' both luma & chroma DQT matrices in marker' |
| 143 | dqt_size = (dqt_size - 2) / 2 # subtact off luma/chroma markers |
| 144 | assert is_square(dqt_size), 'DQT size: %d' % dqt_size |
| 145 | luma_start = dqt + 5 # skip header, length, & matrix id |
| 146 | chroma_start = luma_start + dqt_size + 1 # skip lumen & matrix_id |
| 147 | luma = np.array(jpeg[luma_start:luma_start+dqt_size]) |
| 148 | chroma = np.array(jpeg[chroma_start:chroma_start+dqt_size]) |
| 149 | lumas.append(np.mean(luma)) |
| 150 | chromas.append(np.mean(chroma)) |
| 151 | if debug: |
| 152 | h = int(math.sqrt(dqt_size)) |
| 153 | print ' luma:', luma.reshape(h, h) |
| 154 | print ' chroma:', chroma.reshape(h, h) |
| 155 | else: # odd payload means only 1 matrix |
| 156 | print ' single DQT matrix in marker' |
| 157 | dqt_size = dqt_size - 1 # subtract off luma/chroma marker |
| 158 | assert is_square(dqt_size), 'DQT size: %d' % dqt_size |
| 159 | start = dqt + 5 |
| 160 | matrix = np.array(jpeg[start:start+dqt_size]) |
| 161 | if jpeg[dqt+4]: # chroma == 1 |
| 162 | chromas.append(np.mean(matrix)) |
| 163 | if debug: |
| 164 | h = int(math.sqrt(dqt_size)) |
| 165 | print ' chroma:', matrix.reshape(h, h) |
| 166 | else: # luma == 0 |
| 167 | lumas.append(np.mean(matrix)) |
| 168 | if debug: |
| 169 | h = int(math.sqrt(dqt_size)) |
| 170 | print ' luma:', matrix.reshape(h, h) |
| 171 | |
| 172 | return lumas, chromas |
| 173 | |
| 174 | |
| 175 | def plot_data(qualities, lumas, chromas): |
| 176 | """Create plot of data.""" |
| 177 | print 'qualities: %s' % str(qualities) |
| 178 | print 'luma DQT avgs: %s' % str(lumas) |
| 179 | print 'chroma DQT avgs: %s' % str(chromas) |
| 180 | pylab.title(NAME) |
| 181 | for i in range(lumas.shape[1]): |
| 182 | pylab.plot(qualities, lumas[:, i], '-g'+SYMBOLS[i], |
| 183 | label='luma_dqt'+str(i)) |
| 184 | pylab.plot(qualities, chromas[:, i], '-r'+SYMBOLS[i], |
| 185 | label='chroma_dqt'+str(i)) |
| 186 | pylab.xlim([0, 100]) |
| 187 | pylab.ylim([0, None]) |
| 188 | pylab.xlabel('jpeg.quality') |
| 189 | pylab.ylabel('DQT luma/chroma matrix averages') |
| 190 | pylab.legend(loc='upper right', numpoints=1, fancybox=True) |
| 191 | matplotlib.pyplot.savefig('%s_plot.png' % NAME) |
| 192 | |
| 193 | |
| 194 | def main(): |
| 195 | """Test the camera JPEG compression quality. |
| 196 | |
| 197 | Step JPEG qualities through android.jpeg.quality. Ensure quanitization |
| 198 | matrix decreases with quality increase. Matrix should decrease as the |
| 199 | matrix represents the division factor. Higher numbers --> fewer quantization |
| 200 | levels. |
| 201 | """ |
| 202 | |
| 203 | # determine debug |
| 204 | debug = its.caps.debug_mode() |
| 205 | |
| 206 | # init variables |
| 207 | lumas = [] |
| 208 | chromas = [] |
| 209 | |
| 210 | with its.device.ItsSession() as cam: |
| 211 | props = cam.get_camera_properties() |
Clemenz Portmann | 454b64e | 2020-04-09 12:48:29 -0700 | [diff] [blame] | 212 | its.caps.skip_unless(its.caps.jpeg_quality(props)) |
Clemenz Portmann | 813d396 | 2020-03-06 15:15:29 -0800 | [diff] [blame] | 213 | cam.do_3a() |
| 214 | |
| 215 | # do captures over jpeg quality range |
| 216 | req = its.objects.auto_capture_request() |
| 217 | for q in QUALITIES: |
| 218 | print '\njpeg.quality: %.d' % q |
| 219 | req['android.jpeg.quality'] = q |
| 220 | cap = cam.do_capture(req, cam.CAP_JPEG) |
| 221 | jpeg = cap['data'] |
| 222 | |
| 223 | # strip off start of image |
| 224 | jpeg = strip_soi_marker(jpeg) |
| 225 | |
| 226 | # strip off application specific data |
| 227 | jpeg = strip_appn_data(jpeg) |
| 228 | print 'remaining JPEG header:', jpeg[0:4] |
| 229 | |
| 230 | # find and extract DQTs |
| 231 | lumas_i, chromas_i = extract_dqts(jpeg, debug) |
| 232 | lumas.append(lumas_i) |
| 233 | chromas.append(chromas_i) |
| 234 | |
| 235 | # save JPEG image |
| 236 | img = its.image.convert_capture_to_rgb_image(cap, props=props) |
| 237 | its.image.write_image(img, '%s_%d.jpg' % (NAME, q)) |
| 238 | |
| 239 | # turn lumas/chromas into np array to ease multi-dimensional plots/asserts |
| 240 | lumas = np.array(lumas) |
| 241 | chromas = np.array(chromas) |
| 242 | |
| 243 | # create plot of luma & chroma averages vs quality |
| 244 | plot_data(QUALITIES, lumas, chromas) |
| 245 | |
| 246 | # assert decreasing luma/chroma with improved jpeg quality |
| 247 | for i in range(lumas.shape[1]): |
| 248 | l = lumas[:, i] |
| 249 | c = chromas[:, i] |
| 250 | emsg = 'luma DQT avgs: %s, TOL: %.1f' % (str(l), JPEG_DQT_TOL) |
| 251 | assert all(y < x * JPEG_DQT_TOL for x, y in zip(l, l[1:])), emsg |
| 252 | emsg = 'chroma DQT avgs: %s, TOL: %.1f' % (str(c), JPEG_DQT_TOL) |
| 253 | assert all(y < x * JPEG_DQT_TOL for x, y in zip(c, c[1:])), emsg |
| 254 | |
| 255 | |
| 256 | if __name__ == '__main__': |
| 257 | main() |