Skip to content
Open
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
83 changes: 82 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,79 @@
from tabs.dataset_viewer import dataset_viewer_tab
from tabs.inference import inference_tab
from tabs.evaluator import evaluator_tab
from perceptionmetrics.utils.gui import browse_folder
from perceptionmetrics.utils.gui import browse_folder, browse_file


# ---------------------------------------------------------------------------
# Sidebar helpers
# ---------------------------------------------------------------------------

def _browse_img_dir():
folder = browse_folder()
if folder:
st.session_state.manual_img_dir = folder


def _browse_ann_file():
fpath = browse_file(filetypes=[".json"])
if fpath:
st.session_state.manual_ann_file = fpath


def _browse_manual_dataset_dir():
folder = browse_folder()
if folder:
st.session_state.manual_dataset_dir = folder


_MANUAL_PATH_KEYS = (
"manual_img_dir",
"manual_ann_file",
"manual_dataset_dir",
)


def _render_manual_override_section(dataset_type: str):
"""Render the 'Use manual paths' checkbox and its dataset-specific inputs.

For COCO: image directory + annotation JSON file.
For YOLO: dataset root directory + data.yaml file.
Clears all manual-path session keys when the checkbox is off.
"""
st.checkbox("Use manual paths", key="manual_paths_enabled")

if not st.session_state.get("manual_paths_enabled", False):
for k in _MANUAL_PATH_KEYS:
st.session_state[k] = ""
return

_spacer = "<div style='margin-bottom: 1.75rem;'></div>"

if dataset_type == "COCO":
col1, col2 = st.columns([3, 1])
with col1:
st.text_input("Image Directory", key="manual_img_dir")
with col2:
st.markdown(_spacer, unsafe_allow_html=True)
st.button("Browse", on_click=_browse_img_dir,
key="browse_manual_img_dir")

col1, col2 = st.columns([3, 1])
with col1:
st.text_input("Annotation File (.json)", key="manual_ann_file")
with col2:
st.markdown(_spacer, unsafe_allow_html=True)
st.button("Browse", on_click=_browse_ann_file,
key="browse_manual_ann_file")

elif dataset_type == "YOLO":
col1, col2 = st.columns([3, 1])
with col1:
st.text_input("Dataset Root Directory", key="manual_dataset_dir")
with col2:
st.markdown(_spacer, unsafe_allow_html=True)
st.button("Browse", on_click=_browse_manual_dataset_dir,
key="browse_manual_dataset_dir")


def browse_dataset_path():
Expand Down Expand Up @@ -30,6 +102,10 @@ def browse_dataset_path():
st.session_state.setdefault("evaluation_step", 5)
st.session_state.setdefault("detection_model", None)
st.session_state.setdefault("detection_model_loaded", False)
st.session_state.setdefault("manual_paths_enabled", False)
st.session_state.setdefault("manual_img_dir", "")
st.session_state.setdefault("manual_ann_file", "")
st.session_state.setdefault("manual_dataset_dir", "")

# Sidebar: Dataset Inputs
with st.sidebar:
Expand Down Expand Up @@ -68,6 +144,11 @@ def browse_dataset_path():
help="Upload a YAML dataset configuration file.",
)

# Manual path override — COCO and YOLO
_render_manual_override_section(
st.session_state.get("dataset_type", "COCO")
)

