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 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 */ 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 */ 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 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 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 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 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 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 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 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 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 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