import numpy as np
import matplotlib.pyplot as plt
import cv2 as cv
import scipy as sp
import imageio.v2 as iio
import os
import copy
from io import BytesIO
from typing import Union
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import pyidi
[docs]
def mode_shape_magnification(displacements: np.ndarray,
magnification_factor: Union[int, float],
video: Union["pyidi.pyIDI", None] = None,
image: Union[np.ndarray, np.memmap, None] = None,
points: Union[np.ndarray, None] = None,
background_brightness: float = 0.3,
show_undeformed: bool = False
) -> np.ndarray:
"""
Create an image of a magnified mode-shape of a structure. If a 'pyidi.pyIDI'
class instance is input as argument 'video', the argument 'image' is set to
'video.mraw[0]' and the argument 'points' is set to 'video.points'. These
values can be overwritten by specifying the 'image' and 'points' arguments
explicitly.
:param displacements: displacement (mode-shape) vector
:type displacements: numpy.ndarray
:param magnification_factor: magnification factor
:type magnification_factor: int or float
:param video: pyIDI class instance,
defaults to None
:type video: pyidi.pyIDI or None, optional
:param image: the reference image, on which mode-shape magnification is
performed, defaults to None
:type image: numpy.ndarray, numpy.memmap or None, optional
:param points: image coordinates, where displacements 'displacements' are
defined, defaults to None
:type points: numpy.ndarray or None, optional
:param background_brightness: brightness of the background, expected values
in range [0, 1], defaults to 0.3
:type background_brighness: float, optional
:param show_undeformed: Show the reference image (argument 'image')
underneath the magnified mode-shape, defaults to False
:type show_undeformed: bool, optional
:return: image of a magnified mode-shape of the structure
:rtype: numpy.ndarray
"""
if hasattr(displacements, 'shape') and len(displacements.shape) == 2:
pass
else:
raise TypeError("The expected data type for argument 'displacements' is"\
"a 2D array of image coordinates of points of interest.")
if isinstance(magnification_factor, (int, float)):
pass
else:
raise TypeError("Expected data type for argument 'magnification_factor'"\
" is int or float.")
if (isinstance(background_brightness, (int, float)) and
0 <= background_brightness <= 1):
pass
else:
raise TypeError("Expected data type for argument 'background_brightness'"\
" is float in range [0, 1].")
if isinstance(show_undeformed, bool):
pass
else:
raise TypeError("Expected data type for argument 'show_undeformed' is"\
" boolean.")
if video is not None:
if image is not None:
if isinstance(image, (np.ndarray, np.memmap)):
img_in = image
else:
raise TypeError("Expected object types for argument 'image' are"\
" 'numpy.ndarray' and 'numpy.memmap'.")
else:
img_in = video.mraw[0]
if points is not None:
if isinstance(points, np.ndarray):
points = points
else:
raise TypeError("Expected object type for argument 'points' is "\
"'numpy.ndarray'.")
else:
points = video.points
elif image is not None and points is not None:
if isinstance(image, (np.ndarray, np.memmap)):
img_in = image
else:
raise TypeError("Expected object types for argument 'image' are "\
"'numpy.ndarray' and 'numpy.memmap'.")
if isinstance(points, np.ndarray):
points = points
else:
raise TypeError("Expected object type for argument 'points' is "\
"'numpy.ndarray'.")
else:
raise TypeError("Both the input image and the points of interest need "\
"to be input, either via 'video' attributes 'mraw' and"\
" 'points' or as seperate arguments 'image' and 'points'.")
mesh, mesh_def = create_mesh(points = points,
disp = displacements,
mag_fact = magnification_factor)
img_out, a, b = init_output_image(input_image = img_in,
mesh = mesh.points,
mesh_def = mesh_def.points,
bb = background_brightness,
bu = show_undeformed)
res = warp_image_elements(img_in = img_in,
img_out = img_out,
mesh = mesh,
mesh_def = mesh_def,
a = a,
b = b)
return res
[docs]
def animate(displacements: np.ndarray,
magnification_factor: Union[int, float],
video: Union["pyidi.pyIDI", None] = None,
image: Union[np.ndarray, np.memmap, None] = None,
points: Union[np.ndarray, None] = None,
fps: int = 30,
n_periods: int = 3,
filename: str = 'mode_shape_mag_video',
output_format: str = 'gif',
background_brightness: float = 0.3,
show_undeformed: bool = False
)-> None:
"""
Create a video of a magnified mode-shape of a structure. If a 'pyidi.pyIDI'
class instance is input as argument 'video', the argument 'image' is set to
'video.mraw[0]' and the argument 'points' is set to 'video.points'. These
values can be overwritten by specifying the 'image' and 'points' arguments
explicitly.
:param displacements: displacement vector
:type displacements: numpy.ndarray
:param magnification_factor: magnification factor
:type magnification_factor: int or float
:param video: pyIDI class instance,
defaults to None
:type video: pyidi.pyIDI or None, optional
:param image: the reference image, on which mode-shape magnification is
performed, defaults to None
:type image: numpy.ndarray, numpy.memmap or None, optional
:param points: image coordinates, where displacements 'displacements' are
defined, defaults to None
:type points: numpy.ndarray or None, optional
:param fps: framerate of the created video,
defaults to 30
:type fps: int, optional
:param n_periods: number of periods of oscilation to be animated,
defaults to 3
:type n_periods: int, optional
:param filename: the name of the output video file
defaults to 'mode_shape_mag_video'
:type filename: str
:param output_format: output format of the video, selected from 'gif', 'mp4',
'avi', 'mov', defaults to 'gif'
:type output_format: str, optional
:param background_brightness: brightness of the background, expected values
in range [0, 1], defaults to 0.3
:type background_brighness: float, optional
:param show_undeformed: Show the reference image (argument 'image')
underneath the magnified mode-shape, defaults to True
:type show_undeformed: bool, optional
"""
if hasattr(displacements, 'shape') and len(displacements.shape) == 2:
pass
else:
raise TypeError("The expected data type for argument 'displacements' is"\
" a 2D array of image coordinates of points of interest.")
if isinstance(magnification_factor, (int, float)):
pass
else:
raise TypeError("Expected data type for argument 'magnification_factor'"\
" is int or float.")
if isinstance(fps, int):
pass
else:
raise TypeError("Expected data type for argument 'fps' is int.")
if isinstance(n_periods, int):
pass
else:
raise TypeError("Expected data type for argument 'n_periods' is int.")
if isinstance(filename, str):
pass
else:
raise TypeError("Expected data type for argument 'filename' is str.")
if isinstance(output_format, str):
if output_format in ['gif', 'mp4', 'avi', 'mov']:
pass
else:
raise ValueError("Expected value for argument 'output_format' is one"\
" of 'gif', 'mp4', 'avi', 'mov'.")
else:
raise TypeError("Expected data type for argument 'output_format' is str.")
if (isinstance(background_brightness, float) and
0 <= background_brightness <= 1):
pass
else:
raise TypeError("Expected data type for argument 'background_brightness'"\
" is float in range [0, 1].")
if video is not None:
if image is not None:
if isinstance(image, (np.ndarray, np.memmap)):
img_in = image
else:
raise TypeError("Expected object types for argument 'image' are"\
" 'numpy.ndarray' and 'numpy.memmap'.")
else:
img_in = video.mraw[0]
if points is not None:
if isinstance(points, np.ndarray):
points = points
else:
raise TypeError("Expected object type for argument 'points' is "\
"'numpy.ndarray'.")
else:
points = video.points
elif image is not None and points is not None:
if isinstance(image, (np.ndarray, np.memmap)):
img_in = image
else:
raise TypeError("Expected object types for argument 'image' are "\
"'numpy.ndarray' and 'numpy.memmap'.")
if isinstance(points, np.ndarray):
points = points
else:
raise TypeError("Expected object type for argument 'points' is "\
"'numpy.ndarray'.")
else:
raise TypeError("Both the input image and the points of interest need "\
"to be input, either via 'video' attributes 'mraw' and"\
" 'points' or as seperate arguments 'image' and 'points'.")
# Create subfolder defined in 'filename' argument (if needed)
folder, name = os.path.split(filename)
if folder and not os.path.exists(folder):
os.makedirs(folder)
mesh, mesh_def = create_mesh(points = points,
disp = displacements,
mag_fact = magnification_factor)
mesh_def_negative = create_mesh(points = points,
disp = displacements,
mag_fact = -magnification_factor)[1]
# All frames of the output video are the same size, defined by the maximum
# deflections
img_out, a, b = init_output_image(input_image = img_in,
mesh = mesh.points,
mesh_def = np.concatenate((
mesh_def.points,
mesh_def_negative.points
)),
bb = background_brightness,
bu = show_undeformed)
# Harmonic oscilation
frames = np.linspace(0, 2 * np.pi * n_periods, fps * n_periods)
amp = np.sin(frames) * magnification_factor
if output_format == 'gif':
temp_writer = iio.get_writer(uri = f'{filename}.{output_format}',
mode = 'I',
duration = 1)
else:
temp_writer = iio.get_writer(uri = f'{filename}.{output_format}',
fps = fps)
with temp_writer as writer:
for i, el in enumerate(amp):
try:
img_out_i = copy.deepcopy(img_out)
# Create the deformed mesh for a given frame
mesh_def = create_mesh(points = points,
disp = displacements,
mag_fact = el)[1]
res = warp_image_elements(img_in = img_in,
img_out = img_out_i,
mesh = mesh,
mesh_def = mesh_def,
a = a,
b = b)
fig, ax = plt.subplots()
ax.imshow(res, 'gray')
ax.axis('off')
buffer = BytesIO()
fig.savefig(buffer,
format = 'png',
bbox_inches = 'tight',
transparent = True,
pad_inches = 0
)
buffer.seek(0)
figure = iio.imread(buffer)
writer.append_data(figure)
plt.close()
except ValueError:
writer.close()
print("Failed to generate entire video, try a smaller magnification"\
" factor.")
break
buffer.close()
writer.close()
print(f'Video saved in file: {filename}.{output_format}')
[docs]
def create_mesh(points, disp, mag_fact):
"""
Generates a planar mesh of triangles based on the input set of points. Then
generates the deformed planar mesh of triangles based on the displacement
vectors 'disp', scaled by the magnification factor 'mag_fact'.
"""
# Create undeformed mesh
mesh = sp.spatial.Delaunay(points)
# Create deformed mesh
# The coordinates of the original mesh are over-written with their counter-
# parts in the warped mesh, while the triangle connectivity of the original
# mesh is retained.
mesh_def = copy.deepcopy(mesh)
mesh_def.points[:, 0] -= disp[:, 0] * mag_fact
mesh_def.points[:, 1] += disp[:, 1] * mag_fact
return mesh, mesh_def
[docs]
def init_output_image(input_image, mesh, mesh_def, bb, bu):
"""
Initialze the output image. The output image needs to be large enough to
prevent clipping of the motion magnified shape.
"""
d = np.array([
np.min(mesh[:, 1]) - np.min(mesh_def[:, 1]),
- np.max(mesh[:, 1]) + np.max(mesh_def[:, 1]),
np.min(mesh[:, 0]) - np.min(mesh_def[:, 0]),
- np.max(mesh[:, 0]) + np.max(mesh_def[:, 0])
])
d = np.round(d).astype('int')
a = np.max(np.abs([d[2], d[3]]))
b = np.max(np.abs([d[0], d[1]]))
val = np.average(input_image) * bb
if bu:
out = cv.copyMakeBorder(input_image * bb,
top = a,
bottom = a,
left = b,
right = b,
borderType = cv.BORDER_CONSTANT,
value = val)
else:
out = np.ones((input_image.shape[0] + 2 * a,
input_image.shape[1] + 2 * b)) * val
return out, a, b
[docs]
def warp_image_elements(img_in, img_out, mesh, mesh_def, a, b):
"""
Warp image elements based on mesh and deformed mesh nodes.
"""
for i in range(len(mesh.simplices)):
el_0 = np.float32(mesh.points[mesh.simplices[i]])
el_1 = np.float32(mesh_def.points[mesh.simplices[i]])
# Define axis-aligned bounding rectangle for given triangle element in
# its original and deformed state
rect_0 = cv.boundingRect(el_0)
rect_1 = cv.boundingRect(el_1)
reg_0 = [((el_0[j, 1] - rect_0[1]),
(el_0[j, 0] - rect_0[0]))
for j in range(3)]
reg_1 = [((el_1[j, 1] - rect_1[1]),
(el_1[j, 0] - rect_1[0]))
for j in range(3)]
crop_0 = img_in[rect_0[0] : rect_0[0] + rect_0[2],
rect_0[1] : rect_0[1] + rect_0[3]]
# Definition of the affine transformation matrix for the given triangle
# element
aff_mat = cv.getAffineTransform(
src = np.float32(reg_0),
dst = np.float32(reg_1)
)
# Execution of the affine transformation
crop_1 = cv.warpAffine(
src = crop_0,
M = aff_mat,
dsize = (rect_1[3], rect_1[2]),
dst = None,
flags = cv.INTER_LINEAR,
borderMode = cv.BORDER_REFLECT_101,
)
mask = np.zeros((rect_1[2], rect_1[3]), dtype=np.float32)
mask = cv.fillConvexPoly(
img = mask,
points = np.int32(reg_1),
color = 1,
lineType = cv.LINE_AA,
shift=0
)
# Assembly of the transformed element into the output image
img_out[
rect_1[0] + a : rect_1[0] + rect_1[2] + a,
rect_1[1] + b : rect_1[1] + rect_1[3] + b
] = img_out[
rect_1[0] + a : rect_1[0] + rect_1[2] + a,
rect_1[1] + b : rect_1[1] + rect_1[3] + b
] * (1.0 - mask) + crop_1 * mask
return img_out