Skip to content

[p5.js 2.0+ Bug Report]: Compounding dimension decay in p5.Image.pixelDensity and high-DPI canvas truncation in resize() #9153

Description

@Pcmhacker-piro

Most appropriate sub-area of p5.js?

  • Accessibility
  • Color
  • Core/Environment/Rendering
  • Data
  • DOM
  • Events
  • Image
  • IO
  • Math
  • Typography
  • Utilities
  • WebGL
  • WebGPU
  • p5.strands
  • Build process
  • Unit testing
  • Internationalization
  • Friendly errors
  • Other (specify if possible)

p5.js version

2.3.2

Web browser and version

All browsers (tested on Chrome / Playwright Chromium)

Operating system

macOS / Linux / Windows

Steps to reproduce this

Steps:

  1. Create an image (e.g. img = createImage(100, 100)).
  2. Set img.pixelDensity(2) — logical dimensions become 50x50 as expected.
  3. Call img.pixelDensity(2) a second time — logical dimensions become 25x25 (it divides img.width by 2 again instead of remaining 50).
  4. Call img.pixelDensity(1) to reset density — img.width stays 25 instead of returning to 100.
  5. Additionally, on an image with pixelDensity > 1, call img.resize(50, 50)this.canvas.width is set to 50 instead of 50 * pd (100). Subsequent calls to img.loadPixels() read width * pd (100x100) from a 50x50 canvas, filling 75% of the pixel buffer with out-of-bounds transparent black ([0, 0, 0, 0]).

Snippet:

function setup() {
  createCanvas(200, 200);

  // 1. Create a 100x100 image
  const img = createImage(100, 100);
  console.log('Initial:', img.width, img.height); // 100 100

  // 2. Set density to 2
  img.pixelDensity(2);
  console.log('After density 2:', img.width, img.height); // 50 50

  // 3. Re-assert density 2 (should be idempotent)
  img.pixelDensity(2);
  console.log('After 2nd density 2 call:', img.width, img.height); 
  // Actual: 25 25 (BUG! exponentially shrinks on repeated calls)
  // Expected: 50 50

  // 4. Reset density to 1
  img.pixelDensity(1);
  console.log('After reset to density 1:', img.width, img.height);
  // Actual: 25 25 (BUG! fails to restore original dimensions)
  // Expected: 100 100

  // 5. Resize high-DPI image
  img.pixelDensity(2);
  img.resize(50, 50);
  console.log('Canvas physical buffer width:', img.canvas.width);
  // Actual: 50 (BUG! canvas is downscaled to logical size, ignoring density)
  // Expected: 100 (50 * 2)

  img.loadPixels();
  console.log('img.pixels.length:', img.pixels.length); // 40000 (100*100*4)
  // Because canvas is only 50x50, 75% of pixels are read out of bounds as [0, 0, 0, 0]
}

Root Cause Analysis

In src/image/p5.Image.js:

  1. In pixelDensity(density) (lines 66–67):

    this._pixelDensity = density;
    
    // Adjust canvas dimensions based on pixel density
    this.width /= density;
    this.height /= density;

    this.width and this.height represent logical dimensions. Dividing this.width by density in place causes compounding division on successive calls ($W_k = W_0 / \prod d_i$) and makes resetting pixelDensity(1) a no-op.
    Logical dimensions should be derived directly from the physical buffer dimensions:

    this.width = this.canvas.width / density;
    this.height = this.canvas.height / density;
  2. In resize(width, height) (line 787):

    this.canvas.width = this.width = width;
    this.canvas.height = this.height = height;

    The backing canvas is assigned the logical width without multiplying by this._pixelDensity. High-DPI images lose their physical pixel buffer resolution, and loadPixels() requests width * pd by height * pd from an undersized canvas.
    The canvas dimensions must be scaled by pd:

    const pd = this._pixelDensity;
    const canvasWidth = Math.floor(width * pd);
    const canvasHeight = Math.floor(height * pd);
    ...
    this.width = width;
    this.height = height;
    this.canvas.width = canvasWidth;
    this.canvas.height = canvasHeight;

Proposed Fix

diff --git a/src/image/p5.Image.js b/src/image/p5.Image.js
index 423d7f1c..a4abf0a5 100644
--- a/src/image/p5.Image.js
+++ b/src/image/p5.Image.js
@@ -62,9 +62,9 @@ class Image {
 
       this._pixelDensity = density;
 
-      // Adjust canvas dimensions based on pixel density
-      this.width /= density;
-      this.height /= density;
+      // Adjust logical dimensions based on physical canvas dimensions and pixel density
+      this.width = this.canvas.width / density;
+      this.height = this.canvas.height / density;
 
       return this; // Return the image instance for chaining if needed
     } else {
@@ -727,20 +727,24 @@ class Image {
 
     // auto-resize
     if (width === 0 && height === 0) {
-      width = this.canvas.width;
-      height = this.canvas.height;
+      width = this.width;
+      height = this.height;
     } else if (width === 0) {
-      width = (this.canvas.width * height) / this.canvas.height;
+      width = (this.width * height) / this.height;
     } else if (height === 0) {
-      height = (this.canvas.height * width) / this.canvas.width;
+      height = (this.height * width) / this.width;
     }
 
     width = Math.floor(width);
     height = Math.floor(height);
 
+    const pd = this._pixelDensity;
+    const canvasWidth = Math.floor(width * pd);
+    const canvasHeight = Math.floor(height * pd);
+
     const tempCanvas = document.createElement('canvas');
-    tempCanvas.width = width;
-    tempCanvas.height = height;
+    tempCanvas.width = canvasWidth;
+    tempCanvas.height = canvasHeight;
 
     if (this.gifProperties) {
       const props = this.gifProperties;
@@ -761,8 +765,8 @@ class Image {
       };
       for (let i = 0; i < props.numFrames; i++) {
         const resizedImageData = this.drawingContext.createImageData(
-          width,
-          height
+          canvasWidth,
+          canvasHeight
         );
         nearestNeighbor(props.frames[i].image, resizedImageData);
         props.frames[i].image = resizedImageData;
@@ -784,20 +788,22 @@ class Image {
       );
 
     // Resize the original canvas, which will clear its contents
-    this.canvas.width = this.width = width;
-    this.canvas.height = this.height = height;
+    this.width = width;
+    this.height = height;
+    this.canvas.width = canvasWidth;
+    this.canvas.height = canvasHeight;
 
     //Copy the image back
     this.drawingContext.drawImage(
       tempCanvas,
       0,
       0,
-      width,
-      height,
+      canvasWidth,
+      canvasHeight,
       0,
       0,
-      width,
-      height
+      canvasWidth,
+      canvasHeight
     );
 
     if (this.pixels.length > 0) {

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions