feat(elixir): implement proper AprilTag generation

This commit is contained in:
Schuwi
2025-09-14 19:50:54 +02:00
parent 589c9964aa
commit 2cf9fb8282
590 changed files with 11453 additions and 11189 deletions

View File

@@ -8,8 +8,9 @@ defmodule ComponentsElixir.AprilTag do
import Ecto.Query
alias ComponentsElixir.AprilTag.Tag36h11
@tag36h11_count 587
@apriltag_size 200
@doc """
Returns the total number of available AprilTags in the tag36h11 family.
@@ -116,43 +117,11 @@ defmodule ComponentsElixir.AprilTag do
@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.
This creates an actual AprilTag pattern using real tag36h11 data
when available, or falls back to a placeholder pattern.
"""
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>
"""
Tag36h11.generate_apriltag_svg(apriltag_id, opts)
end
@doc """

View File

@@ -0,0 +1,243 @@
defmodule ComponentsElixir.AprilTag.Tag36h11 do
@moduledoc """
Tag36h11 AprilTag family data and generation.
Contains the actual bit patterns for all 587 tags in the tag36h11 family,
sourced from the AprilRobotics apriltag-imgs repository.
"""
import Bitwise
# PostScript file path containing all tag36h11 patterns
@patterns_file "apriltags.ps"
# Parse a PostScript line like: (april.tag.Tag36h11, id = 42) <hexpattern> maketag
defp parse_ps_line(line) do
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do
[_, id_str, hex_pattern] ->
{String.to_integer(id_str), String.downcase(hex_pattern)}
_ ->
nil
end
end
# Extract patterns from PostScript file at compile time
@all_patterns (
path = Path.join([File.cwd!(), "apriltags.ps"])
if File.exists?(path) do
File.read!(path)
|> String.split("\n")
|> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11"))
|> Enum.map(fn line ->
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do
[_, id_str, hex_pattern] ->
{String.to_integer(id_str), String.downcase(hex_pattern)}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
|> Map.new()
else
raise "Error: apriltags.ps file not found in project root. This file is required for AprilTag pattern generation."
end
)
# Sample of real tag36h11 hex patterns from AprilRobotics repository
# This will be populated with patterns extracted from the PostScript file
@tag36h11_patterns @all_patterns
@doc """
Extracts all tag36h11 hex patterns from the PostScript file.
Returns a map of {id => hex_pattern} for all 587 tags.
"""
def extract_patterns_from_ps_file(file_path \\ nil) do
path = file_path || Path.join([File.cwd!(), @patterns_file])
if File.exists?(path) do
File.read!(path)
|> String.split("\n")
|> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11"))
|> Enum.map(&parse_ps_line/1)
|> Enum.reject(&is_nil/1)
|> Map.new()
else
%{} # Return empty map if file not found, will fall back to hardcoded patterns
end
end
@doc """
Gets the hex pattern for a given AprilTag ID.
Returns nil if the ID is not available in our pattern data.
"""
def get_hex_pattern(id) when is_integer(id) and id >= 0 and id < 587 do
Map.get(@tag36h11_patterns, id)
end
def get_hex_pattern(_), do: nil
@doc """
Converts a PostScript hex pattern to a 10x10 binary matrix using 2-bits-per-pixel
with proper row padding handling.
PostScript pads each scanline to byte boundary: 10 pixels × 2 bits = 20 bits,
rounded up to 24 bits = 3 bytes per row. The last 2 pixels per row are padding.
Returns a list of lists where true = black pixel, false = white pixel.
"""
def hex_to_binary_matrix_2bpp(hex_string) when is_binary(hex_string) do
clean_hex =
hex_string
|> String.replace(~r/[<>\s]/, "")
|> String.upcase()
bytes = Base.decode16!(clean_hex, case: :mixed)
# Each scanline is padded to a byte boundary: 3 bytes per 10 pixels (2 bpp)
row_bytes = 3
rows =
for row <- 0..9 do
<<_::binary-size(row * row_bytes), r::binary-size(row_bytes), _::binary>> = bytes
<<b0, b1, b2>> = r
samples =
[
b0 >>> 6 &&& 0x3, b0 >>> 4 &&& 0x3, b0 >>> 2 &&& 0x3, b0 &&& 0x3,
b1 >>> 6 &&& 0x3, b1 >>> 4 &&& 0x3, b1 >>> 2 &&& 0x3, b1 &&& 0x3,
b2 >>> 6 &&& 0x3, b2 >>> 4 &&& 0x3, b2 >>> 2 &&& 0x3, b2 &&& 0x3
]
|> Enum.take(10) # drop the 2 padding samples at end of row
|> Enum.map(&(&1 == 0)) # 0 = black, 3 = white → boolean
samples
end
rows
end
@doc """
Legacy method - kept for backward compatibility.
Use hex_to_binary_matrix_2bpp/1 for correct AprilTag parsing.
"""
def hex_to_binary_matrix(hex_string) when is_binary(hex_string) do
hex_to_binary_matrix_2bpp(hex_string)
end
@doc """
Generates an SVG string from a binary matrix.
Takes a 10x10 binary matrix and converts it to an SVG representation
using integer coordinates with viewBox scaling to eliminate seams.
Only black modules are drawn over a white background.
"""
def binary_matrix_to_svg(binary_matrix, opts \\ []) do
size = Keyword.get(opts, :size, 200) # final CSS size in px
id_text = Keyword.get(opts, :id_text, "")
# binary_matrix is 10x10 of booleans: true=black, false=white
modules_w = 10
modules_h = 10
# Build rects only for black modules, aligned to integer coords in the viewBox space
black_cells =
binary_matrix
|> Enum.with_index()
|> Enum.flat_map(fn {row, y} ->
row
|> Enum.with_index()
|> Enum.filter(fn {is_black, _x} -> is_black end)
|> Enum.map(fn {_is_black, x} ->
~s(<rect x="#{x}" y="#{y}" width="1" height="1" />)
end)
end)
|> Enum.join()
"""
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="#{size}" height="#{size + 24}"
viewBox="0 0 #{modules_w} #{modules_h + 2}"
shape-rendering="crispEdges">
<!-- white background (includes 2 units below for caption area) -->
<rect x="0" y="0" width="#{modules_w}" height="#{modules_h + 2}" fill="white"/>
<!-- tag area outline (optional) -->
<rect x="0" y="0" width="#{modules_w}" height="#{modules_h}" fill="white" stroke="black" stroke-width="0"/>
<!-- black modules -->
<g fill="black">
#{black_cells}
</g>
<!-- caption -->
#{if id_text != "" do
~s(<text x="#{modules_w/2}" y="#{modules_h + 1.4}" text-anchor="middle"
font-family="Arial" font-size="0.9">#{id_text}</text>)
else "" end}
</svg>
"""
end
@doc """
Generates a complete AprilTag SVG for a given ID.
This is the main function that combines hex pattern lookup,
binary conversion, and SVG generation.
"""
def generate_apriltag_svg(id, opts \\ []) do
case get_hex_pattern(id) do
nil ->
# Fallback to placeholder for IDs we don't have patterns for yet
generate_placeholder_svg(id, opts)
hex_pattern ->
binary_matrix = hex_to_binary_matrix_2bpp(hex_pattern)
id_text = "ID: #{String.pad_leading(to_string(id), 3, "0")}"
opts_with_id = Keyword.put(opts, :id_text, id_text)
binary_matrix_to_svg(binary_matrix, opts_with_id)
end
end # Generate a placeholder pattern for IDs we don't have real data for yet
defp generate_placeholder_svg(id, opts) do
size = Keyword.get(opts, :size, 200)
margin = Keyword.get(opts, :margin, div(size, 10))
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 - placeholder for missing real pattern -->
<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(id), 3, "0")} (Placeholder)
</text>
</svg>
"""
end
@doc """
Returns the list of AprilTag IDs that have real pattern data available.
"""
def available_patterns do
Map.keys(@tag36h11_patterns) |> Enum.sort()
end
@doc """
Returns whether a real pattern is available for the given ID.
"""
def has_real_pattern?(id) do
Map.has_key?(@tag36h11_patterns, id)
end
end