diff --git a/pyproject.toml b/pyproject.toml index a89c732..34a8b75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ OpenPTV = "http://www.openptv.net" [project.scripts] pyptv = "pyptv.pyptv_gui:main" +pyptv-tk = "pyptv.tk_gui.main:main" [tool.setuptools] packages = ["pyptv"] diff --git a/pyptv/tk_gui/__init__.py b/pyptv/tk_gui/__init__.py new file mode 100644 index 0000000..b647010 --- /dev/null +++ b/pyptv/tk_gui/__init__.py @@ -0,0 +1,7 @@ +""" +Tk/ttk-based GUI components for PyPTV. + +This package provides a dependency-light alternative to the legacy TraitsUI/Chaco +interfaces. It integrates with Experiment and ParameterManager to edit YAML +parameters and manage parameter sets. +""" \ No newline at end of file diff --git a/pyptv/tk_gui/main.py b/pyptv/tk_gui/main.py new file mode 100644 index 0000000..0e8139f --- /dev/null +++ b/pyptv/tk_gui/main.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +A minimal Tk/ttk main window wired to the Experiment/ParameterManager model. + +Goals: +- No TraitsUI/Enable/Chaco dependencies +- Demonstrate core flows: open experiment dir/YAML, list paramsets, set active, + edit a few core parameters, save back to YAML +""" + +import sys +from pathlib import Path +import tkinter as tk +from tkinter import ttk, filedialog, messagebox + +from pyptv.experiment import Experiment +from pyptv.parameter_manager import ParameterManager + + +class TkMainApp(ttk.Frame): + def __init__(self, master=None, experiment: Experiment | None = None): + super().__init__(master) + self.master = master + self.master.title("PyPTV (Tk/ttk)") + self.pack(fill="both", expand=True) + + self.experiment = experiment or Experiment() + + # Top bar: open, save, active paramset + toolbar = ttk.Frame(self) + toolbar.pack(side="top", fill="x", padx=6, pady=6) + + self.btn_open = ttk.Button(toolbar, text="Open", command=self.on_open) + self.btn_open.pack(side="left") + + self.btn_save = ttk.Button(toolbar, text="Save", command=self.on_save) + self.btn_save.pack(side="left", padx=(6, 0)) + + ttk.Label(toolbar, text="Active:").pack(side="left", padx=(12, 4)) + self.active_var = tk.StringVar() + self.combo_active = ttk.Combobox(toolbar, textvariable=self.active_var, state="readonly") + self.combo_active.pack(side="left", fill="x", expand=True) + self.combo_active.bind("<>", self.on_change_active) + + # Main split: paramsets list and editor panel + main = ttk.Panedwindow(self, orient="horizontal") + main.pack(fill="both", expand=True, padx=6, pady=6) + + left = ttk.Frame(main) + right = ttk.Frame(main) + main.add(left, weight=1) + main.add(right, weight=3) + + # Paramsets tree + self.tree = ttk.Treeview(left, show="tree") + self.tree.pack(fill="both", expand=True) + self.tree.bind("<>", self.on_tree_select) + + # Simple editor for a subset of PTV parameters + form = ttk.Frame(right) + form.pack(fill="both", expand=True) + + row = 0 + ttk.Label(form, text="num_cams").grid(row=row, column=0, sticky="e", padx=4, pady=4) + self.num_cams_var = tk.IntVar(value=0) + ttk.Entry(form, textvariable=self.num_cams_var, width=8).grid(row=row, column=1, sticky="w") + + row += 1 + ttk.Label(form, text="imx").grid(row=row, column=0, sticky="e", padx=4, pady=4) + self.imx_var = tk.IntVar(value=0) + ttk.Entry(form, textvariable=self.imx_var, width=8).grid(row=row, column=1, sticky="w") + + row += 1 + ttk.Label(form, text="imy").grid(row=row, column=0, sticky="e", padx=4, pady=4) + self.imy_var = tk.IntVar(value=0) + ttk.Entry(form, textvariable=self.imy_var, width=8).grid(row=row, column=1, sticky="w") + + row += 1 + ttk.Label(form, text="splitter").grid(row=row, column=0, sticky="e", padx=4, pady=4) + self.splitter_var = tk.BooleanVar(value=False) + ttk.Checkbutton(form, variable=self.splitter_var).grid(row=row, column=1, sticky="w") + + form.grid_columnconfigure(2, weight=1) + + btns = ttk.Frame(right) + btns.pack(anchor="w", padx=2, pady=6) + ttk.Button(btns, text="Apply to model", command=self.on_apply).pack(side="left") + + # Init view + self.refresh_from_model() + + # ------------- Model<->View plumbing ------------- + + def refresh_from_model(self): + # Populate combo + names = [ps.name for ps in getattr(self.experiment, "paramsets", [])] + self.combo_active["values"] = names + if self.experiment.active_params: + self.active_var.set(self.experiment.active_params.name) + else: + self.active_var.set("") + + # Populate tree with paramset nodes + self.tree.delete(*self.tree.get_children()) + root = self.tree.insert("", "end", text="Experiment", open=True) + params_node = self.tree.insert(root, "end", text="Parameters", open=True) + for ps in self.experiment.paramsets: + self.tree.insert(params_node, "end", text=ps.name, values=(ps.yaml_path,)) + + # Load param fields + try: + pm = self.experiment.pm + self.num_cams_var.set(pm.get_n_cam()) + ptv = pm.get_parameter("ptv") + self.imx_var.set(ptv.get("imx", 0)) + self.imy_var.set(ptv.get("imy", 0)) + self.splitter_var.set(bool(ptv.get("splitter", False))) + except Exception: + # Model may not yet be initialized + self.num_cams_var.set(0) + self.imx_var.set(0) + self.imy_var.set(0) + self.splitter_var.set(False) + + def on_apply(self): + # Push form values back into the model + pm = self.experiment.pm + pm.parameters["num_cams"] = int(self.num_cams_var.get()) + if "ptv" not in pm.parameters: + pm.parameters["ptv"] = {} + ptv = pm.parameters["ptv"] + ptv["imx"] = int(self.imx_var.get()) + ptv["imy"] = int(self.imy_var.get()) + ptv["splitter"] = bool(self.splitter_var.get()) + + # Persist to YAML via Experiment + self.experiment.save_parameters() + messagebox.showinfo("Saved", "Parameters saved to YAML") + + def on_open(self): + # Allow selecting either a YAML file or a folder + path = filedialog.askopenfilename( + title="Open parameters.yaml", + filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")] + ) + if path: + p = Path(path) + if p.suffix.lower() in {".yaml", ".yml"}: + pm = ParameterManager() + pm.from_yaml(p) + self.experiment = Experiment(pm=pm) + self.experiment.populate_runs(p.parent) + # Make sure the one we opened is the active set + for idx, ps in enumerate(self.experiment.paramsets): + if ps.yaml_path.resolve() == p.resolve(): + self.experiment.set_active(idx) + break + else: + messagebox.showerror("Error", "Please select a YAML file") + return + else: + # Try selecting a directory instead + d = filedialog.askdirectory(title="Open experiment folder") + if d: + dpath = Path(d) + self.experiment = Experiment() + self.experiment.populate_runs(dpath) + else: + return + + self.refresh_from_model() + + def on_save(self): + self.on_apply() + + def on_tree_select(self, event=None): + # When a paramset is selected in the tree, set it active + sel = self.tree.selection() + if not sel: + return + text = self.tree.item(sel[0], "text") + try: + idx = [ps.name for ps in self.experiment.paramsets].index(text) + except ValueError: + return + self.experiment.set_active(idx) + self.refresh_from_model() + + def on_change_active(self, event=None): + name = self.active_var.get() + for idx, ps in enumerate(self.experiment.paramsets): + if ps.name == name: + self.experiment.set_active(idx) + self.refresh_from_model() + break + + +def main(): + root = tk.Tk() + app = TkMainApp(master=root) + root.geometry("900x600") + root.mainloop() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_experiment_model.py b/tests/test_experiment_model.py new file mode 100644 index 0000000..50406cd --- /dev/null +++ b/tests/test_experiment_model.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +High-level tests for the Experiment model. + +These tests focus on the Experiment class behavior around: +- Managing Paramsets (add, set active, duplicate, create, rename, delete, remove) +- Persisting to YAML via ParameterManager +- Populating runs from an experiment folder, including legacy-to-YAML conversion +""" + +from pathlib import Path +import pytest + +from pyptv.experiment import Experiment +from pyptv.parameter_manager import ParameterManager + + +def write_minimal_yaml(path: Path, num_cams: int = 2, splitter: bool = False): + """ + Write a minimal YAML parameter set that ParameterManager.from_yaml can load. + + The YAML includes: + - num_cams + - ptv: splitter flag (used by get_target_filenames) + - sequence: base_name list (one per camera in non-splitter mode) + """ + if splitter: + # splitter mode uses a single base_name + content = f"""num_cams: {num_cams} +ptv: + splitter: true +sequence: + base_name: + - img/cam1_00000 +""" + else: + # non-splitter uses a base_name per camera + base_names = "\n".join([f" - img/cam{i+1}_00000" for i in range(num_cams)]) + content = f"""num_cams: {num_cams} +ptv: + splitter: false +sequence: + base_name: +{base_names} +""" + path.write_text(content) + + +def write_minimal_legacy_dir(dir_path: Path, num_cams: int = 2): + """ + Create a minimal legacy parameter directory with a valid ptv.par. + Content mirrors other tests in the suite. + """ + dir_path.mkdir(parents=True, exist_ok=True) + ptv_par = dir_path / "ptv.par" + lines = [ + str(num_cams), + ] + # add img/cal pairs for each camera + for i in range(1, num_cams + 1): + lines.append(f"img/cam{i}.10002") + lines.append(f"cal/cam{i}.tif") + # remaining required constants follow real-world examples + lines.extend([ + "1", # tiff flag + "0", # imx (dummy) + "1", # dummy + "1280", + "1024", + "0.012", + "0.012", + "0", + "1", + "1.33", + "1.46", + "6" + ]) + ptv_par.write_text("\n".join(lines) + "\n") + + +def test_add_and_set_active_and_basic_roundtrip(tmp_path: Path): + exp_dir = tmp_path + yaml_a = exp_dir / "parameters_A.yaml" + write_minimal_yaml(yaml_a, num_cams=2) + + exp = Experiment() # fresh Experiment with its own ParameterManager + # Add a paramset and make it active + exp.addParamset("A", yaml_a) + assert exp.nParamsets() == 1 + exp.set_active(0) + assert exp.active_params is not None + assert exp.active_params.name == "A" + + # Loading was invoked by set_active; get_n_cam should reflect YAML + assert exp.get_n_cam() == 2 + + # Save should produce/overwrite the YAML without error + exp.save_parameters() + assert yaml_a.exists() + + +def test_duplicate_and_create_new_paramset(tmp_path: Path): + exp_dir = tmp_path + yaml_a = exp_dir / "parameters_A.yaml" + write_minimal_yaml(yaml_a, num_cams=3) + + exp = Experiment() + exp.addParamset("A", yaml_a) + exp.set_active(0) + + # Duplicate from active + dup_yaml = exp.duplicate_paramset("A") + assert dup_yaml.exists() + assert dup_yaml.name == "parameters_A_copy.yaml" + assert any(ps.name == "A_copy" for ps in exp.paramsets) + + # Create a new paramset (copied from active) + new_yaml = exp.create_new_paramset("B", exp_dir, copy_from_active=True) + assert new_yaml.exists() + assert new_yaml.name == "parameters_B.yaml" + assert any(ps.name == "B" for ps in exp.paramsets) + + +def test_rename_and_delete_and_remove_paramset(tmp_path: Path): + exp_dir = tmp_path + yaml_b = exp_dir / "parameters_B.yaml" + write_minimal_yaml(yaml_b, num_cams=2) + + exp = Experiment() + exp.addParamset("B", yaml_b) + # Not active yet (so we can delete it later) + assert exp.nParamsets() == 1 + + # Rename the paramset and underlying file + paramset_obj, new_yaml = exp.rename_paramset("B", "C") + assert paramset_obj.name == "C" + assert new_yaml.exists() + assert new_yaml.name == "parameters_C.yaml" + assert not yaml_b.exists() + + # Create a legacy dir that should be removed by removeParamset + legacy_dir = new_yaml.parent / "parametersC" + write_minimal_legacy_dir(legacy_dir, num_cams=2) + assert legacy_dir.exists() + + # Remove the paramset: should rename YAML to .bck and remove legacy dir + exp.removeParamset(0) + assert exp.nParamsets() == 0 + assert not legacy_dir.exists() + bck = new_yaml.with_suffix(".bck") + assert bck.exists() + + # Recreate and test delete_paramset (cannot delete active) + yaml_d = exp_dir / "parameters_D.yaml" + write_minimal_yaml(yaml_d, num_cams=2) + exp.addParamset("D", yaml_d) + exp.set_active(0) + with pytest.raises(ValueError): + exp.delete_paramset(0) # cannot delete the active paramset + + # Add a non-active paramset and delete it + yaml_e = exp_dir / "parameters_E.yaml" + write_minimal_yaml(yaml_e, num_cams=2) + exp.addParamset("E", yaml_e) + # delete by index 1 (non-active) + exp.delete_paramset(1) + assert not yaml_e.exists() + assert all(ps.name != "E" for ps in exp.paramsets) + + +def test_populate_runs_converts_legacy_and_loads_yaml(tmp_path: Path): + exp_dir = tmp_path + + # Legacy run folder that should be converted to YAML + legacy_run = exp_dir / "parametersRun1" + write_minimal_legacy_dir(legacy_run, num_cams=2) + assert (legacy_run / "ptv.par").exists() + + # Existing YAML for another run + yaml_run2 = exp_dir / "parameters_Run2.yaml" + write_minimal_yaml(yaml_run2, num_cams=4) + + # Populate + exp = Experiment() + exp.populate_runs(exp_dir) + + # Should have both runs present as paramsets + names = [ps.name for ps in exp.paramsets] + assert "Run1" in names + assert "Run2" in names + + # Should have created YAML for legacy Run1 + created_yaml = exp_dir / "parameters_Run1.yaml" + assert created_yaml.exists() + + # Active set should be loaded and accessible + assert exp.active_params is not None + assert isinstance(exp.pm, ParameterManager) + # After populate, active was set to the first set; ensure get_n_cam is available + assert isinstance(exp.get_n_cam(), int) \ No newline at end of file diff --git a/tests/test_parameter_manager_targets.py b/tests/test_parameter_manager_targets.py new file mode 100644 index 0000000..394bf5b --- /dev/null +++ b/tests/test_parameter_manager_targets.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +Unit tests for ParameterManager.get_target_filenames. + +Covers both splitter and non-splitter modes and verifies that output paths +are computed from YAML contents in a predictable way. +""" + +from pathlib import Path +from pyptv.parameter_manager import ParameterManager + + +def write_yaml(path: Path, num_cams: int, splitter: bool, base_names): + """ + Helper to write a minimal YAML file covering the fields used by + get_target_filenames: num_cams, ptv.splitter, sequence.base_name. + """ + lines = [f"num_cams: {num_cams}"] + lines.append("ptv:") + lines.append(f" splitter: {'true' if splitter else 'false'}") + lines.append("sequence:") + lines.append(" base_name:") + for bn in base_names: + lines.append(f" - {bn}") + path.write_text("\n".join(lines) + "\n") + + +def test_get_target_filenames_splitter_mode(tmp_path: Path): + """ + In splitter mode: + - Only the first base_name is used to determine the folder. + - The function returns cam1..camN in that folder, where N=num_cams. + """ + yaml_path = tmp_path / "params.yaml" + write_yaml( + yaml_path, + num_cams=4, + splitter=True, + base_names=["img/cam_basename_00000"], # only one base_name required + ) + + pm = ParameterManager() + pm.from_yaml(yaml_path) + targets = pm.get_target_filenames() + # Expect 4 camera folders in the same parent directory as the single base name + assert len(targets) == 4 + assert all(t.parent.name == "img" for t in targets) + assert [t.name for t in targets] == ["cam1", "cam2", "cam3", "cam4"] + + +def test_get_target_filenames_non_splitter_mode(tmp_path: Path): + """ + In non-splitter mode: + - One base_name is expected per camera. + - The output list length equals len(base_names) (even if < num_cams). + - Each output path is the parent folder of the provided base_name joined with 'cam{i}'. + """ + yaml_path = tmp_path / "params.yaml" + base_names = [ + "run/img1_00000", + "run/img2_00000", + "run/img3_00000", + ] + write_yaml( + yaml_path, + num_cams=4, # deliberately greater than len(base_names) + splitter=False, + base_names=base_names, + ) + + pm = ParameterManager() + pm.from_yaml(yaml_path) + targets = pm.get_target_filenames() + # Expect as many targets as base_names provided + assert len(targets) == len(base_names) + # Parent folder for each is 'run' + assert all(t.parent.name == "run" for t in targets) + # cam index is based on enumerate position (1-based) + assert [t.name for t in targets] == ["cam1", "cam2", "cam3"] \ No newline at end of file