Source code for arcade.hitbox.pymunk

import pymunk
from PIL.Image import Image
from pymunk import Vec2d
from pymunk.autogeometry import (
    PolylineSet,
    march_soft,
    simplify_curves,
)

from arcade.types import RGBA255, Point2, Point2List

from .base import HitBoxAlgorithm


[docs] class PymunkHitBoxAlgorithm(HitBoxAlgorithm): """ Hit box point algorithm that uses pymunk to calculate the points. This is a more accurate algorithm generating more points. The point count can be controlled with the ``detail`` parameter. """ #: The default detail when creating a new instance. default_detail = 4.5 def __init__(self, *, detail: float | None = None): super().__init__() self.detail = detail or self.default_detail self._cache_name += f"|detail={self.detail}"
[docs] def __call__(self, *, detail: float | None = None) -> "PymunkHitBoxAlgorithm": """Create a new instance with new default values""" return PymunkHitBoxAlgorithm(detail=detail or self.detail)
[docs] def calculate(self, image: Image, detail: float | None = None, **kwargs) -> Point2List: """ Given an RGBA image, this returns points that make up a hit box around it. Args: image: Image get hit box from. detail: How detailed to make the hit box. There's a trade-off in number of points vs. accuracy. """ hit_box_detail = detail or self.detail if image.mode != "RGBA": raise ValueError("Image mode is not RGBA. image.convert('RGBA') is needed.") # Trace the image finding all the outlines and holes line_sets = self.trace_image(image) if len(line_sets) == 0: return self.create_bounding_box(image) # Get the largest line set line_set = self.select_largest_line_set(line_sets) # Reduce number of vertices if len(line_set) > 4: line_set = simplify_curves(line_set, hit_box_detail) return self.to_points_list(image, line_set)
[docs] def to_points_list(self, image: Image, line_set: list[Vec2d]) -> Point2List: """ Convert a line set to a list of points. Coordinates are offset so ``(0,0)`` is the center of the image. Args: image: Image to trace. line_set: Line set to convert. """ # Convert to normal points, offset fo 0,0 is center, flip the y hh = image.height / 2.0 hw = image.width / 2.0 points = [] for vec2 in line_set: point_tuple = ( float(round(vec2.x - hw)), float(round(image.height - (vec2.y - hh) - image.height)), ) points.append(point_tuple) # Remove duplicate end point if len(points) > 1 and points[0] == points[-1]: points.pop() # Return immutable data return tuple(points)
[docs] def trace_image(self, image: Image) -> PolylineSet: """ Trace the image and return a :py:class:~collections.abc.Sequence` of line sets. .. important:: The image :py:attr:`~PIL.Image.Image.mode` must be ``"RGBA"``! * This method raises a :py:class:`TypeError` when it isn't * Use :py:meth:`convert("RGBA") <PIL.Image.Image.convert>` to convert The returned object will be a :py:mod:`pymunk` :py:class:`~pymunk.autogeometry.PolylineSet`. Each :py:class:`list` inside it will contain points as :py:class:`pymunk.vec2d.Vec2d` instances. These lists may represent: * the outline of the image's contents * the holes in the image When this method returns more than one line set, it's important to pick the one which covers the largest portion of the image. Args: image: A :py:class:`PIL.Image.Image` to trace. Returns: A :py:mod:`pymunk` object which is a :py:class:`~collections.abc.Sequence` of :py:class:`~pymunk.autogeometry.PolylineSet` of line sets. """ if image.mode != "RGBA": raise ValueError("Image's mode!='RGBA'! Try using image.convert(\"RGBA\").") def sample_func(sample_point: Point2) -> int: """Function used to sample image.""" # Return 0 when outside of bounds if ( sample_point[0] < 0 or sample_point[1] < 0 or sample_point[0] >= image.width or sample_point[1] >= image.height ): return 0 point_tuple = int(sample_point[0]), int(sample_point[1]) color: RGBA255 = image.getpixel(point_tuple) # type: ignore return 255 if color[3] > 0 else 0 # Do a quick check if it is a full tile # Points are pixel coordinates px1 = 0, 0 px2 = 0, image.height - 1 px3 = image.width - 1, image.height - 1 px4 = image.width - 1, 0 if sample_func(px1) and sample_func(px2) and sample_func(px3) and sample_func(px4): # Actual points are in world coordinates p1 = 0.0, 0.0 p2 = 0.0, float(image.height) p3 = float(image.width), float(image.height) p4 = float(image.width), 0.0 # Manually create a line set points = PolylineSet() points.collect_segment(p2, p3) points.collect_segment(p3, p4) points.collect_segment(p4, p1) return points # Get the bounding box logo_bb = pymunk.BB(-1, -1, image.width, image.height) # How often to sample? down_res = 1 horizontal_samples = int(image.width / down_res) vertical_samples = int(image.height / down_res) # Run the trace # Get back one or more sets of lines covering stuff. # We want the one that covers the most of the sprite # or the line set might just be a hole in the sprite. return march_soft(logo_bb, horizontal_samples, vertical_samples, 99, sample_func)
[docs] def select_largest_line_set(self, line_sets: PolylineSet) -> list[Vec2d]: """ Given a list of line sets, return the one that covers the most of the image. Args: line_sets: List of line sets. """ if len(line_sets) == 1: return line_sets[0] # We have more than one line set. # Try and find one that covers most of the sprite. selected_line_set = line_sets[0] largest_area = -1.0 for line_set in line_sets: min_x = None min_y = None max_x = None max_y = None for point in line_set: if min_x is None or point.x < min_x: min_x = point.x if max_x is None or point.x > max_x: max_x = point.x if min_y is None or point.y < min_y: min_y = point.y if max_y is None or point.y > max_y: max_y = point.y if min_x is None or max_x is None or min_y is None or max_y is None: raise ValueError("No points in bounding box.") # Calculate the area of the bounding box area = (max_x - min_x) * (max_y - min_y) if area > largest_area: largest_area = area selected_line_set = line_set return selected_line_set