blob: 8cf95cc9868d08e1c36ce93052b1c345c3ac0ff0 [file]
# Copyright 2025 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Verifies android.tonemap.mode parameter is applied correctly for
uniform and non-uniform distribution."""
import logging
import os.path
import numpy as np
from matplotlib import pyplot as plt
from mobly import test_runner
import its_base_test
import camera_properties_utils
import capture_request_utils
import image_processing_utils
import its_session_utils
import gen2_rig_controller_utils
_BLUE = 'blue'
_BRIGHTNESS_INCREASE_THRESHOLD = 0.08 # 8%
_BRIGHTNESS_DELTA_THRESHOLD = 0.15 # 15%
_EXPONENTIAL_NONUNIFORM_THRESHOLD = 0.01 # 1%
_CONTRAST_CURVE = 0
_CURVE = 'curve'
_DEFAULT = 'default'
_DEFAULT_VALIDATION = 'default_validation'
_EXPONENTIAL = 'exponential'
_FAST = 1
_GREEN = 'green'
_IS_LINEAR_TONEMAP = 'is_linear_tonemap'
_LINEAR = 'linear'
_METADATA = 'metadata'
_MODE = 'mode'
_NAME = os.path.splitext(os.path.basename(__file__))[0]
_NONUNIFORM = 'nonuniform'
_NUM_FRAMES_PER_CAPTURE = 8
_PATCH_H = 0.2 # center 20%
_PATCH_W = 0.2 # center 20%
_PATCH_X = 0.5 - _PATCH_W/2
_PATCH_Y = 0.5 - _PATCH_H/2
_RED = 'red'
_RGB_R_CH = 0
_TONEMAP_CURVE = 'android.tonemap.curve'
_YUV = 'yuv'
_BRIGHTNESS_CHECK = [ # brightness checks between curves
(_DEFAULT, _LINEAR, _BRIGHTNESS_INCREASE_THRESHOLD, '>='),
(_DEFAULT, _DEFAULT_VALIDATION, _BRIGHTNESS_DELTA_THRESHOLD,
'<='),
(_LINEAR, _EXPONENTIAL, _BRIGHTNESS_INCREASE_THRESHOLD, '>='),
(_EXPONENTIAL, _NONUNIFORM, _EXPONENTIAL_NONUNIFORM_THRESHOLD,
'<=')
]
_EXPONENTIAL_CURVE = (
0.0000, 0.0000, 0.0667, 0.0044, 0.1333, 0.0178, 0.2000, 0.0400,
0.2667, 0.0711, 0.3333, 0.1111, 0.4000, 0.1600, 0.4667, 0.2178,
0.5333, 0.2844, 0.6000, 0.3600, 0.6667, 0.4444, 0.7333, 0.5378,
0.8000, 0.6400, 0.8667, 0.7511, 0.9333, 0.8711, 1.0000, 1.0000
)
# Non-uniform curve is a modified exponential curve characterized
# by having unequally spaced x-coordinates
_NONUNIFORM_CURVE = (
0.0000, 0.0000, 0.0455, 0.0021, 0.0667, 0.0044, 0.0909, 0.0083,
0.1364, 0.0186, 0.1818, 0.0331, 0.2273, 0.0517, 0.2727, 0.0744,
0.3182, 0.1012, 0.3636, 0.1322, 0.4091, 0.1674, 0.4545, 0.2066,
0.5000, 0.2500, 0.6667, 0.4444, 0.8333, 0.6944, 1.0000, 1.0000
)
_TONEMAP_MODES_CONFIG = {
_DEFAULT: {_MODE: _FAST, _CURVE: None},
_DEFAULT_VALIDATION: {_MODE: _CONTRAST_CURVE, _CURVE: None},
_LINEAR: {_MODE: None, _CURVE: None, _IS_LINEAR_TONEMAP: True},
_EXPONENTIAL: {_MODE: _CONTRAST_CURVE, _CURVE: _EXPONENTIAL_CURVE},
_NONUNIFORM: {_MODE: _CONTRAST_CURVE, _CURVE: _NONUNIFORM_CURVE}
}
def _create_tonemap_curve_plot(
tonemap_data_dict, name_with_log_path):
"""Create plot for tonemap curve.
Args:
tonemap_data_dict: Dictionary containing tonemap metadata for various modes.
Keys are tonemap names (e.g., 'default', 'linear'), values are
dictionary w/ 'red', 'green', 'blue' keys, each holding the curve data.
name_with_log_path: file path and name to save tonemap plots in.
"""
# Prepare a list for looping
plot_order = [
(_EXPONENTIAL, 'Exponential Curve Tonemap'),
(_NONUNIFORM, 'Nonuniform Exponential Curve Tonemap'),
(_DEFAULT, 'Default Tonemap'),
(_DEFAULT_VALIDATION, 'Default Validation Tonemap'),
(_LINEAR, 'Linear Tonemap')]
plt.clf() # Clear the current figure
fig = plt.figure(figsize=(18, 12)) # Layout for 6 subplots
colors_map = {'red': 'r', 'green': 'g', 'blue': 'b'}
subplot_index = 1
for tonemap_type, plot_title in plot_order:
ax = fig.add_subplot(2, 3, subplot_index)
# Handle the special case for the input curve
if tonemap_type == _EXPONENTIAL:
# Extracting x and y values for exponential curve tonemap
x_exponential_input, y_exponential_input = _extract_xy(_EXPONENTIAL_CURVE)
ax.plot(x_exponential_input, y_exponential_input, 'k--',
label='Exponential input', alpha=0.7)
if tonemap_type == _NONUNIFORM:
# Extracting x and y values for exponential, nonuniform curve tonemap
x_nonuniform_input, y_nonuniform_input = _extract_xy(_NONUNIFORM_CURVE)
ax.plot(x_nonuniform_input, y_nonuniform_input, 'k--',
label='Nonuniform input', alpha=0.7)
metadata_dict = tonemap_data_dict.get(tonemap_type, {})
for color, color_code in colors_map.items():
if color in metadata_dict:
x, y = _extract_xy(metadata_dict[color])
label = f'{tonemap_type.replace("_", " ").title()} {color} channel'
ax.plot(x, y, f'-{color_code}.', label=label)
else:
logging.warning("'%s' key not found in %s for plotting.",
color, tonemap_type)
ax.set_title(plot_title)
ax.set_xlabel('Tone map X coordinate')
ax.set_ylabel('Tone map Y coordinate')
ax.legend(loc='lower right', numpoints=1, fancybox=True, fontsize=8)
subplot_index += 1
plt.tight_layout()
plt.savefig(f'{name_with_log_path}_plot.png')
def _do_brightness_check(
patch_1st, patch_2nd, tonemap_1st, tonemap_2nd, threshold,
comparison_type):
"""Computes brightness difference between two image patches.
This function compares the red channel brightness of two image patches,
logging the difference and returning an error message if the difference
does not meet predefined expectations based on the tonemap modes.
Args:
patch_1st: img; patch of first tonemap for comparison.
patch_2nd: img; patch of second tonemap for comparison.
tonemap_1st: str; name of the first tonemap.
tonemap_2nd: str; name of the second tonemap.
threshold: float; brightness difference threshold.
comparison_type: str; type of comparison to do ('<=', '>=').
Returns:
A tuple containing:
- e_msg: A string with an error message if a brightness check fails,
otherwise None.
- brightness_diff: A float representing the brightness difference.
"""
e_msg = None
marginal_pass_msg = None
# Check the R channel brightness as it is most sensitive
brightness_1st = image_processing_utils.compute_image_means(patch_1st)
brightness_2nd = image_processing_utils.compute_image_means(patch_2nd)
brightness_diff = brightness_1st[_RGB_R_CH] - brightness_2nd[_RGB_R_CH]
logging.debug('Brightness difference between %s and %s: %.2f',
tonemap_1st, tonemap_2nd, brightness_diff)
# Check that the brightness difference is smaller than the max threshold
if comparison_type == '>=':
if brightness_diff < threshold:
e_msg = (
f'The brightness difference between {tonemap_1st} and '
f'{tonemap_2nd} for greyscale cells did not increase as expected. '
f'Actual: {brightness_diff:.3f}, '
f'Expected to be >= {threshold:.2f}')
logging.debug(e_msg)
elif (brightness_diff <= (
threshold * its_session_utils.MARGINAL_PASS_FACTOR_FLASH)):
marginal_pass_msg = (
f'Marginally passing brightness check between {tonemap_1st} and '
f'{tonemap_2nd}. Actual: {brightness_diff:.2f}, '
f'Expected: {threshold:.1f}. '
)
elif comparison_type == '<=':
if brightness_diff > threshold:
e_msg = (
f'The brightness difference between {tonemap_1st} and '
f'{tonemap_2nd} for greyscale cells exceeds the threshold. '
f'Actual: {brightness_diff:.3f}, '
f'Expected: {threshold:.2f}')
elif (brightness_diff >= (
threshold * its_session_utils.MARGINAL_PASS_FACTOR)):
marginal_pass_msg = (
f'Marginally passing brightness check between {tonemap_1st} and '
f'{tonemap_2nd}. Actual: {brightness_diff:.2f}, '
f'Expected: {threshold:.1f}. '
)
else:
e_msg = f'Invalid comparison type: {comparison_type}'
logging.debug(e_msg)
return e_msg, marginal_pass_msg, brightness_diff
def _do_captures_and_extract_patch(
cam, req, fmt, num_frames_per_cap, tonemap, log_path):
"""Do captures, save image and extract means from center patch.
Args:
cam: camera object.
req: capture request.
fmt: capture format.
num_frames_per_cap: int; number of frames per capture
tonemap: string to determine 'linear' or 'default' tonemap.
log_path: location to save images.
Returns:
A tuple containing:
- last_cap: The last capture metadata.
- patch: image patch of center 20% of image.
"""
cap = cam.do_capture([req]*num_frames_per_cap, fmt)
last_cap = cap[-1]
img = image_processing_utils.convert_capture_to_rgb_image(last_cap)
patch = image_processing_utils.get_image_patch(
img, _PATCH_X, _PATCH_Y, _PATCH_W, _PATCH_H)
image_processing_utils.write_image(
img, f'{os.path.join(log_path, _NAME)}_{tonemap}.jpg')
return last_cap, patch
def _extract_xy(data_list):
"""Extract x and y coordinates from a list of x and y coordinates.
Args:
data_list: list; of x and y coordinates in the same list.
Returns:
x_values: list; of x-coordinates only.
y_values: list; of y-coordinates only.
"""
x_values = data_list[0::2]
y_values = data_list[1::2]
return x_values, y_values
class TonemapSequence(its_base_test.ItsBaseTest):
"""Test tonemap curves with default, linear, exponential & nonuniform pins."""
def test_tonemap_sequence(self):
name_with_log_path = os.path.join(self.log_path, _NAME)
assertion_errors = []
with its_session_utils.ItsSession(
device_id=self.dut.serial,
camera_id=self.camera_id,
hidden_physical_id=self.hidden_physical_id) as cam:
props = cam.get_camera_properties()
props = cam.override_with_hidden_physical_camera_props(props)
first_api_level = its_session_utils.get_first_api_level(self.dut.serial)
max_curve_points = props['android.tonemap.maxCurvePoints']
logging.debug('Max curve points: %s', max_curve_points)
camera_properties_utils.skip_unless(
first_api_level >= its_session_utils.ANDROID17_API_LEVEL and
camera_properties_utils.tonemap_mode(props, _FAST) and
camera_properties_utils.tonemap_contrast_curve(props) and
camera_properties_utils.max_curve_points(props)
)
log_path = self.log_path
# Initialize rotation rig
if self.rotator_cntl == 'None' or self.lighting_cntl == 'None':
logging.debug('Gen2 rig is not available.')
else:
gen2_rig_controller_utils.setup_gen2_rig_with_cam(self, cam)
# Define formats
largest_yuv = capture_request_utils.get_largest_format('yuv', props)
match_ar = (largest_yuv['width'], largest_yuv['height'])
fmt = capture_request_utils.get_near_vga_yuv_format(
props, match_ar=match_ar)
# Set params based on per_frame_control & read_3a
camera_properties_utils.log_minimum_focus_distance(props)
manual_and_per_frame_control = (
camera_properties_utils.per_frame_control(props) and
camera_properties_utils.read_3a(props))
tonemap_results = {}
out_surface = {
'width': fmt['width'], 'height': fmt['height'], 'format': _YUV}
for tonemap, config in _TONEMAP_MODES_CONFIG.items():
logging.info('Processing tonemap mode: %s', tonemap)
num_frames_per_cap = _NUM_FRAMES_PER_CAPTURE # Default for auto
use_linear_tonemap = config.get(_IS_LINEAR_TONEMAP, False)
# Determine the base capture request based on control type
if manual_and_per_frame_control:
logging.debug('PER_FRAME_CONTROL supported.')
num_frames_per_cap = 1
sens, exp, _, _, f_dist = cam.do_3a(
do_af=True, get_results=True, out_surfaces=out_surface)
req = capture_request_utils.manual_capture_request(
sens, exp, f_dist, use_linear_tonemap, props
if use_linear_tonemap else None)
else:
logging.debug('PER_FRAME_CONTROL not supported.')
num_frames_per_cap = _NUM_FRAMES_PER_CAPTURE
cam.do_3a(do_af=True, out_surfaces=out_surface)
req = capture_request_utils.auto_capture_request(
linear_tonemap=use_linear_tonemap, props=props
if use_linear_tonemap else None, do_af=True)
# Apply specific tonemap mode and curve if defined
if config[_MODE] is not None:
req['android.tonemap.mode'] = config[_MODE]
if config[_CURVE] is not None:
req[_TONEMAP_CURVE] = {
_RED: config[_CURVE],
_GREEN: config[_CURVE],
_BLUE: config[_CURVE]
}
if tonemap == _DEFAULT_VALIDATION:
req[_TONEMAP_CURVE] = {
_RED: default_metadata[_RED],
_GREEN: default_metadata[_GREEN],
_BLUE: default_metadata[_BLUE]
}
cap, patch = (
_do_captures_and_extract_patch(
cam, req, fmt, num_frames_per_cap, tonemap, log_path))
tonemap_results[tonemap] = {
_METADATA: cap[_METADATA][_TONEMAP_CURVE],
'patch': patch
}
if tonemap == _DEFAULT:
default_metadata = cap[_METADATA][_TONEMAP_CURVE]
# create tonemap curve plots
_create_tonemap_curve_plot(
{k: v[_METADATA] for k, v in tonemap_results.items()},
name_with_log_path
)
for tm1, tm2, threshold, comparison_type in _BRIGHTNESS_CHECK:
marginal_pass_message = []
if tm1 in tonemap_results and tm2 in tonemap_results:
e_msg, marginal_pass_msg, diff = _do_brightness_check(
tonemap_results[tm1]['patch'], tonemap_results[tm2]['patch'],
tm1, tm2, threshold, comparison_type
)
print(f'{_NAME}_{tm1}_{tm2}_mean_brightness_diff: {diff:.3f}')
logging.debug('%s_%s_mean_brightness_diff: %.3f', tm1, tm2, diff)
if e_msg:
assertion_errors.append(e_msg)
if marginal_pass_msg:
marginal_pass_message.append(marginal_pass_msg)
else:
error_msg = f'Data not available for {tm1} vs {tm2}.'
logging.error(error_msg)
assertion_errors.append(error_msg)
if assertion_errors:
raise AssertionError('\n'.join(assertion_errors))
elif marginal_pass_message:
logging.warning('%s\n %s', its_session_utils.MARGINAL_PASSING_MESSAGE,
marginal_pass_message)
if __name__ == '__main__':
test_runner.main()