          that provided the data. This is used to crop the faces correctly based on whether the
          extracted faces are legacy or full-head extracts. """
          return self._alignments_version
  
      def _get_alignments_paths(self):
          """ Obtain the alignments file paths from the command line arguments passed to the model.
  
          If the argument does not exist or is empty, then scan the input folder for an alignments
          file.
  
          Returns
 
</s> remove         If the argument does not exist or is empty, then scan the input folder for an alignments
        file.
 </s> add         The Faceswap alignments data is returned as a dictionary, whist :attr:`_image_sizes` is
        populated from the training image metadata

        Parameters
        ----------
        image_list: dict
            The file paths for the images to be trained on for each side. The dictionary should
            contain 2 keys ("a" and "b") with the values being a list of full paths corresponding
            to each side. </s> remove     @property
    def aligned_faces(self):
        """ dict: The "a", "b" keys for each side, containing a sub-dictionary with the
        filename as key and :class:`lib.faces.detected_face.aligned` object as value. """
        return self._aligned_faces

 </s> add  </s> remove     def _get_aligned_faces(self, input_sizes):
        """ Pre-generate aligned faces as they are needed for all training functions.
 </s> add         # Validate image is the same size as the other images for the side
        if width != self._image_sizes[side]:
            msg = ("All training images for each side must be of the same size.\nImages "
                   "in side '{}' have mismatched sizes {} and {}.\nPlease double check "
                   "your dataset".format(side.upper(), self._image_sizes[side], width))
            raise FaceswapError(msg)

    def _update_legacy_facesets(self):
        """ Update the png header data for legacy face sets that do not contain the meta data in
        the exif header.
        """
        if self._validate_metadata(output_warning=False):
            logger.debug("All faces contain valid header information")
            return

        for side, png_meta in self._detected_faces.items():
            if all(png_meta.values()):
                continue
            filenames = [filename for filename, meta in png_meta.items() if not meta]
            logger.info("Legacy faces discovered for side '%s'. Updating %s images...",
                        side.upper(), len(filenames))
            alignments = Alignments(*os.path.split(self._get_alignments_path(side)))
            self._alignments_version.setdefault(side, set()).add(alignments.version)

            executor = futures.ThreadPoolExecutor()
            with executor:
                images = {executor.submit(update_legacy_png_header, filename, alignments): filename
                          for filename in filenames}

                for future in tqdm(
                        futures.as_completed(images),
                        desc="Updating legacy training images ({})".format(side.upper()),
                        total=len(filenames),
                        leave=False):
                    detected_face = DetectedFace()
                    detected_face.from_png_meta(future.result())
                    png_meta[images[future]] = detected_face

    def _get_alignments_path(self, side):
        """ Obtain the path to an alignments file for the given training side.

        Used for updating legacy facesets to contain the meta information within the image header

        Parameters
        ----------
        side: ["a" or "b"]
            The training side to obtain the alignments file for.

        Returns
        -------
        str
            The full path to the training alignments file

        Raises
        ------
        FaceswapError
            If an alignments file cannot be located
        """
        alignments_path = getattr(self._args, "alignments_path_{}".format(side))
        if not alignments_path:
            image_path = getattr(self._args, "input_{}".format(side))
            alignments_path = os.path.join(image_path, "alignments.fsa")
        if not os.path.exists(alignments_path):
            msg = ("You are using a legacy faceset that does not contain embedded "
                   "meta-information. An alignments file must be provided so that these files can "
                   "be updated.\n"
                   f"Alignments file does not exist: '{alignments_path}'")
            raise FaceswapError(msg)
        return alignments_path

    # <<< VALIDATE LOADED DETECTED FACE INFORMATION >>>
    def _validity_check(self):
        """ Check the validity of the finally loaded data.

        Ensure that each side has consistent alignments versions.
        Ensure that each side has a full compliment of metadata.
        """
        invalid = [side.upper()
                   for side, version in self._alignments_version.items() if len(version) > 1]
        if invalid:
            raise FaceswapError("Mixing legacy and full head extracted facesets is not supported. "
                                "The following side(s) contain a mix of extracted face "
                                "types: {}".format(invalid))
        # Replace check alignments version sets with actual floats
        self._alignments_version = {key: val.pop()
                                    for key, val in self._alignments_version.items()}

        if 1.0 in self._alignments_version.values() and self._config["centering"] != "legacy":
            logger.warning("You are using legacy extracted faces but have selected '%s' "
                           "centering which is incompatible. Switching centering to 'legacy'",
                           self._config["centering"])
            self._config["centering"] = "legacy"

        self._validate_metadata(output_warning=True)
        self._validate_masks()

    def _validate_metadata(self, output_warning=True):
        """ Validate that all images to be trained on have associated alignments data. If not
        generate a warning. </s> remove             Optionally the key ``hash`` will be provided, but not all use cases will know the
            face hash at this time.
 </s> add  </s> remove class Merge():  # pylint:disable=too-few-public-methods
    """ Merge multiple alignments files into one.

    Parameters
    ----------
    alignments: :class:`tools.lib_alignments.media.AlignmentData`
        The alignments data loaded from an alignments file for this rename job
    arguments: :class:`argparse.Namespace`
        The :mod:`argparse` arguments as passed in from :mod:`tools.py`
    """
    def __init__(self, alignments, arguments):
        self._alignments = alignments
        self._check_versions()
        self._faces = self._get_faces(arguments)
        self._final_alignments = alignments[0]
        self._process_alignments = alignments[1:]
        self._hashes_to_frame = None

    def _check_versions(self):
        """ Ensure all alignments files are compatible versions. If not, exit with error. """
        versions = [al.version for al in self._alignments]
        logger.debug(versions)
        if any(vers < 2.0 for vers in versions) and any(vers >= 2.0 for vers in versions):
            logger.error("You have selected incompatible alignments files for merging. You cannot "
                         "merge alignments files for legacy extracted faces with aligments files "
                         "for full-head extracted faces.")
            logger.info("You can update legacy alignments files by using the Extract job in the "
                        "Alignments tool to re-extract the faces in full-head format.")
            sys.exit(0)

    @staticmethod
    def _get_faces(arguments):
        """ If faces argument is specified, load the faces folder otherwise return ``None``.

        Parameters
        ----------
        arguments: :class:`argparse.Namespace`
            The :mod:`argparse` arguments as passed in from :mod:`tools.py`

        Returns
        -------
        :class:`tools.alignments.media.Faces` or ``None``
            The faces object or ``None`` if not faces folder specified
        """
        if not hasattr(arguments, "faces_dir") or not arguments.faces_dir:
            return None
        return Faces(arguments.faces_dir)

    def process(self):
        """Run the alignments file merging process. """
        logger.info("[MERGE ALIGNMENTS]")  # Tidy up cli output
        if self._faces is not None:
            self._remove_faces()
        self._hashes_to_frame = self._final_alignments.hashes_to_frame
        skip_count = 0
        merge_count = 0
        total_count = sum([alignments.frames_count for alignments in self._process_alignments])

        with tqdm(desc="Merging Alignments", total=total_count) as pbar:
            for alignments in self._process_alignments:
                for _, src_alignments, _, frame in alignments.yield_faces():
                    for idx, alignment in enumerate(src_alignments):
                        if not alignment.get("hash", None):
                            logger.warning("Alignment '%s':%s has no Hash! Skipping", frame, idx)
                            skip_count += 1
                            continue
                        if self._check_exists(frame, alignment, idx):
                            skip_count += 1
                            continue
                        self._merge_alignment(frame, alignment, idx)
                        merge_count += 1
                    pbar.update(1)
        logger.info("Alignments Merged: %s", merge_count)
        logger.info("Alignments Skipped: %s", skip_count)
        if merge_count != 0:
            self._set_destination_filename()
            self._final_alignments.save()

    def _remove_faces(self):
        """ Removes faces from the alignments file if a faces folder has been provided and the
        faces do not exist within each alignments file. """
        face_hashes = list(self._faces.items.keys())
        del_faces_count = 0
        del_frames_count = 0
        if not face_hashes:
            logger.error("No face hashes. This would remove all faces from your alignments file.")
            return
        for alignments in tqdm(self._alignments, desc="Filtering out faces"):
            pre_face_count = alignments.faces_count
            pre_frames_count = alignments.frames_count
            alignments.filter_hashes(face_hashes, filter_out=False)
            # Remove frames with no faces
            frames = list(alignments.data.keys())
            for frame in frames:
                if not alignments.frame_has_faces(frame):
                    del alignments.data[frame]
            post_face_count = alignments.faces_count
            post_frames_count = alignments.frames_count
            removed_faces = pre_face_count - post_face_count
            removed_frames = pre_frames_count - post_frames_count
            del_faces_count += removed_faces
            del_frames_count += removed_frames
            logger.verbose("Removed %s faces and %s frames from %s",
                           removed_faces, removed_frames, os.path.basename(alignments.file))
        logger.info("Total removed - faces: %s, frames: %s", del_faces_count, del_frames_count)

    def _check_exists(self, frame, alignment, index):
        """ Remove duplicate faces from the alignments file when an instance of the face already
        exists.

        Parameters
        ----------
        frame: str
            The frame name for the current frame being processed
        alignment: dict
            The alignment dictionary for the current face in the frame
        index: int
            The face index for the current face in the frame

        Returns
        -------
        bool
            ``True`` if the face has been already been seen, otherwise ``False``
        """
        existing_frame = self._hashes_to_frame.get(alignment["hash"], None)
        if not existing_frame:
            return False
        if frame in existing_frame.keys():
            logger.verbose("Face '%s': %s already exists in destination at position %s. "
                           "Skipping", frame, index, existing_frame[frame])
        elif frame not in existing_frame.keys():
            logger.verbose("Face '%s': %s exists in destination as: %s. "
                           "Skipping", frame, index, existing_frame)
        return True

    def _merge_alignment(self, frame, alignment, idx):
        """ Merge the source alignment into the destination final alignments dictionary

        Parameters
        ----------
        frame: str
            The frame name for the current frame being processed
        alignment: dict
            The alignment dictionary for the current face in the frame
        index: int
            The face index for the current face in the frame
        """
        logger.debug("Merging alignment: (frame: %s, src_idx: %s, hash: %s)",
                     frame, idx, alignment["hash"])
        self._hashes_to_frame.setdefault(alignment["hash"], dict())[frame] = idx
        self._final_alignments.data.setdefault(frame,
                                               dict()).setdefault("faces", []).append(alignment)

    def _set_destination_filename(self):
        """ Set the destination filename """
        folder = os.path.split(self._final_alignments.file)[0]
        ext = os.path.splitext(self._final_alignments.file)[1]
        now = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = os.path.join(folder, "alignments_merged_{}{}".format(now, ext))
        logger.debug("Output set to: '%s'", filename)
        self._final_alignments.set_filename(filename)


 </s> add 
    @property
    def aligned_faces(self):
        """ dict: The "a", "b" keys for each side, containing a sub-dictionary with the
        filename as key and :class:`lib.align.AlignedFace` object as value. """
        return self._aligned_faces

    # <<< LOAD DETECTED FACE INFORMATION FROM PNG HEADER >>>
    def _load_detected_faces(self, image_list):
        """ Obtain the metadata from the png training image headers for all images used for
        training.
          that provided the data. This is used to crop the faces correctly based on whether the
          extracted faces are legacy or full-head extracts. """
          return self._alignments_version
  
    @property
    def aligned_faces(self):
        """ dict: The "a", "b" keys for each side, containing a sub-dictionary with the
        filename as key and :class:`lib.align.AlignedFace` object as value. """
        return self._aligned_faces

    # <<< LOAD DETECTED FACE INFORMATION FROM PNG HEADER >>>
    def _load_detected_faces(self, image_list):
        """ Obtain the metadata from the png training image headers for all images used for
        training.
    @property
    def aligned_faces(self):
        """ dict: The "a", "b" keys for each side, containing a sub-dictionary with the
        filename as key and :class:`lib.align.AlignedFace` object as value. """
        return self._aligned_faces

    # <<< LOAD DETECTED FACE INFORMATION FROM PNG HEADER >>>
    def _load_detected_faces(self, image_list):
        """ Obtain the metadata from the png training image headers for all images used for
        training.
  
          If the argument does not exist or is empty, then scan the input folder for an alignments
          file.
  
          Returns
