Convert reverse search parsing to changeset-backed form

This commit is contained in:
Liam 2024-07-19 09:29:02 -04:00
parent e6a63f4c25
commit 7a5d26144a
7 changed files with 163 additions and 83 deletions

View file

@ -8,6 +8,8 @@ defmodule Philomena.DuplicateReports do
alias Philomena.Repo alias Philomena.Repo
alias Philomena.DuplicateReports.DuplicateReport alias Philomena.DuplicateReports.DuplicateReport
alias Philomena.DuplicateReports.SearchQuery
alias Philomena.DuplicateReports.Uploader
alias Philomena.ImageIntensities.ImageIntensity alias Philomena.ImageIntensities.ImageIntensity
alias Philomena.Images.Image alias Philomena.Images.Image
alias Philomena.Images alias Philomena.Images
@ -47,6 +49,63 @@ defmodule Philomena.DuplicateReports do
limit: ^limit limit: ^limit
end end
@doc """
Executes the reverse image search query from parameters.
## Examples
iex> execute_search_query(%{"image" => ..., "distance" => "0.25"})
{:ok, [%Image{...}, ....]}
iex> execute_search_query(%{"image" => ..., "distance" => "asdf"})
{:error, %Ecto.Changeset{}}
"""
def execute_search_query(attrs \\ %{}) do
%SearchQuery{}
|> SearchQuery.changeset(attrs)
|> Uploader.analyze_upload(attrs)
|> Ecto.Changeset.apply_action(:create)
|> case do
{:ok, search_query} ->
intensities = generate_intensities(search_query)
aspect = search_query.image_aspect_ratio
limit = search_query.limit
dist = search_query.distance
images =
{intensities, aspect}
|> find_duplicates(dist: dist, aspect_dist: dist, limit: limit)
|> preload([:user, :intensity, [:sources, tags: :aliases]])
|> Repo.all()
{:ok, images}
error ->
error
end
end
defp generate_intensities(search_query) do
analysis = SearchQuery.to_analysis(search_query)
file = search_query.uploaded_image
PhilomenaMedia.Processors.intensities(analysis, file)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking search query changes.
## Examples
iex> change_search_query(search_query)
%Ecto.Changeset{source: %SearchQuery{}}
"""
def change_search_query(%SearchQuery{} = search_query) do
SearchQuery.changeset(search_query)
end
@doc """ @doc """
Gets a single duplicate_report. Gets a single duplicate_report.

View file

@ -0,0 +1,59 @@
defmodule Philomena.DuplicateReports.SearchQuery do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :distance, :float, default: 0.25
field :limit, :integer, default: 10
field :image_width, :integer
field :image_height, :integer
field :image_format, :string
field :image_duration, :float
field :image_mime_type, :string
field :image_is_animated, :boolean
field :image_aspect_ratio, :float
field :uploaded_image, :string, virtual: true
end
@doc false
def changeset(search_query, attrs \\ %{}) do
search_query
|> cast(attrs, [:distance, :limit])
|> validate_number(:distance, greater_than_or_equal_to: 0, less_than_or_equal_to: 1)
|> validate_number(:limit, greater_than_or_equal_to: 1, less_than_or_equal_to: 50)
end
@doc false
def image_changeset(search_query, attrs \\ %{}) do
search_query
|> cast(attrs, [
:image_width,
:image_height,
:image_format,
:image_duration,
:image_mime_type,
:image_is_animated,
:image_aspect_ratio,
:uploaded_image
])
|> validate_number(:image_width, greater_than: 0)
|> validate_number(:image_height, greater_than: 0)
|> validate_inclusion(
:image_mime_type,
~W(image/gif image/jpeg image/png image/svg+xml video/webm),
message: "(#{attrs["image_mime_type"]}) is invalid"
)
end
@doc false
def to_analysis(search_query) do
%PhilomenaMedia.Analyzers.Result{
animated?: search_query.image_is_animated,
dimensions: {search_query.image_width, search_query.image_height},
duration: search_query.image_duration,
extension: search_query.image_format,
mime_type: search_query.image_mime_type
}
end
end

View file

@ -0,0 +1,17 @@
defmodule Philomena.DuplicateReports.Uploader do
@moduledoc """
Upload and processing callback logic for SearchQuery images.
"""
alias Philomena.DuplicateReports.SearchQuery
alias PhilomenaMedia.Uploader
def analyze_upload(search_query, params) do
Uploader.analyze_upload(
search_query,
"image",
params["image"],
&SearchQuery.image_changeset/2
)
end
end

View file

@ -1,7 +1,7 @@
defmodule PhilomenaWeb.Api.Json.Search.ReverseController do defmodule PhilomenaWeb.Api.Json.Search.ReverseController do
use PhilomenaWeb, :controller use PhilomenaWeb, :controller
alias PhilomenaWeb.ImageReverse alias Philomena.DuplicateReports
alias Philomena.Interactions alias Philomena.Interactions
plug PhilomenaWeb.ScraperCachePlug plug PhilomenaWeb.ScraperCachePlug
@ -14,7 +14,14 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do
image_params image_params
|> Map.put("distance", conn.params["distance"]) |> Map.put("distance", conn.params["distance"])
|> Map.put("limit", conn.params["limit"]) |> Map.put("limit", conn.params["limit"])
|> ImageReverse.images() |> DuplicateReports.execute_search_query()
|> case do
{:ok, images} ->
images
{:error, _changeset} ->
[]
end
interactions = Interactions.user_interactions(images, user) interactions = Interactions.user_interactions(images, user)

View file

@ -1,7 +1,8 @@
defmodule PhilomenaWeb.Search.ReverseController do defmodule PhilomenaWeb.Search.ReverseController do
use PhilomenaWeb, :controller use PhilomenaWeb, :controller
alias PhilomenaWeb.ImageReverse alias Philomena.DuplicateReports.SearchQuery
alias Philomena.DuplicateReports
plug PhilomenaWeb.ScraperCachePlug plug PhilomenaWeb.ScraperCachePlug
plug PhilomenaWeb.ScraperPlug, params_key: "image", params_name: "image" plug PhilomenaWeb.ScraperPlug, params_key: "image", params_name: "image"
@ -12,12 +13,18 @@ defmodule PhilomenaWeb.Search.ReverseController do
def create(conn, %{"image" => image_params}) def create(conn, %{"image" => image_params})
when is_map(image_params) and image_params != %{} do when is_map(image_params) and image_params != %{} do
images = ImageReverse.images(image_params) case DuplicateReports.execute_search_query(image_params) do
{:ok, images} ->
changeset = DuplicateReports.change_search_query(%SearchQuery{})
render(conn, "index.html", title: "Reverse Search", images: images, changeset: changeset)
render(conn, "index.html", title: "Reverse Search", images: images) {:error, changeset} ->
render(conn, "index.html", title: "Reverse Search", images: nil, changeset: changeset)
end
end end
def create(conn, _params) do def create(conn, _params) do
render(conn, "index.html", title: "Reverse Search", images: nil) changeset = DuplicateReports.change_search_query(%SearchQuery{})
render(conn, "index.html", title: "Reverse Search", images: nil, changeset: changeset)
end end
end end

View file

@ -1,74 +0,0 @@
defmodule PhilomenaWeb.ImageReverse do
alias PhilomenaMedia.Analyzers
alias PhilomenaMedia.Processors
alias Philomena.DuplicateReports
alias Philomena.Repo
import Ecto.Query
def images(image_params) do
image_params
|> Map.get("image")
|> analyze()
|> intensities()
|> case do
:error ->
[]
{analysis, intensities} ->
{width, height} = analysis.dimensions
aspect = width / height
dist = parse_dist(image_params)
limit = parse_limit(image_params)
{intensities, aspect}
|> DuplicateReports.find_duplicates(dist: dist, aspect_dist: dist, limit: limit)
|> preload([:user, :intensity, [:sources, tags: :aliases]])
|> Repo.all()
end
end
defp analyze(%Plug.Upload{path: path}) do
case Analyzers.analyze(path) do
{:ok, analysis} -> {analysis, path}
_ -> :error
end
end
defp analyze(_upload), do: :error
defp intensities(:error), do: :error
defp intensities({analysis, path}) do
{analysis, Processors.intensities(analysis, path)}
end
# The distance metric is taxicab distance, not Euclidean,
# because this is more efficient to index.
defp parse_dist(%{"distance" => distance}) do
distance
|> Decimal.parse()
|> case do
{value, _rest} -> Decimal.to_float(value)
_ -> 0.25
end
|> clamp(0.01, 1.0)
end
defp parse_dist(_params), do: 0.25
defp parse_limit(%{"limit" => limit}) do
limit
|> Integer.parse()
|> case do
{limit, _rest} -> limit
_ -> 10
end
|> clamp(1, 50)
end
defp parse_limit(_params), do: 10
defp clamp(n, min, _max) when n < min, do: min
defp clamp(n, _min, max) when n > max, do: max
defp clamp(n, _min, _max), do: n
end

View file

@ -1,6 +1,6 @@
h1 Reverse Search h1 Reverse Search
= form_for :image, ~p"/search/reverse", [multipart: true], fn f -> = form_for @changeset, ~p"/search/reverse", [multipart: true, as: :image], fn f ->
p p
' Basic image similarity search. Finds uploaded images similar to the one ' Basic image similarity search. Finds uploaded images similar to the one
' provided based on simple intensities and uses the median frame of ' provided based on simple intensities and uses the median frame of
@ -13,6 +13,10 @@ h1 Reverse Search
p Upload a file from your computer, or provide a link to the page containing the image and click Fetch. p Upload a file from your computer, or provide a link to the page containing the image and click Fetch.
.field .field
= file_input f, :image, class: "input js-scraper" = file_input f, :image, class: "input js-scraper"
= error_tag f, :image
= error_tag f, :image_width
= error_tag f, :image_height
= error_tag f, :image_mime_type
.field.field--inline .field.field--inline
= url_input f, :url, name: "url", class: "input input--wide js-scraper", placeholder: "Link a deviantART page, a Tumblr post, or the image directly" = url_input f, :url, name: "url", class: "input input--wide js-scraper", placeholder: "Link a deviantART page, a Tumblr post, or the image directly"
@ -26,9 +30,10 @@ h1 Reverse Search
.field .field
= label f, :distance, "Match distance (suggested values: between 0.2 and 0.5)" = label f, :distance, "Match distance (suggested values: between 0.2 and 0.5)"
br br
= number_input f, :distance, value: 0.25, min: 0, max: 1, step: 0.01, class: "input" = number_input f, :distance, min: 0, max: 1, step: 0.01, class: "input"
= error_tag f, :distance
= hidden_input f, :limit, value: 10 = error_tag f, :limit
.field .field
= submit "Reverse Search", class: "button" = submit "Reverse Search", class: "button"