with st.expander("Model Inputs", expanded=False):
st.file_uploader(
"Model File (.pt, .onnx, .h5, .pb, .pth, .torchscript)",
Expand Down
71 changes: 69 additions & 2 deletions perceptionmetrics/datasets/yolo.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,50 @@
from perceptionmetrics.utils import io as uio


def find_yaml_and_dataset_dir(dataset_path: str, split: str) -> Tuple[str, str]:
"""
Find a YAML config file and validate the dataset root for a YOLO dataset.

Searches for any ``*.yaml`` / ``*.yml`` file in *dataset_path*. Accepts
any filename (e.g. ``data.yaml``, ``coco128.yaml``) so the function works
with datasets that use non-standard YAML names.

:param dataset_path: Root of the YOLO dataset (contains a *.yaml, images/, labels/)
:type dataset_path: str
:param split: Dataset split name (e.g., "train", "val", "test") — used only
to surface a clearer error when the YAML lacks that split key.
:type split: str
:return: Tuple of (yaml_path, dataset_path)
:rtype: Tuple[str, str]
:raises FileNotFoundError: If no YAML file exists in *dataset_path*, or if
the requested split key is missing/null in the YAML.
"""
if not os.path.isdir(dataset_path):
raise FileNotFoundError(f"Dataset root not found: {dataset_path}")

# Accept any .yaml / .yml in the root — prefer data.yaml if present
yaml_candidates = glob(os.path.join(dataset_path, "*.yaml")) + glob(
os.path.join(dataset_path, "*.yml")
)
if not yaml_candidates:
raise FileNotFoundError(
f"No YAML config file found in {dataset_path}. "
"Expected a *.yaml or *.yml file at the dataset root."
)
# Prefer data.yaml; fall back to the first match
preferred = os.path.join(dataset_path, "data.yaml")
yaml_path = preferred if preferred in yaml_candidates else yaml_candidates[0]

dataset_info = uio.read_yaml(yaml_path)
split_path = dataset_info.get(split)
if not split_path:
raise FileNotFoundError(
f"Split '{split}' is missing or null in {yaml_path}."
)

return yaml_path, dataset_path


def build_dataset(
dataset_fname: str, dataset_dir: Optional[str] = None, im_ext: str = "jpg"
) -> Tuple[pd.DataFrame, dict, str]:
Expand Down Expand Up @@ -57,8 +101,31 @@ def build_dataset(
dataset_fname,
)
continue
images_dir = os.path.join(dataset_dir, split_path)
labels_dir = os.path.join(dataset_dir, split_path.replace("images", "labels"))

# Resolve images_dir robustly:
# The YAML's split_path may be an absolute path originating from a
# different machine (e.g. a Colab path like /content/.../images/train).
# When os.path.join(dataset_dir, split_path) would resolve to a
# non-existent directory, fall back to the canonical local layout:
# <dataset_dir>/images/<split> and <dataset_dir>/labels/<split>.
candidate_images = os.path.join(dataset_dir, split_path)
if os.path.isabs(split_path) or not os.path.isdir(candidate_images):
images_dir = os.path.join(dataset_dir, "images", split)
labels_dir = os.path.join(dataset_dir, "labels", split)
if not os.path.isdir(images_dir):
logging.warning(
"Image directory for split '%s' not found at '%s' or '%s'; skipping.",
split,
candidate_images,
images_dir,
)
continue
else:
images_dir = candidate_images
labels_dir = os.path.join(
dataset_dir, split_path.replace("images", "labels")
)

for label_fname in glob(os.path.join(labels_dir, "*.txt")):
label_basename = os.path.basename(label_fname)
image_basename = label_basename.replace(".txt", f".{im_ext}")
Expand Down
85 changes: 85 additions & 0 deletions perceptionmetrics/utils/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,90 @@ def browse_folder():
except (FileNotFoundError, Exception):
continue
return None
except Exception:
return None


def browse_file(filetypes=None):
"""
Opens a native file selection dialog and returns the selected file path.
Works on Windows, macOS, and Linux (with zenity or kdialog).

:param filetypes: List of file extensions to filter (e.g. [".json", ".yaml"]).
Pass None or empty list to allow all files.
:type filetypes: list[str] | None
:return: Selected file path or None if cancelled.
:rtype: str | None
"""
try:
is_windows = sys.platform.startswith("win")
is_wsl_env = is_wsl()
if is_windows or is_wsl_env:
# Build a PowerShell filter string like "JSON files (*.json)|*.json|All files (*.*)|*.*"
if filetypes:
parts = []
for ext in filetypes:
ext_clean = ext.lstrip(".")
parts.append(f"{ext_clean.upper()} files (*.{ext_clean})|*.{ext_clean}")
parts.append("All files (*.*)|*.*")
filter_str = "|".join(parts)
else:
filter_str = "All files (*.*)|*.*"

script = (
"Add-Type -AssemblyName System.windows.forms;"
"$f=New-Object System.Windows.Forms.OpenFileDialog;"
f'$f.Filter="{filter_str}";'
'if($f.ShowDialog() -eq "OK"){Write-Output $f.FileName}'
)
result = subprocess.run(
["powershell.exe", "-NoProfile", "-Command", script],
capture_output=True,
text=True,
timeout=30,
)
fpath = result.stdout.strip()
if fpath and is_wsl_env:
result = subprocess.run(
["wslpath", "-u", fpath],
capture_output=True,
text=True,
timeout=30,
)
fpath = result.stdout.strip()
return fpath if fpath else None
elif sys.platform == "darwin":
if filetypes:
type_list = ", ".join(f'"{e.lstrip(".")}"' for e in filetypes)
script = f'POSIX path of (choose file with prompt "Select file:" of type {{{type_list}}})'
else:
script = 'POSIX path of (choose file with prompt "Select file:")'
result = subprocess.run(
["osascript", "-e", script], capture_output=True, text=True, timeout=30
)
fpath = result.stdout.strip()
return fpath if fpath else None
else:
# Linux: try zenity, then kdialog
for tool in ["zenity", "kdialog"]:
try:
if tool == "zenity":
cmd = ["zenity", "--file-selection", "--title=Select file"]
if filetypes:
for ext in filetypes:
cmd += ["--file-filter", f"*{ext}"]
else:
cmd = ["kdialog", "--getopenfilename", "--title", "Select file"]
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=30
)
if result.returncode in (0, 1):
fpath = result.stdout.strip()
return fpath if fpath else None
except subprocess.TimeoutExpired:
return None
except (FileNotFoundError, Exception):
continue
return None
except Exception:
return None
Loading