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()