ZaStoGram_desktop/Telegram/SourceFiles/test/test_ink.h
John Preston 411bdf92d9 [ai] Read a text pen from solid glyph cores
Task: 2026/09/22/read-a-text-pen-from-glyph-cores-in-the-harness
2026-09-24 22:54:55 +04:00

393 lines
14 KiB
C++

/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
#include "base/basic_types.h"
#include <QtCore/QRect>
#include <QtCore/QString>
#include <QtGui/QColor>
#include <QtGui/QImage>
#include <vector>
namespace Test {
class Runner;
inline constexpr auto kInkDelta = 45;
inline constexpr auto kSameTolerance = 40;
inline constexpr auto kOnLine = 10.;
inline constexpr auto kInkMargin = 8.;
inline constexpr auto kBackgroundSame = 6;
inline constexpr auto kMinInkPixels = 24;
// Chroma is max(channel) - min(channel). 24 keeps a near-neutral fringe
// (the diagnosing run's #888988 has chroma 1) out and a saturated mark in.
// The density floor sits in the measured gap between a fringe smear (~0.26)
// and a coloured glyph (~0.70). Neither retunes a shipped ink threshold.
inline constexpr auto kChromaticFloor = 24;
inline constexpr auto kChromaticDensityFloor = 0.40;
// Not kInkMargin: retuning the attribution gap must not redefine a core.
inline constexpr auto kGlyphCoreTolerance = 8;
struct InkCandidate {
QString name;
QColor color;
};
// Classified, NoInk, CandidatesCollide and BackgroundCollinear all mean the
// band WAS scanned, so
// every frame-level number on the reading - total, inkPixels,
// backgroundPixels, widestRun and the measured colours - is a real
// measurement. OutsideImage, NoRowsInBand and NotScanned mean no pixel was
// ever looked at, so those numbers are absent rather than measured zeroes.
// Classified claims only that the ink was offered to the candidates, not
// that any pixel was attributed: a scan whose candidates separate but sit
// farther than kOnLine from every ink pixel answers Classified with every
// count 0 and ambiguous == inkPixels. BackgroundCollinear is the other
// zero: a candidate lies within kInkMargin of the segment from the measured
// modal background to another candidate, so that zero is not a count.
enum class InkScanState {
NotScanned,
OutsideImage,
NoRowsInBand,
CandidatesCollide,
NoInk,
Classified,
BackgroundCollinear,
};
[[nodiscard]] QString InkScanStateName(InkScanState state);
struct DerivedBand {
bool ok = false;
QRect fillRegion;
QRect band;
std::vector<int> rows;
int rowsExamined = 0;
int fillRows = 0;
QString reason;
};
// One candidate's count read out of a scan. |count| is -1 and |refusal| is
// non-empty on every refusing path - an index the reading does not have, a
// reading that classified nothing, candidates that do not separate - so a
// refused ask can never be read back as a measured zero. A
// background-collinear reading is one of those refusals. |name| and |color|
// are filled whenever |index| names a real candidate, refusal or not, so
// even a refusal says which candidate was asked about, and InkCountDetails
// prints count=none beside that refusal.
struct InkCount {
int index = -1;
QString name;
QColor color;
int count = -1;
QString refusal;
[[nodiscard]] bool read() const {
return refusal.isEmpty();
}
};
struct InkScan {
bool ok = false;
QRect band;
QColor background;
QColor ink;
QColor classifiedInk;
int total = 0;
int inkPixels = 0;
int backgroundPixels = 0;
int ambiguous = 0;
int scannedRows = 0;
int widestRun = 0;
int widestRunRow = -1;
std::vector<InkCandidate> candidates;
std::vector<int> counts;
InkScanState state = InkScanState::NotScanned;
QString reason;
// The only way to read one candidate's count out of this reading.
// |candidates| and |counts| are filled on every path ScanInk returns
// through, so the two are always the same length; the count is still
// proved against |counts| itself rather than against that invariant, so
// an ask this reading cannot answer - an index outside either vector, or
// a state that classified nothing - comes back as a named refusal about
// the frame rather than as a read past the end of a vector the scan
// never filled.
[[nodiscard]] InkCount countAt(int index) const;
};
struct InkMeasure {
DerivedBand derived;
InkScan scan;
bool separable = false;
QString collision;
float64 contrast = 0.;
QString report;
};
[[nodiscard]] int ChannelDelta(QColor a, QColor b);
[[nodiscard]] bool Separable(const std::vector<InkCandidate> &candidates);
[[nodiscard]] QString CollisionDump(
const std::vector<InkCandidate> &candidates);
// Keeps rows of |box| whose modal background is the literal |fill|. When
// |box| is strictly inside the image and that fill is also the image's
// background outside |box|, no band can be separated: |reason| names that
// case and |ok| stays false, distinct from "no row of the recovered box
// has the pill fill as its own background".
[[nodiscard]] DerivedBand DeriveBand(
const QImage &image,
QRect box,
QColor fill);
// Scans |band| clipped to the image, measures the frame-level numbers over
// every scanned row, and attributes each ink pixel to the nearest candidate.
// Only two answers are ok = false, because only they looked at no pixel at
// all: OutsideImage, when |band| does not intersect the image, and
// NoRowsInBand, when an explicit |onlyRows| list has no row inside the band.
// CandidatesCollide - the candidates do not separate, so every ink pixel is
// ambiguous and no count is attributed - NoInk, and BackgroundCollinear -
// one candidate lies within kInkMargin of the segment from the measured
// modal background to another, so a pixel of it can never be attributed -
// all keep ok = true, because the band was scanned and their frame-level
// numbers are measured. BackgroundCollinear is not Classified, and countAt
// refuses it. It loses to CandidatesCollide and to NoInk, which keep their
// texts.
// |candidates| and |counts| are filled before either refusal returns, so the
// two are always the same length and a refused reading still names what it
// was asked to classify against. |reason| is never empty on a returned
// reading, and is u"none"_q only on the Classified verdict.
[[nodiscard]] InkScan ScanInk(
const QImage &image,
QRect band,
std::vector<InkCandidate> candidates,
const std::vector<int> &onlyRows = {});
[[nodiscard]] float64 InkContrast(QColor a, QColor b);
[[nodiscard]] QString FormatInkReport(
QColor fill,
QColor ink,
float64 contrast,
int inkPixels);
// InkCountDetails prints one count reading the same way on both verdicts -
// index, name, colour and count - with count=none and the refusal in place
// of a number whenever the ask was refused, so no log line can show a
// refused ask as a measurement.
[[nodiscard]] QString InkCountDetails(const InkCount &reading);
// FormatInkScan starts with FormatInkReport's exact text and then says what
// the reading was classified against: the candidate colours, the counts read
// back through countAt, CollisionDump's collision text and the five
// thresholds the classification used. It prints them on the passing verdict
// as well as on a refusing one, so a palette whose references do not
// separate is readable from the log without re-running a whole campaign.
[[nodiscard]] QString FormatInkScan(const InkScan &scan, QColor fill);
// Both of MeasurePaintedInk's branches assign |report|, so it is non-empty
// on every path: the derivation's own reason when the band cannot be
// derived, and FormatInkScan's reading-level text otherwise, which carries
// a refused scan's reason exactly as it carries a classified one's numbers.
// That refused-scan half is unreachable THROUGH MeasurePaintedInk, because
// DeriveBand always hands ScanInk a non-empty band inside the image and a
// rows list every entry of which lies inside that band; the self-test
// therefore measures it as FormatInkScan over a ScanInk refusal.
[[nodiscard]] InkMeasure MeasurePaintedInk(
const QImage &image,
QRect box,
QColor fill,
std::vector<InkCandidate> candidates);
// One cluster read out of a chromatic raster. |box|, |color| and |density|
// are filled only when |read()| is true. A refusal leaves them empty and
// carries |refusal|, so a caller cannot treat an empty box as a measurement.
struct ChromaticBox {
QRect box;
QString refusal;
[[nodiscard]] bool read() const {
return refusal.isEmpty();
}
};
struct ChromaticMean {
QColor color;
QString refusal;
[[nodiscard]] bool read() const {
return refusal.isEmpty();
}
};
struct ChromaticDensity {
float64 density = 0.;
QString refusal;
[[nodiscard]] bool read() const {
return refusal.isEmpty();
}
};
// Found means a square window of the chromatic bounding box's height cleared
// kChromaticDensityFloor. NoChromatic and BelowDensity were scanned, so
// |totalChromatic| is a real count (zero for NoChromatic) and ok stays true.
// OutsideBand looked at no pixel. NotScanned is the unread value. The mean
// is the average of the pixels in the densest window, never a colour the
// caller passed as |pen| or |fill|. readBox, readMean and readDensity refuse
// on every state except Found.
enum class ChromaticRasterState {
NotScanned,
OutsideBand,
NoChromatic,
BelowDensity,
Found,
};
[[nodiscard]] QString ChromaticRasterStateName(ChromaticRasterState state);
struct ChromaticRaster {
bool ok = false;
QRect band;
int totalChromatic = 0;
QRect box;
int matched = 0;
float64 density = 0.;
QColor mean;
ChromaticRasterState state = ChromaticRasterState::NotScanned;
QString reason;
[[nodiscard]] bool found() const {
return state == ChromaticRasterState::Found;
}
[[nodiscard]] ChromaticBox readBox() const;
[[nodiscard]] ChromaticMean readMean() const;
[[nodiscard]] ChromaticDensity readDensity() const;
};
// Keeps pixels in |band| whose channel spread clears kChromaticFloor and
// that match neither |pen| nor |fill| within kSameTolerance, then reports
// the densest square window. An invalid |pen| or |fill| is not an exclusion.
[[nodiscard]] ChromaticRaster ReadChromaticRaster(
const QImage &image,
QRect band,
QColor pen,
QColor fill);
// Prints the box, matched count, total chromatic count, density and measured
// mean on a found reading, and names the refusal on every other reading.
// A refused box, density or mean is the text "none", not an empty value.
[[nodiscard]] QString FormatChromaticRaster(const ChromaticRaster &reading);
// Measured means at least one solid core was read, so the modal colour,
// its count, the core count and every pen's solid count are measurements.
// NoPaint and NoSolidCore were scanned, so |ok| stays true and |strongest|
// is a real distance, but they hand back no colour and no count. OutsideBand
// looked at no pixel. Unread is the value before ReadGlyphCore. A named pen
// is only something to count against: it is never copied into |modal|.
enum class GlyphCoreState {
Unread,
OutsideBand,
NoPaint,
NoSolidCore,
Measured,
};
[[nodiscard]] QString GlyphCoreStateName(GlyphCoreState state);
// |color| is the modal solid-core colour, |count| is how many cores have
// it, and |cores| is the solid-core count. All three are unset, and
// |refusal| is non-empty, unless the reading measured a core.
struct GlyphCoreModal {
QColor color;
int count = -1;
int cores = -1;
QString refusal;
[[nodiscard]] bool read() const {
return refusal.isEmpty();
}
};
// One named pen's solid-core count. |count| is -1 and |refusal| is
// non-empty on every refusing path, including an index this reading does
// not have, so a refused ask is never a measured zero. The pen colour is
// not a field: a count is not an echo of the query.
struct GlyphCoreSolid {
int index = -1;
int count = -1;
QString refusal;
[[nodiscard]] bool read() const {
return refusal.isEmpty();
}
};
struct GlyphCore {
bool ok = false;
QRect band;
QColor background;
int total = 0;
int inkPixels = 0;
int cores = 0;
int strongest = -1;
QColor modal;
int modalCount = -1;
std::vector<InkCandidate> pens;
std::vector<int> solid;
GlyphCoreState state = GlyphCoreState::Unread;
QString reason;
// Refuse every state except Measured. |solid| is sized to |pens| on
// every path ReadGlyphCore returns, with -1 until a measured reading
// fills it, so the accessor does not read off the end of an empty vector.
[[nodiscard]] GlyphCoreModal readModal() const;
[[nodiscard]] GlyphCoreSolid solidAt(int index) const;
};
// Band background is the modal colour of the clip. Ink is a pixel at least
// kInkDelta from it. A solid core is an ink pixel within kGlyphCoreTolerance
// of the per-channel extreme away from that background. The modal colour is
// the mode of those cores. |pens| may be empty; each entry is only counted.
[[nodiscard]] GlyphCore ReadGlyphCore(
const QImage &image,
QRect band,
std::vector<InkCandidate> pens = {});
// Prints the band, measured background, core tolerance, strongest distance,
// core count, modal colour and share, and each pen's solid count on a
// measured reading. A refused colour or count is the text "none".
[[nodiscard]] QString FormatGlyphCore(const GlyphCore &reading);
// AppendDeriveBandSelfTest is the underivable-band refusal measuring
// itself. Three synthetic images, no widget, no window, no session, chats,
// network, account or wallet: the same fill inside and outside the
// candidate (new reason, and MeasurePaintedInk.report carries it), a
// contrasting surround that still derives, and an inside that does not
// match the fill (existing no-rows reason). It emits no deliberate
// failure.
//
// Its later stages are the ink scan measuring itself over the same kind of
// synthetic images: a band that classifies two candidates with its counts
// read back through InkScan::countAt, an out-of-range ask that refuses and
// still lets the stage write the verdict lines after it, the two ok = false
// refusals read back beside all four DeriveBand reasons, a colliding
// candidate pair whose reading names the collision, a scanned band with no
// ink, the not-scanned reading a refused derivation leaves behind, and
// FormatInkScan's candidate colours and thresholds on a passing verdict.
// The background-collinear stage and the chromatic-raster stage register
// here too: a triple the measured background cannot separate, beside one
// that still classifies, and a dense mark beside an equal-count smear and
// a flat fill. The glyph-core stage registers here too.
void AppendDeriveBandSelfTest(not_null<Runner*> runner);
} // namespace Test