diff --git a/PyReconstruct/modules/datatypes/series.py b/PyReconstruct/modules/datatypes/series.py index 3696b9b3..1a71392a 100644 --- a/PyReconstruct/modules/datatypes/series.py +++ b/PyReconstruct/modules/datatypes/series.py @@ -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, @@ -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 @@ -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 @@ -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. diff --git a/PyReconstruct/modules/gui/dialog/__init__.py b/PyReconstruct/modules/gui/dialog/__init__.py index 84bda0c4..7a82b4d6 100644 --- a/PyReconstruct/modules/gui/dialog/__init__.py +++ b/PyReconstruct/modules/gui/dialog/__init__.py @@ -20,4 +20,4 @@ from .backup_comment import BackupCommentDialog from .table_columns import TableColumnsDialog from .import_series import ImportSeriesDialog -from .malformed_contours import MalformedContoursDialog \ No newline at end of file +from .malformed_contours import MalformedContoursDialog, PixelDustDialog \ No newline at end of file diff --git a/PyReconstruct/modules/gui/dialog/malformed_contours.py b/PyReconstruct/modules/gui/dialog/malformed_contours.py index d25a7507..27881a57 100644 --- a/PyReconstruct/modules/gui/dialog/malformed_contours.py +++ b/PyReconstruct/modules/gui/dialog/malformed_contours.py @@ -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): @@ -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) @@ -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 @@ -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." + ) diff --git a/PyReconstruct/modules/gui/main/main_imports.py b/PyReconstruct/modules/gui/main/main_imports.py index 8795980e..0433fd74 100644 --- a/PyReconstruct/modules/gui/main/main_imports.py +++ b/PyReconstruct/modules/gui/main/main_imports.py @@ -59,6 +59,7 @@ ShortcutsDialog, BackupCommentDialog, ImportSeriesDialog, + PixelDustDialog, ) from PyReconstruct.modules.gui.popup import ( diff --git a/PyReconstruct/modules/gui/main/main_window.py b/PyReconstruct/modules/gui/main/main_window.py index 4cd9b854..66546349 100644 --- a/PyReconstruct/modules/gui/main/main_window.py +++ b/PyReconstruct/modules/gui/main/main_window.py @@ -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. diff --git a/PyReconstruct/modules/gui/main/menubar.py b/PyReconstruct/modules/gui/main/menubar.py index 72cc4622..fb2e9a49 100644 --- a/PyReconstruct/modules/gui/main/menubar.py +++ b/PyReconstruct/modules/gui/main/menubar.py @@ -193,7 +193,16 @@ def return_series_menu(self): }, None, ("findobjectfirst_act", "Find first object contour...", self.series, self.findObjectFirst), - ("removeduplicates_act", "Remove duplicate traces", "", self.deleteDuplicateTraces), + { + "attr_name": "cleanupmenu", + "text": "Clean up", + "opts": + [ + ("removeduplicates_act", "Remove duplicate traces...", "", self.deleteDuplicateTraces), + ("removepixeldust_act", "Remove pixel-dust traces...", "", self.removePixelDustTraces), + ("removeempty_act", "Remove empty traces...", "", self.removeEmptyTraces), + ] + }, None, ("updatecuration_act", "Update curation from history", "", self.updateCurationFromHistory), None, diff --git a/WHATS_NEW.md b/WHATS_NEW.md index b6746157..026511a8 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -6,6 +6,7 @@ full release notes on GitHub (linked from the dialog). ## [Unreleased] +- **New "Clean up" tools tidy stray traces in your series.** Under the Series menu, a new "Clean up" submenu can remove duplicate traces, find tiny "pixel-dust" specks below an area you choose (shown in a list you review and trim before anything is deleted), and remove empty traces that hold no real shape. Every clean-up is a single step you can undo (Ctrl+Z), and locked objects are left untouched. - **The "Developer" update channel has been removed.** Updates now come on two channels, Stable and Beta, and Beta remains the right home for testers. If you were on Developer, PyReconstruct now keeps you on Beta automatically, so you do not need to change anything. To follow the very latest code between betas, install from source and run "git pull" (see the README). ## [1.21.0-beta-4] — 2026-07-18 diff --git a/tests/test_data_cleanup.py b/tests/test_data_cleanup.py new file mode 100644 index 00000000..6eca9ebf --- /dev/null +++ b/tests/test_data_cleanup.py @@ -0,0 +1,318 @@ +"""Tests for the data clean-up operations (Series menu "Clean up", issue #67). + +Three operations, all built on the proven series-states bulk-edit path so each +is a single undoable action with a progress bar: + + * Remove duplicate traces -> Series.deleteDuplicateTraces (pre-existing; + same-name + geometrically-coincident only). Guarded here against the + false positive of merging two *distinct* objects that happen to coincide. + * Remove pixel-dust traces -> Series.findPixelDustTraces (scan) + a review + list that deletes via Series.deleteMalformedTraces. Small closed traces at + or below an area threshold (um^2); large traces and zero-area/degenerate + traces are NOT flagged. + * Remove empty traces -> Series.findEmptyTraces (scan) + the same + signature-based delete. Zero-area closed / zero-length open / no-point + traces only; real traces (including small pixel-dust) are NOT flagged. + +Everything runs end-to-end against the real shapes1.jser fixture with synthetic +traces layered on, plus a real SeriesStates to prove single-undo restoration. +""" +import os +import shutil + +import pytest + +FIXTURE = os.path.join( + os.path.dirname(__file__), "..", "PyReconstruct", "assets", + "checker", "files", "shapes1.jser", +) + + +def _load_series(tmp_path): + if not os.path.exists(FIXTURE): + pytest.skip("fixture shapes1.jser not found") + fp = str(tmp_path / "shapes1.jser") + shutil.copyfile(FIXTURE, fp) + + from PySide6.QtWidgets import QApplication + QApplication.instance() or QApplication(["test"]) + from PyReconstruct.modules.datatypes.series import Series + from PyReconstruct.modules.datatypes.series_data import SeriesData + from PyReconstruct.modules.backend.progress import NullProgressReporter + + series = Series.openJser(fp) + sd = SeriesData(series) + sd.refresh() + series.data = sd + series.setProgressReporter(NullProgressReporter) + return series + + +def _template_trace(section): + """A real closed trace from the section, to clone attributes from.""" + for cname in section.contours: + for trace in section.contours[cname]: + if trace.closed and len(trace.points) >= 3: + return trace + pytest.skip("no closed trace in fixture section") + + +def _make(section, name, points, closed=True): + """Clone a real trace's attributes with new name/points, add + save.""" + t = _template_trace(section).copy() + t.name = name + t.points = list(points) + t.closed = closed + section.addTrace(t, log_event=False) + return t + + +def _snum_with_closed(series): + """First section number that has a usable closed template trace.""" + for snum in sorted(series.sections): + section = series.loadSection(snum) + for cname in section.contours: + for trace in section.contours[cname]: + if trace.closed and len(trace.points) >= 3: + return snum + pytest.skip("no closed trace anywhere in fixture") + + +def _new_states(series): + from PyReconstruct.modules.backend.func.state_manager import SeriesStates + return SeriesStates(series) + + +def _count(series, snum, name): + return len(series.loadSection(snum).contours.get(name, [])) + + +# --------------------------------------------------------------------------- +# pixel-dust +# --------------------------------------------------------------------------- + +def test_pixel_dust_flags_only_small_closed_traces(tmp_path): + """A tiny closed trace is flagged; a large one and an open one are not.""" + series = _load_series(tmp_path) + snum = _snum_with_closed(series) + section = series.loadSection(snum) + + _make(section, "DUST", [(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1)]) + _make(section, "BIG", [(0, 0), (50, 0), (50, 50), (0, 50)]) + _make(section, "LINE", [(0, 0), (0.1, 0), (0.2, 0)], closed=False) + section.save() + + from PyReconstruct.modules.datatypes.series import Series + reloaded = series.loadSection(snum) + dust_area = Series._traceArea(reloaded.contours["DUST"][0], reloaded.tform) + big_area = Series._traceArea(reloaded.contours["BIG"][0], reloaded.tform) + assert 0 < dust_area < big_area + + threshold = (dust_area + big_area) / 2 + records = series.findPixelDustTraces(threshold) + names = {r["name"] for r in records} + assert "DUST" in names + assert "BIG" not in names # above threshold + assert "LINE" not in names # open trace, no area + # every record carries the area used for display + a delete signature + dust_rec = next(r for r in records if r["name"] == "DUST") + assert dust_rec["area"] == pytest.approx(dust_area) + assert "match" in dust_rec and dust_rec["points"] == 4 + + +def test_pixel_dust_threshold_is_inclusive_edge(tmp_path): + """A trace exactly at the threshold is flagged; just below the area is not.""" + series = _load_series(tmp_path) + snum = _snum_with_closed(series) + section = series.loadSection(snum) + _make(section, "DUST", [(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1)]) + section.save() + + from PyReconstruct.modules.datatypes.series import Series + area = Series._traceArea( + series.loadSection(snum).contours["DUST"][0], + series.loadSection(snum).tform, + ) + # exactly at area -> inclusive hit + assert {r["name"] for r in series.findPixelDustTraces(area)} >= {"DUST"} + # strictly below -> miss + assert "DUST" not in {r["name"] for r in series.findPixelDustTraces(area * 0.99)} + + +def test_pixel_dust_scan_does_not_modify(tmp_path): + """Scanning never removes anything; only an explicit delete does.""" + series = _load_series(tmp_path) + snum = _snum_with_closed(series) + section = series.loadSection(snum) + _make(section, "DUST", [(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1)]) + section.save() + + series.findPixelDustTraces(1e9) # huge threshold, would match everything + assert _count(series, snum, "DUST") == 1, "scan must not delete" + + +def test_pixel_dust_skips_locked_unless_included(tmp_path): + """Locked objects are excluded from candidates by default.""" + series = _load_series(tmp_path) + snum = _snum_with_closed(series) + section = series.loadSection(snum) + _make(section, "DUST", [(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1)]) + section.save() + series.setAttr("DUST", "locked", True) + + assert "DUST" not in {r["name"] for r in series.findPixelDustTraces(1e9)} + assert "DUST" in { + r["name"] for r in series.findPixelDustTraces(1e9, include_locked=True) + } + + +def test_pixel_dust_delete_is_single_undo(tmp_path): + """Deleting flagged dust across sections undoes in one step.""" + series = _load_series(tmp_path) + snum = _snum_with_closed(series) + section = series.loadSection(snum) + _make(section, "DUST", [(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1)]) + section.save() + + from PyReconstruct.modules.datatypes.series import Series + area = Series._traceArea( + series.loadSection(snum).contours["DUST"][0], + series.loadSection(snum).tform, + ) + records = series.findPixelDustTraces(area * 1.5) + assert records + + states = _new_states(series) + deleted = series.deleteMalformedTraces(records, series_states=states) + assert len(deleted) == len(records) + assert _count(series, snum, "DUST") == 0 + + can, _, _ = states.canUndo() + assert can + states.undoState() + assert _count(series, snum, "DUST") == 1, "undo restores the dust trace" + + +def test_pixel_dust_delete_across_sections_single_undo(tmp_path): + """Dust on several sections is removed and restored by ONE series undo.""" + series = _load_series(tmp_path) + dust_pts = [(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1)] + snums = sorted(series.sections)[:3] + for snum in snums: + section = series.loadSection(snum) + _make(section, "DUST", dust_pts) + section.save() + + records = series.findPixelDustTraces(1e6) + touched = {r["section"] for r in records if r["name"] == "DUST"} + assert touched == set(snums), "dust found on every seeded section" + + states = _new_states(series) + series.deleteMalformedTraces( + [r for r in records if r["name"] == "DUST"], series_states=states + ) + for snum in snums: + assert _count(series, snum, "DUST") == 0 + + states.undoState() # single undo + for snum in snums: + assert _count(series, snum, "DUST") == 1, \ + f"one undo must restore dust on section {snum}" + + +# --------------------------------------------------------------------------- +# empty / degenerate +# --------------------------------------------------------------------------- + +def test_empty_flags_degenerate_only(tmp_path): + """Zero-area closed and zero-length open traces are flagged; real ones not.""" + series = _load_series(tmp_path) + snum = _snum_with_closed(series) + section = series.loadSection(snum) + + _make(section, "COLLINEAR", [(0, 0), (1, 1), (2, 2)]) # closed, area 0 + _make(section, "ZEROLEN", [(5, 5), (5, 5)], closed=False) # open, length 0 + _make(section, "DUST", [(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1)]) # real area + _make(section, "BIG", [(0, 0), (50, 0), (50, 50), (0, 50)]) + section.save() + + names = {r["name"] for r in series.findEmptyTraces()} + assert "COLLINEAR" in names + assert "ZEROLEN" in names + assert "DUST" not in names # tiny but non-zero area -> pixel dust, not empty + assert "BIG" not in names + + +def test_empty_delete_is_single_undo(tmp_path): + """Removing empty traces is one undoable operation.""" + series = _load_series(tmp_path) + snum = _snum_with_closed(series) + section = series.loadSection(snum) + _make(section, "COLLINEAR", [(0, 0), (1, 1), (2, 2)]) + section.save() + + records = series.findEmptyTraces() + assert {r["name"] for r in records} >= {"COLLINEAR"} + + states = _new_states(series) + deleted = series.deleteMalformedTraces(records, series_states=states) + assert _count(series, snum, "COLLINEAR") == 0 + + states.undoState() + assert _count(series, snum, "COLLINEAR") == 1 + + +def test_empty_skips_locked(tmp_path): + """Locked objects are never flagged as empty.""" + series = _load_series(tmp_path) + snum = _snum_with_closed(series) + section = series.loadSection(snum) + _make(section, "COLLINEAR", [(0, 0), (1, 1), (2, 2)]) + section.save() + series.setAttr("COLLINEAR", "locked", True) + assert "COLLINEAR" not in {r["name"] for r in series.findEmptyTraces()} + + +# --------------------------------------------------------------------------- +# duplicates (pre-existing op) + false-positive guard +# --------------------------------------------------------------------------- + +def test_duplicate_removal_and_undo(tmp_path): + """Two identical traces of the SAME object collapse to one, undoably.""" + series = _load_series(tmp_path) + snum = _snum_with_closed(series) + section = series.loadSection(snum) + pts = [(0, 0), (10, 0), (10, 10), (0, 10)] + _make(section, "DUPE", pts) + _make(section, "DUPE", list(pts)) # exact duplicate, same name + section.save() + assert _count(series, snum, "DUPE") == 2 + + states = _new_states(series) + removed = series.deleteDuplicateTraces(0.95, series_states=states) + assert snum in removed and "DUPE" in removed[snum] + assert _count(series, snum, "DUPE") == 1 + + states.undoState() + assert _count(series, snum, "DUPE") == 2, "undo restores the duplicate" + + +def test_duplicate_removal_does_not_merge_distinct_objects(tmp_path): + """Two coincident traces with DIFFERENT names are not duplicates. + + This is the key false-positive guard: legitimately-overlapping distinct + objects must survive, because duplicate detection is scoped per object + name (per contour), never geometry-only across objects. + """ + series = _load_series(tmp_path) + snum = _snum_with_closed(series) + section = series.loadSection(snum) + pts = [(0, 0), (10, 0), (10, 10), (0, 10)] + _make(section, "OBJ_A", pts) + _make(section, "OBJ_B", list(pts)) # identical geometry, different object + section.save() + + states = _new_states(series) + series.deleteDuplicateTraces(0.95, series_states=states) + assert _count(series, snum, "OBJ_A") == 1 + assert _count(series, snum, "OBJ_B") == 1