ITS: create test to test for scene change

Add test to check for afSceneChange flag. Testing done with
Pixel 4 cam[0] --> PASS with auto and manual
Pixel 3 cam[0] --> SKIP
Tecno Spark 3 cam[0] --> SKIP, but correct behavior less 3A

bug: 69932800

Change-Id: Ia3eddb8f2d20366c46991cfce6c45ad8edb64cf3
diff --git a/apps/CameraITS/pymodules/its/caps.py b/apps/CameraITS/pymodules/its/caps.py
index 4c527d9..06e1174 100644
--- a/apps/CameraITS/pymodules/its/caps.py
+++ b/apps/CameraITS/pymodules/its/caps.py
@@ -572,6 +572,18 @@
             sensor_fusion(props)])
 
 
+def continuous_picture(props):
+    """Returns whether a device supports CONTINUOUS_PICTURE."""
+    return props.has_key('android.control.afAvailableModes') and \
+              4 in props['android.control.afAvailableModes']
+
+
+def af_scene_change(props):
+    """Returns whether a device supports afSceneChange."""
+    return props.has_key('camera.characteristics.resultKeys') and \
+              'android.control.afSceneChange' in props['camera.characteristics.resultKeys']
+
+
 class __UnitTest(unittest.TestCase):
     """Run a suite of unit tests on this module.
     """
