feat: grade a sparse window's polyline by slope, so phosphor does not switch off on zoom
Intensity grading was gated on the column reduction, which is gated on a density whose only job is killing the moire beat. Dwell has nothing to do with that, so narrowing a window past the threshold turned the whole phosphor model off and a discontinuity went from a third of a flat top's ink to all of it in one pixel of zoom. stage_trace_slopes() grades each segment by its own slope instead, with a measure that agrees with the dense path at the boundary, and reports itself as FrameStats::slope_traces -- disjoint from column_traces, so a host asserting graded_traces == column_traces reads the same as before. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc908efa36
commit
37e414bcc5
8 files changed
+442
-13
No files matched your search
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cmath>
|
||||
#include <chrono>
|
||||
#include <algorithm>
|
||||
@@ -57,7 +58,9 @@ inline void check_near(double a, double b, const char* file, int line, const cha
|
||||
|
||||
inline int run_all() {
|
||||
int failed = 0;
|
||||
const char* only = std::getenv("PLOT_TEST_FILTER");
|
||||
for (auto& c : cases()) {
|
||||
if (only != nullptr && std::string(c.name).find(only) == std::string::npos) continue;
|
||||
try {
|
||||
c.fn();
|
||||
std::printf("[ ok ] %s\n", c.name);
|
||||
|
||||
@@ -6985,3 +6985,230 @@ PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) {
|
||||
}
|
||||
(void)layout;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/// A true square wave: `period` samples a cycle, half at +1 and half at -1, so every
|
||||
/// transition is a single-sample discontinuity. That is the waveform the phosphor model
|
||||
/// is hardest on -- a riser has no intermediate samples to deposit ink in, so whatever
|
||||
/// the grading does to it is the whole of what a reader sees.
|
||||
std::vector<plot::f32> square_samples(plot::u64 period) {
|
||||
std::vector<plot::f32> y(1'000'000);
|
||||
for (plot::u64 i = 0; i < y.size(); ++i) {
|
||||
y[i] = (i % period) < period / 2u ? 1.0f : -1.0f;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/// A column the signal only *passed* through must still be a column it reached:
|
||||
/// `intensity_floor` exists so that "a riser dimmed out of existence is a square wave
|
||||
/// drawn as two disconnected lines", and the per-column grading used to break that
|
||||
/// promise on exactly the waveform it names.
|
||||
///
|
||||
/// The cause was the "lit at all" cutoff inside the column reduction. Grading normalises
|
||||
/// a column against its own peak and then discards sub-buckets under `0.01 * peak`. A
|
||||
/// column holding both a flat level and a riser has a peak set by the level -- the level
|
||||
/// deposits its whole unit into one row -- while the riser spreads a unit over 300 rows,
|
||||
/// so every riser row lands three orders of magnitude under the cutoff and is dropped
|
||||
/// before the floor can be applied to it. The floor never sees the riser; there is no
|
||||
/// tier 0 to fall back to.
|
||||
///
|
||||
/// Measured over this frame's 116 riser columns: the graded path inked 2 916 of the
|
||||
/// ungraded path's 35 884 rows (8%), gutting 106 of the 116 and leaving a 311-row dark
|
||||
/// run in a 312-row axis. The ten survivors are the phase that puts a riser in a column
|
||||
/// of its own, which is what made the defect read as a *flicker* under panning rather
|
||||
/// than as a missing edge. The cutoff is now `min(0.01 * peak, 0.5 * w_min)`, so it can
|
||||
/// never exclude a deposit some segment actually made, and the reading is 35 572 rows
|
||||
/// (99.1%) with 115 of 116 columns intact.
|
||||
PLOT_TEST(a_graded_column_keeps_a_riser_it_shares_with_a_flat_level) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
// 2098 samples a cycle over 104.8 samples a column is about 20 columns a cycle, so a
|
||||
// riser's column also holds a long stretch of the level it leaves -- the sharing this
|
||||
// test is about -- rather than being a column of pure transition.
|
||||
plot::data::ArraySeries s(square_samples(2098), 0.0, 1.0);
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||
|
||||
// Columns whose band spans more than half the axis: on a square wave those are the
|
||||
// transitions and nothing else, since a level's own band is a ribbon a few rows deep.
|
||||
// "Tall" is the property at stake -- a riser drawn as a stub is as wrong as one not
|
||||
// drawn at all -- so it is both how the columns are chosen and what is asserted of
|
||||
// them, read from the ungraded frame and then re-read from the graded one.
|
||||
const auto tall_columns = [&](const plot::Rect& a) {
|
||||
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
||||
std::vector<plot::u32> out;
|
||||
for (plot::u32 col = 0; col < static_cast<plot::u32>(a.w); ++col) {
|
||||
plot::u32 lo = rows;
|
||||
plot::u32 hi = 0;
|
||||
for (plot::u32 row = 0; row < rows; ++row) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + col,
|
||||
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
||||
// 20 of the 191 a full stroke inks, which is under the faintest tier
|
||||
// (`intensity_floor` 0.3) -- so a row this rejects is a row no tier drew.
|
||||
if (static_cast<int>(px[2]) - static_cast<int>(px[0]) > 20) {
|
||||
if (row < lo) lo = row;
|
||||
hi = row;
|
||||
}
|
||||
}
|
||||
if (lo < hi && hi - lo > rows / 2u) out.push_back(col);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// The axes rect is read *after* a draw: `Plot::layout()` sizes its label strips from
|
||||
// the tick text at the style's font, so before the first frame it answers with the
|
||||
// unmeasured estimate and the window would be off by 11 columns in x.
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
dump_if_requested(r, "riser-ungraded.ppm");
|
||||
const plot::Rect a = p.layout().axes;
|
||||
const std::vector<plot::u32> flat = tall_columns(a);
|
||||
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 0);
|
||||
|
||||
r.style().intensity_grading = true;
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
dump_if_requested(r, "riser-graded.ppm");
|
||||
const std::vector<plot::u32> lit = tall_columns(a);
|
||||
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
|
||||
// How many of the ungraded frame's own riser columns survived the grading, which is
|
||||
// the reading the ratio below is taken from. Both lists are ascending, so this is one
|
||||
// pass.
|
||||
plot::u32 kept = 0;
|
||||
for (plot::usize i = 0, j = 0; i < flat.size() && j < lit.size();) {
|
||||
if (flat[i] == lit[j]) {
|
||||
++kept;
|
||||
++i;
|
||||
++j;
|
||||
} else if (flat[i] < lit[j]) {
|
||||
++i;
|
||||
} else {
|
||||
++j;
|
||||
}
|
||||
}
|
||||
std::printf("[riser] axes=[%.1f %.1f %.1f %.1f] flat=%zu graded=%zu kept=%u\n", a.x, a.y, a.w,
|
||||
a.h, flat.size(), lit.size(), kept);
|
||||
|
||||
// Enough columns to make the ratio mean something; 116 were measured.
|
||||
PLOT_CHECK(flat.size() > 50u);
|
||||
// And the grading kept all but one of them. The one is real and is not the defect: the
|
||||
// ungraded path inks *two* full-height columns per transition -- a column's own min/max,
|
||||
// the connectivity extension to its neighbour, and the gathered window's slack sample
|
||||
// all land in x -- where the reduction inks one, which is also why 116 columns appear
|
||||
// for 58 transitions. Before the fix this read 10 of 116.
|
||||
PLOT_CHECK(kept * 100u >= static_cast<plot::u32>(flat.size()) * 95u);
|
||||
}
|
||||
|
||||
/// Dwell dimming must not switch off when the window gets narrow. It used to, because
|
||||
/// the grading was gated on the *column reduction*, and that reduction is gated on two
|
||||
/// gathered points per pixel column -- a density whose only purpose is removing the
|
||||
/// moire beat. Zooming past `dense_threshold_spp` therefore turned the whole phosphor
|
||||
/// model off in one step, and a discontinuity that had been drawn at a third of the
|
||||
/// level's ink was suddenly drawn at all of it: measured 71 of 191 at 3.4 samples a
|
||||
/// pixel and 191 of 191 at 1.0, which is what a user reported as an edge that "lights
|
||||
/// up" on zoom.
|
||||
///
|
||||
/// `Renderer::stage_trace_slopes()` grades the polyline by each segment's own slope, so
|
||||
/// the model now spans both rasterisation paths. The riser reads 71 either side of the
|
||||
/// threshold, and this test is that pair of readings.
|
||||
PLOT_TEST(a_sparse_trace_is_graded_by_slope_like_a_dense_one) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
plot::data::ArraySeries s(square_samples(2098), 0.0, 1.0);
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
|
||||
// -2..2 so the two levels sit a quarter in from each edge rather than on it: a level
|
||||
// on the boundary row is half clipped away, and its ink would then read low for a
|
||||
// reason that has nothing to do with grading.
|
||||
const plot::u32 ay = p.add_y_axis({"v", {-2.0, 2.0}, false, plot::AxisPlace::Left, false});
|
||||
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||
r.style().intensity_grading = true;
|
||||
|
||||
struct Reading {
|
||||
int riser = 0; ///< peak ink in the riser's own columns, at mid height
|
||||
int flat = 0; ///< peak ink in a level's columns, well clear of the riser
|
||||
};
|
||||
// Both windows are centred on the transition at sample 1049, so the riser's column is
|
||||
// known by construction rather than searched for -- a search would have to decide what
|
||||
// a riser looks like, which is the very thing in question.
|
||||
const auto measure = [&](double window) {
|
||||
p.set_x_range(ax, {1049.0 - window / 2.0, 1049.0 + window / 2.0});
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
const plot::Rect a = p.layout().axes;
|
||||
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
||||
const plot::u32 cols = static_cast<plot::u32>(a.w);
|
||||
const plot::u32 mid = cols / 2u;
|
||||
Reading out;
|
||||
for (plot::u32 col = 0; col < cols; ++col) {
|
||||
for (plot::u32 row = 0; row < rows; ++row) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + col,
|
||||
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
||||
const int cover = static_cast<int>(px[2]) - static_cast<int>(px[0]);
|
||||
if (col + 6u < mid || col > mid + 6u) {
|
||||
// A level's own ink. The six-column guard is for the near-vertical stroke's
|
||||
// own width plus the connector either side of it.
|
||||
if (cover > out.flat) out.flat = cover;
|
||||
} else if (row >= 3u * rows / 8u && row <= 5u * rows / 8u) {
|
||||
// The riser's own ink: the middle quarter of the axis, which neither level
|
||||
// reaches. Reading the full height instead answers 191 at every zoom, because
|
||||
// the riser's column contains a level too.
|
||||
if (cover > out.riser) out.riser = cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// 3.4 samples a pixel and 1.0, so one window either side of `dense_threshold_spp`
|
||||
// (1.5) over the same waveform at the same place.
|
||||
const Reading dense = measure(2000.0);
|
||||
const plot::FrameStats dense_stats = r.stats();
|
||||
const Reading sparse = measure(600.0);
|
||||
const plot::FrameStats sparse_stats = r.stats();
|
||||
std::printf("[slope] dense riser=%d flat=%d graded=%u slopes=%u | sparse riser=%d flat=%d "
|
||||
"graded=%u slopes=%u\n",
|
||||
dense.riser, dense.flat, dense_stats.graded_traces, dense_stats.slope_traces,
|
||||
sparse.riser, sparse.flat, sparse_stats.graded_traces, sparse_stats.slope_traces);
|
||||
|
||||
// The two frames took the two paths, or the comparison is between a frame and itself.
|
||||
PLOT_CHECK(dense_stats.graded_traces == 1 && dense_stats.slope_traces == 0);
|
||||
PLOT_CHECK(sparse_stats.slope_traces == 1 && sparse_stats.graded_traces == 0);
|
||||
// Neither path dims the level: a flat segment dwells in its row, so it is the top tier
|
||||
// and full ink. Both read 191.
|
||||
PLOT_CHECK(dense.flat > 180 && sparse.flat > 180);
|
||||
// And both dim the discontinuity, to about a third -- `intensity_floor` is 0.3 and a
|
||||
// riser spanning the axis is the longest span there is, so it sits on the floor. Both
|
||||
// read 71. Before the slope grading the sparse frame read 191 here.
|
||||
PLOT_CHECK(dense.riser * 100 < dense.flat * 55);
|
||||
PLOT_CHECK(sparse.riser * 100 < sparse.flat * 55);
|
||||
// The point of the exercise: the same edge at the two zooms is the same ink, so there
|
||||
// is no step at the threshold for a reader to see. A tier either way is 23 of 191, and
|
||||
// the measured pair is exact.
|
||||
PLOT_CHECK(std::abs(dense.riser - sparse.riser) < 24);
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
}
|
||||
Reference in new issue
Block a user