Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions probeflow/analysis/grains.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,12 @@ def measure_periodicity(
freq_m = math.sqrt(freq_m_x**2 + freq_m_y**2)
period_m = 1.0 / freq_m if freq_m > 0 else 0.0

angle_deg = math.degrees(math.atan2(fy * pixel_size_y_m,
fx * pixel_size_x_m))
# Orientation belongs to the physical reciprocal vector. Multiplying
# the pixel frequencies by pixel size (the old implementation) applies
# anisotropy in the wrong direction and effectively squares the aspect
# ratio error. The period above already uses these calibrated
# components, so derive the angle from the same vector.
angle_deg = math.degrees(math.atan2(freq_m_y, freq_m_x))

results.append({
'period_m': period_m,
Expand Down
27 changes: 23 additions & 4 deletions probeflow/cli/commands/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
_Op,
_parse_processing_steps,
_processing_state_from_ops,
_record_op,
_write_output,
)

Expand All @@ -40,9 +39,19 @@ def _cmd_pipeline(args) -> int:
if args.plane >= scan.n_planes:
log.error("Plane %d not present (file has %d)", args.plane, scan.n_planes)
return 1
for op in ops:
scan.planes[args.plane] = op(scan.planes[args.plane])
_record_op(scan, op.name, op.params)
state = _processing_state_from_ops(ops)
if state.steps:
from probeflow.processing.state import apply_processing_state_with_calibration

scan.planes[args.plane], new_range = apply_processing_state_with_calibration(
scan.planes[args.plane],
state,
scan_range_m=scan.scan_range_m,
strict=True,
)
if new_range is not None:
scan.scan_range_m = new_range
scan.record_processing_state(state)
try:
_write_output(args, scan, default_suffix=".sxm")
except Exception as exc:
Expand Down Expand Up @@ -104,6 +113,7 @@ def _cmd_prepare_png(args) -> int:
mask_set = None
new_plane, new_range = apply_processing_state_with_calibration(
plane, state, roi_set, mask_set=mask_set, scan_range_m=raw_range,
strict=True,
)
scan.planes[args.plane] = new_plane
if new_range is not None:
Expand Down Expand Up @@ -212,7 +222,11 @@ def _cmd_plane_bg(args) -> int:

params_hist: dict = {"order": args.order}
if args.step_tolerance:
step_threshold_deg = float(getattr(args, "step_threshold_deg", 3.0))
params_hist["step_tolerance"] = True
params_hist["step_threshold_deg"] = step_threshold_deg
else:
step_threshold_deg = 3.0
if fit_roi is not None:
params_hist["fit_roi"] = getattr(fit_roi, "name", "inline")
if apply_roi is not None:
Expand All @@ -221,13 +235,18 @@ def _cmd_plane_bg(args) -> int:
params_hist["exclude_roi"] = getattr(exclude_roi, "name", "inline")

def _bg_op(a: np.ndarray) -> np.ndarray:
from probeflow.cli.processing_ops import _pixel_sizes_m_from_scan
psx, psy = _pixel_sizes_m_from_scan(scan)
return _proc.subtract_background(
a,
order=args.order,
fit_roi=fit_roi,
apply_roi=apply_roi,
exclude_roi=exclude_roi,
step_tolerance=args.step_tolerance,
step_threshold_deg=step_threshold_deg,
pixel_size_x_m=psx,
pixel_size_y_m=psy,
)

op = _Op("plane_bg", params_hist, fn=_bg_op)
Expand Down
2 changes: 2 additions & 0 deletions probeflow/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ def _build_parser() -> argparse.ArgumentParser:
help="Polynomial order (1=plane, 2=quadratic, 3=cubic, 4=quartic)")
plane_bg.add_argument("--step-tolerance", action="store_true",
help="Exclude step-edge pixels from the polynomial fit")
plane_bg.add_argument("--step-threshold-deg", type=float, default=3.0,
help="Maximum physical surface slope retained by --step-tolerance (degrees)")
plane_bg.add_argument("--fit-roi", type=str, default=None, metavar="NAME_OR_ID",
help="Fit background to pixels within this persisted ROI only")
plane_bg.add_argument("--fit-roi-rect", type=float, nargs=4,
Expand Down
16 changes: 14 additions & 2 deletions probeflow/cli/processing_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,22 @@ def _apply_to_plane(
f"Plane {plane_idx} not present — file has {scan.n_planes} plane(s)"
)
old_shape = scan.planes[plane_idx].shape
scan.planes[plane_idx] = op(scan.planes[plane_idx])
if isinstance(op, _Op) and op.state is not None:
from probeflow.processing.state import apply_processing_state_with_calibration

scan.planes[plane_idx], new_range = apply_processing_state_with_calibration(
scan.planes[plane_idx],
op.state,
scan_range_m=scan.scan_range_m,
strict=True,
)
if new_range is not None:
scan.scan_range_m = new_range
else:
scan.planes[plane_idx] = op(scan.planes[plane_idx])
new_shape = scan.planes[plane_idx].shape
if isinstance(op, _Op):
if new_shape != old_shape:
if op.state is None and new_shape != old_shape:
new_range = _update_scan_range_for_op(
op.name, scan.scan_range_m, old_shape, new_shape,
)
Expand Down
10 changes: 9 additions & 1 deletion probeflow/core/file_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@
})


