Skip to content

Commit 38b6eec

Browse files
authored
Merge pull request #20 from labthings/process-from-raw-api
Draft: Added the ability to capture raw and process later
2 parents 13806c1 + b91a750 commit 38b6eec

File tree

5 files changed

+298
-19
lines changed

5 files changed

+298
-19
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "labthings-picamera2"
3-
version = "0.0.1-dev2"
3+
version = "0.0.2-dev0"
44
authors = [
55
{ name="Richard Bowman", email="richard.bowman@cantab.net" },
66
]
@@ -17,6 +17,7 @@ dependencies = [
1717
"labthings-fastapi>=0.0.7",
1818
"numpy",
1919
"scipy",
20+
"pillow",
2021
]
2122

2223
[project.optional-dependencies]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .thing import StreamingPiCamera2

src/labthings_picamera2/server.py

Lines changed: 0 additions & 14 deletions
This file was deleted.

src/labthings_picamera2/thing.py

Lines changed: 240 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from __future__ import annotations
2+
from dataclasses import dataclass
23
from datetime import datetime
4+
import io
35
import json
46
import logging
57
import os
68
import tempfile
79
import time
810
from tempfile import TemporaryDirectory
11+
import uuid
912

1013
from pydantic import BaseModel, BeforeValidator, RootModel
1114

@@ -17,10 +20,13 @@
1720
from labthings_fastapi.types.numpy import NDArray
1821
from labthings_fastapi.dependencies.metadata import GetThingStates
1922
from labthings_fastapi.dependencies.blocking_portal import BlockingPortal
20-
from labthings_fastapi.outputs.blob import blob_type
21-
from typing import Annotated, Any, Iterator, Literal, Mapping, Optional
23+
from labthings_fastapi.outputs.blob import Blob, BlobBytes
24+
from typing import Annotated, Any, Iterator, Literal, Mapping, Optional, Self
2225
from contextlib import contextmanager
2326
import piexif
27+
from scipy.ndimage import zoom
28+
from scipy.interpolate import interp1d
29+
from PIL import Image
2430
from threading import RLock
2531
import picamera2
2632
from picamera2 import Picamera2
@@ -30,9 +36,27 @@
3036
from . import recalibrate_utils
3137

3238

33-
JPEGBlob = blob_type("image/jpeg")
39+
class JPEGBlob(Blob):
40+
media_type: str = "image/jpeg"
3441

3542

43+
class PNGBlob(Blob):
44+
media_type: str = "image/png"
45+
46+
47+
class RawBlob(Blob):
48+
media_type: str = "image/raw"
49+
50+
51+
class RawImageModel(BaseModel):
52+
image_data: RawBlob
53+
thing_states: Optional[Mapping[str, Mapping]]
54+
metadata: Optional[Mapping[str, Mapping]]
55+
processing_inputs: Optional[ImageProcessingInputs] = None
56+
size: tuple[int, int]
57+
stride: int
58+
format: str
59+
3660
class PicameraControl(PropertyDescriptor):
3761
def __init__(
3862
self, control_name: str, model: type = float, description: Optional[str] = None
@@ -68,7 +92,7 @@ def __init__(self, stream: MJPEGStream, portal: BlockingPortal):
6892
self.stream = stream
6993
self.portal = portal
7094

71-
def outputframe(self, frame, _keyframe=True, _timestamp=None):
95+
def outputframe(self, frame, _keyframe=True, _timestamp=None, _packet=None, _audio=False):
7296
"""Add a frame to the stream's ringbuffer"""
7397
self.stream.add_frame(frame, self.portal)
7498

@@ -98,6 +122,78 @@ class LensShading(BaseModel):
98122
Cb: list[list[float]]
99123

100124

125+
class ImageProcessingInputs(BaseModel):
126+
lens_shading: LensShading
127+
colour_gains: tuple[float, float]
128+
white_norm_lores: NDArray
129+
raw_size: tuple[int, int]
130+
colour_correction_matrix: tuple[float, float, float, float, float, float, float, float, float]
131+
gamma: NDArray
132+
133+
134+
@dataclass
135+
class ImageProcessingCache:
136+
white_norm: np.ndarray
137+
gamma: interp1d
138+
ccm: np.ndarray
139+
140+
141+
class BlobNumpyDict(BlobBytes):
142+
def __init__(self, arrays: Mapping[str, np.ndarray]):
143+
self._arrays = arrays
144+
self._bytesio: Optional[io.BytesIO] = None
145+
self.media_type = "application/npz"
146+
147+
@property
148+
def arrays(self) -> Mapping[str, np.ndarray]:
149+
return self._arrays
150+
151+
@property
152+
def _bytes(self) -> bytes: #noqa mypy: override
153+
"""Generate binary content on-the-fly from numpy data"""
154+
if not self._bytesio:
155+
out = io.BytesIO()
156+
np.savez(out, **self.arrays)
157+
self._bytes_cache = out.getvalue()
158+
return self._bytes_cache
159+
160+
161+
class NumpyBlob(Blob):
162+
media_type: str = "application/npz"
163+
164+
@classmethod
165+
def from_arrays(cls, arrays: Mapping[str, np.ndarray]) -> Self:
166+
return cls.model_construct( # type: ignore[return-value]
167+
href="blob://local",
168+
_data=BlobNumpyDict(
169+
arrays,
170+
media_type=cls.default_media_type()
171+
),
172+
)
173+
174+
175+
176+
def raw2rggb(raw: np.ndarray, size: tuple[int, int]) -> np.ndarray:
177+
"""Convert packed 10 bit raw to RGGB 8 bit"""
178+
raw = np.asarray(raw) # ensure it's an array
179+
output_shape = (size[1]//2, size[0]//2, 4)
180+
rggb = np.empty(output_shape, dtype=np.uint8)
181+
raw_w = rggb.shape[1] // 2 * 5
182+
for plane, offset in enumerate([(1, 1), (0, 1), (1, 0), (0, 0)]):
183+
rggb[:, ::2, plane] = raw[offset[0] :: 2, offset[1] : raw_w + offset[1] : 5]
184+
rggb[:, 1::2, plane] = raw[
185+
offset[0] :: 2, offset[1] + 2 : raw_w + offset[1] + 2 : 5
186+
]
187+
return rggb
188+
189+
190+
def rggb2rgb(rggb: np.ndarray) -> np.ndarray:
191+
"""Convert rggb to rgb by averaging green channels"""
192+
return np.stack(
193+
[rggb[..., 0], rggb[..., 1] // 2 + rggb[..., 2] // 2, rggb[..., 3]], axis=2
194+
)
195+
196+
101197
class StreamingPiCamera2(Thing):
102198
"""A Thing that represents an OpenCV camera"""
103199

@@ -442,6 +538,146 @@ def capture_array(
442538
with self.picamera() as cam:
443539
return cam.capture_array(stream_name)
444540

541+
@thing_action
542+
def capture_raw(
543+
self,
544+
states_getter: GetThingStates,
545+
get_states: bool=True,
546+
get_processing_inputs: bool=True,
547+
) -> RawImageModel:
548+
"""Capture a raw image
549+
550+
This function is intended to be as fast as possible, and will return
551+
as soon as an image has been captured. The output format is not intended
552+
to be useful, except as input to `raw_to_png`.
553+
554+
When used via the HTTP interface, this function returns the data as a
555+
`Blob` object, meaning it can be passed to another action without
556+
transferring it over the network.
557+
"""
558+
with self.picamera() as cam:
559+
(buffer, ), parameters = cam.capture_buffers(["raw"])
560+
configuration = cam.camera_configuration()
561+
return RawImageModel(
562+
image_data = RawBlob.from_bytes(buffer.tobytes()),
563+
thing_states = states_getter() if get_states else None,
564+
metadata = { "parameters": parameters, "sensor": configuration["sensor"] },
565+
processing_inputs = (
566+
self.image_processing_inputs if get_processing_inputs else None
567+
),
568+
size = configuration["raw"]["size"],
569+
format = configuration["raw"]["format"],
570+
stride = configuration["raw"]["stride"],
571+
)
572+
573+
@thing_property
574+
def image_processing_inputs(self) -> ImageProcessingInputs:
575+
"""The information needed to turn raw images into processed ones"""
576+
lst = self.lens_shading_tables
577+
lum = np.array(lst.luminance)
578+
Cr = np.array(lst.Cr)
579+
Cb = np.array(lst.Cb)
580+
gr, gb = self.colour_gains
581+
G = 1 / lum
582+
R = (
583+
G / Cr / gr * np.min(Cr)
584+
) # The extra /np.max(Cr) emulates the quirky handling of Cr in
585+
B = G / Cb / gb * np.min(Cb) # the picamera2 pipeline
586+
white_norm_lores = np.stack([R, G, B], axis=2)
587+
588+
with self.picamera() as cam:
589+
size: tuple[int, int] = cam.camera_configuration()["raw"]["size"]
590+
591+
contrast_algorithm = Picamera2.find_tuning_algo(self.tuning, "rpi.contrast")
592+
gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1, 2))
593+
594+
return ImageProcessingInputs(
595+
lens_shading=lst,
596+
colour_gains=(gr, gb),
597+
colour_correction_matrix=self.colour_correction_matrix,
598+
white_norm_lores=white_norm_lores,
599+
raw_size=size,
600+
gamma=gamma,
601+
)
602+
603+
@staticmethod
604+
def generate_image_processing_cache(
605+
p: ImageProcessingInputs,
606+
) -> ImageProcessingCache:
607+
"""Prepare to process raw images
608+
609+
This is a static method to ensure its outputs depend only on its
610+
inputs."""
611+
zoom_factors = [
612+
i / 2 / n for i, n in zip(p.raw_size[::-1], p.white_norm_lores.shape[:2])
613+
] + [1]
614+
white_norm = zoom(p.white_norm_lores, zoom_factors, order=1)[
615+
: (p.raw_size[1]//2), : (p.raw_size[0]//2), :
616+
]
617+
ccm = np.array(p.colour_correction_matrix).reshape((3,3))
618+
gamma = interp1d(p.gamma[:, 0] / 255, p.gamma[:, 1] / 255)
619+
return ImageProcessingCache(
620+
white_norm=white_norm,
621+
ccm = ccm,
622+
gamma = gamma,
623+
)
624+
625+
_image_processing_cache: ImageProcessingCache | None = None
626+
@thing_action
627+
def prepare_image_normalisation(
628+
self,
629+
inputs: ImageProcessingInputs | None = None
630+
) -> ImageProcessingInputs:
631+
"""The parameters used to convert raw image data into processed images
632+
633+
NB this method uses only information from `inputs` or
634+
`self.image_processing_inputs`, to ensure repeatability
635+
"""
636+
p = inputs or self.image_processing_inputs
637+
self._image_processing_cache = self.generate_image_processing_cache(p)
638+
return p
639+
640+
@thing_action
641+
def process_raw_array(
642+
self,
643+
raw: RawImageModel,
644+
use_cache: bool = False,
645+
)->NDArray:
646+
"""Convert a raw image to a processed array"""
647+
if not use_cache:
648+
if raw.processing_inputs is None:
649+
raise ValueError(
650+
"The raw image does not contain processing inputs, "
651+
"and we are not using the cache. This may be solved by "
652+
"capturing with `get_processing_inputs=True`."
653+
)
654+
self.prepare_image_normalisation(
655+
raw.processing_inputs
656+
)
657+
p = self._image_processing_cache
658+
assert p is not None
659+
assert raw.format == "SBGGR10_CSI2P"
660+
buffer = np.frombuffer(raw.image_data.content, dtype=np.uint8)
661+
packed = buffer.reshape((-1, raw.stride))
662+
rgb = rggb2rgb(raw2rggb(packed, raw.size))
663+
normed = rgb / p.white_norm
664+
corrected = np.dot(
665+
p.ccm, normed.reshape((-1, 3)).T
666+
).T.reshape(normed.shape)
667+
corrected[corrected < 0] = 0
668+
corrected[corrected > 255] = 255
669+
processed_image = p.gamma(corrected)
670+
return processed_image.astype(np.uint8)
671+
672+
@thing_action
673+
def raw_to_png(self, raw: RawImageModel, use_cache: bool = False)->PNGBlob:
674+
"""Process a raw image to a PNG"""
675+
arr = self.process_raw_array(raw=raw, use_cache=use_cache)
676+
image = Image.fromarray(arr.astype(np.uint8), mode="RGB")
677+
out = io.BytesIO()
678+
image.save(out, format="png")
679+
return PNGBlob.from_bytes(out.getvalue())
680+
445681
@thing_property
446682
def camera_configuration(self) -> Mapping:
447683
"""The "configuration" dictionary of the picamera2 object

tests/test_acquisition.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from labthings_picamera2 import StreamingPiCamera2
2+
from labthings_fastapi.server import ThingServer
3+
from labthings_fastapi.client import ThingClient
4+
from fastapi.testclient import TestClient
5+
from PIL import Image
6+
import numpy as np
7+
from pytest import fixture
8+
9+
@fixture(scope="module")
10+
def client():
11+
server = ThingServer()
12+
server.add_thing(StreamingPiCamera2(), "/camera/")
13+
with TestClient(server.app) as test_client:
14+
client = ThingClient.from_url("/camera/", client=test_client)
15+
yield client
16+
17+
def test_calibration(client):
18+
client.full_auto_calibrate()
19+
20+
21+
def test_jpeg_and_array(client):
22+
blob = client.grab_jpeg()
23+
mjpeg_frame = Image.open(blob.open())
24+
assert mjpeg_frame
25+
blob = client.capture_jpeg(resolution="main")
26+
jpeg_capture = Image.open(blob.open())
27+
assert jpeg_capture
28+
arrlist = client.capture_array(stream_name="main")
29+
array_main = np.array(arrlist)
30+
assert mjpeg_frame.size == jpeg_capture.size
31+
assert array_main.shape[1::-1] == jpeg_capture.size
32+
33+
def test_raw_and_processed(client):
34+
raw = client.capture_raw()
35+
blob = client.raw_to_png(raw=raw)
36+
img = Image.open(blob.open())
37+
print(img.size)
38+
39+
def test_raw_with_cache(client):
40+
#params = client.prepare_image_normalisation()
41+
inputs = client.image_processing_inputs
42+
client.prepare_image_normalisation(inputs=inputs)
43+
raws = [
44+
client.capture_raw(get_processing_inputs=False, get_states=False)
45+
for _ in range(3)
46+
]
47+
blobs = [
48+
client.raw_to_png(raw=raw, use_cache=True)
49+
for raw in raws
50+
]
51+
for blob in blobs:
52+
img = Image.open(blob.open())
53+
expected_size = (
54+
d//2 for d in inputs["raw_size"]
55+
)

0 commit comments

Comments
 (0)