flowgraph 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowgraph/__init__.py +9 -0
- flowgraph/__main__.py +6 -0
- flowgraph/adapters/__init__.py +141 -0
- flowgraph/adapters/controls.py +96 -0
- flowgraph/adapters/cosapp.py +195 -0
- flowgraph/adapters/data_types.py +59 -0
- flowgraph/adapters/documentation.py +45 -0
- flowgraph/adapters/field_ops.py +121 -0
- flowgraph/adapters/file_conversion.py +393 -0
- flowgraph/adapters/files.py +191 -0
- flowgraph/adapters/filters.py +173 -0
- flowgraph/adapters/image_tools.py +394 -0
- flowgraph/adapters/mesh_creation_tools.py +431 -0
- flowgraph/adapters/mesh_generations.py +58 -0
- flowgraph/adapters/mesh_ops.py +362 -0
- flowgraph/adapters/paraview.py +39 -0
- flowgraph/adapters/plaid_bridge.py +205 -0
- flowgraph/adapters/readers.py +215 -0
- flowgraph/adapters/remote_files.py +118 -0
- flowgraph/adapters/simple_sources.py +199 -0
- flowgraph/adapters/sinks.py +80 -0
- flowgraph/adapters/user_code.py +196 -0
- flowgraph/adapters/workflow.py +176 -0
- flowgraph/adapters/workflow_interfaces.py +78 -0
- flowgraph/adapters/writers.py +159 -0
- flowgraph/application/__init__.py +1 -0
- flowgraph/application/node_registry.py +14 -0
- flowgraph/application/port_conversions.py +63 -0
- flowgraph/application/wasm_execution.py +132 -0
- flowgraph/application/workflow_cli.py +417 -0
- flowgraph/application/workflow_core.py +1193 -0
- flowgraph/application/workflow_history.py +93 -0
- flowgraph/application/workflow_io.py +393 -0
- flowgraph/application/workflow_layout.py +170 -0
- flowgraph/application/workflow_web_catalog.py +143 -0
- flowgraph/application/workflows.py +7 -0
- flowgraph/core_cli.py +81 -0
- flowgraph/domain/__init__.py +5 -0
- flowgraph/domain/mesh_document.py +91 -0
- flowgraph/examples/AllFilters.py +19 -0
- flowgraph/examples/convert_format.py +21 -0
- flowgraph/resources.py +29 -0
- flowgraph/testdata/AllFilters.json +390 -0
- flowgraph/testdata/FlowGraph.h5 +0 -0
- flowgraph/testdata/FlowGraph.stl +0 -0
- flowgraph/testdata/FlowGraph.xdmf +22 -0
- flowgraph/testdata/SimpleReadMesh.json +70 -0
- flowgraph/testdata/SpherePoints.csv +83 -0
- flowgraph/testdata/muscat-muscat-mesh-conversion.json +101 -0
- flowgraph/version.py +3 -0
- flowgraph-0.1.2.dist-info/METADATA +150 -0
- flowgraph-0.1.2.dist-info/RECORD +56 -0
- flowgraph-0.1.2.dist-info/WHEEL +5 -0
- flowgraph-0.1.2.dist-info/entry_points.txt +2 -0
- flowgraph-0.1.2.dist-info/licenses/LICENSE +29 -0
- flowgraph-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Workflow nodes that transform canonical meshes with Muscat operations."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import Muscat.MeshContainers.ElementsDescription as ED
|
|
7
|
+
from Muscat.MeshContainers.Filters.FilterObjects import ElementFilter
|
|
8
|
+
from Muscat.MeshContainers.Filters.FilterOperators import FilterOperatorBase
|
|
9
|
+
from Muscat.MeshTools.MeshInspectionTools import ExtractElementsByElementFilter
|
|
10
|
+
|
|
11
|
+
from flowgraph.adapters.readers import MESH_DOCUMENT
|
|
12
|
+
from flowgraph.application.workflow_core import (
|
|
13
|
+
NodeDefinition,
|
|
14
|
+
ParameterDefinition,
|
|
15
|
+
ParameterOption,
|
|
16
|
+
PortDefinition,
|
|
17
|
+
PortDirection,
|
|
18
|
+
)
|
|
19
|
+
from flowgraph.domain.mesh_document import MeshDocument
|
|
20
|
+
|
|
21
|
+
from .data_types import MUSCAT_ELEMENT_FILTER, ParameterKind
|
|
22
|
+
|
|
23
|
+
ELEMENT_DIMENSION_OPTIONS = (0, 1, 2, 3)
|
|
24
|
+
ELEMENT_TYPE_OPTIONS = tuple(
|
|
25
|
+
element_type.name
|
|
26
|
+
for element_type in ED.ElementType
|
|
27
|
+
if element_type is not ED.ElementType.Element_NA
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parameter_list(parameters: Mapping[str, Any], name: str) -> Sequence[Any]:
|
|
32
|
+
"""Return one list-like node parameter with a clear validation error."""
|
|
33
|
+
values = parameters.get(name, ())
|
|
34
|
+
if isinstance(values, (str, bytes)) or not isinstance(values, Sequence):
|
|
35
|
+
raise TypeError(f"Element filter parameter {name!r} must be a list")
|
|
36
|
+
return values
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _tag_list(parameters: Mapping[str, Any], name: str) -> list[str]:
|
|
40
|
+
"""Return a validated list of tag names for a Muscat element filter."""
|
|
41
|
+
values = _parameter_list(parameters, name)
|
|
42
|
+
tags: list[str] = []
|
|
43
|
+
for value in values:
|
|
44
|
+
if not isinstance(value, str):
|
|
45
|
+
raise TypeError(f"Element filter parameter {name!r} must contain strings")
|
|
46
|
+
tags.append(value)
|
|
47
|
+
return tags
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _create_element_filter(
|
|
51
|
+
_inputs: Mapping[str, Any], parameters: Mapping[str, Any]
|
|
52
|
+
) -> Mapping[str, Any]:
|
|
53
|
+
"""Create a Muscat element filter from JSON-serializable selections."""
|
|
54
|
+
raw_dimensions = _parameter_list(parameters, "dimensionality")
|
|
55
|
+
dimensionality: list[int] = []
|
|
56
|
+
for value in raw_dimensions:
|
|
57
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
58
|
+
raise TypeError("Element filter dimensions must be integers")
|
|
59
|
+
if value not in ELEMENT_DIMENSION_OPTIONS:
|
|
60
|
+
raise ValueError(f"Unsupported element dimension: {value}")
|
|
61
|
+
if value not in dimensionality:
|
|
62
|
+
dimensionality.append(value)
|
|
63
|
+
|
|
64
|
+
raw_element_types = _parameter_list(parameters, "element_types")
|
|
65
|
+
element_types: list[ED.ElementType] = []
|
|
66
|
+
for name in raw_element_types:
|
|
67
|
+
if not isinstance(name, str):
|
|
68
|
+
raise TypeError("Element filter element types must be names")
|
|
69
|
+
if name not in ELEMENT_TYPE_OPTIONS:
|
|
70
|
+
raise ValueError(f"Unknown Muscat element type: {name!r}")
|
|
71
|
+
element_type = ED.ElementType[name]
|
|
72
|
+
if element_type not in element_types:
|
|
73
|
+
element_types.append(element_type)
|
|
74
|
+
|
|
75
|
+
node_tags = _tag_list(parameters, "ntag")
|
|
76
|
+
element_tags = _tag_list(parameters, "etag")
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"filter": ElementFilter(
|
|
80
|
+
dimensionality=dimensionality or None,
|
|
81
|
+
elementType=element_types or None,
|
|
82
|
+
nTag=node_tags or None,
|
|
83
|
+
eTag=element_tags or None,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
CREATE_MUSCAT_ELEMENT_FILTER = NodeDefinition(
|
|
89
|
+
id="create-muscat-element-filter",
|
|
90
|
+
icon="mdi-filter-cog-outline",
|
|
91
|
+
label="Element Selector",
|
|
92
|
+
description="Creates an element filter by dimensionality, element type, and tags.",
|
|
93
|
+
ports=(
|
|
94
|
+
PortDefinition(
|
|
95
|
+
"filter",
|
|
96
|
+
PortDirection.OUTPUT,
|
|
97
|
+
MUSCAT_ELEMENT_FILTER,
|
|
98
|
+
"Element filter",
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
executor=_create_element_filter,
|
|
102
|
+
parameters=(
|
|
103
|
+
ParameterDefinition(
|
|
104
|
+
"dimensionality",
|
|
105
|
+
ParameterKind.MULTI_SELECT,
|
|
106
|
+
"Dimensionality",
|
|
107
|
+
[],
|
|
108
|
+
options=tuple(
|
|
109
|
+
ParameterOption(value, str(value)) for value in ELEMENT_DIMENSION_OPTIONS
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
ParameterDefinition(
|
|
113
|
+
"element_types",
|
|
114
|
+
ParameterKind.MULTI_SELECT,
|
|
115
|
+
"Element types",
|
|
116
|
+
[],
|
|
117
|
+
options=tuple(
|
|
118
|
+
ParameterOption(value, value.replace("_", " ")) for value in ELEMENT_TYPE_OPTIONS
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
ParameterDefinition(
|
|
122
|
+
"ntag",
|
|
123
|
+
ParameterKind.LIST_STR,
|
|
124
|
+
"Node tags",
|
|
125
|
+
[],
|
|
126
|
+
placeholder="Enter node tags",
|
|
127
|
+
),
|
|
128
|
+
ParameterDefinition(
|
|
129
|
+
"etag",
|
|
130
|
+
ParameterKind.LIST_STR,
|
|
131
|
+
"Element tags",
|
|
132
|
+
[],
|
|
133
|
+
placeholder="Enter element tags",
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _filter_mesh(inputs: Mapping[str, Any], _parameters: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
140
|
+
"""Extract the elements selected by a Muscat filter into a new document."""
|
|
141
|
+
document: MeshDocument = inputs["mesh"]
|
|
142
|
+
element_filter: ElementFilter | FilterOperatorBase = inputs["filter"]
|
|
143
|
+
|
|
144
|
+
filtered_mesh = ExtractElementsByElementFilter(
|
|
145
|
+
document.mesh,
|
|
146
|
+
element_filter,
|
|
147
|
+
copy=False,
|
|
148
|
+
)
|
|
149
|
+
filtered_mesh.nodeFields = document.mesh.nodeFields
|
|
150
|
+
|
|
151
|
+
filtered_document = MeshDocument(
|
|
152
|
+
mesh=filtered_mesh,
|
|
153
|
+
)
|
|
154
|
+
return {"mesh": filtered_document}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
FILTER_MESH_WITH_MUSCAT = NodeDefinition(
|
|
158
|
+
id="filter-mesh-muscat",
|
|
159
|
+
icon="mdi-filter-outline",
|
|
160
|
+
label="Filter Mesh",
|
|
161
|
+
description="Extracts mesh elements selected by a Muscat element filter.",
|
|
162
|
+
ports=(
|
|
163
|
+
PortDefinition("mesh", PortDirection.INPUT, MESH_DOCUMENT, "Mesh"),
|
|
164
|
+
PortDefinition(
|
|
165
|
+
"filter",
|
|
166
|
+
PortDirection.INPUT,
|
|
167
|
+
MUSCAT_ELEMENT_FILTER,
|
|
168
|
+
"Element filter",
|
|
169
|
+
),
|
|
170
|
+
PortDefinition("mesh", PortDirection.OUTPUT, MESH_DOCUMENT, "Filtered mesh"),
|
|
171
|
+
),
|
|
172
|
+
executor=_filter_mesh,
|
|
173
|
+
)
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Workflow nodes that read, transform, write, and convert Pillow images."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from Muscat.MeshTools.ConstantRectilinearMeshTools import CreateConstantRectilinearMesh
|
|
10
|
+
from PIL import Image as PillowImage
|
|
11
|
+
from PIL import UnidentifiedImageError
|
|
12
|
+
from PIL.Image import Image
|
|
13
|
+
|
|
14
|
+
from flowgraph.application.workflow_core import (
|
|
15
|
+
NodeDefinition,
|
|
16
|
+
ParameterDefinition,
|
|
17
|
+
ParameterOption,
|
|
18
|
+
PortDefinition,
|
|
19
|
+
PortDirection,
|
|
20
|
+
)
|
|
21
|
+
from flowgraph.domain.mesh_document import MeshDocument
|
|
22
|
+
|
|
23
|
+
from .data_types import IMAGE, MESH_DOCUMENT, ParameterKind
|
|
24
|
+
|
|
25
|
+
LOGGER = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ImageLoadError(RuntimeError):
|
|
29
|
+
"""Raised when a selected image file cannot be loaded by Pillow."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ImageWriteError(RuntimeError):
|
|
33
|
+
"""Raised when a Pillow image cannot be written to a destination."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ImageMeshError(RuntimeError):
|
|
37
|
+
"""Raised when a Pillow image cannot be converted into a mesh document."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ImageOperationError(RuntimeError):
|
|
41
|
+
"""Raised when a Pillow image transformation cannot be completed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _require_image(inputs: Mapping[str, Any]) -> Image:
|
|
45
|
+
"""Return the required Pillow image input or raise an actionable error."""
|
|
46
|
+
image = inputs["image"]
|
|
47
|
+
if not isinstance(image, Image):
|
|
48
|
+
raise ImageOperationError("Image input must be a PIL.Image.Image instance")
|
|
49
|
+
return image
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _positive_integer(parameters: Mapping[str, Any], name: str) -> int:
|
|
53
|
+
"""Return a positive integer parameter or raise an actionable operation error."""
|
|
54
|
+
value = parameters[name]
|
|
55
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
56
|
+
raise ImageOperationError(
|
|
57
|
+
f"{name.replace('_', ' ').capitalize()} must be a positive integer"
|
|
58
|
+
)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _nonnegative_integer(parameters: Mapping[str, Any], name: str) -> int:
|
|
63
|
+
"""Return a non-negative integer parameter or raise an actionable operation error."""
|
|
64
|
+
value = parameters[name]
|
|
65
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
66
|
+
raise ImageOperationError(
|
|
67
|
+
f"{name.replace('_', ' ').capitalize()} must be a non-negative integer"
|
|
68
|
+
)
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_source(parameters: Mapping[str, Any]) -> Path:
|
|
73
|
+
"""Return an existing image source path or raise an actionable load error."""
|
|
74
|
+
source = Path(parameters["path"]).expanduser()
|
|
75
|
+
if not source.is_file():
|
|
76
|
+
raise ImageLoadError(f"Image file does not exist or is not a file: {source}")
|
|
77
|
+
return source
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate_destination(parameters: Mapping[str, Any]) -> Path:
|
|
81
|
+
"""Return a writable image destination path or raise an actionable write error."""
|
|
82
|
+
destination = Path(parameters["filename"]).expanduser()
|
|
83
|
+
if not destination.name:
|
|
84
|
+
raise ImageWriteError("Image filename must not be empty")
|
|
85
|
+
if not destination.suffix:
|
|
86
|
+
raise ImageWriteError(
|
|
87
|
+
f"Image filename has no extension; Pillow cannot select an output format: {destination}"
|
|
88
|
+
)
|
|
89
|
+
if not destination.parent.is_dir():
|
|
90
|
+
raise ImageWriteError(
|
|
91
|
+
f"Image output directory does not exist or is not a directory: {destination.parent}"
|
|
92
|
+
)
|
|
93
|
+
return destination
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_image(_inputs: Mapping[str, Any], parameters: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
97
|
+
"""Load an image with Pillow and detach its pixel data from the source file."""
|
|
98
|
+
source = _validate_source(parameters)
|
|
99
|
+
try:
|
|
100
|
+
with PillowImage.open(source) as image:
|
|
101
|
+
result = image.copy()
|
|
102
|
+
except (OSError, UnidentifiedImageError) as error:
|
|
103
|
+
LOGGER.exception("Pillow failed while loading %s", source)
|
|
104
|
+
raise ImageLoadError(f"Pillow could not load '{source}': {error}") from error
|
|
105
|
+
|
|
106
|
+
LOGGER.info("Pillow loaded image %s (%s, %s)", source, result.mode, result.size)
|
|
107
|
+
return {"image": result}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _write_image(inputs: Mapping[str, Any], parameters: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
111
|
+
"""Write a Pillow image using the format inferred from its destination extension."""
|
|
112
|
+
destination = _validate_destination(parameters)
|
|
113
|
+
image = inputs["image"]
|
|
114
|
+
if not isinstance(image, Image):
|
|
115
|
+
raise ImageWriteError("Image input must be a PIL.Image.Image instance")
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
image.save(destination)
|
|
119
|
+
except (OSError, ValueError) as error:
|
|
120
|
+
LOGGER.exception("Pillow failed while writing %s", destination)
|
|
121
|
+
raise ImageWriteError(f"Pillow could not write '{destination}': {error}") from error
|
|
122
|
+
|
|
123
|
+
LOGGER.info("Pillow wrote image to %s", destination.resolve())
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resize_image(inputs: Mapping[str, Any], parameters: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
128
|
+
"""Resize an image to a requested width and height using Lanczos resampling."""
|
|
129
|
+
image = _require_image(inputs)
|
|
130
|
+
width = _positive_integer(parameters, "width")
|
|
131
|
+
height = _positive_integer(parameters, "height")
|
|
132
|
+
result = image.resize((width, height), PillowImage.Resampling.LANCZOS)
|
|
133
|
+
LOGGER.info("Resized Pillow image from %s to %s", image.size, result.size)
|
|
134
|
+
return {"image": result}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _crop_image(inputs: Mapping[str, Any], parameters: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
138
|
+
"""Crop an image to an in-bounds box described by left, top, right, and bottom."""
|
|
139
|
+
image = _require_image(inputs)
|
|
140
|
+
left = _nonnegative_integer(parameters, "left")
|
|
141
|
+
top = _nonnegative_integer(parameters, "top")
|
|
142
|
+
right = _positive_integer(parameters, "right")
|
|
143
|
+
bottom = _positive_integer(parameters, "bottom")
|
|
144
|
+
width, height = image.size
|
|
145
|
+
if left >= right or top >= bottom:
|
|
146
|
+
raise ImageOperationError(
|
|
147
|
+
"Crop right and bottom coordinates must exceed left and top coordinates"
|
|
148
|
+
)
|
|
149
|
+
if right > width or bottom > height:
|
|
150
|
+
raise ImageOperationError(
|
|
151
|
+
f"Crop box ({left}, {top}, {right}, {bottom}) exceeds image bounds {image.size}"
|
|
152
|
+
)
|
|
153
|
+
result = image.crop((left, top, right, bottom))
|
|
154
|
+
LOGGER.info(
|
|
155
|
+
"Cropped Pillow image %s to box (%s, %s, %s, %s)", image.size, left, top, right, bottom
|
|
156
|
+
)
|
|
157
|
+
return {"image": result}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _rotate_image(inputs: Mapping[str, Any], parameters: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
161
|
+
"""Rotate an image counter-clockwise by the requested number of degrees."""
|
|
162
|
+
image = _require_image(inputs)
|
|
163
|
+
angle = parameters["angle"]
|
|
164
|
+
if isinstance(angle, bool) or not isinstance(angle, (int, float)):
|
|
165
|
+
raise ImageOperationError("Angle must be a number of degrees")
|
|
166
|
+
expand = parameters["expand"]
|
|
167
|
+
if not isinstance(expand, bool):
|
|
168
|
+
raise ImageOperationError("Expand output must be a Boolean value")
|
|
169
|
+
result = image.rotate(angle, resample=PillowImage.Resampling.BICUBIC, expand=expand)
|
|
170
|
+
LOGGER.info("Rotated Pillow image %s by %s degrees (expand=%s)", image.size, angle, expand)
|
|
171
|
+
return {"image": result}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _convert_image_mode(
|
|
175
|
+
inputs: Mapping[str, Any], parameters: Mapping[str, Any]
|
|
176
|
+
) -> Mapping[str, Any]:
|
|
177
|
+
"""Convert an image to one of the supported common Pillow color modes."""
|
|
178
|
+
image = _require_image(inputs)
|
|
179
|
+
mode = parameters["mode"]
|
|
180
|
+
if mode not in _COLOR_MODE_OPTIONS:
|
|
181
|
+
raise ImageOperationError(f"Unsupported target color mode: {mode!r}")
|
|
182
|
+
result = image.convert(mode)
|
|
183
|
+
LOGGER.info("Converted Pillow image from %s to %s", image.mode, result.mode)
|
|
184
|
+
return {"image": result}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _node_colors(image: Image) -> np.ndarray:
|
|
188
|
+
"""Return image colors in the node order used by Muscat's rectilinear mesh.
|
|
189
|
+
|
|
190
|
+
The output has one row per image pixel and one to four columns according to
|
|
191
|
+
the L, LA, RGB, or RGBA channel convention.
|
|
192
|
+
"""
|
|
193
|
+
normalized = image if image.mode in {"L", "LA", "RGB", "RGBA"} else image.convert("RGBA")
|
|
194
|
+
pixels = np.asarray(normalized)
|
|
195
|
+
if pixels.ndim == 2:
|
|
196
|
+
pixels = pixels[..., np.newaxis]
|
|
197
|
+
|
|
198
|
+
return np.ascontiguousarray(pixels.transpose(1, 0, 2).reshape(-1, pixels.shape[-1]))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _image_to_mesh(inputs: Mapping[str, Any], parameters: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
202
|
+
"""Convert a Pillow image into a structured Muscat mesh with a ``Colors`` field."""
|
|
203
|
+
image = inputs["image"]
|
|
204
|
+
if not isinstance(image, Image):
|
|
205
|
+
raise ImageMeshError("Image input must be a PIL.Image.Image instance")
|
|
206
|
+
|
|
207
|
+
width, height = image.size
|
|
208
|
+
if width < 1 or height < 1:
|
|
209
|
+
raise ImageMeshError("Image dimensions must both be at least one pixel")
|
|
210
|
+
|
|
211
|
+
storeOnNodeFields = parameters.get("storeOnNodeFields", True)
|
|
212
|
+
if not storeOnNodeFields:
|
|
213
|
+
width += 1
|
|
214
|
+
height += 1
|
|
215
|
+
|
|
216
|
+
mesh = CreateConstantRectilinearMesh(
|
|
217
|
+
dimensions=[width, height], origin=[0.0, 0.0], spacing=[1.0, 1.0]
|
|
218
|
+
)
|
|
219
|
+
colors = _node_colors(image)
|
|
220
|
+
|
|
221
|
+
if parameters.get("storeOnNodeFields", True):
|
|
222
|
+
mesh.nodeFields["Colors"] = colors
|
|
223
|
+
else:
|
|
224
|
+
mesh.elemFields["Colors"] = colors
|
|
225
|
+
|
|
226
|
+
LOGGER.info(
|
|
227
|
+
"Converted Pillow image (%s, %s) to a mesh with Colors on %s fields",
|
|
228
|
+
image.mode,
|
|
229
|
+
image.size,
|
|
230
|
+
"node" if parameters.get("storeOnNodeFields", True) else "element",
|
|
231
|
+
)
|
|
232
|
+
return {"mesh": MeshDocument(mesh=mesh)}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
_COLOR_MODE_OPTIONS = {
|
|
236
|
+
"1": "1-bit pixels",
|
|
237
|
+
"L": "Grayscale",
|
|
238
|
+
"LA": "Grayscale with alpha",
|
|
239
|
+
"RGB": "Red, green, blue",
|
|
240
|
+
"RGBA": "Red, green, blue, alpha",
|
|
241
|
+
"CMYK": "Cyan, magenta, yellow, black",
|
|
242
|
+
"HSV": "Hue, saturation, value",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
READ_IMAGE = NodeDefinition(
|
|
247
|
+
id="read-image",
|
|
248
|
+
icon="mdi-image-arrow-down-outline",
|
|
249
|
+
label="Read Image (Pillow)",
|
|
250
|
+
description="Loads an image file into a Pillow image.",
|
|
251
|
+
ports=(PortDefinition("image", PortDirection.OUTPUT, IMAGE, "Image"),),
|
|
252
|
+
executor=_read_image,
|
|
253
|
+
parameters=(
|
|
254
|
+
ParameterDefinition(
|
|
255
|
+
"path",
|
|
256
|
+
ParameterKind.FILE,
|
|
257
|
+
"File path",
|
|
258
|
+
"",
|
|
259
|
+
"/path/to/image",
|
|
260
|
+
file_patterns=("*.bmp", "*.gif", "*.jpeg", "*.jpg", "*.png", "*.tiff", "*.webp"),
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
WRITE_IMAGE = NodeDefinition(
|
|
267
|
+
id="write-image",
|
|
268
|
+
icon="mdi-image-arrow-up-outline",
|
|
269
|
+
label="Write Image (Pillow)",
|
|
270
|
+
description="Writes a Pillow image using the output filename extension.",
|
|
271
|
+
ports=(PortDefinition("image", PortDirection.INPUT, IMAGE, "Image"),),
|
|
272
|
+
executor=_write_image,
|
|
273
|
+
parameters=(
|
|
274
|
+
ParameterDefinition(
|
|
275
|
+
"filename",
|
|
276
|
+
ParameterKind.FILE,
|
|
277
|
+
"Filename",
|
|
278
|
+
"",
|
|
279
|
+
"/path/to/image.png",
|
|
280
|
+
file_patterns=("*.bmp", "*.gif", "*.jpeg", "*.jpg", "*.png", "*.tiff", "*.webp"),
|
|
281
|
+
),
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
RESIZE_IMAGE = NodeDefinition(
|
|
287
|
+
id="resize-image",
|
|
288
|
+
icon="mdi-arrow-expand-all",
|
|
289
|
+
label="Resize Image",
|
|
290
|
+
description="Resizes a Pillow image to a target width and height.",
|
|
291
|
+
ports=(
|
|
292
|
+
PortDefinition("image", PortDirection.INPUT, IMAGE, "Image"),
|
|
293
|
+
PortDefinition("image", PortDirection.OUTPUT, IMAGE, "Image"),
|
|
294
|
+
),
|
|
295
|
+
executor=_resize_image,
|
|
296
|
+
parameters=(
|
|
297
|
+
ParameterDefinition("width", ParameterKind.INTEGER, "Width", 1),
|
|
298
|
+
ParameterDefinition("height", ParameterKind.INTEGER, "Height", 1),
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
CROP_IMAGE = NodeDefinition(
|
|
304
|
+
id="crop-image",
|
|
305
|
+
icon="mdi-crop",
|
|
306
|
+
label="Crop Image",
|
|
307
|
+
description="Crops a Pillow image to an in-bounds rectangular region.",
|
|
308
|
+
ports=(
|
|
309
|
+
PortDefinition("image", PortDirection.INPUT, IMAGE, "Image"),
|
|
310
|
+
PortDefinition("image", PortDirection.OUTPUT, IMAGE, "Image"),
|
|
311
|
+
),
|
|
312
|
+
executor=_crop_image,
|
|
313
|
+
parameters=(
|
|
314
|
+
ParameterDefinition("left", ParameterKind.INTEGER, "Left", 0),
|
|
315
|
+
ParameterDefinition("top", ParameterKind.INTEGER, "Top", 0),
|
|
316
|
+
ParameterDefinition("right", ParameterKind.INTEGER, "Right", 1),
|
|
317
|
+
ParameterDefinition("bottom", ParameterKind.INTEGER, "Bottom", 1),
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
ROTATE_IMAGE = NodeDefinition(
|
|
323
|
+
id="rotate-image",
|
|
324
|
+
icon="mdi-rotate-right",
|
|
325
|
+
label="Rotate Image",
|
|
326
|
+
description="Rotates a Pillow image counter-clockwise by an angle in degrees.",
|
|
327
|
+
ports=(
|
|
328
|
+
PortDefinition("image", PortDirection.INPUT, IMAGE, "Image"),
|
|
329
|
+
PortDefinition("image", PortDirection.OUTPUT, IMAGE, "Image"),
|
|
330
|
+
),
|
|
331
|
+
executor=_rotate_image,
|
|
332
|
+
parameters=(
|
|
333
|
+
ParameterDefinition("angle", ParameterKind.FLOAT, "Angle (degrees)", 0.0),
|
|
334
|
+
ParameterDefinition("expand", ParameterKind.BOOLEAN, "Expand output", True, port=False),
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
CONVERT_IMAGE_MODE = NodeDefinition(
|
|
340
|
+
id="convert-image-mode",
|
|
341
|
+
icon="mdi-palette-outline",
|
|
342
|
+
label="Convert Image Mode",
|
|
343
|
+
description="Converts a Pillow image to a selected color mode.",
|
|
344
|
+
ports=(
|
|
345
|
+
PortDefinition("image", PortDirection.INPUT, IMAGE, "Image"),
|
|
346
|
+
PortDefinition("image", PortDirection.OUTPUT, IMAGE, "Image"),
|
|
347
|
+
),
|
|
348
|
+
executor=_convert_image_mode,
|
|
349
|
+
parameters=(
|
|
350
|
+
ParameterDefinition(
|
|
351
|
+
"mode",
|
|
352
|
+
ParameterKind.STR_SELECT,
|
|
353
|
+
"Color mode",
|
|
354
|
+
"RGB",
|
|
355
|
+
options=tuple(
|
|
356
|
+
ParameterOption(value, label) for value, label in _COLOR_MODE_OPTIONS.items()
|
|
357
|
+
),
|
|
358
|
+
port=False,
|
|
359
|
+
),
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
IMAGE_TO_MESH = NodeDefinition(
|
|
365
|
+
id="image-to-mesh",
|
|
366
|
+
icon="mdi-image-filter-center-focus-weak",
|
|
367
|
+
label="Image To Mesh",
|
|
368
|
+
description="Converts a Pillow image to a structured mesh with a Colors field.",
|
|
369
|
+
ports=(
|
|
370
|
+
PortDefinition("image", PortDirection.INPUT, IMAGE, "Image"),
|
|
371
|
+
PortDefinition("mesh", PortDirection.OUTPUT, MESH_DOCUMENT, "Mesh"),
|
|
372
|
+
),
|
|
373
|
+
executor=_image_to_mesh,
|
|
374
|
+
parameters=(
|
|
375
|
+
ParameterDefinition(
|
|
376
|
+
"storeOnNodeFields",
|
|
377
|
+
ParameterKind.BOOLEAN,
|
|
378
|
+
"Store colors on node fields",
|
|
379
|
+
True,
|
|
380
|
+
port=False,
|
|
381
|
+
),
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
AVAILABLE_NODES = (
|
|
387
|
+
READ_IMAGE,
|
|
388
|
+
WRITE_IMAGE,
|
|
389
|
+
RESIZE_IMAGE,
|
|
390
|
+
CROP_IMAGE,
|
|
391
|
+
ROTATE_IMAGE,
|
|
392
|
+
CONVERT_IMAGE_MODE,
|
|
393
|
+
IMAGE_TO_MESH,
|
|
394
|
+
)
|