diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8675ad5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "deno.enable": true, + "deno.unstable": true +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c725acc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Claudiu Ceia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..57496ab --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# dhash + +``` +Perceptual hashing is the use of a fingerprinting algorithm that produces a +snippet or fingerprint of various forms of multimedia. +``` + +A `dhash` implementation for Deno. + +Based on the +["Kind of Like That"](https://www.hackerfactor.com/blog/?/archives/529-Kind-of-Like-That.html) +article by [Dr. Neal Krawetz](https://www.hackerfactor.com/about.php). + +## Usage + +You can compare dhash values by simply computing the Hamming distance between +them: + +- A distance of 0 represents an identical, or very similar image +- A distance greater than 10 means that you're most likely dealing with a + different image +- A distance between 1 and 10 may indicate that you're dealing with variations + of the same base image + +```ts +const [hash1, hash2] = await Promise.all([ + dhash("./tests/dalle.png"), + dhash("./tests/dalle-copyright.png"), +]); + +console.log(compare(hash1, hash2)); +``` + +There are also two functions that you may use to display the fingerprint in a +non-hash form: + +```ts +/** + * toAscii will return the fingerprint represented as a matrix of + * black/white pixels, represented by default through unicode low density + * and full blocks, ie: + * + * ██░░██████░░░░░░ + * ░░██░░░░░░░░░░██ + * ██░░░░░░████░░██ + * ░░████░░████░░██ + * ░░░░░░░░░░░░░░██ + * ████████░░░░░░░░ + * ░░░░░░██░░░░░░░░ + * ░░░░░░░░░░░░░░░░ + * / + toAscii(hash: string, chars: [string, string]): string + + /** + * save will render the fingerprint as an 8x8px PNG file with black and + * white pixels, at the specified path. + * + * async save(hash: string, file: string): Promise + */ +``` + +## License + +MIT © [Claudiu Ceia](https://github.com/ClaudiuCeia) diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..56e26d6 --- /dev/null +++ b/mod.ts @@ -0,0 +1 @@ +export * from "./src/dhash.ts"; \ No newline at end of file diff --git a/src/dhash.ts b/src/dhash.ts new file mode 100644 index 0000000..b6abf15 --- /dev/null +++ b/src/dhash.ts @@ -0,0 +1,93 @@ +import { + decode, + GIF, + Image, +} from "https://deno.land/x/imagescript@v1.2.14/mod.ts"; +import { normalize, join } from "https://deno.land/std@0.153.0/path/mod.ts"; + +export const dhash = async (path: string) => { + const resolvedPath = join(Deno.cwd(), normalize(path)); + + let file; + try { + file = await Deno.readFile(resolvedPath); + } catch { + throw new Error(`Failed to open "${resolvedPath}"`); + } + + const image = await decode(file); + if (image instanceof GIF) { + throw new Error("GIF not supported"); + } + + const grayscale = image.saturation(0); + const resized = grayscale.resize(9, 8); + + const out = []; + for (let x = 1; x <= resized.height; x++) { + for (let y = 1; y <= resized.height; y++) { + const left = resized.getPixelAt(x, y); + const right = resized.getPixelAt(x + 1, y); + out.push(left < right ? 1 : 0); + } + } + + return parseInt(out.join(""), 2).toString(16); +}; + +export const compare = (hash1: string, hash2: string) => { + if (hash1.length !== hash2.length) { + throw new Error(` + Hashes should be of the same length. + Got ${hash1} of ${hash1.length} and ${hash2} of ${hash2.length} + `); + } + + let counter = 0; + for (const [idx, c] of hash1.split("").entries()) { + if (c !== hash2[idx]) { + counter++; + } + } + + return counter; +}; + +export const toAscii = (hash: string, chars = ["░░", "██"]) => { + const bin = parseInt(hash, 16).toString(2).split(""); + let counter = 0; + let row = ""; + for (const bit of bin) { + row += bit === "0" ? chars[0] : chars[1]; + counter++; + if (counter === 8) { + row += "\n"; + counter = 0; + } + } + return row + chars[0]; +}; + +export const save = async (hash: string, file: string) => { + const bin = parseInt(hash, 16).toString(2).split(""); + const out = new Image(8, 8); + + const white = Image.rgbToColor(255, 255, 255); + const black = Image.rgbToColor(0, 0, 0); + + let column = 1; + let row = 1; + for (const bit of bin) { + console.log(column, row); + out.setPixelAt(column, row, parseInt(bit) === 1 ? black : white); + row++; + if (row === 9) { + column++; + row = 1; + } + } + + out.setPixelAt(8, 8, white); + const enc = await out.encode(); + await Deno.writeFile(`${file}.png`, enc); +}; diff --git a/tests/dalle-bolder-copyright.jpeg b/tests/dalle-bolder-copyright.jpeg new file mode 100644 index 0000000..339d54c Binary files /dev/null and b/tests/dalle-bolder-copyright.jpeg differ diff --git a/tests/dalle-copyright.png b/tests/dalle-copyright.png new file mode 100644 index 0000000..3c3b16b Binary files /dev/null and b/tests/dalle-copyright.png differ diff --git a/tests/dalle-crop.jpeg b/tests/dalle-crop.jpeg new file mode 100644 index 0000000..0d670d8 Binary files /dev/null and b/tests/dalle-crop.jpeg differ diff --git a/tests/dalle-edited.jpeg b/tests/dalle-edited.jpeg new file mode 100644 index 0000000..8f4775c Binary files /dev/null and b/tests/dalle-edited.jpeg differ diff --git a/tests/dalle-stickers.jpeg b/tests/dalle-stickers.jpeg new file mode 100644 index 0000000..a1e1786 Binary files /dev/null and b/tests/dalle-stickers.jpeg differ diff --git a/tests/dalle.png b/tests/dalle.png new file mode 100644 index 0000000..72d23f1 Binary files /dev/null and b/tests/dalle.png differ diff --git a/tests/dhash.test.ts b/tests/dhash.test.ts new file mode 100644 index 0000000..478469d --- /dev/null +++ b/tests/dhash.test.ts @@ -0,0 +1,38 @@ +import { assertEquals } from "https://deno.land/std@0.149.0/testing/asserts.ts"; +import { compare, dhash, toAscii } from "../src/dhash.ts"; + +Deno.test("sample", async () => { + assertEquals(await dhash("./tests/dalle.png"), "5c20c6b680f80800"); +}); + +Deno.test("comparison", async () => { + const res = await Promise.all([ + dhash("./tests/dalle.png"), + dhash("./tests/dalle-copyright.png"), + dhash("./tests/dalle-bolder-copyright.jpeg"), + dhash("./tests/dalle-crop.jpeg"), + dhash("./tests/dalle-edited.jpeg"), + dhash("./tests/dalle-stickers.jpeg"), + ]); + + assertEquals(compare(res[0], res[1]), 0); + assertEquals(compare(res[0], res[2]), 5); + assertEquals(compare(res[0], res[3]), 14); + assertEquals(compare(res[0], res[4]), 7); + assertEquals(compare(res[0], res[5]), 6); +}); + +Deno.test("print", async () => { + const hash = await dhash("./tests/dalle.png"); + assertEquals( + toAscii(hash), + `██░░██████░░░░░░ + ░░██░░░░░░░░░░██ + ██░░░░░░████░░██ + ░░████░░████░░██ + ░░░░░░░░░░░░░░██ + ████████░░░░░░░░ + ░░░░░░██░░░░░░░░ + ░░░░░░░░░░░░░░░░`.replaceAll(" ", "") + ); +});