Source code for epdfsuite.filereader
import re
import os
import sys
import hyperspy.api as hs
from .camera_library import DETECTOR_LIBRARY
import numpy as np
[docs]
def extract_camera_type(metadata, detector_lib=DETECTOR_LIBRARY):
"""
Identify the camera type from HyperSpy metadata using regex alias matching.
First attempts an exact match of the image title against library keys, then
falls back to case-insensitive regex search over each detector's alias list.
Parameters
----------
metadata : HyperSpy metadata object
Metadata loaded from a DM4 (or other HyperSpy-supported) file.
detector_lib : dict, optional
Detector library to search. Defaults to the built-in
:data:`DETECTOR_LIBRARY`.
Returns
-------
camera_key : str or None
Key of the matched detector in ``detector_lib``, or ``None`` if not found.
camera_title : str or None
Raw title string found in the metadata, or ``None`` if unavailable.
"""
try:
if hasattr(metadata, 'General'):
general = metadata.General
if hasattr(general, 'title'):
title = general.title
# First, search for exact match
if title in detector_lib:
return title, title
# Then, search by regex on aliases
title_lower = title.lower()
for camera_key, params in detector_lib.items():
if 'aliases' in params:
for alias_pattern in params['aliases']:
try:
# Compile pattern as regex
pattern = re.compile(alias_pattern, re.IGNORECASE)
if pattern.search(title_lower):
return camera_key, title
except re.error:
# If not valid regex, search as substring
if alias_pattern.lower() in title_lower:
return camera_key, title
# No match found
print(f" ⚠ No match found in aliases")
return None, title
except Exception as e:
print(f"Error extracting camera type: {e}")
return None, None
def _search_metadata_recursive(obj, target_keys, depth=0, max_depth=10):
"""
Recursively search a HyperSpy metadata object for target field names.
Traverses attributes and dictionary-like entries at each level, performing
case-insensitive substring matching of attribute names against ``target_keys``.
Parameters
----------
obj : object
Root metadata object (HyperSpy ``DictionaryTreeBrowser`` or any object
with ``__dict__`` or dict-like interface).
target_keys : list of str
Field names to search for (case-insensitive substring match).
depth : int, optional
Current recursion depth. Default is 0.
max_depth : int, optional
Maximum recursion depth to prevent infinite loops. Default is 10.
Returns
-------
found : dict
Mapping ``{attribute_name: value}`` for all matched scalar fields
(int, float, or str).
"""
found = {}
if depth > max_depth or obj is None:
return found
try:
# Search in attributes
if hasattr(obj, '__dict__'):
for attr_name, attr_value in obj.__dict__.items():
attr_lower = attr_name.lower()
# Check if this attribute matches any target key
for target_key in target_keys:
if target_key.lower() in attr_lower:
if isinstance(attr_value, (int, float, str)) and attr_value is not None:
found[attr_name] = attr_value
break
# Recursively search in nested objects
if hasattr(attr_value, '__dict__'):
nested_found = _search_metadata_recursive(attr_value, target_keys, depth + 1, max_depth)
found.update(nested_found)
# Search in dictionary-like objects
if hasattr(obj, '__getitem__') and hasattr(obj, 'keys'):
for key in obj.keys():
key_lower = key.lower()
value = obj[key]
# Check if key matches any target
for target_key in target_keys:
if target_key.lower() in key_lower:
if isinstance(value, (int, float, str)) and value is not None:
found[key] = value
break
# Recursively search in nested objects
if hasattr(value, '__dict__') or (hasattr(value, '__getitem__') and hasattr(value, 'keys')):
nested_found = _search_metadata_recursive(value, target_keys, depth + 1, max_depth)
found.update(nested_found)
except Exception as e:
pass # Silently skip objects that can't be searched
return found
[docs]
def extract_wavelength(metadata=None, voltage_kv=None):
"""
Determine the relativistic electron wavelength from metadata or voltage.
Searches the metadata recursively for a wavelength, beam energy, or
accelerating voltage field. If a direct wavelength is found (0.001–1 Å)
it is returned immediately. Otherwise the wavelength is computed from the
voltage using the relativistic de Broglie relation:
.. math::
\\lambda = \\frac{h}{\\sqrt{2 m_e e V \\left(1 + \\frac{eV}{2 m_e c^2}\\right)}}
Parameters
----------
metadata : HyperSpy metadata object, optional
Metadata from a DM4 file. If ``None``, ``voltage_kv`` must be provided.
voltage_kv : float, optional
Accelerating voltage in kV. Overrides the voltage found in ``metadata``
if both are provided.
Returns
-------
wavelength : float or None
Electron wavelength in Å, or ``None`` if neither voltage nor wavelength
could be determined.
"""
wavelength_angstrom = None
# Deep search in metadata for relevant fields
if metadata is not None:
try:
# Search for wavelength, energy, and voltage fields
search_keys = ['wavelength', 'energy', 'beam_energy', 'accelerating_voltage', 'acceleration_voltage']
found_values = _search_metadata_recursive(metadata, search_keys)
# Priority: wavelength > beam_energy > energy > accelerating_voltage
for key, value in found_values.items():
key_lower = key.lower()
try:
# Try to convert to float
val_float = float(value)
# Check for wavelength
if 'wavelength' in key_lower:
# Assume it's in Ångströms if small enough (typically 0.01 - 0.1 Å for electrons)
if 0.001 < val_float < 1:
wavelength_angstrom = val_float
#print(f" ✓ Found wavelength in metadata: {val_float:.6f} Å")
break
# Check for energy fields
elif any(term in key_lower for term in ['energy', 'accelerat']):
if voltage_kv is None:
# Energy in keV if < 10000, else in eV
if val_float < 10000:
voltage_kv = val_float # Already in keV
#print(f" ✓ Found energy in metadata: {val_float:.1f} keV")
else:
voltage_kv = val_float / 1000 # Convert from eV to keV
#print(f" ✓ Found energy in metadata: {val_float:.1f} eV ({voltage_kv:.1f} keV)")
except (ValueError, TypeError):
pass # Skip non-numeric values
except Exception as e:
print(f"Warning: Could not search metadata: {e}")
# If wavelength found directly, return it
if wavelength_angstrom is not None:
return wavelength_angstrom
# Otherwise, calculate from voltage
if voltage_kv is None:
print("⚠ Voltage (kV) or wavelength not found in metadata")
return None
# Ensure voltage_kv is a number
try:
voltage_kv = float(voltage_kv)
except (ValueError, TypeError):
print("⚠ Could not convert voltage to float")
return None
# Constants (SI units)
h = 6.62607015e-34 # Planck constant (J·s)
m_e = 9.1093837015e-31 # Electron mass (kg)
e = 1.602176634e-19 # Elementary charge (C)
c = 299792458 # Speed of light (m/s)
V = voltage_kv * 1000 # Convert kV to V
# De Broglie wavelength with relativistic correction
# λ = h / √(2*m_e*e*V*(1 + e*V/(2*m_e*c²)))
rest_energy = m_e * c**2 / e # Rest energy in eV
kinetic_energy = V # Kinetic energy in eV
relativistic_factor = 1 + kinetic_energy / (2 * rest_energy)
wavelength_m = h / np.sqrt(2 * m_e * e * V * relativistic_factor)
# Convert to Ångströms
wavelength_angstrom = wavelength_m * 1e10
#print(f" ✓ Calculated wavelength from {voltage_kv:.1f} kV: {wavelength_angstrom:.6f} Å")
return wavelength_angstrom
[docs]
def get_detector_params(camera_key, detector_lib=DETECTOR_LIBRARY):
"""
Return the parameters of a detector from the library, without alias entries.
Parameters
----------
camera_key : str
Key name of the detector in ``detector_lib``.
detector_lib : dict, optional
Detector library to query. Defaults to :data:`DETECTOR_LIBRARY`.
Returns
-------
params : dict or None
Copy of the detector parameter dictionary (``pixel_size``,
``image_width``, ``image_height``, ``binning``, ``description``),
or ``None`` if ``camera_key`` is not found.
"""
if camera_key in detector_lib:
params = detector_lib[camera_key].copy()
params.pop('aliases', None) # Remove aliases from result
return params
else:
print(f"⚠ Camera type '{camera_key}' not found in library")
print(f"Available cameras: {list(detector_lib.keys())}")
return None
[docs]
def load_data(file, normalize=True, verbose=True):
"""
Load a DM4 image file and return detector metadata and the image array.
Identifies the camera type and wavelength from the file metadata,
optionally normalises the raw counts by the exposure time, and prints
a summary of the detected instrument parameters.
Parameters
----------
file : str
Path to the DM4 image file.
normalize : bool, optional
If ``True`` (default), divide the image by the exposure time (s)
to obtain a count-rate image. Has no effect if the exposure time
cannot be found in the metadata.
verbose : bool, optional
If ``True`` (default), print loaded file info and detector parameters.
Returns
-------
detector_info : dict
Dictionary with keys: ``camera_type``, ``camera_title``,
``pixel_size`` (µm), ``image_width`` (px), ``image_height`` (px),
``binning``, ``description``, ``wavelength`` (Å),
``exposure_time`` (s, if found).
raw_image : ndarray
2D float array of the image, normalised by exposure time if requested.
"""
# Load image
image = hs.load(file)
metadata = image.metadata
raw_image = image.data
# Extract detector info automatically
camera_key, camera_title = extract_camera_type(metadata)
# Extract wavelength information
wavelength_info = extract_wavelength(metadata)
if camera_key:
print(f" ✓ camera type from database: {camera_key}")
detector_info = get_detector_params(camera_key)
detector_info['camera_type'] = camera_key
detector_info['camera_title'] = camera_title
detector_info['binning'] = detector_info['image_height'] / raw_image.shape[0] # Calculate binning from image dimensions
else:
# If detector not found, create dict with default values
detector_info = {
'camera_type': None,
'camera_title': camera_title,
'wavelength' : wavelength_info,
'pixel_size': None,
'image_width': raw_image.shape[1],
'image_height': raw_image.shape[0],
'binning': 1,
'description': 'Unknown detector',
'wavelength': wavelength_info if wavelength_info is not None else None,
'note': 'Dimensions from raw image'
}
# Add wavelength information if available
if wavelength_info is not None:
detector_info['wavelength'] = wavelength_info
# Extract exposure time
exposure_time = None
found_values = _search_metadata_recursive(metadata, ['exposure_time','exposure', 'acquisition_time', 'dwell_time'])
for key, value in found_values.items():
exposure_time = float(value)
detector_info['exposure_time'] = exposure_time
break
if verbose:
print(f"Loaded file: {file}")
print("Sample information:")
for key, value in detector_info.items():
print(f" {key}: {value}")
if normalize:
if 'exposure_time' in detector_info and detector_info['exposure_time'] is not None:
raw_image = raw_image / detector_info['exposure_time']
#if verbose:
#print(f" ✓ Normalized image by exposure time: {detector_info['exposure_time']} s")
else:
print(" ⚠ Exposure time not found, image not normalized")
return detector_info, raw_image
[docs]
def add_detector(camera_key, pixel_size, image_width, image_height, binning=1, description='', aliases=None):
"""
Add a new detector entry to the in-memory library and persist it to disk.
The entry is appended to the global :data:`DETECTOR_LIBRARY` dictionary
and immediately written back to ``camera_library.py``.
Parameters
----------
camera_key : str
Unique identifier for the detector (e.g. ``'K3_300kV'``).
pixel_size : float
Physical pixel size in micrometres (µm).
image_width : int
Full-frame image width in pixels.
image_height : int
Full-frame image height in pixels.
binning : int, optional
Hardware binning factor applied at acquisition. Default is 1.
description : str, optional
Human-readable description of the detector.
aliases : list of str, optional
List of regex patterns (case-insensitive) used to match this detector
from image title strings in metadata. Default is ``[]``.
Returns
-------
success : bool
``True`` if the detector was added, ``False`` if ``camera_key``
already exists in the library.
"""
if camera_key in DETECTOR_LIBRARY:
print(f"⚠ Camera type '{camera_key}' already exists in library")
return False
if aliases is None:
aliases = []
# Add to in-memory dictionary
DETECTOR_LIBRARY[camera_key] = {
'pixel_size': pixel_size,
'image_width': image_width,
'image_height': image_height,
'binning': binning,
'description': description,
'aliases': aliases
}
# Save to camera_library.py file
_save_detector_library()
print(f"✓ Added detector: '{camera_key}' with pixel_size={pixel_size}µm")
print(f"✓ Saved to camera_library.py")
return
def _save_detector_library():
"""
Persist the current state of :data:`DETECTOR_LIBRARY` to ``camera_library.py``.
Overwrites the file with a pretty-printed Python assignment so that changes
made via :func:`add_detector` survive across sessions.
"""
import pprint
# Get the path to camera_library.py
current_dir = os.path.dirname(os.path.abspath(__file__))
camera_lib_path = os.path.join(current_dir, 'camera_library.py')
# Generate the content
content = "# TEM detector library with their specifications, can be extended\nDETECTOR_LIBRARY = "
content += pprint.pformat(DETECTOR_LIBRARY, indent=4)
content += "\n"
# Write to file
try:
with open(camera_lib_path, 'w') as f:
f.write(content)
except Exception as e:
print(f"⚠ Error saving detector library: {e}")