blob: 08cf70cec7158f798d2f6736e74ad7b643b0e396 [file] [log] [blame]
/*
* Copyright (C) 2017 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.
*/
package com.android.media.tests;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.util.Pair;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import javax.imageio.ImageIO;
/**
* Class that analyzes a screenshot captured from AudioLoopback test. There is a wave form in the
* screenshot that has specific colors (TARGET_COLOR). This class extracts those colors and analyzes
* wave amplitude, duration and form and make a decision if it's a legitimate wave form or not.
*/
public class AudioLoopbackImageAnalyzer {
// General
private static final int VERTICAL_THRESHOLD = 0;
private static final int PRIMARY_WAVE_COLOR = 0xFF1E4A99;
private static final int SECONDARY_WAVE_COLOR = 0xFF1D4998;
private static final int[] TARGET_COLORS_TABLET =
new int[] {PRIMARY_WAVE_COLOR, SECONDARY_WAVE_COLOR};
private static final int[] TARGET_COLORS_PHONE =
new int[] {PRIMARY_WAVE_COLOR, SECONDARY_WAVE_COLOR};
private static final float EXPERIMENTAL_WAVE_MAX_TABLET = 69.0f; // In percent of image height
private static final float EXPERIMENTAL_WAVE_MAX_PHONE = 32.0f; // In percent of image height
// Image
private static final int TABLET_SCREEN_MIN_WIDTH = 1700;
private static final int TABLET_SCREEN_MIN_HEIGHT = 2300;
// Duration parameters
// Max duration should not span more than 2 of the 11 sections in the graph
// Min duration should not be less than 1/4 of a section
private static final float SECTION_WIDTH_IN_PERCENT = 100 * 1 / 11; // In percent of image width
private static final float DURATION_MIN = SECTION_WIDTH_IN_PERCENT / 4;
// Amplitude
// Required numbers of column for a response
private static final int MIN_NUMBER_OF_COLUMNS = 4;
// The difference between two amplitude columns should not be more than this
private static final float MAX_ALLOWED_COLUMN_DECREASE = 0.42f;
// Only check MAX_ALLOWED_COLUMN_DECREASE up to this number
private static final float MIN_NUMBER_OF_DECREASING_COLUMNS = 8;
// Minimum space between two amplitude columns
private static final int MIN_SPACE_BETWEEN_TWO_COLUMNS = 4;
private static final int MIN_SPACE_BETWEEN_TWO_COLUMNS_TABLET = 5;
enum Result {
PASS,
FAIL,
UNKNOWN
}
private static class Amplitude {
public int maxHeight = -1;
public int zeroCounter = 0;
}
public static Pair<Result, String> analyzeImage(String imgFile) {
final String FN_TAG = "AudioLoopbackImageAnalyzer.analyzeImage";
BufferedImage img = null;
try {
final File f = new File(imgFile);
img = ImageIO.read(f);
} catch (final IOException e) {
CLog.e(e);
throw new RuntimeException("Error loading image file '" + imgFile + "'");
}
final int width = img.getWidth();
final int height = img.getHeight();
CLog.i("image width=" + width + ", height=" + height);
// Compute thresholds and min/max values based on image witdh, height
final float waveMax;
final int[] targetColors;
final int amplitudeCenterMaxDiff;
final float maxDuration;
final int minNrOfZeroesBetweenAmplitudes;
final int horizontalStart; //ignore anything left of this bound
int horizontalThreshold = 10;
if (width >= TABLET_SCREEN_MIN_WIDTH && height >= TABLET_SCREEN_MIN_HEIGHT) {
CLog.i("Apply TABLET config values");
waveMax = EXPERIMENTAL_WAVE_MAX_TABLET;
amplitudeCenterMaxDiff = 40;
maxDuration = 5 * SECTION_WIDTH_IN_PERCENT;
targetColors = TARGET_COLORS_TABLET;
horizontalStart = Math.round(1.7f * SECTION_WIDTH_IN_PERCENT * width / 100.0f);
horizontalThreshold = 40;
minNrOfZeroesBetweenAmplitudes = MIN_SPACE_BETWEEN_TWO_COLUMNS_TABLET;
} else {
waveMax = EXPERIMENTAL_WAVE_MAX_PHONE;
amplitudeCenterMaxDiff = 20;
maxDuration = 2.5f * SECTION_WIDTH_IN_PERCENT;
targetColors = TARGET_COLORS_PHONE;
horizontalStart = Math.round(1 * SECTION_WIDTH_IN_PERCENT * width / 100.0f);
minNrOfZeroesBetweenAmplitudes = MIN_SPACE_BETWEEN_TWO_COLUMNS;
}
// Amplitude
// Max height should be about 80% of wave max.
// Min height should be about 40% of wave max.
final float AMPLITUDE_MAX_VALUE = waveMax * 0.8f;
final float AMPLITUDE_MIN_VALUE = waveMax * 0.4f;
final int[] vertical = new int[height];
final int[] horizontal = new int[width];
projectPixelsToXAxis(img, targetColors, horizontal, width, height);
filter(horizontal, horizontalThreshold);
final Pair<Integer, Integer> durationBounds = getBounds(horizontal, horizontalStart, -1);
if (!boundsWithinRange(durationBounds, 0, width)) {
final String fmt = "%1$s Upper/Lower bound along horizontal axis not found";
final String err = String.format(fmt, FN_TAG);
CLog.w(err);
return new Pair<Result, String>(Result.FAIL, err);
}
projectPixelsToYAxis(img, targetColors, vertical, height, durationBounds);
filter(vertical, VERTICAL_THRESHOLD);
final Pair<Integer, Integer> amplitudeBounds = getBounds(vertical, -1, -1);
if (!boundsWithinRange(durationBounds, 0, height)) {
final String fmt = "%1$s: Upper/Lower bound along vertical axis not found";
final String err = String.format(fmt, FN_TAG);
CLog.w(err);
return new Pair<Result, String>(Result.FAIL, err);
}
final int durationLeft = durationBounds.first.intValue();
final int durationRight = durationBounds.second.intValue();
final int amplitudeTop = amplitudeBounds.first.intValue();
final int amplitudeBottom = amplitudeBounds.second.intValue();
final float amplitude = (amplitudeBottom - amplitudeTop) * 100.0f / height;
final float duration = (durationRight - durationLeft) * 100.0f / width;
CLog.i("AudioLoopbackImageAnalyzer: Amplitude=" + amplitude + ", Duration=" + duration);
Pair<Result, String> amplResult =
analyzeAmplitude(
vertical,
amplitude,
amplitudeTop,
amplitudeBottom,
AMPLITUDE_MIN_VALUE,
AMPLITUDE_MAX_VALUE,
amplitudeCenterMaxDiff);
if (amplResult.first != Result.PASS) {
return amplResult;
}
amplResult =
analyzeDuration(
horizontal,
duration,
durationLeft,
durationRight,
DURATION_MIN,
maxDuration,
MIN_NUMBER_OF_COLUMNS,
minNrOfZeroesBetweenAmplitudes);
if (amplResult.first != Result.PASS) {
return amplResult;
}
return new Pair<Result, String>(Result.PASS, "");
}
/**
* Function to analyze the waveforms duration (how wide it stretches along x-axis) and to make
* sure the waveform degrades nicely, i.e. the amplitude columns becomes smaller and smaller
* over time.
*
* @param horizontal - int array with waveforms amplitude values
* @param duration - calculated length of duration in percent of screen width
* @param durationLeft - index for "horizontal" where waveform starts
* @param durationRight - index for "horizontal" where waveform ends
* @param durationMin - if duration is below this value, return FAIL and failure reason
* @param durationMax - if duration exceed this value, return FAIL and failure reason
* @param minNumberOfAmplitudes - min number of amplitudes (columns) in waveform to pass test
* @param minNrOfZeroesBetweenAmplitudes - min number of required zeroes between amplitudes
* @return - returns result status and failure reason, if any
*/
private static Pair<Result, String> analyzeDuration(
int[] horizontal,
float duration,
int durationLeft,
int durationRight,
final float durationMin,
final float durationMax,
final int minNumberOfAmplitudes,
final int minNrOfZeroesBetweenAmplitudes) {
// This is the tricky one; basically, there should be "columns" that starts
// at "durationLeft", with the tallest column to the left and then column
// height will drop until it fades completely after "durationRight".
final String FN_TAG = "AudioLoopbackImageAnalyzer.analyzeDuration";
if (duration < durationMin || duration > durationMax) {
final String fmt = "%1$s: Duration outside range, value=%2$f, range=(%3$f,%4$f)";
return handleError(fmt, FN_TAG, duration, durationMin, durationMax);
}
final ArrayList<Amplitude> amplitudes = new ArrayList<Amplitude>();
Amplitude currentAmplitude = new Amplitude();
amplitudes.add(currentAmplitude);
int zeroCounter = 0;
// Create amplitude objects that track largest amplitude within a "group" in the array.
// Example array:
// [ 202, 530, 420, 12, 0, 0, 0, 0, 0, 0, 0, 236, 423, 262, 0, 0, 0, 0, 0, 0, 0, 0 ]
// We would get two amplitude objects with amplitude 530 and 423. Each amplitude object
// will also get the number of zeroes to the next amplitude, i.e. 7 and 8 respectively.
for (int i = durationLeft; i < durationRight; i++) {
final int v = horizontal[i];
if (v == 0) {
// Count how many consecutive zeroes we have
zeroCounter++;
continue;
}
CLog.i("index=" + i + ", v=" + v);
if (zeroCounter >= minNrOfZeroesBetweenAmplitudes) {
// Found a new amplitude; update old amplitude
// with the "gap" count - i.e. nr of zeroes between the amplitudes
if (currentAmplitude != null) {
currentAmplitude.zeroCounter = zeroCounter;
}
// Create new Amplitude object
currentAmplitude = new Amplitude();
amplitudes.add(currentAmplitude);
}
// Reset counter
zeroCounter = 0;
if (currentAmplitude != null && v > currentAmplitude.maxHeight) {
currentAmplitude.maxHeight = v;
}
}
StringBuilder sb = new StringBuilder(128);
int counter = 0;
for (final Amplitude a : amplitudes) {
CLog.i(
sb.append("Amplitude=")
.append(counter)
.append(", MaxHeight=")
.append(a.maxHeight)
.append(", ZeroesToNextColumn=")
.append(a.zeroCounter)
.toString());
counter++;
sb.setLength(0);
}
if (amplitudes.size() < minNumberOfAmplitudes) {
final String fmt = "%1$s: Not enough amplitude columns, value=%2$d";
return handleError(fmt, FN_TAG, amplitudes.size());
}
int currentColumnHeight = -1;
int oldColumnHeight = -1;
for (int i = 0; i < amplitudes.size(); i++) {
if (i == 0) {
oldColumnHeight = amplitudes.get(i).maxHeight;
continue;
}
currentColumnHeight = amplitudes.get(i).maxHeight;
if (oldColumnHeight > currentColumnHeight) {
// We want at least a good number of columns that declines nicely.
// After MIN_NUMBER_OF_DECREASING_COLUMNS, we don't really care that much
if (i < MIN_NUMBER_OF_DECREASING_COLUMNS
&& currentColumnHeight < (oldColumnHeight * MAX_ALLOWED_COLUMN_DECREASE)) {
final String fmt =
"%1$s: Amplitude column heights declined too much, "
+ "old=%2$d, new=%3$d, column=%4$d";
return handleError(fmt, FN_TAG, oldColumnHeight, currentColumnHeight, i);
}
oldColumnHeight = currentColumnHeight;
} else if (oldColumnHeight == currentColumnHeight) {
if (i < MIN_NUMBER_OF_DECREASING_COLUMNS) {
final String fmt =
"%1$s: Amplitude column heights are same, "
+ "old=%2$d, new=%3$d, column=%4$d";
return handleError(fmt, FN_TAG, oldColumnHeight, currentColumnHeight, i);
}
} else {
final String fmt =
"%1$s: Amplitude column heights don't decline, "
+ "old=%2$d, new=%3$d, column=%4$d";
return handleError(fmt, FN_TAG, oldColumnHeight, currentColumnHeight, i);
}
}
return new Pair<Result, String>(Result.PASS, "");
}
/**
* Function to analyze the waveforms duration (how wide it stretches along x-axis) and to make
* sure the waveform degrades nicely, i.e. the amplitude columns becomes smaller and smaller
* over time.
*
* @param vertical - integer array with waveforms amplitude accumulated values
* @param amplitude - calculated height of amplitude in percent of screen height
* @param amplitudeTop - index in "vertical" array where waveform starts
* @param amplitudeBottom - index in "vertical" array where waveform ends
* @param amplitudeMin - if amplitude is below this value, return FAIL and failure reason
* @param amplitudeMax - if amplitude exceed this value, return FAIL and failure reason
* @param amplitudeCenterDiffThreshold - threshold to check that waveform is centered
* @return - returns result status and failure reason, if any
*/
private static Pair<Result, String> analyzeAmplitude(
int[] vertical,
float amplitude,
int amplitudeTop,
int amplitudeBottom,
final float amplitudeMin,
final float amplitudeMax,
final int amplitudeCenterDiffThreshold) {
final String FN_TAG = "AudioLoopbackImageAnalyzer.analyzeAmplitude";
if (amplitude < amplitudeMin || amplitude > amplitudeMax) {
final String fmt = "%1$s: Amplitude outside range, value=%2$f, range=(%3$f,%4$f)";
final String err = String.format(fmt, FN_TAG, amplitude, amplitudeMin, amplitudeMax);
CLog.w(err);
return new Pair<Result, String>(Result.FAIL, err);
}
// Are the amplitude top/bottom centered around the centerline?
final int amplitudeCenter = getAmplitudeCenter(vertical, amplitudeTop, amplitudeBottom);
final int topDiff = amplitudeCenter - amplitudeTop;
final int bottomDiff = amplitudeBottom - amplitudeCenter;
final int diff = Math.abs(topDiff - bottomDiff);
if (diff < amplitudeCenterDiffThreshold) {
return new Pair<Result, String>(Result.PASS, "");
}
final String fmt =
"%1$s: Amplitude not centered topDiff=%2$d, bottomDiff=%3$d, "
+ "center=%4$d, diff=%5$d";
final String err = String.format(fmt, FN_TAG, topDiff, bottomDiff, amplitudeCenter, diff);
CLog.w(err);
return new Pair<Result, String>(Result.FAIL, err);
}
private static int getAmplitudeCenter(int[] vertical, int amplitudeTop, int amplitudeBottom) {
int max = -1;
int center = -1;
for (int i = amplitudeTop; i < amplitudeBottom; i++) {
if (vertical[i] > max) {
max = vertical[i];
center = i;
}
}
return center;
}
private static void projectPixelsToXAxis(
BufferedImage img,
final int[] targetColors,
int[] horizontal,
final int width,
final int height) {
// "Flatten image" by projecting target colors horizontally,
// counting number of found pixels in each column
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
final int color = img.getRGB(x, y);
for (final int targetColor : targetColors) {
if (color == targetColor) {
horizontal[x]++;
break;
}
}
}
}
}
private static void projectPixelsToYAxis(
BufferedImage img,
final int[] targetColors,
int[] vertical,
int height,
Pair<Integer, Integer> horizontalMinMax) {
final int min = horizontalMinMax.first.intValue();
final int max = horizontalMinMax.second.intValue();
// "Flatten image" by projecting target colors (between min/max) vertically,
// counting number of found pixels in each row
// Pass over y-axis, restricted to horizontalMin, horizontalMax
for (int y = 0; y < height; y++) {
for (int x = min; x <= max; x++) {
final int color = img.getRGB(x, y);
for (final int targetColor : targetColors) {
if (color == targetColor) {
vertical[y]++;
break;
}
}
}
}
}
private static Pair<Integer, Integer> getBounds(int[] array, int lowerBound, int upperBound) {
// Determine min, max
if (lowerBound == -1) {
lowerBound = 0;
}
if (upperBound == -1) {
upperBound = array.length - 1;
}
int min = -1;
for (int i = lowerBound; i <= upperBound; i++) {
if (array[i] > 0) {
min = i;
break;
}
}
int max = -1;
for (int i = upperBound; i >= lowerBound; i--) {
if (array[i] > 0) {
max = i;
break;
}
}
return new Pair<Integer, Integer>(Integer.valueOf(min), Integer.valueOf(max));
}
private static void filter(int[] array, final int threshold) {
// Filter horizontal array; set all values < threshold to 0
for (int i = 0; i < array.length; i++) {
final int v = array[i];
if (v != 0 && v <= threshold) {
array[i] = 0;
}
}
}
private static boolean boundsWithinRange(Pair<Integer, Integer> bounds, int low, int high) {
return low <= bounds.first.intValue()
&& bounds.first.intValue() < high
&& low <= bounds.second.intValue()
&& bounds.second.intValue() < high;
}
private static Pair<Result, String> handleError(String fmt, String tag, int arg1) {
final String err = String.format(fmt, tag, arg1);
CLog.w(err);
return new Pair<Result, String>(Result.FAIL, err);
}
private static Pair<Result, String> handleError(
String fmt, String tag, int arg1, int arg2, int arg3) {
final String err = String.format(fmt, tag, arg1, arg2, arg3);
CLog.w(err);
return new Pair<Result, String>(Result.FAIL, err);
}
private static Pair<Result, String> handleError(
String fmt, String tag, float arg1, float arg2, float arg3) {
final String err = String.format(fmt, tag, arg1, arg2, arg3);
CLog.w(err);
return new Pair<Result, String>(Result.FAIL, err);
}
}