diff --git a/EmbedSeg/criterions/my_loss.py b/EmbedSeg/criterions/my_loss.py index e026c47..7aec8ba 100644 --- a/EmbedSeg/criterions/my_loss.py +++ b/EmbedSeg/criterions/my_loss.py @@ -77,10 +77,17 @@ def forward( instance_ids = np.arange(instances.size(1)) else: instance_ids = instance.unique() - instance_ids = instance_ids[instance_ids != 0] + instance_ids = instance_ids[instance_ids > 0] + + # pixels labelled -1 carry no information and are excluded from the loss + if self.one_hot: + ignore = (instance < 0).any(1) # 1 x h x w + else: + ignore = instance < 0 # 1 x h x w + n_valid = (~ignore).sum().clamp(min=1) # regress bg to zero - bg_mask = label == 0 + bg_mask = (label == 0) & ~ignore if bg_mask.sum() > 0: seed_loss += torch.sum(torch.pow(seed_map[bg_mask] - 0, 2)) @@ -117,7 +124,11 @@ def forward( ) # apply lovasz-hinge loss - instance_loss = instance_loss + lovasz_hinge(dist * 2 - 1, in_mask) + lovasz_labels = in_mask.long() + lovasz_labels[ignore] = -1 + instance_loss = instance_loss + lovasz_hinge( + dist * 2 - 1, lovasz_labels, ignore=-1 + ) # seed loss seed_loss += self.foreground_weight * torch.sum( torch.pow(seed_map[in_mask] - dist[in_mask].detach(), 2) @@ -133,7 +144,7 @@ def forward( instance_loss /= obj_count var_loss /= obj_count - seed_loss = seed_loss / (height * width) + seed_loss = seed_loss / n_valid loss += w_inst * instance_loss + w_var * var_loss + w_seed * seed_loss diff --git a/EmbedSeg/criterions/my_loss_3d.py b/EmbedSeg/criterions/my_loss_3d.py index 4053670..c3d6acb 100644 --- a/EmbedSeg/criterions/my_loss_3d.py +++ b/EmbedSeg/criterions/my_loss_3d.py @@ -90,10 +90,14 @@ def forward( label = labels[b].unsqueeze(0) # 1 x d x h x w center_image = center_images[b].unsqueeze(0) # 1 x d x h x w instance_ids = instance.unique() - instance_ids = instance_ids[instance_ids != 0] + instance_ids = instance_ids[instance_ids > 0] + + # pixels labelled -1 carry no information and are excluded from the loss + ignore = instance < 0 # 1 x d x h x w + n_valid = (~ignore).sum().clamp(min=1) # regress bg to zero - bg_mask = label == 0 + bg_mask = (label == 0) & ~ignore if bg_mask.sum() > 0: seed_loss += torch.sum(torch.pow(seed_map[bg_mask] - 0, 2)) @@ -127,7 +131,11 @@ def forward( ) # apply lovasz-hinge loss - instance_loss = instance_loss + lovasz_hinge(dist * 2 - 1, in_mask) + lovasz_labels = in_mask.long() + lovasz_labels[ignore] = -1 + instance_loss = instance_loss + lovasz_hinge( + dist * 2 - 1, lovasz_labels, ignore=-1 + ) # seed loss seed_loss += self.foreground_weight * torch.sum( @@ -144,7 +152,7 @@ def forward( instance_loss /= obj_count var_loss /= obj_count - seed_loss = seed_loss / (depth * height * width) + seed_loss = seed_loss / n_valid loss += w_inst * instance_loss + w_var * var_loss + w_seed * seed_loss diff --git a/EmbedSeg/datasets/ThreeDimensionalDataset.py b/EmbedSeg/datasets/ThreeDimensionalDataset.py index 9443220..314c1e7 100644 --- a/EmbedSeg/datasets/ThreeDimensionalDataset.py +++ b/EmbedSeg/datasets/ThreeDimensionalDataset.py @@ -235,7 +235,7 @@ def __getitem__(self, index): @classmethod def decode_instance(cls, pic, one_hot, bg_id=None): - pic = np.array(pic, copy=False, dtype=np.uint16) + pic = np.asarray(pic, dtype=np.int32) instance_map = np.zeros( (pic.shape[0], pic.shape[1], pic.shape[2]), dtype=np.int16 ) @@ -253,6 +253,9 @@ def decode_instance(cls, pic, one_hot, bg_id=None): else: class_map[mask] = 1 + # pixels labelled -1 carry no information and are ignored in the loss + instance_map[pic < 0] = -1 + return instance_map, class_map @classmethod @@ -271,11 +274,11 @@ def rle_decode(cls, filename, one_hot=False, center=False): else: if center: mask_decoded = np.zeros( - ast.literal_eval(df_numpy[0][2]), dtype=np.bool + ast.literal_eval(df_numpy[0][2]), dtype=bool ) # obtain size by reading first row of csv file else: mask_decoded = np.zeros( - ast.literal_eval(df_numpy[0][2]), dtype=np.uint16 + ast.literal_eval(df_numpy[0][2]), dtype=np.int32 ) # obtain size by reading first row of csv file for row in df_numpy: d["counts"] = ast.literal_eval(row[1]) diff --git a/EmbedSeg/datasets/TwoDimensionalDataset.py b/EmbedSeg/datasets/TwoDimensionalDataset.py index d0ddbcc..8554e75 100644 --- a/EmbedSeg/datasets/TwoDimensionalDataset.py +++ b/EmbedSeg/datasets/TwoDimensionalDataset.py @@ -201,7 +201,7 @@ def __getitem__(self, index): @classmethod def decode_instance(cls, pic, one_hot, bg_id=None): - pic = np.array(pic, copy=False, dtype=np.uint16) + pic = np.asarray(pic, dtype=np.int32) if one_hot: instance_map = np.zeros( (pic.shape[0], pic.shape[1], pic.shape[2]), dtype=np.uint8 @@ -221,6 +221,10 @@ def decode_instance(cls, pic, one_hot, bg_id=None): else: class_map[mask] = 1 + if not one_hot: + # pixels labelled -1 carry no information and are ignored in the loss + instance_map[pic < 0] = -1 + return instance_map, class_map @classmethod @@ -239,11 +243,11 @@ def rle_decode(cls, filename, one_hot=False, center=False): else: if center: mask_decoded = np.zeros( - ast.literal_eval(df_numpy[0][2]), dtype=np.bool + ast.literal_eval(df_numpy[0][2]), dtype=bool ) # obtain size by reading first row of csv file else: mask_decoded = np.zeros( - ast.literal_eval(df_numpy[0][2]), dtype=np.uint16 + ast.literal_eval(df_numpy[0][2]), dtype=np.int32 ) # obtain size by reading first row of csv file for row in df_numpy: d["counts"] = ast.literal_eval(row[1]) diff --git a/EmbedSeg/test.py b/EmbedSeg/test.py index 0404bf0..1c1a968 100644 --- a/EmbedSeg/test.py +++ b/EmbedSeg/test.py @@ -9,7 +9,7 @@ from EmbedSeg.utils.metrics import matching_dataset, obtain_APdsb_one_hot from EmbedSeg.utils.utils import Cluster, Cluster_3d import numpy as np -from tifffile import imsave +from tifffile import imwrite from EmbedSeg.utils.test_time_augmentation import apply_tta_2d, apply_tta_3d from scipy.ndimage import zoom from scipy.optimize import minimize_scalar, linear_sum_assignment @@ -1138,15 +1138,15 @@ def test(fg_thresh, *args): # save predictions base, _ = os.path.splitext(os.path.basename(sample["im_name"][0])) instances_file = os.path.join(save_dir, "predictions/", base + ".tif") - imsave( + imwrite( instances_file, instance_map.cpu().detach().numpy().astype(np.uint16), ) seeds_file = os.path.join(save_dir, "seeds/", base + ".tif") - imsave(seeds_file, seed_map.cpu().detach().numpy()) + imwrite(seeds_file, seed_map.cpu().detach().numpy()) if "instance" in sample: gt_file = os.path.join(save_dir, "ground-truth/", base + ".tif") - imsave(gt_file, sample["instance"].squeeze().cpu().detach().numpy()) + imwrite(gt_file, sample["instance"].squeeze().cpu().detach().numpy()) if save_results and "instance" in sample: if not os.path.exists(os.path.join(save_dir, "results/")): @@ -1471,14 +1471,14 @@ def test_3d(fg_thresh, *args): image_file_names.append(base) instances_file = os.path.join(save_dir, "predictions/", base + ".tif") - imsave(instances_file, instance_map) + imwrite(instances_file, instance_map) seeds_file = os.path.join(save_dir, "seeds/", base + ".tif") - imsave(seeds_file, seed_map) + imwrite(seeds_file, seed_map) if "instance" in sample: gt_file = os.path.join(save_dir, "ground-truth/", base + ".tif") - imsave(gt_file, sample["instance"].squeeze().cpu().detach().numpy()) + imwrite(gt_file, sample["instance"].squeeze().cpu().detach().numpy()) if save_results and "instance" in sample: if not os.path.exists(os.path.join(save_dir, "results/")): @@ -1783,19 +1783,19 @@ def test_3d_sliced(fg_thresh, *args): image_file_names.append(base) prediction_file = os.path.join(save_dir, "predictions/", base + ".tif") - imsave( + imwrite( prediction_file, instance_map.cpu().detach().numpy().astype(np.uint16), ) seeds_file = os.path.join(save_dir, "seeds/", base + ".tif") - imsave( + imwrite( seeds_file, torch.sigmoid(output[0, -1, ...]).cpu().detach().numpy() ) if "instance" in sample: gt_file = os.path.join(save_dir, "ground-truth/", base + ".tif") - imsave(gt_file, sample["instance"].squeeze().cpu().detach().numpy()) + imwrite(gt_file, sample["instance"].squeeze().cpu().detach().numpy()) if save_results and "instance" in sample: if not os.path.exists(os.path.join(save_dir, "results/")): @@ -2203,10 +2203,10 @@ def test_3d_ilp(fg_thresh, *args): # save predictions base, _ = os.path.splitext(os.path.basename(sample["im_name"][0])) instances_file = os.path.join(save_dir, "predictions/", base + ".tif") - imsave(instances_file, instance_map) + imwrite(instances_file, instance_map) seeds_file = os.path.join(save_dir, "seeds/", base + ".tif") - imsave(seeds_file, np.asarray(seed_z).astype(float)) + imwrite(seeds_file, np.asarray(seed_z).astype(float)) if save_results and "instance" in sample: if not os.path.exists(os.path.join(save_dir, "results/")): diff --git a/EmbedSeg/train.py b/EmbedSeg/train.py index 0954c99..d9a658e 100644 --- a/EmbedSeg/train.py +++ b/EmbedSeg/train.py @@ -162,7 +162,7 @@ def train_vanilla( instances[0].cpu(), key="groundtruth", title="Ground Truth" ) # TODO instance_ids = instances[0].unique() - instance_ids = instance_ids[instance_ids != 0] + instance_ids = instance_ids[instance_ids > 0] if display_embedding: ( @@ -392,7 +392,7 @@ def train_vanilla_3d( title="Ground Truth", ) # TODO instance_ids = instances[0].unique() - instance_ids = instance_ids[instance_ids != 0] + instance_ids = instance_ids[instance_ids > 0] visualizer.display( predictions.cpu()[zslice, ...], key="prediction", title="Prediction" @@ -542,7 +542,7 @@ def val_vanilla( instances[0].cpu(), key="groundtruth", title="Ground Truth" ) # TODO instance_ids = instances[0].unique() - instance_ids = instance_ids[instance_ids != 0] + instance_ids = instance_ids[instance_ids > 0] if display_embedding: ( center_x, @@ -773,7 +773,7 @@ def val_vanilla_3d( title="Ground Truth", ) # TODO instance_ids = instances[0].unique() - instance_ids = instance_ids[instance_ids != 0] + instance_ids = instance_ids[instance_ids > 0] visualizer.display( predictions.cpu()[zslice, ...], diff --git a/EmbedSeg/utils/generate_crops.py b/EmbedSeg/utils/generate_crops.py index 80ca86e..8b80b16 100644 --- a/EmbedSeg/utils/generate_crops.py +++ b/EmbedSeg/utils/generate_crops.py @@ -37,6 +37,10 @@ def grow(sl, interior): def shrink(interior): return tuple(slice(int(w[0]), (-1 if w[1] else None)) for w in interior) + # pixels labelled -1 are ignored (unknown); keep them and fill holes of the objects only + ignore = lbl_img < 0 + lbl_img = np.where(ignore, 0, lbl_img) + objects = find_objects(lbl_img) lbl_img_filled = np.zeros_like(lbl_img) for i, sl in enumerate(objects, 1): @@ -47,6 +51,7 @@ def shrink(interior): grown_mask = lbl_img[grow(sl, interior)] == i mask_filled = binary_fill_holes(grown_mask, **kwargs)[shrink_slice] lbl_img_filled[sl][mask_filled] = i + lbl_img_filled[ignore & (lbl_img_filled == 0)] = -1 return lbl_img_filled @@ -373,7 +378,7 @@ def process( os.makedirs(os.path.dirname(center_image_path)) print("Created new directory : {}".format(center_image_path)) - instance = tifffile.imread(inst).astype(np.uint16) + instance = tifffile.imread(inst).astype(np.int32) image = tifffile.imread(im).astype(float) if norm == "min-max-percentile": @@ -430,7 +435,7 @@ def process( center_image_crop = generate_center_image( instance_crop, center, ids, one_hot ) - tifffile.imsave( + tifffile.imwrite( image_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), im_crop, ) @@ -448,13 +453,13 @@ def process( center_image_crop, ) else: - tifffile.imsave( + tifffile.imwrite( instance_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), instance_crop, ) - tifffile.imsave( + tifffile.imwrite( center_image_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), @@ -471,7 +476,7 @@ def process( center_image_crop = generate_center_image( instance_crop, center, ids, one_hot ) - tifffile.imsave( + tifffile.imwrite( image_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), im_crop, ) @@ -489,13 +494,13 @@ def process( center_image_crop, ) else: - tifffile.imsave( + tifffile.imwrite( instance_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), instance_crop, ) - tifffile.imsave( + tifffile.imwrite( center_image_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), @@ -591,7 +596,7 @@ def process_3d( os.makedirs(os.path.dirname(center_image_path)) print("Created new directory : {}".format(center_image_path)) - instance = tifffile.imread(inst).astype(np.uint16) + instance = tifffile.imread(inst).astype(np.int32) image = tifffile.imread(im).astype(float) if norm == "min-max-percentile": @@ -665,14 +670,14 @@ def process_3d( anisotropy_factor=anisotropy_factor, speed_up=speed_up, ) - tifffile.imsave( + tifffile.imwrite( image_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), im_crop, ) if rle_encode: encode( instance_path + os.path.basename(im)[:-4] + "_{:03d}.csv".format(j), - instance_crop.astype(np.uint16), + instance_crop.astype(np.int32), ) encode( center_image_path @@ -681,11 +686,11 @@ def process_3d( center_image_crop, ) else: - tifffile.imsave( + tifffile.imwrite( instance_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), - instance_crop.astype(np.uint16), + instance_crop.astype(np.int32), ) - tifffile.imsave( + tifffile.imwrite( center_image_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), @@ -753,7 +758,7 @@ def process_one_hot( except FileExistsError: pass - instance = tifffile.imread(inst).astype(np.uint16) + instance = tifffile.imread(inst).astype(np.int32) image = tifffile.imread(im).astype(float) if norm == "min-max-percentile": @@ -798,7 +803,7 @@ def process_one_hot( instance_crop, center, ids, one_hot ) instance_crop_sparse = sparsify(instance_crop) - tifffile.imsave( + tifffile.imwrite( image_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), im_crop, ) @@ -816,11 +821,11 @@ def process_one_hot( one_hot=False, ) else: - tifffile.imsave( + tifffile.imwrite( instance_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), instance_crop_sparse, ) - tifffile.imsave( + tifffile.imwrite( center_image_path + os.path.basename(im)[:-4] + "_{:03d}.tif".format(j), @@ -918,7 +923,7 @@ def process_3d_sliced( os.makedirs(os.path.dirname(center_image_path)) print("Created new directory : {}".format(center_image_path)) - instance = tifffile.imread(inst).astype(np.uint16) + instance = tifffile.imread(inst).astype(np.int32) image = tifffile.imread(im).astype(float) if norm == "min-max-percentile": image = normalize_min_max_percentile(image, 1, 99.8, axis=(0, 1, 2)) @@ -971,11 +976,11 @@ def process_3d_sliced( instance_crop = instance_np[ kk : kk + crop_size_z, jj : jj + crop_size_y, x ] - if np.sum(instance_crop) > 0: # shouldn't be only background + if np.any(instance_crop > 0): # shouldn't be only background center_image_crop = generate_center_image( instance_crop, center, ids, one_hot=one_hot ) - tifffile.imsave( + tifffile.imwrite( image_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) @@ -990,7 +995,7 @@ def process_3d_sliced( + "_{:03d}".format(id) + "_{:03d}".format(x) + "_ZY.csv", - instance_crop.astype(np.uint16), + instance_crop.astype(np.int32), ) encode( center_image_path @@ -1001,15 +1006,15 @@ def process_3d_sliced( center_image_crop, ) else: - tifffile.imsave( + tifffile.imwrite( instance_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) + "_{:03d}".format(x) + "_ZY.tif", - instance_crop.astype(np.uint16), + instance_crop.astype(np.int32), ) - tifffile.imsave( + tifffile.imwrite( center_image_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) @@ -1028,11 +1033,11 @@ def process_3d_sliced( instance_crop = instance_np[ z, jj : jj + crop_size_y, ii : ii + crop_size_x ] - if np.sum(instance_crop) > 0: + if np.any(instance_crop > 0): center_image_crop = generate_center_image( instance_crop, center, ids, one_hot=one_hot ) - tifffile.imsave( + tifffile.imwrite( image_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) @@ -1047,7 +1052,7 @@ def process_3d_sliced( + "_{:03d}".format(id) + "_{:03d}".format(z) + "_YX.csv", - instance_crop.astype(np.uint16), + instance_crop.astype(np.int32), ) encode( center_image_path @@ -1058,15 +1063,15 @@ def process_3d_sliced( center_image_crop, ) else: - tifffile.imsave( + tifffile.imwrite( instance_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) + "_{:03d}".format(z) + "_YX.tif", - instance_crop.astype(np.uint16), + instance_crop.astype(np.int32), ) - tifffile.imsave( + tifffile.imwrite( center_image_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) @@ -1087,11 +1092,11 @@ def process_3d_sliced( instance_crop = np.transpose( instance_np[kk : kk + crop_size_z, y, ii : ii + crop_size_x] ) - if np.sum(instance_crop) > 0: + if np.any(instance_crop > 0): center_image_crop = generate_center_image( instance_crop, center, ids, one_hot=one_hot ) - tifffile.imsave( + tifffile.imwrite( image_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) @@ -1106,7 +1111,7 @@ def process_3d_sliced( + "_{:03d}".format(id) + "_{:03d}".format(y) + "_XZ.csv", - instance_crop.astype(np.uint16), + instance_crop.astype(np.int32), ) encode( center_image_path @@ -1117,15 +1122,15 @@ def process_3d_sliced( center_image_crop, ) else: - tifffile.imsave( + tifffile.imwrite( instance_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) + "_{:03d}".format(y) + "_XZ.tif", - instance_crop.astype(np.uint16), + instance_crop.astype(np.int32), ) - tifffile.imsave( + tifffile.imwrite( center_image_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) @@ -1216,7 +1221,7 @@ def process_3d_ilp( os.makedirs(os.path.dirname(center_image_path)) print("Created new directory : {}".format(center_image_path)) - instance = tifffile.imread(inst).astype(np.uint16) + instance = tifffile.imread(inst).astype(np.int32) image = tifffile.imread(im).astype(float) if norm == "min-max-percentile": image = normalize_min_max_percentile(image, 1, 99.8, axis=(0, 1, 2)) @@ -1260,11 +1265,11 @@ def process_3d_ilp( instance_crop = instance_np[ z, jj : jj + crop_size_y, ii : ii + crop_size_x ] - if np.sum(instance_crop) > 0: + if np.any(instance_crop > 0): center_image_crop = generate_center_image( instance_crop, center, ids, one_hot=one_hot ) - tifffile.imsave( + tifffile.imwrite( image_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) @@ -1279,7 +1284,7 @@ def process_3d_ilp( + "_{:03d}".format(id) + "_{:03d}".format(z) + "_YX.csv", - instance_crop.astype(np.uint16), + instance_crop.astype(np.int32), ) encode( center_image_path @@ -1290,15 +1295,15 @@ def process_3d_ilp( center_image_crop, ) else: - tifffile.imsave( + tifffile.imwrite( instance_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) + "_{:03d}".format(z) + "_YX.tif", - instance_crop.astype(np.uint16), + instance_crop.astype(np.int32), ) - tifffile.imsave( + tifffile.imwrite( center_image_path + os.path.basename(im)[:-4] + "_{:03d}".format(id) diff --git a/EmbedSeg/utils/preprocess_data.py b/EmbedSeg/utils/preprocess_data.py index 6df794e..7d79bd2 100644 --- a/EmbedSeg/utils/preprocess_data.py +++ b/EmbedSeg/utils/preprocess_data.py @@ -536,7 +536,7 @@ def calculate_object_size( size_list.append(len(x)) elif not one_hot and mode == "2d": ids = np.unique(ma) - ids = ids[ids != background_id] + ids = ids[ids > background_id] for id in ids: y, x = np.where(ma == id) size_list_x.append(np.max(x) - np.min(x)) @@ -544,7 +544,7 @@ def calculate_object_size( size_list.append(len(x)) elif not one_hot and mode in ["3d", "3d_sliced"]: ids = np.unique(ma) - ids = ids[ids != background_id] + ids = ids[ids > background_id] if process_k is not None: n_ids = process_k[1] else: @@ -559,7 +559,7 @@ def calculate_object_size( elif not one_hot and mode == "3d_ilp": for z in range(ma.shape[0]): ids = np.unique(ma[z]) - ids = ids[ids != background_id] + ids = ids[ids > background_id] for id in ids: y, x = np.where(ma[z] == id) size_list_y.append(np.max(y) - np.min(y)) diff --git a/EmbedSeg/utils/utils.py b/EmbedSeg/utils/utils.py index 7099ce7..d4aa389 100644 --- a/EmbedSeg/utils/utils.py +++ b/EmbedSeg/utils/utils.py @@ -3,6 +3,7 @@ import matplotlib import matplotlib.pyplot as plt +from matplotlib.backends import BackendFilter, backend_registry import numpy as np import pandas as pd import seaborn as sns @@ -131,7 +132,7 @@ def prepare_img(image): @staticmethod def mypause(interval): backend = plt.rcParams["backend"] - if backend in matplotlib.rcsetup.interactive_bk: + if backend in backend_registry.list_builtin(BackendFilter.INTERACTIVE): figManager = matplotlib._pylab_helpers.Gcf.get_active() if figManager is not None: canvas = figManager.canvas diff --git a/pyproject.toml b/pyproject.toml index df92bdc..fb58596 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ source = "vcs" [tool.hatch.build.targets.wheel] only-include = ["EmbedSeg"] -sources = ["EmbedSeg"] # https://peps.python.org/pep-0621/ [project]