Compare commits

...

17 commits

Author SHA1 Message Date
Julian Stirling
59f8f01202 Merge branch 'position-without-form' into 'v3'
Remove form tags from around position controls as it can lead to unwanted refresh

See merge request openflexure/openflexure-microscope-server!662
2026-07-21 17:58:33 +00:00
Julian Stirling
6e6ce3b8ed Remove form tags from around position controls as it can lead to unwanted refresh 2026-07-21 11:44:28 -03:00
Joe Knapper
be233e6b95 Merge branch 'cpu-time-offset' into 'v3'
Re-calculate time offset from CPU time to real time on each frame

Closes #830

See merge request openflexure/openflexure-microscope-server!660
2026-07-20 14:10:08 +00:00
Julian Stirling
faa20801ff update picamera coverage zip 2026-07-20 14:29:32 +01:00
Julian Stirling
a7b298889a Re-calculate time offset from CPU time to real time on each frame 2026-07-18 13:44:24 -03:00
Julian Stirling
3b89a499d0 Merge branch 'log-summary' into 'v3'
Use min-height with clamping to preserve space without truncating early

Closes #818

See merge request openflexure/openflexure-microscope-server!654
2026-07-13 22:34:45 +00:00
Julian Stirling
db6c3021ae Merge branch 'checkbox-margin' into 'v3'
Add margin to checkbox to prevent overlap with text on right, remove spaces

Closes #821

See merge request openflexure/openflexure-microscope-server!658
2026-07-13 22:04:13 +00:00
Joe Knapper
2d0d91773b Add margin to checkbox to prevent overlap with text on right, remove spaces 2026-07-10 16:26:50 +01:00
Joe Knapper
bb38e11ba8 Merge branch 'settling-time' into 'v3'
Decrease default settling time to 0.05

See merge request openflexure/openflexure-microscope-server!657
2026-07-10 13:04:12 +00:00
Julian Stirling
277cd70446 Merge branch 'autofocus-time' into 'v3'
Capture timestamp with frame to improve autofocus

Closes #723

See merge request openflexure/openflexure-microscope-server!656
2026-07-10 11:54:32 +00:00
Joe Knapper
e709001494 Decrease default settling time to 0.05 2026-07-10 10:49:51 +01:00
Julian Stirling
07cd60db29 update picamera coverage zip 2026-07-10 10:47:59 +01:00
Julian Stirling
764306544c Send timestamp with simulation frames 2026-07-10 10:37:35 +01:00
Julian Stirling
7ba2c03f61 Use us not ns for picamera timestamp and fix descriptor typing 2026-07-10 10:19:43 +01:00
Julian Stirling
f85361ae67 Subclass the mjpegstream descriptor 2026-07-10 09:48:13 +01:00
Julian Stirling
ad7b0d8da8 Record timestamp when adding frames to stream 2026-07-10 09:39:09 +01:00
Joe Knapper
3440da6f8f Use min-height with clamping to preserve space without truncating early 2026-07-09 15:17:15 +01:00
10 changed files with 139 additions and 48 deletions

Binary file not shown.

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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;
}

View file

@ -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>

View file

@ -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;

View file

@ -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"