feat: use AprilTag instead of QR code

This commit is contained in:
Schuwi
2025-09-14 18:52:24 +02:00
parent 788ad54724
commit 589c9964aa
600 changed files with 12814 additions and 122 deletions

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -17,7 +17,7 @@ defmodule ComponentsElixirWeb do
those modules here.
"""
def static_paths, do: ~w(assets fonts images user_generated favicon.ico robots.txt)
def static_paths, do: ~w(assets fonts images user_generated apriltags favicon.ico robots.txt)
def router do
quote do

View File

@@ -1,12 +1,12 @@
defmodule ComponentsElixirWeb.StorageLocationsLive do
@moduledoc """
LiveView for managing storage locations and QR codes.
LiveView for managing storage locations and AprilTags.
"""
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.StorageLocation
alias ComponentsElixir.QRCode
alias ComponentsElixir.AprilTag
@impl true
def mount(_params, session, socket) do
@@ -24,8 +24,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> assign(:show_edit_form, false)
|> assign(:editing_location, nil)
|> assign(:form, nil)
|> assign(:qr_scanner_open, false)
|> assign(:scanned_codes, [])
|> assign(:apriltag_scanner_open, false)
|> assign(:scanned_tags, [])
|> assign(:page_title, "Storage Location Management")}
end
end
@@ -38,7 +38,9 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{:noreply,
socket
|> assign(:show_add_form, true)
|> assign(:form, form)}
|> assign(:form, form)
|> assign(:available_apriltag_ids, AprilTag.available_apriltag_ids())
|> assign(:apriltag_mode, "auto")}
end
def handle_event("hide_add_form", _params, socket) do
@@ -64,7 +66,9 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
socket
|> assign(:show_edit_form, true)
|> assign(:editing_location, location)
|> assign(:form, form)}
|> assign(:form, form)
|> assign(:available_apriltag_ids, AprilTag.available_apriltag_ids())
|> assign(:edit_apriltag_mode, "keep")}
end
def handle_event("hide_edit_form", _params, socket) do
@@ -76,7 +80,32 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
end
def handle_event("save_location", %{"storage_location" => location_params}, socket) do
case Inventory.create_storage_location(location_params) do
# Process AprilTag assignment based on mode
processed_params = case socket.assigns.apriltag_mode do
"none" ->
# Remove any apriltag_id from params to ensure it's nil
Map.delete(location_params, "apriltag_id")
"auto" ->
# Auto-assign next available AprilTag ID
case AprilTag.next_available_apriltag_id() do
nil ->
# No available IDs, proceed without AprilTag
Map.delete(location_params, "apriltag_id")
apriltag_id ->
Map.put(location_params, "apriltag_id", apriltag_id)
end
"manual" ->
# Use the manually entered apriltag_id (validation will be handled by changeset)
location_params
_ ->
# Fallback: remove apriltag_id
Map.delete(location_params, "apriltag_id")
end
case Inventory.create_storage_location(processed_params) do
{:ok, _location} ->
{:noreply,
socket
@@ -121,57 +150,86 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
end
end
def handle_event("open_qr_scanner", _params, socket) do
{:noreply, assign(socket, :qr_scanner_open, true)}
def handle_event("open_apriltag_scanner", _params, socket) do
{:noreply, assign(socket, :apriltag_scanner_open, true)}
end
def handle_event("close_qr_scanner", _params, socket) do
{:noreply, assign(socket, :qr_scanner_open, false)}
def handle_event("close_apriltag_scanner", _params, socket) do
{:noreply, assign(socket, :apriltag_scanner_open, false)}
end
def handle_event("qr_scanned", %{"code" => code}, socket) do
case QRCode.parse_qr_data(code) do
{:ok, parsed} ->
case Inventory.get_storage_location_by_qr_code(parsed.code) do
def handle_event("apriltag_scanned", %{"apriltag_id" => apriltag_id_str}, socket) do
case Integer.parse(apriltag_id_str) do
{apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 ->
case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do
nil ->
{:noreply, put_flash(socket, :error, "Storage location not found for QR code: #{code}")}
{:noreply, put_flash(socket, :error, "Storage location not found for AprilTag ID: #{apriltag_id}")}
location ->
scanned_codes = [%{code: code, location: location} | socket.assigns.scanned_codes]
scanned_tags = [%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags]
{:noreply,
socket
|> assign(:scanned_codes, scanned_codes)
|> assign(:scanned_tags, scanned_tags)
|> put_flash(:info, "Scanned: #{location.path}")}
end
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Invalid QR code: #{reason}")}
_ ->
{:noreply, put_flash(socket, :error, "Invalid AprilTag ID: #{apriltag_id_str}")}
end
end
def handle_event("clear_scanned", _params, socket) do
{:noreply, assign(socket, :scanned_codes, [])}
{:noreply, assign(socket, :scanned_tags, [])}
end
def handle_event("download_qr", %{"id" => id}, socket) do
def handle_event("set_apriltag_mode", %{"mode" => mode}, socket) do
{:noreply, assign(socket, :apriltag_mode, mode)}
end
def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do
# Clear the apriltag_id field when switching modes
form = case mode do
"remove" ->
socket.assigns.form
|> Phoenix.Component.to_form()
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil))
"keep" ->
current_id = socket.assigns.editing_location.apriltag_id
socket.assigns.form
|> Phoenix.Component.to_form()
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id))
_ ->
socket.assigns.form
end
{:noreply,
socket
|> assign(:edit_apriltag_mode, mode)
|> assign(:form, form)}
end
def handle_event("download_apriltag", %{"id" => id}, socket) do
case Inventory.get_storage_location!(id) do
%{apriltag_id: nil} ->
{:noreply, put_flash(socket, :error, "No AprilTag assigned to this location")}
location ->
case QRCode.generate_qr_image(location) do
{:ok, png_data} ->
filename = "#{location.name |> String.replace(" ", "_")}_QR.png"
case AprilTag.get_apriltag_url(location) do
nil ->
{:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")}
apriltag_url ->
filename = "#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg"
# Send file download to browser
{:noreply,
socket
|> push_event("download_file", %{
|> push_event("download_apriltag", %{
filename: filename,
data: Base.encode64(png_data),
mime_type: "image/png"
url: apriltag_url,
apriltag_id: location.apriltag_id
})}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Failed to generate QR code")}
end
end
end
@@ -230,8 +288,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
Inventory.count_components_in_storage_location(location_id)
end
defp get_qr_image_url(location) do
QRCode.get_qr_image_url(location)
defp get_apriltag_url(location) do
AprilTag.get_apriltag_url(location)
end
# Component for rendering individual storage location items with QR code support
@@ -284,21 +342,21 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<p class="text-xs text-gray-400">
{count_components_in_location(@location.id)} components
</p>
<%= if @location.qr_code do %>
<%= if @location.apriltag_id do %>
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
QR: {@location.qr_code}
AprilTag: {@location.apriltag_id}
</span>
<% end %>
</div>
</div>
<%= if @location.qr_code do %>
<%= if @location.apriltag_id do %>
<div class="flex items-center space-x-3">
<%= if get_qr_image_url(@location) do %>
<div class="qr-code-container flex-shrink-0">
<%= if get_apriltag_url(@location) do %>
<div class="apriltag-container flex-shrink-0">
<img
src={get_qr_image_url(@location)}
alt={"QR Code for #{@location.name}"}
class="w-16 h-16 border border-gray-200 rounded bg-white"
src={get_apriltag_url(@location)}
alt={"AprilTag for #{@location.name}"}
class="w-16 h-auto border border-gray-200 rounded bg-white"
onerror="this.style.display='none'"
/>
</div>
@@ -308,10 +366,10 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div>
<% end %>
<button
phx-click="download_qr"
phx-click="download_apriltag"
phx-value-id={@location.id}
class="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 flex-shrink-0"
title="Download QR Code"
title="Download AprilTag"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
Download
@@ -427,6 +485,76 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<.input field={@form[:description]} type="textarea" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">AprilTag ID (Optional)</label>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<input
type="radio"
name="apriltag_assignment"
value="none"
id="apriltag_none"
checked={@apriltag_mode == "none"}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
phx-click="set_apriltag_mode"
phx-value-mode="none"
/>
<label for="apriltag_none" class="text-sm text-gray-700">
No AprilTag assignment
</label>
</div>
<div class="flex items-center space-x-2">
<input
type="radio"
name="apriltag_assignment"
value="auto"
id="apriltag_auto"
checked={@apriltag_mode == "auto"}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
phx-click="set_apriltag_mode"
phx-value-mode="auto"
/>
<label for="apriltag_auto" class="text-sm text-gray-700">
Auto-assign next available ID
</label>
</div>
<div class="flex items-center space-x-2">
<input
type="radio"
name="apriltag_assignment"
value="manual"
id="apriltag_manual"
checked={@apriltag_mode == "manual"}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
phx-click="set_apriltag_mode"
phx-value-mode="manual"
/>
<label for="apriltag_manual" class="text-sm text-gray-700">
Choose specific ID
</label>
</div>
<%= if @apriltag_mode == "manual" do %>
<div class="ml-6 space-y-2">
<.input
field={@form[:apriltag_id]}
type="number"
min="0"
max="586"
placeholder="Enter ID (0-586)"
class="w-32"
/>
<div class="text-xs text-gray-500">
Available IDs: <%= length(@available_apriltag_ids) %> of 587
<%= if length(@available_apriltag_ids) < 20 do %>
<br/>Next available: <%= @available_apriltag_ids |> Enum.take(10) |> Enum.join(", ") %>
<%= if length(@available_apriltag_ids) > 10, do: "..." %>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
@@ -483,6 +611,75 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<.input field={@form[:description]} type="textarea" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">AprilTag ID</label>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<input
type="radio"
name="edit_apriltag_assignment"
value="keep"
id="edit_apriltag_keep"
checked={@edit_apriltag_mode == "keep"}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
phx-click="set_edit_apriltag_mode"
phx-value-mode="keep"
/>
<label for="edit_apriltag_keep" class="text-sm text-gray-700">
Keep current assignment
</label>
</div>
<div class="flex items-center space-x-2">
<input
type="radio"
name="edit_apriltag_assignment"
value="change"
id="edit_apriltag_change"
checked={@edit_apriltag_mode == "change"}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
phx-click="set_edit_apriltag_mode"
phx-value-mode="change"
/>
<label for="edit_apriltag_change" class="text-sm text-gray-700">
Change to different ID
</label>
</div>
<div class="flex items-center space-x-2">
<input
type="radio"
name="edit_apriltag_assignment"
value="remove"
id="edit_apriltag_remove"
checked={@edit_apriltag_mode == "remove"}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
phx-click="set_edit_apriltag_mode"
phx-value-mode="remove"
/>
<label for="edit_apriltag_remove" class="text-sm text-gray-700">
Remove AprilTag assignment
</label>
</div>
<%= if @edit_apriltag_mode == "change" do %>
<div class="ml-6 space-y-2">
<.input
field={@form[:apriltag_id]}
type="number"
min="0"
max="586"
placeholder="Enter new ID (0-586)"
class="w-32"
/>
<div class="text-xs text-gray-500">
Available IDs: <%= length(@available_apriltag_ids) %> of 587
</div>
</div>
<% end %>
<p class="text-xs text-gray-500 mt-1">
Current: <%= if @editing_location.apriltag_id, do: "ID #{@editing_location.apriltag_id}", else: "None" %>
</p>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
@@ -504,43 +701,43 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div>
<% end %>
<!-- QR Scanner Modal -->
<%= if @qr_scanner_open do %>
<!-- AprilTag Scanner Modal -->
<%= if @apriltag_scanner_open do %>
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-10 mx-auto p-5 border w-11/12 md:w-1/2 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">QR Code Scanner</h3>
<h3 class="text-lg font-medium text-gray-900">AprilTag Scanner</h3>
<button
phx-click="close_qr_scanner"
phx-click="close_apriltag_scanner"
class="text-gray-400 hover:text-gray-600"
>
<.icon name="hero-x-mark" class="w-6 h-6" />
</button>
</div>
<!-- QR Scanner Interface -->
<!-- AprilTag Scanner Interface -->
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<.icon name="hero-qr-code" class="mx-auto h-12 w-12 text-gray-400" />
<p class="mt-2 text-sm text-gray-600">Camera QR scanner would go here</p>
<p class="text-xs text-gray-500 mt-1">In a real implementation, this would use JavaScript QR scanning</p>
<p class="mt-2 text-sm text-gray-600">Camera AprilTag scanner would go here</p>
<p class="text-xs text-gray-500 mt-1">In a real implementation, this would use JavaScript AprilTag detection</p>
<!-- Test buttons for demo -->
<div class="mt-4 space-y-2">
<p class="text-sm font-medium text-gray-700">Test with sample codes:</p>
<p class="text-sm font-medium text-gray-700">Test with sample AprilTag IDs:</p>
<button
phx-click="qr_scanned"
phx-value-code="SL:0:1MTKDM:ROOT"
phx-click="apriltag_scanned"
phx-value-apriltag_id="0"
class="block w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded"
>
Scan "Shelf A"
Scan AprilTag ID 0
</button>
<button
phx-click="qr_scanned"
phx-value-code="SL:1:VDI701:1MTKDM"
phx-click="apriltag_scanned"
phx-value-apriltag_id="1"
class="block w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded"
>
Scan "Drawer 1"
Scan AprilTag ID 1
</button>
</div>
</div>
@@ -549,8 +746,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div>
<% end %>
<!-- Scanned Codes Display -->
<%= if length(@scanned_codes) > 0 do %>
<!-- Scanned Tags Display -->
<%= if length(@scanned_tags) > 0 do %>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex justify-between items-center mb-2">
@@ -563,10 +760,10 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</button>
</div>
<div class="space-y-2">
<div :for={scan <- @scanned_codes} class="flex items-center justify-between bg-white p-2 rounded border">
<div :for={scan <- @scanned_tags} class="flex items-center justify-between bg-white p-2 rounded border">
<div>
<span class="font-medium text-gray-900">{location_display_name(scan.location)}</span>
<span class="text-sm text-gray-600 ml-2">({scan.code})</span>
<span class="text-sm text-gray-600 ml-2">(AprilTag ID {scan.apriltag_id})</span>
</div>
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
Level {scan.location.level}
@@ -582,7 +779,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Storage Location Hierarchy</h2>
<p class="text-sm text-gray-500 mt-1">Manage your physical storage locations and QR codes</p>
<p class="text-sm text-gray-500 mt-1">Manage your physical storage locations and AprilTags</p>
</div>
<%= if Enum.empty?(@storage_locations) do %>

View File

@@ -0,0 +1,57 @@
defmodule Mix.Tasks.Apriltag.GenerateAll do
@moduledoc """
Generates all 587 AprilTag tag36h11 SVG files for the storage system.
## Examples
mix apriltag.generate_all
mix apriltag.generate_all --force
Options:
* `--force` - Regenerate all files even if they already exist
"""
use Mix.Task
@shortdoc "Generate all AprilTag SVG files"
def run(args) do
{opts, [], []} = OptionParser.parse(args, strict: [force: :boolean])
force_regenerate = Keyword.get(opts, :force, false)
Mix.Task.run("app.start")
IO.puts("Generating AprilTag SVG files...")
IO.puts("Force regenerate: #{force_regenerate}")
start_time = System.monotonic_time(:millisecond)
result = ComponentsElixir.AprilTag.generate_all_apriltag_svgs(
force_regenerate: force_regenerate
)
end_time = System.monotonic_time(:millisecond)
duration = end_time - start_time
IO.puts("Generation completed in #{duration}ms")
IO.puts("Total: #{result.total}")
IO.puts("Success: #{result.success}")
IO.puts("Errors: #{result.errors}")
if result.errors > 0 do
IO.puts("\nErrors encountered:")
result.results
|> Enum.filter(&match?({:error, _, _}, &1))
|> Enum.each(fn {:error, id, reason} ->
IO.puts(" AprilTag ID #{id}: #{inspect(reason)}")
end)
end
if result.success == result.total do
IO.puts("\n✅ All AprilTag SVG files generated successfully!")
IO.puts("Files are available in: priv/static/apriltags/")
else
IO.puts("\n⚠️ Some files failed to generate. Check the errors above.")
System.halt(1)
end
end
end