Skip to content

Commit 7fd0ccc

Browse files
authored
add base image comparison (#80)
* add base image comparison * add methods for each mode * format and fix rubocop * remove def delegate * add doc strings * added function tests and appended examples * fixed typo * update docstrings * fix typo * check the file exists * add changelog * fix unstable session path test
1 parent 0b10980 commit 7fd0ccc

16 files changed

+385
-32
lines changed

.rubocop.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ Metrics/ClassLength:
1111
Metrics/AbcSize:
1212
Enabled: false
1313
Metrics/CyclomaticComplexity:
14-
Max: 8
14+
Max: 9
1515
Metrics/PerceivedComplexity:
16-
Max: 8
16+
Max: 9
1717
Style/Documentation:
1818
Enabled: false
1919
Style/CommentedKeyword:

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file.
33

44
## [Unreleased]
55
### Enhancements
6+
- add base image comparison
7+
- `match_images_features`, `find_image_occurrence`, `get_images_similarity`, `compare_images`
68
- [internal] No longer have dependency for Selenium's wait
79
- [internal] Separate mjsonwp commands module and w3c commands module from one command module
810

lib/appium_lib_core.rb

+1-6
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,7 @@
55
require_relative 'appium_lib_core/patch'
66
require_relative 'appium_lib_core/driver'
77

8-
# for multi touch related methods
9-
require_relative 'appium_lib_core/device/touch_actions'
10-
require_relative 'appium_lib_core/device/multi_touch'
11-
require_relative 'appium_lib_core/device/screen_record'
12-
require_relative 'appium_lib_core/device/app_state'
13-
require_relative 'appium_lib_core/device/clipboard_content_type'
8+
require_relative 'appium_lib_core/device'
149

1510
require_relative 'appium_lib_core/android'
1611
require_relative 'appium_lib_core/android_uiautomator2'

lib/appium_lib_core/common.rb

-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
require_relative 'common/error'
33
require_relative 'common/log'
44
require_relative 'common/command'
5-
require_relative 'common/device'
65
require_relative 'common/base'
76
require_relative 'common/wait'
87
require_relative 'common/ws/websocket'

lib/appium_lib_core/common/command/common.rb

+2-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ module Commands
4545
get_settings: [:get, 'session/:session_id/appium/settings'.freeze],
4646
update_settings: [:post, 'session/:session_id/appium/settings'.freeze],
4747
stop_recording_screen: [:post, 'session/:session_id/appium/stop_recording_screen'.freeze],
48-
start_recording_screen: [:post, 'session/:session_id/appium/start_recording_screen'.freeze]
48+
start_recording_screen: [:post, 'session/:session_id/appium/start_recording_screen'.freeze],
49+
compare_images: [:post, 'session/:session_id/appium/compare_images'.freeze]
4950
}.freeze
5051

5152
COMMAND_ANDROID = {

lib/appium_lib_core/common/device.rb lib/appium_lib_core/device.rb

+8-6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
require_relative 'device/touch_actions'
2+
require_relative 'device/multi_touch'
3+
require_relative 'device/screen_record'
4+
require_relative 'device/app_state'
5+
require_relative 'device/clipboard_content_type'
6+
require_relative 'device/image_comparison'
7+
18
require 'base64'
29

310
module Appium
@@ -513,6 +520,7 @@ def save_viewport_screenshot(png_path)
513520
add_app_management
514521
add_device_lock
515522
add_file_management
523+
Core::Device::ImageComparison.extended
516524
end
517525

518526
# def extended
@@ -522,7 +530,6 @@ def add_endpoint_method(method)
522530
block_given? ? create_bridge_command(method, &Proc.new) : create_bridge_command(method)
523531

524532
delegate_driver_method method
525-
delegate_from_appium_driver method
526533
end
527534

528535
# @private CoreBridge
@@ -539,11 +546,6 @@ def delegate_driver_method(method)
539546
::Appium::Core::Base::Driver.class_eval { def_delegator :@bridge, method }
540547
end
541548

542-
# @private
543-
def delegate_from_appium_driver(method, delegation_target = :driver)
544-
def_delegator delegation_target, method
545-
end
546-
547549
# @private
548550
def create_bridge_command(method)
549551
::Appium::Core::Base::Bridge::MJSONWP.class_eval do
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
require 'base64'
2+
3+
module Appium
4+
module Core
5+
module Device
6+
module ImageComparison
7+
extend Forwardable
8+
9+
MODE = [:matchFeatures, :getSimilarity, :matchTemplate].freeze
10+
11+
MATCH_FEATURES = {
12+
detector_name: %w(AKAZE AGAST BRISK FAST GFTT KAZE MSER SIFT ORB),
13+
match_func: %w(FlannBased BruteForce BruteForceL1 BruteForceHamming BruteForceHammingLut BruteForceSL2),
14+
goodMatchesFactor: nil, # Integer
15+
visualize: [true, false]
16+
}.freeze
17+
18+
MATCH_TEMPLATE = {
19+
visualize: [true, false]
20+
}.freeze
21+
22+
GET_SIMILARITY = {
23+
visualize: [true, false]
24+
}.freeze
25+
26+
# @!method match_images_features(first_image:, second_image:, detector_name: 'ORB',
27+
# match_func: 'BruteForce', good_matches_factor: 100, visualize: false)
28+
# Performs images matching by features with default options. Read https://docs.opencv.org/3.0-beta/doc/py_tutorials/py_feature2d/py_matcher/py_matcher.html
29+
# for more details on this topic.
30+
#
31+
# @param [String] first_image An image data. All image formats, that OpenCV library itself accepts, are supported.
32+
# @param [String] second_image An image data. All image formats, that OpenCV library itself accepts, are supported.
33+
# @param [String] detector_name Sets the detector name for features matching
34+
# algorithm. Some of these detectors (FAST, AGAST, GFTT, FAST, SIFT and MSER) are
35+
# not available in the default OpenCV installation and have to be enabled manually
36+
# before library compilation. The default detector name is 'ORB'.
37+
# @param [String] match_func The name of the matching function. The default one is 'BruteForce'.
38+
# @param [String] good_matches_factor The maximum count of "good" matches (e. g. with minimal distances).
39+
# The default one is nil.
40+
# @param [Bool] visualise Makes the endpoint to return an image, which contains the visualized result of
41+
# the corresponding picture matching operation. This option is disabled by default.
42+
#
43+
# @example
44+
# @driver.match_images_features first_image: "image data 1", second_image: "image data 2"
45+
#
46+
# visual = @@driver.match_images_features first_image: image1, second_image: image2, visualize: true
47+
# File.write 'match_images_visual.png', Base64.decode64(visual['visualization']) # if the image is PNG
48+
#
49+
50+
# @!method find_image_occurrence(full_image:, partial_image:, detector_name: 'ORB', visualize: false)
51+
# Performs images matching by template to find possible occurrence of the partial image
52+
# in the full image with default options. Read https://docs.opencv.org/2.4/doc/tutorials/imgproc/histograms/template_matching/template_matching.html
53+
# for more details on this topic.
54+
#
55+
# @param [String] full_image: A full image data.
56+
# @param [String] partial_image: A partial image data. All image formats, that OpenCV library itself accepts,
57+
# are supported.
58+
# @param [Bool] visualise: Makes the endpoint to return an image, which contains the visualized result of
59+
# the corresponding picture matching operation. This option is disabled by default.
60+
#
61+
# @example
62+
# @driver.find_image_occurrence full_image: "image data 1", partial_image: "image data 2"
63+
#
64+
# visual = @@driver.find_image_occurrence full_image: image1, partial_image: image2, visualize: true
65+
# File.write 'find_result_visual.png', Base64.decode64(visual['visualization']) # if the image is PNG
66+
#
67+
68+
# @!method get_images_similarity(first_image:, second_image:, detector_name: 'ORB', visualize: false)
69+
# Performs images matching to calculate the similarity score between them
70+
# with default options. The flow there is similar to the one used in `find_image_occurrence`
71+
# but it is mandatory that both images are of equal size.
72+
#
73+
# @param [String] first_image: An image data. All image formats, that OpenCV library itself accepts, are supported.
74+
# @param [String] second_image: An image data. All image formats, that OpenCV library itself accepts, are supported.
75+
# @param [Bool] visualise: Makes the endpoint to return an image, which contains the visualized result of
76+
# the corresponding picture matching operation. This option is disabled by default.
77+
#
78+
# @example
79+
# @driver.get_images_similarity first_image: "image data 1", second_image: "image data 2"
80+
#
81+
# visual = @@driver.get_images_similarity first_image: image1, second_image: image2, visualize: true
82+
# File.write 'images_similarity_visual.png', Base64.decode64(visual['visualization']) # if the image is PNG
83+
#
84+
85+
# @!method compare_images(mode:, first_image:, second_image:, options:)
86+
#
87+
# Performs images comparison using OpenCV framework features.
88+
# It is expected that both OpenCV framework and opencv4nodejs
89+
# module are installed on the machine where Appium server is running.
90+
#
91+
# @param [Symbol] mode: One of possible comparison modes: `:matchFeatures`, `:getSimilarity`, `:matchTemplate`.
92+
# `:matchFeatures is by default.
93+
# @param [String] first_image: An image data. All image formats, that OpenCV library itself accepts, are supported.
94+
# @param [String] second_image: An image data. All image formats, that OpenCV library itself accepts, are supported.
95+
# @param [Hash] options: The content of this dictionary depends on the actual `mode` value.
96+
# See the documentation on `appium-support` module for more details.
97+
# @returns [Hash] The content of the resulting dictionary depends on the actual `mode` and `options` values.
98+
# See the documentation on `appium-support` module for more details.
99+
#
100+
101+
####
102+
## class << self
103+
####
104+
105+
def self.extended
106+
::Appium::Core::Device.add_endpoint_method(:match_images_features) do
107+
def match_images_features(first_image:, # rubocop:disable Metrics/ParameterLists
108+
second_image:,
109+
detector_name: 'ORB',
110+
match_func: 'BruteForce',
111+
good_matches_factor: nil,
112+
visualize: false)
113+
unless ::Appium::Core::Device::ImageComparison::MATCH_FEATURES[:detector_name].member?(detector_name.to_s)
114+
raise "detector_name should be #{::Appium::Core::Device::ImageComparison::MATCH_FEATURES[:detector_name]}"
115+
end
116+
unless ::Appium::Core::Device::ImageComparison::MATCH_FEATURES[:match_func].member?(match_func.to_s)
117+
raise "match_func should be #{::Appium::Core::Device::ImageComparison::MATCH_FEATURES[:match_func]}"
118+
end
119+
unless ::Appium::Core::Device::ImageComparison::MATCH_FEATURES[:visualize].member?(visualize)
120+
raise "visualize should be #{::Appium::Core::Device::ImageComparison::MATCH_FEATURES[:visualize]}"
121+
end
122+
123+
options = {}
124+
options[:detectorName] = detector_name.to_s.upcase
125+
options[:matchFunc] = match_func.to_s
126+
options[:goodMatchesFactor] = good_matches_factor.to_i unless good_matches_factor.nil?
127+
options[:visualize] = visualize
128+
129+
compare_images(mode: :matchFeatures, first_image: first_image, second_image: second_image, options: options)
130+
end
131+
end
132+
133+
::Appium::Core::Device.add_endpoint_method(:find_image_occurrence) do
134+
def find_image_occurrence(full_image:, partial_image:, visualize: false)
135+
unless ::Appium::Core::Device::ImageComparison::MATCH_TEMPLATE[:visualize].member?(visualize)
136+
raise "visualize should be #{::Appium::Core::Device::ImageComparison::MATCH_TEMPLATE[:visualize]}"
137+
end
138+
139+
options = {}
140+
options[:visualize] = visualize
141+
142+
compare_images(mode: :matchTemplate, first_image: full_image, second_image: partial_image, options: options)
143+
end
144+
end
145+
146+
::Appium::Core::Device.add_endpoint_method(:get_images_similarity) do
147+
def get_images_similarity(first_image:, second_image:, visualize: false)
148+
unless ::Appium::Core::Device::ImageComparison::GET_SIMILARITY[:visualize].member?(visualize)
149+
raise "visualize should be #{::Appium::Core::Device::ImageComparison::GET_SIMILARITY[:visualize]}"
150+
end
151+
152+
options = {}
153+
options[:visualize] = visualize
154+
155+
compare_images(mode: :getSimilarity, first_image: first_image, second_image: second_image, options: options)
156+
end
157+
end
158+
159+
::Appium::Core::Device.add_endpoint_method(:compare_images) do
160+
def compare_images(mode: :matchFeatures, first_image:, second_image:, options: nil)
161+
unless ::Appium::Core::Device::ImageComparison::MODE.member?(mode)
162+
raise "content_type should be #{::Appium::Core::Device::ImageComparison::MODE}"
163+
end
164+
165+
params = {}
166+
params[:mode] = mode
167+
params[:firstImage] = Base64.encode64 first_image
168+
params[:secondImage] = Base64.encode64 second_image
169+
params[:options] = options if options
170+
171+
execute(:compare_images, {}, params)
172+
end
173+
end
174+
end # self
175+
end # module ImageComparison
176+
end # module Device
177+
end # module Core
178+
end # module Appium

lib/appium_lib_core/driver.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ def set_automation_name_if_nil
403403

404404
# @private
405405
def write_session_id(session_id, export_path = '/tmp/appium_lib_session')
406-
File.open(export_path, 'w') { |f| f.puts session_id }
406+
File.write(export_path, session_id)
407407
rescue IOError => e
408408
::Appium::Logger.warn e
409409
nil

test/functional/android/android/device_test.rb

+45
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,51 @@ def test_clipbord
347347
assert_equal input, @@driver.get_clipboard
348348
end
349349

350+
def test_image_comparison_match_result
351+
image1 = File.read './test/functional/data/test_normal.png'
352+
image2 = File.read './test/functional/data/test_has_blue.png'
353+
354+
match_result = @@driver.match_images_features first_image: image1, second_image: image2
355+
assert_equal %w(points1 rect1 points2 rect2 totalCount count), match_result.keys
356+
357+
match_result_visual = @@driver.match_images_features first_image: image1, second_image: image2, visualize: true
358+
assert_equal %w(points1 rect1 points2 rect2 totalCount count visualization), match_result_visual.keys
359+
File.write 'match_result_visual.png', Base64.decode64(match_result_visual['visualization'])
360+
assert File.size? 'match_result_visual.png'
361+
362+
File.delete 'match_result_visual.png'
363+
end
364+
365+
def test_image_comparison_find_result
366+
image1 = File.read './test/functional/data/test_normal.png'
367+
image2 = File.read './test/functional/data/test_has_blue.png'
368+
369+
find_result = @@driver.find_image_occurrence full_image: image1, partial_image: image2
370+
assert_equal({ 'rect' => { 'x' => 0, 'y' => 0, 'width' => 750, 'height' => 1334 } }, find_result)
371+
372+
find_result_visual = @@driver.find_image_occurrence full_image: image1, partial_image: image2, visualize: true
373+
assert_equal %w(rect visualization), find_result_visual.keys
374+
File.write 'find_result_visual.png', Base64.decode64(find_result_visual['visualization'])
375+
assert File.size? 'find_result_visual.png'
376+
377+
File.delete 'find_result_visual.png'
378+
end
379+
380+
def test_image_comparison_get_images_result
381+
image1 = File.read './test/functional/data/test_normal.png'
382+
image2 = File.read './test/functional/data/test_has_blue.png'
383+
384+
get_images_result = @@driver.get_images_similarity first_image: image1, second_image: image2
385+
assert_equal({ 'score' => 0.891606867313385 }, get_images_result)
386+
387+
get_images_result_visual = @@driver.get_images_similarity first_image: image1, second_image: image2, visualize: true
388+
assert_equal %w(score visualization), get_images_result_visual.keys
389+
File.write 'get_images_result_visual.png', Base64.decode64(get_images_result_visual['visualization'])
390+
assert File.size? 'get_images_result_visual.png'
391+
392+
File.delete 'get_images_result_visual.png'
393+
end
394+
350395
private
351396

352397
def scroll_to(text)
304 KB
Loading

test/functional/data/test_normal.png

266 KB
Loading

test/functional/ios/ios/device_test.rb

+45
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,51 @@ def test_clipbord
259259

260260
assert_equal input, @@driver.get_clipboard
261261
end
262+
263+
def test_image_comparison_match_result
264+
image1 = File.read './test/functional/data/test_normal.png'
265+
image2 = File.read './test/functional/data/test_has_blue.png'
266+
267+
match_result = @@driver.match_images_features first_image: image1, second_image: image2
268+
assert_equal %w(points1 rect1 points2 rect2 totalCount count), match_result.keys
269+
270+
match_result_visual = @@driver.match_images_features first_image: image1, second_image: image2, visualize: true
271+
assert_equal %w(points1 rect1 points2 rect2 totalCount count visualization), match_result_visual.keys
272+
File.write 'match_result_visual.png', Base64.decode64(match_result_visual['visualization'])
273+
assert File.size? 'match_result_visual.png'
274+
275+
File.delete 'match_result_visual.png'
276+
end
277+
278+
def test_image_comparison_find_result
279+
image1 = File.read './test/functional/data/test_normal.png'
280+
image2 = File.read './test/functional/data/test_has_blue.png'
281+
282+
find_result = @@driver.find_image_occurrence full_image: image1, partial_image: image2
283+
assert_equal({ 'rect' => { 'x' => 0, 'y' => 0, 'width' => 750, 'height' => 1334 } }, find_result)
284+
285+
find_result_visual = @@driver.find_image_occurrence full_image: image1, partial_image: image2, visualize: true
286+
assert_equal %w(rect visualization), find_result_visual.keys
287+
File.write 'find_result_visual.png', Base64.decode64(find_result_visual['visualization'])
288+
assert File.size? 'find_result_visual.png'
289+
290+
File.delete 'find_result_visual.png'
291+
end
292+
293+
def test_image_comparison_get_images_result
294+
image1 = File.read './test/functional/data/test_normal.png'
295+
image2 = File.read './test/functional/data/test_has_blue.png'
296+
297+
get_images_result = @@driver.get_images_similarity first_image: image1, second_image: image2
298+
assert_equal({ 'score' => 0.891606867313385 }, get_images_result)
299+
300+
get_images_result_visual = @@driver.get_images_similarity first_image: image1, second_image: image2, visualize: true
301+
assert_equal %w(score visualization), get_images_result_visual.keys
302+
File.write 'get_images_result_visual.png', Base64.decode64(get_images_result_visual['visualization'])
303+
assert File.size? 'get_images_result_visual.png'
304+
305+
File.delete 'get_images_result_visual.png'
306+
end
262307
end
263308
end
264309
end

0 commit comments

Comments
 (0)