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 HORIZONTAL_THRESHOLD = 10; 37 private static final int VERTICAL_THRESHOLD = 0; 38 private static final int PRIMARY_WAVE_COLOR = 0xFF1E4A99; 39 private static final int SECONDARY_WAVE_COLOR = 0xFF1D4998; 40 private static final int[] TARGET_COLORS_TABLET = 41 new int[] {PRIMARY_WAVE_COLOR, SECONDARY_WAVE_COLOR}; 42 private static final int[] TARGET_COLORS_PHONE = new int[] {PRIMARY_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.50f; 62 // Only check MAX_ALLOWED_COLUMN_DECREASE up to this number 63 private static final float MIN_NUMBER_OF_DECREASING_COLUMNS = 8; 64 65 enum Result { 66 PASS, 67 FAIL, 68 UNKNOWN 69 } 70 71 private static class Amplitude { 72 public int maxHeight = -1; 73 public int zeroCounter = 0; 74 } 75 76 public static Pair<Result, String> analyzeImage(String imgFile) { 77 final String FN_TAG = "AudioLoopbackImageAnalyzer.analyzeImage"; 78 79 BufferedImage img = null; 80 try { 81 final File f = new File(imgFile); 82 img = ImageIO.read(f); 83 } catch (final IOException e) { 84 CLog.e(e); 85 throw new RuntimeException("Error loading image file '" + imgFile + "'"); 86 } 87 88 final int width = img.getWidth(); 89 final int height = img.getHeight(); 90 91 CLog.i("image width=" + width + ", height=" + height); 92 93 // Compute thresholds and min/max values based on image witdh, height 94 final float waveMax; 95 final int[] targetColors; 96 final int amplitudeCenterMaxDiff; 97 final float maxDuration; 98 final int minNrOfZeroesBetweenAmplitudes; 99 100 if (width >= TABLET_SCREEN_MIN_WIDTH && height >= TABLET_SCREEN_MIN_HEIGHT) { 101 CLog.i("Apply TABLET config values"); 102 waveMax = EXPERIMENTAL_WAVE_MAX_TABLET; 103 amplitudeCenterMaxDiff = 40; 104 minNrOfZeroesBetweenAmplitudes = 8; 105 maxDuration = 3 * SECTION_WIDTH_IN_PERCENT; 106 targetColors = TARGET_COLORS_TABLET; 107 } else { 108 waveMax = EXPERIMENTAL_WAVE_MAX_PHONE; 109 amplitudeCenterMaxDiff = 20; 110 minNrOfZeroesBetweenAmplitudes = 5; 111 maxDuration = 2.5f * SECTION_WIDTH_IN_PERCENT; 112 targetColors = TARGET_COLORS_PHONE; 113 } 114 115 // Amplitude 116 // Max height should be about 80% of wave max. 117 // Min height should be about 40% of wave max. 118 final float AMPLITUDE_MAX_VALUE = waveMax * 0.8f; 119 final float AMPLITUDE_MIN_VALUE = waveMax * 0.4f; 120 121 final int[] vertical = new int[height]; 122 final int[] horizontal = new int[width]; 123 124 projectPixelsToXAxis(img, targetColors, horizontal, width, height); 125 filter(horizontal, HORIZONTAL_THRESHOLD); 126 final Pair<Integer, Integer> durationBounds = getBounds(horizontal); 127 if (!boundsWithinRange(durationBounds, 0, width)) { 128 final String fmt = "%1$s Upper/Lower bound along horizontal axis not found"; 129 final String err = String.format(fmt, FN_TAG); 130 CLog.w(err); 131 return new Pair<Result, String>(Result.FAIL, err); 132 } 133 134 projectPixelsToYAxis(img, targetColors, vertical, height, durationBounds); 135 filter(vertical, VERTICAL_THRESHOLD); 136 final Pair<Integer, Integer> amplitudeBounds = getBounds(vertical); 137 if (!boundsWithinRange(durationBounds, 0, height)) { 138 final String fmt = "%1$s: Upper/Lower bound along vertical axis not found"; 139 final String err = String.format(fmt, FN_TAG); 140 CLog.w(err); 141 return new Pair<Result, String>(Result.FAIL, err); 142 } 143 144 final int durationLeft = durationBounds.first.intValue(); 145 final int durationRight = durationBounds.second.intValue(); 146 final int amplitudeTop = amplitudeBounds.first.intValue(); 147 final int amplitudeBottom = amplitudeBounds.second.intValue(); 148 149 final float amplitude = (amplitudeBottom - amplitudeTop) * 100.0f / height; 150 final float duration = (durationRight - durationLeft) * 100.0f / width; 151 152 CLog.i("AudioLoopbackImageAnalyzer: Amplitude=" + amplitude + ", Duration=" + duration); 153 154 Pair<Result, String> amplResult = 155 analyzeAmplitude( 156 vertical, 157 amplitude, 158 amplitudeTop, 159 amplitudeBottom, 160 AMPLITUDE_MIN_VALUE, 161 AMPLITUDE_MAX_VALUE, 162 amplitudeCenterMaxDiff); 163 if (amplResult.first != Result.PASS) { 164 return amplResult; 165 } 166 167 amplResult = 168 analyzeDuration( 169 horizontal, 170 duration, 171 durationLeft, 172 durationRight, 173 DURATION_MIN, 174 maxDuration, 175 MIN_NUMBER_OF_COLUMNS, 176 minNrOfZeroesBetweenAmplitudes); 177 if (amplResult.first != Result.PASS) { 178 return amplResult; 179 } 180 181 return new Pair<Result, String>(Result.PASS, ""); 182 } 183 184 /** 185 * Function to analyze the waveforms duration (how wide it stretches along x-axis) and to make 186 * sure the waveform degrades nicely, i.e. the amplitude columns becomes smaller and smaller 187 * over time. 188 * 189 * @param horizontal - int array with waveforms amplitude values 190 * @param duration - calculated length of duration in percent of screen width 191 * @param durationLeft - index for "horizontal" where waveform starts 192 * @param durationRight - index for "horizontal" where waveform ends 193 * @param durationMin - if duration is below this value, return FAIL and failure reason 194 * @param durationMax - if duration exceed this value, return FAIL and failure reason 195 * @param minNumberOfAmplitudes - min number of amplitudes (columns) in waveform to pass test 196 * @param minNrOfZeroesBetweenAmplitudes - min number of required zeroes between amplitudes 197 * @return - returns result status and failure reason, if any 198 */ 199 private static Pair<Result, String> analyzeDuration( 200 int[] horizontal, 201 float duration, 202 int durationLeft, 203 int durationRight, 204 final float durationMin, 205 final float durationMax, 206 final int minNumberOfAmplitudes, 207 final int minNrOfZeroesBetweenAmplitudes) { 208 // This is the tricky one; basically, there should be "columns" that starts 209 // at "durationLeft", with the tallest column to the left and then column 210 // height will drop until it fades completely after "durationRight". 211 final String FN_TAG = "AudioLoopbackImageAnalyzer.analyzeDuration"; 212 213 if (duration < durationMin || duration > durationMax) { 214 final String fmt = "%1$s: Duration outside range, value=%2$f, range=(%3$f,%4$f)"; 215 return handleError(fmt, FN_TAG, duration, durationMin, durationMax); 216 } 217 218 final ArrayList<Amplitude> amplitudes = new ArrayList<Amplitude>(); 219 Amplitude currentAmplitude = null; 220 int zeroCounter = 0; 221 222 for (int i = durationLeft; i < durationRight; i++) { 223 final int v = horizontal[i]; 224 if (v == 0) { 225 zeroCounter++; 226 } else { 227 CLog.i("index=" + i + ", v=" + v); 228 229 if (zeroCounter > minNrOfZeroesBetweenAmplitudes) { 230 // Found a new amplitude; update old amplitude 231 // with the "gap" count - i.e. nr of zeroes between the amplitudes 232 if (currentAmplitude != null) { 233 currentAmplitude.zeroCounter = zeroCounter; 234 } 235 236 // Create new Amplitude object 237 currentAmplitude = new Amplitude(); 238 amplitudes.add(currentAmplitude); 239 } 240 241 // Reset counter 242 zeroCounter = 0; 243 244 if (currentAmplitude != null && v > currentAmplitude.maxHeight) { 245 currentAmplitude.maxHeight = horizontal[i]; 246 } 247 } 248 } 249 250 StringBuilder sb = new StringBuilder(128); 251 int counter = 0; 252 for (final Amplitude a : amplitudes) { 253 CLog.i( 254 sb.append("Amplitude=") 255 .append(counter) 256 .append(", MaxHeight=") 257 .append(a.maxHeight) 258 .append(", ZeroesToNextColumn=") 259 .append(a.zeroCounter) 260 .toString()); 261 counter++; 262 sb.setLength(0); 263 } 264 265 if (amplitudes.size() < minNumberOfAmplitudes) { 266 final String fmt = "%1$s: Not enough amplitude columns, value=%2$d"; 267 return handleError(fmt, FN_TAG, amplitudes.size()); 268 } 269 270 int currentColumnHeight = -1; 271 int oldColumnHeight = -1; 272 for (int i = 0; i < amplitudes.size(); i++) { 273 if (i == 0) { 274 oldColumnHeight = amplitudes.get(i).maxHeight; 275 continue; 276 } 277 278 currentColumnHeight = amplitudes.get(i).maxHeight; 279 if (oldColumnHeight > currentColumnHeight) { 280 // We want at least a good number of columns that declines nicely. 281 // After MIN_NUMBER_OF_DECREASING_COLUMNS, we don't really care that much 282 if (i < MIN_NUMBER_OF_DECREASING_COLUMNS 283 && currentColumnHeight < (oldColumnHeight * MAX_ALLOWED_COLUMN_DECREASE)) { 284 final String fmt = 285 "%1$s: Amplitude column heights declined too much, " 286 + "old=%2$d, new=%3$d, column=%4$d"; 287 return handleError(fmt, FN_TAG, oldColumnHeight, currentColumnHeight, i); 288 } 289 oldColumnHeight = currentColumnHeight; 290 } else if (oldColumnHeight == currentColumnHeight) { 291 if (i < MIN_NUMBER_OF_DECREASING_COLUMNS) { 292 final String fmt = 293 "%1$s: Amplitude column heights are same, " 294 + "old=%2$d, new=%3$d, column=%4$d"; 295 return handleError(fmt, FN_TAG, oldColumnHeight, currentColumnHeight, i); 296 } 297 } else { 298 final String fmt = 299 "%1$s: Amplitude column heights don't decline, " 300 + "old=%2$d, new=%3$d, column=%4$d"; 301 return handleError(fmt, FN_TAG, oldColumnHeight, currentColumnHeight, i); 302 } 303 } 304 305 return new Pair<Result, String>(Result.PASS, ""); 306 } 307 308 /** 309 * Function to analyze the waveforms duration (how wide it stretches along x-axis) and to make 310 * sure the waveform degrades nicely, i.e. the amplitude columns becomes smaller and smaller 311 * over time. 312 * 313 * @param vertical - integer array with waveforms amplitude accumulated values 314 * @param amplitude - calculated height of amplitude in percent of screen height 315 * @param amplitudeTop - index in "vertical" array where waveform starts 316 * @param amplitudeBottom - index in "vertical" array where waveform ends 317 * @param amplitudeMin - if amplitude is below this value, return FAIL and failure reason 318 * @param amplitudeMax - if amplitude exceed this value, return FAIL and failure reason 319 * @param amplitudeCenterDiffThreshold - threshold to check that waveform is centered 320 * @return - returns result status and failure reason, if any 321 */ 322 private static Pair<Result, String> analyzeAmplitude( 323 int[] vertical, 324 float amplitude, 325 int amplitudeTop, 326 int amplitudeBottom, 327 final float amplitudeMin, 328 final float amplitudeMax, 329 final int amplitudeCenterDiffThreshold) { 330 final String FN_TAG = "AudioLoopbackImageAnalyzer.analyzeAmplitude"; 331 332 if (amplitude < amplitudeMin || amplitude > amplitudeMax) { 333 final String fmt = "%1$s: Amplitude outside range, value=%2$f, range=(%3$f,%4$f)"; 334 final String err = String.format(fmt, FN_TAG, amplitude, amplitudeMin, amplitudeMax); 335 CLog.w(err); 336 return new Pair<Result, String>(Result.FAIL, err); 337 } 338 339 // Are the amplitude top/bottom centered around the centerline? 340 final int amplitudeCenter = getAmplitudeCenter(vertical, amplitudeTop, amplitudeBottom); 341 final int topDiff = amplitudeCenter - amplitudeTop; 342 final int bottomDiff = amplitudeBottom - amplitudeCenter; 343 final int diff = Math.abs(topDiff - bottomDiff); 344 345 if (diff < amplitudeCenterDiffThreshold) { 346 return new Pair<Result, String>(Result.PASS, ""); 347 } 348 349 final String fmt = 350 "%1$s: Amplitude not centered topDiff=%2$d, bottomDiff=%3$d, " 351 + "center=%4$d, diff=%5$d"; 352 final String err = String.format(fmt, FN_TAG, topDiff, bottomDiff, amplitudeCenter, diff); 353 CLog.w(err); 354 return new Pair<Result, String>(Result.FAIL, err); 355 } 356 357 private static int getAmplitudeCenter(int[] vertical, int amplitudeTop, int amplitudeBottom) { 358 int max = -1; 359 int center = -1; 360 for (int i = amplitudeTop; i < amplitudeBottom; i++) { 361 if (vertical[i] > max) { 362 max = vertical[i]; 363 center = i; 364 } 365 } 366 367 return center; 368 } 369 370 private static void projectPixelsToXAxis( 371 BufferedImage img, 372 final int[] targetColors, 373 int[] horizontal, 374 final int width, 375 final int height) { 376 // "Flatten image" by projecting target colors horizontally, 377 // counting number of found pixels in each column 378 for (int y = 0; y < height; y++) { 379 for (int x = 0; x < width; x++) { 380 final int color = img.getRGB(x, y); 381 for (final int targetColor : targetColors) { 382 if (color == targetColor) { 383 horizontal[x]++; 384 break; 385 } 386 } 387 } 388 } 389 } 390 391 private static void projectPixelsToYAxis( 392 BufferedImage img, 393 final int[] targetColors, 394 int[] vertical, 395 int height, 396 Pair<Integer, Integer> horizontalMinMax) { 397 398 final int min = horizontalMinMax.first.intValue(); 399 final int max = horizontalMinMax.second.intValue(); 400 401 // "Flatten image" by projecting target colors (between min/max) vertically, 402 // counting number of found pixels in each row 403 404 // Pass over y-axis, restricted to horizontalMin, horizontalMax 405 for (int y = 0; y < height; y++) { 406 for (int x = min; x <= max; x++) { 407 final int color = img.getRGB(x, y); 408 for (final int targetColor : targetColors) { 409 if (color == targetColor) { 410 vertical[y]++; 411 break; 412 } 413 } 414 } 415 } 416 } 417 418 private static Pair<Integer, Integer> getBounds(int[] array) { 419 // Determine min, max 420 int min = -1; 421 for (int i = 0; i < array.length; i++) { 422 if (array[i] > 0) { 423 min = i; 424 break; 425 } 426 } 427 428 int max = -1; 429 for (int i = array.length - 1; i >= 0; i--) { 430 if (array[i] > 0) { 431 max = i; 432 break; 433 } 434 } 435 436 return new Pair<Integer, Integer>(Integer.valueOf(min), Integer.valueOf(max)); 437 } 438 439 private static void filter(int[] array, final int threshold) { 440 // Filter horizontal array; set all values < threshold to 0 441 for (int i = 0; i < array.length; i++) { 442 final int v = array[i]; 443 if (v != 0 && v <= threshold) { 444 array[i] = 0; 445 } 446 } 447 } 448 449 private static boolean boundsWithinRange(Pair<Integer, Integer> bounds, int low, int high) { 450 return low <= bounds.first.intValue() 451 && bounds.first.intValue() < high 452 && low <= bounds.second.intValue() 453 && bounds.second.intValue() < high; 454 } 455 456 private static Pair<Result, String> handleError(String fmt, String tag, int arg1) { 457 final String err = String.format(fmt, tag, arg1); 458 CLog.w(err); 459 return new Pair<Result, String>(Result.FAIL, err); 460 } 461 462 private static Pair<Result, String> handleError( 463 String fmt, String tag, int arg1, int arg2, int arg3) { 464 final String err = String.format(fmt, tag, arg1, arg2, arg3); 465 CLog.w(err); 466 return new Pair<Result, String>(Result.FAIL, err); 467 } 468 469 private static Pair<Result, String> handleError( 470 String fmt, String tag, float arg1, float arg2, float arg3) { 471 final String err = String.format(fmt, tag, arg1, arg2, arg3); 472 CLog.w(err); 473 return new Pair<Result, String>(Result.FAIL, err); 474 } 475 } 476