def make_synthetic_dataset(
    source_samples: DataFrame,
    image_dir: Path,
    mask_dir: Path,
    anomalous_ratio: float = 0.5,
) -> DataFrame:
    """Convert normal samples into a mixed set with synthetic anomalies.

    The function generates synthetic anomalous images and their corresponding
    masks by applying Perlin noise-based perturbations to normal images.

    Args:
        source_samples: DataFrame containing normal images used as source for
            synthetic anomalies. Must contain columns: ``image_path``,
            ``label``, ``label_index``, ``mask_path``, and ``split``.
        image_dir: Directory where synthetic anomalous images will be saved.
        mask_dir: Directory where ground truth anomaly masks will be saved.
        anomalous_ratio: Fraction of source samples to convert to anomalous
            samples. Defaults to ``0.5``.

    Returns:
        DataFrame containing both normal and synthetic anomalous samples.

    Raises:
        ValueError: If source samples contain any anomalous images.
        NotADirectoryError: If ``image_dir`` or ``mask_dir`` is not a directory.

    Example:
        >>> df = make_synthetic_dataset(
        ...     source_samples=normal_df,
        ...     image_dir=Path("./synthetic/images"),
        ...     mask_dir=Path("./synthetic/masks"),
        ...     anomalous_ratio=0.3
        ... )
        >>> len(df[df.label == "abnormal"])  # 30% are anomalous
        30
    """
    if 1 in source_samples.label_index.to_numpy():
        msg = "All source images must be normal."
        raise ValueError(msg)

    if not image_dir.is_dir():
        msg = f"{image_dir} is not a folder."
        raise NotADirectoryError(msg)

    if not mask_dir.is_dir():
        msg = f"{mask_dir} is not a folder."
        raise NotADirectoryError(msg)

    # filter relevant columns
    source_samples = source_samples.filter(["image_path", "label", "label_index", "mask_path", "split"])
    # randomly select samples for augmentation
    n_anomalous = int(anomalous_ratio * len(source_samples))
    anomalous_samples = source_samples.sample(n_anomalous)
    normal_samples = source_samples.drop(anomalous_samples.index)
    anomalous_samples = anomalous_samples.reset_index(drop=True)

    # initialize augmenter
    augmenter = PerlinAnomalyGenerator(
        anomaly_source_path="./datasets/dtd",
        probability=1.0,
        blend_factor=(0.01, 0.2),
    )

    def augment(sample: Series) -> Series:
        """Apply synthetic anomalous augmentation to a sample.

        Args:
            sample: DataFrame row containing image information.

        Returns:
            Series containing updated information about the augmented image.
        """
        # read and transform image
        image = read_image(sample.image_path, as_tensor=True)
        # apply anomalous perturbation
        aug_im, mask = augmenter(image)
        # target file name with leading zeros
        file_name = f"{str(sample.name).zfill(int(math.log10(n_anomalous)) + 1)}.png"
        # write image
        aug_im = (aug_im.squeeze().permute((1, 2, 0)) * 255).numpy()
        aug_im = cv2.cvtColor(aug_im, cv2.COLOR_RGB2BGR)
        im_path = image_dir / file_name
        cv2.imwrite(str(im_path), aug_im)
        # write mask
        mask = (mask.squeeze() * 255).numpy()
        mask_path = mask_dir / file_name
        cv2.imwrite(str(mask_path), mask)
        out = {
            "image_path": str(im_path),
            "label": "abnormal",
            "label_index": 1,
            "mask_path": str(mask_path),
            "split": Split.VAL,
        }
        return Series(out)

    anomalous_samples = anomalous_samples.apply(augment, axis=1)

    return pd.concat([normal_samples, anomalous_samples], ignore_index=True)
Convert normal samples into a mixed set with synthetic anomalies.

    The function generates synthetic anomalous images and their corresponding
    masks by applying Perlin noise-based perturbations to normal images.

    Args:
        source_samples: DataFrame containing normal images used as source for
            synthetic anomalies. Must contain columns: ``image_path``,
            ``label``, ``label_index``, ``mask_path``, and ``split``.
        image_dir: Directory where synthetic anomalous images will be saved.
        mask_dir: Directory where ground truth anomaly masks will be saved.
        anomalous_ratio: Fraction of source samples to convert to anomalous
            samples. Defaults to ``0.5``.

    Returns:
        DataFrame containing both normal and synthetic anomalous samples.

    Raises:
        ValueError: If source samples contain any anomalous images.
        NotADirectoryError: If ``image_dir`` or ``mask_dir`` is not a directory.

    Example:
        >>> df = make_synthetic_dataset(
        ...     source_samples=normal_df,
        ...     image_dir=Path("./synthetic/images"),
        ...     mask_dir=Path("./synthetic/masks"),
        ...     anomalous_ratio=0.3
        ... )
        >>> len(df[df.label == "abnormal"])  # 30% are anomalous
        30
    
src/anomalib/data/utils/synthetic.py