"""``B13`` — counting by hand on the layer canvas.
:mod:`spacr.counting` is the session: the classes, the markers, the undo stack
and the export, all in plain numpy so the tally can be tested without a
display. This is the mouse and the panel — a click that lands on a *world*
coordinate, a live tally beside the image, and one button that writes the
clicks out rather than the total.
Why a click removes as well as adds
-----------------------------------
A counting session is thousands of clicks and a proportion of them are wrong.
The two ways of correcting a mistake are different actions: **undo** takes back
the last thing you did, and **clicking a marker again** takes back a specific
thing you did some time ago. Both are here, and clicking a marker removes it
whatever class it was scored as — a counter who has to re-select the class
before they can take a marker back will leave the wrong marker there.
The tally is derived, never typed
---------------------------------
Everything the panel shows is read from the marker layers on every model
event. There is no counter variable to drift out of step with the picture, so
a marker removed through the layer list rather than through this panel changes
the number too.
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (QAbstractItemView, QFileDialog, QHBoxLayout,
QLabel, QListWidget, QListWidgetItem,
QVBoxLayout, QWidget)
from ..counting import CountingSession
from ..layers import FieldKey, LayerError, LayerEvent
from .layer_viewer import CanvasTool, LayerCanvas
from .theme import register_widget_qss
from .widgets.preview_controls import FlatButton
LOG = logging.getLogger(__name__)
__all__ = [
"CountingTool",
"CountingPanel",
]
def _counting_qss(palette: Dict[str, Any], opacity) -> str:
"""This panel's QSS block, appended to every generated stylesheet."""
return f"""
QWidget#CountingPanel {{
background: transparent;
}}
QListWidget#CountingClasses {{
background: {palette["surface_alt"]};
border: 1px solid {palette["border_soft"]};
border-radius: 10px;
padding: 4px;
color: {palette["fg"]};
}}
QListWidget#CountingClasses::item {{
padding: 5px 6px;
border-radius: 6px;
}}
QListWidget#CountingClasses::item:selected {{
background: {palette["accent_soft"]};
color: {palette["fg"]};
}}
QLabel#CountingTotal {{
color: {palette["fg_muted"]};
}}
"""
register_widget_qss("CountingPanel", _counting_qss, replace=True)
[docs]
class CountingPanel(QWidget):
"""The tally beside the image, and the button that writes it out.
:param canvas: the :class:`~spacr.qt.layer_viewer.LayerCanvas` to count on.
:param classes: what is being counted; see
:class:`spacr.counting.CountingSession`.
:param field: the :class:`~spacr.layers.FieldKey` being counted, carried
into the export so a count can join the measurement tables.
:param parent: parent widget; ownership only.
:param session: an already-built
:class:`~spacr.counting.CountingSession`. ``None`` builds one from
the other arguments, which is the ordinary case; PASSING ONE HANDS THE
PANEL A SESSION THAT ALREADY HAS STATE -- a part-finished tally,
or one a test wrote directly -- so the panel resumes it instead of
starting over.
"""
#: The tally changed. Carries ``{class: count}``.
counts_changed = Signal(object)
#: A count was written. Carries the path.
exported = Signal(str)
def __init__(self, canvas: LayerCanvas, parent=None, *,
classes: Optional[List[Any]] = None,
field: Optional[FieldKey] = None,
session: Optional[CountingSession] = None):
"""Build the counting panel over a canvas.
:param canvas: the canvas clicks are counted on.
:param parent: parent widget, or ``None``.
:param classes: the classes to count; ignored when ``session`` is given.
:param field: which field this count belongs to; ignored when
``session`` is given.
:param session: an existing session to continue, rather than starting a
new count.
"""
super().__init__(parent)
self.setObjectName("CountingPanel")
self._canvas = canvas
self._session = session or CountingSession(
canvas.stack, classes=classes, field=field)
self._tool: Optional[CountingTool] = None
self._syncing = False
self._build()
self._canvas.stack.subscribe(self._on_layers_changed)
self.refresh()
def _build(self) -> None:
"""Lay out the count toggle, the class list, the tally and the exports."""
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(6)
self.count_button = FlatButton(
"Count", self,
tooltip="Click to add a marker, click a marker to take it away, "
"1–9 choose the class, Backspace undoes")
self.count_button.setCheckable(True)
self.count_button.toggled.connect(self._on_count_toggled)
outer.addWidget(self.count_button)
self.class_list = QListWidget(self)
self.class_list.setObjectName("CountingClasses")
self.class_list.setSelectionMode(QAbstractItemView.SingleSelection)
self.class_list.currentRowChanged.connect(self._on_class_selected)
outer.addWidget(self.class_list, 1)
row = QHBoxLayout()
row.setSpacing(4)
self.add_class_button = FlatButton("+ Class", self,
tooltip="Count something else too")
self.add_class_button.clicked.connect(self.add_class)
row.addWidget(self.add_class_button)
self.undo_button = FlatButton("Undo", self,
tooltip="Take back the last click")
self.undo_button.clicked.connect(self.undo)
row.addWidget(self.undo_button)
self.clear_button = FlatButton("Clear", self,
tooltip="Remove every marker")
self.clear_button.clicked.connect(self.clear)
row.addWidget(self.clear_button)
outer.addLayout(row)
self.total = QLabel("", self)
self.total.setObjectName("CountingTotal")
self.total.setWordWrap(True)
outer.addWidget(self.total)
exports = QHBoxLayout()
exports.setSpacing(4)
self.export_button = FlatButton(
"Export clicks…", self,
tooltip="One row per marker: class, world coordinates and units")
self.export_button.clicked.connect(self.export_points)
exports.addWidget(self.export_button, 1)
self.summary_button = FlatButton(
"Export tally…", self, tooltip="One row per class: count and share")
self.summary_button.clicked.connect(self.export_summary)
exports.addWidget(self.summary_button, 1)
outer.addLayout(exports)
@property
[docs]
def session(self) -> CountingSession:
"""The counting session this panel drives."""
return self._session
@property
[docs]
def start_counting(self) -> CountingTool:
"""Attach the counting tool to the canvas and return it."""
self._tool = CountingTool(self._session)
self._canvas.set_tool(self._tool)
return self._tool
[docs]
def stop_counting(self) -> None:
"""Give the canvas its mouse back."""
if self._canvas.tool is self._tool and self._tool is not None:
self._canvas.set_tool(None)
self._tool = None
def _on_count_toggled(self, checked: bool) -> None:
"""Start or stop counting to match the button.
:param checked: the button's new state.
"""
if checked:
self.start_counting()
else:
self.stop_counting()
def _on_layers_changed(self, event: LayerEvent) -> None:
"""Refresh the tally when the markers change.
The counts are derived from the layer rather than stored, so a marker
removed through the layer list changes the number here too.
:param event: the layer event; only data and membership changes matter.
"""
if event.kind in ("data", "inserted", "removed"):
self.refresh()
[docs]
def add_class(self, name: Optional[str] = None) -> str:
"""Count one more thing; returns the class name."""
if not isinstance(name, str) or not name.strip():
name = f"class {len(self._session.classes) + 1}"
entry = self._session.add_class(name)
self.refresh()
return entry.name
[docs]
def undo(self) -> bool:
"""Take back the last click. ``True`` if there was one."""
undone = self._session.undo()
self.refresh()
return undone is not None
[docs]
def clear(self) -> int:
"""Remove every marker of every class; returns how many went."""
removed = self._session.clear()
self.refresh()
return removed
[docs]
def export_points(self) -> Optional[str]:
"""Write one row per marker, asking where."""
return self._export(summary=False)
[docs]
def export_summary(self) -> Optional[str]:
"""Write one row per class, asking where."""
return self._export(summary=True)
def _export(self, *, summary: bool) -> Optional[str]:
"""Ask where to write a count and write it.
:param summary: write the per-class tally rather than one row per
marker.
:returns: the written path, or ``None`` when the dialog was cancelled.
"""
suggested = os.path.join(
os.getcwd(), "counts_summary.csv" if summary else "counts.csv")
path, _ = QFileDialog.getSaveFileName(
self, "Save the count", suggested, "CSV (*.csv)")
if not path:
return None
return self.write(path, summary=summary)
[docs]
def write(self, path: str, *, summary: bool = False) -> Optional[str]:
"""Write the count to ``path`` without asking; returns the path.
The seam the dialog goes through, so a screen (or a test) can save a
count without a modal.
:param path: destination CSV path, handed to the session's
``to_csv``; on an ``OSError`` or ``LayerError`` the error is shown
in the panel and ``None`` is returned.
:param summary: write one row per class instead of one per marker.
"""
try:
target = self._session.to_csv(path, summary=summary)
except (OSError, LayerError) as exc:
LOG.info("could not write the count", exc_info=True)
self.total.setText(f"Could not write the count: {exc}")
return None
self.exported.emit(target)
return target
def _on_class_selected(self, row: int) -> None:
"""Make the selected class the one new clicks are counted into.
:param row: the newly selected row; ignored while the panel is filling
the list itself, which would otherwise change the active class as a
side effect of a refresh.
"""
if self._syncing or row < 0:
return
names = self._session.class_names
if row < len(names):
self._session.active = names[row]
[docs]
def refresh(self) -> None:
"""Redraw the tally from the marker layers."""
counts = self._session.counts()
self._syncing = True
try:
self.class_list.clear()
for entry in self._session.classes:
n = counts[entry.name]
share = self._session.fraction(entry.name)
shortcut = f" [{entry.shortcut}]" if entry.shortcut else ""
item = QListWidgetItem(
f"{entry.name}{shortcut} {n} ({share:.0%})")
item.setData(Qt.UserRole, entry.name)
item.setToolTip(
f"{n} marker(s) of {entry.name}"
+ (f" — press {entry.shortcut} to count these"
if entry.shortcut else ""))
self.class_list.addItem(item)
names = self._session.class_names
if self._session.active in names:
self.class_list.setCurrentRow(names.index(self._session.active))
finally:
self._syncing = False
self.total.setText(self._session.describe())
self.counts_changed.emit(counts)
[docs]
def closeEvent(self, event) -> None:
"""Stop listening and give the canvas its mouse back.
:param event: the close event; it is passed on unchanged to the
base-class handler.
"""
self.stop_counting()
self._canvas.stack.unsubscribe(self._on_layers_changed)
super().closeEvent(event)