Skip to content

Commit 8317ac8

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

File tree

3 files changed

+511
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)