diff --git a/apps/CameraITS/tests/scene_change/scene_change.pdf b/apps/CameraITS/tests/scene_change/scene_change.pdf
new file mode 100644
index 0000000..a5786d6
--- /dev/null
+++ b/apps/CameraITS/tests/scene_change/scene_change.pdf
Binary files differ
diff --git a/apps/CameraITS/tests/scene_change/scene_change_0.5x_scaled.pdf b/apps/CameraITS/tests/scene_change/scene_change_0.5x_scaled.pdf
new file mode 100644
index 0000000..ca92a4e
--- /dev/null
+++ b/apps/CameraITS/tests/scene_change/scene_change_0.5x_scaled.pdf
Binary files differ
diff --git a/apps/CameraITS/tests/scene_change/scene_change_0.67x_scaled.pdf b/apps/CameraITS/tests/scene_change/scene_change_0.67x_scaled.pdf
new file mode 100644
index 0000000..eca9d3e
--- /dev/null
+++ b/apps/CameraITS/tests/scene_change/scene_change_0.67x_scaled.pdf
Binary files differ
diff --git a/apps/CameraITS/tests/scene_change/test_scene_change.py b/apps/CameraITS/tests/scene_change/test_scene_change.py
new file mode 100644
index 0000000..2304759
--- /dev/null
+++ b/apps/CameraITS/tests/scene_change/test_scene_change.py
@@ -0,0 +1,200 @@
+# Copyright 2020 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.
+
+import multiprocessing
+import os.path
+import subprocess
+import sys
+import time
+import its.caps
+import its.device
+import its.image
+import its.objects
+
+BRIGHT_CHANGE_TOL = 0.2
+CONTINUOUS_PICTURE_MODE = 4
+CONVERGED_3A = [[2, 2, 2], [2, 5, 2], [2, 6, 2]]  # [AE, AF, AWB]
+# AE_STATES: {0: INACTIVE, 1: SEARCHING, 2: CONVERGED, 3: LOCKED,
+#             4: FLASH_REQ, 5: PRECAPTURE}
+# AF_STATES: {0: INACTIVE, 1: PASSIVE_SCAN, 2: PASSIVE_FOCUSED,
+#             3: PASSIVE_UNFOCUSED, 4: ACTIVE_SCAN, 5: FOCUS_LOCKED,
+#             6: NOT_FOCUSED_LOCKED}
+# AWB_STATES: {0: INACTIVE, 1: SEARCHING, 2: CONVERGED, 3: LOCKED}
+DELAY_CAPTURE = 0.55  # delay in first capture to sync events (sec)
+DELAY_DISPLAY = 3.0  # time when display turns OFF (sec)
+FPS = 30
+FRAME_SHIFT = 5.0  # number of frames to shift to try and find scene change
+NAME = os.path.basename(__file__).split('.')[0]
+NUM_BURSTS = 6
+NUM_FRAMES = 50
+W, H = 640, 480
+
+
+def get_cmd_line_args():
+    chart_host_id = None
+    for s in list(sys.argv[1:]):
+        if s[:6] == 'chart=' and len(s) > 6:
+            chart_host_id = s[6:]
+    return chart_host_id
+
+
+def mask_3a_settling_frames(cap_data):
+    converged_frame = -1
+    for i, cap in enumerate(cap_data):
+        if cap['3a_state'] in CONVERGED_3A:
+            converged_frame = i
+            break
+    print 'Frames index where 3A converges: %d' % converged_frame
+    assert converged_frame != -1, '3A does not converge'
+    return converged_frame
+
+
+def determine_if_scene_changed(cap_data, converged_frame):
+    scene_changed = False
+    bright_changed = False
+    settled_frame_brightness = cap_data[converged_frame]['avg']
+    for i in range(converged_frame, len(cap_data)):
+        if cap_data[i]['avg'] <= (
+                settled_frame_brightness * (1.0 - BRIGHT_CHANGE_TOL)):
+            bright_changed = True
+        if cap_data[i]['flag'] == 1:
+            scene_changed = True
+    return scene_changed, bright_changed
+
+
+def toggle_screen(chart_host_id, state, delay):
+    t0 = time.time()
+    print 'tablet event start'
+    screen_id_arg = ('screen=%s' % chart_host_id)
+    state_id_arg = 'state=%s' % state
+    delay_arg = 'delay=%.3f' % delay
+    cmd = ['python', os.path.join(os.environ['CAMERA_ITS_TOP'], 'tools',
+                                  'toggle_screen.py'), screen_id_arg,
+           state_id_arg, delay_arg]
+    screen_cmd_code = subprocess.call(cmd)
+    assert screen_cmd_code == 0
+    t = time.time() - t0
+    print 'tablet event %s: %.3f' % (state, t)
+
+
+def capture_frames(delay, burst):
+    """Capture frames."""
+    cap_data_list = []
+    with its.device.ItsSession() as cam:
+        req = its.objects.auto_capture_request()
+        req['android.control.afMode'] = CONTINUOUS_PICTURE_MODE
+        fmt = {'format': 'yuv', 'width': W, 'height': H}
+        t0 = time.time()
+        time.sleep(delay)
+        print 'cap event start:', time.time() - t0
+        caps = cam.do_capture([req]*NUM_FRAMES, fmt)
+        print 'cap event stop:', time.time() - t0
+        # extract frame metadata and frame
+        for i, cap in enumerate(caps):
+            cap_data = {}
+            md = cap['metadata']
+            exp = md['android.sensor.exposureTime']
+            iso = md['android.sensor.sensitivity']
+            fd = md['android.lens.focalLength']
+            ae_state = md['android.control.aeState']
+            af_state = md['android.control.afState']
+            awb_state = md['android.control.awbState']
+            fd_str = 'infinity'
+            if fd != 0.0:
+                fd_str = str(round(1.0E2/fd, 2)) + 'cm'
+            scene_change_flag = md['android.control.afSceneChange']
+            assert scene_change_flag in [0, 1], 'afSceneChange not in [0,1]'
+            img = its.image.convert_capture_to_rgb_image(cap)
+            its.image.write_image(img, '%s_%d_%d.jpg' % (NAME, burst+1, i))
+            tile = its.image.get_image_patch(img, 0.45, 0.45, 0.1, 0.1)
+            g = its.image.compute_image_means(tile)[1]
+            print '%d, iso: %d, exp: %.2fms, fd: %s, avg: %.3f' % (
+                    i, iso, exp*1E-6, fd_str, g),
+            print '[ae,af,awb]: [%d,%d,%d], change: %d' % (
+                    ae_state, af_state, awb_state, scene_change_flag)
+            cap_data['exp'] = exp
+            cap_data['iso'] = iso
+            cap_data['fd'] = fd
+            cap_data['3a_state'] = [ae_state, af_state, awb_state]
+            cap_data['avg'] = g
+            cap_data['flag'] = scene_change_flag
+            cap_data_list.append(cap_data)
+        return cap_data_list
+
+
+def main():
+    """Test scene change.
+
+    Do auto capture with face scene. Power down tablet and recapture.
+    Confirm android.control.afSceneChangeDetected is True.
+    """
+    # check for skip conditions and do 3a up front
+    with its.device.ItsSession() as cam:
+        props = cam.get_camera_properties()
+        props = cam.override_with_hidden_physical_camera_props(props)
+        its.caps.skip_unless(its.caps.continuous_picture(props) and
+                             its.caps.af_scene_change(props) and
+                             its.caps.read_3a(props))
+        cam.do_3a()
+
+    # do captures with scene change
+    chart_host_id = get_cmd_line_args()
+    cap_delay = DELAY_CAPTURE
+    scene_delay = DELAY_DISPLAY
+    for burst in range(NUM_BURSTS):
+        if chart_host_id:
+            print '\nToggling tablet. Scene change at %.3fs.' % scene_delay
+            multiprocessing.Process(name='p1', target=toggle_screen,
+                                    args=(chart_host_id, 'OFF',
+                                          scene_delay,)).start()
+        else:
+            print '\nWave hand in front of camera to create scene change.'
+        cap_data = capture_frames(cap_delay, burst+1)
+
+        # find frame where 3A converges
+        converged_frame = mask_3a_settling_frames(cap_data)
+
+        # turn tablet back on to return to baseline scene state
+        if chart_host_id:
+            toggle_screen(chart_host_id, 'ON', 0)
+
+        # determine if brightness changed and/or scene change flag asserted
+        scene_changed, bright_changed = determine_if_scene_changed(
+                cap_data, converged_frame)
+        if not scene_changed:
+            if bright_changed:
+                print ' No scene change, but brightness change.'
+                scene_delay -= FRAME_SHIFT/FPS  # tablet-off earlier
+            else:
+                print ' No scene change, no brightness change.'
+                if cap_data[NUM_FRAMES-1]['avg'] < 0.1:
+                    print ' Scene dark entire capture. Shift later.'
+                    scene_delay += FRAME_SHIFT/FPS  # tablet-off later
+                else:
+                    print ' Scene light entire capture. Shift earlier.'
+                    scene_delay -= FRAME_SHIFT/FPS  # tablet-off earlier
+            print ' Retry with tablet turning OFF earlier.'
+        elif scene_changed and bright_changed:
+            print ' scene & brightness change on burst %d.' % (burst+1)
+            break
+        elif scene_changed and not bright_changed:
+            msg = ' scene change, but no brightness change.'
+            assert False, msg
+        if burst == NUM_BURSTS - 1:
+            msg = 'No scene change in %dx tries' % NUM_BURSTS
+            assert False, msg
+
+
+if __name__ == '__main__':
+    main()
diff --git a/apps/CameraITS/tools/run_all_tests.py b/apps/CameraITS/tools/run_all_tests.py
index ef8a91c..83177f2 100644
--- a/apps/CameraITS/tools/run_all_tests.py
+++ b/apps/CameraITS/tools/run_all_tests.py
@@ -56,7 +56,7 @@
 #   scene*_a/b/... are similar scenes that share one or more tests
 ALL_SCENES = ['scene0', 'scene1_1', 'scene1_2', 'scene2_a', 'scene2_b',
               'scene2_c', 'scene2_d', 'scene2_e', 'scene3', 'scene4',
-              'scene5', 'sensor_fusion']
+              'scene5', 'sensor_fusion', 'scene_change']
 
 # Scenes that are logically grouped and can be called as group
 GROUPED_SCENES = {
@@ -66,7 +66,8 @@
 
 # Scenes that can be automated through tablet display
 AUTO_SCENES = ['scene0', 'scene1_1', 'scene1_2', 'scene2_a', 'scene2_b',
-               'scene2_c', 'scene2_d', 'scene2_e', 'scene3', 'scene4']
+               'scene2_c', 'scene2_d', 'scene2_e', 'scene3', 'scene4',
+               'scene_change']
 
 SCENE_REQ = {
         'scene0': None,
@@ -86,7 +87,8 @@
         'sensor_fusion': 'Rotating checkboard pattern. See '
                          'sensor_fusion/SensorFusion.pdf for detailed '
                          'instructions.\nNote that this test will be skipped '
-                         'on devices not supporting REALTIME camera timestamp.'
+                         'on devices not supporting REALTIME camera timestamp.',
+        'scene_change': 'The picture in tests/scene_change.pdf with faces'
 }
 
 SCENE_EXTRA_ARGS = {
@@ -119,7 +121,10 @@
         'scene3': [],
         'scene4': [],
         'scene5': [],
-        'sensor_fusion': []
+        'sensor_fusion': [],
+        'scene_change': [
+                ['test_scene_change', 31]
+        ]
 }
 
 # Must match mHiddenPhysicalCameraSceneIds in ItsTestActivity.java
