Phash distance filter (#3596)

* Add phash_distance filter criterion
* Add distance to phash filter in UI
This commit is contained in:
WithoutPants
2023-04-17 15:36:51 +10:00
committed by GitHub
parent 62a1bc22c9
commit dcc73c4873
13 changed files with 184 additions and 17 deletions

View File

@@ -165,7 +165,9 @@ input SceneFilterType {
"""Filter by file checksum""" """Filter by file checksum"""
checksum: StringCriterionInput checksum: StringCriterionInput
"""Filter by file phash""" """Filter by file phash"""
phash: StringCriterionInput phash: StringCriterionInput @deprecated(reason: "Use phash_distance instead")
"""Filter by file phash distance"""
phash_distance: PhashDistanceCriterionInput
"""Filter by path""" """Filter by path"""
path: StringCriterionInput path: StringCriterionInput
"""Filter by file count""" """Filter by file count"""
@@ -527,6 +529,12 @@ input TimestampCriterionInput {
modifier: CriterionModifier! modifier: CriterionModifier!
} }
input PhashDistanceCriterionInput {
value: String!
modifier: CriterionModifier!
distance: Int
}
enum FilterMode { enum FilterMode {
SCENES, SCENES,
PERFORMERS, PERFORMERS,

View File

@@ -136,3 +136,9 @@ type TimestampCriterionInput struct {
Value2 *string `json:"value2"` Value2 *string `json:"value2"`
Modifier CriterionModifier `json:"modifier"` Modifier CriterionModifier `json:"modifier"`
} }
type PhashDistanceCriterionInput struct {
Value string `json:"value"`
Modifier CriterionModifier `json:"modifier"`
Distance *int `json:"distance"`
}

View File

@@ -27,6 +27,8 @@ type SceneFilterType struct {
Checksum *StringCriterionInput `json:"checksum"` Checksum *StringCriterionInput `json:"checksum"`
// Filter by file phash // Filter by file phash
Phash *StringCriterionInput `json:"phash"` Phash *StringCriterionInput `json:"phash"`
// Filter by phash distance
PhashDistance *PhashDistanceCriterionInput `json:"phash_distance"`
// Filter by path // Filter by path
Path *StringCriterionInput `json:"path"` Path *StringCriterionInput `json:"path"`
// Filter by file count // Filter by file count

View File

@@ -29,6 +29,7 @@ func (d *CustomSQLiteDriver) Open(dsn string) (driver.Conn, error) {
"regexp": regexFn, "regexp": regexFn,
"durationToTinyInt": durationToTinyIntFn, "durationToTinyInt": durationToTinyIntFn,
"basename": basenameFn, "basename": basenameFn,
"phash_distance": phashDistanceFn,
} }
for name, fn := range funcs { for name, fn := range funcs {

10
pkg/sqlite/phash.go Normal file
View File

@@ -0,0 +1,10 @@
package sqlite
import "github.com/corona10/goimagehash"
func phashDistanceFn(phash1 int64, phash2 int64) (int64, error) {
hash1 := goimagehash.NewImageHash(uint64(phash1), goimagehash.PHash)
hash2 := goimagehash.NewImageHash(uint64(phash2), goimagehash.PHash)
distance, _ := hash1.Distance(hash2)
return int64(distance), nil
}

View File

@@ -882,17 +882,16 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF
query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if sceneFilter.Phash != nil { if sceneFilter.Phash != nil {
qb.addSceneFilesTable(f) // backwards compatibility
f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") scenePhashDistanceCriterionHandler(qb, &models.PhashDistanceCriterionInput{
Value: sceneFilter.Phash.Value,
value, _ := utils.StringToPhash(sceneFilter.Phash.Value)
intCriterionHandler(&models.IntCriterionInput{
Value: int(value),
Modifier: sceneFilter.Phash.Modifier, Modifier: sceneFilter.Phash.Modifier,
}, "fingerprints_phash.fingerprint", nil)(ctx, f) })(ctx, f)
} }
})) }))
query.handleCriterion(ctx, scenePhashDistanceCriterionHandler(qb, sceneFilter.PhashDistance))
query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil)) query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil))
// legacy rating handler // legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(sceneFilter.Rating, "scenes.rating", nil)) query.handleCriterion(ctx, rating5CriterionHandler(sceneFilter.Rating, "scenes.rating", nil))
@@ -1382,6 +1381,45 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id
} }
} }
func scenePhashDistanceCriterionHandler(qb *SceneStore, phashDistance *models.PhashDistanceCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if phashDistance != nil {
qb.addSceneFilesTable(f)
f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'")
value, _ := utils.StringToPhash(phashDistance.Value)
distance := 0
if phashDistance.Distance != nil {
distance = *phashDistance.Distance
}
if distance == 0 {
// use the default handler
intCriterionHandler(&models.IntCriterionInput{
Value: int(value),
Modifier: phashDistance.Modifier,
}, "fingerprints_phash.fingerprint", nil)(ctx, f)
}
switch {
case phashDistance.Modifier == models.CriterionModifierEquals && distance > 0:
// needed to avoid a type mismatch
f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'")
f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) < ?", value, distance)
case phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0:
// needed to avoid a type mismatch
f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'")
f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) > ?", value, distance)
default:
intCriterionHandler(&models.IntCriterionInput{
Value: int(value),
Modifier: phashDistance.Modifier,
}, "fingerprints_phash.fingerprint", nil)(ctx, f)
}
}
}
}
func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindFilterType) { func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindFilterType) {
if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" { if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" {
return return

View File

@@ -38,6 +38,8 @@ import { RatingFilter } from "./Filters/RatingFilter";
import { BooleanFilter } from "./Filters/BooleanFilter"; import { BooleanFilter } from "./Filters/BooleanFilter";
import { OptionsListFilter } from "./Filters/OptionsListFilter"; import { OptionsListFilter } from "./Filters/OptionsListFilter";
import { PathFilter } from "./Filters/PathFilter"; import { PathFilter } from "./Filters/PathFilter";
import { PhashCriterion } from "src/models/list-filter/criteria/phash";
import { PhashFilter } from "./Filters/PhashFilter";
interface IGenericCriterionEditor { interface IGenericCriterionEditor {
criterion: Criterion<CriterionValue>; criterion: Criterion<CriterionValue>;
@@ -172,6 +174,11 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
<RatingFilter criterion={criterion} onValueChanged={onValueChanged} /> <RatingFilter criterion={criterion} onValueChanged={onValueChanged} />
); );
} }
if (criterion instanceof PhashCriterion) {
return (
<PhashFilter criterion={criterion} onValueChanged={onValueChanged} />
);
}
if ( if (
criterion instanceof CountryCriterion && criterion instanceof CountryCriterion &&
(criterion.modifier === CriterionModifier.Equals || (criterion.modifier === CriterionModifier.Equals ||

View File

@@ -0,0 +1,63 @@
import React from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { IPhashDistanceValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import { CriterionModifier } from "src/core/generated-graphql";
interface IPhashFilterProps {
criterion: Criterion<IPhashDistanceValue>;
onValueChanged: (value: IPhashDistanceValue) => void;
}
export const PhashFilter: React.FC<IPhashFilterProps> = ({
criterion,
onValueChanged,
}) => {
const intl = useIntl();
const { value } = criterion;
function valueChanged(event: React.ChangeEvent<HTMLInputElement>) {
onValueChanged({
value: event.target.value,
distance: criterion.value.distance,
});
}
function distanceChanged(event: React.ChangeEvent<HTMLInputElement>) {
let distance = parseInt(event.target.value);
if (distance < 0 || isNaN(distance)) {
distance = 0;
}
onValueChanged({
distance,
value: criterion.value.value,
});
}
return (
<div>
<Form.Group>
<Form.Control
className="btn-secondary"
onChange={valueChanged}
value={value ? value.value : ""}
placeholder={intl.formatMessage({ id: "phash" })}
/>
</Form.Group>
{criterion.modifier !== CriterionModifier.IsNull &&
criterion.modifier !== CriterionModifier.NotNull && (
<Form.Group>
<Form.Control
className="btn-secondary"
onChange={distanceChanged}
type="number"
value={value ? value.distance : ""}
placeholder={intl.formatMessage({ id: "distance" })}
/>
</Form.Group>
)}
</div>
);
};

View File

@@ -22,6 +22,7 @@ import {
IStashIDValue, IStashIDValue,
IDateValue, IDateValue,
ITimestampValue, ITimestampValue,
IPhashDistanceValue,
} from "../types"; } from "../types";
export type Option = string | number | IOptionType; export type Option = string | number | IOptionType;
@@ -32,7 +33,8 @@ export type CriterionValue =
| INumberValue | INumberValue
| IStashIDValue | IStashIDValue
| IDateValue | IDateValue
| ITimestampValue; | ITimestampValue
| IPhashDistanceValue;
const modifierMessageIDs = { const modifierMessageIDs = {
[CriterionModifier.Equals]: "criterion_modifier.equals", [CriterionModifier.Equals]: "criterion_modifier.equals",

View File

@@ -48,7 +48,7 @@ import { MoviesCriterionOption } from "./movies";
import { GalleriesCriterion } from "./galleries"; import { GalleriesCriterion } from "./galleries";
import { CriterionType } from "../types"; import { CriterionType } from "../types";
import { InteractiveCriterion } from "./interactive"; import { InteractiveCriterion } from "./interactive";
import { DuplicatedCriterion, PhashCriterionOption } from "./phash"; import { DuplicatedCriterion, PhashCriterion } from "./phash";
import { CaptionCriterion } from "./captions"; import { CaptionCriterion } from "./captions";
import { RatingCriterion } from "./rating"; import { RatingCriterion } from "./rating";
import { CountryCriterion } from "./country"; import { CountryCriterion } from "./country";
@@ -167,7 +167,7 @@ export function makeCriteria(
new StringCriterionOption("media_info.checksum", type, "checksum") new StringCriterionOption("media_info.checksum", type, "checksum")
); );
case "phash": case "phash":
return new StringCriterion(PhashCriterionOption); return new PhashCriterion();
case "duplicated": case "duplicated":
return new DuplicatedCriterion(); return new DuplicatedCriterion();
case "country": case "country":

View File

@@ -1,15 +1,19 @@
import { CriterionModifier } from "src/core/generated-graphql"; import {
CriterionModifier,
PhashDistanceCriterionInput,
} from "src/core/generated-graphql";
import { IPhashDistanceValue } from "../types";
import { import {
BooleanCriterionOption, BooleanCriterionOption,
Criterion,
CriterionOption, CriterionOption,
PhashDuplicateCriterion, PhashDuplicateCriterion,
StringCriterion,
} from "./criterion"; } from "./criterion";
export const PhashCriterionOption = new CriterionOption({ export const PhashCriterionOption = new CriterionOption({
messageID: "media_info.phash", messageID: "media_info.phash",
type: "phash", type: "phash",
parameterName: "phash", parameterName: "phash_distance",
inputType: "text", inputType: "text",
modifierOptions: [ modifierOptions: [
CriterionModifier.Equals, CriterionModifier.Equals,
@@ -19,9 +23,30 @@ export const PhashCriterionOption = new CriterionOption({
], ],
}); });
export class PhashCriterion extends StringCriterion { export class PhashCriterion extends Criterion<IPhashDistanceValue> {
constructor() { constructor() {
super(PhashCriterionOption); super(PhashCriterionOption, { value: "", distance: 0 });
}
public getLabelValue() {
const { value, distance } = this.value;
if (
(this.modifier === CriterionModifier.Equals ||
this.modifier === CriterionModifier.NotEquals) &&
distance
) {
return `${value} (${distance})`;
} else {
return `${value}`;
}
}
protected toCriterionInput(): PhashDistanceCriterionInput {
return {
value: this.value.value,
modifier: this.modifier,
distance: this.value.distance,
};
} }
} }

View File

@@ -48,6 +48,11 @@ export interface ITimestampValue {
value2: string | undefined; value2: string | undefined;
} }
export interface IPhashDistanceValue {
value: string;
distance?: number;
}
export function criterionIsHierarchicalLabelValue( export function criterionIsHierarchicalLabelValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any value: any

View File

@@ -289,7 +289,7 @@ const makeScenesPHashMatchUrl = (phash: GQL.Maybe<string> | undefined) => {
if (!phash) return "#"; if (!phash) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);
const criterion = new PhashCriterion(); const criterion = new PhashCriterion();
criterion.value = phash; criterion.value = { value: phash };
filter.criteria.push(criterion); filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
}; };