Source code for cosedaUI.metadata_window
"""Preflight/metadata dialog for viewing and editing acquisition details and masks."""
from coseda.logging_utils import log_print
import configparser
import os
import numpy as np
from pathlib import Path
from PyQt6.QtWidgets import QMessageBox
from coseda.pixmask import generate_mask, save_mask_hdf5, remove_mask_hdf5, generate_full_mask_hdf5
from coseda.nexus.paths import (
BEAM_DIRECTION_PATH,
BEAM_GROUP_PATH,
BEAM_INCIDENT_ENERGY_PATH,
DETECTOR_GROUP_PATH,
DETECTOR_PIXEL_SIZE_X_PATH,
DETECTOR_PIXEL_SIZE_Y_PATH,
get_mask_dataset,
)
from coseda.nexus.groups import ensure_nexus_parents
from coseda.nexus.detector import write_detector_geometry
from coseda.io import config_to_paths
from coseda.logging_utils import log_start
from PyQt6.QtWidgets import (
QDialog, QFormLayout, QLabel, QLineEdit,
QComboBox, QPushButton, QWidget, QHBoxLayout, QFileDialog
)
[docs]
class MetadataWindow(QDialog):
"""Dialog to view/edit acquisition metadata and manage detector masks."""
def __init__(self, ini_path, parent=None):
"""Initialize the Preflight/Metadata dialog for a specific INI file."""
super().__init__(parent)
self.setWindowTitle("Preflight Check")
self.ini_path = ini_path
self.original_values = {}
# Keep a direct handle to the main window for workspace access
self.main_window = parent
# Layout and form for metadata fields
form_layout = QFormLayout(self)
self.field_widgets = {}
# Acceleration voltage (kV)
acc_edit = QLineEdit(self); acc_edit.setReadOnly(True)
acc_container = QWidget(self)
acc_layout = QHBoxLayout(acc_container)
acc_layout.setContentsMargins(0, 0, 0, 0)
acc_layout.addWidget(acc_edit)
acc_layout.addWidget(QLabel("kV"))
form_layout.addRow("Acceleration Voltage:", acc_container)
self.field_widgets["acceleration_voltage"] = acc_edit
acc_edit.setToolTip("Acceleration voltage in kilovolts (kV). Editable only if missing in metadata.")
# Camera length
cam_len_edit = QLineEdit(self)
cam_len_edit.setReadOnly(True)
cam_len_container = QWidget(self)
cam_len_layout = QHBoxLayout(cam_len_container)
cam_len_layout.setContentsMargins(0, 0, 0, 0)
cam_len_layout.addWidget(cam_len_edit)
cam_len_layout.addWidget(QLabel("m"))
form_layout.addRow("Camera Length:", cam_len_container)
self.field_widgets["camera_length"] = cam_len_edit
cam_len_edit.setToolTip("Camera length in meters, if the value is prefilled, it means it could be obtained from the metadata of.")
# Camera length correction factor (unitless)
corr_edit = QLineEdit(self)
corr_edit.setReadOnly(False)
corr_edit.setText("1")
corr_container = QWidget(self)
corr_layout = QHBoxLayout(corr_container)
corr_layout.setContentsMargins(0, 0, 0, 0)
corr_layout.addWidget(corr_edit)
form_layout.addRow("Camera Length Correction Factor:", corr_container)
self.field_widgets["camera_length_correction"] = corr_edit
corr_edit.setToolTip("Camera length correction factor (unitless); use this to adjust the camera length if you know the metadata value is incorrect; then go to your facility staff and complain")
# Save back to INI when correction factor changes
corr_edit.textChanged.connect(self.save_camera_length_correction)
# Resolution (width x height) in px
rw_edit = QLineEdit(self); rw_edit.setReadOnly(True)
rh_edit = QLineEdit(self); rh_edit.setReadOnly(True)
res_container = QWidget(self)
res_layout = QHBoxLayout(res_container)
res_layout.setContentsMargins(0, 0, 0, 0)
res_layout.addWidget(rw_edit)
res_layout.addWidget(QLabel("×"))
res_layout.addWidget(rh_edit)
res_layout.addWidget(QLabel("px"))
form_layout.addRow("Resolution:", res_container)
self.field_widgets["resolution_width"] = rw_edit
self.field_widgets["resolution_height"] = rh_edit
rw_edit.setToolTip("Detector resolution width in pixels (read-only).")
rh_edit.setToolTip("Detector resolution height in pixels (read-only).")
# Binning (width x height)
bw_edit = QLineEdit(self); bw_edit.setReadOnly(True)
bh_edit = QLineEdit(self); bh_edit.setReadOnly(True)
bin_container = QWidget(self)
bin_layout = QHBoxLayout(bin_container)
bin_layout.setContentsMargins(0, 0, 0, 0)
bin_layout.addWidget(bw_edit)
bin_layout.addWidget(QLabel("×"))
bin_layout.addWidget(bh_edit)
form_layout.addRow("Bin Factor:", bin_container)
self.field_widgets["binning_width"] = bw_edit
self.field_widgets["binning_height"] = bh_edit
bw_edit.setToolTip("Binning width factor (dimensionless, read-only).")
bh_edit.setToolTip("Binning height factor (dimensionless, read-only).")
# Binned resolution (computed, width x height) in px
brw_edit = QLineEdit(self); brw_edit.setReadOnly(True)
brh_edit = QLineEdit(self); brh_edit.setReadOnly(True)
bres_container = QWidget(self)
bres_layout = QHBoxLayout(bres_container)
bres_layout.setContentsMargins(0, 0, 0, 0)
bres_layout.addWidget(brw_edit)
bres_layout.addWidget(QLabel("×"))
bres_layout.addWidget(brh_edit)
bres_layout.addWidget(QLabel("px"))
form_layout.addRow("Binned Resolution:", bres_container)
self.binned_resolution_widgets = {"width": brw_edit, "height": brh_edit}
brw_edit.setToolTip("(Binned) frame resolution width in pixels.")
brh_edit.setToolTip("(Binned) frame resolution height in pixels.")
# Exposure time (s)
exp_edit = QLineEdit(self); exp_edit.setReadOnly(True)
exp_container = QWidget(self)
exp_layout = QHBoxLayout(exp_container)
exp_layout.setContentsMargins(0, 0, 0, 0)
exp_layout.addWidget(exp_edit)
exp_layout.addWidget(QLabel("s"))
form_layout.addRow("Exposure Time:", exp_container)
self.field_widgets["exposure_time"] = exp_edit
exp_edit.setToolTip("Exposure time in seconds (read-only).")
# ADU (dimensionless)
adu_edit = QLineEdit(self); adu_edit.setReadOnly(True)
adu_container = QWidget(self)
adu_layout = QHBoxLayout(adu_container)
adu_layout.setContentsMargins(0, 0, 0, 0)
adu_layout.addWidget(adu_edit)
form_layout.addRow("ADU:", adu_container)
self.field_widgets["adu_per_photon"] = adu_edit
adu_edit.setToolTip("Detector conversion gain in ADU per photon (dimensionless). Editable if missing.")
# Pixels per meter (m⁻¹) with Calculate
ppm_edit = QLineEdit(self); ppm_edit.setReadOnly(True)
ppm_btn = QPushButton("Calculate", self)
ppm_btn.clicked.connect(self.open_pixel_calc_dialog)
ppm_container = QWidget(self)
ppm_layout = QHBoxLayout(ppm_container)
ppm_layout.setContentsMargins(0, 0, 0, 0)
ppm_layout.addWidget(ppm_edit)
ppm_layout.addWidget(QLabel("m⁻¹"))
ppm_layout.addWidget(ppm_btn)
form_layout.addRow("Pixels per Meter:", ppm_container)
self.field_widgets["pixels_per_meter"] = ppm_edit
self.calculate_button = ppm_btn
ppm_edit.setToolTip("Pixels per meter (m⁻¹). Auto-filled when available; calculate if empty.")
ppm_btn.setToolTip("Open calculator to derive pixels per meter.")
# Pixel mask controls
pixelmask_btn = QPushButton('Mask from Image', self)
pixelmask_btn.clicked.connect(self.on_pixelmask_btn_clicked)
remove_mask_btn = QPushButton('Remove Mask', self)
remove_mask_btn.clicked.connect(self.on_remove_mask_clicked)
fullmask_btn = QPushButton('Generate Full Mask', self)
fullmask_btn.clicked.connect(self.on_generate_full_mask_clicked)
# hide remove when no mask, hide fullmask when mask exists
remove_mask_btn.setVisible(False)
fullmask_btn.setVisible(False)
pixelmask_container = QWidget(self)
pixelmask_layout = QHBoxLayout(pixelmask_container)
pixelmask_layout.setContentsMargins(0, 0, 0, 0)
pixelmask_layout.addWidget(pixelmask_btn)
pixelmask_layout.addWidget(remove_mask_btn)
pixelmask_layout.addWidget(fullmask_btn)
form_layout.addRow('Pixel Mask:', pixelmask_container)
self.pixelmask_btn = pixelmask_btn
self.remove_mask_btn = remove_mask_btn
self.fullmask_btn = fullmask_btn
self.pixelmask_btn.setToolTip("Load an bitmap image to generate and store a dectector/beamstop mask in the HDF5 file.")
self.remove_mask_btn.setToolTip("Remove the stored mask from the HDF5 file.")
self.fullmask_btn.setToolTip("Generate a full mask covering the detector; only use this option if your detector has a single chip and you do not want to mask a beamstop or bad pixels.")
# Set this layout on the dialog
self.setLayout(form_layout)
self.load_and_print_acquisition_details()
# Save back to INI whenever pixels_per_meter field changes
self.field_widgets["pixels_per_meter"].textChanged.connect(self.save_pixels_per_meter)
# Save back to INI whenever acceleration_voltage changes
self.field_widgets["acceleration_voltage"].textChanged.connect(self.save_acceleration_voltage)
# Save any other manually edited fields
for field, widget in self.field_widgets.items():
if field not in ("pixels_per_meter", "acceleration_voltage", "camera_length_correction"):
widget.textChanged.connect(lambda value, f=field: self.save_metadata_field(f, value))
# Automatically recalculate binned resolution when resolution or bin factors change
for key in ("resolution_width", "resolution_height", "binning_width", "binning_height"):
widget = self.field_widgets.get(key)
if widget:
widget.textChanged.connect(self._update_binned_resolution)
# Close and Apply-to-workspace buttons
close_btn = QPushButton("Close", self)
close_btn.clicked.connect(self.close)
close_btn.setToolTip("Close the Preflight dialog.")
apply_btn = QPushButton("Apply to Workspace", self)
apply_btn.setToolTip("Copy current metadata (and mask if present) to all files in the workspace.")
apply_btn.clicked.connect(self.apply_to_workspace)
edit_btn = QPushButton("Edit Metadata", self)
edit_btn.setToolTip("Unlock populated metadata fields for manual correction.")
edit_btn.clicked.connect(self.confirm_metadata_edit)
self.edit_metadata_btn = edit_btn
btn_container = QWidget(self)
btn_row = QHBoxLayout(btn_container)
btn_row.setContentsMargins(0, 0, 0, 0)
btn_row.addWidget(edit_btn)
btn_row.addWidget(apply_btn)
btn_row.addWidget(close_btn)
# Add across both columns
self.layout().addRow(btn_container)
# Reduce width of all line edits to avoid overly wide fields
for widget in list(self.field_widgets.values()) + list(getattr(self, 'binned_resolution_widgets', {}).values()):
widget.setMaximumWidth(100)
# Adjust window size to fit content
self.adjustSize()
def _log_to_ini(self, ini_path: str, message: str):
"""Log a message to the INI's associated logfile if available."""
try:
_, _, _, logfile_path, _, _ = config_to_paths(ini_path)
if logfile_path:
log_start(logfile_path, message)
except Exception as e:
log_print(f"[metadata log] failed for {ini_path}: {e}")
@staticmethod
def _render_val(val):
"""Render values for logging, making empties explicit."""
if val is None:
return "<unset>"
sval = str(val).strip()
return sval if sval else "<empty>"
def _log_change(self, ini_path: str, field: str, old, new):
"""Log a specific field change with before/after values."""
if old == new:
return
self._log_to_ini(
ini_path,
f"Preflight change: {field} {self._render_val(old)} -> {self._render_val(new)}"
)
def _resolve_h5_path_for_ini(self, ini_path: str):
"""Resolve the HDF5 path for an INI using explicit Paths entries first."""
if not ini_path:
return None
candidates = []
seen = set()
def _add(path: str):
if not path:
return
path = os.path.expanduser(os.path.expandvars(path))
ap = os.path.abspath(path)
if ap in seen:
return
seen.add(ap)
candidates.append(ap)
ini_abs = os.path.abspath(ini_path)
ini_dir = os.path.dirname(ini_abs)
cfg = configparser.ConfigParser()
try:
cfg.read(ini_abs)
except Exception:
pass
if cfg.has_section("Paths"):
h5_rel = cfg.get("Paths", "h5file", fallback="").strip()
out_rel = cfg.get("Paths", "outputfolder", fallback="").strip()
if h5_rel:
if os.path.isabs(h5_rel):
_add(h5_rel)
else:
_add(os.path.join(ini_dir, h5_rel))
if out_rel:
_add(os.path.join(ini_dir, out_rel, h5_rel))
try:
_, _, _, _, _, h5file_path = config_to_paths(ini_abs)
if h5file_path:
_add(h5file_path)
except Exception:
pass
stem = os.path.splitext(ini_abs)[0]
_add(stem + ".h5")
_add(stem + ".hdf5")
_add(stem + ".cxi")
for path in candidates:
if os.path.exists(path):
return Path(path)
return None
def _update_detector_pixel_size_h5(self, h5_path: Path, pixels_per_meter: float) -> None:
"""Persist detector pixel size in NeXus paths when available."""
if pixels_per_meter is None or pixels_per_meter <= 0:
return
if not h5_path.exists():
return
meters_per_pixel = 1.0 / pixels_per_meter
try:
import h5py
with h5py.File(h5_path, "r+") as f:
ensure_nexus_parents(f)
det_group = f.require_group(DETECTOR_GROUP_PATH.lstrip("/"))
for path, long_name in (
(DETECTOR_PIXEL_SIZE_X_PATH, "Detector pixel size in X"),
(DETECTOR_PIXEL_SIZE_Y_PATH, "Detector pixel size in Y"),
):
name = path.rsplit("/", 1)[-1]
if name in det_group:
del det_group[name]
ds = det_group.create_dataset(name, data=meters_per_pixel)
ds.attrs["units"] = "m"
ds.attrs["long_name"] = long_name
except Exception as exc:
log_print(f"Failed to update detector pixel size in {h5_path}: {exc}")
def _update_detector_geometry_h5(self, h5_path: Path, camera_length: float, correction_factor: float) -> None:
"""Persist detector geometry metadata when available."""
if camera_length is None or camera_length <= 0:
return
if correction_factor is None or correction_factor <= 0:
correction_factor = 1.0
if not h5_path.exists():
return
try:
import h5py
with h5py.File(h5_path, "r+") as f:
write_detector_geometry(f, camera_length, correction_factor)
except Exception as exc:
log_print(f"Failed to update detector geometry in {h5_path}: {exc}")
def _update_beam_energy_h5(self, h5_path: Path, acceleration_voltage_v: float) -> None:
"""Persist beam incident energy in NeXus paths when available."""
if acceleration_voltage_v is None or acceleration_voltage_v <= 0:
return
if not h5_path.exists():
return
incident_energy_ev = acceleration_voltage_v
try:
import h5py
with h5py.File(h5_path, "r+") as f:
ensure_nexus_parents(f)
beam_group = f.require_group(BEAM_GROUP_PATH.lstrip("/"))
beam_group.attrs["NX_class"] = "NXbeam"
if BEAM_INCIDENT_ENERGY_PATH.rsplit("/", 1)[-1] in beam_group:
del beam_group[BEAM_INCIDENT_ENERGY_PATH.rsplit("/", 1)[-1]]
ds = beam_group.create_dataset("incident_energy", data=incident_energy_ev)
ds.attrs["units"] = "eV"
ds.attrs["long_name"] = "Incident beam energy"
if BEAM_DIRECTION_PATH.rsplit("/", 1)[-1] in beam_group:
del beam_group[BEAM_DIRECTION_PATH.rsplit("/", 1)[-1]]
direction = np.array([0.0, 0.0, 1.0], dtype=np.float32)
ds = beam_group.create_dataset("beam_direction", data=direction)
ds.attrs["units"] = "1"
ds.attrs["long_name"] = "Beam direction in detector coordinates (+Z along beam)"
except Exception as exc:
log_print(f"Failed to update beam energy in {h5_path}: {exc}")
[docs]
def load_and_print_acquisition_details(self):
"""Populate widgets from the current INI and set read-only/editable states."""
config = configparser.ConfigParser()
try:
config.read(self.ini_path)
except Exception as e:
log_print(f"Failed to read INI file: {e}")
return
section_name = "AcquisitionDetails"
# Save detector_used for later
self.detector_used = config.get(section_name, "detector_used", fallback="")
# Populate each field from the INI or leave empty
if section_name in config:
for field, widget in self.field_widgets.items():
raw = config.get(section_name, field, fallback="")
self.original_values[field] = raw
if field == "acceleration_voltage" and raw:
try:
kv = float(raw) / 1000.0
display = format(kv, 'f').rstrip('0').rstrip('.')
except ValueError:
display = raw
widget.setText(display)
else:
widget.setText(raw)
# Ensure camera length correction factor exists in INI
raw_corr = config.get(section_name, "camera_length_correction", fallback="")
if raw_corr:
self.field_widgets["camera_length_correction"].setText(raw_corr)
else:
# default and write back
corr_val = "1"
self.field_widgets["camera_length_correction"].setText(corr_val)
config.set(section_name, "camera_length_correction", corr_val)
with open(self.ini_path, "w") as f:
config.write(f)
self.original_values["camera_length_correction"] = corr_val
# Compute binned resolution
try:
rw = float(self.field_widgets["resolution_width"].text())
rh = float(self.field_widgets["resolution_height"].text())
bw = float(self.field_widgets["binning_width"].text())
bh = float(self.field_widgets["binning_height"].text())
brw = rw / bw if bw else 0
brh = rh / bh if bh else 0
self.binned_resolution_widgets["width"].setText(str(int(brw)) if brw.is_integer() else format(brw,'f').rstrip('0').rstrip('.'))
self.binned_resolution_widgets["height"].setText(str(int(brh)) if brh.is_integer() else format(brh,'f').rstrip('0').rstrip('.'))
except ValueError:
self.binned_resolution_widgets["width"].clear()
self.binned_resolution_widgets["height"].clear()
# Grey out Calculate button if pixels_per_meter exists
ppm_val = self.field_widgets["pixels_per_meter"].text()
self.calculate_button.setEnabled(not bool(ppm_val))
# Make any empty metadata fields editable (except correction factor)
for key, widget in self.field_widgets.items():
if key == "camera_length_correction":
continue
if not widget.text().strip():
widget.setReadOnly(False)
else:
widget.setReadOnly(True)
else:
log_print(f"Section '{section_name}' not found in {self.ini_path}")
# Ensure Calculate is enabled if no metadata
self.calculate_button.setEnabled(True)
# If no AcquisitionDetails, make all fields editable
for key, widget in self.field_widgets.items():
if key == "camera_length_correction":
continue
widget.setReadOnly(False)
# Pixel mask: show/hide remove button if mask exists in HDF5
h5_path = self._resolve_h5_path_for_ini(self.ini_path)
try:
import h5py
with h5py.File(h5_path, 'r') as f:
has_mask = get_mask_dataset(f) is not None
except:
has_mask = False
self.remove_mask_btn.setVisible(has_mask)
self.fullmask_btn.setVisible(not has_mask)
def _downstream_processing_summary(self):
"""Return labels for processing results that may depend on metadata."""
h5_path = self._resolve_h5_path_for_ini(self.ini_path)
if not h5_path or not os.path.exists(h5_path):
return []
found = []
try:
import h5py
with h5py.File(h5_path, "r") as h5file:
checks = [
("peakfinding results", ("entry/data/peakXPosRaw", "entry/data/peakpos_x", "entry/data/peak_x")),
("beam-center results", ("entry/data/center_x", "entry/data/center_y", "entry/data/detector_shift")),
("indexing/integration results", ("entry/data/indexed", "entry/data/index_angles")),
]
for label, paths in checks:
if any(path in h5file for path in paths):
found.append(label)
process_root = h5file.get("entry/process")
if process_root is not None:
prefixes = {
"peakfinding_": "peakfinding process records",
"strip_": "frame-stripping process records",
"centerfinding_": "centerfinding process records",
"centerrefinement_": "center-refinement process records",
"indexing_": "indexing/integration process records",
"merging_": "merging process records",
}
for name in process_root:
for prefix, label in prefixes.items():
if name.startswith(prefix):
found.append(label)
break
except Exception as exc:
log_print(f"Failed to inspect downstream processing state: {exc}")
return sorted(set(found))
[docs]
def confirm_metadata_edit(self):
"""Warn before unlocking metadata fields that were populated automatically."""
downstream = self._downstream_processing_summary()
if downstream:
warning = (
"This file already has downstream processing results:\n\n"
+ "\n".join(f"- {item}" for item in downstream)
+ "\n\nChanging preflight metadata can make existing results inconsistent. "
"You may need to rerun later workflow steps after saving changes.\n\n"
"Do you want to unlock the metadata fields?"
)
else:
warning = (
"Changing preflight metadata can affect later workflow steps and derived "
"HDF5 metadata.\n\nDo you want to unlock the metadata fields?"
)
reply = QMessageBox.question(
self,
"Edit Preflight Metadata",
warning,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
for key, widget in self.field_widgets.items():
if key == "camera_length_correction":
continue
widget.setReadOnly(False)
self.calculate_button.setEnabled(True)
self.edit_metadata_btn.setEnabled(False)
self._log_to_ini(self.ini_path, "Preflight metadata fields unlocked for manual editing")
[docs]
def open_pixel_calc_dialog(self):
"""
Open the PixelCalcDialog and link its result back to the pixels_per_meter field.
"""
initial_bin = self.field_widgets.get("binning_width", QLineEdit()).text()
dlg = PixelCalcDialog(initial_bin_factor=initial_bin, initial_detector=self.detector_used, parent=self)
# update main field whenever dialog result changes
dlg.result_edit.textChanged.connect(
self.field_widgets["pixels_per_meter"].setText
)
dlg.exec()
def _update_binned_resolution(self):
"""
Recalculate and display binned resolution when resolution or bin factors change.
"""
try:
rw = float(self.field_widgets["resolution_width"].text())
rh = float(self.field_widgets["resolution_height"].text())
bw = float(self.field_widgets["binning_width"].text())
bh = float(self.field_widgets["binning_height"].text())
brw = rw / bw if bw else 0
brh = rh / bh if bh else 0
# Format without trailing zeros
brw_str = str(int(brw)) if brw.is_integer() else format(brw, 'f').rstrip('0').rstrip('.')
brh_str = str(int(brh)) if brh.is_integer() else format(brh, 'f').rstrip('0').rstrip('.')
self.binned_resolution_widgets["width"].setText(brw_str)
self.binned_resolution_widgets["height"].setText(brh_str)
except (ValueError, KeyError):
self.binned_resolution_widgets["width"].clear()
self.binned_resolution_widgets["height"].clear()
[docs]
def save_pixels_per_meter(self, value: str):
"""
Write the updated pixels_per_meter value back to the INI file.
"""
config = configparser.ConfigParser()
config.read(self.ini_path)
section_name = "AcquisitionDetails"
if not config.has_section(section_name):
config.add_section(section_name)
old = config.get(section_name, "pixels_per_meter", fallback=self.original_values.get("pixels_per_meter", ""))
# Format without scientific notation
sval = value
fval = None
try:
fval = float(value)
if fval.is_integer():
sval = str(int(fval))
else:
sval = format(fval, 'f').rstrip('0').rstrip('.')
except ValueError:
pass
config.set(section_name, "pixels_per_meter", sval)
try:
with open(self.ini_path, "w") as f:
config.write(f)
self._log_change(self.ini_path, "pixels_per_meter", old, sval)
self.original_values["pixels_per_meter"] = sval
if fval is not None:
_, _, _, _, _, h5file_path = config_to_paths(self.ini_path)
if h5file_path:
self._update_detector_pixel_size_h5(Path(h5file_path), fval)
except Exception as e:
log_print(f"Failed to write INI file: {e}")
[docs]
def save_acceleration_voltage(self, value: str):
"""
Convert back to volts and write acceleration_voltage to INI.
"""
config = configparser.ConfigParser()
config.read(self.ini_path)
section = "AcquisitionDetails"
if not config.has_section(section):
config.add_section(section)
old = config.get(section, "acceleration_voltage", fallback=self.original_values.get("acceleration_voltage", ""))
# Convert kV input back to volts
sval = value
try:
fkv = float(value)
fv = fkv * 1000.0
if fv.is_integer():
sval = str(int(fv))
else:
sval = format(fv, 'f').rstrip('0').rstrip('.')
except ValueError:
pass
config.set(section, "acceleration_voltage", sval)
try:
with open(self.ini_path, "w") as f:
config.write(f)
self._log_change(self.ini_path, "acceleration_voltage", old, sval)
self.original_values["acceleration_voltage"] = sval
try:
fv = float(sval)
except ValueError:
fv = None
if fv is not None:
_, _, _, _, _, h5file_path = config_to_paths(self.ini_path)
if h5file_path:
self._update_beam_energy_h5(Path(h5file_path), fv)
except Exception as e:
log_print(f"Failed to write INI file: {e}")
[docs]
def save_camera_length_correction(self, value: str):
"""
Write the updated camera_length_correction value back to the INI file.
"""
config = configparser.ConfigParser()
config.read(self.ini_path)
section = "AcquisitionDetails"
if not config.has_section(section):
config.add_section(section)
old = config.get(section, "camera_length_correction", fallback=self.original_values.get("camera_length_correction", ""))
config.set(section, "camera_length_correction", value)
try:
with open(self.ini_path, "w") as f:
config.write(f)
self._log_change(self.ini_path, "camera_length_correction", old, value)
self.original_values["camera_length_correction"] = value
try:
corr = float(value)
except ValueError:
corr = None
try:
camera_length = float(config.get(section, "camera_length"))
except Exception:
camera_length = None
if camera_length is not None and corr is not None:
_, _, _, _, _, h5file_path = config_to_paths(self.ini_path)
if h5file_path:
self._update_detector_geometry_h5(Path(h5file_path), camera_length, corr)
except Exception as e:
log_print(f"Failed to write INI file: {e}")
[docs]
def save_metadata_field(self, field: str, value: str):
"""
Write an arbitrary metadata field back to the INI file.
"""
config = configparser.ConfigParser()
config.read(self.ini_path)
section = "AcquisitionDetails"
if not config.has_section(section):
config.add_section(section)
old = config.get(section, field, fallback=self.original_values.get(field, ""))
config.set(section, field, value)
try:
with open(self.ini_path, "w") as f:
config.write(f)
self._log_change(self.ini_path, field, old, value)
self.original_values[field] = value
if field == "camera_length":
try:
camera_length = float(value)
except ValueError:
camera_length = None
try:
corr = float(config.get(section, "camera_length_correction", fallback="1"))
except ValueError:
corr = 1.0
if camera_length is not None:
_, _, _, _, _, h5file_path = config_to_paths(self.ini_path)
if h5file_path:
self._update_detector_geometry_h5(Path(h5file_path), camera_length, corr)
except Exception as e:
log_print(f"Failed to write INI file: {e}")
[docs]
def on_pixelmask_btn_clicked(self):
"""Load a mask image, generate mask data, and store it into the current HDF5."""
path, _ = QFileDialog.getOpenFileName(self, "Select Mask Image", "", "Images (*.bmp *.png *.jpg)")
if not path:
return
try:
_, mask = generate_mask(Path(path))
h5_path = self._resolve_h5_path_for_ini(self.ini_path)
if h5_path is None:
QMessageBox.warning(self, 'Error', "No HDF5 file could be resolved from this INI.")
return
save_mask_hdf5(mask, h5_path, dataset='mask')
# show remove button now that mask exists
self.remove_mask_btn.setVisible(True)
self._log_to_ini(self.ini_path, f"Saved mask to {h5_path}")
except Exception as e:
QMessageBox.warning(self, 'Error', str(e))
[docs]
def on_remove_mask_clicked(self):
"""Remove the mask dataset from the current HDF5 and update button visibility."""
try:
h5_path = self._resolve_h5_path_for_ini(self.ini_path)
if h5_path is None:
QMessageBox.warning(self, 'Error', "No HDF5 file could be resolved from this INI.")
return
remove_mask_hdf5(h5_path, dataset='mask')
# hide remove button after removal
self.remove_mask_btn.setVisible(False)
# re-add full mask button to layout after remove button
layout = self.remove_mask_btn.parent().layout()
layout.addWidget(self.fullmask_btn)
self.fullmask_btn.show()
# adjust container size
container = self.remove_mask_btn.parentWidget()
if container:
container.adjustSize()
self.adjustSize()
self._log_to_ini(self.ini_path, f"Removed mask from {h5_path}")
except Exception as e:
QMessageBox.warning(self, 'Error', str(e))
[docs]
def apply_to_workspace(self):
"""
Copy current metadata (AcquisitionDetails) and mask (if present) to all INIs in the workspace.
Requires confirmation that datasets were collected under the same conditions.
"""
if not self.main_window:
QMessageBox.information(self, "No Workspace", "No workspace is open in the main window.")
return
workspace_paths = []
wsp_dict = getattr(self.main_window, "workspace_ini_paths", None)
if wsp_dict:
workspace_paths.extend(list(wsp_dict.values()))
# Fallback: if workspace is empty, still allow applying to the current INI
if not workspace_paths and getattr(self.main_window, "full_ini_file_path", None):
workspace_paths.append(self.main_window.full_ini_file_path)
if not workspace_paths:
QMessageBox.information(self, "No Workspace Files", "No INI files are available to update.")
return
confirm = QMessageBox.question(
self,
"Apply to Workspace",
"Apply the current metadata and mask to all INI files in the workspace?\n\n"
"Continue only if all datasets were collected under the same conditions.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if confirm != QMessageBox.StandardButton.Yes:
return
# Load current AcquisitionDetails to propagate
source_cfg = configparser.ConfigParser()
source_cfg.read(self.ini_path)
if "AcquisitionDetails" not in source_cfg:
QMessageBox.warning(self, "Missing Metadata", "Current INI has no AcquisitionDetails section to copy.")
return
acq_items = dict(source_cfg.items("AcquisitionDetails"))
ppm_raw = acq_items.get("pixels_per_meter", "")
try:
ppm_value = float(ppm_raw)
except (TypeError, ValueError):
ppm_value = None
av_raw = acq_items.get("acceleration_voltage", "")
try:
av_value = float(av_raw)
except (TypeError, ValueError):
av_value = None
cl_raw = acq_items.get("camera_length", "")
try:
cl_value = float(cl_raw)
except (TypeError, ValueError):
cl_value = None
corr_raw = acq_items.get("camera_length_correction", "")
try:
corr_value = float(corr_raw)
except (TypeError, ValueError):
corr_value = 1.0
# Load mask (optional)
mask_data = None
try:
import h5py
h5_path = self._resolve_h5_path_for_ini(self.ini_path)
if h5_path is not None:
with h5py.File(h5_path, "r") as f:
mask_ds = get_mask_dataset(f)
if mask_ds is not None:
mask_data = mask_ds[:]
except Exception:
mask_data = None
applied = 0
mask_applied = 0
failures = []
for ini_path in workspace_paths:
try:
if not Path(ini_path).exists():
failures.append((ini_path, "INI file not found"))
continue
cfg = configparser.ConfigParser()
cfg.read(ini_path)
if not cfg.has_section("AcquisitionDetails"):
cfg.add_section("AcquisitionDetails")
for k, v in acq_items.items():
cfg.set("AcquisitionDetails", k, v)
with open(ini_path, "w") as f:
cfg.write(f)
applied += 1
target_h5 = self._resolve_h5_path_for_ini(ini_path)
if target_h5 is None:
failures.append((ini_path, "No HDF5 file could be resolved from INI Paths"))
continue
if mask_data is not None:
save_mask_hdf5(mask_data, target_h5, dataset="mask")
mask_applied += 1
if ppm_value is not None:
self._update_detector_pixel_size_h5(target_h5, ppm_value)
if av_value is not None:
self._update_beam_energy_h5(target_h5, av_value)
if cl_value is not None:
self._update_detector_geometry_h5(target_h5, cl_value, corr_value)
self._log_to_ini(ini_path, f"Applied AcquisitionDetails from {self.ini_path}")
if mask_data is not None:
self._log_to_ini(ini_path, f"Copied mask from {self.ini_path} to {target_h5}")
except Exception as e:
failures.append((ini_path, str(e)))
summary = [f"Updated metadata for {applied} workspace INI files."]
if mask_data is not None:
summary.append(f"Copied mask to {mask_applied} HDF5 files.")
if failures:
detail = "\n".join(f"- {p}: {err}" for p, err in failures)
QMessageBox.warning(self, "Apply to Workspace", "\n".join(summary + ["Failures:", detail]))
else:
QMessageBox.information(self, "Apply to Workspace", "\n".join(summary))
[docs]
def on_generate_full_mask_clicked(self):
"""
Generate a full-valid mask and save it to the HDF5, then show the remove button.
"""
try:
h5_path = self._resolve_h5_path_for_ini(self.ini_path)
if h5_path is None:
QMessageBox.warning(self, 'Error', "No HDF5 file could be resolved from this INI.")
return
generate_full_mask_hdf5(h5_path)
self.remove_mask_btn.setVisible(True)
# remove full mask button from layout
layout = self.fullmask_btn.parent().layout()
layout.removeWidget(self.fullmask_btn)
self.fullmask_btn.hide()
# adjust container size
container = self.fullmask_btn.parentWidget()
if container:
container.adjustSize()
self.adjustSize()
except Exception as e:
QMessageBox.warning(self, 'Error', str(e))
[docs]
def is_metadata_complete(self) -> bool:
"""
Returns True if all required metadata fields have values (non-empty).
"""
# List of keys that must be non-empty
required_fields = [
"acceleration_voltage",
"camera_length",
"camera_length_correction",
"resolution_width",
"resolution_height",
"binning_width",
"binning_height",
"pixels_per_meter"
]
for key in required_fields:
widget = self.field_widgets.get(key)
if widget is None or not widget.text().strip():
return False
return True
[docs]
class PixelCalcDialog(QDialog):
"""
Dialog to calculate pixels_per_meter based on detector or custom.
"""
def __init__(self, initial_bin_factor="", initial_detector=None, parent=None):
super().__init__(parent)
self.setWindowTitle("Calculate Pixels per Meter")
self.resize(350, 180)
layout = QFormLayout(self)
# Detector dropdown
self.detector_combo = QComboBox(self)
self.detector_combo.addItems([
"Dectris ELA",
"Dectris SINGLA",
"Gatan OneView",
"Thermo Fisher Ceta-D",
"Thermo Fisher Ceta-S",
"Timepix3/Medipix3",
"Other"
])
self.detector_combo.setToolTip(
"Select your detector model; presets the physical pixel size; choose 'Other' if your detector is not listed."
)
# Preselect detector based on INI value (default to Other if unspecified)
if initial_detector:
det_lower = initial_detector.lower()
if "ceta" in det_lower:
self.detector_combo.setCurrentText("Thermo Fisher Ceta-D")
elif "timepix3" in det_lower or "medipix3" in det_lower:
self.detector_combo.setCurrentText("Timepix3/Medipix3")
elif "gatan" in det_lower or "oneview" in det_lower:
self.detector_combo.setCurrentText("Gatan OneView")
elif "dectris ela" in det_lower:
self.detector_combo.setCurrentText("Dectris ELA")
elif "dectris singla" in det_lower:
self.detector_combo.setCurrentText("Dectris SINGLA")
else:
self.detector_combo.setCurrentText("Other")
else:
# No detector info: switch to Other
self.detector_combo.setCurrentText("Other")
layout.addRow("Detector:", self.detector_combo)
# Physical pixel size input
self.physical_pixel_size_edit = QLineEdit(self)
self.physical_pixel_size_edit.setPlaceholderText("")
self.physical_pixel_size_edit.setToolTip(
"Enter the physical pixel size of your detector in micrometers; will be preset for known detectors."
)
# Place unit to the right of the textbox
size_container = QWidget(self)
size_layout = QHBoxLayout(size_container)
size_layout.setContentsMargins(0, 0, 0, 0)
size_layout.addWidget(self.physical_pixel_size_edit)
size_layout.addWidget(QLabel("µm"))
layout.addRow("Physical Pixel Size:", size_container)
# Bin factor input
self.bin_factor_edit = QLineEdit(self)
self.bin_factor_edit.setPlaceholderText("e.g. 2")
self.bin_factor_edit.setText(initial_bin_factor)
self.bin_factor_edit.setToolTip(
"Enter the bin factor used during acquisition if missing"
)
layout.addRow("Bin Factor:", self.bin_factor_edit)
# Result display
self.result_edit = QLineEdit(self)
self.result_edit.setReadOnly(True)
self.result_edit.setToolTip(
"Calculated pixels per meter"
)
layout.addRow("Pixels per Meter:", self.result_edit)
# Connections
self.detector_combo.currentTextChanged.connect(self.on_detector_changed)
self.physical_pixel_size_edit.textChanged.connect(self.calculate)
self.bin_factor_edit.textChanged.connect(self.calculate)
# Initialize with defaults
self.on_detector_changed(self.detector_combo.currentText())
# Close button
save_btn = QPushButton("Close", self)
save_btn.clicked.connect(self.accept)
layout.addRow(save_btn)
[docs]
def on_detector_changed(self, text):
defaults = {
"Timepix3/Medipix3": "55",
"Thermo Fisher Ceta-D": "14",
"Thermo Fisher Ceta-S": "14",
"Gatan OneView": "15",
"Dectris ELA": "75",
"Dectris SINGLA": "75"
}
if text in defaults:
# preset and lock the size
self.physical_pixel_size_edit.setText(defaults[text])
self.physical_pixel_size_edit.setReadOnly(True)
else:
# custom entry
self.physical_pixel_size_edit.clear()
self.physical_pixel_size_edit.setReadOnly(False)
self.calculate()
[docs]
def calculate(self):
try:
size_um = float(self.physical_pixel_size_edit.text())
bin_factor = float(self.bin_factor_edit.text())
size_m = size_um * 1e-6
if size_m > 0 and bin_factor > 0:
ppm = 1.0 / (size_m * bin_factor)
# Use full decimal or integer representation
if ppm.is_integer():
text = str(int(ppm))
else:
# fixed-point decimal, no trailing zeros
text = format(ppm, 'f').rstrip('0').rstrip('.')
self.result_edit.setText(text)
# Live update main metadata window field
parent = self.parent()
if parent and hasattr(parent, 'field_widgets'):
parent.field_widgets["pixels_per_meter"].setText(text)
else:
self.result_edit.clear()
parent = self.parent()
if parent and hasattr(parent, 'field_widgets'):
parent.field_widgets["pixels_per_meter"].clear()
except ValueError:
self.result_edit.clear()
parent = self.parent()
if parent and hasattr(parent, 'field_widgets'):
parent.field_widgets["pixels_per_meter"].clear()