@@ -155,7 +160,8 @@
         'scene5': [],
         'sensor_fusion': [
                 'test_sensor_fusion'
-        ]
+        ],
+        'scene_change': []
 }
 
 # Tests run in more than 1 scene.
@@ -181,7 +187,8 @@
         'scene3': [],
         'scene4': [],
         'scene5': [],
-        'sensor_fusion': []
+        'sensor_fusion': [],
+        'scene_change': []
 }
 
 
@@ -568,7 +575,8 @@
                 if cmd is not None:
                     valid_scene_code = subprocess.call(cmd, cwd=topdir)
                     assert valid_scene_code == 0
-            print 'Start running ITS on camera %s, %s' % (id_combo_string, scene)
+            print 'Start running ITS on camera %s, %s' % (
+                    id_combo_string, scene)
             # Extract chart from scene for scene3 once up front
             chart_loc_arg = ''
             chart_height = CHART_HEIGHT
@@ -582,6 +590,8 @@
                 chart_loc_arg = 'chart_loc=%.2f,%.2f,%.2f,%.2f,%.3f' % (
                         chart.xnorm, chart.ynorm, chart.wnorm, chart.hnorm,
                         chart.scale)
+            if scene == 'scene_change' and not auto_scene_switch:
+                print '\nWave hand over camera to create scene change'
             # Run each test, capturing stdout and stderr.
             for (testname, testpath) in tests:
                 # Only pick predefined tests for hidden physical camera
