chromium/chrome/browser/resources/lens/overlay/find_words_in_region.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import type {CenterRotatedBox} from './geometry.mojom-webui.js';
import type {Word} from './text.mojom-webui.js';

// A point in the normalized coordinate plane.
interface Vertex {
  x: number;
  y: number;
}

// A bounding box, in normalized coordinates.
interface BoundingBox {
  left: number;
  right: number;
  top: number;
  bottom: number;
}

export enum ClippingEdge {
  LEFT,
  RIGHT,
  TOP,
  BOTTOM,
}

export interface WordsInRegionResult {
  // The index of the first word in the selection, or -1 if no words found.
  startIndex: number;
  // The index of the last word in the selection, or -1 if no words found.
  endIndex: number;
  // The intersection over union between the bounding boxes of the selected
  // words and the selection region.
  iou: number;
}

// Searches for words intersecting the selection region. Since coordinates are
// normalized, the image bounds are required to correctly handle rotations.
export function findWordsInRegion(
    words: Word[], selectionRegion: CenterRotatedBox,
    imageBounds: DOMRect): WordsInRegionResult {
  let intersectionArea = 0;
  let unionArea = areaOfPolygon(toPolygon(selectionRegion, imageBounds));
  let startIndex = -1;
  let endIndex = -1;
  const selectionBounds = {
    left: selectionRegion.box.x - selectionRegion.box.width / 2,
    right: selectionRegion.box.x + selectionRegion.box.width / 2,
    top: selectionRegion.box.y - selectionRegion.box.height / 2,
    bottom: selectionRegion.box.y + selectionRegion.box.height / 2,
  };
  const wordPolygons =
      words.map((word) => toPolygon(word.geometry!.boundingBox, imageBounds));
  const clippedWords =
      wordPolygons.map((wordPolygon) => clip(wordPolygon, selectionBounds));

  for (let i = 0; i < words.length; i++) {
    const wordPolygon = wordPolygons[i];
    const clippedWord = clippedWords[i];
    const wordIntersectionArea = areaOfPolygon(clippedWord);
    if (wordIntersectionArea > 0) {
      if (startIndex === -1) {
        startIndex = endIndex = i;
      } else {
        startIndex = Math.min(startIndex, i);
        endIndex = Math.max(endIndex, i);
      }
      intersectionArea += wordIntersectionArea;
      unionArea += areaOfPolygon(wordPolygon) - wordIntersectionArea;
    }
  }
  const iou = intersectionArea / unionArea;
  return {iou, startIndex, endIndex};
}

// Converts a CenterRotatedBox to an array of vertices representing a polygon.
export function toPolygon(
    box: CenterRotatedBox, imageBounds: DOMRect): Vertex[] {
  const polygon: Vertex[] = [];
  const center = {x: box.box.x, y: box.box.y};
  const left = box.box.x - box.box.width / 2;
  const right = box.box.x + box.box.width / 2;
  const top = box.box.y - box.box.height / 2;
  const bottom = box.box.y + box.box.height / 2;
  polygon.push(rotate(center, box.rotation, {x: left, y: top}, imageBounds));
  polygon.push(rotate(center, box.rotation, {x: right, y: top}, imageBounds));
  polygon.push(
      rotate(center, box.rotation, {x: right, y: bottom}, imageBounds));
  polygon.push(rotate(center, box.rotation, {x: left, y: bottom}, imageBounds));
  return polygon;
}

// Calculates the area of a polygon using the shoelace formula.
export function areaOfPolygon(polygon: Vertex[]): number {
  let area = 0;
  for (let i = 0; i < polygon.length; i++) {
    if (i < polygon.length - 1) {
      area += polygon[i].x * polygon[i + 1].y - polygon[i + 1].x * polygon[i].y;
    } else {
      area += polygon[i].x * polygon[0].y - polygon[0].x * polygon[i].y;
    }
  }
  return 0.5 * Math.abs(area);
}

// Returns the result of rotating the target vertex about the anchor vertex.
// All vertices are defined in normalized coordinates, so the image bounds
// are used to convert to absolute coordinates while rotating.
export function rotate(
    anchor: Vertex, angleRadian: number, target: Vertex,
    imageBounds: DOMRect): Vertex {
  const anchorXAbs = anchor.x * imageBounds.width;
  const anchorYAbs = anchor.y * imageBounds.height;
  const targetXAbs = target.x * imageBounds.width;
  const targetYAbs = target.y * imageBounds.height;

  const cosAngle = Math.cos(angleRadian);
  const sinAngle = Math.sin(angleRadian);
  const deltaX = targetXAbs - anchorXAbs;
  const deltaY = targetYAbs - anchorYAbs;
  const rotatedDeltaX = deltaX * cosAngle - deltaY * sinAngle;
  const rotatedDeltaY = deltaY * cosAngle + deltaX * sinAngle;
  return {
    x: (anchorXAbs + rotatedDeltaX) / imageBounds.width,
    y: (anchorYAbs + rotatedDeltaY) / imageBounds.height,
  };
}

// Clips the polygon to the selection bounds. As the selection
// region is an axis-aligned rectangle, a simplified case of the
// Sutherland-Hodgman algorithm may be used.
export function clip(polygon: Vertex[], selectionBounds: BoundingBox) {
  polygon = clipEdge(polygon, selectionBounds, ClippingEdge.TOP);
  polygon = clipEdge(polygon, selectionBounds, ClippingEdge.LEFT);
  polygon = clipEdge(polygon, selectionBounds, ClippingEdge.BOTTOM);
  polygon = clipEdge(polygon, selectionBounds, ClippingEdge.RIGHT);
  return polygon;
}

// Clips the polygon to a single edge of the selection bounds.
function clipEdge(
    polygon: Vertex[], selectionBounds: BoundingBox,
    edge: ClippingEdge): Vertex[] {
  const clippedPolygon: Vertex[] = [];
  if (polygon.length === 0) {
    return clippedPolygon;
  }
  let previous: Vertex;
  let next = polygon[polygon.length - 1];
  let isPreviousInside: boolean;
  let isNextInside = isInsideEdge(next, selectionBounds, edge);
  for (let i = 0; i < polygon.length; i++) {
    previous = next;
    next = polygon[i];
    isPreviousInside = isNextInside;
    isNextInside = isInsideEdge(next, selectionBounds, edge);
    if (isPreviousInside !== isNextInside) {
      clippedPolygon.push(
          intersectionWithEdge(previous, next, selectionBounds, edge));
    }
    if (isNextInside) {
      clippedPolygon.push(next);
    }
  }
  return clippedPolygon;
}

// Returns whether the given vertex is inside the specified edge of the
// selection bounds.
export function isInsideEdge(
    vertex: Vertex, selectionBounds: BoundingBox, edge: ClippingEdge) {
  switch (edge) {
    case ClippingEdge.LEFT:
      return vertex.x >= selectionBounds.left;
    case ClippingEdge.RIGHT:
      return vertex.x <= selectionBounds.right;
    case ClippingEdge.TOP:
      return vertex.y >= selectionBounds.top;
    case ClippingEdge.BOTTOM:
      return vertex.y <= selectionBounds.bottom;
  }
}

// Given two vertices defining a line, returns the intersection of that line
// with the specified edge of the selection bounds.
export function intersectionWithEdge(
    v0: Vertex, v1: Vertex, selectionBounds: BoundingBox,
    edge: ClippingEdge): Vertex {
  switch (edge) {
    case ClippingEdge.LEFT:
      return {
        x: selectionBounds.left,
        y: v0.y + (v1.y - v0.y) * (selectionBounds.left - v0.x) / (v1.x - v0.x),
      };
    case ClippingEdge.RIGHT:
      return {
        x: selectionBounds.right,
        y: v0.y +
            (v1.y - v0.y) * (selectionBounds.right - v0.x) / (v1.x - v0.x),
      };
    case ClippingEdge.TOP:
      return {
        x: v0.x + (v1.x - v0.x) * (selectionBounds.top - v0.y) / (v1.y - v0.y),
        y: selectionBounds.top,
      };
    case ClippingEdge.BOTTOM:
      return {
        x: v0.x +
            (v1.x - v0.x) * (selectionBounds.bottom - v0.y) / (v1.y - v0.y),
        y: selectionBounds.bottom,
      };
  }
}