blob: b205f547801baa28da3f980ddc764def70c6adb8 [file]
# Copyright 2024 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.
"""Ensure the captures from the default camera app and JCA are consistent."""
import logging
import math
import os
import pathlib
import threading
import time
import types
import camera_properties_utils
import image_processing_utils
import gen2_rig_controller_utils
import ip_chart_extraction_utils as ce
import ip_chart_pattern_detector as pd
import ip_metrics_utils
import its_base_test
import its_device_utils
import its_session_utils
from mobly import test_runner
from mobly.controllers.android_device_lib import adb
import sensor_fusion_utils
from snippet_uiautomator import uiautomator
import ui_interaction_utils
_CAMERA_HARDWARE_LEVEL_MAPPING = types.MappingProxyType({
0: 'LIMITED',
1: 'FULL',
2: 'LEGACY',
3: 'LEVEL_3',
4: 'EXTERNAL',
})
_JETPACK_CAMERA_APP_PACKAGE_NAME = 'com.google.jetpackcamera'
_AWB_DIFF_THRESHOLD = 4
_BRIGHTNESS_DIFF_THRESHOLD = 10
_NAME = os.path.splitext(os.path.basename(__file__))[0]
_COMMON_IMG_ARS_ATOL = 0.01
_COLOR_DIFF_THRESHOLD = 6
_CLOSE_CAMERA_WAIT_TIME_SECONDS = 5
def get_jca_ar(default_capture_path):
"""Returns the aspect ratio to be used in JCA.
Args:
default_capture_path: default camera app capture path
Returns:
jca_ar: aspect ratio to be used by JCA
Raises:
AssertionError: If JCA does not support the aspect ratio
"""
default_ar = ip_metrics_utils.get_aspect_ratio(default_capture_path)
logging.debug('Default camera app aspect ratio: %.2f', default_ar)
if (math.isclose(default_ar, 3/4, abs_tol=_COMMON_IMG_ARS_ATOL) or
math.isclose(default_ar, 4/3, abs_tol=_COMMON_IMG_ARS_ATOL)):
jca_ar = ui_interaction_utils.THREE_TO_FOUR_ASPECT_RATIO_DESC
elif (math.isclose(default_ar, 9/16, abs_tol=_COMMON_IMG_ARS_ATOL) or
math.isclose(default_ar, 16/9, abs_tol=_COMMON_IMG_ARS_ATOL)):
jca_ar = ui_interaction_utils.NINE_TO_SIXTEEN_ASPECT_RATIO_DESC
elif math.isclose(default_ar, 1, abs_tol=_COMMON_IMG_ARS_ATOL):
jca_ar = ui_interaction_utils.ONE_TO_ONE_ASPECT_RATIO_DESC
else:
raise AssertionError('Aspect ratio not supported by JCA')
logging.debug('Using %s for JCA captures.', jca_ar)
return jca_ar
class DefaultJcaImageParityClassTest(its_base_test.ItsBaseTest):
"""Test for default camera and JCA image parity."""
def _setup_gen2rig(self):
logging.debug('Setting up gen2 rig')
# Configure and setup gen2 rig
if self.rotator_cntl == 'None':
logging.debug('Gen2 rig motor is not available.')
self.motor_port = None
self.motor_channel = None
else:
self.motor_channel = int(self.rotator_ch)
self.motor_port = gen2_rig_controller_utils.find_serial_port(
self.rotator_cntl)
if self.motor_port:
gen2_rig_controller_utils.configure_rotator(
self.motor_port, self.motor_channel)
gen2_rig_controller_utils.rotate(self.motor_port, self.motor_channel)
logging.debug('Successfully configured and rotated the gen2 rig.')
if self.lighting_cntl == 'None':
logging.debug('Gen2 rig lights is not available.')
self.lighting_port = None
self.lighting_channel = None
else:
lights_channel = int(self.lighting_ch)
lights_port = gen2_rig_controller_utils.find_serial_port(self.lighting_cntl)
if lights_port:
sensor_fusion_utils.establish_serial_comm(lights_port)
gen2_rig_controller_utils.set_lighting_state(
lights_port, lights_channel, 'ON')
logging.debug('Successfully configured and turned ON the gen2 rig lights.')
def setup_class(self):
super().setup_class()
self.dut.services.register(
uiautomator.ANDROID_SERVICE_NAME, uiautomator.UiAutomatorService
)
gen2_rig_controller_utils.get_usb_devices_connected()
self.dut.adb.shell(['input', 'keyevent', 'KEYCODE_WAKEUP'])
time.sleep(its_base_test.TABLET_CMD_DELAY_SEC)
self.dut.adb.shell('svc power stayon usb')
# start screen recording
def start_screen_recording():
self.dut.adb.shell(
'screenrecord /sdcard/test_default_jca_ip_screen_recording.mp4')
self.thread = threading.Thread(target=start_screen_recording)
self.thread.start()
def teardown_test(self):
ui_interaction_utils.force_stop_app(
self.dut, _JETPACK_CAMERA_APP_PACKAGE_NAME
)
try:
self.dut.adb.shell(['pkill', '-SIGINT', 'screenrecord'])
except adb.AdbError as e:
logging.debug('Could not kill screenrecord process: %s', e)
if self.thread:
self.thread.join()
self.dut.adb.pull(['/sdcard/test_default_jca_ip_screen_recording.mp4',
self.log_path])
if self.rotator_cntl == 'gen2_rotator':
# Release the serial ports properly after the test
motor_port = gen2_rig_controller_utils.find_serial_port(self.rotator_cntl)
if motor_port:
motor_port.close()
if self.lighting_cntl == 'gen2_lights':
# Lights will go back to default state after the test
lights_port = gen2_rig_controller_utils.find_serial_port(
self.lighting_cntl
)
if lights_port:
lights_port.close()
def on_fail(self, record):
super().on_fail(record)
self.dut.take_screenshot(self.log_path, prefix='on_test_fail')
def test_default_jca_capture_ip(self):
"""Check default camera and JCA app image consistency."""
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)
if (props['android.lens.facing']
== camera_properties_utils.LENS_FACING['FRONT']):
camera_facing = 'front'
else:
camera_facing = 'rear'
logging.debug('Camera facing: %s', camera_facing)
camera_hardware_level = _CAMERA_HARDWARE_LEVEL_MAPPING[
props.get('android.info.supportedHardwareLevel')
]
logging.debug('Camera hardware level: %s', camera_hardware_level)
# logging for data collection
print(f'{_NAME}_camera_hardware_level: {camera_hardware_level}')
first_api_level = its_session_utils.get_first_api_level(self.dut.serial)
is_dut_tablet_or_desktop = its_device_utils.is_dut_tablet_or_desktop(
self.dut.serial)
# Skip the test if camera is not primary or if it is a tablet
is_primary_camera = self.hidden_physical_id is None
camera_properties_utils.skip_unless(
not is_dut_tablet_or_desktop and
is_primary_camera and
first_api_level >= its_session_utils.ANDROID16_API_LEVEL
)
device_id = self.dut.serial
# Set up gen2 rig
self._setup_gen2rig()
# Get default camera app pkg name
pkg_name = cam.get_default_camera_pkg()
logging.debug('Default camera pkg name: %s', pkg_name)
camera_ids = cam.get_camera_ids()
primary_rear_cam = camera_ids.get('primaryRearCameraId')
primary_front_cam = camera_ids.get('primaryFrontCameraId')
flip_camera = True
logging.debug('Camera ids on device: %s', camera_ids)
if primary_rear_cam is None or primary_front_cam is None:
logging.debug('Device only has one primary camera')
flip_camera = False
if camera_facing == 'front':
camera_properties_utils.skip_unless(self.camera_id == primary_front_cam)
if camera_facing == 'rear':
camera_properties_utils.skip_unless(self.camera_id == primary_rear_cam)
ui_interaction_utils.default_camera_app_dut_setup(device_id, pkg_name)
# Launch ItsTestActivity
its_device_utils.start_its_test_activity(device_id)
if self.dut.ui(text='OK').wait.exists(
timeout=ui_interaction_utils.WAIT_INTERVAL_FIVE_SECONDS
):
self.dut.ui(text='OK').click.wait()
# Ensure that the device is orthogonal and then close camera
if self.motor_port is not None:
gen2_rig_controller_utils.rotate_to_orthogonal_position(
cam, self.log_path, self.motor_port, self.motor_channel)
cam.close_camera()
ui_interaction_utils.force_stop_app(
self.dut, its_device_utils.CTS_VERIFIER_PKG
)
time.sleep(_CLOSE_CAMERA_WAIT_TIME_SECONDS)
# Take capture with default camera app
device_img_path = ui_interaction_utils.launch_and_take_capture(
dut=self.dut,
pkg_name=pkg_name,
camera_facing=camera_facing,
log_path=self.log_path,
flip_camera=flip_camera
)
ui_interaction_utils.pull_img_files(
device_id, device_img_path, self.log_path
)
time.sleep(_CLOSE_CAMERA_WAIT_TIME_SECONDS) # wait for camera to close
# Check if the image captured is HDR or not
its_device_utils.start_its_test_activity(device_id)
cam.__init__(device_id=self.dut.serial, camera_id=self.camera_id,
hidden_physical_id=self.hidden_physical_id)
gainmap_present = cam.check_gain_map_present(device_img_path)
logging.debug('gainmap_present: %s', gainmap_present)
default_img_name = pathlib.Path(device_img_path).name
default_path = os.path.join(self.log_path, default_img_name)
logging.debug('Default capture img name: %s', default_img_name)
default_capture_path = pathlib.Path(default_path)
default_capture_path = default_capture_path.with_name(
f'{default_capture_path.stem}_default{default_capture_path.suffix}'
)
os.rename(default_path, default_capture_path)
# Get the zoomRatio value used by default camera app
default_watch_dump_file = os.path.join(
self.log_path,
ui_interaction_utils.DEFAULT_CAMERA_WATCH_DUMP_FILE
)
jca_ar = get_jca_ar(default_capture_path)
zoom_method = ui_interaction_utils.get_default_camera_zoom_method(
default_watch_dump_file)
logging.debug('Default camera app uses %s to control the zoom.',
zoom_method)
zoom_ratio = 1.0
if zoom_method == 'cropRegion':
scaler_crop_region = (
ui_interaction_utils.get_default_camera_crop_region(
default_watch_dump_file)
)
zoom_ratio = ip_metrics_utils.derive_hal_zoom_ratio(
props, scaler_crop_region)
else:
zoom_ratio = ui_interaction_utils.get_default_camera_zoom_ratio(
default_watch_dump_file)
logging.debug('Default camera captures zoomRatio value: %s', zoom_ratio)
jca_zoom_ratio = None
if zoom_ratio != 1.0:
jca_zoom_ratio = zoom_ratio
video_stabilization = None
video_stabilization_mode = (
ui_interaction_utils.get_default_camera_video_stabilization(
default_watch_dump_file)
)
if video_stabilization_mode == 'OFF':
# Check if device has OIS enabled
ois_enabled = (
ui_interaction_utils.get_default_camera_ois_mode(
default_watch_dump_file)
)
if ois_enabled == 'ON':
video_stabilization = (
ui_interaction_utils.JCA_VIDEO_STABILIZATION_MODE_OPTICAL
)
else:
video_stabilization = (
ui_interaction_utils.JCA_VIDEO_STABILIZATION_MODE_OFF
)
elif video_stabilization_mode == 'PREVIEW_STABILIZATION':
video_stabilization = (
ui_interaction_utils.JCA_VIDEO_STABILIZATION_MODE_ON
)
elif video_stabilization_mode == 'ON':
video_stabilization = (
ui_interaction_utils.JCA_VIDEO_STABILIZATION_MODE_HIGH_QUALITY
)
else:
logging.warning(
'Unexpected video stabilization mode: %s. Leaving JCA stabilization'
' as default.', video_stabilization_mode
)
# Take JCA capture with UI
ui_interaction_utils.force_stop_app(
self.dut, its_device_utils.CTS_VERIFIER_PKG
)
time.sleep(_CLOSE_CAMERA_WAIT_TIME_SECONDS)
cam.sock = None # stop socket commands after CTS Verifier is closed
jca_capture_path, _ = ui_interaction_utils.launch_jca_and_capture(
self.dut,
self.log_path,
camera_facing=props['android.lens.facing'],
zoom_ratio=jca_zoom_ratio,
video_stabilization=video_stabilization,
jca_aspect_ratio=jca_ar,
enable_hdr=gainmap_present
)
ui_interaction_utils.pull_img_files(
device_id, jca_capture_path, self.log_path
)
img_name = pathlib.Path(jca_capture_path).name
jca_path = os.path.join(self.log_path, img_name)
logging.debug('JCA capture img name: %s', img_name)
jca_capture_path = pathlib.Path(jca_path)
jca_capture_path = jca_capture_path.with_name(
f'{jca_capture_path.stem}_jca{jca_capture_path.suffix}'
)
os.rename(jca_path, jca_capture_path)
# Decompress UltraHDR image to RGB image
if gainmap_present:
default_capture_rgb_path = default_capture_path.with_name(
f'{default_capture_path.stem}_rgb{default_capture_path.suffix}'
)
image_processing_utils.decompress_ultrahdr_image(
default_capture_path, default_capture_rgb_path
)
jca_capture_rgb_path = jca_capture_path.with_name(
f'{jca_capture_path.stem}_rgb{jca_capture_path.suffix}'
)
image_processing_utils.decompress_ultrahdr_image(
jca_capture_path, jca_capture_rgb_path
)
default_capture_path = default_capture_rgb_path
jca_capture_path = jca_capture_rgb_path
logging.debug('Decompressed UltraHDR image to RGB image')
# Extract FULL_CHART from the captured image.
_, _ = (
ce.get_feature_from_image(
default_capture_path,
'default_full_chart',
self.log_path,
pd.TestChartFeature.FULL_CHART,
)
)
_, _ = ce.get_feature_from_image(
jca_capture_path,
'jca_full_chart',
self.log_path,
pd.TestChartFeature.FULL_CHART,
)
default_qr_code, _ = ce.get_feature_from_image(
default_capture_path,
'default_qr_code',
self.log_path,
pd.TestChartFeature.CENTER_QR_CODE,
)
jca_qr_code, _ = ce.get_feature_from_image(
jca_capture_path,
'jca_qr_code',
self.log_path,
pd.TestChartFeature.CENTER_QR_CODE,
)
logging.debug('Checking if FoV match between default and jca captures')
default_fov = ip_metrics_utils.get_fov_in_degrees(
default_capture_path, default_qr_code, self.chart_distance)
logging.debug('Default camera FoV: %.2f', default_fov)
jca_fov = ip_metrics_utils.get_fov_in_degrees(
jca_capture_path, jca_qr_code, self.chart_distance)
logging.debug('JCA camera FoV: %.2f', jca_fov)
fov_match = True
if not math.isclose(
default_fov, jca_fov, rel_tol=ip_metrics_utils.FOV_REL_TOL):
fov_match = False
logging.debug(
'Default and JCA FOV difference within tolerance: %s.\n '
'Expected: %s, Actual: %s', fov_match,
ip_metrics_utils.FOV_REL_TOL,
abs(default_fov - jca_fov) / max(abs(default_fov), abs(jca_fov))
)
# logging for data collection
print(f'{_NAME}_fov_match: {fov_match}')
# Get cropped dynamic range patch cells
default_dynamic_range_patch_cells = (
ce.get_cropped_dynamic_range_patch_cells(
default_capture_path, self.log_path, 'default')
)
jca_dynamic_range_patch_cells = ce.get_cropped_dynamic_range_patch_cells(
jca_capture_path, self.log_path, 'jca'
)
e_msg = []
# Get brightness diff between default and jca captures
mean_brightness_diff = ip_metrics_utils.do_brightness_check(
default_dynamic_range_patch_cells, jca_dynamic_range_patch_cells
)
# logging for data collection
print(f'{_NAME}_mean_brightness_diff: {mean_brightness_diff}')
logging.debug('mean_brightness_diff: %f', mean_brightness_diff)
if abs(mean_brightness_diff) > _BRIGHTNESS_DIFF_THRESHOLD:
e_msg.append('Device fails the brightness difference criteria.')
marginal_brightness_pass = False
marginal_pass_msg = []
if (abs(mean_brightness_diff) > (
_BRIGHTNESS_DIFF_THRESHOLD * its_session_utils.MARGINAL_PASS_FACTOR)
and
abs(mean_brightness_diff) <= (_BRIGHTNESS_DIFF_THRESHOLD)):
marginal_brightness_pass = True
marginal_pass_msg.append('Marginally passing brightness check.')
# Get white balance diff between default and jca captures
mean_white_balance_diff = ip_metrics_utils.do_white_balance_check(
default_dynamic_range_patch_cells, jca_dynamic_range_patch_cells,
'default', 'jca'
)
marginal_while_balance_pass = False
if (abs(mean_white_balance_diff) > (
_AWB_DIFF_THRESHOLD * its_session_utils.MARGINAL_PASS_FACTOR)
and
abs(mean_white_balance_diff) <= (_AWB_DIFF_THRESHOLD)):
marginal_while_balance_pass = True
marginal_pass_msg.append('Marginally passing white balance diff check.')
# Get cropped color cells
default_cropped_color_cells = ce.get_cropped_color_cells(
default_capture_path, self.log_path, 'default')
jca_cropped_color_cells = ce.get_cropped_color_cells(
jca_capture_path, self.log_path, 'jca')
mean_delta_ab_diff, _ = ip_metrics_utils.get_color_rendering_variation(
default_cropped_color_cells, jca_cropped_color_cells, gainmap_present)
# logging for data collection
print(f'{_NAME}_mean_delta_ab_diff: {mean_delta_ab_diff}')
logging.debug('mean_delta_ab_diff: %.2f', mean_delta_ab_diff)
if abs(mean_delta_ab_diff) > _COLOR_DIFF_THRESHOLD:
e_msg.append('Device fails the color rendering difference criteria.')
# Check for marginal pass for color rendering check
marginal_color_pass = False
if (abs(mean_delta_ab_diff) > (
_COLOR_DIFF_THRESHOLD * its_session_utils.MARGINAL_PASS_FACTOR)
and
abs(mean_delta_ab_diff) <= (_COLOR_DIFF_THRESHOLD)):
marginal_color_pass = True
marginal_pass_msg.append('Marginally passing color rendering check.')
# logging for data collection
print(f'{_NAME}_mean_white_balance_diff: {mean_white_balance_diff}')
logging.debug('mean_white_balance_diff: %f', mean_white_balance_diff)
if abs(mean_white_balance_diff) > _AWB_DIFF_THRESHOLD:
e_msg.append('Device fails the white balance difference criteria.')
if not fov_match:
e_msg.append('Device fails the FOV match check.')
if first_api_level >= its_session_utils.ANDROID17_API_LEVEL:
sysprop_enabled_output = self.dut.adb.shell(
['getprop', 'ro.camera.default_app_social_media_parity_enabled']
)
sysprop_enabled = str(sysprop_enabled_output.decode('utf-8')).strip()
logging.debug('sysprop_enabled: %s', sysprop_enabled)
if sysprop_enabled.lower() != 'true':
raise AssertionError(
'Android 17 and above requires sysprop enabled.')
if e_msg:
if first_api_level <= its_session_utils.ANDROID17_API_LEVEL:
raise AssertionError(
f'{its_session_utils.NOT_YET_MANDATED_MESSAGE}\n\n{e_msg}')
else:
raise AssertionError(e_msg) # Checks mandated starting Android 17
else: # Check for marginal pass
if (marginal_brightness_pass or marginal_while_balance_pass or
marginal_color_pass):
logging.warning('%s\n %s', its_session_utils.MARGINAL_PASSING_MESSAGE,
marginal_pass_msg)
if __name__ == '__main__':
test_runner.main()