Skip to content

Commit ee5801c

Browse files
authored
Merge pull request #149 from Gohnnyman/feat/border-sampling
feature: edgeDepth, smoothing and snapAbove added to PictureEffect
2 parents 1b627f6 + 032ae45 commit ee5801c

3 files changed

Lines changed: 181 additions & 3 deletions

File tree

‎docs/help/help.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1295,6 +1295,17 @@
12951295
"sampling": {
12961296
"line": "One pixel per light, or the mean of the light's rectangle.",
12971297
"more": "**point** takes the one pixel at a light's center, sharp and the fastest. With **area** each light averages its rectangle of the picture, so fine detail stays steady when a large picture shrinks onto few lights."
1298+
},
1299+
"edgeDepth": {
1300+
"line": "How far in the edge lights look, in percent; 0 is their own share.",
1301+
"more": "A border strip's outermost lights own a sliver at the edge, where letterboxes and overscan live. Raise **edgeDepth** and they reach further in, as Hyperion's 8% does; a stride keeps the reads the same."
1302+
},
1303+
"smoothing": {
1304+
"line": "How slowly a light eases to a new color, as a half-life; 0 is off.",
1305+
"more": "Raise **smoothing** and a light eases toward its new color, settling a noisy source alike on every source and board. A cut is a real jump, so **snapAbove** lands a bigger move whole; at 0 every move eases."
1306+
},
1307+
"snapAbove": {
1308+
"line": "A channel moving further than this jumps instead of easing."
12981309
}
12991310
},
13001311
"role": "effect",

