diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 3921413d..1cfc579d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -60,7 +60,7 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: "pip" - - run: pip install -e '.[dev]' + - run: pip install -e '.[dev, track]' - run: pytest --cov=src --cov-report term-missing doctest: runs-on: ubuntu-latest @@ -74,5 +74,5 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: "pip" - - run: pip install -e '.[dev]' - - run: make -C docs doctest + - run: pip install -e '.[dev, track]' + - run: make -C docs doctest \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 4106f8a3..5d3f8885 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,16 +1,17 @@ version: 2 build: - os: ubuntu-22.04 - tools: - python: "3.10" + os: ubuntu-22.04 + tools: + python: "3.10" sphinx: - configuration: docs/source/conf.py + configuration: docs/source/conf.py python: - install: - - method: pip - path: . - extra_requirements: - - dev + install: + - method: pip + path: . + extra_requirements: + - dev + - track diff --git a/docs/source/_static/vision/vision_gui.gif b/docs/source/_static/vision/vision_gui.gif new file mode 100644 index 00000000..ece8763b Binary files /dev/null and b/docs/source/_static/vision/vision_gui.gif differ diff --git a/docs/source/_static/vision/vision_scheme.jpeg b/docs/source/_static/vision/vision_scheme.jpeg new file mode 100644 index 00000000..45974581 Binary files /dev/null and b/docs/source/_static/vision/vision_scheme.jpeg differ diff --git a/docs/source/_templates/class.rst b/docs/source/_templates/class.rst index 26ee6880..7f42c44a 100644 --- a/docs/source/_templates/class.rst +++ b/docs/source/_templates/class.rst @@ -18,14 +18,3 @@ {%- endfor %} {% endif %} {% endblock %} - - {% block attributes %} - {% if attributes %} - .. rubric:: {{ _('Attributes') }} - - .. autosummary:: - {% for item in attributes %} - ~{{ name }}.{{ item }} - {%- endfor %} - {% endif %} - {% endblock %} diff --git a/docs/source/index.rst b/docs/source/index.rst index 6f7d6c0e..8728a48d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -9,6 +9,7 @@ tICA onion clustering vision + track analysis data processing HDF5er @@ -74,7 +75,7 @@ or with conda:: If you want to use the :mod:`dynsight.vision` and :mod:`dynsight.track` modules you will need to install a series of packages. This can be done with with pip:: - $ pip install ultralytics PyYAML + $ pip install ultralytics PyYAML pandas trackpy How to contribute diff --git a/docs/source/track.rst b/docs/source/track.rst new file mode 100644 index 00000000..2a1049f7 --- /dev/null +++ b/docs/source/track.rst @@ -0,0 +1,14 @@ +track +===== + +This module tracks particle trajectories in `.xyz` files that lack explicit +particle IDs, assigning consistent identities across frames. +It is especially useful for outputs generated by the :doc:`vision` module. + +Functions +--------- + +.. toctree:: + :maxdepth: 1 + + track_xyz <_autosummary/dynsight.track.track_xyz> diff --git a/docs/source/vision.rst b/docs/source/vision.rst index 4f08e89e..a2668721 100644 --- a/docs/source/vision.rst +++ b/docs/source/vision.rst @@ -1,7 +1,43 @@ Vision ====== -Code for computer vision and trajectory extraction from experimental videos. +Code that contains useful tools to obtain trajectories from video files. +The extraction is possible thanks to the optimization of a Convolutional Neural +Network (CNN) model training to +detect specific objects in the video. Once the positions are recovered, a +tracking algorithm (:doc:`track`) links each object with its own identity number. + +.. figure:: _static/vision/vision_scheme.jpeg + :alt: Schematic representation of a typical dynsight.vision application. + :align: center + + Schematic representation of a typical `dynsight.vision` application. + +The object detection is managed by the Ultralytics_ external library. If you +use this tool, please cite them by referring to this link_. + +.. _Ultralytics: https://www.ultralytics.com +.. _link: https://docs.ultralytics.com/models/yolo11/#citations-and-acknowledgements + +-------------- +The Vision GUI +-------------- + +The `dynsight.vision` module includes an internal Graphical User Interface (GUI) +designed to assist users in preparing the training items required for object +detection. It only requires an *initial guess* of the objects present in +the video, which is then used to generate a synthetic dataset to bootstrap the +training process. + +.. tip:: + + Applying some basic color correction to the input video (especially converting + it to grayscale with enhanced contrast) can significantly improve the quality + of the initial model. + +.. image:: _static/vision/vision_gui.gif + :alt: Usage of the GUI. + :align: center ----- Usage @@ -12,3 +48,6 @@ Usage Video <_autosummary/dynsight.vision.Video.rst> Detect <_autosummary/dynsight.vision.Detect.rst> + +Once obtained the particle positions, is possible to obtain the trajectories of +each particle by using the :doc:`track` module. diff --git a/examples/video_detection.py b/examples/video_detection.py index 9d5695cb..d1d78bf2 100644 --- a/examples/video_detection.py +++ b/examples/video_detection.py @@ -113,10 +113,25 @@ def main() -> None: ) if args.predict: detection.predict_frames(model_path=args.detect_model) + xyz_trajectory = Path.cwd() / "trajectory.xyz" detection.compute_xyz( prediction_folder_path=Path("prediction"), output_path=Path.cwd(), ) + tr_xyz_trajectory = Path.cwd() / "tracked_trajectory.xyz" + trj = dynsight.track.track_xyz( + input_xyz=xyz_trajectory, + output_xyz=tr_xyz_trajectory, + ) + # Usage example of the trj object after tracking. + lens_descriptor = trj.get_lens(r_cut=5) + lens_descriptor.dump_to_json(output_project) + # Compute clustering + onion_lens = lens_descriptor.get_onion(delta_t=2) + onion_lens.plot_output( + file_path=args.output, + data_insight=lens_descriptor, + ) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index b1949aee..d14db4ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dev = [ "furo", "numba", ] +track = ["trackpy", "pandas"] [project.urls] github = "https://github.com/GMPavanLab/dynsight" @@ -110,6 +111,8 @@ module = [ 'Pillow.*', 'ultralytics.*', 'opencv-python.*', + 'pandas.*', + 'trackpy.*', 'cv2.*', ] ignore_missing_imports = true diff --git a/src/dynsight/__init__.py b/src/dynsight/__init__.py index 36bc1232..e16b196f 100644 --- a/src/dynsight/__init__.py +++ b/src/dynsight/__init__.py @@ -8,6 +8,7 @@ onion, soap, tica, + track, trajectory, utilities, vision, @@ -21,6 +22,7 @@ "onion", "soap", "tica", + "track", "trajectory", "utilities", "vision", diff --git a/src/dynsight/_internal/track/__init__.py b/src/dynsight/_internal/track/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/dynsight/_internal/track/track.py b/src/dynsight/_internal/track/track.py new file mode 100644 index 00000000..25ed5ff0 --- /dev/null +++ b/src/dynsight/_internal/track/track.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +import pandas as pd +import trackpy as tp + +from dynsight.trajectory import Trj + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(message)s", +) +logger = logging.getLogger(__name__) + + +def track_xyz( + input_xyz: Path, + output_xyz: Path, + search_range: float = 10, + memory: int = 1, + adaptive_stop: None | float = 0.95, + adaptive_step: None | float = 0.5, +) -> Trj: + """Track particles from an `.xyz` file and write a new file with IDs. + + The input `.xyz` is assumed to contain only raw 3D coordinates + (without atom labels/identity), and each frame begins with a line + indicating the number of atoms, followed by a comment line, then a list of + positions. Each frame in the input file must follow this structure:: + + + comment line + + + ... + + + The output file will have the same structure, but each line will start + with the tracked particle ID. + + Parameters: + input_xyz: + Path to the input .xyz file containing positions only. + + output_xyz: + Path where the output .xyz file with particle IDs will be saved. + + search_range: + The maximum allowable displacement of objects between frames for + them to be considered the same particle. + + memory: + The maximum number of frames during which an object can vanish, + then reppear nearby, and be considered the same particle. + + adaptive_stop: + If not `None`, when encountering a region with too many candidate + links (subnet), retry by progressively reducing `search_range` + until the subnet is solvable. If `search_range` becomes less or + equal than the `adaptive_stop`, give up and raise a + `SubnetOversizeException`. + + adaptive_step: + Factor by which the `search_range` is multiplied to reduce it + during adaptive search. Effective only if `adaptive_stop` is not + `None`. + """ + if adaptive_stop is None and adaptive_step is not None: + msg = "adaptive_step is set but adaptive_stop is None." + raise ValueError(msg) + if adaptive_stop is not None and adaptive_step is None: + msg = "adaptive_stop is set but adaptive_step is None." + raise ValueError(msg) + + input_xyz = Path(input_xyz) + output_xyz = Path(output_xyz) + + if not input_xyz.exists(): + msg = f"Input file not found: {input_xyz}" + raise FileNotFoundError(msg) + + positions = _collect_positions(input_xyz=input_xyz) + + if not {"frame", "x", "y", "z"}.issubset(positions.columns): + msg = "Error in the .xyz format. Each line must be ." + raise ValueError(msg) + + linked = tp.link_df( + positions, + search_range=search_range, + memory=memory, + adaptive_step=adaptive_step, + adaptive_stop=adaptive_stop, + ) + + with output_xyz.open("w") as f: + for frame_num in sorted(linked["frame"].unique()): + frame_data = linked[linked["frame"] == frame_num] + f.write(f"{len(frame_data)}\n") + f.write(f"Frame {frame_num}\n") + for _, row in frame_data.iterrows(): + pid = int(row["particle"]) + x, y, z = row["x"], row["y"], row["z"] + f.write(f"{pid} {x:.6f} {y:.6f} {z:.6f}\n") + + logger.info(f"Linked .xyz file written to: {output_xyz}") + return Trj.init_from_xyz(traj_file=output_xyz, dt=1) + + +def _collect_positions(input_xyz: Path) -> pd.DataFrame: + """Read the xyz file and return the positions dataset at each frame.""" + lines = input_xyz.read_text().splitlines() + + data = [] + frame = -1 + row = 0 + dimensions = 3 + for _ in range(len(lines)): + if row >= len(lines): + break + if lines[row].strip().isdigit(): + num_atoms = int(lines[row]) + frame += 1 + row += 2 # skip comment line. + for a in range(num_atoms): + if row + a >= len(lines): + break + parts = lines[row + a].strip().split() + if len(parts) >= dimensions: + x, y, z = map(float, parts[0:3]) + data.append({"frame": frame, "x": x, "y": y, "z": z}) + row += num_atoms + else: + row += 1 + + return pd.DataFrame(data) diff --git a/src/dynsight/_internal/trajectory/trajectory.py b/src/dynsight/_internal/trajectory/trajectory.py index 2f366d7d..23e77cdd 100644 --- a/src/dynsight/_internal/trajectory/trajectory.py +++ b/src/dynsight/_internal/trajectory/trajectory.py @@ -23,7 +23,7 @@ class Insight: """Contains an analysis perfomed on a trajectory. - Attributes: + Parameters: dataset: The values of a some trajectory's descriptor. meta: A dictionary containing the relevant parameters. """ @@ -246,7 +246,7 @@ def get_onion_analysis( class ClusterInsight: """Contains a clustering analysis. - Attributes: + Parameters: labels: The labels assigned by the clustering algorithm. """ @@ -278,7 +278,7 @@ def load_from_json(cls, file_path: Path) -> ClusterInsight: class OnionInsight(ClusterInsight): """Contains an onion-clustering analysis. - Attributes: + Parameters: labels: The labels assigned by the clustering algorithm. state_list: List of the onion-clustering Gaussian states. reshaped_data: The input data reshaped for onion-clustering. @@ -535,7 +535,7 @@ def plot_sankey( class Trj: """Contains a trajectory. - Attributes: + Parameters: universe: a MDAnalysis.Universe containing the trajectory. .. warning:: @@ -621,6 +621,7 @@ def get_soap( n_max: int, l_max: int, respect_pbc: bool = True, + selection: str = "all", centers: str = "all", n_core: int = 1, ) -> Insight: @@ -635,6 +636,7 @@ def get_soap( soapnmax=n_max, soaplmax=l_max, soap_respectpbc=respect_pbc, + selection=selection, centers=centers, n_core=n_core, ) @@ -643,6 +645,7 @@ def get_soap( "n_max": n_max, "l_max": l_max, "respect_pbc": respect_pbc, + "selection": selection, "centers": centers, } return Insight(dataset=soap, meta=attr_dict) diff --git a/src/dynsight/_internal/vision/vision_gui.py b/src/dynsight/_internal/vision/vision_gui.py index d15b4ec0..bed2ca73 100644 --- a/src/dynsight/_internal/vision/vision_gui.py +++ b/src/dynsight/_internal/vision/vision_gui.py @@ -6,9 +6,11 @@ from pathlib import Path from typing import Any -from PIL import Image +from PIL import Image, ImageTk +from PIL.Image import Resampling +# Dataclass for storing bounding box information. @dataclass class Box: id: int @@ -19,12 +21,8 @@ class Box: abs_coords: tuple[int, int, int, int] +# GUI class for creating synthetic datasets from images. class VisionGUI: - """GUI for interactively labeling images by drawing bounding boxes. - - * Author: Simone Martino - """ - def __init__( self, master: tk.Tk, @@ -35,47 +33,30 @@ def __init__( self.image_path = image_path self.destination_folder = destination_folder self.master.title("Dynsight: Label tool") - try: - self.image = tk.PhotoImage(file=image_path) - except Exception as e: - msg = f"Error loading image: {e}" - raise ValueError(msg) from e - # Setup the main grid + + # Setup the GUI window size. + screen_width = self.master.winfo_screenwidth() + screen_height = self.master.winfo_screenheight() + self.master.geometry(f"{screen_width // 2}x{screen_height // 2}") + + # Load the image. + pil_image = Image.open(self.image_path) + self.original_image = pil_image + self.original_width, self.original_height = pil_image.size + + self.scale = 1.0 + self.canvas_image_id: int | None = None + + # GUI window layout. self.master.rowconfigure(index=0, weight=1) - # Image self.master.columnconfigure(index=0, weight=1) - # Sidebar - self.master.columnconfigure(index=1, weight=1) + self.master.columnconfigure(index=1, weight=0) - # Image canvas - self.canvas = tk.Canvas( - master=self.master, - width=self.image.width(), - height=self.image.height(), - cursor="crosshair", - ) + # Canvas for image labelling. + self.canvas = tk.Canvas(master=self.master, cursor="crosshair") self.canvas.grid(row=0, column=0, sticky="nsew") - self.canvas.create_image(0, 0, anchor=tk.NW, image=self.image) - # Rulers - self.h_line = self.canvas.create_line( - 0, # x0 - 0, # y0 - self.image.width(), # x1 - 0, # y1 - fill="blue", - dash=(2, 2), - width=3, - ) - self.v_line = self.canvas.create_line( - 0, # x0 - 0, # y0 - 0, # x1 - self.image.height(), # y1 - fill="blue", - dash=(2, 2), - width=3, - ) - # Sidebar + + # Buttons. self.sidebar = tk.Frame( master=self.master, width=150, @@ -85,7 +66,6 @@ def __init__( self.sidebar.grid(row=0, column=1, sticky="ns") self.sidebar.grid_propagate(flag=False) - # Buttons self.submit_button = tk.Button( self.sidebar, text="Submit", @@ -107,21 +87,105 @@ def __init__( ) self.close_button.pack(pady=10, fill="x") - # Labelling variables self.start_x = 0 self.start_y = 0 self.current_box = 0 self.boxes: list[Box] = [] - # Mouse bindings + # Rulers. + self.h_line = self.canvas.create_line( + 0, # x0 + 0, # y0 + 0, # x1 + 0, # y1 + fill="blue", + dash=(2, 2), + width=3, + ) + self.v_line = self.canvas.create_line( + 0, # x0 + 0, # y0 + 0, # x1 + 0, # y1 + fill="blue", + dash=(2, 2), + width=3, + ) + + # Bindings. self.canvas.bind("", self._on_click_press) self.canvas.bind("", self._on_click_release) self.canvas.bind("", self._on_mouse_drag) self.canvas.bind("", self._follow_mouse) + self.master.bind("", self._resize) + + # Initial rendering of the image. + self._resize() + + # Resize image and redraw everything when the window is resized + def _resize(self, _: tk.Event[Any] | None = None) -> None: + canvas_width = self.canvas.winfo_width() + canvas_height = self.canvas.winfo_height() + size_thr = 10 + if canvas_width < size_thr or canvas_height < size_thr: + return + + # Scaling factor. + self.scale = min( + canvas_width / self.original_width, + canvas_height / self.original_height, + ) + resized = self.original_image.resize( + ( + int(self.original_width * self.scale), + int(self.original_height * self.scale), + ), + Resampling.LANCZOS, + ) + # Update image and boxes. + self.tk_image = ImageTk.PhotoImage(resized) + self.canvas.delete("all") + self.canvas_image_id = self.canvas.create_image( + 0, + 0, + anchor=tk.NW, + image=self.tk_image, + ) + + # Re-draw boxes + for box in self.boxes: + x1, y1, x2, y2 = [int(v * self.scale) for v in box.abs_coords] + box.id = self.canvas.create_rectangle( + x1, + y1, + x2, + y2, + outline="red", + width=3, + ) - # Mouse functions + # Recreate guide lines + self.h_line = self.canvas.create_line( + 0, # x0 + 0, # y0 + canvas_width, # x1 + 0, # y1 + fill="blue", + dash=(2, 2), + width=3, + ) + self.v_line = self.canvas.create_line( + 0, # x0 + 0, # y0 + 0, # x1 + canvas_height, # y1 + fill="blue", + dash=(2, 2), + width=3, + ) + + # Start drawing a box. def _on_click_press(self, event: tk.Event[Any]) -> None: - """Starts drawing the box on mouse press.""" self.start_x, self.start_y = event.x, event.y self.current_box = self.canvas.create_rectangle( self.start_x, # x0 @@ -132,15 +196,23 @@ def _on_click_press(self, event: tk.Event[Any]) -> None: width=3, ) + # Finish drawing the box and store its data def _on_click_release(self, event: tk.Event[Any]) -> None: - """Finalize the box on mouse release.""" x2, y2 = event.x, event.y x1, y1 = self.start_x, self.start_y - abs_coords = (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)) - center_x = (x1 + x2) / (2 * self.image.width()) - center_y = (y1 + y2) / (2 * self.image.height()) - width_rel = abs(x2 - x1) / self.image.width() - height_rel = abs(y2 - y1) / self.image.height() + x1_orig = int(min(x1, x2) / self.scale) + y1_orig = int(min(y1, y2) / self.scale) + x2_orig = int(max(x1, x2) / self.scale) + y2_orig = int(max(y1, y2) / self.scale) + + abs_coords = (x1_orig, y1_orig, x2_orig, y2_orig) + + center_x = (x1_orig + x2_orig) / (2 * self.original_width) + center_y = (y1_orig + y2_orig) / (2 * self.original_height) + width_rel = abs(x2_orig - x1_orig) / self.original_width + height_rel = abs(y2_orig - y1_orig) / self.original_height + + # Create and store Box object. box_info = Box( id=self.current_box, center_x=center_x, @@ -151,54 +223,40 @@ def _on_click_release(self, event: tk.Event[Any]) -> None: ) self.boxes.append(box_info) self.current_box = 0 + self._resize() # Redraw for consistent scaling def _on_mouse_drag(self, event: tk.Event[Any]) -> None: - """Update the box coordinates while dragging the mouse.""" sel_x, sel_y = event.x, event.y self.canvas.coords( - self.current_box, # ID + self.current_box, self.start_x, # x0 self.start_y, # y0 sel_x, # x1 sel_y, # y1 ) - # Sync rulers too self.canvas.coords( - self.h_line, # ID + self.h_line, 0, # x0 sel_y, # y0 - self.image.width(), # x1 + self.canvas.winfo_width(), # x1 sel_y, # y1 ) self.canvas.coords( - self.v_line, # ID + self.v_line, sel_x, # x0 0, # y0 sel_x, # x1 - self.image.height(), # y1 + self.canvas.winfo_height(), # y1 ) + # Update box as mouse is dragged. def _follow_mouse(self, event: tk.Event[Any]) -> None: - """Sync guide lines position with mouse movement.""" x, y = event.x, event.y - self.canvas.coords( - self.h_line, # ID - 0, # x0 - y, # y0 - self.image.width(), # x1 - y, # y1 - ) - self.canvas.coords( - self.v_line, # ID - x, # x0 - 0, # y0 - x, # x1 - self.image.height(), # y1 - ) + self.canvas.coords(self.h_line, 0, y, self.canvas.winfo_width(), y) + self.canvas.coords(self.v_line, x, 0, x, self.canvas.winfo_height()) - # Button functions + # Save cropped images from each bounding box. def _submit(self) -> None: - """Save the cropped images.""" cropped_img_folder = self.destination_folder / "training_items" cropped_img_folder.mkdir(exist_ok=True) pil_image = Image.open(self.image_path) @@ -209,12 +267,13 @@ def _submit(self) -> None: cropped_image.save(save_path) self.master.destroy() + # Remove last drawn box. def _undo(self) -> None: - """Remove the last drawn box.""" if self.boxes: last_box = self.boxes.pop() self.canvas.delete(last_box.id) + self._resize() + # Close the GUI def _close(self) -> None: - """Close without saving.""" self.master.destroy() diff --git a/src/dynsight/track.py b/src/dynsight/track.py new file mode 100644 index 00000000..b88355f5 --- /dev/null +++ b/src/dynsight/track.py @@ -0,0 +1,9 @@ +"""track package.""" + +from dynsight._internal.track.track import ( + track_xyz, +) + +__all__ = [ + "track_xyz", +]