ShadowFinder: back to the predicted polarization factor, in the plane the background shows

The measured-modulation finder of the previous three commits is withdrawn. On a CCD sweep with a
holder arm along x it lost the arm: a small rise in the deep pixels' count promoted part of the
arm to shadow, which left the rest of the arm too few deep pixels to pass as transmitting
hardware, and the mask fell from 2.5 % to 0.4 % of the detector. On a 16-Mpixel stills detector
the per-band fit went wrong in the outer bands and masked 3.6 % of the detector instead of 0.03 %.
The shadow finder is rc178's again.

The stills defect it was meant to fix came from dividing by the factor of the wrong plane: the
background of that run is suppressed along the image's y axis, not lab x, and the division left
dim lobes over the corners on the arm search's thresholds, so that the third decimal of p moved
thousands of pixels. The degree cannot be told from the data, but the plane can. Before the
correction the mean projection is compared with the profile a polarized source gives each radial
band (rings relative to their upper decile, each sector the brighter of itself and the one opposite,
bands seen over three quarters of the azimuth); the stated degree is applied in the plane the
background follows when it follows one clearly (correlation > 0.5, at least 0.3 of a fully
polarized source's modulation), and as stated otherwise. Over 46 rotation sweeps and 11 stills runs
only that one run is turned, so every other mask is unchanged; there the mask is the 5248-pixel stop
at p = 0.99, 0.995 and 0.998.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
This commit is contained in:
leonarski_fandClaude Opus 5.5 committed 2026-10-23 06:41:10 +02:00
1 parent f6ef620afd
commit fc86fd4a10
7 files changed
+385 -376

No files matched your search

+50 -52
View File
@@ -331,13 +331,11 @@ TEST_CASE("ShadowFinder_TheRingsFollowTheCentreTheCallerNames", "[ShadowFinder]"
CHECK(at_measured[I((PATCH_X0 + PATCH_X1) / 2, (PATCH_Y0 + PATCH_Y1) / 2)] == 1);
// About the true centre the rings are flat and only the patch and its penumbra come back;
// about the file's they cut across the fall-off and more of the detector does. (Less than it
// used to: the part of the fall-off that is a second harmonic about the wrong centre is taken out
// with the background's azimuthal modulation.)
// about the file's they cut across the fall-off and a large part of the detector does.
const auto masked_at_file = std::count(at_file.begin(), at_file.end(), 1u);
const auto masked_at_measured = std::count(at_measured.begin(), at_measured.end(), 1u);
CHECK(masked_at_measured < 2 * PATCH_PX);
CHECK(masked_at_file > masked_at_measured);
CHECK(masked_at_file > 3 * PATCH_PX);
}
// A detector on a swung 2theta arm. The beam no longer lands on the geometry's centre (the point of
@@ -381,7 +379,8 @@ TEST_CASE("ShadowFinder_ATiltedDetectorIsNotAShadow", "[ShadowFinder]") {
// several times the dip this class looks for, so with the modulation left in, half of every outer
// ring reads as shadow with nothing in the beam. The scene here is exactly that and nothing else -
// a background carrying the Kahn factor of a horizontally polarized source, no hardware anywhere -
// and it must come back empty.
// and it must come back empty when the experiment states its polarization, and does not when it
// says nothing, which is what the correction is for.
TEST_CASE("ShadowFinder_APolarizedBackgroundIsNotAShadow", "[ShadowFinder]") {
// Short enough that this small detector reaches 2 theta = 70 degrees in its corner, where
// the source suppresses its own plane to a fifth. A real geometry gets there with a short
@@ -413,11 +412,7 @@ TEST_CASE("ShadowFinder_APolarizedBackgroundIsNotAShadow", "[ShadowFinder]") {
frame[I(x, y)] = static_cast<int32_t>(std::lround(LEVEL * factor / at_peak));
}
// The modulation is measured from the background, so what the experiment states about the source
// - the beam's own value, another degree, none at all, or the plane turned by 90 degrees - is not
// read and cannot turn the lobes into a shadow.
for (const std::optional<float> polarization : {std::optional<float>(POLARIZATION), std::optional<float>(0.9f),
std::optional<float>(), std::optional<float>(-POLARIZATION)}) {
auto mask_for = [&](const std::optional<float> &polarization) {
DiffractionExperiment x = TestExperiment();
x.DetectorDistance_mm(DISTANCE_MM).PolarizationFactor(polarization);
const PixelMask pixel_mask(x);
@@ -429,15 +424,19 @@ TEST_CASE("ShadowFinder_APolarizedBackgroundIsNotAShadow", "[ShadowFinder]") {
finder.AddImage(msg, buffer);
}
const auto mask = finder.GetMask();
CHECK(std::count_if(mask.begin(), mask.end(), [](uint32_t v) { return v != 0; }) == 0);
}
return std::count(mask.begin(), mask.end(), 1u);
};
CHECK(mask_for(POLARIZATION) == 0);
CHECK(mask_for(std::nullopt) > static_cast<long>(W) * H / 20);
}
// A beam stop with four arms on a polarized background at a short distance: the stop has to come back,
// two of its arms inside the background's dim lobes, the lobes themselves must not, and the mask must not move when the experiment states another
// degree of polarization or none. The degree cannot be told from the data, so a mask that followed
// it would follow a guess.
TEST_CASE("ShadowFinder_TheMaskDoesNotDependOnTheStatedPolarization", "[ShadowFinder]") {
// A beam stop with four arms on a background polarized in the plane along the image's y, at a short
// distance, in an experiment that states the usual horizontal plane. Divided by the factor of the
// stated plane the background's lobes read as shadow, and how much of them did followed the third
// decimal of the stated degree. The plane is taken from the background, so the stop and its arms come
// back, the lobes do not, and the mask is the same for any stated degree and either stated plane.
TEST_CASE("ShadowFinder_ThePolarizationPlaneIsTheOneTheBackgroundShows", "[ShadowFinder]") {
constexpr float DISTANCE_MM = 8.0f;
constexpr double TRUE_POLARIZATION = -0.998; // the plane along the image's y
constexpr int LEVEL = 24;
@@ -477,44 +476,43 @@ TEST_CASE("ShadowFinder_TheMaskDoesNotDependOnTheStatedPolarization", "[ShadowFi
CHECK(mask[I(C + 20, 3)] == 0); // but not the lobe itself
CHECK(mask[I(C - 20, H - 4)] == 0);
CHECK(mask_for(0.998f) == mask);
CHECK(mask_for(std::nullopt) == mask);
CHECK(mask_for(-0.998f) == mask);
}
// The modulation is measured from the sector medians of each band. A wide shadow on one side of the
// beam - a third of the ring dimmed to 70 %, too little to be left out as hardware - must not be
// fitted as part of it: the modulation is the same on opposite sides of the beam and the shadow is
// not. A band seen only as an arc is not fitted and takes its inner neighbour's harmonic.
TEST_CASE("ShadowFinder_AOneSidedShadowIsNotPartOfTheModulation", "[ShadowFinder]") {
// The plane is decided from the sector medians of each band: the cos 2phi harmonic has to follow the
// profile a polarized source gives it, with a one-sided shadow over a third of the ring left out by
// comparing opposite sectors. A background without modulation decides nothing.
TEST_CASE("ShadowFinder_PolarizationPlane", "[ShadowFinder]") {
using namespace shadow_finder;
constexpr double C2 = -0.3; // the background's own modulation: dim along x
const int n_bands = 3;
std::vector<double> medians(static_cast<size_t>(n_bands) * HARMONIC_SECTORS);
for (int band = 0; band < n_bands; band++)
for (int k = 0; k < HARMONIC_SECTORS; k++) {
const double phi = (k + 0.5) * 2.0 * std::numbers::pi / HARMONIC_SECTORS - std::numbers::pi;
double m = 1.0 + C2 * std::cos(2.0 * phi);
if (k >= 8 && k < 16)
m *= 0.7; // the shadow, a third of the ring around phi = 0
if (band == 2 && (k < 6 || k >= 12))
m = -1.0; // the outer band is only an arc
medians[static_cast<size_t>(band) * HARMONIC_SECTORS + k] = m;
}
std::vector<float> c, s;
AzimuthalShapeFit(medians, n_bands, c, s);
for (int band = 0; band < n_bands; band++) {
CHECK(c[band] == Catch::Approx(C2).margin(0.01));
CHECK(s[band] == Catch::Approx(0.0).margin(0.01));
}
constexpr int N_BANDS = 8;
std::vector<float> amplitude(N_BANDS);
for (int band = 0; band < N_BANDS; band++)
amplitude[band] = 0.05f * static_cast<float>(band + 1);
auto medians = [&](double sign, bool shadow) {
std::vector<double> m(static_cast<size_t>(N_BANDS) * HARMONIC_SECTORS);
for (int band = 0; band < N_BANDS; band++)
for (int k = 0; k < HARMONIC_SECTORS; k++) {
const double phi = (k + 0.5) * 2.0 * std::numbers::pi / HARMONIC_SECTORS - std::numbers::pi;
double v = 1.0 + sign * amplitude[band] * std::cos(2.0 * phi);
if (shadow && k >= 8 && k < 16)
v *= 0.7; // a third of the ring around phi = 0
m[static_cast<size_t>(band) * HARMONIC_SECTORS + k] = v;
}
return m;
};
CHECK(PolarizationPlane(medians(-1.0, false), amplitude) == 1.0f); // dim along x: horizontal
CHECK(PolarizationPlane(medians(+1.0, false), amplitude) == -1.0f); // dim along y: vertical
CHECK(PolarizationPlane(medians(+1.0, true), amplitude) == -1.0f); // the shadow along x does not hide it
CHECK(PolarizationPlane(medians(0.0, false), amplitude) == 0.0f); // no modulation: no decision
}
// An unpolarized background in an experiment that states 0.99. When the finder divided by the stated
// factor, every outer ring came out bright along x and dim along y - a smooth lobe below PENUMBRA_RATIO
// across the top and bottom of the detector with nothing in the beam. On its own the lobe is never
// deep, but a small absorbing speck inside it is, and the transmitting-hardware step joined the two
// into one piece "dim everywhere and deep in places" and masked the whole lobe. The modulation is
// measured from the background now, so there is no lobe, and the speck, far below a shadow's size,
// stays unmasked.
// The other side of that correction: a polarization factor that is not the beam's. Here the
// background is unpolarized and the experiment states 0.99, so after the division every outer ring is
// bright along x and dim along y - a smooth lobe below PENUMBRA_RATIO across the top and bottom of the
// detector with nothing in the beam. On its own the lobe is never deep, but a small absorbing speck
// inside it is, and the transmitting-hardware step joined the two into one piece "dim everywhere and
// deep in places" and masked the whole lobe. The step compares a pixel with its ring's own second
// harmonic, so the lobe is explained away and the speck, far below a shadow's size, stays unmasked.
TEST_CASE("ShadowFinder_AWrongPolarizationFactorIsNotAnArm", "[ShadowFinder]") {
constexpr float DISTANCE_MM = 6.0f;
constexpr int LEVEL = 24;
@@ -633,9 +631,9 @@ namespace {
}
// The mask is made on the GPU wherever the projection is there. On scenes where no pixel sits within a
// rounding of a threshold the two must agree to the pixel: every step but the ring geometry, the
// Poisson test's logarithm and the sectors' azimuth is exact on both, and these scenes are built so
// that none of the three decides anything at an edge.
// rounding of a threshold the two must agree to the pixel: every step but the polarization factor,
// the Poisson test's logarithm and the arm search's azimuth is exact on both, and these scenes are
// built so that none of the three decides anything at an edge.
TEST_CASE("ShadowFinder_DeviceMaskMatchesHost", "[ShadowFinder]") {
if (get_gpu_count() == 0)
SKIP("no GPU");