p5.beholder.js

/**
 * The main class of the library. It should have a single instance per sketch, which is then prepared at the `setup()` function.
 */
class P5Beholder {
  /**
   * Creates an instance of P5Beholder. It is automatically called during p5 loading. Initialization of the library is only compelte after `prepare()` is called on sketch setup (see {@link P5Beholder#prepare}).
   * @class
   * @private
   */
  constructor() {
    // Requires the Beholder system
    this._bh = require("beholder-detection").default;
  }

  /**
   * Sets-up the p5beholder and sketch integration. Adds Beholder to the HTML page at the and initializes it with proper config file.
   * **Important**: must be called once on `setup()`.
   * The config file must use {@link P5Beholder._defaultConfig} as a base.
   *
   * @param {*} [config=P5Beholder._defaultConfig] - Controls how Beholder detection will run.
   * @param {string} [querySelector="#beholder_root"] - Picks an HTML element to be the parent of the Beholder system elements (GUI, etc).
   * @memberof P5Beholder
   */
  prepare(
    config = P5Beholder._defaultConfig,
    querySelector = "#beholder_root"
  ) {
    // Stores the configuration
    this._config = config;
    this._rootQuery = querySelector;
    // Sets up Beholder in the document DOM.
    if (this._rootQuery == null || this._rootQuery == "#beholder_root") {
      let newDiv = document.createElement("div");
      newDiv.setAttribute("id", "beholder_root");
      document.querySelector("body").appendChild(newDiv);
    }
    // Start the detection system
    this._bh.init(this._rootQuery, this._config);
  }

  /**
   *If a marker is present, the mouse button 0 goes down and, when undetected, goes up. The position of the resulting click can be defined, in pixels.
   * @param {number!} [markerId=0] - The id of the marker to check presence.
   * @param {number} [clickX=0] - The X position where of the resulting click.
   * @param {number} [clickY=0] - The Y position where of the resulting click.
   * @memberof P5Beholder
   */
  markerPresenceToMouseClick(markerId = 0, clickX = 0, clickY = 0) {
    let pos = createVector(clickX, clickY);

    const downEvent = new MouseEvent("mousedown", {
      bubbles: true,
      cancelable: true,
      button: 0,
      clientX: pos.x,
      clientY: pos.y,
    });

    const upEvent = new MouseEvent("mouseup", {
      bubbles: true,
      cancelable: true,
      button: 0,
      clientX: pos.x,
      clientY: pos.y,
    });

    this._forwardEventForId(markerId, "markerdetected", downEvent);
    this._forwardEventForId(markerId, "markerundetected", upEvent);
  }

  /**
   * Quick converter between marker presence and keyboard input. When a marker is detected, fires the `keydown` event. Whe the marker is undetected, fires the `keyup` event.
   * @param {number} [markerId=0] - The id of the marker to check.
   * @param {number} [pKeyCode=32] - The number with the keyboard code (from `event.key`).
   * @memberof P5Beholder
   */
  markerPresenceToKey(markerId = 0, pKeyCode = "Space") {
    const downEvent = new KeyboardEvent("keydown", {
      bubbles: true,
      cancelable: true,
      keyCode: pKeyCode,
      which: pKeyCode,
    });

    const upEvent = new KeyboardEvent("keyup", {
      bubbles: true,
      cancelable: true,
      keyCode: pKeyCode,
      which: pKeyCode,
    });

    this._forwardEventForId(markerId, "markerdetected", downEvent);
    this._forwardEventForId(markerId, "markerundetected", upEvent);
  }

  /**
   * Changes the timeout value for all markers.
   * @param {number} [timeout=50] - New time in milliseconds.
   * @memberof P5Beholder
   */
  setMarkersTimeout(timeout = 50) {
    for (let i = 0; i < this._bh.getAllMarkers().length; i++) {
      const item = this._bh.getMarker(i);
      item.timeout = timeout;
    }
  }