def has_supported_suffix(path) -> bool:
"""Return whether *path* can contain a currently supported probe file."""
try:
return Path(path).suffix.lower() in _SNIFF_SUFFIXES
except (TypeError, ValueError):
return False


class FileType(Enum):
CREATEC_IMAGE = "createc_image"
CREATEC_SPEC = "createc_spec"
Expand Down Expand Up @@ -67,7 +75,7 @@ def sniff_file_type(path) -> FileType:
"""
try:
p = Path(path)
if p.suffix.lower() not in _SNIFF_SUFFIXES:
if not has_supported_suffix(p):
return FileType.UNKNOWN
with p.open("rb") as fh:
head = fh.read(_SNIFF_BYTES)
Expand Down
6 changes: 5 additions & 1 deletion probeflow/core/indexing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from probeflow.core.browse_filters import FolderFilterState, scan_matches_folder_filters
from probeflow.core.common import _f
from probeflow.core.file_type import FileType, sniff_file_type
from probeflow.core.file_type import FileType, has_supported_suffix, sniff_file_type

# Folder names to always skip when walking.
_SKIP_DIRS: frozenset[str] = frozenset({
Expand Down Expand Up @@ -361,6 +361,8 @@ def _peek_subfolder(
except OSError:
continue
if is_file:
if not has_supported_suffix(e.name):
continue
if files_examined >= max_files:
capped = True
break
Expand Down Expand Up @@ -421,6 +423,8 @@ def subfolder_matches_filters(
continue
if not is_file:
continue
if not has_supported_suffix(entry.name):
continue
if files_examined >= max_files:
return False
files_examined += 1
Expand Down
13 changes: 6 additions & 7 deletions probeflow/core/scan_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,12 @@ def processing_history(self) -> list[dict]:

@processing_history.setter
def processing_history(self, entries: list[dict] | None) -> None:
from probeflow.processing.history import processing_state_from_history
self._processing_state = processing_state_from_history(entries)
self._processing_history_timestamps = [
entry.get("timestamp") if isinstance(entry, dict) else None
for entry in (entries or [])
if isinstance(entry, dict) and entry.get("op")
]
from probeflow.processing.history import (
processing_state_and_timestamps_from_history,
)
state, timestamps = processing_state_and_timestamps_from_history(entries)
self._processing_state = state
self._processing_history_timestamps = timestamps

@property
def n_planes(self) -> int:
Expand Down
9 changes: 7 additions & 2 deletions probeflow/core/source_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@ def build_source_identity(
"item_type": item_type,
"file_size_bytes": int(stat.st_size),
"mtime_ns": int(stat.st_mtime_ns),
"sha256": _sha256_file(p),
"sha256": sha256_file(p),
"data_offset": int(data_offset) if data_offset is not None else None,
}


def _sha256_file(path: Path) -> str:
def sha256_file(path: Path) -> str:
"""Return the hexadecimal SHA-256 digest of a file's bytes."""
h = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()


# Backward-compatible private spelling retained for existing internal callers.
_sha256_file = sha256_file
25 changes: 23 additions & 2 deletions probeflow/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,7 @@ def _create_tv_window(self) -> WorkspaceWindow:
self._tv_sidebar = TVSidebar(t)
self._tv_pool = QThreadPool.globalInstance()
self._tv_signals = _TVWorkerSignals()
self._tv_run_generation = 0
self._tv_signals.finished.connect(self._on_tv_finished)
self._tv_sidebar.load_from_browse_requested.connect(
self._on_tv_load_from_browse)
Expand Down Expand Up @@ -1205,6 +1206,10 @@ def _on_tv_load_from_browse(self):
except Exception as exc:
self._tv_sidebar.set_status(f"Could not read scan: {exc}")
return
# Invalidate any worker that is still finishing the previously loaded
# image. Its result must never be attached to this entry.
self._tv_run_generation = getattr(self, "_tv_run_generation", 0) + 1
self._tv_sidebar.set_running(False)
self._tv_panel.load_entry(entry, plane_idx, arr, px_m)
self._tv_sidebar.set_status(
f"Loaded {entry.stem} (plane {plane_idx}). Adjust parameters and Run.")
Expand All @@ -1215,12 +1220,28 @@ def _on_tv_run(self):
self._tv_sidebar.set_status("Load a scan first.")
return
params = self._tv_sidebar.params()
self._tv_run_generation = getattr(self, "_tv_run_generation", 0) + 1
run_identity = (
self._tv_run_generation,
id(arr),
self._tv_panel.current_plane_idx(),
)
self._tv_sidebar.set_running(True)
self._tv_sidebar.set_status(f"Running TV-denoise ({params['method']})…")
worker = _TVWorker(arr, params, self._tv_signals)
worker = _TVWorker(arr, params, self._tv_signals, run_identity)
self._tv_pool.start(worker)

