Skip to content

Add render tool: synthetic Earth-limb imagery for EarthLim testing - #10

Open
j4lando wants to merge 7 commits into
mainfrom
feature/earth-render-tool
Open

Add render tool: synthetic Earth-limb imagery for EarthLim testing#10
j4lando wants to merge 7 commits into
mainfrom
feature/earth-render-tool

Conversation

@j4lando

@j4lando j4lando commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #9.

What this adds

A new render tool (found_tools.render, CLI entry point found_tools_render) that generates synthetic Earth-limb test imagery for EarthLim and similar attitude-determination tools, following the DIY pipeline proposed in #9:

  • geometry.py — Sun direction (ECI) and ECI→ECEF rotation for a given UTC datetime.
  • textures.py — picks the date-appropriate Blue Marble Next Generation texture (by month) and the generic cloud layer texture.
  • scene.py — packages Sun vector, camera intrinsics (reusing found_tools.utils.Camera), camera pose, and texture filenames into a JSON-serializable scene description.
  • main.py — the found_tools_render CLI: parses date/camera/position/attitude/texture-dir args, writes the scene JSON, and (with --render) calls blender_scene.render_scene in-process to build and render it.
  • blender_scene.py — the only module that imports bpy; builds the Blender scene (Earth + cloud sphere, Sun lamp along the computed vector, a sun-direction-aware atmosphere shell for limb glow/terminator softening, camera intrinsics/pose matched to the scene JSON) and renders it with Cycles.
  • src/found_tools/render/README.md — usage docs, including the texture-download caveat from Add a synthetic Earth-rendering tool for generating EarthLim test imagery #9 (no month-by-month cloud product exists, so the cloud layer is generic) and a "Choosing camera parameters" section on avoiding a too-narrow FOV / too-close camera.

bpy (Blender as a Python module) is a real project dependency (Python pinned to >=3.13,<3.14 for wheel availability), so --render works standalone with no separate Blender install required.

Design notes / tradeoffs

  • Ephemeris library: Add a synthetic Earth-rendering tool for generating EarthLim test imagery #9 suggested spiceypy or astropy. I used a closed-form, low-precision analytical Sun-position formula (Vallado, Fundamentals of Astrodynamics and Applications, Alg. 29) and the IAU 1982 GMST polynomial for ECI→ECEF instead of either library. The analytical formulas are accurate to ~0.01°, more than sufficient for lighting/texture selection in synthetic test imagery, and they're fully offline-testable, avoiding a SPICE kernel download or IERS data dependency in this repo's fail_under = 100 CI. If a project later needs higher precision, spiceypy/astropy can be swapped in behind the same function signatures.
  • blender_scene.py is excluded from ty checkbpy ships no type stubs, so ty can't resolve its dynamically-generated attributes (bpy.context, bpy.ops, etc.). It's still exercised by real tests (see below).

Render bug fixes

I actually ran bpy.ops.render.render end-to-end (not just the unit tests) against real Blue Marble imagery and visually inspected the output at each step, through two rounds of fixes:

Round 1 — render came out as a blurry, overexposed mess:

  1. Missing smooth shading (the main cause of the "blurry mess"). The Earth/cloud/atmosphere UV spheres were never smooth-shaded, so each of their ~256×128 quad faces rendered with one flat normal. Under smooth, low-contrast lighting (open ocean near the terminator) that regular lat/long face grid showed up as a persistent, screen-space-aligned checkerboard of brightness steps. I confirmed this wasn't denoising or adaptive sampling (reproduced identically with both off) or the atmosphere shell (still present with the shell removed) before finding the real cause. Fixed with bpy.ops.object.shade_smooth() on each sphere.
  2. Overexposure. add_atmosphere() used Blender's Sky Texture node as the World background. That node models a ground-level sky dome, so as a World background it lit the entire background in every direction (not just near Earth's limb) with its own bright, independent default sun unrelated to the scene's actual Sun lamp — blowing out roughly half of every render to solid white. Replaced with a black deep-space World background plus a Fresnel-driven emission shell around the Earth, so the glow is strongest at grazing viewing angles (the limb) and fades toward the disk's center.
  3. README example camera parameters were a bad example, not a code bug. --position 7000000 0 0 with a 50mm-equivalent lens put the camera at only ~622 km altitude with a ~5.5° field of view — a telephoto shot of a few-km ocean patch magnified to fill the frame. Swapped the example for parameters that produce a full Earth disk, and added a README section explaining how FOV, camera distance, and Earth's angular size interact.

