diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2180a1d..2118af4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,8 @@ on: push: paths: - '**.py' + - 'environments/*.yml' + - '.github/workflows/test.yml' jobs: test: @@ -29,4 +31,4 @@ jobs: shell: bash -l {0} run: | export PYTHONPATH=. - pytest -vs test/ + pytest -vvs test/ diff --git a/.gitignore b/.gitignore index ae9a085..cf299cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ __pycache__ build dist -.pytest_cache \ No newline at end of file +.pytest_cache +_LOCAL_LEO +*.spec diff --git a/README.md b/README.md index fa07868..dc8b31f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,6 @@ TACTool was initially developed within the British Geological Survey by: The original idea was by Connor Newstead and Matt Horstwood. - ## Development ### Installation @@ -41,6 +40,8 @@ conda env create -f environments/macos-environment.yml conda activate tactool-macos ``` +_Note: Both environments have been generated using `environments/unversioned-environment.yml`._ + ### Running the Program To run the program, first you need to setup your Python path. @@ -66,9 +67,7 @@ pre-loaded into the GraphicsView. ### Class Relationship Diagram -
- Mermaid JS Code - +```mermaid classDiagram direction LR @@ -76,59 +75,84 @@ pre-loaded into the GraphicsView. QApplication Manages preloaded modes of the application --- - +Window - +developer_mode: bool +testing_mode: bool + +window: Window + +graphics_view: GraphicsView + +graphics_scene: GraphicsScene + +table_model: TableModel + +table_view: TableView + +set_scale_dialog: Optional[SetScaleDialog] + +recoordinate_dialog: Optional[RecoordinateDialog] developer_mode() } class Window { QMainWindow - User Interface with data preprocessing and data flow + Main User Interface with data flow --- +testing_mode: bool +default_settings: dict[str, Any] - +image_filepath: str - +csv_filepath: str + +image_filepath: Optional[str] + +csv_filepath: Optional[str] +point_colour: str - +status_bar_messages: dict[str, dict[str, Any]] +graphics_view: GraphicsView +graphics_scene: GraphicsScene +table_model: TableModel +table_view: TableView - +set_scale_dialog: SetScaleDialog + +set_scale_dialog: Optional[SetScaleDialog] + +recoordinate_dialog: Optional[RecoordinateDialog] + +menu_bar: QMenuBar + +menu_bar_file: QMenu + +import_image_button: QAction + +export_image_button: QAction + +import_tactool_csv_button: QAction + +export_tactool_csv_button: QAction + +recoordinate_sem_csv_button: QAction + +menu_bar_tools: QMenu + +ghost_point_button: QAction + +status_bar: QStatusBar + +sample_name_input: QLineEdit + +mount_name_input: QLineEdit + +material_input: QLineEdit + +label_input: QComboBox + +colour_button: QPushButton + +diameter_input: QSpinBox + +scale_value_input: QLineEdit + +set_scale_button: QPushButton + +clear_points_button: QPushButton + +reset_ids_button: QPushButton + +reset_settings_button: QPushButton + +status_bar_messages: dict[str, dict[str, Any]] +main_input_widgets: list[QWidget] + +dialogs: list[QDialog] +setup_ui_elements() - +set_colour_button_style() +connect_signals_and_slots() + +set_colour_button_style() +create_status_bar_messages() +toggle_status_bar_messages() +import_image_get_path() +export_image_get_path() +import_tactool_csv_get_path() +load_tactool_csv_data(filepath) - +process_tactool_csv(filepath) - +parse_row_data(item, default_values) +export_tactool_csv_get_path() +validate_current_data(validate_image) - +add_analysis_point(x, y, label, diameter, scale, colour, notes, apid, sample_name, mount_name, material, from_click) + +add_analysis_point(x, y, apid, label, diameter, scale, colour, sample_name, mount_name, material, notes, use_windows_inputs, ghost) + +add_ghost_point(x, y) +remove_analysis_point(x, y, apid) - +reload_analysis_points() - +reset_analysis_points() + +reload_analysis_points(index, transform) +clear_analysis_points() - +update_analysis_points() - +set_point_colour() - +toggle_scaling_mode() - +toggle_main_input_widgets(enable) - +clear_scale_clicked() - +set_scale(scale) + +get_point_colour() + +set_point_colour(colour) +get_point_settings(analysis_point, clicked_column_index) +reset_settings() - +update_point_settings(sample_name, mount_name, material, label, diameter, scale, colour) - +data_error_message(error) - +show_message(title, message, type) + +update_point_settings(label, diameter, scale, colour, sample_name, mount_name, material) + +toggle_main_input_widgets(enable) + +set_scale(scale) + +toggle_scaling_mode() + +toggle_recoordinate_dialog() + +qmessagebox_error(error) +closeEvent(event) } @@ -136,11 +160,58 @@ pre-loaded into the GraphicsView. QTableView Manage the display of TableModel data --- - +set_column_sizes() + +format_columns() +mousePressEvent(event) +signal: selected_analysis_point(analysis_point, column) } + class TableModel{ + QAbstractTableModel + Manage AnalysisPoint data + --- + +headers: list[str] + +_data: list[list[Any]] + +editable_columns: list[int] + +public_headers: list[str] + +analysis_points: list[AnalysisPoint] + +reference_points: list[AnalysisPoint] + +next_point_id: int + + +headerData(section, orientation, role) + +columnCount(*args) + +rowCount(*args) + +data(index, role) + +setData(index, value, role) + +flags(index) + +add_point(analysis_point) + +remove_point(target_id) + +get_point_by_ellipse(target_ellipse) + +get_point_by_apid(target_id) + signal: updated_analysis_point(index) + } + + class AnalysisPoint{ + Create AnalysisPoint data + --- + +id: int + +label: str + +x: int + +y: int + +diameter: int + +scale: float + +colour: str + +sample_name: str + +mount_name: str + +material: str + +notes: str + +_outer_ellipse: QGraphicsEllipseItem + +_inner_ellipse: QGraphicsEllipseItem + +_label_text_item: QGraphicsTextItem + + +field_names() + +aslist() + } + class GraphicsView{ QGraphicsView Manage user interaction and visual display of GraphicsScene @@ -148,8 +219,9 @@ pre-loaded into the GraphicsView. +_zoom: int +_empty: bool +_image: QGraphicsPixmapItem + +disable_analysis_points: bool +navigation_mode: bool - +set_scale_mode: bool + +scaling_mode: bool +scale_start_point: QPointF +scale_end_point: QPointF +graphics_scene: GraphicsScene @@ -164,118 +236,99 @@ pre-loaded into the GraphicsView. +save_image(filepath) +show_entire_image() +toggle_scaling_mode() - +reset_scale_line_points() + +reset_scaling_elements() + +remove_ghost_point() +signal: left_click(x, y) +signal: right_click(x, y) +signal: scale_move_event(pixel_distance) - } - - class SetScaleDialog{ - QDialog - Allows the user to interactively calculate a scale - --- - +testing_mode: bool - +pixel_input_default: str - - +setup_ui_elements() - +connect_signals_and_slots() - +update_scale() - +scale_move_event_handler(pixel_distance) - +set_scale() - +closeEvent(event) - signal: clear_scale() - signal: set_scale_clicked(scale) - signal: closed_set_scale_dialog() + +signal: move_ghost_point(x, y) } class GraphicsScene{ QGraphicsScene Manage items painted on image --- - +_maximum_point_id: int - +scaling_rect: QGraphicsRectItem +scaling_group: QGraphicsItemGroup +scaling_line: QGraphicsLineItem - +table_model: TableModel + +transparent_window: QGraphicsRectItem - +add_analysis_point(x, y, label, diameter, scale, colour, notes, apid, sample_name, mount_name, material) - +remove_analysis_point(x, y, apid) + +add_analysis_point(x, y, apid, label, diameter, colour, scale, ghost) + +remove_analysis_point(ap) + +move_analysis_point(ap, x_change, y_change) +get_ellipse_at(x, y) - +next_point_id() +toggle_transparent_window(graphics_view_image) +draw_scale_line(start_point, end_point) +draw_scale_point(x, y) +remove_scale_items() } - class TableModel{ - QAbstractTableModel - Manage AnalysisPoint data + class SetScaleDialog{ + QDialog + Allows the user to interactively calculate a scale --- - +headers: list[str] - +_data: list[list[Any]] - +editable_columns: list[int] + +testing_mode: bool + +pixel_input_default: str + +set_scale_button: QPushButton + +clear_scale_button: QPushButton + +cancel_button: QPushButton + +distance_input: QSpinBox + +pixel_input: QLineEdit + +scale_value: QLineEdit - +headerData(section, orientation, role) - +columnCount(*args) - +rowCount(*args) - +data(index, role) - +setData(index, value, role) - +flags(index) - +add_point(analysis_point) - +remove_point(target_id) - +get_point_by_ellipse(target_ellipse) - +get_point_by_apid(target_id) - +reference_points() - +analysis_points() - +export_csv(filepath) - +convert_export_headers() - +convert_export_point() - signal: invalid_label_entry(title, message, type) - signal: updated_analysis_point(index) + +setup_ui_elements() + +connect_signals_and_slots() + +update_scale() + +scale_move_event_handler(pixel_distance) + +set_scale() + +clear_scale() + +closeEvent(event) + signal: clear_scale_clicked() + signal: set_scale_clicked(scale) + signal: closed_set_scale_dialog() } - class AnalysisPoint{ - Create AnalysisPoint data + class RecoordinateDialog{ + QDialog + Allows the user to recoordinate an SEM CSV file --- - +id: int - +x: int - +y: int - +label: str - +diameter: int - +scale: float - +colour: str - +sample_name: str - +mount_name: str - +material: str - +notes: str - +_outer_ellipse: QGraphicsEllipseItem - +_inner_ellipse: QGraphicsEllipseItem - +_label_text_item: QGraphicsTextItem + +testing_mode: bool + +ref_points: list[AnalysisPoint] + +image_size: QSize + +recoordinated_point_dicts: list[dict[str, str | int | float]] + +input_csv_button: QPushButton + +input_csv_filepath_label: QLineEdit + +recoordinate_button: QPushButton + +cancel_button: QPushButton - +field_names() - +aslist() + +setup_ui_elements() + +connect_signals_and_slots() + +get_input_csv() + +import_and_recoordinate_sem_csv() + +recoordinate_sem_points(point_dicts) + +closeEvent(event) + signal: closed_recoordinate_dialog() } TACtool *-- Window Window *-- GraphicsView Window *-- TableView Window *-- SetScaleDialog - GraphicsView *-- GraphicsScene - GraphicsScene *-- TableModel + Window *-- RecoordinateDialog + TableView *-- TableModel TableModel *-- AnalysisPoint + GraphicsView *-- GraphicsScene Window <.. TableView : selected_analysis_point(analysis_point, column) - Window <.. SetScaleDialog : clear_scale() - Window <.. SetScaleDialog : set_scale_clicked(scale) - Window <.. SetScaleDialog : closed_set_scale_dialog() + Window <.. TableModel : updated_analysis_point(index) Window <.. GraphicsView : left_click(x, y) Window <.. GraphicsView : right_click(x, y) Window <.. GraphicsView : scale_move_event(pixel_distance) - -
- -![TACtool - Class Relationship Diagram](class_relationship_diagram.png) + Window <.. GraphicsView : move_ghost_point(x, y) + Window <.. SetScaleDialog : clear_scale_clicked() + Window <.. SetScaleDialog : set_scale_clicked(scale) + Window <.. SetScaleDialog : closed_set_scale_dialog() + Window <.. RecoordinateDialog : closed_recoordinate_dialog() +``` ### Testing @@ -285,25 +338,6 @@ Ensure you have setup your Python path. Then you can run the tests with: pytest -vv test/ ``` -#### List of Tests - -**test_integration.py** -- test_add_and_remove_points -- test_clear_points -- test_reset_id_values -- test_reset_settings -- test_toggle_scaling_mode -- test_set_scale -- test_export_image -- test_import_tactool_csv -- test_export_tactool_csv -- test_reference_point_hint -- test_scale_hint - -**test_model.py** -- test_analysis_point_public_attributes_match -- test_model - ### Create a standalone executable using PyInstaller ``` diff --git a/class_relationship_diagram.png b/class_relationship_diagram.png deleted file mode 100644 index 27838c5..0000000 Binary files a/class_relationship_diagram.png and /dev/null differ diff --git a/environments/macos-environment.yml b/environments/macos-environment.yml index 76ac584..9a6d6a6 100644 --- a/environments/macos-environment.yml +++ b/environments/macos-environment.yml @@ -1,35 +1,52 @@ name: tactool-macos channels: + - conda-forge - defaults dependencies: + - blas=2.121=openblas + - blas-devel=3.9.0=21_osx64_openblas - bzip2=1.0.8=h1de35cc_0 - - ca-certificates=2023.05.30=hecd8cb5_0 + - ca-certificates=2023.12.12=hecd8cb5_0 + - libblas=3.9.0=21_osx64_openblas + - libcblas=3.9.0=21_osx64_openblas + - libcxx=16.0.6=hd57cbcb_0 + - libexpat=2.5.0=hf0c8a7f_1 - libffi=3.4.4=hecd8cb5_0 + - libgfortran=5.0.0=13_2_0_h97931a8_3 + - libgfortran5=13.2.0=h2873a65_3 + - liblapack=3.9.0=21_osx64_openblas + - liblapacke=3.9.0=21_osx64_openblas + - libopenblas=0.3.26=openmp_hfef2a42_0 + - libsqlite=3.45.1=h92b6c6a_0 + - libzlib=1.2.13=h8a1eda9_5 + - llvm-openmp=17.0.6=hb6ac08f_0 - ncurses=6.4=hcec6c5f_0 - - openssl=3.0.9=hca72f7f_0 - - pip=23.1.2=py311hecd8cb5_0 - - python=3.11.3=hf27a42d_1 + - numpy=1.26.4=py311hc43a94b_0 + - openblas=0.3.26=openmp_h6794695_0 + - openssl=3.2.1=hd75f5a5_0 + - pip=24.0=pyhd8ed1ab_0 + - python=3.11.7=h9f0c242_1_cpython + - python_abi=3.11=4_cp311 - readline=8.2=hca72f7f_0 - - setuptools=67.8.0=py311hecd8cb5_0 - - sqlite=3.41.2=h6c40b1e_0 - - tk=8.6.12=h5d9f67b_0 - - tzdata=2023c=h04d1e81_0 - - wheel=0.38.4=py311hecd8cb5_0 - - xz=5.4.2=h6c40b1e_0 - - zlib=1.2.13=h4dc903c_0 + - setuptools=68.2.2=py311hecd8cb5_0 + - tk=8.6.13=h1abcd95_1 + - tzdata=2023d=h04d1e81_0 + - wheel=0.41.2=py311hecd8cb5_0 + - xz=5.4.5=h6c40b1e_0 - pip: - - altgraph==0.17.3 - - flake8==6.0.0 + - altgraph==0.17.4 + - flake8==7.0.0 - iniconfig==2.0.0 - - macholib==1.16.2 + - macholib==1.16.3 - mccabe==0.7.0 - - packaging==23.1 - - pluggy==1.2.0 - - pycodestyle==2.10.0 - - pyflakes==3.0.1 - - pyinstaller==5.13.0 - - pyinstaller-hooks-contrib==2023.4 - - pyqt5==5.15.9 - - pyqt5-qt5==5.15.2 - - pyqt5-sip==12.12.1 - - pytest==7.4.0 + - packaging==23.2 + - pluggy==1.4.0 + - pycodestyle==2.11.1 + - pyflakes==3.2.0 + - pyinstaller==6.4.0 + - pyinstaller-hooks-contrib==2024.1 + - pyqt5==5.15.10 + - pyqt5-qt5==5.15.12 + - pyqt5-sip==12.13.0 + - pytest==8.0.0 +prefix: /usr/local/miniconda/envs/tactool-macos diff --git a/environments/unversioned-environment.yml b/environments/unversioned-environment.yml new file mode 100644 index 0000000..7cfe2de Binary files /dev/null and b/environments/unversioned-environment.yml differ diff --git a/environments/windows-environment.yml b/environments/windows-environment.yml index 8db7df5..f168fe7 100644 --- a/environments/windows-environment.yml +++ b/environments/windows-environment.yml @@ -1,37 +1,61 @@ name: tactool-windows channels: + - conda-forge - defaults dependencies: + - blas=2.121=openblas + - blas-devel=3.9.0=21_win64_openblas - bzip2=1.0.8=he774522_0 - - ca-certificates=2023.05.30=haa95532_0 + - ca-certificates=2023.12.12=haa95532_0 + - libblas=3.9.0=21_win64_openblas + - libcblas=3.9.0=21_win64_openblas + - libexpat=2.5.0=h63175ca_1 - libffi=3.4.4=hd77b12b_0 - - openssl=3.0.9=h2bbff1b_0 - - pip=23.1.2=py311haa95532_0 - - python=3.11.3=he1021f5_1 - - setuptools=67.8.0=py311haa95532_0 - - sqlite=3.41.2=h2bbff1b_0 - - tk=8.6.12=h2bbff1b_0 - - tzdata=2023c=h04d1e81_0 + - libflang=5.0.0=h6538335_20180525 + - liblapack=3.9.0=21_win64_openblas + - liblapacke=3.9.0=21_win64_openblas + - libopenblas=0.3.26=pthreads_hc140b1d_0 + - libsqlite=3.45.1=hcfcfb64_0 + - libzlib=1.2.13=hcfcfb64_5 + - llvm-meta=5.0.0=0 + - m2w64-gcc-libgfortran=5.3.0=6 + - m2w64-gcc-libs=5.3.0=7 + - m2w64-gcc-libs-core=5.3.0=7 + - m2w64-gmp=6.1.0=2 + - m2w64-libwinpthread-git=5.0.0.4634.697f757=2 + - msys2-conda-epoch=20160418=1 + - numpy=1.26.4=py311h0b4df5a_0 + - openblas=0.3.26=pthreads_h3721920_0 + - openmp=5.0.0=vc14_1 + - openssl=3.2.1=hcfcfb64_0 + - pip=24.0=pyhd8ed1ab_0 + - python=3.11.7=h2628c8c_1_cpython + - python_abi=3.11=4_cp311 + - setuptools=68.2.2=py311haa95532_0 + - tk=8.6.13=h5226925_1 + - tzdata=2023d=h04d1e81_0 + - ucrt=10.0.22621.0=h57928b3_0 - vc=14.2=h21ff451_1 - - vs2015_runtime=14.27.29016=h5e58377_2 - - wheel=0.38.4=py311haa95532_0 - - xz=5.4.2=h8cc25b3_0 - - zlib=1.2.13=h8cc25b3_0 + - vc14_runtime=14.38.33130=h82b7239_18 + - vs2015_runtime=14.38.33130=hcb4865c_18 + - wheel=0.41.2=py311haa95532_0 + - xz=5.4.5=h8cc25b3_0 - pip: - - altgraph==0.17.3 + - altgraph==0.17.4 - colorama==0.4.6 - - flake8==6.0.0 + - flake8==7.0.0 - iniconfig==2.0.0 - mccabe==0.7.0 - - packaging==23.1 + - packaging==23.2 - pefile==2023.2.7 - - pluggy==1.2.0 - - pycodestyle==2.10.0 - - pyflakes==3.0.1 - - pyinstaller==5.13.0 - - pyinstaller-hooks-contrib==2023.4 - - pyqt5==5.15.9 + - pluggy==1.4.0 + - pycodestyle==2.11.1 + - pyflakes==3.2.0 + - pyinstaller==6.4.0 + - pyinstaller-hooks-contrib==2024.1 + - pyqt5==5.15.10 - pyqt5-qt5==5.15.2 - - pyqt5-sip==12.12.1 - - pytest==7.4.0 + - pyqt5-sip==12.13.0 + - pytest==8.0.0 - pywin32-ctypes==0.2.2 +prefix: C:\Miniconda\envs\tactool-windows diff --git a/instructions.md b/instructions.md index 9c4bf04..1d217b7 100644 --- a/instructions.md +++ b/instructions.md @@ -1,154 +1,181 @@ -## Toolbar - File +## Analysis Points -To access file functionality, press the _File_ button in the toolbar, located at the top left of the application. +### Creating Analysis Points -### Import Image +With your analysis point settings already defined, you can `Left Click` anywhere on the currently loaded image to place an analysis point at that location. +Analysis points will be displayed in your selected `colour`, along with their assigned `id` value and selected `label`. -_Import an image into the application._ +### Removing Analysis Points -- Press the _Import Image_ button. -- Select a image file using the file picker. +You can `Right Click` anywhere within an existing analysis point circle to remove the analysis point. If you have multiple analysis point stacked on top of one another, the last analysis point placed will be the analysis point at the top of the stack. Therefore, when using `Right Click` on a stack of analysis point, the analysis point at the top of the stack will be removed first. -### Export Image +### Modifying Analysis Points Settings -_Export the current image with the current analysis points added to it._ +You can `Left Click` on the `id` value of an existing analysis point within the table data. Doing so will set all analysis point settings to the be the same as the settings of the selected and existing analysis point. -- Press the _Export Image_ button. -- Locate the directory you wish to export the image file to. -- Input the filename for the image file. +## Analysis Points Table Data -By default, the exported file will be a PNG file. However, you can add your own file extension to the filename if you wish to create a different file type. +### Displayed Data -### Import TACtool CSV +Data which is displayed within the table at the bottom of the window displays the list of analysis point currently on the image. This data includes the following: +- `id` + - The id value assigned to the analysis point. This is automatically incremented when new analysis points are added. +- `label` + - The label assigned to the analysis point. Either `RefMark` or `Spot`. +- `x` + - The x coordinate location of the analysis point on the image. +- `y` + - The y coordinate location of the analysis point on the image. +- `diameter` + - The diameter of the analysis point measured in `µm`. +- `scale` + - The scale of the analysis point measured in `Pixels per µm`. +- `colour` + - The colour of the analysis point, represented in a Hex Colour Code. +- `sample_name` + - The sample name assigned to the analysis point. +- `mount_name` + - The mount name assigned to the analysis point. +- `material` + - The material assigned to the analysis point. +- `notes` + - Any notes assigned to the analysis point. + +The following fields can be modified after placing an analysis point. To modify these values, `Left Click` twice on the cell containing the value you would like to modify. Once you have inputted your new value, press `Enter` to confirm the change. +- `label` +- `sample_name` +- `mount_name` +- `material` +- `notes` -_Import a CSV file of previously exported TACtool analysis point data._ +## Analysis Point Metadata and Settings -- Press the _Import TACtool CSV_ button. -- Select a CSV file using the file picker. +When creating a new analysis point, any current metadata field values and analysis point settings will be applied to that new analysis point. Therefore, analysis point metadata fields and settings should be selected before an analysis point is created. -The selected CSV must be a file of previously saved coordinates, i.e. a TACtool CSV format. +### Metadata Fields -### Export TACtool CSV +To change a metadata field, `Left Click` on the input box of the metadata field you would like to change and type in your value. -_Export the current analysis point data to a TACtool CSV file._ +### Label -- Press the _Export TACtool CSV_ button. -- Locate the directory you wish to export the CSV file to. -- Input the filename for the CSV file. +To change the `label`, click on the drop-down menu next to `Label` and select your label. A `label` can be either `RefMark` or `Spot`. -By default, the exported file will be a CSV file. However, you can add your own file extension to the filename if you wish to create a different file type, though this is not recommended. +_Note: A `label` value can be changed after analysis point creation. Simply `Left Click` twice on the cell containing the value you would like to modify. Once you have inputted your new value, press `Enter` to confirm the change._ -Upon export, the **sample_name** and **id** columns will be concatenated into a single column labeled **Name**, using the character pattern **_#** to join them. +### Colour -## User Interface Buttons +To change the `colour`, click on the colour box next to `Colour` and use the colour picker to select a colour. -### Clear Points +### Diameter -_Clear all of the currently existing analysis points._ +To change the `diameter`, either use the up/down arrows next to the `Diameter` input box, or type in your own value. It must be a whole number. -### Reset IDs +_Note: The `Diameter` is measured in `µm`_ -_Reset the ID values of the currently existing analysis points. This will make the ID values increment sequentially, starting from 1._ +### Scale -### Reset Settings +To set the scale, complete the following steps: -_Reset the current analysis point settings to default._ -- _sample_name = None_ -- _mount_name = None_ -- _material = None_ -- _colour = yellow/#ffff00_ -- _diameter = 10_ -- _scale = 1.0_ +- Press the `Set Scale` button. A _Set Scale_ window will then open. +- The image on the main window will become slightly grey. This means you can now draw a line across the image. Start by clicking once to create the start of the line and clicking again to create the end of the line. Pressing the `Clear` button will remove any current lines. +- Now you can change the estimated distance in microns. To do this, either use the up/down arrows next to the `Distance` input box, or type in your own value. It must be a whole number. +- Pressing `OK` will confirm the new `scale` and close the _Set Scale_ window. -## Analysis Point Metadata and Settings +_Note: The `Scale` is measured in `Pixels per µm`_ -When creating a new analysis point, any current metadata field values and analysis point settings will be applied to that new analysis point. Therefore analysis point metadata fields and settings should be selected before an analysis point is created. +## Image Navigation -### Metadata Fields +To avoid issues image navigation issues, it is recommended to tab into the image viewer before attempting to navigate it. To do this, simply place your mouse over the current image, and press the `Middle Mouse Button`. -To change a metadata field, _Left Click_ on the input box of the metadata field you would like to change and type in your value. +To enable Image Navigation, hold down the `Ctrl` key. +Image Navigation mode will be automatically disabled when you stop holding the `Ctrl` key. -### Label +### Zooming -To change the label, click on the drop-down menu next to _Label_ and select your label. A label can be either "RefMark" or "Spot". +Whilst Image Navigation is enabled, simply use the mouse scroll wheel. Scrolling `down` will zoom out, whilst scrolling `up` will zoom in. -_A label value can be changed after analysis point creation. Simply Left Click twice on the cell containing the value you would like to modify. Once you have inputted your new value, press Enter to confirm the change._ +### Panning -### Colour +Whilst Image Navigation is enabled, hold down `Left Click` on the current image and move your mouse to pan the image. -To change the colour, click on the colour box next to _Colour_ and use the colour picker to select a colour. +**OR** -### Diameter +You can use the arrow keys to move across the image in the corresponding direction. -To change the diameter, either use the up/down arrows next to the _Diameter_ input box, or type in your own value. It must be a whole number. +## User Interface Buttons -_Note: The **Diameter** is measured in **µm**_ +### Clear Points -### Scale +_Clear all of the currently existing analysis points._ -To set the scale, complete the following steps: +### Reset IDs -- Press the _Set Scale_ button. A _Set Scale_ window will then open. -- The image on the main window will become slightly grey. This means you can now draw a line across the image. Start by clicking once to create the start of the line and clicking again to create the end of the line. Pressing the _Clear_ button will remove any current lines. -- Now you change the estimated distance in microns. To do this, either use the up/down arrows next to the _Distance_ input box, or type in your own value. It must be a whole number. -- Pressing _OK_ will confirm the new scale and close the _Set Scale_ window. +_Reset the `id` values of the currently existing analysis points. This will make the `id` values increment sequentially, starting from `1`._ -_Note: The **Scale** is measured in **Pixels per µm**_ +### Reset Settings -## Analysis Points +_Reset the current analysis point settings to default._ +- `sample_name` = `None` +- `mount_name` = `None` +- `material` = `None` +- `colour` = `yellow`/`#ffff00` +- `diameter` = `10` +- `scale` = `1.0` -### Creating Analysis Points +## Toolbar - File -With your analysis point settings already defined, you can _Left Click_ anywhere on the currently loaded image to place an analysis point at that location. -Analysis points will be displayed in your selected colour, along with their assigned ID value and selected label. +To access file functionality, press the `File` button in the toolbar, located at the top left of the application. -### Removing Analysis Points +### Import Image -You can _Right Click_ anywhere within an existing analysis point circle to remove the analysis point. If you have multiple analysis point stacked on top of one another, the last analysis point placed will be the analysis point at the top of the stack. Therefore, when using _Right Click_ on a stack of analysis point, the analysis point at the top of the stack will be removed first. +_Import an image into the application._ -### Modifying Analysis Points Settings +- Press the `Import Image` button. +- Select an image file using the file picker. -You can _Left Click_ on the _id_ value of an existing analysis point within the table data. Doing so will set all analysis point settings to the be the same as the settings of the selected and existing analysis point. +### Export Image -## Analysis Points Table Data +_Export the current image with the current analysis points added to it._ -### Displayed Data +- Press the `Export Image` button. +- Locate the directory you wish to export the image file to. +- Input the filename for the image file. -Data which is displayed within the table at the bottom of the window displays the list of analysis point currently on the image. This data includes the following: -- id _(The ID value assigned to the analysis point. This is automatically incremented when new analysis points are added.)_ -- label _(The label assigned to the analysis point. Either "RefMark" or "Spot".)_ -- x _(The x coordinate location of the analysis point on the image.)_ -- y _(The y coordinate location of the analysis point on the image.)_ -- diameter _(The diameter of the analysis point measured in **µm**.)_ -- scale _(The scale of the analysis point measured in **Pixels per µm**.)_ -- colour _(The colour of the analysis point, represented in a Hex Colour Code.)_ -- sample_name _(The sample name assigned to the analysis point.)_ -- mount_name _(The mount name assigned to the analysis point.)_ -- material _(The material assigned to the analysis point)_ -- notes _(Any notes assigned to the analysis point.)_ - -The following fields can be modified after placing an analysis point. To modify these values, _Left Click_ twice on the cell containing the value you would like to modify. Once you have inputted your new value, press _Enter_ to confirm the change. -- _label_ -- _sample_name_ -- _mount_name_ -- _material_ -- _notes_ +By default, the exported file will be a `PNG` file. However, you can add your own file extension to the filename if you wish to create a different file type. -## Image Navigation +### Import TACtool CSV -To avoid issues image navigation issues, it is recommended to tab into the image viewer before attempting to navigate it. To do this, simply place your mouse over the current image, and press the _Middle Mouse Button_. +_Import a `CSV` file of previously exported TACtool analysis point data._ -To enable Image Navigation, hold down the _Ctrl_ key. -Image Navigation mode will be automatically disabled when you stop holding the _Ctrl_ key. +- Press the `Import TACtool CSV` button. +- Select a `CSV` file using the file picker. -### Zooming +The selected `CSV` must be a file of previously saved coordinates, i.e. a TACtool CSV format. -Whilst Image Navigation is enabled, simply use the mouse scroll wheel. Scrolling _down_ will zoom out, whilst scrolling _up_ will zoom in. +### Export TACtool CSV -### Panning +_Export the current analysis point data to a TACtool CSV file._ -Whilst Image Navigation is enabled, hold down _Left Click_ on the current image and move your mouse to pan the image. +- Press the `Export TACtool CSV` button. +- Locate the directory you wish to export the `CSV` file to. +- Input the filename for the `CSV` file. -**OR** +By default, the exported file will be a `CSV` file. However, you can add your own file extension to the filename if you wish to create a different file type, though this is not recommended. -You can use the arrow keys to move across the image in the corresponding direction. +_Note: Upon export, the `sample_name` and `id` columns will be concatenated into a single column labelled `Name`, using the character pattern `_#` to join them._ + +### Import and Recoordinate SEM CSV + +_Import and recoordinate a given SEM CSV file, using the current reference points in TACtool._ + +- Ensure you currently have 3 analysis points with the label `RefMark` placed in TACtool. +- Press the `Import and Recoordinate SEM CSV` button. +- Select an input `CSV` file by clicking on the `Select Input CSV` button and then use the file picker. +- Press the `Import and Recoordinate` button. +- The SEM points from the given CSV file will then be imported as Analysis Points and recoordinated based on the initially placed reference points. + +_Notes:_ +- _Imported SEM points will retain their existing `Particle ID` values, as they will be used to assign the Analysis Point `id` values._ +- _When SEM points are imported from a CSV file, it is assumed that the origin for their coordinates will be **top right**, but the origin in TACtool is **top left**. To account for this, `SEM` coordinates automatically have their `x` axis inverted according to the currently loaded image, thus making their effective origin **top left**._ +- _When the SEM points are imported using this method, they will adopt any of the current Analysis Point settings applied in the TACtool window._ +- _If there are more than `3` analysis points with the label `RefMark` in TACtool, the recoordination process will only use the first `3` reference points from the Analysis Points Table Data._ diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index e382d57..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -addopts = --pdbcls=IPython.terminal.debugger:Pdb \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a6e17af..0000000 --- a/requirements.txt +++ /dev/null @@ -1,18 +0,0 @@ -altgraph==0.17.3 -attrs==22.2.0 -colorama==0.4.6 -flake8==6.0.0 -iniconfig==2.0.0 -mccabe==0.7.0 -packaging==23.0 -pefile==2023.2.7 -pluggy==1.0.0 -pycodestyle==2.10.0 -pyflakes==3.0.1 -pyinstaller==5.7.0 -pyinstaller-hooks-contrib==2023.1 -pyqt5==5.15.7 -pyqt5-qt5==5.15.2 -pyqt5-sip==12.11.1 -pytest==7.2.1 -pywin32-ctypes==0.2.0 \ No newline at end of file diff --git a/tactool/analysis_point.py b/tactool/analysis_point.py new file mode 100644 index 0000000..5f36f5f --- /dev/null +++ b/tactool/analysis_point.py @@ -0,0 +1,296 @@ +import dataclasses +from csv import ( + DictReader, + writer, +) +from pathlib import Path +from typing import Any + +from PyQt5.QtWidgets import ( + QGraphicsEllipseItem, + QGraphicsTextItem, +) + +SEM_HEADERS = { + "id_header": "Particle ID", + "ref_col": "Mineral Classification", + "x_header": "Laser Ablation Centre X", + "y_header": "Laser Ablation Centre Y", +} + + +@dataclasses.dataclass +class AnalysisPoint: + """ + Container class for encapsulating Analysis point data. + """ + # Define the class variables for the Analysis Points + id: int + label: str + x: int + y: int + diameter: int + scale: float + colour: str + sample_name: str + mount_name: str + material: str + notes: str + _outer_ellipse: QGraphicsEllipseItem + _inner_ellipse: QGraphicsEllipseItem + _label_text_item: QGraphicsTextItem + + + @classmethod + def field_names(cls) -> list[str]: + """ + Get the field names of the class object. + """ + return [field.name for field in dataclasses.fields(cls)] + + + def aslist(self) -> list[ + int, + int, + int, + str, + int, + float, + str, + str, + str, + str, + str, + QGraphicsEllipseItem, + QGraphicsEllipseItem, + QGraphicsTextItem, + ]: + """ + Get the attributes of an Analysis Point object as a list. + """ + attributes_list = [ + self.id, + self.label, + self.x, + self.y, + self.diameter, + self.scale, + self.colour, + self.sample_name, + self.mount_name, + self.material, + self.notes, + self._outer_ellipse, + self._inner_ellipse, + self._label_text_item + ] + return attributes_list + + +def parse_tactool_csv(filepath: str, default_settings: dict[str, Any]) -> list[dict[str, Any]]: + """ + Parse the data in a given TACtool CSV file. + """ + # Defining all header name changes, datatypes and default values + fields = { + "Name": { + "new_header": "apid", + "type": int, + "default": 0, + }, + "sample_name": { + "new_header": "sample_name", + "type": str, + "default": "", + }, + "Type": { + "new_header": "label", + "type": str, + "default": "", + }, + "X": { + "new_header": "x", + "type": int, + "default": 0, + }, + "Y": { + "new_header": "y", + "type": int, + "default": 0, + }, + "diameter": { + "new_header": "diameter", + "type": int, + "default": default_settings["diameter"], + }, + "scale": { + "new_header": "scale", + "type": float, + "default": default_settings["scale"], + }, + "colour": { + "new_header": "colour", + "type": str, + "default": default_settings["colour"], + }, + "mount_name": { + "new_header": "mount_name", + "type": str, + "default": "", + }, + "material": { + "new_header": "material", + "type": str, + "default": "", + }, + "notes": { + "new_header": "notes", + "type": str, + "default": "", + }, + } + + ap_dicts = [] + with open(filepath) as csv_file: + reader = DictReader(csv_file) + # Iterate through each line in the CSV file + for id, item in enumerate(reader): + # The default ID value is incremented with the row number + fields["Name"]["default"] = id + 1 + ap_dict = parse_row_data(item, fields) + ap_dicts.append(ap_dict) + + return ap_dicts + + +def parse_row_data(item: dict[str, Any], fields: dict[str, dict[str, Any]]) -> dict[str, Any]: + """ + Parse the data of an Analysis Point row item in a CSV file. + Takes a 'fields' dictionary which contains data on column renaming, default values and datatypes. + """ + # Split the id and sample_name value from the Name column + if "_#" in item["Name"]: + item["sample_name"], item["Name"] = item["Name"].rsplit("_#", maxsplit=1) + + # If there is a Z column which is requried for the laser, then remove it + if "Z" in item: + item.pop("Z") + + ap_dict = {} + for field, field_data in fields.items(): + # If the field is found and it is not empty, use it + # Otherwise, use the default value + if field in item and item[field] != "": + new_value = item[field] + else: + new_value = field_data["default"] + + # Add the field value to the new dictionary + # using the new header and converting it to the correct type + ap_dict[field_data["new_header"]] = field_data["type"](new_value) + return ap_dict + + +def export_tactool_csv(filepath: str, headers: list[str], analysis_points: list[AnalysisPoint]) -> None: + """ + Write the given header data and analysis points to the given filepath. + This is specifically for TACtool Analysis Point data. + """ + with open(filepath, "w", newline="") as csvfile: + csvwriter = writer(csvfile) + # Modify and write the header data + new_headers = convert_export_headers(headers) + csvwriter.writerow(new_headers) + for analysis_point in analysis_points: + csv_row = convert_export_point(analysis_point, headers) + csvwriter.writerow(csv_row) + + +def convert_export_headers(headers: list[str]) -> list[str]: + """ + Convert the header data for a CSV export. + This will rename some headers, remove sample_name, and add a Z field. + """ + header_conversions = { + "id": "Name", + "label": "Type", + "x": "X", + "y": "Y", + } + new_headers = [ + header_conversions[old_header] + if old_header in header_conversions + else old_header + for old_header in headers + ] + # Remove the sample_name field, it is concatenated with ID + new_headers.pop(new_headers.index("sample_name")) + + # Insert a new Z column after the Y column for the laser formatting + z_index = new_headers.index("Y") + 1 + new_headers.insert(z_index, "Z") + return new_headers + + +def convert_export_point(analysis_point: AnalysisPoint, headers: list[str]) -> list: + """ + Convert an Analysis Point for a CSV export. + This will concatenate the ID and sample_name into a single field, + and add a Z field. + """ + analysis_point_row = analysis_point.aslist()[:len(headers)] + + # Concat the sample_name and id into 1 column + # Also pads zeros on id column value + analysis_point_row[headers.index("id")] = f"{analysis_point.sample_name}_#{analysis_point.id:03d}" + analysis_point_row.pop(headers.index("sample_name")) + + # Insert a new Z column after the Y column for the laser formatting + analysis_point_row.insert(headers.index("y") + 1, 0) + return analysis_point_row + + +def parse_sem_csv(filepath: str | Path) -> list[dict[str, str | int | float]]: + """ + Parse an SEM CSV file into a list of dictionaries. + We only retain the integer ID value and the coordinates of the points. + The ID can be used later in the lab workflow to re-link the points to extra metadata. + """ + point_dicts = [] + with open(filepath) as csv_file: + reader = DictReader(csv_file) + + # Check that the given CSV file has the required headers + for header in SEM_HEADERS.values(): + if header not in reader.fieldnames: + raise KeyError(f"SEM CSV missing required header: {header}") + + # Iterate through each line in the CSV file + for item in reader: + + point_dict = { + "x": float(item[SEM_HEADERS["x_header"]]), + "y": float(item[SEM_HEADERS["y_header"]]), + } + + # Sometimes the ID is not an integer, we ignore those + if item[SEM_HEADERS["id_header"]].isnumeric(): + point_dict["apid"] = int(item[SEM_HEADERS["id_header"]]) + + # Apply the correct label + if item[SEM_HEADERS["ref_col"]] == "Fiducial": + point_dict["label"] = "RefMark" + else: + point_dict["label"] = "Spot" + + point_dicts.append(point_dict) + + return point_dicts + + +def reset_id(analysis_point: AnalysisPoint) -> AnalysisPoint: + """ + Reset the ID value of a given Analysis Point. + """ + analysis_point.id = None + return analysis_point diff --git a/tactool/graphics_scene.py b/tactool/graphics_scene.py index a8d5bc7..ba9202c 100644 --- a/tactool/graphics_scene.py +++ b/tactool/graphics_scene.py @@ -1,7 +1,3 @@ -""" -The Graphics Scene manages elements which are painted onto images. -""" - from typing import Optional from PyQt5.QtCore import ( @@ -25,48 +21,52 @@ QGraphicsTextItem, ) -from tactool.table_model import AnalysisPoint, TableModel +from tactool.analysis_point import AnalysisPoint +from tactool.utils import LoggerMixin -class GraphicsScene(QGraphicsScene): +class GraphicsScene(QGraphicsScene, LoggerMixin): """ PyQt QGraphicsScene with convenience functions for Analysis Point data. + Manages elements which are painted onto images. """ def __init__(self) -> None: super().__init__() - # _maximum_point_id is used to track the next incremental ID value available - self._maximum_point_id = 0 # Defining variables used in the Graphics Scene for scaling mode - self.scaling_rect: Optional[QGraphicsRectItem] = None self.scaling_group: Optional[QGraphicsItemGroup] = None self.scaling_line: Optional[QGraphicsLineItem] = None - - self.table_model = TableModel() + self.transparent_window: Optional[QGraphicsRectItem] = None def add_analysis_point( - self, - x: int, - y: int, - label: str, - diameter: int, - colour: str, - scale: float, - notes: str = "", - apid: int = None, - sample_name: str = "", - mount_name: str = "", - material: str = "", - ) -> AnalysisPoint: - """ - Function to draw an Analysis Point onto the Graphics Scene and - add it's data to the Table Model. - """ + self, + x: int, + y: int, + apid: int, + label: str, + diameter: int, + colour: str, + scale: float, + ghost: bool = False, + ) -> tuple[QGraphicsEllipseItem, QGraphicsEllipseItem, QGraphicsTextItem]: + """ + Draw an Analysis Point onto the Graphics Scene. + Returns the newly created graphics items. + """ + # The alpha changes for ghost points + if ghost: + alpha = 100 + else: + alpha = 200 + self.logger.debug("Adding new Analysis Point") + # Set the drawing colours to use the given colour # pen just provides an outline of an object # brush also fills the object - pen = QPen(QColor(colour)) + qcolour = QColor(colour) + qcolour.setAlpha(alpha) + pen = QPen(qcolour) brush = QBrush(pen.color()) # Calculate the scaled diameter size in pixels based on the given scale @@ -90,128 +90,95 @@ def add_analysis_point( pen, ) - # If no analysis point ID is given, assign it the next ID available - # Else, the next available ID requires incrementing anyway due to - # the additional point being added - if not apid: - apid = self.next_point_id - else: - _ = self.next_point_id - # Set the label text of the point # Use the given label if there is one, else use the point ID label_text = f"{apid}_{label}" if label else str(apid) # Create the label as a PyQt graphic and set it's attributes appropriately label_text_item = QGraphicsTextItem(label_text) label_text_item.setPos(x, y) - label_text_item.setDefaultTextColor(QColor(colour)) + label_text_item.setDefaultTextColor(qcolour) label_text_item.setFont(QFont("Helvetica", 14)) # Add the label to the Graphics Scene self.addItem(label_text_item) - # Place the new point data into an Analysis Point object - point_data = AnalysisPoint( - apid, - label, - x, - y, - diameter, - scale, - colour, - sample_name, - mount_name, - material, - notes, - outer_ellipse, - inner_ellipse, - label_text_item, - ) - # Add the Analysis Point to the Table Model - self.table_model.add_point(point_data) - return point_data + return outer_ellipse, inner_ellipse, label_text_item - def remove_analysis_point(self, x: int, y: int, apid: int) -> Optional[int]: + def remove_analysis_point( + self, + ap: AnalysisPoint, + log: bool = True, + ) -> None: """ - Function to remove an Analysis Point from the Graphics Scene based on it's coordinates. + Remove the QGraphics items belonging to the given AnalysisPoint from the GraphicsScene. """ - analysis_point = None - # If a target ID is provided, get the Analysis Point using it's ID - if apid: - analysis_point = self.table_model.get_point_by_apid(apid) + if log: + self.logger.debug("Removing graphics items for analysis point ID: %s", ap.id) + for item in [ap._inner_ellipse, ap._outer_ellipse, ap._label_text_item]: + self.removeItem(item) - # Else when the user right clicks on the Graphics View to remove an Analysis Point - elif x and y: - # Get the ellipse and check it exists - ellipse = self.get_ellipse_at(x, y) - if ellipse: - # Get the corresponding Analysis Point object of the ellipse - analysis_point = self.table_model.get_point_by_ellipse(ellipse) - # If an Analysis Point is found - if analysis_point: - self.table_model.remove_point(analysis_point.id) - # Remove the ellipse elements from the PyQt Graphics Scene - self.removeItem(analysis_point._outer_ellipse) - self.removeItem(analysis_point._inner_ellipse) - self.removeItem(analysis_point._label_text_item) - return analysis_point.id + def move_analysis_point( + self, + ap: AnalysisPoint, + x_change: int, + y_change: int, + ) -> None: + """ + Move the QGraphics items belonging to the given AnalysisPoint on the GraphicsScene. + """ + for item in [ap._inner_ellipse, ap._outer_ellipse, ap._label_text_item]: + item.moveBy(x_change, y_change) def get_ellipse_at(self, x: int, y: int) -> Optional[QGraphicsEllipseItem]: """ - Function to get an Ellipse Item from the Graphics Scene at the given coordinates. + Get an Ellipse Item from the Graphics Scene at the given coordinates. """ - # Using list comprehension to iterate through the existing Analysis Points # Using PyQt QGraphicsScene selection functions to find the Analysis Points at the given coordinates # Only adding it to the list if it is an Ellipse item ellipse_items = [ - item for item in self.items(QRectF(x, y, 2, 2), - Qt.ItemSelectionMode(Qt.IntersectsItemShape), - Qt.SortOrder(Qt.DescendingOrder)) - if type(item) is QGraphicsEllipseItem + item + for item in self.items( + QRectF(x, y, 2, 2), + Qt.ItemSelectionMode(Qt.IntersectsItemShape), + Qt.SortOrder(Qt.DescendingOrder), + ) + if isinstance(item, QGraphicsEllipseItem) ] # If there are items in the list, return the first as it will be the target ellipse return ellipse_items[0] if ellipse_items else None - @property - def next_point_id(self) -> int: - """ - Function to iterate and return the maximum Analysis Point ID value. - """ - self._maximum_point_id += 1 - return self._maximum_point_id - - def toggle_transparent_window(self, graphics_view_image: QGraphicsPixmapItem) -> None: """ - Function to toggle a transparent grey overlay ontop of the image when entering scaling mode. + Toggle a transparent grey overlay ontop of the image for scaling mode. """ - if not self.scaling_rect: + if self.transparent_window is not None: + self.logger.debug("Removing transparent window") + # Remove the PyQt Rect from the PyQt Item Group and reset the scaling_rect variable + self.removeItem(self.transparent_window) + self.transparent_window = None + else: + self.logger.debug("Adding transparent window") # Convert the current image to a pixmap image_pixmap = graphics_view_image.pixmap() image_width, image_height = image_pixmap.width(), image_pixmap.height() # Create a PyQt Rect Item matching the size of the current image - self.scaling_rect = QGraphicsRectItem(QRectF(0, 0, image_width, image_height)) + self.transparent_window = QGraphicsRectItem(QRectF(0, 0, image_width, image_height)) # Set the Rect Item to be 50% transparent and grey in colour - self.scaling_rect.setOpacity(0.5) - self.scaling_rect.setBrush(Qt.gray) + self.transparent_window.setOpacity(0.5) + self.transparent_window.setBrush(Qt.gray) # Creating a PyQt Item Group to store all Graphics Scene scaling items within one variable self.scaling_group = self.createItemGroup([]) - self.scaling_group.addToGroup(self.scaling_rect) - # Else there is currently a transparent PyQt Rect - else: - # Remove the PyQt Rect from the PyQt Item Group and reset the scaling_rect variable - self.removeItem(self.scaling_rect) - self.scaling_rect = None + self.scaling_group.addToGroup(self.transparent_window) def draw_scale_line(self, start_point: float, end_point: float) -> None: """ - Function to draw or redraw the scale line when in scaling mode. + Draw or redraw the scale line when in scaling mode. """ # If there is current a line, then remove it from the PyQt Item Group and # reset the scaling_line variable @@ -234,7 +201,7 @@ def draw_scale_line(self, start_point: float, end_point: float) -> None: def draw_scale_point(self, x: int, y: int) -> None: """ - Function to draw an ellipse at the given coordinates. + Draw an ellipse at the given coordinates. Used for drawing small ellipse' at both ends of the scaling line. """ # Set the drawing mode to use a PyQt Pen in red @@ -248,14 +215,15 @@ def draw_scale_point(self, x: int, y: int) -> None: def remove_scale_items(self) -> None: """ - Function to remove all items from the Graphics Scene associated with the scaling mode. + Remove all items from the Graphics Scene associated with the scaling mode. """ + self.logger.debug("Removing scaling items") # If there are items in the PyQt Item Group for scaling items if self.scaling_group: # Iterate through the items and remove them if they are not # the transparet PyQt Rect, this is removed separately for item in self.scaling_group.childItems(): - if item != self.scaling_rect: + if item != self.transparent_window: self.removeItem(item) # Reset the scaling_line variable self.scaling_line = None diff --git a/tactool/graphics_view.py b/tactool/graphics_view.py index 805022b..0fe8ff1 100644 --- a/tactool/graphics_view.py +++ b/tactool/graphics_view.py @@ -1,10 +1,7 @@ -""" -The Graphics View is the user interface element in charge of image interaction. -It can load and save images and is responsible for capturing mouse events. -""" - import math +from typing import Optional + from PyQt5.QtCore import ( pyqtSignal, QPointF, @@ -26,10 +23,12 @@ QGraphicsView, ) +from tactool.analysis_point import AnalysisPoint from tactool.graphics_scene import GraphicsScene +from tactool.utils import LoggerMixin -class GraphicsView(QGraphicsView): +class GraphicsView(QGraphicsView, LoggerMixin): """ PyQt QGraphicsView with convenience functions for modifications. Also includes functions for user interaction with the Graphics View. @@ -42,6 +41,9 @@ class GraphicsView(QGraphicsView): # Tracks the users mouse movement on the Graphics View whilst in scaling mode scale_move_event = pyqtSignal(float) + # Tracks the position for a ghost analysis point + move_ghost_point = pyqtSignal(int, int) + def __init__(self) -> None: super().__init__() @@ -50,11 +52,13 @@ def __init__(self) -> None: self._empty = True # This stores the current image of the PyQt Graphics View as a PyQt Pixmap Item self._image = QGraphicsPixmapItem() + self.disable_analysis_points = False self.navigation_mode = False # Setting scaling variables - self.set_scale_mode = False + self.scaling_mode = False self.scale_start_point = QPointF() self.scale_end_point = QPointF() + self.ghost_point: Optional[AnalysisPoint] = None # Create the Graphics Scene which is displayed in the Graphics View self.graphics_scene = GraphicsScene() @@ -66,7 +70,7 @@ def __init__(self) -> None: def mousePressEvent(self, event: QMouseEvent) -> None: """ - Function to handle mouse clicking interaction events with the Graphics View. + Handler for mouse clicking interaction events with the Graphics View. Since we are only adding functionality to mousePressEvent, we pass the event to the parent PyQt class, QGraphicsView, at the end of the function to handle @@ -76,7 +80,7 @@ def mousePressEvent(self, event: QMouseEvent) -> None: if self._image.isUnderMouse(): clicked_point = self.mapToScene(event.pos()).toPoint() - if self.set_scale_mode: + if self.scaling_mode: # If there is no current start point of a scaling line if self.scale_start_point.isNull(): # Set the start point of the scaling line to be the clicked point @@ -91,7 +95,7 @@ def mousePressEvent(self, event: QMouseEvent) -> None: # Call the Graphics Scene function to draw the point self.graphics_scene.draw_scale_point(clicked_point.x(), clicked_point.y()) - elif not self.navigation_mode: + elif not self.navigation_mode and not self.disable_analysis_points: clicked_button = event.button() if clicked_button == Qt.LeftButton: @@ -104,27 +108,40 @@ def mousePressEvent(self, event: QMouseEvent) -> None: def mouseMoveEvent(self, event: QMouseEvent) -> None: """ - Function to handle mouse movement interaction events with the Graphics View. + Handler for mouse movement interaction events with the Graphics View. Since we are only adding functionality to mouseMoveEvent, we pass the event to the parent PyQt class, QGraphicsView, at the end of the function to handle all other event occurences. """ - if self.set_scale_mode: + event_position = self.mapToScene(event.pos()).toPoint() + + # Check to ensure a ghost point is not left behind when it shouldn't exist + if (self.disable_analysis_points and self.ghost_point is not None) or not self._image.isUnderMouse(): + self.remove_ghost_point() + + if self.scaling_mode: # If there is a current start point but not an end point of a scaling line if not self.scale_start_point.isNull() and self.scale_end_point.isNull(): # Emit a signal that the mouse has been moved # Passing the start point of the scaling line and the coordinates of the mouse - start, end = self.scale_start_point, self.mapToScene(event.pos()).toPoint() + start, end = self.scale_start_point, event_position pixel_distance = round(math.sqrt((start.y() - end.y())**2 + (start.x() - end.x())**2), 2) self.graphics_scene.draw_scale_line(start, end) self.scale_move_event.emit(pixel_distance) + + # Check if ghost points should be active + if not self.disable_analysis_points: + # If the cursor is on the image and navigation mode is not enabled + if self._image.isUnderMouse() and not self.navigation_mode: + # Add a new ghost point + self.move_ghost_point.emit(event_position.x(), event_position.y()) super().mouseMoveEvent(event) def wheelEvent(self, event: QWheelEvent) -> None: """ - Function to handle mouse scroll wheel interaction events with the Graphics View. + Handler for mouse scroll wheel interaction events with the Graphics View. The function does not pass the event back to the parent class PyQt QGraphicsView because the default wheelEvent triggers the scrolling of the Graphics View. @@ -159,7 +176,7 @@ def wheelEvent(self, event: QWheelEvent) -> None: def keyPressEvent(self, event: QKeyEvent) -> None: """ - Function to handle keyboard press events. + Handler for keyboard press events. Since we are only adding functionality to keyPressEvent, we pass the event to the parent PyQt class, QGraphicsView, at the end of the function to handle @@ -172,12 +189,13 @@ def keyPressEvent(self, event: QKeyEvent) -> None: # Enable navigation mode self.navigation_mode = True self.setDragMode(QGraphicsView.ScrollHandDrag) + self.remove_ghost_point() super().keyPressEvent(event) def keyReleaseEvent(self, event: QKeyEvent) -> None: """ - Function to handle keyboard release events. + Handler for keyboard release events. Since we are only adding functionality to keyReleaseEvent, we pass the event to the parent PyQt class, QGraphicsView, at the end of the function to handle @@ -193,7 +211,7 @@ def keyReleaseEvent(self, event: QKeyEvent) -> None: def configure_frame(self) -> None: """ - Function to configure the settings of the Graphics View. + Configure the settings of the Graphics View. """ # Sets the Graphics View to anchor it's centre to the current position of the mouse # This applies when zooming into the image @@ -210,8 +228,9 @@ def configure_frame(self) -> None: def load_image(self, filepath: str) -> None: """ - Fuction to load an image from a given path into the Graphics View as a PyQt Pixmap. + Load an image from a given path into the Graphics View as a PyQt Pixmap. """ + self.logger.info("Loading image: %s", filepath) # Load the image into a PyQt Pixmap pixmap = QPixmap(filepath) # Reset the zoom value @@ -231,8 +250,9 @@ def load_image(self, filepath: str) -> None: def save_image(self, filepath: str) -> None: """ - Function to get the current Graphics Scene state and save it to a given file. + Get the current Graphics Scene state and save it to a given file. """ + self.logger.info("Saving current graphics state to: %s", filepath) # If you get the size of the Graphics Scene rather than the Graphics View, # then the saved image includes points which go over the border of the imported image rect = self.sceneRect().toRect() @@ -252,9 +272,9 @@ def save_image(self, filepath: str) -> None: def show_entire_image(self) -> None: """ - Function to show the entirety of the current image in the Graphics View. + Show the entirety of the current image in the Graphics View. """ - # Get a rectf of the current image + # Get a QRectF of the current image rect = QRectF(self._image.pixmap().rect()) # If a rectf object was successfully created if not rect.isNull(): @@ -280,26 +300,36 @@ def show_entire_image(self) -> None: def toggle_scaling_mode(self) -> None: """ - Function to toggle the scaling mode Graphics Scene settings. + Toggle the scaling mode Graphics Scene settings. """ - self.set_scale_mode = not self.set_scale_mode - self.graphics_scene.toggle_transparent_window(self._image) + self.scaling_mode = not self.scaling_mode - # If the program is currently in Scaling mode - if self.set_scale_mode: + if self.scaling_mode: + self.logger.debug("Activating scaling mode") # Set the Graphics View cursor to a crosshair self.setCursor(Qt.CrossCursor) - # Else when the program is not currently in Scaling mode else: - # Reset the scaling mode attributes to normal - self.graphics_scene.remove_scale_items() - self.reset_scale_line_points() + self.logger.debug("Deactivating scaling mode") + self.reset_scaling_elements() self.setCursor(Qt.ArrowCursor) - def reset_scale_line_points(self) -> None: + def reset_scaling_elements(self) -> None: """ - Function to reset the scaling line points to be blank QPointF objects. + Reset the scaling elements back to their default values. """ + self.logger.debug("Reset scaling elements") self.scale_start_point = QPointF() self.scale_end_point = QPointF() + self.graphics_scene.remove_scale_items() + + + def remove_ghost_point(self) -> None: + """ + Remove the current ghost point if it exists. + """ + if self.ghost_point is not None: + ghost_point_id = self.ghost_point.id + self.graphics_scene.remove_analysis_point(self.ghost_point, log=False) + self.ghost_point = None + self.logger.info("Deleted Ghost Point: %s", ghost_point_id) diff --git a/tactool/main.py b/tactool/main.py index 10e9579..76bc337 100644 --- a/tactool/main.py +++ b/tactool/main.py @@ -1,27 +1,39 @@ -""" -Main TACtool application class definition. -""" - import argparse +import logging import sys from PyQt5 import QtGui from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QApplication + +from tactool.graphics_scene import GraphicsScene +from tactool.graphics_view import GraphicsView +from tactool.recoordinate_dialog import RecoordinateDialog +from tactool.set_scale_dialog import SetScaleDialog +from tactool.table_model import TableModel +from tactool.table_view import TableView +from tactool.utils import LoggerMixin from tactool.window import Window -class TACtool(QApplication): +class TACtool(QApplication, LoggerMixin): """ PyQt QApplication class with references to high-level components. + Includes some convenience property methods to aid with testing. """ def __init__( self, args, developer_mode: bool = False, + debug_mode: bool = False, testing_mode: bool = False, ) -> None: super().__init__(args) + + if debug_mode: + LoggerMixin._set_logger_levels(logging.DEBUG) + + self.logger.info("Initialising TACtool application") self.testing_mode = testing_mode self.window = Window(self.testing_mode) @@ -39,15 +51,41 @@ def __init__( def developer_mode(self) -> None: """ - Function to start the program in developer mode. + Start the program in developer mode. """ # Preload an image into the program path = "test/data/test_cl_montage.png" + self.logger.debug("Starting developer mode with image: %s", path) self.window.image_filepath = path self.window.setWindowTitle(f"TACtool: {self.window.image_filepath}") - self.window.graphics_view.load_image(path) + self.graphics_view.load_image(path) # This shows the entirety of a preloaded image in the Graphics View during initialisation - self.window.graphics_view.setTransform(QtGui.QTransform()) + self.graphics_view.setTransform(QtGui.QTransform()) + + + @property + def graphics_view(self) -> GraphicsView: + return self.window.graphics_view + + @property + def graphics_scene(self) -> GraphicsScene: + return self.window.graphics_scene + + @property + def table_model(self) -> TableModel: + return self.window.table_model + + @property + def table_view(self) -> TableView: + return self.window.table_view + + @property + def set_scale_dialog(self) -> SetScaleDialog: + return self.window.set_scale_dialog + + @property + def recoordinate_dialog(self) -> RecoordinateDialog: + return self.window.recoordinate_dialog if __name__ == "__main__": @@ -58,6 +96,12 @@ def developer_mode(self) -> None: action="store_true", help="Developer mode", ) + parser.add_argument( + "--debug", + default=False, + action="store_true", + help="Debug mode", + ) args = parser.parse_args() - tactool_application = TACtool(sys.argv, args.dev) + tactool_application = TACtool(sys.argv, developer_mode=args.dev, debug_mode=args.debug) diff --git a/tactool/recoordinate_dialog.py b/tactool/recoordinate_dialog.py new file mode 100644 index 0000000..362ffce --- /dev/null +++ b/tactool/recoordinate_dialog.py @@ -0,0 +1,249 @@ +import numpy as np + +from PyQt5.QtCore import ( + pyqtSignal, + Qt, + QSize, +) +from PyQt5.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, +) + +from tactool.analysis_point import ( + SEM_HEADERS, + AnalysisPoint, + parse_sem_csv, +) +from tactool.utils import LoggerMixin + + +class RecoordinateDialog(QDialog, LoggerMixin): + """ + PyQt QDialog class for creating the recoordination dialog box. + """ + # Tracks when the Recoordinate Dialog Box is closed + closed_recoordinate_dialog = pyqtSignal() + + def __init__( + self, + testing_mode: bool, + ref_points: list[AnalysisPoint], + image_size: QSize, + ) -> None: + super().__init__() + self.testing_mode = testing_mode + self.ref_points = ref_points + self.image_size = image_size + + # Setting the Dialog Box settings + self.setWindowTitle("Recoordination") + self.setMinimumSize(300, 150) + self.setWindowFlags( + Qt.Window | Qt.WindowCloseButtonHint + ) + self.setup_ui_elements() + self.connect_signals_and_slots() + + # This is used later to save recoordinated points + self.recoordinated_point_dicts: list[dict[str, str | int | float]] = [] + + if not self.testing_mode: + self.show() + + + def setup_ui_elements(self) -> None: + """ + Create the elements of the Set Scale dialog box User Interface. + Also sets the layout for the dialog box. + """ + self.logger.debug("Setting up UI elements") + # Create the UI elements + input_csv_label = QLabel("Input CSV") + self.input_csv_button = QPushButton("Select Input CSV", self) + self.input_csv_filepath_label = QLineEdit("") + self.input_csv_filepath_label.setDisabled(True) + + self.recoordinate_button = QPushButton("Import and Recoordinate") + self.cancel_button = QPushButton("Cancel", self) + + # Arrange the main layout + layout = QVBoxLayout() + layout.addWidget(input_csv_label) + layout.addWidget(self.input_csv_button) + layout.addWidget(self.input_csv_filepath_label) + + # Add the final 2 buttons alongside eachother + bottom_button_layout = QHBoxLayout() + bottom_button_layout.addWidget(self.recoordinate_button) + bottom_button_layout.addWidget(self.cancel_button) + layout.addLayout(bottom_button_layout) + + # Set the layout + self.setLayout(layout) + + + def connect_signals_and_slots(self) -> None: + """ + Function for connecting signals and slots of buttons and input boxes. + """ + self.logger.debug("Connecting signals and slots") + self.input_csv_button.clicked.connect(self.get_input_csv) + self.recoordinate_button.clicked.connect(self.import_and_recoordinate_sem_csv) + self.cancel_button.clicked.connect(self.closeEvent) + + + def get_input_csv(self) -> None: + """ + Get the input CSV file for recoordination from the user. + """ + pyqt_open_dialog = QFileDialog.getOpenFileName( + self, + "Import Recoordination CSV", + filter="*.csv", + ) + input_csv = pyqt_open_dialog[0] + self.input_csv_filepath_label.setText(input_csv) + self.logger.info("Selected input CSV: %s", input_csv) + + + def import_and_recoordinate_sem_csv(self) -> None: + """ + Get the given CSV file, if it is valid then perform the recoordination process. + """ + # Check the given paths + input_csv = self.input_csv_filepath_label.text() + if input_csv == "": + QMessageBox.warning(None, "Invalid Path", "Please select an input SEM CSV first.") + return + + # Get the points from the SEM CSV + try: + self.logger.info("Loading SEM CSV: %s", input_csv) + point_dicts = parse_sem_csv(filepath=input_csv) + except KeyError as error: + self.logger.error(error) + string_headers = "\n".join(SEM_HEADERS.values()) + QMessageBox.warning( + None, + "Invalid CSV File", + f"The given file does not contain the required headers:\n\n{string_headers}", + ) + return + + # Invert all of the X coordinates because the SEM has an origin at the top right + # but TACtool has an origin at the top left + for idx, point_dict in enumerate(point_dicts): + point_dict["x"] = self.image_size.width() - point_dict["x"] + point_dicts[idx] = point_dict + + self.recoordinated_point_dicts = self.recoordinate_sem_points(point_dicts) + self.closeEvent() + + + def recoordinate_sem_points( + self, + point_dicts: list[dict[str, str | int | float]], + ) -> list[dict[str, str | int | float]]: + """ + Recoordinate the given input SEM CSV file points using the current Analysis Points as reference points. + """ + # Calculate the matrix + self.logger.debug("Calculating recoordination matrix") + # For source and dest points, we only use the first 3 reference points + # Format the points into lists of tuples of x and y values + source = [ + (item["x"], item["y"]) + for item in point_dicts + if item["label"] == "RefMark" + ][:3] + dest = [ + (point.x, point.y) + for point in self.ref_points + ][:3] + matrix = affine_transform_matrix(source=source, dest=dest) + + # Apply the matrix + # Track if any of the new points extend the image boundary + extends_boundary = False + for idx, item in enumerate(point_dicts): + point = (item["x"], item["y"]) + new_x, new_y = affine_transform_point(matrix=matrix, point=point) + point_dicts[idx]["x"] = new_x + point_dicts[idx]["y"] = new_y + # Check if the new point extends the image boundary + if new_x > self.image_size.width() or new_x < 0 or new_y > self.image_size.height() or new_y < 0: + extends_boundary = True + + self.logger.debug("Transformed point %s to %s", point, (new_x, new_y)) + self.logger.info("Transformed %s points", len(point_dicts)) + + # Create a message informing the user that the recoordinated points extend the image boundary + if extends_boundary: + message = "At least 1 of the recoordinated points goes beyond the current image boundary" + self.logger.warning(message) + QMessageBox.warning(None, "Recoordination Warning", message) + + return point_dicts + + + def closeEvent(self, event=None) -> None: + """ + Function which is run by PyQt when the application is closed. + """ + self.closed_recoordinate_dialog.emit() + + +""" +Notes on affine transformation: +An affine transformation converts one set of points into another via rotation, +skewing, scaling and translation. It can be achieved mathematically by matrix +multiplication of a point vector by a transformation matrix. It is slightly +complicated by the fact that a 2D transformation requires "homogeneous" +coordinates with 3 dimensions. The transformation matrix can be calculated by +solving a linear equation involving the source and destination coordinate sets. +The following articles are helpful: ++ https://junfengzhang.com/2023/01/17/affine-transformation-why-3d-matrix-for-a-2d-transformation/ ++ https://medium.com/hipster-color-science/computing-2d-affine-transformations-using-only-matrix-multiplication-2ccb31b52181 # noqa +""" + + +def affine_transform_matrix( + source: list[tuple[float, float]], + dest: list[tuple[float, float]], +) -> np.ndarray: + # Convert the source and destination points to NumPy arrays + source_array = np.array(source) + dest_array = np.array(dest) + + # Add a column of ones to make the points homogeneous coordinates + ones_column = np.ones((source_array.shape[0], 1)) + source_homogeneous = np.hstack((source_array, ones_column)) + dest_homogeneous = np.hstack((dest_array, ones_column)) + + # Perform linear least squares regression to find the affine transformation matrix + matrix, _, _, _ = np.linalg.lstsq(source_homogeneous, dest_homogeneous, rcond=None) + + return matrix.T + + +def affine_transform_point( + matrix: np.ndarray, + point: tuple[float, float], +) -> tuple[int, int]: + """Apply an affine transformation to a 2D point""" + # Convert the source point to 3D NumPy array + # Adding z=1 makes point "homogeneous" + src = np.array([*point, 1]) + + # Apply the affine transformation + dest = matrix @ src + dest = (round(dest[0]), round(dest[1])) # to 2D integer coordinate + + return dest diff --git a/tactool/set_scale_dialog.py b/tactool/set_scale_dialog.py index 82f02a8..9f28f5b 100644 --- a/tactool/set_scale_dialog.py +++ b/tactool/set_scale_dialog.py @@ -1,8 +1,3 @@ -""" -The Scale Dialog is used for allowing the user to see the new scale values. -They can then either reset the values, cancel them or continue with them. -""" - from PyQt5.QtCore import ( pyqtSignal, Qt, @@ -18,13 +13,16 @@ QVBoxLayout, ) +from tactool.utils import LoggerMixin + -class SetScaleDialog(QDialog): +class SetScaleDialog(QDialog, LoggerMixin): """ PyQt QDialog class for creating the set scale dialog box when in scaling mode. + This dialog allows the user to set the scale values visually. """ # Tracks when the Clear button is clicked - clear_scale = pyqtSignal() + clear_scale_clicked = pyqtSignal() # Tracks when the Set Scale button is clicked set_scale_clicked = pyqtSignal(float) # Tracks when the Set Scale Dialog Box is closed @@ -34,14 +32,13 @@ def __init__(self, testing_mode: bool) -> None: super().__init__() self.testing_mode = testing_mode - # Setting default input value self.pixel_input_default = "Not Set" # Setting the Dialog Box settings self.setWindowTitle("Set Scale") self.setMinimumSize(100, 200) self.setWindowFlags( - Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowStaysOnTopHint + Qt.Window | Qt.WindowCloseButtonHint ) self.setup_ui_elements() self.connect_signals_and_slots() @@ -52,9 +49,10 @@ def __init__(self, testing_mode: bool) -> None: def setup_ui_elements(self) -> None: """ - Function to create the elements of the Set Scale dialog box User Interface. + Create the elements of the Set Scale dialog box User Interface. Also sets the layout for the dialog box. """ + self.logger.debug("Setting up UI elements") # Main buttons self.set_scale_button = QPushButton("OK", self) self.clear_scale_button = QPushButton("Clear", self) @@ -102,11 +100,12 @@ def setup_ui_elements(self) -> None: def connect_signals_and_slots(self) -> None: """ - Function for connecting signals and slots of buttons and input boxes. + Connecting signals and slots of buttons and input boxes. """ + self.logger.debug("Connecting signals and slots") # Connect signals and slots for buttons self.set_scale_button.clicked.connect(self.set_scale) - self.clear_scale_button.clicked.connect(self.clear_scale.emit) + self.clear_scale_button.clicked.connect(self.clear_scale) self.cancel_button.clicked.connect(self.closeEvent) # Connect signals and slots for input boxes @@ -114,44 +113,54 @@ def connect_signals_and_slots(self) -> None: self.pixel_input.textChanged.connect(self.update_scale) - def update_scale(self) -> tuple[float, float]: + def update_scale(self) -> None: """ - Function to update the scale value in the Set Scale dialog box. + Update the scale value in the Set Scale dialog box. """ pixels = self.pixel_input.text() distance = float(self.distance_input.value()) if self.distance_input.value() else 0.0 - scale = "" - # If the pixels value is not the default value and the distance is greater than 0 + if pixels != self.pixel_input_default and distance > 0: pixels = float(pixels) # Calculate the ratio difference as the new scale value # Scale value then represents the number of pixels per micron scale = round(pixels / distance, 2) - # Update the scale value in the Set Scale Dialog box - self.scale_value.setText(str(scale)) - return pixels, distance + # Update the scale value in the Set Scale Dialog box + self.scale_value.setText(str(scale)) def scale_move_event_handler(self, pixel_distance: float) -> None: """ - Function to handle the change of a scale point on the PyQt Graphics Scene when in scaling mode. + Handler for mouse movement on the PyQt Graphics Scene. + Updates the pixel value in the dialog. """ - # Set the pixel distance value in the Set Scale Dialog box self.pixel_input.setText(str(pixel_distance)) def set_scale(self) -> None: """ - Function to update the scalue value in in the scale input box of the main window. + Update the scalue value in in the scale input box of the main window. """ # If a scale value has been entered if self.scale_value.text(): + self.logger.info("Set scale: %s", self.scale_value.text()) # Emit a signal that the set scale button has been clicked # passing the input value as a float self.set_scale_clicked.emit(float(self.scale_value.text())) self.closeEvent() + def clear_scale(self) -> None: + """ + Clear the current scaling values and elements. + """ + self.logger.debug("Cleared current scale") + self.distance_input.setValue(0) + self.pixel_input.setText(self.pixel_input_default) + self.scale_value.setText("") + self.clear_scale_clicked.emit() + + def closeEvent(self, event=None) -> None: """ Function which is run by PyQt when the application is closed. diff --git a/tactool/table_model.py b/tactool/table_model.py index 3669e06..447719f 100644 --- a/tactool/table_model.py +++ b/tactool/table_model.py @@ -1,14 +1,3 @@ -""" -The Analysis Point class stores Analysis Point data. - -The Table Model acts as a central storage for Analysis Points -and Graphics Scene items. -""" - -import dataclasses -from csv import writer -from pathlib import Path -from textwrap import dedent from typing import ( Any, Optional, @@ -22,84 +11,17 @@ ) from PyQt5.QtWidgets import ( QGraphicsEllipseItem, - QGraphicsTextItem, + QMessageBox, ) - -@dataclasses.dataclass -class AnalysisPoint: - """ - Container class for encapsulating Analysis point data. - """ - # Define the class variables for the Analysis Points - id: int - label: str - x: int - y: int - diameter: int - scale: float - colour: str - sample_name: str - mount_name: str - material: str - notes: str - _outer_ellipse: QGraphicsEllipseItem - _inner_ellipse: QGraphicsEllipseItem - _label_text_item: QGraphicsTextItem - - - @classmethod - def field_names(cls) -> list[str]: - """ - Function to get the field names of the class object. - """ - return [field.name for field in dataclasses.fields(cls)] - - - def aslist(self) -> list[ - int, - int, - int, - str, - int, - float, - str, - str, - str, - str, - str, - QGraphicsEllipseItem, - QGraphicsEllipseItem, - QGraphicsTextItem, - ]: - """ - Function to get the attributes of an Analysis Point object as a list. - """ - attributes_list = [ - self.id, - self.label, - self.x, - self.y, - self.diameter, - self.scale, - self.colour, - self.sample_name, - self.mount_name, - self.material, - self.notes, - self._outer_ellipse, - self._inner_ellipse, - self._label_text_item - ] - return attributes_list +from tactool.analysis_point import AnalysisPoint +from tactool.utils import LoggerMixin -class TableModel(QAbstractTableModel): +class TableModel(QAbstractTableModel, LoggerMixin): """ PyQt QAbstractTableModel for storing AnalysisPoints. """ - # Tracks if a new edited input in the PyQt Table Model is invalid - invalid_label_entry = pyqtSignal(str, str, str) # Tracks if a new edited input in the PyQt Table Model is accepted updated_analysis_points = pyqtSignal(QModelIndex) @@ -120,7 +42,7 @@ def __init__(self) -> None: def headerData(self, section: int, orientation: Qt.Orientation, role: int) -> str: """ - Function to set and return the header values from the QAbstractTableModel. + Set and return the header values from the QAbstractTableModel. """ if role == Qt.DisplayRole: if orientation == Qt.Horizontal: @@ -131,24 +53,26 @@ def headerData(self, section: int, orientation: Qt.Orientation, role: int) -> st def columnCount(self, *args) -> int: """ - Function to return the number of columns in the QAbstractTableModel. + Return the number of columns in the QAbstractTableModel. + Internal method for PyQt. """ return len(self.headers) def rowCount(self, *args) -> int: """ - Function to return the number of rows in the QAbstractTableModel. + Return the number of rows in the QAbstractTableModel. + Internal method for PyQt. """ return len(self._data) def data(self, index: QModelIndex, role: int) -> Optional[str]: """ - Function to format the data to be displayed in the QAbstractTableModel. + Format the data to be displayed in the QAbstractTableModel. It is called when displaying values in the cells, also called when editing (doubleclick). + Internal method for PyQt. """ - if role == Qt.DisplayRole or role == Qt.EditRole: row = index.row() col = index.column() @@ -161,8 +85,9 @@ def data(self, index: QModelIndex, role: int) -> Optional[str]: def setData(self, index: QModelIndex, value: str, role: Qt.ItemDataRole = Qt.EditRole) -> bool: """ - Function to update the value in a cell of the QAbstractTableModel. + Update the value in a cell of the QAbstractTableModel. It is called when editing a value in an editable cell. + Internal method for PyQt. """ if index.isValid(): row = index.row() @@ -177,16 +102,10 @@ def setData(self, index: QModelIndex, value: str, role: Qt.ItemDataRole = Qt.Edi value = "RefMark" # If the new label is not one of the required label values else: - # Create a message informing the user that their input value is invalid - message = dedent(f""" - '{value}' is not a valid label. - - Please use either 'Spot' or 'RefMark'. - """) - self.invalid_label_entry.emit( + QMessageBox.warning( + None, "Invalid Label", - message, - "warning", + f"'{value}' is not a valid label.\n\nPlease use either 'Spot' or 'RefMark'.", ) return False @@ -201,46 +120,47 @@ def setData(self, index: QModelIndex, value: str, role: Qt.ItemDataRole = Qt.Edi def flags(self, index: QModelIndex) -> Qt.ItemFlag: """ - Function to set the flags of the cells within the QAbstractTableModel. + Set the flags of the cells within the QAbstractTableModel. + Internal method for PyQt. """ - # If the given column should be an editable column, set it to be editable # Set all columns to be selectable and enabled + default_flags = Qt.ItemIsSelectable | Qt.ItemIsEnabled + # If the given column should be an editable column, set it to be editable if index.column() in self.editable_columns: - return Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable + return default_flags | Qt.ItemIsEditable else: - return Qt.ItemIsSelectable | Qt.ItemIsEnabled + return default_flags def add_point(self, analysis_point: AnalysisPoint) -> None: """ - Function to add an Analysis Point object as a row. + Add an Analysis Point object as a row. """ self._data.append(analysis_point.aslist()) def remove_point(self, target_id: int) -> None: """ - Function to remove an Analysis Point object using it's ID value. + Remove an Analysis Point object using it's ID value. """ for index, analysis_point in enumerate(self.analysis_points): - # If the target ID is equal to the current ID, remove it from the table if target_id == analysis_point.id: self._data.pop(index) + break def get_point_by_ellipse(self, target_ellipse: QGraphicsEllipseItem) -> AnalysisPoint: """ - Function to get the data of an Analysis Point object. + Get the data of an Analysis Point object using its ellipse object. """ for analysis_point in self.analysis_points: - # If the target ellipse is equal to either the current outer or inner ellipse if target_ellipse in [analysis_point._outer_ellipse, analysis_point._inner_ellipse]: return analysis_point def get_point_by_apid(self, target_id: int) -> AnalysisPoint: """ - Function to get an Analysis Point using its ID value. + Get an Analysis Point using its ID value. """ for analysis_point in self.analysis_points: if int(target_id) == analysis_point.id: @@ -248,76 +168,40 @@ def get_point_by_apid(self, target_id: int) -> AnalysisPoint: @property - def reference_points(self) -> list[AnalysisPoint]: + def public_headers(self) -> list[str]: """ - Function to return Analysis Points which are a RefMark point. + Return just the public headers. """ - # Using list comprehension to get Analysis Points if their label attribute is equal to RefMark - label_index = AnalysisPoint.field_names().index("label") - return [AnalysisPoint(*item) for item in self._data if item[label_index] == "RefMark"] + return [header for header in self.headers if not header.startswith("_")] @property def analysis_points(self) -> list[AnalysisPoint]: """ - Function to return all of the Analysis Points. + Return all of the Analysis Points. """ - # Using list comprehension to get all Analysis Points and unpack their values into Analysis Point objects return [AnalysisPoint(*item) for item in self._data] - def export_csv(self, filepath: Path) -> None: - """ - Get all the existing Analysis Points and write them to as a CSV file. - """ - # Do not save the last 3 columns as they contain PyQt graphics data - with open(filepath, "w", newline="") as csvfile: - csvwriter = writer(csvfile) - # Modify and write the header data - new_headers = self.convert_export_headers() - csvwriter.writerow(new_headers) - # Iterate through each existing analysis point and write it's data - for analysis_point in self.analysis_points: - csv_row = self.convert_export_point(analysis_point) - csvwriter.writerow(csv_row) - - - def convert_export_headers(self) -> list[str]: + @property + def reference_points(self) -> list[AnalysisPoint]: """ - Function to convert the header data formatting for a CSV export. + Return Analysis Points which are RefMarks point. """ - header_conversions = { - "id": "Name", - "label": "Type", - "x": "X", - "y": "Y", - } - headers = self.headers[:len(self.headers) - 3] - for old_header, new_header in zip(header_conversions, header_conversions.values()): - headers[headers.index(old_header)] = new_header - # Remove the sample_name field - headers.pop(headers.index("sample_name")) - - # Insert a new Z column after the Y column for the laser formatting - z_index = headers.index("Y") + 1 - headers.insert(z_index, "Z") - return headers - - - def convert_export_point(self, analysis_point: AnalysisPoint) -> list: + label_index = AnalysisPoint.field_names().index("label") + return [AnalysisPoint(*item) for item in self._data if item[label_index] == "RefMark"] + + + @property + def next_point_id(self) -> int: """ - Function to convert an Analysis Point formatting for a CSV export. + Return the current maximum Analysis Point ID value + 1. """ - headers = self.headers[:len(self.headers) - 3] - id_idx, sample_name_idx = headers.index("id"), headers.index("sample_name") - analysis_point_row = analysis_point.aslist()[:len(self.headers) - 3] - - # Concat the sample_name and id into 1 column - # Also pads zeros on id column value - analysis_point_row[id_idx] = f"{analysis_point.sample_name}_#{analysis_point.id:03d}" - analysis_point_row.pop(sample_name_idx) - - # Insert a new Z column after the Y column for the laser formatting - z_index = headers.index("y") + 1 - analysis_point_row.insert(z_index, 0) - return analysis_point_row + ids = [ + analysis_point.id + for analysis_point in self.analysis_points + ] + if len(ids) == 0: + return 1 + else: + return max(ids) + 1 diff --git a/tactool/table_view.py b/tactool/table_view.py index 341b416..a8bb357 100644 --- a/tactool/table_view.py +++ b/tactool/table_view.py @@ -1,7 +1,3 @@ -""" -The Table View manages how the data which is stored in the Table Model is displayed in the User Interface. -""" - from PyQt5.QtCore import ( pyqtSignal, Qt, @@ -29,23 +25,31 @@ def __init__(self, table_model: TableModel) -> None: self.horizontalHeader().setStretchLastSection(True) self.verticalHeader().setVisible(False) self.setAlternatingRowColors(True) - self.set_column_sizes() + self.format_columns() - def set_column_sizes(self) -> None: + def format_columns(self) -> None: """ - Function to set the sizing of specific columns in the Table View. + Format the columns in the TableView. + This includes sizing specific columns and hiding private fields. """ - headers = TableModel().headers - resize_columns = ["id", "x", "y", "label", "diameter", "scale", "colour"] + headers: list[str] = self.model().headers + # Resize the first 7 columns to be smaller + resize_columns = ["id", "x", "y", "label", "diameter", "scale", "colour"] for column_name in resize_columns: self.setColumnWidth(headers.index(column_name), 100) + # Hide private fields + for idx, column in enumerate(headers): + # Columns beginning with an _ store the PyQt Graphics elements corresponding to the Analysis Points + if column.startswith("_"): + self.hideColumn(idx) + def mousePressEvent(self, event: QMouseEvent) -> None: """ - Function to handle mouse clicks on the Table View. + Handler for mouse clicks on the Table View. Since we are only adding functionality to mousePressEvent, we pass the event to the parent PyQt class, QTableView, at the end of the function to handle diff --git a/tactool/utils.py b/tactool/utils.py index 90fb6ad..8e63645 100644 --- a/tactool/utils.py +++ b/tactool/utils.py @@ -1,10 +1,33 @@ -""" -Module for utilities and functions to help with development. -""" import logging from PyQt5.QtCore import pyqtRemoveInputHook +logging.basicConfig( + level=logging.INFO, + format="%(levelname)s: %(asctime)s %(name)s: %(message)s", + datefmt="{%Y-%m-%d %H:%M:%S}", +) + + +class LoggerMixin: + """ + Logger class to give each class of the TACtool application a built-in logger. + """ + def __init__(self): + self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) + + + @staticmethod + def _set_logger_levels(level: int) -> None: + """ + Set the logger levels across all classes in the TACtool application. + """ + top_level = LoggerMixin + children = [top_level] + top_level.__subclasses__() + for child in children: + logger = logging.getLogger(child.__name__) + logger.setLevel(level) + def ipdb_breakpoint(): """ diff --git a/tactool/window.py b/tactool/window.py index 38e52bd..38ea60a 100644 --- a/tactool/window.py +++ b/tactool/window.py @@ -1,19 +1,14 @@ -""" -The Window manages the user interface layout and interaction with -buttons and input boxes. -""" - -import logging - -from csv import DictReader -from textwrap import dedent -from typing import Optional +from typing import ( + Callable, + Optional, +) from PyQt5.QtCore import QModelIndex from PyQt5.QtGui import QFont from PyQt5.QtWidgets import ( QColorDialog, QComboBox, + QDialog, QFileDialog, QHBoxLayout, QLabel, @@ -27,27 +22,27 @@ QWidget, ) +from tactool.analysis_point import ( + AnalysisPoint, + export_tactool_csv, + parse_tactool_csv, + reset_id, +) from tactool.graphics_view import GraphicsView +from tactool.recoordinate_dialog import RecoordinateDialog from tactool.set_scale_dialog import SetScaleDialog -from tactool.table_model import AnalysisPoint +from tactool.table_model import TableModel from tactool.table_view import TableView - -logger = logging.getLogger("tactool") -logger.setLevel(logging.DEBUG) -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s %(name)s %(message)s", -) +from tactool.utils import LoggerMixin -class Window(QMainWindow): +class Window(QMainWindow, LoggerMixin): """ PyQt QMainWindow class which displays the application's interface and manages user interaction with it. """ def __init__(self, testing_mode: bool) -> None: super().__init__() - logger.info("Initialising TACtool application") self.testing_mode = testing_mode self.default_settings = { @@ -66,20 +61,21 @@ def __init__(self, testing_mode: bool) -> None: # point_colour is stored as a class vairable because it requires formatting # this variable is the formatted version ready to use for other functions self.point_colour: str = self.default_settings["colour"] - self.status_bar_messages = self.create_status_bar_messages() # Setup the User Interface self.setWindowTitle("TACtool") self.setMinimumSize(750, 650) self.graphics_view = GraphicsView() self.graphics_scene = self.graphics_view.graphics_scene - self.table_model = self.graphics_view.graphics_scene.table_model + self.table_model = TableModel() self.table_view = TableView(self.table_model) self.set_scale_dialog: Optional[SetScaleDialog] = None + self.recoordinate_dialog: Optional[RecoordinateDialog] = None self.setup_ui_elements() self.connect_signals_and_slots() + self.status_bar_messages = self.create_status_bar_messages() self.toggle_status_bar_messages() - self.main_input_widgets = [ + self.main_input_widgets: list[QWidget] = [ self.menu_bar_file, self.sample_name_input, self.mount_name_input, @@ -87,6 +83,7 @@ def __init__(self, testing_mode: bool) -> None: self.label_input, self.colour_button, self.diameter_input, + self.set_scale_button, self.reset_ids_button, self.reset_settings_button, self.clear_points_button, @@ -96,18 +93,25 @@ def __init__(self, testing_mode: bool) -> None: def setup_ui_elements(self) -> None: """ - Function to setup the User Interface elements. + Setup the User Interface elements. """ + self.logger.debug("Setting up UI elements") # Create the menu bar self.menu_bar = self.menuBar() # Create the file drop down self.menu_bar_file = self.menu_bar.addMenu("&File") # Add buttons to the file drop down - self.menu_bar_file_import_image = self.menu_bar_file.addAction("Import Image") - self.menu_bar_file_export_image = self.menu_bar_file.addAction("Export Image") + self.import_image_button = self.menu_bar_file.addAction("Import Image") + self.export_image_button = self.menu_bar_file.addAction("Export Image") self.menu_bar_file.addSeparator() - self.file_menu_bar_import_tactool_csv = self.menu_bar_file.addAction("Import TACtool CSV") - self.file_menu_bar_export_tactool_csv = self.menu_bar_file.addAction("Export TACtool CSV") + self.import_tactool_csv_button = self.menu_bar_file.addAction("Import TACtool CSV") + self.export_tactool_csv_button = self.menu_bar_file.addAction("Export TACtool CSV") + self.menu_bar_file.addSeparator() + self.recoordinate_sem_csv_button = self.menu_bar_file.addAction("Import and Recoordinate SEM CSV") + # Create the tools drop down + self.menu_bar_tools = self.menu_bar.addMenu("&Tools") + self.ghost_point_button = self.menu_bar_tools.addAction("Ghost Point") + self.ghost_point_button.setCheckable(True) # Create the status bar self.status_bar = QStatusBar(self) @@ -150,13 +154,6 @@ def setup_ui_elements(self) -> None: self.reset_ids_button = QPushButton("Reset IDs", self) self.reset_settings_button = QPushButton("Reset Settings", self) - # Iterate through each header in the PyQt Table Model - for idx, column in enumerate(self.graphics_view.graphics_scene.table_model.headers): - # Hide any columns which start with an "_" in the PyQt Table View - # These columns store the PyQt Graphics elements corresponding to the Analysis Points - if column.startswith("_"): - self.table_view.hideColumn(idx) - # Arrange the layout of the user interface sidebar = QVBoxLayout() @@ -203,52 +200,67 @@ def setup_ui_elements(self) -> None: self.setCentralWidget(central_widget) - def set_colour_button_style(self) -> None: - """ - Function to set the CSS stylesheet of the Colour Button in the User Interface. - """ - colour_button_stylesheet = """ - QToolTip { - background-color: white; - color: black; - border: black solid 1px; - }; - background-color: #BTN_COLOUR; - border: none; - """.replace("#BTN_COLOUR", self.point_colour) - self.colour_button.setStyleSheet(colour_button_stylesheet) - - def connect_signals_and_slots(self) -> None: """ - Function for connecting signals and slots of User Interface interactions. + Connect signals and slots to User Interface interactions. """ + self.logger.debug("Connecting signals and slots") # Connect menu bar clicks to handlers - self.menu_bar_file_import_image.triggered.connect(self.import_image_get_path) - self.menu_bar_file_export_image.triggered.connect(self.export_image_get_path) - self.file_menu_bar_import_tactool_csv.triggered.connect(self.import_tactool_csv_get_path) - self.file_menu_bar_export_tactool_csv.triggered.connect(self.export_tactool_csv_get_path) + self.import_image_button.triggered.connect(self.import_image_get_path) + self.export_image_button.triggered.connect(self.export_image_get_path) + self.import_tactool_csv_button.triggered.connect(self.import_tactool_csv_get_path) + self.export_tactool_csv_button.triggered.connect(self.export_tactool_csv_get_path) + self.recoordinate_sem_csv_button.triggered.connect(self.toggle_recoordinate_dialog) + self.ghost_point_button.triggered.connect(self.graphics_view.remove_ghost_point) # Connect button clicks to handlers self.clear_points_button.clicked.connect(self.clear_analysis_points) - self.reset_ids_button.clicked.connect(self.reset_analysis_points) + self.reset_ids_button.clicked.connect(lambda: self.reload_analysis_points(transform=reset_id)) self.reset_settings_button.clicked.connect(self.reset_settings) - self.colour_button.clicked.connect(self.set_point_colour) + self.colour_button.clicked.connect(self.get_point_colour) self.set_scale_button.clicked.connect(self.toggle_scaling_mode) - # Connect Graphics View interactinos to handlers + # Connect Graphics View interactions to handlers self.graphics_view.left_click.connect(self.add_analysis_point) self.graphics_view.right_click.connect(self.remove_analysis_point) + self.graphics_view.move_ghost_point.connect(self.add_ghost_point) # Connect Table interaction clicks to handlers self.table_view.selected_analysis_point.connect(self.get_point_settings) - self.table_model.invalid_label_entry.connect(self.show_message) - self.table_model.updated_analysis_points.connect(self.update_analysis_points) + self.table_model.updated_analysis_points.connect(self.reload_analysis_points) + + + @property + def dialogs(self) -> list[QDialog]: + """ + Return a list of the current dialog attributes. + """ + dialogs = [ + self.set_scale_dialog, + self.recoordinate_dialog, + ] + return dialogs + + + def set_colour_button_style(self) -> None: + """ + Set the CSS stylesheet of the Colour Button in the User Interface. + """ + colour_button_stylesheet = """ + QToolTip { + background-color: white; + color: black; + border: black solid 1px; + }; + background-color: #BTN_COLOUR; + border: none; + """.replace("#BTN_COLOUR", self.point_colour) + self.colour_button.setStyleSheet(colour_button_stylesheet) - def create_status_bar_messages(self): + def create_status_bar_messages(self) -> dict[str, dict[str, None | QLabel | Callable[[], tuple[bool, str]]]]: """ - Function to create the status bar message functions. + Create the status bar message functions. """ # Each of these functions contains the condition for the status message and the message itself # These must be functions so that the conditional statement is dynamic @@ -282,8 +294,9 @@ def set_scale(self: Window): def toggle_status_bar_messages(self) -> None: """ - Function to toggle all of the status bar messages. + Toggle all of the status bar messages. """ + self.logger.debug("Toggling %s status bar messages", len(self.status_bar_messages)) for status_name in self.status_bar_messages: # Get the status, condition result and message from the dictionary status = self.status_bar_messages[status_name]["status"] @@ -311,179 +324,131 @@ def toggle_status_bar_messages(self) -> None: def import_image_get_path(self) -> None: """ - Function to create a PyQt File Dialog, allowing the user to visually select an image file to import. + Create a PyQt File Dialog, allowing the user to visually select an image file to import. """ pyqt_open_dialog = QFileDialog.getOpenFileName( - self, - "Import Image", + parent=self, + directory="Import Image", filter=self.default_settings["image_format"], ) - path = pyqt_open_dialog[0] - if path: + filepath = pyqt_open_dialog[0] + if filepath: try: - self.graphics_view.load_image(path) - self.image_filepath = path + self.graphics_view.load_image(filepath) + self.image_filepath = filepath self.setWindowTitle(f"TACtool: {self.image_filepath}") except Exception as error: - self.data_error_message(error) + self.qmessagebox_error(error) def export_image_get_path(self) -> None: """ - Function to create a PyQt File Dialog, allowing the user to visually select a directory to export an image file. + Create a PyQt File Dialog, allowing the user to visually select a directory to export an image file. """ if self.validate_current_data(validate_image=True): - filepath = self.image_filepath if self.image_filepath else "" + current_filepath = self.image_filepath if self.image_filepath else "" pyqt_save_dialog = QFileDialog.getSaveFileName( - self, - "Export Image", - filepath, - self.default_settings["image_format"], + parent=self, + caption="Export Image", + directory=current_filepath, + filter=self.default_settings["image_format"], ) - path = pyqt_save_dialog[0] - if path: + filepath = pyqt_save_dialog[0] + if filepath: try: - self.graphics_view.save_image(path) + self.graphics_view.save_image(filepath) except Exception as error: - self.data_error_message(error) + self.qmessagebox_error(error) def import_tactool_csv_get_path(self) -> None: """ - Function to create a PyQt File Dialog, allowing the user to visually select a TACtool CSV file to import. + Create a PyQt File Dialog, allowing the user to visually select a TACtool CSV file to import. """ pyqt_open_dialog = QFileDialog.getOpenFileName( - self, - "Import TACtool CSV", + parent=self, + caption="Import TACtool CSV", filter=self.default_settings["csv_format"], ) - path = pyqt_open_dialog[0] - if path: + filepath = pyqt_open_dialog[0] + if filepath: try: - self.load_tactool_csv_data(path) - self.csv_filepath = path + self.load_tactool_csv_data(filepath) + self.csv_filepath = filepath except Exception as error: - self.data_error_message(error) + self.qmessagebox_error(error) def load_tactool_csv_data(self, filepath: str) -> None: """ - Get all the analysis points from a csv and display them in model and scene. + Load the Analysis Point data from a given CSV file and add it into the program. """ try: - self.process_tactool_csv(filepath) + self.logger.info("Loading TACtool CSV file: %s", filepath) + analysis_points = parse_tactool_csv(filepath, self.default_settings) + self.clear_analysis_points() + self.reset_settings() + # Track if any of the points extend the image boundary + extends_boundary = False + image_size = self.graphics_view._image.pixmap().size() + for analysis_point in analysis_points: + self.add_analysis_point(**analysis_point, use_window_inputs=False) + ap_x = analysis_point["x"] + ap_y = analysis_point["y"] + if ap_x > image_size.width() or ap_x < 0 or ap_y > image_size.height() or ap_y < 0: + extends_boundary = True + self.table_view.scrollToTop() + + # Create a message informing the user that the points extend the image boundary + if extends_boundary: + message = "At least 1 of the imported analysis points goes beyond the current image boundary" + self.logger.warning(message) + QMessageBox.warning(None, "Imported Points Warning", message) + # A KeyError and UnicodeError usually occur with an incorrectly formatted CSV file except (KeyError, UnicodeError): # Show a message to the user informing them of which headers should be in the CSV file - public_headers = [header for header in self.table_model.headers - if not header.startswith("_")] - message = dedent(f""" - There was an error when loading data from CSV file: {filepath.split("/")[-1]}. - - Must use csv with header {public_headers}. - """) - self.show_message("Error loading data", message, "warning") - - - def process_tactool_csv(self, filepath: str) -> None: - """ - Function to process the data in a given TACtool CSV file - and create the required Analysis Points. - """ - # Clear existing points and settings first - self.clear_analysis_points() - self.reset_settings() - - default_values = { - "Name": 0, - "X": 0, - "Y": 0, - "diameter": self.default_settings["diameter"], - "scale": float(self.default_settings["scale"]), - "colour": self.default_settings["colour"], - } - - with open(filepath) as csv_file: - reader = DictReader(csv_file) - # Iterate through each line in the CSV file - for id, item in enumerate(reader): - - # Split the id and sample_name value from the Name column - if "_#" in item["Name"]: - item["sample_name"], item["Name"] = item["Name"].rsplit("_#", maxsplit=1) - - # The default ID value is incremented with the row number - default_values["Name"] = id + 1 - # If there is a Z column which is requried for the laser, then remove it - try: - item.pop("Z") - except KeyError: - pass - - item = self.parse_row_data(item, default_values) - - # Rename specific fields to match function arguments - header_changes = { - "Name": "apid", - "X": "x", - "Y": "y", - "Type": "label", - } - for old_header, new_header in zip(header_changes, list(header_changes.values())): - item[new_header] = item.pop(old_header) - self.add_analysis_point(**item, from_click=False) - self.table_view.scrollToTop() - - - def parse_row_data(self, item: dict, default_values: dict) -> dict: - """ - Function to parse the data of an Analysis Point row item in a CSV file. - """ - # Define the field names and their type conversions in Python - fields = ["Name", "X", "Y", "diameter", "scale", "colour"] - pre_processes = [int, int, int, int, float, None] - - # Iterate through each field, it's type conversion and it's default value - for field, pre_process in zip(fields, pre_processes): - try: - # If a value has been given - if item[field]: - # If the value requires preprocessing - if pre_process: - item[field] = pre_process(item[field]) - # Else when no value is given - else: - item[field] = default_values[field] - # In the event of a KeyError, throw away the value which caused the error - except KeyError: - item[field] = default_values[field] - return item + required_headers = [ + " " + val + for val in ["Name", "Type", "X", "Y", "diameter", "scale", "colour", "mount_name", "material", "notes"] + ] + message = "\n".join([ + "There was an error when loading data from CSV file:", + " " + filepath.split('/')[-1] + "\n", + "Plese use a CSV file with the following headers:", + *required_headers, + ]) + QMessageBox.warning(None, "Error Loading Data", message) def export_tactool_csv_get_path(self) -> None: """ - Function to create a PyQt File Dialog, - allowing the user to visually select a directory to save a TACtool CSV file. + Create a PyQt File Dialog allowing the user to visually select a directory to save a TACtool CSV file. """ if self.validate_current_data(): - filepath = self.csv_filepath if self.csv_filepath else "" + current_filepath = self.csv_filepath if self.csv_filepath else "" pyqt_save_dialog = QFileDialog.getSaveFileName( - self, - "Export as TACtool CSV", - filepath, - self.default_settings["csv_format"], + parent=self, + caption="Export as TACtool CSV", + directory=current_filepath, + filter=self.default_settings["csv_format"], ) - path = pyqt_save_dialog[0] - if path: + filepath = pyqt_save_dialog[0] + if filepath: try: - self.table_model.export_csv(path) + self.logger.info("Exporting Analysis Points to: %s", filepath) + export_tactool_csv( + filepath=filepath, + headers=self.table_model.public_headers, + analysis_points=self.table_model.analysis_points, + ) except Exception as error: - self.data_error_message(error) + self.qmessagebox_error(error) def validate_current_data(self, validate_image: bool = False) -> bool: """ - Function to check if the current data of the Analysis Points is valid. + Check if the current data of the Analysis Points is valid. Used when exporting data to a file. Each validation step contains a return statement which is used when the validation fails, thus preventing the remaining validation. @@ -492,38 +457,28 @@ def validate_current_data(self, validate_image: bool = False) -> bool: if validate_image: # If there is currently no image in the PyQt Graphics View if self.graphics_view._empty: - message = dedent(""" - Image not found. - - There is no image to save. - """) - # This is an information dialog, meaning it can only return True - if self.show_message("Warning", message, "warning"): - return False + QMessageBox.warning(None, "Image Not Found", "There is no image to save.") + return False # If there are less than 3 reference points - if self.status_bar_messages["ref_points"]["status"]: + if len(self.table_model.reference_points) < 3: default_label = self.default_settings["label"] - message = dedent(f""" - Missing reference points. - - There must be at least 3 points labelled '{default_label}'. - - Do you still want to continue? - """) - # If the user presses Cancel - if not self.show_message("Warning", message, "question"): + choice = QMessageBox.question( + None, + "Missing Reference Points", + f"There must be at least 3 points labelled '{default_label}.\n\nDo you still want to continue?", + ) + if choice == QMessageBox.No: return False # If the scale value has not been changed if self.scale_value_input.text() == self.default_settings["scale"]: - message = dedent(""" - A scale value has not been set. - - Do you still want to continue? - """) - # If the user presses Cancel - if not self.show_message("Warning", message, "question"): + choice = QMessageBox.question( + None, + "No Scale Set", + "A scale value has not been set.\n\nDo you still want to continue?", + ) + if choice == QMessageBox.No: return False # If all checks are passed then continue @@ -534,109 +489,182 @@ def add_analysis_point( self, x: int, y: int, - label: str = None, - diameter: int = None, - scale: float = None, - colour: str = None, - notes: str = "", - apid: int = None, + apid: Optional[int] = None, + label: Optional[str] = None, + diameter: Optional[int] = None, + scale: Optional[float] = None, + colour: Optional[str] = None, sample_name: str = "", mount_name: str = "", material: str = "", - from_click: bool = True, + notes: str = "", + use_window_inputs: bool = True, + ghost: bool = False, ) -> None: """ Add an Analysis Point to the PyQt Graphics Scene. The main ways a user can do this is by clicking on the Graphics Scene, or by importing a TACtool CSV file. If the Analysis Point has been created from a click, get the values from the window settings. - Otherwise, from_click is set to False and the Analysis Point settings are retrieved from the CSV columns. - """ - if from_click: - # Get the required input values from the window input settings - # Coordinates and the Point ID are taken from the arguments, notes defaults to None - label = self.label_input.currentText() - diameter = self.diameter_input.value() - colour = self.point_colour - scale = float(self.scale_value_input.text()) + Otherwise, use_window_inputs is set to False and the Analysis Point settings are retrieved from + the given input values where possible. + + The ghost option is used to determine if the AnalysisPoint is a transparent hint used on the + GraphicsView/GraphicsScene, or a genuine Analysis Point. + """ + # If it is meant to be a ghost point but ghost points are disabled, just return and end the process + if ghost and not self.ghost_point_button.isChecked(): + return + + # Assign attributes + if use_window_inputs: + # Get the required input values from the window input settings if they are not given + # We only do this for the settings fields, not the metadata, because the settings are required + # but the metadata is optional + if label is None: + label = self.label_input.currentText() + if diameter is None: + diameter = self.diameter_input.value() + if scale is None: + scale = float(self.scale_value_input.text()) + if colour is None: + colour = self.point_colour sample_name = self.sample_name_input.text() mount_name = self.mount_name_input.text() material = self.material_input.text() + # If no analysis point ID is given, assign it the next ID available + if not apid: + apid = self.table_model.next_point_id + + # Get the graphics items for the analysis point + outer_ellipse, inner_ellipse, label_text_item = self.graphics_scene.add_analysis_point( + x=x, + y=y, + apid=apid, + label=label, + diameter=diameter, + colour=colour, + scale=scale, + ghost=ghost, + ) - analysis_point = self.graphics_scene.add_analysis_point( + # Place the new point data into an Analysis Point object + analysis_point = AnalysisPoint( x=x, y=y, + id=apid, label=label, diameter=diameter, scale=scale, colour=colour, - notes=notes, - apid=apid, sample_name=sample_name, mount_name=mount_name, material=material, + notes=notes, + _outer_ellipse=outer_ellipse, + _inner_ellipse=inner_ellipse, + _label_text_item=label_text_item, ) - logger.debug("Created Analysis Point: %s", analysis_point) - # Update the status bar messages and PyQt Table View - self.toggle_status_bar_messages() - self.table_view.model().layoutChanged.emit() + if ghost: + point_type = "Ghost" + self.graphics_view.ghost_point = analysis_point + else: + self.graphics_view.remove_ghost_point() + point_type = "Analysis" + self.table_model.add_point(analysis_point) + # Update the status bar messages and PyQt Table View + self.toggle_status_bar_messages() + self.table_view.model().layoutChanged.emit() + + self.logger.debug("Created %s Point: %s", point_type, analysis_point) + self.logger.info("Created %s Point with ID: %s", point_type, analysis_point.id) - def remove_analysis_point(self, x: int = None, y: int = None, apid: int = None) -> None: + def add_ghost_point(self, x: int, y: int) -> None: """ - Function to remove an Analysis Point from the PyQt Graphics Scene. - The Point is specified using it's coordinates or it's ID value. + Add a ghost point or move the existing ghost point. """ - deletion_result = self.graphics_scene.remove_analysis_point(x=x, y=y, apid=apid) - # If the deletion returned a value, it is the Analysis Point ID and so is outputted - if deletion_result: - logger.debug("Deleted Analysis Point: %s", deletion_result) - - # Update the status bar messages and PyQt Table View - self.toggle_status_bar_messages() - self.table_view.model().layoutChanged.emit() + # If a ghost point doesn't exist + if self.graphics_view.ghost_point is None: + self.add_analysis_point(x=x, y=y, use_window_inputs=True, ghost=True) + else: + # Calculate movement change + x_change = x - self.graphics_view.ghost_point.x + y_change = y - self.graphics_view.ghost_point.y + # Update metadata coordinates + self.graphics_view.ghost_point.x = x + self.graphics_view.ghost_point.y = y + self.graphics_scene.move_analysis_point( + ap=self.graphics_view.ghost_point, + x_change=x_change, + y_change=y_change, + ) - def reload_analysis_points(self) -> None: + def remove_analysis_point( + self, + x: Optional[int] = None, + y: Optional[int] = None, + apid: Optional[int] = None, + ) -> None: """ - Function to reload all of the existing Analysis Points. + Remove an Analysis Point from the PyQt Graphics Scene and Table Model. + The Point is specified using it's coordinates or it's ID value. """ - # Save the existing Points before clearing them - current_analysis_points = self.table_model.analysis_points - self.clear_analysis_points() - # Iterate through each previously existing Point and recreate it - for analysis_point in current_analysis_points: - self.add_analysis_point( - apid=analysis_point.id, - x=analysis_point.x, - y=analysis_point.y, - label=analysis_point.label, - diameter=analysis_point.diameter, - scale=analysis_point.scale, - colour=analysis_point.colour, - sample_name=analysis_point.sample_name, - mount_name=analysis_point.mount_name, - material=analysis_point.material, - notes=analysis_point.notes, - from_click=False - ) + # If a ghost point exists, it must be deleted before deleting the genuine AnalysisPoint + # Because when getting a point by ellipse, the ghost point becomes the selected point for deletion otherwise + if self.graphics_view.ghost_point is not None: + self.graphics_view.remove_ghost_point() + + analysis_point = None + # If a target ID is provided, get the Analysis Point using it's ID + if apid: + analysis_point = self.table_model.get_point_by_apid(apid) + + # Else when the user right clicks on the Graphics View to remove an Analysis Point + elif x and y: + # Get the ellipse and check it exists + ellipse = self.graphics_scene.get_ellipse_at(x, y) + if ellipse: + # Get the corresponding Analysis Point object of the ellipse + analysis_point = self.table_model.get_point_by_ellipse(ellipse) + + if analysis_point is not None: + self.table_model.remove_point(analysis_point.id) + self.graphics_scene.remove_analysis_point(analysis_point) + # Update the status bar messages and PyQt TableView + self.toggle_status_bar_messages() + self.table_view.model().layoutChanged.emit() + + self.logger.info("Deleted Analysis Point: %s", analysis_point.id) + # Re-add the ghost point + self.graphics_view.move_ghost_point.emit(x, y) - def reset_analysis_points(self) -> None: + + def reload_analysis_points( + self, + index: Optional[QModelIndex] = None, + transform: Optional[Callable[[AnalysisPoint], AnalysisPoint]] = None, + ) -> None: """ - Function to reset the ID values of all existing Analysis Points. + Reload all of the existing Analysis Points. + Takes an index which indicates if the TableView should be automatically scrolled to a specific point. + Also takes a transform function to transform the existing Analysis Points before replacing them. """ + self.logger.debug("Reloading Analysis Points with transform: %s", transform) # Save the existing Points before clearing them current_analysis_points = self.table_model.analysis_points self.clear_analysis_points() - self.graphics_scene._maximum_point_id = 0 - # Iterate through each previously existing Point and recreate it without the existing ID - # This forces it to automatically increment from 0 + # Iterate through each previously existing Point and recreate it for analysis_point in current_analysis_points: + if transform is not None: + analysis_point = transform(analysis_point) self.add_analysis_point( x=analysis_point.x, y=analysis_point.y, + apid=analysis_point.id, label=analysis_point.label, diameter=analysis_point.diameter, scale=analysis_point.scale, @@ -645,38 +673,28 @@ def reset_analysis_points(self) -> None: mount_name=analysis_point.mount_name, material=analysis_point.material, notes=analysis_point.notes, - from_click=False + use_window_inputs=False, ) + # Index is given when the user edits a cell in the PyQt Table View + # It represents the index of the modified cell + if index is not None: + # When the Analysis Points are reloaded, the scroll position in the PyQt Table View resets + # Therefore, we scroll back to where the user was previously scrolled + self.table_view.scrollTo(index) + def clear_analysis_points(self) -> None: """ - Function to delete all existing Analysis Points. + Clear all existing Analysis Points. """ - # Iterate through existing Analysis Points and delete them for point in self.table_model.analysis_points: self.remove_analysis_point(apid=point.id) - # Reset the maximum Analysis Point ID value - self.graphics_scene._maximum_point_id = 0 - - - def update_analysis_points(self, index: QModelIndex = None) -> None: - """ - Function to reload the Analysis Points currently in the application and optionally scroll to a given index. - """ - self.reload_analysis_points() - # Index is given when the user edits a cell in the PyQt Table View - # It represents the index of the modified cell - if index: - # When the Analysis Points are reloaded, the scroll position in the PyQt Table View resets - # Therefore, we scroll back to where the user was previously scrolled - self.table_view.scrollTo(index) - - def set_point_colour(self) -> None: + def get_point_colour(self) -> None: """ - Function to update the selected colour in the user interface. + Get a new colour from the user through a QColorDialog. """ # Create a PyQt Colour Dialog to select a colour colour = QColorDialog.getColor() @@ -684,125 +702,75 @@ def set_point_colour(self) -> None: # Update the colour of the button and the value # colour is a QColor class hex_colour = colour.name() - self.point_colour = hex_colour - self.set_colour_button_style() + self.set_point_colour(hex_colour) - def toggle_scaling_mode(self) -> None: + def set_point_colour(self, colour: str) -> None: """ - Function to toggle the program's scaling mode functionality. + Set the currently selected colour as the given colour. + Also updates the stylesheet for the GUI colour button to reflect the change. """ - # Toggle the scaling mode for the Graphics View - self.graphics_view.toggle_scaling_mode() - - # If the program is not in scaling mode - if self.set_scale_dialog is None: - # Create the Set Scale Dialog box - self.set_scale_dialog = SetScaleDialog(self.testing_mode) - # Disable main window input widgets - self.toggle_main_input_widgets(False) - # Move the Set Scale Dialog box to be at the top left corner of the main window - main_window_pos = self.pos() - self.set_scale_dialog.move(main_window_pos.x() + 50, main_window_pos.y() + 50) - - # Connect the Set Scale dialog buttons - self.set_scale_dialog.set_scale_clicked.connect(self.set_scale) - self.set_scale_dialog.clear_scale.connect(self.clear_scale_clicked) - self.set_scale_dialog.closed_set_scale_dialog.connect(self.toggle_scaling_mode) - self.graphics_view.scale_move_event.connect(self.set_scale_dialog.scale_move_event_handler) - - # Else when the program is in scaling mode, reset the Set Scaling Dialog value - else: - self.set_scale_dialog = None - # Enable main window widgets - self.toggle_main_input_widgets(True) - - - def toggle_main_input_widgets(self, enable: bool) -> None: - """ - Toggle each of the input widgets in the main window to be enabled or disabled. - """ - for widget in self.main_input_widgets: - widget.setEnabled(enable) - - - def clear_scale_clicked(self) -> None: - """ - Function to clear the scaling mode when the Clear button is clicked in the Set Scale dialog box. - """ - self.graphics_scene.remove_scale_items() - self.graphics_view.reset_scale_line_points() - self.set_scale_dialog.pixel_input.setText(self.set_scale_dialog.pixel_input_default) - self.set_scale_dialog.distance_input.setValue(0) - - - def set_scale(self, scale: float) -> None: - """ - Function to set the scale of the program given when the Set scale button is clicked in the Set Scale dialog box. - """ - self.scale_value_input.setText(str(scale)) - self.toggle_status_bar_messages() + self.point_colour = colour + self.set_colour_button_style() def get_point_settings(self, analysis_point: AnalysisPoint, clicked_column_index: int) -> None: """ - Function to get the settings of an Analysis Point which has been selected in the PyQt Table View. + Get the settings of an Analysis Point which has been selected in the PyQt Table View. These settings are then updated to be the current settings. """ - logger.debug("Selected Analysis Point: %s", analysis_point) + self.logger.info("Selected Analysis Point with ID: %s", analysis_point.id) # If the column of the cell the user clicked is the id if clicked_column_index == self.table_model.headers.index("id"): # Update the Analysis Point settings to be the same as the Point settings of the Point selected in the table self.update_point_settings( - sample_name=analysis_point.sample_name, - mount_name=analysis_point.mount_name, - material=analysis_point.material, label=analysis_point.label, diameter=analysis_point.diameter, scale=analysis_point.scale, colour=analysis_point.colour, + sample_name=analysis_point.sample_name, + mount_name=analysis_point.mount_name, + material=analysis_point.material, ) def reset_settings(self) -> None: """ - Function to reset input fields and general Analysis Point settings to default. + Reset input fields and general Analysis Point settings to default. """ self.update_point_settings( - sample_name=self.default_settings["metadata"], - mount_name=self.default_settings["metadata"], - material=self.default_settings["metadata"], label=self.default_settings["label"], diameter=self.default_settings["diameter"], scale=self.default_settings["scale"], colour=self.default_settings["colour"], + sample_name=self.default_settings["metadata"], + mount_name=self.default_settings["metadata"], + material=self.default_settings["metadata"], ) def update_point_settings( self, - sample_name: str = None, - mount_name: str = None, - material: str = None, - label: str = None, - diameter: int = None, - scale: str | float = None, - colour: str = None, + label: Optional[str] = None, + diameter: Optional[int] = None, + scale: Optional[str | float] = None, + colour: Optional[str] = None, + sample_name: Optional[str] = None, + mount_name: Optional[str] = None, + material: Optional[str] = None, ) -> None: """ - Function to update the Analysis Point settings to be the given settings. + Update the Analysis Point settings to be the given settings. If a value is given for a field, then the value and any corresponding User Interface elements are updated. """ - - if sample_name is not None: - self.sample_name_input.setText(sample_name) - - if mount_name is not None: - self.mount_name_input.setText(mount_name) - - if material is not None: - self.material_input.setText(material) + self.logger.debug( + ( + "Updating Analysis Point settings: label='%s' diamter='%s', scale='%s', colour='%s', " + "sample_name='%s', mount_name='%s', material='%s'" + ), + label, diameter, scale, colour, sample_name, mount_name, material + ) if label is not None: self.label_input.setCurrentText(label) @@ -815,53 +783,129 @@ def update_point_settings( self.toggle_status_bar_messages() if colour is not None: - self.point_colour = colour - self.set_colour_button_style() + self.set_point_colour(colour) + + if sample_name is not None: + self.sample_name_input.setText(sample_name) + + if mount_name is not None: + self.mount_name_input.setText(mount_name) + + if material is not None: + self.material_input.setText(material) - def data_error_message(self, error: Exception) -> None: + def toggle_main_input_widgets(self, enable: bool) -> None: """ - Function to show an error message to the user in the event that - an error occurs when loading in data. + Toggle each of the input widgets in the main window to be enabled or disabled. """ - self.show_message( - "Error loading data", - f"An unexpected error occured: {error}", - "warning", - ) + self.logger.debug("Toggling main widgets to state: %s", enable) + for widget in self.main_input_widgets: + widget.setEnabled(enable) + self.graphics_scene.toggle_transparent_window(self.graphics_view._image) + self.graphics_view.disable_analysis_points = not enable + # Ensure no ghost points are left behind + self.graphics_view.remove_ghost_point() - def show_message(self, title: str, message: str, type: str) -> bool: + def set_scale(self, scale: float) -> None: """ - Function to show a given message to the user in a PyQt QMessageBox. + Set the scale of the program given when the Set scale button is clicked in the Set Scale dialog box. """ - # Creating the PyQt Message box and formatting it - widget = QMessageBox() - widget.setWindowTitle(title) - widget.setText(message) - widget.setStandardButtons(QMessageBox.Ok) + self.scale_value_input.setText(str(scale)) + self.toggle_status_bar_messages() - # Setting the type of message - if type == "warning": - widget.setIcon(QMessageBox.Warning) - elif type == "information": - widget.setIcon(QMessageBox.Information) - elif type == "question": - widget.setIcon(QMessageBox.Question) - widget.setStandardButtons(QMessageBox.Yes | QMessageBox.Cancel) - # Show the message box - message_box = widget.exec_() - # If the user presses the Cancel button on the message box - if message_box == QMessageBox.Cancel: - return False - return True + def toggle_scaling_mode(self) -> None: + """ + Toggle the program's scaling mode functionality. + """ + # Toggle the scaling mode for the Graphics View + self.graphics_view.toggle_scaling_mode() + + # If the program is not in scaling mode + if self.set_scale_dialog is None: + self.set_scale_dialog = SetScaleDialog(self.testing_mode) + self.toggle_main_input_widgets(False) + # Move the Dialog box to be at the top left corner of the main window + main_window_pos = self.pos() + self.set_scale_dialog.move(main_window_pos.x() + 50, main_window_pos.y() + 50) + + # Connect the Set Scale dialog buttons + self.set_scale_dialog.set_scale_clicked.connect(self.set_scale) + self.set_scale_dialog.clear_scale_clicked.connect(self.graphics_view.reset_scaling_elements) + self.set_scale_dialog.closed_set_scale_dialog.connect(self.toggle_scaling_mode) + self.graphics_view.scale_move_event.connect(self.set_scale_dialog.scale_move_event_handler) + + # Else when the program is in scaling mode, reset the Set Scaling Dialog value + else: + self.set_scale_dialog = None + # Enable main window widgets + self.toggle_main_input_widgets(True) + + + def toggle_recoordinate_dialog(self) -> None: + """ + Toggle the recoordination dialog window. + """ + # If there are 3 reference points which can be used for recoordination + if len(self.table_model.reference_points) >= 3: + # If the program is not in recoordination mode + if self.recoordinate_dialog is None: + # Create the Recoordinate Dialog box + self.recoordinate_dialog = RecoordinateDialog( + testing_mode=self.testing_mode, + ref_points=self.table_model.reference_points, + image_size=self.graphics_view._image.pixmap().size(), + ) + # Disable main window input widgets + self.toggle_main_input_widgets(False) + # Move the Dialog box to be at the top left corner of the main window + main_window_pos = self.pos() + self.recoordinate_dialog.move(main_window_pos.x() + 50, main_window_pos.y() + 50) + + # Connect the Recoordinate dialog buttons + self.recoordinate_dialog.closed_recoordinate_dialog.connect(self.toggle_recoordinate_dialog) + + # Else when the program is in recoordination mode, end the recoordination process + else: + # Keep the recoordinated points and close the dialog + recoordinated_point_dicts = self.recoordinate_dialog.recoordinated_point_dicts + self.recoordinate_dialog = None + + # If the user confirmed the recoordination process + if len(recoordinated_point_dicts) > 0: + # Clear the current points + self.clear_analysis_points() + # Add the recoordinated points as new Analysis Points to the canvas + for point_dict in recoordinated_point_dicts: + # We use the window inputs to fill the Analysis Point empty settings + self.add_analysis_point(**point_dict, use_window_inputs=True) + + # Enable main window widgets + self.toggle_main_input_widgets(True) + else: + self.logger.error("Missing 3 references points for recoordination") + QMessageBox.warning( + None, + "Missing Reference Points", + "3 Reference points are required to perform recoordination" + ) + + + def qmessagebox_error(self, error: Exception) -> None: + """ + Show an error message to the user in the event that + an error occurs when loading in data. + """ + QMessageBox.warning(None, "Error Loading Data", f"An unexpected error occured: {error}") def closeEvent(self, event=None) -> None: """ Function which is run by PyQt when the application is closed. """ - # If the Set Scale dialog box is open then close it - if self.set_scale_dialog is not None: - self.set_scale_dialog.close() + # Close any open dialogs + for dialog in self.dialogs: + if dialog is not None: + dialog.close() diff --git a/test/conftest.py b/test/conftest.py index 7f8af1d..f0af84e 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,7 +1,13 @@ -""" -Definitions for test fixtures used within other tests. -""" import pytest +from PyQt5.QtCore import ( + Qt, + QEvent, + QPoint, +) +from PyQt5.QtGui import QMouseEvent +from PyQt5.QtWidgets import QMessageBox + +from tactool.table_model import TableModel from tactool.main import TACtool @@ -13,8 +19,50 @@ def tactool(): running QApplication instance to allow Qt commands to work. """ # Create an instance of the application in developer mode and testing mode - tactool_application = TACtool([], developer_mode=True, testing_mode=True) + tactool_application = TACtool([], developer_mode=True, debug_mode=True, testing_mode=True) yield tactool_application # Delete the application instance before restarting a new one del tactool_application + + +@pytest.fixture(scope="function") +def model(): + """ + An instance of the TableModel to be used in tests. + """ + return TableModel() + + +@pytest.fixture(scope="function") +def public_index(model: TableModel): + """ + The index in the list of headers where the public headers end. + """ + return len(model.public_headers) + + +@pytest.fixture() +def monkeypatch_qmsgbox_question_yes(monkeypatch: pytest.MonkeyPatch) -> None: + """ + A monkeypatch to prevent QMessageBox warning and information popups from showing during tests. + """ + for attribute in ["warning", "information"]: + monkeypatch.setattr(QMessageBox, attribute, lambda *args: QMessageBox.Ok) + + +def create_mock_mouse_event( + x: int = 0, + y: int = 0, +) -> QMouseEvent: + """ + Create a QEvent object for tests to simulate mouse movement input from user. + """ + event = QMouseEvent( + QEvent.MouseMove, + QPoint(x, y), + Qt.NoButton, + Qt.MouseButtons(Qt.NoButton), + Qt.KeyboardModifiers(Qt.NoModifier), + ) + return event diff --git a/test/data/SEM_co-ordinate_import_test_set.csv b/test/data/SEM_co-ordinate_import_test_set.csv new file mode 100644 index 0000000..5242113 --- /dev/null +++ b/test/data/SEM_co-ordinate_import_test_set.csv @@ -0,0 +1,9 @@ +Particle ID,Mineral Classification,Laser Ablation Centre X,Laser Ablation Centre Y,Effective Diameter m,Feret Max Diameter m,Feret Min Diameter m,F (N),Cl (N) +A,Fiducial,91.576,67.762,,,,, +B,Fiducial,86.01,55.893,,,,, +C,Fiducial,98.138,49.417,,,,, +509,Apatite,96.764747,49.303754,56.85,70.99,52.98,,1.51 +577,Apatite,97.520798,55.785059,104.12,160.72,81.76,,1.32 +662,Apatite,93.746436,60.03264,49.1,59.89,42.98,,2.63 +705,Apatite,91.770031,62.312733,41.5,58.29,33.49,, +759,Apatite,92.415936,67.080603,44.62,63.02,37.54,,0.69 diff --git a/test/data/SEM_co-ordinate_import_test_set_4_refs.csv b/test/data/SEM_co-ordinate_import_test_set_4_refs.csv new file mode 100644 index 0000000..355c642 --- /dev/null +++ b/test/data/SEM_co-ordinate_import_test_set_4_refs.csv @@ -0,0 +1,10 @@ +Particle ID,Mineral Classification,Laser Ablation Centre X,Laser Ablation Centre Y,Effective Diameter m,Feret Max Diameter m,Feret Min Diameter m,F (N),Cl (N) +A,Fiducial,91.576,67.762,,,,, +B,Fiducial,86.01,55.893,,,,, +C,Fiducial,98.138,49.417,,,,, +D,Fiducial,90.432,62.384,,,,, +509,Apatite,96.764747,49.303754,56.85,70.99,52.98,,1.51 +577,Apatite,97.520798,55.785059,104.12,160.72,81.76,,1.32 +662,Apatite,93.746436,60.03264,49.1,59.89,42.98,,2.63 +705,Apatite,91.770031,62.312733,41.5,58.29,33.49,, +759,Apatite,92.415936,67.080603,44.62,63.02,37.54,,0.69 diff --git a/test/test_import_export.py b/test/test_import_export.py new file mode 100644 index 0000000..09110b7 --- /dev/null +++ b/test/test_import_export.py @@ -0,0 +1,209 @@ +from pathlib import Path + +import pytest +from PyQt5.QtGui import QPixmap + +from tactool.main import TACtool +from tactool.analysis_point import ( + AnalysisPoint, + export_tactool_csv, + parse_sem_csv, +) + + +def test_export_image(tactool: TACtool, tmp_path: Path): + tmp_image_path = tmp_path / "exported_image.png" + + # Add some Analysis Points + tactool.graphics_view.left_click.emit(101, 101) + tactool.graphics_view.left_click.emit(202, 202) + tactool.graphics_view.left_click.emit(303, 303) + tactool.window.update_point_settings( + sample_name="sample_x83", + mount_name="mount_x81", + material="rock", + label="Spot", + diameter=100, + colour="#ff0000", + ) + tactool.graphics_view.left_click.emit(404, 404) + # The 5th point purposefully goes over the imported image border + tactool.graphics_view.left_click.emit(555, 555) + + # Zoom in on the PyQt Graphics View + factor = 1.25 + tactool.graphics_view._zoom += 1 + tactool.graphics_view.scale(factor, factor) + + # Save the image to the given filepath + tactool.graphics_view.save_image(str(tmp_image_path)) + + # Check that the filepath and the newly saved file exist + assert tmp_image_path.exists() + assert tmp_image_path.is_file() + + # Load the newly created file and the expected image file into a PyQt5 Pixmap + # This allows us to use already imported modules to compare the image sizes + actual_image = QPixmap(str(tmp_image_path)) + expected_image = QPixmap("test/data/exported_image.png") + assert actual_image.size() == expected_image.size() + + +@pytest.mark.parametrize("filepath, expected_points", [ + ("test/data/analysis_points_complete.csv", [ + AnalysisPoint(1, "RefMark", 472, 336, 10, 1.0, "#ffff00", "sample_x83", "mount_x81", "rock", + "this point has padded zeros in the id column", None, None, None), + AnalysisPoint(2, "RefMark", 394, 318, 10, 1.0, "#ffff00", "sample_x83", "mount_x81", + "rock", "", None, None, None), + AnalysisPoint(3, "RefMark", 469, 268, 10, 1.0, "#ffff00", "sample_x83", "mount_x81", + "rock", "point3", None, None, None), + AnalysisPoint(4, "Spot", 527, 340, 10, 1.0, "#204a87", "sample_x67", "mount_x15", "duck", + "point4 with whitespace, and comma", None, None, None), + AnalysisPoint(5, "Spot", 362, 380, 15, 1.0, "#204a87", "sample_x67", "mount_x15", "duck", + "point5 with whitespace", None, None, None), + ]), + ("test/data/id_x_y_partial.csv", [ + AnalysisPoint(1, "RefMark", 295, 276, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), + AnalysisPoint(2, "RefMark", 386, 257, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), + AnalysisPoint(3, "RefMark", 334, 282, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), + AnalysisPoint(4, "RefMark", 357, 315, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), + AnalysisPoint(5, "RefMark", 327, 334, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), + ]), + ("test/data/x_y_partial.csv", [ + AnalysisPoint(1, "Spot", 295, 276, 10, 1.0, "#ffff00", "", "", "rock", "", None, None, None), + AnalysisPoint(2, "Spot", 386, 257, 10, 1.0, "#ffff00", "", "", "rock", "", None, None, None), + AnalysisPoint(3, "Spot", 334, 282, 10, 1.0, "#ffff00", "", "", "rock", "", None, None, None), + AnalysisPoint(4, "Spot", 357, 315, 10, 1.0, "#ffff00", "", "", "duck", "", None, None, None), + AnalysisPoint(5, "Spot", 327, 334, 10, 1.0, "#ffff00", "", "", "duck", "", None, None, None), + ]), +]) +def test_import_tactool_csv( + tactool: TACtool, + public_index: int, + filepath: str, + expected_points: list[AnalysisPoint], +): + # Check that the PyQt Table Model data is empty + assert tactool.table_model.analysis_points == [] + + # Set Analysis Point settings that are used where data is missing + tactool.window.update_point_settings( + sample_name="sample_x83", + mount_name="mount_x81", + material="rock", + label="Spot", + diameter=99, + scale=1.0, + colour="#999999", + ) + + # Import the data from the given TACtool CSV file + tactool.window.load_tactool_csv_data(filepath) + + # Iterate through the actual Analysis Points created from the CSV file + # and the calculated Analysis Points in this test + for loaded_point, expected_point in zip(tactool.table_model.analysis_points, expected_points): + # Using list slicing to compare just the public attributes of the Analysis Points, i.e. up to the last 3 + assert expected_point.aslist()[:public_index] == loaded_point.aslist()[:public_index] + + # Click new points + tactool.graphics_view.left_click.emit(111, 111) + + # Check that the ID values continue from the maximum ID value in the CSV file + assert len(tactool.table_model.analysis_points) == 6 + + +def test_export_tactool_csv(tactool: TACtool, tmp_path: Path): + # Check that the PyQt Table Model data is empty + assert tactool.table_model.analysis_points == [] + + csv_path = tmp_path / "test.csv" + expected_headers = ["Name", "Type", "X", "Y", "Z", "diameter", "scale", "colour", + "mount_name", "material", "notes"] + expected_data = [ + ["_#001", "RefMark", 101, 101, 0, 10, 1.0, "#ffff00", "", "", ""], + ["_#002", "RefMark", 202, 202, 0, 10, 1.0, "#ffff00", "", "", ""], + ["sample_x83_#003", "Spot", 303, 303, 0, 100, 1.5, "#444444", "mount_x81", "duck", ""], + ] + + # Add 2 Analysis Points + tactool.graphics_view.left_click.emit(101, 101) + tactool.graphics_view.left_click.emit(202, 202) + + # Adjust the settings for the 3rd Analysis Point + tactool.window.update_point_settings( + sample_name="sample_x83", + mount_name="mount_x81", + material="duck", + label="Spot", + diameter=100, + scale=1.5, + colour="#444444", + ) + tactool.graphics_view.left_click.emit(303, 303) + + # Save the data to the given CSV file path + export_tactool_csv( + filepath=csv_path, + headers=tactool.table_model.public_headers, + analysis_points=tactool.table_model.analysis_points, + ) + assert_csv_data(csv_path, expected_headers, expected_data) + + +def assert_csv_data(csv_path: str, expected_headers: list[str], expected_data: list) -> None: + """ + Function to assert that the CSV data in the given file matches the given expected data. + """ + with open(csv_path) as csv_file: + # Check that the headers are correct + actual_headers = [item.strip() for item in csv_file.readline().split(",")] + assert actual_headers == expected_headers + + # Check that the Analysis Point data is correct + lines = csv_file.readlines() + # Iterate through the expected Analysis Point data + for index, item in enumerate(expected_data): + # Convert the CSV row into a list + csv_row_data = [item.strip() for item in lines[index].split(",")] + + # Iterate through the CSV Analysis Points + for item_attribute, csv_attribute in zip(item, csv_row_data): + # Check that the attributes match + # Attributes from the expected Analysis Point are converted to a string because + # the raw CSV data will all be a string type + assert csv_attribute == str(item_attribute) + + +def test_parse_sem_csv_good(): + # Arrange + sem_csv = Path("test/data/SEM_co-ordinate_import_test_set.csv") + expected_point_dicts = [ + {"label": "RefMark", "x": 91.576, "y": 67.762}, + {"label": "RefMark", "x": 86.01, "y": 55.893}, + {"label": "RefMark", "x": 98.138, "y": 49.417}, + {"apid": 509, "label": "Spot", "x": 96.764747, "y": 49.303754}, + {"apid": 577, "label": "Spot", "x": 97.520798, "y": 55.785059}, + {"apid": 662, "label": "Spot", "x": 93.746436, "y": 60.03264}, + {"apid": 705, "label": "Spot", "x": 91.770031, "y": 62.312733}, + {"apid": 759, "label": "Spot", "x": 92.415936, "y": 67.080603}, + ] + + # Act + actual_point_dicts = parse_sem_csv(sem_csv) + + # Assert + assert expected_point_dicts == actual_point_dicts + + +def test_parse_sem_csv_bad(): + # Arrange + sem_csv = Path("test/data/analysis_points_complete.csv") + expected_error = "SEM CSV missing required header: Particle ID" + + # Act + with pytest.raises(KeyError) as excinfo: + parse_sem_csv(sem_csv) + + # Assert + assert expected_error in str(excinfo.value) diff --git a/test/test_integration.py b/test/test_integration.py index 3ca279e..2fb4ccb 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -1,35 +1,21 @@ -""" -Integration tests to confirm that classes interact correctly. - -It tests the connection of signals and slots by emitting signals to trigger changes and simulating button clicks. -Analysis Points are added and removed by emitting corresponding click signals. - -tactool fixtures start a running QApplication for the context of the test. -""" -from pathlib import WindowsPath - import pytest -from PyQt5.QtGui import QPixmap -from tactool.main import TACtool -from tactool.table_model import AnalysisPoint - +from PyQt5.QtWidgets import QGraphicsRectItem -PUBLIC_INDEX = len(TACtool([], testing_mode=True).window.table_model.headers) - 3 +from tactool.main import TACtool +from tactool.analysis_point import AnalysisPoint +from conftest import create_mock_mouse_event -def test_add_and_remove_points(tactool: TACtool) -> None: - """ - Function to test the functionality of adding and removing Analysis Points via mouse clicks. - """ +def test_add_and_remove_points(tactool: TACtool, public_index: int, monkeypatch: pytest.MonkeyPatch): # Test for empty model (ensures no leakage between apc fixtures) - assert tactool.window.table_model.analysis_points == [] + assert tactool.table_model.analysis_points == [] # This is the width of the pen used to create the _outer_ellipse item to be added to the # diameter to assert if the ellipse was created to the correct size as the bounding box includes pen width offset = 4 # The 1st Analysis Point has default settings - tactool.window.graphics_view.left_click.emit(101, 101) + tactool.graphics_view.left_click.emit(101, 101) # Adjust the settings for the 2nd Analysis Point tactool.window.update_point_settings( @@ -41,7 +27,16 @@ def test_add_and_remove_points(tactool: TACtool) -> None: scale=2.0, colour="#222222", ) - tactool.window.graphics_view.left_click.emit(202, 202) + tactool.graphics_view.left_click.emit(202, 202) + + # Enable ghost point and monkeypatch function to detect mouse position is on image + monkeypatch.setattr(tactool.graphics_view._image, "isUnderMouse", lambda: True) + tactool.window.ghost_point_button.toggle() + # Check that the ghost point inherits the correct settings + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=203, y=305)) + assert tactool.graphics_view.ghost_point.aslist()[:public_index] == AnalysisPoint( + 3, "Spot", 203, 305, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", "", None, None, None + ).aslist()[:public_index] # Adjust the settings for the 3rd Analysis Point # Purposefully making it overlap the 2nd Analysis Point @@ -52,7 +47,7 @@ def test_add_and_remove_points(tactool: TACtool) -> None: label="RefMark", colour="#333333", ) - tactool.window.graphics_view.left_click.emit(240, 240) + tactool.graphics_view.left_click.emit(240, 240) expected_data = [ AnalysisPoint(1, "RefMark", 101, 101, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), @@ -60,56 +55,65 @@ def test_add_and_remove_points(tactool: TACtool) -> None: AnalysisPoint(3, "RefMark", 240, 240, 50, 2.0, "#333333", "sample_x67", "mount_x15", "duck", "", None, None, None), ] + assert len(tactool.table_model.analysis_points) == len(expected_data) # Iterate through each actual Analysis Point and compare to expected Analysis Point - for index, analysis_point in enumerate(tactool.window.table_model.analysis_points): + for analysis_point, expected_analysis_point in zip(tactool.table_model.analysis_points, expected_data): # Using list slicing to compare just the public attributes of the Analysis Points, i.e. up to the last 3 - assert analysis_point.aslist()[:PUBLIC_INDEX] == expected_data[index].aslist()[:PUBLIC_INDEX] + assert analysis_point.aslist()[:public_index] == expected_analysis_point.aslist()[:public_index] # Compare the size of the actual ellipse to the mathematically expected size - expected_ellipse = (expected_data[index].diameter * expected_data[index].scale) + offset + expected_ellipse = (expected_analysis_point.diameter * expected_analysis_point.scale) + offset assert analysis_point._outer_ellipse.boundingRect().width() == expected_ellipse # Remove the Analysis Point with an ID value of 3 # Purposefully clicking between the 2nd and 3rd point to ensure the 3rd one is still deleted # It should work like a stack when deleting overlapping points - tactool.window.graphics_view.right_click.emit(221, 221) + tactool.graphics_view.right_click.emit(221, 221) # Right click on an empty part of the PyQt Graphics View # Nothing should change - tactool.window.graphics_view.right_click.emit(0, 0) + tactool.graphics_view.right_click.emit(0, 0) + + # Check that the ghost point uses the newly deleted ID value of 3 + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=176, y=301)) + assert tactool.graphics_view.ghost_point.aslist()[:public_index] == AnalysisPoint( + 3, "RefMark", 176, 301, 50, 2.0, "#333333", "sample_x67", "mount_x15", "duck", "", None, None, None + ).aslist()[:public_index] # Adjust the settings for the 4th Analysis Point to match those of the 2nd Analysis Point # This is done by emitting a signal from the PyQt Table View of the selected Analysis Point - tactool.window.table_view.selected_analysis_point.emit(expected_data[1], 0) - # The 4th Analysis Point ID value should be 4 because it is still incrementing from the 3rd Analysis Point - # The maximum ID value also does not change when Analysis Points are deleted - tactool.window.graphics_view.left_click.emit(404, 404) + tactool.table_view.selected_analysis_point.emit(expected_data[1], 0) + # The 4th Analysis Point ID value should be 3 + tactool.graphics_view.left_click.emit(404, 404) expected_data = [ AnalysisPoint(1, "RefMark", 101, 101, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), AnalysisPoint(2, "Spot", 202, 202, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", "", None, None, None), - AnalysisPoint(4, "Spot", 404, 404, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", "", None, None, None), + AnalysisPoint(3, "Spot", 404, 404, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", "", None, None, None), ] + assert len(tactool.table_model.analysis_points) == len(expected_data) # Iterate through each actual Analysis Point and compare to expected Analysis Point - for index, analysis_point in enumerate(tactool.window.table_model.analysis_points): + for analysis_point, expected_analysis_point in zip(tactool.table_model.analysis_points, expected_data): # Using list slicing to compare just the public attributes of the Analysis Points, i.e. up to the last 3 - assert analysis_point.aslist()[:PUBLIC_INDEX] == expected_data[index].aslist()[:PUBLIC_INDEX] + assert analysis_point.aslist()[:public_index] == expected_analysis_point.aslist()[:public_index] # Compare the size of the actual ellipse to the mathematically expected size - expected_ellipse = (expected_data[index].diameter * expected_data[index].scale) + offset + expected_ellipse = (expected_analysis_point.diameter * expected_analysis_point.scale) + offset assert analysis_point._outer_ellipse.boundingRect().width() == expected_ellipse + # Check that the ghost point inherits the correct settings + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=82, y=288)) + assert tactool.graphics_view.ghost_point.aslist()[:public_index] == AnalysisPoint( + 4, "Spot", 82, 288, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", "", None, None, None + ).aslist()[:public_index] -def test_clear_points(tactool: TACtool) -> None: - """ - Function to test the functionality of the Clear Points button. - Some points are purposefully overlapping for the test. - """ + +def test_clear_points(tactool: TACtool): # Check that the PyQt Table Model data is empty - assert tactool.window.table_model.analysis_points == [] + assert tactool.table_model.analysis_points == [] # Add some Analysis Points - tactool.window.graphics_view.left_click.emit(101, 101) - tactool.window.graphics_view.left_click.emit(202, 202) - tactool.window.graphics_view.left_click.emit(303, 303) + tactool.graphics_view.left_click.emit(101, 101) + tactool.graphics_view.left_click.emit(202, 202) + tactool.graphics_view.left_click.emit(303, 303) # The 5th point partially overlaps the 4th point # This is intentional as this used to cause issues tactool.window.update_point_settings( @@ -120,46 +124,83 @@ def test_clear_points(tactool: TACtool) -> None: diameter=100, colour="#ff0000", ) - tactool.window.graphics_view.left_click.emit(404, 404) - tactool.window.graphics_view.left_click.emit(440, 440) + tactool.graphics_view.left_click.emit(404, 404) + tactool.graphics_view.left_click.emit(440, 440) # Simulate a button click of the Clear Points button tactool.window.clear_points_button.click() # Check that all Analysis Points have been removed - assert tactool.window.table_model.analysis_points == [] + assert tactool.table_model.analysis_points == [] -def test_reset_id_values(tactool: TACtool) -> None: +def test_reload_analysis_points_no_args(tactool: TACtool, public_index: int): """ - Function to test the functionality of the Reset IDs button. + Test the functionality of reloading analysis points. """ + # Arrange + # The 1st Analysis Point has default settings + tactool.graphics_view.left_click.emit(101, 101) + + # Adjust the settings for the 2nd Analysis Point + tactool.window.update_point_settings( + sample_name="sample_x83", + mount_name="mount_x81", + material="rock", + label="Spot", + diameter=50, + scale=2.0, + colour="#222222", + ) + tactool.graphics_view.left_click.emit(202, 202) + + # Adjust the settings for the 3rd Analysis Point + # Purposefully making it overlap the 2nd Analysis Point + tactool.window.update_point_settings( + sample_name="sample_x67", + mount_name="mount_x15", + material="duck", + label="RefMark", + colour="#333333", + ) + tactool.graphics_view.left_click.emit(240, 240) + expected_data = tactool.table_model.analysis_points + + # Act + tactool.window.reload_analysis_points() + + # Assert + assert len(tactool.table_model.analysis_points) == len(expected_data) + # Iterate through each actual Analysis Point and compare to expected Analysis Point + for analysis_point, expected_analysis_point in zip(tactool.table_model.analysis_points, expected_data): + # Using list slicing to compare just the public attributes of the Analysis Points, i.e. up to the last 3 + assert analysis_point.aslist()[:public_index] == expected_analysis_point.aslist()[:public_index] + + +def test_reset_id_values(tactool: TACtool): # Add some Analysis Points - tactool.window.graphics_view.left_click.emit(101, 101) - tactool.window.graphics_view.left_click.emit(202, 202) - tactool.window.graphics_view.left_click.emit(303, 303) - tactool.window.graphics_view.left_click.emit(404, 404) - tactool.window.graphics_view.left_click.emit(505, 505) + tactool.graphics_view.left_click.emit(101, 101) + tactool.graphics_view.left_click.emit(202, 202) + tactool.graphics_view.left_click.emit(303, 303) + tactool.graphics_view.left_click.emit(404, 404) + tactool.graphics_view.left_click.emit(505, 505) # Remove the 1st and 4th Analysis Points # This will make the ID values go 2, 3, 5 - tactool.window.graphics_view.right_click.emit(101, 101) - tactool.window.graphics_view.right_click.emit(404, 404) + tactool.graphics_view.right_click.emit(101, 101) + tactool.graphics_view.right_click.emit(404, 404) # Simulate a button click of the Reset IDs button tactool.window.reset_ids_button.click() # Iterate through each actual Analysis Point - for current_id, analysis_point in enumerate(tactool.window.table_model.analysis_points): + for current_id, analysis_point in enumerate(tactool.table_model.analysis_points): # Check that the ID value is equal to expected # We calculate expected ID value using the index of the Analysis Point in the Table Model assert analysis_point.id == current_id + 1 -def test_reset_settings(tactool: TACtool) -> None: - """ - Function to test the functionality of the Reset Settings button. - """ +def test_reset_settings(tactool: TACtool): # Adjust the settings tactool.window.update_point_settings( sample_name="sample_x83", @@ -175,9 +216,9 @@ def test_reset_settings(tactool: TACtool) -> None: tactool.window.reset_settings_button.click() # Add some points, these should now have the default settings and metadata - tactool.window.graphics_view.left_click.emit(101, 101) - tactool.window.graphics_view.left_click.emit(202, 202) - tactool.window.graphics_view.left_click.emit(303, 303) + tactool.graphics_view.left_click.emit(101, 101) + tactool.graphics_view.left_click.emit(202, 202) + tactool.graphics_view.left_click.emit(303, 303) expected_settings = [ tactool.window.default_settings["label"], @@ -190,7 +231,7 @@ def test_reset_settings(tactool: TACtool) -> None: ] # Iterate through each actual Analysis Point - for analysis_point in tactool.window.table_model.analysis_points: + for analysis_point in tactool.table_model.analysis_points: actual_settings = [ analysis_point.label, analysis_point.diameter, @@ -203,233 +244,7 @@ def test_reset_settings(tactool: TACtool) -> None: assert actual_settings == expected_settings -def test_toggle_scaling_mode(tactool: TACtool) -> None: - """ - Function to test the functionality of the scaling mode. - """ - # Check that the SetScaleDialog and the transparent rectangle - # on the PyQt Graphics Scene do not exist - assert tactool.window.set_scale_dialog is None - assert tactool.window.graphics_scene.scaling_rect is None - # Check that the main input widgets are enabled - for widget in tactool.window.main_input_widgets: - assert widget.isEnabled() is True - - # Start the scaling mode - tactool.window.toggle_scaling_mode() - - # Check that the SetScaleDialog and the transparent rectangle - # on the PyQt Graphics Scene exist and are the correct type - assert tactool.window.set_scale_dialog is not None - assert tactool.window.graphics_scene.scaling_rect is not None - # Check that the main input widgets are disabled - for widget in tactool.window.main_input_widgets: - assert widget.isEnabled() is False - - # Set the scale, following the same steps as the user would - tactool.window.set_scale_dialog.scale_value.setText(str(2.0)) - tactool.window.set_scale_dialog.set_scale() - - # Check that the SetScaleDialog and the transparent rectangle - # on the PyQt Graphics Scene do not exist - assert tactool.window.set_scale_dialog is None - assert tactool.window.graphics_scene.scaling_rect is None - # Check that the main input widgets are enabled - for widget in tactool.window.main_input_widgets: - assert widget.isEnabled() is True - - -def test_set_scale(tactool: TACtool) -> None: - """ - Function to test the functionality of setting the scale. - """ - # Set the scale, following the same steps as the user would - scale = 2.0 - tactool.window.toggle_scaling_mode() - tactool.window.set_scale_dialog.scale_value.setText(str(scale)) - tactool.window.set_scale_dialog.set_scale() - - # Add some points, these should now have the new scale - tactool.window.graphics_view.left_click.emit(101, 101) - tactool.window.graphics_view.left_click.emit(202, 202) - tactool.window.graphics_view.left_click.emit(303, 303) - - # Iterate through each actual Analysis Point - for analysis_point in tactool.window.table_model.analysis_points: - # Check that the scale value is equal to expected - assert analysis_point.scale == scale - - -def test_export_image(tactool: TACtool, tmp_path: WindowsPath) -> None: - """ - Function to test the functionality of exporting an image. - """ - tmp_image_path = tmp_path / "exported_image.png" - - # Add some Analysis Points - tactool.window.graphics_view.left_click.emit(101, 101) - tactool.window.graphics_view.left_click.emit(202, 202) - tactool.window.graphics_view.left_click.emit(303, 303) - tactool.window.update_point_settings( - sample_name="sample_x83", - mount_name="mount_x81", - material="rock", - label="Spot", - diameter=100, - colour="#ff0000", - ) - tactool.window.graphics_view.left_click.emit(404, 404) - # The 5th point purposefully goes over the imported image border - tactool.window.graphics_view.left_click.emit(555, 555) - - # Zoom in on the PyQt Graphics View - factor = 1.25 - tactool.window.graphics_view._zoom += 1 - tactool.window.graphics_view.scale(factor, factor) - - # Save the image to the given filepath - tactool.window.graphics_view.save_image(str(tmp_image_path)) - - # Check that the filepath and the newly saved file exist - assert tmp_image_path.exists() - assert tmp_image_path.is_file() - - # Load the newly created file and the expected image file into a PyQt5 Pixmap - # This allows us to use already imported modules to compare the image sizes - actual_image = QPixmap(str(tmp_image_path)) - expected_image = QPixmap("test/data/exported_image.png") - assert actual_image.size() == expected_image.size() - - -@pytest.mark.parametrize("filepath, expected_points", [ - ("test/data/analysis_points_complete.csv", [ - AnalysisPoint(1, "RefMark", 472, 336, 10, 1.0, "#ffff00", "sample_x83", "mount_x81", "rock", - "this point has padded zeros in the id column", None, None, None), - AnalysisPoint(2, "RefMark", 394, 318, 10, 1.0, "#ffff00", "sample_x83", "mount_x81", - "rock", "", None, None, None), - AnalysisPoint(3, "RefMark", 469, 268, 10, 1.0, "#ffff00", "sample_x83", "mount_x81", - "rock", "point3", None, None, None), - AnalysisPoint(4, "Spot", 527, 340, 10, 1.0, "#204a87", "sample_x67", "mount_x15", "duck", - "point4 with whitespace, and comma", None, None, None), - AnalysisPoint(5, "Spot", 362, 380, 15, 1.0, "#204a87", "sample_x67", "mount_x15", "duck", - "point5 with whitespace", None, None, None), - ]), - ("test/data/id_x_y_partial.csv", [ - AnalysisPoint(1, "RefMark", 295, 276, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), - AnalysisPoint(2, "RefMark", 386, 257, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), - AnalysisPoint(3, "RefMark", 334, 282, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), - AnalysisPoint(4, "RefMark", 357, 315, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), - AnalysisPoint(5, "RefMark", 327, 334, 10, 1.0, "#ffff00", "", "", "", "", None, None, None), - ]), - ("test/data/x_y_partial.csv", [ - AnalysisPoint(1, "Spot", 295, 276, 10, 1.0, "#ffff00", "", "", "rock", "", None, None, None), - AnalysisPoint(2, "Spot", 386, 257, 10, 1.0, "#ffff00", "", "", "rock", "", None, None, None), - AnalysisPoint(3, "Spot", 334, 282, 10, 1.0, "#ffff00", "", "", "rock", "", None, None, None), - AnalysisPoint(4, "Spot", 357, 315, 10, 1.0, "#ffff00", "", "", "duck", "", None, None, None), - AnalysisPoint(5, "Spot", 327, 334, 10, 1.0, "#ffff00", "", "", "duck", "", None, None, None), - ]), -]) -def test_import_tactool_csv(tactool: TACtool, filepath: str, expected_points: list[AnalysisPoint]) -> None: - """ - Function to test the functionality of importing a TACtool CSV file. - """ - # Check that the PyQt Table Model data is empty - assert tactool.window.table_model.analysis_points == [] - - # Set Analysis Point settings that are used where data is missing - tactool.window.update_point_settings( - sample_name="sample_x83", - mount_name="mount_x81", - material="rock", - label="Spot", - diameter=99, - scale=1.0, - colour="#999999", - ) - - # Import the data from the given TACtool CSV file - tactool.window.process_tactool_csv(filepath) - - # Iterate through the actual Analysis Points created from the CSV file - # and the calculated Analysis Points in this test - for loaded_point, expected_point in zip(tactool.window.table_model.analysis_points, expected_points): - # Using list slicing to compare just the public attributes of the Analysis Points, i.e. up to the last 3 - assert expected_point.aslist()[:PUBLIC_INDEX] == loaded_point.aslist()[:PUBLIC_INDEX] - assert tactool.window.graphics_scene._maximum_point_id == len(tactool.window.table_model.analysis_points) - - # Click new points - tactool.window.graphics_view.left_click.emit(111, 111) - - # Check that the ID values continue from the maximum ID value in the CSV file - assert len(tactool.window.table_model.analysis_points) == 6 - assert tactool.window.graphics_scene._maximum_point_id == len(tactool.window.table_model.analysis_points) - - -def test_export_tactool_csv(tactool: TACtool, tmp_path: WindowsPath) -> None: - """ - Function to test the functionality of exporting a TACtool CSV file. - """ - # Check that the PyQt Table Model data is empty - assert tactool.window.table_model.analysis_points == [] - - csv_path = tmp_path / "test.csv" - expected_headers = ["Name", "Type", "X", "Y", "Z", "diameter", "scale", "colour", - "mount_name", "material", "notes"] - expected_data = [ - ["_#001", "RefMark", 101, 101, 0, 10, 1.0, "#ffff00", "", "", ""], - ["_#002", "RefMark", 202, 202, 0, 10, 1.0, "#ffff00", "", "", ""], - ["sample_x83_#003", "Spot", 303, 303, 0, 100, 1.5, "#444444", "mount_x81", "duck", ""], - ] - - # Add 2 Analysis Points - tactool.window.graphics_view.left_click.emit(101, 101) - tactool.window.graphics_view.left_click.emit(202, 202) - - # Adjust the settings for the 3rd Analysis Point - tactool.window.update_point_settings( - sample_name="sample_x83", - mount_name="mount_x81", - material="duck", - label="Spot", - diameter=100, - scale=1.5, - colour="#444444", - ) - tactool.window.graphics_view.left_click.emit(303, 303) - - # Save the data to the given CSV file path - tactool.window.table_model.export_csv(csv_path) - assert_csv_data(csv_path, expected_headers, expected_data) - - -def assert_csv_data(csv_path: str, expected_headers: list[str], expected_data: list) -> None: - """ - Function to assert that the CSV data in the given file matches the given expected data. - """ - with open(csv_path) as csv_file: - # Check that the headers are correct - actual_headers = [item.strip() for item in csv_file.readline().split(",")] - assert actual_headers == expected_headers - - # Check that the Analysis Point data is correct - lines = csv_file.readlines() - # Iterate through the expected Analysis Point data - for index, item in enumerate(expected_data): - # Convert the CSV row into a list - csv_row_data = [item.strip() for item in lines[index].split(",")] - - # Iterate through the CSV Analysis Points - for item_attribute, csv_attribute in zip(item, csv_row_data): - # Check that the attributes match - # Attributes from the expected Analysis Point are converted to a string because - # the raw CSV data will all be a string type - assert csv_attribute == str(item_attribute) - - -def test_reference_point_hint(tactool: TACtool) -> None: - """ - Function to test the functionality of the RefMark Points reminder in the Status Bar. - """ +def test_reference_point_hint(tactool: TACtool): # Check reference Points hint is visible ref_points_status = tactool.window.status_bar_messages["ref_points"]["status"] assert ref_points_status is not None @@ -437,9 +252,9 @@ def test_reference_point_hint(tactool: TACtool) -> None: # Add 3 analysis points with the label 'RefMark' tactool.window.label_input.setCurrentText("RefMark") - tactool.window.graphics_view.left_click.emit(100, 100) - tactool.window.graphics_view.left_click.emit(150, 150) - tactool.window.graphics_view.left_click.emit(200, 200) + tactool.graphics_view.left_click.emit(100, 100) + tactool.graphics_view.left_click.emit(150, 150) + tactool.graphics_view.left_click.emit(200, 200) # Check reference Points hint not is visible ref_points_status = tactool.window.status_bar_messages["ref_points"]["status"] @@ -447,7 +262,7 @@ def test_reference_point_hint(tactool: TACtool) -> None: assert ref_points_status not in tactool.window.status_bar.children() # Remove 1 Analysis Point with label 'RefMark', bringing the total to 2 reference Points - tactool.window.graphics_view.right_click.emit(100, 100) + tactool.graphics_view.right_click.emit(100, 100) # Check reference Points hint is visible ref_points_status = tactool.window.status_bar_messages["ref_points"]["status"] @@ -456,7 +271,7 @@ def test_reference_point_hint(tactool: TACtool) -> None: # Add 1 Analysis Point with label 'Spot', keeping the total at 2 reference Points tactool.window.label_input.setCurrentText("Spot") - tactool.window.graphics_view.left_click.emit(100, 100) + tactool.graphics_view.left_click.emit(100, 100) # Check reference Points hint is visible ref_points_status = tactool.window.status_bar_messages["ref_points"]["status"] @@ -464,39 +279,94 @@ def test_reference_point_hint(tactool: TACtool) -> None: assert ref_points_status in tactool.window.status_bar.children() -def test_scale_hint(tactool: TACtool) -> None: - """ - Function to test the functionality of the Set Scale reminder in the Status Bar. - """ - # Check Set Scale hint is not visible - set_scale_status = tactool.window.status_bar_messages["set_scale"]["status"] - assert set_scale_status is None - assert set_scale_status not in tactool.window.status_bar.children() - - # Add some points by clicking - tactool.window.graphics_view.left_click.emit(101, 101) - tactool.window.graphics_view.left_click.emit(202, 202) - tactool.window.graphics_view.left_click.emit(303, 303) - - # Check Set Scale hint is visible - set_scale_status = tactool.window.status_bar_messages["set_scale"]["status"] - assert set_scale_status is not None - assert set_scale_status in tactool.window.status_bar.children() - - # Set the scale, following the same steps as the user would - tactool.window.toggle_scaling_mode() - tactool.window.set_scale_dialog.scale_value.setText(str(2.0)) - tactool.window.set_scale_dialog.set_scale() - - # Check Set Scale hint is not visible - set_scale_status = tactool.window.status_bar_messages["set_scale"]["status"] - assert set_scale_status is None - assert set_scale_status not in tactool.window.status_bar.children() - - # Reset the Scale value to the default value - tactool.window.reset_settings() - - # Check Set Scale hint is visible - set_scale_status = tactool.window.status_bar_messages["set_scale"]["status"] - assert set_scale_status is not None - assert set_scale_status in tactool.window.status_bar.children() +def test_ghost_point_delete_analysis_point(tactool: TACtool, public_index: int, monkeypatch: pytest.MonkeyPatch): + # Enable ghost point and monkeypatch function to detect mouse position is on image + monkeypatch.setattr(tactool.graphics_view._image, "isUnderMouse", lambda: True) + tactool.window.ghost_point_button.toggle() + + # Ensure ghost point initially exists + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=203, y=305)) + assert tactool.graphics_view.ghost_point.aslist()[:public_index] == AnalysisPoint( + 1, "RefMark", 203, 305, 10, 1.0, "#ffff00", "", "", "", "", None, None, None + ).aslist()[:public_index] + + # Add an Analysis Point + tactool.graphics_view.left_click.emit(215, 215) + + # Ensure ghost point has newly incremented ID + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=83, y=106)) + assert tactool.graphics_view.ghost_point.aslist()[:public_index] == AnalysisPoint( + 2, "RefMark", 83, 106, 10, 1.0, "#ffff00", "", "", "", "", None, None, None + ).aslist()[:public_index] + + # Remove the Analysis Point + tactool.graphics_view.right_click.emit(218, 218) + + # Ensure the ghost point has the new next ID (back to 1) + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=176, y=301)) + assert tactool.graphics_view.ghost_point.aslist()[:public_index] == AnalysisPoint( + 1, "RefMark", 176, 301, 10, 1.0, "#ffff00", "", "", "", "", None, None, None + ).aslist()[:public_index] + + +def test_ghost_point_enable_disable(tactool: TACtool, public_index: int, monkeypatch: pytest.MonkeyPatch): + # Ensure ghost point does not initially exist (it is disabled by default) + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=203, y=305)) + assert tactool.graphics_view.ghost_point is None + + # Enable ghost point and monkeypatch function to detect mouse position is on image + monkeypatch.setattr(tactool.graphics_view._image, "isUnderMouse", lambda: True) + tactool.window.ghost_point_button.toggle() + + # Ensure ghost point is now created + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=83, y=106)) + assert tactool.graphics_view.ghost_point.aslist()[:public_index] == AnalysisPoint( + 1, "RefMark", 83, 106, 10, 1.0, "#ffff00", "", "", "", "", None, None, None + ).aslist()[:public_index] + + # Disable the ghost point + # We have to toggle the state and trigger the click separately + tactool.window.ghost_point_button.toggle() + tactool.window.ghost_point_button.trigger() + + # Ensure the ghost point does not exist + assert tactool.graphics_view.ghost_point is None + + +def test_toggle_main_input_widgets(tactool: TACtool, monkeypatch: pytest.MonkeyPatch): + # Enable ghost point and monkeypatch function to detect mouse position is on image + monkeypatch.setattr(tactool.graphics_view._image, "isUnderMouse", lambda: True) + tactool.window.ghost_point_button.toggle() + + # Trigger a mouse movement event + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=83, y=106)) + + # Ensure everything is enabled by default + for widget in tactool.window.main_input_widgets: + assert widget.isEnabled() is True + assert tactool.graphics_scene.transparent_window is None + assert tactool.graphics_view.disable_analysis_points is False + assert isinstance(tactool.graphics_view.ghost_point, AnalysisPoint) + + # Disable main widgets + tactool.window.toggle_main_input_widgets(enable=False) + # Trigger a mouse movement event + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=83, y=106)) + + # Ensure everything is disabled + for widget in tactool.window.main_input_widgets: + assert widget.isEnabled() is False + assert isinstance(tactool.graphics_scene.transparent_window, QGraphicsRectItem) + assert tactool.graphics_view.disable_analysis_points is True + assert tactool.graphics_view.ghost_point is None + + # Enable main widgets + tactool.window.toggle_main_input_widgets(enable=True) + # Trigger a mouse movement event + tactool.graphics_view.mouseMoveEvent(create_mock_mouse_event(x=83, y=106)) + + # Ensure everything is enabled again + for widget in tactool.window.main_input_widgets: + assert widget.isEnabled() is True + assert tactool.graphics_scene.transparent_window is None + assert isinstance(tactool.graphics_view.ghost_point, AnalysisPoint) diff --git a/test/test_model.py b/test/test_model.py index e5d1cc1..aba0136 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -1,15 +1,7 @@ -""" -Tests for classes and functions within model.py - -qapp fixture starts a running QApplication for the context of the test. -""" import pytest -from tactool.main import TACtool -from tactool.table_model import TableModel, AnalysisPoint - - -PUBLIC_INDEX = len(TACtool([], testing_mode=True).window.table_model.headers) - 3 +from tactool.analysis_point import AnalysisPoint +from tactool.table_model import TableModel @pytest.mark.parametrize("expected_data, match_status", [ @@ -48,21 +40,18 @@ True), ] ) -def test_analysis_point_public_attributes_match(expected_data: AnalysisPoint, match_status: bool) -> None: - """ - Function to test the functionality of comparing Analysis Point public attributes. - For this, only the public attributes must match the existing Analysis Point. - """ +def test_analysis_point_public_attributes_match( + public_index: int, + expected_data: AnalysisPoint, + match_status: bool, +): analysis_point = AnalysisPoint(1, "RefMark", 123, 456, 10, 1.0, "#ffff00", "sample_x67", "mount_x15", "duck", "note1", None, None, None) # Compare just the public attributes of the points, i.e. up to the last 3 - assert (analysis_point.aslist()[:PUBLIC_INDEX] == expected_data.aslist()[:PUBLIC_INDEX]) is match_status + assert (analysis_point.aslist()[:public_index] == expected_data.aslist()[:public_index]) is match_status -def test_model() -> None: - """ - Function to test the functionality of the PyQt Table Model of TACtool. - """ +def test_model(model: TableModel): expected_data = [ [1, "RefMark", 123, 456, 10, 1.0, "#ffff00", "sample_x83", "mount_x15", "rock", "note1", "outer_ellipse1", "inner_ellipse1", "label_item1"], @@ -71,7 +60,6 @@ def test_model() -> None: [3, "Spot", 123, 456, 10, 1.0, "#ffff00", "sample_x67", "mount_x15", "duck", "note3", "outer_ellipse3", "inner_ellipse3", "label_item3"] ] - model = TableModel() # Check that the PyQt Table Model is empty with the correct headers assert model._data == [] @@ -91,11 +79,13 @@ def test_model() -> None: "_inner_ellipse", "_label_text_item", ] + assert model.next_point_id == 1 # Add Analysis Points to the PyQt Table Model and check that it has added correctly for row in expected_data: model.add_point(AnalysisPoint(*row)) assert model._data == expected_data + assert model.next_point_id == 4 # Check that the PyQt Table Model does not return non existent Analysis Points assert model.get_point_by_ellipse("non-existent ellipse") is None @@ -109,12 +99,19 @@ def test_model() -> None: # Check that the PyQt Table Model does not change the data when removing a non existent Analysis Point model.remove_point(4) assert model._data == expected_data + assert model.next_point_id == 4 # Check that the PyQt Table Model does remove the correct Analysis Point model.remove_point(1) assert model._data == expected_data[1:] + assert model.next_point_id == 4 # Check that the PyQt Table Model does return the correct Analysis Point using the Point's ID value analysis_point_3 = model.get_point_by_apid(3) assert analysis_point_3 == AnalysisPoint(3, "Spot", 123, 456, 10, 1.0, "#ffff00", "sample_x67", "mount_x15", "duck", "note3", "outer_ellipse3", "inner_ellipse3", "label_item3") + + # Check that the PyQt Table Model does remove the correct Analysis Point + model.remove_point(3) + assert model._data == expected_data[1:2] + assert model.next_point_id == 3 diff --git a/test/test_scaling.py b/test/test_scaling.py new file mode 100644 index 0000000..3ca2ad0 --- /dev/null +++ b/test/test_scaling.py @@ -0,0 +1,90 @@ +import pytest + +from tactool.main import TACtool + + +def test_toggle_scaling_mode(tactool: TACtool): + # Check that the SetScaleDialog does not exist + assert tactool.window.set_scale_dialog is None + # Check that the main input widgets are enabled + for widget in tactool.window.main_input_widgets: + assert widget.isEnabled() is True + assert tactool.graphics_view.disable_analysis_points is False + assert tactool.graphics_scene.transparent_window is None + + # Start the scaling mode + tactool.window.toggle_scaling_mode() + + # Check that the SetScaleDialog does exist + assert tactool.window.set_scale_dialog is not None + # Check that the main input widgets are disabled + for widget in tactool.window.main_input_widgets: + assert widget.isEnabled() is False + assert tactool.graphics_view.disable_analysis_points is True + assert tactool.graphics_scene.transparent_window is not None + + # Set the scale, following the same steps as the user would + tactool.window.set_scale_dialog.scale_value.setText(str(2.0)) + tactool.window.set_scale_dialog.set_scale() + + # Check that the SetScaleDialog does not exist + assert tactool.window.set_scale_dialog is None + # Check that the main input widgets are enabled + for widget in tactool.window.main_input_widgets: + assert widget.isEnabled() is True + assert tactool.graphics_view.disable_analysis_points is False + assert tactool.graphics_scene.transparent_window is None + + +def test_set_scale(tactool: TACtool): + # Set the scale, following the same steps as the user would + scale = 2.0 + tactool.window.toggle_scaling_mode() + tactool.set_scale_dialog.scale_value.setText(str(scale)) + tactool.set_scale_dialog.set_scale() + + # Add some points, these should now have the new scale + tactool.graphics_view.left_click.emit(101, 101) + tactool.graphics_view.left_click.emit(202, 202) + tactool.graphics_view.left_click.emit(303, 303) + + # Iterate through each actual Analysis Point + for analysis_point in tactool.table_model.analysis_points: + # Check that the scale value is equal to expected + # We use pytest.approx to compare the floats due to potential computing errors in floating point numbers + assert analysis_point.scale == pytest.approx(scale) + + +def test_scale_hint(tactool: TACtool): + # Check Set Scale hint is not visible + set_scale_status = tactool.window.status_bar_messages["set_scale"]["status"] + assert set_scale_status is None + assert set_scale_status not in tactool.window.status_bar.children() + + # Add some points by clicking + tactool.graphics_view.left_click.emit(101, 101) + tactool.graphics_view.left_click.emit(202, 202) + tactool.graphics_view.left_click.emit(303, 303) + + # Check Set Scale hint is visible + set_scale_status = tactool.window.status_bar_messages["set_scale"]["status"] + assert set_scale_status is not None + assert set_scale_status in tactool.window.status_bar.children() + + # Set the scale, following the same steps as the user would + tactool.window.toggle_scaling_mode() + tactool.set_scale_dialog.scale_value.setText(str(2.0)) + tactool.set_scale_dialog.set_scale() + + # Check Set Scale hint is not visible + set_scale_status = tactool.window.status_bar_messages["set_scale"]["status"] + assert set_scale_status is None + assert set_scale_status not in tactool.window.status_bar.children() + + # Reset the Scale value to the default value + tactool.window.reset_settings() + + # Check Set Scale hint is visible + set_scale_status = tactool.window.status_bar_messages["set_scale"]["status"] + assert set_scale_status is not None + assert set_scale_status in tactool.window.status_bar.children() diff --git a/test/test_transformation.py b/test/test_transformation.py new file mode 100644 index 0000000..5fa7d53 --- /dev/null +++ b/test/test_transformation.py @@ -0,0 +1,179 @@ +import numpy as np +from typing import Any + +import pytest + +from tactool.main import TACtool +from tactool.recoordinate_dialog import ( + affine_transform_point, + affine_transform_matrix, +) + +X_PLUS_10 = np.array([[1, 0, 10], + [0, 1, 0], + [0, 0, 1]]) + +Y_PLUS_10 = np.array([[1, 0, 0], + [0, 1, 10], + [0, 0, 1]]) + +SCALE_BY_2ish = np.array([[2.1, 0, 0], + [0, 2.1, 0], + [0, 0, 1]]) + + +@pytest.mark.parametrize( + ["matrix", "src", "expected"], + [ + (X_PLUS_10, (1, 1), (11, 1)), + (X_PLUS_10, (1, 0), (11, 0)), + (X_PLUS_10, (0, 1), (10, 1)), + (Y_PLUS_10, (1, 1), (1, 11)), + (Y_PLUS_10, (1, 0), (1, 10)), + (Y_PLUS_10, (0, 1), (0, 11)), + (SCALE_BY_2ish, (1, 1), (2, 2)), # should round to nearest int + (SCALE_BY_2ish, (10, 10), (21, 21)), # no rounding required + ] +) +def test_affine_transform_point(matrix, src, expected): + # Act + transformed = affine_transform_point(matrix, src) + + # Assert + assert transformed == expected + + +def test_affine_transform_matrix(): + # Arrange + src = [(0, 0), (1, 0), (1, 1), (0, 1)] + dest = [(2, 2), (3, 2), (3, 3), (2, 3)] + expected = np.array([ + [1, 0, 2], + [0, 1, 2], + [0, 0, 1] + ]) + + # Act + matrix = affine_transform_matrix(src, dest) + + # Assert + np.testing.assert_array_almost_equal(matrix, expected, decimal=10) + + +def test_toggle_recoordinate_dialog(tactool: TACtool, monkeypatch_qmsgbox_question_yes: pytest.MonkeyPatch): + # Check that the RecoordinateDialog does not exist + assert tactool.window.recoordinate_dialog is None + # Check that the main input widgets are enabled + for widget in tactool.window.main_input_widgets: + assert widget.isEnabled() is True + assert tactool.graphics_view.disable_analysis_points is False + assert tactool.graphics_scene.transparent_window is None + + # Add 2 RefMark points + tactool.graphics_view.left_click.emit(336, 472) + tactool.graphics_view.left_click.emit(318, 394) + + # Try to start the recoordination dialog + # This should not work because 3 RefMark points are needed + # and currently there are only 2 + tactool.window.toggle_recoordinate_dialog() + + # Check that the RecoordinateDialog does not exist + assert tactool.window.recoordinate_dialog is None + # Check that the main input widgets are enabled + for widget in tactool.window.main_input_widgets: + assert widget.isEnabled() is True + assert tactool.graphics_view.disable_analysis_points is False + assert tactool.graphics_scene.transparent_window is None + + # Add the 3rd RefMark point + tactool.graphics_view.left_click.emit(268, 469) + + # Toggle the recoordinate dialog, this should now work + tactool.window.toggle_recoordinate_dialog() + + # Check that the RecoordinateDialog does exist + assert tactool.window.recoordinate_dialog is not None + # Check that the main input widgets are disabled + for widget in tactool.window.main_input_widgets: + assert widget.isEnabled() is False + assert tactool.graphics_view.disable_analysis_points is True + assert tactool.graphics_scene.transparent_window is not None + + # Close the RecoordinateDialog + tactool.recoordinate_dialog.cancel_button.click() + + # Check that the RecoordinateDialog does not exist + assert tactool.window.recoordinate_dialog is None + # Check that the main input widgets are enabled + for widget in tactool.window.main_input_widgets: + assert widget.isEnabled() is True + assert tactool.graphics_view.disable_analysis_points is False + assert tactool.graphics_scene.transparent_window is None + + +@pytest.mark.parametrize( + ["input_csv", "expected_points_data"], + [ + ( + "test/data/SEM_co-ordinate_import_test_set.csv", + [ + [1, "RefMark", 336, 472, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [2, "RefMark", 318, 394, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [3, "RefMark", 268, 469, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [509, "Spot", 271, 458, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [577, "Spot", 287, 483, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [662, "Spot", 309, 466, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [705, "Spot", 320, 458, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [759, "Spot", 332, 477, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + ], + ), + ( + "test/data/SEM_co-ordinate_import_test_set_4_refs.csv", + [ + [1, "RefMark", 336, 472, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [2, "RefMark", 318, 394, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [3, "RefMark", 268, 469, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [4, "RefMark", 324, 447, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [509, "Spot", 271, 458, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [577, "Spot", 287, 483, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [662, "Spot", 309, 466, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [705, "Spot", 320, 458, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + [759, "Spot", 332, 477, 50, 2.0, "#222222", "sample_x83", "mount_x81", "rock", ""], + ], + ), + ], +) +def test_import_and_recoordinate_sem_csv( + tactool: TACtool, + public_index: int, + input_csv: str, + expected_points_data: list[list[Any]], +): + # Arrange + # Place 4 Analysis Points which will be used for recoordination + # Only the first 3 should be used + tactool.graphics_view.left_click.emit(336, 472) + tactool.graphics_view.left_click.emit(318, 394) + tactool.graphics_view.left_click.emit(268, 469) + tactool.graphics_view.left_click.emit(87, 392) + # Modify the Analysis Point settings as these should be applied to the recoordinated points + tactool.window.update_point_settings( + sample_name="sample_x83", + mount_name="mount_x81", + material="rock", + label="Spot", + diameter=50, + scale=2.0, + colour="#222222", + ) + + # Act + # Toggle recoordinate dialog so that the recoordinate_dialog is callable + tactool.window.toggle_recoordinate_dialog() + tactool.recoordinate_dialog.input_csv_filepath_label.setText(input_csv) + tactool.recoordinate_dialog.import_and_recoordinate_sem_csv() + + # Assert + for expected_point, actual_point in zip(expected_points_data, tactool.table_model.analysis_points): + assert expected_point == actual_point.aslist()[:public_index]