def _on_tv_finished(self, result, error: str):
def _on_tv_finished(self, result, error: str, run_identity=None):
current_arr = self._tv_panel.current_array()
expected = (
getattr(self, "_tv_run_generation", 0),
id(current_arr),
self._tv_panel.current_plane_idx(),
)
if run_identity != expected:
# A different entry/plane was loaded, or a newer run was started.
# Ignore this completion without changing the current run's UI.
return
self._tv_sidebar.set_running(False)
if error:
self._tv_sidebar.set_status(f"TV-denoise failed: {error}")
Expand Down
11 changes: 10 additions & 1 deletion probeflow/gui/dialogs/image_arithmetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,14 +482,23 @@ def _image_params(self) -> dict[str, Any]:
entry = self._selected_entry()
if entry is None:
raise ValueError("No source image selected.")
source_path = Path(entry.path).resolve()
from probeflow.core.source_identity import sha256_file

stat = source_path.stat()
plane_idx = int(self._plane_combo.currentData() or 0)
return {
"operation": self._operation(),
"operand_type": "image",
"source_path": str(Path(entry.path).resolve()),
"source_path": str(source_path),
"source_label": str(getattr(entry, "stem", Path(entry.path).stem)),
"plane_idx": plane_idx,
"plane_label": self._plane_combo.currentText(),
"source_fingerprint": {
"file_size_bytes": int(stat.st_size),
"mtime_ns": int(stat.st_mtime_ns),
"sha256": sha256_file(source_path),
},
}

def _generated_params(self) -> dict[str, Any]:
Expand Down
15 changes: 10 additions & 5 deletions probeflow/gui/roi_manager_dock.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ def _on_invert(self) -> None:
if roi_id is None or roi_set is None:
return
roi = roi_set.get(roi_id)
if roi is None:
if roi is None or roi.kind not in AREA_ROI_KINDS:
return
get_shape = self._cb.get("get_image_shape")
image_shape = get_shape() if get_shape else None
Expand All @@ -212,7 +212,7 @@ def _on_combine(self) -> None:
return
rois = [roi_set.get(rid) for rid in roi_ids]
rois = [r for r in rois if r is not None]
if len(rois) < 2:
if len(rois) < 2 or any(r.kind not in AREA_ROI_KINDS for r in rois):
return
mode = self._combine_mode.currentText()
from probeflow.core import roi as _roi_module
Expand All @@ -224,11 +224,16 @@ def _on_combine(self) -> None:
def _on_item_selection_changed(self) -> None:
ids = self._selected_roi_ids()
n = len(ids)
roi_set = self._roi_set_getter()
selected = [roi_set.get(rid) for rid in ids] if roi_set is not None else []
all_area = len(selected) == n and all(
roi is not None and roi.kind in AREA_ROI_KINDS for roi in selected
)
self._rename_btn.setEnabled(n == 1)
self._delete_btn.setEnabled(n >= 1)
self._active_btn.setEnabled(n == 1)
self._invert_btn.setEnabled(n == 1)
self._combine_btn.setEnabled(n >= 2)
self._invert_btn.setEnabled(n == 1 and all_area)
self._combine_btn.setEnabled(n >= 2 and all_area)
self._cb.get("on_roi_selection_changed", lambda: None)()

# ── context menu ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -260,7 +265,7 @@ def _show_context_menu(self, pos) -> None:
set_active_act.triggered.connect(self._on_set_active)

invert_act = menu.addAction("Invert")
invert_act.setEnabled(roi is not None)
invert_act.setEnabled(is_area)
invert_act.triggered.connect(self._on_invert)

menu.addSeparator()
Expand Down
9 changes: 5 additions & 4 deletions probeflow/gui/tv.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,28 @@ def _sep() -> QFrame:


class _TVWorkerSignals(QObject):
finished = Signal(object, str) # denoised-or-None, error-or-""
finished = Signal(object, str, object) # result, error, run identity


class _TVWorker(QRunnable):
"""Run tv_denoise off the GUI thread."""

def __init__(self, arr: np.ndarray, params: dict,
signals: _TVWorkerSignals):
signals: _TVWorkerSignals, run_identity=None):
super().__init__()
self._arr = arr
self._params = params
self._signals = signals
self._run_identity = run_identity

@Slot()
def run(self):
try:
from probeflow.processing import tv_denoise
out = tv_denoise(self._arr, **self._params)
self._signals.finished.emit(out, "")
self._signals.finished.emit(out, "", self._run_identity)
except Exception as exc:
self._signals.finished.emit(None, str(exc))
self._signals.finished.emit(None, str(exc), self._run_identity)


class TVPanel(QWidget):
Expand Down
Loading
Loading