marzo 27, 2024 · python pygame graphics 9-slice scaling

9-slice scaling in Pygame

Quando si lavora con immagini 2D è frequente la necessità di modificarne le dimensioni per adattarle all'ambiente in cui sono posizionate. Però spesso non è possibile procedere con una semplice scalatura, poichè si introdurrebbero delle distorsioni o dei problemi legati al cambio di risoluzione, specialmente quando le proporzioni dell'immagine iniziale non sono rispettate.

Un caso tipico è il ridimensionamento di pannelli della UI di un videogioco. I pannelli di sfondo sono tipicamente commissionati a un artista e realizzati in una dimensione fissa, e poi spetta al programmatore adattarli, magari in maniera dinamica. Ma questo adattamento deve evitare distorsioni. Vediamo per esempio cosa succede se si ridimensiona indiscriminatamente un pannello con una cornice.

A sinistra l'immagine originale, a destra l'immagine con larghezza triplicata. Si nota subito che è una schifezza: la cornice a destra e sinistra è stata scalata anch'essa, con i risultati che si vedono, e gli angoli sono deformati a causa del cambio di proporzione.

Il meccanismo che si utilizza in queste situazioni è chiamato 9-slice scaling. Si divide l'immagine in 9 settori, isolando il centro dell'immagine, i bordi e gli angoli, e ciascuna parte viene deformata in maniera diversa: gli angoli non vengono alterati e i lati vengono modificati solo lungo il loro lato maggiore. Maggiori dettagli sull'apposito articolo su Wikipedia, da cui ho anche gentilmente rubato la seguente immagine.

Probabilmente da qualche parte tra le librerie di Pygame esiste già un modo per farlo, ma ho voluto provare a farlo da zero come esercizio.

Supponiamo di avere in input alla nostra funzione l'immagine originale, le dimensioni desiderate dell'immagine da produrre e la larghezza dei margini:

def scale_image(
  image:pygame.Surface, 
  width:float, 
  height:float, 
  margin:int=10
) -> pygame.Surface:

Prendiamo nota delle dimensioni iniziali (che ci serviranno per fare i calcoli):

orig_width, orig_height = image.get_size()

Creiamo un pannello vuoto con le dimensioni desiderate.

scaled_image = pygame.Surface((width, height), pygame.SRCALPHA)

Copiamo i quattro angoli; non hanno bisogno di essere deformati, quindi li possiamo semplicemente copiare nella posizione giusta. E' sufficiente usare il comando blit indicando come primo parametro l'immagine di partenza, come secondo parametro la posizione all'interno della nuova immagine, e come ultimo parametro le coordinate che delimitano l'area da copiare.

# angolo top left
scaled_image.blit(
  image, 
  (0, 0), 
  (0, 0, margin, margin)
)

# ...altri angoli

Copiamo i quattro lati. Per ciascuno, estraiamo l'immagine parziale da quella originale (con il comando subsurface), la deformiamo nella direzione desiderata (transform.smoothscale) e la copiamo, come già visto per gli angoli, nell'immagine finale:

# top
image_top = image.subsurface(
  pygame.Rect(margin, 0, orig_width-margin*2, margin)
)
scaled_image.blit(
  pygame.transform.smoothscale(
    image_top, 
    (width-margin*2, margin)
  ), 
  (margin, 0)
)

# ...altri lati

E infine copiamo il centro. Il procedimento è analogo a quanto visto per i lati ma la deformazione è in entrambe le direzioni:

# centro
image_center = image.subsurface(
  pygame.Rect(margin, margin, orig_width-margin*2, orig_height-margin*2)
)

scaled_image.blit(
  pygame.transform.smoothscale(
    image_center, 
    (width-margin*2, height-margin*2)
  ), 
  (margin, margin)
)

Voilà! Il codice completo è qualcosa del genere:

import pygame

def scale_image(image:pygame.Surface, width:float, height:float, margin:int=10) -> pygame.Surface:

  orig_width, orig_height = image.get_size()

  scaled_image = pygame.Surface((width, height), pygame.SRCALPHA)
  
  # angolo top left
  scaled_image.blit(image, (0, 0), (0, 0, margin, margin))

  # angolo top right
  scaled_image.blit(image, (width-margin, 0), (orig_width-margin, 0, orig_width-margin, margin))

  # angolo bottom left
  scaled_image.blit(image, (0, height-margin), (0, orig_height-margin, margin, orig_height))

  # angolo bottom right
  scaled_image.blit(image, (width-margin, height-margin), (orig_width-margin, orig_height-margin, orig_width-margin, orig_height))

  # centro (scalato)
  image_center = image.subsurface(pygame.Rect(margin, margin, orig_width-margin*2, orig_height-margin*2))
  scaled_image.blit(pygame.transform.smoothscale(image_center, (width-margin*2, height-margin*2)), (margin, margin))

  # top (scalato)
  image_top = image.subsurface(pygame.Rect(margin, 0, orig_width-margin*2, margin))
  scaled_image.blit(pygame.transform.smoothscale(image_top, (width-margin*2, margin)), (margin, 0))

  # bottom (scalato)
  image_bottom = image.subsurface(pygame.Rect(margin, orig_height - margin, orig_width - margin*2, margin))
  scaled_image.blit(pygame.transform.smoothscale(image_bottom, (width-margin*2, margin)), (margin, height - margin))

  # left (scalato)
  image_left = image.subsurface(pygame.Rect(0, margin, margin, orig_height-margin*2))
  scaled_image.blit(pygame.transform.smoothscale(image_left, (margin, height-2*margin)), (0, margin))

  # right (scalato)
  image_right = image.subsurface(pygame.Rect(orig_width-margin, margin, margin, orig_height-margin*2))
  scaled_image.blit(pygame.transform.smoothscale(image_right, (margin, height-2*margin)), (width-margin, margin))

  return scaled_image

Il risultato è decisamente meglio, specialmente se lo confrontiamo con quello che abbiamo ottenuto prima:

9-slice scaling

scaling grezzo

Alla prossima!