Skip to content

Commit d6a368c

Browse files
author
Mykola Mokhnach
committed
Add ScreenshotState class for screenshots comparison purposes
1 parent 48f3a96 commit d6a368c

File tree

3 files changed

+503
-0
lines changed

3 files changed

+503
-0
lines changed

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ dependencies {
6666
compile 'commons-io:commons-io:2.5'
6767
compile 'org.springframework:spring-context:4.3.5.RELEASE'
6868
compile 'org.aspectj:aspectjweaver:1.8.10'
69+
compile 'nu.pattern:opencv:2.4.9-7'
6970

7071
testCompile 'junit:junit:4.12'
7172
}
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* See the NOTICE file distributed with this work for additional
5+
* information regarding copyright ownership.
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+
17+
package io.appium.java_client;
18+
19+
import org.opencv.core.Core;
20+
import org.opencv.core.CvType;
21+
import org.opencv.core.Mat;
22+
import org.opencv.core.Size;
23+
import org.opencv.imgproc.Imgproc;
24+
25+
import java.awt.AlphaComposite;
26+
import java.awt.Graphics2D;
27+
import java.awt.image.BufferedImage;
28+
import java.awt.image.DataBufferByte;
29+
import java.util.Optional;
30+
import java.util.function.Function;
31+
import java.util.function.Supplier;
32+
33+
public class ScreenshotState {
34+
private static final long DEFAULT_INTERVAL_MS = 500;
35+
36+
private Optional<BufferedImage> previousScreenshot = Optional.empty();
37+
private Supplier<BufferedImage> stateProvider;
38+
39+
private long comparisonIntervalMs = DEFAULT_INTERVAL_MS;
40+
41+
/**
42+
* The class constructor accepts single argument, which is
43+
* lambda function, that provides the screenshot of the necessary
44+
* screen area to be verified for similarity.
45+
* This lambda method is NOT called upon class creation.
46+
* One has to invoke {@link #remember()} method in order to call it.
47+
*
48+
* <p>Examples of provider function with Appium driver:
49+
* <code>
50+
* () -&gt; {
51+
* final byte[] srcImage = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
52+
* return ImageIO.read(new ByteArrayInputStream(srcImage));
53+
* }
54+
* </code>
55+
* or
56+
* <code>
57+
* () -&gt; {
58+
* final byte[] srcImage = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
59+
* final BufferedImage screenshot = ImageIO.read(new ByteArrayInputStream(srcImage));
60+
* final WebElement element = driver.findElement(locator);
61+
* // Can be simplified in Selenium 3.0+ by using getRect method of WebElement interface
62+
* final Point elementLocation = element.getLocation();
63+
* final Dimension elementSize = element.getSize();
64+
* return screenshot.getSubimage(
65+
* new Rectangle(elementLocation.x, elementLocation.y, elementSize.width, elementSize.height);
66+
* }
67+
* </code>
68+
*
69+
* @param stateProvider lambda function, which returns a screenshot for further comparison
70+
*/
71+
public ScreenshotState(Supplier<BufferedImage> stateProvider) {
72+
this.stateProvider = stateProvider;
73+
}
74+
75+
/**
76+
* Gets the interval value in ms between similarity verification rounds in <em>verify*</em> methods.
77+
*
78+
* @return current interval value in ms
79+
*/
80+
public long getComparisonInterval() {
81+
return comparisonIntervalMs;
82+
}
83+
84+
/**
85+
* Sets the interval between similarity verification rounds in <em>verify*</em> methods.
86+
*
87+
* @param ms interval value in ms. 500 by default
88+
* @return self instance for chaining
89+
*/
90+
public ScreenshotState setComparisonInterval(long ms) {
91+
this.comparisonIntervalMs = ms;
92+
return this;
93+
}
94+
95+
/**
96+
* Call this method to save the initial screenshot state.
97+
* It is mandatory to call before any <em>verify*</em> method is invoked.
98+
*
99+
* @return self instance for chaining
100+
*/
101+
public ScreenshotState remember() {
102+
this.previousScreenshot = Optional.of(stateProvider.get());
103+
return this;
104+
}
105+
106+
/**
107+
* This method allows to pass a custom bitmap for further comparison
108+
* instead of taking one using screenshot provider function. This might
109+
* be useful in some advanced cases.
110+
*
111+
* @param customInitialState valid bitmap
112+
* @return self instance for chaining
113+
*/
114+
public ScreenshotState remember(BufferedImage customInitialState) {
115+
this.previousScreenshot = Optional.of(customInitialState);
116+
return this;
117+
}
118+
119+
public static class ScreenshotComparisonError extends RuntimeException {
120+
private static final long serialVersionUID = -7011854909939194466L;
121+
122+
ScreenshotComparisonError(Throwable reason) {
123+
super(reason);
124+
}
125+
126+
ScreenshotComparisonError(String message) {
127+
super(message);
128+
}
129+
}
130+
131+
public static class ScreenshotComparisonTimeout extends RuntimeException {
132+
private static final long serialVersionUID = 6336247721154252476L;
133+
private double currentScore = Double.NaN;
134+
135+
ScreenshotComparisonTimeout(String message, double currentScore) {
136+
super(message);
137+
this.currentScore = currentScore;
138+
}
139+
140+
public double getCurrentScore() {
141+
return currentScore;
142+
}
143+
}
144+
145+
private ScreenshotState checkState(Function<Double, Boolean> checkerFunc, long timeoutMs) {
146+
return checkState(checkerFunc, timeoutMs, ResizeMode.NO_RESIZE);
147+
}
148+
149+
private ScreenshotState checkState(Function<Double, Boolean> checkerFunc, long timeoutMs, ResizeMode resizeMode) {
150+
final long started = System.currentTimeMillis();
151+
double score;
152+
do {
153+
final BufferedImage currentState = stateProvider.get();
154+
score = getOverlapScore(this.previousScreenshot
155+
.orElseThrow(() -> new ScreenshotComparisonError("Initial screenshot state is not set. "
156+
+ "Nothing to compare")), currentState, resizeMode);
157+
if (checkerFunc.apply(score)) {
158+
return this;
159+
}
160+
try {
161+
Thread.sleep(comparisonIntervalMs);
162+
} catch (InterruptedException e) {
163+
throw new ScreenshotComparisonError(e);
164+
}
165+
}
166+
while (System.currentTimeMillis() - started <= timeoutMs);
167+
throw new ScreenshotComparisonTimeout(
168+
String.format("SScreenshot comparison timed out after %s ms. Actual similarity score: %.5f",
169+
timeoutMs, score), score);
170+
}
171+
172+
/**
173+
* Verifies whether the state of the screenshot provided by stateProvider lambda function
174+
* is changed within the given timeout.
175+
*
176+
* @param timeoutMs timeout value in milliseconds
177+
* @param minScore the value in range (0.0, 1.0)
178+
* @return self instance for chaining
179+
* @throws ScreenshotComparisonTimeout if the calculated score is still
180+
* greater or equal to the given score after timeout happens
181+
* @throws ScreenshotComparisonError if {@link #remember()} method has not been invoked yet
182+
*/
183+
public ScreenshotState verifyChanged(long timeoutMs, double minScore) {
184+
return checkState((x) -> x < minScore, timeoutMs);
185+
}
186+
187+
/**
188+
* Verifies whether the state of the screenshot provided by stateProvider lambda function
189+
* is changed within the given timeout.
190+
*
191+
* @param timeoutMs timeout value in milliseconds
192+
* @param minScore the value in range (0.0, 1.0)
193+
* @param resizeMode one of <em>ResizeMode</em> enum values.
194+
* Set it to a value different from <em>NO_RESIZE</em>
195+
* if the actual screenshot is expected to have different
196+
* dimensions in comparison to the previously remembered one
197+
* @return self instance for chaining
198+
* @throws ScreenshotComparisonTimeout if the calculated score is still
199+
* greater or equal to the given score after timeout happens
200+
* @throws ScreenshotComparisonError if {@link #remember()} method has not been invoked yet
201+
*/
202+
public ScreenshotState verifyChanged(long timeoutMs, double minScore, ResizeMode resizeMode) {
203+
return checkState((x) -> x < minScore, timeoutMs, resizeMode);
204+
}
205+
206+
/**
207+
* Verifies whether the state of the screenshot provided by stateProvider lambda function
208+
* is not changed within the given timeout.
209+
*
210+
* @param timeoutMs timeout value in milliseconds
211+
* @param minScore the value in range (0.0, 1.0)
212+
* @return self instance for chaining
213+
* @throws ScreenshotComparisonTimeout if the calculated score is still
214+
* less than the given score after timeout happens
215+
* @throws ScreenshotComparisonError if {@link #remember()} method has not been invoked yet
216+
*/
217+
public ScreenshotState verifyNotChanged(long timeoutMs, double minScore) {
218+
return checkState((x) -> x >= minScore, timeoutMs);
219+
}
220+
221+
/**
222+
* Verifies whether the state of the screenshot provided by stateProvider lambda function
223+
* is changed within the given timeout.
224+
*
225+
* @param timeoutMs timeout value in milliseconds
226+
* @param minScore the value in range (0.0, 1.0)
227+
* @param resizeMode one of <em>ResizeMode</em> enum values.
228+
* Set it to a value different from <em>NO_RESIZE</em>
229+
* if the actual screenshot is expected to have different
230+
* dimensions in comparison to the previously remembered one
231+
* @return self instance for chaining
232+
* @throws ScreenshotComparisonTimeout if the calculated score is still
233+
* less than the given score after timeout happens
234+
* @throws ScreenshotComparisonError if {@link #remember()} method has not been invoked yet
235+
*/
236+
public ScreenshotState verifyNotChanged(long timeoutMs, double minScore, ResizeMode resizeMode) {
237+
return checkState((x) -> x >= minScore, timeoutMs, resizeMode);
238+
}
239+
240+
private static Mat prepareImageForComparison(BufferedImage srcImage) {
241+
final BufferedImage result = new BufferedImage(srcImage.getWidth(), srcImage.getHeight(),
242+
BufferedImage.TYPE_3BYTE_BGR);
243+
final Graphics2D g = srcImage.createGraphics();
244+
try {
245+
g.setComposite(AlphaComposite.Src);
246+
g.drawImage(srcImage, 0, 0, null);
247+
} finally {
248+
g.dispose();
249+
}
250+
final byte[] pixels = ((DataBufferByte) result.getRaster().getDataBuffer()).getData();
251+
final Mat imageMat = new Mat(result.getHeight(), result.getWidth(), CvType.CV_8UC3);
252+
imageMat.put(0, 0, pixels);
253+
return imageMat;
254+
}
255+
256+
private static Mat resizeFirstMatrixToSecondMatrixResolution(Mat first, Mat second) {
257+
if (first.width() != second.width() || first.height() != second.height()) {
258+
final Mat result = new Mat();
259+
final Size sz = new Size(second.width(), second.height());
260+
Imgproc.resize(first, result, sz);
261+
return result;
262+
}
263+
return first;
264+
}
265+
266+
/**
267+
* A shortcut to {@link #getOverlapScore(BufferedImage, BufferedImage, ResizeMode)} method
268+
* for the case if both reference and template images are expected to have the same dimensions.
269+
*
270+
* @param refImage reference image
271+
* @param tplImage template
272+
* @return similarity score value in range (-1.0, 1.0). 1.0 is returned if the images are equal
273+
* @throws ScreenshotComparisonError if provided images are not valid or have different resolution
274+
*/
275+
public static double getOverlapScore(BufferedImage refImage, BufferedImage tplImage) {
276+
return getOverlapScore(refImage, tplImage, ResizeMode.NO_RESIZE);
277+
}
278+
279+
/**
280+
* Compares two valid java bitmaps and calculates similarity score between them.
281+
*
282+
* @param refImage reference image
283+
* @param tplImage template
284+
* @param resizeMode one of possible enum values. Set it either to <em>TEMPLATE_TO_REFERENCE_RESOLUTION</em> or
285+
* <em>REFERENCE_TO_TEMPLATE_RESOLUTION</em> if given bitmaps have different dimensions
286+
* @return similarity score value in range (-1.0, 1.0). 1.0 is returned if the images are equal
287+
* @throws ScreenshotComparisonError if provided images are not valid or have
288+
* different resolution, but resizeMode has been set to <em>NO_RESIZE</em>
289+
*/
290+
public static double getOverlapScore(BufferedImage refImage, BufferedImage tplImage, ResizeMode resizeMode) {
291+
Mat ref = prepareImageForComparison(refImage);
292+
if (ref.empty()) {
293+
throw new ScreenshotComparisonError("Reference image cannot be converted for further comparison");
294+
}
295+
Mat tpl = prepareImageForComparison(tplImage);
296+
if (tpl.empty()) {
297+
throw new ScreenshotComparisonError("Template image cannot be converted for further comparison");
298+
}
299+
switch (resizeMode) {
300+
case TEMPLATE_TO_REFERENCE_RESOLUTION:
301+
tpl = resizeFirstMatrixToSecondMatrixResolution(tpl, ref);
302+
break;
303+
case REFERENCE_TO_TEMPLATE_RESOLUTION:
304+
ref = resizeFirstMatrixToSecondMatrixResolution(ref, tpl);
305+
break;
306+
default:
307+
// do nothing
308+
}
309+
310+
if (ref.width() != tpl.width() || ref.height() != tpl.height()) {
311+
throw new ScreenshotComparisonError(
312+
"Resolutions of template and reference images are expected to be equal. "
313+
+ "Try different resizeMode value."
314+
);
315+
}
316+
317+
final Mat res = new Mat(ref.rows() - tpl.rows() + 1, ref.cols() - tpl.cols() + 1, CvType.CV_32FC1);
318+
Imgproc.matchTemplate(ref, tpl, res, Imgproc.TM_CCOEFF_NORMED);
319+
Core.MinMaxLocResult minMaxLocResult = Core.minMaxLoc(res);
320+
return minMaxLocResult.maxVal;
321+
}
322+
323+
public enum ResizeMode {
324+
NO_RESIZE, TEMPLATE_TO_REFERENCE_RESOLUTION, REFERENCE_TO_TEMPLATE_RESOLUTION
325+
}
326+
}

0 commit comments

Comments
 (0)