Merge branch 'pil-simulation' into 'v3'
Improve simulation frame rate See merge request openflexure/openflexure-microscope-server!419
This commit is contained in:
commit
551e392e91
1 changed files with 39 additions and 22 deletions
|
|
@ -12,11 +12,10 @@ from typing import Literal, Optional, Mapping
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
import time
|
import time
|
||||||
|
import io
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image, ImageFilter
|
||||||
from scipy.ndimage import gaussian_filter
|
|
||||||
|
|
||||||
import labthings_fastapi as lt
|
import labthings_fastapi as lt
|
||||||
|
|
||||||
|
|
@ -194,7 +193,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
|
|
||||||
self.canvas[top:bottom, left:right] -= sprite
|
self.canvas[top:bottom, left:right] -= sprite
|
||||||
|
|
||||||
def generate_image(self, pos: tuple[int, int, int]) -> np.ndarray:
|
def generate_image(self, pos: tuple[int, int, int]) -> Image:
|
||||||
"""Generate an image with blobs based on supplied coordinates.
|
"""Generate an image with blobs based on supplied coordinates.
|
||||||
|
|
||||||
:param pos: a 3-item tuple containing the x,y,z coordinates of the 'stage'
|
:param pos: a 3-item tuple containing the x,y,z coordinates of the 'stage'
|
||||||
|
|
@ -214,11 +213,9 @@ class SimulatedCamera(BaseCamera):
|
||||||
canvas = self.canvas if self._show_sample else self.blank_canvas
|
canvas = self.canvas if self._show_sample else self.blank_canvas
|
||||||
# Use npx to make each 1d index list 3D
|
# Use npx to make each 1d index list 3D
|
||||||
focused_image = canvas[np.ix_(x_indices, y_indices, z_indices)]
|
focused_image = canvas[np.ix_(x_indices, y_indices, z_indices)]
|
||||||
image = gaussian_filter(
|
|
||||||
focused_image,
|
image = fast_pil_blur(focused_image, sigma=np.abs(pos[2]) / 5)
|
||||||
sigma=np.abs(pos[2]) / 5,
|
|
||||||
axes=(0, 1),
|
|
||||||
)
|
|
||||||
if image.shape != self.shape:
|
if image.shape != self.shape:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Image shape {image.shape} does not match intended shape {self.shape}"
|
f"Image shape {image.shape} does not match intended shape {self.shape}"
|
||||||
|
|
@ -228,7 +225,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
image += RNG.normal(scale=self.noise_level, size=self.shape).astype("int16")
|
image += RNG.normal(scale=self.noise_level, size=self.shape).astype("int16")
|
||||||
image[image < 0] = 0
|
image[image < 0] = 0
|
||||||
image[image > 255] = 255
|
image[image > 255] = 255
|
||||||
return image.astype("uint8")
|
return Image.fromarray(image.astype("uint8"))
|
||||||
|
|
||||||
def attach_to_server(
|
def attach_to_server(
|
||||||
self, server: lt.ThingServer, path: str, setting_storage_path: str
|
self, server: lt.ThingServer, path: str, setting_storage_path: str
|
||||||
|
|
@ -251,7 +248,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
self._stage = self._server.things["/stage/"]
|
self._stage = self._server.things["/stage/"]
|
||||||
return self._stage.instantaneous_position
|
return self._stage.instantaneous_position
|
||||||
|
|
||||||
def generate_frame(self) -> np.ndarray:
|
def generate_frame(self) -> Image:
|
||||||
"""Generate a frame with blobs based on the stage coordinates."""
|
"""Generate a frame with blobs based on the stage coordinates."""
|
||||||
try:
|
try:
|
||||||
pos = self.get_stage_position()
|
pos = self.get_stage_position()
|
||||||
|
|
@ -313,18 +310,18 @@ class SimulatedCamera(BaseCamera):
|
||||||
|
|
||||||
def _capture_frames(self) -> None:
|
def _capture_frames(self) -> None:
|
||||||
portal = lt.get_blocking_portal(self)
|
portal = lt.get_blocking_portal(self)
|
||||||
|
last_frame_t = time.time()
|
||||||
while self._capture_enabled:
|
while self._capture_enabled:
|
||||||
time.sleep(self.frame_interval)
|
wait_time = last_frame_t - time.time() - self.frame_interval
|
||||||
|
if wait_time > 0:
|
||||||
|
time.sleep(wait_time)
|
||||||
|
last_frame_t = time.time()
|
||||||
try:
|
try:
|
||||||
frame = self.generate_frame()
|
frame = self.generate_frame()
|
||||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
self.mjpeg_stream.add_frame(_frame2bytes(frame), portal)
|
||||||
self.mjpeg_stream.add_frame(jpeg, portal)
|
ds_frame = frame.resize((320, 240), resample=Image.NEAREST)
|
||||||
# Downsample for lores
|
self.lores_mjpeg_stream.add_frame(_frame2bytes(ds_frame), portal)
|
||||||
ds_frame = cv2.resize(
|
|
||||||
frame, (320, 240), interpolation=cv2.INTER_NEAREST
|
|
||||||
)
|
|
||||||
jpeg_lores = cv2.imencode(".jpg", ds_frame)[1].tobytes()
|
|
||||||
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOGGER.exception(f"Failed to capture frame: {e}, retrying...")
|
LOGGER.exception(f"Failed to capture frame: {e}, retrying...")
|
||||||
|
|
||||||
|
|
@ -353,7 +350,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
if wait is not None:
|
if wait is not None:
|
||||||
LOGGER.warning("Simulation camera has no wait option. Use None.")
|
LOGGER.warning("Simulation camera has no wait option. Use None.")
|
||||||
LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}")
|
LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}")
|
||||||
return self.generate_frame()
|
return np.array(self.generate_frame())
|
||||||
|
|
||||||
def capture_image(
|
def capture_image(
|
||||||
self,
|
self,
|
||||||
|
|
@ -370,7 +367,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
if wait is not None:
|
if wait is not None:
|
||||||
LOGGER.warning("Simulation camera has no wait option. Use None.")
|
LOGGER.warning("Simulation camera has no wait option. Use None.")
|
||||||
LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}")
|
LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}")
|
||||||
return Image.fromarray(self.generate_frame())
|
return self.generate_frame()
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def full_auto_calibrate(self, portal: lt.deps.BlockingPortal) -> None:
|
def full_auto_calibrate(self, portal: lt.deps.BlockingPortal) -> None:
|
||||||
|
|
@ -423,3 +420,23 @@ class SimulatedCamera(BaseCamera):
|
||||||
def manual_camera_settings(self) -> list[PropertyControl]:
|
def manual_camera_settings(self) -> list[PropertyControl]:
|
||||||
"""The camera settings to expose as property controls in the settings panel."""
|
"""The camera settings to expose as property controls in the settings panel."""
|
||||||
return [property_control_for(self, "noise_level", label="Noise Level")]
|
return [property_control_for(self, "noise_level", label="Noise Level")]
|
||||||
|
|
||||||
|
|
||||||
|
def _frame2bytes(frame: Image) -> bytes:
|
||||||
|
"""Convert frame to bytes."""
|
||||||
|
with io.BytesIO() as buf:
|
||||||
|
# Save in low quality for speed.
|
||||||
|
frame.save(buf, format="JPEG", quality=85)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def fast_pil_blur(array: np.ndarray, sigma: float) -> np.ndarray:
|
||||||
|
"""Apply Gaussian blur using PIL (faster than scipy)."""
|
||||||
|
if sigma < 0.5:
|
||||||
|
return array # no visible blur needed
|
||||||
|
|
||||||
|
img_pil = Image.fromarray(array.astype(np.uint8))
|
||||||
|
img_pil = img_pil.filter(ImageFilter.GaussianBlur(radius=sigma))
|
||||||
|
|
||||||
|
# Convert back to NumPy array
|
||||||
|
return np.array(img_pil, dtype=array.dtype)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue