lannotex 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (64) hide show
  1. {lannotex-0.1.1 → lannotex-0.2.0}/CHANGELOG.md +49 -0
  2. {lannotex-0.1.1 → lannotex-0.2.0}/PKG-INFO +5 -4
  3. {lannotex-0.1.1 → lannotex-0.2.0}/README.md +3 -2
  4. {lannotex-0.1.1 → lannotex-0.2.0}/docs/guide/annotating.md +34 -7
  5. {lannotex-0.1.1 → lannotex-0.2.0}/docs/migrate-from-labelme.md +10 -6
  6. {lannotex-0.1.1 → lannotex-0.2.0}/pyproject.toml +3 -1
  7. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/__init__.py +1 -1
  8. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/app.py +181 -37
  9. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/canvas.py +165 -15
  10. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/exporters.py +49 -19
  11. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/home.py +15 -7
  12. lannotex-0.2.0/src/lannotex/icons.py +55 -0
  13. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/models.py +20 -2
  14. lannotex-0.2.0/src/lannotex/palette.py +72 -0
  15. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/theme.py +9 -0
  16. lannotex-0.2.0/src/lannotex/thumbnails.py +101 -0
  17. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/workers.py +84 -22
  18. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/PKG-INFO +5 -4
  19. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/SOURCES.txt +3 -0
  20. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/requires.txt +1 -1
  21. {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_exporters.py +17 -0
  22. lannotex-0.2.0/tests/test_ui.py +283 -0
  23. {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_workflows.py +17 -0
  24. lannotex-0.1.1/tests/test_ui.py +0 -148
  25. {lannotex-0.1.1 → lannotex-0.2.0}/LICENSE +0 -0
  26. {lannotex-0.1.1 → lannotex-0.2.0}/LICENSES/GPL-3.0-only.txt +0 -0
  27. {lannotex-0.1.1 → lannotex-0.2.0}/LICENSES/LGPL-3.0-only.txt +0 -0
  28. {lannotex-0.1.1 → lannotex-0.2.0}/LICENSES/Qt-GPL-exception-1.0.txt +0 -0
  29. {lannotex-0.1.1 → lannotex-0.2.0}/LICENSES/THIRD_PARTY_NOTICES.md +0 -0
  30. {lannotex-0.1.1 → lannotex-0.2.0}/Lannotex.spec +0 -0
  31. {lannotex-0.1.1 → lannotex-0.2.0}/MANIFEST.in +0 -0
  32. {lannotex-0.1.1 → lannotex-0.2.0}/docs/MICROSOFT_STORE.md +0 -0
  33. {lannotex-0.1.1 → lannotex-0.2.0}/docs/PERFORMANCE.md +0 -0
  34. {lannotex-0.1.1 → lannotex-0.2.0}/docs/ROADMAP.md +0 -0
  35. {lannotex-0.1.1 → lannotex-0.2.0}/docs/TEAM.md +0 -0
  36. {lannotex-0.1.1 → lannotex-0.2.0}/docs/VERIFICATION.md +0 -0
  37. {lannotex-0.1.1 → lannotex-0.2.0}/docs/cli.md +0 -0
  38. {lannotex-0.1.1 → lannotex-0.2.0}/docs/getting-started/installation.md +0 -0
  39. {lannotex-0.1.1 → lannotex-0.2.0}/docs/getting-started/quickstart.md +0 -0
  40. {lannotex-0.1.1 → lannotex-0.2.0}/docs/guide/import-export.md +0 -0
  41. {lannotex-0.1.1 → lannotex-0.2.0}/docs/guide/projects.md +0 -0
  42. {lannotex-0.1.1 → lannotex-0.2.0}/docs/index.md +0 -0
  43. {lannotex-0.1.1 → lannotex-0.2.0}/requirements-windows-build.txt +0 -0
  44. {lannotex-0.1.1 → lannotex-0.2.0}/run_lannotex.py +0 -0
  45. {lannotex-0.1.1 → lannotex-0.2.0}/scripts/benchmark.py +0 -0
  46. {lannotex-0.1.1 → lannotex-0.2.0}/scripts/build_linux.sh +0 -0
  47. {lannotex-0.1.1 → lannotex-0.2.0}/scripts/build_macos.sh +0 -0
  48. {lannotex-0.1.1 → lannotex-0.2.0}/scripts/build_windows.ps1 +0 -0
  49. {lannotex-0.1.1 → lannotex-0.2.0}/scripts/collect_notices.py +0 -0
  50. {lannotex-0.1.1 → lannotex-0.2.0}/scripts/verify_executable.py +0 -0
  51. {lannotex-0.1.1 → lannotex-0.2.0}/setup.cfg +0 -0
  52. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/__main__.py +0 -0
  53. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/cli.py +0 -0
  54. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/domain.py +0 -0
  55. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/py.typed +0 -0
  56. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/storage.py +0 -0
  57. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/team.py +0 -0
  58. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/dependency_links.txt +0 -0
  59. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/entry_points.txt +0 -0
  60. {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/top_level.txt +0 -0
  61. {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_cli.py +0 -0
  62. {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_domain.py +0 -0
  63. {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_storage.py +0 -0
  64. {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_team.py +0 -0
@@ -6,6 +6,55 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] — 2026-10-14
10
+
11
+ Viewing, speed and layout: sharp zoom on large images, instant navigation, and a
12
+ workspace you can arrange yourself.
13
+
14
+ ### Added
15
+
16
+ - **Full-resolution viewing.** Zooming in past the preview loads the original pixels in the
17
+ background, so large images stay sharp at any zoom. Only the visible region is drawn.
18
+ Detail decoding is capped at 64 MP to protect memory.
19
+ - **Instant next/previous.** Neighbouring images are decoded ahead of time.
20
+ - **Minimap** (`M`) for orientation and click-to-jump, and a **pixel grid** (`G`) from
21
+ 1200% zoom upwards.
22
+ - **Zoom controls:** actual pixels (`Ctrl+1`), zoom in/out (`Ctrl+=` / `Ctrl+-`) up to
23
+ 6400%. The zoom readout now shows true pixel scale, so 100% means actual pixels.
24
+ - **Image adjustments:** brightness, contrast and gamma in a new **Image view** panel. They
25
+ change the display only, never your files, and are applied just to the visible region, so
26
+ they stay fast on large images.
27
+ - **Dockable panels:** Dataset, Inspector and Image view can be moved, tabbed, floated onto
28
+ another monitor, or closed. Your layout is remembered between sessions. Use
29
+ **View → Panels** to bring a panel back or apply a preset (default, or all panels on the
30
+ right). Focus mode hides every panel and restores your exact layout afterwards.
31
+ - **Thumbnails in the image list**, generated in the background with the rows you can see
32
+ first. They're cached on disk in your user cache folder, not the project, so reopening a
33
+ project is instant. The cache is capped at 512 MB. Toggle them under **View → Show
34
+ thumbnails in image list**.
35
+ - **Icons:** a consistent line-icon set for the toolbar, navigation and Home screen,
36
+ redrawn for light and dark themes, plus the Lannotex logo as the window icon. Icon-only
37
+ buttons have tooltips and screen-reader names.
38
+ - **Command palette** (`Ctrl+K`): type part of any menu command, for example "zoom",
39
+ "export" or "layout", and press Enter to run it.
40
+ - **Number keys 1–9** choose the drawing label, which relabels the selected shape, or
41
+ toggle classes in classification projects.
42
+ - **Project → Import LabelMe folder…** imports a whole folder, including subfolders.
43
+
44
+ ### Changed
45
+
46
+ - **LabelMe import keeps going.**
47
+ - Circles and rotated rectangles are converted to polygons.
48
+ - Points, lines and masks, which Lannotex can't draw yet, are skipped and counted in the
49
+ import summary instead of rejecting the whole file.
50
+ - Invalid shapes are dropped one at a time.
51
+ - A malformed JSON file no longer stops the rest of the folder.
52
+
53
+ ### Fixed
54
+
55
+ - PySide6 6.12.0 is excluded. On Linux with Python 3.11 it over-releases references on every
56
+ background job, which made Lannotex abort on exit. Versions 6.8.3–6.11 are unaffected.
57
+
9
58
  ## [0.1.1] — 2026-10-14
10
59
 
11
60
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lannotex
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Fast, offline-first image annotation for classifications, boxes, and polygons.
5
5
  Author: Lourdhu Raju
6
6
  Maintainer: Lourdhu Raju
@@ -30,7 +30,7 @@ Requires-Python: >=3.11
30
30
  Description-Content-Type: text/markdown
31
31
  License-File: LICENSE
32
32
  License-File: LICENSES/THIRD_PARTY_NOTICES.md
33
- Requires-Dist: PySide6-Essentials<7,>=6.8.3
33
+ Requires-Dist: PySide6-Essentials!=6.12.0,<7,>=6.8.3
34
34
  Requires-Dist: Pillow>=11.1
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: pytest>=8.3; extra == "dev"
@@ -131,6 +131,7 @@ Every shortcut can be remapped. See the [annotating guide](https://lourdhu02.git
131
131
  | | |
132
132
  | --- | --- |
133
133
  | **Annotate** | Bounding boxes · polygons with vertex drag, insert, and delete · multi-label image classes · "no target" negatives · 100-step undo/redo · zoom under the pointer, pan, arrow-key nudging · focus mode · light and dark themes |
134
+ | **View** | Sharp full-resolution zoom up to 6400% · next/previous images decoded ahead · minimap · pixel grid · brightness, contrast and gamma · thumbnails · dockable panels you can float onto a second monitor, with saved layouts · `Ctrl+K` command palette |
134
135
  | **Stay safe** | SQLite projects with versioned migrations · recovery drafts and 700 ms autosave · transactional saves · geometry validation · backup and restore · relinking for moved images · EXIF orientation handled |
135
136
  | **Scale** | Virtualized image list (at most 1,024 records in memory) · background import and decoding · 96 MB preview cache · coordinates normalized to the original resolution |
136
137
  | **Export** | Labelme JSON · ready-to-train YOLO (`images/`, `labels/`, `data.yaml`) · COCO JSON · classification CSV · lossless native JSONL · each export reports anything it skipped |
@@ -181,8 +182,8 @@ hashes. There is no telemetry, no external AI service, and no automatic upload.
181
182
 
182
183
  ## Roadmap
183
184
 
184
- Next up: more LabelMe shape types (points, lines, circles), VOC and YOLO-seg export, and
185
- number-key label selection. See the
185
+ Next up: point, line, rotated-box, keypoint and brush-mask tools, per-object attributes,
186
+ and VOC, YOLO-seg and YOLO-OBB export. See the
186
187
  [roadmap](https://lourdhu02.github.io/Lannotex/ROADMAP/) and the
187
188
  [measured performance](https://lourdhu02.github.io/Lannotex/PERFORMANCE/).
188
189
 
@@ -89,6 +89,7 @@ Every shortcut can be remapped. See the [annotating guide](https://lourdhu02.git
89
89
  | | |
90
90
  | --- | --- |
91
91
  | **Annotate** | Bounding boxes · polygons with vertex drag, insert, and delete · multi-label image classes · "no target" negatives · 100-step undo/redo · zoom under the pointer, pan, arrow-key nudging · focus mode · light and dark themes |
92
+ | **View** | Sharp full-resolution zoom up to 6400% · next/previous images decoded ahead · minimap · pixel grid · brightness, contrast and gamma · thumbnails · dockable panels you can float onto a second monitor, with saved layouts · `Ctrl+K` command palette |
92
93
  | **Stay safe** | SQLite projects with versioned migrations · recovery drafts and 700 ms autosave · transactional saves · geometry validation · backup and restore · relinking for moved images · EXIF orientation handled |
93
94
  | **Scale** | Virtualized image list (at most 1,024 records in memory) · background import and decoding · 96 MB preview cache · coordinates normalized to the original resolution |
94
95
  | **Export** | Labelme JSON · ready-to-train YOLO (`images/`, `labels/`, `data.yaml`) · COCO JSON · classification CSV · lossless native JSONL · each export reports anything it skipped |
@@ -139,8 +140,8 @@ hashes. There is no telemetry, no external AI service, and no automatic upload.
139
140
 
140
141
  ## Roadmap
141
142
 
142
- Next up: more LabelMe shape types (points, lines, circles), VOC and YOLO-seg export, and
143
- number-key label selection. See the
143
+ Next up: point, line, rotated-box, keypoint and brush-mask tools, per-object attributes,
144
+ and VOC, YOLO-seg and YOLO-OBB export. See the
144
145
  [roadmap](https://lourdhu02.github.io/Lannotex/ROADMAP/) and the
145
146
  [measured performance](https://lourdhu02.github.io/Lannotex/PERFORMANCE/).
146
147
 
@@ -8,7 +8,10 @@
8
8
  | Box | ++b++ | Drag from one corner to the opposite corner. |
9
9
  | Polygon | ++p++ | Click to add vertices; ++enter++ finishes, ++escape++ cancels. |
10
10
 
11
- The label selected in the **Labels & annotations** panel is applied to new shapes.
11
+ The label selected in the **Labels & annotations** panel is applied to new shapes. Press
12
+ ++1++–++9++ to choose the first nine labels, in alphabetical order. If a shape is selected,
13
+ it is relabelled. In classification projects, the number keys toggle classes on the
14
+ current image instead.
12
15
 
13
16
  ### Editing polygons
14
17
 
@@ -22,16 +25,38 @@ The label selected in the **Labels & annotations** panel is applied to new shape
22
25
 
23
26
  | Action | Control |
24
27
  | --- | --- |
25
- | Zoom | Mouse wheel (zooms under the pointer) |
26
- | Pan | Hold ++space++ and drag |
28
+ | Zoom | Mouse wheel (zooms under the pointer), ++ctrl+equal++ / ++ctrl+minus++ |
29
+ | Actual pixels (100%) | ++ctrl+1++ |
30
+ | Pan | Hold ++space++ and drag, or click and drag in the minimap |
27
31
  | Fit to window | ++f++ |
32
+ | Minimap on/off | ++m++ |
33
+ | Pixel grid on/off (from 1200%) | ++g++ |
28
34
  | Nudge selection | Arrow keys (hold ++shift++ for a 10× step) |
29
35
  | Focus mode (hide panels) | ++ctrl+shift+f++ |
30
36
 
37
+ ## Image adjustments
38
+
39
+ The **Image view** panel has brightness, contrast and gamma sliders for dark, washed-out or
40
+ low-contrast images. They change only what you see, never your files or the exported data.
41
+ **Reset adjustments**, or **View → Reset image adjustments**, returns to the original.
42
+
43
+ ## Arranging the workspace
44
+
45
+ The **Dataset**, **Inspector** and **Image view** panels are dockable:
46
+
47
+ - Drag a panel's title bar to move it to another side, or onto another panel to tab them.
48
+ - Use the float button to detach a panel, for example onto a second monitor.
49
+ - Close panels you don't need. **View → Panels** lists them all so you can bring them back.
50
+ - **View → Panels → Default layout** and **All panels on the right** are one-click presets.
51
+ - **Focus mode** (++ctrl+shift+f++) hides every panel. Press it again to restore your layout.
52
+
53
+ Your layout is saved when you close Lannotex.
54
+
31
55
  ## Keyboard reference
32
56
 
33
57
  | Action | Default |
34
58
  | --- | --- |
59
+ | Command palette: search and run any command | ++ctrl+k++ |
35
60
  | Save | ++ctrl+s++ |
36
61
  | Save & next | ++ctrl+enter++ |
37
62
  | Previous / next image | ++alt+left++ / ++alt+right++ |
@@ -57,9 +82,11 @@ dataset:
57
82
 
58
83
  ## Coordinates
59
84
 
60
- Geometry is stored **normalized** (0–1) relative to the original image size. Large
61
- images are displayed as a preview capped at 2,560 px on the longest side, but stored
62
- coordinates and exports always use the original resolution. EXIF orientation is
63
- respected.
85
+ Geometry is stored **normalized** (0–1) relative to the original image size. Images open
86
+ as a fast preview (up to 2,560 px). When you zoom in past that, Lannotex loads the
87
+ original pixels in the background, so the image stays sharp up to 6400%. The zoom
88
+ readout shows true scale: 100% means one image pixel per screen pixel. Full-resolution
89
+ detail is available for images up to 64 megapixels; larger images stay on the preview.
90
+ EXIF orientation is respected.
64
91
 
65
92
  Undo history keeps the last 100 changes for the image you're working on.
@@ -6,8 +6,9 @@ and still hand files back to LabelMe users.
6
6
  ## Import a LabelMe folder
7
7
 
8
8
  1. Create a project (**Combined pipeline** enables every tool).
9
- 2. Choose **Project → Import annotations…** and select the folder that contains your
10
- LabelMe `.json` files.
9
+ 2. Choose **Project → Import LabelMe folder…** and select the folder that contains your
10
+ LabelMe `.json` files. Subfolders are included. Use **Import annotations…** for a single
11
+ file.
11
12
  3. Lannotex reads each file, finds the image through its `imagePath`, checks that the
12
13
  dimensions match, and creates any missing labels.
13
14
 
@@ -19,16 +20,19 @@ Your LabelMe JSON files and images are left untouched.
19
20
  | --- | --- |
20
21
  | `rectangle` shapes | Bounding boxes |
21
22
  | `polygon` shapes | Polygons |
23
+ | `circle` shapes | Polygons (32 points around the circle) |
24
+ | `rotation` (rotated rectangle) shapes | Polygons with the four corners |
22
25
  | `flags` set to `true` | Image classes |
23
26
  | Image files via `imagePath` | Referenced in place, not copied |
24
27
 
25
28
  !!! warning "Current limitations"
26
- - Files containing `circle`, `line`, `linestrip`, `point`, or `mask` shapes are
27
- rejected **as a whole file**, and the error list names them. Support for these
28
- shapes is planned.
29
+ - `point`, `line`, `linestrip` and `mask` shapes are **skipped**. The rest of each file
30
+ still imports, and the import summary lists how many of each were skipped. Support
31
+ for these shapes is planned.
32
+ - Shapes with invalid geometry, such as a polygon with fewer than 3 points, are skipped
33
+ and listed the same way.
29
34
  - `imageData` embedded in the JSON isn't used. The image file must exist on disk.
30
35
  - `group_id`, `description`, and per-shape `flags` aren't imported.
31
- - Only JSON files directly inside the chosen folder are read, not subfolders.
32
36
 
33
37
  ## Export back to LabelMe
34
38
 
@@ -32,7 +32,9 @@ classifiers = [
32
32
  ]
33
33
  # Ranges, not pins: exact pins break installs on newer Pythons. Release binaries pin
34
34
  # their full set in requirements-windows-build.txt.
35
- dependencies = ["PySide6-Essentials>=6.8.3,<7", "Pillow>=11.1"]
35
+ # PySide6 6.12.0 over-releases None/True on every QThreadPool job and worker-thread signal,
36
+ # which aborts Python 3.11 on Linux at exit ("bool_dealloc"); 6.8.3-6.11.0 are unaffected.
37
+ dependencies = ["PySide6-Essentials>=6.8.3,<7,!=6.12.0", "Pillow>=11.1"]
36
38
 
37
39
  [project.optional-dependencies]
38
40
  dev = [
@@ -1,2 +1,2 @@
1
1
  """Lannotex: offline-first image annotation."""
2
- __version__ = "0.1.1"
2
+ __version__ = "0.2.0"
@@ -6,18 +6,21 @@ import os
6
6
  from pathlib import Path
7
7
  import sys
8
8
  import uuid
9
- from PySide6.QtCore import QEvent, QModelIndex, QSettings, Qt, QThreadPool, QTimer, Slot
9
+ from PySide6.QtCore import QEvent, QModelIndex, QSettings, QSize, Qt, QThreadPool, QTimer, Slot
10
10
  from PySide6.QtGui import QAction, QColor, QKeySequence
11
11
  from PySide6.QtWidgets import (QAbstractSpinBox, QApplication, QCheckBox, QComboBox,
12
- QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFrame, QGridLayout,
12
+ QDialog, QDialogButtonBox, QDockWidget, QDoubleSpinBox, QFileDialog, QSlider, QFrame, QGridLayout,
13
13
  QHBoxLayout, QInputDialog, QKeySequenceEdit, QLabel, QLineEdit, QListView, QListWidget,
14
14
  QListWidgetItem, QMainWindow, QMessageBox, QProgressBar, QPushButton, QScrollArea,
15
- QSplitter, QStackedWidget, QTableWidget, QTableWidgetItem, QTextEdit, QToolButton, QVBoxLayout, QWidget)
15
+ QStackedWidget, QTableWidget, QTableWidgetItem, QTextEdit, QToolButton, QVBoxLayout, QWidget)
16
16
  from . import __version__
17
17
  from .canvas import Canvas
18
18
  from .domain import Annotation, Document, validate_all
19
19
  from .exporters import analyze, export_project, import_annotations
20
20
  from .models import ImageListModel
21
+ from .palette import CommandPalette, menu_commands
22
+ from .thumbnails import Thumbnails
23
+ from .icons import icon, logo
21
24
  from .storage import ConflictError, Project, atomic_text
22
25
  from .theme import apply_theme
23
26
  from .workers import ImageLoader, Job, import_images
@@ -28,7 +31,8 @@ COLORS = ['#37b8a5','#70a8f5','#d5a85a','#bf8bef','#ed8ca4','#74c9db','#9fbd68']
28
31
  DEFAULT_KEYS = {'save':'Ctrl+S','save_next':'Ctrl+Return','previous':'Alt+Left','next':'Alt+Right',
29
32
  'undo':'Ctrl+Z','redo':'Ctrl+Shift+Z','delete':'Del','cancel':'Esc','finish':'Return',
30
33
  'fit':'F','select':'V','box':'B','polygon':'P','duplicate':'Ctrl+D','redraw':'R',
31
- 'vertex':'Shift+Del','focus':'Ctrl+Shift+F','open':'Ctrl+O','import':'Ctrl+I'}
34
+ 'vertex':'Shift+Del','focus':'Ctrl+Shift+F','open':'Ctrl+O','import':'Ctrl+I',
35
+ 'zoom_in':'Ctrl+=','zoom_out':'Ctrl+-','actual':'Ctrl+1','minimap':'M','grid':'G','palette':'Ctrl+K'}
32
36
 
33
37
 
34
38
  def button(text, callback, primary=False):
@@ -59,7 +63,9 @@ class Main(QMainWindow):
59
63
  self.job = None; self.job_kind = ''; self.focus_mode = False
60
64
  self.pool = QThreadPool(self); self.pool.setMaxThreadCount(1)
61
65
  self.loader = ImageLoader(self)
66
+ self.thumbnails = Thumbnails(self); self.thumbnails.prune_later()
62
67
  self.loader.ready.connect(self.image_ready); self.loader.failed.connect(self.image_failed)
68
+ self.loader.detail_ready.connect(self.detail_ready); self.loader.detail_failed.connect(self.detail_failed)
63
69
  self.timer = QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(self.save)
64
70
  self.filter_timer = QTimer(self); self.filter_timer.setSingleShot(True); self.filter_timer.timeout.connect(self.filter_images)
65
71
  self.actions = {}; self._build_ui(); self._build_menus()
@@ -75,41 +81,57 @@ class Main(QMainWindow):
75
81
  QApplication.instance().installEventFilter(self)
76
82
  geometry = self.settings.value('geometry')
77
83
  if geometry: self.restoreGeometry(geometry)
78
- sizes = self.settings.value('splitter')
79
- if sizes: self.splitter.restoreState(sizes)
80
- self.set_save_state('No project'); self.refresh_controls()
84
+ layout = self.settings.value('layout')
85
+ if layout: self.dock_host.restoreState(layout)
86
+ self.set_save_state('No project'); self.refresh_controls(); self.apply_icons()
87
+
88
+ def apply_icons(self):
89
+ palette=QApplication.palette(); color=palette.text().color().name(); on_accent=palette.highlightedText().color().name()
90
+ for widget,name in [(self.home_button,'home'),(self.new_button,'plus'),(self.open_button,'folder-open'),(self.team_button,'users'),
91
+ (self.theme_button,'theme'),(self.analysis_button,'chart'),(self.import_button,'folder-plus'),(self.files_button,'file-plus'),
92
+ (self.undo_button,'undo'),(self.redo_button,'redo'),(self.fit_button,'fit'),(self.prev_button,'prev'),
93
+ (self.next_image_button,'next'),(self.save_button,'save')]:
94
+ widget.setIcon(icon(name,color)); widget.setIconSize(QSize(18,18))
95
+ self.export_button.setIcon(icon('export',on_accent)); self.export_button.setIconSize(QSize(18,18))
96
+ for mode,tool in self.tool_buttons.items():
97
+ tool.setIcon(icon(mode,color)); tool.setIconSize(QSize(18,18)); tool.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
98
+ self.home.set_icon_color(color)
99
+ self.setWindowIcon(logo())
81
100
 
82
101
  def _build_ui(self):
83
102
  root = QWidget(); outer = QVBoxLayout(root); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0)
84
103
  header=QFrame(); header.setObjectName('header'); bar=QHBoxLayout(header); bar.setContentsMargins(20,14,20,14)
85
- bar.addWidget(button('‹ Home',self.go_home))
104
+ self.home_button=button('Home',self.go_home); bar.addWidget(self.home_button)
86
105
  brand=QVBoxLayout(); brand.setSpacing(1); brand.addWidget(label('Lannotex','brand')); self.pipeline_title=label('ANNOTATION WORKSPACE'); brand.addWidget(self.pipeline_title)
87
106
  bar.addLayout(brand); bar.addSpacing(28)
88
107
  self.project_title=label('No project open'); bar.addWidget(self.project_title,1)
89
- bar.addWidget(button('New project',self.new_project)); bar.addWidget(button('Open',self.open_project))
108
+ self.new_button=button('New project',self.new_project); bar.addWidget(self.new_button)
109
+ self.open_button=button('Open',self.open_project); bar.addWidget(self.open_button)
90
110
  self.team_button=button('Team',self.team_dialog); bar.addWidget(self.team_button)
91
111
  self.sync_button=button('Sync',self.sync_team); bar.addWidget(self.sync_button); self.sync_button.hide()
92
- bar.addWidget(button('◐',self.toggle_theme))
112
+ self.theme_button=button('',self.toggle_theme); self.theme_button.setToolTip('Light / dark theme'); self.theme_button.setAccessibleName('Toggle theme'); bar.addWidget(self.theme_button)
93
113
  self.analysis_button=button('Analysis',self.show_analysis); bar.addWidget(self.analysis_button)
94
114
  self.export_button=button('Export',self.export_dialog,True); bar.addWidget(self.export_button)
95
115
  outer.addWidget(header)
96
- self.splitter=QSplitter(); self.splitter.setChildrenCollapsible(False)
116
+ # Inner main window so panels dock, tab, float and persist within the workspace page only.
117
+ self.dock_host=QMainWindow(); self.dock_host.setWindowFlags(Qt.Widget); self.dock_host.setObjectName('dockHost')
118
+ self.dock_host.setDockOptions(QMainWindow.AnimatedDocks|QMainWindow.AllowTabbedDocks|QMainWindow.AllowNestedDocks)
97
119
  self.left=QFrame(); self.left.setObjectName('panel'); left=QVBoxLayout(self.left); left.setContentsMargins(12,10,12,10)
98
- left.addWidget(label('DATASET','section'))
99
120
  self.dataset_count=label('0 images'); left.addWidget(self.dataset_count)
100
- row=QHBoxLayout(); self.import_button=button('+ Folder',self.import_folder); row.addWidget(self.import_button)
101
- self.files_button=button('+ Files',self.import_files); row.addWidget(self.files_button); left.addLayout(row)
121
+ row=QHBoxLayout(); self.import_button=button('Folder',self.import_folder); self.import_button.setToolTip('Import a folder of images (Ctrl+I)'); row.addWidget(self.import_button)
122
+ self.files_button=button('Files',self.import_files); self.files_button.setToolTip('Import image files'); row.addWidget(self.files_button); left.addLayout(row)
102
123
  self.search=QLineEdit(); self.search.setPlaceholderText('Search image names…'); self.search.setClearButtonEnabled(True)
103
124
  self.search.textChanged.connect(lambda:self.filter_timer.start(180)); left.addWidget(self.search)
104
125
  self.filter=QComboBox()
105
126
  for text,data in [('All images','all'),('Unlabeled','new'),('Labeled','done'),('No targets','empty')]: self.filter.addItem(text,data)
106
127
  self.filter.currentIndexChanged.connect(self.filter_images); left.addWidget(self.filter)
107
- self.model=ImageListModel(self); self.images=QListView(); self.images.setModel(self.model)
128
+ self.model=ImageListModel(self,self.thumbnails); self.images=QListView(); self.images.setModel(self.model)
129
+ self.images.setIconSize(QSize(56,42)); self.model.set_show_thumbnails(self.settings.value('thumbnails',True,type=bool))
108
130
  self.images.setUniformItemSizes(True); self.images.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
109
131
  self.images.setAccessibleName('Project images'); self.images.selectionModel().currentChanged.connect(self.select_index)
110
132
  left.addWidget(self.images,1)
111
133
  self.left_hint=label('Images stay on your disk.\nNothing is uploaded.'); left.addWidget(self.left_hint)
112
- self.left.setMinimumWidth(220); self.splitter.addWidget(self.left)
134
+ self.left.setMinimumWidth(220)
113
135
  center=QWidget(); middle=QVBoxLayout(center); middle.setContentsMargins(8,0,8,0); middle.setSpacing(8)
114
136
  top=QHBoxLayout(); self.image_title=label('Annotation studio','section'); top.addWidget(self.image_title,1)
115
137
  self.save_badge=label('No project','save'); top.addWidget(self.save_badge); middle.addLayout(top)
@@ -118,20 +140,26 @@ class Main(QMainWindow):
118
140
  tool=QToolButton(); tool.setText(f'{text} {key}'); tool.setCheckable(True); tool.setToolTip(f'{text} tool ({key})')
119
141
  tool.clicked.connect(lambda checked=False,m=mode:self.set_tool(m)); tools.addWidget(tool); self.tool_buttons[mode]=tool
120
142
  self.tool_buttons['select'].setChecked(True)
121
- tools.addStretch(); self.undo_button=button('↶',self.undo); self.undo_button.setToolTip('Undo (Ctrl+Z)'); tools.addWidget(self.undo_button)
122
- self.redo_button=button('↷',self.redo); self.redo_button.setToolTip('Redo (Ctrl+Shift+Z)'); tools.addWidget(self.redo_button)
123
- self.zoom_label=label('100%'); tools.addWidget(self.zoom_label); tools.addWidget(button('Fit',lambda:self.canvas.fit()))
143
+ tools.addStretch(); self.undo_button=button('',self.undo); self.undo_button.setToolTip('Undo (Ctrl+Z)'); self.undo_button.setAccessibleName('Undo'); tools.addWidget(self.undo_button)
144
+ self.redo_button=button('',self.redo); self.redo_button.setToolTip('Redo (Ctrl+Shift+Z)'); self.redo_button.setAccessibleName('Redo'); tools.addWidget(self.redo_button)
145
+ self.zoom_label=label('100%'); tools.addWidget(self.zoom_label)
146
+ self.fit_button=button('Fit',lambda:self.canvas.fit()); self.fit_button.setToolTip('Fit image (F)'); tools.addWidget(self.fit_button)
124
147
  middle.addLayout(tools)
125
148
  self.canvas=Canvas(); self.canvas.edited.connect(self.commit); self.canvas.selected.connect(self.selection_changed)
126
149
  self.canvas.message.connect(self.notice); self.canvas.zoomed.connect(lambda n:self.zoom_label.setText(f'{n}%'))
150
+ self.canvas.detail_wanted.connect(self.request_detail)
151
+ self.canvas.show_minimap=self.settings.value('minimap',True,type=bool); self.canvas.show_grid=self.settings.value('pixel_grid',True,type=bool)
127
152
  middle.addWidget(self.canvas,1)
128
153
  self.canvas_hint=label('B Draw box · P Draw polygon · Space + drag Pan · F Fit')
129
154
  self.canvas_hint.setWordWrap(True); middle.addWidget(self.canvas_hint)
130
- footer=QHBoxLayout(); footer.addWidget(button('←',lambda:self.navigate(-1)))
131
- self.position=label('— / —'); footer.addWidget(self.position); footer.addWidget(button('→',lambda:self.navigate(1)))
155
+ footer=QHBoxLayout(); self.prev_button=button('',lambda:self.navigate(-1)); self.prev_button.setToolTip('Previous image (Alt+Left)')
156
+ self.prev_button.setAccessibleName('Previous image'); footer.addWidget(self.prev_button)
157
+ self.position=label('— / —'); footer.addWidget(self.position)
158
+ self.next_image_button=button('',lambda:self.navigate(1)); self.next_image_button.setToolTip('Next image (Alt+Right)')
159
+ self.next_image_button.setAccessibleName('Next image'); footer.addWidget(self.next_image_button)
132
160
  footer.addStretch(); self.save_button=button('Save',self.save); footer.addWidget(self.save_button)
133
161
  self.next_button=button('Save && next→',self.save_next,True); footer.addWidget(self.next_button); middle.addLayout(footer)
134
- self.splitter.addWidget(center)
162
+ self.dock_host.setCentralWidget(center)
135
163
  self.right=QScrollArea(); self.right.setWidgetResizable(True); self.right.setMinimumWidth(245)
136
164
  inspector=QWidget(); form=QVBoxLayout(inspector); form.setContentsMargins(14,10,14,14); form.setSpacing(9)
137
165
  self.inspector_heading=label('LABELS & ANNOTATIONS','section'); form.addWidget(self.inspector_heading)
@@ -160,14 +188,35 @@ class Main(QMainWindow):
160
188
  self.redraw_button=button('Redraw',lambda:self.canvas.redraw()); editrow.addWidget(self.redraw_button); form.addLayout(editrow)
161
189
  self.delete_button=button('Delete selected',self.delete_selected); self.delete_button.setObjectName('danger'); form.addWidget(self.delete_button)
162
190
  form.addWidget(button('Clear image annotations',self.clear_annotations))
163
- from PySide6.QtWidgets import QSlider
164
191
  self.opacity_heading=label('OVERLAY OPACITY','section'); form.addWidget(self.opacity_heading); self.opacity=QSlider(Qt.Horizontal); self.opacity.setRange(0,70)
165
192
  self.opacity.setValue(self.settings.value('opacity',15,type=int)); self.canvas.opacity=self.opacity.value()/100
166
193
  self.opacity.valueChanged.connect(self.opacity_changed); form.addWidget(self.opacity)
167
194
  self.polygon_hint=label('Polygon: drag a vertex to edit. Double-click an edge to insert. Shift+Delete removes the selected vertex.'); self.polygon_hint.setWordWrap(True); form.addWidget(self.polygon_hint)
168
195
  form.addStretch(); form.addWidget(button('Keyboard && preferences',self.preferences))
169
- self.right.setWidget(inspector); self.splitter.addWidget(self.right); self.splitter.setSizes([260,850,280])
170
- wrap=QWidget(); layout=QVBoxLayout(wrap); layout.setContentsMargins(12,12,12,12); layout.addWidget(self.splitter)
196
+ self.right.setWidget(inspector)
197
+ self.view_panel=QWidget(); vp=QVBoxLayout(self.view_panel); vp.setContentsMargins(14,10,14,14); vp.setSpacing(4)
198
+ self.adjust_sliders={}
199
+ for key,title,low,high,default in [('brightness','Brightness',-100,100,0),('contrast','Contrast',-100,100,0),('gamma','Gamma',20,300,100)]:
200
+ row=QHBoxLayout(); row.addWidget(label(title)); row.addStretch(); value=label(''); row.addWidget(value); vp.addLayout(row)
201
+ slider=QSlider(Qt.Horizontal); slider.setRange(low,high); slider.setValue(default); slider.setAccessibleName(title)
202
+ slider.valueChanged.connect(self.adjustments_changed); vp.addWidget(slider); self.adjust_sliders[key]=(slider,value,default)
203
+ vp.addWidget(button('Reset adjustments',self.reset_adjustments)); vp.addSpacing(8)
204
+ self.minimap_check=QCheckBox('Minimap when zoomed in'); self.minimap_check.setChecked(self.canvas.show_minimap)
205
+ self.minimap_check.toggled.connect(self.minimap_checked); vp.addWidget(self.minimap_check)
206
+ self.grid_check=QCheckBox('Pixel grid from 1200%'); self.grid_check.setChecked(self.canvas.show_grid)
207
+ self.grid_check.toggled.connect(self.grid_checked); vp.addWidget(self.grid_check)
208
+ vp.addStretch(); self.adjustments_changed()
209
+ self.docks={}
210
+ for key,title,widget,area in [('dataset','Dataset',self.left,Qt.LeftDockWidgetArea),('inspector','Inspector',self.right,Qt.RightDockWidgetArea),
211
+ ('view','Image view',self.view_panel,Qt.RightDockWidgetArea)]:
212
+ dock=QDockWidget(title,self.dock_host); dock.setObjectName(f'dock-{key}'); dock.setWidget(widget)
213
+ dock.setFeatures(QDockWidget.DockWidgetMovable|QDockWidget.DockWidgetFloatable|QDockWidget.DockWidgetClosable)
214
+ self.dock_host.addDockWidget(area,dock); self.docks[key]=dock
215
+ self.dock_host.resizeDocks([self.docks['dataset'],self.docks['inspector']],[260,290],Qt.Horizontal)
216
+ # Image view shares a tab with the Inspector so the object list keeps full height on laptop screens.
217
+ self.dock_host.tabifyDockWidget(self.docks['inspector'],self.docks['view']); self.docks['inspector'].raise_()
218
+ self.default_layout=self.dock_host.saveState()
219
+ wrap=QWidget(); layout=QVBoxLayout(wrap); layout.setContentsMargins(8,8,8,8); layout.addWidget(self.dock_host)
171
220
  outer.addWidget(wrap,1); self.setCentralWidget(root)
172
221
  self.status=label('Ready · local-only workspace'); self.statusBar().addWidget(self.status,1)
173
222
  self.progress=QProgressBar(); self.progress.setMaximumWidth(180); self.progress.hide(); self.statusBar().addPermanentWidget(self.progress)
@@ -181,7 +230,8 @@ class Main(QMainWindow):
181
230
  file=self.menuBar().addMenu('Project')
182
231
  add(file,'New project…',self.new_project); add(file,'Open project…',self.open_project,'open')
183
232
  add(file,'Import image folder…',self.import_folder,'import'); add(file,'Import image files…',self.import_files)
184
- add(file,'Import annotations…',self.import_annotation_dialog); file.addSeparator()
233
+ add(file,'Import annotations…',self.import_annotation_dialog)
234
+ add(file,'Import LabelMe folder…',lambda:self.import_annotation_dialog(folder=True)); file.addSeparator()
185
235
  add(file,'Save',self.save,'save'); add(file,'Save && next',self.save_next,'save_next')
186
236
  add(file,'Export…',self.export_dialog); add(file,'Backup project…',self.backup_dialog)
187
237
  add(file,'Restore backup…',self.restore_dialog); add(file,'Relink current image…',self.relink_dialog)
@@ -192,7 +242,18 @@ class Main(QMainWindow):
192
242
  ('vertex','Delete polygon vertex',self.canvas.delete_vertex),('cancel','Cancel drawing',self.canvas.cancel),
193
243
  ('finish','Finish polygon',self.canvas.finish_polygon)]: add(edit,text,slot,key)
194
244
  view=self.menuBar().addMenu('View')
195
- add(view,'Fit image',self.canvas.fit,'fit'); add(view,'Focus mode',self.toggle_focus,'focus')
245
+ add(view,'Command palette…',self.open_palette,'palette'); view.addSeparator()
246
+ add(view,'Fit image',self.canvas.fit,'fit'); add(view,'Actual pixels (100%)',self.canvas.actual_pixels,'actual')
247
+ add(view,'Zoom in',lambda:self.canvas.zoom_by(1.25),'zoom_in'); add(view,'Zoom out',lambda:self.canvas.zoom_by(1/1.25),'zoom_out')
248
+ add(view,'Show minimap',self.toggle_minimap,'minimap'); add(view,'Show pixel grid',self.toggle_grid,'grid')
249
+ add(view,'Reset image adjustments',self.reset_adjustments)
250
+ add(view,'Show thumbnails in image list',self.toggle_thumbnails); view.addSeparator()
251
+ panels=view.addMenu('Panels')
252
+ for dock in self.docks.values(): panels.addAction(dock.toggleViewAction())
253
+ panels.addSeparator()
254
+ add(panels,'Default layout',lambda:self.apply_layout('default'))
255
+ add(panels,'All panels on the right',lambda:self.apply_layout('right'))
256
+ add(view,'Focus mode',self.toggle_focus,'focus')
196
257
  add(view,'Toggle light / dark',self.toggle_theme); add(view,'Analysis',self.show_analysis)
197
258
  add(view,'Previous image',lambda:self.navigate(-1),'previous'); add(view,'Next image',lambda:self.navigate(1),'next')
198
259
  for mode in ('select','box','polygon'): add(view,mode.title()+' tool',lambda checked=False,m=mode:self.set_tool(m),mode)
@@ -213,6 +274,8 @@ class Main(QMainWindow):
213
274
  if typing and key not in ('save','save_next'): return False
214
275
  if key in self.actions:
215
276
  self.actions[key][2](); return True
277
+ if not typing and Qt.Key_1<=event.key()<=Qt.Key_9 and event.modifiers() in (Qt.NoModifier,Qt.KeypadModifier):
278
+ if self.pick_label(event.key()-Qt.Key_1): return True
216
279
  if not typing and event.key() in (Qt.Key_Left,Qt.Key_Right,Qt.Key_Up,Qt.Key_Down) and event.modifiers() in (Qt.NoModifier,Qt.ShiftModifier):
217
280
  self.nudge(event.key(), .01 if event.modifiers()==Qt.ShiftModifier else .001); return True
218
281
  return super().eventFilter(obj,event)
@@ -347,9 +410,45 @@ class Main(QMainWindow):
347
410
  @Slot(object,object)
348
411
  def image_ready(self,token,image):
349
412
  if token!=self.token: return
350
- self.loading=False; self.canvas.set_image(image); self.refresh_controls()
413
+ source=(self.current['width'],self.current['height']) if self.current['width'] and self.current['height'] else None
414
+ self.loading=False; self.canvas.set_image(image,source); self.refresh_controls()
351
415
  self.notice(f"{self.current['width']} × {self.current['height']} px · full-resolution coordinates · local project")
352
416
  if self.dirty and self.settings.value('autosave',True,type=bool): self.timer.start(700)
417
+ self.prefetch_neighbors()
418
+
419
+ def prefetch_neighbors(self):
420
+ row=self.images.currentIndex().row(); paths=[]
421
+ for r in (row+1,row-1,row+2):
422
+ record=self.model.record(r)
423
+ if record:
424
+ path=self.project.resolve(record['path'])
425
+ if path.is_file(): paths.append(path)
426
+ self.loader.prefetch(paths)
427
+
428
+ def request_detail(self):
429
+ if self.current: self.loader.request_detail(self.token,self.project.resolve(self.current['path']))
430
+
431
+ @Slot(object,object)
432
+ def detail_ready(self,token,image):
433
+ if token==self.token: self.canvas.set_detail(image)
434
+
435
+ @Slot(object,str)
436
+ def detail_failed(self,token,error):
437
+ if token==self.token: self.notice(f'Showing the preview at this zoom: {error}')
438
+
439
+ def toggle_minimap(self):
440
+ self.canvas.toggle_minimap(); self.settings.setValue('minimap',self.canvas.show_minimap)
441
+ self.minimap_check.blockSignals(True); self.minimap_check.setChecked(self.canvas.show_minimap); self.minimap_check.blockSignals(False)
442
+
443
+ def minimap_checked(self,on):
444
+ if self.canvas.show_minimap!=on: self.toggle_minimap()
445
+
446
+ def grid_checked(self,on):
447
+ if self.canvas.show_grid!=on: self.toggle_grid()
448
+
449
+ def toggle_grid(self):
450
+ self.canvas.toggle_grid(); self.settings.setValue('pixel_grid',self.canvas.show_grid)
451
+ self.grid_check.blockSignals(True); self.grid_check.setChecked(self.canvas.show_grid); self.grid_check.blockSignals(False)
353
452
 
354
453
  @Slot(object,str)
355
454
  def image_failed(self,token,error):
@@ -565,9 +664,10 @@ class Main(QMainWindow):
565
664
  root=self.project.root
566
665
  self.start_job('import',lambda cancel,progress:import_images(root,paths,cancel,progress))
567
666
 
568
- def import_annotation_dialog(self):
667
+ def import_annotation_dialog(self,folder=False):
569
668
  if not self.project or self.job or self.project.setting('team_remote'): return
570
- path,_=QFileDialog.getOpenFileName(self,'Import Labelme JSON or native annotations.jsonl','','Annotations (*.json *.jsonl)')
669
+ if folder: path=QFileDialog.getExistingDirectory(self,'Import a LabelMe folder (subfolders included)')
670
+ else: path,_=QFileDialog.getOpenFileName(self,'Import Labelme JSON or native annotations.jsonl','','Annotations (*.json *.jsonl)')
571
671
  if path and self.save():
572
672
  root=self.project.root; self.start_job('annotations',lambda cancel,progress:import_annotations(root,path,cancel,progress))
573
673
 
@@ -589,7 +689,9 @@ class Main(QMainWindow):
589
689
  if self.job_kind in ('import','annotations'):
590
690
  self.refresh_images(); self.refresh_labels()
591
691
  text=f"Imported {result['added']:,} images"+(' · cancelled' if result.get('cancelled') else '')
592
- if result.get('errors'): self.show_report('Import summary',text+'\n\n'+'\n'.join(result['errors']))
692
+ shapes=result.get('skipped_shapes') or {}
693
+ details=([('Shapes skipped:\n'+'\n'.join(f' {n:,} × {reason}' for reason,n in sorted(shapes.items())))] if shapes else [])+(['\n'.join(result['errors'])] if result.get('errors') else [])
694
+ if details: self.show_report('Import summary',text+'\n\n'+'\n\n'.join(details))
593
695
  self.notice(text)
594
696
  elif self.job_kind=='export':
595
697
  self.show_report('Export summary',f"Included: {result['included']}\nUnlabeled included: {result['unlabeled']}\nSkipped images: {result['skipped']}\nAnnotations unsupported by this format: {result['unsupported_annotations']}\nCancelled: {result.get('cancelled',False)}\n\n{result['path']}\n\n"+'\n'.join(result['errors']))
@@ -703,7 +805,48 @@ class Main(QMainWindow):
703
805
  self.project.set_setting('tasks',tasks); self.set_tool('select'); self.refresh_controls()
704
806
 
705
807
  def toggle_focus(self):
706
- self.focus_mode=not self.focus_mode; self.left.setVisible(not self.focus_mode); self.right.setVisible(not self.focus_mode)
808
+ self.focus_mode=not self.focus_mode
809
+ if self.focus_mode:
810
+ self.pre_focus=self.dock_host.saveState()
811
+ for dock in self.docks.values(): dock.hide()
812
+ else: self.dock_host.restoreState(self.pre_focus)
813
+
814
+ def pick_label(self,index):
815
+ """Number keys: toggle a class in classification projects, otherwise choose the drawing label
816
+ (which relabels the selected shape, like choosing it in the label box)."""
817
+ if not self.project or not self.current or self.loading: return False
818
+ if self.project.setting('tasks',['box','polygon','classification'])==['classification']:
819
+ if index>=self.classes.count() or not self.classes.isEnabled(): return False
820
+ item=self.classes.item(index); item.setCheckState(Qt.Unchecked if item.checkState()==Qt.Checked else Qt.Checked)
821
+ return True
822
+ if index>=self.label_combo.count(): return False
823
+ self.label_combo.setCurrentIndex(index); self.notice(f'Label {index+1}: {self.label_combo.currentText()}')
824
+ return True
825
+
826
+ def toggle_thumbnails(self):
827
+ self.model.set_show_thumbnails(not self.model.show_thumbnails); self.settings.setValue('thumbnails',self.model.show_thumbnails)
828
+
829
+ def open_palette(self):
830
+ commands=[c for c in menu_commands(self.menuBar()) if not c[0].startswith('Command palette')]
831
+ self.command_palette=CommandPalette(self,commands); self.command_palette.open()
832
+
833
+ def apply_layout(self,name):
834
+ self.focus_mode=False
835
+ self.dock_host.restoreState(self.default_layout)
836
+ for dock in self.docks.values(): dock.setFloating(False); dock.show()
837
+ if name=='right':
838
+ self.dock_host.addDockWidget(Qt.RightDockWidgetArea,self.docks['dataset'])
839
+ self.dock_host.tabifyDockWidget(self.docks['inspector'],self.docks['dataset'])
840
+ self.docks['inspector'].raise_()
841
+
842
+ def adjustments_changed(self,*_):
843
+ values={key:slider.value() for key,(slider,_,_) in self.adjust_sliders.items()}
844
+ for key,(slider,text,_) in self.adjust_sliders.items():
845
+ text.setText(f"{values[key]/100:.2f}" if key=='gamma' else f"{values[key]:+d}")
846
+ self.canvas.set_adjustments(values['brightness'],values['contrast'],values['gamma']/100)
847
+
848
+ def reset_adjustments(self):
849
+ for slider,_,default in self.adjust_sliders.values(): slider.setValue(default)
707
850
 
708
851
  def join_team(self):
709
852
  if self.job or self.sync_job or self.host or not self.save(): return
@@ -875,7 +1018,7 @@ class Main(QMainWindow):
875
1018
  layout.addWidget(button('Close',dialog.accept)); dialog.exec()
876
1019
 
877
1020
  def toggle_theme(self):
878
- self.dark=not self.dark; self.settings.setValue('dark',self.dark); apply_theme(QApplication.instance(),self.dark)
1021
+ self.dark=not self.dark; self.settings.setValue('dark',self.dark); apply_theme(QApplication.instance(),self.dark); self.apply_icons()
879
1022
 
880
1023
  def preferences(self):
881
1024
  dialog=QDialog(self); dialog.setWindowTitle('Keyboard & preferences'); dialog.resize(530,700); layout=QVBoxLayout(dialog)
@@ -898,12 +1041,13 @@ class Main(QMainWindow):
898
1041
  def closeEvent(self,event):
899
1042
  if self.sync_job:
900
1043
  self.sync_job.cancel.set(); self.notice('Waiting for the current team request to finish.'); event.ignore(); return
901
- if self.job or self.loader.active:
902
- self.cancel_job(); self.loader.pending=None
1044
+ if self.job:
1045
+ self.cancel_job(); self.loader.stop()
903
1046
  self.notice('Finishing the active file operation. Close again when it has stopped.'); event.ignore(); return
1047
+ self.loader.stop(); self.thumbnails.pool.clear(); self.thumbnails.pool.waitForDone(3000)
904
1048
  if not self.save():
905
1049
  self.fail('Cannot close safely','Saving failed. Your edits remain in this window. Resolve disk space or permissions and try Save again.'); event.ignore(); return
906
- self.settings.setValue('geometry',self.saveGeometry()); self.settings.setValue('splitter',self.splitter.saveState())
1050
+ self.settings.setValue('geometry',self.saveGeometry()); self.settings.setValue('layout',self.pre_focus if self.focus_mode else self.dock_host.saveState())
907
1051
  self.sync_timer.stop()
908
1052
  if self.host: self.host.stop(); self.host=None
909
1053
  if self.project: self.project.close(); self.project=None
@@ -912,7 +1056,7 @@ class Main(QMainWindow):
912
1056
 
913
1057
  def main():
914
1058
  app=QApplication(sys.argv); app.setOrganizationName('Lannotex'); app.setApplicationName('Lannotex Desktop')
915
- app.setStyle('Fusion')
1059
+ app.setStyle('Fusion'); app.setWindowIcon(logo())
916
1060
  window=Main(); window.show()
917
1061
  # A bounded test must actually enter the Qt event loop and report completion.
918
1062
  if '--smoke-test' in sys.argv: