Skip to content

Commit d583758

Browse files
committed
feat(snapshot): generate annotated event snapshots with bounding boxes
Wire snapshot generation into the event flow: - When a new detection track is confirmed, capture the current frame, draw all tracked bounding boxes with color-coded labels, and save as JPEG alongside the event - Add SnapshotConsumer that decodes IDR frames from the main (high-res) stream and caches the latest full-resolution frame; event snapshots use this when available, falling back to detection resolution - Scale bounding box coordinates from detection to full resolution - Save snapshots asynchronously to avoid blocking the detection pipeline - Fix event snapshot endpoint to serve inline for <img> tags (attachment only with ?download=1) - Add snapshot retention cleanup tied to event_retain_days config - Remove unused internal/event package (dead code)
1 parent 1cb1702 commit d583758

12 files changed

Lines changed: 417 additions & 427 deletions

File tree

cmd/vedetta/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func main() {
7070

7171
slog.Info("native Go media pipeline active (no ffmpeg required)")
7272

73-
recorder := recording.New(cfg.Recording, db, hub)
73+
recorder := recording.New(cfg.Recording, db, hub, cfg.Events.SnapshotPath)
7474

7575
// Register cameras for recording
7676
for _, cam := range cfg.Cameras {
@@ -101,7 +101,7 @@ func main() {
101101

102102
events := make(chan camera.Event, 100)
103103

104-
manager := camera.NewManager(cfg.Cameras, detector, events, hub)
104+
manager := camera.NewManager(cfg.Cameras, detector, events, hub, cfg.Events.SnapshotPath, cfg.Events.SnapshotQuality)
105105
manager.Start(ctx)
106106

107107
// Periodically publish camera online/offline status to MQTT.

internal/api/server.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,11 @@ func (s *Server) handleEventSnapshot(w http.ResponseWriter, r *http.Request) {
293293
writeJSON(w, http.StatusNotFound, map[string]string{"error": "snapshot not found"})
294294
return
295295
}
296+
// Use inline disposition for <img> tags; download param triggers attachment
296297
filename := fmt.Sprintf("%s_%s.jpg", event.ID, event.Label)
297-
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
298+
if r.URL.Query().Get("download") != "" {
299+
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
300+
}
298301
http.ServeFile(w, r, event.SnapshotPath)
299302
}
300303

@@ -728,7 +731,7 @@ func (s *Server) handleEventDetailPartial(w http.ResponseWriter, r *http.Request
728731
`{{if .ClipPath}}<a href="/api/events/{{.ID}}/clip" download class="download-row">` +
729732
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>` +
730733
` Download Clip</a>{{end}}` +
731-
`{{if .SnapshotPath}}<a href="/api/events/{{.ID}}/snapshot" download class="download-row">` +
734+
`{{if .SnapshotPath}}<a href="/api/events/{{.ID}}/snapshot?download=1" download class="download-row">` +
732735
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>` +
733736
` Download Snapshot</a>{{end}}` +
734737
`{{if not .ClipPath}}{{if not .SnapshotPath}}<div class="download-row disabled">No media available</div>{{end}}{{end}}` +

internal/api/server_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ func newTestServer(t *testing.T) (*Server, *storage.DB) {
2424
}
2525
t.Cleanup(func() { _ = db.Close() })
2626

27-
mgr := camera.NewManager(nil, nil, nil, nil)
27+
mgr := camera.NewManager(nil, nil, nil, nil, "", 85)
2828
rec := recording.New(config.RecordingConfig{
2929
Path: t.TempDir(),
30-
}, db, nil)
30+
}, db, nil, "")
3131

3232
apiCfg := config.APIConfig{Host: "127.0.0.1", Port: 0}
3333
srv := New(apiCfg, db, mgr, rec, nil)

internal/camera/camera.go

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/rvben/vedetta/internal/detect"
1616
"github.com/rvben/vedetta/internal/media"
1717
"github.com/rvben/vedetta/internal/rtsp"
18+
"github.com/rvben/vedetta/internal/snapshot"
1819
)
1920

