Skip to content

Commit 96305fe

Browse files
Merge pull request #595 from mykola-mokhnach/screenshot_state
Add possibility to calculate screenshots overlap score + several helpers
2 parents 6061b74 + adfc567 commit 96305fe

File tree

3 files changed

+484
-0
lines changed

3 files changed

+484
-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 'org.openpnp:opencv:3.2.0-1'
6970

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

0 commit comments

Comments
 (0)