diff --git a/pyproject.toml b/pyproject.toml index 3c30811..843a906 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "httpx>=0.28.1", "pydantic>=2.10.5", "pydantic-settings>=2.7.1", + "shapely>=2.0.6", + "colour-science>=0.4.6", ] [build-system] diff --git a/src/color_correction_asdfghjkl/constant/color_checker.py b/src/color_correction_asdfghjkl/constant/color_checker.py new file mode 100644 index 0000000..c0ef633 --- /dev/null +++ b/src/color_correction_asdfghjkl/constant/color_checker.py @@ -0,0 +1,31 @@ +import numpy as np + +# in BGR format +reference_color_d50 = np.array( + [ + [68, 82, 115], # 1. Dark skin + [128, 149, 195], # 2. Light skin + [157, 123, 93], # 3. Blue sky + [65, 108, 91], # 4. Foliage + [175, 129, 130], # 5. Blue flower + [171, 191, 99], # 6. Bluish green + [46, 123, 220], # 7. Orange + [168, 92, 72], # 8. Purplish blue + [97, 84, 194], # 9. Moderate red + [104, 59, 91], # 10. Purple + [62, 189, 161], # 11. Yellow green + [40, 161, 229], # 12. Orange yellow + [147, 63, 42], # 13. Blue + [72, 149, 72], # 14. Green + [57, 50, 175], # 15. Red + [22, 200, 238], # 16. Yellow + [150, 84, 188], # 17. Magenta + [166, 137, 0], # 18. Cyan + [240, 245, 245], # 19. White 9.5 + [201, 202, 201], # 20. Neutral 8 + [162, 162, 161], # 21. Neutral 6.5 + [121, 121, 120], # 22. Neutral 5 + [85, 85, 83], # 23. Neutral 3.5 + [51, 50, 50], # 24. Black 2 + ], +) diff --git a/src/color_correction_asdfghjkl/core/correction/base.py b/src/color_correction_asdfghjkl/core/correction/base.py new file mode 100644 index 0000000..af006bf --- /dev/null +++ b/src/color_correction_asdfghjkl/core/correction/base.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod + +import numpy as np + + +class BaseComputeCorrection(ABC): + @abstractmethod + def fit(self, image: np.ndarray) -> np.ndarray: ... diff --git a/src/color_correction_asdfghjkl/core/correction/least_squares.py b/src/color_correction_asdfghjkl/core/correction/least_squares.py new file mode 100644 index 0000000..6dbdf03 --- /dev/null +++ b/src/color_correction_asdfghjkl/core/correction/least_squares.py @@ -0,0 +1,41 @@ +import time + +import numpy as np + +from color_correction_asdfghjkl.core.correction.base import BaseComputeCorrection + + +class LeastSquaresRegression(BaseComputeCorrection): + def __init__(self) -> None: + self.model = None + + def fit( + self, + input_patches: np.ndarray, + reference_patches: np.ndarray, + ) -> np.ndarray: + start_time = time.perf_counter() + + self.model = np.linalg.lstsq( + a=input_patches, + b=reference_patches, + rcond=None, + )[0] # get only matrix of coefficients + + exc_time = time.perf_counter() - start_time + print(f"Least Squares Regression: {exc_time} seconds") + return self.model + + def compute_correction(self, input_image: np.ndarray) -> np.ndarray: + if self.model is None: + raise ValueError("Model is not fitted yet. Please call fit() method first.") + + # Reshape + h, w, c = input_image.shape + image = input_image.reshape(-1, 3).astype(np.float32) + + image = np.dot(input_image, self.model) + + # Clip dan convert kembali ke uint8 + corrected_image = np.clip(image, 0, 255).astype(np.uint8).reshape(h, w, c) + return corrected_image diff --git a/src/color_correction_asdfghjkl/schemas/yolov8_det.py b/src/color_correction_asdfghjkl/schemas/yolov8_det.py index c845b29..75c7bca 100644 --- a/src/color_correction_asdfghjkl/schemas/yolov8_det.py +++ b/src/color_correction_asdfghjkl/schemas/yolov8_det.py @@ -1,6 +1,15 @@ import numpy as np from pydantic import BaseModel +from color_correction_asdfghjkl.utils.geometry_processing import ( + extract_intersecting_patches, + generate_expected_patches, + suggest_missing_patch_coordinates, +) +from color_correction_asdfghjkl.utils.image_processing import ( + calculate_mean_rgb, + crop_region_with_margin, +) from color_correction_asdfghjkl.utils.yolo_utils import draw_detections box_tuple = tuple[int, int, int, int] @@ -41,3 +50,42 @@ def draw_detections(self, image: np.ndarray, mask_alpha: float = 0.2) -> np.ndar class_ids=self.class_ids, mask_alpha=mask_alpha, ) + + def get_list_patches(self, input_image: np.ndarray) -> list[np.ndarray]: + ls_cards, ls_patches = self.get_each_class_box() + + if len(ls_cards) == 0: + raise ValueError("No cards detected") + + if len(ls_patches) == 0: + raise ValueError("No patches detected") + + # Extract card coordinates + card_box = ls_cards[0] + ls_grid_card = generate_expected_patches(card_box) + + # get ls grid card + ls_ordered_patch_bbox = extract_intersecting_patches( + ls_patches=ls_patches, + ls_grid_card=ls_grid_card, + ) + + if None in ls_ordered_patch_bbox: + # Auto fill missing patches ---------------- + print("Auto fill missing patch...", ls_ordered_patch_bbox) + d_suggest = suggest_missing_patch_coordinates(ls_ordered_patch_bbox) + for idx, patch in d_suggest.items(): + ls_ordered_patch_bbox[idx] = patch + print(f"result len = {len(ls_ordered_patch_bbox)}") + + ls_rgb_mean_patch = [] + for coord_patch in ls_ordered_patch_bbox: + cropped_patch = crop_region_with_margin( + image=input_image, + coordinates=coord_patch, + margin_ratio=0.2, + ) + rgb_mean_patch = calculate_mean_rgb(cropped_patch) + ls_rgb_mean_patch.append(rgb_mean_patch) + + return ls_rgb_mean_patch diff --git a/src/color_correction_asdfghjkl/services/color_correction.py b/src/color_correction_asdfghjkl/services/color_correction.py new file mode 100644 index 0000000..cf056c2 --- /dev/null +++ b/src/color_correction_asdfghjkl/services/color_correction.py @@ -0,0 +1,185 @@ +from typing import Literal + +import colour as cl +import cv2 +import numpy as np +from numpy.typing import NDArray + +from color_correction_asdfghjkl.constant.color_checker import reference_color_d50 +from color_correction_asdfghjkl.core.card_detection.yolov8_det_onnx import ( + YOLOv8CardDetector, +) +from color_correction_asdfghjkl.core.correction.least_squares import ( + LeastSquaresRegression, +) +from color_correction_asdfghjkl.utils.image_processing import generate_image_patches + +ColorPatchType = NDArray[np.uint8] +ImageType = NDArray[np.uint8] + + +class ColorCorrection: + """Color correction handler using color card detection and correction models. + + Parameters + ---------- + detection_model : {'yolov8'} + The model to use for color card detection. + correction_model : {'least_squares'} + The model to use for color correction. + reference_color_card : str, optional + Path to the reference color card image. + use_gpu : bool, default=True + Whether to use GPU for card detection. + """ + + def __init__( + self, + detection_model: Literal["yolov8"] = "yolov8", + correction_model: Literal["least_squares"] = "least_squares", + reference_color_card: str | None = None, + use_gpu: bool = True, + ) -> None: + self.reference_color_card = reference_color_card or reference_color_d50 + self.correction_model = self._initialize_correction_model(correction_model) + self.card_detector = self._initialize_detector(detection_model, use_gpu) + self.correction_weights: NDArray | None = None + + def _initialize_correction_model(self, model_name: str) -> LeastSquaresRegression: + if model_name == "least_squares": + return LeastSquaresRegression() + raise ValueError(f"Unsupported correction model: {model_name}") + + def _initialize_detector( + self, + model_name: str, + use_gpu: bool, + ) -> YOLOv8CardDetector: + if model_name == "yolov8": + return YOLOv8CardDetector(use_gpu=use_gpu) + raise ValueError(f"Unsupported detection model: {model_name}") + + def extract_color_patches(self, input_image: ImageType) -> list[ColorPatchType]: + """Extract color patches from input image using card detection. + + Parameters + ---------- + input_image : NDArray + Input image from which to extract color patches. + + Returns + ------- + list[NDArray] + List of BGR mean values for each detected patch. + Each element is an array of shape (3,) containing [B, G, R] values. + """ + detection_result = self.card_detector.detect(image=input_image.copy()) + return detection_result.get_list_patches(input_image=input_image.copy()) + + def fit( + self, + input_image: ImageType, + reference_image: ImageType | None = None, + ) -> tuple[NDArray, list[ColorPatchType], list[ColorPatchType]]: + """Fit color correction model using input and reference images. + + Parameters + ---------- + input_image : NDArray + Image BGR to be corrected that contains color checker classic 24 patches. + reference_image : NDArray, optional + Image BGR to be reference that contains color checker classic 24 patches. + + Returns + ------- + Tuple[NDArray, List[NDArray], List[NDArray]] + Correction weights, input patches, and reference patches. + """ + input_patches = self.extract_color_patches(input_image=input_image) + reference_patches = ( + reference_color_d50 + if reference_image is None + else self.extract_color_patches(reference_image) + ) + + self.correction_weights = self.correction_model.fit( + input_patches=input_patches, + reference_patches=reference_patches, + ) + return self.correction_weights, input_patches, reference_patches + + def correct_image(self, input_image: ImageType) -> ImageType: + """Apply color correction to input image. + + Parameters + ---------- + input_image : NDArray + Image to be color corrected. + + Returns + ------- + NDArray + Color corrected image. + """ + if self.correction_weights is None: + raise RuntimeError("Model must be fitted before correction") + + return self.correction_model.compute_correction( + input_image=input_image.copy(), + ) + + def calculate_color_difference( + self, + image1: ImageType, + image2: ImageType, + ) -> tuple[float, float, float, float]: + """Calculate color difference metrics between two images. + + Parameters + ---------- + image1, image2 : NDArray + Images to compare in BGR format. + + Returns + ------- + Tuple[float, float, float, float] + Minimum, maximum, mean, and standard deviation of delta E values. + """ + rgb1 = cv2.cvtColor(image1, cv2.COLOR_BGR2RGB) + rgb2 = cv2.cvtColor(image2, cv2.COLOR_BGR2RGB) + + lab1 = cl.XYZ_to_Lab(cl.sRGB_to_XYZ(rgb1 / 255)) + lab2 = cl.XYZ_to_Lab(cl.sRGB_to_XYZ(rgb2 / 255)) + + delta_e = cl.difference.delta_E(lab1, lab2, method="CIE 2000") + + return ( + float(np.min(delta_e)), + float(np.max(delta_e)), + float(np.mean(delta_e)), + float(np.std(delta_e)), + ) + + +if __name__ == "__main__": + import os + + image_path = "color_correction_asdfghjkl/asset/images/cc-1.jpg" + image_path = "color_correction_asdfghjkl/asset/images/cc-19.png" + filename = os.path.basename(image_path) + cc = ColorCorrection(detection_model="yolov8", correction_model="least_squares") + input_image = cv2.imread(image_path) + _, ls_input_patches, ls_reference_patches = cc.fit(input_image=input_image) + corrected_image = cc.correct_image(input_image=input_image) + + in_img_patch = generate_image_patches(ls_input_patches) + ref_img_patch = generate_image_patches(ls_reference_patches) + cv2.imwrite(f"input_image_patches-{filename}.jpg", in_img_patch) + cv2.imwrite(f"reference_image_patches-{filename}.jpg", ref_img_patch) + cc.calculate_color_difference(in_img_patch, ref_img_patch) + + ls_correct_patch = cc.extract_color_patches(input_image=corrected_image) + corrected_img_patch = generate_image_patches(ls_correct_patch) + cv2.imwrite(f"corrected_image_patches-{filename}.jpg", corrected_img_patch) + cc.calculate_color_difference(corrected_img_patch, ref_img_patch) + cv2.imwrite(f"corrected_image-{filename}.jpg", corrected_image) diff --git a/src/color_correction_asdfghjkl/utils/geometry_processing.py b/src/color_correction_asdfghjkl/utils/geometry_processing.py new file mode 100644 index 0000000..aebf049 --- /dev/null +++ b/src/color_correction_asdfghjkl/utils/geometry_processing.py @@ -0,0 +1,215 @@ +import numpy as np +import shapely + +box_tuple = tuple[int, int, int, int] + + +def get_max_iou_shapely( + ref_box: shapely.geometry.box, + target_boxes: list[shapely.geometry.box], +) -> tuple[float, int, shapely.geometry.box]: + max_iou = 0 + max_idx = -1 + + # Compare with each target box + for idx, target_box in enumerate(target_boxes): + # Calculate intersection and union + intersection_area = ref_box.intersection(target_box).area + union_area = ref_box.union(target_box).area + + # Calculate IoU + iou = intersection_area / union_area if union_area > 0 else 0 + + # Update maximum IoU if current is larger + if iou > max_iou: + max_iou = iou + max_idx = idx + + return max_iou, max_idx, target_boxes[max_idx] + + +def box_to_xyxy(box: shapely.geometry.box) -> tuple[int, int, int, int]: + """Convert shapely box to xyxy format""" + minx, miny, maxx, maxy = box.bounds + return int(minx), int(miny), int(maxx), int(maxy) + + +def box_centroid_xy(box: shapely.geometry.box) -> tuple[int, int]: + return int(box.centroid.x), int(box.centroid.y) + + +def generate_expected_patches(card_box: box_tuple) -> list[box_tuple]: + card_x1, card_y1, card_x2, card_y2 = card_box + card_width = card_x2 - card_x1 + card_height = card_y2 - card_y1 + + # get expected grid of cards + patch_width = card_width / 6 + patch_height = card_height / 4 + + expected_patches = [] + for row in range(4): + for col in range(6): + x1 = card_x1 + col * patch_width + y1 = card_y1 + row * patch_height + x2 = x1 + patch_width + y2 = y1 + patch_height + expected_patches.append((x1, y1, x2, y2)) + + return expected_patches + + +def extract_intersecting_patches( + ls_patches: list[box_tuple], + ls_grid_card: list[box_tuple], +) -> list[box_tuple]: + ls_ordered_patch = [] + for _, grid_card in enumerate(ls_grid_card, start=1): + # get intesect patch + gx1, gy1, gx2, gy2 = grid_card + grid_box = shapely.box(*grid_card) + ls_intersect = [ + shapely.box(*xyxy) + for xyxy in ls_patches + if grid_box.intersects(shapely.box(*xyxy)) + ] + len_intersect = len(ls_intersect) + if len_intersect > 0: + max_iou, max_id, intersect_box = get_max_iou_shapely( + ref_box=grid_box, + target_boxes=ls_intersect, + ) + # intersect_box = ls_intersect[max_id] + val = box_to_xyxy(intersect_box) + ls_ordered_patch.append(val) + else: + ls_ordered_patch.append(None) + return ls_ordered_patch + + +def calculate_patch_statistics(ls_ordered_patch: list[box_tuple]) -> tuple: + ls_dx = [] + ls_dy = [] + ls_w_grid = [] + ls_h_grid = [] + for idx, patch in enumerate(ls_ordered_patch): + if patch is None: + continue + + ls_w_grid.append(patch[2] - patch[0]) + ls_h_grid.append(patch[3] - patch[1]) + + if idx not in [5, 11, 17, 23] or idx == 0: + x1 = patch[0] + next_x1 = ls_ordered_patch[idx + 1] + if next_x1 is not None: + dx = next_x1[0] - x1 + ls_dx.append(dx) + + syarat = idx + 6 + if syarat < len(ls_ordered_patch): + y1 = patch[1] + next_y1 = ls_ordered_patch[idx + 6] + if next_y1 is not None: + dy = next_y1[1] - y1 + ls_dy.append(dy) + + mean_dx = np.mean(ls_dx) + mean_dy = np.mean(ls_dy) + mean_w = np.mean(ls_w_grid) + mean_h = np.mean(ls_h_grid) + + print(ls_dx, mean_dx) + return mean_dx, mean_dy, mean_w, mean_h + + +def suggest_missing_patch_coordinates( # noqa: C901 + ls_ordered_patch: list[box_tuple], +) -> dict[int, box_tuple]: + d_suggest = {} + + mean_dx, mean_dy, mean_w, mean_h = calculate_patch_statistics( + ls_ordered_patch=ls_ordered_patch, + ) + + for idx, patch in enumerate(ls_ordered_patch): + if patch is not None: + continue + + # looking for nearest neghbor + neigh_right = None + neigh_left = None + neigh_top = None + neigh_bottom = None + + id_neigh_right = idx + 1 + id_neigh_left = idx - 1 + id_neigh_top = idx - 6 + id_neigh_bottom = idx + 6 + + if id_neigh_right not in [0, 6, 12, 18] and id_neigh_right <= 23: + neigh_right = ls_ordered_patch[id_neigh_right] + + if id_neigh_left not in [5, 11, 17, 23] and id_neigh_left >= 0: + neigh_left = ls_ordered_patch[id_neigh_left] + + if id_neigh_top >= 0: + neigh_top = ls_ordered_patch[id_neigh_top] + + if id_neigh_bottom <= 23: + neigh_bottom = ls_ordered_patch[id_neigh_bottom] + + suggested_patch = None + + # print(f"neigh_right: {neigh_right}") + # print(f"neigh_left: {neigh_left}") + # print(f"neigh_top: {neigh_top}") + # print(f"neigh_bottom: {neigh_bottom}") + + if neigh_right is not None: + # Dari kanan, geser ke kiri dengan mean_dx + x1 = neigh_right[0] - mean_dx + y1 = neigh_right[1] + suggested_patch = ( + int(x1), + int(y1), + int(x1 + mean_w), + int(y1 + mean_h), + ) + + elif neigh_left is not None: + # Dari kiri, geser ke kanan dengan mean_dx + x1 = neigh_left[0] + int(mean_dx) + y1 = neigh_left[1] + suggested_patch = ( + int(x1), + int(y1), + int(x1 + mean_w), + int(y1 + mean_h), + ) + + elif neigh_top is not None: + # Dari atas, geser ke bawah dengan mean_dy + x1 = neigh_top[0] + y1 = neigh_top[1] + mean_dy + suggested_patch = ( + int(x1), + int(y1), + int(x1 + mean_w), + int(y1 + mean_h), + ) + + elif neigh_bottom is not None: + # Dari bawah, geser ke atas dengan mean_dy + x1 = neigh_bottom[0] + y1 = neigh_bottom[1] - mean_dy + suggested_patch = ( + int(x1), + int(y1), + int(x1 + mean_w), + int(y1 + mean_h), + ) + + d_suggest[idx] = suggested_patch + + return d_suggest diff --git a/src/color_correction_asdfghjkl/utils/image_processing.py b/src/color_correction_asdfghjkl/utils/image_processing.py new file mode 100644 index 0000000..2bef3fc --- /dev/null +++ b/src/color_correction_asdfghjkl/utils/image_processing.py @@ -0,0 +1,143 @@ +import matplotlib.figure +import matplotlib.pyplot as plt +import numpy as np + + +def crop_region_with_margin( + image: np.ndarray, + coordinates: tuple[int, int, int, int], + margin_ratio: float = 0.2, +) -> np.ndarray: + """Crop a region from image with additional margin from given coordinates. + + Parameters + ---------- + image : np.ndarray + Input image array of shape (H, W, C) or (H, W). + coordinates : np.ndarray + Bounding box coordinates [x1, y1, x2, y2]. + margin_ratio : float, optional + Ratio of margin to add relative to region size, by default 0.2. + + Returns + ------- + np.ndarray + Cropped image region with margins. + """ + y1, y2 = coordinates[1], coordinates[3] + x1, x2 = coordinates[0], coordinates[2] + + height = y2 - y1 + margin_y = height * margin_ratio + width = x2 - x1 + margin_x = width * margin_ratio + + crop_y1 = int(y1 + margin_y) + crop_y2 = int(y2 - margin_y) + crop_x1 = int(x1 + margin_x) + crop_x2 = int(x2 - margin_x) + + return image[crop_y1:crop_y2, crop_x1:crop_x2] + + +def calculate_mean_rgb(img: np.ndarray) -> np.ndarray: + """Calculate mean RGB values across spatial dimensions. + + Parameters + ---------- + img : np.ndarray + Input image array of shape (H, W, C). + + Returns + ------- + np.ndarray + Array of mean RGB values, shape (C,), dtype uint8. + """ + return np.mean(img, axis=(0, 1)).astype(np.uint8) + + +def generate_image_patches( + ls_patches: list[tuple[int, int, int, int]], + patch_size: tuple[int, int, int] = (50, 50, 1), +) -> np.ndarray: + ls_stack_h = [] + ls_stack_v = [] + + for _idx, patch in enumerate(ls_patches, start=1): + patch_img = np.tile(patch, patch_size) + ls_stack_h.append(patch_img) + if _idx % 6 == 0: + row = np.hstack(ls_stack_h) + ls_stack_v.append(row) + ls_stack_h = [] + image = np.vstack(ls_stack_v).astype(np.uint8) + return image + + +def display_image_grid( + images: list[tuple[str, np.ndarray | matplotlib.figure.Figure]], + grid_size: tuple[int, int] = (2, 3), + figsize: tuple[int, int] = (15, 10), + save_path: str | None = None, + dpi: int = 300, +) -> matplotlib.figure.Figure: + """ + Display images in a grid layout with titles + + Parameters: + ----------- + images : List[Tuple[str, Union[np.ndarray, matplotlib.figure.Figure]]] + List of tuples containing (title, image) + grid_size : Tuple[int, int] + Grid layout in (rows, columns) format + figsize : Tuple[int, int] + Size of the entire figure in inches + save_path : Optional[str] + If provided, save the figure to this path + dpi : int + DPI for saved figure + + Returns: + -------- + matplotlib.figure.Figure + The figure object containing the grid + """ + + rows, cols = grid_size + fig = plt.figure(figsize=figsize) + + for idx, (title, img) in enumerate(images): + if idx >= rows * cols: + print( + f"Warning: Only showing first {rows * cols} images due to " + "grid size limitation", + ) + break + + ax = fig.add_subplot(rows, cols, idx + 1) + + # Handle different image types + if isinstance(img, np.ndarray): + if len(img.shape) == 2: # Grayscale + ax.imshow(img, cmap="gray") + else: # RGB/RGBA + ax.imshow(img) + elif isinstance(img, matplotlib.figure.Figure): + # Convert matplotlib figure to image array + fig.canvas.draw() + img_array = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8) + img_array = img_array.reshape(fig.canvas.get_width_height()[::-1] + (3,)) + ax.imshow(img_array) + + ax.set_title(title) + ax.axis("off") + + plt.tight_layout() + + # Save figure if path is provided + if save_path: + fig.savefig(save_path, dpi=dpi, bbox_inches="tight") + print(f"Figure saved to: {save_path}") + + plt.close() # Close the figure to free memory + return fig diff --git a/src/color_correction_asdfghjkl/utils/yolo_utils.py b/src/color_correction_asdfghjkl/utils/yolo_utils.py index e8b8044..b2bf62d 100644 --- a/src/color_correction_asdfghjkl/utils/yolo_utils.py +++ b/src/color_correction_asdfghjkl/utils/yolo_utils.py @@ -70,7 +70,6 @@ def nms(boxes: np.ndarray, scores: np.ndarray, iou_threshold: float) -> list[int ious = compute_iou(boxes[box_id, :], boxes[sorted_indices[1:], :]) # Remove boxes with IoU over the threshold, - print("ious:", ious) keep_indices = np.where(ious < iou_threshold)[0] # update sorted_indices diff --git a/uv.lock b/uv.lock index cc92f0a..1cac344 100644 --- a/uv.lock +++ b/uv.lock @@ -129,9 +129,11 @@ name = "color-correction-asdfghjkl" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "colour-science" }, { name = "httpx" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "shapely" }, ] [package.dev-dependencies] @@ -159,9 +161,11 @@ yolodet = [ [package.metadata] requires-dist = [ + { name = "colour-science", specifier = ">=0.4.6" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.10.5" }, { name = "pydantic-settings", specifier = ">=2.7.1" }, + { name = "shapely", specifier = ">=2.0.6" }, ] [package.metadata.requires-dev] @@ -200,6 +204,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018 }, ] +[[package]] +name = "colour-science" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, + { name = "numpy", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + { name = "scipy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/29/4ea0082b8ad8c5e18b9ac7cf7ad5f21b70e10976ed53b877773ead3c268d/colour_science-0.4.6.tar.gz", hash = "sha256:be98c2c9b2a5caf0c443431f402599ca9e1cc7d944bb804156803bcc97af4cf0", size = 2228183 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/3e/7a39e00d11a58ab6aa75985433ad4b8001eabf965c20280ed22ed1512887/colour_science-0.4.6-py3-none-any.whl", hash = "sha256:4cd90e6d500c16f3c24225da57031e1944de52fec6b484f5bb3d4ea7d0cfee08", size = 2480689 }, +] + [[package]] name = "contourpy" version = "1.3.1" @@ -473,6 +493,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "imageio" +version = "2.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, + { name = "numpy", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/47/57e897fb7094afb2d26e8b2e4af9a45c7cf1a405acdeeca001fdf2c98501/imageio-2.37.0.tar.gz", hash = "sha256:71b57b3669666272c818497aebba2b4c5f20d5b37c81720e5e1a56d59c492996", size = 389963 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/bd/b394387b598ed84d8d0fa90611a90bee0adc2021820ad5729f7ced74a8e2/imageio-2.37.0-py3-none-any.whl", hash = "sha256:11efa15b87bc7871b61590326b2d635439acc321cf7f8ce996f812543ce10eed", size = 315796 }, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -1525,6 +1559,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914 }, ] +[[package]] +name = "shapely" +version = "2.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, + { name = "numpy", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/89/0d20bac88016be35ff7d3c0c2ae64b477908f1b1dfa540c5d69ac7af07fe/shapely-2.0.6.tar.gz", hash = "sha256:997f6159b1484059ec239cacaa53467fd8b5564dabe186cd84ac2944663b0bf6", size = 282361 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d4/f84bbbdb7771f5b9ade94db2398b256cf1471f1eb0ca8afbe0f6ca725d5a/shapely-2.0.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29a34e068da2d321e926b5073539fd2a1d4429a2c656bd63f0bd4c8f5b236d0b", size = 1449635 }, + { url = "https://files.pythonhosted.org/packages/03/10/bd6edb66ed0a845f0809f7ce653596f6fd9c6be675b3653872f47bf49f82/shapely-2.0.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c84c3f53144febf6af909d6b581bc05e8785d57e27f35ebaa5c1ab9baba13b", size = 1296756 }, + { url = "https://files.pythonhosted.org/packages/af/09/6374c11cb493a9970e8c04d7be25f578a37f6494a2fecfbed3a447b16b2c/shapely-2.0.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad2fae12dca8d2b727fa12b007e46fbc522148a584f5d6546c539f3464dccde", size = 2381960 }, + { url = "https://files.pythonhosted.org/packages/2b/a6/302e0d9c210ccf4d1ffadf7ab941797d3255dcd5f93daa73aaf116a4db39/shapely-2.0.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3304883bd82d44be1b27a9d17f1167fda8c7f5a02a897958d86c59ec69b705e", size = 2468133 }, + { url = "https://files.pythonhosted.org/packages/8c/be/e448681dc485f2931d4adee93d531fce93608a3ee59433303cc1a46e21a5/shapely-2.0.6-cp310-cp310-win32.whl", hash = "sha256:3ec3a0eab496b5e04633a39fa3d5eb5454628228201fb24903d38174ee34565e", size = 1294982 }, + { url = "https://files.pythonhosted.org/packages/cd/4c/6f4a6fc085e3be01c4c9de0117a2d373bf9fec5f0426cf4d5c94090a5a4d/shapely-2.0.6-cp310-cp310-win_amd64.whl", hash = "sha256:28f87cdf5308a514763a5c38de295544cb27429cfa655d50ed8431a4796090c4", size = 1441141 }, + { url = "https://files.pythonhosted.org/packages/37/15/269d8e1f7f658a37e61f7028683c546f520e4e7cedba1e32c77ff9d3a3c7/shapely-2.0.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5aeb0f51a9db176da9a30cb2f4329b6fbd1e26d359012bb0ac3d3c7781667a9e", size = 1449578 }, + { url = "https://files.pythonhosted.org/packages/37/63/e182e43081fffa0a2d970c480f2ef91647a6ab94098f61748c23c2a485f2/shapely-2.0.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9a7a78b0d51257a367ee115f4d41ca4d46edbd0dd280f697a8092dd3989867b2", size = 1296792 }, + { url = "https://files.pythonhosted.org/packages/6e/5a/d019f69449329dcd517355444fdb9ddd58bec5e080b8bdba007e8e4c546d/shapely-2.0.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f32c23d2f43d54029f986479f7c1f6e09c6b3a19353a3833c2ffb226fb63a855", size = 2443997 }, + { url = "https://files.pythonhosted.org/packages/25/aa/53f145e5a610a49af9ac49f2f1be1ec8659ebd5c393d66ac94e57c83b00e/shapely-2.0.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3dc9fb0eb56498912025f5eb352b5126f04801ed0e8bdbd867d21bdbfd7cbd0", size = 2528334 }, + { url = "https://files.pythonhosted.org/packages/64/64/0c7b0a22b416d36f6296b92bb4219d82b53d0a7c47e16fd0a4c85f2f117c/shapely-2.0.6-cp311-cp311-win32.whl", hash = "sha256:d93b7e0e71c9f095e09454bf18dad5ea716fb6ced5df3cb044564a00723f339d", size = 1294669 }, + { url = "https://files.pythonhosted.org/packages/b1/5a/6a67d929c467a1973b6bb9f0b00159cc343b02bf9a8d26db1abd2f87aa23/shapely-2.0.6-cp311-cp311-win_amd64.whl", hash = "sha256:c02eb6bf4cfb9fe6568502e85bb2647921ee49171bcd2d4116c7b3109724ef9b", size = 1442032 }, + { url = "https://files.pythonhosted.org/packages/46/77/efd9f9d4b6a762f976f8b082f54c9be16f63050389500fb52e4f6cc07c1a/shapely-2.0.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cec9193519940e9d1b86a3b4f5af9eb6910197d24af02f247afbfb47bcb3fab0", size = 1450326 }, + { url = "https://files.pythonhosted.org/packages/68/53/5efa6e7a4036a94fe6276cf7bbb298afded51ca3396b03981ad680c8cc7d/shapely-2.0.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83b94a44ab04a90e88be69e7ddcc6f332da7c0a0ebb1156e1c4f568bbec983c3", size = 1298480 }, + { url = "https://files.pythonhosted.org/packages/88/a2/1be1db4fc262e536465a52d4f19d85834724fedf2299a1b9836bc82fe8fa/shapely-2.0.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:537c4b2716d22c92036d00b34aac9d3775e3691f80c7aa517c2c290351f42cd8", size = 2439311 }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9a57e187cbf2fbbbdfd4044a4f9ce141c8d221f9963750d3b001f0ec080d/shapely-2.0.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fea108334be345c283ce74bf064fa00cfdd718048a8af7343c59eb40f59726", size = 2524835 }, + { url = "https://files.pythonhosted.org/packages/6d/0a/f407509ab56825f39bf8cfce1fb410238da96cf096809c3e404e5bc71ea1/shapely-2.0.6-cp312-cp312-win32.whl", hash = "sha256:42fd4cd4834747e4990227e4cbafb02242c0cffe9ce7ef9971f53ac52d80d55f", size = 1295613 }, + { url = "https://files.pythonhosted.org/packages/7b/b3/857afd9dfbfc554f10d683ac412eac6fa260d1f4cd2967ecb655c57e831a/shapely-2.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:665990c84aece05efb68a21b3523a6b2057e84a1afbef426ad287f0796ef8a48", size = 1442539 }, + { url = "https://files.pythonhosted.org/packages/34/e8/d164ef5b0eab86088cde06dee8415519ffd5bb0dd1bd9d021e640e64237c/shapely-2.0.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:42805ef90783ce689a4dde2b6b2f261e2c52609226a0438d882e3ced40bb3013", size = 1445344 }, + { url = "https://files.pythonhosted.org/packages/ce/e2/9fba7ac142f7831757a10852bfa465683724eadbc93d2d46f74a16f9af04/shapely-2.0.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d2cb146191a47bd0cee8ff5f90b47547b82b6345c0d02dd8b25b88b68af62d7", size = 1296182 }, + { url = "https://files.pythonhosted.org/packages/cf/dc/790d4bda27d196cd56ec66975eaae3351c65614cafd0e16ddde39ec9fb92/shapely-2.0.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3fdef0a1794a8fe70dc1f514440aa34426cc0ae98d9a1027fb299d45741c381", size = 2423426 }, + { url = "https://files.pythonhosted.org/packages/af/b0/f8169f77eac7392d41e231911e0095eb1148b4d40c50ea9e34d999c89a7e/shapely-2.0.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c665a0301c645615a107ff7f52adafa2153beab51daf34587170d85e8ba6805", size = 2513249 }, + { url = "https://files.pythonhosted.org/packages/f6/1d/a8c0e9ab49ff2f8e4dedd71b0122eafb22a18ad7e9d256025e1f10c84704/shapely-2.0.6-cp313-cp313-win32.whl", hash = "sha256:0334bd51828f68cd54b87d80b3e7cee93f249d82ae55a0faf3ea21c9be7b323a", size = 1294848 }, + { url = "https://files.pythonhosted.org/packages/23/38/2bc32dd1e7e67a471d4c60971e66df0bdace88656c47a9a728ace0091075/shapely-2.0.6-cp313-cp313-win_amd64.whl", hash = "sha256:d37d070da9e0e0f0a530a621e17c0b8c3c9d04105655132a87cfff8bd77cc4c2", size = 1441371 }, +] + [[package]] name = "six" version = "1.17.0"