From 79c8fe60ed313cb7ec269ba3408294b78f49361d Mon Sep 17 00:00:00 2001 From: ahuang11 Date: Sat, 23 Mar 2024 21:28:53 -0700 Subject: [PATCH 1/2] Fix paused for GIF and add more example recipes --- docs/best_practices.md | 10 ++ docs/example_recipes/air_temperature.md | 5 +- docs/example_recipes/co2_timeseries.md | 158 ++++++++++++++++++++ docs/example_recipes/temperature_anomaly.md | 153 +++++++++++++++++++ mkdocs.yml | 2 + streamjoy/_utils.py | 2 +- streamjoy/models.py | 2 +- streamjoy/streams.py | 5 +- tests/conftest.py | 1 + tests/test_streams.py | 21 ++- 10 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 docs/example_recipes/co2_timeseries.md create mode 100644 docs/example_recipes/temperature_anomaly.md diff --git a/docs/best_practices.md b/docs/best_practices.md index a1770df..d71bdf1 100644 --- a/docs/best_practices.md +++ b/docs/best_practices.md @@ -170,3 +170,13 @@ No 'max_frames' specified; using the default 50 / 100 frames. Pass `-1` to use a ## 🧩 Use `processes=False` for rendering HoloViews objects This is done automatically! However, in case there's an edge case, note that the kdims/vdims don't seem to carry over properly to the subprocesses when rendering HoloViews objects. It might complain that it can't find the desired dimensions. + +## 📚 Use `threads_per_worker` if flickering + +Matplotlib is not always thread-safe, so if you're seeing flickering, set `threads_per_worker=1`. + +```python +from streamjoy import stream + +stream(..., threads_per_worker=1) +``` \ No newline at end of file diff --git a/docs/example_recipes/air_temperature.md b/docs/example_recipes/air_temperature.md index 9e92c3e..7c8810e 100644 --- a/docs/example_recipes/air_temperature.md +++ b/docs/example_recipes/air_temperature.md @@ -11,6 +11,7 @@ Highlights: import xarray as xr import streamjoy.xarray -ds = xr.tutorial.open_dataset("air_temperature") -ds.streamjoy("air_temperature.mp4") +if __name__ == "__main__": + ds = xr.tutorial.open_dataset("air_temperature") + ds.streamjoy("air_temperature.mp4") ``` diff --git a/docs/example_recipes/co2_timeseries.md b/docs/example_recipes/co2_timeseries.md new file mode 100644 index 0000000..5b5863e --- /dev/null +++ b/docs/example_recipes/co2_timeseries.md @@ -0,0 +1,158 @@ +# CO2 timeseries + + + +Shows the yearly CO2 measurements from the Mauna Loa Observatory in Hawaii. + +The data is sourced from the [datasets/co2-ppm-daily](https://github.com/datasets/co2-ppm-daily/blob/master/co2-ppm-daily-flow.py). + +Highlights: + +- Uses `wrap_matplotlib` to automatically handle saving and closing the figure. +- Uses a custom `renderer` function to create each frame of the animation. +- Uses `Paused` to pause the animation at notable dates. + +```python +import pandas as pd +import matplotlib.pyplot as plt +from matplotlib.ticker import AutoMinorLocator +from streamjoy import stream, wrap_matplotlib, Paused + +URL = "https://raw.githubusercontent.com/datasets/co2-ppm-daily/master/data/co2-ppm-daily.csv" +NOTABLE_YEARS = { + 1958: "Mauna Loa measurements begin", + 1979: "1st World Climate Conference", + 1997: "Kyoto Protocol", + 2005: "exceeded 380 ppm", + 2010: "exceeded 390 ppm", + 2013: "exceeded 400 ppm", + 2015: "Paris Agreement", +} + + +@wrap_matplotlib() +def renderer(df): + plt.style.use("dark_background") + + fig, ax = plt.subplots(figsize=(7, 5)) + fig.patch.set_facecolor("#1b1e23") + ax.set_facecolor("#1b1e23") + ax.set_frame_on(False) + ax.axis("off") + ax.set_title( + "CO2 Yearly Max", + fontsize=20, + loc="left", + fontname="Courier New", + color="lightgrey", + ) + + # draw line + df.plot( + y="value", + color="lightgrey", # Line color + legend=False, + ax=ax, + ) + + # max date + max_date = df["value"].idxmax() + max_co2 = df["value"].max() + ax.text( + 0.0, + 0.92, + f"{max_co2:.0f} ppm", + va="bottom", + ha="left", + transform=ax.transAxes, + fontsize=25, + color="lightgrey", + ) + ax.text( + 0.0, + 0.91, + f"Peaked in {max_date.year}", + va="top", + ha="left", + transform=ax.transAxes, + fontsize=12, + color="lightgrey", + fontname="Courier New", + ) + + # draw end point + date = df.index[-1] + co2 = df["value"].values[-1] + diff = df["diff"].fillna(0).values[-1] + diff = f"+{diff:.0f}" if diff >= 0 else f"{diff:.0f}" + ax.scatter(date, co2, color="red", zorder=999) + ax.annotate( + f"{diff} ppm", + (date, co2), + textcoords="offset points", + xytext=(-10, 5), + fontsize=12, + ha="right", + va="bottom", + color="lightgrey", + ) + + # draw source label + ax.text( + 0.0, + 0.03, + f"Source: {URL}", + va="bottom", + ha="left", + transform=ax.transAxes, + fontsize=8, + color="lightgrey", + ) + + # properly tighten layout + plt.subplots_adjust(bottom=0, top=0.9, right=0.9, left=0.05) + + # pause at notable years + year = date.year + if year in NOTABLE_YEARS: + ax.annotate( + f"{NOTABLE_YEARS[year]} - {year}", + (date, co2), + textcoords="offset points", + xytext=(-10, 3), + fontsize=10.5, + ha="right", + va="top", + color="lightgrey", + fontname="Courier New", + ) + return Paused(ax, 2.8) + else: + ax.annotate( + year, + (date, co2), + textcoords="offset points", + xytext=(-10, 3), + fontsize=10.5, + ha="right", + va="top", + color="lightgrey", + fontname="Courier New", + ) + return ax + + +if __name__ == "__main__": + df = ( + pd.read_csv(URL, parse_dates=True, index_col="date") + .resample("1YE") + .max() + .interpolate() + .assign( + diff=lambda df: df["value"].diff(), + ) + ) + stream(df, renderer=renderer, max_frames=-1, threads_per_worker=1).write("co2_emissions.mp4") +``` \ No newline at end of file diff --git a/docs/example_recipes/temperature_anomaly.md b/docs/example_recipes/temperature_anomaly.md new file mode 100644 index 0000000..cc80916 --- /dev/null +++ b/docs/example_recipes/temperature_anomaly.md @@ -0,0 +1,153 @@ +# Temperature anomaly + + + +Shows the global temperature anomaly from 1995 to 2024 using the HadCRUT5 dataset. The video pauses at notable dates. + +Highlights: + +- Uses `wrap_matplotlib` to automatically handle saving and closing the figure. +- Uses a custom `renderer` function to create each frame of the animation. +- Uses `Paused` to pause the animation at notable dates. + +```python +import pandas as pd +import matplotlib.pyplot as plt +from streamjoy import stream, wrap_matplotlib, Paused + +URL = "https://climexp.knmi.nl/data/ihadcrut5_global.dat" +NOTABLE_DATES = { + "1997-12": "Kyoto Protocol adopted", + "2005-01": "Exceeded 380 ppm", + "2010-01": "Exceeded 390 ppm", + "2013-05": "Exceeded 400 ppm", + "2015-12": "Paris Agreement signed", + "2016-01": "CO2 permanently over 400 ppm", +} + + +@wrap_matplotlib() +def renderer(df): + plt.style.use("dark_background") # Setting the style for dark mode + + fig, ax = plt.subplots() + fig.patch.set_facecolor("#1b1e23") + ax.set_facecolor("#1b1e23") + ax.set_frame_on(False) + ax.axis("off") + + # Set title + year = df["year"].iloc[-1] + ax.set_title( + f"Global Temperature Anomaly {year} [HadCRUT5]", + fontsize=15, + loc="left", + fontname="Courier New", + color="lightgrey", + ) + + # draw line + df.groupby("year")["anom"].plot( + y="anom", color="lightgrey", legend=False, ax=ax, lw=0.5 + ) + + # add source text at bottom right + ax.text( + 0.01, + 0.05, + f"Source: {URL}", + va="bottom", + ha="left", + transform=ax.transAxes, + fontsize=8, + color="lightgrey", + fontname="Courier New", + ) + + # draw end point + jday = df.index.values[-1] + anom = df["anom"].values[-1] + ax.scatter(jday, anom, color="red", zorder=999) + anom_label = f"+{anom:.1f} K" if anom > 0 else f"{anom:.1f} K" + ax.annotate( + anom_label, + (jday, anom), + textcoords="offset points", + xytext=(-10, 5), + fontsize=12, + ha="right", + va="bottom", + color="lightgrey", + ) + + # draw yearly labels + for year, df_year in df.reset_index().groupby("year").last().iloc[-5:].iterrows(): + if df_year["month"] != 12: + continue + ax.annotate( + year, + (df_year["jday"], df_year["anom"]), + fontsize=12, + ha="left", + va="center", + color="lightgrey", + fontname="Courier New", + ) + + plt.subplots_adjust(bottom=0, top=0.9, left=0.05) + + month = df["date"].iloc[-1].strftime("%b") + ax.annotate( + month, + (jday, anom), + textcoords="offset points", + xytext=(-10, 3), + fontsize=12, + ha="right", + va="top", + color="lightgrey", + fontname="Courier New", + ) + date_string = df["date"].iloc[-1].strftime("%Y-%m") + if date_string in NOTABLE_DATES: + ax.annotate( + f"{NOTABLE_DATES[date_string]}", + xy=(0, 1), + xycoords="axes fraction", + xytext=(0, -5), + textcoords="offset points", + fontsize=12, + ha="left", + va="top", + color="lightgrey", + fontname="Courier New", + ) + return Paused(fig, 3) + return fig + + +df = ( + pd.read_csv( + URL, + comment="#", + header=None, + sep="\s+", + na_values=[-999.9], + ) + .rename(columns={0: "year"}) + .melt(id_vars="year", var_name="month", value_name="anom") +) +df.index = pd.to_datetime( + df["year"].astype(str) + df["month"].astype(str), format="%Y%m" +) +df = df.sort_index()["1995":"2024"] +df["jday"] = df.index.dayofyear +df = df.rename_axis("date").reset_index().set_index("jday") +df_list = [df[:i] for i in range(1, len(df) + 1)] + +stream(df_list, renderer=renderer, threads_per_worker=1).write( + "temperature_anomaly.mp4" +) +``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 68d4da0..0a7fa61 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -67,6 +67,8 @@ nav: - Example recipes: - Air temperature: example_recipes/air_temperature.md - Sine wave: example_recipes/sine_wave.md + - CO2 timeseries: example_recipes/co2_timeseries.md + - Temperature anomaly: example_recipes/temperature_anomaly.md - Sea ice: example_recipes/sea_ice.md - OISST globe: example_recipes/oisst_globe.md - Gender gapminder: example_recipes/gender_gapminder.md diff --git a/streamjoy/_utils.py b/streamjoy/_utils.py index c5a2db2..30752e3 100644 --- a/streamjoy/_utils.py +++ b/streamjoy/_utils.py @@ -296,5 +296,5 @@ def imread_with_pause( ) -> np.ndarray | Paused: imread_kwargs = dict(extension=extension, plugin=plugin) if isinstance(uri, Paused): - return Paused(iio.imread(uri.output, **imread_kwargs), uri.seconds).squeeze() + return Paused(iio.imread(uri.output, **imread_kwargs).squeeze(), uri.seconds) return iio.imread(uri, **imread_kwargs).squeeze() diff --git a/streamjoy/models.py b/streamjoy/models.py index 49f35f6..66f69bd 100644 --- a/streamjoy/models.py +++ b/streamjoy/models.py @@ -17,7 +17,7 @@ class Paused(param.Parameterized): output = param.Parameter(doc="The output to pause for.") - seconds = param.Integer(doc="The number of seconds to pause for.") + seconds = param.Number(doc="The number of seconds to pause for.") def __init__(self, output: Any, seconds: int, **params): self.output = output diff --git a/streamjoy/streams.py b/streamjoy/streams.py index 7bd794e..1e5c17a 100644 --- a/streamjoy/streams.py +++ b/streamjoy/streams.py @@ -1249,8 +1249,11 @@ def _write_images( self._prepend_intro(buf, intro_frame, **write_kwargs) - for image in images: + for i, image in enumerate(images): image = _utils.get_result(image) + if isinstance(image, Paused): + duration[i] = image.seconds * 1000 + image = image.output buf.write(image[:, :, :3], **write_kwargs) del image diff --git a/tests/conftest.py b/tests/conftest.py index 7d7f692..05adf04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,7 @@ def client(): @pytest.fixture(autouse=True, scope="session") def default_config(): + config["fps"] = 1 config["max_frames"] = 3 config["max_files"] = 2 config["ending_pause"] = 0 diff --git a/tests/test_streams.py b/tests/test_streams.py index e84d8ac..006b697 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -1,7 +1,10 @@ import pytest +import pandas as pd from imageio.v3 import improps from streamjoy.streams import GifStream, Mp4Stream +from streamjoy.wrappers import wrap_matplotlib +from streamjoy.models import Paused class AbstractTestMediaStream: @@ -44,14 +47,30 @@ def test_from_directory(self, stream_cls, data_dir): sj = stream_cls.from_directory(data_dir, pattern="*.png") self._assert_stream_and_props(sj, stream_cls) - class TestGifStream(AbstractTestMediaStream): @pytest.fixture(scope="class") def stream_cls(self): return GifStream + def test_paused(self, stream_cls, df): + @wrap_matplotlib() + def renderer(df, groupby=None): # TODO: fix bug groupby not needed + return Paused(df.plot(), seconds=2) + + buf = stream_cls.from_pandas(df, renderer=renderer).write() + props = improps(buf) + assert props.n_images == 3 class TestMp4Stream(AbstractTestMediaStream): @pytest.fixture(scope="class") def stream_cls(self): return Mp4Stream + + def test_paused(self, stream_cls, df): + @wrap_matplotlib() + def renderer(df, groupby=None): # TODO: fix bug groupby not needed + return Paused(df.plot(), seconds=2) + + buf = stream_cls.from_pandas(df, renderer=renderer).write() + props = improps(buf) + assert props.n_images == 9 From 0684b3f71687fbdd984e1f76342b137a49f6f254 Mon Sep 17 00:00:00 2001 From: ahuang11 Date: Sat, 23 Mar 2024 21:31:46 -0700 Subject: [PATCH 2/2] Lint --- tests/test_streams.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_streams.py b/tests/test_streams.py index 006b697..2926f46 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -1,10 +1,9 @@ import pytest -import pandas as pd from imageio.v3 import improps +from streamjoy.models import Paused from streamjoy.streams import GifStream, Mp4Stream from streamjoy.wrappers import wrap_matplotlib -from streamjoy.models import Paused class AbstractTestMediaStream: @@ -47,6 +46,7 @@ def test_from_directory(self, stream_cls, data_dir): sj = stream_cls.from_directory(data_dir, pattern="*.png") self._assert_stream_and_props(sj, stream_cls) + class TestGifStream(AbstractTestMediaStream): @pytest.fixture(scope="class") def stream_cls(self): @@ -61,6 +61,7 @@ def renderer(df, groupby=None): # TODO: fix bug groupby not needed props = improps(buf) assert props.n_images == 3 + class TestMp4Stream(AbstractTestMediaStream): @pytest.fixture(scope="class") def stream_cls(self):