Skip to content

Commit

Permalink
Media Utils (#27)
Browse files Browse the repository at this point in the history
  • Loading branch information
njooma authored May 10, 2024
1 parent 230e34f commit 8ea0736
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,10 @@
import com.viam.common.v1.Common;
import com.viam.common.v1.Common.GetGeometriesRequest;
import com.viam.common.v1.Common.ResponseMetadata;
import com.viam.component.camera.v1.Camera.Format;
import com.viam.component.camera.v1.Camera.GetImageRequest;
import com.viam.component.camera.v1.Camera.GetImageResponse;
import com.viam.component.camera.v1.Camera.GetImagesRequest;
import com.viam.component.camera.v1.Camera.GetImagesResponse;
import com.viam.component.camera.v1.Camera.Image;
import com.viam.component.camera.v1.Camera.*;
import com.viam.component.camera.v1.CameraServiceGrpc;
import com.viam.sdk.core.rpc.Channel;

import java.util.AbstractMap.SimpleEntry;
import java.util.List;
import java.util.Map;
Expand All @@ -24,55 +20,54 @@
*/
public class CameraRPCClient extends com.viam.sdk.core.component.camera.Camera {

private final CameraServiceGrpc.CameraServiceBlockingStub client;
private final CameraServiceGrpc.CameraServiceBlockingStub client;

public CameraRPCClient(final String name, final Channel chan) {
super(name);
final CameraServiceGrpc.CameraServiceBlockingStub client = CameraServiceGrpc.newBlockingStub(
chan);
if (chan.getCallCredentials().isPresent()) {
this.client = client.withCallCredentials(chan.getCallCredentials().get());
} else {
this.client = client;
public CameraRPCClient(final String name, final Channel chan) {
super(name);
final CameraServiceGrpc.CameraServiceBlockingStub client = CameraServiceGrpc.newBlockingStub(chan);
if (chan.getCallCredentials().isPresent()) {
this.client = client.withCallCredentials(chan.getCallCredentials().get());
} else {
this.client = client;
}
}
}

@Override
public Struct doCommand(final Map<String, Value> command) {
return client.doCommand(Common.DoCommandRequest.newBuilder().
setName(getName().getName()).
setCommand(Struct.newBuilder().putAllFields(command).build()).
build()).getResult();
}
@Override
public Struct doCommand(final Map<String, Value> command) {
return client.doCommand(Common.DoCommandRequest.newBuilder().
setName(getName().getName()).
setCommand(Struct.newBuilder().putAllFields(command).build()).
build()).getResult();
}

@Override
public List<Common.Geometry> getGeometries(final Optional<Struct> extra) {
final GetGeometriesRequest.Builder builder = Common.GetGeometriesRequest.newBuilder().
setName(getName().getName());
extra.ifPresent(builder::setExtra);
return client.getGeometries(builder.build()).getGeometriesList();
}
@Override
public List<Common.Geometry> getGeometries(final Optional<Struct> extra) {
final GetGeometriesRequest.Builder builder = Common.GetGeometriesRequest.newBuilder().
setName(getName().getName());
extra.ifPresent(builder::setExtra);
return client.getGeometries(builder.build()).getGeometriesList();
}

@Override
public Image getImage(final Format format,
Optional<Struct> extra) {
final GetImageRequest.Builder builder = GetImageRequest.newBuilder().
setName(getName().getName()).
setMimeType(Camera.formatToMime(format));
extra.ifPresent(builder::setExtra);
final GetImageResponse resp = client.getImage(builder.build());
final Image.Builder imgBuilder = Image.newBuilder().
setSourceName(getName().getName()).
setImage(resp.getImage());
@Override
public Image getImage(final Format format,
Optional<Struct> extra) {
final GetImageRequest.Builder builder = GetImageRequest.newBuilder().
setName(getName().getName()).
setMimeType(Camera.formatToMime(format));
extra.ifPresent(builder::setExtra);
final GetImageResponse resp = client.getImage(builder.build());
final Image.Builder imgBuilder = Image.newBuilder().
setSourceName(getName().getName()).
setImage(resp.getImage());

return imgBuilder.setFormat(Camera.mimeToFormat(resp.getMimeType())).build();
}
return imgBuilder.setFormat(Camera.mimeToFormat(resp.getMimeType())).build();
}

@Override
public Entry<List<Image>, ResponseMetadata> getImages() {
final GetImagesRequest.Builder builder = GetImagesRequest.newBuilder().
setName(getName().getName());
final GetImagesResponse resp = client.getImages(builder.build());
return new SimpleEntry<>(resp.getImagesList(), resp.getResponseMetadata());
}
@Override
public Entry<List<Image>, ResponseMetadata> getImages() {
final GetImagesRequest.Builder builder = GetImagesRequest.newBuilder().
setName(getName().getName());
final GetImagesResponse resp = client.getImages(builder.build());
return new SimpleEntry<>(resp.getImagesList(), resp.getResponseMetadata());
}
}
175 changes: 175 additions & 0 deletions core/sdk/src/main/java/com/viam/sdk/core/util/Media.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
@file:JvmName("MediaUtils")

package com.viam.sdk.core.util

import com.viam.component.camera.v1.Camera.Format
import com.viam.component.camera.v1.Camera.Image
import java.nio.ByteBuffer

/**
* The dimensions of an [Image] (width x height)
*/
data class ImageDimensions(val width: Int, val height: Int) {
override fun toString(): String {
return "$width x $height"
}
}

/**
* Get the dimensions of an image from its data and mime-type.
*
* Only specific mime-types are supported: PNG, JPEG, and a custom vnd.viam.rgba.
* The corresponding [Format] are [Format.FORMAT_PNG], [Format.FORMAT_JPEG], and [Format.FORMAT_RAW_RGBA].
*
* @param image the byte data of the image
* @param mimeType the mime-type of the image
*/
fun getDimensions(image: Iterable<Byte>, mimeType: String): ImageDimensions {
if (mimeType.contains("(?i)jpe?g".toRegex())) {
return getDimensionsFromJPEG(image)
}
if (mimeType.contains("png", ignoreCase = true)) {
return getDimensionsFromPNG(image)
}
if (mimeType.contains("viam.rgba", ignoreCase = true) || mimeType.contains("FORMAT_RAW_RGBA", ignoreCase = true)) {
return getDimensionsFromViamRGBA(image)
}
throw IllegalArgumentException("Unsupported mime type: $mimeType")
}

/**
* The dimensions of the image, if [Image.getFormat] returns a supported [Format].
* See [getDimensions] for more details.
*/
val Image.dimensions: ImageDimensions?
get() = try {
getDimensions(this.image, this.format.name)
} catch (e: Exception) {
null
}

/**
* The width of the image, if [Image.getFormat] returns a supported [Format].
* See [getDimensions] for more details.
*/
val Image.width: Int?
get() = this.dimensions?.width

/**
* The height of the image, if [Image.getFormat] returns a supported [Format].
* See [getDimensions] for more details.
*/
val Image.height: Int?
get() = this.dimensions?.height

private fun ByteArray.getShort(byteIndex: Int = 0): Short {
return ByteBuffer.wrap(this).getShort(byteIndex)
}

private fun ByteArray.getInt(byteIndex: Int = 0): Int {
return ByteBuffer.wrap(this).getInt(byteIndex)
}

private fun getDimensionsFromJPEG(image: Iterable<Byte>): ImageDimensions {
// JPEG Specification: https://www.w3.org/Graphics/JPEG/itu-t81.pdf
// Specification for markers: Table B.1
var offset = 0
while (offset < image.count()) {
while (image.elementAt(offset) == 0xFF.toByte()) {
// Skip all 0xFF bytes
offset += 1
}
val marker = image.elementAt(offset)
offset += 1
if (marker == 0x01.toByte()) {
// Temporary/private use marker
offset += 1
continue
}
if (marker.toInt() in 0xD0..0xD7) {
// Restart (RST) maker
offset += 1
continue
}
if (marker == 0xD8.toByte()) {
// Start of image (SOI) marker
offset += 1
continue
}
if (marker == 0xD9.toByte()) {
// End of image (EOI) marker
break
}
val len = image.filterIndexed { index, _ -> (offset..offset + 1).contains(index) }
.toByteArray()
.getShort() // The length of the section
if (marker == 0xC0.toByte() || marker == 0xC2.toByte()) { // The two most popular Start of frame (SOF) markers
val height =
image.filterIndexed { index, _ -> (offset + 3..offset + 4).contains(index) }.toByteArray().getShort()
val width =
image.filterIndexed { index, _ -> (offset + 5..offset + 6).contains(index) }.toByteArray().getShort()
return ImageDimensions(width.toInt(), height.toInt())
}
offset += len
}
throw IllegalArgumentException("Invalid JPEG: Could not extract dimensions")
}

private fun getDimensionsFromPNG(image: Iterable<Byte>): ImageDimensions {
// PNG Specification: https://www.w3.org/TR/png/

// PNG will always start with this signature
val signature = image.take(8)
if (signature != listOf(
0x89.toByte(),
0x50.toByte(),
0x4E.toByte(),
0x47.toByte(),
0x0D.toByte(),
0x0A.toByte(),
0x1A.toByte(),
0x0A.toByte()
)
) {
throw IllegalArgumentException("Invalid PNG: Invalid signature")
}

/*
Get only the values from the header (IHDR) chunk that we care about.
IHDR is always the first chunk after the signature.
In the header, the sections are as follows:
* 4 bytes: Size of chunk
* 4 bytes: Type*
* 4 bytes: Width*
* 4 bytes: Height*
* ... remaining bytes
Because we only care about the Type, Width, and Height, we can skip the first 4 bytes
*/
val header = image.filterIndexed { index, _ -> (12..24).contains(index) }.toByteArray()
val type = header.decodeToString(endIndex = 4)
if (type != "IHDR") {
throw IllegalArgumentException("Invalid PNG: Invalid headers")
}
val width = header.getInt(4)
val height = header.getInt(8)
return ImageDimensions(width, height)

}

private fun getDimensionsFromViamRGBA(image: Iterable<Byte>): ImageDimensions {
// Viam RGBA header comes in 3 4-byte chunks:
// * Magic Number/Signature
// * Width
// * Height
val header = image.take(12)
val chunks = header.chunked(4)

val magicNumber = chunks[0]
if (magicNumber.toByteArray().decodeToString() != "RGBA") {
throw IllegalArgumentException("Invalid VIAM RGBA: Invalid headers")
}

val width = chunks[1].toByteArray().getInt()
val height = chunks[2].toByteArray().getInt()
return ImageDimensions(width, height)
}
42 changes: 42 additions & 0 deletions core/sdk/src/test/java/com/viam/sdk/core/util/MediaTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.viam.sdk.core.util

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.io.File

class MediaTest {
private val testDir = "${System.getProperty("user.dir")}/src/test/java/com/viam/sdk/core/util/"

private val testJPEG = File("$testDir/test_jpeg.jpg").readBytes().toList()
private val jpegWidth = 190
private val jpegHeight = 191

private val testPNG = File("$testDir/test_png.png").readBytes().toList()
private val pngWidth = 431
private val pngHeight = 577

private val testViamRGBA = File("$testDir/test_viam_rgba").readBytes().toList()
private val rgbaWidth = 640
private val rgbaHeight = 480

@Test
fun getDimensionsJPEG() {
val dims = getDimensions(testJPEG, "image/jpeg")
assertEquals(jpegWidth, dims.width)
assertEquals(jpegHeight, dims.height)
}

@Test
fun getDimensionsPNG() {
val dims = getDimensions(testPNG, "image/png")
assertEquals(pngWidth, dims.width)
assertEquals(pngHeight, dims.height)
}

@Test
fun getDimensionsRGBA() {
val dims = getDimensions(testViamRGBA, "image/vnd.viam.rgba")
assertEquals(rgbaWidth, dims.width)
assertEquals(rgbaHeight, dims.height)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
2 changes: 1 addition & 1 deletion java/examples/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ tasks.register('runExample2Client', JavaExec) {
tasks.register('runExample3Client', JavaExec) {
classpath sourceSets.main.runtimeClasspath
mainClass = "com.viam.sdk.java.examples.Example3Client"
}
}

0 comments on commit 8ea0736

Please sign in to comment.