@@ -729,14 +739,16 @@
             print 'Shutting down chart screen: ', chart_host_id
             screen_id_arg = ('screen=%s' % chart_host_id)
             cmd = ['python', os.path.join(os.environ['CAMERA_ITS_TOP'], 'tools',
-                                          'turn_off_screen.py'), screen_id_arg]
+                                          'toggle_screen.py'), screen_id_arg,
+                                          'state=OFF']
             screen_off_code = subprocess.call(cmd)
             assert screen_off_code == 0
 
             print 'Shutting down DUT screen: ', device_id
             screen_id_arg = ('screen=%s' % device_id)
             cmd = ['python', os.path.join(os.environ['CAMERA_ITS_TOP'], 'tools',
-                                          'turn_off_screen.py'), screen_id_arg]
+                                          'toggle_screen.py'), screen_id_arg,
+                                          'state=OFF']
             screen_off_code = subprocess.call(cmd)
             assert screen_off_code == 0
 
diff --git a/apps/CameraITS/tools/turn_off_screen.py b/apps/CameraITS/tools/toggle_screen.py
similarity index 73%
rename from apps/CameraITS/tools/turn_off_screen.py
rename to apps/CameraITS/tools/toggle_screen.py
index 1faef9e..e91fadb 100644
--- a/apps/CameraITS/tools/turn_off_screen.py
+++ b/apps/CameraITS/tools/toggle_screen.py
@@ -17,31 +17,39 @@
 import sys
 import time
 
-TURN_OFF_DELAY = 1  # seconds. Needed for back to back runs
+SCREEN_DELAY = 1  # seconds. Needed for back to back runs
 
 
 def main():
     """Put screen to sleep."""
     screen_id = ''
+    state = 'OFF'  # turn OFF by default
+    delay_sec = 0  # no delay by default
     for s in sys.argv[1:]:
         if s[:7] == 'screen=' and len(s) > 7:
             screen_id = s[7:]
+        elif s[:6] == 'state=' and len(s) > 6:
+            state = s[6:]
+        elif s[:6] == 'delay=' and len(s) > 6:
+            delay_sec = float(s[6:])
 
     if not screen_id:
         print 'Error: need to specify screen serial'
         assert False
 
+    time.sleep(delay_sec)
     cmd = ('adb -s %s shell dumpsys power | egrep "Display Power"'
            % screen_id)
     process = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
     cmd_ret = process.stdout.read()
     screen_state = re.split(r'[s|=]', cmd_ret)[-1]
-    if 'OFF' in screen_state:
-        print 'Screen already OFF.'
+    if state in screen_state:
+        print 'Screen already %s.' % state
     else:
+        print 'Turning screen %s.' % state
         pwrdn = ('adb -s %s shell input keyevent POWER' % screen_id)
         subprocess.Popen(pwrdn.split())
-        time.sleep(TURN_OFF_DELAY)
+        time.sleep(SCREEN_DELAY)
 
 if __name__ == '__main__':
     main()