  /**
   * Debug functionality for drawing a marker on the canvas.
   * @param {number} [markerId=0] - The id of the marker to draw.
   * @memberof P5Beholder
   */
  debugDrawMarker(markerId = 0) {
    let marker = this._bh.getMarker(markerId);
    if (marker.present == false) return;
    push();
    fill(255, 0, 0);
    stroke(0, 0, 0);
    beginShape();
    vertex(
      this.cameraToCanvasVector(marker.corners[0]).x,
      this.cameraToCanvasVector(marker.corners[0]).y
    );
    vertex(
      this.cameraToCanvasVector(marker.corners[1]).x,
      this.cameraToCanvasVector(marker.corners[1]).y
    );
    vertex(
      this.cameraToCanvasVector(marker.corners[2]).x,
      this.cameraToCanvasVector(marker.corners[2]).y
    );
    vertex(
      this.cameraToCanvasVector(marker.corners[3]).x,
      this.cameraToCanvasVector(marker.corners[3]).y
    );
    endShape(CLOSE);
    fill(0, 255, 0);
    let center = this.cameraToCanvasXY(marker.center.x, marker.center.y);
    ellipse(center.x, center.y, 10, 10);
    textSize(12);
    text(marker.rotation, center.x, center.y + 30, 80, 40);
    pop();
  }

  /**
   * Converts X position from camera to canvas.
   * @param {number} x - Camera X position.
   * @return {number} - Canvas X position.
   * @memberof P5Beholder
   */
  cameraToCanvasX(x) {
    return map(x, 0, this.cameraWidth, 0, width);
  }

  /**
   * Converts Y position from camera to canvas.
   * @param {number} y - Camera Y position.
   * @return {number} - Canvas Y position.
   * @memberof P5Beholder
   */
  cameraToCanvasY(y) {
    return map(y, 0, this.cameraHeight, 0, height);
  }

  /**
   * Converts a position from camera to canvas using a p5.Vector.
   * @param {p5.Vector} coord - p5.Vector with camera position.
   * @return {p5.Vector} - p5.Vector with canvas position.
   * @memberof P5Beholder
   */
  cameraToCanvasVector(coord) {
    return createVector(
      this.cameraToCanvasX(coord.x),
      this.cameraToCanvasY(coord.y)
    );
  }

  /**
   * Converts a position from camera to canvas positions using 2 numbers.
   * @param {number} pX - Camera-relative X value.
   * @param {number} pY - Camera-relative Y value.
   * @return {p5.Vector} - p5.Vector with canvas position.
   * @memberof P5Beholder
   */
  cameraToCanvasXY(pX, pY) {
    return createVector(this.cameraToCanvasX(pX), this.cameraToCanvasY(pY));
  }

  /**
   * Checks if a marker's rotation is within a specified range from a target angle. Useful for detecting things like a crank or spinner with a marker at its center.
   * All angles are in radians.
   * @param {number} markerId - The id of the marker to be checked.
   * @param {number} detectionTargetAngle - The target angle.
   * @param {number} angleRange - The range centers around the target angle. Should be less than PI.
   * @return {true|false}
   * @memberof P5Beholder
   */
  markerRotationInRange(markerId, detectionTargetAngle, angleRange) {
    const m = this._bh.getMarker(markerId);
    const currentAngle = m.present ? m.rotation : 0;
    return this.angleInRange(currentAngle, detectionTargetAngle, angleRange);
  }

  /**
   * Checks if the current angle is within a defined range from a target angle. All angles are in radians.
   * @param {number} currentAngle - Value to be checked.
   * @param {number} detectionTargetAngle - The target angle.
   * @param {number} angleRange - The range centers around the target angle. Should be less than PI.
   * @return {true|false}
   * @memberof P5Beholder
   */
  angleInRange(currentAngle, detectionTargetAngle, angleRange) {
    const av = p5.Vector.fromAngle(currentAngle, 1);
    const dv = p5.Vector.fromAngle(detectionTargetAngle, 1);
    const da = dv.angleBetween(av);
    return abs(da) <= angleRange / 2;
  }

