Skip to content

Commit

Permalink
Use new simplified hillshading for surface plugins (#541)
Browse files Browse the repository at this point in the history
  • Loading branch information
Hans Kallekleiv authored Jan 26, 2021
1 parent 651e89a commit a205358
Show file tree
Hide file tree
Showing 14 changed files with 181 additions and 446 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [UNRELEASED] - YYYY-MM-DD
### Changed
- [#544](https://github.com/equinor/webviz-subsurface/pull/544) - All plugins now use new special `webviz_settings` argument to plugin's `__init__` method for common settings in favor of piggybacking dictionary onto the to the Dash applicaton object.
- [#541](https://github.com/equinor/webviz-subsurface/pull/541) - Implemented new onepass shader for all surface plugins.

### Fixed
- [#536](https://github.com/equinor/webviz-subsurface/pull/536) - Fixed issue and bumped dependencies related to Pandas version 1.2.0. Bumped dependency to webviz-config to support mypy typechecks.
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"scipy>=1.2",
"statsmodels>=0.12.1", # indirect dependency through https://plotly.com/python/linear-fits/
"webviz-config>=0.2.7",
"webviz-subsurface-components>=0.2.0",
"webviz-subsurface-components>=0.3.0",
"xtgeo>=2.8",
],
tests_require=TESTS_REQUIRE,
Expand Down
37 changes: 36 additions & 1 deletion webviz_subsurface/_datainput/image_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ def array_to_png(tensor: np.ndarray, shift: bool = True, colormap: bool = False)
"Can not shift a colormap which is not utilizing alpha channel"
)
tensor[0][0][3] = 0.0 # Make first color channel transparent

if tensor.ndim == 2:
image = Image.fromarray(np.uint8(tensor), "L")
elif tensor.ndim == 3:
Expand All @@ -74,3 +73,39 @@ def array_to_png(tensor: np.ndarray, shift: bool = True, colormap: bool = False)
base64_data = base64.b64encode(byte_io.read()).decode("ascii")

return f"data:image/png;base64,{base64_data}"


def array2d_to_png(tensor: np.ndarray) -> str:
"""The leaflet map dash component takes in pictures as base64 data
(or as a link to an existing hosted image). I.e. for containers wanting
to create pictures on-the-fly from numpy arrays, they have to be converted
to base64. This is an example function of how that can be done.
This function encodes the numpy array to a RGBA png.
The array is encoded as a heightmap, in Mapbox Terrain RGB format
(https://docs.mapbox.com/help/troubleshooting/access-elevation-data/).
The undefined values are set as having alpha = 0. The height values are
shifted to start from 0.
"""

shape = tensor.shape
tensor = np.repeat(tensor, 4) # This will flatten the array

tensor[0::4][np.isnan(tensor[0::4])] = 0 # Red
tensor[1::4][np.isnan(tensor[1::4])] = 0 # Green
tensor[2::4][np.isnan(tensor[2::4])] = 0 # Blue

tensor[0::4] = np.floor((tensor[0::4] / (256 * 256)) % 256) # Red
tensor[1::4] = np.floor((tensor[1::4] / 256) % 256) # Green
tensor[2::4] = np.floor(tensor[2::4] % 256) # Blue
tensor[3::4] = np.where(np.isnan(tensor[3::4]), 0, 255) # Alpha

# Back to 2d shape + 1 dimension for the rgba values.
tensor = tensor.reshape((shape[0], shape[1], 4))
image = Image.fromarray(np.uint8(tensor), "RGBA")

byte_io = io.BytesIO()
image.save(byte_io, format="png")
byte_io.seek(0)
base64_data = base64.b64encode(byte_io.read()).decode("ascii")
return f"data:image/png;base64,{base64_data}"
168 changes: 0 additions & 168 deletions webviz_subsurface/_datainput/surface.py
Original file line number Diff line number Diff line change
@@ -1,181 +1,13 @@
from typing import Optional, Union, List, Dict, Any
import io
import base64

import numpy as np
from xtgeo import RegularSurface
from webviz_config.common_cache import CACHE
from PIL import Image
from .image_processing import array_to_png


@CACHE.memoize(timeout=CACHE.TIMEOUT)
def load_surface(surface_path: str) -> RegularSurface:
return RegularSurface(surface_path)


def get_surface_arr(
surface: RegularSurface,
unrotate: bool = True,
flip: bool = True,
clip_min: Union[float, np.ndarray, None] = None,
clip_max: Union[float, np.ndarray, None] = None,
) -> List[np.ndarray]:
if clip_min or clip_max:
np.ma.clip(surface.values, clip_min, clip_max, out=surface.values)
if unrotate:
surface.unrotate()

x, y, z = surface.get_xyz_values()
if flip:
x = np.flip(x.transpose(), axis=0)
y = np.flip(y.transpose(), axis=0)
z = np.flip(z.transpose(), axis=0)
z.filled(np.nan)
return [x, y, z]


@CACHE.memoize(timeout=CACHE.TIMEOUT)
def get_surface_fence(fence: np.ndarray, surface: RegularSurface) -> np.ndarray:
return surface.get_fence(fence)


# pylint: disable=too-many-arguments
def make_surface_layer(
surface: RegularSurface,
name: str = "surface",
updatemode: str = "update",
min_val: Optional[float] = None,
max_val: Optional[float] = None,
color: Optional[List[str]] = None,
shader_type: Optional[str] = "soft-hillshading",
shadows: bool = False,
unit: str = " ",
) -> Dict[str, Any]:
"""Make NewLayeredMap surface image base layer
Args:
surface: an xtgeo surface object
name: name of the surface object
min_val: minimum value to be plotted in map
max_val: maximum value to be plotted in map
color: an array with colors as strings
shader_type: determines shader in map
unit: determines unit on the map axes
Returns:
A surface layer that can be plotted in NewLayeredMap
"""

zvalues = get_surface_arr(surface, clip_min=min_val, clip_max=max_val)[2]
min_val = min_val if min_val is not None else np.nanmin(zvalues)
max_val = max_val if max_val is not None else np.nanmax(zvalues)
if shader_type == "hillshading_shadows":
shader_type = "hillshading"
shadows = True
img = Image.open(io.BytesIO(base64.b64decode(array_to_png(zvalues.copy())[22:])))
width, height = img.size
if width * height >= 300 * 300:
scale = 1.0
else:
ratio = (1000 ** 2) / (width * height)
scale = np.sqrt(ratio).round(2)
color = (
[
"#fde725",
"#b5de2b",
"#6ece58",
"#35b779",
"#1f9e89",
"#26828e",
"#31688e",
"#3e4989",
"#482878",
"#440154",
]
if color is None
else color
)
return {
"name": name,
"checked": True,
"id": name,
"action": updatemode,
"baseLayer": True,
"data": [
{
"type": "image",
"url": array_to_png(zvalues.copy()),
"colorScale": {
"colors": color,
"prefixZeroAlpha": False,
"scaleType": "linear",
},
"shader": {
"type": shader_type,
"shadows": shadows,
"shadowIterations": 128,
"elevationScale": -1.0,
"pixelScale": 11000,
"setBlackToAlpha": True,
},
"bounds": [[surface.xmin, surface.ymin], [surface.xmax, surface.ymax]],
"minvalue": round(np.nanmin(zvalues), 4),
"maxvalue": round(np.nanmax(zvalues), 4),
"unit": str(unit),
"imageScale": scale,
}
],
}


def get_surface_layers(
switch: Dict[str, bool],
surface_name: str,
surfaces: List[RegularSurface],
min_val: Optional[float] = None,
max_val: Optional[float] = None,
) -> List[Dict[str, Any]]:
"""Creates layers in map view from all surfaces
Args:
switch: Toggle hillshading on/off
surface_name: Name of surface
surfaces: List containing a single surface with corresponding depth error, depth trend etc.
min_val: Minimum z-value of surface
max_val: Maximum z-value of surface
Returns:
layers: List of all surface layers
"""
shader_type = "hillshading" if switch["value"] is True else None
depth_list = [
"Depth",
"Depth uncertainty",
"Depth residual",
"Depth residual uncertainty",
"Depth trend",
"Depth trend uncertainty",
]
layers = []
for i, surface in enumerate(surfaces):
if surface is not None:
s_layer = make_surface_layer(
surface,
name=depth_list[i],
min_val=min_val,
max_val=max_val,
color=[
"#440154",
"#482878",
"#3e4989",
"#31688e",
"#26828e",
"#1f9e89",
"#35b779",
"#6ece58",
"#b5de2b",
"#fde725",
],
shader_type=shader_type,
)
s_layer["id"] = surface_name + " " + depth_list[i] + "-id"
s_layer["action"] = "add"
layers.append(s_layer)
return layers
1 change: 1 addition & 0 deletions webviz_subsurface/_models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .ensemble_model import EnsembleModel
from .ensemble_set_model import EnsembleSetModel
from .surface_leaflet_model import SurfaceLeafletModel
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import numpy as np
import xtgeo

from webviz_subsurface._datainput.image_processing import array_to_png
from webviz_subsurface._datainput.image_processing import array2d_to_png


class SurfaceLeafletModel:
Expand All @@ -20,47 +20,51 @@ def __init__(
clip_min: Optional[float] = None,
clip_max: Optional[float] = None,
unit: str = " ",
shader_type: str = "soft-hillshading",
shadows: bool = False,
apply_shading: bool = False,
colors: Optional[list] = None,
updatemode: str = "update",
):
self.name = name if name is not None else surface.name
self.surface = surface
self.shadows = True if shader_type == "hillshading_shadows" else shadows
self.shader_type = (
"hillshading" if shader_type == "hillshading_shadows" else shader_type
)
self.apply_shading = apply_shading
self.updatemode = updatemode
self.bounds = [[surface.xmin, surface.ymin], [surface.xmax, surface.ymax]]
self.zvalues = self.filled_z(clip_min=clip_min, clip_max=clip_max)
self.zvalues = self.get_zvalues(clip_min=clip_min, clip_max=clip_max)
self.unit = unit
self.colors = self.set_colors(colors)

@property
def img_url(self) -> str:
return array_to_png(self.zvalues.copy())
return array2d_to_png(self.scaled_zvalues.copy())

@property
def min_val(self) -> float:
return np.nanmin(self.zvalues)

@property
def max_val(self) -> float:
return np.nanmax(self.zvalues)

@property
def scale_factor(self) -> float:
if self.min_val == 0.0 and self.max_val == 0.0:
return 1.0
return (256 * 256 * 256 - 1) / (self.max_val - self.min_val)

@property
def img_scale(self) -> float:
def map_scale(self) -> float:
img = Image.open(
io.BytesIO(base64.b64decode(array_to_png(self.zvalues.copy())[22:]))
io.BytesIO(
base64.b64decode(array2d_to_png(self.scaled_zvalues.copy())[22:])
)
)
width, height = img.size
if width * height >= 300 * 300:
return 1.0
ratio = (1000 ** 2) / (width * height)
return np.sqrt(ratio).round(2)

@property
def min_val(self) -> float:
return round(np.nanmin(self.zvalues), 4)

@property
def max_val(self) -> float:
return round(np.nanmax(self.zvalues), 4)

def filled_z(
def get_zvalues(
self,
unrotate: bool = True,
flip: bool = True,
Expand All @@ -72,13 +76,15 @@ def filled_z(
np.ma.clip(surface.values, clip_min, clip_max, out=surface.values)
if unrotate:
surface.unrotate()

x, y, z = surface.get_xyz_values()
surface.fill(np.nan)
values = surface.values
if flip:
x = np.flip(x.transpose(), axis=0)
y = np.flip(y.transpose(), axis=0)
z = np.flip(z.transpose(), axis=0)
return z.filled(np.nan)
values = np.flip(values.transpose(), axis=0)
return values

@property
def scaled_zvalues(self) -> np.ndarray:
return (self.zvalues - self.min_val) * self.scale_factor

@staticmethod
def set_colors(colors: list = None) -> list:
Expand Down Expand Up @@ -113,22 +119,16 @@ def layer(self) -> dict:
"url": self.img_url,
"colorScale": {
"colors": self.colors,
"prefixZeroAlpha": False,
"scaleType": "linear",
},
"shader": {
"type": self.shader_type,
"shadows": self.shadows,
"shadowIterations": 128,
"elevationScale": -1.0,
"pixelScale": 11000,
"setBlackToAlpha": True,
"applyHillshading": self.apply_shading,
},
"bounds": self.bounds,
"minvalue": self.min_val,
"maxvalue": self.max_val,
"unit": self.unit,
"imageScale": self.img_scale,
"imageScale": self.map_scale,
}
],
}
Loading

0 comments on commit a205358

Please sign in to comment.