Round 2 — the round-1 limb glow was a uniform white halo regardless of where the Sun was:

The Fresnel-only glow shell from round 1 glowed the same on the day and night side of the limb — physically wrong. add_atmosphere() now also takes the scene dict and masks/colors the glow by dot(surface normal, Sun direction) (the same Sun vector already driving the Sun lamp), on top of the existing view-angle Fresnel term:

  • Fresnel still shapes where on the sphere the shell is visible (the limb vs. the disk center).
  • A Sun-facing term (Geometry Normal · Sun direction, both in world/ECEF space) masks that down to the sunlit crescent via a Map Range that fades in just before the terminator and out on the night side, instead of a hard cutoff.
  • A ColorRamp keyed off the same dot product shifts the glow from warm orange right at the terminator (long atmospheric path, like a ground sunset) to blue further into daylight (shorter, bluer-scattering path).

Verified across three viewpoints: near-subsolar (uniform bright rim is correct there — the whole visible limb really is sunlit), perpendicular-to-sun (glow present on the sunlit limb, absent near the terminator), and a pulled-back full-disk view confirming the day/night asymmetry and the warm-to-blue color shift right where the terminator meets the limb.

Test plan

  • just check-all (lint, 100% coverage, tests, ty check) passes, including tests against the real bpy module: smooth-shading on all three spheres, atmosphere world background + shell material, the sun-direction node wiring (Geometry Normal → dot product with a normalized sun_vector_ecef → Map Range → emission strength/color), the warm/blue color ramp stops, and actual bpy.ops.render.render calls
  • Manually ran the full render pipeline against real downloaded Blue Marble imagery and visually inspected output at every step of both fix rounds (not just relying on unit tests/coverage)

j4lando and others added 5 commits July 28, 2026 16:55
Implements the DIY Earth-rendering pipeline from #9: analytical Sun
ephemeris and ECI->ECEF geometry (closed-form, no SPICE kernel/IERS
download needed, fully unit-tested), a scene builder that resolves
date-appropriate Blue Marble Next Generation + cloud textures and
camera intrinsics/pose, and a headless Blender script (blender_scene.py)
that renders the scene with a Sun lamp and Nishita atmosphere shader for
limb glow/terminator softening. bpy isn't installable for this project's
Python version, so blender_scene.py runs under Blender's own interpreter
and is invoked via subprocess from the found_tools_render CLI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root cause: the Earth/cloud/atmosphere UV spheres were never smooth-shaded,
so Blender rendered each of their ~256x128 quad faces with a single flat
normal. Under smooth, low-contrast lighting (open ocean near the
terminator) that regular lat/long face grid showed up as a persistent,
screen-space-aligned checkerboard of brightness steps -- confirmed by
reproducing it with denoising off and adaptive sampling off (unaffected)
and with the atmosphere shell removed entirely (still present), which
ruled out those as causes before finding the actual one. Fixed by calling
bpy.ops.object.shade_smooth() on each sphere after creation, with a test
asserting every polygon.use_smooth on Earth/CloudLayer/Atmosphere.