  /// Wrappers to Beholder API

  getMarker(markerId) {
    return this._bh.getMarker(markerId);
  }

  /// PRIVATE METHODS AND PROPERTIES

  /**
   * An object holding camera, detection, feed and overlay default options for the Beholder detection system.
   * @static
   * @memberof P5Beholder
   * @example
   * // Structure of the default configuration object
{
  camera_params: {
    videoSize: 0, // The video size values map to the following [320 x 240, 640 x 480, 1280 x 720, 1920 x 1080]
    rearCamera: false, // Boolean value for defaulting to the rear facing camera. Only works on mobile
    torch: false, // Boolean value for if torch/flashlight is on. Only works for rear facing mobile cameras. Can only be set from init
  },
  detection_params: {
    minMarkerDistance: 10,
    minMarkerPerimeter: 0.2,
    maxMarkerPerimeter: 0.8,
    sizeAfterPerspectiveRemoval: 49,
  },
  feed_params: {
    contrast: 0,
    brightness: 0,
    grayscale: 0,
    flip: false,
  },
  overlay_params: {
    present: true, // Determines if the Beholder overlay will display or be invisible entirely via display: none
    hide: true, // Determines if the overlay should be hidden on the left of the screen or visible
  },
}
   */
  static _defaultConfig = {
    camera_params: {
      videoSize: 0, // The video size values map to the following [320 x 240, 640 x 480, 1280 x 720, 1920 x 1080]
      rearCamera: false, // Boolean value for defaulting to the rear facing camera. Only works on mobile
      torch: false, // Boolean value for if torch/flashlight is on. Only works for rear facing mobile cameras. Can only be set from init
    },
    detection_params: {
      minMarkerDistance: 10,
      minMarkerPerimeter: 0.2,
      maxMarkerPerimeter: 0.8,
      sizeAfterPerspectiveRemoval: 49,
    },
    feed_params: {
      contrast: 0,
      brightness: 0,
      grayscale: 0,
      flip: false,
    },
    overlay_params: {
      present: true, // Determines if the Beholder overlay will display or be invisible entirely via display: none
      hide: true, // Determines if the overlay should be hidden on the left of the screen or visible
    },
  };

  _forwardEventForId(markerId, eventName, destEvt) {
    window.addEventListener(eventName, (srcEvt) => {
      if (srcEvt.detail.id == markerId) {
        window.dispatchEvent(destEvt);
      }
    });
  }
}

/**
 * Global instance of the P5Beholder class.
 * @global
 */
global.p5beholder = new P5Beholder();

// Register preupdate on p5 flow. Thsi needs to be outside the class because of p5 way of dealing with registering methods (i.e. it loses the `this` binding). So, the preUpdate changes things on the global instance just created. Not very nice, but works.
global.p5.prototype.registerMethod("pre", _beholderPreUpdate);

function _beholderPreUpdate() {
  const p5b = global.p5beholder;
  p5b.cameraWidth = p5b._bh.getVideo().width;
  p5b.cameraHeight = p5b._bh.getVideo().height;

  let prevPresence = p5b._bh.getAllMarkers().map((m) => m.present);

  p5b._bh.update();

  let currPresence = p5b._bh.getAllMarkers().map((m) => m.present);

  if (frameCount == 1) {
    prevPresence = [false];
    currPresence = [false];
  }

  prevPresence.forEach((prevState, index) => {
    const currState = currPresence[index];

    if (prevState == false && currState == true) {
      let evDetected = new CustomEvent("markerdetected", {
        detail: { id: index, marker: p5b._bh.getMarker(index) },
      });
      window.dispatchEvent(evDetected);
      return;
    }
    if (prevState == true && currState == false) {
      let evUndetected = new CustomEvent("markerundetected", {
        detail: { id: index, marker: p5b._bh.getMarker(index) },
      });
      window.dispatchEvent(evUndetected);
      return;
    }
  });
}