segfix 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
segfix/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """segfix — fix instance segmentation of tree point clouds."""
2
+
3
+ from .model import NOISE, UNASSIGNED, PointCloud
4
+
5
+ __all__ = ["PointCloud", "UNASSIGNED", "NOISE"]
6
+ __version__ = "0.1.0"
segfix/analysis.py ADDED
@@ -0,0 +1,68 @@
1
+ """Segmentation-quality analysis: spatial neighbour queries.
2
+
3
+ UI-agnostic, like :mod:`~segfix.operations`. Point-set distances are
4
+ approximated by sampling each tree down to a few thousand points and querying
5
+ a KD-tree — exact enough for "does this touch?" questions at forest scale,
6
+ and fast enough to run per keypress.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ from .model import NOISE, UNASSIGNED, PointCloud
14
+
15
+ _SAMPLE_CAP = 3000
16
+
17
+
18
+ def _sample(rng, idx: np.ndarray, cap: int = _SAMPLE_CAP) -> np.ndarray:
19
+ if idx.size <= cap:
20
+ return idx
21
+ return rng.choice(idx, cap, replace=False)
22
+
23
+
24
+ def neighbours_by_points(
25
+ cloud: PointCloud, tid: int, reach: float, rng=None
26
+ ) -> set[int]:
27
+ """IDs of trees whose points come within ``reach`` metres of tree
28
+ ``tid``'s points.
29
+
30
+ Bounding-box tests massively over-count neighbours in a closed canopy
31
+ (a tall tree's box spans its whole crown), so boxes are only used as a
32
+ prefilter; candidates are confirmed by sampled point-to-point distance.
33
+ """
34
+ from scipy.spatial import cKDTree
35
+
36
+ rng = rng or np.random.default_rng(0)
37
+ labels, coords = cloud.labels, cloud.coords
38
+ mine = np.flatnonzero(labels == tid)
39
+ if mine.size == 0:
40
+ return set()
41
+ lo = coords[mine].min(axis=0) - reach
42
+ hi = coords[mine].max(axis=0) + reach
43
+ # Per-axis chained test avoids two full (N, 3) boolean temporaries.
44
+ cx, cy, cz = coords[:, 0], coords[:, 1], coords[:, 2]
45
+ in_box = (
46
+ (cx >= lo[0]) & (cx <= hi[0])
47
+ & (cy >= lo[1]) & (cy <= hi[1])
48
+ & (cz >= lo[2]) & (cz <= hi[2])
49
+ )
50
+ in_box[mine] = False
51
+ # Everything below works off the in-box points only, so the per-candidate
52
+ # loop no longer rescans all N labels once per candidate.
53
+ box_idx = np.flatnonzero(in_box)
54
+ box_labels = labels[box_idx]
55
+ cand = np.unique(box_labels)
56
+ cand = cand[(cand != UNASSIGNED) & (cand != NOISE) & (cand != tid)]
57
+ if not cand.size:
58
+ return set()
59
+ kd = cKDTree(coords[_sample(rng, mine)])
60
+ out: set[int] = set()
61
+ for t in cand:
62
+ theirs = box_idx[box_labels == t]
63
+ d, _ = kd.query(
64
+ coords[_sample(rng, theirs)], k=1, distance_upper_bound=reach
65
+ )
66
+ if np.isfinite(d).any():
67
+ out.add(int(t))
68
+ return out
segfix/app.py ADDED
@@ -0,0 +1,280 @@
1
+ """Command-line entry point.
2
+
3
+ ``segfix`` always opens with a startup dialog to pick a recent project or
4
+ import a new file — there's no way to pass a cloud path directly on the
5
+ command line, so every session goes through the registry. See
6
+ :mod:`registry`/:mod:`startup_ui`.
7
+
8
+ Once a path is chosen it opens in the one editing mode: a table of every tree
9
+ in the file, where double-clicking a row loads that tree plus its spatial
10
+ neighbours rather than the whole cloud. See :mod:`treecatalog`/:mod:`scene_ui`.
11
+
12
+ That mode needs to seek to an arbitrary point without parsing everything
13
+ before it, so the accepted formats are the ones that store points as
14
+ fixed-size records at a known offset: binary PLY and uncompressed LAS.
15
+ arbor's ``.laz`` output is decompressed to ``.las`` on import (see
16
+ :mod:`segfix.workspace`). :func:`segfix.io.load` rejects the rest.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import sys
23
+
24
+
25
+ def _prefer_discrete_gpu() -> None:
26
+ """Best-effort: on a hybrid-graphics machine, point OpenGL/Direct3D at
27
+ the discrete GPU instead of whatever the platform defaults to (often
28
+ the weaker integrated one — see the "GPU:" status-bar label added by
29
+ :func:`viewer.add_gpu_status_widget` to confirm which one actually got
30
+ used). Detects hardware rather than hardcoding one machine's GPU model
31
+ name, never overrides a preference already set (env var on Linux, the
32
+ registry key on Windows), and never raises — worst case this is a
33
+ no-op and the platform default stands.
34
+
35
+ Must run before ``import napari`` (like the Wayland workaround below):
36
+ the Linux/WSL half relies on the underlying GL/EGL libraries not having
37
+ picked an adapter yet, which happens at first context creation, not at
38
+ process start — setting the env var any later risks losing the race
39
+ against whichever import gets there first. The Windows half doesn't
40
+ have that race (it's a registry key, not an env var) but only takes
41
+ effect from the *next* launch of this Python executable, not this one:
42
+ Windows reads a process's GPU preference at process creation, before
43
+ any of our code has had a chance to run.
44
+ """
45
+ try:
46
+ if sys.platform == "win32":
47
+ _prefer_discrete_gpu_windows()
48
+ elif sys.platform == "linux":
49
+ _prefer_discrete_gpu_linux()
50
+ # macOS: no per-process lever like the two above. Apple Silicon has
51
+ # a single GPU anyway; Intel-Mac dual-GPU switching is a system
52
+ # Energy Saver setting, not something a process can request.
53
+ except Exception:
54
+ pass # detection failing should never block startup
55
+
56
+
57
+ def _prefer_discrete_gpu_linux() -> None:
58
+ import os
59
+ import shutil
60
+ import subprocess
61
+
62
+ is_wsl = False
63
+ try:
64
+ with open("/proc/version", encoding="utf-8") as f:
65
+ is_wsl = "microsoft" in f.read().lower()
66
+ except OSError:
67
+ pass
68
+ has_nvidia = shutil.which("nvidia-smi") is not None and subprocess.run(
69
+ ["nvidia-smi", "-L"], capture_output=True, timeout=2
70
+ ).returncode == 0
71
+ if not has_nvidia:
72
+ return
73
+ if is_wsl:
74
+ # Mesa's D3D12 backend (the only GL path WSLg offers) matches this
75
+ # against the adapter names Windows exposes, substring not exact —
76
+ # so it still works if the discrete card is a different NVIDIA
77
+ # model than whatever machine this was tested on.
78
+ os.environ.setdefault("MESA_D3D12_DEFAULT_ADAPTER_NAME", "NVIDIA")
79
+ else:
80
+ # Native Linux NVIDIA Optimus/PRIME laptops: ask for the discrete
81
+ # GPU's GLX vendor lib instead of the integrated one.
82
+ os.environ.setdefault("__NV_PRIME_RENDER_OFFLOAD", "1")
83
+ os.environ.setdefault("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
84
+
85
+
86
+ def _prefer_discrete_gpu_windows() -> None:
87
+ """Set this Python executable's GPU preference to "High performance" in
88
+ the per-app registry key Settings > System > Display > Graphics also
89
+ writes to — the documented, vendor-agnostic way one process can ask
90
+ Windows for the discrete GPU without touching any other app's choice.
91
+ HKEY_CURRENT_USER, so it needs no elevation; keyed by ``sys.executable``,
92
+ so it only affects this specific Python (e.g. one conda env), not every
93
+ Python on the machine, and never touches a value already set — by the
94
+ user via Settings, or by this same call on a previous run.
95
+ """
96
+ import winreg
97
+
98
+ exe = sys.executable
99
+ key = winreg.CreateKeyEx(
100
+ winreg.HKEY_CURRENT_USER,
101
+ r"Software\Microsoft\DirectX\UserGpuPreferences",
102
+ 0,
103
+ winreg.KEY_SET_VALUE | winreg.KEY_QUERY_VALUE,
104
+ )
105
+ try:
106
+ try:
107
+ winreg.QueryValueEx(key, exe)
108
+ return # already has a preference (ours or the user's) — leave it
109
+ except FileNotFoundError:
110
+ pass
111
+ winreg.SetValueEx(key, exe, 0, winreg.REG_SZ, "GpuPreference=2;")
112
+ finally:
113
+ winreg.CloseKey(key)
114
+
115
+
116
+ def main(argv=None) -> int:
117
+ _prefer_discrete_gpu()
118
+ parser = argparse.ArgumentParser(
119
+ prog="segfix",
120
+ description="GUI tool to fix tree point-cloud instance segmentation.",
121
+ )
122
+ parser.add_argument(
123
+ "--label-field",
124
+ default=None,
125
+ help="Name of the per-point tree/instance ID field (auto-detected if omitted)",
126
+ )
127
+ parser.add_argument(
128
+ "--point-size", type=float, default=0.01,
129
+ help="Render size of points in metres (default: 0.01)",
130
+ )
131
+ args = parser.parse_args(argv)
132
+
133
+ # Must import napari before any QApplication exists (including the one
134
+ # the startup dialog below would otherwise create first): napari applies
135
+ # a Wayland+NVIDIA OpenGL workaround at import time that only works if
136
+ # nothing has created a Qt application yet. Doing this out of order is a
137
+ # real, reproduced crash — the app launches but every draw call fails
138
+ # with "OpenGL.error.GLError: invalid enumerant" / "no valid context".
139
+ import napari
140
+
141
+ from . import registry
142
+ from .startup_ui import choose_project
143
+
144
+ choice = choose_project()
145
+ if choice is None:
146
+ return 0
147
+ open_path, registry_path, kind = choice
148
+ args.cloud = open_path
149
+ registry.add_entry(registry_path, kind=kind)
150
+
151
+ return _run_scene(napari, args)
152
+
153
+
154
+ def _combined_panel(*widgets):
155
+ """Stack several dock-panel widgets into one scrollable widget.
156
+
157
+ Used so scene mode's own tree table sits above the shared segfix editing
158
+ panel in a single right-hand dock, instead of a separate left dock —
159
+ everything lives in one place.
160
+ """
161
+ from qtpy.QtWidgets import QScrollArea, QVBoxLayout, QWidget
162
+
163
+ inner = QWidget()
164
+ lay = QVBoxLayout(inner)
165
+ lay.setContentsMargins(4, 4, 4, 4)
166
+ lay.setSpacing(4)
167
+ for w in widgets:
168
+ lay.addWidget(w)
169
+ lay.addStretch()
170
+ scroll = QScrollArea()
171
+ scroll.setWidgetResizable(True)
172
+ scroll.setWidget(inner)
173
+ return scroll
174
+
175
+
176
+ def _dock_right(viewer, widget):
177
+ """Dock ``widget`` on the right, sized to fill the full window height.
178
+
179
+ ``QtViewerDockWidget.__init__`` (napari) unconditionally sets the docked
180
+ widget's vertical size policy to ``QSizePolicy.Maximum`` — capped at its
181
+ sizeHint, regardless of available space — independently of the
182
+ ``add_vertical_stretch`` option. That leaves a lone right-hand dock sized
183
+ to a fraction of the window, forcing users to scroll for content that
184
+ would otherwise fit. Override it to ``Expanding`` so the dock claims the
185
+ rest of the window height, then nudge it with the oversized-resizeDocks
186
+ idiom napari itself uses for its own layer-list dock at startup
187
+ (qt_main_window.py's ``_QtMainWindow.__init__``).
188
+ """
189
+ from qtpy.QtCore import Qt
190
+ from qtpy.QtWidgets import QSizePolicy
191
+
192
+ dock = viewer.window.add_dock_widget(widget, name="segfix", area="right")
193
+ policy = dock.widget().sizePolicy()
194
+ policy.setVerticalPolicy(QSizePolicy.Policy.Expanding)
195
+ dock.widget().setSizePolicy(policy)
196
+ viewer.window._qt_window.resizeDocks([dock], [10000], Qt.Orientation.Vertical)
197
+ # Qt's default corner ownership gives the top-right corner to the top
198
+ # dock area, which pushes the right dock's top edge down below it —
199
+ # leaving a gap of bare canvas in that corner. Give the corner to the
200
+ # right dock instead so it reaches all the way up to y=0.
201
+ viewer.window._qt_window.setCorner(
202
+ Qt.Corner.TopRightCorner, Qt.DockWidgetArea.RightDockWidgetArea
203
+ )
204
+ return dock
205
+
206
+
207
+ def _dock_top(viewer, panel) -> None:
208
+ """Dock ``panel.top_bar`` (Interaction / View / Cross section) along the
209
+ top of the window — it's about the canvas/viewport, not the tree-review
210
+ workflow the main side panel is organised around."""
211
+ viewer.window.add_dock_widget(panel.top_bar, name="view", area="top")
212
+
213
+
214
+ def _run_scene(napari, args) -> int:
215
+ """Default mode for a single big cloud (binary PLY or uncompressed LAS):
216
+ a tree table where picking a row loads that tree plus its neighbours,
217
+ instead of the whole cloud."""
218
+ import numpy as np
219
+
220
+ from .model import PointCloud
221
+ from .scene_ui import SceneController, SceneWidget
222
+ from .treecatalog import open_catalog
223
+ from .viewer import (
224
+ add_cloud_layer,
225
+ add_gpu_status_widget,
226
+ apply_cloudcompare_controls,
227
+ busy,
228
+ strip_ui,
229
+ )
230
+ from .widgets import SegFixController, SegFixWidget, bind_shortcuts
231
+
232
+ viewer = napari.Viewer(title=f"segfix — {args.cloud}")
233
+ from .icons import app_icon
234
+
235
+ viewer.window._qt_window.setWindowIcon(app_icon())
236
+ viewer.window._qt_window.showMaximized()
237
+ # Before any loading: strip_ui/apply_cloudcompare_controls only touch
238
+ # napari's own built-in chrome (menu bar, default docks, camera), not
239
+ # anything of ours, so they're safe this early — and doing them now
240
+ # means the "busy" status repaint below (and the catalog scan) never
241
+ # flashes the default, unstripped napari GUI first.
242
+ apply_cloudcompare_controls(viewer)
243
+ strip_ui(viewer)
244
+ # An empty Points layer, added before any (potentially slow) loading:
245
+ # with zero layers, napari shows its own "drag a file here" welcome
246
+ # screen over the canvas — adding this one, even with no points yet,
247
+ # dismisses that so the busy status below isn't fighting it for
248
+ # attention.
249
+ empty = PointCloud(
250
+ coords=np.empty((0, 3), np.float32), labels=np.empty(0, np.int32)
251
+ )
252
+ layer = add_cloud_layer(viewer, empty, point_size=args.point_size)
253
+ # After the first layer add, so the GL context exists to read a renderer
254
+ # string from (see gpu_renderer_info).
255
+ add_gpu_status_widget(viewer)
256
+ busy(viewer, f"Scanning trees in {args.cloud}…")
257
+
258
+ catalog = open_catalog(args.cloud, label_field=args.label_field)
259
+
260
+ seg = SegFixController(viewer, empty, layer)
261
+ panel = SegFixWidget(seg)
262
+ scene_ctrl = SceneController(viewer, catalog, seg, point_size=args.point_size)
263
+ scene_panel = SceneWidget(scene_ctrl)
264
+ panel.on_done_changed = scene_panel.refresh
265
+
266
+ _dock_top(viewer, panel)
267
+ _dock_right(viewer, _combined_panel(scene_panel, panel))
268
+ panel.size_spin.setValue(args.point_size)
269
+ bind_shortcuts(viewer, panel)
270
+
271
+ viewer.status = (
272
+ f"{len(catalog.records)} trees in {args.cloud}. "
273
+ "Double-click a tree to load it with neighbours."
274
+ )
275
+ napari.run()
276
+ return 0
277
+
278
+
279
+ if __name__ == "__main__":
280
+ sys.exit(main())
segfix/icons.py ADDED
@@ -0,0 +1,302 @@
1
+ """Programmatically drawn icons for the segfix buttons.
2
+
3
+ Icons are painted with QPainter at runtime rather than shipped as files:
4
+ they stay crisp at any DPI, share one visual style, and adding one is a few
5
+ lines of path code. All drawing happens in a 32×32 coordinate space; colours
6
+ are chosen for napari's dark theme.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from qtpy.QtCore import QPointF, QRectF, Qt
12
+ from qtpy.QtGui import (
13
+ QColor,
14
+ QIcon,
15
+ QPainter,
16
+ QPainterPath,
17
+ QPen,
18
+ QPixmap,
19
+ QPolygonF,
20
+ )
21
+
22
+ FG = QColor("#d8d8d8")
23
+ GREEN = QColor("#86c98a")
24
+ RED = QColor("#e08080")
25
+ BLUE = QColor("#7fb3e0")
26
+ AMBER = QColor("#e0c060")
27
+
28
+
29
+ def _pen(color=FG, width=2.4, dashed=False) -> QPen:
30
+ pen = QPen(color)
31
+ pen.setWidthF(width)
32
+ pen.setCapStyle(Qt.RoundCap)
33
+ pen.setJoinStyle(Qt.RoundJoin)
34
+ if dashed:
35
+ pen.setStyle(Qt.DashLine)
36
+ return pen
37
+
38
+
39
+ def _arrowhead(p: QPainter, tip, left, right, color=FG) -> None:
40
+ p.setPen(Qt.NoPen)
41
+ p.setBrush(color)
42
+ p.drawPolygon(QPolygonF([QPointF(*tip), QPointF(*left), QPointF(*right)]))
43
+
44
+
45
+ def _lasso(p: QPainter) -> None:
46
+ path = QPainterPath(QPointF(9, 19))
47
+ path.cubicTo(QPointF(3, 10), QPointF(12, 3), QPointF(19, 5))
48
+ path.cubicTo(QPointF(28, 7), QPointF(29, 15), QPointF(23, 18))
49
+ path.cubicTo(QPointF(18, 21), QPointF(12, 22), QPointF(9, 19))
50
+ p.drawPath(path)
51
+ p.drawLine(QPointF(9, 19), QPointF(16, 28)) # rope tail
52
+
53
+
54
+ def _target(p: QPainter) -> None:
55
+ p.drawEllipse(QRectF(8, 8, 16, 16))
56
+ for a, b in [((16, 3), (16, 9)), ((16, 23), (16, 29)),
57
+ ((3, 16), (9, 16)), ((23, 16), (29, 16))]:
58
+ p.drawLine(QPointF(*a), QPointF(*b))
59
+ p.setPen(Qt.NoPen)
60
+ p.setBrush(AMBER)
61
+ p.drawEllipse(QPointF(16, 16), 2.5, 2.5)
62
+
63
+
64
+ def _reassign(p: QPainter) -> None:
65
+ p.drawLine(QPointF(4, 16), QPointF(16, 16))
66
+ _arrowhead(p, (20, 16), (14, 12), (14, 20))
67
+ p.setPen(_pen(BLUE))
68
+ p.setBrush(Qt.NoBrush)
69
+ p.drawEllipse(QRectF(19, 11, 10, 10))
70
+
71
+
72
+ def _new_tree(p: QPainter) -> None:
73
+ p.setPen(_pen(GREEN))
74
+ p.drawEllipse(QRectF(6, 6, 20, 20))
75
+ p.drawLine(QPointF(16, 11), QPointF(16, 21))
76
+ p.drawLine(QPointF(11, 16), QPointF(21, 16))
77
+
78
+
79
+ def _merge(p: QPainter) -> None:
80
+ p.setPen(_pen(BLUE))
81
+ p.drawEllipse(QRectF(4, 9, 14, 14))
82
+ p.drawEllipse(QRectF(14, 9, 14, 14))
83
+
84
+
85
+ def _noise(p: QPainter) -> None:
86
+ p.setPen(_pen(RED, 3.0))
87
+ p.drawLine(QPointF(9, 9), QPointF(23, 23))
88
+ p.drawLine(QPointF(23, 9), QPointF(9, 23))
89
+
90
+
91
+ def _unassign(p: QPainter) -> None:
92
+ p.setPen(_pen(dashed=True))
93
+ p.drawEllipse(QRectF(7, 7, 18, 18))
94
+
95
+
96
+ def _split(p: QPainter) -> None:
97
+ p.setPen(_pen(BLUE))
98
+ p.drawLine(QPointF(16, 12), QPointF(8, 22))
99
+ p.drawLine(QPointF(16, 12), QPointF(24, 22))
100
+ p.setBrush(Qt.NoBrush)
101
+ p.drawEllipse(QPointF(16, 7), 4, 4)
102
+ p.drawEllipse(QPointF(7, 25), 4, 4)
103
+ p.drawEllipse(QPointF(25, 25), 4, 4)
104
+
105
+
106
+ def _isolate(p: QPainter) -> None:
107
+ path = QPainterPath(QPointF(4, 16))
108
+ path.cubicTo(QPointF(10, 7), QPointF(22, 7), QPointF(28, 16))
109
+ path.cubicTo(QPointF(22, 25), QPointF(10, 25), QPointF(4, 16))
110
+ p.drawPath(path)
111
+ p.setPen(Qt.NoPen)
112
+ p.setBrush(FG)
113
+ p.drawEllipse(QPointF(16, 16), 3.5, 3.5)
114
+
115
+
116
+ def _seed(p: QPainter) -> None:
117
+ p.setPen(_pen(GREEN))
118
+ p.drawLine(QPointF(16, 18), QPointF(16, 9))
119
+ p.drawLine(QPointF(16, 12), QPointF(11, 7))
120
+ p.drawLine(QPointF(16, 12), QPointF(21, 7))
121
+ p.setPen(Qt.NoPen)
122
+ p.setBrush(AMBER)
123
+ p.drawEllipse(QPointF(16, 23), 5, 5)
124
+
125
+
126
+ def _grow(p: QPainter) -> None:
127
+ p.setPen(_pen(GREEN))
128
+ p.drawLine(QPointF(16, 29), QPointF(16, 12))
129
+ p.drawLine(QPointF(16, 20), QPointF(7, 11))
130
+ p.drawLine(QPointF(16, 16), QPointF(25, 7))
131
+
132
+
133
+ def _clear(p: QPainter) -> None:
134
+ p.setPen(_pen(width=2.0))
135
+ p.drawLine(QPointF(11, 11), QPointF(21, 21))
136
+ p.drawLine(QPointF(21, 11), QPointF(11, 21))
137
+
138
+
139
+ def _undo(p: QPainter) -> None:
140
+ p.drawArc(QRectF(8, 9, 16, 16), 0, 180 * 16)
141
+ _arrowhead(p, (8, 24), (3, 16), (13, 16))
142
+
143
+
144
+ def _redo(p: QPainter) -> None:
145
+ p.drawArc(QRectF(8, 9, 16, 16), 0, 180 * 16)
146
+ _arrowhead(p, (24, 24), (19, 16), (29, 16))
147
+
148
+
149
+ def _save(p: QPainter) -> None:
150
+ p.drawLine(QPointF(16, 4), QPointF(16, 15))
151
+ _arrowhead(p, (16, 20), (11, 13), (21, 13))
152
+ path = QPainterPath(QPointF(6, 19))
153
+ path.lineTo(QPointF(6, 27))
154
+ path.lineTo(QPointF(26, 27))
155
+ path.lineTo(QPointF(26, 19))
156
+ p.drawPath(path)
157
+
158
+
159
+ def _folder(p: QPainter) -> None:
160
+ path = QPainterPath(QPointF(5, 25))
161
+ path.lineTo(QPointF(5, 9))
162
+ path.lineTo(QPointF(13, 9))
163
+ path.lineTo(QPointF(15, 12))
164
+ path.lineTo(QPointF(27, 12))
165
+ path.lineTo(QPointF(27, 25))
166
+ path.closeSubpath()
167
+ p.drawPath(path)
168
+
169
+
170
+ def _hide(p: QPainter) -> None:
171
+ """Crossed-out eye: hide points from view."""
172
+ p.drawEllipse(QRectF(5, 10, 22, 12))
173
+ p.setBrush(FG)
174
+ p.drawEllipse(QRectF(13, 13, 6, 6))
175
+ p.setBrush(Qt.NoBrush)
176
+ p.setPen(_pen(RED))
177
+ p.drawLine(QPointF(7, 27), QPointF(25, 5))
178
+
179
+
180
+ def _fade(p: QPainter) -> None:
181
+ """Faint dashed eye: fade points (still there, still selectable)."""
182
+ p.setPen(_pen(dashed=True))
183
+ p.drawEllipse(QRectF(5, 10, 22, 12))
184
+ p.drawEllipse(QRectF(13, 13, 6, 6))
185
+
186
+
187
+ def _move(p: QPainter) -> None:
188
+ """Four-way arrows: camera/movement mode."""
189
+ p.drawLine(QPointF(16, 9), QPointF(16, 23))
190
+ p.drawLine(QPointF(9, 16), QPointF(23, 16))
191
+ _arrowhead(p, (16, 3), (11, 9), (21, 9))
192
+ _arrowhead(p, (16, 29), (11, 23), (21, 23))
193
+ _arrowhead(p, (3, 16), (9, 11), (9, 21))
194
+ _arrowhead(p, (29, 16), (23, 11), (23, 21))
195
+
196
+
197
+ def _next(p: QPainter) -> None:
198
+ p.drawLine(QPointF(4, 16), QPointF(18, 16))
199
+ _arrowhead(p, (23, 16), (16, 10), (16, 22))
200
+ p.drawLine(QPointF(27, 8), QPointF(27, 24))
201
+
202
+
203
+ def _prev(p: QPainter) -> None:
204
+ p.drawLine(QPointF(28, 16), QPointF(14, 16))
205
+ _arrowhead(p, (9, 16), (16, 10), (16, 22))
206
+ p.drawLine(QPointF(5, 8), QPointF(5, 24))
207
+
208
+
209
+ _DRAW = {
210
+ "hide": _hide,
211
+ "fade": _fade,
212
+ "move": _move,
213
+ "lasso": _lasso,
214
+ "prev": _prev,
215
+ "target": _target,
216
+ "reassign": _reassign,
217
+ "new": _new_tree,
218
+ "merge": _merge,
219
+ "noise": _noise,
220
+ "unassign": _unassign,
221
+ "split": _split,
222
+ "isolate": _isolate,
223
+ "seed": _seed,
224
+ "grow": _grow,
225
+ "clear": _clear,
226
+ "undo": _undo,
227
+ "redo": _redo,
228
+ "save": _save,
229
+ "folder": _folder,
230
+ "next": _next,
231
+ }
232
+
233
+ _cache: dict[str, QIcon] = {}
234
+
235
+
236
+ def icon(name: str) -> QIcon:
237
+ """Return the named icon, drawing (and caching) it on first use."""
238
+ if name not in _cache:
239
+ pix = QPixmap(64, 64)
240
+ pix.setDevicePixelRatio(2) # crisp on HiDPI screens
241
+ pix.fill(Qt.transparent)
242
+ p = QPainter(pix)
243
+ p.setRenderHint(QPainter.Antialiasing)
244
+ p.setPen(_pen())
245
+ p.setBrush(Qt.NoBrush)
246
+ _DRAW[name](p)
247
+ p.end()
248
+ _cache[name] = QIcon(pix)
249
+ return _cache[name]
250
+
251
+
252
+ _app_icon_cache: QIcon | None = None
253
+
254
+
255
+ def app_icon() -> QIcon:
256
+ """The segfix window/taskbar icon: a canopy split into two colours —
257
+ one tree wrongly merged into two, or two wrongly merged into one,
258
+ either way the thing this tool fixes — over a short trunk. Unlike the
259
+ button icons above this one is filled, not just stroked, so it stays
260
+ legible at taskbar/dock sizes; several resolutions are baked in so a
261
+ window manager picks a crisp one instead of scaling a single pixmap.
262
+ """
263
+ global _app_icon_cache
264
+ if _app_icon_cache is not None:
265
+ return _app_icon_cache
266
+
267
+ result = QIcon()
268
+ for s in (16, 24, 32, 48, 64, 128, 256):
269
+ pix = QPixmap(s, s)
270
+ pix.fill(Qt.transparent)
271
+ p = QPainter(pix)
272
+ p.setRenderHint(QPainter.Antialiasing)
273
+
274
+ canopy_d = s * 0.74
275
+ cx, cy = s * 0.5, s * 0.44
276
+ rect = QRectF(cx - canopy_d / 2, cy - canopy_d / 2, canopy_d, canopy_d)
277
+ p.setPen(Qt.NoPen)
278
+ p.setBrush(GREEN)
279
+ p.drawPie(rect, 90 * 16, 180 * 16) # left half
280
+ p.setBrush(AMBER)
281
+ p.drawPie(rect, 270 * 16, 180 * 16) # right half
282
+
283
+ outline = QPen(QColor("#2a2a2a"))
284
+ outline.setWidthF(max(1.0, s * 0.035))
285
+ p.setPen(outline)
286
+ p.setBrush(Qt.NoBrush)
287
+ p.drawLine(QPointF(cx, cy - canopy_d / 2), QPointF(cx, cy + canopy_d / 2))
288
+ p.drawEllipse(rect)
289
+
290
+ trunk_w = s * 0.11
291
+ trunk_h = s * 0.30
292
+ trunk_rect = QRectF(
293
+ cx - trunk_w / 2, cy + canopy_d / 2 - s * 0.03, trunk_w, trunk_h
294
+ )
295
+ p.setPen(Qt.NoPen)
296
+ p.setBrush(QColor("#8a6a4a"))
297
+ p.drawRoundedRect(trunk_rect, trunk_w * 0.3, trunk_w * 0.3)
298
+
299
+ p.end()
300
+ result.addPixmap(pix)
301
+ _app_icon_cache = result
302
+ return result