mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-23 20:18:00 +01:00
Convert reverse search parsing to changeset-backed form
This commit is contained in:
parent
e6a63f4c25
commit
7a5d26144a
7 changed files with 163 additions and 83 deletions
|
@ -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.
|
||||||
|
|
||||||
|
|
59
lib/philomena/duplicate_reports/search_query.ex
Normal file
59
lib/philomena/duplicate_reports/search_query.ex
Normal 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
|
17
lib/philomena/duplicate_reports/uploader.ex
Normal file
17
lib/philomena/duplicate_reports/uploader.ex
Normal 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
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue