Skip to content

Commit eb487ab

Browse files
committed
Further JPEG capture optimisations using simplejpeg
Using simplejpeg we can do zero-copy JPEG encode. This does rely on some extra features pushed to the simplejpeg repo. Signed-off-by: David Plowman <[email protected]>
1 parent 16d52ae commit eb487ab

File tree

2 files changed

+100
-37
lines changed

2 files changed

+100
-37
lines changed

picamera2/picamera2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,7 @@ def configure_(self, camera_config="preview"):
11481148
self.controls = Controls(self, controls=self.camera_config['controls'])
11491149
self.configure_count += 1
11501150

1151-
if "ScalerCrops" in self.camera_controls:
1151+
if False and "ScalerCrops" in self.camera_controls:
11521152
par_crop = self.camera_controls["ScalerCrops"]
11531153
full_fov = self.camera_controls["ScalerCrop"][1]
11541154
scaler_crops = [par_crop[0] if camera_config["main"]["preserve_ar"] else full_fov]

picamera2/request.py

Lines changed: 99 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import numpy as np
1111
import piexif
12+
import simplejpeg
1213
from pidng.camdefs import Picamera2Camera
1314
from pidng.core import PICAM2DNG
1415
from PIL import Image
@@ -59,8 +60,8 @@ def __enter__(self) -> "MappedArray":
5960
else:
6061
config = self.__stream.configuration
6162

62-
# helpers.make_array never makes a copy.
63-
array = self.__request.picam2.helpers.make_array(array, config)
63+
# helpers._make_array_shared never makes a copy.
64+
array = self.__request.picam2.helpers._make_array_shared(array, config)
6465

6566
self.__array = array
6667
return self
@@ -76,6 +77,8 @@ def array(self) -> Optional[np.ndarray]:
7677

7778

7879
class CompletedRequest:
80+
FASTER_JPEG = True # set to False to use the older JPEG encode method
81+
7982
def __init__(self, request: Any, picam2: "Picamera2") -> None:
8083
self.request = request
8184
self.ref_count: int = 1
@@ -142,7 +145,7 @@ def make_array(self, name: str) -> np.ndarray:
142145
config = self.config.get(name, None)
143146
if config is None:
144147
raise RuntimeError(f'Stream {name!r} is not defined')
145-
elif config['format'] == 'MJPEG':
148+
elif config['format'] == 'MJPEG':
146149
return np.array(Image.open(io.BytesIO(self.make_buffer(name))))
147150

148151
# We don't want to send out an exported handle to the camera buffer, so we're going to have
@@ -196,8 +199,47 @@ def save(self, name: str, file_output: Any, format: Optional[str] = None,
196199
exif_data - dictionary containing user defined exif data (based on `piexif`). This will
197200
overwrite existing exif information generated by picamera2.
198201
"""
199-
return self.picam2.helpers.save(self.make_image(name), self.get_metadata(), file_output,
200-
format, exif_data)
202+
# We have a more optimised path for writing JPEGs using simplejpeg.
203+
config = self.config.get(name, None)
204+
if config is None:
205+
raise RuntimeError(f'Stream {name!r} is not defined')
206+
if self.FASTER_JPEG and config['format'] != "MJPEG" and \
207+
self.picam2.helpers._get_format_str(file_output, format) in ('jpg', 'jpeg'):
208+
quality = self.picam2.options.get("quality", 90)
209+
with MappedArray(self, 'main') as m:
210+
format = self.config[name]["format"]
211+
if format == 'YUV420':
212+
width, height = self.config[name]['size']
213+
Y = m.array[:height, :width]
214+
reshaped = m.array.reshape((m.array.shape[0] * 2, m.array.strides[0] // 2))
215+
U = reshaped[2 * height: 2 * height + height // 2, :width // 2]
216+
V = reshaped[2 * height + height // 2:, :width // 2]
217+
output_bytes = simplejpeg.encode_jpeg_yuv_planes(Y, U, V, quality)
218+
Y = reshaped = U = V = None
219+
else:
220+
FORMAT_TABLE = {"XBGR8888": "RGBX", "XRGB8888": "BGRX", "BGR888": "RGB", "RGB888": "BGR"}
221+
output_bytes = simplejpeg.encode_jpeg(m.array, quality, FORMAT_TABLE[format], '420')
222+
223+
exif = self.picam2.helpers._prepare_exif(self.get_metadata(), exif_data)
224+
225+
if isinstance(file_output, io.BytesIO):
226+
f = file_output
227+
else:
228+
f = open(file_output, 'wb')
229+
try:
230+
if exif:
231+
# Splice in the exif data as we write it out.
232+
f.write(output_bytes[:2] + bytes.fromhex('ffe1') + (len(exif) + 2).to_bytes(2, 'big'))
233+
f.write(exif)
234+
f.write(output_bytes[2:])
235+
else:
236+
f.write(output_bytes)
237+
except Exception:
238+
if f is not file_output:
239+
f.close()
240+
else:
241+
return self.picam2.helpers.save(self.make_image(name), self.get_metadata(), file_output,
242+
format, exif_data)
201243

202244
def save_dng(self, file_output: Any, name: str = "raw") -> None:
203245
"""Save a DNG RAW image of the raw stream's buffer."""
@@ -218,17 +260,19 @@ class Helpers:
218260
def __init__(self, picam2: "Picamera2"):
219261
self.picam2 = picam2
220262

221-
def make_array(self, buffer: np.ndarray, config: Dict[str, Any]) -> np.ndarray:
222-
"""Make a 2D numpy array from the named stream's buffer."""
263+
def _make_array_shared(self, buffer: np.ndarray, config: Dict[str, Any]) -> np.ndarray:
264+
"""Makes a 2d numpy array from the named stream's buffer without copying memory.
265+
266+
This method makes an array that is guaranteed to be shared with the underlying
267+
buffer, that is, no copy of the pixel data is made.
268+
"""
223269
array = buffer
224270
fmt = config["format"]
225271
w, h = config["size"]
226272
stride = config["stride"]
227273

228-
# Turning the 1d array into a 2d image-like array only works if the
229-
# image stride (which is in bytes) is a whole number of pixels. Even
230-
# then, if they don't match exactly you will get "padding" down the RHS.
231-
# Working around this requires another expensive copy of all the data.
274+
# Reshape the 1d array into an image, and "slice" off any padding bytes on the
275+
# right hand edge (which doesn't copy the pixel data).
232276
if fmt in ("BGR888", "RGB888"):
233277
if stride != w * 3:
234278
array = array.reshape((h, stride))
@@ -263,6 +307,18 @@ def make_array(self, buffer: np.ndarray, config: Dict[str, Any]) -> np.ndarray:
263307
raise RuntimeError("Format " + fmt + " not supported")
264308
return image
265309

310+
def make_array(self, buffer, config):
311+
"""Makes a 2d numpy array for the named stream's buffer.
312+
313+
This method makes a copy of the underlying camera buffer, so that it can be
314+
safely returned to the camera system.
315+
"""
316+
array = self._make_array_shared(buffer, config)
317+
if array.data.c_contiguous:
318+
return np.copy(array)
319+
else:
320+
return np.ascontiguousarray(array)
321+
266322
def _get_pil_mode(self, fmt):
267323
mode_lookup = {"RGB888": "BGR", "BGR888": "RGB", "XBGR8888": "RGBX", "XRGB8888": "BGRX"}
268324
mode = mode_lookup.get(fmt, None)
@@ -277,7 +333,7 @@ def make_image(self, buffer: np.ndarray, config: Dict[str, Any], width: Optional
277333
if fmt == "MJPEG":
278334
return Image.open(io.BytesIO(buffer)) # type: ignore
279335
else:
280-
rgb = self.make_array(buffer, config)
336+
rgb = self._make_array_shared(buffer, config)
281337

282338
# buffer was already a copy, so don't need to worry about an extra copy for the "RGBX" mode.
283339
buf = rgb
@@ -294,6 +350,34 @@ def make_image(self, buffer: np.ndarray, config: Dict[str, Any], width: Optional
294350
pil_img = pil_img.resize((width, height)) # type: ignore
295351
return pil_img
296352

353+
def _prepare_exif(self, metadata, exif_data):
354+
exif = b''
355+
if "AnalogueGain" in metadata and "DigitalGain" in metadata:
356+
datetime_now = datetime.now().strftime("%Y:%m:%d %H:%M:%S")
357+
zero_ifd = {piexif.ImageIFD.Make: "Raspberry Pi",
358+
piexif.ImageIFD.Model: self.picam2.camera.id,
359+
piexif.ImageIFD.Software: "Picamera2",
360+
piexif.ImageIFD.DateTime: datetime_now}
361+
total_gain = metadata["AnalogueGain"] * metadata["DigitalGain"]
362+
exif_ifd = {piexif.ExifIFD.DateTimeOriginal: datetime_now,
363+
piexif.ExifIFD.ExposureTime: (metadata["ExposureTime"], 1000000),
364+
piexif.ExifIFD.ISOSpeedRatings: int(total_gain * 100)}
365+
exif_dict = {"0th": zero_ifd, "Exif": exif_ifd}
366+
# merge user provided exif data, overwriting the defaults
367+
exif_dict = exif_dict | (exif_data or {})
368+
exif = piexif.dump(exif_dict)
369+
return exif
370+
371+
def _get_format_str(self, file_output, format):
372+
if isinstance(format, str):
373+
return format.lower()
374+
elif isinstance(file_output, str):
375+
return file_output.split('.')[-1].lower()
376+
elif isinstance(file_output, Path):
377+
return file_output.suffix.lower()
378+
else:
379+
raise RuntimeError("Cannot determine format to save")
380+
297381
def save(self, img: Image.Image, metadata: Dict[str, Any], file_output: Union[str, Path], format: Optional[str] = None,
298382
exif_data: Optional[Dict] = None) -> None:
299383
"""Save a JPEG or PNG image of the named stream's buffer.
@@ -305,36 +389,15 @@ def save(self, img: Image.Image, metadata: Dict[str, Any], file_output: Union[st
305389
exif_data = {}
306390
# This is probably a hideously expensive way to do a capture.
307391
start_time = time.monotonic()
308-
exif = b''
309-
if isinstance(format, str):
310-
format_str = format.lower()
311-
elif isinstance(file_output, str):
312-
format_str = file_output.split('.')[-1].lower()
313-
elif isinstance(file_output, Path):
314-
format_str = file_output.suffix.lower()
315-
else:
316-
raise RuntimeError("Cannot determine format to save")
392+
format_str = self._get_format_str(file_output, format)
317393
if format_str in ('png') and img.mode == 'RGBX':
318394
# It seems we can't save an RGBX png file, so make it RGBA instead. We can't use RGBA
319395
# everywhere, because we can only save an RGBX jpeg, not an RGBA one.
320396
img = img.convert(mode='RGBA')
397+
exif = b''
321398
if format_str in ('jpg', 'jpeg'):
322399
# Make up some extra EXIF data.
323-
if "AnalogueGain" in metadata and "DigitalGain" in metadata:
324-
datetime_now = datetime.now().strftime("%Y:%m:%d %H:%M:%S")
325-
assert self.picam2.camera is not None
326-
zero_ifd = {piexif.ImageIFD.Make: "Raspberry Pi",
327-
piexif.ImageIFD.Model: self.picam2.camera.id,
328-
piexif.ImageIFD.Software: "Picamera2",
329-
piexif.ImageIFD.DateTime: datetime_now}
330-
total_gain = metadata["AnalogueGain"] * metadata["DigitalGain"]
331-
exif_ifd = {piexif.ExifIFD.DateTimeOriginal: datetime_now,
332-
piexif.ExifIFD.ExposureTime: (metadata["ExposureTime"], 1000000),
333-
piexif.ExifIFD.ISOSpeedRatings: int(total_gain * 100)}
334-
exif_dict = {"0th": zero_ifd, "Exif": exif_ifd}
335-
# merge user provided exif data, overwriting the defaults
336-
exif_dict = exif_dict | exif_data
337-
exif = piexif.dump(exif_dict)
400+
exif = self._prepare_exif(metadata, exif_data)
338401
# compress_level=1 saves pngs much faster, and still gets most of the compression.
339402
png_compress_level = self.picam2.options.get("compress_level", 1)
340403
jpeg_quality = self.picam2.options.get("quality", 90)

0 commit comments

Comments
 (0)