SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ src / library / ComputerVision.php⤓
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\library;

use seekquarry\yioop\configs as C;

/**
 * For crawlHash
 */
require_once __DIR__ . "/Utility.php";
/**
 * For localeTagToIso639_2Tag, which turns a language tag, for example
 * en-US, into the three-letter language code tesseract takes, here eng
 */
require_once __DIR__ . "/LocaleFunctions.php";
/**
 * ComputerVision holds the ways Yioop reads a picture for indexing. At
 * present that is reading the text written in a picture, which the image
 * and PDF processors add to a page's description.
 */
class ComputerVision
{
    /**
     * TILE_SIDE is how many points across and down each square looked at
     * by likelyHoldsText is. A square this size holds a few letters of
     * ordinary print.
     */
    const TILE_SIDE = 32;
    /**
     * TILES_ACROSS is how many squares likelyHoldsText looks at along each
     * side of a picture, spread evenly from edge to edge.
     */
    const TILES_ACROSS = 8;
    /**
     * FLAT_SPREAD is the standard deviation of shades, out of 255, below
     * which likelyHoldsText takes a square to be of one shade and leaves it
     * out.
     */
    const FLAT_SPREAD = 12;
    /**
     * LEAST_INK_GAP is how far apart, out of 255, the average shades of
     * the dark and light points of a square must be for the square to be
     * like print.
     */
    const LEAST_INK_GAP = 80;
    /**
     * MOST_MIDDLE_SHARE is the largest share of a square's points that may
     * lie in the middle half between its dark and light averages for the
     * square to be like print. Print is ink and paper with only its edges
     * between; a photograph runs through every shade.
     */
    const MOST_MIDDLE_SHARE = 0.2;
    /**
     * LEAST_PRINT_SHARE is the share of the squares that are not of one
     * shade which must be like print for likelyHoldsText to say a picture
     * likely holds text.
     */
    const LEAST_PRINT_SHARE = 0.75;
    /**
     * $ocr_time adds up the seconds recognizeText has spent running
     * tesseract since a fetcher last read and cleared it, so the fetcher's
     * log can say how much of its processing time went to reading text in
     * pictures.
     * @var float
     */
    public static $ocr_time = 0;
    /**
     * OCR_MOST_SIDE is the most points a picture's longer side has when
     * tesseract reads it; a larger picture is scaled down first. A news
     * picture 2,400 points across with four lines of text read the same
     * scaled to 1,200 across, in 0.25 seconds against 0.33 to 0.40.
     */
    const OCR_MOST_SIDE = 1600;
    /**
     * likelyHoldsText judges quickly whether the picture saved at
     * $image_path is likely to hold text, so that recognizeText runs
     * tesseract, which takes seconds, only where there may be words to
     * read. It looks at TILES_ACROSS by TILES_ACROSS squares of TILE_SIDE
     * points spread over the picture. Squares of one shade are left out.
     * Each other square has its points split into dark and light at the
     * shade that best separates them (Otsu's method), and is like print
     * where the two groups are at least LEAST_INK_GAP apart and at most
     * MOST_MIDDLE_SHARE of its points lie between them. Print is ink on
     * paper with little between; a photograph runs through every shade.
     * The picture likely holds text where at least LEAST_PRINT_SHARE of
     * the squares are like print, or where every square is of one shade.
     * Each square is judged on its own, so a page scanned under uneven
     * light still reads as print.
     *
     * A picture written as a portable bit, gray or pixel map, as
     * PdfProcessor writes them, is read from its bytes; others are read
     * with the drawing library. A picture the drawing library cannot read
     * is taken to hold text, so tesseract still gets the chance.
     *
     * @param string $image_path a filepath to an image
     * @return bool whether the picture likely holds text
     */
    public static function likelyHoldsText($image_path)
    {
        $bytes = @file_get_contents($image_path);
        if (!is_string($bytes) || $bytes === "") {
            return false;
        }
        if (preg_match('/^P([456])\s+(\d+)\s+(\d+)\s(?:(\d+)\s)?/',
            $bytes, $header)) {
            [$head, $kind, $wide, $high] = $header;
            $wide = (int)$wide;
            $high = (int)$high;
            $start = strlen($head);
            if ($kind == "4") {
                $row_length = intdiv($wide + 7, 8);
                $shade_at = function ($across, $down) use ($bytes, $start,
                    $row_length) {
                    $byte = ord($bytes[$start + $down * $row_length +
                        intdiv($across, 8)] ?? "\0");
                    return (($byte >> (7 - $across % 8)) & 1) ? 0 : 255;
                };
            } else {
                $each = ($kind == "6") ? 3 : 1;
                $shade_at = function ($across, $down) use ($bytes, $start,
                    $wide, $each) {
                    return ord($bytes[$start + ($down * $wide + $across) *
                        $each] ?? "\xff");
                };
            }
        } else {
            $image = @imagecreatefromstring($bytes);
            if ($image === false) {
                return true;
            }
            imagepalettetotruecolor($image);
            $wide = imagesx($image);
            $high = imagesy($image);
            $shade_at = function ($across, $down) use ($image) {
                $color = imagecolorat($image, $across, $down);
                return intdiv(299 * (($color >> 16) & 0xFF) +
                    587 * (($color >> 8) & 0xFF) + 114 * ($color & 0xFF),
                    1000);
            };
        }
        $side_across = min(self::TILE_SIDE, $wide);
        $side_down = min(self::TILE_SIDE, $high);
        $busy = 0;
        $print = 0;
        for ($i = 0; $i < self::TILES_ACROSS; $i++) {
            $top = intdiv(($high - $side_down) * $i, self::TILES_ACROSS - 1);
            for ($j = 0; $j < self::TILES_ACROSS; $j++) {
                $left = intdiv(($wide - $side_across) * $j,
                    self::TILES_ACROSS - 1);
                $shades = [];
                for ($down = $top; $down < $top + $side_down; $down++) {
                    for ($across = $left; $across < $left + $side_across;
                        $across++) {
                        $shades[] = $shade_at($across, $down);
                    }
                }
                $judged = self::squareLikePrint($shades);
                if ($judged !== null) {
                    $busy++;
                    $print += $judged ? 1 : 0;
                }
            }
        }
        /* Where every square looked at is of one shade, the picture is
           blank or its print is too sparse to be met, so tesseract is
           left to decide. */
        return $busy == 0 || $print >= self::LEAST_PRINT_SHARE * $busy;
    }
    /**
     * squareLikePrint judges whether the shades of one square of a
     * picture look like print, for likelyHoldsText. The shades are split
     * into dark and light at the shade that makes the two groups differ
     * most from each other relative to their size (Otsu's method: the
     * split that makes the variance between the groups largest). The
     * square is like print where the averages of the two groups are at
     * least LEAST_INK_GAP apart and at most MOST_MIDDLE_SHARE of the
     * shades lie in the middle half between those averages.
     *
     * @param array $shades shades of gray from 0 to 255, one for each
     *      point of the square
     * @return bool|null whether the square is like print, or null where
     *      its standard deviation is under FLAT_SPREAD, so that it is of
     *      one shade and says nothing
     */
    public static function squareLikePrint($shades)
    {
        $count = count($shades);
        if ($count == 0) {
            return null;
        }
        $sum = array_sum($shades);
        $mean = $sum / $count;
        $spread = 0;
        $tally = array_fill(0, 256, 0);
        foreach ($shades as $shade) {
            $spread += ($shade - $mean) ** 2;
            $tally[$shade]++;
        }
        if (sqrt($spread / $count) < self::FLAT_SPREAD) {
            return null;
        }
        $best = -1;
        $dark_mean = 0;
        $light_mean = 0;
        $dark_count = 0;
        $dark_sum = 0;
        for ($shade = 0; $shade < 255; $shade++) {
            $dark_count += $tally[$shade];
            $dark_sum += $shade * $tally[$shade];
            $light_count = $count - $dark_count;
            if ($dark_count == 0 || $light_count == 0) {
                continue;
            }
            $dark = $dark_sum / $dark_count;
            $light = ($sum - $dark_sum) / $light_count;
            $between = $dark_count * $light_count * ($light - $dark) ** 2;
            if ($between > $best) {
                $best = $between;
                $dark_mean = $dark;
                $light_mean = $light;
            }
        }
        $gap = $light_mean - $dark_mean;
        if ($gap < self::LEAST_INK_GAP) {
            return false;
        }
        $middle = 0;
        foreach ($shades as $shade) {
            if ($shade > $dark_mean + $gap / 4 &&
                $shade < $light_mean - $gap / 4) {
                $middle++;
            }
        }
        return $middle <= self::MOST_MIDDLE_SHARE * $count;
    }
    /**
     * scaledForRecognition writes the picture at $image_path scaled down so
     * its longer side is OCR_MOST_SIDE points, as a PNG in $temp_dir, when
     * that side is longer, so tesseract, whose time grows with the number
     * of points, reads a smaller picture. recognizeText calls it first.
     * A picture already small enough, or one the drawing library cannot
     * read, such as the portable maps PdfProcessor writes, gets "".
     *
     * @param string $image_path a filepath to an image
     * @param string $temp_dir folder, ending in a slash, for the scaled copy
     * @return string the scaled copy's path, or "" when none was written
     */
    public static function scaledForRecognition($image_path, $temp_dir)
    {
        $size = @getimagesize($image_path);
        if (empty($size[0]) || empty($size[1]) ||
            max($size[0], $size[1]) <= self::OCR_MOST_SIDE) {
            return "";
        }
        $image = @imagecreatefromstring((string)@file_get_contents(
            $image_path));
        if ($image === false) {
            return "";
        }
        $ratio = self::OCR_MOST_SIDE / max($size[0], $size[1]);
        $scaled = imagescale($image, max(1, intval($size[0] * $ratio)),
            max(1, intval($size[1] * $ratio)));
        if ($scaled === false) {
            return "";
        }
        $scaled_path = $temp_dir . "scaled-" . getmypid() . "-" .
            pathinfo($image_path, PATHINFO_FILENAME) . ".png";
        return imagepng($scaled, $scaled_path) ? $scaled_path : "";
    }
    /**
     * recognizeText runs tesseract on the picture saved at $image_path and
     * gives back the text tesseract read in it, looking for the languages
     * given. The image and PDF processors call it for a picture they have
     * saved. Where tesseract is not installed, or TESSERACT is set to
     * false, it runs nothing and gives back the empty string. The time
     * tesseract takes is added to $ocr_time. tesseract writes the text to
     * a file in $temp_dir, which for a Fetcher is that fetcher's own
     * temporary folder.
     *
     * @param string $image_path a filepath to an image
     * @param array $langs locale_tags of languages we want to extract text for
     * @param string $temp_dir folder tesseract writes the text it read to,
     *      such as PageProcessor::$temp_dir
     * @return string text extracted from image
     */
    public static function recognizeText($image_path,
        $langs = [C\DEFAULT_LOCALE], $temp_dir = C\TEMP_DIR)
    {
        static $call_count = 0;
        $tesseract = programPath("TESSERACT", "tesseract");
        if ($tesseract === "" || !self::likelyHoldsText($image_path)) {
            return "";
        }
        $temp_dir .= "/";
        if (!file_exists($temp_dir)) {
            mkdir($temp_dir);
        }
        if (!file_exists($temp_dir)) {
            return "";
        }
        $scaled_path = self::scaledForRecognition($image_path, $temp_dir);
        if ($scaled_path !== "") {
            $text = self::recognizeText($scaled_path, $langs, $temp_dir);
            @unlink($scaled_path);
            return $text;
        }
        $image_file_name = pathinfo($image_path, PATHINFO_BASENAME);
        $iso_string = "";
        $add = "";
        foreach ($langs as $lang) {
            $iso_lang = localeTagToIso639_2Tag($lang);
            $iso_string .= $add . $iso_lang;
            $add = "+";
        }
        $ocr_file = $temp_dir . $call_count . $image_file_name . "-out";
        $ocr_exec = $tesseract . " " . escapeshellarg($image_path) .
            " " . escapeshellarg($ocr_file) . " -l " .
            escapeshellarg($iso_string) . (strstr(PHP_OS, "WIN") !== false ?
            " > NUL 2>&1" : " > /dev/null 2>&1");
        /* tesseract built with OpenMP starts a thread for each core, and
           waiting on those threads took five times as long as reading a
           page of text with one thread. Several fetchers on one machine
           make that worse. A limit already set is kept. */
        $thread_limit = getenv("OMP_THREAD_LIMIT");
        if ($thread_limit === false) {
            putenv("OMP_THREAD_LIMIT=1");
        }
        $start_time = microtime(true);
        exec($ocr_exec);
        self::$ocr_time += microtime(true) - $start_time;
        if ($thread_limit === false) {
            putenv("OMP_THREAD_LIMIT");
        }
        $ocr_file .= ".txt";
        $ocr_string =  "";
        if (file_exists($ocr_file)) {
            $ocr_string = file_get_contents($ocr_file);
            @unlink($ocr_file);
        }
        $call_count ++;
        return trim($ocr_string, " \t\n\r\0\x0B\x0C");
    }
}