diff --git a/.gitignore b/.gitignore index 198fc00..0129f76 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,17 @@ # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ + +*~ +*.pyc +env +*.jpg +build +dist +ImageHash.egg-info/ +.eggs +.DS_Store +.python-version +.coverage + +tmp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a2a62c2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,52 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=.gitignore] + - id: end-of-file-fixer + - id: check-yaml + - id: debug-statements + - id: double-quote-string-fixer + - id: name-tests-test + - id: requirements-txt-fixer +- repo: https://github.com/tekwizely/pre-commit-golang + rev: v1.0.0-rc.1 + hooks: + - id: go-mod-tidy + - id: go-imports + args: [-w] +- repo: https://github.com/golangci/golangci-lint + rev: v1.53.3 + hooks: + - id: golangci-lint +- repo: https://github.com/asottile/setup-cfg-fmt + rev: v2.5.0 + hooks: + - id: setup-cfg-fmt +- repo: https://github.com/asottile/reorder-python-imports + rev: v3.12.0 + hooks: + - id: reorder-python-imports + args: [--py38-plus, --add-import, 'from __future__ import annotations'] +- repo: https://github.com/asottile/add-trailing-comma + rev: v3.1.0 + hooks: + - id: add-trailing-comma +- repo: https://github.com/asottile/dead + rev: v1.5.2 + hooks: + - id: dead +- repo: https://github.com/asottile/pyupgrade + rev: v3.15.2 + hooks: + - id: pyupgrade + args: [--py38-plus] +- repo: https://github.com/hhatto/autopep8 + rev: v2.1.0 + hooks: + - id: autopep8 +- repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 diff --git a/_examples/images/example.png b/_examples/images/example.png new file mode 100644 index 0000000..c4b9bdf Binary files /dev/null and b/_examples/images/example.png differ diff --git a/_examples/sample1.jpg b/_examples/images/sample1.jpg similarity index 100% rename from _examples/sample1.jpg rename to _examples/images/sample1.jpg diff --git a/_examples/sample2.jpg b/_examples/images/sample2.jpg similarity index 100% rename from _examples/sample2.jpg rename to _examples/images/sample2.jpg diff --git a/_examples/sample3.jpg b/_examples/images/sample3.jpg similarity index 100% rename from _examples/sample3.jpg rename to _examples/images/sample3.jpg diff --git a/_examples/sample4.jpg b/_examples/images/sample4.jpg similarity index 100% rename from _examples/sample4.jpg rename to _examples/images/sample4.jpg diff --git a/go.mod b/go.mod index 6985773..e458019 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,19 @@ module github.com/corona10/goimagehash -go 1.20 +go 1.21 -require github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 +toolchain go1.22.0 require ( - github.com/anthonynsimon/bild v0.13.0 // indirect - golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + github.com/anthonynsimon/bild v0.13.0 + github.com/gen2brain/avif v0.3.1 + github.com/spakin/netpbm v1.3.0 + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 + golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 +) + +require ( + github.com/ebitengine/purego v0.7.1 // indirect + github.com/tetratelabs/wazero v1.7.1 // indirect + golang.org/x/sys v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index 6d5bf68..d961400 100644 --- a/go.sum +++ b/go.sum @@ -7,17 +7,21 @@ github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8Nz github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA= +github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gen2brain/avif v0.3.1 h1:womS2LKvhS/dSR3zIKUxtJW+riGlY48akGWqc+YgHtE= +github.com/gen2brain/avif v0.3.1/go.mod h1:s9sI2zo2cF6EdyRVCtnIfwL/Qb3k0TkOIEsz6ovK1ms= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/spakin/netpbm v1.3.0 h1:eDX7VvrkN5sHXW0luZXRA4AKDlLmu0E5sNxJ7VSTwxc= +github.com/spakin/netpbm v1.3.0/go.mod h1:Q+ep6vNv1G44qSWp0wt3Y9o1m/QXjmaXZIFC0PMVpq0= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= @@ -25,13 +29,18 @@ github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb6 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/tetratelabs/wazero v1.7.1 h1:QtSfd6KLc41DIMpDYlJdoMc6k7QTN246DM2+n2Y/Dx8= +github.com/tetratelabs/wazero v1.7.1/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 h1:uc17S921SPw5F2gJo7slQ3aqvr2RwpL7eb3+DZncu3s= golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/hashImage/__init__.py b/hashImage/__init__.py new file mode 100644 index 0000000..b09bd68 --- /dev/null +++ b/hashImage/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .hashImage import main + +__all__ = ('main',) diff --git a/hashImage/__main__.py b/hashImage/__main__.py new file mode 100644 index 0000000..ab64d5d --- /dev/null +++ b/hashImage/__main__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from . import hashImage + +print(hashImage.main()) diff --git a/hashImage/hashImage.py b/hashImage/hashImage.py new file mode 100644 index 0000000..a26bc46 --- /dev/null +++ b/hashImage/hashImage.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import argparse + +import imagehash +from PIL import Image + + +def bin_str(hash): + return ''.join(str(b) for b in 1 * hash.hash.flatten()) + + +def main(args: list[str] | None = None) -> str: + ap = argparse.ArgumentParser() + ap.add_argument('-file', required=True) + opts = ap.parse_args(args) + im = Image.open(opts.file) + + return ( + f'ahash: {bin_str(imagehash.average_hash(im))}\n' + f'dhash: {bin_str(imagehash.dhash(im))}\n' + f'phash: {bin_str(imagehash.phash(im))}' + ) + + +if __name__ == '__main__': + print(main()) diff --git a/hashImage/main.go b/hashImage/main.go new file mode 100644 index 0000000..348cce2 --- /dev/null +++ b/hashImage/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "flag" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log" + "os" + + _ "github.com/spakin/netpbm" + + "github.com/corona10/goimagehash" + _ "github.com/gen2brain/avif" + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" +) + +func main() { + imPath := flag.String("file", "", "image file to hash") + flag.Parse() + if imPath == nil || *imPath == "" { + flag.Usage() + os.Exit(1) + } + + file, err := os.Open(*imPath) + if err != nil { + log.Printf("Failed to open file %s: %s", *imPath, err) + os.Exit(1) + } + defer file.Close() + im, format, err := image.Decode(file) + if err != nil { + msg := fmt.Sprintf("Failed to decode Image: %s", err) + log.Println(msg) + os.Exit(1) + } + + if format == "webp" { + if ycbcr, ok := im.(*image.YCbCr); ok { + im = goimagehash.FancyUpscale(ycbcr) + } + } + + var ( + ahash *goimagehash.ImageHash + dhash *goimagehash.ImageHash + phash *goimagehash.ImageHash + ) + + ahash, err = goimagehash.AverageHash(im) + if err != nil { + msg := fmt.Sprintf("Failed to ahash Image: %s", err) + log.Println(msg) + os.Exit(1) + } + dhash, err = goimagehash.DifferenceHash(im) + if err != nil { + msg := fmt.Sprintf("Failed to dhash Image: %s", err) + log.Println(msg) + os.Exit(1) + } + phash, err = goimagehash.PerceptionHash(im) + if err != nil { + msg := fmt.Sprintf("Failed to phash Image: %s", err) + log.Println(msg) + os.Exit(1) + } + + fmt.Printf("ahash: %s\n", ahash.BinString()) + fmt.Printf("dhash: %s\n", dhash.BinString()) + fmt.Printf("phash: %s\n", phash.BinString()) +} diff --git a/imagehash.go b/imagehash.go index 6af7655..4f8e73f 100644 --- a/imagehash.go +++ b/imagehash.go @@ -142,23 +142,13 @@ func ImageHashFromString(s string) (*ImageHash, error) { } // String returns a hex representation of the hash -func (h *ImageHash) String(bin bool) string { - strFmt := strFmtHex - if bin { - strFmt = strFmtBin - } - kindStr := "" - switch h.kind { - case AHash: - kindStr = "a" - case PHash: - kindStr = "p" - case DHash: - kindStr = "d" - case WHash: - kindStr = "w" - } - return fmt.Sprintf(strFmt, kindStr, h.hash) +func (h *ImageHash) String() string { + return fmt.Sprintf("%016x", h.hash) +} + +// String returns a binary representation of the hash +func (h *ImageHash) BinString() string { + return fmt.Sprintf("%064b", h.hash) } // NewExtImageHash function creates a new big hash @@ -166,7 +156,7 @@ func NewExtImageHash(hash []uint64, kind Kind, bits int) *ExtImageHash { return &ExtImageHash{hash: hash, kind: kind, bits: bits} } -// Bits method returns an actual hash bit size +// Bits method returns the hash bit size func (h *ExtImageHash) Bits() int { return h.bits } diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2c3623c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,20 @@ +[metadata] +name = hashImage +long_description = file: README.md +long_description_content_type = text/markdown +license = BSD-2-Clause +license_files = LICENSE +classifiers = + License :: OSI Approved :: BSD License + +[testenv] +description = Test go imagehash +deps = + pytest>=7 + imagehash +commands = + python -m pytest {posargs:.} + +[flake8] +max-line-length = 120 +extend-ignore = E203, E501, A003, A005, T202, E701 diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..1fbbead --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import pathlib + +import _pytest.terminal +import pytest + +_pytest.terminal._color_for_type['incompatible'] = 'red' +_pytest.terminal._color_for_type['passable'] = 'yellow' +_pytest.terminal._color_for_type['compatible'] = 'green' +_pytest.terminal._color_for_type['exact'] = 'green' + + +@pytest.hookimpl +def pytest_report_teststatus(report, config): + """Customizes the reporting of test statuses.""" + if report.when != 'call': + return None + + if isinstance(report, pytest.CollectReport) or report.outcome in ('skipped'): + return None + + props = dict(report.user_properties) + ahash = props.get('ahash:', -1) + dhash = props.get('dhash:', -1) + phash = props.get('phash:', -1) + if ahash == dhash == phash == 0: + if report.outcome == 'failed': + return 'failed', 'E', ('EXACT', {'red': True}) + return 'exact', 'E', ('EXACT', {'green': True}) + if ahash >= 10 or dhash >= 10 or phash >= 10: + return 'incompatible', 'I', ('INCOMPATIBLE', {'red': True}) + if 4 < ahash < 10 or 4 < dhash < 10 or 4 < phash < 10: + return 'passable', 'P', ('PASSABLE', {'yellow': True}) + if ahash <= 4 or dhash <= 4 or phash <= 4: + return 'compatible', 'C', ('COMPATIBLE', {'green': True}) + + +def pytest_terminal_summary(terminalreporter, exitstatus, config): + incompatible = terminalreporter._get_reports_to_display('incompatible') + passable = terminalreporter._get_reports_to_display('passable') + compatible = terminalreporter._get_reports_to_display('compatible') + exact = terminalreporter._get_reports_to_display('exact') + total = len(incompatible) + len(passable) + len(compatible) + len(exact) + if total < 1: + return + incompatible_percent = int((len(incompatible)/total)*100) + passable_percent = int((len(passable)/total)*100) + compatible_percent = int((len(compatible)/total)*100) + exact_percent = int((len(exact)/total)*100) + final_percent = ((len(exact)+len(compatible))/total)*100 + + parts = ( + (f'incompatible: {incompatible_percent:02d}%', {'red': True}), + (f'passable: {passable_percent:02d}%', {'yellow': True}), + (f'compatible: {compatible_percent:02d}%', {'green': True}), + (f'exact: {exact_percent:02d}%', {'green': True}), + ( + f'final: {final_percent:02.2f}%', { + 'green' if final_percent > 80 else 'red': True, + }, + ), + ) + line_parts = [] + for text, markup in parts: + with_markup = terminalreporter._tw.markup(text, **markup) + line_parts.append(with_markup) + msg = ', '.join(line_parts) + terminalreporter.write_line(msg) + + +def pytest_addoption(parser): + parser.addoption( + '--hasher', default='go run ./hashImage -file {}', help="The commandline image hasher to test ('go run ./hashImage -file {}')", + ) + parser.addoption( + '--image-dir', default='_examples/images', help='The file path to a directory of images to test', + ) + parser.addoption( + '--fail-skip', action='store_true', help="skip images that can't be decoded", + ) + + +def pytest_generate_tests(metafunc): + if 'file' in metafunc.fixturenames: + file = metafunc.config.getoption('--image-dir') + files = [] + cwd = pathlib.Path('').absolute() + for p in pathlib.Path(file).iterdir(): + if not p.is_file(): + continue + p = p.absolute() + if p.is_relative_to(cwd): + files.append(str(p.relative_to(cwd))) + else: + files.append(str(p.absolute())) + metafunc.parametrize('file', files) diff --git a/test/hash_test.py b/test/hash_test.py new file mode 100644 index 0000000..2e53cf0 --- /dev/null +++ b/test/hash_test.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import shlex +import subprocess + +import pytest + +import hashImage + + +def hamming_distance(h1, h2) -> int: + if isinstance(h1, int): + n1 = h1 + else: + n1 = int(h1, 2) + + if isinstance(h2, int): + n2 = h2 + else: + n2 = int(h2, 2) + + # xor the two numbers + n = n1 ^ n2 + + # count up the 1's in the binary string + return sum(b == '1' for b in bin(n)[2:]) + + +def test_hash(request, file, record_property): + hasher = request.config.getoption('--hasher') + try: + python = hashImage.main(['-file', file]) + '\n' + except Exception: + pytest.skip('python imagehash failed') + return + + sh = shlex.shlex(hasher, punctuation_chars=True, posix=True) + sh.whitespace_split = True + cmd_list = list(sh) + for i, p in enumerate(cmd_list): + if p == '{}': + cmd_list[i] = file + + external = subprocess.run(cmd_list, shell=None, capture_output=True) + external_stdout = str(external.stdout, encoding='utf-8') + if external.returncode != 0: + pytest.skip(str(external.stderr)) + return + external_stdout_h = python_h = '' + for p, e in zip(python.split('\n'), external_stdout.splitlines()): + p_, e_ = p.split(' '), e.split(' ') + h = hamming_distance(p_[1], e_[1]) + record_property(p_[0], h) + external_stdout_h += ' '.join(e_) + f' {h}\n' + python_h += ' '.join(p_) + f' {h}\n' + + assert external_stdout_h == python_h