Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 97 additions & 12 deletions components/camera/camera.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import (
"context"
"fmt"
"image"
"time"

"github.com/pkg/errors"
pb "go.viam.com/api/component/camera/v1"

"go.viam.com/rdk/data"
"go.viam.com/rdk/gostream"
"go.viam.com/rdk/logging"
"go.viam.com/rdk/pointcloud"
"go.viam.com/rdk/resource"
"go.viam.com/rdk/rimage"
Expand Down Expand Up @@ -143,18 +145,6 @@ type Camera interface {
Properties(ctx context.Context) (Properties, error)
}

// VideoSource is a camera that has `Stream` embedded to directly integrate with gostream.
// Note that generally, when writing camera components from scratch, embedding `Stream` is an anti-pattern.
type VideoSource interface {
Camera
Stream(ctx context.Context, errHandlers ...gostream.ErrorHandler) (gostream.VideoStream, error)
}

// ReadImage reads an image from the given source that is immediately available.
func ReadImage(ctx context.Context, src gostream.VideoSource) (image.Image, func(), error) {
return gostream.ReadImage(ctx, src)
}

// DecodeImageFromCamera retrieves image bytes from a camera resource and serializes it as an image.Image.
func DecodeImageFromCamera(ctx context.Context, mimeType string, extra map[string]interface{}, cam Camera) (image.Image, error) {
resBytes, resMetadata, err := cam.Image(ctx, mimeType, extra)
Expand All @@ -171,6 +161,101 @@ func DecodeImageFromCamera(ctx context.Context, mimeType string, extra map[strin
return img, nil
}

// GetImageFromGetImages is a utility function to quickly implement GetImage from an already-implemented GetImages method.
// It returns a byte slice and ImageMetadata, which is the same response signature as the Image method.
//
// If sourceName is nil, it returns the first image in the response slice.
// If sourceName is not nil, it returns the image with the matching source name.
// If no image is found with the matching source name, it returns an error.
//
// It uses the mimeType arg to specify how to encode the bytes returned from GetImages.
func GetImageFromGetImages(ctx context.Context, sourceName *string, mimeType string, cam Camera) ([]byte, ImageMetadata, error) {
// TODO(RSDK-10991): pass through extra field when implemented
images, _, err := cam.Images(ctx)
if err != nil {
return nil, ImageMetadata{}, fmt.Errorf("could not get images from camera: %w", err)
}
if len(images) == 0 {
return nil, ImageMetadata{}, errors.New("no images returned from camera")
}

// if mimeType is empty, use JPEG as default
if mimeType == "" {
mimeType = utils.MimeTypeJPEG
}

var img image.Image
if sourceName == nil {
img = images[0].Image
} else {
for _, i := range images {
if i.SourceName == *sourceName {
img = i.Image
break
}
}
if img == nil {
return nil, ImageMetadata{}, errors.New("no image found with source name: " + *sourceName)
}
}

if img == nil {
return nil, ImageMetadata{}, errors.New("image is nil")
}

imgBytes, err := rimage.EncodeImage(ctx, img, mimeType)
if err != nil {
return nil, ImageMetadata{}, fmt.Errorf("could not encode image: %w", err)
}
return imgBytes, ImageMetadata{MimeType: mimeType}, nil
}

// GetImagesFromGetImage is a utility function to quickly implement GetImages from an already-implemented GetImage method.
// It takes a mimeType and a camera as args, and returns a slice of NamedImage and ResponseMetadata,
// which is the same response signature as the Images method. We use the mimeType arg to specify
// how to decode the image bytes returned from GetImage. Source name is empty string always.
// It returns a slice of NamedImage of length 1 and ResponseMetadata, using the camera's name as the source name.
func GetImagesFromGetImage(
ctx context.Context,
mimeType string,
cam Camera,
logger logging.Logger,
) ([]NamedImage, resource.ResponseMetadata, error) {
// TODO(RSDK-10991): pass through extra field when implemented
resBytes, resMetadata, err := cam.Image(ctx, mimeType, nil)
if err != nil {
return nil, resource.ResponseMetadata{}, fmt.Errorf("could not get image bytes from camera: %w", err)
}
if len(resBytes) == 0 {
return nil, resource.ResponseMetadata{}, errors.New("received empty bytes from camera")
}

resMimetype, _ := utils.CheckLazyMIMEType(resMetadata.MimeType)
reqMimetype, _ := utils.CheckLazyMIMEType(mimeType)
if resMimetype != reqMimetype {
logger.Warnf("requested mime type %s, but received %s", mimeType, resMimetype)
}

img, err := rimage.DecodeImage(ctx, resBytes, utils.WithLazyMIMEType(resMetadata.MimeType))
if err != nil {
return nil, resource.ResponseMetadata{}, fmt.Errorf("could not decode into image.Image: %w", err)
}

return []NamedImage{{Image: img, SourceName: ""}}, resource.ResponseMetadata{CapturedAt: time.Now()}, nil
}

// VideoSource is a camera that has `Stream` embedded to directly integrate with gostream.
// Note that generally, when writing camera components from scratch, embedding `Stream` is an anti-pattern.
type VideoSource interface {
Camera
Stream(ctx context.Context, errHandlers ...gostream.ErrorHandler) (gostream.VideoStream, error)
}

// ReadImage reads an image from the given source that is immediately available.
func ReadImage(ctx context.Context, src gostream.VideoSource) (image.Image, func(), error) {
return gostream.ReadImage(ctx, src)
}

// A PointCloudSource is a source that can generate pointclouds.
type PointCloudSource interface {
NextPointCloud(ctx context.Context) (pointcloud.PointCloud, error)
Expand Down
210 changes: 210 additions & 0 deletions components/camera/camera_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"image"
"testing"
"time"

"github.com/pkg/errors"
"go.viam.com/test"
Expand All @@ -15,6 +16,7 @@ import (
"go.viam.com/rdk/resource"
"go.viam.com/rdk/rimage"
"go.viam.com/rdk/rimage/transform"
"go.viam.com/rdk/testutils/inject"
rutils "go.viam.com/rdk/utils"
)

Expand All @@ -23,6 +25,8 @@ const (
depthCameraName = "camera_depth"
failCameraName = "camera2"
missingCameraName = "camera3"
source1Name = "source1"
source2Name = "source2"
)

type simpleSource struct {
Expand Down Expand Up @@ -250,3 +254,209 @@ func TestCameraWithProjector(t *testing.T) {

test.That(t, cam2.Close(context.Background()), test.ShouldBeNil)
}

// verifyImageEquality compares two images and verifies they are identical.
func verifyImageEquality(t *testing.T, img1, img2 image.Image) {
t.Helper()
diff, _, err := rimage.CompareImages(img1, img2)
test.That(t, err, test.ShouldBeNil)
test.That(t, diff, test.ShouldEqual, 0)
}

// verifyDecodedImage verifies that decoded image bytes match the original image.
func verifyDecodedImage(t *testing.T, imgBytes []byte, mimeType string, originalImg image.Image) {
t.Helper()
test.That(t, len(imgBytes), test.ShouldBeGreaterThan, 0)

// For JPEG, compare the raw bytes instead of the decoded image since the decoded image is
// not guaranteed to be the same as the original image due to lossy compression.
if mimeType == rutils.MimeTypeJPEG {
expectedBytes, err := rimage.EncodeImage(context.Background(), originalImg, mimeType)
test.That(t, err, test.ShouldBeNil)
test.That(t, imgBytes, test.ShouldResemble, expectedBytes)
return
}

// For other formats, compare the decoded images
decodedImg, err := rimage.DecodeImage(context.Background(), imgBytes, mimeType)
test.That(t, err, test.ShouldBeNil)
verifyImageEquality(t, decodedImg, originalImg)
}

func TestGetImageFromGetImages(t *testing.T) {
testImg1 := image.NewRGBA(image.Rect(0, 0, 100, 100))
testImg2 := image.NewRGBA(image.Rect(0, 0, 200, 200))

rgbaCam := inject.NewCamera("rgba_cam")
rgbaCam.ImagesFunc = func(ctx context.Context) ([]camera.NamedImage, resource.ResponseMetadata, error) {
return []camera.NamedImage{
{Image: testImg1, SourceName: source1Name},
{Image: testImg2, SourceName: source2Name},
}, resource.ResponseMetadata{CapturedAt: time.Now()}, nil
}

dm := rimage.NewEmptyDepthMap(100, 100)
depthCam := inject.NewCamera("depth_cam")
depthCam.ImagesFunc = func(ctx context.Context) ([]camera.NamedImage, resource.ResponseMetadata, error) {
return []camera.NamedImage{{Image: dm, SourceName: source1Name}}, resource.ResponseMetadata{CapturedAt: time.Now()}, nil
}

t.Run("PNG mime type", func(t *testing.T) {
imgBytes, metadata, err := camera.GetImageFromGetImages(context.Background(), nil, rutils.MimeTypePNG, rgbaCam)
test.That(t, err, test.ShouldBeNil)
test.That(t, metadata.MimeType, test.ShouldEqual, rutils.MimeTypePNG)
verifyDecodedImage(t, imgBytes, rutils.MimeTypePNG, testImg1)
})

t.Run("JPEG mime type", func(t *testing.T) {
imgBytes, metadata, err := camera.GetImageFromGetImages(context.Background(), nil, rutils.MimeTypeJPEG, rgbaCam)
test.That(t, err, test.ShouldBeNil)
test.That(t, metadata.MimeType, test.ShouldEqual, rutils.MimeTypeJPEG)
verifyDecodedImage(t, imgBytes, rutils.MimeTypeJPEG, testImg1)
})

t.Run("request mime type depth, but actual image is RGBA", func(t *testing.T) {
_, _, err := camera.GetImageFromGetImages(context.Background(), nil, rutils.MimeTypeRawDepth, rgbaCam)
test.That(t, err.Error(), test.ShouldContainSubstring, "cannot convert image type")
})

t.Run("request JPEG, but actual image is depth map", func(t *testing.T) {
img, metadata, err := camera.GetImageFromGetImages(context.Background(), nil, rutils.MimeTypeJPEG, depthCam)
test.That(t, err, test.ShouldBeNil) // expect success because we can convert the depth map to JPEG
test.That(t, metadata.MimeType, test.ShouldEqual, rutils.MimeTypeJPEG)
verifyDecodedImage(t, img, rutils.MimeTypeJPEG, dm)
})

t.Run("request PNG, but actual image is depth map", func(t *testing.T) {
img, metadata, err := camera.GetImageFromGetImages(context.Background(), nil, rutils.MimeTypePNG, depthCam)
test.That(t, err, test.ShouldBeNil) // expect success because we can convert the depth map to PNG
test.That(t, metadata.MimeType, test.ShouldEqual, rutils.MimeTypePNG)
verifyDecodedImage(t, img, rutils.MimeTypePNG, dm)
})

t.Run("request empty mime type", func(t *testing.T) {
img, metadata, err := camera.GetImageFromGetImages(context.Background(), nil, "", rgbaCam)
// empty mime type defaults to JPEG
test.That(t, err, test.ShouldBeNil)
test.That(t, img, test.ShouldNotBeNil)
test.That(t, metadata.MimeType, test.ShouldEqual, rutils.MimeTypeJPEG)
verifyDecodedImage(t, img, rutils.MimeTypeJPEG, testImg1)
})

t.Run("error case", func(t *testing.T) {
errorCam := inject.NewCamera("error_cam")
errorCam.ImagesFunc = func(ctx context.Context) ([]camera.NamedImage, resource.ResponseMetadata, error) {
return nil, resource.ResponseMetadata{}, errors.New("test error")
}
_, _, err := camera.GetImageFromGetImages(context.Background(), nil, rutils.MimeTypePNG, errorCam)
test.That(t, err, test.ShouldBeError, errors.New("could not get images from camera: test error"))
})

t.Run("empty images case", func(t *testing.T) {
emptyCam := inject.NewCamera("empty_cam")
emptyCam.ImagesFunc = func(ctx context.Context) ([]camera.NamedImage, resource.ResponseMetadata, error) {
return []camera.NamedImage{}, resource.ResponseMetadata{CapturedAt: time.Now()}, nil
}
_, _, err := camera.GetImageFromGetImages(context.Background(), nil, rutils.MimeTypePNG, emptyCam)
test.That(t, err, test.ShouldBeError, errors.New("no images returned from camera"))
})

t.Run("nil image case", func(t *testing.T) {
nilImageCam := inject.NewCamera("nil_image_cam")
nilImageCam.ImagesFunc = func(ctx context.Context) ([]camera.NamedImage, resource.ResponseMetadata, error) {
return []camera.NamedImage{{Image: nil, SourceName: source1Name}}, resource.ResponseMetadata{CapturedAt: time.Now()}, nil
}
_, _, err := camera.GetImageFromGetImages(context.Background(), nil, rutils.MimeTypePNG, nilImageCam)
test.That(t, err, test.ShouldBeError, errors.New("image is nil"))
})

t.Run("multiple images, specify source name", func(t *testing.T) {
sourceName := source2Name
img, metadata, err := camera.GetImageFromGetImages(context.Background(), &sourceName, rutils.MimeTypePNG, rgbaCam)
test.That(t, err, test.ShouldBeNil)
test.That(t, metadata.MimeType, test.ShouldEqual, rutils.MimeTypePNG)
verifyDecodedImage(t, img, rutils.MimeTypePNG, testImg2)
})
}

func TestGetImagesFromGetImage(t *testing.T) {
logger := logging.NewTestLogger(t)
testImg := image.NewRGBA(image.Rect(0, 0, 100, 100))

rgbaCam := inject.NewCamera("rgba_cam")
rgbaCam.ImageFunc = func(ctx context.Context, mimeType string, extra map[string]interface{}) ([]byte, camera.ImageMetadata, error) {
imgBytes, err := rimage.EncodeImage(ctx, testImg, mimeType)
if err != nil {
return nil, camera.ImageMetadata{}, err
}
return imgBytes, camera.ImageMetadata{MimeType: mimeType}, nil
}

t.Run("PNG mime type", func(t *testing.T) {
startTime := time.Now()
images, metadata, err := camera.GetImagesFromGetImage(context.Background(), rutils.MimeTypePNG, rgbaCam, logger)
endTime := time.Now()
test.That(t, err, test.ShouldBeNil)
test.That(t, len(images), test.ShouldEqual, 1)
test.That(t, images[0].SourceName, test.ShouldEqual, "")
verifyImageEquality(t, images[0].Image, testImg)
test.That(t, metadata.CapturedAt.IsZero(), test.ShouldBeFalse)
test.That(t, metadata.CapturedAt.After(startTime), test.ShouldBeTrue)
test.That(t, metadata.CapturedAt.Before(endTime), test.ShouldBeTrue)
})

t.Run("JPEG mime type", func(t *testing.T) {
startTime := time.Now()
images, metadata, err := camera.GetImagesFromGetImage(context.Background(), rutils.MimeTypeJPEG, rgbaCam, logger)
endTime := time.Now()
test.That(t, err, test.ShouldBeNil)
test.That(t, len(images), test.ShouldEqual, 1)
test.That(t, images[0].SourceName, test.ShouldEqual, "")
imgBytes, err := rimage.EncodeImage(context.Background(), images[0].Image, rutils.MimeTypeJPEG)
test.That(t, err, test.ShouldBeNil)
verifyDecodedImage(t, imgBytes, rutils.MimeTypeJPEG, testImg)
test.That(t, metadata.CapturedAt.IsZero(), test.ShouldBeFalse)
test.That(t, metadata.CapturedAt.After(startTime), test.ShouldBeTrue)
test.That(t, metadata.CapturedAt.Before(endTime), test.ShouldBeTrue)
})

t.Run("request mime type depth, but actual image is RGBA", func(t *testing.T) {
rgbaImg := image.NewRGBA(image.Rect(0, 0, 100, 100))
rgbaCam := inject.NewCamera("rgba_cam")
rgbaCam.ImageFunc = func(ctx context.Context, reqMimeType string, extra map[string]interface{}) ([]byte, camera.ImageMetadata, error) {
imgBytes, err := rimage.EncodeImage(ctx, rgbaImg, rutils.MimeTypeRawRGBA)
if err != nil {
return nil, camera.ImageMetadata{}, err
}
return imgBytes, camera.ImageMetadata{MimeType: rutils.MimeTypeRawRGBA}, nil
}
startTime := time.Now()
images, metadata, err := camera.GetImagesFromGetImage(context.Background(), rutils.MimeTypeRawDepth, rgbaCam, logger)
endTime := time.Now()
test.That(t, err, test.ShouldBeNil)
test.That(t, len(images), test.ShouldEqual, 1)
test.That(t, images[0].SourceName, test.ShouldEqual, "")
test.That(t, metadata.CapturedAt.IsZero(), test.ShouldBeFalse)
test.That(t, metadata.CapturedAt.After(startTime), test.ShouldBeTrue)
test.That(t, metadata.CapturedAt.Before(endTime), test.ShouldBeTrue)
verifyImageEquality(t, images[0].Image, rgbaImg) // we should ignore the requested mime type and get back an RGBA image
})

t.Run("error case", func(t *testing.T) {
errorCam := inject.NewCamera("error_cam")
errorCam.ImageFunc = func(ctx context.Context, mimeType string, extra map[string]interface{}) ([]byte, camera.ImageMetadata, error) {
return nil, camera.ImageMetadata{}, errors.New("test error")
}
_, _, err := camera.GetImagesFromGetImage(context.Background(), rutils.MimeTypePNG, errorCam, logger)
test.That(t, err, test.ShouldBeError, errors.New("could not get image bytes from camera: test error"))
})

t.Run("empty bytes case", func(t *testing.T) {
emptyCam := inject.NewCamera("empty_cam")
emptyCam.ImageFunc = func(ctx context.Context, mimeType string, extra map[string]interface{}) ([]byte, camera.ImageMetadata, error) {
return []byte{}, camera.ImageMetadata{MimeType: mimeType}, nil
}
_, _, err := camera.GetImagesFromGetImage(context.Background(), rutils.MimeTypePNG, emptyCam, logger)
test.That(t, err, test.ShouldBeError, errors.New("received empty bytes from camera"))
})
}
Loading