Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: port histogram model #55

Draft
wants to merge 4 commits into
base: v2-mvc
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions mvc_histogram.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "5b3d3ba7",
"metadata": {},
"outputs": [],
"source": [
"from typing import Any\n",
"\n",
"from ndv.data import cells3d\n",
"from ndv.models import LUTModel, StatsModel\n",
"from ndv.views import get_histogram_backend\n",
"from ndv.views.protocols import PHistogramView\n",
"\n",
"\n",
"# TODO: Put this somewhere else.\n",
"class Controller:\n",
" \"\"\"A (Qt) wrapper around another HistogramView with some additional controls.\"\"\"\n",
"\n",
" def __init__(\n",
" self,\n",
" stats_model: StatsModel | None = None,\n",
" lut_model: LUTModel | None = None,\n",
" view: PHistogramView | None = None,\n",
" ) -> None:\n",
" if stats_model is None:\n",
" stats_model = StatsModel()\n",
" if lut_model is None:\n",
" lut_model = LUTModel()\n",
" if view is None:\n",
" view = get_histogram_backend()\n",
" self._stats = stats_model\n",
" self._lut = lut_model\n",
" self._view = view\n",
"\n",
" # A HistogramView is both a StatsView and a LUTView\n",
" # StatModel <-> StatsView\n",
" self._stats.events.data.connect(self._set_data)\n",
" self._stats.events.bins.connect(self._set_data)\n",
" # LutModel <-> LutView\n",
" self._lut.events.clims.connect(self._set_model_clims)\n",
" self._view.climsChanged.connect(self._set_view_clims)\n",
" self._lut.events.gamma.connect(self._set_model_gamma)\n",
" self._view.gammaChanged.connect(self._set_view_gamma)\n",
"\n",
" def _set_data(self) -> None:\n",
" values, bin_edges = self._stats.histogram\n",
" self._view.setHistogram(values, bin_edges)\n",
"\n",
" def _set_model_clims(self) -> None:\n",
" clims = self._lut.clims\n",
" self._view.setClims(clims)\n",
"\n",
" def _set_view_clims(self, clims: tuple[float, float]) -> None:\n",
" self._lut.clims = clims\n",
"\n",
" def _set_model_gamma(self) -> None:\n",
" gamma = self._lut.gamma\n",
" self._view.setGamma(gamma)\n",
"\n",
" def _set_view_gamma(self, gamma: float) -> None:\n",
" self._lut.gamma = gamma\n",
"\n",
" def view(self) -> Any:\n",
" \"\"\"Returns an object that can be displayed by the active backend.\"\"\"\n",
" return self._view\n",
"\n",
"\n",
"viewer = Controller()\n",
"viewer._stats.data = cells3d()\n",
"\n",
"viewer.view().show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "595ad5fd",
"metadata": {},
"outputs": [],
"source": [
"# Change the data\n",
"from numpy.random import normal\n",
"\n",
"viewer._stats.data = normal(30000, 10000, 10000000)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.7"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
102 changes: 102 additions & 0 deletions mvc_histogram.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import (
QApplication,
QPushButton,
QVBoxLayout,
QWidget,
)

from ndv.models import LUTModel, StatsModel
from ndv.views import get_histogram_backend

if TYPE_CHECKING:
from typing import Any

from ndv.views.protocols import PHistogramView


class Controller:
"""A (Qt) wrapper around another HistogramView with some additional controls."""

def __init__(
self,
stats_model: StatsModel | None = None,
lut_model: LUTModel | None = None,
view: PHistogramView | None = None,
) -> None:
self._wdg = QWidget()
if stats_model is None:
stats_model = StatsModel()
if lut_model is None:
lut_model = LUTModel()
if view is None:
view = get_histogram_backend()
self._stats = stats_model
self._lut = lut_model
self._view = view

# A HistogramView is both a StatsView and a LUTView
# StatModel <-> StatsView
self._stats.events.data.connect(self._set_data)
self._stats.events.bins.connect(self._set_data)
# LutModel <-> LutView
self._lut.events.clims.connect(self._set_model_clims)
self._view.climsChanged.connect(self._set_view_clims)
self._lut.events.gamma.connect(self._set_model_gamma)
self._view.gammaChanged.connect(self._set_view_gamma)

# Data updates
self._data_btn = QPushButton("Change Data")
self._data_btn.setCheckable(True)
self._data_btn.toggled.connect(
lambda toggle: self.timer.blockSignals(not toggle)
)

def _update_data() -> None:
"""Replaces the displayed data."""
self._stats.data = np.random.normal(10, 10, 10000)

self.timer = QTimer()
self.timer.setInterval(10)
self.timer.blockSignals(True)
self.timer.timeout.connect(_update_data)
self.timer.start()

# Layout
self._layout = QVBoxLayout(self._wdg)
self._layout.addWidget(self._view.view())
self._layout.addWidget(self._data_btn)

def _set_data(self) -> None:
values, bin_edges = self._stats.histogram
self._view.setHistogram(values, bin_edges)

def _set_model_clims(self) -> None:
clims = self._lut.clims
self._view.setClims(clims)

def _set_view_clims(self, clims: tuple[float, float]) -> None:
self._lut.clims = clims

def _set_model_gamma(self) -> None:
gamma = self._lut.gamma
self._view.setGamma(gamma)

def _set_view_gamma(self, gamma: float) -> None:
self._lut.gamma = gamma

def view(self) -> Any:
"""Returns an object that can be displayed by the active backend."""
return self._wdg


app = QApplication.instance() or QApplication([])

widget = Controller()
widget.view().show()
app.exec()
4 changes: 3 additions & 1 deletion src/ndv/controller/_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ class ViewerController:
"""The controller mostly manages the connection between the model and the view."""

def __init__(
self, view: PView | None = None, data: DataDisplayModel | None = None
self,
view: PView | None = None,
data: DataDisplayModel | None = None,
) -> None:
if data is None:
data = DataDisplayModel()
Expand Down
9 changes: 8 additions & 1 deletion src/ndv/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
from ._array_display_model import ArrayDisplayModel
from ._data_display_model import DataDisplayModel
from ._lut_model import LUTModel
from ._stats_model import StatsModel
from .data_wrappers._data_wrapper import DataWrapper

__all__ = ["ArrayDisplayModel", "LUTModel", "DataDisplayModel", "DataWrapper"]
__all__ = [
"ArrayDisplayModel",
"LUTModel",
"DataDisplayModel",
"DataWrapper",
"StatsModel",
]
138 changes: 138 additions & 0 deletions src/ndv/models/_stats_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Model protocols for data display."""

from __future__ import annotations

from collections.abc import Sequence
from functools import cached_property
from typing import TYPE_CHECKING, Annotated, cast

import numpy as np
from pydantic import (
GetCoreSchemaHandler,
GetJsonSchemaHandler,
computed_field,
model_validator,
)
from pydantic_core import core_schema

from ndv.models._base_model import NDVModel

if TYPE_CHECKING:
from typing import Any

from pydantic.json_schema import JsonSchemaValue


# copied from https://github.com/tlambert03/microsim
class _NumpyNdarrayPydanticAnnotation:
@classmethod
def __get_pydantic_core_schema__(
cls, _source_type: Any, _handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
def validate_from_any(value: Any) -> np.ndarray:
try:
return np.asarray(value)
except Exception as e:
raise ValueError(f"Cannot cast {value} to numpy.ndarray: {e}") from e

from_any_schema = core_schema.chain_schema(
[
core_schema.any_schema(),
core_schema.no_info_plain_validator_function(validate_from_any),
]
)

return core_schema.json_or_python_schema(
json_schema=from_any_schema,
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(np.ndarray),
from_any_schema,
]
),
serialization=core_schema.plain_serializer_function_ser_schema(
lambda instance: instance.tolist()
),
)

@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
# Use the same schema that would be used for arrays
return handler(core_schema.list_schema(core_schema.any_schema()))


NumpyNdarray = Annotated[np.ndarray, _NumpyNdarrayPydanticAnnotation]


class StatsModel(NDVModel):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one immediate thought I'm having is that i'm not sure this object needs to be a "model" at all. It doesn't really feel like a part of the "state" that we need to track (for example, something that we would ever need to serialize). It seems like it will almost always be derived from other state (specifically, the data_wrapper and the current_index): i.e. when the sliders change or some other user interaction happens that requires us to recalculate a histogram, some object will definitely need to receive that new data and calculate a histogram, but whether it needs to have the full model/view/controller treatment is something i'm not yet convinced of. So, it's possible we could reduce the complexity here a bit by not treating this as a first class object that we pass in to __init__ functions and stuff, but rather just as an implementation detail.

"""Representation of the statistics of a dataset.

A model that computes and caches statistical properties of a dataset,
including standard deviation, average, and histogram.

Those interested in statistics should listen to the events.data and events.bins
signals emitted by this object.

TODO can we only have the data signal?

Parameters
----------
data : np.ndarray | None
The dataset.
bins : int
Number of bins to use for histogram computation. Defaults to 256.
average : float
The average (mean) value of data.
standard_deviation : float
The standard deviation of data.
histogram : tuple[Sequence[int], Sequence[float]]
A 2-tuple of sequences.

The first sequence contains (n) integers, where index i is the number of data
points in the ith bin.

The second sequence contains (n+1) floats. The ith bin spans the domain
between the values at index i (inclusive) and index i+1 (exclusive).
"""

data: NumpyNdarray | None = None
bins: int = 256

@model_validator(mode="before")
def validate_data(cls, input: dict[str, Any], *args: Any) -> dict[str, Any]:
"""Delete computed fields when data changes."""
# Recompute computed stats when bins/data changes
if "data" in input:
for field in ["average", "standard_deviation", "histogram"]:
if field in input:
del input[field]
return input

@computed_field # type: ignore[prop-decorator]
@cached_property
def standard_deviation(self) -> float:
"""Computes the standard deviation of the dataset."""
if self.data is None:
return float("nan")
return float(np.std(self.data))

@computed_field # type: ignore[prop-decorator]
@cached_property
def average(self) -> float:
"""Computes the average of the dataset."""
if self.data is None:
return float("nan")
return float(np.mean(self.data))

@computed_field # type: ignore[prop-decorator]
@cached_property
def histogram(self) -> tuple[Sequence[int], Sequence[float]]:
"""Computes the histogram of the dataset."""
if self.data is None:
return ([], [])
return cast(
tuple[Sequence[int], Sequence[float]],
np.histogram(self.data, bins=self.bins),
)
Loading
Loading