Every embedding now carries the quality of the input it came from. Both axes fall out of the AR-005 warp for free: crop_sharpness() is the normalised Laplacian variance over the aligned 112x112, so contrast and size cannot leak into it, and the alignment residual is the part of the landmark deformation a similarity transform cannot explain, so in-plane roll reads as zero and foreshortening does not. Carried, not consumed. Nothing discounts or thresholds on either number yet -- that is AR-030 and VR-012, and the knee has to be located against recorded data before a gate is chosen. What this change buys is that the data exists to locate it with. No face is admitted unscored: the -1 sentinel is preserved rather than clamped, and a degenerate landmark fit is counted rather than silently dropped. Takes the VR-001 dump to schema_version 2. The bump is not for readers, which check for the datasets by name and replay a v1 dump unchanged; it is so a consumer can tell "never scored" from "scored zero", which is not recoverable from the arrays afterwards. TRACES: AR-028, AR-029, AR-030 | VR-001 | SR-002
454 lines
18 KiB
C++
454 lines
18 KiB
C++
// TRACES: AR-005, AR-028, AR-029, AR-030 | UT-130, UT-131, UT-132, UT-133, UT-134, UT-135, UT-136, UT-137, UT-138 | SR-002
|
||
//
|
||
// Unit tests for the geometric/numeric helpers in types.hpp and face_utils.hpp:
|
||
// cosine_similarity, the ArcFace 5-point alignment transform, and the two
|
||
// measured axes of the AR-028 quality vector — the alignment residual AR-030
|
||
// reads as visibility, and the normalised Laplacian variance AR-029 reads as
|
||
// sharpness. The aligner node is exercised here too, since it is the unit that
|
||
// fills the vector in. GPU-free, model-free.
|
||
#include <catch2/catch_test_macros.hpp>
|
||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||
|
||
#include "face_utils.hpp"
|
||
#include "nodes/face_aligner_node.hpp"
|
||
#include "types.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <array>
|
||
#include <cmath>
|
||
#include <cstdint>
|
||
#include <limits>
|
||
|
||
using Catch::Matchers::WithinAbs;
|
||
using Catch::Matchers::WithinRel;
|
||
|
||
TEST_CASE("cosine_similarity of a unit vector with itself is 1", "[types]") {
|
||
std::array<float, 512> raw{};
|
||
raw[3] = 2.f; raw[7] = -1.f;
|
||
Embedding e = l2_normalise(raw.data());
|
||
CHECK_THAT(cosine_similarity(e, e), WithinAbs(1.0f, 1e-6f));
|
||
}
|
||
|
||
TEST_CASE("cosine_similarity of orthogonal vectors is 0", "[types]") {
|
||
Embedding a{}, b{};
|
||
a[0] = 1.f;
|
||
b[1] = 1.f;
|
||
CHECK_THAT(cosine_similarity(a, b), WithinAbs(0.0f, 1e-6f));
|
||
}
|
||
|
||
TEST_CASE("cosine_similarity of opposite vectors is -1", "[types]") {
|
||
Embedding a{}, b{};
|
||
a[5] = 1.f;
|
||
b[5] = -1.f;
|
||
CHECK_THAT(cosine_similarity(a, b), WithinAbs(-1.0f, 1e-6f));
|
||
}
|
||
|
||
TEST_CASE("align_face maps the reference landmarks onto the 112x112 canvas", "[face_utils]") {
|
||
// Build a synthetic image where the five landmarks sit at known positions.
|
||
// Feeding align_face the *reference* positions themselves should yield an
|
||
// (near-)identity similarity transform, so the output is 112x112.
|
||
cv::Mat img(200, 200, CV_8UC3, cv::Scalar(0, 0, 0));
|
||
std::array<cv::Point2f, 5> lm;
|
||
for (int i = 0; i < 5; ++i) {
|
||
lm[i] = {kArcFaceRef[i][0], kArcFaceRef[i][1]};
|
||
cv::circle(img, lm[i], 2, cv::Scalar(255, 255, 255), -1);
|
||
}
|
||
|
||
cv::Mat crop = align_face(img, lm);
|
||
REQUIRE_FALSE(crop.empty());
|
||
CHECK(crop.cols == 112);
|
||
CHECK(crop.rows == 112);
|
||
}
|
||
|
||
TEST_CASE("align_face is translation-equivariant", "[face_utils]") {
|
||
// Shifting all landmarks by a constant offset must still produce a valid
|
||
// 112x112 crop (the similarity transform absorbs the translation).
|
||
cv::Mat img(300, 300, CV_8UC3, cv::Scalar(30, 30, 30));
|
||
std::array<cv::Point2f, 5> lm;
|
||
const float dx = 100.f, dy = 80.f;
|
||
for (int i = 0; i < 5; ++i)
|
||
lm[i] = {kArcFaceRef[i][0] + dx, kArcFaceRef[i][1] + dy};
|
||
|
||
cv::Mat crop = align_face(img, lm);
|
||
REQUIRE_FALSE(crop.empty());
|
||
CHECK(crop.cols == 112);
|
||
CHECK(crop.rows == 112);
|
||
}
|
||
|
||
TEST_CASE("align_face returns empty on degenerate (collinear) landmarks", "[face_utils]") {
|
||
// All five landmarks identical → the affine fit is degenerate.
|
||
cv::Mat img(200, 200, CV_8UC3, cv::Scalar(0, 0, 0));
|
||
std::array<cv::Point2f, 5> lm;
|
||
for (auto& p : lm) p = {50.f, 50.f};
|
||
|
||
cv::Mat crop = align_face(img, lm);
|
||
CHECK(crop.empty());
|
||
}
|
||
|
||
// ── AR-030: the alignment residual as a visibility measure ────────────────────
|
||
// These assert the *properties* the measure is relied on for, not a magic value.
|
||
// Each would fail under a RANSAC fit, which buys a small residual by discarding
|
||
// the very landmarks that carry the signal.
|
||
|
||
namespace {
|
||
|
||
std::array<cv::Point2f, 5> canonical() {
|
||
std::array<cv::Point2f, 5> lm;
|
||
for (int i = 0; i < 5; ++i) lm[i] = {kArcFaceRef[i][0], kArcFaceRef[i][1]};
|
||
return lm;
|
||
}
|
||
|
||
// Rotate by `deg` in-plane, scale uniformly, translate — i.e. exactly the 4 DoF
|
||
// the similarity transform models.
|
||
std::array<cv::Point2f, 5> similarity(const std::array<cv::Point2f, 5>& in,
|
||
float deg, float s, float tx, float ty) {
|
||
const float r = deg * 3.14159265358979f / 180.f;
|
||
const float c = std::cos(r), sn = std::sin(r);
|
||
std::array<cv::Point2f, 5> out;
|
||
for (int i = 0; i < 5; ++i)
|
||
out[i] = {s * (c * in[i].x - sn * in[i].y) + tx,
|
||
s * (sn * in[i].x + c * in[i].y) + ty};
|
||
return out;
|
||
}
|
||
|
||
// Squash x about the centroid by `k`: the anisotropic deformation an out-of-plane
|
||
// yaw produces, and the one a similarity provably cannot absorb.
|
||
std::array<cv::Point2f, 5> foreshorten(const std::array<cv::Point2f, 5>& in, float k) {
|
||
float cx = 0.f;
|
||
for (const auto& p : in) cx += p.x;
|
||
cx /= 5.f;
|
||
std::array<cv::Point2f, 5> out = in;
|
||
for (auto& p : out) p.x = cx + (p.x - cx) * k;
|
||
return out;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
TEST_CASE("residual is zero for a face in canonical pose", "[face_utils][AR-030]") {
|
||
const Alignment a = estimate_alignment(canonical());
|
||
REQUIRE(a.ok);
|
||
CHECK_THAT(a.residual, WithinAbs(0.0f, 1e-3f));
|
||
}
|
||
|
||
TEST_CASE("residual ignores in-plane roll, scale and translation", "[face_utils][AR-030]") {
|
||
// The structural claim behind AR-030: the fit absorbs all four similarity
|
||
// DoF exactly, so what remains is only the deformation a similarity cannot
|
||
// explain. A rolled head must not read as a turned one.
|
||
for (float deg : {-40.f, -12.f, 0.f, 17.f, 65.f}) {
|
||
const Alignment a = estimate_alignment(similarity(canonical(), deg, 3.5f, 220.f, -40.f));
|
||
REQUIRE(a.ok);
|
||
CHECK_THAT(a.residual, WithinAbs(0.0f, 1e-3f));
|
||
}
|
||
}
|
||
|
||
TEST_CASE("residual rises monotonically with foreshortening", "[face_utils][AR-030]") {
|
||
float prev = -1.f;
|
||
for (float k : {1.0f, 0.9f, 0.75f, 0.5f, 0.3f}) {
|
||
const Alignment a = estimate_alignment(foreshorten(canonical(), k));
|
||
REQUIRE(a.ok);
|
||
CHECK(a.residual > prev);
|
||
prev = a.residual;
|
||
}
|
||
}
|
||
|
||
TEST_CASE("residual is independent of face size", "[face_utils][AR-030]") {
|
||
// The measure must not silently re-express face size — that is AR-002's job,
|
||
// and double-counting it would make a small frontal face look occluded.
|
||
// Same deformation, two very different face sizes, one answer.
|
||
const auto small = similarity(foreshorten(canonical(), 0.7f), 20.f, 1.0f, 0.f, 0.f);
|
||
const auto large = similarity(foreshorten(canonical(), 0.7f), 20.f, 12.0f, 500.f, 300.f);
|
||
|
||
const Alignment a = estimate_alignment(small);
|
||
const Alignment b = estimate_alignment(large);
|
||
REQUIRE(a.ok);
|
||
REQUIRE(b.ok);
|
||
CHECK_THAT(b.residual, WithinAbs(a.residual, 1e-2f));
|
||
}
|
||
|
||
TEST_CASE("the fit never mirrors the face", "[face_utils][AR-030]") {
|
||
// SVD will happily return an orientation-reversing solution; a similarity
|
||
// transform may rotate but never reflect. Without the determinant guard a
|
||
// mirrored landmark set fits "perfectly" as a reflection.
|
||
const auto mirrored = foreshorten(canonical(), -1.f);
|
||
const Alignment a = estimate_alignment(mirrored);
|
||
REQUIRE(a.ok);
|
||
|
||
const double det = a.M.at<double>(0,0) * a.M.at<double>(1,1)
|
||
- a.M.at<double>(0,1) * a.M.at<double>(1,0);
|
||
CHECK(det > 0.0);
|
||
CHECK(a.residual > 1.0f); // and the mirroring shows up as misfit
|
||
}
|
||
|
||
TEST_CASE("degenerate landmarks report not-ok rather than a residual", "[face_utils][AR-030]") {
|
||
std::array<cv::Point2f, 5> lm;
|
||
for (auto& p : lm) p = {50.f, 50.f};
|
||
|
||
const Alignment a = estimate_alignment(lm);
|
||
CHECK_FALSE(a.ok);
|
||
CHECK(a.M.empty());
|
||
}
|
||
|
||
// ── AR-029: normalised Laplacian variance as a sharpness measure ──────────────
|
||
// As with AR-030 above, these assert the *properties* the measure is relied on
|
||
// for rather than magic values: no threshold is set here or anywhere else, so a
|
||
// number that drifted with the OpenCV version would still be usable — a number
|
||
// that stopped falling with blur, or started tracking exposure, would not.
|
||
|
||
namespace {
|
||
|
||
// Pink noise: broadband, but with a 1/f spectrum, so most of the energy sits at
|
||
// low frequency the way it does in a photograph. An LCG rather than cv::randu so
|
||
// the ladder is identical on every machine and every OpenCV build.
|
||
//
|
||
// **The 1/f part is load-bearing, not decoration.** On a flat-spectrum texture
|
||
// (raw white noise) the Gaussian ladder still falls, but the motion-blur ladder
|
||
// *rises* — 80.1 → 90.0 across the same kernel lengths used below. That is not a
|
||
// bug in the measure, it is what a normalised measure must do on such an input:
|
||
// a horizontal smear takes energy out of the numerator and the denominator
|
||
// together, and what survives is vertical detail that really is just as fine.
|
||
// Real crops have the low-frequency mass that keeps the denominator steady while
|
||
// the numerator falls. See the second hazard note on crop_sharpness().
|
||
cv::Mat pink(int size, uint32_t seed = 12345u) {
|
||
cv::Mat white(size, size, CV_32F);
|
||
uint32_t s = seed;
|
||
for (int y = 0; y < size; ++y)
|
||
for (int x = 0; x < size; ++x) {
|
||
s = s * 1664525u + 1013904223u;
|
||
white.at<float>(y, x) = float((s >> 16) & 0xFFFF) / 65535.f - 0.5f;
|
||
}
|
||
|
||
// Octaves weighted 1/f. The sigma=0 band keeps genuine per-pixel detail in,
|
||
// so the top of the blur ladder is a sharp image rather than an already-soft
|
||
// one.
|
||
cv::Mat acc = cv::Mat::zeros(size, size, CV_32F);
|
||
const double sigma[] = {0.0, 1.0, 2.0, 4.0, 8.0};
|
||
const double weight[] = {1.0, 2.0, 4.0, 8.0, 16.0};
|
||
for (int k = 0; k < 5; ++k) {
|
||
cv::Mat band;
|
||
if (sigma[k] <= 0.0) band = white.clone();
|
||
else cv::GaussianBlur(white, band, {0, 0}, sigma[k], sigma[k], cv::BORDER_REPLICATE);
|
||
acc += band * weight[k];
|
||
}
|
||
|
||
// Into [40, 215]: 8-bit like a real crop, with headroom at both ends so the
|
||
// contrast test can halve it without clipping.
|
||
double lo = 0, hi = 0;
|
||
cv::minMaxLoc(acc, &lo, &hi);
|
||
const double scale = 175.0 / (hi - lo);
|
||
cv::Mat out;
|
||
acc.convertTo(out, CV_8U, scale, 40.0 - lo * scale);
|
||
return out;
|
||
}
|
||
|
||
// One master pattern, resampled. So "the same face at 40 px and at 400 px" is
|
||
// literally the same image at two resolutions, and a test about source
|
||
// resolution is not accidentally a test about two different textures.
|
||
// INTER_AREA because area-averaging is what a sensor does when it images the
|
||
// same subject onto fewer pixels.
|
||
cv::Mat texture(int size) {
|
||
static const cv::Mat master = pink(448);
|
||
if (size == master.cols) return master;
|
||
cv::Mat out;
|
||
cv::resize(master, out, {size, size}, 0, 0, cv::INTER_AREA);
|
||
return out;
|
||
}
|
||
|
||
cv::Mat gaussian(const cv::Mat& in, double sigma) {
|
||
if (sigma <= 0.0) return in.clone();
|
||
cv::Mat out;
|
||
cv::GaussianBlur(in, out, {0, 0}, sigma, sigma, cv::BORDER_REPLICATE);
|
||
return out;
|
||
}
|
||
|
||
// Horizontal box smear — motion blur, which is anisotropic and so attenuates
|
||
// only one axis of the spectrum. A measure tuned to the isotropic case can
|
||
// miss it.
|
||
cv::Mat motion(const cv::Mat& in, int len) {
|
||
if (len <= 1) return in.clone();
|
||
const cv::Mat k(1, len, CV_32F, cv::Scalar(1.0 / len));
|
||
cv::Mat out;
|
||
cv::filter2D(in, out, -1, k, {-1, -1}, 0, cv::BORDER_REPLICATE);
|
||
return out;
|
||
}
|
||
|
||
// The pipeline reaches 112×112 through warpAffine's INTER_LINEAR; resize with
|
||
// the same interpolation so a test about source resolution is not really a test
|
||
// about which resampler was used.
|
||
cv::Mat to_crop(const cv::Mat& in) {
|
||
cv::Mat out;
|
||
cv::resize(in, out, {112, 112}, 0, 0, cv::INTER_LINEAR);
|
||
return out;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
TEST_CASE("sharpness falls monotonically along a Gaussian blur ladder", "[face_utils][AR-029][UT-130]") {
|
||
const cv::Mat src = texture(112);
|
||
float prev = std::numeric_limits<float>::infinity();
|
||
for (double sigma : {0.0, 0.6, 1.0, 1.6, 2.5, 4.0}) {
|
||
const float s = crop_sharpness(gaussian(src, sigma));
|
||
CHECK(s < prev);
|
||
CHECK(s > 0.f);
|
||
prev = s;
|
||
}
|
||
}
|
||
|
||
TEST_CASE("sharpness falls monotonically under motion blur too", "[face_utils][AR-029][UT-131]") {
|
||
// Motion blur is the failure mode that leaves the bounding box looking
|
||
// perfectly healthy, so it is the one the measure exists for.
|
||
const cv::Mat src = texture(112);
|
||
float prev = std::numeric_limits<float>::infinity();
|
||
for (int len : {1, 3, 5, 9, 15}) {
|
||
const float s = crop_sharpness(motion(src, len));
|
||
CHECK(s < prev);
|
||
CHECK(s > 0.f);
|
||
prev = s;
|
||
}
|
||
}
|
||
|
||
TEST_CASE("contrast does not leak into sharpness", "[face_utils][AR-029][UT-132]") {
|
||
// The normalisation that makes the axis mean the same thing in a dim scene
|
||
// and a bright one. Without it VR-012 would locate a different knee per
|
||
// film — a magic number wearing a measurement's clothes (AR-024).
|
||
const cv::Mat src = texture(112);
|
||
|
||
cv::Mat dim;
|
||
src.convertTo(dim, CV_8U, 0.5, 64.0); // half contrast, re-centred, no clipping
|
||
|
||
const float a = crop_sharpness(src);
|
||
const float b = crop_sharpness(dim);
|
||
REQUIRE(a > 0.f);
|
||
CHECK_THAT(b, WithinRel(a, 0.03f));
|
||
}
|
||
|
||
TEST_CASE("the contrast invariance is exact, and 8-bit sampling is what bends it",
|
||
"[face_utils][AR-029]") {
|
||
// Worth separating because the two have different consequences. The
|
||
// algebra is exact — scaling I by α scales the Laplacian by α, so both
|
||
// variances scale by α² and cancel — which is why halving a float crop
|
||
// changes nothing at all.
|
||
//
|
||
// What deviates is the 8-bit *round trip*: halving the contrast of a stored
|
||
// crop throws away a bit of dynamic range, and the quantisation floor it
|
||
// leaves behind is broadband, so it lands almost entirely in the numerator.
|
||
// The effect scales with how little signal is left to compete with it —
|
||
// measured on this texture, a half-contrast copy reads 0.9% high when sharp,
|
||
// 24% high at sigma 1.2 and 148% high at sigma 2.5.
|
||
//
|
||
// So: a dim *and* soft crop reads sharper than it is, and that is the corner
|
||
// of the axis VR-012 has to put a knee in. Asserted here rather than left as
|
||
// a comment, because "the measure is contrast-invariant" is the kind of claim
|
||
// that gets repeated without its precondition.
|
||
cv::Mat src;
|
||
gaussian(texture(112), 1.2).convertTo(src, CV_32F);
|
||
const cv::Mat half = src * 0.5 + 64.0;
|
||
|
||
const float a = crop_sharpness(src);
|
||
const float b = crop_sharpness(half);
|
||
REQUIRE(a > 0.f);
|
||
CHECK_THAT(b, WithinRel(a, 1e-5f));
|
||
}
|
||
|
||
TEST_CASE("a small sharp face outscores a large soft one", "[face_utils][AR-029][UT-134]") {
|
||
// The register's named edge case: "size must not leak into this axis". What
|
||
// that means operationally is that the measure is not a monotone function of
|
||
// source face size — it reports the detail present in the embedder's input,
|
||
// so the ordering can and must invert when the large face is the blurred one.
|
||
//
|
||
// Small sharp: 40 px of real detail, upsampled 2.8x → finest scale ~2.8 crop px.
|
||
// Large soft: 400 px blurred at sigma 20, downsampled 3.57x → ~5.6 crop px.
|
||
const float small_sharp = crop_sharpness(to_crop(texture(40)));
|
||
const float large_soft = crop_sharpness(to_crop(gaussian(texture(400), 20.0)));
|
||
|
||
CHECK(small_sharp > large_soft);
|
||
}
|
||
|
||
TEST_CASE("a flat crop scores zero rather than dividing by zero", "[face_utils][AR-029][UT-135]") {
|
||
const cv::Mat flat(112, 112, CV_8UC3, cv::Scalar(90, 90, 90));
|
||
const float s = crop_sharpness(flat);
|
||
CHECK(std::isfinite(s));
|
||
CHECK_THAT(s, WithinAbs(0.0f, 1e-6f));
|
||
}
|
||
|
||
TEST_CASE("an empty crop is unscored, not zero", "[face_utils][AR-029][UT-136]") {
|
||
// -1 says "nothing measured this"; 0 says "measured, and there was no
|
||
// detail". Collapsing them would put unscored faces at the bottom of the
|
||
// quality axis, where VR-012 would read them as the blurriest in the film.
|
||
CHECK(crop_sharpness(cv::Mat()) < 0.f);
|
||
}
|
||
|
||
// ── AR-028: the aligner fills the vector, and loses nothing quietly ───────────
|
||
|
||
namespace {
|
||
|
||
// A face at `centre` in an image with enough texture for sharpness to be a real
|
||
// number rather than the flat-crop zero.
|
||
std::array<cv::Point2f, 5> face_at(cv::Point2f centre, float scale) {
|
||
std::array<cv::Point2f, 5> lm;
|
||
for (int i = 0; i < 5; ++i)
|
||
lm[i] = {centre.x + (kArcFaceRef[i][0] - 56.f) * scale,
|
||
centre.y + (kArcFaceRef[i][1] - 56.f) * scale};
|
||
return lm;
|
||
}
|
||
|
||
cv::Mat textured_frame(int w, int h) {
|
||
cv::Mat gray = texture(std::max(w, h));
|
||
cv::Mat bgr;
|
||
cv::cvtColor(gray(cv::Rect(0, 0, w, h)), bgr, cv::COLOR_GRAY2BGR);
|
||
return bgr;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
TEST_CASE("every face the aligner admits carries a full quality vector", "[face_utils][AR-028][UT-137]") {
|
||
SceneFrame sf;
|
||
sf.source.image = textured_frame(400, 300);
|
||
for (auto c : {cv::Point2f{120.f, 100.f}, cv::Point2f{280.f, 190.f}}) {
|
||
DetectedFace f;
|
||
f.landmarks = face_at(c, 1.2f);
|
||
f.bbox = cv::Rect2f(c.x - 60.f, c.y - 60.f, 120.f, 120.f);
|
||
f.confidence = 0.9f;
|
||
sf.faces.push_back(f);
|
||
}
|
||
|
||
FaceAlignerFunc aligner;
|
||
const AlignedSceneFrame out = aligner(std::move(sf));
|
||
|
||
REQUIRE(out.faces.size() == 2);
|
||
for (const auto& f : out.faces) {
|
||
// Not "is it good quality" — that is VR-012's to decide. Only that the
|
||
// sentinel is gone, so no embedding reaches the matcher unscored.
|
||
CHECK(f.sharpness >= 0.f);
|
||
CHECK(f.alignment_residual >= 0.f);
|
||
}
|
||
CHECK(aligner.scored() == 2);
|
||
CHECK(aligner.degenerate() == 0);
|
||
}
|
||
|
||
TEST_CASE("a degenerate detection is counted, not silently vanished", "[face_utils][AR-028][UT-138]") {
|
||
// It cannot be scored — there is no crop and no fit to score — so it is
|
||
// dropped. The requirement is that the drop leaves a trace: without the
|
||
// tally, a detector emitting unusable landmark sets produces a dump that
|
||
// looks exactly like footage with fewer faces in it.
|
||
SceneFrame sf;
|
||
sf.source.image = textured_frame(400, 300);
|
||
|
||
DetectedFace good;
|
||
good.landmarks = face_at({150.f, 140.f}, 1.2f);
|
||
good.confidence = 0.9f;
|
||
sf.faces.push_back(good);
|
||
|
||
DetectedFace degenerate;
|
||
for (auto& p : degenerate.landmarks) p = {200.f, 200.f};
|
||
degenerate.confidence = 0.9f;
|
||
sf.faces.push_back(degenerate);
|
||
|
||
FaceAlignerFunc aligner;
|
||
const AlignedSceneFrame out = aligner(std::move(sf));
|
||
|
||
CHECK(out.faces.size() == 1);
|
||
CHECK(out.crops.size() == 1);
|
||
CHECK(aligner.scored() == 1);
|
||
CHECK(aligner.degenerate() == 1);
|
||
}
|