<?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");
}
}