···11+# Test data (downloaded images for benchmarking)
22+testdata/
33+44+# Go build artifacts
55+*.exe
66+*.exe~
77+*.dll
88+*.so
99+*.dylib
1010+1111+# Go test cache
1212+*.test
1313+*.out
1414+1515+# Binaries
1616+/pdqhasher
1717+/helper
1818+1919+# IDE
2020+.idea/
2121+.vscode/
2222+*.swp
2323+*.swo
2424+*~
2525+2626+# OS
2727+.DS_Store
2828+Thumbs.db
+21
LICENSE
···11+MIT License
22+33+Copyright (c) 2026 me@haileyok.com
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+132
README.md
···11+# gopdq
22+33+A Go implementation of [Meta's PDQ](https://github.com/facebook/ThreatExchange/tree/main/pdq) perceptual hashing algorithm.
44+55+PDQ is a perceptual hashing algorithm designed to identify visually similar images. It generates a compact 256-bit hash that remains stable across common image transformations like resizing, compression, and minor edits.
66+77+88+## Installation
99+1010+```bash
1111+go get github.com/haileyok/gopdq
1212+```
1313+1414+## Usage
1515+1616+There are two different functions provided in this package: `HashFromFile` and `HashFromImage`. While either will work, you should ensure that the input image has been resized to a size no greater than 512x512. See
1717+[the PDQ paper](https://github.com/facebook/ThreatExchange/blob/main/hashing/hashing.pdf).
1818+1919+> Using two-pass Jarosz filters (i.e. tent convolutions), compute a weighted average of 64x64 subblocks of
2020+the luminance image. (This is prohibitively time-consuming for megapixel input so we recommend using an
2121+off-the-shelf technique to first resize to 512x512 before converting from RGB to luminance.)
2222+2323+For conveneicne, there is a helper method `helpers.ResizeIfNeeded(img image.Image)` which will return a resized `image.Image` that can be passed to `HashFromImage`.
2424+2525+2626+```go
2727+package main
2828+2929+import (
3030+ "fmt"
3131+ "log"
3232+3333+ "github.com/haileyok/gopdq"
3434+)
3535+3636+func main() {
3737+ // Hash an image file, assuming it has already been resized.
3838+ // NOTE: There is no logic that _guarantees_ an image has been resized, this is up to you to ensure.
3939+ result, err := pdq.HashFromFile("image.jpg")
4040+ if err != nil {
4141+ log.Fatal(err)
4242+ }
4343+4444+ fmt.Printf("Hash: %s\n", result.Hash)
4545+ fmt.Printf("Quality: %d\n", result.Quality)
4646+}
4747+```
4848+4949+### Using with pre-loaded images
5050+5151+```go
5252+import (
5353+ "image"
5454+ _ "image/jpeg"
5555+5656+ "github.com/haileyok/gopdq"
5757+ "github.com/haileyok/gopdq/helpers"
5858+)
5959+6060+func main() {
6161+ // Open the image and decode it
6262+ file, _ := os.Open("image.jpg")
6363+ img, _, _ := image.Decode(file)
6464+6565+ // Resize if needed
6666+ img = helpers.ResizeIfNeeded(img)
6767+6868+ // Generate hash
6969+ result, _ := pdq.HashFromImage(img)
7070+ fmt.Println(result.Hash)
7171+}
7272+```
7373+7474+### HashResult
7575+7676+Both of the above functions will return a `HashResult`, which includes both the hash and the quality score.
7777+7878+```go
7979+type HashResult struct {
8080+ Hash string
8181+ Quality int // Results with a quality score < 50 should be discarded
8282+ ImageHeightTimesWidth int
8383+ HashDuration time.Duration
8484+}
8585+```
8686+8787+## Command Line Tools
8888+8989+### PDQ Hasher
9090+9191+```bash
9292+# Build the hasher
9393+go build ./cmd/pdqhasher
9494+9595+# Hash an image
9696+./pdqhasher path/to/image.jpg
9797+9898+# Output:
9999+# Hash: e77b19ca5399466258c656bc4666a7853939a567a9193939e667199856ccc6c6
100100+# Quality: 100
101101+# Binary: 1110011110110001000110011010010100110011100110010100011001100010...
102102+```
103103+104104+### Hamming Distance Helper
105105+106106+```bash
107107+# Build the helper
108108+go build ./cmd/helper
109109+110110+# Calculate hamming distance
111111+./helper hamming <hash1> <hash2>
112112+113113+# Output:
114114+# 8
115115+```
116116+117117+## About Distance
118118+119119+Please see https://github.com/facebook/ThreatExchange/tree/main/pdq#matching
120120+121121+Note that outputs from the C++ implementation's example binary and the `pdqhasher` binary provided here may not return hashes that are exactly the same due to
122122+differences in resizing libraries. This is expected, see https://github.com/facebook/ThreatExchange/tree/main/pdq#hashing.
123123+124124+## References
125125+126126+- [PDQ Algorithm (C++ Reference)](https://github.com/facebook/ThreatExchange/tree/main/pdq)
127127+- [PDQ Hashing Paper](https://github.com/facebook/ThreatExchange/blob/main/hashing/hashing.pdf)
128128+- [ThreatExchange](https://github.com/facebook/ThreatExchange)
129129+130130+## Acknowledgments
131131+132132+This is a Go implementation of Meta's PDQ algorithm. All credit for the algorithm design goes to the original authors.
···11+package helpers
22+33+import (
44+ "encoding/hex"
55+ "fmt"
66+ "math/bits"
77+)
88+99+// Converts a 64-character hexidecimal PDQ hash into a 256-character binary string
1010+// representation, useful for inserting into some vector stores.
1111+func PdqHashToBinary(input string) (string, error) {
1212+ hashb, err := hex.DecodeString(input)
1313+ if err != nil {
1414+ return "", err
1515+ }
1616+1717+ result := make([]byte, len(hashb)*8)
1818+ for i, b := range hashb {
1919+ for j := 7; j >= 0; j-- {
2020+ if (b>>j)&1 == 1 {
2121+ result[i*8+(7-j)] = '1'
2222+ } else {
2323+ result[i*8+(7-j)] = '0'
2424+ }
2525+ }
2626+ }
2727+2828+ return string(result), nil
2929+}
3030+3131+// Calculate the hamming distance between two PDQ hashes. Input hashes should be 64-character
3232+// hexidecimal strings. Returns a value between 0 (identical) and 256 (completely different).
3333+func HammingDistance(hashOne, hashTwo string) (int, error) {
3434+ bytes1, err := hex.DecodeString(hashOne)
3535+ if err != nil {
3636+ return 0, fmt.Errorf("invalid hash1: %w", err)
3737+ }
3838+3939+ bytes2, err := hex.DecodeString(hashTwo)
4040+ if err != nil {
4141+ return 0, fmt.Errorf("invalid hash2: %w", err)
4242+ }
4343+4444+ if len(bytes1) != 32 {
4545+ return 0, fmt.Errorf("first hash has invalid length: expected 32 bytes, got %d", len(bytes1))
4646+ }
4747+ if len(bytes2) != 32 {
4848+ return 0, fmt.Errorf("second hash has invalid length: expected 32 bytes, got %d", len(bytes2))
4949+ }
5050+5151+ distance := 0
5252+ for i := range 32 {
5353+ xor := bytes1[i] ^ bytes2[i]
5454+ distance += bits.OnesCount8(xor)
5555+ }
5656+5757+ return distance, nil
5858+}
+29
helpers/resize.go
···11+package helpers
22+33+import (
44+ "image"
55+ _ "image/gif"
66+ _ "image/jpeg"
77+ _ "image/png"
88+99+ pdq "github.com/haileyok/gopdq"
1010+ "github.com/nfnt/resize"
1111+ _ "golang.org/x/image/bmp"
1212+ _ "golang.org/x/image/tiff"
1313+ _ "golang.org/x/image/webp"
1414+)
1515+1616+func ResizeIfNeeded(img image.Image) image.Image {
1717+ size := img.Bounds().Size()
1818+1919+ if size.X > pdq.DownsampleDims || size.Y > pdq.DownsampleDims {
2020+ // SEE: https://github.com/facebook/ThreatExchange/blob/main/pdq/cpp/io/pdqio.cpp#L103
2121+ // we use NearestNeighbor here as the PDQ uses that algo as well (unspecified parameter
2222+ // which defaults to nearest neighbor, see https://cimg.eu/reference/structcimg__library_1_1CImg.html)
2323+ // even still, because the two libraries have different implementations, we'll still see
2424+ // minor differences in output. that is expected. see "More on Downsampling" in hashing.pdf
2525+ return resize.Resize(pdq.DownsampleDims, pdq.DownsampleDims, img, resize.NearestNeighbor)
2626+ }
2727+2828+ return img
2929+}
+344
pdq.go
···11+// Reimplementation of https://github.com/facebook/ThreatExchange/blob/main/pdq in Golang
22+//
33+// For reference, please see https://github.com/facebook/ThreatExchange/blob/main/hashing/hashing.pdf
44+//
55+// Function names are similar or the same as those in the reference C++ implementation, and
66+// any questions about implementation should reference that code.
77+88+package pdq
99+1010+import (
1111+ "errors"
1212+ "fmt"
1313+ "image"
1414+ _ "image/gif"
1515+ _ "image/jpeg"
1616+ _ "image/png"
1717+ "math"
1818+ "os"
1919+ "time"
2020+2121+ _ "golang.org/x/image/bmp"
2222+ _ "golang.org/x/image/tiff"
2323+ _ "golang.org/x/image/webp"
2424+)
2525+2626+// HashResult contains the output of a PDQ hash operation
2727+type HashResult struct {
2828+ Hash string
2929+ Quality int
3030+ ImageHeightTimesWidth int
3131+ HashDuration time.Duration
3232+}
3333+3434+// Various constants pulled from the reference implementation
3535+const (
3636+ LumaFromRCoeff = 0.299
3737+ LumaFromGCoeff = 0.587
3838+ LumaFromBCoeff = 0.114
3939+4040+ PdqNumJaroszXYPasses = 2
4141+4242+ DownsampleDims = 512
4343+4444+ MinHashableDim = 5
4545+)
4646+4747+var (
4848+ ErrInvalidFile = errors.New("invalid input file name")
4949+)
5050+5151+// SEE: https://github.com/facebook/ThreatExchange/blob/main/pdq/cpp/hashing/pdqhashing.cpp#L42
5252+var dctMatrix64 []float32
5353+5454+func init() {
5555+ const numRows = 16
5656+ const numCols = 64
5757+5858+ matrixScaleFactor := math.Sqrt(2.0 / float64(numCols))
5959+6060+ dctMatrix64 = make([]float32, numRows*numCols)
6161+6262+ for i := range numRows {
6363+ for j := range numCols {
6464+ dctMatrix64[i*numCols+j] = float32(matrixScaleFactor * math.Cos((math.Pi/2.0/float64(numCols))*float64(i+1)*float64(2*j+1)))
6565+ }
6666+ }
6767+}
6868+6969+// HashFromImage generates a PDQ hash from an image.Image
7070+// The image should idealy be pre-resizes to 512x512 or smaller for performance reasons.
7171+// SEE: https://github.com/facebook/ThreatExchange/blob/main/hashing/hashing.pdf, "More on Downsampling"
7272+// Returns a HashResult containing the hash and a quality score between 0 and 100.
7373+// Please reference the evaluation data for selecting a good quality score. From hashing.pdf:
7474+// "Confident-match distances are up to the system designer, of course, but 30, 20, or less has been found to
7575+// produce good results on evaluation data."
7676+func HashFromImage(img image.Image) (*HashResult, error) {
7777+ bounds := img.Bounds()
7878+ size := bounds.Size()
7979+8080+ imageHeightTimesWidth := size.Y * size.X
8181+8282+ luma, numRows, numCols := loadFloatLumaFromImage(img)
8383+8484+ fullBuffer2 := make([]float32, numRows*numCols)
8585+8686+ hashStart := time.Now()
8787+ hash, quality := hash256FromFloatLuma(luma, fullBuffer2, numRows, numCols)
8888+ hashTime := time.Since(hashStart)
8989+9090+ return &HashResult{
9191+ Hash: hash,
9292+ Quality: quality,
9393+ ImageHeightTimesWidth: imageHeightTimesWidth,
9494+ HashDuration: hashTime,
9595+ }, nil
9696+}
9797+9898+// Opens a file at the specified file and uses image.Image to decode the image. Returns the result of
9999+// HashFromImage. This is a convenience wrapper around HashFromImage that handles the IO and decoding for you.
100100+// Ideally, you should call HashFromImage on your own with a 512x512 or smaller image that you have resized
101101+// yourself. This function is provided only to match the reference implementation.
102102+func HashFromFile(filename string) (*HashResult, error) {
103103+ if filename == "" {
104104+ return nil, ErrInvalidFile
105105+ }
106106+107107+ file, err := os.Open(filename)
108108+ if err != nil {
109109+ return nil, fmt.Errorf("failed to open file for hashing: %w", err)
110110+ }
111111+ defer file.Close()
112112+113113+ img, _, err := image.Decode(file)
114114+ if err != nil {
115115+ return nil, fmt.Errorf("failed to decode image: %w", err)
116116+ }
117117+118118+ return HashFromImage(img)
119119+}
120120+121121+func loadFloatLumaFromImage(img image.Image) ([]float32, int, int) {
122122+ bounds := img.Bounds()
123123+ numRows := bounds.Dy()
124124+ numCols := bounds.Dx()
125125+ luma := make([]float32, numRows*numCols)
126126+127127+ for row := range numRows {
128128+ for col := range numCols {
129129+ // purposefully discarding alpha
130130+ r, g, b, _ := img.At(bounds.Min.X+col, bounds.Min.Y+row).RGBA()
131131+132132+ r8 := float32(r >> 8)
133133+ g8 := float32(g >> 8)
134134+ b8 := float32(b >> 8)
135135+136136+ luma[row*numCols+col] = LumaFromRCoeff*r8 + LumaFromGCoeff*g8 + LumaFromBCoeff*b8
137137+ }
138138+ }
139139+140140+ return luma, numRows, numCols
141141+}
142142+143143+// SEE: https://github.com/facebook/ThreatExchange/blob/main/pdq/cpp/hashing/pdqhashing.cpp#L127
144144+func hash256FromFloatLuma(
145145+ fullBuffer1 []float32,
146146+ fullBuffer2 []float32,
147147+ numRows, numCols int,
148148+) (string, int) {
149149+ // from reference impl, do not return a hash for images taht are too small
150150+ if numRows < MinHashableDim || numCols < MinHashableDim {
151151+ return "", 0
152152+ }
153153+154154+ buffer64x64 := make([]float32, 64*64)
155155+ buffer16x64 := make([]float32, 16*64)
156156+ buffer16x16 := make([]float32, 16*16)
157157+158158+ quality := float256FromFloatLuma(fullBuffer1, fullBuffer2, numRows, numCols, buffer64x64, buffer16x64, buffer16x16)
159159+160160+ hash := convertBufferToHash(buffer16x16)
161161+162162+ return hash, quality
163163+}
164164+165165+const hexChars = "0123456789abcdef"
166166+167167+func convertBufferToHash(buffer16x16 []float32) string {
168168+ median := torben(buffer16x16)
169169+170170+ words := make([]uint16, 16)
171171+172172+ for i := range 16 {
173173+ for j := range 16 {
174174+ if buffer16x16[i*16+j] > median {
175175+ bitIndex := i*16 + j
176176+ wordIndex := bitIndex / 16
177177+ bitInWord := bitIndex % 16
178178+ words[wordIndex] |= 1 << bitInWord
179179+ }
180180+ }
181181+ }
182182+183183+ result := make([]byte, 64)
184184+ for i := range 16 {
185185+ word := words[15-i]
186186+ offset := i * 4
187187+ result[offset+0] = hexChars[word>>12]
188188+ result[offset+1] = hexChars[(word>>8)&0xF]
189189+ result[offset+2] = hexChars[(word>>4)&0xF]
190190+ result[offset+3] = hexChars[word&0xF]
191191+ }
192192+193193+ return string(result)
194194+}
195195+196196+// SEE: https://github.com/facebook/ThreatExchange/blob/main/pdq/cpp/hashing/pdqhashing.cpp#L158
197197+func float256FromFloatLuma(
198198+ fullBuffer1 []float32,
199199+ fullBuffer2 []float32,
200200+ numRows, numCols int,
201201+ buffer64x64 []float32,
202202+ buffer16x64 []float32,
203203+ buffer16x16 []float32,
204204+) int {
205205+ if numRows == 64 && numCols == 64 {
206206+ copy(buffer64x64, fullBuffer1)
207207+ } else {
208208+ windowSizeAlongRows := computeJaroszFilterWindowSize(numCols, 64)
209209+ windowSizeAlongCols := computeJaroszFilterWindowSize(numRows, 64)
210210+211211+ jaroszFilterFloat(fullBuffer1, fullBuffer2, numRows, numCols, windowSizeAlongRows, windowSizeAlongCols, PdqNumJaroszXYPasses)
212212+213213+ decimateFloat(fullBuffer1, numRows, numCols, buffer64x64, 64, 64)
214214+ }
215215+216216+ quality := imageDomainQualityMetric(buffer64x64)
217217+218218+ dct64To16(buffer64x64, buffer16x64, buffer16x16)
219219+220220+ return quality
221221+}
222222+223223+// SEE: https://github.com/facebook/ThreatExchange/blob/main/pdq/cpp/hashing/pdqhashing.cpp#L318
224224+func imageDomainQualityMetric(buffer64x64 []float32) int {
225225+ gradientSum := 0
226226+227227+ for i := range 63 {
228228+ for j := range 64 {
229229+ u := buffer64x64[i*64+j]
230230+ v := buffer64x64[(i+1)*64+j]
231231+ d := int(math.Abs(float64((u - v) * 100 / 255)))
232232+ gradientSum += d
233233+ }
234234+ }
235235+236236+ for i := range 64 {
237237+ for j := range 63 {
238238+ u := buffer64x64[i*64+j]
239239+ v := buffer64x64[i*64+j+1]
240240+ d := int(math.Abs(float64((u - v) * 100 / 255)))
241241+ gradientSum += d
242242+ }
243243+ }
244244+245245+ quality := min(gradientSum/90, 100)
246246+247247+ return quality
248248+}
249249+250250+// SEE: https://github.com/facebook/ThreatExchange/blob/main/pdq/cpp/hashing/pdqhashing.cpp#L355
251251+func dct64To16(A []float32, T []float32, B []float32) {
252252+ for i := range 16 {
253253+ dctRow := dctMatrix64[i*64:]
254254+ for j := range 64 {
255255+ var sum0, sum1, sum2, sum3 float32
256256+257257+ for k := 0; k < 64; k += 4 {
258258+ sum0 += dctRow[k] * A[k*64+j]
259259+ sum1 += dctRow[k+1] * A[(k+1)*64+j]
260260+ sum2 += dctRow[k+2] * A[(k+2)*64+j]
261261+ sum3 += dctRow[k+3] * A[(k+3)*64+j]
262262+ }
263263+264264+ T[i*64+j] = sum0 + sum1 + sum2 + sum3
265265+ }
266266+ }
267267+268268+ for i := range 16 {
269269+ tRow := T[i*64:]
270270+ for j := range 16 {
271271+ dctRow := dctMatrix64[j*64:]
272272+ var sum0, sum1, sum2, sum3 float32
273273+274274+ for k := 0; k < 64; k += 4 {
275275+ sum0 += tRow[k] * dctRow[k]
276276+ sum1 += tRow[k+1] * dctRow[k+1]
277277+ sum2 += tRow[k+2] * dctRow[k+2]
278278+ sum3 += tRow[k+3] * dctRow[k+3]
279279+ }
280280+281281+ B[i*16+j] = sum0 + sum1 + sum2 + sum3
282282+ }
283283+ }
284284+}
285285+286286+// SEE: https://github.com/facebook/ThreatExchange/blob/main/pdq/cpp/hashing/torben.cpp
287287+func torben(m []float32) float32 {
288288+ n := len(m)
289289+ if n == 0 {
290290+ return 0
291291+ }
292292+293293+ min, max := m[0], m[0]
294294+ for i := 1; i < n; i++ {
295295+ if m[i] < min {
296296+ min = m[i]
297297+ }
298298+ if m[i] > max {
299299+ max = m[i]
300300+ }
301301+ }
302302+303303+ var guess, maxltguess, mingtguess float32
304304+ var less, greater, equal int
305305+306306+ for {
307307+ guess = (min + max) / 2
308308+ less, greater, equal = 0, 0, 0
309309+ maxltguess = min
310310+ mingtguess = max
311311+312312+ for i := range n {
313313+ if m[i] < guess {
314314+ less++
315315+ if m[i] > maxltguess {
316316+ maxltguess = m[i]
317317+ }
318318+ } else if m[i] > guess {
319319+ greater++
320320+ if m[i] < mingtguess {
321321+ mingtguess = m[i]
322322+ }
323323+ } else {
324324+ equal++
325325+ }
326326+ }
327327+328328+ if less <= (n+1)/2 && greater <= (n+1)/2 {
329329+ break
330330+ } else if less > greater {
331331+ max = maxltguess
332332+ } else {
333333+ min = mingtguess
334334+ }
335335+ }
336336+337337+ if less >= (n+1)/2 {
338338+ return maxltguess
339339+ } else if less+equal >= (n+1)/2 {
340340+ return guess
341341+ } else {
342342+ return mingtguess
343343+ }
344344+}
+46
setup_testdata.py
···11+#!/usr/bin/env python3
22+33+import os
44+import sys
55+import urllib.request
66+from pathlib import Path
77+import time
88+99+TESTDATA_DIR = "testdata/images"
1010+1111+1212+def download_images(num_images=50):
1313+ """Download test images of various sizes"""
1414+1515+ Path(TESTDATA_DIR).mkdir(parents=True, exist_ok=True)
1616+1717+ print(f"Downloading {num_images} images from Lorem Picsum...")
1818+ print()
1919+2020+ for i in range(1, num_images + 1):
2121+ if i % 3 == 0:
2222+ width, height = 400, 300
2323+ elif i % 3 == 1:
2424+ width, height = 800, 600
2525+ else:
2626+ width, height = 1920, 1080
2727+2828+ url = f"https://picsum.photos/{width}/{height}?random={i}"
2929+ output_path = os.path.join(TESTDATA_DIR, f"test_image_{i}.jpg")
3030+3131+ try:
3232+ print(f"Downloading image {i}/{num_images} ({width}x{height})...", end=" ")
3333+ urllib.request.urlretrieve(url, output_path)
3434+ time.sleep(0.1)
3535+3636+ except Exception as e:
3737+ print(f"Failed to download image: {e}")
3838+3939+ print()
4040+ print("Setup complete!")
4141+ print(f"Downloaded images to: {TESTDATA_DIR}")
4242+4343+4444+if __name__ == "__main__":
4545+ num_images = int(sys.argv[1]) if len(sys.argv) > 1 else 50
4646+ download_images(num_images)