@@ -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.
214270func (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
0 commit comments