Compare commits

...

1 Commits

Author SHA1 Message Date
FoxxMD
2028843714 refactor(image): Begin moving image comparison logic into own service 2022-08-18 15:28:50 -04:00
2 changed files with 222 additions and 4 deletions

View File

@@ -0,0 +1,199 @@
import {Logger} from "winston";
import {SubredditResources} from "../Subreddit/SubredditResources";
import {StrongImageDetection} from "./interfaces";
import ImageData from "./ImageData";
import {bitsToHexLength, mergeArr} from "../util";
import {CMError} from "../Utils/Errors";
import {ImageHashCacheData} from "./Infrastructure/Atomic";
import leven from "leven";
export interface CompareImageOptions {
config?: StrongImageDetection
}
export interface ThresholdResults {
withinHard: boolean | undefined,
withinSoft: boolean | undefined
}
export class ImageComparisonService {
protected reference!: ImageData
protected resources: SubredditResources;
protected logger: Logger;
protected detectionConfig: StrongImageDetection;
constructor(resources: SubredditResources, logger: Logger, config: StrongImageDetection) {
this.resources = resources;
this.logger = logger.child({labels: ['Image Detection']}, mergeArr);
this.detectionConfig = config;
}
async setReference(img: ImageData, options?: CompareImageOptions) {
this.reference = img;
const {config = this.detectionConfig} = options || {};
try {
this.reference.setPreferredResolutionByWidth(800);
if (config.hash.enable) {
if (config.hash.ttl !== undefined) {
const refHash = await this.resources.getImageHash(this.reference);
if (refHash === undefined) {
await this.reference.hash(config.hash.bits);
await this.resources.setImageHash(this.reference, config.hash.ttl);
} else if (refHash.original.length !== bitsToHexLength(config.hash.bits)) {
this.logger.warn('Reference image hash length did not correspond to bits specified in config. Recomputing...');
await this.reference.hash(config.hash.bits);
await this.resources.setImageHash(this.reference, config.hash.ttl);
} else {
this.reference.setFromHashCache(refHash);
}
} else {
await this.reference.hash(config.hash.bits);
}
}
} catch (err: any) {
throw new CMError('Could not set reference image due to an error', {cause: err});
}
}
compareDiffWithThreshold(diff: number, options?: CompareImageOptions): ThresholdResults {
const {
config: {
hash: {
hardThreshold = 5,
softThreshold = undefined,
} = {},
} = this.detectionConfig
} = options || {};
let hard: boolean | undefined;
let soft: boolean | undefined;
if ((null !== hardThreshold && undefined !== hardThreshold)) {
hard = diff <= hardThreshold;
if (hard) {
return {withinHard: hard, withinSoft: hard};
}
}
if ((null !== softThreshold && undefined !== softThreshold)) {
soft = diff <= softThreshold;
}
return {withinHard: hard, withinSoft: soft};
}
async compareWithCandidate(candidate: ImageData, options?: CompareImageOptions) {
const {config = this.detectionConfig} = options || {};
if (config.hash.enable) {
await this.compareCandidateHash(candidate, options);
}
}
async compareCandidateHash(candidate: ImageData, options?: CompareImageOptions) {
const {config = this.detectionConfig} = options || {};
let compareHash: Required<ImageHashCacheData> | undefined;
if (config.hash.ttl !== undefined) {
compareHash = await this.resources.getImageHash(candidate);
}
if (compareHash === undefined) {
compareHash = await candidate.hash(config.hash.bits);
if (config.hash.ttl !== undefined) {
await this.resources.setImageHash(candidate, config.hash.ttl);
}
} else {
candidate.setFromHashCache(compareHash);
}
let diff = await this.compareImageHashes(this.reference, candidate, options);
let threshRes = this.compareDiffWithThreshold(diff, options);
if(threshRes.withinSoft !== true && threshRes.withinHard !== true) {
// up to this point we rely naively on hashes that were:
//
// * from cache/db for which we do not have resolutions stored (maybe fix this??)
// * hashes generated from PREVIEWS from reddit that should be the same *width*
//
// we don't have control over how reddit resizes previews or the quality of the previews
// so if we don't get a match using our initial naive, but cpu/data lite approach,
// then we need to check original sources to see if it's possible there has been resolution/cropping trickery
if(this.reference.isMaybeCropped(candidate)) {
const [normalizedRefSharp, normalizedCandidateSharp, width, height] = await this.reference.normalizeImagesForComparison('pixel', candidate, false);
const normalizedRef = new ImageData({width, height, path: this.reference.path});
normalizedRef.sharpImg = normalizedRefSharp;
const normalizedCandidate = new ImageData({width, height, path: candidate.path});
normalizedCandidate.sharpImg = normalizedCandidateSharp;
const normalDiff = await this.compareImageHashes(normalizedRef, normalizedCandidate, options);
let normalizedThreshRes = this.compareDiffWithThreshold(normalDiff, options);
}
}
/* // return image if hard is defined and diff is less
if (null !== config.hash.hardThreshold && diff <= config.hash.hardThreshold) {
return x;
}
// hard is either not defined or diff was greater than hard
// if soft is defined
if (config.hash.softThreshold !== undefined) {
// and diff is greater than soft allowance
if (diff > config.hash.softThreshold) {
// not similar enough
return null;
}
// similar enough, will continue on to pixel (if enabled!)
} else {
// only hard was defined and did not pass
return null;
}*/
}
async compareImageHashes(reference: ImageData, candidate: ImageData, options?: CompareImageOptions) {
const {config = this.detectionConfig} = options || {};
const {
hash: {
bits = 16,
} = {},
} = config;
let refHash = await reference.hash(bits);
let compareHash = await candidate.hash(bits);
if (compareHash.original.length !== refHash.original.length) {
this.logger.warn(`Hash lengths were not the same! Will need to recompute compare hash to match reference.\n\nReference: ${reference.basePath} has is ${refHash.original.length} char long | Comparing: ${candidate.basePath} has is ${compareHash} ${compareHash.original.length} long`);
refHash = await reference.hash(bits, true, true);
compareHash = await candidate.hash(bits, true, true);
}
let diff: number;
const odistance = leven(refHash.original, compareHash.original);
diff = (odistance / refHash.original.length) * 100;
// compare flipped hash if it exists
// if it has less difference than normal comparison then the image is probably flipped (or so different it doesn't matter)
if (compareHash.flipped !== undefined) {
const fdistance = leven(refHash.original, compareHash.flipped);
const fdiff = (fdistance / refHash.original.length) * 100;
if (fdiff < diff) {
diff = fdiff;
}
}
return diff;
}
async compareCandidatePixel() {
// TODO
}
async compareImagePixels() {
// TODO
}
}