‎src/light/effects/PictureEffect.h‎

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,51 @@ class PictureEffect : public EffectBase {
2626
/// One pixel per light, or the mean of the light's rectangle.
2727
/// @guide **point** takes the one pixel at a light's center, sharp and the fastest. With **area** each light averages its rectangle of the picture, so fine detail stays steady when a large picture shrinks onto few lights.
2828
uint8_t sampling = kPoint;
29+
/// How far in the edge lights look, in percent; 0 is their own share.
30+
/// @guide A border strip's outermost lights own a sliver at the edge, where letterboxes and overscan live. Raise **edgeDepth** and they reach further in, as Hyperion's 8% does; a stride keeps the reads the same.
31+
uint8_t edgeDepth = 0;
32+
/// How slowly a light eases to a new color, as a half-life; 0 is off.
33+
/// @guide Raise **smoothing** and a light eases toward its new color, settling a noisy source alike on every source and board. A cut is a real jump, so **snapAbove** lands a bigger move whole; at 0 every move eases.
34+
uint8_t smoothing = 0;
35+
/// A channel moving further than this jumps instead of easing.
36+
uint8_t snapAbove = 80;
2937

3038
/// Publish the placement and the sampling.
3139
void defineControls() override {
3240
static constexpr const char* kFits[] = {"fit", "fill", "stretch"};
3341
static constexpr const char* kSamplings[] = {"point", "area"};
3442
controls_.addSelect("fit", fit, kFits, kFitCount);
3543
controls_.addSelect("sampling", sampling, kSamplings, kSamplingCount);
44+
controls_.addControl("edgeDepth", edgeDepth, 0, 50);
45+
controls_.addControl("smoothing", smoothing, 0, 255);
46+
controls_.addControl("snapAbove", snapAbove, 0, 255);
47+
controls_.setHidden(controls_.count() - 1, smoothing == 0);
3648
}
3749
/// The placement changes the ranges and the sampling the picture drawn, so both redraw.
38-
bool affectsPrepare(const char* name) const override { return std::strcmp(name, "fit") == 0 || std::strcmp(name, "sampling") == 0; }
50+
bool affectsPrepare(const char* name) const override {
51+
// edgeDepth moves the ranges, and smoothing allocates or frees the accumulators.
52+
return std::strcmp(name, "fit") == 0 || std::strcmp(name, "sampling") == 0
53+
|| std::strcmp(name, "edgeDepth") == 0 || std::strcmp(name, "smoothing") == 0;
54+
}
3955

4056
/// Size the range tables to the grid; the first picture fills them.
4157
void prepare() override {
4258
cols_.resize(width());
4359
rows_.resize(height());
60+
// 8.8 per channel, and only while smoothing, since a step is often a fraction of a byte.
61+
const size_t cells = static_cast<size_t>(cols_.count()) * rows_.count();
62+
state_.resize(smoothing != 0 ? cells * 3u : 0);
63+
primed_ = false;
4464
picW_ = picH_ = 0; // stale, so the next tick refills and draws
4565
}
4666

4767
/// Sample a picture that is new, or redraw one the layer overwrote; with nothing to sample the layer is cleared once.
4868
void tick() MM_NONBLOCKING override {
69+
// The time since the last tick, which the smoothing's half-life is measured in.
70+
const uint32_t now = platform::millis();
71+
dtMs_ = ticked_ ? now - lastMs_ : 0;
72+
lastMs_ = now;
73+
ticked_ = true;
4974
const PictureService::Reader reader;
5075
if (!reader) return; // the source is swapping or filling its only picture: sample on the next tick
5176
const PictureFrame* f = &reader.frame();
@@ -69,8 +94,11 @@ class PictureEffect : public EffectBase {
6994
picH_ = f->height;
7095
redraw = true;
7196
}
72-
if (!redraw && f->generation == lastGen_ && layerGen == lastLayerGen_) return;
97+
// A settling smoother has not reached the picture yet, so it draws again on an unchanged one.
98+
if (!redraw && !settling_ && f->generation == lastGen_ && layerGen == lastLayerGen_) return;
99+
settling_ = false;
73100
gather(*f);
101+
primed_ = true;
74102
lastGen_ = f->generation;
75103
lastLayerGen_ = layerGen + 1; // the Layer bumps the generation after this tick, for our own write
76104
}
@@ -79,6 +107,12 @@ class PictureEffect : public EffectBase {
79107
/// One axis of a cell's source rectangle, [lo, hi) in picture pixels; empty outside a fitted picture.
80108
struct Range { uint16_t lo, hi; };
81109

110+
/// An edge cell's range at `deep` picture lines in: the first reaches inward from 0, the last back from the end.
111+
static void reachEdge(int32_t k, int32_t used, uint32_t pic, uint32_t deep, uint32_t& lo, uint32_t& hi) {
112+
if (k == 0) hi = deep;
113+
if (k == used - 1 && pic > deep) lo = pic - deep;
114+
}
115+
82116
/// Each cell's range along one axis: the grid's `n` cells over the picture's `picW` × `picH`, `columns` naming the axis.
83117
void fillRanges(Range* out, uint16_t n, uint16_t picW, uint16_t picH, bool columns) const {
84118
const uint32_t gw = cols_.count(), gh = rows_.count();
@@ -87,10 +121,13 @@ class PictureEffect : public EffectBase {
87121
const int32_t used = static_cast<int32_t>(columns ? usedW : usedH);
88122
const uint32_t pic = columns ? picW : picH;
89123
const int32_t offset = (static_cast<int32_t>(columns ? gw : gh) - used) / 2; // negative when cropped
124+
// What an edge cell reaches instead of its own share, deeper OR shallower. The control sets the depth rather than raising a floor under it, and 0 leaves every cell its share.
125+
const uint32_t deep = static_cast<uint32_t>(pic) * edgeDepth / 100u;
90126
for (uint32_t i = 0; i < n; i++) {
91127
const int32_t k = static_cast<int32_t>(i) - offset;
92128
if (k < 0 || k >= used) { out[i] = {0, 0}; continue; }
93129
uint32_t lo = static_cast<uint32_t>(k) * pic / static_cast<uint32_t>(used), hi = (static_cast<uint32_t>(k) + 1) * pic / static_cast<uint32_t>(used);
130+
if (deep) reachEdge(k, used, pic, deep, lo, hi);
94131
if (hi <= lo) hi = lo + 1; // a picture smaller than the grid: neighboring cells share a pixel
95132
out[i] = {static_cast<uint16_t>(lo), static_cast<uint16_t>(hi)};
96133
}
@@ -108,6 +145,7 @@ class PictureEffect : public EffectBase {
108145

109146
/// One pass over the grid, each cell from its rectangle.
110147
void gather(const PictureFrame& f) {
148+
const uint32_t keep = halfLifeKeep(dtMs_, halfLifeMs()); // the share of each gap this step leaves
111149
const draw::Canvas cv = canvas();
112150
const uint32_t gw = cols_.count(), gh = rows_.count();
113151
for (uint32_t y = 0; y < gh; y++) {
@@ -116,11 +154,45 @@ class PictureEffect : public EffectBase {
116154
const Range c = cols_[x];
117155
const size_t cell = static_cast<size_t>(y) * gw + x;
118156
if (r.hi == r.lo || c.hi == c.lo) { draw::pixel(cv, cell, RGB{0, 0, 0}); continue; }
119-
draw::pixel(cv, cell, sampling == kArea ? average(f, c, r) : at(f, (c.lo + c.hi - 1) / 2, (r.lo + r.hi - 1) / 2));
157+
const RGB want = sampling == kArea ? average(f, c, r) : at(f, (c.lo + c.hi - 1) / 2, (r.lo + r.hi - 1) / 2);
158+
draw::pixel(cv, cell, state_ ? eased(cell, want, keep) : want);
120159
}
121160
}
122161
}
123162

163+
/// One cell eased toward `want` by the step's `keep`, its 8.8 state keeping the fraction of a step a byte would lose.
164+
RGB eased(size_t cell, RGB want, uint32_t keep) {
165+
const uint8_t target[3] = {want.r, want.g, want.b};
166+
const int32_t snap = static_cast<int32_t>(snapAbove) << 8;
167+
uint8_t out[3];
168+
for (int ch = 0; ch < 3; ch++) {
169+
const size_t slot = cell * 3 + ch;
170+
const int32_t wanted = static_cast<int32_t>(target[ch]) << 8;
171+
const int32_t delta = wanted - static_cast<int32_t>(state_[slot]);
172+
// The first picture, and any move big enough to be a cut, land whole.
173+
const bool jump = !primed_ || (snapAbove && (delta > snap || delta < -snap));
174+
const int32_t now = jump ? wanted : easeToward(state_[slot], wanted, keep);
175+
state_[slot] = static_cast<uint16_t>(now);
176+
out[ch] = static_cast<uint8_t>(now >> 8);
177+
if (now != wanted) settling_ = true;
178+
}
179+
return RGB{out[0], out[1], out[2]};
180+
}
181+
182+
public:
183+
/// Exponential smoothing: `held` moves toward `wanted`, both 8.8, leaving `keep` (0..65536, from `halfLifeKeep`) of the gap. A step below one count still moves one, so a light arrives; no time passed moves nothing.
184+
static int32_t easeToward(int32_t held, int32_t wanted, uint32_t keep) MM_NONBLOCKING {
185+
const int32_t delta = wanted - held;
186+
if (delta == 0 || keep >= 65536u) return held;
187+
int32_t now = wanted - static_cast<int32_t>(static_cast<int64_t>(delta) * keep / 65536);
188+
if (now == held) now += delta > 0 ? 1 : -1;
189+
return now;
190+
}
191+
192+
private:
193+
/// The smoothing control as a half-life in milliseconds, squared so the short range gets the travel, as Trails' persistence is.
194+
uint32_t halfLifeMs() const { return 20u + static_cast<uint32_t>(smoothing) * smoothing / 16u; }
195+
124196
/// The pixel at (x, y).
125197
static RGB at(const PictureFrame& f, uint32_t x, uint32_t y) {
126198
const uint8_t* p = f.rgb + (static_cast<size_t>(y) * f.width + x) * 3;
@@ -152,6 +224,12 @@ class PictureEffect : public EffectBase {
152224
return static_cast<uint8_t>(root);
153225
}
154226

227+
ScratchBuffer<uint16_t> state_{*this}; ///< 8.8 per channel per cell, only while smoothing
228+
bool primed_ = false; ///< whether a first picture has landed whole
229+
bool settling_ = false; ///< whether the last gather left a cell short of its target
230+
uint32_t lastMs_ = 0; ///< when the last tick ran
231+
uint32_t dtMs_ = 0; ///< the milliseconds since it
232+
bool ticked_ = false; ///< whether a tick has run, so the first measures no time
155233
ScratchBuffer<Range> cols_{*this}; ///< each grid column's picture columns
156234
ScratchBuffer<Range> rows_{*this}; ///< each grid row's picture rows
157235
uint16_t picW_ = 0, picH_ = 0; ///< the picture size the tables were filled for

‎test/unit/light/unit_PictureEffect.cpp‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,92 @@ TEST_CASE("PictureEffect without a Picture service says so and shows black") {
135135
CHECK(std::string(r.fx.status()) == "no picture: add a Picture service under Services");
136136
CHECK(r.cell(0, 0)[0] == 0);
137137
}
138+
139+
// --- Border sampling ---------------------------------------------------------------------------
140+
// A border strip's outermost lights own a sliver at the edge, where a letterbox and the overscan junk live, so edgeDepth reaches further in. Smoothing eases a light toward its new color.
141+
142+
// The control sets the depth rather than raising a floor under it, so on a coarse grid an edge cell takes less than its own share.
143+
TEST_CASE("PictureEffect edgeDepth gives the outermost lights the depth they ask for") {
144+
// Four columns over 100 picture columns: each owns 25, so 10% is deliberately shallower.
145+
PictureRig deep(100, 100, 4, 4, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kArea);
146+
deep.fx.edgeDepth = 10;
147+
deep.fx.prepare();
148+
deep.layer.tick();
149+
150+
PictureRig share(100, 100, 4, 4, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kArea);
151+
share.layer.tick();
152+
153+
// The ramp rises left to right, so a shallower first column reads darker: the leftmost tenth, not quarter.
154+
CHECK(deep.cell(0, 3)[0] < share.cell(0, 3)[0]);
155+
// And the last column reads brighter, averaging the rightmost tenth rather than its quarter.
156+
CHECK(deep.cell(3, 3)[0] > share.cell(3, 3)[0]);
157+
// Interior columns are on no edge, so they are untouched.
158+
CHECK(deep.cell(1, 3)[0] == share.cell(1, 3)[0]);
159+
CHECK(deep.cell(2, 3)[0] == share.cell(2, 3)[0]);
160+
}
161+
162+
// 0 is the whole point of the default: it must leave the ranges exactly as they were.
163+
TEST_CASE("PictureEffect edgeDepth of 0 samples precisely what it did before") {
164+
PictureRig off(64, 64, 8, 8, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kArea);
165+
off.fx.edgeDepth = 0;
166+
off.fx.prepare();
167+
off.layer.tick();
168+
PictureRig plain(64, 64, 8, 8, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kArea);
169+
plain.layer.tick();
170+
for (size_t y = 0; y < 8; y++)
171+
for (size_t x = 0; x < 8; x++)
172+
for (int ch = 0; ch < 3; ch++) CHECK(off.cell(x, y)[ch] == plain.cell(x, y)[ch]);
173+
}
174+
175+
// Smoothing is a half-life in time, so after one half-life half the gap is closed, whatever the frame rate.
176+
TEST_CASE("PictureEffect smoothing closes half the gap in one half-life") {
177+
const int32_t from = 0, to = 255 << 8;
178+
const int32_t half = mm::PictureEffect::easeToward(from, to, mm::halfLifeKeep(500, 500));
179+
CHECK(half >= (to / 2) - 256);
180+
CHECK(half <= (to / 2) + 256);
181+
}
182+
183+
// Frame-rate independence: ten short steps land where one long step does, within the 8.8 rounding.
184+
TEST_CASE("PictureEffect smoothing settles at the same speed at any frame rate") {
185+
int32_t fast = 0;
186+
for (int i = 0; i < 10; i++) fast = mm::PictureEffect::easeToward(fast, 255 << 8, mm::halfLifeKeep(20, 500));
187+
const int32_t slow = mm::PictureEffect::easeToward(0, 255 << 8, mm::halfLifeKeep(200, 500));
188+
CHECK(fast >= slow - 512);
189+
CHECK(fast <= slow + 512);
190+
}
191+
192+
// The one 8-bit state would fail: a step is a fraction of a byte, so a light arrives only if the fractions are kept.
193+
TEST_CASE("PictureEffect smoothing arrives exactly, and no time passed moves nothing") {
194+
int32_t v = 255 << 8;
195+
for (int i = 0; i < 2000; i++) v = mm::PictureEffect::easeToward(v, 0, mm::halfLifeKeep(20, 4000));
196+
CHECK(v == 0);
197+
CHECK(mm::PictureEffect::easeToward(100, 5000, mm::halfLifeKeep(0, 500)) == 100);
198+
}
199+
200+
// A cut is a real jump, and easing through it reads as the lights lagging the picture.
201+
TEST_CASE("PictureEffect snapAbove lands a cut whole instead of easing into it") {
202+
PictureRig rig(8, 8, 8, 8, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kPoint);
203+
rig.fx.smoothing = 200;
204+
rig.fx.snapAbove = 40; // 255 to 0 is far beyond this
205+
rig.fx.prepare();
206+
rig.layer.tick();
207+
REQUIRE(rig.cell(0, 0)[0] == 255);
208+
209+
uint8_t* rgb = rig.pic.beginWrite();
210+
std::memset(rgb, 0, static_cast<size_t>(rig.pic.width) * rig.pic.height * 3);
211+
rig.pic.endWrite();
212+
rig.layer.tick();
213+
CHECK(rig.cell(0, 0)[0] == 0); // one tick, not four hundred
214+
}
215+
216+
// Smoothing off is the default, so it must cost nothing and follow the picture exactly.
217+
TEST_CASE("PictureEffect without smoothing follows the picture tick for tick") {
218+
PictureRig rig(8, 8, 8, 8, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kPoint);
219+
rig.layer.tick();
220+
REQUIRE(rig.cell(0, 0)[0] == 255);
221+
uint8_t* rgb = rig.pic.beginWrite();
222+
std::memset(rgb, 0, static_cast<size_t>(rig.pic.width) * rig.pic.height * 3);
223+
rig.pic.endWrite();
224+
rig.layer.tick();
225+
CHECK(rig.cell(0, 0)[0] == 0);
226+
}

0 commit comments

Comments
 (0)