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 +6 -0
- segfix/analysis.py +68 -0
- segfix/app.py +280 -0
- segfix/icons.py +302 -0
- segfix/io.py +408 -0
- segfix/lasso.py +245 -0
- segfix/model.py +157 -0
- segfix/operations.py +42 -0
- segfix/registry.py +109 -0
- segfix/scene_ui.py +175 -0
- segfix/startup_ui.py +182 -0
- segfix/treecatalog.py +465 -0
- segfix/viewer.py +404 -0
- segfix/widgets.py +1354 -0
- segfix/workspace.py +62 -0
- segfix-0.1.0.dist-info/METADATA +209 -0
- segfix-0.1.0.dist-info/RECORD +21 -0
- segfix-0.1.0.dist-info/WHEEL +5 -0
- segfix-0.1.0.dist-info/entry_points.txt +2 -0
- segfix-0.1.0.dist-info/licenses/LICENSE +21 -0
- segfix-0.1.0.dist-info/top_level.txt +1 -0
segfix/__init__.py
ADDED
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
|