-
Notifications
You must be signed in to change notification settings - Fork 7
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
gselzer
wants to merge
4
commits into
pyapp-kit:v2-mvc
Choose a base branch
from
gselzer:histogram-model
base: v2-mvc
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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): | ||
"""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), | ||
) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 thecurrent_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.