We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/joeyballentine/paint-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
"""Drawing engine: pygame.Surface wrapper with draw ops and undo stack."""
import math
import random
import pygame
import numpy as np
from dataclasses import dataclass
VALID_BRUSH_SHAPES = ("round", "flat", "filbert", "fan", "palette_knife")
@dataclass
class DrawState:
color: tuple = (0, 0, 0)
brush_size: int = 3
background_color: tuple = (255, 255, 255)
oil_paint: bool = False
brush_shape: str = "round"
class Canvas:
MAX_UNDO = 50
def __init__(self, width: int, height: int):
self.width = width
self.height = height
self.state = DrawState()
self.surface = pygame.Surface((width, height))
self.surface.fill(self.state.background_color)
self._undo_stack: list[pygame.Surface] = []
def _save_undo(self):
if len(self._undo_stack) >= self.MAX_UNDO:
self._undo_stack.pop(0)
self._undo_stack.append(self.surface.copy())
def undo(self) -> bool:
if not self._undo_stack:
return False
self.surface = self._undo_stack.pop()
return True
def execute(self, cmd: dict):
action = cmd.get("action")
method = getattr(self, f"_do_{action}", None)
if method is None:
raise ValueError(f"Unknown action: {action}")
method(cmd)
# --- State operations (no undo) ---
def _do_set_color(self, cmd: dict):
self.state.color = (cmd["r"], cmd["g"], cmd["b"])
def _do_set_brush_size(self, cmd: dict):
self.state.brush_size = cmd["size"]
def _do_set_oil_paint(self, cmd: dict):
self.state.oil_paint = cmd["enabled"]
def _do_set_brush_shape(self, cmd: dict):
shape = cmd["shape"]
if shape in VALID_BRUSH_SHAPES:
self.state.brush_shape = shape
# --- Oil paint helpers ---
def _sample_color(self, x: int, y: int) -> tuple:
"""Read the RGB color of a single pixel on the canvas."""
x = max(0, min(x, self.width - 1))
y = max(0, min(y, self.height - 1))
return tuple(self.surface.get_at((x, y))[:3])
def _avg_color_around(self, cx: int, cy: int, radius: int) -> tuple:
"""Average the canvas color in a disc around (cx, cy), sampling
uniformly in all directions (N/S/E/W and diagonals)."""
r_sum, g_sum, b_sum, count = 0, 0, 0, 0
step = max(1, radius // 3)
for dx in range(-radius, radius + 1, step):
for dy in range(-radius, radius + 1, step):
if dx * dx + dy * dy <= radius * radius:
c = self._sample_color(cx + dx, cy + dy)
r_sum += c[0]; g_sum += c[1]; b_sum += c[2]
count += 1
if count == 0:
return self._sample_color(cx, cy)
return (r_sum // count, g_sum // count, b_sum // count)
@staticmethod
def _blend(base: tuple, top: tuple, ratio: float = 0.5) -> tuple:
"""Mix two colors. ratio=1.0 means all top, 0.0 means all base."""
return tuple(int(b * (1 - ratio) + t * ratio) for b, t in zip(base, top))
def _oil_dab(self, cx: int, cy: int, radius: int, brush_color: tuple = None,
paint_strength: float = 0.75, direction: float = 0.0):
"""Paint a single oil-paint dab, dispatching to shape-specific method.
brush_color: the paint on the brush (defaults to state color).
paint_strength (0..1): how much brush color vs canvas color.
direction: stroke direction in radians (0 = right).
"""
if brush_color is None:
brush_color = self.state.color
shape = self.state.brush_shape
if shape == "round":
self._dab_round(cx, cy, radius, brush_color, paint_strength)
elif shape == "flat":
self._dab_flat(cx, cy, radius, brush_color, paint_strength, direction)
elif shape == "filbert":
self._dab_filbert(cx, cy, radius, brush_color, paint_strength, direction)
elif shape == "fan":
self._dab_fan(cx, cy, radius, brush_color, paint_strength, direction)
elif shape == "palette_knife":
self._dab_knife(cx, cy, radius, brush_color, paint_strength, direction)
else:
self._dab_round(cx, cy, radius, brush_color, paint_strength)
def _dab_round(self, cx: int, cy: int, radius: int, brush_color: tuple,
paint_strength: float):
"""Original circular dab with soft edges and concentric rings."""
# Paint concentric rings from outside in; each ring blends more brush
rings = max(2, radius // 2)
for ring in range(rings, -1, -1):
t = ring / max(rings, 1) # 1.0 at edge, 0.0 at center
ring_r = max(1, int(radius * (t + 0.05)))
# Cubic falloff for softer edges on large brushes
edge_blend = t * t * t
center_ratio = paint_strength * (1.0 - edge_blend * 0.85)
canvas_color = self._avg_color_around(cx, cy, ring_r)
blended = self._blend(canvas_color, brush_color, center_ratio)
# Irregular ring with jittered radius for painterly texture
for angle_step in range(0, 360, max(4, 8 - radius // 5)):
a = math.radians(angle_step)
r_jitter = ring_r * random.uniform(0.8, 1.0)
ex = int(cx + r_jitter * math.cos(a))
ey = int(cy + r_jitter * math.sin(a))
pygame.draw.line(self.surface, blended, (cx, cy), (ex, ey), 1)
def _dab_shaped(self, cx: int, cy: int, radius: int, brush_color: tuple,
paint_strength: float, direction: float,
half_w: float, half_h: float, is_ellipse: bool = False,
hard_edge: bool = False):
"""Generic shaped dab renderer using numpy bulk ops.
half_w, half_h: half-extents in local coords (before rotation).
is_ellipse: True for elliptical mask (filbert), False for rectangular.
hard_edge: True for minimal falloff (palette knife).
"""
cos_d = math.cos(-direction)
sin_d = math.sin(-direction)
# Axis-aligned bounding box of the rotated shape
extent = int(math.ceil(max(half_w, half_h))) + 1
x_lo = max(0, cx - extent)
x_hi = min(self.width, cx + extent + 1)
y_lo = max(0, cy - extent)
y_hi = min(self.height, cy + extent + 1)
if x_lo >= x_hi or y_lo >= y_hi:
return
# Build coordinate grids for the bounding box
ys = np.arange(y_lo, y_hi, dtype=np.float32)
xs = np.arange(x_lo, x_hi, dtype=np.float32)
gx, gy = np.meshgrid(xs, ys) # (rows, cols)
dx = gx - cx
dy = gy - cy
# Rotate to local (brush-aligned) coordinates
lx = dx * cos_d - dy * sin_d
ly = dx * sin_d + dy * cos_d
# Compute shape mask and normalized distance t (0=center, 1=edge)
if is_ellipse:
nx = lx / half_w if half_w > 0 else lx * 0
ny = ly / half_h if half_h > 0 else ly * 0
dist_sq = nx * nx + ny * ny
mask = dist_sq <= 1.0
t = np.sqrt(dist_sq)
else:
abs_lx = np.abs(lx)
abs_ly = np.abs(ly)
mask = (abs_lx <= half_w) & (abs_ly <= half_h)
tx = abs_lx / half_w if half_w > 0 else abs_lx * 0
ty = abs_ly / half_h if half_h > 0 else abs_ly * 0
t = np.maximum(tx, ty)
# Painterly jitter: randomly skip ~5% of edge pixels
jitter = np.random.random(t.shape).astype(np.float32)
mask = mask & ~((t > 0.7) & (jitter < 0.05))
if not np.any(mask):
return
# --- Bristle texture ---
# Simulate individual bristle tracks across the brush width (ly axis).
# Each bristle band is ~1.5px wide with its own random intensity,
# creating visible streaks along the stroke direction.
bristle_spacing = 1.5
bristle_count = max(1, int(2 * half_h / bristle_spacing))
# Quantize ly into bristle bands
bristle_idx = np.floor((ly + half_h) / bristle_spacing).astype(np.int32)
np.clip(bristle_idx, 0, bristle_count, out=bristle_idx)
# Random intensity per bristle (some bristles carry more paint)
bristle_strengths = np.random.uniform(0.55, 1.0,
size=bristle_count + 1).astype(np.float32)
bristle_mod = bristle_strengths[bristle_idx]
# Add fine per-pixel noise on top for grain
pixel_noise = np.random.uniform(0.88, 1.0, size=t.shape).astype(np.float32)
bristle_mod *= pixel_noise
# Palette knife gets minimal bristle texture (hard, smooth edge)
if hard_edge:
bristle_mod = bristle_mod * 0.3 + 0.7 # dampen toward uniform
# Compute per-pixel blend strength
if hard_edge:
strength = np.where(mask, paint_strength * 0.95, 0.0).astype(np.float32)
else:
edge_blend = t * t * t
strength = np.where(mask,
paint_strength * (1.0 - edge_blend * 0.85),
0.0).astype(np.float32)
# Apply bristle texture to strength
strength *= bristle_mod
# Read canvas pixels for the region — surfarray is (W, H, 3),
# so slice as [x_lo:x_hi, y_lo:y_hi] then transpose to (rows, cols, 3).
raw = pygame.surfarray.pixels3d(self.surface)
region = raw[x_lo:x_hi, y_lo:y_hi].transpose(1, 0, 2).astype(np.float32)
del raw # release surface lock
# Blend: result = canvas * (1 - s) + brush * s
brush_arr = np.array(brush_color, dtype=np.float32)
s = strength[:, :, np.newaxis] # (rows, cols, 1)
blended = region * (1.0 - s) + brush_arr * s
np.clip(blended, 0, 255, out=blended)
# Write back only masked pixels
raw = pygame.surfarray.pixels3d(self.surface)
# Transpose blended back to (cols, rows, 3) for surfarray
blended_t = blended.transpose(1, 0, 2).astype(np.uint8)
# Build transposed mask (cols, rows) matching surfarray layout
mask_t = mask.T
raw[x_lo:x_hi, y_lo:y_hi][mask_t] = blended_t[mask_t]
del raw # release surface lock
def _dab_flat(self, cx: int, cy: int, radius: int, brush_color: tuple,
paint_strength: float, direction: float):
"""Flat brush: wide rectangle, aspect ratio ~3:1."""
half_w = radius * 1.5 # wide along stroke
half_h = radius * 0.5 # narrow perpendicular
self._dab_shaped(cx, cy, radius, brush_color, paint_strength, direction,
half_w, half_h, is_ellipse=False)
def _dab_filbert(self, cx: int, cy: int, radius: int, brush_color: tuple,
paint_strength: float, direction: float):
"""Filbert brush: oval, aspect ratio ~2.5:1, soft edges."""
half_w = radius * 1.25
half_h = radius * 0.5
self._dab_shaped(cx, cy, radius, brush_color, paint_strength, direction,
half_w, half_h, is_ellipse=True)
def _dab_fan(self, cx: int, cy: int, radius: int, brush_color: tuple,
paint_strength: float, direction: float):
"""Fan brush: very wide, very thin rectangle, aspect ratio ~6:1."""
half_w = radius * 3.0
half_h = radius * 0.5
# Fan brush has lighter paint application
self._dab_shaped(cx, cy, radius, brush_color, paint_strength * 0.6,
direction, half_w, half_h, is_ellipse=False)
def _dab_knife(self, cx: int, cy: int, radius: int, brush_color: tuple,
paint_strength: float, direction: float):
"""Palette knife: hard-edged rectangle, aspect ratio ~4:1."""
half_w = radius * 2.0
half_h = radius * 0.5
self._dab_shaped(cx, cy, radius, brush_color, paint_strength, direction,
half_w, half_h, is_ellipse=False, hard_edge=True)
def _oil_stroke(self, points: list[tuple[int, int]]):
"""Lay down oil-paint dabs along a series of points.
Simulates a loaded brush: paint starts strong and depletes
exponentially. The brush also *picks up* canvas color as it
moves, so the carried pigment shifts toward whatever it drags
over — just like real oil paint."""
n = len(points)
if n == 0:
return
# --- paint-load parameters ---
# Half-life in dab-steps: after this many dabs, paint is at 50%
half_life = 140
decay = 0.5 ** (1.0 / half_life) # per-step multiplier
paint_load = 0.95 # current opacity of brush paint
carried_color = self.state.color # what's on the brush right now
pickup_rate = 0.03 # how fast canvas color mixes in
for i, (px, py) in enumerate(points):
# Compute stroke direction from surrounding points
if i < n - 1:
direction = math.atan2(points[i + 1][1] - py,
points[i + 1][0] - px)
elif i > 0:
direction = math.atan2(py - points[i - 1][1],
px - points[i - 1][0])
else:
direction = 0.0
# Sample what's under the brush before we stamp
canvas_color = self._avg_color_around(px, py, self.state.brush_size)
# The brush picks up a bit of canvas color each dab
carried_color = self._blend(canvas_color, carried_color,
1.0 - pickup_rate)
size_jitter = random.uniform(0.88, 1.12)
# Brush also loses width as paint runs out
size_factor = 0.8 + 0.2 * paint_load
r = max(1, int(self.state.brush_size * size_jitter * size_factor))
self._oil_dab(px, py, r, brush_color=carried_color,
paint_strength=paint_load, direction=direction)
# Exponential depletion
paint_load *= decay
# Floor so the tail doesn't become invisible
paint_load = max(paint_load, 0.18)
def _blend_dab(self, cx: int, cy: int, radius: int, strength: float = 0.15,
dx_dir: float = 0.0, dy_dir: float = 0.0):
"""Smudge/blend dab: pulls colors in the stroke direction.
Samples in all directions around the point, weighted so pixels
*behind* the stroke direction contribute more (simulating a
palette knife dragging paint forward). strength is capped at
0.25 to keep blending subtle."""
strength = min(strength, 0.25)
# If we have a stroke direction, weight samples behind the motion
has_dir = abs(dx_dir) + abs(dy_dir) > 0.01
if has_dir:
mag = math.hypot(dx_dir, dy_dir)
ndx, ndy = dx_dir / mag, dy_dir / mag
else:
ndx, ndy = 0.0, 0.0
# Gather directional weighted average
r_sum, g_sum, b_sum, w_sum = 0.0, 0.0, 0.0, 0.0
step = max(1, radius // 3)
for sx in range(-radius, radius + 1, step):
for sy in range(-radius, radius + 1, step):
dist_sq = sx * sx + sy * sy
if dist_sq > radius * radius:
continue
c = self._sample_color(cx + sx, cy + sy)
# Distance weight: closer = more influence
dist = math.sqrt(dist_sq) if dist_sq > 0 else 0.1
w = 1.0 / (1.0 + dist * 0.3)
# Directional weight: behind the stroke = more influence
if has_dir and dist > 0.1:
dot = (-sx * ndx + -sy * ndy) / dist
w *= (1.0 + max(0, dot) * 1.5)
r_sum += c[0] * w; g_sum += c[1] * w; b_sum += c[2] * w
w_sum += w
if w_sum < 0.01:
return
avg = (int(r_sum / w_sum), int(g_sum / w_sum), int(b_sum / w_sum))
# Paint concentric rings, fading toward edge
rings = max(2, radius // 2)
for ring in range(rings, -1, -1):
t = ring / max(rings, 1)
ring_r = max(1, int(radius * (t + 0.05)))
local_str = strength * (1.0 - t * t * 0.7)
for angle_step in range(0, 360, max(4, 8 - radius // 5)):
a = math.radians(angle_step)
r_jitter = ring_r * random.uniform(0.88, 1.0)
ex = int(cx + r_jitter * math.cos(a))
ey = int(cy + r_jitter * math.sin(a))
pixel_color = self._sample_color(ex, ey)
blended = self._blend(pixel_color, avg, local_str)
pygame.draw.line(self.surface, blended, (cx, cy), (ex, ey), 1)
def _blend_stroke(self, points: list[tuple[int, int]], strength: float = 0.15):
"""Run the blend brush along a series of points, using stroke
direction to weight the smudge like a palette knife."""
for i, (px, py) in enumerate(points):
# Compute local stroke direction from surrounding points
dx_dir, dy_dir = 0.0, 0.0
if i > 0:
dx_dir = px - points[i - 1][0]
dy_dir = py - points[i - 1][1]
elif i < len(points) - 1:
dx_dir = points[i + 1][0] - px
dy_dir = points[i + 1][1] - py
size_jitter = random.uniform(0.92, 1.08)
r = max(1, int(self.state.brush_size * size_jitter))
self._blend_dab(px, py, r, strength, dx_dir, dy_dir)
@staticmethod
def _interpolate(x1, y1, x2, y2, spacing=3) -> list[tuple[int, int]]:
"""Return evenly-spaced points along a line segment."""
dx, dy = x2 - x1, y2 - y1
dist = math.hypot(dx, dy)
steps = max(1, int(dist / spacing))
return [(int(x1 + dx * t / steps), int(y1 + dy * t / steps))
for t in range(steps + 1)]
# --- Normal-mode shape helpers ---
def _shape_extents(self, radius: int) -> tuple[float, float]:
"""Return (half_w, half_h) for the current brush shape at given radius."""
shape = self.state.brush_shape
if shape == "flat":
return radius * 1.5, radius * 0.5
elif shape == "filbert":
return radius * 1.25, radius * 0.5
elif shape == "fan":
return radius * 3.0, radius * 0.5
elif shape == "palette_knife":
return radius * 2.0, radius * 0.5
return radius, radius # round
def _draw_shape_polygon(self, cx: int, cy: int, radius: int,
direction: float = 0.0):
"""Draw a filled rotated polygon for non-round shapes in normal mode."""
shape = self.state.brush_shape
half_w, half_h = self._shape_extents(radius)
if shape == "filbert":
# Approximate ellipse with polygon points
cos_d = math.cos(direction)
sin_d = math.sin(direction)
pts = []
for a_step in range(0, 360, 15):
a = math.radians(a_step)
lx = half_w * math.cos(a)
ly = half_h * math.sin(a)
wx = cx + lx * cos_d - ly * sin_d
wy = cy + lx * sin_d + ly * cos_d
pts.append((int(wx), int(wy)))
if len(pts) >= 3:
pygame.draw.polygon(self.surface, self.state.color, pts)
else:
# Rectangle shapes (flat, fan, palette_knife)
cos_d = math.cos(direction)
sin_d = math.sin(direction)
corners = [(-half_w, -half_h), (half_w, -half_h),
(half_w, half_h), (-half_w, half_h)]
pts = []
for lx, ly in corners:
wx = cx + lx * cos_d - ly * sin_d
wy = cy + lx * sin_d + ly * cos_d
pts.append((int(wx), int(wy)))
pygame.draw.polygon(self.surface, self.state.color, pts)
def _normal_draw_point(self, x: int, y: int):
"""Draw a single point in normal mode, respecting brush shape."""
if self.state.brush_shape == "round":
pygame.draw.circle(self.surface, self.state.color,
(x, y), self.state.brush_size)
else:
self._draw_shape_polygon(x, y, self.state.brush_size, 0.0)
def _normal_draw_line(self, x1: int, y1: int, x2: int, y2: int):
"""Draw a line in normal mode, respecting brush shape."""
if self.state.brush_shape == "round":
pygame.draw.line(self.surface, self.state.color,
(x1, y1), (x2, y2), self.state.brush_size)
else:
direction = math.atan2(y2 - y1, x2 - x1)
pts = self._interpolate(x1, y1, x2, y2)
for px, py in pts:
self._draw_shape_polygon(px, py, self.state.brush_size, direction)
def _normal_draw_path(self, points: list):
"""Draw a path in normal mode, respecting brush shape."""
if self.state.brush_shape == "round":
pygame.draw.lines(self.surface, self.state.color, False,
points, self.state.brush_size)
else:
for i in range(len(points) - 1):
p1, p2 = points[i], points[i + 1]
direction = math.atan2(p2[1] - p1[1], p2[0] - p1[0])
seg_pts = self._interpolate(p1[0], p1[1], p2[0], p2[1])
for px, py in seg_pts:
self._draw_shape_polygon(px, py, self.state.brush_size,
direction)
# --- Drawing operations (save undo first) ---
def _do_draw_point(self, cmd: dict):
self._save_undo()
if self.state.oil_paint:
self._oil_dab(cmd["x"], cmd["y"], self.state.brush_size)
else:
self._normal_draw_point(cmd["x"], cmd["y"])
def _do_draw_line(self, cmd: dict):
self._save_undo()
if self.state.oil_paint:
pts = self._interpolate(cmd["x1"], cmd["y1"], cmd["x2"], cmd["y2"])
self._oil_stroke(pts)
else:
self._normal_draw_line(cmd["x1"], cmd["y1"], cmd["x2"], cmd["y2"])
def _do_draw_rect(self, cmd: dict):
self._save_undo()
rect = pygame.Rect(cmd["x"], cmd["y"], cmd["width"], cmd["height"])
width = 0 if cmd.get("filled", False) else self.state.brush_size
pygame.draw.rect(self.surface, self.state.color, rect, width)
def _do_draw_ellipse(self, cmd: dict):
self._save_undo()
rect = pygame.Rect(cmd["x"], cmd["y"], cmd["width"], cmd["height"])
width = 0 if cmd.get("filled", False) else self.state.brush_size
pygame.draw.ellipse(self.surface, self.state.color, rect, width)
def _do_draw_path(self, cmd: dict):
self._save_undo()
points = cmd["points"]
if len(points) < 2:
return
if self.state.oil_paint:
# Interpolate between user-supplied points for smooth dab coverage
dense = []
for i in range(len(points) - 1):
dense.extend(self._interpolate(
points[i][0], points[i][1],
points[i + 1][0], points[i + 1][1]))
self._oil_stroke(dense)
else:
self._normal_draw_path(points)
def _do_batch_strokes(self, cmd: dict):
"""Execute many strokes in one command. Each stroke gets a fresh
brush load. Accepts optional per-stroke color and brush_size."""
self._save_undo()
strokes = cmd["strokes"]
for s in strokes:
# Apply per-stroke overrides
if "color" in s:
self.state.color = tuple(s["color"])
if "brush_size" in s:
self.state.brush_size = s["brush_size"]
if "brush_shape" in s:
shape = s["brush_shape"]
if shape in VALID_BRUSH_SHAPES:
self.state.brush_shape = shape
kind = s.get("type", "path")
if kind == "line":
pts = self._interpolate(s["x1"], s["y1"], s["x2"], s["y2"])
if self.state.oil_paint:
self._oil_stroke(pts)
else:
self._normal_draw_line(s["x1"], s["y1"],
s["x2"], s["y2"])
elif kind == "path":
points = s["points"]
if len(points) < 2:
continue
if self.state.oil_paint:
dense = []
for i in range(len(points) - 1):
dense.extend(self._interpolate(
points[i][0], points[i][1],
points[i + 1][0], points[i + 1][1]))
self._oil_stroke(dense)
else:
self._normal_draw_path(points)
elif kind == "point":
if self.state.oil_paint:
self._oil_dab(s["x"], s["y"], self.state.brush_size)
else:
self._normal_draw_point(s["x"], s["y"])
def _do_blend_path(self, cmd: dict):
self._save_undo()
points = cmd["points"]
strength = cmd.get("strength", 0.15)
if len(points) < 2:
return
dense = []
for i in range(len(points) - 1):
dense.extend(self._interpolate(
points[i][0], points[i][1],
points[i + 1][0], points[i + 1][1]))
self._blend_stroke(dense, strength)
def _do_flood_fill(self, cmd: dict):
self._save_undo()
x, y = cmd["x"], cmd["y"]
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return
pixel_array = pygame.PixelArray(self.surface)
target_color = pixel_array[x, y]
fill_color = self.surface.map_rgb(self.state.color)
if target_color == fill_color:
pixel_array.close()
return
stack = [(x, y)]
visited = set()
while stack:
cx, cy = stack.pop()
if cx < 0 or cx >= self.width or cy < 0 or cy >= self.height:
continue
if (cx, cy) in visited:
continue
if pixel_array[cx, cy] != target_color:
continue
visited.add((cx, cy))
pixel_array[cx, cy] = fill_color
stack.append((cx + 1, cy))
stack.append((cx - 1, cy))
stack.append((cx, cy + 1))
stack.append((cx, cy - 1))
pixel_array.close()
def _do_clear(self, cmd: dict):
self._save_undo()
self.surface.fill(self.state.background_color)
def _do_undo(self, cmd: dict):
self.undo()
# --- Read-only operations (no undo) ---
def get_pixels_rgb(self, x: int = 0, y: int = 0,
w: int | None = None, h: int | None = None) -> list[list[list[int]]]:
"""Return a 2D list of [r, g, b] values (row-major) for the given region."""
if w is None:
w = self.width - x
if h is None:
h = self.height - y
# Clamp to canvas bounds
x = max(0, min(x, self.width - 1))
y = max(0, min(y, self.height - 1))
w = min(w, self.width - x)
h = min(h, self.height - y)
pixel_array = pygame.PixelArray(self.surface)
rows = []
for row in range(y, y + h):
r_list = []
for col in range(x, x + w):
mapped = pixel_array[col, row]
r, g, b, _ = self.surface.unmap_rgb(mapped)
r_list.append([r, g, b])
rows.append(r_list)
pixel_array.close()
return rows