blob: 686476ef2e14e28aa14aff79f62141546478b9da [file] [log] [blame]
# Copyright 2018 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""A factory test for the Fingerprint sensor.
Description
-----------
Tests that the fingerprint sensor is connected properly and has no defect
by executing commands through the fingerprint micro-controller.
Test Procedure
--------------
This is an automated test without user interaction,
it might use a rubber finger pressed against the sensor by a proper fixture.
Dependency
----------
The pytest supposes that the system as a fingerprint MCU exposed through the
kernel cros_ec driver as ``/dev/cros_fp``.
When available, it uses the vendor 'libfputils' shared library and its Python
helper to compute the image quality signal-to-noise ratio.
Examples
--------
Minimum runnable example to check if the fingerprint sensor is connected
properly and fits the default quality settings::
{
"pytest_name": "fingerprint_mcu"
}
To check if the sensor has at most 10 dead pixels and its HWID is 0x140c,
with bounds for the pixel grayscale median values and finger detection zones,
add this in test list::
{
"pytest_name": "fingerprint_mcu",
"args": {
"dead_pixel_max": 10,
"sensor_hwid": [
1234,
[5120, 65520]
],
"pixel_median": {
"cb_type1" : [180, 220],
"cb_type2" : [80, 120],
"icb_type1" : [15, 70],
"icb_type2" : [155, 210]
},
"detect_zones" : [
[8, 16, 15, 23], [24, 16, 31, 23], [40, 16, 47, 23],
[8, 66, 15, 73], [24, 66, 31, 73], [40, 66, 47, 73],
[8, 118, 15, 125], [24, 118, 31, 125], [40, 118, 47, 125],
[8, 168, 15, 175], [24, 168, 31, 175], [40, 168, 47, 175]
]
}
}
"""
import logging
import sys
import unittest
import numpy
from cros.factory.device import device_utils
from cros.factory.testlog import testlog
from cros.factory.test.utils import fpmcu_utils
from cros.factory.utils import type_utils
from cros.factory.utils.arg_utils import Arg
from cros.factory.utils import schema
# use the fingerprint image processing library if available
sys.path.extend(['/usr/local/opt/fpc', '/opt/fpc'])
try:
import fputils
libfputils = fputils.FpUtils()
except ImportError:
libfputils = None
_ARG_SENSOR_HWID_SCHEMA = schema.JSONSchemaDict(
'sensor hwid schema object',
{
'anyOf': [
{
'type': ['integer', 'null']
},
{
'type': 'array',
'items': {
'anyOf': [
{
'type': 'integer'
},
{
'type': 'array',
'items': {
'type': 'integer'
},
'minItems': 2,
'maxItems': 2
}
]
}
}
]
})
class FingerprintTest(unittest.TestCase):
"""Tests the fingerprint sensor."""
ARGS = [
Arg('sensor_hwid', (int, list),
'The list of rules of accepted finger sensor Hardware IDs. A rule in '
'sensor_hwid should be an integer or a list of two integers. If the '
'rule is an integer then it is an exact match. Otherwise the ID '
'masked by the second integer must match the first integer. If the '
'list is empty, the test do not check ID. Otherwise, The test fail '
'if all of the rules are not matched. This value could also be an'
'integer for backward compability, and it is an exact match.',
default=None, schema=_ARG_SENSOR_HWID_SCHEMA),
Arg('max_dead_pixels', int,
'The maximum number of dead pixels on the fingerprint sensor.',
default=10),
Arg('max_dead_detect_pixels', int,
'The maximum number of dead pixels in the detection zone.',
default=0),
Arg('max_pixel_dev', int,
'The maximum deviation from the median for a pixel of a given type.',
default=35),
Arg('pixel_median', dict,
'Keys: "(cb|icb)_(type1|type2)", '
'Values: a list of [minimum, maximum] '
'Range constraints of the pixel median value of the checkerboards.',
default={}),
Arg('detect_zones', list,
'a list of rectangles [x1, y1, x2, y2] defining '
'the finger detection zones on the sensor.',
default=[]),
Arg('min_snr', float,
'The minimum signal-to-noise ratio for the image quality.',
default=0.0),
Arg('rubber_finger_present', bool,
'A Rubber finger is pressed against the sensor for quality testing.',
default=False),
Arg('max_reset_pixel_dev', int,
'The maximum deviation from the median per column for a pixel from '
'test reset image.',
default=55),
Arg('max_error_reset_pixels', int,
'The maximum number of error pixels in the test_reset image.',
default=5),
Arg('fpframe_retry_count', int,
'The maximum number of retry for fpframe.',
default=0),
]
# MKBP index for Fingerprint sensor event
EC_MKBP_EVENT_FINGERPRINT = '5'
def setUp(self):
self._dut = device_utils.CreateDUTInterface()
self._fpmcu = fpmcu_utils.FpmcuDevice(self._dut)
def tearDown(self):
self._fpmcu.FpmcuCommand('fpmode', 'reset')
def FpmcuTryWaitEvent(self, *args, **kwargs):
try:
self._fpmcu.FpmcuCommand('waitevent', *args, **kwargs)
except Exception as e:
logging.error('Wait event fail: %s', e)
def FpmcuGetFpframe(self, *args, **kwargs):
# try fpframe command for at most (fpframe_retry_count + 1) times.
for num_retries in range(self.args.fpframe_retry_count + 1):
try:
img = self._fpmcu.FpmcuCommand('fpframe', *args, **kwargs)
break
except Exception as e:
if num_retries < self.args.fpframe_retry_count:
logging.info('Retrying fpframe %d times', num_retries + 1)
else:
# raise an exception if last attempt failed
raise e
return img
def IsDetectZone(self, x, y):
for x1, y1, x2, y2 in self.args.detect_zones:
if (x in range(x1, x2 + 1) and
y in range(y1, y2 + 1)):
return True
return False
def CheckPnmAndExtractPixels(self, pnm):
if not pnm:
raise type_utils.TestFailure('Failed to retrieve image')
lines = pnm.split('\n')
if lines[0].strip() != 'P2':
raise type_utils.TestFailure('Unsupported/corrupted image')
try:
# strip header/footer
pixel_lines = lines[3:-1]
except (IndexError, ValueError):
raise type_utils.TestFailure('Corrupted image')
return pixel_lines
def CalculateMedianAndDev(self, matrix):
# Transform the 2D array of triples in a 1-D array of triples
pixels = matrix.reshape((-1, 3))
median = numpy.median([v for v, x, y in pixels])
dev = [(abs(v - median), x, y) for v, x, y in pixels]
return median, dev
def ProcessCheckboardPixels(self, lines, parity):
# Keep only type-1 or type-2 pixels depending on parity
matrix = numpy.array([[(int(v), x, y) for x, v
in enumerate(l.strip().split())
if (x + y) % 2 == parity]
for y, l in enumerate(lines)])
return self.CalculateMedianAndDev(matrix)
def CheckerboardTest(self, inverted=False):
full_name = 'Inv. checkerboard' if inverted else 'Checkerboard'
short_name = 'icb' if inverted else 'cb'
# trigger the checkerboard test pattern and capture it
self._fpmcu.FpmcuCommand('fpmode', 'capture',
'pattern1' if inverted else 'pattern0')
# wait for the end of capture (or timeout after 500 ms)
self.FpmcuTryWaitEvent(self.EC_MKBP_EVENT_FINGERPRINT, '500')
# retrieve the resulting image as a PNM
pnm = self.FpmcuGetFpframe()
pixel_lines = self.CheckPnmAndExtractPixels(pnm)
# Build arrays of black and white pixels (aka Type-1 / Type-2)
# Compute pixels parameters for each type
median1, dev1 = self.ProcessCheckboardPixels(pixel_lines, 0)
median2, dev2 = self.ProcessCheckboardPixels(pixel_lines, 1)
all_dev = dev1 + dev2
max_dev = numpy.max([d for d, _, _ in all_dev])
# Count dead pixels (deviating too much from the median)
dead_count = 0
dead_detect_count = 0
for d, x, y in all_dev:
if d > self.args.max_pixel_dev:
dead_count += 1
if self.IsDetectZone(x, y):
dead_detect_count += 1
# Log everything first for debugging
logging.info('%s type 1 median:\t%d', full_name, median1)
logging.info('%s type 2 median:\t%d', full_name, median2)
logging.info('%s max deviation:\t%d', full_name, max_dev)
logging.info('%s dead pixels:\t%d', full_name, dead_count)
logging.info('%s dead pixels in detect zones:\t%d',
full_name, dead_detect_count)
testlog.UpdateParam(
name='dead_pixels_%s' % short_name,
description='Number of dead pixels',
value_unit='pixels')
if not testlog.CheckNumericParam(
name='dead_pixels_%s' % short_name,
value=dead_count,
max=self.args.max_dead_pixels):
raise type_utils.TestFailure('Too many dead pixels')
testlog.UpdateParam(
name='dead_detect_pixels_%s' % short_name,
description='Dead pixels in detect zone',
value_unit='pixels')
if not testlog.CheckNumericParam(
name='dead_detect_pixels_%s' % short_name,
value=dead_detect_count,
max=self.args.max_dead_detect_pixels):
raise type_utils.TestFailure('Too many dead pixels in detect zone')
# Check specified pixel range constraints
t1 = "%s_type1" % short_name
testlog.UpdateParam(
name=t1,
description='Median Type-1 pixel value',
value_unit='8-bit grayscale')
if t1 in self.args.pixel_median and not testlog.CheckNumericParam(
name=t1,
value=median1,
min=self.args.pixel_median[t1][0],
max=self.args.pixel_median[t1][1]):
raise type_utils.TestFailure('Out of range Type-1 pixels')
t2 = "%s_type2" % short_name
testlog.UpdateParam(
name=t2,
description='Median Type-2 pixel value',
value_unit='8-bit grayscale')
if t2 in self.args.pixel_median and not testlog.CheckNumericParam(
name=t2,
value=median2,
min=self.args.pixel_median[t2][0],
max=self.args.pixel_median[t2][1]):
raise type_utils.TestFailure('Out of range Type-2 pixels')
def CalculateMedianAndDevPerColumns(self, matrix):
# The data flow of the input matrix would be
# 1. original matrix:
# [1, 2, 150]
# [1, 2, 150]
# [1, 2, 3 ]
# 2. rotate 90 to access column in an index of array:
# [150, 150, 3]
# [2, 2, 2]
# [1, 1, 1]
# 3. flipud first level of array to put first column at index 0:
# [1, 1, 1]
# [2, 2, 2]
# [150, 150, 3]
# 4. medians per column - [1, 2, 150]
# 5. devs per column:
# [0, 0, 0 ]
# [0, 0, 0 ]
# [0, 0, 147]
matrix = numpy.rot90(matrix)
matrix = numpy.flipud(matrix)
medians = [numpy.median([v for v, x, y in l]) for l in matrix]
devs = [[(abs(v - medians[x]), x, y) for v, x, y in l] for l in matrix]
return medians, devs
def ProcessResetPixelImage(self, lines):
matrix = numpy.array([[(int(v), x, y) for x, v
in enumerate(l.strip().split())]
for y, l in enumerate(lines)])
return self.CalculateMedianAndDevPerColumns(matrix)
def ResetPixelTest(self):
# reset the sensor and leave it in reset state then capture the single
# frame.
self._fpmcu.FpmcuCommand('fpmode', 'capture', 'test_reset')
# wait for the end of capture (or timeout after 500 ms)
self.FpmcuTryWaitEvent(self.EC_MKBP_EVENT_FINGERPRINT, '500')
# retrieve the resulting image as a PNM
pnm = self.FpmcuGetFpframe()
pixel_lines = self.CheckPnmAndExtractPixels(pnm)
# Compute median value and the deviation of every pixels per column.
medians, devs = self.ProcessResetPixelImage(pixel_lines)
# Count error pixels (deviating too much from the median)
error_count = 0
max_dev_per_columns = [numpy.max([d for d, _, _ in col]) for col in devs]
for col in devs:
for d, _, _ in col:
if d > self.args.max_reset_pixel_dev:
error_count += 1
# Log everything first for debugging
logging.info('error_count:\t%d', error_count)
logging.info('median per columns: %s', medians)
logging.info('max dev per columns (col, max_dev, median):\t%s',
max_dev_per_columns)
testlog.UpdateParam(
name='error_reset_pixel',
description='Number of error reset pixels',
value_unit='pixels')
if not testlog.CheckNumericParam(
name='error_reset_pixel',
value=error_count,
max=self.args.max_error_reset_pixels):
raise type_utils.TestFailure('Too many error reset pixels')
def runTest(self):
# Verify communication with the FPMCU
ro_ver, rw_ver = self._fpmcu.GetFpmcuFirmwareVersion()
self.assertTrue(ro_ver is not None and rw_ver is not None,
'Unable to retrieve FPMCU version')
logging.info("FPMCU version RO %s RW %s", ro_ver, rw_ver)
# Retrieve the sensor identifier
model = self._fpmcu.GetSensorId()
expected_hwid = type_utils.MakeList(self.args.sensor_hwid or [])
testlog.UpdateParam(
name='sensor_hwid', description='Sensor Hardware ID register')
testlog.LogParam('sensor_hwid', model)
def match(rule):
if isinstance(rule, int):
return model == rule
return (model & rule[1]) == rule[0]
if expected_hwid and not any(match(rule) for rule in expected_hwid):
raise type_utils.TestFailure('Invalid sensor HWID: %r' % model)
# checkerboard test patterns
self.CheckerboardTest(inverted=False)
self.CheckerboardTest(inverted=True)
self.ResetPixelTest()
if self.args.rubber_finger_present:
# Test sensor image quality
self._fpmcu.FpmcuCommand('fpmode', 'capture', 'qual')
# wait for the end of capture (or timeout after 5s)
self.FpmcuTryWaitEvent(self.EC_MKBP_EVENT_FINGERPRINT, '5000')
img = self.FpmcuGetFpframe('raw', encoding=None)
# record the raw image file for quality evaluation
testlog.AttachContent(
content=str(img),
name='finger_mqt.raw',
description='raw MQT finger image')
# Check quality if the function if available
if libfputils:
rc, snr = libfputils.mqt(img)
logging.info('MQT SNR %f (err:%d)', snr, rc)
if rc:
raise type_utils.TestFailure('MQT failed with error %d' % (rc))
testlog.UpdateParam(
name='mqt_snr', description='Image signal-to-noise ratio')
if not testlog.CheckNumericParam(
name='mqt_snr', value=snr, min=self.args.min_snr):
raise type_utils.TestFailure('Bad quality image')
elif self.args.min_snr > 0.0:
raise type_utils.TestFailure('No image quality library available')