mirror of
https://github.com/deepfakes/faceswap.git
synced 2026-10-17 05:21:10 +08:00
1403 lines
53 KiB
Python
1403 lines
53 KiB
Python
#!/usr/bin python3
|
|
""" Utilities for working with images """
|
|
from __future__ import annotations
|
|
import json
|
|
import logging
|
|
import os
|
|
import struct
|
|
import typing as T
|
|
|
|
from ast import literal_eval
|
|
from concurrent import futures
|
|
from queue import Empty as QueueEmpty, Full as QueueFull, Queue
|
|
from threading import current_thread, main_thread
|
|
from zlib import crc32
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from lib.align.objects import PNGHeader
|
|
from lib.logger import parse_class_init
|
|
from lib.multithreading import FSThread
|
|
from lib.utils import FaceswapError, get_image_paths, get_module_objects
|
|
from lib.video import check_for_video, VideoReader
|
|
|
|
|
|
if T.TYPE_CHECKING:
|
|
import numpy.typing as npt
|
|
from lib.multithreading import ErrorState
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Image I/O
|
|
@T.overload
|
|
def read_image(filename: str,
|
|
raise_error: T.Literal[False] = False,
|
|
with_metadata: T.Literal[False] = False) -> npt.NDArray[np.uint8] | None: ...
|
|
|
|
|
|
@T.overload
|
|
def read_image(filename: str,
|
|
raise_error: T.Literal[True],
|
|
with_metadata: T.Literal[False] = False) -> npt.NDArray[np.uint8]: ...
|
|
|
|
|
|
@T.overload
|
|
def read_image(filename: str,
|
|
raise_error: T.Literal[False] = False,
|
|
*,
|
|
with_metadata: T.Literal[True]) -> tuple[npt.NDArray[np.uint8], PNGHeader]: ...
|
|
|
|
|
|
@T.overload
|
|
def read_image(filename: str,
|
|
raise_error: T.Literal[True],
|
|
with_metadata: T.Literal[True]) -> npt.NDArray[np.uint8]: ...
|
|
|
|
|
|
def read_image(filename: str, # noqa[C901] # pylint:disable=too-many-statements,too-many-branches
|
|
raise_error: bool = False,
|
|
with_metadata: bool = False
|
|
) -> np.ndarray | None | tuple[npt.NDArray[np.uint8], PNGHeader]:
|
|
"""Read an image file from a file location.
|
|
|
|
Extends the functionality of :func:`cv2.imread()` by ensuring that an image was actually
|
|
loaded. Errors can be logged and ignored so that the process can continue on an image load
|
|
failure.
|
|
|
|
Parameters
|
|
----------
|
|
filename
|
|
Full path to the image to be loaded.
|
|
raise_error
|
|
If ``True`` then any failures (including the returned image being ``None``) will be
|
|
raised. If ``False`` then an error message will be logged, but the error will not be
|
|
raised. Default: ``False``
|
|
with_metadata
|
|
Only returns a value if the images loaded are extracted Faceswap faces. If ``True`` then
|
|
returns the Faceswap metadata stored with in a Face images .png EXIF header.
|
|
Default: ``False``
|
|
|
|
Returns
|
|
-------
|
|
image
|
|
The image in `BGR` channel order as UINT8 for the corresponding :attr:`filename`
|
|
metadata
|
|
The faceswap metadata corresponding to the image. Only returned if
|
|
`with_metadata` is ``True``
|
|
|
|
Example
|
|
-------
|
|
>>> image_file = "/path/to/image.png"
|
|
>>> try:
|
|
>>> image = read_image(image_file, raise_error=True, with_metadata=False)
|
|
>>> except:
|
|
>>> raise ValueError("There was an error")
|
|
"""
|
|
logger.trace("Requested image: '%s'", filename) # type:ignore[attr-defined]
|
|
success = True
|
|
image = None
|
|
retval: np.ndarray | tuple[np.ndarray, PNGHeader] | None = None
|
|
try:
|
|
with open(filename, "rb") as in_file:
|
|
raw_file = in_file.read()
|
|
image = cv2.imdecode(np.frombuffer(raw_file, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
|
|
if image is None:
|
|
raise ValueError("Image is None")
|
|
if image.ndim == 2: # Convert grayscale to BGR
|
|
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
|
elif image.ndim == 2 and image.shape[2] == 4: # Strip mask
|
|
image = image[:, :, :3]
|
|
|
|
if np.issubdtype(image.dtype, np.integer):
|
|
info = np.iinfo(T.cast(np.integer, image.dtype)) # Scale non UINT8 INT images to UINT8
|
|
if info.max != 255:
|
|
image = image.astype(np.float32) / info.max * 255.0
|
|
elif np.issubdtype(image.dtype, np.floating):
|
|
# Just naively clip floating images to 0-1 for now
|
|
image = (np.clip(image, 0.0, 1.0) * 255.).astype(np.float32)
|
|
|
|
if image.dtype != np.uint8:
|
|
image = np.clip(image, 0, 255).astype(np.uint8)
|
|
|
|
if with_metadata:
|
|
metadata = png_read_meta(raw_file)
|
|
assert isinstance(metadata, PNGHeader)
|
|
retval = (image, metadata)
|
|
else:
|
|
retval = image
|
|
except TypeError as err:
|
|
success = False
|
|
msg = f"Error while reading image (TypeError): '{filename}'"
|
|
msg += f". Original error message: {str(err)}"
|
|
logger.error(msg)
|
|
if raise_error:
|
|
raise TypeError(msg) from err
|
|
except ValueError as err:
|
|
success = False
|
|
msg = ("Error while reading image. This can be caused by special characters in the "
|
|
f"filename or a corrupt image file: '{filename}'")
|
|
msg += f". Original error message: {str(err)}"
|
|
logger.error(msg)
|
|
if raise_error:
|
|
raise ValueError(msg) from err
|
|
except Exception as err: # pylint:disable=broad-except
|
|
success = False
|
|
msg = f"Failed to load image '{filename}'. Original Error: {str(err)}"
|
|
logger.error(msg)
|
|
if raise_error:
|
|
raise Exception(msg) from err # pylint:disable=broad-exception-raised
|
|
logger.trace("Loaded image: '%s'. Success: %s", filename, success) # type:ignore[attr-defined]
|
|
return retval
|
|
|
|
|
|
@T.overload
|
|
def read_image_batch(filenames: list[str], with_metadata: T.Literal[False] = False
|
|
) -> np.ndarray: ...
|
|
|
|
|
|
@T.overload
|
|
def read_image_batch(filenames: list[str], with_metadata: T.Literal[True]
|
|
) -> tuple[np.ndarray, list[PNGHeader]]: ...
|
|
|
|
|
|
def read_image_batch(filenames: list[str], with_metadata: bool = False
|
|
) -> np.ndarray | tuple[np.ndarray, list[PNGHeader]]:
|
|
"""Load a batch of images from the given file locations.
|
|
|
|
Leverages multi-threading to load multiple images from disk at the same time leading to vastly
|
|
reduced image read times.
|
|
|
|
Parameters
|
|
----------
|
|
filenames
|
|
A of full paths to the images to be loaded.
|
|
with_metadata
|
|
Only returns a value if the images loaded are extracted Faceswap faces. If ``True`` then
|
|
returns the Faceswap metadata stored within each Face's .png exif header.
|
|
Default: ``False``
|
|
|
|
Returns
|
|
-------
|
|
batch
|
|
The batch of images in `BGR` channel order returned in the order of :attr:`filenames`
|
|
metadata
|
|
The faceswap metadata corresponding to each image in the batch. Only returned if
|
|
`with_metadata` is ``True``
|
|
|
|
Notes
|
|
-----
|
|
As the images are compiled into a batch, they should be all of the same dimensions, otherwise a
|
|
homogenous array will be returned
|
|
|
|
Example
|
|
-------
|
|
>>> image_filenames = ["/path/to/image_1.png", "/path/to/image_2.png", "/path/to/image_3.png"]
|
|
>>> images = read_image_batch(image_filenames)
|
|
>>> print(images.shape)
|
|
... (3, 64, 64, 3)
|
|
>>> images, metadata = read_image_batch(image_filenames, with_metadata=True)
|
|
>>> print(images.shape)
|
|
... (3, 64, 64, 3)
|
|
>>> print(len(metadata))
|
|
... 3
|
|
"""
|
|
logger.trace("Requested batch: '%s'", filenames) # type:ignore[attr-defined]
|
|
batch: list[np.ndarray | None] = [None for _ in range(len(filenames))]
|
|
meta: list[PNGHeader | None] = [None for _ in range(len(filenames))]
|
|
|
|
with futures.ThreadPoolExecutor() as executor:
|
|
images = {executor.submit( # NOTE submit strips positionals, breaking type-checking
|
|
read_image, # type:ignore[arg-type]
|
|
filename,
|
|
raise_error=True, # pyright:ignore[reportArgumentType]
|
|
with_metadata=with_metadata): idx # pyright:ignore[reportArgumentType]
|
|
for idx, filename in enumerate(filenames)}
|
|
|
|
for future in futures.as_completed(images):
|
|
result = T.cast(np.ndarray | tuple[np.ndarray, "PNGHeader"], future.result())
|
|
ret_idx = images[future]
|
|
if with_metadata:
|
|
assert isinstance(result, tuple)
|
|
batch[ret_idx], meta[ret_idx] = result
|
|
else:
|
|
assert isinstance(result, np.ndarray)
|
|
batch[ret_idx] = result
|
|
|
|
arr_batch = np.array(batch)
|
|
retval: np.ndarray | tuple[np.ndarray, list[PNGHeader]]
|
|
if with_metadata:
|
|
retval = (arr_batch, T.cast(list["PNGHeader"], meta))
|
|
else:
|
|
retval = arr_batch
|
|
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"Returning images: (filenames: %s, batch shape: %s, with_metadata: %s)",
|
|
filenames, arr_batch.shape, with_metadata)
|
|
return retval
|
|
|
|
|
|
def read_image_meta(filename):
|
|
""" Read the Faceswap metadata stored in an extracted face's exif header.
|
|
|
|
Parameters
|
|
----------
|
|
filename: str
|
|
Full path to the image to be retrieve the meta information for.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
The output dictionary will contain the `width` and `height` of the png image as well as any
|
|
`itxt` information.
|
|
Example
|
|
-------
|
|
>>> image_file = "/path/to/image.png"
|
|
>>> metadata = read_image_meta(image_file)
|
|
>>> width = metadata["width]
|
|
>>> height = metadata["height"]
|
|
>>> faceswap_info = metadata["itxt"]
|
|
"""
|
|
retval = {}
|
|
if os.path.splitext(filename)[-1].lower() != ".png":
|
|
# Get the dimensions directly from the image for non-png
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"Non png found. Loading file for dimensions: '%s'",
|
|
filename)
|
|
img = cv2.imread(filename)
|
|
assert img is not None
|
|
retval["height"], retval["width"] = img.shape[:2]
|
|
return retval
|
|
with open(filename, "rb") as in_file:
|
|
try:
|
|
chunk = in_file.read(8)
|
|
except PermissionError as exc:
|
|
raise PermissionError(f"PermissionError while reading: {filename}") from exc
|
|
|
|
if chunk != b"\x89PNG\r\n\x1a\n":
|
|
raise ValueError(f"Invalid header found in png: {filename}")
|
|
|
|
while True:
|
|
chunk = in_file.read(8)
|
|
length, field = struct.unpack(">I4s", chunk)
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"Read chunk: (chunk: %s, length: %s, field: %s",
|
|
chunk, length, field)
|
|
if not chunk or field == b"IDAT":
|
|
break
|
|
if field == b"IHDR":
|
|
# Get dimensions
|
|
chunk = in_file.read(8)
|
|
retval["width"], retval["height"] = struct.unpack(">II", chunk)
|
|
length -= 8
|
|
elif field == b"iTXt":
|
|
keyword, value = in_file.read(length).split(b"\0", 1)
|
|
if keyword == b"faceswap":
|
|
retval["itxt"] = literal_eval(value[4:].decode("utf-8", errors="replace"))
|
|
break
|
|
logger.trace("Skipping iTXt chunk: '%s'", # type:ignore[attr-defined]
|
|
keyword.decode("latin-1", errors="ignore"))
|
|
length = 0 # Reset marker for next chunk
|
|
in_file.seek(length + 4, 1)
|
|
logger.trace("filename: %s, metadata: %s", filename, retval) # type:ignore[attr-defined]
|
|
return retval
|
|
|
|
|
|
def read_image_meta_batch(filenames):
|
|
""" Read the Faceswap metadata stored in a batch extracted faces' exif headers.
|
|
|
|
Leverages multi-threading to load multiple images from disk at the same time
|
|
leading to vastly reduced image read times. Creates a generator to retrieve filenames
|
|
with their metadata as they are calculated.
|
|
|
|
Notes
|
|
-----
|
|
The order of returned values is non-deterministic so will most likely not be returned in the
|
|
same order as the filenames
|
|
|
|
Parameters
|
|
----------
|
|
filenames: list
|
|
A list of ``str`` full paths to the images to be loaded.
|
|
|
|
Yields
|
|
-------
|
|
tuple
|
|
(**filename** (`str`), **metadata** (`dict`) )
|
|
|
|
Example
|
|
-------
|
|
>>> image_filenames = ["/path/to/image_1.png", "/path/to/image_2.png", "/path/to/image_3.png"]
|
|
>>> for filename, meta in read_image_meta_batch(image_filenames):
|
|
>>> <do something>
|
|
"""
|
|
logger.trace("Requested batch: '%s'", filenames) # type:ignore[attr-defined]
|
|
executor = futures.ThreadPoolExecutor()
|
|
with executor:
|
|
logger.debug("Submitting %s items to executor", len(filenames))
|
|
read_meta = {executor.submit(read_image_meta, filename): filename
|
|
for filename in filenames}
|
|
logger.debug("Successfully submitted %s items to executor", len(filenames))
|
|
for future in futures.as_completed(read_meta):
|
|
retval = (read_meta[future], future.result())
|
|
logger.trace("Yielding: %s", retval) # type:ignore[attr-defined]
|
|
yield retval
|
|
|
|
|
|
def pack_to_itxt(metadata: PNGHeader | dict[str, T.Any] | bytes) -> bytes:
|
|
""" Pack the given metadata dictionary to a PNG iTXt header field.
|
|
|
|
Parameters
|
|
----------
|
|
metadata
|
|
The dictionary to write to the header. Can be pre-encoded as utf-8.
|
|
|
|
Returns
|
|
-------
|
|
A byte encoded PNG iTXt field, including chunk header and CRC
|
|
"""
|
|
if isinstance(metadata, PNGHeader):
|
|
metadata = metadata.to_dict()
|
|
if not isinstance(metadata, bytes):
|
|
metadata = str(metadata).encode("utf-8", "strict")
|
|
key = "faceswap".encode("latin-1", "strict")
|
|
|
|
chunk = key + b"\0\0\0\0\0" + metadata
|
|
crc = struct.pack(">I", crc32(chunk, crc32(b"iTXt")) & 0xFFFFFFFF)
|
|
length = struct.pack(">I", len(chunk))
|
|
retval = length + b"iTXt" + chunk + crc
|
|
return retval
|
|
|
|
|
|
def update_existing_metadata(filename: str, metadata: PNGHeader | bytes) -> None:
|
|
""" Update the png header metadata for an existing .png extracted face file on the filesystem.
|
|
|
|
Parameters
|
|
----------
|
|
filename
|
|
The full path to the face to be updated
|
|
metadata
|
|
The dictionary to write to the header. Can be pre-encoded as utf-8.
|
|
"""
|
|
if not isinstance(metadata, bytes):
|
|
metadata = str(metadata.to_dict()).encode("utf-8", errors="strict")
|
|
|
|
tmp_filename = filename + "~"
|
|
with open(filename, "rb") as png, open(tmp_filename, "wb") as tmp:
|
|
chunk = png.read(8)
|
|
if chunk != b"\x89PNG\r\n\x1a\n":
|
|
raise ValueError(f"Invalid header found in png: {filename}")
|
|
tmp.write(chunk)
|
|
|
|
while True:
|
|
chunk = png.read(8)
|
|
length, field = struct.unpack(">I4s", chunk)
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"Read chunk: (chunk: %s, length: %s, field: %s)",
|
|
chunk, length, field)
|
|
|
|
if field == b"IDAT": # Write out all remaining data
|
|
logger.trace("Writing image data and closing png") # type:ignore[attr-defined]
|
|
tmp.write(chunk + png.read())
|
|
break
|
|
|
|
if field != b"iTXt": # Write non iTXt chunk straight out
|
|
logger.trace("Copying existing chunk") # type:ignore[attr-defined]
|
|
tmp.write(chunk + png.read(length + 4)) # Header + CRC
|
|
continue
|
|
|
|
keyword, value = png.read(length).split(b"\0", 1)
|
|
if keyword != b"faceswap":
|
|
# Write existing non fs-iTXt data + CRC
|
|
logger.trace("Copying non-faceswap iTXt chunk: %s", # type:ignore[attr-defined]
|
|
keyword)
|
|
tmp.write(keyword + b"\0" + value + png.read(4))
|
|
continue
|
|
|
|
logger.trace("Updating faceswap iTXt chunk") # type:ignore[attr-defined]
|
|
tmp.write(pack_to_itxt(metadata))
|
|
png.seek(4, 1) # Skip old CRC
|
|
|
|
os.replace(tmp_filename, filename)
|
|
|
|
|
|
def encode_image(image: np.ndarray,
|
|
extension: str,
|
|
encoding_args: tuple[int, ...] | None = None,
|
|
metadata: PNGHeader | dict[str, T.Any] | bytes | None = None) -> bytes:
|
|
"""Encode an image.
|
|
|
|
Parameters
|
|
----------
|
|
image
|
|
The image to be encoded in `BGR` channel order.
|
|
extension
|
|
A compatible `cv2` image file extension that the final image is to be saved to.
|
|
encoding_args
|
|
Any encoding arguments to pass to cv2's imencode function
|
|
metadata
|
|
Metadata for the image. If provided, and the extension is png or tiff, this information
|
|
will be written to the PNG itxt header. Default:``None`` Can be provided as a python dict
|
|
or pre-encoded
|
|
|
|
Returns
|
|
-------
|
|
encoded_image: bytes
|
|
The image encoded into the correct file format as bytes
|
|
|
|
Example
|
|
-------
|
|
>>> image_file = "/path/to/image.png"
|
|
>>> image = read_image(image_file)
|
|
>>> encoded_image = encode_image(image, ".jpg")
|
|
"""
|
|
if metadata and extension.lower() not in (".png", ".tif"):
|
|
raise ValueError("Metadata is only supported for .png and .tif images")
|
|
args = tuple() if encoding_args is None else encoding_args
|
|
|
|
retval = cv2.imencode(extension, image, args)[1].tobytes()
|
|
if metadata:
|
|
func = {".png": png_write_meta, ".tif": tiff_write_meta}[extension]
|
|
retval = func(retval, metadata)
|
|
return retval
|
|
|
|
|
|
def png_write_meta(image: bytes, data: PNGHeader | dict[str, T.Any] | bytes) -> bytes:
|
|
"""Write Faceswap information to a png's iTXt field.
|
|
|
|
Parameters
|
|
----------
|
|
image
|
|
The bytes encoded png file to write header data to
|
|
data
|
|
The dictionary to write to the header. Can be pre-encoded as utf-8.
|
|
|
|
Notes
|
|
-----
|
|
This is a fairly stripped down and non-robust header writer to fit a very specific task. OpenCV
|
|
will not write any iTXt headers to the PNG file, so we make the assumption that the only iTXt
|
|
header that exists is the one that we created for storing alignments.
|
|
|
|
References
|
|
----------
|
|
PNG Specification: https://www.w3.org/TR/2003/REC-PNG-20031110/
|
|
"""
|
|
split = image.find(b"IDAT") - 4
|
|
retval = image[:split] + pack_to_itxt(data) + image[split:]
|
|
return retval
|
|
|
|
|
|
def tiff_write_meta(image: bytes, # pylint:disable=too-many-locals
|
|
data: PNGHeader | dict[str, T.Any] | bytes) -> bytes:
|
|
"""Write Faceswap information to a tiff's image_description field.
|
|
|
|
Parameters
|
|
----------
|
|
png
|
|
The bytes encoded tiff file to write header data to
|
|
data
|
|
The data to write to the image-description field. If provided as a dict, then it should be
|
|
a json serializable object, otherwise it should be data encoded as ascii bytes
|
|
|
|
Notes
|
|
-----
|
|
This handles a very specific task of adding, and populating, an ImageDescription field in a
|
|
Tiff file generated by OpenCV. For any other use cases it will likely fail
|
|
"""
|
|
if isinstance(data, PNGHeader):
|
|
data = data.to_dict()
|
|
if not isinstance(data, bytes):
|
|
data = json.dumps(data, ensure_ascii=True).encode("ascii")
|
|
|
|
assert image[:2] == b"II", "Not a supported TIFF file"
|
|
assert struct.unpack("<H", image[2:4])[0] == 42, "Only version 42 Tiff files are supported"
|
|
ptr = struct.unpack("<I", image[4:8])[0]
|
|
rendered = image[:ptr] # Pack up to IFD
|
|
|
|
num_tags = struct.unpack("<H", image[ptr: ptr + 2])[0]
|
|
ptr += 2
|
|
rendered += struct.pack("<H", num_tags + 1) # Pack new IFD field count
|
|
remainder = image[ptr + num_tags * 12:] # Hold the data from after the IFD
|
|
assert struct.unpack("<I", remainder[:4])[0] == 0, "Multi-page TIFF files not supported"
|
|
|
|
dtypes = {2: "1s", 3: "1H", 4: "1I", 7: '1B'}
|
|
|
|
ifd = b""
|
|
insert_idx = -1
|
|
for i in range(num_tags):
|
|
tag = image[ptr + i * 12:ptr + (1 + i) * 12]
|
|
|
|
tag_id = struct.unpack("<H", tag[0:2])[0]
|
|
assert tag_id != 270, "Not a supported TIFF file"
|
|
|
|
tag_count = struct.unpack("<I", tag[4:8])[0]
|
|
tag_type = dtypes[struct.unpack("<H", tag[2:4])[0]]
|
|
size = tag_count * struct.calcsize(tag_type)
|
|
|
|
if insert_idx < 0 and tag_id > 270:
|
|
insert_idx = i # Log insert location of image description
|
|
|
|
if size <= 4: # value in offset column
|
|
ifd += tag
|
|
continue
|
|
|
|
ifd += tag[:8]
|
|
tag_offset = struct.unpack("<I", tag[8:12])[0]
|
|
new_offset = struct.pack("<I", tag_offset + 12) # Increment by length of new ifd entry
|
|
ifd += new_offset
|
|
|
|
end = len(rendered) + len(ifd) + 12 + len(remainder)
|
|
desc = struct.pack("HH", 270, 2)
|
|
desc += struct.pack("II", len(data), end)
|
|
# TODO confirm no extra pages in end of IFD
|
|
|
|
rendered += ifd[:insert_idx * 12] + desc + ifd[insert_idx * 12:] + remainder + data
|
|
return rendered
|
|
|
|
|
|
def tiff_read_meta(image: bytes) -> dict[str, T.Any]: # pylint:disable=too-many-locals
|
|
""" Read information stored in a Tiff's Image Description field
|
|
|
|
Returns
|
|
-------
|
|
dict[str, Any]
|
|
Any arbitrary information stored in the TIFF header (for example matrix information for
|
|
the patch writer)
|
|
"""
|
|
assert image[:2] == b"II", "Not a supported TIFF file"
|
|
assert struct.unpack("<H", image[2:4])[0] == 42, "Only version 42 Tiff files are supported"
|
|
ptr = struct.unpack("<I", image[4:8])[0]
|
|
|
|
num_tags = struct.unpack("<H", image[ptr: ptr + 2])[0]
|
|
ptr += 2
|
|
ifd_end = ptr + num_tags * 12
|
|
ifd = image[ptr: ifd_end]
|
|
next_ifd = struct.unpack("<I", image[ifd_end:ifd_end + 4])[0]
|
|
assert next_ifd == 0, "Multi-page TIFF files not supported"
|
|
|
|
dtypes = {2: "1s", 3: "1H", 4: "1I", 7: '1B'}
|
|
data = None
|
|
for i in range(num_tags):
|
|
tag = ifd[i * 12:(1 + i) * 12]
|
|
tag_id = struct.unpack("<H", tag[0:2])[0]
|
|
if tag_id != 270:
|
|
continue
|
|
|
|
tag_count = struct.unpack("<I", tag[4:8])[0]
|
|
tag_type = dtypes[struct.unpack("<H", tag[2:4])[0]]
|
|
size = tag_count * struct.calcsize(tag_type)
|
|
tag_offset = struct.unpack("<I", tag[8:12])[0]
|
|
|
|
data = image[tag_offset: tag_offset + size]
|
|
|
|
assert data is not None, "No Metadata found in Tiff File"
|
|
retval = json.loads(data.decode("ascii"))
|
|
return retval
|
|
|
|
|
|
def png_read_meta(image: bytes) -> PNGHeader | dict[str, T.Any]:
|
|
""" Read the Faceswap information stored in a png's iTXt field.
|
|
|
|
Parameters
|
|
----------
|
|
image
|
|
The bytes encoded png file to read header data from
|
|
|
|
Returns
|
|
-------
|
|
The Faceswap information stored in the PNG header. This will either be a PNGHeader object if an
|
|
extracted face, or other arbitrary information (for example for the Patch Writer)
|
|
|
|
Notes
|
|
-----
|
|
This is a very stripped down, non-robust and non-secure header reader to fit a very specific
|
|
task. OpenCV will not write any iTXt headers to the PNG file, so we make the assumption that
|
|
the only iTXt header that exists is the one that Faceswap created for storing alignments.
|
|
"""
|
|
retval: PNGHeader | dict[str, T.Any] | None = None
|
|
pointer = 0
|
|
while True:
|
|
pointer = image.find(b"iTXt", pointer) - 4
|
|
if pointer < 0:
|
|
logger.trace("No metadata in png") # type:ignore[attr-defined]
|
|
break
|
|
length = struct.unpack(">I", image[pointer:pointer + 4])[0]
|
|
pointer += 8
|
|
keyword, value = image[pointer:pointer + length].split(b"\0", 1)
|
|
if keyword == b"faceswap":
|
|
retval = PNGHeader.from_dict(literal_eval(value[4:].decode("utf-8", errors="ignore")))
|
|
break
|
|
logger.trace("Skipping iTXt chunk: '%s'", # type:ignore[attr-defined]
|
|
keyword.decode("latin-1", errors="ignore"))
|
|
pointer += length + 4
|
|
assert retval is not None
|
|
return retval
|
|
|
|
|
|
def generate_thumbnail(image, size=96, quality=60):
|
|
""" Generate a jpg thumbnail for the given image.
|
|
|
|
Parameters
|
|
----------
|
|
image: :class:`numpy.ndarray`
|
|
Three channel BGR image to convert to a jpg thumbnail
|
|
size: int
|
|
The width and height, in pixels, that the thumbnail should be generated at
|
|
quality: int
|
|
The jpg quality setting to use
|
|
|
|
Returns
|
|
-------
|
|
:class:`numpy.ndarray`
|
|
The given image encoded to a jpg at the given size and quality settings
|
|
"""
|
|
logger.trace("Input shape: %s, size: %s, quality: %s", # type:ignore[attr-defined]
|
|
image.shape, size, quality)
|
|
orig_size = image.shape[0]
|
|
if orig_size != size:
|
|
interpolator = cv2.INTER_AREA if orig_size > size else cv2.INTER_CUBIC
|
|
image = cv2.resize(image, (size, size), interpolation=interpolator)
|
|
retval = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, quality])[1]
|
|
logger.trace("Output shape: %s", retval.shape) # type:ignore[attr-defined]
|
|
return retval
|
|
|
|
|
|
def batch_convert_color(batch, color_space):
|
|
""" Convert a batch of images from one color space to another.
|
|
|
|
Converts a batch of images by reshaping the batch prior to conversion rather than iterating
|
|
over the images. This leads to a significant speed up in the convert process.
|
|
|
|
Parameters
|
|
----------
|
|
batch: numpy.ndarray
|
|
A batch of images.
|
|
color_space: str
|
|
The OpenCV Color Conversion Code suffix. For example for BGR to LAB this would be
|
|
``'BGR2LAB'``.
|
|
See https://docs.opencv.org/4.1.1/d8/d01/group__imgproc__color__conversions.html for a full
|
|
list of color codes.
|
|
|
|
Returns
|
|
-------
|
|
numpy.ndarray
|
|
The batch converted to the requested color space.
|
|
|
|
Example
|
|
-------
|
|
>>> images_bgr = numpy.array([image1, image2, image3])
|
|
>>> images_lab = batch_convert_color(images_bgr, "BGR2LAB")
|
|
|
|
Notes
|
|
-----
|
|
This function is only compatible for color space conversions that have the same image shape
|
|
for source and destination color spaces.
|
|
|
|
If you use :func:`batch_convert_color` with 8-bit images, the conversion will have some
|
|
information lost. For many cases, this will not be noticeable but it is recommended
|
|
to use 32-bit images in cases that need the full range of colors or that convert an image
|
|
before an operation and then convert back.
|
|
"""
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"Batch converting: (batch shape: %s, color_space: %s)",
|
|
batch.shape, color_space)
|
|
original_shape = batch.shape
|
|
batch = batch.reshape((original_shape[0] * original_shape[1], *original_shape[2:]))
|
|
batch = cv2.cvtColor(batch, getattr(cv2, f"COLOR_{color_space}"))
|
|
return batch.reshape(original_shape)
|
|
|
|
|
|
def hex_to_rgb(hex_code):
|
|
""" Convert a hex number to it's RGB counterpart.
|
|
|
|
Parameters
|
|
----------
|
|
hex_code: str
|
|
The hex code to convert (e.g. `"#0d25ac"`)
|
|
|
|
Returns
|
|
-------
|
|
tuple
|
|
The hex code as a 3 integer (`R`, `G`, `B`) tuple
|
|
"""
|
|
value = hex_code.lstrip("#")
|
|
chars = len(value)
|
|
return tuple(int(value[i:i + chars // 3], 16) for i in range(0, chars, chars // 3))
|
|
|
|
|
|
def rgb_to_hex(rgb):
|
|
""" Convert an RGB tuple to it's hex counterpart.
|
|
|
|
Parameters
|
|
----------
|
|
rgb: tuple
|
|
The (`R`, `G`, `B`) integer values to convert (e.g. `(0, 255, 255)`)
|
|
|
|
Returns
|
|
-------
|
|
str:
|
|
The 6 digit hex code with leading `#` applied
|
|
"""
|
|
return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
|
|
|
|
|
|
# ################### #
|
|
# <<< VIDEO UTILS >>> #
|
|
# ################### #
|
|
|
|
class ImageIO():
|
|
""" Perform disk IO for images or videos in a background thread.
|
|
|
|
This is the parent thread for :class:`ImagesLoader` and :class:`ImagesSaver` and should not
|
|
be called directly.
|
|
|
|
Parameters
|
|
----------
|
|
path: str or list
|
|
The path to load or save images to/from. For loading this can be a folder which contains
|
|
images, video file or a list of image files. For saving this must be an existing folder.
|
|
queue_size: int
|
|
The amount of images to hold in the internal buffer.
|
|
args: tuple, optional
|
|
The arguments to be passed to the loader or saver thread. Default: ``None``
|
|
|
|
See Also
|
|
--------
|
|
lib.image.ImagesLoader : Background Image Loader inheriting from this class.
|
|
lib.image.ImagesSaver : Background Image Saver inheriting from this class.
|
|
"""
|
|
|
|
def __init__(self, path, queue_size, args=None):
|
|
logger.debug(parse_class_init(locals()))
|
|
self._name = self.__class__.__name__
|
|
self._args = tuple() if args is None else args
|
|
self._location = path
|
|
self._check_location_exists()
|
|
self._queue = Queue(maxsize=queue_size)
|
|
self._thread = None
|
|
self._error_state: ErrorState | None = None
|
|
|
|
@property
|
|
def location(self):
|
|
""" str: The folder or video that was passed in as the :attr:`path` parameter. """
|
|
return self._location
|
|
|
|
def _check_location_exists(self):
|
|
""" Check whether the input location exists.
|
|
|
|
Raises
|
|
------
|
|
FaceswapError
|
|
If the given location does not exist
|
|
"""
|
|
if isinstance(self.location, str) and not os.path.exists(self.location):
|
|
raise FaceswapError(f"The location '{self.location}' does not exist")
|
|
if isinstance(self.location, (list, tuple)) and not all(os.path.exists(location)
|
|
for location in self.location):
|
|
raise FaceswapError("Not all locations in the input list exist")
|
|
|
|
def _set_thread(self):
|
|
""" Set the background thread for the load and save iterators and launch it. """
|
|
logger.trace("[%s] Setting thread", self._name) # type:ignore[attr-defined]
|
|
if self._thread is not None and self._thread.is_alive():
|
|
logger.trace("[%s] Thread pre-exists and is alive: %s", # type:ignore[attr-defined]
|
|
self._name, self._thread)
|
|
return
|
|
self._thread = FSThread(self._process,
|
|
name=self.__class__.__name__,
|
|
args=(self._queue, ))
|
|
self._error_state = self._thread.error_state
|
|
logger.debug("[%s] Set thread: %s", self._name, self._thread)
|
|
self._thread.start()
|
|
|
|
def _process(self, queue):
|
|
""" Image IO process to be run in a thread. Override for loader/saver process.
|
|
|
|
Parameters
|
|
----------
|
|
queue: queue.Queue()
|
|
The ImageIO Queue
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def close(self):
|
|
""" Closes down and joins the internal threads """
|
|
logger.debug("[%s] Received Close", self._name)
|
|
if self._thread is not None:
|
|
self._thread.join()
|
|
del self._thread
|
|
self._thread = None
|
|
logger.debug("[%s] Closed", self._name)
|
|
|
|
|
|
class ImagesLoader(ImageIO):
|
|
"""Perform image loading from a folder of images or a video.
|
|
|
|
Images will be loaded and returned in the order that they appear in the folder, or in the video
|
|
to ensure deterministic ordering. Loading occurs in a background thread, caching 8 images at a
|
|
time so that other processes do not need to wait on disk reads.
|
|
|
|
See also :class:`ImageIO` for additional attributes.
|
|
|
|
Parameters
|
|
----------
|
|
path
|
|
The path to load images from. This can be a folder which contains images a video file or a
|
|
list of image files.
|
|
queue_size
|
|
The amount of images to hold in the internal buffer. Default: 8.
|
|
fast_count
|
|
When loading from video, the video needs to be parsed frame by frame to get an accurate
|
|
count. This can be done quite quickly without guaranteed accuracy, or slower with
|
|
guaranteed accuracy. Set to ``True`` to count quickly, or ``False`` to count slower
|
|
but accurately. Default: ``True``.
|
|
skip_list
|
|
Optional list of frame/image indices to not load. Any indices provided here will be skipped
|
|
when executing the :func:`load` function from the given location. Default: ``None``
|
|
count
|
|
If the number of images that the loader will encounter is already known, it can be passed
|
|
in here to skip the image counting step, which can save time at launch. Set to ``None`` if
|
|
the count is not already known. Default: ``None``
|
|
pts
|
|
The Presentation Timestamps if the source is a video and they are available. Default:
|
|
``None``
|
|
keyframes
|
|
The Keyframes if the source is a video and they are available. Default: ``None``
|
|
|
|
Examples
|
|
--------
|
|
Loading from a video file:
|
|
|
|
>>> loader = ImagesLoader('/path/to/video.mp4')
|
|
>>> for filename, image in loader.load():
|
|
>>> <do processing>
|
|
"""
|
|
def __init__(self,
|
|
path: str | list[str],
|
|
queue_size: int = 8,
|
|
fast_count: bool = True,
|
|
skip_list: list[int] | None = None,
|
|
count: int | None = None,
|
|
pts: list[int] | None = None,
|
|
keyframes: list[int] | None = None) -> None:
|
|
logger.debug(parse_class_init(locals()))
|
|
super().__init__(path, queue_size=queue_size)
|
|
self._skip_list = set() if skip_list is None else set(skip_list)
|
|
self._is_video = check_for_video(self.location)
|
|
self._count: int | None = None
|
|
self._file_list: list[str] = []
|
|
self._reader = VideoReader(self.location,
|
|
fast_count=fast_count,
|
|
pts=pts,
|
|
keyframes=keyframes) if self._is_video else None
|
|
self._get_count_and_filelist(count)
|
|
|
|
@property
|
|
def count(self) -> int:
|
|
"""The number of images or video frames in the source location. This count includes any
|
|
files that will ultimately be skipped if a :attr:`skip_list` has been provided. See also
|
|
:attr:`process_count`"""
|
|
assert self._count is not None
|
|
return self._count
|
|
|
|
@property
|
|
def process_count(self) -> int:
|
|
"""The number of images or video frames to be processed (IE the total count less items that
|
|
are to be skipped from the :attr:`skip_list`)"""
|
|
return self.count - len(self._skip_list)
|
|
|
|
@property
|
|
def is_video(self) -> bool:
|
|
"""``True`` if the input is a video, ``False`` if it is not"""
|
|
return self._is_video
|
|
|
|
@property
|
|
def file_list(self) -> list[str]:
|
|
"""A full list of files in the source location. This includes any files that will
|
|
ultimately be skipped if a :attr:`skip_list` has been provided. If the input is a video
|
|
then this is a list of dummy filenames as corresponding to an alignments file """
|
|
return self._file_list
|
|
|
|
@property
|
|
def processed_file_list(self) -> list[str]:
|
|
"""A list of files in the source location with any files that will be skipped removed"""
|
|
return [f for i, f in enumerate(self._file_list) if i not in self._skip_list]
|
|
|
|
def add_skip_list(self, skip_list: list[int]) -> None:
|
|
"""Add a skip list to this :class:`ImagesLoader`
|
|
|
|
Parameters
|
|
----------
|
|
skip_list
|
|
A list of indices corresponding to the frame indices that should be skipped by the
|
|
:func:`load` function.
|
|
"""
|
|
logger.debug("[%s] skip_list: %s", self._name, skip_list)
|
|
self._skip_list = set(skip_list)
|
|
|
|
def _get_count_and_filelist(self, count: int | None) -> None:
|
|
"""Set the count of images to be processed and set the file list. If the input is a video,
|
|
a dummy file list is created for checking against an alignments file, otherwise it will be
|
|
a list of full filenames.
|
|
|
|
Parameters
|
|
----------
|
|
count: int
|
|
The number of images that the loader will encounter if already known, otherwise
|
|
``None``
|
|
"""
|
|
if self._is_video:
|
|
assert self._reader is not None
|
|
self._count = len(self._reader)
|
|
self._file_list = [self._dummy_video_frame_name(i) for i in range(self.count)]
|
|
else:
|
|
if isinstance(self.location, (list, tuple)):
|
|
self._file_list = list(self.location)
|
|
else:
|
|
self._file_list = get_image_paths(self.location)
|
|
self._count = len(self.file_list) if count is None else count
|
|
logger.debug("[%s] count: %s", self._name, self.count)
|
|
logger.trace("[%s] file_list: %s", self._name, self.file_list) # type:ignore[attr-defined]
|
|
|
|
def _process(self, queue: Queue) -> None:
|
|
"""The load thread.
|
|
|
|
Loads from a folder of images or from a video and puts to a queue
|
|
|
|
Parameters
|
|
----------
|
|
queue
|
|
The ImageIO Queue
|
|
"""
|
|
iterator = self._from_video if self._is_video else self._from_folder
|
|
logger.debug("[%s] Load iterator: %s", self._name, iterator)
|
|
assert self._error_state is not None
|
|
for retval in iterator():
|
|
filename, image = retval[:2]
|
|
if image is None or (not image.any() and image.ndim not in (2, 3)):
|
|
# All black frames will return not numpy.any() so check dims too
|
|
logger.warning("Unable to open image. Skipping: '%s'", filename)
|
|
continue
|
|
logger.trace("[%s] Putting to queue: %s", # type:ignore[attr-defined]
|
|
self._name, [v.shape if isinstance(v, np.ndarray) else v for v in retval])
|
|
|
|
while True:
|
|
if self._error_state.has_error:
|
|
logger.debug("[%s] Thread error detected in worker thread", self._name)
|
|
return
|
|
try:
|
|
queue.put(retval, timeout=0.2)
|
|
break
|
|
except QueueFull:
|
|
logger.trace("[%s] Queue full. Waiting", # type:ignore[attr-defined]
|
|
self._name)
|
|
continue
|
|
logger.trace("[%s] Putting EOF", self._name) # type:ignore[attr-defined]
|
|
queue.put("EOF")
|
|
|
|
def _dummy_video_frame_name(self, index: int) -> str:
|
|
"""Return a dummy filename for video files. The file name is made up of:
|
|
<video_filename>_<frame_number>.<video_extension>
|
|
|
|
Notes
|
|
-----
|
|
Indexes start at 0, frame numbers start at 1, so index is incremented by 1
|
|
when creating the filename
|
|
|
|
Parameters
|
|
----------
|
|
index
|
|
The index number for the frame in the video file
|
|
|
|
Returns
|
|
-------
|
|
A dummied filename for a video frame
|
|
"""
|
|
vid_name, ext = os.path.splitext(os.path.basename(self.location))
|
|
return f"{vid_name}_{index + 1:06d}{ext}"
|
|
|
|
def _from_video(self) -> T.Generator[tuple[str, npt.NDArray[np.uint8]], None, None]:
|
|
"""Generator for loading frames from a video
|
|
|
|
Yields
|
|
------
|
|
filename
|
|
The dummy filename of the loaded video frame.
|
|
image
|
|
The loaded video frame.
|
|
"""
|
|
assert self._reader is not None
|
|
logger.debug("[%s] Loading frames from video: '%s'", self._name, self.location)
|
|
for idx, frame in enumerate(self._reader):
|
|
if idx in self._skip_list:
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"[%s] Skipping frame %s due to skip list", self._name, idx)
|
|
continue
|
|
image = T.cast("npt.NDArray[np.uint8]",
|
|
frame.to_ndarray(channel_last=True, format="bgr24"))
|
|
filename = self._dummy_video_frame_name(idx)
|
|
logger.trace("[%s] Loading video frame: '%s'", # type:ignore[attr-defined]
|
|
self._name, filename)
|
|
yield filename, image
|
|
|
|
def _from_folder(self) -> T.Generator[tuple[str, npt.NDArray[np.uint8]] |
|
|
tuple[str, npt.NDArray[np.uint8], PNGHeader],
|
|
None, None]:
|
|
"""Generator for loading images from a folder
|
|
|
|
Yields
|
|
------
|
|
filename
|
|
The filename of the loaded image.
|
|
image
|
|
The loaded image.
|
|
metadata
|
|
The Faceswap metadata associated with the loaded image. (:class:`FacesLoader` only)
|
|
"""
|
|
logger.debug("[%s] Loading frames from folder: '%s'", self._name, self.location)
|
|
for idx, filename in enumerate(self.file_list):
|
|
if idx in self._skip_list:
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"[%s] Skipping frame %s due to skip list", self._name, filename)
|
|
continue
|
|
image_read = read_image(filename, raise_error=False)
|
|
if image_read is None:
|
|
logger.warning("Frame not loaded: '%s'", filename)
|
|
continue
|
|
yield filename, image_read
|
|
|
|
def load(self) -> T.Generator[tuple[str, npt.NDArray[np.uint8]] |
|
|
tuple[str, npt.NDArray[np.uint8], PNGHeader], None, None]:
|
|
"""Generator for loading images from the given :attr:`location`
|
|
|
|
If :class:`FacesLoader` is in use then the Faceswap metadata of the image stored in the
|
|
image exif file is added as the final item in the output `tuple`.
|
|
|
|
Yields
|
|
------
|
|
filename
|
|
The filename of the loaded image.
|
|
image
|
|
The loaded image.
|
|
metadata
|
|
The Faceswap metadata associated with the loaded image. (:class:`FacesLoader` only)
|
|
"""
|
|
logger.debug("[%s] Initializing Load Generator", self._name)
|
|
self._set_thread()
|
|
assert self._error_state is not None
|
|
while True:
|
|
if self._error_state.has_error:
|
|
current = current_thread()
|
|
if current is main_thread():
|
|
self._error_state.re_raise()
|
|
else:
|
|
logger.debug("[%s.%s] Thread error detected in worker thread",
|
|
current.name, self._name)
|
|
break
|
|
try:
|
|
retval = self._queue.get(True, 1)
|
|
except QueueEmpty:
|
|
continue
|
|
if retval == "EOF":
|
|
logger.trace("[%s] Got EOF", self._name) # type:ignore[attr-defined]
|
|
break
|
|
logger.trace("[%s] Yielding: %s", # type:ignore[attr-defined]
|
|
self._name, [v.shape if isinstance(v, np.ndarray) else v for v in retval])
|
|
yield retval
|
|
logger.debug("[%s] Closing Load Generator", self._name)
|
|
self.close()
|
|
|
|
|
|
class FacesLoader(ImagesLoader):
|
|
""" Loads faces from a faces folder along with the face's Faceswap metadata.
|
|
|
|
Examples
|
|
--------
|
|
Loading faces with their Faceswap metadata:
|
|
|
|
>>> loader = FacesLoader('/path/to/faces/folder')
|
|
>>> for filename, face, metadata in loader.load():
|
|
>>> <do processing>
|
|
"""
|
|
def __init__(self, path, skip_list=None, count=None):
|
|
logger.debug(parse_class_init(locals()))
|
|
super().__init__(path, queue_size=8, skip_list=skip_list, count=count)
|
|
|
|
def _get_count_and_filelist(self, count):
|
|
""" Override default implementation to only return png files from the source folder
|
|
|
|
Parameters
|
|
----------
|
|
count: int
|
|
The number of images that the loader will encounter if already known, otherwise
|
|
``None``
|
|
"""
|
|
if isinstance(self.location, (list, tuple)):
|
|
file_list = self.location
|
|
else:
|
|
file_list = get_image_paths(self.location)
|
|
|
|
self._file_list = [fname for fname in file_list
|
|
if os.path.splitext(fname)[-1].lower() == ".png"]
|
|
self._count = len(self.file_list) if count is None else count
|
|
|
|
logger.debug("[%s] count: %s", self._name, self.count)
|
|
logger.trace("[%s] file_list: %s", self._name, self.file_list) # type:ignore[attr-defined]
|
|
|
|
def _from_folder(self):
|
|
""" Generator for loading images from a folder
|
|
Faces will only ever be loaded from a folder, so this is the only function requiring
|
|
an override
|
|
|
|
Yields
|
|
------
|
|
filename: str
|
|
The filename of the loaded image.
|
|
image: numpy.ndarray
|
|
The loaded image.
|
|
metadata: dict
|
|
The Faceswap metadata associated with the loaded image.
|
|
"""
|
|
logger.debug("[%s] Loading images from folder: '%s'", self._name, self.location)
|
|
for idx, filename in enumerate(self.file_list):
|
|
if idx in self._skip_list:
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"[%s] Skipping face %s due to skip list", self._name, idx)
|
|
continue
|
|
image_read = read_image(filename, raise_error=False, with_metadata=True)
|
|
retval = filename, *image_read
|
|
if retval[1] is None:
|
|
logger.warning("Face not loaded: '%s'", filename)
|
|
continue
|
|
yield retval
|
|
|
|
|
|
class SingleFrameLoader(ImagesLoader):
|
|
"""Allows direct access to a frame by filename or frame index.
|
|
|
|
As we are interested in instant access to frames, there is no requirement to process in a
|
|
background thread, as either way we need to wait for the frame to load.
|
|
|
|
Parameters
|
|
----------
|
|
path
|
|
Full path to the input media
|
|
video_meta_data
|
|
Existing video meta information containing the pts_time and is_key flags for the given
|
|
video. Used in conjunction with single_frame_reader for faster seeks. Providing this means
|
|
that the video does not need to be scanned again. Set to ``None`` if the video is to be
|
|
scanned. Default: ``None``
|
|
"""
|
|
def __init__(self,
|
|
path: str,
|
|
video_meta_data: dict[T.Literal["pts_time", "keyframes"], list[int]] | None = None
|
|
) -> None:
|
|
logger.debug(parse_class_init(locals()))
|
|
self._video_meta_data: dict[T.Literal["pts_time", "keyframes"],
|
|
list[int]] | None = video_meta_data
|
|
pts = None if video_meta_data is None else video_meta_data["pts_time"]
|
|
keyframes = None if video_meta_data is None else video_meta_data["keyframes"]
|
|
super().__init__(path, queue_size=1, fast_count=False, pts=pts, keyframes=keyframes)
|
|
|
|
@property
|
|
def video_meta_data(self) -> dict[T.Literal["pts_time", "keyframes"], list[int]] | None:
|
|
"""For videos contains the keys `frame_pts` holding a list of time stamps for each
|
|
frame and `keyframes` holding the frame index of each key frame.
|
|
|
|
Notes
|
|
-----
|
|
Only populated if the input is a video and single frame reader is being used, otherwise
|
|
returns ``None``.
|
|
"""
|
|
if self._reader is None:
|
|
return None
|
|
return {"pts_time": self._reader.info.pts.tolist(),
|
|
"keyframes": self._reader.info.keyframes.tolist()}
|
|
|
|
def image_from_index(self, index: int) -> tuple[str, npt.NDArray[np.uint8]]:
|
|
"""Return a single image from :attr:`file_list` for the given index. We do not use a
|
|
background thread for this task, as it is assumed that requesting an image by index will be
|
|
done when required.
|
|
|
|
|
|
Parameters
|
|
----------
|
|
index: int
|
|
The index number (frame number) of the frame to retrieve. NB: The first frame is
|
|
index `0`
|
|
|
|
Returns
|
|
-------
|
|
filename: str
|
|
The filename of the returned image
|
|
image: :class:`numpy.ndarray`
|
|
The image for the given index
|
|
"""
|
|
if self.is_video:
|
|
assert self._reader is not None
|
|
image = T.cast("npt.NDArray[np.uint8]",
|
|
self._reader.get(index).to_ndarray(channel_last=True, format="bgr24"))
|
|
filename = self._dummy_video_frame_name(index)
|
|
else:
|
|
file_list = [f for idx, f in enumerate(self._file_list)
|
|
if idx not in self._skip_list] if self._skip_list else self._file_list
|
|
|
|
filename = file_list[index]
|
|
image = read_image(filename, raise_error=True)
|
|
filename = os.path.basename(filename)
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"[%s] index: %s, filename: %s image shape: %s",
|
|
self._name, index, filename, image.shape)
|
|
return filename, image
|
|
|
|
def close(self) -> None:
|
|
"""Shut down the video reader"""
|
|
if self._reader is not None:
|
|
self._reader.close()
|
|
super().close()
|
|
|
|
|
|
class ImagesSaver(ImageIO):
|
|
""" Perform image saving to a destination folder.
|
|
|
|
Images are saved in a background ThreadPoolExecutor to allow for concurrent saving.
|
|
See also :class:`ImageIO` for additional attributes.
|
|
|
|
Parameters
|
|
----------
|
|
path: str
|
|
The folder to save images to. This must be an existing folder.
|
|
queue_size: int, optional
|
|
The amount of images to hold in the internal buffer. Default: 8.
|
|
as_bytes: bool, optional
|
|
``True`` if the image is already encoded to bytes, ``False`` if the image is a
|
|
:class:`numpy.ndarray`. Default: ``False``.
|
|
|
|
Examples
|
|
--------
|
|
|
|
>>> saver = ImagesSaver('/path/to/save/folder')
|
|
>>> for filename, image in <image_iterator>:
|
|
>>> saver.save(filename, image)
|
|
>>> saver.close()
|
|
"""
|
|
|
|
def __init__(self, path, queue_size=8, as_bytes=False):
|
|
logger.debug(parse_class_init(locals()))
|
|
super().__init__(path, queue_size=queue_size)
|
|
self._as_bytes = as_bytes
|
|
|
|
def _check_location_exists(self):
|
|
""" Check whether the output location exists and is a folder
|
|
|
|
Raises
|
|
------
|
|
FaceswapError
|
|
If the given location does not exist or the location is not a folder
|
|
"""
|
|
if not isinstance(self.location, str):
|
|
raise FaceswapError("The output location must be a string not a "
|
|
f"{type(self.location)}")
|
|
super()._check_location_exists()
|
|
if not os.path.isdir(self.location):
|
|
raise FaceswapError(f"The output location '{self.location}' is not a folder")
|
|
|
|
def _process(self, queue):
|
|
""" Saves images from the save queue to the given :attr:`location` inside a thread.
|
|
|
|
Parameters
|
|
----------
|
|
queue: queue.Queue()
|
|
The ImageIO Queue
|
|
"""
|
|
executor = futures.ThreadPoolExecutor(thread_name_prefix=self.__class__.__name__)
|
|
assert self._error_state is not None
|
|
while True:
|
|
if self._error_state.has_error:
|
|
logger.debug("[%s] Thread error detected in worker thread", self._name)
|
|
executor.shutdown(cancel_futures=True)
|
|
return
|
|
item = queue.get()
|
|
if item == "EOF":
|
|
logger.debug("[%s] EOF received", self._name)
|
|
break
|
|
logger.trace("[%s] Submitting: '%s'", self._name, item[0]) # type:ignore[attr-defined]
|
|
executor.submit(self._save, *item)
|
|
executor.shutdown()
|
|
|
|
def _save(self,
|
|
filename: str,
|
|
image: bytes | np.ndarray,
|
|
sub_folder: str | None) -> None:
|
|
""" Save a single image inside a ThreadPoolExecutor
|
|
|
|
Parameters
|
|
----------
|
|
filename: str
|
|
The filename of the image to be saved. NB: Any folders passed in with the filename
|
|
will be stripped and replaced with :attr:`location`.
|
|
image: bytes or :class:`numpy.ndarray`
|
|
The encoded image or numpy array to be saved
|
|
subfolder: str or ``None``
|
|
If the file should be saved in a subfolder in the output location, the subfolder should
|
|
be provided here. ``None`` for no subfolder.
|
|
"""
|
|
location = os.path.join(self.location, sub_folder) if sub_folder else self._location
|
|
if sub_folder and not os.path.exists(location):
|
|
os.makedirs(location)
|
|
|
|
filename = os.path.join(location, os.path.basename(filename))
|
|
try:
|
|
if self._as_bytes:
|
|
assert isinstance(image, bytes)
|
|
with open(filename, "wb") as out_file:
|
|
out_file.write(image)
|
|
else:
|
|
assert isinstance(image, np.ndarray)
|
|
cv2.imwrite(filename, image)
|
|
logger.trace("[%s] Saved image: '%s'", # type:ignore[attr-defined]
|
|
self._name, filename)
|
|
except Exception as err: # pylint:disable=broad-except
|
|
logger.error("Failed to save image '%s'. Original Error: %s", filename, str(err))
|
|
del image
|
|
del filename
|
|
|
|
def save(self,
|
|
filename: str,
|
|
image: bytes | np.ndarray,
|
|
sub_folder: str | None = None) -> None:
|
|
""" Save the given image in the background thread
|
|
|
|
Ensure that :func:`close` is called once all save operations are complete.
|
|
|
|
Parameters
|
|
----------
|
|
filename: str
|
|
The filename of the image to be saved. NB: Any folders passed in with the filename
|
|
will be stripped and replaced with :attr:`location`.
|
|
image: bytes
|
|
The encoded image to be saved
|
|
subfolder: str, optional
|
|
If the file should be saved in a subfolder in the output location, the subfolder should
|
|
be provided here. ``None`` for no subfolder. Default: ``None``
|
|
"""
|
|
if self._error_state is not None and self._error_state.has_error:
|
|
logger.debug("[%s.%s] Thread error detected in worker thread. Not putting",
|
|
current_thread().name, self._name)
|
|
return
|
|
self._set_thread()
|
|
logger.trace("[%s] Putting to save queue: '%s'", # type:ignore[attr-defined]
|
|
self._name, filename)
|
|
self._queue.put((filename, image, sub_folder))
|
|
|
|
def close(self):
|
|
""" Signal to the Save Threads that they should be closed and cleanly shutdown
|
|
the saver """
|
|
logger.debug("[%s] Putting EOF to save queue", self._name)
|
|
self._queue.put("EOF")
|
|
super().close()
|
|
|
|
|
|
__all__ = get_module_objects(__name__)
|