Skip to content

Commit df053f5

Browse files
authored
Convert milling GIF API endpoint into workflow instead (#829)
* Converted 'make_milling_gif' endpoint function into a workflow instead to avoid endpoint timing out * Removed intermediate NumPy array and convert frame-by-frame straight to PIL Image objects to reduce memory footprint * Updated test for 'make_gif' endpoint function * Added test module for 'make_milling_gif' workflow
1 parent 12ab53a commit df053f5

6 files changed

Lines changed: 246 additions & 142 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ TomographyMetadataContext = "murfey.client.contexts.tomo_metadata:TomographyMeta
116116
"data_collection" = "murfey.workflows.register_data_collection:run"
117117
"data_collection_group" = "murfey.workflows.register_data_collection_group:run"
118118
"experiment_type_update" = "murfey.workflows.register_experiment_type_update:run"
119+
"fib.make_milling_gif" = "murfey.workflows.fib.make_milling_gif:run"
119120
"fib.register_atlas" = "murfey.workflows.fib.register_atlas:run"
120121
"fib.register_milling_progress" = "murfey.workflows.fib.register_milling_progress:run"
121122
"pato" = "murfey.workflows.notifications:notification_setup"
Lines changed: 12 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
11
import logging
2-
import os
32
from pathlib import Path
43

5-
import numpy as np
6-
import PIL.Image
74
from fastapi import APIRouter, Depends
85
from pydantic import BaseModel
9-
from sqlmodel import select
106

11-
import murfey.util.db as MurfeyDB
127
from murfey.server import _transport_object
138
from murfey.server.api.auth import validate_instrument_token
14-
from murfey.server.murfey_db import murfey_db
15-
from murfey.util import sanitise_path
16-
from murfey.util.config import get_machine_config
17-
from murfey.util.models import LamellaSiteInfo
9+
from murfey.util.models import FIBGIFParameters, LamellaSiteInfo
1810

1911
logger = logging.getLogger("murfey.server.api.workflow_fib")
2012

@@ -65,76 +57,19 @@ def register_fib_milling_progress(
6557
)
6658

6759

68-
class FIBGIFParameters(BaseModel):
69-
lamella_number: int
70-
images: list[Path]
71-
output_file: Path
72-
73-
7460
@router.post("/sessions/{session_id}/make_gif")
7561
async def make_gif(
7662
session_id: int,
7763
gif_params: FIBGIFParameters,
78-
db=murfey_db,
7964
):
80-
# Load machine config and session info
81-
session_entry = db.exec(
82-
select(MurfeyDB.Session).where(MurfeyDB.Session.id == session_id)
83-
).one()
84-
instrument_name = session_entry.instrument_name
85-
visit_name = session_entry.visit
86-
machine_config = get_machine_config(instrument_name=instrument_name)[
87-
instrument_name
88-
]
89-
rsync_basepath = machine_config.rsync_basepath or Path(".").resolve()
90-
91-
# Sanitise and verify that the output directory is relative to rsync basepath
92-
output_file = sanitise_path(gif_params.output_file)
93-
if not output_file.is_relative_to(rsync_basepath):
94-
logger.error("Output file path is not permitted")
95-
raise ValueError
96-
97-
# Create folders in the visit directory and onwards and change permissions
98-
visit_index = output_file.parts.index(visit_name)
99-
for current_path in list(reversed(output_file.parents))[visit_index + 1 :]:
100-
if not current_path.exists():
101-
current_path.mkdir(parents=True)
102-
logger.debug(f"Created output directory {current_path}")
103-
try:
104-
os.chmod(current_path, mode=machine_config.mkdir_chmod)
105-
except PermissionError:
106-
logger.warning(
107-
f"Insufficient permissions to modify directory {current_path}"
108-
)
109-
continue
110-
111-
# Load the images as PIL Image objects
112-
arr: list[np.ndarray] = []
113-
for f in gif_params.images:
114-
with PIL.Image.open(f) as im:
115-
im.thumbnail((512, 512))
116-
frame = np.array(im, dtype=np.float32)
117-
vmin, vmax = np.percentile(frame, (0.5, 99.5))
118-
scale = 255 / ((vmax - vmin) or 1)
119-
np.clip(frame, a_min=vmin, a_max=vmax, out=frame)
120-
np.subtract(frame, vmin, out=frame)
121-
np.multiply(frame, scale, out=frame)
122-
arr.append(frame.astype(np.uint8))
123-
arr = np.array(arr).astype(np.uint8)
124-
125-
# Convert back to PIL.Image objects and save as GIF
126-
try:
127-
converted = [PIL.Image.fromarray(a, mode="L") for a in arr]
128-
converted[0].save(
129-
output_file,
130-
format="GIF",
131-
append_images=converted[1:],
132-
save_all=True,
133-
duration=30,
134-
loop=0,
135-
)
136-
logger.info(f"Created GIF file {output_file}")
137-
return {"output_gif": str(output_file)}
138-
finally:
139-
for im in converted:
140-
im.close()
65+
if _transport_object is None:
66+
logger.error("No TransportManager object was set up")
67+
return None
68+
_transport_object.send(
69+
_transport_object.feedback_queue,
70+
{
71+
"register": "fib.make_milling_gif",
72+
"session_id": session_id,
73+
"gif_params": gif_params.model_dump(mode="json"),
74+
},
75+
)

src/murfey/util/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,12 @@ class LamellaSiteInfo(BaseModel):
233233
steps: MillingSteps | None = None
234234

235235

236+
class FIBGIFParameters(BaseModel):
237+
lamella_number: int
238+
images: list[Path]
239+
output_file: Path
240+
241+
236242
"""
237243
=======================================================================================
238244
Single Particle Analysis
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import logging
2+
import os
3+
from pathlib import Path
4+
from typing import Any
5+
6+
import numpy as np
7+
import PIL.Image
8+
from sqlmodel import Session as SQLModelSession, select
9+
10+
import murfey.util.db as MurfeyDB
11+
from murfey.util import sanitise_path
12+
from murfey.util.config import get_machine_config
13+
from murfey.util.models import FIBGIFParameters
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
def run(message: dict[str, Any], murfey_db: SQLModelSession):
19+
# Outer try-finally block to close Murfey DB with
20+
try:
21+
try:
22+
# Parse and unpack incoming message
23+
session_id = int(message["session_id"])
24+
gif_params = FIBGIFParameters(**message["gif_params"])
25+
except Exception:
26+
logger.error("Error parsing contents of message", exc_info=True)
27+
return {"success": False, "requeue": False}
28+
29+
# Load machine config and session info
30+
session_entry = murfey_db.exec(
31+
select(MurfeyDB.Session).where(MurfeyDB.Session.id == session_id)
32+
).one()
33+
instrument_name = session_entry.instrument_name
34+
visit_name = session_entry.visit
35+
machine_config = get_machine_config(instrument_name=instrument_name)[
36+
instrument_name
37+
]
38+
rsync_basepath = machine_config.rsync_basepath or Path(".").resolve()
39+
40+
# Sanitise and verify that the output directory is relative to rsync basepath
41+
output_file = sanitise_path(gif_params.output_file)
42+
if not output_file.is_relative_to(rsync_basepath):
43+
raise ValueError("Output file path is not permitted")
44+
45+
# Create folders in the visit directory and onwards and change permissions
46+
visit_index = output_file.parts.index(visit_name)
47+
for current_path in list(reversed(output_file.parents))[visit_index + 1 :]:
48+
if not current_path.exists():
49+
current_path.mkdir(parents=True)
50+
logger.debug(f"Created output directory {current_path}")
51+
try:
52+
os.chmod(current_path, mode=machine_config.mkdir_chmod)
53+
except PermissionError:
54+
logger.warning(
55+
f"Insufficient permissions to modify directory {current_path}"
56+
)
57+
continue
58+
59+
# Load the images as PIL Image objects
60+
converted: list[PIL.Image.Image] = []
61+
for f in gif_params.images:
62+
with PIL.Image.open(f) as im:
63+
im.thumbnail((512, 512))
64+
frame = np.array(im, dtype=np.float32)
65+
# Normalise to 8-bit
66+
vmin, vmax = np.percentile(frame, (0.5, 99.5))
67+
scale = 255 / ((vmax - vmin) or 1)
68+
np.clip(frame, a_min=vmin, a_max=vmax, out=frame)
69+
np.subtract(frame, vmin, out=frame)
70+
np.multiply(frame, scale, out=frame)
71+
# Convert back to PIL Image
72+
converted.append(
73+
PIL.Image.fromarray(frame.astype(np.uint8), mode="L").copy()
74+
)
75+
del frame # Explicitly remove frame from memory
76+
# Save stack as a GIF
77+
if not converted:
78+
raise ValueError("No images were provided or loaded")
79+
converted[0].save(
80+
output_file,
81+
format="GIF",
82+
append_images=converted[1:],
83+
save_all=True,
84+
duration=30,
85+
loop=0,
86+
)
87+
logger.info(f"Created GIF file {output_file}")
88+
return {"success": True}
89+
except Exception:
90+
logger.error("Error creating FIB milling GIF", exc_info=True)
91+
return {"success": False, "requeue": False}
92+
finally:
93+
murfey_db.close()
Lines changed: 50 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
from pathlib import Path
22
from unittest.mock import MagicMock
33

4-
import numpy as np
5-
import PIL.Image
64
import pytest
75
from pytest_mock import MockerFixture
86

97
from murfey.server.api.workflow_fib import (
108
FIBAtlasFile,
11-
FIBGIFParameters,
129
make_gif,
1310
register_fib_atlas,
1411
)
12+
from murfey.util.models import FIBGIFParameters
1513

1614

1715
@pytest.mark.parametrize(
@@ -33,7 +31,7 @@ def test_register_fib_atlas(
3331
# Mock the logger
3432
mock_logger = mocker.patch("murfey.server.api.workflow_fib.logger")
3533

36-
# Mock the tranposrt object
34+
# Mock the transport object
3735
if has_transport_object:
3836
mock_transport_object = MagicMock()
3937
mock_transport_object.feedback_queue = "dummy"
@@ -67,74 +65,61 @@ def test_register_fib_atlas(
6765
mock_logger.error.assert_called_with("No TransportManager object was set up")
6866

6967

68+
@pytest.mark.parametrize(
69+
"has_transport_object",
70+
(
71+
True,
72+
False,
73+
),
74+
)
7075
@pytest.mark.asyncio
7176
async def test_make_gif(
7277
mocker: MockerFixture,
7378
tmp_path: Path,
79+
has_transport_object: bool,
7480
):
75-
# Set up test variables
76-
session_id = 10
77-
instrument_name = "test_instrument"
78-
rsync_basepath = tmp_path / "data"
79-
visit_name = "cm12345-6"
80-
year = 2020
81-
visit_dir = rsync_basepath / str(year) / visit_name
82-
lamella_num = 12
83-
lamella_folder = "Lamella"
84-
if lamella_num > 1:
85-
lamella_folder += f" ({lamella_num})"
86-
output_file = (
87-
visit_dir
88-
/ "processed"
89-
/ "project_name"
90-
/ "grid_1"
91-
/ "drift_correction"
92-
/ f"lamella_{lamella_num}.gif"
93-
)
94-
95-
# Create a list of test image file paths
96-
raw_images = [
97-
visit_dir
98-
/ "autotem"
99-
/ visit_name
100-
/ "Sites"
101-
/ lamella_folder
102-
/ "DCImages/DCM_asdfjkl/asdfjkl-Polishing-dc_rescan-image-.png"
103-
] * 5
104-
# Mock the output of PIL.Image.open to always return a NumPY array
105-
mocker.patch(
106-
"murfey.server.api.workflow_fib.PIL.Image.open",
107-
return_value=PIL.Image.fromarray(np.ones((512, 512), dtype=np.uint16)),
108-
)
109-
110-
# Create the Pydantic model
111-
params = FIBGIFParameters(
112-
lamella_number=lamella_num,
113-
images=[str(f) for f in raw_images],
114-
output_file=output_file,
115-
)
81+
# Set up the variables
82+
session_id = 1
83+
gif_params_dict = {
84+
"lamella_number": 1,
85+
"images": [
86+
str(tmp_path / "some_file.png"),
87+
],
88+
"output_file": str(tmp_path / "target_file.gif"),
89+
}
90+
gif_params = FIBGIFParameters(**gif_params_dict)
11691

117-
# Mock the database query
118-
mock_db = MagicMock()
119-
mock_db.exec.return_value.one.return_value.instrument_name = instrument_name
120-
mock_db.exec.return_value.one.return_value.visit = visit_name
92+
# Mock the logger
93+
mock_logger = mocker.patch("murfey.server.api.workflow_fib.logger")
12194

122-
# Mock the machine config and 'get_machine_config'
123-
mock_machine_config = MagicMock()
124-
mock_machine_config.mkdir_chmod = 0o2775
125-
mock_machine_config.rsync_basepath = rsync_basepath
126-
mocker.patch(
127-
"murfey.server.api.workflow_fib.get_machine_config",
128-
return_value={
129-
instrument_name: mock_machine_config,
130-
},
131-
)
95+
# Mock the transport object
96+
if has_transport_object:
97+
mock_transport_object = MagicMock()
98+
mock_transport_object.feedback_queue = "dummy"
99+
mocker.patch(
100+
"murfey.server.api.workflow_fib._transport_object",
101+
mock_transport_object,
102+
)
103+
else:
104+
mocker.patch(
105+
"murfey.server.api.workflow_fib._transport_object",
106+
None,
107+
)
132108

133-
# Run the function and check that the expected outputs are there
134-
result = await make_gif(
109+
# Run the function and check that the expected calls were made
110+
await make_gif(
135111
session_id=session_id,
136-
gif_params=params,
137-
db=mock_db,
112+
gif_params=gif_params,
138113
)
139-
assert output_file.exists()
140-
assert result.get("output_gif") == str(output_file)
114+
115+
if has_transport_object:
116+
mock_transport_object.send.assert_called_with(
117+
"dummy",
118+
{
119+
"register": "fib.make_milling_gif",
120+
"session_id": session_id,
121+
"gif_params": gif_params_dict,
122+
},
123+
)
124+
else:
125+
mock_logger.error.assert_called_with("No TransportManager object was set up")

0 commit comments

Comments
 (0)