- 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>
424 lines
12 KiB
Go
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)
|
|
}
|