mirror of
https://github.com/stashapp/stash.git
synced 2025-12-16 20:07:05 +03:00
Phash distance filter (#3596)
* Add phash_distance filter criterion * Add distance to phash filter in UI
This commit is contained in:
@@ -165,7 +165,9 @@ input SceneFilterType {
|
||||
"""Filter by file checksum"""
|
||||
checksum: StringCriterionInput
|
||||
"""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"""
|
||||
path: StringCriterionInput
|
||||
"""Filter by file count"""
|
||||
@@ -527,6 +529,12 @@ input TimestampCriterionInput {
|
||||
modifier: CriterionModifier!
|
||||
}
|
||||
|
||||
input PhashDistanceCriterionInput {
|
||||
value: String!
|
||||
modifier: CriterionModifier!
|
||||
distance: Int
|
||||
}
|
||||
|
||||
enum FilterMode {
|
||||
SCENES,
|
||||
PERFORMERS,
|
||||
|
||||
@@ -136,3 +136,9 @@ type TimestampCriterionInput struct {
|
||||
Value2 *string `json:"value2"`
|
||||
Modifier CriterionModifier `json:"modifier"`
|
||||
}
|
||||
|
||||
type PhashDistanceCriterionInput struct {
|
||||
Value string `json:"value"`
|
||||
Modifier CriterionModifier `json:"modifier"`
|
||||
Distance *int `json:"distance"`
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ type SceneFilterType struct {
|
||||
Checksum *StringCriterionInput `json:"checksum"`
|
||||
// Filter by file phash
|
||||
Phash *StringCriterionInput `json:"phash"`
|
||||
// Filter by phash distance
|
||||
PhashDistance *PhashDistanceCriterionInput `json:"phash_distance"`
|
||||
// Filter by path
|
||||
Path *StringCriterionInput `json:"path"`
|
||||
// Filter by file count
|
||||
|
||||
@@ -29,6 +29,7 @@ func (d *CustomSQLiteDriver) Open(dsn string) (driver.Conn, error) {
|
||||
"regexp": regexFn,
|
||||
"durationToTinyInt": durationToTinyIntFn,
|
||||
"basename": basenameFn,
|
||||
"phash_distance": phashDistanceFn,
|
||||
}
|
||||
|
||||
for name, fn := range funcs {
|
||||
|
||||
10
pkg/sqlite/phash.go
Normal file
10
pkg/sqlite/phash.go
Normal 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
|
||||
}
|
||||
@@ -882,17 +882,16 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF
|
||||
|
||||
query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
|
||||
if sceneFilter.Phash != 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(sceneFilter.Phash.Value)
|
||||
intCriterionHandler(&models.IntCriterionInput{
|
||||
Value: int(value),
|
||||
// backwards compatibility
|
||||
scenePhashDistanceCriterionHandler(qb, &models.PhashDistanceCriterionInput{
|
||||
Value: sceneFilter.Phash.Value,
|
||||
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))
|
||||
// legacy rating handler
|
||||
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) {
|
||||
if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" {
|
||||
return
|
||||
|
||||
@@ -38,6 +38,8 @@ import { RatingFilter } from "./Filters/RatingFilter";
|
||||
import { BooleanFilter } from "./Filters/BooleanFilter";
|
||||
import { OptionsListFilter } from "./Filters/OptionsListFilter";
|
||||
import { PathFilter } from "./Filters/PathFilter";
|
||||
import { PhashCriterion } from "src/models/list-filter/criteria/phash";
|
||||
import { PhashFilter } from "./Filters/PhashFilter";
|
||||
|
||||
interface IGenericCriterionEditor {
|
||||
criterion: Criterion<CriterionValue>;
|
||||
@@ -172,6 +174,11 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
|
||||
<RatingFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
if (criterion instanceof PhashCriterion) {
|
||||
return (
|
||||
<PhashFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
if (
|
||||
criterion instanceof CountryCriterion &&
|
||||
(criterion.modifier === CriterionModifier.Equals ||
|
||||
|
||||
63
ui/v2.5/src/components/List/Filters/PhashFilter.tsx
Normal file
63
ui/v2.5/src/components/List/Filters/PhashFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IStashIDValue,
|
||||
IDateValue,
|
||||
ITimestampValue,
|
||||
IPhashDistanceValue,
|
||||
} from "../types";
|
||||
|
||||
export type Option = string | number | IOptionType;
|
||||
@@ -32,7 +33,8 @@ export type CriterionValue =
|
||||
| INumberValue
|
||||
| IStashIDValue
|
||||
| IDateValue
|
||||
| ITimestampValue;
|
||||
| ITimestampValue
|
||||
| IPhashDistanceValue;
|
||||
|
||||
const modifierMessageIDs = {
|
||||
[CriterionModifier.Equals]: "criterion_modifier.equals",
|
||||
|
||||
@@ -48,7 +48,7 @@ import { MoviesCriterionOption } from "./movies";
|
||||
import { GalleriesCriterion } from "./galleries";
|
||||
import { CriterionType } from "../types";
|
||||
import { InteractiveCriterion } from "./interactive";
|
||||
import { DuplicatedCriterion, PhashCriterionOption } from "./phash";
|
||||
import { DuplicatedCriterion, PhashCriterion } from "./phash";
|
||||
import { CaptionCriterion } from "./captions";
|
||||
import { RatingCriterion } from "./rating";
|
||||
import { CountryCriterion } from "./country";
|
||||
@@ -167,7 +167,7 @@ export function makeCriteria(
|
||||
new StringCriterionOption("media_info.checksum", type, "checksum")
|
||||
);
|
||||
case "phash":
|
||||
return new StringCriterion(PhashCriterionOption);
|
||||
return new PhashCriterion();
|
||||
case "duplicated":
|
||||
return new DuplicatedCriterion();
|
||||
case "country":
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { CriterionModifier } from "src/core/generated-graphql";
|
||||
import {
|
||||
CriterionModifier,
|
||||
PhashDistanceCriterionInput,
|
||||
} from "src/core/generated-graphql";
|
||||
import { IPhashDistanceValue } from "../types";
|
||||
import {
|
||||
BooleanCriterionOption,
|
||||
Criterion,
|
||||
CriterionOption,
|
||||
PhashDuplicateCriterion,
|
||||
StringCriterion,
|
||||
} from "./criterion";
|
||||
|
||||
export const PhashCriterionOption = new CriterionOption({
|
||||
messageID: "media_info.phash",
|
||||
type: "phash",
|
||||
parameterName: "phash",
|
||||
parameterName: "phash_distance",
|
||||
inputType: "text",
|
||||
modifierOptions: [
|
||||
CriterionModifier.Equals,
|
||||
@@ -19,9 +23,30 @@ export const PhashCriterionOption = new CriterionOption({
|
||||
],
|
||||
});
|
||||
|
||||
export class PhashCriterion extends StringCriterion {
|
||||
export class PhashCriterion extends Criterion<IPhashDistanceValue> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ export interface ITimestampValue {
|
||||
value2: string | undefined;
|
||||
}
|
||||
|
||||
export interface IPhashDistanceValue {
|
||||
value: string;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
export function criterionIsHierarchicalLabelValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any
|
||||
|
||||
@@ -289,7 +289,7 @@ const makeScenesPHashMatchUrl = (phash: GQL.Maybe<string> | undefined) => {
|
||||
if (!phash) return "#";
|
||||
const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);
|
||||
const criterion = new PhashCriterion();
|
||||
criterion.value = phash;
|
||||
criterion.value = { value: phash };
|
||||
filter.criteria.push(criterion);
|
||||
return `/scenes?${filter.makeQueryParameters()}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user