Skip to content

Commit 2794c37

Browse files
committed
Added thumbnail maker
1 parent 29f6b12 commit 2794c37

File tree

6 files changed

+205
-16
lines changed

6 files changed

+205
-16
lines changed

notebooks/catalog_reading.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
def _():
99
import marimo as mo
1010
import pystac
11+
from pathlib import Path
1112

12-
return mo, pystac
13+
return Path, mo, pystac
1314

1415

1516
@app.cell
16-
def _(pystac):
17-
catalog = pystac.Catalog.from_file("./data/processed/catalog.json")
17+
def _(Path, pystac):
18+
catalog = pystac.Catalog.from_file(Path.home() / "data-folder/catalog/catalog.json")
1819
catalog.describe()
1920
return (catalog,)
2021

notebooks/omega_cubes.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,73 @@ def _(dt, ex_nc_ds_l2, re):
886886
return
887887

888888

889+
@app.cell
890+
def _(ex_nc_ds_l2, plt):
891+
ex_nc_ds_l2.Reflectance.mean("wavelength").plot(cmap=plt.cm.get_cmap("viridis"))
892+
plt.title("Reflectance map based on wavelengh mean")
893+
return
894+
895+
896+
@app.cell
897+
def _(Path, ex_nc_ds_l2, mo, np, plt):
898+
# import numpy as np
899+
from PIL import Image
900+
from typing import Literal
901+
from tempfile import TemporaryDirectory
902+
903+
def convert_to_thumbnail(
904+
data: np.ndarray,
905+
resize_dims: tuple[int, int],
906+
mode: Literal["L", "RGB", "RGBA"] = "L",
907+
cmap: str | None = None,
908+
) -> Image.Image:
909+
"""
910+
Converts a 2D or 3D NumPy array into a resized PNG-style image.
911+
Applies a matplotlib colormap if provided.
912+
"""
913+
# --- Ensure numpy array ---
914+
data = np.asarray(data, dtype=float)
915+
916+
# --- Normalize input data to 0-1 ---
917+
data = (data - np.nanmin(data)) / (np.nanmax(data) - np.nanmin(data) + 1e-8)
918+
919+
# --- Apply colormap if requested ---
920+
if cmap is not None:
921+
cm = plt.get_cmap(cmap)
922+
result = cm(data)[..., :4] # includes alpha
923+
result = (result * 255).astype(np.uint8)
924+
if mode == "RGB":
925+
result = result[..., :3]
926+
else:
927+
result = (data * 255).astype(np.uint8)
928+
if mode in ["RGB", "RGBA"]:
929+
result = np.stack([result] * (3 if mode == "RGB" else 4), axis=-1)
930+
931+
# --- Resize with high-quality interpolation ---
932+
img = Image.fromarray(result, mode=mode)
933+
img = img.resize(resize_dims, Image.Resampling.LANCZOS)
934+
935+
return img
936+
937+
tempdir = TemporaryDirectory()
938+
resized_img = convert_to_thumbnail(
939+
ex_nc_ds_l2.Reflectance.mean("wavelength").values,
940+
(256, 256),
941+
mode="RGB",
942+
cmap="rainbow",
943+
)
944+
945+
resized_img.save(Path(tempdir.name) / "test.png")
946+
mo.image(src=Path(tempdir.name) / "test.png")
947+
return
948+
949+
950+
@app.cell
951+
def _(ex_nc_ds_l2):
952+
getattr(ex_nc_ds_l2.Reflectance, "mean")("wavelength").values
953+
return
954+
955+
889956
@app.cell
890957
def _():
891958
return

notebooks/projections.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ def _(WktIoHandler):
2323
return
2424

2525

26+
@app.cell
27+
def _():
28+
from stac_validator import stac_validator
29+
30+
stac = stac_validator.StacValidate("./data/processed/catalog.json", extensions=True)
31+
stac.run()
32+
stac.message
33+
return
34+
35+
2636
@app.cell
2737
def _():
2838
return

