spaxel/mothership/internal/render/floorplan.go
jedarden 1903085e12 test: enhance floor-plan renderer tests with precise coordinate and color verification
- Add TestZoneBoundariesAtCorrectCoordinates to verify zone boundaries appear at correct pixel coordinates
- Add TestZoneBoundaryEdges to verify zone edge detection
- Enhance TestPixelColors with accurate background color verification (#1a1a2e)
- Fix person color detection by sampling multiple pixels to find red fill
- Tests verify 300x300 PNG dimensions, correct zone boundary coordinates, and accurate colors
- All renderer tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 04:29:13 -04:00

424 lines
12 KiB
Go

// Package render provides floor-plan thumbnail rendering for notifications.
package render
import (
"bytes"
"fmt"
"image/color"
"math"
"github.com/fogleman/gg"
)
// NotificationType represents the type of notification event.
type NotificationType string
const (
NotificationZoneEnter NotificationType = "zone_enter"
NotificationZoneLeave NotificationType = "zone_leave"
NotificationZoneVacant NotificationType = "zone_vacant"
NotificationFallDetected NotificationType = "fall_detected"
NotificationFallEscalation NotificationType = "fall_escalation"
NotificationAnomalyAlert NotificationType = "anomaly_alert"
NotificationNodeOffline NotificationType = "node_offline"
NotificationSleepSummary NotificationType = "sleep_summary"
)
// Zone represents a zone in the floor plan.
type Zone struct {
ID string `json:"id"`
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
W float64 `json:"w"`
D float64 `json:"d"`
Color string `json:"color"`
Highlight bool `json:"highlight"` // Highlight this zone (event location)
}
// Node represents a node position.
type Node struct {
Label string `json:"label"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
// Person represents a tracked person.
type Person struct {
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
Color string `json:"color"`
Confidence float64 `json:"confidence"`
IsFall bool `json:"is_fall"`
}
// Portal represents a portal between zones.
type Portal struct {
Name string `json:"name"`
X1 float64 `json:"x1"`
Y1 float64 `json:"y1"`
X2 float64 `json:"x2"`
Y2 float64 `json:"y2"`
}
// RenderConfig holds configuration for floor-plan rendering.
type RenderConfig struct {
Width int // Image width in pixels (default 300)
Height int // Image height in pixels (default 300)
RoomWidth float64 // Room width in meters
RoomDepth float64 // Room depth in meters
Zones []Zone // Zones to render
Nodes []Node // Nodes to render
People []Person // People to render
Portals []Portal // Portals to render
EventType NotificationType // Event type for title overlay
EventTitle string // Event title text
BackgroundColor color.Color // Background color
}
// DefaultRenderConfig returns a config with sensible defaults.
func DefaultRenderConfig() RenderConfig {
return RenderConfig{
Width: 300,
Height: 300,
RoomWidth: 10.0,
RoomDepth: 10.0,
BackgroundColor: color.RGBA{26, 26, 46, 255}, // Dark background
}
}
// Renderer generates floor-plan thumbnails.
type Renderer struct {
config RenderConfig
dc *gg.Context
}
// NewRenderer creates a new floor-plan renderer.
func NewRenderer(config RenderConfig) *Renderer {
if config.Width == 0 {
config.Width = 300
}
if config.Height == 0 {
config.Height = 300
}
if config.RoomWidth == 0 {
config.RoomWidth = 10.0
}
if config.RoomDepth == 0 {
config.RoomDepth = 10.0
}
if config.BackgroundColor == nil {
config.BackgroundColor = color.RGBA{26, 26, 46, 255}
}
return &Renderer{config: config}
}
// Render generates a floor-plan PNG as bytes.
func (r *Renderer) Render() ([]byte, error) {
// Create drawing context
r.dc = gg.NewContext(r.config.Width, r.config.Height)
// Draw background
r.dc.SetColor(r.config.BackgroundColor)
r.dc.Clear()
// Calculate scale factors
margin := 10.0
drawWidth := float64(r.config.Width) - 2*margin
drawHeight := float64(r.config.Height) - 2*margin - 20 // Reserve space for title
scaleX := drawWidth / r.config.RoomWidth
scaleY := drawHeight / r.config.RoomDepth
scale := math.Min(scaleX, scaleY)
// Center the drawing
offsetX := margin + (drawWidth - r.config.RoomWidth*scale)/2
offsetY := margin + (drawHeight - r.config.RoomDepth*scale)/2
// Draw zones
for _, zone := range r.config.Zones {
r.drawZone(zone, offsetX, offsetY, scale)
}
// Draw portals
for _, portal := range r.config.Portals {
r.drawPortal(portal, offsetX, offsetY, scale)
}
// Draw nodes
for _, node := range r.config.Nodes {
r.drawNode(node, offsetX, offsetY, scale)
}
// Draw people
for _, person := range r.config.People {
r.drawPerson(person, offsetX, offsetY, scale)
}
// Draw event title overlay
if r.config.EventTitle != "" {
r.drawEventTitle()
}
// Encode to PNG
var buf bytes.Buffer
if err := r.dc.EncodePNG(&buf); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
return buf.Bytes(), nil
}
// drawZone draws a zone rectangle with optional highlight.
func (r *Renderer) drawZone(zone Zone, offsetX, offsetY, scale float64) {
// Calculate screen coordinates
x := offsetX + zone.X*scale
y := offsetY + zone.Y*scale
w := zone.W * scale
h := zone.D * scale
// Parse zone color
zoneColor := r.parseColor(zone.Color)
if zoneColor.A == 0 {
zoneColor = color.RGBA{79, 195, 247, 51} // Default blue with 20% opacity
}
// Draw zone fill
if zone.Highlight {
// Brighter fill for highlighted zone
r.dc.SetColor(color.RGBA{
R: uint8(math.Min(255, float64(zoneColor.R) * 1.5)),
G: uint8(math.Min(255, float64(zoneColor.G) * 1.5)),
B: uint8(math.Min(255, float64(zoneColor.B) * 1.5)),
A: 150, // Higher opacity for highlight
})
r.dc.DrawRectangle(x, y, w, h)
r.dc.Fill()
// White border for highlighted zone
r.dc.SetLineWidth(2)
r.dc.SetColor(color.RGBA{255, 255, 255, 255})
r.dc.DrawRectangle(x, y, w, h)
r.dc.Stroke()
} else {
// Normal semi-transparent fill
r.dc.SetColor(color.RGBA{
R: zoneColor.R,
G: zoneColor.G,
B: zoneColor.B,
A: 51, // 20% opacity
})
r.dc.DrawRectangle(x, y, w, h)
r.dc.Fill()
// Thin white outline
r.dc.SetLineWidth(1)
r.dc.SetColor(color.RGBA{255, 255, 255, 100})
r.dc.DrawRectangle(x, y, w, h)
r.dc.Stroke()
}
// Draw zone label (if space permits)
if w > 30 && h > 15 {
r.dc.SetColor(color.RGBA{255, 255, 255, 200})
// Truncate name if too long
label := zone.Name
if len(label) > 10 {
label = label[:7] + "..."
}
tw, th := r.dc.MeasureString(label)
r.dc.DrawStringAnchored(label, x+w/2, y+h/2, 0.5, 0.5)
_ = tw
_ = th
}
}
// drawPortal draws a portal as a purple line.
func (r *Renderer) drawPortal(portal Portal, offsetX, offsetY, scale float64) {
x1 := offsetX + portal.X1*scale
y1 := offsetY + portal.Y1*scale
x2 := offsetX + portal.X2*scale
y2 := offsetY + portal.Y2*scale
r.dc.SetLineWidth(2)
r.dc.SetColor(color.RGBA{168, 85, 247, 255}) // Purple
r.dc.DrawLine(x1, y1, x2, y2)
r.dc.Stroke()
}
// drawNode draws a node position as a small white circle.
func (r *Renderer) drawNode(node Node, offsetX, offsetY, scale float64) {
x := offsetX + node.X*scale
y := offsetY + node.Y*scale
r.dc.SetColor(color.RGBA{255, 255, 255, 255})
r.dc.DrawCircle(x, y, 3)
r.dc.Fill()
}
// drawPerson draws a person as a colored circle with name label.
func (r *Renderer) drawPerson(person Person, offsetX, offsetY, scale float64) {
x := offsetX + person.X*scale
y := offsetY + person.Y*scale
// Parse person color
personColor := r.parseColor(person.Color)
if personColor.A == 0 {
if person.IsFall {
personColor = color.RGBA{239, 83, 80, 255} // Red for fall
} else {
personColor = color.RGBA{136, 136, 136, 255} // Gray for unknown
}
}
// Diameter proportional to confidence (10px to 20px)
diameter := 10.0 + person.Confidence*10.0
if diameter > 20 {
diameter = 20
}
if diameter < 10 {
diameter = 10
}
// Draw filled circle
r.dc.SetColor(personColor)
r.dc.DrawCircle(x, y, diameter/2)
r.dc.Fill()
// Draw white outline
r.dc.SetLineWidth(1.5)
r.dc.SetColor(color.RGBA{255, 255, 255, 255})
r.dc.DrawCircle(x, y, diameter/2)
r.dc.Stroke()
// Draw name label above circle
if person.Name != "" {
r.dc.SetColor(color.RGBA{255, 255, 255, 255})
r.dc.DrawStringAnchored(person.Name, x, y-diameter/2-2, 0.5, 1.0)
}
}
// drawEventTitle draws the event title at the bottom.
func (r *Renderer) drawEventTitle() {
r.dc.SetColor(color.RGBA{255, 255, 255, 200})
// Draw at bottom-left with margin
margin := 10.0
r.dc.DrawStringWrapped(r.config.EventTitle, margin, float64(r.config.Height)-margin-10, 0, 0, float64(r.config.Width)-2*margin, 1.0, gg.AlignLeft)
}
// parseColor parses a hex color string or returns a default color.
func (r *Renderer) parseColor(hex string) color.RGBA {
if len(hex) == 0 {
return color.RGBA{}
}
var rVal, gVal, bVal uint8
n, _ := fmt.Sscanf(hex, "#%02x%02x%02x", &rVal, &gVal, &bVal)
if n == 3 {
return color.RGBA{R: rVal, G: gVal, B: bVal, A: 255}
}
// Try with alpha
var aVal uint8
n, _ = fmt.Sscanf(hex, "#%02x%02x%02x%02x", &rVal, &gVal, &bVal, &aVal)
if n == 4 {
return color.RGBA{R: rVal, G: gVal, B: bVal, A: aVal}
}
return color.RGBA{}
}
// GenerateThumbnail generates a floor-plan thumbnail with the given configuration.
func GenerateThumbnail(config RenderConfig) ([]byte, error) {
renderer := NewRenderer(config)
return renderer.Render()
}
// GenerateZoneEnterThumbnail generates a thumbnail for zone entry event.
func GenerateZoneEnterThumbnail(roomWidth, roomDepth float64, zones []Zone, person Person, zoneName string) ([]byte, error) {
// Highlight the zone where person entered
highlightedZones := make([]Zone, len(zones))
for i, z := range zones {
highlightedZones[i] = z
if z.Name == zoneName {
highlightedZones[i].Highlight = true
}
}
config := DefaultRenderConfig()
config.RoomWidth = roomWidth
config.RoomDepth = roomDepth
config.Zones = highlightedZones
config.People = []Person{person}
config.EventType = NotificationZoneEnter
config.EventTitle = fmt.Sprintf("%s entered %s", person.Name, zoneName)
return GenerateThumbnail(config)
}
// GenerateFallDetectedThumbnail generates a thumbnail for fall detection event.
func GenerateFallDetectedThumbnail(roomWidth, roomDepth float64, zones []Zone, person Person, zoneName string) ([]byte, error) {
// Highlight the zone where fall occurred
highlightedZones := make([]Zone, len(zones))
for i, z := range zones {
highlightedZones[i] = z
if z.Name == zoneName {
highlightedZones[i].Highlight = true
}
}
// Mark person as fallen
fallenPerson := person
fallenPerson.IsFall = true
config := DefaultRenderConfig()
config.RoomWidth = roomWidth
config.RoomDepth = roomDepth
config.Zones = highlightedZones
config.People = []Person{fallenPerson}
config.EventType = NotificationFallDetected
config.EventTitle = fmt.Sprintf("Fall: %s in %s", person.Name, zoneName)
return GenerateThumbnail(config)
}
// GenerateAnomalyAlertThumbnail generates a thumbnail for anomaly alert.
func GenerateAnomalyAlertThumbnail(roomWidth, roomDepth float64, zones []Zone, zoneName string) ([]byte, error) {
// Highlight the anomalous zone
highlightedZones := make([]Zone, len(zones))
for i, z := range zones {
highlightedZones[i] = z
if z.Name == zoneName {
highlightedZones[i].Highlight = true
}
}
config := DefaultRenderConfig()
config.RoomWidth = roomWidth
config.RoomDepth = roomDepth
config.Zones = highlightedZones
config.EventType = NotificationAnomalyAlert
config.EventTitle = fmt.Sprintf("Unusual activity in %s", zoneName)
return GenerateThumbnail(config)
}
// GenerateSleepSummaryThumbnail generates a thumbnail for sleep summary.
func GenerateSleepSummaryThumbnail(roomWidth, roomDepth float64, zones []Zone, person Person, duration string) ([]byte, error) {
config := DefaultRenderConfig()
config.RoomWidth = roomWidth
config.RoomDepth = roomDepth
config.Zones = zones
config.People = []Person{person}
config.EventType = NotificationSleepSummary
config.EventTitle = fmt.Sprintf("Sleep: %s (last night)", duration)
return GenerateThumbnail(config)
}