keep keep keep keep replace replace keep keep keep keep keep
 Update extracted faces to use PNG EXIF data (#1123)

Documentation
  - Update Usage.md, align.rst and image.rst
lib.image.py
  - read_image - Remove hash return, add metadata return
  - Remove read_image_hash functions
  - Add read_image_meta functios
  - Replace encode_image_with_hash with encode_image (to store metadata)
  - Add png meta reading and writing functions
  - Update Image Loaders/Savers to handle metadata rather than hashes
lib.training_data
  - Naming updates to remove references to hashes
lib.align.Alignments
  - Add versioning notes
  - Increment alignments version to 2.1
  - Deprecate hashing lookup functions
  - Replace filter_hashes with filter_faces
lib.align.detected_face
  - DetectedFace
    - Remove hash property
    - Add png header data serializing/deserializing functions
  - Mask
    - Add png header data serializing/deserializing functions
  - add update_legacy_png_header function to update png meta data
lib.cli.args - Deprecate alignments files for training
- plugins.train.trainer
  - Update alignments/mask code to read png header data
- scripts.convert
  - Aligned images folder - read data from png headers
- scripts.extract
  - Write png header information and no longer store hash of face
- tools.alignments
  - remove leftover-faces, merge and update-hashes jobs
  - Update jobs to use png meta data rather than hashes
- tools.manual
  - Update extract code to output png meta data and don't store hashes
  - Perform check on launch that tool is not pointing at a faces folder
tools.mask
  - Update to use png meta data
tools.sort
  - Update to use png meta data 
plugins/train/trainer/_base.py