xref: /aosp_15_r20/cts/apps/CameraITS/tests/scene9/test_jpeg_quality.py (revision b7c941bb3fa97aba169d73cee0bed2de8ac964bf)
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