Source code for playnano.utils.time_utils

"""
Timestamp and annotation utilities for playNano.

This module provides functions for normalising per-frame timestamps,
drawing scale bar and timestamp overlays onto AFM image frames, and
generating UTC timestamps for provenance records.

Functions
---------
normalize_timestamps
    Parse and normalise per-frame timestamp metadata to float seconds.
draw_scale_and_timestamp
    Draw a scale bar and timestamp overlay onto an AFM image frame.
utc_now_iso
    Return the current UTC time as an ISO 8601 string.
"""

from __future__ import annotations

from datetime import datetime
from importlib.resources import files

# Allow compatibility with Python 3.10
try:
    from datetime import UTC
except ImportError:
    from datetime import timezone

    UTC = timezone.utc

from typing import Any

import dateutil.parser
import numpy as np
from PIL import Image, ImageDraw, ImageFont


[docs] def normalize_timestamps(metadata_list: list[dict[str, Any]]) -> list[dict[str, Any]]: """ Normalize timestamp data to a float in seconds. Given a list of per-frame metadata dicts, parse each 'timestamp' entry (if present) into a float (seconds). Returns a new list of dicts with 'timestamp' replaced by float or None. - ISO-format strings → parsed with dateutil.isoparse() - datetime objects → .timestamp() - numeric (int/float) → float() - missing/unparsable → None Parameters ---------- metadata_list : list of dict List of metadata dictionaries, each possibly containing a 'timestamp'. Returns ------- list of dict List of metadata dicts with 'timestamp' normalized to float seconds or None. """ normalized: list[dict[str, Any]] = [] for md in metadata_list: new_md = dict(md) # shallow copy so we don't mutate the original t = new_md.get("timestamp", None) if t is None: new_md["timestamp"] = None elif isinstance(t, str): try: dt = dateutil.parser.isoparse(t) new_md["timestamp"] = dt.timestamp() except Exception: # parsing failed new_md["timestamp"] = None elif isinstance(t, datetime): new_md["timestamp"] = t.timestamp() elif isinstance(t, (int, float)): new_md["timestamp"] = float(t) else: new_md["timestamp"] = None normalized.append(new_md) return normalized
[docs] def draw_scale_and_timestamp( image: np.ndarray, timestamp: float, pixel_size_nm: float, resize_scale: float, annotation_scale: float, bar_length_nm: int = 100, font_scale: float = 1, draw_ts: bool = True, draw_scale: bool = True, color: tuple = (255, 255, 255), ) -> np.ndarray: """ Draw a scale bar and/or timestamp overlay onto an AFM image frame. Two separate scale factors are used to decouple physical accuracy from visual consistency. ``resize_scale`` ensures the scale bar length correctly represents the physical nanometre distance regardless of how the frame was resized. ``annotation_scale`` ensures margins, bar thickness, and text positions appear proportionally identical at any output resolution. Parameters ---------- image : np.ndarray RGB image array of shape (H, W, 3), dtype uint8. timestamp : float Timestamp in seconds to display in the top-left corner. pixel_size_nm : float Physical size of one pixel in the original (pre-resize) frame, in nanometres. Used to compute the scale bar length in pixels. resize_scale : float Factor by which the frame was upscaled from its original resolution (``output_height / original_height``). Used only for computing the physically accurate scale bar width. annotation_scale : float Factor relative to ``REFERENCE_HEIGHT`` used to scale all annotation geometry — margins, bar thickness, label gaps, and text position (``output_height / REFERENCE_HEIGHT``). Ensures annotations occupy the same visual proportion at any output resolution. bar_length_nm : int Desired scale bar length in nanometres. Default is 100. font_scale : float Multiplier applied to the base font point size (15 pt). Default is 1. draw_ts : bool Whether to draw the timestamp in the top-left corner. Default is ``True``. draw_scale : bool Whether to draw the scale bar and label in the bottom-left corner. Default is ``True``. color : tuple RGB colour for all annotations. Default is white ``(255, 255, 255)``. Returns ------- np.ndarray Annotated image as a uint8 array of shape (H, W, 3). Notes ----- - Requires the Steps-Mono font bundled in ``playnano.resources.fonts``. Falls back to the PIL default font if the file cannot be loaded. - The scale bar is only drawn when ``pixel_size_nm > 0`` and ``bar_length_nm > 0``. """ # Convert to PIL for drawing pil = Image.fromarray(image) draw = ImageDraw.Draw(pil) W, H = pil.size # ==== Font setup ==== steps_font_path = files("playnano.resources.fonts").joinpath( "Steps-Mono/Steps-Mono.otf" ) # compute a point size to match the GUI's QFont ptsize = int(15 * font_scale) try: font = ImageFont.truetype(steps_font_path, ptsize) except Exception: # fallback to default font = ImageFont.load_default() # Helper to measure text size def measure(text): """Measure text size in pixels, compatible with different PIL versions.""" try: return font.getsize(text) except AttributeError: bbox = draw.textbbox((0, 0), text, font=font) return (bbox[2] - bbox[0], bbox[3] - bbox[1]) # Scale all pixel margins/sizes proportionally margin = int(10 * annotation_scale) bar_h = max(1, int(5 * annotation_scale)) bottom_margin = int(22 * annotation_scale) label_gap = int(9 * annotation_scale) ts_gap = int(2 * annotation_scale) # ==== DRAW TIMESTAMP ==== if draw_ts: ts_text = f"{timestamp:.2f} s" tw, th = measure(ts_text) y_offset = th + ts_gap draw.text((margin, y_offset), ts_text, font=font, fill=color) # ==== DRAW SCALE BAR ==== if draw_scale and bar_length_nm > 0 and pixel_size_nm and pixel_size_nm > 0: px_per_nm = 1.0 / pixel_size_nm raw_bar_px = bar_length_nm * px_per_nm bar_w = int(raw_bar_px * resize_scale) x0, y0 = margin, H - bottom_margin x1, y1 = x0 + bar_w - 1, y0 + bar_h - 1 draw.rectangle([x0, y0, x1, y1], fill=color) label = f"{bar_length_nm} nm" lw, lh = measure(label) draw.text((x0, y0 - lh - label_gap), label, font=font, fill=color) return np.array(pil)
[docs] def utc_now_iso() -> str: """ Return the current UTC time as an ISO 8601 string. Returns ------- str UTC timestamp in ISO 8601 format, e.g. ``'2024-01-15T10:30:00Z'``. """ return datetime.now(UTC).isoformat().replace("+00:00", "Z")