Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 137 additions & 2 deletions PyReconstruct/modules/datatypes/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from .default_settings import default_settings, default_series_settings
from .host_tree import HostTree

from PyReconstruct.modules.calc import traceGeometry

from PyReconstruct.modules.constants import (
createHiddenDir,
welcome_series_dir,
Expand Down Expand Up @@ -1633,9 +1635,13 @@ def smoothObject(self, obj_names: list, series_states=None, log_event=True) -> l

return malformed

def deleteMalformedTraces(self, records : list, series_states=None) -> list:
def deleteMalformedTraces(self, records : list, series_states=None, message="Deleting malformed contours...") -> list:
"""Delete specific malformed traces reported by smoothObject.

Also used by the data clean-up operations (pixel-dust / empty traces),
which produce records with the same schema (name, section, match); pass
a different ``message`` so the progress bar reads sensibly.

Each record must carry the keys produced by smoothObject — in
particular "name", "section" and "match" (a {"color", "points"}
signature). Because sections are reloaded fresh from disk, the stored
Expand All @@ -1661,7 +1667,7 @@ def deleteMalformedTraces(self, records : list, series_states=None) -> list:

deleted = []
for snum, section in self.enumerateSections(
message="Deleting malformed contours...",
message=message,
series_states=series_states
):
removed_any = False
Expand Down Expand Up @@ -1700,6 +1706,135 @@ def _traceMatchesSignature(trace, signature) -> bool:
return False
return True

@staticmethod
def _cleanupRecord(obj_name, snum, index, trace, reason, area=None) -> dict:
"""Build a clean-up candidate record.

Uses the same schema smoothObject produces (so the records can be
deleted with deleteMalformedTraces and shown in the review dialog): a
"match" signature of color + 7-decimal-rounded points re-finds the exact
trace after the section is reloaded from disk. An optional physical
area (um^2) is carried for display in the pixel-dust review list.
"""
num_points = len(trace.points)
record = {
"name": obj_name,
"section": snum,
"index": index,
"points": num_points,
"location": (
tuple(round(c, 4) for c in trace.points[0])
if num_points else None
),
"reason": reason,
"match": {
"color": trace.color,
"points": [
(round(x, 7), round(y, 7)) for x, y in trace.points
],
},
}
if area is not None:
record["area"] = area
return record

@staticmethod
def _traceArea(trace, tform) -> float:
"""Physical area (um^2) of a trace, matching the object/trace tables.

Mirrors TraceData: map the points through the section transform, then
run the shared traceGeometry math. Open traces have no enclosed area.
"""
if not trace.closed or len(trace.points) < 3:
return 0.0
pts = tform.mapPointsArray(trace.points)
if not len(pts):
return 0.0
_, area, _, _ = traceGeometry(pts, True)
return abs(area)

def findPixelDustTraces(self, threshold_area : float, include_locked=False) -> list:
"""Find tiny "pixel-dust" traces at or below an area threshold.

A candidate is a CLOSED trace whose physical area (um^2, computed the
same way the object/trace tables report it) is greater than zero and at
or below ``threshold_area``. Zero-area / degenerate traces are left to
findEmptyTraces so the two operations stay disjoint. Open traces (lines)
have no enclosed area and are never pixel dust. Locked objects are
skipped unless ``include_locked`` is True. This only scans; nothing is
modified. Use deleteMalformedTraces to remove the chosen records.

Params:
threshold_area (float): the maximum area (um^2), inclusive
include_locked (bool): True to also consider locked objects
Returns:
(list): candidate records (see _cleanupRecord)
"""
candidates = []
for snum, section in self.enumerateSections(
message="Scanning for pixel-dust traces...",
):
tform = section.tform
for cname in section.contours:
if not include_locked and self.getAttr(cname, "locked"):
continue
for index, trace in enumerate(section.contours[cname]):
if not trace.closed or len(trace.points) < 3:
continue
area = self._traceArea(trace, tform)
if 0 < area <= threshold_area:
candidates.append(self._cleanupRecord(
cname, snum, index, trace,
reason=f"Area {area:.6g} um^2 (<= {threshold_area:.6g})",
area=area,
))
return candidates

def findEmptyTraces(self, include_locked=False) -> list:
"""Find empty / degenerate traces (no meaningful geometry).

A trace is empty when it encloses/spans nothing: no points, a closed
trace with zero area (fewer than 3 points or fully collinear/coincident
points), or an open trace with zero length (all points coincident).
These are unambiguous to remove. Locked objects are skipped unless
``include_locked`` is True. Scans only; use deleteMalformedTraces to
remove the chosen records.

Params:
include_locked (bool): True to also consider locked objects
Returns:
(list): candidate records (see _cleanupRecord)
"""
candidates = []
for snum, section in self.enumerateSections(
message="Scanning for empty traces...",
):
tform = section.tform
for cname in section.contours:
if not include_locked and self.getAttr(cname, "locked"):
continue
for index, trace in enumerate(section.contours[cname]):
npts = len(trace.points)
if npts == 0:
reason = "No points"
elif trace.closed:
if self._traceArea(trace, tform) == 0:
reason = "Closed trace enclosing zero area"
else:
continue
else:
length, _, _, _ = traceGeometry(
tform.mapPointsArray(trace.points), False
)
if length == 0:
reason = "Open trace of zero length"
else:
continue
candidates.append(self._cleanupRecord(
cname, snum, index, trace, reason=reason,
))
return candidates

def editObjectRadius(self, obj_names : list, new_rad : float, series_states=None):
"""Change the radii of all traces of an object.

Expand Down
2 changes: 1 addition & 1 deletion PyReconstruct/modules/gui/dialog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@
from .backup_comment import BackupCommentDialog
from .table_columns import TableColumnsDialog
from .import_series import ImportSeriesDialog
from .malformed_contours import MalformedContoursDialog
from .malformed_contours import MalformedContoursDialog, PixelDustDialog
100 changes: 82 additions & 18 deletions PyReconstruct/modules/gui/dialog/malformed_contours.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ class MalformedContoursDialog(QDialog):
"""

COLUMNS = ["Object", "Section", "Point count", "Location (x, y)", "Reason"]
WINDOW_TITLE = "Traces skipped during smoothing"

def _columnSpecs(self):
"""Return (record-key, kind) pairs, one per column in COLUMNS.

kind is one of "str", "int", "float", "loc" and controls how the cell
value is stored (numeric kinds sort numerically). Subclasses override
this together with COLUMNS to show different fields.
"""
return [
("name", "str"),
("section", "int"),
("points", "int"),
("location", "loc"),
("reason", "str"),
]

def __init__(self, mainwindow: QWidget, records: list, navigate=None,
delete=None):
Expand Down Expand Up @@ -58,7 +74,7 @@ def __init__(self, mainwindow: QWidget, records: list, navigate=None,
self.navigate = navigate
self.delete = delete

self.setWindowTitle("Traces skipped during smoothing")
self.setWindowTitle(self.WINDOW_TITLE)
self.resize(660, 420)

self.heading = QLabel(self._headingText(), self)
Expand Down Expand Up @@ -179,27 +195,26 @@ def _populate(self):
# resolve it back to the real record via this map; the key travels with
# the row through re-sorting.
self._records_by_key = {}
specs = self._columnSpecs()
for row, r in enumerate(self.records):

self._records_by_key[row] = r

name_item = QTableWidgetItem(str(r["name"]))
name_item.setData(Qt.UserRole, row)

section_item = QTableWidgetItem()
section_item.setData(Qt.DisplayRole, int(r["section"]))

points_item = QTableWidgetItem()
points_item.setData(Qt.DisplayRole, int(r["points"]))

loc = r.get("location")
loc_item = QTableWidgetItem(self._format_location(loc))

reason_item = QTableWidgetItem(str(r["reason"]))

for col, item in enumerate(
(name_item, section_item, points_item, loc_item, reason_item)
):
for col, (key, kind) in enumerate(specs):
item = QTableWidgetItem()
if kind == "int":
item.setData(Qt.DisplayRole, int(r[key]))
elif kind == "float":
# store as a float so the column sorts numerically
item.setData(Qt.DisplayRole, round(float(r[key]), 8))
elif kind == "loc":
item = QTableWidgetItem(self._format_location(r.get(key)))
else: # "str"
item = QTableWidgetItem(str(r[key]))
# a stable per-row key on the first column, resolved back to the
# real record by _recordAtRow; it travels with the row on sort
if col == 0:
item.setData(Qt.UserRole, row)
item.setTextAlignment(Qt.AlignCenter)
# show the full cell value on hover; columns are stretched to
# fit the window, so wider values (e.g. the Reason) truncate
Expand Down Expand Up @@ -334,3 +349,52 @@ def saveCSV(self):
return
with open(fp, "w", newline="") as f:
csv.writer(f).writerows(self._rows_for_export())


class PixelDustDialog(MalformedContoursDialog):
"""Review tiny "pixel-dust" traces before removing them.

A data clean-up review list: every row is a small closed trace at or below
the area threshold the user chose. The user inspects the candidates (and can
"Go to trace" to confirm), then deselects any legitimate small trace before
"Delete selected" / "Delete all". Reuses all of the selection, navigation,
deletion (undoable), and export behaviour of MalformedContoursDialog; only
the columns (an Area column) and the explanatory heading differ.
"""

COLUMNS = ["Object", "Section", "Area (um^2)", "Point count",
"Location (x, y)", "Reason"]
WINDOW_TITLE = "Remove pixel-dust traces"

def _columnSpecs(self):
return [
("name", "str"),
("section", "int"),
("area", "float"),
("points", "int"),
("location", "loc"),
("reason", "str"),
]

def _headingText(self):
"""Explain the pixel-dust review and how to act on it."""
num_traces = len(self.records)
if not num_traces:
return (
"All listed traces have been deleted.\n\n"
"You can close this window."
)

num_objs = len({r["name"] for r in self.records})
trace_word = "trace" if num_traces == 1 else "traces"
obj_word = "object" if num_objs == 1 else "objects"

return (
f"{num_traces} small (pixel-dust) {trace_word} across "
f"{num_objs} {obj_word} at or below the area threshold.\n\n"
"These are typically stray specks left by segmentation. Review the "
"candidates below — select a row and click “Go to trace” to inspect "
"one — and deselect any legitimate trace you want to keep. Then use "
"“Delete selected” or “Delete all” to remove them (can be undone).\n\n"
"Nothing is removed until you choose to delete."
)
1 change: 1 addition & 0 deletions PyReconstruct/modules/gui/main/main_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
ShortcutsDialog,
BackupCommentDialog,
ImportSeriesDialog,
PixelDustDialog,
)

from PyReconstruct.modules.gui.popup import (
Expand Down
68 changes: 68 additions & 0 deletions PyReconstruct/modules/gui/main/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -2617,6 +2617,74 @@ def deleteDuplicateTraces(self):
self.field.reload()
self.seriesModified(True)

def removePixelDustTraces(self):
"""Find tiny "pixel-dust" traces and remove them through a review list.

Scans the series for small closed traces at or below a user-chosen area
threshold (um^2), then opens a reviewable list: nothing is removed until
the user inspects the candidates, deselects any to keep, and deletes.
Deletion is a single undoable operation (see deleteMalformedContours).
"""
self.saveAllData()

structure = [
["Maximum trace area (um^2):", ("float", 0.01)],
]
response, confirmed = QuickDialog.get(
self, structure, "Remove pixel-dust traces"
)
if not confirmed:
return
threshold = response[0]
if threshold is None or threshold <= 0:
notify("Please enter an area threshold greater than zero.")
return

# locked objects are always left alone: the review-list delete path
# (deleteMalformedContours) refuses locked objects, so surfacing them
# here would be a dead end. Empty-trace removal skips locked the same way.
candidates = self.series.findPixelDustTraces(threshold)
if not candidates:
notify("No pixel-dust traces found at or below that area.")
return

# reviewable list: the user confirms/deselects before anything is deleted
self.pixel_dust_dialog = PixelDustDialog(
self,
candidates,
navigate=self.field.focusMalformedContour,
delete=self.field.deleteMalformedContours,
)
self.pixel_dust_dialog.show()

def removeEmptyTraces(self):
"""Find and remove empty / degenerate traces (no meaningful geometry).

These are unambiguous (no points, zero-area closed traces, or zero-length
open traces), so they are removed after a single count-stating
confirmation rather than a review list. Locked objects are left alone.
Removal is one undoable operation.
"""
self.saveAllData()

candidates = self.series.findEmptyTraces(include_locked=False)
if not candidates:
notify("No empty traces found.")
return

count = len(candidates)
noun = "empty trace" if count == 1 else "empty traces"
if not notifyConfirm(
f"Remove {count} {noun} from the series?\n\n"
"This can be undone (Ctrl+Z).",
yn=True,
):
return

deleted = self.field.deleteMalformedContours(candidates)
if deleted:
notify(f"Removed {len(deleted)} {noun}.")

def addTo3D(self, names, ztraces=False):
"""Generate the 3D view for a list of objects.

Expand Down
Loading
Loading