src/psup_stac_converter/omega/_base.py

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from psup_stac_converter.extensions import apply_sci, apply_ssys
2626
from psup_stac_converter.informations.data_providers import providers as data_providers
2727
from psup_stac_converter.settings import create_logger
28+
from psup_stac_converter.utils.file_utils import convert_arr_to_thumbnail
2829
from psup_stac_converter.utils.io import PsupIoHandler
2930
from psup_stac_converter.utils.models import (
3031
CubedataVariable,
@@ -123,6 +124,10 @@ def __init__(
123124
self.nc_metadata_folder = (
124125
psup_io_handler.output_folder / f"{self.metadata_folder_prefix}nc"
125126
)
127+
self.thumbnail_folder = (
128+
psup_io_handler.output_folder / f"{self.metadata_folder_prefix}_thumbnail"
129+
)
130+
self.thumbnail_dims = (256, 256)
126131
self.log.debug(f".sav metadata folder: {self.sav_metadata_folder}")
127132
self.log.debug(f".nc metadata folder: {self.nc_metadata_folder}")
128133
if not self.sav_metadata_folder.exists():
@@ -454,6 +459,38 @@ def create_stac_item(self, orbit_cube_idx: str, **kwargs) -> pystac.Item:
454459
except OmegaCubeDataMissingError:
455460
self.log.warning(f"IDL.sav not found for {orbit_cube_idx}. Skipping.")
456461

462+
thumbnail_location = (
463+
self.thumbnail_folder
464+
/ f"{orbit_cube_idx}_{self.thumbnail_dims[0]}x{self.thumbnail_dims[1]}.png"
465+
)
466+
467+
# Normally the thumbnail should be generated
468+
# but if not, the file is open
469+
if not thumbnail_location.exists():
470+
nc_data = self.open_file(orbit_cube_idx, "nc")
471+
thumbnail_strategy = "mean"
472+
473+
# define thumbnail strategy
474+
# By default, takes the reflectance cube
475+
self.make_thumbnail(
476+
orbit_cube_idx=orbit_cube_idx,
477+
data=getattr(nc_data.Reflectance, thumbnail_strategy)(
478+
"wavelength"
479+
).values,
480+
dims=self.thumbnail_dims,
481+
)
482+
483+
nc_data.close()
484+
485+
# Thumbnail
486+
thumbn_asset = pystac.Asset(
487+
href=thumbnail_location.as_posix(),
488+
media_type=pystac.MediaType.PNG,
489+
roles=["thumbnail"],
490+
description="PNG thumbnail preview for visualizations",
491+
)
492+
pystac_item.add_asset("thumbnail", thumbn_asset)
493+
457494
# extensions
458495
pystac_item = cast(pystac.Item, apply_ssys(pystac_item))
459496

@@ -476,13 +513,17 @@ def create_stac_item(self, orbit_cube_idx: str, **kwargs) -> pystac.Item:
476513
return pystac_item
477514

478515
def find_cubedata_from_ncfile(
479-
self, orbit_cube_idx: str
516+
self, orbit_cube_idx: str, thumbnail_strategy: str = "mean"
480517
) -> dict[str, dict[str, Dimension | Variable]]:
481518
"""From the NetCDF file, extracts the cubedata information needed for the
482-
generated STAC item
519+
generated STAC item.
483520
484521
Args:
485-
orbit_cube_idx (str): _description_
522+
orbit_cube_idx (str): the ID of the orbit cube
523+
524+
Returns:
525+
dict[str, dict[str, Dimension | Variable]]: A dict containing
526+
"dimensions", "variables" and "extras" as main keys
486527
"""
487528
dimensions = {}
488529
variables = {}
@@ -574,6 +615,16 @@ def find_cubedata_from_ncfile(
574615
# Add some extras if you want
575616
extras = self.find_extra_nc_data(nc_data)
576617

618+
# define thumbnail strategy
619+
# By default, takes the reflectance cube
620+
self.make_thumbnail(
621+
orbit_cube_idx=orbit_cube_idx,
622+
data=getattr(nc_data.Reflectance, thumbnail_strategy)(
623+
"wavelength"
624+
).values,
625+
dims=self.thumbnail_dims,
626+
)
627+
577628
nc_data.close()
578629
except OSError as ose:
579630
self.log.error(f"[{ose.__class__.__name__}] {ose}")
@@ -620,3 +671,29 @@ def retrieve_nc_info_from_saved_state(self, orbit_cube_idx: str) -> dict[str, An
620671
nc_info = {}
621672

622673
return nc_info
674+
675+
def make_thumbnail(
676+
self,
677+
orbit_cube_idx: str,
678+
data: np.ndarray,
679+
dims: tuple[int, int],
680+
mode: Literal["L", "RGB", "RGBA"] = "RGB",
681+
cmap: str = "viridis",
682+
fmt: str = "png",
683+
):
684+
"""Creates a thumbnail for a datacube out of one of its 2D data arrays.
685+
686+
Args:
687+
orbit_cube_idx (str): _description_
688+
data (np.ndarray): _description_
689+
dims (tuple[int, int]): _description_
690+
mode (str, optional): _description_. Defaults to "RGB".
691+
cmap (str, optional): _description_. Defaults to "viridis".
692+
fmt (str, optional): _description_. Defaults to "png".
693+
"""
694+
thumbnail = convert_arr_to_thumbnail(
695+
data=data, resize_dims=dims, mode=mode, cmap=cmap
696+
)
697+
thumbnail.save(
698+
self.thumbnail_folder / f"{orbit_cube_idx}_{dims[0]}x{dims[1]}.{fmt}"
699+
)

src/psup_stac_converter/omega/mineral_maps.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class OmegaMineralMapDesc(BaseModel):
3434
raster_lng_description="""This data product is a global NIR 1-micrometer albedo map of Mars based on reflectance data acquired by the Mars Express OMEGA hyperspectral camera from January 2004 to August 2010""",
3535
raster_keywords=["albedo", "global"],
3636
thumbnail=HttpUrl(
37-
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/albedo_r1080_equ_map_reduce.png/sitools/upload/download-thumb.png"
37+
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/albedo_r1080_equ_map_reduce.png"
3838
),
3939
publication=Publication(
4040
citation="""
@@ -50,7 +50,7 @@ class OmegaMineralMapDesc(BaseModel):
5050
raster_lng_description="""This data product is a global ferric oxide spectral parameter map of Mars based on reflectance data acquired by the Mars Express OMEGA hyperspectral camera from January 2004 to August 2010. This ferric oxide spectral parameter (DB530) is based on the strength of the 0.53 micrometer ferric absorption edge.""",
5151
raster_keywords=["ferric oxides", "global"],
5252
thumbnail=HttpUrl(
53-
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/ferric_bd530_equ_map_reduce.png/sitools/upload/download-thumb.png"
53+
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/ferric_bd530_equ_map_reduce.png"
5454
),
5555
publication=Publication(
5656
citation="""
@@ -66,7 +66,7 @@ class OmegaMineralMapDesc(BaseModel):
6666
raster_lng_description="""This data product is a global nanophase ferric oxide (dust) spectral parameter map of Mars based on reflectance data acquired by the Mars Express OMEGA hyperspectral camera from January 2004 to August 2010. This nanophase ferric oxide spectral parameter (NNPHS) is based on the absorption feature centered at 0.86 micrometer.""",
6767
raster_keywords=["nanophase ferric oxides", "global"],
6868
thumbnail=HttpUrl(
69-
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/ferric_nnphs_equ_map_reduce.png/sitools/upload/download-thumb.png"
69+
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/ferric_nnphs_equ_map_reduce.png"
7070
),
7171
publication=Publication(
7272
citation="""
@@ -82,7 +82,7 @@ class OmegaMineralMapDesc(BaseModel):
8282
raster_lng_description="""This data product is a global olivine spectral parameter map of Mars based on reflectance data acquired by the Mars Express OMEGA hyperspectral camera from January 2004 to August 2010. This olivine spectral parameter (OSP1) detects Mg-rich and/or small grain size and/or low abundance olivine.""",
8383
raster_keywords=["olivine", "global"],
8484
thumbnail=HttpUrl(
85-
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/olivine_osp1_equ_map_reduce.png/sitools/upload/download-thumb.png"
85+
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/olivine_osp1_equ_map_reduce.png"
8686
),
8787
publication=Publication(
8888
citation="""
@@ -98,7 +98,7 @@ class OmegaMineralMapDesc(BaseModel):
9898
raster_lng_description="""This data product is a global olivine spectral parameter map of Mars based on reflectance data acquired by the Mars Express OMEGA hyperspectral camera from January 2004 to August 2010. This olivine spectral parameter (OSP2) is sensitive to olivine with high iron content and/or large grain size and/or high abundance.""",
9999
raster_keywords=["olivine", "global"],
100100
thumbnail=HttpUrl(
101-
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/olivine_osp2_equ_map_reduce.png/sitools/upload/download-thumb.png"
101+
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/olivine_osp2_equ_map_reduce.png"
102102
),
103103
publication=Publication(
104104
citation="""
@@ -114,7 +114,7 @@ class OmegaMineralMapDesc(BaseModel):
114114
raster_lng_description="""This data product is a global olivine spectral parameter map of Mars based on reflectance data acquired by the Mars Express OMEGA hyperspectral camera from January 2004 to August 2010. This olivine spectral parameter (OSP3) determines the full band depth at 1.36 micrometer relative to a continuum. It preferentially detects olivine with a large Fe content and/or with large grain size and/or with high abundance.""",
115115
raster_keywords=["olivine", "global"],
116116
thumbnail=HttpUrl(
117-
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/olivine_osp3_equ_map_reduce.png/sitools/upload/download-thumb.png"
117+
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/olivine_osp3_equ_map_reduce.png"
118118
),
119119
publication=Publication(
120120
citation="""
@@ -130,7 +130,7 @@ class OmegaMineralMapDesc(BaseModel):
130130
raster_lng_description="""This data product is a global pyroxene spectral parameter map of Mars based on reflectance data acquired by the Mars Express OMEGA hyperspectral camera from January 2004 to August 2010. This pyroxene spectral parameter (BD2000) is based on its 2 micrometer absorption band due to both high-calcium and low-calcium pyroxene.""",
131131
raster_keywords=["pyroxene", "global"],
132132
thumbnail=HttpUrl(
133-
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/pyroxene_bd2000_equ_map_reduce.png/sitools/upload/download-thumb.png"
133+
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/pyroxene_bd2000_equ_map_reduce.png"
134134
),
135135
publication=Publication(
136136
citation="""
@@ -146,7 +146,7 @@ class OmegaMineralMapDesc(BaseModel):
146146
raster_lng_description="""60 ppd global map of Solar Albedo from OMEGA data fileld with TES 20 ppd solar albedo global maps (Putzig and Mellon, 2007b) (21600x10800 pixels). This map is 100% filled. Variable name is "albedo". "latitude" and "longitude" indicate the coordinates of the centers of the pixels. Reference : Vincendon et al., Icarus, 2015""",
147147
raster_keywords=["albedo", "filled", "global"],
148148
thumbnail=HttpUrl(
149-
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/albedo_filled_reduce.png/sitools/upload/download-thumb.png"
149+
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/albedo_filled_reduce.png"
150150
),
151151
publication=Publication(
152152
citation="""
@@ -162,7 +162,7 @@ class OmegaMineralMapDesc(BaseModel):
162162
raster_lng_description="""60 ppd global map of Solar Albedo from OMEGA data only (21600 x 10800 pixels). This map is filled at 94.79% (rest are NaN). Variable name is "albedo". "latitude" and "longitude" indicate the coordinates of the centers of the pixels. Reference : Vincendon et al., Icarus, 2015.""",
163163
raster_keywords=["albedo", "unfilled", "global"],
164164
thumbnail=HttpUrl(
165-
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/albedo_unfilled_reduce.png/sitools/upload/download-thumb.png"
165+
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/albedo_unfilled_reduce.png"
166166
),
167167
publication=Publication(
168168
citation="""
@@ -178,7 +178,7 @@ class OmegaMineralMapDesc(BaseModel):
178178
raster_lng_description="""40 ppd global maps of surface emissivity at 5.03 mic. Holes are set to NaN.""",
179179
raster_keywords=["emissivity", "5mic", "global"],
180180
thumbnail=HttpUrl(
181-
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/emissivite_5.03mic_OMEGA0_reduce.png/sitools/upload/download-thumb.png"
181+
"http://psup.ias.u-psud.fr/sitools/datastorage/user/storage/marsdata/omega/png/emissivite_5.03mic_OMEGA0_reduce.png"
182182
),
183183
publication=Publication(
184184
citation="""

src/psup_stac_converter/utils/file_utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
from pathlib import Path
33
from typing import Literal
44

5+
import matplotlib.pyplot as plt
6+
import numpy as np
57
import rasterio
68
from astropy.io import fits
79
from attr import asdict
10+
from PIL import Image
811
from rasterio.transform import from_gcps
912
from rich.console import Console
1013

@@ -118,3 +121,34 @@ def fits_header_to_dict(
118121
fits_obj[header_key] = header_val
119122

120123
return fits_obj
124+
125+
126+
def convert_arr_to_thumbnail(
127+
data: np.ndarray,
128+
resize_dims: tuple[int, int],
129+
mode: Literal["L", "RGB", "RGBA"] = "L",
130+
cmap: str | None = None,
131+
) -> Image.Image:
132+
"""
133+
Converts a 2D or 3D NumPy array into a resized PNG-style image.
134+
Applies a matplotlib colormap if provided.
135+
"""
136+
data = np.asarray(data, dtype=float)
137+
138+
data = (data - np.nanmin(data)) / (np.nanmax(data) - np.nanmin(data) + 1e-8)
139+
140+
if cmap is not None:
141+
cm = plt.get_cmap(cmap)
142+
result = cm(data)[..., :4] # includes alpha
143+
result = (result * 255).astype(np.uint8)
144+
if mode == "RGB":
145+
result = result[..., :3]
146+
else:
147+
result = (data * 255).astype(np.uint8)
148+
if mode in ["RGB", "RGBA"]:
149+
result = np.stack([result] * (3 if mode == "RGB" else 4), axis=-1)
150+
151+
img = Image.fromarray(result, mode=mode)
152+
img = img.resize(resize_dims, Image.Resampling.LANCZOS)
153+
154+
return img

0 commit comments

Comments
 (0)