feat: use AprilTag instead of QR code
This commit is contained in:
226
lib/components_elixir/apriltag.ex
Normal file
226
lib/components_elixir/apriltag.ex
Normal file
@@ -0,0 +1,226 @@
|
||||
defmodule ComponentsElixir.AprilTag do
|
||||
@moduledoc """
|
||||
AprilTag generation and management for storage locations.
|
||||
|
||||
Provides functionality to generate AprilTag images for storage locations
|
||||
and manage the tag36h11 family (IDs 0-586).
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
@tag36h11_count 587
|
||||
@apriltag_size 200
|
||||
|
||||
@doc """
|
||||
Returns the total number of available AprilTags in the tag36h11 family.
|
||||
"""
|
||||
def tag36h11_count, do: @tag36h11_count
|
||||
|
||||
@doc """
|
||||
Validates if an AprilTag ID is valid for tag36h11 family.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> ComponentsElixir.AprilTag.valid_apriltag_id?(42)
|
||||
true
|
||||
|
||||
iex> ComponentsElixir.AprilTag.valid_apriltag_id?(587)
|
||||
false
|
||||
"""
|
||||
def valid_apriltag_id?(id) when is_integer(id) do
|
||||
id >= 0 and id < @tag36h11_count
|
||||
end
|
||||
def valid_apriltag_id?(_), do: false
|
||||
|
||||
@doc """
|
||||
Gets the SVG file path for a given AprilTag ID.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> ComponentsElixir.AprilTag.get_apriltag_svg_path(42)
|
||||
"/apriltags/tag36h11_id_042.svg"
|
||||
"""
|
||||
def get_apriltag_svg_path(apriltag_id) when is_integer(apriltag_id) do
|
||||
if valid_apriltag_id?(apriltag_id) do
|
||||
"/apriltags/tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the SVG file URL for a storage location's AprilTag.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> location = %StorageLocation{apriltag_id: 42}
|
||||
iex> ComponentsElixir.AprilTag.get_apriltag_url(location)
|
||||
"/apriltags/tag36h11_id_042.svg"
|
||||
"""
|
||||
def get_apriltag_url(storage_location) do
|
||||
case storage_location.apriltag_id do
|
||||
nil -> nil
|
||||
apriltag_id -> get_apriltag_svg_path(apriltag_id)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of all available AprilTag IDs.
|
||||
"""
|
||||
def all_apriltag_ids do
|
||||
0..(@tag36h11_count - 1) |> Enum.to_list()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of used AprilTag IDs in the system.
|
||||
"""
|
||||
def used_apriltag_ids do
|
||||
ComponentsElixir.Repo.all(
|
||||
from sl in ComponentsElixir.Inventory.StorageLocation,
|
||||
where: not is_nil(sl.apriltag_id),
|
||||
select: sl.apriltag_id
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of available (unused) AprilTag IDs.
|
||||
"""
|
||||
def available_apriltag_ids do
|
||||
used = used_apriltag_ids()
|
||||
all_apriltag_ids() -- used
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the next available AprilTag ID, or nil if all are used.
|
||||
"""
|
||||
def next_available_apriltag_id do
|
||||
available_apriltag_ids() |> List.first()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates label data for a storage location with AprilTag.
|
||||
|
||||
This could be used to generate PDF labels or send to a label printer.
|
||||
"""
|
||||
def generate_label_data(storage_location) do
|
||||
%{
|
||||
apriltag_id: storage_location.apriltag_id,
|
||||
apriltag_url: get_apriltag_url(storage_location),
|
||||
name: storage_location.name,
|
||||
path: storage_location.path,
|
||||
level: storage_location.level,
|
||||
description: storage_location.description
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates an SVG string for an AprilTag with the given ID.
|
||||
|
||||
This creates a basic SVG representation of the AprilTag pattern
|
||||
with the ID displayed below it for human readability.
|
||||
|
||||
Note: This is a simplified implementation. For production use,
|
||||
you'd want to use the actual AprilTag generation algorithm or
|
||||
pre-generated assets.
|
||||
"""
|
||||
def generate_apriltag_svg(apriltag_id, opts \\ []) do
|
||||
size = Keyword.get(opts, :size, @apriltag_size)
|
||||
margin = Keyword.get(opts, :margin, div(size, 10))
|
||||
|
||||
# For now, create a placeholder square pattern
|
||||
# In a real implementation, you'd generate the actual AprilTag pattern
|
||||
square_size = size - (2 * margin)
|
||||
|
||||
"""
|
||||
<svg width="#{size}" height="#{size + 30}" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- White background -->
|
||||
<rect width="#{size}" height="#{size + 30}" fill="white"/>
|
||||
|
||||
<!-- AprilTag placeholder (simplified) -->
|
||||
<rect x="#{margin}" y="#{margin}" width="#{square_size}" height="#{square_size}"
|
||||
fill="white" stroke="black" stroke-width="2"/>
|
||||
|
||||
<!-- Simplified tag pattern - in reality this would be the actual AprilTag -->
|
||||
<rect x="#{margin + 10}" y="#{margin + 10}" width="#{square_size - 20}" height="#{square_size - 20}"
|
||||
fill="black"/>
|
||||
<rect x="#{margin + 20}" y="#{margin + 20}" width="#{square_size - 40}" height="#{square_size - 40}"
|
||||
fill="white"/>
|
||||
|
||||
<!-- ID text below -->
|
||||
<text x="#{size / 2}" y="#{size + 20}" text-anchor="middle"
|
||||
font-family="Arial" font-size="14" font-weight="bold">
|
||||
ID: #{String.pad_leading(to_string(apriltag_id), 3, "0")}
|
||||
</text>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates and saves all 587 AprilTag SVG files to the static directory.
|
||||
|
||||
This should be run once during setup to pre-generate all AprilTag images.
|
||||
"""
|
||||
def generate_all_apriltag_svgs(opts \\ []) do
|
||||
static_dir = Path.join([
|
||||
Application.app_dir(:components_elixir, "priv/static"),
|
||||
"apriltags"
|
||||
])
|
||||
|
||||
# Ensure directory exists
|
||||
File.mkdir_p!(static_dir)
|
||||
|
||||
force_regenerate = Keyword.get(opts, :force_regenerate, false)
|
||||
|
||||
results =
|
||||
all_apriltag_ids()
|
||||
|> Task.async_stream(
|
||||
fn apriltag_id ->
|
||||
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
|
||||
file_path = Path.join(static_dir, filename)
|
||||
|
||||
if force_regenerate || !File.exists?(file_path) do
|
||||
svg_content = generate_apriltag_svg(apriltag_id, opts)
|
||||
|
||||
case File.write(file_path, svg_content) do
|
||||
:ok -> {:ok, apriltag_id, file_path}
|
||||
{:error, reason} -> {:error, apriltag_id, reason}
|
||||
end
|
||||
else
|
||||
{:ok, apriltag_id, file_path}
|
||||
end
|
||||
end,
|
||||
timeout: :infinity,
|
||||
max_concurrency: System.schedulers_online() * 2
|
||||
)
|
||||
|> Enum.map(fn {:ok, result} -> result end)
|
||||
|
||||
success_count = results |> Enum.count(&match?({:ok, _, _}, &1))
|
||||
error_count = results |> Enum.count(&match?({:error, _, _}, &1))
|
||||
|
||||
%{
|
||||
total: @tag36h11_count,
|
||||
success: success_count,
|
||||
errors: error_count,
|
||||
results: results
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up AprilTag SVG file for a specific ID.
|
||||
|
||||
Should be called when storage locations are deleted to prevent orphaned files.
|
||||
"""
|
||||
def cleanup_apriltag_svg(apriltag_id) do
|
||||
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
|
||||
file_path = Path.join([
|
||||
Application.app_dir(:components_elixir, "priv/static/apriltags"),
|
||||
filename
|
||||
])
|
||||
|
||||
if File.exists?(file_path) do
|
||||
File.rm(file_path)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -24,11 +24,9 @@ defmodule ComponentsElixir.Inventory do
|
||||
processed_locations = compute_hierarchy_fields_batch(locations)
|
||||
|> Enum.sort_by(&{&1.level, &1.name})
|
||||
|
||||
# Ensure QR codes exist for all locations (in background)
|
||||
# Ensure AprilTag SVGs exist for all locations
|
||||
spawn(fn ->
|
||||
Enum.each(processed_locations, fn location ->
|
||||
ComponentsElixir.QRCode.get_qr_image_url(location)
|
||||
end)
|
||||
ComponentsElixir.AprilTag.generate_all_apriltag_svgs()
|
||||
end)
|
||||
|
||||
processed_locations
|
||||
@@ -109,11 +107,11 @@ defmodule ComponentsElixir.Inventory do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a storage location by QR code.
|
||||
Gets a storage location by AprilTag ID.
|
||||
"""
|
||||
def get_storage_location_by_qr_code(qr_code) do
|
||||
def get_storage_location_by_apriltag_id(apriltag_id) do
|
||||
StorageLocation
|
||||
|> where([sl], sl.qr_code == ^qr_code)
|
||||
|> where([sl], sl.apriltag_id == ^apriltag_id)
|
||||
|> preload(:parent)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
@@ -138,8 +136,6 @@ defmodule ComponentsElixir.Inventory do
|
||||
|
||||
case result do
|
||||
{:ok, location} ->
|
||||
# Automatically generate QR code image
|
||||
spawn(fn -> ComponentsElixir.QRCode.get_qr_image_url(location) end)
|
||||
{:ok, location}
|
||||
error ->
|
||||
error
|
||||
@@ -159,8 +155,6 @@ defmodule ComponentsElixir.Inventory do
|
||||
|
||||
case result do
|
||||
{:ok, updated_location} ->
|
||||
# Automatically regenerate QR code image if name or hierarchy changed
|
||||
spawn(fn -> ComponentsElixir.QRCode.get_qr_image_url(updated_location, force_regenerate: true) end)
|
||||
{:ok, updated_location}
|
||||
error ->
|
||||
error
|
||||
@@ -171,9 +165,6 @@ defmodule ComponentsElixir.Inventory do
|
||||
Deletes a storage location.
|
||||
"""
|
||||
def delete_storage_location(%StorageLocation{} = storage_location) do
|
||||
# Clean up QR code image before deleting
|
||||
ComponentsElixir.QRCode.cleanup_qr_image(storage_location.id)
|
||||
|
||||
Repo.delete(storage_location)
|
||||
end
|
||||
|
||||
@@ -185,17 +176,17 @@ defmodule ComponentsElixir.Inventory do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses a QR code string and returns storage location information.
|
||||
Parses an AprilTag ID and returns storage location information.
|
||||
"""
|
||||
def parse_qr_code(qr_string) do
|
||||
case get_storage_location_by_qr_code(qr_string) do
|
||||
def parse_apriltag_id(apriltag_id) when is_integer(apriltag_id) do
|
||||
case get_storage_location_by_apriltag_id(apriltag_id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
location ->
|
||||
{:ok, %{
|
||||
type: :storage_location,
|
||||
location: location,
|
||||
qr_code: qr_string
|
||||
apriltag_id: apriltag_id
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,13 +7,14 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
|
||||
alias ComponentsElixir.Inventory.{StorageLocation, Component}
|
||||
|
||||
schema "storage_locations" do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :qr_code, :string
|
||||
field :apriltag_id, :integer
|
||||
field :is_active, :boolean, default: true
|
||||
|
||||
# Computed/virtual fields - not stored in database
|
||||
@@ -31,13 +32,14 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
@doc false
|
||||
def changeset(storage_location, attrs) do
|
||||
storage_location
|
||||
|> cast(attrs, [:name, :description, :parent_id, :is_active])
|
||||
|> cast(attrs, [:name, :description, :parent_id, :is_active, :apriltag_id])
|
||||
|> validate_required([:name])
|
||||
|> validate_length(:name, min: 1, max: 100)
|
||||
|> validate_length(:description, max: 500)
|
||||
|> validate_apriltag_id()
|
||||
|> foreign_key_constraint(:parent_id)
|
||||
|> validate_no_circular_reference()
|
||||
|> put_qr_code()
|
||||
|> put_apriltag_id()
|
||||
end
|
||||
|
||||
# Prevent circular references (location being its own ancestor)
|
||||
@@ -79,19 +81,24 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the QR code format for this storage location.
|
||||
Format: SL:{level}:{qr_code}:{parent_qr_or_ROOT}
|
||||
Returns the AprilTag format for this storage location.
|
||||
Returns the AprilTag ID that corresponds to this location.
|
||||
"""
|
||||
def qr_format(storage_location, parent \\ nil) do
|
||||
parent_code = if parent, do: parent.qr_code, else: "ROOT"
|
||||
"SL:#{storage_location.level}:#{storage_location.qr_code}:#{parent_code}"
|
||||
def apriltag_format(storage_location) do
|
||||
storage_location.apriltag_id
|
||||
end
|
||||
|
||||
# Private functions for changeset processing
|
||||
|
||||
defp put_qr_code(changeset) do
|
||||
case get_field(changeset, :qr_code) do
|
||||
nil -> put_change(changeset, :qr_code, generate_qr_code())
|
||||
defp validate_apriltag_id(changeset) do
|
||||
changeset
|
||||
|> validate_number(:apriltag_id, greater_than_or_equal_to: 0, less_than_or_equal_to: 586)
|
||||
|> unique_constraint(:apriltag_id, message: "AprilTag ID is already in use")
|
||||
end
|
||||
|
||||
defp put_apriltag_id(changeset) do
|
||||
case get_field(changeset, :apriltag_id) do
|
||||
nil -> put_change(changeset, :apriltag_id, get_next_available_apriltag_id())
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
@@ -118,16 +125,22 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
"#{compute_path(parent)}/#{name}"
|
||||
end
|
||||
|
||||
defp generate_qr_code do
|
||||
# Generate a unique 6-character alphanumeric code
|
||||
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
defp get_next_available_apriltag_id do
|
||||
# Get all used AprilTag IDs
|
||||
used_ids = ComponentsElixir.Repo.all(
|
||||
from sl in ComponentsElixir.Inventory.StorageLocation,
|
||||
where: not is_nil(sl.apriltag_id),
|
||||
select: sl.apriltag_id
|
||||
)
|
||||
|
||||
1..6
|
||||
|> Enum.map(fn _ ->
|
||||
chars
|
||||
|> String.graphemes()
|
||||
|> Enum.random()
|
||||
end)
|
||||
|> Enum.join()
|
||||
# Find the first available ID (0-586)
|
||||
0..586
|
||||
|> Enum.find(&(&1 not in used_ids))
|
||||
|> case do
|
||||
nil ->
|
||||
# All IDs are used - this should be handled at the application level
|
||||
raise "All AprilTag IDs are in use"
|
||||
id -> id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user