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 pyplot as plt 22from mobly import test_runner 23import numpy as np 24 25import its_base_test 26import camera_properties_utils 27import capture_request_utils 28import image_processing_utils 29import its_session_utils 30 31_JPEG_APPN_MARKERS = [[255, 224], [255, 225], [255, 226], [255, 227], 32 [255, 228], [255, 229], [255, 230], [255, 231], 33 [255, 232], [255, 235]] 34_JPEG_DHT_MARKER = [255, 196] # JPEG Define Huffman Table 35_JPEG_DQT_MARKER = [255, 219] # JPEG Define Quantization Table 36_JPEG_DQT_RTOL = 0.8 # -20% for each +20 in jpeg.quality (empirical number) 37_JPEG_EOI_MARKER = [255, 217] # JPEG End of Image 38_JPEG_SOI_MARKER = [255, 216] # JPEG Start of Image 39_JPEG_SOS_MARKER = [255, 218] # JPEG Start of Scan 40_NAME = os.path.splitext(os.path.basename(__file__))[0] 41_QUALITIES = [25, 45, 65, 85] 42_SYMBOLS = ['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 i = 0 82 # find APPN markers and strip off payloads at beginning of jpeg 83 while i < len(jpeg) - 1: 84 if [jpeg[i], jpeg[i + 1]] in _JPEG_APPN_MARKERS: 85 length = int(jpeg[i + 2]) * 256 + int(jpeg[i + 3]) + 2 86 logging.debug('stripped APPN length: %d', length) 87 jpeg = np.concatenate((jpeg[0:i], jpeg[length:]), axis=None) 88 elif ([jpeg[i], jpeg[i + 1]] == _JPEG_DQT_MARKER or 89 [jpeg[i], jpeg[i + 1]] == _JPEG_DHT_MARKER): 90 break 91 else: 92 i += 1 93 94 return jpeg 95 96 97def find_dqt_markers(marker, jpeg): 98 """Find location(s) of marker list in jpeg. 99 100 DQT marker is of form [xFF, xDB]. 101 102 Args: 103 marker: list; marker values 104 jpeg: 1-D numpy int [0:255] array; JPEG capture w/ SOI & APPN stripped 105 106 Returns: 107 locs: list; marker locations in jpeg 108 """ 109 locs = [] 110 marker_len = len(marker) 111 for i in range(len(jpeg) - marker_len + 1): 112 if list(jpeg[i:i + marker_len]) == marker: 113 locs.append(i) 114 return locs 115 116 117def extract_dqts(jpeg, debug=False): 118 """Find and extract the DQT info in the JPEG. 119 120 SOI marker and APPN markers plus data are stripped off front of JPEG. 121 DQT marker is of form [xFF, xDB] followed by [size_msb, size_lsb]. 122 Size includes the size values, but not the marker values. 123 Luma DQT is prefixed by 0, Chroma DQT by 1. 124 DQTs can have both luma & chroma or each individually. 125 There can be more than one DQT table for luma and chroma. 126 127 Args: 128 jpeg: 1-D numpy int [0:255] array; values from JPEG capture 129 debug: bool; command line flag to print debug data 130 131 Returns: 132 lumas,chromas: lists of numpy means of luma & chroma DQT matrices. 133 Higher values represent higher compression. 134 """ 135 136 dqt_markers = find_dqt_markers(_JPEG_DQT_MARKER, jpeg) 137 logging.debug('DQT header loc(s):%s', dqt_markers) 138 lumas = [] 139 chromas = [] 140 for i, dqt in enumerate(dqt_markers): 141 if debug: 142 logging.debug('DQT %d start: %d, marker: %s, length: %s', i, dqt, 143 jpeg[dqt:dqt + 2], jpeg[dqt + 2:dqt + 4]) 144 # strip off size marker 145 dqt_size = int(jpeg[dqt + 2]) * 256 + int(jpeg[dqt + 3]) - 2 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 plt.title(_NAME) 188 for i in range(lumas.shape[1]): 189 plt.plot( 190 qualities, lumas[:, i], '-g' + _SYMBOLS[i], label='luma_dqt' + str(i)) 191 plt.plot( 192 qualities, 193 chromas[:, i], 194 '-r' + _SYMBOLS[i], 195 label='chroma_dqt' + str(i)) 196 plt.xlim([0, 100]) 197 plt.ylim([0, None]) 198 plt.xlabel('jpeg.quality') 199 plt.ylabel('DQT luma/chroma matrix averages') 200 plt.legend(loc='upper right', numpoints=1, fancybox=True) 201 plt.savefig(f'{img_name}_plot.png') 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, 231 its_session_utils.CHART_DISTANCE_NO_SCALING) 232 233 # Check skip conditions 234 camera_properties_utils.skip_unless( 235 camera_properties_utils.jpeg_quality(props)) 236 cam.do_3a() 237 238 # do captures over jpeg quality range 239 req = capture_request_utils.auto_capture_request() 240 for q in _QUALITIES: 241 logging.debug('jpeg.quality: %.d', q) 242 req['android.jpeg.quality'] = q 243 cap = cam.do_capture(req, cam.CAP_JPEG) 244 jpeg = cap['data'] 245 246 # strip off start of image 247 jpeg = strip_soi_marker(jpeg) 248 249 # strip off application specific data 250 jpeg = strip_appn_data(jpeg) 251 logging.debug('remaining JPEG header:%s', jpeg[0:4]) 252 253 # find and extract DQTs 254 lumas_i, chromas_i = extract_dqts(jpeg, debug) 255 lumas.append(lumas_i) 256 chromas.append(chromas_i) 257 258 # save JPEG image 259 img = image_processing_utils.convert_capture_to_rgb_image( 260 cap, props=props) 261 img_name = os.path.join(self.log_path, _NAME) 262 image_processing_utils.write_image(img, f'{img_name}_{q}.jpg') 263 264 # turn lumas/chromas into np array to ease multi-dimensional plots/asserts 265 lumas = np.array(lumas) 266 chromas = np.array(chromas) 267 268 # create plot of luma & chroma averages vs quality 269 plot_data(_QUALITIES, lumas, chromas, img_name) 270 271 # assert decreasing luma/chroma with improved jpeg quality 272 for i in range(lumas.shape[1]): 273 l = lumas[:, i] 274 c = chromas[:, i] 275 if not all(y < x * _JPEG_DQT_RTOL for x, y in zip(l, l[1:])): 276 raise AssertionError(f'luma DQT avgs: {l}, RTOL: {_JPEG_DQT_RTOL}') 277 278 if not all(y < x * _JPEG_DQT_RTOL for x, y in zip(c, c[1:])): 279 raise AssertionError(f'chroma DQT avgs: {c}, RTOL: {_JPEG_DQT_RTOL}') 280 281if __name__ == '__main__': 282 test_runner.main() 283