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# limitations under the License. 14"""Verifies android.jpeg.quality increases JPEG image quality.""" 15 16 17import logging 18import math 19import os.path 20 21from matplotlib import pylab 22import matplotlib.pyplot 23from mobly import test_runner 24import numpy as np 25 26import its_base_test 27import camera_properties_utils 28import capture_request_utils 29import image_processing_utils 30import its_session_utils 31 32JPEG_APPN_MARKERS = [[255, 224], [255, 225], [255, 226], [255, 227], [255, 228], 33 [255, 229], [255, 230], [255, 231], [255, 232], [255, 235]] 34JPEG_DHT_MARKER = [255, 196] # JPEG Define Huffman Table 35JPEG_DQT_MARKER = [255, 219] # JPEG Define Quantization Table 36JPEG_DQT_TOL = 0.8 # -20% for each +20 in jpeg.quality (empirical number) 37JPEG_EOI_MARKER = [255, 217] # JPEG End of Image 38JPEG_SOI_MARKER = [255, 216] # JPEG Start of Image 39JPEG_SOS_MARKER = [255, 218] # JPEG Start of Scan 40NAME = os.path.splitext(os.path.basename(__file__))[0] 41QUALITIES = [25, 45, 65, 85] 42SYMBOLS = ['o', 's', 'v', '^', '<', '>'] 43 44 45def is_square(integer): 46 root = math.sqrt(integer) 47 return integer == int(root + 0.5)**2 48 49 50def strip_soi_marker(jpeg): 51 """Strip off start of image marker. 52 53 SOI is of form [xFF xD8] and JPEG needs to start with marker. 54 55 Args: 56 jpeg: 1-D numpy int [0:255] array; values from JPEG capture 57 58 Returns: 59 jpeg with SOI marker stripped off. 60 """ 61 62 soi = jpeg[0:2] 63 if list(soi) != JPEG_SOI_MARKER: 64 raise AssertionError('JPEG has no Start Of Image marker') 65 return jpeg[2:] 66 67 68def strip_appn_data(jpeg): 69 """Strip off application specific data at beginning of JPEG. 70 71 APPN markers are of form [xFF, xE*, size_msb, size_lsb] and should follow 72 SOI marker. 73 74 Args: 75 jpeg: 1-D numpy int [0:255] array; values from JPEG capture 76 77 Returns: 78 jpeg with APPN marker(s) and data stripped off. 79 """ 80 81 length = 0 82 i = 0 83 # find APPN markers and strip off payloads at beginning of jpeg 84 while i < len(jpeg) - 1: 85 if [jpeg[i], jpeg[i + 1]] in JPEG_APPN_MARKERS: 86 length = jpeg[i + 2] * 256 + jpeg[i + 3] + 2 87 logging.debug('stripped APPN length:%d', length) 88 jpeg = np.concatenate((jpeg[0:i], jpeg[length:]), axis=None) 89 elif ([jpeg[i], jpeg[i + 1]] == JPEG_DQT_MARKER or 90 [jpeg[i], jpeg[i + 1]] == JPEG_DHT_MARKER): 91 break 92 else: 93 i += 1 94 95 return jpeg 96 97 98def find_dqt_markers(marker, jpeg): 99 """Find location(s) of marker list in jpeg. 100 101 DQT marker is of form [xFF, xDB]. 102 103 Args: 104 marker: list; marker values 105 jpeg: 1-D numpy int [0:255] array; JPEG capture w/ SOI & APPN stripped 106 107 Returns: 108 locs: list; marker locations in jpeg 109 """ 110 locs = [] 111 marker_len = len(marker) 112 for i in range(len(jpeg) - marker_len + 1): 113 if list(jpeg[i:i + marker_len]) == marker: 114 locs.append(i) 115 return locs 116 117 118def extract_dqts(jpeg, debug=False): 119 """Find and extract the DQT info in the JPEG. 120 121 SOI marker and APPN markers plus data are stripped off front of JPEG. 122 DQT marker is of form [xFF, xDB] followed by [size_msb, size_lsb]. 123 Size includes the size values, but not the marker values. 124 Luma DQT is prefixed by 0, Chroma DQT by 1. 125 DQTs can have both luma & chroma or each individually. 126 There can be more than one DQT table for luma and chroma. 127 128 Args: 129 jpeg: 1-D numpy int [0:255] array; values from JPEG capture 130 debug: bool; command line flag to print debug data 131 132 Returns: 133 lumas,chromas: lists of numpy means of luma & chroma DQT matrices. 134 Higher values represent higher compression. 135 """ 136 137 dqt_markers = find_dqt_markers(JPEG_DQT_MARKER, jpeg) 138 logging.debug('DQT header loc(s):%s', dqt_markers) 139 lumas = [] 140 chromas = [] 141 for i, dqt in enumerate(dqt_markers): 142 if debug: 143 logging.debug('DQT %d start: %d, marker: %s, length: %s', i, dqt, 144 jpeg[dqt:dqt + 2], jpeg[dqt + 2:dqt + 4]) 145 dqt_size = jpeg[dqt + 2] * 256 + jpeg[dqt + 3] - 2 # strip off size marker 146 if dqt_size % 2 == 0: # even payload means luma & chroma 147 logging.debug(' both luma & chroma DQT matrices in marker') 148 dqt_size = (dqt_size - 2) // 2 # subtact off luma/chroma markers 149 if not is_square(dqt_size): 150 raise AssertionError(f'DQT size: {dqt_size}') 151 luma_start = dqt + 5 # skip header, length, & matrix id 152 chroma_start = luma_start + dqt_size + 1 # skip lumen & matrix_id 153 luma = np.array(jpeg[luma_start: luma_start + dqt_size]) 154 chroma = np.array(jpeg[chroma_start: chroma_start + dqt_size]) 155 lumas.append(np.mean(luma)) 156 chromas.append(np.mean(chroma)) 157 if debug: 158 h = int(math.sqrt(dqt_size)) 159 logging.debug(' luma:%s', luma.reshape(h, h)) 160 logging.debug(' chroma:%s', chroma.reshape(h, h)) 161 else: # odd payload means only 1 matrix 162 logging.debug(' single DQT matrix in marker') 163 dqt_size = dqt_size - 1 # subtract off luma/chroma marker 164 if not is_square(dqt_size): 165 raise AssertionError(f'DQT size: {dqt_size}') 166 start = dqt + 5 167 matrix = np.array(jpeg[start:start + dqt_size]) 168 if jpeg[dqt + 4]: # chroma == 1 169 chromas.append(np.mean(matrix)) 170 if debug: 171 h = int(math.sqrt(dqt_size)) 172 logging.debug(' chroma:%s', matrix.reshape(h, h)) 173 else: # luma == 0 174 lumas.append(np.mean(matrix)) 175 if debug: 176 h = int(math.sqrt(dqt_size)) 177 logging.debug(' luma:%s', matrix.reshape(h, h)) 178 179 return lumas, chromas 180 181 182def plot_data(qualities, lumas, chromas, img_name): 183 """Create plot of data.""" 184 logging.debug('qualities: %s', str(qualities)) 185 logging.debug('luma DQT avgs: %s', str(lumas)) 186 logging.debug('chroma DQT avgs: %s', str(chromas)) 187 pylab.title(NAME) 188 for i in range(lumas.shape[1]): 189 pylab.plot( 190 qualities, lumas[:, i], '-g' + SYMBOLS[i], label='luma_dqt' + str(i)) 191 pylab.plot( 192 qualities, 193 chromas[:, i], 194 '-r' + SYMBOLS[i], 195 label='chroma_dqt' + str(i)) 196 pylab.xlim([0, 100]) 197 pylab.ylim([0, None]) 198 pylab.xlabel('jpeg.quality') 199 pylab.ylabel('DQT luma/chroma matrix averages') 200 pylab.legend(loc='upper right', numpoints=1, fancybox=True) 201 matplotlib.pyplot.savefig('%s_plot.png' % img_name) 202 203 204class JpegQualityTest(its_base_test.ItsBaseTest): 205 """Test the camera JPEG compression quality. 206 207 Step JPEG qualities through android.jpeg.quality. Ensure quanitization 208 matrix decreases with quality increase. Matrix should decrease as the 209 matrix represents the division factor. Higher numbers --> fewer quantization 210 levels. 211 """ 212 213 def test_jpeg_quality(self): 214 logging.debug('Starting %s', NAME) 215 # init variables 216 lumas = [] 217 chromas = [] 218 219 with its_session_utils.ItsSession( 220 device_id=self.dut.serial, 221 camera_id=self.camera_id, 222 hidden_physical_id=self.hidden_physical_id) as cam: 223 224 props = cam.get_camera_properties() 225 props = cam.override_with_hidden_physical_camera_props(props) 226 debug = self.debug_mode 227 228 # Load chart for scene 229 its_session_utils.load_scene( 230 cam, props, self.scene, self.tablet, self.chart_distance) 231 232 # Check skip conditions 233 camera_properties_utils.skip_unless( 234 camera_properties_utils.jpeg_quality(props)) 235 cam.do_3a() 236 237 # do captures over jpeg quality range 238 req = capture_request_utils.auto_capture_request() 239 for q in QUALITIES: 240 logging.debug('jpeg.quality: %.d', q) 241 req['android.jpeg.quality'] = q 242 cap = cam.do_capture(req, cam.CAP_JPEG) 243 jpeg = cap['data'] 244 245 # strip off start of image 246 jpeg = strip_soi_marker(jpeg) 247 248 # strip off application specific data 249 jpeg = strip_appn_data(jpeg) 250 logging.debug('remaining JPEG header:%s', jpeg[0:4]) 251 252 # find and extract DQTs 253 lumas_i, chromas_i = extract_dqts(jpeg, debug) 254 lumas.append(lumas_i) 255 chromas.append(chromas_i) 256 257 # save JPEG image 258 img = image_processing_utils.convert_capture_to_rgb_image( 259 cap, props=props) 260 img_name = os.path.join(self.log_path, NAME) 261 image_processing_utils.write_image(img, '%s_%d.jpg' % (img_name, q)) 262 263 # turn lumas/chromas into np array to ease multi-dimensional plots/asserts 264 lumas = np.array(lumas) 265 chromas = np.array(chromas) 266 267 # create plot of luma & chroma averages vs quality 268 plot_data(QUALITIES, lumas, chromas, img_name) 269 270 # assert decreasing luma/chroma with improved jpeg quality 271 for i in range(lumas.shape[1]): 272 l = lumas[:, i] 273 c = chromas[:, i] 274 if not all(y < x * JPEG_DQT_TOL for x, y in zip(l, l[1:])): 275 raise AssertionError(f'luma DQT avgs: {l}, TOL: {JPEG_DQT_TOL}') 276 277 if not all(y < x * JPEG_DQT_TOL for x, y in zip(c, c[1:])): 278 raise AssertionError(f'chroma DQT avgs: {c}, TOL: {JPEG_DQT_TOL}') 279 280if __name__ == '__main__': 281 test_runner.main() 282