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.
- {lannotex-0.1.1 → lannotex-0.2.0}/CHANGELOG.md +49 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/PKG-INFO +5 -4
- {lannotex-0.1.1 → lannotex-0.2.0}/README.md +3 -2
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/guide/annotating.md +34 -7
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/migrate-from-labelme.md +10 -6
- {lannotex-0.1.1 → lannotex-0.2.0}/pyproject.toml +3 -1
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/__init__.py +1 -1
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/app.py +181 -37
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/canvas.py +165 -15
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/exporters.py +49 -19
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/home.py +15 -7
- lannotex-0.2.0/src/lannotex/icons.py +55 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/models.py +20 -2
- lannotex-0.2.0/src/lannotex/palette.py +72 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/theme.py +9 -0
- lannotex-0.2.0/src/lannotex/thumbnails.py +101 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/workers.py +84 -22
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/PKG-INFO +5 -4
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/SOURCES.txt +3 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/requires.txt +1 -1
- {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_exporters.py +17 -0
- lannotex-0.2.0/tests/test_ui.py +283 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_workflows.py +17 -0
- lannotex-0.1.1/tests/test_ui.py +0 -148
- {lannotex-0.1.1 → lannotex-0.2.0}/LICENSE +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/LICENSES/GPL-3.0-only.txt +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/LICENSES/LGPL-3.0-only.txt +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/LICENSES/Qt-GPL-exception-1.0.txt +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/LICENSES/THIRD_PARTY_NOTICES.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/Lannotex.spec +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/MANIFEST.in +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/MICROSOFT_STORE.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/PERFORMANCE.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/ROADMAP.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/TEAM.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/VERIFICATION.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/cli.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/getting-started/installation.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/getting-started/quickstart.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/guide/import-export.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/guide/projects.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/docs/index.md +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/requirements-windows-build.txt +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/run_lannotex.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/scripts/benchmark.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/scripts/build_linux.sh +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/scripts/build_macos.sh +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/scripts/build_windows.ps1 +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/scripts/collect_notices.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/scripts/verify_executable.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/setup.cfg +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/__main__.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/cli.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/domain.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/py.typed +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/storage.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex/team.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/dependency_links.txt +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/entry_points.txt +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/src/lannotex.egg-info/top_level.txt +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_cli.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_domain.py +0 -0
- {lannotex-0.1.1 → lannotex-0.2.0}/tests/test_storage.py +0 -0
- {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.
|
|
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
|
|
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:
|
|
185
|
-
|
|
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:
|
|
143
|
-
|
|
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
|
-
|
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
-
|
|
27
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
101
|
-
self.files_button=button('
|
|
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)
|
|
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('
|
|
122
|
-
self.redo_button=button('
|
|
123
|
-
self.zoom_label=label('100%'); tools.addWidget(self.zoom_label)
|
|
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();
|
|
131
|
-
self.
|
|
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.
|
|
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)
|
|
170
|
-
|
|
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)
|
|
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,'
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
902
|
-
self.cancel_job(); self.loader.
|
|
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('
|
|
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:
|