mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +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"""
|
"""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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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) {
|
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
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
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,
|
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",
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user