diff --git a/src/leaf_contour_efd/utils/paths.py b/src/leaf_contour_efd/utils/paths.py new file mode 100644 index 0000000..370d8f5 --- /dev/null +++ b/src/leaf_contour_efd/utils/paths.py @@ -0,0 +1,69 @@ +"""Path utilities for output/export directories.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +from appdirs import user_data_dir + +_APP_DIR_NAME = "leaf_contour_efd" + + +def _user_writable_base_dir() -> Path: + """Return a user-writable directory for application outputs.""" + return Path(user_data_dir(_APP_DIR_NAME)).resolve() + + +def _is_writable_dir(path: Path) -> bool: + """Return whether ``path`` can be used as a writable directory.""" + try: + path.mkdir(parents=True, exist_ok=True) + probe = path / ".write_test" + with probe.open("w", encoding="utf-8"): + pass + probe.unlink(missing_ok=True) + return True + except OSError: + return False + + +def get_output_base_dir() -> Path: + """Return the base directory used for writing the ``output`` folder. + + Rules + ----- + - Frozen on all platforms: user-writable application data directory. + - Non-frozen: repository/application root when writable, otherwise user data. + """ + fallback = _user_writable_base_dir() + + if getattr(sys, "frozen", False): + return fallback + + # Development mode: src/leaf_contour_efd/utils/paths.py -> repo root + repo_root = Path(__file__).resolve().parents[3] + if _is_writable_dir(repo_root): + return repo_root + return fallback + + +def get_output_dir(*parts: str) -> Path: + """Return an output subdirectory and ensure it exists. + + If directory creation fails in the primary base location, this function + transparently falls back to a user-writable directory. + """ + out_dir = get_output_base_dir() / "output" + if parts: + out_dir = out_dir.joinpath(*parts) + + try: + out_dir.mkdir(parents=True, exist_ok=True) + except OSError: + out_dir = _user_writable_base_dir() / "output" + if parts: + out_dir = out_dir.joinpath(*parts) + out_dir.mkdir(parents=True, exist_ok=True) + + return out_dir diff --git a/src/leaf_contour_efd/widgets/binarize_image.py b/src/leaf_contour_efd/widgets/binarize_image.py index b06a599..26afff9 100644 --- a/src/leaf_contour_efd/widgets/binarize_image.py +++ b/src/leaf_contour_efd/widgets/binarize_image.py @@ -26,6 +26,7 @@ from napari.layers import Image from napari.utils.colormaps import DirectLabelColormap from pathlib import Path +from leaf_contour_efd.utils.paths import get_output_dir def make_binarize_image_widget(viewer: "napari.Viewer"): @@ -223,7 +224,7 @@ def binarize_image( method: str = "Otsu", threshold: int = 128, save_check: bool = True, - save_dir: Path = Path("./output/binarized_images"), + save_dir: Path = get_output_dir("binarized_images"), ): """ Run the selected binarization method diff --git a/src/leaf_contour_efd/widgets/calculate_efd.py b/src/leaf_contour_efd/widgets/calculate_efd.py index 328dbfd..d627fd3 100644 --- a/src/leaf_contour_efd/widgets/calculate_efd.py +++ b/src/leaf_contour_efd/widgets/calculate_efd.py @@ -15,6 +15,7 @@ import pandas as pd from pathlib import Path import copy +from leaf_contour_efd.utils.paths import get_output_dir def close_contour(df_contour: pd.DataFrame) -> np.ndarray: @@ -391,10 +392,8 @@ def calculate_efd_and_save(payload) -> None: # fallback to plain string leaf_id = str(leaf_id) - output_dir_ef = Path("output/coefficients_efd") - output_dir_ef_normalized = Path("output/coefficients_efd_normalized") - output_dir_ef.mkdir(parents=True, exist_ok=True) - output_dir_ef_normalized.mkdir(parents=True, exist_ok=True) + output_dir_ef = get_output_dir("coefficients_efd") + output_dir_ef_normalized = get_output_dir("coefficients_efd_normalized") ef_file = output_dir_ef / f"{id}_{leaf_id}.csv" ef_normalized_file = output_dir_ef_normalized / f"{id}_{leaf_id}.csv" df_ef.to_csv(ef_file, index=False) diff --git a/src/leaf_contour_efd/widgets/clear_viewer.py b/src/leaf_contour_efd/widgets/clear_viewer.py index 6d8f1a3..be522cd 100644 --- a/src/leaf_contour_efd/widgets/clear_viewer.py +++ b/src/leaf_contour_efd/widgets/clear_viewer.py @@ -24,6 +24,7 @@ from typing import Sequence from qtpy.QtCore import QTimer +from leaf_contour_efd.utils.paths import get_output_dir def _clear_except_async(viewer, keep_names: set[str], on_done=None): @@ -151,7 +152,7 @@ def _get_base_image_id() -> str: # ---- create FileEdit with dynamic default path ---- default_image_id = _get_base_image_id() - default_dir = Path("output/rois") + default_dir = get_output_dir("rois") # default_dir.mkdir(parents=True, exist_ok=True) default_path = default_dir / f"{default_image_id}.png" diff --git a/src/leaf_contour_efd/widgets/crop_rectangle.py b/src/leaf_contour_efd/widgets/crop_rectangle.py index ad68591..35058cc 100644 --- a/src/leaf_contour_efd/widgets/crop_rectangle.py +++ b/src/leaf_contour_efd/widgets/crop_rectangle.py @@ -26,6 +26,7 @@ from pathlib import Path import napari from qtpy.QtCore import QTimer +from leaf_contour_efd.utils.paths import get_output_dir COLOR_CYCLE = ["#FF4B00", "#005AFF"] # base(orange), tip(blue) LABELS = ("base", "tip") @@ -243,7 +244,7 @@ def _apply(): def add_roi( roi_index: int = 1, save_cropped: bool = True, - save_dir: str = "output/cropped_images", + save_dir: Path = get_output_dir("cropped_images"), ): """Add a cropped ROI image and its landmark layer.""" ROIs_layer = viewer.layers["ROIs"] diff --git a/src/leaf_contour_efd/widgets/extract_contour.py b/src/leaf_contour_efd/widgets/extract_contour.py index c86a02f..cfdc3b5 100644 --- a/src/leaf_contour_efd/widgets/extract_contour.py +++ b/src/leaf_contour_efd/widgets/extract_contour.py @@ -29,6 +29,7 @@ from datetime import datetime from napari.utils.colormaps import DirectLabelColormap from qtpy.QtCore import QTimer +from leaf_contour_efd.utils.paths import get_output_dir def make_extract_contour_widget(viewer: "napari.Viewer"): @@ -76,9 +77,9 @@ def make_extract_contour_widget(viewer: "napari.Viewer"): call_button="Extract Contour", ) def extract_contour( - folder_csv=Path("./output/contour"), - folder_final_mask=Path("./output/binarized_image_final"), - folder_blob_mask=Path("./output/contour_image"), + folder_csv=Path(get_output_dir("contour")), + folder_final_mask=Path(get_output_dir("binarized_image_final")), + folder_blob_mask=Path(get_output_dir("contour_image")), save_final_mask=True, save_blob_mask=True, ): diff --git a/src/leaf_contour_efd/widgets/rotate_image.py b/src/leaf_contour_efd/widgets/rotate_image.py index be8b2ff..608eed1 100644 --- a/src/leaf_contour_efd/widgets/rotate_image.py +++ b/src/leaf_contour_efd/widgets/rotate_image.py @@ -27,6 +27,7 @@ from pathlib import Path from numbers import Integral import copy +from leaf_contour_efd.utils.paths import get_output_dir # ------------------------------------------------------------------------- @@ -289,7 +290,7 @@ def make_points_metadata_widget( def points_metadata_widget( viewer: "napari.Viewer", save_rotated: bool = True, - save_dir: str = "output/rotated_images", + save_dir: Path = get_output_dir("rotated_images"), ) -> dict[str, Any]: """Rotate the image corresponding to the current Points layer.""" try: