Camera Integration#
The SDK provides multiple ways to interact with the Qtechnology Hypervision line of hyperspectral cameras. The pipeline API integrates directly with the cameras and as a result makes it easy to create complex processing pipelines without having to worry about handling individual frames. Alternatively, the cameras can be operated like regular cameras using a stream of two-dimensional frames.
Warning
The camera functionality is in its early stages of development and may change significantly.
Creating a camera object#
The base type for camera interoperability is hs.control.Camera
Let us look at an example of how to create a new camera object and perform some basic operations on it:
from qtec_hv_sdk import control
cam = control.Camera("<camera_ip>")
# Alternatively, you can create a simulated camera from an existing Image
# Note that this will convert BSQ images to BIL internally, since the Camera assumes a push-broom architecture
cam = control.Camera(<img_object>)
# Printing properties of the camera is easy
print(cam.get_wavelengths())
print(cam.get_framerate())
{
// Create the camera object
hv_camera_t* cam;
hv_camera_new("192.168.2.229", &cam);
// Alternatively, the camera can be initialized from a hv_image_t object.
// hv_camera_from_image(<img>, &cam);
// Get the wavelengths
slice_boxed_float_t* wavelengths;
hv_camera_get_wavelengths(cam, &wavelengths);
// Print the wavelength values
printf("Wavelengths:\n");
for (size_t i = 0; i < wavelengths->len; i++) {
printf("%fnm\n", wavelengths->ptr[i]);
}
// Print the framerate
const float fps = hv_camera_get_framerate(cam);
The camera object supports setting many common camera settings, including exposure time, framerate, and binning.
Tip
We provide a guide on how to capture high-quality hyperspectral images on our main documentation site.
Note
The simulated camera is useful for testing pipelines that are meant to run on live data.
The simulated camera allows setting/reading the same settings as the real camera, but many settings don’t have any impact on the image output. Only the band-selection currently affects the output image.
The following additional settings will be implemented soon: - Horizontal cropping - Binning - Bit-mode
Image capture#
The hs.control.Camera hs.Image
The process is more easily explained with an example:
img = cam.to_hs_image()
# capture 10 frames
array_img = img[:10, :, :].resolve()
# build pipeline and then capture 10 frames
pl = img.ensure_dtype(hs.float32) / 254
array_img = pl[:10, :, :].resolve()
# trying to resolve the raw image results in an error because the stream is infinite
try:
array_img = img.resolve()
except ValueError as e:
print(e)
// Get image object
hv_image_t* img;
hv_camera_get_image(cam, &img);
// Capture 10 frames
hv_slice_desc_t slice_first10 = {
.lines = hv_slice_elem_new_range(0, 10, 1),
.samples = hv_slice_elem_new_range(0, -1, 1),
.bands = hv_slice_elem_new_range(0, -1, 1)
};
hv_image_t* first10 = hv_image_slice(img, slice_first10);
hv_image_t* array_img;
hv_image_resolve(first10, &array_img);
hv_image_free(&array_img);
hv_image_free(&first10);
// Build pipeline and then capture 10 frames
// Create calculation (convert to float32 and divide by 254)
hv_image_t* float_img;
hv_image_ensure_dtype(img, HV_DTYPE_F32, &float_img);
hv_image_t* img_result = hv_image_div_scalar_f32(float_img, 254);
// Slice and resolve the image
hv_image_t* first10_pipeline = hv_image_slice(img_result, slice_first10);
hv_image_t* array_img_pipeline;
hv_image_resolve(first10_pipeline, &array_img_pipeline);
hv_image_free(&first10_pipeline);
hv_image_free(&array_img_pipeline);
// Trying to resolve the raw image results in an error because the
// stream is infinite
hv_image_t* img_infinite;
int err = hv_image_resolve(img, &img_infinite);
// The error value will be set
assert(err != 0);
// and the image pointer will still be NULL
assert(img_infinite == NULL);
To capture images, you call a function on the image object that executes the pipeline. It doesn’t have to be
hs.Image.resolve() hs.Image.to_numpy()
Note
A future version of the SDK will support streaming images from a pipeline. This feature will make it easy to integrate a live camera stream with any processing system or library.
As you can see in the example, it is necessary to, at some point, slice the streaming dimension of the pipeline. Since the Hypervision camera is a pushbroom camera, the streaming dimension is the lines, i.e., the first slicing dimension.
Hyperspectral camera settings#
There is no classical cropping functionality. Instead, you set the desired bands using the
hs.control.Camera.set_bands()
# Set bands
bands = cam.set_bands([
(0, 50),
(152, 180)
])
print(bands)
# Set horizontal cropping
crop = cam.set_horizontal_crop(200, 500)
print(crop)
// Set bands
hv_band_list_t* bands;
hv_band_list_new(&bands);
// The API allows for setting a step value, but this is currently ignored.
hv_band_list_append(bands, 0, 50, -1);
hv_band_list_append(bands, 152, 180, -1);
hv_band_list_t* out_bands;
hv_camera_set_bands(cam, bands, &out_bands);
const char* str = hv_band_list_debug_fmt(out_bands);
printf("%s\n", str);
free(str);
hv_band_list_free(&out_bands);
// Set horizontal cropping
hv_horizontal_crop_t* out_crop;
hv_camera_set_horizontal_crop(camera, 200, 500, out_crop);
const char* str2 = hv_horizontal_crop_debug_fmt(out_crop);
printf("%s\n", str2);
free(str2);
hv_horizontal_crop_free(&out_crop);
Warning
The camera interface currently only supports band ranges since it is tied to the underlying sensor functionality. The Hypervision 1700 sensors support up to 8 distinct ranges. Additionally, the ranges y-values will always be clamped to numbers divisible by 4 - this is a sensor limitation. The camera interface will always extend a provided range to include the selection. For example, the range (5, 10) will result in a returned band selection of (4, 11).
Important
In the future, we will implement the ability to select arbitrary bands.
Streaming individual frames #
It is possible to stream raw frames from the camera by calling hs.control.Camera.stream(). The stream can be used as
an iterator and also supports the context manager protocol to automatically stop streaming.
from qtec_hv_sdk import control
cam = control.Camera("10.100.10.100")
# Use as an iterator with the context manager protocol
with cam.stream() as stream:
for i in range(100):
meta, frame = next(stream) # Get the next frame
print(frame.shape)
# Manual usage
stream = cam.stream() # Start streaming
meta, frame = stream.get_frame() # Get a single frame
stream.stop() # Stop the stream
stream() returns only once the camera has accepted the
connection and is streaming, so a camera that cannot start raises there rather than yielding an
empty stream. A camera that has just stopped a stream needs a moment to release the video device
before it will accept a new one, which stream() waits out for you; back-to-back
stop()/stream() pairs therefore work without a delay in between, at the cost of the call
occasionally taking a second or so to return.
Buffering and backpressure #
Frames arrive faster than most consumers process them, so a camera keeps a local buffer of
capacity frames. Buffering is configured on the camera, not per stream, through the
stream_options attribute. The configuration therefore applies
to every stream that camera starts - including the ones driven by an Image
pipeline, which have no other way to pass options.
from qtec_hv_sdk import control
cam = control.Camera("10.100.10.100")
cam.stream_options.capacity = 64
# Guarantee that no frame is lost on this machine.
with cam.stream(n_frames=1000) as stream:
for meta, frame in stream:
slow_processing(frame)
assert stream.dropped() == 0
The attribute is a live view of the camera’s configuration, so assigning a field takes effect
immediately. To change several at once, assign a whole
StreamOptions; its constructor takes each setting as a keyword-only
argument and leaves omitted ones at their default:
cam.stream_options = control.StreamOptions(capacity=64, on_full="block")
There are four settings:
bufferedDeliver frames oldest-first (FIFO). Defaults to
True, which is what you want for capture. WhenFalse, each read returns the newest frame and discards the backlog, which suits live preview. The discarded frames count towardsdropped().capacityNumber of frames the buffer holds. Defaults to
20. Must be at least1;0raisesValueError.wait_timeoutHow long a read waits for a frame before raising
TimeoutError, which usually means the camera has stalled. Must exceed the longest expected gap between frames. Iterating stops the stream on a timeout;get_frameleaves it running so the call can be retried. Defaults to 1 s.on_fullWhat to do when the buffer is full, either
"block"or"evict"."block"(the default) stalls the camera until the consumer frees a slot, sodropped()stays at0."evict"discards the oldest buffered frame so the camera never stalls, at the cost of silently losing frames.
So a live-preview camera, which wants the most recent frame rather than a complete record, is configured like this:
cam.stream_options = control.StreamOptions(buffered=False, capacity=2, on_full="evict")
Warning
on_full="block" only guarantees that nothing is lost after the frame reaches the host.
If the consumer stays behind for long enough, the backpressure propagates to the camera, which
then drops frames on its own. Those show up as meta.dropped on later frames - see
below. Give the buffer enough capacity to absorb bursts,
and keep the per-frame work short.
Checking for dropped frames#
All streaming functionalities forward frame metadata. For frames captured from a camera, this includes a timestamp and a dropped flag. These can be used to handle uneven frame pacing and missing frames.
Note
meta.dropped marks a frame the camera failed to deliver to the host. It is unrelated to
dropped(), which counts frames discarded locally
because the consumer fell behind.
A frame the camera failed to deliver is replaced by a black frame with meta.dropped set, so the
stream stays gapless and a pipeline still sees the line it expected. These stand-ins carry the
meta.seq of the line that went missing, but the meta.timestamp of the frame that follows
the gap. So meta.seq is contiguous across a camera-side loss, and a jump in it means frames
were discarded locally - see Buffering and backpressure.
When using pipelines, this can be used in the @hs.util.operation decorator as shown below:
import qtec_hv_sdk as hs
from qtec_hv_sdk import control
import numpy as np
import logging
logger = logging.get_logger("main")
cam = control.Camera("10.100.10.100")
@operation(hs.bil)
def op(meta: hs.FrameMeta, frame: np.ndarray) -> np.ndarray:
if meta.dropped:
logger.warning(f"Dropped frame {meta.seq} at time {meta.timestamp}")
# Do some processing here
# ...
return frame
out_img = op(cam)
# Any method calls that fetches data from the pipeline will now issue warnings if a dropped frame is encountered.
out_img.array_plane(250, hs.bands)
out_img.to_numpy()
When using a plain camera CameraStream object or a pipeline Stream object, the metadata is passed with each new frame, making custom processing simple:
import qtec_hv_sdk as hs
from qtec_hv_sdk import control
import numpy as np
import logging
logger = logging.get_logger("main")
cam = control.Camera("10.100.10.100")
with cam.stream() as stream:
for i in range(100):
meta, frame = next(stream) # Get the next frame
if meta.dropped:
print(f"Dropped frame {meta.seq} at time {meta.timestamp}")
# Handle dropped frame