2021
// Event represents a detected object event from a camera.
@@ -36,7 +37,10 @@ type Camera struct {
3637
tracker *detect.Tracker
3738
motionDetector *detect.MotionDetector
3839
events chan<- Event
39-
hub *rtsp.Hub
40+
hub *rtsp.Hub
41+
eventSnapDir string
42+
eventSnapQuality int
43+
snapConsumer *media.SnapshotConsumer
4044

4145
mu sync.RWMutex
4246
rawFrame []byte // RGB24 frame data, guarded by mu
@@ -55,14 +59,19 @@ type CameraStatus struct {
5559
LastFrame time.Time `json:"last_frame"`
5660
}
5761

58-
func NewCamera(cfg config.CameraConfig, detector *detect.Detector, events chan<- Event, hub *rtsp.Hub) *Camera {
62+
func NewCamera(cfg config.CameraConfig, detector *detect.Detector, events chan<- Event, hub *rtsp.Hub, snapshotPath string, snapshotQuality int) *Camera {
63+
if snapshotQuality <= 0 {
64+
snapshotQuality = 85
65+
}
5966
return &Camera{
6067
config: cfg,
6168
detector: detector,
6269
tracker: detect.NewTracker(30, 3),
6370
motionDetector: detect.NewMotionDetector(25, 200, 0.05),
6471
events: events,
6572
hub: hub,
73+
eventSnapDir: snapshotPath,
74+
eventSnapQuality: snapshotQuality,
6675
confirmedTracks: make(map[int]bool),
6776
}
6877
}
@@ -200,6 +209,9 @@ func (c *Camera) readFrames(ctx context.Context) {
200209
source.AddConsumer(consumer)
201210
defer source.RemoveConsumer(consumer)
202211

212+
// Attach snapshot consumer to the main (high-res) stream for event snapshots
213+
c.startSnapshotConsumer(ctx)
214+
203215
for {
204216
select {
205217
case <-ctx.Done():
@@ -210,6 +222,50 @@ func (c *Camera) readFrames(ctx context.Context) {
210222
}
211223
}
212224

225+
// startSnapshotConsumer attaches a decoder to the main stream that caches
226+
// the latest full-resolution frame for use in event snapshots.
227+
func (c *Camera) startSnapshotConsumer(ctx context.Context) {
228+
recordURL := c.RecordURL()
229+
if recordURL == c.config.URL {
230+
// Same stream for detect and record — no benefit from a separate consumer
231+
return
232+
}
233+
234+
mainSource := c.hub.GetOrCreate(recordURL)
235+
236+
// Wait briefly for track info (main stream may already be connected for recording)
237+
var videoTrack *rtsp.TrackInfo
238+
for i := 0; i < 10; i++ {
239+
videoTrack = mainSource.VideoTrack()
240+
if videoTrack != nil {
241+
break
242+
}
243+
select {
244+
case <-ctx.Done():
245+
return
246+
case <-time.After(500 * time.Millisecond):
247+
}
248+
}
249+
if videoTrack == nil {
250+
slog.Warn("snapshot consumer: main stream not available", "camera", c.config.Name)
251+
return
252+
}
253+
254+
sc := media.NewSnapshotConsumer(c.config.Name, videoTrack)
255+
if sc == nil {
256+
return
257+
}
258+
259+
c.snapConsumer = sc
260+
mainSource.AddConsumer(sc)
261+
262+
go func() {
263+
<-ctx.Done()
264+
mainSource.RemoveConsumer(sc)
265+
sc.Close()
266+
}()
267+
}
268+
213269
// processFrame handles a decoded RGB24 frame — motion detection + YOLO.
214270
func (c *Camera) processFrame(buf []byte, w, h int) {
215271
frameSize := w * h * 3
@@ -238,18 +294,87 @@ func (c *Camera) processFrame(buf []byte, w, h int) {
238294
detections := c.detector.DetectRGB24(buf, w, h)
239295
tracked := c.tracker.Update(detections)
240296

297+
// Collect all current detections for annotation
298+
allDetections := make([]detect.Detection, len(tracked))
299+
for i, obj := range tracked {
300+
allDetections[i] = detect.Detection{
301+
Label: obj.Label,
302+
Score: obj.Score,
303+
Box: obj.Box,
304+
}
305+
}
306+
307+
// Generate one annotated frame with ALL bounding boxes (reused for all new events).
308+
// Prefer the full-resolution main stream frame; fall back to the detection frame.
309+
var annotatedFrame *image.RGBA
310+
if c.eventSnapDir != "" {
311+
hasNewTrack := false
312+
for _, obj := range tracked {
313+
if !c.confirmedTracks[obj.TrackID] {
314+
hasNewTrack = true
315+
break
316+
}
317+
}
318+
if hasNewTrack {
319+
var fullRes *image.RGBA
320+
if sc := c.snapConsumer; sc != nil {
321+
fullRes = sc.LastFrame()
322+
}
323+
if fullRes != nil {
324+
// Copy so we don't mutate the snapshot consumer's cached frame
325+
annotatedFrame = image.NewRGBA(fullRes.Bounds())
326+
copy(annotatedFrame.Pix, fullRes.Pix)
327+
// Scale detection boxes from detect resolution to full resolution
328+
frameW := annotatedFrame.Bounds().Dx()
329+
frameH := annotatedFrame.Bounds().Dy()
330+
scaled := make([]detect.Detection, len(allDetections))
331+
for i, d := range allDetections {
332+
scaled[i] = detect.Detection{
333+
Label: d.Label,
334+
Score: d.Score,
335+
Box: [4]int{
336+
d.Box[0] * frameW / w,
337+
d.Box[1] * frameH / h,
338+
d.Box[2] * frameW / w,
339+
d.Box[3] * frameH / h,
340+
},
341+
}
342+
}
343+
snapshot.DrawDetectionsInPlace(annotatedFrame, scaled)
344+
} else {
345+
// No full-res frame available, use detection frame
346+
annotatedFrame = rawToRGBA(buf, w, h)
347+
snapshot.DrawDetectionsInPlace(annotatedFrame, allDetections)
348+
}
349+
}
350+
}
351+
241352
// Emit events for newly confirmed tracks
242353
for _, obj := range tracked {
243354
if !c.confirmedTracks[obj.TrackID] {
244355
c.confirmedTracks[obj.TrackID] = true
245-
c.events <- Event{
246-
ID: fmt.Sprintf("%s-t%d-%d", c.config.Name, obj.TrackID, time.Now().UnixMilli()),
356+
eventID := fmt.Sprintf("%s-t%d-%d", c.config.Name, obj.TrackID, time.Now().UnixMilli())
357+
ev := Event{
358+
ID: eventID,
247359
CameraName: c.config.Name,
248360
Label: obj.Label,
249361
Score: obj.Score,
250362
Box: obj.Box,
251363
Timestamp: time.Now(),
252364
}
365+
366+
if annotatedFrame != nil {
367+
snapFile := filepath.Join(c.eventSnapDir, c.config.Name, eventID+".jpg")
368+
ev.SnapshotPath = snapFile
369+
quality := c.eventSnapQuality
370+
go func() {
371+
if err := snapshot.SaveSnapshot(annotatedFrame, snapFile, quality); err != nil {
372+
slog.Error("failed to save event snapshot", "event", eventID, "error", err)
373+
}
374+
}()
375+
}
376+
377+
c.events <- ev
253378
}
254379
}
255380

internal/camera/camera_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ func TestSnapshotRGB24_NoFrame(t *testing.T) {
1212
cam := NewCamera(config.CameraConfig{
1313
Name: "test",
1414
Detect: config.StreamConfig{Width: 64, Height: 64, FPS: 5},
15-
}, nil, make(chan<- Event, 1), nil)
15+
}, nil, make(chan<- Event, 1), nil, "", 85)
1616

1717
dst := make([]byte, 64*64*3)
1818
_, _, ok := cam.SnapshotRGB24(dst)
@@ -25,7 +25,7 @@ func TestSnapshotRGB24_CopiesFrame(t *testing.T) {
2525
cam := NewCamera(config.CameraConfig{
2626
Name: "test",
2727
Detect: config.StreamConfig{Width: 4, Height: 4, FPS: 5},
28-
}, nil, make(chan<- Event, 1), nil)
28+
}, nil, make(chan<- Event, 1), nil, "", 85)
2929

3030
frameSize := 4 * 4 * 3
3131
frame := make([]byte, frameSize)
@@ -59,7 +59,7 @@ func TestSnapshotRGB24_DstTooSmall(t *testing.T) {
5959
cam := NewCamera(config.CameraConfig{
6060
Name: "test",
6161
Detect: config.StreamConfig{Width: 4, Height: 4, FPS: 5},
62-
}, nil, make(chan<- Event, 1), nil)
62+
}, nil, make(chan<- Event, 1), nil, "", 85)
6363

6464
frameSize := 4 * 4 * 3
6565
cam.mu.Lock()
@@ -79,7 +79,7 @@ func TestFrameSize(t *testing.T) {
7979
cam := NewCamera(config.CameraConfig{
8080
Name: "test",
8181
Detect: config.StreamConfig{Width: 320, Height: 240, FPS: 5},
82-
}, nil, make(chan<- Event, 1), nil)
82+
}, nil, make(chan<- Event, 1), nil, "", 85)
8383

8484
expected := 320 * 240 * 3
8585
if got := cam.FrameSize(); got != expected {
@@ -91,7 +91,7 @@ func TestIsOnline_NoHub(t *testing.T) {
9191
cam := NewCamera(config.CameraConfig{
9292
Name: "test",
9393
URL: "rtsp://localhost/test",
94-
}, nil, make(chan<- Event, 1), nil)
94+
}, nil, make(chan<- Event, 1), nil, "", 85)
9595

9696
if cam.IsOnline() {
9797
t.Error("expected IsOnline=false with nil hub")
@@ -107,7 +107,7 @@ func TestIsOnline_NoSource(t *testing.T) {
107107
cam := NewCamera(config.CameraConfig{
108108
Name: "test",
109109
URL: "rtsp://localhost/test",
110-
}, nil, make(chan<- Event, 1), hub)
110+
}, nil, make(chan<- Event, 1), hub, "", 85)
111111

112112
// No source created for this URL yet
113113
if cam.IsOnline() {
@@ -119,7 +119,7 @@ func TestStatus_NoHub(t *testing.T) {
119119
cam := NewCamera(config.CameraConfig{
120120
Name: "test",
121121
URL: "rtsp://localhost/test",
122-
}, nil, make(chan<- Event, 1), nil)
122+
}, nil, make(chan<- Event, 1), nil, "", 85)
123123

124124
st := cam.Status()
125125
if st.Online {

internal/camera/manager.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type Manager struct {
2020
mu sync.RWMutex
2121
}
2222

23-
func NewManager(configs []config.CameraConfig, detector *detect.Detector, events chan<- Event, hub *rtsp.Hub) *Manager {
23+
func NewManager(configs []config.CameraConfig, detector *detect.Detector, events chan<- Event, hub *rtsp.Hub, snapshotPath string, snapshotQuality int) *Manager {
2424
m := &Manager{
2525
cameras: make(map[string]*Camera),
2626
detector: detector,
@@ -30,7 +30,7 @@ func NewManager(configs []config.CameraConfig, detector *detect.Detector, events
3030

3131
for _, cfg := range configs {
3232
if cfg.Enabled {
33-
cam := NewCamera(cfg, detector, events, hub)
33+
cam := NewCamera(cfg, detector, events, hub, snapshotPath, snapshotQuality)
3434
m.cameras[cfg.Name] = cam
3535
}
3636
}

0 commit comments

Comments
 (0)