View File

@@ -42,8 +42,8 @@ class ImageData {
return await (await this.sharp()).clone().toFormat(format).toBuffer();
}
async hash(bits: number = 16, useVariantIfPossible = true): Promise<Required<ImageHashCacheData>> {
if (this.hashResult === undefined || this.hashResultFlipped === undefined) {
async hash(bits: number = 16, useVariantIfPossible = true, force = false): Promise<Required<ImageHashCacheData>> {
if (force || (this.hashResult === undefined || this.hashResultFlipped === undefined)) {
let ref: ImageData | undefined;
if (useVariantIfPossible && this.preferredResolution !== undefined) {
ref = this.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
@@ -182,6 +182,25 @@ class ImageData {
return this.width === otherImage.width && this.height === otherImage.height;
}
isMaybeCropped(otherImage: ImageData, allowDiff = 10): boolean {
if (!this.hasDimensions || !otherImage.hasDimensions) {
return false;
}
const refWidth = this.width as number;
const refHeight = this.height as number;
const oWidth = otherImage.width as number;
const oHeight = otherImage.height as number;
const sWidth = refWidth <= oWidth ? refWidth : oWidth;
const sHeight = refHeight <= oHeight ? refHeight : oHeight;
const widthDiff = sWidth / (sWidth === refWidth ? oWidth : refWidth);
const heightDiff = sHeight / (sHeight === refHeight ? oHeight : refHeight);
return widthDiff <= allowDiff || heightDiff <= allowDiff;
}
async sameAspectRatio(otherImage: ImageData) {
let thisRes = this.actualResolution;
let otherRes = otherImage.actualResolution;
@@ -207,12 +226,12 @@ class ImageData {
return {width: width as number, height: height as number};
}
async normalizeImagesForComparison(compareLibrary: ('pixel' | 'resemble'), imgToCompare: ImageData): Promise<[Sharp, Sharp, number, number]> {
async normalizeImagesForComparison(compareLibrary: ('pixel' | 'resemble'), imgToCompare: ImageData, usePreferredResolution = true): Promise<[Sharp, Sharp, number, number]> {
const sFunc = await getSharpAsync();
let refImage = this as ImageData;
let compareImage = imgToCompare;
if (this.preferredResolution !== undefined) {
if (usePreferredResolution && this.preferredResolution !== undefined) {
const matchingVariant = compareImage.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
if (matchingVariant !== undefined) {
compareImage = matchingVariant;