Compare commits
17 commits
d26b93e1be
...
59f8f01202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f8f01202 | ||
|
|
6e6ce3b8ed | ||
|
|
be233e6b95 | ||
|
|
faa20801ff | ||
|
|
a7b298889a | ||
|
|
3b89a499d0 | ||
|
|
db6c3021ae | ||
|
|
2d0d91773b | ||
|
|
bb38e11ba8 | ||
|
|
277cd70446 | ||
|
|
e709001494 | ||
|
|
07cd60db29 | ||
|
|
764306544c | ||
|
|
7ba2c03f61 | ||
|
|
f85361ae67 | ||
|
|
ad7b0d8da8 | ||
|
|
3440da6f8f |
10 changed files with 139 additions and 48 deletions
Binary file not shown.
|
|
@ -69,7 +69,7 @@ class StackParams(BaseModel):
|
||||||
# Using docstrings under variables as this is how pdoc would expect
|
# Using docstrings under variables as this is how pdoc would expect
|
||||||
# attributed to be documented
|
# attributed to be documented
|
||||||
|
|
||||||
settling_time: float = Field(default=0.3, ge=0)
|
settling_time: float = Field(default=0.05, ge=0)
|
||||||
"""Time (in seconds) between moving and capturing an image"""
|
"""Time (in seconds) between moving and capturing an image"""
|
||||||
|
|
||||||
origin: StackOrigin = StackOrigin.START
|
origin: StackOrigin = StackOrigin.START
|
||||||
|
|
@ -345,12 +345,14 @@ class JPEGSharpnessMonitor:
|
||||||
async def monitor_sharpness(self) -> None:
|
async def monitor_sharpness(self) -> None:
|
||||||
"""Start monitoring sharpness metrics."""
|
"""Start monitoring sharpness metrics."""
|
||||||
self.running = True
|
self.running = True
|
||||||
async for frame in self.camera.lores_mjpeg_stream.frame_async_generator():
|
while self.running:
|
||||||
self._jpeg_times.append(time.time())
|
i = await self.camera.lores_mjpeg_stream.next_frame()
|
||||||
|
entry = await self.camera.lores_mjpeg_stream.ringbuffer_entry(i)
|
||||||
|
self._jpeg_times.append(entry.timestamp.timestamp())
|
||||||
|
|
||||||
# JPEG sharpness metric
|
# JPEG sharpness metric
|
||||||
if self.record & SharpnessMethod.JPEG:
|
if self.record & SharpnessMethod.JPEG:
|
||||||
self._jpeg_sizes.append(len(frame))
|
self._jpeg_sizes.append(len(entry.frame))
|
||||||
|
|
||||||
# FocusFoM metric
|
# FocusFoM metric
|
||||||
if self.record & SharpnessMethod.FOCUS_FOM:
|
if self.record & SharpnessMethod.FOCUS_FOM:
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from abc import ABC, abstractmethod
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import Any, Literal, Mapping, Optional, Self
|
from typing import Any, Literal, Mapping, Optional, Self, Union, overload
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import piexif
|
import piexif
|
||||||
|
|
@ -216,6 +216,70 @@ class CaptureGalleryInfo(BaseModel):
|
||||||
card_type: Literal["Capture"] = "Capture"
|
card_type: Literal["Capture"] = "Capture"
|
||||||
|
|
||||||
|
|
||||||
|
class MJPEGStreamWithTimestamp(lt.outputs.MJPEGStream):
|
||||||
|
"""An MJPEG stream where the frame time can be recorded with the frame.
|
||||||
|
|
||||||
|
This can be used to capture when the image was taken by the sensor if known
|
||||||
|
rather than take the time it is added into the ring buffer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def add_frame(self, frame: bytes, timestamp: Optional[datetime] = None) -> None:
|
||||||
|
"""Add a JPEG to the MJPEG stream.
|
||||||
|
|
||||||
|
Modify the standard function to have an option to send in the capture time.
|
||||||
|
|
||||||
|
:param frame: The frame to add
|
||||||
|
:param timestamp: The time the frame was captured.
|
||||||
|
|
||||||
|
:raise ValueError: if the supplied frame does not start with the JPEG
|
||||||
|
start bytes and end with the end bytes.
|
||||||
|
"""
|
||||||
|
if not (
|
||||||
|
frame[0] == 0xFF
|
||||||
|
and frame[1] == 0xD8
|
||||||
|
and frame[-2] == 0xFF
|
||||||
|
and frame[-1] == 0xD9
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid JPEG")
|
||||||
|
with self._lock:
|
||||||
|
entry = self._ringbuffer[(self.last_frame_i + 1) % len(self._ringbuffer)]
|
||||||
|
entry.timestamp = timestamp if timestamp is not None else datetime.now()
|
||||||
|
entry.frame = frame
|
||||||
|
entry.index = self.last_frame_i + 1
|
||||||
|
self._thing_server_interface.start_async_task_soon(
|
||||||
|
self.notify_new_frame, entry.index
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MJPEGStreamDescriptorWithTimestamp(lt.outputs.MJPEGStreamDescriptor):
|
||||||
|
"""Subclass the Labthings descriptor to return our own class."""
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def __get__(self, obj: Literal[None], type: type | None = None) -> Self: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def __get__(
|
||||||
|
self, obj: lt.Thing, type: type | None = None
|
||||||
|
) -> MJPEGStreamWithTimestamp: ...
|
||||||
|
|
||||||
|
def __get__(
|
||||||
|
self,
|
||||||
|
obj: Optional[lt.Thing],
|
||||||
|
type: type[lt.Thing] | None = None, # noqa: A002
|
||||||
|
) -> Union[MJPEGStreamWithTimestamp | Self]:
|
||||||
|
"""Return our own MJPEG Stream with timestamp."""
|
||||||
|
if obj is None:
|
||||||
|
return self
|
||||||
|
try:
|
||||||
|
return obj.__dict__[self.name]
|
||||||
|
except KeyError:
|
||||||
|
obj.__dict__[self.name] = MJPEGStreamWithTimestamp(
|
||||||
|
**self._kwargs,
|
||||||
|
thing_server_interface=obj._thing_server_interface,
|
||||||
|
)
|
||||||
|
return obj.__dict__[self.name]
|
||||||
|
|
||||||
|
|
||||||
class BaseCamera(OFMThing, ABC):
|
class BaseCamera(OFMThing, ABC):
|
||||||
"""The base class for all cameras. All cameras must directly inherit from this class.
|
"""The base class for all cameras. All cameras must directly inherit from this class.
|
||||||
|
|
||||||
|
|
@ -227,8 +291,8 @@ class BaseCamera(OFMThing, ABC):
|
||||||
|
|
||||||
_all_background_detectors: Mapping[str, BackgroundDetectAlgorithm] = lt.thing_slot()
|
_all_background_detectors: Mapping[str, BackgroundDetectAlgorithm] = lt.thing_slot()
|
||||||
|
|
||||||
mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
|
mjpeg_stream = MJPEGStreamDescriptorWithTimestamp()
|
||||||
lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
|
lores_mjpeg_stream = MJPEGStreamDescriptorWithTimestamp()
|
||||||
_memory_buffer = CameraMemoryBuffer()
|
_memory_buffer = CameraMemoryBuffer()
|
||||||
supports_focus_fom: bool = False
|
supports_focus_fom: bool = False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import tempfile
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import (
|
from typing import (
|
||||||
|
|
@ -54,7 +55,7 @@ from openflexure_microscope_server.ui import (
|
||||||
property_control_for,
|
property_control_for,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import BaseCamera, CaptureMode, StreamingMode
|
from . import BaseCamera, CaptureMode, MJPEGStreamWithTimestamp, StreamingMode
|
||||||
from . import picamera_recalibrate_utils as recalibrate_utils
|
from . import picamera_recalibrate_utils as recalibrate_utils
|
||||||
from . import picamera_tuning_file_utils as tf_utils
|
from . import picamera_tuning_file_utils as tf_utils
|
||||||
|
|
||||||
|
|
@ -75,24 +76,37 @@ class MissingCalibrationError(RuntimeError):
|
||||||
class PicameraStreamOutput(Output):
|
class PicameraStreamOutput(Output):
|
||||||
"""An Output class that sends frames to a stream."""
|
"""An Output class that sends frames to a stream."""
|
||||||
|
|
||||||
def __init__(self, stream: lt.outputs.MJPEGStream) -> None:
|
def __init__(self, stream: MJPEGStreamWithTimestamp, encoder: MJPEGEncoder) -> None:
|
||||||
"""Create an output that puts frames in an MJPEGStream.
|
"""Create an output that puts frames in an MJPEGStream.
|
||||||
|
|
||||||
:param stream: The labthings MJPEGStream to send frames to.
|
:param stream: The labthings MJPEGStream to send frames to.
|
||||||
|
:param encoder: The encoder that is encoding the frames. This is needed to get
|
||||||
|
the full timestamp.
|
||||||
"""
|
"""
|
||||||
Output.__init__(self)
|
Output.__init__(self)
|
||||||
self.stream = stream
|
self.stream = stream
|
||||||
|
self.encoder = encoder
|
||||||
|
|
||||||
def outputframe(
|
def outputframe(
|
||||||
self,
|
self,
|
||||||
frame: bytes,
|
frame: bytes,
|
||||||
_keyframe: Optional[bool] = True,
|
_keyframe: Optional[bool] = True,
|
||||||
_timestamp: Optional[int] = None,
|
timestamp: Optional[int] = None,
|
||||||
_packet: Any = None,
|
_packet: Any = None,
|
||||||
_audio: bool = False,
|
_audio: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a frame to the stream's ringbuffer."""
|
"""Add a frame to the stream's ringbuffer."""
|
||||||
self.stream.add_frame(frame)
|
if timestamp is None or self.encoder.firsttimestamp is None:
|
||||||
|
frame_timestamp = None
|
||||||
|
else:
|
||||||
|
# The difference between unix time and the CPU time. Do not cache this
|
||||||
|
# number as it can change with clock slew or other time adjustments.
|
||||||
|
cpu_dt = time.time() - time.monotonic()
|
||||||
|
# Reconstruct full CPU time of the frame in us
|
||||||
|
timestamp += self.encoder.firsttimestamp
|
||||||
|
# Convert to datetime
|
||||||
|
frame_timestamp = datetime.fromtimestamp(cpu_dt + timestamp / 1e6)
|
||||||
|
self.stream.add_frame(frame, frame_timestamp)
|
||||||
|
|
||||||
|
|
||||||
class PiCamera2StreamingMode(StreamingMode):
|
class PiCamera2StreamingMode(StreamingMode):
|
||||||
|
|
@ -487,14 +501,16 @@ class StreamingPiCamera2(BaseCamera, ABC):
|
||||||
picam.configure(stream_config)
|
picam.configure(stream_config)
|
||||||
LOGGER.info("Starting picamera MJPEG stream...")
|
LOGGER.info("Starting picamera MJPEG stream...")
|
||||||
stream_name = "lores" if mode_info.use_lores_as_preview else "main"
|
stream_name = "lores" if mode_info.use_lores_as_preview else "main"
|
||||||
|
encoder = MJPEGEncoder(self.mjpeg_bitrate)
|
||||||
picam.start_recording(
|
picam.start_recording(
|
||||||
MJPEGEncoder(self.mjpeg_bitrate),
|
encoder,
|
||||||
PicameraStreamOutput(self.mjpeg_stream),
|
PicameraStreamOutput(self.mjpeg_stream, encoder),
|
||||||
name=stream_name,
|
name=stream_name,
|
||||||
)
|
)
|
||||||
|
encoder = MJPEGEncoder(100000000)
|
||||||
picam.start_encoder(
|
picam.start_encoder(
|
||||||
MJPEGEncoder(100000000),
|
encoder,
|
||||||
PicameraStreamOutput(self.lores_mjpeg_stream),
|
PicameraStreamOutput(self.lores_mjpeg_stream, encoder),
|
||||||
name="lores",
|
name="lores",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import io
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import Optional, Self, overload
|
from typing import Optional, Self, overload
|
||||||
|
|
@ -467,9 +468,10 @@ class SimulatedCamera(BaseCamera):
|
||||||
last_frame_t = time.time()
|
last_frame_t = time.time()
|
||||||
|
|
||||||
frame = self.generate_frame()
|
frame = self.generate_frame()
|
||||||
self.mjpeg_stream.add_frame(_frame2bytes(frame))
|
timestamp = datetime.fromtimestamp(last_frame_t)
|
||||||
|
self.mjpeg_stream.add_frame(_frame2bytes(frame), timestamp)
|
||||||
ds_frame = frame.resize((320, 240), resample=Image.Resampling.NEAREST)
|
ds_frame = frame.resize((320, 240), resample=Image.Resampling.NEAREST)
|
||||||
self.lores_mjpeg_stream.add_frame(_frame2bytes(ds_frame))
|
self.lores_mjpeg_stream.add_frame(_frame2bytes(ds_frame), timestamp)
|
||||||
|
|
||||||
@lt.action
|
@lt.action
|
||||||
def discard_frames(self) -> None:
|
def discard_frames(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -228,3 +228,11 @@ a:hover {
|
||||||
.uk-pagination > .uk-active > * {
|
.uk-pagination > .uk-active > * {
|
||||||
color: @global-primary-background;
|
color: @global-primary-background;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Checkboxes
|
||||||
|
*/
|
||||||
|
|
||||||
|
.uk-checkbox {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
@ -20,8 +20,7 @@
|
||||||
:value="option"
|
:value="option"
|
||||||
:checked="modelValue.includes(option)"
|
:checked="modelValue.includes(option)"
|
||||||
@change="toggleOption(option)"
|
@change="toggleOption(option)"
|
||||||
/>
|
/>{{ option }}
|
||||||
{{ option }}
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,9 @@ export default {
|
||||||
.log-summary-text {
|
.log-summary-text {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: large;
|
font-size: large;
|
||||||
height: calc(2 * 1.4em); /* height of element is 2 lines */
|
|
||||||
|
/* Reserve two lines even when message is short. line-clamp handles truncation */
|
||||||
|
min-height: calc(2 * 1.4em);
|
||||||
line-height: 1.4em;
|
line-height: 1.4em;
|
||||||
padding: 0 5px; /* Give some padding so there's room for ... to not expand too far */
|
padding: 0 5px; /* Give some padding so there's room for ... to not expand too far */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ and zero position buttons. It also includes the d-pad.
|
||||||
<li class="uk-closed">
|
<li class="uk-closed">
|
||||||
<a class="uk-accordion-title" href="#">Position</a>
|
<a class="uk-accordion-title" href="#">Position</a>
|
||||||
<div class="uk-accordion-content">
|
<div class="uk-accordion-content">
|
||||||
<form>
|
|
||||||
<!-- Text boxes to set and view position -->
|
<!-- Text boxes to set and view position -->
|
||||||
<div class="input-and-buttons-container">
|
<div class="input-and-buttons-container">
|
||||||
<input
|
<input
|
||||||
|
|
@ -37,7 +36,6 @@ and zero position buttons. It also includes the d-pad.
|
||||||
@error="modalError"
|
@error="modalError"
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
|
||||||
<action-button
|
<action-button
|
||||||
thing="stage"
|
thing="stage"
|
||||||
action="set_zero_position"
|
action="set_zero_position"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<div>
|
<div>
|
||||||
<h3>Stream Settings</h3>
|
<h3>Stream Settings</h3>
|
||||||
<label
|
<label
|
||||||
><input v-model="disableStream" class="uk-checkbox" type="checkbox" /> Disable Web
|
><input v-model="disableStream" class="uk-checkbox" type="checkbox" />Disable Web
|
||||||
Stream</label
|
Stream</label
|
||||||
>
|
>
|
||||||
<p class="uk-margin-small">This will disable the embedded web stream of the camera.</p>
|
<p class="uk-margin-small">This will disable the embedded web stream of the camera.</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue