Skip to content

Commit

Permalink
Load re-coordinated SEM points into TACtool rather than exporting them
Browse files Browse the repository at this point in the history
  • Loading branch information
leorudczenko committed Jan 26, 2024
1 parent fd8bfbc commit 66b4373
Show file tree
Hide file tree
Showing 3 changed files with 97 additions and 116 deletions.
52 changes: 29 additions & 23 deletions tactool/analysis_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
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:
Expand Down Expand Up @@ -249,43 +256,42 @@ def convert_export_point(analysis_point: AnalysisPoint, headers: list[str]) -> l
return analysis_point_row


def parse_sem_csv(filepath: str, required_headers: list[str]) -> tuple[list[dict[str, Any]], list[str]]:
def parse_sem_csv(filepath: str) -> list[dict[str, str | int | float]]:
"""
Parse an SEM CSV file.
Returns a list of dictionary rows, and the list of headers in the same order they are in the current file.
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
reader.fieldnames
for header in required_headers:
for header in SEM_HEADERS.values():
if header not in reader.fieldnames:
raise KeyError(f"Missing required header: {header}")
raise KeyError(f"SEM CSV missing required header: {header}")

# Iterate through each line in the CSV file
for item in reader:
# Convert the required coordinate headers to floats
for header in required_headers:
item[header] = float(item[header])
point_dicts.append(item)

return point_dicts, reader.fieldnames
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"]])

def export_sem_csv(filepath: str, headers: list[str], points: list[dict[str, Any]]) -> None:
"""
Write the given header data and point data to the given filepath.
This is specifically for SEM data.
"""
with open(filepath, "w", newline="") as csvfile:
csvwriter = writer(csvfile)
csvwriter.writerow(headers)
for point in points:
# Convert the dictionary to a list of values matching the header positions
csv_row = [point[header] for header in headers]
csvwriter.writerow(csv_row)
# 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:
Expand Down
128 changes: 43 additions & 85 deletions tactool/recoordinate_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
)

from tactool.analysis_point import (
SEM_HEADERS,
AnalysisPoint,
parse_sem_csv,
export_sem_csv,
)
from tactool.utils import LoggerMixin

Expand All @@ -47,13 +47,16 @@ def __init__(

# Setting the Dialog Box settings
self.setWindowTitle("Recoordination")
self.setMinimumSize(300, 200)
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()

Expand All @@ -70,22 +73,14 @@ def setup_ui_elements(self) -> None:
self.input_csv_filepath_label = QLineEdit("")
self.input_csv_filepath_label.setDisabled(True)

output_csv_label = QLabel("Output CSV")
self.output_csv_button = QPushButton("Select Output CSV", self)
self.output_csv_filepath_label = QLineEdit("")
self.output_csv_filepath_label.setDisabled(True)

self.recoordinate_button = QPushButton("Recoordinate and Export")
self.recoordinate_button = QPushButton("Import and Re-coordinate")
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)
layout.addWidget(output_csv_label)
layout.addWidget(self.output_csv_button)
layout.addWidget(self.output_csv_filepath_label)

# Add the final 2 buttons alongside eachother
bottom_button_layout = QHBoxLayout()
Expand All @@ -103,8 +98,7 @@ def connect_signals_and_slots(self) -> None:
"""
self.logger.debug("Connecting signals and slots")
self.input_csv_button.clicked.connect(self.get_input_csv)
self.output_csv_button.clicked.connect(self.get_output_csv)
self.recoordinate_button.clicked.connect(self.recoordinate_and_export)
self.recoordinate_button.clicked.connect(self.import_and_recoordinate_sem_csv)
self.cancel_button.clicked.connect(self.closeEvent)


Expand All @@ -122,90 +116,58 @@ def get_input_csv(self) -> None:
self.logger.info("Selected input CSV: %s", input_csv)


def get_output_csv(self) -> None:
"""
Get the output CSV file for the recoordination results.
"""
pyqt_save_dialog = QFileDialog.getSaveFileName(
parent=self,
caption="Export Recoordinated CSV",
directory=self.input_csv_filepath_label.text(),
filter="*.csv",
)
output_csv = pyqt_save_dialog[0]
self.output_csv_filepath_label.setText(output_csv)
self.logger.info("Selected output CSV: %s", output_csv)


def recoordinate_and_export(self) -> None:
def import_and_recoordinate_sem_csv(self) -> None:
"""
Get the given CSV files, if they are both valid then perform the recoordination process.
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()
output_csv = self.output_csv_filepath_label.text()
if input_csv != "" and output_csv != "":
result = self.recoordinate_sem_points(input_csv, output_csv)
if result:
self.closeEvent()
else:
QMessageBox.warning(None, "Invalid Paths", "Please select an input and output CSV first.")

if input_csv == "":
QMessageBox.warning(None, "Invalid Path", "Please select an input SEM CSV first.")
return

def recoordinate_sem_points(
self,
input_csv: str,
output_csv: str,
invert_x_axis_dest: bool = True,
x_header: str = "Laser Ablation Centre X",
y_header: str = "Laser Ablation Centre Y",
ref_col: str = "Mineral Classification",
ref_label: str = "Fiducial",
) -> bool:
"""
Recoordinate the given input SEM CSV file points using the current Analysis Points as reference points.
Saves the resulting SEM data to the given output CSV file.
Returns a bool which signals if the recoordination successfully completed.
invert_x_axis_dest determines if the X axis coordinate values of the
destination coordinates should be inverted.
This is used because the origin of the PyQt GraphicsScene is at the top left,
but the origin of the SEM coordinates of at the top right.
Therefore, we use the size of the image to invert the X axis origin of the destination coordinates
to account for this difference.
The x_header, y_header, ref_col and ref_label values can be changed to allow recoordination
of CSV files with different headers and data.
For example, using the following values would allow recoordination for TACtool CSV files:
invert_x_axis_dest=False, x_header="X", y_header="Y", ref_col="Type", ref_label="RefMark"
"""
# Parse the SEM CSV data
required_sem_headers = [x_header, y_header]
# Get the points from the SEM CSV
try:
self.logger.info("Loading SEM CSV: %s", input_csv)
point_dicts, csv_headers = parse_sem_csv(filepath=input_csv, required_headers=required_sem_headers)
point_dicts = parse_sem_csv(filepath=input_csv)
except KeyError as error:
self.logger.error(error)
string_headers = "\n".join(required_sem_headers)
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 False
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, only use the first 3 reference points
# Format the source and dest points into lists of tuples of x and y values
# 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_header], item[y_header])
(item["x"], item["y"])
for item in point_dicts
if item[ref_col] == ref_label
if item["label"] == "RefMark"
][:3]
# Check if the destination points (from TACtool) need the y axis inverted to change the origin
dest = [
(self.image_size.width() - point.x, point.y)
if invert_x_axis_dest else (point.x, point.y)
(point.x, point.y)
for point in self.ref_points
][:3]
matrix = affine_transform_matrix(source=source, dest=dest)
Expand All @@ -214,28 +176,24 @@ def recoordinate_sem_points(
# Track if any of the new points extend the image boundary
extends_boundary = False
for idx, item in enumerate(point_dicts):
point = (item[x_header], item[y_header])
point = (item["x"], item["y"])
new_x, new_y = affine_transform_point(matrix=matrix, point=point)
point_dicts[idx][x_header] = new_x
point_dicts[idx][y_header] = new_y
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))

# Export the new points to the output CSV
self.logger.info("Saving recoordination results to: %s", output_csv)
export_sem_csv(filepath=output_csv, headers=csv_headers, points=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 True
return point_dicts


def closeEvent(self, event=None) -> None:
Expand Down
33 changes: 25 additions & 8 deletions tactool/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def setup_ui_elements(self) -> None:
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("Recoordinate SEM CSV")
self.recoordinate_sem_csv_button = self.menu_bar_file.addAction("Import and Re-coordinate 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")
Expand Down Expand Up @@ -507,7 +507,8 @@ def add_analysis_point(
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, use_window_inputs is set to False and the Analysis Point settings are retrieved from the CSV columns.
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.
Expand All @@ -518,11 +519,17 @@ def add_analysis_point(

# Assign attributes
if use_window_inputs:
# Get the required input values from the window input settings
label = self.label_input.currentText()
diameter = self.diameter_input.value()
colour = self.point_colour
scale = float(self.scale_value_input.text())
# 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()
Expand Down Expand Up @@ -861,9 +868,19 @@ def toggle_recoordinate_dialog(self) -> None:
# Connect the Recoordinate dialog buttons
self.recoordinate_dialog.closed_recoordinate_dialog.connect(self.toggle_recoordinate_dialog)

# Else when the program is in recoordination mode, reset the Recoordination Dialog value
# 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

# 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:
Expand Down

0 comments on commit 66b4373

Please sign in to comment.