1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.media.tests;
17 
18 import com.android.tradefed.log.LogUtil.CLog;
19 import com.android.tradefed.util.Pair;
20 
21 import java.awt.image.BufferedImage;
22 import java.io.File;
23 import java.io.IOException;
24 import java.util.ArrayList;
25 
26 import javax.imageio.ImageIO;
27 
28 /**
29  * Class that analyzes a screenshot captured from AudioLoopback test. There is a wave form in the
30  * screenshot that has specific colors (TARGET_COLOR). This class extracts those colors and analyzes
31  * wave amplitude, duration and form and make a decision if it's a legitimate wave form or not.
32  */
33 public class AudioLoopbackImageAnalyzer {
34 
35     // General
36     private static final int VERTICAL_THRESHOLD = 0;
37     private static final int PRIMARY_WAVE_COLOR = 0xFF1E4A99;
38     private static final int SECONDARY_WAVE_COLOR = 0xFF1D4998;
39     private static final int[] TARGET_COLORS_TABLET =
40             new int[] {PRIMARY_WAVE_COLOR, SECONDARY_WAVE_COLOR};
41     private static final int[] TARGET_COLORS_PHONE =
42             new int[] {PRIMARY_WAVE_COLOR, SECONDARY_WAVE_COLOR};
43 
44     private static final float EXPERIMENTAL_WAVE_MAX_TABLET = 69.0f; // In percent of image height
45     private static final float EXPERIMENTAL_WAVE_MAX_PHONE = 32.0f; // In percent of image height
46 
47     // Image
48     private static final int TABLET_SCREEN_MIN_WIDTH = 1700;
49     private static final int TABLET_SCREEN_MIN_HEIGHT = 2300;
50 
51     // Duration parameters
52     // Max duration should not span more than 2 of the 11 sections in the graph
53     // Min duration should not be less than 1/4 of a section
54     private static final float SECTION_WIDTH_IN_PERCENT = 100 * 1 / 11; // In percent of image width
55     private static final float DURATION_MIN = SECTION_WIDTH_IN_PERCENT / 4;
56 
57     // Amplitude
58     // Required numbers of column for a response
59     private static final int MIN_NUMBER_OF_COLUMNS = 4;
60     // The difference between two amplitude columns should not be more than this
61     private static final float MAX_ALLOWED_COLUMN_DECREASE = 0.42f;
62     // Only check MAX_ALLOWED_COLUMN_DECREASE up to this number
63     private static final float MIN_NUMBER_OF_DECREASING_COLUMNS = 8;
64     // Minimum space between two amplitude columns
65     private static final int MIN_SPACE_BETWEEN_TWO_COLUMNS = 4;
66     private static final int MIN_SPACE_BETWEEN_TWO_COLUMNS_TABLET = 5;
67 
68     enum Result {
69         PASS,
70         FAIL,
71         UNKNOWN
72     }
73 
74     private static class Amplitude {
75         public int maxHeight = -1;
76         public int zeroCounter = 0;
77     }
78 
analyzeImage(String imgFile)79     public static Pair<Result, String> analyzeImage(String imgFile) {
80         final String FN_TAG = "AudioLoopbackImageAnalyzer.analyzeImage";
81 
82         BufferedImage img = null;
83         try {
84             final File f = new File(imgFile);
85             img = ImageIO.read(f);
86         } catch (final IOException e) {
87             CLog.e(e);
88             throw new RuntimeException("Error loading image file '" + imgFile + "'");
89         }
90 
91         final int width = img.getWidth();
92         final int height = img.getHeight();
93 
94         CLog.i("image width=" + width + ", height=" + height);
95 
96         // Compute thresholds and min/max values based on image witdh, height
97         final float waveMax;
98         final int[] targetColors;
99         final int amplitudeCenterMaxDiff;
100         final float maxDuration;
101         final int minNrOfZeroesBetweenAmplitudes;
102         final int horizontalStart; //ignore anything left of this bound
103         int horizontalThreshold = 10;
104 
105         if (width >= TABLET_SCREEN_MIN_WIDTH && height >= TABLET_SCREEN_MIN_HEIGHT) {
106             CLog.i("Apply TABLET config values");
107             waveMax = EXPERIMENTAL_WAVE_MAX_TABLET;
108             amplitudeCenterMaxDiff = 40;
109             maxDuration = 5 * SECTION_WIDTH_IN_PERCENT;
110             targetColors = TARGET_COLORS_TABLET;
111             horizontalStart = Math.round(1.7f * SECTION_WIDTH_IN_PERCENT * width / 100.0f);
112             horizontalThreshold = 40;
113             minNrOfZeroesBetweenAmplitudes = MIN_SPACE_BETWEEN_TWO_COLUMNS_TABLET;
114         } else {
115             waveMax = EXPERIMENTAL_WAVE_MAX_PHONE;
116             amplitudeCenterMaxDiff = 20;
117             maxDuration = 2.5f * SECTION_WIDTH_IN_PERCENT;
118             targetColors = TARGET_COLORS_PHONE;
119             horizontalStart = 0;
120             minNrOfZeroesBetweenAmplitudes = MIN_SPACE_BETWEEN_TWO_COLUMNS;
121         }
122 
123         // Amplitude
124         // Max height should be about 80% of wave max.
125         // Min height should be about 40% of wave max.
126         final float AMPLITUDE_MAX_VALUE = waveMax * 0.8f;
127         final float AMPLITUDE_MIN_VALUE = waveMax * 0.4f;
128 
129         final int[] vertical = new int[height];
130         final int[] horizontal = new int[width];
131 
132         projectPixelsToXAxis(img, targetColors, horizontal, width, height);
133         filter(horizontal, horizontalThreshold);
134         final Pair<Integer, Integer> durationBounds = getBounds(horizontal, horizontalStart, -1);
135         if (!boundsWithinRange(durationBounds, 0, width)) {
136             final String fmt = "%1$s Upper/Lower bound along horizontal axis not found";
137             final String err = String.format(fmt, FN_TAG);
138             CLog.w(err);
139             return new Pair<Result, String>(Result.FAIL, err);
140         }
141 
142         projectPixelsToYAxis(img, targetColors, vertical, height, durationBounds);
143         filter(vertical, VERTICAL_THRESHOLD);
144         final Pair<Integer, Integer> amplitudeBounds = getBounds(vertical, -1, -1);
145         if (!boundsWithinRange(durationBounds, 0, height)) {
146             final String fmt = "%1$s: Upper/Lower bound along vertical axis not found";
147             final String err = String.format(fmt, FN_TAG);
148             CLog.w(err);
149             return new Pair<Result, String>(Result.FAIL, err);
150         }
151 
152         final int durationLeft = durationBounds.first.intValue();
153         final int durationRight = durationBounds.second.intValue();
154         final int amplitudeTop = amplitudeBounds.first.intValue();
155         final int amplitudeBottom = amplitudeBounds.second.intValue();
156 
157         final float amplitude = (amplitudeBottom - amplitudeTop) * 100.0f / height;
158         final float duration = (durationRight - durationLeft) * 100.0f / width;
159 
160         CLog.i("AudioLoopbackImageAnalyzer: Amplitude=" + amplitude + ", Duration=" + duration);
161 
162         Pair<Result, String> amplResult =
163                 analyzeAmplitude(
164                         vertical,
165                         amplitude,
166                         amplitudeTop,
167                         amplitudeBottom,
168                         AMPLITUDE_MIN_VALUE,
169                         AMPLITUDE_MAX_VALUE,
170                         amplitudeCenterMaxDiff);
171         if (amplResult.first != Result.PASS) {
172             return amplResult;
173         }
174 
175         amplResult =
176                 analyzeDuration(
177                         horizontal,
178                         duration,
179                         durationLeft,
180                         durationRight,
181                         DURATION_MIN,
182                         maxDuration,
183                         MIN_NUMBER_OF_COLUMNS,
184                         minNrOfZeroesBetweenAmplitudes);
185         if (amplResult.first != Result.PASS) {
186             return amplResult;
187         }
188 
189         return new Pair<Result, String>(Result.PASS, "");
190     }
191 
192     /**
193      * Function to analyze the waveforms duration (how wide it stretches along x-axis) and to make
194      * sure the waveform degrades nicely, i.e. the amplitude columns becomes smaller and smaller
195      * over time.
196      *
197      * @param horizontal - int array with waveforms amplitude values
198      * @param duration - calculated length of duration in percent of screen width
199      * @param durationLeft - index for "horizontal" where waveform starts
200      * @param durationRight - index for "horizontal" where waveform ends
201      * @param durationMin - if duration is below this value, return FAIL and failure reason
202      * @param durationMax - if duration exceed this value, return FAIL and failure reason
203      * @param minNumberOfAmplitudes - min number of amplitudes (columns) in waveform to pass test
204      * @param minNrOfZeroesBetweenAmplitudes - min number of required zeroes between amplitudes
205      * @return - returns result status and failure reason, if any
206      */
analyzeDuration( int[] horizontal, float duration, int durationLeft, int durationRight, final float durationMin, final float durationMax, final int minNumberOfAmplitudes, final int minNrOfZeroesBetweenAmplitudes)207     private static Pair<Result, String> analyzeDuration(
208             int[] horizontal,
209             float duration,
210             int durationLeft,
211             int durationRight,
212             final float durationMin,
213             final float durationMax,
214             final int minNumberOfAmplitudes,
215             final int minNrOfZeroesBetweenAmplitudes) {
216         // This is the tricky one; basically, there should be "columns" that starts
217         // at "durationLeft", with the tallest column to the left and then column
218         // height will drop until it fades completely after "durationRight".
219         final String FN_TAG = "AudioLoopbackImageAnalyzer.analyzeDuration";
220 
221         if (duration < durationMin || duration > durationMax) {
222             final String fmt = "%1$s: Duration outside range, value=%2$f, range=(%3$f,%4$f)";
223             return handleError(fmt, FN_TAG, duration, durationMin, durationMax);
224         }
225 
226         final ArrayList<Amplitude> amplitudes = new ArrayList<Amplitude>();
227         Amplitude currentAmplitude = new Amplitude();
228         amplitudes.add(currentAmplitude);
229         int zeroCounter = 0;
230 
231         // Create amplitude objects that track largest amplitude within a "group" in the array.
232         // Example array:
233         //      [ 202, 530, 420, 12, 0, 0, 0, 0, 0, 0, 0, 236, 423, 262, 0, 0, 0, 0, 0, 0, 0, 0 ]
234         // We would get two amplitude objects with amplitude 530 and 423. Each amplitude object
235         // will also get the number of zeroes to the next amplitude, i.e. 7 and 8 respectively.
236         for (int i = durationLeft; i < durationRight; i++) {
237             final int v = horizontal[i];
238             if (v == 0) {
239                 // Count how many consecutive zeroes we have
240                 zeroCounter++;
241                 continue;
242             }
243 
244             CLog.i("index=" + i + ", v=" + v);
245 
246             if (zeroCounter >= minNrOfZeroesBetweenAmplitudes) {
247                 // Found a new amplitude; update old amplitude
248                 // with the "gap" count - i.e. nr of zeroes between the amplitudes
249                 if (currentAmplitude != null) {
250                     currentAmplitude.zeroCounter = zeroCounter;
251                 }
252 
253                 // Create new Amplitude object
254                 currentAmplitude = new Amplitude();
255                 amplitudes.add(currentAmplitude);
256             }
257 
258             // Reset counter
259             zeroCounter = 0;
260 
261             if (currentAmplitude != null && v > currentAmplitude.maxHeight) {
262                 currentAmplitude.maxHeight = v;
263             }
264         }
265 
266         StringBuilder sb = new StringBuilder(128);
267         int counter = 0;
268         for (final Amplitude a : amplitudes) {
269             CLog.i(
270                     sb.append("Amplitude=")
271                             .append(counter)
272                             .append(", MaxHeight=")
273                             .append(a.maxHeight)
274                             .append(", ZeroesToNextColumn=")
275                             .append(a.zeroCounter)
276                             .toString());
277             counter++;
278             sb.setLength(0);
279         }
280 
281         if (amplitudes.size() < minNumberOfAmplitudes) {
282             final String fmt = "%1$s: Not enough amplitude columns, value=%2$d";
283             return handleError(fmt, FN_TAG, amplitudes.size());
284         }
285 
286         int currentColumnHeight = -1;
287         int oldColumnHeight = -1;
288         for (int i = 0; i < amplitudes.size(); i++) {
289             if (i == 0) {
290                 oldColumnHeight = amplitudes.get(i).maxHeight;
291                 continue;
292             }
293 
294             currentColumnHeight = amplitudes.get(i).maxHeight;
295             if (oldColumnHeight > currentColumnHeight) {
296                 // We want at least a good number of columns that declines nicely.
297                 // After MIN_NUMBER_OF_DECREASING_COLUMNS, we don't really care that much
298                 if (i < MIN_NUMBER_OF_DECREASING_COLUMNS
299                         && currentColumnHeight < (oldColumnHeight * MAX_ALLOWED_COLUMN_DECREASE)) {
300                     final String fmt =
301                             "%1$s: Amplitude column heights declined too much, "
302                                     + "old=%2$d, new=%3$d, column=%4$d";
303                     return handleError(fmt, FN_TAG, oldColumnHeight, currentColumnHeight, i);
304                 }
305                 oldColumnHeight = currentColumnHeight;
306             } else if (oldColumnHeight == currentColumnHeight) {
307                 if (i < MIN_NUMBER_OF_DECREASING_COLUMNS) {
308                     final String fmt =
309                             "%1$s: Amplitude column heights are same, "
310                                     + "old=%2$d, new=%3$d, column=%4$d";
311                     return handleError(fmt, FN_TAG, oldColumnHeight, currentColumnHeight, i);
312                 }
313             } else {
314                 final String fmt =
315                         "%1$s: Amplitude column heights don't decline, "
316                                 + "old=%2$d, new=%3$d, column=%4$d";
317                 return handleError(fmt, FN_TAG, oldColumnHeight, currentColumnHeight, i);
318             }
319         }
320 
321         return new Pair<Result, String>(Result.PASS, "");
322     }
323 
324     /**
325      * Function to analyze the waveforms duration (how wide it stretches along x-axis) and to make
326      * sure the waveform degrades nicely, i.e. the amplitude columns becomes smaller and smaller
327      * over time.
328      *
329      * @param vertical - integer array with waveforms amplitude accumulated values
330      * @param amplitude - calculated height of amplitude in percent of screen height
331      * @param amplitudeTop - index in "vertical" array where waveform starts
332      * @param amplitudeBottom - index in "vertical" array where waveform ends
333      * @param amplitudeMin - if amplitude is below this value, return FAIL and failure reason
334      * @param amplitudeMax - if amplitude exceed this value, return FAIL and failure reason
335      * @param amplitudeCenterDiffThreshold - threshold to check that waveform is centered
336      * @return - returns result status and failure reason, if any
337      */
analyzeAmplitude( int[] vertical, float amplitude, int amplitudeTop, int amplitudeBottom, final float amplitudeMin, final float amplitudeMax, final int amplitudeCenterDiffThreshold)338     private static Pair<Result, String> analyzeAmplitude(
339             int[] vertical,
340             float amplitude,
341             int amplitudeTop,
342             int amplitudeBottom,
343             final float amplitudeMin,
344             final float amplitudeMax,
345             final int amplitudeCenterDiffThreshold) {
346         final String FN_TAG = "AudioLoopbackImageAnalyzer.analyzeAmplitude";
347 
348         if (amplitude < amplitudeMin || amplitude > amplitudeMax) {
349             final String fmt = "%1$s: Amplitude outside range, value=%2$f, range=(%3$f,%4$f)";
350             final String err = String.format(fmt, FN_TAG, amplitude, amplitudeMin, amplitudeMax);
351             CLog.w(err);
352             return new Pair<Result, String>(Result.FAIL, err);
353         }
354 
355         // Are the amplitude top/bottom centered around the centerline?
356         final int amplitudeCenter = getAmplitudeCenter(vertical, amplitudeTop, amplitudeBottom);
357         final int topDiff = amplitudeCenter - amplitudeTop;
358         final int bottomDiff = amplitudeBottom - amplitudeCenter;
359         final int diff = Math.abs(topDiff - bottomDiff);
360 
361         if (diff < amplitudeCenterDiffThreshold) {
362             return new Pair<Result, String>(Result.PASS, "");
363         }
364 
365         final String fmt =
366                 "%1$s: Amplitude not centered topDiff=%2$d, bottomDiff=%3$d, "
367                         + "center=%4$d, diff=%5$d";
368         final String err = String.format(fmt, FN_TAG, topDiff, bottomDiff, amplitudeCenter, diff);
369         CLog.w(err);
370         return new Pair<Result, String>(Result.FAIL, err);
371     }
372 
getAmplitudeCenter(int[] vertical, int amplitudeTop, int amplitudeBottom)373     private static int getAmplitudeCenter(int[] vertical, int amplitudeTop, int amplitudeBottom) {
374         int max = -1;
375         int center = -1;
376         for (int i = amplitudeTop; i < amplitudeBottom; i++) {
377             if (vertical[i] > max) {
378                 max = vertical[i];
379                 center = i;
380             }
381         }
382 
383         return center;
384     }
385 
projectPixelsToXAxis( BufferedImage img, final int[] targetColors, int[] horizontal, final int width, final int height)386     private static void projectPixelsToXAxis(
387             BufferedImage img,
388             final int[] targetColors,
389             int[] horizontal,
390             final int width,
391             final int height) {
392         // "Flatten image" by projecting target colors horizontally,
393         // counting number of found pixels in each column
394         for (int y = 0; y < height; y++) {
395             for (int x = 0; x < width; x++) {
396                 final int color = img.getRGB(x, y);
397                 for (final int targetColor : targetColors) {
398                     if (color == targetColor) {
399                         horizontal[x]++;
400                         break;
401                     }
402                 }
403             }
404         }
405     }
406 
projectPixelsToYAxis( BufferedImage img, final int[] targetColors, int[] vertical, int height, Pair<Integer, Integer> horizontalMinMax)407     private static void projectPixelsToYAxis(
408             BufferedImage img,
409             final int[] targetColors,
410             int[] vertical,
411             int height,
412             Pair<Integer, Integer> horizontalMinMax) {
413 
414         final int min = horizontalMinMax.first.intValue();
415         final int max = horizontalMinMax.second.intValue();
416 
417         // "Flatten image" by projecting target colors (between min/max) vertically,
418         // counting number of found pixels in each row
419 
420         // Pass over y-axis, restricted to horizontalMin, horizontalMax
421         for (int y = 0; y < height; y++) {
422             for (int x = min; x <= max; x++) {
423                 final int color = img.getRGB(x, y);
424                 for (final int targetColor : targetColors) {
425                     if (color == targetColor) {
426                         vertical[y]++;
427                         break;
428                     }
429                 }
430             }
431         }
432     }
433 
getBounds(int[] array, int lowerBound, int upperBound)434     private static Pair<Integer, Integer> getBounds(int[] array, int lowerBound, int upperBound) {
435         // Determine min, max
436         if (lowerBound == -1) {
437             lowerBound = 0;
438         }
439 
440         if (upperBound == -1) {
441             upperBound = array.length - 1;
442         }
443 
444         int min = -1;
445         for (int i = lowerBound; i <= upperBound; i++) {
446             if (array[i] > 0) {
447                 min = i;
448                 break;
449             }
450         }
451 
452         int max = -1;
453         for (int i = upperBound; i >= lowerBound; i--) {
454             if (array[i] > 0) {
455                 max = i;
456                 break;
457             }
458         }
459 
460         return new Pair<Integer, Integer>(Integer.valueOf(min), Integer.valueOf(max));
461     }
462 
filter(int[] array, final int threshold)463     private static void filter(int[] array, final int threshold) {
464         // Filter horizontal array; set all values < threshold to 0
465         for (int i = 0; i < array.length; i++) {
466             final int v = array[i];
467             if (v != 0 && v <= threshold) {
468                 array[i] = 0;
469             }
470         }
471     }
472 
boundsWithinRange(Pair<Integer, Integer> bounds, int low, int high)473     private static boolean boundsWithinRange(Pair<Integer, Integer> bounds, int low, int high) {
474         return low <= bounds.first.intValue()
475                 && bounds.first.intValue() < high
476                 && low <= bounds.second.intValue()
477                 && bounds.second.intValue() < high;
478     }
479 
handleError(String fmt, String tag, int arg1)480     private static Pair<Result, String> handleError(String fmt, String tag, int arg1) {
481         final String err = String.format(fmt, tag, arg1);
482         CLog.w(err);
483         return new Pair<Result, String>(Result.FAIL, err);
484     }
485 
handleError( String fmt, String tag, int arg1, int arg2, int arg3)486     private static Pair<Result, String> handleError(
487             String fmt, String tag, int arg1, int arg2, int arg3) {
488         final String err = String.format(fmt, tag, arg1, arg2, arg3);
489         CLog.w(err);
490         return new Pair<Result, String>(Result.FAIL, err);
491     }
492 
handleError( String fmt, String tag, float arg1, float arg2, float arg3)493     private static Pair<Result, String> handleError(
494             String fmt, String tag, float arg1, float arg2, float arg3) {
495         final String err = String.format(fmt, tag, arg1, arg2, arg3);
496         CLog.w(err);
497         return new Pair<Result, String>(Result.FAIL, err);
498     }
499 }
500