diff --git a/lib/philomena/duplicate_reports.ex b/lib/philomena/duplicate_reports.ex index 9bf85cdb..5f84c9da 100644 --- a/lib/philomena/duplicate_reports.ex +++ b/lib/philomena/duplicate_reports.ex @@ -8,6 +8,8 @@ defmodule Philomena.DuplicateReports do alias Philomena.Repo alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.DuplicateReports.SearchQuery + alias Philomena.DuplicateReports.Uploader alias Philomena.ImageIntensities.ImageIntensity alias Philomena.Images.Image alias Philomena.Images @@ -47,6 +49,63 @@ defmodule Philomena.DuplicateReports do limit: ^limit 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 """ Gets a single duplicate_report. diff --git a/lib/philomena/duplicate_reports/search_query.ex b/lib/philomena/duplicate_reports/search_query.ex new file mode 100644 index 00000000..bc922077 --- /dev/null +++ b/lib/philomena/duplicate_reports/search_query.ex @@ -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 diff --git a/lib/philomena/duplicate_reports/uploader.ex b/lib/philomena/duplicate_reports/uploader.ex new file mode 100644 index 00000000..41fc4998 --- /dev/null +++ b/lib/philomena/duplicate_reports/uploader.ex @@ -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 diff --git a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex index ba94b753..1b7a6011 100644 --- a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex @@ -1,7 +1,7 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageReverse + alias Philomena.DuplicateReports alias Philomena.Interactions plug PhilomenaWeb.ScraperCachePlug @@ -14,7 +14,14 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do image_params |> Map.put("distance", conn.params["distance"]) |> 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) diff --git a/lib/philomena_web/controllers/search/reverse_controller.ex b/lib/philomena_web/controllers/search/reverse_controller.ex index 54c52dac..967b968a 100644 --- a/lib/philomena_web/controllers/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/search/reverse_controller.ex @@ -1,7 +1,8 @@ defmodule PhilomenaWeb.Search.ReverseController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageReverse + alias Philomena.DuplicateReports.SearchQuery + alias Philomena.DuplicateReports plug PhilomenaWeb.ScraperCachePlug plug PhilomenaWeb.ScraperPlug, params_key: "image", params_name: "image" @@ -12,12 +13,18 @@ defmodule PhilomenaWeb.Search.ReverseController do def create(conn, %{"image" => image_params}) 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 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 diff --git a/lib/philomena_web/image_reverse.ex b/lib/philomena_web/image_reverse.ex deleted file mode 100644 index e8e32f85..00000000 --- a/lib/philomena_web/image_reverse.ex +++ /dev/null @@ -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 diff --git a/lib/philomena_web/templates/search/reverse/index.html.slime b/lib/philomena_web/templates/search/reverse/index.html.slime index d8b934df..83dd72a7 100644 --- a/lib/philomena_web/templates/search/reverse/index.html.slime +++ b/lib/philomena_web/templates/search/reverse/index.html.slime @@ -1,6 +1,6 @@ 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 ' Basic image similarity search. Finds uploaded images similar to the one ' 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. .field = 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 = 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 = label f, :distance, "Match distance (suggested values: between 0.2 and 0.5)" 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 = submit "Reverse Search", class: "button"