Repository: beetbox/beets

Issue:
Image similarity comparison should use modern `magick` binary when available
Beets is having intermittent issues with the latest version of Imagemagick as it seems the commands used to call compare,convert, etc have changed in the latest verison IM 7.  in a cmd prompt running 'Magick compare' produces output as pictured below. 'Magick' was not required in previous versions. 


![2022-05-02-23_28_50-VMHost - VMHost - Remote Desktop Connection](https://user-images.githubusercontent.com/90217713/152687778-077b3532-ea3b-4721-8551-546fc5d9a927.png)

```
lyrics: fetched lyrics: Prince - 3121 - 3121
Sending event: art_set
embedart: Embedding album art into Otep - KULT 45
Sending event: database_change
embedart: Extracting album art from: Otep - KULT 45 - Hail to the Thief to: C:\Users\ANDREW~1\AppData\Local\Temp\tmpj8j8woil.jpg
embedart: comparing images with pipeline ['convert', 'F:\\Download Queues\\Complete\\Music Library Temp\\Artists\\Otep\\2018 - KULT 45\\thumb.jpg', 'C:\\Users\\ANDREW~1\\AppData\\Local\\Temp\\tmpj8j8woil.jpg', '-colorspace', 'gray', 'MIFF:-'] | ['compare', '-metric', 'PHASH', '-', 'null:']
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Lolita (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Lolita
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Te-Amo-Coraz%C3%B3n (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Te Amo Corazón
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Black-Sweat (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Black Sweat
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Incense-And-Candles (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Incense and Candles
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Love (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Love
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Satisfied (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Satisfied
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Fury (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Fury
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/The-Word (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - The Word
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Beautiful%2C-Loved-And-Blessed (404)lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Beautiful, Loved and Blessed
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/The-Dance (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - The Dance
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Get-On-The-Boat (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Get on the Boat
Sending event: database_change
Traceback (most recent call last):
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\Scripts\beet-script.py", line 33, in <module>
    sys.exit(load_entry_point('beets==1.6.1', 'console_scripts', 'beet')())
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\ui\__init__.py", line 1285, in main
    _raw_main(args)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\ui\__init__.py", line 1272, in _raw_main
    subcommand.func(lib, suboptions, subargs)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\ui\commands.py", line 973, in import_func
    import_files(lib, paths, query)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\ui\commands.py", line 943, in import_files
    session.run()
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\importer.py", line 340, in run
    pl.run_parallel(QUEUE_SIZE)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\util\pipeline.py", line 446, in run_parallel
    raise exc_info[1].with_traceback(exc_info[2])
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\util\pipeline.py", line 358, in run
    self.coro.send(msg)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\util\pipeline.py", line 170, in coro
    task = func(*(args + (task,)))
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\importer.py", line 1566, in manipulate_files
    task.manipulate_files(
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\importer.py", line 766, in manipulate_files
    plugins.send('import_task_files', session=session, task=self)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\plugins.py", line 488, in send
    result = handler(**arguments)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\plugins.py", line 145, in wrapper
    return func(*args, **kwargs)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beetsplug\fetchart.py", line 1063, in assign_art
    self._set_art(task.album, candidate, not self.src_removed)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beetsplug\fetchart.py", line 1048, in _set_art
    album.set_art(candidate.path, delete)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\library.py", line 1340, in set_art
    plugins.send('art_set', album=self)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\plugins.py", line 488, in send
    result = handler(**arguments)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\plugins.py", line 145, in wrapper
    return func(*args, **kwargs)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beetsplug\embedart.py", line 187, in process_album
    art.embed_album(self._log, album, max_width, True,
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\art.py", line 103, in embed_album
    embed_item(log, item, imagepath, maxwidth, None, compare_threshold,
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\art.py", line 58, in embed_item
    if not check_art_similarity(log, item, imagepath, compare_threshold):
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\art.py", line 144, in check_art_similarity
    compare_proc = subprocess.Popen(
  File "C:\Program Files\Python310\lib\subprocess.py", line 966, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "C:\Program Files\Python310\lib\subprocess.py", line 1435, in _execute_child
    hp, ht, pid, tid = _winapi.CreateProcess(executable, args,
FileNotFoundError: [WinError 2] The system cannot find the file specified
```




Discussion:
Interesting! The ImageMagick wrapper is supposed to automatically detects whether to use the `convert` binary or the `magick convert` subcommand here:
https://github.com/beetbox/beets/blob/debbe4efa512a66345c251211d874f487b8cd278/beets/util/artresizer.py#L465

It's a little hard too see what's going on from that output (it might be a bit easier if you used a fenced code block in the Markdown for the comment, i.e., wrapped the output in ``` markers?). I suppose we should be logging a bit more information to show the result of the `get_im_version` version check, and to show what command we're actually trying to execute… that could help narrow down what's crashing.
@sampsyo - it doesn't look like we actually use our ImageMagick wrapper for checking art similarity:
https://github.com/beetbox/beets/blob/debbe4efa512a66345c251211d874f487b8cd278/beets/art.py#L135-L143

I guess the fix is to start using it.
apologies, fixed the code post
Aha, thanks, @jackwilsdon! Yes indeed. It would be great if we could share some code to centralize the IM command detection…

Resolving patch:
diff --git a/beets/art.py b/beets/art.py
--- a/beets/art.py
+++ b/beets/art.py
@@ -17,8 +17,6 @@
 """
 
 
-import subprocess
-import platform
 from tempfile import NamedTemporaryFile
 import os
 
@@ -53,14 +51,22 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
                quality=0):
     """Embed an image into the item's media file.
     """
-    # Conditions and filters.
+    # Conditions.
     if compare_threshold:
-        if not check_art_similarity(log, item, imagepath, compare_threshold):
+        is_similar = check_art_similarity(
+            log, item, imagepath, compare_threshold)
+        if is_similar is None:
+            log.warning('Error while checking art similarity; skipping.')
+            return
+        elif not is_similar:
             log.info('Image not similar; skipping.')
             return
+
     if ifempty and get_art(log, item):
         log.info('media file already contained art')
         return
+
+    # Filters.
     if maxwidth and not as_album:
         imagepath = resize_image(log, imagepath, maxwidth, quality)
 
@@ -115,76 +121,30 @@ def resize_image(log, imagepath, maxwidth, quality):
     return imagepath
 
 
-def check_art_similarity(log, item, imagepath, compare_threshold):
+def check_art_similarity(
+    log,
+    item,
+    imagepath,
+    compare_threshold,
+    artresizer=None,
+):
     """A boolean indicating if an image is similar to embedded item art.
+
+    If no embedded art exists, always return `True`. If the comparison fails
+    for some reason, the return value is `None`.
+
+    This must only be called if `ArtResizer.shared.can_compare` is `True`.
     """
     with NamedTemporaryFile(delete=True) as f:
         art = extract(log, f.name, item)
 
-        if art:
-            is_windows = platform.system() == "Windows"
-
-            # Converting images to grayscale tends to minimize the weight
-            # of colors in the diff score. So we first convert both images
-            # to grayscale and then pipe them into the `compare` command.
-            # On Windows, ImageMagick doesn't support the magic \\?\ prefix
-            # on paths, so we pass `prefix=False` to `syspath`.
-            convert_cmd = ['convert', syspath(imagepath, prefix=False),
-                           syspath(art, prefix=False),
-                           '-colorspace', 'gray', 'MIFF:-']
-            compare_cmd = ['compare', '-metric', 'PHASH', '-', 'null:']
-            log.debug('comparing images with pipeline {} | {}',
-                      convert_cmd, compare_cmd)
-            convert_proc = subprocess.Popen(
-                convert_cmd,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.PIPE,
-                close_fds=not is_windows,
-            )
-            compare_proc = subprocess.Popen(
-                compare_cmd,
-                stdin=convert_proc.stdout,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.PIPE,
-                close_fds=not is_windows,
-            )
-
-            # Check the convert output. We're not interested in the
-            # standard output; that gets piped to the next stage.
-            convert_proc.stdout.close()
-            convert_stderr = convert_proc.stderr.read()
-            convert_proc.stderr.close()
-            convert_proc.wait()
-            if convert_proc.returncode:
-                log.debug(
-                    'ImageMagick convert failed with status {}: {!r}',
-                    convert_proc.returncode,
-                    convert_stderr,
-                )
-                return
-
-            # Check the compare output.
-            stdout, stderr = compare_proc.communicate()
-            if compare_proc.returncode:
-                if compare_proc.returncode != 1:
-                    log.debug('ImageMagick compare failed: {0}, {1}',
-                              displayable_path(imagepath),
-                              displayable_path(art))
-                    return
-                out_str = stderr
-            else:
-                out_str = stdout
-
-            try:
-                phash_diff = float(out_str)
-            except ValueError:
-                log.debug('IM output is not a number: {0!r}', out_str)
-                return
-
-            log.debug('ImageMagick compare score: {0}', phash_diff)
-            return phash_diff <= compare_threshold
-
-    return True
+        if not art:
+            return True
+
+        if artresizer is None:
+            artresizer = ArtResizer.shared
+
+        return artresizer.compare(art, imagepath, compare_threshold)
 
 
 def extract(log, outpath, item):
diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py
--- a/beets/util/artresizer.py
+++ b/beets/util/artresizer.py
@@ -19,11 +19,13 @@
 import subprocess
 import os
 import os.path
+import platform
 import re
 from tempfile import NamedTemporaryFile
 from urllib.parse import urlencode
 from beets import logging
 from beets import util
+from beets.util import bytestring_path, displayable_path, py3_path, syspath
 
 # Resizing methods
 PIL = 1
@@ -55,11 +57,12 @@ def temp_file_for(path):
     specified path.
     """
     ext = os.path.splitext(path)[1]
-    with NamedTemporaryFile(suffix=util.py3_path(ext), delete=False) as f:
-        return util.bytestring_path(f.name)
+    with NamedTemporaryFile(suffix=py3_path(ext), delete=False) as f:
+        return bytestring_path(f.name)
 
 
-def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
+def pil_resize(artresizer, maxwidth, path_in, path_out=None, quality=0,
+               max_filesize=0):
     """Resize using Python Imaging Library (PIL).  Return the output path
     of resized image.
     """
@@ -67,10 +70,10 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
     from PIL import Image
 
     log.debug('artresizer: PIL resizing {0} to {1}',
-              util.displayable_path(path_in), util.displayable_path(path_out))
+              displayable_path(path_in), displayable_path(path_out))
 
     try:
-        im = Image.open(util.syspath(path_in))
+        im = Image.open(syspath(path_in))
         size = maxwidth, maxwidth
         im.thumbnail(size, Image.ANTIALIAS)
 
@@ -80,7 +83,7 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
 
         # progressive=False only affects JPEGs and is the default,
         # but we include it here for explicitness.
-        im.save(util.py3_path(path_out), quality=quality, progressive=False)
+        im.save(py3_path(path_out), quality=quality, progressive=False)
 
         if max_filesize > 0:
             # If maximum filesize is set, we attempt to lower the quality of
@@ -92,7 +95,7 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
                 lower_qual = 95
             for i in range(5):
                 # 5 attempts is an abitrary choice
-                filesize = os.stat(util.syspath(path_out)).st_size
+                filesize = os.stat(syspath(path_out)).st_size
                 log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
                 if filesize <= max_filesize:
                     return path_out
@@ -103,7 +106,7 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
                 if lower_qual < 10:
                     lower_qual = 10
                 # Use optimize flag to improve filesize decrease
-                im.save(util.py3_path(path_out), quality=lower_qual,
+                im.save(py3_path(path_out), quality=lower_qual,
                         optimize=True, progressive=False)
             log.warning("PIL Failed to resize file to below {0}B",
                         max_filesize)
@@ -113,11 +116,12 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
             return path_out
     except OSError:
         log.error("PIL cannot create thumbnail for '{0}'",
-                  util.displayable_path(path_in))
+                  displayable_path(path_in))
         return path_in
 
 
-def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
+def im_resize(artresizer, maxwidth, path_in, path_out=None, quality=0,
+              max_filesize=0):
     """Resize using ImageMagick.
 
     Use the ``magick`` program or ``convert`` on older versions. Return
@@ -125,15 +129,15 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
     """
     path_out = path_out or temp_file_for(path_in)
     log.debug('artresizer: ImageMagick resizing {0} to {1}',
-              util.displayable_path(path_in), util.displayable_path(path_out))
+              displayable_path(path_in), displayable_path(path_out))
 
     # "-resize WIDTHx>" shrinks images with the width larger
     # than the given width while maintaining the aspect ratio
     # with regards to the height.
     # ImageMagick already seems to default to no interlace, but we include it
     # here for the sake of explicitness.
-    cmd = ArtResizer.shared.im_convert_cmd + [
-        util.syspath(path_in, prefix=False),
+    cmd = artresizer.im_convert_cmd + [
+        syspath(path_in, prefix=False),
         '-resize', f'{maxwidth}x>',
         '-interlace', 'none',
     ]
@@ -146,13 +150,13 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
     if max_filesize > 0:
         cmd += ['-define', f'jpeg:extent={max_filesize}b']
 
-    cmd.append(util.syspath(path_out, prefix=False))
+    cmd.append(syspath(path_out, prefix=False))
 
     try:
         util.command_output(cmd)
     except subprocess.CalledProcessError:
         log.warning('artresizer: IM convert failed for {0}',
-                    util.displayable_path(path_in))
+                    displayable_path(path_in))
         return path_in
 
     return path_out
@@ -164,20 +168,21 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
 }
 
 
-def pil_getsize(path_in):
+def pil_getsize(artresizer, path_in):
     from PIL import Image
 
     try:
-        im = Image.open(util.syspath(path_in))
+        im = Image.open(syspath(path_in))
         return im.size
     except OSError as exc:
         log.error("PIL could not read file {}: {}",
-                  util.displayable_path(path_in), exc)
+                  displayable_path(path_in), exc)
+        return None
 
 
-def im_getsize(path_in):
-    cmd = ArtResizer.shared.im_identify_cmd + \
-        ['-format', '%w %h', util.syspath(path_in, prefix=False)]
+def im_getsize(artresizer, path_in):
+    cmd = artresizer.im_identify_cmd + \
+        ['-format', '%w %h', syspath(path_in, prefix=False)]
 
     try:
         out = util.command_output(cmd).stdout
@@ -188,11 +193,12 @@ def im_getsize(path_in):
             'getting size with command {}:\n{}',
             exc.returncode, cmd, exc.output.strip()
         )
-        return
+        return None
     try:
         return tuple(map(int, out.split(b' ')))
     except IndexError:
         log.warning('Could not understand IM output: {0!r}', out)
+        return None
 
 
 BACKEND_GET_SIZE = {
@@ -201,25 +207,25 @@ def im_getsize(path_in):
 }
 
 
-def pil_deinterlace(path_in, path_out=None):
+def pil_deinterlace(artresizer, path_in, path_out=None):
     path_out = path_out or temp_file_for(path_in)
     from PIL import Image
 
     try:
-        im = Image.open(util.syspath(path_in))
-        im.save(util.py3_path(path_out), progressive=False)
+        im = Image.open(syspath(path_in))
+        im.save(py3_path(path_out), progressive=False)
         return path_out
     except IOError:
         return path_in
 
 
-def im_deinterlace(path_in, path_out=None):
+def im_deinterlace(artresizer, path_in, path_out=None):
     path_out = path_out or temp_file_for(path_in)
 
-    cmd = ArtResizer.shared.im_convert_cmd + [
-        util.syspath(path_in, prefix=False),
+    cmd = artresizer.im_convert_cmd + [
+        syspath(path_in, prefix=False),
         '-interlace', 'none',
-        util.syspath(path_out, prefix=False),
+        syspath(path_out, prefix=False),
     ]
 
     try:
@@ -235,10 +241,10 @@ def im_deinterlace(path_in, path_out=None):
 }
 
 
-def im_get_format(filepath):
-    cmd = ArtResizer.shared.im_identify_cmd + [
+def im_get_format(artresizer, filepath):
+    cmd = artresizer.im_identify_cmd + [
         '-format', '%[magick]',
-        util.syspath(filepath)
+        syspath(filepath)
     ]
 
     try:
@@ -247,11 +253,11 @@ def im_get_format(filepath):
         return None
 
 
-def pil_get_format(filepath):
+def pil_get_format(artresizer, filepath):
     from PIL import Image, UnidentifiedImageError
 
     try:
-        with Image.open(util.syspath(filepath)) as im:
+        with Image.open(syspath(filepath)) as im:
             return im.format
     except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError):
         log.exception("failed to detect image format for {}", filepath)
@@ -264,11 +270,11 @@ def pil_get_format(filepath):
 }
 
 
-def im_convert_format(source, target, deinterlaced):
-    cmd = ArtResizer.shared.im_convert_cmd + [
-        util.syspath(source),
+def im_convert_format(artresizer, source, target, deinterlaced):
+    cmd = artresizer.im_convert_cmd + [
+        syspath(source),
         *(["-interlace", "none"] if deinterlaced else []),
-        util.syspath(target),
+        syspath(target),
     ]
 
     try:
@@ -282,12 +288,12 @@ def im_convert_format(source, target, deinterlaced):
         return source
 
 
-def pil_convert_format(source, target, deinterlaced):
+def pil_convert_format(artresizer, source, target, deinterlaced):
     from PIL import Image, UnidentifiedImageError
 
     try:
-        with Image.open(util.syspath(source)) as im:
-            im.save(util.py3_path(target), progressive=not deinterlaced)
+        with Image.open(syspath(source)) as im:
+            im.save(py3_path(target), progressive=not deinterlaced)
             return target
     except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError,
             OSError):
@@ -301,6 +307,83 @@ def pil_convert_format(source, target, deinterlaced):
 }
 
 
+def im_compare(artresizer, im1, im2, compare_threshold):
+    is_windows = platform.system() == "Windows"
+
+    # Converting images to grayscale tends to minimize the weight
+    # of colors in the diff score. So we first convert both images
+    # to grayscale and then pipe them into the `compare` command.
+    # On Windows, ImageMagick doesn't support the magic \\?\ prefix
+    # on paths, so we pass `prefix=False` to `syspath`.
+    convert_cmd = artresizer.im_convert_cmd + [
+        syspath(im2, prefix=False), syspath(im1, prefix=False),
+        '-colorspace', 'gray', 'MIFF:-'
+    ]
+    compare_cmd = artresizer.im_compare_cmd + [
+        '-metric', 'PHASH', '-', 'null:',
+    ]
+    log.debug('comparing images with pipeline {} | {}',
+              convert_cmd, compare_cmd)
+    convert_proc = subprocess.Popen(
+        convert_cmd,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        close_fds=not is_windows,
+    )
+    compare_proc = subprocess.Popen(
+        compare_cmd,
+        stdin=convert_proc.stdout,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        close_fds=not is_windows,
+    )
+
+    # Check the convert output. We're not interested in the
+    # standard output; that gets piped to the next stage.
+    convert_proc.stdout.close()
+    convert_stderr = convert_proc.stderr.read()
+    convert_proc.stderr.close()
+    convert_proc.wait()
+    if convert_proc.returncode:
+        log.debug(
+            'ImageMagick convert failed with status {}: {!r}',
+            convert_proc.returncode,
+            convert_stderr,
+        )
+        return None
+
+    # Check the compare output.
+    stdout, stderr = compare_proc.communicate()
+    if compare_proc.returncode:
+        if compare_proc.returncode != 1:
+            log.debug('ImageMagick compare failed: {0}, {1}',
+                      displayable_path(im2), displayable_path(im1))
+            return None
+        out_str = stderr
+    else:
+        out_str = stdout
+
+    try:
+        phash_diff = float(out_str)
+    except ValueError:
+        log.debug('IM output is not a number: {0!r}', out_str)
+        return None
+
+    log.debug('ImageMagick compare score: {0}', phash_diff)
+    return phash_diff <= compare_threshold
+
+
+def pil_compare(artresizer, im1, im2, compare_threshold):
+    # It is an error to call this when ArtResizer.can_compare is not True.
+    raise NotImplementedError()
+
+
+BACKEND_COMPARE = {
+    PIL: pil_compare,
+    IMAGEMAGICK: im_compare,
+}
+
+
 class Shareable(type):
     """A pseudo-singleton metaclass that allows both shared and
     non-shared instances. The ``MyClass.shared`` property holds a
@@ -328,7 +411,6 @@ def __init__(self):
         """
         self.method = self._check_method()
         log.debug("artresizer: method is {0}", self.method)
-        self.can_compare = self._can_compare()
 
         # Use ImageMagick's magick binary when it's available. If it's
         # not, fall back to the older, separate convert and identify
@@ -338,9 +420,11 @@ def __init__(self):
             if self.im_legacy:
                 self.im_convert_cmd = ['convert']
                 self.im_identify_cmd = ['identify']
+                self.im_compare_cmd = ['compare']
             else:
                 self.im_convert_cmd = ['magick']
                 self.im_identify_cmd = ['magick', 'identify']
+                self.im_compare_cmd = ['magick', 'compare']
 
     def resize(
         self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
@@ -352,7 +436,7 @@ def resize(
         """
         if self.local:
             func = BACKEND_FUNCS[self.method[0]]
-            return func(maxwidth, path_in, path_out,
+            return func(self, maxwidth, path_in, path_out,
                         quality=quality, max_filesize=max_filesize)
         else:
             return path_in
@@ -360,7 +444,7 @@ def resize(
     def deinterlace(self, path_in, path_out=None):
         if self.local:
             func = DEINTERLACE_FUNCS[self.method[0]]
-            return func(path_in, path_out)
+            return func(self, path_in, path_out)
         else:
             return path_in
 
@@ -389,7 +473,8 @@ def get_size(self, path_in):
         """
         if self.local:
             func = BACKEND_GET_SIZE[self.method[0]]
-            return func(path_in)
+            return func(self, path_in)
+        return None
 
     def get_format(self, path_in):
         """Returns the format of the image as a string.
@@ -398,7 +483,8 @@ def get_format(self, path_in):
         """
         if self.local:
             func = BACKEND_GET_FORMAT[self.method[0]]
-            return func(path_in)
+            return func(self, path_in)
+        return None
 
     def reformat(self, path_in, new_format, deinterlaced=True):
         """Converts image to desired format, updating its extension, but
@@ -423,17 +509,28 @@ def reformat(self, path_in, new_format, deinterlaced=True):
         # file path was removed
         result_path = path_in
         try:
-            result_path = func(path_in, path_new, deinterlaced)
+            result_path = func(self, path_in, path_new, deinterlaced)
         finally:
             if result_path != path_in:
                 os.unlink(path_in)
         return result_path
 
-    def _can_compare(self):
+    @property
+    def can_compare(self):
         """A boolean indicating whether image comparison is available"""
 
         return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7)
 
+    def compare(self, im1, im2, compare_threshold):
+        """Return a boolean indicating whether two images are similar.
+
+        Only available locally.
+        """
+        if self.local:
+            func = BACKEND_COMPARE[self.method[0]]
+            return func(self, im1, im2, compare_threshold)
+        return None
+
     @staticmethod
     def _check_method():
         """Return a tuple indicating an available method and its version.


Test patch:
diff --git a/test/test_art_resize.py b/test/test_art_resize.py
--- a/test/test_art_resize.py
+++ b/test/test_art_resize.py
@@ -50,6 +50,7 @@ def _test_img_resize(self, resize_func):
         """Test resizing based on file size, given a resize_func."""
         # Check quality setting unaffected by new parameter
         im_95_qual = resize_func(
+            ArtResizer.shared,
             225,
             self.IMG_225x225,
             quality=95,
@@ -60,6 +61,7 @@ def _test_img_resize(self, resize_func):
 
         # Attempt a lower filesize with same quality
         im_a = resize_func(
+            ArtResizer.shared,
             225,
             self.IMG_225x225,
             quality=95,
@@ -72,6 +74,7 @@ def _test_img_resize(self, resize_func):
 
         # Attempt with lower initial quality
         im_75_qual = resize_func(
+            ArtResizer.shared,
             225,
             self.IMG_225x225,
             quality=75,
@@ -80,6 +83,7 @@ def _test_img_resize(self, resize_func):
         self.assertExists(im_75_qual)
 
         im_b = resize_func(
+            ArtResizer.shared,
             225,
             self.IMG_225x225,
             quality=95,
@@ -107,7 +111,7 @@ def test_pil_file_deinterlace(self):
         Check if pil_deinterlace function returns images
         that are non-progressive
         """
-        path = pil_deinterlace(self.IMG_225x225)
+        path = pil_deinterlace(ArtResizer.shared, self.IMG_225x225)
         from PIL import Image
         with Image.open(path) as img:
             self.assertFalse('progression' in img.info)
@@ -119,7 +123,7 @@ def test_im_file_deinterlace(self):
         Check if im_deinterlace function returns images
         that are non-progressive.
         """
-        path = im_deinterlace(self.IMG_225x225)
+        path = im_deinterlace(ArtResizer.shared, self.IMG_225x225)
         cmd = ArtResizer.shared.im_identify_cmd + [
             '-format', '%[interlace]', syspath(path, prefix=False),
         ]
diff --git a/test/test_embedart.py b/test/test_embedart.py
--- a/test/test_embedart.py
+++ b/test/test_embedart.py
@@ -24,7 +24,7 @@
 
 from mediafile import MediaFile
 from beets import config, logging, ui
-from beets.util import syspath, displayable_path
+from beets.util import artresizer, syspath, displayable_path
 from beets.util.artresizer import ArtResizer
 from beets import art
 
@@ -216,16 +216,31 @@ def test_clear_art_with_no_input(self):
         self.assertEqual(mediafile.images[0].data, self.image_data)
 
 
-@patch('beets.art.subprocess')
+class DummyArtResizer(ArtResizer):
+    """An `ArtResizer` which pretends that ImageMagick is available, and has
+    a sufficiently recent version to support image comparison.
+    """
+    @staticmethod
+    def _check_method():
+        return artresizer.IMAGEMAGICK, (7, 0, 0), True
+
+
+@patch('beets.util.artresizer.subprocess')
 @patch('beets.art.extract')
 class ArtSimilarityTest(unittest.TestCase):
     def setUp(self):
         self.item = _common.item()
         self.log = logging.getLogger('beets.embedart')
+        self.artresizer = DummyArtResizer()
 
     def _similarity(self, threshold):
-        return art.check_art_similarity(self.log, self.item, b'path',
-                                        threshold)
+        return art.check_art_similarity(
+            self.log,
+            self.item,
+            b'path',
+            threshold,
+            artresizer=self.artresizer,
+        )
 
     def _popen(self, status=0, stdout="", stderr=""):
         """Create a mock `Popen` object."""