Also replaced add_atmosphere()'s use of Blender's Sky Texture node as the
World background: that node models a ground-level sky dome, so as a World
background it lit the *entire* background in every direction (not just
near Earth's limb) with its own bright, independent default sun unrelated
to this scene's Sun lamp -- this is what was blowing out roughly half of
every render to solid white. Replaced with a black deep-space World
background plus a Fresnel-driven emission shell around the Earth, so the
glow is strongest at grazing viewing angles (the limb) and fades toward
the disk's center, matching an actual from-space limb-glow/terminator
appearance.

Verified by actually running bpy.ops.render.render (not just unit tests)
against real Blue Marble imagery and visually inspecting the output before
and after each fix. The README's example camera parameters were also
replaced: the previous --position/--focal-length/--pixel-pitch combination
put the camera at ~622 km altitude with a 50mm-equivalent lens (~5.5 deg
FOV), a telephoto shot of a few-km patch of ocean magnified to fill the
frame, not a "blurry render bug" but a bad example command. Swapped for
values that produce a full Earth disk, and added a "Choosing camera
parameters" section explaining how field of view, distance, and Earth's
angular size interact so users don't hit the same thing.

Excluded blender_scene.py from `ty check` (pre-existing gap from the
earlier switch to bpy as a real dependency: bpy ships no type stubs, so ty
can't resolve any of its dynamically-generated attributes -- unrelated to
this fix, but restores `just check-all` to green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The limb-glow shell added in the previous fix used view-angle Fresnel
alone, so every point of the limb glowed equally regardless of where the
Sun actually was -- a uniform white ring even on the shell's night side,
not realistic atmospheric scattering.

add_atmosphere() now also takes the scene dict and masks/colors the glow
by dot(surface normal, Sun direction) -- the same Sun vector already used
for the Sun lamp:

- Fresnel (view angle) still shapes *where on the sphere* the shell is
  visible at all (the limb, not the disk center).
- A new sun-facing term (Geometry node Normal dot a Sun direction vector,
  both in world/ECEF space) masks that down to the sunlit crescent via a
  Map Range that fades in just before the terminator and out on the night
  side, instead of a hard cutoff.
- A ColorRamp keyed off the same dot product shifts the glow from warm
  orange right at the terminator (long atmospheric path, like a ground
  sunset) to blue further into daylight (shorter, bluer-scattering path).

Verified by rendering three viewpoints and visually inspecting each: (1)
camera near the subsolar direction, where a uniform bright rim is in fact
correct since the whole visible limb is sunlit; (2) camera roughly
perpendicular to the Sun vector, showing the glow present on the sunlit
limb and absent near the terminator; (3) a pulled-back full-disk view
confirming the day/night asymmetry and the warm-to-blue color shift right
where the terminator meets the limb.

Extended test_blender_scene.py: add_atmosphere is called with the scene
dict everywhere now, and new tests assert the sun-direction node is wired
from scene["sun_vector_ecef"] (normalized), the dot-product node consumes
Geometry Normal and that vector, emission strength/color are graph-driven
rather than constants, and the color ramp's stops match the intended
warm-terminator/blue-day gradient.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Earth mesh (and the concentric cloud/atmosphere shells) were built
as true spheres via primitive_uv_sphere_add. WGS84's ~21 km polar
flattening is small next to Earth's radius but matters for accurate
EarthLim limb-fitting, so all three are now built as WGS84 ellipsoids:
a sphere at the equatorial radius with its local Z axis (Earth's
rotation axis) scaled and baked down to the WGS84 polar radius.
Verified against the actual mesh vertex bounding radii in tests, since
the ~0.3% flattening isn't distinguishable by eye at normal framing.

Also splits add_atmosphere into add_space_background (always applied)
and add_atmosphere_glow (the limb-glow shell), and threads a new
--no-atmosphere CLI flag through main.py -> scene.py -> blender_scene.py
so the glow shell can be skipped independently of the Earth/background.
Verified by rendering both with and without the flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@j4lando

j4lando commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Pushed another round of fixes on top of the render-quality pass (blurry mess -> limb glow, directional glow):

Earth modeled as a WGS84 ellipsoid, not a sphere. The Earth mesh (and the concentric cloud/atmosphere shells) were built with bpy.ops.mesh.primitive_uv_sphere_add(radius=...) -- a true sphere. WGS84's ~21 km polar flattening (equatorial radius 6,378,137 m vs. polar radius 6,356,752.314245 m, ~1/298.257) is small relative to Earth's radius but matters for accurate EarthLim limb-fitting. All three meshes are now built via a new _add_wgs84_ellipsoid() helper: a sphere at the equatorial radius with its local Z axis (Earth's rotation axis, matching the ECI/ECEF convention already used in geometry.py) scaled down to the WGS84 polar radius and baked into the mesh via transform_apply, so the cloud and atmosphere shells stay concentric with, and share the flattening of, the Earth mesh.

Since ~0.3% flattening isn't distinguishable by eye at normal camera framing, I verified this mathematically rather than visually: new tests compute the actual mesh vertex bounding radii (equatorial vs. polar) for the Earth, cloud layer, and atmosphere shell and assert they match the WGS84 constants (equatorial 6,378,137 m, polar 6,356,752.314245 m, difference 21,384.685755 m) to within a small tolerance.

Added --no-atmosphere. Threaded a new flag through main.py -> scene.py (atmosphere_glow_enabled, defaults True) -> blender_scene.py. add_atmosphere is now split into add_space_background() (always applied -- the black deep-space World background) and add_atmosphere_glow() (the sun-facing limb-glow shell, now skippable). Rendered both ways to confirm the toggle actually works:

--no-atmosphere off (default) --no-atmosphere on
bright glowing limb ring visible hard Earth edge, no glow

Test/coverage status: 119 tests passing, 100% coverage maintained, ruff check/ruff format clean, ty check clean (no new errors).

j4lando and others added 2 commits July 30, 2026 13:53
CI's "Run linters" step was failing on PR #10: it installs
ruff@latest fresh each run (currently 0.16.1), which flags DTZ001
(naive datetime.datetime()), UP017 (datetime.UTC alias), C408 (dict()
call vs literal), and import sorting that an older locally-cached
`uvx ruff` (0.15.13) didn't catch. Applied ruff's safe fixes across
the render tool's src/tests, manually converted one dict() call to a
literal, and left `# noqa: DTZ001` on the two datetime() calls that
are deliberately naive (testing the "assume UTC" fallback path).

Added CLAUDE.md documenting just check-all, what ci.yml actually runs
(read from .github/workflows/, not guessed), and the ruff-version-drift
/ ty-not-in-CI mismatches that caused this failure, so the same class
of "green locally, red in CI" surprise doesn't happen again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Strips out the atmosphere/limb-glow feature entirely rather than
shipping it half-working behind a flag: removes add_atmosphere_glow(),
the ATMOSPHERE_RADIUS_SCALE constant, and the --no-atmosphere /
atmosphere_glow_enabled plumbing through main.py -> scene.py ->
blender_scene.py, plus its tests. render_scene() now just builds the
Earth+clouds (WGS84 ellipsoid), Sun lamp, black deep-space background,
and camera -- no glow shell.

Also adds .github/images/render_example.png, a representative render
from the current pipeline (Earth ellipsoid + clouds + correct sun
lighting/terminator, no atmosphere glow), carved out of the blanket
*.png gitignore rule as a checked-in doc asset, for embedding in PR #10
so reviewers can see actual output without pulling the branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@j4lando

j4lando commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Removed the atmosphere/limb-glow shell entirely for now (add_atmosphere_glow, --no-atmosphere, atmosphere_glow_enabled, and its tests) rather than shipping it half-working behind a flag -- it's deprioritized, not gone for good; picking it back up later can start from the git history on blender_scene.py. render_scene() now just builds the WGS84-ellipsoid Earth + clouds, the Sun lamp, the black deep-space background, and the camera.

Here's a representative render from the current pipeline (--date 2026-03-20T12:00:00, ~25,000 km altitude, no atmosphere glow):

Example render: WGS84 Earth with clouds and correct sun terminator, no atmosphere glow

just check-all (ruff, 100% coverage, ty) and CI are both green on e02b831.

@j4lando
j4lando requested a review from lsakunes July 30, 2026 21:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a synthetic Earth-rendering tool for generating EarthLim test imagery

1 participant